diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b2aa76450..4d8c71407 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -2,16 +2,19 @@ # [Choice] Go version: 1, 1.15, 1.14 ARG VARIANT="1" -FROM mcr.microsoft.com/vscode/devcontainers/go:${VARIANT} +FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT} # [Option] Install Node.js ARG INSTALL_NODE="true" ARG NODE_VERSION="lts/*" RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi -# Install additional OS packages +# [Optional] Uncomment this section to install additional OS packages. RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ - && apt-get -y install --no-install-recommends ffmpeg + && apt-get -y install --no-install-recommends libtag1-dev ffmpeg + +# [Optional] Uncomment the next line to use go get to install anything else you need +# RUN go get -x # [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 +# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index efe965428..bb56b4e22 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,10 +4,10 @@ "dockerfile": "Dockerfile", "args": { // Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14 - "VARIANT": "1.27", + "VARIANT": "1.20", // Options "INSTALL_NODE": "true", - "NODE_VERSION": "v24" + "NODE_VERSION": "v18" } }, "workspaceMount": "", @@ -18,46 +18,44 @@ "--volume=${localWorkspaceFolder}:/workspaces/${localWorkspaceFolderBasename}:Z" ], // Set *default* container specific settings.json values on container create. - "customizations": { - "vscode": { - "settings": { - "terminal.integrated.shell.linux": "/bin/bash", - "go.useGoProxyToCheckForToolUpdates": false, - "go.useLanguageServer": true, - "go.gopath": "/go", - "go.goroot": "/usr/local/go", - "go.toolsGopath": "/go/bin", - "go.formatTool": "goimports", - "go.lintOnSave": "package", - "go.lintTool": "golangci-lint", - "editor.formatOnSave": true, - "[javascript]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[json]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" - }, - "[jsonc]": { - "editor.defaultFormatter": "vscode.json-language-features" - } - }, - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "golang.Go", - "esbenp.prettier-vscode", - "tamasfe.even-better-toml" - ] + "settings": { + "terminal.integrated.shell.linux": "/bin/bash", + "go.useGoProxyToCheckForToolUpdates": false, + "go.useLanguageServer": true, + "go.gopath": "/go", + "go.goroot": "/usr/local/go", + "go.toolsGopath": "/go/bin", + "go.formatTool": "goimports", + "go.lintOnSave": "package", + "go.lintTool": "golangci-lint", + "editor.formatOnSave": true, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "vscode.json-language-features" } }, + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "golang.Go", + "esbenp.prettier-vscode", + "tamasfe.even-better-toml" + ], // Use 'forwardPorts' to make a list of ports inside the container available locally. "forwardPorts": [ 4533, 4633 ], + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "make setup-dev", // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. "remoteUser": "vscode", "remoteEnv": { "ND_MUSICFOLDER": "./music", "ND_DATAFOLDER": "./data" } -} \ No newline at end of file +} diff --git a/.dockerignore b/.dockerignore index eb012c6e2..8b4f1dbc5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,19 +1,10 @@ .DS_Store ui/node_modules -ui/build -!ui/build/.gitkeep Dockerfile docker-compose*.yml data *.db testDB navidrome +navidrome.db navidrome.toml -tmp -!tmp/taglib -dist -binaries -cache -music -music.old -!Dockerfile \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e5ed36e70..534cfbd11 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,10 +1,10 @@ # These are supported funding model platforms -ko_fi: deluan github: deluan -open_collective: navidrome -liberapay: deluan patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: deluan +liberapay: deluan tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry issuehunt: # Replace with a single IssueHunt username diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml deleted file mode 100644 index 6cb54dbdb..000000000 --- a/.github/actions/prepare-docker/action.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: 'Prepare Docker Buildx environment' -description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH' -inputs: - github_token: - description: 'GitHub token' - required: true - default: '' - hub_repository: - description: 'Docker Hub repository to push images to' - required: false - default: '' - hub_username: - description: 'Docker Hub username' - required: false - default: '' - hub_password: - description: 'Docker Hub password' - required: false - default: '' -outputs: - tags: - description: 'Docker image tags' - value: ${{ steps.meta.outputs.tags }} - labels: - description: 'Docker image labels' - value: ${{ steps.meta.outputs.labels }} - annotations: - description: 'Docker image annotations' - value: ${{ steps.meta.outputs.annotations }} - version: - description: 'Docker image version' - value: ${{ steps.meta.outputs.version }} - hub_repository: - description: 'Docker Hub repository' - value: ${{ env.DOCKER_HUB_REPO }} - hub_enabled: - description: 'Is Docker Hub enabled' - value: ${{ env.DOCKER_HUB_ENABLED }} - -runs: - using: 'composite' - steps: - - name: Check Docker Hub configuration - shell: bash - run: | - if [ -z "${{inputs.hub_repository}}" ]; then - echo "DOCKER_HUB_REPO=none" >> $GITHUB_ENV - echo "DOCKER_HUB_ENABLED=false" >> $GITHUB_ENV - else - echo "DOCKER_HUB_REPO=${{inputs.hub_repository}}" >> $GITHUB_ENV - echo "DOCKER_HUB_ENABLED=true" >> $GITHUB_ENV - fi - - - name: Login to Docker Hub - if: inputs.hub_username != '' && inputs.hub_password != '' - uses: docker/login-action@v4 - with: - username: ${{ inputs.hub_username }} - password: ${{ inputs.hub_password }} - - - name: Login to GitHub Container Registry - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ inputs.github_token }} - - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v4 - with: - # Runner IPs are shared, so anonymous base image pulls get rate-limited. - buildkitd-config-inline: | - [registry."docker.io"] - mirrors = ["mirror.gcr.io"] - - - name: Extract metadata for Docker image - id: meta - uses: docker/metadata-action@v6 - with: - github-token: ${{ inputs.github_token }} - labels: | - maintainer=deluan@navidrome.org - images: | - name=${{env.DOCKER_HUB_REPO}},enable=${{env.DOCKER_HUB_ENABLED}} - name=ghcr.io/${{ github.repository }} - tags: | - type=ref,event=pr - type=semver,pattern={{version}} - type=raw,value=develop,enable={{is_default_branch}} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 327ab0dfc..f453009e7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,13 +10,3 @@ updates: schedule: interval: weekly open-pull-requests-limit: 10 -- package-ecosystem: docker - directory: "/" - schedule: - interval: weekly - open-pull-requests-limit: 10 -- package-ecosystem: github-actions - directory: "/.github/workflows" - schedule: - interval: weekly - open-pull-requests-limit: 10 \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 10431e909..000000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,38 +0,0 @@ -### Description - - -### Related Issues - - -### Type of Change -- [ ] Bug fix -- [ ] New feature -- [ ] Documentation update -- [ ] Refactor -- [ ] Other (please describe): - -### Checklist -Please review and check all that apply: - -- [ ] My code follows the project’s coding style -- [ ] I have tested the changes locally -- [ ] I have added or updated documentation as needed -- [ ] I have added tests that prove my fix/feature works (or explain why not) -- [ ] All existing and new tests pass - -### How to Test - - -### Screenshots / Demos (if applicable) - - -### Additional Notes - - - \ No newline at end of file diff --git a/.github/workflows/coverage-on-pr.yml b/.github/workflows/coverage-on-pr.yml deleted file mode 100644 index 03260cacc..000000000 --- a/.github/workflows/coverage-on-pr.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Report coverage on PR -on: - workflow_run: - workflows: ['Pipeline: Test, Lint, Build'] - types: [completed] -jobs: - comment: - name: Comment coverage report - if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-latest - permissions: - contents: read - actions: read - pull-requests: write - env: - COVERAGE_COMMENT: 'true' - steps: - # Only the config, from the base branch: this job holds a write token, so - # it must never check out the fork. - - name: Check out the octocov config - uses: actions/checkout@v7 - with: - sparse-checkout: .octocov.yml - sparse-checkout-cone-mode: false - persist-credentials: false - - # Into a subdirectory. A pull_request run executes the fork's own copy of - # pipeline.yml, so every file in here is attacker-controlled. - - uses: actions/download-artifact@v8 - with: - name: octocov-pr - path: untrusted - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - - - name: Verify the artifact and take the coverage profile - id: pr - env: - GH_TOKEN: ${{ github.token }} - HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - run: | - number=$(head -c 20 untrusted/pr_number | tr -d '[:space:]') - case "$number" in ''|*[!0-9]*) - echo "::error::artifact pr_number is not a number"; exit 1;; - esac - sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .head.sha) - if [ "$sha" != "$HEAD_SHA" ]; then - echo "::error::artifact claims PR #$number, but its head $sha is not $HEAD_SHA"; exit 1 - fi - cp untrusted/coverage.out coverage.out - echo "number=$number" >> "$GITHUB_OUTPUT" - - - uses: k1LoW/octocov-action@v1 - env: - # A workflow_run job looks like a push to the default branch. Point - # octocov back at the pull request and at the run that produced it. - GITHUB_PULL_REQUEST_NUMBER: ${{ steps.pr.outputs.number }} - OCTOCOV_GITHUB_REF: refs/pull/${{ steps.pr.outputs.number }}/merge - OCTOCOV_GITHUB_SHA: ${{ github.event.workflow_run.head_sha }} - OCTOCOV_GITHUB_RUN_ID: ${{ github.event.workflow_run.id }} diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 80ec18e90..38b7b8a86 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@v9 + - uses: actions/github-script@v3 with: # This snippet is public-domain, taken from # https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml @@ -19,7 +19,8 @@ jobs: const pull_user_id = ${{github.event.sender.id}}; const issue_number = await (async () => { - for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) { + const pulls = await github.pulls.list({owner, repo}); + for await (const {data} of github.paginate.iterator(pulls)) { for (const pull of data) { if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) { return pull.number; @@ -33,24 +34,21 @@ jobs: return core.error(`No matching pull request found`); } - const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id}); - const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-')); - if (!downloadable.length) { + const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id}); + if (!artifacts.length) { return core.error(`No artifacts found`); } - const header = `Download the artifacts for this pull request:`; - let body = `${header}\n`; - for (const art of downloadable) { + let body = `Download the artifacts for this pull request:\n`; + for (const art of artifacts) { body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`; } - const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number}); - // Match on the body too: octocov also comments as github-actions[bot]. - const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header)); + const {data: comments} = await github.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.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); + await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); } else { core.info(`Creating a comment`); - await github.rest.issues.createComment({repo, owner, issue_number, body}); + await github.issues.createComment({repo, owner, issue_number, body}); } diff --git a/.github/workflows/pipeline.dockerfile b/.github/workflows/pipeline.dockerfile new file mode 100644 index 000000000..0e2a1a24b --- /dev/null +++ b/.github/workflows/pipeline.dockerfile @@ -0,0 +1,38 @@ +##################################################### +### Copy platform specific binary +FROM bash as copy-binary +ARG TARGETPLATFORM + +RUN echo "Target Platform = ${TARGETPLATFORM}" + +COPY dist . +RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then cp navidrome_linux_amd64_linux_amd64_v1/navidrome /navidrome; fi +RUN if [ "$TARGETPLATFORM" = "linux/386" ]; then cp navidrome_linux_386_linux_386/navidrome /navidrome; fi +RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then cp navidrome_linux_arm64_linux_arm64/navidrome /navidrome; fi +RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then cp navidrome_linux_arm_linux_arm_6/navidrome /navidrome; fi +RUN if [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then cp navidrome_linux_arm_linux_arm_7/navidrome /navidrome; fi +RUN chmod +x /navidrome + + +##################################################### +### Build Final Image +FROM alpine as release +LABEL maintainer="deluan@navidrome.org" + +# Install ffmpeg and output build config +RUN apk add --no-cache ffmpeg +RUN ffmpeg -buildconf + +COPY --from=copy-binary /navidrome /app/ + +VOLUME ["/data", "/music"] +ENV ND_MUSICFOLDER /music +ENV ND_DATAFOLDER /data +ENV ND_PORT 4533 +ENV GODEBUG "asyncpreemptoff=1" + +EXPOSE ${ND_PORT} +HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1 +WORKDIR /app + +ENTRYPOINT ["/app/navidrome"] diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index c1ba8714c..fd3c33865 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -1,4 +1,4 @@ -name: "Pipeline: Test, Lint, Build" +name: 'Pipeline: Test, Lint, Build' on: push: branches: @@ -9,330 +9,103 @@ on: branches: - master -concurrency: - group: ${{ startsWith(github.ref, 'refs/tags/v') && 'tag' || 'branch' }}-${{ github.ref }} - cancel-in-progress: true - -env: - IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }} - jobs: - git-version: - name: Get version info - runs-on: ubuntu-latest - outputs: - git_tag: ${{ steps.git-version.outputs.GIT_TAG }} - git_sha: ${{ steps.git-version.outputs.GIT_SHA }} - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - fetch-tags: true - - - name: Show git version info - run: | - echo "git describe (dirty): $(git describe --dirty --always --tags)" - echo "git describe --tags --abbrev=0: $(git describe --tags --abbrev=0)" - echo "git tag: $(git tag --sort=-committerdate | head -n 1)" - echo "github_ref: $GITHUB_REF" - echo "github_head_sha: ${{ github.event.pull_request.head.sha }}" - git tag -l - - name: Determine git current SHA and latest tag - id: git-version - run: | - GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true) - if [ -n "$GIT_TAG" ]; then - if [[ "$GITHUB_REF" != refs/tags/* ]]; then - GIT_TAG=${GIT_TAG}-SNAPSHOT - fi - echo "GIT_TAG=$GIT_TAG" >> $GITHUB_OUTPUT - fi - GIT_SHA=$(git rev-parse --short HEAD) - PR_NUM=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH") - if [[ $PR_NUM != "null" ]]; then - GIT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-8) - GIT_SHA="pr-${PR_NUM}/${GIT_SHA}" - fi - echo "GIT_SHA=$GIT_SHA" >> $GITHUB_OUTPUT - - echo "GIT_TAG=$GIT_TAG" - echo "GIT_SHA=$GIT_SHA" - go-lint: name: Lint Go code runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - name: Install taglib + run: sudo apt-get install libtag1-dev - - uses: actions/setup-go@v6 + - name: Set up Go 1.20 + uses: actions/setup-go@v3 with: - go-version-file: go.mod + go-version: 1.20.x - # Keep CI on the same version `make lint` installs, so a clean local run - # cannot turn red in CI just because a new golangci-lint was released. - - name: Resolve golangci-lint version - id: golangci-version - run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT" + - uses: actions/checkout@v3 - name: golangci-lint - uses: golangci/golangci-lint-action@v9 + uses: golangci/golangci-lint-action@v3 with: - version: ${{ steps.golangci-version.outputs.version }} - problem-matchers: true + version: latest + github-token: ${{ secrets.GITHUB_TOKEN }} args: --timeout 2m - - name: Run go goimports - run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$'` + - name: Install goimports + run: go install golang.org/x/tools/cmd/goimports + + - run: goimports -w `find . -name '*.go' | grep -v '_gen.go$'` - run: go mod tidy - name: Verify no changes from goimports and go mod tidy run: | git status --porcelain if [ -n "$(git status --porcelain)" ]; then - echo 'To fix this check, run "make format" and commit the changes' + echo 'To fix this check, run "goimports -w $(find . -name '*.go' | grep -v '_gen.go$') && go mod tidy"' exit 1 fi - - name: Resolve OpenAPI tool versions - id: api-tools - run: echo "key=$(grep -E '^(VACUUM|OAPI_CODEGEN|OASDIFF)_VERSION' Makefile | tr -d ' \n')" >> "$GITHUB_OUTPUT" - - - name: Cache OpenAPI tools - uses: actions/cache@v6 + - uses: actions/setup-node@v3 with: - path: bin - key: api-tools-${{ runner.os }}-${{ steps.api-tools.outputs.key }} + node-version: 18 + cache: 'npm' + cache-dependency-path: '**/package-lock.json' - - name: Lint OpenAPI spec - run: make api-lint + - name: Build and Lint OpenAPI spec + run: make lintapi gen - - name: Run go generate - run: | - make api-gen - go generate ./... - - name: Verify no changes from go generate + - name: Verify no changes run: | git status --porcelain if [ -n "$(git status --porcelain)" ]; then - echo 'Generated code is out of date. Run "make gen" and commit the changes' + echo 'Changes to OpenAPI spec caused changes to the code. Please review and commit the changes.' exit 1 fi - - name: Check for breaking OpenAPI changes - if: github.event_name == 'pull_request' - run: | - git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }} - make api-diff API_DIFF_BASE=${{ github.event.pull_request.base.sha }} - - validate-migrations: - name: Validate DB migrations - runs-on: ubuntu-latest - # PR-only gate is at step level: a job-level skip would propagate through - # the needs chain (actions/runner#491) and skip all release jobs on tag pushes. - steps: - - uses: actions/checkout@v7 - if: github.event_name == 'pull_request' + - uses: actions/upload-artifact@v3 with: - fetch-depth: 0 - # Refresh the base branch so the check compares against its CURRENT tip, - # not the (possibly stale) commit the PR was opened against. - - name: Fetch latest base branch - if: github.event_name == 'pull_request' - run: git fetch --no-tags origin "+refs/heads/${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}" - - name: Validate migration ordering and naming - if: github.event_name == 'pull_request' - env: - BASE_REF: origin/${{ github.event.pull_request.base.ref }} - run: ./.github/workflows/validate-migrations.sh + name: openapi.yaml + path: api/openapi.yaml go: - name: Test Go code + name: Test with Go ${{ matrix.go_version }} runs-on: ubuntu-latest + strategy: + matrix: + go_version: [1.20.x,1.19.x] steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v7 + - name: Install taglib + run: sudo apt-get install libtag1-dev - - uses: actions/setup-go@v6 + - name: Check out code into the Go module directory + uses: actions/checkout@v3 + + - name: Set up Go ${{ matrix.go_version }} + uses: actions/setup-go@v3 with: - go-version-file: go.mod + go-version: ${{ matrix.go_version }} + cache: true - name: Download dependencies - run: go mod download - - # Name must stay unique across the workflow: octocov matches step names - # by name across every job, and waits for each match to finish. - - name: Test with coverage - run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$') - - - name: Test ndpgen - run: | - cd plugins/cmd/ndpgen - go test -shuffle=on -v - go build -o ndpgen . - ./ndpgen --help - - - name: Upload coverage profile - uses: actions/upload-artifact@v7 - with: - name: octocov-go - path: coverage.out - if-no-files-found: error - - go-plugins: - name: Test Go plugins - runs-on: ubuntu-latest - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v7 - - - uses: actions/setup-go@v6 - id: setup-go - with: - go-version-file: go.mod - - # Without this, the suite recompiles every test plugin WASM module, - # which dominates its runtime under -race. - - name: Cache the WASM compilation cache - uses: actions/cache@v6 - with: - path: plugins/testdata/.wazero-cache - key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }} - restore-keys: wazero-${{ runner.os }}- - - - name: Test plugins - run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 --cover --covermode=atomic --coverprofile=coverage.out --output-dir=. ./plugins/ - - - name: Upload coverage profile - uses: actions/upload-artifact@v7 - with: - name: octocov-plugins - path: coverage.out - if-no-files-found: error - - coverage: - name: Report coverage - runs-on: ubuntu-latest - needs: [go, go-plugins] - permissions: - contents: read - actions: write - env: - COVERAGE_COMMENT: 'false' - steps: - - uses: actions/checkout@v7 - - - uses: actions/download-artifact@v8 - with: - pattern: octocov-* - - # Merge here rather than letting octocov do it: octocov reports statement - # coverage for a single profile, but switches to line counting for several. - - name: Merge coverage profiles - run: | - echo "mode: atomic" > coverage.out - awk 'FNR==1 && /^mode:/ {next} {k=$1" "$2; c[k]+=$3} END {for (k in c) print k, c[k]}' \ - octocov-*/coverage.out | sort >> coverage.out - - - uses: k1LoW/octocov-action@v1 - - - name: Save the PR number for the comment workflow - if: github.event_name == 'pull_request' - run: echo "${{ github.event.pull_request.number }}" > pr_number - - - name: Upload the merged profile for the comment workflow - if: github.event_name == 'pull_request' - uses: actions/upload-artifact@v7 - with: - name: octocov-pr - path: | - coverage.out - pr_number - if-no-files-found: error - - go-windows: - name: Test Go code (Windows) - runs-on: windows-2022 - env: - FFMPEG_VERSION: "7.1" - FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds - steps: - - uses: actions/checkout@v7 - - - 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@v6 - 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 + if: steps.cache-go.outputs.cache-hit != 'true' + continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}} 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: bash - run: | - cd plugins/cmd/ndpgen - go test -shuffle=on -v - go build -o ndpgen.exe . - ./ndpgen.exe --help + continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}} + run: go test -shuffle=on -race -cover ./... -v js: - name: Test JS code + name: Build JS bundle runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: - node-version: 24 - cache: "npm" - cache-dependency-path: "**/package-lock.json" + node-version: 18 + cache: 'npm' + cache-dependency-path: '**/package-lock.json' - name: npm install dependencies run: | @@ -354,340 +127,119 @@ jobs: cd ui npm run build - i18n-lint: - name: Lint i18n files + - uses: actions/upload-artifact@v3 + with: + name: js-bundle + path: ui/build + + binaries: + name: Build binaries + needs: [js, go, go-lint] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - run: | - set -e - for file in resources/i18n/*.json; do - echo "Validating $file" - if ! jq empty "$file" 2>error.log; then - error_message=$(cat error.log) - line_number=$(echo "$error_message" | grep -oP 'line \K[0-9]+') - echo "::error file=$file,line=$line_number::$error_message" - exit 1 - fi - done - - run: ./.github/workflows/validate-translations.sh -v - - - check-push-enabled: - name: Check Docker configuration - runs-on: ubuntu-latest - outputs: - is_enabled: ${{ steps.check.outputs.is_enabled }} - steps: - - name: Check if Docker push is configured - id: check - run: echo "is_enabled=${{ secrets.DOCKER_HUB_USERNAME != '' }}" >> $GITHUB_OUTPUT - - build: - name: Build - needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] - strategy: - matrix: - platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] - runs-on: ubuntu-latest - env: - IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }} - IS_ARMV5: ${{ matrix.platform == 'linux/arm/v5' && 'true' || 'false' }} - IS_DOCKER_PUSH_CONFIGURED: ${{ needs.check-push-enabled.outputs.is_enabled == 'true' }} - DOCKER_BUILD_SUMMARY: false - GIT_SHA: ${{ needs.git-version.outputs.git_sha }} - GIT_TAG: ${{ needs.git-version.outputs.git_tag }} - steps: - - name: Sanitize platform name - id: set-platform - run: | - PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_') - echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV - - - uses: actions/checkout@v7 - - - name: Prepare Docker Buildx - uses: ./.github/actions/prepare-docker - id: docker - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - hub_repository: ${{ vars.DOCKER_HUB_REPO }} - hub_username: ${{ secrets.DOCKER_HUB_USERNAME }} - hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }} - - - name: Build Binaries - uses: docker/build-push-action@v7 - with: - context: . - file: Dockerfile - platforms: ${{ matrix.platform }} - outputs: | - type=local,dest=./output/${{ env.PLATFORM }} - target: binary - build-args: | - GIT_SHA=${{ env.GIT_SHA }} - GIT_TAG=${{ env.GIT_TAG }} - - - name: Set up QEMU for smoke test - if: env.IS_LINUX == 'true' - uses: docker/setup-qemu-action@v4 - - # The binary is static, so binfmt+qemu runs it directly on the runner. - # Catches startup crashes in cross-compiled binaries before they ship, - # e.g. the broken ifunc relocations on 32-bit arm from issue #5738. - - name: Smoke-test binary - if: env.IS_LINUX == 'true' - run: | - BIN=./output/${{ env.PLATFORM }}/navidrome - chmod +x "$BIN" - "$BIN" --help >/dev/null - echo "OK: ${{ matrix.platform }} binary starts" - - - name: Upload Binaries - uses: actions/upload-artifact@v7 - with: - name: navidrome-${{ env.PLATFORM }} - path: ./output - retention-days: 7 - - - name: Build and push image by digest - id: push-image - if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false' - uses: docker/build-push-action@v7 - with: - context: . - file: Dockerfile - platforms: ${{ matrix.platform }} - labels: ${{ steps.docker.outputs.labels }} - build-args: | - GIT_SHA=${{ env.GIT_SHA }} - GIT_TAG=${{ env.GIT_TAG }} - 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 - - - name: Export digest - if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false' - run: | - mkdir -p /tmp/digests - digest="${{ steps.push-image.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - - name: Upload digest - uses: actions/upload-artifact@v7 - if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false' - with: - name: digests-${{ env.PLATFORM }} - path: /tmp/digests/* - if-no-files-found: error - retention-days: 1 - - push-manifest-ghcr: - name: Push to GHCR - permissions: - contents: read - packages: write - runs-on: ubuntu-latest - needs: [build, check-push-enabled] - if: needs.check-push-enabled.outputs.is_enabled == 'true' - env: - REGISTRY_IMAGE: ghcr.io/${{ github.repository }} - steps: - - uses: actions/checkout@v7 - - - name: Download digests - uses: actions/download-artifact@v8 - with: - path: /tmp/digests - pattern: digests-* - merge-multiple: true - - - name: Prepare Docker Buildx - uses: ./.github/actions/prepare-docker - id: docker - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - - - name: Create manifest list and push to ghcr.io - working-directory: /tmp/digests - run: | - docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io"))) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ - $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) - - - name: Inspect image in ghcr.io - run: | - docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }} - - push-manifest-dockerhub: - name: Push to Docker Hub - runs-on: ubuntu-latest - permissions: - contents: read - needs: [build, check-push-enabled] - if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != '' - continue-on-error: true - steps: - - uses: actions/checkout@v7 - - - name: Download digests - uses: actions/download-artifact@v8 - with: - path: /tmp/digests - pattern: digests-* - merge-multiple: true - - - name: Prepare Docker Buildx - uses: ./.github/actions/prepare-docker - id: docker - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - hub_repository: ${{ vars.DOCKER_HUB_REPO }} - hub_username: ${{ secrets.DOCKER_HUB_USERNAME }} - hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }} - - - name: Create manifest list and push to Docker Hub - uses: nick-fields/retry@v4 - with: - timeout_minutes: 5 - max_attempts: 3 - retry_wait_seconds: 30 - command: | - cd /tmp/digests - docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io") | not)) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ - $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) - - - name: Inspect image in Docker Hub - run: | - docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }} - - cleanup-digests: - name: Cleanup digest artifacts - runs-on: ubuntu-latest - needs: [push-manifest-ghcr, push-manifest-dockerhub] - if: always() && needs.push-manifest-ghcr.result == 'success' - steps: - - name: Delete unnecessary digest artifacts - env: - GH_TOKEN: ${{ github.token }} - run: | - for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do - gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact - done - - msi: - name: Build Windows installers - needs: [build, git-version] - runs-on: ubuntu-24.04 - - steps: - - uses: actions/checkout@v7 - - - uses: actions/download-artifact@v8 - with: - path: ./binaries - pattern: navidrome-windows* - merge-multiple: true - - - name: Install Wix - run: sudo apt-get install -y wixl jq - - - name: Build MSI - env: - GIT_TAG: ${{ needs.git-version.outputs.git_tag }} - run: | - rm -rf binaries/msi - sudo GIT_TAG=$GIT_TAG release/wix/build_msi.sh ${GITHUB_WORKSPACE} 386 - sudo GIT_TAG=$GIT_TAG release/wix/build_msi.sh ${GITHUB_WORKSPACE} amd64 - du -h binaries/msi/*.msi - - - name: Upload MSI files - uses: actions/upload-artifact@v7 - with: - name: navidrome-windows-installers - path: binaries/msi/*.msi - retention-days: 7 - - release: - name: Package/Release - needs: [build, msi] - runs-on: ubuntu-latest - outputs: - package_list: ${{ steps.set-package-list.outputs.package_list }} - steps: - - uses: actions/checkout@v7 + - name: Checkout Code + uses: actions/checkout@v3 with: fetch-depth: 0 - fetch-tags: true - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@v3 with: - path: ./binaries - pattern: navidrome-* - merge-multiple: true + name: js-bundle + path: ui/build - - run: ls -lR ./binaries - - - name: Set RELEASE_FLAGS for snapshot releases - if: env.IS_RELEASE == 'false' - run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV - - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v7 - with: - version: '2.16.0' - args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}" + - name: Config /github/workspace folder as trusted + uses: docker://deluan/ci-goreleaser:1.20.3-1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Remove build artifacts - run: | - ls -l ./dist - rm ./dist/*.tar.gz ./dist/*.zip - - - name: Upload all-packages artifact - uses: actions/upload-artifact@v7 with: - name: packages - path: dist/navidrome_0* + args: /bin/bash -c "git config --global --add safe.directory /github/workspace; git describe --dirty --always --tags" - - id: set-package-list - name: Export list of generated packages - run: | - cd dist - set +x - ITEMS=$(ls navidrome_0* | sed 's/^navidrome_0[^_]*_linux_//' | jq -R -s -c 'split("\n")[:-1]') - echo $ITEMS - echo "package_list=${ITEMS}" >> $GITHUB_OUTPUT + - name: Run GoReleaser - SNAPSHOT + if: startsWith(github.ref, 'refs/tags/') != true + uses: docker://deluan/ci-goreleaser:1.20.3-1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + args: goreleaser release --rm-dist --skip-publish --snapshot - upload-packages: - name: Upload Linux PKG + - name: Run GoReleaser - RELEASE + if: startsWith(github.ref, 'refs/tags/') + uses: docker://deluan/ci-goreleaser:1.20.3-1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + args: goreleaser release --rm-dist + + - uses: actions/upload-artifact@v3 + with: + name: binaries + path: | + dist + !dist/*.tar.gz + !dist/*.zip + + docker: + name: Build and publish Docker images + needs: [binaries] runs-on: ubuntu-latest - needs: [release] - strategy: - matrix: - item: ${{ fromJson(needs.release.outputs.package_list) }} + env: + DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}} steps: - - name: Download all-packages artifact - uses: actions/download-artifact@v8 - with: - name: packages - path: ./dist + - name: Set up QEMU + id: qemu + uses: docker/setup-qemu-action@v2 + if: env.DOCKER_IMAGE != '' - - name: Upload all-packages artifact - uses: actions/upload-artifact@v7 - with: - name: navidrome_linux_${{ matrix.item }} - path: dist/navidrome_0*_linux_${{ matrix.item }} + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v2 + if: env.DOCKER_IMAGE != '' -# delete-artifacts: -# name: Delete unused artifacts -# runs-on: ubuntu-latest -# needs: [upload-packages] -# steps: -# - name: Delete all-packages artifact -# env: -# GH_TOKEN: ${{ github.token }} -# run: | -# for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("packages")) | .id'); do -# gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact -# done \ No newline at end of file + - uses: actions/checkout@v3 + if: env.DOCKER_IMAGE != '' + + - uses: actions/download-artifact@v3 + if: env.DOCKER_IMAGE != '' + with: + name: binaries + path: dist + + - name: Login to Docker Hub + if: env.DOCKER_IMAGE != '' + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Login to GitHub Container Registry + if: env.DOCKER_IMAGE != '' + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + if: env.DOCKER_IMAGE != '' + id: meta + uses: docker/metadata-action@v4 + with: + labels: | + maintainer=deluan + images: | + name=${{secrets.DOCKER_IMAGE}},enable=${{env.GITHUB_REF_TYPE == 'tag' || github.ref == format('refs/heads/{0}', 'master')}} + name=ghcr.io/${{ github.repository }} + tags: | + type=ref,event=pr + type=semver,pattern={{version}} + type=raw,value=develop,enable={{is_default_branch}} + + - name: Build and Push + if: env.DOCKER_IMAGE != '' + uses: docker/build-push-action@v4 + with: + context: . + file: .github/workflows/pipeline.dockerfile + platforms: linux/amd64,linux/386,linux/arm/v6,linux/arm/v7,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} diff --git a/.github/workflows/push-translations.sh b/.github/workflows/push-translations.sh deleted file mode 100755 index be153eea8..000000000 --- a/.github/workflows/push-translations.sh +++ /dev/null @@ -1,138 +0,0 @@ -#!/bin/sh - -set -e - -I18N_DIR=resources/i18n - -# Normalize JSON for deterministic comparison: -# remove empty/null attributes, sort keys alphabetically -process_json() { - jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1" -} - -# Get list of all languages configured in the POEditor project -get_language_list() { - curl -s -X POST https://api.poeditor.com/v2/languages/list \ - -d api_token="${POEDITOR_APIKEY}" \ - -d id="${POEDITOR_PROJECTID}" -} - -# Extract language name from the language list JSON given a language code -get_language_name() { - lang_code="$1" - lang_list="$2" - echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name" -} - -# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr") -get_lang_code() { - filepath="$1" - filename=$(basename "$filepath") - echo "${filename%.*}" -} - -# Export the current translation for a language from POEditor (v2 API) -export_language() { - lang_code="$1" - response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \ - -d api_token="${POEDITOR_APIKEY}" \ - -d id="${POEDITOR_PROJECTID}" \ - -d language="$lang_code" \ - -d type="key_value_json") - - url=$(echo "$response" | jq -r '.result.url') - if [ -z "$url" ] || [ "$url" = "null" ]; then - echo "Failed to export $lang_code: $response" >&2 - return 1 - fi - echo "$url" -} - -# Flatten nested JSON to POEditor languages/update format. -# POEditor uses term + context pairs, where: -# term = the leaf key name -# context = the parent path as "key1"."key2"."key3" (empty for root keys) -flatten_to_poeditor() { - jq -c '[paths(scalars) as $p | - { - "term": ($p | last | tostring), - "context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end), - "translation": {"content": getpath($p)} - } - ]' "$1" -} - -# Update translations for a language in POEditor via languages/update API -update_language() { - lang_code="$1" - file="$2" - - flatten_to_poeditor "$file" > /tmp/poeditor_data.json - response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \ - -d api_token="${POEDITOR_APIKEY}" \ - -d id="${POEDITOR_PROJECTID}" \ - -d language="$lang_code" \ - --data-urlencode data@/tmp/poeditor_data.json) - rm -f /tmp/poeditor_data.json - - status=$(echo "$response" | jq -r '.response.status') - if [ "$status" != "success" ]; then - echo "Failed to update $lang_code: $response" >&2 - return 1 - fi - - parsed=$(echo "$response" | jq -r '.result.translations.parsed') - added=$(echo "$response" | jq -r '.result.translations.added') - updated=$(echo "$response" | jq -r '.result.translations.updated') - echo " Translations - parsed: $parsed, added: $added, updated: $updated" -} - -# --- Main --- - -if [ $# -eq 0 ]; then - echo "Usage: $0 [file2] ..." - echo "No files specified. Nothing to do." - exit 0 -fi - -lang_list=$(get_language_list) -upload_count=0 - -for file in "$@"; do - if [ ! -f "$file" ]; then - echo "Warning: File not found: $file, skipping" - continue - fi - - lang_code=$(get_lang_code "$file") - lang_name=$(get_language_name "$lang_code" "$lang_list") - - if [ -z "$lang_name" ]; then - echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file" - continue - fi - - echo "Processing $lang_name ($lang_code)..." - - # Export current state from POEditor - url=$(export_language "$lang_code") - curl -sSL "$url" -o poeditor_export.json - - # Normalize both files for comparison - process_json "$file" > local_normalized.json - process_json poeditor_export.json > remote_normalized.json - - # Compare normalized versions - if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then - echo " No differences, skipping" - else - echo " Differences found, updating POEditor..." - update_language "$lang_code" "$file" - upload_count=$((upload_count + 1)) - fi - - rm -f poeditor_export.json local_normalized.json remote_normalized.json -done - -echo "" -echo "Done. Updated $upload_count translation(s) in POEditor." diff --git a/.github/workflows/push-translations.yml b/.github/workflows/push-translations.yml deleted file mode 100644 index 546c64114..000000000 --- a/.github/workflows/push-translations.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: POEditor export - -on: - push: - branches: - - master - paths: - - 'resources/i18n/*.json' - -jobs: - push-translations: - runs-on: ubuntu-latest - if: ${{ github.repository_owner == 'navidrome' }} - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 2 - - - name: Detect changed translation files - id: changed - run: | - CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- 'resources/i18n/*.json' | tr '\n' ' ') - echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT - echo "Changed translation files: $CHANGED_FILES" - - - name: Push translations to POEditor - if: ${{ steps.changed.outputs.files != '' }} - env: - POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }} - POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }} - run: | - .github/workflows/push-translations.sh ${{ steps.changed.outputs.files }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 33c8fadbd..3b11e9450 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -12,9 +12,8 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v6 + - uses: dessant/lock-threads@v4.0.0 with: - process-only: 'issues, prs' issue-inactive-days: 120 pr-inactive-days: 120 log-output: true @@ -28,7 +27,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@v10 + - uses: actions/stale@v7 with: operations-per-run: 999 days-before-issue-stale: 180 diff --git a/.github/workflows/update-translations.sh b/.github/workflows/update-translations.sh deleted file mode 100755 index 23d0ef209..000000000 --- a/.github/workflows/update-translations.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/bin/sh - -set -e - -I18N_DIR=resources/i18n - -# Function to process JSON: remove empty attributes and sort -process_json() { - jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1" -} - -# Function to check differences between local and remote translations -check_lang_diff() { - filename=${I18N_DIR}/"$1".json - url=$(curl -s -X POST https://poeditor.com/api/ \ - -d api_token="${POEDITOR_APIKEY}" \ - -d action="export" \ - -d id="${POEDITOR_PROJECTID}" \ - -d language="$1" \ - -d type="key_value_json" | jq -r .item) - if [ -z "$url" ]; then - echo "Failed to export $1" - return 1 - fi - curl -sSL "$url" > poeditor.json - - process_json "$filename" > "$filename".tmp - process_json poeditor.json > poeditor.tmp - - diff=$(diff -u "$filename".tmp poeditor.tmp) || true - if [ -n "$diff" ]; then - echo "$diff" - mv poeditor.json "$filename" - fi - - rm -f poeditor.json poeditor.tmp "$filename".tmp -} - -# Function to get the list of languages -get_language_list() { - response=$(curl -s -X POST https://api.poeditor.com/v2/languages/list \ - -d api_token="${POEDITOR_APIKEY}" \ - -d id="${POEDITOR_PROJECTID}") - - echo $response -} - -# Function to get the language name from the language code -get_language_name() { - lang_code="$1" - lang_list="$2" - - lang_name=$(echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name") - - if [ -z "$lang_name" ]; then - echo "Error: Language code '$lang_code' not found" >&2 - return 1 - fi - - echo "$lang_name" -} - -# Function to get the language code from the file path -get_lang_code() { - filepath="$1" - # Extract just the filename - filename=$(basename "$filepath") - - # Remove the extension - lang_code="${filename%.*}" - - echo "$lang_code" -} - -lang_list=$(get_language_list) - -# Check differences for each language -for file in ${I18N_DIR}/*.json; do - code=$(get_lang_code "$file") - lang=$(jq -r .languageName < "$file") - lang_name=$(get_language_name "$code" "$lang_list") - echo "Downloading $lang_name - $lang ($code)" - check_lang_diff "$code" -done - -# List changed languages to stderr -languages="" -for file in $(git diff --name-only --exit-code | grep json); do - lang_code=$(get_lang_code "$file") - lang_name=$(get_language_name "$lang_code" "$lang_list") - languages="${languages}$(echo "$lang_name" | tr -d '\n'), " -done -echo "${languages%??}" 1>&2 \ No newline at end of file diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml index 5db32f3fa..e8286732b 100644 --- a/.github/workflows/update-translations.yml +++ b/.github/workflows/update-translations.yml @@ -8,26 +8,21 @@ jobs: runs-on: ubuntu-latest if: ${{ github.repository_owner == 'navidrome' }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v3 - name: Get updated translations - id: poeditor env: POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }} POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }} run: | - .github/workflows/update-translations.sh 2> title.tmp - title=$(cat title.tmp) - echo "::set-output name=title::$title" - rm title.tmp + ./update-translations.sh - name: Show changes, if any run: | git status --porcelain git diff - name: Create Pull Request - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@v4 with: token: ${{ secrets.PAT }} - author: "navidrome-bot " - commit-message: "fix(ui): update ${{ steps.poeditor.outputs.title }} translations from POEditor" - title: "fix(ui): update ${{ steps.poeditor.outputs.title }} translations from POEditor" + commit-message: Update translations + title: Update translations from POEditor branch: update-translations diff --git a/.github/workflows/validate-migrations.sh b/.github/workflows/validate-migrations.sh deleted file mode 100755 index 07d05c3a6..000000000 --- a/.github/workflows/validate-migrations.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env bash -# -# Validates DB migrations added by a pull request: -# 1. Ordering - an added migration must be NEWER than the latest migration -# already on the base branch. Goose applies migrations in -# timestamp order, so an older-timestamped migration would be -# silently skipped on databases already upgraded past it. -# 2. Uniqueness - no two migration files may share a timestamp. -# 3. Naming - files must match YYYYMMDDHHMMSS_lower_snake_name.(sql|go). -# -# On failure it prints a human-readable message and, when running in GitHub -# Actions, emits an error annotation bound to the offending file so the message -# also renders inline in the PR "Files changed" tab. -# -# Compares HEAD against $BASE_REF (default origin/master). Requires full history -# (fetch-depth: 0 in CI). -# -e is intentionally omitted: the script accumulates violations into $status -# and must not exit on the first non-zero command (grep no-match, a false [[ ]] -# in an if, `is_migration || continue`). -set -uo pipefail -export LC_ALL=C - -MIGRATIONS_DIR="db/migrations" -BASE_REF="${BASE_REF:-origin/master}" -NAME_RE='^[0-9]{14}_[a-z0-9_]+\.(sql|go)$' - -status=0 - -# Log a message to stderr and mark the run as failed. -fail() { - printf '%s\n' "$1" >&2 - status=1 -} - -# Emit a GitHub Actions error annotation bound to a file, so the message renders -# inline on the offending migration in the PR "Files changed" tab. No-op outside -# CI. `%`, newline and CR are encoded as required by the workflow-command syntax -# (the `%` replacement must run first so the encodings we add aren't re-escaped). -annotate() { # $1=file $2=message - [ "${GITHUB_ACTIONS:-}" = "true" ] || return 0 - local msg="$2" - msg="${msg//'%'/%25}" - msg="${msg//$'\n'/%0A}" - msg="${msg//$'\r'/%0D}" - printf '::error file=%s,line=1::%s\n' "$1" "$msg" -} - -# Report a migration problem: log it, annotate the offending file, mark failed. -report() { # $1=file $2=message - fail "$2" - printf '\n' >&2 - annotate "$1" "$2" -} - -human_ts() { - local t="$1" - printf '%s-%s-%s %s:%s:%s' "${t:0:4}" "${t:4:2}" "${t:6:2}" "${t:8:2}" "${t:10:2}" "${t:12:2}" -} - -is_migration() { # $1=basename -> 0 if a .sql/.go file with a 14-digit prefix - local b="$1" - case "$b" in - *.sql | *.go) ;; - *) return 1 ;; - esac - [[ "${b%%_*}" =~ ^[0-9]{14}$ ]] -} - -if ! git rev-parse --verify --quiet "$BASE_REF" >/dev/null; then - printf '❌ Cannot resolve base ref "%s". In CI, check out with fetch-depth: 0.\n' "$BASE_REF" >&2 - exit 1 -fi - -# --- Newest timestamp already on the base branch --- -base_max="" -base_max_file="" -while IFS= read -r f; do - [ -z "$f" ] && continue - b="$(basename "$f")" - is_migration "$b" || continue - ts="${b%%_*}" - if [[ "$ts" > "$base_max" ]]; then - base_max="$ts" - base_max_file="$f" - fi -done < <(git ls-tree -r --name-only "$BASE_REF" -- "$MIGRATIONS_DIR" 2>/dev/null) - -# --- Ordering + naming on files added by this PR --- -while IFS= read -r f; do - [ -z "$f" ] && continue - b="$(basename "$f")" - case "$b" in - *.sql) ;; # any .sql in this dir must be a migration - *.go) [[ "$b" == [0-9]* ]] || continue ;; # non-timestamped .go = helper (e.g. migration.go), skip - *) continue ;; - esac - if [ "${f%/*}" != "$MIGRATIONS_DIR" ]; then - report "$f" "❌ Migration file in a subdirectory: $f - Migrations must live directly in $MIGRATIONS_DIR/ — only $MIGRATIONS_DIR/*.sql (and - top-level .go migrations) are embedded, so a nested file would be SILENTLY SKIPPED. - Move it to $MIGRATIONS_DIR/$b." - continue - fi - if ! [[ "$b" =~ $NAME_RE ]]; then - report "$f" "❌ Malformed migration filename: $f - Expected YYYYMMDDHHMMSS_lower_snake_name.(sql|go); the name segment must be lowercase. - Regenerate with: make migration-sql name= (or make migration-go name=)" - continue - fi - ts="${b%%_*}" - if [[ -n "$base_max" ]] && ! [[ "$ts" > "$base_max" ]]; then - report "$f" "❌ Migration ordering error: $f ($(human_ts "$ts")) - is older than (or equal to) the newest migration already on ${BASE_REF#origin/}: - $base_max_file ($(human_ts "$base_max")) - - Goose applies migrations in timestamp order, so databases already upgraded - past that point would SILENTLY SKIP your migration. - - Fix: regenerate it with a current timestamp: - make migration-sql name= (or make migration-go name=) - then move your SQL/Go body into the new file and delete the old one." - fi -done < <(git diff --diff-filter=A --name-only "$BASE_REF"...HEAD -- "$MIGRATIONS_DIR" 2>/dev/null) - -# --- Duplicate timestamps across the merged set (HEAD) --- -all_migs="$(git ls-tree -r --name-only HEAD -- "$MIGRATIONS_DIR" 2>/dev/null)" -dups="$(printf '%s\n' "$all_migs" | while IFS= read -r f; do - b="$(basename "$f")" - is_migration "$b" || continue - printf '%s\n' "${b%%_*}" -done | sort | uniq -d)" -if [ -n "$dups" ]; then - while IFS= read -r ts; do - [ -z "$ts" ] && continue - colliding="$(printf '%s\n' "$all_migs" | grep "/${ts}_" || true)" - printf '❌ Duplicate migration timestamp %s used by multiple files:\n' "$ts" >&2 - while IFS= read -r cf; do - [ -z "$cf" ] && continue - printf ' %s\n' "$cf" >&2 - annotate "$cf" "Duplicate migration timestamp $ts — shared by another migration. Timestamps must be unique; regenerate one with make migration-*." - done <<< "$colliding" - printf ' Every migration needs a unique timestamp. Regenerate one with make migration-*.\n' >&2 - status=1 - done <<< "$dups" -fi - -if [ "$status" -eq 0 ]; then - echo "✅ DB migrations OK (ordering, uniqueness, naming)." -fi -exit "$status" diff --git a/.github/workflows/validate-translations.sh b/.github/workflows/validate-translations.sh deleted file mode 100755 index a6b346e78..000000000 --- a/.github/workflows/validate-translations.sh +++ /dev/null @@ -1,236 +0,0 @@ -#!/bin/bash - -# validate-translations.sh -# -# This script validates the structure of JSON translation files by comparing them -# against the reference English translation file (ui/src/i18n/en.json). -# -# The script performs the following validations: -# 1. JSON syntax validation using jq -# 2. Structural validation - ensures all keys from English file are present -# 3. Reports missing keys (translation incomplete) -# 4. Reports extra keys (keys not in English reference, possibly deprecated) -# 5. Emits GitHub Actions annotations for CI/CD integration -# -# Usage: -# ./validate-translations.sh -# -# Environment Variables: -# EN_FILE - Path to reference English file (default: ui/src/i18n/en.json) -# TRANSLATION_DIR - Directory containing translation files (default: resources/i18n) -# -# Exit codes: -# 0 - All translations are valid -# 1 - One or more translations have structural issues -# -# GitHub Actions Integration: -# The script outputs GitHub Actions annotations using ::error and ::warning -# format that will be displayed in PR checks and workflow summaries. - -# Script to validate JSON translation files structure against en.json -set -e - -# Path to the reference English translation file -EN_FILE="${EN_FILE:-ui/src/i18n/en.json}" -TRANSLATION_DIR="${TRANSLATION_DIR:-resources/i18n}" -VERBOSE=false - -# Parse command line arguments -while [[ $# -gt 0 ]]; do - case "$1" in - -v|--verbose) - VERBOSE=true - shift - ;; - -h|--help) - echo "Usage: $0 [options]" - echo "" - echo "Validates JSON translation files structure against English reference file." - echo "" - echo "Options:" - echo " -h, --help Show this help message" - echo " -v, --verbose Show detailed output (default: only show errors)" - echo "" - echo "Environment Variables:" - echo " EN_FILE Path to reference English file (default: ui/src/i18n/en.json)" - echo " TRANSLATION_DIR Directory with translation files (default: resources/i18n)" - echo "" - echo "Examples:" - echo " $0 # Validate all translation files (quiet mode)" - echo " $0 -v # Validate with detailed output" - echo " EN_FILE=custom/en.json $0 # Use custom reference file" - echo " TRANSLATION_DIR=custom/i18n $0 # Use custom translations directory" - exit 0 - ;; - *) - echo "Unknown option: $1" >&2 - echo "Use --help for usage information" >&2 - exit 1 - ;; - esac -done - -# Color codes for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -if [[ "$VERBOSE" == "true" ]]; then - echo "Validating translation files structure against ${EN_FILE}..." -fi - -# Check if English reference file exists -if [[ ! -f "$EN_FILE" ]]; then - echo "::error::Reference file $EN_FILE not found" - exit 1 -fi - -# Function to extract all JSON keys from a file, creating a flat list of dot-separated paths -extract_keys() { - local file="$1" - jq -r 'paths(scalars) as $p | $p | join(".")' "$file" 2>/dev/null | sort -} - -# Function to extract all non-empty string keys (to identify structural issues) -extract_structure_keys() { - local file="$1" - # Get only keys where values are not empty strings - jq -r 'paths(scalars) as $p | select(getpath($p) != "") | $p | join(".")' "$file" 2>/dev/null | sort -} - -# Function to validate a single translation file -validate_translation() { - local translation_file="$1" - local filename=$(basename "$translation_file") - local has_errors=false - local verbose=${2:-false} - - if [[ "$verbose" == "true" ]]; then - echo "Validating $filename..." - fi - - # First validate JSON syntax - if ! jq empty "$translation_file" 2>/dev/null; then - echo "::error file=$translation_file::Invalid JSON syntax" - echo -e "${RED}✗ $filename has invalid JSON syntax${NC}" - return 1 - fi - - # Extract all keys from both files (for statistics) - local en_keys_file=$(mktemp) - local translation_keys_file=$(mktemp) - - extract_keys "$EN_FILE" > "$en_keys_file" - extract_keys "$translation_file" > "$translation_keys_file" - - # Extract only non-empty structure keys (to validate structural issues) - local en_structure_file=$(mktemp) - local translation_structure_file=$(mktemp) - - extract_structure_keys "$EN_FILE" > "$en_structure_file" - extract_structure_keys "$translation_file" > "$translation_structure_file" - - # Find structural issues: keys in translation not in English (misplaced) - local extra_keys=$(comm -13 "$en_keys_file" "$translation_keys_file") - - # Find missing keys (for statistics only) - local missing_keys=$(comm -23 "$en_keys_file" "$translation_keys_file") - - # Count keys for statistics - local total_en_keys=$(wc -l < "$en_keys_file") - local total_translation_keys=$(wc -l < "$translation_keys_file") - local missing_count=0 - local extra_count=0 - - if [[ -n "$missing_keys" ]]; then - missing_count=$(echo "$missing_keys" | grep -c '^' || echo 0) - fi - - if [[ -n "$extra_keys" ]]; then - extra_count=$(echo "$extra_keys" | grep -c '^' || echo 0) - has_errors=true - fi - - # Report extra/misplaced keys (these are structural issues) - if [[ -n "$extra_keys" ]]; then - if [[ "$verbose" == "true" ]]; then - echo -e "${YELLOW}Misplaced keys in $filename ($extra_count):${NC}" - fi - - while IFS= read -r key; do - # Try to find the line number - line=$(grep -n "\"$(echo "$key" | sed 's/.*\.//')" "$translation_file" | head -1 | cut -d: -f1) - line=${line:-1} # Default to line 1 if not found - - echo "::error file=$translation_file,line=$line::Misplaced key: $key" - - if [[ "$verbose" == "true" ]]; then - echo " + $key (line ~$line)" - fi - done <<< "$extra_keys" - fi - - # Clean up temp files - rm -f "$en_keys_file" "$translation_keys_file" "$en_structure_file" "$translation_structure_file" - - # Print statistics - if [[ "$verbose" == "true" ]]; then - echo " Keys: $total_translation_keys/$total_en_keys (Missing: $missing_count, Extra/Misplaced: $extra_count)" - - if [[ "$has_errors" == "true" ]]; then - echo -e "${RED}✗ $filename has structural issues${NC}" - else - echo -e "${GREEN}✓ $filename structure is valid${NC}" - fi - elif [[ "$has_errors" == "true" ]]; then - echo -e "${RED}✗ $filename has structural issues (Extra/Misplaced: $extra_count)${NC}" - fi - - return $([[ "$has_errors" == "true" ]] && echo 1 || echo 0) -} - -# Main validation loop -validation_failed=false -total_files=0 -failed_files=0 -valid_files=0 - -for translation_file in "$TRANSLATION_DIR"/*.json; do - if [[ -f "$translation_file" ]]; then - total_files=$((total_files + 1)) - if ! validate_translation "$translation_file" "$VERBOSE"; then - validation_failed=true - failed_files=$((failed_files + 1)) - else - valid_files=$((valid_files + 1)) - fi - - if [[ "$VERBOSE" == "true" ]]; then - echo "" # Add spacing between files - fi - fi -done - -# Summary -if [[ "$VERBOSE" == "true" ]]; then - echo "=========================================" - echo "Translation Validation Summary:" - echo " Total files: $total_files" - echo " Valid files: $valid_files" - echo " Files with structural issues: $failed_files" - echo "=========================================" -fi - -if [[ "$validation_failed" == "true" ]]; then - if [[ "$VERBOSE" == "true" ]]; then - echo -e "${RED}Translation validation failed - $failed_files file(s) have structural issues${NC}" - else - echo -e "${RED}Translation validation failed - $failed_files/$total_files file(s) have structural issues${NC}" - fi - exit 1 -elif [[ "$VERBOSE" == "true" ]]; then - echo -e "${GREEN}All translation files are structurally valid${NC}" -fi - -exit 0 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4ed971491..6a18b5871 100644 --- a/.gitignore +++ b/.gitignore @@ -5,46 +5,24 @@ /navidrome /iTunes*.xml /tmp -/bin data/* vendor/*/ wiki TODO.md var navidrome.toml -!release/linux/navidrome.toml master.zip testDB +navidrome.db cache/* *.swp -coverage.out +embedded_gen.go dist music -music.old -*.db* +navidrome.db-shm +navidrome.db-wal +tags .gitinfo docker-compose.yml !contrib/docker-compose.yml -binaries -navidrome-* -/ndpgen -AGENTS.md -.github/prompts -.github/instructions -.github/git-commit-instructions.md -*.exe -*.test -*.wasm -*.ndp -openspec/ -.agents -go.work* -.worktrees/ -.playwright-mcp/ - -# Temp benchmark files -zz_*_test.go - -# wazero compilation cache for the plugins test suite -/plugins/testdata/.wazero-cache/ -/plugins/testdata/*.stage/ +/api/openapi.yaml diff --git a/.golangci.yml b/.golangci.yml index 923c06c7a..e7836b4c6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,87 +1,35 @@ -version: "2" run: - build-tags: - - netgo - - sqlite_fts5 + go: "1.19" + linters: enable: - asasalint - asciicheck - bidichk - bodyclose - - containedctx - - copyloopvar - dogsled - durationcheck + - errcheck - errorlint - - forbidigo - - gocritic + - exportloopref - gocyclo - goprintffuncname - gosec + - gosimple + - govet + - ineffassign - misspell - nakedret - nilerr - rowserrcheck - - unconvert - - whitespace - disable: - staticcheck - settings: - errcheck: - exclude-functions: - - (*github.com/zeebo/xxh3.Hasher).Write - gocritic: - disable-all: true - enabled-checks: - - deprecatedComment - gosec: - excludes: - - G501 - - G401 - - G505 - - G115 - forbidigo: - forbid: - - pattern: 'tx\.Exec$' - msg: "use tx.ExecContext(ctx, ...) in migrations to propagate context" - - pattern: 'tx\.Query$' - msg: "use tx.QueryContext(ctx, ...) in migrations to propagate context" - - pattern: 'tx\.QueryRow$' - msg: "use tx.QueryRowContext(ctx, ...) in migrations to propagate context" - govet: - enable: - - nilness - exclusions: - rules: - - linters: - - gosec - path: _test\.go - text: "G703" - - path: _test\.go - linters: - - containedctx - - path-except: 'db/migrations/' - linters: - - forbidigo - generated: lax - presets: - - comments - - common-false-positives - - legacy - - std-error-handling - paths: - - third_party$ - - builtin$ - - examples$ - - node_modules - - _gen\.go$ - - .worktrees -formatters: - exclusions: - generated: lax - paths: - - third_party$ - - builtin$ - - examples$ - - node_modules + - typecheck + - unconvert + - unused + - whitespace + +issues: + exclude-rules: + - linters: + - gosec + text: "(G501|G401|G505):" diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 000000000..1679f3f09 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,139 @@ +# GoReleaser config +project_name: navidrome + +builds: + - id: navidrome_linux_amd64 + env: + - CGO_ENABLED=1 + goos: + - linux + goarch: + - amd64 + flags: + - -tags=netgo + ldflags: + - "-extldflags '-static -lz'" + - -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}} + + - id: navidrome_linux_386 + env: + - CGO_ENABLED=1 + - PKG_CONFIG_PATH=/i386/lib/pkgconfig + goos: + - linux + goarch: + - "386" + flags: + - -tags=netgo + ldflags: + - "-extldflags '-static'" + - -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}} + + - id: navidrome_linux_arm + env: + - CGO_ENABLED=1 + - CC=arm-linux-gnueabi-gcc + - CXX=arm-linux-gnueabi-g++ + - PKG_CONFIG_PATH=/arm/lib/pkgconfig + goos: + - linux + goarch: + - arm + goarm: + - "5" + - "6" + - "7" + flags: + - -tags=netgo + ldflags: + - "-extldflags '-static'" + - -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}} + + - id: navidrome_linux_arm64 + env: + - CGO_ENABLED=1 + - CC=aarch64-linux-gnu-gcc + - CXX=aarch64-linux-gnu-g++ + - PKG_CONFIG_PATH=/arm64/lib/pkgconfig + goos: + - linux + goarch: + - arm64 + flags: + - -tags=netgo + ldflags: + - "-extldflags '-static'" + - -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}} + + - id: navidrome_windows_386 + env: + - CGO_ENABLED=1 + - CC=i686-w64-mingw32-gcc + - CXX=i686-w64-mingw32-g++ + - PKG_CONFIG_PATH=/mingw32/lib/pkgconfig + goos: + - windows + goarch: + - "386" + flags: + - -tags=netgo + ldflags: + - "-extldflags '-static'" + - -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}} + + - id: navidrome_windows_amd64 + env: + - CGO_ENABLED=1 + - CC=x86_64-w64-mingw32-gcc + - CXX=x86_64-w64-mingw32-g++ + - PKG_CONFIG_PATH=/mingw64/lib/pkgconfig + goos: + - windows + goarch: + - amd64 + flags: + - -tags=netgo + ldflags: + - "-extldflags '-static'" + - -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}} + + - id: navidrome_darwin_amd64 + env: + - CGO_ENABLED=1 + - CC=o64-clang + - CXX=o64-clang++ + - PKG_CONFIG_PATH=/darwin/lib/pkgconfig + goos: + - darwin + goarch: + - amd64 + flags: + - -tags=netgo + ldflags: + - -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}} + +archives: + - format_overrides: + - goos: windows + format: zip + replacements: + darwin: macOS + linux: Linux + windows: Windows + 386: i386 + amd64: x86_64 + +checksum: + name_template: "{{ .ProjectName }}_checksums.txt" + +snapshot: + name_template: "{{ .Tag }}-SNAPSHOT" + +release: + draft: true + +changelog: + # sort: asc + filters: + exclude: + - "^docs:" diff --git a/.nvmrc b/.nvmrc index 54c65116f..3f430af82 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v24 +v18 diff --git a/.octocov.yml b/.octocov.yml deleted file mode 100644 index c4ad10712..000000000 --- a/.octocov.yml +++ /dev/null @@ -1,44 +0,0 @@ -# Code coverage reporting for pull requests. See https://github.com/k1LoW/octocov -# The 30s default is not enough: scanning this repo's artifacts for the baseline -# eats most of it, leaving none for the report upload. -timeout: 5m -coverage: - # A single pre-merged profile: octocov reports statements for one path, but - # switches to line counting when it merges several itself. - paths: - - coverage.out - # Not code under test: tests/ holds the mocks and helpers, *_gen.go is generated. - # Both patterns need the '**/' prefix: the comment workflow has no source tree, - # so octocov cannot shorten the profile's import paths to repo-relative ones. - exclude: - - '**/tests/**' - - '**/*_gen.go' -codeToTestRatio: - # Needs the pull request's own source, which the comment workflow must not - # check out: it holds a write token. - if: env.COVERAGE_COMMENT != 'true' - code: - - '**/*.go' - - '!**/*_test.go' - - '!**/*_gen.go' - test: - - '**/*_test.go' -testExecutionTime: - if: true - steps: - - Test with coverage - - Test plugins -diff: - datastores: - - artifact://${GITHUB_REPOSITORY} -comment: - # Only the 'Report coverage on PR' workflow sets this: a pull_request run from - # a fork gets a read-only token, so commenting from here 403s. - if: env.COVERAGE_COMMENT == 'true' - updatePrevious: true -summary: - if: true -report: - if: is_default_branch - datastores: - - artifact://${GITHUB_REPOSITORY} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 71c13b497..6c08ea2c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ Before submitting a pull request, ensure that you go through the following: ### Commit Conventions Each commit message must adhere to the following format: ``` -(scope): +(scope): - [optional body] ``` @@ -48,15 +48,14 @@ This improves the readability of the messages It can be one of the following: 1. **feat**: Addition of a new feature 2. **fix**: Bug fix -3. **sec**: Fixing security issues -4. **docs**: Documentation Changes -5. **style**: Changes to styling -6. **refactor**: Refactoring of code -7. **perf**: Code that affects performance -8. **test**: Updating or improving the current tests -9. **build**: Changes to Build process -10. **revert**: Reverting to a previous commit -11. **chore** : updating grunt tasks etc +3. **docs**: Documentation Changes +4. **style**: Changes to styling +5. **refactor**: Refactoring of code +6. **perf**: Code that affects performance +7. **test**: Updating or improving the current tests +8. **build**: Changes to Build process +9. **revert**: Reverting to a previous commit +10. **chore** : updating grunt tasks etc If there is a breaking change in your Pull Request, please add `BREAKING CHANGE` in the optional body section diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 5fc0529f0..000000000 --- a/Dockerfile +++ /dev/null @@ -1,217 +0,0 @@ -FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcross - -######################################################################################################################## -### Build xx (original image: tonistiigi/xx) -FROM --platform=$BUILDPLATFORM alpine:3.22 AS xx-build - -# v1.9.0 -ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50 - -RUN apk add -U --no-cache git -RUN git clone https://github.com/tonistiigi/xx && \ - cd xx && \ - git checkout ${XX_VERSION} && \ - mkdir -p /out && \ - cp src/xx-* /out/ - -RUN cd /out && \ - ln -s xx-cc /out/xx-clang && \ - ln -s xx-cc /out/xx-clang++ && \ - ln -s xx-cc /out/xx-c++ && \ - ln -s xx-apt /out/xx-apt-get - -# xx mimics the original tonistiigi/xx image -FROM scratch AS xx -COPY --from=xx-build /out/ /usr/bin/ - -######################################################################################################################## -### Build Navidrome UI -FROM --platform=$BUILDPLATFORM node:lts-alpine AS ui -WORKDIR /app - -# Install node dependencies -COPY ui/package.json ui/package-lock.json ./ -COPY ui/bin/ ./bin/ -RUN npm ci - -# Build bundle -COPY ui/ ./ -RUN npm run build -- --outDir=/build - -FROM scratch AS ui-bundle -COPY --from=ui /build /build - -######################################################################################################################## -### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen) -FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build-alpine -COPY --from=xx / / - -ARG TARGETPLATFORM - -RUN apk add --no-cache clang lld file git -RUN xx-apk add --no-cache gcc musl-dev zlib-dev -RUN xx-verify --setup - -WORKDIR /workspace - -RUN --mount=type=bind,source=. \ - --mount=type=cache,target=/root/.cache \ - --mount=type=cache,target=/go/pkg/mod \ - go mod download - -ARG GIT_SHA -ARG GIT_TAG - -RUN --mount=type=bind,source=. \ - --mount=from=ui,source=/build,target=./ui/build,ro \ - --mount=type=cache,target=/root/.cache \ - --mount=type=cache,target=/go/pkg/mod </dev/null || true - - # Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler. - # So let's use gcc for everything except Darwin. - if [ "$(xx-info os)" != "darwin" ]; then - export CC=$(xx-info)-gcc - export CXX=$(xx-info)-g++ - export LD_EXTRA="-extldflags '-static -latomic'" - fi - # GNU ld corrupts the R_ARM_IRELATIVE addends of libatomic's ifunc resolvers - # (wrong address, Thumb bit lost) once .text outgrows the 16MB Thumb branch - # range, making static arm binaries jump to garbage inside glibc's ifunc - # resolution and crash before main() (issue #5738). Link 32-bit arm with LLD, - # which emits correct addends. - if [ "$(xx-info arch)" = "arm" ]; then - export LD_EXTRA="-extldflags '-static -latomic -fuse-ld=lld'" - fi - if [ "$(xx-info os)" = "windows" ]; then - export EXT=".exe" - fi - - BUILD_TAGS=$(./release/build-tags.sh) - go build -tags="${BUILD_TAGS}" -ldflags="${LD_EXTRA} -w -s \ - -X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \ - -X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \ - -o /out/navidrome${EXT} . - # Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738). - ./release/verify-binary.sh /out/navidrome* -EOT - -# Verify if the binary was built for the correct platform and it is statically linked -RUN xx-verify --static /out/navidrome* - -FROM scratch AS binary -COPY --from=build /out / - -######################################################################################################################## -### Build no-op stubs for mpv's video-output libraries -# mpv links libEGL/libgbm for video output only; Navidrome drives it headless, for audio. -# Real mesa pulls in LLVM + gallium (+218MB uncompressed), so ship stubs it never calls. -FROM --platform=$BUILDPLATFORM alpine:3.22 AS mpv-stubs -COPY --from=xx / / -RUN apk add --no-cache clang lld binutils mesa-egl mesa-gbm -ARG TARGETPLATFORM -RUN xx-apk add --no-cache musl-dev -RUN < /tmp/stub.c - test -s /tmp/stub.c - xx-clang -shared -nostdlib -fPIC -Wl,-soname,$so -o /out/$so /tmp/stub.c - xx-verify /out/$so - done -EOT - -######################################################################################################################## -### Build Final Image -FROM alpine:3.22 AS final -LABEL maintainer="deluan@navidrome.org" -LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome" - -# Install runtime dependencies -# - libwebp + symlinks: enables native WebP encoding via purego/dlopen -# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer, -# otherwise the deleted bytes still ship in the image. -RUN apk add -U --no-cache curl ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \ - for lib in libwebp libwebpdemux libwebpmux; do \ - target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \ - [ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \ - done && \ - rm -rf /usr/lib/gallium-pipe /usr/lib/dri \ - /usr/lib/libEGL.so* /usr/lib/libgbm.so* /usr/lib/libgallium*.so /usr/lib/libLLVM.so* \ - /usr/lib/libGL.so* /usr/lib/libGLESv2.so* /usr/lib/libglapi.so* - -COPY --from=mpv-stubs /out/ /usr/lib/ -RUN mpv --no-video --ao=null --version > /dev/null - -# Copy navidrome binary (musl build for Docker, enables native libwebp) -COPY --from=build-alpine /out/navidrome /app/ - -VOLUME ["/data", "/music"] -ENV ND_MUSICFOLDER=/music -ENV ND_DATAFOLDER=/data -ENV ND_CONFIGFILE=/data/navidrome.toml -ENV ND_PORT=4533 -RUN touch /.nddockerenv - -EXPOSE ${ND_PORT} -WORKDIR /app -ENV PATH="/app:${PATH}" - -ENTRYPOINT ["/app/navidrome"] - diff --git a/Makefile b/Makefile index f31d98a01..eb3b05df0 100644 --- a/Makefile +++ b/Makefile @@ -1,34 +1,17 @@ GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ') NODE_VERSION=$(shell cat .nvmrc) -comma:=, -GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS)) - -# Set global environment variables, required for most targets -export ND_ENABLEINSIGHTSCOLLECTOR=false - ifneq ("$(wildcard .git/HEAD)","") GIT_SHA=$(shell git rev-parse --short HEAD) -GIT_TAG=$(shell git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)-SNAPSHOT +GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`) else GIT_SHA=source_archive -GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT +GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD))) endif -SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386 -IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//') -PLATFORMS ?= $(SUPPORTED_PLATFORMS) -DOCKER_TAG ?= deluan/navidrome:develop +CI_RELEASER_VERSION=1.20.3-1 ## https://github.com/navidrome/ci-goreleaser -GOLANGCI_LINT_VERSION ?= v2.14.0 -VACUUM_VERSION ?= v0.30.6 -OAPI_CODEGEN_VERSION ?= v2.8.0 -OASDIFF_VERSION ?= v1.32.1 -API_DIFF_BASE ?= origin/master - -UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") - -setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First Install dependencies and prepare development environment +setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment @echo Downloading Node dependencies... @(cd ./ui && npm ci) .PHONY: setup @@ -37,145 +20,60 @@ dev: check_env ##@Development Start Navidrome in development mode, with hot-re npx foreman -j Procfile.dev -p 4533 start .PHONY: dev -server: check_go_env buildjs ##@Development Start the backend in development mode - go tool reflex -d none -c reflex.conf +server: check_go_env ##@Development Start the backend in development mode + @go run github.com/cespare/reflex@latest -d none -c reflex.conf .PHONY: server -stop: ##@Development Stop development servers (UI and backend) - @echo "Stopping development servers..." - @-pkill -f "vite" - @-pkill -f "go tool reflex.*reflex.conf" - @-pkill -f "go run.*netgo" - @echo "Development servers stopped." -.PHONY: stop - watch: ##@Development Start Go tests in watch mode (re-run when code changes) - go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -notify ./... + go run github.com/onsi/ginkgo/v2/ginkgo@latest watch -notify ./... .PHONY: watch -PKG ?= ./... -test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server - go test -tags $(GO_BUILD_TAGS) $(PKG) +test: ##@Development Run Go tests + go test -race -shuffle=on ./... .PHONY: test -test-ndpgen: ##@Development Run tests for ndpgen plugin - cd plugins/cmd/ndpgen && go test ./...... -.PHONY: test-ndpgen - -testall: test test-ndpgen test-i18n test-js ##@Development Run Go and JS tests +testall: test ##@Development Run Go and JS tests, and validate OpenAPI spec + @(cd ./ui && npm test -- --watchAll=false) .PHONY: testall -test-race: ##@Development Run Go tests with race detector - go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG) -.PHONY: test-race - -test-js: ##@Development Run JS tests - @(cd ./ui && npm run test) -.PHONY: test-js - -test-i18n: ##@Development Validate all translations files - ./.github/workflows/validate-translations.sh -.PHONY: test-i18n - -install-golangci-lint: ##@Development Install golangci-lint if not present - @INSTALL=false; \ - 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..."; \ - rm -f ./bin/golangci-lint; \ - INSTALL=true; \ - fi; \ - else \ - INSTALL=true; \ - fi; \ - if [ "$$INSTALL" = "true" ]; then \ - echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)..."; \ - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s $(GOLANGCI_LINT_VERSION); \ - fi -.PHONY: install-golangci-lint - -install-api-tools: ##@Development Install OpenAPI tools (vacuum, oapi-codegen, oasdiff) into ./bin - @STAMP=bin/.api-tools-$(VACUUM_VERSION)-$(OAPI_CODEGEN_VERSION)-$(OASDIFF_VERSION); \ - if [ ! -f $$STAMP ] || [ ! -x bin/vacuum ] || [ ! -x bin/oapi-codegen ] || [ ! -x bin/oasdiff ]; then \ - echo "Installing OpenAPI tools..."; \ - GOBIN=$(CURDIR)/bin go install github.com/daveshanley/vacuum@$(VACUUM_VERSION) && \ - GOBIN=$(CURDIR)/bin go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) && \ - GOBIN=$(CURDIR)/bin go install github.com/oasdiff/oasdiff@$(OASDIFF_VERSION) && \ - rm -f bin/.api-tools-* && touch $$STAMP; \ - fi -.PHONY: install-api-tools - -api-lint: install-api-tools ##@Development Lint the OpenAPI spec - ./bin/vacuum lint -r api/.vacuum.yaml -d -q -b --fail-severity error api/openapi/openapi.yaml -.PHONY: api-lint - -api-bundle: install-api-tools ##@Development Bundle the multi-file OpenAPI spec into api/bundled - ./bin/vacuum bundle -q --composed -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.yaml - ./bin/vacuum bundle -q --composed --format json -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.json -.PHONY: api-bundle - -api-gen: api-bundle ##@Development Generate the API v1 server code from the bundled spec - ./bin/oapi-codegen -config server/apiv1/oapi-codegen.yaml api/bundled/openapi.json -.PHONY: api-gen - -api-diff: api-bundle ##@Development Fail on breaking OpenAPI changes against the merge-base with $(API_DIFF_BASE) - @git rev-parse --verify --quiet $(API_DIFF_BASE)^{commit} >/dev/null || { echo "Base ref $(API_DIFF_BASE) not found; set API_DIFF_BASE"; exit 1; }; \ - BASE="$$(git merge-base HEAD $(API_DIFF_BASE) 2>/dev/null)"; \ - if [ -z "$$BASE" ]; then \ - echo "No merge-base with $(API_DIFF_BASE); falling back to its tip"; \ - BASE=$(API_DIFF_BASE); \ - fi; \ - if git cat-file -e $$BASE:api/bundled/openapi.json 2>/dev/null; then \ - git show $$BASE:api/bundled/openapi.json > $(CURDIR)/bin/api-base.json && \ - ./bin/oasdiff breaking $(CURDIR)/bin/api-base.json api/bundled/openapi.json --fail-on ERR --severity-levels api/.oasdiff-levels.txt; \ - else \ - echo "No bundled spec at $$BASE; skipping breaking-change check"; \ - fi -.PHONY: api-diff - -lint: install-golangci-lint ##@Development Lint Go code - PATH=./bin:$$PATH golangci-lint run --timeout 5m +lint: ##@Development Lint Go code + go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run -v --timeout 5m .PHONY: lint -lintall: lint ##@Development Lint Go and JS code +lintapi: api/openapi.yaml ##@Development Lint OpenAPI spec + npx @redocly/cli lint api/openapi.yaml +.PHONY: lintapi + +lintall: lint lintapi ##@Development Lint Go and JS code @(cd ./ui && npm run check-formatting) || (echo "\n\nPlease run 'npm run prettier' to fix formatting issues." && exit 1) @(cd ./ui && npm run lint) .PHONY: lintall -format: ##@Development Format code - @(cd ./ui && npm run prettier) - @go tool goimports -w `find . -name '*.go' | grep -v _gen.go$$ | grep -v .pb.go$$` - @go mod tidy -.PHONY: format - -wire: check_go_env ##@Development Update Dependency Injection - go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./... +gen: check_go_env api ##@Development Update Generated Code (wire, mocks, openapi, etc) + go run github.com/google/wire/cmd/wire@latest ./... .PHONY: wire -gen: check_go_env api-gen ##@Development Run go generate for code generation - go generate ./... - cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust - cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types - cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -rust -shared=../../types - cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust -shared=../../types - cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types - go mod tidy -C plugins/pdk/go -.PHONY: gen +api: check_go_env api/openapi.yaml + go generate ./server/api/... +.PHONY: api + +spec_parts=$(shell find api -name '*.yml') +api/openapi.yaml: $(spec_parts) + @echo "Bundling OpenAPI spec..." + npx @redocly/cli bundle api/spec.yml -o api/openapi.yaml snapshots: ##@Development Update (GoLang) Snapshot tests - UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/... + UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/v2/ginkgo@latest ./server/subsonic/... .PHONY: snapshots migration-sql: ##@Development Create an empty SQL migration file @if [ -z "${name}" ]; then echo "Usage: make migration-sql name=name_of_migration_file"; exit 1; fi - go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migrations create ${name} sql + go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migration create ${name} sql .PHONY: migration migration-go: ##@Development Create an empty Go migration file @if [ -z "${name}" ]; then echo "Usage: make migration-go name=name_of_migration_file"; exit 1; fi - go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migrations create ${name} + go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migration create ${name} .PHONY: migration setup-dev: setup @@ -187,134 +85,53 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push) @(cd .git/hooks && ln -sf ../../git/* .) .PHONY: setup-git -build: check_go_env buildjs ##@Build Build the project - go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS) -.PHONY: build - -buildall: deprecated build +buildall: buildjs build ##@Build Build the project, both frontend and backend .PHONY: buildall -debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on) - go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS) -.PHONY: debug-build +build: warning-noui-build check_go_env ##@Build Build only backend + go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo +.PHONY: build -buildjs: check_node_env ui/build/index.html ##@Build Build only frontend +buildjs: check_node_env ##@Build Build only frontend + @(cd ./ui && npm run build) .PHONY: buildjs -docker-buildjs: ##@Build Build only frontend using Docker - docker build --output "./ui" --target ui-bundle . -.PHONY: docker-buildjs +all: warning-noui-build ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend + docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \ + goreleaser release --rm-dist --skip-publish --snapshot +.PHONY: all -ui/build/index.html: $(UI_SRC_FILES) - @(cd ./ui && npm run build) +single: warning-noui-build ##@Cross_Compilation Build binaries for a single supported platforms. It does not build the frontend + @if [ -z "${GOOS}" -o -z "${GOARCH}" ]; then \ + echo "Usage: GOOS= GOARCH= make single"; \ + echo "Options:"; \ + grep -- "- id: navidrome_" .goreleaser.yml | sed 's/- id: navidrome_//g'; \ + exit 1; \ + fi + @echo "Building binaries for ${GOOS}/${GOARCH}" + docker run -t -v $(PWD):/workspace -e GOOS -e GOARCH -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \ + goreleaser build --rm-dist --snapshot --single-target --id navidrome_${GOOS}_${GOARCH} +.PHONY: single -docker-platforms: ##@Cross_Compilation List supported platforms - @echo "Supported platforms:" - @echo "$(SUPPORTED_PLATFORMS)" | tr ',' '\n' | sort | sed 's/^/ /' - @echo "\nUsage: make PLATFORMS=\"linux/amd64\" docker-build" - @echo " make IMAGE_PLATFORMS=\"linux/amd64\" docker-image" -.PHONY: docker-platforms - -docker-build: ##@Cross_Compilation Cross-compile for any supported platform (check `make docker-platforms`) - docker buildx build \ - --platform $(PLATFORMS) \ - --build-arg GIT_TAG=${GIT_TAG} \ - --build-arg GIT_SHA=${GIT_SHA} \ - --output "./binaries" --target binary . -.PHONY: docker-build - -docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms - @echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true - @echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true - @echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true - docker buildx build \ - --platform $(IMAGE_PLATFORMS) \ - --build-arg GIT_TAG=${GIT_TAG} \ - --build-arg GIT_SHA=${GIT_SHA} \ - --tag $(DOCKER_TAG) . -.PHONY: docker-image - -docker-msi: ##@Cross_Compilation Build MSI installer for Windows - make docker-build PLATFORMS=windows/386,windows/amd64 - DOCKER_CLI_HINTS=false docker build -q -t navidrome-msi-builder -f release/wix/msitools.dockerfile . - @rm -rf binaries/msi - docker run -it --rm -v $(PWD):/workspace -v $(PWD)/binaries:/workspace/binaries -e GIT_TAG=${GIT_TAG} \ - navidrome-msi-builder sh -c "release/wix/build_msi.sh /workspace 386 && release/wix/build_msi.sh /workspace amd64" - @du -h binaries/msi/*.msi -.PHONY: docker-msi - -docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag= - @if [ -z "$(tag)" ]; then echo "Usage: make docker-run tag="; exit 1; fi - @TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \ - VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \ - if [ -f navidrome.toml ]; then \ - VOLUMES="$$VOLUMES -v $(PWD)/navidrome.toml:/data/navidrome.toml:ro"; \ - MUSIC_FOLDER=$$(grep '^MusicFolder' navidrome.toml | head -n1 | sed 's/.*= *"//' | sed 's/".*//'); \ - if [ -n "$$MUSIC_FOLDER" ] && [ -d "$$MUSIC_FOLDER" ]; then \ - VOLUMES="$$VOLUMES -v $$MUSIC_FOLDER:/music:ro"; \ - fi; \ - fi; \ - echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag) -.PHONY: docker-run - -package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms - @if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi - goreleaser release -f release/goreleaser.yml --clean --skip=publish --snapshot -.PHONY: package +warning-noui-build: + @echo "WARNING: This command does not build the frontend, it uses the latest built with 'make buildjs'" +.PHONY: warning-noui-build get-music: ##@Development Download some free music from Navidrome's demo instance mkdir -p music ( cd music; \ - curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=2Y3qQA6zJC3ObbBrF9ZBoV" > brock.zip; \ - curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=04HrSORpypcLGNUdQp37gn" > back_on_earth.zip; \ - curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=5xcMPJdeEgNrGtnzYbzAqb" > ugress.zip; \ - curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=1jjQMAZrG3lUsJ0YH6ZRS0" > voodoocuts.zip; \ + curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=ec2093ec4801402f1e17cc462195cdbb" > brock.zip; \ + curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \ + curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \ + curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \ for file in *.zip; do unzip -n $${file}; done ) @echo "Done. Remember to set your MusicFolder to ./music" .PHONY: get-music -########################################## -#### Worktrees - -WORKTREES_DIR := .worktrees - -wt: check_go_env ##@Worktrees Create and setup a git worktree. Usage: make wt name=feature-name [go=1] - @if [ -z "${name}" ]; then echo "Usage: make wt name= [go=1]"; exit 1; fi - @mkdir -p $(WORKTREES_DIR) - @echo "Creating worktree for branch '${name}'..." - @git worktree add $(WORKTREES_DIR)/${name} -b ${name} 2>/dev/null || \ - git worktree add $(WORKTREES_DIR)/${name} ${name} - @if [ -n "${go}" ]; then \ - ./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name} --go-only; \ - else \ - ./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name}; \ - fi - @echo "\nWorktree ready at $(WORKTREES_DIR)/${name}" - @echo " cd $(WORKTREES_DIR)/${name}" -.PHONY: wt - -rm-wt: ##@Worktrees Remove a git worktree. Usage: make rm-wt name=feature-name - @if [ -z "${name}" ]; then echo "Usage: make rm-wt name="; exit 1; fi - @if [ ! -d "$(WORKTREES_DIR)/${name}" ]; then echo "Worktree '${name}' not found in $(WORKTREES_DIR)/"; exit 1; fi - @echo "Removing worktree '${name}'..." - @git worktree remove --force $(WORKTREES_DIR)/${name} - @echo "Worktree '${name}' removed." - @echo "Note: branch '${name}' still exists. Delete it with: git branch -D ${name}" -.PHONY: rm-wt - -ls-wt: ##@Worktrees List all active git worktrees - @git worktree list -.PHONY: ls-wt - ########################################## #### Miscellaneous -clean: - @rm -rf ./binaries ./dist ./ui/build/* - @touch ./ui/build/.gitkeep -.PHONY: clean - release: @if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi go mod tidy @@ -354,10 +171,6 @@ check_node_env: pre-push: lintall testall .PHONY: pre-push -deprecated: - @echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead." -.PHONY: deprecated - .DEFAULT_GOAL := help HELP_FUN = \ diff --git a/Procfile.dev b/Procfile.dev index 0c187e811..5af64f49b 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,2 +1,2 @@ JS: sh -c "cd ./ui && npm start" -GO: go tool reflex -d none -c reflex.conf +GO: go run github.com/cespare/reflex@latest -d none -c reflex.conf diff --git a/README.md b/README.md index 4bc85e6a6..e056b24ac 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ [![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF) [![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md) -[![Gurubase](https://img.shields.io/badge/Gurubase-Ask%20Navidrome%20Guru-006BFF?style=flat-square)](https://gurubase.io/g/navidrome) Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your music collection from any browser or mobile device. It's like your personal Spotify! @@ -52,21 +51,11 @@ A share of the revenue helps fund the development of Navidrome at no additional - **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided - Ready to use binaries for all major platforms, including **Raspberry Pi** - Automatically **monitors your library** for changes, importing new files and reloading new metadata - - Supports **lyrics** from sidecar .ttml, .yaml/.yml Lyricsfile, .elrc, .lrc, .srt, .txt files and embedded TTML, Enhanced LRC, LRC, SRT, and plain-text tags (via `lyricspriority`) - **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com) - **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps) - **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported** - Translated to **various languages** -## Translations - -Navidrome uses [POEditor](https://poeditor.com/) for translations, and we are always looking -for [more contributors](https://www.navidrome.org/docs/developers/translations/) - - - - - ## Documentation All documentation can be found in the project's website: https://www.navidrome.org/docs. Here are some useful direct links: diff --git a/adapters/deezer/client.go b/adapters/deezer/client.go deleted file mode 100644 index 03f37af19..000000000 --- a/adapters/deezer/client.go +++ /dev/null @@ -1,239 +0,0 @@ -package deezer - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - - "github.com/microcosm-cc/bluemonday" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/log" -) - -const apiBaseURL = "https://api.deezer.com" -const authBaseURL = "https://auth.deezer.com" - -// errCodeQuota is Deezer's "Quota limit exceeded"; it arrives in the body, with HTTP 200 -// and no rate-limit headers, so the body code is the only signal. -const errCodeQuota = 4 - -type deezerError struct { - Type string `json:"type"` - Message string `json:"message"` - Code int `json:"code"` -} - -func (e *deezerError) Error() string { - return fmt.Sprintf("deezer error(%d): %s", e.Code, e.Message) -} - -type httpDoer interface { - Do(req *http.Request) (*http.Response, error) -} - -type client struct { - httpDoer httpDoer - jwt jwtToken -} - -func newClient(hc httpDoer) *client { - return &client{ - httpDoer: hc, - } -} - -func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) { - params := url.Values{} - params.Add("q", name) - params.Add("order", "RANKING") - params.Add("limit", strconv.Itoa(limit)) - req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil) - if err != nil { - return nil, err - } - req.URL.RawQuery = params.Encode() - - var results SearchArtistResults - err = c.makeRequest(req, &results) - if err != nil { - return nil, err - } - - if len(results.Data) == 0 { - return nil, agents.ErrNotFound - } - return results.Data, nil -} - -func (c *client) makeRequest(req *http.Request, response any) error { - log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL) - resp, err := c.httpDoer.Do(req) - if err != nil { - return err - } - - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - // Checked before the status: a throttled request still answers 200, and decoding its body - // into a result type yields an empty one, which reads as "nothing found". - if err := parseBodyError(data); err != nil { - return err - } - if resp.StatusCode != 200 { - return fmt.Errorf("deezer http status: (%d)", resp.StatusCode) - } - - return json.Unmarshal(data, response) -} - -// parseBodyError returns the error Deezer reported in the body, or nil when it reported none. -func parseBodyError(data []byte) error { - var body errorResponse - // Discarded: a payload that is not an error object leaves Error nil, which is the "none" answer. - _ = json.Unmarshal(data, &body) - switch { - case body.Error == nil: - return nil - case body.Error.Code == errCodeQuota: - return errors.Join(body.Error, agents.ErrRetryLater) - default: - return body.Error - } -} - -func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) { - req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/related", apiBaseURL, artistID), nil) - if err != nil { - return nil, err - } - - var results RelatedArtists - err = c.makeRequest(req, &results) - if err != nil { - return nil, err - } - - return results.Data, nil -} - -func (c *client) getTopTracks(ctx context.Context, artistID int, limit int) ([]Track, error) { - params := url.Values{} - params.Add("limit", strconv.Itoa(limit)) - req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/top", apiBaseURL, artistID), nil) - if err != nil { - return nil, err - } - req.URL.RawQuery = params.Encode() - - var results TopTracks - err = c.makeRequest(req, &results) - if err != nil { - return nil, err - } - - return results.Data, nil -} - -const pipeAPIURL = "https://pipe.deezer.com/api" - -var strictPolicy = bluemonday.StrictPolicy() - -func (c *client) getArtistBio(ctx context.Context, artistID int, lang string) (string, error) { - jwt, err := c.getJWT(ctx) - if err != nil { - return "", fmt.Errorf("deezer: failed to get JWT: %w", err) - } - - query := map[string]any{ - "operationName": "ArtistBio", - "variables": map[string]any{ - "artistId": strconv.Itoa(artistID), - }, - "query": `query ArtistBio($artistId: String!) { - artist(artistId: $artistId) { - bio { - full - } - } - }`, - } - - body, err := json.Marshal(query) - if err != nil { - return "", err - } - - req, err := http.NewRequestWithContext(ctx, "POST", pipeAPIURL, bytes.NewReader(body)) - if err != nil { - return "", err - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept-Language", lang) - req.Header.Set("Authorization", "Bearer "+jwt) - - log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", lang) - resp, err := c.httpDoer.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("deezer: failed to fetch biography: %s", resp.Status) - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - type graphQLResponse struct { - Data struct { - Artist struct { - Bio struct { - Full string `json:"full"` - } `json:"bio"` - } `json:"artist"` - } `json:"data"` - Errors []struct { - Message string `json:"message"` - } - } - - var result graphQLResponse - if err := json.Unmarshal(data, &result); err != nil { - return "", fmt.Errorf("deezer: failed to parse GraphQL response: %w", err) - } - - if len(result.Errors) > 0 { - var errs []error - for m := range result.Errors { - errs = append(errs, errors.New(result.Errors[m].Message)) - } - err := errors.Join(errs...) - return "", fmt.Errorf("deezer: GraphQL error: %w", err) - } - - if result.Data.Artist.Bio.Full == "" { - return "", errors.New("deezer: biography not found") - } - - return cleanBio(result.Data.Artist.Bio.Full), nil -} - -func cleanBio(bio string) string { - bio = strings.ReplaceAll(bio, "

", "\n") - return strictPolicy.Sanitize(bio) -} diff --git a/adapters/deezer/client_auth.go b/adapters/deezer/client_auth.go deleted file mode 100644 index eb664c00b..000000000 --- a/adapters/deezer/client_auth.go +++ /dev/null @@ -1,101 +0,0 @@ -package deezer - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "sync" - "time" - - "github.com/lestrrat-go/jwx/v3/jwt" - "github.com/navidrome/navidrome/log" -) - -type jwtToken struct { - token string - expiresAt time.Time - mu sync.RWMutex -} - -func (j *jwtToken) get() (string, bool) { - j.mu.RLock() - defer j.mu.RUnlock() - if time.Now().Before(j.expiresAt) { - return j.token, true - } - return "", false -} - -func (j *jwtToken) set(token string, expiresIn time.Duration) { - j.mu.Lock() - defer j.mu.Unlock() - j.token = token - j.expiresAt = time.Now().Add(expiresIn) -} - -func (c *client) getJWT(ctx context.Context) (string, error) { - // Check if we have a valid cached token - if token, valid := c.jwt.get(); valid { - return token, nil - } - - // Fetch a new anonymous token - req, err := http.NewRequestWithContext(ctx, "GET", authBaseURL+"/login/anonymous?jo=p&rto=c", nil) - if err != nil { - return "", err - } - req.Header.Set("Accept", "application/json") - - resp, err := c.httpDoer.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", fmt.Errorf("deezer: failed to get JWT token: %s", resp.Status) - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - type authResponse struct { - JWT string `json:"jwt"` //nolint:gosec - } - - var result authResponse - if err := json.Unmarshal(data, &result); err != nil { - return "", fmt.Errorf("deezer: failed to parse auth response: %w", err) - } - - if result.JWT == "" { - return "", errors.New("deezer: no JWT token in response") - } - - // Parse JWT to get actual expiration time - token, err := jwt.ParseString(result.JWT, jwt.WithVerify(false), jwt.WithValidate(false)) - if err != nil { - return "", fmt.Errorf("deezer: failed to parse JWT token: %w", err) - } - - // Calculate TTL with a 1-minute buffer for clock skew and network delays - expiresAt, ok := token.Expiration() - if !ok || expiresAt.IsZero() { - return "", errors.New("deezer: JWT token has no expiration time") - } - - ttl := time.Until(expiresAt) - 1*time.Minute - if ttl <= 0 { - return "", errors.New("deezer: JWT token already expired or expires too soon") - } - - c.jwt.set(result.JWT, ttl) - log.Trace(ctx, "Fetched new Deezer JWT token", "expiresAt", expiresAt, "ttl", ttl) - - return result.JWT, nil -} diff --git a/adapters/deezer/client_auth_test.go b/adapters/deezer/client_auth_test.go deleted file mode 100644 index 005a84e1a..000000000 --- a/adapters/deezer/client_auth_test.go +++ /dev/null @@ -1,294 +0,0 @@ -package deezer - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "sync" - "time" - - "github.com/lestrrat-go/jwx/v3/jwt" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("JWT Authentication", func() { - var httpClient *fakeHttpClient - var client *client - var ctx context.Context - - BeforeEach(func() { - httpClient = &fakeHttpClient{} - client = newClient(httpClient) - ctx = context.Background() - }) - - Describe("getJWT", func() { - Context("with a valid JWT response", func() { - It("successfully fetches and caches a JWT token", func() { - testJWT := createTestJWT(5 * time.Minute) - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))), - }) - - token, err := client.getJWT(ctx) - Expect(err).To(BeNil()) - Expect(token).To(Equal(testJWT)) - }) - - It("returns the cached token on subsequent calls", func() { - testJWT := createTestJWT(5 * time.Minute) - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))), - }) - - // First call should fetch from API - token1, err := client.getJWT(ctx) - Expect(err).To(BeNil()) - Expect(token1).To(Equal(testJWT)) - Expect(httpClient.lastRequest.URL.Path).To(Equal("/login/anonymous")) - - // Second call should return cached token without hitting API - httpClient.lastRequest = nil // Clear last request to verify no new request is made - token2, err := client.getJWT(ctx) - Expect(err).To(BeNil()) - Expect(token2).To(Equal(testJWT)) - Expect(httpClient.lastRequest).To(BeNil()) // No new request made - }) - - It("parses the JWT expiration time correctly", func() { - expectedExpiration := time.Now().Add(5 * time.Minute) - testToken, err := jwt.NewBuilder(). - Expiration(expectedExpiration). - Build() - Expect(err).To(BeNil()) - testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature()) - Expect(err).To(BeNil()) - - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))), - }) - - token, err := client.getJWT(ctx) - Expect(err).To(BeNil()) - Expect(token).ToNot(BeEmpty()) - - // Verify the token is cached until close to expiration - // The cache should expire 1 minute before the JWT expires - expectedCacheExpiry := expectedExpiration.Add(-1 * time.Minute) - Expect(client.jwt.expiresAt).To(BeTemporally("~", expectedCacheExpiry, 2*time.Second)) - }) - }) - - Context("with JWT tokens that expire soon", func() { - It("rejects tokens that expire in less than 1 minute", func() { - // Create a token that expires in 30 seconds (less than 1-minute buffer) - testJWT := createTestJWT(30 * time.Second) - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))), - }) - - _, err := client.getJWT(ctx) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon")) - }) - - It("rejects already expired tokens", func() { - // Create a token that expired 1 minute ago - testJWT := createTestJWT(-1 * time.Minute) - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))), - }) - - _, err := client.getJWT(ctx) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon")) - }) - - It("accepts tokens that expire in more than 1 minute", func() { - // Create a token that expires in 2 minutes (just over the 1-minute buffer) - testJWT := createTestJWT(2 * time.Minute) - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))), - }) - - token, err := client.getJWT(ctx) - Expect(err).To(BeNil()) - Expect(token).ToNot(BeEmpty()) - }) - }) - - Context("with invalid responses", func() { - It("handles HTTP error responses", func() { - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 500, - Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)), - }) - - _, err := client.getJWT(ctx) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to get JWT token")) - }) - - It("handles malformed JSON responses", func() { - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{invalid json}`)), - }) - - _, err := client.getJWT(ctx) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to parse auth response")) - }) - - It("handles responses with empty JWT field", func() { - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"jwt":""}`)), - }) - - _, err := client.getJWT(ctx) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(Equal("deezer: no JWT token in response")) - }) - - It("handles invalid JWT tokens", func() { - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"jwt":"not-a-valid-jwt"}`)), - }) - - _, err := client.getJWT(ctx) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to parse JWT token")) - }) - - It("rejects JWT tokens without expiration", func() { - // Create a JWT without expiration claim - testToken, err := jwt.NewBuilder(). - Claim("custom", "value"). - Build() - Expect(err).To(BeNil()) - - // Verify token has no expiration - _, hasExp := testToken.Expiration() - Expect(hasExp).To(BeFalse()) - - testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature()) - Expect(err).To(BeNil()) - - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))), - }) - - _, err = client.getJWT(ctx) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(Equal("deezer: JWT token has no expiration time")) - }) - }) - - Context("token caching behavior", func() { - It("fetches a new token when the cached token expires", func() { - // First token expires in 5 minutes - firstJWT := createTestJWT(5 * time.Minute) - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, firstJWT))), - }) - - token1, err := client.getJWT(ctx) - Expect(err).To(BeNil()) - Expect(token1).To(Equal(firstJWT)) - - // Manually expire the cached token - client.jwt.expiresAt = time.Now().Add(-1 * time.Second) - - // Second token with different expiration (10 minutes) - secondJWT := createTestJWT(10 * time.Minute) - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, secondJWT))), - }) - - token2, err := client.getJWT(ctx) - Expect(err).To(BeNil()) - Expect(token2).To(Equal(secondJWT)) - Expect(token2).ToNot(Equal(token1)) - }) - }) - }) - - Describe("jwtToken cache", func() { - var cache *jwtToken - - BeforeEach(func() { - cache = &jwtToken{} - }) - - It("returns false for expired tokens", func() { - cache.set("test-token", -1*time.Second) // Already expired - token, valid := cache.get() - Expect(valid).To(BeFalse()) - Expect(token).To(BeEmpty()) - }) - - It("returns true for valid tokens", func() { - cache.set("test-token", 4*time.Minute) - token, valid := cache.get() - Expect(valid).To(BeTrue()) - Expect(token).To(Equal("test-token")) - }) - - It("is thread-safe for concurrent access", func() { - wg := sync.WaitGroup{} - - // Writer goroutine - wg.Go(func() { - for i := range 100 { - cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour) - time.Sleep(1 * time.Millisecond) - } - }) - - // Reader goroutine - wg.Go(func() { - for range 100 { - cache.get() - time.Sleep(1 * time.Millisecond) - } - }) - - // Wait for both goroutines to complete - wg.Wait() - - // Verify final state is valid - token, valid := cache.get() - Expect(valid).To(BeTrue()) - Expect(token).To(HavePrefix("token-")) - }) - }) -}) - -// createTestJWT creates a valid JWT token for testing purposes -func createTestJWT(expiresIn time.Duration) string { - token, err := jwt.NewBuilder(). - Expiration(time.Now().Add(expiresIn)). - Build() - if err != nil { - panic(fmt.Sprintf("failed to create test JWT: %v", err)) - } - signed, err := jwt.Sign(token, jwt.WithInsecureNoSignature()) - if err != nil { - panic(fmt.Sprintf("failed to sign test JWT: %v", err)) - } - return string(signed) -} diff --git a/adapters/deezer/client_test.go b/adapters/deezer/client_test.go deleted file mode 100644 index 84d981a76..000000000 --- a/adapters/deezer/client_test.go +++ /dev/null @@ -1,242 +0,0 @@ -package deezer - -import ( - "bytes" - "errors" - "fmt" - "io" - "net/http" - "os" - "time" - - "github.com/navidrome/navidrome/core/agents" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("client", func() { - var httpClient *fakeHttpClient - var client *client - - BeforeEach(func() { - httpClient = &fakeHttpClient{} - client = newClient(httpClient) - }) - - Describe("ArtistImages", func() { - It("returns artist images from a successful request", func() { - f, err := os.Open("tests/fixtures/deezer.search.artist.json") - Expect(err).To(BeNil()) - httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200}) - - artists, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) - Expect(err).To(BeNil()) - Expect(artists).To(HaveLen(17)) - Expect(artists[0].Name).To(Equal("Michael Jackson")) - Expect(artists[0].PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg")) - }) - - It("fails if artist was not found", func() { - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)), - }) - - _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) - Expect(err).To(MatchError(agents.ErrNotFound)) - }) - - // Deezer answers 200 with no rate-limit headers when throttling, so this body is the only signal. - It("reports an exhausted quota as a retryable error, not as a missing artist", func() { - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString( - `{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)), - }) - - _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) - Expect(err).To(HaveOccurred()) - Expect(err).ToNot(MatchError(agents.ErrNotFound), - "a throttled lookup would otherwise settle the artist as having no image") - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - Expect(err.Error()).To(ContainSubstring("Quota limit exceeded")) - }) - - It("reports a non-quota body error as a plain error", func() { - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString( - `{"error":{"type":"Exception","message":"Invalid query","code":100}}`)), - }) - - _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20) - Expect(err).To(HaveOccurred()) - Expect(err).ToNot(MatchError(agents.ErrNotFound)) - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse(), - "only a throttle asks the caller to come back later") - }) - }) - - Describe("TopTracks", func() { - It("returns top tracks with artist and album info from a successful request", func() { - f, err := os.Open("tests/fixtures/deezer.artist.top.json") - Expect(err).To(BeNil()) - httpClient.mock("https://api.deezer.com/artist/27/top", http.Response{Body: f, StatusCode: 200}) - - tracks, err := client.getTopTracks(GinkgoT().Context(), 27, 5) - Expect(err).To(BeNil()) - Expect(tracks).To(HaveLen(5)) - - // Verify first track has all expected fields - Expect(tracks[0].Title).To(Equal("Instant Crush (feat. Julian Casablancas)")) - Expect(tracks[0].Artist.Name).To(Equal("Daft Punk")) - Expect(tracks[0].Album.Title).To(Equal("Random Access Memories")) - - // Verify second track - Expect(tracks[1].Title).To(Equal("One More Time")) - Expect(tracks[1].Artist.Name).To(Equal("Daft Punk")) - Expect(tracks[1].Album.Title).To(Equal("Discovery")) - }) - }) - - Describe("ArtistBio", func() { - BeforeEach(func() { - // Mock the JWT token endpoint with a valid JWT that expires in 5 minutes - testJWT := createTestJWT(5 * time.Minute) - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))), - }) - }) - - It("returns artist bio from a successful request", func() { - f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json") - Expect(err).To(BeNil()) - httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) - - bio, err := client.getArtistBio(GinkgoT().Context(), 27, "en") - Expect(err).To(BeNil()) - Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel")) - Expect(bio).ToNot(ContainSubstring("

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

")) - }) - - It("uses the provided language", func() { - f, err := os.Open("tests/fixtures/deezer.artist.bio.fr.json") - Expect(err).To(BeNil()) - httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) - - _, err = client.getArtistBio(GinkgoT().Context(), 27, "fr") - Expect(err).To(BeNil()) - Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr")) - }) - - It("includes the JWT token in the request", func() { - f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json") - Expect(err).To(BeNil()) - httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) - - _, err = client.getArtistBio(GinkgoT().Context(), 27, "en") - Expect(err).To(BeNil()) - // Verify that the Authorization header has the Bearer token format - authHeader := httpClient.lastRequest.Header.Get("Authorization") - Expect(authHeader).To(HavePrefix("Bearer ")) - Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars - }) - - It("handles GraphQL errors", func() { - errorResponse := `{ - "data": { - "artist": { - "bio": { - "full": "" - } - } - }, - "errors": [ - { - "message": "Artist not found" - }, - { - "message": "Invalid artist ID" - } - ] - }` - httpClient.mock("https://pipe.deezer.com/api", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(errorResponse)), - }) - - _, err := client.getArtistBio(GinkgoT().Context(), 999, "en") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("GraphQL error")) - Expect(err.Error()).To(ContainSubstring("Artist not found")) - Expect(err.Error()).To(ContainSubstring("Invalid artist ID")) - }) - - It("handles empty biography", func() { - emptyBioResponse := `{ - "data": { - "artist": { - "bio": { - "full": "" - } - } - } - }` - httpClient.mock("https://pipe.deezer.com/api", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)), - }) - - _, err := client.getArtistBio(GinkgoT().Context(), 27, "en") - Expect(err).To(MatchError("deezer: biography not found")) - }) - - It("handles JWT token fetch failure", func() { - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 500, - Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)), - }) - - _, err := client.getArtistBio(GinkgoT().Context(), 27, "en") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to get JWT")) - }) - - It("handles JWT token that expires too soon", func() { - // Create a JWT that expires in 30 seconds (less than the 1-minute buffer) - expiredJWT := createTestJWT(30 * time.Second) - httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))), - }) - - _, err := client.getArtistBio(GinkgoT().Context(), 27, "en") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon")) - }) - }) -}) - -type fakeHttpClient struct { - responses map[string]*http.Response - lastRequest *http.Request -} - -func (c *fakeHttpClient) mock(url string, response http.Response) { - if c.responses == nil { - c.responses = make(map[string]*http.Response) - } - c.responses[url] = &response -} - -func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) { - c.lastRequest = req - u := req.URL - u.RawQuery = "" - if resp, ok := c.responses[u.String()]; ok { - return resp, nil - } - panic("URL not mocked: " + u.String()) -} diff --git a/adapters/deezer/deezer.go b/adapters/deezer/deezer.go deleted file mode 100644 index d3570a29f..000000000 --- a/adapters/deezer/deezer.go +++ /dev/null @@ -1,199 +0,0 @@ -package deezer - -import ( - "cmp" - "context" - "errors" - "fmt" - "slices" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/cache" - "github.com/navidrome/navidrome/utils/httpclient" - "github.com/navidrome/navidrome/utils/slice" -) - -const deezerAgentName = "deezer" -const deezerApiPictureXlSize = 1000 -const deezerApiPictureBigSize = 500 -const deezerApiPictureMediumSize = 250 -const deezerApiPictureSmallSize = 56 -const deezerArtistSearchLimit = 50 - -type deezerAgent struct { - dataStore model.DataStore - client *client - languages []string -} - -func deezerConstructor(dataStore model.DataStore) agents.Interface { - agent := &deezerAgent{ - dataStore: dataStore, - languages: conf.Server.Deezer.Languages, - } - httpClient := httpclient.New(consts.DefaultHttpClientTimeOut) - cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut) - agent.client = newClient(cachedHttpClient) - return agent -} - -func (s *deezerAgent) AgentName() string { - return deezerAgentName -} - -func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([]agents.ExternalImage, error) { - artist, err := s.searchArtist(ctx, name) - if err != nil { - if errors.Is(err, agents.ErrNotFound) { - log.Warn(ctx, "Artist not found in deezer", "artist", name) - } else { - log.Error(ctx, "Error calling deezer", "artist", name, err) - } - return nil, err - } - - var res []agents.ExternalImage - possibleImages := []struct { - URL string - Size int - }{ - {artist.PictureXl, deezerApiPictureXlSize}, - {artist.PictureBig, deezerApiPictureBigSize}, - {artist.PictureMedium, deezerApiPictureMediumSize}, - {artist.PictureSmall, deezerApiPictureSmallSize}, - } - for _, imgData := range possibleImages { - if imgData.URL != "" && !isPlaceholderPicture(imgData.URL) { - res = append(res, agents.ExternalImage{ - URL: imgData.URL, - Size: imgData.Size, - }) - } - } - if len(res) == 0 { - return nil, agents.ErrNotFound - } - return res, nil -} - -// deezerEmptyPicturePath is Deezer's empty-image-id path shape for artists with no picture -// (…/images/artist//1000x1000-…), which serves a generic silhouette on any CDN host. -const deezerEmptyPicturePath = "/images/artist//" - -func isPlaceholderPicture(url string) bool { - return strings.Contains(url, deezerEmptyPicturePath) -} - -func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) { - artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit) - if err != nil { - return nil, err - } - - log.Trace(ctx, "Artists found", "count", len(artists), "searched_name", name) - for i := range artists { - log.Trace(ctx, fmt.Sprintf("Artists found #%d", i), "name", artists[i].Name, "id", artists[i].ID, "link", artists[i].Link) - if i > 2 { - break - } - } - - // Deezer's RANKING order isn't reliable for homonyms: rank name matches - // ahead of non-matches, prefer an exact-case match, then the most fans. - rank := func(a Artist) int { - switch { - case a.Name == name: - return 2 - case strings.EqualFold(a.Name, name): - return 1 - default: - return 0 - } - } - slices.SortFunc(artists, func(a, b Artist) int { - return cmp.Or( - cmp.Compare(rank(b), rank(a)), - cmp.Compare(b.NbFan, a.NbFan), - cmp.Compare(a.ID, b.ID), - ) - }) - best := artists[0] - if !strings.EqualFold(best.Name, name) { - log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name) - return nil, agents.ErrNotFound - } - log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan) - return new(best), nil -} - -func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) { - artist, err := s.searchArtist(ctx, name) - if err != nil { - return nil, err - } - - related, err := s.client.getRelatedArtists(ctx, artist.ID) - if err != nil { - return nil, err - } - - res := slice.Map(related, func(r Artist) agents.Artist { - return agents.Artist{ - Name: r.Name, - } - }) - if len(res) > limit { - res = res[:limit] - } - return res, nil -} - -func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) { - artist, err := s.searchArtist(ctx, artistName) - if err != nil { - return nil, err - } - - tracks, err := s.client.getTopTracks(ctx, artist.ID, count) - if err != nil { - return nil, err - } - - res := slice.Map(tracks, func(r Track) agents.Song { - return agents.Song{ - Name: r.Title, - Album: r.Album.Title, - Duration: uint32(r.Duration * 1000), // Convert seconds to milliseconds - } - }) - return res, nil -} - -func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) { - artist, err := s.searchArtist(ctx, name) - if err != nil { - return "", err - } - - for _, lang := range s.languages { - bio, err := s.client.getArtistBio(ctx, artist.ID, lang) - if err == nil && bio != "" { - return bio, nil - } - log.Debug(ctx, "Deezer/artist.bio returned empty/error, trying next language", "artist", name, "lang", lang, err) - } - return "", agents.ErrNotFound -} - -func init() { - conf.AddHook(func() { - if conf.Server.Deezer.Enabled { - agents.Register(deezerAgentName, deezerConstructor) - } - }) -} diff --git a/adapters/deezer/deezer_test.go b/adapters/deezer/deezer_test.go deleted file mode 100644 index 82d02c244..000000000 --- a/adapters/deezer/deezer_test.go +++ /dev/null @@ -1,296 +0,0 @@ -package deezer - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "net/http" - "os" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("deezerAgent", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - DeferCleanup(configtest.SetupConfig()) - conf.Server.Deezer.Enabled = true - }) - - Describe("deezerConstructor", func() { - It("uses configured languages", func() { - conf.Server.Deezer.Languages = []string{"pt", "en"} - agent := deezerConstructor(&tests.MockDataStore{}).(*deezerAgent) - Expect(agent.languages).To(Equal([]string{"pt", "en"})) - }) - }) - - Describe("searchArtist", func() { - var agent *deezerAgent - var httpClient *fakeHttpClient - - BeforeEach(func() { - httpClient = &fakeHttpClient{} - agent = &deezerAgent{ - dataStore: &tests.MockDataStore{}, - client: newClient(httpClient), - } - }) - - It("picks the exact-name match with the most fans when several share the name", func() { - // Deezer RANKING order returns a low-popularity homonym first (see issue #5802) - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"data":[ - {"id":61045802,"name":"Queen","nb_fan":75}, - {"id":141954732,"name":"Queen","nb_fan":397}, - {"id":135041032,"name":"Queen(Ares)","nb_fan":133}, - {"id":183179807,"name":"Queen","nb_fan":53}, - {"id":412,"name":"Queen","nb_fan":12744378} - ],"total":5}`)), - }) - - artist, err := agent.searchArtist(ctx, "Queen") - - Expect(err).ToNot(HaveOccurred()) - Expect(artist.ID).To(Equal(412)) - }) - - It("matches the name case-insensitively", func() { - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"data":[ - {"id":1,"name":"QUEEN","nb_fan":10}, - {"id":2,"name":"queen","nb_fan":20} - ],"total":2}`)), - }) - - artist, err := agent.searchArtist(ctx, "Queen") - - Expect(err).ToNot(HaveOccurred()) - Expect(artist.ID).To(Equal(2)) - }) - - // The artwork worker settles an artist as "no image" on agents.ErrNotFound, so a throttled - // lookup reaching that here would record a permanent absence. - It("surfaces an exhausted quota instead of reporting the artist as not found", func() { - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString( - `{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)), - }) - - _, err := agent.searchArtist(ctx, "Queen") - - Expect(err).To(HaveOccurred()) - Expect(err).ToNot(MatchError(agents.ErrNotFound)) - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - }) - - It("returns ErrNotFound when no result matches the name exactly", func() { - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"data":[ - {"id":1,"name":"Queens of the Stone Age","nb_fan":100} - ],"total":1}`)), - }) - - _, err := agent.searchArtist(ctx, "Queen") - - Expect(err).To(MatchError(agents.ErrNotFound)) - }) - }) - - Describe("GetArtistImages", func() { - var agent *deezerAgent - var httpClient *fakeHttpClient - - BeforeEach(func() { - httpClient = &fakeHttpClient{} - agent = &deezerAgent{ - dataStore: &tests.MockDataStore{}, - client: newClient(httpClient), - } - }) - - It("returns the real images when the artist has a picture", func() { - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"data":[ - {"id":412,"name":"Queen","nb_fan":12744378, - "picture_xl":"https://cdn-images.dzcdn.net/images/artist/abc/1000x1000-000000-80-0-0.jpg", - "picture_big":"https://cdn-images.dzcdn.net/images/artist/abc/500x500-000000-80-0-0.jpg"} - ],"total":1}`)), - }) - - images, err := agent.GetArtistImages(ctx, "", "Queen", "") - - Expect(err).ToNot(HaveOccurred()) - Expect(images).To(HaveLen(2)) - Expect(images[0].URL).To(ContainSubstring("1000x1000")) - }) - - It("returns ErrNotFound when the artist only has empty-id placeholder pictures", func() { - httpClient.mock("https://api.deezer.com/search/artist", http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"data":[ - {"id":412,"name":"Queen","nb_fan":12744378, - "picture_xl":"https://cdn-images.dzcdn.net/images/artist//1000x1000-000000-80-0-0.jpg", - "picture_big":"https://cdn-images.dzcdn.net/images/artist//500x500-000000-80-0-0.jpg", - "picture_medium":"https://cdn-images.dzcdn.net/images/artist//250x250-000000-80-0-0.jpg", - "picture_small":"https://cdn-images.dzcdn.net/images/artist//56x56-000000-80-0-0.jpg"} - ],"total":1}`)), - }) - - images, err := agent.GetArtistImages(ctx, "", "Queen", "") - - Expect(err).To(MatchError(agents.ErrNotFound)) - Expect(images).To(BeEmpty()) - }) - }) - - Describe("GetArtistBiography - Language Fallback", func() { - var agent *deezerAgent - var httpClient *langAwareHttpClient - - BeforeEach(func() { - httpClient = newLangAwareHttpClient() - - // Mock search artist (returns Michael Jackson) - fSearch, _ := os.Open("tests/fixtures/deezer.search.artist.json") - httpClient.searchResponse = &http.Response{Body: fSearch, StatusCode: 200} - - // Mock JWT token - testJWT := createTestJWT(5 * time.Minute) - httpClient.jwtResponse = &http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))), - } - }) - - setupAgent := func(languages []string) { - conf.Server.Deezer.Languages = languages - agent = &deezerAgent{ - dataStore: &tests.MockDataStore{}, - client: newClient(httpClient), - languages: languages, - } - } - - It("returns content in first language when available (1 bio API call)", func() { - setupAgent([]string{"fr", "en"}) - - // French biography available - fFr, _ := os.Open("tests/fixtures/deezer.artist.bio.fr.json") - httpClient.bioResponses["fr"] = &http.Response{Body: fFr, StatusCode: 200} - - bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "") - - Expect(err).ToNot(HaveOccurred()) - Expect(bio).To(ContainSubstring("Guy-Manuel de Homem Christo et Thomas Bangalter")) - Expect(httpClient.bioRequestCount).To(Equal(1)) - Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("fr")) - }) - - It("falls back to second language when first returns empty (2 bio API calls)", func() { - setupAgent([]string{"ja", "en"}) - - // Japanese returns empty biography - fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json") - httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200} - // English returns full biography - fEn, _ := os.Open("tests/fixtures/deezer.artist.bio.en.json") - httpClient.bioResponses["en"] = &http.Response{Body: fEn, StatusCode: 200} - - bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "") - - Expect(err).ToNot(HaveOccurred()) - Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel")) - Expect(httpClient.bioRequestCount).To(Equal(2)) - Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("ja")) - Expect(httpClient.bioRequests[1].Header.Get("Accept-Language")).To(Equal("en")) - }) - - It("returns ErrNotFound when all languages return empty", func() { - setupAgent([]string{"ja", "xx"}) - - // Both languages return empty biography - fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json") - httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200} - fXx, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json") - httpClient.bioResponses["xx"] = &http.Response{Body: fXx, StatusCode: 200} - - _, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "") - - Expect(err).To(MatchError(agents.ErrNotFound)) - Expect(httpClient.bioRequestCount).To(Equal(2)) - }) - }) -}) - -// langAwareHttpClient is a mock HTTP client that returns different responses based on the Accept-Language header -type langAwareHttpClient struct { - searchResponse *http.Response - jwtResponse *http.Response - bioResponses map[string]*http.Response - bioRequests []*http.Request - bioRequestCount int -} - -func newLangAwareHttpClient() *langAwareHttpClient { - return &langAwareHttpClient{ - bioResponses: make(map[string]*http.Response), - bioRequests: make([]*http.Request, 0), - } -} - -func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) { - // Handle search artist request - if req.URL.Host == "api.deezer.com" && req.URL.Path == "/search/artist" { - if c.searchResponse != nil { - return c.searchResponse, nil - } - return &http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)), - }, nil - } - - // Handle JWT token request - if req.URL.Host == "auth.deezer.com" && req.URL.Path == "/login/anonymous" { - if c.jwtResponse != nil { - return c.jwtResponse, nil - } - return &http.Response{ - StatusCode: 500, - Body: io.NopCloser(bytes.NewBufferString(`{"error":"no mock"}`)), - }, nil - } - - // Handle bio request (GraphQL API) - if req.URL.Host == "pipe.deezer.com" && req.URL.Path == "/api" { - c.bioRequestCount++ - c.bioRequests = append(c.bioRequests, req) - lang := req.Header.Get("Accept-Language") - if resp, ok := c.bioResponses[lang]; ok { - return resp, nil - } - // Return empty bio by default - return &http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{"data":{"artist":{"bio":{"full":""}}}}`)), - }, nil - } - - panic("URL not mocked: " + req.URL.String()) -} diff --git a/adapters/deezer/responses.go b/adapters/deezer/responses.go deleted file mode 100644 index 6cc95dd4b..000000000 --- a/adapters/deezer/responses.go +++ /dev/null @@ -1,62 +0,0 @@ -package deezer - -type SearchArtistResults struct { - Data []Artist `json:"data"` - Total int `json:"total"` - Next string `json:"next"` -} - -type Artist struct { - ID int `json:"id"` - Name string `json:"name"` - Link string `json:"link"` - Picture string `json:"picture"` - PictureSmall string `json:"picture_small"` - PictureMedium string `json:"picture_medium"` - PictureBig string `json:"picture_big"` - PictureXl string `json:"picture_xl"` - NbAlbum int `json:"nb_album"` - NbFan int `json:"nb_fan"` - Radio bool `json:"radio"` - Tracklist string `json:"tracklist"` - Type string `json:"type"` -} - -type errorResponse struct { - Error *deezerError `json:"error"` -} - -type RelatedArtists struct { - Data []Artist `json:"data"` - Total int `json:"total"` -} - -type TopTracks struct { - Data []Track `json:"data"` - Total int `json:"total"` - Next string `json:"next"` -} - -type Track struct { - ID int `json:"id"` - Title string `json:"title"` - Link string `json:"link"` - Duration int `json:"duration"` - Rank int `json:"rank"` - Preview string `json:"preview"` - Artist Artist `json:"artist"` - Album Album `json:"album"` - Contributors []Artist `json:"contributors"` -} - -type Album struct { - ID int `json:"id"` - Title string `json:"title"` - Cover string `json:"cover"` - CoverSmall string `json:"cover_small"` - CoverMedium string `json:"cover_medium"` - CoverBig string `json:"cover_big"` - CoverXl string `json:"cover_xl"` - Tracklist string `json:"tracklist"` - Type string `json:"type"` -} diff --git a/adapters/deezer/responses_test.go b/adapters/deezer/responses_test.go deleted file mode 100644 index 5a3fc7798..000000000 --- a/adapters/deezer/responses_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package deezer - -import ( - "encoding/json" - "os" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Responses", func() { - Describe("Search type=artist", func() { - It("parses the artist search result correctly ", func() { - var resp SearchArtistResults - body, err := os.ReadFile("tests/fixtures/deezer.search.artist.json") - Expect(err).To(BeNil()) - err = json.Unmarshal(body, &resp) - Expect(err).To(BeNil()) - - Expect(resp.Data).To(HaveLen(17)) - michael := resp.Data[0] - Expect(michael.Name).To(Equal("Michael Jackson")) - Expect(michael.PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg")) - }) - }) - - Describe("Error", func() { - It("parses the error response correctly", func() { - var errorResp errorResponse - body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`) - err := json.Unmarshal(body, &errorResp) - Expect(err).To(BeNil()) - - Expect(errorResp.Error.Code).To(Equal(501)) - Expect(errorResp.Error.Message).To(Equal("Missing parameters: q")) - }) - }) - - Describe("Related Artists", func() { - It("parses the related artists response correctly", func() { - var resp RelatedArtists - body, err := os.ReadFile("tests/fixtures/deezer.artist.related.json") - Expect(err).To(BeNil()) - err = json.Unmarshal(body, &resp) - Expect(err).To(BeNil()) - - Expect(resp.Data).To(HaveLen(20)) - justice := resp.Data[0] - Expect(justice.Name).To(Equal("Justice")) - Expect(justice.ID).To(Equal(6404)) - }) - }) - - Describe("Top Tracks", func() { - It("parses the top tracks response correctly", func() { - var resp TopTracks - body, err := os.ReadFile("tests/fixtures/deezer.artist.top.json") - Expect(err).To(BeNil()) - err = json.Unmarshal(body, &resp) - Expect(err).To(BeNil()) - - Expect(resp.Data).To(HaveLen(5)) - track := resp.Data[0] - Expect(track.Title).To(Equal("Instant Crush (feat. Julian Casablancas)")) - Expect(track.ID).To(Equal(67238732)) - Expect(track.Album.Title).To(Equal("Random Access Memories")) - }) - }) -}) diff --git a/adapters/gotaglib/end_to_end_test.go b/adapters/gotaglib/end_to_end_test.go deleted file mode 100644 index 0f9a90d94..000000000 --- a/adapters/gotaglib/end_to_end_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package gotaglib - -import ( - "io/fs" - "os" - "time" - - "github.com/djherbis/times" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/metadata" - . "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) - return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID")) - } - - BeforeEach(func() { - e = &extractor{fs: os.DirFS(".")} - }) - - 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", new(0.0), new(1.0), new(0.0), new(1.0)), - ) - }) - - Describe("lyrics", func() { - makeLyrics := func(code, secondLine string) model.Lyrics { - return model.Lyrics{ - DisplayArtist: "", - DisplayTitle: "", - Lang: code, - Line: []model.Line{ - {Start: new(int64(0)), Value: "This is"}, - {Start: new(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/gotaglib/gotaglib.go b/adapters/gotaglib/gotaglib.go deleted file mode 100644 index 7ea98a442..000000000 --- a/adapters/gotaglib/gotaglib.go +++ /dev/null @@ -1,301 +0,0 @@ -// Package gotaglib provides an alternative metadata extractor using go-taglib, -// a pure Go (WASM-based) implementation of TagLib. -// -// This extractor aims for parity with the CGO-based taglib extractor. It uses -// TagLib's PropertyMap interface for standard tags. The File handle API provides -// efficient access to format-specific tags (ID3v2 frames, MP4 atoms, ASF attributes) -// through a single file open operation. -// -// This extractor is registered under the name "taglib". It only works with a filesystem -// (fs.FS) and does not support direct local file paths. Files returned by the filesystem -// must implement io.ReadSeeker for go-taglib to read them. -package gotaglib - -import ( - "errors" - "fmt" - "io" - "io/fs" - "runtime/debug" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/storage/local" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/metadata" - "go.senan.xyz/taglib" -) - -type extractor struct { - fs fs.FS -} - -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 { - bi, ok := debug.ReadBuildInfo() - if ok { - for _, dep := range bi.Deps { - if dep.Path == "go.senan.xyz/taglib" { - if dep.Replace != nil { - return dep.Replace.Version - } - return dep.Version - } - } - } - return "unknown" -} - -func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err error) { - // Recover from panics in the WASM runtime that can occur during any taglib - // operation (opening, reading tags, or reading properties). This catches crashes - // from malformed files or WASM runtime issues (e.g., wazero mmap failures on - // hardened systems with MemoryDenyWriteExecute=true). - debug.SetPanicOnFault(true) - defer func() { - if r := recover(); r != nil { - log.Error("gotaglib: WASM runtime panic reading file. Skipping", "filePath", filePath, "panic", r) - debug.PrintStack() - err = fmt.Errorf("WASM runtime panic: %v", r) - } - }() - - f, close, err := e.openFile(filePath) - if err != nil { - log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err) - return nil, err - } - defer close() - - // Get all tags and properties in one go - allTags := f.AllTags() - props := f.Properties() - - // Map properties to AudioProperties - ap := metadata.AudioProperties{ - Duration: props.Length.Round(time.Millisecond * 10), - BitRate: int(props.Bitrate), - Channels: int(props.Channels), - SampleRate: int(props.SampleRate), - BitDepth: int(props.BitsPerSample), - Codec: props.Codec, - } - - // Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys) - normalizedTags := make(map[string][]string, len(allTags.Tags)) - for key, values := range allTags.Tags { - lowerKey := strings.ToLower(key) - normalizedTags[lowerKey] = values - } - - // Process format-specific raw tags - processRawTags(allTags, normalizedTags) - - // Parse track/disc totals from "N/Total" format - parseTuple(normalizedTags, "track") - parseTuple(normalizedTags, "disc") - - // Adjust some ID3 tags - parseLyrics(normalizedTags) - parseTIPL(normalizedTags) - delete(normalizedTags, "tmcl") // TMCL is already parsed by TagLib - - // Determine if file has embedded picture - hasPicture := len(props.Images) > 0 - - return &metadata.Info{ - Tags: normalizedTags, - AudioProperties: ap, - HasPicture: hasPicture, - }, nil -} - -// openFile opens the file at filePath using the extractor's filesystem. -// It returns a TagLib File handle and a cleanup function to close resources. -func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) { - // Open the file from the filesystem - file, err := e.fs.Open(filePath) - if err != nil { - return nil, nil, err - } - rs, isSeekable := file.(io.ReadSeeker) - if !isSeekable { - file.Close() - return nil, nil, errors.New("file is not seekable") - } - // WithFilename provides a format detection hint via the file extension, - // since OpenStream alone relies on content-sniffing which fails for some files. - f, err = taglib.OpenStream(rs, - taglib.WithReadStyle(taglib.ReadStyleFast), - taglib.WithFilename(filePath), - ) - if err != nil { - file.Close() - return nil, nil, err - } - closeFunc = func() { - f.Close() - file.Close() - } - return f, closeFunc, nil -} - -// parseTuple parses track/disc numbers in "N/Total" format and separates them. -// For example, tracknumber="2/10" becomes tracknumber="2" and tracktotal="10". -func parseTuple(tags map[string][]string, 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]} - } - } -} - -// parseLyrics ensures lyrics tags have a language code. -// If lyrics exist without a language code, they are moved to "lyrics:xxx". -func parseLyrics(tags map[string][]string) { - lyrics := tags["lyrics"] - if len(lyrics) > 0 { - tags["lyrics:xxx"] = lyrics - delete(tags, "lyrics") - } -} - -// processRawTags processes format-specific raw tags based on the detected file format. -// This handles ID3v2 frames (MP3/WAV/AIFF), MP4 atoms, and ASF attributes. -func processRawTags(allTags taglib.AllTags, normalizedTags map[string][]string) { - switch allTags.Format { - case taglib.FormatMPEG, taglib.FormatWAV, taglib.FormatAIFF: - parseID3v2Frames(allTags.Raw, normalizedTags) - case taglib.FormatMP4: - parseMP4Atoms(allTags.Raw, normalizedTags) - case taglib.FormatASF: - parseASFAttributes(allTags.Raw, normalizedTags) - } -} - -// parseID3v2Frames processes ID3v2 raw frames to extract USLT/SYLT with language codes. -// This extracts language-specific lyrics that the standard Tags() doesn't provide. -func parseID3v2Frames(rawFrames map[string][]string, tags map[string][]string) { - // Process frames that have language-specific data - for key, values := range rawFrames { - lowerKey := strings.ToLower(key) - - // Handle USLT:xxx and SYLT:xxx (lyrics with language codes) - if strings.HasPrefix(lowerKey, "uslt:") || strings.HasPrefix(lowerKey, "sylt:") { - parts := strings.SplitN(lowerKey, ":", 2) - if len(parts) == 2 && parts[1] != "" { - lang := parts[1] - lyricsKey := "lyrics:" + lang - tags[lyricsKey] = append(tags[lyricsKey], values...) - } - } - } - - // If we found any language-specific lyrics from ID3v2 frames, remove the generic lyrics - for key := range tags { - if strings.HasPrefix(key, "lyrics:") && key != "lyrics" { - delete(tags, "lyrics") - break - } - } -} - -const iTunesKeyPrefix = "----:com.apple.iTunes:" - -// parseMP4Atoms processes MP4 raw atoms to get iTunes-specific tags. -func parseMP4Atoms(rawAtoms map[string][]string, tags map[string][]string) { - // Process all atoms and add them to tags - for key, values := range rawAtoms { - // Strip iTunes prefix and convert to lowercase - normalizedKey := strings.TrimPrefix(key, iTunesKeyPrefix) - normalizedKey = strings.ToLower(normalizedKey) - - // Only add if the tag doesn't already exist (avoid duplication with PropertyMap) - if _, exists := tags[normalizedKey]; !exists { - tags[normalizedKey] = values - } - } -} - -// parseASFAttributes processes ASF raw attributes to get WMA-specific tags. -func parseASFAttributes(rawAttrs map[string][]string, tags map[string][]string) { - // Process all attributes and add them to tags - for key, values := range rawAttrs { - normalizedKey := strings.ToLower(key) - - // Only add if the tag doesn't already exist (avoid duplication with PropertyMap) - if _, exists := tags[normalizedKey]; !exists { - tags[normalizedKey] = values - } - } -} - -// 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", -} - -// 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.SplitSeq(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("taglib", func(fsys fs.FS, baseDir string) local.Extractor { - return &extractor{fsys} - }) - conf.AddHook(func() { - log.Debug("go-taglib version", "version", extractor{}.Version()) - }) -} diff --git a/adapters/gotaglib/gotaglib_test.go b/adapters/gotaglib/gotaglib_test.go deleted file mode 100644 index 05924914d..000000000 --- a/adapters/gotaglib/gotaglib_test.go +++ /dev/null @@ -1,318 +0,0 @@ -package gotaglib - -import ( - "io/fs" - "os" - "strings" - - "github.com/navidrome/navidrome/tests" - "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{fs: os.DirFS(".")} - }) - - 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"})) - // 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( - 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=1100:duration=1" -c:a libopus test.opus (tags added via mutagen) - Entry("correctly parses opus tags (#4998)", "test.opus", "1s", 1, 48000, 0, "+5.12 dB", "0.11345678", "+5.12 dB", "0.11345678", false, true), - - // ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma - // Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order - Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true), - - // 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() { - 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") - - 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() { - // Strip leading slash for DirFS rooted at "/" - _, err := e.extractMetadata(accessForbiddenFile[1:]) - Expect(err).To(MatchError(os.ErrPermission)) - }) - - It("skips the file if it cannot be read", func() { - // Get current working directory to construct paths relative to root - cwd, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - // Strip leading slash for DirFS rooted at "/" - files := []string{ - cwd[1:] + "/tests/fixtures/test.mp3", - cwd[1:] + "/tests/fixtures/test.ogg", - accessForbiddenFile[1:], - } - mds, err := e.Parse(files...) - Expect(err).NotTo(HaveOccurred()) - Expect(mds).To(HaveLen(2)) - Expect(mds).ToNot(HaveKey(accessForbiddenFile[1:])) - }) - }) - }) - - }) - - 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/lastfm/agent.go b/adapters/lastfm/agent.go deleted file mode 100644 index dbd73c30d..000000000 --- a/adapters/lastfm/agent.go +++ /dev/null @@ -1,451 +0,0 @@ -package lastfm - -import ( - "context" - "errors" - "fmt" - "net/http" - "regexp" - "strconv" - "strings" - "sync" - - "github.com/andybalholm/cascadia" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/cache" - "github.com/navidrome/navidrome/utils/httpclient" - "golang.org/x/net/html" -) - -const ( - lastFMAgentName = "lastfm" - sessionKeyProperty = "LastFMSessionKey" -) - -var ignoredContent = []string{ - // Empty Artist/Album - `Read more on Last\.fm\.?`) - -func cleanContent(content string) string { - return strings.TrimSpace(lastFMReadMoreRegex.ReplaceAllString(content, "")) -} - -type lastfmAgent struct { - ds model.DataStore - sessionKeys *agents.SessionKeys - apiKey string - secret string - languages []string - client *client - httpClient httpDoer - getInfoMutex sync.Mutex -} - -func lastFMConstructor(ds model.DataStore) *lastfmAgent { - if !conf.Server.LastFM.Enabled || conf.Server.LastFM.ApiKey == "" || conf.Server.LastFM.Secret == "" { - return nil - } - l := &lastfmAgent{ - ds: ds, - languages: conf.Server.LastFM.Languages, - apiKey: conf.Server.LastFM.ApiKey, - secret: conf.Server.LastFM.Secret, - sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, - } - hc := httpclient.New(consts.DefaultHttpClientTimeOut) - chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) - l.httpClient = chc - l.client = newClient(l.apiKey, l.secret, chc) - return l -} - -func (l *lastfmAgent) AgentName() string { - return lastFMAgentName -} - -var imageRegex = regexp.MustCompile(`u\/(\d+)`) - -// isValidContent checks if content is non-empty and not in the ignored list -func isValidContent(content string) bool { - content = strings.TrimSpace(content) - if content == "" { - return false - } - for _, ign := range ignoredContent { - if strings.HasPrefix(content, ign) { - return false - } - } - return true -} - -func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) { - var a *Album - var resp agents.AlbumInfo - for _, lang := range l.languages { - var err error - a, err = l.callAlbumGetInfo(ctx, name, artist, lang) - if err != nil { - return nil, err - } - resp.Name = a.Name - resp.MBID = a.MBID - resp.URL = a.URL - if isValidContent(a.Description.Summary) { - resp.Description = cleanContent(a.Description.Summary) - return &resp, nil - } - log.Debug(ctx, "LastFM/album.getInfo returned empty/ignored description, trying next language", "album", name, "artist", artist, "lang", lang) - } - // This condition should not be hit (languages default to ["en"]), but just in case - if a == nil { - return nil, agents.ErrNotFound - } - return &resp, nil -} - -func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { - a, err := l.callAlbumGetInfo(ctx, name, artist, l.languages[0]) - if err != nil { - return nil, err - } - - // Last.fm can return duplicate sizes. - seenSizes := map[int]bool{} - images := make([]agents.ExternalImage, 0) - - // This assumes that Last.fm returns images with size small, medium, and large. - // This is true as of December 29, 2022 - for _, img := range a.Image { - size := imageRegex.FindStringSubmatch(img.URL) - // Last.fm can return images without URL - if len(size) == 0 || len(size[0]) < 4 { - log.Trace(ctx, "LastFM/albuminfo image URL does not match expected regex or is empty", "url", img.URL, "size", img.Size) - continue - } - numericSize, err := strconv.Atoi(size[0][2:]) - if err != nil { - log.Error(ctx, "LastFM/albuminfo image URL does not match expected regex", "url", img.URL, "size", img.Size, err) - return nil, err - } - if _, exists := seenSizes[numericSize]; !exists { - images = append(images, agents.ExternalImage{ - Size: numericSize, - URL: img.URL, - }) - seenSizes[numericSize] = true - } - } - return images, nil -} - -func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { - a, err := l.callArtistGetInfo(ctx, name, l.languages[0]) - if err != nil { - return "", err - } - if a.MBID == "" { - return "", agents.ErrNotFound - } - return a.MBID, nil -} - -func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) { - a, err := l.callArtistGetInfo(ctx, name, l.languages[0]) - if err != nil { - return "", err - } - if a.URL == "" { - return "", agents.ErrNotFound - } - return a.URL, nil -} - -func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) { - for _, lang := range l.languages { - a, err := l.callArtistGetInfo(ctx, name, lang) - if err != nil { - return "", err - } - if isValidContent(a.Bio.Summary) { - return cleanContent(a.Bio.Summary), nil - } - log.Debug(ctx, "LastFM/artist.getInfo returned empty/ignored biography, trying next language", "artist", name, "lang", lang) - } - return "", agents.ErrNotFound -} - -func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) { - resp, err := l.callArtistGetSimilar(ctx, name, limit) - if err != nil { - return nil, err - } - if len(resp) == 0 { - return nil, agents.ErrNotFound - } - var res []agents.Artist - for _, a := range resp { - res = append(res, agents.Artist{ - Name: a.Name, - MBID: a.MBID, - }) - } - return res, nil -} - -func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) { - resp, err := l.callArtistGetTopTracks(ctx, artistName, count) - if err != nil { - return nil, err - } - if len(resp) == 0 { - return nil, agents.ErrNotFound - } - var res []agents.Song - for _, t := range resp { - res = append(res, agents.Song{ - Name: t.Name, - MBID: t.MBID, - }) - } - return res, nil -} - -func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) { - resp, err := l.callTrackGetSimilar(ctx, name, artist, count) - if err != nil { - return nil, err - } - if len(resp) == 0 { - return nil, agents.ErrNotFound - } - res := make([]agents.Song, 0, len(resp)) - for _, t := range resp { - res = append(res, agents.Song{ - Name: t.Name, - MBID: t.MBID, - Artists: []agents.Artist{{Name: t.Artist.Name, MBID: t.Artist.MBID}}, - }) - } - return res, nil -} - -var ( - artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`) - artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name - - // Not a RetryLaterError on purpose: parking the agent would also stall its API-backed - // methods, which the page block does not affect. - errNoArtistPage = errors.New("no artist image in Last.fm page") -) - -func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) { - log.Debug(ctx, "Getting artist images from Last.fm", "name", name) - a, err := l.callArtistGetInfo(ctx, name, l.languages[0]) - if err != nil { - return nil, fmt.Errorf("get artist info: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.URL, nil) - if err != nil { - return nil, fmt.Errorf("create artist image request: %w", err) - } - resp, err := l.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("get artist url: %w", err) - } - defer resp.Body.Close() - - node, err := html.Parse(resp.Body) - if err != nil { - return nil, fmt.Errorf("parse html: %w", err) - } - - var res []agents.ExternalImage - n := cascadia.Query(node, artistOpenGraphQuery) - if n == nil { - // A real artist page always has og:image; its absence means a bot challenge or a redesign. - log.Warn(ctx, "Last.fm did not return a usable artist page", "name", name, "url", a.URL) - return nil, errNoArtistPage - } - for _, attr := range n.Attr { - if attr.Key != "content" { - continue - } - if strings.Contains(attr.Val, artistIgnoredImage) { - log.Debug(ctx, "Artist image is ignored default image", "name", name, "url", attr.Val) - return res, nil - } - - res = []agents.ExternalImage{ - {URL: attr.Val}, - } - } - return res, nil -} - -// callAlbumGetInfo matches on name+artist only. Last.fm's album.getInfo by MBID is unreliable — -// a correct MBID can return a different album (or none) — so the MBID is deliberately not passed. -func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, lang string) (*Album, error) { - a, err := l.client.albumGetInfo(ctx, name, artist, "", lang) - if err != nil { - if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 { - // A not-found is a definitive absence, not a fault: return the shared sentinel so the - // artwork worker's breaker/transient checks don't retry it, and log it at Debug. - log.Debug(ctx, "Album not found in Last.fm", "album", name, "artist", artist) - return nil, agents.ErrNotFound - } - log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "artist", artist, err) - return nil, err - } - return a, nil -} - -func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) { - l.getInfoMutex.Lock() - defer l.getInfoMutex.Unlock() - - a, err := l.client.artistGetInfo(ctx, name, lang) - if err != nil { - if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 { - // A not-found is a definitive absence, not a fault: return the shared sentinel so it - // doesn't trip the artwork worker's breaker, and log at Debug instead of Error. - log.Debug(ctx, "Artist not found in Last.fm", "artist", name) - return nil, agents.ErrNotFound - } - log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err) - return nil, err - } - return a, nil -} - -func (l *lastfmAgent) callArtistGetSimilar(ctx context.Context, name string, limit int) ([]Artist, error) { - s, err := l.client.artistGetSimilar(ctx, name, limit) - if err != nil { - log.Error(ctx, "Error calling LastFM/artist.getSimilar", "artist", name, err) - return nil, err - } - return s.Artists, nil -} - -func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName string, count int) ([]Track, error) { - t, err := l.client.artistGetTopTracks(ctx, artistName, count) - if err != nil { - log.Error(ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, err) - return nil, err - } - return t.Track, nil -} - -func (l *lastfmAgent) callTrackGetSimilar(ctx context.Context, name, artist string, count int) ([]SimilarTrack, error) { - s, err := l.client.trackGetSimilar(ctx, name, artist, count) - if err != nil { - log.Error(ctx, "Error calling LastFM/track.getSimilar", "track", name, "artist", artist, err) - return nil, err - } - return s.Track, nil -} - -func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string { - if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 { - return track.Participants[role][0].Name - } - return displayName -} - -func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { - sk, err := l.sessionKeys.Get(ctx, userId) - if err != nil || sk == "" { - return scrobbler.ErrNotAuthorized - } - - err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{ - artist: l.getArtistForScrobble(track, model.RoleArtist, track.Artist), - track: track.Title, - album: track.Album, - trackNumber: track.TrackNumber, - mbid: track.MbzRecordingID, - duration: int(track.Duration), - albumArtist: l.getArtistForScrobble(track, model.RoleAlbumArtist, track.AlbumArtist), - }) - if err != nil { - log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err) - return errors.Join(err, scrobbler.ErrUnrecoverable) - } - return nil -} - -func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error { - sk, err := l.sessionKeys.Get(ctx, userId) - if err != nil || sk == "" { - return errors.Join(err, scrobbler.ErrNotAuthorized) - } - - if s.Duration <= 30 { - log.Debug(ctx, "Skipping Last.fm scrobble for short song", "track", s.Title, "duration", s.Duration) - return nil - } - err = l.client.scrobble(ctx, sk, ScrobbleInfo{ - artist: l.getArtistForScrobble(&s.MediaFile, model.RoleArtist, s.Artist), - track: s.Title, - album: s.Album, - trackNumber: s.TrackNumber, - mbid: s.MbzRecordingID, - duration: int(s.Duration), - albumArtist: l.getArtistForScrobble(&s.MediaFile, model.RoleAlbumArtist, s.AlbumArtist), - timestamp: s.TimeStamp, - }) - if err == nil { - return nil - } - var lfErr *lastFMError - isLastFMError := errors.As(err, &lfErr) - if !isLastFMError { - log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err) - return errors.Join(err, scrobbler.ErrRetryLater) - } - // 11: service offline; 16: temporarily unavailable. Rate limiting is mapped by the client. - if lfErr.Code == 11 || lfErr.Code == 16 || errors.Is(err, scrobbler.ErrRetryLater) { - return errors.Join(err, scrobbler.ErrRetryLater) - } - return errors.Join(err, scrobbler.ErrUnrecoverable) -} - -func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool { - sk, err := l.sessionKeys.Get(ctx, userId) - 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 { - // This is a workaround for the fact that a (Interface)(nil) is not the same as a (*lastfmAgent)(nil) - // See https://go.dev/doc/faq#nil_error - a := lastFMConstructor(ds) - if a != nil { - return a - } - return nil - }) - scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler { - // Same as above - this is a workaround for the fact that a (Scrobbler)(nil) is not the same as a (*lastfmAgent)(nil) - // See https://go.dev/doc/faq#nil_error - a := lastFMConstructor(ds) - if a != nil { - return a - } - return nil - }) - }) -} diff --git a/adapters/lastfm/agent_test.go b/adapters/lastfm/agent_test.go deleted file mode 100644 index 0f68bd46b..000000000 --- a/adapters/lastfm/agent_test.go +++ /dev/null @@ -1,731 +0,0 @@ -package lastfm - -import ( - "bytes" - "context" - "errors" - "io" - "net/http" - "net/url" - "os" - "strconv" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -const ( - lastfmError3 = `{"error":3,"message":"Invalid Method - No method with that name in this package","links":[]}` - lastfmError6 = `{"error":6,"message":"The artist you supplied could not be found","links":[]}` -) - -var _ = Describe("lastfmAgent", func() { - var ds model.DataStore - var ctx context.Context - BeforeEach(func() { - ds = &tests.MockDataStore{} - ctx = context.Background() - DeferCleanup(configtest.SetupConfig()) - conf.Server.LastFM.Enabled = true - conf.Server.LastFM.ApiKey = "123" - conf.Server.LastFM.Secret = "secret" - }) - Describe("lastFMConstructor", func() { - When("Agent is properly configured", func() { - It("uses configured api key and languages", func() { - conf.Server.LastFM.Languages = []string{"pt", "en"} - agent := lastFMConstructor(ds) - Expect(agent.apiKey).To(Equal("123")) - Expect(agent.secret).To(Equal("secret")) - Expect(agent.languages).To(Equal([]string{"pt", "en"})) - }) - }) - When("Agent is disabled", func() { - It("returns nil", func() { - conf.Server.LastFM.Enabled = false - Expect(lastFMConstructor(ds)).To(BeNil()) - }) - }) - When("ApiKey is empty", func() { - It("returns nil", func() { - conf.Server.LastFM.ApiKey = "" - Expect(lastFMConstructor(ds)).To(BeNil()) - }) - }) - When("Secret is empty", func() { - It("returns nil", func() { - conf.Server.LastFM.Secret = "" - Expect(lastFMConstructor(ds)).To(BeNil()) - }) - }) - }) - - Describe("GetArtistBiography", func() { - var agent *lastfmAgent - var httpClient *tests.FakeHttpClient - BeforeEach(func() { - httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", httpClient) - agent = lastFMConstructor(ds) - agent.client = client - }) - - It("returns the biography", func() { - f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente.")) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) - }) - - It("returns an error if Last.fm call fails", func() { - httpClient.Err = errors.New("error") - _, err := agent.GetArtistBiography(ctx, "123", "U2", "") - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) - }) - - It("returns an error if Last.fm call returns an error", func() { - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200} - _, err := agent.GetArtistBiography(ctx, "123", "U2", "") - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) - }) - - It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)), - StatusCode: 200, - } - _, err := agent.GetArtistBiography(ctx, "123", "U2", "") - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - }) - }) - - Describe("Language Fallback", func() { - Describe("GetArtistBiography", func() { - var agent *lastfmAgent - var httpClient *langAwareHttpClient - - BeforeEach(func() { - httpClient = newLangAwareHttpClient() - }) - - It("returns content in first language when available (1 API call)", func() { - conf.Server.LastFM.Languages = []string{"pt", "en"} - agent = lastFMConstructor(ds) - agent.client = newClient("API_KEY", "SECRET", httpClient) - - // Portuguese biography available - f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") - httpClient.responses["pt"] = http.Response{Body: f, StatusCode: 200} - - bio, err := agent.GetArtistBiography(ctx, "123", "U2", "") - - Expect(err).ToNot(HaveOccurred()) - Expect(bio).To(ContainSubstring("U2 é uma das mais importantes bandas de rock")) - Expect(httpClient.requestCount).To(Equal(1)) - Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("pt")) - }) - - It("falls back to second language when first returns empty (2 API calls)", func() { - conf.Server.LastFM.Languages = []string{"ja", "en"} - agent = lastFMConstructor(ds) - agent.client = newClient("API_KEY", "SECRET", httpClient) - - // Japanese returns empty/ignored biography (actual Last.fm response with just "Read more" link) - fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json") - httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200} - // English returns full biography - fEn, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.en.json") - httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200} - - bio, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "") - - Expect(err).ToNot(HaveOccurred()) - Expect(bio).To(ContainSubstring("Legião Urbana was a Brazilian post-punk band")) - Expect(httpClient.requestCount).To(Equal(2)) - Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja")) - Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en")) - }) - - It("returns ErrNotFound when all languages return empty", func() { - conf.Server.LastFM.Languages = []string{"ja", "xx"} - agent = lastFMConstructor(ds) - agent.client = newClient("API_KEY", "SECRET", httpClient) - - // Both languages return empty/ignored biography (using actual Last.fm response format) - fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json") - httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200} - // Second language also returns empty - fXx, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json") - httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200} - - _, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "") - - Expect(err).To(MatchError(agents.ErrNotFound)) - Expect(httpClient.requestCount).To(Equal(2)) - }) - }) - - Describe("GetAlbumInfo", func() { - var agent *lastfmAgent - var httpClient *langAwareHttpClient - - BeforeEach(func() { - httpClient = newLangAwareHttpClient() - }) - - It("falls back to second language when first returns empty description (2 API calls)", func() { - conf.Server.LastFM.Languages = []string{"ja", "en"} - agent = lastFMConstructor(ds) - agent.client = newClient("API_KEY", "SECRET", httpClient) - - // Japanese returns album without wiki/description (actual Last.fm response) - fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json") - httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200} - // English returns album with description - fEn, _ := os.Open("tests/fixtures/lastfm.album.getinfo.en.json") - httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200} - - albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "") - - Expect(err).ToNot(HaveOccurred()) - Expect(albumInfo.Name).To(Equal("Dois")) - Expect(albumInfo.Description).To(ContainSubstring("segundo álbum de estúdio")) - Expect(httpClient.requestCount).To(Equal(2)) - Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja")) - Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en")) - }) - - It("returns album without description when all languages return empty", func() { - conf.Server.LastFM.Languages = []string{"ja", "xx"} - agent = lastFMConstructor(ds) - agent.client = newClient("API_KEY", "SECRET", httpClient) - - // Both languages return album without description - fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json") - httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200} - fXx, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json") - httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200} - - albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "") - - Expect(err).ToNot(HaveOccurred()) - Expect(albumInfo.Name).To(Equal("Dois")) - Expect(albumInfo.Description).To(BeEmpty()) - Expect(httpClient.requestCount).To(Equal(2)) - }) - }) - }) - - Describe("GetSimilarArtists", func() { - var agent *lastfmAgent - var httpClient *tests.FakeHttpClient - BeforeEach(func() { - httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", httpClient) - agent = lastFMConstructor(ds) - agent.client = client - }) - - It("returns similar artists", func() { - f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - Expect(agent.GetSimilarArtists(ctx, "123", "U2", "", 2)).To(Equal([]agents.Artist{ - {Name: "Passengers", MBID: "e110c11f-1c94-4471-a350-c38f46b29389"}, - {Name: "INXS", MBID: "481bf5f9-2e7c-4c44-b08a-05b32bc7c00d"}, - })) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) - }) - - It("returns an error if Last.fm call fails", func() { - httpClient.Err = errors.New("error") - _, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2) - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) - }) - - It("returns an error if Last.fm call returns an error", func() { - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200} - _, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2) - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) - }) - }) - - Describe("GetArtistTopSongs", func() { - var agent *lastfmAgent - var httpClient *tests.FakeHttpClient - BeforeEach(func() { - httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", httpClient) - agent = lastFMConstructor(ds) - agent.client = client - }) - - It("returns top songs", func() { - f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - Expect(agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)).To(Equal([]agents.Song{ - {Name: "Beautiful Day", MBID: "f7f264d0-a89b-4682-9cd7-a4e7c37637af"}, - {Name: "With or Without You", MBID: "6b9a509f-6907-4a6e-9345-2f12da09ba4b"}, - })) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) - }) - - It("returns an error if Last.fm call fails", func() { - httpClient.Err = errors.New("error") - _, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2) - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) - }) - - It("returns an error if Last.fm call returns an error", func() { - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200} - _, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2) - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2")) - }) - }) - - Describe("GetSimilarSongsByTrack", func() { - var agent *lastfmAgent - var httpClient *tests.FakeHttpClient - BeforeEach(func() { - httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", httpClient) - agent = lastFMConstructor(ds) - agent.client = client - }) - - It("returns similar songs", func() { - f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{ - {Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}}, - {Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}}, - {Name: "Don't You Want Me", MBID: "", Artists: []agents.Artist{{Name: "The Human League", MBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}}}, - {Name: "Tainted Love", MBID: "", Artists: []agents.Artist{{Name: "Soft Cell", MBID: "7fb50287-029d-47cc-825a-235ca28024b2"}}}, - {Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artists: []agents.Artist{{Name: "New Order", MBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}}}, - })) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough")) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Depeche Mode")) - }) - - It("returns ErrNotFound when no similar songs found", func() { - f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - _, err := agent.GetSimilarSongsByTrack(ctx, "123", "UnknownTrack", "UnknownArtist", "", 3) - Expect(err).To(MatchError(agents.ErrNotFound)) - Expect(httpClient.RequestCount).To(Equal(1)) - }) - - It("returns an error if Last.fm call fails", func() { - httpClient.Err = errors.New("error") - _, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3) - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - }) - - It("returns an error if Last.fm call returns an error", func() { - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200} - _, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3) - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - }) - }) - - Describe("Scrobbling", func() { - var agent *lastfmAgent - var httpClient *tests.FakeHttpClient - var track *model.MediaFile - BeforeEach(func() { - _ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1") - httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", httpClient) - agent = lastFMConstructor(ds) - agent.client = client - track = &model.MediaFile{ - ID: "123", - Title: "Track Title", - Album: "Track Album", - Artist: "Track Artist", - AlbumArtist: "Track AlbumArtist", - TrackNumber: 1, - Duration: 180, - MbzRecordingID: "mbz-123", - Participants: map[model.Role]model.ParticipantList{ - model.RoleArtist: []model.Participant{ - {Artist: model.Artist{ID: "ar-1", Name: "First Artist"}}, - {Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}}, - }, - model.RoleAlbumArtist: []model.Participant{ - {Artist: model.Artist{ID: "ar-1", Name: "First Album Artist"}}, - {Artist: model.Artist{ID: "ar-2", Name: "Second Album Artist"}}, - }, - }, - } - }) - - Describe("NowPlaying", func() { - It("calls Last.fm with correct params", func() { - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200} - - err := agent.NowPlaying(ctx, "user-1", track, 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) - body, _ := io.ReadAll(httpClient.SavedRequest.Body) - sentParams, _ := url.ParseQuery(string(body)) - Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying")) - Expect(sentParams.Get("sk")).To(Equal("SK-1")) - Expect(sentParams.Get("track")).To(Equal(track.Title)) - Expect(sentParams.Get("album")).To(Equal(track.Album)) - Expect(sentParams.Get("artist")).To(Equal(track.Artist)) - Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist)) - Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber))) - Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32))) - Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID)) - }) - - It("returns ErrNotAuthorized if user is not linked", func() { - err := agent.NowPlaying(ctx, "user-2", track, 0) - Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) - }) - - When("ScrobbleFirstArtistOnly is true", func() { - BeforeEach(func() { - conf.Server.LastFM.ScrobbleFirstArtistOnly = true - }) - - It("uses only the first artist", func() { - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200} - - err := agent.NowPlaying(ctx, "user-1", track, 0) - - Expect(err).ToNot(HaveOccurred()) - body, _ := io.ReadAll(httpClient.SavedRequest.Body) - sentParams, _ := url.ParseQuery(string(body)) - Expect(sentParams.Get("artist")).To(Equal("First Artist")) - Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist")) - }) - }) - }) - - Describe("scrobble", func() { - It("calls Last.fm with correct params", func() { - ts := time.Now() - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200} - - err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts}) - - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) - body, _ := io.ReadAll(httpClient.SavedRequest.Body) - sentParams, _ := url.ParseQuery(string(body)) - Expect(sentParams.Get("method")).To(Equal("track.scrobble")) - Expect(sentParams.Get("sk")).To(Equal("SK-1")) - Expect(sentParams.Get("track")).To(Equal(track.Title)) - Expect(sentParams.Get("album")).To(Equal(track.Album)) - Expect(sentParams.Get("artist")).To(Equal(track.Artist)) - Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist)) - Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber))) - Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32))) - Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID)) - Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10))) - }) - - When("ScrobbleFirstArtistOnly is true", func() { - BeforeEach(func() { - conf.Server.LastFM.ScrobbleFirstArtistOnly = true - }) - - It("uses only the first artist", func() { - ts := time.Now() - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200} - - err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts}) - - Expect(err).ToNot(HaveOccurred()) - body, _ := io.ReadAll(httpClient.SavedRequest.Body) - sentParams, _ := url.ParseQuery(string(body)) - Expect(sentParams.Get("artist")).To(Equal("First Artist")) - Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist")) - }) - }) - - It("skips songs with less than 31 seconds", func() { - track.Duration = 29 - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200} - - err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) - - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest).To(BeNil()) - }) - - It("returns ErrNotAuthorized if user is not linked", func() { - err := agent.Scrobble(ctx, "user-2", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) - Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) - }) - - It("returns ErrRetryLater on error 11", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"error":11,"message":"Service Offline - This service is temporarily offline. Try again later."}`)), - StatusCode: 400, - } - - err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) - Expect(err).To(MatchError(scrobbler.ErrRetryLater)) - }) - - It("returns ErrRetryLater on error 16", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"error":16,"message":"There was a temporary error processing your request. Please try again"}`)), - StatusCode: 400, - } - - err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) - Expect(err).To(MatchError(scrobbler.ErrRetryLater)) - }) - - It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)), - StatusCode: 200, - } - - err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) - Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) - }) - - It("returns ErrRetryLater on http errors", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`internal server error`)), - StatusCode: 500, - } - - err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) - Expect(err).To(MatchError(scrobbler.ErrRetryLater)) - }) - - It("returns ErrUnrecoverable on other errors", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"error":8,"message":"Operation failed - Something else went wrong"}`)), - StatusCode: 400, - } - - err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) - Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) - }) - }) - }) - - Describe("GetAlbumInfo", func() { - var agent *lastfmAgent - var httpClient *tests.FakeHttpClient - BeforeEach(func() { - httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", httpClient) - agent = lastFMConstructor(ds) - agent.client = client - }) - - It("returns the biography", func() { - f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{ - Name: "Believe", - MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62", - Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob", - URL: "https://www.last.fm/music/Cher/Believe", - })) - Expect(httpClient.RequestCount).To(Equal(1)) - // MBID is deliberately not sent — album.getInfo matches on name+artist only. - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) - Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("Believe")) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Cher")) - }) - - It("returns empty images if no images are available", func() { - f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty_urls.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - Expect(agent.GetAlbumInfo(ctx, "The Definitive Less Damage And More Joy", "The Jesus and Mary Chain", "")).To(Equal(&agents.AlbumInfo{ - Name: "The Definitive Less Damage And More Joy", - URL: "https://www.last.fm/music/The+Jesus+and+Mary+Chain/The+Definitive+Less+Damage+And+More+Joy", - })) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("The Definitive Less Damage And More Joy")) - }) - - It("returns an error if Last.fm call fails", func() { - httpClient.Err = errors.New("error") - _, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) - }) - - It("returns an error if Last.fm call returns an error", func() { - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200} - _, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) - }) - - It("returns an error when Last.fm returns an error 6 (album not found)", func() { - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} - _, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") - Expect(err).To(HaveOccurred()) - // A definitive not-found must satisfy the sentinel, or the artwork worker retries it. - Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) - }) - }) - - Describe("GetArtistImages", func() { - var agent *lastfmAgent - var apiClient *tests.FakeHttpClient - var httpClient *tests.FakeHttpClient - - BeforeEach(func() { - apiClient = &tests.FakeHttpClient{} - httpClient = &tests.FakeHttpClient{} - client := newClient("API_KEY", "SECRET", apiClient) - agent = lastFMConstructor(ds) - agent.client = client - agent.httpClient = httpClient - }) - - It("returns the artist image from the page", func() { - fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") - apiClient.Res = http.Response{Body: fApi, StatusCode: 200} - - fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.html") - httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} - - images, err := agent.GetArtistImages(ctx, "123", "U2", "") - Expect(err).ToNot(HaveOccurred()) - Expect(images).To(HaveLen(1)) - Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png")) - }) - - It("maps a Last.fm error 6 (artist not found) to the shared not-found sentinel", func() { - apiClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} - _, err := agent.GetArtistImages(ctx, "123", "Nonexistent Artist", "") - // Not a fault: runs of missing artists must not trip the worker's circuit breaker. - Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue()) - }) - - It("returns empty list if image is the ignored default image", func() { - fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") - apiClient.Res = http.Response{Body: fApi, StatusCode: 200} - - fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.ignored.html") - httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} - - images, err := agent.GetArtistImages(ctx, "123", "U2", "") - Expect(err).ToNot(HaveOccurred()) - Expect(images).To(BeEmpty()) - }) - - It("errors when the page has no meta tags", func() { - fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") - apiClient.Res = http.Response{Body: fApi, StatusCode: 200} - - fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html") - httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} - - _, err := agent.GetArtistImages(ctx, "123", "U2", "") - Expect(err).To(MatchError(errNoArtistPage)) - }) - - It("errors when Last.fm serves a bot challenge page", func() { - fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") - apiClient.Res = http.Response{Body: fApi, StatusCode: 200} - - fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html") - httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} - - images, err := agent.GetArtistImages(ctx, "123", "U2", "") - Expect(err).To(MatchError(errNoArtistPage)) - Expect(images).To(BeEmpty()) - }) - - It("does not park the agent: the failure is not a retry-later", func() { - // A RetryLaterError would cool down the agent's API-backed methods too. - fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") - apiClient.Res = http.Response{Body: fApi, StatusCode: 200} - - fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html") - httpClient.Res = http.Response{Body: fScraper, StatusCode: 200} - - _, err := agent.GetArtistImages(ctx, "123", "U2", "") - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse()) - }) - - It("returns error if API call fails", func() { - apiClient.Err = errors.New("api error") - _, err := agent.GetArtistImages(ctx, "123", "U2", "") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("get artist info")) - }) - - It("returns error if scraper call fails", func() { - fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") - apiClient.Res = http.Response{Body: fApi, StatusCode: 200} - - httpClient.Err = errors.New("scraper error") - _, err := agent.GetArtistImages(ctx, "123", "U2", "") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("get artist url")) - }) - }) -}) - -// langAwareHttpClient is a mock HTTP client that returns different responses based on the lang parameter -type langAwareHttpClient struct { - responses map[string]http.Response - requests []*http.Request - requestCount int -} - -func newLangAwareHttpClient() *langAwareHttpClient { - return &langAwareHttpClient{ - responses: make(map[string]http.Response), - requests: make([]*http.Request, 0), - } -} - -func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) { - c.requestCount++ - c.requests = append(c.requests, req) - lang := req.URL.Query().Get("lang") - if resp, ok := c.responses[lang]; ok { - return &resp, nil - } - // Return default empty response if no specific response is configured - return &http.Response{ - StatusCode: 200, - Body: io.NopCloser(bytes.NewBufferString(`{}`)), - }, nil -} diff --git a/adapters/lastfm/auth_router_test.go b/adapters/lastfm/auth_router_test.go deleted file mode 100644 index 476daf443..000000000 --- a/adapters/lastfm/auth_router_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package lastfm - -import ( - "bytes" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "time" - - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("auth_router", func() { - var ( - ds *tests.MockDataStore - userProps *tests.MockedUserPropsRepo - httpClient *tests.FakeHttpClient - router *Router - ) - - const ( - victimID = "victim-user-id" - attackerID = "attacker-user-id" - ) - - BeforeEach(func() { - userProps = &tests.MockedUserPropsRepo{} - ds = &tests.MockDataStore{ - MockedProperty: &tests.MockedPropertyRepo{}, - MockedUserProps: userProps, - } - auth.Init(ds) - - httpClient = &tests.FakeHttpClient{} - router = &Router{ - ds: ds, - apiKey: "API_KEY", - secret: "SECRET", - sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, - } - router.client = newClient(router.apiKey, router.secret, httpClient) - router.Handler = router.routes() - }) - - storedSessionKey := func(userID string) string { - key, _ := userProps.Get(GinkgoT().Context(), userID, sessionKeyProperty) - return key - } - - stubGetSessionOK := func(sessionKey string) { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"` + sessionKey + `","subscriber":0}}`)), - StatusCode: 200, - } - } - - Describe("getLinkStatus", func() { - It("includes a signed linkToken for the authenticated user", func() { - req := httptest.NewRequest(http.MethodGet, "/link", nil) - ctx := request.WithUser(req.Context(), model.User{ID: victimID}) - req = req.WithContext(ctx) - rec := httptest.NewRecorder() - - router.getLinkStatus(rec, req) - - Expect(rec.Code).To(Equal(http.StatusOK)) - var body map[string]any - Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed()) - Expect(body["apiKey"]).To(Equal("API_KEY")) - Expect(body["status"]).To(Equal(false)) - token, ok := body["linkToken"].(string) - Expect(ok).To(BeTrue()) - Expect(token).ToNot(BeEmpty()) - - verified, err := verifyLinkToken(token) - Expect(err).ToNot(HaveOccurred()) - Expect(verified).To(Equal(victimID)) - }) - }) - - Describe("callback", func() { - It("stores the session key under the user encoded in the signed token", func() { - stubGetSessionOK("LEGIT_SESSION") - linkToken, err := createLinkToken(victimID) - Expect(err).ToNot(HaveOccurred()) - - req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken+"&token=LASTFM_TOKEN", nil) - rec := httptest.NewRecorder() - router.callback(rec, req) - - Expect(rec.Code).To(Equal(http.StatusOK)) - Expect(storedSessionKey(victimID)).To(Equal("LEGIT_SESSION")) - }) - - It("rejects a raw (unsigned) uid value", func() { - req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+victimID+"&token=LASTFM_TOKEN", nil) - rec := httptest.NewRecorder() - router.callback(rec, req) - - Expect(rec.Code).To(Equal(http.StatusBadRequest)) - Expect(storedSessionKey(victimID)).To(BeEmpty()) - Expect(httpClient.SavedRequest).To(BeNil()) - }) - - It("rejects an expired link token", func() { - expiredToken, err := auth.EncodeToken(map[string]any{ - "uid": victimID, - "scope": linkTokenScope, - "exp": time.Now().Add(-1 * time.Minute).UTC().Unix(), - }) - Expect(err).ToNot(HaveOccurred()) - - req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+expiredToken+"&token=LASTFM_TOKEN", nil) - rec := httptest.NewRecorder() - router.callback(rec, req) - - Expect(rec.Code).To(Equal(http.StatusBadRequest)) - Expect(storedSessionKey(victimID)).To(BeEmpty()) - Expect(httpClient.SavedRequest).To(BeNil()) - }) - - It("rejects a token with the wrong scope (e.g. a regular session JWT)", func() { - sessionJWT, err := auth.CreateToken(&model.User{ID: attackerID, UserName: "attacker"}) - Expect(err).ToNot(HaveOccurred()) - - req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+sessionJWT+"&token=LASTFM_TOKEN", nil) - rec := httptest.NewRecorder() - router.callback(rec, req) - - Expect(rec.Code).To(Equal(http.StatusBadRequest)) - Expect(storedSessionKey(attackerID)).To(BeEmpty()) - Expect(httpClient.SavedRequest).To(BeNil()) - }) - - It("writes only under the user encoded in the token, regardless of query manipulation", func() { - // An attacker holds a legitimate link token for their own account. - // They attempt to call the callback hoping to overwrite the victim's - // session key — but the handler must derive the user ID from the - // signed token, not from any other input. - stubGetSessionOK("ATTACKER_SESSION") - attackerToken, err := createLinkToken(attackerID) - Expect(err).ToNot(HaveOccurred()) - - req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+attackerToken+"&token=LASTFM_TOKEN&user="+victimID, nil) - rec := httptest.NewRecorder() - router.callback(rec, req) - - Expect(rec.Code).To(Equal(http.StatusOK)) - Expect(storedSessionKey(attackerID)).To(Equal("ATTACKER_SESSION")) - Expect(storedSessionKey(victimID)).To(BeEmpty()) - }) - - It("returns 400 when uid is missing", func() { - req := httptest.NewRequest(http.MethodGet, "/link/callback?token=LASTFM_TOKEN", nil) - rec := httptest.NewRecorder() - router.callback(rec, req) - - Expect(rec.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns 400 when token is missing", func() { - linkToken, err := createLinkToken(victimID) - Expect(err).ToNot(HaveOccurred()) - - req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken, nil) - rec := httptest.NewRecorder() - router.callback(rec, req) - - Expect(rec.Code).To(Equal(http.StatusBadRequest)) - }) - }) - - Describe("link token helpers", func() { - It("round-trips a freshly issued token", func() { - token, err := createLinkToken(victimID) - Expect(err).ToNot(HaveOccurred()) - - uid, err := verifyLinkToken(token) - Expect(err).ToNot(HaveOccurred()) - Expect(uid).To(Equal(victimID)) - }) - - It("rejects garbage", func() { - _, err := verifyLinkToken("not-a-jwt") - Expect(err).To(HaveOccurred()) - }) - - It("rejects a token whose scope claim is wrong", func() { - wrongScopeToken, err := auth.EncodeToken(map[string]any{ - "uid": victimID, - "scope": "some-other-scope", - "exp": time.Now().Add(linkTokenTTL).UTC().Unix(), - }) - Expect(err).ToNot(HaveOccurred()) - - _, err = verifyLinkToken(wrongScopeToken) - Expect(err).To(MatchError("invalid link token scope")) - }) - - It("rejects a scoped token that has no expiration", func() { - nonExpiringToken, err := auth.EncodeToken(map[string]any{ - "uid": victimID, - "scope": linkTokenScope, - }) - Expect(err).ToNot(HaveOccurred()) - - _, err = verifyLinkToken(nonExpiringToken) - Expect(err).To(MatchError("link token missing expiration")) - }) - - It("rejects a Jellyfin access token", func() { - usr := &model.User{ID: "u1", UserName: "johndoe"} - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - - _, err = verifyLinkToken(tokenStr) - Expect(err).To(HaveOccurred()) - }) - }) -}) diff --git a/adapters/lastfm/client_test.go b/adapters/lastfm/client_test.go deleted file mode 100644 index f72c84b91..000000000 --- a/adapters/lastfm/client_test.go +++ /dev/null @@ -1,312 +0,0 @@ -package lastfm - -import ( - "bytes" - "context" - "crypto/md5" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("client", func() { - var httpClient *tests.FakeHttpClient - var client *client - - BeforeEach(func() { - httpClient = &tests.FakeHttpClient{} - client = newClient("API_KEY", "SECRET", httpClient) - }) - - Describe("albumGetInfo", func() { - It("returns an album on successful response", func() { - f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234", "pt") - Expect(err).To(BeNil()) - Expect(album.Name).To(Equal("Believe")) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo")) - }) - - It("does not double-encode plus signs", func() { - f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - _, err := client.albumGetInfo(context.Background(), "Lungs", "Florence + the Machine", "", "en") - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence + the Machine")) - }) - }) - - Describe("artistGetInfo", func() { - It("returns an artist for a successful response", func() { - f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - artist, err := client.artistGetInfo(context.Background(), "U2", "pt") - Expect(err).To(BeNil()) - Expect(artist.Name).To(Equal("U2")) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo")) - }) - - It("double-encodes plus signs in the artist name", func() { - f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - _, err := client.artistGetInfo(context.Background(), "Florence + the Machine", "en") - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence %2B the Machine")) - }) - - It("fails if Last.fm returns an http status != 200", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)), - StatusCode: 500, - } - - _, err := client.artistGetInfo(context.Background(), "U2", "pt") - Expect(err).To(MatchError("last.fm http status: (500)")) - }) - - It("fails if Last.fm returns an http status != 200", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"error":3,"message":"Invalid Method - No method with that name in this package"}`)), - StatusCode: 400, - } - - _, err := client.artistGetInfo(context.Background(), "U2", "pt") - Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"})) - }) - - It("fails if Last.fm returns an error", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"error":6,"message":"The artist you supplied could not be found"}`)), - StatusCode: 200, - } - - _, err := client.artistGetInfo(context.Background(), "U2", "pt") - Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"})) - }) - - It("fails if HttpClient.Do() returns error", func() { - httpClient.Err = errors.New("generic error") - - _, err := client.artistGetInfo(context.Background(), "U2", "pt") - Expect(err).To(MatchError("generic error")) - }) - - It("fails if returned body is not a valid JSON", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`NOT_VALID_JSON`)), - StatusCode: 200, - } - - _, err := client.artistGetInfo(context.Background(), "U2", "pt") - Expect(err).To(MatchError("invalid character '<' looking for beginning of value")) - }) - - }) - - Describe("artistGetSimilar", func() { - It("returns an artist for a successful response", func() { - f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - similar, err := client.artistGetSimilar(context.Background(), "U2", 2) - Expect(err).To(BeNil()) - Expect(len(similar.Artists)).To(Equal(2)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar")) - }) - - It("double-encodes plus signs in the artist name", func() { - f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - _, err := client.artistGetSimilar(context.Background(), "+44", 2) - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("%2B44")) - }) - }) - - Describe("artistGetTopTracks", func() { - It("returns top tracks for a successful response", func() { - f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - top, err := client.artistGetTopTracks(context.Background(), "U2", 2) - Expect(err).To(BeNil()) - Expect(len(top.Track)).To(Equal(2)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks")) - }) - - It("double-encodes plus signs in the artist name", func() { - f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - _, err := client.artistGetTopTracks(context.Background(), "C+C Music Factory", 2) - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("C%2BC Music Factory")) - }) - }) - - Describe("trackGetSimilar", func() { - It("returns similar tracks for a successful response", func() { - f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - similar, err := client.trackGetSimilar(context.Background(), "Just Can't Get Enough", "Depeche Mode", 5) - Expect(err).To(BeNil()) - Expect(len(similar.Track)).To(Equal(5)) - Expect(similar.Track[0].Name).To(Equal("Dreaming of Me")) - Expect(similar.Track[0].Artist.Name).To(Equal("Depeche Mode")) - Expect(similar.Track[0].Match).To(Equal(1.0)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough")) - }) - - It("double-encodes plus signs in the track and artist names", func() { - f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - _, err := client.trackGetSimilar(context.Background(), "1+1", "Queen + Paul Rodgers", 5) - Expect(err).ToNot(HaveOccurred()) - query := httpClient.SavedRequest.URL.Query() - Expect(query.Get("track")).To(Equal("1%2B1")) - Expect(query.Get("artist")).To(Equal("Queen %2B Paul Rodgers")) - }) - - It("returns empty list when no similar tracks found", func() { - f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - similar, err := client.trackGetSimilar(context.Background(), "UnknownTrack", "UnknownArtist", 3) - Expect(err).To(BeNil()) - Expect(similar.Track).To(BeEmpty()) - }) - }) - - Describe("GetToken", func() { - It("returns a token when the request is successful", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"token":"TOKEN"}`)), - StatusCode: 200, - } - - Expect(client.GetToken(context.Background())).To(Equal("TOKEN")) - queryParams := httpClient.SavedRequest.URL.Query() - Expect(queryParams.Get("method")).To(Equal("auth.getToken")) - Expect(queryParams.Get("format")).To(Equal("json")) - Expect(queryParams.Get("api_key")).To(Equal("API_KEY")) - Expect(queryParams.Get("api_sig")).ToNot(BeEmpty()) - }) - }) - - Describe("getSession", func() { - It("returns a session key when the request is successful", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"SESSION_KEY","subscriber":0}}`)), - StatusCode: 200, - } - - Expect(client.getSession(context.Background(), "TOKEN")).To(Equal("SESSION_KEY")) - queryParams := httpClient.SavedRequest.URL.Query() - Expect(queryParams.Get("method")).To(Equal("auth.getSession")) - Expect(queryParams.Get("format")).To(Equal("json")) - Expect(queryParams.Get("token")).To(Equal("TOKEN")) - Expect(queryParams.Get("api_key")).To(Equal("API_KEY")) - Expect(queryParams.Get("api_sig")).ToNot(BeEmpty()) - }) - }) - - Describe("scrobble", func() { - It("sends parameters in request body for POST", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"scrobbles":{"scrobble":{"ignoredMessage":{"code":"0"}},"@attr":{"accepted":1}}}`)), - StatusCode: 200, - } - - info := ScrobbleInfo{ - artist: "U2", - track: "One", - album: "Achtung Baby", - trackNumber: 1, - duration: 276, - albumArtist: "U2", - } - err := client.scrobble(context.Background(), "SESSION_KEY", info) - Expect(err).To(BeNil()) - - req := httpClient.SavedRequest - Expect(req.Method).To(Equal(http.MethodPost)) - Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded")) - Expect(req.URL.RawQuery).To(BeEmpty()) - - body, _ := io.ReadAll(req.Body) - bodyParams, _ := url.ParseQuery(string(body)) - Expect(bodyParams.Get("method")).To(Equal("track.scrobble")) - Expect(bodyParams.Get("artist")).To(Equal("U2")) - Expect(bodyParams.Get("track")).To(Equal("One")) - Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY")) - Expect(bodyParams.Get("api_key")).To(Equal("API_KEY")) - Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty()) - }) - }) - - Describe("updateNowPlaying", func() { - It("sends parameters in request body for POST", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"nowplaying":{"ignoredMessage":{"code":"0"}}}`)), - StatusCode: 200, - } - - info := ScrobbleInfo{ - artist: "U2", - track: "One", - album: "Achtung Baby", - trackNumber: 1, - duration: 276, - albumArtist: "U2", - } - err := client.updateNowPlaying(context.Background(), "SESSION_KEY", info) - Expect(err).To(BeNil()) - - req := httpClient.SavedRequest - Expect(req.Method).To(Equal(http.MethodPost)) - Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded")) - Expect(req.URL.RawQuery).To(BeEmpty()) - - body, _ := io.ReadAll(req.Body) - bodyParams, _ := url.ParseQuery(string(body)) - Expect(bodyParams.Get("method")).To(Equal("track.updateNowPlaying")) - Expect(bodyParams.Get("artist")).To(Equal("U2")) - Expect(bodyParams.Get("track")).To(Equal("One")) - Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY")) - Expect(bodyParams.Get("api_key")).To(Equal("API_KEY")) - Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty()) - }) - }) - - Describe("sign", func() { - It("adds an api_sig param with the signature", func() { - params := url.Values{} - params.Add("d", "444") - params.Add("callback", "https://myserver.com") - params.Add("a", "111") - params.Add("format", "json") - params.Add("c", "333") - params.Add("b", "222") - client.sign(params) - Expect(params).To(HaveKey("api_sig")) - sig := params.Get("api_sig") - expected := fmt.Sprintf("%x", md5.Sum([]byte("a111b222c333d444SECRET"))) - Expect(sig).To(Equal(expected)) - }) - }) -}) diff --git a/adapters/lastfm/link_token.go b/adapters/lastfm/link_token.go deleted file mode 100644 index fd8ceb3c9..000000000 --- a/adapters/lastfm/link_token.go +++ /dev/null @@ -1,50 +0,0 @@ -package lastfm - -import ( - "errors" - "time" - - "github.com/navidrome/navidrome/core/auth" -) - -const ( - linkTokenScope = "lastfm-link" - linkTokenTTL = 5 * time.Minute -) - -// createLinkToken issues a signed token binding the Last.fm callback to the -// user who initiated the OAuth flow. It travels back through Last.fm via the -// `cb` URL in place of the previously-trusted raw `uid` query parameter. -func createLinkToken(userID string) (string, error) { - claims := map[string]any{ - "uid": userID, - "scope": linkTokenScope, - "exp": time.Now().Add(linkTokenTTL).UTC().Unix(), - } - return auth.EncodeToken(claims) -} - -// verifyLinkToken validates a signed link token and returns the encoded user ID. -// It enforces both the signature/expiry (via the underlying JWT verifier) and a -// dedicated scope claim, preventing tokens minted for other purposes (e.g. a -// regular session JWT) from being accepted here. -func verifyLinkToken(tokenStr string) (string, error) { - token, err := auth.DecodeAndVerifyToken(tokenStr) - if err != nil { - return "", err - } - // jwtauth treats a token without `exp` as non-expiring; require it - // explicitly so an accidental regression cannot mint permanent tokens. - if exp, ok := token.Expiration(); !ok || exp.IsZero() { - return "", errors.New("link token missing expiration") - } - var scope string - if err := token.Get("scope", &scope); err != nil || scope != linkTokenScope { - return "", errors.New("invalid link token scope") - } - var uid string - if err := token.Get("uid", &uid); err != nil || uid == "" { - return "", errors.New("invalid link token user ID") - } - return uid, nil -} diff --git a/adapters/listenbrainz/agent.go b/adapters/listenbrainz/agent.go deleted file mode 100644 index a59a5393f..000000000 --- a/adapters/listenbrainz/agent.go +++ /dev/null @@ -1,261 +0,0 @@ -package listenbrainz - -import ( - "context" - "errors" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/cache" - "github.com/navidrome/navidrome/utils/httpclient" - "github.com/navidrome/navidrome/utils/slice" -) - -const ( - listenBrainzAgentName = "listenbrainz" - sessionKeyProperty = "ListenBrainzSessionKey" -) - -type listenBrainzAgent struct { - ds model.DataStore - sessionKeys *agents.SessionKeys - baseURL string - client *client -} - -func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent { - l := &listenBrainzAgent{ - ds: ds, - sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, - baseURL: conf.Server.ListenBrainz.BaseURL, - } - hc := httpclient.New(consts.DefaultHttpClientTimeOut) - chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut) - l.client = newClient(l.baseURL, chc) - return l -} - -func (l *listenBrainzAgent) AgentName() string { - return listenBrainzAgentName -} - -func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo { - artistMBIDs := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string { - return p.MbzArtistID - }) - artistNames := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string { - return p.Name - }) - li := listenInfo{ - TrackMetadata: trackMetadata{ - ArtistName: track.Artist, - TrackName: track.Title, - ReleaseName: track.Album, - AdditionalInfo: additionalInfo{ - SubmissionClient: consts.AppName, - SubmissionClientVersion: consts.Version, - TrackNumber: track.TrackNumber, - ArtistNames: artistNames, - ArtistMBIDs: artistMBIDs, - RecordingMBID: track.MbzRecordingID, - ReleaseMBID: track.MbzAlbumID, - ReleaseGroupMBID: track.MbzReleaseGroupID, - DurationMs: int(track.Duration * 1000), - }, - }, - } - return li -} - -func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { - sk, err := l.sessionKeys.Get(ctx, userId) - if err != nil || sk == "" { - return errors.Join(err, scrobbler.ErrNotAuthorized) - } - - li := l.formatListen(track) - err = l.client.updateNowPlaying(ctx, sk, li) - if err != nil { - log.Warn(ctx, "ListenBrainz updateNowPlaying returned error", "track", track.Title, err) - return errors.Join(err, scrobbler.ErrUnrecoverable) - } - return nil -} - -func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error { - sk, err := l.sessionKeys.Get(ctx, userId) - if err != nil || sk == "" { - return errors.Join(err, scrobbler.ErrNotAuthorized) - } - - li := l.formatListen(&s.MediaFile) - li.ListenedAt = int(s.TimeStamp.Unix()) - err = l.client.scrobble(ctx, sk, li) - - if err == nil { - return nil - } - var lbErr *listenBrainzError - isListenBrainzError := errors.As(err, &lbErr) - if !isListenBrainzError { - log.Warn(ctx, "ListenBrainz Scrobble returned HTTP error", "track", s.Title, err) - return errors.Join(err, scrobbler.ErrRetryLater) - } - if lbErr.Code == 500 || lbErr.Code == 503 { - return errors.Join(err, scrobbler.ErrRetryLater) - } - return errors.Join(err, scrobbler.ErrUnrecoverable) -} - -func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool { - sk, err := l.sessionKeys.Get(ctx, userId) - return err == nil && sk != "" -} - -func (l *listenBrainzAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) { - if mbid == "" { - return "", agents.ErrNotFound - } - - url, err := l.client.getArtistUrl(ctx, mbid) - if err != nil { - return "", err - } - return url, nil -} - -func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) { - resp, err := l.client.getArtistTopSongs(ctx, mbid, count) - if err != nil { - return nil, err - } - if len(resp) == 0 { - return nil, agents.ErrNotFound - } - - res := make([]agents.Song, len(resp)) - for i, t := range resp { - res[i] = agents.Song{ - Album: t.ReleaseName, - AlbumMBID: t.ReleaseMBID, - Artists: topSongArtists(t.ArtistName, t.ArtistMBIDs), - Duration: t.DurationMs, - Name: t.RecordingName, - MBID: t.RecordingMbid, - } - } - return res, nil -} - -// topSongArtists maps the top-recordings response, which carries a single combined display name -// (e.g. "X feat. Y") plus a per-artist MBID list, onto agents.Artist. Names and MBIDs are not -// positionally pairable, so the display name attaches to the first credit and any further MBIDs -// become MBID-only collaborators — still valid identity signals for the matcher. -func topSongArtists(name string, mbids []string) []agents.Artist { - if len(mbids) == 0 { - if name == "" { - return nil - } - return []agents.Artist{{Name: name}} - } - artists := make([]agents.Artist, len(mbids)) - artists[0] = agents.Artist{Name: name, MBID: mbids[0]} - for i, m := range mbids[1:] { - artists[i+1] = agents.Artist{MBID: m} - } - return artists -} - -func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) { - if mbid == "" { - return nil, agents.ErrNotFound - } - - resp, err := l.client.getSimilarArtists(ctx, mbid, limit) - if err != nil { - return nil, err - } - - if len(resp) == 0 { - return nil, agents.ErrNotFound - } - - artists := make([]agents.Artist, len(resp)) - for i, artist := range resp { - artists[i] = agents.Artist{ - MBID: artist.MBID, - Name: artist.Name, - } - } - - return artists, nil -} - -func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id string, name string, artist string, mbid string, limit int) ([]agents.Song, error) { - if mbid == "" { - return nil, agents.ErrNotFound - } - - resp, err := l.client.getSimilarRecordings(ctx, mbid, limit) - if err != nil { - return nil, err - } - - if len(resp) == 0 { - return nil, agents.ErrNotFound - } - - songs := make([]agents.Song, len(resp)) - for i, song := range resp { - songs[i] = agents.Song{ - Album: song.ReleaseName, - AlbumMBID: song.ReleaseMBID, - Artists: []agents.Artist{{Name: song.Artist}}, - MBID: song.MBID, - Name: song.Name, - } - } - - return songs, nil -} - -func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error { - return nil -} - -func init() { - conf.AddHook(func() { - if conf.Server.ListenBrainz.Enabled { - scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler { - // This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil) - // See https://go.dev/doc/faq#nil_error - a := listenBrainzConstructor(ds) - if a != nil { - return a - } - return nil - }) - - agents.Register(listenBrainzAgentName, func(ds model.DataStore) agents.Interface { - // This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil) - // See https://go.dev/doc/faq#nil_error - a := listenBrainzConstructor(ds) - if a != nil { - return a - } - return nil - }) - } - }) -} - -var ( - _ agents.ArtistTopSongsRetriever = (*listenBrainzAgent)(nil) - _ agents.ArtistURLRetriever = (*listenBrainzAgent)(nil) - _ agents.ArtistSimilarRetriever = (*listenBrainzAgent)(nil) - _ agents.SimilarSongsByTrackRetriever = (*listenBrainzAgent)(nil) -) diff --git a/adapters/listenbrainz/agent_test.go b/adapters/listenbrainz/agent_test.go deleted file mode 100644 index d2aa07f1a..000000000 --- a/adapters/listenbrainz/agent_test.go +++ /dev/null @@ -1,479 +0,0 @@ -package listenbrainz - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "net/http" - "os" - "time" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - . "github.com/onsi/gomega/gstruct" -) - -var _ = Describe("listenBrainzAgent", func() { - var ds model.DataStore - var ctx context.Context - var agent *listenBrainzAgent - var httpClient *tests.FakeHttpClient - var track *model.MediaFile - - BeforeEach(func() { - ds = &tests.MockDataStore{} - ctx = context.Background() - _ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1") - httpClient = &tests.FakeHttpClient{} - agent = listenBrainzConstructor(ds) - agent.client = newClient("http://localhost:8080", httpClient) - track = &model.MediaFile{ - ID: "123", - Title: "Track Title", - Album: "Track Album", - Artist: "Track Artist", - TrackNumber: 1, - MbzRecordingID: "mbz-123", - MbzAlbumID: "mbz-456", - MbzReleaseGroupID: "mbz-789", - Duration: 142.2, - Participants: map[model.Role]model.ParticipantList{ - model.RoleArtist: []model.Participant{ - {Artist: model.Artist{ID: "ar-1", Name: "Artist 1", MbzArtistID: "mbz-111"}}, - {Artist: model.Artist{ID: "ar-2", Name: "Artist 2", MbzArtistID: "mbz-222"}}, - }, - }, - } - }) - - Describe("formatListen", func() { - It("constructs the listenInfo properly", func() { - lr := agent.formatListen(track) - Expect(lr).To(MatchAllFields(Fields{ - "ListenedAt": Equal(0), - "TrackMetadata": MatchAllFields(Fields{ - "ArtistName": Equal(track.Artist), - "TrackName": Equal(track.Title), - "ReleaseName": Equal(track.Album), - "AdditionalInfo": MatchAllFields(Fields{ - "SubmissionClient": Equal(consts.AppName), - "SubmissionClientVersion": Equal(consts.Version), - "TrackNumber": Equal(track.TrackNumber), - "RecordingMBID": Equal(track.MbzRecordingID), - "ReleaseMBID": Equal(track.MbzAlbumID), - "ReleaseGroupMBID": Equal(track.MbzReleaseGroupID), - "ArtistNames": ConsistOf("Artist 1", "Artist 2"), - "ArtistMBIDs": ConsistOf("mbz-111", "mbz-222"), - "DurationMs": Equal(142200), - }), - }), - })) - }) - }) - - Describe("NowPlaying", func() { - It("updates NowPlaying successfully", func() { - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200} - - err := agent.NowPlaying(ctx, "user-1", track, 0) - Expect(err).ToNot(HaveOccurred()) - }) - - It("returns ErrNotAuthorized if user is not linked", func() { - err := agent.NowPlaying(ctx, "user-2", track, 0) - Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) - }) - }) - - Describe("Scrobble", func() { - var sc scrobbler.Scrobble - - BeforeEach(func() { - sc = scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()} - }) - - It("sends a Scrobble successfully", func() { - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200} - - err := agent.Scrobble(ctx, "user-1", sc) - Expect(err).ToNot(HaveOccurred()) - }) - - It("sets the Timestamp properly", func() { - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200} - - err := agent.Scrobble(ctx, "user-1", sc) - Expect(err).ToNot(HaveOccurred()) - - decoder := json.NewDecoder(httpClient.SavedRequest.Body) - var lr listenBrainzRequestBody - err = decoder.Decode(&lr) - - Expect(err).ToNot(HaveOccurred()) - Expect(lr.Payload[0].ListenedAt).To(Equal(int(sc.TimeStamp.Unix()))) - }) - - It("returns ErrNotAuthorized if user is not linked", func() { - err := agent.Scrobble(ctx, "user-2", sc) - Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) - }) - - It("returns ErrRetryLater on error 503", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"code": 503, "error": "Cannot submit listens to queue, please try again later."}`)), - StatusCode: 503, - } - - err := agent.Scrobble(ctx, "user-1", sc) - Expect(err).To(MatchError(scrobbler.ErrRetryLater)) - }) - - It("returns ErrRetryLater on error 500", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"code": 500, "error": "Something went wrong. Please try again."}`)), - StatusCode: 500, - } - - err := agent.Scrobble(ctx, "user-1", sc) - Expect(err).To(MatchError(scrobbler.ErrRetryLater)) - }) - - It("returns ErrRetryLater on http errors", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`Bad Gateway`)), - StatusCode: 500, - } - - err := agent.Scrobble(ctx, "user-1", sc) - Expect(err).To(MatchError(scrobbler.ErrRetryLater)) - }) - - It("returns ErrUnrecoverable on other errors", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "error": "BadRequest: Invalid JSON document submitted."}`)), - StatusCode: 400, - } - - err := agent.Scrobble(ctx, "user-1", sc) - Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) - }) - - It("keeps a 429 scrobble for retry and carries the delay", func() { - httpClient.Res = http.Response{ - StatusCode: 429, - Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}}, - Body: io.NopCloser(bytes.NewBufferString(`{"code":429,"error":"rate limited"}`)), - } - err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) - Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) - retry, ok := errors.AsType[*agents.RetryLaterError](err) - Expect(ok).To(BeTrue()) - Expect(retry.RetryIn).To(Equal(7 * time.Second)) - }) - }) - - Describe("GetArtistUrl", func() { - var agent *listenBrainzAgent - var httpClient *tests.FakeHttpClient - BeforeEach(func() { - httpClient = &tests.FakeHttpClient{} - client := newClient("BASE_URL", httpClient) - agent = listenBrainzConstructor(ds) - agent.client = client - }) - - It("returns artist url when MBID present", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - Expect(agent.GetArtistURL(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")).To(Equal("http://projectmili.com/")) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) - }) - - It("returns error when url not present", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - _, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973") - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973")) - }) - - It("returns error when fetch calls fails", func() { - httpClient.Err = errors.New("error") - _, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973") - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973")) - }) - - It("returns error when ListenBrainz returns an error", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)), - StatusCode: 400, - } - _, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973") - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973")) - }) - }) - - Describe("GetTopSongs", func() { - var agent *listenBrainzAgent - var httpClient *tests.FakeHttpClient - BeforeEach(func() { - httpClient = &tests.FakeHttpClient{} - client := newClient("BASE_URL", httpClient) - agent = listenBrainzConstructor(ds) - agent.client = client - }) - - It("returns error when fetch calls", func() { - httpClient.Err = errors.New("error") - _, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1) - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) - }) - - It("returns an error on listenbrainz error", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)), - StatusCode: 400, - } - _, err := agent.GetArtistTopSongs(ctx, "", "", "1", 1) - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/1")) - }) - - It("returns all tracks when asked", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 2) - Expect(err).ToNot(HaveOccurred()) - Expect(data).To(Equal([]agents.Song{ - { - ID: "", - Name: "world.execute(me);", - MBID: "9980309d-3480-4e7e-89ce-fce971a452be", - Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}}, - Album: "Miracle Milk", - AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", - Duration: 211912, - }, - { - ID: "", - Name: "String Theocracy", - MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9", - Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}}, - Album: "String Theocracy", - AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e", - Duration: 174000, - }, - })) - }) - - It("returns only one track when prompted", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1) - Expect(err).ToNot(HaveOccurred()) - Expect(data).To(Equal([]agents.Song{ - { - ID: "", - Name: "world.execute(me);", - MBID: "9980309d-3480-4e7e-89ce-fce971a452be", - Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}}, - Album: "Miracle Milk", - AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", - Duration: 211912, - }, - })) - }) - - It("maps a multi-artist top song to one named artist plus MBID-only collaborators", func() { - body := `[{ - "recording_name": "Collab", - "recording_mbid": "rec-1", - "artist_name": "Drake feat. Future", - "artist_mbids": ["mbid-drake", "mbid-future"], - "release_name": "Album", - "release_mbid": "rel-1", - "length": 200000 - }]` - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200} - data, err := agent.GetArtistTopSongs(ctx, "", "", "mbid-drake", 1) - Expect(err).ToNot(HaveOccurred()) - Expect(data).To(HaveLen(1)) - Expect(data[0].Artists).To(Equal([]agents.Artist{ - {Name: "Drake feat. Future", MBID: "mbid-drake"}, - {MBID: "mbid-future"}, - })) - }) - - It("leaves Artists nil when the top song carries no name or MBIDs", func() { - body := `[{"recording_name": "Anon", "recording_mbid": "rec-1", "artist_name": "", "artist_mbids": []}]` - httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200} - data, err := agent.GetArtistTopSongs(ctx, "", "", "x", 1) - Expect(err).ToNot(HaveOccurred()) - Expect(data).To(HaveLen(1)) - Expect(data[0].Artists).To(BeNil()) - }) - }) - - Describe("GetSimilarArtists", func() { - var agent *listenBrainzAgent - var httpClient *tests.FakeHttpClient - baseUrl := "https://labs.api.listenbrainz.org/similar-artists/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids=" - mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50" - - BeforeEach(func() { - httpClient = &tests.FakeHttpClient{} - client := newClient("BASE_URL", httpClient) - agent = listenBrainzConstructor(ds) - agent.client = client - }) - - It("returns error when fetch calls", func() { - httpClient.Err = errors.New("error") - _, err := agent.GetSimilarArtists(ctx, "", "", mbid, 1) - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) - }) - - It("returns an error on listenbrainz error", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`Bad request`)), - StatusCode: 400, - } - _, err := agent.GetSimilarArtists(ctx, "", "", "1", 1) - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1")) - }) - - It("returns all data on call", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 2) - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) - Expect(resp).To(Equal([]agents.Artist{ - {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"}, - {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha"}, - })) - }) - - It("returns subset of data on call", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 1) - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) - Expect(resp).To(Equal([]agents.Artist{ - {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"}, - })) - }) - }) - - Describe("GetSimilarTracks", func() { - var agent *listenBrainzAgent - var httpClient *tests.FakeHttpClient - mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae" - baseUrl := "https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&recording_mbids=" - - BeforeEach(func() { - httpClient = &tests.FakeHttpClient{} - client := newClient("BASE_URL", httpClient) - agent = listenBrainzConstructor(ds) - agent.client = client - }) - - It("returns error when fetch calls", func() { - httpClient.Err = errors.New("error") - _, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1) - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) - }) - - It("returns an error on listenbrainz error", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`Bad request`)), - StatusCode: 400, - } - _, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", "1", 1) - Expect(err).To(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1")) - }) - - It("returns all data on call", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) - Expect(resp).To(Equal([]agents.Song{ - { - ID: "", - Name: "Take On Me", - MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", - ISRC: "", - Artists: []agents.Artist{{Name: "a‐ha"}}, - Album: "Hunting High and Low", - AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", - Duration: 0, - }, - { - ID: "", - Name: "Wake Me Up Before You Go‐Go", - MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", - ISRC: "", - Artists: []agents.Artist{{Name: "Wham!"}}, - Album: "Make It Big", - AlbumMBID: "c143d542-48dc-446b-b523-1762da721638", - Duration: 0, - }, - })) - }) - - It("returns subset of data on call", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - - resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.RequestCount).To(Equal(1)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid)) - Expect(resp).To(Equal([]agents.Song{ - { - ID: "", - Name: "Take On Me", - MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", - ISRC: "", - Artists: []agents.Artist{{Name: "a‐ha"}}, - Album: "Hunting High and Low", - AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", - Duration: 0, - }, - })) - }) - }) -}) diff --git a/adapters/listenbrainz/client.go b/adapters/listenbrainz/client.go deleted file mode 100644 index aae4fb51d..000000000 --- a/adapters/listenbrainz/client.go +++ /dev/null @@ -1,395 +0,0 @@ -package listenbrainz - -import ( - "bytes" - "cmp" - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "path" - "slices" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/log" -) - -const ( - lbzApiUrl = "https://api.listenbrainz.org/1/" - labsBase = "https://labs.api.listenbrainz.org/" -) - -// retryLaterErr reads the wait ListenBrainz asked for. It sends X-RateLimit-Reset-In -// (delta-seconds) on every response, including the 429, and never Retry-After. -func retryLaterErr(h http.Header) *agents.RetryLaterError { - return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(h.Get("X-RateLimit-Reset-In"))} -} - -var ( - ErrorNotFound = errors.New("listenbrainz: not found") -) - -type listenBrainzError struct { - Code int - Message string -} - -func (e *listenBrainzError) Error() string { - return fmt.Sprintf("ListenBrainz error(%d): %s", e.Code, e.Message) -} - -type httpDoer interface { - Do(req *http.Request) (*http.Response, error) -} - -func newClient(baseURL string, hc httpDoer) *client { - return &client{baseURL, hc} -} - -type client struct { - baseURL string - hc httpDoer -} - -type listenBrainzResponse struct { - Code int `json:"code"` - Message string `json:"message"` - Error string `json:"error"` - Status string `json:"status"` - Valid bool `json:"valid"` - UserName string `json:"user_name"` -} - -type listenBrainzRequest struct { - ApiKey string //nolint:gosec - Body listenBrainzRequestBody -} - -type listenBrainzRequestBody struct { - ListenType listenType `json:"listen_type,omitempty"` - Payload []listenInfo `json:"payload,omitempty"` -} - -type listenType string - -const ( - Single listenType = "single" - PlayingNow listenType = "playing_now" -) - -type listenInfo struct { - ListenedAt int `json:"listened_at,omitempty"` - TrackMetadata trackMetadata `json:"track_metadata"` -} - -type trackMetadata struct { - ArtistName string `json:"artist_name,omitempty"` - TrackName string `json:"track_name,omitempty"` - ReleaseName string `json:"release_name,omitempty"` - AdditionalInfo additionalInfo `json:"additional_info"` -} - -type additionalInfo struct { - SubmissionClient string `json:"submission_client,omitempty"` - SubmissionClientVersion string `json:"submission_client_version,omitempty"` - TrackNumber int `json:"tracknumber,omitempty"` - ArtistNames []string `json:"artist_names,omitempty"` - ArtistMBIDs []string `json:"artist_mbids,omitempty"` - RecordingMBID string `json:"recording_mbid,omitempty"` - ReleaseMBID string `json:"release_mbid,omitempty"` - ReleaseGroupMBID string `json:"release_group_mbid,omitempty"` - DurationMs int `json:"duration_ms,omitempty"` -} - -func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) { - r := &listenBrainzRequest{ - ApiKey: apiKey, - } - response, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, "validate-token", r) - if err != nil { - return nil, err - } - return response, nil -} - -func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error { - r := &listenBrainzRequest{ - ApiKey: apiKey, - Body: listenBrainzRequestBody{ - ListenType: PlayingNow, - Payload: []listenInfo{li}, - }, - } - - resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r) - if err != nil { - return err - } - if resp.Status != "ok" { - log.Warn(ctx, "ListenBrainz: NowPlaying was not accepted", "status", resp.Status) - } - return nil -} - -func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) error { - r := &listenBrainzRequest{ - ApiKey: apiKey, - Body: listenBrainzRequestBody{ - ListenType: Single, - Payload: []listenInfo{li}, - }, - } - resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r) - if err != nil { - return err - } - if resp.Status != "ok" { - log.Warn(ctx, "ListenBrainz: Scrobble was not accepted", "status", resp.Status) - } - return nil -} - -func (c *client) path(endpoint string) (string, error) { - u, err := url.Parse(c.baseURL) - if err != nil { - return "", err - } - u.Path = path.Join(u.Path, endpoint) - return u.String(), nil -} - -func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) { - b, _ := json.Marshal(r.Body) - uri, err := c.path(endpoint) - if err != nil { - return nil, err - } - req, _ := http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b)) - req.Header.Add("Content-Type", "application/json; charset=UTF-8") - - if r.ApiKey != "" { - req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey)) - } - - log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL) - resp, err := c.hc.Do(req) - if err != nil { - return nil, err - } - - defer resp.Body.Close() - if resp.StatusCode == http.StatusTooManyRequests { - return nil, retryLaterErr(resp.Header) - } - decoder := json.NewDecoder(resp.Body) - - var response listenBrainzResponse - jsonErr := decoder.Decode(&response) - if resp.StatusCode != 200 && jsonErr != nil { - return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) - } - if jsonErr != nil { - return nil, jsonErr - } - if response.Code != 0 && response.Code != 200 { - // LB also reports rate limiting as a body code, not only as an HTTP status. - if response.Code == http.StatusTooManyRequests { - return &response, retryLaterErr(resp.Header) - } - return &response, &listenBrainzError{Code: response.Code, Message: response.Error} - } - - return &response, nil -} - -type lbzHttpError struct { - Code int `json:"code"` - Error string `json:"error"` -} - -func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint string, params url.Values) (*http.Response, error) { - req, _ := http.NewRequestWithContext(ctx, method, lbzApiUrl+endpoint, nil) - req.Header.Add("Content-Type", "application/json; charset=UTF-8") - req.URL.RawQuery = params.Encode() - - log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL) - resp, err := c.hc.Do(req) - - if err != nil { - return nil, err - } - - // On a 200 code, there is no code. Decode using using error message if it exists - if resp.StatusCode != 200 { - defer resp.Body.Close() - if resp.StatusCode == http.StatusTooManyRequests { - return nil, retryLaterErr(resp.Header) - } - decoder := json.NewDecoder(resp.Body) - - var lbzError lbzHttpError - jsonErr := decoder.Decode(&lbzError) - - if jsonErr != nil { - return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) - } - - return nil, &listenBrainzError{Code: lbzError.Code, Message: lbzError.Error} - } - - return resp, err -} - -type artistMetadataResult struct { - Rels struct { - OfficialHomepage string `json:"official homepage,omitempty"` - } `json:"rels,omitzero"` -} - -func (c *client) getArtistUrl(ctx context.Context, mbid string) (string, error) { - params := url.Values{} - params.Add("artist_mbids", mbid) - resp, err := c.makeGenericRequest(ctx, http.MethodGet, "metadata/artist", params) - if err != nil { - return "", err - } - - defer resp.Body.Close() - decoder := json.NewDecoder(resp.Body) - - var response []artistMetadataResult - jsonErr := decoder.Decode(&response) - if jsonErr != nil { - return "", fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) - } - - if len(response) == 0 || response[0].Rels.OfficialHomepage == "" { - return "", ErrorNotFound - } - - return response[0].Rels.OfficialHomepage, nil -} - -type trackInfo struct { - ArtistName string `json:"artist_name"` - ArtistMBIDs []string `json:"artist_mbids"` - DurationMs uint32 `json:"length"` - RecordingName string `json:"recording_name"` - RecordingMbid string `json:"recording_mbid"` - ReleaseName string `json:"release_name"` - ReleaseMBID string `json:"release_mbid"` -} - -func (c *client) getArtistTopSongs(ctx context.Context, mbid string, count int) ([]trackInfo, error) { - resp, err := c.makeGenericRequest(ctx, http.MethodGet, "popularity/top-recordings-for-artist/"+mbid, url.Values{}) - if err != nil { - return nil, err - } - - defer resp.Body.Close() - decoder := json.NewDecoder(resp.Body) - - var response []trackInfo - jsonErr := decoder.Decode(&response) - if jsonErr != nil { - return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) - } - - if len(response) > count { - return response[0:count], nil - } - - return response, nil -} - -type artist struct { - MBID string `json:"artist_mbid"` - Name string `json:"name"` - Score int `json:"score"` -} - -func (c *client) getSimilarArtists(ctx context.Context, mbid string, limit int) ([]artist, error) { - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil) - req.Header.Add("Content-Type", "application/json; charset=UTF-8") - req.URL.RawQuery = url.Values{ - "artist_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.ArtistAlgorithm}, - }.Encode() - - log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL) - resp, err := c.hc.Do(req) - - if err != nil { - return nil, err - } - - defer resp.Body.Close() - decoder := json.NewDecoder(resp.Body) - - var artists []artist - jsonErr := decoder.Decode(&artists) - if jsonErr != nil { - return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) - } - - if len(artists) > limit { - return artists[:limit], nil - } - - return artists, nil -} - -type recording struct { - MBID string `json:"recording_mbid"` - Name string `json:"recording_name"` - Artist string `json:"artist_credit_name"` - ReleaseName string `json:"release_name"` - ReleaseMBID string `json:"release_mbid"` - Score int `json:"score"` -} - -func (c *client) getSimilarRecordings(ctx context.Context, mbid string, limit int) ([]recording, error) { - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil) - req.Header.Add("Content-Type", "application/json; charset=UTF-8") - req.URL.RawQuery = url.Values{ - "recording_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.TrackAlgorithm}, - }.Encode() - - log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL) - resp, err := c.hc.Do(req) - - if err != nil { - return nil, err - } - - defer resp.Body.Close() - decoder := json.NewDecoder(resp.Body) - - var recordings []recording - jsonErr := decoder.Decode(&recordings) - if jsonErr != nil { - return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) - } - - // For whatever reason, labs API isn't guaranteed to give results in the proper order - // and may also provide duplicates. See listenbrainz.labs.similar-recordings-real-out-of-order.json - // generated from https://labs.api.listenbrainz.org/similar-recordings/json?recording_mbids=8f3471b5-7e6a-48da-86a9-c1c07a0f47ae&algorithm=session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30 - slices.SortFunc(recordings, func(a, b recording) int { - return cmp.Or( - cmp.Compare(b.Score, a.Score), // Sort by score descending - cmp.Compare(a.MBID, b.MBID), // Then by MBID ascending to ensure deterministic order for duplicates - ) - }) - - recordings = slices.CompactFunc(recordings, func(a, b recording) bool { - return a.MBID == b.MBID - }) - - if len(recordings) > limit { - return recordings[:limit], nil - } - - return recordings, nil -} diff --git a/adapters/listenbrainz/client_test.go b/adapters/listenbrainz/client_test.go deleted file mode 100644 index ec0b0ac11..000000000 --- a/adapters/listenbrainz/client_test.go +++ /dev/null @@ -1,537 +0,0 @@ -package listenbrainz - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("client", func() { - var httpClient *tests.FakeHttpClient - var client *client - BeforeEach(func() { - httpClient = &tests.FakeHttpClient{} - client = newClient("BASE_URL/", httpClient) - }) - - Describe("listenBrainzResponse", func() { - It("parses a response properly", func() { - var response listenBrainzResponse - err := json.Unmarshal([]byte(`{"code": 200, "message": "Message", "user_name": "UserName", "valid": true, "status": "ok", "error": "Error"}`), &response) - - Expect(err).ToNot(HaveOccurred()) - Expect(response.Code).To(Equal(200)) - Expect(response.Message).To(Equal("Message")) - Expect(response.UserName).To(Equal("UserName")) - Expect(response.Valid).To(BeTrue()) - Expect(response.Status).To(Equal("ok")) - Expect(response.Error).To(Equal("Error")) - }) - }) - - Describe("validateToken", func() { - BeforeEach(func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)), - StatusCode: 200, - } - }) - - It("formats the request properly", func() { - _, err := client.validateToken(context.Background(), "LB-TOKEN") - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/validate-token")) - Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN")) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - }) - - It("parses and returns the response", func() { - res, err := client.validateToken(context.Background(), "LB-TOKEN") - Expect(err).ToNot(HaveOccurred()) - Expect(res.Valid).To(Equal(true)) - Expect(res.UserName).To(Equal("ListenBrainzUser")) - }) - }) - - Context("with listenInfo", func() { - var li listenInfo - BeforeEach(func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), - StatusCode: 200, - } - li = listenInfo{ - TrackMetadata: trackMetadata{ - ArtistName: "Track Artist", - TrackName: "Track Title", - ReleaseName: "Track Album", - AdditionalInfo: additionalInfo{ - TrackNumber: 1, - ArtistNames: []string{"Artist 1", "Artist 2"}, - ArtistMBIDs: []string{"mbz-789", "mbz-012"}, - RecordingMBID: "mbz-123", - ReleaseMBID: "mbz-456", - DurationMs: 142200, - }, - }, - } - }) - - Describe("updateNowPlaying", func() { - It("formats the request properly", func() { - Expect(client.updateNowPlaying(context.Background(), "LB-TOKEN", li)).To(Succeed()) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens")) - Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN")) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - - body, _ := io.ReadAll(httpClient.SavedRequest.Body) - f, _ := os.ReadFile("tests/fixtures/listenbrainz.nowplaying.request.json") - Expect(body).To(MatchJSON(f)) - }) - }) - - Describe("scrobble", func() { - BeforeEach(func() { - li.ListenedAt = 1635000000 - }) - - It("formats the request properly", func() { - Expect(client.scrobble(context.Background(), "LB-TOKEN", li)).To(Succeed()) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens")) - Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN")) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - - body, _ := io.ReadAll(httpClient.SavedRequest.Body) - f, _ := os.ReadFile("tests/fixtures/listenbrainz.scrobble.request.json") - Expect(body).To(MatchJSON(f)) - }) - }) - }) - - Context("getArtistUrl", func() { - baseUrl := "https://api.listenbrainz.org/1/metadata/artist?" - It("handles a malformed request with status code", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)), - StatusCode: 400, - } - _, err := client.getArtistUrl(context.Background(), "1") - Expect(err.Error()).To(Equal("ListenBrainz error(400): artist mbid 1 is not valid.")) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1")) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - }) - - It("handles a malformed request without meaningful body", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(``)), - StatusCode: 501, - } - _, err := client.getArtistUrl(context.Background(), "1") - Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (501)")) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1")) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - }) - - It("It returns not found when the artist has no official homepage", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - _, err := client.getArtistUrl(context.Background(), "7c2cc610-f998-43ef-a08f-dae3344b8973") - Expect(err.Error()).To(Equal("listenbrainz: not found")) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=7c2cc610-f998-43ef-a08f-dae3344b8973")) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - }) - - It("It returns data when the artist has a homepage", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - url, err := client.getArtistUrl(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56") - Expect(err).ToNot(HaveOccurred()) - Expect(url).To(Equal("http://projectmili.com/")) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - }) - }) - - Context("getArtistTopSongs", func() { - baseUrl := "https://api.listenbrainz.org/1/popularity/top-recordings-for-artist/" - - It("handles a malformed request with status code", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)), - StatusCode: 400, - } - _, err := client.getArtistTopSongs(context.Background(), "1", 50) - Expect(err.Error()).To(Equal("ListenBrainz error(400): artist_mbid: '1' is not a valid uuid")) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1")) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - }) - - It("handles a malformed request without standard body", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(``)), - StatusCode: 500, - } - _, err := client.getArtistTopSongs(context.Background(), "1", 1) - Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (500)")) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1")) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - }) - - It("It returns all tracks when given the opportunity", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 5) - Expect(err).ToNot(HaveOccurred()) - Expect(data).To(Equal([]trackInfo{ - { - ArtistName: "Mili", - ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}, - DurationMs: 211912, - RecordingName: "world.execute(me);", - RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be", - ReleaseName: "Miracle Milk", - ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", - }, - { - ArtistName: "Mili", - ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}, - DurationMs: 174000, - RecordingName: "String Theocracy", - RecordingMbid: "afa2c83d-b17f-4029-b9da-790ea9250cf9", - ReleaseName: "String Theocracy", - ReleaseMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e", - }, - })) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - }) - - It("It returns a subset of tracks when allowed", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1) - Expect(err).ToNot(HaveOccurred()) - Expect(data).To(Equal([]trackInfo{ - { - ArtistName: "Mili", - ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}, - DurationMs: 211912, - RecordingName: "world.execute(me);", - RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be", - ReleaseName: "Miracle Milk", - ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408", - }, - })) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - }) - }) - - Context("getSimilarArtists", func() { - var algorithm string - - BeforeEach(func() { - algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30" - DeferCleanup(configtest.SetupConfig()) - }) - - getUrl := func(mbid string) string { - return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-artists/json?algorithm=%s&artist_mbids=%s", algorithm, mbid) - } - - mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50" - - It("handles a malformed request with status code", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`Bad request`)), - StatusCode: 400, - } - _, err := client.getSimilarArtists(context.Background(), "1", 2) - Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)")) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1"))) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - }) - - It("handles real data properly", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - resp, err := client.getSimilarArtists(context.Background(), mbid, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - Expect(resp).To(Equal([]artist{ - {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800}, - {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792}, - })) - }) - - It("truncates data when requested", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - resp, err := client.getSimilarArtists(context.Background(), "db92a151-1ac2-438b-bc43-b82e149ddd50", 1) - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - Expect(resp).To(Equal([]artist{ - {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800}, - })) - }) - - It("fetches a different endpoint when algorithm changes", func() { - algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30" - conf.Server.ListenBrainz.ArtistAlgorithm = algorithm - - f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - resp, err := client.getSimilarArtists(context.Background(), mbid, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - Expect(resp).To(Equal([]artist{ - {MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800}, - {MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792}, - })) - }) - }) - - Context("getSimilarRecordings", func() { - var algorithm string - - BeforeEach(func() { - algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30" - DeferCleanup(configtest.SetupConfig()) - }) - - getUrl := func(mbid string) string { - return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=%s&recording_mbids=%s", algorithm, mbid) - } - - mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae" - - It("handles a malformed request with status code", func() { - httpClient.Res = http.Response{ - Body: io.NopCloser(bytes.NewBufferString(`Bad request`)), - StatusCode: 400, - } - _, err := client.getSimilarRecordings(context.Background(), "1", 2) - Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)")) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1"))) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - }) - - It("handles real data properly", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - resp, err := client.getSimilarRecordings(context.Background(), mbid, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - Expect(resp).To(Equal([]recording{ - { - MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", - Name: "Take On Me", - Artist: "a‐ha", - ReleaseName: "Hunting High and Low", - ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", - Score: 124, - }, - { - MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", - Name: "Wake Me Up Before You Go‐Go", - Artist: "Wham!", - ReleaseName: "Make It Big", - ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638", - Score: 65, - }, - })) - }) - - It("truncates data when requested", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - resp, err := client.getSimilarRecordings(context.Background(), mbid, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - Expect(resp).To(Equal([]recording{ - { - MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", - Name: "Take On Me", - Artist: "a‐ha", - ReleaseName: "Hunting High and Low", - ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", - Score: 124, - }, - })) - }) - - It("properly sorts by score and truncates duplicates", func() { - f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - // There are actually 5 items. The dedup should happen FIRST - resp, err := client.getSimilarRecordings(context.Background(), mbid, 4) - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - Expect(resp).To(Equal([]recording{ - { - MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", - Name: "Take On Me", - Artist: "a‐ha", - ReleaseName: "Hunting High and Low", - ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", - Score: 124, - }, - { - MBID: "e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5", - Name: "Everybody Wants to Rule the World", - Artist: "Tears for Fears", - ReleaseName: "Songs From the Big Chair", - ReleaseMBID: "21f19b06-81f1-347a-add5-5d0c77696597", - Score: 68, - }, - { - MBID: "80033c72-aa19-4ba8-9227-afb075fec46e", - Name: "Wake Me Up Before You Go‐Go", - Artist: "Wham!", - ReleaseName: "Make It Big", - ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638", - Score: 65, - }, - { - MBID: "ef4c6855-949e-4e22-b41e-8e0a2d372d5f", - Name: "Tainted Love", - Artist: "Soft Cell", - ReleaseName: "Non-Stop Erotic Cabaret", - ReleaseMBID: "1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1", - Score: 61, - }, - })) - }) - - It("uses a different algorithm when configured", func() { - algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30" - conf.Server.ListenBrainz.TrackAlgorithm = algorithm - - f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json") - httpClient.Res = http.Response{Body: f, StatusCode: 200} - resp, err := client.getSimilarRecordings(context.Background(), mbid, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) - Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid))) - Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) - Expect(resp).To(Equal([]recording{ - { - MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3", - Name: "Take On Me", - Artist: "a‐ha", - ReleaseName: "Hunting High and Low", - ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc", - Score: 124, - }, - })) - }) - }) - - Describe("rate limiting", func() { - It("returns RetryLaterError with the header delay on 429", func() { - httpClient.Res = http.Response{ - StatusCode: 429, - Header: http.Header{"X-Ratelimit-Reset-In": []string{"3"}}, - Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)), - } - _, err := client.validateToken(context.Background(), "token") - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - retry, ok := errors.AsType[*agents.RetryLaterError](err) - Expect(ok).To(BeTrue()) - Expect(retry.RetryIn).To(Equal(3 * time.Second)) - }) - - It("returns RetryLaterError with zero delay when no header is present", func() { - httpClient.Res = http.Response{ - StatusCode: 429, - Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), - } - _, err := client.validateToken(context.Background(), "token") - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - retry, _ := errors.AsType[*agents.RetryLaterError](err) - Expect(retry.RetryIn).To(BeZero()) - }) - - DescribeTable("caps absurd header values at one hour", - func(header string) { - httpClient.Res = http.Response{ - StatusCode: 429, - Header: http.Header{"X-Ratelimit-Reset-In": []string{header}}, - Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), - } - _, err := client.validateToken(context.Background(), "token") - retry, _ := errors.AsType[*agents.RetryLaterError](err) - Expect(retry.RetryIn).To(Equal(time.Hour)) - }, - Entry("a large value", "999999"), - Entry("a huge value", "99999999999"), - // Scaling this to nanoseconds before capping wraps past 2^64, landing on ~0.29s. - Entry("a value that overflows int64 nanoseconds", "18446744074"), - ) - - It("maps a body-level 429 sent with a non-429 status", func() { - httpClient.Res = http.Response{ - StatusCode: 200, - Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}}, - Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)), - } - _, err := client.validateToken(context.Background(), "token") - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - retry, ok := errors.AsType[*agents.RetryLaterError](err) - Expect(ok).To(BeTrue()) - Expect(retry.RetryIn).To(Equal(7 * time.Second)) - }) - - It("returns RetryLaterError on a 429 from makeGenericRequest", func() { - httpClient.Res = http.Response{ - StatusCode: 429, - Header: http.Header{"X-Ratelimit-Reset-In": []string{"5"}}, - Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)), - } - _, err := client.getArtistUrl(context.Background(), "1") - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - retry, ok := errors.AsType[*agents.RetryLaterError](err) - Expect(ok).To(BeTrue()) - Expect(retry.RetryIn).To(Equal(5 * time.Second)) - }) - }) -}) diff --git a/api/.oasdiff-levels.txt b/api/.oasdiff-levels.txt deleted file mode 100644 index 1792adcb2..000000000 --- a/api/.oasdiff-levels.txt +++ /dev/null @@ -1 +0,0 @@ -response-property-enum-value-added INFO diff --git a/api/.vacuum.yaml b/api/.vacuum.yaml deleted file mode 100644 index bd31e23f8..000000000 --- a/api/.vacuum.yaml +++ /dev/null @@ -1,155 +0,0 @@ -extends: [[spectral:oas, recommended]] -rules: - # vacuum's `enumeration` function mis-resolves hyphenated `then.field` names, - # so the value check below targets `x-module` via `given` instead. - nd-operation-x-module-required: - description: Every operation belongs to exactly one capability module. - severity: error - given: $.paths[*][get,put,post,delete,patch] - then: - field: x-module - function: truthy - nd-operation-x-module: - description: Every operation's capability module is one of the known values. - severity: error - given: $.paths[*][get,put,post,delete,patch]['x-module'] - then: - function: enumeration - functionOptions: - values: - - core - - streaming - - download - - artwork - - lyrics - - transcoding - - annotations - - playback - - queue - - custom-tags - - grouping - - playlists - - smart-playlists - - sync - - events - - jukebox - - sharing - - radio - - admin - nd-operation-stability-level-required: - description: Every operation declares its stability level, which the breaking-change gate relies on. - severity: error - given: $.paths[*][get,put,post,delete,patch] - then: - field: x-stability-level - function: truthy - nd-operation-stability-level: - description: Every operation's stability level is alpha, beta, or stable. - severity: error - given: $.paths[*][get,put,post,delete,patch]['x-stability-level'] - then: - function: enumeration - functionOptions: - values: - - alpha - - beta - - stable - nd-operation-required-fields: - description: Operations need a stable operationId, summary, description and tags. - severity: error - given: $.paths[*][get,put,post,delete,patch] - then: - - field: operationId - function: truthy - - field: summary - function: truthy - - field: description - function: truthy - - field: tags - function: truthy - # Our schemas live in path/response files, not root components, so this - # walks every resolved `properties` map in the document via `$..` instead. - nd-schema-property-descriptions: - description: Every schema property is documented. - severity: error - given: $..properties[*] - then: - field: description - function: truthy - # patternProperties covers the full 4xx/5xx range; needs an explicit - # `properties` entry too, or `additionalProperties: false` rejects it. - nd-error-responses-are-problems: - description: 4xx and 5xx responses use application/problem+json. - severity: error - given: $.paths[*][*].responses - then: - function: schema - functionOptions: - forceValidationOnCurrentNode: true - schema: - type: object - patternProperties: - "^[45][0-9][0-9]$": - type: object - required: [content] - properties: - content: - type: object - properties: - application/problem+json: {} - required: [application/problem+json] - additionalProperties: false - # Same filter limitation applies here: "is this a list endpoint" is expressed - # as a JSON Schema if/then on the operation object instead of a `given` filter. - nd-list-endpoints-paginate: - description: List endpoints declare the shared offset and limit parameters. - severity: error - given: $.paths[*].get - then: - function: schema - functionOptions: - forceValidationOnCurrentNode: true - schema: - type: object - if: - required: [responses] - properties: - responses: - type: object - required: ['200'] - properties: - '200': - type: object - required: [content] - properties: - content: - type: object - required: [application/json] - properties: - application/json: - type: object - required: [schema] - properties: - schema: - type: object - required: [properties] - properties: - properties: - type: object - required: [items] - then: - required: [parameters] - properties: - parameters: - type: array - allOf: - - contains: - type: object - properties: - name: - const: offset - - contains: - type: object - properties: - name: - const: limit diff --git a/api/bundled/openapi.json b/api/bundled/openapi.json deleted file mode 100644 index bfe794c71..000000000 --- a/api/bundled/openapi.json +++ /dev/null @@ -1,262 +0,0 @@ -{ - "openapi": "3.0.3", - "info": { - "title": "Navidrome API", - "version": "1.0.0", - "description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n", - "license": { - "name": "GPL-3.0", - "url": "https://www.gnu.org/licenses/gpl-3.0.html" - } - }, - "servers": [ - { - "url": "/api/v1" - } - ], - "tags": [ - { - "name": "server", - "description": "Server discovery and the published OpenAPI document." - } - ], - "paths": { - "/server": { - "get": { - "operationId": "getServerInfo", - "x-module": "core", - "x-stability-level": "alpha", - "tags": [ - "server" - ], - "summary": "Describe the server", - "description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n", - "responses": { - "200": { - "description": "Server description.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServerInfo" - } - } - } - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/openapi.json": { - "get": { - "operationId": "getOpenAPISpecJSON", - "x-module": "core", - "x-stability-level": "alpha", - "tags": [ - "server" - ], - "summary": "Get the OpenAPI document (JSON)", - "description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.", - "responses": { - "200": { - "description": "The OpenAPI document.", - "headers": { - "ETag": { - "$ref": "#/components/headers/ETag" - } - }, - "content": { - "application/json": { - "schema": { - "type": "object", - "description": "OpenAPI 3.0 document." - } - } - } - }, - "304": { - "$ref": "#/components/responses/NotModified" - } - } - } - }, - "/openapi.yaml": { - "get": { - "operationId": "getOpenAPISpecYAML", - "x-module": "core", - "x-stability-level": "alpha", - "tags": [ - "server" - ], - "summary": "Get the OpenAPI document (YAML)", - "description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.", - "responses": { - "200": { - "description": "The OpenAPI document.", - "headers": { - "ETag": { - "$ref": "#/components/headers/ETag" - } - }, - "content": { - "application/yaml": { - "schema": { - "type": "object", - "description": "OpenAPI 3.0 document." - } - } - } - }, - "304": { - "$ref": "#/components/responses/NotModified" - } - } - } - } - }, - "components": { - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT", - "description": "Short-lived access token minted from a device grant. Not yet applied to any operation." - } - }, - "schemas": { - "ServerInfo": { - "type": "object", - "description": "Public server description. Everything an add-server screen needs before login.", - "required": [ - "name", - "serverVersion", - "specVersion", - "setupRequired", - "loginMethods" - ], - "properties": { - "name": { - "type": "string", - "description": "Human-readable server product name." - }, - "serverVersion": { - "type": "string", - "description": "Version of the running server build." - }, - "specVersion": { - "type": "string", - "description": "Version of the OpenAPI document this server implements." - }, - "setupRequired": { - "type": "boolean", - "description": "True until the first admin user has been created." - }, - "loginMethods": { - "type": "array", - "description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.", - "items": { - "type": "string", - "enum": [ - "password" - ] - } - } - } - }, - "Problem": { - "type": "object", - "description": "RFC 9457 problem details, returned for every 4xx and 5xx response.", - "required": [ - "title", - "status", - "code" - ], - "properties": { - "type": { - "type": "string", - "description": "URI reference identifying the problem type. Omitted while the problem carries no semantics\nbeyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their\nown semantics get their own URI; switch on `code` instead.\n" - }, - "title": { - "type": "string", - "description": "Short human-readable summary, the same for all occurrences of this problem type." - }, - "status": { - "type": "integer", - "description": "HTTP status code of this response." - }, - "detail": { - "type": "string", - "description": "Human-readable explanation specific to this occurrence. Omitted for internal errors." - }, - "code": { - "type": "string", - "description": "Machine-readable error code, and the value clients switch on. New codes may be added.", - "enum": [ - "validation", - "unauthorized", - "forbidden", - "not_found", - "method_not_allowed", - "unavailable", - "internal" - ] - }, - "errors": { - "type": "array", - "description": "Per-field failures. Present only when `code` is `validation`.", - "items": { - "$ref": "#/components/schemas/ValidationError" - } - } - } - }, - "ValidationError": { - "type": "object", - "description": "One field-level validation failure.", - "required": [ - "field", - "message" - ], - "properties": { - "field": { - "type": "string", - "description": "Name of the offending query parameter, path parameter, or body field (dotted for nested)." - }, - "message": { - "type": "string", - "description": "Why the value was rejected." - } - } - } - }, - "responses": { - "InternalError": { - "description": "Unexpected server failure. Details are in the server log.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Problem" - } - } - } - }, - "NotModified": { - "description": "Not modified.", - "headers": { - "ETag": { - "$ref": "#/components/headers/ETag" - } - } - } - }, - "headers": { - "ETag": { - "description": "Entity tag for `If-None-Match` revalidation.", - "schema": { - "type": "string" - } - } - } - } -} \ No newline at end of file diff --git a/api/bundled/openapi.yaml b/api/bundled/openapi.yaml deleted file mode 100644 index f1ae95b76..000000000 --- a/api/bundled/openapi.yaml +++ /dev/null @@ -1,194 +0,0 @@ -openapi: 3.0.3 -info: - title: Navidrome API - version: 1.0.0 - description: | - Navidrome API v1. Spec-first, additive within v1. Clients discover implemented - capability modules through `GET /server` and never sniff versions. - - Enums are open: new values may be added to any enum within v1. Clients must - accept values they do not recognise instead of failing. - - Every operation declares `x-stability-level`: `alpha` operations may change or - disappear without notice, `beta` and `stable` operations only change additively. - A level is only ever raised, never lowered. - - `HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods - in its `Allow` header. - license: - name: GPL-3.0 - url: https://www.gnu.org/licenses/gpl-3.0.html -servers: - - url: /api/v1 -tags: - - name: server - description: Server discovery and the published OpenAPI document. -paths: - /server: - get: - operationId: getServerInfo - x-module: core - x-stability-level: alpha - tags: [server] - summary: Describe the server - description: | - Returns the public server description. No authentication required. - Authenticated requests will additionally receive the implemented capability modules - once authentication is available. - responses: - '200': - description: Server description. - content: - application/json: - schema: - $ref: '#/components/schemas/ServerInfo' - '500': - $ref: '#/components/responses/InternalError' - /openapi.json: - get: - operationId: getOpenAPISpecJSON - x-module: core - x-stability-level: alpha - tags: [server] - summary: Get the OpenAPI document (JSON) - description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. - responses: - '200': - description: The OpenAPI document. - headers: - ETag: - $ref: '#/components/headers/ETag' - content: - application/json: - schema: - type: object - description: OpenAPI 3.0 document. - '304': - $ref: '#/components/responses/NotModified' - /openapi.yaml: - get: - operationId: getOpenAPISpecYAML - x-module: core - x-stability-level: alpha - tags: [server] - summary: Get the OpenAPI document (YAML) - description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. - responses: - '200': - description: The OpenAPI document. - headers: - ETag: - $ref: '#/components/headers/ETag' - content: - application/yaml: - schema: - type: object - description: OpenAPI 3.0 document. - '304': - $ref: '#/components/responses/NotModified' -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: Short-lived access token minted from a device grant. Not yet applied to any operation. - schemas: - ServerInfo: - type: object - description: Public server description. Everything an add-server screen needs before login. - required: - - name - - serverVersion - - specVersion - - setupRequired - - loginMethods - properties: - name: - type: string - description: Human-readable server product name. - serverVersion: - type: string - description: Version of the running server build. - specVersion: - type: string - description: Version of the OpenAPI document this server implements. - setupRequired: - type: boolean - description: True until the first admin user has been created. - loginMethods: - type: array - description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. - items: - type: string - enum: - - password - Problem: - type: object - description: RFC 9457 problem details, returned for every 4xx and 5xx response. - required: - - title - - status - - code - properties: - type: - type: string - description: | - URI reference identifying the problem type. Omitted while the problem carries no semantics - beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their - own semantics get their own URI; switch on `code` instead. - title: - type: string - description: Short human-readable summary, the same for all occurrences of this problem type. - status: - type: integer - description: HTTP status code of this response. - detail: - type: string - description: Human-readable explanation specific to this occurrence. Omitted for internal errors. - code: - type: string - description: Machine-readable error code, and the value clients switch on. New codes may be added. - enum: - - validation - - unauthorized - - forbidden - - not_found - - method_not_allowed - - unavailable - - internal - errors: - type: array - description: Per-field failures. Present only when `code` is `validation`. - items: - $ref: '#/components/schemas/ValidationError' - ValidationError: - type: object - description: One field-level validation failure. - required: - - field - - message - properties: - field: - type: string - description: Name of the offending query parameter, path parameter, or body field (dotted for nested). - message: - type: string - description: Why the value was rejected. - responses: - InternalError: - description: Unexpected server failure. Details are in the server log. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Problem' - NotModified: - description: Not modified. - headers: - ETag: - $ref: '#/components/headers/ETag' - headers: - ETag: - description: Entity tag for `If-None-Match` revalidation. - schema: - type: string diff --git a/api/embed.go b/api/embed.go deleted file mode 100644 index 7e94c2362..000000000 --- a/api/embed.go +++ /dev/null @@ -1,35 +0,0 @@ -package api - -import ( - _ "embed" - "encoding/json" - "sync" -) - -//go:embed bundled/openapi.json -var specJSON []byte - -//go:embed bundled/openapi.yaml -var specYAML []byte - -func SpecJSON() []byte { - return specJSON -} - -func SpecYAML() []byte { - return specYAML -} - -var specVersion = sync.OnceValue(func() string { - var doc struct { - Info struct { - Version string `json:"version"` - } `json:"info"` - } - _ = json.Unmarshal(SpecJSON(), &doc) - return doc.Info.Version -}) - -func SpecVersion() string { - return specVersion() -} diff --git a/api/embed_test.go b/api/embed_test.go deleted file mode 100644 index ebd5a7d1d..000000000 --- a/api/embed_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package api_test - -import ( - "os" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/navidrome/navidrome/api" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "gopkg.in/yaml.v3" -) - -var _ = Describe("Bundled spec", func() { - It("embeds a valid OpenAPI 3 document", func() { - doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON()) - Expect(err).ToNot(HaveOccurred()) - Expect(doc.Validate(GinkgoT().Context())).To(Succeed()) - Expect(doc.Paths.Find("/server")).ToNot(BeNil()) - }) - - It("embeds the YAML variant", func() { - var doc map[string]any - Expect(yaml.Unmarshal(api.SpecYAML(), &doc)).To(Succeed()) - Expect(doc).To(HaveKey("paths")) - }) - - It("reports the version from the bundle, matching the source root document", func() { - src, err := os.ReadFile("api/openapi/openapi.yaml") - Expect(err).ToNot(HaveOccurred()) - var root struct { - Info struct{ Version string } `yaml:"info"` - } - Expect(yaml.Unmarshal(src, &root)).To(Succeed()) - Expect(api.SpecVersion()).To(Equal(root.Info.Version)) - Expect(api.SpecVersion()).ToNot(BeEmpty()) - }) -}) diff --git a/api/openapi/components/headers/ETag.yaml b/api/openapi/components/headers/ETag.yaml deleted file mode 100644 index 0f64e6792..000000000 --- a/api/openapi/components/headers/ETag.yaml +++ /dev/null @@ -1,3 +0,0 @@ -description: Entity tag for `If-None-Match` revalidation. -schema: - type: string diff --git a/api/openapi/components/parameters/limit.yaml b/api/openapi/components/parameters/limit.yaml deleted file mode 100644 index 9bef4fc8b..000000000 --- a/api/openapi/components/parameters/limit.yaml +++ /dev/null @@ -1,9 +0,0 @@ -name: limit -in: query -description: Maximum number of items to return. -required: false -schema: - type: integer - minimum: 1 - maximum: 2000 - default: 100 diff --git a/api/openapi/components/responses/BadRequest.yaml b/api/openapi/components/responses/BadRequest.yaml deleted file mode 100644 index 1a6e0b657..000000000 --- a/api/openapi/components/responses/BadRequest.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: The request is malformed or fails validation. -content: - application/problem+json: - schema: - $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/Forbidden.yaml b/api/openapi/components/responses/Forbidden.yaml deleted file mode 100644 index 6259185ea..000000000 --- a/api/openapi/components/responses/Forbidden.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: The caller is authenticated but not allowed to do this. -content: - application/problem+json: - schema: - $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/InternalError.yaml b/api/openapi/components/responses/InternalError.yaml deleted file mode 100644 index 20e654d44..000000000 --- a/api/openapi/components/responses/InternalError.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: Unexpected server failure. Details are in the server log. -content: - application/problem+json: - schema: - $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/NotFound.yaml b/api/openapi/components/responses/NotFound.yaml deleted file mode 100644 index 6083a5cd1..000000000 --- a/api/openapi/components/responses/NotFound.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: No such resource or endpoint. -content: - application/problem+json: - schema: - $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/responses/NotModified.yaml b/api/openapi/components/responses/NotModified.yaml deleted file mode 100644 index 36bc1a61c..000000000 --- a/api/openapi/components/responses/NotModified.yaml +++ /dev/null @@ -1,4 +0,0 @@ -description: Not modified. -headers: - ETag: - $ref: ../headers/ETag.yaml diff --git a/api/openapi/components/responses/Unauthorized.yaml b/api/openapi/components/responses/Unauthorized.yaml deleted file mode 100644 index 0209f4dd9..000000000 --- a/api/openapi/components/responses/Unauthorized.yaml +++ /dev/null @@ -1,5 +0,0 @@ -description: Missing, invalid, or expired credentials. -content: - application/problem+json: - schema: - $ref: ../schemas/Problem.yaml diff --git a/api/openapi/components/schemas/ListMeta.yaml b/api/openapi/components/schemas/ListMeta.yaml deleted file mode 100644 index 6321e0588..000000000 --- a/api/openapi/components/schemas/ListMeta.yaml +++ /dev/null @@ -1,13 +0,0 @@ -type: object -description: Pagination metadata carried by every list response. -required: [total, offset, limit] -properties: - total: - type: integer - description: Total number of items matching the request, ignoring pagination. - offset: - type: integer - description: Zero-based index of the first returned item. - limit: - type: integer - description: Maximum number of items in this page. diff --git a/api/openapi/components/schemas/Problem.yaml b/api/openapi/components/schemas/Problem.yaml deleted file mode 100644 index 0fd36d4b1..000000000 --- a/api/openapi/components/schemas/Problem.yaml +++ /dev/null @@ -1,35 +0,0 @@ -type: object -description: RFC 9457 problem details, returned for every 4xx and 5xx response. -required: [title, status, code] -properties: - type: - type: string - description: | - URI reference identifying the problem type. Omitted while the problem carries no semantics - beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their - own semantics get their own URI; switch on `code` instead. - title: - type: string - description: Short human-readable summary, the same for all occurrences of this problem type. - status: - type: integer - description: HTTP status code of this response. - detail: - type: string - description: Human-readable explanation specific to this occurrence. Omitted for internal errors. - code: - type: string - description: Machine-readable error code, and the value clients switch on. New codes may be added. - enum: - - validation - - unauthorized - - forbidden - - not_found - - method_not_allowed - - unavailable - - internal - errors: - type: array - description: Per-field failures. Present only when `code` is `validation`. - items: - $ref: ./ValidationError.yaml diff --git a/api/openapi/components/schemas/ServerInfo.yaml b/api/openapi/components/schemas/ServerInfo.yaml deleted file mode 100644 index 8906924eb..000000000 --- a/api/openapi/components/schemas/ServerInfo.yaml +++ /dev/null @@ -1,22 +0,0 @@ -type: object -description: Public server description. Everything an add-server screen needs before login. -required: [name, serverVersion, specVersion, setupRequired, loginMethods] -properties: - name: - type: string - description: Human-readable server product name. - serverVersion: - type: string - description: Version of the running server build. - specVersion: - type: string - description: Version of the OpenAPI document this server implements. - setupRequired: - type: boolean - description: True until the first admin user has been created. - loginMethods: - type: array - description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. - items: - type: string - enum: [password] diff --git a/api/openapi/components/schemas/ValidationError.yaml b/api/openapi/components/schemas/ValidationError.yaml deleted file mode 100644 index 8a1cbc4f9..000000000 --- a/api/openapi/components/schemas/ValidationError.yaml +++ /dev/null @@ -1,10 +0,0 @@ -type: object -description: One field-level validation failure. -required: [field, message] -properties: - field: - type: string - description: Name of the offending query parameter, path parameter, or body field (dotted for nested). - message: - type: string - description: Why the value was rejected. diff --git a/api/openapi/openapi.yaml b/api/openapi/openapi.yaml deleted file mode 100644 index 73cbb7b27..000000000 --- a/api/openapi/openapi.yaml +++ /dev/null @@ -1,39 +0,0 @@ -openapi: 3.0.3 -info: - title: Navidrome API - version: 1.0.0 - description: | - Navidrome API v1. Spec-first, additive within v1. Clients discover implemented - capability modules through `GET /server` and never sniff versions. - - Enums are open: new values may be added to any enum within v1. Clients must - accept values they do not recognise instead of failing. - - Every operation declares `x-stability-level`: `alpha` operations may change or - disappear without notice, `beta` and `stable` operations only change additively. - A level is only ever raised, never lowered. - - `HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods - in its `Allow` header. - license: - name: GPL-3.0 - url: https://www.gnu.org/licenses/gpl-3.0.html -servers: - - url: /api/v1 -tags: - - name: server - description: Server discovery and the published OpenAPI document. -paths: - /server: - $ref: ./paths/server.yaml - /openapi.json: - $ref: ./paths/openapi.yaml#/json - /openapi.yaml: - $ref: ./paths/openapi.yaml#/yaml -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: Short-lived access token minted from a device grant. Not yet applied to any operation. diff --git a/api/openapi/paths/openapi.yaml b/api/openapi/paths/openapi.yaml deleted file mode 100644 index 3c25dc8b7..000000000 --- a/api/openapi/paths/openapi.yaml +++ /dev/null @@ -1,42 +0,0 @@ -json: - get: - operationId: getOpenAPISpecJSON - x-module: core - x-stability-level: alpha - tags: [server] - summary: Get the OpenAPI document (JSON) - description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. - responses: - '200': - description: The OpenAPI document. - headers: - ETag: - $ref: ../components/headers/ETag.yaml - content: - application/json: - schema: - type: object - description: OpenAPI 3.0 document. - '304': - $ref: ../components/responses/NotModified.yaml -yaml: - get: - operationId: getOpenAPISpecYAML - x-module: core - x-stability-level: alpha - tags: [server] - summary: Get the OpenAPI document (YAML) - description: The bundled OpenAPI document of the running server version. Supports ETag revalidation. - responses: - '200': - description: The OpenAPI document. - headers: - ETag: - $ref: ../components/headers/ETag.yaml - content: - application/yaml: - schema: - type: object - description: OpenAPI 3.0 document. - '304': - $ref: ../components/responses/NotModified.yaml diff --git a/api/openapi/paths/server.yaml b/api/openapi/paths/server.yaml deleted file mode 100644 index 1f881dbb1..000000000 --- a/api/openapi/paths/server.yaml +++ /dev/null @@ -1,19 +0,0 @@ -get: - operationId: getServerInfo - x-module: core - x-stability-level: alpha - tags: [server] - summary: Describe the server - description: | - Returns the public server description. No authentication required. - Authenticated requests will additionally receive the implemented capability modules - once authentication is available. - responses: - '200': - description: Server description. - content: - application/json: - schema: - $ref: ../components/schemas/ServerInfo.yaml - '500': - $ref: ../components/responses/InternalError.yaml diff --git a/api/parameters/_index.yml b/api/parameters/_index.yml new file mode 100644 index 000000000..4280002ed --- /dev/null +++ b/api/parameters/_index.yml @@ -0,0 +1,28 @@ +pageOffset: + $ref: './query/pageOffset.yml' +pageLimit: + $ref: './query/pageLimit.yml' +filterEquals: + $ref: './query/filterEquals.yml' +filterLessThan: + $ref: './query/filterLessThan.yml' +filterLessOrEqual: + $ref: './query/filterLessOrEqual.yml' +filterGreaterThan: + $ref: './query/filterGreaterThan.yml' +filterGreaterOrEqual: + $ref: './query/filterGreaterOrEqual.yml' +filterContains: + $ref: './query/filterContains.yml' +filterStartsWith: + $ref: './query/filterStartsWith.yml' +filterEndsWith: + $ref: './query/filterEndsWith.yml' +sort: + $ref: './query/sort.yml' +include: + $ref: './query/include.yml' +includeForTracks: + $ref: './query/includeForTracks.yml' +includeForAlbums: + $ref: './query/includeForAlbums.yml' diff --git a/api/parameters/query/filterContains.yml b/api/parameters/query/filterContains.yml new file mode 100644 index 000000000..71cee77c4 --- /dev/null +++ b/api/parameters/query/filterContains.yml @@ -0,0 +1,9 @@ +name: filter[contains] +in: query +description: 'Filter by any property containing text. Usage: filter[contains]=property:value' +required: false +schema: + type: array + items: + type: string + pattern: '^\w+:\w+' diff --git a/api/parameters/query/filterEndsWith.yml b/api/parameters/query/filterEndsWith.yml new file mode 100644 index 000000000..390bd5606 --- /dev/null +++ b/api/parameters/query/filterEndsWith.yml @@ -0,0 +1,9 @@ +name: filter[endsWith] +in: query +description: 'Filter by any property that ends with text. Usage: filter[endsWith]=property:value' +required: false +schema: + type: array + items: + type: string + pattern: '^\w+:\w+' diff --git a/api/parameters/query/filterEquals.yml b/api/parameters/query/filterEquals.yml new file mode 100644 index 000000000..a523e04b8 --- /dev/null +++ b/api/parameters/query/filterEquals.yml @@ -0,0 +1,9 @@ +name: filter[equals] +in: query +description: 'Filter by any property with an exact match. Usage: filter[equals]=property:value' +required: false +schema: + type: array + items: + type: string + pattern: '^\w+:\w+' diff --git a/api/parameters/query/filterGreaterOrEqual.yml b/api/parameters/query/filterGreaterOrEqual.yml new file mode 100644 index 000000000..8a1ed8ec0 --- /dev/null +++ b/api/parameters/query/filterGreaterOrEqual.yml @@ -0,0 +1,9 @@ +name: filter[greaterOrEqual] +in: query +description: 'Filter by any numeric property greater than or equal to a value. Usage: filter[greaterOrEqual]=property:value' +required: false +schema: + type: array + items: + type: string + pattern: '^\w+:\d+' diff --git a/api/parameters/query/filterGreaterThan.yml b/api/parameters/query/filterGreaterThan.yml new file mode 100644 index 000000000..009a70d19 --- /dev/null +++ b/api/parameters/query/filterGreaterThan.yml @@ -0,0 +1,9 @@ +name: filter[greaterThan] +in: query +description: 'Filter by any numeric property greater than a value. Usage: filter[greaterThan]=property:value' +required: false +schema: + type: array + items: + type: string + pattern: '^\w+:\d+' diff --git a/api/parameters/query/filterLessOrEqual.yml b/api/parameters/query/filterLessOrEqual.yml new file mode 100644 index 000000000..33d029983 --- /dev/null +++ b/api/parameters/query/filterLessOrEqual.yml @@ -0,0 +1,9 @@ +name: filter[lessOrEqual] +in: query +description: 'Filter by any numeric property less than or equal to a value. Usage: filter[lessOrEqual]=property:value' +required: false +schema: + type: array + items: + type: string + pattern: '^\w+:\d+' diff --git a/api/parameters/query/filterLessThan.yml b/api/parameters/query/filterLessThan.yml new file mode 100644 index 000000000..5eb0657f6 --- /dev/null +++ b/api/parameters/query/filterLessThan.yml @@ -0,0 +1,9 @@ +name: filter[lessThan] +in: query +description: 'Filter by any numeric property less than a value. Usage: filter[lessThan]=property:value' +required: false +schema: + type: array + items: + type: string + pattern: '^\w+:\d+' diff --git a/api/parameters/query/filterStartsWith.yml b/api/parameters/query/filterStartsWith.yml new file mode 100644 index 000000000..1f7fa0039 --- /dev/null +++ b/api/parameters/query/filterStartsWith.yml @@ -0,0 +1,9 @@ + name: filter[startsWith] + in: query + description: 'Filter by any property that starts with text. Usage: filter[startsWith]=property:value' + required: false + schema: + type: array + items: + type: string + pattern: '^\w+:\w+' diff --git a/api/parameters/query/include.yml b/api/parameters/query/include.yml new file mode 100644 index 000000000..f3b0a579f --- /dev/null +++ b/api/parameters/query/include.yml @@ -0,0 +1,6 @@ +name: include +in: query +description: Related resources to include in the response, separated by commas +required: false +schema: + type: string diff --git a/api/parameters/query/includeForAlbum.yml b/api/parameters/query/includeForAlbum.yml new file mode 100644 index 000000000..d5c24a46f --- /dev/null +++ b/api/parameters/query/includeForAlbum.yml @@ -0,0 +1,13 @@ +name: include +in: query +description: Related resources to include in the response, separated by commas +required: false +explode: false +schema: + type: array + x-go-type: includeSlice + items: + type: string + enum: + - track + - artist diff --git a/api/parameters/query/includeForAlbums.yml b/api/parameters/query/includeForAlbums.yml new file mode 100644 index 000000000..411102451 --- /dev/null +++ b/api/parameters/query/includeForAlbums.yml @@ -0,0 +1,12 @@ +name: include +in: query +description: Related resources to include in the response, separated by commas +required: false +explode: false +schema: + type: array + x-go-type: includeSlice + items: + type: string + enum: + - artist diff --git a/api/parameters/query/includeForTracks.yml b/api/parameters/query/includeForTracks.yml new file mode 100644 index 000000000..2e5b2125f --- /dev/null +++ b/api/parameters/query/includeForTracks.yml @@ -0,0 +1,13 @@ +name: include +in: query +description: Related resources to include in the response, separated by commas +required: false +explode: false +schema: + type: array + x-go-type: includeSlice + items: + type: string + enum: + - album + - artist diff --git a/api/parameters/query/pageLimit.yml b/api/parameters/query/pageLimit.yml new file mode 100644 index 000000000..7f49946cf --- /dev/null +++ b/api/parameters/query/pageLimit.yml @@ -0,0 +1,9 @@ +name: page[limit] +in: query +description: The number of items per page +required: false +schema: + type: integer + format: int32 + minimum: 0 + default: 10 diff --git a/api/openapi/components/parameters/offset.yaml b/api/parameters/query/pageOffset.yml similarity index 50% rename from api/openapi/components/parameters/offset.yaml rename to api/parameters/query/pageOffset.yml index 9145d6eaa..831f6b914 100644 --- a/api/openapi/components/parameters/offset.yaml +++ b/api/parameters/query/pageOffset.yml @@ -1,8 +1,9 @@ -name: offset +name: page[offset] in: query -description: Zero-based index of the first item to return. +description: The offset for pagination required: false schema: type: integer + format: int32 minimum: 0 default: 0 diff --git a/api/parameters/query/sort.yml b/api/parameters/query/sort.yml new file mode 100644 index 000000000..99e439df4 --- /dev/null +++ b/api/parameters/query/sort.yml @@ -0,0 +1,6 @@ +name: sort +in: query +description: Sort the results by one or more properties, separated by commas. Prefix the property with '-' for descending order. +required: false +schema: + type: string diff --git a/api/resources/album.yml b/api/resources/album.yml new file mode 100644 index 000000000..12de5467c --- /dev/null +++ b/api/resources/album.yml @@ -0,0 +1,33 @@ +get: + summary: Retrieve an individual album + operationId: getAlbum + parameters: + - $ref: '../parameters/query/includeForAlbum.yml' + - name: albumId + in: path + description: The unique identifier of the album + required: true + schema: + type: string + responses: + '200': + description: An album object + content: + application/vnd.api+json: + schema: + type: object + required: [data] + properties: + data: + $ref: '../schemas/Album.yml' + included: + description: Included resources, as requested by the `include` query parameter + type: array + items: + $ref: '../schemas/IncludedResource.yml' + '403': + $ref: '../responses/NotAuthorized.yml' + '404': + $ref: '../responses/NotFound.yml' + '500': + $ref: '../responses/InternalServerError.yml' diff --git a/api/resources/albums.yml b/api/resources/albums.yml new file mode 100644 index 000000000..e682e4baa --- /dev/null +++ b/api/resources/albums.yml @@ -0,0 +1,44 @@ +get: + summary: Retrieve a list of albums + operationId: getAlbums + parameters: + - $ref: '../parameters/query/pageLimit.yml' + - $ref: '../parameters/query/pageOffset.yml' + - $ref: '../parameters/query/filterEquals.yml' + - $ref: '../parameters/query/filterContains.yml' + - $ref: '../parameters/query/filterLessThan.yml' + - $ref: '../parameters/query/filterLessOrEqual.yml' + - $ref: '../parameters/query/filterGreaterThan.yml' + - $ref: '../parameters/query/filterGreaterOrEqual.yml' + - $ref: '../parameters/query/filterStartsWith.yml' + - $ref: '../parameters/query/filterEndsWith.yml' + - $ref: '../parameters/query/sort.yml' + - $ref: '../parameters/query/includeForAlbums.yml' + responses: + '200': + description: A list of albums + content: + application/vnd.api+json: + schema: + type: object + required: [data, links] + properties: + data: + type: array + items: + $ref: '../schemas/Album.yml' + links: + $ref: '../schemas/PaginationLinks.yml' + meta: + $ref: '../schemas/PaginationMeta.yml' + included: + description: Included resources, as requested by the `include` query parameter + type: array + items: + $ref: '../schemas/IncludedResource.yml' + '400': + $ref: '../responses/BadRequest.yml' + '403': + $ref: '../responses/NotAuthorized.yml' + '500': + $ref: '../responses/InternalServerError.yml' diff --git a/api/resources/artist.yml b/api/resources/artist.yml new file mode 100644 index 000000000..b600c8716 --- /dev/null +++ b/api/resources/artist.yml @@ -0,0 +1,28 @@ +get: + summary: Retrieve an individual artist + operationId: getArtist + parameters: + - $ref: '../parameters/query/include.yml' + - name: artistId + in: path + description: The unique identifier of the artist + required: true + schema: + type: string + responses: + '200': + description: An artist object + content: + application/vnd.api+json: + schema: + type: object + required: [data] + properties: + data: + $ref: '../schemas/Artist.yml' + '403': + $ref: '../responses/NotAuthorized.yml' + '404': + $ref: '../responses/NotFound.yml' + '500': + $ref: '../responses/InternalServerError.yml' diff --git a/api/resources/artists.yml b/api/resources/artists.yml new file mode 100644 index 000000000..44db745e8 --- /dev/null +++ b/api/resources/artists.yml @@ -0,0 +1,39 @@ +get: + summary: Retrieve a list of artists + operationId: getArtists + parameters: + - $ref: '../parameters/query/pageLimit.yml' + - $ref: '../parameters/query/pageOffset.yml' + - $ref: '../parameters/query/filterEquals.yml' + - $ref: '../parameters/query/filterContains.yml' + - $ref: '../parameters/query/filterLessThan.yml' + - $ref: '../parameters/query/filterLessOrEqual.yml' + - $ref: '../parameters/query/filterGreaterThan.yml' + - $ref: '../parameters/query/filterGreaterOrEqual.yml' + - $ref: '../parameters/query/filterStartsWith.yml' + - $ref: '../parameters/query/filterEndsWith.yml' + - $ref: '../parameters/query/sort.yml' + - $ref: '../parameters/query/include.yml' + responses: + '200': + description: A list of artists + content: + application/vnd.api+json: + schema: + type: object + required: [data, links] + properties: + data: + type: array + items: + $ref: '../schemas/Artist.yml' + links: + $ref: '../schemas/PaginationLinks.yml' + meta: + $ref: '../schemas/PaginationMeta.yml' + '400': + $ref: '../responses/BadRequest.yml' + '403': + $ref: '../responses/NotAuthorized.yml' + '500': + $ref: '../responses/InternalServerError.yml' diff --git a/api/resources/server.yml b/api/resources/server.yml new file mode 100644 index 000000000..f57467335 --- /dev/null +++ b/api/resources/server.yml @@ -0,0 +1,18 @@ +get: + summary: Get server's global info + operationId: getServerInfo + responses: + '200': + description: The response’s data key maps to a resource object dictionary representing the server. + content: + application/vnd.api+json: + schema: + type: object + required: [data] + properties: + data: + $ref: '../schemas/ServerInfo.yml' + '403': + $ref: '../responses/NotAuthorized.yml' + '500': + $ref: '../responses/InternalServerError.yml' diff --git a/api/resources/track.yml b/api/resources/track.yml new file mode 100644 index 000000000..a3fa69034 --- /dev/null +++ b/api/resources/track.yml @@ -0,0 +1,33 @@ +get: + summary: Retrieve an individual track + operationId: getTrack + parameters: + - $ref: '../parameters/query/includeForTracks.yml' + - name: trackId + in: path + description: The unique identifier of the track + required: true + schema: + type: string + responses: + '200': + description: A track object + content: + application/vnd.api+json: + schema: + type: object + required: [data] + properties: + data: + $ref: '../schemas/Track.yml' + included: + description: Included resources, as requested by the `include` query parameter + type: array + items: + $ref: '../schemas/IncludedResource.yml' + '403': + $ref: '../responses/NotAuthorized.yml' + '404': + $ref: '../responses/NotFound.yml' + '500': + $ref: '../responses/InternalServerError.yml' diff --git a/api/resources/tracks.yml b/api/resources/tracks.yml new file mode 100644 index 000000000..62f71fa55 --- /dev/null +++ b/api/resources/tracks.yml @@ -0,0 +1,44 @@ +get: + summary: Retrieve a list of tracks + operationId: getTracks + parameters: + - $ref: '../parameters/query/pageLimit.yml' + - $ref: '../parameters/query/pageOffset.yml' + - $ref: '../parameters/query/filterEquals.yml' + - $ref: '../parameters/query/filterContains.yml' + - $ref: '../parameters/query/filterLessThan.yml' + - $ref: '../parameters/query/filterLessOrEqual.yml' + - $ref: '../parameters/query/filterGreaterThan.yml' + - $ref: '../parameters/query/filterGreaterOrEqual.yml' + - $ref: '../parameters/query/filterStartsWith.yml' + - $ref: '../parameters/query/filterEndsWith.yml' + - $ref: '../parameters/query/sort.yml' + - $ref: '../parameters/query/includeForTracks.yml' + responses: + '200': + description: A list of tracks + content: + application/vnd.api+json: + schema: + type: object + required: [data, links] + properties: + data: + type: array + items: + $ref: '../schemas/Track.yml' + links: + $ref: '../schemas/PaginationLinks.yml' + meta: + $ref: '../schemas/PaginationMeta.yml' + included: + description: Included resources, as requested by the `include` query parameter + type: array + items: + $ref: '../schemas/IncludedResource.yml' + '400': + $ref: '../responses/BadRequest.yml' + '403': + $ref: '../responses/NotAuthorized.yml' + '500': + $ref: '../responses/InternalServerError.yml' diff --git a/api/responses/BadRequest.yml b/api/responses/BadRequest.yml new file mode 100644 index 000000000..2b3e88ed7 --- /dev/null +++ b/api/responses/BadRequest.yml @@ -0,0 +1,5 @@ +description: Bad Request +content: + application/vnd.api+json: + schema: + $ref: '../schemas/ErrorList.yml' diff --git a/api/responses/InternalServerError.yml b/api/responses/InternalServerError.yml new file mode 100644 index 000000000..8d0f6bded --- /dev/null +++ b/api/responses/InternalServerError.yml @@ -0,0 +1,5 @@ +description: Internal Server Error +content: + application/vnd.api+json: + schema: + $ref: '../schemas/ErrorList.yml' diff --git a/api/responses/NotAuthorized.yml b/api/responses/NotAuthorized.yml new file mode 100644 index 000000000..70a10441c --- /dev/null +++ b/api/responses/NotAuthorized.yml @@ -0,0 +1,5 @@ +description: Not Authorized +content: + application/vnd.api+json: + schema: + $ref: '../schemas/ErrorList.yml' diff --git a/api/responses/NotFound.yml b/api/responses/NotFound.yml new file mode 100644 index 000000000..426976152 --- /dev/null +++ b/api/responses/NotFound.yml @@ -0,0 +1,5 @@ +description: Not Found +content: + application/vnd.api+json: + schema: + $ref: '../schemas/ErrorList.yml' diff --git a/api/responses/_index.yml b/api/responses/_index.yml new file mode 100644 index 000000000..fe735b55a --- /dev/null +++ b/api/responses/_index.yml @@ -0,0 +1,8 @@ +NotFound: + $ref: './NotFound.yml' +NotAuthorized: + $ref: './NotAuthorized.yml' +BadRequest: + $ref: './BadRequest.yml' +InternalServerError: + $ref: './InternalServerError.yml' diff --git a/api/schemas/Album.yml b/api/schemas/Album.yml new file mode 100644 index 000000000..49c959be8 --- /dev/null +++ b/api/schemas/Album.yml @@ -0,0 +1,20 @@ +allOf: + - $ref: './ResourceObject.yml' + - type: object + properties: + attributes: + $ref: './AlbumAttributes.yml' + relationships: + type: object + properties: + artists: + type: array + items: + $ref: './AlbumArtistRelationship.yml' + tracks: + type: array + items: + $ref: './AlbumTrackRelationship.yml' + required: + - artists + - tracks diff --git a/api/schemas/AlbumArtistRelationship.yml b/api/schemas/AlbumArtistRelationship.yml new file mode 100644 index 000000000..39b688c15 --- /dev/null +++ b/api/schemas/AlbumArtistRelationship.yml @@ -0,0 +1,9 @@ +type: object +properties: + meta: + $ref: './ArtistMetaObject.yml' + data: + $ref: './ResourceObject.yml' +required: + - meta + - data diff --git a/api/schemas/AlbumAttributes.yml b/api/schemas/AlbumAttributes.yml new file mode 100644 index 000000000..00ce3fb1e --- /dev/null +++ b/api/schemas/AlbumAttributes.yml @@ -0,0 +1,23 @@ +type: object +properties: + title: + type: string + description: The title of the album + artist: + type: string + description: The artist of the album + releaseDate: + type: string + description: The release date of the album + tracktotal: + type: integer + description: The number of tracks on the album + disctotal: + type: integer + description: The number of discs in the album + genre: + type: string + description: The genre of the album +required: + - title + - artist diff --git a/api/schemas/AlbumTrackRelationship.yml b/api/schemas/AlbumTrackRelationship.yml new file mode 100644 index 000000000..a83f5b2b6 --- /dev/null +++ b/api/schemas/AlbumTrackRelationship.yml @@ -0,0 +1,6 @@ +type: object +properties: + data: + $ref: './ResourceObject.yml' +required: + - data diff --git a/api/schemas/Artist.yml b/api/schemas/Artist.yml new file mode 100644 index 000000000..a9b2102bd --- /dev/null +++ b/api/schemas/Artist.yml @@ -0,0 +1,27 @@ +allOf: + - $ref: './ResourceObject.yml' + - type: object + properties: + attributes: + $ref: './ArtistAttributes.yml' + relationships: + type: object + properties: + tracks: + type: object + properties: + data: + type: array + items: + $ref: './ArtistTrackRelationship.yml' + required: + - data + albums: + type: object + properties: + data: + type: array + items: + $ref: './ArtistAlbumRelationship.yml' + required: + - data diff --git a/api/schemas/ArtistAlbumRelationship.yml b/api/schemas/ArtistAlbumRelationship.yml new file mode 100644 index 000000000..39b688c15 --- /dev/null +++ b/api/schemas/ArtistAlbumRelationship.yml @@ -0,0 +1,9 @@ +type: object +properties: + meta: + $ref: './ArtistMetaObject.yml' + data: + $ref: './ResourceObject.yml' +required: + - meta + - data diff --git a/api/schemas/ArtistAttributes.yml b/api/schemas/ArtistAttributes.yml new file mode 100644 index 000000000..252668a3c --- /dev/null +++ b/api/schemas/ArtistAttributes.yml @@ -0,0 +1,10 @@ +type: object +properties: + name: + type: string + description: The name of the artist + bio: + type: string + description: A short biography of the artist +required: + - name diff --git a/api/schemas/ArtistMetaObject.yml b/api/schemas/ArtistMetaObject.yml new file mode 100644 index 000000000..7143815f4 --- /dev/null +++ b/api/schemas/ArtistMetaObject.yml @@ -0,0 +1,6 @@ +type: object +properties: + role: + $ref: './ArtistRole.yml' +required: + - role diff --git a/api/schemas/ArtistRole.yml b/api/schemas/ArtistRole.yml new file mode 100644 index 000000000..61106a350 --- /dev/null +++ b/api/schemas/ArtistRole.yml @@ -0,0 +1,5 @@ +type: string +enum: + - artist + - albumArtist +description: The role of an artist in a track or album diff --git a/api/schemas/ArtistTrackRelationship.yml b/api/schemas/ArtistTrackRelationship.yml new file mode 100644 index 000000000..0c3583e10 --- /dev/null +++ b/api/schemas/ArtistTrackRelationship.yml @@ -0,0 +1,14 @@ +type: object +properties: + meta: + type: object + properties: + role: + $ref: './ArtistRole.yml' + required: + - role + data: + $ref: './ResourceObject.yml' +required: + - meta + - data diff --git a/api/schemas/ErrorList.yml b/api/schemas/ErrorList.yml new file mode 100644 index 000000000..4fbaea236 --- /dev/null +++ b/api/schemas/ErrorList.yml @@ -0,0 +1,7 @@ +type: object +required: [errors] +properties: + errors: + type: array + items: + $ref: './ErrorObject.yml' diff --git a/api/schemas/ErrorObject.yml b/api/schemas/ErrorObject.yml new file mode 100644 index 000000000..bc13d16f3 --- /dev/null +++ b/api/schemas/ErrorObject.yml @@ -0,0 +1,10 @@ +type: object +properties: + id: + type: string + status: + type: string + title: + type: string + detail: + type: string diff --git a/api/schemas/IncludedResource.yml b/api/schemas/IncludedResource.yml new file mode 100644 index 000000000..8887e0554 --- /dev/null +++ b/api/schemas/IncludedResource.yml @@ -0,0 +1,10 @@ +oneOf: + - $ref: './Track.yml' + - $ref: './Album.yml' + - $ref: './Artist.yml' +discriminator: + propertyName: type + mapping: + track: './Track.yml' + album: './Album.yml' + artist: './Artist.yml' diff --git a/api/schemas/PaginationLinks.yml b/api/schemas/PaginationLinks.yml new file mode 100644 index 000000000..31690c6a8 --- /dev/null +++ b/api/schemas/PaginationLinks.yml @@ -0,0 +1,14 @@ +type: object +properties: + first: + type: string + format: uri + prev: + type: string + format: uri + next: + type: string + format: uri + last: + type: string + format: uri diff --git a/api/schemas/PaginationMeta.yml b/api/schemas/PaginationMeta.yml new file mode 100644 index 000000000..877237652 --- /dev/null +++ b/api/schemas/PaginationMeta.yml @@ -0,0 +1,14 @@ +type: object +properties: + currentPage: + type: integer + format: int32 + description: The current page in the collection + totalPages: + type: integer + format: int32 + description: The total number of pages in the collection + totalItems: + type: integer + format: int32 + description: The total number of items in the collection diff --git a/api/schemas/ResourceList.yml b/api/schemas/ResourceList.yml new file mode 100644 index 000000000..45fc844b3 --- /dev/null +++ b/api/schemas/ResourceList.yml @@ -0,0 +1,18 @@ +type: object +properties: + data: + oneOf: + - $ref: './Track.yml' + - $ref: './Album.yml' + - $ref: './Artist.yml' + - type: array + items: + $ref: './ResourceObject.yml' + included: + type: array + items: + $ref: './IncludedResource.yml' + links: + $ref: './PaginationLinks.yml' + meta: + $ref: './PaginationMeta.yml' diff --git a/api/schemas/ResourceObject.yml b/api/schemas/ResourceObject.yml new file mode 100644 index 000000000..adbc88140 --- /dev/null +++ b/api/schemas/ResourceObject.yml @@ -0,0 +1,8 @@ +type: object +required: [id, type] +properties: + id: + type: string + description: The unique identifier for the resource + type: + $ref: './ResourceType.yml' diff --git a/api/schemas/ResourceType.yml b/api/schemas/ResourceType.yml new file mode 100644 index 000000000..70b12e7ec --- /dev/null +++ b/api/schemas/ResourceType.yml @@ -0,0 +1,6 @@ +type: string +description: The type of the resource +enum: + - album + - artist + - track diff --git a/api/schemas/ServerInfo.yml b/api/schemas/ServerInfo.yml new file mode 100644 index 000000000..58e7379e6 --- /dev/null +++ b/api/schemas/ServerInfo.yml @@ -0,0 +1,24 @@ +type: object +required: [server, serverVersion, authRequired, features] +properties: + server: + type: string + description: The name of the server software. + example: "navidrome" + serverVersion: + type: string + description: The version number of the server. + example: "0.60.0" + authRequired: + type: boolean + description: Whether the user has access to the server. + example: true + features: + type: array + description: A list of optional features the server supports. + items: + type: string + enum: + - albums + - artists + - images diff --git a/api/schemas/Track.yml b/api/schemas/Track.yml new file mode 100644 index 000000000..bd730fae7 --- /dev/null +++ b/api/schemas/Track.yml @@ -0,0 +1,8 @@ +allOf: + - $ref: './ResourceObject.yml' + - type: object + properties: + attributes: + $ref: './TrackAttributes.yml' + relationships: + $ref: './TrackRelationships.yml' diff --git a/api/schemas/TrackArtistRelationship.yml b/api/schemas/TrackArtistRelationship.yml new file mode 100644 index 000000000..39b688c15 --- /dev/null +++ b/api/schemas/TrackArtistRelationship.yml @@ -0,0 +1,9 @@ +type: object +properties: + meta: + $ref: './ArtistMetaObject.yml' + data: + $ref: './ResourceObject.yml' +required: + - meta + - data diff --git a/api/schemas/TrackAttributes.yml b/api/schemas/TrackAttributes.yml new file mode 100644 index 000000000..bb238e41d --- /dev/null +++ b/api/schemas/TrackAttributes.yml @@ -0,0 +1,55 @@ +type: object +required: [title, artist, album, albumartist, track, mimetype, duration, channels, bitrate, size] +properties: + title: + type: string + description: The title of the track + artist: # TODO: Remove + type: string + description: The name of the artist who performed the track + albumartist: # TODO: Remove + type: string + description: The primary artist of the album the track belongs to. + album: # TODO Remove + type: string + description: The name of the album the track belongs to + genre: # TODO Remove + type: string + description: The genre of the track. + track: + type: integer + description: The track number within the album. + disc: + type: integer + description: The disc number within a multi-disc album. + year: + type: integer + description: The release year of the track or album. + bpm: + type: integer + description: The beats per minute (BPM) of the track. + recording-mbid: + type: string + description: The MusicBrainz identifier for the recording of the track. + track-mbid: + type: string + description: The MusicBrainz identifier for the track. + comments: + type: string + description: Any additional comments or notes about the track. + mimetype: + type: string + description: The MIME type of the audio file. + duration: + type: number + format: float + description: The duration of the track in seconds + channels: + type: integer + description: The number of audio channels in the track. + bitrate: + type: integer + description: The bitrate of the audio file in kilobits per second (kbps). + size: + type: integer + description: The size of the audio file in bytes. diff --git a/api/schemas/TrackRelationships.yml b/api/schemas/TrackRelationships.yml new file mode 100644 index 000000000..43a69a712 --- /dev/null +++ b/api/schemas/TrackRelationships.yml @@ -0,0 +1,12 @@ +type: object +properties: + artists: + type: array + items: + $ref: './TrackArtistRelationship.yml' + albums: + type: array + items: + $ref: './AlbumTrackRelationship.yml' +required: + - artists diff --git a/api/schemas/_index.yml b/api/schemas/_index.yml new file mode 100644 index 000000000..cb371569f --- /dev/null +++ b/api/schemas/_index.yml @@ -0,0 +1,46 @@ +ServerInfo: + $ref: './ServerInfo.yml' +ResourceObject: + $ref: './ResourceObject.yml' +ResourceType: + $ref: './ResourceType.yml' +ResourceList: + $ref: './ResourceList.yml' +IncludedResource: + $ref: './IncludedResource.yml' +Track: + $ref: './Track.yml' +TrackAttributes: + $ref: './TrackAttributes.yml' +TrackRelationships: + $ref: './TrackRelationships.yml' +TrackArtistRelationship: + $ref: './TrackArtistRelationship.yml' +ArtistRole: + $ref: './ArtistRole.yml' +Artist: + $ref: './Artist.yml' +ArtistAttributes: + $ref: './ArtistAttributes.yml' +ArtistAlbumRelationship: + $ref: './ArtistAlbumRelationship.yml' +ArtistTrackRelationship: + $ref: './ArtistTrackRelationship.yml' +ArtistMetaObject: + $ref: './ArtistMetaObject.yml' +Album: + $ref: './Album.yml' +AlbumAttributes: + $ref: './AlbumAttributes.yml' +AlbumArtistRelationship: + $ref: './AlbumArtistRelationship.yml' +AlbumTrackRelationship: + $ref: './AlbumTrackRelationship.yml' +PaginationLinks: + $ref: './PaginationLinks.yml' +PaginationMeta: + $ref: './PaginationMeta.yml' +ErrorList: + $ref: './ErrorList.yml' +ErrorObject: + $ref: './ErrorObject.yml' diff --git a/api/spec.yml b/api/spec.yml new file mode 100644 index 000000000..58f671aad --- /dev/null +++ b/api/spec.yml @@ -0,0 +1,52 @@ +openapi: 3.0.0 +info: + version: 0.2.0 + title: Navidrome API + description: > + This spec describes the Navidrome API, which allows users to browse and manage their music library via a JSON:API + based interface. The API provides endpoints for albums, tracks, artists, playlists and images, along with their + relationships. Clients can retrieve information about the items in the library, filter and sort results, and + perform actions such as creating and deleting playlists. With this API, developers can build music apps and + services that integrate with Navidrome music server, providing a seamless experience for users to access and + manage their music collection. + contact: + name: Navidrome + url: https://navidrome.org + license: + name: GNU General Public License v3.0 + url: https://github.com/navidrome/navidrome/blob/master/LICENSE + +servers: + - url: /api/v2 + +security: + - bearerAuth: [] + +paths: + /server: + $ref: './resources/server.yml' + /tracks: + $ref: './resources/tracks.yml' + /tracks/{trackId}: + $ref: './resources/track.yml' + /artists: + $ref: './resources/artists.yml' + /artists/{artistId}: + $ref: './resources/artist.yml' + /albums: + $ref: './resources/albums.yml' + /albums/{albumId}: + $ref: './resources/album.yml' + +components: + parameters: + $ref: './parameters/_index.yml' + schemas: + $ref: './schemas/_index.yml' + responses: + $ref: './responses/_index.yml' + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT diff --git a/cmd/artwork.go b/cmd/artwork.go deleted file mode 100644 index 72685678e..000000000 --- a/cmd/artwork.go +++ /dev/null @@ -1,1022 +0,0 @@ -package cmd - -import ( - "cmp" - "context" - "errors" - "fmt" - "io" - "os" - "slices" - "strconv" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/plugins" - "github.com/navidrome/navidrome/utils/slice" - "github.com/spf13/cobra" -) - -var explainLive bool - -// Only one subcommand runs per invocation, so reprocess and cancel bind the same flag targets. -var ( - artworkKinds []string - artworkSources []string - artworkPriorities []string - artworkAll bool - artworkDryRun bool - artworkYes bool -) - -func init() { - artworkExplainCmd.Flags().BoolVar(&explainLive, "live", false, - "walk the chain again now, performing real external lookups, instead of reporting the "+ - "stored trace of the last resolution; also initializes plugin agents, which may open "+ - "external connections") - artworkReprocessCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil, - "kinds to reprocess ("+kindPrefixes(artwork.ReprocessKinds)+"); repeatable") - artworkReprocessCmd.Flags().StringSliceVar(&artworkSources, "source", nil, - "only items currently resolved from these sources (e.g. folder, external:deezer, absent, "+ - "or failed for the absent ones that gave up)") - artworkReprocessCmd.Flags().BoolVar(&artworkAll, "all", false, "reprocess every kind") - artworkReprocessCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false, - "report what would be queued and exit without queueing") - artworkReprocessCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt") - artworkCancelCmd.Flags().StringSliceVar(&artworkKinds, "kind", nil, - "kinds to cancel ("+kindPrefixes(artwork.RefreshableKinds)+"); repeatable") - artworkCancelCmd.Flags().StringSliceVar(&artworkPriorities, "priority", nil, - "only rows queued at these priorities ("+priorityNames()+"); repeatable") - artworkCancelCmd.Flags().BoolVar(&artworkAll, "all", false, "cancel every kind at every priority") - artworkCancelCmd.Flags().BoolVar(&artworkDryRun, "dry-run", false, - "report what would be cancelled and exit without cancelling") - artworkCancelCmd.Flags().BoolVarP(&artworkYes, "yes", "y", false, "skip the confirmation prompt") - artworkCmd.AddCommand(artworkExplainCmd) - artworkCmd.AddCommand(artworkRefreshCmd) - artworkCmd.AddCommand(artworkReprocessCmd) - artworkCmd.AddCommand(artworkCancelCmd) - artworkCmd.AddCommand(artworkStatusCmd) - rootCmd.AddCommand(artworkCmd) -} - -var artworkCmd = &cobra.Command{ - Use: "artwork", - Short: "Inspect and re-resolve artwork", -} - -var artworkExplainCmd = &cobra.Command{ - Use: "explain [] ", - Short: "Explain why an item's artwork resolved the way it did", - Long: "Explain why an item's artwork resolved the way it did.\n\n" + - "The item can be given as a bare id, a full artwork id (e.g. al-), or a pair.\n" + - " is one of: " + kindPrefixes(explainKinds) + ".\n" + - "A disc artwork id is the album id and the disc number, joined by a colon: :2", - Args: cobra.RangeArgs(1, 2), - Run: func(cmd *cobra.Command, args []string) { - runExplain(cmd.Context(), args) - }, -} - -var artworkRefreshCmd = &cobra.Command{ - Use: "refresh [] ...", - Short: "Clear an item's artwork state and re-resolve it", - Long: "Clear an item's artwork state and re-resolve it.\n\n" + - "Each item can be given as a bare id, a full artwork id (e.g. al-), or a shared\n" + - " ... leader. is one of: " + kindPrefixes(artwork.RefreshableKinds) + ".", - Args: cobra.MinimumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - runRefresh(cmd.Context(), args) - }, -} - -var artworkReprocessCmd = &cobra.Command{ - Use: "reprocess", - Short: "Re-enqueue artwork in bulk, by kind and/or by the source it currently resolves from", - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - runReprocess(cmd.Context()) - }, -} - -var artworkCancelCmd = &cobra.Command{ - Use: "cancel", - Short: "Cancel pending artwork work in bulk, by kind and/or queue priority", - Long: "Cancel pending artwork work in bulk, by kind and/or queue priority.\n\n" + - "Only the queue is touched: resolved artwork and the state behind `artwork explain` are\n" + - "left alone, and the trace of why a cancelled item last failed goes with its queue row.\n\n" + - "Work already picked up is not interrupted, and an item with no artwork yet can be\n" + - "queued again by the hourly re-check. The selection is applied again when you confirm,\n" + - "so anything queued after the preview is cancelled too. Use it to call off a bulk\n" + - "reprocess, not to stop the worker.", - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - runCancel(cmd.Context()) - }, -} - -var artworkStatusCmd = &cobra.Command{ - Use: "status", - Short: "Report the artwork queue, where artwork resolves from, and the config state", - Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - runStatus(cmd.Context()) - }, -} - -func runStatus(ctx context.Context) { - defer db.Init(ctx)() - ds, ctx := getAdminContext(ctx) - - rep, err := collectStatus(ctx, ds) - if err != nil { - log.Fatal(ctx, err) - } - fmt.Print(formatStatus(rep)) -} - -type sourceCount struct { - kind model.Kind - source string - count int64 -} - -// absentCount partitions a kind's absent states: noImage was answered, failed gave up. -type absentCount struct { - kind model.Kind - noImage int64 - failed int64 -} - -type statusReport struct { - queue []model.ArtworkQueueStat - sources []sourceCount - absent []absentCount - inputs []artwork.FingerprintInput - stored string - current string -} - -func (r statusReport) queueTotal() int64 { return queueTotal(r.queue) } - -func queueTotal(stats []model.ArtworkQueueStat) int64 { - var n int64 - for _, s := range stats { - n += s.Count - } - return n -} - -func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) { - q := ds.ArtworkQueue() - var rep statusReport - var err error - if rep.queue, err = q.CountQueued(ctx, nil, nil); err != nil { - return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err) - } - - for _, k := range artwork.ReprocessKinds { - sources, err := q.SourcesInUse(ctx, k) - if err != nil { - return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) - } - slices.Sort(sources) - for _, s := range sources { - n, err := q.CountBySource(ctx, k, []string{s}) - if err != nil { - return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err) - } - rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n}) - // An absent state is exactly a row with no source, so it needs no second query. - if s == "" { - failed, err := q.CountBySource(ctx, k, []string{model.ArtworkSourceFailed}) - if err != nil { - return rep, fmt.Errorf("counting failed %s artwork: %w", k, err) - } - rep.absent = append(rep.absent, absentCount{kind: k, noImage: n - failed, failed: failed}) - } - } - } - - rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs() - if rep.stored, err = ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, ""); err != nil { - return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err) - } - return rep, nil -} - -func formatStatus(rep statusReport) string { - var sb strings.Builder - w := newTabWriter(&sb) - - fmt.Fprintln(w, "Queue") - if len(rep.queue) == 0 { - fmt.Fprintln(w, " (empty)") - } else { - printQueueStats(w, rep.queue, rep.queueTotal(), "ITEMS", " ") - } - - fmt.Fprintln(w, "\nSources") - fmt.Fprintln(w, " KIND\tSOURCE\tITEMS") - for _, s := range rep.sources { - fmt.Fprintf(w, " %s\t%s\t%d\n", s.kind, displaySource(s.source), s.count) - } - - fmt.Fprintln(w, "\nAbsent (resolved, no image found)") - fmt.Fprintln(w, " KIND\tNO IMAGE\tFAILED") - for _, a := range rep.absent { - fmt.Fprintf(w, " %s\t%d\t%d\n", a.kind, a.noImage, a.failed) - } - fmt.Fprintln(w, " (nothing retries these; 'artwork reprocess --source absent' retries both columns)") - fmt.Fprintln(w, " (failed = gave up rather than being answered, so the ones most likely to resolve;\n"+ - " 'artwork reprocess --source failed' retries just those)") - - fmt.Fprintln(w, "\nConfig") - fmt.Fprintf(w, " State:\t%s\n", configState(rep)) - fmt.Fprintf(w, " Stored fingerprint:\t%s\n", cmp.Or(rep.stored, "(none)")) - fmt.Fprintf(w, " Current fingerprint:\t%s\n", rep.current) - if len(rep.inputs) > 0 { - fmt.Fprintln(w, " Fingerprint inputs (changing any of these makes the stored artwork stale):") - for _, in := range rep.inputs { - fmt.Fprintf(w, " %s:\t%s\n", in.Name, in.Value) - } - } - - w.Flush() - return sb.String() -} - -func configState(rep statusReport) string { - if rep.stored != rep.current { - return "fingerprint changed — stored artwork keeps the old resolution; " + - "run 'artwork reprocess --all' to apply it" - } - return "up to date" -} - -// printQueueStats writes the shared queue breakdown; the caller owns the tab writer and flushes it. -func printQueueStats(w io.Writer, stats []model.ArtworkQueueStat, total int64, countHeader, indent string) { - fmt.Fprintf(w, "%sKIND\tPRIORITY\t%s\n", indent, countHeader) - for _, s := range stats { - fmt.Fprintf(w, "%s%s\t%s\t%d\n", indent, kindName(s.ItemKind), priorityName(s.Priority), s.Count) - } - fmt.Fprintf(w, "%sTOTAL\t\t%d\n", indent, total) -} - -func kindName(prefix string) string { - if k, ok := model.ParseKind(prefix); ok { - return k.String() - } - return prefix -} - -type artworkPriority struct { - name string - value int -} - -// knownPriorities is the one listing behind both the name and the parse, so they cannot drift. -var knownPriorities = []artworkPriority{ - {"bump", model.ArtworkPriorityBump}, - {"scan", model.ArtworkPriorityScan}, - {"recheck", model.ArtworkPriorityRecheck}, - {"backfill", model.ArtworkPriorityBackfill}, -} - -// priorityName falls back to the number: a row written by a newer version still has to print. -func priorityName(p int) string { - for _, ap := range knownPriorities { - if ap.value == p { - return ap.name - } - } - return strconv.Itoa(p) -} - -func priorityNames() string { - return strings.Join(slice.Map(knownPriorities, func(ap artworkPriority) string { return ap.name }), ", ") -} - -func parseArtworkPriority(s string) (int, error) { - for _, ap := range knownPriorities { - if ap.name == s { - return ap.value, nil - } - } - return 0, fmt.Errorf("invalid priority %q, expected one of: %s", s, priorityNames()) -} - -func runReprocess(ctx context.Context) { - kinds, err := selectedKinds(artworkKinds, artworkSources, artworkAll) - if err != nil { - log.Fatal(ctx, err) - } - - defer db.Init(ctx)() - ds, ctx := getAdminContext(ctx) - - // Only a kind that can reach an agent needs the count, and loading a plugin creates its - // services. A preview must not reach the network, so init never runs here. - var imageAgents artwork.ImageAgentCount - if needsImageAgents(kinds) { - mgr := loadPluginAgents(ctx, false) - defer func() { _ = mgr.Stop() }() - imageAgents = artwork.NewImageAgentCount(agents.GetAgents(ds, mgr)) - } - - if err := reprocessArtwork(ctx, ds, kinds, repositorySources(artworkSources), imageAgents, - artworkDryRun, confirmUnlessYes(artworkYes, os.Stdin, "re-resolve"), os.Stdout); err != nil { - log.Fatal(ctx, err) - } -} - -func selectedKinds(kinds, sources []string, all bool) ([]model.Kind, error) { - // A source filter on its own is already a complete selection, so it does not also need a kind. - if all || (len(kinds) == 0 && len(sources) > 0) { - return artwork.ReprocessKinds, nil - } - if len(kinds) == 0 { - return nil, fmt.Errorf("no selector given: pass --kind, --source or --all") - } - return parseAll(kinds, func(s string) (model.Kind, error) { - return parseArtworkKind(s, artwork.ReprocessKinds) - }) -} - -// absentSource is how the stored empty source — resolved, no image — is spelled on the CLI, and -// failedSource the subset of it that gave up rather than being answered. -const ( - absentSource = "absent" - failedSource = "failed" -) - -func repositorySources(sources []string) []string { - return slice.Map(sources, func(s string) string { - switch s { - case absentSource: - return "" - case failedSource: - return model.ArtworkSourceFailed - } - return s - }) -} - -func displaySource(s string) string { - if s == model.ArtworkSourceFailed { - return failedSource - } - return cmp.Or(s, absentSource) -} - -type confirmFunc func(out io.Writer, total, external int64) bool - -func confirmUnlessYes(yes bool, in io.Reader, verb string) confirmFunc { - if yes { - return func(io.Writer, int64, int64) bool { return true } - } - return promptConfirm(in, verb) -} - -// externalEstimate claims no bound: a local hit ends the walk before any agent is asked, and the -// plugin agents it counts are only the ones this process managed to load. -func externalEstimate(n int64) string { - if n == 0 { - return "none" - } - return fmt.Sprintf("~%d estimated (plugin agents counted only when they load; local hits may need fewer)", n) -} - -func externalLookupLine(n int64) string { - return fmt.Sprintf("External lookups: %s.", externalEstimate(n)) -} - -// loadPluginAgents loads the plugins named in Agents, so the CLI resolves through the same agents a -// running server would. A load failure is reported, not fatal: the built-in agents still answer. -func loadPluginAgents(ctx context.Context, runInit bool) *plugins.Manager { - mgr := getPluginManager() - if err := mgr.LoadPlugins(ctx, configuredAgents(), runInit); err != nil { - log.Warn(ctx, "Could not load plugins; plugin-provided agents will be missing", err) - } - return mgr -} - -// needsImageAgents asks exactly what ExternalLookupsPerItem asks, so the gate cannot disagree with -// the estimate it guards. Playlists count: their generated grid resolves album art through agents. -func needsImageAgents(kinds []model.Kind) bool { - return slices.ContainsFunc(kinds, artwork.MayFetchExternal) -} - -// configuredAgents names the agents in priority order; one absent from it can never supply an image. -func configuredAgents() []string { - var names []string - for name := range strings.SplitSeq(conf.Server.Agents, ",") { - if name = strings.TrimSpace(name); name != "" { - names = append(names, name) - } - } - return names -} - -func promptConfirm(in io.Reader, verb string) confirmFunc { - return func(out io.Writer, total, external int64) bool { - var cost string - if external > 0 { - cost = fmt.Sprintf(" %s", externalLookupLine(external)) - } - fmt.Fprintf(out, "\nThis will %s %d items.%s Continue? [y/N] ", verb, total, cost) - var answer string - if _, err := fmt.Fscanln(in, &answer); err != nil { - return false - } - answer = strings.ToLower(strings.TrimSpace(answer)) - return answer == "y" || answer == "yes" - } -} - -// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when -// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only. -func validateSources(ctx context.Context, q model.ArtworkQueueRepository, sources []string) error { - if len(sources) == 0 { - return nil - } - var inUse []string - for _, k := range artwork.ReprocessKinds { - found, err := q.SourcesInUse(ctx, k) - if err != nil { - return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err) - } - inUse = slice.Unique(append(inUse, found...)) - } - var unknown []string - for _, s := range sources { - // The reserved absent and failed sources are valid even when nothing currently matches them. - if s != "" && s != model.ArtworkSourceFailed && !slices.Contains(inUse, s) { - unknown = append(unknown, displaySource(s)) - } - } - if len(unknown) == 0 { - return nil - } - // failed is accepted but never stored, so listing only what is in use would hide it. - valid := append(slice.Map(inUse, displaySource), failedSource) - slices.Sort(valid) - return fmt.Errorf("no artwork resolves from %s; sources in use: %s", - strings.Join(unknown, ", "), cmp.Or(strings.Join(valid, ", "), "(none)")) -} - -// reprocessArtwork previews from CountBySource — rows matched — then reports what EnqueueBySource -// actually inserted; the two differ because an already-queued row is left untouched. -func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string, - imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error { - q := ds.ArtworkQueue() - if err := validateSources(ctx, q, sources); err != nil { - return err - } - - // Derived from what actually drives the queries, so a filter added to this signature cannot - // silently keep stamping the fingerprint for a partial run. - markApplied := func() error { - if len(sources) > 0 || len(kinds) < len(artwork.ReprocessKinds) { - return nil - } - if err := artwork.MarkConfigApplied(ctx, ds); err != nil { - return fmt.Errorf("recording the applied artwork config: %w", err) - } - return nil - } - - matched := make([]int64, len(kinds)) - var total, external int64 - for i, k := range kinds { - n, err := q.CountBySource(ctx, k, sources) - if err != nil { - return fmt.Errorf("counting %s artwork: %w", k, err) - } - matched[i] = n - total += n - external += n * artwork.ExternalLookupsPerItem(k, imageAgents) - } - printReprocessPreview(out, kinds, matched, total, external, sources) - - switch { - case dryRun: - fmt.Fprintln(out, "\nDry run: nothing was queued.") - return nil - case total == 0: - // An empty match set still leaves nothing resolved under the old config. - fmt.Fprintln(out, "Nothing was queued.") - return markApplied() - case !confirm(out, total, external): - fmt.Fprintln(out, "Aborted: nothing was queued.") - return nil - } - - var queued int64 - for i, k := range kinds { - if matched[i] == 0 { - continue - } - n, err := q.EnqueueBySource(ctx, k, sources, model.ArtworkPriorityRecheck) - if err != nil { - return fmt.Errorf("queueing %s artwork: %w", k, err) - } - queued += n - fmt.Fprintf(out, "%s: %d queued\n", k, n) - } - fmt.Fprintf(out, "Queued %d of %d matched items.\n", queued, total) - if skipped := total - queued; skipped > 0 { - fmt.Fprintf(out, "Already queued, left unchanged: %d (priority and retry backoff untouched).\n", skipped) - } - return markApplied() -} - -func runCancel(ctx context.Context) { - kinds, priorities, err := cancelSelection(artworkKinds, artworkPriorities, artworkAll) - if err != nil { - log.Fatal(ctx, err) - } - - defer db.Init(ctx)() - ds, ctx := getAdminContext(ctx) - - if err := cancelArtwork(ctx, ds, kinds, priorities, artworkDryRun, - confirmUnlessYes(artworkYes, os.Stdin, "cancel"), os.Stdout); err != nil { - log.Fatal(ctx, err) - } -} - -// cancelSelection leaves --all as the empty filter the repository reads as "every one", so a row -// whose kind this build does not know still gets cancelled. -func cancelSelection(kinds, priorities []string, all bool) ([]model.Kind, []int, error) { - if all { - return nil, nil, nil - } - if len(kinds) == 0 && len(priorities) == 0 { - return nil, nil, fmt.Errorf("no selector given: pass --kind, --priority or --all") - } - // RefreshableKinds, not ReprocessKinds: media files are queued, so --kind must reach them. - outKinds, err := parseAll(kinds, func(s string) (model.Kind, error) { - return parseArtworkKind(s, artwork.RefreshableKinds) - }) - if err != nil { - return nil, nil, err - } - outPriorities, err := parseAll(priorities, parseArtworkPriority) - if err != nil { - return nil, nil, err - } - return outKinds, outPriorities, nil -} - -// parseAll drops repeats: a doubled selector would overstate the total the operator confirms. -func parseAll[T comparable](values []string, parse func(string) (T, error)) ([]T, error) { - out := make([]T, 0, len(values)) - for _, v := range values { - parsed, err := parse(v) - if err != nil { - return nil, err - } - out = append(out, parsed) - } - return slice.Unique(out), nil -} - -func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int, - dryRun bool, confirm confirmFunc, out io.Writer) error { - q := ds.ArtworkQueue() - matched, err := q.CountQueued(ctx, kinds, priorities) - if err != nil { - return fmt.Errorf("counting queued artwork: %w", err) - } - total := queueTotal(matched) - w := newTabWriter(out) - printQueueStats(w, matched, total, "MATCHED", "") - w.Flush() - - switch { - case total == 0: - fmt.Fprintln(out, "\nNothing matches this selection.") - return nil - case dryRun: - fmt.Fprintln(out, "\nDry run: nothing was cancelled.") - return nil - case !confirm(out, total, 0): - fmt.Fprintln(out, "Aborted: nothing was cancelled.") - return nil - } - - cancelled, err := q.PurgeQueued(ctx, kinds, priorities) - if err != nil { - return fmt.Errorf("cancelling queued artwork: %w", err) - } - // Count and delete are separate statements, so a drain in between makes these two differ. - fmt.Fprintf(out, "Cancelled %d of %d matched items.\n", cancelled, total) - return nil -} - -// printReprocessPreview also states the external estimate, which --dry-run must show because it -// skips the prompt that would otherwise carry it. -func printReprocessPreview(out io.Writer, kinds []model.Kind, matched []int64, total, external int64, sources []string) { - w := newTabWriter(out) - shown := slice.Map(sources, displaySource) - fmt.Fprintf(w, "Sources:\t%s\n\n", cmp.Or(strings.Join(shown, ", "), "(any)")) - fmt.Fprintln(w, "KIND\tMATCHED") - for i, k := range kinds { - fmt.Fprintf(w, "%s\t%d\n", k, matched[i]) - } - fmt.Fprintf(w, "TOTAL\t%d\n", total) - w.Flush() - - fmt.Fprintf(out, "\n%s\n", externalLookupLine(external)) - if total == 0 { - fmt.Fprintln(out, "\nNothing matches this selection.") - } -} - -func runRefresh(ctx context.Context, args []string) { - defer db.Init(ctx)() - ds, ctx := getAdminContext(ctx) - - targets, failures, err := resolveArtworkTargets(ctx, ds, args, artwork.RefreshableKinds) - if err != nil { - log.Fatal(ctx, err) - } - for _, f := range failures { - log.Error(ctx, "Skipping unresolved item", f) - } - failed := refreshItems(ctx, ds, targets, os.Stdout) + len(failures) - if failed > 0 { - log.Fatal(ctx, "Failed to refresh artwork", "failed", failed, "total", len(targets)+len(failures)) - } -} - -// refreshItems keeps going after a failure — the items are independent — and returns how many failed. -func refreshItems(ctx context.Context, ds model.DataStore, targets []model.ArtworkID, out io.Writer) int { - var failed int - for _, t := range targets { - kind, id := t.Kind, t.ID - // artwork.Refresh would happily queue an id that does not exist, orphaning a queue row. - if _, err := artwork.ItemName(ctx, ds, kind, id); err != nil { - log.Error(ctx, "Item not found", "kind", kind, "id", id, err) - failed++ - continue - } - if err := artwork.Refresh(ctx, ds, kind, id); err != nil { - log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err) - failed++ - continue - } - fmt.Fprintf(out, "%s/%s: queued\n", kind.Prefix(), id) - } - return failed -} - -// explainKinds is every kind explain accepts: it reports stored state and config too, so a kind -// with no chain to walk still has something to answer with. -var explainKinds = []model.Kind{ - model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork, - model.KindMediaFileArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork, -} - -func kindPrefixes(kinds []model.Kind) string { - return strings.Join(model.KindPrefixes(kinds), ", ") -} - -func parseArtworkKind(s string, valid []model.Kind) (model.Kind, error) { - kind, ok := model.ParseKind(s) - if ok && slices.Contains(valid, kind) { - return kind, nil - } - return kind, invalidKindErr(s, valid) -} - -func invalidKindErr(s string, valid []model.Kind) error { - return fmt.Errorf("invalid kind %q, expected one of: %s", s, kindPrefixes(valid)) -} - -// resolveArtworkTargets resolves explain/refresh positional args into artwork ids, accepting a -// shared " ..." leader or self-describing args (a bare id, or a full artwork id). A -// self-describing arg that cannot be resolved is returned as a failure rather than aborting the -// batch, so refresh can process the resolvable ids; a malformed leader is a usage error. -func resolveArtworkTargets(ctx context.Context, ds model.DataStore, args []string, valid []model.Kind) ([]model.ArtworkID, []error, error) { - if kind, ok := model.ParseKind(args[0]); ok && len(args) > 1 { - if !slices.Contains(valid, kind) { - return nil, nil, invalidKindErr(args[0], valid) - } - return slice.Map(args[1:], func(id string) model.ArtworkID { - return model.ArtworkID{Kind: kind, ID: id} - }), nil, nil - } - var targets []model.ArtworkID - var failures []error - for _, arg := range args { - target, err := artworkKindAndID(ctx, ds, arg) - if err == nil && !slices.Contains(valid, target.Kind) { - err = invalidKindErr(target.Kind.Prefix(), valid) - } - if err != nil { - failures = append(failures, err) - continue - } - targets = append(targets, target) - } - return targets, failures, nil -} - -// artworkKindAndID resolves one self-describing argument: a full artwork id (al-) takes its kind -// from the prefix, a bare id is looked up. Entity ids never start with "-", so no collision. -func artworkKindAndID(ctx context.Context, ds model.DataStore, arg string) (model.ArtworkID, error) { - if artID, err := model.ParseArtworkID(arg); err == nil && artID.ID != "" { - return model.ArtworkID{Kind: artID.Kind, ID: artID.ID}, nil - } - kind, err := model.GetEntityKindByID(ctx, ds, arg) - if errors.Is(err, model.ErrNotFound) { - return model.ArtworkID{}, fmt.Errorf("could not determine kind for %q; pass an explicit ", arg) - } - if err != nil { - return model.ArtworkID{}, err - } - return model.ArtworkID{Kind: kind, ID: arg}, nil -} - -// explainAgents accounts for every configured agent: one the CLI cannot construct (a plugin, or a -// built-in missing its credentials) never reaches the Chain, so the raw list alone overstates it. -func explainAgents(configured string, available []string) string { - if strings.TrimSpace(configured) == "" { - return "(none)" - } - var unavailable bool - names := slice.Map(strings.Split(configured, ","), func(name string) string { - name = strings.TrimSpace(name) - if slices.Contains(available, name) { - return name - } - unavailable = true - return name + "*" - }) - line := strings.Join(names, ", ") - if unavailable { - line += " (* not available to the CLI)" - } - return line -} - -// availableImageAgents names the agents that can actually supply an image for kind. -func availableImageAgents(ds model.DataStore, mgr *plugins.Manager, kind model.Kind) []string { - ag := agents.GetAgents(ds, mgr) - if kind == model.KindArtistArtwork { - return slice.Map(ag.ArtistImageAgents(), func(a agents.ArtistImageAgent) string { return a.Name }) - } - return slice.Map(ag.AlbumImageAgents(), func(a agents.AlbumImageAgent) string { return a.Name }) -} - -// explainResult states the verdict of the walk. A skipped or failed external tier, or a local -// candidate that would not open, leaves the outcome unknown: nothing observed that there is no artwork. -func explainResult(source string, steps []artwork.TraceStep) string { - if source != "" { - for _, s := range steps { - if s.Outcome == artwork.OutcomeHit { - break - } - // An external winner discards the earlier error, so the resolver settles it with no retry. - if s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix) && - !strings.HasPrefix(source, artwork.ExternalPrefix) { - return "resolved from " + source + - " (indeterminate: a higher-priority external lookup failed; this may resolve differently on a retry)" - } - } - return "resolved from " + source - } - for _, s := range steps { - switch { - case s.Outcome == artwork.OutcomeError && strings.HasPrefix(s.Candidate, artwork.ExternalPrefix): - return "indeterminate (an external lookup failed; the item may resolve on a later attempt)" - // A stage error or an unreadable candidate means a source was found but not processed; the - // worker retries rather than settling absent, so neither reads as a clean miss. - case s.Outcome == artwork.OutcomeError, s.Outcome == artwork.OutcomeUnreadable: - return "indeterminate (a candidate was found but could not be processed; the worker retries rather than settling absent)" - } - } - return "not resolved" -} - -// explainConfig names the setting that decides where a kind's artwork comes from, and its value. -func explainConfig(kind model.Kind) (name, value string) { - switch kind { - case model.KindArtistArtwork: - return "ArtistArtPriority", conf.Server.ArtistArtPriority - case model.KindAlbumArtwork: - return "CoverArtPriority", conf.Server.CoverArtPriority - case model.KindDiscArtwork: - return "DiscArtPriority", conf.Server.DiscArtPriority - case model.KindMediaFileArtwork: - return "EnableMediaFileCoverArt", strconv.FormatBool(conf.Server.EnableMediaFileCoverArt) - } - return "", "" -} - -type explainReport struct { - kind model.Kind - id string - name string - stored *model.ItemArtwork - queued *model.ArtworkQueueItem - agents string - // steps is the chain walk: recorded when the item was resolved, or performed just now when walked. - steps []artwork.TraceStep - source string - walked bool - resolveErr error -} - -// explainChainOrigin says whether the operator is reading history or a walk performed just now, -// since the two can disagree after a config change. -func explainChainOrigin(rep explainReport) string { - if rep.walked { - return "walked now" - } - if rep.stored != nil { - return "recorded " + formatTime(rep.stored.AttemptedAt) - } - return "not recorded" -} - -// writeSteps prints the trace rows. An empty last cell would end tabwriter's column block and -// break the alignment, so a missing detail is rendered as a dash. -func writeSteps(w io.Writer, indent string, steps []artwork.TraceStep) { - for _, s := range steps { - fmt.Fprintf(w, "%s%s\t%s\t%s\n", indent, s.Candidate, s.Outcome, cmp.Or(s.Detail, "-")) - } -} - -// writeStepTable prints a secondary trace, and nothing at all when there is none to show. -func writeStepTable(w io.Writer, title string, steps []artwork.TraceStep) { - if len(steps) == 0 { - return - } - // No tab on the title: it closes the preceding column block, so these rows align among themselves. - fmt.Fprintf(w, " %s:\n", title) - writeSteps(w, " ", steps) -} - -func formatExplain(rep explainReport) string { - var sb strings.Builder - w := newTabWriter(&sb) - explainable := artwork.Explainable(rep.kind) - stateful := artwork.KeepsState(rep.kind) - unrecorded := !rep.walked && rep.stored == nil - - fmt.Fprintln(w, "Item") - fmt.Fprintf(w, " Kind:\t%s (%s)\n", rep.kind, rep.kind.Prefix()) - fmt.Fprintf(w, " ID:\t%s\n", rep.id) - fmt.Fprintf(w, " Name:\t%s\n", rep.name) - - fmt.Fprintln(w, "\nStored") - switch { - case !stateful: - fmt.Fprintf(w, " (%s artwork is resolved on every request and never recorded)\n", rep.kind) - case rep.stored == nil: - fmt.Fprintln(w, " (no artwork state recorded)") - default: - fmt.Fprintf(w, " Source:\t%s\n", displaySource(rep.stored.Source)) - fmt.Fprintf(w, " Hash:\t%s\n", cmp.Or(rep.stored.Hash, "(absent)")) - if rep.stored.SourcePath != "" { - fmt.Fprintf(w, " Source path:\t%s\n", rep.stored.SourcePath) - } - fmt.Fprintf(w, " Attempted at:\t%s\n", formatTime(rep.stored.AttemptedAt)) - } - - fmt.Fprintln(w, "\nQueue") - switch { - case !stateful: - fmt.Fprintln(w, " (never queued)") - case rep.queued == nil: - fmt.Fprintln(w, " (not queued)") - default: - fmt.Fprintf(w, " Priority:\t%s (%d)\n", priorityName(rep.queued.Priority), rep.queued.Priority) - fmt.Fprintf(w, " Attempts:\t%d\n", rep.queued.Attempts) - fmt.Fprintf(w, " Retry at:\t%s\n", formatTime(rep.queued.RetryAt)) - } - if rep.queued != nil { - writeStepTable(w, "Last attempt failed", artwork.DecodeTrace(rep.queued.Trace, "")) - } - if rep.stored != nil { - writeStepTable(w, "Gave up after", artwork.DecodeTrace(rep.stored.LastFailure, "")) - } - - fmt.Fprintln(w, "\nConfig") - if setting, value := explainConfig(rep.kind); setting == "" { - fmt.Fprintln(w, " (no artwork source configuration applies)") - } else { - fmt.Fprintf(w, " %s:\t%s\n", setting, value) - if rep.agents != "" { - fmt.Fprintf(w, " Agents:\t%s\n", rep.agents) - } - } - - fmt.Fprintf(w, "\nChain (%s)\n", explainChainOrigin(rep)) - switch { - case !explainable: - fmt.Fprintf(w, " (%s artwork does not walk a priority chain)\n", rep.kind) - case unrecorded: - fmt.Fprintln(w, " (no resolution recorded yet; re-run with --live to walk the chain now)") - case !rep.walked && len(rep.steps) == 0 && rep.stored.Hash != "": - // A stored image with no chain can only predate trace recording: a recorded resolution that - // found an image always records its winning candidate. - fmt.Fprintln(w, " (this item was resolved before traces were recorded; re-run with --live)") - case !rep.walked && len(rep.steps) == 0: - // Absent with no chain: an empty priority list walked nothing, or a pre-tracing absent row. - fmt.Fprintln(w, " (no candidates were recorded; re-run with --live to walk the chain now)") - default: - fmt.Fprintln(w, " CANDIDATE\tOUTCOME\tDETAIL") - writeSteps(w, " ", rep.steps) - } - - fmt.Fprintln(w, "\nResult") - switch { - case rep.resolveErr != nil: - fmt.Fprintf(w, " resolution failed: %s\n", rep.resolveErr) - case !explainable: - fmt.Fprintln(w, " not evaluated (no chain was walked; see Stored above)") - case unrecorded: - fmt.Fprintln(w, " not evaluated (nothing recorded; re-run with --live to walk the chain now)") - default: - fmt.Fprintf(w, " %s\n", explainResult(rep.source, rep.steps)) - } - - w.Flush() - return sb.String() -} - -func formatTime(t time.Time) string { - if t.IsZero() { - return "-" - } - return t.Format(time.RFC3339) -} - -func runExplain(ctx context.Context, args []string) { - defer db.Init(ctx)() - ds, ctx := getAdminContext(ctx) - - targets, failures, err := resolveArtworkTargets(ctx, ds, args, explainKinds) - if err != nil { - log.Fatal(ctx, err) - } - if len(failures) > 0 { - log.Fatal(ctx, failures[0]) - } - if len(targets) != 1 { - log.Fatal(ctx, "explain takes a single item; pass one id or a pair") - } - kind, id := targets[0].Kind, targets[0].ID - - name, err := artwork.ItemName(ctx, ds, kind, id) - if err != nil { - log.Fatal(ctx, "Item not found", "kind", kind, "id", id, err) - } - rep := explainReport{kind: kind, id: id, name: name} - if artwork.KeepsState(kind) { - rep.stored, err = ds.Artwork().GetItemArtwork(ctx, kind, id, model.ImageTypePrimary) - if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err) - } - rep.queued, err = ds.ArtworkQueue().Get(ctx, kind, id, model.ImageTypePrimary) - if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err) - } - } - - // Disc artwork keeps no row, so it has no stored trace and can only be explained by walking now. - rep.walked = explainLive || !artwork.KeepsState(kind) - if artwork.Explainable(kind) { - // Only artist and album reach an agent, and the load must precede the resolver, which reads - // the same manager. - if kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork { - mgr := loadPluginAgents(ctx, explainLive) - defer func() { _ = mgr.Stop() }() - rep.agents = explainAgents(conf.Server.Agents, availableImageAgents(ds, mgr, kind)) - } - switch { - case rep.walked: - trace := &artwork.ChainTrace{} - rep.source, rep.resolveErr = CreateArtworkResolver(trace, explainLive).Resolve(ctx, kind, id) - rep.steps = trace.Steps() - case rep.stored != nil: - rep.steps = artwork.DecodeTrace(rep.stored.Trace, rep.stored.SourcePath) - rep.source = rep.stored.Source - } - } - fmt.Print(formatExplain(rep)) - // The steps taken before a failed walk are the diagnosis, so report them before exiting. - if rep.resolveErr != nil { - log.Fatal(ctx, "Failed to resolve artwork", "kind", kind, "id", id, rep.resolveErr) - } -} diff --git a/cmd/artwork_test.go b/cmd/artwork_test.go deleted file mode 100644 index df0ea0665..000000000 --- a/cmd/artwork_test.go +++ /dev/null @@ -1,1206 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "io" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("parseArtworkKind", func() { - It("accepts a supported kind", func() { - k, err := parseArtworkKind("ar", artwork.ReprocessKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(k).To(Equal(model.KindArtistArtwork)) - }) - - It("rejects an unknown kind and lists the valid ones", func() { - _, err := parseArtworkKind("zz", artwork.ReprocessKinds) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("ar")) - Expect(err.Error()).To(ContainSubstring("al")) - }) - - It("rejects a known kind the command does not accept", func() { - _, err := parseArtworkKind("mf", artwork.ReprocessKinds) - Expect(err).To(HaveOccurred()) - }) - - DescribeTable("accepts the kinds each command supports", - func(prefix string, valid []model.Kind) { - _, err := parseArtworkKind(prefix, valid) - Expect(err).ToNot(HaveOccurred()) - }, - Entry("explain reads disc artwork", "dc", explainKinds), - Entry("explain reads media file artwork", "mf", explainKinds), - // Disc artwork has no state to clear and the worker cannot resolve it, so refresh must not - // accept it: the queue row would be rejected on every drain. - Entry("refresh re-queues media files", "mf", artwork.RefreshableKinds), - ) - - It("rejects disc artwork for refresh", func() { - _, err := parseArtworkKind("dc", artwork.RefreshableKinds) - Expect(err).To(HaveOccurred()) - }) -}) - -var _ = Describe("resolveArtworkTargets", func() { - var ds *tests.MockDataStore - ctx := context.Background() - - BeforeEach(func() { - artists := tests.CreateMockArtistRepo() - artists.SetData(model.Artists{{ID: "artist1"}}) - ds = &tests.MockDataStore{MockedArtist: artists} - }) - - It("accepts the explicit leader shared by every id", func() { - targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"al", "x", "y"}, explainKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(failures).To(BeEmpty()) - Expect(targets).To(Equal([]model.ArtworkID{ - {Kind: model.KindAlbumArtwork, ID: "x"}, {Kind: model.KindAlbumArtwork, ID: "y"}})) - }) - - It("rejects an explicit kind the command does not accept as a usage error", func() { - _, _, err := resolveArtworkTargets(ctx, ds, []string{"dc", "x"}, artwork.RefreshableKinds) - Expect(err).To(MatchError(ContainSubstring("invalid kind"))) - }) - - It("resolves a bare id by looking it up across tables", func() { - targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1"}, explainKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(failures).To(BeEmpty()) - Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindArtistArtwork, ID: "artist1"}})) - }) - - It("reads the kind from a full artwork id prefix without a database lookup", func() { - targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum"}, explainKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}})) - }) - - It("strips the hash suffix from a full artwork id", func() { - targets, _, err := resolveArtworkTargets(ctx, ds, []string{"al-realalbum_0123456789abcdef"}, explainKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(Equal([]model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "realalbum"}})) - }) - - It("collects a self-describing arg whose kind the command does not accept", func() { - targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"dc-realalbum:2"}, artwork.RefreshableKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(BeEmpty()) - Expect(failures).To(HaveLen(1)) - Expect(failures[0]).To(MatchError(ContainSubstring("invalid kind"))) - }) - - It("collects an id that matches nothing and has no kind prefix", func() { - targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"nope"}, explainKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(BeEmpty()) - Expect(failures).To(HaveLen(1)) - Expect(failures[0]).To(MatchError(ContainSubstring("could not determine kind"))) - }) - - It("resolves the valid ids and collects the unresolvable ones", func() { - targets, failures, err := resolveArtworkTargets(ctx, ds, []string{"artist1", "nope", "al-realalbum"}, explainKinds) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(Equal([]model.ArtworkID{ - {Kind: model.KindArtistArtwork, ID: "artist1"}, {Kind: model.KindAlbumArtwork, ID: "realalbum"}})) - Expect(failures).To(HaveLen(1)) - Expect(failures[0]).To(MatchError(ContainSubstring("could not determine kind"))) - }) -}) - -var _ = Describe("explainResult", func() { - It("reports the winning source", func() { - steps := []artwork.TraceStep{{Candidate: "folder", Outcome: "hit", Detail: "/music/a.jpg"}} - Expect(explainResult("folder", steps)).To(ContainSubstring("resolved from folder")) - }) - - It("reports not resolved when every candidate was tried and missed", func() { - steps := []artwork.TraceStep{ - {Candidate: "artist.*", Outcome: "miss"}, - {Candidate: "external:deezer", Outcome: "miss"}, - } - Expect(explainResult("", steps)).To(Equal("not resolved")) - }) - - It("reports indeterminate when a local candidate exists but could not be read", func() { - steps := []artwork.TraceStep{ - {Candidate: "cover.*", Outcome: "miss"}, - {Candidate: "embedded", Outcome: "unreadable"}, - } - Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), - "the worker retries an unreadable candidate instead of settling absent, so this is not a clean miss") - }) - - It("reports indeterminate when a processing stage errored after a candidate was found", func() { - steps := []artwork.TraceStep{ - {Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"}, - {Candidate: "store", Outcome: "error", Detail: "disk full"}, - } - Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), - "a stage error is a processing failure the worker retries, not a definitive miss") - }) - - It("does not qualify a hit that an earlier unreadable candidate preceded", func() { - // chainState.try stamps only the external error onto a hit and drops the local one, so the - // worker settles this as found; warning about it would be a false alarm. - steps := []artwork.TraceStep{ - {Candidate: "embedded", Outcome: "unreadable"}, - {Candidate: "cover.*", Outcome: "hit", Detail: "/music/cover.jpg"}, - } - Expect(explainResult("folder", steps)).To(Equal("resolved from folder")) - }) - - It("reports indeterminate when an external lookup failed transiently", func() { - steps := []artwork.TraceStep{ - {Candidate: "artist.*", Outcome: "miss"}, - {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, - } - Expect(explainResult("", steps)).To(ContainSubstring("indeterminate"), - "a failed network call is not evidence that the item has no artwork") - }) - - It("qualifies a win a failed higher-priority external lookup could have taken", func() { - steps := []artwork.TraceStep{ - {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, - {Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}, - } - res := explainResult("artist.*", steps) - Expect(res).To(ContainSubstring("resolved from artist.*")) - Expect(res).To(ContainSubstring("indeterminate"), - "the resolver serves this hit but retries later, so the winner is provisional") - }) - - It("does not qualify an external win that followed a failed external lookup", func() { - steps := []artwork.TraceStep{ - {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, - {Candidate: "external:lastfm", Outcome: "hit", Detail: "http://img"}, - } - Expect(explainResult("external:lastfm", steps)).To(Equal("resolved from external:lastfm"), - "a later agent supplying the image discards the earlier error, so there is no retry to warn about") - }) - - It("does not qualify a win that outranked the failed external lookup", func() { - steps := []artwork.TraceStep{ - {Candidate: "artist.*", Outcome: "hit"}, - {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, - } - Expect(explainResult("artist.*", steps)).To(Equal("resolved from artist.*")) - }) -}) - -var _ = Describe("explainAgents", func() { - It("accounts for every configured agent, marking the ones the CLI could not use", func() { - out := explainAgents("artist-nfo-metadata,apple-music,deezer,lastfm", []string{"deezer"}) - for _, name := range []string{"artist-nfo-metadata", "apple-music", "deezer", "lastfm"} { - Expect(out).To(ContainSubstring(name), - "a configured agent missing from this line reads as if it had never been configured") - } - Expect(out).To(ContainSubstring("not available to the CLI")) - }) - - It("does not mark anything when every configured agent is available", func() { - out := explainAgents("deezer, lastfm", []string{"lastfm", "deezer"}) - Expect(out).To(Equal("deezer, lastfm")) - }) - - It("reports an empty configuration as none, not as an unavailable agent", func() { - Expect(explainAgents("", nil)).To(Equal("(none)")) - }) -}) - -var _ = Describe("formatExplain", func() { - var rep explainReport - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.ArtistArtPriority = "external, artist.*" - rep = explainReport{ - kind: model.KindArtistArtwork, - id: "ar-1", - name: "Radiohead", - agents: "lastfm,spotify", - walked: true, - steps: []artwork.TraceStep{ - {Candidate: "upload", Outcome: "skipped", Detail: "no uploaded image"}, - {Candidate: "external:deezer", Outcome: "error", Detail: "context deadline exceeded"}, - }, - source: "", - } - }) - - It("reports the item, its config and the chain it walked", func() { - out := formatExplain(rep) - Expect(out).To(ContainSubstring("Radiohead")) - Expect(out).To(ContainSubstring("ar-1")) - Expect(out).To(ContainSubstring("ArtistArtPriority")) - Expect(out).To(ContainSubstring("lastfm,spotify")) - Expect(out).To(ContainSubstring("external:deezer")) - Expect(out).To(ContainSubstring("indeterminate")) - }) - - It("reports the absence of stored state and of a queue row", func() { - out := formatExplain(rep) - Expect(out).To(ContainSubstring("no artwork state recorded")) - Expect(out).To(ContainSubstring("not queued")) - }) - - It("prints the stored state and the queue row when they exist", func() { - attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC) - rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc123", - SourcePath: "/music/cover.jpg", AttemptedAt: attempted} - rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 2, - RetryAt: attempted.Add(time.Hour)} - rep.source = "folder" - - out := formatExplain(rep) - Expect(out).To(ContainSubstring("abc123")) - Expect(out).To(ContainSubstring("/music/cover.jpg")) - Expect(out).To(ContainSubstring("2026-08-13T10:00:00Z")) - Expect(out).To(ContainSubstring("scan (50)"), "a bare 50 makes the operator look the priority up") - Expect(out).To(ContainSubstring("resolved from folder")) - }) - - It("marks a known-absent stored state instead of printing an empty hash", func() { - rep.stored = &model.ItemArtwork{AttemptedAt: time.Now()} - Expect(formatExplain(rep)).To(ContainSubstring("absent")) - }) - - It("reports a failed walk as failed, not as unresolved", func() { - rep.resolveErr = errors.New("no such directory") - - out := formatExplain(rep) - Expect(out).To(ContainSubstring("resolution failed: no such directory")) - Expect(out).ToNot(ContainSubstring("indeterminate")) - Expect(out).To(ContainSubstring("external:deezer"), "the steps taken before the failure still print") - }) - - It("says a kind that does not walk a chain has no chain, without an empty table", func() { - conf.Server.CoverArtPriority = "cover.*, embedded" - rep.kind = model.KindPlaylistArtwork - rep.steps = nil - rep.agents = "" - - out := formatExplain(rep) - Expect(out).To(ContainSubstring("does not walk a priority chain")) - Expect(out).ToNot(ContainSubstring("CANDIDATE"), - "an empty chain table reads as 'nothing was tried', which is false") - Expect(out).ToNot(ContainSubstring("not resolved"), - "nothing was resolved because nothing was attempted") - Expect(out).ToNot(ContainSubstring("CoverArtPriority"), - "the priority chain config does not govern this kind") - }) - - It("says disc artwork keeps no state instead of reporting it as unresolved state", func() { - conf.Server.DiscArtPriority = "cover.jpg, embedded" - rep = explainReport{ - kind: model.KindDiscArtwork, id: "al-1:2", name: "OK Computer (disc 2)", - steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}}, - source: "folder", - walked: true, - } - - out := formatExplain(rep) - Expect(out).To(ContainSubstring("never recorded")) - Expect(out).To(ContainSubstring("never queued")) - Expect(out).ToNot(ContainSubstring("no artwork state recorded"), - "a missing row would read as a lookup that failed, when disc artwork has no row by design") - Expect(out).To(ContainSubstring("DiscArtPriority")) - Expect(out).ToNot(ContainSubstring("Agents:"), "disc artwork never asks an agent") - Expect(out).To(ContainSubstring("resolved from folder")) - }) - - Context("stored traces", func() { - BeforeEach(func() { - rep.walked = false - rep.steps = nil - }) - - It("labels a recorded chain with when it was recorded, not as a walk done now", func() { - attempted := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC) - rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: attempted} - rep.steps = []artwork.TraceStep{{Candidate: "artist.*", Outcome: "hit", Detail: "/music/artist.jpg"}} - rep.source = "folder" - - out := formatExplain(rep) - Expect(out).To(ContainSubstring("Chain (recorded 2026-08-13T10:00:00Z)")) - Expect(out).To(ContainSubstring("/music/artist.jpg")) - Expect(out).To(ContainSubstring("resolved from folder")) - }) - - It("says so when the item has never been resolved", func() { - out := formatExplain(rep) - Expect(out).To(ContainSubstring("no resolution recorded yet")) - Expect(out).To(ContainSubstring("--live")) - Expect(out).ToNot(ContainSubstring("not resolved"), - "nothing was recorded, which is not the same as resolving to nothing") - }) - - It("distinguishes a row written before traces existed from one with an empty chain", func() { - rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now()} - - Expect(formatExplain(rep)).To(ContainSubstring("resolved before traces were recorded")) - }) - - It("does not call an absent row with an empty recorded chain a pre-tracing row", func() { - // An empty priority list records a real but empty chain and resolves absent; that is not a - // legacy row, so it must not be reported as resolved before tracing existed. - rep.stored = &model.ItemArtwork{Source: "", Hash: "", AttemptedAt: time.Now()} - - out := formatExplain(rep) - Expect(out).ToNot(ContainSubstring("resolved before traces were recorded")) - Expect(out).To(ContainSubstring("no candidates were recorded")) - Expect(out).To(ContainSubstring("not resolved"), "the Result still reports the absence plainly") - }) - - It("prints why the last attempt failed and why it gave up", func() { - rep.queued = &model.ArtworkQueueItem{Priority: model.ArtworkPriorityScan, Attempts: 3, - Trace: `[{"c":"decode","o":"error","d":"bad header"}]`} - rep.stored = &model.ItemArtwork{Source: "folder", Hash: "abc", AttemptedAt: time.Now(), - LastFailure: `[{"c":"read","o":"error","d":"i/o timeout"}]`} - - out := formatExplain(rep) - Expect(out).To(ContainSubstring("Last attempt failed")) - Expect(out).To(ContainSubstring("bad header")) - Expect(out).To(ContainSubstring("Gave up after")) - Expect(out).To(ContainSubstring("i/o timeout")) - }) - - It("omits the failure tables when there is no failure to report", func() { - out := formatExplain(rep) - Expect(out).ToNot(ContainSubstring("Last attempt failed")) - Expect(out).ToNot(ContainSubstring("Gave up after")) - }) - }) - - It("reports the setting that governs media file artwork", func() { - conf.Server.EnableMediaFileCoverArt = false - rep = explainReport{ - kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag", - walked: true, - steps: []artwork.TraceStep{ - {Candidate: "embedded", Outcome: "skipped", Detail: "EnableMediaFileCoverArt is off"}, - }, - } - - out := formatExplain(rep) - Expect(out).To(ContainSubstring("EnableMediaFileCoverArt")) - Expect(out).To(ContainSubstring("false")) - Expect(out).To(ContainSubstring("not resolved")) - Expect(out).To(ContainSubstring("no artwork state recorded"), "media files do keep state") - }) -}) - -var _ = Describe("explainConfig", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DiscArtPriority = "cover.jpg" - conf.Server.EnableMediaFileCoverArt = true - }) - - DescribeTable("names the setting that decides where a kind's artwork comes from", - func(kind model.Kind, setting, value string) { - gotSetting, gotValue := explainConfig(kind) - Expect(gotSetting).To(Equal(setting)) - Expect(gotValue).To(Equal(value)) - }, - Entry("disc", model.KindDiscArtwork, "DiscArtPriority", "cover.jpg"), - Entry("media file", model.KindMediaFileArtwork, "EnableMediaFileCoverArt", "true"), - Entry("playlist has none", model.KindPlaylistArtwork, "", ""), - ) -}) - -var _ = Describe("artwork refresh command", func() { - It("requires at least one argument", func() { - Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{})).To(HaveOccurred()) - Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"id1"})).ToNot(HaveOccurred()) - Expect(artworkRefreshCmd.Args(artworkRefreshCmd, []string{"ar", "id1"})).ToNot(HaveOccurred()) - }) -}) - -var _ = Describe("artwork reprocess selection", func() { - It("errors when no selector is given", func() { - _, err := selectedKinds(nil, nil, false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("--all")) - }) - - It("returns every kind for --all", func() { - ks, err := selectedKinds(nil, nil, true) - Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(ConsistOf(artwork.ReprocessKinds)) - }) - - It("returns every kind for a source filter given without a kind", func() { - ks, err := selectedKinds(nil, []string{"folder"}, false) - Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(ConsistOf(artwork.ReprocessKinds), "--source alone is already a complete selection") - }) - - It("returns only the named kinds", func() { - ks, err := selectedKinds([]string{"ar"}, nil, false) - Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork})) - }) - - It("keeps a named kind selection alongside a source filter", func() { - ks, err := selectedKinds([]string{"ar"}, []string{"folder"}, false) - Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork})) - }) - - It("rejects an unknown kind", func() { - _, err := selectedKinds([]string{"zz"}, nil, false) - Expect(err).To(HaveOccurred()) - }) - - It("counts a repeated kind once", func() { - ks, err := selectedKinds([]string{"ar", "ar"}, nil, false) - Expect(err).ToNot(HaveOccurred()) - Expect(ks).To(Equal([]model.Kind{model.KindArtistArtwork})) - }) -}) - -var _ = Describe("explain/reprocess source round trip", func() { - ctx := context.Background() - - // storedSource reads back the Source line explain printed, as an operator would copy it. - storedSource := func(out string) string { - GinkgoHelper() - for line := range strings.SplitSeq(out, "\n") { - if after, ok := strings.CutPrefix(strings.TrimSpace(line), "Source:"); ok { - return strings.TrimSpace(after) - } - } - Fail("explain printed no Source line") - return "" - } - - It("names the absent state as reprocess --source accepts it", func() { - ds := &tests.MockDataStore{} - art := ds.Artwork().(*tests.MockArtworkRepo) - Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(), - ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed()) - - shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1", - stored: &model.ItemArtwork{AttemptedAt: time.Now()}})) - - q := ds.ArtworkQueue() - Expect(validateSources(ctx, q, repositorySources([]string{shown}))).To(Succeed(), - "explain's spelling of a source must be pasteable into --source") - Expect(validateSources(ctx, q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(), - "a parenthesised name would be rejected, so explain must not print one") - }) -}) - -var _ = Describe("repositorySources", func() { - It("maps the user-facing absent name onto the stored empty source", func() { - Expect(repositorySources([]string{"absent", "folder"})).To(Equal([]string{"", "folder"})) - }) - - It("maps the failed name onto the pseudo-source, and back for display", func() { - Expect(repositorySources([]string{failedSource})).To(Equal([]string{model.ArtworkSourceFailed})) - Expect(displaySource(model.ArtworkSourceFailed)).To(Equal(failedSource)) - }) - - It("keeps an empty selection empty, meaning every source", func() { - Expect(repositorySources(nil)).To(BeEmpty()) - }) -}) - -var _ = Describe("promptConfirm", func() { - var out strings.Builder - - BeforeEach(func() { out.Reset() }) - - It("states the external cost and accepts an explicit yes", func() { - Expect(promptConfirm(strings.NewReader("y\n"), "re-resolve")(&out, 42, 7)).To(BeTrue()) - Expect(out.String()).To(ContainSubstring("re-resolve 42 items")) - Expect(out.String()).To(ContainSubstring("External lookups: ~7 estimated")) - }) - - It("defaults to no on anything else", func() { - Expect(promptConfirm(strings.NewReader("\n"), "re-resolve")(&out, 1, 1)).To(BeFalse()) - Expect(promptConfirm(strings.NewReader("nope\n"), "re-resolve")(&out, 1, 1)).To(BeFalse()) - Expect(promptConfirm(strings.NewReader(""), "re-resolve")(&out, 1, 1)).To(BeFalse()) - }) - - It("drops the external clause when no lookup will be made", func() { - Expect(promptConfirm(strings.NewReader("y\n"), "cancel")(&out, 3, 0)).To(BeTrue()) - Expect(out.String()).To(ContainSubstring("cancel 3 items.")) - Expect(out.String()).ToNot(ContainSubstring("External lookups")) - }) -}) - -var _ = Describe("confirmUnlessYes", func() { - var out strings.Builder - - BeforeEach(func() { out.Reset() }) - - It("prompts when --yes was not given", func() { - Expect(confirmUnlessYes(false, strings.NewReader("n\n"), "re-resolve")(&out, 5, 5)).To(BeFalse()) - Expect(out.String()).To(ContainSubstring("Continue?")) - }) - - It("bypasses the prompt only for --yes", func() { - Expect(confirmUnlessYes(true, strings.NewReader(""), "re-resolve")(&out, 5, 5)).To(BeTrue()) - Expect(out.String()).To(BeEmpty(), "--yes must not print a prompt it never reads") - }) -}) - -var _ = Describe("reprocessArtwork", func() { - var ds *tests.MockDataStore - var art *tests.MockArtworkRepo - var queue *tests.MockArtworkQueueRepo - var out strings.Builder - var imageAgents artwork.ImageAgentCount - ctx := context.Background() - kinds := []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork} - accept := func(io.Writer, int64, int64) bool { return true } - decline := func(io.Writer, int64, int64) bool { return false } - - put := func(kind model.Kind, id, source string) { - Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id, - ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed()) - } - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.CoverArtPriority = "cover.*, external" - conf.Server.ArtistArtPriority = "artist.*, external" - conf.Server.EnableM3UExternalAlbumArt = false - imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1} - ds = &tests.MockDataStore{} - art = ds.Artwork().(*tests.MockArtworkRepo) - queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo) - out.Reset() - put(model.KindArtistArtwork, "ar-1", "external:deezer") - put(model.KindArtistArtwork, "ar-2", "") - put(model.KindAlbumArtwork, "al-1", "external:deezer") - put(model.KindAlbumArtwork, "al-2", "folder") - }) - - It("previews the per-kind breakdown and queues nothing on a dry run", func() { - Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, true, accept, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("external:deezer")) - Expect(out.String()).To(ContainSubstring("artist")) - Expect(out.String()).To(ContainSubstring("album")) - Expect(out.String()).To(ContainSubstring("TOTAL")) - Expect(out.String()).To(ContainSubstring("Dry run")) - Expect(queue.Count(ctx)).To(BeZero()) - }) - - It("queues nothing when the operator declines", func() { - Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("Aborted")) - Expect(queue.Count(ctx)).To(BeZero()) - }) - - DescribeTable("records the applied config only for a run that leaves nothing on the old one", - func(selected []model.Kind, sources []string, dryRun, applied bool) { - Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) - - Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed()) - - want := "stale-fingerprint" - if applied { - want = artwork.ConfigFingerprint() - } - Expect(ds.Property().Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(want)) - }, - Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true), - Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true), - Entry("filtered by source", artwork.ReprocessKinds, []string{"external:deezer"}, false, false), - Entry("a subset of kinds", []model.Kind{model.KindAlbumArtwork}, nil, false, false), - Entry("a dry run applies nothing", artwork.ReprocessKinds, nil, true, false), - ) - - It("queues the matching items at recheck priority, leaving their artwork state alone", func() { - Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed()) - - Expect(queue.Count(ctx)).To(Equal(int64(2))) - queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck)) - _, err = queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued") - - stored, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork") - }) - - It("targets the absent state", func() { - Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed()) - - Expect(queue.Count(ctx)).To(Equal(int64(1))) - _, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - }) - - It("reports matched and queued separately when part of the set is already queued", func() { - Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", - ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed()) - - Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items")) - Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1")) - queued, err := queue.Get(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump), - "an already-queued row keeps its priority and backoff") - }) - - It("stops at a selection that matches nothing instead of prompting", func() { - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindRadioArtwork}, nil, imageAgents, false, - func(io.Writer, int64, int64) bool { - Fail("must not prompt when there is nothing to queue") - return true - }, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("Nothing")) - Expect(queue.Count(ctx)).To(BeZero()) - }) - - It("reports an empty selection as a dry run when one was asked for", func() { - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindRadioArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("Nothing matches")) - Expect(out.String()).To(ContainSubstring("Dry run")) - }) - - It("shows the external estimate on a dry run, which never reaches the prompt", func() { - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindAlbumArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("External lookups: ~2 estimated")) - }) - - It("bills every agent per item, not one lookup per item", func() { - imageAgents = artwork.ImageAgentCount{Artist: 2, Album: 3} - var external int64 - capture := func(_ io.Writer, _, e int64) bool { external = e; return false } - - Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, capture, &out)).To(Succeed()) - - Expect(external).To(Equal(int64(2*2+2*3)), "2 artists at 2 agents plus 2 albums at 3 agents") - Expect(out.String()).To(ContainSubstring("External lookups: ~10 estimated")) - }) - - It("names the estimate's blind spots instead of claiming a bound it cannot hold", func() { - Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, true, accept, &out)).To(Succeed()) - - // The count includes plugin agents once they load, so the caveat is about a failed load, - // not about plugins being invisible to the CLI. - Expect(out.String()).To(ContainSubstring("plugin agents counted only when they load")) - Expect(out.String()).To(ContainSubstring("local hits may need fewer")) - Expect(out.String()).ToNot(ContainSubstring("up to"), "plugin agents make any ceiling false") - Expect(out.String()).ToNot(ContainSubstring("at least"), "a local hit makes any floor false") - }) - - It("says so when the selection needs no external lookup", func() { - conf.Server.CoverArtPriority = "cover.*" - put(model.KindPlaylistArtwork, "pl-1", "playlist") - - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("External lookups: none")) - }) - - It("counts playlists as external cost when the m3u image fetch is enabled", func() { - conf.Server.CoverArtPriority = "cover.*" - conf.Server.EnableM3UExternalAlbumArt = true - put(model.KindPlaylistArtwork, "pl-1", "playlist") - var external int64 - capture := func(_ io.Writer, _, e int64) bool { external = e; return false } - - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, false, capture, &out)).To(Succeed()) - - Expect(external).To(Equal(int64(1))) - Expect(out.String()).To(ContainSubstring("External lookups: ~1 estimated")) - }) - - It("bills a playlist for every album its grid samples, at every agent", func() { - imageAgents = artwork.ImageAgentCount{Album: 3} - put(model.KindPlaylistArtwork, "pl-1", "playlist") - var external int64 - capture := func(_ io.Writer, _, e int64) bool { external = e; return false } - - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, false, capture, &out)).To(Succeed()) - - Expect(external).To(Equal(int64(artwork.PlaylistGridSamples*3)), - "one playlist samples 4 albums, each walking all 3 album agents") - }) - - It("counts only the kinds that call an external agent as external cost", func() { - put(model.KindRadioArtwork, "ra-1", "upload") - var total, external int64 - capture := func(_ io.Writer, t, e int64) bool { total, external = t, e; return false } - - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindAlbumArtwork, model.KindRadioArtwork}, - nil, imageAgents, false, capture, &out)).To(Succeed()) - - Expect(total).To(Equal(int64(3))) - Expect(external).To(Equal(int64(2)), "radio artwork never reaches an external agent") - }) - - It("rejects an unknown source and names the ones in use", func() { - err := reprocessArtwork(ctx, ds, kinds, []string{"externa:deezer"}, imageAgents, true, accept, &out) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("externa:deezer")) - Expect(err.Error()).To(ContainSubstring("external:deezer")) - Expect(err.Error()).To(ContainSubstring("folder")) - Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name") - Expect(queue.Count(ctx)).To(BeZero()) - }) - - It("accepts the absent filter with nothing absent, still rejecting a typo", func() { - put(model.KindArtistArtwork, "ar-2", "folder") - - Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{absentSource}), - imageAgents, false, accept, &out)).To(Succeed(), - "a reserved source must stay valid once the library has none of it") - Expect(out.String()).To(ContainSubstring("Nothing matches")) - Expect(queue.Count(ctx)).To(BeZero()) - - Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}), - imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected") - }) - - It("names failed among the valid sources when rejecting a typo", func() { - err := reprocessArtwork(ctx, ds, kinds, []string{"faild"}, imageAgents, true, accept, &out) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed"), - "failed is accepted but never stored, so it has to be named explicitly") - }) - - It("accepts a source another kind uses, letting the empty selection report itself", func() { - Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, []string{"folder"}, - imageAgents, false, decline, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("Nothing matches"), - "a well-formed filter must not be reported as a typo because of the kinds selected") - Expect(queue.Count(ctx)).To(BeZero()) - }) -}) - -var _ = Describe("artwork status command", func() { - It("takes no arguments", func() { - Expect(artworkStatusCmd.Args(artworkStatusCmd, []string{})).ToNot(HaveOccurred()) - Expect(artworkStatusCmd.Args(artworkStatusCmd, []string{"x"})).To(HaveOccurred()) - }) -}) - -var _ = Describe("collectStatus", func() { - var ds *tests.MockDataStore - var art *tests.MockArtworkRepo - var queue *tests.MockArtworkQueueRepo - ctx := context.Background() - - BeforeEach(func() { - ds = &tests.MockDataStore{} - art = ds.Artwork().(*tests.MockArtworkRepo) - queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo) - put := func(kind model.Kind, id, source, hash string, attempted time.Time) { - Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id, - ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed()) - } - put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now()) - put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour)) - // ar-3 is absent because it gave up, so the two absent artists split across the columns. - Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3", - ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed()) - put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now()) - Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9", - ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed()) - }) - - It("reports the queue, the source distribution and the absent totals", func() { - rep, err := collectStatus(ctx, ds) - Expect(err).ToNot(HaveOccurred()) - - Expect(rep.queue).To(ConsistOf(model.ArtworkQueueStat{ItemKind: "ar", - Priority: model.ArtworkPriorityBackfill, Count: 1})) - Expect(rep.sources).To(ContainElements( - sourceCount{kind: model.KindArtistArtwork, source: "external:deezer", count: 1}, - sourceCount{kind: model.KindArtistArtwork, source: "", count: 2}, - sourceCount{kind: model.KindAlbumArtwork, source: "folder", count: 1}, - )) - Expect(rep.absent).To(ContainElement(absentCount{kind: model.KindArtistArtwork, noImage: 1, failed: 1}), - "two absent artists, one answered and one that gave up") - }) - - It("compares the stored fingerprint against the current one", func() { - Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed()) - - rep, err := collectStatus(ctx, ds) - Expect(err).ToNot(HaveOccurred()) - Expect(rep.stored).To(Equal("old-fingerprint")) - Expect(rep.current).To(Equal(artwork.ConfigFingerprint()), - "the CLI must report the value backfill itself compares") - }) - - It("queues nothing", func() { - _, err := collectStatus(ctx, ds) - Expect(err).ToNot(HaveOccurred()) - Expect(queue.Count(ctx)).To(Equal(int64(1)), "status must not enqueue anything") - }) -}) - -var _ = Describe("formatStatus", func() { - var rep statusReport - - BeforeEach(func() { - rep = statusReport{ - queue: []model.ArtworkQueueStat{ - {ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2}, - {ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}, - }, - sources: []sourceCount{ - {kind: model.KindArtistArtwork, source: "external:deezer", count: 5}, - {kind: model.KindArtistArtwork, source: "", count: 2}, - }, - absent: []absentCount{ - {kind: model.KindArtistArtwork, noImage: 1, failed: 1}, - }, - inputs: []artwork.FingerprintInput{{Name: "Agents", Value: "deezer,lastfm"}}, - stored: "abc123", - current: "abc123", - } - }) - - // block isolates one section, so an assertion cannot be satisfied by a coincidence elsewhere. - block := func(out, header string) string { - GinkgoHelper() - _, after, found := strings.Cut(out, header+"\n") - Expect(found).To(BeTrue(), "the %q block must be printed", header) - body, _, _ := strings.Cut(after, "\n\n") - return body - } - - It("names the kind and the priority of every queued row", func() { - queue := block(formatStatus(rep), "Queue") - Expect(queue).To(MatchRegexp(`artist\s+backfill\s+2`)) - Expect(queue).To(MatchRegexp(`album\s+scan\s+1`)) - }) - - It("totals the queue", func() { - Expect(block(formatStatus(rep), "Queue")).To(MatchRegexp(`TOTAL\s+3`)) - }) - - It("counts each source, naming the empty one absent", func() { - sources := block(formatStatus(rep), "Sources") - Expect(sources).To(MatchRegexp(`artist\s+external:deezer\s+5`)) - Expect(sources).To(MatchRegexp(`artist\s+absent\s+2`)) - }) - - It("partitions the absent states into answered and gave up", func() { - out := block(formatStatus(rep), "Absent (resolved, no image found)") - Expect(out).To(ContainSubstring("NO IMAGE")) - Expect(out).To(MatchRegexp(`artist\s+1\s+1`), "1 answered plus 1 failed, summing to 2 absent") - Expect(formatStatus(rep)).To(ContainSubstring("artwork reprocess --source failed")) - }) - - It("says absent states are never retried on their own, and names both commands that do", func() { - out := formatStatus(rep) - Expect(out).To(ContainSubstring("nothing retries these")) - Expect(out).To(ContainSubstring("artwork reprocess --source absent")) - Expect(out).To(ContainSubstring("artwork reprocess --source failed")) - }) - - It("reports a matching fingerprint as up to date, whatever else is queued", func() { - Expect(block(formatStatus(rep), "Config")).To(MatchRegexp(`State:\s+up to date`)) - }) - - It("echoes the config inputs a fingerprint change would have come from", func() { - out := block(formatStatus(rep), "Config") - Expect(out).To(MatchRegexp(`Agents:\s+deezer,lastfm`)) - Expect(out).To(ContainSubstring("abc123"), "the fingerprint values themselves must be printed") - }) - - It("reports a changed fingerprint as stale artwork, and names the command that applies it", func() { - rep.stored = "older" - rep.queue = nil - - out := formatStatus(rep) - Expect(out).To(ContainSubstring("fingerprint changed")) - Expect(out).To(ContainSubstring("artwork reprocess --all")) - Expect(out).ToNot(ContainSubstring("up to date")) - }) - - It("reports a never-recorded fingerprint without printing an empty value", func() { - rep.stored = "" - - out := formatStatus(rep) - Expect(out).To(ContainSubstring("(none)")) - Expect(out).To(ContainSubstring("fingerprint changed")) - }) - - It("says the queue is empty instead of printing a headless table", func() { - rep.queue = nil - - out := formatStatus(rep) - Expect(out).To(ContainSubstring("empty")) - Expect(out).ToNot(ContainSubstring("PRIORITY")) - }) -}) - -var _ = Describe("refreshItems", func() { - var ds *tests.MockDataStore - var queue *tests.MockArtworkQueueRepo - var art *tests.MockArtworkRepo - var out strings.Builder - ctx := context.Background() - - BeforeEach(func() { - albums := tests.CreateMockAlbumRepo() - albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}}) - ds = &tests.MockDataStore{MockedAlbum: albums} - art = ds.Artwork().(*tests.MockArtworkRepo) - queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo) - out.Reset() - }) - - It("clears the stored state and queues each id at Bump priority", func() { - Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(), - ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed()) - - Expect(refreshItems(ctx, ds, []model.ArtworkID{ - {Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero()) - - _, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump)) - Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n")) - }) - - It("skips an id that does not exist instead of queuing it", func() { - Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1)) - - _, err := queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row") - Expect(out.String()).To(BeEmpty()) - }) - - It("continues past a failing id and counts the failures", func() { - Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-1"}, - {Kind: model.KindAlbumArtwork, ID: "al-2"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(Equal(1)) - - Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"), - "the ids after a failure are still refreshed") - }) -}) - -var _ = Describe("needsImageAgents", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.CoverArtPriority = "cover.*, external" - conf.Server.ArtistArtPriority = "artist.*, external" - conf.Server.EnableM3UExternalAlbumArt = false - }) - - It("is false for a selection no agent can serve", func() { - Expect(needsImageAgents([]model.Kind{model.KindRadioArtwork})).To(BeFalse()) - }) - - // The generated playlist grid resolves album art through the image agents, so a playlist - // selection needs the count even though no agent is asked for a playlist image directly. - It("is true for playlists, whose grid tiles resolve through the album chain", func() { - Expect(needsImageAgents([]model.Kind{model.KindPlaylistArtwork})).To(BeTrue()) - }) - - It("is true when any one of several kinds can reach an agent", func() { - Expect(needsImageAgents([]model.Kind{model.KindRadioArtwork, model.KindAlbumArtwork})).To(BeTrue()) - }) - - It("is false once the chains no longer reach an agent", func() { - conf.Server.CoverArtPriority = "cover.*" - conf.Server.ArtistArtPriority = "artist.*" - Expect(needsImageAgents(artwork.ReprocessKinds)).To(BeFalse()) - }) -}) - -var _ = Describe("configuredAgents", func() { - BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) }) - - It("splits and trims the configured list", func() { - conf.Server.Agents = "lastfm, spotify ,deezer" - Expect(configuredAgents()).To(Equal([]string{"lastfm", "spotify", "deezer"})) - }) - - // An empty name would match no plugin, but it also must not make the list look non-empty: - // LoadPlugins treats an empty list as "load nothing". - It("drops empty entries rather than passing a name nothing can match", func() { - conf.Server.Agents = " , ," - Expect(configuredAgents()).To(BeEmpty()) - }) -}) - -var _ = Describe("parseArtworkPriority", func() { - It("accepts every name status prints", func() { - for _, p := range []int{model.ArtworkPriorityRecheck, model.ArtworkPriorityBackfill, - model.ArtworkPriorityScan, model.ArtworkPriorityBump} { - Expect(parseArtworkPriority(priorityName(p))).To(Equal(p)) - } - }) - - It("rejects an unknown name and lists the valid ones", func() { - _, err := parseArtworkPriority("urgent") - Expect(err).To(MatchError(ContainSubstring(`invalid priority "urgent"`))) - Expect(err).To(MatchError(ContainSubstring("backfill"))) - }) - - // Accepting the raw numbers would make the help text a lie and let a typo like 11 select nothing. - It("rejects the numeric form", func() { - _, err := parseArtworkPriority("10") - Expect(err).To(HaveOccurred()) - }) -}) - -var _ = Describe("artwork cancel selection", func() { - It("errors when no selector is given", func() { - _, _, err := cancelSelection(nil, nil, false) - Expect(err).To(MatchError(ContainSubstring("no selector given"))) - }) - - // Empty, not an enumeration of the known kinds: --all must also take a queue row whose kind - // this build does not recognise. - It("selects with no filter at all for --all", func() { - kinds, priorities, err := cancelSelection(nil, nil, true) - Expect(err).ToNot(HaveOccurred()) - Expect(kinds).To(BeEmpty()) - Expect(priorities).To(BeEmpty()) - }) - - // The queue holds media file rows, so --all must reach them. - It("accepts media file artwork, which reprocess does not", func() { - kinds, _, err := cancelSelection([]string{"mf"}, nil, false) - Expect(err).ToNot(HaveOccurred()) - Expect(kinds).To(Equal([]model.Kind{model.KindMediaFileArtwork})) - }) - - It("treats a priority filter on its own as a complete selection", func() { - kinds, priorities, err := cancelSelection(nil, []string{"backfill"}, false) - Expect(err).ToNot(HaveOccurred()) - Expect(kinds).To(BeEmpty(), "no kind filter means every kind") - Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill})) - }) - - It("returns only the named kinds and priorities", func() { - kinds, priorities, err := cancelSelection([]string{"ar", "al"}, []string{"backfill", "scan"}, false) - Expect(err).ToNot(HaveOccurred()) - Expect(kinds).To(Equal([]model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork})) - Expect(priorities).To(Equal([]int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan})) - }) - - It("counts a repeated kind and a repeated priority once", func() { - kinds, priorities, err := cancelSelection([]string{"ar", "ar"}, []string{"bump", "bump"}, false) - Expect(err).ToNot(HaveOccurred()) - Expect(kinds).To(HaveLen(1)) - Expect(priorities).To(HaveLen(1)) - }) - - It("rejects an unknown kind", func() { - _, _, err := cancelSelection([]string{"zz"}, nil, false) - Expect(err).To(MatchError(ContainSubstring(`invalid kind "zz"`))) - }) - - It("rejects a kind that is never queued", func() { - _, _, err := cancelSelection([]string{"dc"}, nil, false) - Expect(err).To(MatchError(ContainSubstring("invalid kind"))) - }) - - It("rejects an unknown priority", func() { - _, _, err := cancelSelection(nil, []string{"urgent"}, false) - Expect(err).To(MatchError(ContainSubstring("invalid priority"))) - }) -}) - -var _ = Describe("cancelArtwork", func() { - var ds *tests.MockDataStore - var queue *tests.MockArtworkQueueRepo - var out strings.Builder - ctx := context.Background() - accept := func(io.Writer, int64, int64) bool { return true } - decline := func(io.Writer, int64, int64) bool { return false } - - BeforeEach(func() { - ds = &tests.MockDataStore{} - queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo) - out.Reset() - Expect(queue.Enqueue(ctx, - model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityBackfill}, - model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityBump}, - model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-1", ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityBackfill}, - )).To(Succeed()) - }) - - It("previews the per-kind breakdown and cancels nothing on a dry run", func() { - Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindArtistArtwork}, nil, true, accept, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("artist")) - Expect(out.String()).To(ContainSubstring("backfill")) - Expect(out.String()).To(ContainSubstring("TOTAL")) - Expect(out.String()).To(ContainSubstring("Dry run")) - Expect(queue.Count(ctx)).To(BeNumerically("==", 3)) - }) - - It("cancels nothing when the operator declines", func() { - Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("Aborted")) - Expect(queue.Count(ctx)).To(BeNumerically("==", 3)) - }) - - It("deletes the selected rows and leaves the rest queued", func() { - Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed()) - - Expect(queue.Count(ctx)).To(BeNumerically("==", 1)) - _, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued") - Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items.")) - }) - - It("cancels every kind and priority when neither filter is given", func() { - Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed()) - Expect(queue.Count(ctx)).To(BeZero()) - }) - - It("stops at a selection that matches nothing instead of prompting", func() { - refuse := func(io.Writer, int64, int64) bool { - Fail("must not prompt when nothing matches") - return false - } - Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed()) - - Expect(out.String()).To(ContainSubstring("Nothing matches this selection.")) - Expect(queue.Count(ctx)).To(BeNumerically("==", 3)) - }) - - It("reports a queue read failure instead of reporting nothing to cancel", func() { - queue.Err = errors.New("read failed") - Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(MatchError(ContainSubstring("read failed"))) - }) -}) diff --git a/cmd/backup.go b/cmd/backup.go deleted file mode 100644 index 932a833f2..000000000 --- a/cmd/backup.go +++ /dev/null @@ -1,146 +0,0 @@ -package cmd - -import ( - "context" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/log" - "github.com/spf13/cobra" -) - -var ( - backupCount int - backupDir string - force bool - restorePath string -) - -func init() { - rootCmd.AddCommand(backupRoot) - - backupCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory to manually make backup") - backupRoot.AddCommand(backupCmd) - - pruneCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory holding Navidrome backups") - pruneCmd.Flags().IntVarP(&backupCount, "keep-count", "k", -1, "specify the number of backups to keep. 0 remove ALL backups, and negative values mean to use the default from configuration") - pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero") - backupRoot.AddCommand(pruneCmd) - - restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)") - restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning") - _ = restoreCommand.MarkFlagRequired("backup-file") - backupRoot.AddCommand(restoreCommand) -} - -var ( - backupRoot = &cobra.Command{ - Use: "backup", - Aliases: []string{"bkp"}, - Short: "Create, restore and prune database backups", - Long: "Create, restore and prune database backups", - } - - backupCmd = &cobra.Command{ - Use: "create", - Short: "Create a backup database", - Long: "Manually backup Navidrome database. This will ignore BackupCount", - Run: func(cmd *cobra.Command, _ []string) { - runBackup(cmd.Context()) - }, - } - - pruneCmd = &cobra.Command{ - Use: "prune", - Short: "Prune database backups", - Long: "Manually prune database backups according to backup rules", - Run: func(cmd *cobra.Command, _ []string) { - runPrune(cmd.Context()) - }, - } - - restoreCommand = &cobra.Command{ - Use: "restore", - Short: "Restore Navidrome database", - Long: "Restore Navidrome database from a backup. This must be done offline", - Run: func(cmd *cobra.Command, _ []string) { - runRestore(cmd.Context()) - }, - } -) - -func runBackup(ctx context.Context) { - if backupDir != "" { - conf.Server.Backup.Path = conf.NewDir(backupDir) - } - - requireExistingDB() - - start := time.Now() - path, err := db.Backup(ctx) - if err != nil { - log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err) - } - - elapsed := time.Since(start) - log.Info("Backup complete", "elapsed", elapsed, "path", path) -} - -func runPrune(ctx context.Context) { - if backupDir != "" { - conf.Server.Backup.Path = conf.NewDir(backupDir) - } - - if backupCount != -1 { - conf.Server.Backup.Count = backupCount - } - - if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") { - log.Warn("Prune cancelled") - return - } - - requireExistingDB() - - start := time.Now() - count, err := db.Prune(ctx) - if err != nil { - log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err) - } - - elapsed := time.Since(start) - - log.Info("Prune complete", "elapsed", elapsed, "successfully pruned", count) -} - -func runRestore(ctx context.Context) { - requireExistingDB() - - // A relative --backup-file is resolved against Backup.Path, the same folder - // `backup create` writes to. Without this, the value was treated as relative - // to the working directory, where the file does not exist. - if !filepath.IsAbs(restorePath) { - backupPath, err := conf.Server.Backup.Path.Path() - if err != nil { - log.Fatal("Backup directory not available", "backupPath", conf.Server.Backup.Path, err) - return - } - restorePath = filepath.Join(backupPath, restorePath) - } - - if !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") { - log.Warn("Restore cancelled") - return - } - - start := time.Now() - err := db.Restore(ctx, restorePath) - if err != nil { - log.Fatal("Error restoring database", "backupFile", restorePath, err) - } - - elapsed := time.Since(start) - log.Info("Restore complete", "elapsed", elapsed) -} diff --git a/cmd/doctor.go b/cmd/doctor.go deleted file mode 100644 index fd0eb1cd7..000000000 --- a/cmd/doctor.go +++ /dev/null @@ -1,99 +0,0 @@ -package cmd - -import ( - "context" - "database/sql" - "fmt" - "io" - "os" - - "github.com/navidrome/navidrome/db" - "github.com/spf13/cobra" -) - -func init() { - rootCmd.AddCommand(doctorCmd) -} - -var doctorCmd = &cobra.Command{ - Use: "doctor", - Short: "Check your Navidrome installation for problems", - Long: "Run read-only health checks and report what was found. Checks the database for " + - "corruption and foreign key violations, and reports whether 'navidrome search rebuild' " + - "can fix what it finds. This command never alters your data", - Run: func(cmd *cobra.Command, _ []string) { - runDoctor(cmd.Context()) - }, -} - -func runDoctor(ctx context.Context) { - requireExistingDB() - - healthy := doctor(ctx, db.Db(), os.Stdout) - db.Close(ctx) - if !healthy { - os.Exit(1) - } -} - -const recoveryAdvice = "Restore a backup (navidrome backup restore), or try SQLite's '.recover' command." - -func printFindings(out io.Writer, check, noun string, items []string) { - fmt.Fprintf(out, "%s reported %d %s:\n", check, len(items), noun) - for _, item := range items { - fmt.Fprintln(out, " "+item) - } -} - -func doctor(ctx context.Context, database *sql.DB, out io.Writer) bool { - healthy := true - - fmt.Fprintln(out, "Checking database integrity...") - issues, truncated, err := db.IntegrityCheck(ctx, database) - switch { - case err != nil: - fmt.Fprintln(out, "The integrity check could not complete: "+err.Error()) - fmt.Fprintln(out, recoveryAdvice) - return false - case len(issues) == 0: - fmt.Fprintln(out, "Integrity check passed.") - default: - healthy = false - printFindings(out, "Integrity check", "issue(s)", issues) - switch { - case truncated: - fmt.Fprintln(out, "The integrity check stopped at its limit, so the damage may reach further than listed.") - fmt.Fprintln(out, recoveryAdvice) - case db.IsFTSCorruptionOnly(issues): - fmt.Fprintln(out, "Corruption is limited to the search index. Run 'navidrome search rebuild' to fix it.") - default: - fmt.Fprintln(out, "Corruption is not limited to the search index, and cannot be repaired automatically.") - fmt.Fprintln(out, recoveryAdvice) - } - } - - fmt.Fprintln(out, "Checking foreign keys...") - violations, err := db.ForeignKeyCheck(ctx, database) - switch { - case err != nil: - healthy = false - fmt.Fprintln(out, "The foreign key check could not complete: "+err.Error()) - case len(violations) == 0: - fmt.Fprintln(out, "Foreign key check passed.") - default: - healthy = false - lines := make([]string, 0, len(violations)) - for _, v := range violations { - lines = append(lines, - fmt.Sprintf("%s: %d row(s) reference missing rows in %s", v.Table, v.Count, v.Parent)) - } - printFindings(out, "Foreign key check", "violation(s)", lines) - fmt.Fprintln(out, "These are orphaned rows, not corruption. 'navidrome scan -f' clears some of them "+ - "in library data; the rest have to be removed by hand.") - } - - if healthy { - fmt.Fprintln(out, "Database is healthy.") - } - return healthy -} diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go deleted file mode 100644 index 7f8cd9728..000000000 --- a/cmd/doctor_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package cmd - -import ( - "context" - "database/sql" - "os" - "path/filepath" - "strings" - - "github.com/navidrome/navidrome/db" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("doctor", func() { - var ( - ctx context.Context - dbPath string - database *sql.DB - out *strings.Builder - reopen func() - ) - - // A file-backed DB so specs can corrupt raw pages; a table named like a real FTS - // search table so IsFTSCorruptionOnly matches, plus a parent/child pair for FK checks. - BeforeEach(func() { - ctx = context.Background() - dbPath = filepath.Join(GinkgoT().TempDir(), "doctor.db") - reopen = func() { - var err error - database, err = sql.Open(db.Dialect, dbPath) - Expect(err).ToNot(HaveOccurred()) - database.SetMaxOpenConns(1) - } - reopen() - DeferCleanup(func() { _ = database.Close() }) - - for _, stmt := range []string{ - `create virtual table media_file_fts using fts5(title, content='', content_rowid='rowid')`, - `insert into media_file_fts(rowid, title) values (1, 'teenage lobotomy'), (2, 'rockaway beach')`, - `create table library(id integer primary key)`, - `create table media_file(id integer primary key, library_id integer references library(id))`, - } { - _, err := database.ExecContext(ctx, stmt) - Expect(err).ToNot(HaveOccurred()) - } - out = &strings.Builder{} - }) - - It("reports a healthy database", func() { - Expect(doctor(ctx, database, out)).To(BeTrue()) - Expect(out.String()).To(ContainSubstring("Database is healthy.")) - }) - - It("points to 'search rebuild' when corruption is limited to the search index", func() { - _, err := database.ExecContext(ctx, - `update media_file_fts_data set block = x'deadbeefdeadbeef' where id > 1`) - Expect(err).ToNot(HaveOccurred()) - - Expect(doctor(ctx, database, out)).To(BeFalse()) - Expect(out.String()).To(ContainSubstring("navidrome search rebuild")) - }) - - It("points to a backup restore when corruption is not limited to the search index", func() { - _, err := database.ExecContext(ctx, - `insert into library(id) - with recursive s(x) as (select 1 union all select x+1 from s where x < 200) - select x from s`) - Expect(err).ToNot(HaveOccurred()) - var rootPage, pageSize int64 - Expect(database.QueryRowContext(ctx, - `select rootpage from sqlite_master where name = 'library'`).Scan(&rootPage)).To(Succeed()) - Expect(database.QueryRowContext(ctx, `pragma page_size`).Scan(&pageSize)).To(Succeed()) - Expect(database.Close()).To(Succeed()) - f, err := os.OpenFile(dbPath, os.O_WRONLY, 0600) - Expect(err).ToNot(HaveOccurred()) - _, err = f.WriteAt([]byte{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef}, (rootPage-1)*pageSize+40) - Expect(err).ToNot(HaveOccurred()) - Expect(f.Close()).To(Succeed()) - reopen() - - Expect(doctor(ctx, database, out)).To(BeFalse()) - Expect(out.String()).To(ContainSubstring("backup restore")) - Expect(out.String()).ToNot(ContainSubstring("search rebuild")) - }) - - It("reports foreign key violations", func() { - _, err := database.ExecContext(ctx, `pragma foreign_keys = off`) - Expect(err).ToNot(HaveOccurred()) - _, err = database.ExecContext(ctx, `insert into media_file(id, library_id) values (1, 999)`) - Expect(err).ToNot(HaveOccurred()) - - Expect(doctor(ctx, database, out)).To(BeFalse()) - Expect(out.String()).To(ContainSubstring("Foreign key check reported")) - Expect(out.String()).To(ContainSubstring("media_file")) - Expect(out.String()).To(ContainSubstring("navidrome scan -f")) - // GC never touches player, share or playqueue, so don't promise a full cleanup. - Expect(out.String()).To(ContainSubstring("removed by hand")) - }) - - // Every issue names an FTS-like index, so IsFTSCorruptionOnly alone would send the - // user to 'search rebuild', but the pragma stopped at its limit without saying so. - It("does not blame the search index when the issue list is truncated", func() { - for _, stmt := range []string{ - `create table t(a, b)`, - `with recursive s(x) as (select 1 union all select x+1 from s where x < 300) - insert into t select x, x + 10000 from s`, - `create index media_file_fts_probe on t(a)`, - `pragma writable_schema=on`, - `update sqlite_master set sql = 'CREATE INDEX media_file_fts_probe ON t(b)' - where name = 'media_file_fts_probe'`, - } { - _, err := database.ExecContext(ctx, stmt) - Expect(err).ToNot(HaveOccurred()) - } - Expect(database.Close()).To(Succeed()) - reopen() - - Expect(doctor(ctx, database, out)).To(BeFalse()) - Expect(out.String()).ToNot(ContainSubstring("search rebuild")) - Expect(out.String()).To(ContainSubstring("backup restore")) - }) -}) diff --git a/cmd/inspect.go b/cmd/inspect.go deleted file mode 100644 index 05f569f3e..000000000 --- a/cmd/inspect.go +++ /dev/null @@ -1,119 +0,0 @@ -package cmd - -import ( - "context" - "encoding/json" - "fmt" - "path/filepath" - "strings" - - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" - "github.com/pelletier/go-toml/v2" - "github.com/spf13/cobra" - "gopkg.in/yaml.v3" -) - -var ( - format string -) - -func init() { - inspectCmd.Flags().StringVarP(&format, "format", "f", "jsonindent", "output format (pretty, toml, yaml, json, jsonindent)") - rootCmd.AddCommand(inspectCmd) -} - -var inspectCmd = &cobra.Command{ - Use: "inspect [files to inspect]", - Short: "Inspect tags", - Long: "Show file tags as seen by Navidrome", - Args: cobra.MinimumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { - runInspector(cmd.Context(), args) - }, -} - -var marshalers = map[string]func(any) ([]byte, error){ - "pretty": prettyMarshal, - "toml": toml.Marshal, - "yaml": yaml.Marshal, - "json": json.Marshal, - "jsonindent": func(v any) ([]byte, error) { - return json.MarshalIndent(v, "", " ") - }, -} - -func prettyMarshal(v any) ([]byte, error) { - out := v.([]core.InspectOutput) - var res strings.Builder - for i := range out { - res.WriteString(fmt.Sprintf("====================\nFile: %s\n\n", out[i].File)) - t, _ := toml.Marshal(out[i].RawTags) - res.WriteString(fmt.Sprintf("Raw tags:\n%s\n\n", t)) - t, _ = toml.Marshal(out[i].MappedTags) - res.WriteString(fmt.Sprintf("Mapped tags:\n%s\n\n", t)) - } - return []byte(res.String()), nil -} - -func runInspector(ctx context.Context, args []string) { - marshal := marshalers[format] - if marshal == nil { - log.Fatal("Invalid format", "format", format) - } - libs := loadLibraries(ctx) - matcher := model.NewLibraryMatcher(libs) - var out []core.InspectOutput - for _, filePath := range args { - if !model.IsAudioFile(filePath) { - log.Warn("Not an audio file", "file", filePath) - continue - } - lib, ok := libraryForFile(matcher, filePath) - if !ok && len(libs) > 0 { - log.Warn("File is not in any library, using the global PID config", "file", filePath) - } - output, err := core.Inspect(filePath, lib, "") - if err != nil { - log.Warn("Unable to process file", "file", filePath, "error", err) - continue - } - - out = append(out, *output) - } - data, _ := marshal(out) - fmt.Println(string(data)) -} - -// loadLibraries reads the libraries, so each file gets its library's PID config. It never creates a DB. -func loadLibraries(ctx context.Context) model.Libraries { - if dbFile, ok := existingDBFile(); !ok { - log.Warn(ctx, "No database found, using the global PID config", "path", dbFile) - return nil - } - defer db.Init(ctx)() - libs, err := persistence.New(db.Db()).Library().GetAll(ctx) - if err != nil { - log.Warn(ctx, "Could not load libraries, using the global PID config", err) - return nil - } - for i := range libs { - if absPath, err := filepath.Abs(libs[i].Path); err == nil { - libs[i].Path = absPath - } - } - return libs -} - -// libraryForFile falls back to the default library with no overrides, which uses the global PID config. -func libraryForFile(matcher *model.LibraryMatcher, filePath string) (model.Library, bool) { - if absPath, err := filepath.Abs(filePath); err == nil { - if lib, ok := matcher.FindLibrary(absPath); ok { - return lib, true - } - } - return model.Library{ID: model.DefaultLibraryID}, false -} diff --git a/cmd/inspect_test.go b/cmd/inspect_test.go deleted file mode 100644 index 728dc8770..000000000 --- a/cmd/inspect_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package cmd - -import ( - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("inspect", func() { - Describe("libraryForFile", func() { - var matcher *model.LibraryMatcher - var root string - - BeforeEach(func() { - root = GinkgoT().TempDir() - cwd, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - matcher = model.NewLibraryMatcher(model.Libraries{ - {ID: 1, Path: filepath.Join(root, "music")}, - {ID: 2, Path: filepath.Join(cwd, "loose"), PIDAlbum: "folder"}, - }) - }) - - It("returns the library that contains an absolute path", func() { - lib, ok := libraryForFile(matcher, filepath.Join(root, "music", "album", "track.mp3")) - Expect(ok).To(BeTrue()) - Expect(lib.ID).To(Equal(1)) - }) - - It("resolves a relative path against the working directory", func() { - lib, ok := libraryForFile(matcher, filepath.Join("loose", "track.mp3")) - Expect(ok).To(BeTrue()) - Expect(lib.PIDAlbum).To(Equal("folder")) - }) - - It("falls back to the default library without overrides", func() { - lib, ok := libraryForFile(matcher, filepath.Join(root, "elsewhere", "track.mp3")) - Expect(ok).To(BeFalse()) - Expect(lib).To(Equal(model.Library{ID: model.DefaultLibraryID})) - }) - }) - - Describe("loadLibraries", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("does not create a database when there is none", func() { - dbFile := filepath.Join(GinkgoT().TempDir(), "navidrome.db") - conf.Server.DbPath = dbFile + "?_journal_mode=WAL" - - Expect(loadLibraries(GinkgoT().Context())).To(BeNil()) - Expect(dbFile).ToNot(BeAnExistingFile()) - }) - }) -}) diff --git a/cmd/missing.go b/cmd/missing.go deleted file mode 100644 index ce95e39f0..000000000 --- a/cmd/missing.go +++ /dev/null @@ -1,171 +0,0 @@ -package cmd - -import ( - "bufio" - "context" - "encoding/csv" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "strconv" - "strings" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "github.com/spf13/cobra" -) - -var missingListFormat string - -func init() { - missingListCmd.Flags().StringVarP(&missingListFormat, "format", "f", "csv", "output format [supported values: csv, json]") - missingCmd.AddCommand(missingListCmd) - missingCmd.AddCommand(missingFixCmd) - rootCmd.AddCommand(missingCmd) -} - -var ( - missingCmd = &cobra.Command{ - Use: "missing", - Short: "Manage missing files", - Long: "List files marked as missing and remap them onto existing files", - } - - missingListCmd = &cobra.Command{ - Use: "list", - Short: "List missing files", - Run: func(cmd *cobra.Command, _ []string) { - runMissingList(cmd.Context()) - }, - } - - missingFixCmd = &cobra.Command{ - Use: "fix ", - Short: "Remap a missing file onto an existing file", - Long: "Remap a file marked as missing onto an existing (non-missing) file, the same way\n" + - "the scanner reconciles moved or renamed files. Each argument may be a media file ID,\n" + - "a library-relative path, or a libraryID:path pair.", - Args: cobra.ExactArgs(2), - Run: func(cmd *cobra.Command, args []string) { - runMissingFix(cmd.Context(), args[0], args[1]) - }, - } -) - -type displayMissingFile struct { - ID string `json:"id"` - LibraryID int `json:"libraryId"` - Title string `json:"title"` - Album string `json:"album"` - Artist string `json:"artist"` - Path string `json:"path"` -} - -func runMissingList(ctx context.Context) { - if missingListFormat != "csv" && missingListFormat != "json" { - log.Fatal("Invalid output format. Must be one of csv, json", "format", missingListFormat) - } - - ds, ctx := getAdminContext(ctx) - mfs, err := ds.MediaFile().GetCursor(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": true}, - Sort: "path", - }) - if err == nil { - err = writeMissingList(os.Stdout, missingListFormat, mfs) - } - if err != nil { - log.Fatal(ctx, "Failed to retrieve missing files", err) - } -} - -// writeMissingList streams the cursor so a library with many missing files doesn't get loaded into memory -func writeMissingList(w io.Writer, format string, mfs model.MediaFileCursor) error { - if format == "json" { - bw := bufio.NewWriter(w) - _, _ = io.WriteString(bw, "[") - sep := "" - for mf, err := range mfs { - if err != nil { - return err - } - j, _ := json.Marshal(displayMissingFile{ID: mf.ID, LibraryID: mf.LibraryID, Title: mf.Title, Album: mf.Album, Artist: mf.Artist, Path: mf.Path}) - _, _ = fmt.Fprintf(bw, "%s%s", sep, j) - sep = "," - } - _, _ = io.WriteString(bw, "]\n") - return bw.Flush() - } - - cw := csv.NewWriter(w) - _ = cw.Write([]string{"id", "library id", "title", "album", "artist", "path"}) - for mf, err := range mfs { - if err != nil { - return err - } - _ = cw.Write([]string{mf.ID, strconv.Itoa(mf.LibraryID), mf.Title, mf.Album, mf.Artist, mf.Path}) - } - cw.Flush() - return cw.Error() -} - -func runMissingFix(ctx context.Context, missingRef, targetRef string) { - ds, ctx := getAdminContext(ctx) - - missing := resolveMediaFile(ctx, ds, missingRef) - target := resolveMediaFile(ctx, ds, targetRef) - - if err := core.NewMaintenance(ds).RemapMissingFile(ctx, missing.ID, target.ID); err != nil { - log.Fatal(ctx, "Failed to remap missing file", "missing", missing.Path, "target", target.Path, err) - } - fmt.Printf("Remapped %q onto %q\n", missing.Path, target.Path) -} - -// resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path). -func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile { - mf, err := ds.MediaFile().Get(ctx, ref) - if err == nil { - return mf - } - if !errors.Is(err, model.ErrNotFound) { - log.Fatal(ctx, "Error looking up media file", "ref", ref, err) - } - - mfs, err := ds.MediaFile().FindByPaths(ctx, []string{ref}) - if err != nil { - log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err) - } - if len(mfs) == 0 { - log.Fatal(ctx, "No media file found", "ref", ref) - } - mfs = preferQualified(ref, mfs) - if len(mfs) > 1 { - log.Fatal(ctx, "Path matches multiple files; disambiguate with an ID or libraryID:path", "ref", ref, "matches", len(mfs)) - } - return &mfs[0] -} - -// preferQualified resolves the ambiguity FindByPaths creates by searching a "libraryID:path" -// reference both ways: an explicit library wins over a file literally named like one. -func preferQualified(ref string, mfs model.MediaFiles) model.MediaFiles { - id, path, ok := strings.Cut(ref, ":") - if !ok { - return mfs - } - libraryID, err := strconv.Atoi(id) - if err != nil { - return mfs - } - qualified := slice.Filter(mfs, func(mf model.MediaFile) bool { - return mf.LibraryID == libraryID && strings.EqualFold(mf.Path, path) - }) - if len(qualified) == 0 { - return mfs - } - return qualified -} diff --git a/cmd/missing_test.go b/cmd/missing_test.go deleted file mode 100644 index 2e96cf3c7..000000000 --- a/cmd/missing_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package cmd - -import ( - "errors" - "strings" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("writeMissingList", func() { - cursor := func(err error, mfs ...model.MediaFile) model.MediaFileCursor { - return func(yield func(model.MediaFile, error) bool) { - for _, mf := range mfs { - if !yield(mf, nil) { - return - } - } - if err != nil { - yield(model.MediaFile{}, err) - } - } - } - song := model.MediaFile{ID: "1", LibraryID: 1, Path: "Bach: Goldberg/01.mp3", Title: "Aria", Album: "Goldberg", Artist: "Bach"} - - It("writes csv with a header, quoting as needed", func() { - var out strings.Builder - Expect(writeMissingList(&out, "csv", cursor(nil, song))).To(Succeed()) - Expect(out.String()).To(Equal("id,library id,title,album,artist,path\n1,1,Aria,Goldberg,Bach,Bach: Goldberg/01.mp3\n")) - }) - - It("writes a json array", func() { - var out strings.Builder - Expect(writeMissingList(&out, "json", cursor(nil, song, song))).To(Succeed()) - Expect(out.String()).To(MatchJSON(`[ - {"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"}, - {"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"} - ]`)) - }) - - It("writes an empty json array when nothing is missing", func() { - var out strings.Builder - Expect(writeMissingList(&out, "json", cursor(nil))).To(Succeed()) - Expect(out.String()).To(MatchJSON(`[]`)) - }) - - It("returns the cursor's error", func() { - var out strings.Builder - Expect(writeMissingList(&out, "csv", cursor(errors.New("boom"), song))).To(MatchError("boom")) - }) -}) - -var _ = Describe("preferQualified", func() { - target := model.MediaFile{ID: "want", LibraryID: 1, Path: "foo.mp3"} - decoy := model.MediaFile{ID: "decoy", LibraryID: 1, Path: "1:foo.mp3"} - - It("picks the library-qualified match over a literal path that looks like one", func() { - Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, decoy})).To(Equal(model.MediaFiles{target})) - }) - - It("picks the named library when the same path exists in two", func() { - other := model.MediaFile{ID: "other", LibraryID: 2, Path: "foo.mp3"} - Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, other})).To(Equal(model.MediaFiles{target})) - }) - - It("leaves an unqualified reference ambiguous", func() { - both := model.MediaFiles{target, {ID: "other", LibraryID: 2, Path: "foo.mp3"}} - Expect(preferQualified("foo.mp3", both)).To(Equal(both)) - }) - - It("leaves it alone when the prefix is not a library id", func() { - both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}} - Expect(preferQualified("x:foo.mp3", both)).To(Equal(both)) - }) - - It("leaves it alone when no candidate matches the qualified form", func() { - both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}} - Expect(preferQualified("9:nope.mp3", both)).To(Equal(both)) - }) -}) diff --git a/cmd/pls.go b/cmd/pls.go index bf16ea420..1d390c1e8 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -2,311 +2,70 @@ package cmd import ( "context" - "encoding/csv" - "encoding/json" "errors" - "fmt" - "io" "os" - "path/filepath" - "strconv" - "strings" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/auth" + "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/utils/ioutils" - "github.com/navidrome/navidrome/utils/slice" - "github.com/navidrome/navidrome/utils/str" + "github.com/navidrome/navidrome/persistence" "github.com/spf13/cobra" ) var ( - playlistID string - outputFile string - userID string - outputFormat string - syncFlag bool + playlistID string + outputFile string ) -type displayPlaylist struct { - Id string `json:"id"` - Name string `json:"name"` - OwnerName string `json:"ownerName"` - OwnerId string `json:"ownerId"` - Public bool `json:"public"` -} - -type displayPlaylists []displayPlaylist - func init() { plsCmd.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID") plsCmd.Flags().StringVarP(&outputFile, "output", "o", "", "output file (default stdout)") _ = plsCmd.MarkFlagRequired("playlist") rootCmd.AddCommand(plsCmd) - - 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 ( - plsCmd = &cobra.Command{ - Use: "pls", - Short: "Export playlists", - Long: "Export Navidrome playlists to M3U files", - Run: func(cmd *cobra.Command, args []string) { - runExporter(cmd.Context()) - }, - } - - listCommand = &cobra.Command{ - Use: "list", - Short: "List playlists", - Run: func(cmd *cobra.Command, args []string) { - 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 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().GetAll(ctx, options) - if err != nil { - log.Fatal(ctx, "Failed to retrieve playlists", err) - } - return pls +var plsCmd = &cobra.Command{ + Use: "pls", + Short: "Export playlists", + Long: "Export Navidrome playlists to M3U files", + Run: func(cmd *cobra.Command, args []string) { + runExporter() + }, } -func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist { - playlist, err := ds.Playlist().GetWithTracks(ctx, nameOrID, true, false) +func runExporter() { + sqlDB := db.Db() + ds := persistence.New(sqlDB) + ctx := auth.WithAdminUser(context.Background(), ds) + playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true) if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Fatal("Error retrieving playlist", "name", nameOrID, err) + log.Fatal("Error retrieving playlist", "name", playlistID, err) } if errors.Is(err, model.ErrNotFound) { - playlists, err := ds.Playlist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}}) + playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": playlistID}}) if err != nil { - log.Fatal("Error retrieving playlist", "name", nameOrID, err) + log.Fatal("Error retrieving playlist", "name", playlistID, err) } if len(playlists) > 0 { - playlist, err = ds.Playlist().GetWithTracks(ctx, playlists[0].ID, true, false) + playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true) if err != nil { - log.Fatal("Error retrieving playlist", "name", nameOrID, err) + log.Fatal("Error retrieving playlist", "name", playlistID, err) } } } if playlist == nil { - log.Fatal("Playlist not found", "name", nameOrID) + log.Fatal("Playlist not found", "name", playlistID) } - return playlist -} - -func runExporter(ctx context.Context) { - ds, ctx := getAdminContext(ctx) - playlist := findPlaylist(ctx, ds, playlistID) - writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile) -} - -func writePlaylist(m3u string, out io.Writer, file string) { - if file == "" || file == "-" { - fmt.Fprint(out, m3u) - return - } - if err := os.WriteFile(file, []byte(m3u), 0600); err != nil { - log.Fatal("Error writing to the output file", "file", file, err) - } -} - -func runExport(ctx context.Context) { - ds, ctx := getAdminContext(ctx) - - if playlistID != "" && outputFile == "" { - playlist := findPlaylist(ctx, ds, playlistID) - writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile) + pls := playlist.ToM3U8() + if outputFile == "-" || outputFile == "" { + println(pls) 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().GetWithTracks(ctx, 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) - 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 allPls { - _ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)}) - } - w.Flush() - } else { - 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 - display[idx].OwnerName = playlist.OwnerName - display[idx].Public = playlist.Public - } - - j, _ := json.Marshal(display) - 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, artwork.NewUploader(ds)) - - 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) + err = os.WriteFile(outputFile, []byte(pls), 0600) if err != nil { - return 0 + log.Fatal("Error writing to the output file", "file", outputFile, err) } - 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/pls_test.go b/cmd/pls_test.go deleted file mode 100644 index f3e8c7edd..000000000 --- a/cmd/pls_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("writePlaylist", func() { - const m3u = "#EXTM3U\n#PLAYLIST:DJ Wave\n#EXTINF:364,Bel Canto - Dreaming Girl\n" - plsFile := filepath.Join(os.TempDir(), fmt.Sprintf("navidrome-pls-%d.m3u8", os.Getpid())) - - BeforeEach(func() { - DeferCleanup(func() { _ = os.Remove(plsFile) }) - }) - - DescribeTable("writes the playlist to exactly one destination", - func(file, wantStream, wantFile string) { - var out strings.Builder - - writePlaylist(m3u, &out, file) - - written, _ := os.ReadFile(plsFile) - Expect(out.String()).To(Equal(wantStream)) - Expect(string(written)).To(Equal(wantFile)) - }, - Entry("no file name writes to the stream", "", m3u, ""), - Entry("a dash writes to the stream", "-", m3u, ""), - Entry("a path writes to the file", plsFile, "", m3u), - ) -}) diff --git a/cmd/plugin.go b/cmd/plugin.go deleted file mode 100644 index 7b8a9a393..000000000 --- a/cmd/plugin.go +++ /dev/null @@ -1,557 +0,0 @@ -package cmd - -import ( - "context" - "encoding/csv" - "encoding/json" - "fmt" - "io" - "os" - "strconv" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/plugins" - "github.com/spf13/cobra" -) - -// pluginManager is the subset of *plugins.Manager the CLI needs. -type pluginManager interface { - EnablePlugin(ctx context.Context, id string) error - DisablePlugin(ctx context.Context, id string) error - ValidatePluginConfig(ctx context.Context, id, configJSON string) error - UpdatePluginConfig(ctx context.Context, id, configJSON string) error - UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error - UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error - RescanPlugins(ctx context.Context) error -} - -var ( - pluginListFormat string - pluginInfoFormat string -) - -var ( - editConfig string - editConfigFile string - editUsers string - editAllUsers bool - editLibraries string - editAllLibs bool - editWriteAccess bool - editNoWrite bool -) - -func init() { - rootCmd.AddCommand(pluginRoot) - - pluginListCmd.Flags().StringVarP(&pluginListFormat, "format", "f", "table", "output format [supported values: table, csv, json]") - pluginRoot.AddCommand(pluginListCmd) - pluginRoot.AddCommand(pluginEnableCmd) - pluginRoot.AddCommand(pluginDisableCmd) - - pluginEditCmd.Flags().StringVar(&editConfig, "config", "", "plugin config as JSON") - pluginEditCmd.Flags().StringVar(&editConfigFile, "config-file", "", "read plugin config JSON from a file ('-' for stdin)") - pluginEditCmd.MarkFlagsMutuallyExclusive("config", "config-file") - pluginEditCmd.Flags().StringVar(&editUsers, "users", "", `usernames the plugin may access: comma-separated (alice,bob) or a JSON array (["alice","bob"])`) - pluginEditCmd.Flags().BoolVar(&editAllUsers, "all-users", false, "grant the plugin access to all users") - pluginEditCmd.MarkFlagsMutuallyExclusive("users", "all-users") - pluginEditCmd.Flags().StringVar(&editLibraries, "libraries", "", `library IDs the plugin may access: comma-separated (1,2) or a JSON array ([1,2])`) - pluginEditCmd.Flags().BoolVar(&editAllLibs, "all-libraries", false, "grant the plugin access to all libraries") - pluginEditCmd.MarkFlagsMutuallyExclusive("libraries", "all-libraries") - pluginEditCmd.Flags().BoolVar(&editWriteAccess, "write-access", false, "allow the plugin write access to libraries") - pluginEditCmd.Flags().BoolVar(&editNoWrite, "no-write-access", false, "deny the plugin write access to libraries") - pluginEditCmd.MarkFlagsMutuallyExclusive("write-access", "no-write-access") - pluginRoot.AddCommand(pluginEditCmd) - - pluginInfoCmd.Flags().StringVarP(&pluginInfoFormat, "format", "f", "text", "output format [supported values: text, json]") - pluginRoot.AddCommand(pluginInfoCmd) - pluginRoot.AddCommand(pluginValidateCmd) - pluginRoot.AddCommand(pluginRescanCmd) -} - -var ( - pluginRoot = &cobra.Command{ - Use: "plugin", - Short: "Manage and inspect plugins", - Long: "List, inspect, enable, disable, configure, rescan, and validate plugins", - } - - pluginListCmd = &cobra.Command{ - Use: "list", - Short: "List installed plugins", - Run: func(cmd *cobra.Command, args []string) { - runPluginList(cmd.Context()) - }, - } - - pluginEnableCmd = &cobra.Command{ - Use: "enable ", - Short: "Enable a plugin", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - requirePluginsEnabled(cmd.Context()) - _, ctx := getAdminContext(cmd.Context()) - mgr := GetPluginManager(ctx) - if err := enablePlugin(ctx, mgr, args[0]); err != nil { - log.Fatal(ctx, "Failed to enable plugin", "id", args[0], err) - } - }, - } - - pluginDisableCmd = &cobra.Command{ - Use: "disable ", - Short: "Disable a plugin", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - requirePluginsEnabled(cmd.Context()) - _, ctx := getAdminContext(cmd.Context()) - mgr := GetPluginManager(ctx) - if err := disablePlugin(ctx, mgr, args[0]); err != nil { - log.Fatal(ctx, "Failed to disable plugin", "id", args[0], err) - } - }, - } -) - -var ( - pluginInfoCmd = &cobra.Command{ - Use: "info ", - Short: "Show details for an installed plugin or a .ndp package", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - runPluginInfo(cmd.Context(), args[0]) - }, - } - - pluginValidateCmd = &cobra.Command{ - Use: "validate ", - Short: "Validate an installed plugin or a .ndp package manifest", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - runPluginValidate(cmd.Context(), args[0]) - }, - } -) - -// isPackagePath checks only the extension (not existence) so a mistyped path -// still routes to ReadManifest, which reports a precise "no such file" error. -func isPackagePath(arg string) bool { - return strings.HasSuffix(arg, plugins.PackageExtension) -} - -func formatPluginInfo(p *model.Plugin, format string) (string, error) { - switch format { - case "json": - b, err := json.MarshalIndent(p, "", " ") - if err != nil { - return "", err - } - return string(b), nil - case "text": - default: - return "", fmt.Errorf("invalid output format %q (supported: text, json)", format) - } - name, version := manifestSummary(*p) - var sb strings.Builder - fmt.Fprintf(&sb, "ID: %s\n", p.ID) - fmt.Fprintf(&sb, "Name: %s\n", name) - fmt.Fprintf(&sb, "Version: %s\n", version) - fmt.Fprintf(&sb, "Enabled: %t\n", p.Enabled) - fmt.Fprintf(&sb, "Path: %s\n", p.Path) - fmt.Fprintf(&sb, "SHA256: %s\n", p.SHA256) - fmt.Fprintf(&sb, "All users: %t\n", p.AllUsers) - fmt.Fprintf(&sb, "All libs: %t\n", p.AllLibraries) - fmt.Fprintf(&sb, "Write access: %t\n", p.AllowWriteAccess) - if p.Users != "" { - fmt.Fprintf(&sb, "Users: %s\n", p.Users) - } - if p.Libraries != "" { - fmt.Fprintf(&sb, "Libraries: %s\n", p.Libraries) - } - if m, err := plugins.ParseManifest([]byte(p.Manifest)); err == nil { - if perms := m.Permissions.DeclaredNames(); len(perms) > 0 { - fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", ")) - } - } - if !p.CreatedAt.IsZero() { - fmt.Fprintf(&sb, "Created: %s\n", p.CreatedAt.Format(time.RFC3339)) - } - if !p.UpdatedAt.IsZero() { - fmt.Fprintf(&sb, "Updated: %s\n", p.UpdatedAt.Format(time.RFC3339)) - } - if p.Config != "" { - fmt.Fprintf(&sb, "Config: %s\n", p.Config) - } - if p.LastError != "" { - fmt.Fprintf(&sb, "Last error: %s\n", p.LastError) - } - return sb.String(), nil -} - -func formatManifestInfo(m *plugins.Manifest, sha256, format string) (string, error) { - switch format { - case "json": - b, err := json.MarshalIndent(struct { - *plugins.Manifest - SHA256 string `json:"sha256"` - }{m, sha256}, "", " ") - if err != nil { - return "", err - } - return string(b), nil - case "text": - default: - return "", fmt.Errorf("invalid output format %q (supported: text, json)", format) - } - var sb strings.Builder - fmt.Fprintf(&sb, "Name: %s\n", m.Name) - fmt.Fprintf(&sb, "Version: %s\n", m.Version) - fmt.Fprintf(&sb, "Author: %s\n", m.Author) - if m.Description != nil { - fmt.Fprintf(&sb, "Description: %s\n", *m.Description) - } - if m.Website != nil { - fmt.Fprintf(&sb, "Website: %s\n", *m.Website) - } - if perms := m.Permissions.DeclaredNames(); len(perms) > 0 { - fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", ")) - } - fmt.Fprintf(&sb, "SHA256: %s\n", sha256) - return sb.String(), nil -} - -func runPluginInfo(ctx context.Context, arg string) { - if isPackagePath(arg) { - m, err := plugins.ReadManifest(arg) - if err != nil { - log.Fatal(ctx, "Failed to read package", "path", arg, err) - } - sha, err := plugins.ComputeFileSHA256(arg) - if err != nil { - log.Fatal(ctx, "Failed to hash package", "path", arg, err) - } - out, err := formatManifestInfo(m, sha, pluginInfoFormat) - if err != nil { - log.Fatal(ctx, "Failed to format output", err) - } - fmt.Print(out) - return - } - requirePluginsEnabled(ctx) - ds, ctx := getAdminContext(ctx) - p, err := ds.Plugin().Get(ctx, arg) - if err != nil { - log.Fatal(ctx, "Plugin not found", "id", arg, err) - } - out, err := formatPluginInfo(p, pluginInfoFormat) - if err != nil { - log.Fatal(ctx, "Failed to format output", err) - } - fmt.Print(out) -} - -func runPluginValidate(ctx context.Context, arg string) { - if isPackagePath(arg) { - if _, err := plugins.ReadManifest(arg); err != nil { - log.Fatal(ctx, "Validation failed", "path", arg, err) - } - fmt.Printf("%s: OK\n", arg) - return - } - requirePluginsEnabled(ctx) - ds, ctx := getAdminContext(ctx) - p, err := ds.Plugin().Get(ctx, arg) - if err != nil { - log.Fatal(ctx, "Plugin not found", "id", arg, err) - } - if _, err := plugins.ParseManifest([]byte(p.Manifest)); err != nil { - log.Fatal(ctx, "Validation failed", "id", arg, err) - } - if p.Config != "" { - mgr := GetPluginManager(ctx) - if err := mgr.ValidatePluginConfig(ctx, arg, p.Config); err != nil { - log.Fatal(ctx, "Config validation failed", "id", arg, err) - } - } - fmt.Printf("%s: OK\n", arg) -} - -// manifestSummary extracts the display name and version from a stored manifest JSON, falling -// back to the plugin ID when the manifest can't be parsed. -func manifestSummary(p model.Plugin) (name, version string) { - var m struct { - Name string `json:"name"` - Version string `json:"version"` - } - if err := json.Unmarshal([]byte(p.Manifest), &m); err != nil { - return p.ID, "" - } - return m.Name, m.Version -} - -func formatPluginList(list model.Plugins, format string) (string, error) { - switch format { - case "json": - b, err := json.MarshalIndent(list, "", " ") - if err != nil { - return "", err - } - return string(b), nil - case "csv": - var sb strings.Builder - w := csv.NewWriter(&sb) - _ = w.Write([]string{"id", "name", "version", "enabled", "last error"}) - for _, p := range list { - name, version := manifestSummary(p) - _ = w.Write([]string{p.ID, name, version, fmt.Sprintf("%t", p.Enabled), p.LastError}) - } - w.Flush() - return sb.String(), w.Error() - case "table": - var sb strings.Builder - w := newTabWriter(&sb) - fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR") - for _, p := range list { - name, version := manifestSummary(p) - fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%s\n", p.ID, name, version, p.Enabled, p.LastError) - } - w.Flush() - return sb.String(), nil - default: - return "", fmt.Errorf("invalid output format %q (supported: table, csv, json)", format) - } -} - -func runPluginList(ctx context.Context) { - requirePluginsEnabled(ctx) - ds, ctx := getAdminContext(ctx) - list, err := ds.Plugin().GetAll(ctx) - if err != nil { - log.Fatal(ctx, "Failed to list plugins", err) - } - out, err := formatPluginList(list, pluginListFormat) - if err != nil { - log.Fatal(ctx, "Failed to format output", err) - } - fmt.Print(out) -} - -// requirePluginsEnabled gates DB/manager-backed commands; off-disk .ndp -// inspection deliberately skips this so it works without a configured server. -func requirePluginsEnabled(ctx context.Context) { - if !conf.Server.Plugins.Enabled { - log.Fatal(ctx, "Plugin system is disabled (set Plugins.Enabled to use this command)") - } -} - -func enablePlugin(ctx context.Context, mgr pluginManager, id string) error { - return mgr.EnablePlugin(ctx, id) -} - -func disablePlugin(ctx context.Context, mgr pluginManager, id string) error { - return mgr.DisablePlugin(ctx, id) -} - -type pluginEditOptions struct { - config *string // nil = leave unchanged - users *string - allUsers *bool - libraries *string - allLibraries *bool - writeAccess *bool -} - -var pluginEditCmd = &cobra.Command{ - Use: "edit ", - Short: "Update a plugin's config and/or permissions", - Args: cobra.ExactArgs(1), - Run: func(cmd *cobra.Command, args []string) { - requirePluginsEnabled(cmd.Context()) - ds, ctx := getAdminContext(cmd.Context()) - cur, err := ds.Plugin().Get(ctx, args[0]) - if err != nil { - log.Fatal(ctx, "Plugin not found", "id", args[0], err) - } - mgr := GetPluginManager(ctx) - opts := buildEditOptionsFromFlags(ctx, cmd) - if err := applyPluginEdit(ctx, mgr, cur, opts); err != nil { - log.Fatal(ctx, "Failed to edit plugin", "id", args[0], err) - } - }, -} - -func buildEditOptionsFromFlags(ctx context.Context, cmd *cobra.Command) pluginEditOptions { - var opts pluginEditOptions - switch { - case cmd.Flags().Changed("config"): - c := editConfig - opts.config = &c - case cmd.Flags().Changed("config-file"): - c := readConfigFile(ctx, editConfigFile) - opts.config = &c - } - if cmd.Flags().Changed("users") { - u := editUsers - opts.users = &u - } - if cmd.Flags().Changed("all-users") { - v := editAllUsers - opts.allUsers = &v - } - if cmd.Flags().Changed("libraries") { - l := editLibraries - opts.libraries = &l - } - if cmd.Flags().Changed("all-libraries") { - v := editAllLibs - opts.allLibraries = &v - } - if cmd.Flags().Changed("write-access") || cmd.Flags().Changed("no-write-access") { - // write-access is part of the library-permission group, so it is updated - // alongside the (preserved) library list rather than on its own. - wa := editWriteAccess && !editNoWrite - opts.writeAccess = &wa - } - return opts -} - -func readConfigFile(ctx context.Context, path string) string { - var data []byte - var err error - if path == "-" { - data, err = io.ReadAll(os.Stdin) - } else { - data, err = os.ReadFile(path) - } - if err != nil { - log.Fatal(ctx, "Failed to read config file", "path", path, err) - } - return string(data) -} - -// applyPluginEdit applies the requested changes on top of the plugin's current -// state. Like the native API, it reads the existing users/libraries before -// updating so that flipping one flag (e.g. --write-access) does not wipe -// unspecified fields, and rejects non-JSON users/libraries values. -func applyPluginEdit(ctx context.Context, mgr pluginManager, cur *model.Plugin, opts pluginEditOptions) error { - if opts.config == nil && opts.users == nil && opts.allUsers == nil && - opts.libraries == nil && opts.allLibraries == nil && opts.writeAccess == nil { - return fmt.Errorf("nothing to update: provide at least one of --config/--users/--libraries/--write-access") - } - id := cur.ID - if opts.config != nil { - if err := mgr.ValidatePluginConfig(ctx, id, *opts.config); err != nil { - return fmt.Errorf("invalid config: %w", err) - } - if err := mgr.UpdatePluginConfig(ctx, id, *opts.config); err != nil { - return err - } - } - if opts.users != nil || opts.allUsers != nil { - users, allUsers := cur.Users, cur.AllUsers - if opts.users != nil { - parsed, err := usersToJSON(*opts.users) - if err != nil { - return err - } - users = parsed - allUsers = false // an explicit list means "restrict to these users" - } - if opts.allUsers != nil { - allUsers = *opts.allUsers - } - if err := mgr.UpdatePluginUsers(ctx, id, users, allUsers); err != nil { - return err - } - } - if opts.libraries != nil || opts.allLibraries != nil || opts.writeAccess != nil { - libs, allLibs, writeAccess := cur.Libraries, cur.AllLibraries, cur.AllowWriteAccess - if opts.libraries != nil { - parsed, err := librariesToJSON(*opts.libraries) - if err != nil { - return err - } - libs = parsed - allLibs = false // an explicit list means "restrict to these libraries" - } - if opts.allLibraries != nil { - allLibs = *opts.allLibraries - } - if opts.writeAccess != nil { - writeAccess = *opts.writeAccess - } - if err := mgr.UpdatePluginLibraries(ctx, id, libs, allLibs, writeAccess); err != nil { - return err - } - } - return nil -} - -// usersToJSON accepts either a JSON array (starts with '[') or a comma-separated -// list and returns the JSON-array form the manager stores. -func usersToJSON(value string) (string, error) { - if strings.TrimSpace(value) == "" { - return "[]", nil - } - if strings.HasPrefix(strings.TrimSpace(value), "[") { - if !json.Valid([]byte(value)) { - return "", fmt.Errorf("invalid JSON in --users") - } - return value, nil - } - var names []string - for _, u := range strings.Split(value, ",") { - if u = strings.TrimSpace(u); u != "" { - names = append(names, u) - } - } - b, _ := json.Marshal(names) - return string(b), nil -} - -// librariesToJSON accepts either a JSON array (starts with '[') or a -// comma-separated list of integer IDs and returns the JSON-array form stored. -func librariesToJSON(value string) (string, error) { - if strings.TrimSpace(value) == "" { - return "[]", nil - } - if strings.HasPrefix(strings.TrimSpace(value), "[") { - if !json.Valid([]byte(value)) { - return "", fmt.Errorf("invalid JSON in --libraries") - } - return value, nil - } - ids := []int{} - for _, l := range strings.Split(value, ",") { - if l = strings.TrimSpace(l); l != "" { - id, err := strconv.Atoi(l) - if err != nil { - return "", fmt.Errorf("invalid library ID %q: must be an integer", l) - } - ids = append(ids, id) - } - } - b, _ := json.Marshal(ids) - return string(b), nil -} - -var pluginRescanCmd = &cobra.Command{ - Use: "rescan", - Short: "Re-discover plugins in the plugins folder", - Run: func(cmd *cobra.Command, args []string) { - requirePluginsEnabled(cmd.Context()) - _, ctx := getAdminContext(cmd.Context()) - mgr := GetPluginManager(ctx) - if err := rescanPlugins(ctx, mgr); err != nil { - log.Fatal(ctx, "Failed to rescan plugins", err) - } - }, -} - -func rescanPlugins(ctx context.Context, mgr pluginManager) error { - return mgr.RescanPlugins(ctx) -} diff --git a/cmd/plugin_test.go b/cmd/plugin_test.go deleted file mode 100644 index b6233057f..000000000 --- a/cmd/plugin_test.go +++ /dev/null @@ -1,360 +0,0 @@ -package cmd - -import ( - "context" - "encoding/json" - "errors" - "strings" - "time" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/plugins" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var samplePlugins = model.Plugins{ - {ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true}, - {ID: "beta", Manifest: `{"name":"Beta","version":"2.1.0","author":"me"}`, Enabled: false, LastError: "boom"}, -} - -var _ = Describe("plugin command format flags", func() { - // Regression: list and info must not share a format variable. Binding the - // same var on both commands makes the last init() registration clobber the - // other's default, breaking `plugin list` with no -f flag. - It("defaults `list -f` to table", func() { - Expect(pluginListCmd.Flags().Lookup("format").DefValue).To(Equal("table")) - }) - - It("defaults `info -f` to text", func() { - Expect(pluginInfoCmd.Flags().Lookup("format").DefValue).To(Equal("text")) - }) -}) - -var _ = Describe("formatPluginList", func() { - It("renders csv with a header and one row per plugin", func() { - out, err := formatPluginList(samplePlugins, "csv") - Expect(err).ToNot(HaveOccurred()) - lines := strings.Split(strings.TrimSpace(out), "\n") - Expect(lines).To(HaveLen(3)) // header + 2 rows - Expect(lines[0]).To(ContainSubstring("id")) - Expect(out).To(ContainSubstring("alpha")) - Expect(out).To(ContainSubstring("beta")) - }) - - It("renders valid json", func() { - out, err := formatPluginList(samplePlugins, "json") - Expect(err).ToNot(HaveOccurred()) - var got []map[string]any - Expect(json.Unmarshal([]byte(out), &got)).To(Succeed()) - Expect(got).To(HaveLen(2)) - }) - - It("renders a human table by default", func() { - out, err := formatPluginList(samplePlugins, "table") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("Alpha")) - Expect(out).To(ContainSubstring("1.0.0")) - }) - - It("errors on an unknown format", func() { - _, err := formatPluginList(samplePlugins, "yaml") - Expect(err).To(HaveOccurred()) - }) -}) - -var _ = Describe("enable/disable plugin", func() { - It("calls EnablePlugin on the manager", func() { - mgr := &tests.MockPluginManager{} - err := enablePlugin(context.Background(), mgr, "alpha") - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.EnablePluginCalls).To(Equal([]string{"alpha"})) - }) - - It("calls DisablePlugin on the manager", func() { - mgr := &tests.MockPluginManager{} - err := disablePlugin(context.Background(), mgr, "beta") - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.DisablePluginCalls).To(Equal([]string{"beta"})) - }) -}) - -var _ = Describe("applyPluginEdit", func() { - var cur *model.Plugin - BeforeEach(func() { - cur = &model.Plugin{ID: "alpha", Users: `["bob"]`, Libraries: `[1,2]`, AllowWriteAccess: true} - }) - - It("validates then updates config when config is provided", func() { - mgr := &tests.MockPluginManager{} - cfg := `{"key":"val"}` - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1)) - Expect(mgr.ValidatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg)) - Expect(mgr.UpdatePluginConfigCalls).To(HaveLen(1)) - Expect(mgr.UpdatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg)) - }) - - It("updates users with allUsers flag", func() { - mgr := &tests.MockPluginManager{} - all := true - err := applyPluginEdit(context.Background(), mgr, cur, - pluginEditOptions{allUsers: &all}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginUsersCalls).To(HaveLen(1)) - Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeTrue()) - }) - - It("updates libraries with allLibraries and write access", func() { - mgr := &tests.MockPluginManager{} - all := true - wr := true - err := applyPluginEdit(context.Background(), mgr, cur, - pluginEditOptions{allLibraries: &all, writeAccess: &wr}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1)) - Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeTrue()) - Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeTrue()) - }) - - It("preserves existing fields when only the write-access flag changes", func() { - mgr := &tests.MockPluginManager{} - no := false - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{writeAccess: &no}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1)) - Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) // not wiped - Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeFalse()) - }) - - It("preserves existing users when only the all-users flag changes", func() { - mgr := &tests.MockPluginManager{} - all := true - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{allUsers: &all}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["bob"]`)) // not wiped - }) - - It("parses a comma-separated users value into a JSON array", func() { - mgr := &tests.MockPluginManager{} - users := "alice, bob" - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`)) - }) - - It("passes a JSON-array users value through unchanged", func() { - mgr := &tests.MockPluginManager{} - users := `["alice","bob"]` - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`)) - }) - - It("rejects a malformed JSON-array users value", func() { - mgr := &tests.MockPluginManager{} - users := `["alice"` - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) - Expect(err).To(HaveOccurred()) - Expect(mgr.UpdatePluginUsersCalls).To(BeEmpty()) - }) - - It("parses a comma-separated libraries value into a JSON array of ints", func() { - mgr := &tests.MockPluginManager{} - libs := "1, 2" - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) - }) - - It("rejects a non-integer library ID", func() { - mgr := &tests.MockPluginManager{} - libs := "1,abc" - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs}) - Expect(err).To(HaveOccurred()) - Expect(mgr.UpdatePluginLibrariesCalls).To(BeEmpty()) - }) - - It("clears allUsers when an explicit users list is set", func() { - mgr := &tests.MockPluginManager{} - cur.AllUsers = true - users := "alice" - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice"]`)) - Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeFalse()) - }) - - It("clears allLibraries when an explicit libraries list is set", func() { - mgr := &tests.MockPluginManager{} - cur.AllLibraries = true - libs := "1" - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs}) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1]`)) - Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeFalse()) - }) - - It("does nothing and errors when no fields are set", func() { - mgr := &tests.MockPluginManager{} - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{}) - Expect(err).To(HaveOccurred()) - }) - - It("aborts the config update when validation fails", func() { - mgr := &tests.MockPluginManager{ValidateError: errors.New("bad config")} - cfg := `{"key":"val"}` - err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg}) - Expect(err).To(HaveOccurred()) - Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1)) - Expect(mgr.UpdatePluginConfigCalls).To(BeEmpty()) - }) -}) - -var _ = Describe("isPackagePath", func() { - It("is true for any .ndp path", func() { - Expect(isPackagePath("/some/dir/x.ndp")).To(BeTrue()) - }) - It("is true for a non-existent .ndp path (so ReadManifest reports the error)", func() { - Expect(isPackagePath("/nope/x.ndp")).To(BeTrue()) - }) - It("is false for a bare plugin id", func() { - Expect(isPackagePath("my-plugin")).To(BeFalse()) - }) -}) - -var _ = Describe("formatPluginInfo", func() { - It("renders installed plugin details as text", func() { - p := &model.Plugin{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true} - out, err := formatPluginInfo(p, "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("alpha")) - Expect(out).To(ContainSubstring("Alpha")) - }) - It("renders json", func() { - p := &model.Plugin{ID: "alpha", Manifest: `{}`} - out, err := formatPluginInfo(p, "json") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("alpha")) - }) -}) - -var _ = Describe("formatManifestInfo", func() { - It("renders text with name, version, author", func() { - m := &plugins.Manifest{Name: "My Plugin", Version: "2.0.0", Author: "me"} - out, err := formatManifestInfo(m, "abc123", "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("My Plugin")) - Expect(out).To(ContainSubstring("2.0.0")) - Expect(out).To(ContainSubstring("me")) - }) - - It("omits Description and Website when nil", func() { - m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"} - out, err := formatManifestInfo(m, "abc123", "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).ToNot(ContainSubstring("Description:")) - Expect(out).ToNot(ContainSubstring("Website:")) - }) - - It("includes Description and Website when set", func() { - desc := "a cool plugin" - site := "https://example.com" - m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a", Description: &desc, Website: &site} - out, err := formatManifestInfo(m, "abc123", "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("a cool plugin")) - Expect(out).To(ContainSubstring("https://example.com")) - }) - - It("renders valid json", func() { - m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"} - out, err := formatManifestInfo(m, "abc123", "json") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("\"name\"")) - }) -}) - -var _ = Describe("formatPluginInfo enriched text", func() { - var fixedTime time.Time - - BeforeEach(func() { - fixedTime = time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) - }) - - It("includes Users, Libraries, Permissions, Created, Updated in text output", func() { - p := &model.Plugin{ - ID: "myplugin", - Manifest: `{"name":"My Plugin","version":"1.0.0","author":"me","permissions":{"users":{},"subsonicapi":{}}}`, - Enabled: true, - Users: "alice,bob", - Libraries: "1,2", - CreatedAt: fixedTime, - UpdatedAt: fixedTime.Add(time.Hour), - } - out, err := formatPluginInfo(p, "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("alice,bob")) - Expect(out).To(ContainSubstring("1,2")) - Expect(out).To(ContainSubstring("subsonicapi")) - Expect(out).To(ContainSubstring("users")) - Expect(out).To(ContainSubstring("2025-01-15T12:00:00Z")) - }) - - It("omits Users and Libraries lines when empty", func() { - p := &model.Plugin{ - ID: "myplugin", - Manifest: `{"name":"X","version":"1.0.0","author":"me"}`, - CreatedAt: fixedTime, - UpdatedAt: fixedTime, - } - out, err := formatPluginInfo(p, "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).ToNot(ContainSubstring("Users:")) - Expect(out).ToNot(ContainSubstring("Libraries:")) - }) - - It("does not alter json output", func() { - p := &model.Plugin{ID: "x", Manifest: `{}`, Users: "alice"} - out, err := formatPluginInfo(p, "json") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring(`"x"`)) - }) -}) - -var _ = Describe("formatManifestInfo enriched text", func() { - It("includes Permissions when declared", func() { - p := &plugins.Permissions{Http: &plugins.HTTPPermission{}} - m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p} - out, err := formatManifestInfo(m, "abc123", "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring("http")) - Expect(out).To(ContainSubstring("Permissions:")) - }) - - It("omits Permissions line when no permissions declared", func() { - m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a"} - out, err := formatManifestInfo(m, "abc123", "text") - Expect(err).ToNot(HaveOccurred()) - Expect(out).ToNot(ContainSubstring("Permissions:")) - }) - - It("does not alter json output", func() { - p := &plugins.Permissions{Http: &plugins.HTTPPermission{}} - m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p} - out, err := formatManifestInfo(m, "abc123", "json") - Expect(err).ToNot(HaveOccurred()) - Expect(out).To(ContainSubstring(`"name"`)) - }) -}) - -var _ = Describe("rescanPlugins", func() { - It("calls RescanPlugins on the manager", func() { - mgr := &tests.MockPluginManager{} - err := rescanPlugins(context.Background(), mgr) - Expect(err).ToNot(HaveOccurred()) - Expect(mgr.RescanPluginsCalls).To(Equal(1)) - }) -}) diff --git a/cmd/root.go b/cmd/root.go index e39c55365..415cf12f3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,35 +2,30 @@ package cmd import ( "context" - "net/http" + "errors" + "fmt" "os" - "os/signal" "strings" - "syscall" "time" "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/scheduler" "github.com/navidrome/navidrome/server/backgrounds" - "github.com/spf13/cobra" - "github.com/spf13/viper" + "github.com/prometheus/client_golang/prometheus/promhttp" "golang.org/x/sync/errgroup" - // Import adapters to register them - _ "github.com/navidrome/navidrome/adapters/deezer" - _ "github.com/navidrome/navidrome/adapters/gotaglib" - _ "github.com/navidrome/navidrome/adapters/lastfm" - _ "github.com/navidrome/navidrome/adapters/listenbrainz" + "github.com/spf13/cobra" + "github.com/spf13/viper" ) +var interrupted = errors.New("service was interrupted") + var ( cfgFile string noBanner bool @@ -44,25 +39,17 @@ Complete documentation is available at https://www.navidrome.org/docs`, preRun() }, Run: func(cmd *cobra.Command, args []string) { - if err := runNavidrome(cmd.Context()); err != nil { - log.Fatal("Fatal error in Navidrome. Aborting", err) - } - }, - PostRun: func(cmd *cobra.Command, args []string) { - postRun() + runNavidrome() }, Version: consts.Version, } ) -// Execute runs the root cobra command, which will start the Navidrome server by calling the runNavidrome function. func Execute() { - ctx, cancel := mainContext(context.Background()) - defer cancel() - rootCmd.SetVersionTemplate(`{{println .Version}}`) - if err := rootCmd.ExecuteContext(ctx); err != nil { - log.Fatal(err) + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) } } @@ -70,63 +57,34 @@ func preRun() { if !noBanner { println(resources.Banner()) } - conf.Load(noBanner) + conf.Load() } -func postRun() { - log.Info("Navidrome stopped, bye.") -} +func runNavidrome() { + db.Init() + defer func() { + if err := db.Close(); err != nil { + log.Error("Error closing DB", err) + } + log.Info("Navidrome stopped, bye.") + }() -// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks. -// If any of the services returns an error, it stops the others and returns that error, so the caller can -// exit with a non-zero code. If the context is cancelled (a signal or a service stop), it returns nil. -func runNavidrome(parentCtx context.Context) error { - defer db.Init(parentCtx)() - - g, ctx := errgroup.WithContext(parentCtx) + g, ctx := errgroup.WithContext(context.Background()) g.Go(startServer(ctx)) - g.Go(startSignaller(ctx)) + g.Go(startSignaler(ctx)) g.Go(startScheduler(ctx)) - g.Go(startPlaybackServer(ctx)) - g.Go(startJellyfinDiscovery(ctx)) - g.Go(schedulePeriodicBackup(ctx)) - g.Go(startInsightsCollector(ctx)) - g.Go(scheduleDBAnalyzer(ctx)) - g.Go(startPluginManager(ctx)) - artworkWorker := CreateArtworkWorker() - g.Go(startArtworkWorker(ctx, artworkWorker)) - g.Go(scheduleArtworkHousekeeping(ctx, artworkWorker)) - g.Go(runInitialScan(ctx)) - if conf.Server.Scanner.Enabled { - g.Go(startScanWatcher(ctx)) - g.Go(schedulePeriodicScan(ctx)) - } else { - log.Warn(ctx, "Automatic Scanning is DISABLED") - } + g.Go(schedulePeriodicScan(ctx)) - // Errors caused by a normal shutdown are not failures - if err := g.Wait(); err != nil && parentCtx.Err() == nil { - return err + if err := g.Wait(); err != nil && !errors.Is(err, interrupted) { + log.Error("Fatal error in Navidrome. Aborting", err) } - return nil } -// mainContext returns a context that is cancelled when the process receives a signal to exit. -func mainContext(ctx context.Context) (context.Context, context.CancelFunc) { - return signal.NotifyContext(ctx, - os.Interrupt, - syscall.SIGHUP, - syscall.SIGTERM, - syscall.SIGABRT, - ) -} - -// startServer starts the Navidrome web server, adding all the necessary routers. func startServer(ctx context.Context) func() error { return func() error { - a := CreateServer() - a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter(ctx)) - a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter(ctx)) + a := CreateServer(conf.Server.MusicFolder) + a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter()) + a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter()) a.MountRouter("Public Endpoints", consts.URLPathPublic, CreatePublicRouter()) if conf.Server.LastFM.Enabled { a.MountRouter("LastFM Auth", consts.URLPathNativeAPI+"/lastfm", CreateLastFMRouter()) @@ -134,324 +92,65 @@ func startServer(ctx context.Context) func() error { if conf.Server.ListenBrainz.Enabled { a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter()) } - if conf.Server.Jellyfin.Enabled { - a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx)) - } - if conf.Server.DevAPIv1 { - a.MountRouter("API v1", consts.URLPathAPIv1, CreateAPIv1Router(ctx)) - } if conf.Server.Prometheus.Enabled { - p := CreatePrometheus() - // blocking call because takes <100ms but useful if fails - p.WriteInitialMetrics(ctx) - a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler()) + // blocking call because takes <1ms but useful if fails + core.WriteInitialMetrics() + a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, promhttp.Handler()) } if conf.Server.DevEnableProfiler { - a.MountRouter("Profiling", "/debug", profilerHandler()) + a.MountRouter("Profiling", "/debug", middleware.Profiler()) } if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { - a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler()) + a.MountRouter("Background images", consts.DefaultUILoginBackgroundURL, backgrounds.NewHandler()) } + a.MountRouter("New Native API", consts.URLPathAPI, CreateNewNativeAPIRouter()) return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey) } } -// profilerHandler returns the pprof handler. net/http/pprof resolves the profile -// name from the raw request path, so the BasePath has to come off first. -func profilerHandler() http.Handler { - // A trailing or root slash would make StripPrefix drop the leading slash chi needs. - basePath := strings.TrimRight(conf.Server.BasePath, "/") - return http.StripPrefix(basePath, middleware.Profiler()) -} - -// schedulePeriodicScan schedules a periodic scan of the music library, if configured. func schedulePeriodicScan(ctx context.Context) func() error { return func() error { - schedule := conf.Server.Scanner.Schedule + schedule := conf.Server.ScanSchedule if schedule == "" { - log.Info(ctx, "Periodic scan is DISABLED") + log.Warn("Periodic scan is DISABLED") return nil } - s := CreateScanner(ctx) + scanner := GetScanner() schedulerInstance := scheduler.GetInstance() log.Info("Scheduling periodic scan", "schedule", schedule) - _, err := schedulerInstance.Add(schedule, func() { - _, err := s.ScanAll(ctx, false) - if err != nil { - log.Error(ctx, "Error executing periodic scan", err) - } + err := schedulerInstance.Add(schedule, func() { + _ = scanner.RescanAll(ctx, false) }) if err != nil { - log.Error(ctx, "Error scheduling periodic scan", err) + log.Error("Error scheduling periodic scan", err) } - return nil - } -} -// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from -// the one used by their last finished scan -func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) { - libs, err := ds.Library().GetAll(ctx) - if err != nil { - return nil, err - } - var names []string - for _, lib := range libs { - if lib.PIDChanged() { - names = append(names, lib.Name) - } - } - return names, nil -} - -// runInitialScan runs an initial scan of the music library if needed. -func runInitialScan(ctx context.Context) func() error { - return func() error { - ds := CreateDataStore() - fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0") - if err != nil { - return err - } - inProgress, err := ds.Library().ScanInProgress(ctx) - if err != nil { - return err - } - pidChangedLibs, err := librariesWithChangedPID(ctx, ds) - if err != nil { - return err - } - scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup - scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0 time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan - if scanNeeded { - s := CreateScanner(ctx) - switch { - case fullScanRequired == "1": - log.Warn(ctx, "Full scan required after migration") - _ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey) - case len(pidChangedLibs) > 0: - // Includes never-scanned libraries. The scanner rescans in full only the ones that need it - log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs) - case inProgress: - log.Warn(ctx, "Resuming interrupted scan") - default: - log.Info("Executing initial scan") - } - - _, err = s.ScanAll(ctx, fullScanRequired == "1") - if err != nil { - log.Error(ctx, "Scan failed", err) - } else { - log.Info(ctx, "Scan completed") - } - } else { - log.Debug(ctx, "Initial scan not needed") + log.Debug("Executing initial scan") + if err := scanner.RescanAll(ctx, false); err != nil { + log.Error("Error executing initial scan", err) } + log.Debug("Finished initial scan") return nil } } -func startScanWatcher(ctx context.Context) func() error { - return func() error { - if conf.Server.Scanner.WatcherWait == 0 { - log.Debug("Folder watcher is DISABLED") - return nil - } - w := CreateScanWatcher(ctx) - err := w.Run(ctx) - if err != nil { - log.Error("Error starting watcher", err) - } - return nil - } -} - -func schedulePeriodicBackup(ctx context.Context) func() error { - return func() error { - schedule := conf.Server.Backup.Schedule - if schedule == "" { - log.Info(ctx, "Periodic backup is DISABLED") - return nil - } - - schedulerInstance := scheduler.GetInstance() - - log.Info("Scheduling periodic backup", "schedule", schedule) - _, err := schedulerInstance.Add(schedule, func() { - start := time.Now() - path, err := db.Backup(ctx) - elapsed := time.Since(start) - if err != nil { - log.Error(ctx, "Error backing up database", "elapsed", elapsed, err) - return - } - log.Info(ctx, "Backup complete", "elapsed", elapsed, "path", path) - - count, err := db.Prune(ctx) - if err != nil { - log.Error(ctx, "Error pruning database", "error", err) - } else if count > 0 { - log.Info(ctx, "Successfully pruned old files", "count", count) - } else { - log.Info(ctx, "No backups pruned") - } - }) - - return err - } -} - -func scheduleDBAnalyzer(ctx context.Context) func() error { - return func() error { - if !conf.Server.EnableScheduledDBAnalyze { - log.Info(ctx, "Scheduled DB analysis is DISABLED") - return nil - } - log.Info(ctx, "Scheduling DB analysis check", "schedule", consts.DBAnalyzeCheckSchedule) - schedulerInstance := scheduler.GetInstance() - _, err := schedulerInstance.Add(consts.DBAnalyzeCheckSchedule, func() { - release, ok := scanner.LockForMaintenance() - if !ok { - log.Debug(ctx, "Skipping DB analysis check because a scan is in progress") - return - } - defer release() - if _, err := db.OptimizeIfNeeded(ctx); err != nil { - log.Error(ctx, "Error analyzing DB", err) - } - }) - return err - } -} - -// startScheduler starts the Navidrome scheduler, which is used to run periodic tasks. func startScheduler(ctx context.Context) func() error { + log.Info(ctx, "Starting scheduler") + schedulerInstance := scheduler.GetInstance() + return func() error { - log.Info(ctx, "Starting scheduler") - schedulerInstance := scheduler.GetInstance() schedulerInstance.Run(ctx) return nil } } -// startInsightsCollector starts the Navidrome Insight Collector, if configured. -func startInsightsCollector(ctx context.Context) func() error { - return func() error { - if !conf.Server.EnableInsightsCollector { - log.Info(ctx, "Insight Collector is DISABLED") - return nil - } - log.Info(ctx, "Starting Insight Collector") - select { - case <-time.After(conf.Server.DevInsightsInitialDelay): - case <-ctx.Done(): - return nil - } - ic := CreateInsights() - ic.Run(ctx) - return nil - } -} - -// startJellyfinDiscovery never returns an error: a discovery failure must not stop the server. -func startJellyfinDiscovery(ctx context.Context) func() error { - return func() error { - if !conf.Server.Jellyfin.Enabled || !conf.Server.Jellyfin.AutoDiscovery { - log.Debug("Jellyfin auto-discovery is DISABLED") - return nil - } - CreateJellyfinDiscovery().Serve(ctx) - return nil - } -} - -// startPlaybackServer starts the Navidrome playback server, if configured. -// It is responsible for the Jukebox functionality -func startPlaybackServer(ctx context.Context) func() error { - return func() error { - if !conf.Server.Jukebox.Enabled { - log.Debug("Jukebox is DISABLED") - return nil - } - log.Info(ctx, "Starting Jukebox service") - playbackInstance := GetPlaybackServer() - return playbackInstance.Run(ctx) - } -} - -// startArtworkWorker starts the background artwork acquisition worker. It always -// runs; the queue is simply empty until something enqueues work into it. -func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error { - return func() error { - log.Info(ctx, "Starting artwork worker") - // The scanner writes to the DB for its whole run; competing for the write lock makes both fail. - worker.PauseWhile(scanner.IsScanning) - return worker.Run(ctx) - } -} - -// outsideScan runs a DB maintenance job unless a scan is running, and keeps a scan from starting -// until it ends; both write to the DB, and competing for the lock can make either fail. -func outsideScan(ctx context.Context, job string, run func(context.Context) error) { - release, ok := scanner.LockForMaintenance() - if !ok { - log.Debug(ctx, "Skipping "+job+" because a scan is in progress") - return - } - defer release() - if err := run(ctx); err != nil { - log.Error(ctx, "Error running "+job, err) - } -} - -// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and -// reports an artwork config change without acting on it. -func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error { - return func() error { - schedulerInstance := scheduler.GetInstance() - - if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() { - outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll) - }); err != nil { - log.Error(ctx, "Error scheduling artwork missing-state recheck", err) - } - - if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() { - outsideScan(ctx, "artwork prune", worker.RunPrune) - }); err != nil { - log.Error(ctx, "Error scheduling artwork prune", err) - } - - // Also run the missing-row recheck once at startup so a never-scanned entity is picked up - // immediately, not only on the next hourly tick (e.g. after enabling the feature). - outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll) - - if err := worker.ReconcileConfig(ctx); err != nil { - log.Error(ctx, "Error checking the artwork config fingerprint", err) - } - return nil - } -} - -// startPluginManager starts the plugin manager, if configured. -func startPluginManager(ctx context.Context) func() error { - return func() error { - manager := GetPluginManager(ctx) - if !conf.Server.Plugins.Enabled { - log.Debug("Plugin system is DISABLED") - return nil - } - log.Info(ctx, "Starting plugin manager") - return manager.Start(ctx) - } -} - // TODO: Implement some struct tags to map flags to viper func init() { cobra.OnInitialize(func() { - conf.InitConfig(cfgFile, true) + conf.InitConfig(cfgFile) }) rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`) @@ -460,38 +159,32 @@ func init() { rootCmd.PersistentFlags().String("datafolder", viper.GetString("datafolder"), "folder to store application data (DB), needs write access") rootCmd.PersistentFlags().String("cachefolder", viper.GetString("cachefolder"), "folder to store cache data (transcoding, images...), needs write access") rootCmd.PersistentFlags().StringP("loglevel", "l", viper.GetString("loglevel"), "log level, possible values: error, info, debug, trace") - rootCmd.PersistentFlags().String("logfile", viper.GetString("logfile"), "log file path, if not set logs will be printed to stderr") _ = viper.BindPFlag("musicfolder", rootCmd.PersistentFlags().Lookup("musicfolder")) _ = viper.BindPFlag("datafolder", rootCmd.PersistentFlags().Lookup("datafolder")) _ = viper.BindPFlag("cachefolder", rootCmd.PersistentFlags().Lookup("cachefolder")) _ = viper.BindPFlag("loglevel", rootCmd.PersistentFlags().Lookup("loglevel")) - _ = viper.BindPFlag("logfile", rootCmd.PersistentFlags().Lookup("logfile")) rootCmd.Flags().StringP("address", "a", viper.GetString("address"), "IP address to bind to") rootCmd.Flags().IntP("port", "p", viper.GetInt("port"), "HTTP port Navidrome will listen to") rootCmd.Flags().String("baseurl", viper.GetString("baseurl"), "base URL to configure Navidrome behind a proxy (ex: /music or http://my.server.com)") rootCmd.Flags().String("tlscert", viper.GetString("tlscert"), "optional path to a TLS cert file (enables HTTPS listening)") - rootCmd.Flags().String("unixsocketperm", viper.GetString("unixsocketperm"), "optional file permission for the unix socket") rootCmd.Flags().String("tlskey", viper.GetString("tlskey"), "optional path to a TLS key file (enables HTTPS listening)") rootCmd.Flags().Duration("sessiontimeout", viper.GetDuration("sessiontimeout"), "how long Navidrome will wait before closing web ui idle sessions") rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library") rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page") rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI") - rootCmd.Flags().Bool("enabletranscodingcancellation", viper.GetBool("enabletranscodingcancellation"), "enables transcoding context cancellation") rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache") rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache") - rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized") rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`") - rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint") + rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`") rootCmd.Flags().String("prometheus.metricspath", viper.GetString("prometheus.metricspath"), "http endpoint for prometheus metrics") _ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address")) _ = viper.BindPFlag("port", rootCmd.Flags().Lookup("port")) _ = viper.BindPFlag("tlscert", rootCmd.Flags().Lookup("tlscert")) - _ = viper.BindPFlag("unixsocketperm", rootCmd.Flags().Lookup("unixsocketperm")) _ = viper.BindPFlag("tlskey", rootCmd.Flags().Lookup("tlskey")) _ = viper.BindPFlag("baseurl", rootCmd.Flags().Lookup("baseurl")) @@ -503,7 +196,6 @@ func init() { _ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath")) _ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig")) - _ = viper.BindPFlag("enabletranscodingcancellation", rootCmd.Flags().Lookup("enabletranscodingcancellation")) _ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize")) _ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize")) } diff --git a/cmd/root_test.go b/cmd/root_test.go deleted file mode 100644 index 423cd2a8f..000000000 --- a/cmd/root_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package cmd - -import ( - "errors" - "net/http" - "net/http/httptest" - "path" - "runtime/pprof" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = pprof.NewProfile("nd-profiler-test") - -var _ = Describe("profilerHandler", func() { - // Mirrors how server.MountRouter mounts the handler. - mount := func() http.Handler { - router := chi.NewRouter() - router.Mount(path.Join(conf.Server.BasePath, "/debug"), profilerHandler()) - return router - } - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - DescribeTable("serves a named profile", - func(basePath string) { - conf.Server.BasePath = basePath - - w := httptest.NewRecorder() - target := path.Join(basePath, "/debug/pprof/nd-profiler-test") + "?debug=1" - mount().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil)) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(HavePrefix("nd-profiler-test profile: total 0")) - }, - Entry("without a BasePath", ""), - Entry("with a BasePath", "/music"), - Entry("with a root BasePath", "/"), - Entry("with a trailing-slash BasePath", "/music/"), - ) -}) - -var _ = Describe("librariesWithChangedPID", func() { - var ds *tests.MockDataStore - var libs *tests.MockLibraryRepo - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - libs = &tests.MockLibraryRepo{} - ds = &tests.MockDataStore{MockedLibrary: libs} - }) - - It("returns only the libraries whose PID config changed", func() { - pid := model.Library{}.EffectivePID() - libs.SetData(model.Libraries{ - {ID: 1, Name: "Same", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, - {ID: 2, Name: "Changed", PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, - {ID: 3, Name: "Never scanned"}, - }) - Expect(librariesWithChangedPID(GinkgoT().Context(), ds)).To(ConsistOf("Changed", "Never scanned")) - }) - - It("returns the error from the repository", func() { - libs.Err = errors.New("db down") - _, err := librariesWithChangedPID(GinkgoT().Context(), ds) - Expect(err).To(MatchError("db down")) - }) -}) diff --git a/cmd/scan.go b/cmd/scan.go index 08eccbf2a..7a577e152 100644 --- a/cmd/scan.go +++ b/cmd/scan.go @@ -1,37 +1,16 @@ package cmd import ( - "bufio" "context" - "encoding/gob" - "errors" - "fmt" - "os" - "strings" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/scanner" - "github.com/navidrome/navidrome/utils/pl" "github.com/spf13/cobra" ) -var ( - fullScan bool - subprocess bool - targets []string - targetFile string -) +var fullRescan bool func init() { - scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps") - scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)") - scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")") - scanCmd.Flags().StringVar(&targetFile, "target-file", "", "path to file containing targets (one libraryID:folderPath per line)") + scanCmd.Flags().BoolVarP(&fullRescan, "full", "f", false, "check all subfolders, ignoring timestamps") rootCmd.AddCommand(scanCmd) } @@ -40,129 +19,16 @@ var scanCmd = &cobra.Command{ Short: "Scan music folder", Long: "Scan music folder for updates", Run: func(cmd *cobra.Command, args []string) { - runScanner(cmd.Context()) + runScanner() }, } -func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) (bool, error) { - var changesDetected bool - var scanErrors []error - for status := range pl.ReadOrDone(ctx, progress) { - if status.Warning != "" { - log.Warn(ctx, "Scan warning", "error", status.Warning) - } - if status.Error != "" { - log.Error(ctx, "Scan error", "error", status.Error) - scanErrors = append(scanErrors, errors.New(status.Error)) - } - if status.ChangesDetected { - changesDetected = true - } - } - - if fullScan { +func runScanner() { + scanner := GetScanner() + _ = scanner.RescanAll(context.Background(), fullRescan) + if fullRescan { log.Info("Finished full rescan") } else { log.Info("Finished rescan") } - return changesDetected, errors.Join(scanErrors...) -} - -func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) { - encoder := gob.NewEncoder(os.Stdout) - for status := range pl.ReadOrDone(ctx, progress) { - err := encoder.Encode(status) - if err != nil { - log.Error(ctx, "Failed to encode status", err) - } - } -} - -func runScanner(ctx context.Context) { - sqlDB := db.Db() - defer db.Db().Close() - ds := persistence.New(sqlDB) - pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - // Parse targets from command line or file - var scanTargets []model.ScanTarget - var err error - - if targetFile != "" { - scanTargets, err = readTargetsFromFile(targetFile) - if err != nil { - log.Fatal(ctx, "Failed to read targets from file", err) - } - log.Info(ctx, "Scanning specific folders from file", "numTargets", len(scanTargets)) - } else if len(targets) > 0 { - scanTargets, err = model.ParseTargets(targets) - if err != nil { - log.Fatal(ctx, "Failed to parse targets", err) - } - log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets)) - } - - effectiveFullScan := fullScan - if !subprocess { - effectiveFullScan = scanner.EffectiveFullScan(ctx, ds, fullScan, scanTargets) - if effectiveFullScan { - if err := db.MarkOptimizePending(ctx); err != nil { - log.Error(ctx, "Error marking DB analysis pending", err) - } - } - } - - progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets) - if err != nil { - log.Fatal(ctx, "Failed to scan", err) - } - - // Wait for the scanner to finish - if subprocess { - trackScanAsSubprocess(ctx, progress) - } else { - changesDetected, scanErr := trackScanInteractively(ctx, progress) - runPostScanAnalysis(ctx, changesDetected, effectiveFullScan, scanErr) - } -} - -func runPostScanAnalysis(ctx context.Context, changesDetected, effectiveFullScan bool, scanErr error) { - if changesDetected { - if err := db.MarkOptimizePending(ctx); err != nil { - log.Error(ctx, "Error marking DB analysis pending", err) - } - } - if effectiveFullScan && scanErr == nil { - if err := db.Optimize(ctx); err != nil { - log.Error(ctx, "Error analyzing DB", err) - } - } -} - -// readTargetsFromFile reads scan targets from a file, one per line. -// Each line should be in the format "libraryID:folderPath". -// Empty lines and lines starting with # are ignored. -func readTargetsFromFile(filePath string) ([]model.ScanTarget, error) { - file, err := os.Open(filePath) - if err != nil { - return nil, fmt.Errorf("failed to open target file: %w", err) - } - defer file.Close() - - var targetStrings []string - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - // Skip empty lines and comments - if line == "" { - continue - } - targetStrings = append(targetStrings, line) - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("failed to read target file: %w", err) - } - - return model.ParseTargets(targetStrings) } diff --git a/cmd/scan_test.go b/cmd/scan_test.go deleted file mode 100644 index 309d09f98..000000000 --- a/cmd/scan_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package cmd - -import ( - "context" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/scanner" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("trackScanInteractively", func() { - It("reports changes and scan errors", func() { - progress := make(chan *scanner.ProgressInfo, 2) - progress <- &scanner.ProgressInfo{ChangesDetected: true} - progress <- &scanner.ProgressInfo{Error: "scan failed"} - close(progress) - - changesDetected, err := trackScanInteractively(context.Background(), progress) - Expect(changesDetected).To(BeTrue()) - Expect(err).To(MatchError("scan failed")) - }) -}) - -var _ = Describe("readTargetsFromFile", func() { - var tempDir string - - BeforeEach(func() { - var err error - tempDir, err = os.MkdirTemp("", "navidrome-test-") - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - os.RemoveAll(tempDir) - }) - - It("reads valid targets from file", func() { - filePath := filepath.Join(tempDir, "targets.txt") - content := "1:Music/Rock\n2:Music/Jazz\n3:Classical\n" - err := os.WriteFile(filePath, []byte(content), 0600) - Expect(err).ToNot(HaveOccurred()) - - targets, err := readTargetsFromFile(filePath) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(HaveLen(3)) - Expect(targets[0]).To(Equal(model.ScanTarget{LibraryID: 1, FolderPath: "Music/Rock"})) - Expect(targets[1]).To(Equal(model.ScanTarget{LibraryID: 2, FolderPath: "Music/Jazz"})) - Expect(targets[2]).To(Equal(model.ScanTarget{LibraryID: 3, FolderPath: "Classical"})) - }) - - It("skips empty lines", func() { - filePath := filepath.Join(tempDir, "targets.txt") - content := "1:Music/Rock\n\n2:Music/Jazz\n\n" - err := os.WriteFile(filePath, []byte(content), 0600) - Expect(err).ToNot(HaveOccurred()) - - targets, err := readTargetsFromFile(filePath) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(HaveLen(2)) - }) - - It("trims whitespace", func() { - filePath := filepath.Join(tempDir, "targets.txt") - content := " 1:Music/Rock \n\t2:Music/Jazz\t\n" - err := os.WriteFile(filePath, []byte(content), 0600) - Expect(err).ToNot(HaveOccurred()) - - targets, err := readTargetsFromFile(filePath) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(HaveLen(2)) - Expect(targets[0].FolderPath).To(Equal("Music/Rock")) - Expect(targets[1].FolderPath).To(Equal("Music/Jazz")) - }) - - It("returns error for non-existent file", func() { - _, err := readTargetsFromFile("/nonexistent/file.txt") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to open target file")) - }) - - It("returns error for invalid target format", func() { - filePath := filepath.Join(tempDir, "targets.txt") - content := "invalid-format\n" - err := os.WriteFile(filePath, []byte(content), 0600) - Expect(err).ToNot(HaveOccurred()) - - _, err = readTargetsFromFile(filePath) - Expect(err).To(HaveOccurred()) - }) - - It("handles mixed valid and empty lines", func() { - filePath := filepath.Join(tempDir, "targets.txt") - content := "\n1:Music/Rock\n\n\n2:Music/Jazz\n\n" - err := os.WriteFile(filePath, []byte(content), 0600) - Expect(err).ToNot(HaveOccurred()) - - targets, err := readTargetsFromFile(filePath) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(HaveLen(2)) - }) -}) diff --git a/cmd/search.go b/cmd/search.go deleted file mode 100644 index 46b7eebd5..000000000 --- a/cmd/search.go +++ /dev/null @@ -1,55 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/log" - "github.com/spf13/cobra" -) - -var searchRebuildForce bool - -func init() { - rootCmd.AddCommand(searchRoot) - - searchRebuildCmd.Flags().BoolVarP(&searchRebuildForce, "force", "f", false, "bypass rebuild confirmation") - searchRoot.AddCommand(searchRebuildCmd) -} - -var ( - searchRoot = &cobra.Command{ - Use: "search", - Short: "Search index maintenance", - } - - searchRebuildCmd = &cobra.Command{ - Use: "rebuild", - Short: "Rebuild the full-text search index", - Long: "Drop and rebuild the full-text search index from the library data. Fixes a corrupted " + - "or desynced search index without any data loss. Note that 'navidrome doctor' detects a " + - "corrupted index, but cannot tell when the index has merely drifted out of sync with the " + - "library. This must be done offline", - Run: func(cmd *cobra.Command, _ []string) { - runSearchRebuild(cmd.Context()) - }, - } -) - -func runSearchRebuild(ctx context.Context) { - requireExistingDB() - - if !searchRebuildForce && !confirmYES("This will rebuild the search index. Make sure Navidrome is not running.") { - log.Warn("Rebuild cancelled") - return - } - - fmt.Println("Rebuilding the search index...") - err := db.RebuildFTS(ctx, db.Db()) - db.Close(ctx) - if err != nil { - log.Fatal("Error rebuilding the search index", err) - } - fmt.Println("Search index rebuilt successfully.") -} diff --git a/cmd/signaler_nonunix.go b/cmd/signaler_nonunix.go new file mode 100644 index 000000000..aad970382 --- /dev/null +++ b/cmd/signaler_nonunix.go @@ -0,0 +1,27 @@ +//go:build windows || plan9 + +package cmd + +import ( + "context" + "os" + "os/signal" + + "github.com/navidrome/navidrome/log" +) + +func startSignaler(ctx context.Context) func() error { + log.Info(ctx, "Starting signaler") + + return func() error { + var sigChan = make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt) + select { + case sig := <-sigChan: + log.Info(ctx, "Received termination signal", "signal", sig) + return interrupted + case <-ctx.Done(): + return nil + } + } +} diff --git a/cmd/signaller_unix.go b/cmd/signaler_unix.go similarity index 59% rename from cmd/signaller_unix.go rename to cmd/signaler_unix.go index f47dbf46a..5ed5b1457 100644 --- a/cmd/signaller_unix.go +++ b/cmd/signaler_unix.go @@ -14,24 +14,35 @@ import ( const triggerScanSignal = syscall.SIGUSR1 -func startSignaller(ctx context.Context) func() error { +func startSignaler(ctx context.Context) func() error { log.Info(ctx, "Starting signaler") - scanner := CreateScanner(ctx) + scanner := GetScanner() return func() error { var sigChan = make(chan os.Signal, 1) - signal.Notify(sigChan, triggerScanSignal) + signal.Notify( + sigChan, + os.Interrupt, + triggerScanSignal, + syscall.SIGHUP, + syscall.SIGTERM, + syscall.SIGABRT, + ) for { select { case sig := <-sigChan: + if sig != triggerScanSignal { + log.Info(ctx, "Received termination signal", "signal", sig) + return interrupted + } log.Info(ctx, "Received signal, triggering a new scan", "signal", sig) start := time.Now() - _, err := scanner.ScanAll(ctx, false) + err := scanner.RescanAll(ctx, false) if err != nil { log.Error(ctx, "Error scanning", err) } - log.Info(ctx, "Triggered scan complete", "elapsed", time.Since(start)) + log.Info(ctx, "Triggered scan complete", "elapsed", time.Since(start).Round(100*time.Millisecond)) case <-ctx.Done(): return nil } diff --git a/cmd/signaller_nounix.go b/cmd/signaller_nounix.go deleted file mode 100644 index de488cbd2..000000000 --- a/cmd/signaller_nounix.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build windows || plan9 - -package cmd - -import ( - "context" -) - -// Windows and Plan9 don't support SIGUSR1, so we don't need to start a signaler -func startSignaller(ctx context.Context) func() error { - return func() error { - return nil - } -} diff --git a/cmd/svc.go b/cmd/svc.go deleted file mode 100644 index 4e8b1fd85..000000000 --- a/cmd/svc.go +++ /dev/null @@ -1,272 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - "path/filepath" - "sync" - "time" - - "github.com/kardianos/service" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/spf13/cobra" -) - -var ( - svcStatusLabels = map[service.Status]string{ - service.StatusUnknown: "Unknown", - service.StatusStopped: "Stopped", - service.StatusRunning: "Running", - } - - installUser string - workingDirectory string -) - -func init() { - svcCmd.AddCommand(buildInstallCmd()) - svcCmd.AddCommand(buildUninstallCmd()) - svcCmd.AddCommand(buildStartCmd()) - svcCmd.AddCommand(buildStopCmd()) - svcCmd.AddCommand(buildStatusCmd()) - svcCmd.AddCommand(buildExecuteCmd()) - rootCmd.AddCommand(svcCmd) -} - -var svcCmd = &cobra.Command{ - Use: "service", - Aliases: []string{"svc"}, - Short: "Manage Navidrome as a service", - Long: fmt.Sprintf("Manage Navidrome as a service, using the OS service manager (%s)", service.Platform()), - Run: runServiceCmd, -} - -type svcControl struct { - ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop - cancel context.CancelFunc - done chan struct{} -} - -func (p *svcControl) Start(service.Service) error { - p.done = make(chan struct{}) - p.ctx, p.cancel = context.WithCancel(context.Background()) - go func() { - err := runNavidrome(p.ctx) - close(p.done) - // service.Run() only returns when it gets a stop request, so exit here to let the - // service manager see the failure and restart the service - if err != nil { - log.Fatal("Fatal error in Navidrome. Aborting", err) - } - }() - return nil -} - -func (p *svcControl) Stop(service.Service) error { - log.Info("Stopping service") - p.cancel() - select { - case <-p.done: - log.Info("Service stopped gracefully") - case <-time.After(10 * time.Second): - log.Error("Service did not stop in time. Killing it.") - } - return nil -} - -var svcInstance = sync.OnceValue(func() service.Service { - options := make(service.KeyValue) - options["Restart"] = "on-failure" - options["SuccessExitStatus"] = "SIGKILL" - options["UserService"] = false - options["LogDirectory"] = conf.Server.DataFolder.String() - options["SystemdScript"] = systemdScript - if conf.Server.LogFile != "" { - options["LogOutput"] = false - } else { - options["LogOutput"] = true - options["LogDirectory"] = conf.Server.DataFolder.String() - } - svcConfig := &service.Config{ - UserName: installUser, - Name: "navidrome", - DisplayName: "Navidrome", - Description: "Your Personal Streaming Service", - Dependencies: []string{ - "After=remote-fs.target network.target", - }, - WorkingDirectory: executablePath(), - Option: options, - } - arguments := []string{"service", "execute"} - if conf.Server.ConfigFile != "" { - arguments = append(arguments, "-c", conf.Server.ConfigFile) - } - svcConfig.Arguments = arguments - - prg := &svcControl{} - svc, err := service.New(prg, svcConfig) - if err != nil { - log.Fatal(err) - } - return svc -}) - -func runServiceCmd(cmd *cobra.Command, _ []string) { - _ = cmd.Help() -} - -func executablePath() string { - if workingDirectory != "" { - return workingDirectory - } - - ex, err := os.Executable() - if err != nil { - log.Fatal(err) - } - return filepath.Dir(ex) -} - -func buildInstallCmd() *cobra.Command { - runInstallCmd := func(_ *cobra.Command, _ []string) { - var err error - println("Installing service with:") - println(" working directory: " + executablePath()) - println(" music folder: " + conf.Server.MusicFolder) - println(" data folder: " + conf.Server.DataFolder.String()) - if conf.Server.LogFile != "" { - println(" log file: " + conf.Server.LogFile) - } else { - println(" logs folder: " + conf.Server.DataFolder.String()) - } - if cfgFile != "" { - conf.Server.ConfigFile, err = filepath.Abs(cfgFile) - if err != nil { - log.Fatal(err) - } - println(" config file: " + conf.Server.ConfigFile) - } - err = svcInstance().Install() - if err != nil { - log.Fatal(err) - } - println("Service installed. Use 'navidrome svc start' to start it.") - } - - cmd := &cobra.Command{ - Use: "install", - Short: "Install Navidrome service.", - Run: runInstallCmd, - } - cmd.Flags().StringVarP(&installUser, "user", "u", "", "user to run service") - cmd.Flags().StringVarP(&workingDirectory, "working-directory", "w", "", "working directory of service") - - return cmd -} - -func buildUninstallCmd() *cobra.Command { - return &cobra.Command{ - Use: "uninstall", - Short: "Uninstall Navidrome service. Does not delete the music or data folders", - Run: func(cmd *cobra.Command, args []string) { - err := svcInstance().Uninstall() - if err != nil { - log.Fatal(err) - } - println("Service uninstalled. Music and data folders are still intact.") - }, - } -} - -func buildStartCmd() *cobra.Command { - return &cobra.Command{ - Use: "start", - Short: "Start Navidrome service", - Run: func(cmd *cobra.Command, args []string) { - err := svcInstance().Start() - if err != nil { - log.Fatal(err) - } - println("Service started. Use 'navidrome svc status' to check its status.") - }, - } -} - -func buildStopCmd() *cobra.Command { - return &cobra.Command{ - Use: "stop", - Short: "Stop Navidrome service", - Run: func(cmd *cobra.Command, args []string) { - err := svcInstance().Stop() - if err != nil { - log.Fatal(err) - } - println("Service stopped. Use 'navidrome svc status' to check its status.") - }, - } -} - -func buildStatusCmd() *cobra.Command { - return &cobra.Command{ - Use: "status", - Short: "Show Navidrome service status", - Run: func(cmd *cobra.Command, args []string) { - status, err := svcInstance().Status() - if err != nil { - log.Fatal(err) - } - fmt.Printf("Navidrome is %s.\n", svcStatusLabels[status]) - }, - } -} - -func buildExecuteCmd() *cobra.Command { - return &cobra.Command{ - Use: "execute", - Short: "Run navidrome as a service in the foreground (it is very unlikely you want to run this, you are better off running just navidrome)", - Run: func(cmd *cobra.Command, args []string) { - err := svcInstance().Run() - if err != nil { - log.Fatal(err) - } - }, - } -} - -const systemdScript = `[Unit] -Description={{Description}} -ConditionFileIsExecutable={{Path | cmdEscape}} -{{range Dependencies}}{{.}} -{{end}} -[Service] -StartLimitInterval=5 -StartLimitBurst=10 -ExecStart={{Path | cmdEscape}}{{range Arguments}} {{. | cmd}}{{end}} -{{if WorkingDirectory}}WorkingDirectory={{WorkingDirectory | cmdEscape}}{{end}} -{{if UserName}}User={{UserName}}{{end}} -{{if Restart}}Restart={{Restart}}{{end}} -{{if SuccessExitStatus}}SuccessExitStatus={{SuccessExitStatus}}{{end}} -TimeoutStopSec=20 -RestartSec=120 -EnvironmentFile=-/etc/sysconfig/{{Name}} -Environment="ND_SYSTEMD_PRIORITY_LOGGING=1" - -DevicePolicy=closed -NoNewPrivileges=yes -PrivateTmp=yes -ProtectControlGroups=yes -ProtectKernelModules=yes -ProtectKernelTunables=yes -RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 -RestrictNamespaces=yes -RestrictRealtime=yes -SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap -{{if WorkingDirectory}}ReadWritePaths={{WorkingDirectory | cmdEscape}}{{end}} -ProtectSystem=full - -[Install] -WantedBy=multi-user.target -` diff --git a/cmd/svc_test.go b/cmd/svc_test.go deleted file mode 100644 index 7c34563b3..000000000 --- a/cmd/svc_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package cmd - -import ( - "regexp" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("systemdScript template", func() { - systemdKeys := map[string]bool{ - "Description": true, "Path": true, "Name": true, "Dependencies": true, - "Arguments": true, "ChRoot": true, "WorkingDirectory": true, - "UserName": true, "ReloadSignal": true, "PIDFile": true, - "LogDirectory": true, "OutputFileSupport": true, "LimitNOFILE": true, - "Restart": true, "SuccessExitStatus": true, "EnvVars": true, - } - systemdFuncs := map[string]bool{"cmd": true, "cmdEscape": true} - - actionRe := regexp.MustCompile(`\{\{(.*?)\}\}`) - - parseAction := func(action string) (key string, funcs []string) { - action = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(action, "-"), "-")) - kw, rest, _ := strings.Cut(action, " ") - switch kw { - case "end", "else": - return "", nil - case "if", "range": - return strings.TrimSpace(rest), nil - } - parts := strings.Split(action, "|") - for _, p := range parts[1:] { - funcs = append(funcs, strings.TrimSpace(p)) - } - return strings.TrimSpace(parts[0]), funcs - } - - It("only references keys and functions the service library provides", func() { - matches := actionRe.FindAllStringSubmatch(systemdScript, -1) - Expect(matches).ToNot(BeEmpty()) - - for _, m := range matches { - key, funcs := parseAction(m[1]) - if key != "" && key != "." { - Expect(systemdKeys).To(HaveKey(key), - "template action %q uses a key unknown to kardianos/service", m[0]) - } - for _, fn := range funcs { - Expect(systemdFuncs).To(HaveKey(fn), - "template action %q uses an unknown pipeline function", m[0]) - } - } - }) -}) diff --git a/cmd/user.go b/cmd/user.go deleted file mode 100644 index eb64e69fe..000000000 --- a/cmd/user.go +++ /dev/null @@ -1,475 +0,0 @@ -package cmd - -import ( - "context" - "encoding/csv" - "encoding/json" - "errors" - "fmt" - "os" - "strconv" - "strings" - "syscall" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/spf13/cobra" - "golang.org/x/term" -) - -var ( - email string - libraryIds []int - name string - - removeEmail bool - removeName bool - setAdmin bool - setPassword bool - setRegularUser bool -) - -func init() { - rootCmd.AddCommand(userRoot) - - userCreateCommand.Flags().StringVarP(&userID, "username", "u", "", "username") - - userCreateCommand.Flags().StringVarP(&email, "email", "e", "", "New user email") - userCreateCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries. If empty, the user can access all libraries. This is incompatible with admin, as admin can always access all libraries") - - userCreateCommand.Flags().BoolVarP(&setAdmin, "admin", "a", false, "If set, make the user an admin. This user will have access to every library") - userCreateCommand.Flags().StringVar(&name, "name", "", "New user's name (this is separate from username used to log in)") - - _ = userCreateCommand.MarkFlagRequired("username") - - userRoot.AddCommand(userCreateCommand) - - userDeleteCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id") - _ = userDeleteCommand.MarkFlagRequired("user") - userRoot.AddCommand(userDeleteCommand) - - userEditCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id") - - userEditCommand.Flags().BoolVar(&setAdmin, "set-admin", false, "If set, make the user an admin") - userEditCommand.Flags().BoolVar(&setRegularUser, "set-regular", false, "If set, make the user a non-admin") - userEditCommand.MarkFlagsMutuallyExclusive("set-admin", "set-regular") - - userEditCommand.Flags().BoolVar(&removeEmail, "remove-email", false, "If set, clear the user's email") - userEditCommand.Flags().StringVarP(&email, "email", "e", "", "New user email") - userEditCommand.MarkFlagsMutuallyExclusive("email", "remove-email") - - userEditCommand.Flags().BoolVar(&removeName, "remove-name", false, "If set, clear the user's name") - userEditCommand.Flags().StringVar(&name, "name", "", "New user name (this is separate from username used to log in)") - userEditCommand.MarkFlagsMutuallyExclusive("name", "remove-name") - - userEditCommand.Flags().BoolVar(&setPassword, "set-password", false, "If set, the user's new password will be prompted on the CLI") - - userEditCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries by id") - - _ = userEditCommand.MarkFlagRequired("user") - userRoot.AddCommand(userEditCommand) - - userListCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]") - userRoot.AddCommand(userListCommand) -} - -var ( - userRoot = &cobra.Command{ - Use: "user", - Short: "Administer users", - Long: "Create, delete, list, or update users", - } - - userCreateCommand = &cobra.Command{ - Use: "create", - Aliases: []string{"c"}, - Short: "Create a new user", - Run: func(cmd *cobra.Command, args []string) { - runCreateUser(cmd.Context()) - }, - } - - userDeleteCommand = &cobra.Command{ - Use: "delete", - Aliases: []string{"d"}, - Short: "Deletes an existing user", - Run: func(cmd *cobra.Command, args []string) { - runDeleteUser(cmd.Context()) - }, - } - - userEditCommand = &cobra.Command{ - Use: "edit", - Aliases: []string{"e"}, - Short: "Edit a user", - Long: "Edit the password, admin status, and/or library access", - Run: func(cmd *cobra.Command, args []string) { - runUserEdit(cmd.Context()) - }, - } - - userListCommand = &cobra.Command{ - Use: "list", - Short: "List users", - Run: func(cmd *cobra.Command, args []string) { - runUserList(cmd.Context()) - }, - } -) - -func promptPassword() string { - for { - fmt.Print("Enter new password (press enter with no password to cancel): ") - // This cast is necessary for some platforms - password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert - - if err != nil { - log.Fatal("Error getting password", err) - } - - fmt.Print("\nConfirm new password (press enter with no password to cancel): ") - confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert - - if err != nil { - log.Fatal("Error getting password confirmation", err) - } - - // clear the line. - fmt.Println() - - pass := string(password) - confirm := string(confirmation) - - if pass == "" { - return "" - } - - if pass == confirm { - return pass - } - - fmt.Println("Password and password confirmation do not match") - } -} - -func libraryError(libraries model.Libraries) error { - ids := make([]int, len(libraries)) - for idx, library := range libraries { - ids[idx] = library.ID - } - return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %v", libraryIds, ids) -} - -func runCreateUser(ctx context.Context) { - password := promptPassword() - if password == "" { - log.Fatal("Empty password provided, user creation cancelled") - } - - user := model.User{ - UserName: userID, - Email: email, - Name: name, - IsAdmin: setAdmin, - NewPassword: password, - } - - if user.Name == "" { - user.Name = userID - } - - ds, ctx := getAdminContext(ctx) - - err := ds.WithTx(func(tx model.DataStore) error { - existingUser, err := tx.User().FindByUsername(ctx, userID) - if existingUser != nil { - return fmt.Errorf("existing user '%s'", userID) - } - - if err != nil && !errors.Is(err, model.ErrNotFound) { - return fmt.Errorf("failed to check existing username: %w", err) - } - - if len(libraryIds) > 0 && !setAdmin { - user.Libraries, err = tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}}) - if err != nil { - return err - } - - if len(user.Libraries) != len(libraryIds) { - return libraryError(user.Libraries) - } - } else { - user.Libraries, err = tx.Library().GetAll(ctx) - if err != nil { - return err - } - } - - err = tx.User().Put(ctx, &user) - if err != nil { - return err - } - - updatedIds := make([]int, len(user.Libraries)) - for idx, lib := range user.Libraries { - updatedIds[idx] = lib.ID - } - - err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds) - return err - }) - - if err != nil { - log.Fatal(ctx, err) - } - - log.Info(ctx, "Successfully created user", "id", user.ID, "username", user.UserName) -} - -func runDeleteUser(ctx context.Context) { - ds, ctx := getAdminContext(ctx) - - var err error - var user *model.User - - err = ds.WithTx(func(tx model.DataStore) error { - count, err := tx.User().CountAll(ctx) - if err != nil { - return err - } - - if count == 1 { - return errors.New("refusing to delete the last user") - } - - user, err = getUser(ctx, userID, tx) - if err != nil { - return err - } - - return tx.User().Delete(ctx, user.ID) - }) - - if err != nil { - log.Fatal(ctx, "Failed to delete user", err) - } - - log.Info(ctx, "Deleted user", "username", user.UserName) -} - -func runUserEdit(ctx context.Context) { - ds, ctx := getAdminContext(ctx) - - var err error - var user *model.User - changes := []string{} - - err = ds.WithTx(func(tx model.DataStore) error { - var newLibraries model.Libraries - - user, err = getUser(ctx, userID, tx) - if err != nil { - return err - } - - if len(libraryIds) > 0 && !setAdmin { - libraries, err := tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}}) - - if err != nil { - return err - } - - if len(libraries) != len(libraryIds) { - return libraryError(libraries) - } - - newLibraries = libraries - changes = append(changes, "updated library ids") - } - - if setAdmin && !user.IsAdmin { - libraries, err := tx.Library().GetAll(ctx) - if err != nil { - return err - } - - user.IsAdmin = true - user.Libraries = libraries - changes = append(changes, "set admin") - - newLibraries = libraries - } - - if setRegularUser && user.IsAdmin { - user.IsAdmin = false - changes = append(changes, "set regular user") - } - - if setPassword { - password := promptPassword() - - if password != "" { - user.NewPassword = password - changes = append(changes, "updated password") - } - } - - if email != "" && email != user.Email { - user.Email = email - changes = append(changes, "updated email") - } else if removeEmail && user.Email != "" { - user.Email = "" - changes = append(changes, "removed email") - } - - if name != "" && name != user.Name { - user.Name = name - changes = append(changes, "updated name") - } else if removeName && user.Name != "" { - user.Name = "" - changes = append(changes, "removed name") - } - - if len(changes) == 0 { - return nil - } - - err := tx.User().Put(ctx, user) - if err != nil { - return err - } - - if len(newLibraries) > 0 { - updatedIds := make([]int, len(newLibraries)) - for idx, lib := range newLibraries { - updatedIds[idx] = lib.ID - } - - err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds) - if err != nil { - return err - } - } - - return nil - }) - - if err != nil { - log.Fatal(ctx, "Failed to update user", err) - } - - if len(changes) == 0 { - log.Info(ctx, "No changes for user", "user", user.UserName) - } else { - log.Info(ctx, "Updated user", "user", user.UserName, "changes", strings.Join(changes, ", ")) - } -} - -type displayLibrary struct { - ID int `json:"id"` - Path string `json:"path"` -} - -type displayUser struct { - Id string `json:"id"` - Username string `json:"username"` - Name string `json:"name"` - Email string `json:"email"` - Admin bool `json:"admin"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - LastAccess *time.Time `json:"lastAccess"` - LastLogin *time.Time `json:"lastLogin"` - Libraries []displayLibrary `json:"libraries"` -} - -func runUserList(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) - - userList, err := ds.User().ReadAll(ctx) - if err != nil { - log.Fatal(ctx, "Failed to retrieve users", err) - } - - if outputFormat == "csv" { - w := csv.NewWriter(os.Stdout) - _ = w.Write([]string{ - "user id", - "username", - "user's name", - "user email", - "admin", - "created at", - "updated at", - "last access", - "last login", - "libraries", - }) - for _, user := range userList { - paths := make([]string, len(user.Libraries)) - - for idx, library := range user.Libraries { - paths[idx] = fmt.Sprintf("%d:%s", library.ID, library.Path) - } - - var lastAccess, lastLogin string - - if user.LastAccessAt != nil { - lastAccess = user.LastAccessAt.Format(time.RFC3339Nano) - } else { - lastAccess = "never" - } - - if user.LastLoginAt != nil { - lastLogin = user.LastLoginAt.Format(time.RFC3339Nano) - } else { - lastLogin = "never" - } - - _ = w.Write([]string{ - user.ID, - user.UserName, - user.Name, - user.Email, - strconv.FormatBool(user.IsAdmin), - user.CreatedAt.Format(time.RFC3339Nano), - user.UpdatedAt.Format(time.RFC3339Nano), - lastAccess, - lastLogin, - fmt.Sprintf("'%s'", strings.Join(paths, "|")), - }) - } - w.Flush() - } else { - users := make([]displayUser, len(userList)) - for idx, user := range userList { - paths := make([]displayLibrary, len(user.Libraries)) - - for idx, library := range user.Libraries { - paths[idx].ID = library.ID - paths[idx].Path = library.Path - } - - users[idx].Id = user.ID - users[idx].Username = user.UserName - users[idx].Name = user.Name - users[idx].Email = user.Email - users[idx].Admin = user.IsAdmin - users[idx].CreatedAt = user.CreatedAt - users[idx].UpdatedAt = user.UpdatedAt - users[idx].LastAccess = user.LastAccessAt - users[idx].LastLogin = user.LastLoginAt - users[idx].Libraries = paths - } - - j, _ := json.Marshal(users) - fmt.Printf("%s\n", j) - } -} diff --git a/cmd/utils.go b/cmd/utils.go deleted file mode 100644 index f35a31fb1..000000000 --- a/cmd/utils.go +++ /dev/null @@ -1,74 +0,0 @@ -package cmd - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "strings" - "text/tabwriter" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/persistence" -) - -// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists. -func existingDBFile() (string, bool) { - path, _, _ := strings.Cut(conf.Server.DbPath, "?") - _, err := os.Stat(path) - return path, err == nil -} - -// requireExistingDB aborts the command when the database file does not exist. -func requireExistingDB() { - if path, ok := existingDBFile(); !ok { - log.Fatal("No existing database", "path", path) - } -} - -func confirmYES(warning string) bool { - fmt.Println(warning) - fmt.Printf("Please enter YES (all caps) to continue: ") - var input string - _, err := fmt.Scanln(&input) - return input == "YES" && err == nil -} - -// newTabWriter keeps every CLI table on the same column settings. -func newTabWriter(out io.Writer) *tabwriter.Writer { - return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) -} - -func getAdminContext(ctx context.Context) (model.DataStore, context.Context) { - sqlDB := db.Db() - ds := persistence.New(sqlDB) - ctx = auth.WithAdminUser(ctx, ds) - u, _ := request.UserFrom(ctx) - if !u.IsAdmin { - log.Fatal(ctx, "There must be at least one admin user to run this command.") - } - return ds, ctx -} - -func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) { - user, err := ds.User().FindByUsername(ctx, id) - - if err != nil && !errors.Is(err, model.ErrNotFound) { - return nil, fmt.Errorf("finding user by name: %w", err) - } - - if errors.Is(err, model.ErrNotFound) { - user, err = ds.User().Get(ctx, id) - if err != nil { - return nil, fmt.Errorf("finding user by id: %w", err) - } - } - - return user, nil -} diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 2a396689d..47c01d948 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -1,152 +1,75 @@ // Code generated by Wire. DO NOT EDIT. -//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5" +//go:generate go run github.com/google/wire/cmd/wire //go:build !wireinject // +build !wireinject package cmd import ( - "context" "github.com/google/wire" - "github.com/navidrome/navidrome/adapters/lastfm" - "github.com/navidrome/navidrome/adapters/listenbrainz" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/agents/lastfm" + "github.com/navidrome/navidrome/core/agents/listenbrainz" "github.com/navidrome/navidrome/core/artwork" - "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/quickconnect" "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" "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/plugins" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/server/apiv1" + "github.com/navidrome/navidrome/server/api" "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/jellyfin" "github.com/navidrome/navidrome/server/nativeapi" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic" -) - -import ( - _ "github.com/navidrome/navidrome/adapters/deezer" - _ "github.com/navidrome/navidrome/adapters/gotaglib" - _ "github.com/navidrome/navidrome/adapters/lastfm" - _ "github.com/navidrome/navidrome/adapters/listenbrainz" + "sync" ) // Injectors from wire_injectors.go: -func CreateDataStore() model.DataStore { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) - return dataStore -} - -func CreateServer() *server.Server { +func CreateServer(musicFolder string) *server.Server { sqlDB := db.Db() dataStore := persistence.New(sqlDB) broker := events.GetBroker() - insights := metrics.GetInstance(dataStore) - serverServer := server.New(dataStore, broker, insights) + serverServer := server.New(dataStore, broker) return serverServer } -func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { +func CreateNativeAPIRouter() *nativeapi.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) share := core.NewShare(dataStore) - uploader := artwork.NewUploader(dataStore) - playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) - insights := metrics.GetInstance(dataStore) - broker := events.GetBroker() - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) - watcher := scanner.GetWatcher(dataStore, modelScanner) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager) - user := core.NewUser(dataStore, manager) - maintenance := core.NewMaintenance(dataStore) - agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) - quickConnect := quickconnect.GetInstance() - router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect) + router := nativeapi.New(dataStore, share) return router } -func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { +func CreateNewNativeAPIRouter() *api.Router { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + router := api.New(dataStore) + return router +} + +func CreateSubsonicAPIRouter() *subsonic.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() - imageStore := artwork.GetImageStore() fFmpeg := ffmpeg.New() - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) - transcodingCache := stream.GetTranscodingCache() - mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) - transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) + agentsAgents := agents.New(dataStore) + externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata) + transcodingCache := core.GetTranscodingCache() + mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork) + archiver := core.NewArchiver(mediaStreamer, dataStore, share) players := core.NewPlayers(dataStore) + scanner := GetScanner() broker := events.GetBroker() - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) - uploader := artwork.NewUploader(dataStore) - playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) - modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) - playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) - playbackServer := playback.GetInstance(dataStore) - lyricsLyrics := lyrics.NewLyrics(dataStore, manager) - 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 -} - -func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) - fileCache := artwork.GetImageCache() - imageStore := artwork.GetImageStore() - fFmpeg := ffmpeg.New() - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) - transcodingCache := stream.GetTranscodingCache() - mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) - transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) - players := core.NewPlayers(dataStore) - broker := events.GetBroker() - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) - uploader := artwork.NewUploader(dataStore) - playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) - agentsAgents := agents.GetAgents(dataStore, manager) - matcherMatcher := matcher.New(dataStore) - provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker) - sonicSonic := sonic.New(dataStore, manager, matcherMatcher) - lyricsLyrics := lyrics.NewLyrics(dataStore, manager) - quickConnect := quickconnect.GetInstance() - router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect) - return router -} - -func CreateAPIv1Router(ctx context.Context) *apiv1.Router { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) - router := apiv1.New(dataStore) + playlists := core.NewPlaylists(dataStore) + playTracker := scrobbler.GetPlayTracker(dataStore, broker) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, externalMetadata, scanner, broker, playlists, playTracker, share) return router } @@ -154,15 +77,15 @@ func CreatePublicRouter() *public.Router { sqlDB := db.Db() dataStore := persistence.New(sqlDB) fileCache := artwork.GetImageCache() - imageStore := artwork.GetImageStore() fFmpeg := ffmpeg.New() - artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg) - transcodingCache := stream.GetTranscodingCache() - mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) - transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) + agentsAgents := agents.New(dataStore) + externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata) + transcodingCache := core.GetTranscodingCache() + mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) - archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork) - router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver) + archiver := core.NewArchiver(mediaStreamer, dataStore, share) + router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver) return router } @@ -180,98 +103,34 @@ func CreateListenBrainzRouter() *listenbrainz.Router { return router } -func CreateJellyfinDiscovery() *jellyfin.Discovery { +func createScanner() scanner.Scanner { sqlDB := db.Db() dataStore := persistence.New(sqlDB) - discovery := jellyfin.NewDiscovery(dataStore) - return discovery -} - -func CreateInsights() metrics.Insights { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) - insights := metrics.GetInstance(dataStore) - return insights -} - -func CreatePrometheus() metrics.Metrics { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - return metricsMetrics -} - -func CreateScanner(ctx context.Context) model.Scanner { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) - broker := events.GetBroker() - uploader := artwork.NewUploader(dataStore) - playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) - return modelScanner -} - -func CreateScanWatcher(ctx context.Context) scanner.Watcher { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) - broker := events.GetBroker() - uploader := artwork.NewUploader(dataStore) - playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader) - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics) - watcher := scanner.GetWatcher(dataStore, modelScanner) - return watcher -} - -func GetPlaybackServer() playback.PlaybackServer { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) - playbackServer := playback.GetInstance(dataStore) - return playbackServer -} - -func CreateArtworkWorker() *artwork.Worker { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) - imageStore := artwork.GetImageStore() - broker := events.GetBroker() - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) - fFmpeg := ffmpeg.New() + playlists := core.NewPlaylists(dataStore) fileCache := artwork.GetImageCache() - worker := artwork.NewWorker(dataStore, imageStore, agentsAgents, fFmpeg, broker, fileCache) - return worker -} - -func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) - broker := events.GetBroker() - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - agentsAgents := agents.GetAgents(dataStore, manager) fFmpeg := ffmpeg.New() - tracingResolver := artwork.NewTracingResolver(dataStore, agentsAgents, fFmpeg, trace, live) - return tracingResolver -} - -func getPluginManager() *plugins.Manager { - sqlDB := db.Db() - dataStore := persistence.New(sqlDB) + agentsAgents := agents.New(dataStore) + externalMetadata := core.NewExternalMetadata(dataStore, agentsAgents) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, externalMetadata) + cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) broker := events.GetBroker() - metricsMetrics := metrics.GetPrometheusInstance(dataStore) - manager := plugins.GetManager(dataStore, broker, metricsMetrics) - return manager + scannerScanner := scanner.New(dataStore, playlists, cacheWarmer, broker) + return scannerScanner } // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, apiv1.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader))) +var allProviders = wire.NewSet(core.Set, artwork.Set, subsonic.New, nativeapi.New, api.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, db.Db) -func GetPluginManager(ctx context.Context) *plugins.Manager { - manager := getPluginManager() - manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx)) - return manager +// Scanner must be a Singleton +var ( + onceScanner sync.Once + scannerInstance scanner.Scanner +) + +func GetScanner() scanner.Scanner { + onceScanner.Do(func() { + scannerInstance = createScanner() + }) + return scannerInstance } diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index c5006e07f..ad444ee5c 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -3,29 +3,19 @@ package cmd import ( - "context" + "sync" "github.com/google/wire" - "github.com/navidrome/navidrome/adapters/lastfm" - "github.com/navidrome/navidrome/adapters/listenbrainz" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/agents/lastfm" + "github.com/navidrome/navidrome/core/agents/listenbrainz" "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/lyrics" - "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/db" - "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/plugins" "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/server/apiv1" + "github.com/navidrome/navidrome/server/api" "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/jellyfin" "github.com/navidrome/navidrome/server/nativeapi" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic" @@ -34,68 +24,40 @@ import ( var allProviders = wire.NewSet( core.Set, artwork.Set, - server.New, subsonic.New, - jellyfin.New, - jellyfin.NewDiscovery, - apiv1.New, nativeapi.New, + api.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, - scanner.GetInstance, - scanner.GetWatcher, - metrics.GetPrometheusInstance, db.Db, - plugins.GetManager, - sonic.New, - wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), - wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), - wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), - wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), - wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), - wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), - wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), - wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), - wire.Bind(new(core.Watcher), new(scanner.Watcher)), - wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)), ) -func CreateDataStore() model.DataStore { +func CreateServer(musicFolder string) *server.Server { + panic(wire.Build( + server.New, + allProviders, + )) +} + +func CreateNativeAPIRouter() *nativeapi.Router { panic(wire.Build( allProviders, )) } -func CreateServer() *server.Server { +func CreateNewNativeAPIRouter() *api.Router { panic(wire.Build( allProviders, )) } -func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { - panic(wire.Build( - allProviders, - )) -} - -func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { - panic(wire.Build( - allProviders, - )) -} - -func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router { - panic(wire.Build( - allProviders, - )) -} - -func CreateAPIv1Router(ctx context.Context) *apiv1.Router { +func CreateSubsonicAPIRouter() *subsonic.Router { panic(wire.Build( allProviders, + GetScanner, )) } @@ -117,63 +79,22 @@ func CreateListenBrainzRouter() *listenbrainz.Router { )) } -func CreateJellyfinDiscovery() *jellyfin.Discovery { - panic(wire.Build( - allProviders, - )) +// Scanner must be a Singleton +var ( + onceScanner sync.Once + scannerInstance scanner.Scanner +) + +func GetScanner() scanner.Scanner { + onceScanner.Do(func() { + scannerInstance = createScanner() + }) + return scannerInstance } -func CreateInsights() metrics.Insights { +func createScanner() scanner.Scanner { panic(wire.Build( allProviders, + scanner.New, )) } - -func CreatePrometheus() metrics.Metrics { - panic(wire.Build( - allProviders, - )) -} - -func CreateScanner(ctx context.Context) model.Scanner { - panic(wire.Build( - allProviders, - )) -} - -func CreateScanWatcher(ctx context.Context) scanner.Watcher { - panic(wire.Build( - allProviders, - )) -} - -func GetPlaybackServer() playback.PlaybackServer { - panic(wire.Build( - allProviders, - )) -} - -func CreateArtworkWorker() *artwork.Worker { - panic(wire.Build( - allProviders, - )) -} - -func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver { - panic(wire.Build( - allProviders, - artwork.NewTracingResolver, - )) -} - -func getPluginManager() *plugins.Manager { - panic(wire.Build( - allProviders, - )) -} - -func GetPluginManager(ctx context.Context) *plugins.Manager { - manager := getPluginManager() - manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx)) - return manager -} diff --git a/conf/buildtags/doc.go b/conf/buildtags/doc.go deleted file mode 100644 index f637b6355..000000000 --- a/conf/buildtags/doc.go +++ /dev/null @@ -1,6 +0,0 @@ -// Package buildtags provides compile-time enforcement of required build tags. -// -// Each file in this package is guarded by a build constraint and exports a variable -// that main.go references. If a required tag is missing during compilation, the build -// fails with an "undefined" error, directing the developer to use `make build`. -package buildtags diff --git a/conf/buildtags/netgo.go b/conf/buildtags/netgo.go deleted file mode 100644 index 407004703..000000000 --- a/conf/buildtags/netgo.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build netgo - -package buildtags - -// The `netgo` tag is required when compiling the project. See https://github.com/navidrome/navidrome/issues/700 - -var NETGO = true diff --git a/conf/buildtags/sqlite_fts5.go b/conf/buildtags/sqlite_fts5.go deleted file mode 100644 index 1476e04cd..000000000 --- a/conf/buildtags/sqlite_fts5.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build sqlite_fts5 - -package buildtags - -// FTS5 is required for full-text search. Without this tag, the SQLite driver -// won't include FTS5 support, causing runtime failures on migrations and search queries. - -var SQLITE_FTS5 = true diff --git a/conf/configtest/configtest.go b/conf/configtest/configtest.go index cd0ac41ed..b947e6263 100644 --- a/conf/configtest/configtest.go +++ b/conf/configtest/configtest.go @@ -2,7 +2,9 @@ package configtest import "github.com/navidrome/navidrome/conf" -// TODO Remove this redirection and call SnapshotConfig directly from tests func SetupConfig() func() { - return conf.SnapshotConfig() + oldValues := *conf.Server + return func() { + conf.Server = &oldValues + } } diff --git a/conf/configuration.go b/conf/configuration.go index efa9cbf9a..377f1d95b 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -1,327 +1,132 @@ package conf import ( - "cmp" - "encoding" - "encoding/json" "fmt" - "math" "net/url" "os" "path/filepath" - "reflect" - "regexp" "runtime" - "slices" "strings" - "sync" "time" - "github.com/bmatcuk/doublestar/v4" - "github.com/dustin/go-humanize" - "github.com/go-viper/encoding/ini" - "github.com/go-viper/mapstructure/v2" "github.com/kr/pretty" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/scheduler" - "github.com/navidrome/navidrome/utils/run" - "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/number" + "github.com/robfig/cron/v3" "github.com/spf13/viper" ) type configOptions struct { - ConfigFile string `conf:"-"` - Address string - Port int - UnixSocketPerm string - EnforceNonRootUser bool - MusicFolder string - DataFolder Dir - CacheFolder Dir - DbPath string - LogLevel string - LogFile string - SessionTimeout time.Duration - BaseURL string - BasePath string - BaseHost string - BaseScheme string - TLSCert string - TLSKey string - UILoginBackgroundURL string - UIWelcomeMessage string - MaxSidebarPlaylists int - EnableTranscodingConfig bool - EnableDownloads bool - EnableExternalServices bool - EnableM3UExternalAlbumArt bool - EnableInsightsCollector bool - EnableScheduledDBAnalyze bool - EnableMediaFileCoverArt bool - TranscodingCacheSize string - ImageCacheSize string - AlbumPlayCountMode string - EnableArtworkPrecache bool - AutoImportPlaylists bool - DefaultPlaylistPublicVisibility bool - PlaylistsPath string - SmartPlaylistRefreshDelay time.Duration - AutoTranscodeDownload bool - DefaultDownsamplingFormat string - Search searchOptions `json:",omitzero"` - Matcher matcherOptions `json:",omitzero"` - RecentlyAddedByModTime bool - PreferSortTags bool - EnableNaturalSorting bool - IgnoredArticles string - IndexGroups string - FFmpegPath string - MPVPath string - MPVCmdTemplate string - CoverArtPriority string - CoverArtQuality int - EnableWebPEncoding bool - ArtistArtPriority string - ArtistImageFolder string - DiscArtPriority string - LyricsPriority string - EnableGravatar bool - EnableFavourites bool - EnableStarRating bool - EnableUserEditing bool - EnableArtworkUpload bool - MaxImageUploadSize string - MaxImageSize string - EnableSharing bool - ShareURL string - DefaultShareExpiration time.Duration - DefaultDownloadableShare bool - DefaultTheme string - DefaultLanguage string - DefaultUIVolume int - UISearchDebounceMs int - UICoverArtSize int - EnableReplayGain bool - EnableCoverAnimation bool - EnableNowPlaying bool - UIPlaybackReportInterval time.Duration - GATrackingID string - EnableLogRedacting bool - AuthRequestLimit int - AuthWindowLength time.Duration - PasswordEncryptionKey string - ExtAuth extAuthOptions - Plugins pluginsOptions - HTTPHeaders httpHeaderOptions `json:",omitzero"` - Prometheus prometheusOptions `json:",omitzero"` - Scanner scannerOptions `json:",omitzero"` - Jukebox jukeboxOptions `json:",omitzero"` - Backup backupOptions `json:",omitzero"` - PID pidOptions `json:",omitzero"` - Inspect inspectOptions `json:",omitzero"` - Subsonic subsonicOptions `json:",omitzero"` - Transcoding transcodingOptions `json:",omitzero"` - LastFM lastfmOptions `json:",omitzero"` - Deezer deezerOptions `json:",omitzero"` - ListenBrainz listenBrainzOptions `json:",omitzero"` - Jellyfin jellyfinOptions `json:",omitzero"` - EnableScrobbleHistory bool - Tags map[string]TagConf `json:",omitempty"` - Agents string + ConfigFile string + Address string + Port int + MusicFolder string + DataFolder string + CacheFolder string + DbPath string + LogLevel string + ScanInterval time.Duration + ScanSchedule string + SessionTimeout time.Duration + BaseURL string + BasePath string + BaseHost string + BaseScheme string + TLSCert string + TLSKey string + UILoginBackgroundURL string + UIWelcomeMessage string + MaxSidebarPlaylists int + EnableTranscodingConfig bool + EnableDownloads bool + EnableExternalServices bool + EnableMediaFileCoverArt bool + TranscodingCacheSize string + ImageCacheSize string + EnableArtworkPrecache bool + AutoImportPlaylists bool + PlaylistsPath string + AutoTranscodeDownload bool + DefaultDownsamplingFormat string + SearchFullString bool + RecentlyAddedByModTime bool + IgnoredArticles string + IndexGroups string + SubsonicArtistParticipations bool + FFmpegPath string + CoverArtPriority string + CoverJpegQuality int + ArtistArtPriority string + EnableGravatar bool + EnableFavourites bool + EnableStarRating bool + EnableUserEditing bool + EnableSharing bool + DefaultDownloadableShare bool + DefaultTheme string + DefaultLanguage string + DefaultUIVolume int + EnableReplayGain bool + EnableCoverAnimation bool + GATrackingID string + EnableLogRedacting bool + AuthRequestLimit int + AuthWindowLength time.Duration + PasswordEncryptionKey string + ReverseProxyUserHeader string + ReverseProxyWhitelist string + Prometheus prometheusOptions + Scanner scannerOptions + + Agents string + LastFM lastfmOptions + Spotify spotifyOptions + ListenBrainz listenBrainzOptions // DevFlags. These are used to enable/disable debugging and incomplete features - DevLogLevels map[string]string `json:",omitempty"` - DevLogSourceLine bool - DevEnableProfiler bool - DevAutoCreateAdminPassword string - DevAutoLoginUsername string - DevActivityPanel bool - DevActivityPanelUpdateRate time.Duration - DevSidebarPlaylists bool - DevShowArtistPage bool - DevUIShowConfig bool - DevNewEventStream bool - DevOffsetOptimize int - DevArtworkMaxRequests int - DevArtworkThrottleBacklogLimit int - DevArtworkThrottleBacklogTimeout time.Duration - DevArtworkThrottleBuffered bool - DevArtworkWorkerConcurrency int - DevArtworkExternalMaxRPS int - DevArtistInfoTimeToLive time.Duration - DevAlbumInfoTimeToLive time.Duration - DevExternalScanner bool - DevScannerThreads uint - DevSelectiveWatcher bool - DevInsightsInitialDelay time.Duration - DevEnablePlayerInsights bool - DevEnablePluginsInsights bool - DevPluginCompilationTimeout time.Duration - DevExternalArtistFetchMultiplier float64 - DevPreserveUnicodeInExternalCalls bool - DevEnableMediaFileProbe bool - DevAPIv1 bool + DevLogSourceLine bool + DevLogLevels map[string]string + DevEnableProfiler bool + DevAutoCreateAdminPassword string + DevAutoLoginUsername string + DevActivityPanel bool + DevSidebarPlaylists bool + DevEnableBufferedScrobble bool + DevShowArtistPage bool + DevArtworkMaxRequests int + DevArtworkThrottleBacklogLimit int + DevArtworkThrottleBacklogTimeout time.Duration + DevArtistInfoTimeToLive time.Duration + DevAlbumInfoTimeToLive time.Duration } type scannerOptions struct { - Enabled bool - Schedule string - WatcherWait time.Duration - ScanOnStartup bool - Extractor string - ArtistJoiner string - ArtistSplitExceptions []string // Artist names never split by tag separators - GenreSeparators string // Deprecated: Use Tags.genre.Split instead - GroupAlbumReleases bool // Deprecated: Use PID.Album instead - FollowSymlinks bool // Whether to follow symlinks when scanning directories - IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning - PurgeMissing string // Values: "never", "always", "full" -} - -type transcodingOptions struct { - MaxConcurrent int - MaxConcurrentPerUser int - EnableCancellation bool -} - -type subsonicOptions struct { - AppendSubtitle bool - AppendAlbumVersion bool - ArtistParticipations bool - DefaultReportRealPath bool - EnableAverageRating bool - LegacyClients string - MinimalClients string -} - -type TagConf struct { - Ignore bool `yaml:"ignore" json:",omitempty"` - Aliases []string `yaml:"aliases" json:",omitempty"` - Type string `yaml:"type" json:",omitempty"` - MaxLength int `yaml:"maxLength" json:",omitempty"` - Split []string `yaml:"split" json:",omitempty"` - Album bool `yaml:"album" json:",omitempty"` + Extractor string + GenreSeparators string + GroupAlbumReleases bool } type lastfmOptions struct { - Enabled bool - ApiKey string //nolint:gosec - Secret string //nolint:gosec - Language string - ScrobbleFirstArtistOnly bool - - // Computed values - Languages []string `conf:"-"` // Computed from Language, split by comma + Enabled bool + ApiKey string + Secret string + Language string } -type deezerOptions struct { - Enabled bool - Language string - - // Computed values - Languages []string `conf:"-"` // Computed from Language, split by comma +type spotifyOptions struct { + ID string + Secret string } type listenBrainzOptions struct { - Enabled bool - BaseURL string - ArtistAlgorithm string - TrackAlgorithm string -} - -type jellyfinOptions struct { - Enabled bool - ServerName string - // ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated - // GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users. - ExposedPublicUsers string - AutoDiscovery bool - QuickConnect bool - // MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB - // cursor — and its pooled connection — for the whole client-paced response, so without a bound - // enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI. - MaxConcurrentStreams int -} - -type httpHeaderOptions struct { - FrameOptions string + Enabled bool + BaseURL string } type prometheusOptions struct { Enabled bool MetricsPath string - Password string //nolint:gosec -} - -type AudioDeviceDefinition []string - -type jukeboxOptions struct { - Enabled bool - Devices []AudioDeviceDefinition - Default string - AdminOnly bool -} - -type backupOptions struct { - Count int - Path Dir - Schedule string -} - -type pidOptions struct { - Track string - Album string -} - -type inspectOptions struct { - Enabled bool - MaxRequests int - BacklogLimit int - BacklogTimeout int -} - -type pluginsOptions struct { - Enabled bool - Folder Dir - CacheSize string - AutoReload bool - LogLevel string -} - -type extAuthOptions struct { - TrustedSources string - UserHeader string - LogoutURL string -} - -type searchOptions struct { - Backend string - 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 -} - -// TLSEnabled reports whether the server serves HTTPS. Both halves are required, -// so callers cannot infer it from the certificate alone. -func (c *configOptions) TLSEnabled() bool { - return c.TLSCert != "" && c.TLSKey != "" } var ( @@ -329,98 +134,40 @@ var ( hooks []func() ) -// SnapshotConfig returns a function that restores Server to its current state. -// Uses JSON round-tripping so Dir fields get fresh sync.Once values. -func SnapshotConfig() func() { - snapshot, err := json.Marshal(Server) - if err != nil { - panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err)) - } - return func() { - var restored configOptions - if err := json.Unmarshal(snapshot, &restored); err != nil { - panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err)) - } - Server = &restored - } -} - func LoadFromFile(confFile string) { viper.SetConfigFile(confFile) err := viper.ReadInConfig() if err != nil { - logFatal("Error reading config file:", err) + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err) + os.Exit(1) } - Load(true) + Load() } -func durationNonNegativeOrDefault(val *time.Duration, original time.Duration) { - if val.Nanoseconds() < 0 { - log.Warn("Duration is a negative value. Using default value", "value", *val, "default", original) - *val = original - } -} - -func Load(noConfigDump bool) { - parseIniFileConfiguration() - remapEnvVarKeysFromConfig() - - // Map deprecated options to their new names for backwards compatibility - for _, o := range deprecatedOptions { - if o.replacement != "" { - mapDeprecatedOption(o.name, o.replacement) - } - } - - err := viper.Unmarshal(&Server, viper.DecodeHook( - mapstructure.ComposeDecodeHookFunc( - mapstructure.TextUnmarshallerHookFunc(), - mapstructure.StringToTimeDurationHookFunc(), - mapstructure.StringToSliceHookFunc(","), - ), - )) +func Load() { + err := viper.Unmarshal(&Server) if err != nil { - logFatal("Error parsing config:", err) + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err) + os.Exit(1) + } + err = os.MkdirAll(Server.DataFolder, os.ModePerm) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", "path", Server.DataFolder, err) + os.Exit(1) } - // Validate non-root user early, before any filesystem operations - if err := validateEnforceNonRootUser(); err != nil { - logFatal(err) + if Server.CacheFolder == "" { + Server.CacheFolder = filepath.Join(Server.DataFolder, "cache") } - - if Server.CacheFolder.String() == "" { - Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache")) - } - - if Server.Plugins.Enabled { - if Server.Plugins.Folder.String() == "" { - Server.Plugins.Folder = NewDirWithPerm(filepath.Join(Server.DataFolder.String(), "plugins"), 0700) - } else { - Server.Plugins.Folder = NewDirWithPerm(Server.Plugins.Folder.String(), 0700) - } + err = os.MkdirAll(Server.CacheFolder, os.ModePerm) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", "path", Server.CacheFolder, err) + os.Exit(1) } Server.ConfigFile = viper.GetViper().ConfigFileUsed() if Server.DbPath == "" { - Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath) - } - - out := os.Stderr - if Server.LogFile != "" { - if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil { - logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error())) - } - out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - 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") != "" { - // When running under systemd, prepend syslog priority prefixes so - // journald assigns the correct severity to each log line. - // Note that we have an additional environment variable, as JOURNAL_STREAM - // can be present in a systemd environment even if not running as a systemd service - log.EnableJournalFormat() + Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath) } log.SetLevelString(Server.LogLevel) @@ -428,46 +175,15 @@ func Load(noConfigDump bool) { log.SetLogSourceLine(Server.DevLogSourceLine) log.SetRedacting(Server.EnableLogRedacting) - durationNonNegativeOrDefault(&Server.SessionTimeout, consts.DefaultSessionTimeout) - durationNonNegativeOrDefault(&Server.SmartPlaylistRefreshDelay, consts.DefaultSmartRefresh) - durationNonNegativeOrDefault(&Server.DefaultShareExpiration, consts.DefaultShareExpiration) - durationNonNegativeOrDefault(&Server.UIPlaybackReportInterval, consts.DefaultUIPlaybackReportInterval) - durationNonNegativeOrDefault(&Server.AuthWindowLength, consts.DefaultAuthWindowLength) - durationNonNegativeOrDefault(&Server.Scanner.WatcherWait, consts.DefaultWatcherWait) - - durationNonNegativeOrDefault(&Server.DevActivityPanelUpdateRate, consts.DefaultActivityPanelUpdateRate) - durationNonNegativeOrDefault(&Server.DevArtworkThrottleBacklogTimeout, consts.RequestThrottleBacklogTimeout) - durationNonNegativeOrDefault(&Server.DevArtistInfoTimeToLive, consts.ArtistInfoTimeToLive) - durationNonNegativeOrDefault(&Server.DevAlbumInfoTimeToLive, consts.AlbumInfoTimeToLive) - durationNonNegativeOrDefault(&Server.DevInsightsInitialDelay, consts.InsightsInitialDelay) - durationNonNegativeOrDefault(&Server.DevPluginCompilationTimeout, consts.DefaultPluginCompilationTimeout) - - // Log deprecated, removed and unknown options - for _, o := range deprecatedOptions { - logDeprecatedOptions(o.name, o.replacement) + if err := validateScanSchedule(); err != nil { + os.Exit(1) } - logRemovedOptions(removedOptions...) - logUnknownOptions() - - err = run.Sequentially( - validateScanSchedule, - validateBackupSchedule, - validatePlaylistsPath, - validatePurgeMissingOption, - validateByteSize("MaxImageUploadSize", Server.MaxImageUploadSize), - validateByteSize("MaxImageSize", Server.MaxImageSize), - validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL), - ) - if err != nil { - logFatal(err) - } - - Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend) if Server.BaseURL != "" { u, err := url.Parse(Server.BaseURL) if err != nil { - logFatal("Invalid BaseURL:", err) + _, _ = fmt.Fprintf(os.Stderr, "FATAL: Invalid BaseURL %s: %s\n", Server.BaseURL, err.Error()) + os.Exit(1) } Server.BasePath = u.Path u.Path = "" @@ -476,339 +192,29 @@ func Load(noConfigDump bool) { Server.BaseScheme = u.Scheme } - // Log configuration source - if Server.ConfigFile != "" { - log.Info("Loaded configuration", "file", Server.ConfigFile) - } else if hasNDEnvVars() { - log.Info("No configuration file found. Loaded configuration only from environment variables") - } else { - log.Warn("No configuration file found. Using default values. To specify a config file, use the --configfile flag or set the ND_CONFIGFILE environment variable.") - } - // Print current configuration if log level is Debug - if log.IsGreaterOrEqualTo(log.LevelDebug) && !noConfigDump { - prettyConf := pretty.Sprintf("Configuration: %# v", Server) + if log.CurrentLevel() >= log.LevelDebug { + prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server) if Server.EnableLogRedacting { prettyConf = log.Redact(prettyConf) } - _, _ = fmt.Fprintln(out, prettyConf) + _, _ = fmt.Fprintln(os.Stderr, prettyConf) } if !Server.EnableExternalServices { disableExternalServices() } - // Make sure we don't have empty PIDs - Server.PID.Album = cmp.Or(Server.PID.Album, consts.DefaultAlbumPID) - Server.PID.Track = cmp.Or(Server.PID.Track, consts.DefaultTrackPID) - - // Parse LastFM.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage) - Server.LastFM.Languages = parseLanguages(Server.LastFM.Language) - - // Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage) - Server.Deezer.Languages = parseLanguages(Server.Deezer.Language) - - // 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 - } - - if Server.Scanner.Extractor != consts.DefaultScannerExtractor { - log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor) - Server.Scanner.Extractor = consts.DefaultScannerExtractor - } - - // Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back. - imgSize, _ := humanize.ParseBytes(Server.MaxImageSize) - uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize) - if imgSize < uploadSize { - log.Warn("MaxImageSize must be at least MaxImageUploadSize, raising", "value", Server.MaxImageSize, "newValue", Server.MaxImageUploadSize) - Server.MaxImageSize = Server.MaxImageUploadSize - } - // Call init hooks for _, hook := range hooks { hook() } } -// deprecatedOptions still work, but will be removed in a future release. An empty -// replacement means the option is now ignored. -var deprecatedOptions = []struct{ name, replacement string }{ - {"Scanner.GenreSeparators", ""}, - {"Scanner.GroupAlbumReleases", ""}, - {"DevEnableBufferedScrobble", ""}, - {"SearchFullString", "Search.FullString"}, - {"ReverseProxyWhitelist", "ExtAuth.TrustedSources"}, - {"ReverseProxyUserHeader", "ExtAuth.UserHeader"}, - {"HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions"}, - {"CoverJpegQuality", "CoverArtQuality"}, - {"SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold"}, - {"EnableTranscodingCancellation", "Transcoding.EnableCancellation"}, -} - -var removedOptions = []string{"Spotify.ID", "Spotify.Secret"} - -func logDeprecatedOptions(oldName, newName string) { - envVar := envVarName(oldName) - newEnvVar := envVarName(newName) - logWarning := func(oldName, newName string) { - if newName != "" { - log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName)) - } else { - log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", oldName)) - } - } - if os.Getenv(envVar) != "" { - logWarning(envVar, newEnvVar) - } - if viper.InConfig(oldName) { - logWarning(oldName, newName) - } -} - -// logRemovedOptions checks if the option is set, and if yes, outputs a warning message saying the option is -// not available anymore -func logRemovedOptions(options ...string) { - for _, option := range options { - envVar := envVarName(option) - logWarning := func(option string) { - log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option)) - } - if viper.InConfig(option) { - logWarning(option) - } - if os.Getenv(envVar) != "" { - logWarning(envVar) - } - } -} - -// 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 := ndKeyToCanonical(key) - displayNDKey := "ND_" + strings.ToUpper(stripped) - canonicalName := canonicalOptionName(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, cmp.Or(canonicalName, toPascalCase(canonicalKey)), - )) - return - } - - viper.Set(canonicalKey, viper.Get(key)) - // Unknown keys get no advice here, logUnknownOptions reports them instead - if canonicalName != "" { - _, _ = 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, canonicalName, - ) - } - } -} - -// 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) { - // viper.Set outranks the config file, so an explicit replacement must win over the legacy value - if viper.IsSet(legacyName) && !explicitlySet(newName) { - viper.Set(newName, viper.Get(legacyName)) - } -} - -// explicitlySet reports whether the user provided the option, ignoring defaults, -// which viper.IsSet counts as set. The ND_ spelling is also accepted in the config -// file, and remapEnvVarKeysFromConfig has already moved it out of InConfig's reach. -func explicitlySet(name string) bool { - envVar := envVarName(name) - return viper.InConfig(name) || os.Getenv(envVar) != "" || viper.InConfig(strings.ToLower(envVar)) -} - -func envVarName(option string) string { - if option == "" { - return "" - } - return "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_")) -} - -func logUnknownOptions() { - for _, key := range unknownConfigKeys() { - msg := fmt.Sprintf("Option '%s' is not recognized and will be ignored", key) - if matches := suggestOptions(key); len(matches) > 0 { - msg += fmt.Sprintf(". Did you mean '%s'?", strings.Join(matches, "' or '")) - } - log.Warn(msg) - } -} - -// suggestOptions returns the known options sharing the last segment with key, -// catching options written outside their section. -func suggestOptions(key string) []string { - key = strings.ToLower(key) - leaf := leafKey(key) - canonical, _ := configKeys() - var matches []string - for known, name := range canonical { - // Removed options are known only so they get their own warning, never suggest them - if known != key && leafKey(known) == leaf && !slices.Contains(removedOptions, name) { - matches = append(matches, name) - } - } - slices.Sort(matches) - return matches -} - -func leafKey(key string) string { - return key[strings.LastIndex(key, ".")+1:] -} - -// unknownConfigKeys returns config file keys that don't match any known option, so -// typos and options written outside their section don't fail silently. -func unknownConfigKeys() []string { - // INI files keep the original [default] section alongside the merged one - skipDefault := strings.EqualFold(filepath.Ext(viper.ConfigFileUsed()), ".ini") - - var unknown []string - for _, key := range viper.AllKeys() { - if !viper.InConfig(key) || canonicalOptionName(key) != "" { - continue - } - if skipDefault && strings.HasPrefix(key, "default.") { - continue - } - // Only ND_-prefixed keys that remapEnvVarKeysFromConfig could resolve are valid - if strings.HasPrefix(key, "nd_") && canonicalOptionName(ndKeyToCanonical(key)) != "" { - continue - } - unknown = append(unknown, key) - } - slices.Sort(unknown) - return asWrittenInConfigFile(unknown) -} - -func ndKeyToCanonical(key string) string { - return strings.ReplaceAll(strings.TrimPrefix(key, "nd_"), "_", ".") -} - -// canonicalOptionName returns the documented spelling of a known option key, or "" -// if it matches no option. Subkeys of free-form maps have no fixed spelling. -func canonicalOptionName(key string) string { - keys, prefixes := configKeys() - if name, ok := keys[key]; ok { - return name - } - if slices.ContainsFunc(prefixes, func(p string) bool { return strings.HasPrefix(key, p) }) { - return toPascalCase(key) - } - return "" -} - -// asWrittenInConfigFile restores the casing the keys have in the config file, as -// viper lowercases every key it loads. -func asWrittenInConfigFile(keys []string) []string { - if len(keys) == 0 { - return nil - } - data, err := os.ReadFile(viper.ConfigFileUsed()) - if err != nil { - return keys - } - casing := map[string]string{} - for _, match := range configFileKeyRx.FindAllStringSubmatch(string(data), -1) { - for segment := range strings.SplitSeq(match[1], ".") { - lower := strings.ToLower(segment) - casing[lower] = cmp.Or(casing[lower], segment) - } - } - return slice.Map(keys, func(key string) string { - segments := strings.Split(key, ".") - for i, s := range segments { - segments[i] = cmp.Or(casing[s], s) - } - return strings.Join(segments, ".") - }) -} - -// Matches keys and section headers in all supported config formats. -var configFileKeyRx = regexp.MustCompile(`(?m)^\s*\[?\s*"?([\w.]+)"?\s*[]=:]`) - -// configKeys maps every accepted option name, lowercased, to its canonical spelling, -// plus the prefixes of free-form map options (Tags, DevLogLevels). -var configKeys = sync.OnceValues(func() (map[string]string, []string) { - keys := map[string]string{} - var prefixes []string - - var collect func(t reflect.Type, prefix string) - collect = func(t reflect.Type, prefix string) { - for field := range t.Fields() { - // `conf:"-"` marks values computed during Load, not settable in the config - if !field.IsExported() || field.Tag.Get("conf") == "-" { - continue - } - name := prefix + field.Name - if field.Type.Kind() == reflect.Struct && !reflect.PointerTo(field.Type).Implements(textUnmarshalerType) { - collect(field.Type, name+".") - continue - } - lower := strings.ToLower(name) - keys[lower] = name - if field.Type.Kind() == reflect.Map { - prefixes = append(prefixes, lower+".") - } - } - } - collect(reflect.TypeFor[configOptions](), "") - - for _, o := range deprecatedOptions { - keys[strings.ToLower(o.name)] = o.name - } - for _, o := range removedOptions { - keys[strings.ToLower(o)] = o - } - return keys, prefixes -}) - -var textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() - -// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it -// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default] -// section into the root level. -func parseIniFileConfiguration() { - cfgFile := viper.ConfigFileUsed() - if strings.ToLower(filepath.Ext(cfgFile)) == ".ini" { - var iniConfig map[string]any - err := viper.Unmarshal(&iniConfig) - if err != nil { - logFatal("Error parsing config:", err) - } - cfg, ok := iniConfig["default"].(map[string]any) - if !ok { - logFatal("Error parsing config: missing [default] section:", iniConfig) - } - err = viper.MergeConfigMap(cfg) - if err != nil { - logFatal("Error parsing config:", err) - } - } -} - func disableExternalServices() { log.Info("All external integrations are DISABLED!") - Server.EnableInsightsCollector = false - Server.EnableM3UExternalAlbumArt = false Server.LastFM.Enabled = false - Server.Deezer.Enabled = false + Server.Spotify.ID = "" Server.ListenBrainz.Enabled = false Server.Agents = "" if Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL { @@ -816,144 +222,33 @@ func disableExternalServices() { } } -func validatePlaylistsPath() error { - for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) { - _, err := doublestar.Match(path, "") - if err != nil { - return fmt.Errorf("invalid PlaylistsPath %q: %w", path, err) - } - } - return nil -} - -// parseLanguages parses a comma-separated language string into a slice. -// It trims whitespace from each entry and ensures at least [DefaultInfoLanguage] is returned. -func parseLanguages(lang string) []string { - var languages []string - for l := range strings.SplitSeq(lang, ",") { - l = strings.TrimSpace(l) - if l != "" { - languages = append(languages, l) - } - } - if len(languages) == 0 { - return []string{consts.DefaultInfoLanguage} - } - return languages -} - -func validatePurgeMissingOption() error { - allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull} - valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing) - if !valid { - err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues) - Server.Scanner.PurgeMissing = consts.PurgeMissingNever - return err - } - return nil -} - -func validateByteSize(name, value string) func() error { - return func() error { - size, err := humanize.ParseBytes(value) - if err != nil { - return fmt.Errorf("invalid %s %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", name, value, err) - } - if size == 0 { - return fmt.Errorf("invalid %s %q: must be greater than zero", name, value) - } - if size > math.MaxInt64 { - return fmt.Errorf("invalid %s %q: value is too large", name, value) - } - 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 = "" + if Server.ScanInterval != -1 { + log.Warn("ScanInterval is DEPRECATED. Please use ScanSchedule. See docs at https://navidrome.org/docs/usage/configuration-options/") + if Server.ScanSchedule != "@every 1m" { + log.Error("You cannot specify both ScanInterval and ScanSchedule, ignoring ScanInterval") + } else { + if Server.ScanInterval == 0 { + Server.ScanSchedule = "" + } else { + Server.ScanSchedule = fmt.Sprintf("@every %s", Server.ScanInterval) + } + log.Warn("Setting ScanSchedule", "schedule", Server.ScanSchedule) + } + } + if Server.ScanSchedule == "0" || Server.ScanSchedule == "" { + Server.ScanSchedule = "" return nil } - var err error - Server.Scanner.Schedule, err = validateSchedule(Server.Scanner.Schedule, "Scanner.Schedule") - return err -} - -func validateBackupSchedule() error { - if Server.Backup.Path.String() == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 { - Server.Backup.Schedule = "" - return nil + if _, err := time.ParseDuration(Server.ScanSchedule); err == nil { + Server.ScanSchedule = "@every " + Server.ScanSchedule } - var err error - Server.Backup.Schedule, err = validateSchedule(Server.Backup.Schedule, "Backup.Schedule") - return err -} - -func validateSchedule(schedule, field string) (string, error) { - _, err := scheduler.ParseCrontab(schedule) + c := cron.New() + _, err := c.AddFunc(Server.ScanSchedule, func() {}) if err != nil { - return schedule, fmt.Errorf("invalid %s %q (see https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format): %w", field, schedule, err) + log.Error("Invalid ScanSchedule. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", "schedule", Server.ScanSchedule, err) } - return schedule, nil -} - -// validateURL checks if the provided URL is valid and has either http or https scheme. -// It returns a function that can be used as a hook to validate URLs in the config. -func validateURL(optionName, optionURL string) func() error { - return func() error { - if optionURL == "" { - return nil - } - u, err := url.Parse(optionURL) - if err != nil { - return fmt.Errorf("invalid %s %q: %w", optionName, optionURL, err) - } - if u.Scheme != "http" && u.Scheme != "https" { - return fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme) - } - if u.Host == "" || u.Opaque != "" { - return fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL) - } - return nil - } -} - -func normalizeSearchBackend(value string) string { - v := strings.ToLower(strings.TrimSpace(value)) - switch v { - case "fts", "legacy": - return v - default: - log.Error("Invalid Search.Backend value, falling back to 'fts'", "value", value) - return "fts" - } -} - -// 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, ".") + return err } // AddHook is used to register initialization code that should run as soon as the config is loaded @@ -961,27 +256,16 @@ func AddHook(hook func()) { hooks = append(hooks, hook) } -// hasNDEnvVars checks if any ND_ prefixed environment variables are set (excluding ND_CONFIGFILE) -func hasNDEnvVars() bool { - for _, env := range os.Environ() { - if strings.HasPrefix(env, "ND_") && !strings.HasPrefix(env, "ND_CONFIGFILE=") { - return true - } - } - return false -} - -func setViperDefaults() { +func init() { viper.SetDefault("musicfolder", filepath.Join(".", "music")) viper.SetDefault("cachefolder", "") viper.SetDefault("datafolder", ".") viper.SetDefault("loglevel", "info") - viper.SetDefault("logfile", "") 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("scaninterval", -1) + viper.SetDefault("scanschedule", "@every 1m") viper.SetDefault("baseurl", "") viper.SetDefault("tlscert", "") viper.SetDefault("tlskey", "") @@ -991,37 +275,23 @@ func setViperDefaults() { viper.SetDefault("enabletranscodingconfig", false) viper.SetDefault("transcodingcachesize", "100MB") viper.SetDefault("imagecachesize", "100MB") - viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute) viper.SetDefault("enableartworkprecache", true) viper.SetDefault("autoimportplaylists", true) - viper.SetDefault("defaultplaylistpublicvisibility", false) - viper.SetDefault("playlistspath", "") - viper.SetDefault("smartPlaylistRefreshDelay", consts.DefaultSmartRefresh) + viper.SetDefault("playlistspath", consts.DefaultPlaylistsPath) viper.SetDefault("enabledownloads", true) viper.SetDefault("enableexternalservices", true) - viper.SetDefault("enablem3uexternalalbumart", false) - viper.SetDefault("enablemediafilecoverart", true) + viper.SetDefault("enableMediaFileCoverArt", true) viper.SetDefault("autotranscodedownload", false) viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat) - viper.SetDefault("search.fullstring", false) - viper.SetDefault("search.backend", "fts") - viper.SetDefault("matcher.preferstarred", true) - viper.SetDefault("matcher.fuzzythreshold", 85) + viper.SetDefault("searchfullstring", false) viper.SetDefault("recentlyaddedbymodtime", false) - viper.SetDefault("prefersorttags", false) - viper.SetDefault("enablenaturalsorting", false) viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A") viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)") + viper.SetDefault("subsonicartistparticipations", false) 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("coverjpegquality", 75) viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") - viper.SetDefault("artistimagefolder", "") - viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded") - viper.SetDefault("lyricspriority", ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded") viper.SetDefault("enablegravatar", false) viper.SetDefault("enablefavourites", true) viper.SetDefault("enablestarrating", true) @@ -1029,90 +299,33 @@ func setViperDefaults() { viper.SetDefault("defaulttheme", "Dark") 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("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval) - viper.SetDefault("enableartworkupload", true) - viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize) - viper.SetDefault("maximagesize", consts.DefaultMaxImageSize) - viper.SetDefault("enablesharing", true) - viper.SetDefault("shareurl", "") - viper.SetDefault("defaultshareexpiration", consts.DefaultShareExpiration) - viper.SetDefault("defaultdownloadableshare", false) viper.SetDefault("gatrackingid", "") - viper.SetDefault("enableinsightscollector", true) - viper.SetDefault("enablescheduleddbanalyze", true) viper.SetDefault("enablelogredacting", true) viper.SetDefault("authrequestlimit", 5) - viper.SetDefault("authwindowlength", consts.DefaultAuthWindowLength) + viper.SetDefault("authwindowlength", 20*time.Second) viper.SetDefault("passwordencryptionkey", "") - viper.SetDefault("extauth.userheader", "Remote-User") - viper.SetDefault("extauth.trustedsources", "") - viper.SetDefault("extauth.logouturl", "") + + viper.SetDefault("reverseproxyuserheader", "Remote-User") + viper.SetDefault("reverseproxywhitelist", "") + viper.SetDefault("prometheus.enabled", false) - viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath) - viper.SetDefault("prometheus.password", "") - viper.SetDefault("jukebox.enabled", false) - viper.SetDefault("jukebox.devices", []AudioDeviceDefinition{}) - viper.SetDefault("jukebox.default", "") - viper.SetDefault("jukebox.adminonly", true) - viper.SetDefault("scanner.enabled", true) - viper.SetDefault("scanner.schedule", "0") + viper.SetDefault("prometheus.metricspath", "/metrics") + viper.SetDefault("scanner.extractor", consts.DefaultScannerExtractor) - viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait) - viper.SetDefault("scanner.scanonstartup", true) - viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner) - viper.SetDefault("scanner.artistsplitexceptions", []string{}) - viper.SetDefault("scanner.genreseparators", "") + viper.SetDefault("scanner.genreseparators", ";/,") viper.SetDefault("scanner.groupalbumreleases", false) - viper.SetDefault("scanner.followsymlinks", true) - viper.SetDefault("scanner.ignoredotfolders", true) - viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever) - viper.SetDefault("subsonic.appendsubtitle", true) - viper.SetDefault("subsonic.appendalbumversion", true) - viper.SetDefault("subsonic.artistparticipations", false) - viper.SetDefault("subsonic.defaultreportrealpath", false) - viper.SetDefault("subsonic.enableaveragerating", true) - viper.SetDefault("subsonic.legacyclients", "DSub") - viper.SetDefault("subsonic.minimalclients", "SubMusic") - viper.SetDefault("transcoding.maxconcurrent", 0) - viper.SetDefault("transcoding.maxconcurrentperuser", 0) - viper.SetDefault("transcoding.enablecancellation", false) - viper.SetDefault("agents", "deezer,lastfm,listenbrainz") + + viper.SetDefault("agents", "lastfm,spotify") viper.SetDefault("lastfm.enabled", true) - viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage) - viper.SetDefault("lastfm.apikey", "") - viper.SetDefault("lastfm.secret", "") - viper.SetDefault("lastfm.scrobblefirstartistonly", false) - viper.SetDefault("deezer.enabled", true) - viper.SetDefault("deezer.language", consts.DefaultInfoLanguage) + viper.SetDefault("lastfm.language", "en") + viper.SetDefault("lastfm.apikey", consts.LastFMAPIKey) + viper.SetDefault("lastfm.secret", consts.LastFMAPISecret) + viper.SetDefault("spotify.id", "") + viper.SetDefault("spotify.secret", "") viper.SetDefault("listenbrainz.enabled", true) - viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL) - viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm) - viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm) - viper.SetDefault("jellyfin.enabled", false) - viper.SetDefault("jellyfin.servername", "") - viper.SetDefault("jellyfin.autodiscovery", false) - viper.SetDefault("jellyfin.quickconnect", true) - viper.SetDefault("enablescrobblehistory", true) - viper.SetDefault("httpheaders.frameoptions", "DENY") - viper.SetDefault("backup.path", "") - viper.SetDefault("backup.schedule", "") - viper.SetDefault("backup.count", 0) - viper.SetDefault("pid.track", consts.DefaultTrackPID) - viper.SetDefault("pid.album", consts.DefaultAlbumPID) - viper.SetDefault("inspect.enabled", true) - viper.SetDefault("inspect.maxrequests", 1) - viper.SetDefault("inspect.backloglimit", consts.RequestThrottleBacklogLimit) - viper.SetDefault("inspect.backlogtimeout", consts.RequestThrottleBacklogTimeout) - viper.SetDefault("plugins.folder", "") - viper.SetDefault("plugins.enabled", true) - viper.SetDefault("plugins.cachesize", "200MB") - viper.SetDefault("plugins.autoreload", false) - viper.SetDefault("plugins.loglevel", "") + viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/") // DevFlags. These are used to enable/disable debugging and incomplete features viper.SetDefault("devlogsourceline", false) @@ -1120,54 +333,19 @@ func setViperDefaults() { viper.SetDefault("devautocreateadminpassword", "") viper.SetDefault("devautologinusername", "") viper.SetDefault("devactivitypanel", true) - viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond) + viper.SetDefault("enablesharing", false) + viper.SetDefault("defaultdownloadableshare", false) + viper.SetDefault("devenablebufferedscrobble", true) viper.SetDefault("devsidebarplaylists", true) viper.SetDefault("devshowartistpage", true) - viper.SetDefault("devuishowconfig", true) - viper.SetDefault("devneweventstream", true) - viper.SetDefault("devapiv1", false) - viper.SetDefault("devoffsetoptimize", 50000) - // Half the pool: streams may take up to this many connections, leaving the rest for the scanner, - // scrobbles and the UI. See MaxOpenConns. - viper.SetDefault("jellyfin.maxconcurrentstreams", max(2, MaxOpenConns()/2)) - viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2)) + viper.SetDefault("devartworkmaxrequests", number.Max(2, runtime.NumCPU()/3)) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) - viper.SetDefault("devartworkthrottlebuffered", true) - // Half the CPU count (min 2), so local resolution scales with the host but stays under the - // SQLite pool (MaxOpenConns) — leaving connections for the scanner, scrobbles and the UI. - viper.SetDefault("devartworkworkerconcurrency", max(2, runtime.NumCPU()/2)) - // External RPS gates outbound calls to third-party services (per service); it is bounded by - // their tolerance, not the host, so it stays a small constant regardless of CPU count. - viper.SetDefault("devartworkexternalmaxrps", 2) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive) - viper.SetDefault("devexternalscanner", true) - viper.SetDefault("devscannerthreads", 5) - viper.SetDefault("devselectivewatcher", true) - viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay) - viper.SetDefault("devenableplayerinsights", true) - viper.SetDefault("devenablepluginsinsights", true) - viper.SetDefault("devplugincompilationtimeout", time.Minute) - viper.SetDefault("devexternalartistfetchmultiplier", 1.5) - viper.SetDefault("devpreserveunicodeinexternalcalls", false) - viper.SetDefault("devenablemediafileprobe", true) } -func init() { - setViperDefaults() -} - -func InitConfig(cfgFile string, loadEnvVars bool) { - codecRegistry := viper.NewCodecRegistry() - _ = codecRegistry.RegisterCodec("ini", ini.Codec{ - LoadOptions: ini.LoadOptions{ - UnescapeValueDoubleQuotes: true, - UnescapeValueCommentSymbols: true, - }, - }) - viper.SetOptions(viper.WithCodecRegistry(codecRegistry)) - +func InitConfig(cfgFile string) { cfgFile = getConfigFile(cfgFile) if cfgFile != "" { // Use config file from the flag. @@ -1179,41 +357,21 @@ func InitConfig(cfgFile string, loadEnvVars bool) { } _ = viper.BindEnv("port") - if loadEnvVars { - viper.SetEnvPrefix("ND") - replacer := strings.NewReplacer(".", "_") - viper.SetEnvKeyReplacer(replacer) - viper.AutomaticEnv() - } + viper.SetEnvPrefix("ND") + replacer := strings.NewReplacer(".", "_") + viper.SetEnvKeyReplacer(replacer) + viper.AutomaticEnv() err := viper.ReadInConfig() if viper.ConfigFileUsed() != "" && err != nil { - logFatal("Navidrome could not open config file:", err) + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err) + os.Exit(1) } } -// getConfigFile returns the path to the config file, either from the flag or from the environment variable. -// If it is defined in the environment variable, it will check if the file exists. func getConfigFile(cfgFile string) string { if cfgFile != "" { return cfgFile } - cfgFile = os.Getenv("ND_CONFIGFILE") - if cfgFile != "" { - if _, err := os.Stat(cfgFile); err == nil { //nolint:gosec - return cfgFile - } - } - return "" -} - -// MaxOpenConns is the size of the shared SQLite connection pool, used by every subsystem (scanner, -// Subsonic, Jellyfin, native API, UI). -// -// It bounds concurrent *readers*: SQLite serializes writers on a single database-wide write lock, so -// more connections buy no write parallelism. A connection is held while blocked on disk I/O or on a -// slow HTTP client, neither of which is CPU-bound — the CPU-bound knob is DevScannerThreads — so the -// count is only loosely related to core count, and the floor is what matters on small machines. -func MaxOpenConns() int { - return max(4, runtime.NumCPU()) + return os.Getenv("ND_CONFIGFILE") } diff --git a/conf/configuration_test.go b/conf/configuration_test.go deleted file mode 100644 index 8c4c8ab86..000000000 --- a/conf/configuration_test.go +++ /dev/null @@ -1,564 +0,0 @@ -package conf_test - -import ( - "bytes" - "fmt" - "os" - "path/filepath" - "runtime" - "testing" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/spf13/viper" -) - -func TestConfiguration(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Configuration Suite") -} - -var _ = Describe("Configuration", func() { - BeforeEach(func() { - // Reset viper configuration - viper.Reset() - conf.SetViperDefaults() - 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() { - It("parses single language", func() { - Expect(conf.ParseLanguages("en")).To(Equal([]string{"en"})) - }) - - It("parses multiple comma-separated languages", func() { - Expect(conf.ParseLanguages("pt,en")).To(Equal([]string{"pt", "en"})) - }) - - It("trims whitespace from languages", func() { - Expect(conf.ParseLanguages(" pt , en ")).To(Equal([]string{"pt", "en"})) - }) - - It("returns default 'en' when empty", func() { - Expect(conf.ParseLanguages("")).To(Equal([]string{"en"})) - }) - - It("returns default 'en' when only whitespace", func() { - Expect(conf.ParseLanguages(" ")).To(Equal([]string{"en"})) - }) - - It("handles multiple languages with various spacing", func() { - Expect(conf.ParseLanguages("ja, pt, en")).To(Equal([]string{"ja", "pt", "en"})) - }) - }) - - Describe("scheduled DB analysis", func() { - It("is enabled by default", func() { - conf.Load(true) - Expect(conf.Server.EnableScheduledDBAnalyze).To(BeTrue()) - }) - - It("can be disabled", func() { - viper.Set("enablescheduleddbanalyze", false) - conf.Load(true) - Expect(conf.Server.EnableScheduledDBAnalyze).To(BeFalse()) - }) - }) - - Describe("ValidateURL", func() { - It("accepts a valid http URL", func() { - fn := conf.ValidateURL("TestOption", "http://example.com/path") - Expect(fn()).To(Succeed()) - }) - - It("accepts a valid https URL", func() { - fn := conf.ValidateURL("TestOption", "https://example.com/path") - Expect(fn()).To(Succeed()) - }) - - It("rejects a URL with no scheme", func() { - fn := conf.ValidateURL("TestOption", "example.com/path") - Expect(fn()).To(MatchError(ContainSubstring("invalid scheme"))) - }) - - It("rejects a URL with an unsupported scheme", func() { - fn := conf.ValidateURL("TestOption", "javascript://example.com/path") - Expect(fn()).To(MatchError(ContainSubstring("invalid scheme"))) - }) - - It("accepts an empty URL (optional config)", func() { - fn := conf.ValidateURL("TestOption", "") - Expect(fn()).To(Succeed()) - }) - - It("includes the option name in the error message", func() { - fn := conf.ValidateURL("MyOption", "ftp://example.com") - Expect(fn()).To(MatchError(ContainSubstring("MyOption"))) - }) - - It("rejects a URL that cannot be parsed", func() { - fn := conf.ValidateURL("TestOption", "://invalid") - Expect(fn()).To(HaveOccurred()) - }) - - It("rejects a URL without a host", func() { - fn := conf.ValidateURL("TestOption", "http:///path") - Expect(fn()).To(MatchError(ContainSubstring("non-empty host is required"))) - }) - }) - - DescribeTable("NormalizeSearchBackend", - func(input, expected string) { - Expect(conf.NormalizeSearchBackend(input)).To(Equal(expected)) - }, - Entry("accepts 'fts'", "fts", "fts"), - Entry("accepts 'legacy'", "legacy", "legacy"), - Entry("normalizes 'FTS' to lowercase", "FTS", "fts"), - Entry("normalizes 'Legacy' to lowercase", "Legacy", "legacy"), - Entry("trims whitespace", " fts ", "fts"), - Entry("falls back to 'fts' for 'fts5'", "fts5", "fts"), - Entry("falls back to 'fts' for unrecognized values", "invalid", "fts"), - Entry("falls back to 'fts' for empty string", "", "fts"), - ) - - DescribeTable("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("unknownConfigKeys", func() { - BeforeEach(func() { - viper.Reset() - conf.SetViperDefaults() - viper.SetDefault("datafolder", GinkgoT().TempDir()) - viper.SetDefault("loglevel", "error") - conf.ResetConf() - }) - - It("reports misplaced and misspelled options, as spelled in the config file", func() { - conf.InitConfig(filepath.Join("testdata", "cfg_unknown_keys.toml"), false) - conf.Load(true) - - Expect(conf.UnknownConfigKeys()).To(ConsistOf( - "ArtistSplitExceptions", "EnableDownlods", "Whatever.Foo", - )) - }) - - DescribeTable("recovers the original casing in all supported formats", - func(file string) { - conf.InitConfig(filepath.Join("testdata", file), false) - conf.Load(true) - - Expect(conf.UnknownConfigKeys()).To(ConsistOf("NotAnOption")) - }, - Entry("TOML", "cfg_unknown_casing.toml"), - Entry("YAML", "cfg_unknown_casing.yaml"), - Entry("JSON", "cfg_unknown_casing.json"), - Entry("INI", "cfg_unknown_casing.ini"), - ) - - It("does not report valid, deprecated or free-form keys", func() { - conf.InitConfig(filepath.Join("testdata", "cfg.toml"), false) - conf.Load(true) - - Expect(conf.UnknownConfigKeys()).To(BeEmpty()) - }) - - It("does not report the [default] section of INI files", func() { - conf.InitConfig(filepath.Join("testdata", "cfg.ini"), false) - conf.Load(true) - - Expect(conf.UnknownConfigKeys()).To(BeEmpty()) - }) - - DescribeTable("SuggestOptions", - func(key string, expected []string) { - Expect(conf.SuggestOptions(key)).To(Equal(expected)) - }, - Entry("suggests the section of a misplaced option", "artistsplitexceptions", - []string{"Scanner.ArtistSplitExceptions"}), - Entry("suggests the section of a misplaced nested option", "backup.fuzzythreshold", - []string{"Matcher.FuzzyThreshold"}), - Entry("suggests every section defining the option", "schedule", - []string{"Backup.Schedule", "Scanner.Schedule"}), - Entry("suggests nothing for a typo", "enabledownlods", nil), - ) - - It("does not report ND_-prefixed keys, as they are remapped", func() { - conf.InitConfig(filepath.Join("testdata", "cfg_nd_keys.toml"), false) - conf.Load(true) - - Expect(conf.UnknownConfigKeys()).To(BeEmpty()) - }) - - It("reports ND_-prefixed keys that remap to no known option", func() { - conf.InitConfig(filepath.Join("testdata", "cfg_nd_bogus.toml"), false) - conf.Load(true) - - Expect(conf.UnknownConfigKeys()).To(ConsistOf("ND_TOTALLY_BOGUS_OPTION")) - Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h")) - }) - - It("migrates every deprecated option that has a replacement", func() { - conf.InitConfig(filepath.Join("testdata", "cfg_deprecated_search.toml"), false) - conf.Load(true) - - Expect(conf.Server.Search.FullString).To(BeTrue()) - Expect(conf.UnknownConfigKeys()).To(BeEmpty()) - }) - - It("warns about each unrecognized option at startup", func() { - var logBuf bytes.Buffer - log.SetOutput(&logBuf) - DeferCleanup(func() { log.SetOutput(GinkgoWriter) }) - - conf.InitConfig(filepath.Join("testdata", "cfg_warning_output.toml"), false) - conf.Load(true) - - Expect(logBuf.String()).To(ContainSubstring( - "Option 'ArtistSplitExceptions' is not recognized and will be ignored. " + - "Did you mean 'Scanner.ArtistSplitExceptions'?")) - Expect(logBuf.String()).To(ContainSubstring( - "Option 'EnableDownlods' is not recognized and will be ignored")) - Expect(logBuf.String()).ToNot(ContainSubstring("ArtistJoiner")) - }) - - Context("with runtime-computed and removed options in the config", func() { - BeforeEach(func() { - conf.InitConfig(filepath.Join("testdata", "cfg_runtime_fields.toml"), false) - conf.Load(true) - }) - - It("reports values computed during Load, which the config cannot set", func() { - Expect(conf.UnknownConfigKeys()).To(ContainElements("ConfigFile", "LastFM.Languages")) - }) - - It("never suggests a removed option", func() { - Expect(conf.SuggestOptions("id")).To(BeEmpty()) - }) - - It("keeps an explicit replacement over the deprecated value", func() { - Expect(conf.Server.Search.FullString).To(BeFalse()) - }) - }) - }) - - 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 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 creating log file directory"))) - }) - - It("creates the log file readable only by the owner", func() { - if runtime.GOOS == "windows" { - Skip("file modes are not enforced on Windows") - } - logFile := filepath.Join(GinkgoT().TempDir(), "navidrome.log") - viper.SetDefault("datafolder", GinkgoT().TempDir()) - viper.SetDefault("logfile", logFile) - DeferCleanup(log.SetOutput, os.Stderr) - conf.Load(true) - - info, err := os.Stat(logFile) - Expect(err).ToNot(HaveOccurred()) - Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600))) - }) - - It("is called when BaseURL is invalid", func() { - viper.SetDefault("datafolder", GinkgoT().TempDir()) - viper.SetDefault("baseurl", "://invalid") - Expect(func() { - conf.Load(true) - }).To(PanicWith(ContainSubstring("Invalid BaseURL"))) - }) - - }) - - Describe("ValidateByteSize", func() { - DescribeTable("accepts valid size values", - func(input string) { - Expect(conf.ValidateByteSize("MaxImageSize", input)()).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) { - Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(MatchError(ContainSubstring("invalid MaxImageSize"))) - }, - Entry("garbage string", "not-a-size"), - Entry("negative-looking", "-10MB"), - Entry("zero", "0"), - Entry("zero with unit", "0MB"), - Entry("overflows int64", "9223372036854775808"), - ) - }) - - Describe("MaxImageSize floor", func() { - BeforeEach(func() { - viper.Reset() - conf.SetViperDefaults() - viper.SetDefault("datafolder", GinkgoT().TempDir()) - viper.SetDefault("loglevel", "error") - conf.ResetConf() - }) - - It("is raised to MaxImageUploadSize when configured lower", func() { - viper.SetDefault("maximagesize", "5MB") - viper.SetDefault("maximageuploadsize", "50MB") - conf.Load(true) - Expect(conf.Server.MaxImageSize).To(Equal("50MB")) - }) - - It("keeps a larger MaxImageSize unchanged", func() { - viper.SetDefault("maximagesize", "30MB") - conf.Load(true) - Expect(conf.Server.MaxImageSize).To(Equal("30MB")) - }) - }) - - Describe("Scanner.Extractor", func() { - BeforeEach(func() { - viper.Reset() - conf.SetViperDefaults() - viper.SetDefault("datafolder", GinkgoT().TempDir()) - viper.SetDefault("loglevel", "error") - conf.ResetConf() - }) - - It("falls back to taglib for an unknown extractor", func() { - viper.SetDefault("scanner.extractor", "ffmpeg") - conf.Load(true) - Expect(conf.Server.Scanner.Extractor).To(Equal("taglib")) - }) - - It("keeps taglib", func() { - conf.Load(true) - Expect(conf.Server.Scanner.Extractor).To(Equal("taglib")) - }) - }) - - 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) - - // Initialize config with the test file - conf.InitConfig(filename, false) - // Load the configuration (with noConfigDump=true) - conf.Load(true) - - // Execute the format-specific assertions - Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format))) - Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format)) - Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"})) - Expect(conf.Server.Tags["artist"].Split).To(Equal([]string{";"})) - - // Check deprecated option mapping - Expect(conf.Server.ExtAuth.UserHeader).To(Equal("X-Auth-User")) - - // The config file used should be the one we created - Expect(conf.Server.ConfigFile).To(Equal(filename)) - }, - Entry("TOML format", "toml"), - Entry("YAML format", "yaml"), - Entry("INI format", "ini"), - Entry("JSON format", "json"), - ) - - It("should use default values for negative duration fields", func() { - filename := filepath.Join("testdata", "invalid_duration.toml") - conf.InitConfig(filename, false) - conf.Load(true) - - server := conf.Server - Expect(server.SessionTimeout).To(Equal(consts.DefaultSessionTimeout)) - Expect(server.SmartPlaylistRefreshDelay).To(Equal(consts.DefaultSmartRefresh)) - Expect(server.DefaultShareExpiration).To(Equal(consts.DefaultShareExpiration)) - Expect(server.UIPlaybackReportInterval).To(Equal(consts.DefaultUIPlaybackReportInterval)) - Expect(server.AuthWindowLength).To(Equal(consts.DefaultAuthWindowLength)) - Expect(server.Scanner.WatcherWait).To(Equal(consts.DefaultWatcherWait)) - - Expect(server.DevActivityPanelUpdateRate).To(Equal(consts.DefaultActivityPanelUpdateRate)) - Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(consts.RequestThrottleBacklogTimeout)) - Expect(server.DevArtistInfoTimeToLive).To(Equal(consts.ArtistInfoTimeToLive)) - Expect(server.DevAlbumInfoTimeToLive).To(Equal(consts.AlbumInfoTimeToLive)) - Expect(server.DevInsightsInitialDelay).To(Equal(consts.InsightsInitialDelay)) - Expect(server.DevPluginCompilationTimeout).To(Equal(consts.DefaultPluginCompilationTimeout)) - }) - - It("should use parsed values for duration fields", func() { - conf.InitConfig(filepath.Join("testdata", "valid_duration.toml"), false) - conf.Load(true) - - configured := 1 * time.Second - - server := conf.Server - Expect(server.SessionTimeout).To(Equal(configured)) - Expect(server.SmartPlaylistRefreshDelay).To(Equal(configured)) - Expect(server.DefaultShareExpiration).To(Equal(configured)) - Expect(server.UIPlaybackReportInterval).To(Equal(configured)) - Expect(server.AuthWindowLength).To(Equal(configured)) - Expect(server.Scanner.WatcherWait).To(Equal(configured)) - - Expect(server.DevActivityPanelUpdateRate).To(Equal(configured)) - Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(configured)) - Expect(server.DevArtistInfoTimeToLive).To(Equal(configured)) - Expect(server.DevAlbumInfoTimeToLive).To(Equal(configured)) - Expect(server.DevInsightsInitialDelay).To(Equal(configured)) - Expect(server.DevPluginCompilationTimeout).To(Equal(configured)) - }) -}) - -var _ = Describe("TLSEnabled", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("is false when neither the certificate nor the key is set", func() { - Expect(conf.Server.TLSEnabled()).To(BeFalse()) - }) - - It("is true when both the certificate and the key are set", func() { - conf.Server.TLSCert = "cert.pem" - conf.Server.TLSKey = "key.pem" - Expect(conf.Server.TLSEnabled()).To(BeTrue()) - }) - - It("is false when only the certificate is set", func() { - conf.Server.TLSCert = "cert.pem" - Expect(conf.Server.TLSEnabled()).To(BeFalse()) - }) - - It("is false when only the key is set", func() { - conf.Server.TLSKey = "key.pem" - Expect(conf.Server.TLSEnabled()).To(BeFalse()) - }) -}) diff --git a/conf/dir.go b/conf/dir.go deleted file mode 100644 index f7a14b933..000000000 --- a/conf/dir.go +++ /dev/null @@ -1,77 +0,0 @@ -package conf - -import ( - "cmp" - "fmt" - "os" -) - -// Dir wraps a directory path and creates the directory on demand. Dir is a -// plain value type — safe to copy, compare, and print via reflection-based -// formatters (pretty.Sprintf("%# v", ...)) without any concurrency hazards. -// Directory creation is delegated to os.MkdirAll on every Path() call; -// MkdirAll is idempotent, so repeated calls cost one stat syscall when the -// directory already exists. -type Dir struct { - path string - perm os.FileMode -} - -// NewDir creates a new Dir with the given path and default permissions (os.ModePerm). -func NewDir(path string) Dir { - return Dir{path: path, perm: os.ModePerm} -} - -// NewDirWithPerm creates a new Dir with the given path and permissions. -// A perm of 0 is treated as "default" and resolves to os.ModePerm at -// directory-creation time; pass an explicit non-zero mode to constrain the -// permissions. -func NewDirWithPerm(path string, perm os.FileMode) Dir { - return Dir{path: path, perm: perm} -} - -// String returns the raw path without creating the directory. Satisfies fmt.Stringer. -func (d Dir) String() string { - return d.path -} - -// Path ensures the directory exists and returns its path. Safe to call -// repeatedly; an empty path is returned as-is with no error. -func (d Dir) Path() (string, error) { - if d.path == "" { - return "", nil - } - if err := os.MkdirAll(d.path, cmp.Or(d.perm, os.ModePerm)); err != nil { - return d.path, fmt.Errorf("creating directory %q: %w", d.path, err) - } - return d.path, nil -} - -// MustPath calls Path() and calls logFatal on error. -func (d Dir) MustPath() string { - path, err := d.Path() - if err != nil { - logFatal("creating directory:", err) - } - return path -} - -// GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf) -// prints the path string instead of the internal struct fields. -func (d Dir) GoString() string { - return fmt.Sprintf("%q", d.path) -} - -// MarshalText returns the raw path bytes. No side effects. -func (d Dir) MarshalText() ([]byte, error) { - return []byte(d.path), nil -} - -// UnmarshalText sets the path from bytes. No side effects. -func (d *Dir) UnmarshalText(text []byte) error { - d.path = string(text) - if d.perm == 0 { - d.perm = os.ModePerm - } - return nil -} diff --git a/conf/dir_test.go b/conf/dir_test.go deleted file mode 100644 index 79db379d2..000000000 --- a/conf/dir_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package conf_test - -import ( - "os" - "sync" - - "github.com/kr/pretty" - "github.com/navidrome/navidrome/conf" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Dir", func() { - Describe("NewDir", func() { - It("creates a Dir with the given path without side effects", func() { - d := conf.NewDir("/some/path") - Expect(d.String()).To(Equal("/some/path")) - }) - }) - - Describe("String", func() { - It("returns the raw path without creating the directory", func() { - d := conf.NewDir("/nonexistent/path/that/should/not/be/created") - Expect(d.String()).To(Equal("/nonexistent/path/that/should/not/be/created")) - }) - }) - - Describe("Path", func() { - It("creates the directory and returns the path on first call", func() { - dir := GinkgoT().TempDir() - target := dir + "/subdir/nested" - d := conf.NewDir(target) - - path, err := d.Path() - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(target)) - Expect(target).To(BeADirectory()) - }) - - It("is idempotent on subsequent calls", func() { - dir := GinkgoT().TempDir() - target := dir + "/idempotent" - d := conf.NewDir(target) - - path1, err1 := d.Path() - path2, err2 := d.Path() - Expect(err1).ToNot(HaveOccurred()) - Expect(err2).ToNot(HaveOccurred()) - Expect(path1).To(Equal(path2)) - Expect(target).To(BeADirectory()) - }) - - It("returns an error when directory cannot be created", func() { - f := GinkgoT().TempDir() - blocker := f + "/blocker" - By("creating a file that blocks directory creation") - Expect(os.WriteFile(blocker, []byte("x"), 0600)).To(Succeed()) - invalid := blocker + "/subdir" - - d := conf.NewDir(invalid) - _, pathErr := d.Path() - Expect(pathErr).To(HaveOccurred()) - }) - - It("returns empty path and no error for empty path", func() { - d := conf.NewDir("") - path, err := d.Path() - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(BeEmpty()) - }) - }) - - Describe("MustPath", func() { - It("returns the path when directory is created successfully", func() { - dir := GinkgoT().TempDir() - target := dir + "/mustpath" - d := conf.NewDir(target) - - path := d.MustPath() - Expect(path).To(Equal(target)) - Expect(target).To(BeADirectory()) - }) - - It("calls logFatal on error", func() { - var fatalMsg []any - restore := conf.SetLogFatal(func(args ...any) { - fatalMsg = args - panic("logFatal called") - }) - DeferCleanup(restore) - - f := GinkgoT().TempDir() + "/blocker" - Expect(os.WriteFile(f, []byte("x"), 0600)).To(Succeed()) - invalid := f + "/subdir" - - d := conf.NewDir(invalid) - Expect(func() { d.MustPath() }).To(Panic()) - Expect(fatalMsg).ToNot(BeEmpty()) - }) - }) - - Describe("MarshalText", func() { - It("returns the raw path bytes without side effects", func() { - d := conf.NewDir("/marshal/path") - b, err := d.MarshalText() - Expect(err).ToNot(HaveOccurred()) - Expect(string(b)).To(Equal("/marshal/path")) - }) - }) - - Describe("UnmarshalText", func() { - It("sets the path from bytes without side effects", func() { - d := conf.NewDir("") - err := d.UnmarshalText([]byte("/unmarshal/path")) - Expect(err).ToNot(HaveOccurred()) - Expect(d.String()).To(Equal("/unmarshal/path")) - }) - - It("allows round-trip marshal/unmarshal", func() { - d1 := conf.NewDir("/round/trip") - b, err := d1.MarshalText() - Expect(err).ToNot(HaveOccurred()) - - var d2 conf.Dir - err = d2.UnmarshalText(b) - Expect(err).ToNot(HaveOccurred()) - Expect(d2.String()).To(Equal(d1.String())) - }) - }) - - Describe("GoString", func() { - // Regression: pretty.Sprintf("%# v", ...) is used by the - // configuration dump. It must render Dir as a quoted path via - // GoString, not dump the internal struct fields. - It("renders Dir as a quoted path under pretty.Sprintf", func() { - type host struct { - DataFolder conf.Dir - } - h := host{DataFolder: conf.NewDir("./data")} - out := pretty.Sprintf("%# v", h) - Expect(out).To(ContainSubstring(`DataFolder: "./data"`)) - Expect(out).ToNot(ContainSubstring("perm:")) - Expect(out).ToNot(ContainSubstring("path:")) - }) - - It("is safe to copy and use concurrently", func() { - // Regression for the Windows "sync: unlock of unlocked mutex" - // crash that was caused by copying a Dir embedding sync.Once. - // Dir is a plain value type now, but keep the concurrent stress - // test to lock in the property. - dir := GinkgoT().TempDir() - d := conf.NewDir(dir + "/race") - var wg sync.WaitGroup - for range 10 { - wg.Go(func() { - copy1 := d - _ = pretty.Sprintf("%# v", copy1) - _, _ = copy1.Path() - }) - } - wg.Wait() - }) - }) -}) diff --git a/conf/export_test.go b/conf/export_test.go deleted file mode 100644 index d1e1a6f99..000000000 --- a/conf/export_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package conf - -func ResetConf() { - Server = &configOptions{} -} - -var SetViperDefaults = setViperDefaults - -var ParseLanguages = parseLanguages - -var ValidateURL = validateURL - -var NormalizeSearchBackend = normalizeSearchBackend - -var ToPascalCase = toPascalCase - -var ValidateByteSize = validateByteSize - -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 } -} - -var UnknownConfigKeys = unknownConfigKeys - -var SuggestOptions = suggestOptions diff --git a/conf/mime/mime_types.go b/conf/mime/mime_types.go deleted file mode 100644 index 33542cb8e..000000000 --- a/conf/mime/mime_types.go +++ /dev/null @@ -1,48 +0,0 @@ -package mime - -import ( - "mime" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/resources" - "gopkg.in/yaml.v3" -) - -type mimeConf struct { - Types map[string]string `yaml:"types"` - Lossless []string `yaml:"lossless"` -} - -var LosslessFormats []string - -func initMimeTypes() { - // In some circumstances, Windows sets JS mime-type to `text/plain`! - _ = mime.AddExtensionType(".js", "text/javascript") - _ = mime.AddExtensionType(".css", "text/css") - _ = mime.AddExtensionType(".webmanifest", "application/manifest+json") - - f, err := resources.FS().Open("mime_types.yaml") - if err != nil { - log.Fatal("Fatal error opening mime_types.yaml", err) - } - defer f.Close() - - var mimeConf mimeConf - err = yaml.NewDecoder(f).Decode(&mimeConf) - if err != nil { - log.Fatal("Fatal error parsing mime_types.yaml", err) - } - for ext, typ := range mimeConf.Types { - _ = mime.AddExtensionType(ext, typ) - } - - for _, ext := range mimeConf.Lossless { - LosslessFormats = append(LosslessFormats, strings.TrimPrefix(ext, ".")) - } -} - -func init() { - conf.AddHook(initMimeTypes) -} diff --git a/conf/testdata/cfg.ini b/conf/testdata/cfg.ini deleted file mode 100644 index cc8b2a4a5..000000000 --- a/conf/testdata/cfg.ini +++ /dev/null @@ -1,8 +0,0 @@ -[default] -MusicFolder = /ini/music -UIWelcomeMessage = 'Welcome ini' ; Just a comment to test the LoadOptions -ReverseProxyUserHeader = 'X-Auth-User' - -[Tags] -Custom.Aliases = ini,test -artist.Split = ";" # Should be able to read ; as a separator \ No newline at end of file diff --git a/conf/testdata/cfg.json b/conf/testdata/cfg.json deleted file mode 100644 index 28fb039d2..000000000 --- a/conf/testdata/cfg.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "musicFolder": "/json/music", - "uiWelcomeMessage": "Welcome json", - "reverseProxyUserHeader": "X-Auth-User", - "Tags": { - "artist": { - "split": ";" - }, - "custom": { - "aliases": [ - "json", - "test" - ] - } - } -} \ No newline at end of file diff --git a/conf/testdata/cfg.toml b/conf/testdata/cfg.toml deleted file mode 100644 index 589e2a100..000000000 --- a/conf/testdata/cfg.toml +++ /dev/null @@ -1,8 +0,0 @@ -musicFolder = "/toml/music" -uiWelcomeMessage = "Welcome toml" -ReverseProxyUserHeader = "X-Auth-User" - -Tags.artist.Split = ';' - -[Tags.custom] -aliases = ["toml", "test"] diff --git a/conf/testdata/cfg.yaml b/conf/testdata/cfg.yaml deleted file mode 100644 index e44d2ebbb..000000000 --- a/conf/testdata/cfg.yaml +++ /dev/null @@ -1,10 +0,0 @@ -musicFolder: "/yaml/music" -uiWelcomeMessage: "Welcome yaml" -reverseProxyUserHeader: "X-Auth-User" -Tags: - artist: - split: [";"] - custom: - aliases: - - yaml - - test diff --git a/conf/testdata/cfg_deprecated_search.toml b/conf/testdata/cfg_deprecated_search.toml deleted file mode 100644 index cc6541e09..000000000 --- a/conf/testdata/cfg_deprecated_search.toml +++ /dev/null @@ -1,2 +0,0 @@ -MusicFolder = "/toml/music" -SearchFullString = true diff --git a/conf/testdata/cfg_nd_bogus.toml b/conf/testdata/cfg_nd_bogus.toml deleted file mode 100644 index 841b40998..000000000 --- a/conf/testdata/cfg_nd_bogus.toml +++ /dev/null @@ -1,3 +0,0 @@ -MusicFolder = "/toml/music" -ND_TOTALLY_BOGUS_OPTION = true -ND_SCANNER_SCHEDULE = "@every 1h" diff --git a/conf/testdata/cfg_nd_conflict.toml b/conf/testdata/cfg_nd_conflict.toml deleted file mode 100644 index 2e8b94bc3..000000000 --- a/conf/testdata/cfg_nd_conflict.toml +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index de441ce66..000000000 --- a/conf/testdata/cfg_nd_keys.toml +++ /dev/null @@ -1,3 +0,0 @@ -ND_ADDRESS = "127.0.0.1" -ND_PORT = 4531 -ND_SCANNER_SCHEDULE = "@every 1h" diff --git a/conf/testdata/cfg_runtime_fields.toml b/conf/testdata/cfg_runtime_fields.toml deleted file mode 100644 index a9c09f619..000000000 --- a/conf/testdata/cfg_runtime_fields.toml +++ /dev/null @@ -1,10 +0,0 @@ -MusicFolder = "/toml/music" -SearchFullString = true -ConfigFile = "/somewhere/else" -ID = "oops" - -[Search] -FullString = false - -[LastFM] -Languages = ["pt"] diff --git a/conf/testdata/cfg_unknown_casing.ini b/conf/testdata/cfg_unknown_casing.ini deleted file mode 100644 index c88db9b1f..000000000 --- a/conf/testdata/cfg_unknown_casing.ini +++ /dev/null @@ -1,3 +0,0 @@ -[default] -MusicFolder = /ini/music -NotAnOption = true diff --git a/conf/testdata/cfg_unknown_casing.json b/conf/testdata/cfg_unknown_casing.json deleted file mode 100644 index cd3a226cd..000000000 --- a/conf/testdata/cfg_unknown_casing.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "MusicFolder": "/json/music", - "NotAnOption": true -} diff --git a/conf/testdata/cfg_unknown_casing.toml b/conf/testdata/cfg_unknown_casing.toml deleted file mode 100644 index f015c9cb2..000000000 --- a/conf/testdata/cfg_unknown_casing.toml +++ /dev/null @@ -1,2 +0,0 @@ -MusicFolder = "/toml/music" -NotAnOption = true diff --git a/conf/testdata/cfg_unknown_casing.yaml b/conf/testdata/cfg_unknown_casing.yaml deleted file mode 100644 index 8987f6351..000000000 --- a/conf/testdata/cfg_unknown_casing.yaml +++ /dev/null @@ -1,2 +0,0 @@ -MusicFolder: /yaml/music -NotAnOption: true diff --git a/conf/testdata/cfg_unknown_keys.toml b/conf/testdata/cfg_unknown_keys.toml deleted file mode 100644 index a7ed55cb0..000000000 --- a/conf/testdata/cfg_unknown_keys.toml +++ /dev/null @@ -1,18 +0,0 @@ -MusicFolder = "/toml/music" - -# Valid option, but written at the root level instead of under Scanner -ArtistSplitExceptions = ["AC/DC", "Tyler, the creator"] - -# Misspelled option -EnableDownlods = true - -# Unknown section -[Whatever] -Foo = "bar" - -# Valid options, must not be reported -[Scanner] -ArtistJoiner = " • " - -[Tags.custom] -aliases = ["toml", "test"] diff --git a/conf/testdata/cfg_warning_output.toml b/conf/testdata/cfg_warning_output.toml deleted file mode 100644 index a52c5ea54..000000000 --- a/conf/testdata/cfg_warning_output.toml +++ /dev/null @@ -1,7 +0,0 @@ -MusicFolder = "/toml/music" -LogLevel = "warn" -ArtistSplitExceptions = ["AC/DC"] -EnableDownlods = true - -[Scanner] -ArtistJoiner = " • " diff --git a/conf/testdata/invalid_duration.toml b/conf/testdata/invalid_duration.toml deleted file mode 100644 index 6540fef60..000000000 --- a/conf/testdata/invalid_duration.toml +++ /dev/null @@ -1,12 +0,0 @@ -SessionTimeout = "-10s" -SmartPlaylistRefreshDelay = "-10s" -UIPlaybackReportInterval = "-10s" -AuthWindowLength = "-10s" -DefaultShareExpiration = "-10s" -Scanner.WatcherWait = "-10s" -DevActivityPanelUpdateRate = "-10s" -DevArtworkThrottleBacklogTimeout = "-10s" -DevArtistInfoTimeToLive = "-10s" -DevAlbumInfoTimeToLive = "-10s" -DevInsightsInitialDelay = "-10s" -DevPluginCompilationTimeout = "-10s" diff --git a/conf/testdata/valid_duration.toml b/conf/testdata/valid_duration.toml deleted file mode 100644 index 6ec4af480..000000000 --- a/conf/testdata/valid_duration.toml +++ /dev/null @@ -1,12 +0,0 @@ -SessionTimeout = "1s" -SmartPlaylistRefreshDelay = "1s" -UIPlaybackReportInterval = "1s" -AuthWindowLength = "1s" -DefaultShareExpiration = "1s" -Scanner.WatcherWait = "1s" -DevActivityPanelUpdateRate = "1s" -DevArtworkThrottleBacklogTimeout = "1s" -DevArtistInfoTimeToLive = "1s" -DevAlbumInfoTimeToLive = "1s" -DevInsightsInitialDelay = "1s" -DevPluginCompilationTimeout = "1s" diff --git a/consts/consts.go b/consts/consts.go index 42e9ec42f..6491479bc 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -1,70 +1,41 @@ package consts import ( - "os" + "crypto/md5" + "fmt" + "path/filepath" "strings" "time" - - "github.com/navidrome/navidrome/model/id" ) const ( AppName = "navidrome" - DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal" - InitialSetupFlagKey = "InitialSetup" - FullScanAfterMigrationFlagKey = "FullScanAfterMigration" - // PlaylistsImportPendingFlagKey marks that playlist import was deferred because - // no admin user existed yet; the next scan with an admin imports them. - PlaylistsImportPendingFlagKey = "PlaylistsImportPending" - LastScanErrorKey = "LastScanError" - LastScanTypeKey = "LastScanType" - LastScanStartTimeKey = "LastScanStartTime" - LastDBAnalyzeAtKey = "LastDBAnalyzeAt" - LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt" - DBAnalyzePendingKey = "DBAnalyzePending" - DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount" - // ArtConfFingerprintPropertyKey is the model.PropertyRepository key the artwork config check - // compares against to detect artwork-affecting config changes across restarts. - ArtConfFingerprintPropertyKey = "ArtConfFingerprint" + DefaultDbPath = "navidrome.db?cache=shared&_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on" + InitialSetupFlagKey = "InitialSetup" UIAuthorizationHeader = "X-ND-Authorization" UIClientUniqueIDHeader = "X-ND-Client-Unique-Id" JWTSecretKey = "JWTSecret" - JWTPublicSecretKey = "JWTPublicSecret" JWTIssuer = "ND" - DefaultSessionTimeout = 48 * time.Hour - DefaultSmartRefresh = 5 * time.Second - DefaultShareExpiration = 8760 * time.Hour + DefaultSessionTimeout = 24 * time.Hour CookieExpiry = 365 * 24 * 3600 // One year - DBAnalyzeCheckSchedule = "@every 30m" - DBAnalyzeMaxAge = 24 * time.Hour - - ArtworkEnqueueMissingSchedule = "@every 1h" - ArtworkPruneSchedule = "@daily" - // DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option // Never ever change this! Or it will break all Navidrome installations that don't set the config option DefaultEncryptionKey = "just for obfuscation" PasswordsEncryptedKey = "PasswordsEncryptedKey" PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec - APIKeyPrefix = "nds_" DevInitialUserName = "admin" DevInitialName = "Dev Admin" URLPathUI = "/app" URLPathNativeAPI = "/api" + URLPathAPI = "/api/v2" URLPathSubsonicAPI = "/rest" URLPathPublic = "/share" URLPathPublicImages = URLPathPublic + "/img" - URLPathJellyfinAPI = "/jellyfin" - URLPathAPIv1 = "/api/v1" - - // JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the - // Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts. - JellyfinServerIDKey = "JellyfinServerID" // DefaultUILoginBackgroundURL uses Navidrome curated background images collection, // available at https://unsplash.com/collections/20072696/navidrome @@ -75,57 +46,28 @@ const ( DefaultUILoginBackgroundURLOffline = "data:image/png;base64," + DefaultUILoginBackgroundOffline DefaultMaxSidebarPlaylists = 100 - DefaultAuthWindowLength = 20 * time.Second RequestThrottleBacklogLimit = 100 RequestThrottleBacklogTimeout = time.Minute ServerReadHeaderTimeout = 3 * time.Second - DefaultInfoLanguage = "en" + ArtistInfoTimeToLive = 24 * time.Hour + AlbumInfoTimeToLive = 7 * 24 * time.Hour - ArtistInfoTimeToLive = 24 * time.Hour - AlbumInfoTimeToLive = 7 * 24 * time.Hour - UpdateLastAccessFrequency = time.Minute - UpdatePlayerFrequency = time.Minute + I18nFolder = "i18n" + SkipScanFile = ".ndignore" - I18nFolder = "i18n" - ScanIgnoreFile = ".ndignore" - ArtworkFolder = "artwork" - // HashedArtworkFolder is a subtree of ArtworkFolder, kept apart from the name-addressed - // upload folders beside it so Prune's sweep never reaches them. - HashedArtworkFolder = "hashed" - - PlaceholderArtistArt = "artist-placeholder.webp" - PlaceholderAlbumArt = "album-placeholder.webp" - PlaceholderAvatar = "logo-192x192.png" - DefaultUIVolume = 100 - DefaultUISearchDebounceMs = 200 - DefaultUIPlaybackReportInterval = time.Minute + PlaceholderArtistArt = "artist-placeholder.webp" + PlaceholderAlbumArt = "placeholder.png" + PlaceholderAvatar = "logo-192x192.png" + UICoverArtSize = 300 + DefaultUIVolume = 100 DefaultHttpClientTimeOut = 10 * time.Second - DefaultListenBrainzBaseURL = "https://api.listenbrainz.org/1/" - DefaultListenBrainzArtistAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30" - DefaultListenBrainzTrackAlgorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30" - DefaultScannerExtractor = "taglib" - DefaultWatcherWait = 5 * time.Second - Zwsp = string('\u200b') - DefaultActivityPanelUpdateRate = 300 * time.Millisecond - DefaultPluginCompilationTimeout = time.Minute -) - -const ( - DefaultUICoverArtSize = 300 - DefaultMaxImageUploadSize = "10MB" - DefaultMaxImageSize = "20MB" -) - -// Prometheus options -const ( - PrometheusDefaultPath = "/metrics" - PrometheusAuthUser = "navidrome" + Zwsp = string('\u200b') ) // Cache options @@ -140,95 +82,45 @@ const ( DefaultCacheCleanUpInterval = 10 * time.Minute ) -// Entity types +// Shared secrets (only add here "secrets" that can be public) const ( - EntityArtist = "artist" - EntityPlaylist = "playlist" - EntityRadio = "radio" -) - -const ( - AlbumPlayCountModeAbsolute = "absolute" - AlbumPlayCountModeNormalized = "normalized" -) - -const ( - //DefaultAlbumPID = "album_legacy" - DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" - DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title" -) - -const ( - InsightsIDKey = "InsightsID" - InsightsEndpoint = "https://insights.navidrome.org/collect" - InsightsUpdateInterval = 24 * time.Hour - InsightsInitialDelay = 30 * time.Minute -) - -const ( - PurgeMissingNever = "never" - PurgeMissingAlways = "always" - PurgeMissingFull = "full" + LastFMAPIKey = "9b94a5515ea66b2da3ec03c12300327e" // nolint:gosec + LastFMAPISecret = "74cb6557cec7171d921af5d7d887c587" // nolint:gosec ) var ( DefaultDownsamplingFormat = "opus" - DefaultTranscodings = []struct { - Name string - TargetFormat string - DefaultBitRate int - Command string - }{ + DefaultTranscodings = []map[string]interface{}{ { - Name: "mp3 audio", - TargetFormat: "mp3", - DefaultBitRate: 192, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -", + "name": "mp3 audio", + "targetFormat": "mp3", + "defaultBitRate": 192, + "command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", }, { - Name: "opus audio", - TargetFormat: "opus", - DefaultBitRate: 128, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + "name": "opus audio", + "targetFormat": "opus", + "defaultBitRate": 128, + "command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", }, { - Name: "aac audio", - TargetFormat: "aac", - DefaultBitRate: 256, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -", - }, - { - Name: "flac audio", - TargetFormat: "flac", - DefaultBitRate: 0, - Command: "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -", + "name": "aac audio", + "targetFormat": "aac", + "defaultBitRate": 256, + "command": "ffmpeg -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", }, } + + DefaultPlaylistsPath = strings.Join([]string{".", "**/**"}, string(filepath.ListSeparator)) ) -var HTTPUserAgent = "Navidrome/" + Version + " - https://github.com/navidrome" - var ( - VariousArtists = "Various Artists" - // TODO This will be dynamic when using disambiguation - VariousArtistsID = "63sqASlAfjbGMuLP4JhnZU" - UnknownAlbum = "[Unknown Album]" - UnknownArtist = "[Unknown Artist]" - // TODO This will be dynamic when using disambiguation - UnknownArtistID = id.NewHash(strings.ToLower(UnknownArtist)) + VariousArtists = "Various Artists" + VariousArtistsID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(VariousArtists)))) + UnknownAlbum = "[Unknown Album]" + UnknownArtist = "[Unknown Artist]" + UnknownArtistID = fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(UnknownArtist)))) VariousArtistsMbzId = "89ad4ac3-39f7-470e-963a-56509c546377" - ArtistJoiner = " • " -) - -var ( ServerStart = time.Now() - - InContainer = func() bool { - // Check if the /.nddockerenv file exists - if _, err := os.Stat("/.nddockerenv"); err == nil { - return true - } - return false - }() ) diff --git a/consts/mime_types.go b/consts/mime_types.go new file mode 100644 index 000000000..c05a08638 --- /dev/null +++ b/consts/mime_types.go @@ -0,0 +1,64 @@ +package consts + +import ( + "mime" + "sort" + "strings" +) + +type format struct { + typ string + lossless bool +} + +var audioFormats = map[string]format{ + ".mp3": {typ: "audio/mpeg"}, + ".ogg": {typ: "audio/ogg"}, + ".oga": {typ: "audio/ogg"}, + ".opus": {typ: "audio/ogg"}, + ".aac": {typ: "audio/mp4"}, + ".alac": {typ: "audio/mp4", lossless: true}, + ".m4a": {typ: "audio/mp4"}, + ".m4b": {typ: "audio/mp4"}, + ".flac": {typ: "audio/flac", lossless: true}, + ".wav": {typ: "audio/x-wav", lossless: true}, + ".wma": {typ: "audio/x-ms-wma"}, + ".ape": {typ: "audio/x-monkeys-audio", lossless: true}, + ".mpc": {typ: "audio/x-musepack"}, + ".shn": {typ: "audio/x-shn", lossless: true}, + ".aif": {typ: "audio/x-aiff"}, + ".aiff": {typ: "audio/x-aiff"}, + ".m3u": {typ: "audio/x-mpegurl"}, + ".pls": {typ: "audio/x-scpls"}, + ".dsf": {typ: "audio/dsd", lossless: true}, + ".wv": {typ: "audio/x-wavpack", lossless: true}, + ".wvp": {typ: "audio/x-wavpack", lossless: true}, + ".mka": {typ: "audio/x-matroska"}, +} +var imageFormats = map[string]string{ + ".gif": "image/gif", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".png": "image/png", + ".bmp": "image/bmp", +} + +var LosslessFormats []string + +func init() { + for ext, fmt := range audioFormats { + _ = mime.AddExtensionType(ext, fmt.typ) + if fmt.lossless { + LosslessFormats = append(LosslessFormats, strings.TrimPrefix(ext, ".")) + } + } + sort.Strings(LosslessFormats) + for ext, typ := range imageFormats { + _ = mime.AddExtensionType(ext, typ) + } + + // In some circumstances, Windows sets JS mime-type to `text/plain`! + _ = mime.AddExtensionType(".js", "text/javascript") + _ = mime.AddExtensionType(".css", "text/css") +} diff --git a/context7.json b/context7.json deleted file mode 100644 index 343873063..000000000 --- a/context7.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "url": "https://context7.com/navidrome/navidrome", - "public_key": "pk_WqzhKScNKWQ84J4n0oG0J" -} diff --git a/contrib/freebsd_rc b/contrib/freebsd_rc index 55f94708e..851ff5e3e 100644 --- a/contrib/freebsd_rc +++ b/contrib/freebsd_rc @@ -11,7 +11,7 @@ # # navidrome_enable (bool): Set to YES to enable navidrome # Default: NO -# navidrome_config (str): navidrome configuration file +# navidrome_config (str): navidrome configration file # Default: /usr/local/etc/navidrome/config.toml # navidrome_datafolder (str): navidrome Folder to store application data # Default: www diff --git a/contrib/navidrome b/contrib/navidrome index 8ab5afe2e..bb6908797 100644 --- a/contrib/navidrome +++ b/contrib/navidrome @@ -2,7 +2,7 @@ name=$RC_SVCNAME command="/opt/navidrome/${RC_SVCNAME}" -command_args="--datafolder /opt/navidrome" +command_args="-datafolder /opt/navidrome" command_user="${RC_SVCNAME}" pidfile="/var/run/${RC_SVCNAME}.pid" output_log="/opt/navidrome/${RC_SVCNAME}.log" diff --git a/contrib/navidrome.service b/contrib/navidrome.service index ef61d2c65..817ab044c 100644 --- a/contrib/navidrome.service +++ b/contrib/navidrome.service @@ -11,13 +11,15 @@ WantedBy=multi-user.target User=navidrome Group=navidrome Type=simple -ExecStart=/usr/bin/navidrome --configfile "/etc/navidrome/navidrome.toml" +ExecStart=/usr/bin/navidrome StateDirectory=navidrome WorkingDirectory=/var/lib/navidrome TimeoutStopSec=20 KillMode=process Restart=on-failure +EnvironmentFile=-/etc/sysconfig/navidrome + # See https://www.freedesktop.org/software/systemd/man/systemd.exec.html CapabilityBoundingSet= DevicePolicy=closed @@ -36,7 +38,7 @@ RestrictNamespaces=yes RestrictRealtime=yes SystemCallFilter=@system-service SystemCallFilter=~@privileged @resources -SystemCallFilter=setrlimit mbind +SystemCallFilter=setrlimit SystemCallArchitectures=native UMask=0066 diff --git a/core/agents/README.md b/core/agents/README.md index cce62889c..1a3a8e96e 100644 --- a/core/agents/README.md +++ b/core/agents/README.md @@ -7,6 +7,6 @@ A new agent must comply with these simple implementation rules: 2) Implement one or more of the `*Retriever()` interfaces. That's where the agent's logic resides. 3) Register itself (in its `init()` function). -For an agent to be used it needs to be listed in the `Agents` config option (default is `"deezer,lastfm"`). The order dictates the priority of the agents +For an agent to be used it needs to be listed in the `Agents` config option (default is `"lastfm,spotify"`). The order dictates the priority of the agents For a simple Agent example, look at the [local_agent](local_agent.go) agent source code. diff --git a/core/agents/agents.go b/core/agents/agents.go index 8ae3124ef..1f9b8c0dd 100644 --- a/core/agents/agents.go +++ b/core/agents/agents.go @@ -1,13 +1,8 @@ package agents import ( - "cmp" "context" - "errors" - "maps" - "slices" "strings" - "sync" "time" "github.com/navidrome/navidrome/conf" @@ -15,195 +10,37 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/singleton" ) -// PluginLoader defines an interface for loading plugins -type PluginLoader interface { - // PluginNames returns the names of all plugins that implement a particular service - PluginNames(capability string) []string - // LoadMediaAgent loads and returns a media agent plugin - LoadMediaAgent(name string) (Interface, bool) -} - -// agentCooldown is the default cooldown duration for an agent that returns a RetryLaterError without a specific -// RetryIn duration. -const agentCooldown = time.Minute - -// errUnsupported marks an agent that does not implement the requested method: it never ran, -// so it neither answered nor throttled. -var errUnsupported = errors.New("agent does not support this method") - -// Agents is a meta-agent that aggregates multiple built-in and plugin agents. It tries each enabled agent in order -// until one returns valid data. type Agents struct { - ds model.DataStore - pluginLoader PluginLoader - cooldowns cooldowns + ds model.DataStore + agents []Interface } -// cooldowns remembers, across dispatches, which agents asked to be left alone and until when. -type cooldowns struct { - mu sync.RWMutex - until map[string]time.Time -} - -func (c *cooldowns) active(name string) bool { - c.mu.RLock() - defer c.mu.RUnlock() - return time.Now().Before(c.until[name]) -} - -// park keeps whichever deadline is later, so a call still in flight when a longer cooldown -// starts cannot cut it short when it finally answers. -func (c *cooldowns) park(name string, d time.Duration) { - until := time.Now().Add(d) - c.mu.Lock() - defer c.mu.Unlock() - if until.After(c.until[name]) { - c.until[name] = until +func New(ds model.DataStore) *Agents { + var order []string + if conf.Server.Agents != "" { + order = strings.Split(conf.Server.Agents, ",") } -} - -// GetAgents returns the singleton instance of Agents -func GetAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents { - return singleton.GetInstance(func() *Agents { - return createAgents(ds, pluginLoader) - }) -} - -// createAgents creates a new Agents instance. Used in tests -func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents { - return &Agents{ - ds: ds, - pluginLoader: pluginLoader, - cooldowns: cooldowns{until: map[string]time.Time{}}, - } -} - -// enabledAgent represents an enabled agent with its type information -type enabledAgent struct { - name string - isPlugin bool -} - -// getEnabledAgentNames returns the current list of enabled agents, including: -// 1. Built-in agents and plugins from config (in the specified order) -// 2. Always include LocalAgentName -// 3. If config is empty, include ONLY LocalAgentName -// Each enabledAgent contains the name and whether it's a plugin (true) or built-in (false) -func (a *Agents) getEnabledAgentNames() []enabledAgent { - // If no agents configured, ONLY use the local agent - if conf.Server.Agents == "" { - return []enabledAgent{{name: LocalAgentName, isPlugin: false}} - } - - // Get all available plugin names - var availablePlugins []string - if a.pluginLoader != nil { - availablePlugins = a.pluginLoader.PluginNames("MetadataAgent") - } - log.Trace("Available MetadataAgent plugins", "plugins", availablePlugins) - - configuredAgents := strings.Split(conf.Server.Agents, ",") - - // Always add LocalAgentName if not already included - hasLocalAgent := slices.Contains(configuredAgents, LocalAgentName) - if !hasLocalAgent { - configuredAgents = append(configuredAgents, LocalAgentName) - } - - // Filter to only include valid agents (built-in or plugins) - var validAgents []enabledAgent - for _, name := range configuredAgents { - // Check if it's a built-in agent - isBuiltIn := Map[name] != nil - - // Check if it's a plugin - isPlugin := slices.Contains(availablePlugins, name) - - if isBuiltIn { - validAgents = append(validAgents, enabledAgent{name: name, isPlugin: false}) - } else if isPlugin { - validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true}) - } else { - log.Debug("Unknown agent ignored", "name", name, "available", availableAgentNames(availablePlugins)) + order = append(order, LocalAgentName) + var res []Interface + for _, name := range order { + init, ok := Map[name] + if !ok { + log.Error("Agent not available. Check configuration", "name", name) + continue } - } - return validAgents -} -// availableAgentNames returns every name accepted by the Agents config option. -func availableAgentNames(plugins []string) []string { - names := append(slices.Collect(maps.Keys(Map)), plugins...) - slices.Sort(names) - return names -} - -func (a *Agents) getAgent(ea enabledAgent) Interface { - if ea.isPlugin { - // Try to load WASM plugin agent (if plugin loader is available) - if a.pluginLoader != nil { - agent, ok := a.pluginLoader.LoadMediaAgent(ea.name) - if ok && agent != nil { - return agent - } - } - } else { - // Try to get built-in agent - constructor, ok := Map[ea.name] - if ok { - agent := constructor(a.ds) - if agent != nil { - return agent - } - log.Debug("Built-in agent not available. Missing configuration?", "name", ea.name) - } + res = append(res, init(ds)) } - return nil + return &Agents{ds: ds, agents: res} } func (a *Agents) AgentName() string { return "agents" } -// ArtistImageAgent pairs an enabled agent's name with its ArtistImageRetriever capability. -type ArtistImageAgent struct { - Name string - Retriever ArtistImageRetriever -} - -// AlbumImageAgent pairs an enabled agent's name with its AlbumImageRetriever capability. -type AlbumImageAgent struct { - Name string - Retriever AlbumImageRetriever -} - -// ArtistImageAgents returns the enabled agents implementing ArtistImageRetriever, -// in conf.Server.Agents order (same order the aggregate dispatch uses). -func (a *Agents) ArtistImageAgents() []ArtistImageAgent { - var result []ArtistImageAgent - for _, ea := range a.getEnabledAgentNames() { - if retriever, ok := a.getAgent(ea).(ArtistImageRetriever); ok { - result = append(result, ArtistImageAgent{Name: ea.name, Retriever: retriever}) - } - } - return result -} - -// AlbumImageAgents returns the enabled agents implementing AlbumImageRetriever, -// in conf.Server.Agents order (same order the aggregate dispatch uses). -func (a *Agents) AlbumImageAgents() []AlbumImageAgent { - var result []AlbumImageAgent - for _, ea := range a.getEnabledAgentNames() { - if retriever, ok := a.getAgent(ea).(AlbumImageRetriever); ok { - result = append(result, AlbumImageAgent{Name: ea.name, Retriever: retriever}) - } - } - return result -} - func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { switch id { case consts.UnknownArtistID: @@ -211,14 +48,22 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str case consts.VariousArtistsID: return "", nil } - - return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) { - retriever, ok := ag.(ArtistMBIDRetriever) - if !ok { - return "", errUnsupported + start := time.Now() + for _, ag := range a.agents { + if utils.IsCtxDone(ctx) { + break } - return retriever.GetArtistMBID(ctx, id, name) - }) + agent, ok := ag.(ArtistMBIDRetriever) + if !ok { + continue + } + mbid, err := agent.GetArtistMBID(ctx, id, name) + if mbid != "" && err == nil { + log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start)) + return mbid, nil + } + } + return "", ErrNotFound } func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) { @@ -228,14 +73,22 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin case consts.VariousArtistsID: return "", nil } - - return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) { - retriever, ok := ag.(ArtistURLRetriever) - if !ok { - return "", errUnsupported + start := time.Now() + for _, ag := range a.agents { + if utils.IsCtxDone(ctx) { + break } - return retriever.GetArtistURL(ctx, id, name, mbid) - }) + agent, ok := ag.(ArtistURLRetriever) + if !ok { + continue + } + url, err := agent.GetArtistURL(ctx, id, name, mbid) + if url != "" && err == nil { + log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start)) + return url, nil + } + } + return "", ErrNotFound } func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) { @@ -245,18 +98,24 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) case consts.VariousArtistsID: return "", nil } - - return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) { - retriever, ok := ag.(ArtistBiographyRetriever) - if !ok { - return "", errUnsupported + start := time.Now() + for _, ag := range a.agents { + if utils.IsCtxDone(ctx) { + break } - return retriever.GetArtistBiography(ctx, id, name, mbid) - }) + agent, ok := ag.(ArtistBiographyRetriever) + if !ok { + continue + } + bio, err := agent.GetArtistBiography(ctx, id, name, mbid) + if err == nil { + log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start)) + return bio, nil + } + } + return "", ErrNotFound } -// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled -// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items. func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) { switch id { case consts.UnknownArtistID: @@ -264,30 +123,18 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l case consts.VariousArtistsID: return nil, nil } - - overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier) - start := time.Now() - attempts := newAttempts(&a.cooldowns) - for _, enabledAgent := range a.getEnabledAgentNames() { - if attempts.skip(enabledAgent.name) { - continue - } - ag := a.getAgent(enabledAgent) - if ag == nil { - continue - } + for _, ag := range a.agents { if utils.IsCtxDone(ctx) { break } - retriever, ok := ag.(ArtistSimilarRetriever) + agent, ok := ag.(ArtistSimilarRetriever) if !ok { continue } - similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit) - attempts.record(enabledAgent.name, err) + similar, err := agent.GetSimilarArtists(ctx, id, name, mbid, limit) if len(similar) > 0 && err == nil { - if log.IsGreaterOrEqualTo(log.LevelTrace) { + if log.CurrentLevel() >= log.LevelTrace { log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start)) } else { log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similarReceived", len(similar), "elapsed", time.Since(start)) @@ -295,7 +142,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l return similar, err } } - return nil, attempts.noResultErr() + return nil, ErrNotFound } func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) { @@ -305,18 +152,24 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([] case consts.VariousArtistsID: return nil, nil } - - return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) { - retriever, ok := ag.(ArtistImageRetriever) - if !ok { - return nil, errUnsupported + start := time.Now() + for _, ag := range a.agents { + if utils.IsCtxDone(ctx) { + break } - return retriever.GetArtistImages(ctx, id, name, mbid) - }) + agent, ok := ag.(ArtistImageRetriever) + if !ok { + continue + } + images, err := agent.GetArtistImages(ctx, id, name, mbid) + if len(images) > 0 && err == nil { + log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start)) + return images, nil + } + } + return nil, ErrNotFound } -// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled -// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items. func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) { switch id { case consts.UnknownArtistID: @@ -324,166 +177,45 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str case consts.VariousArtistsID: return nil, nil } - - overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier) - - return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) { - retriever, ok := ag.(ArtistTopSongsRetriever) - if !ok { - return nil, errUnsupported + start := time.Now() + for _, ag := range a.agents { + if utils.IsCtxDone(ctx) { + break } - return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit) - }) + agent, ok := ag.(ArtistTopSongsRetriever) + if !ok { + continue + } + songs, err := agent.GetArtistTopSongs(ctx, id, artistName, mbid, count) + if len(songs) > 0 && err == nil { + log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start)) + return songs, nil + } + } + return nil, ErrNotFound } func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) { if name == consts.UnknownAlbum { return nil, ErrNotFound } - - return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) { - retriever, ok := ag.(AlbumInfoRetriever) - if !ok { - return nil, errUnsupported - } - return retriever.GetAlbumInfo(ctx, name, artist, mbid) - }) -} - -func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) { - if name == consts.UnknownAlbum { - return nil, ErrNotFound - } - - return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) { - retriever, ok := ag.(AlbumImageRetriever) - if !ok { - return nil, errUnsupported - } - return retriever.GetAlbumImages(ctx, name, artist, mbid) - }) -} - -// GetSimilarSongsByTrack returns similar songs for a given track. -func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) { - return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) { - retriever, ok := ag.(SimilarSongsByTrackRetriever) - if !ok { - return nil, errUnsupported - } - return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count) - }) -} - -// GetSimilarSongsByAlbum returns similar songs for a given album. -func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) { - return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) { - retriever, ok := ag.(SimilarSongsByAlbumRetriever) - if !ok { - return nil, errUnsupported - } - return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count) - }) -} - -// GetSimilarSongsByArtist returns similar songs for a given artist. -func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error) { - switch id { - case consts.UnknownArtistID: - return nil, ErrNotFound - case consts.VariousArtistsID: - return nil, nil - } - - return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) { - retriever, ok := ag.(SimilarSongsByArtistRetriever) - if !ok { - return nil, errUnsupported - } - return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count) - }) -} - -// agentAttempts tallies what the enabled agents did in one dispatch. -type agentAttempts struct { - cooldowns *cooldowns - throttled bool - answered bool -} - -func newAttempts(c *cooldowns) agentAttempts { - return agentAttempts{cooldowns: c} -} - -// skip reports whether name is still cooling down, counting it as throttled for this dispatch. -func (t *agentAttempts) skip(name string) bool { - if !t.cooldowns.active(name) { - return false - } - t.throttled = true - return true -} - -// record files one agent's outcome, parking it when it asked to be retried later. -func (t *agentAttempts) record(name string, err error) { - switch retry, isRetryLater := errors.AsType[*RetryLaterError](err); { - case errors.Is(err, errUnsupported): - case isRetryLater: - t.cooldowns.park(name, cmp.Or(retry.RetryIn, agentCooldown)) - t.throttled = true - default: - t.answered = true - } -} - -// noResultErr tells a retryable empty dispatch (nobody answered) from a definitive miss. -func (t *agentAttempts) noResultErr() error { - if t.throttled && !t.answered { - return ErrRetryLater - } - return ErrNotFound -} - -// callAgent tries each enabled agent in order until found reports a usable result. -func callAgent[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error), found func(T) bool) (T, error) { - var zero T start := time.Now() - attempts := newAttempts(&agents.cooldowns) - for _, enabledAgent := range agents.getEnabledAgentNames() { - if attempts.skip(enabledAgent.name) { - continue - } - ag := agents.getAgent(enabledAgent) - if ag == nil { - continue - } + for _, ag := range a.agents { if utils.IsCtxDone(ctx) { break } - result, err := fn(ag) - attempts.record(enabledAgent.name, err) - if err != nil { - log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err) + agent, ok := ag.(AlbumInfoRetriever) + if !ok { continue } - - if found(result) { - log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start)) - return result, nil + album, err := agent.GetAlbumInfo(ctx, name, artist, mbid) + if err == nil { + log.Debug(ctx, "Got Album Info", "agent", ag.AgentName(), "album", name, "artist", artist, + "mbid", mbid, "elapsed", time.Since(start)) + return album, nil } } - return zero, attempts.noResultErr() -} - -func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) { - return callAgent(ctx, agents, methodName, fn, func(result T) bool { - var zero T - return result != zero - }) -} - -func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) { - return callAgent(ctx, agents, methodName, fn, func(results []T) bool { return len(results) > 0 }) + return nil, ErrNotFound } var _ Interface = (*Agents)(nil) @@ -494,7 +226,3 @@ var _ ArtistSimilarRetriever = (*Agents)(nil) var _ ArtistImageRetriever = (*Agents)(nil) var _ ArtistTopSongsRetriever = (*Agents)(nil) var _ AlbumInfoRetriever = (*Agents)(nil) -var _ AlbumImageRetriever = (*Agents)(nil) -var _ SimilarSongsByTrackRetriever = (*Agents)(nil) -var _ SimilarSongsByAlbumRetriever = (*Agents)(nil) -var _ SimilarSongsByArtistRetriever = (*Agents)(nil) diff --git a/core/agents/agents_plugin_test.go b/core/agents/agents_plugin_test.go deleted file mode 100644 index b2791c00e..000000000 --- a/core/agents/agents_plugin_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package agents - -import ( - "context" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// MockPluginLoader implements PluginLoader for testing -type MockPluginLoader struct { - pluginNames []string - loadedAgents map[string]*MockAgent - pluginCallCount map[string]int -} - -func NewMockPluginLoader() *MockPluginLoader { - return &MockPluginLoader{ - pluginNames: []string{}, - loadedAgents: make(map[string]*MockAgent), - pluginCallCount: make(map[string]int), - } -} - -func (m *MockPluginLoader) PluginNames(serviceName string) []string { - return m.pluginNames -} - -func (m *MockPluginLoader) LoadMediaAgent(name string) (Interface, bool) { - m.pluginCallCount[name]++ - agent, exists := m.loadedAgents[name] - return agent, exists -} - -// MockAgent is a mock agent implementation for testing -type MockAgent struct { - name string - mbid string -} - -func (m *MockAgent) AgentName() string { - return m.name -} - -func (m *MockAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { - return m.mbid, nil -} - -var _ Interface = (*MockAgent)(nil) -var _ ArtistMBIDRetriever = (*MockAgent)(nil) - -var _ PluginLoader = (*MockPluginLoader)(nil) - -var _ = Describe("Agents with Plugin Loading", func() { - var mockLoader *MockPluginLoader - var agents *Agents - - BeforeEach(func() { - mockLoader = NewMockPluginLoader() - - // Create the agents instance with our mock loader - agents = createAgents(nil, mockLoader) - }) - - Context("Dynamic agent discovery", func() { - It("should include ONLY local agent when no config is specified", func() { - // Ensure no specific agents are configured - conf.Server.Agents = "" - - // Add some plugin agents that should be ignored - mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent", "another_plugin") - - // Should only include the local agent - enabledAgents := agents.getEnabledAgentNames() - Expect(enabledAgents).To(HaveLen(1)) - Expect(enabledAgents[0].name).To(Equal(LocalAgentName)) - Expect(enabledAgents[0].isPlugin).To(BeFalse()) // LocalAgent is built-in, not plugin - }) - - It("should NOT include plugin agents when no config is specified", func() { - // Ensure no specific agents are configured - conf.Server.Agents = "" - - // Add a plugin agent - mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent") - - // Should only include the local agent - enabledAgents := agents.getEnabledAgentNames() - Expect(enabledAgents).To(HaveLen(1)) - Expect(enabledAgents[0].name).To(Equal(LocalAgentName)) - Expect(enabledAgents[0].isPlugin).To(BeFalse()) // LocalAgent is built-in, not plugin - }) - - It("should include plugin agents in the enabled agents list ONLY when explicitly configured", func() { - // Add a plugin agent - mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent") - - // With no config, should not include plugin - conf.Server.Agents = "" - enabledAgents := agents.getEnabledAgentNames() - Expect(enabledAgents).To(HaveLen(1)) - Expect(enabledAgents[0].name).To(Equal(LocalAgentName)) - - // When explicitly configured, should include plugin - conf.Server.Agents = "plugin_agent" - enabledAgents = agents.getEnabledAgentNames() - var agentNames []string - var pluginAgentFound bool - for _, agent := range enabledAgents { - agentNames = append(agentNames, agent.name) - if agent.name == "plugin_agent" { - pluginAgentFound = true - Expect(agent.isPlugin).To(BeTrue()) // plugin_agent is a plugin - } - } - Expect(agentNames).To(ContainElements(LocalAgentName, "plugin_agent")) - Expect(pluginAgentFound).To(BeTrue()) - }) - - It("should only include configured plugin agents when config is specified", func() { - // Add two plugin agents - mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_one", "plugin_two") - - // Configure only one of them - conf.Server.Agents = "plugin_one" - - // Verify only the configured one is included - enabledAgents := agents.getEnabledAgentNames() - var agentNames []string - var pluginOneFound bool - for _, agent := range enabledAgents { - agentNames = append(agentNames, agent.name) - if agent.name == "plugin_one" { - pluginOneFound = true - Expect(agent.isPlugin).To(BeTrue()) // plugin_one is a plugin - } - } - Expect(agentNames).To(ContainElements(LocalAgentName, "plugin_one")) - Expect(agentNames).NotTo(ContainElement("plugin_two")) - Expect(pluginOneFound).To(BeTrue()) - }) - - It("should load plugin agents on demand", func() { - ctx := context.Background() - - // Configure to use our plugin - conf.Server.Agents = "plugin_agent" - - // Add a plugin agent - mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent") - mockLoader.loadedAgents["plugin_agent"] = &MockAgent{ - name: "plugin_agent", - mbid: "plugin-mbid", - } - - // Try to get data from it - mbid, err := agents.GetArtistMBID(ctx, "123", "Artist") - - Expect(err).ToNot(HaveOccurred()) - Expect(mbid).To(Equal("plugin-mbid")) - Expect(mockLoader.pluginCallCount["plugin_agent"]).To(Equal(1)) - }) - - It("should try both built-in and plugin agents", func() { - // Create a mock built-in agent - Register("built_in", func(ds model.DataStore) Interface { - return &MockAgent{ - name: "built_in", - mbid: "built-in-mbid", - } - }) - defer func() { - delete(Map, "built_in") - }() - - // Configure to use both built-in and plugin - conf.Server.Agents = "built_in,plugin_agent" - - // Add a plugin agent - mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent") - mockLoader.loadedAgents["plugin_agent"] = &MockAgent{ - name: "plugin_agent", - mbid: "plugin-mbid", - } - - // Verify that both are in the enabled list - enabledAgents := agents.getEnabledAgentNames() - var agentNames []string - var builtInFound, pluginFound bool - for _, agent := range enabledAgents { - agentNames = append(agentNames, agent.name) - if agent.name == "built_in" { - builtInFound = true - Expect(agent.isPlugin).To(BeFalse()) // built-in agent - } - if agent.name == "plugin_agent" { - pluginFound = true - Expect(agent.isPlugin).To(BeTrue()) // plugin agent - } - } - Expect(agentNames).To(ContainElements("built_in", "plugin_agent", LocalAgentName)) - Expect(builtInFound).To(BeTrue()) - Expect(pluginFound).To(BeTrue()) - }) - - It("should respect the order specified in configuration", func() { - // Create mock built-in agents - Register("agent_a", func(ds model.DataStore) Interface { - return &MockAgent{name: "agent_a"} - }) - Register("agent_b", func(ds model.DataStore) Interface { - return &MockAgent{name: "agent_b"} - }) - defer func() { - delete(Map, "agent_a") - delete(Map, "agent_b") - }() - - // Add plugin agents - mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_x", "plugin_y") - - // Configure specific order - plugin first, then built-ins - conf.Server.Agents = "plugin_y,agent_b,plugin_x,agent_a" - - // Get the agent names - enabledAgents := agents.getEnabledAgentNames() - - // Extract just the names to verify the order - agentNames := slice.Map(enabledAgents, func(a enabledAgent) string { return a.name }) - - // Verify the order matches configuration, with LocalAgentName at the end - Expect(agentNames).To(HaveExactElements("plugin_y", "agent_b", "plugin_x", "agent_a", LocalAgentName)) - }) - - It("should NOT call LoadMediaAgent for built-in agents", func() { - ctx := context.Background() - - // Create a mock built-in agent - Register("builtin_agent", func(ds model.DataStore) Interface { - return &MockAgent{ - name: "builtin_agent", - mbid: "builtin-mbid", - } - }) - defer func() { - delete(Map, "builtin_agent") - }() - - // Configure to use only built-in agents - conf.Server.Agents = "builtin_agent" - - // Call GetArtistMBID which should only use the built-in agent - mbid, err := agents.GetArtistMBID(ctx, "123", "Artist") - - Expect(err).ToNot(HaveOccurred()) - Expect(mbid).To(Equal("builtin-mbid")) - - // Verify LoadMediaAgent was NEVER called (no plugin loading for built-in agents) - Expect(mockLoader.pluginCallCount).To(BeEmpty()) - }) - - It("should NOT call LoadMediaAgent for invalid agent names", func() { - ctx := context.Background() - - // Configure with an invalid agent name (not built-in, not a plugin) - conf.Server.Agents = "invalid_agent" - - // This should only result in using the local agent (as the invalid one is ignored) - _, err := agents.GetArtistMBID(ctx, "123", "Artist") - - // Should get ErrNotFound since only local agent is available and it returns not found for this operation - Expect(err).To(MatchError(ErrNotFound)) - - // Verify LoadMediaAgent was NEVER called for the invalid agent - Expect(mockLoader.pluginCallCount).To(BeEmpty()) - }) - }) -}) diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go index 6163c7f3c..336870482 100644 --- a/core/agents/agents_test.go +++ b/core/agents/agents_test.go @@ -3,10 +3,7 @@ package agents import ( "context" "errors" - "slices" - "time" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -16,36 +13,12 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("cooldowns", func() { - // Calls to one agent overlap, so a short cooldown can land after a long one started. - It("keeps the longer deadline when a shorter park lands after it", func() { - c := cooldowns{until: map[string]time.Time{}} - - c.park("fake", time.Hour) - c.park("fake", time.Millisecond) - - time.Sleep(10 * time.Millisecond) - Expect(c.active("fake")).To(BeTrue()) - }) - - It("extends the deadline when the later park is longer", func() { - c := cooldowns{until: map[string]time.Time{}} - - c.park("fake", time.Millisecond) - c.park("fake", time.Hour) - - time.Sleep(10 * time.Millisecond) - Expect(c.active("fake")).To(BeTrue()) - }) -}) - var _ = Describe("Agents", func() { var ctx context.Context var cancel context.CancelFunc var ds model.DataStore var mfRepo *tests.MockMediaFileRepo BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) ctx, cancel = context.WithCancel(context.Background()) mfRepo = tests.CreateMockMediaFileRepo() ds = &tests.MockDataStore{MockedMediaFile: mfRepo} @@ -55,14 +28,14 @@ var _ = Describe("Agents", func() { var ag *Agents BeforeEach(func() { conf.Server.Agents = "" - ag = createAgents(ds, nil) + ag = New(ds) }) It("calls the placeholder GetArtistImages", func() { - mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One"}, {ID: "2", Title: "Two"}}) + mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One", MbzReleaseTrackID: "111"}, {ID: "2", Title: "Two", MbzReleaseTrackID: "222"}}) songs, err := ag.GetArtistTopSongs(ctx, "123", "John Doe", "mb123", 2) Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(ConsistOf([]Song{{ID: "1", Name: "One"}, {ID: "2", Name: "Two"}})) + Expect(songs).To(ConsistOf([]Song{{Name: "One", MBID: "111"}, {Name: "Two", MBID: "222"}})) }) }) @@ -71,47 +44,23 @@ var _ = Describe("Agents", func() { var mock *mockAgent BeforeEach(func() { mock = &mockAgent{} - Register("fake", func(model.DataStore) Interface { return mock }) - Register("disabled", func(model.DataStore) Interface { return nil }) - Register("empty", func(model.DataStore) Interface { return &emptyAgent{} }) - conf.Server.Agents = "empty,fake,disabled" - ag = createAgents(ds, nil) + Register("fake", func(ds model.DataStore) Interface { + return mock + }) + Register("empty", func(ds model.DataStore) Interface { + return struct { + Interface + }{} + }) + conf.Server.Agents = "empty,fake" + ag = New(ds) Expect(ag.AgentName()).To(Equal("agents")) }) - It("does not register disabled agents", func() { - var ags []string - for _, enabledAgent := range ag.getEnabledAgentNames() { - agent := ag.getAgent(enabledAgent) - if agent != nil { - ags = append(ags, agent.AgentName()) - } - } - // local agent is always appended to the end of the agents list - Expect(ags).To(HaveExactElements("empty", "fake", "local")) - Expect(ags).ToNot(ContainElement("disabled")) - }) - - Describe("availableAgentNames", func() { - It("combines built-in agents with the given plugins", func() { - names := availableAgentNames([]string{"apple-music"}) - Expect(names).To(ContainElements("apple-music", LocalAgentName, "fake", "empty")) - }) - - It("returns the names sorted", func() { - names := availableAgentNames([]string{"zz-plugin", "aa-plugin"}) - Expect(slices.IsSorted(names)).To(BeTrue()) - }) - - It("works when there are no plugins", func() { - Expect(availableAgentNames(nil)).To(ContainElement(LocalAgentName)) - }) - }) - Describe("GetArtistMBID", func() { It("returns on first match", func() { Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid")) - Expect(mock.Args).To(HaveExactElements("123", "test")) + Expect(mock.Args).To(ConsistOf("123", "test")) }) It("returns empty if artist is Various Artists", func() { mbid, err := ag.GetArtistMBID(ctx, consts.VariousArtistsID, consts.VariousArtists) @@ -129,7 +78,7 @@ var _ = Describe("Agents", func() { mock.Err = errors.New("error") _, err := ag.GetArtistMBID(ctx, "123", "test") Expect(err).To(MatchError(ErrNotFound)) - Expect(mock.Args).To(HaveExactElements("123", "test")) + Expect(mock.Args).To(ConsistOf("123", "test")) }) It("interrupts if the context is canceled", func() { cancel() @@ -142,7 +91,7 @@ var _ = Describe("Agents", func() { Describe("GetArtistURL", func() { It("returns on first match", func() { Expect(ag.GetArtistURL(ctx, "123", "test", "mb123")).To(Equal("url")) - Expect(mock.Args).To(HaveExactElements("123", "test", "mb123")) + Expect(mock.Args).To(ConsistOf("123", "test", "mb123")) }) It("returns empty if artist is Various Artists", func() { url, err := ag.GetArtistURL(ctx, consts.VariousArtistsID, consts.VariousArtists, "") @@ -160,7 +109,7 @@ var _ = Describe("Agents", func() { mock.Err = errors.New("error") _, err := ag.GetArtistURL(ctx, "123", "test", "mb123") Expect(err).To(MatchError(ErrNotFound)) - Expect(mock.Args).To(HaveExactElements("123", "test", "mb123")) + Expect(mock.Args).To(ConsistOf("123", "test", "mb123")) }) It("interrupts if the context is canceled", func() { cancel() @@ -173,7 +122,7 @@ var _ = Describe("Agents", func() { Describe("GetArtistBiography", func() { It("returns on first match", func() { Expect(ag.GetArtistBiography(ctx, "123", "test", "mb123")).To(Equal("bio")) - Expect(mock.Args).To(HaveExactElements("123", "test", "mb123")) + Expect(mock.Args).To(ConsistOf("123", "test", "mb123")) }) It("returns empty if artist is Various Artists", func() { bio, err := ag.GetArtistBiography(ctx, consts.VariousArtistsID, consts.VariousArtists, "") @@ -191,7 +140,7 @@ var _ = Describe("Agents", func() { mock.Err = errors.New("error") _, err := ag.GetArtistBiography(ctx, "123", "test", "mb123") Expect(err).To(MatchError(ErrNotFound)) - Expect(mock.Args).To(HaveExactElements("123", "test", "mb123")) + Expect(mock.Args).To(ConsistOf("123", "test", "mb123")) }) It("interrupts if the context is canceled", func() { cancel() @@ -201,115 +150,19 @@ var _ = Describe("Agents", func() { }) }) - Describe("cooldown", func() { - It("skips an agent that returned RetryLaterError until the deadline", func() { - mock.Err = &RetryLaterError{RetryIn: time.Hour} - _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) - - // Immediately after: agent is skipped, not called - mock.Err = nil - calls := mock.Calls - _, err = ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(mock.Calls).To(Equal(calls)) - Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) - }) - - // Providers that throttle without saying for how long (Last.fm sends no delay at all) - // must still be parked, or the aggregate keeps calling them on every request. - It("parks an agent that asked to be retried without a delay", func() { - mock.Err = ErrRetryLater - _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) - - mock.Err = nil - calls := mock.Calls - _, err = ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(mock.Calls).To(Equal(calls), "the default cooldown must outlast the request") - Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) - }) - - It("calls the agent again once the cooldown expires", func() { - mock.Err = &RetryLaterError{RetryIn: 10 * time.Millisecond} - _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) - - mock.Err = nil - Eventually(func() (string, error) { - return ag.GetArtistBiography(ctx, "id", "name", "mbid") - }, 5*time.Second, 10*time.Millisecond).Should(Equal("bio")) - }) - - It("returns ErrNotFound, not ErrRetryLater, when agents failed for other reasons", func() { - mock.Err = errors.New("boom") - _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) - Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) - }) - - // ErrRetryLater tells the caller "nobody answered, do not cache this". A definitive - // answer from any other agent is an answer, throttled peer or not. - It("returns ErrNotFound when another agent answered with a definitive miss", func() { - other := &mockAgent{Err: ErrNotFound} - Register("fake2", func(model.DataStore) Interface { return other }) - conf.Server.Agents = "fake,fake2" - ag = createAgents(ds, nil) - mock.Err = &RetryLaterError{RetryIn: time.Hour} - - _, err := ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) - Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) - - // The cooldown was still recorded for the throttled agent - calls := mock.Calls - _, _ = ag.GetArtistBiography(ctx, "id", "name", "mbid") - Expect(mock.Calls).To(Equal(calls)) - }) - - It("returns ErrNotFound when another agent answered with an empty slice", func() { - empty := &testImageAgent{Name: "emptyImages"} - Register("emptyImages", func(model.DataStore) Interface { return empty }) - conf.Server.Agents = "fake,emptyImages" - ag = createAgents(ds, nil) - mock.Err = &RetryLaterError{RetryIn: time.Hour} - - _, err := ag.GetArtistImages(ctx, "123", "test", "mb123") - Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) - Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) - }) - - It("returns ErrRetryLater from GetSimilarArtists when only cooling agents remain", func() { - mock.Err = &RetryLaterError{RetryIn: time.Hour} - _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2) - Expect(errors.Is(err, ErrRetryLater)).To(BeTrue()) - }) - - It("returns ErrNotFound from GetSimilarArtists when another agent answered", func() { - other := &mockAgent{Err: ErrNotFound} - Register("fake2", func(model.DataStore) Interface { return other }) - conf.Server.Agents = "fake,fake2" - ag = createAgents(ds, nil) - mock.Err = &RetryLaterError{RetryIn: time.Hour} - - _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2) - Expect(errors.Is(err, ErrNotFound)).To(BeTrue()) - Expect(errors.Is(err, ErrRetryLater)).To(BeFalse()) - }) - }) - Describe("GetArtistImages", func() { It("returns on first match", func() { Expect(ag.GetArtistImages(ctx, "123", "test", "mb123")).To(Equal([]ExternalImage{{ URL: "imageUrl", Size: 100, }})) - Expect(mock.Args).To(HaveExactElements("123", "test", "mb123")) + Expect(mock.Args).To(ConsistOf("123", "test", "mb123")) }) It("skips the agent if it returns an error", func() { mock.Err = errors.New("error") _, err := ag.GetArtistImages(ctx, "123", "test", "mb123") Expect(err).To(MatchError("not found")) - Expect(mock.Args).To(HaveExactElements("123", "test", "mb123")) + Expect(mock.Args).To(ConsistOf("123", "test", "mb123")) }) It("interrupts if the context is canceled", func() { cancel() @@ -317,42 +170,6 @@ var _ = Describe("Agents", func() { Expect(err).To(MatchError(ErrNotFound)) Expect(mock.Args).To(BeEmpty()) }) - - Context("with multiple image agents", func() { - var first *testImageAgent - var second *testImageAgent - - BeforeEach(func() { - first = &testImageAgent{Name: "imgFail", Err: errors.New("fail")} - second = &testImageAgent{Name: "imgOk", Images: []ExternalImage{{URL: "ok", Size: 1}}} - Register("imgFail", func(model.DataStore) Interface { return first }) - Register("imgOk", func(model.DataStore) Interface { return second }) - }) - - It("falls back to the next agent on error", func() { - conf.Server.Agents = "imgFail,imgOk" - ag = createAgents(ds, nil) - - images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid") - Expect(err).ToNot(HaveOccurred()) - Expect(images).To(Equal([]ExternalImage{{URL: "ok", Size: 1}})) - Expect(first.Args).To(HaveExactElements("id", "artist", "mbid")) - Expect(second.Args).To(HaveExactElements("id", "artist", "mbid")) - }) - - It("falls back if the first agent returns no images", func() { - first.Err = nil - first.Images = []ExternalImage{} - conf.Server.Agents = "imgFail,imgOk" - ag = createAgents(ds, nil) - - images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid") - Expect(err).ToNot(HaveOccurred()) - Expect(images).To(Equal([]ExternalImage{{URL: "ok", Size: 1}})) - Expect(first.Args).To(HaveExactElements("id", "artist", "mbid")) - Expect(second.Args).To(HaveExactElements("id", "artist", "mbid")) - }) - }) }) Describe("GetSimilarArtists", func() { @@ -361,13 +178,13 @@ var _ = Describe("Agents", func() { Name: "Joe Dohn", MBID: "mbid321", }})) - Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 1)) + Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 1)) }) It("skips the agent if it returns an error", func() { mock.Err = errors.New("error") _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1) Expect(err).To(MatchError(ErrNotFound)) - Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 1)) + Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 1)) }) It("interrupts if the context is canceled", func() { cancel() @@ -379,19 +196,17 @@ var _ = Describe("Agents", func() { Describe("GetArtistTopSongs", func() { It("returns on first match", func() { - conf.Server.DevExternalArtistFetchMultiplier = 1 Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{ Name: "A Song", MBID: "mbid444", }})) - Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2)) + Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 2)) }) It("skips the agent if it returns an error", func() { - conf.Server.DevExternalArtistFetchMultiplier = 1 mock.Err = errors.New("error") _, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2) Expect(err).To(MatchError(ErrNotFound)) - Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2)) + Expect(mock.Args).To(ConsistOf("123", "test", "mb123", 2)) }) It("interrupts if the context is canceled", func() { cancel() @@ -399,14 +214,6 @@ var _ = Describe("Agents", func() { Expect(err).To(MatchError(ErrNotFound)) Expect(mock.Args).To(BeEmpty()) }) - It("fetches with multiplier", func() { - conf.Server.DevExternalArtistFetchMultiplier = 2 - Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{ - Name: "A Song", - MBID: "mbid444", - }})) - Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 4)) - }) }) Describe("GetAlbumInfo", func() { @@ -416,14 +223,26 @@ var _ = Describe("Agents", func() { MBID: "mbid444", Description: "A Description", URL: "External URL", + Images: []ExternalImage{ + { + Size: 174, + URL: "https://lastfm.freetls.fastly.net/i/u/174s/00000000000000000000000000000000.png", + }, { + Size: 64, + URL: "https://lastfm.freetls.fastly.net/i/u/64s/00000000000000000000000000000000.png", + }, { + Size: 34, + URL: "https://lastfm.freetls.fastly.net/i/u/34s/00000000000000000000000000000000.png", + }, + }, })) - Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid")) + Expect(mock.Args).To(ConsistOf("album", "artist", "mbid")) }) It("skips the agent if it returns an error", func() { mock.Err = errors.New("error") _, err := ag.GetAlbumInfo(ctx, "album", "artist", "mbid") Expect(err).To(MatchError(ErrNotFound)) - Expect(mock.Args).To(HaveExactElements("album", "artist", "mbid")) + Expect(mock.Args).To(ConsistOf("album", "artist", "mbid")) }) It("interrupts if the context is canceled", func() { cancel() @@ -432,137 +251,12 @@ var _ = Describe("Agents", func() { Expect(mock.Args).To(BeEmpty()) }) }) - - Describe("GetSimilarSongsByTrack", func() { - It("returns on first match", func() { - Expect(ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)).To(Equal([]Song{{ - Name: "Similar Song", - MBID: "mbid555", - }})) - Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2)) - }) - It("skips the agent if it returns an error", func() { - mock.Err = errors.New("error") - _, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2) - Expect(err).To(MatchError(ErrNotFound)) - Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2)) - }) - It("interrupts if the context is canceled", func() { - cancel() - _, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2) - Expect(err).To(MatchError(ErrNotFound)) - Expect(mock.Args).To(BeEmpty()) - }) - }) - - Describe("GetSimilarSongsByAlbum", func() { - It("returns on first match", func() { - Expect(ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)).To(Equal([]Song{{ - Name: "Album Similar Song", - MBID: "mbid666", - }})) - Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2)) - }) - It("skips the agent if it returns an error", func() { - mock.Err = errors.New("error") - _, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2) - Expect(err).To(MatchError(ErrNotFound)) - Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2)) - }) - It("interrupts if the context is canceled", func() { - cancel() - _, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2) - Expect(err).To(MatchError(ErrNotFound)) - Expect(mock.Args).To(BeEmpty()) - }) - }) - - Describe("GetSimilarSongsByArtist", func() { - It("returns on first match", func() { - Expect(ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)).To(Equal([]Song{{ - Name: "Artist Similar Song", - MBID: "mbid777", - }})) - Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2)) - }) - It("skips the agent if it returns an error", func() { - mock.Err = errors.New("error") - _, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2) - Expect(err).To(MatchError(ErrNotFound)) - Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2)) - }) - It("interrupts if the context is canceled", func() { - cancel() - _, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2) - Expect(err).To(MatchError(ErrNotFound)) - Expect(mock.Args).To(BeEmpty()) - }) - }) - }) - - Describe("Image retriever enumeration", func() { - var ag *Agents - var artistImg, artistImg2 *testImageAgent - var albumImg, albumImg2 *testAlbumImageAgent - - BeforeEach(func() { - artistImg = &testImageAgent{Name: "artistImg"} - artistImg2 = &testImageAgent{Name: "artistImg2"} - albumImg = &testAlbumImageAgent{name: "albumImg"} - albumImg2 = &testAlbumImageAgent{name: "albumImg2"} - Register("artistImg", func(model.DataStore) Interface { return artistImg }) - Register("artistImg2", func(model.DataStore) Interface { return artistImg2 }) - Register("albumImg", func(model.DataStore) Interface { return albumImg }) - Register("albumImg2", func(model.DataStore) Interface { return albumImg2 }) - Register("noImages", func(model.DataStore) Interface { return &emptyAgent{} }) - }) - - Describe("ArtistImageAgents", func() { - It("returns only ArtistImageRetriever agents, named, in configured order", func() { - conf.Server.Agents = "artistImg,noImages,artistImg2" - ag = createAgents(ds, nil) - - result := ag.ArtistImageAgents() - Expect(result).To(HaveLen(2)) - Expect(result[0].Name).To(Equal("artistImg")) - Expect(result[0].Retriever).To(BeIdenticalTo(artistImg)) - Expect(result[1].Name).To(Equal("artistImg2")) - Expect(result[1].Retriever).To(BeIdenticalTo(artistImg2)) - }) - - It("is empty when external services are disabled", func() { - conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false - ag = createAgents(ds, nil) - Expect(ag.ArtistImageAgents()).To(BeEmpty()) - }) - }) - - Describe("AlbumImageAgents", func() { - It("returns only AlbumImageRetriever agents, named, in configured order", func() { - conf.Server.Agents = "albumImg,noImages,albumImg2" - ag = createAgents(ds, nil) - - result := ag.AlbumImageAgents() - Expect(result).To(HaveLen(2)) - Expect(result[0].Name).To(Equal("albumImg")) - Expect(result[0].Retriever).To(BeIdenticalTo(albumImg)) - Expect(result[1].Name).To(Equal("albumImg2")) - Expect(result[1].Retriever).To(BeIdenticalTo(albumImg2)) - }) - - It("is empty when external services are disabled", func() { - conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false - ag = createAgents(ds, nil) - Expect(ag.AlbumImageAgents()).To(BeEmpty()) - }) - }) }) }) type mockAgent struct { - Args []any - Err error - Calls int + Args []interface{} + Err error } func (a *mockAgent) AgentName() string { @@ -570,7 +264,7 @@ func (a *mockAgent) AgentName() string { } func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (string, error) { - a.Args = []any{id, name} + a.Args = []interface{}{id, name} if a.Err != nil { return "", a.Err } @@ -578,7 +272,7 @@ func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (st } func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (string, error) { - a.Args = []any{id, name, mbid} + a.Args = []interface{}{id, name, mbid} if a.Err != nil { return "", a.Err } @@ -586,8 +280,7 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri } func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) { - a.Args = []any{id, name, mbid} - a.Calls++ + a.Args = []interface{}{id, name, mbid} if a.Err != nil { return "", a.Err } @@ -595,7 +288,7 @@ func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) } func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) { - a.Args = []any{id, name, mbid} + a.Args = []interface{}{id, name, mbid} if a.Err != nil { return nil, a.Err } @@ -606,7 +299,7 @@ func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([ } func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string, limit int) ([]Artist, error) { - a.Args = []any{id, name, mbid, limit} + a.Args = []interface{}{id, name, mbid, limit} if a.Err != nil { return nil, a.Err } @@ -617,7 +310,7 @@ func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string, } func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid string, count int) ([]Song, error) { - a.Args = []any{id, artistName, mbid, count} + a.Args = []interface{}{id, artistName, mbid, count} if a.Err != nil { return nil, a.Err } @@ -628,7 +321,7 @@ func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid st } func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) { - a.Args = []any{name, artist, mbid} + a.Args = []interface{}{name, artist, mbid} if a.Err != nil { return nil, a.Err } @@ -637,74 +330,17 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) MBID: "mbid444", Description: "A Description", URL: "External URL", + Images: []ExternalImage{ + { + Size: 174, + URL: "https://lastfm.freetls.fastly.net/i/u/174s/00000000000000000000000000000000.png", + }, { + Size: 64, + URL: "https://lastfm.freetls.fastly.net/i/u/64s/00000000000000000000000000000000.png", + }, { + Size: 34, + URL: "https://lastfm.freetls.fastly.net/i/u/34s/00000000000000000000000000000000.png", + }, + }, }, nil } - -func (a *mockAgent) GetSimilarSongsByTrack(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) { - a.Args = []any{id, name, artist, mbid, count} - if a.Err != nil { - return nil, a.Err - } - return []Song{{ - Name: "Similar Song", - MBID: "mbid555", - }}, nil -} - -func (a *mockAgent) GetSimilarSongsByAlbum(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) { - a.Args = []any{id, name, artist, mbid, count} - if a.Err != nil { - return nil, a.Err - } - return []Song{{ - Name: "Album Similar Song", - MBID: "mbid666", - }}, nil -} - -func (a *mockAgent) GetSimilarSongsByArtist(_ context.Context, id, name, mbid string, count int) ([]Song, error) { - a.Args = []any{id, name, mbid, count} - if a.Err != nil { - return nil, a.Err - } - return []Song{{ - Name: "Artist Similar Song", - MBID: "mbid777", - }}, nil -} - -type emptyAgent struct { - Interface -} - -func (e *emptyAgent) AgentName() string { - return "empty" -} - -type testImageAgent struct { - Name string - Images []ExternalImage - Err error - Args []any -} - -func (t *testImageAgent) AgentName() string { return t.Name } - -func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) { - t.Args = []any{id, name, mbid} - return t.Images, t.Err -} - -type testAlbumImageAgent struct { - name string - Images []ExternalImage - Err error - Args []any -} - -func (t *testAlbumImageAgent) AgentName() string { return t.name } - -func (t *testAlbumImageAgent) GetAlbumImages(_ context.Context, name, artist, mbid string) ([]ExternalImage, error) { - t.Args = []any{name, artist, mbid} - return t.Images, t.Err -} diff --git a/core/agents/interfaces.go b/core/agents/interfaces.go index 9225a0442..00f75627d 100644 --- a/core/agents/interfaces.go +++ b/core/agents/interfaces.go @@ -3,11 +3,7 @@ package agents import ( "context" "errors" - "fmt" - "strconv" - "time" - "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/model" ) @@ -17,16 +13,15 @@ type Interface interface { AgentName() string } -// AlbumInfo contains album metadata (no images) type AlbumInfo struct { Name string MBID string Description string URL string + Images []ExternalImage } type Artist struct { - ID string Name string MBID string } @@ -37,78 +32,19 @@ type ExternalImage struct { } type Song struct { - ID string - Name string - MBID string - ISRC string - Artists []Artist - Album string - AlbumMBID string - Duration uint32 // Duration in milliseconds, 0 means unknown + Name string + MBID string } -// Equals reports strict whole-value equality, used to dedup identical input songs. It hashes -// rather than comparing with ==, which the Artists slice makes illegal. -func (s Song) Equals(other Song) bool { - h1, _ := hashstructure.Hash(s, nil) - h2, _ := hashstructure.Hash(other, nil) - return h1 == h2 -} +var ( + ErrNotFound = errors.New("not found") +) -// ErrNotFound means the provider answered and had nothing. Return the underlying error -// for a fault instead, or callers that back off on faults will treat it as definitive. -var ErrNotFound = errors.New("not found") - -// ErrRetryLater is the zero-delay RetryLaterError: the provider is temporarily unavailable -// or throttling us, but did not say for how long. Both errors.Is(err, ErrRetryLater) and -// errors.AsType[*RetryLaterError] match it and every delay-carrying variant. -// Treat it as immutable; build a new RetryLaterError to name a delay. -var ErrRetryLater = &RetryLaterError{} - -// RetryLaterError asks callers to back off, optionally for the delay the provider requested. -type RetryLaterError struct { - RetryIn time.Duration -} - -func (e *RetryLaterError) Error() string { - if e.RetryIn > 0 { - return fmt.Sprintf("retry later (in %s)", e.RetryIn) - } - return "retry later" -} - -func (e *RetryLaterError) Is(target error) bool { - _, ok := target.(*RetryLaterError) - return ok -} - -// MaxRetryIn caps a delay parsed from a provider, so a bogus value cannot park it indefinitely. -const MaxRetryIn = time.Hour -const maxRetryInSeconds = int(MaxRetryIn / time.Second) - -// ParseRetryIn reads a provider's delay given in seconds, from a header or a plugin token. -// Anything unparseable or non-positive means unspecified. -func ParseRetryIn(seconds string) time.Duration { - // Clamp in seconds: scaling first would wrap a huge value past int64 nanoseconds, - // turning "wait an age" into a fraction of a second. Parse at a fixed width so the - // cap holds on the 32-bit targets we ship, where a plain Atoi would overflow first. - secs, err := strconv.ParseInt(seconds, 10, 64) - if err != nil || secs <= 0 { - return 0 - } - return time.Duration(min(secs, int64(maxRetryInSeconds))) * time.Second -} - -// AlbumInfoRetriever provides album info (no images) +// TODO Break up this interface in more specific methods, like artists type AlbumInfoRetriever interface { GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) } -// AlbumImageRetriever provides album images -type AlbumImageRetriever interface { - GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) -} - type ArtistMBIDRetriever interface { GetArtistMBID(ctx context.Context, id string, name string) (string, error) } @@ -133,41 +69,6 @@ type ArtistTopSongsRetriever interface { GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) } -// SimilarSongsByTrackRetriever provides similar songs based on a specific track -type SimilarSongsByTrackRetriever interface { - // GetSimilarSongsByTrack returns songs similar to the given track. - // Parameters: - // - id: local mediafile ID - // - name: track title - // - artist: artist name - // - mbid: MusicBrainz recording ID (may be empty) - // - count: maximum number of results - GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) -} - -// SimilarSongsByAlbumRetriever provides similar songs based on an album -type SimilarSongsByAlbumRetriever interface { - // GetSimilarSongsByAlbum returns songs similar to tracks on the given album. - // Parameters: - // - id: local album ID - // - name: album name - // - artist: album artist name - // - mbid: MusicBrainz release ID (may be empty) - // - count: maximum number of results - GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) -} - -// SimilarSongsByArtistRetriever provides similar songs based on an artist -type SimilarSongsByArtistRetriever interface { - // GetSimilarSongsByArtist returns songs similar to the artist's catalog. - // Parameters: - // - id: local artist ID - // - name: artist name - // - mbid: MusicBrainz artist ID (may be empty) - // - count: maximum number of results - GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error) -} - var Map map[string]Constructor func Register(name string, init Constructor) { diff --git a/core/agents/interfaces_test.go b/core/agents/interfaces_test.go deleted file mode 100644 index 6acbc545d..000000000 --- a/core/agents/interfaces_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package agents_test - -import ( - "errors" - "fmt" - "time" - - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/scrobbler" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("RetryLaterError", func() { - It("matches the ErrRetryLater sentinel via errors.Is", func() { - err := &agents.RetryLaterError{RetryIn: 30 * time.Second} - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - }) - - It("matches through errors.Join and wrapping", func() { - err := fmt.Errorf("calling LB: %w", errors.Join(errors.New("http 429"), &agents.RetryLaterError{})) - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - }) - - It("exposes the delay through the wrapped error", func() { - err := errors.Join(errors.New("http 429"), &agents.RetryLaterError{RetryIn: 42 * time.Second}) - retry, ok := errors.AsType[*agents.RetryLaterError](err) - Expect(ok).To(BeTrue()) - Expect(retry.RetryIn).To(Equal(42 * time.Second)) - }) - - It("matches the sentinel too, reporting no delay", func() { - retry, ok := errors.AsType[*agents.RetryLaterError](agents.ErrRetryLater) - Expect(ok).To(BeTrue()) - Expect(retry.RetryIn).To(BeZero()) - }) - - It("is the same sentinel as scrobbler.ErrRetryLater", func() { - Expect(errors.Is(scrobbler.ErrRetryLater, agents.ErrRetryLater)).To(BeTrue()) - Expect(errors.Is(&agents.RetryLaterError{}, scrobbler.ErrRetryLater)).To(BeTrue()) - }) -}) diff --git a/core/agents/lastfm/agent.go b/core/agents/lastfm/agent.go new file mode 100644 index 000000000..f12cf2d00 --- /dev/null +++ b/core/agents/lastfm/agent.go @@ -0,0 +1,322 @@ +package lastfm + +import ( + "context" + "errors" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" +) + +const ( + lastFMAgentName = "lastfm" + sessionKeyProperty = "LastFMSessionKey" +) + +var ignoredBiographies = []string{ + // Unknown Artist + `Read more on Last.fm")) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) + }) + + It("returns an error if Last.fm call fails", func() { + httpClient.Err = errors.New("error") + _, err := agent.GetArtistBiography(ctx, "123", "U2", "mbid-1234") + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) + }) + + It("returns an error if Last.fm call returns an error", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200} + _, err := agent.GetArtistBiography(ctx, "123", "U2", "mbid-1234") + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) + }) + + It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} + _, err := agent.GetArtistBiography(ctx, "123", "U2", "") + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + }) + + Context("MBID non existent in Last.fm", func() { + It("calls again when the response is artist == [unknown]", func() { + f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.unknown.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + _, _ = agent.GetArtistBiography(ctx, "123", "U2", "mbid-1234") + Expect(httpClient.RequestCount).To(Equal(2)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) + }) + It("calls again when last.fm returns an error 6", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} + _, _ = agent.GetArtistBiography(ctx, "123", "U2", "mbid-1234") + Expect(httpClient.RequestCount).To(Equal(2)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) + }) + }) + }) + + Describe("GetSimilarArtists", func() { + var agent *lastfmAgent + var httpClient *tests.FakeHttpClient + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client := newClient("API_KEY", "SECRET", "pt", httpClient) + agent = lastFMConstructor(ds) + agent.client = client + }) + + It("returns similar artists", func() { + f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + Expect(agent.GetSimilarArtists(ctx, "123", "U2", "mbid-1234", 2)).To(Equal([]agents.Artist{ + {Name: "Passengers", MBID: "e110c11f-1c94-4471-a350-c38f46b29389"}, + {Name: "INXS", MBID: "481bf5f9-2e7c-4c44-b08a-05b32bc7c00d"}, + })) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) + }) + + It("returns an error if Last.fm call fails", func() { + httpClient.Err = errors.New("error") + _, err := agent.GetSimilarArtists(ctx, "123", "U2", "mbid-1234", 2) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) + }) + + It("returns an error if Last.fm call returns an error", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200} + _, err := agent.GetSimilarArtists(ctx, "123", "U2", "mbid-1234", 2) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) + }) + + It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} + _, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + }) + + Context("MBID non existent in Last.fm", func() { + It("calls again when the response is artist == [unknown]", func() { + f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.unknown.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + _, _ = agent.GetSimilarArtists(ctx, "123", "U2", "mbid-1234", 2) + Expect(httpClient.RequestCount).To(Equal(2)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) + }) + It("calls again when last.fm returns an error 6", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} + _, _ = agent.GetSimilarArtists(ctx, "123", "U2", "mbid-1234", 2) + Expect(httpClient.RequestCount).To(Equal(2)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) + }) + }) + }) + + Describe("GetArtistTopSongs", func() { + var agent *lastfmAgent + var httpClient *tests.FakeHttpClient + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client := newClient("API_KEY", "SECRET", "pt", httpClient) + agent = lastFMConstructor(ds) + agent.client = client + }) + + It("returns top songs", func() { + f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + Expect(agent.GetArtistTopSongs(ctx, "123", "U2", "mbid-1234", 2)).To(Equal([]agents.Song{ + {Name: "Beautiful Day", MBID: "f7f264d0-a89b-4682-9cd7-a4e7c37637af"}, + {Name: "With or Without You", MBID: "6b9a509f-6907-4a6e-9345-2f12da09ba4b"}, + })) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) + }) + + It("returns an error if Last.fm call fails", func() { + httpClient.Err = errors.New("error") + _, err := agent.GetArtistTopSongs(ctx, "123", "U2", "mbid-1234", 2) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) + }) + + It("returns an error if Last.fm call returns an error", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200} + _, err := agent.GetArtistTopSongs(ctx, "123", "U2", "mbid-1234", 2) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) + }) + + It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} + _, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2) + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + }) + + Context("MBID non existent in Last.fm", func() { + It("calls again when the response is artist == [unknown]", func() { + f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.unknown.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + _, _ = agent.GetArtistTopSongs(ctx, "123", "U2", "mbid-1234", 2) + Expect(httpClient.RequestCount).To(Equal(2)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) + }) + It("calls again when last.fm returns an error 6", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} + _, _ = agent.GetArtistTopSongs(ctx, "123", "U2", "mbid-1234", 2) + Expect(httpClient.RequestCount).To(Equal(2)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) + }) + }) + }) + + Describe("Scrobbling", func() { + var agent *lastfmAgent + var httpClient *tests.FakeHttpClient + var track *model.MediaFile + BeforeEach(func() { + _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1") + httpClient = &tests.FakeHttpClient{} + client := newClient("API_KEY", "SECRET", "en", httpClient) + agent = lastFMConstructor(ds) + agent.client = client + track = &model.MediaFile{ + ID: "123", + Title: "Track Title", + Album: "Track Album", + Artist: "Track Artist", + AlbumArtist: "Track AlbumArtist", + TrackNumber: 1, + Duration: 180, + MbzTrackID: "mbz-123", + } + }) + + Describe("NowPlaying", func() { + It("calls Last.fm with correct params", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200} + + err := agent.NowPlaying(ctx, "user-1", track) + + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) + sentParams := httpClient.SavedRequest.URL.Query() + Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying")) + Expect(sentParams.Get("sk")).To(Equal("SK-1")) + Expect(sentParams.Get("track")).To(Equal(track.Title)) + Expect(sentParams.Get("album")).To(Equal(track.Album)) + Expect(sentParams.Get("artist")).To(Equal(track.Artist)) + Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist)) + Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber))) + Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32))) + Expect(sentParams.Get("mbid")).To(Equal(track.MbzTrackID)) + }) + + It("returns ErrNotAuthorized if user is not linked", func() { + err := agent.NowPlaying(ctx, "user-2", track) + Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) + }) + }) + + Describe("scrobble", func() { + It("calls Last.fm with correct params", func() { + ts := time.Now() + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200} + + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts}) + + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) + sentParams := httpClient.SavedRequest.URL.Query() + Expect(sentParams.Get("method")).To(Equal("track.scrobble")) + Expect(sentParams.Get("sk")).To(Equal("SK-1")) + Expect(sentParams.Get("track")).To(Equal(track.Title)) + Expect(sentParams.Get("album")).To(Equal(track.Album)) + Expect(sentParams.Get("artist")).To(Equal(track.Artist)) + Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist)) + Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber))) + Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32))) + Expect(sentParams.Get("mbid")).To(Equal(track.MbzTrackID)) + Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10))) + }) + + It("skips songs with less than 31 seconds", func() { + track.Duration = 29 + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200} + + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest).To(BeNil()) + }) + + It("returns ErrNotAuthorized if user is not linked", func() { + err := agent.Scrobble(ctx, "user-2", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) + }) + + It("returns ErrRetryLater on error 11", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"error":11,"message":"Service Offline - This service is temporarily offline. Try again later."}`)), + StatusCode: 400, + } + + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + Expect(err).To(MatchError(scrobbler.ErrRetryLater)) + }) + + It("returns ErrRetryLater on error 16", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"error":16,"message":"There was a temporary error processing your request. Please try again"}`)), + StatusCode: 400, + } + + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + Expect(err).To(MatchError(scrobbler.ErrRetryLater)) + }) + + It("returns ErrRetryLater on http errors", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`internal server error`)), + StatusCode: 500, + } + + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + Expect(err).To(MatchError(scrobbler.ErrRetryLater)) + }) + + It("returns ErrUnrecoverable on other errors", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"error":8,"message":"Operation failed - Something else went wrong"}`)), + StatusCode: 400, + } + + err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}) + Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) + }) + }) + }) + + Describe("GetAlbumInfo", func() { + var agent *lastfmAgent + var httpClient *tests.FakeHttpClient + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client := newClient("API_KEY", "SECRET", "pt", httpClient) + agent = lastFMConstructor(ds) + agent.client = client + }) + + It("returns the biography", func() { + f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{ + Name: "Believe", + MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62", + Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob Read more on Last.fm.", + URL: "https://www.last.fm/music/Cher/Believe", + Images: []agents.ExternalImage{ + { + URL: "https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png", + Size: 34, + }, + { + URL: "https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png", + Size: 64, + }, + { + URL: "https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png", + Size: 174, + }, + { + URL: "https://lastfm.freetls.fastly.net/i/u/300x300/3b54885952161aaea4ce2965b2db1638.png", + Size: 300, + }, + }, + })) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62")) + }) + + It("returns empty images if no images are available", func() { + f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty_urls.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + Expect(agent.GetAlbumInfo(ctx, "The Definitive Less Damage And More Joy", "The Jesus and Mary Chain", "")).To(Equal(&agents.AlbumInfo{ + Name: "The Definitive Less Damage And More Joy", + URL: "https://www.last.fm/music/The+Jesus+and+Mary+Chain/The+Definitive+Less+Damage+And+More+Joy", + Images: []agents.ExternalImage{}, + })) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("The Definitive Less Damage And More Joy")) + }) + + It("returns an error if Last.fm call fails", func() { + httpClient.Err = errors.New("error") + _, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) + }) + + It("returns an error if Last.fm call returns an error", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200} + _, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234")) + }) + + It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} + _, err := agent.GetAlbumInfo(ctx, "123", "U2", "") + Expect(err).To(HaveOccurred()) + Expect(httpClient.RequestCount).To(Equal(1)) + }) + + Context("MBID non existent in Last.fm", func() { + It("calls again when last.fm returns an error 6", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200} + _, _ = agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234") + Expect(httpClient.RequestCount).To(Equal(2)) + Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty()) + }) + }) + }) +}) diff --git a/adapters/lastfm/auth_router.go b/core/agents/lastfm/auth_router.go similarity index 75% rename from adapters/lastfm/auth_router.go rename to core/agents/lastfm/auth_router.go index ec1bb9290..372b5b632 100644 --- a/adapters/lastfm/auth_router.go +++ b/core/agents/lastfm/auth_router.go @@ -18,8 +18,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/utils/httpclient" - "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils" ) //go:embed token_received.html @@ -42,8 +41,10 @@ func NewRouter(ds model.DataStore) *Router { sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } r.Handler = r.routes() - hc := httpclient.New(consts.DefaultHttpClientTimeOut) - r.client = newClient(r.apiKey, r.secret, hc) + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } + r.client = newClient(r.apiKey, r.secret, "en", hc) return r } @@ -64,9 +65,7 @@ func (s *Router) routes() http.Handler { } func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { - resp := map[string]any{ - "apiKey": s.apiKey, - } + resp := map[string]interface{}{} u, _ := request.UserFrom(r.Context()) key, err := s.sessionKeys.Get(r.Context(), u.ID) if err != nil && !errors.Is(err, model.ErrNotFound) { @@ -76,13 +75,6 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { return } resp["status"] = key != "" - linkToken, err := createLinkToken(u.ID) - if err != nil { - log.Error(r.Context(), "Could not create LastFM link token", "userId", u.ID, err) - _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) - return - } - resp["linkToken"] = linkToken _ = rest.RespondWithJSON(w, http.StatusOK, resp) } @@ -97,32 +89,25 @@ func (s *Router) unlink(w http.ResponseWriter, r *http.Request) { } func (s *Router) callback(w http.ResponseWriter, r *http.Request) { - p := req.Params(r) - token, err := p.String("token") - if err != nil { + token := utils.ParamString(r, "token") + if token == "" { _ = rest.RespondWithError(w, http.StatusBadRequest, "token not received") return } - linkToken, err := p.String("uid") - if err != nil { + uid := utils.ParamString(r, "uid") + if uid == "" { _ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received") return } - uid, err := verifyLinkToken(linkToken) - if err != nil { - log.Warn(r.Context(), "Rejected LastFM callback with invalid link token", "requestId", middleware.GetReqID(r.Context()), err) - _ = rest.RespondWithError(w, http.StatusBadRequest, "invalid link token") - return - } // Need to add user to context, as this is a non-authenticated endpoint, so it does not // automatically contain any user info ctx := request.WithUser(r.Context(), model.User{ID: uid}) - err = s.fetchSessionKey(ctx, uid, token) + err := s.fetchSessionKey(ctx, uid, token) if err != nil { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx))) //nolint:gosec + _, _ = w.Write([]byte("An error occurred while authorizing with Last.fm. \n\nRequest ID: " + middleware.GetReqID(ctx))) return } @@ -132,7 +117,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) { func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error { sessionKey, err := s.client.getSession(ctx, token) if err != nil { - log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, + log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token, "requestId", middleware.GetReqID(ctx), err) return err } diff --git a/adapters/lastfm/client.go b/core/agents/lastfm/client.go similarity index 74% rename from adapters/lastfm/client.go rename to core/agents/lastfm/client.go index 7b2601cf9..a993ddc51 100644 --- a/adapters/lastfm/client.go +++ b/core/agents/lastfm/client.go @@ -5,25 +5,20 @@ import ( "crypto/md5" "encoding/hex" "encoding/json" - "errors" "fmt" "net/http" "net/url" - "slices" "sort" "strconv" "strings" "time" - "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" + "golang.org/x/exp/slices" ) const ( apiBaseUrl = "https://ws.audioscrobbler.com/2.0/" - // errCodeRateLimit is Last.fm's "rate limit exceeded"; it arrives in the body, with HTTP 200 - // and no rate-limit headers, so the body code is the only signal. - errCodeRateLimit = 29 ) type lastFMError struct { @@ -39,29 +34,24 @@ type httpDoer interface { Do(req *http.Request) (*http.Response, error) } -func newClient(apiKey string, secret string, hc httpDoer) *client { - return &client{apiKey, secret, hc} +func newClient(apiKey string, secret string, lang string, hc httpDoer) *client { + return &client{apiKey, secret, lang, hc} } type client struct { apiKey string secret string + lang string hc httpDoer } -// escapePlus works around Last.fm decoding artist.* and track.* params twice, turning "+" into a space. -// album.getInfo decodes only once, so it must not use this. -func escapePlus(s string) string { - return strings.ReplaceAll(s, "+", "%2B") -} - -func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) { +func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string) (*Album, error) { params := url.Values{} params.Add("method", "album.getInfo") params.Add("album", name) params.Add("artist", artist) params.Add("mbid", mbid) - params.Add("lang", lang) + params.Add("lang", c.lang) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { return nil, err @@ -69,11 +59,12 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m return &response.Album, nil } -func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) { +func (c *client) artistGetInfo(ctx context.Context, name string, mbid string) (*Artist, error) { params := url.Values{} params.Add("method", "artist.getInfo") - params.Add("artist", escapePlus(name)) - params.Add("lang", lang) + params.Add("artist", name) + params.Add("mbid", mbid) + params.Add("lang", c.lang) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { return nil, err @@ -81,10 +72,11 @@ func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (* return &response.Artist, nil } -func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) { +func (c *client) artistGetSimilar(ctx context.Context, name string, mbid string, limit int) (*SimilarArtists, error) { params := url.Values{} params.Add("method", "artist.getSimilar") - params.Add("artist", escapePlus(name)) + params.Add("artist", name) + params.Add("mbid", mbid) params.Add("limit", strconv.Itoa(limit)) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { @@ -93,10 +85,11 @@ func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) ( return &response.SimilarArtists, nil } -func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) { +func (c *client) artistGetTopTracks(ctx context.Context, name string, mbid string, limit int) (*TopTracks, error) { params := url.Values{} params.Add("method", "artist.getTopTracks") - params.Add("artist", escapePlus(name)) + params.Add("artist", name) + params.Add("mbid", mbid) params.Add("limit", strconv.Itoa(limit)) response, err := c.makeRequest(ctx, http.MethodGet, params, false) if err != nil { @@ -105,19 +98,6 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) return &response.TopTracks, nil } -func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) { - params := url.Values{} - params.Add("method", "track.getSimilar") - params.Add("track", escapePlus(name)) - params.Add("artist", escapePlus(artist)) - params.Add("limit", strconv.Itoa(limit)) - response, err := c.makeRequest(ctx, http.MethodGet, params, false) - if err != nil { - return nil, err - } - return &response.SimilarTracks, nil -} - func (c *client) GetToken(ctx context.Context) (string, error) { params := url.Values{} params.Add("method", "auth.getToken") @@ -208,15 +188,8 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu c.sign(params) } - var req *http.Request - if method == http.MethodPost { - body := strings.NewReader(params.Encode()) - req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, body) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - } else { - req, _ = http.NewRequestWithContext(ctx, method, apiBaseUrl, nil) - req.URL.RawQuery = params.Encode() - } + req, _ := http.NewRequestWithContext(ctx, method, apiBaseUrl, nil) + req.URL.RawQuery = params.Encode() log.Trace(ctx, fmt.Sprintf("Sending Last.fm %s request", req.Method), "url", req.URL) resp, err := c.hc.Do(req) @@ -236,11 +209,7 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu return nil, jsonErr } if response.Error != 0 { - var err error = &lastFMError{Code: response.Error, Message: response.Message} - if response.Error == errCodeRateLimit { - err = errors.Join(err, &agents.RetryLaterError{}) - } - return &response, err + return &response, &lastFMError{Code: response.Error, Message: response.Message} } return &response, nil diff --git a/core/agents/lastfm/client_test.go b/core/agents/lastfm/client_test.go new file mode 100644 index 000000000..491ddfa77 --- /dev/null +++ b/core/agents/lastfm/client_test.go @@ -0,0 +1,173 @@ +package lastfm + +import ( + "bytes" + "context" + "crypto/md5" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("client", func() { + var httpClient *tests.FakeHttpClient + var client *client + + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client = newClient("API_KEY", "SECRET", "pt", httpClient) + }) + + Describe("albumGetInfo", func() { + It("returns an album on successful response", func() { + f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234") + Expect(err).To(BeNil()) + Expect(album.Name).To(Equal("Believe")) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo")) + }) + }) + + Describe("artistGetInfo", func() { + It("returns an artist for a successful response", func() { + f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + artist, err := client.artistGetInfo(context.Background(), "U2", "123") + Expect(err).To(BeNil()) + Expect(artist.Name).To(Equal("U2")) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=123&method=artist.getInfo")) + }) + + It("fails if Last.fm returns an http status != 200", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)), + StatusCode: 500, + } + + _, err := client.artistGetInfo(context.Background(), "U2", "123") + Expect(err).To(MatchError("last.fm http status: (500)")) + }) + + It("fails if Last.fm returns an http status != 200", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"error":3,"message":"Invalid Method - No method with that name in this package"}`)), + StatusCode: 400, + } + + _, err := client.artistGetInfo(context.Background(), "U2", "123") + Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"})) + }) + + It("fails if Last.fm returns an error", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"error":6,"message":"The artist you supplied could not be found"}`)), + StatusCode: 200, + } + + _, err := client.artistGetInfo(context.Background(), "U2", "123") + Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"})) + }) + + It("fails if HttpClient.Do() returns error", func() { + httpClient.Err = errors.New("generic error") + + _, err := client.artistGetInfo(context.Background(), "U2", "123") + Expect(err).To(MatchError("generic error")) + }) + + It("fails if returned body is not a valid JSON", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`NOT_VALID_JSON`)), + StatusCode: 200, + } + + _, err := client.artistGetInfo(context.Background(), "U2", "123") + Expect(err).To(MatchError("invalid character '<' looking for beginning of value")) + }) + + }) + + Describe("artistGetSimilar", func() { + It("returns an artist for a successful response", func() { + f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + similar, err := client.artistGetSimilar(context.Background(), "U2", "123", 2) + Expect(err).To(BeNil()) + Expect(len(similar.Artists)).To(Equal(2)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&mbid=123&method=artist.getSimilar")) + }) + }) + + Describe("artistGetTopTracks", func() { + It("returns top tracks for a successful response", func() { + f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json") + httpClient.Res = http.Response{Body: f, StatusCode: 200} + + top, err := client.artistGetTopTracks(context.Background(), "U2", "123", 2) + Expect(err).To(BeNil()) + Expect(len(top.Track)).To(Equal(2)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&mbid=123&method=artist.getTopTracks")) + }) + }) + + Describe("GetToken", func() { + It("returns a token when the request is successful", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"token":"TOKEN"}`)), + StatusCode: 200, + } + + Expect(client.GetToken(context.Background())).To(Equal("TOKEN")) + queryParams := httpClient.SavedRequest.URL.Query() + Expect(queryParams.Get("method")).To(Equal("auth.getToken")) + Expect(queryParams.Get("format")).To(Equal("json")) + Expect(queryParams.Get("api_key")).To(Equal("API_KEY")) + Expect(queryParams.Get("api_sig")).ToNot(BeEmpty()) + }) + }) + + Describe("getSession", func() { + It("returns a session key when the request is successful", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"SESSION_KEY","subscriber":0}}`)), + StatusCode: 200, + } + + Expect(client.getSession(context.Background(), "TOKEN")).To(Equal("SESSION_KEY")) + queryParams := httpClient.SavedRequest.URL.Query() + Expect(queryParams.Get("method")).To(Equal("auth.getSession")) + Expect(queryParams.Get("format")).To(Equal("json")) + Expect(queryParams.Get("token")).To(Equal("TOKEN")) + Expect(queryParams.Get("api_key")).To(Equal("API_KEY")) + Expect(queryParams.Get("api_sig")).ToNot(BeEmpty()) + }) + }) + + Describe("sign", func() { + It("adds an api_sig param with the signature", func() { + params := url.Values{} + params.Add("d", "444") + params.Add("callback", "https://myserver.com") + params.Add("a", "111") + params.Add("format", "json") + params.Add("c", "333") + params.Add("b", "222") + client.sign(params) + Expect(params).To(HaveKey("api_sig")) + sig := params.Get("api_sig") + expected := fmt.Sprintf("%x", md5.Sum([]byte("a111b222c333d444SECRET"))) + Expect(sig).To(Equal(expected)) + }) + }) +}) diff --git a/adapters/lastfm/lastfm_suite_test.go b/core/agents/lastfm/lastfm_suite_test.go similarity index 100% rename from adapters/lastfm/lastfm_suite_test.go rename to core/agents/lastfm/lastfm_suite_test.go diff --git a/adapters/lastfm/responses.go b/core/agents/lastfm/responses.go similarity index 84% rename from adapters/lastfm/responses.go rename to core/agents/lastfm/responses.go index 026741672..1ceebe767 100644 --- a/adapters/lastfm/responses.go +++ b/core/agents/lastfm/responses.go @@ -5,7 +5,6 @@ type Response struct { SimilarArtists SimilarArtists `json:"similarartists"` TopTracks TopTracks `json:"toptracks"` Album Album `json:"album"` - SimilarTracks SimilarTracks `json:"similartracks"` Error int `json:"error"` Message string `json:"message"` Token string `json:"token"` @@ -60,28 +59,6 @@ type TopTracks struct { Attr Attr `json:"@attr"` } -type SimilarTracks struct { - Track []SimilarTrack `json:"track"` - Attr SimilarAttr `json:"@attr"` -} - -type SimilarTrack struct { - Name string `json:"name"` - MBID string `json:"mbid"` - Match float64 `json:"match"` - Artist SimilarTrackArtist `json:"artist"` -} - -type SimilarTrackArtist struct { - Name string `json:"name"` - MBID string `json:"mbid"` -} - -type SimilarAttr struct { - Artist string `json:"artist"` - Track string `json:"track"` -} - type Session struct { Name string `json:"name"` Key string `json:"key"` diff --git a/adapters/lastfm/responses_test.go b/core/agents/lastfm/responses_test.go similarity index 100% rename from adapters/lastfm/responses_test.go rename to core/agents/lastfm/responses_test.go diff --git a/adapters/lastfm/token_received.html b/core/agents/lastfm/token_received.html similarity index 100% rename from adapters/lastfm/token_received.html rename to core/agents/lastfm/token_received.html diff --git a/core/agents/listenbrainz/agent.go b/core/agents/listenbrainz/agent.go new file mode 100644 index 000000000..f98c91859 --- /dev/null +++ b/core/agents/listenbrainz/agent.go @@ -0,0 +1,119 @@ +package listenbrainz + +import ( + "context" + "errors" + "net/http" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" +) + +const ( + listenBrainzAgentName = "listenbrainz" + sessionKeyProperty = "ListenBrainzSessionKey" +) + +type listenBrainzAgent struct { + ds model.DataStore + sessionKeys *agents.SessionKeys + baseURL string + client *client +} + +func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent { + l := &listenBrainzAgent{ + ds: ds, + sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, + baseURL: conf.Server.ListenBrainz.BaseURL, + } + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } + chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut) + l.client = newClient(l.baseURL, chc) + return l +} + +func (l *listenBrainzAgent) AgentName() string { + return listenBrainzAgentName +} + +func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo { + li := listenInfo{ + TrackMetadata: trackMetadata{ + ArtistName: track.Artist, + TrackName: track.Title, + ReleaseName: track.Album, + AdditionalInfo: additionalInfo{ + SubmissionClient: consts.AppName, + SubmissionClientVersion: consts.Version, + TrackNumber: track.TrackNumber, + ArtistMbzIDs: []string{track.MbzArtistID}, + TrackMbzID: track.MbzTrackID, + ReleaseMbID: track.MbzAlbumID, + }, + }, + } + return li +} + +func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error { + sk, err := l.sessionKeys.Get(ctx, userId) + if err != nil || sk == "" { + return scrobbler.ErrNotAuthorized + } + + li := l.formatListen(track) + err = l.client.updateNowPlaying(ctx, sk, li) + if err != nil { + log.Warn(ctx, "ListenBrainz updateNowPlaying returned error", "track", track.Title, err) + return scrobbler.ErrUnrecoverable + } + return nil +} + +func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error { + sk, err := l.sessionKeys.Get(ctx, userId) + if err != nil || sk == "" { + return scrobbler.ErrNotAuthorized + } + + li := l.formatListen(&s.MediaFile) + li.ListenedAt = int(s.TimeStamp.Unix()) + err = l.client.scrobble(ctx, sk, li) + + if err == nil { + return nil + } + var lbErr *listenBrainzError + isListenBrainzError := errors.As(err, &lbErr) + if !isListenBrainzError { + log.Warn(ctx, "ListenBrainz Scrobble returned HTTP error", "track", s.Title, err) + return scrobbler.ErrRetryLater + } + if lbErr.Code == 500 || lbErr.Code == 503 { + return scrobbler.ErrRetryLater + } + return scrobbler.ErrUnrecoverable +} + +func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool { + sk, err := l.sessionKeys.Get(ctx, userId) + return err == nil && sk != "" +} + +func init() { + conf.AddHook(func() { + if conf.Server.ListenBrainz.Enabled { + scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler { + return listenBrainzConstructor(ds) + }) + } + }) +} diff --git a/core/agents/listenbrainz/agent_test.go b/core/agents/listenbrainz/agent_test.go new file mode 100644 index 000000000..e1a17736a --- /dev/null +++ b/core/agents/listenbrainz/agent_test.go @@ -0,0 +1,161 @@ +package listenbrainz + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" +) + +var _ = Describe("listenBrainzAgent", func() { + var ds model.DataStore + var ctx context.Context + var agent *listenBrainzAgent + var httpClient *tests.FakeHttpClient + var track *model.MediaFile + + BeforeEach(func() { + ds = &tests.MockDataStore{} + ctx = context.Background() + _ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1") + httpClient = &tests.FakeHttpClient{} + agent = listenBrainzConstructor(ds) + agent.client = newClient("http://localhost:8080", httpClient) + track = &model.MediaFile{ + ID: "123", + Title: "Track Title", + Album: "Track Album", + Artist: "Track Artist", + TrackNumber: 1, + MbzTrackID: "mbz-123", + MbzAlbumID: "mbz-456", + MbzArtistID: "mbz-789", + } + }) + + Describe("formatListen", func() { + It("constructs the listenInfo properly", func() { + var idArtistId = func(element interface{}) string { + return element.(string) + } + + lr := agent.formatListen(track) + Expect(lr).To(MatchAllFields(Fields{ + "ListenedAt": Equal(0), + "TrackMetadata": MatchAllFields(Fields{ + "ArtistName": Equal(track.Artist), + "TrackName": Equal(track.Title), + "ReleaseName": Equal(track.Album), + "AdditionalInfo": MatchAllFields(Fields{ + "SubmissionClient": Equal(consts.AppName), + "SubmissionClientVersion": Equal(consts.Version), + "TrackNumber": Equal(track.TrackNumber), + "TrackMbzID": Equal(track.MbzTrackID), + "ReleaseMbID": Equal(track.MbzAlbumID), + "ArtistMbzIDs": MatchAllElements(idArtistId, Elements{ + "mbz-789": Equal(track.MbzArtistID), + }), + }), + }), + })) + }) + }) + + Describe("NowPlaying", func() { + It("updates NowPlaying successfully", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200} + + err := agent.NowPlaying(ctx, "user-1", track) + Expect(err).ToNot(HaveOccurred()) + }) + + It("returns ErrNotAuthorized if user is not linked", func() { + err := agent.NowPlaying(ctx, "user-2", track) + Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) + }) + }) + + Describe("Scrobble", func() { + var sc scrobbler.Scrobble + + BeforeEach(func() { + sc = scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()} + }) + + It("sends a Scrobble successfully", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200} + + err := agent.Scrobble(ctx, "user-1", sc) + Expect(err).ToNot(HaveOccurred()) + }) + + It("sets the Timestamp properly", func() { + httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200} + + err := agent.Scrobble(ctx, "user-1", sc) + Expect(err).ToNot(HaveOccurred()) + + decoder := json.NewDecoder(httpClient.SavedRequest.Body) + var lr listenBrainzRequestBody + err = decoder.Decode(&lr) + + Expect(err).ToNot(HaveOccurred()) + Expect(lr.Payload[0].ListenedAt).To(Equal(int(sc.TimeStamp.Unix()))) + }) + + It("returns ErrNotAuthorized if user is not linked", func() { + err := agent.Scrobble(ctx, "user-2", sc) + Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) + }) + + It("returns ErrRetryLater on error 503", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 503, "error": "Cannot submit listens to queue, please try again later."}`)), + StatusCode: 503, + } + + err := agent.Scrobble(ctx, "user-1", sc) + Expect(err).To(MatchError(scrobbler.ErrRetryLater)) + }) + + It("returns ErrRetryLater on error 500", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 500, "error": "Something went wrong. Please try again."}`)), + StatusCode: 500, + } + + err := agent.Scrobble(ctx, "user-1", sc) + Expect(err).To(MatchError(scrobbler.ErrRetryLater)) + }) + + It("returns ErrRetryLater on http errors", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`Bad Gateway`)), + StatusCode: 500, + } + + err := agent.Scrobble(ctx, "user-1", sc) + Expect(err).To(MatchError(scrobbler.ErrRetryLater)) + }) + + It("returns ErrUnrecoverable on other errors", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "error": "BadRequest: Invalid JSON document submitted."}`)), + StatusCode: 400, + } + + err := agent.Scrobble(ctx, "user-1", sc) + Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) + }) + }) +}) diff --git a/adapters/listenbrainz/auth_router.go b/core/agents/listenbrainz/auth_router.go similarity index 93% rename from adapters/listenbrainz/auth_router.go rename to core/agents/listenbrainz/auth_router.go index 1ff1a1495..2382aeb73 100644 --- a/adapters/listenbrainz/auth_router.go +++ b/core/agents/listenbrainz/auth_router.go @@ -16,7 +16,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/utils/httpclient" ) type sessionKeysRepo interface { @@ -38,7 +37,9 @@ func NewRouter(ds model.DataStore) *Router { sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, } r.Handler = r.routes() - hc := httpclient.New(consts.DefaultHttpClientTimeOut) + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc) return r } @@ -59,7 +60,7 @@ func (s *Router) routes() http.Handler { } func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { - resp := map[string]any{} + resp := map[string]interface{}{} u, _ := request.UserFrom(r.Context()) key, err := s.sessionKeys.Get(r.Context(), u.ID) if err != nil && !errors.Is(err, model.ErrNotFound) { @@ -106,7 +107,7 @@ func (s *Router) link(w http.ResponseWriter, r *http.Request) { return } - _ = rest.RespondWithJSON(w, http.StatusOK, map[string]any{"status": resp.Valid, "user": resp.UserName}) + _ = rest.RespondWithJSON(w, http.StatusOK, map[string]interface{}{"status": resp.Valid, "user": resp.UserName}) } func (s *Router) unlink(w http.ResponseWriter, r *http.Request) { diff --git a/adapters/listenbrainz/auth_router_test.go b/core/agents/listenbrainz/auth_router_test.go similarity index 97% rename from adapters/listenbrainz/auth_router_test.go rename to core/agents/listenbrainz/auth_router_test.go index c3861799a..dc705dbc9 100644 --- a/adapters/listenbrainz/auth_router_test.go +++ b/core/agents/listenbrainz/auth_router_test.go @@ -37,7 +37,7 @@ var _ = Describe("ListenBrainz Auth Router", func() { req = httptest.NewRequest("GET", "/listenbrainz/link", nil) r.getLinkStatus(resp, req) Expect(resp.Code).To(Equal(http.StatusOK)) - var parsed map[string]any + var parsed map[string]interface{} Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) Expect(parsed["status"]).To(Equal(false)) }) @@ -47,7 +47,7 @@ var _ = Describe("ListenBrainz Auth Router", func() { req = httptest.NewRequest("GET", "/listenbrainz/link", nil) r.getLinkStatus(resp, req) Expect(resp.Code).To(Equal(http.StatusOK)) - var parsed map[string]any + var parsed map[string]interface{} Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) Expect(parsed["status"]).To(Equal(true)) }) @@ -80,7 +80,7 @@ var _ = Describe("ListenBrainz Auth Router", func() { req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`)) r.link(resp, req) Expect(resp.Code).To(Equal(http.StatusOK)) - var parsed map[string]any + var parsed map[string]interface{} Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) Expect(parsed["status"]).To(Equal(true)) Expect(parsed["user"]).To(Equal("ListenBrainzUser")) diff --git a/core/agents/listenbrainz/client.go b/core/agents/listenbrainz/client.go new file mode 100644 index 000000000..8ed4d1691 --- /dev/null +++ b/core/agents/listenbrainz/client.go @@ -0,0 +1,176 @@ +package listenbrainz + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + + "github.com/navidrome/navidrome/log" +) + +type listenBrainzError struct { + Code int + Message string +} + +func (e *listenBrainzError) Error() string { + return fmt.Sprintf("ListenBrainz error(%d): %s", e.Code, e.Message) +} + +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +func newClient(baseURL string, hc httpDoer) *client { + return &client{baseURL, hc} +} + +type client struct { + baseURL string + hc httpDoer +} + +type listenBrainzResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Error string `json:"error"` + Status string `json:"status"` + Valid bool `json:"valid"` + UserName string `json:"user_name"` +} + +type listenBrainzRequest struct { + ApiKey string + Body listenBrainzRequestBody +} + +type listenBrainzRequestBody struct { + ListenType listenType `json:"listen_type,omitempty"` + Payload []listenInfo `json:"payload,omitempty"` +} + +type listenType string + +const ( + Single listenType = "single" + PlayingNow listenType = "playing_now" +) + +type listenInfo struct { + ListenedAt int `json:"listened_at,omitempty"` + TrackMetadata trackMetadata `json:"track_metadata,omitempty"` +} + +type trackMetadata struct { + ArtistName string `json:"artist_name,omitempty"` + TrackName string `json:"track_name,omitempty"` + ReleaseName string `json:"release_name,omitempty"` + AdditionalInfo additionalInfo `json:"additional_info,omitempty"` +} + +type additionalInfo struct { + SubmissionClient string `json:"submission_client,omitempty"` + SubmissionClientVersion string `json:"submission_client_version,omitempty"` + TrackNumber int `json:"tracknumber,omitempty"` + TrackMbzID string `json:"track_mbid,omitempty"` + ArtistMbzIDs []string `json:"artist_mbids,omitempty"` + ReleaseMbID string `json:"release_mbid,omitempty"` +} + +func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) { + r := &listenBrainzRequest{ + ApiKey: apiKey, + } + response, err := c.makeRequest(ctx, http.MethodGet, "validate-token", r) + if err != nil { + return nil, err + } + return response, nil +} + +func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error { + r := &listenBrainzRequest{ + ApiKey: apiKey, + Body: listenBrainzRequestBody{ + ListenType: PlayingNow, + Payload: []listenInfo{li}, + }, + } + + resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r) + if err != nil { + return err + } + if resp.Status != "ok" { + log.Warn(ctx, "ListenBrainz: NowPlaying was not accepted", "status", resp.Status) + } + return nil +} + +func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) error { + r := &listenBrainzRequest{ + ApiKey: apiKey, + Body: listenBrainzRequestBody{ + ListenType: Single, + Payload: []listenInfo{li}, + }, + } + resp, err := c.makeRequest(ctx, http.MethodPost, "submit-listens", r) + if err != nil { + return err + } + if resp.Status != "ok" { + log.Warn(ctx, "ListenBrainz: Scrobble was not accepted", "status", resp.Status) + } + return nil +} + +func (c *client) path(endpoint string) (string, error) { + u, err := url.Parse(c.baseURL) + if err != nil { + return "", err + } + u.Path = path.Join(u.Path, endpoint) + return u.String(), nil +} + +func (c *client) makeRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) { + b, _ := json.Marshal(r.Body) + uri, err := c.path(endpoint) + if err != nil { + return nil, err + } + req, _ := http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b)) + req.Header.Add("Content-Type", "application/json; charset=UTF-8") + + if r.ApiKey != "" { + req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey)) + } + + log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL) + resp, err := c.hc.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var response listenBrainzResponse + jsonErr := decoder.Decode(&response) + if resp.StatusCode != 200 && jsonErr != nil { + return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode) + } + if jsonErr != nil { + return nil, jsonErr + } + if response.Code != 0 && response.Code != 200 { + return &response, &listenBrainzError{Code: response.Code, Message: response.Error} + } + + return &response, nil +} diff --git a/core/agents/listenbrainz/client_test.go b/core/agents/listenbrainz/client_test.go new file mode 100644 index 000000000..42fc2dc15 --- /dev/null +++ b/core/agents/listenbrainz/client_test.go @@ -0,0 +1,118 @@ +package listenbrainz + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "os" + + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("client", func() { + var httpClient *tests.FakeHttpClient + var client *client + BeforeEach(func() { + httpClient = &tests.FakeHttpClient{} + client = newClient("BASE_URL/", httpClient) + }) + + Describe("listenBrainzResponse", func() { + It("parses a response properly", func() { + var response listenBrainzResponse + err := json.Unmarshal([]byte(`{"code": 200, "message": "Message", "user_name": "UserName", "valid": true, "status": "ok", "error": "Error"}`), &response) + + Expect(err).ToNot(HaveOccurred()) + Expect(response.Code).To(Equal(200)) + Expect(response.Message).To(Equal("Message")) + Expect(response.UserName).To(Equal("UserName")) + Expect(response.Valid).To(BeTrue()) + Expect(response.Status).To(Equal("ok")) + Expect(response.Error).To(Equal("Error")) + }) + }) + + Describe("validateToken", func() { + BeforeEach(func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)), + StatusCode: 200, + } + }) + + It("formats the request properly", func() { + _, err := client.validateToken(context.Background(), "LB-TOKEN") + Expect(err).ToNot(HaveOccurred()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/validate-token")) + Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + }) + + It("parses and returns the response", func() { + res, err := client.validateToken(context.Background(), "LB-TOKEN") + Expect(err).ToNot(HaveOccurred()) + Expect(res.Valid).To(Equal(true)) + Expect(res.UserName).To(Equal("ListenBrainzUser")) + }) + }) + + Context("with listenInfo", func() { + var li listenInfo + BeforeEach(func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), + StatusCode: 200, + } + li = listenInfo{ + TrackMetadata: trackMetadata{ + ArtistName: "Track Artist", + TrackName: "Track Title", + ReleaseName: "Track Album", + AdditionalInfo: additionalInfo{ + TrackNumber: 1, + TrackMbzID: "mbz-123", + ArtistMbzIDs: []string{"mbz-789"}, + ReleaseMbID: "mbz-456", + }, + }, + } + }) + + Describe("updateNowPlaying", func() { + It("formats the request properly", func() { + Expect(client.updateNowPlaying(context.Background(), "LB-TOKEN", li)).To(Succeed()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens")) + Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + + body, _ := io.ReadAll(httpClient.SavedRequest.Body) + f, _ := os.ReadFile("tests/fixtures/listenbrainz.nowplaying.request.json") + Expect(body).To(MatchJSON(f)) + }) + }) + + Describe("scrobble", func() { + BeforeEach(func() { + li.ListenedAt = 1635000000 + }) + + It("formats the request properly", func() { + Expect(client.scrobble(context.Background(), "LB-TOKEN", li)).To(Succeed()) + Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost)) + Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens")) + Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN")) + Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8")) + + body, _ := io.ReadAll(httpClient.SavedRequest.Body) + f, _ := os.ReadFile("tests/fixtures/listenbrainz.scrobble.request.json") + Expect(body).To(MatchJSON(f)) + }) + }) + }) +}) diff --git a/adapters/listenbrainz/listenbrainz_suite_test.go b/core/agents/listenbrainz/listenbrainz_suite_test.go similarity index 100% rename from adapters/listenbrainz/listenbrainz_suite_test.go rename to core/agents/listenbrainz/listenbrainz_suite_test.go diff --git a/core/agents/local_agent.go b/core/agents/local_agent.go index c777ab46d..ce8f9f07c 100644 --- a/core/agents/local_agent.go +++ b/core/agents/local_agent.go @@ -5,8 +5,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/utils/slice" ) const LocalAgentName = "local" @@ -24,7 +22,7 @@ func (p *localAgent) AgentName() string { } func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) { - top, err := p.ds.MediaFile().GetAll(ctx, model.QueryOptions{ + top, err := p.ds.MediaFile(ctx).GetAll(model.QueryOptions{ Sort: "playCount", Order: "desc", Max: count, @@ -39,51 +37,14 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid if err != nil { return nil, err } - return songsFrom(top), nil -} - -func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) { - seed, err := p.ds.MediaFile().Get(ctx, id) - if err != nil { - return nil, err + var result []Song + for _, s := range top { + result = append(result, Song{ + Name: s.Title, + MBID: s.MbzReleaseTrackID, + }) } - // Tag ids derive from (name, value), so the seed's genre ids need no extra query. - genreIDs := slice.Map(seed.Tags.Flatten(model.TagGenre), func(t model.Tag) string { return t.ID }) - if len(genreIDs) == 0 { - return nil, nil - } - // Ask for extra so we can drop the seed itself and still fill the count. - candidates, err := p.ds.MediaFile().GetRandom(ctx, model.QueryOptions{ - Filters: squirrel.And{ - persistence.SongGenres.ByID(genreIDs), - squirrel.Eq{"missing": false}, - }, - Max: count + 1, - }) - if err != nil { - return nil, err - } - filtered := make(model.MediaFiles, 0, len(candidates)) - for _, s := range candidates { - if s.ID == id { - continue - } - filtered = append(filtered, s) - if len(filtered) >= count { - break - } - } - return songsFrom(filtered), nil -} - -func songsFrom(mfs model.MediaFiles) []Song { - if len(mfs) == 0 { - return nil - } - - return slice.Map(mfs, func(mf model.MediaFile) Song { - return Song{ID: mf.ID, Name: mf.Title} - }) + return result, nil } func init() { diff --git a/core/agents/local_agent_test.go b/core/agents/local_agent_test.go deleted file mode 100644 index 50a0ce297..000000000 --- a/core/agents/local_agent_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package agents - -import ( - "context" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("localAgent GetSimilarSongsByTrack", func() { - var ds *tests.MockDataStore - var mfRepo *tests.MockMediaFileRepo - var agent *localAgent - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - mfRepo = &tests.MockMediaFileRepo{} - ds = &tests.MockDataStore{MockedMediaFile: mfRepo} - agent = &localAgent{ds: ds} - }) - - It("excludes the seed track from its own similars", func() { - seed := model.MediaFile{ID: "seed-1", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} - related := model.MediaFile{ID: "rel-1", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} - // SetData keys by ID; a duplicate "seed-1" entry would clobber the real seed. - mfRepo.SetData(model.MediaFiles{seed, related}) - - songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-1", "Seed", "", "", 10) - - Expect(err).ToNot(HaveOccurred()) - names := slice.Map(songs, func(s Song) string { return s.Name }) - Expect(names).ToNot(ContainElement("Seed")) - }) - - // The mock ignores QueryOptions.Filters, so assert the predicate itself: otherwise this spec - // would pass just as well with no genre filter at all. - It("queries the indexed genre join for the seed's own genres, skipping missing files", func() { - rock := model.NewTag(model.TagGenre, "Rock") - seed := model.MediaFile{ID: "seed-4", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} - mfRepo.SetData(model.MediaFiles{seed}) - - _, err := agent.GetSimilarSongsByTrack(ctx, "seed-4", "Seed", "", "", 10) - Expect(err).ToNot(HaveOccurred()) - - sql, args, sqlErr := mfRepo.Options.Filters.ToSql() - Expect(sqlErr).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("media_file_tags"), "must use the indexed join, not a json_tree scan") - Expect(sql).To(ContainSubstring("missing")) - Expect(args).To(ContainElement(false), "must exclude missing files, not select them") - Expect(args).To(ContainElement(rock.ID), "must filter on the seed's own genre tag id") - Expect(args).ToNot(ContainElement(model.NewTag(model.TagGenre, "Jazz").ID)) - }) - - It("returns the library id so the matcher can resolve the song", func() { - seed := model.MediaFile{ID: "seed-3", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} - // Without the id the matcher falls through to its MBID/title phases and resolves nothing, - // so the local fallback silently returns an empty mix. - related := model.MediaFile{ID: "rel-3", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} - mfRepo.SetData(model.MediaFiles{seed, related}) - - songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-3", "Seed", "", "", 10) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(ContainElement(Song{ID: "rel-3", Name: "Related"})) - }) - - It("asks for one extra candidate so dropping the seed still fills the count", func() { - // The mock returns rows sorted by id, so the seed comes first and would consume the only - // slot if the query did not over-fetch. - seed := model.MediaFile{ID: "a-seed", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} - related := model.MediaFile{ID: "b-rel", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}} - mfRepo.SetData(model.MediaFiles{seed, related}) - - songs, err := agent.GetSimilarSongsByTrack(ctx, "a-seed", "Seed", "", "", 1) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].Name).To(Equal("Related")) - }) - - It("returns nil when the seed track has no genres", func() { - seed := model.MediaFile{ID: "seed-2", Title: "NoGenre"} - mfRepo.SetData(model.MediaFiles{seed}) - - songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-2", "NoGenre", "", "", 10) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(BeEmpty()) - // Without the early return an empty tag filter would scan the whole library. - Expect(mfRepo.Options).To(Equal(model.QueryOptions{}), "must not query at all") - }) -}) diff --git a/core/agents/session_keys.go b/core/agents/session_keys.go index 1eb414b15..cea6005ff 100644 --- a/core/agents/session_keys.go +++ b/core/agents/session_keys.go @@ -13,13 +13,13 @@ type SessionKeys struct { } func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error { - return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey) + return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey) } func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) { - return sk.DataStore.UserProps().Get(ctx, userId, sk.KeyName) + return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName) } func (sk *SessionKeys) Delete(ctx context.Context, userId string) error { - return sk.DataStore.UserProps().Delete(ctx, userId, sk.KeyName) + return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName) } diff --git a/core/agents/song_test.go b/core/agents/song_test.go deleted file mode 100644 index c13710a38..000000000 --- a/core/agents/song_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package agents - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Song.Equals", func() { - base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}} - It("true for identical songs incl Artists", func() { - Expect(base.Equals(base)).To(BeTrue()) - }) - It("false when Artists differ", func() { - other := base - other.Artists = []Artist{{ID: "y", Name: "B"}} - Expect(base.Equals(other)).To(BeFalse()) - }) - It("false when a scalar differs", func() { - other := base - other.Name = "T" - Expect(base.Equals(other)).To(BeFalse()) - }) - It("true when both have empty Artists and equal scalars", func() { - a := Song{ID: "1", Name: "S"} - Expect(a.Equals(a)).To(BeTrue()) - }) -}) diff --git a/core/agents/spotify/client.go b/core/agents/spotify/client.go new file mode 100644 index 000000000..25b1f9ede --- /dev/null +++ b/core/agents/spotify/client.go @@ -0,0 +1,116 @@ +package spotify + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/navidrome/navidrome/log" +) + +const apiBaseUrl = "https://api.spotify.com/v1/" + +var ( + ErrNotFound = errors.New("spotify: not found") +) + +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +func newClient(id, secret string, hc httpDoer) *client { + return &client{id, secret, hc} +} + +type client struct { + id string + secret string + hc httpDoer +} + +func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) { + token, err := c.authorize(ctx) + if err != nil { + return nil, err + } + + params := url.Values{} + params.Add("type", "artist") + params.Add("q", name) + params.Add("offset", "0") + params.Add("limit", strconv.Itoa(limit)) + req, _ := http.NewRequestWithContext(ctx, "GET", apiBaseUrl+"search", nil) + req.URL.RawQuery = params.Encode() + req.Header.Add("Authorization", "Bearer "+token) + + var results SearchResults + err = c.makeRequest(req, &results) + if err != nil { + return nil, err + } + + if len(results.Artists.Items) == 0 { + return nil, ErrNotFound + } + return results.Artists.Items, err +} + +func (c *client) authorize(ctx context.Context) (string, error) { + payload := url.Values{} + payload.Add("grant_type", "client_credentials") + + encodePayload := payload.Encode() + req, _ := http.NewRequestWithContext(ctx, "POST", "https://accounts.spotify.com/api/token", strings.NewReader(encodePayload)) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Content-Length", strconv.Itoa(len(encodePayload))) + auth := c.id + ":" + c.secret + req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth))) + + response := map[string]interface{}{} + err := c.makeRequest(req, &response) + if err != nil { + return "", err + } + + if v, ok := response["access_token"]; ok { + return v.(string), nil + } + log.Error(ctx, "Invalid spotify response", "resp", response) + return "", errors.New("invalid response") +} + +func (c *client) makeRequest(req *http.Request, response interface{}) error { + log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL) + resp, err := c.hc.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode != 200 { + return c.parseError(data) + } + + return json.Unmarshal(data, response) +} + +func (c *client) parseError(data []byte) error { + var e Error + err := json.Unmarshal(data, &e) + if err != nil { + return err + } + return fmt.Errorf("spotify error(%s): %s", e.Code, e.Message) +} diff --git a/core/agents/spotify/client_test.go b/core/agents/spotify/client_test.go new file mode 100644 index 000000000..2782d2122 --- /dev/null +++ b/core/agents/spotify/client_test.go @@ -0,0 +1,131 @@ +package spotify + +import ( + "bytes" + "context" + "io" + "net/http" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("client", func() { + var httpClient *fakeHttpClient + var client *client + + BeforeEach(func() { + httpClient = &fakeHttpClient{} + client = newClient("SPOTIFY_ID", "SPOTIFY_SECRET", httpClient) + }) + + Describe("ArtistImages", func() { + It("returns artist images from a successful request", func() { + f, _ := os.Open("tests/fixtures/spotify.search.artist.json") + httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200}) + httpClient.mock("https://accounts.spotify.com/api/token", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)), + }) + + artists, err := client.searchArtists(context.TODO(), "U2", 10) + Expect(err).To(BeNil()) + Expect(artists).To(HaveLen(20)) + Expect(artists[0].Popularity).To(Equal(82)) + + images := artists[0].Images + Expect(images).To(HaveLen(3)) + Expect(images[0].Width).To(Equal(640)) + Expect(images[1].Width).To(Equal(320)) + Expect(images[2].Width).To(Equal(160)) + }) + + It("fails if artist was not found", func() { + httpClient.mock("https://api.spotify.com/v1/search", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{ + "artists" : { + "href" : "https://api.spotify.com/v1/search?query=dasdasdas%2Cdna&type=artist&offset=0&limit=20", + "items" : [ ], "limit" : 20, "next" : null, "offset" : 0, "previous" : null, "total" : 0 + }}`)), + }) + httpClient.mock("https://accounts.spotify.com/api/token", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)), + }) + + _, err := client.searchArtists(context.TODO(), "U2", 10) + Expect(err).To(MatchError(ErrNotFound)) + }) + + It("fails if not able to authorize", func() { + f, _ := os.Open("tests/fixtures/spotify.search.artist.json") + httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200}) + httpClient.mock("https://accounts.spotify.com/api/token", http.Response{ + StatusCode: 400, + Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)), + }) + + _, err := client.searchArtists(context.TODO(), "U2", 10) + Expect(err).To(MatchError("spotify error(invalid_client): Invalid client")) + }) + }) + + Describe("authorize", func() { + It("returns an access_token on successful authorization", func() { + httpClient.mock("https://accounts.spotify.com/api/token", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)), + }) + + token, err := client.authorize(context.TODO()) + Expect(err).To(BeNil()) + Expect(token).To(Equal("NEW_ACCESS_TOKEN")) + auth := httpClient.lastRequest.Header.Get("Authorization") + Expect(auth).To(Equal("Basic U1BPVElGWV9JRDpTUE9USUZZX1NFQ1JFVA==")) + }) + + It("fails on unsuccessful authorization", func() { + httpClient.mock("https://accounts.spotify.com/api/token", http.Response{ + StatusCode: 400, + Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)), + }) + + _, err := client.authorize(context.TODO()) + Expect(err).To(MatchError("spotify error(invalid_client): Invalid client")) + }) + + It("fails on invalid JSON response", func() { + httpClient.mock("https://accounts.spotify.com/api/token", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(`{NOT_VALID}`)), + }) + + _, err := client.authorize(context.TODO()) + Expect(err).To(MatchError("invalid character 'N' looking for beginning of object key string")) + }) + }) +}) + +type fakeHttpClient struct { + responses map[string]*http.Response + lastRequest *http.Request +} + +func (c *fakeHttpClient) mock(url string, response http.Response) { + if c.responses == nil { + c.responses = make(map[string]*http.Response) + } + c.responses[url] = &response +} + +func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) { + c.lastRequest = req + u := req.URL + u.RawQuery = "" + if resp, ok := c.responses[u.String()]; ok { + return resp, nil + } + panic("URL not mocked: " + u.String()) +} diff --git a/core/agents/spotify/responses.go b/core/agents/spotify/responses.go new file mode 100644 index 000000000..21166bf74 --- /dev/null +++ b/core/agents/spotify/responses.go @@ -0,0 +1,30 @@ +package spotify + +type SearchResults struct { + Artists ArtistsResult `json:"artists"` +} + +type ArtistsResult struct { + HRef string `json:"href"` + Items []Artist `json:"items"` +} + +type Artist struct { + Genres []string `json:"genres"` + HRef string `json:"href"` + ID string `json:"id"` + Popularity int `json:"popularity"` + Images []Image `json:"images"` + Name string `json:"name"` +} + +type Image struct { + URL string `json:"url"` + Width int `json:"width"` + Height int `json:"height"` +} + +type Error struct { + Code string `json:"error"` + Message string `json:"error_description"` +} diff --git a/core/agents/spotify/responses_test.go b/core/agents/spotify/responses_test.go new file mode 100644 index 000000000..704119816 --- /dev/null +++ b/core/agents/spotify/responses_test.go @@ -0,0 +1,48 @@ +package spotify + +import ( + "encoding/json" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Responses", func() { + Describe("Search type=artist", func() { + It("parses the artist search result correctly ", func() { + var resp SearchResults + body, _ := os.ReadFile("tests/fixtures/spotify.search.artist.json") + err := json.Unmarshal(body, &resp) + Expect(err).To(BeNil()) + + Expect(resp.Artists.Items).To(HaveLen(20)) + u2 := resp.Artists.Items[0] + Expect(u2.Name).To(Equal("U2")) + Expect(u2.Genres).To(ContainElements("irish rock", "permanent wave", "rock")) + Expect(u2.ID).To(Equal("51Blml2LZPmy7TTiAg47vQ")) + Expect(u2.HRef).To(Equal("https://api.spotify.com/v1/artists/51Blml2LZPmy7TTiAg47vQ")) + Expect(u2.Images[0].URL).To(Equal("https://i.scdn.co/image/e22d5c0c8139b8439440a69854ed66efae91112d")) + Expect(u2.Images[0].Width).To(Equal(640)) + Expect(u2.Images[0].Height).To(Equal(640)) + Expect(u2.Images[1].URL).To(Equal("https://i.scdn.co/image/40d6c5c14355cfc127b70da221233315497ec91d")) + Expect(u2.Images[1].Width).To(Equal(320)) + Expect(u2.Images[1].Height).To(Equal(320)) + Expect(u2.Images[2].URL).To(Equal("https://i.scdn.co/image/7293d6752ae8a64e34adee5086858e408185b534")) + Expect(u2.Images[2].Width).To(Equal(160)) + Expect(u2.Images[2].Height).To(Equal(160)) + }) + }) + + Describe("Error", func() { + It("parses the error response correctly", func() { + var errorResp Error + body := []byte(`{"error":"invalid_client","error_description":"Invalid client"}`) + err := json.Unmarshal(body, &errorResp) + Expect(err).To(BeNil()) + + Expect(errorResp.Code).To(Equal("invalid_client")) + Expect(errorResp.Message).To(Equal("Invalid client")) + }) + }) +}) diff --git a/core/agents/spotify/spotify.go b/core/agents/spotify/spotify.go new file mode 100644 index 000000000..b0b3f39be --- /dev/null +++ b/core/agents/spotify/spotify.go @@ -0,0 +1,95 @@ +package spotify + +import ( + "context" + "errors" + "fmt" + "net/http" + "sort" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" + "github.com/xrash/smetrics" +) + +const spotifyAgentName = "spotify" + +type spotifyAgent struct { + ds model.DataStore + id string + secret string + client *client +} + +func spotifyConstructor(ds model.DataStore) agents.Interface { + l := &spotifyAgent{ + ds: ds, + id: conf.Server.Spotify.ID, + secret: conf.Server.Spotify.Secret, + } + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } + chc := utils.NewCachedHTTPClient(hc, consts.DefaultHttpClientTimeOut) + l.client = newClient(l.id, l.secret, chc) + return l +} + +func (s *spotifyAgent) AgentName() string { + return spotifyAgentName +} + +func (s *spotifyAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) { + a, err := s.searchArtist(ctx, name) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "Artist not found in Spotify", "artist", name) + } else { + log.Error(ctx, "Error calling Spotify", "artist", name, err) + } + return nil, err + } + + var res []agents.ExternalImage + for _, img := range a.Images { + res = append(res, agents.ExternalImage{ + URL: img.URL, + Size: img.Width, + }) + } + return res, nil +} + +func (s *spotifyAgent) searchArtist(ctx context.Context, name string) (*Artist, error) { + artists, err := s.client.searchArtists(ctx, name, 40) + if err != nil || len(artists) == 0 { + return nil, model.ErrNotFound + } + name = strings.ToLower(name) + + // Sort results, prioritizing artists with images, with similar names and with high popularity, in this order + sort.Slice(artists, func(i, j int) bool { + ai := fmt.Sprintf("%-5t-%03d-%04d", len(artists[i].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[i].Name), 1, 1, 2), 1000-artists[i].Popularity) + aj := fmt.Sprintf("%-5t-%03d-%04d", len(artists[j].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[j].Name), 1, 1, 2), 1000-artists[j].Popularity) + return ai < aj + }) + + // If the first one has the same name, that's the one + if strings.ToLower(artists[0].Name) != name { + return nil, model.ErrNotFound + } + return &artists[0], err +} + +func init() { + conf.AddHook(func() { + if conf.Server.Spotify.ID != "" && conf.Server.Spotify.Secret != "" { + agents.Register(spotifyAgentName, spotifyConstructor) + } + }) +} diff --git a/adapters/deezer/deezer_suite_test.go b/core/agents/spotify/spotify_suite_test.go similarity index 74% rename from adapters/deezer/deezer_suite_test.go rename to core/agents/spotify/spotify_suite_test.go index a42282da7..275b05e73 100644 --- a/adapters/deezer/deezer_suite_test.go +++ b/core/agents/spotify/spotify_suite_test.go @@ -1,4 +1,4 @@ -package deezer +package spotify import ( "testing" @@ -9,9 +9,9 @@ import ( . "github.com/onsi/gomega" ) -func TestDeezer(t *testing.T) { +func TestSpotify(t *testing.T) { tests.Init(t, false) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) - RunSpecs(t, "Deezer Test Suite") + RunSpecs(t, "Spotify Test Suite") } diff --git a/core/archiver.go b/core/archiver.go index 60eb44858..00cc882be 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -2,99 +2,65 @@ package core import ( "archive/zip" - "cmp" "context" - "errors" "fmt" "io" - "net/http" "os" - "path" "path/filepath" - "strconv" "strings" - "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/stream" "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/utils/slice" - "github.com/navidrome/navidrome/utils/str" ) -const archiveCoverArtSize = 500 - type Archiver interface { ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error - ZipShare(ctx context.Context, s *model.Share, w io.Writer) error + ZipShare(ctx context.Context, id string, w io.Writer) error ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error } -func NewArchiver(ms stream.MediaStreamer, decider stream.TranscodeDecider, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver { - return &archiver{ds: ds, ms: ms, decider: decider, shares: shares, artwork: artwork} +func NewArchiver(ms MediaStreamer, ds model.DataStore, shares Share) Archiver { + return &archiver{ds: ds, ms: ms, shares: shares} } type archiver struct { - ds model.DataStore - ms stream.MediaStreamer - decider stream.TranscodeDecider - shares Share - artwork artwork.Artwork + ds model.DataStore + ms MediaStreamer + shares Share } func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { - return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id}, model.ArtworkID{}) + return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id}) } func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { - // Match by album-artist participation, not the deprecated album_artist_id - // column (first album artist only), so co-album-artists are included too. - filter := squirrel.And{ - persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist), - squirrel.Eq{"missing": false}, - } - return a.zipAlbums(ctx, id, format, bitrate, out, filter, model.Artist{ID: id}.CoverArtID()) + return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_artist_id": id}) } -// rootArt, when set, is added to the archive root. -func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer, rootArt model.ArtworkID) error { - mfs, err := a.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: filters, Sort: "album"}) +func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error { + mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"}) if err != nil { log.Error(ctx, "Error loading mediafiles from artist", "id", id, err) return err } z := createZipWriter(out, format, bitrate) - albums := slice.GroupOrdered(mfs, func(mf model.MediaFile) string { return mf.AlbumID }) - folders := albumFolders(albums) + albums := slice.Group(mfs, func(mf model.MediaFile) string { + return mf.AlbumID + }) for _, album := range albums { discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber }) - isMultiDisc := len(discs) > 1 - folder := folders[album[0].AlbumID] - log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder, - "format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album)) + isMultDisc := len(discs) > 1 + log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, + "format", format, "bitrate", bitrate, "isMultDisc", isMultDisc, "numTracks", len(album)) for _, mf := range album { - req := a.resolveRequest(ctx, &mf, format, bitrate) - file := a.albumFilename(mf, req.Format, isMultiDisc, folder) - if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { - // Stop iterating: continuing would just rack up more - // rejections from the limiter. Close finalises whatever - // tracks were already written; the rejected one is not - // present in the archive (addFileToZip aborts before - // writing its entry header). - _ = z.Close() - return addErr - } + file := a.albumFilename(mf, format, isMultDisc) + _ = a.addFileToZip(ctx, z, mf, format, bitrate, file) } - // After the tracks, so a slow artwork lookup doesn't delay the first bytes. - a.addCoverArtToZip(ctx, z, album[0].AlbumCoverArtID(), folder) } - a.addCoverArtToZip(ctx, z, rootArt, "") err = z.Close() if err != nil { log.Error(ctx, "Error closing zip file", "id", id, err) @@ -112,133 +78,46 @@ func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer { return z } -// Tried in order; the first one whose values are distinct across the clashing albums wins. -// One album may have an empty value: it keeps the plain name, which the others can't clash with. -var albumDisambiguators = []func(model.MediaFile) string{ - func(mf model.MediaFile) string { return mf.Tags.First(model.TagAlbumVersion) }, - func(mf model.MediaFile) string { - // Reissues share Year (often the original's) but not ReleaseYear. - if y := cmp.Or(mf.ReleaseYear, mf.Year); y != 0 { - return strconv.Itoa(y) - } - return "" - }, - func(mf model.MediaFile) string { return mf.MbzAlbumType }, - func(mf model.MediaFile) string { return mf.Tags.First(model.TagRecordLabel) }, - func(mf model.MediaFile) string { return mf.CatalogNum }, - func(mf model.MediaFile) string { return mf.AlbumID[:min(6, len(mf.AlbumID))] }, - func(mf model.MediaFile) string { return mf.AlbumID }, -} - -// albumFolders maps each album id to its zip folder. Albums whose names sanitize to the -// same folder get a " [suffix]" from the first disambiguator that tells them all apart. -func albumFolders(albums [][]model.MediaFile) map[string]string { - byName := map[string][]model.MediaFile{} - for _, album := range albums { - name := str.SanitizeFilename(album[0].FullAlbumName()) - byName[name] = append(byName[name], album[0]) - } - folders := make(map[string]string, len(albums)) - for name, group := range byName { - if len(group) == 1 { - folders[group[0].AlbumID] = name - continue - } - fields: - for _, field := range albumDisambiguators { - ids := make(map[string]string, len(group)) // suffix -> album id - for _, mf := range group { - s := str.SanitizeFilename(field(mf)) - if _, dup := ids[s]; dup { - continue fields - } - ids[s] = mf.AlbumID - } - for s, id := range ids { - folders[id] = name - if s != "" { - folders[id] = fmt.Sprintf("%s [%s]", name, s) - } - } - break - } - } - return folders -} - -func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool, folder string) string { +func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultDisc bool) string { _, file := filepath.Split(mf.Path) if format != "raw" { file = strings.TrimSuffix(file, mf.Suffix) + format } - if isMultiDisc { + if isMultDisc { file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file) } - return fmt.Sprintf("%s/%s", folder, file) + return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file) } -// ZipShare takes an already-loaded share: Share.Load records a visit, so -// loading it again here would count every download twice. -func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer) error { +func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error { + s, err := a.shares.Load(ctx, id) if !s.Downloadable { return model.ErrNotAuthorized } + if err != nil { + return err + } log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks)) - // The share is the authorization (as in the public image handler): an anonymous lookup would - // hide a private playlist. Only the cover read is elevated. - coverCtx := request.WithUser(ctx, model.User{IsAdmin: true}) - return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, coverCtx, s.CoverArtID(), false) + return a.zipMediaFiles(ctx, id, s.Format, s.MaxBitRate, out, s.Tracks) } func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { - pls, err := a.ds.Playlist().GetWithTracks(ctx, id, true, false) + pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true) if err != nil { log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err) return err } mfs := pls.MediaFiles() log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs)) - return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, ctx, pls.CoverArtID(), true) + return a.zipMediaFiles(ctx, id, format, bitrate, out, mfs) } -func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, coverCtx context.Context, coverArt model.ArtworkID, addM3U bool) error { +func (a *archiver) zipMediaFiles(ctx context.Context, id string, format string, bitrate int, out io.Writer, mfs model.MediaFiles) error { z := createZipWriter(out, format, bitrate) - - zippedMfs := make(model.MediaFiles, len(mfs)) for idx, mf := range mfs { - req := a.resolveRequest(ctx, &mf, format, bitrate) - file := a.playlistFilename(mf, req.Format, idx) - if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { - // Abort the whole archive: continuing would silently emit - // empty zip entries since the headers are already written. - _ = z.Close() - return addErr - } - mf.Path = file - zippedMfs[idx] = mf + file := a.playlistFilename(mf, format, idx) + _ = a.addFileToZip(ctx, z, mf, format, bitrate, file) } - a.addCoverArtToZip(coverCtx, z, coverArt, "") - - // Add M3U file if requested - if addM3U && len(zippedMfs) > 0 { - plsName := str.SanitizeFilename(name) - w, err := z.CreateHeader(&zip.FileHeader{ - Name: plsName + ".m3u", - Modified: mfs[0].UpdatedAt, - Method: zip.Store, - }) - if err != nil { - log.Error(ctx, "Error creating playlist zip entry", err) - return err - } - - _, err = w.Write([]byte(zippedMfs.ToM3U8(plsName, false))) - if err != nil { - log.Error(ctx, "Error writing m3u in zip", err) - return err - } - } - err := z.Close() if err != nil { log.Error(ctx, "Error closing zip file", "id", id, err) @@ -251,112 +130,46 @@ 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, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext) + return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext) } -func (a *archiver) resolveRequest(ctx context.Context, mf *model.MediaFile, format string, bitrate int) stream.Request { - if format == "" || format == "raw" { - return stream.Request{Format: "raw"} - } - return a.decider.ResolveRequest(ctx, mf, format, bitrate, 0) +func sanitizeName(target string) string { + return strings.ReplaceAll(target, "/", "_") } -func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, req stream.Request, filename string) error { - path := mf.AbsolutePath() - - // Open the source before writing the zip entry header so a rejection - // (limiter, missing file, etc.) does not leave an empty entry in the - // archive. - var r io.ReadCloser - var err error - if req.Format != "raw" { - r, err = a.ms.NewStream(ctx, &mf, req) - } else { - r, err = os.Open(path) - } - if err != nil { - log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.Format, err) - return err - } - defer func() { - if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { - log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err) - } - }() - +func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error { w, err := z.CreateHeader(&zip.FileHeader{ Name: filename, Modified: mf.UpdatedAt, Method: zip.Store, }) if err != nil { - log.Error(ctx, "Error creating zip entry", "file", path, err) + log.Error(ctx, "Error creating zip entry", "file", mf.Path, err) return err } + var r io.ReadCloser + if format != "raw" && format != "" { + r, err = a.ms.DoStream(ctx, &mf, format, bitrate) + } else { + r, err = os.Open(mf.Path) + } + if err != nil { + log.Error(ctx, "Error opening file for zipping", "file", mf.Path, "format", format, err) + return err + } + + defer func() { + if err := r.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug { + log.Error(ctx, "Error closing stream", "id", mf.ID, "file", mf.Path, err) + } + }() + _, err = io.Copy(w, r) if err != nil { - log.Error(ctx, "Error zipping file", "file", path, err) + log.Error(ctx, "Error zipping file", "file", mf.Path, err) return err } return nil } - -// addCoverArtToZip adds the cover as dir/folder.. Errors are logged, never returned. -func (a *archiver) addCoverArtToZip(ctx context.Context, z *zip.Writer, artID model.ArtworkID, dir string) { - if artID.ID == "" { - return - } - // Buffered so a failed read leaves no empty entry. - data, err := a.readCoverArt(ctx, artID) - if errors.Is(err, artwork.ErrUnavailable) || errors.Is(err, model.ErrNotFound) { - log.Debug(ctx, "No cover art to add to zip", "artID", artID) - return - } - if err != nil { - log.Warn(ctx, "Error reading cover art for zipping", "artID", artID, err) - return - } - ext := coverArtExtension(data) - if ext == "" { - log.Warn(ctx, "Unknown cover art image type, not adding it to zip", "artID", artID) - return - } - w, err := z.CreateHeader(&zip.FileHeader{ - Name: path.Join(dir, "folder."+ext), - Modified: time.Now(), - Method: zip.Store, - }) - if err != nil { - log.Warn(ctx, "Error creating cover art zip entry", "artID", artID, err) - return - } - if _, err = w.Write(data); err != nil { - log.Warn(ctx, "Error zipping cover art", "artID", artID, err) - } -} - -func (a *archiver) readCoverArt(ctx context.Context, artID model.ArtworkID) ([]byte, error) { - img, err := a.artwork.Get(ctx, artID, archiveCoverArtSize, false) - if err != nil { - return nil, err - } - defer img.Close() - return io.ReadAll(img) -} - -// Resizing may re-encode the image, so the type comes from its bytes. -func coverArtExtension(data []byte) string { - switch http.DetectContentType(data) { - case "image/jpeg": - return "jpg" - case "image/png": - return "png" - case "image/webp": - return "webp" - case "image/gif": - return "gif" - } - return "" -} diff --git a/core/archiver_test.go b/core/archiver_test.go index 178d1b6b9..6c95b7bac 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -4,19 +4,12 @@ import ( "archive/zip" "bytes" "context" - "errors" "io" "strings" "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/stream" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/persistence" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -26,19 +19,15 @@ var _ = Describe("Archiver", func() { var ( arch core.Archiver ms *mockMediaStreamer - dc *fakeDecider ds *mockDataStore sh *mockShare - ca *mockCoverArt ) BeforeEach(func() { ms = &mockMediaStreamer{} - dc = &fakeDecider{} - sh = &mockShare{} ds = &mockDataStore{} - ca = &mockCoverArt{images: map[string][]byte{}} - arch = core.NewArchiver(ms, dc, ds, sh, ca) + sh = &mockShare{} + arch = core.NewArchiver(ms, ds, sh) }) Context("ZipAlbum", func() { @@ -54,8 +43,8 @@ var _ = Describe("Archiver", func() { Sort: "album", }}).Return(mfs, nil) - ds.On("MediaFile").Return(mfRepo) - ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3) + ds.On("MediaFile", mock.Anything).Return(mfRepo) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3) out := new(bytes.Buffer) err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out) @@ -68,23 +57,6 @@ var _ = Describe("Archiver", func() { Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3")) Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3")) }) - - It("streams the request resolved by the transcode decider and names the entry after its format", func() { - mfRepo := &mockMediaFileRepository{} - mfRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{Path: "test_data/01 - track1.flac", Suffix: "flac", AlbumID: "1"}}, nil) - ds.On("MediaFile").Return(mfRepo) - resolved := stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2} - dc.resolved = &resolved - ms.On("NewStream", mock.Anything, mock.Anything, resolved).Return(io.NopCloser(strings.NewReader("test")), nil).Once() - - out := new(bytes.Buffer) - Expect(arch.ZipAlbum(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed()) - ms.AssertExpectations(GinkgoT()) - - zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) - Expect(err).ToNot(HaveOccurred()) - Expect(zr.File[0].Name).To(HaveSuffix("01 - track1.opus")) - }) }) Context("ZipArtist", func() { @@ -96,15 +68,12 @@ var _ = Describe("Archiver", func() { mfRepo := &mockMediaFileRepository{} mfRepo.On("GetAll", []model.QueryOptions{{ - Filters: squirrel.And{ - persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist), - squirrel.Eq{"missing": false}, - }, - Sort: "album", + Filters: squirrel.Eq{"album_artist_id": "1"}, + Sort: "album", }}).Return(mfs, nil) - ds.On("MediaFile").Return(mfRepo) - ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ds.On("MediaFile", mock.Anything).Return(mfRepo) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out) @@ -117,166 +86,6 @@ var _ = Describe("Archiver", func() { Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3")) Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3")) }) - - When("albums that share a name", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - // zipArtistEntries zips the given tracks as artist "1" and returns the entry names in zip order. - zipArtistEntries := func(mfs model.MediaFiles) []string { - mfRepo := &mockMediaFileRepository{} - mfRepo.On("GetAll", mock.Anything).Return(mfs, nil) - ds.On("MediaFile", mock.Anything).Return(mfRepo) - ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil) - - out := new(bytes.Buffer) - Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed()) - zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) - Expect(err).To(BeNil()) - names := make([]string, len(zr.File)) - for i, f := range zr.File { - names[i] = f.Name - } - return names - } - - It("keeps the albums in query order", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Album C"}, - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"}, - {Path: "a/02.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"}, - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album B"}, - }) - Expect(names).To(Equal([]string{"Album C/01.mp3", "Album A/01.mp3", "Album A/02.mp3", "Album B/01.mp3"})) - }) - - It("suffixes the year when it tells the albums apart", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01 - Intro.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001}, - {Path: "b/01 - Intro.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005}, - }) - Expect(names).To(Equal([]string{"Greatest Hits [2001]/01 - Intro.mp3", "Greatest Hits [2005]/01 - Intro.mp3"})) - }) - - It("prefers the release year, so reissues of the same original are told apart", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2001}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2011}, - {Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 1996}, - }) - Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2011]/01.mp3", "Greatest Hits [1996]/01.mp3"})) - }) - - It("names the folder after the full album name", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, - Tags: model.Tags{model.TagAlbumVersion: {"Original"}}}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, - Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}}, - }) - Expect(names).To(Equal([]string{"Greatest Hits (Original)/01.mp3", "Greatest Hits (CD_Digital)/01.mp3"})) - }) - - It("prefers the album version over the year when it is not part of the name", func() { - conf.Server.Subsonic.AppendAlbumVersion = false - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, - Tags: model.Tags{model.TagAlbumVersion: {"Original"}}}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, - Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}}, - }) - Expect(names).To(Equal([]string{"Greatest Hits [Original]/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"})) - }) - - It("leaves the one album without the field unsuffixed", func() { - conf.Server.Subsonic.AppendAlbumVersion = false - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, - Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}}, - }) - Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"})) - }) - - It("skips a field that is empty on more than one album", func() { - conf.Server.Subsonic.AppendAlbumVersion = false - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005}, - {Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 2010, - Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}}, - }) - Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2005]/01.mp3", "Greatest Hits [2010]/01.mp3"})) - }) - - It("skips a field that is the same on every album", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-1"}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-2"}, - }) - Expect(names).To(Equal([]string{"Live [CAT-1]/01.mp3", "Live [CAT-2]/01.mp3"})) - }) - - It("falls back to the album id when nothing differs", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "0123456789abcdef", Album: "Greatest Hits", Year: 2001}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "fedcba9876543210", Album: "Greatest Hits", Year: 2001}, - }) - Expect(names).To(Equal([]string{"Greatest Hits [012345]/01.mp3", "Greatest Hits [fedcba]/01.mp3"})) - }) - - It("treats names that sanitize to the same folder as a clash", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "A/B", Year: 2001}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: `A\B`, Year: 2005}, - }) - Expect(names).To(Equal([]string{"A_B [2001]/01.mp3", "A_B [2005]/01.mp3"})) - }) - - It("sanitizes the suffix", func() { - conf.Server.Subsonic.AppendAlbumVersion = false - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"Vinyl"}}}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}}, - }) - Expect(names).To(Equal([]string{"Hits [Vinyl]/01.mp3", "Hits [CD_Digital]/01.mp3"})) - }) - - It("leaves the folder name alone when only one album has it", func() { - names := zipArtistEntries(model.MediaFiles{ - {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001}, - {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Other", Year: 2005}, - }) - Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Other/01.mp3"})) - }) - }) - }) - - Context("when the transcode limiter rejects a file", func() { - It("aborts the archive instead of continuing with empty entries", func() { - mfs := model.MediaFiles{ - {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1}, - {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1}, - } - - mfRepo := &mockMediaFileRepository{} - mfRepo.On("GetAll", []model.QueryOptions{{ - Filters: squirrel.Eq{"album_id": "1"}, - Sort: "album", - }}).Return(mfs, nil) - ds.On("MediaFile").Return(mfRepo) - - ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}). - Return(nil, stream.ErrTooManyTranscodes).Once() - - out := new(bytes.Buffer) - err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out) - Expect(err).To(MatchError(stream.ErrTooManyTranscodes)) - // NewStream should only have been called once: the loop must bail - // out on the rejection instead of trying every remaining track. - ms.AssertNumberOfCalls(GinkgoT(), "NewStream", 1) - }) }) Context("ZipShare", func() { @@ -294,16 +103,13 @@ var _ = Describe("Archiver", func() { Tracks: mfs, } - ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + sh.On("Load", mock.Anything, "1").Return(share, nil) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) - err := arch.ZipShare(context.Background(), share, out) + err := arch.ZipShare(context.Background(), "1", out) Expect(err).To(BeNil()) - // Share.Load records a visit; re-loading here would double-count - // every download. - sh.AssertNotCalled(GinkgoT(), "Load", mock.Anything, mock.Anything) - zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) Expect(err).To(BeNil()) @@ -315,30 +121,6 @@ var _ = Describe("Archiver", func() { }) Context("ZipPlaylist", func() { - It("names the entries and the M3U lines after the resolved format", func() { - pls := &model.Playlist{ID: "1", Name: "Test Playlist", Tracks: []model.PlaylistTrack{ - {MediaFile: model.MediaFile{Path: "test_data/01 - track1.flac", Suffix: "flac", Artist: "Artist 1", Title: "track1"}}, - }} - plRepo := &mockPlaylistRepository{} - plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil) - ds.On("Playlist").Return(plRepo) - dc.resolved = &stream.Request{Format: "opus", BitRate: 128} - ms.On("NewStream", mock.Anything, mock.Anything, *dc.resolved).Return(io.NopCloser(strings.NewReader("test")), nil) - - out := new(bytes.Buffer) - Expect(arch.ZipPlaylist(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed()) - - zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) - Expect(err).ToNot(HaveOccurred()) - Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.opus")) - m3u, err := zr.File[1].Open() - Expect(err).ToNot(HaveOccurred()) - defer m3u.Close() - content, err := io.ReadAll(m3u) - Expect(err).ToNot(HaveOccurred()) - Expect(string(content)).To(ContainSubstring("01 - Artist 1 - track1.opus")) - }) - It("zips a playlist correctly", func() { tracks := []model.PlaylistTrack{ {MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}}, @@ -352,9 +134,9 @@ var _ = Describe("Archiver", func() { } plRepo := &mockPlaylistRepository{} - plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil) - ds.On("Playlist").Return(plRepo) - ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + plRepo.On("GetWithTracks", "1", true).Return(pls, nil) + ds.On("Playlist", mock.Anything).Return(plRepo) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out) @@ -363,230 +145,34 @@ var _ = Describe("Archiver", func() { zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) Expect(err).To(BeNil()) - Expect(len(zr.File)).To(Equal(3)) + Expect(len(zr.File)).To(Equal(2)) Expect(zr.File[0].Name).To(Equal("01 - AC_DC - track1.mp3")) Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3")) - Expect(zr.File[2].Name).To(Equal("Test Playlist.m3u")) - - // Verify M3U content - m3uFile, err := zr.File[2].Open() - Expect(err).To(BeNil()) - defer m3uFile.Close() - - m3uContent, err := io.ReadAll(m3uFile) - Expect(err).To(BeNil()) - - expectedM3U := "#EXTM3U\n#PLAYLIST:Test Playlist\n#EXTINF:0,AC/DC - track1\n01 - AC_DC - track1.mp3\n#EXTINF:0,Artist 2 - track2\n02 - Artist 2 - track2.mp3\n" - Expect(string(m3uContent)).To(Equal(expectedM3U)) - }) - }) - Context("cover art", func() { - var ( - jpegData = []byte("\xff\xd8\xff\xe0 fake jpeg") - pngData = []byte("\x89PNG\x0d\x0a\x1a\x0a fake png") - ) - - mockAlbumTracks := func(filter squirrel.Sqlizer, mfs model.MediaFiles) { - mfRepo := &mockMediaFileRepository{} - mfRepo.On("GetAll", []model.QueryOptions{{Filters: filter, Sort: "album"}}).Return(mfs, nil) - ds.On("MediaFile", mock.Anything).Return(mfRepo) - ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil) - } - - It("adds the album cover to the album folder", func() { - ca.images["al-1"] = jpegData - mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{ - {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1}, - }) - - out := new(bytes.Buffer) - Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed()) - - files := readZip(out) - Expect(files).To(HaveLen(2)) - Expect(files).To(HaveKeyWithValue("Album_Promo/folder.jpg", jpegData)) - Expect(ca.requests).To(ConsistOf(coverRequest{id: "al-1", size: 500, square: false})) - }) - - It("adds the artist image to the root and each album cover to its folder", func() { - ca.images["ar-1"] = pngData - ca.images["al-1"] = jpegData - ca.images["al-2"] = jpegData - mockAlbumTracks(squirrel.And{ - persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist), - squirrel.Eq{"missing": false}, - }, model.MediaFiles{ - {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1}, - {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album 2", DiscNumber: 1}, - }) - - out := new(bytes.Buffer) - Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed()) - - files := readZip(out) - Expect(files).To(HaveLen(5)) - Expect(files).To(HaveKeyWithValue("folder.png", pngData)) - Expect(files).To(HaveKeyWithValue("Album 1/folder.jpg", jpegData)) - Expect(files).To(HaveKeyWithValue("Album 2/folder.jpg", jpegData)) - }) - - It("puts each same-named album's cover in that album's own folder", func() { - ca.images["al-1"] = jpegData - ca.images["al-2"] = pngData - mockAlbumTracks(squirrel.And{ - persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist), - squirrel.Eq{"missing": false}, - }, model.MediaFiles{ - {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, DiscNumber: 1}, - {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, DiscNumber: 1}, - }) - - out := new(bytes.Buffer) - Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed()) - - files := readZip(out) - Expect(files).To(HaveKeyWithValue("Greatest Hits [2001]/folder.jpg", jpegData)) - Expect(files).To(HaveKeyWithValue("Greatest Hits [2005]/folder.png", pngData)) - }) - - It("adds the playlist cover to the root", func() { - ca.images["pl-1"] = jpegData - plRepo := &mockPlaylistRepository{} - plRepo.On("GetWithTracks", "1", true, false).Return(&model.Playlist{ - ID: "1", - Name: "Test Playlist", - Tracks: []model.PlaylistTrack{ - {MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Artist: "Artist 1", Title: "track1"}}, - }, - }, nil) - ds.On("Playlist", mock.Anything).Return(plRepo) - ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil) - - out := new(bytes.Buffer) - Expect(arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)).To(Succeed()) - - files := readZip(out) - Expect(files).To(HaveLen(3)) - Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData)) - Expect(files).To(HaveKey("Test Playlist.m3u")) - }) - - It("adds the shared item's cover to the root, even for a private playlist", func() { - ca.images["pl-10"] = jpegData - ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil) - share := &model.Share{ - ID: "1", - Downloadable: true, - Format: "mp3", - MaxBitRate: 128, - ResourceType: "playlist", - ResourceIDs: "10", - Tracks: model.MediaFiles{ - {ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"}, - }, - } - - out := new(bytes.Buffer) - Expect(arch.ZipShare(context.Background(), share, out)).To(Succeed()) - - files := readZip(out) - Expect(files).To(HaveLen(2)) - Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData)) - Expect(ca.requests).To(ConsistOf(coverRequest{id: "pl-10", size: 500, square: false, admin: true})) - }) - - It("still builds the archive when the cover cannot be read", func() { - ca.err = errors.New("boom") - mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{ - {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1}, - }) - - out := new(bytes.Buffer) - Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed()) - - files := readZip(out) - Expect(files).To(HaveLen(1)) - Expect(files).To(HaveKey("Album/01 - track1.mp3")) }) }) }) -func readZip(out *bytes.Buffer) map[string][]byte { - zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) - Expect(err).ToNot(HaveOccurred()) - files := make(map[string][]byte, len(zr.File)) - for _, f := range zr.File { - r, err := f.Open() - Expect(err).ToNot(HaveOccurred()) - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - _ = r.Close() - files[f.Name] = data - } - return files -} - -type coverRequest struct { - id string - size int - square bool - admin bool -} - -type mockCoverArt struct { - artwork.Artwork - images map[string][]byte - err error - requests []coverRequest -} - -func (m *mockCoverArt) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*artwork.Image, error) { - user, _ := request.UserFrom(ctx) - m.requests = append(m.requests, coverRequest{id: artID.String(), size: size, square: square, admin: user.IsAdmin}) - if m.err != nil { - return nil, m.err - } - data, ok := m.images[artID.String()] - if !ok { - return nil, artwork.ErrUnavailable - } - return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(data))}, nil -} - type mockDataStore struct { mock.Mock model.DataStore } -func (m *mockDataStore) MediaFile() model.MediaFileRepository { - args := m.Called() +func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + args := m.Called(ctx) return args.Get(0).(model.MediaFileRepository) } -func (m *mockDataStore) Playlist() model.PlaylistRepository { - args := m.Called() +func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository { + args := m.Called(ctx) return args.Get(0).(model.PlaylistRepository) } -func (m *mockDataStore) Library() model.LibraryRepository { - return &mockLibraryRepository{} -} - -type mockLibraryRepository struct { - mock.Mock - model.LibraryRepository -} - -func (m *mockLibraryRepository) GetPath(_ context.Context, id int) (string, error) { - return "/music", nil -} - type mockMediaFileRepository struct { mock.Mock model.MediaFileRepository } -func (m *mockMediaFileRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { +func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { args := m.Called(options) return args.Get(0).(model.MediaFiles), args.Error(1) } @@ -596,35 +182,22 @@ type mockPlaylistRepository struct { model.PlaylistRepository } -func (m *mockPlaylistRepository) GetWithTracks(_ context.Context, id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) { - args := m.Called(id, refreshSmartPlaylists, includeMissing) +func (m *mockPlaylistRepository) GetWithTracks(id string, includeTracks bool) (*model.Playlist, error) { + args := m.Called(id, includeTracks) return args.Get(0).(*model.Playlist), args.Error(1) } type mockMediaStreamer struct { mock.Mock - stream.MediaStreamer + core.MediaStreamer } -func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { - args := m.Called(ctx, mf, req) +func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, format string, bitrate int) (*core.Stream, error) { + args := m.Called(ctx, mf, format, bitrate) if args.Error(1) != nil { return nil, args.Error(1) } - return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil -} - -// fakeDecider echoes the legacy format/bitrate unless a resolved request is set. -type fakeDecider struct { - stream.TranscodeDecider - resolved *stream.Request -} - -func (f *fakeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request { - if f.resolved != nil { - return *f.resolved - } - return stream.Request{Format: format, BitRate: bitRate, Offset: offset} + return &core.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil } type mockShare struct { diff --git a/core/artwork/agent_images.go b/core/artwork/agent_images.go deleted file mode 100644 index 985abacd7..000000000 --- a/core/artwork/agent_images.go +++ /dev/null @@ -1,131 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "io" - "net/url" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/str" -) - -// externalName mirrors the normalization the aggregate provider applies, so agent searches match. -func externalName(name string) string { - if conf.Server.DevPreserveUnicodeInExternalCalls { - return name - } - return str.Clear(name) -} - -// bestImageURL returns the largest fetchable image URL. Only one is returned and its failure ends -// the agent's turn, so an unfetchable candidate must never win: url.Parse alone accepts anything. -func bestImageURL(imgs []agents.ExternalImage) *url.URL { - var best *url.URL - var bestSize int - for i := range imgs { - if imgs[i].URL == "" { - continue - } - u, err := url.Parse(imgs[i].URL) - if err != nil || !u.IsAbs() || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { - continue - } - if best == nil || imgs[i].Size > bestSize { - best, bestSize = u, imgs[i].Size - } - } - return best -} - -// longerRetry keeps whichever external failure asks for the longer wait, so one provider's -// short delay cannot shorten another's. -func longerRetry(a, b error) error { - if a == nil { - return b - } - var ra, rb *agents.RetryLaterError - if errors.As(b, &rb) && (!errors.As(a, &ra) || rb.RetryIn > ra.RetryIn) { - return b - } - return a -} - -// fetchArtistImage tries each enabled artist-image agent in order. The error is non-nil only when no -// agent succeeded and at least one failed transiently. -func fetchArtistImage(ctx context.Context, ag *agents.Agents, gate gateFunc, ar model.Artist) (io.ReadCloser, string, error) { - // Synthetic artists would otherwise get an unrelated agent result assigned to them. - switch ar.ID { - case consts.UnknownArtistID, consts.VariousArtistsID: - traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, Detail: "synthetic artist"}) - return nil, "", nil - } - name := externalName(ar.Name) - imageAgents := ag.ArtistImageAgents() - if len(imageAgents) == 0 { - traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, - Detail: "no enabled agent provides artist images"}) - return nil, "", nil - } - var extErr error - for _, a := range imageAgents { - reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) { - imgs, err := a.Retriever.GetArtistImages(ctx, ar.ID, name, ar.MbzArtistID) - if err != nil { - return nil, "", err - } - u := bestImageURL(imgs) - if u == nil { - return nil, "", agents.ErrNotFound - } - return fromURL(ctx, u) - }) - recordAgent(ctx, a.Name, reader, path, err) - if reader != nil { - return reader, a.Name, nil - } - if isTransientExternal(err) { - extErr = longerRetry(extErr, err) - log.Debug(ctx, "Artwork: External artist-image lookup failed", "agent", a.Name, "artist", ar.Name, err) - } - } - return nil, "", extErr -} - -// fetchAlbumImage is the album counterpart of fetchArtistImage. -func fetchAlbumImage(ctx context.Context, ag *agents.Agents, gate gateFunc, al model.Album) (io.ReadCloser, string, error) { - name, artist := externalName(al.Name), externalName(al.AlbumArtist) - imageAgents := ag.AlbumImageAgents() - if len(imageAgents) == 0 { - traceFrom(ctx).add(TraceStep{Candidate: externalCandidate, Outcome: OutcomeSkipped, - Detail: "no enabled agent provides album images"}) - return nil, "", nil - } - var extErr error - for _, a := range imageAgents { - reader, path, err := gate(a.Name, func() (io.ReadCloser, string, error) { - imgs, err := a.Retriever.GetAlbumImages(ctx, name, artist, al.MbzAlbumID) - if err != nil { - return nil, "", err - } - u := bestImageURL(imgs) - if u == nil { - return nil, "", agents.ErrNotFound - } - return fromURL(ctx, u) - }) - recordAgent(ctx, a.Name, reader, path, err) - if reader != nil { - return reader, a.Name, nil - } - if isTransientExternal(err) { - extErr = longerRetry(extErr, err) - log.Debug(ctx, "Artwork: External album-image lookup failed", "agent", a.Name, "album", al.Name, err) - } - } - return nil, "", extErr -} diff --git a/core/artwork/agent_images_test.go b/core/artwork/agent_images_test.go deleted file mode 100644 index d0c2429b0..000000000 --- a/core/artwork/agent_images_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/str" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// fakeImageAgent is a built-in agent stub implementing both image retrievers; it -// records call counts so per-agent ordering and short-circuiting can be asserted. -type fakeImageAgent struct { - name string - imgs []agents.ExternalImage - err error - artistCalls int - albumCalls int - gotArtistName string - gotAlbumName string - // block, when set, holds every lookup until closed, standing in for a slow/rate-limited agent. - block chan struct{} - // mu guards the call counters: the worker resolves several items concurrently. - mu sync.Mutex -} - -func (f *fakeImageAgent) AgentName() string { return f.name } - -func (f *fakeImageAgent) GetArtistImages(_ context.Context, _, name, _ string) ([]agents.ExternalImage, error) { - if f.block != nil { - <-f.block - } - f.mu.Lock() - f.artistCalls++ - f.gotArtistName = name - f.mu.Unlock() - return f.imgs, f.err -} - -func (f *fakeImageAgent) GetAlbumImages(_ context.Context, name, _, _ string) ([]agents.ExternalImage, error) { - f.albumCalls++ - f.gotAlbumName = name - return f.imgs, f.err -} - -// imageAgents registers the fakes as built-in agents and enables them in order. The fakes -// ignore the DataStore, so reusing the process-wide GetAgents singleton across tests is safe. -func imageAgents(fakes ...*fakeImageAgent) *agents.Agents { - names := make([]string, 0, len(fakes)) - for _, f := range fakes { - fake := f - agents.Register(fake.name, func(model.DataStore) agents.Interface { return fake }) - names = append(names, fake.name) - } - conf.Server.Agents = strings.Join(names, ",") - return agents.GetAgents(&tests.MockDataStore{}, nil) -} - -var _ = Describe("agent images", func() { - var ( - ctx context.Context - srv *httptest.Server - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = context.Background() - srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("image-bytes")) - })) - DeferCleanup(srv.Close) - }) - - img := func(path string, size int) agents.ExternalImage { - return agents.ExternalImage{URL: srv.URL + path, Size: size} - } - - Describe("bestImageURL", func() { - It("picks the largest-Size URL and skips empty ones", func() { - u := bestImageURL([]agents.ExternalImage{ - {URL: "http://x/small", Size: 10}, - {URL: "", Size: 9999}, - {URL: "http://x/big", Size: 100}, - }) - Expect(u).ToNot(BeNil()) - Expect(u.String()).To(Equal("http://x/big")) - }) - - It("skips a malformed largest URL and falls back to a valid smaller one", func() { - u := bestImageURL([]agents.ExternalImage{ - {URL: "http://x/valid", Size: 10}, - {URL: "http://x/%zz", Size: 100}, // invalid percent-escape, largest - }) - Expect(u).ToNot(BeNil()) - Expect(u.String()).To(Equal("http://x/valid")) - }) - - It("returns nil when there is no non-empty URL", func() { - Expect(bestImageURL(nil)).To(BeNil()) - Expect(bestImageURL([]agents.ExternalImage{{URL: "", Size: 5}})).To(BeNil()) - }) - - // Plugins hand these over as free-form strings, and url.Parse accepts them all. An - // unfetchable candidate that wins here ends the agent's turn before its valid images run. - DescribeTable("skips a candidate that cannot be fetched", - func(badURL string) { - u := bestImageURL([]agents.ExternalImage{ - {URL: badURL, Size: 100}, // largest, and first - {URL: "https://cdn.example.com/ok.jpg", Size: 10}, - }) - Expect(u).ToNot(BeNil()) - Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg")) - }, - Entry("a relative path", "images/big.jpg"), - Entry("a root-relative path", "/images/big.jpg"), - Entry("a scheme we cannot fetch", "ftp://host/big.jpg"), - Entry("a scheme-relative URL", "//host/big.jpg"), - Entry("a URL with no host", "http:///big.jpg"), - ) - - // Size is often 0 for every candidate, and only a strictly larger one replaces the first, - // so an unfetchable entry in first position would otherwise stick. - It("skips an unfetchable first candidate when every Size is zero", func() { - u := bestImageURL([]agents.ExternalImage{ - {URL: "images/rel.jpg"}, - {URL: "https://cdn.example.com/ok.jpg"}, - }) - Expect(u).ToNot(BeNil()) - Expect(u.String()).To(Equal("https://cdn.example.com/ok.jpg")) - }) - - It("returns nil when no candidate is fetchable", func() { - Expect(bestImageURL([]agents.ExternalImage{ - {URL: "images/a.jpg", Size: 10}, - {URL: "ftp://host/b.jpg", Size: 20}, - })).To(BeNil()) - }) - }) - - Describe("fetchArtistImage", func() { - It("returns the first agent's image and its name", func() { - a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} - ag := imageAgents(a) - - r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "Artist"}) - Expect(r).ToNot(BeNil()) - defer r.Close() - Expect(name).To(Equal("agentA")) - Expect(err).ToNot(HaveOccurred()) - }) - - It("skips the external lookup for synthetic artists", func() { - a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} - ag := imageAgents(a) - - for _, id := range []string{consts.UnknownArtistID, consts.VariousArtistsID} { - r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: id, Name: "Various Artists"}) - Expect(r).To(BeNil()) - Expect(name).To(BeEmpty()) - Expect(err).ToNot(HaveOccurred()) - } - Expect(a.artistCalls).To(Equal(0), "synthetic artists never reach the agents") - }) - - It("records a skipped external candidate when no agent provides artist images", func() { - ag := imageAgents() - t := &ChainTrace{} - - r, _, err := fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, model.Artist{ID: "ar1"}) - Expect(r).To(BeNil()) - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped, - Detail: "no enabled agent provides artist images"}}), - "a configured external token must never be silently absent from the chain") - }) - - It("records a skipped external candidate for synthetic artists", func() { - a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} - ag := imageAgents(a) - t := &ChainTrace{} - - _, _, _ = fetchArtistImage(withTrace(ctx, t), ag, passthroughGate, - model.Artist{ID: consts.VariousArtistsID, Name: "Various Artists"}) - Expect(t.Steps()).To(HaveLen(1)) - Expect(t.Steps()[0].Outcome).To(Equal(OutcomeSkipped)) - Expect(t.Steps()[0].Detail).To(ContainSubstring("synthetic")) - }) - - It("clears typographic characters from the query name unless preserving unicode", func() { - conf.Server.DevPreserveUnicodeInExternalCalls = false - a := &fakeImageAgent{name: "agentA"} - ag := imageAgents(a) - - _, _, _ = fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1", Name: "AC’DC"}) - Expect(a.gotArtistName).To(Equal(str.Clear("AC’DC"))) - }) - - It("falls through to a later agent, and its success beats the earlier error", func() { - a := &fakeImageAgent{name: "agentA", err: errBreakerOpen} - b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 50)}} - ag := imageAgents(a, b) - - r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) - Expect(r).ToNot(BeNil()) - defer r.Close() - Expect(name).To(Equal("agentB")) - Expect(err).ToNot(HaveOccurred(), "a later hit clears an earlier agent's error") - Expect(a.artistCalls).To(Equal(1)) - Expect(b.artistCalls).To(Equal(1)) - }) - - It("reports a clean miss when every agent finds nothing", func() { - a := &fakeImageAgent{name: "agentA"} // no images, no error -> not found - b := &fakeImageAgent{name: "agentB", err: agents.ErrNotFound} - ag := imageAgents(a, b) - - r, name, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) - Expect(r).To(BeNil()) - Expect(name).To(BeEmpty()) - Expect(err).ToNot(HaveOccurred(), "not-found is definitive, never a transient failure") - }) - - It("reports an error when one agent fails transiently and the rest find nothing", func() { - a := &fakeImageAgent{name: "agentA", err: agents.ErrNotFound} - b := &fakeImageAgent{name: "agentB", err: context.DeadlineExceeded} - ag := imageAgents(a, b) - - r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) - Expect(r).To(BeNil()) - Expect(err).To(HaveOccurred()) - }) - - // The worker reschedules on this delay, so it is only honored if the agent loop - // returns it. Two throttled agents: the longest wait is the one that must survive. - It("returns the longest retry delay the providers asked for", func() { - a := &fakeImageAgent{name: "agentA", err: &agents.RetryLaterError{RetryIn: 10 * time.Second}} - b := &fakeImageAgent{name: "agentB", err: &agents.RetryLaterError{RetryIn: 5 * time.Second}} - ag := imageAgents(a, b) - - r, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) - Expect(r).To(BeNil()) - retry, ok := errors.AsType[*agents.RetryLaterError](err) - Expect(ok).To(BeTrue()) - Expect(retry.RetryIn).To(Equal(10 * time.Second)) - }) - - It("returns no delay when the provider did not ask for one", func() { - ag := imageAgents(&fakeImageAgent{name: "agentA", err: errors.New("boom")}) - - _, _, err := fetchArtistImage(ctx, ag, passthroughGate, model.Artist{ID: "ar1"}) - Expect(err).To(HaveOccurred()) - _, ok := errors.AsType[*agents.RetryLaterError](err) - Expect(ok).To(BeFalse(), "a plain failure must not look like a throttle") - }) - }) - - Describe("fetchAlbumImage", func() { - It("returns the winning agent's image and name", func() { - a := &fakeImageAgent{name: "agentA", imgs: []agents.ExternalImage{img("/a", 100)}} - ag := imageAgents(a) - - r, name, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album", AlbumArtist: "Artist"}) - Expect(r).ToNot(BeNil()) - defer r.Close() - Expect(name).To(Equal("agentA")) - Expect(err).ToNot(HaveOccurred()) - Expect(a.albumCalls).To(Equal(1)) - }) - - It("records a skipped external candidate when no agent provides album images", func() { - ag := imageAgents() - t := &ChainTrace{} - - r, _, err := fetchAlbumImage(withTrace(ctx, t), ag, passthroughGate, model.Album{Name: "Album"}) - Expect(r).To(BeNil()) - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "external", Outcome: OutcomeSkipped, - Detail: "no enabled agent provides album images"}}), - "a configured external token must never be silently absent from the chain") - }) - - It("reports an error when the only agent fails transiently", func() { - a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded} - ag := imageAgents(a) - - r, _, err := fetchAlbumImage(ctx, ag, passthroughGate, model.Album{Name: "Album"}) - Expect(r).To(BeNil()) - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("gate naming", func() { - It("invokes the gate once per agent, keyed by agent name", func() { - a := &fakeImageAgent{name: "agentA", err: context.DeadlineExceeded} - b := &fakeImageAgent{name: "agentB", imgs: []agents.ExternalImage{img("/b", 1)}} - ag := imageAgents(a, b) - - var gatedNames []string - gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - gatedNames = append(gatedNames, name) - return f() - } - - r, _, _ := fetchArtistImage(ctx, ag, gate, model.Artist{ID: "ar1"}) - Expect(r).ToNot(BeNil()) - defer r.Close() - Expect(gatedNames).To(Equal([]string{"agentA", "agentB"})) - }) - }) -}) diff --git a/core/artwork/animation.go b/core/artwork/animation.go deleted file mode 100644 index 07f493eb4..000000000 --- a/core/artwork/animation.go +++ /dev/null @@ -1,120 +0,0 @@ -package artwork - -import ( - "bytes" - "encoding/binary" -) - -// isAnimatedGIF checks for multiple image descriptor blocks (0x2C) in a GIF file. -// Animated GIFs use GIF89a and contain multiple image blocks. -func isAnimatedGIF(data []byte) bool { - // GIF header: "GIF87a" or "GIF89a" - if !bytes.HasPrefix(data, []byte("GIF")) { - return false - } - - // Skip header (6 bytes) + logical screen descriptor (7 bytes) - pos := 13 - if pos >= len(data) { - return false - } - - // Skip Global Color Table if present (bit 7 of packed byte at offset 10) - if len(data) > 10 && data[10]&0x80 != 0 { - // GCT size = 3 * 2^(N+1) where N = bits 0-2 of packed byte - gctSize := 3 * (1 << ((data[10] & 0x07) + 1)) - pos += gctSize - } - - frameCount := 0 - for pos < len(data) { - switch data[pos] { - case 0x2C: // Image Descriptor - marks a frame - frameCount++ - if frameCount > 1 { - return true - } - pos++ // skip introducer - if pos+8 >= len(data) { - return false - } - pos += 8 // skip x, y, w, h (each 2 bytes) - packed := data[pos] - pos++ // skip packed byte - // Skip Local Color Table if present - if packed&0x80 != 0 { - lctSize := 3 * (1 << ((packed & 0x07) + 1)) - pos += lctSize - } - // Skip LZW minimum code size - pos++ - // Skip sub-blocks - pos = skipGIFSubBlocks(data, pos) - case 0x21: // Extension block - pos++ // skip introducer - if pos >= len(data) { - return false - } - pos++ // skip extension label - // Skip sub-blocks - pos = skipGIFSubBlocks(data, pos) - case 0x3B: // Trailer - return false - default: - // Unknown block, bail - return false - } - } - return false -} - -// skipGIFSubBlocks advances past a sequence of GIF sub-blocks (terminated by a zero-length block). -func skipGIFSubBlocks(data []byte, pos int) int { - for pos < len(data) { - blockSize := int(data[pos]) - pos++ // skip size byte - if blockSize == 0 { - break - } - pos += blockSize - } - return pos -} - -// isAnimatedWebP checks for ANMF (animation frame) chunks in a WebP RIFF container. -func isAnimatedWebP(data []byte) bool { - // WebP header: "RIFF" + 4 bytes size + "WEBP" - if !bytes.HasPrefix(data, []byte("RIFF")) || len(data) < 12 { - return false - } - if !bytes.Equal(data[8:12], []byte("WEBP")) { - return false - } - // Scan for ANMF chunk identifier - return bytes.Contains(data[12:], []byte("ANMF")) -} - -// isAnimatedPNG checks for the acTL (animation control) chunk in a PNG file. -// APNG files contain an acTL chunk that is not present in static PNGs. -func isAnimatedPNG(data []byte) bool { - // PNG signature: 8 bytes - pngSig := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} - if !bytes.HasPrefix(data, pngSig) { - return false - } - - // Scan chunks for "acTL" (animation control) - pos := uint64(8) - dataLen := uint64(len(data)) - for pos+8 <= dataLen { - chunkLen := uint64(binary.BigEndian.Uint32(data[pos : pos+4])) - chunkType := string(data[pos+4 : pos+8]) - - if chunkType == "acTL" { - return true - } - // Move to next chunk: 4 (length) + 4 (type) + chunkLen (data) + 4 (CRC) - pos += 12 + chunkLen - } - return false -} diff --git a/core/artwork/animation_test.go b/core/artwork/animation_test.go deleted file mode 100644 index 9000a8511..000000000 --- a/core/artwork/animation_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package artwork - -import ( - "bytes" - "encoding/binary" - "image" - "image/color" - "image/gif" - "image/png" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Animation detection", func() { - Describe("isAnimatedGIF", func() { - It("detects an animated GIF with multiple frames", func() { - Expect(isAnimatedGIF(createAnimatedGIF(2))).To(BeTrue()) - }) - - It("detects an animated GIF with many frames", func() { - Expect(isAnimatedGIF(createAnimatedGIF(5))).To(BeTrue()) - }) - - It("does not flag a static GIF (single frame)", func() { - Expect(isAnimatedGIF(createAnimatedGIF(1))).To(BeFalse()) - }) - - It("returns false for non-GIF data", func() { - Expect(isAnimatedGIF(nil)).To(BeFalse()) - Expect(isAnimatedGIF([]byte{0xFF, 0xD8})).To(BeFalse()) - }) - }) - - Describe("isAnimatedWebP", func() { - It("detects an animated WebP with ANMF chunk", func() { - Expect(isAnimatedWebP(createAnimatedWebPBytes())).To(BeTrue()) - }) - - It("does not flag a static WebP (no ANMF chunk)", func() { - Expect(isAnimatedWebP(createStaticWebPBytes())).To(BeFalse()) - }) - - It("returns false for non-WebP data", func() { - Expect(isAnimatedWebP(nil)).To(BeFalse()) - Expect(isAnimatedWebP([]byte{0xFF, 0xD8})).To(BeFalse()) - }) - }) - - Describe("isAnimatedPNG", func() { - It("detects an APNG with acTL chunk", func() { - Expect(isAnimatedPNG(createAPNGBytes())).To(BeTrue()) - }) - - It("does not flag a static PNG (no acTL chunk)", func() { - Expect(isAnimatedPNG(createStaticPNGBytes())).To(BeFalse()) - }) - - It("returns false for non-PNG data", func() { - Expect(isAnimatedPNG(nil)).To(BeFalse()) - Expect(isAnimatedPNG([]byte{0xFF, 0xD8})).To(BeFalse()) - }) - }) -}) - -// createAnimatedGIF creates a minimal animated GIF with the given number of frames. -func createAnimatedGIF(frames int) []byte { - g := &gif.GIF{ - LoopCount: 0, - } - for range frames { - img := image.NewPaletted(image.Rect(0, 0, 2, 2), color.Palette{color.Black, color.White}) - g.Image = append(g.Image, img) - g.Delay = append(g.Delay, 10) - } - var buf bytes.Buffer - err := gif.EncodeAll(&buf, g) - if err != nil { - panic(err) - } - return buf.Bytes() -} - -// writeUint32LE appends a little-endian uint32 to the buffer. -func writeUint32LE(buf *bytes.Buffer, v uint32) { - b := make([]byte, 4) - binary.LittleEndian.PutUint32(b, v) - buf.Write(b) -} - -// writeUint32BE appends a big-endian uint32 to the buffer. -func writeUint32BE(buf *bytes.Buffer, v uint32) { - b := make([]byte, 4) - binary.BigEndian.PutUint32(b, v) - buf.Write(b) -} - -// createAnimatedWebPBytes creates a minimal RIFF/WEBP container with an ANMF chunk. -func createAnimatedWebPBytes() []byte { - var buf bytes.Buffer - buf.WriteString("RIFF") - writeUint32LE(&buf, 100) // file size placeholder - buf.WriteString("WEBP") - // VP8X chunk (extended format, required for animation) - buf.WriteString("VP8X") - writeUint32LE(&buf, 10) - buf.Write(make([]byte, 10)) - // ANIM chunk (animation parameters) - buf.WriteString("ANIM") - writeUint32LE(&buf, 6) - buf.Write(make([]byte, 6)) - // ANMF chunk (animation frame) - buf.WriteString("ANMF") - writeUint32LE(&buf, 16) - buf.Write(make([]byte, 16)) - return buf.Bytes() -} - -// createStaticWebPBytes creates a minimal RIFF/WEBP container without ANMF chunks. -func createStaticWebPBytes() []byte { - var buf bytes.Buffer - buf.WriteString("RIFF") - writeUint32LE(&buf, 20) // file size - buf.WriteString("WEBP") - // VP8 chunk (simple lossy format) - buf.WriteString("VP8 ") - writeUint32LE(&buf, 4) - buf.Write(make([]byte, 4)) - return buf.Bytes() -} - -// createAPNGBytes creates a minimal PNG with an acTL chunk (making it APNG). -func createAPNGBytes() []byte { - // Start with a real PNG - staticPNG := createStaticPNGBytes() - - // Insert an acTL chunk after the IHDR chunk. - // PNG structure: signature (8) + IHDR chunk (4 len + 4 type + 13 data + 4 crc = 25) - ihdrEnd := 8 + 25 - var buf bytes.Buffer - buf.Write(staticPNG[:ihdrEnd]) - // Write acTL chunk: length=8, type="acTL", data=num_frames(4)+num_plays(4), CRC=4 - writeUint32BE(&buf, 8) // chunk data length - buf.WriteString("acTL") - writeUint32BE(&buf, 2) // num_frames - writeUint32BE(&buf, 0) // num_plays (0 = infinite) - writeUint32BE(&buf, 0) // CRC placeholder - buf.Write(staticPNG[ihdrEnd:]) - return buf.Bytes() -} - -// createStaticPNGBytes creates a minimal valid static PNG. -func createStaticPNGBytes() []byte { - img := image.NewRGBA(image.Rect(0, 0, 2, 2)) - var buf bytes.Buffer - err := png.Encode(&buf, img) - if err != nil { - panic(err) - } - return buf.Bytes() -} diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 2a9ebe771..bc4a726f1 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -1,439 +1,130 @@ package artwork import ( - "bytes" "context" "errors" - "fmt" + _ "image/gif" "io" - "os" "time" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" + "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/resources" "github.com/navidrome/navidrome/utils/cache" + _ "golang.org/x/image/webp" ) var ErrUnavailable = errors.New("artwork unavailable") -// errStaleSource means the backing file's mtime no longer matches RefMtime, so the stored hash may be stale. -var errStaleSource = errors.New("artwork: source file changed since resolution") - -// Image is one servable artwork response. -type Image struct { - io.ReadCloser - Hash string // pixel identity; "" for placeholders - ETag string // representation validator; "" means Hash applies (full-size original) - LastUpdated time.Time - Placeholder bool -} - -// representationTag varies with dimensions and encode settings, so a config change invalidates -// a revalidating client's cache even though the pixel hash is unchanged. -func representationTag(hash string, size int, square bool) string { - return fmt.Sprintf("%s.%d.%v.%s", hash, size, square, formatQualityTag()) -} - type Artwork interface { - // Get returns ErrUnavailable when there is nothing to serve and model.ErrNotFound when - // the id resolves to nothing, so the caller can pick placeholder vs 404. - Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) - // GetOrPlaceholder accepts an artwork token or a raw entity id, falling back to the - // kind's placeholder image (never resized, Placeholder=true). - GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) + Get(ctx context.Context, artID model.ArtworkID, size int) (io.ReadCloser, time.Time, error) + GetOrPlaceholder(ctx context.Context, id string, size int) (io.ReadCloser, time.Time, error) } -func NewArtwork(ds model.DataStore, cache cache.FileCache, store *ImageStore, ffm ffmpeg.FFmpeg) Artwork { - return &service{ds: ds, cache: cache, store: store, ffmpeg: ffm} +func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, em core.ExternalMetadata) Artwork { + return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, em: em} } -// entityExists reports whether the entity an artwork id points at is still there: state rows -// outlive a deleted entity until the next prune, so a servable row is not evidence of its owner. -func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID) bool { - var found bool - var err error - switch artID.Kind { - case model.KindArtistArtwork: - found, err = ds.Artist().Exists(ctx, artID.ID) - case model.KindAlbumArtwork: - found, err = ds.Album().Exists(ctx, artID.ID) - case model.KindMediaFileArtwork: - found, err = ds.MediaFile().Exists(ctx, artID.ID) - case model.KindPlaylistArtwork: - found, err = ds.Playlist().Exists(ctx, artID.ID) - case model.KindRadioArtwork: - found, err = ds.Radio().Exists(ctx, artID.ID) - case model.KindDiscArtwork: - albumID, _, perr := model.ParseDiscArtworkID(artID.ID) - if perr != nil { - return false - } - found, err = ds.Album().Exists(ctx, albumID) - default: - return false - } - return err == nil && found -} - -type service struct { +type artwork struct { ds model.DataStore cache cache.FileCache - store *ImageStore ffmpeg ffmpeg.FFmpeg + em core.ExternalMetadata } -func (s *service) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*Image, error) { - artID, err := s.parseArtworkID(ctx, id) - var img *Image +type artworkReader interface { + cache.Item + LastUpdated() time.Time + Reader(ctx context.Context) (io.ReadCloser, string, error) +} + +func (a *artwork) GetOrPlaceholder(ctx context.Context, id string, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) { + artID, err := a.getArtworkId(ctx, id) if err == nil { - img, err = s.Get(ctx, artID, size, square) + reader, lastUpdate, err = a.Get(ctx, artID, size) } - // Only a resolvable entity with no art gets a placeholder; an unknown id must stay - // ErrNotFound so callers can still answer 404 / Subsonic error 70. if errors.Is(err, ErrUnavailable) { - return placeholderImage(artID.Kind), nil + if artID.Kind == model.KindArtistArtwork { + reader, _ = resources.FS().Open(consts.PlaceholderArtistArt) + } else { + reader, _ = resources.FS().Open(consts.PlaceholderAlbumArt) + } + return reader, consts.ServerStart, nil } - return img, err + return reader, lastUpdate, err } -func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { - if artID.ID == "" { - return nil, ErrUnavailable - } - if size < 0 { - size = 0 // a negative size means full-size, not a giant (OOM) resize rectangle - } - switch artID.Kind { - case model.KindDiscArtwork: - return s.serveDisc(ctx, artID, size, square) - case model.KindMediaFileArtwork: - return s.serveMediaFile(ctx, artID, size, square) - default: - return s.serveEntity(ctx, artID, size, square) - } -} - -func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { - ia, err := s.ds.Artwork().GetItemArtwork(ctx, artID.Kind, artID.ID, model.ImageTypePrimary) - switch { - case errors.Is(err, model.ErrNotFound): - return s.provisional(ctx, artID, size, square) - case err != nil: - return nil, err - case ia.Hash == "": - // Settled absent: only an explicit reprocess or refresh retries it. - return nil, ErrUnavailable - default: - return s.serveHash(ctx, artID, ia, size, square) - } -} - -// serveSource is the one place bytes become an Image. hash is the pixel identity ("" for disc art) -// and doubles as the full-size validator, so an ETag is only needed when resized or hash is "". -func (s *service) serveSource(ctx context.Context, key, hash string, lastUpdate time.Time, - size int, square bool, open func() (io.ReadCloser, error), -) (*Image, error) { - if size == 0 && !square { - rc, err := open() - if err != nil { - return nil, err - } - if rc == nil { - return nil, ErrUnavailable - } - img := &Image{ReadCloser: rc, Hash: hash, LastUpdated: lastUpdate} - if hash == "" { - img.ETag = representationTag(key, size, square) - } - return img, nil - } - stream, err := s.cache.Get(ctx, &resizedItem{ - hash: key, size: size, square: square, ffmpeg: s.ffmpeg, open: open, - }) +func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int) (reader io.ReadCloser, lastUpdate time.Time, err error) { + artReader, err := a.getArtworkReader(ctx, artID, size) if err != nil { - return nil, err + return nil, time.Time{}, err } - return &Image{ReadCloser: stream, Hash: hash, ETag: representationTag(key, size, square), LastUpdated: lastUpdate}, nil -} -// serveHash serves the bytes of a found state row. A mismatch/open error is dangling, but a -// cancelled request is not: it must not enqueue a re-resolution. -func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *model.ItemArtwork, size int, square bool) (*Image, error) { - // Only this path can hand back a deleted entity's bytes; the others load their entity anyway. - if !entityExists(ctx, s.ds, artID) { - return nil, ErrUnavailable - } - // Checked here, not in openOriginal: a resize-cache hit never opens the source. - if isFileBacked(ia.Source) && !model.IsImageFile(ia.SourcePath) { - log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath) - return s.dangling(ctx, artID) - } - art, err := s.ds.Artwork().GetImage(ctx, ia.Hash) + r, err := a.cache.Get(ctx, artReader) if err != nil { - if errors.Is(err, model.ErrNotFound) { - return s.dangling(ctx, artID) + if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) { + log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err) } - return nil, err + return nil, time.Time{}, err } - img, err := s.serveSource(ctx, ia.Hash, ia.Hash, ia.UpdatedAt, size, square, - func() (io.ReadCloser, error) { return openOriginal(ia, art.Mime, s.store) }) - if err != nil { - if errors.Is(err, context.Canceled) { - return nil, err - } - log.Warn(ctx, "Artwork: Could not serve image", "artID", artID, "size", size, err) - return s.dangling(ctx, artID) - } - return img, nil + return r, artReader.LastUpdated(), nil } -// openOriginal enforces the mtime invariant: bytes are never served under a hash they no longer match. -func openOriginal(ia *model.ItemArtwork, mime string, store *ImageStore) (io.ReadCloser, error) { - if isFileBacked(ia.Source) { - f, err := os.Open(ia.SourcePath) - if err != nil { - return nil, err - } - info, err := f.Stat() - if err != nil { - f.Close() - return nil, err - } - if ia.RefMtime != 0 && info.ModTime().UnixNano() != ia.RefMtime { - f.Close() - log.Debug("Artwork: Backing file changed since resolution", "path", ia.SourcePath, - "hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano()) - return nil, errStaleSource - } - return f, nil - } - // Store-backed bytes still carry the source's mtime, to detect edits to embedded art. - if ia.SourcePath != "" && ia.RefMtime != 0 { - info, err := os.Stat(ia.SourcePath) - if err != nil { - return nil, err - } - if info.ModTime().UnixNano() != ia.RefMtime { - log.Debug("Artwork: Source file changed since resolution", "path", ia.SourcePath, - "hash", ia.Hash, "resolvedMtime", ia.RefMtime, "currentMtime", info.ModTime().UnixNano()) - return nil, errStaleSource - } - } - return store.Open(ia.Hash, mime) -} - -// provisional serves local bytes for an entity with no state row, enqueuing the worker but -// never writing a state row itself. -func (s *service) provisional(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { - item := model.ArtworkQueueItem{ItemKind: artID.Kind.Prefix(), ItemID: artID.ID, ImageType: model.ImageTypePrimary} - res, err := newLocalResolver(s.ds, s.ffmpeg).resolve(ctx, item) - if err != nil { - return nil, err - } - s.enqueue(ctx, artID, model.ArtworkPriorityBump) - log.Debug(ctx, "Artwork: Provisional read-through, no state row yet", "artID", artID, - "source", res.source, "hit", res.reader != nil) - return s.serveResolution(ctx, res, size, square) -} - -// serveResolution turns a local resolution's bytes into a servable Image (byte-hash only, no decode). -func (s *service) serveResolution(ctx context.Context, res resolution, size int, square bool) (*Image, error) { - if res.reader == nil { - return nil, ErrUnavailable - } - defer res.reader.Close() - data, err := readCapped(res.reader) - if err != nil { - return nil, ErrUnavailable - } - hash, err := hashImage(bytes.NewReader(data)) - if err != nil { - return nil, ErrUnavailable - } - // Keyed by the byte-hash, so the entry lines up with the worker's eventual store entry. - return s.serveSource(ctx, hash, hash, unixMtime(res.refMtime), size, square, - func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil }) -} - -func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { - // The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL - // must fall back to disc/album instead of serving stale persisted embedded art. - if !conf.Server.EnableMediaFileCoverArt { - mf, err := s.ds.MediaFile().Get(ctx, artID.ID) - if err != nil { - return nil, err - } - return s.Get(ctx, mf.DiscCoverArtID(), size, square) - } - ia, err := s.ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary) - switch { - case err == nil && ia.Hash != "": - return s.serveHash(ctx, artID, ia, size, square) - case err == nil: - // absent row: fall through - case errors.Is(err, model.ErrNotFound): - // no row: fall through - default: - return nil, err - } - noRow := errors.Is(err, model.ErrNotFound) - - mf, err := s.ds.MediaFile().Get(ctx, artID.ID) - if err != nil { - return nil, err - } - if noRow && conf.Server.EnableMediaFileCoverArt && mf.HasCoverArt { - return s.provisionalEmbedded(ctx, artID, *mf, size, square) - } - // Mirror MediaFile.CoverArtID: a track defers to its disc art, which falls back to the album. - return s.Get(ctx, mf.DiscCoverArtID(), size, square) -} - -// provisionalEmbedded serves a track's embedded art immediately, leaving the state row to the worker. -func (s *service) provisionalEmbedded(ctx context.Context, artID model.ArtworkID, mf model.MediaFile, size int, square bool) (*Image, error) { - lib, err := loadLibraryView(ctx, s.ds, mf.LibraryID) - if err != nil { - return nil, err - } - res, ok := resolveEmbedded(ctx, lib, s.ffmpeg, mf.Path) - s.enqueue(ctx, artID, model.ArtworkPriorityBump) - if !ok { - // Eligible but unextractable: fall back the way CoverArtID does, not to a placeholder. - return s.Get(ctx, mf.DiscCoverArtID(), size, square) - } - return s.serveResolution(ctx, res, size, square) -} - -// serveDisc reads disc art through with no state row and no enqueue, falling back to the album cover. -func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) { - dr, err := newDiscArtworkReader(ctx, s.ds, artID) - if err != nil { - return nil, err - } - // Single-disc albums run the chain too: a disc can carry art distinct from the album cover. - selectImage := func() (io.ReadCloser, error) { - res, err := dr.selectImage(ctx, s.ffmpeg, conf.Server.DiscArtPriority, &chainState{}) - return res.reader, err - } - albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID} - // Disc art has no state row, hence no content hash: keying on id, album mtime and - // DiscArtPriority lets a warm cache answer without running the chain or touching the disk. - key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority) - img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, selectImage) - if err != nil { - if errors.Is(err, context.Canceled) { - return nil, err - } - return s.Get(ctx, albumArtID, size, square) - } - return img, nil -} - -// dangling enqueues a re-resolution and reports unavailable, leaving the state row untouched. -func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image, error) { - log.Debug(ctx, "Artwork: State row points at bytes we cannot serve, re-resolving", "artID", artID) - s.enqueue(ctx, artID, model.ArtworkPriorityScan) - return nil, ErrUnavailable -} - -func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) { - err := s.ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{ - ItemKind: artID.Kind.Prefix(), - ItemID: artID.ID, - ImageType: model.ImageTypePrimary, - Priority: priority, - }) - if err != nil { - log.Warn(ctx, "Artwork: Could not enqueue re-resolution", "artID", artID, err) - } -} - -func placeholderImage(kind model.Kind) *Image { - path := consts.PlaceholderAlbumArt - if kind == model.KindArtistArtwork { - path = consts.PlaceholderArtistArt - } - r, _ := resources.FS().Open(path) - return &Image{ReadCloser: r, Placeholder: true} -} - -type coverArtIDGetter interface { +type coverArtGetter interface { CoverArtID() model.ArtworkID } -// parseArtworkID accepts an artwork token or a raw entity id, resolving the latter to its CoverArtID. -func (s *service) parseArtworkID(ctx context.Context, id string) (model.ArtworkID, error) { +func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID, error) { if id == "" { return model.ArtworkID{}, ErrUnavailable } - if artID, err := model.ParseArtworkID(id); err == nil { + artID, err := model.ParseArtworkID(id) + if err == nil { return artID, nil } - entity, err := model.GetEntityByID(ctx, s.ds, id) + + log.Trace(ctx, "ArtworkID invalid. Trying to figure out kind based on the ID", "id", id) + entity, err := model.GetEntityByID(ctx, a.ds, id) if err != nil { return model.ArtworkID{}, err } - if e, ok := entity.(coverArtIDGetter); ok { - return e.CoverArtID(), nil + if e, ok := entity.(coverArtGetter); ok { + artID = e.CoverArtID() } - return model.ArtworkID{}, model.ErrNotFound + switch e := entity.(type) { + case *model.Artist: + log.Trace(ctx, "ID is for an Artist", "id", id, "name", e.Name, "artist", e.Name) + case *model.Album: + log.Trace(ctx, "ID is for an Album", "id", id, "name", e.Name, "artist", e.AlbumArtist) + case *model.MediaFile: + log.Trace(ctx, "ID is for a MediaFile", "id", id, "title", e.Title, "album", e.Album) + case *model.Playlist: + log.Trace(ctx, "ID is for a Playlist", "id", id, "name", e.Name) + } + return artID, nil } -// TracingResolver is the CLI's read-only view of resolution: it walks the priority chain, records -// the walk and reports the winning source, without ever writing artwork state. -type TracingResolver struct { - inner *resolver - trace *ChainTrace -} - -// NewTracingResolver builds a TracingResolver that records its priority-chain walk. Without live -// it gets no agents at all, so neither a chain nor any fallback added later can reach a provider; -// with it, one item is at most one call per agent, so the rate limiter and breaker are bypassed. -func NewTracingResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, t *ChainTrace, live bool) *TracingResolver { - inner := newLocalResolver(ds, ffm) - if live { - inner = newResolver(ds, ag, ffm, passthroughGate) +func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int) (artworkReader, error) { + var artReader artworkReader + var err error + if size > 0 { + artReader, err = resizedFromOriginal(ctx, a, artID, size) + } else { + switch artID.Kind { + case model.KindArtistArtwork: + artReader, err = newArtistReader(ctx, a, artID, a.em) + case model.KindAlbumArtwork: + artReader, err = newAlbumArtworkReader(ctx, a, artID, a.em) + case model.KindMediaFileArtwork: + artReader, err = newMediafileArtworkReader(ctx, a, artID) + case model.KindPlaylistArtwork: + artReader, err = newPlaylistArtworkReader(ctx, a, artID) + default: + return nil, ErrUnavailable + } } - return &TracingResolver{inner: inner, trace: t} -} - -// Resolve walks kind's sources for id, recording the walk, and reports the winning source -// ("" when none produced an image). -func (r *TracingResolver) Resolve(ctx context.Context, kind model.Kind, id string) (string, error) { - switch kind { - case model.KindArtistArtwork: - return r.explain(ctx, r.inner.resolveArtist, id) - case model.KindAlbumArtwork: - return r.explain(ctx, r.inner.resolveAlbum, id) - case model.KindDiscArtwork: - return r.explain(ctx, r.inner.resolveDisc, id) - case model.KindMediaFileArtwork: - return r.explain(ctx, r.inner.resolveMediaFile, id) - } - return "", fmt.Errorf("artwork: %s artwork has no chain to explain", kind) -} - -// explain discards the bytes: nothing downstream persists this resolution, so nothing else -// would close the reader either. -func (r *TracingResolver) explain(ctx context.Context, resolve func(context.Context, string) (resolution, error), id string) (string, error) { - res, err := resolve(withTrace(ctx, r.trace), id) - if err != nil { - return "", err - } - if res.reader != nil { - _ = res.reader.Close() - } - return res.source, nil -} - -func unixMtime(mtime int64) time.Time { - if mtime <= 0 { - return time.Time{} - } - return time.Unix(0, mtime) // RefMtime is unix-nanoseconds + return artReader, err } diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go new file mode 100644 index 000000000..5d2371f5c --- /dev/null +++ b/core/artwork/artwork_internal_test.go @@ -0,0 +1,243 @@ +package artwork + +import ( + "context" + "errors" + "image" + "io" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Artwork", func() { + var aw *artwork + var ds model.DataStore + var ffmpeg *tests.MockFFmpeg + ctx := log.NewContext(context.TODO()) + var alOnlyEmbed, alEmbedNotFound, alOnlyExternal, alExternalNotFound, alMultipleCovers model.Album + var arMultipleCovers model.Artist + var mfWithEmbed, mfAnotherWithEmbed, mfWithoutEmbed, mfCorruptedCover model.MediaFile + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.ImageCacheSize = "0" // Disable cache + conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*" + + ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} + alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3"} + alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3"} + alOnlyExternal = model.Album{ID: "444", Name: "Only external", ImageFiles: "tests/fixtures/artist/an-album/front.png"} + alExternalNotFound = model.Album{ID: "555", Name: "External not found", ImageFiles: "tests/fixtures/NON_EXISTENT.png"} + arMultipleCovers = model.Artist{ID: "777", Name: "All options"} + alMultipleCovers = model.Album{ + ID: "666", + Name: "All options", + EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", + Paths: "tests/fixtures/artist/an-album", + ImageFiles: "tests/fixtures/artist/an-album/cover.jpg" + consts.Zwsp + + "tests/fixtures/artist/an-album/front.png" + consts.Zwsp + + "tests/fixtures/artist/an-album/artist.png", + AlbumArtistID: "777", + } + mfWithEmbed = model.MediaFile{ID: "22", Path: "tests/fixtures/test.mp3", HasCoverArt: true, AlbumID: "222"} + mfAnotherWithEmbed = model.MediaFile{ID: "23", Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true, AlbumID: "666"} + mfWithoutEmbed = model.MediaFile{ID: "44", Path: "tests/fixtures/test.ogg", AlbumID: "444"} + mfCorruptedCover = model.MediaFile{ID: "45", Path: "tests/fixtures/test.ogg", HasCoverArt: true, AlbumID: "444"} + + cache := GetImageCache() + ffmpeg = tests.NewMockFFmpeg("content from ffmpeg") + aw = NewArtwork(ds, cache, ffmpeg, nil).(*artwork) + }) + + Describe("albumArtworkReader", func() { + Context("ID not found", func() { + It("returns ErrNotFound if album is not in the DB", func() { + _, err := newAlbumArtworkReader(ctx, aw, model.MustParseArtworkID("al-NOT-FOUND"), nil) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + Context("Embed images", func() { + BeforeEach(func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alOnlyEmbed, + alEmbedNotFound, + }) + }) + It("returns embed cover", func() { + aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3")) + }) + It("returns ErrUnavailable if embed path is not available", func() { + ffmpeg.Error = errors.New("not available") + aw, err := newAlbumArtworkReader(ctx, aw, alEmbedNotFound.CoverArtID(), nil) + Expect(err).ToNot(HaveOccurred()) + _, _, err = aw.Reader(ctx) + Expect(err).To(MatchError(ErrUnavailable)) + }) + }) + Context("External images", func() { + BeforeEach(func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alOnlyExternal, + alExternalNotFound, + }) + }) + It("returns external cover", func() { + aw, err := newAlbumArtworkReader(ctx, aw, alOnlyExternal.CoverArtID(), nil) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png")) + }) + It("returns ErrUnavailable if external file is not available", func() { + aw, err := newAlbumArtworkReader(ctx, aw, alExternalNotFound.CoverArtID(), nil) + Expect(err).ToNot(HaveOccurred()) + _, _, err = aw.Reader(ctx) + Expect(err).To(MatchError(ErrUnavailable)) + }) + }) + Context("Multiple covers", func() { + BeforeEach(func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alMultipleCovers, + }) + }) + DescribeTable("CoverArtPriority", + func(priority string, expected string) { + conf.Server.CoverArtPriority = priority + aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(expected)) + }, + Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"), + Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"), + Entry(nil, " embedded , front.* , cover.*,folder.*", "tests/fixtures/artist/an-album/test.mp3"), + ) + }) + }) + Describe("artistArtworkReader", func() { + Context("Multiple covers", func() { + BeforeEach(func() { + ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{ + arMultipleCovers, + }) + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alMultipleCovers, + }) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + mfAnotherWithEmbed, + }) + }) + DescribeTable("ArtistArtPriority", + func(priority string, expected string) { + conf.Server.ArtistArtPriority = priority + aw, err := newArtistReader(ctx, aw, arMultipleCovers.CoverArtID(), nil) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(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"), + ) + }) + }) + Describe("mediafileArtworkReader", func() { + Context("ID not found", func() { + It("returns ErrNotFound if mediafile is not in the DB", func() { + _, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + Context("Embed images", func() { + BeforeEach(func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alOnlyEmbed, + alOnlyExternal, + }) + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + mfWithEmbed, + mfWithoutEmbed, + mfCorruptedCover, + }) + }) + It("returns embed cover", func() { + aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID()) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal("tests/fixtures/test.mp3")) + }) + It("returns embed cover if successfully extracted by ffmpeg", func() { + aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) + Expect(err).ToNot(HaveOccurred()) + r, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(io.ReadAll(r)).To(Equal([]byte("content from ffmpeg"))) + Expect(path).To(Equal("tests/fixtures/test.ogg")) + }) + It("returns album cover if cannot read embed artwork", func() { + ffmpeg.Error = errors.New("not available") + aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal("al-444_0")) + }) + It("returns album cover if media file has no cover art", func() { + aw, err := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-"+mfWithoutEmbed.ID)) + Expect(err).ToNot(HaveOccurred()) + _, path, err := aw.Reader(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal("al-444_0")) + }) + }) + }) + Describe("resizedArtworkReader", func() { + BeforeEach(func() { + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alMultipleCovers, + }) + }) + It("returns a PNG if original image is a PNG", func() { + conf.Server.CoverArtPriority = "front.png" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 15) + Expect(err).ToNot(HaveOccurred()) + + br, format, err := asImageReader(r) + Expect(format).To(Equal("image/png")) + Expect(err).ToNot(HaveOccurred()) + + img, _, err := image.Decode(br) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Bounds().Size().X).To(Equal(15)) + Expect(img.Bounds().Size().Y).To(Equal(15)) + }) + It("returns a JPEG if original image is not a PNG", func() { + conf.Server.CoverArtPriority = "cover.jpg" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200) + Expect(err).ToNot(HaveOccurred()) + + br, format, err := asImageReader(r) + Expect(format).To(Equal("image/jpeg")) + Expect(err).ToNot(HaveOccurred()) + + img, _, err := image.Decode(br) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Bounds().Size().X).To(Equal(200)) + Expect(img.Bounds().Size().Y).To(Equal(200)) + }) + }) +}) diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index 921a77b89..dfd66e5e5 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -1,129 +1,17 @@ package artwork import ( - "context" - "io/fs" - "net/netip" - "net/url" - "os" - "path/filepath" - "runtime" - "strings" "testing" - "time" - "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/httpclient" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "go.uber.org/goleak" ) func TestArtwork(t *testing.T) { - // Runs unconditionally: the two leaks below are pre-existing and out of this - // package's control, so they're ignored by exact top-function instead. - defer goleak.VerifyNone(t, - goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), - // notify's own init() starts a singleton tree the moment it's imported (via - // core/storage/local or plugins); recursive on darwin, nonrecursive on linux. - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), - ) - tests.Init(t, false) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) RunSpecs(t, "Artwork Suite") } - -// productionImageClient keeps the guarded client for the specs that assert it refuses loopback. -var productionImageClient = remoteImageClient - -// httptest servers listen on loopback, which the production client refuses. -var _ = BeforeSuite(func() { - remoteImageClient = httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128")) -}) - -// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests. -type osDirFS struct{ fs.FS } - -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 -} - -// fakeFolderRepo covers the three FolderRepository methods the resolvers reach for. The zero value -// answers as an unremarkable library does; the fields drive the album-root lookup and its failures. -type fakeFolderRepo struct { - model.FolderRepository - result []model.Folder - err error - parentResult *model.Folder - getErr error - getCallCount int - // hasOtherAudio is returned by HasAudioOutsideFolders (the album-root check). - // False means the parent qualifies as an album root. - hasOtherAudio bool - otherAudioErr error -} - -func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) { - return f.result, f.err -} - -func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) { - return f.hasOtherAudio, f.otherAudioErr -} - -func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) { - f.getCallCount++ - if f.getErr != nil { - return nil, f.getErr - } - if f.parentResult != nil { - return f.parentResult, nil - } - return nil, model.ErrNotFound -} diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go index 2e8ed070e..11fe951ad 100644 --- a/core/artwork/artwork_test.go +++ b/core/artwork/artwork_test.go @@ -1,557 +1,57 @@ -package artwork +package artwork_test import ( - "bytes" "context" - "image" "io" - "os" - "path/filepath" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/cache" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("Artwork", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - artRepo *tests.MockArtworkRepo - queueRepo *tests.MockArtworkQueueRepo - albumRepo *tests.MockAlbumRepo - mfRepo *tests.MockMediaFileRepo - folderRepo *fakeFolderRepo - libRepo *tests.MockLibraryRepo - ffm *tests.MockFFmpeg - store *ImageStore - imgCache cache.FileCache - svc Artwork - repoRoot string - coverBytes []byte - seedEntity func(kind, id string) - ) - - primaryKey := func(kind, id string) string { return kind + "|" + id + "|" + model.ImageTypePrimary } - - seedFoundStore := func(kind, id string, imgBytes []byte) string { - hash, err := hashImage(bytes.NewReader(imgBytes)) - Expect(err).ToNot(HaveOccurred()) - Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed()) - Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed()) - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed()) - seedEntity(kind, id) - return hash - } - - // Without its owning entity, a state row is not served at all. - seedEntity = func(kind, id string) { - GinkgoHelper() - switch kind { - case "al": - Expect(albumRepo.Put(ctx, &model.Album{ID: id, Name: "Album"})).To(Succeed()) - case "mf": - Expect(mfRepo.Put(ctx, &model.MediaFile{ID: id})).To(Succeed()) - } - } - - readAll := func(img *Image) []byte { - GinkgoHelper() - defer img.Close() - data, err := io.ReadAll(img) - Expect(err).ToNot(HaveOccurred()) - return data - } + var aw artwork.Artwork + var ds model.DataStore + var ffmpeg *tests.MockFFmpeg BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ctx = context.Background() - var err error - repoRoot, err = os.Getwd() - Expect(err).ToNot(HaveOccurred()) - coverBytes, err = os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) - Expect(err).ToNot(HaveOccurred()) + conf.Server.ImageCacheSize = "0" // Disable cache + cache := artwork.GetImageCache() + ffmpeg = tests.NewMockFFmpeg("content from ffmpeg") + aw = artwork.NewArtwork(ds, cache, ffmpeg, nil) + }) - conf.Server.EnableWebPEncoding = false - conf.Server.CoverArtQuality = 75 - conf.Server.CoverArtPriority = "cover.*" - conf.Server.DiscArtPriority = "cover.*" - conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) + Context("GetOrPlaceholder", func() { + Context("Empty ID", func() { + It("returns placeholder if album is not in the DB", func() { + r, _, err := aw.GetOrPlaceholder(context.Background(), "", 0) + Expect(err).ToNot(HaveOccurred()) - artRepo = tests.CreateMockArtworkRepo() - queueRepo = tests.CreateMockArtworkQueueRepo() - albumRepo = tests.CreateMockAlbumRepo() - mfRepo = tests.CreateMockMediaFileRepo() - folderRepo = &fakeFolderRepo{} - libRepo = &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - ds = &tests.MockDataStore{ - MockedArtwork: artRepo, - MockedArtworkQueue: queueRepo, - MockedAlbum: albumRepo, - MockedMediaFile: mfRepo, - MockedFolder: folderRepo, - MockedLibrary: libRepo, - } - ffm = tests.NewMockFFmpeg("") - store = NewImageStore(GinkgoT().TempDir()) - imgCache = cache.NewFileCache("ServingTest", "100MB", "images", 0, - func(ctx context.Context, arg cache.Item) (io.Reader, error) { - return arg.(artworkReader).Reader(ctx) + ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) + Expect(err).ToNot(HaveOccurred()) + phBytes, err := io.ReadAll(ph) + Expect(err).ToNot(HaveOccurred()) + + result, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + + Expect(result).To(Equal(phBytes)) }) - Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue()) - svc = NewArtwork(ds, imgCache, store, ffm) - }) - - Describe("found state", func() { - It("serves a store-backed found image sized (cache miss resizes, second call is a cache hit)", func() { - seedFoundStore("al", "al1", coverBytes) - - img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false) - Expect(err).ToNot(HaveOccurred()) - // A resized response versions its ETag with the encode settings, not the pixel hash. - Expect(img.ETag).To(Equal(representationTag(img.Hash, 100, false))) - Expect(img.ETag).ToNot(Equal(img.Hash)) - resized := readAll(img) - cfg, _, err := image.DecodeConfig(bytes.NewReader(resized)) - Expect(err).ToNot(HaveOccurred()) - Expect(cfg.Width).To(Equal(100)) - - // Deleting the store file proves the warm entry serves without touching the original. - hash, _ := hashImage(bytes.NewReader(coverBytes)) - Expect(os.Remove(store.path(hash, "image/jpeg"))).To(Succeed()) - Eventually(func(g Gomega) { - img2, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 100, false) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(readAll(img2)).To(Equal(resized)) - }).Should(Succeed()) - }) - - It("treats a negative size as a full-size request, not a giant resize", func() { - seedFoundStore("al", "alneg", coverBytes) - - img, err := svc.Get(ctx, model.MustParseArtworkID("al-alneg"), -2000000000, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes), "original bytes, no resize (would OOM)") - }) - - It("streams a file-backed found image at full size", func() { - dir := GinkgoT().TempDir() - imgPath := filepath.Join(dir, "cover.jpg") - Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) - mtime := fileMtime(imgPath) - Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed()) - seedEntity("al", "al2") - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa", - Source: "folder", SourcePath: imgPath, RefMtime: mtime, - })).To(Succeed()) - - img, err := svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - - It("treats a file-backed row pointing at a non-image file as dangling", func() { - dir := GinkgoT().TempDir() - secretPath := filepath.Join(dir, "config.ini") - Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed()) - Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed()) - seedEntity("al", "alni") - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd", - Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath), - })).To(Succeed()) - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-alni"), 0, false) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(queueRepo.Data[primaryKey("al", "alni")].Priority).To(Equal(model.ArtworkPriorityScan)) - }) - - It("refuses a non-image file-backed row even when a resized copy is already cached", func() { - secret := []byte("password=secret") - dir := GinkgoT().TempDir() - secretPath := filepath.Join(dir, "config.ini") - Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed()) - Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed()) - seedEntity("al", "alnic") - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee", - Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath), - })).To(Succeed()) - - // Older versions cached the raw bytes when the resize failed. - seed := func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(secret)), nil } - stream, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, open: seed, ffmpeg: ffm}) - Expect(err).ToNot(HaveOccurred()) - Expect(io.ReadAll(stream)).To(Equal(secret)) - Expect(stream.Close()).To(Succeed()) - Eventually(func(g Gomega) { - s, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, ffmpeg: ffm, - open: func() (io.ReadCloser, error) { return nil, os.ErrNotExist }}) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(s.Cached).To(BeTrue()) - _ = s.Close() - }).Should(Succeed()) - - _, err = svc.Get(ctx, model.MustParseArtworkID("al-alnic"), 100, false) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(queueRepo.Data[primaryKey("al", "alnic")].Priority).To(Equal(model.ArtworkPriorityScan)) - }) - - It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() { - dir := GinkgoT().TempDir() - imgPath := filepath.Join(dir, "cover.jpg") - Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) - Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed()) - seedEntity("al", "al3") - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb", - Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999, - })).To(Succeed()) - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan)) - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb")) - }) - - It("enforces the mtime rule on the sized (loader) path too", func() { - dir := GinkgoT().TempDir() - imgPath := filepath.Join(dir, "cover.jpg") - Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed()) - Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed()) - seedEntity("al", "al3b") - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc", - Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999, - })).To(Succeed()) - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-al3b"), 100, false) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(queueRepo.Data[primaryKey("al", "al3b")].Priority).To(Equal(model.ArtworkPriorityScan)) - }) - - // State rows outlive a deleted entity until the next prune. - It("refuses to serve a found row whose entity is gone", func() { - hash := seedFoundStore("al", "alzz", coverBytes) - Expect(hash).ToNot(BeEmpty()) - albumRepo.SetData(model.Albums{}) // the album is deleted; its artwork row survives - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-alzz"), 0, false) - Expect(err).To(MatchError(ErrUnavailable)) - }) - - It("never re-enqueues an absent state on view, however old", func() { - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour), - })).To(Succeed()) - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-al4"), 0, false) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(queueRepo.Data).To(BeEmpty()) }) }) - - Describe("provisional read-through", func() { - It("serves local folder art, enqueues a Bump, and writes no state row", func() { - folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} - albumRepo.SetData(model.Albums{{ID: "al5", Name: "Album", FolderIDs: []string{"f1"}}}) - - img, err := svc.Get(ctx, model.MustParseArtworkID("al-al5"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - - Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump)) - _, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("returns ErrUnavailable and enqueues a Bump when nothing local resolves", func() { - folderRepo.result = nil - albumRepo.SetData(model.Albums{{ID: "al6", Name: "Album"}}) - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false) - Expect(err).To(MatchError(ErrUnavailable)) - Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump)) - _, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("media file", func() { - It("serves a track's own found art", func() { - seedFoundStore("mf", "mf1", coverBytes) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - - It("ignores a resolved mf row and delegates to the album when per-track art is disabled", func() { - conf.Server.EnableMediaFileCoverArt = false - seedFoundStore("mf", "mf7", []byte("stale embedded track art")) - seedFoundStore("al", "albz", coverBytes) - mfRepo.SetData(model.MediaFiles{{ID: "mf7", AlbumID: "albz"}}) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf7"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes), "album art, not the persisted embedded art") - }) - - It("delegates to the album when the track's state is absent", func() { - seedFoundStore("al", "albm", coverBytes) - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed()) - mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}}) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - _, mfEnq := queueRepo.Data[primaryKey("mf", "mf2")] - Expect(mfEnq).To(BeFalse()) - }) - - It("delegates to the album (no enqueue) when the track is not embedded-eligible", func() { - conf.Server.EnableMediaFileCoverArt = true - seedFoundStore("al", "albn", coverBytes) - mfRepo.SetData(model.MediaFiles{{ID: "mf3", AlbumID: "albn", HasCoverArt: false}}) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf3"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - _, mfEnq := queueRepo.Data[primaryKey("mf", "mf3")] - Expect(mfEnq).To(BeFalse()) - }) - - It("extracts embedded art provisionally and enqueues the track when eligible", func() { - conf.Server.EnableMediaFileCoverArt = true - mfRepo.SetData(model.MediaFiles{{ - ID: "mf4", AlbumID: "albo", HasCoverArt: true, - Path: "tests/fixtures/artist/an-album/test.mp3", LibraryID: 0, - }}) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf4"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(len(readAll(img))).To(BeNumerically(">", 0)) - Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump)) - _, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("delegates a multi-disc track to its disc art, not straight to the album", func() { - folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} - albumRepo.SetData(model.Albums{{ID: "aldd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "One", 2: "Two"}}}) - seedFoundStore("al", "aldd", []byte("album-art-distinct")) // album's own found art differs - mfRepo.SetData(model.MediaFiles{{ID: "mf5", AlbumID: "aldd", DiscNumber: 1, HasCoverArt: false}}) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf5"), 0, false) - Expect(err).ToNot(HaveOccurred()) - // The disc-folder image, not the album's own art: proof it routed through serveDisc. - Expect(readAll(img)).To(Equal(coverBytes)) - }) - - It("falls back to the album when an eligible track's embedded art will not extract", func() { - conf.Server.EnableMediaFileCoverArt = true - // HasCoverArt is set, but the file is not audio, so nothing extracts. - mfRepo.SetData(model.MediaFiles{{ - ID: "mfbad", AlbumID: "albad", LibraryID: 0, HasCoverArt: true, - Path: "tests/fixtures/artist/an-album/front.png", - }}) - albumRepo.SetData(model.Albums{{ID: "albad", Name: "Album"}}) - seedFoundStore("al", "albad", coverBytes) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mfbad"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes), "a placeholder here would be worse than the album cover") - }) - - It("routes a single-disc track through disc resolution too", func() { - // DiscArtPriority applies to single-disc albums too, over the album's own found art. - folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} - albumRepo.SetData(model.Albums{{ID: "alsd", Name: "Album", FolderIDs: []string{"f1"}, Discs: model.Discs{1: ""}}}) - seedFoundStore("al", "alsd", []byte("album-art-distinct")) - mfRepo.SetData(model.MediaFiles{{ID: "mf6", AlbumID: "alsd", DiscNumber: 1, HasCoverArt: false}}) - - img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf6"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - }) - - Describe("disc", func() { - It("serves a local disc-folder image", func() { - folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} - albumRepo.SetData(model.Albums{{ID: "aldc", Name: "Album", FolderIDs: []string{"f1"}}}) - - img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc", 1), nil), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - - // The resize cache keys on id + album mtime, not on the bytes, so dropping the source - // between the two requests is what shows a warm hit never touches the filesystem. - It("serves a sized disc image from cache without re-reading the source", func() { - folderRepo.result = []model.Folder{{Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}}} - albumRepo.SetData(model.Albums{{ID: "aldc3", Name: "Album", FolderIDs: []string{"f1"}}}) - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc3", 1), nil) - - first, err := svc.Get(ctx, discID, 64, false) - Expect(err).ToNot(HaveOccurred()) - warmed := readAll(first) - Expect(warmed).ToNot(BeEmpty()) - - folderRepo.result = nil - - second, err := svc.Get(ctx, discID, 64, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(second)).To(Equal(warmed), "a warm sized request must not touch the source") - }) - - // A disc image can change without the album row changing, so the key folds in ImagesUpdatedAt. - It("invalidates the cached image when the folder's images change", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}, - ImagesUpdatedAt: time.Now().Add(-time.Hour), - }} - albumRepo.SetData(model.Albums{{ID: "aldc4", Name: "Album", FolderIDs: []string{"f1"}}}) - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc4", 1), nil) - - first, err := svc.Get(ctx, discID, 64, false) - Expect(err).ToNot(HaveOccurred()) - firstKey := first.ETag - readAll(first) - - folderRepo.result[0].ImagesUpdatedAt = time.Now() - second, err := svc.Get(ctx, discID, 64, false) - Expect(err).ToNot(HaveOccurred()) - readAll(second) - Expect(second.ETag).ToNot(Equal(firstKey), "a replaced image must not keep the old cache entry") - }) - - // Disc art has no content hash, so without an explicit validator every ETag would be empty. - It("gives a full-size disc image a validator that tracks the source", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"cover.jpg"}, - ImagesUpdatedAt: time.Now().Add(-time.Hour), - }} - albumRepo.SetData(model.Albums{{ID: "aldc5", Name: "Album", FolderIDs: []string{"f1"}}}) - discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc5", 1), nil) - - first, err := svc.Get(ctx, discID, 0, false) - Expect(err).ToNot(HaveOccurred()) - readAll(first) - Expect(first.ETag).ToNot(BeEmpty()) - - folderRepo.result[0].ImagesUpdatedAt = time.Now() - second, err := svc.Get(ctx, discID, 0, false) - Expect(err).ToNot(HaveOccurred()) - readAll(second) - Expect(second.ETag).ToNot(Equal(first.ETag), "a replaced image must not revalidate as unchanged") - }) - - It("falls back to album art when no disc image matches", func() { - folderRepo.result = nil - albumRepo.SetData(model.Albums{{ID: "aldc2", Name: "Album"}}) - seedFoundStore("al", "aldc2", coverBytes) - - img, err := svc.Get(ctx, model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID("aldc2", 1), nil), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - }) - - Describe("GetOrPlaceholder", func() { - It("accepts a raw entity id and serves its cover art", func() { - albumRepo.SetData(model.Albums{{ID: "rawal", Name: "Album"}}) - seedFoundStore("al", "rawal", coverBytes) - - img, err := svc.GetOrPlaceholder(ctx, "rawal", 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Placeholder).To(BeFalse()) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - - It("falls back to the album placeholder ignoring size and square", func() { - img, err := svc.GetOrPlaceholder(ctx, "", 300, true) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Placeholder).To(BeTrue()) - Expect(img.Hash).To(BeEmpty()) - Expect(img.LastUpdated).To(BeZero()) - - ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) - Expect(err).ToNot(HaveOccurred()) - phBytes, _ := io.ReadAll(ph) - Expect(readAll(img)).To(Equal(phBytes)) - }) - - It("falls back to the artist placeholder for an absent artist", func() { - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed()) - - img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Placeholder).To(BeTrue()) - - ph, err := resources.FS().Open(consts.PlaceholderArtistArt) - Expect(err).ToNot(HaveOccurred()) - phBytes, _ := io.ReadAll(ph) - Expect(readAll(img)).To(Equal(phBytes)) - }) - - // "No art" and "no such entity" are different answers: clients 404 only on the latter. - It("reports not-found rather than a placeholder for an id with no entity", func() { - _, err := svc.GetOrPlaceholder(ctx, "al-nosuchalbum", 0, false) - Expect(err).To(MatchError(model.ErrNotFound)) - - _, err = svc.GetOrPlaceholder(ctx, "nosuchrawid", 0, false) - Expect(err).To(MatchError(model.ErrNotFound)) + Context("Get", func() { + Context("Empty ID", func() { + It("returns an ErrUnavailable error", func() { + _, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0) + Expect(err).To(MatchError(artwork.ErrUnavailable)) + }) }) }) }) - -func fileMtime(path string) int64 { - GinkgoHelper() - info, err := os.Stat(path) - Expect(err).ToNot(HaveOccurred()) - return info.ModTime().UnixNano() -} - -var _ = Describe("EntityExists", func() { - var ctx context.Context - var ds *tests.MockDataStore - - BeforeEach(func() { - ctx = context.Background() - albumRepo := tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{{ID: "al1"}}) - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{{ID: "ar1"}}) - radioRepo := tests.CreateMockedRadioRepo() - Expect(radioRepo.Put(ctx, &model.Radio{ID: "ra1", Name: "R"})).To(Succeed()) - ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo} - }) - - DescribeTable("reports whether the owning entity is still there", - func(id string, expected bool) { - Expect(entityExists(ctx, ds, model.MustParseArtworkID(id))).To(Equal(expected)) - }, - Entry("existing album", "al-al1", true), - Entry("deleted album", "al-gone", false), - Entry("existing artist", "ar-ar1", true), - Entry("deleted artist", "ar-gone", false), - Entry("existing radio", "ra-ra1", true), - Entry("deleted radio", "ra-gone", false), - // Disc art has no entity of its own; it stands or falls with its album. - Entry("disc of an existing album", "dc-al1:1", true), - Entry("disc of a deleted album", "dc-gone:1", false), - Entry("malformed disc id", "dc-nodiscnum", false), - ) -}) diff --git a/core/artwork/benchmark_decode_test.go b/core/artwork/benchmark_decode_test.go deleted file mode 100644 index cfbfe5605..000000000 --- a/core/artwork/benchmark_decode_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package artwork - -import ( - "bytes" - "fmt" - "image" - _ "image/jpeg" - _ "image/png" - "testing" -) - -func BenchmarkImageDecode(b *testing.B) { - sizes := []int{300, 1000, 3000} - formats := []struct { - name string - gen func(tb testing.TB, w, h int) []byte - }{ - {"jpeg", func(tb testing.TB, w, h int) []byte { return generateJPEG(tb, w, h, 75) }}, - {"png", func(tb testing.TB, w, h int) []byte { return generatePNG(tb, w, h) }}, - } - - for _, format := range formats { - for _, size := range sizes { - data := format.gen(b, size, size) - b.Run(fmt.Sprintf("%s/%dx%d", format.name, size, size), func(b *testing.B) { - b.SetBytes(int64(len(data))) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _, err := image.Decode(bytes.NewReader(data)) - if err != nil { - b.Fatal(err) - } - } - }) - } - } -} diff --git a/core/artwork/benchmark_encode_test.go b/core/artwork/benchmark_encode_test.go deleted file mode 100644 index d8ab858f5..000000000 --- a/core/artwork/benchmark_encode_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package artwork - -import ( - "bytes" - "fmt" - "image/jpeg" - "image/png" - "testing" -) - -func BenchmarkImageEncode(b *testing.B) { - img := generateGradientImage(300, 300) - - jpegQualities := []int{60, 75, 90} - for _, q := range jpegQualities { - b.Run(fmt.Sprintf("jpeg/q%d/300x300", q), func(b *testing.B) { - var buf bytes.Buffer - b.ResetTimer() - for i := 0; i < b.N; i++ { - buf.Reset() - if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: q}); err != nil { - b.Fatal(err) - } - } - b.ReportMetric(float64(buf.Len()), "bytes") - }) - } - - b.Run("png/300x300", func(b *testing.B) { - var buf bytes.Buffer - b.ResetTimer() - for i := 0; i < b.N; i++ { - buf.Reset() - if err := png.Encode(&buf, img); err != nil { - b.Fatal(err) - } - } - b.ReportMetric(float64(buf.Len()), "bytes") - }) -} diff --git a/core/artwork/benchmark_helpers_test.go b/core/artwork/benchmark_helpers_test.go deleted file mode 100644 index f3201d412..000000000 --- a/core/artwork/benchmark_helpers_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package artwork - -import ( - "bytes" - "image" - "image/color" - "image/draw" - "image/jpeg" - "image/png" - "testing" -) - -// generateJPEG creates a JPEG image of the given dimensions with a gradient pattern. -// The gradient ensures the image has realistic entropy (not trivially compressible). -func generateJPEG(t testing.TB, width, height, quality int) []byte { - t.Helper() - img := generateGradientImage(width, height) - var buf bytes.Buffer - if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil { - t.Fatal(err) - } - return buf.Bytes() -} - -// generatePNG creates a PNG image of the given dimensions with a gradient pattern. -func generatePNG(t testing.TB, width, height int) []byte { - t.Helper() - img := generateGradientImage(width, height) - var buf bytes.Buffer - if err := png.Encode(&buf, img); err != nil { - t.Fatal(err) - } - return buf.Bytes() -} - -// generateGradientImage creates an RGBA image with a diagonal gradient pattern. -func generateGradientImage(width, height int) *image.RGBA { - img := image.NewRGBA(image.Rect(0, 0, width, height)) - for y := range height { - for x := range width { - r := uint8((x * 255) / width) - g := uint8((y * 255) / height) - b := uint8(((x + y) * 255) / (width + height)) - img.Set(x, y, color.RGBA{R: r, G: g, B: b, A: 255}) - } - } - return img -} - -// gradientNRGBA mirrors generateGradientImage in the type makeThumbnail hands the encoders. -func gradientNRGBA(size int) *image.NRGBA { - src := generateGradientImage(size, size) - dst := image.NewNRGBA(src.Bounds()) - draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src) - return dst -} diff --git a/core/artwork/benchmark_pipeline_test.go b/core/artwork/benchmark_pipeline_test.go deleted file mode 100644 index 23d5954df..000000000 --- a/core/artwork/benchmark_pipeline_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package artwork - -import ( - "fmt" - "testing" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" -) - -func BenchmarkResizeFullPipeline(b *testing.B) { - cleanup := configtest.SetupConfig() - b.Cleanup(cleanup) - conf.Server.CoverArtQuality = 75 - - sourceSizes := []int{1000, 3000} - targetSize := 300 - - for _, srcSize := range sourceSizes { - jpegData := generateJPEG(b, srcSize, srcSize, 90) - - b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d", srcSize, srcSize, targetSize), func(b *testing.B) { - b.SetBytes(int64(len(jpegData))) - b.ResetTimer() - for i := 0; i < b.N; i++ { - result, _, err := resizeStaticImage(jpegData, targetSize, false) - if err != nil { - b.Fatal(err) - } - if result == nil { - b.Fatal("expected non-nil resized image") - } - } - }) - - b.Run(fmt.Sprintf("jpeg/%dx%d_to_%d_square", srcSize, srcSize, targetSize), func(b *testing.B) { - b.SetBytes(int64(len(jpegData))) - b.ResetTimer() - for i := 0; i < b.N; i++ { - result, _, err := resizeStaticImage(jpegData, targetSize, true) - if err != nil { - b.Fatal(err) - } - if result == nil { - b.Fatal("expected non-nil resized image") - } - } - }) - } -} diff --git a/core/artwork/benchmark_tag_test.go b/core/artwork/benchmark_tag_test.go deleted file mode 100644 index fd649beab..000000000 --- a/core/artwork/benchmark_tag_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package artwork - -import ( - "path/filepath" - "runtime" - "testing" - - "go.senan.xyz/taglib" -) - -func BenchmarkTagExtraction(b *testing.B) { - // Ensure working directory is the project root (tests.Init not called with -run='^$') - _, file, _, ok := runtime.Caller(0) - if !ok { - b.Fatal("runtime.Caller failed") - } - appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", "..")) - - // Use existing test fixture with embedded artwork - testFile := filepath.Join(appPath, "tests/fixtures/artist/an-album/test.mp3") - - b.ResetTimer() - for i := 0; i < b.N; i++ { - f, err := taglib.OpenReadOnly(testFile, taglib.WithReadStyle(taglib.ReadStyleFast)) - if err != nil { - b.Fatal(err) - } - images := f.Properties().Images - if len(images) == 0 { - b.Fatal("no images found in test file") - } - data, err := f.Image(0) - if err != nil || len(data) == 0 { - b.Fatal("failed to extract image data") - } - f.Close() - } -} diff --git a/core/artwork/blurhash/blurhash.go b/core/artwork/blurhash/blurhash.go deleted file mode 100644 index 29e1ccfda..000000000 --- a/core/artwork/blurhash/blurhash.go +++ /dev/null @@ -1,208 +0,0 @@ -// Package blurhash implements the blurhash encoding (https://github.com/woltapp/blurhash), -// parameterized to match Jellyfin so clients see equivalent hashes. -package blurhash - -import ( - "errors" - "image" - "image/draw" - "math" - "strings" - "sync" - - xdraw "golang.org/x/image/draw" -) - -const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" - -// maxInputSize: larger inputs are slower with no visible difference in the result. -const maxInputSize = 128 - -// components picks x/y component counts targeting ~16 near-square tiles. -func components(width, height int) (int, int) { - xf := math.Sqrt(16.0 * float64(width) / float64(height)) - yf := xf * float64(height) / float64(width) - return min(int(xf)+1, 9), min(int(yf)+1, 9) -} - -// Encode returns the blurhash of img, deriving the component counts from its aspect ratio. -func Encode(img image.Image) (string, error) { - if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 { - return "", errors.New("blurhash: empty image") - } - // Pre-downscale: its rounding can flip a component count, and the hash is a client cache key. - xComp, yComp := components(img.Bounds().Dx(), img.Bounds().Dy()) - src := pixelsOf(downscale(img)) - w, h := src.w, src.h - - cosX := make([][]float64, xComp) - for i := range cosX { - cosX[i] = make([]float64, w) - for x := range cosX[i] { - cosX[i][x] = math.Cos(math.Pi * float64(i) * float64(x) / float64(w)) - } - } - cosY := make([][]float64, yComp) - for j := range cosY { - cosY[j] = make([]float64, h) - for y := range cosY[j] { - cosY[j][y] = math.Cos(math.Pi * float64(j) * float64(y) / float64(h)) - } - } - - lin := srgbToLinearTable() - factors := make([][3]float64, xComp*yComp) - linR := make([]float64, w) - linG := make([]float64, w) - linB := make([]float64, w) - rowR := make([]float64, xComp) - rowG := make([]float64, xComp) - rowB := make([]float64, xComp) - for y := range h { - row := src.pix[y*src.stride:] - for x := range w { - p := x * 4 - r, g, b := row[p], row[p+1], row[p+2] - if src.straight { - r, g, b = premultiply(r, g, b, row[p+3]) - } - linR[x], linG[x], linB[x] = lin[r], lin[g], lin[b] - } - // The basis is separable, so a row costs xComp dot products plus one fold over yComp, - // rather than xComp*yComp multiply-accumulates per pixel. - for i := range xComp { - var sr, sg, sb float64 - for x, c := range cosX[i] { - sr += c * linR[x] - sg += c * linG[x] - sb += c * linB[x] - } - rowR[i], rowG[i], rowB[i] = sr, sg, sb - } - for j := range yComp { - cy := cosY[j][y] - for i := range xComp { - f := &factors[j*xComp+i] - f[0] += cy * rowR[i] - f[1] += cy * rowG[i] - f[2] += cy * rowB[i] - } - } - } - for idx := range factors { - norm := 2.0 - if idx == 0 { - norm = 1.0 - } - scale := norm / float64(w*h) - factors[idx][0] *= scale - factors[idx][1] *= scale - factors[idx][2] *= scale - } - - var sb strings.Builder - sb.WriteString(encode83((xComp-1)+(yComp-1)*9, 1)) - - // Derived counts are at least 1x9, so there is always at least one AC factor. - ac := factors[1:] - actualMax := 0.0 - for _, f := range ac { - actualMax = max(actualMax, math.Abs(f[0]), math.Abs(f[1]), math.Abs(f[2])) - } - quantMax := int(max(0, min(82, math.Floor(actualMax*166-0.5)))) - maxVal := float64(quantMax+1) / 166 - sb.WriteString(encode83(quantMax, 1)) - - dc := factors[0] - sb.WriteString(encode83(linearToSRGB(dc[0])<<16|linearToSRGB(dc[1])<<8|linearToSRGB(dc[2]), 4)) - for _, f := range ac { - sb.WriteString(encode83(quantAC(f[0], maxVal)*19*19+quantAC(f[1], maxVal)*19+quantAC(f[2], maxVal), 2)) - } - return sb.String(), nil -} - -// pixels is direct Pix access for the pixel loop, avoiding a per-pixel allocation via image.At. -type pixels struct { - pix []uint8 - stride int - w, h int - // straight marks non-premultiplied alpha, which the loop premultiplies to keep the hash - // identical to the one an equivalent *image.RGBA produces. - straight bool -} - -// pixelsOf accepts the two types the artwork pipeline produces without copying, and converts -// anything else. -func pixelsOf(img image.Image) pixels { - b := img.Bounds() - switch src := img.(type) { - case *image.RGBA: - return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy()} - case *image.NRGBA: - return pixels{pix: src.Pix, stride: src.Stride, w: b.Dx(), h: b.Dy(), straight: true} - } - dst := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) - draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) - return pixels{pix: dst.Pix, stride: dst.Stride, w: b.Dx(), h: b.Dy()} -} - -func premultiply(r, g, b, a uint8) (uint8, uint8, uint8) { - if a == 255 { - return r, g, b - } - return uint8(uint32(r) * uint32(a) / 255), uint8(uint32(g) * uint32(a) / 255), uint8(uint32(b) * uint32(a) / 255) -} - -var srgbToLinearTable = sync.OnceValue(func() *[256]float64 { - var t [256]float64 - for i := range t { - t[i] = srgbToLinear(i) - } - return &t -}) - -func downscale(img image.Image) image.Image { - b := img.Bounds() - w, h := b.Dx(), b.Dy() - if w <= maxInputSize && h <= maxInputSize { - return img - } - scale := float64(maxInputSize) / float64(max(w, h)) - dst := image.NewRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) - xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil) - return dst -} - -func quantAC(v, maxVal float64) int { - return int(max(0, min(18, math.Floor(signPow(v/maxVal, 0.5)*9+9.5)))) -} - -func signPow(v, exp float64) float64 { - return math.Copysign(math.Pow(math.Abs(v), exp), v) -} - -func srgbToLinear(v int) float64 { - f := float64(v) / 255 - if f <= 0.04045 { - return f / 12.92 - } - return math.Pow((f+0.055)/1.055, 2.4) -} - -func linearToSRGB(v float64) int { - v = min(max(0, v), 1) - if v <= 0.0031308 { - return int(v*12.92*255 + 0.5) - } - return int((1.055*math.Pow(v, 1/2.4)-0.055)*255 + 0.5) -} - -// encode83 encodes value as a fixed-width, big-endian base83 string of the given length. -func encode83(value, length int) string { - b := make([]byte, length) - for i := length - 1; i >= 0; i-- { - b[i] = alphabet[value%83] - value /= 83 - } - return string(b) -} diff --git a/core/artwork/blurhash/blurhash_suite_test.go b/core/artwork/blurhash/blurhash_suite_test.go deleted file mode 100644 index b0e96d7a7..000000000 --- a/core/artwork/blurhash/blurhash_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package blurhash_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestBlurHash(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "BlurHash Suite") -} diff --git a/core/artwork/blurhash/blurhash_test.go b/core/artwork/blurhash/blurhash_test.go deleted file mode 100644 index 91187165e..000000000 --- a/core/artwork/blurhash/blurhash_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package blurhash_test - -import ( - "image" - "image/color" - "strings" - - "github.com/navidrome/navidrome/core/artwork/blurhash" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" - -func decode83(s string) int { - v := 0 - for _, c := range s { - v = v*83 + strings.IndexRune(alphabet, c) - } - return v -} - -func solidImage(w, h int, c color.NRGBA) image.Image { - img := image.NewNRGBA(image.Rect(0, 0, w, h)) - for y := range h { - for x := range w { - img.SetNRGBA(x, y, c) - } - } - return img -} - -func gradientImage(w, h int) image.Image { - img := image.NewNRGBA(image.Rect(0, 0, w, h)) - for y := range h { - for x := range w { - img.SetNRGBA(x, y, color.NRGBA{R: uint8(255 * x / w), G: uint8(255 * y / h), B: 128, A: 255}) - } - } - return img -} - -var _ = Describe("Encode input types", func() { - // The pipeline hands Encode an *image.NRGBA; reading it must stay equivalent to the - // premultiplied *image.RGBA it used to receive, or every hash silently shifts. - buildPair := func(alpha uint8) (*image.NRGBA, *image.RGBA) { - const size = 40 - nrgba := image.NewNRGBA(image.Rect(0, 0, size, size)) - rgba := image.NewRGBA(image.Rect(0, 0, size, size)) - for y := range size { - for x := range size { - c := color.NRGBA{ - R: uint8(255 * x / size), G: uint8(255 * y / size), - B: uint8((x + y) * 255 / (2 * size)), A: alpha, - } - nrgba.SetNRGBA(x, y, c) - rgba.Set(x, y, c) // image.RGBA.Set premultiplies - } - } - return nrgba, rgba - } - - DescribeTable("gives an NRGBA the same hash as the premultiplied RGBA it replaces", - func(alpha uint8) { - nrgba, rgba := buildPair(alpha) - fromNRGBA, err := blurhash.Encode(nrgba) - Expect(err).ToNot(HaveOccurred()) - fromRGBA, err := blurhash.Encode(rgba) - Expect(err).ToNot(HaveOccurred()) - Expect(fromNRGBA).To(Equal(fromRGBA)) - }, - Entry("opaque", uint8(255)), - Entry("partly transparent", uint8(128)), - Entry("fully transparent, which premultiplication crushes to black", uint8(0)), - ) -}) - -var _ = Describe("Encode", func() { - // The size flag encodes (xComp-1) + (yComp-1)*9. - DescribeTable("derives component counts from aspect ratio (Jellyfin formula)", - func(w, h, expectedX, expectedY int) { - hash, err := blurhash.Encode(gradientImage(w, h)) - Expect(err).ToNot(HaveOccurred()) - Expect(decode83(hash[:1])).To(Equal((expectedX - 1) + (expectedY-1)*9)) - }, - Entry("square album art", 60, 60, 5, 5), - Entry("smallest square", 1, 1, 5, 5), - Entry("landscape 16:9", 192, 108, 6, 4), - Entry("portrait 9:16", 108, 192, 4, 6), - Entry("extreme landscape capped at 9", 1000, 10, 9, 1), - Entry("extreme portrait capped at 9", 10, 1000, 1, 9), - ) - - It("rejects an empty image", func() { - _, err := blurhash.Encode(image.NewNRGBA(image.Rect(0, 0, 0, 0))) - Expect(err).To(HaveOccurred()) - }) - - It("produces the spec-mandated length", func() { - // 1 (size flag) + 1 (max AC) + 4 (DC) + 2 per AC component; a square derives 5x5 - h, err := blurhash.Encode(solidImage(8, 8, color.NRGBA{R: 10, G: 20, B: 30, A: 255})) - Expect(err).ToNot(HaveOccurred()) - Expect(h).To(HaveLen(4 + 2 + 2*(5*5-1))) - }) - - It("stores the average color in the DC component", func() { - h, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 200, G: 100, B: 50, A: 255})) - Expect(err).ToNot(HaveOccurred()) - dc := decode83(h[2:6]) - Expect(dc >> 16).To(BeNumerically("~", 200, 1)) - Expect((dc >> 8) & 0xFF).To(BeNumerically("~", 100, 1)) - Expect(dc & 0xFF).To(BeNumerically("~", 50, 1)) - }) - - It("is deterministic", func() { - img := gradientImage(64, 64) - h1, err1 := blurhash.Encode(img) - h2, err2 := blurhash.Encode(img) - Expect(err1).ToNot(HaveOccurred()) - Expect(err2).ToNot(HaveOccurred()) - Expect(h1).To(Equal(h2)) - }) - - It("produces different hashes for different images", func() { - h1, _ := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 255, A: 255})) - h2, _ := blurhash.Encode(gradientImage(16, 16)) - Expect(h1).ToNot(Equal(h2)) - }) - - It("downscales large images internally without changing the result materially", func() { - big, err := blurhash.Encode(solidImage(1000, 1000, color.NRGBA{R: 60, G: 120, B: 180, A: 255})) - Expect(err).ToNot(HaveOccurred()) - small, err := blurhash.Encode(solidImage(16, 16, color.NRGBA{R: 60, G: 120, B: 180, A: 255})) - Expect(err).ToNot(HaveOccurred()) - Expect(big[2:6]).To(Equal(small[2:6])) - }) -}) diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go new file mode 100644 index 000000000..eed935e9f --- /dev/null +++ b/core/artwork/cache_warmer.go @@ -0,0 +1,146 @@ +package artwork + +import ( + "context" + "fmt" + "io" + "sync" + "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" + "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/pl" + "golang.org/x/exp/maps" +) + +type CacheWarmer interface { + PreCache(artID model.ArtworkID) +} + +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 { + return &noopCacheWarmer{} + } + + a := &cacheWarmer{ + artwork: artwork, + cache: cache, + buffer: make(map[model.ArtworkID]struct{}), + wakeSignal: make(chan struct{}, 1), + } + + // Create a context with a fake admin user, to be able to pre-cache Playlist CoverArts + ctx := request.WithUser(context.TODO(), model.User{IsAdmin: true}) + go a.run(ctx) + return a +} + +type cacheWarmer struct { + artwork Artwork + buffer map[model.ArtworkID]struct{} + mutex sync.Mutex + cache cache.FileCache + wakeSignal chan struct{} +} + +var ignoredIds = map[string]struct{}{ + consts.VariousArtistsID: {}, + consts.UnknownArtistID: {}, +} + +func (a *cacheWarmer) PreCache(artID model.ArtworkID) { + if _, shouldIgnore := ignoredIds[artID.ID]; shouldIgnore { + return + } + a.mutex.Lock() + defer a.mutex.Unlock() + a.buffer[artID] = struct{}{} + a.sendWakeSignal() +} + +func (a *cacheWarmer) sendWakeSignal() { + // Don't block if the previous signal was not read yet + select { + case a.wakeSignal <- struct{}{}: + default: + } +} + +func (a *cacheWarmer) run(ctx context.Context) { + for { + a.waitSignal(ctx, 10*time.Second) + if ctx.Err() != nil { + break + } + + // If cache not available, keep waiting + if !a.cache.Available(ctx) { + if len(a.buffer) > 0 { + log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", len(a.buffer)) + } + continue + } + + a.mutex.Lock() + + // If there's nothing to send, keep waiting + if len(a.buffer) == 0 { + a.mutex.Unlock() + continue + } + + batch := maps.Keys(a.buffer) + a.buffer = make(map[model.ArtworkID]struct{}) + a.mutex.Unlock() + + a.processBatch(ctx, batch) + } +} + +func (a *cacheWarmer) waitSignal(ctx context.Context, timeout time.Duration) { + var to <-chan time.Time + if !a.cache.Available(ctx) { + tmr := time.NewTimer(timeout) + defer tmr.Stop() + to = tmr.C + } + select { + case <-to: + case <-a.wakeSignal: + case <-ctx.Done(): + } +} + +func (a *cacheWarmer) processBatch(ctx context.Context, batch []model.ArtworkID) { + log.Trace(ctx, "PreCaching a new batch of artwork", "batchSize", len(batch)) + input := pl.FromSlice(ctx, batch) + errs := pl.Sink(ctx, 2, input, a.doCacheImage) + for err := range errs { + log.Warn(ctx, "Error warming cache", err) + } +} + +func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize) + if err != nil { + return fmt.Errorf("error cacheing id='%s': %w", id, err) + } + defer r.Close() + _, err = io.Copy(io.Discard, r) + if err != nil { + return err + } + return nil +} + +type noopCacheWarmer struct{} + +func (a *noopCacheWarmer) PreCache(model.ArtworkID) {} diff --git a/core/artwork/disc.go b/core/artwork/disc.go deleted file mode 100644 index a050f6685..000000000 --- a/core/artwork/disc.go +++ /dev/null @@ -1,281 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "io" - "path" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/Masterminds/squirrel" - "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/slice" -) - -// discArtworkReader resolves disc-level artwork from a library's folder images -// and embedded tags. It is used by the serving path's provisional disc read-through. -type discArtworkReader struct { - album model.Album - discNumber int - imgFiles []string // library-relative, forward-slash, no leading slash - discFoldersRel map[string]bool // library-relative folder paths - isMultiFolder bool - firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs - lib libraryView - // Newest ImagesUpdatedAt across the album's and this disc's folders: an image can be - // replaced without the album row changing, so this is what makes a cache key notice it. - imagesUpdatedAt time.Time -} - -// cacheTime is the disc image's validity stamp: any of these moving means the selection may -// have changed. -func (d *discArtworkReader) cacheTime() time.Time { - return utils.TimeNewest(d.album.UpdatedAt, d.album.ImportedAt, d.imagesUpdatedAt) -} - -func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.ArtworkID) (*discArtworkReader, error) { - albumID, discNumber, err := model.ParseDiscArtworkID(artID.ID) - if err != nil { - return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err) - } - - al, err := ds.Album().Get(ctx, albumID) - if err != nil { - return nil, err - } - - _, imgFiles, albumImagesAt, err := loadAlbumFoldersPaths(ctx, ds, *al) - if err != nil { - return nil, err - } - - var imagesUpdatedAt time.Time - if albumImagesAt != nil { - imagesUpdatedAt = *albumImagesAt - } - - // Query mediafiles for this album + disc to find folder associations and first track - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Sort: "track_number", - Order: "ASC", - Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber}, - }) - if err != nil { - return nil, err - } - - lib, err := loadLibraryView(ctx, 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 - for _, mf := range mfs { - if mf.Path != "" { - firstTrackRel = filepath.ToSlash(mf.Path) - break - } - } - folderIDs := slice.Unique(slice.Map(mfs, func(mf model.MediaFile) string { return mf.FolderID })) - - // Resolve folder IDs to library-relative paths - discFoldersRel := make(map[string]bool) - if len(folderIDs) > 0 { - folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"folder.id": folderIDs}, - }) - if err != nil { - return nil, err - } - for _, f := range folders { - rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/") - discFoldersRel[rel] = true - imagesUpdatedAt = utils.TimeNewest(imagesUpdatedAt, f.ImagesUpdatedAt) - } - } - - return &discArtworkReader{ - album: *al, - discNumber: discNumber, - imgFiles: imgFiles, - discFoldersRel: discFoldersRel, - isMultiFolder: len(al.FolderIDs) > 1, - firstTrackRel: firstTrackRel, - lib: lib, - imagesUpdatedAt: imagesUpdatedAt, - }, nil -} - -// discCandidate is one DiscArtPriority entry. skip is set when the entry maps to no source at -// all, so a chain walk can say why instead of leaving a configured entry unaccounted for. -type discCandidate struct { - pattern string - resolve func() (resolution, bool) - skip string -} - -func (d *discArtworkReader) discCandidates(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []discCandidate { - folder := func(sf sourceFunc) func() (resolution, bool) { - return func() (resolution, bool) { return resolveFolderSource(d.lib, sf) } - } - var cc []discCandidate - for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { - pattern = strings.TrimSpace(pattern) - if pattern == "" { - continue - } - c := discCandidate{pattern: pattern} - switch { - case pattern == "embedded": - c.resolve = func() (resolution, bool) { - return resolveEmbedded(ctx, d.lib, ffmpeg, d.firstTrackRel) - } - case pattern == externalCandidate: - c.skip = "external sources are not supported for disc artwork" - case pattern == "discsubtitle": - subtitle := strings.TrimSpace(d.album.Discs[d.discNumber]) - if subtitle == "" { - c.skip = "disc has no subtitle" - } else { - c.resolve = folder(d.fromDiscSubtitle(ctx, subtitle)) - } - case len(d.imgFiles) == 0: - c.skip = "no images in album folder" - default: - c.resolve = folder(d.fromExternalFile(ctx, pattern)) - } - cc = append(cc, c) - } - return cc -} - -// selectImage walks the DiscArtPriority entries and returns the first that yields an image. -// chain records the walk; the serving path passes an untraced one and pays nothing for it. -func (d *discArtworkReader) selectImage(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string, - chain *chainState) (resolution, error) { - for _, c := range d.discCandidates(ctx, ffmpeg, priority) { - if err := ctx.Err(); err != nil { - return resolution{}, err - } - if c.skip != "" { - chain.record(c.pattern, OutcomeSkipped, c.skip) - continue - } - start := time.Now() - res, ok := c.resolve() - log.Trace(ctx, "Artwork: Tried a disc artwork candidate", "albumID", d.album.ID, - "disc", d.discNumber, "pattern", c.pattern, "hit", ok, "path", res.sourcePath, - "elapsed", time.Since(start)) - if res, ok = chain.try(c.pattern, res, ok); ok { - return res, nil - } - } - return chain.exhausted(), nil -} - -// fromDiscSubtitle returns a sourceFunc that matches image files whose stem -// (filename without extension) equals the disc subtitle (case-insensitive). -func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc { - return func() (io.ReadCloser, string, error) { - for _, file := range d.imgFiles { - stem := utils.BaseName(file) - if !strings.EqualFold(stem, subtitle) { - continue - } - f, err := d.lib.FS.Open(file) - if err != nil { - log.Warn(ctx, "Artwork: Could not open disc art file", "file", file, err) - continue - } - return f, file, nil - } - return nil, "", fmt.Errorf("disc %d: no image file matching subtitle %q", d.discNumber, subtitle) - } -} - -// filepath.Match's '\' escape is excluded on purpose: treating it 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. Caller must lowercase both args and have already verified the match. -func extractDiscNumber(pattern, filename string) (int, bool) { - metaIdx := strings.IndexAny(pattern, globMetaChars) - if metaIdx < 0 { - return 0, false - } - prefix := pattern[:metaIdx] - if !strings.HasPrefix(filename, prefix) { - return 0, false - } - - start := len(prefix) - end := start - for end < len(filename) && filename[end] >= '0' && filename[end] <= '9' { - end++ - } - if end == start { - return 0, false - } - num, err := strconv.Atoi(filename[start:end]) - if err != nil { - return 0, false - } - return num, true -} - -// fromExternalFile matches image files against a (lowercase) glob pattern. A numbered -// filename whose number equals the target disc wins over any unnumbered candidate. -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 := strings.ToLower(path.Base(file)) - match, err := filepath.Match(pattern, name) - if err != nil { - log.Warn(ctx, "Artwork: Error matching disc art file to pattern", "pattern", pattern, "file", file) - continue - } - if !match { - 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, "Artwork: Could not open disc art file", "file", file, err) - continue - } - return f, file, nil - } - } - - 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, "Artwork: Could not open disc art file", "file", file, err) - continue - } - return f, file, nil - } - return nil, "", fmt.Errorf("disc %d: pattern '%s' not matched by files", d.discNumber, pattern) - } -} diff --git a/core/artwork/disc_test.go b/core/artwork/disc_test.go deleted file mode 100644 index 8852741cf..000000000 --- a/core/artwork/disc_test.go +++ /dev/null @@ -1,539 +0,0 @@ -package artwork - -import ( - "context" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Disc Artwork Reader", func() { - Describe("extractDiscNumber", func() { - DescribeTable("extracts disc number from filename based on glob pattern", - func(pattern, filename string, expectedNum int, expectedOk bool) { - num, ok := extractDiscNumber(pattern, filename) - Expect(ok).To(Equal(expectedOk)) - if expectedOk { - Expect(num).To(Equal(expectedNum)) - } - }, - // Standard disc patterns - Entry("disc1.jpg", "disc*.*", "disc1.jpg", 1, true), - Entry("disc2.png", "disc*.*", "disc2.png", 2, true), - Entry("disc01.jpg", "disc*.*", "disc01.jpg", 1, true), - Entry("disc02.png", "disc*.*", "disc02.png", 2, true), - Entry("disc10.jpg", "disc*.*", "disc10.jpg", 10, true), - - // CD patterns - Entry("cd1.jpg", "cd*.*", "cd1.jpg", 1, true), - Entry("cd02.png", "cd*.*", "cd02.png", 2, true), - - // No number in filename - Entry("disc.jpg has no number", "disc*.*", "disc.jpg", 0, false), - Entry("cd.jpg has no number", "cd*.*", "cd.jpg", 0, false), - - // Extra text after number - Entry("disc2-bonus.jpg", "disc*.*", "disc2-bonus.jpg", 2, true), - Entry("disc01_front.png", "disc*.*", "disc01_front.png", 1, true), - - // Case insensitive (filename already lowered by caller) - Entry("Disc1.jpg lowered", "disc*.*", "disc1.jpg", 1, true), - - // 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), - ) - }) - - Describe("fromExternalFile", func() { - var ( - ctx context.Context - tmpDir string - ) - - BeforeEach(func() { - ctx = context.Background() - tmpDir = GinkgoT().TempDir() - }) - - // 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 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}, - 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(f1)) - }) - - 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}, - 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, 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}, - } - - cc := reader.discCandidates(ctx, nil, "disc*.*, cover.*") - Expect(cc).To(HaveLen(2)) - res, ok := cc[0].resolve() - Expect(ok).To(BeTrue()) - Expect(res.sourcePath).To(Equal(reader.lib.Abs(f2))) - res.reader.Close() - - cc = reader.discCandidates(ctx, nil, "cover.*, disc*.*") - Expect(cc).To(HaveLen(2)) - res, ok = cc[0].resolve() - Expect(ok).To(BeTrue()) - Expect(res.sourcePath).To(Equal(reader.lib.Abs(f1))) - res.reader.Close() - }) - - DescribeTable("numbered match wins over shared fallback within a pattern", - 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}, - discFoldersRel: map[string]bool{"album/cd1": true}, - isMultiFolder: 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(f1)) - }) - - It("prefers disc number over folder when number is present", 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}, - discFoldersRel: map[string]bool{"album/cd1": true}, - isMultiFolder: 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(f1)) - }) - - It("does not match disc2.jpg when looking for disc 1", func() { - f1 := createFile("album/disc2.jpg") - reader := &discArtworkReader{ - 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*.*") - r, _, _ := sf() - Expect(r).To(BeNil()) - }) - }) - - Describe("fromDiscSubtitle", func() { - var ( - ctx context.Context - tmpDir string - ) - - BeforeEach(func() { - ctx = context.Background() - tmpDir = GinkgoT().TempDir() - }) - - 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 relPath - } - - It("matches image file whose stem equals the disc subtitle (case-insensitive)", func() { - f1 := createFile("album/The Blue Disc.jpg") - reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1}, - lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, - } - - sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - r.Close() - Expect(path).To(Equal(f1)) - }) - - It("matches case-insensitively", func() { - f1 := createFile("album/bonus tracks.png") - reader := &discArtworkReader{ - discNumber: 2, - imgFiles: []string{f1}, - lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, - } - - sf := reader.fromDiscSubtitle(ctx, "Bonus Tracks") - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - r.Close() - Expect(path).To(Equal(f1)) - }) - - It("returns error when no matching file found", func() { - f1 := createFile("album/cover.jpg") - reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1}, - lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, - } - - sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") - _, _, err := sf() - Expect(err).To(HaveOccurred()) - }) - - It("matches first file when multiple extensions exist", func() { - f1 := createFile("album/The Blue Disc.jpg") - f2 := createFile("album/The Blue Disc.png") - reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1, f2}, - lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, - } - - sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") - r, path, err := sf() - Expect(err).ToNot(HaveOccurred()) - Expect(r).ToNot(BeNil()) - r.Close() - Expect(path).To(Equal(f1)) - }) - }) - - Describe("discArtworkReader", func() { - var ( - reader *discArtworkReader - tmpDir string - ) - - BeforeEach(func() { - tmpDir = GinkgoT().TempDir() - reader = &discArtworkReader{ - discNumber: 2, - isMultiFolder: true, - discFoldersRel: map[string]bool{"music/album/cd2": true}, - imgFiles: []string{ - "music/album/cd1/disc.jpg", - "music/album/cd2/disc.jpg", - "music/album/cd2/disc2.jpg", - }, - firstTrackRel: "music/album/cd2/track1.flac", - lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, - } - }) - - Describe("selectImage", func() { - It("abandons the walk when the context is cancelled", func() { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - res, err := reader.selectImage(ctx, nil, "disc*.*, cover.*", &chainState{}) - - Expect(err).To(MatchError(context.Canceled)) - Expect(res.reader).To(BeNil()) - }) - - // "the track has no embedded art" and "the track is there but unreadable" are the two - // answers a wrong-artwork report needs told apart; only the second is worth retrying. - It("reports a track it cannot parse as unreadable, not as a miss", func() { - trace := &ChainTrace{} - track := filepath.Join(tmpDir, filepath.FromSlash(reader.firstTrackRel)) - Expect(os.MkdirAll(filepath.Dir(track), 0755)).To(Succeed()) - Expect(os.WriteFile(track, []byte("not audio"), 0600)).To(Succeed()) - - res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded", - &chainState{trace: trace}) - - Expect(err).ToNot(HaveOccurred()) - Expect(res.localError).To(BeTrue()) - Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeUnreadable}})) - }) - - It("reports a disc with no tracks to read as a miss", func() { - trace := &ChainTrace{} - reader.firstTrackRel = "" - - res, err := reader.selectImage(context.Background(), tests.NewMockFFmpeg(""), "embedded", - &chainState{trace: trace}) - - Expect(err).ToNot(HaveOccurred()) - Expect(res.localError).To(BeFalse(), "there was nothing to read, so nothing failed to read") - Expect(trace.Steps()).To(Equal([]TraceStep{{Candidate: "embedded", Outcome: OutcomeMiss}})) - }) - }) - - Describe("discCandidates", func() { - It("returns a resolvable candidate for glob patterns", func() { - cc := reader.discCandidates(context.Background(), nil, "disc*.*") - Expect(cc).To(HaveLen(1)) - Expect(cc[0].resolve).ToNot(BeNil()) - }) - - It("returns one candidate per entry, in order", func() { - cc := reader.discCandidates(context.Background(), nil, "disc*.*, cd*.*, embedded") - Expect(slice.Map(cc, func(c discCandidate) string { return c.pattern })). - To(Equal([]string{"disc*.*", "cd*.*", "embedded"})) - }) - - It("skips an empty entry rather than building a glob that matches nothing", func() { - cc := reader.discCandidates(context.Background(), nil, "disc*.*,") - Expect(cc).To(HaveLen(1)) - }) - - // The skip reasons below are what `artwork explain` prints, so an entry that maps to no - // source must say why instead of vanishing from the walk. - DescribeTable("keeps an entry that maps to no source, with its reason", - func(setup func(), priority, reason string) { - setup() - cc := reader.discCandidates(context.Background(), nil, priority) - Expect(cc).To(HaveLen(1)) - Expect(cc[0].resolve).To(BeNil()) - Expect(cc[0].skip).To(Equal(reason)) - }, - Entry("external is unsupported", func() {}, "external", - "external sources are not supported for disc artwork"), - Entry("no images in the album folder", func() { reader.imgFiles = nil }, "disc*.*", - "no images in album folder"), - Entry("the disc has no subtitle", - func() { reader.album = model.Album{Discs: model.Discs{2: ""}} }, "discsubtitle", - "disc has no subtitle"), - ) - - It("returns source func for discsubtitle pattern", func() { - reader.album = model.Album{Discs: model.Discs{2: "Bonus Tracks"}} - cc := reader.discCandidates(context.Background(), nil, "discsubtitle") - Expect(cc).To(HaveLen(1)) - Expect(cc[0].resolve).ToNot(BeNil()) - }) - }) - }) -}) diff --git a/core/artwork/dominant/dominant.go b/core/artwork/dominant/dominant.go deleted file mode 100644 index 2bbda8ae9..000000000 --- a/core/artwork/dominant/dominant.go +++ /dev/null @@ -1,127 +0,0 @@ -// Package dominant extracts an image's dominant colour, for use as a flat placeholder while the -// real artwork loads. -package dominant - -import ( - "fmt" - "image" - "math" - "sort" -) - -const ( - // 4 bits per channel: coarse enough that near-identical pixels land together, fine enough that - // distinct colours stay apart. - bits = 4 - nBins = 1 << (3 * bits) - // Only the heaviest bins can win, and merging is O(n^2) over whatever survives. - maxBins = 64 - // Oklab distance below which two bins are the same colour to the eye. Merging matters because a - // gradient splits across adjacent bins and would otherwise lose to a smaller flat region. - mergeDist = 0.10 -) - -type bin struct { - r, g, b float64 - n float64 -} - -// Color returns the dominant colour as "#rrggbb", or "" when the image has no pixels. It reports -// presence, not salience: a mostly white sleeve returns white. -func Color(img image.Image) string { - var bins [nBins]bin - total := 0 - eachPixel(img, func(r, g, b uint8) { - i := int(r>>(8-bits))<<(2*bits) | int(g>>(8-bits))<>(8-bits)) - bins[i].r += float64(r) - bins[i].g += float64(g) - bins[i].b += float64(b) - bins[i].n++ - total++ - }) - if total == 0 { - return "" - } - - used := make([]bin, 0, 32) - for i := range bins { - if bins[i].n > 0 { - used = append(used, bins[i]) - } - } - sort.Slice(used, func(i, j int) bool { return used[i].n > used[j].n }) - if len(used) > maxBins { - used = used[:maxBins] - } - - merged := make([]bin, 0, len(used)) - for _, b := range used { - if i := nearest(merged, b); i >= 0 { - merged[i].r += b.r - merged[i].g += b.g - merged[i].b += b.b - merged[i].n += b.n - continue - } - merged = append(merged, b) - } - - best := merged[0] - for _, m := range merged[1:] { - if m.n > best.n { - best = m - } - } - return fmt.Sprintf("#%02x%02x%02x", - uint8(best.r/best.n+0.5), uint8(best.g/best.n+0.5), uint8(best.b/best.n+0.5)) -} - -func nearest(merged []bin, b bin) int { - bl, ba, bb := oklab(b.r/b.n, b.g/b.n, b.b/b.n) - for i, m := range merged { - ml, ma, mb := oklab(m.r/m.n, m.g/m.n, m.b/m.n) - if math.Sqrt((bl-ml)*(bl-ml)+(ba-ma)*(ba-ma)+(bb-mb)*(bb-mb)) < mergeDist { - return i - } - } - return -1 -} - -// eachPixel walks the image, taking the NRGBA fast path the artwork pipeline always hits: both hash -// encoders already read the shared thumbnail in that form. -func eachPixel(img image.Image, fn func(r, g, b uint8)) { - if p, ok := img.(*image.NRGBA); ok { - for y := range p.Rect.Dy() { - row := p.Pix[y*p.Stride : y*p.Stride+p.Rect.Dx()*4] - for x := 0; x < len(row); x += 4 { - fn(row[x], row[x+1], row[x+2]) - } - } - return - } - b := img.Bounds() - for y := b.Min.Y; y < b.Max.Y; y++ { - for x := b.Min.X; x < b.Max.X; x++ { - r, g, bl, _ := img.At(x, y).RGBA() - fn(uint8(r>>8), uint8(g>>8), uint8(bl>>8)) - } - } -} - -func srgbToLinear(v float64) float64 { - v /= 255 - if v <= 0.04045 { - return v / 12.92 - } - return math.Pow((v+0.055)/1.055, 2.4) -} - -func oklab(r, g, b float64) (float64, float64, float64) { - lr, lg, lb := srgbToLinear(r), srgbToLinear(g), srgbToLinear(b) - l := math.Cbrt(0.4122214708*lr + 0.5363325363*lg + 0.0514459929*lb) - m := math.Cbrt(0.2119034982*lr + 0.6806995451*lg + 0.1073969566*lb) - s := math.Cbrt(0.0883024619*lr + 0.2817188376*lg + 0.6299787005*lb) - return 0.2104542553*l + 0.7936177850*m - 0.0040720468*s, - 1.9779984951*l - 2.4285922050*m + 0.4505937099*s, - 0.0259040371*l + 0.7827717662*m - 0.8086757660*s -} diff --git a/core/artwork/dominant/dominant_suite_test.go b/core/artwork/dominant/dominant_suite_test.go deleted file mode 100644 index 56dbd6209..000000000 --- a/core/artwork/dominant/dominant_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package dominant_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestDominant(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Dominant Suite") -} diff --git a/core/artwork/dominant/dominant_test.go b/core/artwork/dominant/dominant_test.go deleted file mode 100644 index da7fed09c..000000000 --- a/core/artwork/dominant/dominant_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package dominant_test - -import ( - "image" - "image/color" - - "github.com/navidrome/navidrome/core/artwork/dominant" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// fill paints rect with c onto img. -func fill(img *image.NRGBA, r image.Rectangle, c color.NRGBA) { - for y := r.Min.Y; y < r.Max.Y; y++ { - for x := r.Min.X; x < r.Max.X; x++ { - img.SetNRGBA(x, y, c) - } - } -} - -func newImg(w, h int, c color.NRGBA) *image.NRGBA { - img := image.NewNRGBA(image.Rect(0, 0, w, h)) - fill(img, img.Bounds(), c) - return img -} - -var _ = Describe("Color", func() { - It("returns a solid image's own colour", func() { - Expect(dominant.Color(newImg(20, 20, color.NRGBA{0x33, 0x66, 0x99, 255}))).To(Equal("#336699")) - }) - - It("returns empty for an image with no pixels", func() { - Expect(dominant.Color(image.NewNRGBA(image.Rect(0, 0, 0, 0)))).To(Equal("")) - }) - - // Presence, not salience: this is a placeholder, so the large field wins even though the small - // patch is the more interesting colour. - It("picks the largest area, not the most vivid one", func() { - img := newImg(20, 20, color.NRGBA{0xfa, 0xfa, 0xfa, 255}) - fill(img, image.Rect(0, 0, 4, 4), color.NRGBA{0xff, 0x00, 0x00, 255}) - Expect(dominant.Color(img)).To(Equal("#fafafa")) - }) - - It("reports a near-black cover as near-black", func() { - img := newImg(20, 20, color.NRGBA{0x05, 0x05, 0x05, 255}) - fill(img, image.Rect(0, 0, 5, 5), color.NRGBA{0x00, 0xff, 0x00, 255}) - Expect(dominant.Color(img)).To(Equal("#050505")) - }) - - // A gradient splits across many quantisation bins. Without merging, each slice is smaller than - // the flat block and the block would win despite covering far less of the image. - It("merges a gradient's bins so it beats a smaller flat block", func() { - img := image.NewNRGBA(image.Rect(0, 0, 40, 40)) - for y := range 40 { - for x := range 40 { - // 30 columns of blue gradient == 75% of the image - if x < 30 { - img.SetNRGBA(x, y, color.NRGBA{0x10, 0x20, uint8(0xa0 + x), 255}) - } else { - img.SetNRGBA(x, y, color.NRGBA{0xff, 0xcc, 0x00, 255}) - } - } - } - got := dominant.Color(img) - Expect(got).To(HavePrefix("#1020"), "expected the blue gradient, got "+got) - }) - - It("is deterministic", func() { - img := image.NewNRGBA(image.Rect(0, 0, 30, 30)) - for y := range 30 { - for x := range 30 { - img.SetNRGBA(x, y, color.NRGBA{uint8(x * 7), uint8(y * 5), uint8(x + y), 255}) - } - } - first := dominant.Color(img) - for range 5 { - Expect(dominant.Color(img)).To(Equal(first)) - } - }) - - It("handles images that are not NRGBA", func() { - src := newImg(10, 10, color.NRGBA{0x20, 0x40, 0x60, 255}) - rgba := image.NewRGBA(src.Bounds()) - for y := range 10 { - for x := range 10 { - rgba.Set(x, y, src.At(x, y)) - } - } - Expect(dominant.Color(rgba)).To(Equal("#204060")) - }) -}) diff --git a/core/artwork/e2e/acquire_serve_test.go b/core/artwork/e2e/acquire_serve_test.go deleted file mode 100644 index 8cfa54113..000000000 --- a/core/artwork/e2e/acquire_serve_test.go +++ /dev/null @@ -1,349 +0,0 @@ -package e2e - -import ( - "context" - "encoding/base64" - "errors" - "io" - "os" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/cache" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Covers the enqueue → drain → serve chain; per-source resolution rules live in the unit suites. -var _ = Describe("Acquisition → serve loop", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - artRepo *tests.MockArtworkRepo - queueRepo *tests.MockArtworkQueueRepo - albumRepo *tests.MockAlbumRepo - artistRepo *tests.MockArtistRepo - mfRepo *tests.MockMediaFileRepo - plRepo *tests.MockPlaylistRepo - radioRepo *tests.MockedRadioRepo - folderRepo *fakeFolderRepo - libRepo *tests.MockLibraryRepo - store *artwork.ImageStore - svc artwork.Artwork - worker *artwork.Worker - coverBytes []byte - ) - - itemFound := func(kind model.Kind, id string) func() bool { - return func() bool { - ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary) - return err == nil && ia.Hash != "" - } - } - itemAbsent := func(kind model.Kind, id string) func() bool { - return func() bool { - ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary) - return err == nil && ia.Hash == "" - } - } - // Enqueues the way the serving paths do, so the drain is driven by a plain queue row. - bump := func(kind, id string) { - GinkgoHelper() - Expect(ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{ - ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityBump, - })).To(Succeed()) - } - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = context.Background() - repoRoot, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - coverBytes = readFixture(coverFixture) - - conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) - conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) - conf.Server.CoverArtPriority = "cover.jpg" - conf.Server.ArtistArtPriority = "artist.png" // keeps artist resolution offline - conf.Server.EnableMediaFileCoverArt = true - conf.Server.DevArtworkWorkerConcurrency = 1 - - folderRepo = &fakeFolderRepo{} - libRepo = &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: repoRoot}}) - artRepo = tests.CreateMockArtworkRepo() - queueRepo = tests.CreateMockArtworkQueueRepo() - albumRepo = tests.CreateMockAlbumRepo() - artistRepo = tests.CreateMockArtistRepo() - mfRepo = tests.CreateMockMediaFileRepo() - plRepo = tests.CreateMockPlaylistRepo() - radioRepo = tests.CreateMockedRadioRepo() - radioRepo.Data = map[string]*model.Radio{} - ds = &tests.MockDataStore{ - MockedArtwork: artRepo, - MockedArtworkQueue: queueRepo, - MockedAlbum: albumRepo, - MockedArtist: artistRepo, - MockedMediaFile: mfRepo, - MockedPlaylist: plRepo, - MockedRadio: radioRepo, - MockedFolder: folderRepo, - MockedLibrary: libRepo, - } - ffm := tests.NewMockFFmpeg("") - store = artwork.NewImageStore(GinkgoT().TempDir()) - // size=0 requests stream originals, so this reader is never called (serving_test covers resizing). - imgCache := cache.NewFileCache("ArtworkPipelineE2E", "100MB", "images", 0, - func(context.Context, cache.Item) (io.Reader, error) { - return nil, errors.New("resize not exercised in e2e") - }) - Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue()) - - svc = artwork.NewArtwork(ds, imgCache, store, ffm) - worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) - }) - - seedFolderAlbum := func(albumID string) { - folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}} - albumRepo.SetData(model.Albums{{ID: albumID, Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}}) - } - - It("acquires and serves a cover whose format has no registered decoder (#5950)", func() { - libDir := GinkgoT().TempDir() - Expect(os.MkdirAll(filepath.Join(libDir, "an-album"), 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(libDir, "an-album", "cover.jxl"), jxlFixture, 0600)).To(Succeed()) - - conf.Server.CoverArtPriority = "cover.*" - libRepo.SetData(model.Libraries{{ID: 0, Path: libDir}}) - folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jxl"}}} - albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}}) - - bump("al", "al1") - runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1")) - - img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Placeholder).To(BeFalse()) - Expect(readAll(img)).To(Equal(jxlFixture)) - }) - - It("acquires album folder art and serves the exact bytes under its hash", func() { - seedFolderAlbum("al1") - bump("al", "al1") - runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("folder")) - - img, err := svc.Get(ctx, model.MustParseArtworkID("al-al1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Hash).To(Equal(ia.Hash)) - Expect(img.Placeholder).To(BeFalse()) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - - It("acquires an artist's uploaded image and serves it", func() { - name := writeUpload(consts.EntityArtist, "artist-e2e.png", artistPngFixture) - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: name}}) - bump("ar", "ar1") - runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("upload")) - - img, err := svc.Get(ctx, model.MustParseArtworkID("ar-ar1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Hash).To(Equal(ia.Hash)) - Expect(readAll(img)).To(Equal(readFixture(artistPngFixture))) - }) - - It("generates a playlist grid from its tracks' album art and serves it from the store", func() { - seedFolderAlbum("al1") - plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"al1"}} - bump("pl", "pl1") - runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("generated")) - - img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Hash).To(Equal(ia.Hash)) - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - Expect(art.Mime).To(Equal("image/png")) - Expect(len(readAll(img))).To(BeNumerically(">", 0)) - }) - - It("acquires a radio station's uploaded image and serves it", func() { - name := writeUpload(consts.EntityRadio, "radio-e2e.jpg", coverFixture) - radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name} - bump("ra", "ra1") - runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("upload")) - - img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Hash).To(Equal(ia.Hash)) - Expect(readAll(img)).To(Equal(coverBytes)) - }) - - It("serves an unresolved track provisionally, then upgrades to the worker's state row", func() { - mfRepo.SetData(model.MediaFiles{{ - ID: "mf1", AlbumID: "al1", HasCoverArt: true, LibraryID: 0, Path: mp3Fixture, - }}) - - provisional, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(provisional.Placeholder).To(BeFalse()) - Expect(provisional.Hash).ToNot(BeEmpty()) - provisionalBytes := readAll(provisional) - Expect(len(provisionalBytes)).To(BeNumerically(">", 0)) - - _, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row") - - // The provisional read enqueued a Bump; drain it. - runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1")) - ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("embedded")) - Expect(ia.Hash).To(Equal(provisional.Hash)) - - resolved, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(resolved.Hash).To(Equal(ia.Hash)) - Expect(readAll(resolved)).To(Equal(provisionalBytes)) - }) - - It("stores dimensions, mime and a real blurhash alongside the acquired bytes", func() { - seedFolderAlbum("al1") - bump("al", "al1") - runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - Expect(art.Mime).To(Equal("image/jpeg")) - Expect(art.Width).To(BeNumerically(">", 0)) - Expect(art.Height).To(BeNumerically(">", 0)) - Expect(art.SizeBytes).To(BeNumerically("==", len(coverBytes))) - // Never a synthesized value: both hashes are encoded from the real pixels. - Expect(art.BlurHash).ToNot(BeEmpty()) - Expect(art.ThumbHash).ToNot(BeEmpty()) - raw, err := base64.StdEncoding.DecodeString(art.ThumbHash) - Expect(err).ToNot(HaveOccurred()) - Expect(len(raw)).To(BeNumerically(">=", 5)) - }) - - It("acquires GIF artwork, whose decoder only core/artwork's blank import registers", func() { - writeUploadedImage(consts.EntityRadio, "station.gif", gifFixture) - radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: "station.gif"} - bump("ra", "ra1") - runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - Expect(art.Mime).To(Equal("image/gif")) - Expect(art.Width).To(BeNumerically("==", 4)) - }) - - It("deduplicates byte-identical art across entities onto one image row", func() { - folderRepo.result = []model.Folder{{Path: albumFolderPath, ImageFiles: []string{"cover.jpg"}}} - albumRepo.SetData(model.Albums{ - {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}, - {ID: "al2", Name: "Same Cover", FolderIDs: []string{"f1"}, LibraryID: 0}, - }) - bump("al", "al1") - bump("al", "al2") - runWorkerUntil(ctx, worker, func() bool { - return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")() - }) - - ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash") - Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes)) - }) - - It("stops serving a file-backed image once its source file changes underneath", func() { - name := writeUpload(consts.EntityRadio, "radio-stale.jpg", coverFixture) - radioRepo.Data["ra1"] = &model.Radio{ID: "ra1", Name: "Station", UploadedImage: name} - bump("ra", "ra1") - runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - staleHash := ia.Hash - - path := model.UploadedImagePath(consts.EntityRadio, name) - Expect(os.WriteFile(path, readFixture(artistPngFixture), 0o600)).To(Succeed()) - newer := time.Now().Add(2 * time.Second) - Expect(os.Chtimes(path, newer, newer)).To(Succeed()) - - // The mtime no longer matches the state row, so the stale bytes are not served. - _, err = svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false) - Expect(err).To(MatchError(artwork.ErrUnavailable)) - - // That failed read enqueued a re-resolution. - runWorkerUntil(ctx, worker, func() bool { - cur, gerr := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary) - return gerr == nil && cur.Hash != "" && cur.Hash != staleHash - }) - img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(readAll(img)).To(Equal(readFixture(artistPngFixture))) - }) - - It("records an absent state for an entity with no art and reports it unavailable", func() { - albumRepo.SetData(model.Albums{{ID: "alx", Name: "Artless", LibraryID: 0}}) - bump("al", "alx") - runWorkerUntil(ctx, worker, itemAbsent(model.KindAlbumArtwork, "alx")) - - _, err := svc.Get(ctx, model.MustParseArtworkID("al-alx"), 0, false) - Expect(err).To(MatchError(artwork.ErrUnavailable)) - - img, err := svc.GetOrPlaceholder(ctx, "al-alx", 0, false) - Expect(err).ToNot(HaveOccurred()) - Expect(img.Placeholder).To(BeTrue()) - }) -}) - -func mustGet(img *artwork.Image, err error) *artwork.Image { - GinkgoHelper() - Expect(err).ToNot(HaveOccurred()) - return img -} - -// Raw bytes on purpose: encoding a GIF here would register image/gif in the test binary, masking -// jxlFixture is a JPEG XL bare codestream header: a real image format, with no stdlib decoder. -var jxlFixture = []byte{0xff, 0x0a, 0x00, 0x10, 0x00} - -// the production import the spec above guards. -var gifFixture = []byte{ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x04, 0x00, 0x04, 0x00, 0x80, 0x00, - 0x00, 0x2e, 0x86, 0xc1, 0xf4, 0xd0, 0x3f, 0x2c, 0x00, 0x00, 0x00, 0x00, - 0x04, 0x00, 0x04, 0x00, 0x00, 0x02, 0x05, 0x44, 0x7c, 0x67, 0xb8, 0x05, - 0x00, 0x3b, -} diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go deleted file mode 100644 index 7b5e7b4a3..000000000 --- a/core/artwork/e2e/album_test.go +++ /dev/null @@ -1,470 +0,0 @@ -package e2e - -import ( - "testing/fstest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// The in-memory library FS cannot satisfy the os.Open(SourcePath) used to serve folder art, so -// folder scenarios assert on the worker's state row (Source + SourcePath) instead of the bytes. -var _ = Describe("Album artwork resolution", func() { - BeforeEach(func() { - setupResolutionHarness() - }) - - 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": smallPNG("album-root"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") - }) - }) - - // 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": smallPNG("album-root"), - "Artist/Album/CD1/cover.jpg": smallPNG("disc1"), - "Artist/Album/CD2/cover.jpg": smallPNG("disc2"), - }) - scan() - - al := firstAlbum() - Expect(al.FolderIDs).To(HaveLen(2), - "sanity check: the two disc subfolders should form one multi-disc album") - expectAlbumFolderCover(al, "Artist/Album/cover.jpg") - }) - }) - - // 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": smallPNG("album-root"), - "Artist/Album/CD1/folder.jpg": smallPNG("disc1"), - "Artist/Album/CD2/folder.jpg": smallPNG("disc2"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg") - }) - }) - - // 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": smallPNG("album-root"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") - }) - }) - - // 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": smallPNG("album-root"), - "Album/CD1/folder.jpg": smallPNG("disc1"), - "Album/CD2/folder.jpg": smallPNG("disc2"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Album/cover.jpg") - }) - }) - - 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": smallPNG("external"), - }) - scan() - replaceWithRealMP3("Artist/Album/01 - Track.mp3") - - ia := acquire(model.KindAlbumArtwork, firstAlbum().ID) - Expect(ia.Source).To(Equal("embedded")) - Expect(storedBytes(ia)).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") - - ia := acquire(model.KindAlbumArtwork, firstAlbum().ID) - Expect(ia.Source).To(Equal("embedded")) - Expect(storedBytes(ia)).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": smallPNG("case-insensitive"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/Cover.JPG") - }) - }) - - 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": smallPNG("primary"), - "Artist/Album/cover.1.jpg": smallPNG("secondary"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") - }) - }) - - When("the album has no cover and CoverArtPriority lists only file patterns", func() { - // Artist/ - // └── Album/ - // └── 01 - Track.mp3 (no image files — settles absent) - It("settles absent", func() { - conf.Server.CoverArtPriority = "cover.*, folder.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - }) - scan() - expectAlbumAbsent(firstAlbum()) - }) - }) - - // 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": smallPNG("folder"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg") - }) - }) - - 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": smallPNG("front"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/front.jpg") - }) - }) - - 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": smallPNG("cover"), - "Artist/Album/folder.jpg": smallPNG("folder"), - "Artist/Album/front.jpg": smallPNG("front"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") - }) - }) - - 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": smallPNG("folder"), - "Artist/Album/front.jpg": smallPNG("front"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/folder.jpg") - }) - }) - - 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": smallPNG("second"), - "Artist/Album/cover.jpg": smallPNG("primary"), - "Artist/Album/cover.1.jpg": smallPNG("first"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") - }) - }) - - 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": smallPNG("cover"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") - }) - }) - - // Regression introduced in v0.62.0 (#5451 + #5457): the parent-folder - // fallback can pick up images from the ARTIST folder, serving the artist - // thumbnail as album art for any album without its own image files. - When("an album has no images and the artist folder has folder.jpg", func() { - // Artist/ - // ├── folder.jpg ← artist thumbnail, must NOT become album art - // ├── Album A/ - // │ └── 01 - Track.mp3 (no images) - // └── Album B/ - // ├── 01 - Track.mp3 - // └── cover.jpg - It("does not use the artist image as album art", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/folder.jpg": smallPNG("artist-thumbnail"), - "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}), - "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), - "Artist/Album B/cover.jpg": smallPNG("album-b"), - }) - scan() - - // Album B first: the acquire in expectAlbumAbsent would settle Album B too. - expectAlbumFolderCover(albumByName("Album B"), "Artist/Album B/cover.jpg") - expectAlbumAbsent(albumByName("Album A")) - }) - }) - - When("a single-disc album is spread across sibling folders under the artist folder", func() { - // Artist/ - // ├── folder.jpg ← artist thumbnail, must NOT become album art - // ├── Album A/ - // │ └── 01 - Track.mp3 (album: "Album A") - // ├── Album A bonus/ - // │ └── 02 - Track.mp3 (album: "Album A" — same album, second folder) - // └── Album B/ - // ├── 01 - Track.mp3 - // └── cover.jpg - It("does not use the artist image as album art for the spread album", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/folder.jpg": smallPNG("artist-thumbnail"), - "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}), - "Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}), - "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), - "Artist/Album B/cover.jpg": smallPNG("album-b"), - }) - scan() - - alA := albumByName("Album A") - Expect(alA.FolderIDs).To(HaveLen(2), - "sanity check: the two sibling folders should form one spread album") - expectAlbumAbsent(alA) - }) - }) - - // albumRootParent refuses the library root as an album root (parent.ParentID == ""). - When("a multi-disc album sits directly at the library root with a cover.jpg beside it", func() { - // (library root) - // ├── cover.jpg ← must NOT be adopted - // ├── CD1/ - // │ └── 01 - Track.mp3 - // └── CD2/ - // └── 01 - Track.mp3 - It("does not adopt the library-root image as album art", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "cover.jpg": smallPNG("library-root"), - "CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"album": "Rootless", "disc": "1"}), - "CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"album": "Rootless", "disc": "2"}), - }) - scan() - expectAlbumAbsent(firstAlbum()) - }) - }) - - // The shallower artist-folder cover.jpg would win the basename tie, but albumRootParent skips - // the parent folder for a single-folder album that has images of its own. - When("a single-folder album has its own cover.jpg and the artist folder has one too", func() { - // Artist/ - // ├── cover.jpg ← shallower, but must NOT win - // └── Album/ - // ├── 01 - Track.mp3 - // └── cover.jpg ← should win - It("prefers the album's own cover over the shallower artist-folder cover", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/cover.jpg": smallPNG("artist-image"), - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/Album/cover.jpg": smallPNG("album-own"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") - }) - }) - - When("a spread album has its own front.jpg but the artist folder has cover.jpg", func() { - // Artist/ - // ├── cover.jpg ← artist image; matches cover.* (first pattern), - // │ must NOT shadow the album's own front.jpg - // ├── Album A/ - // │ ├── 01 - Track.mp3 (album: "Album A") - // │ └── front.jpg ← should win - // ├── Album A bonus/ - // │ └── 02 - Track.mp3 (album: "Album A") - // └── Album B/ - // └── 01 - Track.mp3 (other-album audio: rejects the artist folder as a root) - It("prefers the album's own art over the artist image", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/cover.jpg": smallPNG("artist-image"), - "Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}), - "Artist/Album A/front.jpg": smallPNG("album-a-front"), - "Artist/Album A bonus/02 - Track.mp3": trackFile(2, "Track A2", map[string]any{"album": "Album A", "albumartist": "Artist"}), - "Artist/Album B/01 - Track.mp3": trackFile(1, "Track B", map[string]any{"album": "Album B", "albumartist": "Artist"}), - }) - scan() - - alA := albumByName("Album A") - Expect(alA.FolderIDs).To(HaveLen(2), - "sanity check: the two sibling folders should form one spread album") - expectAlbumFolderCover(alA, "Artist/Album A/front.jpg") - }) - }) - - 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": smallPNG("cover"), - }) - scan() - expectAlbumFolderCover(firstAlbum(), "Artist/Album/cover.jpg") - }) - }) -}) diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go deleted file mode 100644 index 38c28a9b2..000000000 --- a/core/artwork/e2e/artist_test.go +++ /dev/null @@ -1,291 +0,0 @@ -package e2e - -import ( - "os" - "path/filepath" - "testing/fstest" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" - "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". -// Library-folder images are file-backed (asserted on the worker state row); uploaded and -// image-folder images are real files on disk (asserted byte-for-byte). -var _ = Describe("Artist artwork resolution", func() { - BeforeEach(func() { - setupResolutionHarness() - }) - - 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": smallPNG("artist-folder"), - }) - scan() - expectArtistFolder(soleArtist(), "Artist/artist.jpg") - }) - }) - - 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": smallPNG("album-artist"), - }) - scan() - expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg") - }) - }) - - 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": smallPNG("artist-folder"), - "Artist/Album/artist.jpg": smallPNG("album-artist"), - }) - scan() - expectArtistFolder(soleArtist(), "Artist/artist.jpg") - }) - }) - - When("ArtistArtPriority has no album/ fallback", func() { - // Artist/ - // ├── artist.jpg ← must resolve via the artist folder itself - // └── Album/ - // └── 01 - Track.mp3 - It("still resolves the artist folder and returns artist.*", func() { - conf.Server.ArtistArtPriority = "artist.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), - "Artist/artist.jpg": smallPNG("artist-folder"), - }) - scan() - expectArtistFolder(soleArtist(), "Artist/artist.jpg") - }) - }) - - When("the artist's only album folder has no images of its own", func() { - // Artist/ - // ├── backdrop1.jpg - // ├── folder.jpg ← matched by folder.* - // ├── logo.png - // └── Album/ - // ├── 01 - Track.mp3 - // └── 02 - Track.mp3 - It("resolves the artist folder, not the library root", func() { - conf.Server.ArtistArtPriority = "folder.*, artist.*, album/artist.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}), - "Artist/Album/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}), - "Artist/backdrop1.jpg": smallPNG("backdrop"), - "Artist/folder.jpg": smallPNG("artist-folder"), - "Artist/logo.png": smallPNG("logo"), - }) - scan() - expectArtistFolder(soleArtist(), "Artist/folder.jpg") - }) - }) - - When("the artist's only album has its tracks in disc subfolders", func() { - // Artist/ - // ├── artist.jpg ← wins (artist.* before album/artist.*) - // └── Album/ - // ├── artist.jpg - // ├── CD1/01 - Track.mp3 - // └── CD2/02 - Track.mp3 - It("prefers the artist-folder image over the album-folder one", func() { - conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" - setLayout(fstest.MapFS{ - "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}), - "Artist/Album/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}), - "Artist/artist.jpg": smallPNG("artist-folder"), - "Artist/Album/artist.jpg": smallPNG("album-artist"), - }) - scan() - expectArtistFolder(soleArtist(), "Artist/artist.jpg") - }) - }) - - When("one album has disc subfolders and another sits at artist level", func() { - // Artist/ - // ├── artist.jpg ← wins - // ├── Album1/ - // │ ├── artist.jpg - // │ ├── CD1/01 - Track.mp3 - // │ └── CD2/02 - Track.mp3 - // └── Album2/03 - Track.mp3 - It("prefers the artist-folder image over the album-folder one", func() { - conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" - setLayout(fstest.MapFS{ - "Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}), - "Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}), - "Artist/Album2/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}), - "Artist/artist.jpg": smallPNG("artist-folder"), - "Artist/Album1/artist.jpg": smallPNG("album-artist"), - }) - scan() - expectArtistFolder(soleArtist(), "Artist/artist.jpg") - }) - }) - - When("every album of the artist has its tracks in disc subfolders", func() { - // Artist/ - // ├── artist.jpg ← wins - // ├── Album1/ - // │ ├── artist.jpg - // │ ├── CD1/01 - Track.mp3 - // │ └── CD2/02 - Track.mp3 - // └── Album2/ - // ├── CD1/03 - Track.mp3 - // └── CD2/04 - Track.mp3 - It("prefers the artist-folder image over the album-folder one", func() { - conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" - setLayout(fstest.MapFS{ - "Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}), - "Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}), - "Artist/Album2/CD1/03 - Track.mp3": trackFile(3, "Track 3", map[string]any{"albumartist": "Artist", "album": "Album2"}), - "Artist/Album2/CD2/04 - Track.mp3": trackFile(4, "Track 4", map[string]any{"albumartist": "Artist", "album": "Album2"}), - "Artist/artist.jpg": smallPNG("artist-folder"), - "Artist/Album1/artist.jpg": smallPNG("album-artist"), - }) - scan() - expectArtistFolder(soleArtist(), "Artist/artist.jpg") - }) - }) - - 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": smallPNG("artist-folder"), - }) - scan() - ar := soleArtist() - - uploaded := ar.ID + "_upload.jpg" - writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded")) - ar.UploadedImage = uploaded - Expect(rds.Artist().Put(rctx, &ar)).To(Succeed()) - - ia := acquire(model.KindArtistArtwork, ar.ID) - Expect(ia.Source).To(Equal("upload")) - Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("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": smallPNG("album-artist"), - }) - scan() - expectArtistFolder(soleArtist(), "Artist/Album/artist.jpg") - }) - }) - - // resolveArtist only samples albums where this artist is the SOLE album artist, so a - // collaboration or compilation never donates its images as the artist's own. - When("the artist's only album is credited to two album artists", func() { - // Artist/ - // └── Collab Album/ (album artists: "Artist" + a collaborator) - // ├── 01 - Track.mp3 - // └── artist.jpg ← must NOT become the artist image - It("ignores the album's images and settles absent", func() { - conf.Server.ArtistArtPriority = "album/artist.*" - // " / " is a default artists split separator, so this single tag yields two album artists. - setLayout(fstest.MapFS{ - "Artist/Collab Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist / Collaborator"}), - "Artist/Collab Album/artist.jpg": smallPNG("collab-artist"), - }) - scan() - Expect(firstAlbum().Participants[model.RoleAlbumArtist]).To(HaveLen(2), - "sanity check: the album must be credited to two album artists") - - ar := soleArtist() - ia := acquire(model.KindArtistArtwork, ar.ID) - Expect(ia.Hash).To(BeEmpty()) - Expect(serveErr(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))). - To(MatchError(artwork.ErrUnavailable)) - }) - }) - - 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"), pngBytes("image-folder"), 0o600)).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() - ia := acquire(model.KindArtistArtwork, ar.ID) - Expect(ia.Source).To(Equal("folder")) - Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(pngBytes("image-folder"))) - }) - }) -}) - -func soleArtist() model.Artist { - GinkgoHelper() - artists, err := rds.Artist().GetAll(rctx, 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 deleted file mode 100644 index 5fdd5f27b..000000000 --- a/core/artwork/e2e/disc_test.go +++ /dev/null @@ -1,333 +0,0 @@ -package e2e - -import ( - "fmt" - "testing/fstest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/artwork" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Disc art is a serve-time read through the library FS (no worker state row), so per-disc images -// are asserted byte-for-byte, while album-root covers are asserted on the state row. -var _ = Describe("Disc artwork resolution", func() { - BeforeEach(func() { - setupResolutionHarness() - }) - - 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": smallPNG("disc1-image"), - }) - scan() - expectDiscImage(firstAlbum(), 1, "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 — nothing to serve) - It("reports the disc lookup as unavailable", func() { - conf.Server.DiscArtPriority = "disc*.*, cd*.*" - conf.Server.CoverArtPriority = "cover.*, folder.*" - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), - }) - scan() - Expect(serveErr(discArtID(firstAlbum(), 1))).To(MatchError(artwork.ErrUnavailable)) - }) - }) - - 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": smallPNG("album-cover"), - }) - scan() - expectDiscImage(firstAlbum(), 1, "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": smallPNG("disc-one"), - "Artist/Album/disc10.jpg": smallPNG("disc-ten"), - }) - scan() - expectDiscImage(firstAlbum(), 1, "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": smallPNG("disc-1"), - "Artist/Album/CD2/disc2.jpg": smallPNG("disc-2"), - }) - scan() - expectDiscImage(firstAlbum(), 2, "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": smallPNG("disc-1"), - "Artist/Album/CD2/cd2.png": smallPNG("cd-2"), - }) - scan() - expectDiscImage(firstAlbum(), 2, "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": smallPNG("disc1-cover"), - "Artist/Album/CD2/cover.jpg": smallPNG("disc2-cover"), - }) - scan() - expectDiscImage(firstAlbum(), 1, "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": smallPNG("disc-1"), - "Artist/Album/CD2/cd2.png": smallPNG("cd-2"), - "Artist/Album/cover.jpg": smallPNG("album-cover"), - }) - scan() - - al := firstAlbum() - for _, n := range []int{1, 2} { - expectDiscImage(al, n, "album-cover") - } - }) - }) - - 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": smallPNG("disc-1"), - "Artist/Album/disc2/cd2.png": smallPNG("cd-2"), - "Artist/Album/cover.jpg": smallPNG("album-root"), - }) - scan() - - al := firstAlbum() - expectDiscImage(al, 1, "disc-1") - expectDiscImage(al, 2, "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": smallPNG("bonus-tracks"), - }) - scan() - expectDiscImage(firstAlbum(), 1, "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": smallPNG("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"] = smallPNG(fmt.Sprintf("disc-%02d-folder", discNum)) - } - setLayout(layout) - scan() - - al := firstAlbum() - expectAlbumFolderCover(al, "(2001) The Golden Road/cover.jpg") - for i := range discNames { - discNum := i + 1 - expectDiscImage(al, discNum, fmt.Sprintf("disc-%02d-folder", 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": smallPNG("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"] = smallPNG(fmt.Sprintf("disc-%02d-folder", i)) - } - setLayout(layout) - scan() - - al := firstAlbum() - expectAlbumFolderCover(al, "Album/cover.jpg") - for i := 1; i <= 3; i++ { - expectDiscImage(al, i, fmt.Sprintf("disc-%02d-folder", 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": smallPNG("cover"), - }) - scan() - expectDiscImage(firstAlbum(), 1, "cover") - }) - }) -}) diff --git a/core/artwork/e2e/e2e_suite_test.go b/core/artwork/e2e/e2e_suite_test.go deleted file mode 100644 index d03e22228..000000000 --- a/core/artwork/e2e/e2e_suite_test.go +++ /dev/null @@ -1,88 +0,0 @@ -// Package e2e exercises the artwork pipeline end to end: the real Worker drains the queue and the -// real Service serves the result, over a real ImageStore and real library files. -package e2e - -import ( - "context" - "io" - "os" - "path/filepath" - "testing" - "time" - - _ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor - "github.com/navidrome/navidrome/core/artwork" - _ "github.com/navidrome/navidrome/core/storage/local" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "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 Pipeline E2E Suite") -} - -// Fixtures relative to the project root (tests.Init chdirs there). -const ( - coverFixture = "tests/fixtures/artist/an-album/cover.jpg" - mp3Fixture = "tests/fixtures/artist/an-album/test.mp3" - artistPngFixture = "tests/fixtures/artist/an-album/artist.png" - albumFolderPath = "tests/fixtures/artist/an-album" -) - -func readFixture(rel string) []byte { - GinkgoHelper() - data, err := os.ReadFile(rel) - Expect(err).ToNot(HaveOccurred(), "reading fixture %q", rel) - return data -} - -func readAll(img *artwork.Image) []byte { - GinkgoHelper() - Expect(img).ToNot(BeNil()) - defer img.Close() - data, err := io.ReadAll(img) - Expect(err).ToNot(HaveOccurred()) - return data -} - -func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) { - GinkgoHelper() - runCtx, cancel := context.WithCancel(ctx) - done := make(chan error, 1) - go func() { done <- worker.Run(runCtx) }() - // Long enough for one retry (3-7s backoff, 5s poll tick). - Eventually(until, 15*time.Second, 10*time.Millisecond).Should(BeTrue()) - cancel() - Eventually(done, 2*time.Second).Should(Receive(BeNil())) -} - -type fakeFolderRepo struct { - model.FolderRepository - result []model.Folder -} - -func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) { - return f.result, nil -} - -func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) { - return false, nil -} - -func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) { - return nil, model.ErrNotFound -} - -func writeUpload(entityType, name, srcFixture string) string { - GinkgoHelper() - dst := model.UploadedImagePath(entityType, name) - Expect(os.MkdirAll(filepath.Dir(dst), 0o755)).To(Succeed()) - Expect(os.WriteFile(dst, readFixture(srcFixture), 0o600)).To(Succeed()) - return name -} diff --git a/core/artwork/e2e/mediafile_test.go b/core/artwork/e2e/mediafile_test.go deleted file mode 100644 index 3ddb9dc48..000000000 --- a/core/artwork/e2e/mediafile_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package e2e - -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 -// -// Embedded art lands in the content-addressed store (asserted byte-for-byte); disc-level art is a -// serve-time read through the library FS. -var _ = Describe("MediaFile artwork resolution", func() { - BeforeEach(func() { - setupResolutionHarness() - }) - - 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": smallPNG("disc-1"), - "Artist/Album/CD2/disc2.jpg": smallPNG("disc-2"), - "Artist/Album/cover.jpg": smallPNG("album-root"), - }) - scan() - - mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") - Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("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": smallPNG("album-cover"), - }) - scan() - - mf := mediafileOn("Artist/Album/01 - Track.mp3") - Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("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": smallPNG("album-root"), - }) - scan() - - mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") - Expect(serveBytes(mf.CoverArtID())).To(Equal(pngBytes("album-root"))) - }) - }) - - When("a track has its own embedded art", func() { - // Artist/ - // └── Album/ - // └── 01 - Track.mp3 ← has embedded picture (wins over every fallback) - It("resolves the track's embedded image into the store", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), - }) - scan() - replaceWithRealMP3("Artist/Album/01 - Track.mp3") - - mf := mediafileOn("Artist/Album/01 - Track.mp3") - ia := acquire(model.KindMediaFileArtwork, mf.ID) - Expect(ia.Source).To(Equal("embedded")) - Expect(storedBytes(ia)).To(Equal(embeddedArtBytes)) - }) - }) - - When("EnableMediaFileCoverArt is turned off after the track was scanned", func() { - // Artist/ - // └── Album/ - // ├── 01 - Track.mp3 ← has embedded picture (must NOT be served) - // └── cover.jpg ← wins (per-track art disabled at serve time) - It("serves the album cover instead of the track's embedded art", func() { - conf.Server.CoverArtPriority = defaultCoverPriority - setLayout(fstest.MapFS{ - "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), - "Artist/Album/cover.jpg": smallPNG("album-cover"), - }) - scan() - replaceWithRealMP3("Artist/Album/01 - Track.mp3") - - // The setting is not part of the artwork fingerprint, so it must be honored at serve time. - conf.Server.EnableMediaFileCoverArt = false - mf := mediafileOn("Artist/Album/01 - Track.mp3") - trackArtID := model.NewArtworkID(model.KindMediaFileArtwork, mf.ID, nil) - Expect(serveBytes(trackArtID)).To(Equal(pngBytes("album-cover"))) - }) - }) -}) - -func mediafileOn(relPath string) model.MediaFile { - GinkgoHelper() - mfs, err := rds.MediaFile().GetAll(rctx, 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 deleted file mode 100644 index 0d862b20e..000000000 --- a/core/artwork/e2e/playlist_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package e2e - -import ( - "image/color" - "os" - "path/filepath" - "testing/fstest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - . "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. Absent -// -// The library is an in-memory FS, but uploaded/sidecar/local-external images are real files on -// disk — the resolver reads them via os.Open, so those tests place them in a real tempdir. -var _ = Describe("Playlist artwork resolution", func() { - BeforeEach(func() { - setupResolutionHarness() - }) - - 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", pngBytes("playlist-upload")) - pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"}) - - ia := acquire(model.KindPlaylistArtwork, pl.ID) - Expect(ia.Source).To(Equal("upload")) - Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("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"), 0o600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), pngBytes("sidecar"), 0o600)).To(Succeed()) - - pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath}) - - ia := acquire(model.KindPlaylistArtwork, pl.ID) - Expect(ia.Source).To(Equal("folder")) - Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("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"), 0o600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), pngBytes("sidecar-png"), 0o600)).To(Succeed()) - - pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath}) - - Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("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, pngBytes("external-local"), 0o600)).To(Succeed()) - - pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath}) - - Expect(serveBytes(pl.CoverArtID())).To(Equal(pngBytes("external-local"))) - }) - }) - - When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() { - // (no local files — the http source is gated off, so resolution settles absent) - It("skips the URL and settles absent", func() { - conf.Server.EnableM3UExternalAlbumArt = false - pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"}) - - ia := acquire(model.KindPlaylistArtwork, pl.ID) - Expect(ia.Hash).To(BeEmpty()) - Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable)) - - img, err := rsvc.GetOrPlaceholder(rctx, pl.CoverArtID().String(), 0, false) - Expect(err).ToNot(HaveOccurred()) - defer img.Close() - Expect(img.Placeholder).To(BeTrue()) - }) - }) - - When("a playlist has no images and no tracks", func() { - // (no uploaded/sidecar/external image and no album art to sample) - It("settles absent", func() { - pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"}) - - ia := acquire(model.KindPlaylistArtwork, pl.ID) - Expect(ia.Hash).To(BeEmpty()) - Expect(serveErr(pl.CoverArtID())).To(MatchError(artwork.ErrUnavailable)) - }) - }) - - When("a playlist has no uploaded/sidecar/external image but has tracks with album covers", func() { - // Library: - // Artist/ - // ├── AlbumA/ - // │ ├── 01 - Track.mp3 - // │ └── cover.png ← tile 1 source - // └── AlbumB/ - // ├── 01 - Track.mp3 - // └── cover.png ← tile 2 source - // Playlist "pl-7" references tracks from both albums, so the worker generates a tiled - // cover from 2 distinct album art tiles (mirrored to fill the 2x2 grid). - 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": smallPNG("albumA"), - "Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}), - "Artist/AlbumB/cover.png": smallPNG("albumB"), - }) - scan() - - mfs, err := rds.MediaFile().GetAll(rctx, 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(rds.Playlist().Put(rctx, &pl)).To(Succeed()) - - ia := acquire(model.KindPlaylistArtwork, pl.ID) - Expect(ia.Source).To(Equal("generated")) - data := storedBytes(ia) - // The tiled cover is a PNG-encoded image; exact bytes vary (random album order). - Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})) - // Two tiles are mirrored into the 2x2 grid as [A B B A], so opposite corners match. - q := gridQuadrants(data) - Expect(q[0]).To(Equal(q[3])) - Expect(q[1]).To(Equal(q[2])) - Expect(q[0]).ToNot(Equal(q[1])) - }) - }) - - When("a playlist has tracks from four albums, each with its own cover", func() { - // Library: - // Artist/ - // ├── AlbumA/{01 - Track.mp3, cover.png} ← tile 1 - // ├── AlbumB/{01 - Track.mp3, cover.png} ← tile 2 - // ├── AlbumC/{01 - Track.mp3, cover.png} ← tile 3 - // └── AlbumD/{01 - Track.mp3, cover.png} ← tile 4 - It("fills all four grid quadrants with distinct album art", func() { - conf.Server.CoverArtPriority = "cover.*" - layout := fstest.MapFS{} - for _, name := range []string{"AlbumA", "AlbumB", "AlbumC", "AlbumD"} { - layout["Artist/"+name+"/01 - Track.mp3"] = trackFile(1, "T"+name, map[string]any{"album": name}) - layout["Artist/"+name+"/cover.png"] = smallPNG(name) - } - setLayout(layout) - scan() - - mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).To(HaveLen(4)) - ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) - - pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"} - pl.AddMediaFilesByID(ids) - Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed()) - - ia := acquire(model.KindPlaylistArtwork, pl.ID) - Expect(ia.Source).To(Equal("generated")) - q := gridQuadrants(storedBytes(ia)) - Expect([]color.RGBA{q[0], q[1], q[2], q[3]}).To(HaveLen(4)) - Expect(q[0]).ToNot(Equal(q[1])) - Expect(q[0]).ToNot(Equal(q[2])) - Expect(q[0]).ToNot(Equal(q[3])) - Expect(q[1]).ToNot(Equal(q[2])) - Expect(q[1]).ToNot(Equal(q[3])) - Expect(q[2]).ToNot(Equal(q[3])) - }) - }) -}) - -func putPlaylist(pl model.Playlist) model.Playlist { - GinkgoHelper() - if pl.OwnerID == "" { - pl.OwnerID = "admin-1" - } - Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed()) - return pl -} diff --git a/core/artwork/e2e/radio_test.go b/core/artwork/e2e/radio_test.go deleted file mode 100644 index 72af215d1..000000000 --- a/core/artwork/e2e/radio_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package e2e - -import ( - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Radio art is uploaded-image-only, with no fallback. Uploads are real files on disk, so they -// serve back byte-for-byte; a radio with no upload settles absent. -var _ = Describe("Radio artwork resolution", func() { - BeforeEach(func() { - setupResolutionHarness() - }) - - 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", pngBytes("radio-logo")) - rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"} - Expect(rds.Radio().Put(rctx, &rd)).To(Succeed()) - - ia := acquire(model.KindRadioArtwork, rd.ID) - Expect(ia.Source).To(Equal("upload")) - Expect(serveBytes(model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil))).To(Equal(pngBytes("radio-logo"))) - }) - }) - - When("a radio has no uploaded image", func() { - // (no files on disk — the resolver has no sources to fall back to) - It("settles absent", func() { - rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"} - Expect(rds.Radio().Put(rctx, &rd)).To(Succeed()) - - ia := acquire(model.KindRadioArtwork, rd.ID) - Expect(ia.Hash).To(BeEmpty()) - Expect(serveErr(model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil))).To(MatchError(artwork.ErrUnavailable)) - }) - }) -}) diff --git a/core/artwork/e2e/resolution_harness_test.go b/core/artwork/e2e/resolution_harness_test.go deleted file mode 100644 index fbd89925e..000000000 --- a/core/artwork/e2e/resolution_harness_test.go +++ /dev/null @@ -1,365 +0,0 @@ -package e2e - -import ( - "bytes" - "context" - "fmt" - "hash/fnv" - "image" - "image/color" - "image/png" - "io" - "maps" - "os" - "path/filepath" - "strings" - "sync" - "testing/fstest" - "time" - - _ "github.com/navidrome/navidrome/adapters/gotaglib" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/metrics" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" - "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/navidrome/navidrome/tests/harness" - "github.com/navidrome/navidrome/utils/cache" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "go.senan.xyz/taglib" -) - -const fakeLibScheme = "artworkfake" -const fakeLibPath = fakeLibScheme + ":///music" - -const ( - defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external" - defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded" -) - -var ( - rctx context.Context - rds *tests.MockDataStore - rstore *artwork.ImageStore - rsvc artwork.Artwork - rworker *artwork.Worker - fakeFS *storagetest.FakeFS -) - -// The go-sqlite3 singleton holds the file open for the whole suite, and Windows cannot unlink a -// file with a live handle, so the DB cannot live in Ginkgo's per-spec TempDir. -var suiteDBTempDir string - -// Migrating the schema costs ~400ms, so it runs once per suite and specs reset by truncating. -var userTables []string - -var _ = BeforeSuite(func() { - suiteDBTempDir = GinkgoT().TempDir() - - DeferCleanup(configtest.SetupConfig()) - conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL" - conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) - db.Db().SetMaxOpenConns(1) - db.Init(request.WithUser(context.Background(), model.User{ID: "admin-1", IsAdmin: true})) - - userTables = harness.ResettableTables() -}) - -var _ = AfterSuite(func() { - db.Close(context.Background()) -}) - -func setupResolutionHarness() { - DeferCleanup(configtest.SetupConfig()) - - tempDir := GinkgoT().TempDir() - conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL" - conf.Server.DataFolder = conf.NewDir(tempDir) - conf.Server.MusicFolder = fakeLibPath - conf.Server.DevExternalScanner = false - conf.Server.ImageCacheSize = "0" - conf.Server.EnableExternalServices = false - conf.Server.EnableMediaFileCoverArt = true - conf.Server.DevArtworkWorkerConcurrency = 1 - - rctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true}) - harness.TruncateDB(userTables) - - rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - - adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"} - Expect(rds.User().Put(rctx, &adminUser)).To(Succeed()) - - lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath} - Expect(rds.Library().Put(rctx, &lib)).To(Succeed()) - Expect(rds.User().SetUserLibraries(rctx, adminUser.ID, []int{lib.ID})).To(Succeed()) - - loadEmbeddedFixture() - - fakeFS = &storagetest.FakeFS{} - storagetest.Register(fakeLibScheme, fakeFS) - - ffm := tests.NewMockFFmpeg("") - rstore = artwork.NewImageStore(filepath.Join(tempDir, consts.HashedArtworkFolder)) - // size=0 requests stream originals, so this reader is never called (serving_test covers resizing). - imgCache := cache.NewFileCache("ArtworkResolutionE2E", "100MB", "images", 0, - func(context.Context, cache.Item) (io.Reader, error) { - return nil, fmt.Errorf("resize not exercised in e2e") - }) - Eventually(func() bool { return imgCache.Available(rctx) }, 10*time.Second).Should(BeTrue()) - - rsvc = artwork.NewArtwork(rds, imgCache, rstore, ffm) - rworker = artwork.NewWorker(rds, rstore, agents.GetAgents(rds, nil), ffm, events.NoopBroker(), imgCache) -} - -// setLayout paths must be relative and forward-slash. -func setLayout(files fstest.MapFS) { - GinkgoHelper() - fakeFS.SetFiles(files) -} - -func scan() { - GinkgoHelper() - s := scanner.New(rctx, rds, events.NoopBroker(), - playlists.NewPlaylists(rds, artwork.NewUploader(rds)), metrics.NewNoopInstance()) - _, err := s.ScanAll(rctx, true) - Expect(err).ToNot(HaveOccurred()) -} - -func acquire(kind model.Kind, id string) model.ItemArtwork { - GinkgoHelper() - // Enqueues the way the serving paths do, so the drain is driven by a plain queue row. - Expect(rds.ArtworkQueue().EnqueuePreservingBackoff(rctx, model.ArtworkQueueItem{ - ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityBump, - })).To(Succeed()) - var ia *model.ItemArtwork - runResolutionWorkerUntil(func() bool { - got, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary) - if err != nil { - return false - } - ia = got - return true - }) - return *ia -} - -func runResolutionWorkerUntil(until func() bool) { - GinkgoHelper() - runCtx, cancel := context.WithCancel(rctx) - done := make(chan error, 1) - go func() { done <- rworker.Run(runCtx) }() - Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue()) - cancel() - Eventually(done, 2*time.Second).Should(Receive(BeNil())) -} - -func serveBytes(artID model.ArtworkID) []byte { - GinkgoHelper() - img, err := rsvc.Get(rctx, artID, 0, false) - Expect(err).ToNot(HaveOccurred()) - defer img.Close() - data, err := io.ReadAll(img) - Expect(err).ToNot(HaveOccurred()) - return data -} - -func serveErr(artID model.ArtworkID) error { - img, err := rsvc.Get(rctx, artID, 0, false) - if img != nil { - img.Close() - } - return err -} - -func libFileBytes(suffix string) []byte { - GinkgoHelper() - var match string - for name := range fakeFS.MapFS { - if strings.HasSuffix(name, suffix) { - Expect(match).To(BeEmpty(), "suffix %q is ambiguous: %q and %q", suffix, match, name) - match = name - } - } - Expect(match).ToNot(BeEmpty(), "no library file ends with %q", suffix) - return fakeFS.MapFS[match].Data -} - -// Serving before acquiring is deliberate: with no state row the request resolves through the -// library FS, while a settled folder row is read with os.Open, which the in-memory FS cannot serve. -func expectAlbumFolderCover(al model.Album, suffix string) { - GinkgoHelper() - requireNoStateRow(model.KindAlbumArtwork, al.ID) - Expect(serveBytes(al.CoverArtID())).To(Equal(libFileBytes(suffix))) - ia := acquire(model.KindAlbumArtwork, al.ID) - Expect(ia.Source).To(Equal("folder")) - Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix(suffix)) -} - -// A drain settles every ready item, so byte-level folder assertions must precede any acquire. -func requireNoStateRow(kind model.Kind, id string) { - GinkgoHelper() - _, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), - "assert %s %q before acquiring any other entity in this spec", kind, id) -} - -func expectAlbumAbsent(al model.Album) { - GinkgoHelper() - ia := acquire(model.KindAlbumArtwork, al.ID) - Expect(ia.Hash).To(BeEmpty()) - Expect(serveErr(al.CoverArtID())).To(MatchError(artwork.ErrUnavailable)) -} - -func expectArtistFolder(ar model.Artist, suffix string) { - GinkgoHelper() - requireNoStateRow(model.KindArtistArtwork, ar.ID) - Expect(serveBytes(model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil))).To(Equal(libFileBytes(suffix))) - ia := acquire(model.KindArtistArtwork, ar.ID) - Expect(ia.Source).To(Equal("folder")) - Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix(suffix)) -} - -func writeUploadedImage(entity, filename string, data []byte) { - GinkgoHelper() - dst := model.UploadedImagePath(entity, filename) - Expect(os.MkdirAll(filepath.Dir(dst), 0o755)).To(Succeed()) - Expect(os.WriteFile(dst, data, 0o600)).To(Succeed()) -} - -func discArtID(al model.Album, disc int) model.ArtworkID { - return model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, disc), &al.UpdatedAt) -} - -// Disc art is a pure serve-time read through the library FS: no worker, no state row. -func expectDiscImage(al model.Album, disc int, label string) { - GinkgoHelper() - Expect(serveBytes(discArtID(al, disc))).To(Equal(pngBytes(label))) -} - -// Samples in rect() order: top-left, top-right, bottom-left, bottom-right. -func gridQuadrants(data []byte) [4]color.RGBA { - GinkgoHelper() - img, _, err := image.Decode(bytes.NewReader(data)) - Expect(err).ToNot(HaveOccurred()) - b := img.Bounds() - qw, qh := b.Dx()/4, b.Dy()/4 - at := func(x, y int) color.RGBA { - c := color.RGBAModel.Convert(img.At(b.Min.X+x, b.Min.Y+y)) - return c.(color.RGBA) - } - return [4]color.RGBA{at(qw, qh), at(3*qw, qh), at(qw, 3*qh), at(3*qw, 3*qh)} -} - -// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath. -func storedBytes(ia model.ItemArtwork) []byte { - GinkgoHelper() - art, err := rds.Artwork().GetImage(rctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - r, err := rstore.Open(ia.Hash, art.Mime) - Expect(err).ToNot(HaveOccurred()) - defer r.Close() - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - return data -} - -// The pixel color derives from label, so each label yields distinct, still-decodable bytes. -func smallPNG(label string) *fstest.MapFile { - 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} - img := image.NewRGBA(image.Rect(0, 0, 2, 2)) - 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()} -} - -func pngBytes(label string) []byte { - GinkgoHelper() - return smallPNG(label).Data -} - -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) -} - -// FakeFS's JSON-encoded tags aren't taglib-readable, so embedded-art specs swap in these real MP3 -// bytes after scanning. Loaded lazily: tests.Init must chdir to the project root first. -var ( - embeddedFixtureOnce sync.Once - embeddedArtFixture []byte - embeddedArtBytes []byte -) - -func loadEmbeddedFixture() { - embeddedFixtureOnce.Do(func() { - embeddedArtFixture = readFixture(mp3Fixture) - embeddedArtBytes = extractEmbeddedArt(embeddedArtFixture) - }) -} - -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 -} - -func replaceWithRealMP3(relPath string) { - GinkgoHelper() - fakeFS.MapFS[relPath] = &fstest.MapFile{Data: embeddedArtFixture} -} - -func firstAlbum() model.Album { - GinkgoHelper() - albums, err := rds.Album().GetAll(rctx, model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums)) - return albums[0] -} - -func albumByName(name string) model.Album { - GinkgoHelper() - albums, err := rds.Album().GetAll(rctx, model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - for _, al := range albums { - if al.Name == name { - return al - } - } - Fail(fmt.Sprintf("album %q not found among %d albums", name, len(albums))) - return model.Album{} -} diff --git a/core/artwork/folders_album.go b/core/artwork/folders_album.go deleted file mode 100644 index 7b423adb6..000000000 --- a/core/artwork/folders_album.go +++ /dev/null @@ -1,148 +0,0 @@ -package artwork - -import ( - "cmp" - "context" - "errors" - "path" - "slices" - "strings" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/natural" - "github.com/navidrome/navidrome/utils/slice" -) - -func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model.Album) ([]string, []string, *time.Time, error) { - folders, err := loadFolders(ctx, ds, album.FolderIDs) - if err != nil { - return nil, nil, nil, err - } - - parent, err := albumRootParent(ctx, ds, folders, album.FolderIDs) - if err != nil { - return nil, nil, nil, err - } - if parent != nil { - folders = append(folders, *parent) - } - - paths := slice.Map(folders, func(f model.Folder) string { return f.AbsolutePath() }) - imgFiles, updatedAt := folderImages(folders) - return paths, imgFiles, &updatedAt, nil -} - -func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) { - return ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}}) -} - -// folderImages collects the folders' image files, sorted so files without -// numeric suffixes win (e.g. cover.jpg over cover.1.jpg). -func folderImages(folders []model.Folder) ([]string, time.Time) { - var imgFiles []string - var updatedAt time.Time - for _, f := range folders { - 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, path.Join(rel, img)) - } - } - slices.SortFunc(imgFiles, compareImageFiles) - return imgFiles, updatedAt -} - -// albumRootParent returns the common parent of the album's folders when it -// qualifies as the album's root folder (e.g. "Artist/Album" above disc -// subfolders), or nil when there is no such parent. This finds cover art in -// the album root folder when tracks live in disc subfolders, like -// "Artist/Album/cover.jpg" with tracks in "Artist/Album/CD1/" and -// "Artist/Album/CD2/". The parent must look like an album root, not an -// artist-level folder — it qualifies only when it holds no audio belonging to -// other albums — so artist images are never served as album art. -func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Folder, folderIDs []string) (*model.Folder, error) { - folderIDSet := make(map[string]bool, len(folderIDs)) - for _, id := range folderIDs { - folderIDSet[id] = true - } - commonParentID := commonParentFolder(folders, folderIDSet) - if commonParentID == "" { - return nil, nil - } - // Single-folder albums only use the parent when the folder has no images - // of its own (indicating a disc subfolder needing parent artwork). - if len(folders) < 2 && anyFolderHasImages(folders) { - return nil, nil - } - parent, err := ds.Folder().Get(ctx, commonParentID) - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID) - return nil, nil - } - if err != nil { - return nil, err - } - if parent.ParentID == "" { - // The library root can never be an album root - return nil, nil - } - hasOtherAudio, err := ds.Folder().HasAudioOutsideFolders(ctx, *parent, folderIDs) - if err != nil { - return nil, err - } - if hasOtherAudio { - return nil, nil - } - return parent, 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) == 0 { - return "" - } - parentID := folders[0].ParentID - if parentID == "" || folderIDSet[parentID] { - return "" - } - for _, f := range folders[1:] { - if f.ParentID != parentID { - return "" - } - } - return parentID -} - -// 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(path.Base(a), path.Ext(a)) - baseB := strings.TrimSuffix(path.Base(b), path.Ext(b)) - - // 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/folders_album_test.go b/core/artwork/folders_album_test.go deleted file mode 100644 index a263dbaaf..000000000 --- a/core/artwork/folders_album_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "time" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// The e2e specs cover which image wins for a layout; these pin what a layout cannot reach: that -// the album-root parent is only fetched when it could qualify, and what happens when that fails. -var _ = Describe("loadAlbumFoldersPaths", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - repo *fakeFolderRepo - album model.Album - now time.Time - ) - - BeforeEach(func() { - ctx = context.Background() - now = time.Now().Truncate(time.Second) - repo = &fakeFolderRepo{} - ds = &tests.MockDataStore{MockedFolder: repo} - album = model.Album{ - ID: "album1", - Name: "Album", - FolderIDs: []string{"folder1", "folder2", "folder3"}, - } - }) - - It("does not query the parent when it is already one of the album's folders", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist", Name: "Album", ParentID: "folder2", - ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, - {ID: "folder2", Path: "", Name: "Artist", ImagesUpdatedAt: now}, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg")) - Expect(repo.getCallCount).To(BeZero()) - }) - - It("does not query the parent when the album's folders have different parents", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist1/Album", Name: "part1", ParentID: "parentA", - ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, - {ID: "folder2", Path: "Artist2/Album", Name: "part2", ParentID: "parentB", - ImagesUpdatedAt: now}, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(ConsistOf("Artist1/Album/part1/cover.jpg")) - Expect(repo.getCallCount).To(BeZero()) - }) - - It("does not query the parent for a single-folder album that has images of its own", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist", Name: "Album", ParentID: "artistFolder", - ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg")) - Expect(repo.getCallCount).To(BeZero()) - }) - - It("does not promote the library root, so its images never become album art", func() { - 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}, - } - repo.parentResult = &model.Folder{ID: "rootFolder", Name: ".", ImageFiles: []string{"unrelated.jpg"}} - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(ConsistOf("AlbumPart1/cover.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("does not promote a parent that holds another album's audio", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "albumFolder", - ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, - {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "albumFolder", - ImagesUpdatedAt: now}, - } - repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album", - ParentID: "artistFolder", ImageFiles: []string{"artist.jpg"}} - repo.hasOtherAudio = true - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(ConsistOf("Artist/Album/CD1/cover.jpg")) - }) - - It("promotes the album root parent into the returned paths", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "albumFolder", - ImagesUpdatedAt: now}, - {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "albumFolder", - ImagesUpdatedAt: now}, - } - repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album", - ParentID: "artistFolder", ImageFiles: []string{"cover.jpg"}} - - paths, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(ConsistOf("Artist/Album/cover.jpg")) - Expect(paths).To(HaveLen(3)) - }) - - It("propagates errors from the album-root check", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist/Album", Name: "disc1", ParentID: "albumFolder", - ImagesUpdatedAt: now}, - } - repo.parentResult = &model.Folder{ID: "albumFolder", Path: "Artist", Name: "Album", - ParentID: "artistFolder", ImageFiles: []string{"cover.jpg"}} - repo.otherAudioErr = errors.New("db connection failed") - - _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).To(MatchError("db connection failed")) - }) - - It("propagates non-ErrNotFound errors from the parent folder lookup", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "parentFolder", - ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, - {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "parentFolder", - ImagesUpdatedAt: now}, - } - repo.getErr = errors.New("db connection failed") - - _, _, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).To(MatchError("db connection failed")) - Expect(repo.getCallCount).To(Equal(1)) - }) - - It("continues when the parent folder has been deleted", func() { - repo.result = []model.Folder{ - {ID: "folder1", Path: "Artist/Album", Name: "CD1", ParentID: "missingParent", - ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}}, - {ID: "folder2", Path: "Artist/Album", Name: "CD2", ParentID: "missingParent", - ImagesUpdatedAt: now}, - } - - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) - - Expect(err).ToNot(HaveOccurred()) - Expect(imgFiles).To(ConsistOf("Artist/Album/CD1/cover.jpg")) - Expect(repo.getCallCount).To(Equal(1)) - }) -}) - -// folderImages is the sort that decides which of several same-named images wins, so it is pinned -// directly rather than through a layout that can only show the winner. -var _ = Describe("folderImages", func() { - It("prefers base filenames over numeric-suffixed ones", func() { - imgFiles, _ := folderImages([]model.Folder{ - {Path: "Artist", Name: "Album", ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"}}, - }) - - Expect(imgFiles).To(HaveExactElements( - "Artist/Album/cover.jpg", "Artist/Album/cover.1.jpg", "Artist/Album/cover.2.jpg")) - }) - - It("prefers shallower paths when the base filenames tie", func() { - imgFiles, _ := folderImages([]model.Folder{ - {Path: "Artist/Album", Name: "CD1", ImageFiles: []string{"cover.jpg"}}, - {Path: "Artist", Name: "Album", ImageFiles: []string{"cover.jpg"}}, - }) - - Expect(imgFiles).To(HaveExactElements("Artist/Album/cover.jpg", "Artist/Album/CD1/cover.jpg")) - }) - - It("sorts case-insensitively", func() { - imgFiles, _ := folderImages([]model.Folder{ - {Path: "Artist", Name: "Album", ImageFiles: []string{"Cover.jpg", "back.JPG"}}, - }) - - Expect(imgFiles).To(HaveExactElements("Artist/Album/back.JPG", "Artist/Album/Cover.jpg")) - }) - - It("reports the newest ImagesUpdatedAt across the folders", func() { - now := time.Now().Truncate(time.Second) - newest := now.Add(5 * time.Minute) - - _, updatedAt := folderImages([]model.Folder{ - {Path: "Artist", Name: "Album", ImagesUpdatedAt: now}, - {Path: "Artist/Album", Name: "CD1", ImagesUpdatedAt: newest}, - }) - - Expect(updatedAt).To(Equal(newest)) - }) -}) diff --git a/core/artwork/folders_artist.go b/core/artwork/folders_artist.go deleted file mode 100644 index 3403935db..000000000 --- a/core/artwork/folders_artist.go +++ /dev/null @@ -1,210 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "fmt" - "io" - "io/fs" - "os" - "path" - "path/filepath" - "slices" - "strings" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/slice" - "github.com/navidrome/navidrome/utils/str" -) - -const ( - maxArtistFolderTraversalDepth = 3 -) - -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 needs forward slashes; filepath.Rel returns backslashes on Windows. - rel = filepath.ToSlash(rel) - current := artistFolder - var unreadable error - for range maxArtistFolderTraversalDepth { - reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern) - if err == nil { - return reader, hit, nil - } - if errors.Is(err, errSourceUnreadable) { - unreadable = err - } - if rel == "." { - break // reached library root - } - rel = path.Dir(rel) - current = filepath.Dir(current) - } - if unreadable != nil { - return nil, "", unreadable - } - return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder) - } -} - -// findImageInFolder returns the first image matching pattern; absFolder is only used for -// the returned display path and log messages. -func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) { - log.Trace(ctx, "Artwork: 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, "Artwork: Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err) - return nil, "", err - } - - imagePaths := slice.Filter(matches, model.IsImageFile) - - // Prefer base filenames over numeric-suffixed ones (artist.jpg before artist.1.jpg) - slices.SortFunc(imagePaths, compareImageFiles) - - var openErr error - for _, p := range imagePaths { - f, err := libFS.Open(p) - if err != nil { - log.Warn(ctx, "Artwork: Could not open cover art file", "file", p, err) - openErr = fmt.Errorf("%w: %s: %w", errSourceUnreadable, p, err) - continue - } - _, name := path.Split(p) - return f, filepath.Join(absFolder, name), nil - } - if openErr != nil { - return nil, "", openErr - } - - 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() -} - -// loadArtistAlbumRoots returns one path per album — the deepest folder holding -// all of that album's tracks — so an album split into disc subfolders can't -// pull the artist folder's common prefix below the artist level. -func loadArtistAlbumRoots(ctx context.Context, ds model.DataStore, albums model.Albums) ([]string, []string, *time.Time, error) { - var folderIDs []string - for _, album := range albums { - folderIDs = append(folderIDs, album.FolderIDs...) - } - folders, err := loadFolders(ctx, ds, folderIDs) - if err != nil { - return nil, nil, nil, err - } - - pathByID := slice.ToMap(folders, func(f model.Folder) (string, string) { - return f.ID, f.AbsolutePath() - }) - var roots []string - for _, album := range albums { - var albumPaths []string - for _, fid := range album.FolderIDs { - if p, ok := pathByID[fid]; ok { - albumPaths = append(albumPaths, p) - } - } - if len(albumPaths) > 0 { - roots = append(roots, commonDir(albumPaths)) - } - } - - imgFiles, updatedAt := folderImages(folders) - return roots, imgFiles, &updatedAt, nil -} - -// commonDir returns the deepest directory containing all paths. Trailing -// separators keep the comparison on segment boundaries, so a shared name -// fragment (".../Album" and ".../Album2") is never read as a shared directory. -func commonDir(paths []string) string { - sep := string(filepath.Separator) - common := str.LongestCommonPrefix(slice.Map(paths, func(p string) string { return p + sep })) - if !strings.HasSuffix(common, sep) { - common, _ = filepath.Split(common) - } - return filepath.Clean(common) -} - -func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) { - if len(albums) == 0 { - return "", time.Time{}, nil - } - libID := albums[0].LibraryID // TODO: Support albums spanning multiple libraries - - // paths holds one root per album: two or more distinct roots already meet at - // the artist folder, while a single root is an album folder needing a climb. - roots := slices.Compact(slices.Sorted(slices.Values(paths))) - folderPath := commonDir(roots) - if len(roots) < 2 { - folderPath = filepath.Dir(folderPath) - } - - // Cleaned like the album paths; Join keeps an empty path empty, Clean would return ".". - libPath, _ := ds.Library().GetPath(ctx, libID) - libPath = filepath.Join(libPath) - folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath) - - log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID, - "libPath", libPath, "libID", libID, "albumPaths", paths) - - folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}}) - if err != nil || len(folders) == 0 { - log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID, - "libPath", libPath, "libID", libID, err) - return "", time.Time{}, err - } - return folderPath, folders[0].ImagesUpdatedAt, nil -} - -// findImageInArtistFolder matches an image by MBID or artist name (case-insensitive), "" if none. -func findImageInArtistFolder(folder, mbzArtistID, artistName string) string { - entries, err := os.ReadDir(folder) - if err != nil { - return "" - } - for _, candidate := range []string{mbzArtistID, artistName} { - if candidate == "" { - continue - } - for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - base := utils.BaseName(name) - if strings.EqualFold(base, candidate) && model.IsImageFile(name) { - return filepath.Join(folder, name) - } - } - } - return "" -} diff --git a/core/artwork/folders_artist_paths_test.go b/core/artwork/folders_artist_paths_test.go deleted file mode 100644 index 127661891..000000000 --- a/core/artwork/folders_artist_paths_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// commonDir and loadArtistFolder decide how far above the albums the artist folder sits. A layout -// can only show the image that won, so the arithmetic is pinned here. -var _ = Describe("commonDir", func() { - It("returns the folder itself for a single path", func() { - Expect(commonDir([]string{filepath.FromSlash("/music/artist/album")})). - To(Equal(filepath.FromSlash("/music/artist/album"))) - }) - - It("returns the deepest shared folder", func() { - Expect(commonDir([]string{ - filepath.FromSlash("/music/artist/album/cd1"), - filepath.FromSlash("/music/artist/album/cd2"), - })).To(Equal(filepath.FromSlash("/music/artist/album"))) - }) - - It("does not read a shared name fragment as a shared folder", func() { - Expect(commonDir([]string{ - filepath.FromSlash("/music/artist/Album"), - filepath.FromSlash("/music/artist/Album2"), - })).To(Equal(filepath.FromSlash("/music/artist"))) - }) -}) - -var _ = Describe("loadArtistFolder", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - repo *fakeFolderRepo - albums model.Albums - updatedAt time.Time - ) - - BeforeEach(func() { - ctx = context.Background() - updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute) - repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}} - libRepo := &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 1, Path: filepath.FromSlash("/music")}}) - ds = &tests.MockDataStore{MockedFolder: repo, MockedLibrary: libRepo} - albums = model.Albums{{LibraryID: 1, ID: "album1", Name: "Album 1"}} - }) - - It("returns empty when the artist has no albums", func() { - folder, upd, err := loadArtistFolder(ctx, ds, model.Albums{}, []string{"/dummy/path"}) - - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(BeEmpty()) - Expect(upd).To(BeZero()) - }) - - It("climbs above the album folder when the artist has a single album root", func() { - folder, upd, err := loadArtistFolder(ctx, ds, albums, - []string{filepath.FromSlash("/music/artist/album1")}) - - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) - Expect(upd).To(Equal(updatedAt)) - }) - - It("climbs above the shared folder when two albums live in the same one", func() { - folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{ - filepath.FromSlash("/music/artist/split"), - filepath.FromSlash("/music/artist/split"), - }) - - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) - Expect(upd).To(Equal(updatedAt)) - }) - - It("stops at the folder where distinct album roots already meet", func() { - folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{ - filepath.FromSlash("/music/artist/album1"), - filepath.FromSlash("/music/artist/album2"), - }) - - Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) - Expect(upd).To(Equal(updatedAt)) - }) - - It("returns the error when the folder lookup fails", func() { - repo.err = errors.New("fake error") - - folder, upd, err := loadArtistFolder(ctx, ds, albums, []string{ - filepath.FromSlash("/music/artist/album1"), - filepath.FromSlash("/music/artist/album2"), - }) - - Expect(err).To(MatchError(ContainSubstring("fake error"))) - Expect(folder).To(BeEmpty()) - Expect(upd).To(BeZero()) - }) -}) diff --git a/core/artwork/folders_artist_test.go b/core/artwork/folders_artist_test.go deleted file mode 100644 index a35e40232..000000000 --- a/core/artwork/folders_artist_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "io/fs" - "testing/fstest" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// unreadableFS globs like its embedded MapFS but refuses to open anything. Injecting the error -// keeps this independent of the filesystem: os.Chmod does not restrict read access on Windows. -type unreadableFS struct{ fstest.MapFS } - -func (u unreadableFS) Open(string) (fs.File, error) { return nil, fs.ErrPermission } - -var _ = Describe("findImageInFolder", func() { - var ctx context.Context - var files fstest.MapFS - - BeforeEach(func() { - ctx = context.Background() - files = fstest.MapFS{"artist.jpg": &fstest.MapFile{Data: []byte("img")}} - }) - - It("returns the first matching image", func() { - r, hit, err := findImageInFolder(ctx, files, ".", "/lib", "artist.*") - Expect(err).ToNot(HaveOccurred()) - defer r.Close() - Expect(hit).To(HaveSuffix("artist.jpg")) - }) - - // The glob matched, so the image exists; failing to open it says nothing about whether the - // artist has one, and must not let the resolver settle on absent. - It("reports a matched but unreadable image as unreadable, not as a miss", func() { - _, _, err := findImageInFolder(ctx, unreadableFS{files}, ".", "/lib", "artist.*") - Expect(err).To(MatchError(errSourceUnreadable)) - }) - - It("reports a plain miss when nothing matches", func() { - _, _, err := findImageInFolder(ctx, files, ".", "/lib", "nothing.*") - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, errSourceUnreadable)).To(BeFalse(), "no match is definitive, not transient") - }) -}) diff --git a/core/artwork/gate.go b/core/artwork/gate.go deleted file mode 100644 index aa2ad8f2f..000000000 --- a/core/artwork/gate.go +++ /dev/null @@ -1,171 +0,0 @@ -package artwork - -import ( - "cmp" - "context" - "errors" - "io" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "golang.org/x/time/rate" -) - -const ( - breakerThreshold = 5 - breakerProbeAfter = time.Minute - // breakerRecoveries is how many consecutive answers an open breaker needs before it trusts the - // provider again. One is not enough: a provider that is rate-limiting or blocking us still - // answers the occasional request, and closing on the first of those puts the agent straight - // back to full rate, which is what earns the next block. - breakerRecoveries = 3 -) - -var errBreakerOpen = errors.New("artwork: external circuit breaker open") - -// gateFunc gates one named external fetch (rate limit + circuit breaker per name). -type gateFunc = func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) - -func passthroughGate(_ string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - return f() -} - -// isTransientExternal reports whether an external failure is worth retrying; a not-found -// (from either package) is a definitive answer, not a fault. -func isTransientExternal(err error) bool { - return err != nil && !errors.Is(err, agents.ErrNotFound) && !errors.Is(err, model.ErrNotFound) -} - -// extGate is one agent's rate limiter and circuit breaker, so a failing provider backs off -// in isolation from the others. -type extGate struct { - limiter *rate.Limiter - breaker *breaker -} - -// gate runs a named external step through that agent's rate limiter and circuit breaker. -func (w *Worker) gate(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - g := w.gateFor(name) - allowed, gen := g.breaker.allow() - if !allowed { - log.Debug(w.runCtx, "Artwork: Skipping agent, circuit breaker open", "agent", name) - return nil, "", errBreakerOpen - } - // Timed separately so a throttled agent isn't mistaken for a slow provider. - waitStart := time.Now() - if err := g.limiter.Wait(w.runCtx); err != nil { - return nil, "", err - } - callStart := time.Now() - r, path, err := f() - g.breaker.record(name, gen, err) - log.Trace(w.runCtx, "Artwork: External agent call", "agent", name, "hit", r != nil, - "limiterWait", callStart.Sub(waitStart), "elapsed", time.Since(callStart), err) - return r, path, err -} - -// gateFor lazily creates the per-name gate on first use. -func (w *Worker) gateFor(name string) *extGate { - w.gatesMu.Lock() - defer w.gatesMu.Unlock() - if g, ok := w.gates[name]; ok { - return g - } - rps := conf.Server.DevArtworkExternalMaxRPS - limit := rate.Inf - if rps > 0 { - limit = rate.Limit(rps) - } - g := &extGate{limiter: rate.NewLimiter(limit, max(1, rps)), breaker: newBreaker()} - w.gates[name] = g - return g -} - -// breaker opens after breakerThreshold consecutive errors and admits a single probe once -// breakerProbeAfter has elapsed; it closes after breakerRecoveries consecutive answers. -type breaker struct { - mu sync.Mutex - failures int - openedAt time.Time - // recoveries counts consecutive good answers while open; a single failure discards them. - recoveries int - // generation identifies the current open episode, so an answer from a call admitted before - // the breaker opened cannot be mistaken for evidence that it has recovered. - generation int - // probeAfter overrides the probe delay for the current episode when a provider named its own - // back-off; zero falls back to breakerProbeAfter. - probeAfter time.Duration -} - -func newBreaker() *breaker { return &breaker{} } - -// allow reports whether a call may proceed, and the open episode it was admitted under: zero -// when the breaker was closed, the current generation when admitted as a half-open probe. -func (b *breaker) allow() (bool, int) { - b.mu.Lock() - defer b.mu.Unlock() - if b.failures < breakerThreshold { - return true, 0 - } - if time.Since(b.openedAt) >= cmp.Or(b.probeAfter, breakerProbeAfter) { - b.openedAt = time.Now() // start a fresh probe window so only one caller passes - return true, b.generation - } - return false, 0 -} - -func (b *breaker) record(name string, gen int, err error) { - // A cancelled run says nothing about the provider, so it neither counts nor clears. - if errors.Is(err, context.Canceled) { - return - } - b.mu.Lock() - defer b.mu.Unlock() - // An explicit back-off is a definitive "stop for this long", so it opens the breaker at once - // with the provider's own delay instead of waiting for the failure threshold. - if retry, ok := errors.AsType[*agents.RetryLaterError](err); ok && retry.RetryIn > 0 { - b.recoveries = 0 - b.failures = breakerThreshold - b.openedAt = time.Now() - b.probeAfter = retry.RetryIn - b.generation++ - log.Warn("Artwork: Circuit breaker opened for agent, provider asked to back off", "agent", name, - "probeAfter", retry.RetryIn) - return - } - if isTransientExternal(err) { - b.recoveries = 0 - b.failures++ - if b.failures == breakerThreshold { - b.openedAt = time.Now() - b.probeAfter = 0 - b.generation++ - log.Warn("Artwork: Circuit breaker opened for agent", "agent", name, - "consecutiveFailures", b.failures, "probeAfter", breakerProbeAfter, err) - } - return - } - if b.failures < breakerThreshold { - b.failures = 0 - return - } - // Only a probe from this open episode is evidence of recovery. The worker drains concurrently, - // so answers keep arriving from calls admitted before the breaker opened; counting those would - // close it with no probe interval elapsed, which is the burst this exists to prevent. - if gen == 0 || gen != b.generation { - return - } - // A not-found counts because the provider did answer, but on its own it is thin evidence that - // a provider which just blocked us is well. - b.recoveries++ - if b.recoveries < breakerRecoveries { - return - } - log.Info("Artwork: Circuit breaker closed for agent", "agent", name, - "consecutiveAnswers", b.recoveries) - b.failures, b.recoveries = 0, 0 -} diff --git a/core/artwork/gate_test.go b/core/artwork/gate_test.go deleted file mode 100644 index a954d4cac..000000000 --- a/core/artwork/gate_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package artwork - -import ( - "errors" - "time" - - "github.com/navidrome/navidrome/core/agents" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// allowed drops the generation token when a caller only cares about admission. -func allowed(b *breaker) bool { ok, _ := b.allow(); return ok } - -var _ = Describe("breaker", func() { - // The worker drains concurrently, so when the breaker opens there are already calls past - // allow(), queued in the rate limiter or waiting on a response. Their answers arrive - // afterwards. Counting those as recovery closes the breaker with no probe interval elapsed, - // which is the burst the ramp exists to prevent. No clock is involved: the race is an - // ordering, so it is reproduced by making the calls in the order concurrency produces. - It("ignores answers from calls admitted before it opened", func() { - b := newBreaker() - - // A batch clears allow() while the breaker is still closed. - for range breakerThreshold + breakerRecoveries { - ok, gen := b.allow() - Expect(ok).To(BeTrue()) - Expect(gen).To(BeZero(), "admitted with the breaker closed, so not a probe") - } - - // The fast failures in that batch open it. - for range breakerThreshold { - b.record("agentA", 0, errors.New("blocked")) - } - Expect(allowed(b)).To(BeFalse(), "breaker is open") - - // The slower answers from the same batch land now. - for range breakerRecoveries { - b.record("agentA", 0, nil) - } - - Expect(allowed(b)).To(BeFalse(), - "answers from calls admitted before the breaker opened must not close it") - }) - - It("opens at once when a provider asks to retry later, honoring its delay", func() { - b := newBreaker() - Expect(allowed(b)).To(BeTrue(), "starts closed") - - // A single explicit back-off opens the breaker without reaching the failure threshold. - b.record("agentA", 0, &agents.RetryLaterError{RetryIn: 5 * time.Second}) - - Expect(allowed(b)).To(BeFalse(), "an explicit back-off opens the breaker immediately") - Expect(b.probeAfter).To(Equal(5*time.Second), "the provider's delay drives the probe interval") - }) -}) diff --git a/core/artwork/hash_encoder_bench_test.go b/core/artwork/hash_encoder_bench_test.go deleted file mode 100644 index 2bc4b90ba..000000000 --- a/core/artwork/hash_encoder_bench_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package artwork - -import ( - "fmt" - "image" - "testing" - - "github.com/navidrome/navidrome/core/artwork/blurhash" - "github.com/navidrome/navidrome/core/artwork/thumbhash" -) - -// hashEncoders are the two placeholder hashes decodeArtwork computes from one shared thumbnail. -var hashEncoders = []struct { - name string - encode func(image.Image) error -}{ - {"blurhash", func(img image.Image) error { _, err := blurhash.Encode(img); return err }}, - {"thumbhash", func(img image.Image) error { _, err := thumbhash.Encode(img); return err }}, -} - -func benchEncoder(b *testing.B, encode func(image.Image) error, img image.Image) { - b.Helper() - b.ReportAllocs() - for b.Loop() { - if err := encode(img); err != nil { - b.Fatal(err) - } - } -} - -// BenchmarkHashEncodersAtInputSize is the bar: both encoders are handed the identical image -// makeThumbnail produces, so neither is measured with a conversion the other avoids. -func BenchmarkHashEncodersAtInputSize(b *testing.B) { - img := gradientNRGBA(thumbnailSize) - for _, e := range hashEncoders { - b.Run(e.name, func(b *testing.B) { benchEncoder(b, e.encode, img) }) - } -} - -// BenchmarkHashEncoders sweeps past the pipeline's input size, where each package's own defensive -// downscale starts to dominate. thumbnailSize itself is covered by the benchmark above. -func BenchmarkHashEncoders(b *testing.B) { - for _, size := range []int{300, 600, 900, 1200, 1500} { - img := gradientNRGBA(size) - for _, e := range hashEncoders { - b.Run(fmt.Sprintf("%s/%dx%d", e.name, size, size), func(b *testing.B) { - benchEncoder(b, e.encode, img) - }) - } - } -} diff --git a/core/artwork/housekeeping.go b/core/artwork/housekeeping.go deleted file mode 100644 index 5ec701541..000000000 --- a/core/artwork/housekeeping.go +++ /dev/null @@ -1,173 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "slices" - "strconv" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "github.com/zeebo/xxh3" -) - -// ReprocessKinds omits media files: they resolve embedded-only, at scan or on view. Artists lead -// so bulk enqueues give the most external-dependent kind a queue headstart. -var ReprocessKinds = []model.Kind{ - model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindRadioArtwork, -} - -// KeepsState reports whether a kind is recorded in item_artwork and the artwork queue. Disc -// artwork is read through on every request and cached by content key, so it has neither. -func KeepsState(kind model.Kind) bool { return kind != model.KindDiscArtwork } - -// RefreshableKinds is every kind Refresh can clear and re-queue, so it holds exactly the kinds -// KeepsState admits. Media files are absent from ReprocessKinds but belong here: the worker -// resolves them, it just never enumerates them in bulk. -var RefreshableKinds = append(slices.Clone(ReprocessKinds), model.KindMediaFileArtwork) - -// settlesAbsentOnGiveUp reports whether an exhausted retry budget records an absent state. Media -// files are excluded because retrying one costs nothing: they resolve embedded-only, from a local -// read, and only a view ever enqueues them. -func settlesAbsentOnGiveUp(prefix string) bool { - kind, ok := model.ParseKind(prefix) - return ok && KeepsState(kind) && kind != model.KindMediaFileArtwork -} - -// artworkEpoch invalidates all resolution state when bumped; bump it whenever resolution semantics change. -const artworkEpoch = 1 - -// FingerprintInput is one config value the fingerprint covers, named after the setting it came from. -type FingerprintInput struct { - Name string - Value string -} - -// FingerprintInputs is the single listing of what ConfigFingerprint hashes. -func FingerprintInputs() []FingerprintInput { - return []FingerprintInput{ - {"CoverArtPriority", conf.Server.CoverArtPriority}, - {"ArtistArtPriority", conf.Server.ArtistArtPriority}, - {"ArtistImageFolder", conf.Server.ArtistImageFolder}, - {"Agents", conf.Server.Agents}, - {"EnableExternalServices", strconv.FormatBool(conf.Server.EnableExternalServices)}, - {"EnableM3UExternalAlbumArt", strconv.FormatBool(conf.Server.EnableM3UExternalAlbumArt)}, - } -} - -// ConfigFingerprint covers the inputs that affect resolution outcomes; a change invalidates stored state. -func ConfigFingerprint() string { - values := slice.Map(FingerprintInputs(), func(i FingerprintInput) string { return i.Value }) - raw := fmt.Sprintf("%s|%d", strings.Join(values, "|"), artworkEpoch) - return fmt.Sprintf("%016x", xxh3.Hash([]byte(raw))) -} - -// ReconcileConfigFingerprint warns when the artwork config changed since the library was last -// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess. -func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error { - current := ConfigFingerprint() - stored, err := ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, "") - if err != nil { - return err - } - switch stored { - case current: - case "": - // An unset fingerprint counts as current; the alternative warns every upgrading install once. - return MarkConfigApplied(ctx, ds) - default: - log.Warn(ctx, "Artwork: Config changed since the last full reprocess. Stored artwork keeps "+ - "the old resolution; run 'navidrome artwork reprocess --all' to apply the change", - "stored", stored, "current", current, "inputs", FingerprintInputs()) - } - return nil -} - -// MarkConfigApplied records the current fingerprint as the one the library is resolved under. -func MarkConfigApplied(ctx context.Context, ds model.DataStore) error { - return ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, ConfigFingerprint()) -} - -// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off). -func enqueueMissingAll(ctx context.Context, ds model.DataStore) error { - queue := ds.ArtworkQueue() - for _, kind := range ReprocessKinds { - if _, err := queue.EnqueueAllMissing(ctx, kind, model.ArtworkPriorityRecheck); err != nil { - return err - } - } - return nil -} - -// ItemName resolves a kind+id to the entity's display name, and errors when the item -// does not exist. Callers use it to reject ids that would otherwise orphan a queue row. -func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) { - switch kind { - case model.KindArtistArtwork: - ar, err := ds.Artist().Get(ctx, id) - if err != nil { - return "", err - } - return ar.Name, nil - case model.KindAlbumArtwork: - al, err := ds.Album().Get(ctx, id) - if err != nil { - return "", err - } - return al.Name, nil - case model.KindPlaylistArtwork: - pls, err := ds.Playlist().Get(ctx, id) - if err != nil { - return "", err - } - return pls.Name, nil - case model.KindRadioArtwork: - rd, err := ds.Radio().Get(ctx, id) - if err != nil { - return "", err - } - return rd.Name, nil - case model.KindMediaFileArtwork: - mf, err := ds.MediaFile().Get(ctx, id) - if err != nil { - return "", err - } - return mf.Title, nil - case model.KindDiscArtwork: - return discArtworkName(ctx, ds, id) - } - return "", fmt.Errorf("unsupported kind %q", kind.Prefix()) -} - -func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string, error) { - albumID, discNumber, err := model.ParseDiscArtworkID(id) - if err != nil { - return "", err - } - al, err := ds.Album().Get(ctx, albumID) - if err != nil { - return "", err - } - name := fmt.Sprintf("%s (disc %d)", al.Name, discNumber) - // The subtitle is itself a DiscArtPriority candidate, so name it where the chain can be read against it. - if subtitle := strings.TrimSpace(al.Discs[discNumber]); subtitle != "" { - name += ": " + subtitle - } - return name, nil -} - -// Refresh drops an item's resolved artwork state and re-queues it at Bump priority. -func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error { - if err := ds.Artwork().DeleteForItems(ctx, kind, []string{id}); err != nil { - return fmt.Errorf("clearing artwork state: %w", err) - } - item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump} - if err := ds.ArtworkQueue().Enqueue(ctx, item); err != nil { - return fmt.Errorf("enqueuing artwork refresh: %w", err) - } - return nil -} diff --git a/core/artwork/housekeeping_test.go b/core/artwork/housekeeping_test.go deleted file mode 100644 index 0f501d0b1..000000000 --- a/core/artwork/housekeeping_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package artwork - -import ( - "context" - "slices" - - "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/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("RefreshableKinds", func() { - // The two are meant to describe the same fact. Nothing but this test stops them from drifting, - // and a drift would have `artwork explain` report state for a kind that keeps none. - It("holds exactly the kinds that keep state", func() { - for _, k := range []model.Kind{ - model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, - model.KindRadioArtwork, model.KindMediaFileArtwork, model.KindDiscArtwork, - } { - Expect(slices.Contains(RefreshableKinds, k)).To(Equal(KeepsState(k)), k.String()) - } - }) -}) - -var _ = Describe("Housekeeping", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - queueRepo *tests.MockArtworkQueueRepo - propRepo *tests.MockedPropertyRepo - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = context.Background() - conf.Server.CoverArtPriority = "embedded, folder" - conf.Server.ArtistArtPriority = "artist.jpg" - conf.Server.Agents = "spotify" - conf.Server.EnableExternalServices = true - - queueRepo = tests.CreateMockArtworkQueueRepo() - propRepo = &tests.MockedPropertyRepo{} - ds = &tests.MockDataStore{MockedArtworkQueue: queueRepo, MockedProperty: propRepo} - }) - - Describe("Fingerprint", func() { - DescribeTable("changes when a fingerprint-affecting config value changes", - func(change func()) { - before := ConfigFingerprint() - change() - Expect(ConfigFingerprint()).NotTo(Equal(before)) - }, - Entry("CoverArtPriority", func() { conf.Server.CoverArtPriority = "folder, embedded" }), - Entry("ArtistImageFolder", func() { conf.Server.ArtistImageFolder = "/after" }), - Entry("EnableM3UExternalAlbumArt", func() { conf.Server.EnableM3UExternalAlbumArt = true }), - ) - - // Pinned: a changed formula tells every existing install its artwork config went stale. - It("hashes a given config to a stable value", func() { - conf.Server.CoverArtPriority = "cover.*, embedded" - conf.Server.ArtistArtPriority = "artist.*, external" - conf.Server.ArtistImageFolder = "" - conf.Server.Agents = "lastfm,spotify" - conf.Server.EnableExternalServices = true - conf.Server.EnableM3UExternalAlbumArt = false - - Expect(ConfigFingerprint()).To(Equal("7b538a83a870c16d")) - }) - - It("reports the config inputs it hashes, so a change can be traced to a setting", func() { - conf.Server.Agents = "lastfm,spotify" - conf.Server.CoverArtPriority = "cover.*, embedded" - - Expect(FingerprintInputs()).To(ContainElements( - FingerprintInput{Name: "Agents", Value: "lastfm,spotify"}, - FingerprintInput{Name: "CoverArtPriority", Value: "cover.*, embedded"}, - )) - }) - - It("does not change when the server version changes", func() { - original := consts.Version - DeferCleanup(func() { consts.Version = original }) - f1 := ConfigFingerprint() - consts.Version = original + "-next" - Expect(ConfigFingerprint()).To(Equal(f1), - "the version must not invalidate artwork state: every build would report a stale config") - }) - }) - - Describe("ReconcileConfigFingerprint", func() { - It("records the current fingerprint when none was ever stored", func() { - Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed()) - - Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint())) - }) - - It("leaves a stale fingerprint stored, so the warning survives a restart", func() { - Expect(propRepo.Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed()) - - Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed()) - - Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint")) - }) - }) - - Describe("EnqueueMissingAll", func() { - var artRepo *tests.MockArtworkRepo - - BeforeEach(func() { - artRepo = tests.CreateMockArtworkRepo() - ds.MockedArtwork = artRepo - queueRepo.ItemArtworkSource = artRepo - queueRepo.ExistingIDs = map[string]map[string]bool{ - "al": {"al1": true, "al2": true}, - "ar": {"ar1": true}, - "pl": {"pl1": true}, - "ra": {"ra1": true}, - } - }) - - It("enqueues only entities that have no item_artwork row, across all kinds", func() { - artRepo.ItemData["al-resolved"] = model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "somehash"} - artRepo.ItemData["ar-absent"] = model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: ""} - - err := enqueueMissingAll(ctx, ds) - Expect(err).ToNot(HaveOccurred()) - - for _, it := range queueRepo.Data { - Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) - } - Expect(findQueued(queueRepo, "al", "al2")).ToNot(BeNil()) - Expect(findQueued(queueRepo, "pl", "pl1")).ToNot(BeNil()) - Expect(findQueued(queueRepo, "ra", "ra1")).ToNot(BeNil()) - Expect(findQueued(queueRepo, "al", "al1")).To(BeNil()) - Expect(findQueued(queueRepo, "ar", "ar1")).To(BeNil()) - }) - }) -}) - -var _ = Describe("ItemName", func() { - var ds *tests.MockDataStore - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - albumRepo := tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{ - {ID: "al-1", Name: "Kid A"}, - {ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}}, - }) - ds = &tests.MockDataStore{MockedAlbum: albumRepo} - Expect(ds.Artist().(*tests.MockArtistRepo).Put(ctx, &model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed()) - }) - - It("returns the album name", func() { - Expect(ItemName(ctx, ds, model.KindAlbumArtwork, "al-1")).To(Equal("Kid A")) - }) - - It("returns the artist name", func() { - Expect(ItemName(ctx, ds, model.KindArtistArtwork, "ar-1")).To(Equal("Radiohead")) - }) - - It("errors for an unknown album", func() { - _, err := ItemName(ctx, ds, model.KindAlbumArtwork, "nope") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("errors for an unsupported kind", func() { - // model.Kind is a struct with unexported fields, so the zero value is the only - // unsupported Kind constructible from outside package model. - _, err := ItemName(ctx, ds, model.Kind{}, "al-1") - Expect(err).To(HaveOccurred()) - }) - - Context("disc artwork", func() { - It("names the album, the disc and its subtitle", func() { - Expect(ItemName(ctx, ds, model.KindDiscArtwork, "al-2:2")). - To(Equal("Sandinista! (disc 2): Side Three")) - }) - - It("omits the subtitle when the disc has none", func() { - Expect(ItemName(ctx, ds, model.KindDiscArtwork, "al-2:1")). - To(Equal("Sandinista! (disc 1)")) - }) - - It("rejects an id that is not :", func() { - _, err := ItemName(ctx, ds, model.KindDiscArtwork, "al-2") - Expect(err).To(HaveOccurred()) - }) - }) -}) diff --git a/core/artwork/image_cache.go b/core/artwork/image_cache.go index b1970d21d..ac0f63794 100644 --- a/core/artwork/image_cache.go +++ b/core/artwork/image_cache.go @@ -1,22 +1,30 @@ package artwork import ( - "bytes" "context" + "fmt" "io" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/singleton" ) -// artworkReader is the cache.Item the image cache loader dispatches on: Reader -// produces the (possibly resized) bytes to store under Key. -type artworkReader interface { - cache.Item - Reader(ctx context.Context) (io.ReadCloser, error) +type cacheKey struct { + artID model.ArtworkID + lastUpdate time.Time +} + +func (k *cacheKey) Key() string { + return fmt.Sprintf( + "%s-%s.%d", + k.artID.Kind, + k.artID.ID, + k.lastUpdate.UnixMilli(), + ) } type imageCache struct { @@ -28,49 +36,9 @@ func GetImageCache() cache.FileCache { return &imageCache{ FileCache: cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems, func(ctx context.Context, arg cache.Item) (io.Reader, error) { - return arg.(artworkReader).Reader(ctx) + r, _, err := arg.(artworkReader).Reader(ctx) + return r, err }), } }) } - -// resizedItem is an artworkReader that resizes bytes opened by open() and caches the -// result under a hash-derived key. -type resizedItem struct { - hash string - size int - square bool - ffmpeg ffmpeg.FFmpeg - open func() (io.ReadCloser, error) -} - -// Key is the ETag namespaced for the cache, so the validator a client holds and the entry it -// validates can never drift apart. -func (r *resizedItem) Key() string { - return "h-" + representationTag(r.hash, r.size, r.square) -} - -func (r *resizedItem) Reader(ctx context.Context) (io.ReadCloser, error) { - orig, err := r.open() - if err != nil { - return nil, err - } - // An open() that reports "no image" as a nil reader would otherwise panic on the Close below. - if orig == nil { - return nil, ErrUnavailable - } - defer orig.Close() - data, err := readCapped(orig) - if err != nil { - return nil, err - } - resized, _, err := resizeImageData(ctx, r.ffmpeg, data, r.size, r.square) - if err != nil || resized == nil { - // Resize failed or image already within bounds: serve the original bytes. - return io.NopCloser(bytes.NewReader(data)), nil - } - if rc, ok := resized.(io.ReadCloser); ok { - return rc, nil - } - return io.NopCloser(resized), nil -} diff --git a/core/artwork/image_cache_test.go b/core/artwork/image_cache_test.go deleted file mode 100644 index a74c51088..000000000 --- a/core/artwork/image_cache_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "io" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("resizedItem", func() { - Describe("Reader", func() { - newItem := func(open func() (io.ReadCloser, error)) *resizedItem { - return &resizedItem{hash: "abc123", size: 300, open: open} - } - - It("reports a nil reader as unavailable instead of panicking on it", func() { - // Every caller is expected to report "no image" as an error, but a nil reader reaches - // the deferred Close as a nil interface, which takes the whole request down. - _, err := newItem(func() (io.ReadCloser, error) { return nil, nil }).Reader(context.Background()) - Expect(err).To(MatchError(ErrUnavailable)) - }) - - It("propagates the open error", func() { - boom := errors.New("boom") - _, err := newItem(func() (io.ReadCloser, error) { return nil, boom }).Reader(context.Background()) - Expect(err).To(MatchError(boom)) - }) - - It("serves the original bytes when they cannot be resized", func() { - rc, err := newItem(func() (io.ReadCloser, error) { - return io.NopCloser(strings.NewReader("not an image")), nil - }).Reader(context.Background()) - Expect(err).ToNot(HaveOccurred()) - defer rc.Close() - Expect(io.ReadAll(rc)).To(Equal([]byte("not an image"))) - }) - }) -}) diff --git a/core/artwork/image_store.go b/core/artwork/image_store.go deleted file mode 100644 index 2ded3e636..000000000 --- a/core/artwork/image_store.go +++ /dev/null @@ -1,158 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "fmt" - "io" - "io/fs" - "os" - "path/filepath" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/zeebo/xxh3" -) - -// ImageStore is the content-addressed store for artwork images with no library file backing them. -type ImageStore struct { - root string -} - -func NewImageStore(rootDir string) *ImageStore { - return &ImageStore{root: rootDir} -} - -func GetImageStore() *ImageStore { - return NewImageStore(filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, consts.HashedArtworkFolder)) -} - -// extForMime must stay stable across OSes: extensions are baked into stored paths and re-derived on Open. -func extForMime(m string) string { - switch m { - case "image/jpeg": - return ".jpg" - case "image/png": - return ".png" - case "image/gif": - return ".gif" - case "image/webp": - return ".webp" - } - return ".img" -} - -func hashImage(r io.Reader) (string, error) { - d := xxh3.New() - if _, err := io.Copy(d, r); err != nil { - return "", err - } - return fmt.Sprintf("%016x", d.Sum64()), nil -} - -// validHash guards path sharding: a malformed hash would slice-panic or inject path separators. -func validHash(hash string) bool { - if len(hash) != 16 { - return false - } - for _, c := range []byte(hash) { - if (c < '0' || c > '9') && (c < 'a' || c > 'f') { - return false - } - } - return true -} - -func (s *ImageStore) path(hash, mimeType string) string { - return filepath.Join(s.root, hash[0:2], hash[2:4], hash+extForMime(mimeType)) -} - -func (s *ImageStore) Write(hash, mimeType string, r io.Reader) error { - if !validHash(hash) { - return fmt.Errorf("imagestore: invalid hash %q", hash) - } - dst := s.path(hash, mimeType) - if _, err := os.Stat(dst); err == nil { - // A touched mtime marks the file live so a concurrent prune spares it. - now := time.Now() - if err := os.Chtimes(dst, now, now); err == nil { - return nil - } - // touch failed (likely pruned concurrently) — fall through and rewrite it - } - if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { - return err - } - tmp, err := os.CreateTemp(filepath.Dir(dst), "."+hash+".tmp*") - if err != nil { - return err - } - defer os.Remove(tmp.Name()) - if _, err := io.Copy(tmp, r); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - if err := os.Chmod(tmp.Name(), 0640); err != nil { - return err - } - return os.Rename(tmp.Name(), dst) -} - -func (s *ImageStore) Open(hash, mimeType string) (io.ReadCloser, error) { - if !validHash(hash) { - return nil, fmt.Errorf("imagestore: invalid hash %q", hash) - } - return os.Open(s.path(hash, mimeType)) -} - -// Sweep removes store files not accepted by keep. Files modified after cutoff are always -// kept: their acquisition row may not be committed yet. -func (s *ImageStore) Sweep(ctx context.Context, cutoff time.Time, keep func(hash, ext string) bool) (int, error) { - removed, failed := 0, 0 - var lastErr error - err := filepath.WalkDir(s.root, func(path string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - if err := ctx.Err(); err != nil { - return err - } - info, err := d.Info() - if err != nil { - return err - } - if info.ModTime().After(cutoff) { - return nil - } - name := d.Name() - remove := strings.HasPrefix(name, ".") // abandoned temp file past the grace window - if !remove { - ext := filepath.Ext(name) - remove = !keep(strings.TrimSuffix(name, ext), ext) - } - if remove { - // #nosec G122 -- path comes from WalkDir over our own store root, no attacker-controlled symlinks - if err := os.Remove(path); err != nil { - // One unremovable file must not strand the rest of the store until the next prune. - failed, lastErr = failed+1, err - return nil //nolint:nilerr // counted and reported in aggregate below - } - removed++ - } - return nil - }) - // Aggregated: a store that has gone read-only would otherwise warn once per file, every prune. - if failed > 0 { - log.Warn(ctx, "Artwork: Could not remove store files", "count", failed, "swept", removed, lastErr) - } - if errors.Is(err, fs.ErrNotExist) { - return removed, nil - } - return removed, err -} diff --git a/core/artwork/image_store_test.go b/core/artwork/image_store_test.go deleted file mode 100644 index 7de3ba761..000000000 --- a/core/artwork/image_store_test.go +++ /dev/null @@ -1,239 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "io" - "os" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("ImageStore", func() { - var store *ImageStore - var root string - var ctx context.Context - - BeforeEach(func() { - root = GinkgoT().TempDir() - store = NewImageStore(root) - ctx = context.Background() - }) - - It("hashes deterministically", func() { - h1, err := hashImage(bytes.NewReader([]byte("some image bytes"))) - Expect(err).ToNot(HaveOccurred()) - h2, _ := hashImage(bytes.NewReader([]byte("some image bytes"))) - Expect(h1).To(Equal(h2)) - Expect(h1).To(HaveLen(16)) - h3, _ := hashImage(bytes.NewReader([]byte("other bytes"))) - Expect(h3).ToNot(Equal(h1)) - }) - - It("writes sharded and reads back", func() { - data := []byte("jpeg-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - - Expect(filepath.Join(root, h[0:2], h[2:4], h+".jpg")).To(BeAnExistingFile()) - - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - defer rc.Close() - got, _ := io.ReadAll(rc) - Expect(got).To(Equal(data)) - }) - - It("writes group-readable image files", func() { - tests.SkipOnWindows("uses Unix file permission bits") - data := []byte("jpeg-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - - info, err := os.Stat(store.path(h, "image/jpeg")) - Expect(err).ToNot(HaveOccurred()) - Expect(info.Mode().Perm()).To(Equal(os.FileMode(0640))) - }) - - It("is idempotent on duplicate writes and preserves the original content", func() { - data := []byte("dup") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) - // A duplicate write only touches mtime; passing different bytes under the same - // hash proves the second reader is never consumed to overwrite the file. - Expect(store.Write(h, "image/png", bytes.NewReader([]byte("not-dup")))).To(Succeed()) - - rc, err := store.Open(h, "image/png") - Expect(err).ToNot(HaveOccurred()) - defer rc.Close() - got, err := io.ReadAll(rc) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(Equal(data)) - }) - - It("refreshes the mtime on a duplicate write", func() { - data := []byte("touch-me") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) - old := time.Now().Add(-2 * time.Hour) - Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) - - Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) - - info, err := os.Stat(store.path(h, "image/png")) - Expect(err).ToNot(HaveOccurred()) - Expect(info.ModTime()).To(BeTemporally(">", time.Now().Add(-time.Minute))) - }) - - It("rewrites the bytes when the existing file vanished before the liveness touch", func() { - data := []byte("vanishing") - h, _ := hashImage(bytes.NewReader(data)) - for range 10 { - Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) - Expect(os.Remove(store.path(h, "image/png"))).To(Succeed()) - Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) - - rc, err := store.Open(h, "image/png") - Expect(err).ToNot(HaveOccurred()) - got, _ := io.ReadAll(rc) - rc.Close() - Expect(got).To(Equal(data)) - } - }) - - It("returns fs.ErrNotExist for missing images", func() { - _, err := store.Open("beefbeefbeefbeef", "image/jpeg") - Expect(os.IsNotExist(err)).To(BeTrue()) - }) - - It("rejects invalid hashes instead of panicking", func() { - for _, h := range []string{"", "ab", "BEEFBEEFBEEFBEEF", "../../../../etcpw", "beefbeefbeefbee/"} { - Expect(store.Write(h, "image/jpeg", bytes.NewReader([]byte("x")))).To(MatchError(ContainSubstring("invalid hash"))) - _, err := store.Open(h, "image/jpeg") - Expect(err).To(MatchError(ContainSubstring("invalid hash"))) - } - }) - - It("sweeps unknown files, keeps known ones", func() { - d1 := []byte("keep-me") - h1, _ := hashImage(bytes.NewReader(d1)) - Expect(store.Write(h1, "image/jpeg", bytes.NewReader(d1))).To(Succeed()) - d2 := []byte("orphan") - h2, _ := hashImage(bytes.NewReader(d2)) - Expect(store.Write(h2, "image/jpeg", bytes.NewReader(d2))).To(Succeed()) - - old := time.Now().Add(-2 * time.Hour) - Expect(os.Chtimes(store.path(h2, "image/jpeg"), old, old)).To(Succeed()) - - removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(h, _ string) bool { return h == h1 }) - Expect(err).ToNot(HaveOccurred()) - Expect(removed).To(Equal(1)) - _, err = store.Open(h2, "image/jpeg") - Expect(os.IsNotExist(err)).To(BeTrue()) - rc, err := store.Open(h1, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("sweeps a stale mime variant of a known hash, keeps the current one", func() { - data := []byte("same-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - old := time.Now().Add(-2 * time.Hour) - Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) - Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) - - // The recorded mime is image/jpeg, so the .png variant is obsolete. - removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(hash, ext string) bool { - return hash == h && ext == ".jpg" - }) - Expect(err).ToNot(HaveOccurred()) - Expect(removed).To(Equal(1)) - _, err = store.Open(h, "image/png") - Expect(os.IsNotExist(err)).To(BeTrue()) - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("keeps young unknown files inside the grace window", func() { - d := []byte("fresh-orphan") - h, _ := hashImage(bytes.NewReader(d)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(d))).To(Succeed()) - - removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return false }) - Expect(err).ToNot(HaveOccurred()) - Expect(removed).To(Equal(0)) - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("removes abandoned temp files past the grace window, keeps fresh ones", func() { - oldTmp := filepath.Join(root, ".old.tmp") - Expect(os.WriteFile(oldTmp, []byte("x"), 0600)).To(Succeed()) - old := time.Now().Add(-2 * time.Hour) - Expect(os.Chtimes(oldTmp, old, old)).To(Succeed()) - - freshTmp := filepath.Join(root, ".fresh.tmp") - Expect(os.WriteFile(freshTmp, []byte("y"), 0600)).To(Succeed()) - - removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return true }) - Expect(err).ToNot(HaveOccurred()) - Expect(removed).To(Equal(1)) - Expect(oldTmp).ToNot(BeAnExistingFile()) - Expect(freshTmp).To(BeAnExistingFile()) - }) - - It("keeps sweeping past a file it cannot remove", func() { - tests.SkipOnWindows("uses Unix file permission bits") - if os.Geteuid() == 0 { - Skip("read-only dir cannot block root (e.g. tests in a container)") - } - old := time.Now().Add(-2 * time.Hour) - // "blocked" sorts before "ok", so the walk hits the unremovable file first. - blockedDir := filepath.Join(root, "blocked") - Expect(os.MkdirAll(blockedDir, 0755)).To(Succeed()) - blocked := filepath.Join(blockedDir, "a.jpg") - Expect(os.WriteFile(blocked, []byte("x"), 0600)).To(Succeed()) - Expect(os.Chtimes(blocked, old, old)).To(Succeed()) - - okDir := filepath.Join(root, "ok") - Expect(os.MkdirAll(okDir, 0755)).To(Succeed()) - reachable := filepath.Join(okDir, "b.jpg") - Expect(os.WriteFile(reachable, []byte("y"), 0600)).To(Succeed()) - Expect(os.Chtimes(reachable, old, old)).To(Succeed()) - - Expect(os.Chmod(blockedDir, 0500)).To(Succeed()) - DeferCleanup(func() { _ = os.Chmod(blockedDir, 0755) }) - - removed, err := store.Sweep(ctx, time.Now().Add(-time.Hour), func(string, string) bool { return false }) - Expect(err).ToNot(HaveOccurred()) - Expect(removed).To(Equal(1)) - Expect(blocked).To(BeAnExistingFile()) - Expect(reachable).ToNot(BeAnExistingFile()) - }) - - // Prune holds the worker's write lock for the whole sweep, and shutdown waits on the - // worker, so an uncancellable walk over a large store stalls it until SIGKILL. - It("abandons the walk when the context is cancelled", func() { - old := time.Now().Add(-2 * time.Hour) - for _, name := range []string{"a", "b", "c", "d"} { - p := filepath.Join(root, name+".jpg") - Expect(os.WriteFile(p, []byte("x"), 0600)).To(Succeed()) - Expect(os.Chtimes(p, old, old)).To(Succeed()) - } - cancelCtx, cancel := context.WithCancel(ctx) - cancel() - - _, err := store.Sweep(cancelCtx, time.Now().Add(-time.Hour), func(string, string) bool { return false }) - Expect(err).To(MatchError(context.Canceled)) - matches, _ := filepath.Glob(filepath.Join(root, "*.jpg")) - Expect(matches).To(HaveLen(4), "a cancelled sweep must not keep deleting") - }) -}) diff --git a/core/artwork/library_fs.go b/core/artwork/library_fs.go deleted file mode 100644 index 84e088c68..000000000 --- a/core/artwork/library_fs.go +++ /dev/null @@ -1,64 +0,0 @@ -package artwork - -import ( - "context" - "net/url" - "path/filepath" - "strings" - - "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().Get(ctx, 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: localOSRoot(lib.Path)}, nil -} - -// localOSRoot maps a library path to its on-disk root so Abs() yields paths os.Open/os.Stat accept: -// a file:// URL becomes its parsed OS path (bare paths already are; non-local schemes stay unchanged). -func localOSRoot(libPath string) string { - if !strings.Contains(libPath, "://") { - return libPath - } - u, err := url.Parse(libPath) - if err != nil || u.Scheme != storage.LocalSchemaID { - return libPath - } - // Windows drive URLs (file://C:/Music) put the volume in Host; rejoin it, matching - // core/storage/local's newLocalStorage so os.Open/os.Stat get a valid path. - if filepath.VolumeName(u.Host) != "" { - return filepath.Join(u.Host, u.Path) - } - return u.Path -} diff --git a/core/artwork/library_fs_test.go b/core/artwork/library_fs_test.go deleted file mode 100644 index 868c5e45a..000000000 --- a/core/artwork/library_fs_test.go +++ /dev/null @@ -1,52 +0,0 @@ -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().Put(ctx, &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("normalizes a library path to an OS root that Abs can join for os.Open/os.Stat", func() { - // file:// URLs become their parsed OS path; bare paths and non-local schemes are unchanged. - Expect(localOSRoot("file:///music/library")).To(Equal("/music/library")) - Expect(localOSRoot("/music/library")).To(Equal("/music/library")) - Expect(localOSRoot("fake:///music")).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().Put(ctx, &model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed()) - _, err := loadLibraryView(ctx, ds, 2) - Expect(err).To(HaveOccurred()) - }) -}) diff --git a/core/artwork/playlist_cover.go b/core/artwork/playlist_cover.go deleted file mode 100644 index e74b64808..000000000 --- a/core/artwork/playlist_cover.go +++ /dev/null @@ -1,116 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "image" - "image/draw" - "image/png" - "io" - "os" - "path/filepath" - "strings" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils" - xdraw "golang.org/x/image/draw" -) - -const tileSize = 600 - -// findPlaylistSidecarPath finds an image beside plsPath with the same base name (case-insensitive). -func findPlaylistSidecarPath(ctx context.Context, plsPath string) string { - if plsPath == "" { - return "" - } - dir := filepath.Dir(plsPath) - base := strings.TrimSuffix(filepath.Base(plsPath), filepath.Ext(plsPath)) - - entries, err := os.ReadDir(dir) - if err != nil { - log.Warn(ctx, "Artwork: Could not read directory for playlist sidecar", "dir", dir, err) - return "" - } - for _, entry := range entries { - name := entry.Name() - nameBase := utils.BaseName(name) - if !entry.IsDir() && strings.EqualFold(nameBase, base) && model.IsImageFile(name) { - return filepath.Join(dir, name) - } - } - return "" -} - -func rect(pos int) image.Rectangle { - r := image.Rectangle{} - switch pos { - case 1: - r.Min.X = tileSize / 2 - case 2: - r.Min.Y = tileSize / 2 - case 3: - r.Min.X = tileSize / 2 - r.Min.Y = tileSize / 2 - } - r.Max.X = r.Min.X + tileSize/2 - r.Max.Y = r.Min.Y + tileSize/2 - return r -} - -// fillCenter center-crops src and scales it to fill dstW x dstH exactly. -func fillCenter(src image.Image, dstW, dstH int) image.Image { - srcBounds := src.Bounds() - srcW := srcBounds.Dx() - srcH := srcBounds.Dy() - - srcAspect := float64(srcW) / float64(srcH) - dstAspect := float64(dstW) / float64(dstH) - - var cropRect image.Rectangle - if srcAspect > dstAspect { - cropW := int(float64(srcH) * dstAspect) - cropX := (srcW - cropW) / 2 - cropRect = image.Rect(srcBounds.Min.X+cropX, srcBounds.Min.Y, srcBounds.Min.X+cropX+cropW, srcBounds.Max.Y) - } else { - cropH := int(float64(srcW) / dstAspect) - cropY := (srcH - cropH) / 2 - cropRect = image.Rect(srcBounds.Min.X, srcBounds.Min.Y+cropY, srcBounds.Max.X, srcBounds.Min.Y+cropY+cropH) - } - - dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) - xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil) - return dst -} - -// decodeTile runs before the processor's size guards apply, so it enforces the caps itself. -func decodeTile(r io.ReadCloser) (image.Image, error) { - data, err := readCapped(r) - if err != nil { - return nil, err - } - img, _, err := decodeCapped(data) - if err != nil { - return nil, err - } - return fillCenter(img, tileSize/2, tileSize/2), nil -} - -func assembleTiles(tiles []image.Image) (io.ReadCloser, error) { - buf := new(bytes.Buffer) - var err error - if len(tiles) == 4 { - rgba := image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}}) - draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src) - draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src) - draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src) - draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src) - err = png.Encode(buf, rgba) - } else { - err = png.Encode(buf, tiles[0]) - } - if err != nil { - return nil, err - } - return io.NopCloser(buf), nil -} diff --git a/core/artwork/processor.go b/core/artwork/processor.go deleted file mode 100644 index 78e625e94..000000000 --- a/core/artwork/processor.go +++ /dev/null @@ -1,356 +0,0 @@ -package artwork - -import ( - "bytes" - "cmp" - "context" - "encoding/base64" - "errors" - "fmt" - "image" - "image/draw" - _ "image/gif" // the only artwork format with no other importer in this package - "io" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/artwork/blurhash" - "github.com/navidrome/navidrome/core/artwork/dominant" - "github.com/navidrome/navidrome/core/artwork/thumbhash" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - xdraw "golang.org/x/image/draw" -) - -// outcome tells the worker whether to delete the queue row (found/absent) or reschedule it. -type outcome int - -const ( - outcomeFound outcome = iota - // outcomeFoundStale: state was written and is served, but a higher-priority external - // source failed, so the row must retry to give it another chance. - outcomeFoundStale - outcomeAbsent - outcomeFailed -) - -func (o outcome) String() string { - switch o { - case outcomeFound: - return "found" - case outcomeFoundStale: - return "foundStale" - case outcomeAbsent: - return "absent" - default: - return "failed" - } -} - -// thumbnailSize is the max dimension fed to both hash encoders; thumbhash rejects anything larger. -const thumbnailSize = 100 - -// maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could point at -// an arbitrarily large endpoint. -func maxImageBytes() int64 { - return parseSize(conf.Server.MaxImageSize, consts.DefaultMaxImageSize) -} - -// maxImagePixels guards against decompression bombs: a tiny file can declare a canvas that -// image.Decode would expand into gigabytes. -const maxImagePixels = 64 << 20 - -// acquired is what a successful acquire persisted, handed back so the caller can warm the resize -// cache without re-reading the rows and the file it just wrote. -type acquired struct { - ia *model.ItemArtwork - mime string - data []byte -} - -// processor turns one queue item into stored artwork; settling the queue row is the Worker's job. -type processor struct { - ds model.DataStore - store *ImageStore - resolver *resolver - pruneLock sync.Locker -} - -// acquire resolves one queue item end to end: find an image, hash/decode/ -// blurhash it, place its bytes, and persist the resulting state. -func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) { - repo := p.ds.Artwork() - start := time.Now() - defer func() { - log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID, - "outcome", out, "elapsed", time.Since(start)) - }() - - res, err := p.resolver.resolve(ctx, item) - if err != nil { - traceStage(ctx, "resolve", err) - log.Warn(ctx, "Artwork: Could not resolve item", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil, 0 - } - if retry, ok := errors.AsType[*agents.RetryLaterError](res.extErr); ok { - retryIn = retry.RetryIn - } - if res.reader == nil { - if res.extErr != nil || res.localError { - // A fault is not a definitive "no image": never settle absent, keep serving old state. - // A chainless resolver (playlist/radio) records no step, so leave a fallback or explain is blank. - if t := traceFrom(ctx); len(t.Steps()) == 0 { - outcome := OutcomeError - if res.localError { - outcome = OutcomeUnreadable - } - t.add(TraceStep{Candidate: cmp.Or(res.source, "source"), Outcome: outcome}) - } - log.Debug(ctx, "Artwork: No image, but a source faulted; keeping previous state", - "kind", item.ItemKind, "id", item.ItemID, "extErr", res.extErr, "localError", res.localError) - return outcomeFailed, nil, retryIn - } - return writeAbsent(ctx, repo, item), nil, 0 - } - defer res.reader.Close() - - readStart := time.Now() - data, err := readCapped(res.reader) - if err != nil { - traceStage(ctx, "read", err) - log.Warn(ctx, "Artwork: Failed to read resolved image", "kind", item.ItemKind, "id", item.ItemID, "source", res.source, err) - return outcomeFailed, nil, retryIn - } - log.Debug(ctx, "Artwork: Read resolved image", "kind", item.ItemKind, "id", item.ItemID, - "source", res.source, "bytes", len(data), "elapsed", time.Since(readStart)) - - hashStart := time.Now() - hash, err := hashImage(bytes.NewReader(data)) - if err != nil { - traceStage(ctx, "hash", err) - log.Warn(ctx, "Artwork: Failed to hash image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil, retryIn - } - log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID, - "hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart)) - - art, err := repo.GetImage(ctx, hash) - switch { - case err == nil && art.Width > 0: - log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind, - "id", item.ItemID, "hash", hash) - // A row with no dimensions was stored when no decoder matched; retry in case one exists now. - case err == nil, errors.Is(err, model.ErrNotFound): - decodeStart := time.Now() - art, err = decodeArtwork(ctx, hash, data) - // Extension-matched local bytes we cannot decode are most likely a codec we lack; an - // external body carries no such guarantee, and empty bytes are no image at all. - if errors.Is(err, image.ErrFormat) && len(data) > 0 && isLocalSource(res.source) { - log.Debug(ctx, "Artwork: No decoder for this image format, storing it without placeholders", - "kind", item.ItemKind, "id", item.ItemID, "source", res.source, "bytes", len(data)) - art, err = undecodedArtwork(hash), nil - } - if err != nil { - traceStage(ctx, "decode", err) - log.Warn(ctx, "Artwork: Failed to decode resolved image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil, retryIn - } - log.Debug(ctx, "Artwork: Decoded new image", "kind", item.ItemKind, "id", item.ItemID, "hash", hash, - "width", art.Width, "height", art.Height, "mime", art.Mime, "elapsed", time.Since(decodeStart)) - default: - traceStage(ctx, "lookup", err) - log.Warn(ctx, "Artwork: Failed to look up image hash", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil, retryIn - } - art.SizeBytes = int64(len(data)) - - ia, err := p.persist(ctx, repo, item, art, res, data) - if err != nil { - traceStage(ctx, "store", err) - log.Warn(ctx, "Artwork: Failed to persist resolved image", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed, nil, retryIn - } - got = &acquired{ia: ia, mime: art.Mime, data: data} - if res.extErr != nil { - log.Debug(ctx, "Artwork: Serving a lower-priority source after an external failure", - "kind", item.ItemKind, "id", item.ItemID, "source", res.source) - return outcomeFoundStale, got, retryIn - } - return outcomeFound, got, retryIn -} - -// persist places the bytes and commits the rows referencing them, excluding Prune for that -// window only so a slow resolution can never hold it off. -func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem, - art *model.Artwork, res resolution, data []byte, -) (*model.ItemArtwork, error) { - if p.pruneLock != nil { - p.pruneLock.Lock() - defer p.pruneLock.Unlock() - } - sourcePath, refMtime, err := placeBytes(p.store, art, res, data) - if err != nil { - return nil, fmt.Errorf("writing image store: %w", err) - } - if err := repo.PutImage(ctx, art); err != nil { - return nil, fmt.Errorf("persisting artwork image: %w", err) - } - ia := &model.ItemArtwork{ - ItemKind: item.ItemKind, - ItemID: item.ItemID, - ImageType: item.ImageType, - Hash: art.Hash, - Source: res.source, - SourcePath: sourcePath, - RefMtime: refMtime, - AttemptedAt: time.Now(), - Trace: traceFrom(ctx).encode(sourcePath), - } - // PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row. - if err := repo.PutItemArtwork(ctx, ia); err != nil { - return nil, fmt.Errorf("persisting item artwork state: %w", err) - } - return ia, nil -} - -// writeAbsent records a known-absent state: every source answered definitively "no". -func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome { - err := repo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: item.ItemKind, - ItemID: item.ItemID, - ImageType: item.ImageType, - AttemptedAt: time.Now(), - Trace: traceFrom(ctx).encode(""), - }) - if err != nil { - log.Warn(ctx, "Artwork: Failed to persist absent state", "kind", item.ItemKind, "id", item.ItemID, err) - return outcomeFailed - } - log.Debug(ctx, "Artwork: Settled absent, every source answered definitively", - "kind", item.ItemKind, "id", item.ItemID) - return outcomeAbsent -} - -func readCapped(r io.Reader) ([]byte, error) { - limit := maxImageBytes() - data, err := io.ReadAll(io.LimitReader(r, limit+1)) - if err != nil { - return nil, err - } - if int64(len(data)) > limit { - return nil, fmt.Errorf("image exceeds size cap %d", limit) - } - return data, nil -} - -// decodeCapped rejects declared dimensions over maxImagePixels before the full-decode allocation. -func decodeCapped(data []byte) (image.Image, string, error) { - cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) - if err != nil { - return nil, "", fmt.Errorf("decode image config: %w", err) - } - // Compared by division so the cap cannot be defeated by an int64 overflow. - if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxImagePixels/cfg.Height { - return nil, "", fmt.Errorf("image dimensions %dx%d exceed pixel cap %d", cfg.Width, cfg.Height, maxImagePixels) - } - img, _, err := image.Decode(bytes.NewReader(data)) - if err != nil { - return nil, "", fmt.Errorf("decode image: %w", err) - } - return img, format, nil -} - -// undecodedArtwork is the row for bytes no decoder matched: servable, but with no dimensions -// and none of the placeholders a decode would have produced. -func undecodedArtwork(hash string) *model.Artwork { - return &model.Artwork{Hash: hash, Mime: mimeForFormat("")} -} - -// decodeArtwork builds a new Artwork row from raw bytes: dimensions, mime and the two -// placeholder hashes, both encoded from one shared downscaled thumbnail. -func decodeArtwork(ctx context.Context, hash string, data []byte) (*model.Artwork, error) { - img, format, err := decodeCapped(data) - if err != nil { - return nil, err - } - - thumb := makeThumbnail(img, thumbnailSize) - bh, err := blurhash.Encode(thumb) - if err != nil { - log.Warn(ctx, "Artwork: Blurhash encoding failed", "hash", hash, err) - bh = "" - } - - var th string - if raw, err := thumbhash.Encode(thumb); err != nil { - log.Warn(ctx, "Artwork: Thumbhash encoding failed", "hash", hash, err) - } else { - th = base64.StdEncoding.EncodeToString(raw) - } - - return &model.Artwork{ - Hash: hash, - Mime: mimeForFormat(format), - Width: img.Bounds().Dx(), - Height: img.Bounds().Dy(), - BlurHash: bh, - ThumbHash: th, - DominantColor: dominant.Color(thumb), - }, nil -} - -// makeThumbnail downscales img to fit within maxSize on its longest side; it never upscales. -func makeThumbnail(img image.Image, maxSize int) image.Image { - b := img.Bounds() - w, h := b.Dx(), b.Dy() - if w <= maxSize && h <= maxSize { - return toFastScaleType(img) - } - scale := float64(maxSize) / float64(max(w, h)) - // NRGBA, not RGBA: thumbhash requires straight alpha, and blurhash reads this type without - // converting, so neither encoder allocates a second copy of the thumbnail. - dst := image.NewNRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) - xdraw.CatmullRom.Scale(dst, dst.Bounds(), toFastScaleType(img), b, draw.Src, nil) - return dst -} - -// isFileBacked reports whether the bytes already live in a library/upload file, so the -// content-addressed store must not duplicate them. -func isFileBacked(source string) bool { - return source == "folder" || source == "upload" -} - -// isLocalSource reports whether the bytes came off disk rather than off the network. -func isLocalSource(source string) bool { - return isFileBacked(source) || source == "embedded" -} - -// placeBytes reports the item's backing-file provenance and writes the bytes into the store -// for the sources that have none. -func placeBytes(store *ImageStore, art *model.Artwork, res resolution, data []byte) (sourcePath string, refMtime int64, err error) { - if isFileBacked(res.source) { - return res.sourcePath, res.refMtime, nil - } - if res.source == "embedded" { - sourcePath, refMtime = res.sourcePath, res.refMtime - } - return sourcePath, refMtime, store.Write(art.Hash, art.Mime, bytes.NewReader(data)) -} - -// mimeForFormat maps an image.Decode format name to its MIME type; extForMime is the inverse. -func mimeForFormat(format string) string { - switch format { - case "jpeg": - return "image/jpeg" - case "png": - return "image/png" - case "gif": - return "image/gif" - case "webp": - return "image/webp" - } - return "application/octet-stream" -} diff --git a/core/artwork/processor_test.go b/core/artwork/processor_test.go deleted file mode 100644 index 8eec97cde..000000000 --- a/core/artwork/processor_test.go +++ /dev/null @@ -1,654 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "encoding/binary" - "errors" - "hash/crc32" - "image" - "image/color" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "runtime" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// jxlCodestream is a JPEG XL bare codestream header: a real image format, with no stdlib decoder. -var jxlCodestream = []byte{0xff, 0x0a, 0x00, 0x10, 0x00} - -// DecodeConfig reads only the header, so the pixel data can be omitted entirely. -func pngHeaderWithDims(w, h uint32) []byte { - ihdr := make([]byte, 13) - binary.BigEndian.PutUint32(ihdr[0:], w) - binary.BigEndian.PutUint32(ihdr[4:], h) - ihdr[8] = 8 // bit depth - ihdr[9] = 2 // color type: truecolor - chunk := append([]byte("IHDR"), ihdr...) - out := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a} - out = binary.BigEndian.AppendUint32(out, uint32(len(ihdr))) - out = append(out, chunk...) - return binary.BigEndian.AppendUint32(out, crc32.ChecksumIEEE(chunk)) -} - -var _ = Describe("processor.acquire", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - folderRepo *fakeFolderRepo - libRepo *tests.MockLibraryRepo - ffm *tests.MockFFmpeg - ag *agents.Agents - store *ImageStore - artRepo *tests.MockArtworkRepo - repoRoot string - proc *processor - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = context.Background() - var err error - repoRoot, err = os.Getwd() - Expect(err).ToNot(HaveOccurred()) - - folderRepo = &fakeFolderRepo{} - libRepo = &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - ffm = tests.NewMockFFmpeg("") - ag = agents.GetAgents(&tests.MockDataStore{}, nil) - artRepo = tests.CreateMockArtworkRepo() - ds = &tests.MockDataStore{ - MockedFolder: folderRepo, - MockedLibrary: libRepo, - MockedArtwork: artRepo, - } - ds.MockedAlbum = tests.CreateMockAlbumRepo() - store = NewImageStore(GinkgoT().TempDir()) - proc = &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)} - - conf.Server.CoverArtPriority = "cover.jpg, embedded" - }) - - It("found-folder: persists state from a folder image, writes no store file, keeps sourcePath/refMtime", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, - }) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) - Expect(out).To(Equal(outcomeFound)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).ToNot(BeEmpty()) - Expect(ia.Source).To(Equal("folder")) - Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) - Expect(ia.RefMtime).To(BeNumerically(">", 0)) - - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - // Every placeholder is derived from the one shared thumbnail, so all three land together. - Expect(art.BlurHash).ToNot(BeEmpty()) - Expect(art.ThumbHash).ToNot(BeEmpty()) - Expect(art.DominantColor).To(MatchRegexp(`^#[0-9a-f]{6}$`)) - _, err = store.Open(ia.Hash, art.Mime) - Expect(os.IsNotExist(err)).To(BeTrue(), "folder-backed art must not be duplicated into the store") - }) - - Describe("prune lock scope", func() { - var lock *countingLocker - - BeforeEach(func() { - lock = &countingLocker{} - proc.pruneLock = lock - }) - - It("holds it once across the write window", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "alL1", Name: "Album", FolderIDs: []string{"f1"}}, - }) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL1"}) - Expect(out).To(Equal(outcomeFound)) - Expect(lock.locks).To(BeNumerically(">", 0), "the write window must exclude prune") - Expect(lock.held()).To(BeFalse(), "the window must close before acquire returns") - }) - - // Resolution can reach the network, so holding the lock across it would let one slow - // provider block prune, and every drain queued behind prune's writer. - It("never takes it while only resolving", func() { - folderRepo.result = nil - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "alL2", Name: "Album", FolderIDs: []string{"f1"}}, - }) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alL2"}) - Expect(out).To(Equal(outcomeAbsent)) - Expect(lock.locks).To(BeZero()) - }) - }) - - It("found-embedded: writes a store file and computes a non-empty blurhash from a real fixture", func() { - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, - }) - folderRepo.result = nil - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) - Expect(out).To(Equal(outcomeFound)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("embedded")) - Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) - - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - Expect(art.BlurHash).ToNot(BeEmpty()) - - rc, err := store.Open(ia.Hash, art.Mime) - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("absent: no local source and no external error persists a known-absent state", func() { - folderRepo.result = nil - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al3", Name: "Album"}, - }) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) - Expect(out).To(Equal(outcomeAbsent)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(BeEmpty()) - Expect(ia.Source).To(BeEmpty()) - Expect(ia.AttemptedAt).To(BeTemporally("~", time.Now(), time.Second)) - }) - - It("failed-on-unreadable-local: a listed cover that will not open never records absent", func() { - conf.Server.CoverArtPriority = "cover.jpg" - // A folder listing that names a cover the FS will not hand over: a stale mount. - libRoot := GinkgoT().TempDir() - Expect(os.MkdirAll(filepath.Join(libRoot, "an-album"), 0o755)).To(Succeed()) - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) - folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jpg"}}} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al-io", Name: "Album", FolderIDs: []string{"f1"}}, - }) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"}) - Expect(out).To(Equal(outcomeFailed)) - - _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-io", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent") - }) - - // An upload outranks every source, so an unreadable one must not let a lower one take over. - It("failed-on-unreadable-upload: an upload that will not open never records absent", func() { - if runtime.GOOS == "windows" { - // The file would still open, so the spec would pass on the decode error instead. - Skip("chmod does not restrict read access on Windows") - } - radioRepo := tests.CreateMockedRadioRepo() - radioRepo.Data = map[string]*model.Radio{} - ds.MockedRadio = radioRepo - dir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(dir) - upload := model.UploadedImagePath(consts.EntityRadio, "ra-io.jpg") - Expect(os.MkdirAll(filepath.Dir(upload), 0o755)).To(Succeed()) - Expect(os.WriteFile(upload, []byte("x"), 0o600)).To(Succeed()) - Expect(os.Chmod(upload, 0o000)).To(Succeed()) // present, but unreadable - DeferCleanup(func() { _ = os.Chmod(upload, 0o600) }) - radioRepo.Data["ra-io"] = &model.Radio{ID: "ra-io", Name: "Station", UploadedImage: "ra-io.jpg"} - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"}) - Expect(out).To(Equal(outcomeFailed)) - - _, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra-io", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent") - }) - - // Playlist/radio resolvers walk no chain, so a fault records no step; without a fallback, - // explain would show a give-up with an empty "Gave up after" table. - It("chainless fault: records a fallback trace step naming the faulted source", func() { - if runtime.GOOS == "windows" { - // os.Open under a non-directory maps to a not-exist error on Windows, so no localError. - Skip("cannot provoke an open fault via a non-directory parent on Windows") - } - radioRepo := tests.CreateMockedRadioRepo() - radioRepo.Data = map[string]*model.Radio{} - ds.MockedRadio = radioRepo - dir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(dir) - upload := model.UploadedImagePath(consts.EntityRadio, "ra-tr.jpg") - // A plain file where the upload's parent should be makes os.Open fault with ENOTDIR, - // deterministically and regardless of the test user's privileges. - Expect(os.MkdirAll(filepath.Dir(filepath.Dir(upload)), 0o755)).To(Succeed()) - Expect(os.WriteFile(filepath.Dir(upload), []byte("x"), 0o600)).To(Succeed()) - radioRepo.Data["ra-tr"] = &model.Radio{ID: "ra-tr", Name: "Station", UploadedImage: "ra-tr.jpg"} - - trace := &ChainTrace{} - out, _, _ := proc.acquire(withTrace(ctx, trace), model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-tr"}) - Expect(out).To(Equal(outcomeFailed)) - - steps := trace.Steps() - Expect(steps).To(HaveLen(1), "a radio fault must leave one step so explain is not blank") - Expect(steps[0].Candidate).To(Equal("upload")) - Expect(steps[0].Outcome).To(Equal(OutcomeUnreadable)) - }) - - It("failed-on-extError: leaves the item's state untouched", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al4", Name: "Album"}, - }) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - - out, _, retryIn := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) - Expect(out).To(Equal(outcomeFailed)) - Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay") - - _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("failed-on-extError: reports the delay a throttled provider asked for", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al4r", Name: "Album"}, - }) - imageAgents(&fakeImageAgent{name: "throttled", err: &agents.RetryLaterError{RetryIn: 42 * time.Second}}) - - out, _, retryIn := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4r"}) - Expect(out).To(Equal(outcomeFailed)) - Expect(retryIn).To(Equal(42 * time.Second)) - }) - - It("found-stale: a fallback hit after a transient external failure persists state and returns outcomeFoundStale", func() { - conf.Server.CoverArtPriority = "external, cover.jpg" - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}}, - }) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"}) - Expect(out).To(Equal(outcomeFoundStale)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).ToNot(BeEmpty()) - Expect(ia.Source).To(Equal("folder")) - }) - - It("undecodable local file: acquires it anyway, with no placeholder metadata", func() { - libRoot := GinkgoT().TempDir() - Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), jxlCodestream, 0600)).To(Succeed()) - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alU", Name: "Album", FolderIDs: []string{"f1"}}}) - folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"}) - Expect(out).To(Equal(outcomeFound)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alU", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("folder")) - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - Expect(art.Width).To(BeZero()) - Expect(art.BlurHash).To(BeEmpty()) - }) - - It("empty local file: fails without writing state", func() { - libRoot := GinkgoT().TempDir() - Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), nil, 0600)).To(Succeed()) - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alE", Name: "Album", FolderIDs: []string{"f1"}}}) - folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"}) - Expect(out).To(Equal(outcomeFailed)) - - _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alE", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - // An agent answering 200 with a non-image body must keep retrying, not pin garbage as a cover. - It("undecodable external body: fails without writing state", func() { - conf.Server.CoverArtPriority = "external" - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("rate limited")) - })) - DeferCleanup(srv.Close) - - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alX", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"}) - Expect(out).To(Equal(outcomeFailed)) - - _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alX", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("found-external: persists source as external: and stores the fetched bytes", func() { - conf.Server.CoverArtPriority = "external" - imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) - Expect(err).ToNot(HaveOccurred()) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write(imgBytes) - })) - DeferCleanup(srv.Close) - - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alext", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "deezerFake", imgs: []agents.ExternalImage{{URL: srv.URL, Size: 500}}}) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"}) - Expect(out).To(Equal(outcomeFound)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alext", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("external:deezerFake")) - Expect(ia.Hash).ToNot(BeEmpty()) - - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - rc, err := store.Open(ia.Hash, art.Mime) - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("dedup: a second item with identical bytes skips decode and reuses the artwork row", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al5", Name: "Album A", FolderIDs: []string{"f1"}}, - {ID: "al6", Name: "Album B", FolderIDs: []string{"f1"}}, - }) - - out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) - Expect(out1).To(Equal(outcomeFound)) - ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - - // A re-decode instead of a hash dedup would overwrite this sentinel. - poisoned := artRepo.Data[ia1.Hash] - poisoned.BlurHash = "SENTINEL" - artRepo.Data[ia1.Hash] = poisoned - - out2, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) - Expect(out2).To(Equal(outcomeFound)) - ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia2.Hash).To(Equal(ia1.Hash)) - - reused, err := artRepo.GetImage(ctx, ia1.Hash) - Expect(err).ToNot(HaveOccurred()) - Expect(reused.BlurHash).To(Equal("SENTINEL")) - }) - - It("two items, two files, identical bytes: each item keeps its own provenance; the shared artwork row is written once", func() { - // Byte-identical files share one hash, but provenance is per item. - libRoot := GinkgoT().TempDir() - imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) - Expect(err).ToNot(HaveOccurred()) - for sub, mtime := range map[string]int64{"album-a": 1000, "album-b": 2000} { - dir := filepath.Join(libRoot, sub) - Expect(os.MkdirAll(dir, 0755)).To(Succeed()) - img := filepath.Join(dir, "cover.jpg") - Expect(os.WriteFile(img, imgBytes, 0600)).To(Succeed()) - Expect(os.Chtimes(img, time.Unix(mtime, 0), time.Unix(mtime, 0))).To(Succeed()) - } - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "alA", Name: "Album A", FolderIDs: []string{"fa"}}, - {ID: "alB", Name: "Album B", FolderIDs: []string{"fb"}}, - }) - - folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}} - outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"}) - Expect(outN).To(Equal(outcomeFound)) - iaA, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(iaA.Source).To(Equal("folder")) - Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) - Expect(iaA.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano())) - - // A re-decode instead of a hash dedup would overwrite this sentinel. - poisoned := artRepo.Data[iaA.Hash] - poisoned.BlurHash = "SENTINEL" - artRepo.Data[iaA.Hash] = poisoned - - folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}} - outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"}) - Expect(outN).To(Equal(outcomeFound)) - iaB, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alB", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(iaB.Hash).To(Equal(iaA.Hash)) - Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg")) - Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano())) - - iaAafter, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg")) - Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano())) - - Expect(artRepo.Data).To(HaveLen(1)) - reused, err := artRepo.GetImage(ctx, iaA.Hash) - Expect(err).ToNot(HaveOccurred()) - Expect(reused.BlurHash).To(Equal("SENTINEL")) - }) - - It("decode failure on found bytes: fails without writing state", func() { - tmpDir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) - imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra1_test.jpg") - // Truncated PNG: a known format, so this is a real decode failure, not a missing decoder. - Expect(os.WriteFile(imgPath, pngHeaderWithDims(100, 100), 0600)).To(Succeed()) - - radioRepo := tests.CreateMockedRadioRepo() - radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio", UploadedImage: "ra1_test.jpg"}} - ds.MockedRadio = radioRepo - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) - Expect(out).To(Equal(outcomeFailed)) - - _, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("oversized read: a resolved image larger than the cap fails without writing state", func() { - tmpDir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) - imgPath := filepath.Join(tmpDir, "artwork", "radio", "big_test.jpg") - f, err := os.Create(imgPath) - Expect(err).ToNot(HaveOccurred()) - Expect(f.Truncate(maxImageBytes() + 1)).To(Succeed()) - Expect(f.Close()).To(Succeed()) - - radioRepo := tests.CreateMockedRadioRepo() - radioRepo.Data = map[string]*model.Radio{"big": {ID: "big", Name: "Radio", UploadedImage: "big_test.jpg"}} - ds.MockedRadio = radioRepo - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"}) - Expect(out).To(Equal(outcomeFailed)) - - _, err = artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "big", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - // The cap is compared by division so an overflowing product cannot slip past it; no supported - // format can declare these dimensions, so this pins the arithmetic, not a live hole. - DescribeTable("rejects out-of-range declared dimensions", - func(w, h uint32) { - _, err := decodeArtwork(ctx, "bomb", pngHeaderWithDims(w, h)) - Expect(err).To(HaveOccurred()) - }, - Entry("both dimensions at the 32-bit maximum", uint32(0xffffffff), uint32(0xffffffff)), - Entry("both dimensions at the signed 32-bit maximum", uint32(0x7fffffff), uint32(0x7fffffff)), - Entry("zero width", uint32(0), uint32(100)), - Entry("zero height", uint32(100), uint32(0)), - ) - - It("decompression bomb: rejects huge declared dimensions before the full decode", func() { - data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap - _, err := decodeArtwork(ctx, "bomb", data) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("dimensions")) - }) - - It("unknown format: reports ErrFormat so the caller can decide", func() { - _, err := decodeArtwork(ctx, "jxl", jxlCodestream) - Expect(err).To(MatchError(image.ErrFormat)) - }) - - It("undecodedArtwork: carries the hash and mime, and nothing a decode would add", func() { - art := undecodedArtwork("jxl") - Expect(art.Hash).To(Equal("jxl")) - Expect(art.Mime).To(Equal("application/octet-stream")) - Expect(art.Width).To(BeZero()) - Expect(art.Height).To(BeZero()) - Expect(art.BlurHash).To(BeEmpty()) - Expect(art.ThumbHash).To(BeEmpty()) - Expect(art.DominantColor).To(BeEmpty()) - }) - - It("corrupt image of a known format: still fails", func() { - data := pngHeaderWithDims(100, 100) // header declares a decodable size, body is missing - _, err := decodeArtwork(ctx, "truncated", data) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("decode image")) - }) - - // Without this a metadata-less row would be reused forever, so a decoder added later - // could never upgrade it. - It("metadata-less row: re-decodes on reuse instead of skipping", func() { - libRoot := GinkgoT().TempDir() - imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) - Expect(err).ToNot(HaveOccurred()) - Expect(os.MkdirAll(filepath.Join(libRoot, "album"), 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(libRoot, "album", "cover.jpg"), imgBytes, 0600)).To(Succeed()) - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alM", Name: "Album", FolderIDs: []string{"f1"}}}) - folderRepo.result = []model.Folder{{Path: "album", ImageFiles: []string{"cover.jpg"}}} - - hash, err := hashImage(bytes.NewReader(imgBytes)) - Expect(err).ToNot(HaveOccurred()) - Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed()) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"}) - Expect(out).To(Equal(outcomeFound)) - - upgraded, err := artRepo.GetImage(ctx, hash) - Expect(err).ToNot(HaveOccurred()) - Expect(upgraded.Width).To(BeNumerically(">", 0)) - Expect(upgraded.BlurHash).ToNot(BeEmpty()) - }) - - It("store write failure: fails without writing state", func() { - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al7", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, - }) - folderRepo.result = nil - - // A store root that is a plain file makes every MkdirAll under it fail. - blockedRoot := filepath.Join(GinkgoT().TempDir(), "not-a-dir") - Expect(os.WriteFile(blockedRoot, []byte("x"), 0600)).To(Succeed()) - proc.store = NewImageStore(blockedRoot) - - out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) - Expect(out).To(Equal(outcomeFailed)) - - _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) -}) - -type countingLocker struct { - locks int - unlocks int -} - -func (l *countingLocker) Lock() { l.locks++ } -func (l *countingLocker) Unlock() { l.unlocks++ } -func (l *countingLocker) held() bool { return l.locks != l.unlocks } - -var _ = Describe("makeThumbnail", func() { - // Both encoders read Pix directly and thumbhash needs straight alpha, so emitting NRGBA is - // what keeps either of them from allocating a converted copy of the thumbnail. - It("emits NRGBA when it downscales", func() { - src := image.NewRGBA(image.Rect(0, 0, 400, 300)) - Expect(makeThumbnail(src, 100)).To(BeAssignableToTypeOf(&image.NRGBA{})) - }) - - It("fits the longest side to maxSize and keeps the aspect ratio", func() { - src := image.NewRGBA(image.Rect(0, 0, 400, 300)) - b := makeThumbnail(src, 100).Bounds() - Expect(b.Dx()).To(Equal(100)) - Expect(b.Dy()).To(Equal(75)) - }) - - It("never upscales an image already within bounds", func() { - src := image.NewRGBA(image.Rect(0, 0, 40, 30)) - Expect(makeThumbnail(src, 100).Bounds()).To(Equal(src.Bounds())) - }) - - // A fully transparent pixel's colour cannot survive any resample, since the scaler works in - // premultiplied space and multiplying by zero is not invertible. Partial alpha is the case - // that distinguishes straight storage from premultiplied. - It("keeps partly transparent colour straight rather than premultiplied", func() { - src := image.NewNRGBA(image.Rect(0, 0, 400, 400)) - for y := range 400 { - for x := range 400 { - src.SetNRGBA(x, y, color.NRGBA{R: 200, G: 40, B: 90, A: 128}) - } - } - thumb, ok := makeThumbnail(src, 100).(*image.NRGBA) - Expect(ok).To(BeTrue()) - // Premultiplied storage would have halved this to ~100. - Expect(thumb.Pix[0]).To(BeNumerically("==", 200)) - Expect(thumb.Pix[3]).To(BeNumerically("==", 128)) - }) -}) - -var _ = Describe("maxImageBytes", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("reads MaxImageSize from config", func() { - conf.Server.MaxImageSize = "30MB" - Expect(maxImageBytes()).To(Equal(int64(30_000_000))) - }) -}) diff --git a/core/artwork/prune.go b/core/artwork/prune.go deleted file mode 100644 index 15b4a5673..000000000 --- a/core/artwork/prune.go +++ /dev/null @@ -1,63 +0,0 @@ -package artwork - -import ( - "context" - "time" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -// pruneMinAge guards the window between artwork insert and item_artwork upsert. -const pruneMinAge = time.Hour - -func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error { - start := time.Now() - defer func() { log.Debug(ctx, "Artwork: Prune finished", "elapsed", time.Since(start)) }() - repo := ds.Artwork() - - purged, err := repo.PurgeDanglingItems(ctx) - if err != nil { - return err - } - if purged > 0 { - log.Info(ctx, "Artwork: Purged dangling item state", "count", purged) - } - - // Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed). - queuePurged, err := ds.ArtworkQueue().PurgeDangling(ctx) - if err != nil { - return err - } - if queuePurged > 0 { - log.Info(ctx, "Artwork: Purged dangling queue rows", "count", queuePurged) - } - - // Files younger than the grace window may belong to acquisitions whose rows aren't committed yet. - cutoff := time.Now().Add(-pruneMinAge) - orphans, err := repo.PurgeOrphans(ctx, cutoff) - if err != nil { - return err - } - if orphans > 0 { - log.Info(ctx, "Artwork: Removed orphan image rows", "count", orphans) - } - - // Read after the delete, so the sweep below reclaims the files of the rows just removed. - mimes, err := repo.GetMimeByHash(ctx) - if err != nil { - return err - } - removed, err := store.Sweep(ctx, cutoff, func(hash, ext string) bool { - // A known hash under a stale extension is a superseded mime variant — reclaim it. - m, ok := mimes[hash] - return ok && ext == extForMime(m) - }) - if err != nil { - return err - } - if removed > 0 { - log.Info(ctx, "Artwork: Swept stray files", "count", removed) - } - return nil -} diff --git a/core/artwork/prune_test.go b/core/artwork/prune_test.go deleted file mode 100644 index 674211dfa..000000000 --- a/core/artwork/prune_test.go +++ /dev/null @@ -1,241 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "errors" - "os" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type flakyGetArtworkRepo struct { - *tests.MockArtworkRepo -} - -func (f *flakyGetArtworkRepo) GetMimeByHash(context.Context) (map[string]string, error) { - return nil, errors.New("db locked") -} - -var _ = Describe("Prune", func() { - var ctx context.Context - var ds *tests.MockDataStore - var store *ImageStore - var awRepo *tests.MockArtworkRepo - - BeforeEach(func() { - ctx = GinkgoT().Context() - ds = &tests.MockDataStore{} - awRepo = ds.Artwork().(*tests.MockArtworkRepo) - store = NewImageStore(GinkgoT().TempDir()) - }) - - // PutImage refreshes created_at like the SQL repo, so fixtures are aged directly. - ageArtwork := func(h string, t time.Time) { - a := awRepo.Data[h] - a.CreatedAt = t - awRepo.Data[h] = a - } - - It("purges dangling item_artwork state for gone entities, summed across kinds", func() { - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed()) - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed()) - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed()) - awRepo.ExistingIDs = map[string]map[string]bool{ - "al": {}, - "ar": {"live-artist": true}, - } - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - _, err := awRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "live-artist", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - }) - - It("purges dangling artwork_queue rows for gone entities", func() { - queueRepo := tests.CreateMockArtworkQueueRepo() - Expect(queueRepo.Enqueue(ctx, - model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary}, - model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary}, - )).To(Succeed()) - queueRepo.ExistingIDs = map[string]map[string]bool{"al": {"live-album": true}} - ds.MockedArtworkQueue = queueRepo - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - Expect(findQueued(queueRepo, "al", "gone-album")).To(BeNil()) - Expect(findQueued(queueRepo, "al", "live-album")).ToNot(BeNil()) - }) - - It("deletes orphan rows and their store files, keeps referenced ones", func() { - data := []byte("orphan-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - old := time.Now().Add(-2 * time.Hour) - Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) - ageArtwork(h, old) - - kept := []byte("kept-bytes") - hk, _ := hashImage(bytes.NewReader(kept)) - Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed()) - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed()) - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - _, err := awRepo.GetImage(ctx, h) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = store.Open(h, "image/jpeg") - Expect(os.IsNotExist(err)).To(BeTrue()) - rc, err := store.Open(hk, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("spares an aged row that item_artwork state still references", func() { - data := []byte("reacquired-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) - ageArtwork(h, time.Now().Add(-2*time.Hour)) - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "a1", - ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed()) - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - _, err := awRepo.GetImage(ctx, h) - Expect(err).ToNot(HaveOccurred()) - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("spares an unreferenced row recreated inside the grace window", func() { - data := []byte("fresh-reacquired-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - // Reacquisition refreshed created_at, so the row is unreferenced but too young to drop. - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - _, err := awRepo.GetImage(ctx, h) - Expect(err).ToNot(HaveOccurred()) - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("spares an orphan file freshly touched by an overlapping acquisition", func() { - data := []byte("racing-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) - ageArtwork(h, time.Now().Add(-2*time.Hour)) - // The row is orphaned, but a concurrent acquisition just touched the file's mtime. - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("sweeps store files that have no artwork row", func() { - stray := []byte("no-row-bytes") - h, _ := hashImage(bytes.NewReader(stray)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(stray))).To(Succeed()) - old := time.Now().Add(-2 * time.Hour) - Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - _, err := store.Open(h, "image/jpeg") - Expect(os.IsNotExist(err)).To(BeTrue()) - }) - - It("sweeps an obsolete mime variant of a reacquired hash", func() { - data := []byte("variant-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/png", bytes.NewReader(data))).To(Succeed()) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - old := time.Now().Add(-2 * time.Hour) - Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed()) - Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed()) - // The row records the current mime; the .png file is a superseded variant. - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed()) - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - _, err := store.Open(h, "image/png") - Expect(os.IsNotExist(err)).To(BeTrue()) - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("reclaims the other orphan files when one of them cannot be removed", func() { - tests.SkipOnWindows("uses Unix file permission bits") - if os.Geteuid() == 0 { - Skip("read-only dir cannot block root (e.g. tests in a container)") - } - old := time.Now().Add(-2 * time.Hour) - - blocked := []byte("blocked-bytes") - hb, _ := hashImage(bytes.NewReader(blocked)) - Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed()) - Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed()) - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed()) - ageArtwork(hb, old) - - good := []byte("good-bytes") - hg, _ := hashImage(bytes.NewReader(good)) - Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed()) - Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed()) - Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed()) - ageArtwork(hg, old) - - // A read-only shard directory makes os.Remove fail (EACCES) for hb's file only. - shardDir := filepath.Dir(store.path(hb, "image/jpeg")) - Expect(shardDir).ToNot(Equal(filepath.Dir(store.path(hg, "image/jpeg"))), "fixtures must land in different shards") - Expect(os.Chmod(shardDir, 0500)).To(Succeed()) - DeferCleanup(func() { _ = os.Chmod(shardDir, 0755) }) - - Expect(prune(context.Background(), ds, store)).To(Succeed()) - - _, err := awRepo.GetImage(ctx, hg) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = store.Open(hg, "image/jpeg") - Expect(os.IsNotExist(err)).To(BeTrue()) - - // The row purge does not depend on file removal, so only the file survives. - _, err = awRepo.GetImage(ctx, hb) - Expect(err).To(MatchError(model.ErrNotFound)) - rc, err := store.Open(hb, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) - - It("never sweeps files on a transient DB error", func() { - ds.MockedArtwork = &flakyGetArtworkRepo{MockArtworkRepo: tests.CreateMockArtworkRepo()} - - data := []byte("live-bytes") - h, _ := hashImage(bytes.NewReader(data)) - Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed()) - - Expect(prune(context.Background(), ds, store)).ToNot(Succeed()) - - rc, err := store.Open(h, "image/jpeg") - Expect(err).ToNot(HaveOccurred()) - rc.Close() - }) -}) diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go new file mode 100644 index 000000000..dbf1b9fac --- /dev/null +++ b/core/artwork/reader_album.go @@ -0,0 +1,74 @@ +package artwork + +import ( + "context" + "crypto/md5" + "fmt" + "io" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/model" +) + +type albumArtworkReader struct { + cacheKey + a *artwork + em core.ExternalMetadata + album model.Album +} + +func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, em core.ExternalMetadata) (*albumArtworkReader, error) { + al, err := artwork.ds.Album(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + a := &albumArtworkReader{ + a: artwork, + em: em, + album: *al, + } + a.cacheKey.artID = artID + a.cacheKey.lastUpdate = al.UpdatedAt + return a, nil +} + +func (a *albumArtworkReader) Key() string { + var hash [16]byte + if conf.Server.EnableExternalServices { + hash = md5.Sum([]byte(conf.Server.Agents + conf.Server.CoverArtPriority)) + } + return fmt.Sprintf( + "%s.%x.%t", + a.cacheKey.Key(), + hash, + conf.Server.EnableExternalServices, + ) +} +func (a *albumArtworkReader) LastUpdated() time.Time { + return a.album.UpdatedAt +} + +func (a *albumArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + var ff = a.fromCoverArtPriority(ctx, a.a.ffmpeg, conf.Server.CoverArtPriority) + return selectImageReader(ctx, a.artID, ff...) +} + +func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ffmpeg.FFmpeg, priority string) []sourceFunc { + var ff []sourceFunc + for _, pattern := range strings.Split(strings.ToLower(priority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "embedded": + ff = append(ff, fromTag(a.album.EmbedArtPath), fromFFmpegTag(ctx, ffmpeg, a.album.EmbedArtPath)) + case pattern == "external": + ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.em)) + case a.album.ImageFiles != "": + ff = append(ff, fromExternalFile(ctx, a.album.ImageFiles, pattern)) + } + } + return ff +} diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go new file mode 100644 index 000000000..b25858f0c --- /dev/null +++ b/core/artwork/reader_artist.go @@ -0,0 +1,127 @@ +package artwork + +import ( + "context" + "crypto/md5" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" +) + +type artistReader struct { + cacheKey + a *artwork + em core.ExternalMetadata + artist model.Artist + artistFolder string + files string +} + +func newArtistReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, em core.ExternalMetadata) (*artistReader, error) { + ar, err := artwork.ds.Artist(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + als, err := artwork.ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_artist_id": artID.ID}}) + if err != nil { + return nil, err + } + a := &artistReader{ + a: artwork, + em: em, + artist: *ar, + } + // 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 + //a.cacheKey.lastUpdate = ar.ExternalInfoUpdatedAt + var files []string + var paths []string + for _, al := range als { + files = append(files, al.ImageFiles) + paths = append(paths, splitList(al.Paths)...) + if a.cacheKey.lastUpdate.Before(al.UpdatedAt) { + a.cacheKey.lastUpdate = al.UpdatedAt + } + } + a.files = strings.Join(files, consts.Zwsp) + a.artistFolder = utils.LongestCommonPrefix(paths) + if !strings.HasSuffix(a.artistFolder, string(filepath.Separator)) { + a.artistFolder, _ = filepath.Split(a.artistFolder) + } + a.cacheKey.artID = artID + return a, nil +} + +func (a *artistReader) Key() string { + hash := md5.Sum([]byte(conf.Server.Agents + conf.Server.Spotify.ID)) + return fmt.Sprintf( + "%s.%t.%x", + a.cacheKey.Key(), + conf.Server.EnableExternalServices, + hash, + ) +} + +func (a *artistReader) LastUpdated() time.Time { + return a.lastUpdate +} + +func (a *artistReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + var ff = a.fromArtistArtPriority(ctx, conf.Server.ArtistArtPriority) + return selectImageReader(ctx, a.artID, ff...) +} + +func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority string) []sourceFunc { + var ff []sourceFunc + for _, pattern := range strings.Split(strings.ToLower(priority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "external": + ff = append(ff, fromArtistExternalSource(ctx, a.artist, a.em)) + case strings.HasPrefix(pattern, "album/"): + ff = append(ff, fromExternalFile(ctx, a.files, strings.TrimPrefix(pattern, "album/"))) + default: + ff = append(ff, fromArtistFolder(ctx, a.artistFolder, pattern)) + } + } + return ff +} + +func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc { + return func() (io.ReadCloser, string, error) { + fsys := os.DirFS(artistFolder) + matches, err := fs.Glob(fsys, pattern) + if err != nil { + log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", artistFolder) + return nil, "", err + } + if len(matches) == 0 { + return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, artistFolder) + } + for _, m := range matches { + filePath := filepath.Join(artistFolder, m) + if !model.IsImageFile(m) { + continue + } + f, err := os.Open(filePath) + if err != nil { + log.Warn(ctx, "Could not open cover art file", "file", filePath, err) + return nil, "", err + } + return f, filePath, nil + } + return nil, "", nil + } +} diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go new file mode 100644 index 000000000..b86fe7154 --- /dev/null +++ b/core/artwork/reader_mediafile.go @@ -0,0 +1,64 @@ +package artwork + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" +) + +type mediafileArtworkReader struct { + cacheKey + a *artwork + mediafile model.MediaFile + album model.Album +} + +func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) { + mf, err := artwork.ds.MediaFile(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + al, err := artwork.ds.Album(ctx).Get(mf.AlbumID) + if err != nil { + return nil, err + } + a := &mediafileArtworkReader{ + a: artwork, + mediafile: *mf, + album: *al, + } + a.cacheKey.artID = artID + if al.UpdatedAt.After(mf.UpdatedAt) { + a.cacheKey.lastUpdate = al.UpdatedAt + } else { + a.cacheKey.lastUpdate = mf.UpdatedAt + } + return a, nil +} + +func (a *mediafileArtworkReader) Key() string { + return fmt.Sprintf( + "%s.%t", + a.cacheKey.Key(), + conf.Server.EnableMediaFileCoverArt, + ) +} +func (a *mediafileArtworkReader) LastUpdated() time.Time { + return a.lastUpdate +} + +func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + var ff []sourceFunc + if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork { + ff = []sourceFunc{ + fromTag(a.mediafile.Path), + fromFFmpegTag(ctx, a.a.ffmpeg, a.mediafile.Path), + } + } + ff = append(ff, fromAlbum(ctx, a.a, a.mediafile.AlbumCoverArtID())) + return selectImageReader(ctx, a.artID, ff...) +} diff --git a/core/artwork/reader_playlist.go b/core/artwork/reader_playlist.go new file mode 100644 index 000000000..a2c7c182b --- /dev/null +++ b/core/artwork/reader_playlist.go @@ -0,0 +1,150 @@ +package artwork + +import ( + "bytes" + "context" + "errors" + "image" + "image/draw" + "image/png" + "io" + "time" + + "github.com/disintegration/imaging" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" +) + +type playlistArtworkReader struct { + cacheKey + a *artwork + pl model.Playlist +} + +const tileSize = 600 + +func newPlaylistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*playlistArtworkReader, error) { + pl, err := artwork.ds.Playlist(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + a := &playlistArtworkReader{ + a: artwork, + pl: *pl, + } + a.cacheKey.artID = artID + a.cacheKey.lastUpdate = pl.UpdatedAt + return a, nil +} + +func (a *playlistArtworkReader) LastUpdated() time.Time { + return a.lastUpdate +} + +func (a *playlistArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + ff := []sourceFunc{ + a.fromGeneratedTiledCover(ctx), + fromAlbumPlaceholder(), + } + return selectImageReader(ctx, a.artID, ff...) +} + +func (a *playlistArtworkReader) fromGeneratedTiledCover(ctx context.Context) sourceFunc { + return func() (io.ReadCloser, string, error) { + tiles, err := a.loadTiles(ctx) + if err != nil { + return nil, "", err + } + r, err := a.createTiledImage(ctx, tiles) + return r, "", err + } +} + +func toArtworkIDs(albumIDs []string) []model.ArtworkID { + return slice.Map(albumIDs, func(id string) model.ArtworkID { + al := model.Album{ID: id} + return al.CoverArtID() + }) +} + +func (a *playlistArtworkReader) loadTiles(ctx context.Context) ([]image.Image, error) { + tracksRepo := a.a.ds.Playlist(ctx).Tracks(a.pl.ID, false) + albumIds, err := tracksRepo.GetAlbumIDs(model.QueryOptions{Max: 4, Sort: "random()"}) + if err != nil { + log.Error(ctx, "Error getting album IDs for playlist", "id", a.pl.ID, "name", a.pl.Name, err) + return nil, err + } + ids := toArtworkIDs(albumIds) + + var tiles []image.Image + for len(tiles) < 4 { + if len(ids) == 0 { + break + } + id := ids[len(ids)-1] + ids = ids[0 : len(ids)-1] + r, _, err := fromAlbum(ctx, a.a, id)() + if err != nil { + continue + } + tile, err := a.createTile(ctx, r) + if err == nil { + tiles = append(tiles, tile) + } + _ = r.Close() + } + switch len(tiles) { + case 0: + return nil, errors.New("could not find any eligible cover") + case 2: + tiles = append(tiles, tiles[1], tiles[0]) + case 3: + tiles = append(tiles, tiles[0]) + } + return tiles, nil +} + +func (a *playlistArtworkReader) createTile(_ context.Context, r io.ReadCloser) (image.Image, error) { + img, _, err := image.Decode(r) + if err != nil { + return nil, err + } + return imaging.Fill(img, tileSize/2, tileSize/2, imaging.Center, imaging.Lanczos), nil +} + +func (a *playlistArtworkReader) createTiledImage(_ context.Context, tiles []image.Image) (io.ReadCloser, error) { + buf := new(bytes.Buffer) + var rgba draw.Image + var err error + if len(tiles) == 4 { + rgba = image.NewRGBA(image.Rectangle{Max: image.Point{X: tileSize - 1, Y: tileSize - 1}}) + draw.Draw(rgba, rect(0), tiles[0], image.Point{}, draw.Src) + draw.Draw(rgba, rect(1), tiles[1], image.Point{}, draw.Src) + draw.Draw(rgba, rect(2), tiles[2], image.Point{}, draw.Src) + draw.Draw(rgba, rect(3), tiles[3], image.Point{}, draw.Src) + err = png.Encode(buf, rgba) + } else { + err = png.Encode(buf, tiles[0]) + } + if err != nil { + return nil, err + } + return io.NopCloser(buf), nil +} + +func rect(pos int) image.Rectangle { + r := image.Rectangle{} + switch pos { + case 1: + r.Min.X = tileSize / 2 + case 2: + r.Min.Y = tileSize / 2 + case 3: + r.Min.X = tileSize / 2 + r.Min.Y = tileSize / 2 + } + r.Max.X = r.Min.X + tileSize/2 + r.Max.Y = r.Min.Y + tileSize/2 + return r +} diff --git a/core/artwork/reader_resized.go b/core/artwork/reader_resized.go new file mode 100644 index 000000000..32e337513 --- /dev/null +++ b/core/artwork/reader_resized.go @@ -0,0 +1,137 @@ +package artwork + +import ( + "bufio" + "bytes" + "context" + "fmt" + "image" + "image/jpeg" + "image/png" + "io" + "net/http" + "time" + + "github.com/disintegration/imaging" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/number" +) + +type resizedArtworkReader struct { + artID model.ArtworkID + cacheKey string + lastUpdate time.Time + size int + a *artwork +} + +func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int) (*resizedArtworkReader, error) { + r := &resizedArtworkReader{a: a} + r.artID = artID + r.size = size + + // Get lastUpdated and cacheKey from original artwork + original, err := a.getArtworkReader(ctx, artID, 0) + if err != nil { + return nil, err + } + r.cacheKey = original.Key() + r.lastUpdate = original.LastUpdated() + return r, nil +} + +func (a *resizedArtworkReader) Key() string { + return fmt.Sprintf( + "%s.%d.%d", + a.cacheKey, + a.size, + conf.Server.CoverJpegQuality, + ) +} + +func (a *resizedArtworkReader) LastUpdated() time.Time { + return a.lastUpdate +} + +func (a *resizedArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + // Get artwork in original size, possibly from cache + orig, _, err := a.a.Get(ctx, a.artID, 0) + if err != nil { + return nil, "", err + } + + // Keep a copy of the original data. In case we can't resize it, send it as is + buf := new(bytes.Buffer) + r := io.TeeReader(orig, buf) + defer orig.Close() + + resized, origSize, err := resizeImage(r, a.size) + if resized == nil { + log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size) + } else { + log.Trace(ctx, "Resizing artwork", "artID", a.artID, "original", origSize, "resized", a.size) + } + if err != nil { + log.Warn(ctx, "Could not resize image. Will return image as is", "artID", a.artID, "size", a.size, err) + } + if err != nil || resized == nil { + // Force finish reading any remaining data + _, _ = io.Copy(io.Discard, r) + return io.NopCloser(buf), "", nil //nolint:nilerr + } + return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil +} + +func asImageReader(r io.Reader) (io.Reader, string, error) { + br := bufio.NewReader(r) + buf, err := br.Peek(512) + if err == io.EOF && len(buf) > 0 { + // Check if there are enough bytes to detect type + typ := http.DetectContentType(buf) + if typ != "" { + return br, typ, nil + } + } + if err != nil { + return nil, "", err + } + return br, http.DetectContentType(buf), nil +} + +func resizeImage(reader io.Reader, size int) (io.Reader, int, error) { + r, format, err := asImageReader(reader) + if err != nil { + return nil, 0, err + } + + img, _, err := image.Decode(r) + if err != nil { + return nil, 0, err + } + + // Don't upscale the image + bounds := img.Bounds() + originalSize := number.Max(bounds.Max.X, bounds.Max.Y) + if originalSize <= size { + return nil, originalSize, nil + } + + var m *image.NRGBA + // Preserve the aspect ratio of the image. + if bounds.Max.X > bounds.Max.Y { + m = imaging.Resize(img, size, 0, imaging.Lanczos) + } else { + m = imaging.Resize(img, 0, size, imaging.Lanczos) + } + + buf := new(bytes.Buffer) + buf.Reset() + if format == "image/png" { + err = png.Encode(buf, m) + } else { + err = jpeg.Encode(buf, m, &jpeg.Options{Quality: conf.Server.CoverJpegQuality}) + } + return buf, originalSize, err +} diff --git a/core/artwork/resize.go b/core/artwork/resize.go deleted file mode 100644 index 9b3ff46e5..000000000 --- a/core/artwork/resize.go +++ /dev/null @@ -1,144 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "fmt" - "image" - "image/draw" - "image/jpeg" - "image/png" - "io" - "sync" - "time" - - "github.com/gen2brain/webp" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/log" - xdraw "golang.org/x/image/draw" -) - -func init() { - conf.AddHook(func() { - // gen2brain/webp picks native vs WASM in its own init(), with no way to switch at - // runtime: 32-bit builds need the "nodynamic" tag (see Dockerfile) to force WASM. - if err := webp.Dynamic(); err != nil { - log.Debug("Artwork: Using WASM WebP encoder/decoder", "reason", err) - } else { - log.Debug("Artwork: Using native libwebp for WebP encoding/decoding") - } - }) -} - -var bufPool = sync.Pool{ - New: func() any { - return new(bytes.Buffer) - }, -} - -// resizeImageData resizes raw image bytes to fit size, preserving animation where -// possible. A nil reader means the image was already within bounds (no resize needed). -func resizeImageData(ctx context.Context, ffm ffmpeg.FFmpeg, data []byte, size int, square bool) (io.Reader, int, error) { - start := time.Now() - defer func() { - log.Trace(ctx, "Artwork: Resized image", "bytes", len(data), "size", size, "square", square, - "elapsed", time.Since(start)) - }() - if isAnimatedGIF(data) { - if ffm.IsAvailable() { - // Animated GIF: convert to animated WebP via ffmpeg (with optional resize) - r, err := ffm.ConvertAnimatedImage(ctx, bytes.NewReader(data), size, conf.Server.CoverArtQuality) - if err == nil { - return r, 0, nil - } - log.Warn(ctx, "Artwork: 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, size, square) -} - -// toFastScaleType converts types x/image/draw has no optimized scaler for (e.g. *image.NYCbCrA, -// *image.Paletted) to *image.RGBA, avoiding CatmullRom.Scale's generic per-pixel fallback. -func toFastScaleType(img image.Image) image.Image { - switch img.(type) { - case *image.RGBA, *image.NRGBA, *image.Gray, *image.YCbCr: - return img - default: - rgba := image.NewRGBA(img.Bounds()) - draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src) - return rgba - } -} - -func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) { - original, format, err := decodeCapped(data) - if err != nil { - return nil, 0, err - } - - bounds := original.Bounds() - originalSize := max(bounds.Max.X, bounds.Max.Y) - - // Clamp size to original dimensions - upscaling wastes resources and adds no information - if size > originalSize { - size = originalSize - } - - if originalSize <= size && !square { - return nil, originalSize, nil - } - - // Calculate aspect-fit dimensions - srcW, srcH := bounds.Dx(), bounds.Dy() - scale := float64(size) / float64(max(srcW, srcH)) - dstW := int(float64(srcW) * scale) - dstH := int(float64(srcH) * scale) - - var dst *image.NRGBA - var dstRect image.Rectangle - if square { - // Square canvas with image centered (transparent padding via zero-initialized NRGBA) - dst = image.NewNRGBA(image.Rect(0, 0, size, size)) - offsetX := (size - dstW) / 2 - offsetY := (size - dstH) / 2 - dstRect = image.Rect(offsetX, offsetY, offsetX+dstW, offsetY+dstH) - } else { - // Tight-fit canvas - dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) - dstRect = dst.Bounds() - } - original = toFastScaleType(original) - xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil) - - buf := bufPool.Get().(*bytes.Buffer) - buf.Reset() - 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) - return nil, originalSize, err - } - // Copy bytes before returning buffer to pool (pool may reuse the buffer) - encoded := bytes.Clone(buf.Bytes()) - bufPool.Put(buf) - return bytes.NewReader(encoded), originalSize, nil -} - -// formatQualityTag folds the encoder config (WebP toggle + quality) into a cache-key -// fragment, so flipping either setting invalidates previously-encoded sized artwork. -func formatQualityTag() string { - if conf.Server.EnableWebPEncoding { - return fmt.Sprintf("webp%d", conf.Server.CoverArtQuality) - } - return fmt.Sprintf("q%d", conf.Server.CoverArtQuality) -} diff --git a/core/artwork/resize_test.go b/core/artwork/resize_test.go deleted file mode 100644 index 76b95974d..000000000 --- a/core/artwork/resize_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package artwork - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("resizeStaticImage", func() { - It("rejects images whose declared dimensions exceed the pixel cap before decoding", func() { - _, _, err := resizeStaticImage(pngHeaderWithDims(9000, 9000), 300, false) - Expect(err).To(MatchError(ContainSubstring("exceed pixel cap"))) - }) -}) diff --git a/core/artwork/resolve.go b/core/artwork/resolve.go deleted file mode 100644 index fb07332fe..000000000 --- a/core/artwork/resolve.go +++ /dev/null @@ -1,608 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "fmt" - "image" - "io" - "io/fs" - "net/url" - "os" - "path" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" -) - -// resolution is one attempted acquisition outcome for an entity. -type resolution struct { - reader io.ReadCloser // nil when no source yielded an image - source string // model.ItemArtwork.Source value: "folder", "embedded", "external", "upload", "generated" - sourcePath string // backing library/upload file (folder/upload: the image; embedded: the audio file); "" otherwise - refMtime int64 // sourcePath mtime (unix-nanoseconds) at resolution; 0 when no sourcePath - // a faulted external source, carrying the provider's requested delay when it named one. - // With no reader it forces failed (never absent); on a hit, serve this but retry later. - extErr error - // a local source that should have been readable wasn't. With no reader it forces failed, - // so a transient I/O fault never records absent. - localError bool -} - -// chainState carries what a priority walk has seen so far. A hit takes extErr with it so a -// transient external failure still retries; localErr is dropped, as the scanner re-lists changes. -type chainState struct { - extErr error - localErr bool - trace *ChainTrace // nil only where no caller attached one -} - -// try stamps the accumulated external failure onto a hit, and records the miss otherwise. -func (c *chainState) try(candidate string, res resolution, ok bool) (resolution, bool) { - if ok { - res.extErr = c.extErr - c.record(candidate, OutcomeHit, res.sourcePath) - return res, true - } - c.localErr = c.localErr || res.localError - if res.localError { - c.record(candidate, OutcomeUnreadable, "") - } else { - c.record(candidate, OutcomeMiss, "") - } - return resolution{}, false -} - -func (c *chainState) record(candidate string, out Outcome, detail string) { - c.trace.add(TraceStep{Candidate: candidate, Outcome: out, Detail: detail}) -} - -// exhausted is the outcome when no source in the chain yielded an image. -func (c *chainState) exhausted() resolution { - return resolution{extErr: c.extErr, localError: c.localErr} -} - -// externalSource holds the agents to ask and the rate limiter/circuit breaker to ask them through. -type externalSource struct { - agents *agents.Agents - gate gateFunc -} - -// resolver walks a kind's priority chain and returns the first hit; a nil ext means local-only. -type resolver struct { - ds model.DataStore - ffmpeg ffmpeg.FFmpeg - ext *externalSource -} - -func newResolver(ds model.DataStore, ag *agents.Agents, ffm ffmpeg.FFmpeg, gate gateFunc) *resolver { - if gate == nil { - gate = passthroughGate - } - return &resolver{ds: ds, ffmpeg: ffm, ext: &externalSource{agents: ag, gate: gate}} -} - -// newLocalResolver builds a resolver that can neither reach the network nor sample album art -// for the worker-built grid. -func newLocalResolver(ds model.DataStore, ffm ffmpeg.FFmpeg) *resolver { - return &resolver{ds: ds, ffmpeg: ffm} -} - -func (r *resolver) resolve(ctx context.Context, item model.ArtworkQueueItem) (resolution, error) { - kind, _ := model.ParseKind(item.ItemKind) - switch kind { - case model.KindAlbumArtwork: - return r.resolveAlbum(ctx, item.ItemID) - case model.KindArtistArtwork: - return r.resolveArtist(ctx, item.ItemID) - case model.KindPlaylistArtwork: - return r.resolvePlaylist(ctx, item.ItemID) - case model.KindRadioArtwork: - return r.resolveRadio(ctx, item.ItemID) - case model.KindMediaFileArtwork: - return r.resolveMediaFile(ctx, item.ItemID) - default: - return resolution{}, fmt.Errorf("artwork: kind %q is not resolvable by the worker", item.ItemKind) - } -} - -// Explainable reports whether TracingResolver can walk this kind's sources and report which one -// won; playlists and radios resolve from a fixed internal order, with nothing configured to explain. -func Explainable(kind model.Kind) bool { - switch kind { - case model.KindArtistArtwork, model.KindAlbumArtwork, model.KindDiscArtwork, model.KindMediaFileArtwork: - return true - } - return false -} - -// MayFetchExternal reports whether resolving this kind can issue an external request under the -// current config. Playlists inherit the album chain: the generated grid resolves album art. -func MayFetchExternal(kind model.Kind) bool { - switch kind { - case model.KindArtistArtwork: - return chainFetchesExternal(conf.Server.ArtistArtPriority) - case model.KindAlbumArtwork: - return chainFetchesExternal(conf.Server.CoverArtPriority) - case model.KindPlaylistArtwork: - return conf.Server.EnableM3UExternalAlbumArt || chainFetchesExternal(conf.Server.CoverArtPriority) - default: - return false - } -} - -// ImageAgentCount is how many enabled agents provide artist and album images. -type ImageAgentCount struct{ Artist, Album int } - -// NewImageAgentCount counts what an external step would consult, so an estimate and the gate that -// guards it cannot disagree about which agents exist. -func NewImageAgentCount(ag *agents.Agents) ImageAgentCount { - if ag == nil { - return ImageAgentCount{} - } - return ImageAgentCount{Artist: len(ag.ArtistImageAgents()), Album: len(ag.AlbumImageAgents())} -} - -// ExternalLookupsPerItem reports what resolving one item of this kind can cost: every image agent is -// tried, and a zero count still bills one, so agents the caller cannot see never read as free. -func ExternalLookupsPerItem(kind model.Kind, agents ImageAgentCount) int64 { - if !MayFetchExternal(kind) { - return 0 - } - switch kind { - case model.KindArtistArtwork: - return int64(max(agents.Artist, 1)) - case model.KindAlbumArtwork: - return int64(max(agents.Album, 1)) - case model.KindPlaylistArtwork: - var n int64 - if conf.Server.EnableM3UExternalAlbumArt { - n++ - } - if chainFetchesExternal(conf.Server.CoverArtPriority) { - n += PlaylistGridSamples * int64(max(agents.Album, 1)) - } - return n - } - return 0 -} - -func chainFetchesExternal(priority string) bool { - for pattern := range strings.SplitSeq(strings.ToLower(priority), ",") { - if strings.TrimSpace(pattern) == externalCandidate { - return true - } - } - return false -} - -// Album and artist fetches stop here when the resolver is local-only, rather than at each point in -// the chain walk; resolvePlaylist gates the third network path, the m3u image URL, itself. -func (r *resolver) fetchExternalAlbum(ctx context.Context, al model.Album) (io.ReadCloser, string, error) { - if r.ext == nil { - return nil, "", nil - } - return fetchAlbumImage(ctx, r.ext.agents, r.ext.gate, al) -} - -func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io.ReadCloser, string, error) { - if r.ext == nil { - return nil, "", nil - } - return fetchArtistImage(ctx, r.ext.agents, r.ext.gate, ar) -} - -// resolveAlbum walks conf.Server.CoverArtPriority over the folder, embedded and external sources. -func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution, error) { - al, err := r.ds.Album().Get(ctx, albumID) - if err != nil { - return resolution{}, err - } - _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, r.ds, *al) - if err != nil { - return resolution{}, err - } - lib, err := loadLibraryView(ctx, r.ds, al.LibraryID) - if err != nil { - return resolution{}, err - } - - chain := chainState{trace: traceFrom(ctx)} - for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") { - pattern = strings.TrimSpace(pattern) - if pattern == "" { - continue - } - switch { - case pattern == "embedded": - res, ok := resolveEmbedded(ctx, lib, r.ffmpeg, al.EmbedArtPath) - if res, ok = chain.try(pattern, res, ok); ok { - return res, nil - } - case pattern == externalCandidate: - if rd, name, err := r.fetchExternalAlbum(ctx, *al); rd != nil { - return resolution{reader: rd, source: ExternalPrefix + name}, nil - } else if err != nil { - chain.extErr = longerRetry(chain.extErr, err) - } - case len(imgFiles) > 0: - res, ok := resolveFolderFile(ctx, lib, imgFiles, pattern) - if res, ok = chain.try(pattern, res, ok); ok { - return res, nil - } - default: - chain.record(pattern, OutcomeSkipped, "no images in album folder") - } - } - return chain.exhausted(), nil -} - -// resolveArtist tries the uploaded image first, then walks conf.Server.ArtistArtPriority. -func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resolution, error) { - ar, err := r.ds.Artist().Get(ctx, artistID) - if err != nil { - return resolution{}, err - } - chain := chainState{trace: traceFrom(ctx)} - upload, uploadOK := resolveLocalFile(ar.UploadedImagePath(), "upload") - if res, ok := chain.try("upload", upload, uploadOK); ok { - return res, nil - } - if upload.localError { - // The upload outranks every other source; falling through would persist a lower-priority - // image as if the upload were gone. - return upload, nil - } - - // Only consider albums where the artist is the sole album artist. - als, err := r.ds.Album().GetAll(ctx, model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)}) - if err != nil { - return resolution{}, err - } - albumPaths, imgFiles, _, err := loadArtistAlbumRoots(ctx, r.ds, als) - if err != nil { - return resolution{}, err - } - artistFolder, _, err := loadArtistFolder(ctx, r.ds, als, albumPaths) - if err != nil { - return resolution{}, err - } - var lib libraryView - if len(als) > 0 { - lib, err = loadLibraryView(ctx, r.ds, als[0].LibraryID) - if err != nil { - return resolution{}, err - } - } - - for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") { - pattern = strings.TrimSpace(pattern) - if pattern == "" { - continue - } - switch { - case pattern == externalCandidate: - if rd, name, err := r.fetchExternalArtist(ctx, *ar); rd != nil { - return resolution{reader: rd, source: ExternalPrefix + name}, nil - } else if err != nil { - chain.extErr = longerRetry(chain.extErr, err) - } - case pattern == "image-folder": - res, ok := resolveArtistImageFolder(ar) - if res, ok = chain.try(pattern, res, ok); ok { - return res, nil - } - case strings.HasPrefix(pattern, "album/"): - if lib.FS == nil { - chain.record(pattern, OutcomeSkipped, "artist has no albums") - continue - } - res, ok := resolveFolderFile(ctx, lib, imgFiles, strings.TrimPrefix(pattern, "album/")) - if res, ok = chain.try(pattern, res, ok); ok { - return res, nil - } - default: - if lib.FS == nil { - chain.record(pattern, OutcomeSkipped, "artist has no albums") - continue - } - if artistFolder == "" { - chain.record(pattern, OutcomeSkipped, "no artist folder") - continue - } - res, ok := resolveArtistFolderPattern(ctx, lib, artistFolder, pattern) - if res, ok = chain.try(pattern, res, ok); ok { - return res, nil - } - } - } - return chain.exhausted(), nil -} - -// PlaylistGridSamples is how many albums resolvePlaylist samples to build the generated grid. -const PlaylistGridSamples = 4 - -// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid. -func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) { - pl, err := r.ds.Playlist().Get(ctx, playlistID) - if err != nil { - return resolution{}, err - } - - var extErr error - for _, src := range []struct{ path, source string }{ - {pl.UploadedImagePath(), "upload"}, - {findPlaylistSidecarPath(ctx, pl.Path), "folder"}, - } { - res, ok := resolveLocalFile(src.path, src.source) - if ok { - return res, nil - } - if res.localError { - // These outrank the generated grid; falling through would replace them with it. - return res, nil - } - } - // A local ExternalImageURL is file-backed and served in place; only http(s) needs the gated fetch. - localImg, remoteImg := classifyPlaylistImage(pl.ExternalImageURL) - if localImg != "" { - res, ok := resolveLocalFile(localImg, "folder") - if ok { - return res, nil - } - if res.localError { - return res, nil - } - } - if r.ext == nil { - // The remote fetch and the generated grid are worker-only; a request must do neither. - return resolution{}, nil - } - if remoteImg != nil && conf.Server.EnableM3UExternalAlbumArt { - sf := func() (io.ReadCloser, string, error) { return fromURL(ctx, remoteImg) } - if res, ok, err := resolveExternalStep(r.ext.gate, "m3u", sf); ok { - return res, nil - } else if err != nil { - extErr = longerRetry(extErr, err) - // Record it here with its detail: once album sampling adds its own steps, the processor's - // empty-trace fallback no longer fires, and the error that forced the retry would be lost. - traceFrom(ctx).add(TraceStep{Candidate: ExternalPrefix + "m3u", Outcome: OutcomeError, Detail: err.Error()}) - } - } - - tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false) - if tracks == nil { - return resolution{}, fmt.Errorf("resolvePlaylist: could not load tracks for playlist %s", pl.ID) - } - albumIDs, err := tracks.GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"}) - if err != nil { - return resolution{}, err - } - - var tiles []image.Image - var tileErr error // first internal (non-external) tile failure, e.g. album deleted mid-flight - for _, albumID := range albumIDs { - res, err := r.resolveAlbum(ctx, albumID) - if err != nil { - if tileErr == nil { - tileErr = err - } - continue - } - if res.extErr != nil { - extErr = longerRetry(extErr, res.extErr) - } - if res.reader == nil { - continue - } - tile, decErr := decodeTile(res.reader) - res.reader.Close() - if decErr == nil { - tiles = append(tiles, tile) - } - if len(tiles) == PlaylistGridSamples { - break - } - } - if len(tiles) == 0 { - // A tile-level failure must never resolve as a clean absent. - if tileErr != nil { - return resolution{}, fmt.Errorf("resolvePlaylist: sampled album art failed: %w", tileErr) - } - return resolution{extErr: extErr}, nil - } - // Grow to 4 tiles by repeating what we have. - switch len(tiles) { - case 2: - tiles = append(tiles, tiles[1], tiles[0]) - case 3: - tiles = append(tiles, tiles[0]) - } - grid, err := assembleTiles(tiles) - if err != nil { - return resolution{extErr: extErr}, nil //nolint:nilerr // encode failure is a soft "no image", not a resolution error - } - return resolution{reader: grid, source: "generated", extErr: extErr}, nil -} - -// resolveRadio serves only an uploaded image; there is no fallback. -func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution, error) { - radio, err := r.ds.Radio().Get(ctx, radioID) - if err != nil { - return resolution{}, err - } - res, _ := resolveLocalFile(radio.UploadedImagePath(), "upload") - return res, nil -} - -// resolveMediaFile resolves a track's own embedded art only, so disabled or missing cover art -// is a definitive absent. -func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, error) { - mf, err := r.ds.MediaFile().Get(ctx, id) - if err != nil { - return resolution{}, err - } - chain := chainState{trace: traceFrom(ctx)} - switch { - case !conf.Server.EnableMediaFileCoverArt: - chain.record("embedded", OutcomeSkipped, "EnableMediaFileCoverArt is off") - return resolution{}, nil - case !mf.HasCoverArt: - chain.record("embedded", OutcomeMiss, "the track has no embedded cover art") - return resolution{}, nil - } - lib, err := loadLibraryView(ctx, r.ds, mf.LibraryID) - if err != nil { - return resolution{}, err - } - res, ok := resolveEmbedded(ctx, lib, r.ffmpeg, mf.Path) - if res, ok = chain.try("embedded", res, ok); ok { - return res, nil - } - return chain.exhausted(), nil -} - -// resolveDisc walks conf.Server.DiscArtPriority. Disc artwork keeps no state row and is never -// queued: the serving path reads it through on every request, so this only ever explains. -func (r *resolver) resolveDisc(ctx context.Context, id string) (resolution, error) { - dr, err := newDiscArtworkReader(ctx, r.ds, model.ArtworkID{Kind: model.KindDiscArtwork, ID: id}) - if err != nil { - return resolution{}, err - } - chain := chainState{trace: traceFrom(ctx)} - return dr.selectImage(ctx, r.ffmpeg, conf.Server.DiscArtPriority, &chain) -} - -// resolveExternalStep runs a single external sourceFunc through the named gate. A not-found is a -// definitive "no", returned as (_, false, nil); any other error is a failure the caller records. -func resolveExternalStep(gate gateFunc, name string, sf sourceFunc) (resolution, bool, error) { - r, path, err := gate(name, sf) - if r != nil { - return resolution{reader: r, source: externalCandidate, sourcePath: path}, true, nil - } - if errors.Is(err, model.ErrNotFound) { - return resolution{}, false, nil - } - return resolution{}, false, err -} - -// classifyPlaylistImage splits a playlist ExternalImageURL into a local filesystem path or a -// remote http(s) URL; at most one is set. -func classifyPlaylistImage(imageURL string) (localPath string, remote *url.URL) { - if imageURL == "" { - return "", nil - } - u, err := url.Parse(imageURL) - if err != nil { - return imageURL, nil // unparseable → treat as a local path - } - switch u.Scheme { - case "http", "https": - return "", u - case "file": - return u.Path, nil - default: - return imageURL, nil - } -} - -func resolveEmbedded(ctx context.Context, lib libraryView, ffm ffmpeg.FFmpeg, embedRel string) (resolution, bool) { - if embedRel == "" { - return resolution{}, false - } - abs := lib.Abs(embedRel) - var unreadable bool - for _, sf := range []sourceFunc{fromTag(ctx, lib.FS, embedRel), fromFFmpegTag(ctx, ffm, abs)} { - r, _, err := sf() - if r != nil { - return resolution{reader: r, source: "embedded", sourcePath: abs, refMtime: mtimeViaFS(lib.FS, embedRel)}, true - } - unreadable = unreadable || errors.Is(err, errSourceUnreadable) - } - return resolution{localError: unreadable}, false -} - -// resolveFolderSource turns a source that yields a library-relative image path into a folder -// resolution, keeping an existing-but-unopenable file distinct from an absent one. -func resolveFolderSource(lib libraryView, sf sourceFunc) (resolution, bool) { - r, path, err := sf() - if r == nil { - return resolution{localError: errors.Is(err, errSourceUnreadable)}, false - } - return resolution{reader: r, source: "folder", sourcePath: lib.Abs(path), refMtime: mtimeViaFS(lib.FS, path)}, true -} - -func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string, pattern string) (resolution, bool) { - return resolveFolderSource(lib, fromExternalFile(ctx, lib.FS, imgFiles, pattern)) -} - -// IsArtistImageFile reports whether a file name matches any file-glob token of ArtistArtPriority. -// Basename-only on purpose: the chain climbs parent folders, so a token's prefix is not fixed. -func IsArtistImageFile(name string) bool { - name = strings.ToLower(name) - for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") { - pattern = strings.TrimSpace(pattern) - if pattern == "" || pattern == externalCandidate || pattern == "image-folder" { - continue - } - if ok, _ := path.Match(path.Base(pattern), name); ok { - return true - } - } - return false -} - -func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) { - folder := conf.Server.ArtistImageFolder - if folder == "" { - return resolution{}, false - } - return resolveLocalFile(findImageInArtistFolder(folder, ar.MbzArtistID, ar.Name), "folder") -} - -func resolveArtistFolderPattern(ctx context.Context, lib libraryView, artistFolder, pattern string) (resolution, bool) { - r, path, err := fromArtistFolder(ctx, lib.FS, lib.absRoot, artistFolder, pattern)() - if r == nil { - return resolution{localError: errors.Is(err, errSourceUnreadable)}, false - } - return resolution{reader: r, source: "folder", sourcePath: path, refMtime: mtimeOf(path)}, true -} - -// resolveLocalFile opens an absolute path directly. A missing path is "no source"; any other -// open failure says nothing about whether the image exists. -func resolveLocalFile(path, source string) (resolution, bool) { - if path == "" || !model.IsImageFile(path) { - return resolution{}, false - } - f, err := os.Open(path) - if err != nil { - // Carry the source label even on a fault, so a resolver with no chain (playlist/radio) can - // still name what faulted in the trace. - return resolution{source: source, localError: !errors.Is(err, fs.ErrNotExist)}, false - } - return resolution{reader: f, source: source, sourcePath: path, refMtime: mtimeOf(path)}, true -} - -func mtimeOf(path string) int64 { - info, err := os.Stat(path) - if err != nil { - return 0 - } - return info.ModTime().UnixNano() -} - -// mtimeViaFS stats through the library FS, since library roots in tests may not be real OS paths. -func mtimeViaFS(fsys fs.FS, name string) int64 { - if fsys == nil || name == "" { - return 0 - } - info, err := fs.Stat(fsys, name) - if err != nil { - return 0 - } - return info.ModTime().UnixNano() -} diff --git a/core/artwork/resolve_test.go b/core/artwork/resolve_test.go deleted file mode 100644 index 2a36531bb..000000000 --- a/core/artwork/resolve_test.go +++ /dev/null @@ -1,844 +0,0 @@ -package artwork - -import ( - "bytes" - "context" - "errors" - "image" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "sync/atomic" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("IsArtistImageFile", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("matches bare and album/-prefixed glob tokens, case-insensitively", func() { - conf.Server.ArtistArtPriority = "artist.*, album/artistfolder.*, external" - Expect(IsArtistImageFile("Artist.jpg")).To(BeTrue()) - Expect(IsArtistImageFile("artistfolder.png")).To(BeTrue()) - Expect(IsArtistImageFile("cover.jpg")).To(BeFalse()) - }) - - It("matches a directory-bearing glob by its basename", func() { - conf.Server.ArtistArtPriority = "images/artist.*, external" - Expect(IsArtistImageFile("artist.jpg")).To(BeTrue()) - Expect(IsArtistImageFile("cover.jpg")).To(BeFalse()) - }) - - It("does not treat non-file tokens as globs", func() { - conf.Server.ArtistArtPriority = "image-folder, external" - Expect(IsArtistImageFile("image-folder")).To(BeFalse()) - Expect(IsArtistImageFile("external")).To(BeFalse()) - }) -}) - -var _ = Describe("resolveItem", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - folderRepo *fakeFolderRepo - libRepo *tests.MockLibraryRepo - ffm *tests.MockFFmpeg - ag *agents.Agents - repoRoot string - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = context.Background() - var err error - repoRoot, err = os.Getwd() - Expect(err).ToNot(HaveOccurred()) - - folderRepo = &fakeFolderRepo{} - libRepo = &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - ffm = tests.NewMockFFmpeg("") - ag = agents.GetAgents(&tests.MockDataStore{}, nil) - ds = &tests.MockDataStore{ - MockedFolder: folderRepo, - MockedLibrary: libRepo, - } - }) - - Describe("kind dispatch", func() { - It("returns an error for kinds the worker never enqueues", func() { - _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "zz", ItemID: "x"}) - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("media file", func() { - BeforeEach(func() { - conf.Server.EnableMediaFileCoverArt = true - ds.MockedMediaFile = tests.CreateMockMediaFileRepo() - }) - - It("resolves embedded art from the track file", func() { - ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, - }) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf1"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("embedded")) - Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) - Expect(res.refMtime).To(BeNumerically(">", 0)) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - It("resolves absent when the track has no cover art", func() { - ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "mf2", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: false}, - }) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - It("resolves absent when media file cover art is disabled", func() { - conf.Server.EnableMediaFileCoverArt = false - ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "mf3", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, - }) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mf3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - }) - - It("returns the error when the track is not in the DB", func() { - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "missing"}) - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(res.reader).To(BeNil()) - }) - }) - - Describe("album", func() { - BeforeEach(func() { - conf.Server.CoverArtPriority = "cover.jpg, embedded" - ds.MockedAlbum = tests.CreateMockAlbumRepo() - }) - - It("resolves folder art from the library FS", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, - }) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("folder")) - Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg")) - Expect(res.refMtime).To(BeNumerically(">", 0)) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - It("falls back to embedded art when no folder image matches", func() { - folderRepo.result = nil - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, - }) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("embedded")) - Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3")) - Expect(res.refMtime).To(BeNumerically(">", 0)) - }) - - It("sets extErr when the external source errors without being not-found", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al3", Name: "Album"}, - }) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).To(HaveOccurred()) - }) - - It("does not set extErr when the external source reports not-found", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al4", Name: "Album"}, - }) - // no image agents enabled -> the external step is a definitive not-found - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - It("carries extErr onto a fallback folder hit after a transient external failure", func() { - conf.Server.CoverArtPriority = "external, cover.jpg" - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al6", Name: "Album", FolderIDs: []string{"f1"}}, - }) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("folder")) - Expect(res.extErr).To(HaveOccurred()) - }) - - It("does not carry extErr onto a fallback folder hit after a definitive external not-found", func() { - conf.Server.CoverArtPriority = "external, cover.jpg" - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al7", Name: "Album", FolderIDs: []string{"f1"}}, - }) - // no image agents enabled -> the external step is a definitive not-found - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("folder")) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - It("routes the external step through the injected gate, keyed by agent name", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al5", Name: "Album"}, - }) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")}) - var gatedNames []string - gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - gatedNames = append(gatedNames, name) - return f() - } - - res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.extErr).To(HaveOccurred()) - Expect(gatedNames).To(Equal([]string{"failAgent"})) - }) - }) - - Describe("artist", func() { - It("resolves the uploaded image before any priority chain lookup", func() { - tmpDir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "artist"), 0755)).To(Succeed()) - imgPath := filepath.Join(tmpDir, "artwork", "artist", "ar1_test.jpg") - Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed()) - - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}}) - ds.MockedArtist = artistRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("upload")) - Expect(res.sourcePath).To(Equal(imgPath)) - }) - - It("falls through to the ArtistArtPriority chain when there is no upload", func() { - conf.Server.ArtistArtPriority = "album/artist.*" - folderRepo.result = []model.Folder{{ - LibraryPath: testFileLibPath(repoRoot), - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"artist.png"}, - }} - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}}) - ds.MockedArtist = artistRepo - ds.MockedAlbum = tests.CreateMockAlbumRepo() - ds.MockedAlbum.(*tests.MockAlbumRepo).All = model.Albums{ - {ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}, - } - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("folder")) - Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png")) - }) - - It("sets extErr when the external source errors without being not-found", func() { - conf.Server.ArtistArtPriority = "external" - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist"}}) - ds.MockedArtist = artistRepo - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).To(HaveOccurred()) - }) - - It("does not set extErr when the external source reports not-found", func() { - conf.Server.ArtistArtPriority = "external" - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) - ds.MockedArtist = artistRepo - // no image agents enabled -> the external step is a definitive not-found - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - It("routes the external step through the injected gate, keyed by agent name", func() { - conf.Server.ArtistArtPriority = "external" - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}}) - ds.MockedArtist = artistRepo - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")}) - var gatedNames []string - gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - gatedNames = append(gatedNames, name) - return f() - } - - res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.extErr).To(HaveOccurred()) - Expect(gatedNames).To(Equal([]string{"failAgent"})) - }) - }) - - Describe("radio", func() { - It("yields an empty resolution when there is no uploaded image", func() { - tmpDir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - - radioRepo := tests.CreateMockedRadioRepo() - radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio"}} - ds.MockedRadio = radioRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res).To(Equal(resolution{})) - }) - - It("resolves the uploaded image when set", func() { - tmpDir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) - imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra2_test.jpg") - Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) - - radioRepo := tests.CreateMockedRadioRepo() - radioRepo.Data = map[string]*model.Radio{"ra2": {ID: "ra2", Name: "Radio", UploadedImage: "ra2_test.jpg"}} - ds.MockedRadio = radioRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("upload")) - Expect(res.sourcePath).To(Equal(imgPath)) - }) - }) - - Describe("playlist", func() { - BeforeEach(func() { - conf.Server.CoverArtPriority = "cover.jpg" - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum = tests.CreateMockAlbumRepo() - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "t1", Name: "T1", FolderIDs: []string{"f1"}}, - {ID: "t2", Name: "T2", FolderIDs: []string{"f1"}}, - {ID: "t3", Name: "T3", FolderIDs: []string{"f1"}}, - {ID: "t4", Name: "T4", FolderIDs: []string{"f1"}}, - }) - }) - - DescribeTable("yields a generated grid from up to 4 album tiles", - func(albumIDs []string, expectedSize int) { - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pl1", Name: "Playlist"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: albumIDs} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl1"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("generated")) - - img, format, err := image.Decode(res.reader) - Expect(err).ToNot(HaveOccurred()) - Expect(format).To(Equal("png")) - Expect(img.Bounds().Dx()).To(Equal(expectedSize)) - Expect(img.Bounds().Dy()).To(Equal(expectedSize)) - }, - // tileSize-1: the 4-tile canvas is built as [0, tileSize-1]. - Entry("1 album -> single tile", []string{"t1"}, tileSize/2), - Entry("2 albums -> duplicated to 4 tiles", []string{"t1", "t2"}, tileSize-1), - Entry("3 albums -> duplicated to 4 tiles", []string{"t1", "t2", "t3"}, tileSize-1), - Entry("4 albums -> full grid", []string{"t1", "t2", "t3", "t4"}, tileSize-1), - ) - - // The grid samples album art through the full album chain, so a playlist reaches the - // network even with the m3u fetch off. - It("calls the album image agents for its grid tiles when m3u art is disabled", func() { - conf.Server.EnableM3UExternalAlbumArt = false - conf.Server.CoverArtPriority = "external" - folderRepo.result = nil - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "plgrid", Name: "Playlist"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} - ds.MockedPlaylist = plRepo - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("boom")}) - var gatedNames []string - gate := func(name string, f func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - gatedNames = append(gatedNames, name) - return f() - } - - _, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plgrid"}) - Expect(err).ToNot(HaveOccurred()) - Expect(gatedNames).To(Equal([]string{"failAgent", "failAgent"}), "one lookup per sampled album") - }) - - It("resolves the uploaded image before the generated grid", func() { - tmpDir := GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "playlist"), 0755)).To(Succeed()) - imgPath := filepath.Join(tmpDir, "artwork", "playlist", "plu_test.jpg") - Expect(os.WriteFile(imgPath, []byte("uploaded playlist image"), 0600)).To(Succeed()) - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "plu", Name: "Playlist", UploadedImage: "plu_test.jpg"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plu"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("upload")) - Expect(res.sourcePath).To(Equal(imgPath)) - }) - - It("resolves a sidecar image next to the playlist file before the grid", func() { - plDir := GinkgoT().TempDir() - Expect(os.WriteFile(filepath.Join(plDir, "list.m3u"), []byte("#EXTM3U"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(plDir, "list.jpg"), []byte("sidecar image"), 0600)).To(Succeed()) - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pls", Name: "Playlist", Path: filepath.Join(plDir, "list.m3u")}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1", "t2"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pls"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("folder")) - Expect(filepath.ToSlash(res.sourcePath)).To(HaveSuffix("list.jpg")) - }) - - It("serves a local ExternalImageURL as a file-backed reference (staleness-checked)", func() { - dir := GinkgoT().TempDir() - imgPath := filepath.Join(dir, "cover.png") - Expect(os.WriteFile(imgPath, []byte("local external image"), 0600)).To(Succeed()) - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pll", Name: "Playlist", ExternalImageURL: imgPath}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pll"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("folder")) - Expect(res.sourcePath).To(Equal(imgPath)) - Expect(res.refMtime).To(BeNumerically(">", 0)) - }) - - It("never opens a local ExternalImageURL that is not an image file", func() { - folderRepo.result = nil // no grid tiles, so only the local file could produce a reader - dir := GinkgoT().TempDir() - secretPath := filepath.Join(dir, "config.ini") - Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed()) - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "plni", Name: "Playlist", ExternalImageURL: secretPath}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plni"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.sourcePath).ToNot(Equal(secretPath)) - }) - - It("routes ExternalImageURL through extGate and sets extError on transient failure", func() { - conf.Server.EnableM3UExternalAlbumArt = true - folderRepo.result = nil // no grid tiles, so the external failure is what surfaces - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "ple", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - var gatedNames []string - gate := func(name string, _ func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - gatedNames = append(gatedNames, name) - return nil, "", errors.New("network down") - } - - res, err := newResolver(ds, ag, ffm, gate).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "ple"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).To(HaveOccurred()) - Expect(gatedNames).To(Equal([]string{"m3u"}), "the playlist URL fetch is gated under \"m3u\"") - }) - - It("records the m3u failure in the trace even when album sampling adds its own steps", func() { - conf.Server.EnableM3UExternalAlbumArt = true - folderRepo.result = nil // the sampled album yields no tile, so the m3u failure is what forced the retry - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "plm3u", Name: "Playlist", ExternalImageURL: "http://example.com/cover.jpg"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - gate := func(string, func() (io.ReadCloser, string, error)) (io.ReadCloser, string, error) { - return nil, "", errors.New("network down") - } - - trace := &ChainTrace{} - res, err := newResolver(ds, ag, ffm, gate).resolve(withTrace(ctx, trace), - model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm3u"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.extErr).To(HaveOccurred()) - - steps := trace.Steps() - var m3u *TraceStep - for i := range steps { - if steps[i].Candidate == ExternalPrefix+"m3u" && steps[i].Outcome == OutcomeError { - m3u = &steps[i] - } - } - Expect(m3u).ToNot(BeNil(), "the m3u fetch error must be traced at its source, not left to the empty-trace fallback") - Expect(m3u.Detail).To(Equal("network down"), - "the trace must carry the underlying error so explain can tell a timeout from an HTTP error") - }) - - It("treats a missing local ExternalImageURL as a definitive miss, not extError", func() { - folderRepo.result = nil // no grid tiles, so the local-file miss is what surfaces - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "plm", Name: "Playlist", ExternalImageURL: "/nonexistent/path/cover.jpg"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plm"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - It("treats an ExternalImageURL 404 as a definitive miss and falls through to the grid", func() { - conf.Server.EnableM3UExternalAlbumArt = true - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pl404", Name: "Playlist", ExternalImageURL: srv.URL}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl404"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("generated")) - Expect(res.extErr).ToNot(HaveOccurred()) - }) - - // A local resolver holds no agents: reaching the external branch would panic, not degrade. - It("skips the external step instead of dereferencing absent agents", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum = tests.CreateMockAlbumRepo() - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}}) - - res, err := newLocalResolver(ds, ffm).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).ToNot(HaveOccurred(), "a skipped step is not a failed one") - }) - - // The worker resolving the same playlist is asserted alongside, so this cannot pass vacuously. - It("resolves a playlist locally without fetching remotely or building the grid", func() { - conf.Server.EnableM3UExternalAlbumArt = true - var hits atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - hits.Add(1) - w.WriteHeader(http.StatusNotFound) - })) - defer srv.Close() - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pllocal", Name: "Playlist", ExternalImageURL: srv.URL}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - item := model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pllocal"} - - res, err := newLocalResolver(ds, ffm).resolve(ctx, item) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil(), "no local source, and the grid is worker-only") - Expect(hits.Load()).To(BeZero(), "a request must never reach the network") - - worker, err := newResolver(ds, ag, ffm, nil).resolve(ctx, item) - Expect(err).ToNot(HaveOccurred()) - Expect(worker.reader).ToNot(BeNil()) - defer worker.reader.Close() - Expect(worker.source).To(Equal("generated"), "the worker does build the grid") - Expect(hits.Load()).To(Equal(int32(1)), "and the worker does fetch") - }) - - It("treats an ExternalImageURL 500 as a transient failure and sets extError", func() { - conf.Server.EnableM3UExternalAlbumArt = true - folderRepo.result = nil // no grid tiles, so the external failure is what surfaces - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - })) - defer srv.Close() - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pl500", Name: "Playlist", ExternalImageURL: srv.URL}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl500"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.extErr).To(HaveOccurred()) - }) - - It("yields an empty resolution when no album has art", func() { - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "empty1", Name: "Empty"}, - }) - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pl2", Name: "Playlist"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"empty1"}} - ds.MockedPlaylist = plRepo - folderRepo.result = nil - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.source).To(BeEmpty()) - }) - - It("skips a grid tile whose declared dimensions are a decompression bomb", func() { - libRoot := GinkgoT().TempDir() - Expect(os.MkdirAll(filepath.Join(libRoot, "bomb"), 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(libRoot, "bomb", "cover.jpg"), pngHeaderWithDims(50000, 50000), 0600)).To(Succeed()) - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(libRoot)}}) - folderRepo.result = []model.Folder{{Path: "bomb", ImageFiles: []string{"cover.jpg"}}} - - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "plbomb", Name: "Playlist"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"t1"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plbomb"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(res.source).To(BeEmpty()) - }) - - It("does not resolve as absent when every sampled album fails to resolve", func() { - // The album ids are absent from MockAlbumRepo, so every tile fails non-externally. - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pl3", Name: "Playlist"}}) - plRepo.TracksRepo = &tests.MockPlaylistTrackRepo{AlbumIDs: []string{"missing1", "missing2"}} - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl3"}) - Expect(err).To(HaveOccurred()) - Expect(res).To(Equal(resolution{})) - }) - - It("returns an error when the playlist tracks cannot be loaded", func() { - plRepo := tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pl4", Name: "Playlist"}}) - ds.MockedPlaylist = plRepo - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl4"}) - Expect(err).To(HaveOccurred()) - Expect(res).To(Equal(resolution{})) - }) - }) -}) - -// decodeTile runs before the processor's own guards, so it must enforce the caps itself. -var _ = Describe("decodeTile", func() { - It("rejects a decompression bomb before the full decode", func() { - data := pngHeaderWithDims(50000, 50000) // 2.5 gigapixels, far above the cap - _, err := decodeTile(io.NopCloser(bytes.NewReader(data))) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("dimensions")) - }) - - It("rejects a tile larger than the size cap", func() { - data := bytes.Repeat([]byte{0}, int(maxImageBytes())+1) - _, err := decodeTile(io.NopCloser(bytes.NewReader(data))) - Expect(err).To(HaveOccurred()) - }) -}) - -var _ = Describe("Explainable", func() { - It("is true for the kinds the resolver walks", func() { - Expect(Explainable(model.KindArtistArtwork)).To(BeTrue()) - Expect(Explainable(model.KindAlbumArtwork)).To(BeTrue()) - Expect(Explainable(model.KindDiscArtwork)).To(BeTrue()) - Expect(Explainable(model.KindMediaFileArtwork)).To(BeTrue()) - }) - - It("is false for the kinds resolved from a fixed internal order", func() { - Expect(Explainable(model.KindPlaylistArtwork)).To(BeFalse()) - Expect(Explainable(model.KindRadioArtwork)).To(BeFalse()) - }) -}) - -var _ = Describe("MayFetchExternal", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.CoverArtPriority = "cover.*, embedded" - conf.Server.ArtistArtPriority = "artist.*" - conf.Server.EnableM3UExternalAlbumArt = false - }) - - It("is true for the kinds whose chain includes the external candidate", func() { - conf.Server.CoverArtPriority = "cover.*, external" - conf.Server.ArtistArtPriority = "artist.*, external" - Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeTrue()) - Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeTrue()) - }) - - It("is false for a chain with no external candidate", func() { - Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeFalse()) - Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeFalse()) - }) - - It("is true for playlists when the m3u image fetch is enabled", func() { - conf.Server.EnableM3UExternalAlbumArt = true - Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue()) - }) - - It("is true for playlists whose grid tiles resolve through an external album chain", func() { - conf.Server.CoverArtPriority = "cover.*, external" - Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue()) - }) - - It("is false for playlists with both paths off", func() { - Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeFalse()) - }) - - It("is false for the kinds that only read local files", func() { - conf.Server.CoverArtPriority = "external" - conf.Server.ArtistArtPriority = "external" - conf.Server.EnableM3UExternalAlbumArt = true - Expect(MayFetchExternal(model.KindRadioArtwork)).To(BeFalse()) - Expect(MayFetchExternal(model.KindMediaFileArtwork)).To(BeFalse()) - }) -}) - -var _ = Describe("ExternalLookupsPerItem", func() { - count := ImageAgentCount{Artist: 3, Album: 2} - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.CoverArtPriority = "cover.*, external" - conf.Server.ArtistArtPriority = "artist.*, external" - conf.Server.EnableM3UExternalAlbumArt = false - }) - - It("bills one call per agent, since the walk only stops early on a hit", func() { - Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(Equal(int64(3))) - Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(Equal(int64(2))) - }) - - It("bills a playlist for every album its grid samples", func() { - Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)). - To(Equal(int64(PlaylistGridSamples) * 2)) - }) - - It("adds the m3u image fetch on top of the grid", func() { - conf.Server.EnableM3UExternalAlbumArt = true - Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)). - To(Equal(int64(PlaylistGridSamples)*2 + 1)) - }) - - It("bills only the m3u fetch when the album chain stays local", func() { - conf.Server.CoverArtPriority = "cover.*" - conf.Server.EnableM3UExternalAlbumArt = true - Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(Equal(int64(1))) - }) - - It("still bills a call when no agent is visible, which plugins never are offline", func() { - none := ImageAgentCount{} - Expect(ExternalLookupsPerItem(model.KindArtistArtwork, none)).To(Equal(int64(1))) - Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, none)).To(Equal(int64(1))) - Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, none)). - To(Equal(int64(PlaylistGridSamples))) - }) - - It("is zero whenever the kind reaches no agent at all", func() { - conf.Server.CoverArtPriority = "cover.*" - conf.Server.ArtistArtPriority = "artist.*" - Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(BeZero()) - Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(BeZero()) - Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(BeZero()) - Expect(ExternalLookupsPerItem(model.KindRadioArtwork, count)).To(BeZero()) - }) -}) diff --git a/core/artwork/sources.go b/core/artwork/sources.go index daa084c16..832901f22 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -3,126 +3,106 @@ package artwork import ( "bytes" "context" - "errors" "fmt" "io" - "io/fs" "net/http" "net/url" + "os" "path/filepath" - "regexp" + "reflect" + "runtime" "strings" "time" + "github.com/dhowden/tag" + "github.com/navidrome/navidrome/consts" + "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/httpclient" - "github.com/navidrome/navidrome/utils/netguard" - "go.senan.xyz/taglib" + "github.com/navidrome/navidrome/resources" ) -// errSourceUnreadable marks a candidate the resolver knows exists but could not read. Failing -// to open it is not evidence the entity has no artwork, so callers must not settle on absent. -var errSourceUnreadable = errors.New("artwork source unreadable") +func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) { + for _, f := range extractFuncs { + if ctx.Err() != nil { + return nil, "", ctx.Err() + } + start := time.Now() + r, path, err := f() + if r != nil { + msg := fmt.Sprintf("Found %s artwork", artID.Kind) + log.Debug(ctx, msg, "artID", artID, "path", path, "source", f, "elapsed", time.Since(start)) + return r, path, nil + } + log.Trace(ctx, "Failed trying to extract artwork", "artID", artID, "source", f, "elapsed", time.Since(start), err) + } + return nil, "", fmt.Errorf("could not get a cover art for %s: %w", artID, ErrUnavailable) +} type sourceFunc func() (r io.ReadCloser, path string, err error) -func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc { +func (f sourceFunc) String() string { + name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() + name = strings.TrimPrefix(name, "github.com/navidrome/navidrome/core/artwork.") + if _, after, found := strings.Cut(name, ")."); found { + name = after + } + name = strings.TrimSuffix(name, ".func1") + return name +} + +func splitList(s string) []string { + return strings.Split(s, consts.Zwsp) +} + +func fromExternalFile(ctx context.Context, files string, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { - var openErr error - for _, file := range files { + for _, file := range splitList(files) { _, name := filepath.Split(file) match, err := filepath.Match(pattern, strings.ToLower(name)) if err != nil { - log.Warn(ctx, "Artwork: Error matching cover art file to pattern", "pattern", pattern, "file", file) + log.Warn(ctx, "Error matching cover art file to pattern", "pattern", pattern, "file", file) continue } - if !match || !model.IsImageFile(name) { + if !match { continue } - f, err := libFS.Open(file) + f, err := os.Open(file) if err != nil { - log.Warn(ctx, "Artwork: Could not open cover art file", "file", file, err) - openErr = fmt.Errorf("%w: %s: %w", errSourceUnreadable, file, err) + log.Warn(ctx, "Could not open cover art file", "file", file, err) continue } - return f, file, nil - } - if openErr != nil { - return nil, "", openErr + return f, file, err } return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files) } } -// These regexes are used to match the picture type in the file, in the order they are listed. -var picTypeRegexes = []*regexp.Regexp{ - regexp.MustCompile(`(?i).*cover.*front.*|.*front.*cover.*`), - regexp.MustCompile(`(?i).*front.*`), - regexp.MustCompile(`(?i).*cover.*`), -} - -func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc { +func fromTag(path string) sourceFunc { return func() (io.ReadCloser, string, error) { - if relPath == "" { + if path == "" { return nil, "", nil } - f, err := libFS.Open(relPath) + f, err := os.Open(path) if err != nil { - return nil, "", fmt.Errorf("%w: %s: %w", errSourceUnreadable, relPath, err) + 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, "", fmt.Errorf("%w: %s: %w", errSourceUnreadable, relPath, err) - } - // Close in LIFO order: tf first (it holds rs internally), then f. defer f.Close() - defer tf.Close() - images := tf.Properties().Images - if len(images) == 0 { - return nil, "", fmt.Errorf("no embedded image found in %s", relPath) + m, err := tag.ReadFrom(f) + if err != nil { + return nil, "", err } - 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", relPath) + picture := m.Picture() + if picture == nil { + return nil, "", fmt.Errorf("no embedded image found in %s", path) } - return io.NopCloser(bytes.NewReader(data)), relPath, nil + return io.NopCloser(bytes.NewReader(picture.Data)), path, nil } } -func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path string) int { - for _, regex := range picTypeRegexes { - for i, img := range images { - if regex.MatchString(img.Type) { - log.Trace(ctx, "Artwork: Found embedded image", "type", img.Type, "path", path) - return i - } - } - } - log.Trace(ctx, "Artwork: Could not find a front image. Getting the first one", "type", images[0].Type, "path", path) - 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 == "" { @@ -132,49 +112,64 @@ func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourc if err != nil { return nil, "", err } - // 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) + defer r.Close() + buf := new(bytes.Buffer) + _, err = io.Copy(buf, r) + if err != nil { + return nil, "", err } - return readCloser{Reader: io.MultiReader(bytes.NewReader(buf[:n]), r), Closer: r}, path, nil + return io.NopCloser(buf), 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) + if err != nil { + return nil, "", err + } + return r, id.String(), nil + } } -// remoteImageClient fetches URLs from playlists and agents (plugins included), so it must not reach -// internal hosts. Shared so fetches reuse connections. -var remoteImageClient = httpclient.NewExternal(5 * time.Second) +func fromAlbumPlaceholder() sourceFunc { + return func() (io.ReadCloser, string, error) { + r, _ := resources.FS().Open(consts.PlaceholderAlbumArt) + return r, consts.PlaceholderAlbumArt, nil + } +} +func fromArtistExternalSource(ctx context.Context, ar model.Artist, em core.ExternalMetadata) sourceFunc { + return func() (io.ReadCloser, string, error) { + imageUrl, err := em.ArtistImage(ctx, ar.ID) + if err != nil { + return nil, "", err + } + + return fromURL(ctx, imageUrl) + } +} + +func fromAlbumExternalSource(ctx context.Context, al model.Album, em core.ExternalMetadata) sourceFunc { + return func() (io.ReadCloser, string, error) { + imageUrl, err := em.AlbumImage(ctx, al.ID) + if err != nil { + return nil, "", err + } + + return fromURL(ctx, imageUrl) + } +} func fromURL(ctx context.Context, imageUrl *url.URL) (io.ReadCloser, string, error) { + hc := http.Client{Timeout: 5 * time.Second} req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageUrl.String(), nil) - resp, err := remoteImageClient.Do(req) - if errors.Is(err, netguard.ErrPrivateAddress) { - // Retrying cannot change where the URL points: settle absent instead of tripping the breaker. - log.Warn(ctx, "Artwork: Refused to fetch image from a private or loopback address", "url", imageUrl, err) - return nil, "", model.ErrNotFound - } + resp, err := hc.Do(req) if err != nil { return nil, "", err } - // An agent-advertised URL that 404s is a definitive miss, not a fault: settle absent - // instead of retrying forever and tripping the artwork breaker. - if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone { - resp.Body.Close() - return nil, "", model.ErrNotFound - } if resp.StatusCode != http.StatusOK { resp.Body.Close() - return nil, "", fmt.Errorf("error retrieving artwork from %s: %s", imageUrl, resp.Status) + return nil, "", fmt.Errorf("error retrieveing artwork from %s: %s", imageUrl, resp.Status) } return resp.Body, imageUrl.String(), nil } diff --git a/core/artwork/sources_internal_test.go b/core/artwork/sources_internal_test.go deleted file mode 100644 index bc81b56e7..000000000 --- a/core/artwork/sources_internal_test.go +++ /dev/null @@ -1,178 +0,0 @@ -package artwork - -import ( - "bytes" - "errors" - "io" - "io/fs" - "net/http" - "net/http/httptest" - "net/netip" - "net/url" - "os" - "strings" - "sync/atomic" - "testing/fstest" - "time" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/httpclient" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("fromURL", func() { - var ( - hits atomic.Int32 - target *httptest.Server - ) - - BeforeEach(func() { - hits.Store(0) - target = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - hits.Add(1) - _, _ = w.Write([]byte("image-bytes")) - })) - DeferCleanup(target.Close) - }) - - useClient := func(c *http.Client) { - prev := remoteImageClient - remoteImageClient = c - DeferCleanup(func() { remoteImageClient = prev }) - } - fetch := func(rawURL string) ([]byte, error) { - u, err := url.Parse(rawURL) - Expect(err).ToNot(HaveOccurred()) - r, _, err := fromURL(GinkgoT().Context(), u) - if err != nil { - return nil, err - } - defer r.Close() - return io.ReadAll(r) - } - // Stand-in for a public host: only 127.0.0.1 is allowed, so every other private address stays refused. - onlyLocalhostV4 := func() *http.Client { - return httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.1/32")) - } - - DescribeTable("refuses private and loopback targets as a definitive miss", - func(rawURL string) { - useClient(productionImageClient) - u, _ := url.Parse(target.URL) - _, err := fetch(strings.ReplaceAll(rawURL, "PORT", u.Port())) - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(hits.Load()).To(BeZero()) - }, - Entry("IPv4 loopback", "http://127.0.0.1:PORT/x"), - Entry("localhost", "http://localhost:PORT/x"), - Entry("cloud metadata", "http://169.254.169.254/"), - Entry("IPv6 loopback", "http://[::1]/"), - ) - - It("refuses a redirect from an allowed host to a loopback address", func() { - useClient(onlyLocalhostV4()) - redirector := httptest.NewServer(http.RedirectHandler(strings.Replace(target.URL, "127.0.0.1", "127.0.0.2", 1), http.StatusFound)) - DeferCleanup(redirector.Close) - - _, err := fetch(redirector.URL) - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(hits.Load()).To(BeZero()) - }) - - It("fetches from an allowed address", func() { - useClient(onlyLocalhostV4()) - Expect(fetch(target.URL + "/cover.jpg")).To(Equal([]byte("image-bytes"))) - }) -}) - -var _ = Describe("fromExternalFile", func() { - It("opens a matching file via the library FS", func() { - fsys := fstest.MapFS{ - "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")) - }) - - It("skips a matching file that is not an image", func() { - fsys := fstest.MapFS{ - "a/cover.ini": &fstest.MapFile{Data: []byte("password=secret")}, - "a/cover.jpg": &fstest.MapFile{Data: []byte("a")}, - } - f := fromExternalFile(GinkgoT().Context(), fsys, []string{"a/cover.ini", "a/cover.jpg"}, "cover.*") - r, path, err := f() - Expect(err).ToNot(HaveOccurred()) - defer r.Close() - Expect(path).To(Equal("a/cover.jpg")) - }) -}) - -var _ = Describe("fromTag", func() { - 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/artwork/thumbhash/testdata/alpha.png b/core/artwork/thumbhash/testdata/alpha.png deleted file mode 100644 index 6ac85943a..000000000 Binary files a/core/artwork/thumbhash/testdata/alpha.png and /dev/null differ diff --git a/core/artwork/thumbhash/testdata/gen_fixtures.mjs b/core/artwork/thumbhash/testdata/gen_fixtures.mjs deleted file mode 100644 index b2e488b51..000000000 --- a/core/artwork/thumbhash/testdata/gen_fixtures.mjs +++ /dev/null @@ -1,90 +0,0 @@ -// Generates lossless PNG fixtures <=100px. Run: node gen_fixtures.mjs -import { writeFileSync } from 'fs' -import { deflateSync } from 'zlib' - -const crcTable = Array.from({ length: 256 }, (_, n) => { - let c = n - for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 - return c >>> 0 -}) -const crc32 = (buf) => { - let c = 0xffffffff - for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8) - return (c ^ 0xffffffff) >>> 0 -} -const chunk = (type, data) => { - const len = Buffer.alloc(4) - len.writeUInt32BE(data.length) - const body = Buffer.concat([Buffer.from(type, 'ascii'), data]) - const crc = Buffer.alloc(4) - crc.writeUInt32BE(crc32(body)) - return Buffer.concat([len, body, crc]) -} -const png = (w, h, rgba) => { - const ihdr = Buffer.alloc(13) - ihdr.writeUInt32BE(w, 0) - ihdr.writeUInt32BE(h, 4) - ihdr[8] = 8 // bit depth - ihdr[9] = 6 // truecolor + alpha - const raw = Buffer.alloc(h * (w * 4 + 1)) - for (let y = 0; y < h; y++) { - raw[y * (w * 4 + 1)] = 0 // filter: none - for (let x = 0; x < w * 4; x++) raw[y * (w * 4 + 1) + 1 + x] = rgba[y * w * 4 + x] - } - return Buffer.concat([ - Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), - chunk('IHDR', ihdr), - chunk('IDAT', deflateSync(raw)), - chunk('IEND', Buffer.alloc(0)), - ]) -} - -// A ramp has exactly-zero DCT coefficients, and a zero coefficient's nibble is decided by float -// rounding noise, so no two summation orders agree on it. Dither gives every coefficient signal. -let seed = 0 -const dither = () => { - seed ^= seed << 13 - seed ^= seed >>> 17 - seed ^= seed << 5 - seed >>>= 0 - return (seed % 9) - 4 -} -const shade = (v) => Math.max(0, Math.min(255, v + dither())) - -const make = (w, h, fn) => { - seed = 0x9e3779b9 - const rgba = new Uint8Array(w * h * 4) - for (let y = 0; y < h; y++) - for (let x = 0; x < w; x++) fn(rgba, (y * w + x) * 4, x, y, w, h) - return png(w, h, rgba) -} - -const gradient = (rgba, i, x, y, w, h) => { - rgba[i] = shade(Math.floor((255 * x) / w)) - rgba[i + 1] = shade(Math.floor((255 * y) / h)) - rgba[i + 2] = shade(Math.floor((255 * (x + y)) / (w + h))) - rgba[i + 3] = 255 -} -const solid = (rgba, i) => { - rgba[i] = 60 - rgba[i + 1] = 120 - rgba[i + 2] = 180 - rgba[i + 3] = 255 -} -// RGB must vary with position too: a constant color composited over its own average cancels to a -// flat L/P/Q (the bug this fixture exists to catch), so pair a color gradient with the alpha ramp. -const alphaRamp = (rgba, i, x, y, w, h) => { - rgba[i] = shade(Math.floor((255 * x) / w)) - rgba[i + 1] = shade(Math.floor((255 * y) / h)) - rgba[i + 2] = shade(Math.floor((255 * (x + y)) / (w + h))) - rgba[i + 3] = shade(Math.floor((255 * (x + 2 * y)) / (w + 2 * h))) -} - -const out = new URL('.', import.meta.url).pathname -writeFileSync(out + 'square.png', make(100, 100, gradient)) -writeFileSync(out + 'landscape.png', make(100, 60, gradient)) -writeFileSync(out + 'portrait.png', make(60, 100, gradient)) -writeFileSync(out + 'alpha.png', make(80, 80, alphaRamp)) -writeFileSync(out + 'solid.png', make(64, 64, solid)) -writeFileSync(out + 'tiny.png', make(1, 1, solid)) -console.log('fixtures written') diff --git a/core/artwork/thumbhash/testdata/gen_generated.mjs b/core/artwork/thumbhash/testdata/gen_generated.mjs deleted file mode 100644 index e8eae585e..000000000 --- a/core/artwork/thumbhash/testdata/gen_generated.mjs +++ /dev/null @@ -1,28 +0,0 @@ -// Produces generated.json: ThumbHashes of synthetic images, from the vendored reference. -// The pixels are a pure function of their index, so Go rebuilds them byte-for-byte from the same -// formula and only the hashes need committing. Run: node gen_generated.mjs -import { writeFileSync } from 'fs' -import { rgbaToThumbHash } from './thumbhash.js' - -const COUNT = 300 - -// mix is a stateless 32-bit finaliser; Go's mix in thumbhash_test.go must match it exactly. -const mix = (n) => { - n = Math.imul(n ^ (n >>> 16), 2246822507) >>> 0 - n = Math.imul(n ^ (n >>> 13), 3266489909) >>> 0 - return (n ^ (n >>> 16)) >>> 0 -} - -const out = [] -for (let i = 0; i < COUNT; i++) { - const w = 1 + (mix(i * 3 + 1) % 100) - const h = 1 + (mix(i * 3 + 2) % 100) - const rgba = new Uint8Array(w * h * 4) - for (let k = 0; k < rgba.length; k++) rgba[k] = mix(i * 1000003 + k) & 255 - // Random alpha is opaque essentially never, so half are forced opaque to exercise the 7x7 - // no-alpha layout as often as the 5x5-plus-alpha one. - if (i % 2 === 0) for (let k = 3; k < rgba.length; k += 4) rgba[k] = 255 - out.push({ w, h, hash: Buffer.from(rgbaToThumbHash(w, h, rgba)).toString('base64') }) -} -writeFileSync(new URL('.', import.meta.url).pathname + 'generated.json', JSON.stringify(out) + '\n') -console.log(`${out.length} vectors; first ${out[0].w}x${out[0].h} ${out[0].hash}`) diff --git a/core/artwork/thumbhash/testdata/gen_golden.mjs b/core/artwork/thumbhash/testdata/gen_golden.mjs deleted file mode 100644 index 2825e0709..000000000 --- a/core/artwork/thumbhash/testdata/gen_golden.mjs +++ /dev/null @@ -1,37 +0,0 @@ -// Produces golden.json from the vendored reference. Run: node gen_golden.mjs -import { readFileSync, writeFileSync, readdirSync } from 'fs' -import { inflateSync } from 'zlib' -import { rgbaToThumbHash } from './thumbhash.js' - -// Minimal reader for the exact PNGs gen_fixtures.mjs writes: 8-bit RGBA, filter 0, single IDAT. -const readPNG = (buf) => { - let w = 0, h = 0 - const idat = [] - for (let off = 8; off < buf.length; ) { - const len = buf.readUInt32BE(off) - const type = buf.toString('ascii', off + 4, off + 8) - const data = buf.subarray(off + 8, off + 8 + len) - if (type === 'IHDR') { - w = data.readUInt32BE(0) - h = data.readUInt32BE(4) - if (data[8] !== 8 || data[9] !== 6) throw new Error('expected 8-bit RGBA') - } else if (type === 'IDAT') idat.push(data) - off += 12 + len - } - const raw = inflateSync(Buffer.concat(idat)) - const rgba = new Uint8Array(w * h * 4) - for (let y = 0; y < h; y++) { - if (raw[y * (w * 4 + 1)] !== 0) throw new Error('expected filter 0') - for (let x = 0; x < w * 4; x++) rgba[y * w * 4 + x] = raw[y * (w * 4 + 1) + 1 + x] - } - return { w, h, rgba } -} - -const dir = new URL('.', import.meta.url).pathname -const golden = {} -for (const name of readdirSync(dir).filter((f) => f.endsWith('.png')).sort()) { - const { w, h, rgba } = readPNG(readFileSync(dir + name)) - golden[name] = Buffer.from(rgbaToThumbHash(w, h, rgba)).toString('base64') -} -writeFileSync(dir + 'golden.json', JSON.stringify(golden, null, 2) + '\n') -console.log(golden) diff --git a/core/artwork/thumbhash/testdata/generated.json b/core/artwork/thumbhash/testdata/generated.json deleted file mode 100644 index 1b49bbb03..000000000 --- a/core/artwork/thumbhash/testdata/generated.json +++ /dev/null @@ -1 +0,0 @@ -[{"w":28,"h":79,"hash":"4AcCCgBFgGVnc6OLh2u/b7Y="},{"w":94,"h":86,"hash":"4PeBBYAIBZlXeaVKmwl6Zx+i9EeEUqdlGA=="},{"w":61,"h":88,"hash":"3/cBBQCxf6l3ioV1WadpmG+B7Pm4"},{"w":29,"h":41,"hash":"H/iBDAIHX5Chbnyqawdo8otij7N3a0qJBA=="},{"w":63,"h":74,"hash":"3wcCBgB0x884dZeDiopMhclYDUpUAm4="},{"w":29,"h":25,"hash":"HwiCBIIHF79zi6+Gg2fAhk/4fEtnapaYDQ=="},{"w":57,"h":98,"hash":"H/gBBABIejmXynwHHoVAnvdXSw=="},{"w":71,"h":69,"hash":"IPiBBYAHmD2KUW/lQQyLaLCDh0KklFn32g=="},{"w":71,"h":50,"hash":"3/cBBYBohqhLaJpwZoZUd011gBCJ"},{"w":89,"h":83,"hash":"4PeBBYAHkBp+Q/bXx0CSiZ+sT4Z4fIyYZg=="},{"w":92,"h":57,"hash":"IPgBBICgJdaPlJqBnUfQcGbwdA=="},{"w":39,"h":95,"hash":"3/eBAgAIymeYlAuWDQ9RUidjZat7jxk="},{"w":100,"h":82,"hash":"4AcCBoDiuKh2N+TrR6fh9nFm+5lp8Kc="},{"w":92,"h":93,"hash":"HwiCBQAIuI2JaEYDVRA6gU/q5PxIULPapw=="},{"w":67,"h":90,"hash":"IPgBBQC1bGaGk47Hl3+nddp6vwuY"},{"w":78,"h":71,"hash":"HwiCBYAHmZZIYn9JhXCLjZaQSWV2ZUGrfw=="},{"w":85,"h":42,"hash":"4AcCA4KeKZCAlcZzkAz7i6k="},{"w":8,"h":50,"hash":"3/eBCQAH+IVwSkj7IiZPL/h0dYpCSEg="},{"w":13,"h":78,"hash":"HwgCCQJGjqtkmUowdno/3fY="},{"w":66,"h":95,"hash":"HwiCAwAHCjuAqh2Fn/H9K5loYGiYmJU="},{"w":2,"h":84,"hash":"nvcFEQJ7c7iHmKwJj5iHCGA="},{"w":37,"h":32,"hash":"IAiCBIIIldBHeXKZ6f3EA2DV0K2GZkvDBA=="},{"w":41,"h":25,"hash":"3wcCDII7Jst5c0WFhga8cEnTBg=="},{"w":57,"h":26,"hash":"IPiBAoAIuvRneZfgjPSmAhZrl9W8SPU="},{"w":31,"h":40,"hash":"IPgBDQK6o7REKkCxJ1cXRlNqsK+B"},{"w":7,"h":69,"hash":"3veBCQIHdoKrFgYOxqU5cAVYqapmgjQ="},{"w":82,"h":59,"hash":"3/cBBYD4M6tIe6iJenZX0jv3i6QF"},{"w":85,"h":48,"hash":"IAiCA4AHyKR2lgvPG0TFBnXQWGkydzw="},{"w":53,"h":67,"hash":"HwgCBgKbVol5V393ersDOea29bSlZPk="},{"w":79,"h":26,"hash":"IAiCAoAI6QZtSGnvJmnwcJiHZquG+Hc="},{"w":14,"h":28,"hash":"3/cBDAJIhLeYi1+pu1owyIVvMg=="},{"w":85,"h":78,"hash":"4AeCBYAHXPhldaZ2ibVPVQmKh/iJlZQrig=="},{"w":26,"h":67,"hash":"H/gBCwI4T6OnR3GPhq81kGo="},{"w":51,"h":13,"hash":"IPiBCYII24eGC4VNfYAJR/S6V8WLeyg="},{"w":57,"h":81,"hash":"4AcCBQCoojSIY0qYe3f3UQGuawz+"},{"w":8,"h":72,"hash":"4PeBCQIHSUVYVn8ANrQoB3UWWYiXWbA="},{"w":89,"h":40,"hash":"IAgCA4LDe0AG5hb2aM8bcEM="},{"w":55,"h":94,"hash":"3/eBAwAHpIVmT7VvjwahW2NPhkiaDok="},{"w":59,"h":81,"hash":"H/gBDQBERoZIo4nKhqgKaJYvSkVA"},{"w":41,"h":45,"hash":"3/eBBQAHdeyYm3UGRR99adcGQ0RwRZpGmA=="},{"w":11,"h":28,"hash":"HxgCCwLHZnhGlGVggoAJ6GU="},{"w":8,"h":36,"hash":"4PeBCQIIbWHpldBF78rO/EKZaaiAqHo="},{"w":40,"h":100,"hash":"IAgCAwAIT2uoiESGs1BDrxw="},{"w":63,"h":33,"hash":"3weCA4IHmrEpmk9KVK92+LOxI+sLqfs="},{"w":72,"h":63,"hash":"HwgCBoCYlQZkaHeXaeabRJit12VwH7Y="},{"w":29,"h":90,"hash":"3/eBAgAIzqlo+KRtoAl1qh2KO7ZwWWo="},{"w":81,"h":26,"hash":"IPgBAoJVdauz6giWSnhgCYk="},{"w":92,"h":76,"hash":"IPiBBIAHrET8bFvpCZQdZQubVvO0vEV5DQ=="},{"w":85,"h":99,"hash":"3wcCBgB4L9nVxUhpWUyqb76n4LjPOfw="},{"w":79,"h":72,"hash":"4AeCBYAHWneox5/01wN7aHnvSAZscNB3qA=="},{"w":76,"h":18,"hash":"4PcBCoL6mZdmelSFz5UU9EQ="},{"w":78,"h":90,"hash":"4PeBBAAIDnhKoKRQpgBqMJy+iar8L4pZDg=="},{"w":72,"h":13,"hash":"3+cBCYKsdkJZyx6/lICO+8I="},{"w":78,"h":19,"hash":"H/iBAYAImX9hwHSeDXImENV3UIN1hMc="},{"w":29,"h":34,"hash":"IAgCDgB+WIp4KreKyhKJX6mqKKCvEI0="},{"w":63,"h":43,"hash":"H/iBA4AI++eiSIlZkLl/ZwKZhrlnSqg="},{"w":19,"h":79,"hash":"3wcCCgD1k3t0hnYqBHm6Di4="},{"w":82,"h":44,"hash":"3/eBA4AIqi2AjW6pr5YLmwF+OlNou7s="},{"w":70,"h":24,"hash":"3wcCCoAyRWCKl2Z3kHa6A1o="},{"w":28,"h":93,"hash":"4PeBCgAHwrsHVqZgqIePdy6kkJh4RRY="},{"w":44,"h":28,"hash":"H/gBBIJ2xMevRKKpZ4bnUTD6hQ=="},{"w":77,"h":9,"hash":"HwiCCYIIxixdZgmgw/m6OIcDrDM6WZ0="},{"w":59,"h":39,"hash":"H/gBBYKlW6qoh11be/NmovTQCbYc"},{"w":9,"h":93,"hash":"4PeBAQIHFVn4p5mIv/DH95KWbUXgUOk="},{"w":9,"h":96,"hash":"3wcCAQKKqluVczewLNPP0G8="},{"w":54,"h":83,"hash":"IPiBAwAIZVRaj5dvnH4NiFfGpfFPxjg="},{"w":75,"h":3,"hash":"3/cFEYR4iPWKmIiV99OsmPc="},{"w":96,"h":42,"hash":"4PeBAoAHcJqRe3qfQ6f3THWIkFi2ivY="},{"w":74,"h":8,"hash":"4PcBCYJ8JYC0NJh5+a5HnaA="},{"w":2,"h":95,"hash":"oPeBCQR3f82Im7eL3/dIdXgHiHiHiHc="},{"w":40,"h":25,"hash":"3/cBDIJGPXu1PFWvGyI2SH8JWA=="},{"w":48,"h":56,"hash":"H/iBDAAHU4OHDHmKpoT1qngAiHZWl2d4CQ=="},{"w":71,"h":18,"hash":"3/cBCoBFzceDtlBaL20Z8Ok="},{"w":60,"h":44,"hash":"3/eBBIAHqreoZmb9ePMT+YS4hIZZmncHCg=="},{"w":42,"h":25,"hash":"3/cBDIKPdrNMd9eNmFlSn7SZCw=="},{"w":64,"h":67,"hash":"IPiBBQAHbSpXaJBWxEgLybIIa6jFdgxmsw=="},{"w":74,"h":85,"hash":"4AcCBgCVe0R0W1l7eTSq0/dH+zScBSU="},{"w":42,"h":71,"hash":"4AeCAwAIZyCedbhPdZlPiaKXHQRSNeA="},{"w":61,"h":34,"hash":"H/gBBII/nL94LK58lLTVCUz9Jw=="},{"w":26,"h":66,"hash":"4AeCCgAISob0BHp4m1BvNhVnCiiFAtM="},{"w":17,"h":62,"hash":"IAgCCgI8jI9nEofooGprka8="},{"w":58,"h":90,"hash":"IAiCAwAI+Rt0bsP6o/o02aef81rcY4g="},{"w":41,"h":96,"hash":"H/gBAwDLuolgvElQHQppYPY="},{"w":57,"h":6,"hash":"IPiBCYIIh0q8dn/PhQBnd8ApWKNbdKo="},{"w":38,"h":47,"hash":"4PcBDgI6lpeRR2aABrhXvWW0UNaEIHg="},{"w":76,"h":6,"hash":"3xeCCYAISIuiIk91a6CpEKQVvUdsBYc="},{"w":85,"h":66,"hash":"H/gBBYDvamWWd5h1Vo2YlvdTB6/w"},{"w":7,"h":52,"hash":"4AeCCQII+ZuGYmj6XPaXdqa3B0a2dNA="},{"w":1,"h":4,"hash":"Hhk+UjgIh2iYh4iYGIuPh/g="},{"w":19,"h":26,"hash":"3weCDAIHY2JJumCmuqb1ToC4Yy94dxtUBw=="},{"w":63,"h":98,"hash":"3/cBBQBSUpp6Kn2Gnn+ikgXX5EBM"},{"w":36,"h":27,"hash":"IPiBBIAHh6xAemiM6Of5b5+q+XOJtpUwBg=="},{"w":69,"h":50,"hash":"IAgCBYK2ilWImIhgd8ikFPeoVvSn"},{"w":17,"h":100,"hash":"HwiCAQAI8el0tLUJG2OAFon6I0mAZ8U="},{"w":59,"h":24,"hash":"4AcCC4Inv4c4e2OIYDUGV7g="},{"w":57,"h":48,"hash":"3/eBBIAHBXh6Y4qllpB1ryKBqvqpSomhDA=="},{"w":90,"h":41,"hash":"IAgCC4CidBWGNweJm6+coBY="},{"w":68,"h":3,"hash":"oBiCCYII8hsmqlAgxcXwJHh2mAVZhWc="},{"w":77,"h":25,"hash":"4PcBCoL0iEqFdXO2zo+n9Eo="},{"w":50,"h":41,"hash":"HwiCBIAHSK9XhZbW6qiAX4j+o1Yn5JPmBA=="},{"w":95,"h":87,"hash":"3/cBBoBbqfUutpKElbmKm4B0C4I0ePA="},{"w":19,"h":7,"hash":"H/iBCoIYmUl/V1VESZ+mA6eLeHenBnY="},{"w":62,"h":28,"hash":"3wcCC4JqV4G1jAeTn3ttsvs="},{"w":80,"h":84,"hash":"HwiCBQAItaKauK+3WPxqSRf6TQlTiYgs1w=="},{"w":58,"h":11,"hash":"IPgBCYJWdVboUvKJ+nc6wI4="},{"w":62,"h":1,"hash":"oRjCKYZ3iIiIiHCXiJCLCIeIiIiAd/g="},{"w":83,"h":84,"hash":"IAgCBwCXsHOreqRrykpmV4t8lq9nsqEP"},{"w":38,"h":50,"hash":"HwiCBAAIMJSGqHiF+EsGGyZ/MLJmynBjAA=="},{"w":90,"h":25,"hash":"4AcCAoAmQEenlsuKgE4WyvY="},{"w":44,"h":34,"hash":"4AeCDIAIlUgqYFOZB1WIemC1gMKUhIq2Bg=="},{"w":44,"h":58,"hash":"4AcCDQJ1xMubx4YIEjhy2ct7H/IW"},{"w":53,"h":97,"hash":"3/eBAwAIfvkuWL5QdinGj6Tb3EAkpKk="},{"w":46,"h":78,"hash":"3wcCBAD69gtnSeOmSQvGoFC4QA=="},{"w":1,"h":91,"hash":"Xui9GQR3CIiHeIgIiXhfqAj3h4iHiHg="},{"w":62,"h":53,"hash":"HwgCBoDPnGynWGlHeHhaWbZ4ymtwP7k="},{"w":31,"h":16,"hash":"HwiCC4IHYMrnVnx9T5138ke3RXBlSHs="},{"w":79,"h":89,"hash":"3wcCBgCwS65nmVZ1Z3pFOze0G4xfP4U="},{"w":88,"h":46,"hash":"HwiCA4AIeft1qUicgPivQEqYkHp7hEY="},{"w":42,"h":42,"hash":"IAgCBwJd0ybqWZCUd6pdbkhHCZnq9ZoE"},{"w":33,"h":75,"hash":"4AeCAgAIin9mWYoCq51AulUCcmlfBB0="},{"w":74,"h":1,"hash":"3ic+AYp4h4eIiAiHD4hcjQg="},{"w":55,"h":35,"hash":"4AeCA4AIeIClN5NyENOgNZyaiIljBmw="},{"w":2,"h":11,"hash":"HlgKIQZwiHh0aZdocCUKd0w="},{"w":29,"h":33,"hash":"IPiBDAIH9lRItpqqd6CWmKAo+ad49JdoCg=="},{"w":63,"h":40,"hash":"HwgCBIBmUIs2Vol2OVgfOEvSHw=="},{"w":77,"h":90,"hash":"4PeBBAAINniEP5lGAIykwV9XCmS5VmjHCg=="},{"w":78,"h":14,"hash":"3wcCCYBGGoFHx5agj3xrR+8="},{"w":37,"h":40,"hash":"4PeBBQAHy1gEeihmdftWmwDn5DhniHAnSA=="},{"w":79,"h":27,"hash":"H/gBCoCc92ZmanaKgGcGl5k="},{"w":32,"h":86,"hash":"3weCAgAHW6NocGpT+AjOVShetbR/aso="},{"w":14,"h":30,"hash":"H/gBCwLNrIeFxaZPd6YvX1o="},{"w":33,"h":30,"hash":"oAeCBYIHarRwplZVhZj915j1dwd5Y352aQ=="},{"w":62,"h":95,"hash":"H/gBBQB5kGdZ1udkd3lJiV2PbAVc"},{"w":38,"h":93,"hash":"3weCAgAH5D7FRwOFXbAZ+fVpeYk0iHY="},{"w":47,"h":32,"hash":"3/cBDYJ0r1iqSJRkd6p3V3s/a9B/"},{"w":24,"h":9,"hash":"3weCCoIXijxlaV+Eb3RI9odfWVeFVEo="},{"w":17,"h":54,"hash":"4PcBAgSYZHWYgGhbIwMCqHU="},{"w":8,"h":78,"hash":"X/iBAQIIiFJTAjdPiZYEl2PU18bU8nQ="},{"w":8,"h":29,"hash":"HvgBCgYccKvOOXhJ20mAnyg="},{"w":90,"h":54,"hash":"H/iBA4AIb+2Md4RWn/Kfe/FJlyZ1jaQ="},{"w":88,"h":16,"hash":"H/gBCYBnc+aN1jVQYFe1Ov4="},{"w":38,"h":43,"hash":"IAiCBAAHzaaMBvyTQkB3gJ94bziEt4h4AQ=="},{"w":45,"h":33,"hash":"H/gBDYLClnt/2qJ5pLuZZpLzfW+Z"},{"w":9,"h":54,"hash":"3weCAQIIGp+aj3Wd9/GWhYjzxmlbRNg="},{"w":97,"h":47,"hash":"HwgCC4DRtDd8iLn5CHJ3Dnc="},{"w":69,"h":50,"hash":"HwiCBIAHyWepeKMEBzTqh8/5k21oaT90Bw=="},{"w":13,"h":65,"hash":"3/cBCQJpo8dpn410PLfAMvg="},{"w":25,"h":30,"hash":"IPiBBAIHqzf2RJS69XfSTG8Ld2qHiYe2Bw=="},{"w":35,"h":77,"hash":"IPgBAwC/xkaVdZYoy6K/wGU="},{"w":71,"h":99,"hash":"3/eBBAAI8jHjH4eYVgrLOHcQSAdCRrQ3Cw=="},{"w":51,"h":61,"hash":"HwgCDgB4ioRm1K+IiVmHolm68zhYANY="},{"w":99,"h":81,"hash":"4AeCBIAH/JSLl4h51voehr91sfZoWH5pBw=="},{"w":58,"h":89,"hash":"4AcCBQAN9jWNunlhuGOXV/qTrGQG"},{"w":92,"h":16,"hash":"H/iBAYAHzWadOEC11RAUXzefbZKQ2lw="},{"w":20,"h":9,"hash":"3vcBC4R6lm+5cB20+YmS1Ag="},{"w":87,"h":10,"hash":"3+eBAYII7ZVjl0/0dZfQebdwhKU8pfo="},{"w":59,"h":3,"hash":"nwcCCYIU+4fttp4sIM3/v4Y="},{"w":13,"h":39,"hash":"H/iBCgII915YU3MIrA+InAykfK6VeHk="},{"w":22,"h":87,"hash":"3/cBCgJ3MMpXK6a/3GXAf2Y="},{"w":20,"h":61,"hash":"H/iBCgIHcMhmxIC4f1uGBYYJhDl0p9c="},{"w":66,"h":33,"hash":"HwgCBIIVdWCYY5inCWn6Zwzbhg=="},{"w":24,"h":56,"hash":"HwiCAgAIV60ZefWzRA93WHxypWdW1wc="},{"w":36,"h":4,"hash":"HvgBGYaQohaH1cJad41gsFs="},{"w":93,"h":76,"hash":"IPiBBIAIcF8CZi2l97tl7aDCr7ubuXQ4Bg=="},{"w":50,"h":49,"hash":"IAgCB4K8Q4h4hHd5l/iUSzW62DWwG5AE"},{"w":89,"h":33,"hash":"H/iBAoAItPphyoZ4+FLXCCappHhPZLM="},{"w":88,"h":38,"hash":"4PcBA4Dgi0XjhoOT8obwtio="},{"w":92,"h":5,"hash":"4PeBAYII9+Mpe4oUCPQYNXqZtjd56L8="},{"w":16,"h":43,"hash":"HwgCCwLaREjvVTaWYGcna/o="},{"w":43,"h":25,"hash":"H/iBC4IIh7kDmbD1XptgRBwqiJg/xcs="},{"w":23,"h":15,"hash":"IAgCFYLE742XWmeuhhpXpJRT8NAa"},{"w":81,"h":7,"hash":"IPiBAYII/XuJaMxPhJlP16ewLUeXWQQ="},{"w":43,"h":10,"hash":"4PcBCoLS3wRpnEkUcvkwb3Q="},{"w":40,"h":31,"hash":"4PeBBIAHbvoid+O8vCBou5CEhcBWSHukBg=="},{"w":2,"h":54,"hash":"XggCGQh2P1eHhUhzil+FCIM="},{"w":81,"h":51,"hash":"HwiCA4AIXq5fhicHMAp5mmqAO6paeLQ="},{"w":66,"h":34,"hash":"3wcCBIKiaF02Rp91WXV/yMDyNw=="},{"w":82,"h":74,"hash":"4PeBBYAIuB98SKHF1VCErjT2iqiJh49Jlw=="},{"w":68,"h":37,"hash":"H/gBBICMWJmAh1d2Z4isj9tQYw=="},{"w":97,"h":1,"hash":"nxe+GYR3d3iIiIBYh8+FCId4iIiAd/g="},{"w":35,"h":74,"hash":"IAgCAwJaeHZYpGRvC0sBaJg="},{"w":84,"h":16,"hash":"4AeCAYIHtlwCmBe5QIZI/dVrfKfVQJM="},{"w":25,"h":39,"hash":"HwgCDAJR+5x4R7xqGLm9dQAwSA=="},{"w":7,"h":89,"hash":"3weCAQIH71t0jh3R8bisB2CGlqVZa/w="},{"w":11,"h":93,"hash":"4PcBEQIIiUqUVniGpkCGDmY="},{"w":28,"h":16,"hash":"3/eBC4AHZgaLiVrzW/ntHXFCuDtPelc="},{"w":47,"h":1,"hash":"4OdBGYaHiHiIiAh4+ojcfPg="},{"w":83,"h":79,"hash":"4PeBBYAHWU/JlHJmlyqL8DdUi9V3hwhJZQ=="},{"w":31,"h":58,"hash":"3/cBDACMjykH1lXXk4f1m1lXwA=="},{"w":87,"h":27,"hash":"3/eBAoAIWAuadHnSkGf6ZDCpWDbDVHk="},{"w":16,"h":25,"hash":"HxgCDALVgxjVRLTpv0gXoIL7lg=="},{"w":95,"h":6,"hash":"3/eBAYAHNhipdw3ol29wPIUrqUSnB0Y="},{"w":40,"h":79,"hash":"3/cBBACXn5bth4n5V0ifeYb4CA=="},{"w":37,"h":88,"hash":"3/eBAgAHatYMrGagPXqAUTT0aZlkV6k="},{"w":7,"h":64,"hash":"H/gBCQI1U92VyEzQtwv3UiI="},{"w":73,"h":44,"hash":"4AeCA4AI27s6T2uqZi+wyMCreGFnrNs="},{"w":13,"h":53,"hash":"H/gBCgKVPabFiVmfjzmJPgc="},{"w":26,"h":88,"hash":"4AeCAQAH+JrYmobr0Ntl9nJ0DUiHpaU="},{"w":92,"h":96,"hash":"H/gBBwDjZwxVe4qVdpSGh3maxpnPRPAB"},{"w":22,"h":93,"hash":"IPiBAQAIem4GeFLLT5awR4mPYXKA5Tc="},{"w":11,"h":63,"hash":"3/cBCQIEtyd3dmiLRl8zsW8="},{"w":1,"h":16,"hash":"IUbCgRB3CIiHeHj3iIhgOAj3homHiJg="},{"w":86,"h":54,"hash":"3wcCBIBmpUU+NtE5BapGT91XLw=="},{"w":45,"h":45,"hash":"3/eBBQAIjRSWIMBjFweKEy/f4ohbs78Dtw=="},{"w":86,"h":72,"hash":"IPgBBoDpdbynqIevhmp4hEio9NP2gBk="},{"w":56,"h":61,"hash":"HwiCBQAH/ErFesNrqQBrC8foSKc/e7px+A=="},{"w":36,"h":82,"hash":"IPgBAwDXSB9klzdqJkDSCJM="},{"w":19,"h":26,"hash":"IPiBBAIHimHC+WqsTSHQA8CFXPR1aWR7Bw=="},{"w":63,"h":30,"hash":"4AcCC4I3YoJa+ltaWY+Hed8="},{"w":22,"h":67,"hash":"H/iBCgAIdFg69+dzCKkNMJDF6aaPSyc="},{"w":7,"h":4,"hash":"XggGJIgRdYiZWVxIaK+wffh5Sg=="},{"w":65,"h":44,"hash":"H/iBA4AI2jgH3cmnUEkHdX5wtrmFaHQ="},{"w":97,"h":19,"hash":"HwgCCYBPdSVmN45n94dKaAM="},{"w":67,"h":11,"hash":"HwiCCYIImJywpza6bza/d0WndXmo+Dc="},{"w":83,"h":76,"hash":"3wcCBoAIXnqYdVekp+pHk3pn3blfP2g="},{"w":80,"h":98,"hash":"IAiCBAAI39GbiZdfdejP/6+SXIhJSPW3BA=="},{"w":72,"h":86,"hash":"3/cBBgKS+2i4h4h1a46YhnlKwb9G+qc="},{"w":78,"h":29,"hash":"3weCAoAIY2hKCQcA2gp4mpvrhouJX2c="},{"w":91,"h":1,"hash":"IfhBEYZ3iIeIiAiIB4j1i4g="},{"w":68,"h":99,"hash":"IAiCAwAIvewBFkMMqfxbLHqvcVbUZms="},{"w":79,"h":4,"hash":"4AcCCYSbW4Z9nYBL6TpgcE0="},{"w":80,"h":44,"hash":"IAiCA4IHxb+flWn5zAqJiPi0z6Vxk5g="},{"w":43,"h":36,"hash":"4PcBDoC6Qb9xjJWgeX5rc6hoklAaxH8="},{"w":61,"h":21,"hash":"4PeBAoAHoJ2KVFQu/C75JX+S/veGdqI="},{"w":23,"h":93,"hash":"4AcCCgKo/IiJRxNo2ZmfDd0="},{"w":96,"h":65,"hash":"HwiCA4AIgHXGaTw2+itpEHn4d5dxd6c="},{"w":90,"h":27,"hash":"IPgBCoBhgm/V6idYX+m39Sg="},{"w":81,"h":31,"hash":"3weCAoAHn6dXpYipr98KiLRmcGlrmIc="},{"w":73,"h":41,"hash":"3/cBBIA25bcwaFfYQdYCSqbxlg=="},{"w":91,"h":68,"hash":"3/eBBIAI0Jd2OHJIevfJgHx8NNZGpwl/CA=="},{"w":94,"h":74,"hash":"IPgBBoBvTGmIeYhay4x3Z3U3pn8gD0I="},{"w":72,"h":34,"hash":"3weCAoAHQIe83bnPpAqzRjjYpIKvlzc="},{"w":51,"h":34,"hash":"HwgCBYA6fzbdPH26bIaJuMpavwGt"},{"w":55,"h":33,"hash":"H/iBA4AHNLA1nUbPzZ4NNVOQpXiZ11k="},{"w":92,"h":20,"hash":"4PcBAoL2t02KyoishKT/vWc="},{"w":1,"h":94,"hash":"Hvi9CQKICId4iIgYj3tPOAj3h4iHiHg="},{"w":46,"h":34,"hash":"HwgCDYCYTefPhieK2bw3PMCa2Lrw"},{"w":64,"h":30,"hash":"IAiCAoAHTDx/WamEcJBD9saIKnufjmc="},{"w":36,"h":19,"hash":"HwgCDIKHuMmduHeamoBgk/PTeg=="},{"w":90,"h":94,"hash":"IAiCBQAHZy+dmah1KFYwiwVXfURlxoBptQ=="},{"w":89,"h":49,"hash":"H/gBBIBpkIl5rJU5yJrcT/x9UQ=="},{"w":34,"h":2,"hash":"Y8iBCYSIVbsJgXeT+wc2iYeHh4eHiAc="},{"w":25,"h":40,"hash":"3wcCDAKkNHd3x4wKpngw3Pa3Cw=="},{"w":47,"h":32,"hash":"IPiBA4AHl3CHY4VS/WGvOem9n4xZNiY="},{"w":53,"h":96,"hash":"4PcBBAB4zFkHVnZ5Vnb+cQoyqA=="},{"w":47,"h":93,"hash":"4PeBAwAHovPYd5nJhr9y84volUge9qQ="},{"w":86,"h":89,"hash":"4AcCBwIxnclaVpZmhwuXeIqsOUOABWQF"},{"w":40,"h":90,"hash":"IPiBAgAIzWDGoI4lL8cOkpTyeDiGmHY="},{"w":38,"h":66,"hash":"H/gBBAK5wb10WqSEx3BqgHasQA=="},{"w":12,"h":21,"hash":"4AeCAwIHU5iHp/TaqbBfi7ypznOSRIA="},{"w":54,"h":29,"hash":"3/cBBILTCdd3yod7eIVwrqfOnw=="},{"w":78,"h":77,"hash":"3weCBYAIfO9WhGEMTcBzWAd3QJc6hLVohw=="},{"w":88,"h":96,"hash":"3/cBBgCfiF9LQRun2ME2UJ5yuGC1Dgg="},{"w":49,"h":89,"hash":"4PeBAwAIKc/XsRei+QLKXFcyRHdFSvM="},{"w":19,"h":41,"hash":"3wcCCwKIj4pZgGhJXAfKXfQ="},{"w":70,"h":53,"hash":"IPiBBIII2CpoSAcGTfhW94S4b3aFdlqmBQ=="},{"w":55,"h":57,"hash":"4PcBBwAGraR0aVs6blrol9aYrApGsBgG"},{"w":54,"h":29,"hash":"3/eBC4AHyLBqhpD4VxQX9hqwzY9DuVg="},{"w":15,"h":75,"hash":"HwgCCQKJ9qeHapW2EDvPhPc="},{"w":93,"h":81,"hash":"H/iBBIAHC2WedpVnfngIVnX/YBI7RopLBw=="},{"w":64,"h":96,"hash":"4AcCBQCMiJ5GnFu6rDv1yHqfSLQP"},{"w":76,"h":44,"hash":"IPiBA4AHqQeWWI2puG+/SsVZfH+EKFc="},{"w":88,"h":33,"hash":"IAgCA4L4N5JQnp1odH8Id50="},{"w":1,"h":34,"hash":"Xue9MQaICIh3iHj3h3WfiAj3h4iId4g="},{"w":30,"h":4,"hash":"IOgFGYa2AneplIqU+YGncKc="},{"w":92,"h":76,"hash":"HwiCBIAHKrOomZBJlckKC2XFT54JnMSLCA=="},{"w":91,"h":16,"hash":"IPgBCYLldw2ryLmLYHjxVkk="},{"w":3,"h":26,"hash":"3yeGCQIYiY/WpUpL9DmJ/2WYd4anCrk="},{"w":2,"h":14,"hash":"YNcFCQ56SgeK1ld+hvp2+FY="},{"w":93,"h":90,"hash":"IPiBBYAHlR+Hm5gJyPdlAqqImTuPWXlFeA=="},{"w":47,"h":88,"hash":"3/cBBAALd4h3lHZpeIlfs6NTDQ=="},{"w":35,"h":32,"hash":"4AeCBYIHdgJPGVxze8rQrvpIvplllljXBw=="},{"w":38,"h":5,"hash":"4AcCCYRTKNxEawW3UJsl/5Y="},{"w":8,"h":78,"hash":"H/iBCQIHjIUIg8cEM2XQxWp6oKdah5A="},{"w":39,"h":36,"hash":"3/cBDoJQZreZx2RPmKWGpxpkSXA6/EM="},{"w":55,"h":13,"hash":"HwiCAYAHfuUceQxcnO/ZMFE234F3aZY="},{"w":13,"h":56,"hash":"IAgCCgJ7gviHc4ur2H4A1gI="},{"w":26,"h":22,"hash":"3weCBIIHx2iLhiCf+OrZYBToaS9I6Zd1BA=="},{"w":83,"h":19,"hash":"3wcCCoL5dJtjKLdzdZvA/G0="},{"w":7,"h":9,"hash":"IRiCDAQXc8cFtlkd8SmVQIVf+rcJWDm7BA=="},{"w":90,"h":43,"hash":"4PcBA4BWXGyC3oWPkJfDrgo="},{"w":43,"h":77,"hash":"IPiBAwAHhT8asHZ/VIr3tMogVkNnm1g="},{"w":36,"h":27,"hash":"IPgBBYKokWWJhMqLlilFBLsGpL/F"},{"w":86,"h":62,"hash":"3weCBIAHsSsGVZlmSOT8f6tyeRCPwHh6DA=="},{"w":63,"h":89,"hash":"IAgCBQC69pVJd3nafFOo1Xr893oE"},{"w":69,"h":98,"hash":"4AeCBAAHidVTQLuXiQbeVJ9H32iHhUaHAg=="},{"w":12,"h":58,"hash":"HwgCCQKZX0iHh3rGb6X1Nec="},{"w":7,"h":7,"hash":"YPeFFQYXh8baRainTwmCfET7/Km5ia0jxw=="},{"w":40,"h":12,"hash":"IPgBEoSX8Ul3WeSbcLd1Zfg="},{"w":93,"h":47,"hash":"4PeBA4AHY4N7pPu7oJO5AnmeQLYcRLk="},{"w":15,"h":42,"hash":"4AcCCwJ0D/zATmOWM/r3xXM="},{"w":67,"h":45,"hash":"3/eBA4AHVbr4h517mv+eZprFsJioaoQ="},{"w":67,"h":74,"hash":"IAgCBgBqmUc1R2d6iqtrigl4lSf/VXQ="},{"w":29,"h":67,"hash":"3weCAgAHYJRmaGqgmaOAV5q0AZR2akk="},{"w":43,"h":10,"hash":"4AcCCoL8SVDXdVsyiECVaK8="},{"w":75,"h":40,"hash":"IAiCA4AHw2mXw6/AoA5Eo8onAwY4s2o="},{"w":73,"h":91,"hash":"3wcCBgDFhsbJlnmDaYxwRZZUl5GAfzY="},{"w":21,"h":54,"hash":"4AeCAgIHWamZCKf0fNZpTyw6aHxHXzM="},{"w":98,"h":52,"hash":"H/gBBICZZsltiKuMUHewoYEI5g=="},{"w":2,"h":29,"hash":"XwiCCQR3cpuYwKqODEIHWXgHiHiIiIc="}] diff --git a/core/artwork/thumbhash/testdata/golden.json b/core/artwork/thumbhash/testdata/golden.json deleted file mode 100644 index 578986e75..000000000 --- a/core/artwork/thumbhash/testdata/golden.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "alpha.png": "JOiFBQ4nkIexh3p4iA8uB+lYhIeAh3d4dw==", - "landscape.png": "3wcOFJpwh4eBh3d4iIePgAj3hw==", - "portrait.png": "3/cNFBqBB4iId4d3d4iAjwj4hw==", - "solid.png": "HoUBBwB4eHeHd3hweId3h3h4B2+Ih4gA", - "square.png": "H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I", - "tiny.png": "HoU9tx4I9wiIh4hwj3CI+AiIcH/494cP" -} diff --git a/core/artwork/thumbhash/testdata/landscape.png b/core/artwork/thumbhash/testdata/landscape.png deleted file mode 100644 index 413851f47..000000000 Binary files a/core/artwork/thumbhash/testdata/landscape.png and /dev/null differ diff --git a/core/artwork/thumbhash/testdata/portrait.png b/core/artwork/thumbhash/testdata/portrait.png deleted file mode 100644 index bc6e5f0de..000000000 Binary files a/core/artwork/thumbhash/testdata/portrait.png and /dev/null differ diff --git a/core/artwork/thumbhash/testdata/solid.png b/core/artwork/thumbhash/testdata/solid.png deleted file mode 100644 index 3c53a36bf..000000000 Binary files a/core/artwork/thumbhash/testdata/solid.png and /dev/null differ diff --git a/core/artwork/thumbhash/testdata/square.png b/core/artwork/thumbhash/testdata/square.png deleted file mode 100644 index a81000cbc..000000000 Binary files a/core/artwork/thumbhash/testdata/square.png and /dev/null differ diff --git a/core/artwork/thumbhash/testdata/thumbhash.js b/core/artwork/thumbhash/testdata/thumbhash.js deleted file mode 100644 index 369b118ec..000000000 --- a/core/artwork/thumbhash/testdata/thumbhash.js +++ /dev/null @@ -1,288 +0,0 @@ -/** - * Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A. - * - * @param w The width of the input image. Must be ≤100px. - * @param h The height of the input image. Must be ≤100px. - * @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements. - * @returns The ThumbHash as a Uint8Array. - */ -export function rgbaToThumbHash(w, h, rgba) { - // Encoding an image larger than 100x100 is slow with no benefit - if (w > 100 || h > 100) throw new Error(`${w}x${h} doesn't fit in 100x100`) - let { PI, round, max, cos, abs } = Math - - // Determine the average color - let avg_r = 0, avg_g = 0, avg_b = 0, avg_a = 0 - for (let i = 0, j = 0; i < w * h; i++, j += 4) { - let alpha = rgba[j + 3] / 255 - avg_r += alpha / 255 * rgba[j] - avg_g += alpha / 255 * rgba[j + 1] - avg_b += alpha / 255 * rgba[j + 2] - avg_a += alpha - } - if (avg_a) { - avg_r /= avg_a - avg_g /= avg_a - avg_b /= avg_a - } - - let hasAlpha = avg_a < w * h - let l_limit = hasAlpha ? 5 : 7 // Use fewer luminance bits if there's alpha - let lx = max(1, round(l_limit * w / max(w, h))) - let ly = max(1, round(l_limit * h / max(w, h))) - let l = [] // luminance - let p = [] // yellow - blue - let q = [] // red - green - let a = [] // alpha - - // Convert the image from RGBA to LPQA (composite atop the average color) - for (let i = 0, j = 0; i < w * h; i++, j += 4) { - let alpha = rgba[j + 3] / 255 - let r = avg_r * (1 - alpha) + alpha / 255 * rgba[j] - let g = avg_g * (1 - alpha) + alpha / 255 * rgba[j + 1] - let b = avg_b * (1 - alpha) + alpha / 255 * rgba[j + 2] - l[i] = (r + g + b) / 3 - p[i] = (r + g) / 2 - b - q[i] = r - g - a[i] = alpha - } - - // Encode using the DCT into DC (constant) and normalized AC (varying) terms - let encodeChannel = (channel, nx, ny) => { - let dc = 0, ac = [], scale = 0, fx = [] - for (let cy = 0; cy < ny; cy++) { - for (let cx = 0; cx * ny < nx * (ny - cy); cx++) { - let f = 0 - for (let x = 0; x < w; x++) - fx[x] = cos(PI / w * cx * (x + 0.5)) - for (let y = 0; y < h; y++) - for (let x = 0, fy = cos(PI / h * cy * (y + 0.5)); x < w; x++) - f += channel[x + y * w] * fx[x] * fy - f /= w * h - if (cx || cy) { - ac.push(f) - scale = max(scale, abs(f)) - } else { - dc = f - } - } - } - if (scale) - for (let i = 0; i < ac.length; i++) - ac[i] = 0.5 + 0.5 / scale * ac[i] - return [dc, ac, scale] - } - let [l_dc, l_ac, l_scale] = encodeChannel(l, max(3, lx), max(3, ly)) - let [p_dc, p_ac, p_scale] = encodeChannel(p, 3, 3) - let [q_dc, q_ac, q_scale] = encodeChannel(q, 3, 3) - let [a_dc, a_ac, a_scale] = hasAlpha ? encodeChannel(a, 5, 5) : [] - - // Write the constants - let isLandscape = w > h - let header24 = round(63 * l_dc) | (round(31.5 + 31.5 * p_dc) << 6) | (round(31.5 + 31.5 * q_dc) << 12) | (round(31 * l_scale) << 18) | (hasAlpha << 23) - let header16 = (isLandscape ? ly : lx) | (round(63 * p_scale) << 3) | (round(63 * q_scale) << 9) | (isLandscape << 15) - let hash = [header24 & 255, (header24 >> 8) & 255, header24 >> 16, header16 & 255, header16 >> 8] - let ac_start = hasAlpha ? 6 : 5 - let ac_index = 0 - if (hasAlpha) hash.push(round(15 * a_dc) | (round(15 * a_scale) << 4)) - - // Write the varying factors - for (let ac of hasAlpha ? [l_ac, p_ac, q_ac, a_ac] : [l_ac, p_ac, q_ac]) - for (let f of ac) - hash[ac_start + (ac_index >> 1)] |= round(15 * f) << ((ac_index++ & 1) << 2) - return new Uint8Array(hash) -} - -/** - * Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A. - * - * @param hash The bytes of the ThumbHash. - * @returns The width, height, and pixels of the rendered placeholder image. - */ -export function thumbHashToRGBA(hash) { - let { PI, min, max, cos, round } = Math - - // Read the constants - let header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16) - let header16 = hash[3] | (hash[4] << 8) - let l_dc = (header24 & 63) / 63 - let p_dc = ((header24 >> 6) & 63) / 31.5 - 1 - let q_dc = ((header24 >> 12) & 63) / 31.5 - 1 - let l_scale = ((header24 >> 18) & 31) / 31 - let hasAlpha = header24 >> 23 - let p_scale = ((header16 >> 3) & 63) / 63 - let q_scale = ((header16 >> 9) & 63) / 63 - let isLandscape = header16 >> 15 - let lx = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7) - let ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7) - let a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1 - let a_scale = (hash[5] >> 4) / 15 - - // Read the varying factors (boost saturation by 1.25x to compensate for quantization) - let ac_start = hasAlpha ? 6 : 5 - let ac_index = 0 - let decodeChannel = (nx, ny, scale) => { - let ac = [] - for (let cy = 0; cy < ny; cy++) - for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) - ac.push((((hash[ac_start + (ac_index >> 1)] >> ((ac_index++ & 1) << 2)) & 15) / 7.5 - 1) * scale) - return ac - } - let l_ac = decodeChannel(lx, ly, l_scale) - let p_ac = decodeChannel(3, 3, p_scale * 1.25) - let q_ac = decodeChannel(3, 3, q_scale * 1.25) - let a_ac = hasAlpha && decodeChannel(5, 5, a_scale) - - // Decode using the DCT into RGB - let ratio = thumbHashToApproximateAspectRatio(hash) - let w = round(ratio > 1 ? 32 : 32 * ratio) - let h = round(ratio > 1 ? 32 / ratio : 32) - let rgba = new Uint8Array(w * h * 4), fx = [], fy = [] - for (let y = 0, i = 0; y < h; y++) { - for (let x = 0; x < w; x++, i += 4) { - let l = l_dc, p = p_dc, q = q_dc, a = a_dc - - // Precompute the coefficients - for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++) - fx[cx] = cos(PI / w * (x + 0.5) * cx) - for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++) - fy[cy] = cos(PI / h * (y + 0.5) * cy) - - // Decode L - for (let cy = 0, j = 0; cy < ly; cy++) - for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx * ly < lx * (ly - cy); cx++, j++) - l += l_ac[j] * fx[cx] * fy2 - - // Decode P and Q - for (let cy = 0, j = 0; cy < 3; cy++) { - for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) { - let f = fx[cx] * fy2 - p += p_ac[j] * f - q += q_ac[j] * f - } - } - - // Decode A - if (hasAlpha) - for (let cy = 0, j = 0; cy < 5; cy++) - for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++) - a += a_ac[j] * fx[cx] * fy2 - - // Convert to RGB - let b = l - 2 / 3 * p - let r = (3 * l - b + q) / 2 - let g = r - q - rgba[i] = max(0, 255 * min(1, r)) - rgba[i + 1] = max(0, 255 * min(1, g)) - rgba[i + 2] = max(0, 255 * min(1, b)) - rgba[i + 3] = max(0, 255 * min(1, a)) - } - } - return { w, h, rgba } -} - -/** - * Extracts the average color from a ThumbHash. RGB is not be premultiplied by A. - * - * @param hash The bytes of the ThumbHash. - * @returns The RGBA values for the average color. Each value ranges from 0 to 1. - */ -export function thumbHashToAverageRGBA(hash) { - let { min, max } = Math - let header = hash[0] | (hash[1] << 8) | (hash[2] << 16) - let l = (header & 63) / 63 - let p = ((header >> 6) & 63) / 31.5 - 1 - let q = ((header >> 12) & 63) / 31.5 - 1 - let hasAlpha = header >> 23 - let a = hasAlpha ? (hash[5] & 15) / 15 : 1 - let b = l - 2 / 3 * p - let r = (3 * l - b + q) / 2 - let g = r - q - return { - r: max(0, min(1, r)), - g: max(0, min(1, g)), - b: max(0, min(1, b)), - a - } -} - -/** - * Extracts the approximate aspect ratio of the original image. - * - * @param hash The bytes of the ThumbHash. - * @returns The approximate aspect ratio (i.e. width / height). - */ -export function thumbHashToApproximateAspectRatio(hash) { - let header = hash[3] - let hasAlpha = hash[2] & 0x80 - let isLandscape = hash[4] & 0x80 - let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7 - let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7 - return lx / ly -} - -/** - * Encodes an RGBA image to a PNG data URL. RGB should not be premultiplied by - * A. This is optimized for speed and simplicity and does not optimize for size - * at all. This doesn't do any compression (all values are stored uncompressed). - * - * @param w The width of the input image. Must be ≤100px. - * @param h The height of the input image. Must be ≤100px. - * @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements. - * @returns A data URL containing a PNG for the input image. - */ -export function rgbaToDataURL(w, h, rgba) { - let row = w * 4 + 1 - let idat = 6 + h * (5 + row) - let bytes = [ - 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, - w >> 8, w & 255, 0, 0, h >> 8, h & 255, 8, 6, 0, 0, 0, 0, 0, 0, 0, - idat >>> 24, (idat >> 16) & 255, (idat >> 8) & 255, idat & 255, - 73, 68, 65, 84, 120, 1 - ] - let table = [ - 0, 498536548, 997073096, 651767980, 1994146192, 1802195444, 1303535960, - 1342533948, -306674912, -267414716, -690576408, -882789492, -1687895376, - -2032938284, -1609899400, -1111625188 - ] - let a = 1, b = 0 - for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) { - bytes.push(y + 1 < h ? 0 : 1, row & 255, row >> 8, ~row & 255, (row >> 8) ^ 255, 0) - for (b = (b + a) % 65521; i < end; i++) { - let u = rgba[i] & 255 - bytes.push(u) - a = (a + u) % 65521 - b = (b + a) % 65521 - } - } - bytes.push( - b >> 8, b & 255, a >> 8, a & 255, 0, 0, 0, 0, - 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130 - ) - for (let [start, end] of [[12, 29], [37, 41 + idat]]) { - let c = ~0 - for (let i = start; i < end; i++) { - c ^= bytes[i] - c = (c >>> 4) ^ table[c & 15] - c = (c >>> 4) ^ table[c & 15] - } - c = ~c - bytes[end++] = c >>> 24 - bytes[end++] = (c >> 16) & 255 - bytes[end++] = (c >> 8) & 255 - bytes[end++] = c & 255 - } - return 'data:image/png;base64,' + btoa(String.fromCharCode(...bytes)) -} - -/** - * Decodes a ThumbHash to a PNG data URL. This is a convenience function that - * just calls "thumbHashToRGBA" followed by "rgbaToDataURL". - * - * @param hash The bytes of the ThumbHash. - * @returns A data URL containing a PNG for the rendered ThumbHash. - */ -export function thumbHashToDataURL(hash) { - let image = thumbHashToRGBA(hash) - return rgbaToDataURL(image.w, image.h, image.rgba) -} diff --git a/core/artwork/thumbhash/testdata/tiny.png b/core/artwork/thumbhash/testdata/tiny.png deleted file mode 100644 index ab635e11a..000000000 Binary files a/core/artwork/thumbhash/testdata/tiny.png and /dev/null differ diff --git a/core/artwork/thumbhash/thumbhash.go b/core/artwork/thumbhash/thumbhash.go deleted file mode 100644 index a255bec42..000000000 --- a/core/artwork/thumbhash/thumbhash.go +++ /dev/null @@ -1,257 +0,0 @@ -// Package thumbhash implements the ThumbHash encoding algorithm (https://github.com/evanw/thumbhash). -package thumbhash - -import ( - "errors" - "image" - "image/draw" - "math" - - xdraw "golang.org/x/image/draw" -) - -// maxInputSize is the algorithm's hard limit: larger inputs are rejected by every implementation. -const maxInputSize = 100 - -// term is one DCT coefficient's frequency pair, in the reference's triangular scan order. -type term struct{ cx, cy int } - -// Encode returns the ThumbHash of img: 24 bytes when opaque, 25 with alpha. Output matches -// evanw/thumbhash except where a coefficient lands on a quantization tie, where a nibble may differ by 1. -func Encode(img image.Image) ([]byte, error) { - rgba := toNRGBA(downscale(img)) - b := rgba.Bounds() - w, h := b.Dx(), b.Dy() - if w == 0 || h == 0 { - return nil, errors.New("thumbhash: empty image") - } - - avgR, avgG, avgB, avgA := averageColor(rgba, w, h) - hasAlpha := avgA < float64(w*h) - if avgA > 0 { - avgR /= avgA - avgG /= avgA - avgB /= avgA - } - - lLimit := 7.0 - if hasAlpha { - lLimit = 5.0 - } - maxWH := float64(max(w, h)) - lx := max(1, int(math.Round(lLimit*float64(w)/maxWH))) - ly := max(1, int(math.Round(lLimit*float64(h)/maxWH))) - - lTerms := terms(max(3, lx), max(3, ly)) - pTerms := terms(3, 3) - qTerms := terms(3, 3) - var aTerms []term - if hasAlpha { - aTerms = terms(5, 5) - } - - // The widest coefficient region wins: 3x3 chroma, 5x5 alpha, and luma's own lx by ly. - chan5 := 3 - if hasAlpha { - chan5 = 5 - } - nx := max(max(3, lx), chan5) - cosX := cosTable(nx, w) - cosY := cosTable(max(max(3, ly), chan5), h) - - lAcc := make([]float64, len(lTerms)) - pAcc := make([]float64, len(pTerms)) - qAcc := make([]float64, len(qTerms)) - aAcc := make([]float64, len(aTerms)) - rowL := make([]float64, nx) - rowP := make([]float64, nx) - rowQ := make([]float64, nx) - rowA := make([]float64, nx) - - for y := range h { - clear(rowL) - clear(rowP) - clear(rowQ) - clear(rowA) - row := rgba.Pix[y*rgba.Stride:] - for x := range w { - j := x * 4 - alpha := float64(row[j+3]) / 255 - r := avgR*(1-alpha) + alpha/255*float64(row[j]) - g := avgG*(1-alpha) + alpha/255*float64(row[j+1]) - bl := avgB*(1-alpha) + alpha/255*float64(row[j+2]) - lv := (r + g + bl) / 3 - pv := (r+g)/2 - bl - qv := r - g - for cx := range nx { - f := cosX[cx][x] - rowL[cx] += lv * f - rowP[cx] += pv * f - rowQ[cx] += qv * f - } - // hasAlpha is loop-invariant, so this costs a predicted branch rather than a quarter - // of the inner loop that opaque images never need. - if hasAlpha { - for cx := range nx { - rowA[cx] += alpha * cosX[cx][x] - } - } - } - accumulate(lAcc, lTerms, rowL, cosY, y) - accumulate(pAcc, pTerms, rowP, cosY, y) - accumulate(qAcc, qTerms, rowQ, cosY, y) - accumulate(aAcc, aTerms, rowA, cosY, y) - } - - n := float64(w * h) - lDC, lAC, lScale := normalize(lAcc, n) - pDC, pAC, pScale := normalize(pAcc, n) - qDC, qAC, qScale := normalize(qAcc, n) - aDC, aAC, aScale := normalize(aAcc, n) - - return pack(w, h, hasAlpha, lx, ly, - lDC, pDC, qDC, aDC, lScale, pScale, qScale, aScale, lAC, pAC, qAC, aAC), nil -} - -// terms lists the (cx, cy) pairs of the reference's triangular coefficient region, in write order. -func terms(nx, ny int) []term { - var ts []term - for cy := range ny { - for cx := 0; cx*ny < nx*(ny-cy); cx++ { - ts = append(ts, term{cx, cy}) - } - } - return ts -} - -// cosTable precomputes cos(pi/size * c * (i+0.5)) with the reference's exact expression, so the -// table values are bit-identical to recomputing them per coefficient. -func cosTable(n, size int) [][]float64 { - t := make([][]float64, n) - for c := range n { - t[c] = make([]float64, size) - for i := range size { - t[c][i] = math.Cos(math.Pi / float64(size) * float64(c) * (float64(i) + 0.5)) - } - } - return t -} - -func averageColor(rgba *image.NRGBA, w, h int) (r, g, b, a float64) { - for y := range h { - row := rgba.Pix[y*rgba.Stride:] - for x := range w { - j := x * 4 - alpha := float64(row[j+3]) / 255 - r += alpha / 255 * float64(row[j]) - g += alpha / 255 * float64(row[j+1]) - b += alpha / 255 * float64(row[j+2]) - a += alpha - } - } - return r, g, b, a -} - -// accumulate folds one row's per-cx sums into the term accumulators, so the pixel loop costs -// nx multiplies per pixel instead of one per coefficient. -func accumulate(acc []float64, ts []term, row []float64, cosY [][]float64, y int) { - for k, t := range ts { - acc[k] += row[t.cx] * cosY[t.cy][y] - } -} - -// normalize splits the accumulators into DC and scaled AC terms, matching the reference: a constant -// image leaves scale at 0, which skips normalization rather than mapping the terms to the midpoint. -func normalize(acc []float64, n float64) (dc float64, ac []float64, scale float64) { - if len(acc) == 0 { - return 0, nil, 0 - } - dc = acc[0] / n - ac = make([]float64, len(acc)-1) - for i, v := range acc[1:] { - ac[i] = v / n - scale = max(scale, math.Abs(ac[i])) - } - if scale > 0 { - for i := range ac { - ac[i] = 0.5 + 0.5/scale*ac[i] - } - } - return dc, ac, scale -} - -func pack(w, h int, hasAlpha bool, lx, ly int, - lDC, pDC, qDC, aDC, lScale, pScale, qScale, aScale float64, - lAC, pAC, qAC, aAC []float64, -) []byte { - isLandscape := 0 - if w > h { - isLandscape = 1 - } - alphaBit := 0 - if hasAlpha { - alphaBit = 1 - } - header24 := int(math.Round(63*lDC)) | int(math.Round(31.5+31.5*pDC))<<6 | - int(math.Round(31.5+31.5*qDC))<<12 | int(math.Round(31*lScale))<<18 | alphaBit<<23 - lead := lx - if isLandscape == 1 { - lead = ly - } - header16 := lead | int(math.Round(63*pScale))<<3 | int(math.Round(63*qScale))<<9 | isLandscape<<15 - - acs := [][]float64{lAC, pAC, qAC} - acStart := 5 - if hasAlpha { - acs = append(acs, aAC) - acStart = 6 - } - acCount := 0 - for _, ac := range acs { - acCount += len(ac) - } - - hash := make([]byte, acStart+(acCount+1)/2) - hash[0] = byte(header24 & 255) - hash[1] = byte((header24 >> 8) & 255) - hash[2] = byte(header24 >> 16) - hash[3] = byte(header16 & 255) - hash[4] = byte(header16 >> 8) - if hasAlpha { - hash[5] = byte(int(math.Round(15*aDC)) | int(math.Round(15*aScale))<<4) - } - acIndex := 0 - for _, ac := range acs { - for _, f := range ac { - hash[acStart+(acIndex>>1)] |= byte(int(math.Round(15*f)) << ((acIndex & 1) << 2)) - acIndex++ - } - } - return hash -} - -// NRGBA, not RGBA: ThumbHash requires non-premultiplied RGB and the pipeline hands us a -// premultiplied *image.RGBA, which draw.Draw un-premultiplies on the way in. -func toNRGBA(img image.Image) *image.NRGBA { - // Conservative: a sub-image re-slices Pix so the loops would read it correctly too, but the - // copy costs nothing on the origin-anchored images the pipeline actually produces. - if nrgba, ok := img.(*image.NRGBA); ok && nrgba.Rect.Min == (image.Point{}) { - return nrgba - } - b := img.Bounds() - dst := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) - draw.Draw(dst, dst.Bounds(), img, b.Min, draw.Src) - return dst -} - -func downscale(img image.Image) image.Image { - b := img.Bounds() - w, h := b.Dx(), b.Dy() - if w <= maxInputSize && h <= maxInputSize { - return img - } - scale := float64(maxInputSize) / float64(max(w, h)) - dst := image.NewNRGBA(image.Rect(0, 0, max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale)))) - xdraw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Src, nil) - return dst -} diff --git a/core/artwork/thumbhash/thumbhash_suite_test.go b/core/artwork/thumbhash/thumbhash_suite_test.go deleted file mode 100644 index 11a6f0e25..000000000 --- a/core/artwork/thumbhash/thumbhash_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package thumbhash_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestThumbHash(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "ThumbHash Suite") -} diff --git a/core/artwork/thumbhash/thumbhash_test.go b/core/artwork/thumbhash/thumbhash_test.go deleted file mode 100644 index 4832c25bc..000000000 --- a/core/artwork/thumbhash/thumbhash_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package thumbhash_test - -import ( - "encoding/base64" - "encoding/json" - "image" - "image/color" - "image/draw" - _ "image/png" - "os" - "path/filepath" - "runtime" - "slices" - - "github.com/navidrome/navidrome/core/artwork/thumbhash" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// testdataDir is resolved via runtime.Caller because tests.Init (thumbhash_suite_test.go) chdirs -// the process to the repo root, which would break a plain relative "testdata" path. -var testdataDir = func() string { - _, file, _, _ := runtime.Caller(0) - return filepath.Join(filepath.Dir(file), "testdata") -}() - -// fixtureImage decodes a testdata PNG. NRGBA, not RGBA: ThumbHash needs non-premultiplied pixels, -// and RGBA would silently premultiply every fixture that has alpha. -func fixtureImage(name string) *image.NRGBA { - GinkgoHelper() - f, err := os.Open(filepath.Join(testdataDir, name)) - Expect(err).ToNot(HaveOccurred()) - defer f.Close() - src, _, err := image.Decode(f) - Expect(err).ToNot(HaveOccurred()) - b := src.Bounds() - dst := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy())) - draw.Draw(dst, dst.Bounds(), src, b.Min, draw.Src) - return dst -} - -// headerOnlyFixtures have mathematically-zero AC content, so every AC nibble is float rounding -// noise sitting on a quantization tie; only the header bytes carry signal. -var headerOnlyFixtures = []string{"solid.png", "tiny.png"} - -func loadJSON[T any](name string) T { - GinkgoHelper() - data, err := os.ReadFile(filepath.Join(testdataDir, name)) - Expect(err).ToNot(HaveOccurred()) - var out T - Expect(json.Unmarshal(data, &out)).To(Succeed()) - return out -} - -func loadGoldens() map[string]string { - GinkgoHelper() - golden := loadJSON[map[string]string]("golden.json") - Expect(golden).ToNot(BeEmpty()) - return golden -} - -// mix is a stateless 32-bit finaliser matching gen_generated.mjs, so both languages build the -// same synthetic pixels and only the reference's hashes need committing. -func mix(n uint32) uint32 { - n = (n ^ (n >> 16)) * 2246822507 - n = (n ^ (n >> 13)) * 3266489909 - return n ^ (n >> 16) -} - -func generatedImage(i int) *image.NRGBA { - w := 1 + int(mix(uint32(i)*3+1)%100) - h := 1 + int(mix(uint32(i)*3+2)%100) - img := image.NewNRGBA(image.Rect(0, 0, w, h)) - for k := range img.Pix { - img.Pix[k] = byte(mix(uint32(i)*1000003 + uint32(k))) - } - if i%2 == 0 { - for k := 3; k < len(img.Pix); k += 4 { - img.Pix[k] = 255 - } - } - return img -} - -var _ = Describe("Encode", func() { - It("matches every golden vector", func() { - for name, want := range loadGoldens() { - if slices.Contains(headerOnlyFixtures, name) { - continue // see the dedicated header-only spec below - } - got, err := thumbhash.Encode(fixtureImage(name)) - Expect(err).ToNot(HaveOccurred(), "fixture %s", name) - Expect(base64.StdEncoding.EncodeToString(got)).To(Equal(want), "fixture %s", name) - } - }) - - It("reproduces the well-conditioned header of the ill-conditioned fixtures", func() { - for _, name := range headerOnlyFixtures { - want, err := base64.StdEncoding.DecodeString(loadGoldens()[name]) - Expect(err).ToNot(HaveOccurred(), "fixture %s", name) - got, err := thumbhash.Encode(fixtureImage(name)) - Expect(err).ToNot(HaveOccurred(), "fixture %s", name) - Expect(got[:5]).To(Equal(want[:5]), "fixture %s header bytes", name) - } - }) - - // The PNG fixtures cannot reach every layout; these sweep random sizes, aspects and both the - // 7x7 no-alpha and 5x5-plus-alpha coefficient regions against the same reference. - It("matches the reference on 300 generated images", func() { - vectors := loadJSON[[]struct { - W, H int - Hash string - }]("generated.json") - Expect(vectors).ToNot(BeEmpty()) - for i, want := range vectors { - img := generatedImage(i) - Expect(img.Bounds().Dx()).To(Equal(want.W), "vector %d width", i) - Expect(img.Bounds().Dy()).To(Equal(want.H), "vector %d height", i) - got, err := thumbhash.Encode(img) - Expect(err).ToNot(HaveOccurred(), "vector %d", i) - Expect(base64.StdEncoding.EncodeToString(got)).To(Equal(want.Hash), "vector %d (%dx%d)", i, want.W, want.H) - } - }) - - It("quantizes a uniform image's scales to zero", func() { - got, err := thumbhash.Encode(fixtureImage("solid.png")) - Expect(err).ToNot(HaveOccurred()) - header24 := int(got[0]) | int(got[1])<<8 | int(got[2])<<16 - header16 := int(got[3]) | int(got[4])<<8 - Expect((header24>>18)&31).To(Equal(0), "lScale") - Expect((header16>>3)&63).To(Equal(0), "pScale") - Expect((header16>>9)&63).To(Equal(0), "qScale") - }) - - It("produces 24 bytes for a square opaque image", func() { - got, err := thumbhash.Encode(fixtureImage("square.png")) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(HaveLen(24)) - }) - - It("produces 25 bytes when the image has alpha", func() { - got, err := thumbhash.Encode(fixtureImage("alpha.png")) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(HaveLen(25)) - }) - - It("downscales an oversized image rather than failing", func() { - img := image.NewNRGBA(image.Rect(0, 0, 500, 300)) - for i := range img.Pix { - img.Pix[i] = byte(i) - } - got, err := thumbhash.Encode(img) - Expect(err).ToNot(HaveOccurred()) - Expect(len(got)).To(BeNumerically(">=", 5)) - }) - - It("encodes a 1x1 image", func() { - img := image.NewNRGBA(image.Rect(0, 0, 1, 1)) - img.Set(0, 0, color.NRGBA{R: 60, G: 120, B: 180, A: 255}) - got, err := thumbhash.Encode(img) - Expect(err).ToNot(HaveOccurred()) - Expect(got).ToNot(BeEmpty()) - }) - - It("encodes a sub-image like an origin-anchored copy of the same region", func() { - parent := image.NewNRGBA(image.Rect(0, 0, 60, 50)) - for i := range parent.Pix { - parent.Pix[i] = byte(i * 7 % 251) - } - region := image.Rect(10, 7, 40, 30) - cropped := image.NewNRGBA(image.Rect(0, 0, region.Dx(), region.Dy())) - draw.Draw(cropped, cropped.Bounds(), parent, region.Min, draw.Src) - - got, err := thumbhash.Encode(parent.SubImage(region)) - Expect(err).ToNot(HaveOccurred()) - want, err := thumbhash.Encode(cropped) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(Equal(want)) - }) - - It("rejects an empty image", func() { - _, err := thumbhash.Encode(image.NewRGBA(image.Rect(0, 0, 0, 0))) - Expect(err).To(HaveOccurred()) - }) - - It("is deterministic", func() { - img := fixtureImage("square.png") - first, err := thumbhash.Encode(img) - Expect(err).ToNot(HaveOccurred()) - second, err := thumbhash.Encode(img) - Expect(err).ToNot(HaveOccurred()) - Expect(first).To(Equal(second)) - }) -}) diff --git a/core/artwork/trace.go b/core/artwork/trace.go deleted file mode 100644 index bca2f2c7d..000000000 --- a/core/artwork/trace.go +++ /dev/null @@ -1,152 +0,0 @@ -package artwork - -import ( - "context" - "encoding/json" - "io" - "slices" - "sync" - - "github.com/navidrome/navidrome/utils/str" -) - -// Outcome is what the priority chain observed for one candidate; the CLI renders and branches on these. -type Outcome string - -const ( - OutcomeHit Outcome = "hit" - OutcomeMiss Outcome = "miss" - OutcomeUnreadable Outcome = "unreadable" - OutcomeSkipped Outcome = "skipped" - OutcomeError Outcome = "error" -) - -const ( - // externalCandidate labels the external tier itself, for the cases that never reach an agent. - externalCandidate = "external" - // ExternalPrefix qualifies a candidate or a stored source with the agent that produced it. - ExternalPrefix = externalCandidate + ":" -) - -// TraceStep is one candidate the priority chain considered. -type TraceStep struct { - Candidate string - Outcome Outcome - Detail string -} - -// ChainTrace collects the walk of a single resolution: the worker attaches one per queue -// item so it can be stored, and the CLI attaches one per explain. -type ChainTrace struct { - mu sync.Mutex - steps []TraceStep -} - -func (t *ChainTrace) add(step TraceStep) { - if t == nil { - return - } - t.mu.Lock() - defer t.mu.Unlock() - t.steps = append(t.steps, step) -} - -func (t *ChainTrace) Steps() []TraceStep { - if t == nil { - return nil - } - t.mu.Lock() - defer t.mu.Unlock() - return slices.Clone(t.steps) -} - -// maxTraceDetail bounds a stored Detail, which on the failure paths is an error string of -// unknown length. Past ~1kB a row spills to an overflow page, slowing every scan of the table. -const maxTraceDetail = 200 - -// storedStep is the persisted shape of a TraceStep. The keys are single letters because a trace -// is written for every item, and the encoded length is repeated across the whole library. -type storedStep struct { - C string `json:"c"` - O Outcome `json:"o"` - D string `json:"d,omitempty"` -} - -// encode serializes the trace for storage, without the copy Steps would make for a caller -// that only wants to write it. -func (t *ChainTrace) encode(sourcePath string) string { - if t == nil { - return encodeSteps(nil, sourcePath) - } - t.mu.Lock() - defer t.mu.Unlock() - return encodeSteps(t.steps, sourcePath) -} - -// encodeSteps writes the stored form. A hit's Detail is the winning source's path, which the -// same row already stores as source_path, so it is dropped and DecodeTrace puts it back. -func encodeSteps(steps []TraceStep, sourcePath string) string { - out := make([]storedStep, 0, len(steps)) - for _, s := range steps { - d := s.Detail - if s.Outcome == OutcomeHit && d == sourcePath { - d = "" - } - out = append(out, storedStep{C: s.Candidate, O: s.Outcome, D: str.TruncateRunes(d, maxTraceDetail, "...")}) - } - b, _ := json.Marshal(out) // []storedStep is all strings, so this cannot fail - return string(b) -} - -// DecodeTrace reverses the stored form. A trace that will not parse is reported as no trace at all, -// since a diagnostic command must not fail on a bad row. -func DecodeTrace(encoded, sourcePath string) []TraceStep { - if encoded == "" { - return nil - } - var stored []storedStep - if err := json.Unmarshal([]byte(encoded), &stored); err != nil { - return nil - } - steps := make([]TraceStep, 0, len(stored)) - for _, s := range stored { - d := s.D - if d == "" && s.O == OutcomeHit { - d = sourcePath - } - steps = append(steps, TraceStep{Candidate: s.C, Outcome: s.O, Detail: d}) - } - return steps -} - -type traceCtxKey struct{} - -func withTrace(ctx context.Context, t *ChainTrace) context.Context { - return context.WithValue(ctx, traceCtxKey{}, t) -} - -func traceFrom(ctx context.Context) *ChainTrace { - t, _ := ctx.Value(traceCtxKey{}).(*ChainTrace) - return t -} - -// recordAgent files what one external agent answered. The agent loops call this rather than a -// gate wrapper, because only they hold the context that carries the trace. -func recordAgent(ctx context.Context, name string, r io.ReadCloser, path string, err error) { - t := traceFrom(ctx) - candidate := ExternalPrefix + name - switch { - case r != nil: - t.add(TraceStep{Candidate: candidate, Outcome: OutcomeHit, Detail: path}) - case isTransientExternal(err): - t.add(TraceStep{Candidate: candidate, Outcome: OutcomeError, Detail: err.Error()}) - default: - t.add(TraceStep{Candidate: candidate, Outcome: OutcomeMiss}) - } -} - -// traceStage records a failure from the stages that run after the priority chain has already -// picked a winner: most ways an item can fail are here, not in the chain walk. -func traceStage(ctx context.Context, stage string, err error) { - traceFrom(ctx).add(TraceStep{Candidate: stage, Outcome: OutcomeError, Detail: err.Error()}) -} diff --git a/core/artwork/trace_test.go b/core/artwork/trace_test.go deleted file mode 100644 index a16347457..000000000 --- a/core/artwork/trace_test.go +++ /dev/null @@ -1,664 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "io" - "os" - "path/filepath" - "runtime" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("trace vocabulary", func() { - // The CLI renders these verbatim and branches on them; a value change is a change to - // what `artwork explain` tells an operator, so it must be made deliberately. - It("pins the wire values the CLI reads", func() { - Expect([]Outcome{ - OutcomeHit, OutcomeMiss, OutcomeUnreadable, OutcomeSkipped, OutcomeError, - }).To(Equal([]Outcome{"hit", "miss", "unreadable", "skipped", "error"})) - Expect(externalCandidate).To(Equal("external")) - Expect(ExternalPrefix).To(Equal("external:")) - }) -}) - -var _ = Describe("encodeSteps/DecodeTrace", func() { - It("round-trips a trace", func() { - steps := []TraceStep{ - {Candidate: "cover.png", Outcome: OutcomeMiss}, - {Candidate: "cover.*", Outcome: OutcomeHit, Detail: "/music/a/cover.jpg"}, - } - Expect(DecodeTrace(encodeSteps(steps, ""), "")).To(Equal(steps)) - }) - - It("encodes an empty trace as an empty JSON array", func() { - Expect(encodeSteps(nil, "")).To(Equal("[]")) - Expect(DecodeTrace("[]", "")).To(BeEmpty()) - }) - - It("tolerates a row written before the column existed", func() { - Expect(DecodeTrace("", "")).To(BeEmpty()) - }) - - // The hit detail repeats source_path byte for byte, and that column is on the same row. - It("drops a hit detail that repeats sourcePath, and restores it on read", func() { - path := "/music/artist/album/cover.jpg" - steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeHit, Detail: path}} - encoded := encodeSteps(steps, path) - Expect(encoded).NotTo(ContainSubstring(path)) - Expect(DecodeTrace(encoded, path)).To(Equal(steps)) - }) - - It("keeps a detail that differs from sourcePath", func() { - steps := []TraceStep{{Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "https://cdn/x.jpg"}} - Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps)) - }) - - // A row past ~1kB spills to an overflow page on these WITHOUT ROWID tables, which would - // slow every scan; Detail is an error string on the failure paths, so it needs a bound. - It("bounds a detail so one long error cannot inflate the row", func() { - steps := []TraceStep{{Candidate: "decode", Outcome: OutcomeError, Detail: strings.Repeat("x", 5000)}} - - got := DecodeTrace(encodeSteps(steps, ""), "") - - Expect(len(got[0].Detail)).To(BeNumerically("<=", 210)) - Expect(got[0].Detail).To(HaveSuffix("...")) - Expect(got[0].Candidate).To(Equal("decode"), "truncating the detail must not disturb the step") - }) - - It("only restores sourcePath onto a detail-less hit", func() { - steps := []TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}} - Expect(DecodeTrace(encodeSteps(steps, "/music/a/cover.jpg"), "/music/a/cover.jpg")).To(Equal(steps)) - }) -}) - -var _ = Describe("chainTrace", func() { - It("returns nil when no trace is attached", func() { - Expect(traceFrom(context.Background())).To(BeNil()) - }) - - It("collects steps in order", func() { - t := &ChainTrace{} - ctx := withTrace(context.Background(), t) - - traceFrom(ctx).add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss}) - traceFrom(ctx).add(TraceStep{Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"}) - - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "cover.*", Outcome: OutcomeMiss}, - {Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"}, - })) - - s := t.Steps() - s[0].Candidate = "mutated" - Expect(t.Steps()[0].Candidate).To(Equal("cover.*")) - }) - - It("does not panic when the trace is nil", func() { - var t *ChainTrace - Expect(func() { t.add(TraceStep{Candidate: "cover.*", Outcome: OutcomeMiss}) }).ToNot(Panic()) - Expect(t.Steps()).To(BeEmpty(), "a nil trace collects nothing, so reading it must be as safe as writing it") - }) - - It("is safe to use concurrently", func() { - t := &ChainTrace{} - done := make(chan struct{}) - for range 10 { - go func() { - defer GinkgoRecover() - t.add(TraceStep{Candidate: "x", Outcome: OutcomeMiss}) - done <- struct{}{} - }() - } - for range 10 { - <-done - } - Expect(t.Steps()).To(HaveLen(10)) - }) -}) - -var _ = Describe("chainState tracing", func() { - It("records a miss when the candidate was absent", func() { - t := &ChainTrace{} - c := chainState{trace: t} - - _, ok := c.try("cover.*", resolution{}, false) - - Expect(ok).To(BeFalse()) - Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "cover.*", Outcome: OutcomeMiss}})) - }) - - It("records unreadable when the candidate existed but could not be read", func() { - t := &ChainTrace{} - c := chainState{trace: t} - - _, ok := c.try("cover.*", resolution{localError: true}, false) - - Expect(ok).To(BeFalse()) - Expect(t.Steps()).To(HaveLen(1)) - Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable), - "a candidate that existed and failed to decode must be distinguishable from one that was absent") - }) - - It("records a hit with the backing path", func() { - t := &ChainTrace{} - c := chainState{trace: t} - - res, ok := c.try("embedded", resolution{reader: nil, source: "embedded", sourcePath: "/music/a.flac"}, true) - - Expect(ok).To(BeTrue()) - Expect(res.source).To(Equal("embedded")) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "embedded", Outcome: OutcomeHit, Detail: "/music/a.flac"}, - })) - }) -}) - -var _ = Describe("external agent tracing", func() { - var ( - t *ChainTrace - ctx context.Context - body io.ReadCloser - ) - BeforeEach(func() { - t = &ChainTrace{} - ctx = withTrace(context.Background(), t) - body = io.NopCloser(strings.NewReader("x")) - }) - - It("records a hit with the image path", func() { - recordAgent(ctx, "deezer", body, "http://img", nil) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "external:deezer", Outcome: OutcomeHit, Detail: "http://img"}, - })) - }) - - It("records a miss for a not-found", func() { - recordAgent(ctx, "deezer", nil, "", agents.ErrNotFound) - Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss)) - }) - - It("records a miss for a model not-found", func() { - recordAgent(ctx, "deezer", nil, "", model.ErrNotFound) - Expect(t.Steps()[0].Outcome).To(Equal(OutcomeMiss), - "both not-found flavours are definitive answers, not faults") - }) - - It("records an error with its reason", func() { - recordAgent(ctx, "apple-music", nil, "", errors.New("returned status 429")) - Expect(t.Steps()[0].Outcome).To(Equal(OutcomeError)) - Expect(t.Steps()[0].Detail).To(ContainSubstring("429")) - }) -}) - -var _ = Describe("resolveAlbum tracing", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - albumRepo *tests.MockAlbumRepo - folderRepo *fakeFolderRepo - ffm *tests.MockFFmpeg - ag *agents.Agents - t *ChainTrace - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.CoverArtPriority = "cover.jpg, embedded" - repoRoot, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - libRepo := &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - albumRepo = tests.CreateMockAlbumRepo() - folderRepo = &fakeFolderRepo{} - ds = &tests.MockDataStore{ - MockedAlbum: albumRepo, - MockedFolder: folderRepo, - MockedLibrary: libRepo, - } - ffm = tests.NewMockFFmpeg("") - ag = agents.GetAgents(&tests.MockDataStore{}, nil) - t = &ChainTrace{} - ctx = withTrace(context.Background(), t) - }) - - It("records a pattern skipped because the album folder holds no images", func() { - albumRepo.SetData(model.Albums{ - {ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, - }) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(t.Steps()).To(HaveLen(2), "a configured pattern must appear even when the chain never evaluated it") - Expect(t.Steps()[0]).To(Equal(TraceStep{ - Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder", - })) - Expect(t.Steps()[1].Candidate).To(Equal("embedded")) - Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit)) - }) - - It("records an evaluated pattern that matched nothing as a miss, not a skip", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"artist.png"}, - }} - albumRepo.SetData(model.Albums{ - {ID: "al3", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, - }) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "cover.jpg", Outcome: OutcomeMiss}), - "the folder was searched and held no cover.jpg, which is not the same as never looking") - }) - - It("ignores an empty priority token", func() { - conf.Server.CoverArtPriority = "cover.jpg," - albumRepo.SetData(model.Albums{{ID: "al2", Name: "Album", FolderIDs: []string{"f1"}}}) - - _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"}, - })) - }) -}) - -var _ = Describe("resolveArtist tracing", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - artistRepo *tests.MockArtistRepo - albumRepo *tests.MockAlbumRepo - folderRepo *fakeFolderRepo - ffm *tests.MockFFmpeg - ag *agents.Agents - t *ChainTrace - repoRoot string - ) - - uploadPath := func(file string) string { - path := model.UploadedImagePath(consts.EntityArtist, file) - Expect(os.MkdirAll(filepath.Dir(path), 0o755)).To(Succeed()) - Expect(os.WriteFile(path, []byte("uploaded artist image"), 0o600)).To(Succeed()) - return path - } - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) - conf.Server.ArtistArtPriority = "album/artist.*" - var err error - repoRoot, err = os.Getwd() - Expect(err).ToNot(HaveOccurred()) - libRepo := &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - artistRepo = tests.CreateMockArtistRepo() - albumRepo = tests.CreateMockAlbumRepo() - folderRepo = &fakeFolderRepo{} - ds = &tests.MockDataStore{ - MockedArtist: artistRepo, - MockedAlbum: albumRepo, - MockedFolder: folderRepo, - MockedLibrary: libRepo, - } - ffm = tests.NewMockFFmpeg("") - ag = agents.GetAgents(&tests.MockDataStore{}, nil) - t = &ChainTrace{} - ctx = withTrace(context.Background(), t) - }) - - It("records the upload short-circuit as a hit", func() { - path := uploadPath("ar1_test.jpg") - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist", UploadedImage: "ar1_test.jpg"}}) - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(t.Steps()).To(Equal([]TraceStep{{Candidate: "upload", Outcome: OutcomeHit, Detail: path}})) - }) - - It("records an upload miss before walking the chain", func() { - artistRepo.SetData(model.Artists{{ID: "ar2", Name: "Artist"}}) - - _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "upload", Outcome: OutcomeMiss})) - }) - - It("labels each step with the configured priority token", func() { - folderRepo.result = []model.Folder{{ - LibraryPath: testFileLibPath(repoRoot), - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"artist.png"}, - }} - artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) - albumRepo.All = model.Albums{{ID: "al9", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}} - - res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar4"}) - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(t.Steps()).To(HaveLen(2)) - Expect(t.Steps()[1].Candidate).To(Equal("album/artist.*"), - "the step must be labelled with the priority token, not the pattern it was rewritten into") - Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit)) - Expect(filepath.ToSlash(t.Steps()[1].Detail)).To(HaveSuffix("tests/fixtures/artist/an-album/artist.png")) - }) - - It("records a configured pattern that could not be evaluated", func() { - artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}}) - - _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar5"}) - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "upload", Outcome: OutcomeMiss}, - {Candidate: "album/artist.*", Outcome: OutcomeSkipped, Detail: "artist has no albums"}, - }), "a configured pattern that was never evaluated must still appear, and say why") - }) - - It("records why an artist folder pattern was skipped", func() { - conf.Server.ArtistArtPriority = "artist.*" - artistRepo.SetData(model.Artists{{ID: "ar6", Name: "Artist"}}) - albumRepo.All = model.Albums{{ID: "al10", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}} - - _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar6"}) - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "upload", Outcome: OutcomeMiss}, - {Candidate: "artist.*", Outcome: OutcomeSkipped, Detail: "no artist folder"}, - })) - }) - - It("records an upload that exists but cannot be read as unreadable", func() { - if runtime.GOOS == "windows" { - Skip("chmod does not restrict read access on Windows") - } - path := uploadPath("ar3_test.jpg") - Expect(os.Chmod(path, 0o000)).To(Succeed()) - DeferCleanup(func() { _ = os.Chmod(path, 0o600) }) - artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist", UploadedImage: "ar3_test.jpg"}}) - - _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(HaveLen(1)) - Expect(t.Steps()[0].Outcome).To(Equal(OutcomeUnreadable), - "an upload that exists and will not open must not look like an absent upload") - }) -}) - -var _ = Describe("NewTracingResolver", func() { - var ( - ds *tests.MockDataStore - albumRepo *tests.MockAlbumRepo - artistRepo *tests.MockArtistRepo - artworkRepo *tests.MockArtworkRepo - queueRepo *tests.MockArtworkQueueRepo - ffm *tests.MockFFmpeg - t *ChainTrace - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) - conf.Server.CoverArtPriority = "external, embedded" - conf.Server.ArtistArtPriority = "external" - repoRoot, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - libRepo := &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - albumRepo = tests.CreateMockAlbumRepo() - artistRepo = tests.CreateMockArtistRepo() - artworkRepo = tests.CreateMockArtworkRepo() - queueRepo = tests.CreateMockArtworkQueueRepo() - ds = &tests.MockDataStore{ - MockedAlbum: albumRepo, - MockedArtist: artistRepo, - MockedFolder: &fakeFolderRepo{}, - MockedLibrary: libRepo, - MockedArtwork: artworkRepo, - MockedArtworkQueue: queueRepo, - } - ffm = tests.NewMockFFmpeg("") - t = &ChainTrace{} - }) - - Context("resolving", func() { - var fake *fakeImageAgent - - BeforeEach(func() { - // Misses, so the chain falls through to the local tier and both are traced. - fake = &fakeImageAgent{name: "probe", err: agents.ErrNotFound} - albumRepo.SetData(model.Albums{{ - ID: "al1", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}, - }}) - artistRepo.SetData(model.Artists{{ID: "ar1", Name: "Artist"}}) - }) - - It("asks the agents and records what each answered", func() { - source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") - - Expect(err).ToNot(HaveOccurred()) - Expect(source).To(Equal("embedded")) - Expect(fake.albumCalls).To(Equal(1)) - Expect(t.Steps()).To(ContainElement(TraceStep{Candidate: "external:probe", Outcome: OutcomeMiss})) - }) - - It("records the local chain steps too", func() { - _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") - - Expect(err).ToNot(HaveOccurred()) - last := t.Steps()[len(t.Steps())-1] - Expect(last.Candidate).To(Equal("embedded"), "the local chain must be traced, not just the external tier") - Expect(last.Outcome).To(Equal(OutcomeHit)) - }) - - It("never persists artwork state", func() { - _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al1") - - Expect(err).ToNot(HaveOccurred()) - Expect(artworkRepo.ItemData).To(BeEmpty(), - "explain is read-only; a diagnostic walk must never become the stored answer") - Expect(queueRepo.Data).To(BeEmpty()) - }) - - It("resolves an artist without persisting anything", func() { - source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindArtistArtwork, "ar1") - - Expect(err).ToNot(HaveOccurred()) - Expect(source).To(BeEmpty()) - Expect(fake.artistCalls).To(Equal(1)) - Expect(artworkRepo.ItemData).To(BeEmpty()) - Expect(queueRepo.Data).To(BeEmpty()) - }) - - It("closes the reader it does not hand back", func() { - conf.Server.CoverArtPriority = "embedded" - ffm = tests.NewMockFFmpeg("fake image bytes") - albumRepo.SetData(model.Albums{{ - ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/no-such-file.mp3", FolderIDs: []string{"f1"}, - }}) - - source, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "al2") - - Expect(err).ToNot(HaveOccurred()) - Expect(source).To(Equal("embedded")) - Expect(ffm.IsClosed()).To(BeTrue(), "nothing downstream closes it, so a leak is one file handle per invocation") - }) - - // Serving falls back disc -> album and track -> disc -> album. The resolver does not, but - // if it ever did, an explain without --live would start calling providers uninvited. - It("cannot reach a provider without live, whatever the chain does", func() { - conf.Server.DiscArtPriority = "external, cover.*" - conf.Server.CoverArtPriority = "external, cover.*" - conf.Server.EnableMediaFileCoverArt = true - mfRepo := tests.CreateMockMediaFileRepo() - mfRepo.SetData(model.MediaFiles{{ID: "mf1", LibraryID: 0, HasCoverArt: true, - Path: "tests/fixtures/artist/an-album/test.mp3"}}) - ds.MockedMediaFile = mfRepo - offline := NewTracingResolver(ds, imageAgents(fake), ffm, t, false) - - _, err := offline.Resolve(context.Background(), model.KindDiscArtwork, "al1:1") - Expect(err).ToNot(HaveOccurred()) - _, err = offline.Resolve(context.Background(), model.KindMediaFileArtwork, "mf1") - Expect(err).ToNot(HaveOccurred()) - - Expect(fake.albumCalls).To(BeZero()) - Expect(fake.artistCalls).To(BeZero()) - }) - - It("propagates a lookup error", func() { - _, err := NewTracingResolver(ds, imageAgents(fake), ffm, t, true).Resolve(context.Background(), model.KindAlbumArtwork, "nope") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) -}) - -var _ = Describe("resolveDisc tracing", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - albumRepo *tests.MockAlbumRepo - folderRepo *fakeFolderRepo - ffm *tests.MockFFmpeg - t *ChainTrace - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - repoRoot, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - libRepo := &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - albumRepo = tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}}) - folderRepo = &fakeFolderRepo{} - mfRepo := tests.CreateMockMediaFileRepo() - mfRepo.SetData(model.MediaFiles{{ID: "mf1", AlbumID: "al1", DiscNumber: 2, Path: "tests/fixtures/artist/an-album/test.mp3"}}) - ds = &tests.MockDataStore{ - MockedAlbum: albumRepo, - MockedMediaFile: mfRepo, - MockedFolder: folderRepo, - MockedLibrary: libRepo, - } - ffm = tests.NewMockFFmpeg("") - t = &ChainTrace{} - ctx = withTrace(context.Background(), t) - }) - - It("accounts for every configured entry, including the ones that map to no source", func() { - conf.Server.DiscArtPriority = "external, discsubtitle, cover.jpg" - - res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2)) - - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).To(BeNil()) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "external", Outcome: OutcomeSkipped, Detail: "external sources are not supported for disc artwork"}, - {Candidate: "discsubtitle", Outcome: OutcomeSkipped, Detail: "disc has no subtitle"}, - {Candidate: "cover.jpg", Outcome: OutcomeSkipped, Detail: "no images in album folder"}, - })) - }) - - It("records the entry that won and stops there", func() { - conf.Server.DiscArtPriority = "disc*.*, cover.jpg, embedded" - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - - res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2)) - - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("folder")) - Expect(t.Steps()).To(HaveLen(2), "the walk must stop at the winner, and record nothing below it") - Expect(t.Steps()[0]).To(Equal(TraceStep{Candidate: "disc*.*", Outcome: OutcomeMiss})) - Expect(t.Steps()[1].Candidate).To(Equal("cover.jpg")) - Expect(t.Steps()[1].Outcome).To(Equal(OutcomeHit)) - Expect(t.Steps()[1].Detail).To(HaveSuffix(filepath.FromSlash("tests/fixtures/artist/an-album/cover.jpg"))) - Expect(t.Steps()[1].Detail).ToNot(Equal("tests/fixtures/artist/an-album/cover.jpg"), - "a library-relative path sends the operator looking in the wrong place") - }) - - It("reports an unparseable disc id rather than explaining another disc", func() { - conf.Server.DiscArtPriority = "cover.jpg" - _, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, "al1") - Expect(err).To(HaveOccurred()) - }) -}) - -var _ = Describe("resolveMediaFile tracing", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - mfRepo *tests.MockMediaFileRepo - ffm *tests.MockFFmpeg - t *ChainTrace - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableMediaFileCoverArt = true - repoRoot, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - libRepo := &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - mfRepo = tests.CreateMockMediaFileRepo() - mfRepo.SetData(model.MediaFiles{{ - ID: "mf1", Title: "Song", HasCoverArt: true, Path: "tests/fixtures/artist/an-album/test.mp3", - }}) - ds = &tests.MockDataStore{MockedMediaFile: mfRepo, MockedLibrary: libRepo} - ffm = tests.NewMockFFmpeg("") - t = &ChainTrace{} - ctx = withTrace(context.Background(), t) - }) - - It("separates a disabled setting from a track with nothing embedded", func() { - conf.Server.EnableMediaFileCoverArt = false - - _, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1") - - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "embedded", Outcome: OutcomeSkipped, Detail: "EnableMediaFileCoverArt is off"}, - })) - }) - - It("records a track with no embedded art as a miss", func() { - mfRepo.SetData(model.MediaFiles{{ID: "mf2", Title: "Song", HasCoverArt: false}}) - - _, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf2") - - Expect(err).ToNot(HaveOccurred()) - Expect(t.Steps()).To(Equal([]TraceStep{ - {Candidate: "embedded", Outcome: OutcomeMiss, Detail: "the track has no embedded cover art"}, - })) - }) - - It("records the embedded hit", func() { - res, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1") - - Expect(err).ToNot(HaveOccurred()) - Expect(res.reader).ToNot(BeNil()) - defer res.reader.Close() - Expect(res.source).To(Equal("embedded")) - Expect(t.Steps()).To(HaveLen(1)) - Expect(t.Steps()[0].Outcome).To(Equal(OutcomeHit)) - }) -}) diff --git a/core/artwork/uploader.go b/core/artwork/uploader.go deleted file mode 100644 index 76526af3c..000000000 --- a/core/artwork/uploader.go +++ /dev/null @@ -1,106 +0,0 @@ -package artwork - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - - "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/utils" -) - -// MaxImageUploadSize returns the configured max upload size in bytes, or the built-in default. -func MaxImageUploadSize() int64 { - return parseSize(conf.Server.MaxImageUploadSize, consts.DefaultMaxImageUploadSize) -} - -func parseSize(value, fallback string) int64 { - if size, err := humanize.ParseBytes(value); err == nil && size > 0 { - return int64(size) - } - size, _ := humanize.ParseBytes(fallback) - return int64(size) -} - -// Uploader stores a user-uploaded entity image and invalidates that entity's artwork state. -type Uploader interface { - SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) - RemoveImage(ctx context.Context, path string) error - // EnqueueArtwork re-resolves the item's artwork. Call it AFTER persisting the new - // filename, or the worker resolves the old one. - EnqueueArtwork(ctx context.Context, entityType, entityID string) -} - -var uploadEntityKind = map[string]model.Kind{ - consts.EntityArtist: model.KindArtistArtwork, - consts.EntityPlaylist: model.KindPlaylistArtwork, - consts.EntityRadio: model.KindRadioArtwork, -} - -type uploader struct { - ds model.DataStore -} - -func NewUploader(ds model.DataStore) Uploader { - return &uploader{ds: ds} -} - -func (s *uploader) SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (string, error) { - filename := imageFilename(entityID, name, ext) - absPath := model.UploadedImagePath(entityType, filename) - - if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { - return "", fmt.Errorf("creating image directory: %w", err) - } - - if oldPath != "" { - if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) { - log.Warn(ctx, "Artwork: Failed to remove old image", "path", oldPath, err) - } - } - - f, err := os.Create(absPath) - if err != nil { - return "", fmt.Errorf("creating image file: %w", err) - } - defer f.Close() - - if _, err := io.Copy(f, reader); err != nil { - return "", fmt.Errorf("writing image file: %w", err) - } - return filename, nil -} - -func (s *uploader) EnqueueArtwork(ctx context.Context, entityType, id string) { - kind, ok := uploadEntityKind[entityType] - if !ok { - return - } - if err := Refresh(ctx, s.ds, kind, id); err != nil { - log.Warn(ctx, "Artwork: Could not refresh artwork after upload", "kind", kind, "id", id, err) - } -} - -func (s *uploader) RemoveImage(ctx context.Context, path string) error { - if path == "" { - return nil - } - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("removing image %q: %w", path, err) - } - return nil -} - -func imageFilename(id, name, ext string) string { - clean := utils.CleanFileName(name) - if clean == "" { - return id + ext - } - return id + "_" + clean + ext -} diff --git a/core/artwork/uploader_test.go b/core/artwork/uploader_test.go deleted file mode 100644 index b1420c935..000000000 --- a/core/artwork/uploader_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package artwork - -import ( - "context" - "os" - "path/filepath" - "strings" - - "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/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Uploader", func() { - var ctx context.Context - var svc Uploader - var tmpDir string - var artRepo *tests.MockArtworkRepo - var queueRepo *tests.MockArtworkQueueRepo - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - artRepo = tests.CreateMockArtworkRepo() - queueRepo = tests.CreateMockArtworkQueueRepo() - ds := &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo} - svc = NewUploader(ds) - }) - - Describe("SetImage", func() { - It("creates directory and saves image file", func() { - reader := strings.NewReader("fake image data") - filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg") - Expect(err).ToNot(HaveOccurred()) - Expect(filename).To(Equal("ar-1_pink_floyd.jpg")) - - absPath := filepath.Join(tmpDir, "artwork", "artist", "ar-1_pink_floyd.jpg") - data, err := os.ReadFile(absPath) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("fake image data")) - }) - - It("falls back to ID-only filename when name cleans to empty", func() { - reader := strings.NewReader("data") - filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png") - Expect(err).ToNot(HaveOccurred()) - Expect(filename).To(Equal("pl-1.png")) - }) - - It("removes old image when replacing", func() { - oldDir := filepath.Join(tmpDir, "artwork", "artist") - Expect(os.MkdirAll(oldDir, 0755)).To(Succeed()) - oldFile := filepath.Join(oldDir, "ar-1_old.png") - Expect(os.WriteFile(oldFile, []byte("old"), 0600)).To(Succeed()) - - reader := strings.NewReader("new image") - _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "New Name", oldFile, reader, ".jpg") - Expect(err).ToNot(HaveOccurred()) - Expect(oldFile).ToNot(BeAnExistingFile()) - - newPath := filepath.Join(oldDir, "ar-1_new_name.jpg") - Expect(newPath).To(BeAnExistingFile()) - }) - - It("ignores missing old file without error", func() { - reader := strings.NewReader("data") - _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg") - Expect(err).ToNot(HaveOccurred()) - }) - - It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() { - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external", - })).To(Succeed()) - - _, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", strings.NewReader("img"), ".jpg") - Expect(err).ToNot(HaveOccurred()) - - // SetImage only writes the file; the state row survives and nothing is queued until - // the caller has persisted the new filename and called EnqueueArtwork. - _, err = artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty()) - }) - }) - - Describe("EnqueueArtwork", func() { - It("clears artwork state and enqueues a Bump", func() { - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external", - })).To(Succeed()) - - svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1") - - _, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - - queued, err := queueRepo.DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "ar"), - HaveField("ItemID", "ar-1"), - HaveField("Priority", model.ArtworkPriorityBump), - ))) - }) - - It("is a no-op for an unknown entity type", func() { - svc.EnqueueArtwork(context.Background(), "unknown", "x-1") - Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty()) - }) - }) - - Describe("RemoveImage", func() { - It("removes the file at the given path", func() { - dir := filepath.Join(tmpDir, "artwork", "artist") - Expect(os.MkdirAll(dir, 0755)).To(Succeed()) - path := filepath.Join(dir, "ar-1_test.jpg") - Expect(os.WriteFile(path, []byte("img"), 0600)).To(Succeed()) - - err := svc.RemoveImage(ctx, path) - Expect(err).ToNot(HaveOccurred()) - Expect(path).ToNot(BeAnExistingFile()) - }) - - It("succeeds when file does not exist", func() { - err := svc.RemoveImage(ctx, "/nonexistent/file.jpg") - Expect(err).ToNot(HaveOccurred()) - }) - - It("succeeds with empty path", func() { - err := svc.RemoveImage(ctx, "") - Expect(err).ToNot(HaveOccurred()) - }) - }) -}) - -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/core/artwork/wire_providers.go b/core/artwork/wire_providers.go index 54c2ad397..63231b54a 100644 --- a/core/artwork/wire_providers.go +++ b/core/artwork/wire_providers.go @@ -7,7 +7,5 @@ import ( var Set = wire.NewSet( NewArtwork, GetImageCache, - NewWorker, - GetImageStore, - NewUploader, + NewCacheWarmer, ) diff --git a/core/artwork/worker.go b/core/artwork/worker.go deleted file mode 100644 index 4be99f92e..000000000 --- a/core/artwork/worker.go +++ /dev/null @@ -1,366 +0,0 @@ -package artwork - -import ( - "bytes" - "cmp" - "context" - "fmt" - "io" - "math" - "math/rand/v2" - "runtime/debug" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/utils/cache" -) - -const ( - workerPollInterval = 5 * time.Second - backoffBase = 5 * time.Second - // giveUpAfter bounds the retry budget from enqueue; past it the item settles and only an - // explicit reprocess retries it. - giveUpAfter = 12 * time.Hour -) - -// drainPool drains one class of work with its own slot budget, so a blocking kind cannot -// occupy slots another kind needs. -type drainPool struct { - name string - kinds []string - concurrency int -} - -// Worker drains the artwork queue: each external agent is rate-limited and circuit-broken -// independently, and pruneMu serializes prune against the store-write window. -type Worker struct { - proc *processor - cache cache.FileCache - ffmpeg ffmpeg.FFmpeg - broker events.Broker - pruneMu sync.RWMutex - pools []*drainPool - runCtx context.Context //nolint:containedctx // worker lifecycle ctx, set at Run - paused func() bool - - gatesMu sync.Mutex - gates map[string]*extGate -} - -func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker { - w := &Worker{ - proc: &processor{ds: ds, store: store}, - cache: imgCache, - ffmpeg: ffmpeg, - broker: broker, - pools: newDrainPools(), - runCtx: context.Background(), - paused: func() bool { return false }, - gates: map[string]*extGate{}, - } - w.proc.resolver = newResolver(ds, ag, ffmpeg, w.gate) - w.proc.pruneLock = w.pruneMu.RLocker() - return w -} - -// newDrainPools splits the drain by what bounds it: gate() holds a slot while waiting for its -// rate-limit permit, so a sleeping lookup would crowd out a cover sitting on disk. -func newDrainPools() []*drainPool { - budget := conf.MaxOpenConns() // floored at 4, so both remainders below stay positive - local := min(max(1, conf.Server.DevArtworkWorkerConcurrency), budget-1) - // More external slots than the rate allows would only sleep in the limiter. - external := min(max(2, 2*conf.Server.DevArtworkExternalMaxRPS), budget-local) - return []*drainPool{ - {name: "local", kinds: localDrainKinds, concurrency: local}, - {name: "external", kinds: externalDrainKinds, concurrency: external}, - } -} - -// Kind is a proxy for cost: an album that reaches an external agent still costs a local slot. -var ( - externalDrainKinds = []string{model.KindArtistArtwork.Prefix()} - localDrainKinds = []string{ - model.KindAlbumArtwork.Prefix(), - model.KindPlaylistArtwork.Prefix(), - model.KindRadioArtwork.Prefix(), - model.KindMediaFileArtwork.Prefix(), - } -) - -// PauseWhile holds off queue draining whenever paused reports true. Call it before Run. -func (w *Worker) PauseWhile(paused func() bool) { - w.paused = paused -} - -// Run blocks draining the queue until ctx is cancelled. -func (w *Worker) Run(ctx context.Context) error { - w.runCtx = ctx - var wg sync.WaitGroup - for _, p := range w.pools { - wg.Go(func() { w.runPool(ctx, p) }) - } - wg.Wait() - return nil -} - -func (w *Worker) runPool(ctx context.Context, p *drainPool) { - ticker := time.NewTicker(workerPollInterval) - defer ticker.Stop() - for { - n, err := w.drain(ctx, p.concurrency, p.kinds...) - if err != nil && ctx.Err() == nil { - log.Warn(ctx, "Artwork: Worker drain failed", "pool", p.name, err) - } - if ctx.Err() != nil { - return - } - if n > 0 { - continue - } - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } -} - -// RunPrune runs prune under the worker's write lock, so no acquisition can place -// a file while orphans are being reclaimed. This is the only sanctioned prune path. -func (w *Worker) RunPrune(ctx context.Context) error { - w.pruneMu.Lock() - defer w.pruneMu.Unlock() - return prune(ctx, w.proc.ds, w.proc.store) -} - -// ReconcileConfig records the artwork config fingerprint, or warns when it changed. -func (w *Worker) ReconcileConfig(ctx context.Context) error { - return ReconcileConfigFingerprint(ctx, w.proc.ds) -} - -// EnqueueMissingAll requeues entities with no artwork state row: the safety net for anything -// a scan never enqueued. -func (w *Worker) EnqueueMissingAll(ctx context.Context) error { - return enqueueMissingAll(ctx, w.proc.ds) -} - -func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (int, error) { - if w.paused() { - return 0, nil - } - // Dequeue well past the pool size so a slow external lookup never idles the other slots. - // DequeueBatch does not mark rows taken, so this is one query per pass, not per slot. - items, err := w.proc.ds.ArtworkQueue().DequeueBatch(ctx, max(16, 4*concurrency), kinds...) - if err != nil { - return 0, err - } - if len(items) == 0 { - return 0, nil - } - drainStart := time.Now() - // Private playlists need an admin identity; resolved per drain because the worker can - // start before any admin exists. - ctx = auth.WithAdminUser(ctx, w.proc.ds) - sem := make(chan struct{}, concurrency) - var wg sync.WaitGroup - var refreshMu sync.Mutex - var refresh []model.ArtworkQueueItem - for _, item := range items { - select { - case sem <- struct{}{}: - case <-ctx.Done(): - } - // select picks randomly when both cases are ready, so re-check to never dispatch after cancellation. - if ctx.Err() != nil { - wg.Wait() - return len(items), nil //nolint:nilerr // a cancelled drain is a clean stop, not an error - } - if w.paused() { - break - } - wg.Go(func() { - defer func() { <-sem }() - out, got := w.process(ctx, item) - // Absent counts as a visible change too: clients must drop a previously-served - // immutable cover. - if out == outcomeFound || out == outcomeFoundStale || out == outcomeAbsent { - refreshMu.Lock() - refresh = append(refresh, item) - refreshMu.Unlock() - } - // Post-outcome: the queue row is already settled, so warming the cache can't - // block or alter queue ops. - if got != nil { - w.precache(ctx, got) - } - }) - } - wg.Wait() - w.broadcastRefresh(ctx, refresh) - log.Debug(ctx, "Artwork: Drained a batch", "kinds", kinds, "items", len(items), - "refreshed", len(refresh), "concurrency", concurrency, "elapsed", time.Since(drainStart)) - return len(items), nil -} - -// artworkKindToResource maps a kind to its UI resource name; media_file maps to "song", so -// this can't derive from Kind.String(). -var artworkKindToResource = map[model.Kind]string{ - model.KindAlbumArtwork: "album", - model.KindArtistArtwork: "artist", - model.KindPlaylistArtwork: "playlist", - model.KindRadioArtwork: "radio", - model.KindMediaFileArtwork: "song", -} - -// broadcastRefresh emits one coalesced RefreshResource for the batch, so UIs re-fetch the -// affected records and pick up the new coverArt id. -func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueueItem) { - if len(found) == 0 { - return - } - event := &events.RefreshResource{} - byResource := map[string][]string{} - for _, it := range found { - kind, _ := model.ParseKind(it.ItemKind) - if res, ok := artworkKindToResource[kind]; ok { - byResource[res] = append(byResource[res], it.ItemID) - } - } - if len(byResource) == 0 { - return - } - for res, ids := range byResource { - event = event.With(res, ids...) - } - w.broker.SendBroadcastMessage(ctx, event) -} - -func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) { - item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) - trace := &ChainTrace{} - ctx = withTrace(ctx, trace) - out, got, retryIn := w.safeAcquire(ctx, item) - - queue := w.proc.ds.ArtworkQueue() - switch out { - case outcomeFound, outcomeAbsent: - // A scan that re-enqueued this row mid-flight reset its retry_at, so the row survives - // here and the next drain re-resolves it. - if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { - log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err) - } - case outcomeFoundStale, outcomeFailed: - retryAt := time.Now().Add(retryDelay(item.Attempts, retryIn)) - encoded := trace.encode("") - if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) { - // A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its - // fresh, immediate eligibility. - if err := queue.MarkFailedIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil { - log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err) - } - log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID, - "outcome", out, "attempts", item.Attempts+1, "retryIn", time.Until(retryAt), - "budgetLeft", time.Until(item.EnqueuedAt.Add(giveUpAfter))) - break - } - // Art already being served is kept: exhaustion means unreachable, not removed. - settled := "kept previous state" - if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) { - writeAbsent(ctx, w.proc.ds.Artwork(), item) - settled = "recorded absent" - } - // The queue row is about to go, taking the only record of the failure with it. This write is - // unconditional (not CAS-guarded) — safe only because the drain resolves each item serially. - w.recordGiveUp(ctx, item, encoded) - log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID, - "outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled) - if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil { - log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err) - } - } - return out, got -} - -// safeAcquire turns a panic into a failed attempt: the drain runs on a bare goroutine, so an -// unrecovered panic would crash the server, and the still-queued row would crash it again on restart. -func (w *Worker) safeAcquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) { - defer func() { - if r := recover(); r != nil { - log.Error(ctx, "Artwork: Panic while processing item", "kind", item.ItemKind, "id", item.ItemID, - "imageType", item.ImageType, "attempts", item.Attempts, "panic", r, "stack", string(debug.Stack())) - traceStage(ctx, "panic", fmt.Errorf("%v", r)) - out, got, retryIn = outcomeFailed, nil, 0 - } - }() - return w.proc.acquire(ctx, item) -} - -// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item -// that never resolved has no row to update, and creating one would settle it absent. -func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) { - kind, ok := model.ParseKind(item.ItemKind) - if !ok { - return - } - if err := w.proc.ds.Artwork().PutLastFailure(ctx, kind, item.ItemID, item.ImageType, trace); err != nil { - log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err) - } -} - -func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueueItem) bool { - kind, ok := model.ParseKind(item.ItemKind) - if !ok { - return false - } - ia, err := w.proc.ds.Artwork().GetItemArtwork(ctx, kind, item.ItemID, item.ImageType) - return err == nil && ia.Hash != "" -} - -// precache warms the resize cache at the UI cover size from the bytes just acquired, so the -// first UI request hits without re-reading the rows or the file. -func (w *Worker) precache(ctx context.Context, got *acquired) { - if !conf.Server.EnableArtworkPrecache || w.cache == nil || w.cache.Disabled(ctx) { - return - } - precacheStart := time.Now() - // Same key as the serving path: square must match what the list surfaces request, or this - // warms a key nothing reads. - item := &resizedItem{ - hash: got.ia.Hash, - size: conf.Server.UICoverArtSize, - square: true, - ffmpeg: w.ffmpeg, - open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(got.data)), nil }, - } - stream, err := w.cache.Get(ctx, item) - if err != nil { - log.Debug(ctx, "Artwork: Precache failed", "kind", got.ia.ItemKind, "id", got.ia.ItemID, err) - return - } - _, _ = io.Copy(io.Discard, stream) - _ = stream.Close() - log.Trace(ctx, "Artwork: Precached UI size", "kind", got.ia.ItemKind, "id", got.ia.ItemID, - "size", conf.Server.UICoverArtSize, "elapsed", time.Since(precacheStart)) -} - -// backoffFor returns min(5s×4^n, giveUpAfter) scaled by (1+jitter), with jitter in [-0.4, 0.4]. -func backoffFor(attempts int, jitter float64) time.Duration { - d := min(float64(backoffBase)*math.Pow(4, float64(attempts)), float64(giveUpAfter)) - return time.Duration(d * (1 + jitter)) -} - -func backoff(attempts int) time.Duration { - return backoffFor(attempts, rand.Float64()*0.8-0.4) //nolint:gosec // retry jitter, not security-sensitive -} - -// retryDelay is how long a failed item waits: our backoff, unless the provider asked for longer. -func retryDelay(attempts int, hint time.Duration) time.Duration { - return max(backoff(attempts), hint) -} diff --git a/core/artwork/worker_soak_test.go b/core/artwork/worker_soak_test.go deleted file mode 100644 index 9be95cf2a..000000000 --- a/core/artwork/worker_soak_test.go +++ /dev/null @@ -1,152 +0,0 @@ -package artwork - -import ( - "context" - "io" - "os" - "runtime" - "testing" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Deliberately >2000: a leak guard favors a stable signal over speed. -const soakCycles = 2200 - -var _ = Describe("Worker soak", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) - - It("does not leak goroutines, heap, or fds over many acquisition cycles", func() { - if testing.Short() { - Skip("skipping soak test in short mode") - } - DeferCleanup(configtest.SetupConfig()) - - repoRoot, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - - libRepo := &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - folderRepo := &fakeFolderRepo{result: []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }}} - ffm := tests.NewMockFFmpeg("") - ag := agents.GetAgents(&tests.MockDataStore{}, nil) - artRepo := tests.CreateMockArtworkRepo() - albumRepo := tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{ - {ID: "al-folder", Name: "Folder Album", FolderIDs: []string{"f1"}}, - {ID: "al-embed", Name: "Embedded Album", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}}, - }) - ds := &tests.MockDataStore{ - MockedFolder: folderRepo, - MockedLibrary: libRepo, - MockedArtwork: artRepo, - MockedAlbum: albumRepo, - } - store := NewImageStore(GinkgoT().TempDir()) - proc := &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)} - conf.Server.CoverArtPriority = "cover.jpg, embedded" - - // Dangling refs mirror an entity deleted after being enqueued. - items := []model.ArtworkQueueItem{ - {ItemKind: "al", ItemID: "al-folder"}, - {ItemKind: "al", ItemID: "al-embed"}, - {ItemKind: "al", ItemID: "al-does-not-exist"}, - {ItemKind: "ra", ItemID: "ra-does-not-exist"}, - } - - fdCount := func() int { - if runtime.GOOS != "linux" { - return -1 - } - entries, err := os.ReadDir("/proc/self/fd") - if err != nil { - return -1 - } - return len(entries) - } - - settleGoroutines := func() int { - // Background goroutines wind down late, so poll for two consecutive equal samples. - prev := -1 - for range 100 { - runtime.GC() - n := runtime.NumGoroutine() - if n == prev { - return n - } - prev = n - time.Sleep(10 * time.Millisecond) - } - return prev - } - - baselineGoroutines := settleGoroutines() - baselineFDs := fdCount() - - var heapAt10Pct uint64 - start := time.Now() - for i := range soakCycles { - it := items[i%len(items)] - out, _, _ := proc.acquire(context.Background(), it) - - // Read-back exercises the surfaces a caller would use after acquisition. - if out == outcomeFound { - kind, _ := model.ParseKind(it.ItemKind) - ia, err := artRepo.GetItemArtwork(ctx, kind, it.ItemID, model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i) - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i) - rc, err := store.Open(ia.Hash, art.Mime) - switch { - case err == nil: - _, _ = io.Copy(io.Discard, rc) - rc.Close() - case os.IsNotExist(err): - // Folder-backed art has no store file; that's expected. - default: - Expect(err).ToNot(HaveOccurred(), "cycle %d: store.Open", i) - } - } - - if i == soakCycles/10 { - runtime.GC() - var ms runtime.MemStats - runtime.ReadMemStats(&ms) - heapAt10Pct = ms.HeapAlloc - } - } - elapsed := time.Since(start) - - finalGoroutines := settleGoroutines() - finalFDs := fdCount() - - runtime.GC() - var ms runtime.MemStats - runtime.ReadMemStats(&ms) - - GinkgoWriter.Printf("soak: cycles=%d elapsed=%s goroutines(baseline=%d final=%d) heap(10%%-mark=%d final=%d) fds(baseline=%d final=%d)\n", - soakCycles, elapsed, baselineGoroutines, finalGoroutines, heapAt10Pct, ms.HeapAlloc, baselineFDs, finalFDs) - - Expect(finalGoroutines).To(BeNumerically("<=", baselineGoroutines), "goroutine count grew: baseline=%d final=%d", baselineGoroutines, finalGoroutines) - if heapAt10Pct > 0 { - Expect(ms.HeapAlloc).To(BeNumerically("<=", 2*heapAt10Pct), "heap did not plateau: 10%%-mark=%d final=%d (final > 2x 10%%-mark)", heapAt10Pct, ms.HeapAlloc) - } - if runtime.GOOS == "linux" && baselineFDs >= 0 { - Expect(finalFDs).To(BeNumerically("<=", baselineFDs), "fd count grew: baseline=%d final=%d", baselineFDs, finalFDs) - } - }) -}) diff --git a/core/artwork/worker_test.go b/core/artwork/worker_test.go deleted file mode 100644 index 80ca68bc3..000000000 --- a/core/artwork/worker_test.go +++ /dev/null @@ -1,1073 +0,0 @@ -package artwork - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "os" - "slices" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/cache" - "github.com/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "go.uber.org/goleak" -) - -type recordingCache struct { - cache.FileCache - mu sync.Mutex - keys []string - disabled bool -} - -func (c *recordingCache) Disabled(ctx context.Context) bool { - return c.disabled || c.FileCache.Disabled(ctx) -} - -func (c *recordingCache) Get(ctx context.Context, arg cache.Item) (*cache.CachedStream, error) { - c.mu.Lock() - c.keys = append(c.keys, arg.Key()) - c.mu.Unlock() - return c.FileCache.Get(ctx, arg) -} - -func (c *recordingCache) getKeys() []string { - c.mu.Lock() - defer c.mu.Unlock() - return slices.Clone(c.keys) -} - -// Simulates a concurrent Enqueue between DequeueBatch and the worker's delete, so -// DeleteIfUnchanged on the dequeued value no-ops. -type reenqueueOnDequeue struct { - *tests.MockArtworkQueueRepo - done bool -} - -func (r *reenqueueOnDequeue) DequeueBatch(ctx context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) { - items, err := r.MockArtworkQueueRepo.DequeueBatch(ctx, n, kinds...) - if !r.done && len(items) > 0 { - r.done = true - for k, it := range r.Data { - if it.ItemKind == items[0].ItemKind && it.ItemID == items[0].ItemID { - it.RetryAt = items[0].RetryAt.Add(time.Minute) - r.Data[k] = it - } - } - } - return items, err -} - -type fakeEventBroker struct { - http.Handler - mu sync.Mutex - events []events.Event -} - -func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { - f.mu.Lock() - defer f.mu.Unlock() - f.events = append(f.events, event) -} - -func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { - f.mu.Lock() - defer f.mu.Unlock() - f.events = append(f.events, event) -} - -func (f *fakeEventBroker) getEvents() []events.Event { - f.mu.Lock() - defer f.mu.Unlock() - return slices.Clone(f.events) -} - -var _ events.Broker = (*fakeEventBroker)(nil) - -// expireQueued ages a row past the retry budget, so the next drain settles it instead of retrying. -func expireQueued(q *tests.MockArtworkQueueRepo, id string) { - GinkgoHelper() - for k, v := range q.Data { - if v.ItemID == id { - v.EnqueuedAt = time.Now().Add(-(giveUpAfter + time.Hour)) - q.Data[k] = v - } - } -} - -func findQueued(q *tests.MockArtworkQueueRepo, kind, id string) *model.ArtworkQueueItem { - for _, it := range q.Data { - if it.ItemKind == kind && it.ItemID == id { - return &it - } - } - return nil -} - -// visibilityPlaylistDS models playlist_repository's userFilter: a private playlist is only -// visible when the ctx carries an admin, so headless work must wrap ctx with one first. -type visibilityPlaylistDS struct { - *tests.MockDataStore - private model.Playlist - tracks model.PlaylistTrackRepository -} - -func (v *visibilityPlaylistDS) Playlist() model.PlaylistRepository { - repo := tests.CreateMockPlaylistRepo() - repo.TracksRepo = v.tracks - repo.SetData(model.Playlists{v.private}) - return &visibilityPlaylistRepo{MockPlaylistRepo: repo} -} - -type visibilityPlaylistRepo struct { - *tests.MockPlaylistRepo -} - -func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Playlist, error) { - if u, ok := request.UserFrom(ctx); !ok || !u.IsAdmin { - return nil, model.ErrNotFound - } - return v.MockPlaylistRepo.Get(ctx, id) -} - -type panickingAlbumRepo struct { - *tests.MockAlbumRepo - panicID string -} - -func (r *panickingAlbumRepo) Get(ctx context.Context, id string) (*model.Album, error) { - if id == r.panicID { - panic("boom") - } - return r.MockAlbumRepo.Get(ctx, id) -} - -func adminUserRepo() *tests.MockedUserRepo { - repo := tests.CreateMockUserRepo() - Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) - return repo -} - -var _ = Describe("Worker", func() { - var ( - ctx context.Context - ds *tests.MockDataStore - folderRepo *fakeFolderRepo - libRepo *tests.MockLibraryRepo - ffm *tests.MockFFmpeg - ag *agents.Agents - store *ImageStore - artRepo *tests.MockArtworkRepo - queueRepo *tests.MockArtworkQueueRepo - broker *fakeEventBroker - imgCache *recordingCache - repoRoot string - w *Worker - ) - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - var err error - repoRoot, err = os.Getwd() - Expect(err).ToNot(HaveOccurred()) - conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) - - folderRepo = &fakeFolderRepo{} - libRepo = &tests.MockLibraryRepo{} - libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) - ffm = tests.NewMockFFmpeg("") - ag = agents.GetAgents(&tests.MockDataStore{}, nil) - artRepo = tests.CreateMockArtworkRepo() - queueRepo = tests.CreateMockArtworkQueueRepo() - ds = &tests.MockDataStore{ - MockedFolder: folderRepo, - MockedLibrary: libRepo, - MockedArtwork: artRepo, - MockedArtworkQueue: queueRepo, - } - ds.MockedAlbum = tests.CreateMockAlbumRepo() - store = NewImageStore(GinkgoT().TempDir()) - conf.Server.CoverArtPriority = "cover.jpg, embedded" - conf.Server.DevArtworkExternalMaxRPS = 1000 // keep the limiter out of the way of behavior tests - broker = &fakeEventBroker{} - imgCache = &recordingCache{FileCache: cache.NewFileCache("WorkerTest", "100MB", "images", 0, - func(ctx context.Context, arg cache.Item) (io.Reader, error) { - return arg.(artworkReader).Reader(ctx) - })} - // Init walks the cache dir on a goroutine; loaded CI runners can take >1s. - Eventually(func() bool { return imgCache.Available(ctx) }, 10*time.Second).Should(BeTrue()) - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - }) - - Describe("drain", func() { - It("processes a seeded queue item and removes it from the queue", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, - }) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan, - })).To(Succeed()) - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("folder")) - - count, err := queueRepo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeZero(), "a found item must be deleted from the queue") - }) - - It("processes an mf queue item, writing state and storing embedded bytes", func() { - conf.Server.EnableMediaFileCoverArt = true - ds.MockedMediaFile = tests.CreateMockMediaFileRepo() - ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, - }) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "mf", ItemID: "mf1", Priority: model.ArtworkPriorityBump, - })).To(Succeed()) - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("embedded")) - Expect(ia.Hash).ToNot(BeEmpty()) - - art, err := artRepo.GetImage(ctx, ia.Hash) - Expect(err).ToNot(HaveOccurred()) - r, err := store.Open(ia.Hash, art.Mime) - Expect(err).ToNot(HaveOccurred()) - defer r.Close() - data, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - Expect(data).ToNot(BeEmpty(), "embedded bytes must be written to the store") - - count, err := queueRepo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeZero()) - }) - - It("reschedules a failed item via MarkFailed with a backed-off retry_at", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed()) - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - it := findQueued(queueRepo, "al", "al4") - Expect(it).ToNot(BeNil()) - Expect(it.Attempts).To(Equal(1)) - Expect(it.RetryAt).To(BeTemporally(">", time.Now())) - - _, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") - }) - - It("fails an item that panics, without stopping the rest of the batch", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - albums := tests.CreateMockAlbumRepo() - albums.SetData(model.Albums{ - {ID: "alboom", Name: "Album", FolderIDs: []string{"f1"}}, - {ID: "alok", Name: "Album", FolderIDs: []string{"f1"}}, - }) - ds.MockedAlbum = &panickingAlbumRepo{MockAlbumRepo: albums, panicID: "alboom"} - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alboom"})).To(Succeed()) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alok"})).To(Succeed()) - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(2)) - - it := findQueued(queueRepo, "al", "alboom") - Expect(it).ToNot(BeNil(), "a panicking item must be rescheduled, not dropped") - Expect(it.Attempts).To(Equal(1)) - Expect(it.RetryAt).To(BeTemporally(">", time.Now())) - Expect(it.Trace).To(ContainSubstring("boom")) - - Expect(findQueued(queueRepo, "al", "alok")).To(BeNil()) - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alok", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("folder")) - }) - - It("reschedules past the provider's requested delay when it exceeds the backoff", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) - // Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at. - const askedFor = 90 * time.Minute - imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}}) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - it := findQueued(queueRepo, "al", "al9") - Expect(it).ToNot(BeNil()) - Expect(it.RetryAt).To(BeTemporally("~", time.Now().Add(askedFor), time.Minute)) - }) - - It("reschedules a found-stale item via MarkFailed while keeping its served state", func() { - conf.Server.CoverArtPriority = "external, cover.jpg" - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}}, - }) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed()) - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - it := findQueued(queueRepo, "al", "alstale") - Expect(it).ToNot(BeNil(), "a found-stale row must survive for a higher-priority retry") - Expect(it.Attempts).To(Equal(1)) - Expect(it.RetryAt).To(BeTemporally(">", time.Now())) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile") - - evts := broker.getEvents() - Expect(evts).To(HaveLen(1), "the served fallback art must live-refresh the UI") - Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("alstale")) - }) - - It("keeps a row re-enqueued between dequeue and delete", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al7", Name: "Album", FolderIDs: []string{"f1"}}, - }) - racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} - ds.MockedArtworkQueue = racing - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan, - })).To(Succeed()) - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - // The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op. - Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil()) - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("folder")) - }) - - It("keeps a fresh re-enqueue ahead of a stale failure backoff", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al8", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} - ds.MockedArtworkQueue = racing - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed()) - dequeued := findQueued(queueRepo, "al", "al8").RetryAt - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - // The re-enqueue reset retry_at; the failure path must not stomp it nor bump attempts. - it := findQueued(queueRepo, "al", "al8") - Expect(it).ToNot(BeNil()) - Expect(it.Attempts).To(BeZero()) - Expect(it.RetryAt).To(BeTemporally("==", dequeued.Add(time.Minute))) - }) - - It("gives up and settles absent once the retry budget is exhausted", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed()) - // Age the row past the retry budget. - expireQueued(queueRepo, "al9") - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - Expect(findQueued(queueRepo, "al", "al9")).To(BeNil()) - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al9", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(BeEmpty()) - }) - - It("keeps already-served art when the retry budget is exhausted", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al10", Name: "Album"}}) - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary, - Hash: "cafebabe", Source: "external:lastfm", - })).To(Succeed()) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed()) - expireQueued(queueRepo, "al10") - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - Expect(findQueued(queueRepo, "al", "al10")).To(BeNil()) - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al10", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art") - }) - - It("records on the queue row why the last attempt failed", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed()) - - _, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - - it := findQueued(queueRepo, "al", "al11") - Expect(it).ToNot(BeNil()) - Expect(DecodeTrace(it.Trace, "")).To(ContainElement(SatisfyAll( - HaveField("Candidate", "external:failAgent"), - HaveField("Outcome", OutcomeError), - HaveField("Detail", ContainSubstring("agent timed out")), - )), "a retrying row must say why it is retrying") - }) - - // The give-up path settles absent before recording, so the row exists by the time the - // failure is written. Recording first would silently lose it for every unresolved item. - It("keeps the failure for an item that never resolved at all", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed()) - expireQueued(queueRepo, "al13") - - _, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al13", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to") - Expect(ia.Hash).To(BeEmpty()) - Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty()) - }) - - It("keeps the failure on the state row after the queue row is deleted", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}}) - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary, - Hash: "cafebabe", Source: "external:lastfm", - })).To(Succeed()) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - w = NewWorker(ds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed()) - expireQueued(queueRepo, "al12") - - _, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - - Expect(findQueued(queueRepo, "al", "al12")).To(BeNil()) - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al12", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(), - "the queue row is gone, so this is the only remaining record of the failure") - Expect(ia.Hash).To(Equal("cafebabe"), "recording the failure must not disturb the served art") - }) - - // Only a view enqueues a media file, and an absent row is exactly what stops a view from - // doing so: a transient read error would look permanent. - It("does not settle absent on exhaustion for a media file", func() { - conf.Server.EnableMediaFileCoverArt = true - ds.MockedMediaFile = tests.CreateMockMediaFileRepo() - ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true}, - }) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed()) - expireQueued(queueRepo, "mfX") - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - Expect(findQueued(queueRepo, "mf", "mfX")).To(BeNil(), "the row must stop retrying") - _, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), - "no row leaves the track unresolved, so a later view can still recover it") - // Known gap: with no row and no absent settle, there is nowhere to keep the failure. - // Creating one here would write an empty hash, which every reader treats as absent. - }) - - It("resolves a private playlist under an admin context instead of failing forever", func() { - ds.MockedUser = adminUserRepo() - vds := &visibilityPlaylistDS{ - MockDataStore: ds, - private: model.Playlist{ID: "plPriv", OwnerID: "admin"}, - tracks: &tests.MockPlaylistTrackRepo{}, - } - w = NewWorker(vds, store, ag, ffm, broker, imgCache) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed()) - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil()) - ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(BeEmpty()) - }) - - It("returns zero when the queue is empty", func() { - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(BeZero()) - }) - - It("broadcasts a single refresh event for the found items in a batch", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al1", Name: "Album 1", FolderIDs: []string{"f1"}}, - {ID: "al2", Name: "Album 2", FolderIDs: []string{"f1"}}, - }) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed()) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed()) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed()) - - n, err := w.drain(ctx, 3) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(3)) - - evts := broker.getEvents() - Expect(evts).To(HaveLen(1), "exactly one coalesced event per drain batch") - rr, ok := evts[0].(*events.RefreshResource) - Expect(ok).To(BeTrue()) - data := rr.Data(rr) - Expect(data).To(ContainSubstring(`"album"`)) - Expect(data).To(ContainSubstring("al1")) - Expect(data).To(ContainSubstring("al2")) - Expect(data).ToNot(ContainSubstring("artist"), "a failed (unresolved) artist must not be refreshed") - Expect(data).ToNot(ContainSubstring("ar1")) - }) - - DescribeTable("only lists the kinds it actually resolved", - func(kinds []string, wantSong bool) { - items := slice.Map(kinds, func(k string) model.ArtworkQueueItem { - return model.ArtworkQueueItem{ItemKind: k, ItemID: k + "1"} - }) - w.broadcastRefresh(ctx, items) - - evts := broker.getEvents() - Expect(evts).To(HaveLen(1)) - data := evts[0].(*events.RefreshResource).Data(evts[0]) - if wantSong { - Expect(data).To(ContainSubstring(`"song"`)) - } else { - Expect(data).ToNot(ContainSubstring(`"song"`)) - } - }, - // An album's tracks inherit its art, but the dependent ids are unbounded: the client - // fans an album refresh out to the tracks it has loaded. - Entry("album alone does not name songs", []string{"al"}, false), - Entry("artist alone does not", []string{"ar"}, false), - Entry("playlist alone does not", []string{"pl"}, false), - Entry("album mixed with others still does not", []string{"ar", "al"}, false), - Entry("songs resolving on their own are listed by id", []string{"mf"}, true), - ) - - It("broadcasts a refresh when an item resolves to absent (removed cover)", func() { - conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}}) - folderRepo.result = nil - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed()) - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - evts := broker.getEvents() - Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it") - Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3")) - - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found") - }) - - It("does not broadcast when no item is found", func() { - conf.Server.CoverArtPriority = "external" - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}}) - imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed()) - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - Expect(broker.getEvents()).To(BeEmpty(), "a drain with no found items sends no event") - }) - }) - - Describe("gate/breaker", func() { - It("opens after 5 consecutive external errors and short-circuits the step", func() { - var calls int - failing := func() (io.ReadCloser, string, error) { - calls++ - return nil, "", errors.New("boom") - } - for range 5 { - _, _, err := w.gate("A", failing) - Expect(err).To(HaveOccurred()) - } - Expect(calls).To(Equal(5)) - - _, _, err := w.gate("A", failing) - Expect(err).To(MatchError(errBreakerOpen)) - Expect(calls).To(Equal(5), "an open breaker must not call the external step") - }) - - It("resets the failure count on a successful call", func() { - failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } - ok := func() (io.ReadCloser, string, error) { return io.NopCloser(nil), "p", nil } - for range 4 { - _, _, _ = w.gate("A", failing) - } - _, _, err := w.gate("A", ok) - Expect(err).ToNot(HaveOccurred()) - - var calls int - counting := func() (io.ReadCloser, string, error) { - calls++ - return nil, "", errors.New("boom") - } - for range 5 { - _, _, _ = w.gate("A", counting) - } - Expect(calls).To(Equal(5), "the breaker should have re-closed after the success") - }) - - It("does not open the breaker when the run is cancelled", func() { - cancelled := func() (io.ReadCloser, string, error) { return nil, "", context.Canceled } - for range breakerThreshold + 3 { - _, _, err := w.gate("A", cancelled) - Expect(err).To(MatchError(context.Canceled), "a cancellation passes through, never errBreakerOpen") - } - - var calls int - counting := func() (io.ReadCloser, string, error) { - calls++ - return nil, "", errors.New("boom") - } - _, _, _ = w.gate("A", counting) - Expect(calls).To(Equal(1), "the breaker stayed closed, so the step still runs") - }) - - It("ignores a cancellation mid-run, neither counting nor clearing the failures", func() { - failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } - cancelled := func() (io.ReadCloser, string, error) { return nil, "", context.Canceled } - for range breakerThreshold - 1 { - _, _, _ = w.gate("A", failing) - } - _, _, _ = w.gate("A", cancelled) - - var calls int - counting := func() (io.ReadCloser, string, error) { - calls++ - return nil, "", errors.New("boom") - } - _, _, _ = w.gate("A", counting) - Expect(calls).To(Equal(1), "the cancellation must not have counted as the final failure") - - _, _, err := w.gate("A", counting) - Expect(err).To(MatchError(errBreakerOpen), "the cancellation must not have cleared the earlier failures") - Expect(calls).To(Equal(1), "an open breaker must not call the external step") - }) - - It("does not open the breaker on a run of agent not-found misses", func() { - // agents.ErrNotFound is a definitive miss, not a fault: artless items must not - // trip the breaker, or they would loop in retry instead of settling absent. - notFound := func() (io.ReadCloser, string, error) { return nil, "", agents.ErrNotFound } - for range breakerThreshold + 3 { - _, _, err := w.gate("A", notFound) - Expect(err).To(MatchError(agents.ErrNotFound), "a miss passes through, never errBreakerOpen") - } - - var calls int - counting := func() (io.ReadCloser, string, error) { - calls++ - return nil, "", errors.New("boom") - } - _, _, _ = w.gate("A", counting) - Expect(calls).To(Equal(1), "the breaker stayed closed, so the step still runs") - }) - - It("isolates each agent's breaker: one open gate does not block another", func() { - failing := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } - for range breakerThreshold { - _, _, _ = w.gate("A", failing) - } - _, _, err := w.gate("A", failing) - Expect(err).To(MatchError(errBreakerOpen), "agent A's breaker is open") - - var bCalls int - bStep := func() (io.ReadCloser, string, error) { - bCalls++ - return io.NopCloser(nil), "p", nil - } - for range breakerThreshold + 2 { - _, _, err := w.gate("B", bStep) - Expect(err).ToNot(HaveOccurred(), "agent B keeps being called while A is open") - } - Expect(bCalls).To(Equal(breakerThreshold + 2)) - }) - }) - - Describe("precache", func() { - BeforeEach(func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}}, - }) - conf.Server.UICoverArtSize = 300 - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan, - })).To(Succeed()) - }) - - It("warms the resize cache at the UI cover size after a found acquisition", func() { - conf.Server.EnableArtworkPrecache = true - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - // The list surfaces request square covers, so warming any other key is wasted work. - Expect(imgCache.getKeys()).To(ContainElement(ContainSubstring(".300.true."))) - }) - - It("skips warming when precache is disabled", func() { - conf.Server.EnableArtworkPrecache = false - - n, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(1)) - - Expect(imgCache.getKeys()).To(BeEmpty()) - }) - - // It warms from the bytes acquisition already held, so no state row or store file is needed. - It("warms from the acquired bytes without reading them back", func() { - conf.Server.EnableArtworkPrecache = true - ia := &model.ItemArtwork{ - ItemKind: "al", ItemID: "unpersisted", ImageType: model.ImageTypePrimary, - Hash: "abcdef0123456789", UpdatedAt: time.Now(), - } - - data, err := os.ReadFile("tests/fixtures/artist/an-album/cover.jpg") - Expect(err).ToNot(HaveOccurred()) - - w.precache(ctx, &acquired{ia: ia, mime: "image/jpeg", data: data}) - - // Nothing backs that hash on disk, so a hit can only come from the bytes handed in; - // the probe refuses to open, proving nothing is re-read. - probe := &resizedItem{ - hash: ia.Hash, size: 300, square: true, ffmpeg: ffm, - open: func() (io.ReadCloser, error) { return nil, errors.New("precache must not re-read the source") }, - } - stream, err := imgCache.Get(ctx, probe) - Expect(err).ToNot(HaveOccurred()) - defer stream.Close() - Expect(io.ReadAll(stream)).ToNot(BeEmpty()) - }) - }) - - Describe("drain pools", func() { - // A kind in neither pool is never dequeued, with nothing to catch it at compile time. - It("covers every kind the worker can process, exactly once", func() { - var pooled []string - for _, p := range newDrainPools() { - pooled = append(pooled, p.kinds...) - } - for kind := range artworkKindToResource { - Expect(pooled).To(ContainElement(kind.Prefix()), "kind %q belongs to no drain pool", kind.Prefix()) - } - Expect(pooled).To(HaveLen(len(artworkKindToResource)), "a kind is claimed by more than one pool") - }) - - // Artists resolve through a rate-limited agent that holds its slot while waiting, so one - // shared pool would park every album behind them. - It("resolves albums while artists are stuck on a slow agent", func() { - conf.Server.CoverArtPriority = "cover.jpg" - conf.Server.ArtistArtPriority = "external" - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album", FolderIDs: []string{"f1"}}}) - ds.MockedArtist = tests.CreateMockArtistRepo() - - // Every artist lookup blocks until released, standing in for the rate limiter. - block := make(chan struct{}) - artists := model.Artists{} - for i := range 8 { - artists = append(artists, model.Artist{ID: fmt.Sprintf("arx%d", i), Name: "A"}) - } - ds.MockedArtist.(*tests.MockArtistRepo).SetData(artists) - imageAgents(&fakeImageAgent{name: "slowAgent", block: block}) - - // Artists first, exactly as Backfill orders them. - for _, a := range artists { - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "ar", ItemID: a.ID, Priority: model.ArtworkPriorityBackfill, - })).To(Succeed()) - } - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: "alx", Priority: model.ArtworkPriorityBackfill, - })).To(Succeed()) - - runCtx, cancel := context.WithCancel(ctx) - done := make(chan struct{}) - go func() { defer close(done); _ = w.Run(runCtx) }() - // Join Run: a leaked pool goroutine would race the config snapshot Ginkgo restores. - DeferCleanup(func() { - cancel() - close(block) // unpark the blocked lookups so the pools can unwind - <-done - }) - - Eventually(func() bool { - ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alx", model.ImageTypePrimary) - return err == nil && ia.Hash != "" - }, 5*time.Second, 50*time.Millisecond).Should(BeTrue(), - "a blocked external pool must not hold up local artwork") - - _, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "arx0", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound), "artists are still blocked, as intended") - }) - }) - - Describe("batching", func() { - It("leaves undispatched items queued when cancelled mid-batch", func() { - // Every album must resolve, so any dispatched item deletes its row regardless of dequeue order. - albums := model.Albums{} - for i := range 8 { - id := fmt.Sprintf("alc%d", i) - albums = append(albums, model.Album{ID: id, Name: "Album"}) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan, - })).To(Succeed()) - } - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums) - cancelledCtx, cancel := context.WithCancel(ctx) - cancel() - - _, err := w.drain(cancelledCtx, 1) - Expect(err).ToNot(HaveOccurred()) - - for i := range 8 { - id := fmt.Sprintf("alc%d", i) - Expect(findQueued(queueRepo, "al", id)).ToNot(BeNil(), "row "+id+" must survive a cancelled drain") - } - }) - - It("stops dispatching and leaves the rest queued when paused mid-batch", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - albums := model.Albums{} - for i := range 8 { - id := fmt.Sprintf("alp%d", i) - albums = append(albums, model.Album{ID: id, Name: "Album", FolderIDs: []string{"f1"}}) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan, - })).To(Succeed()) - } - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums) - // Pauses as soon as the first item has left the queue. - w.PauseWhile(func() bool { - n, _ := queueRepo.Count(ctx) - return n < 8 - }) - - _, err := w.drain(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - - count, err := queueRepo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(7)), "only the item dispatched before the pause may leave the queue") - }) - - It("dequeues past the worker pool so one drain covers many items", func() { - for i := range 16 { - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}}) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: fmt.Sprintf("alb%d", i), Priority: model.ArtworkPriorityScan, - })).To(Succeed()) - } - - n, err := w.drain(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(16), "a batch sized to the pool would have stopped at 4") - }) - }) - - Describe("RunPrune", func() { - It("runs a prune under the worker mutex", func() { - Expect(w.RunPrune(ctx)).To(Succeed()) - }) - }) - - Describe("Run", func() { - It("exits cleanly when the context is cancelled", func() { - runCtx, cancel := context.WithCancel(ctx) - done := make(chan error, 1) - go func() { done <- w.Run(runCtx) }() - - cancel() - Eventually(done, time.Second).Should(Receive(BeNil())) - }) - - It("does not drain the queue while paused", func() { - folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"cover.jpg"}, - }} - ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: "al1", Name: "Album", FolderIDs: []string{"f1"}}, - }) - Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ - ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan, - })).To(Succeed()) - w.PauseWhile(func() bool { return true }) - - runCtx, cancel := context.WithCancel(ctx) - done := make(chan error, 1) - go func() { done <- w.Run(runCtx) }() - DeferCleanup(func() { - cancel() - Eventually(done, 2*time.Second).Should(Receive(BeNil())) - }) - - Consistently(func() any { return findQueued(queueRepo, "al", "al1") }, 300*time.Millisecond).ShouldNot(BeNil()) - }) - - It("does not leak goroutines after Run exits", func() { - DeferCleanup(configtest.SetupConfig()) - - ignore := goleak.IgnoreCurrent() - DeferCleanup(func() { goleak.VerifyNone(GinkgoT(), ignore) }) - - localDS := &tests.MockDataStore{MockedArtworkQueue: tests.CreateMockArtworkQueueRepo()} - lw := NewWorker(localDS, NewImageStore(GinkgoT().TempDir()), agents.GetAgents(localDS, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}, imgCache) - - runCtx, cancel := context.WithCancel(ctx) - done := make(chan error, 1) - go func() { done <- lw.Run(runCtx) }() - - time.Sleep(20 * time.Millisecond) // let the loop settle on the idle select - cancel() - Eventually(done, 2*time.Second).Should(Receive(BeNil())) - }) - }) -}) - -var _ = Describe("backoff", func() { - It("returns the expected schedule with no jitter", func() { - for _, c := range []struct { - attempts int - want time.Duration - }{ - {0, 5 * time.Second}, - {1, 20 * time.Second}, - {2, 80 * time.Second}, - {3, 320 * time.Second}, - {4, 1280 * time.Second}, - {5, 5120 * time.Second}, - {6, 20480 * time.Second}, - {7, 12 * time.Hour}, - {8, 12 * time.Hour}, - } { - Expect(backoffFor(c.attempts, 0)).To(Equal(c.want), "attempt %d", c.attempts) - } - }) - - It("applies jitter proportionally", func() { - base := backoffFor(2, 0) - Expect(backoffFor(2, 0.2)).To(Equal(time.Duration(float64(base) * 1.2))) - Expect(backoffFor(2, -0.2)).To(Equal(time.Duration(float64(base) * 0.8))) - }) - - It("keeps random jitter within +/-40%", func() { - lo := time.Duration(float64(320*time.Second) * 0.6) - hi := time.Duration(float64(320*time.Second) * 1.4) - for range 200 { - d := backoff(3) - Expect(d).To(BeNumerically(">=", lo)) - Expect(d).To(BeNumerically("<=", hi)) - } - }) -}) - -var _ = Describe("retryDelay", func() { - It("uses the backoff schedule when the provider asked for nothing", func() { - d := retryDelay(0, 0) - Expect(d).To(BeNumerically(">=", 3*time.Second)) - Expect(d).To(BeNumerically("<=", 7*time.Second)) - }) - - It("waits the provider's delay when it is longer than the backoff", func() { - Expect(retryDelay(0, time.Hour)).To(Equal(time.Hour)) - }) - - It("keeps the backoff when it is longer than the provider's delay", func() { - Expect(retryDelay(4, time.Second)).To(BeNumerically(">=", 3*time.Second)) - }) -}) diff --git a/core/artwork/worker_timing_test.go b/core/artwork/worker_timing_test.go deleted file mode 100644 index 63f12d03c..000000000 --- a/core/artwork/worker_timing_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package artwork - -import ( - "errors" - "io" - "testing" - "testing/synctest" - "time" - - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/gomega" -) - -// Drives the real breaker state machine with the fake clock. Plain test: testing/synctest -// needs a *testing.T, which Ginkgo doesn't give. -func TestArtworkBreakerHalfOpen(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - b := newBreaker() - - for range breakerThreshold { - b.record("agentA", 0, errors.New("boom")) - } - g.Expect(allowed(b)).To(BeFalse(), "breaker opens after consecutive errors") - - time.Sleep(breakerProbeAfter - time.Nanosecond) - g.Expect(allowed(b)).To(BeFalse(), "still open before the probe interval") - - time.Sleep(time.Nanosecond) - ok, gen := b.allow() - g.Expect(ok).To(BeTrue(), "half-open: one probe is granted") - g.Expect(gen).ToNot(BeZero(), "a probe carries the open episode it belongs to") - g.Expect(allowed(b)).To(BeFalse(), "only a single probe per interval") - - b.record("agentA", gen, errors.New("boom")) // probe fails -> stay open - time.Sleep(breakerProbeAfter) - ok, gen = b.allow() - g.Expect(ok).To(BeTrue(), "another probe after the next interval") - - // One good answer must not reopen the floodgates: closing here is what let a burst out at - // full rate and got the provider to escalate from throttling to blocking. - b.record("agentA", gen, nil) - g.Expect(allowed(b)).To(BeFalse(), "a single good answer does not close the breaker") - - for range breakerRecoveries - 1 { - time.Sleep(breakerProbeAfter) - ok, gen = b.allow() - g.Expect(ok).To(BeTrue()) - b.record("agentA", gen, nil) - } - g.Expect(allowed(b)).To(BeTrue(), "closed breaker admits freely") - g.Expect(allowed(b)).To(BeTrue()) - }) -} - -// The failure seen in production: while an agent was blocked, the occasional answer it did serve -// reset the breaker, releasing a burst that immediately re-tripped it. Open and closed pairs were -// seconds apart, over and over. -func TestArtworkBreakerDoesNotCloseOnAnIsolatedAnswer(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - b := newBreaker() - open := func() { - for range breakerThreshold { - b.record("agentA", 0, errors.New("blocked")) - } - } - open() - - // A not-found is an answer, so it counts toward recovery, but never on its own. - for range breakerRecoveries * 2 { - time.Sleep(breakerProbeAfter) - ok, gen := b.allow() - g.Expect(ok).To(BeTrue(), "one probe per interval") - b.record("agentA", gen, agents.ErrNotFound) - b.record("agentA", 0, errors.New("blocked")) // the very next call is refused again - g.Expect(allowed(b)).To(BeFalse(), "an answer between failures must not close the breaker") - } - }) -} - -func TestArtworkGatePerAgentBreakerIsolation(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - w := NewWorker(&tests.MockDataStore{}, NewImageStore(t.TempDir()), - agents.GetAgents(&tests.MockDataStore{}, nil), tests.NewMockFFmpeg(""), &fakeEventBroker{}, nil) - - fail := func() (io.ReadCloser, string, error) { return nil, "", errors.New("boom") } - for range breakerThreshold { - _, _, _ = w.gate("A", fail) - } - _, _, err := w.gate("A", fail) - g.Expect(err).To(MatchError(errBreakerOpen), "A opens after consecutive errors") - - // B has its own breaker, untouched by A being open. - var bCalls int - bStep := func() (io.ReadCloser, string, error) { bCalls++; return nil, "", errors.New("boom") } - for range breakerThreshold - 1 { - _, _, err := w.gate("B", bStep) - g.Expect(err).To(MatchError("boom")) - } - g.Expect(bCalls).To(Equal(breakerThreshold-1), "B keeps being called while A is open") - - // After the probe window, A admits exactly one probe again. - time.Sleep(breakerProbeAfter) - var aCalls int - aFail := func() (io.ReadCloser, string, error) { aCalls++; return nil, "", errors.New("boom") } - _, _, _ = w.gate("A", aFail) - g.Expect(aCalls).To(Equal(1), "A grants a single probe after the interval") - _, _, err = w.gate("A", aFail) - g.Expect(err).To(MatchError(errBreakerOpen), "the probe failed, so A stays open") - g.Expect(aCalls).To(Equal(1)) - }) -} diff --git a/core/auth/auth.go b/core/auth/auth.go index 1bdc917da..2dd7923ee 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -1,94 +1,73 @@ package auth import ( - "cmp" "context" - "crypto/sha256" - "errors" - "slices" "sync" "time" "github.com/go-chi/jwtauth/v5" - "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/lestrrat-go/jwx/v2/jwt" "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/id" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils" ) var ( - once sync.Once - // TokenAuth signs UI/API session tokens. Rotated by the id migration so stale sessions die. + once sync.Once + Secret []byte TokenAuth *jwtauth.JWTAuth - // PublicTokenAuth signs public-link tokens (artwork, share streams), on a separate secret that survives the rotation. - PublicTokenAuth *jwtauth.JWTAuth ) -// Audiences a session token can be scoped to. A token with no audience is accepted anywhere. -const ( - AudienceJellyfin = "jellyfin" - AudienceSubsonic = "subsonic" - AudienceNative = "native" -) - -// Init creates the JWTAuth objects from the secrets stored in the DB. -// Missing or undecryptable secrets are regenerated and stored. func Init(ds model.DataStore) { once.Do(func() { - ctx := context.TODO() log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout) - - TokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTSecretKey)), nil) - PublicTokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTPublicSecretKey)), nil) + secret, err := ds.Property(context.TODO()).DefaultGet(consts.JWTSecretKey, "not so secret") + if err != nil { + log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err) + } + Secret = []byte(secret) + TokenAuth = jwtauth.New("HS256", Secret, nil) }) } -func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string { - secret, err := ds.Property().Get(ctx, key) - if err != nil || secret == "" { - log.Info(ctx, "Creating new JWT secret", "key", key) - return createNewSecret(ctx, ds, key) - } - if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil { - log.Error(ctx, "Could not decrypt JWT secret, creating a new one", "key", key, err) - return createNewSecret(ctx, ds, key) - } - return secret +func createBaseClaims() map[string]any { + tokenClaims := map[string]any{} + tokenClaims[jwt.IssuerKey] = consts.JWTIssuer + return tokenClaims } -func CreatePublicToken(claims Claims) (string, error) { - claims.Issuer = consts.JWTIssuer - _, token, err := PublicTokenAuth.Encode(claims.ToMap()) +func CreatePublicToken(claims map[string]any) (string, error) { + tokenClaims := createBaseClaims() + for k, v := range claims { + tokenClaims[k] = v + } + _, token, err := TokenAuth.Encode(tokenClaims) + return token, err } -func CreateExpiringPublicToken(exp time.Time, claims Claims) (string, error) { - claims.Issuer = consts.JWTIssuer +func CreateExpiringPublicToken(exp time.Time, claims map[string]any) (string, error) { + tokenClaims := createBaseClaims() if !exp.IsZero() { - claims.ExpiresAt = exp + tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix() } - _, token, err := PublicTokenAuth.Encode(claims.ToMap()) - return token, err -} + for k, v := range claims { + tokenClaims[k] = v + } + _, token, err := TokenAuth.Encode(tokenClaims) -func userClaims(u *model.User, audience []string) Claims { - return Claims{ - Issuer: consts.JWTIssuer, - Subject: u.UserName, - IssuedAt: time.Now(), - UserID: u.ID, - IsAdmin: u.IsAdmin, - Epoch: u.TokenEpoch, - Audience: audience, - } + return token, err } func CreateToken(u *model.User) (string, error) { - token, _, err := TokenAuth.Encode(userClaims(u, nil).ToMap()) + claims := createBaseClaims() + claims[jwt.SubjectKey] = u.UserName + claims[jwt.IssuedAtKey] = time.Now().UTC().Unix() + claims["uid"] = u.ID + claims["adm"] = u.IsAdmin + token, _, err := TokenAuth.Encode(claims) if err != nil { return "", err } @@ -96,110 +75,38 @@ func CreateToken(u *model.User) (string, error) { return TouchToken(token) } -// CreateAPIToken mints a non-expiring token scoped to one API, matching how Jellyfin -// clients expect tokens to behave. Revocation is by token epoch, not expiry. -func CreateAPIToken(u *model.User, audience string) (string, error) { - _, token, err := TokenAuth.Encode(userClaims(u, []string{audience}).ToMap()) - return token, err -} - func TouchToken(token jwt.Token) (string, error) { - return TouchClaims(ClaimsFromToken(token)) -} + claims, err := token.AsMap(context.Background()) + if err != nil { + return "", err + } + + claims[jwt.ExpirationKey] = time.Now().UTC().Add(conf.Server.SessionTimeout).Unix() + _, newToken, err := TokenAuth.Encode(claims) -func TouchClaims(c Claims) (string, error) { - c = c.WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout)) - _, newToken, err := TokenAuth.Encode(c.ToMap()) return newToken, err } -func Validate(tokenStr string) (Claims, error) { +func Validate(tokenStr string) (map[string]interface{}, error) { token, err := jwtauth.VerifyToken(TokenAuth, tokenStr) if err != nil { - return Claims{}, err + return nil, err } - return ClaimsFromToken(token), nil -} - -// ValidatePublic verifies a public-link token against the public secret. -func ValidatePublic(tokenStr string) (Claims, error) { - token, err := jwtauth.VerifyToken(PublicTokenAuth, tokenStr) - if err != nil { - return Claims{}, err - } - return ClaimsFromToken(token), nil -} - -var ( - ErrTokenRevoked = errors.New("token revoked") - ErrWrongAudience = errors.New("token not valid for this API") - ErrWrongUser = errors.New("token issued for a different user") -) - -// CheckClaims gates a session token against the user it names. Callers must have already -// verified the signature; this adds revocation and API scoping on top. -func CheckClaims(c Claims, usr model.User, audience string) error { - // Usernames can be reused: deleting a user and recreating the name yields a new random id - // at epoch 0, which an old token would otherwise match. - if c.UserID != "" && c.UserID != usr.ID { - return ErrWrongUser - } - if c.Epoch != usr.TokenEpoch { - return ErrTokenRevoked - } - if len(c.Audience) > 0 && !slices.Contains(c.Audience, audience) { - return ErrWrongAudience - } - return nil + return token.AsMap(context.Background()) } func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { - u, err := ds.User().FindFirstAdmin(ctx) + u, err := ds.User(ctx).FindFirstAdmin() if err != nil { - c, err := ds.User().CountAll(ctx) + c, err := ds.User(ctx).CountAll() if c == 0 && err == nil { - log.Debug(ctx, "No admin user yet!", err) + log.Debug(ctx, "Scanner: No admin user yet!", err) } else { - log.Error(ctx, "No admin user found!", err) + log.Error(ctx, "Scanner: No admin user found!", err) } - u = &model.User{IsAdmin: true, UserName: "admin"} + u = &model.User{} } ctx = request.WithUsername(ctx, u.UserName) return request.WithUser(ctx, *u) } - -func createNewSecret(ctx context.Context, ds model.DataStore, key string) string { - secret := id.NewRandom() - encSecret, err := utils.Encrypt(ctx, getEncKey(), secret) - if err != nil { - log.Error(ctx, "Could not encrypt JWT secret", err) - return secret - } - if err := ds.Property().Put(ctx, key, encSecret); err != nil { - log.Error(ctx, "Could not save JWT secret in DB", err) - } - return secret -} - -// EncodeToken creates a signed JWT from an arbitrary claims map. -// It sets the issuer claim automatically. -func EncodeToken(claims map[string]any) (string, error) { - claims[jwt.IssuerKey] = consts.JWTIssuer - _, token, err := TokenAuth.Encode(claims) - return token, err -} - -// DecodeAndVerifyToken verifies a JWT string and returns the parsed token. -func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) { - return jwtauth.VerifyToken(TokenAuth, tokenStr) -} - -func getEncKey() []byte { - key := cmp.Or( - conf.Server.PasswordEncryptionKey, - consts.DefaultEncryptionKey, - ) - sum := sha256.Sum256([]byte(key)) - return sum[:] -} diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index c86dcd08c..a1908e128 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -4,12 +4,12 @@ import ( "testing" "time" + "github.com/go-chi/jwtauth/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -21,7 +21,8 @@ func TestAuth(t *testing.T) { } const ( - oneDay = 24 * time.Hour + testJWTSecret = "not so secret" + oneDay = 24 * time.Hour ) var _ = BeforeSuite(func() { @@ -31,10 +32,8 @@ var _ = BeforeSuite(func() { var _ = Describe("Auth", func() { BeforeEach(func() { - ds := &tests.MockDataStore{ - MockedProperty: &tests.MockedPropertyRepo{}, - } - auth.Init(ds) + auth.Secret = []byte(testJWTSecret) + auth.TokenAuth = jwtauth.New("HS256", auth.Secret, nil) }) Describe("Validate", func() { @@ -44,7 +43,7 @@ var _ = Describe("Auth", func() { }) It("returns the claims from a valid JWT token", func() { - claims := map[string]any{} + claims := map[string]interface{}{} claims["iss"] = "issuer" claims["iat"] = time.Now().Unix() claims["exp"] = time.Now().Add(1 * time.Minute).Unix() @@ -53,11 +52,11 @@ var _ = Describe("Auth", func() { decodedClaims, err := auth.Validate(tokenStr) Expect(err).NotTo(HaveOccurred()) - Expect(decodedClaims.Issuer).To(Equal("issuer")) + Expect(decodedClaims["iss"]).To(Equal("issuer")) }) It("returns ErrExpired if the `exp` field is in the past", func() { - claims := map[string]any{} + claims := map[string]interface{}{} claims["iss"] = "issuer" claims["exp"] = time.Now().Add(-1 * time.Minute).Unix() _, tokenStr, err := auth.TokenAuth.Encode(claims) @@ -81,63 +80,18 @@ var _ = Describe("Auth", func() { claims, err := auth.Validate(tokenStr) Expect(err).NotTo(HaveOccurred()) - Expect(claims.Issuer).To(Equal(consts.JWTIssuer)) - Expect(claims.Subject).To(Equal("johndoe")) - Expect(claims.UserID).To(Equal("123")) - Expect(claims.IsAdmin).To(Equal(true)) - Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now())) - }) - }) - - Describe("Session/Public secret split", func() { - claims := func() map[string]any { - return map[string]any{"iss": "issuer", "exp": time.Now().Add(1 * time.Minute).Unix()} - } - - It("verifies a session token via Validate but not ValidatePublic", func() { - _, tokenStr, err := auth.TokenAuth.Encode(claims()) - Expect(err).NotTo(HaveOccurred()) - _, err = auth.Validate(tokenStr) - Expect(err).NotTo(HaveOccurred()) - _, err = auth.ValidatePublic(tokenStr) - Expect(err).To(HaveOccurred()) - }) - - It("verifies a public token via ValidatePublic but not Validate", func() { - _, tokenStr, err := auth.PublicTokenAuth.Encode(claims()) - Expect(err).NotTo(HaveOccurred()) - _, err = auth.ValidatePublic(tokenStr) - Expect(err).NotTo(HaveOccurred()) - _, err = auth.Validate(tokenStr) - Expect(err).To(HaveOccurred()) - }) - - It("decodes public tokens minted by CreatePublicToken via PublicTokenAuth", func() { - tokenStr, err := auth.CreatePublicToken(auth.Claims{ID: "art-1"}) - Expect(err).NotTo(HaveOccurred()) - claims, err := auth.ValidatePublic(tokenStr) - Expect(err).NotTo(HaveOccurred()) - Expect(claims.ID).To(Equal("art-1")) - _, err = auth.Validate(tokenStr) - Expect(err).To(HaveOccurred()) - }) - - It("decodes expiring public tokens minted by CreateExpiringPublicToken via PublicTokenAuth", func() { - exp := time.Now().Add(1 * time.Hour) - tokenStr, err := auth.CreateExpiringPublicToken(exp, auth.Claims{ID: "art-2"}) - Expect(err).NotTo(HaveOccurred()) - claims, err := auth.ValidatePublic(tokenStr) - Expect(err).NotTo(HaveOccurred()) - Expect(claims.ID).To(Equal("art-2")) - _, err = auth.Validate(tokenStr) - Expect(err).To(HaveOccurred()) + Expect(claims["iss"]).To(Equal(consts.JWTIssuer)) + Expect(claims["sub"]).To(Equal("johndoe")) + Expect(claims["uid"]).To(Equal("123")) + Expect(claims["adm"]).To(Equal(true)) + Expect(claims["exp"]).To(BeTemporally(">", time.Now())) }) }) Describe("TouchToken", func() { It("updates the expiration time", func() { yesterday := time.Now().Add(-oneDay) - claims := map[string]any{} + claims := map[string]interface{}{} claims["iss"] = "issuer" claims["exp"] = yesterday.Unix() token, _, err := auth.TokenAuth.Encode(claims) @@ -148,116 +102,8 @@ var _ = Describe("Auth", func() { decodedClaims, err := auth.Validate(touched) Expect(err).NotTo(HaveOccurred()) - Expect(decodedClaims.ExpiresAt.Sub(yesterday)).To(BeNumerically(">=", oneDay)) - }) - }) - - Describe("CreateAPIToken", func() { - var usr *model.User - - BeforeEach(func() { - usr = &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 4} - }) - - It("does not expire", func() { - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - - claims, err := auth.Validate(tokenStr) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.ExpiresAt.IsZero()).To(BeTrue()) - }) - - It("carries the audience and the user's epoch", func() { - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - - claims, err := auth.Validate(tokenStr) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.Audience).To(Equal([]string{"jellyfin"})) - Expect(claims.Epoch).To(Equal(4)) - Expect(claims.Subject).To(Equal("johndoe")) - Expect(claims.UserID).To(Equal("123")) - }) - }) - - Describe("CreateToken with an epoch", func() { - It("carries the epoch and still expires", func() { - usr := &model.User{ID: "123", UserName: "johndoe", TokenEpoch: 9} - tokenStr, err := auth.CreateToken(usr) - Expect(err).ToNot(HaveOccurred()) - - claims, err := auth.Validate(tokenStr) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.Epoch).To(Equal(9)) - Expect(claims.Audience).To(BeEmpty()) - Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now())) - }) - }) - - Describe("TouchClaims", func() { - It("preserves custom claims and refreshes the expiry", func() { - tokenStr, err := auth.TouchClaims(auth.Claims{Subject: "johndoe", UserID: "123", Epoch: 5}) - Expect(err).ToNot(HaveOccurred()) - - claims, err := auth.Validate(tokenStr) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.Epoch).To(Equal(5)) - Expect(claims.Subject).To(Equal("johndoe")) - Expect(claims.ExpiresAt).To(BeTemporally(">", time.Now())) - }) - }) - - Describe("CheckClaims", func() { - usr := model.User{ID: "123", UserName: "johndoe", TokenEpoch: 2} - - It("accepts a matching epoch and audience", func() { - c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}} - Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed()) - }) - - It("accepts a token with no audience on any API", func() { - c := auth.Claims{Epoch: 2} - Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed()) - Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(Succeed()) - Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(Succeed()) - }) - - It("rejects a stale epoch", func() { - c := auth.Claims{Epoch: 1, Audience: []string{auth.AudienceJellyfin}} - Expect(auth.CheckClaims(c, usr, auth.AudienceJellyfin)).To(MatchError(auth.ErrTokenRevoked)) - }) - - It("rejects a token minted for another API", func() { - c := auth.Claims{Epoch: 2, Audience: []string{auth.AudienceJellyfin}} - Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(MatchError(auth.ErrWrongAudience)) - Expect(auth.CheckClaims(c, usr, auth.AudienceSubsonic)).To(MatchError(auth.ErrWrongAudience)) - }) - - It("accepts a multi-audience token that includes this API", func() { - c := auth.Claims{Epoch: 2, Audience: []string{"other", auth.AudienceNative}} - Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed()) - }) - - It("accepts a pre-upgrade token against a never-bumped user", func() { - fresh := model.User{ID: "456", UserName: "newbie"} - Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed()) - }) - - It("accepts a token whose user id matches", func() { - c := auth.Claims{UserID: "123", Epoch: 2} - Expect(auth.CheckClaims(c, usr, auth.AudienceNative)).To(Succeed()) - }) - - It("rejects a token for a deleted user recreated under the same name", func() { - recreated := model.User{ID: "new-random-id", UserName: "johndoe"} - c := auth.Claims{UserID: "123", Audience: []string{auth.AudienceJellyfin}} - Expect(auth.CheckClaims(c, recreated, auth.AudienceJellyfin)).To(MatchError(auth.ErrWrongUser)) - }) - - It("accepts a token that carries no user id", func() { - fresh := model.User{ID: "456", UserName: "newbie"} - Expect(auth.CheckClaims(auth.Claims{}, fresh, auth.AudienceNative)).To(Succeed()) + exp := decodedClaims["exp"].(time.Time) + Expect(exp.Sub(yesterday)).To(BeNumerically(">=", oneDay)) }) }) }) diff --git a/core/auth/claims.go b/core/auth/claims.go deleted file mode 100644 index 42f7e4f2f..000000000 --- a/core/auth/claims.go +++ /dev/null @@ -1,122 +0,0 @@ -package auth - -import ( - "time" - - "github.com/lestrrat-go/jwx/v3/jwt" -) - -// Claims represents the typed JWT claims used throughout Navidrome, -// replacing the untyped map[string]any approach. -type Claims struct { - // Standard JWT claims - Issuer string - Subject string // username for session tokens - Audience []string // which API may accept this token; empty means any - IssuedAt time.Time - ExpiresAt time.Time - - // Custom claims - UserID string // "uid" - IsAdmin bool // "adm" - 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 - Epoch int // "ep" - the user's token_epoch at mint time -} - -// ToMap converts Claims to a map[string]any for use with TokenAuth.Encode(). -// Only non-zero fields are included. -func (c Claims) ToMap() map[string]any { - m := make(map[string]any) - if c.Issuer != "" { - m[jwt.IssuerKey] = c.Issuer - } - if c.Subject != "" { - m[jwt.SubjectKey] = c.Subject - } - if len(c.Audience) > 0 { - m[jwt.AudienceKey] = c.Audience - } - if !c.IssuedAt.IsZero() { - m[jwt.IssuedAtKey] = c.IssuedAt.UTC().Unix() - } - if !c.ExpiresAt.IsZero() { - m[jwt.ExpirationKey] = c.ExpiresAt.UTC().Unix() - } - if c.UserID != "" { - m["uid"] = c.UserID - } - if c.IsAdmin { - m["adm"] = c.IsAdmin - } - if c.ID != "" { - m["id"] = c.ID - } - if c.Format != "" { - m["f"] = c.Format - } - if c.BitRate != 0 { - m["b"] = c.BitRate - } - if c.ShareID != "" { - m["sid"] = c.ShareID - } - if c.Epoch != 0 { - m["ep"] = c.Epoch - } - return m -} - -func (c Claims) WithExpiresAt(t time.Time) Claims { - c.ExpiresAt = t - return c -} - -// ClaimsFromToken extracts Claims directly from a jwt.Token using token.Get(). -func ClaimsFromToken(token jwt.Token) Claims { - var c Claims - c.Issuer, _ = token.Issuer() - c.Subject, _ = token.Subject() - c.IssuedAt, _ = token.IssuedAt() - c.ExpiresAt, _ = token.Expiration() - c.Audience, _ = token.Audience() - - var uid string - if err := token.Get("uid", &uid); err == nil { - c.UserID = uid - } - var adm bool - if err := token.Get("adm", &adm); err == nil { - c.IsAdmin = adm - } - var id string - if err := token.Get("id", &id); err == nil { - c.ID = id - } - var f string - if err := token.Get("f", &f); err == nil { - c.Format = f - } - c.BitRate = intClaim(token, "b") - var sid string - if err := token.Get("sid", &sid); err == nil { - c.ShareID = sid - } - c.Epoch = intClaim(token, "ep") - return c -} - -// intClaim reads a numeric claim, which a parsed token may decode as either int or float64. -func intClaim(token jwt.Token, key string) int { - var i int - if err := token.Get(key, &i); err == nil { - return i - } - var f float64 - if err := token.Get(key, &f); err == nil { - return int(f) - } - return 0 -} diff --git a/core/auth/claims_test.go b/core/auth/claims_test.go deleted file mode 100644 index 69d054031..000000000 --- a/core/auth/claims_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package auth_test - -import ( - "time" - - "github.com/go-chi/jwtauth/v5" - "github.com/navidrome/navidrome/core/auth" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Claims", func() { - Describe("ToMap", func() { - It("includes only non-zero fields", func() { - c := auth.Claims{ - Issuer: "ND", - Subject: "johndoe", - UserID: "123", - IsAdmin: true, - } - m := c.ToMap() - Expect(m).To(HaveKeyWithValue("iss", "ND")) - Expect(m).To(HaveKeyWithValue("sub", "johndoe")) - Expect(m).To(HaveKeyWithValue("uid", "123")) - Expect(m).To(HaveKeyWithValue("adm", true)) - Expect(m).NotTo(HaveKey("exp")) - Expect(m).NotTo(HaveKey("iat")) - 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() { - now := time.Now() - c := auth.Claims{ - IssuedAt: now, - ExpiresAt: now.Add(time.Hour), - } - m := c.ToMap() - Expect(m).To(HaveKey("iat")) - Expect(m).To(HaveKey("exp")) - }) - - It("includes custom claims for public tokens", func() { - c := auth.Claims{ - ID: "al-123", - Format: "mp3", - BitRate: 192, - } - m := c.ToMap() - Expect(m).To(HaveKeyWithValue("id", "al-123")) - 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() { - It("round-trips session claims through encode/decode", func() { - tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) - now := time.Now().Truncate(time.Second) - original := auth.Claims{ - Issuer: "ND", - Subject: "johndoe", - UserID: "123", - IsAdmin: true, - } - m := original.ToMap() - m["iat"] = now.UTC().Unix() - token, _, err := tokenAuth.Encode(m) - Expect(err).NotTo(HaveOccurred()) - - c := auth.ClaimsFromToken(token) - Expect(c.Issuer).To(Equal("ND")) - Expect(c.Subject).To(Equal("johndoe")) - Expect(c.UserID).To(Equal("123")) - Expect(c.IsAdmin).To(BeTrue()) - Expect(c.IssuedAt.UTC()).To(Equal(now.UTC())) - }) - - It("round-trips public token claims through encode/decode", func() { - tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) - original := auth.Claims{ - Issuer: "ND", - ID: "al-456", - Format: "opus", - BitRate: 128, - ShareID: "abc1234567", - } - token, _, err := tokenAuth.Encode(original.ToMap()) - Expect(err).NotTo(HaveOccurred()) - - 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)) - }) - }) - - Describe("Audience and Epoch claims", func() { - It("omits both when zero", func() { - m := auth.Claims{ID: "artwork-id"}.ToMap() - Expect(m).ToNot(HaveKey("aud")) - Expect(m).ToNot(HaveKey("ep")) - }) - - It("includes them when set", func() { - m := auth.Claims{Subject: "u", Epoch: 3, Audience: []string{"jellyfin"}}.ToMap() - Expect(m).To(HaveKeyWithValue("ep", 3)) - Expect(m).To(HaveKeyWithValue("aud", []string{"jellyfin"})) - }) - - It("round-trips through a signed token", func() { - tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) - _, tokenStr, err := tokenAuth.Encode(auth.Claims{ - Subject: "u", Epoch: 7, Audience: []string{"jellyfin"}, - }.ToMap()) - Expect(err).ToNot(HaveOccurred()) - - token, err := jwtauth.VerifyToken(tokenAuth, tokenStr) - Expect(err).ToNot(HaveOccurred()) - claims := auth.ClaimsFromToken(token) - Expect(claims.Epoch).To(Equal(7)) - Expect(claims.Audience).To(Equal([]string{"jellyfin"})) - }) - - It("reads a token that has neither claim", func() { - tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) - _, tokenStr, err := tokenAuth.Encode(auth.Claims{Subject: "u"}.ToMap()) - Expect(err).ToNot(HaveOccurred()) - - token, err := jwtauth.VerifyToken(tokenAuth, tokenStr) - Expect(err).ToNot(HaveOccurred()) - claims := auth.ClaimsFromToken(token) - Expect(claims.Epoch).To(BeZero()) - Expect(claims.Audience).To(BeEmpty()) - }) - }) - -}) diff --git a/core/common.go b/core/common.go index db2dbbf1b..0619772d6 100644 --- a/core/common.go +++ b/core/common.go @@ -2,9 +2,7 @@ package core import ( "context" - "path/filepath" - "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" ) @@ -15,13 +13,3 @@ func userName(ctx context.Context) string { return user.UserName } } - -// BFR We should only access files through the `storage.Storage` interface. This will require changing how -// TagLib and ffmpeg access files -var AbsolutePath = func(ctx context.Context, ds model.DataStore, libId int, path string) string { - libPath, err := ds.Library().GetPath(ctx, libId) - if err != nil { - return path - } - return filepath.Join(libPath, path) -} diff --git a/core/common_test.go b/core/common_test.go deleted file mode 100644 index f57ea8d38..000000000 --- a/core/common_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package core - -import ( - "context" - "path/filepath" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" -) - -var _ = Describe("common.go", func() { - Describe("userName", func() { - It("returns the username from context", func() { - ctx := request.WithUser(context.Background(), model.User{UserName: "testuser"}) - Expect(userName(ctx)).To(Equal("testuser")) - }) - - It("returns 'UNKNOWN' if no user in context", func() { - ctx := context.Background() - Expect(userName(ctx)).To(Equal("UNKNOWN")) - }) - }) - - Describe("AbsolutePath", func() { - var ( - ds *tests.MockDataStore - libId int - path string - ) - - BeforeEach(func() { - ds = &tests.MockDataStore{} - libId = 1 - path = "music/file.mp3" - mockLib := &tests.MockLibraryRepo{} - mockLib.SetData(model.Libraries{{ID: libId, Path: "/library/root"}}) - ds.MockedLibrary = mockLib - }) - - It("returns the absolute path when library exists", func() { - ctx := context.Background() - abs := AbsolutePath(ctx, ds, libId, path) - Expect(abs).To(Equal(filepath.FromSlash("/library/root/music/file.mp3"))) - }) - - It("returns the original path if library not found", func() { - ctx := context.Background() - abs := AbsolutePath(ctx, ds, 999, path) - Expect(abs).To(Equal(path)) - }) - }) -}) diff --git a/core/external/extdata_helper_test.go b/core/external/extdata_helper_test.go deleted file mode 100644 index 73d88e5b4..000000000 --- a/core/external/extdata_helper_test.go +++ /dev/null @@ -1,331 +0,0 @@ -package external_test - -import ( - "context" - "errors" - - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "github.com/stretchr/testify/mock" -) - -// --- Shared Mock Implementations --- - -// mockArtistRepo mocks model.ArtistRepository -type mockArtistRepo struct { - mock.Mock - model.ArtistRepository -} - -func newMockArtistRepo() *mockArtistRepo { - return &mockArtistRepo{} -} - -// SetData sets up basic Get expectations. -func (m *mockArtistRepo) SetData(artists model.Artists) { - for _, a := range artists { - artistCopy := a - m.On("Get", artistCopy.ID).Return(&artistCopy, nil) - } -} - -// Get implements model.ArtistRepository. -func (m *mockArtistRepo) Get(_ context.Context, id string) (*model.Artist, error) { - args := m.Called(id) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*model.Artist), args.Error(1) -} - -// GetAll implements model.ArtistRepository. -func (m *mockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Artists, 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.Artists), args.Error(1) -} - -// SetError is a helper to set up a generic error for GetAll. -func (m *mockArtistRepo) SetError(hasError bool) { - if hasError { - m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error")) - } -} - -// FindByName is a helper to set up a GetAll expectation for finding by name. -func (m *mockArtistRepo) FindByName(name string, artist model.Artist) { - m.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Filters != nil - })).Return(model.Artists{artist}, nil).Once() -} - -// mockMediaFileRepo mocks model.MediaFileRepository -type mockMediaFileRepo struct { - mock.Mock - model.MediaFileRepository -} - -func newMockMediaFileRepo() *mockMediaFileRepo { - return &mockMediaFileRepo{} -} - -// SetData sets up basic Get expectations. -func (m *mockMediaFileRepo) SetData(mediaFiles model.MediaFiles) { - for _, mf := range mediaFiles { - mfCopy := mf - m.On("Get", mfCopy.ID).Return(&mfCopy, nil) - } -} - -// Get implements model.MediaFileRepository. -func (m *mockMediaFileRepo) Get(ctx context.Context, id string) (*model.MediaFile, error) { - args := m.Called(id) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*model.MediaFile), args.Error(1) -} - -// GetAllByTags implements model.MediaFileRepository. -func (m *mockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { - return m.GetAll(ctx, options...) -} - -// GetAll implements model.MediaFileRepository. -func (m *mockMediaFileRepo) GetAll(ctx context.Context, 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) -} - -// GetRandom implements model.MediaFileRepository. -func (m *mockMediaFileRepo) GetRandom(ctx context.Context, 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) -} - -// SetError is a helper to set up a generic error for GetAll. -func (m *mockMediaFileRepo) SetError(hasError bool) { - if hasError { - m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error")) - } -} - -// FindByMBID is a helper to set up a GetAll expectation for finding by MBID. -func (m *mockMediaFileRepo) FindByMBID(mbid string, mediaFile model.MediaFile) { - m.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Filters != nil - })).Return(model.MediaFiles{mediaFile}, nil).Once() -} - -// FindByArtistAndTitle is a helper to set up a GetAll expectation for finding by artist/title. -func (m *mockMediaFileRepo) FindByArtistAndTitle(artistID string, title string, mediaFile model.MediaFile) { - m.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Filters != nil - })).Return(model.MediaFiles{mediaFile}, nil).Once() -} - -// mockAlbumRepo mocks model.AlbumRepository -type mockAlbumRepo struct { - mock.Mock - model.AlbumRepository -} - -func newMockAlbumRepo() *mockAlbumRepo { - return &mockAlbumRepo{} -} - -// Get implements model.AlbumRepository. -func (m *mockAlbumRepo) Get(_ context.Context, id string) (*model.Album, error) { - args := m.Called(id) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*model.Album), args.Error(1) -} - -// GetAll implements model.AlbumRepository. -func (m *mockAlbumRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Albums, 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.Albums), args.Error(1) -} - -// mockSimilarArtistAgent mocks agents implementing ArtistTopSongsRetriever and ArtistSimilarRetriever -type mockSimilarArtistAgent struct { - mock.Mock - agents.Interface // Embed to satisfy methods not explicitly mocked -} - -func (m *mockSimilarArtistAgent) AgentName() string { - return "mockSimilar" -} - -func (m *mockSimilarArtistAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) { - args := m.Called(ctx, id, artistName, mbid, count) - if args.Get(0) != nil { - return args.Get(0).([]agents.Song), args.Error(1) - } - return nil, args.Error(1) -} - -func (m *mockSimilarArtistAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) { - args := m.Called(ctx, id, name, mbid, limit) - if args.Get(0) != nil { - return args.Get(0).([]agents.Artist), args.Error(1) - } - return nil, args.Error(1) -} - -// mockAgents mocks the main Agents interface used by Provider -type mockAgents struct { - mock.Mock // Embed testify mock - topSongsAgent agents.ArtistTopSongsRetriever - similarAgent agents.ArtistSimilarRetriever - imageAgent agents.ArtistImageRetriever - albumInfoAgent interface { - agents.AlbumInfoRetriever - agents.AlbumImageRetriever - } - bioAgent agents.ArtistBiographyRetriever - mbidAgent agents.ArtistMBIDRetriever - urlAgent agents.ArtistURLRetriever - agents.Interface -} - -func (m *mockAgents) AgentName() string { - return "mockCombined" -} - -func (m *mockAgents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) { - if m.similarAgent != nil { - return m.similarAgent.GetSimilarArtists(ctx, id, name, mbid, limit) - } - args := m.Called(ctx, id, name, mbid, limit) - if args.Get(0) != nil { - return args.Get(0).([]agents.Artist), args.Error(1) - } - return nil, args.Error(1) -} - -func (m *mockAgents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) { - if m.topSongsAgent != nil { - return m.topSongsAgent.GetArtistTopSongs(ctx, id, artistName, mbid, count) - } - args := m.Called(ctx, id, artistName, mbid, count) - if args.Get(0) != nil { - return args.Get(0).([]agents.Song), args.Error(1) - } - return nil, args.Error(1) -} - -func (m *mockAgents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) { - if m.albumInfoAgent != nil { - return m.albumInfoAgent.GetAlbumInfo(ctx, name, artist, mbid) - } - args := m.Called(ctx, name, artist, mbid) - if args.Get(0) != nil { - return args.Get(0).(*agents.AlbumInfo), args.Error(1) - } - return nil, args.Error(1) -} - -func (m *mockAgents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { - if m.mbidAgent != nil { - return m.mbidAgent.GetArtistMBID(ctx, id, name) - } - args := m.Called(ctx, id, name) - return args.String(0), args.Error(1) -} - -func (m *mockAgents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) { - if m.urlAgent != nil { - return m.urlAgent.GetArtistURL(ctx, id, name, mbid) - } - args := m.Called(ctx, id, name, mbid) - return args.String(0), args.Error(1) -} - -func (m *mockAgents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) { - if m.bioAgent != nil { - return m.bioAgent.GetArtistBiography(ctx, id, name, mbid) - } - args := m.Called(ctx, id, name, mbid) - return args.String(0), args.Error(1) -} - -func (m *mockAgents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) { - if m.imageAgent != nil { - return m.imageAgent.GetArtistImages(ctx, id, name, mbid) - } - args := m.Called(ctx, id, name, mbid) - if args.Get(0) != nil { - return args.Get(0).([]agents.ExternalImage), args.Error(1) - } - return nil, args.Error(1) -} - -func (m *mockAgents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { - if m.albumInfoAgent != nil { - return m.albumInfoAgent.GetAlbumImages(ctx, name, artist, mbid) - } - args := m.Called(ctx, name, artist, mbid) - if args.Get(0) != nil { - return args.Get(0).([]agents.ExternalImage), args.Error(1) - } - return nil, args.Error(1) -} - -func (m *mockAgents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) { - args := m.Called(ctx, id, name, artist, mbid, count) - if args.Get(0) != nil { - return args.Get(0).([]agents.Song), args.Error(1) - } - return nil, args.Error(1) -} - -func (m *mockAgents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) { - args := m.Called(ctx, id, name, artist, mbid, count) - if args.Get(0) != nil { - return args.Get(0).([]agents.Song), args.Error(1) - } - return nil, args.Error(1) -} - -func (m *mockAgents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]agents.Song, error) { - args := m.Called(ctx, id, name, mbid, count) - if args.Get(0) != nil { - return args.Get(0).([]agents.Song), args.Error(1) - } - return nil, args.Error(1) -} - -func ids(mfs model.MediaFiles) []string { - return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) -} diff --git a/core/external/extdata_suite_test.go b/core/external/extdata_suite_test.go deleted file mode 100644 index f059e76b3..000000000 --- a/core/external/extdata_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package external - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestExternal(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "External Suite") -} diff --git a/core/external/provider.go b/core/external/provider.go deleted file mode 100644 index 185725259..000000000 --- a/core/external/provider.go +++ /dev/null @@ -1,731 +0,0 @@ -package external - -import ( - "context" - "errors" - "fmt" - "slices" - "sort" - "strings" - "time" - - "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/server/events" - "github.com/navidrome/navidrome/utils" - . "github.com/navidrome/navidrome/utils/gg" - "github.com/navidrome/navidrome/utils/slice" - "github.com/navidrome/navidrome/utils/str" - "golang.org/x/sync/errgroup" -) - -const ( - maxSimilarArtists = 100 - refreshDelay = 5 * time.Second - refreshTimeout = 15 * time.Second - refreshQueueLength = 2000 -) - -type Provider interface { - UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) - UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) - SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) - TopSongs(ctx context.Context, artist, artistId string, count int) (model.MediaFiles, error) - RefreshInfo(ctx context.Context, kind model.Kind, id string) error -} - -type provider struct { - ds model.DataStore - ag Agents - matcher *matcher.Matcher - broker events.Broker - artistQueue refreshQueue[auxArtist] - albumQueue refreshQueue[auxAlbum] -} - -type auxAlbum struct { - model.Album -} - -// Name returns the appropriate album name for external API calls -// based on the DevPreserveUnicodeInExternalCalls configuration option -func (a *auxAlbum) Name() string { - if conf.Server.DevPreserveUnicodeInExternalCalls { - return a.Album.Name - } - return str.Clear(a.Album.Name) -} - -type auxArtist struct { - model.Artist -} - -// Name returns the appropriate artist name for external API calls -// based on the DevPreserveUnicodeInExternalCalls configuration option -func (a *auxArtist) Name() string { - if conf.Server.DevPreserveUnicodeInExternalCalls { - return a.Artist.Name - } - return str.Clear(a.Artist.Name) -} - -type Agents interface { - agents.AlbumInfoRetriever - agents.AlbumImageRetriever - agents.ArtistBiographyRetriever - agents.ArtistMBIDRetriever - agents.ArtistImageRetriever - agents.ArtistSimilarRetriever - agents.ArtistTopSongsRetriever - agents.ArtistURLRetriever - agents.SimilarSongsByTrackRetriever - agents.SimilarSongsByAlbumRetriever - agents.SimilarSongsByArtistRetriever -} - -func NewProvider(ds model.DataStore, agents Agents, m *matcher.Matcher, broker events.Broker) Provider { - e := &provider{ds: ds, ag: agents, matcher: m, broker: broker} - e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo) - e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo) - return e -} - -func (e *provider) broadcastRefresh(ctx context.Context, resource, id string) { - e.broker.SendBroadcastMessage(ctx, (&events.RefreshResource{}).With(resource, id)) -} - -func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) { - var entity any - entity, err := model.GetEntityByID(ctx, e.ds, id) - if err != nil { - return auxAlbum{}, err - } - - var album auxAlbum - switch v := entity.(type) { - case *model.Album: - album.Album = *v - case *model.MediaFile: - return e.getAlbum(ctx, v.AlbumID) - default: - return auxAlbum{}, model.ErrNotFound - } - - return album, nil -} - -func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) { - album, err := e.getAlbum(ctx, id) - if err != nil { - log.Info(ctx, "Not found", "id", id) - return nil, err - } - - updatedAt := V(album.ExternalInfoUpdatedAt) - albumName := album.Name() - if updatedAt.IsZero() { - log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", albumName) - album, err = e.populateAlbumInfo(ctx, album) - if err != nil { - return nil, err - } - } - - // If info is expired, trigger a populateAlbumInfo in the background - if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive { - log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", albumName) - e.albumQueue.enqueue(&album) - } - - return &album.Album, nil -} - -func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) { - start := time.Now() - albumName := album.Name() - info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID) - // Throttled joins not-found: no answer to store, and an unstamped timestamp retries next call. - if errors.Is(err, agents.ErrNotFound) || errors.Is(err, agents.ErrRetryLater) { - return album, nil - } - if err != nil { - log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", albumName, "artist", album.AlbumArtist, - "elapsed", time.Since(start), err) - return album, err - } - - album.ExternalInfoUpdatedAt = new(time.Now()) - album.ExternalUrl = info.URL - - if info.Description != "" { - album.Description = info.Description - } - - images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID) - if err == nil && len(images) > 0 { - sort.Slice(images, func(i, j int) bool { - return images[i].Size > images[j].Size - }) - - album.LargeImageUrl = images[0].URL - - if len(images) >= 2 { - album.MediumImageUrl = images[1].URL - } - - if len(images) >= 3 { - album.SmallImageUrl = images[2].URL - } - } - - err = e.ds.Album().UpdateExternalInfo(ctx, &album.Album) - if err != nil { - log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName, - "elapsed", time.Since(start), err) - } else { - log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start)) - e.broadcastRefresh(ctx, "album", album.ID) - } - - return album, nil -} - -func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error) { - var entity any - entity, err := model.GetEntityByID(ctx, e.ds, id) - if err != nil { - return auxArtist{}, err - } - - var artist auxArtist - switch v := entity.(type) { - case *model.Artist: - artist.Artist = *v - case *model.MediaFile: - return e.getArtist(ctx, v.ArtistID) - case *model.Album: - return e.getArtist(ctx, v.AlbumArtistID) - default: - return auxArtist{}, model.ErrNotFound - } - return artist, nil -} - -func (e *provider) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) { - artist, err := e.refreshArtistInfo(ctx, id) - if err != nil { - return nil, err - } - - err = e.loadSimilar(ctx, &artist, similarCount, includeNotPresent) - return &artist.Artist, err -} - -func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist, error) { - artist, err := e.getArtist(ctx, id) - if err != nil { - return auxArtist{}, err - } - - // If we don't have any info, retrieves it now - updatedAt := V(artist.ExternalInfoUpdatedAt) - artistName := artist.Name() - if updatedAt.IsZero() { - log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artistName) - artist, err = e.populateArtistInfo(ctx, artist) - if err != nil { - return auxArtist{}, err - } - } - - // If info is expired, trigger a populateArtistInfo in the background - if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive { - log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artistName) - e.artistQueue.enqueue(&artist) - } - return artist, nil -} - -func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) { - start := time.Now() - // Get MBID first, if it is not yet available - artistName := artist.Name() - var mbidErr error - if artist.MbzArtistID == "" { - mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName) - mbidErr = err - if mbid != "" && err == nil { - artist.MbzArtistID = mbid - } - } - - // Call all registered agents and collect information. The group carries no context, so a - // returned error does not cancel the siblings; only throttling is reported back. - g := errgroup.Group{} - g.SetLimit(2) - g.Go(func() error { return retryLaterOnly(e.callGetImage(ctx, e.ag, &artist)) }) - g.Go(func() error { return retryLaterOnly(e.callGetBiography(ctx, e.ag, &artist)) }) - g.Go(func() error { return retryLaterOnly(e.callGetURL(ctx, e.ag, &artist)) }) - g.Go(func() error { - return retryLaterOnly(e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true)) - }) - throttled := errors.Is(g.Wait(), agents.ErrRetryLater) || errors.Is(mbidErr, agents.ErrRetryLater) - - if utils.IsCtxDone(ctx) { - log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err()) - return artist, ctx.Err() - } - - // A throttled round keeps the previous timestamp, so the next call retries instead of - // serving an empty cache entry for the whole TTL. - if !throttled { - artist.ExternalInfoUpdatedAt = new(time.Now()) - } - err := e.ds.Artist().UpdateExternalInfo(ctx, &artist.Artist) - if err != nil { - log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName, - "elapsed", time.Since(start), err) - } else { - log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start)) - e.broadcastRefresh(ctx, "artist", artist.ID) - } - return artist, nil -} - -// infoKinds are the kinds RefreshInfo can act on. Callers check this instead of restating -// the set, so the switch below stays the only place that has to know how each kind loads. -var infoKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork} - -// HasInfo reports whether a kind has external info to refresh. -func HasInfo(kind model.Kind) bool { return slices.Contains(infoKinds, kind) } - -// RefreshInfo re-fetches external info for one item, ignoring the TTL. It is synchronous: -// callers that must not block are responsible for detaching it. -func (e *provider) RefreshInfo(ctx context.Context, kind model.Kind, id string) error { - ctx, cancel := context.WithTimeout(ctx, refreshTimeout) - defer cancel() - - switch kind { - case model.KindArtistArtwork: - artist, err := e.getArtist(ctx, id) - if err != nil { - return err - } - _, err = e.populateArtistInfo(ctx, artist) - return err - case model.KindAlbumArtwork: - album, err := e.getAlbum(ctx, id) - if err != nil { - return err - } - _, err = e.populateAlbumInfo(ctx, album) - return err - default: - return model.ErrNotFound - } -} - -func (e *provider) TopSongs(ctx context.Context, artistName, id string, count int) (model.MediaFiles, error) { - artist, err := e.findArtist(ctx, artistName, id) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - log.Error(ctx, "Artist not found", "name", artistName, "id", id, err) - return nil, nil - } - - log.Error(ctx, "Failure occurred when trying to fetch artist", "name", artistName, "id", id, err) - return nil, err - } - - songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count) - if err != nil { - switch { - // Throttled is not an answer, but the caller keeps the empty 200 it got before. - case errors.Is(err, agents.ErrNotFound), errors.Is(err, agents.ErrRetryLater): - log.Trace(ctx, "TopSongs not found", "name", artistName, err) - return nil, model.ErrNotFound - case errors.Is(err, context.Canceled): - log.Debug(ctx, "TopSongs call canceled", err) - default: - log.Warn(ctx, "Error getting top songs from agent", "artist", artistName, err) - } - - return nil, err - } - return songs, nil -} - -func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) { - artistName := artist.Name() - songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artistName, artist.MbzArtistID, count) - if err != nil { - return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err) - } - - // Enrich top songs with the queried artist. A song with no artists, or whose first credit the - // agent left unnamed, is attributed to the queried artist. A first credit that already names an - // artist is left as-is: it may be a different (e.g. featured) artist, so stamping the queried - // MBID onto it would create a false name+MBID pairing. - for i := range songs { - switch { - case len(songs[i].Artists) == 0: - songs[i].Artists = []agents.Artist{{Name: artistName, MBID: artist.MbzArtistID}} - case songs[i].Artists[0].Name == "": - songs[i].Artists[0].Name = artistName - if songs[i].Artists[0].MBID == "" { - songs[i].Artists[0].MBID = artist.MbzArtistID - } - } - } - - mfs, err := e.matcher.MatchSongs(ctx, songs, count) - if err != nil { - return nil, err - } - - if len(mfs) == 0 { - log.Debug(ctx, "No matching top songs found", "name", artistName) - } else { - log.Debug(ctx, "Found matching top songs", "name", artistName, "numSongs", len(mfs)) - } - - return mfs, nil -} - -// retryLaterOnly discards every failure the caller does not act on, so errgroup's -// first-error slot is reserved for the throttling signal. -func retryLaterOnly(err error) error { - if errors.Is(err, agents.ErrRetryLater) { - return err - } - return nil -} - -func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) error { - artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID) - if err != nil { - return err - } - artist.ExternalUrl = artisURL - return nil -} - -func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) error { - bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID) - if err != nil { - return err - } - bio = str.SanitizeText(bio) - bio = strings.ReplaceAll(bio, "\n", " ") - artist.Biography = strings.ReplaceAll(bio, " images[j].Size }) - - if len(images) >= 1 { - artist.LargeImageUrl = images[0].URL - } - if len(images) >= 2 { - artist.MediumImageUrl = images[1].URL - } - if len(images) >= 3 { - artist.SmallImageUrl = images[2].URL - } - return nil -} - -func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist, - limit int, includeNotPresent bool) error { - artistName := artist.Name() - similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit) - if len(similar) == 0 || err != nil { - return err - } - start := time.Now() - sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent) - log.Debug(ctx, "Mapped Similar Artists", "artist", artistName, "numSimilar", len(sa), "elapsed", time.Since(start)) - if err != nil { - return err - } - artist.SimilarArtists = sa - return nil -} - -func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, limit int, includeNotPresent bool) (model.Artists, error) { - var result model.Artists - var notPresent []string - - // Load artists by ID (highest priority) - idMatches, err := e.loadArtistsByID(ctx, similar) - if err != nil { - return nil, err - } - - // Load artists by MBID (second priority) - mbidMatches, err := e.loadArtistsByMBID(ctx, similar, idMatches) - if err != nil { - return nil, err - } - - // Load artists by name (lowest priority, fallback) - nameMatches, err := e.loadArtistsByName(ctx, similar, idMatches, mbidMatches) - if err != nil { - return nil, err - } - - count := 0 - - // Process the similar artists using priority: ID → MBID → Name - for _, s := range similar { - if count >= limit { - break - } - // Try ID match first - if s.ID != "" { - if artist, found := idMatches[s.ID]; found { - result = append(result, artist) - count++ - continue - } - } - // Try MBID match second - if s.MBID != "" { - if artist, found := mbidMatches[s.MBID]; found { - result = append(result, artist) - count++ - continue - } - } - // Fall back to name match - if artist, found := nameMatches[s.Name]; found { - result = append(result, artist) - count++ - } else { - notPresent = append(notPresent, s.Name) - } - } - - // Then fill up with non-present artists - if includeNotPresent && count < limit { - for _, s := range notPresent { - // Let the ID empty to indicate that the artist is not present in the DB - sa := model.Artist{Name: s} - result = append(result, sa) - - count++ - if count >= limit { - break - } - } - } - - return result, nil -} - -func (e *provider) loadArtistsByID(ctx context.Context, similar []agents.Artist) (map[string]model.Artist, error) { - var ids []string - for _, s := range similar { - if s.ID != "" { - ids = append(ids, s.ID) - } - } - matches := map[string]model.Artist{} - if len(ids) == 0 { - return matches, nil - } - res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"artist.id": ids}, - }) - if err != nil { - return matches, err - } - for _, a := range res { - if _, ok := matches[a.ID]; !ok { - matches[a.ID] = a - } - } - return matches, nil -} - -func (e *provider) loadArtistsByMBID(ctx context.Context, similar []agents.Artist, idMatches map[string]model.Artist) (map[string]model.Artist, error) { - var mbids []string - for _, s := range similar { - // Skip if already matched by ID - if s.ID != "" && idMatches[s.ID].ID != "" { - continue - } - if s.MBID != "" { - mbids = append(mbids, s.MBID) - } - } - matches := map[string]model.Artist{} - if len(mbids) == 0 { - return matches, nil - } - res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"mbz_artist_id": mbids}, - }) - if err != nil { - return matches, err - } - for _, a := range res { - if id := a.MbzArtistID; id != "" { - if _, ok := matches[id]; !ok { - matches[id] = a - } - } - } - return matches, nil -} - -func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artist, idMatches map[string]model.Artist, mbidMatches map[string]model.Artist) (map[string]model.Artist, error) { - var names []string - for _, s := range similar { - // Skip if already matched by ID or MBID - if s.ID != "" && idMatches[s.ID].ID != "" { - continue - } - if s.MBID != "" && mbidMatches[s.MBID].ID != "" { - continue - } - names = append(names, s.Name) - } - matches := map[string]model.Artist{} - if len(names) == 0 { - return matches, nil - } - clauses := slice.Map(names, func(name string) squirrel.Sqlizer { - return squirrel.Like{"artist.name": name} - }) - res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Or(clauses), - }) - if err != nil { - return matches, err - } - for _, a := range res { - if _, ok := matches[a.Name]; !ok { - matches[a.Name] = a - } - } - return matches, nil -} - -func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxArtist, error) { - if id != "" { - artist, err := e.ds.Artist().Get(ctx, id) - if err == nil { - return &auxArtist{Artist: *artist}, nil - } - - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Could not find artist by id", "id", id, err) - } else { - return nil, err - } - } - - if artistName == "" { - return nil, model.ErrNotFound - } - - artists, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Like{"artist.name": artistName}, - Max: 1, - }) - if err != nil { - return nil, err - } - if len(artists) == 0 { - return nil, model.ErrNotFound - } - return &auxArtist{Artist: artists[0]}, nil -} - -func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error { - var ids []string - for _, sa := range artist.SimilarArtists { - if sa.ID == "" { - continue - } - ids = append(ids, sa.ID) - } - - similar, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"artist.id": ids}, - }) - if err != nil { - log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name(), err) - return err - } - - // Use a map and iterate through original array, to keep the same order - artistMap := make(map[string]model.Artist) - for _, sa := range similar { - artistMap[sa.ID] = sa - } - - var loaded model.Artists - for _, sa := range artist.SimilarArtists { - if len(loaded) >= count { - break - } - la, ok := artistMap[sa.ID] - if !ok { - if !includeNotPresent { - continue - } - la = sa - la.ID = "" - } - loaded = append(loaded, la) - } - artist.SimilarArtists = loaded - return nil -} - -type refreshQueue[T any] chan<- *T - -func newRefreshQueue[T any](ctx context.Context, processFn func(context.Context, T) (T, error)) refreshQueue[T] { - queue := make(chan *T, refreshQueueLength) - go func() { - for { - select { - case <-ctx.Done(): - return - case <-time.After(refreshDelay): - ctx, cancel := context.WithTimeout(ctx, refreshTimeout) - select { - case item := <-queue: - _, _ = processFn(ctx, *item) - cancel() - case <-ctx.Done(): - cancel() - } - } - } - }() - return queue -} - -func (q *refreshQueue[T]) enqueue(item *T) { - select { - case *q <- item: - default: // It is ok to miss a refresh request - } -} diff --git a/core/external/provider_refreshinfo_test.go b/core/external/provider_refreshinfo_test.go deleted file mode 100644 index a1216dd94..000000000 --- a/core/external/provider_refreshinfo_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package external_test - -import ( - "context" - "slices" - "sync" - "time" - - "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/server/events" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -type fakeBroker struct { - events.Broker - mu sync.Mutex - events []events.Event -} - -func (f *fakeBroker) SendBroadcastMessage(_ context.Context, e events.Event) { - f.mu.Lock() - defer f.mu.Unlock() - f.events = append(f.events, e) -} - -func (f *fakeBroker) sent() []events.Event { - f.mu.Lock() - defer f.mu.Unlock() - return slices.Clone(f.events) -} - -var _ = Describe("Provider - RefreshInfo", func() { - var ( - ctx context.Context - p external.Provider - ds *tests.MockDataStore - ag *mockAgents - broker *fakeBroker - mockArtistRepo *tests.MockArtistRepo - mockAlbumRepo *tests.MockAlbumRepo - ) - - expectArtistAgents := func() { - ag.On("GetArtistMBID", mock.Anything, mock.Anything, mock.Anything).Return("mbid-1", nil) - ag.On("GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.ExternalImage{}, nil) - ag.On("GetArtistBiography", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return("Fresh Bio", nil) - ag.On("GetArtistURL", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return("http://artist.url", nil) - ag.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Artist{}, nil) - } - - expectAlbumAgents := func() { - ag.On("GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(&agents.AlbumInfo{URL: "http://album.url", Description: "Fresh Notes"}, nil) - ag.On("GetAlbumImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.ExternalImage{}, nil) - } - - BeforeEach(func() { - ctx = GinkgoT().Context() - ds = new(tests.MockDataStore) - ag = new(mockAgents) - broker = &fakeBroker{} - p = external.NewProvider(ds, ag, matcher.New(ds), broker) - mockArtistRepo = ds.Artist().(*tests.MockArtistRepo) - mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo) - }) - - It("repopulates an artist even when its info is fresh", func() { - fresh := time.Now() - mockArtistRepo.SetData(model.Artists{{ - ID: "ar-1", Name: "Test Artist", Biography: "stale", ExternalInfoUpdatedAt: &fresh, - }}) - expectArtistAgents() - - Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed()) - - saved, err := mockArtistRepo.Get(ctx, "ar-1") - Expect(err).ToNot(HaveOccurred()) - Expect(saved.Biography).To(Equal("Fresh Bio")) - }) - - It("repopulates an album even when its info is fresh", func() { - fresh := time.Now() - mockAlbumRepo.SetData(model.Albums{{ - ID: "al-1", Name: "Test Album", AlbumArtist: "Test Artist", - Description: "stale", ExternalInfoUpdatedAt: &fresh, - }}) - expectAlbumAgents() - - Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed()) - - saved, err := mockAlbumRepo.Get(ctx, "al-1") - Expect(err).ToNot(HaveOccurred()) - Expect(saved.Description).To(Equal("Fresh Notes")) - }) - - It("returns ErrNotFound for an unknown id", func() { - Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "nope")).To(MatchError(model.ErrNotFound)) - }) - - It("returns ErrNotFound for a kind with no external info", func() { - Expect(p.RefreshInfo(ctx, model.KindPlaylistArtwork, "pl-1")).To(MatchError(model.ErrNotFound)) - }) - - It("broadcasts a RefreshResource naming the artist", func() { - mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Test Artist"}}) - expectArtistAgents() - - Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed()) - - sent := broker.sent() - Expect(sent).To(HaveLen(1)) - rr, ok := sent[0].(*events.RefreshResource) - Expect(ok).To(BeTrue()) - Expect(rr.Data(rr)).To(ContainSubstring("ar-1")) - Expect(rr.Data(rr)).To(ContainSubstring("artist")) - }) - - It("broadcasts a RefreshResource naming the album", func() { - mockAlbumRepo.SetData(model.Albums{{ID: "al-1", Name: "Test Album", AlbumArtist: "Test Artist"}}) - expectAlbumAgents() - - Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed()) - - sent := broker.sent() - Expect(sent).To(HaveLen(1)) - Expect(sent[0].Data(sent[0])).To(ContainSubstring("album")) - Expect(sent[0].Data(sent[0])).To(ContainSubstring("al-1")) - }) - - It("does not broadcast when the artist cannot be loaded", func() { - mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Test Artist"}}) - expectArtistAgents() - mockArtistRepo.SetError(true) - - _ = p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1") - - Expect(broker.sent()).To(BeEmpty()) - }) - - It("reports which kinds have external info", func() { - Expect(external.HasInfo(model.KindArtistArtwork)).To(BeTrue()) - Expect(external.HasInfo(model.KindAlbumArtwork)).To(BeTrue()) - Expect(external.HasInfo(model.KindPlaylistArtwork)).To(BeFalse()) - }) -}) diff --git a/core/external/provider_similarsongs.go b/core/external/provider_similarsongs.go deleted file mode 100644 index 305b8d6ec..000000000 --- a/core/external/provider_similarsongs.go +++ /dev/null @@ -1,300 +0,0 @@ -package external - -import ( - "context" - "errors" - "fmt" - "math/rand/v2" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/random" - "github.com/navidrome/navidrome/utils/slice" - "golang.org/x/sync/errgroup" -) - -const ( - maxSeeds = 5 - // Subsonic passes the client's count through unbounded, and it ends up as a SQL limit. 500 is - // what the widest caller (similarAlbums, limit*5) legitimately asks for. - maxSimilarSongs = 500 -) - -func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) { - // Subsonic passes the client's count straight through: a non-positive one has no valid - // interpretation, and an enormous one overflows the +1 in the local agent's query limit. - if count <= 0 { - return nil, nil - } - count = min(count, maxSimilarSongs) - entity, err := model.GetEntityByID(ctx, e.ds, id) - if err != nil { - // Genre ids don't resolve via GetEntityByID; look them up before giving up. - if !errors.Is(err, model.ErrNotFound) { - return nil, err - } - genre, err := e.ds.Genre().Get(ctx, id) - if err != nil { - return nil, err - } - return e.seedMix(ctx, count, func() (model.MediaFiles, error) { - return e.sampleGenreTracks(ctx, genre, maxSeeds) - }) - } - - // Try entity-specific similarity first, then fall back to seed-track sampling. - switch v := entity.(type) { - case *model.MediaFile: - return e.mixFromAgent(ctx, count, - func() ([]agents.Song, error) { - return e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count) - }, - func() (model.MediaFiles, error) { - return e.similarSongsFallback(ctx, id, count) - }) - case *model.Album: - return e.mixFromAgent(ctx, count, - func() ([]agents.Song, error) { - return e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count) - }, - func() (model.MediaFiles, error) { - return e.seedMix(ctx, count, func() (model.MediaFiles, error) { - return e.sampleAlbumTracks(ctx, v.ID, maxSeeds) - }) - }) - case *model.Artist: - return e.mixFromAgent(ctx, count, - func() ([]agents.Song, error) { - return e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count) - }, - func() (model.MediaFiles, error) { - return e.similarSongsFallback(ctx, id, count) - }, - func() (model.MediaFiles, error) { - return e.seedMix(ctx, count, func() (model.MediaFiles, error) { - return e.sampleArtistTracks(ctx, v.ID, maxSeeds) - }) - }) - case *model.Playlist: - return e.seedMix(ctx, count, func() (model.MediaFiles, error) { - return e.samplePlaylistTracks(ctx, v.ID, maxSeeds) - }) - default: - log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity)) - return nil, model.ErrNotFound - } -} - -// mixFromAgent returns the agent's recommendations matched to library tracks, topped up from the -// fallbacks in order when they don't fill the mix on their own. -func (e *provider) mixFromAgent(ctx context.Context, count int, fetch func() ([]agents.Song, error), fallbacks ...func() (model.MediaFiles, error)) (model.MediaFiles, error) { - var matched model.MediaFiles - if songs, err := fetch(); err == nil { - // Match the whole response: capping at count can stop before a later unique pick. - matched, err = e.matcher.MatchSongs(ctx, songs, len(songs)) - if err != nil { - return nil, err - } - } - return topUp(ctx, matched, count, fallbacks...) -} - -// topUp draws on each source in turn until the mix holds count distinct tracks. -func topUp(ctx context.Context, res model.MediaFiles, count int, sources ...func() (model.MediaFiles, error)) (model.MediaFiles, error) { - // The matcher can re-emit a track, so a full-looking res may hold fewer than count unique ones. - res = dedupByID(res) - var lastErr error - for _, more := range sources { - if len(res) >= count { - break - } - extra, err := more() - if err != nil { - log.Debug(ctx, "Could not top up a short mix", "have", len(res), "want", count, err) - lastErr = err - continue - } - res = dedupByID(append(res, extra...)) - } - if len(res) == 0 { - return nil, lastErr - } - return res[:min(len(res), count)], nil -} - -// seedMix samples seed tracks, runs each through the agent chain's per-track similarity and merges -// the results, falling back to the seeds themselves so the result is never empty. -func (e *provider) seedMix(ctx context.Context, count int, sample func() (model.MediaFiles, error)) (model.MediaFiles, error) { - seeds, err := sample() - if err != nil { - return nil, err - } - if len(seeds) == 0 { - return nil, nil - } - seeds = seeds[:min(len(seeds), maxSeeds)] - - // The per-seed similarity calls are independent and hit the (possibly remote) agent chain, so - // run them concurrently. Best-effort: a seed that errors just contributes nothing. - perSeed := make([][]agents.Song, len(seeds)) - var g errgroup.Group - for i, seed := range seeds { - g.Go(func() error { - if s, err := e.ag.GetSimilarSongsByTrack(ctx, seed.ID, seed.Title, seed.Artist, seed.MbzRecordingID, count); err == nil { - perSeed[i] = s - } - return nil - }) - } - _ = g.Wait() - - var songs []agents.Song - for _, s := range perSeed { - songs = append(songs, s...) - } - // Match the whole merged set, not just count of it: the matcher re-emits a track when two - // seeds recommend it identically, so the duplicates have to be dropped before trimming. Every - // seed reaches the shuffle, so no seed can crowd out the others. - matched, err := e.matcher.MatchSongs(ctx, songs, len(songs)) - if err != nil { - return nil, err - } - matched = dedupByID(matched) - if len(matched) == 0 { - matched = seeds - } - //nolint:gosec // shuffle order is not a security decision - rand.Shuffle(len(matched), func(i, j int) { matched[i], matched[j] = matched[j], matched[i] }) - if len(matched) > count { - matched = matched[:count] - } - return matched, nil -} - -func (e *provider) samplePlaylistTracks(ctx context.Context, playlistID string, n int) (model.MediaFiles, error) { - // Refresh: a smart playlist materializes no tracks until it is evaluated, so skipping it would - // mix an empty seed set. It is a no-op for regular playlists and inside the refresh delay. - repo := e.ds.Playlist().Tracks(ctx, playlistID, true) - if repo == nil { - return nil, model.ErrNotFound - } - // A playlist can hold the same file at several positions, so over-fetch and dedup: a repeated - // seed wastes an agent call and can reach the mix twice through the seed fallback. - tracks, err := repo.GetAll(ctx, model.QueryOptions{ - Sort: "random", - Max: n * 4, - Filters: squirrel.Eq{"missing": false}, - }) - if err != nil { - return nil, err - } - mfs := dedupByID(tracks.MediaFiles()) - return mfs[:min(len(mfs), n)], nil -} - -func dedupByID(mfs model.MediaFiles) model.MediaFiles { - seen := make(map[string]struct{}, len(mfs)) - return slice.Filter(mfs, func(mf model.MediaFile) bool { - if _, dup := seen[mf.ID]; dup { - return false - } - seen[mf.ID] = struct{}{} - return true - }) -} - -func (e *provider) sampleAlbumTracks(ctx context.Context, albumID string, n int) (model.MediaFiles, error) { - return e.sampleTracks(ctx, squirrel.Eq{"album_id": albumID}, n) -} - -func (e *provider) sampleArtistTracks(ctx context.Context, artistID string, n int) (model.MediaFiles, error) { - // media_file.artist_id is the deprecated primary artist, so it misses an artist credited only - // on the album, as on compilations. Same filter the artist listings use. - filter := persistence.ParticipantIDFilter("media_file", artistID, model.RoleArtist, model.RoleAlbumArtist) - return e.sampleTracks(ctx, filter, n) -} - -func (e *provider) sampleGenreTracks(ctx context.Context, genre *model.Genre, n int) (model.MediaFiles, error) { - return e.sampleTracks(ctx, persistence.SongGenres.ByID(genre.ID), n) -} - -// sampleTracks returns up to n random present tracks. Seeds can end up in the mix verbatim, so -// missing files would surface as unplayable entries. -func (e *provider) sampleTracks(ctx context.Context, filter squirrel.Sqlizer, n int) (model.MediaFiles, error) { - return e.ds.MediaFile().GetRandom(ctx, model.QueryOptions{ - Filters: squirrel.And{filter, squirrel.Eq{"missing": false}}, - Max: n, - }) -} - -// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to -// get the artist of the given entity, retrieve similar artists, get their top songs, and pick -// a weighted random selection of songs to return as similar songs. -func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) { - artist, err := e.getArtist(ctx, id) - if err != nil { - return nil, err - } - - _ = e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false) - if utils.IsCtxDone(ctx) { - log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) - return nil, ctx.Err() - } - - weightedSongs := random.NewWeightedChooser[model.MediaFile]() - addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error { - if utils.IsCtxDone(ctx) { - log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) - return ctx.Err() - } - - topCount := max(count, 20) - topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount) - if err != nil { - log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err) - return nil - } - - weight := topCount * (4 + artistWeight) - for _, mf := range topSongs { - weightedSongs.Add(mf, weight) - weight -= 4 - } - return nil - } - - err = addArtist(artist.Artist, weightedSongs, count, 10) - if err != nil { - return nil, err - } - for _, a := range artist.SimilarArtists { - err := addArtist(a, weightedSongs, count, 0) - if err != nil { - return nil, err - } - } - - // Count distinct tracks, not picks: a collaboration sits in the chooser once per artist that - // lists it, and letting those repeats consume the budget strands unique candidates. - var similarSongs model.MediaFiles - picked := map[string]struct{}{} - for len(similarSongs) < count && weightedSongs.Size() > 0 { - s, err := weightedSongs.Pick() - if err != nil { - log.Warn(ctx, "Error getting weighted song", err) - continue - } - if _, dup := picked[s.ID]; dup { - continue - } - picked[s.ID] = struct{}{} - similarSongs = append(similarSongs, s) - } - - return similarSongs, nil -} diff --git a/core/external/provider_similarsongs_test.go b/core/external/provider_similarsongs_test.go deleted file mode 100644 index 3944edb68..000000000 --- a/core/external/provider_similarsongs_test.go +++ /dev/null @@ -1,1073 +0,0 @@ -package external_test - -import ( - "context" - "errors" - "math" - "slices" - "strings" - - "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" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -var _ = Describe("Provider - SimilarSongs", func() { - var ds model.DataStore - var provider Provider - var mockAgent *mockSimilarArtistAgent - var mockTopAgent agents.ArtistTopSongsRetriever - var mockSimilarAgent agents.ArtistSimilarRetriever - var agentsCombined *mockAgents - var artistRepo *mockArtistRepo - var mediaFileRepo *mockMediaFileRepo - var albumRepo *mockAlbumRepo - var playlistRepo *tests.MockPlaylistRepo - var playlistTrackRepo *tests.MockPlaylistTrackRepo - var genreRepo *tests.MockedGenreRepo - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - - artistRepo = newMockArtistRepo() - mediaFileRepo = newMockMediaFileRepo() - albumRepo = newMockAlbumRepo() - playlistTrackRepo = &tests.MockPlaylistTrackRepo{} - playlistRepo = tests.CreateMockPlaylistRepo() - playlistRepo.TracksRepo = playlistTrackRepo - genreRepo = &tests.MockedGenreRepo{} - - ds = &tests.MockDataStore{ - MockedArtist: artistRepo, - MockedMediaFile: mediaFileRepo, - MockedAlbum: albumRepo, - MockedPlaylist: playlistRepo, - MockedGenre: genreRepo, - } - - mockAgent = &mockSimilarArtistAgent{} - mockTopAgent = mockAgent - mockSimilarAgent = mockAgent - - agentsCombined = &mockAgents{ - topSongsAgent: mockTopAgent, - similarAgent: mockSimilarAgent, - } - - provider = NewProvider(ds, agentsCombined, matcher.New(ds), &fakeBroker{}) - }) - - // Resolves track-1 through the GetEntityByID probe order and on to its artist. Left permissive: - // no spec here asserts how many times the entity is looked up. - stubTrackEntity := func() { - track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"} - artist := model.Artist{ID: "artist-1", Name: "Artist"} - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Maybe() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Maybe() - artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe() - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe() - } - - Describe("dispatch by entity type", func() { - Context("when ID is a MediaFile (track)", func() { - It("calls GetSimilarSongsByTrack and returns matched songs", func() { - track := model.MediaFile{ID: "track-1", Title: "Just Can't Get Enough", Artist: "Depeche Mode", MbzRecordingID: "track-mbid"} - - // Depeche Mode artist row used by matcher artist resolution and track-fetch back-mapping. - dmArtist := model.Artist{ID: "dm-1", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid"} - dmParticipant := model.Participant{Artist: dmArtist} - matchedSong := model.MediaFile{ - ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode", - Participants: model.Participants{model.RoleArtist: model.ParticipantList{dmParticipant}}, - } - - // GetEntityByID tries Artist, Album, Playlist, then MediaFile - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 1). - Return([]agents.Song{ - {Name: "Dreaming of Me", MBID: "", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid"}}}, - }, nil).Once() - - // Matcher artist resolution: resolve Depeche Mode in the artist table. - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{dmArtist}, nil).Maybe() - - // ID phase: no IDs → squirrel.And with media_file.id; won't be called but guard it. - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - _, ok := opt.Filters.(squirrel.Eq) - return ok - })).Return(model.MediaFiles{}, nil).Maybe() - - // MBID phase: won't fire (empty MBID). - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - and, ok := opt.Filters.(squirrel.And) - if !ok || len(and) < 1 { - return false - } - eq, hasEq := and[0].(squirrel.Eq) - if !hasEq { - return false - } - _, hasMBID := eq["mbz_recording_id"] - return hasMBID - })).Return(model.MediaFiles{}, nil).Maybe() - - // Matcher track-fetch: subquery returns the matched song with participants. - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - and, ok := opt.Filters.(squirrel.And) - if !ok { - return false - } - for _, f := range and { - sql, _, err := f.ToSql() - if err == nil && strings.Contains(sql, "media_file_artists") { - return true - } - } - return false - })).Return(model.MediaFiles{matchedSong}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "track-1", 1) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("matched-1")) - }) - - It("falls back to artist-based algorithm when GetSimilarSongsByTrack returns empty", func() { - track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"} - artist := model.Artist{ID: "artist-1", Name: "Artist"} - song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} - - // GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", mock.Anything). - Return([]agents.Song{}, nil).Once() - - // Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the mediafile - // and recursively calls getArtist(v.ArtistID) - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once() - - // Then it recurses with the artist-1 ID - artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe() - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 1 && opt.Filters != nil - })).Return(model.Artists{artist}, nil).Maybe() - - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). - Return([]agents.Artist{}, nil).Once() - - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 0 && opt.Filters != nil - })).Return(model.Artists{}, nil).Once() - - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). - Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once() - - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "track-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("song-1")) - }) - - It("tops the mix up with the fallback when the agent's picks alone are too few", func() { - stubTrackEntity() - - // The agent knows one track of the three asked for. - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3). - Return([]agents.Song{{Name: "Agent Pick", MBID: "mbid-agent"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything). - Return(model.MediaFiles{{ID: "agent-1", Title: "Agent Pick", MbzRecordingID: "mbid-agent"}}, nil).Once() - - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). - Return([]agents.Artist{}, nil).Once() - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). - Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything). - Return(model.MediaFiles{{ID: "song-1", Title: "Song One", MbzRecordingID: "mbid-1"}}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "track-1", 3) - - Expect(err).ToNot(HaveOccurred()) - Expect(ids(songs)).To(ConsistOf("agent-1", "song-1")) - }) - - It("reaches a unique pick that sits past the count-th repeat", func() { - stubTrackEntity() - mockAgent.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Artist{}, nil).Maybe() - mockAgent.On("GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Song{}, nil).Maybe() - - // "Song B" sits past the second repeat, so a matcher capped at count never reaches it. - repeated := agents.Song{Name: "Song A", MBID: "mbid-a"} - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 2). - Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ - {ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"}, - {ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"}, - }, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "track-1", 2) - - Expect(err).ToNot(HaveOccurred()) - Expect(ids(songs)).To(ConsistOf("t-a", "t-b")) - }) - - It("keeps topping up when the agent's picks repeat a track", func() { - stubTrackEntity() - - // The matcher re-emits a track when the same input song repeats, so these three - // picks resolve to only two distinct library tracks. - repeated := agents.Song{Name: "Song A", MBID: "mbid-a"} - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", 3). - Return([]agents.Song{repeated, repeated, {Name: "Song B", MBID: "mbid-b"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ - {ID: "t-a", Title: "Song A", MbzRecordingID: "mbid-a"}, - {ID: "t-b", Title: "Song B", MbzRecordingID: "mbid-b"}, - }, nil).Once() - - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15). - Return([]agents.Artist{}, nil).Once() - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything). - Return([]agents.Song{{Name: "Song C", MBID: "mbid-c"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything). - Return(model.MediaFiles{{ID: "t-c", Title: "Song C", MbzRecordingID: "mbid-c"}}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "track-1", 3) - - Expect(err).ToNot(HaveOccurred()) - Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c")) - }) - }) - - Context("when ID is an Album", func() { - It("calls GetSimilarSongsByAlbum and returns matched songs", func() { - album := model.Album{ID: "album-1", Name: "Speak & Spell", AlbumArtist: "Depeche Mode", MbzAlbumID: "album-mbid"} - matchedSong := model.MediaFile{ID: "matched-1", Title: "New Life", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"} - - // GetEntityByID tries Artist, Album, Playlist, then MediaFile - artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "album-1").Return(&album, nil).Once() - - agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 1). - Return([]agents.Song{ - {Name: "New Life", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, - }, nil).Once() - - // Mock loadTracksByID - no ID matches - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - _, ok := opt.Filters.(squirrel.Eq) - return ok - })).Return(model.MediaFiles{}, nil).Once() - - // Mock loadTracksByMBID - MBID match - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - and, ok := opt.Filters.(squirrel.And) - if !ok || len(and) < 1 { - return false - } - _, hasEq := and[0].(squirrel.Eq) - return hasEq - })).Return(model.MediaFiles{matchedSong}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "album-1", 1) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("matched-1")) - }) - - It("falls back to sampled album tracks when GetSimilarSongsByAlbum returns ErrNotFound", func() { - album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "artist-1"} - seed := model.MediaFile{ID: "seed-1", Title: "Seed", Artist: "Artist"} - - artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "album-1").Return(&album, nil).Once() - - agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Album", "Artist", "", mock.Anything). - Return(nil, agents.ErrNotFound).Once() - - mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { - sql, args, err := opt.Filters.ToSql() - return err == nil && strings.Contains(sql, "album_id") && - strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("album-1")) - })).Return(model.MediaFiles{seed}, nil).Once() - - // seedMix falls back to the seed itself when the agent finds nothing. - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "seed-1", "Seed", "Artist", "", mock.Anything). - Return([]agents.Song{}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "album-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("seed-1")) - }) - }) - - Context("when ID is an Album and the album agent returns nothing (AudioMuse-only)", func() { - It("samples the album's tracks and returns their track-similars", func() { - album := model.Album{ID: "al-1", Name: "The Album", AlbumArtist: "A"} - artistRepo.On("Get", "al-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "al-1").Return(&album, nil).Once() - - // AudioMuse doesn't implement album similarity -> empty. - agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-1", "The Album", "A", "", 5). - Return([]agents.Song{}, nil).Once() - - // sampleAlbumTracks -> GetRandom(album_id) -> one seed track - mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { - sql, args, err := opt.Filters.ToSql() - return err == nil && strings.Contains(sql, "album_id") && - strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("al-1")) - })).Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "A"}}, nil).Once() - - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "A", "", 5). - Return([]agents.Song{{Name: "AudioMuseResult", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once() - - // Matcher resolves "AudioMuseResult" -> a real MediaFile credited to artist "A". - aArtist := model.Artist{ID: "a1", Name: "A", OrderArtistName: "a"} - matchedTrack := model.MediaFile{ - ID: "m1", Title: "AudioMuseResult", Artist: "A", - Participants: model.Participants{model.RoleArtist: model.ParticipantList{{Artist: aArtist}}}, - } - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{aArtist}, nil).Maybe() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{matchedTrack}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "al-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - Expect(songs[0].ID).To(Equal("m1")) - }) - }) - - Context("when ID is an Artist", func() { - It("calls GetSimilarSongsByArtist and returns matched songs", func() { - artist := model.Artist{ID: "artist-1", Name: "Depeche Mode", MbzArtistID: "artist-mbid"} - matchedSong := model.MediaFile{ID: "matched-1", Title: "Enjoy the Silence", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"} - - artistRepo.On("Get", "artist-1").Return(&artist, nil).Once() - agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 1). - Return([]agents.Song{ - {Name: "Enjoy the Silence", MBID: "song-mbid", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, - }, nil).Once() - - // Mock loadTracksByID - no ID matches - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - _, ok := opt.Filters.(squirrel.Eq) - return ok - })).Return(model.MediaFiles{}, nil).Once() - - // Mock loadTracksByMBID - MBID match - mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - and, ok := opt.Filters.(squirrel.And) - if !ok || len(and) < 1 { - return false - } - _, hasEq := and[0].(squirrel.Eq) - return hasEq - })).Return(model.MediaFiles{matchedSong}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "artist-1", 1) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("matched-1")) - }) - }) - - Context("when ID is an Artist and both the artist agent and the similar-artists fallback are empty", func() { - It("samples the artist's tracks and returns their track-similars", func() { - artist := model.Artist{ID: "ar-1", Name: "The Artist"} - // Get is called twice: once to resolve the entity, once inside similarSongsFallback. - artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe() - - agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 5). - Return([]agents.Song{}, nil).Once() - // similarSongsFallback: no similar artists, no top songs -> empty (allow its lookups). - mockAgent.On("GetSimilarArtists", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Artist{}, nil).Maybe() - mockAgent.On("GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Song{}, nil).Maybe() - - // Seeds come from the participant join covering both roles, so an artist credited - // only on the album (compilations, classical) still yields seeds. - mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { - sql, args, err := opt.Filters.ToSql() - return err == nil && strings.Contains(sql, "media_file_artists") && - strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("ar-1")) && - slices.Contains(args, any(model.RoleAlbumArtist.String())) && slices.Contains(args, any(model.RoleArtist.String())) - })).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5). - Return([]agents.Song{{Name: "Result"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Result"}}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "ar-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - }) - }) - - Context("when ID is an Artist and the similar-artists fallback can't fill the mix", func() { - It("tops the mix up with the artist's own track-similars", func() { - artist := model.Artist{ID: "ar-1", Name: "Thin Artist"} - topSong := model.MediaFile{ID: "top-1", Title: "Top Song", ArtistID: "ar-1", MbzRecordingID: "mbid-top"} - - artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe() - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe() - - agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "Thin Artist", "", 5). - Return([]agents.Song{}, nil).Once() - - // No similar artist is in the library, so the fallback yields only the seed artist's - // own matching top song: one track for a mix of five. - mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "Thin Artist", "", 15). - Return([]agents.Artist{}, nil).Once() - mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "Thin Artist", "", mock.Anything). - Return([]agents.Song{{Name: "Top Song", MBID: "mbid-top"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{topSong}, nil).Once() - - mediaFileRepo.On("GetRandom", mock.Anything). - Return(model.MediaFiles{{ID: "s1", Title: "Seed", Artist: "Thin Artist"}}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "Thin Artist", "", mock.Anything). - Return([]agents.Song{{Name: "Mix Song", MBID: "mbid-mix"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything). - Return(model.MediaFiles{{ID: "mix-1", Title: "Mix Song", MbzRecordingID: "mbid-mix"}}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "ar-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(ids(songs)).To(ConsistOf("top-1", "mix-1")) - }) - }) - - Context("when ID is an Artist and the agent plus the similar-artists fallback already fill the mix", func() { - It("does not pay for seed-track sampling", func() { - artist := model.Artist{ID: "ar-1", Name: "The Artist"} - artistRepo.On("Get", "ar-1").Return(&artist, nil).Maybe() - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist}, nil).Maybe() - - agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "ar-1", "The Artist", "", 3). - Return([]agents.Song{{Name: "A", MBID: "mbid-a"}, {Name: "B", MBID: "mbid-b"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ - {ID: "t-a", Title: "A", MbzRecordingID: "mbid-a"}, - {ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"}, - }, nil).Once() - - // The similar-artists fallback supplies the third track, so the mix is full. - mockAgent.On("GetSimilarArtists", mock.Anything, "ar-1", "The Artist", "", 15). - Return([]agents.Artist{}, nil).Once() - mockAgent.On("GetArtistTopSongs", mock.Anything, "ar-1", "The Artist", "", mock.Anything). - Return([]agents.Song{{Name: "C", MBID: "mbid-c"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything). - Return(model.MediaFiles{{ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"}}, nil).Once() - - mediaFileRepo.On("GetRandom", mock.Anything).Return(model.MediaFiles{{ID: "seed"}}, nil).Maybe() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Song{}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "ar-1", 3) - - Expect(err).ToNot(HaveOccurred()) - Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c")) - mediaFileRepo.AssertNotCalled(GinkgoT(), "GetRandom", mock.Anything) - }) - }) - - Context("when ID is a Playlist", func() { - It("samples playlist tracks and returns their track-similars", func() { - pls := model.Playlist{ID: "pl-1", Name: "My List"} - seedTrack := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"} - - // GetEntityByID order: Artist, Album, Playlist(hit) - artistRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-1").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - - // samplePlaylistTracks -> Tracks(...).GetAll -> one seed track, bounded+randomized in SQL - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: seedTrack}, - }) - - // seedMix -> GetSimilarSongsByTrack for the seed - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5). - Return([]agents.Song{{Name: "Similar", Artists: []agents.Artist{{Name: "A"}}}}, nil).Once() - - // Matcher resolves "Similar" -> a real MediaFile (allow the matcher's lookups). - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{{ID: "a1", Name: "A"}}, nil).Maybe() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "pl-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - }) - - It("asks for a smart-playlist refresh so an unevaluated one still yields seeds", func() { - // A smart playlist materializes no playlist_tracks until it is evaluated, so sampling - // without the refresh would mix an empty seed set. - pls := model.Playlist{ID: "pl-smart", Name: "Smart"} - artistRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-smart").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, - }) - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Song{}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "pl-smart", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - Expect(playlistRepo.TracksRefreshed).To(BeTrue()) - }) - - It("returns an error instead of panicking when the track repository is unavailable", func() { - // Tracks() logs and returns a nil repository when its own lookup fails. - pls := model.Playlist{ID: "pl-nil", Name: "Gone"} - artistRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-nil").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistRepo.TracksRepo = nil - - _, err := provider.SimilarSongs(ctx, "pl-nil", 5) - - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("does not seed a mix with missing tracks", func() { - pls := model.Playlist{ID: "pl-missing", Name: "Missing"} - artistRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-missing").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, - }) - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return([]agents.Song{}, nil).Maybe() - - _, err := provider.SimilarSongs(ctx, "pl-missing", 5) - - Expect(err).ToNot(HaveOccurred()) - sql, args, sqlErr := playlistTrackRepo.Options.Filters.ToSql() - Expect(sqlErr).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("missing")) - Expect(args).To(ContainElement(false), "must exclude missing files, not select them") - }) - - It("keeps a later seed's picks when an earlier seed overlaps it", func() { - // The matcher stops once it has count matches, and it re-emits the shared track, so - // matching only count of the merged set would spend slots on the duplicate. - pls := model.Playlist{ID: "pl-overlap2", Name: "Overlap2"} - artistRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-overlap2").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, - {MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}}, - }) - shared := agents.Song{ID: "x", Name: "X"} - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3). - Return([]agents.Song{shared}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3). - Return([]agents.Song{shared, {ID: "y", Name: "Y"}, {ID: "z", Name: "Z"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ - {ID: "x", Title: "X"}, {ID: "y", Title: "Y"}, {ID: "z", Title: "Z"}, - }, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "pl-overlap2", 3) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(3), "the duplicate must not cost a slot") - }) - - It("returns a track once when two seeds recommend it", func() { - // The matcher re-emits a track when two inputs are identical, so overlapping - // recommendations would otherwise take two slots in the mix. - pls := model.Playlist{ID: "pl-overlap", Name: "Overlap"} - artistRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-overlap").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, - {MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}}, - }) - shared := agents.Song{ID: "m1", Name: "Shared"} - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5). - Return([]agents.Song{shared}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 5). - Return([]agents.Song{shared}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Shared"}}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "pl-overlap", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1), "the shared recommendation must appear once") - }) - - It("does not seed a mix twice with a track the playlist repeats", func() { - pls := model.Playlist{ID: "pl-dup", Name: "Dupes"} - artistRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-dup").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - // The same file at two positions, which playlists allow. - dup := model.MediaFile{ID: "s1", Title: "Seed One"} - playlistTrackRepo.SetData(model.PlaylistTracks{{MediaFile: dup}, {MediaFile: dup}}) - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 5). - Return([]agents.Song{}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "pl-dup", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1), "the repeated track must appear once") - agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 1) - }) - - It("clamps an enormous count before it reaches the queries", func() { - // count+1 in the local agent overflows on MaxInt64, and GetRandom omits the SQL - // limit unless Max is positive, so the query would hydrate the whole library. - pls := model.Playlist{ID: "pl-huge", Name: "Huge"} - artistRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-huge").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, - }) - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 500). - Return([]agents.Song{}, nil).Once() - - _, err := provider.SimilarSongs(ctx, "pl-huge", math.MaxInt64) - - Expect(err).ToNot(HaveOccurred()) - agentsCombined.AssertExpectations(GinkgoT()) - }) - - It("does not panic when the caller asks for a non-positive count", func() { - // Subsonic passes count straight through, so a negative one reaches the provider. - // The guard returns before any lookup, so no repository setup is needed. - songs, err := provider.SimilarSongs(ctx, "pl-neg", -1) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(BeEmpty()) - }) - - It("blends results from every seed, not just the first", func() { - pls := model.Playlist{ID: "pl-blend", Name: "Blend"} - artistRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-blend").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: model.MediaFile{ID: "s1", Title: "Seed One"}}, - {MediaFile: model.MediaFile{ID: "s2", Title: "Seed Two"}}, - }) - - // Each seed returns a full count's worth, as a real similarity agent does. Asking for - // one more than seed one can supply makes this independent of the final shuffle: - // three of the four matches always include a seed-two track. - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "", "", 3). - Return([]agents.Song{{ID: "a1", Name: "A1"}, {ID: "a2", Name: "A2"}}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "", "", 3). - Return([]agents.Song{{ID: "b1", Name: "B1"}, {ID: "b2", Name: "B2"}}, nil).Once() - - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{ - {ID: "a1", Title: "A1"}, {ID: "a2", Title: "A2"}, - {ID: "b1", Title: "B1"}, {ID: "b2", Title: "B2"}, - }, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "pl-blend", 3) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(3)) - ids := ids(songs) - Expect(ids).To(ContainElement(BeElementOf("b1", "b2")), "seed two must be represented in the mix") - }) - - It("falls back to the seed tracks themselves when no similar songs are found", func() { - pls := model.Playlist{ID: "pl-2", Name: "Fallback List"} - seed1 := model.MediaFile{ID: "s1", Title: "Seed One", Artist: "A"} - seed2 := model.MediaFile{ID: "s2", Title: "Seed Two", Artist: "B"} - - artistRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-2").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - - playlistTrackRepo.SetData(model.PlaylistTracks{ - {MediaFile: seed1}, - {MediaFile: seed2}, - }) - - // Both seeds come back empty, so the mix must fall back to the seeds themselves. - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed One", "A", "", 5). - Return([]agents.Song{}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s2", "Seed Two", "B", "", 5). - Return([]agents.Song{}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "pl-2", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(2)) - Expect([]string{songs[0].ID, songs[1].ID}).To(ConsistOf("s1", "s2")) - }) - - It("samples the album when the agent's picks are not in this library", func() { - // Last.fm answers from its own catalogue, so a small library can match none of it. - // An unmatched non-empty answer must not shortcut the sampling fallback. - album := model.Album{ID: "al-nm", Name: "NoMatch", AlbumArtist: "A"} - artistRepo.On("Get", "al-nm").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "al-nm").Return(&album, nil).Once() - agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-nm", "NoMatch", "A", "", 5). - Return([]agents.Song{{Name: "Not In Library"}}, nil).Once() - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{}, nil).Maybe() - - // The matcher resolves nothing; the sampled seed is what reaches the mix. - mediaFileRepo.On("GetRandom", mock.Anything). - Return(model.MediaFiles{{ID: "s1", Title: "Album Track"}}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Album Track", "", "", 5). - Return([]agents.Song{}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "al-nm", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("s1")) - }) - - It("caps agent calls at maxSeeds when the repository ignores the bound", func() { - // Isolates seedMix's own cap: the album sampler bounds the query with Max, so this - // exercises the guard by having the repo hand back more rows than were asked for. - album := model.Album{ID: "al-cap", Name: "Cap", AlbumArtist: "A"} - artistRepo.On("Get", "al-cap").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "al-cap").Return(&album, nil).Once() - agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "al-cap", "Cap", "A", "", 5). - Return([]agents.Song{}, nil).Once() - - var overflow model.MediaFiles - for _, id := range []string{"t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"} { - overflow = append(overflow, model.MediaFile{ID: id, Title: id}) - } - mediaFileRepo.On("GetRandom", mock.Anything).Return(overflow, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5). - Return([]agents.Song{}, nil) - - _, err := provider.SimilarSongs(ctx, "al-cap", 5) - - Expect(err).ToNot(HaveOccurred()) - agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5) - }) - - It("still finds maxSeeds distinct seeds when the leading positions repeat", func() { - // The sampler over-fetches for exactly this case: a page bounded at maxSeeds could - // be entirely one repeated file and collapse to a single seed. - pls := model.Playlist{ID: "pl-3", Name: "Big List"} - dup := model.MediaFile{ID: "dup", Title: "Dup", Artist: "A"} - tracks := model.PlaylistTracks{ - {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, {MediaFile: dup}, - } - for _, id := range []string{"seed-1", "seed-2", "seed-3", "seed-4", "seed-5"} { - tracks = append(tracks, model.PlaylistTrack{ - MediaFile: model.MediaFile{ID: id, Title: id, Artist: "A"}, - }) - } - - artistRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "pl-3").Return(nil, model.ErrNotFound).Once() - playlistRepo.SetData(model.Playlists{pls}) - playlistTrackRepo.SetData(tracks) - - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, 5). - Return([]agents.Song{}, nil) - - songs, err := provider.SimilarSongs(ctx, "pl-3", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(5)) - Expect(playlistTrackRepo.Options.Sort).To(Equal("random")) - agentsCombined.AssertNumberOfCalls(GinkgoT(), "GetSimilarSongsByTrack", 5) - }) - }) - - Context("when ID is a Genre (not resolved by GetEntityByID)", func() { - It("samples genre songs and returns their track-similars", func() { - // GetEntityByID misses everywhere; the empty/auto-created mocks need no setup. - artistRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once() - albumRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once() - mediaFileRepo.On("Get", "g-1").Return(nil, model.ErrNotFound).Once() - genreRepo.Data = map[string]model.Genre{"g-1": {ID: "g-1", Name: "Jazz"}} - - // sampleGenreTracks -> GetRandom with the indexed media_file_tags semi-join (not a json_tree scan) - mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { - if opt.Filters == nil { - return false - } - sql, args, err := opt.Filters.ToSql() - return err == nil && strings.Contains(sql, "media_file_tags") && - !strings.Contains(sql, "json_tree") && strings.Contains(sql, "missing") && slices.Contains(args, any(false)) - })).Return(model.MediaFiles{{ID: "s1", Title: "Seed"}}, nil).Once() - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "s1", "Seed", "", "", 5). - Return([]agents.Song{{Name: "Similar"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{ID: "m1", Title: "Similar"}}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "g-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - }) - }) - }) - - It("returns similar songs from main artist and similar artists", func() { - artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} - similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"} - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"} - song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"} - - artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() - artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe() - - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 1 && opt.Filters != nil - })).Return(model.Artists{artist1}, nil).Once() - - // New similar songs by artist returns ErrNotFound to trigger fallback - agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything). - Return(nil, agents.ErrNotFound).Once() - - similarAgentsResp := []agents.Artist{ - {Name: "Similar Artist", MBID: "similar-mbid"}, - } - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). - Return(similarAgentsResp, nil).Once() - - // Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name - // MBID lookup returns empty (no match) - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - _, ok := opt.Filters.(squirrel.Eq) - return opt.Max == 0 && ok - })).Return(model.Artists{}, nil).Once() - // Name lookup returns the similar artist - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - _, ok := opt.Filters.(squirrel.Or) - return opt.Max == 0 && ok - })).Return(model.Artists{similarArtist}, nil).Once() - - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). - Return([]agents.Song{ - {Name: "Song One", MBID: "mbid-1"}, - {Name: "Song Two", MBID: "mbid-2"}, - }, nil).Once() - - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything). - Return([]agents.Song{ - {Name: "Song Three", MBID: "mbid-3"}, - }, nil).Once() - - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "artist-1", 3) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(3)) - for _, song := range songs { - Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3")) - } - }) - - It("keeps picking until the fallback holds count distinct tracks", func() { - // A collaboration in two artists' top songs lands in the chooser twice. Picking a fixed - // count of entries lets those duplicates eat the budget and strand unique candidates. - track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist One", ArtistID: "artist-1"} - artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} - similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"} - - artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice() - albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Twice() - mediaFileRepo.On("Get", "track-1").Return(&track, nil).Twice() - artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() - artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe() - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 1 && opt.Filters != nil - })).Return(model.Artists{artist1}, nil).Maybe() - - agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist One", "", 3). - Return([]agents.Song{}, nil).Once() - - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). - Return([]agents.Artist{{Name: "Similar Artist"}}, nil).Once() - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - _, ok := opt.Filters.(squirrel.Eq) - return opt.Max == 0 && ok - })).Return(model.Artists{}, nil).Once() - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - _, ok := opt.Filters.(squirrel.Or) - return opt.Max == 0 && ok - })).Return(model.Artists{similarArtist}, nil).Once() - - shared := model.MediaFile{ID: "t-a", Title: "Shared", MbzRecordingID: "mbid-a"} - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). - Return([]agents.Song{{Name: "Shared", MBID: "mbid-a"}}, nil).Once() - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")). - Return(model.MediaFiles{shared}, nil).Once() - - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything). - Return([]agents.Song{ - {Name: "Shared", MBID: "mbid-a"}, - {Name: "B", MBID: "mbid-b"}, - {Name: "C", MBID: "mbid-c"}, - }, nil).Once() - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{ - shared, - {ID: "t-b", Title: "B", MbzRecordingID: "mbid-b"}, - {ID: "t-c", Title: "C", MbzRecordingID: "mbid-c"}, - }, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "track-1", 3) - - Expect(err).ToNot(HaveOccurred()) - Expect(ids(songs)).To(ConsistOf("t-a", "t-b", "t-c")) - }) - - It("returns ErrNotFound when artist is not found", func() { - artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) - mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) - albumRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) - - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 1 && opt.Filters != nil - })).Return(model.Artists{}, nil).Maybe() - - songs, err := provider.SimilarSongs(ctx, "artist-unknown-artist", 5) - - Expect(err).To(Equal(model.ErrNotFound)) - Expect(songs).To(BeNil()) - }) - - It("returns songs from main artist when GetSimilarArtists returns error", func() { - artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} - - artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 1 && opt.Filters != nil - })).Return(model.Artists{artist1}, nil).Maybe() - - // New similar songs by artist returns ErrNotFound to trigger fallback - agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything). - Return(nil, agents.ErrNotFound).Once() - - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). - Return(nil, errors.New("error getting similar artists")).Once() - - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 0 && opt.Filters != nil - })).Return(model.Artists{}, nil).Once() - - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). - Return([]agents.Song{ - {Name: "Song One", MBID: "mbid-1"}, - }, nil).Once() - - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "artist-1", 1) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("song-1")) - }) - - It("returns empty list when GetArtistTopSongs returns error", func() { - artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} - - artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 1 && opt.Filters != nil - })).Return(model.Artists{artist1}, nil).Maybe() - - // New similar songs by artist returns ErrNotFound to trigger fallback - agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything). - Return(nil, agents.ErrNotFound).Once() - - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). - Return([]agents.Artist{}, nil).Once() - - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 0 && opt.Filters != nil - })).Return(model.Artists{}, nil).Once() - - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). - Return(nil, errors.New("error getting top songs")).Once() - - // Fallback yields nothing, so the sampling path is tried and also finds no tracks. - mediaFileRepo.On("GetRandom", mock.MatchedBy(func(opt model.QueryOptions) bool { - sql, args, err := opt.Filters.ToSql() - return err == nil && strings.Contains(sql, "artist_id") && - strings.Contains(sql, "missing") && slices.Contains(args, any(false)) && slices.Contains(args, any("artist-1")) - })).Return(model.MediaFiles{}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "artist-1", 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(BeEmpty()) - }) - - It("respects count parameter", func() { - artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"} - - artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 1 && opt.Filters != nil - })).Return(model.Artists{artist1}, nil).Maybe() - - // New similar songs by artist returns ErrNotFound to trigger fallback - agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything). - Return(nil, agents.ErrNotFound).Once() - - mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). - Return([]agents.Artist{}, nil).Once() - - artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { - return opt.Max == 0 && opt.Filters != nil - })).Return(model.Artists{}, nil).Once() - - mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). - Return([]agents.Song{ - {Name: "Song One", MBID: "mbid-1"}, - {Name: "Song Two", MBID: "mbid-2"}, - }, nil).Once() - - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - - songs, err := provider.SimilarSongs(ctx, "artist-1", 1) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(BeElementOf("song-1", "song-2")) - }) -}) diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go deleted file mode 100644 index 5d35ba914..000000000 --- a/core/external/provider_topsongs_test.go +++ /dev/null @@ -1,478 +0,0 @@ -package external_test - -import ( - "context" - "errors" - - _ "github.com/navidrome/navidrome/adapters/lastfm" - _ "github.com/navidrome/navidrome/adapters/listenbrainz" - "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/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -var _ = Describe("Provider - TopSongs", func() { - var ( - p Provider - artistRepo *mockArtistRepo // From provider_helper_test.go - mediaFileRepo *mockMediaFileRepo // From provider_helper_test.go - ag *mockAgents // Consolidated mock from export_test.go - ctx context.Context - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - // Disable fuzzy matching for these tests to avoid unexpected GetAll calls - conf.Server.Matcher.FuzzyThreshold = 100 - - ctx = GinkgoT().Context() - - artistRepo = newMockArtistRepo() // Use helper mock - mediaFileRepo = newMockMediaFileRepo() // Use helper mock - - // Configure tests.MockDataStore to use the testify/mock-based repos - ds := &tests.MockDataStore{ - MockedArtist: artistRepo, - MockedMediaFile: mediaFileRepo, - } - - ag = new(mockAgents) - - p = NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) - }) - - It("returns top songs for a known artist", func() { - // Mock finding the artist - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - - // Mock agent response - agentSongs := []agents.Song{ - {Name: "Song One", MBID: "mbid-song-1"}, - {Name: "Song Two", MBID: "mbid-song-2"}, - } - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() - - // Mock finding matching tracks (both returned in a single query) - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"} - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - - songs, err := p.TopSongs(ctx, "Artist One", "", 2) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(2)) - Expect(songs[0].ID).To(Equal("song-1")) - Expect(songs[1].ID).To(Equal("song-2")) - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertExpectations(GinkgoT()) - mediaFileRepo.AssertExpectations(GinkgoT()) - }) - - Context("artist id and/or name", func() { - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - - BeforeEach(func() { - agentSongs := []agents.Song{{Name: "Song One", MBID: "mbid-song-1"}} - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() - - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - }) - - It("returns top songs for a known artist by id only", func() { - artistRepo.On("Get", "artist-1").Return(&artist1, nil).Once() - - songs, err := p.TopSongs(ctx, "", "artist-1", 2) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("song-1")) - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertExpectations(GinkgoT()) - mediaFileRepo.AssertExpectations(GinkgoT()) - }) - - It("returns top songs for a known artist by id, falling back to name", func() { - artistRepo.On("Get", "fake-id").Return(nil, model.ErrNotFound).Once() - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - - songs, err := p.TopSongs(ctx, "Artist One", "fake-id", 2) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("song-1")) - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertExpectations(GinkgoT()) - mediaFileRepo.AssertExpectations(GinkgoT()) - }) - }) - - It("bails if lookup by id returns a fatal error", func() { - artistRepo.On("Get", "artist-1").Return(nil, model.ErrInvalidAuth).Once() - songs, err := p.TopSongs(ctx, "Artist One", "artist-1", 2) - Expect(songs).To(BeEmpty()) - Expect(err).To(HaveOccurred()) - }) - - It("bails if lookup by name returns a fatal error", func() { - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(nil, model.ErrInvalidAuth).Once() - songs, err := p.TopSongs(ctx, "Artist One", "", 2) - Expect(songs).To(BeEmpty()) - Expect(err).To(HaveOccurred()) - }) - - It("backfills name and MBID onto an unnamed primary credit (the queried artist) and matches", func() { - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil) - - // Agent leaves the first credit unnamed (e.g. an MBID-less collaborator slot). That blank - // credit IS the queried artist, so enrichment fills both name and MBID; the song then matches - // the queried artist's track via the backfilled identity. - agentSongs := []agents.Song{ - {Name: "Song One", Artists: []agents.Artist{{}}}, - } - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once() - - track := model.MediaFile{ - ID: "song-1", Title: "Song One", ArtistID: "artist-1", - Participants: model.Participants{model.RoleArtist: model.ParticipantList{ - {Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-artist-1"}}, - }}, - } - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil) - - songs, err := p.TopSongs(ctx, "Artist One", "", 1) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("song-1")) - }) - - It("does not stamp the queried MBID onto an already-named different first credit", func() { - // The queried artist (One) appears only as a featured collaborator; the displayed first credit - // is a DIFFERENT artist (Two) returned without an MBID. Enrichment must NOT assign One's MBID - // to Two — only Two's name match (which fails here) or One's own credit may resolve the track. - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil) - - agentSongs := []agents.Song{ - {Name: "Collab Song", Artists: []agents.Artist{{Name: "Artist Two"}}}, - } - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once() - - // Library track is credited to Artist One (the queried artist) under a same title. If the - // queried MBID were wrongly stamped onto the "Artist Two" credit, that mismatched name+MBID artistRepo.On("Get", "fake-id").Return(nil, model.ErrNotFound).Once() - - // could mis-resolve. With the guard, "Artist Two" stays MBID-less and does not match One's track. - track := model.MediaFile{ - ID: "one-track", Title: "Collab Song", ArtistID: "artist-1", - Participants: model.Participants{model.RoleArtist: model.ParticipantList{ - {Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-artist-1"}}, - }}, - } - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{track}, nil) - - songs, err := p.TopSongs(ctx, "Artist One", "", 1) - - Expect(err).ToNot(HaveOccurred()) - // "Artist Two" (named, MBID-less, not in the library) does not resolve to One's track. - Expect(songs).To(BeEmpty()) - }) - - It("returns nil for an unknown artist", func() { - // Mock artist not found - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{}, nil).Once() - - songs, err := p.TopSongs(ctx, "Unknown Artist", "", 5) - - Expect(err).ToNot(HaveOccurred()) // TopSongs returns nil error if artist not found - Expect(songs).To(BeNil()) - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertNotCalled(GinkgoT(), "GetArtistTopSongs", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) - }) - - It("returns error when the agent returns an error", func() { - // Mock finding the artist - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - - // Mock agent error - agentErr := errors.New("agent error") - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agentErr).Once() - - songs, err := p.TopSongs(ctx, "Artist One", "", 5) - - Expect(err).To(MatchError(agentErr)) - Expect(songs).To(BeNil()) - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertExpectations(GinkgoT()) - }) - - It("returns ErrNotFound when the agent returns ErrNotFound", func() { - // Mock finding the artist - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - - // Mock agent ErrNotFound - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, agents.ErrNotFound).Once() - - songs, err := p.TopSongs(ctx, "Artist One", "", 5) - - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(songs).To(BeNil()) - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertExpectations(GinkgoT()) - }) - - // This endpoint answered with an empty list before retry-later existed; it must keep doing so. - It("returns an empty list, not a client error, when the agents are throttled", func() { - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 5). - Return(nil, agents.ErrRetryLater).Once() - - songs, err := p.TopSongs(ctx, "Artist One", "", 5) - - Expect(songs).To(BeEmpty()) - Expect(err).To(MatchError(model.ErrNotFound), "the handler renders this as an empty 200") - Expect(err).ToNot(MatchError(agents.ErrRetryLater)) - ag.AssertExpectations(GinkgoT()) - }) - - It("returns fewer songs if count is less than available top songs", func() { - // Mock finding the artist - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - - // Mock agent response (only need 1 for the test) - agentSongs := []agents.Song{{Name: "Song One", MBID: "mbid-song-1"}} - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once() - - // Mock finding matching track - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - - songs, err := p.TopSongs(ctx, "Artist One", "", 1) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("song-1")) - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertExpectations(GinkgoT()) - mediaFileRepo.AssertExpectations(GinkgoT()) - }) - - It("returns fewer songs if fewer matching tracks are found", func() { - // Mock finding the artist - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - // Matcher artist resolution for the title-match path (song2 falls through). - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe() - - // Mock agent response - agentSongs := []agents.Song{ - {Name: "Song One", MBID: "mbid-song-1"}, - {Name: "Song Two", MBID: "mbid-song-2"}, - } - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() - - // Mock finding matching tracks (only find song 1 on bulk query) - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() // bulk MBID query - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() // title track-fetch for song2: no match - - songs, err := p.TopSongs(ctx, "Artist One", "", 2) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("song-1")) - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertExpectations(GinkgoT()) - mediaFileRepo.AssertExpectations(GinkgoT()) - }) - - It("returns error when context is canceled during agent call", func() { - // Mock finding the artist - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - - // Setup context that will be canceled - canceledCtx, cancel := context.WithCancel(ctx) - - // Mock agent call to return context canceled error - ag.On("GetArtistTopSongs", canceledCtx, "artist-1", "Artist One", "mbid-artist-1", 5).Return(nil, context.Canceled).Once() - - cancel() // Cancel the context before calling - songs, err := p.TopSongs(canceledCtx, "Artist One", "", 5) - - Expect(err).To(MatchError(context.Canceled)) - Expect(songs).To(BeNil()) - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertExpectations(GinkgoT()) - }) - - It("falls back to title matching when MbzRecordingID is missing", func() { - // Mock finding the artist - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - // Matcher artist resolution for both title-fallback songs. - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe() - - // Mock agent response with songs that have NO MBID (empty string) - agentSongs := []agents.Song{ - {Name: "Song One", MBID: ""}, // No MBID, should fall back to title matching - {Name: "Song Two", MBID: ""}, // No MBID, should fall back to title matching - } - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() - - // Title track-fetch: tracks must carry RoleArtist participants so back-mapping routes them. - participant1 := model.Participant{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one"}} - song1 := model.MediaFile{ - ID: "song-1", Title: "Song One", Artist: "Artist One", ArtistID: "artist-1", - Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}}, - } - song2 := model.MediaFile{ - ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", - Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}}, - } - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - - songs, err := p.TopSongs(ctx, "Artist One", "", 2) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(2)) - Expect(songs[0].ID).To(Equal("song-1")) - Expect(songs[1].ID).To(Equal("song-2")) - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertExpectations(GinkgoT()) - mediaFileRepo.AssertExpectations(GinkgoT()) - }) - - It("combines MBID and title matching when some songs have missing MbzRecordingID", func() { - // Mock finding the artist - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - // Matcher artist resolution for song2's title-fallback path. - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{artist1}, nil).Maybe() - - // Mock agent response with mixed MBID availability - agentSongs := []agents.Song{ - {Name: "Song One", MBID: "mbid-song-1"}, // Has MBID, should match by MBID - {Name: "Song Two", MBID: ""}, // No MBID, should fall back to title matching - } - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() - - // Mock the MBID query (finds song1 by MBID) - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1", OrderTitle: "song one"} - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - - // Title track-fetch: song2 must carry RoleArtist participants for back-mapping. - participant1 := model.Participant{Artist: model.Artist{ID: "artist-1", Name: "Artist One", OrderArtistName: "artist one"}} - song2 := model.MediaFile{ - ID: "song-2", Title: "Song Two", Artist: "Artist One", ArtistID: "artist-1", - Participants: model.Participants{model.RoleArtist: model.ParticipantList{participant1}}, - } - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song2}, nil).Once() - - songs, err := p.TopSongs(ctx, "Artist One", "", 2) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(2)) - Expect(songs[0].ID).To(Equal("song-1")) // Found by MBID - Expect(songs[1].ID).To(Equal("song-2")) // Found by title - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertExpectations(GinkgoT()) - mediaFileRepo.AssertExpectations(GinkgoT()) - }) - - It("only returns requested count when provider returns additional items", func() { - // Mock finding the artist - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - - // Mock agent response - agentSongs := []agents.Song{ - {Name: "Song One", MBID: "mbid-song-1"}, - {Name: "Song Two", MBID: "mbid-song-2"}, - } - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once() - - // Mock finding matching tracks (both returned in a single query) - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-song-2"} - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - - songs, err := p.TopSongs(ctx, "Artist One", "", 1) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("song-1")) - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertExpectations(GinkgoT()) - mediaFileRepo.AssertExpectations(GinkgoT()) - }) - - It("matches songs by ID first when agent provides IDs", func() { - // Mock finding the artist - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - - // Mock agent response with IDs provided (highest priority matching) - // Note: Songs have no MBID to ensure only ID matching is used - agentSongs := []agents.Song{ - {ID: "song-1", Name: "Song One"}, - {ID: "song-2", Name: "Song Two"}, - } - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 2).Return(agentSongs, nil).Once() - - // Mock ID lookup (first query - should match both songs directly) - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1"} - song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1"} - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() - - songs, err := p.TopSongs(ctx, "Artist One", "", 2) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(2)) - Expect(songs[0].ID).To(Equal("song-1")) - Expect(songs[1].ID).To(Equal("song-2")) - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertExpectations(GinkgoT()) - mediaFileRepo.AssertExpectations(GinkgoT()) - }) - - It("falls back to MBID when ID is not found", func() { - // Mock finding the artist - artist1 := model.Artist{ID: "artist-1", Name: "Artist One", MbzArtistID: "mbid-artist-1"} - artistRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.Artists{artist1}, nil).Once() - - // Mock agent response with ID that won't be found, but MBID that will - agentSongs := []agents.Song{ - {ID: "non-existent-id", Name: "Song One", MBID: "mbid-song-1"}, - } - ag.On("GetArtistTopSongs", ctx, "artist-1", "Artist One", "mbid-artist-1", 1).Return(agentSongs, nil).Once() - - // Mock ID lookup - returns empty (ID not found) - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{}, nil).Once() - // Mock MBID lookup - finds the song - song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-song-1"} - mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() - - songs, err := p.TopSongs(ctx, "Artist One", "", 1) - - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(1)) - Expect(songs[0].ID).To(Equal("song-1")) - artistRepo.AssertExpectations(GinkgoT()) - ag.AssertExpectations(GinkgoT()) - mediaFileRepo.AssertExpectations(GinkgoT()) - }) -}) diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go deleted file mode 100644 index d2fd4364e..000000000 --- a/core/external/provider_updatealbuminfo_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package external_test - -import ( - "context" - "errors" - "time" - - "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" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -func init() { - log.SetLevel(log.LevelDebug) -} - -var _ = Describe("Provider - UpdateAlbumInfo", func() { - var ( - ctx context.Context - p external.Provider - ds *tests.MockDataStore - ag *mockAgents - mockAlbumRepo *tests.MockAlbumRepo - ) - - BeforeEach(func() { - ctx = GinkgoT().Context() - ds = new(tests.MockDataStore) - ag = new(mockAgents) - p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) - mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo) - conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour - }) - - It("returns error when album is not found", func() { - album, err := p.UpdateAlbumInfo(ctx, "al-not-found") - - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(album).To(BeNil()) - ag.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything) - }) - - It("populates info when album exists but has no external info", func() { - originalAlbum := &model.Album{ - ID: "al-existing", - Name: "Test Album", - AlbumArtist: "Test Artist", - MbzAlbumID: "mbid-album", - } - mockAlbumRepo.SetData(model.Albums{*originalAlbum}) - - expectedInfo := &agents.AlbumInfo{ - URL: "http://example.com/album", - Description: "Album Description", - } - ag.On("GetAlbumInfo", ctx, "Test Album", "Test Artist", "mbid-album").Return(expectedInfo, nil) - ag.On("GetAlbumImages", ctx, "Test Album", "Test Artist", "mbid-album").Return([]agents.ExternalImage{ - {URL: "http://example.com/large.jpg", Size: 300}, - {URL: "http://example.com/medium.jpg", Size: 200}, - {URL: "http://example.com/small.jpg", Size: 100}, - }, nil) - - updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-existing") - - Expect(err).NotTo(HaveOccurred()) - Expect(updatedAlbum).NotTo(BeNil()) - Expect(updatedAlbum.ID).To(Equal("al-existing")) - Expect(updatedAlbum.ExternalUrl).To(Equal("http://example.com/album")) - Expect(updatedAlbum.Description).To(Equal("Album Description")) - Expect(updatedAlbum.ExternalInfoUpdatedAt).NotTo(BeNil()) - Expect(*updatedAlbum.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second)) - - ag.AssertExpectations(GinkgoT()) - }) - - It("returns cached info when album exists and info is not expired", func() { - now := time.Now() - originalAlbum := &model.Album{ - ID: "al-cached", - Name: "Cached Album", - AlbumArtist: "Cached Artist", - ExternalUrl: "http://cached.com/album", - Description: "Cached Desc", - LargeImageUrl: "http://cached.com/large.jpg", - ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)), - } - mockAlbumRepo.SetData(model.Albums{*originalAlbum}) - - updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-cached") - - Expect(err).NotTo(HaveOccurred()) - Expect(updatedAlbum).NotTo(BeNil()) - Expect(*updatedAlbum).To(Equal(*originalAlbum)) - - ag.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything) - }) - - It("returns cached info and triggers background refresh when info is expired", func() { - now := time.Now() - expiredTime := now.Add(-conf.Server.DevAlbumInfoTimeToLive * 2) - originalAlbum := &model.Album{ - ID: "al-expired", - Name: "Expired Album", - AlbumArtist: "Expired Artist", - ExternalUrl: "http://expired.com/album", - Description: "Expired Desc", - LargeImageUrl: "http://expired.com/large.jpg", - ExternalInfoUpdatedAt: new(expiredTime), - } - mockAlbumRepo.SetData(model.Albums{*originalAlbum}) - - updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-expired") - - Expect(err).NotTo(HaveOccurred()) - Expect(updatedAlbum).NotTo(BeNil()) - Expect(*updatedAlbum).To(Equal(*originalAlbum)) - - ag.AssertNotCalled(GinkgoT(), "GetAlbumInfo", mock.Anything, mock.Anything, mock.Anything, mock.Anything) - }) - - It("returns error when agent fails to get album info", func() { - originalAlbum := &model.Album{ - ID: "al-agent-error", - Name: "Agent Error Album", - AlbumArtist: "Agent Error Artist", - MbzAlbumID: "mbid-agent-error", - } - mockAlbumRepo.SetData(model.Albums{*originalAlbum}) - - expectedErr := errors.New("agent communication failed") - ag.On("GetAlbumInfo", ctx, "Agent Error Album", "Agent Error Artist", "mbid-agent-error").Return(nil, expectedErr) - - updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-agent-error") - - Expect(err).To(MatchError(expectedErr)) - Expect(updatedAlbum).To(BeNil()) - ag.AssertExpectations(GinkgoT()) - }) - - It("returns original album when agent returns ErrNotFound", func() { - originalAlbum := &model.Album{ - ID: "al-agent-notfound", - Name: "Agent NotFound Album", - AlbumArtist: "Agent NotFound Artist", - MbzAlbumID: "mbid-agent-notfound", - } - mockAlbumRepo.SetData(model.Albums{*originalAlbum}) - - ag.On("GetAlbumInfo", ctx, "Agent NotFound Album", "Agent NotFound Artist", "mbid-agent-notfound").Return(nil, agents.ErrNotFound) - - updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-agent-notfound") - - Expect(err).NotTo(HaveOccurred()) - Expect(updatedAlbum).NotTo(BeNil()) - Expect(*updatedAlbum).To(Equal(*originalAlbum)) - Expect(updatedAlbum.ExternalInfoUpdatedAt).To(BeNil()) - - ag.AssertExpectations(GinkgoT()) - }) - - It("returns the original album, unstamped, when the agents are throttled", func() { - originalAlbum := &model.Album{ - ID: "al-throttled", - Name: "Throttled Album", - AlbumArtist: "Throttled Artist", - MbzAlbumID: "mbid-throttled", - } - mockAlbumRepo.SetData(model.Albums{*originalAlbum}) - - ag.On("GetAlbumInfo", ctx, "Throttled Album", "Throttled Artist", "mbid-throttled"). - Return(nil, agents.ErrRetryLater) - - updatedAlbum, err := p.UpdateAlbumInfo(ctx, "al-throttled") - - Expect(err).NotTo(HaveOccurred()) - Expect(updatedAlbum).NotTo(BeNil()) - Expect(*updatedAlbum).To(Equal(*originalAlbum)) - Expect(updatedAlbum.ExternalInfoUpdatedAt).To(BeNil()) - - ag.AssertExpectations(GinkgoT()) - }) -}) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go deleted file mode 100644 index 5e2087d35..000000000 --- a/core/external/provider_updateartistinfo_test.go +++ /dev/null @@ -1,355 +0,0 @@ -package external_test - -import ( - "context" - "errors" - "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" - . "github.com/onsi/gomega" - "github.com/stretchr/testify/mock" -) - -func init() { - log.SetLevel(log.LevelDebug) -} - -var _ = Describe("Provider - UpdateArtistInfo", func() { - var ( - ctx context.Context - p external.Provider - ds *tests.MockDataStore - ag *mockAgents - mockArtistRepo *tests.MockArtistRepo - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DevArtistInfoTimeToLive = 1 * time.Hour - ctx = GinkgoT().Context() - ds = new(tests.MockDataStore) - ag = new(mockAgents) - p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) - mockArtistRepo = ds.Artist().(*tests.MockArtistRepo) - }) - - It("returns error when artist is not found", func() { - artist, err := p.UpdateArtistInfo(ctx, "ar-not-found", 10, false) - - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(artist).To(BeNil()) - ag.AssertNotCalled(GinkgoT(), "GetArtistMBID") - ag.AssertNotCalled(GinkgoT(), "GetArtistImages") - ag.AssertNotCalled(GinkgoT(), "GetArtistBiography") - ag.AssertNotCalled(GinkgoT(), "GetArtistURL") - ag.AssertNotCalled(GinkgoT(), "GetSimilarArtists") - }) - - It("populates info when artist exists but has no external info", func() { - originalArtist := &model.Artist{ - ID: "ar-existing", - Name: "Test Artist", - } - mockArtistRepo.SetData(model.Artists{*originalArtist}) - - expectedMBID := "mbid-artist-123" - expectedBio := "Artist Bio" - expectedURL := "http://artist.url" - expectedImages := []agents.ExternalImage{ - {URL: "http://large.jpg", Size: 300}, - {URL: "http://medium.jpg", Size: 200}, - {URL: "http://small.jpg", Size: 100}, - } - rawSimilar := []agents.Artist{ - {Name: "Similar Artist 1", MBID: "mbid-similar-1"}, - {Name: "Similar Artist 2", MBID: "mbid-similar-2"}, - {Name: "Similar Artist 3", MBID: "mbid-similar-3"}, - } - similarInDS := model.Artist{ID: "ar-similar-2", Name: "Similar Artist 2"} - - ag.On("GetArtistMBID", ctx, "ar-existing", "Test Artist").Return(expectedMBID, nil).Once() - ag.On("GetArtistImages", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedImages, nil).Once() - ag.On("GetArtistBiography", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedBio, nil).Once() - ag.On("GetArtistURL", ctx, "ar-existing", "Test Artist", expectedMBID).Return(expectedURL, nil).Once() - ag.On("GetSimilarArtists", ctx, "ar-existing", "Test Artist", expectedMBID, 100).Return(rawSimilar, nil).Once() - - mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS}) - - updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-existing", 10, false) - - Expect(err).NotTo(HaveOccurred()) - Expect(updatedArtist).NotTo(BeNil()) - Expect(updatedArtist.ID).To(Equal("ar-existing")) - Expect(updatedArtist.MbzArtistID).To(Equal(expectedMBID)) - Expect(updatedArtist.Biography).To(Equal("Artist Bio")) - Expect(updatedArtist.ExternalUrl).To(Equal(expectedURL)) - Expect(updatedArtist.LargeImageUrl).To(Equal("http://large.jpg")) - Expect(updatedArtist.MediumImageUrl).To(Equal("http://medium.jpg")) - Expect(updatedArtist.SmallImageUrl).To(Equal("http://small.jpg")) - Expect(updatedArtist.ExternalInfoUpdatedAt).NotTo(BeNil()) - Expect(*updatedArtist.ExternalInfoUpdatedAt).To(BeTemporally("~", time.Now(), time.Second)) - - Expect(updatedArtist.SimilarArtists).To(HaveLen(1)) - Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-2")) - Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar Artist 2")) - - ag.AssertExpectations(GinkgoT()) - }) - - // Stamping a throttled round would cache the empty result for the whole TTL. - It("does not stamp ExternalInfoUpdatedAt when the agents are throttled", func() { - originalArtist := &model.Artist{ID: "ar-throttled", Name: "Throttled Artist"} - mockArtistRepo.SetData(model.Artists{*originalArtist}) - - ag.On("GetArtistMBID", ctx, "ar-throttled", "Throttled Artist").Return("", agents.ErrRetryLater).Once() - ag.On("GetArtistImages", ctx, "ar-throttled", "Throttled Artist", "").Return(nil, agents.ErrRetryLater).Once() - ag.On("GetArtistBiography", ctx, "ar-throttled", "Throttled Artist", "").Return("", agents.ErrRetryLater).Once() - ag.On("GetArtistURL", ctx, "ar-throttled", "Throttled Artist", "").Return("", agents.ErrRetryLater).Once() - ag.On("GetSimilarArtists", ctx, "ar-throttled", "Throttled Artist", "", 100).Return(nil, agents.ErrRetryLater).Once() - - updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-throttled", 10, false) - - Expect(err).ToNot(HaveOccurred()) - Expect(updatedArtist).NotTo(BeNil()) - Expect(updatedArtist.ExternalInfoUpdatedAt).To(BeNil()) - ag.AssertExpectations(GinkgoT()) - }) - - It("preserves decoded plain text in biography storage", func() { - originalArtist := &model.Artist{ - ID: "ar-encoded-bio", - 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{ - ID: "ar-cached", - Name: "Cached Artist", - MbzArtistID: "mbid-cached", - ExternalUrl: "http://cached.url", - Biography: "Cached Bio", - LargeImageUrl: "http://cached_large.jpg", - ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), - SimilarArtists: model.Artists{ - {ID: "ar-similar-present", Name: "Similar Present"}, - {ID: "ar-similar-absent", Name: "Similar Absent"}, - }, - } - similarInDS := model.Artist{ID: "ar-similar-present", Name: "Similar Present Updated"} - mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS}) - - updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-cached", 5, false) - - Expect(err).NotTo(HaveOccurred()) - Expect(updatedArtist).NotTo(BeNil()) - Expect(updatedArtist.ID).To(Equal(originalArtist.ID)) - Expect(updatedArtist.Name).To(Equal(originalArtist.Name)) - Expect(updatedArtist.MbzArtistID).To(Equal(originalArtist.MbzArtistID)) - Expect(updatedArtist.ExternalUrl).To(Equal(originalArtist.ExternalUrl)) - Expect(updatedArtist.Biography).To(Equal(originalArtist.Biography)) - Expect(updatedArtist.LargeImageUrl).To(Equal(originalArtist.LargeImageUrl)) - Expect(updatedArtist.ExternalInfoUpdatedAt).To(Equal(originalArtist.ExternalInfoUpdatedAt)) - - Expect(updatedArtist.SimilarArtists).To(HaveLen(1)) - Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID)) - Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name)) - - ag.AssertNotCalled(GinkgoT(), "GetArtistMBID") - ag.AssertNotCalled(GinkgoT(), "GetArtistImages") - ag.AssertNotCalled(GinkgoT(), "GetArtistBiography") - ag.AssertNotCalled(GinkgoT(), "GetArtistURL") - }) - - It("returns cached info and triggers background refresh when info is expired", func() { - now := time.Now() - expiredTime := now.Add(-conf.Server.DevArtistInfoTimeToLive * 2) - originalArtist := &model.Artist{ - ID: "ar-expired", - Name: "Expired Artist", - ExternalInfoUpdatedAt: new(expiredTime), - SimilarArtists: model.Artists{ - {ID: "ar-exp-similar", Name: "Expired Similar"}, - }, - } - similarInDS := model.Artist{ID: "ar-exp-similar", Name: "Expired Similar Updated"} - mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS}) - - updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-expired", 5, false) - - Expect(err).NotTo(HaveOccurred()) - Expect(updatedArtist).NotTo(BeNil()) - Expect(updatedArtist.ID).To(Equal(originalArtist.ID)) - Expect(updatedArtist.Name).To(Equal(originalArtist.Name)) - Expect(updatedArtist.ExternalInfoUpdatedAt).To(Equal(originalArtist.ExternalInfoUpdatedAt)) - - Expect(updatedArtist.SimilarArtists).To(HaveLen(1)) - Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID)) - Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name)) - - ag.AssertNotCalled(GinkgoT(), "GetArtistMBID") - ag.AssertNotCalled(GinkgoT(), "GetArtistImages") - ag.AssertNotCalled(GinkgoT(), "GetArtistBiography") - ag.AssertNotCalled(GinkgoT(), "GetArtistURL") - }) - - It("includes non-present similar artists when includeNotPresent is true", func() { - now := time.Now() - originalArtist := &model.Artist{ - ID: "ar-similar-test", - Name: "Similar Test Artist", - ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), - SimilarArtists: model.Artists{ - {ID: "ar-sim-present", Name: "Similar Present"}, - {ID: "", Name: "Similar Absent Raw"}, - {ID: "ar-sim-absent-lookup", Name: "Similar Absent Lookup"}, - }, - } - similarInDS := model.Artist{ID: "ar-sim-present", Name: "Similar Present Updated"} - mockArtistRepo.SetData(model.Artists{*originalArtist, similarInDS}) - - updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-similar-test", 5, true) - - Expect(err).NotTo(HaveOccurred()) - Expect(updatedArtist).NotTo(BeNil()) - - Expect(updatedArtist.SimilarArtists).To(HaveLen(3)) - Expect(updatedArtist.SimilarArtists[0].ID).To(Equal(similarInDS.ID)) - Expect(updatedArtist.SimilarArtists[0].Name).To(Equal(similarInDS.Name)) - Expect(updatedArtist.SimilarArtists[1].ID).To(BeEmpty()) - Expect(updatedArtist.SimilarArtists[1].Name).To(Equal("Similar Absent Raw")) - Expect(updatedArtist.SimilarArtists[2].ID).To(BeEmpty()) - Expect(updatedArtist.SimilarArtists[2].Name).To(Equal("Similar Absent Lookup")) - }) - - It("updates ArtistInfo even if an optional agent call fails", func() { - originalArtist := &model.Artist{ - ID: "ar-agent-fail", - Name: "Agent Fail Artist", - } - mockArtistRepo.SetData(model.Artists{*originalArtist}) - - expectedErr := errors.New("agent MBID failed") - ag.On("GetArtistMBID", ctx, "ar-agent-fail", "Agent Fail Artist").Return("", expectedErr).Once() - ag.On("GetArtistImages", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return(nil, nil).Maybe() - ag.On("GetArtistBiography", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return("", nil).Maybe() - ag.On("GetArtistURL", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything).Return("", nil).Maybe() - ag.On("GetSimilarArtists", ctx, "ar-agent-fail", "Agent Fail Artist", mock.Anything, 100).Return(nil, nil).Maybe() - - updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-agent-fail", 10, false) - - Expect(err).NotTo(HaveOccurred()) - Expect(updatedArtist).NotTo(BeNil()) - Expect(updatedArtist.ID).To(Equal("ar-agent-fail")) - ag.AssertExpectations(GinkgoT()) - }) - - It("matches similar artists by ID first when agent provides IDs", func() { - originalArtist := &model.Artist{ - ID: "ar-id-match", - Name: "ID Match Artist", - } - similarByID := model.Artist{ID: "ar-similar-by-id", Name: "Similar By ID", MbzArtistID: "mbid-similar"} - mockArtistRepo.SetData(model.Artists{*originalArtist, similarByID}) - - // Agent returns similar artist with ID (highest priority matching) - rawSimilar := []agents.Artist{ - {ID: "ar-similar-by-id", Name: "Different Name", MBID: "different-mbid"}, - } - - ag.On("GetArtistMBID", ctx, "ar-id-match", "ID Match Artist").Return("", nil).Once() - ag.On("GetArtistImages", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return(nil, nil).Maybe() - ag.On("GetArtistBiography", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return("", nil).Maybe() - ag.On("GetArtistURL", ctx, "ar-id-match", "ID Match Artist", mock.Anything).Return("", nil).Maybe() - ag.On("GetSimilarArtists", ctx, "ar-id-match", "ID Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once() - - updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-id-match", 10, false) - - Expect(err).NotTo(HaveOccurred()) - Expect(updatedArtist.SimilarArtists).To(HaveLen(1)) - // Should match by ID, not by name or MBID - Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-id")) - Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By ID")) - }) - - It("matches similar artists by MBID when ID is empty", func() { - originalArtist := &model.Artist{ - ID: "ar-mbid-match", - Name: "MBID Match Artist", - } - similarByMBID := model.Artist{ID: "ar-similar-by-mbid", Name: "Similar By MBID", MbzArtistID: "mbid-similar"} - mockArtistRepo.SetData(model.Artists{*originalArtist, similarByMBID}) - - // Agent returns similar artist with only MBID (no ID) - rawSimilar := []agents.Artist{ - {Name: "Different Name", MBID: "mbid-similar"}, - } - - ag.On("GetArtistMBID", ctx, "ar-mbid-match", "MBID Match Artist").Return("", nil).Once() - ag.On("GetArtistImages", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return(nil, nil).Maybe() - ag.On("GetArtistBiography", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return("", nil).Maybe() - ag.On("GetArtistURL", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything).Return("", nil).Maybe() - ag.On("GetSimilarArtists", ctx, "ar-mbid-match", "MBID Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once() - - updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-mbid-match", 10, false) - - Expect(err).NotTo(HaveOccurred()) - Expect(updatedArtist.SimilarArtists).To(HaveLen(1)) - // Should match by MBID since ID was empty - Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-mbid")) - Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By MBID")) - }) - - It("falls back to name matching when ID and MBID don't match", func() { - originalArtist := &model.Artist{ - ID: "ar-name-match", - Name: "Name Match Artist", - } - similarByName := model.Artist{ID: "ar-similar-by-name", Name: "Similar By Name"} - mockArtistRepo.SetData(model.Artists{*originalArtist, similarByName}) - - // Agent returns similar artist with non-matching ID and MBID - rawSimilar := []agents.Artist{ - {ID: "non-existent-id", Name: "Similar By Name", MBID: "non-existent-mbid"}, - } - - ag.On("GetArtistMBID", ctx, "ar-name-match", "Name Match Artist").Return("", nil).Once() - ag.On("GetArtistImages", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return(nil, nil).Maybe() - ag.On("GetArtistBiography", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return("", nil).Maybe() - ag.On("GetArtistURL", ctx, "ar-name-match", "Name Match Artist", mock.Anything).Return("", nil).Maybe() - ag.On("GetSimilarArtists", ctx, "ar-name-match", "Name Match Artist", mock.Anything, 100).Return(rawSimilar, nil).Once() - - updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-name-match", 10, false) - - Expect(err).NotTo(HaveOccurred()) - Expect(updatedArtist.SimilarArtists).To(HaveLen(1)) - // Should fall back to name matching since ID and MBID didn't match - Expect(updatedArtist.SimilarArtists[0].ID).To(Equal("ar-similar-by-name")) - Expect(updatedArtist.SimilarArtists[0].Name).To(Equal("Similar By Name")) - }) -}) diff --git a/core/external_metadata.go b/core/external_metadata.go new file mode 100644 index 000000000..972c0c756 --- /dev/null +++ b/core/external_metadata.go @@ -0,0 +1,586 @@ +package core + +import ( + "context" + "errors" + "net/url" + "sort" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/deluan/sanitize" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" + _ "github.com/navidrome/navidrome/core/agents/lastfm" + _ "github.com/navidrome/navidrome/core/agents/listenbrainz" + _ "github.com/navidrome/navidrome/core/agents/spotify" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/number" + "golang.org/x/sync/errgroup" +) + +const ( + unavailableArtistID = "-1" + maxSimilarArtists = 100 + refreshDelay = 5 * time.Second + refreshTimeout = 15 * time.Second + refreshQueueLength = 2000 +) + +type ExternalMetadata interface { + UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) + UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error) + SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) + TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error) + ArtistImage(ctx context.Context, id string) (*url.URL, error) + AlbumImage(ctx context.Context, id string) (*url.URL, error) +} + +type externalMetadata struct { + ds model.DataStore + ag *agents.Agents + artistQueue chan<- *auxArtist + albumQueue chan<- *auxAlbum +} + +type auxAlbum struct { + model.Album + Name string +} + +type auxArtist struct { + model.Artist + Name string +} + +func NewExternalMetadata(ds model.DataStore, agents *agents.Agents) ExternalMetadata { + e := &externalMetadata{ds: ds, ag: agents} + e.artistQueue = startRefreshQueue(context.TODO(), e.populateArtistInfo) + e.albumQueue = startRefreshQueue(context.TODO(), e.populateAlbumInfo) + return e +} + +func (e *externalMetadata) getAlbum(ctx context.Context, id string) (*auxAlbum, error) { + var entity interface{} + entity, err := model.GetEntityByID(ctx, e.ds, id) + if err != nil { + return nil, err + } + + var album auxAlbum + switch v := entity.(type) { + case *model.Album: + album.Album = *v + album.Name = clearName(v.Name) + case *model.MediaFile: + return e.getAlbum(ctx, v.AlbumID) + default: + return nil, model.ErrNotFound + } + return &album, nil +} + +func (e *externalMetadata) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error) { + album, err := e.getAlbum(ctx, id) + if err != nil { + log.Info(ctx, "Not found", "id", id) + return nil, err + } + + if album.ExternalInfoUpdatedAt.IsZero() { + log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", album.ExternalInfoUpdatedAt, "id", id, "name", album.Name) + err = e.populateAlbumInfo(ctx, album) + if err != nil { + return nil, err + } + } + + if time.Since(album.ExternalInfoUpdatedAt) > conf.Server.DevAlbumInfoTimeToLive { + log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name) + enqueueRefresh(e.albumQueue, album) + } + + return &album.Album, nil +} + +func (e *externalMetadata) populateAlbumInfo(ctx context.Context, album *auxAlbum) error { + start := time.Now() + info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID) + if errors.Is(err, agents.ErrNotFound) { + return nil + } + if err != nil { + log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", album.Name, "artist", album.AlbumArtist, + "elapsed", time.Since(start), err) + return err + } + + album.ExternalInfoUpdatedAt = time.Now() + album.ExternalUrl = info.URL + + if info.Description != "" { + album.Description = info.Description + } + + if len(info.Images) > 0 { + sort.Slice(info.Images, func(i, j int) bool { + return info.Images[i].Size > info.Images[j].Size + }) + + album.LargeImageUrl = info.Images[0].URL + + if len(info.Images) >= 2 { + album.MediumImageUrl = info.Images[1].URL + } + + if len(info.Images) >= 3 { + album.SmallImageUrl = info.Images[2].URL + } + } + + err = e.ds.Album(ctx).Put(&album.Album) + if err != nil { + log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", album.Name, + "elapsed", time.Since(start), err) + } else { + log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start)) + } + + return nil +} + +func (e *externalMetadata) getArtist(ctx context.Context, id string) (*auxArtist, error) { + var entity interface{} + entity, err := model.GetEntityByID(ctx, e.ds, id) + if err != nil { + return nil, err + } + + var artist auxArtist + switch v := entity.(type) { + case *model.Artist: + artist.Artist = *v + artist.Name = clearName(v.Name) + case *model.MediaFile: + return e.getArtist(ctx, v.ArtistID) + case *model.Album: + return e.getArtist(ctx, v.AlbumArtistID) + default: + return nil, model.ErrNotFound + } + return &artist, nil +} + +// Replace some Unicode chars with their equivalent ASCII +func clearName(name string) string { + name = strings.ReplaceAll(name, "–", "-") + name = strings.ReplaceAll(name, "‐", "-") + name = strings.ReplaceAll(name, "“", `"`) + name = strings.ReplaceAll(name, "”", `"`) + name = strings.ReplaceAll(name, "‘", `'`) + name = strings.ReplaceAll(name, "’", `'`) + return name +} + +func (e *externalMetadata) UpdateArtistInfo(ctx context.Context, id string, similarCount int, includeNotPresent bool) (*model.Artist, error) { + artist, err := e.refreshArtistInfo(ctx, id) + if err != nil { + return nil, err + } + + err = e.loadSimilar(ctx, artist, similarCount, includeNotPresent) + return &artist.Artist, err +} + +func (e *externalMetadata) refreshArtistInfo(ctx context.Context, id string) (*auxArtist, error) { + artist, err := e.getArtist(ctx, id) + if err != nil { + return nil, err + } + + // If we don't have any info, retrieves it now + if artist.ExternalInfoUpdatedAt.IsZero() { + log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", artist.ExternalInfoUpdatedAt, "id", id, "name", artist.Name) + err := e.populateArtistInfo(ctx, artist) + if err != nil { + return nil, err + } + } + + // If info is expired, trigger a populateArtistInfo in the background + if time.Since(artist.ExternalInfoUpdatedAt) > conf.Server.DevArtistInfoTimeToLive { + log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", artist.ExternalInfoUpdatedAt, "name", artist.Name) + enqueueRefresh(e.artistQueue, artist) + } + return artist, nil +} + +func (e *externalMetadata) populateArtistInfo(ctx context.Context, artist *auxArtist) error { + start := time.Now() + // Get MBID first, if it is not yet available + if artist.MbzArtistID == "" { + mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artist.Name) + if mbid != "" && err == nil { + artist.MbzArtistID = mbid + } + } + + // Call all registered agents and collect information + g := errgroup.Group{} + g.SetLimit(2) + g.Go(func() error { e.callGetImage(ctx, e.ag, artist); return nil }) + g.Go(func() error { e.callGetBiography(ctx, e.ag, artist); return nil }) + g.Go(func() error { e.callGetURL(ctx, e.ag, artist); return nil }) + g.Go(func() error { e.callGetSimilar(ctx, e.ag, artist, maxSimilarArtists, true); return nil }) + _ = g.Wait() + + if utils.IsCtxDone(ctx) { + log.Warn(ctx, "ArtistInfo update canceled", "elapsed", "id", artist.ID, "name", artist.Name, time.Since(start), ctx.Err()) + return ctx.Err() + } + + artist.ExternalInfoUpdatedAt = time.Now() + err := e.ds.Artist(ctx).Put(&artist.Artist) + if err != nil { + log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artist.Name, + "elapsed", time.Since(start), err) + } else { + log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start)) + } + return nil +} + +func (e *externalMetadata) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) { + artist, err := e.getArtist(ctx, id) + if err != nil { + return nil, err + } + + e.callGetSimilar(ctx, e.ag, artist, 15, false) + if utils.IsCtxDone(ctx) { + log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) + return nil, ctx.Err() + } + + weightedSongs := utils.NewWeightedRandomChooser() + addArtist := func(a model.Artist, weightedSongs *utils.WeightedChooser, count, artistWeight int) error { + if utils.IsCtxDone(ctx) { + log.Warn(ctx, "SimilarSongs call canceled", ctx.Err()) + return ctx.Err() + } + + topCount := number.Max(count, 20) + topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, Artist: a}, topCount) + if err != nil { + log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err) + return nil + } + + weight := topCount * (4 + artistWeight) + for _, mf := range topSongs { + weightedSongs.Add(mf, weight) + weight -= 4 + } + return nil + } + + err = addArtist(artist.Artist, weightedSongs, count, 10) + if err != nil { + return nil, err + } + for _, a := range artist.SimilarArtists { + err := addArtist(a, weightedSongs, count, 0) + if err != nil { + return nil, err + } + } + + var similarSongs model.MediaFiles + for len(similarSongs) < count && weightedSongs.Size() > 0 { + s, err := weightedSongs.GetAndRemove() + if err != nil { + log.Warn(ctx, "Error getting weighted song", err) + continue + } + similarSongs = append(similarSongs, s.(model.MediaFile)) + } + + return similarSongs, nil +} + +func (e *externalMetadata) ArtistImage(ctx context.Context, id string) (*url.URL, error) { + artist, err := e.getArtist(ctx, id) + if err != nil { + return nil, err + } + + e.callGetImage(ctx, e.ag, artist) + if utils.IsCtxDone(ctx) { + log.Warn(ctx, "ArtistImage call canceled", ctx.Err()) + return nil, ctx.Err() + } + + imageUrl := artist.ArtistImageUrl() + if imageUrl == "" { + return nil, agents.ErrNotFound + } + return url.Parse(imageUrl) +} + +func (e *externalMetadata) AlbumImage(ctx context.Context, id string) (*url.URL, error) { + album, err := e.getAlbum(ctx, id) + if err != nil { + return nil, err + } + + info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID) + if errors.Is(err, agents.ErrNotFound) { + return nil, err + } + if utils.IsCtxDone(ctx) { + log.Warn(ctx, "AlbumImage call canceled", ctx.Err()) + return nil, ctx.Err() + } + + // Return the biggest image + var img agents.ExternalImage + for _, i := range info.Images { + if img.Size <= i.Size { + img = i + } + } + if img.URL == "" { + return nil, agents.ErrNotFound + } + return url.Parse(img.URL) +} + +func (e *externalMetadata) TopSongs(ctx context.Context, artistName string, count int) (model.MediaFiles, error) { + artist, err := e.findArtistByName(ctx, artistName) + if err != nil { + log.Error(ctx, "Artist not found", "name", artistName, err) + return nil, nil + } + + return e.getMatchingTopSongs(ctx, e.ag, artist, count) +} + +func (e *externalMetadata) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) { + songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count) + if errors.Is(err, agents.ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + + var mfs model.MediaFiles + for _, t := range songs { + mf, err := e.findMatchingTrack(ctx, t.MBID, artist.ID, t.Name) + if err != nil { + continue + } + mfs = append(mfs, *mf) + if len(mfs) == count { + break + } + } + if len(mfs) == 0 { + log.Debug(ctx, "No matching top songs found", "name", artist.Name) + } else { + log.Debug(ctx, "Found matching top songs", "name", artist.Name, "numSongs", len(mfs)) + } + return mfs, nil +} + +func (e *externalMetadata) findMatchingTrack(ctx context.Context, mbid string, artistID, title string) (*model.MediaFile, error) { + if mbid != "" { + mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"mbz_track_id": mbid}, + }) + if err == nil && len(mfs) > 0 { + return &mfs[0], nil + } + return e.findMatchingTrack(ctx, "", artistID, title) + } + mfs, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Or{ + squirrel.Eq{"artist_id": artistID}, + squirrel.Eq{"album_artist_id": artistID}, + }, + squirrel.Like{"order_title": strings.TrimSpace(sanitize.Accents(title))}, + }, + Sort: "starred desc, rating desc, year asc", + Max: 1, + }) + if err != nil || len(mfs) == 0 { + return nil, model.ErrNotFound + } + return &mfs[0], nil +} + +func (e *externalMetadata) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) { + artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name, artist.MbzArtistID) + if err != nil { + return + } + artist.ExternalUrl = artisURL +} + +func (e *externalMetadata) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) { + bio, err := agent.GetArtistBiography(ctx, artist.ID, clearName(artist.Name), artist.MbzArtistID) + if err != nil { + return + } + bio = utils.SanitizeText(bio) + bio = strings.ReplaceAll(bio, "\n", " ") + artist.Biography = strings.ReplaceAll(bio, " images[j].Size }) + + if len(images) >= 1 { + artist.LargeImageUrl = images[0].URL + } + if len(images) >= 2 { + artist.MediumImageUrl = images[1].URL + } + if len(images) >= 3 { + artist.SmallImageUrl = images[2].URL + } +} + +func (e *externalMetadata) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist, + limit int, includeNotPresent bool) { + similar, err := agent.GetSimilarArtists(ctx, artist.ID, artist.Name, artist.MbzArtistID, limit) + if len(similar) == 0 || err != nil { + return + } + start := time.Now() + sa, err := e.mapSimilarArtists(ctx, similar, includeNotPresent) + log.Debug(ctx, "Mapped Similar Artists", "artist", artist.Name, "numSimilar", len(sa), "elapsed", time.Since(start)) + if err != nil { + return + } + artist.SimilarArtists = sa +} + +func (e *externalMetadata) mapSimilarArtists(ctx context.Context, similar []agents.Artist, includeNotPresent bool) (model.Artists, error) { + var result model.Artists + var notPresent []string + + // First select artists that are present. + for _, s := range similar { + sa, err := e.findArtistByName(ctx, s.Name) + if err != nil { + notPresent = append(notPresent, s.Name) + continue + } + result = append(result, sa.Artist) + } + + // Then fill up with non-present artists + if includeNotPresent { + for _, s := range notPresent { + sa := model.Artist{ID: unavailableArtistID, Name: s} + result = append(result, sa) + } + } + + return result, nil +} + +func (e *externalMetadata) findArtistByName(ctx context.Context, artistName string) (*auxArtist, error) { + artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Like{"artist.name": artistName}, + Max: 1, + }) + if err != nil { + return nil, err + } + if len(artists) == 0 { + return nil, model.ErrNotFound + } + artist := &auxArtist{ + Artist: artists[0], + Name: clearName(artists[0].Name), + } + return artist, nil +} + +func (e *externalMetadata) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error { + var ids []string + for _, sa := range artist.SimilarArtists { + if sa.ID == unavailableArtistID { + continue + } + ids = append(ids, sa.ID) + } + + similar, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"artist.id": ids}, + }) + if err != nil { + log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name, err) + return err + } + + // Use a map and iterate through original array, to keep the same order + artistMap := make(map[string]model.Artist) + for _, sa := range similar { + artistMap[sa.ID] = sa + } + + var loaded model.Artists + for _, sa := range artist.SimilarArtists { + if len(loaded) >= count { + break + } + la, ok := artistMap[sa.ID] + if !ok { + if !includeNotPresent { + continue + } + la = sa + la.ID = unavailableArtistID + } + loaded = append(loaded, la) + } + artist.SimilarArtists = loaded + return nil +} + +func startRefreshQueue[T any](ctx context.Context, processFn func(context.Context, T) error) chan<- T { + queue := make(chan T, refreshQueueLength) + go func() { + for { + time.Sleep(refreshDelay) + ctx, cancel := context.WithTimeout(ctx, refreshTimeout) + select { + case a := <-queue: + _ = processFn(ctx, a) + cancel() + case <-ctx.Done(): + cancel() + break + } + } + }() + return queue +} + +func enqueueRefresh[T any](queue chan<- T, item T) { + select { + case queue <- item: + default: // It is ok to miss a refresh + } +} diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index af59178af..4085d56c6 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -1,349 +1,74 @@ package ffmpeg import ( - "bytes" "context" - "encoding/json" "errors" "fmt" "io" - "io/fs" "os" "os/exec" - "path/filepath" - "slices" "strconv" "strings" "sync" - "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" ) -// TranscodeOptions contains all parameters for a transcoding operation. -type TranscodeOptions struct { - Command string // DB command template (used to detect custom vs default) - Format string // Target format (mp3, opus, aac, flac) - FilePath string - BitRate int // kbps, 0 = codec default - SampleRate int // 0 = no constraint - Channels int // 0 = no constraint - BitDepth int // 0 = no constraint; valid values: 16, 24, 32 - Offset int // seconds - Duration float32 // seconds; 0 = unknown. Only used to repair a piped FLAC header. -} - -// AudioProbeResult contains authoritative audio stream properties from ffprobe. -type AudioProbeResult struct { - Codec string `json:"codec"` - Profile string `json:"profile,omitempty"` - BitRate int `json:"bitRate"` - SampleRate int `json:"sampleRate"` - BitDepth int `json:"bitDepth"` - Channels int `json:"channels"` -} - type FFmpeg interface { - Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) + Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) - ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) - ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) + Probe(ctx context.Context, files []string) (string, error) CmdPath() (string, error) - IsAvailable() bool - IsProbeAvailable() bool - Version() string } 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 -" - probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s" + extractImageCmd = "ffmpeg -i %s -an -vcodec copy -f image2pipe -" + probeCmd = "ffmpeg %s -f ffmetadata" ) type ffmpeg struct{} -func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) { +func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error) { if _, err := ffmpegCmd(); err != nil { return nil, err } - if err := fileExists(opts.FilePath); err != nil { - return nil, err - } - var args []string - if isDefaultCommand(opts.Format, opts.Command) { - args = buildDynamicArgs(opts) - } else { - args = buildTemplateArgs(opts) - } - out, err := e.start(ctx, args) - if err != nil { - return nil, err - } - return patchFLACDuration(out, opts.Duration-float32(opts.Offset)), nil -} - -func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) { - cmdPath, err := ffmpegCmd() - if err != nil { - return nil, err - } - if !animWebP.has(cmdPath, "libwebp_anim") { - return nil, ErrAnimatedWebPUnsupported - } - - args := []string{cmdPath, "-i", "pipe:0"} - if maxSize > 0 { - vf := fmt.Sprintf("scale='min(%d,iw)':'min(%d,ih)':force_original_aspect_ratio=decrease", maxSize, maxSize) - args = append(args, "-vf", vf) - } - args = append(args, "-loop", "0", "-c:v", "libwebp_anim", - "-quality", strconv.Itoa(quality), "-f", "webp", "-") - - 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 + args := createFFmpegCommand(command, path, maxBitRate) + return e.start(ctx, args) } func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) { if _, err := ffmpegCmd(); err != nil { return nil, err } - if err := fileExists(path); err != nil { - return nil, err - } - args := createFFmpegCommand(extractImageCmd, path, 0, 0) + args := createFFmpegCommand(extractImageCmd, path, 0) return e.start(ctx, args) } -func fileExists(path string) error { - s, err := os.Stat(path) - if err != nil { - return err - } - if s.IsDir() { - return fmt.Errorf("'%s' is a directory", path) - } - return nil -} - -func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) { +func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) { if _, err := ffmpegCmd(); err != nil { - return nil, err + return "", err } - if err := fileExists(filePath); err != nil { - return nil, &ProbeError{Path: filePath, Reason: fileAccessReason(err), - NotFound: errors.Is(err, fs.ErrNotExist), err: err} - } - args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0) - log.Trace(ctx, "Executing ffprobe command", "args", args) + args := createProbeCommand(probeCmd, files) + log.Trace(ctx, "Executing ffmpeg command", "args", args) cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec - output, err := cmd.Output() - if err != nil { - return nil, &ProbeError{Path: filePath, Reason: probeClientReason(err, filePath), err: err} - } - result, err := parseProbeOutput(output) - if err != nil { - return nil, &ProbeError{Path: filePath, Reason: err.Error(), err: err} - } - return result, nil -} - -// ProbeError reports an ffprobe failure. Reason is a path-free message safe to -// expose to clients; the wrapped cause carries the full detail for logging. -// NotFound marks the media file itself as missing — a launch failure of a -// deleted ffprobe binary also wraps fs.ErrNotExist, so callers must not infer -// it from the error chain. -type ProbeError struct { - Path string - Reason string - NotFound bool - err error -} - -func (e *ProbeError) Error() string { - if e.err == nil { - return fmt.Sprintf("probe failed on %q: %s", e.Path, e.Reason) - } - return fmt.Sprintf("probe failed on %q: %s", e.Path, probeDetail(e.err)) -} - -// Unwrap exposes the underlying cause so callers can test it with errors.Is -// (e.g. fs.ErrNotExist to detect a missing file). -func (e *ProbeError) Unwrap() error { return e.err } - -// SafeReason returns the path-free reason, safe to send to clients. -func (e *ProbeError) SafeReason() string { return e.Reason } - -// fileAccessReason maps a stat failure to a clear, path-free reason, so a moved -// or unreadable file reads as "file not found" rather than a raw ffprobe message. -func fileAccessReason(err error) string { - switch { - case errors.Is(err, fs.ErrNotExist): - return "file not found" - case errors.Is(err, fs.ErrPermission): - return "permission denied" - default: - return "file not accessible" - } -} - -// probeDetail returns the full diagnostic for logging (may contain paths): -// ffprobe's stderr when present, otherwise the raw error text. -func probeDetail(err error) string { - if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && len(exitErr.Stderr) > 0 { - return strings.TrimSpace(string(exitErr.Stderr)) - } - return err.Error() -} - -// probeClientReason returns a path-free reason for an ffprobe execution failure: -// ffprobe's stderr with the file path stripped, or a generic reason when ffprobe -// couldn't run at all (its launch error may embed the binary path). -func probeClientReason(err error, path string) string { - exitErr, ok := errors.AsType[*exec.ExitError](err) - if !ok || len(exitErr.Stderr) == 0 { - return "could not read file" - } - return strings.TrimSpace(strings.ReplaceAll(string(exitErr.Stderr), path, "the file")) -} - -type probeOutput struct { - Streams []probeStream `json:"streams"` - Format probeFormat `json:"format"` -} - -type probeFormat struct { - BitRate string `json:"bit_rate"` -} - -type probeStream struct { - CodecName string `json:"codec_name"` - CodecType string `json:"codec_type"` - Profile string `json:"profile"` - SampleRate string `json:"sample_rate"` - BitRate string `json:"bit_rate"` - Channels int `json:"channels"` - BitsPerSample int `json:"bits_per_sample"` - BitsPerRawSample string `json:"bits_per_raw_sample"` -} - -func parseProbeOutput(data []byte) (*AudioProbeResult, error) { - var output probeOutput - if err := json.Unmarshal(data, &output); err != nil { - return nil, fmt.Errorf("parsing ffprobe output: %w", err) - } - - for _, s := range output.Streams { - if s.CodecType != "audio" { - continue - } - bitDepth := s.BitsPerSample - if bitDepth == 0 && s.BitsPerRawSample != "" { - bitDepth, _ = strconv.Atoi(s.BitsPerRawSample) - } - result := &AudioProbeResult{ - Codec: s.CodecName, - Channels: s.Channels, - BitDepth: bitDepth, - } - - // Profile: "unknown" → empty - if s.Profile != "" && !strings.EqualFold(s.Profile, "unknown") { - result.Profile = s.Profile - } - - // Sample rate: string → int - if s.SampleRate != "" { - result.SampleRate, _ = strconv.Atoi(s.SampleRate) - } - - // Bit rate: bps string → kbps int - if s.BitRate != "" { - bps, _ := strconv.Atoi(s.BitRate) - result.BitRate = bps / 1000 - } - - // Fallback to format-level bit_rate (needed for FLAC, Opus, etc.) - if result.BitRate == 0 && output.Format.BitRate != "" { - bps, _ := strconv.Atoi(output.Format.BitRate) - result.BitRate = bps / 1000 - } - - return result, nil - } - - return nil, fmt.Errorf("no audio stream found in ffprobe output") + output, _ := cmd.CombinedOutput() + return string(output), nil } func (e *ffmpeg) CmdPath() (string, error) { return ffmpegCmd() } -func (e *ffmpeg) IsAvailable() bool { - _, err := ffmpegCmd() - 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 { - cmd, err := ffmpegCmd() - if err != nil { - return "N/A" - } - out, err := exec.Command(cmd, "-version").CombinedOutput() // #nosec - if err != nil { - return "N/A" - } - parts := strings.Split(string(out), " ") - if len(parts) < 3 { - return "N/A" - } - return parts[2] -} - -func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) (io.ReadCloser, error) { +func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) { log.Trace(ctx, "Executing ffmpeg command", "cmd", args) j := &ffCmd{args: args} - if len(input) > 0 { - j.input = input[0] - } j.PipeReader, j.out = io.Pipe() - err := j.start(ctx) + err := j.start() if err != nil { return nil, err } @@ -353,25 +78,18 @@ func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) ( type ffCmd struct { *io.PipeReader - out *io.PipeWriter - args []string - cmd *exec.Cmd - input io.Reader // optional stdin source - stderr *bytes.Buffer + out *io.PipeWriter + args []string + cmd *exec.Cmd } -func (j *ffCmd) start(ctx context.Context) error { - cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec +func (j *ffCmd) start() error { + cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec cmd.Stdout = j.out - if j.input != nil { - cmd.Stdin = j.input - } - j.stderr = &bytes.Buffer{} - stderrWriter := &limitedWriter{buf: j.stderr, limit: 4096} - if log.IsGreaterOrEqualTo(log.LevelTrace) { - cmd.Stderr = io.MultiWriter(os.Stderr, stderrWriter) + if log.CurrentLevel() >= log.LevelTrace { + cmd.Stderr = os.Stderr } else { - cmd.Stderr = stderrWriter + cmd.Stderr = io.Discard } j.cmd = cmd @@ -383,12 +101,9 @@ func (j *ffCmd) start(ctx context.Context) error { func (j *ffCmd) wait() { if err := j.cmd.Wait(); err != nil { - if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { - errMsg := fmt.Sprintf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()) - if stderrOutput := strings.TrimSpace(j.stderr.String()); stderrOutput != "" { - errMsg += ": " + stderrOutput - } - _ = j.out.CloseWithError(errors.New(errMsg)) + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + _ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())) } else { _ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err)) } @@ -397,208 +112,46 @@ func (j *ffCmd) wait() { _ = j.out.Close() } -// limitedWriter wraps a bytes.Buffer and stops writing once the limit is reached. -// Writes that would exceed the limit are silently discarded to prevent unbounded memory usage. -type limitedWriter struct { - buf *bytes.Buffer - limit int -} - -func (w *limitedWriter) Write(p []byte) (int, error) { - n := len(p) - remaining := w.limit - w.buf.Len() - if remaining <= 0 { - return n, nil // Discard but report success to avoid breaking the writer - } - if len(p) > remaining { - p = p[:remaining] - } - w.buf.Write(p) - return n, nil // Always report full write to avoid ErrShortWrite from io.MultiWriter -} - -// formatCodecMap maps target format to ffmpeg codec flag. -var formatCodecMap = map[string]string{ - "mp3": "libmp3lame", - "opus": "libopus", - "aac": "aac", - "flac": "flac", -} - -// formatOutputMap maps target format to ffmpeg output format flag (-f). -var formatOutputMap = map[string]string{ - "mp3": "mp3", - "opus": "opus", - "aac": "adts", - "flac": "flac", -} - -// defaultCommands is used to detect whether a user has customized their transcoding command. -var defaultCommands = func() map[string]string { - m := make(map[string]string, len(consts.DefaultTranscodings)) - for _, t := range consts.DefaultTranscodings { - m[t.TargetFormat] = t.Command - } - return m -}() - -// isDefaultCommand returns true if the command matches the known default for this format. -func isDefaultCommand(format, command string) bool { - return defaultCommands[format] == command -} - -// buildDynamicArgs programmatically constructs ffmpeg arguments for known formats, -// including all transcoding parameters (bitrate, sample rate, channels). -func buildDynamicArgs(opts TranscodeOptions) []string { - cmdPath, _ := ffmpegCmd() - args := []string{cmdPath} - - if opts.Offset > 0 { - args = append(args, "-ss", strconv.Itoa(opts.Offset)) - } - - args = append(args, "-i", opts.FilePath) - args = append(args, "-map", "0:a:0") - - // Preserve source tags. -map_metadata 0 copies format-level tags (MP3/FLAC); - // -map_metadata 0:s:a:0 copies tags from the first audio stream (OPUS/OGG). - // Both are needed because the two source families store tags at different - // levels. Targeting the audio stream explicitly (s:a:0 rather than s:0) avoids - // pulling metadata from an embedded cover-art/video stream at index 0. Note: - // adts (AAC) output cannot hold tags, so these are a no-op there. - args = append(args, "-map_metadata", "0", "-map_metadata", "0:s:a:0") - - if codec, ok := formatCodecMap[opts.Format]; ok { - args = append(args, "-c:a", codec) - } - - if opts.BitRate > 0 { - args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k") - } - args = injectDynamicAudioFlags(args, opts) - - args = append(args, "-v", "0") - - if outputFmt, ok := formatOutputMap[opts.Format]; ok { - args = append(args, "-f", outputFmt) - } - - args = append(args, "-") - return args -} - -// buildTemplateArgs handles user-customized command templates, with dynamic injection -// of sample rate, channels, and bit depth when requested by the transcode decision. -// 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) -} - -// 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)) - } - if opts.Channels > 0 { - args = injectBeforeOutput(args, "-ac", strconv.Itoa(opts.Channels)) - } - if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) { - args = injectBeforeOutput(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth)) - } - return args -} - -// injectBeforeOutput inserts a flag and value before the trailing "-" (stdout output). -func injectBeforeOutput(args []string, flag, value string) []string { - if len(args) > 0 && args[len(args)-1] == "-" { - result := make([]string, 0, len(args)+2) - result = append(result, args[:len(args)-1]...) - result = append(result, flag, value, "-") - return result - } - return append(args, flag, value) -} - -// isLosslessOutputFormat returns true if the format is a lossless audio format -// where preserving bit depth via -sample_fmt is meaningful. -// Note: this covers only formats ffmpeg can produce as output. For the full set of -// lossless formats used in transcoding decisions, see core/stream/codec.go:isLosslessFormat. -func isLosslessOutputFormat(format string) bool { - switch strings.ToLower(format) { - case "flac", "alac", "wav", "aiff": - return true - } - return false -} - -// bitDepthToSampleFmt converts a bit depth value to the ffmpeg sample_fmt string. -// FLAC only supports s16 and s32; for 24-bit sources, s32 is the correct format -// (ffmpeg packs 24-bit samples into 32-bit containers). -func bitDepthToSampleFmt(bitDepth int) string { - switch bitDepth { - case 16: - return "s16" - case 32: - return "s32" - default: - // 24-bit and other depths: use s32 (the next valid container size) - return "s32" - } -} - // Path will always be an absolute path -func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string { - var args []string - for _, s := range fixCmd(cmd) { - if strings.Contains(s, "%s") { - if offset > 0 && !strings.Contains(cmd, "%t") { - // Pre-input seeking: ffmpeg seeks at the demuxer level (fast) - // instead of decoding all frames up to the offset (slow). - insertAt := len(args) - for i, arg := range slices.Backward(args) { - if arg == "-i" { - insertAt = i - break - } - } - args = slices.Insert(args, insertAt, "-ss", strconv.Itoa(offset)) - } - s = strings.ReplaceAll(s, "%s", path) - args = append(args, s) - } else { - s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset)) - s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate)) - args = append(args, s) - } - } - return args -} - -func fixCmd(cmd string) []string { - split := strings.Fields(cmd) - cmdPath, _ := ffmpegCmd() +func createFFmpegCommand(cmd, path string, maxBitRate int) []string { + split := strings.Split(fixCmd(cmd), " ") for i, s := range split { - if s == "ffmpeg" || s == "ffmpeg.exe" { - split[i] = cmdPath - } - if s == "ffprobe" || s == "ffprobe.exe" { - split[i] = ffprobePath(cmdPath) - } + s = strings.ReplaceAll(s, "%s", path) + s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate)) + split[i] = s } + return split } -// ffprobePath derives the ffprobe binary path from the resolved ffmpeg path. -func ffprobePath(ffmpegCmd string) string { - dir := filepath.Dir(ffmpegCmd) - base := filepath.Base(ffmpegCmd) - return filepath.Join(dir, strings.Replace(base, "ffmpeg", "ffprobe", 1)) +func createProbeCommand(cmd string, inputs []string) []string { + split := strings.Split(fixCmd(cmd), " ") + var args []string + + for _, s := range split { + if s == "%s" { + for _, inp := range inputs { + args = append(args, "-i", inp) + } + } else { + args = append(args, s) + } + } + return args +} + +func fixCmd(cmd string) string { + split := strings.Split(cmd, " ") + var result []string + cmdPath, _ := ffmpegCmd() + for _, s := range split { + if s == "ffmpeg" || s == "ffmpeg.exe" { + result = append(result, cmdPath) + } else { + result = append(result, s) + } + } + return strings.Join(result, " ") } func ffmpegCmd() (string, error) { @@ -621,55 +174,8 @@ 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 46684fe14..ea8ea6a76 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -1,32 +1,16 @@ package ffmpeg import ( - "context" - "errors" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "sync" "testing" - "time" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) func TestFFmpeg(t *testing.T) { - // Inline test init to avoid import cycle with tests package - //nolint:dogsled - _, file, _, _ := runtime.Caller(0) - appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", "..")) - confPath := filepath.Join(appPath, "tests", "navidrome-test.toml") - _ = os.Chdir(appPath) - conf.LoadFromFile(confPath) + tests.Init(t, false) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) RunSpecs(t, "FFmpeg Suite") @@ -40,817 +24,15 @@ var _ = Describe("ffmpeg", func() { }) Describe("createFFmpegCommand", func() { It("creates a valid command line", func() { - args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0) + args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123) Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) }) - It("handles extra spaces in the command string", func() { - args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0) - Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) - }) - Context("when command has time offset param", func() { - It("creates a valid command line with offset", func() { - args := createFFmpegCommand("ffmpeg -ss %t -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456) - Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) - }) - - }) - Context("when command does not have time offset param", func() { - It("adds time offset before the input file name", func() { - args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456) - Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) - }) - }) }) - When("ffmpegPath is set", func() { - It("returns the correct ffmpeg path", func() { - ffmpegPath = "/usr/bin/ffmpeg" - args := createFFmpegCommand("ffmpeg -i %s -f mp3 -", "one.mp3", 0, 0) - Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "mp3", "-"})) - }) - It("returns the correct ffmpeg path with spaces", func() { - ffmpegPath = "/usr/bin/with spaces/ffmpeg.exe" - args := createFFmpegCommand("ffmpeg -i %s -f mp3 -", "one.mp3", 0, 0) - Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "mp3", "-"})) - }) - }) - - Describe("isDefaultCommand", func() { - It("returns true for known default mp3 command", func() { - Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) - }) - It("returns true for known default opus command", func() { - Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) - }) - It("returns true for known default aac command", func() { - Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) - }) - It("returns true for known default flac command", func() { - Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) - }) - It("returns false for a custom command", func() { - Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse()) - }) - It("returns false for unknown format", func() { - Expect(isDefaultCommand("wav", "ffmpeg -i %s -f wav -")).To(BeFalse()) - }) - }) - - Describe("buildDynamicArgs", func() { - It("builds mp3 args with bitrate, samplerate, and channels", func() { - args := buildDynamicArgs(TranscodeOptions{ - Format: "mp3", - FilePath: "/music/file.flac", - BitRate: 256, - SampleRate: 48000, - Channels: 2, - }) - Expect(args).To(Equal([]string{ - "ffmpeg", "-i", "/music/file.flac", - "-map", "0:a:0", - "-map_metadata", "0", "-map_metadata", "0:s:a:0", - "-c:a", "libmp3lame", - "-b:a", "256k", - "-ar", "48000", - "-ac", "2", - "-v", "0", - "-f", "mp3", - "-", - })) - }) - - It("builds flac args without bitrate", func() { - args := buildDynamicArgs(TranscodeOptions{ - Format: "flac", - FilePath: "/music/file.dsf", - SampleRate: 48000, - }) - Expect(args).To(Equal([]string{ - "ffmpeg", "-i", "/music/file.dsf", - "-map", "0:a:0", - "-map_metadata", "0", "-map_metadata", "0:s:a:0", - "-c:a", "flac", - "-ar", "48000", - "-v", "0", - "-f", "flac", - "-", - })) - }) - - It("builds opus args with bitrate only", func() { - args := buildDynamicArgs(TranscodeOptions{ - Format: "opus", - FilePath: "/music/file.flac", - BitRate: 128, - }) - Expect(args).To(Equal([]string{ - "ffmpeg", "-i", "/music/file.flac", - "-map", "0:a:0", - "-map_metadata", "0", "-map_metadata", "0:s:a:0", - "-c:a", "libopus", - "-b:a", "128k", - "-v", "0", - "-f", "opus", - "-", - })) - }) - - It("includes offset when specified", func() { - args := buildDynamicArgs(TranscodeOptions{ - Format: "mp3", - FilePath: "/music/file.mp3", - BitRate: 192, - Offset: 30, - }) - Expect(args).To(Equal([]string{ - "ffmpeg", - "-ss", "30", - "-i", "/music/file.mp3", - "-map", "0:a:0", - "-map_metadata", "0", "-map_metadata", "0:s:a:0", - "-c:a", "libmp3lame", - "-b:a", "192k", - "-v", "0", - "-f", "mp3", - "-", - })) - }) - - It("builds aac args with ADTS output", func() { - args := buildDynamicArgs(TranscodeOptions{ - Format: "aac", - FilePath: "/music/file.flac", - BitRate: 256, - }) - Expect(args).To(Equal([]string{ - "ffmpeg", "-i", "/music/file.flac", - "-map", "0:a:0", - "-map_metadata", "0", "-map_metadata", "0:s:a:0", - "-c:a", "aac", - "-b:a", "256k", - "-v", "0", - "-f", "adts", - "-", - })) - }) - - It("builds flac args with bit depth", func() { - args := buildDynamicArgs(TranscodeOptions{ - Format: "flac", - FilePath: "/music/file.dsf", - BitDepth: 24, - }) - Expect(args).To(Equal([]string{ - "ffmpeg", "-i", "/music/file.dsf", - "-map", "0:a:0", - "-map_metadata", "0", "-map_metadata", "0:s:a:0", - "-c:a", "flac", - "-sample_fmt", "s32", - "-v", "0", - "-f", "flac", - "-", - })) - }) - - It("omits -sample_fmt when bit depth is 0", func() { - args := buildDynamicArgs(TranscodeOptions{ - Format: "flac", - FilePath: "/music/file.flac", - BitDepth: 0, - }) - Expect(args).ToNot(ContainElement("-sample_fmt")) - }) - - It("omits -sample_fmt when bit depth is too low (DSD)", func() { - args := buildDynamicArgs(TranscodeOptions{ - Format: "flac", - FilePath: "/music/file.dsf", - BitDepth: 1, - }) - Expect(args).ToNot(ContainElement("-sample_fmt")) - }) - - DescribeTable("omits -sample_fmt for lossy formats even when bit depth >= 16", - func(format string, bitRate int) { - args := buildDynamicArgs(TranscodeOptions{ - Format: format, - FilePath: "/music/file.flac", - BitRate: bitRate, - BitDepth: 16, - }) - Expect(args).ToNot(ContainElement("-sample_fmt")) - }, - Entry("mp3", "mp3", 256), - Entry("aac", "aac", 256), - Entry("opus", "opus", 128), - ) - }) - - Describe("bitDepthToSampleFmt", func() { - It("converts 16-bit", func() { - Expect(bitDepthToSampleFmt(16)).To(Equal("s16")) - }) - It("converts 24-bit to s32 (FLAC only supports s16/s32)", func() { - Expect(bitDepthToSampleFmt(24)).To(Equal("s32")) - }) - It("converts 32-bit", func() { - Expect(bitDepthToSampleFmt(32)).To(Equal("s32")) - }) - }) - - Describe("buildTemplateArgs", func() { - It("injects -ar and -ac into custom template", func() { - args := buildTemplateArgs(TranscodeOptions{ - Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", - FilePath: "/music/file.flac", - BitRate: 192, - SampleRate: 44100, - Channels: 2, - }) - Expect(args).To(Equal([]string{ - "ffmpeg", "-i", "/music/file.flac", - "-b:a", "192k", "-v", "0", "-f", "mp3", - "-ar", "44100", "-ac", "2", - "-", - })) - }) - - It("injects only -ar when channels is 0", func() { - args := buildTemplateArgs(TranscodeOptions{ - Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", - FilePath: "/music/file.flac", - BitRate: 192, - SampleRate: 48000, - }) - Expect(args).To(Equal([]string{ - "ffmpeg", "-i", "/music/file.flac", - "-b:a", "192k", "-v", "0", "-f", "mp3", - "-ar", "48000", - "-", - })) - }) - - It("does not inject anything when sample rate and channels are 0", func() { - args := buildTemplateArgs(TranscodeOptions{ - Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", - FilePath: "/music/file.flac", - BitRate: 192, - }) - Expect(args).To(Equal([]string{ - "ffmpeg", "-i", "/music/file.flac", - "-b:a", "192k", "-v", "0", "-f", "mp3", - "-", - })) - }) - - It("injects -sample_fmt for lossless output format with bit depth", func() { - args := buildTemplateArgs(TranscodeOptions{ - Command: "ffmpeg -i %s -v 0 -c:a flac -f flac -", - Format: "flac", - FilePath: "/music/file.dsf", - BitDepth: 24, - }) - Expect(args).To(Equal([]string{ - "ffmpeg", "-i", "/music/file.dsf", - "-v", "0", "-c:a", "flac", "-f", "flac", - "-sample_fmt", "s32", - "-", - })) - }) - - It("does not inject -sample_fmt for lossy output format even with bit depth", func() { - args := buildTemplateArgs(TranscodeOptions{ - Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", - Format: "mp3", - FilePath: "/music/file.flac", - BitRate: 192, - BitDepth: 16, - }) - Expect(args).To(Equal([]string{ - "ffmpeg", "-i", "/music/file.flac", - "-b:a", "192k", "-v", "0", "-f", "mp3", - "-", - })) - }) - }) - - Describe("injectBeforeOutput", func() { - It("inserts flag before trailing dash", func() { - args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-"}, "-ar", "48000") - Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-ar", "48000", "-"})) - }) - - It("appends when no trailing dash", func() { - args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3"}, "-ar", "48000") - Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-ar", "48000"})) - }) - }) - - Describe("parseProbeOutput", func() { - It("parses MP3 with embedded artwork (real ffprobe output)", func() { - // Real: MP3 file with mjpeg artwork stream after audio - data := []byte(`{"streams":[` + - `{"index":0,"codec_name":"mp3","codec_long_name":"MP3 (MPEG audio layer 3)","codec_type":"audio",` + - `"sample_fmt":"fltp","sample_rate":"44100","channels":2,"channel_layout":"stereo",` + - `"bits_per_sample":0,"bit_rate":"198314","tags":{"encoder":"LAME3.99r"}},` + - `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline","width":400,"height":400}]}`) - result, err := parseProbeOutput(data) - Expect(err).ToNot(HaveOccurred()) - Expect(result.Codec).To(Equal("mp3")) - Expect(result.Profile).To(BeEmpty()) // MP3 has no profile field - Expect(result.SampleRate).To(Equal(44100)) - Expect(result.Channels).To(Equal(2)) - Expect(result.BitRate).To(Equal(198)) // 198314 bps -> 198 kbps - Expect(result.BitDepth).To(Equal(0)) // lossy codec - }) - - It("parses AAC-LC in m4a container (real ffprobe output)", func() { - // Real: AAC LC file with profile and artwork - data := []byte(`{"streams":[` + - `{"index":0,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` + - `"profile":"LC","codec_type":"audio","sample_fmt":"fltp","sample_rate":"44100",` + - `"channels":2,"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"279958"},` + - `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`) - result, err := parseProbeOutput(data) - Expect(err).ToNot(HaveOccurred()) - Expect(result.Codec).To(Equal("aac")) - Expect(result.Profile).To(Equal("LC")) - Expect(result.SampleRate).To(Equal(44100)) - Expect(result.Channels).To(Equal(2)) - Expect(result.BitRate).To(Equal(279)) // 279958 bps -> 279 kbps - }) - - It("parses HE-AACv2 in mp4 container with video stream (real ffprobe output)", func() { - // Real: Fraunhofer HE-AACv2 sample (LFE-SBRstereo.mp4), video stream before audio - data := []byte(`{"streams":[` + - `{"index":0,"codec_name":"h264","codec_type":"video","profile":"Main"},` + - `{"index":1,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` + - `"profile":"HE-AACv2","codec_type":"audio","sample_fmt":"fltp",` + - `"sample_rate":"48000","channels":2,"channel_layout":"stereo",` + - `"bits_per_sample":0,"bit_rate":"55999"}]}`) - result, err := parseProbeOutput(data) - Expect(err).ToNot(HaveOccurred()) - Expect(result.Codec).To(Equal("aac")) - Expect(result.Profile).To(Equal("HE-AACv2")) - Expect(result.SampleRate).To(Equal(48000)) - Expect(result.Channels).To(Equal(2)) - Expect(result.BitRate).To(Equal(55)) // 55999 bps -> 55 kbps - }) - - It("parses FLAC using bits_per_raw_sample and format-level bit_rate (real ffprobe output)", func() { - // Real: FLAC reports bit depth in bits_per_raw_sample, not bits_per_sample. - // Stream-level bit_rate is absent; format-level bit_rate is used as fallback. - data := []byte(`{"streams":[` + - `{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` + - `"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` + - `"channel_layout":"stereo","bits_per_sample":0,"bits_per_raw_sample":"16"},` + - `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}],` + - `"format":{"bit_rate":"906900"}}`) - result, err := parseProbeOutput(data) - Expect(err).ToNot(HaveOccurred()) - Expect(result.Codec).To(Equal("flac")) - Expect(result.SampleRate).To(Equal(44100)) - Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample - Expect(result.BitRate).To(Equal(906)) // format-level: 906900 bps -> 906 kbps - Expect(result.Profile).To(BeEmpty()) // no profile field in real output - }) - - It("parses Opus with format-level bit_rate fallback (real ffprobe output)", func() { - // Real: Opus stream-level bit_rate is absent; format-level is used as fallback. - data := []byte(`{"streams":[` + - `{"index":0,"codec_name":"opus","codec_long_name":"Opus (Opus Interactive Audio Codec)",` + - `"codec_type":"audio","sample_fmt":"fltp","sample_rate":"48000","channels":2,` + - `"channel_layout":"stereo","bits_per_sample":0}],` + - `"format":{"bit_rate":"128000"}}`) - result, err := parseProbeOutput(data) - Expect(err).ToNot(HaveOccurred()) - Expect(result.Codec).To(Equal("opus")) - Expect(result.SampleRate).To(Equal(48000)) - Expect(result.Channels).To(Equal(2)) - Expect(result.BitRate).To(Equal(128)) // format-level: 128000 bps -> 128 kbps - Expect(result.BitDepth).To(Equal(0)) - }) - - It("parses WAV/PCM with bits_per_sample (real ffprobe output)", func() { - // Real: WAV uses bits_per_sample directly - data := []byte(`{"streams":[` + - `{"index":0,"codec_name":"pcm_s16le","codec_long_name":"PCM signed 16-bit little-endian",` + - `"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` + - `"bits_per_sample":16,"bit_rate":"1411200"}]}`) - result, err := parseProbeOutput(data) - Expect(err).ToNot(HaveOccurred()) - Expect(result.Codec).To(Equal("pcm_s16le")) - Expect(result.SampleRate).To(Equal(44100)) - Expect(result.Channels).To(Equal(2)) - Expect(result.BitDepth).To(Equal(16)) - Expect(result.BitRate).To(Equal(1411)) - }) - - It("parses ALAC in m4a container (real ffprobe output)", func() { - // Real: Beatles - You Can't Do That (2023 Mix), ALAC 16-bit - data := []byte(`{"streams":[` + - `{"index":0,"codec_name":"alac","codec_long_name":"ALAC (Apple Lossless Audio Codec)",` + - `"codec_type":"audio","sample_fmt":"s16p","sample_rate":"44100","channels":2,` + - `"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"1011003",` + - `"bits_per_raw_sample":"16"},` + - `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`) - result, err := parseProbeOutput(data) - Expect(err).ToNot(HaveOccurred()) - Expect(result.Codec).To(Equal("alac")) - Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample - Expect(result.SampleRate).To(Equal(44100)) - Expect(result.Channels).To(Equal(2)) - Expect(result.BitRate).To(Equal(1011)) // 1011003 bps -> 1011 kbps - }) - - It("skips video-only streams", func() { - data := []byte(`{"streams":[{"index":0,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`) - _, err := parseProbeOutput(data) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("no audio stream")) - }) - - It("returns error for empty streams array", func() { - data := []byte(`{"streams":[]}`) - _, err := parseProbeOutput(data) - Expect(err).To(HaveOccurred()) - }) - - It("returns error for invalid JSON", func() { - data := []byte(`not json`) - _, err := parseProbeOutput(data) - Expect(err).To(HaveOccurred()) - }) - - It("parses HiRes multichannel FLAC with format-level bit_rate (real ffprobe output)", func() { - // Real: Pink Floyd - 192kHz/24-bit/7.1 surround FLAC - data := []byte(`{"streams":[` + - `{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` + - `"codec_type":"audio","sample_fmt":"s32","sample_rate":"192000","channels":8,` + - `"channel_layout":"7.1","bits_per_sample":0,"bits_per_raw_sample":"24"},` + - `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Progressive"}],` + - `"format":{"bit_rate":"18432000"}}`) - result, err := parseProbeOutput(data) - Expect(err).ToNot(HaveOccurred()) - Expect(result.Codec).To(Equal("flac")) - Expect(result.SampleRate).To(Equal(192000)) - Expect(result.BitDepth).To(Equal(24)) - Expect(result.Channels).To(Equal(8)) - Expect(result.BitRate).To(Equal(18432)) // format-level: 18432000 bps -> 18432 kbps - }) - - It("parses DSD/DSF file (real ffprobe output)", func() { - // Real: Yes - Owner of a Lonely Heart, DSD64 DSF - data := []byte(`{"streams":[` + - `{"index":0,"codec_name":"dsd_lsbf_planar",` + - `"codec_long_name":"DSD (Direct Stream Digital), least significant bit first, planar",` + - `"codec_type":"audio","sample_fmt":"fltp","sample_rate":"352800","channels":2,` + - `"channel_layout":"stereo","bits_per_sample":8,"bit_rate":"5644800"},` + - `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`) - result, err := parseProbeOutput(data) - Expect(err).ToNot(HaveOccurred()) - Expect(result.Codec).To(Equal("dsd_lsbf_planar")) - Expect(result.BitDepth).To(Equal(8)) // DSD reports 8 bits_per_sample - Expect(result.SampleRate).To(Equal(352800)) // DSD64 sample rate - Expect(result.Channels).To(Equal(2)) - Expect(result.BitRate).To(Equal(5644)) // 5644800 bps -> 5644 kbps - }) - - It("prefers stream-level bit_rate over format-level when both are present", func() { - // ALAC/DSD: stream has bit_rate, format also has bit_rate — stream wins - data := []byte(`{"streams":[` + - `{"index":0,"codec_name":"alac","codec_type":"audio","sample_fmt":"s16p",` + - `"sample_rate":"44100","channels":2,"bits_per_sample":0,` + - `"bit_rate":"1011003","bits_per_raw_sample":"16"}],` + - `"format":{"bit_rate":"1050000"}}`) - result, err := parseProbeOutput(data) - Expect(err).ToNot(HaveOccurred()) - Expect(result.BitRate).To(Equal(1011)) // stream-level: 1011003 bps -> 1011 kbps (not format's 1050) - }) - - It("returns BitRate 0 when neither stream nor format has bit_rate", func() { - data := []byte(`{"streams":[` + - `{"index":0,"codec_name":"flac","codec_type":"audio","sample_fmt":"s16",` + - `"sample_rate":"44100","channels":2,"bits_per_sample":0,"bits_per_raw_sample":"16"}],` + - `"format":{}}`) - result, err := parseProbeOutput(data) - Expect(err).ToNot(HaveOccurred()) - Expect(result.BitRate).To(Equal(0)) - }) - - It("clears 'unknown' profile to empty string", func() { - data := []byte(`{"streams":[{"index":0,"codec_name":"flac",` + - `"codec_type":"audio","profile":"unknown","sample_rate":"44100",` + - `"channels":2,"bits_per_sample":0}]}`) - result, err := parseProbeOutput(data) - Expect(err).ToNot(HaveOccurred()) - Expect(result.Profile).To(BeEmpty()) - }) - }) - - Describe("ProbeError", func() { - It("uses the underlying cause in Error() so logs keep the full detail", func() { - e := &ProbeError{Path: "/music/foo.flac", - err: errors.New("/music/foo.flac: Invalid data found when processing input")} - Expect(e.Error()).To(ContainSubstring("/music/foo.flac")) - Expect(e.Error()).To(ContainSubstring("Invalid data found when processing input")) - }) - - It("returns the path-free reason from SafeReason()", func() { - e := &ProbeError{Path: "/music/foo.flac", Reason: "the file: Invalid data found when processing input"} - Expect(e.SafeReason()).To(Equal("the file: Invalid data found when processing input")) - Expect(e.SafeReason()).ToNot(ContainSubstring("/music/foo.flac")) - }) - - It("unwraps to the underlying cause so errors.Is detects a missing file", func() { - e := &ProbeError{Path: "/music/foo.flac", Reason: "file not found", err: os.ErrNotExist} - Expect(errors.Is(e, os.ErrNotExist)).To(BeTrue()) - }) - }) - - Describe("probeClientReason", func() { - It("strips the file path from ffprobe stderr", func() { - if runtime.GOOS == "windows" { - Skip("uses /bin/sh") - } - _, err := exec.Command("/bin/sh", "-c", "echo '/music/foo.flac: Invalid data found' >&2; exit 1").Output() - Expect(err).To(HaveOccurred()) - Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("the file: Invalid data found")) - }) - - It("returns a generic reason for launch failures, without leaking the binary path", func() { - err := errors.New("fork/exec /opt/navidrome/bin/ffprobe: no such file or directory") - Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("could not read file")) - }) - }) - - Describe("probeDetail", func() { - It("surfaces ffprobe stderr for logging", func() { - if runtime.GOOS == "windows" { - Skip("uses /bin/sh") - } - _, err := exec.Command("/bin/sh", "-c", "echo 'boom detail' >&2; exit 1").Output() - Expect(err).To(HaveOccurred()) - Expect(probeDetail(err)).To(Equal("boom detail")) - }) - }) - - Describe("fileAccessReason", func() { - It("reports a missing file as 'file not found', not a raw stat message", func() { - _, err := os.Stat("/no/such/dir/really-missing.flac") - Expect(err).To(HaveOccurred()) - Expect(fileAccessReason(err)).To(Equal("file not found")) - }) - - It("falls back to a generic reason for other access errors", func() { - Expect(fileAccessReason(errors.New("boom"))).To(Equal("file not accessible")) - }) - }) - - Describe("FFmpeg", func() { - Context("when FFmpeg is available", func() { - var ff FFmpeg - - BeforeEach(func() { - ffOnce = sync.Once{} - ff = New() - // Skip if FFmpeg is not available - if !ff.IsAvailable() { - Skip("FFmpeg not available on this system") - } - }) - - It("ProbeAudioStream returns a not-found ProbeError for a missing file", func() { - _, err := ff.ProbeAudioStream(GinkgoT().Context(), "/no/such/dir/really-missing.flac") - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, os.ErrNotExist)).To(BeTrue()) - var pe *ProbeError - Expect(errors.As(err, &pe)).To(BeTrue()) - Expect(pe.SafeReason()).To(Equal("file not found")) - Expect(pe.NotFound).To(BeTrue()) - }) - - It("should interrupt transcoding when context is cancelled", func() { - ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second) - defer cancel() - - // Use a command that generates audio indefinitely - // -f lavfi uses FFmpeg's built-in audio source - // -t 0 means no time limit (runs forever) - command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -" - - // The input file is not used here, but we need to provide a valid path to the Transcode function - stream, err := ff.Transcode(ctx, TranscodeOptions{ - Command: command, - Format: "mp3", - FilePath: "tests/fixtures/test.mp3", - BitRate: 128, - }) - Expect(err).ToNot(HaveOccurred()) - defer stream.Close() - - // Read some data first to ensure FFmpeg is running - buf := make([]byte, 1024) - _, err = stream.Read(buf) - Expect(err).ToNot(HaveOccurred()) - - // Cancel the context - cancel() - - // 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() { - ctx, cancel := context.WithCancel(GinkgoT().Context()) - cancel() // Cancel immediately - - // This should fail immediately - _, err := ff.Transcode(ctx, TranscodeOptions{ - Command: "ffmpeg -i %s -f mp3 -", - Format: "mp3", - FilePath: "tests/fixtures/test.mp3", - BitRate: 128, - }) - Expect(err).To(MatchError(context.Canceled)) - }) - - It("fills in total_samples on a piped FLAC transcode", func() { - stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{ - Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", - Format: "flac", - FilePath: "tests/fixtures/test.flac", - Duration: 1, // the fixture is exactly 1s at 44100Hz - }) - Expect(err).ToNot(HaveOccurred()) - defer stream.Close() - - out, err := io.ReadAll(stream) - Expect(err).ToNot(HaveOccurred()) - Expect(string(out[:4])).To(Equal("fLaC")) - Expect(readTotalSamples(out)).To(Equal(uint64(44100))) - }) - - It("patches the duration net of the requested offset", func() { - // The command has no %t, so ffmpeg still emits the whole fixture. - // What is under test is the header arithmetic, not the audio. - stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{ - Command: "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", - Format: "flac", - FilePath: "tests/fixtures/test.flac", - Duration: 3, - Offset: 1, - }) - Expect(err).ToNot(HaveOccurred()) - defer stream.Close() - - out, err := io.ReadAll(stream) - Expect(err).ToNot(HaveOccurred()) - Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100))) - }) - }) - - Context("stderr capture", func() { - BeforeEach(func() { - if runtime.GOOS == "windows" { - Skip("stderr capture tests use /bin/sh, skipping on Windows") - } - }) - - It("should include stderr in error when process fails", func() { - ff := &ffmpeg{} - ctx := GinkgoT().Context() - - // Directly call start() with a bash command that writes to stderr and fails - args := []string{"/bin/sh", "-c", "echo 'codec not found: libopus' >&2; exit 1"} - stream, err := ff.start(ctx, args) - Expect(err).ToNot(HaveOccurred()) - defer stream.Close() - - buf := make([]byte, 1024) - _, err = stream.Read(buf) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("codec not found: libopus")) - }) - - It("should not include stderr in error when process succeeds", func() { - ff := &ffmpeg{} - ctx := GinkgoT().Context() - - // Command that writes to stderr but exits successfully - args := []string{"/bin/sh", "-c", "echo 'warning: something' >&2; printf 'output'"} - stream, err := ff.start(ctx, args) - Expect(err).ToNot(HaveOccurred()) - defer stream.Close() - - buf := make([]byte, 1024) - n, err := stream.Read(buf) - Expect(err).ToNot(HaveOccurred()) - Expect(string(buf[:n])).To(Equal("output")) - }) - }) - - Context("with mock process behavior", func() { - var longRunningCmd string - BeforeEach(func() { - // Use a long-running command for testing cancellation - switch runtime.GOOS { - case "windows": - // Use PowerShell's Start-Sleep - ffmpegPath = "powershell" - longRunningCmd = "powershell -Command Start-Sleep -Seconds 10" - default: - // Use sleep on Unix-like systems - ffmpegPath = "sleep" - longRunningCmd = "sleep 10" - } - }) - - It("should terminate the underlying process when context is cancelled", func() { - ff := New() - ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second) - defer cancel() - - // Start a process that will run for a while - stream, err := ff.Transcode(ctx, TranscodeOptions{ - Command: longRunningCmd, - FilePath: "tests/fixtures/test.mp3", - }) - Expect(err).ToNot(HaveOccurred()) - defer stream.Close() - - // Give the process time to start - time.Sleep(50 * time.Millisecond) - - // Cancel the context - cancel() - - // Try to read from the stream, which should fail - buf := make([]byte, 100) - _, err = stream.Read(buf) - Expect(err).To(HaveOccurred(), "Expected stream to be closed due to process termination") - - // Verify the stream is closed by attempting another read - _, err = stream.Read(buf) - Expect(err).To(HaveOccurred()) - }) - }) - }) - - 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)) + Describe("createProbeCommand", func() { + It("creates a valid command line", func() { + args := createProbeCommand(probeCmd, []string{"/music library/one.mp3", "/music library/two.mp3"}) + Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"})) }) }) }) diff --git a/core/ffmpeg/flac_streaminfo.go b/core/ffmpeg/flac_streaminfo.go deleted file mode 100644 index 878c28718..000000000 --- a/core/ffmpeg/flac_streaminfo.go +++ /dev/null @@ -1,66 +0,0 @@ -package ffmpeg - -import ( - "bytes" - "encoding/binary" - "errors" - "io" - "math" -) - -const ( - flacPrefixLen = 26 // through the last total_samples byte - flacMaxTotalSamples = 1<<36 - 1 -) - -// patchFLACDuration fills in the STREAMINFO total_samples that ffmpeg leaves at 0 -// when writing to a pipe, since a decoder cannot seek a cached FLAC without it. -func patchFLACDuration(r io.ReadCloser, duration float32) io.ReadCloser { - if duration <= 0 { - return r - } - return &flacPatcher{ReadCloser: r, duration: duration} -} - -type flacPatcher struct { - io.ReadCloser - duration float32 - // Peeking here rather than in the constructor keeps Transcode from blocking - // until ffmpeg has emitted its first bytes. - stream io.Reader -} - -func (f *flacPatcher) Read(p []byte) (int, error) { - if f.stream == nil { - prefix := make([]byte, flacPrefixLen) - n, err := io.ReadFull(f.ReadCloser, prefix) - if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { - return 0, err - } - prefix = prefix[:n] - if err == nil { - setFLACTotalSamples(prefix, f.duration) - } - f.stream = io.MultiReader(bytes.NewReader(prefix), f.ReadCloser) - } - return f.stream.Read(p) -} - -// setFLACTotalSamples takes the rate from the header rather than the transcode -// options, so a resampled (-ar) output still gets the right count. -func setFLACTotalSamples(prefix []byte, duration float32) { - if string(prefix[:4]) != "fLaC" || prefix[4]&0x7F != 0 { - return - } - // 20-bit rate | 3-bit channels | 5-bit depth | 36-bit total_samples - info := binary.BigEndian.Uint64(prefix[18:]) - rate := info >> 44 - if rate == 0 || info&flacMaxTotalSamples != 0 { - return - } - total := math.Round(float64(duration) * float64(rate)) - if total > flacMaxTotalSamples { - return - } - binary.BigEndian.PutUint64(prefix[18:], info|uint64(total)) -} diff --git a/core/ffmpeg/flac_streaminfo_test.go b/core/ffmpeg/flac_streaminfo_test.go deleted file mode 100644 index 6bf3503d7..000000000 --- a/core/ffmpeg/flac_streaminfo_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package ffmpeg - -import ( - "bytes" - "errors" - "io" - "os" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Decoded independently so the specs do not mirror the production bit-twiddling. -func readSampleRate(b []byte) int { - return int(b[18])<<12 | int(b[19])<<4 | int(b[20])>>4 -} - -func readTotalSamples(b []byte) uint64 { - return uint64(b[21]&0x0F)<<32 | uint64(b[22])<<24 | uint64(b[23])<<16 | uint64(b[24])<<8 | uint64(b[25]) -} - -var _ = Describe("patchFLACDuration", func() { - var fileFLAC []byte - - // Zeroing total_samples reproduces what a piped transcode emits. - pipedFLAC := func() []byte { - b := bytes.Clone(fileFLAC) - b[21] &= 0xF0 - clear(b[22:26]) - return b - } - - readAll := func(in []byte, duration float32) []byte { - out, err := io.ReadAll(patchFLACDuration(io.NopCloser(bytes.NewReader(in)), duration)) - Expect(err).ToNot(HaveOccurred()) - return out - } - - BeforeEach(func() { - var err error - fileFLAC, err = os.ReadFile("tests/fixtures/test.flac") - Expect(err).ToNot(HaveOccurred()) - Expect(readSampleRate(fileFLAC)).To(Equal(44100)) // specs below hard-code this rate - }) - - It("fills in total_samples from the duration", func() { - out := readAll(pipedFLAC(), 1.0) - Expect(readTotalSamples(out)).To(Equal(uint64(44100))) - }) - - It("takes the sample rate from the header, not from the source file", func() { - in := pipedFLAC() - // Rewrite the header's rate to 48000, as -ar would. - in[18], in[19] = 0x0B, 0xB8 - in[20] &= 0x0F - - out := readAll(in, 2.0) - - Expect(readSampleRate(out)).To(Equal(48000)) - Expect(readTotalSamples(out)).To(Equal(uint64(96000))) - }) - - It("rounds to the nearest sample rather than truncating", func() { - // float32(0.7)*44100 is 30869.9995, so truncation would lose a sample. - out := readAll(pipedFLAC(), 0.7) - Expect(readTotalSamples(out)).To(Equal(uint64(30870))) - }) - - It("passes through when the duration overflows the 36-bit field", func() { - in := pipedFLAC() - Expect(readAll(in, 2e6)).To(Equal(in)) - }) - - It("leaves everything after the header untouched", func() { - in := pipedFLAC() - out := readAll(in, 1.0) - Expect(out).To(HaveLen(len(in))) - Expect(out[26:]).To(Equal(in[26:])) - Expect(out[:18]).To(Equal(in[:18])) - }) - - It("leaves an already-populated total_samples alone", func() { - out := readAll(fileFLAC, 99.0) - Expect(out).To(Equal(fileFLAC)) - }) - - It("passes through a stream that is not FLAC", func() { - in := []byte("ID3\x04\x00\x00\x00\x00\x00\x00 not a flac stream at all, just bytes") - Expect(readAll(in, 1.0)).To(Equal(in)) - }) - - It("passes through when the first metadata block is not STREAMINFO", func() { - in := pipedFLAC() - in[4] = 0x04 // VORBIS_COMMENT - Expect(readAll(in, 1.0)).To(Equal(in)) - }) - - It("passes through a stream shorter than the STREAMINFO fields it patches", func() { - in := pipedFLAC()[:20] - Expect(readAll(in, 1.0)).To(Equal(in)) - }) - - It("passes through an empty stream", func() { - Expect(readAll(nil, 1.0)).To(BeEmpty()) - }) - - It("passes through when the duration is zero or negative", func() { - in := pipedFLAC() - Expect(readAll(in, 0)).To(Equal(in)) - Expect(readAll(in, -5)).To(Equal(in)) - }) - - It("passes through when the header declares no sample rate", func() { - in := pipedFLAC() - in[18], in[19] = 0, 0 - in[20] &= 0x0F - Expect(readAll(in, 1.0)).To(Equal(in)) - }) - - It("propagates a read error from the underlying stream", func() { - _, err := io.ReadAll(patchFLACDuration(io.NopCloser(io.MultiReader( - bytes.NewReader(pipedFLAC()[:10]), &errReader{})), 1.0)) - Expect(err).To(MatchError("boom")) - }) - - It("closes the underlying stream", func() { - c := &closeSpy{Reader: bytes.NewReader(pipedFLAC())} - Expect(patchFLACDuration(c, 1.0).Close()).To(Succeed()) - Expect(c.closed).To(BeTrue()) - }) -}) - -type errReader struct{} - -func (e *errReader) Read([]byte) (int, error) { return 0, errors.New("boom") } - -type closeSpy struct { - io.Reader - closed bool -} - -func (c *closeSpy) Close() error { c.closed = true; return nil } diff --git a/core/inspect.go b/core/inspect.go deleted file mode 100644 index c60459b88..000000000 --- a/core/inspect.go +++ /dev/null @@ -1,60 +0,0 @@ -package core - -import ( - "path/filepath" - - "github.com/navidrome/navidrome/core/storage" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/metadata" -) - -type InspectOutput struct { - File string `json:"file"` - RawTags model.RawTags `json:"rawTags"` - MappedTags *model.MediaFile `json:"mappedTags,omitempty"` -} - -func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutput, error) { - path, file := filepath.Split(filePath) - - s, err := storage.For(path) - if err != nil { - return nil, err - } - - fs, err := s.FS() - if err != nil { - return nil, err - } - - tags, err := fs.ReadTags(file) - if err != nil { - return nil, err - } - - tag, ok := tags[file] - if !ok { - log.Error("Could not get tags for path", "path", filePath) - return nil, model.ErrNotFound - } - - md := metadata.New(scannerPath(lib, filePath), tag) - result := &InspectOutput{ - File: filePath, - RawTags: tags[file].Tags, - MappedTags: new(md.ToMediaFile(lib, folderId)), - } - - return result, nil -} - -// scannerPath returns the path the scanner uses for the file (relative to its library), so -// folder-based PIDs match the DB. Files outside the library keep their absolute path. -func scannerPath(lib model.Library, filePath string) string { - absPath, err := filepath.Abs(filePath) - if err != nil || lib.Path == "" { - return filePath - } - return model.LibraryRelativePath(lib.Path, absPath) -} diff --git a/core/inspect_test.go b/core/inspect_test.go deleted file mode 100644 index 0ac90990c..000000000 --- a/core/inspect_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package core_test - -import ( - "path/filepath" - - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Inspect", func() { - var fixtures string - - BeforeEach(func() { - var err error - fixtures, err = filepath.Abs(filepath.Join("tests", "fixtures")) - Expect(err).ToNot(HaveOccurred()) - }) - - It("maps the file with the library-relative path the scanner uses", func() { - lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"} - out, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "") - Expect(err).ToNot(HaveOccurred()) - Expect(out.MappedTags.Path).To(Equal("fixtures/test.mp3")) - Expect(out.MappedTags.LibraryID).To(Equal(2)) - }) - - It("gives the same IDs for relative and absolute paths", func() { - lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"} - abs, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "") - Expect(err).ToNot(HaveOccurred()) - rel, err := core.Inspect(filepath.Join("tests", "fixtures", "test.mp3"), lib, "") - Expect(err).ToNot(HaveOccurred()) - Expect(rel.MappedTags.AlbumID).To(Equal(abs.MappedTags.AlbumID)) - Expect(rel.MappedTags.PID).To(Equal(abs.MappedTags.PID)) - }) - - It("keeps the given path for a file outside the library", func() { - filePath := filepath.Join(fixtures, "test.mp3") - out, err := core.Inspect(filePath, model.Library{ID: model.DefaultLibraryID}, "") - Expect(err).ToNot(HaveOccurred()) - Expect(out.MappedTags.Path).To(Equal(filePath)) - }) -}) diff --git a/core/library.go b/core/library.go deleted file mode 100644 index f1153da26..000000000 --- a/core/library.go +++ /dev/null @@ -1,443 +0,0 @@ -package core - -import ( - "context" - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "slices" - "strconv" - "strings" - "time" - - "github.com/Masterminds/squirrel" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/core/storage" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/metadata" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/utils/slice" -) - -// Watcher interface for managing file system watchers -type Watcher interface { - Watch(ctx context.Context, lib *model.Library) error - StopWatching(ctx context.Context, libraryID int) error -} - -// Library provides business logic for library management and user-library associations -type Library interface { - GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) - SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error - ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error - - Repository() rest.Repository[model.Library] -} - -type libraryService struct { - ds model.DataStore - broker events.Broker - repo *libraryRepositoryWrapper -} - -// NewLibrary creates a new Library service -func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker, pluginManager PluginUnloader) Library { - return &libraryService{ - ds: ds, - broker: broker, - repo: &libraryRepositoryWrapper{ - LibraryRepository: ds.Library(), - ds: ds, - scanner: scanner, - watcher: watcher, - broker: broker, - pluginManager: pluginManager, - }, - } -} - -// User-library association operations - -func (s *libraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) { - // Verify user exists - if _, err := s.ds.User().Get(ctx, userID); err != nil { - return nil, err - } - - return s.ds.User().GetUserLibraries(ctx, userID) -} - -func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error { - // Verify user exists - user, err := s.ds.User().Get(ctx, userID) - if err != nil { - return err - } - - // Admin users get all libraries automatically - don't allow manual assignment - if user.IsAdmin { - return fmt.Errorf("%w: cannot manually assign libraries to admin users", model.ErrValidation) - } - - // Regular users must have at least one library - if len(libraryIDs) == 0 { - return fmt.Errorf("%w: at least one library must be assigned to non-admin users", model.ErrValidation) - } - - // Validate all library IDs exist - if len(libraryIDs) > 0 { - if err := s.validateLibraryIDs(ctx, libraryIDs); err != nil { - return err - } - } - - // Set user libraries - err = s.ds.User().SetUserLibraries(ctx, userID, libraryIDs) - if err != nil { - return fmt.Errorf("error setting user libraries: %w", err) - } - - // Send refresh event to all clients - event := &events.RefreshResource{} - libIDs := slice.Map(libraryIDs, func(id int) string { return strconv.Itoa(id) }) - event = event.With("user", userID).With("library", libIDs...) - s.broker.SendBroadcastMessage(ctx, event) - return nil -} - -func (s *libraryService) ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error { - user, ok := request.UserFrom(ctx) - if !ok { - return fmt.Errorf("user not found in context") - } - - // Admin users have access to all libraries - if user.IsAdmin { - return nil - } - - // Check if user has explicit access to this library - libraries, err := s.ds.User().GetUserLibraries(ctx, userID) - if err != nil { - log.Error(ctx, "Error checking library access", "userID", userID, "libraryID", libraryID, err) - return fmt.Errorf("error checking library access: %w", err) - } - - for _, lib := range libraries { - if lib.ID == libraryID { - return nil - } - } - - return fmt.Errorf("%w: user does not have access to library %d", model.ErrNotAuthorized, libraryID) -} - -// REST repository wrapper - -func (s *libraryService) Repository() rest.Repository[model.Library] { - return s.repo -} - -var _ rest.Persistable[model.Library] = (*libraryRepositoryWrapper)(nil) - -type libraryRepositoryWrapper struct { - model.LibraryRepository - ds model.DataStore - scanner model.Scanner - watcher Watcher - broker events.Broker - pluginManager PluginUnloader -} - -func (r *libraryRepositoryWrapper) Save(ctx context.Context, lib *model.Library) (string, error) { - if err := r.validateLibrary(ctx, lib); err != nil { - return "", err - } - - err := r.LibraryRepository.Put(ctx, lib) - if err != nil { - return "", r.mapError(err) - } - - // Start watcher and trigger scan after successful library creation - if r.watcher != nil { - if err := r.watcher.Watch(ctx, lib); err != nil { - log.Warn(ctx, "Failed to start watcher for new library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) - } - } - - if r.scanner != nil { - go r.triggerScan(ctx, lib, "new") - } - - // Send library refresh event to all clients - if r.broker != nil { - event := &events.RefreshResource{} - r.broker.SendBroadcastMessage(ctx, event.With("library", strconv.Itoa(lib.ID))) - log.Debug(ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name) - } - - return strconv.Itoa(lib.ID), nil -} - -func (r *libraryRepositoryWrapper) Update(ctx context.Context, id string, entity model.Library, cols ...string) error { - lib := &entity - libID, err := strconv.Atoi(id) - if err != nil { - return fmt.Errorf("invalid library ID: %s", id) - } - - lib.ID = libID - if err := r.validateLibrary(ctx, lib); err != nil { - return err - } - - // Get the original library to check if path changed - originalLib, err := r.Get(ctx, libID) - if err != nil { - return r.mapError(err) - } - - pathChanged := originalLib.Path != lib.Path - pidChanged := (updatesColumn(cols, "pidAlbum") && originalLib.PIDAlbum != lib.PIDAlbum) || - (updatesColumn(cols, "pidTrack") && originalLib.PIDTrack != lib.PIDTrack) - - err = r.LibraryRepository.Put(ctx, lib, cols...) - if err != nil { - return r.mapError(err) - } - - if pathChanged && r.watcher != nil { - if err := r.watcher.Watch(ctx, lib); err != nil { - log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) - } - } - - if (pathChanged || pidChanged) && r.scanner != nil { - go r.triggerScan(ctx, lib, "updated") - } - - // Send library refresh event to all clients - if r.broker != nil { - event := &events.RefreshResource{} - r.broker.SendBroadcastMessage(ctx, event.With("library", id)) - log.Debug(ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name) - } - - return nil -} - -func (r *libraryRepositoryWrapper) Delete(ctx context.Context, ids ...string) error { - for _, id := range ids { - if err := r.deleteOne(ctx, id); err != nil { - return err - } - } - return nil -} - -func (r *libraryRepositoryWrapper) deleteOne(ctx context.Context, id string) error { - libID, err := strconv.Atoi(id) - if err != nil { - return &rest.ValidationError{Errors: map[string]string{ - "id": "invalid library ID format", - }} - } - - // Get library info before deletion for logging - lib, err := r.Get(ctx, libID) - if err != nil { - return r.mapError(err) - } - - // Run the deletion in a transaction so the cascade delete and the orphaned-artist - // reconciliation it triggers (see libraryRepository.Delete) commit atomically. - err = r.ds.WithTx(func(tx model.DataStore) error { - return tx.Library().Delete(ctx, libID) - }, "delete library") - if err != nil { - return r.mapError(err) - } - - // Stop watcher and trigger scan after successful library deletion to clean up orphaned data - if r.watcher != nil { - if err := r.watcher.StopWatching(ctx, libID); err != nil { - log.Warn(ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err) - } - } - - if r.scanner != nil { - go r.triggerScan(ctx, lib, "deleted") - } - - // Send library refresh event to all clients - if r.broker != nil { - event := &events.RefreshResource{} - r.broker.SendBroadcastMessage(ctx, event.With("library", id)) - log.Debug(ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name) - } - - // After successful deletion, check if any plugins were auto-disabled - // and need to be unloaded from memory - r.pluginManager.UnloadDisabledPlugins(ctx) - - return nil -} - -// Helper methods - -func (r *libraryRepositoryWrapper) mapError(err error) error { - if err == nil { - return nil - } - - errStr := err.Error() - - // Handle database constraint violations. - // TODO: Being tied to react-admin translations is not ideal, but this will probably go away with the new UI/API - if strings.Contains(errStr, "UNIQUE constraint failed") { - if strings.Contains(errStr, "library.name") { - return &rest.ValidationError{Errors: map[string]string{"name": "ra.validation.unique"}} - } - if strings.Contains(errStr, "library.path") { - return &rest.ValidationError{Errors: map[string]string{"path": "ra.validation.unique"}} - } - } - - return err -} - -func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library *model.Library) error { - validationErrors := make(map[string]string) - - if library.Name == "" { - validationErrors["name"] = "ra.validation.required" - } - - if library.Path == "" { - validationErrors["path"] = "ra.validation.required" - } else { - // Validate path format and accessibility - if err := r.validateLibraryPath(ctx, library); err != nil { - validationErrors["path"] = err.Error() - } - } - - library.PIDAlbum = strings.TrimSpace(library.PIDAlbum) - library.PIDTrack = strings.TrimSpace(library.PIDTrack) - if err := metadata.ValidatePIDSpec(library.PIDAlbum, true); err != nil { - validationErrors["pidAlbum"] = err.Error() - } - if err := metadata.ValidatePIDSpec(library.PIDTrack, false); err != nil { - validationErrors["pidTrack"] = err.Error() - } - - if len(validationErrors) > 0 { - return &rest.ValidationError{Errors: validationErrors} - } - - return nil -} - -// updatesColumn reports whether an update with these columns writes col. No columns means all of them. -func updatesColumn(cols []string, col string) bool { - return len(cols) == 0 || slices.Contains(cols, col) -} - -func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error { - // Validate path format - if !filepath.IsAbs(library.Path) { - return fmt.Errorf("library path must be absolute") - } - - // Clean the path to normalize it - cleanPath := filepath.Clean(library.Path) - library.Path = cleanPath - - // Check if path exists and is accessible using storage abstraction - fileStore, err := storage.For(library.Path) - if err != nil { - return fmt.Errorf("invalid storage scheme: %w", err) - } - - fsys, err := fileStore.FS() - if err != nil { - log.Warn(ctx, "Error validating library.path", "path", library.Path, err) - return fmt.Errorf("resources.library.validation.pathInvalid") - } - - // Check if root directory exists - info, err := fs.Stat(fsys, ".") - if err != nil { - // Parse the error message to check for "not a directory" - log.Warn(ctx, "Error stating library.path", "path", library.Path, err) - errStr := err.Error() - if strings.Contains(errStr, "not a directory") || - strings.Contains(errStr, "The directory name is invalid.") { - return fmt.Errorf("resources.library.validation.pathNotDirectory") - } else if os.IsNotExist(err) { - return fmt.Errorf("resources.library.validation.pathNotFound") - } else if os.IsPermission(err) { - return fmt.Errorf("resources.library.validation.pathNotAccessible") - } else { - return fmt.Errorf("resources.library.validation.pathInvalid") - } - } - - if !info.IsDir() { - return fmt.Errorf("resources.library.validation.pathNotDirectory") - } - - return nil -} - -func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []int) error { - if len(libraryIDs) == 0 { - return nil - } - - // Use CountAll to efficiently validate library IDs exist - count, err := s.ds.Library().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"id": libraryIDs}, - }) - if err != nil { - return fmt.Errorf("error validating library IDs: %w", err) - } - - if int(count) != len(libraryIDs) { - return fmt.Errorf("%w: one or more library IDs are invalid", model.ErrValidation) - } - - return nil -} - -var scanWaitInterval = time.Second - -func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) { - // Runs in its own goroutine and outlives the HTTP request - ctx = context.WithoutCancel(ctx) - - // A running scan loaded the libraries before this change, and would reject a new request - for { - status, err := r.scanner.Status(ctx) - if err != nil || !status.Scanning { - break - } - time.Sleep(scanWaitInterval) - } - - log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) - start := time.Now() - warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan: the scanner rescans libraries with a changed PID config in full - if errors.Is(err, model.ErrAlreadyScanning) { - log.Debug(ctx, "Scan already running, it covers this change", "libraryID", lib.ID, "name", lib.Name) - } else if err != nil { - log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err) - } else { - log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start)) - } -} diff --git a/core/library_test.go b/core/library_test.go deleted file mode 100644 index e6ebb1974..000000000 --- a/core/library_test.go +++ /dev/null @@ -1,1080 +0,0 @@ -package core_test - -import ( - "context" - "errors" - "net/http" - "os" - "path/filepath" - "sync" - - "github.com/deluan/rest" - _ "github.com/navidrome/navidrome/adapters/gotaglib" // Register taglib extractor - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" - _ "github.com/navidrome/navidrome/core/storage/local" // Register local storage - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// These tests require the local storage adapter and the taglib extractor to be registered. -var _ = Describe("Library Service", func() { - var service core.Library - var ds *tests.MockDataStore - var libraryRepo *tests.MockLibraryRepo - var userRepo *tests.MockedUserRepo - var ctx context.Context - var tempDir string - var scanner *tests.MockScanner - var watcherManager *mockWatcherManager - var broker *mockEventBroker - var pluginManager *mockPluginUnloader - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - - ds = &tests.MockDataStore{} - libraryRepo = &tests.MockLibraryRepo{} - userRepo = tests.CreateMockUserRepo() - ds.MockedLibrary = libraryRepo - ds.MockedUser = userRepo - - // Create a mock scanner that tracks calls - scanner = tests.NewMockScanner() - // Create a mock watcher manager - watcherManager = &mockWatcherManager{ - libraryStates: make(map[int]model.Library), - } - // Create a mock event broker - broker = &mockEventBroker{} - // Create a mock plugin unloader - pluginManager = &mockPluginUnloader{} - service = core.NewLibrary(ds, scanner, watcherManager, broker, pluginManager) - ctx = context.Background() - - // Create a temporary directory for testing valid paths - var err error - tempDir, err = os.MkdirTemp("", "navidrome-library-test-") - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { - os.RemoveAll(tempDir) - }) - }) - - Describe("Library CRUD Operations", func() { - var repo rest.Persistable[model.Library] - - BeforeEach(func() { - r := service.Repository() - repo = r.(rest.Persistable[model.Library]) - }) - - Describe("Create", func() { - It("creates a new library successfully", func() { - library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} - - _, err := repo.Save(ctx, library) - - Expect(err).NotTo(HaveOccurred()) - Expect(libraryRepo.Data[1].Name).To(Equal("New Library")) - Expect(libraryRepo.Data[1].Path).To(Equal(tempDir)) - }) - - It("fails when library name is empty", func() { - library := &model.Library{Path: tempDir} - - _, err := repo.Save(ctx, library) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("ra.validation.required")) - }) - - It("fails when library path is empty", func() { - library := &model.Library{Name: "Test"} - - _, err := repo.Save(ctx, library) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("ra.validation.required")) - }) - - It("fails when library path is not absolute", func() { - library := &model.Library{Name: "Test", Path: "relative/path"} - - _, err := repo.Save(ctx, library) - - Expect(err).To(HaveOccurred()) - var validationErr *rest.ValidationError - Expect(errors.As(err, &validationErr)).To(BeTrue()) - Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute")) - }) - - Context("Database constraint violations", func() { - BeforeEach(func() { - // Set up an existing library that will cause constraint violations - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Existing Library", Path: tempDir}, - }) - }) - - AfterEach(func() { - // Reset custom PutFn after each test - libraryRepo.PutFn = nil - }) - - It("handles name uniqueness constraint violation from database", func() { - // Create the directory that will be used for the test - otherTempDir, err := os.MkdirTemp("", "navidrome-other-") - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { os.RemoveAll(otherTempDir) }) - - // Try to create another library with the same name - library := &model.Library{ID: 2, Name: "Existing Library", Path: otherTempDir} - - // Mock the repository to return a UNIQUE constraint error - libraryRepo.PutFn = func(library *model.Library) error { - return errors.New("UNIQUE constraint failed: library.name") - } - - _, err = repo.Save(ctx, library) - - Expect(err).To(HaveOccurred()) - var validationErr *rest.ValidationError - Expect(errors.As(err, &validationErr)).To(BeTrue()) - Expect(validationErr.Errors["name"]).To(Equal("ra.validation.unique")) - }) - - It("handles path uniqueness constraint violation from database", func() { - // Try to create another library with the same path - library := &model.Library{ID: 2, Name: "Different Library", Path: tempDir} - - // Mock the repository to return a UNIQUE constraint error - libraryRepo.PutFn = func(library *model.Library) error { - return errors.New("UNIQUE constraint failed: library.path") - } - - _, err := repo.Save(ctx, library) - - Expect(err).To(HaveOccurred()) - var validationErr *rest.ValidationError - Expect(errors.As(err, &validationErr)).To(BeTrue()) - Expect(validationErr.Errors["path"]).To(Equal("ra.validation.unique")) - }) - }) - }) - - Describe("Update", func() { - BeforeEach(func() { - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Original Library", Path: tempDir}, - }) - }) - - It("updates an existing library successfully", func() { - newTempDir, err := os.MkdirTemp("", "navidrome-library-update-") - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { os.RemoveAll(newTempDir) }) - - library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir} - - err = repo.Update(ctx, "1", *library) - - Expect(err).NotTo(HaveOccurred()) - Expect(libraryRepo.Data[1].Name).To(Equal("Updated Library")) - Expect(libraryRepo.Data[1].Path).To(Equal(newTempDir)) - }) - - It("forwards the columns sent by the client to the repository", func() { - library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir} - - err := repo.Update(ctx, "1", *library, "name", "path") - - Expect(err).NotTo(HaveOccurred()) - Expect(libraryRepo.PutCols).To(Equal([]string{"name", "path"})) - }) - - It("fails when library doesn't exist", func() { - // Create a unique temporary directory to avoid path conflicts - uniqueTempDir, err := os.MkdirTemp("", "navidrome-nonexistent-") - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { os.RemoveAll(uniqueTempDir) }) - - library := &model.Library{ID: 999, Name: "Non-existent", Path: uniqueTempDir} - - err = repo.Update(ctx, "999", *library) - - Expect(err).To(HaveOccurred()) - Expect(err).To(Equal(model.ErrNotFound)) - }) - - It("fails when library name is empty", func() { - library := &model.Library{ID: 1, Path: tempDir} - - err := repo.Update(ctx, "1", *library) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("ra.validation.required")) - }) - - It("cleans and normalizes the path on update", func() { - unnormalizedPath := tempDir + "//../" + filepath.Base(tempDir) - library := &model.Library{ID: 1, Name: "Updated Library", Path: unnormalizedPath} - - err := repo.Update(ctx, "1", *library) - - Expect(err).NotTo(HaveOccurred()) - Expect(libraryRepo.Data[1].Path).To(Equal(filepath.Clean(unnormalizedPath))) - }) - - It("allows updating library with same name (no change)", func() { - // Set up a library - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Test Library", Path: tempDir}, - }) - - // Update the library keeping the same name (should be allowed) - library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir} - - err := repo.Update(ctx, "1", *library) - - Expect(err).NotTo(HaveOccurred()) - }) - - It("allows updating library with same path (no change)", func() { - // Set up a library - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Test Library", Path: tempDir}, - }) - - // Update the library keeping the same path (should be allowed) - library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir} - - err := repo.Update(ctx, "1", *library) - - Expect(err).NotTo(HaveOccurred()) - }) - - Context("Database constraint violations during update", func() { - BeforeEach(func() { - // Reset any custom PutFn from previous tests - libraryRepo.PutFn = nil - }) - - It("handles name uniqueness constraint violation during update", func() { - // Create additional temp directory for the test - otherTempDir, err := os.MkdirTemp("", "navidrome-other-") - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { os.RemoveAll(otherTempDir) }) - - // Set up two libraries - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Library One", Path: tempDir}, - {ID: 2, Name: "Library Two", Path: otherTempDir}, - }) - - // Mock database constraint violation - libraryRepo.PutFn = func(library *model.Library) error { - return errors.New("UNIQUE constraint failed: library.name") - } - - // Try to update library 2 to have the same name as library 1 - library := &model.Library{ID: 2, Name: "Library One", Path: otherTempDir} - - err = repo.Update(ctx, "2", *library) - - Expect(err).To(HaveOccurred()) - var validationErr *rest.ValidationError - Expect(errors.As(err, &validationErr)).To(BeTrue()) - Expect(validationErr.Errors["name"]).To(Equal("ra.validation.unique")) - }) - - It("handles path uniqueness constraint violation during update", func() { - // Create additional temp directory for the test - otherTempDir, err := os.MkdirTemp("", "navidrome-other-") - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { os.RemoveAll(otherTempDir) }) - - // Set up two libraries - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Library One", Path: tempDir}, - {ID: 2, Name: "Library Two", Path: otherTempDir}, - }) - - // Mock database constraint violation - libraryRepo.PutFn = func(library *model.Library) error { - return errors.New("UNIQUE constraint failed: library.path") - } - - // Try to update library 2 to have the same path as library 1 - library := &model.Library{ID: 2, Name: "Library Two", Path: tempDir} - - err = repo.Update(ctx, "2", *library) - - Expect(err).To(HaveOccurred()) - var validationErr *rest.ValidationError - Expect(errors.As(err, &validationErr)).To(BeTrue()) - Expect(validationErr.Errors["path"]).To(Equal("ra.validation.unique")) - }) - }) - }) - - Describe("PID validation", func() { - pidError := func(err error, field string) string { - var validationErr *rest.ValidationError - Expect(errors.As(err, &validationErr)).To(BeTrue()) - return validationErr.Errors[field] - } - - It("rejects an unknown attribute in the album PID", func() { - _, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albmversion"}) - Expect(pidError(err, "pidAlbum")).To(ContainSubstring(`unknown attribute "albmversion"`)) - }) - - It("rejects albumid in the album PID", func() { - _, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albumid"}) - Expect(pidError(err, "pidAlbum")).To(ContainSubstring("albumid")) - }) - - It("rejects an unknown attribute in the track PID", func() { - _, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDTrack: "nosuchtag"}) - Expect(pidError(err, "pidTrack")).To(ContainSubstring(`unknown attribute "nosuchtag"`)) - }) - - It("trims spaces", func() { - library := &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: " folder ", PIDTrack: " "} - _, err := repo.Save(ctx, library) - Expect(err).ToNot(HaveOccurred()) - Expect(library.PIDAlbum).To(Equal("folder")) - Expect(library.PIDTrack).To(BeEmpty()) - }) - }) - - Describe("Path Validation", func() { - Context("Create operation", func() { - It("fails when path is not absolute", func() { - library := &model.Library{Name: "Test", Path: "relative/path"} - - _, err := repo.Save(ctx, library) - - Expect(err).To(HaveOccurred()) - var validationErr *rest.ValidationError - Expect(errors.As(err, &validationErr)).To(BeTrue()) - Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute")) - }) - - It("fails when path does not exist", func() { - nonExistentPath := filepath.Join(tempDir, "nonexistent") - library := &model.Library{Name: "Test", Path: nonExistentPath} - - _, err := repo.Save(ctx, library) - - Expect(err).To(HaveOccurred()) - var validationErr *rest.ValidationError - Expect(errors.As(err, &validationErr)).To(BeTrue()) - Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid")) - }) - - It("fails when path is a file instead of directory", func() { - testFile := filepath.Join(tempDir, "testfile.txt") - err := os.WriteFile(testFile, []byte("test"), 0600) - Expect(err).NotTo(HaveOccurred()) - - library := &model.Library{Name: "Test", Path: testFile} - - _, err = repo.Save(ctx, library) - - Expect(err).To(HaveOccurred()) - var validationErr *rest.ValidationError - Expect(errors.As(err, &validationErr)).To(BeTrue()) - Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotDirectory")) - }) - - It("fails when path is not accessible due to permissions", func() { - Skip("Permission tests are environment-dependent and may fail in CI") - // This test is skipped because creating a directory with no read permissions - // is complex and may not work consistently across different environments - }) - - It("handles multiple validation errors", func() { - library := &model.Library{Name: "", Path: "relative/path"} - - _, err := repo.Save(ctx, library) - - Expect(err).To(HaveOccurred()) - var validationErr *rest.ValidationError - Expect(errors.As(err, &validationErr)).To(BeTrue()) - Expect(validationErr.Errors).To(HaveKey("name")) - Expect(validationErr.Errors).To(HaveKey("path")) - Expect(validationErr.Errors["name"]).To(Equal("ra.validation.required")) - Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute")) - }) - }) - - Context("Update operation", func() { - BeforeEach(func() { - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Test Library", Path: tempDir}, - }) - }) - - It("fails when updated path is not absolute", func() { - library := &model.Library{ID: 1, Name: "Test", Path: "relative/path"} - - err := repo.Update(ctx, "1", *library) - - Expect(err).To(HaveOccurred()) - var validationErr *rest.ValidationError - Expect(errors.As(err, &validationErr)).To(BeTrue()) - Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute")) - }) - - It("allows updating library with same name (no change)", func() { - // Set up a library - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Test Library", Path: tempDir}, - }) - - // Update the library keeping the same name (should be allowed) - library := &model.Library{ID: 1, Name: "Test Library", Path: tempDir} - - err := repo.Update(ctx, "1", *library) - - Expect(err).NotTo(HaveOccurred()) - }) - - It("fails when updated path does not exist", func() { - nonExistentPath := filepath.Join(tempDir, "nonexistent") - library := &model.Library{ID: 1, Name: "Test", Path: nonExistentPath} - - err := repo.Update(ctx, "1", *library) - - Expect(err).To(HaveOccurred()) - var validationErr *rest.ValidationError - Expect(errors.As(err, &validationErr)).To(BeTrue()) - Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid")) - }) - - It("fails when updated path is a file instead of directory", func() { - testFile := filepath.Join(tempDir, "updatefile.txt") - err := os.WriteFile(testFile, []byte("test"), 0600) - Expect(err).NotTo(HaveOccurred()) - - library := &model.Library{ID: 1, Name: "Test", Path: testFile} - - err = repo.Update(ctx, "1", *library) - - Expect(err).To(HaveOccurred()) - var validationErr *rest.ValidationError - Expect(errors.As(err, &validationErr)).To(BeTrue()) - Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotDirectory")) - }) - - It("handles multiple validation errors on update", func() { - // Try to update with empty name and invalid path - library := &model.Library{ID: 1, Name: "", Path: "relative/path"} - - err := repo.Update(ctx, "1", *library) - - Expect(err).To(HaveOccurred()) - var validationErr *rest.ValidationError - Expect(errors.As(err, &validationErr)).To(BeTrue()) - Expect(validationErr.Errors).To(HaveKey("name")) - Expect(validationErr.Errors).To(HaveKey("path")) - Expect(validationErr.Errors["name"]).To(Equal("ra.validation.required")) - Expect(validationErr.Errors["path"]).To(Equal("library path must be absolute")) - }) - }) - }) - - Describe("Delete", func() { - BeforeEach(func() { - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Library to Delete", Path: tempDir}, - }) - }) - - It("deletes an existing library successfully", func() { - err := repo.Delete(ctx, "1") - - Expect(err).NotTo(HaveOccurred()) - Expect(libraryRepo.Data).To(HaveLen(0)) - }) - - It("fails when library doesn't exist", func() { - err := repo.Delete(ctx, "999") - - Expect(err).To(HaveOccurred()) - Expect(err).To(Equal(model.ErrNotFound)) - }) - }) - }) - - Describe("User-Library Association Operations", func() { - var regularUser, adminUser *model.User - - BeforeEach(func() { - regularUser = &model.User{ID: "user1", UserName: "regular", IsAdmin: false} - adminUser = &model.User{ID: "admin1", UserName: "admin", IsAdmin: true} - - userRepo.Data = map[string]*model.User{ - "regular": regularUser, - "admin": adminUser, - } - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Library 1", Path: "/music1"}, - {ID: 2, Name: "Library 2", Path: "/music2"}, - {ID: 3, Name: "Library 3", Path: "/music3"}, - }) - }) - - Describe("GetUserLibraries", func() { - It("returns user's libraries", func() { - userRepo.UserLibraries = map[string][]int{ - "user1": {1}, - } - - result, err := service.GetUserLibraries(ctx, "user1") - - Expect(err).NotTo(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal(1)) - }) - - It("fails when user doesn't exist", func() { - _, err := service.GetUserLibraries(ctx, "nonexistent") - - Expect(err).To(HaveOccurred()) - Expect(err).To(Equal(model.ErrNotFound)) - }) - }) - - Describe("SetUserLibraries", func() { - It("sets libraries for regular user successfully", func() { - err := service.SetUserLibraries(ctx, "user1", []int{1, 2}) - - Expect(err).NotTo(HaveOccurred()) - libraries := userRepo.UserLibraries["user1"] - Expect(libraries).To(HaveLen(2)) - }) - - It("fails when user doesn't exist", func() { - err := service.SetUserLibraries(ctx, "nonexistent", []int{1}) - - Expect(err).To(HaveOccurred()) - Expect(err).To(Equal(model.ErrNotFound)) - }) - - It("fails when trying to set libraries for admin user", func() { - err := service.SetUserLibraries(ctx, "admin1", []int{1}) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("cannot manually assign libraries to admin users")) - }) - - It("fails when no libraries provided for regular user", func() { - err := service.SetUserLibraries(ctx, "user1", []int{}) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("at least one library must be assigned to non-admin users")) - }) - - It("fails when library doesn't exist", func() { - err := service.SetUserLibraries(ctx, "user1", []int{999}) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("one or more library IDs are invalid")) - }) - - It("fails when some libraries don't exist", func() { - err := service.SetUserLibraries(ctx, "user1", []int{1, 999, 2}) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("one or more library IDs are invalid")) - }) - }) - - Describe("ValidateLibraryAccess", func() { - Context("admin user", func() { - BeforeEach(func() { - ctx = request.WithUser(ctx, *adminUser) - }) - - It("allows access to any library", func() { - err := service.ValidateLibraryAccess(ctx, "admin1", 1) - - Expect(err).NotTo(HaveOccurred()) - }) - }) - - Context("regular user", func() { - BeforeEach(func() { - ctx = request.WithUser(ctx, *regularUser) - userRepo.UserLibraries = map[string][]int{ - "user1": {1}, - } - }) - - It("allows access to user's libraries", func() { - err := service.ValidateLibraryAccess(ctx, "user1", 1) - - Expect(err).NotTo(HaveOccurred()) - }) - - It("denies access to libraries user doesn't have", func() { - err := service.ValidateLibraryAccess(ctx, "user1", 2) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("user does not have access to library 2")) - }) - }) - - Context("no user in context", func() { - It("fails with user not found error", func() { - err := service.ValidateLibraryAccess(ctx, "user1", 1) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("user not found in context")) - }) - }) - }) - }) - - Describe("Scan Triggering", func() { - var repo rest.Persistable[model.Library] - - BeforeEach(func() { - r := service.Repository() - repo = r.(rest.Persistable[model.Library]) - }) - - It("triggers scan when creating a new library", func() { - library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} - - _, err := repo.Save(ctx, library) - Expect(err).NotTo(HaveOccurred()) - - // Wait briefly for the goroutine to complete - Eventually(func() int { - return scanner.GetScanAllCallCount() - }, "1s", "10ms").Should(Equal(1)) - - // Verify scan was called with correct parameters - calls := scanner.GetScanAllCalls() - Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan - }) - - It("triggers scan when updating library path", func() { - // First create a library - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Original Library", Path: tempDir}, - }) - - // Create a new temporary directory for the update - newTempDir, err := os.MkdirTemp("", "navidrome-library-update-") - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { os.RemoveAll(newTempDir) }) - - // Update the library with a new path - library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir} - err = repo.Update(ctx, "1", *library) - Expect(err).NotTo(HaveOccurred()) - - // Wait briefly for the goroutine to complete - Eventually(func() int { - return scanner.GetScanAllCallCount() - }, "1s", "10ms").Should(Equal(1)) - - // Verify scan was called with correct parameters - calls := scanner.GetScanAllCalls() - Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan - }) - - It("does not trigger scan when updating library without path change", func() { - // First create a library - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Original Library", Path: tempDir}, - }) - - // Update the library name only (same path) - library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir} - err := repo.Update(ctx, "1", *library) - Expect(err).NotTo(HaveOccurred()) - - // Wait a bit to ensure no scan was triggered - Consistently(func() int { - return scanner.GetScanAllCallCount() - }, "100ms", "10ms").Should(Equal(0)) - }) - - It("triggers scan when updating the library PID config", func() { - libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}}) - - library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"} - Expect(repo.Update(ctx, "1", library)).To(Succeed()) - - Eventually(func() int { - return scanner.GetScanAllCallCount() - }, "1s", "10ms").Should(Equal(1)) - // A quick scan: the scanner itself rescans this library in full - Expect(scanner.GetScanAllCalls()[0].FullScan).To(BeFalse()) - }) - - It("does not trigger scan when the PID fields were not sent", func() { - libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}}) - - // The REST layer decodes a missing pidAlbum as "". Only the sent fields count. - library := model.Library{ID: 1, Name: "Renamed", Path: tempDir} - Expect(repo.Update(ctx, "1", library, "name", "path")).To(Succeed()) - - Consistently(func() int { - return scanner.GetScanAllCallCount() - }, "100ms", "10ms").Should(Equal(0)) - }) - - It("waits for a running scan before triggering a new one", func() { - libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}}) - scanner.SetScanning(true) - - library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"} - Expect(repo.Update(ctx, "1", library)).To(Succeed()) - - Consistently(func() int { - return scanner.GetScanAllCallCount() - }, "200ms", "20ms").Should(Equal(0)) - - scanner.SetScanning(false) - Eventually(func() int { - return scanner.GetScanAllCallCount() - }, "3s", "20ms").Should(Equal(1)) - }) - - It("does not trigger scan when library creation fails", func() { - // Try to create library with invalid data (empty name) - library := &model.Library{Path: tempDir} - - _, err := repo.Save(ctx, library) - Expect(err).To(HaveOccurred()) - - // Ensure no scan was triggered since creation failed - Consistently(func() int { - return scanner.GetScanAllCallCount() - }, "100ms", "10ms").Should(Equal(0)) - }) - - It("does not trigger scan when library update fails", func() { - // First create a library - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Original Library", Path: tempDir}, - }) - - // Try to update with invalid data (empty name) - library := &model.Library{ID: 1, Name: "", Path: tempDir} - err := repo.Update(ctx, "1", *library) - Expect(err).To(HaveOccurred()) - - // Ensure no scan was triggered since update failed - Consistently(func() int { - return scanner.GetScanAllCallCount() - }, "100ms", "10ms").Should(Equal(0)) - }) - - It("triggers scan when deleting a library", func() { - // First create a library - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Library to Delete", Path: tempDir}, - }) - - // Delete the library - err := repo.Delete(ctx, "1") - Expect(err).NotTo(HaveOccurred()) - - // Wait briefly for the goroutine to complete - Eventually(func() int { - return scanner.GetScanAllCallCount() - }, "1s", "10ms").Should(Equal(1)) - - // Verify scan was called with correct parameters - calls := scanner.GetScanAllCalls() - Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan - }) - - It("does not trigger scan when library deletion fails", func() { - // Try to delete a non-existent library - err := repo.Delete(ctx, "999") - Expect(err).To(HaveOccurred()) - - // Ensure no scan was triggered since deletion failed - Consistently(func() int { - return scanner.GetScanAllCallCount() - }, "100ms", "10ms").Should(Equal(0)) - }) - - Context("Watcher Integration", func() { - It("starts watcher when creating a new library", func() { - library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} - - _, err := repo.Save(ctx, library) - Expect(err).NotTo(HaveOccurred()) - - // Verify watcher was started - Eventually(func() int { - return watcherManager.lenStarted() - }, "1s", "10ms").Should(Equal(1)) - - Expect(watcherManager.StartedWatchers[0].ID).To(Equal(1)) - Expect(watcherManager.StartedWatchers[0].Name).To(Equal("New Library")) - Expect(watcherManager.StartedWatchers[0].Path).To(Equal(tempDir)) - }) - - It("restarts watcher when library path is updated", func() { - // First create a library - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Original Library", Path: tempDir}, - }) - - // Simulate that this library already has a watcher - watcherManager.simulateExistingLibrary(model.Library{ID: 1, Name: "Original Library", Path: tempDir}) - - // Create a new temp directory for the update - newTempDir, err := os.MkdirTemp("", "navidrome-library-update-") - Expect(err).NotTo(HaveOccurred()) - DeferCleanup(func() { os.RemoveAll(newTempDir) }) - - // Update library with new path - library := &model.Library{ID: 1, Name: "Updated Library", Path: newTempDir} - err = repo.Update(ctx, "1", *library) - Expect(err).NotTo(HaveOccurred()) - - // Verify watcher was restarted - Eventually(func() int { - return watcherManager.lenRestarted() - }, "1s", "10ms").Should(Equal(1)) - - Expect(watcherManager.RestartedWatchers[0].ID).To(Equal(1)) - Expect(watcherManager.RestartedWatchers[0].Path).To(Equal(newTempDir)) - }) - - It("does not restart watcher when only library name is updated", func() { - // First create a library - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Original Library", Path: tempDir}, - }) - - // Update library with same path but different name - library := &model.Library{ID: 1, Name: "Updated Name", Path: tempDir} - err := repo.Update(ctx, "1", *library) - Expect(err).NotTo(HaveOccurred()) - - // Verify watcher was NOT restarted (since path didn't change) - Consistently(func() int { - return watcherManager.lenRestarted() - }, "100ms", "10ms").Should(Equal(0)) - }) - - It("stops watcher when library is deleted", func() { - // Set up a library - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Test Library", Path: tempDir}, - }) - - err := repo.Delete(ctx, "1") - Expect(err).NotTo(HaveOccurred()) - - // Verify watcher was stopped - Eventually(func() int { - return watcherManager.lenStopped() - }, "1s", "10ms").Should(Equal(1)) - - Expect(watcherManager.StoppedWatchers[0]).To(Equal(1)) - }) - - It("does not stop watcher when library deletion fails", func() { - // Set up a library - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Test Library", Path: tempDir}, - }) - - // Mock deletion to fail by trying to delete non-existent library - err := repo.Delete(ctx, "999") - Expect(err).To(HaveOccurred()) - - // Verify watcher was NOT stopped since deletion failed - Consistently(func() int { - return watcherManager.lenStopped() - }, "100ms", "10ms").Should(Equal(0)) - }) - }) - }) - - Describe("Event Broadcasting", func() { - var repo rest.Persistable[model.Library] - - BeforeEach(func() { - r := service.Repository() - repo = r.(rest.Persistable[model.Library]) - // Clear any events from broker - broker.Events = []events.Event{} - }) - - It("sends refresh event when creating a library", func() { - library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} - - _, err := repo.Save(ctx, library) - - Expect(err).NotTo(HaveOccurred()) - Expect(broker.Events).To(HaveLen(1)) - }) - - It("sends refresh event when updating a library", func() { - // First create a library - libraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Original Library", Path: tempDir}, - }) - - library := &model.Library{ID: 1, Name: "Updated Library", Path: tempDir} - err := repo.Update(ctx, "1", *library) - - Expect(err).NotTo(HaveOccurred()) - Expect(broker.Events).To(HaveLen(1)) - }) - - It("sends refresh event when deleting a library", func() { - // First create a library - libraryRepo.SetData(model.Libraries{ - {ID: 2, Name: "Library to Delete", Path: tempDir}, - }) - - err := repo.Delete(ctx, "2") - - Expect(err).NotTo(HaveOccurred()) - Expect(broker.Events).To(HaveLen(1)) - }) - }) - - Describe("Plugin Manager Integration", func() { - var repo rest.Persistable[model.Library] - - BeforeEach(func() { - // Reset the call count for each test - pluginManager.unloadCalls = 0 - r := service.Repository() - repo = r.(rest.Persistable[model.Library]) - }) - - It("calls UnloadDisabledPlugins after successful library deletion", func() { - libraryRepo.SetData(model.Libraries{ - {ID: 2, Name: "Library to Delete", Path: tempDir}, - }) - - err := repo.Delete(ctx, "2") - Expect(err).NotTo(HaveOccurred()) - Expect(pluginManager.unloadCalls).To(Equal(1)) - }) - - It("does not call UnloadDisabledPlugins when library deletion fails", func() { - // Try to delete non-existent library - err := repo.Delete(ctx, "999") - Expect(err).To(HaveOccurred()) - Expect(pluginManager.unloadCalls).To(Equal(0)) - }) - }) -}) - -// mockPluginUnloader is a simple mock for testing UnloadDisabledPlugins calls -type mockPluginUnloader struct { - unloadCalls int -} - -func (m *mockPluginUnloader) UnloadDisabledPlugins(ctx context.Context) { - m.unloadCalls++ -} - -// mockWatcherManager provides a simple mock implementation of core.Watcher for testing -type mockWatcherManager struct { - StartedWatchers []model.Library - StoppedWatchers []int - RestartedWatchers []model.Library - libraryStates map[int]model.Library // Track which libraries we know about - mu sync.RWMutex -} - -func (m *mockWatcherManager) Watch(ctx context.Context, lib *model.Library) error { - m.mu.Lock() - defer m.mu.Unlock() - - // Check if we already know about this library ID - if _, exists := m.libraryStates[lib.ID]; exists { - // This is a restart - the library already existed - // Update our tracking and record the restart - for i, startedLib := range m.StartedWatchers { - if startedLib.ID == lib.ID { - m.StartedWatchers[i] = *lib - break - } - } - m.RestartedWatchers = append(m.RestartedWatchers, *lib) - m.libraryStates[lib.ID] = *lib - return nil - } - - // This is a new library - first time we're seeing it - m.StartedWatchers = append(m.StartedWatchers, *lib) - m.libraryStates[lib.ID] = *lib - return nil -} - -func (m *mockWatcherManager) StopWatching(ctx context.Context, libraryID int) error { - m.mu.Lock() - defer m.mu.Unlock() - m.StoppedWatchers = append(m.StoppedWatchers, libraryID) - return nil -} - -func (m *mockWatcherManager) lenStarted() int { - m.mu.RLock() - defer m.mu.RUnlock() - return len(m.StartedWatchers) -} - -func (m *mockWatcherManager) lenStopped() int { - m.mu.RLock() - defer m.mu.RUnlock() - return len(m.StoppedWatchers) -} - -func (m *mockWatcherManager) lenRestarted() int { - m.mu.RLock() - defer m.mu.RUnlock() - return len(m.RestartedWatchers) -} - -// simulateExistingLibrary simulates the scenario where a library already exists -// and has a watcher running (used by tests to set up the initial state) -func (m *mockWatcherManager) simulateExistingLibrary(lib model.Library) { - m.mu.Lock() - defer m.mu.Unlock() - m.libraryStates[lib.ID] = lib -} - -// mockEventBroker provides a mock implementation of events.Broker for testing -type mockEventBroker struct { - http.Handler - Events []events.Event - mu sync.RWMutex -} - -func (m *mockEventBroker) SendMessage(ctx context.Context, event events.Event) { - m.mu.Lock() - defer m.mu.Unlock() - m.Events = append(m.Events, event) -} - -func (m *mockEventBroker) SendBroadcastMessage(ctx context.Context, event events.Event) { - m.mu.Lock() - defer m.mu.Unlock() - m.Events = append(m.Events, event) -} diff --git a/core/lyrics/lyrics.go b/core/lyrics/lyrics.go deleted file mode 100644 index d3e8d72b8..000000000 --- a/core/lyrics/lyrics.go +++ /dev/null @@ -1,125 +0,0 @@ -package lyrics - -import ( - "context" - "strings" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" -) - -// maxLegacyLyricsCandidates bounds the duplicate window scanned by the legacy -// artist/title lookup, so source-priority resolution can still reach older -// matches without turning it into an unbounded table scan. -const maxLegacyLyricsCandidates = 10 - -// Provider fetches lyrics for a single media file. It is the contract -// implemented by individual lyrics sources, such as plugins. -type Provider interface { - GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) -} - -// Lyrics resolves lyrics for media files, honoring the configured source -// priority. -type Lyrics interface { - Provider - GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) -} - -// PluginLoader discovers and loads lyrics provider plugins. -type PluginLoader interface { - LoadLyricsProvider(name string) (Provider, bool) -} - -type lyricsService struct { - ds model.DataStore - pluginLoader PluginLoader -} - -// NewLyrics creates a new lyrics service. pluginLoader may be nil if no plugin -// system is available. -func NewLyrics(ds model.DataStore, pluginLoader PluginLoader) Lyrics { - return &lyricsService{ds: ds, pluginLoader: pluginLoader} -} - -// GetLyrics returns lyrics for the given media file, trying sources in the -// order specified by conf.Server.LyricsPriority. -func (l *lyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { - return l.getLyricsForCandidates(ctx, []*model.MediaFile{mf}) -} - -// GetLyricsByArtistTitle resolves lyrics for the legacy artist/title lookup, -// scanning a bounded window of duplicate matches so source priority still wins -// across them. -func (l *lyricsService) GetLyricsByArtistTitle(ctx context.Context, artist, title string) (model.LyricList, error) { - opts := songsByArtistTitleWithLyricsFirst(artist, title) - opts.Max = maxLegacyLyricsCandidates - mediaFiles, err := l.ds.MediaFile().GetAll(ctx, opts) - if err != nil { - return nil, err - } - if len(mediaFiles) == 0 { - return nil, nil - } - candidates := make([]*model.MediaFile, 0, len(mediaFiles)) - for i := range mediaFiles { - candidates = append(candidates, &mediaFiles[i]) - } - return l.getLyricsForCandidates(ctx, candidates) -} - -func songsByArtistTitleWithLyricsFirst(artist, title string) model.QueryOptions { - return model.QueryOptions{ - Sort: "lyrics, updated_at", - Order: "desc", - Filters: And{ - Eq{"missing": false}, - Eq{"title": title}, - Or{ - persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}), - persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}), - }, - }, - } -} - -func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles []*model.MediaFile) (model.LyricList, error) { - for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") { - pattern = strings.TrimSpace(pattern) - if pattern == "" { - continue - } - - for _, mf := range mediaFiles { - if mf == nil { - continue - } - - lyricsList, err := l.getLyricsFromSource(ctx, mf, pattern) - if err != nil { - log.Error(ctx, "error getting lyrics", "source", pattern, err) - continue - } - - if len(lyricsList) > 0 { - return lyricsList, nil - } - } - } - - return nil, nil -} - -func (l *lyricsService) getLyricsFromSource(ctx context.Context, mf *model.MediaFile, pattern string) (model.LyricList, error) { - switch { - case strings.EqualFold(pattern, "embedded"): - return fromEmbedded(ctx, mf) - case strings.HasPrefix(pattern, "."): - return fromExternalFile(ctx, mf, pattern) - default: - return l.fromPlugin(ctx, mf, pattern) - } -} diff --git a/core/lyrics/lyrics_suite_test.go b/core/lyrics/lyrics_suite_test.go deleted file mode 100644 index c9fdcbae8..000000000 --- a/core/lyrics/lyrics_suite_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package lyrics_test - -import ( - "io/fs" - "testing" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/storage/local" - "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" -) - -func TestLyrics(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Lyrics Suite") -} - -// core/storage/local calls log.Fatal if the default scanner extractor is unregistered -// when constructing any localStorage. Register a no-op so storage.For("file://...") works -// in tests without importing the real extractor. -var _ = BeforeSuite(func() { - local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { - return &noopExtractor{} - }) -}) - -type noopExtractor struct{} - -func (e *noopExtractor) Parse(_ ...string) (map[string]metadata.Info, error) { return nil, nil } -func (e *noopExtractor) Version() string { return "noop" } diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go deleted file mode 100644 index b00bcd576..000000000 --- a/core/lyrics/lyrics_test.go +++ /dev/null @@ -1,447 +0,0 @@ -package lyrics_test - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "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/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Lyrics", func() { - var mf model.MediaFile - var ctx context.Context - - embeddedLyrics := model.LyricList{ - model.Lyrics{ - Lang: "xxx", - Line: []model.Line{ - {Value: "This is a set of lyrics"}, - {Value: "That is not good"}, - }, - }, - } - - syncedLyrics := model.LyricList{ - model.Lyrics{ - DisplayArtist: "Rick Astley", - DisplayTitle: "That one song", - Lang: "eng", - Line: []model.Line{ - { - Start: new(int64(18800)), - Value: "We're no strangers to love", - }, - { - Start: new(int64(22801)), - Value: "You know the rules and so do I", - }, - }, - Offset: new(int64(-100)), - Synced: true, - }, - } - - elrcLyrics := model.LyricList{ - model.Lyrics{ - DisplayArtist: "ELRC Artist", - DisplayTitle: "ELRC Song", - Lang: "eng", - Line: []model.Line{ - { - Start: new(int64(1000)), - End: new(int64(3000)), - Value: "Lead words", - Cue: []model.Cue{ - { - Start: new(int64(1000)), - End: new(int64(1500)), - Value: "Lead ", - ByteStart: 0, - ByteEnd: 4, - }, - { - Start: new(int64(1500)), - End: new(int64(3000)), - Value: "words", - ByteStart: 5, - ByteEnd: 9, - }, - }, - }, - { - Start: new(int64(3000)), - Value: "Fallback line", - }, - }, - Synced: true, - }, - } - - ttmlLyrics := model.LyricList{ - model.Lyrics{ - Kind: "main", - Lang: "eng", - Line: []model.Line{ - { - Start: new(int64(18800)), - Value: "We're no strangers to love", - }, - { - Start: new(int64(22800)), - Value: "You know the rules and so do I", - }, - }, - Synced: true, - }, - model.Lyrics{ - Kind: "main", - Lang: "por", - Line: []model.Line{ - { - Start: new(int64(18800)), - Value: "Nao somos estranhos ao amor", - }, - }, - Synced: true, - }, - } - - unsyncedLyrics := model.LyricList{ - model.Lyrics{ - Lang: "xxx", - Line: []model.Line{ - { - Value: "We're no strangers to love", - }, - { - Value: "You know the rules and so do I", - }, - }, - Synced: false, - }, - } - - srtLyrics := model.LyricList{ - model.Lyrics{ - Lang: "xxx", - Line: []model.Line{ - { - Start: new(int64(18800)), - End: new(int64(22800)), - Value: "We're from subtitles", - }, - { - Start: new(int64(22801)), - End: new(int64(26000)), - Value: "Another subtitle line", - }, - }, - Synced: true, - }, - } - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - - lyricsJson, _ := json.Marshal(embeddedLyrics) - - mf = model.MediaFile{ - Lyrics: string(lyricsJson), - Path: "tests/fixtures/test.mp3", - } - ctx = GinkgoT().Context() - }) - - DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) { - conf.Server.LyricsPriority = priority - svc := lyrics.NewLyrics(nil, nil) - list, err := svc.GetLyrics(ctx, &mf) - Expect(err).To(BeNil()) - Expect(list).To(Equal(expected)) - }, - Entry("embedded > lrc > txt", "embedded,.lrc,.txt", embeddedLyrics), - Entry("lrc > embedded > txt", ".lrc,embedded,.txt", syncedLyrics), - Entry("elrc > lrc > embedded", ".elrc,.lrc,embedded", elrcLyrics), - Entry("srt > txt > embedded", ".srt,.txt,embedded", srtLyrics), - Entry("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics), - Entry("ttml > elrc > lrc > srt > embedded", ".ttml,.elrc,.lrc,.srt,embedded", ttmlLyrics)) - - It("resolves source priority across duplicate media files", func() { - conf.Server.LyricsPriority = ".ttml,embedded" - embeddedJSON, err := json.Marshal(embeddedLyrics) - Expect(err).To(BeNil()) - - repo := &tests.MockMediaFileRepo{} - repo.SetData(model.MediaFiles{ - { - Lyrics: string(embeddedJSON), - Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3", - }, - { - Lyrics: "[]", - Path: "tests/fixtures/test.mp3", - }, - }) - svc := lyrics.NewLyrics(&tests.MockDataStore{MockedMediaFile: repo}, nil) - - list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") - Expect(err).To(BeNil()) - Expect(list).To(Equal(ttmlLyrics)) - }) - - It("preserves configured sidecar suffix casing on case-sensitive filesystems", func() { - dir, err := os.MkdirTemp("", "lyrics-case-*") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - Expect(os.RemoveAll(dir)).To(Succeed()) - }) - - probe := filepath.Join(dir, "CASECHECK") - Expect(os.WriteFile(probe, []byte("probe"), 0600)).To(Succeed()) - _, err = os.Stat(filepath.Join(dir, "casecheck")) - if err == nil { - Skip("filesystem is case-insensitive") - } - Expect(os.IsNotExist(err)).To(BeTrue()) - - conf.Server.LyricsPriority = ".LRC" - Expect(os.WriteFile(filepath.Join(dir, "song.LRC"), []byte("[00:01.00]Upper suffix"), 0600)).To(Succeed()) - - svc := lyrics.NewLyrics(nil, nil) - list, err := svc.GetLyrics(ctx, &model.MediaFile{ - LibraryPath: dir, - Path: "song.mp3", - }) - - Expect(err).To(BeNil()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line).To(Equal([]model.Line{ - {Start: new(int64(1000)), Value: "Upper suffix"}, - })) - }) - - It("returns a non-Lyricsfile YAML sidecar as plain text, shadowing lower-priority sources", func() { - dir, err := os.MkdirTemp("", "lyrics-yaml-fallback-*") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - Expect(os.RemoveAll(dir)).To(Succeed()) - }) - - Expect(os.WriteFile(filepath.Join(dir, "song.yaml"), []byte("title: not lyricsfile\n"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(dir, "song.lrc"), []byte("[00:01.00]Fallback line"), 0600)).To(Succeed()) - - conf.Server.LyricsPriority = ".yaml,.lrc" - svc := lyrics.NewLyrics(nil, nil) - list, err := svc.GetLyrics(ctx, &model.MediaFile{ - LibraryPath: dir, - Path: "song.mp3", - }) - - // ParseLyrics falls back to plain text for any suffix when the content - // doesn't match the structured format, so the .yaml hit is non-empty and - // shadows the lower-priority .lrc entirely. - Expect(err).To(BeNil()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Synced).To(BeFalse()) - Expect(list[0].Line).To(Equal([]model.Line{ - {Value: "title: not lyricsfile"}, - })) - }) - - Context("Errors", func() { - var RegularUserContext = XContext - var isRegularUser = os.Getuid() != 0 - if isRegularUser { - RegularUserContext = Context - } - - RegularUserContext("run without root permissions", 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) - Expect(err).ToNot(HaveOccurred()) - - mf.Path = accessForbiddenFile - - DeferCleanup(func() { - Expect(f.Close()).To(Succeed()) - Expect(os.Remove(accessForbiddenFile)).To(Succeed()) - }) - }) - - It("should fallback to embedded if an error happens when parsing file", func() { - conf.Server.LyricsPriority = ".mp3,embedded" - - svc := lyrics.NewLyrics(nil, nil) - list, err := svc.GetLyrics(ctx, &mf) - Expect(err).To(BeNil()) - Expect(list).To(Equal(embeddedLyrics)) - }) - - It("should return nothing if error happens when trying to parse file", func() { - conf.Server.LyricsPriority = ".mp3" - - svc := lyrics.NewLyrics(nil, nil) - list, err := svc.GetLyrics(ctx, &mf) - Expect(err).To(BeNil()) - Expect(list).To(BeEmpty()) - }) - }) - }) - - Context("plugin sources", func() { - var mockLoader *mockPluginLoader - - BeforeEach(func() { - mockLoader = &mockPluginLoader{} - }) - - It("should return lyrics from a plugin", func() { - conf.Server.LyricsPriority = "test-lyrics-plugin" - mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(nil, mockLoader) - list, err := svc.GetLyrics(ctx, &mf) - Expect(err).To(BeNil()) - Expect(list).To(Equal(unsyncedLyrics)) - }) - - It("should try plugin after embedded returns nothing", func() { - conf.Server.LyricsPriority = "embedded,test-lyrics-plugin" - mf.Lyrics = "" // No embedded lyrics - mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(nil, mockLoader) - list, err := svc.GetLyrics(ctx, &mf) - Expect(err).To(BeNil()) - Expect(list).To(Equal(unsyncedLyrics)) - }) - - It("should skip plugin if embedded has lyrics", func() { - conf.Server.LyricsPriority = "embedded,test-lyrics-plugin" - mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(nil, mockLoader) - list, err := svc.GetLyrics(ctx, &mf) - Expect(err).To(BeNil()) - Expect(list).To(Equal(embeddedLyrics)) // embedded wins - }) - - It("should skip unknown plugin names gracefully", func() { - conf.Server.LyricsPriority = "nonexistent-plugin,embedded" - mockLoader.notFound = true - svc := lyrics.NewLyrics(nil, mockLoader) - list, err := svc.GetLyrics(ctx, &mf) - Expect(err).To(BeNil()) - Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded - }) - - It("should preserve plugin name case from config", func() { - conf.Server.LyricsPriority = "MyLyricsPlugin" - mockLoader.pluginName = "MyLyricsPlugin" - mockLoader.lyrics = unsyncedLyrics - svc := lyrics.NewLyrics(nil, mockLoader) - list, err := svc.GetLyrics(ctx, &mf) - Expect(err).To(BeNil()) - Expect(list).To(Equal(unsyncedLyrics)) - }) - - It("should handle plugin error gracefully", func() { - conf.Server.LyricsPriority = "test-lyrics-plugin,embedded" - mockLoader.err = fmt.Errorf("plugin error") - svc := lyrics.NewLyrics(nil, mockLoader) - list, err := svc.GetLyrics(ctx, &mf) - Expect(err).To(BeNil()) - Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded - }) - }) - - var _ = Describe("GetLyricsByArtistTitle", func() { - var svc lyrics.Lyrics - var repo *tests.MockMediaFileRepo - var ds *tests.MockDataStore - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.LyricsPriority = "embedded" - repo = &tests.MockMediaFileRepo{} - ds = &tests.MockDataStore{MockedMediaFile: repo} - svc = lyrics.NewLyrics(ds, nil) - }) - - It("bounds the query to a duplicate window", func() { - repo.SetData(model.MediaFiles{}) - _, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") - Expect(err).ToNot(HaveOccurred()) - Expect(repo.Options.Max).To(Equal(10)) - }) - - It("returns nil when no media file matches", func() { - repo.SetData(model.MediaFiles{}) - list, err := svc.GetLyricsByArtistTitle(ctx, "Nobody", "No Song") - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(BeNil()) - }) - - It("resolves lyrics from the matched media files", func() { - embeddedList, err := model.ParseLyrics(ctx, ".lrc", "eng", []byte("Embedded lyrics line")) - Expect(err).ToNot(HaveOccurred()) - embedded, _ := embeddedList.Main() - embeddedJSON, err := json.Marshal(model.LyricList{embedded}) - Expect(err).ToNot(HaveOccurred()) - repo.SetData(model.MediaFiles{ - {ID: "1", Title: "Never Gonna Give You Up", Lyrics: string(embeddedJSON)}, - }) - - list, err := svc.GetLyricsByArtistTitle(ctx, "Rick Astley", "Never Gonna Give You Up") - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line[0].Value).To(Equal("Embedded lyrics line")) - }) - }) -}) - -type mockPluginLoader struct { - lyrics model.LyricList - err error - notFound bool - pluginName string // expected plugin name (exact match, like real manager) -} - -func (m *mockPluginLoader) PluginNames(_ string) []string { - if m.notFound { - return nil - } - return []string{"test-lyrics-plugin"} -} - -func (m *mockPluginLoader) LoadLyricsProvider(name string) (lyrics.Provider, bool) { - if m.notFound { - return nil, false - } - // If pluginName is set, require exact match (like the real plugin manager) - if m.pluginName != "" && name != m.pluginName { - return nil, false - } - return &mockLyricsProvider{lyrics: m.lyrics, err: m.err}, true -} - -type mockLyricsProvider struct { - lyrics model.LyricList - err error -} - -func (m *mockLyricsProvider) GetLyrics(_ context.Context, _ *model.MediaFile) (model.LyricList, error) { - return m.lyrics, m.err -} diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go deleted file mode 100644 index 23c20122d..000000000 --- a/core/lyrics/sources.go +++ /dev/null @@ -1,93 +0,0 @@ -package lyrics - -import ( - "context" - "errors" - "fmt" - "io" - "io/fs" - "path" - - "github.com/navidrome/navidrome/core/storage" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/ioutils" -) - -func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { - if mf.Lyrics != "" { - log.Trace(ctx, "embedded lyrics found in file", "title", mf.Title) - return mf.StructuredLyrics() - } - - log.Trace(ctx, "no embedded lyrics for file", "path", mf.Title) - - return nil, nil -} - -func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (model.LyricList, error) { - ext := path.Ext(mf.Path) - sidecarRelPath := mf.Path[0:len(mf.Path)-len(ext)] + suffix - ctx = log.NewContext(ctx, "file", sidecarRelPath) - - store, err := storage.For(mf.LibraryPath) - if err != nil { - return nil, fmt.Errorf("getting storage for library: %w", err) - } - fsys, err := store.FS() - if err != nil { - return nil, fmt.Errorf("opening library filesystem: %w", err) - } - - f, err := fsys.Open(sidecarRelPath) - if errors.Is(err, fs.ErrNotExist) { - log.Trace(ctx, "no lyrics found at path") - return nil, nil - } else if err != nil { - return nil, err - } - defer f.Close() - - contents, err := io.ReadAll(ioutils.UTF8Reader(f)) - if err != nil { - return nil, err - } - - list, err := model.ParseLyrics(ctx, suffix, "xxx", contents) - if err != nil { - log.Error(ctx, "error parsing external lyric file", err) - return nil, err - } - - if len(list) == 0 { - log.Trace(ctx, "empty lyrics from external file") - return nil, nil - } - - log.Trace(ctx, "retrieved lyrics from external file") - return list, nil -} - -// fromPlugin attempts to load lyrics from a plugin with the given name. -func (l *lyricsService) fromPlugin(ctx context.Context, mf *model.MediaFile, pluginName string) (model.LyricList, error) { - if l.pluginLoader == nil { - log.Debug(ctx, "Invalid lyric source", "source", pluginName) - return nil, nil - } - - provider, ok := l.pluginLoader.LoadLyricsProvider(pluginName) - if !ok { - log.Warn(ctx, "Lyrics plugin not found", "plugin", pluginName) - return nil, nil - } - - lyricsList, err := provider.GetLyrics(ctx, mf) - if err != nil { - return nil, err - } - - if len(lyricsList) > 0 { - log.Trace(ctx, "Retrieved lyrics from plugin", "plugin", pluginName, "count", len(lyricsList)) - } - return lyricsList, nil -} diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go deleted file mode 100644 index 7c7922bfd..000000000 --- a/core/lyrics/sources_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package lyrics - -import ( - "context" - "encoding/json" - "path/filepath" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("sources", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) - - Describe("fromEmbedded", func() { - It("should return nothing for a media file with no lyrics", func() { - mf := model.MediaFile{} - lyrics, err := fromEmbedded(ctx, &mf) - - Expect(err).To(BeNil()) - Expect(lyrics).To(HaveLen(0)) - }) - - It("should return lyrics for a media file with well-formatted lyrics", func() { - const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" - const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" - - syncedList, _ := model.ParseLyrics(ctx, ".lrc", "eng", []byte(syncedLyrics)) - unsyncedList, _ := model.ParseLyrics(ctx, ".lrc", "xxx", []byte(unsyncedLyrics)) - synced, _ := syncedList.Main() - unsynced, _ := unsyncedList.Main() - - expectedList := model.LyricList{synced, unsynced} - lyricsJson, err := json.Marshal(expectedList) - - Expect(err).ToNot(HaveOccurred()) - - mf := model.MediaFile{ - Lyrics: string(lyricsJson), - } - - lyrics, err := fromEmbedded(ctx, &mf) - Expect(err).To(BeNil()) - Expect(lyrics).ToNot(BeNil()) - Expect(lyrics).To(Equal(expectedList)) - }) - - It("should return an error if somehow the JSON is bad", func() { - mf := model.MediaFile{Lyrics: "["} - lyrics, err := fromEmbedded(ctx, &mf) - - Expect(lyrics).To(HaveLen(0)) - Expect(err).ToNot(BeNil()) - }) - }) - - Describe("fromExternalFile", func() { - var fixturesDir string - - BeforeEach(func() { - // tests.Init sets CWD to the repo root, so "tests/fixtures" resolves correctly. - abs, err := filepath.Abs("tests/fixtures") - Expect(err).ToNot(HaveOccurred()) - fixturesDir = abs - }) - - mf := func(name string) *model.MediaFile { - return &model.MediaFile{LibraryPath: fixturesDir, Path: name} - } - - It("should return nil for lyrics that don't exist", func() { - lyrics, err := fromExternalFile(ctx, mf("01 Invisible (RED) Edit Version.mp3"), ".lrc") - - Expect(err).To(BeNil()) - Expect(lyrics).To(HaveLen(0)) - }) - - // fromExternalFile delegates format parsing to model.ParseLyrics; the - // per-format parser output is covered exhaustively in the model package. - // Here we only verify each suffix is read from the library FS and routed. - DescribeTable("should read the sidecar file and route its suffix to a parser", - func(name, suffix string, expectSynced bool) { - lyrics, err := fromExternalFile(ctx, mf(name), suffix) - - Expect(err).To(BeNil()) - Expect(lyrics).ToNot(BeEmpty()) - Expect(lyrics[0].Line).ToNot(BeEmpty()) - Expect(lyrics[0].Synced).To(Equal(expectSynced)) - }, - Entry(".lrc synced", "test.mp3", ".lrc", true), - Entry(".elrc enhanced", "test.mp3", ".elrc", true), - Entry(".txt plain", "test.mp3", ".txt", false), - Entry(".srt subtitles", "test.mp3", ".srt", true), - Entry(".ttml multilingual", "test.mp3", ".ttml", true), - Entry(".yaml lyricsfile", "test.mp3", ".yaml", true), - ) - - It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() { - lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".lrc") - - Expect(err).To(BeNil()) - Expect(lyrics).To(HaveLen(1)) - Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced") - Expect(lyrics[0].Line).To(HaveLen(1)) - Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0)))) - Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲")) - }) - - It("should handle UTF-16 LE encoded LRC files", func() { - lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".lrc") - - Expect(err).To(BeNil()) - Expect(lyrics).To(HaveLen(1)) - Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced") - Expect(lyrics[0].Line).To(HaveLen(2)) - Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800)))) - Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love")) - Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) - Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I")) - }) - - It("should handle TTML files with UTF-8 BOM marker", func() { - lyrics, err := fromExternalFile(ctx, mf("bom-test.mp3"), ".ttml") - - Expect(err).To(BeNil()) - Expect(lyrics).To(HaveLen(1)) - Expect(lyrics[0].Kind).To(Equal("main")) - Expect(lyrics[0].Synced).To(BeTrue()) - Expect(lyrics[0].Line).To(HaveLen(1)) - Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0)))) - Expect(lyrics[0].Line[0].Value).To(Equal("BOM test line")) - }) - - It("should handle UTF-16 BE encoded TTML files", func() { - lyrics, err := fromExternalFile(ctx, mf("bom-utf16-test.mp3"), ".ttml") - - Expect(err).To(BeNil()) - Expect(lyrics).To(HaveLen(1)) - Expect(lyrics[0].Kind).To(Equal("main")) - Expect(lyrics[0].Synced).To(BeTrue()) - Expect(lyrics[0].Line).To(HaveLen(2)) - Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800)))) - Expect(lyrics[0].Line[0].Value).To(Equal("UTF16 line one")) - Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) - Expect(lyrics[0].Line[1].Value).To(Equal("UTF16 line two")) - }) - }) -}) diff --git a/core/maintenance.go b/core/maintenance.go deleted file mode 100644 index 58265ea2c..000000000 --- a/core/maintenance.go +++ /dev/null @@ -1,319 +0,0 @@ -package core - -import ( - "context" - "errors" - "fmt" - "slices" - "sync" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" -) - -var ( - // ErrNotMissing is returned when a remap is attempted from a file not marked as missing. - ErrNotMissing = errors.New("file is not marked as missing") - // ErrTargetMissing is returned when the remap target is itself a missing file. - ErrTargetMissing = errors.New("target file is missing") - // ErrSameFile is returned when the remap source and target are the same file. - ErrSameFile = errors.New("missing and target are the same file") -) - -type Maintenance interface { - // DeleteMissingFiles deletes specific missing files by their IDs - DeleteMissingFiles(ctx context.Context, ids []string) error - // DeleteAllMissingFiles deletes all files marked as missing - DeleteAllMissingFiles(ctx context.Context) error - // RemapMissingFile moves a missing file's identity onto an existing file, the manual - // counterpart to the scanner's move detection (phaseMissingTracks.moveMatched). - RemapMissingFile(ctx context.Context, missingID, targetID string) error -} - -type maintenanceService struct { - ds model.DataStore - wg sync.WaitGroup -} - -func NewMaintenance(ds model.DataStore) Maintenance { - return &maintenanceService{ - ds: ds, - } -} - -func (s *maintenanceService) DeleteMissingFiles(ctx context.Context, ids []string) error { - return s.deleteMissing(ctx, ids) -} - -func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error { - return s.deleteMissing(ctx, nil) -} - -func (s *maintenanceService) RemapMissingFile(ctx context.Context, missingID, targetID string) error { - if missingID == targetID { - return fmt.Errorf("%w: %q", ErrSameFile, missingID) - } - - missing, err := s.ds.MediaFile().Get(ctx, missingID) - if err != nil { - return fmt.Errorf("loading missing file %q: %w", missingID, err) - } - if !missing.Missing { - return fmt.Errorf("%w: %q", ErrNotMissing, missingID) - } - - target, err := s.ds.MediaFile().GetWithParticipants(ctx, targetID) - if err != nil { - return fmt.Errorf("loading target file %q: %w", targetID, err) - } - if target.Missing { - return fmt.Errorf("%w: %q", ErrTargetMissing, targetID) - } - - oldAlbumID, newAlbumID := missing.AlbumID, target.AlbumID - - err = s.ds.WithTx(func(tx model.DataStore) error { - discardedID := target.ID - - // Preserve the original created_at so the remapped track doesn't resurface in "Recently Added" - target.CreatedAt = missing.CreatedAt - target.ID = missing.ID - if err := tx.MediaFile().Put(ctx, target); err != nil { - return fmt.Errorf("update matched track: %w", err) - } - // Unlike the scanner's freshly-imported target, this one may carry history of its own - if err := tx.MediaFile().ReassignReferences(ctx, discardedID, missing.ID); err != nil { - return fmt.Errorf("reassign target references: %w", err) - } - if err := tx.MediaFile().Delete(ctx, discardedID); err != nil { - return fmt.Errorf("delete discarded track: %w", err) - } - - if oldAlbumID != newAlbumID { - oldAlbumTracks, err := tx.MediaFile().CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album_id": oldAlbumID}}) - if err != nil { - return fmt.Errorf("get old album tracks: %w", err) - } - if oldAlbumTracks == 0 { - if err := tx.Album().ReassignAnnotation(ctx, oldAlbumID, newAlbumID); err != nil { - return fmt.Errorf("reassign album annotations: %w", err) - } - if err := tx.Album().CopyAttributes(ctx, oldAlbumID, newAlbumID, "created_at"); err != nil && !errors.Is(err, model.ErrNotFound) { - return fmt.Errorf("copy album attributes: %w", err) - } - } - } - return nil - }) - if err != nil { - log.Error(ctx, "Error remapping missing file", "missing", missing.Path, "target", target.Path, err) - return err - } - - if err := s.ds.GC(ctx); err != nil { - log.Error(ctx, "Error running GC after remapping missing file", err) - return err - } - - // Stats are refreshed synchronously, unlike deleteMissing, so the CLI sees them before it exits. - // album/artist play count aggregates are not recalculated here; they are refreshed by the next scan. - if _, err := s.ds.Artist().RefreshStats(ctx, true); err != nil { - log.Error(ctx, "Error refreshing artist stats after remapping missing file", err) - } - affectedAlbumIDs := []string{newAlbumID} - if oldAlbumID != newAlbumID { - affectedAlbumIDs = append(affectedAlbumIDs, oldAlbumID) - } - if err := s.refreshAlbums(ctx, affectedAlbumIDs); err != nil { - log.Error(ctx, "Error refreshing album stats after remapping missing file", err) - } - return nil -} - -// deleteMissing handles the deletion of missing files and triggers necessary cleanup operations -func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) error { - // Track affected album IDs before deletion for refresh - affectedAlbumIDs, err := s.getAffectedAlbumIDs(ctx, ids) - if err != nil { - log.Warn(ctx, "Error tracking affected albums for refresh", err) - // Don't fail the operation, just log the warning - } - - // Delete missing files within a transaction - err = s.ds.WithTx(func(tx model.DataStore) error { - if len(ids) == 0 { - _, err := tx.MediaFile().DeleteAllMissing(ctx) - return err - } - return tx.MediaFile().DeleteMissing(ctx, ids) - }) - if err != nil { - log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err) - return err - } - - // Run garbage collection to clean up orphaned records - if err := s.ds.GC(ctx); err != nil { - log.Error(ctx, "Error running GC after deleting missing tracks", err) - return err - } - - // Refresh statistics in background. album/artist play count aggregates are not recalculated - // here; they are refreshed by the next scan. - s.refreshStatsAsync(ctx, affectedAlbumIDs) - - return nil -} - -// refreshAlbums recalculates album attributes (size, duration, song count, etc.) from media files. -// It uses batch queries to minimize database round-trips for efficiency. -func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []string) error { - if len(albumIDs) == 0 { - return nil - } - - log.Debug(ctx, "Refreshing albums", "count", len(albumIDs)) - - // Process in chunks to avoid query size limits - const chunkSize = 100 - for chunk := range slice.CollectChunks(slices.Values(albumIDs), chunkSize) { - if err := s.refreshAlbumChunk(ctx, chunk); err != nil { - return fmt.Errorf("refreshing album chunk: %w", err) - } - } - - log.Debug(ctx, "Successfully refreshed albums", "count", len(albumIDs)) - return nil -} - -// refreshAlbumChunk processes a single chunk of album IDs -func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error { - albumRepo := s.ds.Album() - mfRepo := s.ds.MediaFile() - - // Batch load existing albums - albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"album.id": albumIDs}, - }) - if err != nil { - return fmt.Errorf("loading albums: %w", err) - } - - // Create a map for quick lookup - albumMap := make(map[string]*model.Album, len(albums)) - for i := range albums { - albumMap[albums[i].ID] = &albums[i] - } - - // Batch load all media files for these albums - mediaFiles, err := mfRepo.GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"album_id": albumIDs}, - Sort: "album_id, path", - }) - if err != nil { - return fmt.Errorf("loading media files: %w", err) - } - - // Group media files by album ID - filesByAlbum := make(map[string]model.MediaFiles) - for i := range mediaFiles { - albumID := mediaFiles[i].AlbumID - filesByAlbum[albumID] = append(filesByAlbum[albumID], mediaFiles[i]) - } - - // Recalculate each album from its media files - for albumID, oldAlbum := range albumMap { - mfs, hasTracks := filesByAlbum[albumID] - if !hasTracks { - // Album has no tracks anymore, skip (will be cleaned up by GC) - log.Debug(ctx, "Skipping album with no tracks", "albumID", albumID) - continue - } - - // Recalculate album from media files - newAlbum := mfs.ToAlbum() - - // Only update if something changed (avoid unnecessary writes) - if !oldAlbum.Equals(newAlbum) { - // Preserve original timestamps - newAlbum.UpdatedAt = time.Now() - newAlbum.CreatedAt = oldAlbum.CreatedAt - - if err := albumRepo.Put(ctx, &newAlbum); err != nil { - log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err) - // Continue with other albums instead of failing entirely - continue - } - log.Trace(ctx, "Refreshed album", "albumID", albumID, "name", newAlbum.Name) - } - } - - return nil -} - -// getAffectedAlbumIDs returns distinct album IDs from missing media files -func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []string) ([]string, error) { - var filters squirrel.Sqlizer = squirrel.Eq{"missing": true} - if len(ids) > 0 { - filters = squirrel.And{ - squirrel.Eq{"missing": true}, - squirrel.Eq{"media_file.id": ids}, - } - } - - mfs, err := s.ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: filters, - }) - if err != nil { - return nil, err - } - - // Extract unique album IDs - albumIDMap := make(map[string]struct{}, len(mfs)) - for _, mf := range mfs { - if mf.AlbumID != "" { - albumIDMap[mf.AlbumID] = struct{}{} - } - } - - albumIDs := make([]string, 0, len(albumIDMap)) - for id := range albumIDMap { - albumIDs = append(albumIDs, id) - } - - return albumIDs, nil -} - -// refreshStatsAsync refreshes artist and album statistics in background goroutines -func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) { - // Refresh artist stats in background - s.wg.Go(func() { - bgCtx := request.AddValues(context.Background(), ctx) - if _, err := s.ds.Artist().RefreshStats(bgCtx, true); err != nil { - log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err) - } else { - log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files") - } - - // Refresh album stats in background if we have affected albums - if len(affectedAlbumIDs) > 0 { - if err := s.refreshAlbums(bgCtx, affectedAlbumIDs); err != nil { - log.Error(bgCtx, "Error refreshing album stats after deleting missing files", err) - } else { - log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs)) - } - } - }) -} - -// Wait waits for all background goroutines to complete. -// WARNING: This method is ONLY for testing. Never call this in production code. -// Calling Wait() in production will block until ALL background operations complete -// and may cause race conditions with new operations starting. -func (s *maintenanceService) wait() { - s.wg.Wait() -} diff --git a/core/maintenance_test.go b/core/maintenance_test.go deleted file mode 100644 index 4ffc098d9..000000000 --- a/core/maintenance_test.go +++ /dev/null @@ -1,526 +0,0 @@ -package core - -import ( - "context" - "errors" - "sync" - "time" - - "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/sirupsen/logrus" -) - -var _ = Describe("Maintenance", func() { - var ds *tests.MockDataStore - var mfRepo *extendedMediaFileRepo - var service Maintenance - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - ctx = request.WithUser(ctx, model.User{ID: "user1", IsAdmin: true}) - - ds = createTestDataStore() - mfRepo = ds.MockedMediaFile.(*extendedMediaFileRepo) - service = NewMaintenance(ds) - }) - - Describe("DeleteMissingFiles", func() { - Context("with specific IDs", func() { - It("deletes specific missing files and runs GC", func() { - // Setup: mock missing files with album IDs - mfRepo.SetData(model.MediaFiles{ - {ID: "mf1", AlbumID: "album1", Missing: true}, - {ID: "mf2", AlbumID: "album2", Missing: true}, - }) - - err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"}) - - Expect(err).ToNot(HaveOccurred()) - Expect(mfRepo.deleteMissingCalled).To(BeTrue()) - Expect(mfRepo.deletedIDs).To(Equal([]string{"mf1", "mf2"})) - Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion") - }) - - It("triggers artist stats refresh and album refresh after deletion", func() { - artistRepo := ds.MockedArtist.(*extendedArtistRepo) - // Setup: mock missing files with albums - albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) - albumRepo.SetData(model.Albums{ - {ID: "album1", Name: "Test Album", SongCount: 5}, - }) - mfRepo.SetData(model.MediaFiles{ - {ID: "mf1", AlbumID: "album1", Missing: true}, - {ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180}, - {ID: "mf3", AlbumID: "album1", Missing: false, Size: 2000, Duration: 200}, - }) - - err := service.DeleteMissingFiles(ctx, []string{"mf1"}) - - Expect(err).ToNot(HaveOccurred()) - - // Wait for background goroutines to complete - service.(*maintenanceService).wait() - - // RefreshStats should be called - Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed") - - // Album should be updated with new calculated values - Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Album.Put() should be called to refresh album data") - }) - - It("returns error if deletion fails", func() { - mfRepo.deleteMissingError = errors.New("delete failed") - - err := service.DeleteMissingFiles(ctx, []string{"mf1"}) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("delete failed")) - }) - - It("continues even if album tracking fails", func() { - mfRepo.SetError(true) - - err := service.DeleteMissingFiles(ctx, []string{"mf1"}) - - // Should not fail, just log warning - Expect(err).ToNot(HaveOccurred()) - Expect(mfRepo.deleteMissingCalled).To(BeTrue()) - }) - - It("returns error if GC fails", func() { - mfRepo.SetData(model.MediaFiles{ - {ID: "mf1", AlbumID: "album1", Missing: true}, - }) - - // Set GC to return error - ds.GCError = errors.New("gc failed") - - err := service.DeleteMissingFiles(ctx, []string{"mf1"}) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("gc failed")) - }) - }) - - Context("album ID extraction", func() { - It("extracts unique album IDs from missing files", func() { - mfRepo.SetData(model.MediaFiles{ - {ID: "mf1", AlbumID: "album1", Missing: true}, - {ID: "mf2", AlbumID: "album1", Missing: true}, - {ID: "mf3", AlbumID: "album2", Missing: true}, - }) - - err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2", "mf3"}) - - Expect(err).ToNot(HaveOccurred()) - }) - - It("skips files without album IDs", func() { - mfRepo.SetData(model.MediaFiles{ - {ID: "mf1", AlbumID: "", Missing: true}, - {ID: "mf2", AlbumID: "album1", Missing: true}, - }) - - err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"}) - - Expect(err).ToNot(HaveOccurred()) - }) - }) - }) - - Describe("DeleteAllMissingFiles", func() { - It("deletes all missing files and runs GC", func() { - mfRepo.SetData(model.MediaFiles{ - {ID: "mf1", AlbumID: "album1", Missing: true}, - {ID: "mf2", AlbumID: "album2", Missing: true}, - {ID: "mf3", AlbumID: "album3", Missing: true}, - }) - - err := service.DeleteAllMissingFiles(ctx) - - Expect(err).ToNot(HaveOccurred()) - Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion") - }) - - It("returns error if deletion fails", func() { - mfRepo.SetError(true) - - err := service.DeleteAllMissingFiles(ctx) - - Expect(err).To(HaveOccurred()) - }) - - It("handles empty result gracefully", func() { - mfRepo.SetData(model.MediaFiles{}) - - err := service.DeleteAllMissingFiles(ctx) - - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("Album refresh logic", func() { - var albumRepo *extendedAlbumRepo - - BeforeEach(func() { - albumRepo = ds.MockedAlbum.(*extendedAlbumRepo) - }) - - Context("when album has no tracks after deletion", func() { - It("skips the album without updating it", func() { - // Setup album with no remaining tracks - albumRepo.SetData(model.Albums{ - {ID: "album1", Name: "Empty Album", SongCount: 1}, - }) - mfRepo.SetData(model.MediaFiles{ - {ID: "mf1", AlbumID: "album1", Missing: true}, - }) - - err := service.DeleteMissingFiles(ctx, []string{"mf1"}) - - Expect(err).ToNot(HaveOccurred()) - - // Wait for background goroutines to complete - service.(*maintenanceService).wait() - - // Album should NOT be updated because it has no tracks left - Expect(albumRepo.GetPutCallCount()).To(Equal(0), "Album with no tracks should not be updated") - }) - }) - - Context("when Put fails for one album", func() { - It("continues processing other albums", func() { - albumRepo.SetData(model.Albums{ - {ID: "album1", Name: "Album 1"}, - {ID: "album2", Name: "Album 2"}, - }) - mfRepo.SetData(model.MediaFiles{ - {ID: "mf1", AlbumID: "album1", Missing: true}, - {ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180}, - {ID: "mf3", AlbumID: "album2", Missing: true}, - {ID: "mf4", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200}, - }) - - // Make Put fail on first call but succeed on subsequent calls - albumRepo.putError = errors.New("put failed") - albumRepo.failOnce = true - - err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf3"}) - - // Should not fail even if one album's Put fails - Expect(err).ToNot(HaveOccurred()) - - // Wait for background goroutines to complete - service.(*maintenanceService).wait() - - // Put should have been called multiple times - Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Put should be attempted") - }) - }) - - Context("when media file loading fails", func() { - It("logs warning but continues when tracking affected albums fails", func() { - // Set up log capturing - hook, cleanup := tests.LogHook() - defer cleanup() - - albumRepo.SetData(model.Albums{ - {ID: "album1", Name: "Album 1"}, - }) - mfRepo.SetData(model.MediaFiles{ - {ID: "mf1", AlbumID: "album1", Missing: true}, - }) - // Make GetAll fail when loading media files - mfRepo.SetError(true) - - err := service.DeleteMissingFiles(ctx, []string{"mf1"}) - - // Deletion should succeed despite the tracking error - Expect(err).ToNot(HaveOccurred()) - Expect(mfRepo.deleteMissingCalled).To(BeTrue()) - - // Verify the warning was logged - Expect(hook.LastEntry()).ToNot(BeNil()) - Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel)) - Expect(hook.LastEntry().Message).To(Equal("Error tracking affected albums for refresh")) - }) - }) - }) - - Describe("RemapMissingFile", func() { - It("relocates the missing file's identity onto the target and runs GC", func() { - created := time.Now().Add(-30 * 24 * time.Hour) - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", Path: "old/song.mp3", AlbumID: "album1", CreatedAt: created, Missing: true}, - {ID: "t1", Path: "new/song.mp3", AlbumID: "album1", Missing: false}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) - - got, err := mfRepo.Get(ctx, "m1") - Expect(err).ToNot(HaveOccurred()) - Expect(got.Path).To(Equal("new/song.mp3")) // moved to target's location - Expect(got.Missing).To(BeFalse()) - Expect(got.CreatedAt).To(BeTemporally("==", created)) // created_at preserved - exists, _ := mfRepo.Exists(ctx, "t1") - Expect(exists).To(BeFalse()) // discarded row removed - Expect(ds.GCCalled).To(BeTrue()) - }) - - It("moves the target's annotations, bookmarks and playlist entries onto the surviving id", func() { - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", AlbumID: "album1", Missing: true}, - {ID: "t1", AlbumID: "album1", Missing: false}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) - - Expect(mfRepo.ReassignReferencesCalls).To(HaveKeyWithValue("t1", "m1")) - }) - - It("reassigns album annotations when the old album is emptied", func() { - albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", AlbumID: "album1", Missing: true}, - {ID: "t1", AlbumID: "album2", Missing: false}, - }) - - mfRepo.SetCountAll(0) - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) - - Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue("album1", "album2")) - }) - - It("does not reassign album annotations when the old album is not emptied", func() { - albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", AlbumID: "album1", Missing: true}, - {ID: "m2", AlbumID: "album1", Missing: false}, - {ID: "t1", AlbumID: "album2", Missing: false}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) - - Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty()) - }) - - It("does not reassign annotations when the album is unchanged", func() { - albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", AlbumID: "album1", Missing: true}, - {ID: "t1", AlbumID: "album1", Missing: false}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) - - Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty()) - }) - - It("returns ErrNotFound when the missing file does not exist", func() { - mfRepo.SetData(model.MediaFiles{{ID: "t1", Missing: false}}) - - Expect(service.RemapMissingFile(ctx, "nope", "t1")).To(MatchError(model.ErrNotFound)) - }) - - It("refuses to remap from a file that is not missing", func() { - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", Missing: false}, - {ID: "t1", Missing: false}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ErrNotMissing)) - }) - - It("refuses to remap a file onto itself", func() { - mfRepo.SetData(model.MediaFiles{{ID: "m1", Missing: true}}) - - Expect(service.RemapMissingFile(ctx, "m1", "m1")).To(MatchError(ErrSameFile)) - }) - - It("refuses to remap onto a target that is itself missing", func() { - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", Missing: true}, - {ID: "t1", Missing: true}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ErrTargetMissing)) - }) - - It("refreshes artist and album stats right after the remap", func() { - artistRepo := ds.MockedArtist.(*extendedArtistRepo) - albumRepo := ds.MockedAlbum.(*extendedAlbumRepo) - albumRepo.SetData(model.Albums{ - {ID: "album1", Name: "Old Album", SongCount: 2, Size: 1100, Duration: 110}, - {ID: "album2", Name: "New Album", SongCount: 1, Size: 2000, Duration: 200}, - }) - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", Path: "old/1.mp3", Album: "Old Album", AlbumID: "album1", Missing: true, Size: 100, Duration: 10}, - {ID: "k1", Path: "old/2.mp3", Album: "Old Album", AlbumID: "album1", Missing: false, Size: 1000, Duration: 100}, - {ID: "t1", Path: "new/1.mp3", Album: "New Album", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) - - Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed") - - // The old album lost the remapped track, so its stats are recalculated from the remaining one - oldAlbum, err := albumRepo.Get(ctx, "album1") - Expect(err).ToNot(HaveOccurred()) - Expect(oldAlbum.SongCount).To(Equal(1)) - Expect(oldAlbum.Size).To(Equal(int64(1000))) - Expect(oldAlbum.Duration).To(BeNumerically("==", 100)) - - // The target album keeps the track, now under the missing file's ID - newAlbum, err := albumRepo.Get(ctx, "album2") - Expect(err).ToNot(HaveOccurred()) - Expect(newAlbum.SongCount).To(Equal(1)) - Expect(newAlbum.Size).To(Equal(int64(2000))) - Expect(newAlbum.Duration).To(BeNumerically("==", 200)) - }) - - It("returns an error if GC fails", func() { - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", AlbumID: "album1", Missing: true}, - {ID: "t1", AlbumID: "album1", Missing: false}, - }) - ds.GCError = errors.New("gc failed") - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(MatchError(ContainSubstring("gc failed"))) - }) - - It("preserves the target's participants on the remapped track", func() { - participant := model.Participant{ - Artist: model.Artist{ID: "a1", Name: "Artist", OrderArtistName: "artist", MbzArtistID: "mbz-artist"}, - } - mfRepo.SetData(model.MediaFiles{ - {ID: "m1", AlbumID: "album1", Missing: true}, - {ID: "t1", AlbumID: "album2", Missing: false, Participants: model.Participants{ - model.RoleArtist: model.ParticipantList{participant}, - }}, - }) - - Expect(service.RemapMissingFile(ctx, "m1", "t1")).To(Succeed()) - - // The surviving row is the missing file's ID, holding the target's data - got, err := mfRepo.GetWithParticipants(ctx, "m1") - Expect(err).ToNot(HaveOccurred()) - Expect(got.Participants).To(HaveKeyWithValue(model.RoleArtist, model.ParticipantList{participant})) - }) - }) -}) - -// Test helper to create a mock DataStore with controllable behavior -func createTestDataStore() *tests.MockDataStore { - ds := &tests.MockDataStore{} - - // Create extended album repo with Put tracking - albumRepo := &extendedAlbumRepo{ - MockAlbumRepo: tests.CreateMockAlbumRepo(), - } - ds.MockedAlbum = albumRepo - - // Create extended artist repo with RefreshStats tracking - artistRepo := &extendedArtistRepo{ - MockArtistRepo: tests.CreateMockArtistRepo(), - } - ds.MockedArtist = artistRepo - - // Create extended media file repo with DeleteMissing support - mfRepo := &extendedMediaFileRepo{ - MockMediaFileRepo: tests.CreateMockMediaFileRepo(), - } - ds.MockedMediaFile = mfRepo - - return ds -} - -// Extension of MockMediaFileRepo to add DeleteMissing method -type extendedMediaFileRepo struct { - *tests.MockMediaFileRepo - deleteMissingCalled bool - deletedIDs []string - deleteMissingError error -} - -func (m *extendedMediaFileRepo) DeleteMissing(ctx context.Context, ids []string) error { - m.deleteMissingCalled = true - m.deletedIDs = ids - if m.deleteMissingError != nil { - return m.deleteMissingError - } - // Actually delete from the mock data - for _, id := range ids { - delete(m.Data, id) - } - return nil -} - -// Extension of MockAlbumRepo to track Put calls -type extendedAlbumRepo struct { - *tests.MockAlbumRepo - mu sync.RWMutex - putCallCount int - lastPutData *model.Album - putError error - failOnce bool -} - -func (m *extendedAlbumRepo) Put(ctx context.Context, album *model.Album) error { - m.mu.Lock() - m.putCallCount++ - m.lastPutData = album - - // Handle failOnce behavior - var err error - if m.putError != nil { - if m.failOnce { - err = m.putError - m.putError = nil // Clear error after first failure - m.mu.Unlock() - return err - } - err = m.putError - m.mu.Unlock() - return err - } - m.mu.Unlock() - - return m.MockAlbumRepo.Put(ctx, album) -} - -func (m *extendedAlbumRepo) GetPutCallCount() int { - m.mu.RLock() - defer m.mu.RUnlock() - return m.putCallCount -} - -// Extension of MockArtistRepo to track RefreshStats calls -type extendedArtistRepo struct { - *tests.MockArtistRepo - mu sync.RWMutex - refreshStatsCalled bool - refreshStatsError error -} - -func (m *extendedArtistRepo) RefreshStats(ctx context.Context, allArtists bool) (int64, error) { - m.mu.Lock() - m.refreshStatsCalled = true - err := m.refreshStatsError - m.mu.Unlock() - - if err != nil { - return 0, err - } - return m.MockArtistRepo.RefreshStats(ctx, allArtists) -} - -func (m *extendedArtistRepo) IsRefreshStatsCalled() bool { - m.mu.RLock() - defer m.mu.RUnlock() - return m.refreshStatsCalled -} diff --git a/core/matcher/doc.go b/core/matcher/doc.go deleted file mode 100644 index 1024bc274..000000000 --- a/core/matcher/doc.go +++ /dev/null @@ -1,124 +0,0 @@ -// Package matcher matches song results from external agents (Last.fm, Deezer, -// etc.) to tracks in the local music library, prioritizing accuracy over recall. -// -// It exposes a single [Matcher] type with two entry points that share the same -// matching algorithm: -// -// - [Matcher.MatchSongs] returns an ordered, deduplicated slice of library -// tracks, capped at a requested count. Use it when presenting "similar -// songs" results to a client. -// - [Matcher.MatchSongsIndexed] returns a map from input-song index to matched -// track, with no deduplication. Use it when the caller needs to correlate -// each result back to its input position (e.g. to attach a per-song -// similarity score). -// -// # Algorithm Overview -// -// Each input song is resolved to its best-matching library track using four -// strategies, applied in priority order. A song matched by a higher-priority -// strategy is never reconsidered by a lower-priority one: -// -// 1. Direct ID match: songs with an ID are matched to a MediaFile by ID. -// 2. MusicBrainz Recording ID (MBID) match: songs with an MBID are matched to -// tracks with the same mbz_recording_id. -// 3. ISRC match: songs with an ISRC are matched to tracks carrying that ISRC tag. -// 4. Title+Artist fuzzy match: remaining songs are matched by fuzzy string -// comparison with metadata-specificity scoring (see below). -// -// Priority order is ID > MBID > ISRC > Title+Artist, so more reliable -// identifiers always take precedence over fuzzy text matching. Missing tracks -// (those no longer present on disk) are never matched. -// -// # Fuzzy Matching Details -// -// Title+artist matching uses Jaro-Winkler similarity, with a threshold -// configurable via conf.Server.Matcher.FuzzyThreshold (default 85%). A library -// track must clear the title threshold to be considered. Candidates that clear -// it are ranked by, in order: -// -// 1. Title similarity (Jaro-Winkler score, 0.0–1.0) -// 2. Duration proximity (closer duration scores higher; 1.0 when the agent -// reports no duration) -// 3. Specificity level (0–5, based on metadata precision; higher is better) -// 4. Artist overlap (how many of the song's artists the track credits; more -// shared artists is better) -// 5. Preferred-track flag (enabled by conf.Server.Matcher.PreferStarred; -// prioritizes tracks that are starred or rated >= 4, but only among -// candidates of equal specificity and overlap) -// 6. Album similarity (Jaro-Winkler, as the final tiebreaker) -// -// The specificity levels, from most to least specific, are: -// -// Level 5: Title + Artist identity + Album MBID -// Level 4: Title + Artist identity + Album name (fuzzy) -// Level 3: Title + Artist name + Album name (fuzzy) -// Level 2: Title + Artist identity -// Level 1: Title + Artist name -// Level 0: Title only -// -// "Artist identity" is a match on the artist's Navidrome ID (the strongest signal, -// when a source supplies one) or its MBID. A plain name match is the weaker fallback -// used for an artist with no identity match (e.g. a cover credited to a different -// artist of the same name). -// -// The title phase always requires an agent artist to scope the library query, so -// Level 0 does not mean "no artist": it applies when a candidate matches on title -// but its own artist differs from the query's (e.g. a cover or a featured-artist -// credit), leaving the title as the only shared field. -// -// A song may carry several artists, and the title phase scopes candidate tracks by -// ANY of them: a track credited to at least one shared artist is considered. When a -// source supplies a Navidrome artist ID, that artist is matched directly, skipping -// name/MBID resolution. Among equally specific candidates, the one sharing more of -// the song's artists wins, so a track crediting every collaborator outranks one -// crediting only a single artist. -// -// Each input song is scored independently, so two songs with the same title and -// artist but different durations can resolve to different library tracks (each -// matches the track closest to its own duration). -// -// # Examples -// -// All examples below exercise the title+artist phase, where the interesting -// behavior lives. (Identifier phases — ID, MBID, ISRC — are exact lookups that -// always win over fuzzy matching; they need no illustration.) -// -// Title threshold — a near-miss title still matches; an exact-only threshold -// rejects it: -// -// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"} -// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"} -// With threshold 85%: match succeeds (similarity ~0.87) -// With threshold 100%: no match (not an exact title) -// -// Specificity ranking — among candidates that clear the title threshold, a -// better album match wins: -// -// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} -// Library has: -// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"} // Level 1 -// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3 -// Result: t2 (Level 3 beats Level 1 on the album match) -// -// Duration tiebreak — with title and artist equal, the closest duration wins, -// so two near-identical input songs can resolve to different tracks: -// -// Agent returns: -// {Name: "Untitled", Artist: "Interpol", Duration: 245000} // 4:05 -// {Name: "Untitled", Artist: "Interpol", Duration: 600000} // 10:00 (a live take) -// Library has: -// {ID: "studio", Title: "Untitled", Artist: "Interpol", Duration: 248} // 4:08 -// {ID: "live", Title: "Untitled", Artist: "Interpol", Duration: 602} // 10:02 -// Result: studio for the first song, live for the second -// -// Preferred track — when conf.Server.Matcher.PreferStarred is enabled, a -// starred (or rating >= 4) track is preferred, but only when specificity and -// artist overlap are equal. A more specific match always wins regardless of the -// preferred flag: -// -// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} -// Library has: -// {ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"} // Level 3 -// {ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", Starred: true} // Level 1, starred -// Result: exact (specificity outranks the starred flag; preferred only breaks ties of equal identity) -package matcher diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go deleted file mode 100644 index 3eb4db152..000000000 --- a/core/matcher/matcher.go +++ /dev/null @@ -1,642 +0,0 @@ -package matcher - -import ( - "context" - "fmt" - "maps" - "math" - "slices" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "github.com/navidrome/navidrome/utils/str" - "github.com/xrash/smetrics" -) - -// 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 songs to library tracks and returns up to count -// tracks in the input's order. See the package documentation for the matching -// algorithm. -// -// Each library track appears at most once, unless the same input song is -// repeated: identical input songs intentionally yield repeated output tracks, -// while distinct songs that resolve to the same track are deduplicated. Songs -// that cannot be matched are skipped. -func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { - if len(songs) == 0 { - return nil, nil - } - matches, err := m.resolveMatches(ctx, songs) - if err != nil { - return nil, err - } - return orderAndDedup(songs, matches, count), nil -} - -// MatchSongsIndexed matches agent songs to library tracks and returns a map from -// input-song index to matched track, letting callers correlate results back to -// the input slice. Unmatched songs are omitted from the map. Unlike MatchSongs, -// results are not deduplicated. See the package documentation for the matching -// algorithm. -func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { - if len(songs) == 0 { - return nil, nil - } - return m.resolveMatches(ctx, songs) -} - -// resolveMatches resolves each input song to its best-matching library track, -// keyed by the song's index. Loaders run in priority order (ID > MBID > ISRC > -// Title); each only fills indices not already matched by a higher-priority loader. -func (m *Matcher) resolveMatches(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { - result := make(map[int]model.MediaFile, len(songs)) - if err := m.matchByID(ctx, songs, result); err != nil { - return nil, fmt.Errorf("failed to match tracks by ID: %w", err) - } - if err := m.matchByMBID(ctx, songs, result); err != nil { - return nil, fmt.Errorf("failed to match tracks by MBID: %w", err) - } - if err := m.matchByISRC(ctx, songs, result); err != nil { - return nil, fmt.Errorf("failed to match tracks by ISRC: %w", err) - } - // The title phase is best-effort: a DB failure there must not discard the exact - // matches already found by the higher-priority phases. Only surface it as fatal - // when nothing matched at all. - if err := m.matchByTitle(ctx, songs, result); err != nil { - if len(result) == 0 { - return nil, fmt.Errorf("failed to match tracks by title: %w", err) - } - log.Warn(ctx, "Title matching failed; returning matches from exact phases only", err) - } - return result, nil -} - -// matchByID fills result with direct ID matches. -func (m *Matcher) matchByID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { - var ids []string - for _, s := range songs { - if s.ID != "" { - ids = append(ids, s.ID) - } - } - if len(ids) == 0 { - return nil - } - res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"media_file.id": ids}, - squirrel.Eq{"missing": false}, - }, - }) - if err != nil { - return err - } - byID := make(map[string]model.MediaFile, len(res)) - for _, mf := range res { - byID[mf.ID] = mf // media_file.id is unique, so no dedup needed - } - for i, s := range songs { - if s.ID == "" { - continue - } - if mf, ok := byID[s.ID]; ok { - result[i] = mf - } - } - return nil -} - -// matchByMBID fills result with MusicBrainz Recording ID matches, skipping -// songs already matched by a higher-priority loader. -func (m *Matcher) matchByMBID(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { - var mbids []string - for i, s := range songs { - if _, done := result[i]; done { - continue - } - if s.MBID != "" { - mbids = append(mbids, s.MBID) - } - } - if len(mbids) == 0 { - return nil - } - res, err := m.ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"mbz_recording_id": mbids}, - squirrel.Eq{"missing": false}, - }, - }) - if err != nil { - return err - } - byMBID := make(map[string]model.MediaFile, len(res)) - for _, mf := range res { - if id := mf.MbzRecordingID; id != "" { - if _, ok := byMBID[id]; !ok { - byMBID[id] = mf - } - } - } - for i, s := range songs { - if _, done := result[i]; done { - continue - } - if s.MBID == "" { - continue - } - if mf, ok := byMBID[s.MBID]; ok { - result[i] = mf - } - } - return nil -} - -// matchByISRC fills result with ISRC tag matches, skipping songs already -// matched by a higher-priority loader. -func (m *Matcher) matchByISRC(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { - var isrcs []string - for i, s := range songs { - if _, done := result[i]; done { - continue - } - if s.ISRC != "" { - isrcs = append(isrcs, s.ISRC) - } - } - if len(isrcs) == 0 { - return nil - } - res, err := m.ds.MediaFile().GetAllByTags(ctx, model.TagISRC, isrcs, model.QueryOptions{ - Filters: squirrel.Eq{"missing": false}, - Sort: "starred desc, rating desc, year asc, compilation asc", - }) - if err != nil { - return err - } - byISRC := make(map[string]model.MediaFile, len(res)) - for _, mf := range res { - for _, isrc := range mf.Tags.Values(model.TagISRC) { - if _, ok := byISRC[isrc]; !ok { - byISRC[isrc] = mf - } - } - } - for i, s := range songs { - if _, done := result[i]; done { - continue - } - if s.ISRC == "" { - continue - } - if mf, ok := byISRC[s.ISRC]; ok { - result[i] = mf - } - } - return nil -} - -// queryArtist is one of a song's artists. A non-empty id is matched directly, skipping name/MBID -// resolution; name is pre-sanitized (article-stripped). -type queryArtist struct { - id string - name string - mbid string -} - -// songQuery represents a normalized query for matching a song to library tracks. -type songQuery struct { - title string - artists []queryArtist - album string - albumMBID string - durationMs uint32 -} - -// matchScore combines title/album similarity with metadata specificity for ranking matches. -type matchScore struct { - titleSimilarity float64 - durationProximity float64 - preferredMatch bool - specificityLevel int - artistOverlap int - albumSimilarity float64 -} - -// betterThan returns true if this score beats another. -// Identity signals (specificity, overlap) outrank the taste signal (preferred). -func (s matchScore) betterThan(other matchScore) bool { - if s.titleSimilarity != other.titleSimilarity { - return s.titleSimilarity > other.titleSimilarity - } - if s.durationProximity != other.durationProximity { - return s.durationProximity > other.durationProximity - } - if s.specificityLevel != other.specificityLevel { - return s.specificityLevel > other.specificityLevel - } - if s.artistOverlap != other.artistOverlap { - return s.artistOverlap > other.artistOverlap - } - if s.preferredMatch != other.preferredMatch { - return s.preferredMatch - } - return s.albumSimilarity > other.albumSimilarity -} - -// sanitizedTrack holds pre-sanitized fields for a media file, avoiding redundant sanitization -// when the same track is scored against multiple queries. The `mf` field is a pointer to avoid -// copying the large MediaFile struct into each entry of the sanitized slice. -type sanitizedTrack struct { - mf *model.MediaFile - title string - artist string - album string - artistIDs map[string]struct{} // query's owned artist IDs this track credits; an ID match is the strongest identity signal - artistMBIDs map[string]struct{} // MBIDs of those artists (artist table; mf.MbzArtistID is not populated on the bulk path) -} - -func newSanitizedTrack(mf *model.MediaFile, artistIDs, artistMBIDs map[string]struct{}) sanitizedTrack { - return sanitizedTrack{ - mf: mf, - title: str.SanitizeFieldForSorting(mf.Title), - artist: str.SanitizeFieldForSortingNoArticle(mf.Artist), - album: str.SanitizeFieldForSorting(mf.Album), - artistIDs: artistIDs, - artistMBIDs: artistMBIDs, - } -} - -// computeSpecificityLevel determines how well query metadata matches a track (0-5), taking the best -// level achievable across any of the query's artists. Fields must be pre-sanitized. -// -// A query artist counts as an identity match when the track credits its resolved Navidrome ID (the -// strongest signal, our own primary key) or its MBID; that identity then unlocks the album tiers. -// Name matching is the lowest fallback for an artist with no identity match (e.g. a cover credited -// to a different artist by the same name). -func computeSpecificityLevel(q songQuery, t sanitizedTrack, albumThreshold float64) int { - best := 0 - albumOK := q.album != "" && similarityRatio(t.album, q.album) >= albumThreshold - for _, a := range q.artists { - _, idMember := t.artistIDs[a.id] - _, mbidMember := t.artistMBIDs[a.mbid] - identity := (a.id != "" && idMember) || (a.mbid != "" && mbidMember) - level := 0 - switch { - case identity && q.albumMBID != "" && t.mf.MbzAlbumID == q.albumMBID: - level = 5 - case identity && q.album != "" && albumOK: - level = 4 - case a.name != "" && q.album != "" && t.artist == a.name && albumOK: - level = 3 - case identity: - level = 2 - case a.name != "" && t.artist == a.name: - level = 1 - } - if level > best { - best = level - } - } - return best -} - -// indexedQuery pairs a normalized songQuery with the index of the input song -// it came from, so title matches can be written back to result by index. -type indexedQuery struct { - index int - query songQuery -} - -// matchByTitle fills result with fuzzy title+artist matches, skipping songs -// already matched by a higher-priority loader. -func (m *Matcher) matchByTitle(ctx context.Context, songs []agents.Song, result map[int]model.MediaFile) error { - queries := groupQueries(songs, result) - if len(queries) == 0 { - return nil - } - - resolved, err := m.resolveArtists(ctx, queries) - if err != nil || len(resolved.allIDs) == 0 { - return err - } - - tracks, err := m.fetchTracksCreditedTo(ctx, resolved.allIDs) - if err != nil { - return err - } - - tracksByQuery := resolved.bucketTracks(tracks) - threshold := float64(conf.Server.Matcher.FuzzyThreshold) / 100.0 - for _, iq := range queries { - sanitized := tracksByQuery[iq.index] - if mf, found := m.findBestMatch(iq.query, sanitized, threshold); found { - result[iq.index] = mf - } - } - return nil -} - -// groupQueries builds one normalized title query per still-unmatched song, carrying its full -// artist set. An artist is usable if it carries a Navidrome ID or a non-empty sanitized name; -// songs with no usable artist are skipped (the title phase needs at least one to scope the query). -func groupQueries(songs []agents.Song, result map[int]model.MediaFile) []indexedQuery { - var queries []indexedQuery - for i, s := range songs { - if _, done := result[i]; done { - continue - } - var artists []queryArtist - for _, a := range s.Artists { - name := str.SanitizeFieldForSortingNoArticle(a.Name) - if a.ID == "" && name == "" && a.MBID == "" { - continue - } - artists = append(artists, queryArtist{id: a.ID, name: name, mbid: a.MBID}) - } - if len(artists) == 0 { - continue - } - queries = append(queries, indexedQuery{index: i, query: songQuery{ - title: str.SanitizeFieldForSorting(s.Name), - artists: artists, - album: str.SanitizeFieldForSorting(s.Album), - albumMBID: s.AlbumMBID, - durationMs: s.Duration, - }}) - } - return queries -} - -// resolvedArtists holds the agent artists resolved to artist-table rows, keyed by the query index -// that owns them. Routing is always by stable artist ID, never by name. -type resolvedArtists struct { - byQuery map[int]map[string]struct{} // query index -> set of resolved artist IDs - mbid map[string]string // artist ID -> its MBID (from the artist table) - allIDs []string // every resolved artist ID, for the track lookup -} - -// resolveArtists resolves every artist of every query to artist-table rows. Artists that carry a -// Navidrome ID are owned directly (no name/MBID lookup). The remaining names/MBIDs are resolved in -// one batched query. Ownership is recorded per query index. -func (m *Matcher) resolveArtists(ctx context.Context, queries []indexedQuery) (resolvedArtists, error) { - res := resolvedArtists{ - byQuery: make(map[int]map[string]struct{}, len(queries)), - mbid: make(map[string]string), - } - allIDs := map[string]struct{}{} // de-dupe across fast-path + resolved - - // One pending entry per non-ID artist (carrying the query that owns it). ID-bearing artists - // take the fast-path and are owned directly. - type pendingArtist struct { - name, mbid string - query int - } - var pending []pendingArtist - for _, iq := range queries { - for _, a := range iq.query.artists { - if a.id != "" { - addToSet(res.byQuery, iq.index, a.id) // ID fast-path: own directly - allIDs[a.id] = struct{}{} - continue - } - pending = append(pending, pendingArtist{name: a.name, mbid: a.mbid, query: iq.index}) - } - } - // query indices that supplied each order name / each MBID (skip the empty key — an artist may - // have only one of name/mbid). - nameToQueries := map[string][]int{} - mbidToQueries := map[string][]int{} - for _, p := range pending { - if p.name != "" { - nameToQueries[p.name] = append(nameToQueries[p.name], p.query) - } - if p.mbid != "" { - mbidToQueries[p.mbid] = append(mbidToQueries[p.mbid], p.query) - } - } - - // Query the artist table for name/MBID artists AND for the fast-path IDs (so their MBIDs are - // available for specificity scoring). - var filter squirrel.Or - if len(nameToQueries) > 0 { - filter = append(filter, squirrel.Eq{"order_artist_name": slices.Collect(maps.Keys(nameToQueries))}) - } - if len(mbidToQueries) > 0 { - filter = append(filter, squirrel.Eq{"mbz_artist_id": slices.Collect(maps.Keys(mbidToQueries))}) - } - if len(allIDs) > 0 { - filter = append(filter, squirrel.Eq{"id": slices.Collect(maps.Keys(allIDs))}) - } - if len(filter) > 0 { - artists, err := m.ds.Artist().GetAll(ctx, model.QueryOptions{Filters: filter}) - if err != nil { - return resolvedArtists{}, err - } - for _, a := range artists { - res.mbid[a.ID] = a.MbzArtistID - allIDs[a.ID] = struct{}{} - for _, idx := range nameToQueries[a.OrderArtistName] { - addToSet(res.byQuery, idx, a.ID) - } - if a.MbzArtistID != "" { - for _, idx := range mbidToQueries[a.MbzArtistID] { - addToSet(res.byQuery, idx, a.ID) - } - } - } - } - - res.allIDs = slices.Collect(maps.Keys(allIDs)) - return res, nil -} - -func addToSet(m map[int]map[string]struct{}, k int, v string) { - if m[k] == nil { - m[k] = map[string]struct{}{} - } - m[k][v] = struct{}{} -} - -// scoredTrack is a candidate track for a query; overlap is how many of the query's distinct -// artist IDs the track credits. -type scoredTrack struct { - sanitizedTrack - overlap int -} - -// queryAccum tallies, for one track against one query, the overlap count, the credited artists' -// owned IDs, and their MBIDs. -type queryAccum struct { - overlap int - ids map[string]struct{} - mbids map[string]struct{} -} - -func (r resolvedArtists) bucketTracks(tracks []model.MediaFile) map[int][]scoredTrack { - queriesByArtist := make(map[string][]int) - for idx, ids := range r.byQuery { - for id := range ids { - queriesByArtist[id] = append(queriesByArtist[id], idx) - } - } - - byQuery := make(map[int][]scoredTrack, len(r.byQuery)) - for i := range tracks { - acc := map[int]*queryAccum{} - credited := map[string]struct{}{} - for _, p := range tracks[i].Participants[model.RoleArtist] { - if _, dup := credited[p.ID]; dup { - continue - } - owners, owned := queriesByArtist[p.ID] - if !owned { - continue - } - credited[p.ID] = struct{}{} - mbid := r.mbid[p.ID] // "" if not in the artist-table result - for _, idx := range owners { - a := acc[idx] - if a == nil { - a = &queryAccum{ids: map[string]struct{}{}, mbids: map[string]struct{}{}} - acc[idx] = a - } - a.overlap++ - a.ids[p.ID] = struct{}{} - if mbid != "" { - a.mbids[mbid] = struct{}{} - } - } - } - for idx, a := range acc { - byQuery[idx] = append(byQuery[idx], scoredTrack{ - sanitizedTrack: newSanitizedTrack(&tracks[i], a.ids, a.mbids), - overlap: a.overlap, - }) - } - } - return byQuery -} - -// fetchTracksCreditedTo fetches every non-missing track credited to any of the given artists as -// the main artist (role='artist', not albumartist — that avoids tribute/compilation false -// positives). The non-correlated id IN (subquery) materializes the matching ids once from the -// media_file_artists(artist_id) covering index, far cheaper than a correlated EXISTS that re-runs -// per row. That form isn't expressible via the repository's role filters, so the raw squirrel.Expr -// keeps the media_file_artists schema knowledge here; a dedicated repository method would be the -// cleaner home if this is reused. -func (m *Matcher) fetchTracksCreditedTo(ctx context.Context, artistIDs []string) (model.MediaFiles, error) { - if len(artistIDs) == 0 { - return nil, nil - } - args := slice.Map(artistIDs, func(id string) any { return id }) - return m.ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Expr( - "media_file.id IN (SELECT media_file_id FROM media_file_artists "+ - "WHERE role = 'artist' AND artist_id IN ("+squirrel.Placeholders(len(artistIDs))+"))", args...), - squirrel.Eq{"missing": false}, - }, - Sort: "starred desc, rating desc, year asc, compilation asc", - }) -} - -// 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 - } - durationSec := float64(durationMs) / 1000.0 - diff := math.Abs(durationSec - float64(mediaFileDurationSec)) - return 1.0 / (1.0 + diff) -} - -// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring. -func (m *Matcher) findBestMatch(q songQuery, candidates []scoredTrack, threshold float64) (model.MediaFile, bool) { - var bestMatch model.MediaFile - bestScore := matchScore{titleSimilarity: -1} - found := false - - preferStarred := conf.Server.Matcher.PreferStarred - for _, c := range candidates { - titleSim := similarityRatio(q.title, c.title) - if titleSim < threshold { - continue - } - var albumSim float64 - if q.album != "" { - albumSim = similarityRatio(q.album, c.album) - } - score := matchScore{ - titleSimilarity: titleSim, - durationProximity: durationProximity(q.durationMs, c.mf.Duration), - preferredMatch: preferStarred && isPreferredTrack(c.mf), - albumSimilarity: albumSim, - specificityLevel: computeSpecificityLevel(q, c.sanitizedTrack, threshold), - artistOverlap: c.overlap, - } - if score.betterThan(bestScore) { - bestScore = score - bestMatch = *c.mf - found = true - } - } - return bestMatch, found -} - -func isPreferredTrack(mf *model.MediaFile) bool { - return mf.Starred || mf.Rating >= 4 -} - -// orderAndDedup builds the final ordered result from the per-index matches, -// applying the count limit and deduplication. A library track is added at most -// once unless the same input song appears more than once (callers rely on that -// 1:1 positional behavior for identical duplicate inputs). -func orderAndDedup(songs []agents.Song, matches map[int]model.MediaFile, count int) model.MediaFiles { - mfs := make(model.MediaFiles, 0, len(songs)) - addedBy := make(map[string]int, len(songs)) - - for i, s := range songs { - if len(mfs) == count { - break - } - mf, found := matches[i] - if !found { - continue - } - if prevIdx, alreadyAdded := addedBy[mf.ID]; alreadyAdded { - if !s.Equals(songs[prevIdx]) { - continue - } - } else { - addedBy[mf.ID] = i - } - mfs = append(mfs, mf) - } - return mfs -} - -// similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm. -func similarityRatio(a, b string) float64 { - if a == b { - return 1.0 - } - if len(a) == 0 || len(b) == 0 { - return 0.0 - } - return smetrics.JaroWinkler(a, b, 0.7, 4) -} diff --git a/core/matcher/matcher_internal_test.go b/core/matcher/matcher_internal_test.go deleted file mode 100644 index 5b987d937..000000000 --- a/core/matcher/matcher_internal_test.go +++ /dev/null @@ -1,258 +0,0 @@ -package matcher - -import ( - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("similarityRatio", func() { - It("returns 1.0 for identical strings", func() { - Expect(similarityRatio("hello", "hello")).To(BeNumerically("==", 1.0)) - }) - - It("returns 0.0 for empty strings", func() { - Expect(similarityRatio("", "test")).To(BeNumerically("==", 0.0)) - Expect(similarityRatio("test", "")).To(BeNumerically("==", 0.0)) - }) - - It("returns high similarity for remastered suffix", func() { - ratio := similarityRatio("paranoid android", "paranoid android remastered") - Expect(ratio).To(BeNumerically(">=", 0.85)) - }) - - It("returns high similarity for suffix additions like (Live)", func() { - ratio := similarityRatio("bohemian rhapsody", "bohemian rhapsody live") - Expect(ratio).To(BeNumerically(">=", 0.90)) - }) - - It("returns high similarity for 'yesterday' variants (common prefix)", func() { - ratio := similarityRatio("yesterday", "yesterday once more") - Expect(ratio).To(BeNumerically(">=", 0.85)) - }) - - It("returns low similarity for same suffix", func() { - ratio := similarityRatio("postman (live)", "taxman (live)") - Expect(ratio).To(BeNumerically("<", 0.85)) - }) - - It("handles unicode characters", func() { - ratio := similarityRatio("dont stop believin", "don't stop believin'") - Expect(ratio).To(BeNumerically(">=", 0.85)) - }) - - It("returns low similarity for completely different strings", func() { - ratio := similarityRatio("abc", "xyz") - Expect(ratio).To(BeNumerically("<", 0.5)) - }) - - It("is symmetric", func() { - ratio1 := similarityRatio("hello world", "hello") - ratio2 := similarityRatio("hello", "hello world") - Expect(ratio1).To(Equal(ratio2)) - }) -}) - -var _ = Describe("matcher internals", func() { - It("computeSpecificityLevel uses sanitizedTrack.artistMBIDs for artist-MBID levels", func() { - q := songQuery{ - title: "song", - artists: []queryArtist{{mbid: "artist-mbid-1"}}, - albumMBID: "album-mbid-1", - } - mf := model.MediaFile{Title: "Song", MbzAlbumID: "album-mbid-1"} // note: mf.MbzArtistID intentionally empty - t := newSanitizedTrack(&mf, nil, map[string]struct{}{"artist-mbid-1": {}}) - Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5)) - }) - - It("computeSpecificityLevel maximizes the level over all query artists", func() { - // First artist does not match; second matches by name with a matching album → level 3. - q := songQuery{ - title: "song", - album: "violator", - artists: []queryArtist{ - {name: "no match"}, - {name: "depeche mode"}, - }, - } - mf := model.MediaFile{Title: "Song", Artist: "Depeche Mode", Album: "Violator"} - t := newSanitizedTrack(&mf, nil, nil) - Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(3)) - }) - - It("scores MBID specificity for any credited artist, not just the last", func() { - q := songQuery{ - title: "song", - artists: []queryArtist{ - {name: "drake", mbid: "mbz-drake"}, - {name: "future", mbid: "mbz-future"}, - }, - album: "wrong album", // force album mismatch so only MBID-level (2) is reachable, not 3+ - } - // Track credits BOTH MBIDs; with the old last-wins string this would only match one. - t := sanitizedTrack{ - mf: &model.MediaFile{}, - title: "song", - artist: "drake", - album: "some other album", - artistMBIDs: map[string]struct{}{"mbz-drake": {}, "mbz-future": {}}, - } - // Either artist's MBID matching yields level 2 (MBID, no album match). The point: it is - // reached via mbz-future too, which the old code would have dropped. - Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(2)) - }) - - It("treats an ID-only artist (no name/MBID) as an identity match, unlocking album tiers", func() { - // A plugin that supplies only a Navidrome artist ID: name and mbid are empty. The ID is the - // strongest identity signal, so the track's album still elevates specificity above 0. - q := songQuery{ - title: "song", - artists: []queryArtist{{id: "artist-1"}}, - album: "violator", - albumMBID: "album-mbid-1", - } - // Track credits the owned artist ID; no MBID anywhere (untagged library / ID-only plugin). - mf := model.MediaFile{Title: "Song", Album: "Violator", MbzAlbumID: "album-mbid-1"} - t := newSanitizedTrack(&mf, map[string]struct{}{"artist-1": {}}, nil) - // Album MBID matches → level 5 via the ID identity, where the old code scored 0. - Expect(computeSpecificityLevel(q, t, 0.85)).To(Equal(5)) - - // Same artist, album name matches but no album MBID → level 4 via the ID identity. - q.albumMBID = "" - mf2 := model.MediaFile{Title: "Song", Album: "Violator"} - t2 := newSanitizedTrack(&mf2, map[string]struct{}{"artist-1": {}}, nil) - Expect(computeSpecificityLevel(q, t2, 0.85)).To(Equal(4)) - }) -}) - -var _ = Describe("groupQueries", func() { - It("builds one query per unmatched song carrying all artists", func() { - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}}, - } - queries := groupQueries(songs, map[int]model.MediaFile{}) - Expect(queries).To(HaveLen(1)) - Expect(queries[0].index).To(Equal(0)) - Expect(queries[0].query.title).To(Equal("song a")) - Expect(queries[0].query.artists).To(HaveLen(2)) - Expect(queries[0].query.artists[0].name).To(Equal("drake")) - Expect(queries[0].query.artists[1].name).To(Equal("future")) - }) - - It("strips leading articles from artist names", func() { - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{Name: "The Drake"}}}, - } - queries := groupQueries(songs, map[int]model.MediaFile{}) - Expect(queries).To(HaveLen(1)) - Expect(queries[0].query.artists).To(HaveLen(1)) - Expect(queries[0].query.artists[0].name).To(Equal("drake")) - }) - - It("keeps an artist that carries only an ID (empty name)", func() { - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{ID: "ar-x"}}}, - } - queries := groupQueries(songs, map[int]model.MediaFile{}) - Expect(queries).To(HaveLen(1)) - Expect(queries[0].query.artists).To(HaveLen(1)) - Expect(queries[0].query.artists[0].id).To(Equal("ar-x")) - Expect(queries[0].query.artists[0].name).To(Equal("")) - }) - - It("keeps an artist that carries only an MBID (empty id and name)", func() { - // ListenBrainz collaborators arrive as MBID-only when the API supplies a combined display - // name; the MBID is a usable identity signal and must not be dropped. - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{MBID: "mbz-future"}}}, - } - queries := groupQueries(songs, map[int]model.MediaFile{}) - Expect(queries).To(HaveLen(1)) - Expect(queries[0].query.artists).To(HaveLen(1)) - Expect(queries[0].query.artists[0].id).To(Equal("")) - Expect(queries[0].query.artists[0].name).To(Equal("")) - Expect(queries[0].query.artists[0].mbid).To(Equal("mbz-future")) - }) - - It("drops a fully-empty artist (no id, name, or mbid) but keeps usable ones", func() { - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{}, {Name: "Future"}}}, - } - queries := groupQueries(songs, map[int]model.MediaFile{}) - Expect(queries).To(HaveLen(1)) - Expect(queries[0].query.artists).To(HaveLen(1)) - Expect(queries[0].query.artists[0].name).To(Equal("future")) - }) - - It("skips already-matched songs and songs with no usable artist", func() { - songs := []agents.Song{ - {Name: "Already Matched", Artists: []agents.Artist{{Name: "Drake"}}}, - {Name: "No Artist"}, - {Name: "Song C", Artists: []agents.Artist{{Name: "Future"}}}, - } - queries := groupQueries(songs, map[int]model.MediaFile{0: {ID: "done"}}) - Expect(queries).To(HaveLen(1)) - Expect(queries[0].index).To(Equal(2)) - Expect(queries[0].query.artists[0].name).To(Equal("future")) - }) -}) - -var _ = Describe("bucketTracks", func() { - It("scores tracks by how many of the query's artists they credit (overlap)", func() { - r := resolvedArtists{ - byQuery: map[int]map[string]struct{}{ - 0: {"ar-1": {}, "ar-2": {}}, - }, - mbid: map[string]string{}, - } - trackA := model.MediaFile{ID: "a", Title: "A", - Participants: artistParticipants( - model.Artist{ID: "ar-1", OrderArtistName: "one"}, - model.Artist{ID: "ar-2", OrderArtistName: "two"}, - ), - } - trackB := model.MediaFile{ID: "b", Title: "B", - Participants: artistParticipants(model.Artist{ID: "ar-1", OrderArtistName: "one"}), - } - byQuery := r.bucketTracks(model.MediaFiles{trackA, trackB}) - Expect(byQuery[0]).To(HaveLen(2)) - overlaps := map[string]int{} - for _, st := range byQuery[0] { - overlaps[st.mf.ID] = st.overlap - } - Expect(overlaps["a"]).To(Equal(2)) - Expect(overlaps["b"]).To(Equal(1)) - }) -}) - -var _ = Describe("resolveArtists ID fast-path", func() { - It("owns an artist supplied by ID without a name match", func() { - ctx := GinkgoT().Context() - artistRepo := tests.CreateMockArtistRepo() - artistRepo.SetData(model.Artists{ - {ID: "ar-x", Name: "Some Artist", OrderArtistName: "some artist", MbzArtistID: "mbz-x"}, - }) - ds := &tests.MockDataStore{MockedArtist: artistRepo} - m := New(ds) - - queries := []indexedQuery{ - {index: 0, query: songQuery{title: "song", artists: []queryArtist{{id: "ar-x"}}}}, - } - res, err := m.resolveArtists(ctx, queries) - Expect(err).ToNot(HaveOccurred()) - Expect(res.byQuery[0]).To(HaveKey("ar-x")) - Expect(res.allIDs).To(ContainElement("ar-x")) - Expect(res.mbid["ar-x"]).To(Equal("mbz-x")) - }) -}) - -// artistParticipants builds a Participants map crediting the given artists under RoleArtist. -func artistParticipants(artists ...model.Artist) model.Participants { - list := make(model.ParticipantList, len(artists)) - for i, a := range artists { - list[i] = model.Participant{Artist: a} - } - return model.Participants{model.RoleArtist: list} -} diff --git a/core/matcher/matcher_suite_test.go b/core/matcher/matcher_suite_test.go deleted file mode 100644 index 44877a3c8..000000000 --- a/core/matcher/matcher_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 0f5389ca5..000000000 --- a/core/matcher/matcher_test.go +++ /dev/null @@ -1,1368 +0,0 @@ -package matcher_test - -import ( - "context" - "errors" - "strings" - - "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 artistRepo *mockArtistRepo - var ctx context.Context - var m *matcher.Matcher - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - mediaFileRepo = newMockMediaFileRepo() - artistRepo = newMockArtistRepo() - DeferCleanup(func() { - mediaFileRepo.AssertExpectations(GinkgoT()) - }) - ds = &tests.MockDataStore{ - MockedMediaFile: mediaFileRepo, - MockedArtist: artistRepo, - } - 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() - } - - // allowIdentifierPhases installs .Maybe() catch-alls for the ID/MBID/ISRC phases so - // tests that only care about the title phase don't fail on those unexpected calls. - allowIdentifierPhases := 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() - } - - // 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() { - allowIdentifierPhases() - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{}, nil).Maybe() - mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). - Return(model.MediaFiles{}, nil).Maybe() - } - - // allowTitlePhase wires title matching from a list of library tracks. Each track must carry - // Participants[RoleArtist] with the artist IDs that credit it; the helper derives the artist - // rows the artist resolution returns from those participants, then returns the tracks from - // the track-fetch query. - allowTitlePhase := func(tracks model.MediaFiles) { - // Artist resolution: build artist rows from the tracks' participants. - seen := map[string]model.Artist{} - for _, t := range tracks { - for _, p := range t.Participants[model.RoleArtist] { - if _, ok := seen[p.ID]; !ok { - seen[p.ID] = p.Artist - } - } - } - artists := make(model.Artists, 0, len(seen)) - for _, a := range seen { - artists = append(artists, a) - } - artistRepo.On("GetAll", mock.Anything).Return(artists, nil).Maybe() - // Track fetch (media_file_artists subquery). - mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). - Return(tracks, 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", Artists: []agents.Artist{{Name: "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", Artists: []agents.Artist{{Name: "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", Artists: []agents.Artist{{Name: "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", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, - } - titleMatch := model.MediaFile{ - ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode", - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), - } - allowTitlePhase(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", Artists: []agents.Artist{{Name: "Queen"}}}, - } - fuzzyMatch := model.MediaFile{ - ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", - Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), - } - allowTitlePhase(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", Artists: []agents.Artist{{Name: "The Beatles"}}}, - } - differentTracks := model.MediaFiles{ - {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles", - Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), - }, - } - allowTitlePhase(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)", Artists: []agents.Artist{{Name: "Queen"}}}, - {Name: "Bohemian Rhapsody (Original Mix)", Artists: []agents.Artist{{Name: "Queen"}}}, - } - libraryTrack := model.MediaFile{ - ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", - Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), - } - allowTitlePhase(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", Artists: []agents.Artist{{Name: "Queen"}}, Album: "A Night at the Opera"}, - {Name: "Bohemian Rhapsody", Artists: []agents.Artist{{Name: "Queen"}}, Album: "A Night at the Opera"}, - } - libraryTrack := model.MediaFile{ - ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", - Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), - } - allowTitlePhase(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", Artists: []agents.Artist{{Name: "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", Artists: []agents.Artist{{Name: "Artist"}}}, - {Name: "Song B", Artists: []agents.Artist{{Name: "Artist"}}}, - {Name: "Song C", Artists: []agents.Artist{{Name: "Artist"}}}, - } - tracks := model.MediaFiles{ - {ID: "a", Title: "Song A", Artist: "Artist", - Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), - }, - {ID: "b", Title: "Song B", Artist: "Artist", - Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), - }, - {ID: "c", Title: "Song C", Artist: "Artist", - Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), - }, - } - allowTitlePhase(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()) - }) - }) - - Context("artist grouping", func() { - It("groups title-phase tracks by participant artist ID, not display Artist", func() { - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{Name: "Daft Punk"}}}, - } - // Display Artist differs from the query artist; only the participant - // with order_artist_name "daft punk" routes to this query bucket. - track := model.MediaFile{ - ID: "oan-track", Title: "Song A", - Artist: "Daft Punk feat. Pharrell", - Participants: artistParticipants( - model.Artist{ID: "dp", Name: "Daft Punk", OrderArtistName: "daft punk"}, - model.Artist{ID: "ph", Name: "Pharrell", OrderArtistName: "pharrell"}, - ), - } - allowTitlePhase(model.MediaFiles{track}) - - result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal("oan-track")) - }) - - It("matches a track that credits the searched artist as a collaborator", func() { - songs := []agents.Song{ - {Name: "Crazy", Artists: []agents.Artist{{Name: "INXS"}}}, - } - // "Par-T-One vs. INXS" — display Artist is the collaboration, but INXS is a - // credited artist participant. Searching INXS must match it. - track := model.MediaFile{ - ID: "collab", Title: "Crazy", Artist: "Par-T-One vs. INXS", - Participants: artistParticipants( - model.Artist{ID: "a-partone", Name: "Par-T-One", OrderArtistName: "par-t-one"}, - model.Artist{ID: "a-inxs", Name: "INXS", OrderArtistName: "inxs"}, - ), - } - allowTitlePhase(model.MediaFiles{track}) - - result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal("collab")) - }) - - It("does not match a track where the searched artist is only the album artist", func() { - songs := []agents.Song{ - {Name: "Qmart", Artists: []agents.Artist{{Name: "808 State"}}}, - } - // Track performed by Björk on an "808 State" compilation: 808 State is the - // albumartist, Björk is the performer. Searching 808 State must NOT match it. - track := model.MediaFile{ - ID: "comp", Title: "Qmart", Artist: "Björk", - Participants: model.Participants{ - model.RoleArtist: model.ParticipantList{ - {Artist: model.Artist{ID: "a-bjork", Name: "Björk", OrderArtistName: "bjork"}}, - }, - model.RoleAlbumArtist: model.ParticipantList{ - {Artist: model.Artist{ID: "a-808", Name: "808 State", OrderArtistName: "808 state"}}, - }, - }, - } - // Artist resolution returns "808 state" only if some artist row matches; here the - // album-artist participant exists but is NOT role='artist', so the track-fetch query's - // EXISTS (role='artist') would not return the track in production. The mock - // returns it anyway; back-mapping must drop it because no role='artist' - // participant is a resolved artist for the query "808 state". - artistRepo.On("GetAll", mock.Anything). - Return(model.Artists{{ID: "a-808", Name: "808 State", OrderArtistName: "808 state"}}, nil).Maybe() - mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). - Return(model.MediaFiles{track}, nil).Maybe() - - result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(BeEmpty()) - }) - - It("resolves the artist by ArtistMBID when the name differs", func() { - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{Name: "Typo Artist", MBID: "mbid-9"}}}, - } - track := model.MediaFile{ - ID: "by-mbid", Title: "Song A", Artist: "Correct Artist", - Participants: artistParticipants(model.Artist{ID: "a9", Name: "Correct Artist", OrderArtistName: "correct artist", MbzArtistID: "mbid-9"}), - } - // Artist resolution returns the artist matched by mbz_artist_id; its order name - // ("correct artist") differs from the query name ("typo artist"), so - // resolution must come from the MBID branch. - artistRepo.On("GetAll", mock.Anything). - Return(model.Artists{{ID: "a9", Name: "Correct Artist", OrderArtistName: "correct artist", MbzArtistID: "mbid-9"}}, nil).Maybe() - mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). - Return(model.MediaFiles{track}, nil).Maybe() - - result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal("by-mbid")) - }) - - It("resolves both queries when two share one ArtistMBID under different names", func() { - // Two agent results for the same MusicBrainz artist but spelled differently - // (an alias). Both must match the artist's track via the shared MBID. - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{Name: "Alias One", MBID: "mbid-shared"}}}, - {Name: "Song B", Artists: []agents.Artist{{Name: "Alias Two", MBID: "mbid-shared"}}}, - } - artist := model.Artist{ID: "a-shared", Name: "Canonical", OrderArtistName: "canonical", MbzArtistID: "mbid-shared"} - trackA := model.MediaFile{ID: "ta", Title: "Song A", Artist: "Canonical", Participants: artistParticipants(artist)} - trackB := model.MediaFile{ID: "tb", Title: "Song B", Artist: "Canonical", Participants: artistParticipants(artist)} - artistRepo.On("GetAll", mock.Anything). - Return(model.Artists{{ID: "a-shared", Name: "Canonical", OrderArtistName: "canonical", MbzArtistID: "mbid-shared"}}, nil).Maybe() - mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). - Return(model.MediaFiles{trackA, trackB}, nil).Maybe() - - result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(2)) - Expect([]string{result[0].ID, result[1].ID}).To(ConsistOf("ta", "tb")) - }) - }) - - // These tests register their own track-fetch expectations per-test (to inject - // an error), so they use allowIdentifierPhases — NOT allowOtherPhases, which would - // add a .Maybe() title-phase catch-all that masks the injected error. - Context("title phase DB errors", func() { - It("returns an error when the title query fails and nothing else matched", func() { - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{Name: "Artist One"}}}, - {Name: "Song B", Artists: []agents.Artist{{Name: "Artist Two"}}}, - } - allowIdentifierPhases() - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{ - {ID: "a1", Name: "Artist One", OrderArtistName: "artist one"}, - {ID: "a2", Name: "Artist Two", OrderArtistName: "artist two"}, - }, nil) - mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). - Return(nil, errors.New("db down")) - - _, err := m.MatchSongs(ctx, songs, 5) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("db down")) - }) - - It("keeps exact-phase matches when the title query fails", func() { - songs := []agents.Song{ - {ID: "track-1", Name: "Exact Song", Artists: []agents.Artist{{Name: "Exact Artist"}}}, - {Name: "Fuzzy Song", Artists: []agents.Artist{{Name: "Fuzzy Artist"}}}, - } - idMatch := model.MediaFile{ID: "track-1", Title: "Exact Song", Artist: "Exact Artist"} - expectIDPhase(model.MediaFiles{idMatch}) - 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() - artistRepo.On("GetAll", mock.Anything).Return(model.Artists{ - {ID: "fa", Name: "Fuzzy Artist", OrderArtistName: "fuzzy artist"}, - }, nil) - mediaFileRepo.On("GetAll", mock.MatchedBy(matchTracksByArtistQuery())). - Return(nil, errors.New("db down")) - - 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("multiple artists", func() { - It("prefers the track that shares more of the song's artists", func() { - conf.Server.Matcher.FuzzyThreshold = 85 - songs := []agents.Song{ - {Name: "Life Is Good", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}}, - } - // Both candidates have display Artist "Drake", so they tie at specificity level 1 - // (name match). The deciding factor is artistOverlap: "both" credits Drake AND - // Future (overlap 2), "one" credits only Drake (overlap 1). - bothArtists := model.MediaFile{ - ID: "both", Title: "Life Is Good", Artist: "Drake", - Participants: artistParticipants( - model.Artist{ID: "drake", Name: "Drake", OrderArtistName: "drake"}, - model.Artist{ID: "future", Name: "Future", OrderArtistName: "future"}, - ), - } - oneArtist := model.MediaFile{ - ID: "one", Title: "Life Is Good", Artist: "Drake", - Participants: artistParticipants( - model.Artist{ID: "drake", Name: "Drake", OrderArtistName: "drake"}, - ), - } - allowTitlePhase(model.MediaFiles{oneArtist, bothArtists}) - - result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal("both")) - }) - - It("matches a single-artist song against a track crediting several artists", func() { - conf.Server.Matcher.FuzzyThreshold = 85 - songs := []agents.Song{ - {Name: "Life Is Good", Artists: []agents.Artist{{Name: "Future"}}}, - } - track := model.MediaFile{ - ID: "multi", Title: "Life Is Good", Artist: "Future feat. Drake", - Participants: artistParticipants( - model.Artist{ID: "future", Name: "Future", OrderArtistName: "future"}, - model.Artist{ID: "drake", Name: "Drake", OrderArtistName: "drake"}, - ), - } - allowTitlePhase(model.MediaFiles{track}) - - result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal("multi")) - }) - - It("matches by a directly-supplied Navidrome artist ID (fast-path)", func() { - conf.Server.Matcher.FuzzyThreshold = 85 - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{ID: "ar-x"}}}, - } - track := model.MediaFile{ - ID: "by-id", Title: "Song A", Artist: "Some Artist", - Participants: artistParticipants( - model.Artist{ID: "ar-x", Name: "Some Artist", OrderArtistName: "some artist"}, - ), - } - allowTitlePhase(model.MediaFiles{track}) - - result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal("by-id")) - }) - - It("prefers a higher artist-overlap track over a starred lower-overlap track", func() { - conf.Server.Matcher.PreferStarred = true - conf.Server.Matcher.FuzzyThreshold = 85 - songs := []agents.Song{ - {Name: "Collab Hit", Artists: []agents.Artist{{Name: "Drake"}, {Name: "Future"}}}, - } - // Shares only Drake (overlap 1) but starred. - starredOne := model.MediaFile{ - ID: "starred-one", Title: "Collab Hit", - Annotations: model.Annotations{Starred: true}, - Participants: artistParticipants(model.Artist{ID: "id-drake", Name: "Drake", OrderArtistName: "drake"}), - } - // Shares both (overlap 2), not starred. - shareTwo := model.MediaFile{ - ID: "share-two", Title: "Collab Hit", - Participants: artistParticipants( - model.Artist{ID: "id-drake", Name: "Drake", OrderArtistName: "drake"}, - model.Artist{ID: "id-future", Name: "Future", OrderArtistName: "future"}, - ), - } - allowTitlePhase(model.MediaFiles{starredOne, shareTwo}) - result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal("share-two")) // overlap outranks the starred flag - }) - }) - }) - - Describe("MatchSongsIndexed", func() { - It("returns index-keyed map of matched songs", func() { - songs := []agents.Song{ - {ID: "track-1", Name: "Song One", Artists: []agents.Artist{{Name: "Artist A"}}}, - {ID: "track-2", Name: "Song Two", Artists: []agents.Artist{{Name: "Artist B"}}}, - {ID: "track-3", Name: "Song Three", Artists: []agents.Artist{{Name: "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", Artists: []agents.Artist{{Name: "Unknown Artist"}}}, - {ID: "track-1", Name: "Known Song", Artists: []agents.Artist{{Name: "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", - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid-123"}), - } - wrongMatch := model.MediaFile{ - ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album", - MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid", - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode", MbzArtistID: "artist-mbid-123"}), - } - songs := []agents.Song{ - {Name: "Similar Song", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "artist-mbid-123"}}, Album: "Violator", AlbumMBID: "album-mbid-456"}, - } - - allowTitlePhase(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", - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), - } - wrongMatch := model.MediaFile{ - ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", - Participants: artistParticipants(model.Artist{ID: "oa", Name: "Other Artist", OrderArtistName: "other artist"}), - } - songs := []agents.Song{ - {Name: "Similar Song", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, - } - - allowTitlePhase(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", - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), - } - wrongMatch := model.MediaFile{ - ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album", - Participants: artistParticipants(model.Artist{ID: "oa", Name: "Other Artist", OrderArtistName: "other artist"}), - } - songs := []agents.Song{ - {Name: "Similar Song", Artists: []agents.Artist{{Name: "Depeche Mode"}}}, - } - - allowTitlePhase(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"}, - } - - allowTitlePhase(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!", - Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), - } - cover2 := model.MediaFile{ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits", - Participants: artistParticipants(model.Artist{ID: "ray-charles", Name: "Ray Charles", OrderArtistName: "ray charles"}), - } - cover3 := model.MediaFile{ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way", - Participants: artistParticipants(model.Artist{ID: "sinatra", Name: "Frank Sinatra", OrderArtistName: "frank sinatra"}), - } - - songs := []agents.Song{ - {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Help!"}, - {Name: "Yesterday", Artists: []agents.Artist{{Name: "Ray Charles"}}, Album: "Greatest Hits"}, - {Name: "Yesterday", Artists: []agents.Artist{{Name: "Frank Sinatra"}}, Album: "My Way"}, - } - - allowTitlePhase(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", - Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), - } - lessAccurateMatch := model.MediaFile{ - ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation", - MbzArtistID: "mbid-1", - Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), - } - artistTwoMatch := model.MediaFile{ - ID: "artist-two", Title: "Song B", Artist: "Artist Two", - Participants: artistParticipants(model.Artist{ID: "a2", Name: "Artist Two", OrderArtistName: "artist two"}), - } - - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{Name: "Artist One", MBID: "mbid-1"}}, Album: "Album One", AlbumMBID: "album-mbid-1"}, - {Name: "Song B", Artists: []agents.Artist{{Name: "Artist Two"}}}, - } - - allowTitlePhase(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")) - }) - - It("uses the resolved artist MBID for specificity (level 5)", func() { - songs := []agents.Song{ - {Name: "Song A", Artists: []agents.Artist{{Name: "Artist One", MBID: "mbid-1"}}, Album: "Album One", AlbumMBID: "album-mbid-1"}, - } - // Two tracks with the same title and album; only the one whose resolved artist - // carries mbid-1 (and whose album MBID matches) wins via Level 5. Without the - // resolved MBID, both tracks tie at Level 3 (name+album) and the first wins by - // chance — verifiable by RED-proof: see task-2-report.md. - precise := model.MediaFile{ - ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One", MbzAlbumID: "album-mbid-1", - Participants: artistParticipants(model.Artist{ID: "a1", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: "mbid-1"}), - } - other := model.MediaFile{ - ID: "other", Title: "Song A", Artist: "Artist One", Album: "Album One", MbzAlbumID: "wrong-album-mbid", - Participants: artistParticipants(model.Artist{ID: "a1b", Name: "Artist One", OrderArtistName: "artist one", MbzArtistID: ""}), - } - // Artist resolution returns both a1 (by name+mbid) and a1b (by name). - allowTitlePhase(model.MediaFiles{other, precise}) - - result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal("precise")) - }) - }) - - 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", Artists: []agents.Artist{{Name: "Radiohead"}}}, - } - artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead", - Participants: artistParticipants(model.Artist{ID: "rh", Name: "Radiohead", OrderArtistName: "radiohead"}), - }, - } - - allowTitlePhase(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", Artists: []agents.Artist{{Name: "Queen"}}}, - } - artistTracks := model.MediaFiles{ - {ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", - Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), - }, - } - - allowTitlePhase(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", Artists: []agents.Artist{{Name: "Radiohead"}}}, - } - artistTracks := model.MediaFiles{ - {ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead", - Participants: artistParticipants(model.Artist{ID: "rh", Name: "Radiohead", OrderArtistName: "radiohead"}), - }, - } - - allowTitlePhase(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", Artists: []agents.Artist{{Name: "Artist"}}}, - } - artistTracks := model.MediaFiles{ - {ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist", - Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), - }, - } - - allowTitlePhase(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", Artists: []agents.Artist{{Name: "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)", - Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), - } - wrongMatch := model.MediaFile{ - ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits", - Participants: artistParticipants(model.Artist{ID: "queen", Name: "Queen", OrderArtistName: "queen"}), - } - - allowTitlePhase(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", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, - } - correctMatch := model.MediaFile{ - ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), - } - wrongMatch := model.MediaFile{ - ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101", - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), - } - - allowTitlePhase(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", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, - } - exactMatch := model.MediaFile{ - ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), - } - fuzzyMatch := model.MediaFile{ - ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)", - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), - } - - allowTitlePhase(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 a more specific match over a starred track when PreferStarred is enabled", func() { - conf.Server.Matcher.PreferStarred = true - songs := []agents.Song{ - {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, - } - albumMatch := model.MediaFile{ - ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), - } - starredTrack := model.MediaFile{ - ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", - Annotations: model.Annotations{Starred: true}, - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), - } - allowTitlePhase(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("album-match")) // specificity now outranks the starred flag - }) - - It("prefers a more specific match over a 4-star track when PreferStarred is enabled", func() { - conf.Server.Matcher.PreferStarred = true - songs := []agents.Song{ - {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, - } - albumMatch := model.MediaFile{ - ID: "album-match", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), - } - ratedTrack := model.MediaFile{ - ID: "rated", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Singles", - Annotations: model.Annotations{Rating: 4}, - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), - } - allowTitlePhase(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("album-match")) // specificity now outranks the 4-star rating - }) - - It("prefers a starred track when specificity and overlap are equal", func() { - conf.Server.Matcher.PreferStarred = true - songs := []agents.Song{ - {Name: "Enjoy the Silence", Artists: []agents.Artist{{Name: "Depeche Mode"}}, Album: "Violator"}, - } - // Both credit the same single artist and the same album → equal specificity AND equal overlap. - plain := model.MediaFile{ - ID: "plain", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), - } - starred := model.MediaFile{ - ID: "starred", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator", - Annotations: model.Annotations{Starred: true}, - Participants: artistParticipants(model.Artist{ID: "dm", Name: "Depeche Mode", OrderArtistName: "depeche mode"}), - } - allowTitlePhase(model.MediaFiles{plain, starred}) - result, err := m.MatchSongs(ctx, songs, 5) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result[0].ID).To(Equal("starred")) // preferred still wins the tie - }) - }) - - Describe("duration matching", func() { - BeforeEach(func() { - conf.Server.Matcher.FuzzyThreshold = 100 - }) - - It("prefers tracks with matching duration", func() { - songs := []agents.Song{ - {Name: "Similar Song", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, - } - correctMatch := model.MediaFile{ - ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0, - Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), - } - wrongDuration := model.MediaFile{ - ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0, - Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), - } - - allowTitlePhase(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", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, - } - closeDuration := model.MediaFile{ - ID: "close-duration", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5, - Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), - } - - allowTitlePhase(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", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, - } - closeDuration := model.MediaFile{ - ID: "close", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0, - Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), - } - farDuration := model.MediaFile{ - ID: "far", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0, - Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), - } - - allowTitlePhase(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", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, - } - differentDuration := model.MediaFile{ - ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, - Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), - } - - allowTitlePhase(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", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 180000}, - } - differentTitle := model.MediaFile{ - ID: "wrong-title", Title: "Different Song", Artist: "Test Artist", Duration: 180.0, - Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), - } - correctTitle := model.MediaFile{ - ID: "correct-title", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0, - Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), - } - - allowTitlePhase(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", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 0}, - } - anyTrack := model.MediaFile{ - ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0, - Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), - } - - allowTitlePhase(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", Artists: []agents.Artist{{Name: "Test Artist"}}, Duration: 30000}, - } - shortTrack := model.MediaFile{ - ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0, - Participants: artistParticipants(model.Artist{ID: "ta", Name: "Test Artist", OrderArtistName: "test artist"}), - } - - allowTitlePhase(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")) - }) - - It("matches same title+artist songs to their own closest-duration track", func() { - songs := []agents.Song{ - {Name: "Same Song", Artists: []agents.Artist{{Name: "Same Artist"}}, Duration: 180000}, - {Name: "Same Song", Artists: []agents.Artist{{Name: "Same Artist"}}, Duration: 240000}, - } - shortTrack := model.MediaFile{ - ID: "short", Title: "Same Song", Artist: "Same Artist", Duration: 180.0, - Participants: artistParticipants(model.Artist{ID: "sa", Name: "Same Artist", OrderArtistName: "same artist"}), - } - longTrack := model.MediaFile{ - ID: "long", Title: "Same Song", Artist: "Same Artist", Duration: 240.0, - Participants: artistParticipants(model.Artist{ID: "sa", Name: "Same Artist", OrderArtistName: "same artist"}), - } - - allowTitlePhase(model.MediaFiles{shortTrack, longTrack}) - - result, err := m.MatchSongs(ctx, songs, 5) - - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(2)) - Expect(result[0].ID).To(Equal("short")) - Expect(result[1].ID).To(Equal("long")) - }) - }) - - 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", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Help!"}, - {Name: "Yesterday (Remastered)", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "1"}, - {Name: "Yesterday", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Help!"}, - {Name: "Yesterday (Anthology)", Artists: []agents.Artist{{Name: "The Beatles"}}, Album: "Anthology"}, - } - libraryTrack := model.MediaFile{ - ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!", - Participants: artistParticipants(model.Artist{ID: "beatles", Name: "The Beatles", OrderArtistName: "beatles"}), - } - - allowTitlePhase(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", Artists: []agents.Artist{{Name: "Artist"}}}, - {Name: "Song B", Artists: []agents.Artist{{Name: "Artist"}}}, - {Name: "Song C", Artists: []agents.Artist{{Name: "Artist"}}}, - } - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist", - Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), - } - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist", - Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), - } - trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist", - Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), - } - - allowTitlePhase(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", Artists: []agents.Artist{{Name: "Artist"}}}, - {Name: "Song A (Live)", Artists: []agents.Artist{{Name: "Artist"}}}, - {Name: "Song B", Artists: []agents.Artist{{Name: "Artist"}}}, - {Name: "Song B (Remix)", Artists: []agents.Artist{{Name: "Artist"}}}, - } - trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist", - Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), - } - trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist", - Participants: artistParticipants(model.Artist{ID: "art", Name: "Artist", OrderArtistName: "artist"}), - } - - allowTitlePhase(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(ctx context.Context, 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(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { - return m.GetAll(ctx, options...) -} - -func (m *mockMediaFileRepo) SetError(hasError bool) { - if hasError { - m.On("GetAll", mock.Anything).Return(nil, errors.New("mock repo error")) - } -} - -type mockArtistRepo struct { - mock.Mock - model.ArtistRepository -} - -func newMockArtistRepo() *mockArtistRepo { - return &mockArtistRepo{} -} - -func (m *mockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Artists, 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.Artists), args.Error(1) -} - -// 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 - } -} - -// artistParticipants builds a Participants map crediting the given artists under RoleArtist. -func artistParticipants(artists ...model.Artist) model.Participants { - list := make(model.ParticipantList, len(artists)) - for i, a := range artists { - list[i] = model.Participant{Artist: a} - } - return model.Participants{model.RoleArtist: list} -} - -// matchTracksByArtistQuery matches the title phase's track-fetch query, identified by its -// squirrel.And containing a squirrel.Expr whose SQL references media_file_artists. -func matchTracksByArtistQuery() func(opt model.QueryOptions) bool { - return func(opt model.QueryOptions) bool { - and, ok := opt.Filters.(squirrel.And) - if !ok { - return false - } - for _, f := range and { - sql, _, err := f.ToSql() - if err == nil && strings.Contains(sql, "media_file_artists") { - return true - } - } - return false - } -} diff --git a/core/media_streamer.go b/core/media_streamer.go new file mode 100644 index 000000000..695852192 --- /dev/null +++ b/core/media_streamer.go @@ -0,0 +1,209 @@ +package core + +import ( + "context" + "fmt" + "io" + "mime" + "os" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/cache" +) + +type MediaStreamer interface { + NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) + DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int) (*Stream, error) +} + +type TranscodingCache cache.FileCache + +func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer { + return &mediaStreamer{ds: ds, transcoder: t, cache: cache} +} + +type mediaStreamer struct { + ds model.DataStore + transcoder ffmpeg.FFmpeg + cache cache.FileCache +} + +type streamJob struct { + ms *mediaStreamer + mf *model.MediaFile + format string + bitRate int +} + +func (j *streamJob) Key() string { + return fmt.Sprintf("%s.%s.%d.%s", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format) +} + +func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int) (*Stream, error) { + mf, err := ms.ds.MediaFile(ctx).Get(id) + if err != nil { + return nil, err + } + + return ms.DoStream(ctx, mf, reqFormat, reqBitRate) +} + +func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int) (*Stream, error) { + var format string + var bitRate int + var cached bool + defer func() { + log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached, + "bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw", + "originalFormat", mf.Suffix, "originalBitRate", mf.BitRate) + }() + + format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate) + s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate} + + if format == "raw" { + log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", mf.Path, + "requestBitrate", reqBitRate, "requestFormat", reqFormat, + "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, + "selectedBitrate", bitRate, "selectedFormat", format) + f, err := os.Open(mf.Path) + if err != nil { + return nil, err + } + s.ReadCloser = f + s.Seeker = f + s.format = mf.Suffix + return s, nil + } + + job := &streamJob{ + ms: ms, + mf: mf, + format: format, + bitRate: bitRate, + } + r, err := ms.cache.Get(ctx, job) + if err != nil { + log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err) + return nil, err + } + cached = r.Cached + + s.ReadCloser = r + s.Seeker = r.Seeker + + log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", mf.Path, + "requestBitrate", reqBitRate, "requestFormat", reqFormat, + "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, + "selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable()) + + return s, nil +} + +type Stream struct { + ctx context.Context + mf *model.MediaFile + bitRate int + format string + io.ReadCloser + io.Seeker +} + +func (s *Stream) Seekable() bool { return s.Seeker != nil } +func (s *Stream) Duration() float32 { return s.mf.Duration } +func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) } +func (s *Stream) Name() string { return s.mf.Title + "." + s.format } +func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt } +func (s *Stream) EstimatedContentLength() int { + return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024) +} + +// TODO This function deserves some love (refactoring) +func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) { + format = "raw" + if reqFormat == "raw" { + return format, 0 + } + if reqFormat == mf.Suffix && reqBitRate == 0 { + bitRate = mf.BitRate + return format, bitRate + } + trc, hasDefault := request.TranscodingFrom(ctx) + var cFormat string + var cBitRate int + if reqFormat != "" { + cFormat = reqFormat + } else { + if hasDefault { + cFormat = trc.TargetFormat + cBitRate = trc.DefaultBitRate + if p, ok := request.PlayerFrom(ctx); ok { + cBitRate = p.MaxBitRate + } + } else if reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "" { + // If no format is specified and no transcoding associated to the player, but a bitrate is specified, + // and there is no transcoding set for the player, we use the default downsampling format. + // But only if the requested bitRate is lower than the original bitRate. + log.Debug("Default Downsampling", "Using default downsampling format", conf.Server.DefaultDownsamplingFormat) + cFormat = conf.Server.DefaultDownsamplingFormat + } + } + if reqBitRate > 0 { + cBitRate = reqBitRate + } + if cBitRate == 0 && cFormat == "" { + return format, bitRate + } + t, err := ds.Transcoding(ctx).FindByFormat(cFormat) + if err == nil { + format = t.TargetFormat + if cBitRate != 0 { + bitRate = cBitRate + } else { + bitRate = t.DefaultBitRate + } + } + if format == mf.Suffix && bitRate >= mf.BitRate { + format = "raw" + bitRate = 0 + } + return format, bitRate +} + +var ( + onceTranscodingCache sync.Once + instanceTranscodingCache TranscodingCache +) + +func GetTranscodingCache() TranscodingCache { + onceTranscodingCache.Do(func() { + instanceTranscodingCache = NewTranscodingCache() + }) + return instanceTranscodingCache +} + +func NewTranscodingCache() TranscodingCache { + return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize, + consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems, + func(ctx context.Context, arg cache.Item) (io.Reader, error) { + job := arg.(*streamJob) + t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format) + if err != nil { + log.Error(ctx, "Error loading transcoding command", "format", job.format, err) + return nil, os.ErrInvalid + } + out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.mf.Path, job.bitRate) + if err != nil { + log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err) + return nil, os.ErrInvalid + } + return out, nil + }) +} diff --git a/core/media_streamer_Internal_test.go b/core/media_streamer_Internal_test.go new file mode 100644 index 000000000..001037167 --- /dev/null +++ b/core/media_streamer_Internal_test.go @@ -0,0 +1,161 @@ +package core + +import ( + "context" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MediaStreamer", func() { + var ds model.DataStore + ctx := log.NewContext(context.Background()) + + BeforeEach(func() { + ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} + }) + + Context("selectTranscodingOptions", func() { + mf := &model.MediaFile{} + Context("player is not configured", func() { + It("returns raw if raw is requested", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) + Expect(format).To(Equal("raw")) + }) + It("returns raw if a transcoder does not exists", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0) + Expect(format).To(Equal("raw")) + }) + It("returns the requested format if a transcoder exists", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) + Expect(format).To(Equal("mp3")) + Expect(bitRate).To(Equal(160)) // Default Bit Rate + }) + It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() { + mf.Suffix = "mp3" + mf.BitRate = 112 + format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128) + Expect(format).To(Equal("raw")) + }) + It("returns the requested format if requested BitRate is lower than original", func() { + mf.Suffix = "mp3" + mf.BitRate = 320 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192) + Expect(format).To(Equal("mp3")) + Expect(bitRate).To(Equal(192)) + }) + It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() { + mf.Suffix = "mp3" + mf.BitRate = 320 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) + Expect(format).To(Equal("raw")) + Expect(bitRate).To(Equal(320)) + }) + Context("Downsampling", func() { + BeforeEach(func() { + conf.Server.DefaultDownsamplingFormat = "opus" + mf.Suffix = "FLAC" + mf.BitRate = 960 + }) + It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() { + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128) + Expect(format).To(Equal("opus")) + Expect(bitRate).To(Equal(128)) + }) + It("returns raw if maxBitrate is equal or greater than original", func() { + // This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960) + Expect(format).To(Equal("raw")) + Expect(bitRate).To(Equal(0)) + }) + }) + }) + + Context("player has format configured", func() { + BeforeEach(func() { + t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96} + ctx = request.WithTranscoding(ctx, t) + }) + It("returns raw if raw is requested", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) + Expect(format).To(Equal("raw")) + }) + It("returns configured format/bitrate as default", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0) + Expect(format).To(Equal("oga")) + Expect(bitRate).To(Equal(96)) + }) + It("returns requested format", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) + Expect(format).To(Equal("mp3")) + Expect(bitRate).To(Equal(160)) // Default Bit Rate + }) + It("returns requested bitrate", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80) + Expect(format).To(Equal("oga")) + Expect(bitRate).To(Equal(80)) + }) + It("returns raw if selected bitrate and format is the same as original", func() { + mf.Suffix = "mp3" + mf.BitRate = 192 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192) + Expect(format).To(Equal("raw")) + Expect(bitRate).To(Equal(0)) + }) + }) + Context("player has maxBitRate configured", func() { + BeforeEach(func() { + t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96} + p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 80} + ctx = request.WithTranscoding(ctx, t) + ctx = request.WithPlayer(ctx, p) + }) + It("returns raw if raw is requested", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) + Expect(format).To(Equal("raw")) + }) + It("returns configured format/bitrate as default", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0) + Expect(format).To(Equal("oga")) + Expect(bitRate).To(Equal(80)) + }) + It("returns requested format", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) + Expect(format).To(Equal("mp3")) + Expect(bitRate).To(Equal(160)) // Default Bit Rate + }) + It("returns requested bitrate", func() { + mf.Suffix = "flac" + mf.BitRate = 1000 + format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80) + Expect(format).To(Equal("oga")) + Expect(bitRate).To(Equal(80)) + }) + }) + }) +}) diff --git a/core/media_streamer_test.go b/core/media_streamer_test.go new file mode 100644 index 000000000..1499450d8 --- /dev/null +++ b/core/media_streamer_test.go @@ -0,0 +1,74 @@ +package core_test + +import ( + "context" + "io" + "os" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MediaStreamer", func() { + var streamer core.MediaStreamer + var ds model.DataStore + ffmpeg := tests.NewMockFFmpeg("fake data") + ctx := log.NewContext(context.TODO()) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches") + conf.Server.TranscodingCacheSize = "100MB" + ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} + ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ + {ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0}, + }) + testCache := core.NewTranscodingCache() + Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue()) + streamer = core.NewMediaStreamer(ds, ffmpeg, testCache) + }) + AfterEach(func() { + _ = os.RemoveAll(conf.Server.CacheFolder) + }) + + Context("NewStream", func() { + It("returns a seekable stream if format is 'raw'", func() { + s, err := streamer.NewStream(ctx, "123", "raw", 0) + Expect(err).ToNot(HaveOccurred()) + Expect(s.Seekable()).To(BeTrue()) + }) + It("returns a seekable stream if maxBitRate is 0", func() { + s, err := streamer.NewStream(ctx, "123", "mp3", 0) + Expect(err).ToNot(HaveOccurred()) + Expect(s.Seekable()).To(BeTrue()) + }) + It("returns a seekable stream if maxBitRate is higher than file bitRate", func() { + s, err := streamer.NewStream(ctx, "123", "mp3", 320) + Expect(err).ToNot(HaveOccurred()) + Expect(s.Seekable()).To(BeTrue()) + }) + It("returns a NON seekable stream if transcode is required", func() { + s, err := streamer.NewStream(ctx, "123", "mp3", 64) + Expect(err).To(BeNil()) + Expect(s.Seekable()).To(BeFalse()) + Expect(s.Duration()).To(Equal(float32(257.0))) + }) + It("returns a seekable stream if the file is complete in the cache", func() { + s, err := streamer.NewStream(ctx, "123", "mp3", 32) + Expect(err).To(BeNil()) + _, _ = io.ReadAll(s) + _ = s.Close() + Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue()) + + s, err = streamer.NewStream(ctx, "123", "mp3", 32) + Expect(err).To(BeNil()) + Expect(s.Seekable()).To(BeTrue()) + }) + }) +}) diff --git a/core/metrics.go b/core/metrics.go new file mode 100644 index 000000000..bcbd08337 --- /dev/null +++ b/core/metrics.go @@ -0,0 +1,123 @@ +package core + +import ( + "context" + "fmt" + "strconv" + "sync" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/prometheus/client_golang/prometheus" +) + +func WriteInitialMetrics() { + getPrometheusMetrics().versionInfo.With(prometheus.Labels{"version": consts.Version}).Set(1) +} + +func WriteAfterScanMetrics(ctx context.Context, dataStore model.DataStore, success bool) { + processSqlAggregateMetrics(ctx, dataStore, getPrometheusMetrics().dbTotal) + + scanLabels := prometheus.Labels{"success": strconv.FormatBool(success)} + getPrometheusMetrics().lastMediaScan.With(scanLabels).SetToCurrentTime() + getPrometheusMetrics().mediaScansCounter.With(scanLabels).Inc() +} + +// Prometheus' metrics requires initialization. But not more than once +var ( + prometheusMetricsInstance *prometheusMetrics + prometheusOnce sync.Once +) + +type prometheusMetrics struct { + dbTotal *prometheus.GaugeVec + versionInfo *prometheus.GaugeVec + lastMediaScan *prometheus.GaugeVec + mediaScansCounter *prometheus.CounterVec +} + +func getPrometheusMetrics() *prometheusMetrics { + prometheusOnce.Do(func() { + var err error + prometheusMetricsInstance, err = newPrometheusMetrics() + if err != nil { + log.Fatal("Unable to create Prometheus metrics instance.", err) + } + }) + return prometheusMetricsInstance +} + +func newPrometheusMetrics() (*prometheusMetrics, error) { + res := &prometheusMetrics{ + dbTotal: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "db_model_totals", + Help: "Total number of DB items per model", + }, + []string{"model"}, + ), + versionInfo: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "navidrome_info", + Help: "Information about Navidrome version", + }, + []string{"version"}, + ), + lastMediaScan: prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "media_scan_last", + Help: "Last media scan timestamp by success", + }, + []string{"success"}, + ), + mediaScansCounter: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "media_scans", + Help: "Total success media scans by success", + }, + []string{"success"}, + ), + } + + err := prometheus.DefaultRegisterer.Register(res.dbTotal) + if err != nil { + return nil, fmt.Errorf("unable to register db_model_totals metrics: %w", err) + } + err = prometheus.DefaultRegisterer.Register(res.versionInfo) + if err != nil { + return nil, fmt.Errorf("unable to register navidrome_info metrics: %w", err) + } + err = prometheus.DefaultRegisterer.Register(res.lastMediaScan) + if err != nil { + return nil, fmt.Errorf("unable to register media_scan_last metrics: %w", err) + } + err = prometheus.DefaultRegisterer.Register(res.mediaScansCounter) + if err != nil { + return nil, fmt.Errorf("unable to register media_scans metrics: %w", err) + } + return res, nil +} + +func processSqlAggregateMetrics(ctx context.Context, dataStore model.DataStore, targetGauge *prometheus.GaugeVec) { + albumsCount, err := dataStore.Album(ctx).CountAll() + if err != nil { + log.Warn("album CountAll error", err) + return + } + targetGauge.With(prometheus.Labels{"model": "album"}).Set(float64(albumsCount)) + + songsCount, err := dataStore.MediaFile(ctx).CountAll() + if err != nil { + log.Warn("media CountAll error", err) + return + } + targetGauge.With(prometheus.Labels{"model": "media"}).Set(float64(songsCount)) + + usersCount, err := dataStore.User(ctx).CountAll() + if err != nil { + log.Warn("user CountAll error", err) + return + } + targetGauge.With(prometheus.Labels{"model": "user"}).Set(float64(usersCount)) +} diff --git a/core/metrics/insights.go b/core/metrics/insights.go deleted file mode 100644 index 1d2ff5df4..000000000 --- a/core/metrics/insights.go +++ /dev/null @@ -1,352 +0,0 @@ -package metrics - -import ( - "bytes" - "context" - "encoding/json" - "math" - "net/http" - "os" - "path/filepath" - "runtime" - "runtime/debug" - "slices" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/Masterminds/squirrel" - "github.com/google/uuid" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/metrics/insights" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/plugins" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/utils/httpclient" - "github.com/navidrome/navidrome/utils/singleton" -) - -type Insights interface { - Run(ctx context.Context) - LastRun(ctx context.Context) (timestamp time.Time, success bool) -} - -var ( - insightsID string -) - -type insightsCollector struct { - ds model.DataStore - lastRun atomic.Int64 - lastStatus atomic.Bool -} - -func GetInstance(ds model.DataStore) Insights { - return singleton.GetInstance(func() *insightsCollector { - id, err := ds.Property().Get(context.TODO(), consts.InsightsIDKey) - if err != nil { - log.Trace("Could not get Insights ID from DB. Creating one", err) - id = uuid.NewString() - err = ds.Property().Put(context.TODO(), consts.InsightsIDKey, id) - if err != nil { - log.Trace("Could not save Insights ID to DB", err) - } - } - insightsID = id - return &insightsCollector{ds: ds} - }) -} - -func (c *insightsCollector) Run(ctx context.Context) { - for { - // Refresh admin context on each iteration to handle cases where - // admin user wasn't available on previous runs - insightsCtx := auth.WithAdminUser(ctx, c.ds) - u, _ := request.UserFrom(insightsCtx) - if !u.IsAdmin { - log.Trace(insightsCtx, "No admin user available, skipping insights collection") - } else { - c.sendInsights(insightsCtx) - } - select { - case <-time.After(consts.InsightsUpdateInterval): - continue - case <-ctx.Done(): - return - } - } -} - -func (c *insightsCollector) LastRun(context.Context) (timestamp time.Time, success bool) { - t := c.lastRun.Load() - return time.UnixMilli(t), c.lastStatus.Load() -} - -func (c *insightsCollector) sendInsights(ctx context.Context) { - count, err := c.ds.User().CountAll(ctx, model.QueryOptions{}) - if err != nil { - log.Trace(ctx, "Could not check user count", err) - return - } - if count == 0 { - log.Trace(ctx, "No users found, skipping Insights data collection") - return - } - hc := httpclient.New(consts.DefaultHttpClientTimeOut) - data := c.collect(ctx) - if data == nil { - return - } - body := bytes.NewReader(data) - req, err := http.NewRequestWithContext(ctx, "POST", consts.InsightsEndpoint, body) - if err != nil { - log.Trace(ctx, "Could not create Insights request", err) - return - } - req.Header.Set("Content-Type", "application/json") - resp, err := hc.Do(req) //nolint:gosec - if err != nil { - log.Trace(ctx, "Could not send Insights data", err) - return - } - log.Info(ctx, "Sent Insights data (for details see http://navidrome.org/docs/getting-started/insights", "data", - string(data), "server", consts.InsightsEndpoint, "status", resp.Status) - c.lastRun.Store(time.Now().UnixMilli()) - c.lastStatus.Store(resp.StatusCode < 300) - resp.Body.Close() -} - -func buildInfo() (map[string]string, string) { - bInfo := map[string]string{} - var version string - if info, ok := debug.ReadBuildInfo(); ok { - for _, setting := range info.Settings { - if setting.Value == "" { - continue - } - bInfo[setting.Key] = setting.Value - } - version = info.GoVersion - } - return bInfo, version -} - -func getFSInfo(path string) *insights.FSInfo { - var info insights.FSInfo - - // Normalize the path - absPath, err := filepath.Abs(path) - if err != nil { - return nil - } - absPath = filepath.Clean(absPath) - - fsType, err := getFilesystemType(absPath) - if err != nil { - return nil - } - info.Type = fsType - return &info -} - -// installedPackage returns the official installer format used, as written by our own packagers. -func installedPackage() string { - data, _ := os.ReadFile(filepath.Join(conf.Server.DataFolder.String(), ".package")) - return strings.TrimSpace(string(data)) -} - -// hostingPlatform is env-based, not a file, as app stores can only inject env vars into our image. -func hostingPlatform() string { - return strings.TrimSpace(os.Getenv("ND_PLATFORM")) -} - -var staticData = sync.OnceValue(func() insights.Data { - // Basic info - data := insights.Data{ - InsightsID: insightsID, - Version: consts.Version, - } - - // Build info - data.Build.Settings, data.Build.GoVersion = buildInfo() - data.OS.Containerized = consts.InContainer - - // Install info - data.OS.Package = installedPackage() - data.Platform = hostingPlatform() - - // OS info - data.OS.Type = runtime.GOOS - data.OS.Arch = runtime.GOARCH - data.OS.NumCPU = runtime.NumCPU() - data.OS.Version, data.OS.Distro = getOSVersion() - - // FS info - data.FS.Music = getFSInfo(conf.Server.MusicFolder) - data.FS.Data = getFSInfo(conf.Server.DataFolder.String()) - if conf.Server.CacheFolder.String() != "" { - data.FS.Cache = getFSInfo(conf.Server.CacheFolder.String()) - } - if conf.Server.Backup.Path.String() != "" { - data.FS.Backup = getFSInfo(conf.Server.Backup.Path.String()) - } - - // Config info - data.Config.LogLevel = conf.Server.LogLevel - data.Config.LogFileConfigured = conf.Server.LogFile != "" - data.Config.TLSConfigured = conf.Server.TLSEnabled() - data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL - data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache - data.Config.EnableArtworkUpload = conf.Server.EnableArtworkUpload - 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 - data.Config.EnableSharing = conf.Server.EnableSharing - data.Config.EnableStarRating = conf.Server.EnableStarRating - data.Config.EnableLastFM = conf.Server.LastFM.Enabled && conf.Server.LastFM.ApiKey != "" && conf.Server.LastFM.Secret != "" - data.Config.EnableListenBrainz = conf.Server.ListenBrainz.Enabled - data.Config.EnableDeezer = conf.Server.Deezer.Enabled - data.Config.EnableMediaFileCoverArt = conf.Server.EnableMediaFileCoverArt - data.Config.EnableJukebox = conf.Server.Jukebox.Enabled - data.Config.EnablePrometheus = conf.Server.Prometheus.Enabled - data.Config.TranscodingCacheSize = conf.Server.TranscodingCacheSize - data.Config.ImageCacheSize = conf.Server.ImageCacheSize - data.Config.SessionTimeout = uint64(math.Trunc(conf.Server.SessionTimeout.Seconds())) - data.Config.SearchFullString = conf.Server.Search.FullString - data.Config.SearchBackend = conf.Server.Search.Backend - data.Config.RecentlyAddedByModTime = conf.Server.RecentlyAddedByModTime - data.Config.PreferSortTags = conf.Server.PreferSortTags - data.Config.BackupSchedule = conf.Server.Backup.Schedule - data.Config.BackupCount = conf.Server.Backup.Count - data.Config.DevActivityPanel = conf.Server.DevActivityPanel - data.Config.ScannerEnabled = conf.Server.Scanner.Enabled - data.Config.ScannerExtractor = conf.Server.Scanner.Extractor - data.Config.ScanSchedule = conf.Server.Scanner.Schedule - data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds())) - data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup - data.Config.EnableScheduledDBAnalyze = conf.Server.EnableScheduledDBAnalyze - data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != "" - data.Config.HasCustomPID = conf.Server.PID.Track != consts.DefaultTrackPID || conf.Server.PID.Album != consts.DefaultAlbumPID - data.Config.HasCustomTags = len(conf.Server.Tags) > 0 - - return data -}) - -func (c *insightsCollector) collect(ctx context.Context) []byte { - data := staticData() - data.Uptime = time.Since(consts.ServerStart).Milliseconds() / 1000 - - // Library info - var err error - data.Library.Tracks, err = c.ds.MediaFile().CountAll(ctx) - if err != nil { - log.Trace(ctx, "Error reading tracks count", err) - } - data.Library.Albums, err = c.ds.Album().CountAll(ctx) - if err != nil { - log.Trace(ctx, "Error reading albums count", err) - } - data.Library.Artists, err = c.ds.Artist().CountAll(ctx) - if err != nil { - log.Trace(ctx, "Error reading artists count", err) - } - data.Library.Playlists, err = c.ds.Playlist().CountAll(ctx) - if err != nil { - log.Trace(ctx, "Error reading playlists count", err) - } - data.Library.Shares, err = c.ds.Share().CountAll(ctx) - if err != nil { - log.Trace(ctx, "Error reading shares count", err) - } - data.Library.Radios, err = c.ds.Radio().CountAll(ctx) - if err != nil { - log.Trace(ctx, "Error reading radios count", err) - } - libs, err := c.ds.Library().GetAll(ctx) - if err != nil { - log.Trace(ctx, "Error reading libraries", err) - } - data.Library.Libraries = int64(len(libs)) - if slices.ContainsFunc(libs, func(lib model.Library) bool { return lib.PIDAlbum != "" || lib.PIDTrack != "" }) { - data.Config.HasCustomPID = true - } - data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)}, - }) - if err != nil { - log.Trace(ctx, "Error reading active users count", err) - } - data.Library.FileSuffixes, err = c.ds.MediaFile().CountBySuffix(ctx) - if err != nil { - log.Trace(ctx, "Error reading file suffixes count", err) - } - - // Check for smart playlists - data.Config.HasSmartPlaylists, err = c.hasSmartPlaylists(ctx) - if err != nil { - log.Trace(ctx, "Error checking for smart playlists", err) - } - - // Collect plugins if permitted and enabled - if conf.Server.DevEnablePluginsInsights && conf.Server.Plugins.Enabled { - data.Plugins = c.collectPlugins(ctx) - } - - // Collect active players if permitted - if conf.Server.DevEnablePlayerInsights { - data.Library.ActivePlayers, err = c.ds.Player().CountByClient(ctx, model.QueryOptions{ - Filters: squirrel.Gt{"last_seen": time.Now().Add(-7 * 24 * time.Hour)}, - }) - if err != nil { - log.Trace(ctx, "Error reading active players count", err) - } - } - - // Memory info - var m runtime.MemStats - runtime.ReadMemStats(&m) - data.Mem.Alloc = m.Alloc - data.Mem.TotalAlloc = m.TotalAlloc - data.Mem.Sys = m.Sys - data.Mem.NumGC = m.NumGC - - // Marshal to JSON - resp, err := json.Marshal(data) - if err != nil { - log.Trace(ctx, "Could not marshal Insights data", err) - return nil - } - return resp -} - -// hasSmartPlaylists checks if there are any smart playlists (playlists with rules) -func (c *insightsCollector) hasSmartPlaylists(ctx context.Context) (bool, error) { - count, err := c.ds.Playlist().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.And{squirrel.NotEq{"rules": ""}, squirrel.NotEq{"rules": nil}}, - }) - return count > 0, err -} - -// collectPlugins collects information about installed plugins -func (c *insightsCollector) collectPlugins(_ context.Context) map[string]insights.PluginInfo { - // TODO Fix import/inject cycles - manager := plugins.GetManager(c.ds, events.GetBroker(), nil) - info := manager.GetPluginInfo() - - result := make(map[string]insights.PluginInfo, len(info)) - for name, p := range info { - result[name] = insights.PluginInfo{ - Name: p.Name, - Version: p.Version, - } - } - return result -} diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go deleted file mode 100644 index 8559d4204..000000000 --- a/core/metrics/insights/data.go +++ /dev/null @@ -1,99 +0,0 @@ -package insights - -type Data struct { - InsightsID string `json:"id"` - Version string `json:"version"` - Uptime int64 `json:"uptime"` - // Platform is the app store or hosting provider this instance runs on, self-declared via ND_PLATFORM - Platform string `json:"platform,omitempty"` - Build struct { - // build settings used by the Go compiler - Settings map[string]string `json:"settings"` - GoVersion string `json:"goVersion"` - } `json:"build"` - OS struct { - Type string `json:"type"` - Distro string `json:"distro,omitempty"` - Version string `json:"version,omitempty"` - Containerized bool `json:"containerized"` - Arch string `json:"arch"` - NumCPU int `json:"numCPU"` - Package string `json:"package,omitempty"` - } `json:"os"` - Mem struct { - Alloc uint64 `json:"alloc"` - TotalAlloc uint64 `json:"totalAlloc"` - Sys uint64 `json:"sys"` - NumGC uint32 `json:"numGC"` - } `json:"mem"` - FS struct { - Music *FSInfo `json:"music,omitempty"` - Data *FSInfo `json:"data,omitempty"` - Cache *FSInfo `json:"cache,omitempty"` - Backup *FSInfo `json:"backup,omitempty"` - } `json:"fs"` - Library struct { - Tracks int64 `json:"tracks"` - Albums int64 `json:"albums"` - Artists int64 `json:"artists"` - Playlists int64 `json:"playlists"` - Shares int64 `json:"shares"` - Radios int64 `json:"radios"` - Libraries int64 `json:"libraries"` - ActiveUsers int64 `json:"activeUsers"` - ActivePlayers map[string]int64 `json:"activePlayers,omitempty"` - FileSuffixes map[string]int64 `json:"fileSuffixes,omitempty"` - } `json:"library"` - Config struct { - LogLevel string `json:"logLevel,omitempty"` - LogFileConfigured bool `json:"logFileConfigured,omitempty"` - TLSConfigured bool `json:"tlsConfigured,omitempty"` - ScannerEnabled bool `json:"scannerEnabled,omitempty"` - ScannerExtractor string `json:"scannerExtractor,omitempty"` - ScanSchedule string `json:"scanSchedule,omitempty"` - ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"` - ScanOnStartup bool `json:"scanOnStartup,omitempty"` - EnableScheduledDBAnalyze bool `json:"enableScheduledDBAnalyze,omitempty"` - TranscodingCacheSize string `json:"transcodingCacheSize,omitempty"` - ImageCacheSize string `json:"imageCacheSize,omitempty"` - EnableArtworkPrecache bool `json:"enableArtworkPrecache,omitempty"` - EnableDownloads bool `json:"enableDownloads,omitempty"` - EnableSharing bool `json:"enableSharing,omitempty"` - EnableStarRating bool `json:"enableStarRating,omitempty"` - EnableLastFM bool `json:"enableLastFM,omitempty"` - EnableListenBrainz bool `json:"enableListenBrainz,omitempty"` - EnableDeezer bool `json:"enableDeezer,omitempty"` - 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"` - SearchFullString bool `json:"searchFullString,omitempty"` - SearchBackend string `json:"searchBackend,omitempty"` - RecentlyAddedByModTime bool `json:"recentlyAddedByModTime,omitempty"` - PreferSortTags bool `json:"preferSortTags,omitempty"` - BackupSchedule string `json:"backupSchedule,omitempty"` - BackupCount int `json:"backupCount,omitempty"` - DevActivityPanel bool `json:"devActivityPanel,omitempty"` - DefaultBackgroundURLSet bool `json:"defaultBackgroundURL,omitempty"` - HasSmartPlaylists bool `json:"hasSmartPlaylists,omitempty"` - ReverseProxyConfigured bool `json:"reverseProxyConfigured,omitempty"` - HasCustomPID bool `json:"hasCustomPID,omitempty"` - HasCustomTags bool `json:"hasCustomTags,omitempty"` - } `json:"config"` - Plugins map[string]PluginInfo `json:"plugins,omitempty"` -} - -type PluginInfo struct { - Name string `json:"name"` - Version string `json:"version"` -} - -type FSInfo struct { - Type string `json:"type,omitempty"` -} diff --git a/core/metrics/insights_darwin.go b/core/metrics/insights_darwin.go deleted file mode 100644 index ad59182ef..000000000 --- a/core/metrics/insights_darwin.go +++ /dev/null @@ -1,37 +0,0 @@ -package metrics - -import ( - "os/exec" - "strings" - "syscall" -) - -func getOSVersion() (string, string) { - cmd := exec.Command("sw_vers", "-productVersion") - - output, err := cmd.Output() - if err != nil { - return "", "" - } - - return strings.TrimSpace(string(output)), "" -} - -func getFilesystemType(path string) (string, error) { - var stat syscall.Statfs_t - err := syscall.Statfs(path, &stat) - if err != nil { - return "", err - } - - // Convert the filesystem type name from [16]int8 to string - fsType := make([]byte, 0, 16) - for _, c := range stat.Fstypename { - if c == 0 { - break - } - fsType = append(fsType, byte(c)) - } - - return string(fsType), nil -} diff --git a/core/metrics/insights_default.go b/core/metrics/insights_default.go deleted file mode 100644 index 98c34565b..000000000 --- a/core/metrics/insights_default.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !linux && !windows && !darwin - -package metrics - -import "errors" - -func getOSVersion() (string, string) { return "", "" } - -func getFilesystemType(_ string) (string, error) { return "", errors.New("not implemented") } diff --git a/core/metrics/insights_internal_test.go b/core/metrics/insights_internal_test.go deleted file mode 100644 index 74c8ce236..000000000 --- a/core/metrics/insights_internal_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package metrics - -import ( - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("installedPackage", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir()) - }) - - It("returns empty when there's no .package file", func() { - Expect(installedPackage()).To(BeEmpty()) - }) - - It("reads the .package file from the data folder", func() { - writePackageFile("deb") - - Expect(installedPackage()).To(Equal("deb")) - }) - - It("trims surrounding whitespace, as the msi packager writes a trailing newline", func() { - writePackageFile("msi\n") - - Expect(installedPackage()).To(Equal("msi")) - }) - - It("ignores ND_PLATFORM", func() { - GinkgoT().Setenv("ND_PLATFORM", "zimaos") - - Expect(installedPackage()).To(BeEmpty()) - }) -}) - -var _ = Describe("hostingPlatform", func() { - BeforeEach(func() { - // Setenv registers the restore, then unset so an inherited value can't leak in - GinkgoT().Setenv("ND_PLATFORM", "") - Expect(os.Unsetenv("ND_PLATFORM")).To(Succeed()) - }) - - It("returns empty when ND_PLATFORM is not set", func() { - Expect(hostingPlatform()).To(BeEmpty()) - }) - - It("reads ND_PLATFORM", func() { - GinkgoT().Setenv("ND_PLATFORM", "zimaos") - - Expect(hostingPlatform()).To(Equal("zimaos")) - }) - - It("trims surrounding whitespace", func() { - GinkgoT().Setenv("ND_PLATFORM", " pikapods\n") - - Expect(hostingPlatform()).To(Equal("pikapods")) - }) -}) - -func writePackageFile(content string) { - GinkgoHelper() - path := filepath.Join(conf.Server.DataFolder.String(), ".package") - Expect(os.WriteFile(path, []byte(content), 0600)).To(Succeed()) -} diff --git a/core/metrics/insights_linux.go b/core/metrics/insights_linux.go deleted file mode 100644 index f37c945c1..000000000 --- a/core/metrics/insights_linux.go +++ /dev/null @@ -1,102 +0,0 @@ -package metrics - -import ( - "fmt" - "io" - "os" - "strings" - "syscall" -) - -func getOSVersion() (string, string) { - file, err := os.Open("/etc/os-release") - if err != nil { - return "", "" - } - defer file.Close() - - osRelease, err := io.ReadAll(file) - if err != nil { - return "", "" - } - - lines := strings.Split(string(osRelease), "\n") - version := "" - distro := "" - for _, line := range lines { - if strings.HasPrefix(line, "VERSION_ID=") { - version = strings.ReplaceAll(strings.TrimPrefix(line, "VERSION_ID="), "\"", "") - } - if strings.HasPrefix(line, "ID=") { - distro = strings.ReplaceAll(strings.TrimPrefix(line, "ID="), "\"", "") - } - } - return version, distro -} - -// MountInfo represents an entry from /proc/self/mountinfo -type MountInfo struct { - MountPoint string - FSType string -} - -var fsTypeMap = map[int64]string{ - 0x5346414f: "afs", - 0x187: "autofs", - 0x61756673: "aufs", - 0x9123683E: "btrfs", - 0xc36400: "ceph", - 0xff534d42: "cifs", - 0x28cd3d45: "cramfs", - 0x64626720: "debugfs", - 0xf15f: "ecryptfs", - 0x2011bab0: "exfat", - 0x0000EF53: "ext2/ext3/ext4", - 0xf2f52010: "f2fs", - 0x6a656a63: "fakeowner", // FS inside a container - 0x65735546: "fuse", - 0x4244: "hfs", - 0x482b: "hfs+", - 0x9660: "iso9660", - 0x3153464a: "jfs", - 0x00006969: "nfs", - 0x5346544e: "ntfs", // NTFS_SB_MAGIC - 0x7366746e: "ntfs", - 0x794c7630: "overlayfs", - 0x9fa0: "proc", - 0x517b: "smb", - 0xfe534d42: "smb2", - 0x73717368: "squashfs", - 0x62656572: "sysfs", - 0x01021994: "tmpfs", - 0x01021997: "v9fs", - 0x786f4256: "vboxsf", - 0x4d44: "vfat", - 0xca451a4e: "virtiofs", - 0x58465342: "xfs", - 0x2FC12FC1: "zfs", - 0x7c7c6673: "prlfs", // Parallels Shared Folders - - // Signed/unsigned conversion issues (negative hex values converted to uint32) - -0x6edc97c2: "btrfs", // 0x9123683e - -0x1acb2be: "smb2", // 0xfe534d42 - -0xacb2be: "cifs", // 0xff534d42 - -0xd0adff0: "f2fs", // 0xf2f52010 -} - -func getFilesystemType(path string) (string, error) { - var fsStat syscall.Statfs_t - err := syscall.Statfs(path, &fsStat) - if err != nil { - return "", err - } - - fsType := fsStat.Type - - fsName, exists := fsTypeMap[int64(fsType)] //nolint:unconvert - if !exists { - fsName = fmt.Sprintf("unknown(0x%x)", fsType) - } - - return fsName, nil -} diff --git a/core/metrics/insights_windows.go b/core/metrics/insights_windows.go deleted file mode 100644 index aad1670d8..000000000 --- a/core/metrics/insights_windows.go +++ /dev/null @@ -1,53 +0,0 @@ -package metrics - -import ( - "os/exec" - "regexp" - - "golang.org/x/sys/windows" -) - -// Ex: Microsoft Windows [Version 10.0.26100.1742] -var winVerRegex = regexp.MustCompile(`Microsoft Windows \[.+\s([\d\.]+)\]`) - -func getOSVersion() (version string, _ string) { - cmd := exec.Command("cmd", "/c", "ver") - - output, err := cmd.Output() - if err != nil { - return "", "" - } - - matches := winVerRegex.FindStringSubmatch(string(output)) - if len(matches) != 2 { - return string(output), "" - } - return matches[1], "" -} - -func getFilesystemType(path string) (string, error) { - pathPtr, err := windows.UTF16PtrFromString(path) - if err != nil { - return "", err - } - - var volumeName, filesystemName [windows.MAX_PATH + 1]uint16 - var serialNumber uint32 - var maxComponentLen, filesystemFlags uint32 - - err = windows.GetVolumeInformation( - pathPtr, - &volumeName[0], - windows.MAX_PATH, - &serialNumber, - &maxComponentLen, - &filesystemFlags, - &filesystemName[0], - windows.MAX_PATH) - - if err != nil { - return "", err - } - - return windows.UTF16ToString(filesystemName[:]), nil -} diff --git a/core/metrics/metrics_suite_test.go b/core/metrics/metrics_suite_test.go deleted file mode 100644 index bae622e90..000000000 --- a/core/metrics/metrics_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package metrics - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestMetrics(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Metrics Suite") -} diff --git a/core/metrics/prometheus.go b/core/metrics/prometheus.go deleted file mode 100644 index c85706560..000000000 --- a/core/metrics/prometheus.go +++ /dev/null @@ -1,240 +0,0 @@ -package metrics - -import ( - "context" - "net/http" - "strconv" - "sync" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/singleton" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" -) - -type Metrics interface { - WriteInitialMetrics(ctx context.Context) - WriteAfterScanMetrics(ctx context.Context, success bool) - RecordRequest(ctx context.Context, endpoint, method, client string, status int32, elapsed int64) - RecordPluginRequest(ctx context.Context, plugin, method string, ok bool, elapsed int64) - GetHandler() http.Handler -} - -type metrics struct { - ds model.DataStore -} - -func GetPrometheusInstance(ds model.DataStore) Metrics { - if !conf.Server.Prometheus.Enabled { - return noopMetrics{} - } - - return singleton.GetInstance(func() *metrics { - return &metrics{ds: ds} - }) -} - -func NewNoopInstance() Metrics { - return noopMetrics{} -} - -func (m *metrics) WriteInitialMetrics(ctx context.Context) { - getPrometheusMetrics().versionInfo.With(prometheus.Labels{"version": consts.Version}).Set(1) - processSqlAggregateMetrics(ctx, m.ds, getPrometheusMetrics().dbTotal) -} - -func (m *metrics) WriteAfterScanMetrics(ctx context.Context, success bool) { - processSqlAggregateMetrics(ctx, m.ds, getPrometheusMetrics().dbTotal) - - scanLabels := prometheus.Labels{"success": strconv.FormatBool(success)} - getPrometheusMetrics().lastMediaScan.With(scanLabels).SetToCurrentTime() - getPrometheusMetrics().mediaScansCounter.With(scanLabels).Inc() -} - -func (m *metrics) RecordRequest(_ context.Context, endpoint, method, client string, status int32, elapsed int64) { - httpLabel := prometheus.Labels{ - "endpoint": endpoint, - "method": method, - "client": client, - "status": strconv.FormatInt(int64(status), 10), - } - getPrometheusMetrics().httpRequestCounter.With(httpLabel).Inc() - - httpLatencyLabel := prometheus.Labels{ - "endpoint": endpoint, - "method": method, - "client": client, - } - getPrometheusMetrics().httpRequestDuration.With(httpLatencyLabel).Observe(float64(elapsed)) -} - -func (m *metrics) RecordPluginRequest(_ context.Context, plugin, method string, ok bool, elapsed int64) { - pluginLabel := prometheus.Labels{ - "plugin": plugin, - "method": method, - "ok": strconv.FormatBool(ok), - } - getPrometheusMetrics().pluginRequestCounter.With(pluginLabel).Inc() - - pluginLatencyLabel := prometheus.Labels{ - "plugin": plugin, - "method": method, - } - getPrometheusMetrics().pluginRequestDuration.With(pluginLatencyLabel).Observe(float64(elapsed)) -} - -func (m *metrics) GetHandler() http.Handler { - r := chi.NewRouter() - - if conf.Server.Prometheus.Password != "" { - r.Use(middleware.BasicAuth("metrics", map[string]string{ - consts.PrometheusAuthUser: conf.Server.Prometheus.Password, - })) - } - - // Enable created at timestamp to handle zero counter on create. - // This requires --enable-feature=created-timestamp-zero-ingestion to be passed in Prometheus - r.Handle("/", promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{ - EnableOpenMetrics: true, - EnableOpenMetricsTextCreatedSamples: true, - })) - return r -} - -type prometheusMetrics struct { - dbTotal *prometheus.GaugeVec - versionInfo *prometheus.GaugeVec - lastMediaScan *prometheus.GaugeVec - mediaScansCounter *prometheus.CounterVec - httpRequestCounter *prometheus.CounterVec - httpRequestDuration *prometheus.SummaryVec - pluginRequestCounter *prometheus.CounterVec - pluginRequestDuration *prometheus.SummaryVec -} - -// Prometheus' metrics requires initialization. But not more than once -var getPrometheusMetrics = sync.OnceValue(func() *prometheusMetrics { - quartilesToEstimate := map[float64]float64{0.5: 0.05, 0.75: 0.025, 0.9: 0.01, 0.99: 0.001} - - instance := &prometheusMetrics{ - dbTotal: prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "db_model_totals", - Help: "Total number of DB items per model", - }, - []string{"model"}, - ), - versionInfo: prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "navidrome_info", - Help: "Information about Navidrome version", - }, - []string{"version"}, - ), - lastMediaScan: prometheus.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "media_scan_last", - Help: "Last media scan timestamp by success", - }, - []string{"success"}, - ), - mediaScansCounter: prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "media_scans", - Help: "Total success media scans by success", - }, - []string{"success"}, - ), - httpRequestCounter: prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "http_request_count", - Help: "Request types by status", - }, - []string{"endpoint", "method", "client", "status"}, - ), - httpRequestDuration: prometheus.NewSummaryVec( - prometheus.SummaryOpts{ - Name: "http_request_latency", - Help: "Latency (in ms) of HTTP requests", - Objectives: quartilesToEstimate, - }, - []string{"endpoint", "method", "client"}, - ), - pluginRequestCounter: prometheus.NewCounterVec( - prometheus.CounterOpts{ - Name: "plugin_request_count", - Help: "Plugin requests by method/status", - }, - []string{"plugin", "method", "ok"}, - ), - pluginRequestDuration: prometheus.NewSummaryVec( - prometheus.SummaryOpts{ - Name: "plugin_request_latency", - Help: "Latency (in ms) of plugin requests", - Objectives: quartilesToEstimate, - }, - []string{"plugin", "method"}, - ), - } - - prometheus.DefaultRegisterer.MustRegister( - instance.dbTotal, - instance.versionInfo, - instance.lastMediaScan, - instance.mediaScansCounter, - instance.httpRequestCounter, - instance.httpRequestDuration, - instance.pluginRequestCounter, - instance.pluginRequestDuration, - ) - - return instance -}) - -func processSqlAggregateMetrics(ctx context.Context, ds model.DataStore, targetGauge *prometheus.GaugeVec) { - albumsCount, err := ds.Album().CountAll(ctx) - if err != nil { - log.Warn("album CountAll error", err) - return - } - targetGauge.With(prometheus.Labels{"model": "album"}).Set(float64(albumsCount)) - - artistCount, err := ds.Artist().CountAll(ctx) - if err != nil { - log.Warn("artist CountAll error", err) - return - } - targetGauge.With(prometheus.Labels{"model": "artist"}).Set(float64(artistCount)) - - songsCount, err := ds.MediaFile().CountAll(ctx) - if err != nil { - log.Warn("media CountAll error", err) - return - } - targetGauge.With(prometheus.Labels{"model": "media"}).Set(float64(songsCount)) - - usersCount, err := ds.User().CountAll(ctx) - if err != nil { - log.Warn("user CountAll error", err) - return - } - targetGauge.With(prometheus.Labels{"model": "user"}).Set(float64(usersCount)) -} - -type noopMetrics struct { -} - -func (n noopMetrics) WriteInitialMetrics(context.Context) {} - -func (n noopMetrics) WriteAfterScanMetrics(context.Context, bool) {} - -func (n noopMetrics) RecordRequest(context.Context, string, string, string, int32, int64) {} - -func (n noopMetrics) RecordPluginRequest(context.Context, string, string, bool, int64) {} - -func (n noopMetrics) GetHandler() http.Handler { return nil } diff --git a/core/playback/device.go b/core/playback/device.go deleted file mode 100644 index 8e4e8880e..000000000 --- a/core/playback/device.go +++ /dev/null @@ -1,299 +0,0 @@ -package playback - -import ( - "context" - "errors" - "fmt" - "sync" - - "github.com/navidrome/navidrome/core/playback/mpv" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -type Track interface { - IsPlaying() bool - SetVolume(value float32) // Used to control the playback volume. A float value between 0.0 and 1.0. - Pause() - Unpause() - Position() int - SetPosition(offset int) error - Close() - String() string -} - -type playbackDevice struct { - serviceCtx context.Context //nolint:containedctx // playback service lifecycle ctx - ParentPlaybackServer PlaybackServer - Default bool - User string - Name string - DeviceName string - PlaybackQueue *Queue - Gain float32 - PlaybackDone chan bool - ActiveTrack Track - startTrackSwitcher sync.Once -} - -type DeviceStatus struct { - CurrentIndex int - Playing bool - Gain float32 - Position int -} - -const DefaultGain float32 = 1.0 - -func (pd *playbackDevice) getStatus() DeviceStatus { - pos := 0 - if pd.ActiveTrack != nil { - pos = pd.ActiveTrack.Position() - } - return DeviceStatus{ - CurrentIndex: pd.PlaybackQueue.Index, - Playing: pd.isPlaying(), - Gain: pd.Gain, - Position: pos, - } -} - -// NewPlaybackDevice creates a new playback device which implements all the basic Jukebox mode commands defined here: -// http://www.subsonic.org/pages/api.jsp#jukeboxControl -// Starts the trackSwitcher goroutine for the device. -func NewPlaybackDevice(ctx context.Context, playbackServer PlaybackServer, name string, deviceName string) *playbackDevice { - return &playbackDevice{ - serviceCtx: ctx, - ParentPlaybackServer: playbackServer, - User: "", - Name: name, - DeviceName: deviceName, - Gain: DefaultGain, - PlaybackQueue: NewQueue(), - PlaybackDone: make(chan bool), - } -} - -func (pd *playbackDevice) String() string { - return fmt.Sprintf("Name: %s, Gain: %.4f, Loaded track: %s", pd.Name, pd.Gain, pd.ActiveTrack) -} - -func (pd *playbackDevice) Get(ctx context.Context) (model.MediaFiles, DeviceStatus, error) { - log.Debug(ctx, "Processing Get action", "device", pd) - return pd.PlaybackQueue.Get(), pd.getStatus(), nil -} - -func (pd *playbackDevice) Status(ctx context.Context) (DeviceStatus, error) { - log.Debug(ctx, fmt.Sprintf("processing Status action on: %s, queue: %s", pd, pd.PlaybackQueue)) - return pd.getStatus(), nil -} - -// Set is similar to a clear followed by a add, but will not change the currently playing track. -func (pd *playbackDevice) Set(ctx context.Context, ids []string) (DeviceStatus, error) { - log.Debug(ctx, "Processing Set action", "ids", ids, "device", pd) - - _, err := pd.Clear(ctx) - if err != nil { - log.Error(ctx, "error setting tracks", ids) - return pd.getStatus(), err - } - return pd.Add(ctx, ids) -} - -func (pd *playbackDevice) Start(ctx context.Context) (DeviceStatus, error) { - log.Debug(ctx, "Processing Start action", "device", pd) - - pd.startTrackSwitcher.Do(func() { - log.Info(ctx, "Starting trackSwitcher goroutine") - // Start one trackSwitcher goroutine with each device - go func() { - pd.trackSwitcherGoroutine() - }() - }) - - if pd.ActiveTrack != nil { - if pd.isPlaying() { - log.Debug("trying to start an already playing track") - } else { - pd.ActiveTrack.Unpause() - } - } else { - if !pd.PlaybackQueue.IsEmpty() { - err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index) - if err != nil { - return pd.getStatus(), err - } - pd.ActiveTrack.Unpause() - } - } - - return pd.getStatus(), nil -} - -func (pd *playbackDevice) Stop(ctx context.Context) (DeviceStatus, error) { - log.Debug(ctx, "Processing Stop action", "device", pd) - if pd.ActiveTrack != nil { - pd.ActiveTrack.Pause() - } - return pd.getStatus(), nil -} - -func (pd *playbackDevice) Skip(ctx context.Context, index int, offset int) (DeviceStatus, error) { - log.Debug(ctx, "Processing Skip action", "index", index, "offset", offset, "device", pd) - - wasPlaying := pd.isPlaying() - - if pd.ActiveTrack != nil && wasPlaying { - pd.ActiveTrack.Pause() - } - - if index != pd.PlaybackQueue.Index && pd.ActiveTrack != nil { - pd.ActiveTrack.Close() - pd.ActiveTrack = nil - } - - if pd.ActiveTrack == nil { - err := pd.switchActiveTrackByIndex(index) - if err != nil { - return pd.getStatus(), err - } - } - - err := pd.ActiveTrack.SetPosition(offset) - if err != nil { - log.Error(ctx, "error setting position", err) - return pd.getStatus(), err - } - - if wasPlaying { - _, err = pd.Start(ctx) - if err != nil { - log.Error(ctx, "error starting new track after skipping") - return pd.getStatus(), err - } - } - - return pd.getStatus(), nil -} - -func (pd *playbackDevice) Add(ctx context.Context, ids []string) (DeviceStatus, error) { - log.Debug(ctx, "Processing Add action", "ids", ids, "device", pd) - if len(ids) < 1 { - return pd.getStatus(), nil - } - - items := model.MediaFiles{} - - for _, id := range ids { - mf, err := pd.ParentPlaybackServer.GetMediaFile(id) - if err != nil { - return DeviceStatus{}, err - } - log.Debug(ctx, "Found mediafile: "+mf.Path) - items = append(items, *mf) - } - pd.PlaybackQueue.Add(items) - - return pd.getStatus(), nil -} - -func (pd *playbackDevice) Clear(ctx context.Context) (DeviceStatus, error) { - log.Debug(ctx, "Processing Clear action", "device", pd) - if pd.ActiveTrack != nil { - pd.ActiveTrack.Pause() - pd.ActiveTrack.Close() - pd.ActiveTrack = nil - } - pd.PlaybackQueue.Clear() - return pd.getStatus(), nil -} - -func (pd *playbackDevice) Remove(ctx context.Context, index int) (DeviceStatus, error) { - log.Debug(ctx, "Processing Remove action", "index", index, "device", pd) - // pausing if attempting to remove running track - if pd.isPlaying() && pd.PlaybackQueue.Index == index { - _, err := pd.Stop(ctx) - if err != nil { - log.Error(ctx, "error stopping running track") - return pd.getStatus(), err - } - } - - if index > -1 && index < pd.PlaybackQueue.Size() { - pd.PlaybackQueue.Remove(index) - } else { - log.Error(ctx, "Index to remove out of range: "+fmt.Sprint(index)) - } - return pd.getStatus(), nil -} - -func (pd *playbackDevice) Shuffle(ctx context.Context) (DeviceStatus, error) { - log.Debug(ctx, "Processing Shuffle action", "device", pd) - if pd.PlaybackQueue.Size() > 1 { - pd.PlaybackQueue.Shuffle() - } - return pd.getStatus(), nil -} - -// SetGain is used to control the playback volume. A float value between 0.0 and 1.0. -func (pd *playbackDevice) SetGain(ctx context.Context, gain float32) (DeviceStatus, error) { - log.Debug(ctx, "Processing SetGain action", "newGain", gain, "device", pd) - - if pd.ActiveTrack != nil { - pd.ActiveTrack.SetVolume(gain) - } - pd.Gain = gain - - return pd.getStatus(), nil -} - -func (pd *playbackDevice) isPlaying() bool { - return pd.ActiveTrack != nil && pd.ActiveTrack.IsPlaying() -} - -func (pd *playbackDevice) trackSwitcherGoroutine() { - log.Debug("Started trackSwitcher goroutine", "device", pd) - for { - select { - case <-pd.PlaybackDone: - log.Debug("Track switching detected") - if pd.ActiveTrack != nil { - pd.ActiveTrack.Close() - pd.ActiveTrack = nil - } - - if !pd.PlaybackQueue.IsAtLastElement() { - pd.PlaybackQueue.IncreaseIndex() - log.Debug("Switching to next song", "queue", pd.PlaybackQueue.String()) - err := pd.switchActiveTrackByIndex(pd.PlaybackQueue.Index) - if err != nil { - log.Error("Error switching track", err) - } - if pd.ActiveTrack != nil { - pd.ActiveTrack.Unpause() - } - } else { - log.Debug("There is no song left in the playlist. Finish.") - } - case <-pd.serviceCtx.Done(): - log.Debug("Stopping trackSwitcher goroutine", "device", pd.Name) - return - } - } -} - -func (pd *playbackDevice) switchActiveTrackByIndex(index int) error { - pd.PlaybackQueue.SetIndex(index) - currentTrack := pd.PlaybackQueue.Current() - if currentTrack == nil { - return errors.New("could not get current track") - } - - track, err := mpv.NewTrack(pd.serviceCtx, pd.PlaybackDone, pd.DeviceName, *currentTrack) - if err != nil { - return err - } - pd.ActiveTrack = track - pd.ActiveTrack.SetVolume(pd.Gain) - return nil -} diff --git a/core/playback/mpv/mpv.go b/core/playback/mpv/mpv.go deleted file mode 100644 index 6696eca2a..000000000 --- a/core/playback/mpv/mpv.go +++ /dev/null @@ -1,130 +0,0 @@ -package mpv - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "os/exec" - "strings" - "sync" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/shellquote" -) - -func start(ctx context.Context, args []string) (Executor, error) { - if len(args) == 0 { - return Executor{}, fmt.Errorf("no command arguments provided") - } - log.Debug("Executing mpv command", "cmd", args) - j := Executor{args: args} - j.PipeReader, j.out = io.Pipe() - err := j.start(ctx) - if err != nil { - return Executor{}, err - } - go j.wait() - return j, nil -} - -func (j *Executor) Cancel() error { - if j.cmd != nil { - return j.cmd.Cancel() - } - return fmt.Errorf("there is non command to cancel") -} - -type Executor struct { - *io.PipeReader - out *io.PipeWriter - args []string - cmd *exec.Cmd -} - -func (j *Executor) start(ctx context.Context) error { - cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec - cmd.Stdout = j.out - if log.IsGreaterOrEqualTo(log.LevelTrace) { - cmd.Stderr = os.Stderr - } else { - cmd.Stderr = io.Discard - } - j.cmd = cmd - - if err := cmd.Start(); err != nil { - return fmt.Errorf("starting cmd: %w", err) - } - return nil -} - -func (j *Executor) wait() { - if err := j.cmd.Wait(); err != nil { - if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { - _ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())) - } else { - _ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err)) - } - return - } - _ = j.out.Close() -} - -// Path will always be an absolute path -func createMPVCommand(deviceName string, filename string, socketName string) []string { - // Parse the template structure using shell parsing to handle quoted arguments - templateArgs, err := shellquote.Split(conf.Server.MPVCmdTemplate) - if err != nil { - log.Error("Failed to parse MPV command template", "template", conf.Server.MPVCmdTemplate, err) - return nil - } - - // Replace placeholders in each parsed argument to preserve spaces in substituted values - for i, arg := range templateArgs { - arg = strings.ReplaceAll(arg, "%d", deviceName) - arg = strings.ReplaceAll(arg, "%f", filename) - arg = strings.ReplaceAll(arg, "%s", socketName) - templateArgs[i] = arg - } - - // Replace mpv executable references with the configured path - if len(templateArgs) > 0 { - cmdPath, err := mpvCommand() - if err == nil { - if templateArgs[0] == "mpv" || templateArgs[0] == "mpv.exe" { - templateArgs[0] = cmdPath - } - } - } - - return templateArgs -} - -// This is a 1:1 copy of the stuff in ffmpeg.go, need to be unified. -func mpvCommand() (string, error) { - mpvOnce.Do(func() { - if conf.Server.MPVPath != "" { - mpvPath = conf.Server.MPVPath - mpvPath, mpvErr = exec.LookPath(mpvPath) - } else { - mpvPath, mpvErr = exec.LookPath("mpv") - if errors.Is(mpvErr, exec.ErrDot) { - log.Trace("mpv found in current folder '.'") - mpvPath, mpvErr = exec.LookPath("./mpv") - } - } - if mpvErr == nil { - log.Info("Found mpv", "path", mpvPath) - return - } - }) - return mpvPath, mpvErr -} - -var ( - mpvOnce sync.Once - mpvPath string - mpvErr error -) diff --git a/core/playback/mpv/mpv_test.go b/core/playback/mpv/mpv_test.go deleted file mode 100644 index 6754b39ac..000000000 --- a/core/playback/mpv/mpv_test.go +++ /dev/null @@ -1,394 +0,0 @@ -package mpv - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - "runtime" - "strings" - "sync" - "time" - - "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("MPV", func() { - var ( - testScript string - tempDir string - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - - // Reset MPV cache - mpvOnce = sync.Once{} - mpvPath = "" - mpvErr = nil - - // Create temporary directory for test files - var err error - tempDir, err = os.MkdirTemp("", "mpv_test_*") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { os.RemoveAll(tempDir) }) - - // Create mock MPV script that outputs arguments to stdout - testScript = createMockMPVScript(tempDir) - - // Configure test MPV path - conf.Server.MPVPath = testScript - }) - - Describe("createMPVCommand", func() { - Context("with default template", func() { - BeforeEach(func() { - conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s" - }) - - It("creates correct command with simple paths", func() { - args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket") - Expect(args).To(Equal([]string{ - testScript, - "--audio-device=auto", - "--no-audio-display", - "--pause", - "/music/test.mp3", - "--input-ipc-server=/tmp/socket", - })) - }) - - It("handles paths with spaces", func() { - args := createMPVCommand("auto", "/music/My Album/01 - Song.mp3", "/tmp/socket") - Expect(args).To(Equal([]string{ - testScript, - "--audio-device=auto", - "--no-audio-display", - "--pause", - "/music/My Album/01 - Song.mp3", - "--input-ipc-server=/tmp/socket", - })) - }) - - It("handles complex device names", func() { - deviceName := "coreaudio/AppleUSBAudioEngine:Cambridge Audio :Cambridge Audio USB Audio 1.0:0000:1" - args := createMPVCommand(deviceName, "/music/test.mp3", "/tmp/socket") - Expect(args).To(Equal([]string{ - testScript, - "--audio-device=" + deviceName, - "--no-audio-display", - "--pause", - "/music/test.mp3", - "--input-ipc-server=/tmp/socket", - })) - }) - }) - - Context("with snapcast template (issue #3619)", func() { - BeforeEach(func() { - // This is the template that fails with naive space splitting - conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo" - }) - - It("creates correct command for snapcast integration", func() { - args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket") - Expect(args).To(Equal([]string{ - testScript, - "--no-audio-display", - "--pause", - "/music/test.mp3", - "--input-ipc-server=/tmp/socket", - "--audio-channels=stereo", - "--audio-samplerate=48000", - "--audio-format=s16", - "--ao=pcm", - "--ao-pcm-file=/audio/snapcast_fifo", - })) - }) - }) - - Context("with wrapper script template", func() { - BeforeEach(func() { - // Test case that would break with naive splitting due to quoted arguments - conf.Server.MPVCmdTemplate = `/tmp/mpv.sh --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo` - }) - - It("handles wrapper script paths", func() { - args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket") - Expect(args).To(Equal([]string{ - "/tmp/mpv.sh", - "--no-audio-display", - "--pause", - "/music/test.mp3", - "--input-ipc-server=/tmp/socket", - "--audio-channels=stereo", - })) - }) - }) - - Context("with extra spaces in template", func() { - BeforeEach(func() { - conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s" - }) - - It("handles extra spaces correctly", func() { - args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket") - Expect(args).To(Equal([]string{ - testScript, - "--audio-device=auto", - "--no-audio-display", - "--pause", - "/music/test.mp3", - "--input-ipc-server=/tmp/socket", - })) - }) - }) - Context("with paths containing spaces in template arguments", func() { - BeforeEach(func() { - // Template with spaces in the path arguments themselves - conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --ao-pcm-file="/audio/my folder/snapcast_fifo" --input-ipc-server=%s` - }) - - It("handles spaces in quoted template argument paths", func() { - args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket") - // This test reveals the limitation of strings.Fields() - it will split on all spaces - // Expected behavior would be to keep the path as one argument - Expect(args).To(Equal([]string{ - testScript, - "--no-audio-display", - "--pause", - "/music/test.mp3", - "--ao-pcm-file=/audio/my folder/snapcast_fifo", // This should be one argument - "--input-ipc-server=/tmp/socket", - })) - }) - }) - - Context("with malformed template", func() { - BeforeEach(func() { - // Template with unmatched quotes that will cause shell parsing to fail - conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote` - }) - - It("returns nil when shell parsing fails", func() { - args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket") - Expect(args).To(BeNil()) - }) - }) - - Context("with empty template", func() { - BeforeEach(func() { - conf.Server.MPVCmdTemplate = "" - }) - - It("returns empty slice for empty template", func() { - args := createMPVCommand("auto", "/music/test.mp3", "/tmp/socket") - Expect(args).To(BeEmpty()) - }) - }) - }) - - Describe("start", func() { - BeforeEach(func() { - conf.Server.MPVCmdTemplate = "mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s" - }) - - 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() - - deviceName := "auto" - filename := "/music/test.mp3" - socketName := "/tmp/test_socket" - - args := createMPVCommand(deviceName, filename, socketName) - executor, err := start(ctx, args) - Expect(err).ToNot(HaveOccurred()) - - // Read all the output from stdout (this will block until the process finishes or is canceled) - output, err := io.ReadAll(executor) - Expect(err).ToNot(HaveOccurred()) - - // Parse the captured arguments - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - Expect(lines).To(HaveLen(6)) - Expect(lines[0]).To(Equal(testScript)) - Expect(lines[1]).To(Equal("--audio-device=auto")) - Expect(lines[2]).To(Equal("--no-audio-display")) - Expect(lines[3]).To(Equal("--pause")) - Expect(lines[4]).To(Equal("/music/test.mp3")) - Expect(lines[5]).To(Equal("--input-ipc-server=/tmp/test_socket")) - }) - - 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() - - deviceName := "auto" - filename := "/music/My Album/01 - My Song.mp3" - socketName := "/tmp/test socket" - - args := createMPVCommand(deviceName, filename, socketName) - executor, err := start(ctx, args) - Expect(err).ToNot(HaveOccurred()) - - // Read all the output from stdout (this will block until the process finishes or is canceled) - output, err := io.ReadAll(executor) - Expect(err).ToNot(HaveOccurred()) - - // Parse the captured arguments - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - Expect(lines).To(ContainElement("/music/My Album/01 - My Song.mp3")) - Expect(lines).To(ContainElement("--input-ipc-server=/tmp/test socket")) - }) - - Context("with complex snapcast configuration", func() { - BeforeEach(func() { - conf.Server.MPVCmdTemplate = "mpv --no-audio-display --pause %f --input-ipc-server=%s --audio-channels=stereo --audio-samplerate=48000 --audio-format=s16 --ao=pcm --ao-pcm-file=/audio/snapcast_fifo" - }) - - 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() - - deviceName := "auto" - filename := "/music/album/track.flac" - socketName := "/tmp/mpv-ctrl-test.socket" - - args := createMPVCommand(deviceName, filename, socketName) - executor, err := start(ctx, args) - Expect(err).ToNot(HaveOccurred()) - - // Read all the output from stdout (this will block until the process finishes or is canceled) - output, err := io.ReadAll(executor) - Expect(err).ToNot(HaveOccurred()) - - // Parse the captured arguments - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - - // Verify all expected arguments are present - Expect(lines).To(ContainElement("--no-audio-display")) - Expect(lines).To(ContainElement("--pause")) - Expect(lines).To(ContainElement("/music/album/track.flac")) - Expect(lines).To(ContainElement("--input-ipc-server=/tmp/mpv-ctrl-test.socket")) - Expect(lines).To(ContainElement("--audio-channels=stereo")) - Expect(lines).To(ContainElement("--audio-samplerate=48000")) - Expect(lines).To(ContainElement("--audio-format=s16")) - Expect(lines).To(ContainElement("--ao=pcm")) - Expect(lines).To(ContainElement("--ao-pcm-file=/audio/snapcast_fifo")) - }) - }) - - Context("with nil args", func() { - It("returns error when args is nil", func() { - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - - _, err := start(ctx, nil) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(Equal("no command arguments provided")) - }) - - It("returns error when args is empty", func() { - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - - _, err := start(ctx, []string{}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(Equal("no command arguments provided")) - }) - }) - }) - - Describe("mpvCommand", func() { - BeforeEach(func() { - // Reset the mpv command cache - mpvOnce = sync.Once{} - mpvPath = "" - mpvErr = nil - }) - - It("finds the configured MPV path", func() { - conf.Server.MPVPath = testScript - path, err := mpvCommand() - Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(testScript)) - }) - }) - - Describe("NewTrack integration", func() { - var testMediaFile model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.MPVPath = testScript - - // Create a test media file - testMediaFile = model.MediaFile{ - ID: "test-id", - Path: "/music/test.mp3", - } - }) - - Context("with malformed template", func() { - BeforeEach(func() { - // Template with unmatched quotes that will cause shell parsing to fail - conf.Server.MPVCmdTemplate = `mpv --no-audio-display --pause %f --input-ipc-server=%s --ao-pcm-file="/unclosed/quote` - }) - - It("returns error when createMPVCommand fails", func() { - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - - playbackDone := make(chan bool, 1) - _, err := NewTrack(ctx, playbackDone, "auto", testMediaFile) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(Equal("no mpv command arguments provided")) - }) - }) - }) -}) - -// createMockMPVScript creates a mock script that outputs arguments to stdout -func createMockMPVScript(tempDir string) string { - var scriptContent string - var scriptExt string - - if runtime.GOOS == "windows" { - scriptExt = ".bat" - scriptContent = `@echo off -echo %0 -:loop -if "%~1"=="" goto end -echo %~1 -shift -goto loop -:end -` - } else { - scriptExt = ".sh" - scriptContent = `#!/bin/sh -echo "$0" -for arg in "$@"; do - echo "$arg" -done -` - } - - scriptPath := filepath.Join(tempDir, "mock_mpv"+scriptExt) - err := os.WriteFile(scriptPath, []byte(scriptContent), 0755) // nolint:gosec - if err != nil { - panic(fmt.Sprintf("Failed to create mock script: %v", err)) - } - - return scriptPath -} diff --git a/core/playback/mpv/sockets.go b/core/playback/mpv/sockets.go deleted file mode 100644 index 5c91d94fc..000000000 --- a/core/playback/mpv/sockets.go +++ /dev/null @@ -1,22 +0,0 @@ -//go:build !windows - -package mpv - -import ( - "os" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils" -) - -func socketName(prefix, suffix string) string { - return utils.TempFileName(prefix, suffix) -} - -func removeSocket(socketName string) { - log.Debug("Removing socketfile", "socketfile", socketName) - err := os.Remove(socketName) - if err != nil { - log.Error("Error cleaning up socketfile", "socketfile", socketName, err) - } -} diff --git a/core/playback/mpv/sockets_win.go b/core/playback/mpv/sockets_win.go deleted file mode 100644 index a85e1e784..000000000 --- a/core/playback/mpv/sockets_win.go +++ /dev/null @@ -1,19 +0,0 @@ -//go:build windows - -package mpv - -import ( - "path/filepath" - - "github.com/navidrome/navidrome/model/id" -) - -func socketName(prefix, suffix string) string { - // Windows needs to use a named pipe for the socket - // see https://mpv.io/manual/master#using-mpv-from-other-programs-or-scripts - return filepath.Join(`\\.\pipe\mpvsocket`, prefix+id.NewRandom()+suffix) -} - -func removeSocket(string) { - // Windows automatically handles cleaning up named pipe -} diff --git a/core/playback/mpv/track.go b/core/playback/mpv/track.go deleted file mode 100644 index 1038b9190..000000000 --- a/core/playback/mpv/track.go +++ /dev/null @@ -1,223 +0,0 @@ -package mpv - -// Audio-playback using mpv media-server. See mpv.io -// https://github.com/dexterlb/mpvipc -// https://mpv.io/manual/master/#json-ipc -// https://mpv.io/manual/master/#properties - -import ( - "context" - "fmt" - "os" - "time" - - "github.com/dexterlb/mpvipc" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -type MpvTrack struct { - MediaFile model.MediaFile - PlaybackDone chan bool - Conn *mpvipc.Connection - IPCSocketName string - Exe *Executor - CloseCalled bool -} - -func NewTrack(ctx context.Context, playbackDoneChannel chan bool, deviceName string, mf model.MediaFile) (*MpvTrack, error) { - log.Debug("Loading track", "trackPath", mf.Path, "mediaType", mf.ContentType()) - - if _, err := mpvCommand(); err != nil { - return nil, err - } - - tmpSocketName := socketName("mpv-ctrl-", ".socket") - - args := createMPVCommand(deviceName, mf.AbsolutePath(), tmpSocketName) - if len(args) == 0 { - return nil, fmt.Errorf("no mpv command arguments provided") - } - exe, err := start(ctx, args) - if err != nil { - log.Error("Error starting mpv process", err) - return nil, err - } - - // wait for socket to show up - err = waitForSocket(tmpSocketName, 3*time.Second, 100*time.Millisecond) - if err != nil { - log.Error("Error or timeout waiting for control socket", "socketname", tmpSocketName, err) - return nil, err - } - - conn := mpvipc.NewConnection(tmpSocketName) - err = conn.Open() - - if err != nil { - log.Error("Error opening new connection", err) - return nil, err - } - - theTrack := &MpvTrack{MediaFile: mf, PlaybackDone: playbackDoneChannel, Conn: conn, IPCSocketName: tmpSocketName, Exe: &exe, CloseCalled: false} - - go func() { - conn.WaitUntilClosed() - log.Info("Hitting end-of-stream, signalling on channel") - if !theTrack.CloseCalled { - playbackDoneChannel <- true - } - }() - - return theTrack, nil -} - -func (t *MpvTrack) String() string { - return fmt.Sprintf("Name: %s, Socket: %s", t.MediaFile.Path, t.IPCSocketName) -} - -// Used to control the playback volume. A float value between 0.0 and 1.0. -func (t *MpvTrack) SetVolume(value float32) { - // mpv's volume as described in the --volume parameter: - // Set the startup volume. 0 means silence, 100 means no volume reduction or amplification. - // Negative values can be passed for compatibility, but are treated as 0. - log.Debug("Setting volume", "volume", value, "track", t) - vol := int(value * 100) - - err := t.Conn.Set("volume", vol) - if err != nil { - log.Error("Error setting volume", "volume", value, "track", t, err) - } -} - -func (t *MpvTrack) Unpause() { - log.Debug("Unpausing track", "track", t) - err := t.Conn.Set("pause", false) - if err != nil { - log.Error("Error unpausing track", "track", t, err) - } -} - -func (t *MpvTrack) Pause() { - log.Debug("Pausing track", "track", t) - err := t.Conn.Set("pause", true) - if err != nil { - log.Error("Error pausing track", "track", t, err) - } -} - -func (t *MpvTrack) Close() { - log.Debug("Closing resources", "track", t) - t.CloseCalled = true - // trying to shutdown mpv process using socket - if t.isSocketFilePresent() { - log.Debug("sending shutdown command") - _, err := t.Conn.Call("quit") - if err != nil { - log.Warn("Error sending quit command to mpv-ipc socket", err) - - if t.Exe != nil { - log.Debug("cancelling executor") - err = t.Exe.Cancel() - if err != nil { - log.Warn("Error canceling executor", err) - } - } - } - } - - if t.isSocketFilePresent() { - removeSocket(t.IPCSocketName) - } -} - -func (t *MpvTrack) isSocketFilePresent() bool { - if len(t.IPCSocketName) < 1 { - return false - } - - fileInfo, err := os.Stat(t.IPCSocketName) - return err == nil && fileInfo != nil && !fileInfo.IsDir() -} - -// Position returns the playback position in seconds. -// Every now and then the mpv IPC interface returns "mpv error: property unavailable" -// in this case we have to retry -func (t *MpvTrack) Position() int { - retryCount := 0 - for { - position, err := t.Conn.Get("time-pos") - if err != nil && err.Error() == "mpv error: property unavailable" { - retryCount += 1 - log.Debug("Got mpv error, retrying...", "retries", retryCount, err) - if retryCount > 5 { - return 0 - } - time.Sleep(time.Duration(retryCount) * time.Millisecond) - continue - } - - if err != nil { - log.Error("Error getting position in track", "track", t, err) - return 0 - } - - pos, ok := position.(float64) - if !ok { - log.Error("Could not cast position from mpv into float64", "position", position, "track", t) - return 0 - } else { - return int(pos) - } - } -} - -func (t *MpvTrack) SetPosition(offset int) error { - log.Debug("Setting position", "offset", offset, "track", t) - pos := t.Position() - if pos == offset { - log.Debug("No position difference, skipping operation", "track", t) - return nil - } - err := t.Conn.Set("time-pos", float64(offset)) - if err != nil { - log.Error("Could not set the position in track", "track", t, "offset", offset, err) - return err - } - return nil -} - -func (t *MpvTrack) IsPlaying() bool { - log.Debug("Checking if track is playing", "track", t) - pausing, err := t.Conn.Get("pause") - if err != nil { - log.Error("Problem getting paused status", "track", t, err) - return false - } - - pause, ok := pausing.(bool) - if !ok { - log.Error("Could not cast pausing to boolean", "track", t, "value", pausing) - return false - } - return !pause -} - -func waitForSocket(path string, timeout time.Duration, pause time.Duration) error { - start := time.Now() - end := start.Add(timeout) - var retries = 0 - - for { - fileInfo, err := os.Stat(path) - if err == nil && fileInfo != nil && !fileInfo.IsDir() { - log.Debug("Socket found", "retries", retries, "waitTime", time.Since(start)) - return nil - } - if time.Now().After(end) { - return fmt.Errorf("timeout reached: %s", timeout) - } - time.Sleep(pause) - retries += 1 - } -} diff --git a/core/playback/playback_suite_test.go b/core/playback/playback_suite_test.go deleted file mode 100644 index 8e1134f2b..000000000 --- a/core/playback/playback_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package playback - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestPlayback(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Playback Suite") -} diff --git a/core/playback/playbackserver.go b/core/playback/playbackserver.go deleted file mode 100644 index 48e3bcfaf..000000000 --- a/core/playback/playbackserver.go +++ /dev/null @@ -1,127 +0,0 @@ -// Package playback implements audio playback using PlaybackDevices. It is used to implement the Jukebox mode in turn. -// It makes use of the MPV library to do the playback. Major parts are: -// - decoder which includes decoding and transcoding of various audio file formats -// - device implementing the basic functions to work with audio devices like set, play, stop, skip, ... -// - queue a simple playlist -package playback - -import ( - "context" - "fmt" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/singleton" -) - -type PlaybackServer interface { - Run(ctx context.Context) error - GetDeviceForUser(user string) (*playbackDevice, error) - GetMediaFile(id string) (*model.MediaFile, error) -} - -type playbackServer struct { - ctx *context.Context - datastore model.DataStore - playbackDevices []playbackDevice -} - -// GetInstance returns the playback-server singleton -func GetInstance(ds model.DataStore) PlaybackServer { - return singleton.GetInstance(func() *playbackServer { - return &playbackServer{datastore: ds} - }) -} - -// Run starts the playback server which serves request until canceled using the given context -func (ps *playbackServer) Run(ctx context.Context) error { - ps.ctx = &ctx - - devices, err := ps.initDeviceStatus(ctx, conf.Server.Jukebox.Devices, conf.Server.Jukebox.Default) - if err != nil { - return err - } - ps.playbackDevices = devices - log.Info(ctx, fmt.Sprintf("%d audio devices found", len(devices))) - - defaultDevice, _ := ps.getDefaultDevice() - - log.Info(ctx, "Using audio device: "+defaultDevice.DeviceName) - - <-ctx.Done() - - // Should confirm all subprocess are terminated before returning - return nil -} - -func (ps *playbackServer) initDeviceStatus(ctx context.Context, devices []conf.AudioDeviceDefinition, defaultDevice string) ([]playbackDevice, error) { - pbDevices := make([]playbackDevice, max(1, len(devices))) - defaultDeviceFound := false - - if defaultDevice == "" { - // if there are no devices given and no default device, we create a synthetic device named "auto" - if len(devices) == 0 { - pbDevices[0] = *NewPlaybackDevice(ctx, ps, "auto", "auto") - } - - // if there is but only one entry and no default given, just use that. - if len(devices) == 1 { - if len(devices[0]) != 2 { - return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(devices[0])) - } - pbDevices[0] = *NewPlaybackDevice(ctx, ps, devices[0][0], devices[0][1]) - } - - if len(devices) > 1 { - return []playbackDevice{}, fmt.Errorf("number of audio device found is %d, but no default device defined. Set Jukebox.Default", len(devices)) - } - - pbDevices[0].Default = true - return pbDevices, nil - } - - for idx, audioDevice := range devices { - if len(audioDevice) != 2 { - return []playbackDevice{}, fmt.Errorf("audio device definition ought to contain 2 fields, found: %d ", len(audioDevice)) - } - - pbDevices[idx] = *NewPlaybackDevice(ctx, ps, audioDevice[0], audioDevice[1]) - - if audioDevice[0] == defaultDevice { - pbDevices[idx].Default = true - defaultDeviceFound = true - } - } - - if !defaultDeviceFound { - return []playbackDevice{}, fmt.Errorf("default device name not found: %s ", defaultDevice) - } - return pbDevices, nil -} - -func (ps *playbackServer) getDefaultDevice() (*playbackDevice, error) { - for idx := range ps.playbackDevices { - if ps.playbackDevices[idx].Default { - return &ps.playbackDevices[idx], nil - } - } - return nil, fmt.Errorf("no default device found") -} - -// GetMediaFile retrieves the MediaFile given by the id parameter -func (ps *playbackServer) GetMediaFile(id string) (*model.MediaFile, error) { - return ps.datastore.MediaFile().Get(*ps.ctx, id) -} - -// GetDeviceForUser returns the audio playback device for the given user. As of now this is but only the default device. -func (ps *playbackServer) GetDeviceForUser(user string) (*playbackDevice, error) { - log.Debug("Processing GetDevice", "user", user) - // README: here we might plug-in the user-device mapping one fine day - device, err := ps.getDefaultDevice() - if err != nil { - return nil, err - } - device.User = user - return device, nil -} diff --git a/core/playback/queue.go b/core/playback/queue.go deleted file mode 100644 index 116db5fe6..000000000 --- a/core/playback/queue.go +++ /dev/null @@ -1,138 +0,0 @@ -package playback - -import ( - "fmt" - "math/rand" - "strings" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -type Queue struct { - Index int - Items model.MediaFiles -} - -func NewQueue() *Queue { - return &Queue{ - Index: -1, - Items: model.MediaFiles{}, - } -} - -func (pd *Queue) String() string { - var filenames strings.Builder - for idx, item := range pd.Items { - filenames.WriteString(fmt.Sprint(idx) + ":" + item.Path + " ") - } - return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames.String()) -} - -// returns the current mediafile or nil -func (pd *Queue) Current() *model.MediaFile { - if pd.Index == -1 { - return nil - } - if pd.Index >= len(pd.Items) { - log.Error("internal error: current song index out of bounds", "idx", pd.Index, "length", len(pd.Items)) - return nil - } - - return &pd.Items[pd.Index] -} - -// returns the whole queue -func (pd *Queue) Get() model.MediaFiles { - return pd.Items -} - -func (pd *Queue) Size() int { - return len(pd.Items) -} - -func (pd *Queue) IsEmpty() bool { - return len(pd.Items) < 1 -} - -// set is similar to a clear followed by a add, but will not change the currently playing track. -func (pd *Queue) Set(items model.MediaFiles) { - pd.Clear() - pd.Items = append(pd.Items, items...) -} - -// adding mediafiles to the queue -func (pd *Queue) Add(items model.MediaFiles) { - pd.Items = append(pd.Items, items...) - if pd.Index == -1 && len(pd.Items) > 0 { - pd.Index = 0 - } -} - -// empties whole queue -func (pd *Queue) Clear() { - pd.Index = -1 - pd.Items = nil -} - -// idx Zero-based index of the song to skip to or remove. -func (pd *Queue) Remove(idx int) { - current := pd.Current() - backupID := "" - if current != nil { - backupID = current.ID - } - - pd.Items = append(pd.Items[:idx], pd.Items[idx+1:]...) - - var err error - pd.Index, err = pd.getMediaFileIndexByID(backupID) - if err != nil { - // we seem to have deleted the current id, setting to default: - pd.Index = -1 - } -} - -func (pd *Queue) Shuffle() { - current := pd.Current() - backupID := "" - if current != nil { - backupID = current.ID - } - - //nolint:gosec // shuffle order is not a security decision - rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] }) - - var err error - pd.Index, err = pd.getMediaFileIndexByID(backupID) - if err != nil { - log.Error("Could not find ID while shuffling: %s", backupID) - } -} - -func (pd *Queue) getMediaFileIndexByID(id string) (int, error) { - for idx, item := range pd.Items { - if item.ID == id { - return idx, nil - } - } - return -1, fmt.Errorf("ID not found in playlist: %s", id) -} - -// Sets the index to a new, valid value inside the Items. Values lower than zero are going to be zero, -// values above will be limited by number of items. -func (pd *Queue) SetIndex(idx int) { - pd.Index = max(0, min(idx, len(pd.Items)-1)) -} - -// Are we at the last track? -func (pd *Queue) IsAtLastElement() bool { - return (pd.Index + 1) >= len(pd.Items) -} - -// Goto next index -func (pd *Queue) IncreaseIndex() { - if !pd.IsAtLastElement() { - pd.SetIndex(pd.Index + 1) - } -} diff --git a/core/playback/queue_test.go b/core/playback/queue_test.go deleted file mode 100644 index 00df522f9..000000000 --- a/core/playback/queue_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package playback - -import ( - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Queues", func() { - var queue *Queue - - BeforeEach(func() { - queue = NewQueue() - }) - - Describe("use empty queue", func() { - It("is empty", func() { - Expect(queue.Items).To(BeEmpty()) - Expect(queue.Index).To(Equal(-1)) - }) - }) - - Describe("Operate on small queue", func() { - BeforeEach(func() { - mfs := model.MediaFiles{ - { - ID: "1", Artist: "Queen", Compilation: false, Path: "/music1/hammer.mp3", - }, - { - ID: "2", Artist: "Vinyard Rose", Compilation: false, Path: "/music1/cassidy.mp3", - }, - } - queue.Add(mfs) - }) - - It("contains the preloaded data", func() { - Expect(queue.Get).ToNot(BeNil()) - Expect(queue.Size()).To(Equal(2)) - }) - - It("could read data by ID", func() { - idx, err := queue.getMediaFileIndexByID("1") - Expect(err).ToNot(HaveOccurred()) - Expect(idx).ToNot(BeNil()) - Expect(idx).To(Equal(0)) - - queue.SetIndex(idx) - - mf := queue.Current() - - Expect(mf).ToNot(BeNil()) - Expect(mf.ID).To(Equal("1")) - Expect(mf.Artist).To(Equal("Queen")) - Expect(mf.Path).To(Equal("/music1/hammer.mp3")) - }) - }) - - Describe("Read/Write operations", func() { - BeforeEach(func() { - mfs := model.MediaFiles{ - { - ID: "1", Artist: "Queen", Compilation: false, Path: "/music1/hammer.mp3", - }, - { - ID: "2", Artist: "Vinyard Rose", Compilation: false, Path: "/music1/cassidy.mp3", - }, - { - ID: "3", Artist: "Pink Floyd", Compilation: false, Path: "/music1/time.mp3", - }, - { - ID: "4", Artist: "Mike Oldfield", Compilation: false, Path: "/music1/moonlight-shadow.mp3", - }, - { - ID: "5", Artist: "Red Hot Chili Peppers", Compilation: false, Path: "/music1/californication.mp3", - }, - } - queue.Add(mfs) - }) - - It("contains the preloaded data", func() { - Expect(queue.Get).ToNot(BeNil()) - Expect(queue.Size()).To(Equal(5)) - }) - - It("could read data by ID", func() { - idx, err := queue.getMediaFileIndexByID("5") - Expect(err).ToNot(HaveOccurred()) - Expect(idx).ToNot(BeNil()) - Expect(idx).To(Equal(4)) - - queue.SetIndex(idx) - - mf := queue.Current() - - Expect(mf).ToNot(BeNil()) - Expect(mf.ID).To(Equal("5")) - Expect(mf.Artist).To(Equal("Red Hot Chili Peppers")) - Expect(mf.Path).To(Equal("/music1/californication.mp3")) - }) - - It("could shuffle the data correctly", func() { - queue.Shuffle() - Expect(queue.Size()).To(Equal(5)) - }) - - It("could remove entries correctly", func() { - queue.Remove(0) - Expect(queue.Size()).To(Equal(4)) - - queue.Remove(3) - Expect(queue.Size()).To(Equal(3)) - }) - - It("clear the whole thing on request", func() { - Expect(queue.Size()).To(Equal(5)) - queue.Clear() - Expect(queue.Size()).To(Equal(0)) - }) - }) - -}) diff --git a/core/players.go b/core/players.go index 6fe86fd70..47fa30678 100644 --- a/core/players.go +++ b/core/players.go @@ -5,92 +5,64 @@ import ( "fmt" "time" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" + "github.com/google/uuid" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils" ) type Players interface { Get(ctx context.Context, playerId string) (*model.Player, error) - Register(ctx context.Context, id, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) - Touch(ctx context.Context, plr model.Player, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) + Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error) } func NewPlayers(ds model.DataStore) Players { - return &players{ - ds: ds, - limiter: utils.Limiter{Interval: consts.UpdatePlayerFrequency}, - } + return &players{ds} } type players struct { - ds model.DataStore - limiter utils.Limiter + ds model.DataStore } -func (p *players) Register(ctx context.Context, playerID, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) { +func (p *players) Register(ctx context.Context, id, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) { var plr *model.Player + var trc *model.Transcoding var err error - user, _ := request.UserFrom(ctx) - if playerID != "" { - plr, err = p.ds.Player().Get(ctx, playerID) - if err == nil && (plr.Client != client || plr.UserId != user.ID) { - playerID = "" + userName, _ := request.UsernameFrom(ctx) + if id != "" { + plr, err = p.ds.Player(ctx).Get(id) + if err == nil && plr.Client != client { + id = "" } } - username := userName(ctx) - if err != nil || playerID == "" { - plr, err = p.ds.Player().FindMatch(ctx, user.ID, client, userAgent) + if err != nil || id == "" { + plr, err = p.ds.Player(ctx).FindMatch(userName, client, userAgent) if err == nil { - log.Debug(ctx, "Found matching player", "id", plr.ID, "client", client, "username", username, "type", userAgent) + log.Debug(ctx, "Found matching player", "id", plr.ID, "client", client, "username", userName, "type", userAgent) } else { plr = &model.Player{ - ID: id.NewRandom(), - UserId: user.ID, + ID: uuid.NewString(), + UserName: userName, Client: client, ScrobbleEnabled: true, - ReportRealPath: conf.Server.Subsonic.DefaultReportRealPath, } - log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", username, "type", userAgent) + log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", userName, "type", userAgent) } } - if !plr.HasAPIKey { - plr.Name = fmt.Sprintf("%s [%s]", client, userAgent) - } - return p.refresh(ctx, plr, userAgent, ip) -} - -// Touch refreshes a player that the request already identified (by API key), without guessing or renaming it. -func (p *players) Touch(ctx context.Context, plr model.Player, client, userAgent, ip string) (*model.Player, *model.Transcoding, error) { - if plr.Client == "" { - plr.Client = client - } - return p.refresh(ctx, &plr, userAgent, ip) -} - -func (p *players) refresh(ctx context.Context, plr *model.Player, userAgent, ip string) (*model.Player, *model.Transcoding, error) { + plr.Name = fmt.Sprintf("%s [%s]", client, userAgent) plr.UserAgent = userAgent - plr.IP = ip + plr.IPAddress = ip plr.LastSeen = time.Now() - p.limiter.Do(plr.ID, func() { - ctx, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - - if err := p.ds.Player().Put(ctx, plr); err != nil { - log.Warn(ctx, "Could not save player", "id", plr.ID, "client", plr.Client, "username", userName(ctx), "type", plr.UserAgent, err) - } - }) - if plr.TranscodingId == "" { - return plr, nil, nil + err = p.ds.Player(ctx).Put(plr) + if err != nil { + return nil, nil, err + } + if plr.TranscodingId != "" { + trc, err = p.ds.Transcoding(ctx).Get(plr.TranscodingId) } - trc, err := p.ds.Transcoding().Get(ctx, plr.TranscodingId) return plr, trc, err } func (p *players) Get(ctx context.Context, playerId string) (*model.Player, error) { - return p.ds.Player().Get(ctx, playerId) + return p.ds.Player(ctx).Get(playerId) } diff --git a/core/players_test.go b/core/players_test.go index e452c52ba..22bac5584 100644 --- a/core/players_test.go +++ b/core/players_test.go @@ -34,7 +34,7 @@ var _ = Describe("Players", func() { Expect(p.ID).ToNot(BeEmpty()) Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister)) Expect(p.Client).To(Equal("client")) - Expect(p.UserId).To(Equal("userid")) + Expect(p.UserName).To(Equal("johndoe")) Expect(p.UserAgent).To(Equal("chrome")) Expect(repo.lastSaved).To(Equal(p)) Expect(trc).To(BeNil()) @@ -61,19 +61,8 @@ var _ = Describe("Players", func() { Expect(trc).To(BeNil()) }) - It("does not reuse another user's player by ID", func() { - plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "otheruser", UserAgent: "Pixel", TranscodingId: "1"} - repo.add(plr) - p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") - Expect(err).ToNot(HaveOccurred()) - Expect(p.ID).ToNot(Equal("123")) - Expect(p.UserId).To(Equal("userid")) - Expect(repo.lastSaved).To(Equal(p)) - Expect(trc).To(BeNil()) - }) - It("finds players by ID", func() { - plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}} + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}} repo.add(plr) p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") Expect(err).ToNot(HaveOccurred()) @@ -84,7 +73,7 @@ var _ = Describe("Players", func() { }) It("finds player by client and user names when ID is not found", func() { - plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", UserAgent: "chrome", LastSeen: time.Time{}} + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}} repo.add(plr) p, _, err := players.Register(ctx, "999", "client", "chrome", "1.2.3.4") Expect(err).ToNot(HaveOccurred()) @@ -94,7 +83,7 @@ var _ = Describe("Players", func() { }) It("finds player by client and user names when not ID is provided", func() { - plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", UserAgent: "chrome", LastSeen: time.Time{}} + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserName: "johndoe", LastSeen: time.Time{}} repo.add(plr) p, _, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4") Expect(err).ToNot(HaveOccurred()) @@ -104,7 +93,7 @@ var _ = Describe("Players", func() { }) It("finds player by ID and return its transcoding", func() { - plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", LastSeen: time.Time{}, TranscodingId: "1"} + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}, TranscodingId: "1"} repo.add(plr) p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") Expect(err).ToNot(HaveOccurred()) @@ -113,59 +102,6 @@ var _ = Describe("Players", func() { Expect(repo.lastSaved).To(Equal(p)) Expect(trc.ID).To(Equal("1")) }) - - It("does not rename a player that has an API key", func() { - plr := &model.Player{ID: "123", Name: "My Phone", Client: "client", UserId: "userid", HasAPIKey: true} - repo.add(plr) - p, _, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") - Expect(err).ToNot(HaveOccurred()) - Expect(p.ID).To(Equal("123")) - Expect(p.Name).To(Equal("My Phone")) - }) - - Context("bad username casing", func() { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "Johndoe"}) - ctx = request.WithUsername(ctx, "Johndoe") - - It("finds player by client and user names when not ID is provided", func() { - plr := &model.Player{ID: "123", Name: "A Player", Client: "client", UserId: "userid", UserAgent: "chrome", LastSeen: time.Time{}} - repo.add(plr) - p, _, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4") - Expect(err).ToNot(HaveOccurred()) - Expect(p.ID).To(Equal("123")) - Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister)) - Expect(repo.lastSaved).To(Equal(p)) - }) - }) - }) - - Describe("Touch", func() { - It("records usage but keeps the name and client", func() { - plr := model.Player{ID: "123", Name: "My Phone", Client: "Symfonium", UserId: "userid", HasAPIKey: true} - p, trc, err := players.Touch(ctx, plr, "OtherClient", "android", "1.2.3.4") - Expect(err).ToNot(HaveOccurred()) - Expect(p.Name).To(Equal("My Phone")) - Expect(p.Client).To(Equal("Symfonium")) - Expect(p.UserAgent).To(Equal("android")) - Expect(p.IP).To(Equal("1.2.3.4")) - Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister)) - Expect(repo.lastSaved).To(Equal(p)) - Expect(trc).To(BeNil()) - }) - - It("fills in the client on first use", func() { - p, _, err := players.Touch(ctx, model.Player{ID: "123", Name: "Manual", UserId: "userid"}, "Symfonium", "android", "1.2.3.4") - Expect(err).ToNot(HaveOccurred()) - Expect(p.Client).To(Equal("Symfonium")) - }) - - It("returns the player's transcoding", func() { - p, trc, err := players.Touch(ctx, model.Player{ID: "123", UserId: "userid", TranscodingId: "1"}, "c", "ua", "1.2.3.4") - Expect(err).ToNot(HaveOccurred()) - Expect(p.ID).To(Equal("123")) - Expect(trc.ID).To(Equal("1")) - }) }) }) @@ -182,23 +118,23 @@ func (m *mockPlayerRepository) add(p *model.Player) { m.data[p.ID] = *p } -func (m *mockPlayerRepository) Get(_ context.Context, id string) (*model.Player, error) { +func (m *mockPlayerRepository) Get(id string) (*model.Player, error) { if p, ok := m.data[id]; ok { return &p, nil } return nil, model.ErrNotFound } -func (m *mockPlayerRepository) FindMatch(_ context.Context, userId, client, userAgent string) (*model.Player, error) { +func (m *mockPlayerRepository) FindMatch(userName, client, typ string) (*model.Player, error) { for _, p := range m.data { - if p.Client == client && p.UserId == userId && p.UserAgent == userAgent { + if p.Client == client && p.UserName == userName { return &p, nil } } return nil, model.ErrNotFound } -func (m *mockPlayerRepository) Put(_ context.Context, p *model.Player) error { +func (m *mockPlayerRepository) Put(p *model.Player) error { m.lastSaved = p return nil } diff --git a/core/playlists.go b/core/playlists.go new file mode 100644 index 000000000..fd9433890 --- /dev/null +++ b/core/playlists.go @@ -0,0 +1,258 @@ +package core + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" +) + +type Playlists interface { + ImportFile(ctx context.Context, dir string, fname string) (*model.Playlist, error) + Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error +} + +type playlists struct { + ds model.DataStore +} + +func NewPlaylists(ds model.DataStore) Playlists { + return &playlists{ds: ds} +} + +func (s *playlists) ImportFile(ctx context.Context, dir string, fname string) (*model.Playlist, error) { + pls, err := s.parsePlaylist(ctx, fname, dir) + if err != nil { + log.Error(ctx, "Error parsing playlist", "path", filepath.Join(dir, fname), err) + return nil, err + } + log.Debug("Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks)) + err = s.updatePlaylist(ctx, pls) + if err != nil { + log.Error(ctx, "Error updating playlist", "path", filepath.Join(dir, fname), err) + } + return pls, err +} + +func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, baseDir string) (*model.Playlist, error) { + pls, err := s.newSyncedPlaylist(baseDir, playlistFile) + if err != nil { + return nil, err + } + + file, err := os.Open(pls.Path) + if err != nil { + return nil, err + } + defer file.Close() + + extension := strings.ToLower(filepath.Ext(playlistFile)) + switch extension { + case ".nsp": + return s.parseNSP(ctx, pls, file) + default: + return s.parseM3U(ctx, pls, baseDir, file) + } +} + +func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) { + playlistPath := filepath.Join(baseDir, playlistFile) + info, err := os.Stat(playlistPath) + if err != nil { + return nil, err + } + + var extension = filepath.Ext(playlistFile) + var name = playlistFile[0 : len(playlistFile)-len(extension)] + + pls := &model.Playlist{ + Name: name, + Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile), + Public: false, + Path: playlistPath, + Sync: true, + UpdatedAt: info.ModTime(), + } + return pls, nil +} + +func (s *playlists) parseNSP(ctx context.Context, pls *model.Playlist, file io.Reader) (*model.Playlist, error) { + nsp := &nspFile{} + dec := json.NewDecoder(file) + err := dec.Decode(nsp) + if err != nil { + log.Error(ctx, "Error parsing SmartPlaylist", "playlist", pls.Name, err) + return nil, err + } + pls.Rules = &nsp.Criteria + if nsp.Name != "" { + pls.Name = nsp.Name + } + if nsp.Comment != "" { + pls.Comment = nsp.Comment + } + return pls, nil +} + +func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir string, file io.Reader) (*model.Playlist, error) { + mediaFileRepository := s.ds.MediaFile(ctx) + scanner := bufio.NewScanner(file) + scanner.Split(scanLines) + var mfs model.MediaFiles + for scanner.Scan() { + path := strings.TrimSpace(scanner.Text()) + // Skip empty lines and extended info + if path == "" || strings.HasPrefix(path, "#") { + continue + } + if strings.HasPrefix(path, "file://") { + path = strings.TrimPrefix(path, "file://") + path, _ = url.QueryUnescape(path) + } + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, path) + } + mf, err := mediaFileRepository.FindByPath(path) + if err != nil { + log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path, err) + continue + } + mfs = append(mfs, *mf) + } + pls.Tracks = nil + pls.AddMediaFiles(mfs) + + return pls, scanner.Err() +} + +func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error { + owner, _ := request.UserFrom(ctx) + + pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path) + if err != nil && !errors.Is(err, model.ErrNotFound) { + return err + } + if err == nil && !pls.Sync { + log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path) + return nil + } + + if err == nil { + log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path) + newPls.ID = pls.ID + newPls.Name = pls.Name + newPls.Comment = pls.Comment + newPls.OwnerID = pls.OwnerID + newPls.Public = pls.Public + newPls.EvaluatedAt = time.Time{} + } else { + log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName) + newPls.OwnerID = owner.ID + } + return s.ds.Playlist(ctx).Put(newPls) +} + +// From https://stackoverflow.com/a/41433698 +func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := bytes.IndexAny(data, "\r\n"); i >= 0 { + if data[i] == '\n' { + // We have a line terminated by single newline. + return i + 1, data[0:i], nil + } + advance = i + 1 + if len(data) > i+1 && data[i+1] == '\n' { + advance += 1 + } + return advance, data[0:i], nil + } + // If we're at EOF, we have a final, non-terminated line. Return it. + if atEOF { + return len(data), data, nil + } + // Request more data. + return 0, nil, nil +} + +func (s *playlists) Update(ctx context.Context, playlistID string, + name *string, comment *string, public *bool, + idsToAdd []string, idxToRemove []int) error { + needsInfoUpdate := name != nil || comment != nil || public != nil + needsTrackRefresh := len(idxToRemove) > 0 + + return s.ds.WithTx(func(tx model.DataStore) error { + var pls *model.Playlist + var err error + repo := tx.Playlist(ctx) + if needsTrackRefresh { + pls, err = repo.GetWithTracks(playlistID, true) + pls.RemoveTracks(idxToRemove) + pls.AddTracks(idsToAdd) + } else { + if len(idsToAdd) > 0 { + _, err = repo.Tracks(playlistID, true).Add(idsToAdd) + if err != nil { + return err + } + } + if needsInfoUpdate { + pls, err = repo.Get(playlistID) + } + } + if err != nil { + return err + } + if !needsTrackRefresh && !needsInfoUpdate { + return nil + } + + if name != nil { + pls.Name = *name + } + if comment != nil { + pls.Comment = *comment + } + if public != nil { + pls.Public = *public + } + // Special case: The playlist is now empty + if len(idxToRemove) > 0 && len(pls.Tracks) == 0 { + if err = repo.Tracks(playlistID, true).DeleteAll(); err != nil { + return err + } + } + return repo.Put(pls) + }) +} + +type nspFile struct { + criteria.Criteria + Name string `json:"name"` + Comment string `json:"comment"` +} + +func (i *nspFile) UnmarshalJSON(data []byte) error { + m := map[string]interface{}{} + err := json.Unmarshal(data, &m) + if err != nil { + return err + } + i.Name, _ = m["name"].(string) + i.Comment, _ = m["comment"].(string) + return json.Unmarshal(data, &i.Criteria) +} diff --git a/core/playlists/import.go b/core/playlists/import.go deleted file mode 100644 index b5991b095..000000000 --- a/core/playlists/import.go +++ /dev/null @@ -1,225 +0,0 @@ -package playlists - -import ( - "context" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/ioutils" - "github.com/zeebo/xxh3" - "golang.org/x/text/unicode/norm" -) - -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().Put(ctx, 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() - - hasher := xxh3.New() - reader := io.TeeReader(ioutils.UTF8Reader(file), hasher) - if err := s.parseM3U(ctx, pls, nil, reader); err != nil { - return nil, err - } - pls.ImportedHash = fingerprint(hasher) - if err := s.updatePlaylist(ctx, pls, sync); err != nil { - return nil, err - } - 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().GetAll(ctx) - if err != nil { - return nil, err - } - matcher := model.NewLibraryMatcher(libs) - lib, ok := matcher.FindLibrary(dir) - if !ok { - return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir) - } - - folder, err := s.ds.Folder().GetByPath(ctx, 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, forceSync) - if err != nil { - log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err) - } - return pls, err -} - -func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) { - owner, _ := request.UserFrom(ctx) - pls := &model.Playlist{ - OwnerID: owner.ID, - Public: false, - Sync: false, - } - err := s.parseM3U(ctx, pls, nil, reader) - if err != nil { - log.Error(ctx, "Error parsing playlist", err) - return nil, err - } - err = s.ds.Playlist().Put(ctx, pls) - if err != nil { - log.Error(ctx, "Error saving playlist", err) - return nil, err - } - return pls, nil -} - -func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) { - pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile) - if err != nil { - return nil, err - } - - file, err := os.Open(pls.Path) - if err != nil { - return nil, err - } - defer file.Close() - - // Hash the bytes the parser consumes, giving every imported playlist a content fingerprint - hasher := xxh3.New() - reader := io.TeeReader(ioutils.UTF8Reader(file), hasher) - extension := strings.ToLower(filepath.Ext(playlistFile)) - switch extension { - case ".nsp": - err = s.parseNSP(ctx, pls, reader) - default: - err = s.parseM3U(ctx, pls, folder, reader) - } - if err != nil { - return pls, err - } - pls.ImportedHash = fingerprint(hasher) - return pls, nil -} - -func fingerprint(h *xxh3.Hasher) string { - return id.Encode(h.Sum128().Bytes()) -} - -// findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode -// 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().FindByPath(ctx, path) - if errors.Is(err, model.ErrNotFound) { - altPath := norm.NFD.String(path) - if altPath == path { - altPath = norm.NFC.String(path) - } - if altPath != path { - pls, err = s.ds.Playlist().FindByPath(ctx, 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 - } - 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 - } - - if err == nil { - // Only smart playlists skip on an unchanged file; M3U must re-run so newly-added tracks resolve. - if !forceSync && newPls.IsSmartPlaylist() && newPls.ImportedHash != "" && newPls.ImportedHash == pls.ImportedHash { - log.Trace(ctx, "Playlist file unchanged since last import, skipping", "playlist", pls.Name, "path", pls.Path) - *newPls = *pls // callers must see the stored record, so e.g. ImportFile can still flip Sync - return nil - } - log.Info(ctx, "Updating synced playlist", "playlist", pls.Name, "path", newPls.Path) - newPls.ID = pls.ID - newPls.Name = pls.Name - newPls.Comment = pls.Comment - newPls.OwnerID = pls.OwnerID - newPls.Public = pls.Public - newPls.UploadedImage = pls.UploadedImage // Preserve manual upload - newPls.EvaluatedAt = nil // force re-evaluation on next read - if newPls.IsSmartPlaylist() { - // Tracks aren't materialized at parse time; carry the stored counters so callers see real values - newPls.SongCount = pls.SongCount - newPls.Duration = pls.Duration - newPls.Size = pls.Size - } - } else { - log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName) - newPls.OwnerID = owner.ID - // For NSP files, Public may already be set from the file; for M3U, use server default - if !newPls.IsSmartPlaylist() { - newPls.Public = conf.Server.DefaultPlaylistPublicVisibility - } - } - return s.ds.Playlist().Put(ctx, newPls) -} diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go deleted file mode 100644 index 25960f0fe..000000000 --- a/core/playlists/import_test.go +++ /dev/null @@ -1,1259 +0,0 @@ -package playlists_test - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/zeebo/xxh3" - "golang.org/x/text/unicode/norm" -) - -var _ = Describe("Playlists - Import", func() { - var ds *tests.MockDataStore - var ps playlists.Playlists - var mockPlsRepo *tests.MockPlaylistRepo - var mockLibRepo *tests.MockLibraryRepo - ctx := context.Background() - - BeforeEach(func() { - mockPlsRepo = tests.CreateMockPlaylistRepo() - mockLibRepo = &tests.MockLibraryRepo{} - ds = &tests.MockDataStore{ - MockedPlaylist: mockPlsRepo, - MockedLibrary: mockLibRepo, - } - ctx = request.WithUser(ctx, model.User{ID: "123"}) - }) - - Describe("ImportFromFolder", func() { - var folder *model.Folder - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - ds.MockedMediaFile = &mockedMediaFileRepo{} - libPath, _ := os.Getwd() - // Set up library with the actual library path that matches the folder - mockLibRepo.SetData([]model.Library{{ID: 1, Path: libPath}}) - folder = &model.Folder{ - ID: "1", - LibraryID: 1, - LibraryPath: libPath, - Path: "tests/fixtures", - Name: "playlists", - } - }) - - Describe("M3U", func() { - It("parses well-formed playlists", func() { - pls, err := ps.ImportFromFolder(ctx, folder, "pls1.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(pls.OwnerID).To(Equal("123")) - Expect(pls.Tracks).To(HaveLen(2)) - Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3")) - Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/playlists/test.ogg")) - Expect(mockPlsRepo.Last).To(Equal(pls)) - }) - - It("parses playlists using LF ending", func() { - 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.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.ImportFromFolder(ctx, folder, "bom-test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(pls.OwnerID).To(Equal("123")) - Expect(pls.Name).To(Equal("Test Playlist")) - Expect(pls.Tracks).To(HaveLen(1)) - Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3")) - }) - - It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() { - 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")) - Expect(pls.Tracks).To(HaveLen(1)) - Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3")) - }) - - It("parses #EXTALBUMARTURL with HTTP URL", func() { - conf.Server.EnableM3UExternalAlbumArt = true - - 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)) - }) - - It("parses #EXTALBUMARTURL with absolute local path", func() { - tmpDir := GinkgoT().TempDir() - imgPath := filepath.Join(tmpDir, "cover.jpg") - Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) - - m3u := fmt.Sprintf("#EXTALBUMARTURL:%s\ntest.mp3\ntest.ogg\n", imgPath) - plsFile := filepath.Join(tmpDir, "test.m3u") - Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) - - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ExternalImageURL).To(Equal(imgPath)) - }) - - It("parses #EXTALBUMARTURL with relative local path", func() { - tmpDir := GinkgoT().TempDir() - Expect(os.WriteFile(filepath.Join(tmpDir, "cover.jpg"), []byte("fake image"), 0600)).To(Succeed()) - - m3u := "#EXTALBUMARTURL:cover.jpg\ntest.mp3\n" - plsFile := filepath.Join(tmpDir, "test.m3u") - Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) - - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ExternalImageURL).To(Equal(filepath.Join(tmpDir, "cover.jpg"))) - }) - - It("parses #EXTALBUMARTURL with file:// URL", func() { - tmpDir := GinkgoT().TempDir() - imgPath := filepath.Join(tmpDir, "my cover.jpg") - Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) - - m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\n", strings.ReplaceAll(imgPath, " ", "%20")) - plsFile := filepath.Join(tmpDir, "test.m3u") - Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) - - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ExternalImageURL).To(Equal(imgPath)) - }) - - It("preserves + in file:// URLs (PathUnescape, not QueryUnescape)", func() { - tmpDir := GinkgoT().TempDir() - imgPath := filepath.Join(tmpDir, "A+B.jpg") - Expect(os.WriteFile(imgPath, []byte("fake image"), 0600)).To(Succeed()) - - m3u := fmt.Sprintf("#EXTALBUMARTURL:file://%s\ntest.mp3\n", imgPath) - plsFile := filepath.Join(tmpDir, "test.m3u") - Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) - - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ExternalImageURL).To(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" - plsFile := filepath.Join(tmpDir, "test.m3u") - Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) - - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ExternalImageURL).To(BeEmpty()) - }) - - It("rejects #EXTALBUMARTURL with file:// URL outside library boundaries", func() { - tmpDir := GinkgoT().TempDir() - - m3u := "#EXTALBUMARTURL:file:///etc/passwd\ntest.mp3\n" - plsFile := filepath.Join(tmpDir, "test.m3u") - Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) - - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ExternalImageURL).To(BeEmpty()) - }) - - It("rejects #EXTALBUMARTURL with relative path escaping library", func() { - tmpDir := GinkgoT().TempDir() - - m3u := "#EXTALBUMARTURL:../../etc/passwd\ntest.mp3\n" - plsFile := filepath.Join(tmpDir, "test.m3u") - Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) - - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ExternalImageURL).To(BeEmpty()) - }) - - It("rejects #EXTALBUMARTURL pointing at a non-image file inside the library", func() { - tmpDir := GinkgoT().TempDir() - Expect(os.WriteFile(filepath.Join(tmpDir, "config.ini"), []byte("password=secret"), 0600)).To(Succeed()) - - m3u := "#EXTALBUMARTURL:config.ini\ntest.mp3\n" - plsFile := filepath.Join(tmpDir, "test.m3u") - Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) - - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ExternalImageURL).To(BeEmpty()) - }) - - It("ignores HTTP #EXTALBUMARTURL when EnableM3UExternalAlbumArt is false", func() { - conf.Server.EnableM3UExternalAlbumArt = false - - tmpDir := GinkgoT().TempDir() - m3u := "#EXTALBUMARTURL:https://example.com/cover.jpg\ntest.mp3\n" - plsFile := filepath.Join(tmpDir, "test.m3u") - Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) - - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ExternalImageURL).To(BeEmpty()) - }) - - It("updates ExternalImageURL on re-scan even when UploadedImage is set", func() { - conf.Server.EnableM3UExternalAlbumArt = true - - tmpDir := GinkgoT().TempDir() - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - m3u := "#EXTALBUMARTURL:https://example.com/new-cover.jpg\ntest.mp3\n" - plsFile := filepath.Join(tmpDir, "test.m3u") - Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) - - existingPls := &model.Playlist{ - ID: "existing-id", - Name: "Existing Playlist", - Path: plsFile, - Sync: true, - UploadedImage: "existing-id.jpg", - ExternalImageURL: "https://example.com/old-cover.jpg", - } - 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()) - 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, artwork.NewUploader(ds)) - - 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("stores a content hash but re-imports unchanged M3U playlists", func() { - tmpDir := GinkgoT().TempDir() - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - plsFile := filepath.Join(tmpDir, "test.m3u") - Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - - first, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(first.ImportedHash).ToNot(BeEmpty()) - - // Re-import with a matching stored hash: M3U must still be re-imported, not skipped. - existingPls := &model.Playlist{ - ID: "m3u-id", Name: "Test", Path: plsFile, Sync: true, - OwnerID: "123", ImportedHash: first.ImportedHash, - } - mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} - mockPlsRepo.Last = nil - - _, err = ps.ImportFromFolder(ctx, plsFolder, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last).ToNot(BeNil()) - }) - - It("clears ExternalImageURL on re-scan when directive is removed", func() { - tmpDir := GinkgoT().TempDir() - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - m3u := "test.mp3\n" - plsFile := filepath.Join(tmpDir, "test.m3u") - Expect(os.WriteFile(plsFile, []byte(m3u), 0600)).To(Succeed()) - - existingPls := &model.Playlist{ - ID: "existing-id", - Name: "Existing Playlist", - Path: plsFile, - Sync: true, - ExternalImageURL: "https://example.com/old-cover.jpg", - } - 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()) - Expect(pls.ExternalImageURL).To(BeEmpty()) - }) - }) - - Describe("NSP", func() { - It("parses well-formed playlists", func() { - 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")) - Expect(pls.Name).To(Equal("Recently Played")) - Expect(pls.Comment).To(Equal("Recently played tracks")) - Expect(pls.Rules.Sort).To(Equal("lastPlayed")) - Expect(pls.Rules.Order).To(Equal("desc")) - Expect(pls.Rules.Limit).To(Equal(100)) - Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{})) - }) - It("returns an error if the playlist is not well-formed", func() { - 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.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.ImportFromFolder(ctx, folder, "private_playlist.nsp") - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Name).To(Equal("Private Playlist")) - Expect(pls.Public).To(BeFalse()) - }) - It("uses server default when public field is absent", func() { - conf.Server.DefaultPlaylistPublicVisibility = true - - 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 - }) - - It("preserves counters when re-importing an existing smart playlist", func() { - tmpDir := GinkgoT().TempDir() - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}],"sort":"title","order":"asc"}` - plsFile := filepath.Join(tmpDir, "smart.nsp") - Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) - - existingPls := &model.Playlist{ - ID: "smart-id", - Name: "My Smart", - Path: plsFile, - Sync: true, - OwnerID: "123", - SongCount: 42, - Duration: 123.4, - Size: 5000, - } - mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - _, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp") - - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last).ToNot(BeNil()) - Expect(mockPlsRepo.Last.IsSmartPlaylist()).To(BeTrue()) - Expect(mockPlsRepo.Last.SongCount).To(Equal(42)) - Expect(mockPlsRepo.Last.Duration).To(Equal(float32(123.4))) - Expect(mockPlsRepo.Last.Size).To(Equal(int64(5000))) - }) - - It("skips re-import when the smart playlist file content is unchanged", func() { - tmpDir := GinkgoT().TempDir() - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}` - plsFile := filepath.Join(tmpDir, "smart.nsp") - Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) - - existingPls := &model.Playlist{ - ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true, - OwnerID: "123", SongCount: 42, - ImportedHash: hashOf(nsp), - } - mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - _, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp") - - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last).To(BeNil()) // Put never called: nothing re-written - }) - - It("re-imports when the smart playlist file content changed", func() { - tmpDir := GinkgoT().TempDir() - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}` - plsFile := filepath.Join(tmpDir, "smart.nsp") - Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) - - existingPls := &model.Playlist{ - ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true, - OwnerID: "123", SongCount: 42, - ImportedHash: hashOf("old content"), - } - mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} - - plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - _, err := ps.ImportFromFolder(ctx, plsFolder, "smart.nsp") - - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last).ToNot(BeNil()) // Put called: file changed - Expect(mockPlsRepo.Last.ImportedHash).To(Equal(hashOf(nsp))) - }) - }) - - DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)", - 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) - Expect(plsNameNFD).ToNot(Equal(plsNameNFC)) // Verify they differ - - nameByForm := map[string]string{"NFC": plsNameNFC, "NFD": plsNameNFD} - storedName := nameByForm[storedForm] - filesystemName := nameByForm[filesystemForm] - - tmpDir := GinkgoT().TempDir() - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}} - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - // Create the playlist file on disk with the filesystem's normalization form - plsFile := tmpDir + "/" + filesystemName + ".m3u" - Expect(os.WriteFile(plsFile, []byte("#PLAYLIST:Test\n"), 0600)).To(Succeed()) - - // Pre-populate mock repo with the stored normalization form - storedPath := tmpDir + "/" + storedName + ".m3u" - existingPls := &model.Playlist{ - ID: "existing-id", - Name: "Existing Playlist", - Path: storedPath, - Sync: true, - } - mockPlsRepo.PathMap = map[string]*model.Playlist{storedPath: existingPls} - - // Import using the filesystem's normalization form - plsFolder := &model.Folder{ - ID: "1", - LibraryID: 1, - LibraryPath: tmpDir, - Path: "", - Name: "", - } - pls, err := ps.ImportFromFolder(ctx, plsFolder, filesystemName+".m3u") - Expect(err).ToNot(HaveOccurred()) - - // Should update existing playlist, not create new one - Expect(pls.ID).To(Equal("existing-id")) - Expect(pls.Name).To(Equal("Existing Playlist")) - }, - Entry("finds NFD-stored playlist when filesystem provides NFC path", "NFD", "NFC"), - Entry("finds NFC-stored playlist when filesystem provides NFD path", "NFC", "NFD"), - ) - - Describe("Cross-library relative paths", func() { - var tmpDir, plsDir, songsDir string - - BeforeEach(func() { - // Create temp directory structure - tmpDir = GinkgoT().TempDir() - plsDir = tmpDir + "/playlists" - songsDir = tmpDir + "/songs" - Expect(os.Mkdir(plsDir, 0755)).To(Succeed()) - Expect(os.Mkdir(songsDir, 0755)).To(Succeed()) - - // Setup two different libraries with paths matching our temp structure - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: songsDir}, - {ID: 2, Path: plsDir}, - }) - - // Create a mock media file repository that returns files for both libraries - // Note: The paths are relative to their respective library roots - ds.MockedMediaFile = &mockedMediaFileFromListRepo{ - data: []string{ - "abc.mp3", // This is songs/abc.mp3 relative to songsDir - "def.mp3", // This is playlists/def.mp3 relative to plsDir - }, - } - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - }) - - It("handles relative paths that reference files in other libraries", func() { - // Create a temporary playlist file with relative path - plsContent := "#PLAYLIST:Cross Library Test\n../songs/abc.mp3\ndef.mp3" - plsFile := plsDir + "/test.m3u" - Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed()) - - // Playlist is in the Playlists library folder - // Important: Path should be relative to LibraryPath, and Name is the folder name - plsFolder := &model.Folder{ - ID: "2", - LibraryID: 2, - LibraryPath: plsDir, - Path: "", - Name: "", - } - - 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 - Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library - }) - - It("ignores paths that point outside all libraries", func() { - // Create a temporary playlist file with path outside libraries - plsContent := "#PLAYLIST:Outside Test\n../../outside.mp3\nabc.mp3" - plsFile := plsDir + "/test.m3u" - Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed()) - - plsFolder := &model.Folder{ - ID: "2", - LibraryID: 2, - LibraryPath: plsDir, - Path: "", - Name: "", - } - - 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)) - Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) - }) - - It("handles relative paths with multiple '../' components", func() { - // Create a nested structure: tmpDir/playlists/subfolder/test.m3u - subFolder := plsDir + "/subfolder" - Expect(os.Mkdir(subFolder, 0755)).To(Succeed()) - - // Create the media file in the subfolder directory - // The mock will return it as "def.mp3" relative to plsDir - ds.MockedMediaFile = &mockedMediaFileFromListRepo{ - data: []string{ - "abc.mp3", // From songsDir library - "def.mp3", // From plsDir library root - }, - } - - // From subfolder, ../../songs/abc.mp3 should resolve to songs library - // ../def.mp3 should resolve to plsDir/def.mp3 - plsContent := "#PLAYLIST:Nested Test\n../../songs/abc.mp3\n../def.mp3" - plsFile := subFolder + "/test.m3u" - Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed()) - - // The folder: AbsolutePath = LibraryPath + Path + Name - // So for /playlists/subfolder: LibraryPath=/playlists, Path="", Name="subfolder" - plsFolder := &model.Folder{ - ID: "2", - LibraryID: 2, - LibraryPath: plsDir, - Path: "", // Empty because subfolder is directly under library root - Name: "subfolder", // The folder name - } - - 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 - Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library root - }) - - It("correctly resolves libraries when one path is a prefix of another", func() { - // This tests the bug where /music would match before /music-classical - // Create temp directory structure with prefix conflict - tmpDir := GinkgoT().TempDir() - musicDir := tmpDir + "/music" - musicClassicalDir := tmpDir + "/music-classical" - Expect(os.Mkdir(musicDir, 0755)).To(Succeed()) - Expect(os.Mkdir(musicClassicalDir, 0755)).To(Succeed()) - - // Setup two libraries where one is a prefix of the other - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: musicDir}, // /tmp/xxx/music - {ID: 2, Path: musicClassicalDir}, // /tmp/xxx/music-classical - }) - - // Mock will return tracks from both libraries - ds.MockedMediaFile = &mockedMediaFileFromListRepo{ - data: []string{ - "rock.mp3", // From music library - "bach.mp3", // From music-classical library - }, - } - - // Create playlist in music library that references music-classical - plsContent := "#PLAYLIST:Cross Prefix Test\nrock.mp3\n../music-classical/bach.mp3" - plsFile := musicDir + "/test.m3u" - Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed()) - - plsFolder := &model.Folder{ - ID: "1", - LibraryID: 1, - LibraryPath: musicDir, - Path: "", - Name: "", - } - - 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 - Expect(pls.Tracks[1].Path).To(Equal("bach.mp3")) // From music-classical library (not music!) - }) - - It("correctly handles identical relative paths from different libraries", func() { - // This tests the bug where two libraries have files at the same relative path - // and only one appears in the playlist - tmpDir := GinkgoT().TempDir() - musicDir := tmpDir + "/music" - classicalDir := tmpDir + "/classical" - Expect(os.Mkdir(musicDir, 0755)).To(Succeed()) - Expect(os.Mkdir(classicalDir, 0755)).To(Succeed()) - Expect(os.MkdirAll(musicDir+"/album", 0755)).To(Succeed()) - Expect(os.MkdirAll(classicalDir+"/album", 0755)).To(Succeed()) - // Create placeholder files so paths resolve correctly - Expect(os.WriteFile(musicDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed()) - Expect(os.WriteFile(classicalDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed()) - - // Both libraries have a file at "album/track.mp3" - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: musicDir}, - {ID: 2, Path: classicalDir}, - }) - - // Mock returns files with same relative path but different IDs and library IDs - // Keys use the library-qualified format: "libraryID:path" - ds.MockedMediaFile = &mockedMediaFileRepo{ - data: map[string]model.MediaFile{ - "1:album/track.mp3": {ID: "music-track", Path: "album/track.mp3", LibraryID: 1, Title: "Rock Song"}, - "2:album/track.mp3": {ID: "classical-track", Path: "album/track.mp3", LibraryID: 2, Title: "Classical Piece"}, - }, - } - // Recreate playlists service to pick up new mock - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - // Create playlist in music library that references both tracks - plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3" - plsFile := musicDir + "/test.m3u" - Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed()) - - plsFolder := &model.Folder{ - ID: "1", - LibraryID: 1, - LibraryPath: musicDir, - Path: "", - Name: "", - } - - pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - - // Should have BOTH tracks, not just one - Expect(pls.Tracks).To(HaveLen(2), "Playlist should contain both tracks with same relative path") - - // Verify we got tracks from DIFFERENT libraries (the key fix!) - // Collect the library IDs - libIDs := make(map[int]bool) - for _, track := range pls.Tracks { - libIDs[track.LibraryID] = true - } - Expect(libIDs).To(HaveLen(2), "Tracks should come from two different libraries") - Expect(libIDs[1]).To(BeTrue(), "Should have track from library 1") - Expect(libIDs[2]).To(BeTrue(), "Should have track from library 2") - - // Both tracks should have the same relative path - Expect(pls.Tracks[0].Path).To(Equal("album/track.mp3")) - Expect(pls.Tracks[1].Path).To(Equal("album/track.mp3")) - }) - }) - }) - - 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, artwork.NewUploader(ds)) - - 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, artwork.NewUploader(ds)) - - 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, artwork.NewUploader(ds)) - - 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, artwork.NewUploader(ds)) - - 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, artwork.NewUploader(ds)) - - 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()) - }) - - It("unsyncs a synced smart playlist with sync=false even when content is unchanged", func() { - tmpDir := GinkgoT().TempDir() - mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) - - mockFolderRepo := &mockFolderRepoForImport{ - folder: &model.Folder{ - ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "", - }, - } - ds.MockedFolder = mockFolderRepo - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - - nsp := `{"name":"My Smart","all":[{"is":{"loved":true}}]}` - plsFile := filepath.Join(tmpDir, "smart.nsp") - Expect(os.WriteFile(plsFile, []byte(nsp), 0600)).To(Succeed()) - - existingPls := &model.Playlist{ - ID: "smart-id", Name: "My Smart", Path: plsFile, Sync: true, - OwnerID: "123", SongCount: 42, - ImportedHash: hashOf(nsp), - } - mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} - - pls, err := ps.ImportFile(ctx, plsFile, false) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ID).To(Equal("smart-id")) - Expect(pls.Sync).To(BeFalse()) - Expect(mockPlsRepo.Last).ToNot(BeNil()) - Expect(mockPlsRepo.Last.Sync).To(BeFalse()) - }) - }) - - Describe("ImportM3U", func() { - var repo *mockedMediaFileFromListRepo - BeforeEach(func() { - repo = &mockedMediaFileFromListRepo{} - ds.MockedMediaFile = repo - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}}) - ctx = request.WithUser(ctx, model.User{ID: "123"}) - }) - - It("parses well-formed playlists", func() { - repo.data = []string{ - "tests/test.mp3", - "tests/test.ogg", - "tests/01 Invisible (RED) Edit Version.mp3", - "downloads/newfile.flac", - } - m3u := strings.Join([]string{ - "#PLAYLIST:playlist 1", - "/music/tests/test.mp3", - "/music/tests/test.ogg", - "/new/downloads/newfile.flac", - "file:///music/tests/01%20Invisible%20(RED)%20Edit%20Version.mp3", - }, "\n") - f := strings.NewReader(m3u) - - pls, err := ps.ImportM3U(ctx, f) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.OwnerID).To(Equal("123")) - Expect(pls.Name).To(Equal("playlist 1")) - Expect(pls.Sync).To(BeFalse()) - Expect(pls.Tracks).To(HaveLen(4)) - Expect(pls.Tracks[0].Path).To(Equal("tests/test.mp3")) - Expect(pls.Tracks[1].Path).To(Equal("tests/test.ogg")) - Expect(pls.Tracks[2].Path).To(Equal("downloads/newfile.flac")) - Expect(pls.Tracks[3].Path).To(Equal("tests/01 Invisible (RED) Edit Version.mp3")) - Expect(mockPlsRepo.Last).To(Equal(pls)) - }) - - It("sets the playlist name as a timestamp if the #PLAYLIST directive is not present", func() { - repo.data = []string{ - "tests/test.mp3", - "tests/test.ogg", - "/tests/01 Invisible (RED) Edit Version.mp3", - } - m3u := strings.Join([]string{ - "/music/tests/test.mp3", - "/music/tests/test.ogg", - }, "\n") - f := strings.NewReader(m3u) - pls, err := ps.ImportM3U(ctx, f) - Expect(err).ToNot(HaveOccurred()) - _, err = time.Parse(time.RFC3339, pls.Name) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Tracks).To(HaveLen(2)) - }) - - It("returns only tracks that exist in the database and in the same order as the m3u", func() { - repo.data = []string{ - "album1/test1.mp3", - "album2/test2.mp3", - "album3/test3.mp3", - } - m3u := strings.Join([]string{ - "/music/album3/test3.mp3", - "/music/album1/test1.mp3", - "/music/album4/test4.mp3", - "/music/album2/test2.mp3", - }, "\n") - f := strings.NewReader(m3u) - pls, err := ps.ImportM3U(ctx, f) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Tracks).To(HaveLen(3)) - Expect(pls.Tracks[0].Path).To(Equal("album3/test3.mp3")) - Expect(pls.Tracks[1].Path).To(Equal("album1/test1.mp3")) - Expect(pls.Tracks[2].Path).To(Equal("album2/test2.mp3")) - }) - - It("is case-insensitive when comparing paths", func() { - repo.data = []string{ - "abc/tEsT1.Mp3", - } - m3u := strings.Join([]string{ - "/music/ABC/TeSt1.mP3", - }, "\n") - f := strings.NewReader(m3u) - pls, err := ps.ImportM3U(ctx, f) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Tracks).To(HaveLen(1)) - Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3")) - }) - - It("parses #EXTALBUMARTURL with HTTP URL via ImportM3U", func() { - conf.Server.EnableM3UExternalAlbumArt = true - - repo.data = []string{"tests/test.mp3"} - m3u := "#EXTALBUMARTURL:https://example.com/cover.jpg\n/music/tests/test.mp3\n" - pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u)) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg")) - }) - - It("ignores relative #EXTALBUMARTURL when imported via API (no folder context)", func() { - repo.data = []string{"tests/test.mp3"} - m3u := "#EXTALBUMARTURL:cover.jpg\n/music/tests/test.mp3\n" - pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u)) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ExternalImageURL).To(BeEmpty()) - }) - - DescribeTable("restricts a local #EXTALBUMARTURL to the owner's libraries", - func(imageURL, expected string) { - ctx = request.WithUser(ctx, model.User{ID: "123", Libraries: model.Libraries{{ID: 1, Path: "/music"}}}) - repo.data = []string{"tests/test.mp3"} - m3u := "#EXTALBUMARTURL:" + imageURL + "\n/music/tests/test.mp3\n" - pls, err := ps.ImportM3U(ctx, strings.NewReader(m3u)) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ExternalImageURL).To(Equal(expected)) - }, - Entry("accepts a library the owner can access", "file:///music/cover.jpg", filepath.Clean("/music/cover.jpg")), - Entry("ignores a library the owner cannot access", "file:///new/cover.jpg", ""), - ) - - // Fullwidth characters (e.g., ABCD) are not handled by SQLite's NOCASE collation, - // so we need exact matching for non-ASCII characters. - It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() { - // Fullwidth uppercase ACROSS (U+FF21, U+FF23, U+FF32, U+FF2F, U+FF33, U+FF33) - repo.data = []string{ - "plex/02 - ACROSS.flac", - } - m3u := "/music/plex/02 - ACROSS.flac\n" - f := strings.NewReader(m3u) - pls, err := ps.ImportM3U(ctx, f) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Tracks).To(HaveLen(1)) - Expect(pls.Tracks[0].Path).To(Equal("plex/02 - ACROSS.flac")) - }) - - // Unicode normalization tests: NFC (composed) vs NFD (decomposed) forms - // macOS stores paths in NFD, Linux/Windows use NFC. Playlists may use either form. - DescribeTable("matches paths across Unicode NFC/NFD normalization", - func(description, pathNFC string, dbForm, playlistForm norm.Form) { - pathNFD := norm.NFD.String(pathNFC) - Expect(pathNFD).ToNot(Equal(pathNFC), "test path should have decomposable characters") - - // Set up DB with specified normalization form - var dbPath string - if dbForm == norm.NFC { - dbPath = pathNFC - } else { - dbPath = pathNFD - } - repo.data = []string{dbPath} - - // Set up playlist with specified normalization form - var playlistPath string - if playlistForm == norm.NFC { - playlistPath = pathNFC - } else { - playlistPath = pathNFD - } - m3u := "/music/" + playlistPath + "\n" - f := strings.NewReader(m3u) - - pls, err := ps.ImportM3U(ctx, f) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Tracks).To(HaveLen(1)) - Expect(pls.Tracks[0].Path).To(Equal(dbPath)) - }, - // French: è (U+00E8) decomposes to e + combining grave (U+0065 + U+0300) - Entry("French diacritics - DB:NFD, playlist:NFC", - "macOS DB with Apple Music playlist", - "artist/Michèle/song.mp3", norm.NFD, norm.NFC), - - // Japanese Katakana: ド (U+30C9) decomposes to ト (U+30C8) + combining dakuten (U+3099) - Entry("Japanese Katakana with dakuten - DB:NFC, playlist:NFC (#4884)", - "Linux/Windows DB with NFC playlist", - "artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFC, norm.NFC), - Entry("Japanese Katakana with dakuten - DB:NFD, playlist:NFC (#4884)", - "macOS DB with NFC playlist", - "artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFD, norm.NFC), - - // Cyrillic: й (U+0439) decomposes to и (U+0438) + combining breve (U+0306) - Entry("Cyrillic characters - DB:NFD, playlist:NFC (#4791)", - "macOS DB with NFC playlist", - "Жуки/Батарейка/01 - Разлюбила.mp3", norm.NFD, norm.NFC), - - // Polish: ó (U+00F3) decomposes to o + combining acute (U+0301) - Entry("Polish diacritics - DB:NFD, playlist:NFC (#4663)", - "macOS DB with NFC playlist", - "Zespół/Człowiek/Piosenka o miłości.mp3", norm.NFD, norm.NFC), - Entry("Polish diacritics - DB:NFC, playlist:NFD", - "Linux/Windows DB with macOS-exported playlist", - "Zespół/Człowiek/Piosenka o miłości.mp3", norm.NFC, norm.NFD), - ) - - }) - - Describe("InPath", func() { - var folder model.Folder - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - folder = model.Folder{ - LibraryPath: "/music", - Path: "playlists/abc", - Name: "folder1", - } - }) - - It("returns true if PlaylistsPath is empty", func() { - conf.Server.PlaylistsPath = "" - Expect(playlists.InPath(folder)).To(BeTrue()) - }) - - It("returns true if PlaylistsPath is any (**/**)", func() { - conf.Server.PlaylistsPath = "**/**" - Expect(playlists.InPath(folder)).To(BeTrue()) - }) - - 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()) - }) - - It("returns false if folder is not in PlaylistsPath", func() { - conf.Server.PlaylistsPath = "other" - Expect(playlists.InPath(folder)).To(BeFalse()) - }) - - It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() { - conf.Server.PlaylistsPath = "." - Expect(playlists.InPath(folder)).To(BeFalse()) - - folder2 := model.Folder{ - LibraryPath: "/music", - Path: "", - Name: ".", - } - - Expect(playlists.InPath(folder2)).To(BeTrue()) - }) - }) -}) - -// mockedMediaFileRepo's FindByPaths method returns MediaFiles for the given paths. -// If data map is provided, looks up files by key; otherwise creates them from paths. -type mockedMediaFileRepo struct { - model.MediaFileRepository - data map[string]model.MediaFile -} - -func (r *mockedMediaFileRepo) FindByPaths(ctx context.Context, paths []string) (model.MediaFiles, error) { - var mfs model.MediaFiles - - // If data map provided, look up files - if r.data != nil { - for _, path := range paths { - if mf, ok := r.data[path]; ok { - mfs = append(mfs, mf) - } - } - return mfs, nil - } - - // Otherwise, create MediaFiles from paths - for idx, path := range paths { - // Strip library qualifier if present (format: "libraryID:path") - actualPath := path - libraryID := 1 - if parts := strings.SplitN(path, ":", 2); len(parts) == 2 { - if id, err := strconv.Atoi(parts[0]); err == nil { - libraryID = id - actualPath = parts[1] - } - } - - mfs = append(mfs, model.MediaFile{ - ID: strconv.Itoa(idx), - Path: actualPath, - LibraryID: libraryID, - }) - } - return mfs, nil -} - -// mockedMediaFileFromListRepo's FindByPaths method returns a list of MediaFiles based on the data field -type mockedMediaFileFromListRepo struct { - model.MediaFileRepository - data []string -} - -func (r *mockedMediaFileFromListRepo) FindByPaths(ctx context.Context, paths []string) (model.MediaFiles, error) { - var mfs model.MediaFiles - - for idx, dataPath := range r.data { - for _, requestPath := range paths { - // Strip library qualifier if present (format: "libraryID:path") - actualPath := requestPath - libraryID := 1 - if parts := strings.SplitN(requestPath, ":", 2); len(parts) == 2 { - if id, err := strconv.Atoi(parts[0]); err == nil { - libraryID = id - actualPath = parts[1] - } - } - - // Case-insensitive comparison (like SQL's "collate nocase"), but with no - // implicit Unicode normalization (SQLite does not normalize NFC/NFD). - if strings.EqualFold(actualPath, dataPath) { - mfs = append(mfs, model.MediaFile{ - ID: strconv.Itoa(idx), - Path: dataPath, // Return original path from DB - LibraryID: libraryID, - }) - break - } - } - } - return mfs, nil -} - -type mockFolderRepoForImport struct { - model.FolderRepository - folder *model.Folder -} - -func (m *mockFolderRepoForImport) GetByPath(_ context.Context, _ model.Library, _ string) (*model.Folder, error) { - if m.folder != nil { - return m.folder, nil - } - return nil, model.ErrNotFound -} - -func hashOf(content string) string { - return id.Encode(xxh3.Hash128([]byte(content)).Bytes()) -} diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go deleted file mode 100644 index ab95b8850..000000000 --- a/core/playlists/parse_m3u.go +++ /dev/null @@ -1,282 +0,0 @@ -package playlists - -import ( - "context" - "fmt" - "io" - "net/url" - "path/filepath" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" - "golang.org/x/text/unicode/norm" -) - -func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error { - mediaFileRepository := s.ds.MediaFile() - resolver, err := newPathResolver(ctx, s.ds) - if err != nil { - return err - } - var mfs model.MediaFiles - // Chunked so a huge playlist is not held in memory at once. Each line yields up to - // 4 lookup candidates (NFC/NFD × raw/lowercase), far below SQLite's 32766 variables. - for lines := range slice.CollectChunks(slice.LinesFrom(reader), 100) { - filteredLines := make([]string, 0, len(lines)) - for _, line := range lines { - line := strings.TrimSpace(line) - if after, ok := strings.CutPrefix(line, "#PLAYLIST:"); ok { - pls.Name = after - continue - } - if after, ok := strings.CutPrefix(line, "#EXTALBUMARTURL:"); ok { - owner, _ := request.UserFrom(ctx) - pls.ExternalImageURL = resolveImageURL(after, folder, resolver.matcher, owner) - continue - } - // Skip empty lines and extended info - if line == "" || strings.HasPrefix(line, "#") { - continue - } - if after, ok := strings.CutPrefix(line, "file://"); ok { - line = after - line, _ = url.PathUnescape(line) - } - if !model.IsAudioFile(line) { - continue - } - filteredLines = append(filteredLines, line) - } - resolvedPaths, err := resolver.resolvePaths(ctx, folder, filteredLines) - if err != nil { - log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err) - continue - } - - // SQLite comparisons do not perform Unicode normalization, and filesystem normalization - // differs across platforms (macOS often yields NFD, while Linux/Windows typically use NFC). - // Generate lookup candidates for both forms so playlist entries match DB paths regardless - // of the original normalization. See https://github.com/navidrome/navidrome/issues/4884 - // - // We also include the original (non-lowercased) paths because SQLite's COLLATE NOCASE - // only handles ASCII case-insensitivity. Non-ASCII characters like fullwidth letters - // (e.g., ABCD vs abcd) are not matched case-insensitively by NOCASE. - lookupCandidates := make([]string, 0, len(resolvedPaths)*4) - seen := make(map[string]struct{}, len(resolvedPaths)*4) - for _, path := range resolvedPaths { - // Add original paths first (for exact matching of non-ASCII characters) - nfcRaw := norm.NFC.String(path) - if _, ok := seen[nfcRaw]; !ok { - seen[nfcRaw] = struct{}{} - lookupCandidates = append(lookupCandidates, nfcRaw) - } - nfdRaw := norm.NFD.String(path) - if _, ok := seen[nfdRaw]; !ok { - seen[nfdRaw] = struct{}{} - lookupCandidates = append(lookupCandidates, nfdRaw) - } - - // Add lowercased paths (for ASCII case-insensitive matching via NOCASE) - nfc := strings.ToLower(nfcRaw) - if _, ok := seen[nfc]; !ok { - seen[nfc] = struct{}{} - lookupCandidates = append(lookupCandidates, nfc) - } - nfd := strings.ToLower(nfdRaw) - if _, ok := seen[nfd]; !ok { - seen[nfd] = struct{}{} - lookupCandidates = append(lookupCandidates, nfd) - } - } - - found, err := mediaFileRepository.FindByPaths(ctx, lookupCandidates) - if err != nil { - log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err) - continue - } - - // Build lookup map with library-qualified keys, normalized for comparison. - // Canonicalize to NFC so NFD/NFC become comparable. - existing := make(map[string]int, len(found)) - for idx := range found { - key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(norm.NFC.String(found[idx].Path))) - existing[key] = idx - } - - // Find media files in the order of the resolved paths, to keep playlist order. - // Both `existing` keys and `resolvedPaths` use the library-qualified format "libraryID:relativePath", - // so normalizing the full string produces matching keys (digits and ':' are ASCII-invariant). - for _, path := range resolvedPaths { - key := strings.ToLower(norm.NFC.String(path)) - idx, ok := existing[key] - if ok { - mfs = append(mfs, found[idx]) - } else { - // Prefer logging a composed representation when possible to avoid confusing output - // with decomposed combining marks. - log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", norm.NFC.String(path)) - } - } - } - if pls.Name == "" { - pls.Name = time.Now().Format(time.RFC3339) - } - pls.Tracks = nil - pls.AddMediaFiles(mfs) - - return nil -} - -// pathResolution holds the result of resolving a playlist path to a library-relative path. -type pathResolution struct { - absolutePath string - libraryPath string - libraryID int - valid bool -} - -// ToQualifiedString converts the path resolution to a library-qualified string with forward slashes. -// Format: "libraryID:relativePath" with forward slashes for path separators. -func (r pathResolution) ToQualifiedString() (string, error) { - if !r.valid { - return "", fmt.Errorf("invalid path resolution") - } - relativePath, err := filepath.Rel(r.libraryPath, r.absolutePath) - if err != nil { - return "", err - } - // Convert path separators to forward slashes - return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil -} - -// pathResolver handles path resolution logic for playlist imports. -type pathResolver struct { - matcher *model.LibraryMatcher -} - -// newPathResolver creates a pathResolver with libraries loaded from the datastore. -func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, error) { - libs, err := ds.Library().GetAll(ctx) - if err != nil { - return nil, err - } - matcher := model.NewLibraryMatcher(libs) - return &pathResolver{matcher: matcher}, nil -} - -// resolvePath determines the absolute path and library path for a playlist entry. -// For absolute paths, it uses them directly. -// For relative paths, it resolves them relative to the playlist's folder location. -// Example: playlist at /music/playlists/test.m3u with line "../songs/abc.mp3" -// -// resolves to /music/songs/abc.mp3 -func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolution { - var absolutePath string - if folder != nil && !filepath.IsAbs(line) { - // Resolve relative path to absolute path based on playlist location - absolutePath = filepath.Clean(filepath.Join(folder.AbsolutePath(), line)) - } else { - // Use absolute path directly after cleaning - absolutePath = filepath.Clean(line) - } - - return r.findInLibraries(absolutePath) -} - -// findInLibraries matches an absolute path against all known libraries and returns -// a pathResolution with the library information. Returns an invalid resolution if -// the path is not found in any library. -func (r *pathResolver) findInLibraries(absolutePath string) pathResolution { - lib, ok := r.matcher.FindLibrary(absolutePath) - if !ok { - return pathResolution{valid: false} - } - return pathResolution{ - absolutePath: absolutePath, - libraryPath: filepath.Clean(lib.Path), - libraryID: lib.ID, - valid: true, - } -} - -// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath"). -// For relative paths, it resolves them to absolute paths first, then determines which -// library they belong to. This allows playlists to reference files across library boundaries. -func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) { - results := make([]string, 0, len(lines)) - for idx, line := range lines { - resolution := r.resolvePath(line, folder) - - if !resolution.valid { - log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx) - continue - } - - qualifiedPath, err := resolution.ToQualifiedString() - if err != nil { - log.Debug(ctx, "Error getting library-qualified path", "path", line, - "libPath", resolution.libraryPath, "filePath", resolution.absolutePath, err) - continue - } - - results = append(results, qualifiedPath) - } - - return results, nil -} - -// resolveImageURL resolves an #EXTALBUMARTURL value to a storable string. -// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt). -// Local paths (file://, absolute, or relative) are resolved to an absolute path -// and validated against known library boundaries via matcher. -func resolveImageURL(value string, folder *model.Folder, matcher *model.LibraryMatcher, owner model.User) string { - value = strings.TrimSpace(value) - if value == "" { - return "" - } - - // HTTP(S) URLs — store as-is, but only if external album art is enabled - if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { - if !conf.Server.EnableM3UExternalAlbumArt { - return "" - } - return value - } - - // Resolve to local absolute path - localPath, ok := resolveLocalPath(value, folder) - if !ok || !model.IsImageFile(localPath) { - return "" - } - - lib, ok := matcher.FindLibrary(localPath) - // A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries. - if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) { - return "" - } - return localPath -} - -// resolveLocalPath converts a file://, absolute, or relative path to a clean absolute path. -// Returns ("", false) if the path cannot be resolved. -func resolveLocalPath(value string, folder *model.Folder) (string, bool) { - if after, ok := strings.CutPrefix(value, "file://"); ok { - decoded, err := url.PathUnescape(after) - if err != nil { - return "", false - } - return filepath.Clean(decoded), true - } - if filepath.IsAbs(value) { - return filepath.Clean(value), true - } - if folder == nil { - return "", false - } - return filepath.Clean(filepath.Join(folder.AbsolutePath(), value)), true -} diff --git a/core/playlists/parse_m3u_test.go b/core/playlists/parse_m3u_test.go deleted file mode 100644 index ced6c2b16..000000000 --- a/core/playlists/parse_m3u_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package playlists - -import ( - "context" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("pathResolver", func() { - var ds *tests.MockDataStore - var mockLibRepo *tests.MockLibraryRepo - var resolver *pathResolver - ctx := context.Background() - - BeforeEach(func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)") - mockLibRepo = &tests.MockLibraryRepo{} - ds = &tests.MockDataStore{ - MockedLibrary: mockLibRepo, - } - - // Setup test libraries - mockLibRepo.SetData([]model.Library{ - {ID: 1, Path: "/music"}, - {ID: 2, Path: "/music-classical"}, - {ID: 3, Path: "/podcasts"}, - }) - - var err error - resolver, err = newPathResolver(ctx, ds) - Expect(err).ToNot(HaveOccurred()) - }) - - Describe("resolvePath", func() { - Context("basic", func() { - It("resolves absolute paths", func() { - resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil) - - Expect(resolution.valid).To(BeTrue()) - Expect(resolution.libraryID).To(Equal(1)) - Expect(resolution.libraryPath).To(Equal("/music")) - Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3")) - }) - - It("resolves relative paths when folder is provided", func() { - folder := &model.Folder{ - Path: "playlists", - LibraryPath: "/music", - LibraryID: 1, - } - - resolution := resolver.resolvePath("../artist/album/track.mp3", folder) - - Expect(resolution.valid).To(BeTrue()) - Expect(resolution.libraryID).To(Equal(1)) - Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3")) - }) - - It("returns invalid resolution for paths outside any library", func() { - resolution := resolver.resolvePath("/outside/library/track.mp3", nil) - - Expect(resolution.valid).To(BeFalse()) - }) - }) - - Context("cross-library", func() { - It("resolves path within a library", func() { - resolution := resolver.resolvePath("/music/track.mp3", nil) - - Expect(resolution.valid).To(BeTrue()) - Expect(resolution.libraryID).To(Equal(1)) - Expect(resolution.libraryPath).To(Equal("/music")) - Expect(resolution.absolutePath).To(Equal("/music/track.mp3")) - }) - - It("resolves path to the longest matching library", func() { - resolution := resolver.resolvePath("/music-classical/track.mp3", nil) - - Expect(resolution.valid).To(BeTrue()) - Expect(resolution.libraryID).To(Equal(2)) - Expect(resolution.libraryPath).To(Equal("/music-classical")) - }) - - It("returns invalid resolution for path outside libraries", func() { - resolution := resolver.resolvePath("/videos/movie.mp4", nil) - - Expect(resolution.valid).To(BeFalse()) - }) - - It("cleans the path before matching", func() { - resolution := resolver.resolvePath("/music//artist/../artist/track.mp3", nil) - - Expect(resolution.valid).To(BeTrue()) - Expect(resolution.absolutePath).To(Equal("/music/artist/track.mp3")) - }) - }) - - Context("With relative paths", func() { - It("resolves relative path within same library", func() { - folder := &model.Folder{ - Path: "playlists", - LibraryPath: "/music", - LibraryID: 1, - } - - resolution := resolver.resolvePath("../songs/track.mp3", folder) - - Expect(resolution.valid).To(BeTrue()) - Expect(resolution.libraryID).To(Equal(1)) - Expect(resolution.absolutePath).To(Equal("/music/songs/track.mp3")) - }) - - It("resolves relative path to different library", func() { - folder := &model.Folder{ - Path: "playlists", - LibraryPath: "/music", - LibraryID: 1, - } - - // Path goes up and into a different library - resolution := resolver.resolvePath("../../podcasts/episode.mp3", folder) - - Expect(resolution.valid).To(BeTrue()) - Expect(resolution.libraryID).To(Equal(3)) - Expect(resolution.libraryPath).To(Equal("/podcasts")) - }) - - It("uses matcher to find correct library for resolved path", func() { - folder := &model.Folder{ - Path: "playlists", - LibraryPath: "/music", - LibraryID: 1, - } - - // This relative path resolves to music-classical library - resolution := resolver.resolvePath("../../music-classical/track.mp3", folder) - - Expect(resolution.valid).To(BeTrue()) - Expect(resolution.libraryID).To(Equal(2)) - Expect(resolution.libraryPath).To(Equal("/music-classical")) - }) - - It("returns invalid for relative paths escaping all libraries", func() { - folder := &model.Folder{ - Path: "playlists", - LibraryPath: "/music", - LibraryID: 1, - } - - resolution := resolver.resolvePath("../../../../etc/passwd", folder) - - Expect(resolution.valid).To(BeFalse()) - }) - }) - }) - - Describe("Cross-library resolution scenarios", func() { - It("handles playlist in library A referencing file in library B", func() { - // Playlist is in /music/playlists - folder := &model.Folder{ - Path: "playlists", - LibraryPath: "/music", - LibraryID: 1, - } - - // Relative path that goes to /podcasts library - resolution := resolver.resolvePath("../../podcasts/show/episode.mp3", folder) - - Expect(resolution.valid).To(BeTrue()) - Expect(resolution.libraryID).To(Equal(3), "Should resolve to podcasts library") - Expect(resolution.libraryPath).To(Equal("/podcasts")) - }) - - It("prefers longer library paths when resolving", func() { - // Ensure /music-classical is matched instead of /music - resolution := resolver.resolvePath("/music-classical/baroque/track.mp3", nil) - - Expect(resolution.valid).To(BeTrue()) - Expect(resolution.libraryID).To(Equal(2), "Should match /music-classical, not /music") - }) - }) -}) - -var _ = Describe("pathResolution", func() { - Describe("ToQualifiedString", func() { - It("converts valid resolution to qualified string with forward slashes", func() { - resolution := pathResolution{ - absolutePath: "/music/artist/album/track.mp3", - libraryPath: "/music", - libraryID: 1, - valid: true, - } - - qualifiedStr, err := resolution.ToQualifiedString() - - Expect(err).ToNot(HaveOccurred()) - Expect(qualifiedStr).To(Equal("1:artist/album/track.mp3")) - }) - - It("handles Windows-style paths by converting to forward slashes", func() { - resolution := pathResolution{ - absolutePath: "/music/artist/album/track.mp3", - libraryPath: "/music", - libraryID: 2, - valid: true, - } - - qualifiedStr, err := resolution.ToQualifiedString() - - Expect(err).ToNot(HaveOccurred()) - // Should always use forward slashes regardless of OS - Expect(qualifiedStr).To(ContainSubstring("2:")) - Expect(qualifiedStr).ToNot(ContainSubstring("\\")) - }) - - It("returns error for invalid resolution", func() { - resolution := pathResolution{valid: false} - - _, err := resolution.ToQualifiedString() - - Expect(err).To(HaveOccurred()) - }) - }) -}) diff --git a/core/playlists/parse_nsp.go b/core/playlists/parse_nsp.go deleted file mode 100644 index a5b8b7c02..000000000 --- a/core/playlists/parse_nsp.go +++ /dev/null @@ -1,102 +0,0 @@ -package playlists - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" - "github.com/navidrome/navidrome/utils/jsoncommentstrip" -) - -func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) { - playlistPath := filepath.Join(baseDir, playlistFile) - info, err := os.Stat(playlistPath) - if err != nil { - return nil, err - } - - var extension = filepath.Ext(playlistFile) - var name = playlistFile[0 : len(playlistFile)-len(extension)] - - pls := &model.Playlist{ - Name: name, - Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile), - Public: false, - Path: playlistPath, - Sync: true, - UpdatedAt: info.ModTime(), - } - return pls, nil -} - -func getPositionFromOffset(data []byte, offset int64) (line, column int) { - line = 1 - for _, b := range data[:offset] { - if b == '\n' { - line++ - column = 1 - } else { - column++ - } - } - return -} - -func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error { - nsp := &nspFile{} - reader = io.LimitReader(reader, 100*1024) // Limit to 100KB - reader = jsoncommentstrip.NewReader(reader) - input, err := io.ReadAll(reader) - if err != nil { - return fmt.Errorf("reading SmartPlaylist: %w", err) - } - err = json.Unmarshal(input, nsp) - if err != nil { - if syntaxErr, ok := errors.AsType[*json.SyntaxError](err); ok { - line, col := getPositionFromOffset(input, syntaxErr.Offset) - return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err) - } - return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err) - } - pls.Rules = &nsp.Criteria - if nsp.Name != "" { - pls.Name = nsp.Name - } - if nsp.Comment != "" { - pls.Comment = nsp.Comment - } - if nsp.Public != nil { - pls.Public = *nsp.Public - } else { - pls.Public = conf.Server.DefaultPlaylistPublicVisibility - } - return nil -} - -type nspFile struct { - criteria.Criteria - Name string `json:"name"` - Comment string `json:"comment"` - Public *bool `json:"public"` -} - -func (i *nspFile) UnmarshalJSON(data []byte) error { - m := map[string]any{} - err := json.Unmarshal(data, &m) - if err != nil { - return err - } - i.Name, _ = m["name"].(string) - i.Comment, _ = m["comment"].(string) - if public, ok := m["public"].(bool); ok { - i.Public = &public - } - return json.Unmarshal(data, &i.Criteria) -} diff --git a/core/playlists/parse_nsp_test.go b/core/playlists/parse_nsp_test.go deleted file mode 100644 index d6d69866f..000000000 --- a/core/playlists/parse_nsp_test.go +++ /dev/null @@ -1,242 +0,0 @@ -package playlists - -import ( - "context" - "os" - "path/filepath" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("parseNSP", func() { - var s *playlists - ctx := context.Background() - - BeforeEach(func() { - s = &playlists{} - }) - - It("parses a well-formed NSP with all fields", func() { - nsp := `{ - "name": "My Smart Playlist", - "comment": "A test playlist", - "public": true, - "all": [{"is": {"loved": true}}], - "sort": "title", - "order": "asc", - "limit": 50 - }` - pls := &model.Playlist{Name: "default-name"} - err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Name).To(Equal("My Smart Playlist")) - Expect(pls.Comment).To(Equal("A test playlist")) - Expect(pls.Public).To(BeTrue()) - Expect(pls.Rules).ToNot(BeNil()) - Expect(pls.Rules.Sort).To(Equal("title")) - Expect(pls.Rules.Order).To(Equal("asc")) - Expect(pls.Rules.Limit).To(Equal(50)) - Expect(pls.Rules.Expression).To(BeAssignableToTypeOf(criteria.All{})) - }) - - It("keeps existing name when NSP has no name field", func() { - nsp := `{"all": [{"is": {"loved": true}}]}` - pls := &model.Playlist{Name: "Original Name"} - err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Name).To(Equal("Original Name")) - }) - - It("keeps existing comment when NSP has no comment field", func() { - nsp := `{"all": [{"is": {"loved": true}}]}` - pls := &model.Playlist{Comment: "Original Comment"} - err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Comment).To(Equal("Original Comment")) - }) - - It("strips JSON comments before parsing", func() { - nsp := `{ - // Line comment - "name": "Commented Playlist", - /* Block comment */ - "all": [{"is": {"loved": true}}] - }` - pls := &model.Playlist{} - err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Name).To(Equal("Commented Playlist")) - }) - - It("uses server default when public field is absent", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DefaultPlaylistPublicVisibility = true - - nsp := `{"all": [{"is": {"loved": true}}]}` - pls := &model.Playlist{} - err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Public).To(BeTrue()) - }) - - It("honors explicit public: false over server default", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DefaultPlaylistPublicVisibility = true - - nsp := `{"public": false, "all": [{"is": {"loved": true}}]}` - pls := &model.Playlist{} - err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Public).To(BeFalse()) - }) - - It("returns a syntax error with line and column info", func() { - nsp := "{\n \"name\": \"Bad\",\n \"all\": [INVALID]\n}" - pls := &model.Playlist{} - err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("JSON syntax error in SmartPlaylist")) - Expect(err.Error()).To(MatchRegexp(`line \d+, column \d+`)) - }) - - It("returns a parsing error for completely invalid JSON", func() { - nsp := `not json at all` - pls := &model.Playlist{} - err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("SmartPlaylist")) - }) - - It("rejects a NSP that mixes top-level 'any' and 'all' instead of silently dropping a group", func() { - nsp := `{ - "name": "Overplayed Favorites", - "any": [{"inPlaylist": {"path": "most-played-favorites.nsp"}}], - "all": [{"notInPlaylist": {"path": "favorites-not-played-in-4-yrs.nsp"}}], - "sort": "playCount, lastPlayed" - }` - pls := &model.Playlist{} - err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("SmartPlaylist")) - Expect(err.Error()).To(And(ContainSubstring("all"), ContainSubstring("any"))) - }) - - It("gracefully handles non-string name field", func() { - nsp := `{"name": 123, "all": [{"is": {"loved": true}}]}` - pls := &model.Playlist{Name: "Original"} - err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) - Expect(err).ToNot(HaveOccurred()) - // Type assertion in UnmarshalJSON fails silently; name stays as original - Expect(pls.Name).To(Equal("Original")) - }) - - It("parses limitPercent from NSP", func() { - nsp := `{ - "all": [{"is": {"loved": true}}], - "sort": "playCount", - "order": "desc", - "limitPercent": 25 - }` - pls := &model.Playlist{} - err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Rules).ToNot(BeNil()) - Expect(pls.Rules.LimitPercent).To(Equal(25)) - Expect(pls.Rules.Limit).To(Equal(0)) - }) - - It("parses criteria with multiple rules", func() { - nsp := `{ - "all": [ - {"is": {"loved": true}}, - {"contains": {"title": "rock"}} - ], - "sort": "lastPlayed", - "order": "desc", - "limit": 100 - }` - pls := &model.Playlist{} - err := s.parseNSP(ctx, pls, strings.NewReader(nsp)) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Rules).ToNot(BeNil()) - Expect(pls.Rules.Sort).To(Equal("lastPlayed")) - Expect(pls.Rules.Order).To(Equal("desc")) - Expect(pls.Rules.Limit).To(Equal(100)) - }) -}) - -var _ = Describe("getPositionFromOffset", func() { - It("returns correct position on first line", func() { - data := []byte("hello world") - line, col := getPositionFromOffset(data, 5) - Expect(line).To(Equal(1)) - Expect(col).To(Equal(5)) - }) - - It("returns correct position after newlines", func() { - data := []byte("line1\nline2\nline3") - // Offsets: l(0) i(1) n(2) e(3) 1(4) \n(5) l(6) i(7) n(8) - line, col := getPositionFromOffset(data, 8) - Expect(line).To(Equal(2)) - Expect(col).To(Equal(3)) - }) - - It("returns correct position at start of new line", func() { - data := []byte("line1\nline2") - // After \n at offset 5, col resets to 1; offset 6 is 'l' -> col=1 - line, col := getPositionFromOffset(data, 6) - Expect(line).To(Equal(2)) - Expect(col).To(Equal(1)) - }) - - It("handles multiple newlines", func() { - data := []byte("a\nb\nc\nd") - // a(0) \n(1) b(2) \n(3) c(4) \n(5) d(6) - line, col := getPositionFromOffset(data, 6) - Expect(line).To(Equal(4)) - Expect(col).To(Equal(1)) - }) -}) - -var _ = Describe("newSyncedPlaylist", func() { - var s *playlists - - BeforeEach(func() { - s = &playlists{} - }) - - It("creates a synced playlist with correct attributes", func() { - tmpDir := GinkgoT().TempDir() - Expect(os.WriteFile(filepath.Join(tmpDir, "test.m3u"), []byte("content"), 0600)).To(Succeed()) - - pls, err := s.newSyncedPlaylist(tmpDir, "test.m3u") - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Name).To(Equal("test")) - Expect(pls.Comment).To(Equal("Auto-imported from 'test.m3u'")) - Expect(pls.Public).To(BeFalse()) - Expect(pls.Path).To(Equal(filepath.Join(tmpDir, "test.m3u"))) - Expect(pls.Sync).To(BeTrue()) - Expect(pls.UpdatedAt).ToNot(BeZero()) - }) - - It("strips extension from filename to derive name", func() { - tmpDir := GinkgoT().TempDir() - Expect(os.WriteFile(filepath.Join(tmpDir, "My Favorites.nsp"), []byte("{}"), 0600)).To(Succeed()) - - pls, err := s.newSyncedPlaylist(tmpDir, "My Favorites.nsp") - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Name).To(Equal("My Favorites")) - }) - - It("returns error for non-existent file", func() { - tmpDir := GinkgoT().TempDir() - _, err := s.newSyncedPlaylist(tmpDir, "nonexistent.m3u") - Expect(err).To(HaveOccurred()) - }) -}) diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go deleted file mode 100644 index c9bc03b97..000000000 --- a/core/playlists/playlists.go +++ /dev/null @@ -1,368 +0,0 @@ -package playlists - -import ( - "context" - "io" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/bmatcuk/doublestar/v4" - "github.com/deluan/rest" - "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" -) - -type Playlists interface { - // Reads - GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) - Get(ctx context.Context, id string) (*model.Playlist, error) - GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) - Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) - GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) - - // Mutations - Create(ctx context.Context, playlistId string, name string, ids []string) (string, error) - Delete(ctx context.Context, id string) error - Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error - - // Track management - AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) - InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error) - AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) - AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) - AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) - RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error - ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error - - // Cover art - SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error - RemoveImage(ctx context.Context, playlistID string) error - - // Import - 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 - Repository() rest.Repository[model.Playlist] - TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository[model.PlaylistTrack] -} - -// ImageUploadService is a local interface satisfied by artwork.Uploader. -// Defined here to avoid an import cycle between core/artwork and core/playlists. -type ImageUploadService interface { - SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error) - RemoveImage(ctx context.Context, path string) error - EnqueueArtwork(ctx context.Context, entityType, entityID string) -} - -type playlists struct { - ds model.DataStore - imgUpload ImageUploadService - repo *playlistRepositoryWrapper -} - -func NewPlaylists(ds model.DataStore, imgUpload ImageUploadService) Playlists { - s := &playlists{ds: ds, imgUpload: imgUpload} - s.repo = &playlistRepositoryWrapper{PlaylistRepository: ds.Playlist(), service: s} - return s -} - -func InPath(folder model.Folder) bool { - if conf.Server.PlaylistsPath == "" { - return true - } - rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath()) - for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) { - if match, _ := doublestar.Match(path, rel); match { - return true - } - } - return false -} - -// --- Read operations --- - -func (s *playlists) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) { - return s.ds.Playlist().GetAll(ctx, options...) -} - -func (s *playlists) Get(ctx context.Context, id string) (*model.Playlist, error) { - return s.ds.Playlist().Get(ctx, id) -} - -func (s *playlists) GetWithTracks(ctx context.Context, id string) (*model.Playlist, error) { - return s.ds.Playlist().GetWithTracks(ctx, id, true, false) -} - -func (s *playlists) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) { - return s.ds.Playlist().GetPlaylists(ctx, mediaFileId) -} - -// Tracks scopes a repository to one playlist's tracks, for callers that page or stream them rather -// than loading every one like GetWithTracks. Gets first because PlaylistRepository.Tracks discards -// its error behind a nil (and warns), and this is probed with ids that are usually not playlists. -func (s *playlists) Tracks(ctx context.Context, id string) (model.PlaylistTrackRepository, error) { - repo := s.ds.Playlist() - if _, err := repo.Get(ctx, id); err != nil { - return nil, err - } - tracks := repo.Tracks(ctx, id, true) - if tracks == nil { - return nil, model.ErrNotFound - } - return tracks, nil -} - -// --- Mutation operations --- - -// Create creates a new playlist (when name is provided) or replaces tracks on an existing -// playlist (when playlistId is provided). This matches the Subsonic createPlaylist semantics. -func (s *playlists) Create(ctx context.Context, playlistId string, name string, ids []string) (string, error) { - usr, _ := request.UserFrom(ctx) - err := s.ds.WithTxImmediate(func(tx model.DataStore) error { - var pls *model.Playlist - var err error - - if playlistId != "" { - pls, err = tx.Playlist().Get(ctx, playlistId) - if err != nil { - return err - } - // Ownership first: a non-owner must get ErrNotAuthorized, not a read-only conflict. - if !usr.IsAdmin && pls.OwnerID != usr.ID { - return model.ErrNotAuthorized - } - if !pls.TracksEditable() { - return model.ErrPlaylistNotEditable - } - } else { - pls = &model.Playlist{Name: name} - pls.OwnerID = usr.ID - } - pls.Tracks = nil - pls.AddMediaFilesByID(ids) - - err = tx.Playlist().Put(ctx, pls) - playlistId = pls.ID - return err - }) - return playlistId, err -} - -func (s *playlists) Delete(ctx context.Context, id string) error { - pls, err := s.checkWritable(ctx, id) - if err != nil { - return err - } - - // Clean up custom cover image file if one exists - if path := pls.UploadedImagePath(); path != "" { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - log.Warn(ctx, "Failed to remove playlist image on delete", "path", path, err) - } - } - - return s.ds.Playlist().Delete(ctx, id) -} - -func (s *playlists) Update(ctx context.Context, playlistID string, - name *string, comment *string, public *bool, - idsToAdd []string, idxToRemove []int) error { - var pls *model.Playlist - var err error - hasTrackChanges := len(idsToAdd) > 0 || len(idxToRemove) > 0 - if hasTrackChanges { - pls, err = s.checkTracksEditable(ctx, playlistID) - } else { - pls, err = s.checkWritable(ctx, playlistID) - } - if err != nil { - return err - } - return s.ds.WithTxImmediate(func(tx model.DataStore) error { - repo := tx.Playlist() - - if len(idxToRemove) > 0 { - tracksRepo := repo.Tracks(ctx, playlistID, false) - // Convert 0-based indices to 1-based position IDs and delete them directly, - // avoiding the need to load all tracks into memory. - positions := make([]string, len(idxToRemove)) - for i, idx := range idxToRemove { - positions[i] = strconv.Itoa(idx + 1) - } - if err := tracksRepo.Delete(ctx, positions...); err != nil { - return err - } - if len(idsToAdd) > 0 { - if _, err := tracksRepo.Add(ctx, idsToAdd); err != nil { - return err - } - } - return s.updateMetadata(ctx, tx, pls, name, comment, public) - } - - if len(idsToAdd) > 0 { - if _, err := repo.Tracks(ctx, playlistID, false).Add(ctx, idsToAdd); err != nil { - return err - } - } - if name == nil && comment == nil && public == nil { - return nil - } - // Reuse the playlist from checkWritable (no tracks loaded, so Put only refreshes counters) - return s.updateMetadata(ctx, tx, pls, name, comment, public) - }) -} - -// --- Permission helpers --- - -// checkWritable fetches the playlist and verifies the current user can modify it. -func (s *playlists) checkWritable(ctx context.Context, id string) (*model.Playlist, error) { - pls, err := s.ds.Playlist().Get(ctx, id) - if err != nil { - return nil, err - } - usr, _ := request.UserFrom(ctx) - if !usr.IsAdmin && pls.OwnerID != usr.ID { - return nil, model.ErrNotAuthorized - } - return pls, nil -} - -// checkTracksEditable verifies the user owns the playlist and its tracks are editable. -func (s *playlists) checkTracksEditable(ctx context.Context, playlistID string) (*model.Playlist, error) { - pls, err := s.checkWritable(ctx, playlistID) - if err != nil { - return nil, err - } - if !pls.TracksEditable() { - return nil, model.ErrPlaylistNotEditable - } - return pls, nil -} - -// updateMetadata applies optional metadata changes to a playlist and persists it. -// Accepts a DataStore parameter so it can be used inside transactions. -// The caller is responsible for permission checks. -func (s *playlists) updateMetadata(ctx context.Context, ds model.DataStore, pls *model.Playlist, name *string, comment *string, public *bool) error { - if name != nil { - pls.Name = *name - } - if comment != nil { - pls.Comment = *comment - } - if public != nil { - pls.Public = *public - } - return ds.Playlist().Put(ctx, pls) -} - -// --- Track management operations --- - -func (s *playlists) AddTracks(ctx context.Context, playlistID string, ids []string) (int, error) { - if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { - return 0, err - } - return s.ds.Playlist().Tracks(ctx, playlistID, false).Add(ctx, ids) -} - -// InsertTracks adds tracks before the 1-based position pos; a position past the end appends. -func (s *playlists) InsertTracks(ctx context.Context, playlistID string, ids []string, pos int) (int, error) { - if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { - return 0, err - } - var count int - // Immediate: a deferred tx that reads before shifting fails at once with SQLITE_BUSY under - // concurrent writers instead of waiting for the lock. - err := s.ds.WithTxImmediate(func(tx model.DataStore) error { - var err error - count, err = tx.Playlist().Tracks(ctx, playlistID, false).Insert(ctx, ids, pos) - return err - }) - return count, err -} - -func (s *playlists) AddAlbums(ctx context.Context, playlistID string, albumIds []string) (int, error) { - if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { - return 0, err - } - return s.ds.Playlist().Tracks(ctx, playlistID, false).AddAlbums(ctx, albumIds) -} - -func (s *playlists) AddArtists(ctx context.Context, playlistID string, artistIds []string) (int, error) { - if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { - return 0, err - } - return s.ds.Playlist().Tracks(ctx, playlistID, false).AddArtists(ctx, artistIds) -} - -func (s *playlists) AddDiscs(ctx context.Context, playlistID string, discs []model.DiscID) (int, error) { - if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { - return 0, err - } - return s.ds.Playlist().Tracks(ctx, playlistID, false).AddDiscs(ctx, discs) -} - -func (s *playlists) RemoveTracks(ctx context.Context, playlistID string, trackIds []string) error { - if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { - return err - } - return s.ds.WithTx(func(tx model.DataStore) error { - return tx.Playlist().Tracks(ctx, playlistID, false).Delete(ctx, trackIds...) - }) -} - -func (s *playlists) ReorderTrack(ctx context.Context, playlistID string, pos int, newPos int) error { - if _, err := s.checkTracksEditable(ctx, playlistID); err != nil { - return err - } - return s.ds.WithTxImmediate(func(tx model.DataStore) error { - return tx.Playlist().Tracks(ctx, playlistID, false).Reorder(ctx, pos, newPos) - }) -} - -// --- Cover art operations --- - -func (s *playlists) SetImage(ctx context.Context, playlistID string, reader io.Reader, ext string) error { - pls, err := s.checkWritable(ctx, playlistID) - if err != nil { - return err - } - - oldPath := pls.UploadedImagePath() - filename, err := s.imgUpload.SetImage(ctx, consts.EntityPlaylist, pls.ID, pls.Name, oldPath, reader, ext) - if err != nil { - return err - } - - pls.UploadedImage = filename - if err := s.ds.Playlist().Put(ctx, pls); err != nil { - return err - } - s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID) - return nil -} - -func (s *playlists) RemoveImage(ctx context.Context, playlistID string) error { - pls, err := s.checkWritable(ctx, playlistID) - if err != nil { - return err - } - - if err := s.imgUpload.RemoveImage(ctx, pls.UploadedImagePath()); err != nil { - return err - } - - pls.UploadedImage = "" - if err := s.ds.Playlist().Put(ctx, pls); err != nil { - return err - } - s.imgUpload.EnqueueArtwork(ctx, consts.EntityPlaylist, pls.ID) - return nil -} diff --git a/core/playlists/playlists_suite_test.go b/core/playlists/playlists_suite_test.go deleted file mode 100644 index b57248490..000000000 --- a/core/playlists/playlists_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package playlists_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestPlaylists(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Playlists Suite") -} diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go deleted file mode 100644 index ec44de3bc..000000000 --- a/core/playlists/playlists_test.go +++ /dev/null @@ -1,524 +0,0 @@ -package playlists_test - -import ( - "context" - "os" - "path/filepath" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Playlists", func() { - var ds *tests.MockDataStore - var ps playlists.Playlists - var mockPlsRepo *tests.MockPlaylistRepo - ctx := context.Background() - - BeforeEach(func() { - mockPlsRepo = tests.CreateMockPlaylistRepo() - ds = &tests.MockDataStore{ - MockedPlaylist: mockPlsRepo, - MockedLibrary: &tests.MockLibraryRepo{}, - } - ctx = request.WithUser(ctx, model.User{ID: "123"}) - }) - - Describe("Delete", func() { - var mockTracks *tests.MockPlaylistTrackRepo - - BeforeEach(func() { - mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 3} - mockPlsRepo.Data = map[string]*model.Playlist{ - "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, - } - mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - }) - - It("allows owner to delete their playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.Delete(ctx, "pls-1") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1")) - }) - - It("allows admin to delete any playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) - err := ps.Delete(ctx, "pls-1") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1")) - }) - - It("denies non-owner, non-admin from deleting", func() { - ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - err := ps.Delete(ctx, "pls-1") - Expect(err).To(MatchError(model.ErrNotAuthorized)) - Expect(mockPlsRepo.Deleted).To(BeEmpty()) - }) - - It("returns error when playlist not found", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.Delete(ctx, "nonexistent") - Expect(err).To(Equal(model.ErrNotFound)) - }) - }) - - Describe("Tracks", func() { - var mockTracks *tests.MockPlaylistTrackRepo - - BeforeEach(func() { - mockTracks = &tests.MockPlaylistTrackRepo{} - mockPlsRepo.Data = map[string]*model.Playlist{ - "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, - } - mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - }) - - It("returns the playlist's track repository", func() { - Expect(ps.Tracks(ctx, "pls-1")).To(BeIdenticalTo(mockTracks)) - }) - - It("returns ErrNotFound for an unknown or invisible playlist", func() { - _, err := ps.Tracks(ctx, "nonexistent") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("Create", func() { - BeforeEach(func() { - mockPlsRepo.Data = map[string]*model.Playlist{ - "pls-1": {ID: "pls-1", Name: "Existing", OwnerID: "user-1"}, - "pls-2": {ID: "pls-2", Name: "Other's", OwnerID: "other-user"}, - "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", - Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, - "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true}, - "pls-synced-other": {ID: "pls-synced-other", Name: "Other's Synced", OwnerID: "other-user", Sync: true, Public: true}, - } - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - }) - - It("creates a new playlist with owner set from context", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - id, err := ps.Create(ctx, "", "New Playlist", []string{"song-1", "song-2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(id).ToNot(BeEmpty()) - Expect(mockPlsRepo.Last.Name).To(Equal("New Playlist")) - Expect(mockPlsRepo.Last.OwnerID).To(Equal("user-1")) - }) - - It("replaces tracks on existing playlist when owner matches", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - id, err := ps.Create(ctx, "pls-1", "", []string{"song-3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(id).To(Equal("pls-1")) - Expect(mockPlsRepo.Last.Tracks).To(HaveLen(1)) - }) - - It("allows admin to replace tracks on any playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) - id, err := ps.Create(ctx, "pls-2", "", []string{"song-3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(id).To(Equal("pls-2")) - }) - - It("denies non-owner, non-admin from replacing tracks on existing playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - _, err := ps.Create(ctx, "pls-2", "", []string{"song-3"}) - Expect(err).To(MatchError(model.ErrNotAuthorized)) - }) - - It("returns error when existing playlistId not found", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - _, err := ps.Create(ctx, "nonexistent", "", []string{"song-1"}) - Expect(err).To(Equal(model.ErrNotFound)) - }) - - It("denies replacing tracks on a smart playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - _, err := ps.Create(ctx, "pls-smart", "", []string{"song-1"}) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) - }) - - It("denies replacing tracks on a synced playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - _, err := ps.Create(ctx, "pls-synced", "", []string{"song-1"}) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) - }) - - It("denies a non-owner with authorization, not a conflict, on a public synced playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - _, err := ps.Create(ctx, "pls-synced-other", "", []string{"song-1"}) - Expect(err).To(MatchError(model.ErrNotAuthorized)) - }) - }) - - Describe("Update", func() { - var mockTracks *tests.MockPlaylistTrackRepo - - BeforeEach(func() { - mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2} - mockPlsRepo.Data = map[string]*model.Playlist{ - "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, - "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, - "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", - Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, - "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true}, - } - mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - }) - - It("allows owner to update their playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil) - Expect(err).ToNot(HaveOccurred()) - }) - - It("allows admin to update any playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) - err := ps.Update(ctx, "pls-other", new("Updated Name"), nil, nil, nil, nil) - Expect(err).ToNot(HaveOccurred()) - }) - - It("denies non-owner, non-admin from updating", func() { - ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil) - Expect(err).To(MatchError(model.ErrNotAuthorized)) - }) - - It("returns error when playlist not found", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.Update(ctx, "nonexistent", new("Updated Name"), nil, nil, nil, nil) - Expect(err).To(Equal(model.ErrNotFound)) - }) - - It("denies adding tracks to a smart playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.Update(ctx, "pls-smart", nil, nil, nil, []string{"song-1"}, nil) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) - }) - - It("denies removing tracks from a smart playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.Update(ctx, "pls-smart", nil, nil, nil, nil, []int{0}) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) - }) - - It("allows metadata updates on a smart playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil) - Expect(err).ToNot(HaveOccurred()) - }) - - It("denies adding tracks to a synced playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.Update(ctx, "pls-synced", nil, nil, nil, []string{"song-1"}, nil) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) - }) - - It("allows metadata updates on a synced playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.Update(ctx, "pls-synced", new("Renamed Synced"), nil, nil, nil, nil) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("AddTracks", func() { - var mockTracks *tests.MockPlaylistTrackRepo - - BeforeEach(func() { - mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2} - mockPlsRepo.Data = map[string]*model.Playlist{ - "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, - "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", - Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, - "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, - "pls-synced": {ID: "pls-synced", Name: "Synced", OwnerID: "user-1", Sync: true}, - } - mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - }) - - It("allows owner to add tracks", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - count, err := ps.AddTracks(ctx, "pls-1", []string{"song-1", "song-2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(2)) - Expect(mockTracks.AddedIds).To(ConsistOf("song-1", "song-2")) - }) - - It("allows admin to add tracks to any playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) - count, err := ps.AddTracks(ctx, "pls-other", []string{"song-1"}) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(2)) - }) - - It("denies non-owner, non-admin", func() { - ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - _, err := ps.AddTracks(ctx, "pls-1", []string{"song-1"}) - Expect(err).To(MatchError(model.ErrNotAuthorized)) - }) - - It("denies editing smart playlists", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - _, err := ps.AddTracks(ctx, "pls-smart", []string{"song-1"}) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) - }) - - It("denies editing synced playlists", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - _, err := ps.AddTracks(ctx, "pls-synced", []string{"song-1"}) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) - }) - - It("returns error when playlist not found", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - _, err := ps.AddTracks(ctx, "nonexistent", []string{"song-1"}) - Expect(err).To(Equal(model.ErrNotFound)) - }) - }) - - Describe("RemoveTracks", func() { - var mockTracks *tests.MockPlaylistTrackRepo - - BeforeEach(func() { - mockTracks = &tests.MockPlaylistTrackRepo{} - mockPlsRepo.Data = map[string]*model.Playlist{ - "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, - "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", - Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, - } - mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - }) - - It("allows owner to remove tracks", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1", "track-2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(mockTracks.DeletedIds).To(ConsistOf("track-1", "track-2")) - }) - - It("denies on smart playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.RemoveTracks(ctx, "pls-smart", []string{"track-1"}) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) - }) - - It("denies non-owner", func() { - ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - err := ps.RemoveTracks(ctx, "pls-1", []string{"track-1"}) - Expect(err).To(MatchError(model.ErrNotAuthorized)) - }) - }) - - Describe("InsertTracks", func() { - var mockTracks *tests.MockPlaylistTrackRepo - - BeforeEach(func() { - mockTracks = &tests.MockPlaylistTrackRepo{AddCount: 2} - mockPlsRepo.Data = map[string]*model.Playlist{ - "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, - "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", - Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, - } - mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - }) - - It("inserts the tracks at the given position for the owner", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - count, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1", "song-2"}, 3) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(2)) - Expect(mockTracks.AddedIds).To(Equal([]string{"song-1", "song-2"})) - Expect(mockTracks.InsertPos).To(Equal(3)) - }) - - It("denies non-owner, non-admin", func() { - ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - _, err := ps.InsertTracks(ctx, "pls-1", []string{"song-1"}, 1) - Expect(err).To(MatchError(model.ErrNotAuthorized)) - Expect(mockTracks.AddedIds).To(BeEmpty()) - }) - - It("denies editing smart playlists", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - _, err := ps.InsertTracks(ctx, "pls-smart", []string{"song-1"}, 1) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) - }) - }) - - Describe("ReorderTrack", func() { - var mockTracks *tests.MockPlaylistTrackRepo - - BeforeEach(func() { - mockTracks = &tests.MockPlaylistTrackRepo{} - mockPlsRepo.Data = map[string]*model.Playlist{ - "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, - "pls-smart": {ID: "pls-smart", Name: "Smart", OwnerID: "user-1", - Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}}, - } - mockPlsRepo.TracksRepo = mockTracks - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - }) - - It("allows owner to reorder", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.ReorderTrack(ctx, "pls-1", 1, 3) - Expect(err).ToNot(HaveOccurred()) - Expect(mockTracks.Reordered).To(BeTrue()) - }) - - It("denies on smart playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.ReorderTrack(ctx, "pls-smart", 1, 3) - Expect(err).To(MatchError(model.ErrPlaylistNotEditable)) - }) - }) - - Describe("SetImage", func() { - var tmpDir string - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - - mockPlsRepo.Data = map[string]*model.Playlist{ - "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, - "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, - } - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - }) - - It("saves image file and updates UploadedImage", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - reader := strings.NewReader("fake image data") - err := ps.SetImage(ctx, "pls-1", reader, ".jpg") - Expect(err).ToNot(HaveOccurred()) - - Expect(mockPlsRepo.Last.UploadedImage).To(Equal("pls-1_my_playlist.jpg")) - absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg") - data, err := os.ReadFile(absPath) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(Equal("fake image data")) - }) - - It("removes old image when replacing", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - - // Upload first image - err := ps.SetImage(ctx, "pls-1", strings.NewReader("first"), ".png") - Expect(err).ToNot(HaveOccurred()) - oldPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.png") - Expect(oldPath).To(BeAnExistingFile()) - - // Upload replacement image - err = ps.SetImage(ctx, "pls-1", strings.NewReader("second"), ".jpg") - Expect(err).ToNot(HaveOccurred()) - Expect(oldPath).ToNot(BeAnExistingFile()) - newPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1_my_playlist.jpg") - Expect(newPath).To(BeAnExistingFile()) - }) - - It("allows admin to set image on any playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) - err := ps.SetImage(ctx, "pls-other", strings.NewReader("data"), ".jpg") - Expect(err).ToNot(HaveOccurred()) - }) - - It("denies non-owner", func() { - ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - err := ps.SetImage(ctx, "pls-1", strings.NewReader("data"), ".jpg") - Expect(err).To(MatchError(model.ErrNotAuthorized)) - }) - - It("returns error when playlist not found", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.SetImage(ctx, "nonexistent", strings.NewReader("data"), ".jpg") - Expect(err).To(Equal(model.ErrNotFound)) - }) - }) - - Describe("RemoveImage", func() { - var tmpDir string - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - - // Create a real image file on disk - imgDir := filepath.Join(tmpDir, "artwork", "playlist") - Expect(os.MkdirAll(imgDir, 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(imgDir, "pls-1.jpg"), []byte("img data"), 0600)).To(Succeed()) - - mockPlsRepo.Data = map[string]*model.Playlist{ - "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1", UploadedImage: "pls-1.jpg"}, - "pls-empty": {ID: "pls-empty", Name: "No Cover", OwnerID: "user-1"}, - "pls-other": {ID: "pls-other", Name: "Other's", OwnerID: "other-user"}, - } - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - }) - - It("removes file and clears UploadedImage", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.RemoveImage(ctx, "pls-1") - Expect(err).ToNot(HaveOccurred()) - - Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty()) - absPath := filepath.Join(tmpDir, "artwork", "playlist", "pls-1.jpg") - Expect(absPath).ToNot(BeAnExistingFile()) - }) - - It("succeeds even if playlist has no image", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.RemoveImage(ctx, "pls-empty") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.UploadedImage).To(BeEmpty()) - }) - - It("clears the resolved artwork state and re-queues after removing an upload", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - Expect(ds.Artwork().PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "pl", ItemID: "pls-1", Hash: "oldhash", Source: "upload", - })).To(Succeed()) - - Expect(ps.RemoveImage(ctx, "pls-1")).To(Succeed()) - - _, err := ds.Artwork().GetItemArtwork(ctx, model.KindPlaylistArtwork, "pls-1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - queued, _ := ds.ArtworkQueue().DequeueBatch(ctx, 100) - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "pl"), - HaveField("ItemID", "pls-1"), - HaveField("Priority", model.ArtworkPriorityBump), - ))) - }) - - It("denies non-owner", func() { - ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - err := ps.RemoveImage(ctx, "pls-1") - Expect(err).To(MatchError(model.ErrNotAuthorized)) - }) - - It("returns error when playlist not found", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - err := ps.RemoveImage(ctx, "nonexistent") - Expect(err).To(Equal(model.ErrNotFound)) - }) - }) -}) diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go deleted file mode 100644 index f8e8a9d41..000000000 --- a/core/playlists/rest_adapter.go +++ /dev/null @@ -1,187 +0,0 @@ -package playlists - -import ( - "context" - "reflect" - "strings" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" -) - -// --- REST adapter (follows Share/Library pattern) --- - -func (s *playlists) Repository() rest.Repository[model.Playlist] { - return s.repo -} - -// playlistRepositoryWrapper wraps the playlist repository as a thin REST-to-service adapter, -// delegating to service methods for all mutations. -type playlistRepositoryWrapper struct { - model.PlaylistRepository - service *playlists -} - -var _ rest.Persistable[model.Playlist] = (*playlistRepositoryWrapper)(nil) - -func (r *playlistRepositoryWrapper) Save(ctx context.Context, entity *model.Playlist) (string, error) { - return r.service.savePlaylist(ctx, entity) -} - -func (r *playlistRepositoryWrapper) Update(ctx context.Context, id string, entity model.Playlist, cols ...string) error { - return r.service.updatePlaylistEntity(ctx, id, &entity, cols...) -} - -func (r *playlistRepositoryWrapper) Delete(ctx context.Context, ids ...string) error { - for _, id := range ids { - if err := r.service.Delete(ctx, id); err != nil { - return err - } - } - return nil -} - -func (s *playlists) TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository[model.PlaylistTrack] { - repo := s.ds.Playlist() - tracks := repo.Tracks(ctx, playlistId, refreshSmartPlaylist) - if tracks == nil { - return nil - } - return tracks -} - -// savePlaylist creates a new playlist, assigning the owner from context. -// Only Name, Comment, Public, and Rules are user-settable via the REST API. -func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (string, error) { - usr, _ := request.UserFrom(ctx) - pls.OwnerID = usr.ID - pls.ID = "" // Force new creation - pls.Path = "" // Server-managed (M3U file path) - pls.Sync = false // Server-managed (M3U sync flag) - pls.UploadedImage = "" // Managed by image upload endpoint - pls.ExternalImageURL = "" // Managed by M3U import / plugins only - pls.EvaluatedAt = nil // Server-managed - err := s.ds.Playlist().Put(ctx, pls) - if err != nil { - return "", err - } - return pls.ID, nil -} - -// updatePlaylistEntity updates playlist metadata with permission checks. -// Used by the REST API wrapper. -// -// cols names the fields the client actually sent in the JSON body (extracted by -// rest.Put). When non-empty, fields outside cols are not considered changed and -// are left untouched — this prevents partial requests like bulk "Make Public" -// (body: {"public": true}) from wiping fields that just happen to be zero in -// the deserialized entity (see issue #5541). An empty cols means "treat the -// entity as a complete record" — preserved for callers that don't use the REST -// wrapper. -func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error { - current, err := s.checkWritable(ctx, id) - if err != nil { - return err - } - - sent := sentFields(cols) - - usr, _ := request.UserFrom(ctx) - ownerChanged := sent("ownerId") && entity.OwnerID != "" && entity.OwnerID != current.OwnerID - if !usr.IsAdmin && ownerChanged { - return rest.ErrPermissionDenied - } - - nameChanged := sent("name") && entity.Name != current.Name - commentChanged := sent("comment") && entity.Comment != current.Comment - rulesChanged := sent("rules") && !rulesEqual(current.Rules, entity.Rules) - - if nameChanged || commentChanged || ownerChanged || rulesChanged { - return s.applyContentUpdate(ctx, current, entity, sent, - nameChanged, commentChanged, ownerChanged, rulesChanged) - } - return s.applyFlagsOnly(ctx, current, entity, sent) -} - -// applyContentUpdate handles updates that change at least one of name/comment/ -// owner/rules. It goes through updateMetadata, which always bumps updatedAt -// (invalidating cached cover-art URLs). namePtr/commentPtr are nil when the -// field is absent from the request OR present-but-unchanged (so updateMetadata -// skips them); publicPtr is nil only when public is absent from the request -// (an idempotent public value is still forwarded). -func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *model.Playlist, - sent func(string) bool, nameChanged, commentChanged, ownerChanged, rulesChanged bool, -) error { - if ownerChanged { - current.OwnerID = entity.OwnerID - } - if rulesChanged { - current.Rules = entity.Rules - current.EvaluatedAt = nil // force re-evaluation on next read - current.ImportedHash = "" // rules no longer match the source file; next scan must re-import it - } - if sent("sync") && current.Path != "" && current.Sync != entity.Sync { - current.Sync = entity.Sync - } - var namePtr, commentPtr *string - var publicPtr *bool - if nameChanged { - namePtr = &entity.Name - } - if commentChanged { - commentPtr = &entity.Comment - } - if sent("public") { - publicPtr = &entity.Public - } - return s.updateMetadata(ctx, s.ds, current, namePtr, commentPtr, publicPtr) -} - -// applyFlagsOnly handles updates that only toggle sync/public — skips -// updatedAt so cover art URLs stay stable. -func (s *playlists) applyFlagsOnly(ctx context.Context, current, entity *model.Playlist, - sent func(string) bool, -) error { - var updateCols []string - if sent("sync") && current.Path != "" && current.Sync != entity.Sync { - current.Sync = entity.Sync - updateCols = append(updateCols, "sync") - } - if sent("public") && current.Public != entity.Public { - current.Public = entity.Public - updateCols = append(updateCols, "public") - } - if len(updateCols) == 0 { - return nil - } - return s.ds.Playlist().Put(ctx, current, updateCols...) -} - -// sentFields returns a predicate that reports whether a JSON field was present -// in the request body. Matching is case-insensitive to mirror Go's json -// decoder, which populates struct fields from case-variant keys like -// {"Name":"x"} or {"OWNERID":"y"}. An empty cols list means "treat the entity -// as a full record" — every field is considered sent. -func sentFields(cols []string) func(string) bool { - if len(cols) == 0 { - return func(string) bool { return true } - } - set := slice.ToMap(cols, func(c string) (string, struct{}) { return strings.ToLower(c), struct{}{} }) - return func(field string) bool { - _, ok := set[strings.ToLower(field)] - return ok - } -} - -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 deleted file mode 100644 index e82dd9df5..000000000 --- a/core/playlists/rest_adapter_test.go +++ /dev/null @@ -1,460 +0,0 @@ -package playlists_test - -import ( - "context" - "time" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("REST Adapter", func() { - var ds *tests.MockDataStore - var ps playlists.Playlists - var mockPlsRepo *tests.MockPlaylistRepo - ctx := context.Background() - - BeforeEach(func() { - mockPlsRepo = tests.CreateMockPlaylistRepo() - ds = &tests.MockDataStore{ - MockedPlaylist: mockPlsRepo, - MockedLibrary: &tests.MockLibraryRepo{}, - } - ctx = request.WithUser(ctx, model.User{ID: "123"}) - }) - - Describe("NewRepository", func() { - var repo rest.Persistable[model.Playlist] - - BeforeEach(func() { - mockPlsRepo.Data = map[string]*model.Playlist{ - "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, - } - ps = playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - }) - - Describe("Save", func() { - It("sets the owner from the context user", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) - pls := &model.Playlist{Name: "New Playlist"} - id, err := repo.Save(ctx, pls) - Expect(err).ToNot(HaveOccurred()) - Expect(id).ToNot(BeEmpty()) - Expect(pls.OwnerID).To(Equal("user-1")) - }) - - It("forces a new creation by clearing ID", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) - pls := &model.Playlist{ID: "should-be-cleared", Name: "New"} - _, err := repo.Save(ctx, pls) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.ID).ToNot(Equal("should-be-cleared")) - }) - - It("clears server-managed fields to prevent injection via REST API", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) - pls := &model.Playlist{ - Name: "Legit Playlist", - Comment: "A comment", - Public: true, - Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "test"}}, - Path: "/some/path/playlist.m3u", - Sync: true, - UploadedImage: "injected-image-path", - ExternalImageURL: "http://evil.example.com/ssrf", - EvaluatedAt: new(time.Now()), - } - _, err := repo.Save(ctx, pls) - Expect(err).ToNot(HaveOccurred()) - - saved := mockPlsRepo.Last - // User-settable fields are preserved - Expect(saved.Name).To(Equal("Legit Playlist")) - Expect(saved.Comment).To(Equal("A comment")) - Expect(saved.Public).To(BeTrue()) - Expect(saved.Rules).ToNot(BeNil()) - // Server-managed fields are cleared - Expect(saved.Path).To(BeEmpty()) - Expect(saved.Sync).To(BeFalse()) - Expect(saved.UploadedImage).To(BeEmpty()) - Expect(saved.ExternalImageURL).To(BeEmpty()) - Expect(saved.EvaluatedAt).To(BeNil()) - }) - }) - - Describe("Update", func() { - It("allows owner to update their playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) - pls := &model.Playlist{Name: "Updated"} - err := repo.Update(ctx, "pls-1", *pls) - Expect(err).ToNot(HaveOccurred()) - }) - - It("allows admin to update any playlist", func() { - ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) - pls := &model.Playlist{Name: "Updated"} - err := repo.Update(ctx, "pls-1", *pls) - Expect(err).ToNot(HaveOccurred()) - }) - - It("denies non-owner, non-admin", func() { - ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) - pls := &model.Playlist{Name: "Updated"} - err := repo.Update(ctx, "pls-1", *pls) - Expect(err).To(Equal(rest.ErrPermissionDenied)) - }) - - It("denies regular user from changing ownership", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) - pls := &model.Playlist{Name: "Updated", OwnerID: "other-user"} - err := repo.Update(ctx, "pls-1", *pls) - Expect(err).To(Equal(rest.ErrPermissionDenied)) - }) - - DescribeTable("denies regular user from changing ownership under any case-variant JSON key", - func(colName string) { - // rest.Put's field-name extraction is case-sensitive, but Go's - // json decoder is case-insensitive on struct fields, so any - // {"OwnerId":"x"} / {"OWNERID":"x"} / {"ownerid":"x"} populates - // entity.OwnerID. sentFields normalizes both sides so the - // permission gate fires regardless of casing. - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) - pls := &model.Playlist{OwnerID: "other-user"} - err := repo.Update(ctx, "pls-1", *pls, colName) - Expect(err).To(Equal(rest.ErrPermissionDenied)) - }, - Entry("canonical camelCase", "ownerId"), - Entry("PascalCase", "OwnerId"), - Entry("all upper", "OWNERID"), - Entry("all lower", "ownerid"), - ) - - It("updates smart playlist rules", func() { - mockPlsRepo.Data["smart-1"] = &model.Playlist{ - ID: "smart-1", - Name: "Smart Playlist", - OwnerID: "user-1", - Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}}, - } - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) - newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}} - pls := &model.Playlist{Name: "Smart Playlist", Rules: newRules} - err := repo.Update(ctx, "smart-1", *pls) - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) - }) - - It("invalidates the imported hash when rules change, so the next scan re-syncs the file", func() { - mockPlsRepo.Data["smart-1"] = &model.Playlist{ - ID: "smart-1", - Name: "Smart Playlist", - OwnerID: "user-1", - Path: "/music/smart.nsp", - Sync: true, - ImportedHash: hashOf("file content"), - Rules: &criteria.Criteria{Expression: criteria.Contains{"title": "old"}}, - } - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) - newRules := &criteria.Criteria{Expression: criteria.Contains{"title": "new"}} - pls := &model.Playlist{Rules: newRules} - err := repo.Update(ctx, "smart-1", *pls, "rules") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.ImportedHash).To(BeEmpty()) - }) - - It("allows toggling sync for file-backed playlists", func() { - originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) - mockPlsRepo.Data["file-pls"] = &model.Playlist{ - 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.Repository().(rest.Persistable[model.Playlist]) - pls := &model.Playlist{Name: "File Playlist", Sync: false} - err := repo.Update(ctx, "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.Repository().(rest.Persistable[model.Playlist]) - pls := &model.Playlist{Name: "Manual Playlist", Sync: true} - err := repo.Update(ctx, "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.Repository().(rest.Persistable[model.Playlist]) - pls := &model.Playlist{Name: "My Playlist", Public: true} - err := repo.Update(ctx, "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.Repository().(rest.Persistable[model.Playlist]) - pls := &model.Playlist{Name: "New Name", Sync: false} - err := repo.Update(ctx, "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.Repository().(rest.Persistable[model.Playlist]) - pls := &model.Playlist{Name: "Updated"} - err := repo.Update(ctx, "nonexistent", *pls) - Expect(err).To(Equal(rest.ErrNotFound)) - }) - - // Regression tests for #5541: partial REST updates (e.g. bulk "Make Public") - // must only touch the fields the client actually sent. The cols list from - // rest.Put names those fields; fields outside it must be left alone, even - // when the deserialized entity has zero values for them. - Context("with partial updates (cols)", func() { - BeforeEach(func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - mockPlsRepo.Data["partial"] = &model.Playlist{ - ID: "partial", - Name: "Original Name", - Comment: "Original comment", - OwnerID: "user-1", - Public: false, - } - }) - - It("preserves name and comment when only public is sent (bulk Make Public)", func() { - repo = ps.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "partial", model.Playlist{Public: true}, "public") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.Name).To(Equal("Original Name")) - Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) - Expect(mockPlsRepo.Last.Public).To(BeTrue()) - }) - - It("preserves name when only sync is sent for a file-backed playlist", func() { - mockPlsRepo.Data["file-partial"] = &model.Playlist{ - ID: "file-partial", - Name: "Keep Me", - OwnerID: "user-1", - Path: "/music/p.m3u", - Sync: true, - } - repo = ps.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "file-partial", model.Playlist{Sync: false}, "sync") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.Name).To(Equal("Keep Me")) - Expect(mockPlsRepo.Last.Sync).To(BeFalse()) - }) - - It("renames the playlist when only name is sent", func() { - repo = ps.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "partial", model.Playlist{Name: "Renamed"}, "name") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.Name).To(Equal("Renamed")) - Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) - Expect(mockPlsRepo.Last.Public).To(BeFalse()) - }) - - It("clears the comment when an empty comment is sent explicitly", func() { - repo = ps.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "partial", model.Playlist{Comment: ""}, "comment") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.Comment).To(BeEmpty()) - Expect(mockPlsRepo.Last.Name).To(Equal("Original Name")) - }) - - It("updates rules-only on a smart playlist (Feishin-style edit)", func() { - mockPlsRepo.Data["smart-partial"] = &model.Playlist{ - ID: "smart-partial", - Name: "Smart Original", - Comment: "smart comment", - OwnerID: "user-1", - Public: true, - Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, - } - repo = ps.Repository().(rest.Persistable[model.Playlist]) - newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}, Sort: "year DESC"} - err := repo.Update(ctx, "smart-partial", model.Playlist{Rules: newRules}, "rules") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) - Expect(mockPlsRepo.Last.Name).To(Equal("Smart Original")) - Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment")) - Expect(mockPlsRepo.Last.Public).To(BeTrue()) - }) - - It("resets EvaluatedAt when rules change", func() { - evaluatedAt := time.Now().Add(-1 * time.Hour) - mockPlsRepo.Data["smart-reset"] = &model.Playlist{ - ID: "smart-reset", - Name: "Smart", - OwnerID: "user-1", - Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, - EvaluatedAt: &evaluatedAt, - } - repo = ps.Repository().(rest.Persistable[model.Playlist]) - newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}} - err := repo.Update(ctx, "smart-reset", model.Playlist{Rules: newRules}, "rules") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.EvaluatedAt).To(BeNil()) - }) - - It("keeps EvaluatedAt when rules are not changed", func() { - evaluatedAt := time.Now().Add(-1 * time.Hour) - mockPlsRepo.Data["smart-keep"] = &model.Playlist{ - ID: "smart-keep", - Name: "Smart", - OwnerID: "user-1", - Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, - EvaluatedAt: &evaluatedAt, - } - repo = ps.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "smart-keep", model.Playlist{Name: "Renamed Smart"}, "name") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.EvaluatedAt).ToNot(BeNil()) - Expect(*mockPlsRepo.Last.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second)) - }) - - It("updates name and rules together (smart-playlist Edit form)", func() { - mockPlsRepo.Data["smart-edit"] = &model.Playlist{ - ID: "smart-edit", - Name: "Smart Original", - Comment: "smart comment", - OwnerID: "user-1", - Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, - } - repo = ps.Repository().(rest.Persistable[model.Playlist]) - newRules := &criteria.Criteria{Expression: criteria.Is{"artist": "Miles Davis"}, Sort: "album"} - err := repo.Update(ctx, "smart-edit", - model.Playlist{Name: "Smart Renamed", Rules: newRules}, - "name", "rules") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.Name).To(Equal("Smart Renamed")) - Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) - Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment")) - }) - - It("does not bump the saved rules on an idempotent rules-only PUT", func() { - rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} - mockPlsRepo.Data["smart-idempotent"] = &model.Playlist{ - ID: "smart-idempotent", - Name: "Smart Idempotent", - OwnerID: "user-1", - Rules: rules, - } - repo = ps.Repository().(rest.Persistable[model.Playlist]) - // Same rules sent back — rulesEqual should report no change and - // the request should no-op (no Put call). - sameRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} - err := repo.Update(ctx, "smart-idempotent", model.Playlist{Rules: sameRules}, "rules") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last).To(BeNil()) // no Put happened - }) - - It("preserves rules when only public is sent (smart playlist + bulk Make Public)", func() { - rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} - mockPlsRepo.Data["smart-public"] = &model.Playlist{ - ID: "smart-public", - Name: "Smart Public", - OwnerID: "user-1", - Public: false, - Rules: rules, - } - repo = ps.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "smart-public", model.Playlist{Public: true}, "public") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.Public).To(BeTrue()) - Expect(mockPlsRepo.Last.Rules).To(Equal(rules)) - Expect(mockPlsRepo.Last.Name).To(Equal("Smart Public")) - }) - - It("does not treat a missing ownerId as an ownership transfer attempt", func() { - // A non-admin user sending only {public:true} should not be blocked - // just because OwnerID is the zero value in the deserialized entity. - repo = ps.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "partial", model.Playlist{Public: true}, "public") - Expect(err).ToNot(HaveOccurred()) - }) - - It("matches cols case-insensitively (mirrors json decoder behavior)", func() { - // Go's json decoder populates struct fields from case-variant keys - // like {"Name":"x"}, but rest.Put's field-name extraction is - // case-sensitive. sentFields normalizes both sides so a request - // with {"Name":"Renamed"} is honored, not silently ignored. - repo = ps.Repository().(rest.Persistable[model.Playlist]) - err := repo.Update(ctx, "partial", model.Playlist{Name: "Renamed"}, "Name") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Last.Name).To(Equal("Renamed")) - Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) - }) - }) - }) - - Describe("Delete", func() { - It("delegates to service Delete with permission checks", func() { - ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) - err := repo.Delete(ctx, "pls-1") - Expect(err).ToNot(HaveOccurred()) - Expect(mockPlsRepo.Deleted).To(ContainElement("pls-1")) - }) - - It("denies non-owner", func() { - ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - repo = ps.Repository().(rest.Persistable[model.Playlist]) - err := repo.Delete(ctx, "pls-1") - Expect(err).To(Equal(rest.ErrPermissionDenied)) - }) - }) - }) -}) diff --git a/core/playlists_test.go b/core/playlists_test.go new file mode 100644 index 000000000..0c828e6a7 --- /dev/null +++ b/core/playlists_test.go @@ -0,0 +1,74 @@ +package core + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Playlists", func() { + var ds model.DataStore + var ps Playlists + ctx := context.Background() + + BeforeEach(func() { + ds = &tests.MockDataStore{ + MockedMediaFile: &mockedMediaFile{}, + MockedPlaylist: &mockedPlaylist{}, + } + }) + + Describe("ImportFile", func() { + BeforeEach(func() { + ps = NewPlaylists(ds) + }) + + It("parses well-formed playlists", func() { + pls, err := ps.ImportFile(ctx, "tests/fixtures", "playlists/pls1.m3u") + Expect(err).To(BeNil()) + Expect(pls.Tracks).To(HaveLen(3)) + Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/test.mp3")) + Expect(pls.Tracks[1].Path).To(Equal("tests/fixtures/test.ogg")) + Expect(pls.Tracks[2].Path).To(Equal("/tests/fixtures/01 Invisible (RED) Edit Version.mp3")) + }) + + It("parses playlists using LF ending", func() { + pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "lf-ended.m3u") + Expect(err).To(BeNil()) + Expect(pls.Tracks).To(HaveLen(2)) + }) + + It("parses playlists using CR ending (old Mac format)", func() { + pls, err := ps.ImportFile(ctx, "tests/fixtures/playlists", "cr-ended.m3u") + Expect(err).To(BeNil()) + Expect(pls.Tracks).To(HaveLen(2)) + }) + + }) +}) + +type mockedMediaFile struct { + model.MediaFileRepository +} + +func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) { + return &model.MediaFile{ + ID: "123", + Path: s, + }, nil +} + +type mockedPlaylist struct { + model.PlaylistRepository +} + +func (r *mockedPlaylist) FindByPath(string) (*model.Playlist, error) { + return nil, model.ErrNotFound +} + +func (r *mockedPlaylist) Put(*model.Playlist) error { + return nil +} diff --git a/core/publicurl/publicurl.go b/core/publicurl/publicurl.go deleted file mode 100644 index 345606324..000000000 --- a/core/publicurl/publicurl.go +++ /dev/null @@ -1,85 +0,0 @@ -package publicurl - -import ( - "cmp" - "context" - "net/url" - "path" - "strconv" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/gg" -) - -// ImageURL generates a public URL for artwork images. -// It creates a signed token for the artwork ID and builds a complete public URL. -func ImageURL(ctx context.Context, artID model.ArtworkID, size int) string { - token, _ := auth.CreatePublicToken(auth.Claims{ID: artID.String()}) - uri := path.Join(consts.URLPathPublicImages, token) - params := url.Values{} - if size > 0 { - params.Add("size", strconv.Itoa(size)) - } - return PublicURL(ctx, uri, params) -} - -// PublicURL builds a full URL for public-facing resources. -// It uses ShareURL from config if available, otherwise falls back to the address the -// client used to reach the server, recorded in the context. -func PublicURL(ctx context.Context, u string, params url.Values) string { - if conf.Server.ShareURL == "" { - return AbsoluteURL(ctx, u, params) - } - shareUrl, err := url.Parse(conf.Server.ShareURL) - if err != nil { - return AbsoluteURL(ctx, u, params) - } - buildUrl, err := url.Parse(u) - if err != nil { - return AbsoluteURL(ctx, u, params) - } - 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() - } - return buildUrl.String() -} - -// AbsoluteURL builds an absolute URL from a relative path. -// It uses BaseHost/BaseScheme from config if available, otherwise the address the client -// used to reach the server, recorded in the context by the server's address middleware. -func AbsoluteURL(ctx context.Context, u string, params url.Values) string { - buildUrl, err := url.Parse(u) - if err != nil { - log.Error(ctx, "Failed to parse URL path", "url", u, err) - return "" - } - if strings.HasPrefix(u, "/") { - buildUrl.Path = path.Join(conf.Server.BasePath, buildUrl.Path) - if conf.Server.BaseHost != "" { - buildUrl.Scheme = cmp.Or(conf.Server.BaseScheme, "http") - buildUrl.Host = conf.Server.BaseHost - } else if scheme, host, ok := request.ServerAddressFrom(ctx); ok { - buildUrl.Scheme = scheme - buildUrl.Host = host - } else { - log.Debug(ctx, "Building a public URL with no public address available; set ShareURL to make it reachable", "url", u) - buildUrl.Scheme = gg.If(conf.Server.TLSEnabled(), "https", "http") - buildUrl.Host = "localhost:" + strconv.Itoa(conf.Server.Port) - } - } - if len(params) > 0 { - buildUrl.RawQuery = params.Encode() - } - return buildUrl.String() -} diff --git a/core/publicurl/publicurl_test.go b/core/publicurl/publicurl_test.go deleted file mode 100644 index 0f8ea806c..000000000 --- a/core/publicurl/publicurl_test.go +++ /dev/null @@ -1,232 +0,0 @@ -package publicurl_test - -import ( - "context" - "net/url" - "testing" - - "github.com/go-chi/jwtauth/v5" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/publicurl" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestPublicURL(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Public URL Suite") -} - -var _ = Describe("Public URL Utilities", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - Describe("PublicURL", func() { - When("ShareURL is set", func() { - BeforeEach(func() { - conf.Server.ShareURL = "https://share.example.com" - }) - - It("uses ShareURL as the base", func() { - result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil) - Expect(result).To(Equal("https://share.example.com/path/to/resource")) - }) - - It("includes query parameters", func() { - params := url.Values{"size": []string{"300"}, "format": []string{"png"}} - result := publicurl.PublicURL(context.Background(), "/image/123", params) - Expect(result).To(ContainSubstring("https://share.example.com/image/123")) - Expect(result).To(ContainSubstring("size=300")) - Expect(result).To(ContainSubstring("format=png")) - }) - }) - - When("ShareURL includes a path", func() { - BeforeEach(func() { - conf.Server.ShareURL = "https://example.com/navi" - }) - - It("prepends the ShareURL path to the resource", func() { - result := publicurl.PublicURL(context.Background(), "/share/img/hash", nil) - Expect(result).To(Equal("https://example.com/navi/share/img/hash")) - }) - - It("prepends the ShareURL path and includes query parameters", func() { - params := url.Values{"size": []string{"600"}} - result := publicurl.PublicURL(context.Background(), "/share/img/hash", params) - Expect(result).To(Equal("https://example.com/navi/share/img/hash?size=600")) - }) - - It("handles trailing slash in ShareURL path", func() { - conf.Server.ShareURL = "https://example.com/navi/" - result := publicurl.PublicURL(context.Background(), "/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 = "" - }) - - It("falls back to AbsoluteURL with request", func() { - ctx := request.WithServerAddress(context.Background(), "https", "myserver.com") - result := publicurl.PublicURL(ctx, "/path/to/resource", nil) - Expect(result).To(Equal("https://myserver.com/path/to/resource")) - }) - - It("falls back to localhost on the configured port without request", func() { - conf.Server.Port = 4533 - result := publicurl.PublicURL(context.Background(), "/path/to/resource", nil) - Expect(result).To(Equal("http://localhost:4533/path/to/resource")) - }) - }) - }) - - Describe("AbsoluteURL", func() { - When("BaseHost is set", func() { - BeforeEach(func() { - conf.Server.BaseHost = "configured.example.com" - conf.Server.BaseScheme = "https" - conf.Server.BasePath = "" - }) - - It("uses BaseHost and BaseScheme", func() { - result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) - Expect(result).To(Equal("https://configured.example.com/path/to/resource")) - }) - - It("defaults to http scheme if BaseScheme is empty", func() { - conf.Server.BaseScheme = "" - result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) - Expect(result).To(Equal("http://configured.example.com/path/to/resource")) - }) - }) - - When("BaseHost is not set", func() { - BeforeEach(func() { - conf.Server.BaseHost = "" - conf.Server.BasePath = "" - }) - - It("extracts host from request", func() { - ctx := request.WithServerAddress(context.Background(), "https", "request.example.com") - result := publicurl.AbsoluteURL(ctx, "/path/to/resource", nil) - Expect(result).To(Equal("https://request.example.com/path/to/resource")) - }) - - It("falls back to localhost on the configured port without request", func() { - conf.Server.Port = 8080 - result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) - Expect(result).To(Equal("http://localhost:8080/path/to/resource")) - }) - - It("uses https in the fallback when TLS is configured", func() { - conf.Server.Port = 4533 - conf.Server.TLSCert = "cert.pem" - conf.Server.TLSKey = "key.pem" - result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) - Expect(result).To(Equal("https://localhost:4533/path/to/resource")) - }) - - It("stays on http when only the certificate is configured", func() { - conf.Server.Port = 4533 - conf.Server.TLSCert = "cert.pem" - result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) - Expect(result).To(Equal("http://localhost:4533/path/to/resource")) - }) - }) - - When("BasePath is set", func() { - BeforeEach(func() { - conf.Server.BasePath = "/navidrome" - conf.Server.BaseHost = "example.com" - conf.Server.BaseScheme = "https" - }) - - It("prepends BasePath to the URL", func() { - result := publicurl.AbsoluteURL(context.Background(), "/path/to/resource", nil) - Expect(result).To(Equal("https://example.com/navidrome/path/to/resource")) - }) - }) - - It("passes through absolute URLs unchanged", func() { - result := publicurl.AbsoluteURL(context.Background(), "https://other.example.com/path", nil) - Expect(result).To(Equal("https://other.example.com/path")) - }) - - It("includes query parameters", func() { - conf.Server.BaseHost = "example.com" - conf.Server.BaseScheme = "https" - params := url.Values{"key": []string{"value"}} - result := publicurl.AbsoluteURL(context.Background(), "/path", params) - Expect(result).To(Equal("https://example.com/path?key=value")) - }) - }) - - Describe("ImageURL", func() { - BeforeEach(func() { - conf.Server.ShareURL = "https://share.example.com" - // Initialize JWT auth for token generation - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) - }) - - It("generates a URL with the artwork token", func() { - artID := model.NewArtworkID(model.KindAlbumArtwork, "album-123", nil) - result := publicurl.ImageURL(context.Background(), artID, 0) - Expect(result).To(HavePrefix("https://share.example.com/share/img/")) - }) - - It("includes size parameter when provided", func() { - artID := model.NewArtworkID(model.KindArtistArtwork, "artist-1", nil) - result := publicurl.ImageURL(context.Background(), artID, 300) - Expect(result).To(ContainSubstring("size=300")) - }) - - It("omits size parameter when zero", func() { - artID := model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil) - result := publicurl.ImageURL(context.Background(), artID, 0) - Expect(result).ToNot(ContainSubstring("size=")) - }) - }) - - Describe("ImageURL address precedence", func() { - var artID model.ArtworkID - - BeforeEach(func() { - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) - artID = model.NewArtworkID(model.KindMediaFileArtwork, "track-1", nil) - }) - - It("uses the address of the request that triggered the call", func() { - ctx := request.WithServerAddress(context.Background(), "https", "music.example.com") - - result := publicurl.ImageURL(ctx, artID, 300) - Expect(result).To(HavePrefix("https://music.example.com/share/img/")) - Expect(result).To(ContainSubstring("size=300")) - }) - - It("prefers ShareURL over the address in the context", func() { - conf.Server.ShareURL = "https://share.example.com" - ctx := request.WithServerAddress(context.Background(), "https", "music.example.com") - - result := publicurl.ImageURL(ctx, artID, 0) - Expect(result).To(HavePrefix("https://share.example.com/share/img/")) - }) - - It("falls back to localhost on the configured port when no address is available", func() { - conf.Server.Port = 4533 - result := publicurl.ImageURL(context.Background(), artID, 0) - Expect(result).To(HavePrefix("http://localhost:4533/share/img/")) - }) - }) -}) diff --git a/core/quickconnect/quickconnect.go b/core/quickconnect/quickconnect.go deleted file mode 100644 index 9b69bc904..000000000 --- a/core/quickconnect/quickconnect.go +++ /dev/null @@ -1,203 +0,0 @@ -// Package quickconnect implements Jellyfin-style Quick Connect: a new client shows a short code, and -// an already signed-in user approves it, so the client can sign in without a password. -package quickconnect - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "strings" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/random" - "github.com/navidrome/navidrome/utils/singleton" -) - -const timeout = 10 * time.Minute - -// Initiate is unauthenticated, so the number of live requests must be bounded. -const maxPending = 1000 - -var ( - ErrAlreadyAuthorized = errors.New("quick connect request already authorized") - ErrTooManyRequests = errors.New("too many pending quick connect requests") -) - -type Device struct { - ID string - Name string - App string - AppVersion string -} - -type Request struct { - Device Device - Secret string - Code string - DateAdded time.Time - UserID string -} - -func (r Request) Authorized() bool { - return r.UserID != "" -} - -type QuickConnect interface { - Initiate(device Device) (Request, error) - Status(secret string) (Request, error) - // Lookup returns a request still waiting for approval. - Lookup(code string) (Request, error) - Authorize(code, userID string) (Request, error) - // Redeem returns the id of the user who approved the request. It succeeds only once per secret. - Redeem(secret string) (string, error) -} - -type entry struct { - Request - expiresAt time.Time -} - -type quickConnect struct { - mu sync.Mutex - bySecret map[string]*entry - byCode map[string]*entry -} - -// GetInstance returns the shared store: the Jellyfin and native API routers must see the same requests. -func GetInstance() QuickConnect { - return singleton.GetInstance(newStore) -} - -func New() QuickConnect { - return newStore() -} - -func newStore() *quickConnect { - return &quickConnect{ - bySecret: map[string]*entry{}, - byCode: map[string]*entry{}, - } -} - -// Enabled reports whether users can approve codes from the web UI, which needs the Jellyfin API. -func Enabled() bool { - return conf.Server.Jellyfin.Enabled && conf.Server.Jellyfin.QuickConnect -} - -func (qc *quickConnect) Initiate(device Device) (Request, error) { - qc.mu.Lock() - defer qc.mu.Unlock() - qc.expire() - if len(qc.bySecret) >= maxPending { - return Request{}, ErrTooManyRequests - } - // The fields may be substrings of a much larger header; copy them so the header isn't retained. - device = Device{ID: strings.Clone(device.ID), Name: strings.Clone(device.Name), - App: strings.Clone(device.App), AppVersion: strings.Clone(device.AppVersion)} - now := time.Now() - e := &entry{ - Request: Request{Device: device, Secret: newSecret(), Code: qc.newCode(), DateAdded: now}, - expiresAt: now.Add(timeout), - } - qc.bySecret[e.Secret] = e - qc.byCode[e.Code] = e - return e.Request, nil -} - -func (qc *quickConnect) Status(secret string) (Request, error) { - qc.mu.Lock() - defer qc.mu.Unlock() - e, err := qc.find(qc.bySecret, secret) - if err != nil { - return Request{}, err - } - return e.Request, nil -} - -func (qc *quickConnect) Lookup(code string) (Request, error) { - qc.mu.Lock() - defer qc.mu.Unlock() - e, err := qc.findPending(code) - if err != nil { - return Request{}, err - } - return e.Request, nil -} - -func (qc *quickConnect) Authorize(code, userID string) (Request, error) { - qc.mu.Lock() - defer qc.mu.Unlock() - e, err := qc.findPending(code) - if err != nil { - return Request{}, err - } - e.UserID = userID - e.expiresAt = time.Now().Add(timeout) - return e.Request, nil -} - -func (qc *quickConnect) Redeem(secret string) (string, error) { - qc.mu.Lock() - defer qc.mu.Unlock() - e, err := qc.find(qc.bySecret, secret) - if err != nil || !e.Authorized() { - return "", model.ErrNotFound - } - qc.remove(e) - return e.UserID, nil -} - -func (qc *quickConnect) find(index map[string]*entry, key string) (*entry, error) { - qc.expire() - e, ok := index[key] - if !ok { - return nil, model.ErrNotFound - } - return e, nil -} - -func (qc *quickConnect) findPending(code string) (*entry, error) { - e, err := qc.find(qc.byCode, normalizeCode(code)) - if err == nil && e.Authorized() { - return nil, ErrAlreadyAuthorized - } - return e, err -} - -func (qc *quickConnect) expire() { - now := time.Now() - for _, e := range qc.bySecret { - if !now.Before(e.expiresAt) { - qc.remove(e) - } - } -} - -func (qc *quickConnect) remove(e *entry) { - delete(qc.bySecret, e.Secret) - delete(qc.byCode, e.Code) -} - -func (qc *quickConnect) newCode() string { - for { - code := fmt.Sprintf("%06d", random.Int64N(900000)+100000) - if _, taken := qc.byCode[code]; !taken { - return code - } - } -} - -func newSecret() string { - b := make([]byte, 32) - _, _ = rand.Read(b) - return hex.EncodeToString(b) -} - -// Users often type the code in groups ("123 456"). -func normalizeCode(code string) string { - return strings.Join(strings.Fields(code), "") -} diff --git a/core/quickconnect/quickconnect_suite_test.go b/core/quickconnect/quickconnect_suite_test.go deleted file mode 100644 index 029f4e333..000000000 --- a/core/quickconnect/quickconnect_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package quickconnect - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestQuickConnect(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "QuickConnect Suite") -} diff --git a/core/quickconnect/quickconnect_test.go b/core/quickconnect/quickconnect_test.go deleted file mode 100644 index b03186807..000000000 --- a/core/quickconnect/quickconnect_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package quickconnect - -import ( - "testing" - "testing/synctest" - "time" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var device = Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"} - -var _ = Describe("QuickConnect", func() { - var qc QuickConnect - - BeforeEach(func() { - qc = New() - }) - - Describe("Initiate", func() { - It("creates a pending request with a 6-digit code and a random secret", func() { - req, err := qc.Initiate(device) - Expect(err).ToNot(HaveOccurred()) - Expect(req.Code).To(MatchRegexp(`^[1-9]\d{5}$`)) - Expect(req.Secret).To(MatchRegexp(`^[0-9a-f]{64}$`)) - Expect(req.Device).To(Equal(device)) - Expect(req.DateAdded).ToNot(BeZero()) - Expect(req.Authorized()).To(BeFalse()) - }) - - It("never gives two live requests the same code or secret", func() { - codes := map[string]bool{} - secrets := map[string]bool{} - for range 500 { - req, err := qc.Initiate(device) - Expect(err).ToNot(HaveOccurred()) - codes[req.Code] = true - secrets[req.Secret] = true - } - Expect(codes).To(HaveLen(500)) - Expect(secrets).To(HaveLen(500)) - }) - - It("refuses new requests when too many are pending", func() { - for range maxPending { - _, err := qc.Initiate(device) - Expect(err).ToNot(HaveOccurred()) - } - _, err := qc.Initiate(device) - Expect(err).To(MatchError(ErrTooManyRequests)) - }) - }) - - Describe("Status", func() { - It("returns the request for a known secret", func() { - req, _ := qc.Initiate(device) - got, err := qc.Status(req.Secret) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(Equal(req)) - }) - - It("returns ErrNotFound for an unknown secret", func() { - _, err := qc.Status("nope") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("Lookup", func() { - It("finds a request by code, ignoring spaces", func() { - req, _ := qc.Initiate(device) - got, err := qc.Lookup(req.Code[:3] + " " + req.Code[3:]) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(Equal(req)) - }) - - It("returns ErrNotFound for an unknown code", func() { - _, err := qc.Lookup("000000") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("refuses a request that is already authorized", func() { - req, _ := qc.Initiate(device) - _, _ = qc.Authorize(req.Code, "user-1") - _, err := qc.Lookup(req.Code) - Expect(err).To(MatchError(ErrAlreadyAuthorized)) - }) - }) - - Describe("Authorize", func() { - It("marks the request as authorized by the user", func() { - req, _ := qc.Initiate(device) - got, err := qc.Authorize(" "+req.Code+" ", "user-1") - Expect(err).ToNot(HaveOccurred()) - Expect(got.Authorized()).To(BeTrue()) - Expect(got.UserID).To(Equal("user-1")) - - status, _ := qc.Status(req.Secret) - Expect(status.Authorized()).To(BeTrue()) - }) - - It("refuses a request that is already authorized", func() { - req, _ := qc.Initiate(device) - _, _ = qc.Authorize(req.Code, "user-1") - _, err := qc.Authorize(req.Code, "user-2") - Expect(err).To(MatchError(ErrAlreadyAuthorized)) - }) - - It("returns ErrNotFound for an unknown code", func() { - _, err := qc.Authorize("000000", "user-1") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("Redeem", func() { - It("returns the approving user once, then forgets the request", func() { - req, _ := qc.Initiate(device) - _, _ = qc.Authorize(req.Code, "user-1") - - userID, err := qc.Redeem(req.Secret) - Expect(err).ToNot(HaveOccurred()) - Expect(userID).To(Equal("user-1")) - - _, err = qc.Redeem(req.Secret) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = qc.Status(req.Secret) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = qc.Lookup(req.Code) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("refuses a request that is not authorized yet", func() { - req, _ := qc.Initiate(device) - _, err := qc.Redeem(req.Secret) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = qc.Status(req.Secret) - Expect(err).ToNot(HaveOccurred()) - }) - }) -}) - -// Plain tests: testing/synctest needs a *testing.T, which Ginkgo doesn't give. -func TestPendingRequestExpires(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - qc := New() - req, _ := qc.Initiate(device) - - time.Sleep(timeout - time.Second) - _, err := qc.Status(req.Secret) - g.Expect(err).ToNot(HaveOccurred()) - - time.Sleep(2 * time.Second) - _, err = qc.Status(req.Secret) - g.Expect(err).To(MatchError(model.ErrNotFound)) - _, err = qc.Authorize(req.Code, "user-1") - g.Expect(err).To(MatchError(model.ErrNotFound)) - }) -} - -func TestAuthorizeExtendsExpiry(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - qc := New() - req, _ := qc.Initiate(device) - - time.Sleep(timeout - time.Second) - _, err := qc.Authorize(req.Code, "user-1") - g.Expect(err).ToNot(HaveOccurred()) - - time.Sleep(timeout - time.Second) - userID, err := qc.Redeem(req.Secret) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(userID).To(Equal("user-1")) - }) -} - -func TestExpiredRequestsFreeCapacity(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - qc := New() - for range maxPending { - _, _ = qc.Initiate(device) - } - time.Sleep(timeout + time.Second) - _, err := qc.Initiate(device) - g.Expect(err).ToNot(HaveOccurred()) - }) -} diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index 30bced623..047e43eef 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -5,99 +5,34 @@ import ( "errors" "time" - "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" ) -const ( - minRetryDelay = 5 * time.Second - maxRetryDelay = 4 * time.Minute - // maxRetryShift caps the exponent so the shift never overflows int64. - // minRetryDelay<<6 = 320s already exceeds maxRetryDelay, so 6 reaches the ceiling. - maxRetryShift = 6 -) - -// backoffDelay returns the delay for a zero-based retry index (0 = first retry): -// minRetryDelay doubled per prior failure, clamped to maxRetryDelay. -func backoffDelay(failures int) time.Duration { - if failures < 0 { - failures = 0 - } - if failures >= maxRetryShift { - return maxRetryDelay - } - d := minRetryDelay << failures - if d > maxRetryDelay { - return maxRetryDelay - } - return d -} - -// Loader is a function that loads a scrobbler by name. -// It returns the scrobbler and true if found, or nil and false if not available. -// This allows the buffered scrobbler to always get the current plugin instance. -type Loader func() (Scrobbler, bool) - -// newBufferedScrobbler creates a buffered scrobbler that wraps a static scrobbler instance. -// Use this for builtin scrobblers that don't change. func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler { - return newBufferedScrobblerWithLoader(ds, service, func() (Scrobbler, bool) { - return s, true - }) -} - -// newBufferedScrobblerWithLoader creates a buffered scrobbler that dynamically loads -// the underlying scrobbler on each call. Use this for plugin scrobblers that may be -// reloaded (e.g., after configuration changes). -func newBufferedScrobblerWithLoader(ds model.DataStore, service string, loader Loader) *bufferedScrobbler { - ctx, cancel := context.WithCancel(context.Background()) - b := &bufferedScrobbler{ - ds: ds, - loader: loader, - service: service, - wakeSignal: make(chan struct{}, 1), - ctx: ctx, - cancel: cancel, - } - go b.run(ctx) + b := &bufferedScrobbler{ds: ds, wrapped: s, service: service} + b.wakeSignal = make(chan struct{}, 1) + go b.run(context.TODO()) return b } type bufferedScrobbler struct { ds model.DataStore - loader Loader + wrapped Scrobbler service string wakeSignal chan struct{} - ctx context.Context //nolint:containedctx // scrobbler lifecycle ctx, cancelled by Stop - cancel context.CancelFunc -} - -func (b *bufferedScrobbler) Stop() { - if b.cancel != nil { - b.cancel() - } } func (b *bufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool { - s, ok := b.loader() - if !ok { - return false - } - return s.IsAuthorized(ctx, userId) + return b.wrapped.IsAuthorized(ctx, userId) } -func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { - s, ok := b.loader() - if !ok { - return errors.New("scrobbler not available") - } - return s.NowPlaying(ctx, userId, track, position) +func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error { + return b.wrapped.NowPlaying(ctx, userId, track) } func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { - err := b.ds.ScrobbleBuffer().Enqueue(ctx, b.service, userId, s.ID, s.TimeStamp) + err := b.ds.ScrobbleBuffer(ctx).Enqueue(b.service, userId, s.ID, s.TimeStamp) if err != nil { return err } @@ -106,14 +41,6 @@ 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 { @@ -123,98 +50,67 @@ func (b *bufferedScrobbler) sendWakeSignal() { } func (b *bufferedScrobbler) run(ctx context.Context) { - timer := time.NewTimer(time.Hour) - timer.Stop() - defer timer.Stop() - failures := 0 - backingOff := false for { - // While a backoff window is open the timer is already armed for the rest of it, so a - // wake (a new play enqueued) must not drain: that is the hammering this avoids. - if !backingOff { - if ok, retryIn := b.processQueue(ctx); ok { - failures = 0 - timer.Stop() - } else { - timer.Reset(max(backoffDelay(failures), retryIn)) - backingOff = true - if failures < maxRetryShift { - failures++ - } - } + if !b.processQueue(ctx) { + time.AfterFunc(5*time.Second, func() { + b.sendWakeSignal() + }) } select { case <-b.wakeSignal: - case <-timer.C: - backingOff = false + continue case <-ctx.Done(): return } } } -func (b *bufferedScrobbler) processQueue(ctx context.Context) (bool, time.Duration) { - buffer := b.ds.ScrobbleBuffer() - userIds, err := buffer.UserIDs(ctx, b.service) +func (b *bufferedScrobbler) processQueue(ctx context.Context) bool { + buffer := b.ds.ScrobbleBuffer(ctx) + userIds, err := buffer.UserIDs(b.service) if err != nil { log.Error(ctx, "Error retrieving userIds from scrobble buffer", "scrobbler", b.service, err) - return false, 0 + return false } result := true - var retryIn time.Duration for _, userId := range userIds { - ok, d := b.processUserQueue(ctx, userId) - if !ok { + if !b.processUserQueue(ctx, userId) { result = false - retryIn = max(retryIn, d) } } - return result, retryIn + return result } -func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) (bool, time.Duration) { - // Scrobbles are drained on a background context that no longer carries the - // request's authenticated user. Restore it from the buffered userId so that - // scrobblers relying on the user in the context (e.g. plugins) still get it. - if user, err := b.ds.User().Get(ctx, userId); err != nil { - log.Warn(ctx, "Could not load user for buffered scrobble", "userId", userId, "scrobbler", b.service, err) - } else { - ctx = request.WithUser(ctx, *user) - } - buffer := b.ds.ScrobbleBuffer() +func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) bool { + buffer := b.ds.ScrobbleBuffer(ctx) for { - entry, err := buffer.Next(ctx, b.service, userId) + entry, err := buffer.Next(b.service, userId) if err != nil { log.Error(ctx, "Error reading from scrobble buffer", "scrobbler", b.service, err) - return false, 0 + return false } if entry == nil { - return true, 0 - } - s, ok := b.loader() - if !ok { - log.Warn(ctx, "Scrobbler not available, will retry later", "scrobbler", b.service) - return false, 0 + return true } log.Debug(ctx, "Sending scrobble", "scrobbler", b.service, "track", entry.Title, "artist", entry.Artist) - err = s.Scrobble(ctx, entry.UserID, Scrobble{ + err = b.wrapped.Scrobble(ctx, entry.UserID, Scrobble{ MediaFile: entry.MediaFile, TimeStamp: entry.PlayTime, }) - if retry, ok := errors.AsType[*agents.RetryLaterError](err); ok { + if errors.Is(err, ErrRetryLater) { log.Warn(ctx, "Could not send scrobble. Will be retried", "userId", entry.UserID, "track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err) - return false, retry.RetryIn + return false } if err != nil { log.Error(ctx, "Error sending scrobble to service. Discarding", "scrobbler", b.service, "userId", entry.UserID, "artist", entry.Artist, "track", entry.Title, err) } - err = buffer.Dequeue(ctx, entry) + err = buffer.Dequeue(entry) if err != nil { log.Error(ctx, "Error removing entry from scrobble buffer", "userId", entry.UserID, "track", entry.Title, "artist", entry.Artist, "scrobbler", b.service, err) - return false, 0 + return false } } } diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go deleted file mode 100644 index 6ecd92cab..000000000 --- a/core/scrobbler/buffered_scrobbler_test.go +++ /dev/null @@ -1,308 +0,0 @@ -package scrobbler - -import ( - "context" - "errors" - "sync/atomic" - "testing" - "testing/synctest" - "time" - - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("BufferedScrobbler", func() { - var ds model.DataStore - var scr *fakeScrobbler - var bs *bufferedScrobbler - var ctx context.Context - var buffer *tests.MockedScrobbleBufferRepo - - BeforeEach(func() { - ctx = context.Background() - buffer = tests.CreateMockedScrobbleBufferRepo() - userRepo := tests.CreateMockUserRepo() - Expect(userRepo.Put(ctx, &model.User{ID: "user1", UserName: "alice"})).To(Succeed()) - ds = &tests.MockDataStore{ - MockedScrobbleBuffer: buffer, - MockedUser: userRepo, - } - scr = &fakeScrobbler{Authorized: true} - bs = newBufferedScrobbler(ds, scr, "test") - }) - - It("forwards IsAuthorized calls", func() { - scr.Authorized = true - Expect(bs.IsAuthorized(ctx, "user1")).To(BeTrue()) - - scr.Authorized = false - Expect(bs.IsAuthorized(ctx, "user1")).To(BeFalse()) - }) - - It("forwards NowPlaying calls", func() { - track := &model.MediaFile{ID: "123", Title: "Test Track"} - Expect(bs.NowPlaying(ctx, "user1", track, 0)).To(Succeed()) - Expect(scr.GetNowPlayingCalled()).To(BeTrue()) - Expect(scr.GetUserID()).To(Equal("user1")) - Expect(scr.GetTrack()).To(Equal(track)) - }) - - It("enqueues scrobbles to buffer", func() { - track := model.MediaFile{ID: "123", Title: "Test Track"} - now := time.Now() - scrobble := Scrobble{MediaFile: track, TimeStamp: now} - Expect(buffer.Length(ctx)).To(Equal(int64(0))) - Expect(scr.ScrobbleCalled.Load()).To(BeFalse()) - - Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed()) - - // Wait for the background goroutine to process the scrobble. - // We don't check buffer.Length() here because the background goroutine - // may dequeue the entry before we can observe it. - Eventually(scr.ScrobbleCalled.Load).Should(BeTrue()) - - lastScrobble := scr.LastScrobble.Load() - Expect(lastScrobble.MediaFile.ID).To(Equal("123")) - Expect(lastScrobble.TimeStamp).To(BeTemporally("==", now)) - }) - - It("restores the user in the context when draining buffered scrobbles", func() { - track := model.MediaFile{ID: "123", Title: "Test Track", Artist: "Test Artist"} - scrobble := Scrobble{MediaFile: track, TimeStamp: time.Now()} - - Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed()) - - Eventually(scr.ScrobbleCalled.Load).Should(BeTrue()) - Expect(scr.GetUsername()).To(Equal("alice")) - }) - - It("stops the background goroutine when Stop is called", func() { - // Replace the real run method with one that signals when it exits - done := make(chan struct{}) - - // Start our instrumented run function that will signal when it exits - go func() { - defer close(done) - bs.run(bs.ctx) - }() - - // Wait a bit to ensure the goroutine is running - time.Sleep(10 * time.Millisecond) - - // Call the real Stop method - bs.Stop() - - // Wait for the goroutine to exit or timeout - select { - case <-done: - // Success, goroutine exited - case <-time.After(100 * time.Millisecond): - Fail("Goroutine did not exit in time after Stop was called") - } - }) -}) - -var _ = Describe("backoffDelay", func() { - DescribeTable("computes the exponential backoff curve clamped to the ceiling", - func(failures int, expected time.Duration) { - Expect(backoffDelay(failures)).To(Equal(expected)) - }, - Entry("first failure", 0, 5*time.Second), - Entry("second failure", 1, 10*time.Second), - Entry("third failure", 2, 20*time.Second), - Entry("fourth failure", 3, 40*time.Second), - Entry("fifth failure", 4, 80*time.Second), - Entry("sixth failure", 5, 160*time.Second), - Entry("reaches the ceiling", 6, 4*time.Minute), - Entry("stays clamped past the ceiling", 7, 4*time.Minute), - Entry("stays clamped for large values", 1000, 4*time.Minute), - Entry("negative is treated as zero", -1, 5*time.Second), - ) -}) - -// Drives the real run loop and asserts the exact retry schedule + recovery. Plain -// test: testing/synctest's fake clock needs a *testing.T, which Ginkgo doesn't give. -func TestBufferedScrobblerBackoffSchedule(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - buffer := tests.CreateMockedScrobbleBufferRepo() - userRepo := tests.CreateMockUserRepo() - g.Expect(userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"})).To(Succeed()) - ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} - - flaky := &recoveringScrobbler{} - flaky.fail(ErrRetryLater) - bs := newBufferedScrobbler(ds, flaky, "flaky") - defer func() { bs.Stop(); synctest.Wait() }() - - // Let the loop settle on the empty buffer, then enqueue a scrobble. - synctest.Wait() - track := model.MediaFile{ID: "123", Title: "Test Track", Artist: "Test Artist"} - g.Expect(bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: track, TimeStamp: time.Now()})).To(Succeed()) - - // First attempt fires immediately on the enqueue wake and is left buffered. - synctest.Wait() - g.Expect(flaky.count.Load()).To(Equal(int32(1))) - g.Expect(buffer.Length(t.Context())).To(Equal(int64(1))) - - // Each subsequent retry waits exactly double the previous: 5s, 10s, 20s, 40s. - for i, gap := range []time.Duration{5 * time.Second, 10 * time.Second, 20 * time.Second, 40 * time.Second} { - want := int32(i + 2) - time.Sleep(gap - time.Nanosecond) - synctest.Wait() - g.Expect(flaky.count.Load()).To(Equal(want-1), "retry fired before the %s backoff", gap) - time.Sleep(time.Nanosecond) - synctest.Wait() - g.Expect(flaky.count.Load()).To(Equal(want), "retry did not fire after the %s backoff", gap) - } - - // Once the service recovers, the buffered entry drains when the open - // backoff window closes (a wake alone must not drain it early). - flaky.succeed() - bs.sendWakeSignal() - synctest.Wait() - g.Expect(buffer.Length(t.Context())).To(Equal(int64(1)), "wake during backoff drained early") - time.Sleep(80 * time.Second) - synctest.Wait() - g.Expect(buffer.Length(t.Context())).To(Equal(int64(0))) - }) -} - -func TestBufferedScrobblerBackoffWindow(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - buffer := tests.CreateMockedScrobbleBufferRepo() - userRepo := tests.CreateMockUserRepo() - _ = userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"}) - ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} - scr := &fakeScrobbler{Authorized: true} - scr.SetError(errors.Join(errors.New("boom"), ErrRetryLater)) - bs := newBufferedScrobbler(ds, scr, "test") - defer bs.Stop() - - // First enqueue: one immediate attempt, then a 5s window opens. - _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "1"}, TimeStamp: time.Now()}) - synctest.Wait() - if got := scr.ScrobbleAttempts(); got != 1 { - t.Fatalf("expected 1 attempt after first enqueue, got %d", got) - } - - // A wake inside the window must NOT trigger an early attempt. - time.Sleep(1 * time.Second) - _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "2"}, TimeStamp: time.Now()}) - synctest.Wait() - if got := scr.ScrobbleAttempts(); got != 1 { - t.Fatalf("wake during backoff drained early: %d attempts", got) - } - - // When the 5s window closes, the retry happens. - time.Sleep(4100 * time.Millisecond) - synctest.Wait() - if got := scr.ScrobbleAttempts(); got != 2 { - t.Fatalf("expected retry after window, got %d attempts", got) - } - }) -} - -func TestBufferedScrobblerHonorsServerDelay(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - buffer := tests.CreateMockedScrobbleBufferRepo() - userRepo := tests.CreateMockUserRepo() - _ = userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"}) - ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} - scr := &fakeScrobbler{Authorized: true} - scr.SetError(errors.Join(errors.New("429"), &agents.RetryLaterError{RetryIn: 30 * time.Second})) - bs := newBufferedScrobbler(ds, scr, "test") - defer bs.Stop() - - _ = bs.Scrobble(context.Background(), "user1", Scrobble{MediaFile: model.MediaFile{ID: "1"}, TimeStamp: time.Now()}) - synctest.Wait() - if got := scr.ScrobbleAttempts(); got != 1 { - t.Fatalf("expected 1 attempt, got %d", got) - } - - // The 5s exponential floor is overridden by the 30s server delay. - time.Sleep(20 * time.Second) - synctest.Wait() - if got := scr.ScrobbleAttempts(); got != 1 { - t.Fatalf("retried before server delay elapsed: %d attempts", got) - } - time.Sleep(10100 * time.Millisecond) - synctest.Wait() - if got := scr.ScrobbleAttempts(); got != 2 { - t.Fatalf("expected retry after server delay, got %d attempts", got) - } - }) -} - -// The drain visits users in an arbitrary order, so the longest delay must win regardless -// of which user was seen last. -func TestBufferedScrobblerTakesTheLongestServerDelayAcrossUsers(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - buffer := tests.CreateMockedScrobbleBufferRepo() - userRepo := tests.CreateMockUserRepo() - _ = userRepo.Put(t.Context(), &model.User{ID: "user1", UserName: "alice"}) - _ = userRepo.Put(t.Context(), &model.User{ID: "user2", UserName: "bob"}) - ds := &tests.MockDataStore{MockedScrobbleBuffer: buffer, MockedUser: userRepo} - scr := &recoveringScrobbler{delays: map[string]time.Duration{ - "user1": 10 * time.Second, - "user2": 45 * time.Second, - }} - // Both are buffered before the drain goroutine exists: it drains once on startup, and - // seeing only one user there would park it on that user's delay, ignoring the other. - _ = buffer.Enqueue(t.Context(), "test", "user1", "1", time.Now()) - _ = buffer.Enqueue(t.Context(), "test", "user2", "2", time.Now()) - bs := newBufferedScrobbler(ds, scr, "test") - defer bs.Stop() - - synctest.Wait() - if got := scr.count.Load(); got != 2 { - t.Fatalf("expected both users drained, got %d attempts", got) - } - - time.Sleep(30 * time.Second) - synctest.Wait() - if got := scr.count.Load(); got != 2 { - t.Fatalf("retried on the shorter delay: %d attempts", got) - } - time.Sleep(15100 * time.Millisecond) - synctest.Wait() - if got := scr.count.Load(); got != 4 { - t.Fatalf("expected a retry after the longest delay, got %d attempts", got) - } - }) -} - -// recoveringScrobbler is a race-safe Scrobbler whose error can be toggled while -// the buffered scrobbler's goroutine is draining, to exercise retry then recovery. -// With delays set, it instead fails every scrobble asking for that user's delay. -type recoveringScrobbler struct { - err atomic.Pointer[error] - count atomic.Int32 - delays map[string]time.Duration -} - -func (f *recoveringScrobbler) fail(err error) { f.err.Store(&err) } -func (f *recoveringScrobbler) succeed() { f.err.Store(nil) } - -func (f *recoveringScrobbler) IsAuthorized(context.Context, string) bool { return true } - -func (f *recoveringScrobbler) NowPlaying(context.Context, string, *model.MediaFile, int) error { - return nil -} - -func (f *recoveringScrobbler) Scrobble(_ context.Context, userId string, _ Scrobble) error { - f.count.Add(1) - if f.delays != nil { - return errors.Join(errors.New("429"), &agents.RetryLaterError{RetryIn: f.delays[userId]}) - } - if e := f.err.Load(); e != nil { - return *e - } - return nil -} - -func (f *recoveringScrobbler) PlaybackReport(context.Context, PlaybackSession) error { return nil } diff --git a/core/scrobbler/interfaces.go b/core/scrobbler/interfaces.go index 218b7a919..90141f112 100644 --- a/core/scrobbler/interfaces.go +++ b/core/scrobbler/interfaces.go @@ -5,7 +5,6 @@ import ( "errors" "time" - "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/model" ) @@ -16,16 +15,14 @@ type Scrobble struct { var ( ErrNotAuthorized = errors.New("not authorized") - // ErrRetryLater is an alias of agents.ErrRetryLater so adapters and plugins share one identity. - ErrRetryLater = agents.ErrRetryLater + ErrRetryLater = errors.New("retry later") ErrUnrecoverable = errors.New("unrecoverable") ) type Scrobbler interface { IsAuthorized(ctx context.Context, userId string) bool - NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error + NowPlaying(ctx context.Context, userId string, track *model.MediaFile) 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 deleted file mode 100644 index 1bacec689..000000000 --- a/core/scrobbler/nowplaying_worker.go +++ /dev/null @@ -1,78 +0,0 @@ -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 541a8e92b..198223b22 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -2,54 +2,28 @@ package scrobbler import ( "context" - "encoding/json" - "maps" - "slices" - "sync" + "sort" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + + "github.com/ReneKroon/ttlcache/v2" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/utils/cache" "github.com/navidrome/navidrome/utils/singleton" - "github.com/navidrome/navidrome/utils/slice" ) -const ( - StateStarting = "starting" - StatePlaying = "playing" - StatePaused = "paused" - StateStopped = "stopped" - StateExpired = "expired" -) +const maxNowPlayingExpire = 60 * time.Minute -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 - - // Verdict from the last report, for the expiry callback: its context carries only - // a stub user, so it cannot evaluate the filter itself. - filtered bool +type NowPlayingInfo struct { + MediaFile model.MediaFile + Start time.Time + Username string + PlayerId string + PlayerName string } type Submission struct { @@ -57,413 +31,99 @@ 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 //nolint:containedctx // queued work item carries the request ctx to the worker - userId string - track *model.MediaFile - position int -} - -type playbackReportEntry struct { - ctx context.Context //nolint:containedctx // queued work item carries the request ctx to the worker - info PlaybackSession - filtered bool -} - type PlayTracker interface { - GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) + NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error + GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, 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 -// (avoids import cycles) -type PluginLoader interface { - PluginNames(capability string) []string - LoadScrobbler(name string) (Scrobbler, bool) } type playTracker struct { - ds model.DataStore - broker events.Broker - playMap cache.SimpleCache[string, PlaybackSession] - sessionsMu sync.Mutex // serializes playMap check-then-write across concurrent reports - builtinScrobblers map[string]Scrobbler - pluginScrobblers map[string]Scrobbler - pluginLoader PluginLoader - mu sync.RWMutex - npQueue map[string]nowPlayingEntry - npMu sync.Mutex - npSignal chan struct{} - shutdown chan struct{} - workerDone chan struct{} - prQueue []playbackReportEntry - prMu sync.Mutex - prSignal chan struct{} - prWorkerDone chan struct{} + ds model.DataStore + broker events.Broker + playMap *ttlcache.Cache + scrobblers map[string]Scrobbler } -func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker { +func GetPlayTracker(ds model.DataStore, broker events.Broker) PlayTracker { return singleton.GetInstance(func() *playTracker { - return newPlayTracker(ds, broker, pluginManager) + return newPlayTracker(ds, broker) }) } -// 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, PlaybackSession]() - p := &playTracker{ - ds: ds, - playMap: m, - broker: broker, - builtinScrobblers: make(map[string]Scrobbler), - pluginScrobblers: make(map[string]Scrobbler), - pluginLoader: pluginManager, - npQueue: make(map[string]nowPlayingEntry), - npSignal: make(chan struct{}, 1), - shutdown: make(chan struct{}), - workerDone: make(chan struct{}), - prSignal: make(chan struct{}, 1), - prWorkerDone: make(chan struct{}), - } - 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, info.filtered) - } - }) - - var enabled []string +// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by +// the GetPlayTracker function above +func newPlayTracker(ds model.DataStore, broker events.Broker) *playTracker { + m := ttlcache.NewCache() + m.SkipTTLExtensionOnHit(true) + _ = m.SetTTL(maxNowPlayingExpire) + p := &playTracker{ds: ds, playMap: m, broker: broker} + p.scrobblers = make(map[string]Scrobbler) for name, constructor := range constructors { s := constructor(ds) - if s == nil { - log.Debug("Scrobbler not available. Missing configuration?", "name", name) - continue + if conf.Server.DevEnableBufferedScrobble { + s = newBufferedScrobbler(ds, s, name) } - enabled = append(enabled, name) - s = newBufferedScrobbler(ds, s, name) - p.builtinScrobblers[name] = s + p.scrobblers[name] = s } - log.Debug("List of builtin scrobblers enabled", "names", enabled) - go p.nowPlayingWorker() - go p.playbackReportWorker() return p } -// stopBackgroundWorkers stops the background workers. This is primarily for testing. -func (p *playTracker) stopBackgroundWorkers() { - close(p.shutdown) - <-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. -func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool { - if len(pluginNames) != len(scrobblers) { - return false - } - for _, name := range pluginNames { - if _, ok := scrobblers[name]; !ok { - return false - } - } - return true -} - -// refreshPluginScrobblers updates the pluginScrobblers map to match the current set of plugin scrobblers. -// The buffered scrobblers use a loader function to dynamically get the current plugin instance, -// so we only need to add/remove scrobblers when plugins are added/removed (not when reloaded). -func (p *playTracker) refreshPluginScrobblers() { - p.mu.Lock() - defer p.mu.Unlock() - if p.pluginLoader == nil { - return +func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string) error { + mf, err := p.ds.MediaFile(ctx).Get(trackId) + if err != nil { + log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err) + return err } - // Get the list of available plugin names - pluginNames := p.pluginLoader.PluginNames("Scrobbler") - - // Early return if plugin names match existing scrobblers (no change) - if pluginNamesMatchScrobblers(pluginNames, p.pluginScrobblers) { - return - } - - // Build a set of current plugins for faster lookups - current := make(map[string]struct{}, len(pluginNames)) - - // Process additions - add new plugins with a loader that dynamically fetches the current instance - for _, name := range pluginNames { - current[name] = struct{}{} - if _, exists := p.pluginScrobblers[name]; !exists { - // Capture the name for the closure - pluginName := name - loader := p.pluginLoader - p.pluginScrobblers[name] = newBufferedScrobblerWithLoader(p.ds, name, func() (Scrobbler, bool) { - return loader.LoadScrobbler(pluginName) - }) - } - } - - type stoppableScrobbler interface { - Scrobbler - Stop() - } - - // Process removals - remove plugins that no longer exist - for name, scrobbler := range p.pluginScrobblers { - if _, exists := current[name]; !exists { - // If the scrobbler implements stoppableScrobbler, call Stop() before removing it - if stoppable, ok := scrobbler.(stoppableScrobbler); ok { - log.Debug("Stopping scrobbler", "name", name) - stoppable.Stop() - } - delete(p.pluginScrobblers, name) - } - } -} - -// getActiveScrobblers refreshes plugin scrobblers, acquires a read lock, -// combines builtin and plugin scrobblers into a new map, releases the lock, -// and returns the combined map. -func (p *playTracker) getActiveScrobblers() map[string]Scrobbler { - p.refreshPluginScrobblers() - p.mu.RLock() - defer p.mu.RUnlock() - combined := maps.Clone(p.builtinScrobblers) - maps.Copy(combined, p.pluginScrobblers) - return combined -} - -// hasPlayingSession reports whether clientId's current session is already playing mediaId. -func (p *playTracker) hasPlayingSession(clientId, mediaId string) bool { - cur, err := p.playMap.Get(clientId) - return err == nil && cur.MediaFile.ID == mediaId && cur.State == StatePlaying -} - -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) - clientId := params.ClientId - client := params.ClientName - - now := time.Now() - - // One verdict per report, reused by every dispatch below, so a filter reading - // annotations cannot decide differently on either side of incPlay. - var filtered bool - - switch params.State { - case StateStarting: - // Clients may send starting/playing unordered; a late "starting" must not downgrade - // a playing session, or position estimation freezes until the next report. - if p.hasPlayingSession(clientId, params.MediaId) { - log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId) - return nil - } - mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId) - if err != nil { - return err - } - filtered = p.isFilteredOut(ctx, mf) - info := PlaybackSession{ - MediaFile: *mf, - filtered: filtered, - Start: now, - UserId: user.ID, - Username: user.UserName, - PlayerId: clientId, - PlayerName: client, - State: params.State, - PositionMs: params.PositionMs, - PlaybackRate: params.PlaybackRate, - LastReport: now, - } - p.sessionsMu.Lock() - // re-check: a concurrent "playing" report may have created the session during the load above - if p.hasPlayingSession(clientId, params.MediaId) { - p.sessionsMu.Unlock() - log.Trace(ctx, "Ignoring out-of-order starting report for playing session", "clientId", clientId, "mediaId", params.MediaId) - return nil - } - err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate)) - p.sessionsMu.Unlock() - if err != nil { - log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) - } - p.enqueuePlaybackReport(ctx, info, filtered) - - case StatePlaying, StatePaused: - info, getErr := p.playMap.Get(clientId) - if getErr != nil || info.MediaFile.ID != params.MediaId { - mf, err := p.ds.MediaFile().GetWithParticipants(ctx, 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 - filtered = p.isFilteredOut(ctx, &info.MediaFile) - info.filtered = filtered - ttl := 30 * time.Minute - if params.State == StatePlaying { - ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) - } - log.Trace(ctx, "Updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, "positionMs", params.PositionMs, "playbackRate", params.PlaybackRate, "ttl", ttl) - p.sessionsMu.Lock() - err := p.playMap.AddWithTTL(clientId, info, ttl) - p.sessionsMu.Unlock() - if err != nil { - log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) - } - p.enqueuePlaybackReport(ctx, info, filtered) - - case StateStopped: - var loadedMF *model.MediaFile - haveVerdict := false - if !params.IgnoreScrobble && player.ScrobbleEnabled { - mf, err := p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId) - if err != nil { - return err - } - loadedMF = mf - filtered = p.isFilteredOut(ctx, mf) - haveVerdict = true - trackDurationMs := int64(mf.Duration * 1000) - threshold := min(trackDurationMs*50/100, 240_000) - if params.PositionMs >= threshold { - 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, filtered) - } - } - p.sessionsMu.Lock() - info, getErr := p.playMap.Get(clientId) - // A late stop for a previous track must not end the current session nor reach - // playback reporters, or presence-style plugins would clear the active track. - if getErr == nil && info.MediaFile.ID != params.MediaId { - p.sessionsMu.Unlock() - log.Trace(ctx, "Ignoring out-of-order stopped report for different track", "clientId", clientId, "stoppedMediaId", params.MediaId, "currentMediaId", info.MediaFile.ID) - return nil - } - p.playMap.Remove(clientId) - p.sessionsMu.Unlock() - stoppedInfo := PlaybackSession{ - UserId: user.ID, - Username: user.UserName, - PlayerId: clientId, - PlayerName: client, - State: params.State, - PositionMs: params.PositionMs, - PlaybackRate: params.PlaybackRate, - LastReport: now, - } - if getErr == nil { - stoppedInfo.MediaFile = info.MediaFile - stoppedInfo.Start = info.Start - } else { - mf := loadedMF - if mf == nil { - var mfErr error - mf, mfErr = p.ds.MediaFile().GetWithParticipants(ctx, params.MediaId) - if mfErr != nil { - return mfErr - } - } - stoppedInfo.MediaFile = *mf - } - if !haveVerdict { - filtered = p.isFilteredOut(ctx, &stoppedInfo.MediaFile) - } - p.enqueuePlaybackReport(ctx, stoppedInfo, filtered) + info := NowPlayingInfo{ + MediaFile: *mf, + Start: time.Now(), + Username: user.UserName, + PlayerId: playerId, + PlayerName: playerName, } - if conf.Server.EnableNowPlaying { - p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) + ttl := time.Duration(int(mf.Duration)+5) * time.Second + _ = p.playMap.SetWithTTL(playerId, info, ttl) + player, _ := request.PlayerFrom(ctx) + if player.ScrobbleEnabled { + p.dispatchNowPlaying(ctx, user.ID, mf) } - - // NowPlaying gating, by design distinct from scrobble submission: - // - IgnoreScrobble=true -> still send NowPlaying (suppresses only the - // scrobble submission/play-count above), mirroring the legacy scrobble - // endpoint's submission=false behavior. - // - player.ScrobbleEnabled=false -> never send NowPlaying. - // External agents here are the active scrobblers (Last.fm, ListenBrainz, and - // scrobbler plugins) returned by getActiveScrobblers; see dispatchNowPlaying. - if player.ScrobbleEnabled && - (params.State == StateStarting || params.State == StatePlaying) { - if filtered { - log.Debug(ctx, "Ignoring external NowPlaying update for filtered track", "mediaId", params.MediaId) - } else if info, err := p.playMap.Get(clientId); err == nil { - p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000)) - } - } - return nil } -func (p *playTracker) GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) { - // The cache is process-global, so it holds every user's playback, across all libraries. - res := p.playMap.Values() - if user, ok := request.UserFrom(ctx); ok { - res = slice.Filter(res, func(s PlaybackSession) bool { - return user.HasLibraryAccess(s.MediaFile.LibraryID) - }) +func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile) { + if t.Artist == consts.UnknownArtist { + log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist) + return } - 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) + for name, s := range p.scrobblers { + if !s.IsAuthorized(ctx, userId) { + continue + } + log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist) + err := s.NowPlaying(ctx, userId, t) + 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) { + var res []NowPlayingInfo + for _, playerId := range p.playMap.GetKeys() { + value, err := p.playMap.Get(playerId) + if err != nil { + continue + } + info := value.(NowPlayingInfo) + res = append(res, info) + } + sort.Slice(res, func(i, j int) bool { + return res[i].Start.After(res[j].Start) + }) return res, nil } @@ -471,18 +131,17 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro username, _ := request.UsernameFrom(ctx) player, _ := request.PlayerFrom(ctx) if !player.ScrobbleEnabled { - log.Debug(ctx, "External scrobbling disabled for this player", "player", player.Name, "ip", player.IP, "user", username) + log.Debug(ctx, "External scrobbling disabled for this player", "player", player.Name, "ip", player.IPAddress, "user", username) } event := &events.RefreshResource{} success := 0 for _, s := range submissions { - mf, err := p.ds.MediaFile().GetWithParticipants(ctx, s.TrackID) + mf, err := p.ds.MediaFile(ctx).Get(s.TrackID) if err != nil { log.Error(ctx, "Cannot find track for scrobbling", "id", s.TrackID, "user", username, err) continue } - filtered := p.isFilteredOut(ctx, mf) err = p.incPlay(ctx, mf, s.Timestamp) if err != nil { log.Error(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", username, err) @@ -491,7 +150,7 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro event.With("song", mf.ID).With("album", mf.AlbumID).With("artist", mf.AlbumArtistID) log.Info(ctx, "Scrobbled", "title", mf.Title, "artist", mf.Artist, "user", username, "timestamp", s.Timestamp) if player.ScrobbleEnabled { - p.dispatchScrobble(ctx, mf, s.Timestamp, filtered) + p.dispatchScrobble(ctx, mf, s.Timestamp) } } } @@ -504,66 +163,35 @@ func (p *playTracker) Submit(ctx context.Context, submissions []Submission) erro func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, timestamp time.Time) error { return p.ds.WithTx(func(tx model.DataStore) error { - err := tx.MediaFile().IncPlayCount(ctx, track.ID, timestamp) + err := p.ds.MediaFile(ctx).IncPlayCount(track.ID, timestamp) if err != nil { return err } - err = tx.Album().IncPlayCount(ctx, track.AlbumID, timestamp) + err = p.ds.Album(ctx).IncPlayCount(track.AlbumID, timestamp) if err != nil { return err } - for _, artist := range track.Participants[model.RoleArtist] { - err = tx.Artist().IncPlayCount(ctx, artist.ID, timestamp) - if err != nil { - return err - } - } - if conf.Server.EnableScrobbleHistory { - return tx.Scrobble().RecordScrobble(ctx, track.ID, timestamp) - } - return nil + err = p.ds.Artist(ctx).IncPlayCount(track.ArtistID, timestamp) + return err }) } -// Take this verdict before incPlay mutates what a filter reads, and independently of -// which scrobblers are active: it can be stored on a session and dispatched much later. -// Any parse or query failure fails open, because filtering must not break scrobbling. -func (p *playTracker) isFilteredOut(ctx context.Context, t *model.MediaFile) bool { - u, _ := request.UserFrom(ctx) - if u.ScrobbleFilter == "" { - return false - } - var c criteria.Criteria - if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil { - log.Warn(ctx, "Invalid scrobble filter, ignoring", "user", u.UserName, err) - return false - } - match, err := p.ds.MediaFile().MatchesCriteria(ctx, t.ID, c) - if err != nil { - log.Warn(ctx, "Error evaluating scrobble filter, ignoring", "user", u.UserName, "track", t.Title, err) - return false - } - return match -} - -func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time, filtered bool) { +func (p *playTracker) dispatchScrobble(ctx context.Context, t *model.MediaFile, playTime time.Time) { if t.Artist == consts.UnknownArtist { log.Debug(ctx, "Ignoring external Scrobble for track with unknown artist", "track", t.Title, "artist", t.Artist) return } - if filtered { - log.Debug(ctx, "Ignoring external Scrobble for filtered track", "track", t.Title, "artist", t.Artist) - return - } - - allScrobblers := p.getActiveScrobblers() u, _ := request.UserFrom(ctx) scrobble := Scrobble{MediaFile: *t, TimeStamp: playTime} - for name, s := range allScrobblers { + for name, s := range p.scrobblers { if !s.IsAuthorized(ctx, u.ID) { continue } - log.Debug(ctx, "Buffering Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist) + if conf.Server.DevEnableBufferedScrobble { + log.Debug(ctx, "Buffering Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist) + } else { + log.Debug(ctx, "Sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist) + } err := s.Scrobble(ctx, u.ID, scrobble) if err != nil { log.Error(ctx, "Error sending Scrobble", "scrobbler", name, "track", t.Title, "artist", t.Artist, err) @@ -580,10 +208,3 @@ func Register(name string, init Constructor) { } constructors[name] = init } - -// IsBuiltinScrobbler reports whether name belongs to a registered builtin -// scrobbler (e.g. "lastfm", "listenbrainz"). -func IsBuiltinScrobbler(name string) bool { - _, ok := constructors[name] - return ok -} diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 045827811..fa074fb2d 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -3,18 +3,12 @@ package scrobbler import ( "context" "errors" - "fmt" - "net/http" - "sync" - "sync/atomic" "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/tests" @@ -22,126 +16,79 @@ import ( . "github.com/onsi/gomega" ) -type mockPluginLoader struct { - mu sync.RWMutex - names []string - scrobblers map[string]Scrobbler -} - -func (m *mockPluginLoader) PluginNames(service string) []string { - m.mu.RLock() - defer m.mu.RUnlock() - return m.names -} - -func (m *mockPluginLoader) SetNames(names []string) { - m.mu.Lock() - defer m.mu.Unlock() - m.names = names -} - -func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) { - m.mu.RLock() - defer m.mu.RUnlock() - s, ok := m.scrobblers[name] - return s, ok -} - -// flipOnPlayRepo reports one filter verdict before the play is counted and another -// after, reproducing a filter that reads annotations incPlay mutates. -type flipOnPlayRepo struct { - model.MediaFileRepository - before, after bool - played atomic.Bool -} - -func (r *flipOnPlayRepo) IncPlayCount(ctx context.Context, id string, ts time.Time) error { - r.played.Store(true) - return r.MediaFileRepository.IncPlayCount(ctx, id, ts) -} - -func (r *flipOnPlayRepo) MatchesCriteria(context.Context, string, criteria.Criteria) (bool, error) { - if r.played.Load() { - return r.after, nil - } - return r.before, nil -} - -// slowMediaFileRepo widens the window between a report's session check and its -// write, making check-then-write races reproducible. -type slowMediaFileRepo struct { - model.MediaFileRepository -} - -func (s *slowMediaFileRepo) GetWithParticipants(ctx context.Context, id string) (*model.MediaFile, error) { - time.Sleep(5 * time.Millisecond) - return s.MediaFileRepository.GetWithParticipants(ctx, id) -} - var _ = Describe("PlayTracker", func() { var ctx context.Context var ds model.DataStore - var tracker *playTracker - var eventBroker *fakeEventBroker + var tracker PlayTracker var track model.MediaFile var album model.Album - var artist1 model.Artist - var artist2 model.Artist - var fake *fakeScrobbler + var artist model.Artist + var fake fakeScrobbler BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = GinkgoT().Context() - ctx = request.WithUser(ctx, model.User{ID: "u-1", Libraries: model.Libraries{{ID: 1}}}) + // Remove buffering to simplify tests + conf.Server.DevEnableBufferedScrobble = false + + ctx = context.Background() + ctx = request.WithUser(ctx, model.User{ID: "u-1"}) ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) ds = &tests.MockDataStore{} - fake = &fakeScrobbler{Authorized: true} - Register("fake", func(model.DataStore) Scrobbler { - return fake + fake = fakeScrobbler{Authorized: true} + Register("fake", func(ds model.DataStore) Scrobbler { + return &fake }) - Register("disabled", func(model.DataStore) Scrobbler { - return nil - }) - eventBroker = &fakeEventBroker{} - tracker = newPlayTracker(ds, eventBroker, nil) - tracker.builtinScrobblers["fake"] = fake // Bypass buffering for tests + tracker = newPlayTracker(ds, events.GetBroker()) track = model.MediaFile{ - ID: "123", - LibraryID: 1, - Title: "Track Title", - Album: "Track Album", - AlbumID: "al-1", - TrackNumber: 1, - Duration: 180, - MbzRecordingID: "mbz-123", - Participants: map[model.Role]model.ParticipantList{ - model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1"), _p("ar-2", "Artist 2")}, - }, + ID: "123", + Title: "Track Title", + Album: "Track Album", + AlbumID: "al-1", + Artist: "Track Artist", + ArtistID: "ar-1", + AlbumArtist: "Track AlbumArtist", + TrackNumber: 1, + Duration: 180, + MbzTrackID: "mbz-123", } - _ = ds.MediaFile().Put(ctx, &track) - artist1 = model.Artist{ID: "ar-1"} - _ = ds.Artist().Put(ctx, &artist1) - artist2 = model.Artist{ID: "ar-2"} - _ = ds.Artist().Put(ctx, &artist2) + _ = ds.MediaFile(ctx).Put(&track) + artist = model.Artist{ID: "ar-1"} + _ = ds.Artist(ctx).Put(&artist) album = model.Album{ID: "al-1"} - _ = ds.Album().(*tests.MockAlbumRepo).Put(ctx, &album) + _ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album) }) - AfterEach(func() { - // Stop the worker goroutine to prevent data races between tests - tracker.stopBackgroundWorkers() - }) + Describe("NowPlaying", func() { + It("sends track to agent", func() { + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123") + Expect(err).ToNot(HaveOccurred()) + Expect(fake.NowPlayingCalled).To(BeTrue()) + Expect(fake.UserID).To(Equal("u-1")) + Expect(fake.Track.ID).To(Equal("123")) + }) + It("does not send track to agent if user has not authorized", func() { + fake.Authorized = false - It("does not register disabled scrobblers", func() { - Expect(tracker.builtinScrobblers).To(HaveKey("fake")) - Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled")) - }) + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123") - Describe("IsBuiltinScrobbler", func() { - It("reports whether the name belongs to a registered builtin scrobbler", func() { - Expect(IsBuiltinScrobbler("fake")).To(BeTrue()) - Expect(IsBuiltinScrobbler("some-plugin")).To(BeFalse()) + Expect(err).ToNot(HaveOccurred()) + Expect(fake.NowPlayingCalled).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") + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.NowPlayingCalled).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") + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.NowPlayingCalled).To(BeFalse()) }) }) @@ -149,17 +96,11 @@ var _ = Describe("PlayTracker", func() { It("returns current playing music", func() { track2 := track track2.ID = "456" - _ = ds.MediaFile().Put(ctx, &track2) - 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", - }) + _ = ds.MediaFile(ctx).Put(&track2) + ctx = request.WithUser(context.Background(), model.User{UserName: "user-1"}) + _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123") + ctx = request.WithUser(context.Background(), model.User{UserName: "user-2"}) + _ = tracker.NowPlaying(ctx, "player-2", "player-two", "456") playing, err := tracker.GetNowPlaying(ctx) @@ -175,104 +116,6 @@ var _ = Describe("PlayTracker", func() { Expect(playing[1].Username).To(Equal("user-1")) Expect(playing[1].MediaFile.ID).To(Equal("123")) }) - - It("hides sessions playing from libraries the caller cannot access", func() { - hidden := track - hidden.ID = "789" - hidden.LibraryID = 2 - _ = ds.MediaFile().Put(ctx, &hidden) - reporter := request.WithPlayer( - request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}), - model.Player{ScrobbleEnabled: true}, - ) - _ = tracker.ReportPlayback(reporter, ReportPlaybackParams{ - MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", - }) - - playing, err := tracker.GetNowPlaying(ctx) - - Expect(err).ToNot(HaveOccurred()) - Expect(playing).To(BeEmpty(), "u-1 is granted library 1 only") - }) - - It("shows every session to an admin", func() { - hidden := track - hidden.ID = "789" - hidden.LibraryID = 2 - _ = ds.MediaFile().Put(ctx, &hidden) - reporter := request.WithPlayer( - request.WithUser(GinkgoT().Context(), model.User{ID: "u-2", UserName: "user-2"}), - model.Player{ScrobbleEnabled: true}, - ) - _ = tracker.ReportPlayback(reporter, ReportPlaybackParams{ - MediaId: "789", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", - }) - - adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "adm", IsAdmin: true}) - playing, err := tracker.GetNowPlaying(adminCtx) - - Expect(err).ToNot(HaveOccurred()) - Expect(playing).To(HaveLen(1)) - Expect(playing[0].MediaFile.ID).To(Equal("789")) - }) - }) - - Describe("Expiration events", func() { - It("sends event when entry expires", func() { - 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) - Expect(ok).To(BeTrue()) - Expect(evt.Count).To(Equal(0)) - }) - - It("does not send event when disabled", func() { - conf.Server.EnableNowPlaying = false - tracker = newPlayTracker(ds, eventBroker, nil) - 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() { @@ -283,12 +126,9 @@ var _ = Describe("PlayTracker", func() { err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}}) Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) - Expect(fake.GetUserID()).To(Equal("u-1")) - lastScrobble := fake.LastScrobble.Load() - Expect(lastScrobble.TimeStamp).To(BeTemporally("~", ts, 1*time.Second)) - Expect(lastScrobble.ID).To(Equal("123")) - Expect(lastScrobble.Participants).To(Equal(track.Participants)) + Expect(fake.ScrobbleCalled).To(BeTrue()) + Expect(fake.UserID).To(Equal("u-1")) + Expect(fake.LastScrobble.ID).To(Equal("123")) }) It("increments play counts in the DB", func() { @@ -300,10 +140,7 @@ var _ = Describe("PlayTracker", func() { Expect(err).ToNot(HaveOccurred()) Expect(track.PlayCount).To(Equal(int64(1))) Expect(album.PlayCount).To(Equal(int64(1))) - - // It should increment play counts for all artists - Expect(artist1.PlayCount).To(Equal(int64(1))) - Expect(artist2.PlayCount).To(Equal(int64(1))) + Expect(artist.PlayCount).To(Equal(int64(1))) }) It("does not send track to agent if user has not authorized", func() { @@ -312,7 +149,7 @@ var _ = Describe("PlayTracker", func() { err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) + Expect(fake.ScrobbleCalled).To(BeFalse()) }) It("does not send track to agent if player is not enabled to send scrobbles", func() { @@ -321,7 +158,7 @@ var _ = Describe("PlayTracker", func() { err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) + Expect(fake.ScrobbleCalled).To(BeFalse()) }) It("does not send track to agent if artist is unknown", func() { @@ -330,1286 +167,56 @@ var _ = Describe("PlayTracker", func() { err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) + Expect(fake.ScrobbleCalled).To(BeFalse()) }) It("increments play counts even if it cannot scrobble", func() { - fake.SetError(errors.New("error")) + fake.Error = errors.New("error") err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) + Expect(fake.ScrobbleCalled).To(BeFalse()) Expect(track.PlayCount).To(Equal(int64(1))) Expect(album.PlayCount).To(Equal(int64(1))) - - // It should increment play counts for all artists - Expect(artist1.PlayCount).To(Equal(int64(1))) - Expect(artist2.PlayCount).To(Equal(int64(1))) + Expect(artist.PlayCount).To(Equal(int64(1))) }) - Context("Scrobble History", func() { - It("records scrobble in repository", func() { - conf.Server.EnableScrobbleHistory = true - ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"}) - ts := time.Now() - - err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}}) - - Expect(err).ToNot(HaveOccurred()) - - mockDS := ds.(*tests.MockDataStore) - mockScrobble := mockDS.Scrobble().(*tests.MockScrobbleRepo) - Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1)) - Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123")) - Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1")) - Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts.Unix())) - }) - - It("does not record scrobble when history is disabled", func() { - conf.Server.EnableScrobbleHistory = false - ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"}) - ts := time.Now() - - err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}}) - - Expect(err).ToNot(HaveOccurred()) - mockDS := ds.(*tests.MockDataStore) - mockScrobble := mockDS.Scrobble().(*tests.MockScrobbleRepo) - Expect(mockScrobble.RecordedScrobbles).To(HaveLen(0)) - }) - }) }) - Describe("Scrobble filter", func() { - var repo *tests.MockMediaFileRepo - - BeforeEach(func() { - ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1", - ScrobbleFilter: `{"all":[{"contains":{"title":"Track"}}]}`}) - repo = ds.MediaFile().(*tests.MockMediaFileRepo) - }) - - It("does not send a matching track to the agent", func() { - repo.MatchesCriteriaValue = true - - err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) - }) - - It("still increments play counts for a filtered track", func() { - repo.MatchesCriteriaValue = true - - err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) - - Expect(err).ToNot(HaveOccurred()) - Expect(track.PlayCount).To(Equal(int64(1))) - Expect(album.PlayCount).To(Equal(int64(1))) - }) - - It("sends a non-matching track to the agent", func() { - repo.MatchesCriteriaValue = false - - err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) - }) - - It("fails open when evaluation errors", func() { - repo.MatchesCriteriaErr = errors.New("boom") - - err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) - }) - - It("fails open when the stored filter is not valid JSON", func() { - ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1", ScrobbleFilter: `{broken`}) - repo.MatchesCriteriaValue = true - - err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) - }) - - It("does not send now-playing for a filtered track", func() { - repo.MatchesCriteriaValue = true - - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"}) - - Expect(err).ToNot(HaveOccurred()) - Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) - }) - - It("does not send playback reports for a filtered track", func() { - repo.MatchesCriteriaValue = true - - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"}) - - Expect(err).ToNot(HaveOccurred()) - Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse()) - }) - - It("sends playback reports for a non-matching track", func() { - repo.MatchesCriteriaValue = false - - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", State: StateStarting, ClientId: "player-1", ClientName: "player"}) - - Expect(err).ToNot(HaveOccurred()) - Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) - }) - - It("evaluates the filter even when no scrobbler is active yet", func() { - // The verdict is stored on the session and dispatched at expiry, by which - // time a plugin scrobbler may have been enabled. - tracker.builtinScrobblers = map[string]Scrobbler{} - repo.MatchesCriteriaValue = true - - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", State: StatePlaying, ClientId: "player-12", ClientName: "player"}) - - Expect(err).ToNot(HaveOccurred()) - stored, getErr := tracker.playMap.Get("player-12") - Expect(getErr).ToNot(HaveOccurred()) - Expect(stored.filtered).To(BeTrue()) - }) - - Context("when incPlay itself flips the filter", func() { - // A filter on playCount or lastPlayed changes verdict the moment incPlay - // commits, so the verdict has to be taken before it, not at dispatch time. - var flip *flipOnPlayRepo - - install := func(before, after bool) { - flip = &flipOnPlayRepo{MediaFileRepository: ds.MediaFile(), before: before, after: after} - ds.(*tests.MockDataStore).MockedMediaFile = flip - } - - It("does not scrobble a track the filter matched before the play was counted", func() { - install(true, false) - - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"}) - - Expect(err).ToNot(HaveOccurred()) - Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run") - Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) - }) - - It("still sends the stopped report when the filter only starts matching after the play", func() { - install(false, true) - - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", State: StateStopped, PositionMs: 120_000, ClientId: "player-1", ClientName: "player"}) - - Expect(err).ToNot(HaveOccurred()) - Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run") - Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) - }) - - It("does not report an expired session for a filtered track", func() { - // The expiry callback runs with a stub user, so it cannot evaluate the - // filter itself and must reuse the verdict stored on the session. - info := PlaybackSession{ - MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1", - PlayerId: "player-9", PlayerName: "test-player", State: StatePlaying, filtered: true, - } - _ = tracker.playMap.AddWithTTL("player-9", info, 10*time.Millisecond) - - Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse()) - }) - - It("still reports an expired session for a track that is not filtered", func() { - info := PlaybackSession{ - MediaFile: track, Start: time.Now(), UserId: "u-1", Username: "user-1", - PlayerId: "player-10", PlayerName: "test-player", State: StatePlaying, filtered: false, - } - _ = tracker.playMap.AddWithTTL("player-10", info, 10*time.Millisecond) - - Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) - }) - - It("stores the verdict on the session so expiry can reuse it", func() { - repo.MatchesCriteriaValue = true - - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", State: StatePlaying, ClientId: "player-11", ClientName: "player"}) - - Expect(err).ToNot(HaveOccurred()) - stored, getErr := tracker.playMap.Get("player-11") - Expect(getErr).ToNot(HaveOccurred()) - Expect(stored.filtered).To(BeTrue()) - }) - - It("does not scrobble a Submit whose filter matched before the play was counted", func() { - install(true, false) - - err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) - - Expect(err).ToNot(HaveOccurred()) - Expect(flip.played.Load()).To(BeTrue(), "incPlay must still have run") - Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) - }) - }) - }) - - Describe("ReportPlayback", func() { - const defaultClientId = "client-1" - - 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 when switching tracks on same player", func() { - track2 := track - track2.ID = "456" - _ = ds.MediaFile().Put(ctx, &track2) - - 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: "456", 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].MediaFile.ID).To(Equal("456")) - 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().Put(ctx, &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().Put(ctx, &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() { - 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, - IgnoreScrobble: true, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(track.PlayCount).To(Equal(int64(0))) - Consistently(func() bool { return fake.ScrobbleCalled.Load() }).Should(BeFalse()) - }) - - 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("resilience (out-of-order reports)", func() { - BeforeEach(func() { - track2 := track - track2.ID = "456" - _ = ds.MediaFile().Put(ctx, &track2) - }) - - It("does not downgrade an actively playing session when a late starting report arrives for the same track", func() { - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 1000, 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("playing")) - Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(1000))) - }) - - It("keeps the current session when a stopped report arrives for a different track", func() { - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "456", PositionMs: 0, State: "playing", 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()) - - playing, err := tracker.GetNowPlaying(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(playing).To(HaveLen(1)) - Expect(playing[0].MediaFile.ID).To(Equal("456")) - Expect(playing[0].State).To(Equal("playing")) - }) - - It("still auto-scrobbles the stopped track when the current session is for a different track", func() { - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "456", PositionMs: 0, State: "playing", 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))) - }) - - It("does not dispatch NowPlaying from an ignored out-of-order starting report", func() { - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 60000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, - }) - Expect(err).ToNot(HaveOccurred()) - Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) - fake.nowPlayingCalled.Store(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()) - }) - - It("never lets a concurrent starting report downgrade the playing session", func() { - ds.(*tests.MockDataStore).MockedMediaFile = &slowMediaFileRepo{MediaFileRepository: ds.MediaFile()} - for i := range 20 { - raceClientId := fmt.Sprintf("race-client-%d", i) - var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - defer GinkgoRecover() - _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: raceClientId, - }) - }() - go func() { - defer wg.Done() - defer GinkgoRecover() - _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: raceClientId, - }) - }() - wg.Wait() - info, err := tracker.playMap.Get(raceClientId) - Expect(err).ToNot(HaveOccurred()) - Expect(info.State).To(Equal("playing"), "iteration %d", i) - } - }) - - It("does NOT forward a stopped report for a different track to playback reporters", func() { - err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ - MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, - }) - 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: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, - }) - Expect(err).ToNot(HaveOccurred()) - - Consistently(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeFalse()) - }) - }) - - 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("still dispatches NowPlaying 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()) - Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) - }) - - 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 - - BeforeEach(func() { - pluginFake = &fakeScrobbler{Authorized: true} - pluginLoader = &mockPluginLoader{ - names: []string{"plugin1"}, - scrobblers: map[string]Scrobbler{"plugin1": pluginFake}, - } - tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader) - - // Bypass buffering for both built-in and plugin scrobblers - tracker.builtinScrobblers["fake"] = fake - tracker.pluginScrobblers["plugin1"] = pluginFake - }) - - It("registers and uses plugin scrobbler for NowPlaying", func() { - 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() { - _ = 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) - pluginLoader.SetNames([]string{}) - _ = 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.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()) - }) - - It("calls plugin scrobbler for Submit", func() { - ts := time.Now() - err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}}) - Expect(err).ToNot(HaveOccurred()) - Expect(pluginFake.ScrobbleCalled.Load()).To(BeTrue()) - }) - }) - - Describe("Plugin Scrobbler Management", func() { - var pluginScr *fakeScrobbler - var mockPlugin *mockPluginLoader - var pTracker *playTracker - var mockedBS *mockBufferedScrobbler - - BeforeEach(func() { - ctx = GinkgoT().Context() - ctx = request.WithUser(ctx, model.User{ID: "u-1"}) - ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) - ds = &tests.MockDataStore{} - - // Setup plugin scrobbler - pluginScr = &fakeScrobbler{Authorized: true} - mockPlugin = &mockPluginLoader{ - names: []string{"plugin1"}, - scrobblers: map[string]Scrobbler{"plugin1": pluginScr}, - } - - // Create a tracker with the mock plugin loader - pTracker = newPlayTracker(ds, events.GetBroker(), mockPlugin) - - // Create a mock buffered scrobbler and explicitly cast it to Scrobbler - mockedBS = &mockBufferedScrobbler{ - wrapped: pluginScr, - } - // Make sure the instance is added with its concrete type preserved - pTracker.pluginScrobblers["plugin1"] = mockedBS - }) - - It("calls Stop on scrobblers when removing them", func() { - // Change the plugin names to simulate a plugin being removed - mockPlugin.SetNames([]string{}) - - // Call refreshPluginScrobblers which should detect the removed plugin - pTracker.refreshPluginScrobblers() - - // Verify the Stop method was called - Expect(mockedBS.stopCalled).To(BeTrue()) - - // Verify the scrobbler was removed from the map - Expect(pTracker.pluginScrobblers).NotTo(HaveKey("plugin1")) - }) - }) - - Describe("Plugin reload (config update) behavior", func() { - var mockPlugin *mockPluginLoader - var pTracker *playTracker - var originalScrobbler *fakeScrobbler - var reloadedScrobbler *fakeScrobbler - - BeforeEach(func() { - ctx = GinkgoT().Context() - ctx = request.WithUser(ctx, model.User{ID: "u-1"}) - ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) - ds = &tests.MockDataStore{} - - // Setup initial plugin scrobbler - originalScrobbler = &fakeScrobbler{Authorized: true} - reloadedScrobbler = &fakeScrobbler{Authorized: true} - - mockPlugin = &mockPluginLoader{ - names: []string{"plugin1"}, - scrobblers: map[string]Scrobbler{"plugin1": originalScrobbler}, - } - - // Create tracker - this will create buffered scrobblers with loaders - pTracker = newPlayTracker(ds, events.GetBroker(), mockPlugin) - - // Trigger initial plugin registration - pTracker.refreshPluginScrobblers() - }) - - AfterEach(func() { - pTracker.stopBackgroundWorkers() - }) - - It("uses the new plugin instance after reload (simulating config update)", func() { - // First call should use the original scrobbler - scrobblers := pTracker.getActiveScrobblers() - pluginScr := scrobblers["plugin1"] - Expect(pluginScr).ToNot(BeNil()) - - err := pluginScr.NowPlaying(ctx, "u-1", &track, 0) - Expect(err).ToNot(HaveOccurred()) - Expect(originalScrobbler.GetNowPlayingCalled()).To(BeTrue()) - Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeFalse()) - - // Simulate plugin reload (config update): replace the scrobbler in the loader - // This is what happens when UpdatePluginConfig is called - the plugin manager - // unloads the old plugin and loads a new instance - mockPlugin.mu.Lock() - mockPlugin.scrobblers["plugin1"] = reloadedScrobbler - mockPlugin.mu.Unlock() - - // Reset call tracking - originalScrobbler.nowPlayingCalled.Store(false) - - // Get scrobblers again - should still return the same buffered scrobbler - // but subsequent calls should use the new plugin instance via the loader - scrobblers = pTracker.getActiveScrobblers() - pluginScr = scrobblers["plugin1"] - - err = pluginScr.NowPlaying(ctx, "u-1", &track, 0) - Expect(err).ToNot(HaveOccurred()) - - // The new scrobbler should be called, not the old one - Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeTrue()) - Expect(originalScrobbler.GetNowPlayingCalled()).To(BeFalse()) - }) - - It("handles plugin becoming unavailable temporarily", func() { - // First verify plugin works - scrobblers := pTracker.getActiveScrobblers() - pluginScr := scrobblers["plugin1"] - - err := pluginScr.NowPlaying(ctx, "u-1", &track, 0) - Expect(err).ToNot(HaveOccurred()) - Expect(originalScrobbler.GetNowPlayingCalled()).To(BeTrue()) - - // Simulate plugin becoming unavailable (e.g., during reload) - mockPlugin.mu.Lock() - delete(mockPlugin.scrobblers, "plugin1") - mockPlugin.mu.Unlock() - - originalScrobbler.nowPlayingCalled.Store(false) - - // NowPlaying should return error when plugin unavailable - err = pluginScr.NowPlaying(ctx, "u-1", &track, 0) - Expect(err).To(HaveOccurred()) - Expect(originalScrobbler.GetNowPlayingCalled()).To(BeFalse()) - - // Simulate plugin becoming available again - mockPlugin.mu.Lock() - mockPlugin.scrobblers["plugin1"] = reloadedScrobbler - mockPlugin.mu.Unlock() - - // Should work again with new instance - err = pluginScr.NowPlaying(ctx, "u-1", &track, 0) - Expect(err).ToNot(HaveOccurred()) - Expect(reloadedScrobbler.GetNowPlayingCalled()).To(BeTrue()) - }) - - It("IsAuthorized uses the current plugin instance", func() { - scrobblers := pTracker.getActiveScrobblers() - pluginScr := scrobblers["plugin1"] - - // Original is authorized - Expect(pluginScr.IsAuthorized(ctx, "u-1")).To(BeTrue()) - - // Replace with unauthorized scrobbler - unauthorizedScrobbler := &fakeScrobbler{Authorized: false} - mockPlugin.mu.Lock() - mockPlugin.scrobblers["plugin1"] = unauthorizedScrobbler - mockPlugin.mu.Unlock() - - // Should reflect the new scrobbler's authorization status - Expect(pluginScr.IsAuthorized(ctx, "u-1")).To(BeFalse()) - }) - }) }) -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 - 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] - err atomic.Pointer[error] - scrobbleAttempts atomic.Int32 -} - -// SetError sets the error returned by IsAuthorized/NowPlaying/Scrobble/PlaybackReport. -func (f *fakeScrobbler) SetError(err error) { - f.err.Store(&err) -} - -func (f *fakeScrobbler) getError() error { - if e := f.err.Load(); e != nil { - return *e - } - return nil -} - -// ScrobbleAttempts returns how many times Scrobble was called. -func (f *fakeScrobbler) ScrobbleAttempts() int32 { - return f.scrobbleAttempts.Load() -} - -func (f *fakeScrobbler) GetNowPlayingCalled() bool { - return f.nowPlayingCalled.Load() -} - -func (f *fakeScrobbler) GetUserID() string { - if p := f.userID.Load(); p != nil { - return *p - } - return "" -} - -func (f *fakeScrobbler) GetUsername() string { - if p := f.username.Load(); p != nil { - return *p - } - return "" -} - -func (f *fakeScrobbler) GetTrack() *model.MediaFile { - return f.track.Load() + Authorized bool + NowPlayingCalled bool + ScrobbleCalled bool + UserID string + Track *model.MediaFile + LastScrobble Scrobble + Error error } func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool { - return f.getError() == nil && f.Authorized + return f.Error == nil && f.Authorized } -func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { - f.nowPlayingCalled.Store(true) - if err := f.getError(); err != nil { - return err +func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile) error { + f.NowPlayingCalled = true + if f.Error != nil { + return f.Error } - f.userID.Store(&userId) - // Capture username from context (this is what plugin scrobblers do) - username, _ := request.UsernameFrom(ctx) - if username == "" { - if u, ok := request.UserFrom(ctx); ok { - username = u.UserName - } - } - if username != "" { - f.username.Store(&username) - } - f.track.Store(track) - f.position.Store(int32(position)) + f.UserID = userId + f.Track = track return nil } func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { - f.userID.Store(&userId) - // Capture username from context (this is what plugin scrobblers do) - username, _ := request.UsernameFrom(ctx) - if username == "" { - if u, ok := request.UserFrom(ctx); ok { - username = u.UserName - } - } - if username != "" { - f.username.Store(&username) - } - f.LastScrobble.Store(&s) - f.ScrobbleCalled.Store(true) - f.scrobbleAttempts.Add(1) - if err := f.getError(); err != nil { - return err + f.ScrobbleCalled = true + if f.Error != nil { + return f.Error } + f.UserID = userId + f.LastScrobble = s return nil } - -func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { - f.PlaybackReportCalled.Store(true) - if err := f.getError(); err != nil { - return err - } - f.userID.Store(new(info.UserId)) - 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 { - p.Artist.SortArtistName = sortName[0] - } - return p -} - -type fakeEventBroker struct { - http.Handler - events []events.Event - mu sync.Mutex -} - -func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { - f.mu.Lock() - defer f.mu.Unlock() - f.events = append(f.events, event) -} - -func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { - f.mu.Lock() - defer f.mu.Unlock() - f.events = append(f.events, event) -} - -func (f *fakeEventBroker) getEvents() []events.Event { - f.mu.Lock() - defer f.mu.Unlock() - return f.events -} - -var _ events.Broker = (*fakeEventBroker)(nil) - -// mockBufferedScrobbler used to test that Stop is called -type mockBufferedScrobbler struct { - wrapped Scrobbler - stopCalled bool -} - -func (m *mockBufferedScrobbler) Stop() { - m.stopCalled = true -} - -func (m *mockBufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool { - return m.wrapped.IsAuthorized(ctx, userId) -} - -func (m *mockBufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { - return m.wrapped.NowPlaying(ctx, userId, track, position) -} - -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 deleted file mode 100644 index 799dac616..000000000 --- a/core/scrobbler/playbackreport_worker.go +++ /dev/null @@ -1,69 +0,0 @@ -package scrobbler - -import ( - "context" - - "github.com/navidrome/navidrome/log" -) - -func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession, filtered bool) { - p.prMu.Lock() - defer p.prMu.Unlock() - ctx = context.WithoutCancel(ctx) - p.prQueue = append(p.prQueue, playbackReportEntry{ - ctx: ctx, - info: info, - filtered: filtered, - }) - 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, entry.filtered) - } - } -} - -func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler, filtered bool) { - if filtered { - log.Debug(ctx, "Ignoring external PlaybackReport for filtered track", "track", info.MediaFile.Title, "state", info.State) - return - } - for name, s := range allScrobblers { - if !s.IsAuthorized(ctx, info.UserId) { - continue - } - 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/share.go b/core/share.go index 33c8996ef..6f025bf1b 100644 --- a/core/share.go +++ b/core/share.go @@ -2,75 +2,78 @@ package core import ( "context" - "fmt" - "slices" "strings" "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" + "github.com/deluan/rest" + gonanoid "github.com/matoous/go-nanoid/v2" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/navidrome/navidrome/utils/gg" - "github.com/navidrome/navidrome/utils/nanoid" "github.com/navidrome/navidrome/utils/slice" - "github.com/navidrome/navidrome/utils/str" ) type Share interface { Load(ctx context.Context, id string) (*model.Share, error) - Repository() model.ShareRepository + NewRepository(ctx context.Context) rest.Repository } func NewShare(ds model.DataStore) Share { return &shareService{ - ds: ds, - repo: &shareRepositoryWrapper{ShareRepository: ds.Share(), ds: ds}, + ds: ds, } } type shareService struct { - ds model.DataStore - repo *shareRepositoryWrapper + ds model.DataStore } func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error) { - repo := s.ds.Share() - share, err := repo.Get(ctx, id) + repo := s.ds.Share(ctx) + share, err := repo.Get(id) if err != nil { return nil, err } - expiresAt := V(share.ExpiresAt) - if !expiresAt.IsZero() && expiresAt.Before(time.Now()) { + if !share.ExpiresAt.IsZero() && share.ExpiresAt.Before(time.Now()) { return nil, model.ErrExpired } - share.LastVisitedAt = new(time.Now()) + share.LastVisitedAt = time.Now() share.VisitCount++ - err = repo.Update(ctx, id, *share, "last_visited_at", "visit_count") + err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count") if err != nil { log.Warn(ctx, "Could not increment visit count for share", "share", share.ID) } return share, nil } -func (s *shareService) Repository() model.ShareRepository { - return s.repo +func (s *shareService) NewRepository(ctx context.Context) rest.Repository { + repo := s.ds.Share(ctx) + wrapper := &shareRepositoryWrapper{ + ctx: ctx, + ShareRepository: repo, + Repository: repo.(rest.Repository), + Persistable: repo.(rest.Persistable), + ds: s.ds, + } + return wrapper } type shareRepositoryWrapper struct { model.ShareRepository - ds model.DataStore + rest.Repository + rest.Persistable + ctx context.Context + ds model.DataStore } -func (r *shareRepositoryWrapper) newId(ctx context.Context) (string, error) { +func (r *shareRepositoryWrapper) newId() (string, error) { for { - id, err := nanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10) + id, err := gonanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10) if err != nil { return "", err } - exists, err := r.Exists(ctx, id) + exists, err := r.Exists(id) if err != nil { return "", err } @@ -80,111 +83,91 @@ func (r *shareRepositoryWrapper) newId(ctx context.Context) (string, error) { } } -func (r *shareRepositoryWrapper) Save(ctx context.Context, s *model.Share) (string, error) { - // Owner is always the caller; never trust a client-supplied UserID, as it - // determines the library-access context used to resolve the share contents. - if user, ok := request.UserFrom(ctx); ok { - s.UserID = user.ID - } - id, err := r.newId(ctx) +func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) { + s := entity.(*model.Share) + id, err := r.newId() if err != nil { return "", err } s.ID = id - if V(s.ExpiresAt).IsZero() { - s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration)) + if s.ExpiresAt.IsZero() { + s.ExpiresAt = time.Now().Add(365 * 24 * time.Hour) } - s.ResourceType, err = r.resourceType(ctx, s.ResourceIDs) + firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0] + v, err := model.GetEntityByID(r.ctx, r.ds, firstId) if err != nil { return "", err } - switch s.ResourceType { - case "artist": - s.Contents = r.contentsLabelFromArtist(ctx, s.ID, s.ResourceIDs) - case "album": - s.Contents = r.contentsLabelFromAlbums(ctx, s.ID, s.ResourceIDs) - case "playlist": - s.Contents = r.contentsLabelFromPlaylist(ctx, s.ID, s.ResourceIDs) - case "media_file": - s.Contents = r.contentsLabelFromMediaFiles(ctx, s.ID, s.ResourceIDs) + switch v.(type) { + case *model.Artist: + s.ResourceType = "artist" + s.Contents = r.contentsLabelFromArtist(s.ID, s.ResourceIDs) + case *model.Album: + s.ResourceType = "album" + s.Contents = r.contentsLabelFromAlbums(s.ID, s.ResourceIDs) + case *model.Playlist: + s.ResourceType = "playlist" + s.Contents = r.contentsLabelFromPlaylist(s.ID, s.ResourceIDs) + case *model.MediaFile: + s.ResourceType = "media_file" + s.Contents = r.contentsLabelFromMediaFiles(s.ID, s.ResourceIDs) + default: + log.Error(r.ctx, "Invalid Resource ID", "id", firstId) + return "", model.ErrNotFound + } + if len(s.Contents) > 30 { + s.Contents = s.Contents[:26] + "..." } - s.Contents = str.TruncateRunes(s.Contents, 30, "...") - - return r.ShareRepository.Save(ctx, s) + id, err = r.Persistable.Save(s) + return id, err } -var shareableKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindMediaFileArtwork} - -// resourceType resolves every ID as the current user, so an entity they cannot see cannot -// ride along behind a valid first one, and requires all IDs to be of the same kind. -func (r *shareRepositoryWrapper) resourceType(ctx context.Context, resourceIDs string) (string, error) { - resourceType := "" - for _, id := range strings.Split(resourceIDs, ",") { - kind, err := model.GetEntityKindByID(ctx, r.ds, id) - if err != nil { - return "", err - } - if !slices.Contains(shareableKinds, kind) { - log.Error(ctx, "Invalid Resource ID", "id", id) - return "", model.ErrNotFound - } - if resourceType != "" && kind.String() != resourceType { - return "", fmt.Errorf("%w: share mixes %s and %s resources", model.ErrValidation, resourceType, kind) - } - resourceType = kind.String() - } - return resourceType, nil -} - -func (r *shareRepositoryWrapper) Update(ctx context.Context, id string, entity model.Share, _ ...string) error { +func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error { cols := []string{"description", "downloadable"} // TODO Better handling of Share expiration - if !V(entity.ExpiresAt).IsZero() { + if !entity.(*model.Share).ExpiresAt.IsZero() { cols = append(cols, "expires_at") } - return r.ShareRepository.Update(ctx, id, entity, cols...) + return r.Persistable.Update(id, entity, cols...) } -func (r *shareRepositoryWrapper) contentsLabelFromArtist(ctx context.Context, shareID string, ids string) string { +func (r *shareRepositoryWrapper) contentsLabelFromArtist(shareID string, ids string) string { idList := strings.SplitN(ids, ",", 2) - a, err := r.ds.Artist().Get(ctx, idList[0]) + a, err := r.ds.Artist(r.ctx).Get(idList[0]) if err != nil { - log.Error(ctx, "Error retrieving artist name for share", "share", shareID, err) + log.Error(r.ctx, "Error retrieving artist name for share", "share", shareID, err) return "" } return a.Name } -func (r *shareRepositoryWrapper) contentsLabelFromAlbums(ctx context.Context, shareID string, ids string) string { +func (r *shareRepositoryWrapper) contentsLabelFromAlbums(shareID string, ids string) string { idList := strings.Split(ids, ",") - all, err := r.ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.id": idList}}) + all, err := r.ds.Album(r.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": idList}}) if err != nil { - log.Error(ctx, "Error retrieving album names for share", "share", shareID, err) + log.Error(r.ctx, "Error retrieving album names for share", "share", shareID, err) return "" } names := slice.Map(all, func(a model.Album) string { return a.Name }) return strings.Join(names, ", ") } -func (r *shareRepositoryWrapper) contentsLabelFromPlaylist(ctx context.Context, shareID string, id string) string { - pls, err := r.ds.Playlist().Get(ctx, id) +func (r *shareRepositoryWrapper) contentsLabelFromPlaylist(shareID string, id string) string { + pls, err := r.ds.Playlist(r.ctx).Get(id) if err != nil { - log.Error(ctx, "Error retrieving album names for share", "share", shareID, err) + log.Error(r.ctx, "Error retrieving album names for share", "share", shareID, err) return "" } return pls.Name } -func (r *shareRepositoryWrapper) contentsLabelFromMediaFiles(ctx context.Context, shareID string, ids string) string { +func (r *shareRepositoryWrapper) contentsLabelFromMediaFiles(shareID string, ids string) string { idList := strings.Split(ids, ",") - mfs, err := r.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.And{ - squirrel.Eq{"media_file.id": idList}, - squirrel.Eq{"missing": false}, - }}) + mfs, err := r.ds.MediaFile(r.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": idList}}) if err != nil { - log.Error(ctx, "Error retrieving media files for share", "share", shareID, err) + log.Error(r.ctx, "Error retrieving media files for share", "share", shareID, err) return "" } diff --git a/core/share_test.go b/core/share_test.go index 8ce2bf270..21069bb59 100644 --- a/core/share_test.go +++ b/core/share_test.go @@ -5,7 +5,6 @@ import ( "github.com/deluan/rest" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -14,90 +13,37 @@ import ( var _ = Describe("Share", func() { var ds model.DataStore var share Share - var mockedRepo rest.Persistable[model.Share] + var mockedRepo rest.Persistable ctx := context.Background() BeforeEach(func() { ds = &tests.MockDataStore{} - mockedRepo = ds.Share().(rest.Persistable[model.Share]) + mockedRepo = ds.Share(ctx).(rest.Persistable) share = NewShare(ds) }) Describe("NewRepository", func() { - var repo rest.Persistable[model.Share] + var repo rest.Persistable BeforeEach(func() { - repo = share.Repository().(rest.Persistable[model.Share]) - _ = ds.Album().Put(ctx, &model.Album{ID: "123", Name: "Album"}) + repo = share.NewRepository(ctx).(rest.Persistable) + _ = ds.Album(ctx).Put(&model.Album{ID: "123", Name: "Album"}) }) Describe("Save", func() { It("it sets a random ID", func() { entity := &model.Share{Description: "test", ResourceIDs: "123"} - id, err := repo.Save(ctx, entity) + id, err := repo.Save(entity) Expect(err).ToNot(HaveOccurred()) Expect(id).ToNot(BeEmpty()) Expect(entity.ID).To(Equal(id)) }) - - It("assigns the logged-in user as owner, ignoring a client-supplied UserID", func() { - loggedInCtx := request.WithUser(context.Background(), model.User{ID: "logged-in-user"}) - repo := share.Repository().(rest.Persistable[model.Share]) - entity := &model.Share{Description: "test", ResourceIDs: "123", UserID: "victim-user"} - _, err := repo.Save(loggedInCtx, entity) - Expect(err).ToNot(HaveOccurred()) - Expect(entity.UserID).To(Equal("logged-in-user")) - }) - - It("does not truncate ASCII labels shorter than 30 characters", func() { - _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "456", Title: "Example Media File"}) - entity := &model.Share{Description: "test", ResourceIDs: "456"} - _, err := repo.Save(ctx, entity) - Expect(err).ToNot(HaveOccurred()) - Expect(entity.Contents).To(Equal("Example Media File")) - }) - - It("truncates ASCII labels longer than 30 characters", func() { - _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"}) - entity := &model.Share{Description: "test", ResourceIDs: "789"} - _, err := repo.Save(ctx, entity) - Expect(err).ToNot(HaveOccurred()) - Expect(entity.Contents).To(Equal("Example Media File But The ...")) - }) - - It("does not truncate CJK labels shorter than 30 runes", func() { - _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "456", Title: "青春コンプレックス"}) - entity := &model.Share{Description: "test", ResourceIDs: "456"} - _, err := repo.Save(ctx, entity) - Expect(err).ToNot(HaveOccurred()) - Expect(entity.Contents).To(Equal("青春コンプレックス")) - }) - - It("truncates CJK labels longer than 30 runes", func() { - _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"}) - entity := &model.Share{Description: "test", ResourceIDs: "789"} - _, err := repo.Save(ctx, entity) - Expect(err).ToNot(HaveOccurred()) - Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で...")) - }) - - It("fails when any of the resource IDs does not exist", func() { - entity := &model.Share{Description: "test", ResourceIDs: "123,missing"} - _, err := repo.Save(ctx, entity) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("fails when the resource IDs are of mixed types", func() { - _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "456", Title: "Example Media File"}) - entity := &model.Share{Description: "test", ResourceIDs: "123,456"} - _, err := repo.Save(ctx, entity) - Expect(err).To(HaveOccurred()) - }) }) Describe("Update", func() { It("filters out read-only fields", func() { - err := repo.Update(ctx, "id", model.Share{}) + entity := &model.Share{} + err := repo.Update("id", entity) Expect(err).ToNot(HaveOccurred()) Expect(mockedRepo.(*tests.MockShareRepo).Cols).To(ConsistOf("description", "downloadable")) }) diff --git a/core/sonic/sonic.go b/core/sonic/sonic.go deleted file mode 100644 index 8645d3d28..000000000 --- a/core/sonic/sonic.go +++ /dev/null @@ -1,139 +0,0 @@ -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, - } -} - -// Engine is the sonic-similarity surface the API layers depend on; *Sonic satisfies it. -type Engine interface { - HasProvider() bool - GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) - FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) -} - -var _ Engine = (*Sonic)(nil) - -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().Get(ctx, 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().Get(ctx, startID) - if err != nil { - return nil, fmt.Errorf("getting start media file %s: %w", startID, err) - } - endMF, err := s.ds.MediaFile().Get(ctx, 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/core/sonic/sonic_suite_test.go b/core/sonic/sonic_suite_test.go deleted file mode 100644 index 7058e5de9..000000000 --- a/core/sonic/sonic_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package sonic_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestSonic(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Sonic Suite") -} diff --git a/core/sonic/sonic_test.go b/core/sonic/sonic_test.go deleted file mode 100644 index 813fceea9..000000000 --- a/core/sonic/sonic_test.go +++ /dev/null @@ -1,146 +0,0 @@ -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", Artists: []agents.Artist{{Name: "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/interface.go b/core/storage/interface.go deleted file mode 100644 index 02c1d14d9..000000000 --- a/core/storage/interface.go +++ /dev/null @@ -1,33 +0,0 @@ -package storage - -import ( - "context" - "io/fs" - - "github.com/navidrome/navidrome/model/metadata" -) - -type Storage interface { - FS() (MusicFS, error) -} - -// MusicFS is an interface that extends the fs.FS interface with the ability to read tags from files -type MusicFS interface { - fs.FS - ReadTags(path ...string) (map[string]metadata.Info, error) -} - -// SymlinkResolverFS is an optional interface for MusicFS implementations backed by a real -// filesystem. ResolveSymlink resolves the whole symlink chain of the named entry at the OS -// level and returns the final target's path — including targets outside the FS root, which -// fs.ReadLink-based resolution cannot follow. -type SymlinkResolverFS interface { - ResolveSymlink(name string) (string, error) -} - -// Watcher is a storage with the ability watch the FS and notify changes -type Watcher interface { - // Start starts a watcher on the whole FS and returns a channel to send detected changes. - // The watcher must be stopped when the context is done. - Start(context.Context) (<-chan string, error) -} diff --git a/core/storage/local/deviceid_unix.go b/core/storage/local/deviceid_unix.go deleted file mode 100644 index 42e7dac70..000000000 --- a/core/storage/local/deviceid_unix.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build !windows - -package local - -import ( - "io/fs" - "syscall" -) - -// deviceID identifies the filesystem a file lives on, used to key birth time support per mount. -// It is returned opaquely because its width varies by platform, and it is only used as a map key. -func deviceID(fi fs.FileInfo) (any, bool) { - st, ok := fi.Sys().(*syscall.Stat_t) - if !ok { - return nil, false - } - return st.Dev, true -} diff --git a/core/storage/local/deviceid_windows.go b/core/storage/local/deviceid_windows.go deleted file mode 100644 index f1b44be4d..000000000 --- a/core/storage/local/deviceid_windows.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build windows - -package local - -import "io/fs" - -// deviceID has no Windows equivalent, and none is needed: birth time comes straight from FileInfo. -func deviceID(fs.FileInfo) (any, bool) { return nil, false } diff --git a/core/storage/local/extractors.go b/core/storage/local/extractors.go deleted file mode 100644 index 654e71cc1..000000000 --- a/core/storage/local/extractors.go +++ /dev/null @@ -1,29 +0,0 @@ -package local - -import ( - "io/fs" - "sync" - - "github.com/navidrome/navidrome/model/metadata" -) - -// Extractor is an interface that defines the methods that a tag/metadata extractor must implement -type Extractor interface { - Parse(files ...string) (map[string]metadata.Info, error) - Version() string -} - -type extractorConstructor func(fs.FS, string) Extractor - -var ( - extractors = map[string]extractorConstructor{} - lock sync.RWMutex -) - -// RegisterExtractor registers a new extractor, so it can be used by the local storage. The one to be used is -// defined with the configuration option Scanner.Extractor. -func RegisterExtractor(id string, f extractorConstructor) { - lock.Lock() - defer lock.Unlock() - extractors[id] = f -} diff --git a/core/storage/local/local.go b/core/storage/local/local.go deleted file mode 100644 index e2ce00a1b..000000000 --- a/core/storage/local/local.go +++ /dev/null @@ -1,158 +0,0 @@ -package local - -import ( - "fmt" - "io/fs" - "net/url" - "os" - "path/filepath" - "sync" - "sync/atomic" - "time" - - "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" -) - -// localStorage implements a Storage that reads the files from the local filesystem and uses registered extractors -// to extract the metadata and tags from the files. -type localStorage struct { - u url.URL - extractor Extractor - resolvedPath string - watching atomic.Bool -} - -func newLocalStorage(u url.URL) storage.Storage { - newExtractor, ok := extractors[conf.Server.Scanner.Extractor] - if !ok || newExtractor == nil { - 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 { - u.Path = filepath.Join(u.Host, u.Path) - } - resolvedPath, err := filepath.EvalSymlinks(u.Path) - if err != nil { - log.Warn("Error resolving path", "path", u.Path, "err", err) - resolvedPath = u.Path - } - return &localStorage{u: u, extractor: newExtractor(os.DirFS(u.Path), u.Path), resolvedPath: resolvedPath} -} - -func (s *localStorage) FS() (storage.MusicFS, error) { - path := s.u.Path - if _, err := os.Stat(path); err != nil { //nolint:gosec - return nil, fmt.Errorf("%w: %s", err, path) - } - return &localFS{FS: os.DirFS(path), extractor: s.extractor, root: path}, nil -} - -type localFS struct { - fs.FS - extractor Extractor - root string - // devices whose statx never reports a birth time (NFS, rclone/FUSE), so we ask each only once - noBirthTime sync.Map -} - -// ResolveSymlink implements storage.SymlinkResolverFS. It resolves the whole chain at the -// OS level, so links whose targets live outside the library folder (not reachable through -// the fs.FS abstraction) still resolve to their final target. -func (lfs *localFS) ResolveSymlink(name string) (string, error) { - if !fs.ValidPath(name) { - return "", &fs.PathError{Op: "resolvesymlink", Path: name, Err: fs.ErrInvalid} - } - return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name))) -} - -// ReadLink and Lstat implement fs.ReadLinkFS, so callers can detect symlinks without following them. -var _ fs.ReadLinkFS = (*localFS)(nil) - -func (lfs *localFS) ReadLink(name string) (string, error) { - return fs.ReadLink(lfs.FS, name) -} - -func (lfs *localFS) Lstat(name string) (fs.FileInfo, error) { - return fs.Lstat(lfs.FS, name) -} - -func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) { - res, err := lfs.extractor.Parse(path...) - if err != nil { - return nil, err - } - for path, v := range res { - if v.FileInfo == nil { - info, err := fs.Stat(lfs, path) - if err != nil { - return nil, err - } - v.FileInfo = localFileInfo{ - FileInfo: info, - path: filepath.Join(lfs.root, filepath.FromSlash(path)), - noBirthTime: &lfs.noBirthTime, - } - res[path] = v - } - } - return res, nil -} - -// localFileInfo is a wrapper around fs.FileInfo that adds a BirthTime method, to make it compatible -// with metadata.FileInfo -type localFileInfo struct { - fs.FileInfo - path string - noBirthTime *sync.Map -} - -func (lfi localFileInfo) BirthTime() time.Time { - if ts := times.Get(lfi.FileInfo); ts.HasBirthTime() { - return ts.BirthTime() - } - if bt, ok := lfi.statxBirthTime(); ok { - return bt - } - return time.Now() -} - -// statxBirthTime reads the birth time from the path, which on Linux is the only way to get it. -// Filesystems that never report one are remembered per device, so a scan asks each only once. -func (lfi localFileInfo) statxBirthTime() (time.Time, bool) { - if lfi.path == "" { - return time.Time{}, false - } - dev, hasDev := deviceID(lfi.FileInfo) - memo := lfi.noBirthTime - if hasDev && memo != nil { - if _, skip := memo.Load(dev); skip { - return time.Time{}, false - } - } - ts, err := times.Stat(lfi.path) - if err != nil { - return time.Time{}, false - } - if ts.HasBirthTime() { - return ts.BirthTime(), true - } - if hasDev && memo != nil { - memo.Store(dev, struct{}{}) - } - return time.Time{}, false -} - -func init() { - storage.Register(storage.LocalSchemaID, newLocalStorage) -} diff --git a/core/storage/local/local_suite_test.go b/core/storage/local/local_suite_test.go deleted file mode 100644 index 5934cde5d..000000000 --- a/core/storage/local/local_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package local - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestLocal(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Local Storage Suite") -} diff --git a/core/storage/local/local_test.go b/core/storage/local/local_test.go deleted file mode 100644 index 3ed6d6fd9..000000000 --- a/core/storage/local/local_test.go +++ /dev/null @@ -1,553 +0,0 @@ -package local - -import ( - "io/fs" - "net/url" - "os" - "path/filepath" - "runtime" - "sync" - "time" - - "github.com/djherbis/times" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/storage" - "github.com/navidrome/navidrome/model/metadata" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("LocalStorage", func() { - var tempDir string - var testExtractor *mockTestExtractor - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - - // Create a temporary directory for testing - var err error - tempDir, err = os.MkdirTemp("", "navidrome-local-storage-test-") - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - os.RemoveAll(tempDir) - }) - - // Create and register a test extractor - testExtractor = &mockTestExtractor{ - results: make(map[string]metadata.Info), - } - RegisterExtractor("test", func(fs.FS, string) Extractor { - return testExtractor - }) - conf.Server.Scanner.Extractor = "test" - }) - - Describe("newLocalStorage", func() { - - Context("with valid path", func() { - It("should create a localStorage instance with correct path", func() { - u, err := storage.LocalPathToURL(tempDir) - Expect(err).ToNot(HaveOccurred()) - - storage := newLocalStorage(u) - localStorage := storage.(*localStorage) - - Expect(localStorage.u.Scheme).To(Equal("file")) - // Check that the path is set correctly (could be resolved to real path on macOS) - Expect(localStorage.u.Path).To(ContainSubstring("navidrome-local-storage-test")) - Expect(localStorage.resolvedPath).To(ContainSubstring("navidrome-local-storage-test")) - Expect(localStorage.extractor).ToNot(BeNil()) - }) - - It("should handle URL-decoded paths correctly", func() { - // Create a directory with spaces to test URL decoding - spacedDir := filepath.Join(tempDir, "test folder") - err := os.MkdirAll(spacedDir, 0755) - Expect(err).ToNot(HaveOccurred()) - - // Use proper URL construction instead of manual escaping - u := &url.URL{ - Scheme: "file", - Path: spacedDir, - } - - storage := newLocalStorage(*u) - localStorage, ok := storage.(*localStorage) - Expect(ok).To(BeTrue()) - - Expect(localStorage.u.Path).To(Equal(spacedDir)) - }) - - It("should resolve symlinks when possible", func() { - // Create a real directory and a symlink to it - realDir := filepath.Join(tempDir, "real") - linkDir := filepath.Join(tempDir, "link") - - err := os.MkdirAll(realDir, 0755) - Expect(err).ToNot(HaveOccurred()) - - err = os.Symlink(realDir, linkDir) - Expect(err).ToNot(HaveOccurred()) - - u, err := storage.LocalPathToURL(linkDir) - Expect(err).ToNot(HaveOccurred()) - - storage := newLocalStorage(u) - localStorage, ok := storage.(*localStorage) - Expect(ok).To(BeTrue()) - - Expect(localStorage.u.Path).To(Equal(linkDir)) - // Check that the resolved path contains the real directory name - Expect(localStorage.resolvedPath).To(ContainSubstring("real")) - }) - - It("should use u.Path as resolvedPath when symlink resolution fails", func() { - // Use a non-existent path to trigger symlink resolution failure - nonExistentPath := filepath.Join(tempDir, "non-existent") - - u, err := storage.LocalPathToURL(nonExistentPath) - Expect(err).ToNot(HaveOccurred()) - - storage := newLocalStorage(u) - localStorage, ok := storage.(*localStorage) - Expect(ok).To(BeTrue()) - - Expect(localStorage.u.Path).To(Equal(nonExistentPath)) - Expect(localStorage.resolvedPath).To(Equal(nonExistentPath)) - }) - }) - - Context("with Windows path", func() { - BeforeEach(func() { - if runtime.GOOS != "windows" { - Skip("Windows-specific test") - } - }) - - It("should handle Windows drive letters correctly", func() { - u, err := url.Parse("file://C:/music") - Expect(err).ToNot(HaveOccurred()) - - storage := newLocalStorage(*u) - localStorage, ok := storage.(*localStorage) - Expect(ok).To(BeTrue()) - - // newLocalStorage re-joins the drive letter (u.Host) with u.Path via - // filepath.Join, which yields an OS-native (backslash) path on Windows. - Expect(localStorage.u.Path).To(Equal(filepath.Join("C:", "/music"))) - }) - }) - - 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 := storage.LocalPathToURL(tempDir) - Expect(err).ToNot(HaveOccurred()) - - storage := newLocalStorage(u) - ls, ok := storage.(*localStorage) - Expect(ok).To(BeTrue()) - Expect(ls.extractor).To(BeIdenticalTo(defaultExtractor)) - }) - }) - }) - - Describe("localStorage.FS", func() { - - Context("with existing directory", func() { - It("should return a localFS instance", func() { - u, err := storage.LocalPathToURL(tempDir) - Expect(err).ToNot(HaveOccurred()) - - storage := newLocalStorage(u) - musicFS, err := storage.FS() - Expect(err).ToNot(HaveOccurred()) - Expect(musicFS).ToNot(BeNil()) - - _, ok := musicFS.(*localFS) - Expect(ok).To(BeTrue()) - }) - }) - - Context("with non-existent directory", func() { - It("should return an error", func() { - nonExistentPath := filepath.Join(tempDir, "non-existent") - u, err := storage.LocalPathToURL(nonExistentPath) - Expect(err).ToNot(HaveOccurred()) - - storage := newLocalStorage(u) - _, err = storage.FS() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring(nonExistentPath)) - }) - }) - }) - - Describe("localFS.ResolveSymlink", func() { - var musicFS storage.MusicFS - - BeforeEach(func() { - if runtime.GOOS == "windows" { - Skip("symlink semantics") - } - u, err := storage.LocalPathToURL(tempDir) - Expect(err).ToNot(HaveOccurred()) - musicFS, err = newLocalStorage(u).FS() - Expect(err).ToNot(HaveOccurred()) - }) - - It("implements storage.SymlinkResolverFS", func() { - _, ok := musicFS.(storage.SymlinkResolverFS) - Expect(ok).To(BeTrue()) - }) - - It("resolves a chain that leaves the library folder to its final target", func() { - outside, err := os.MkdirTemp("", "navidrome-symlink-outside-") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { os.RemoveAll(outside) }) - - target := filepath.Join(outside, "final.txt") - Expect(os.WriteFile(target, []byte("data"), 0600)).To(Succeed()) - mid := filepath.Join(outside, "mid.wav") - Expect(os.Symlink(target, mid)).To(Succeed()) - Expect(os.Symlink(mid, filepath.Join(tempDir, "link.wav"))).To(Succeed()) - - resolved, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("link.wav") - Expect(err).ToNot(HaveOccurred()) - expected, err := filepath.EvalSymlinks(target) - Expect(err).ToNot(HaveOccurred()) - Expect(resolved).To(Equal(expected)) - }) - - It("resolves entries in subfolders (slash-separated fs paths)", func() { - Expect(os.MkdirAll(filepath.Join(tempDir, "sub"), 0755)).To(Succeed()) - target := filepath.Join(tempDir, "real.mp3") - Expect(os.WriteFile(target, []byte("audio"), 0600)).To(Succeed()) - Expect(os.Symlink(target, filepath.Join(tempDir, "sub", "link.mp3"))).To(Succeed()) - - resolved, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("sub/link.mp3") - Expect(err).ToNot(HaveOccurred()) - expected, err := filepath.EvalSymlinks(target) - Expect(err).ToNot(HaveOccurred()) - Expect(resolved).To(Equal(expected)) - }) - - It("returns an error for a broken symlink", func() { - Expect(os.Symlink(filepath.Join(tempDir, "missing.mp3"), filepath.Join(tempDir, "broken.mp3"))).To(Succeed()) - - _, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("broken.mp3") - Expect(err).To(HaveOccurred()) - }) - - It("rejects names that are not valid fs paths", func() { - for _, name := range []string{"../outside.mp3", "/etc/hosts", "sub/../../outside.mp3", ""} { - _, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink(name) - Expect(err).To(MatchError(fs.ErrInvalid), name) - } - }) - - It("returns an error for a symlink loop", func() { - Expect(os.Symlink(filepath.Join(tempDir, "loop2.mp3"), filepath.Join(tempDir, "loop1.mp3"))).To(Succeed()) - Expect(os.Symlink(filepath.Join(tempDir, "loop1.mp3"), filepath.Join(tempDir, "loop2.mp3"))).To(Succeed()) - - _, err := musicFS.(storage.SymlinkResolverFS).ResolveSymlink("loop1.mp3") - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("localFS.ReadTags", func() { - var testFile string - - BeforeEach(func() { - // Create a test file - testFile = filepath.Join(tempDir, "test.mp3") - err := os.WriteFile(testFile, []byte("test data"), 0600) - Expect(err).ToNot(HaveOccurred()) - - // Reset extractor state - testExtractor.results = make(map[string]metadata.Info) - testExtractor.err = nil - }) - - Context("when extractor returns complete metadata", func() { - It("should return the metadata as-is", func() { - expectedInfo := metadata.Info{ - Tags: map[string][]string{ - "title": {"Test Song"}, - "artist": {"Test Artist"}, - }, - AudioProperties: metadata.AudioProperties{ - Duration: 180, - BitRate: 320, - }, - FileInfo: &testFileInfo{name: "test.mp3"}, - } - - testExtractor.results["test.mp3"] = expectedInfo - - u, err := storage.LocalPathToURL(tempDir) - Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(u) - musicFS, err := storage.FS() - Expect(err).ToNot(HaveOccurred()) - - results, err := musicFS.ReadTags("test.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveKey("test.mp3")) - Expect(results["test.mp3"]).To(Equal(expectedInfo)) - }) - }) - - Context("when extractor returns metadata without FileInfo", func() { - It("should populate FileInfo from filesystem", func() { - incompleteInfo := metadata.Info{ - Tags: map[string][]string{ - "title": {"Test Song"}, - }, - FileInfo: nil, // Missing FileInfo - } - - testExtractor.results["test.mp3"] = incompleteInfo - - u, err := storage.LocalPathToURL(tempDir) - Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(u) - musicFS, err := storage.FS() - Expect(err).ToNot(HaveOccurred()) - - results, err := musicFS.ReadTags("test.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveKey("test.mp3")) - - result := results["test.mp3"] - Expect(result.FileInfo).ToNot(BeNil()) - Expect(result.FileInfo.Name()).To(Equal("test.mp3")) - - // Should be wrapped in localFileInfo - _, ok := result.FileInfo.(localFileInfo) - Expect(ok).To(BeTrue()) - }) - }) - - Context("when filesystem stat fails", func() { - It("should return an error", func() { - incompleteInfo := metadata.Info{ - Tags: map[string][]string{"title": {"Test Song"}}, - FileInfo: nil, - } - - testExtractor.results["non-existent.mp3"] = incompleteInfo - - u, err := storage.LocalPathToURL(tempDir) - Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(u) - musicFS, err := storage.FS() - Expect(err).ToNot(HaveOccurred()) - - _, err = musicFS.ReadTags("non-existent.mp3") - Expect(err).To(HaveOccurred()) - }) - }) - - Context("when extractor fails", func() { - It("should return the extractor error", func() { - testExtractor.err = &extractorError{message: "extractor failed"} - - u, err := storage.LocalPathToURL(tempDir) - Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(u) - musicFS, err := storage.FS() - Expect(err).ToNot(HaveOccurred()) - - _, err = musicFS.ReadTags("test.mp3") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("extractor failed")) - }) - }) - - Context("with multiple files", func() { - It("should process all files correctly", func() { - // Create another test file - testFile2 := filepath.Join(tempDir, "test2.mp3") - err := os.WriteFile(testFile2, []byte("test data 2"), 0600) - Expect(err).ToNot(HaveOccurred()) - - info1 := metadata.Info{ - Tags: map[string][]string{"title": {"Song 1"}}, - FileInfo: &testFileInfo{name: "test.mp3"}, - } - info2 := metadata.Info{ - Tags: map[string][]string{"title": {"Song 2"}}, - FileInfo: nil, // This one needs FileInfo populated - } - - testExtractor.results["test.mp3"] = info1 - testExtractor.results["test2.mp3"] = info2 - - u, err := storage.LocalPathToURL(tempDir) - Expect(err).ToNot(HaveOccurred()) - storage := newLocalStorage(u) - musicFS, err := storage.FS() - Expect(err).ToNot(HaveOccurred()) - - results, err := musicFS.ReadTags("test.mp3", "test2.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(2)) - - Expect(results["test.mp3"].FileInfo).To(Equal(&testFileInfo{name: "test.mp3"})) - Expect(results["test2.mp3"].FileInfo).ToNot(BeNil()) - Expect(results["test2.mp3"].FileInfo.Name()).To(Equal("test2.mp3")) - }) - }) - }) - - Describe("localFileInfo", func() { - var testFile string - var fileInfo fs.FileInfo - - BeforeEach(func() { - testFile = filepath.Join(tempDir, "test.mp3") - err := os.WriteFile(testFile, []byte("test data"), 0600) - Expect(err).ToNot(HaveOccurred()) - - fileInfo, err = os.Stat(testFile) - Expect(err).ToNot(HaveOccurred()) - }) - - Describe("BirthTime", func() { - It("should return birth time when available", func() { - lfi := localFileInfo{FileInfo: fileInfo} - birthTime := lfi.BirthTime() - - // Birth time should be a valid time (not zero value) - Expect(birthTime).ToNot(BeZero()) - // Should be around the current time (within last few minutes) - Expect(birthTime).To(BeTemporally("~", time.Now(), 5*time.Minute)) - }) - - It("reads the birth time from the path, not the time of the call", func() { - // On Linux, birth time is only available via statx(2) on the path. - lfi := localFileInfo{FileInfo: fileInfo, path: testFile} - time.Sleep(300 * time.Millisecond) - Expect(lfi.BirthTime()).To(BeTemporally("<", time.Now().Add(-200*time.Millisecond))) - }) - - It("does not remember filesystems that do report a birth time", func() { - memo := &sync.Map{} - lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo} - lfi.BirthTime() - - count := 0 - memo.Range(func(_, _ any) bool { count++; return true }) - Expect(count).To(BeZero()) - }) - - It("skips statx on filesystems already known to have none", func() { - if times.Get(fileInfo).HasBirthTime() { - Skip("this platform reports birth time from FileInfo, so statx is never called") - } - dev, ok := deviceID(fileInfo) - Expect(ok).To(BeTrue()) - - memo := &sync.Map{} - memo.Store(dev, struct{}{}) - lfi := localFileInfo{FileInfo: fileInfo, path: testFile, noBirthTime: memo} - time.Sleep(300 * time.Millisecond) - Expect(lfi.BirthTime()).To(BeTemporally("~", time.Now(), 100*time.Millisecond)) - }) - }) - - It("should delegate all other FileInfo methods", func() { - lfi := localFileInfo{FileInfo: fileInfo} - - Expect(lfi.Name()).To(Equal(fileInfo.Name())) - Expect(lfi.Size()).To(Equal(fileInfo.Size())) - Expect(lfi.Mode()).To(Equal(fileInfo.Mode())) - Expect(lfi.ModTime()).To(Equal(fileInfo.ModTime())) - Expect(lfi.IsDir()).To(Equal(fileInfo.IsDir())) - Expect(lfi.Sys()).To(Equal(fileInfo.Sys())) - }) - }) - - Describe("Storage registration", func() { - It("should register localStorage for file scheme", func() { - // This tests the init() function indirectly - storage, err := storage.For(tempDir) - Expect(err).ToNot(HaveOccurred()) - Expect(storage).To(BeAssignableToTypeOf(&localStorage{})) - }) - }) -}) - -// Test extractor for testing -type mockTestExtractor struct { - results map[string]metadata.Info - err error -} - -func (m *mockTestExtractor) Parse(files ...string) (map[string]metadata.Info, error) { - if m.err != nil { - return nil, m.err - } - - result := make(map[string]metadata.Info) - for _, file := range files { - if info, exists := m.results[file]; exists { - result[file] = info - } - } - return result, nil -} - -func (m *mockTestExtractor) Version() string { - return "test-1.0" -} - -type extractorError struct { - message string -} - -func (e *extractorError) Error() string { - return e.message -} - -// Test FileInfo that implements metadata.FileInfo -type testFileInfo struct { - name string - size int64 - mode fs.FileMode - modTime time.Time - isDir bool - birthTime time.Time -} - -func (t *testFileInfo) Name() string { return t.name } -func (t *testFileInfo) Size() int64 { return t.size } -func (t *testFileInfo) Mode() fs.FileMode { return t.mode } -func (t *testFileInfo) ModTime() time.Time { return t.modTime } -func (t *testFileInfo) IsDir() bool { return t.isDir } -func (t *testFileInfo) Sys() any { return nil } -func (t *testFileInfo) BirthTime() time.Time { - if t.birthTime.IsZero() { - return time.Now() - } - return t.birthTime -} diff --git a/core/storage/local/watch_events_darwin.go b/core/storage/local/watch_events_darwin.go deleted file mode 100644 index 6767b3f64..000000000 --- a/core/storage/local/watch_events_darwin.go +++ /dev/null @@ -1,5 +0,0 @@ -package local - -import "github.com/rjeczalik/notify" - -const WatchEvents = notify.All | notify.FSEventsInodeMetaMod diff --git a/core/storage/local/watch_events_default.go b/core/storage/local/watch_events_default.go deleted file mode 100644 index e36bc4007..000000000 --- a/core/storage/local/watch_events_default.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !linux && !darwin && !windows - -package local - -import "github.com/rjeczalik/notify" - -const WatchEvents = notify.All diff --git a/core/storage/local/watch_events_linux.go b/core/storage/local/watch_events_linux.go deleted file mode 100644 index 68fd8aa59..000000000 --- a/core/storage/local/watch_events_linux.go +++ /dev/null @@ -1,5 +0,0 @@ -package local - -import "github.com/rjeczalik/notify" - -const WatchEvents = notify.All | notify.InModify | notify.InAttrib diff --git a/core/storage/local/watch_events_windows.go b/core/storage/local/watch_events_windows.go deleted file mode 100644 index c1b94cf0f..000000000 --- a/core/storage/local/watch_events_windows.go +++ /dev/null @@ -1,5 +0,0 @@ -package local - -import "github.com/rjeczalik/notify" - -const WatchEvents = notify.All | notify.FileNotifyChangeAttributes diff --git a/core/storage/local/watcher.go b/core/storage/local/watcher.go deleted file mode 100644 index ef404d497..000000000 --- a/core/storage/local/watcher.go +++ /dev/null @@ -1,50 +0,0 @@ -package local - -import ( - "context" - "errors" - "fmt" - "path/filepath" - "strings" - - "github.com/navidrome/navidrome/log" - "github.com/rjeczalik/notify" -) - -// Start starts a watcher on the whole FS and returns a channel to send detected changes. -// It uses `notify` to detect changes in the filesystem, so it may not work on all platforms/use-cases. -// Notoriously, it does not work on some networked mounts and Windows with WSL2. -func (s *localStorage) Start(ctx context.Context) (<-chan string, error) { - if !s.watching.CompareAndSwap(false, true) { - return nil, errors.New("watcher already started") - } - input := make(chan notify.EventInfo, 500) - libPath := filepath.Join(s.u.Path, "...") - log.Debug(ctx, "Starting watcher", "lib", libPath) - if err := notify.Watch(libPath, input, WatchEvents); err != nil { - s.watching.Store(false) - return nil, fmt.Errorf("starting watcher on %s: %w", libPath, err) - } - - output := make(chan string, 500) - go func() { - defer close(input) - defer close(output) - defer notify.Stop(input) - - for { - select { - case event := <-input: - log.Trace(ctx, "Detected change", "event", event, "lib", s.u.Path) - name := event.Path() - name = strings.Replace(name, s.resolvedPath, s.u.Path, 1) - output <- name - case <-ctx.Done(): - log.Debug(ctx, "Stopping watcher", "path", s.u.Path) - s.watching.Store(false) - return - } - } - }() - return output, nil -} diff --git a/core/storage/local/watcher_test.go b/core/storage/local/watcher_test.go deleted file mode 100644 index 37387c76a..000000000 --- a/core/storage/local/watcher_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package local_test - -import ( - "context" - "io/fs" - "os" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/storage" - "github.com/navidrome/navidrome/core/storage/local" - _ "github.com/navidrome/navidrome/core/storage/local" - "github.com/navidrome/navidrome/model/metadata" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = XDescribe("Watcher", func() { - var lsw storage.Watcher - var tmpFolder string - - BeforeEach(func() { - tmpFolder = GinkgoT().TempDir() - - local.RegisterExtractor("noop", func(fs fs.FS, path string) local.Extractor { return noopExtractor{} }) - conf.Server.Scanner.Extractor = "noop" - - ls, err := storage.For(tmpFolder) - Expect(err).ToNot(HaveOccurred()) - - // It should implement Watcher - var ok bool - lsw, ok = ls.(storage.Watcher) - Expect(ok).To(BeTrue()) - - // Make sure temp folder is created - Eventually(func() error { - _, err := os.Stat(tmpFolder) - return err - }).Should(Succeed()) - }) - - It("should start and stop watcher", func() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - w, err := lsw.Start(ctx) - Expect(err).ToNot(HaveOccurred()) - cancel() - Eventually(w).Should(BeClosed()) - }) - - It("should return error if watcher is already started", func() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - _, err := lsw.Start(ctx) - Expect(err).ToNot(HaveOccurred()) - _, err = lsw.Start(ctx) - Expect(err).To(HaveOccurred()) - }) - - It("should detect new files", func() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - changes, err := lsw.Start(ctx) - Expect(err).ToNot(HaveOccurred()) - - _, err = os.Create(filepath.Join(tmpFolder, "test.txt")) - Expect(err).ToNot(HaveOccurred()) - - Eventually(changes).WithTimeout(2 * time.Second).Should(Receive(Equal(tmpFolder))) - }) - - It("should detect new subfolders", func() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - changes, err := lsw.Start(ctx) - Expect(err).ToNot(HaveOccurred()) - - Expect(os.Mkdir(filepath.Join(tmpFolder, "subfolder"), 0755)).To(Succeed()) - - Eventually(changes).WithTimeout(2 * time.Second).Should(Receive(Equal(filepath.Join(tmpFolder, "subfolder")))) - }) - - It("should detect changes in subfolders recursively", func() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - subfolder := filepath.Join(tmpFolder, "subfolder1/subfolder2") - Expect(os.MkdirAll(subfolder, 0755)).To(Succeed()) - - changes, err := lsw.Start(ctx) - Expect(err).ToNot(HaveOccurred()) - - filePath := filepath.Join(subfolder, "test.txt") - Expect(os.WriteFile(filePath, []byte("test"), 0600)).To(Succeed()) - - Eventually(changes).WithTimeout(2 * time.Second).Should(Receive(Equal(filePath))) - }) - - It("should detect removed in files", func() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - changes, err := lsw.Start(ctx) - Expect(err).ToNot(HaveOccurred()) - - filePath := filepath.Join(tmpFolder, "test.txt") - Expect(os.WriteFile(filePath, []byte("test"), 0600)).To(Succeed()) - - Eventually(changes).WithTimeout(2 * time.Second).Should(Receive(Equal(filePath))) - - Expect(os.Remove(filePath)).To(Succeed()) - Eventually(changes).WithTimeout(2 * time.Second).Should(Receive(Equal(filePath))) - }) - - It("should detect file moves", func() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - filePath := filepath.Join(tmpFolder, "test.txt") - Expect(os.WriteFile(filePath, []byte("test"), 0600)).To(Succeed()) - - changes, err := lsw.Start(ctx) - Expect(err).ToNot(HaveOccurred()) - - newPath := filepath.Join(tmpFolder, "test2.txt") - Expect(os.Rename(filePath, newPath)).To(Succeed()) - Eventually(changes).WithTimeout(2 * time.Second).Should(Receive(Equal(newPath))) - }) -}) - -type noopExtractor struct{} - -func (s noopExtractor) Parse(files ...string) (map[string]metadata.Info, error) { return nil, nil } -func (s noopExtractor) Version() string { return "0" } - -var _ = Describe("Watcher.Start", func() { - It("returns an error instead of hanging when the path cannot be watched", func() { - local.RegisterExtractor("noop", func(fs fs.FS, path string) local.Extractor { return noopExtractor{} }) - conf.Server.Scanner.Extractor = "noop" - ls, err := storage.For(filepath.Join(GinkgoT().TempDir(), "does-not-exist")) - Expect(err).ToNot(HaveOccurred()) - lsw := ls.(storage.Watcher) - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - _, err = lsw.Start(ctx) - Expect(err).To(HaveOccurred()) - }) -}) diff --git a/core/storage/storage.go b/core/storage/storage.go deleted file mode 100644 index 242965c99..000000000 --- a/core/storage/storage.go +++ /dev/null @@ -1,79 +0,0 @@ -package storage - -import ( - "errors" - "net/url" - "path/filepath" - "strings" - "sync" - - "github.com/navidrome/navidrome/utils/slice" -) - -const LocalSchemaID = "file" - -type constructor func(url.URL) Storage - -var ( - registry = map[string]constructor{} - lock sync.RWMutex -) - -func Register(schema string, c constructor) { - lock.Lock() - defer lock.Unlock() - registry[schema] = c -} - -// LocalPathToURL converts a bare OS filesystem path into an absolute file:// URL, -// applying the same slash-normalisation and per-component escaping the scanner -// relies on. It is the single source of truth for how a local path becomes a -// storage URL, shared by For and by storage tests. -func LocalPathToURL(osPath string) (url.URL, error) { - abs, _ := filepath.Abs(osPath) - abs = filepath.ToSlash(abs) - - // Properly escape each path component using URL standards - pathParts := strings.Split(abs, "/") - escapedParts := slice.Map(pathParts, func(s string) string { - return url.PathEscape(s) - }) - - u, err := url.Parse(LocalSchemaID + "://" + strings.Join(escapedParts, "/")) - if err != nil { - return url.URL{}, err - } - return *u, nil -} - -// For returns a Storage implementation for the given URI. -// It uses the schema part of the URI to find the correct registered -// Storage constructor. -// If the URI does not contain a schema, it is treated as a file:// URI. -func For(uri string) (Storage, error) { - lock.RLock() - defer lock.RUnlock() - parts := strings.Split(uri, "://") - - var u *url.URL - // Paths without schema are treated as file:// and use the default LocalStorage implementation - if len(parts) < 2 { - parsed, err := LocalPathToURL(uri) - if err != nil { - return nil, err - } - u = &parsed - } else { - parsed, err := url.Parse(uri) - if err != nil { - return nil, err - } - u = parsed - } - - c, ok := registry[u.Scheme] - if !ok { - return nil, errors.New("schema '" + u.Scheme + "' not registered") - } - return c(*u), nil -} diff --git a/core/storage/storage_test.go b/core/storage/storage_test.go deleted file mode 100644 index 03904d086..000000000 --- a/core/storage/storage_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package storage - -import ( - "net/url" - "os" - "path/filepath" - "runtime" - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestApp(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Storage Test Suite") -} - -var _ = Describe("Storage", func() { - When("schema is not registered", func() { - BeforeEach(func() { - registry = map[string]constructor{} - }) - - It("should return error", func() { - _, err := For("file:///tmp") - Expect(err).To(HaveOccurred()) - }) - }) - When("schema is registered", func() { - BeforeEach(func() { - registry = map[string]constructor{} - Register("file", func(url url.URL) Storage { return &fakeLocalStorage{u: url} }) - Register("s3", func(url url.URL) Storage { return &fakeS3Storage{u: url} }) - }) - - It("should return correct implementation", func() { - s, err := For("file:///tmp") - Expect(err).ToNot(HaveOccurred()) - Expect(s).To(BeAssignableToTypeOf(&fakeLocalStorage{})) - Expect(s.(*fakeLocalStorage).u.Scheme).To(Equal("file")) - Expect(s.(*fakeLocalStorage).u.Path).To(Equal("/tmp")) - - s, err = For("s3:///bucket") - Expect(err).ToNot(HaveOccurred()) - Expect(s).To(BeAssignableToTypeOf(&fakeS3Storage{})) - Expect(s.(*fakeS3Storage).u.Scheme).To(Equal("s3")) - Expect(s.(*fakeS3Storage).u.Path).To(Equal("/bucket")) - }) - It("should return a file implementation when schema is not specified", func() { - s, err := For("/tmp") - Expect(err).ToNot(HaveOccurred()) - Expect(s).To(BeAssignableToTypeOf(&fakeLocalStorage{})) - Expect(s.(*fakeLocalStorage).u.Scheme).To(Equal("file")) - Expect(s.(*fakeLocalStorage).u.Path).To(Equal("/tmp")) - }) - It("should return a file implementation for a relative folder", func() { - s, err := For("tmp") - Expect(err).ToNot(HaveOccurred()) - cwd, _ := os.Getwd() - Expect(s).To(BeAssignableToTypeOf(&fakeLocalStorage{})) - u := s.(*fakeLocalStorage).u - Expect(u.Scheme).To(Equal("file")) - // On Windows the drive letter lands in u.Host, so re-join it with - // u.Path (as newLocalStorage does) to keep the assertion OS-independent. - got := filepath.Join(u.Host, filepath.FromSlash(u.Path)) - Expect(got).To(Equal(filepath.Join(cwd, "tmp"))) - }) - It("should return error if schema is unregistered", func() { - _, err := For("webdav:///tmp") - Expect(err).To(HaveOccurred()) - }) - DescribeTable("should handle paths with special characters correctly", - func(inputPath string) { - s, err := For(inputPath) - Expect(err).ToNot(HaveOccurred()) - Expect(s).To(BeAssignableToTypeOf(&fakeLocalStorage{})) - Expect(s.(*fakeLocalStorage).u.Scheme).To(Equal("file")) - // The path should be exactly the same as the input - after URL parsing it gets decoded back - Expect(s.(*fakeLocalStorage).u.Path).To(Equal(inputPath)) - }, - Entry("hash symbols", "/tmp/test#folder/file.mp3"), - Entry("spaces", "/tmp/test folder/file with spaces.mp3"), - Entry("question marks", "/tmp/test?query/file.mp3"), - Entry("ampersands", "/tmp/test&/file.mp3"), - Entry("multiple special chars", "/tmp/Song #1 & More?.mp3"), - ) - }) - - Describe("LocalPathToURL", func() { - It("builds a file:// URL from an absolute path", func() { - u, err := LocalPathToURL("/tmp/music") - Expect(err).ToNot(HaveOccurred()) - Expect(u.Scheme).To(Equal("file")) - Expect(u.Path).To(Equal("/tmp/music")) - }) - - It("escapes special characters and decodes them back in Path", func() { - u, err := LocalPathToURL("/tmp/Song #1 & More?.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(u.Path).To(Equal("/tmp/Song #1 & More?.mp3")) - }) - - It("produces the same url.URL that For uses for a bare path", func() { - registry = map[string]constructor{} - Register("file", func(url url.URL) Storage { return &fakeLocalStorage{u: url} }) - s, err := For("/tmp/music") - Expect(err).ToNot(HaveOccurred()) - direct, err := LocalPathToURL("/tmp/music") - Expect(err).ToNot(HaveOccurred()) - Expect(s.(*fakeLocalStorage).u).To(Equal(direct)) - }) - - // On Windows, library paths are drive-lettered (e.g. C:\Music). This - // exercises the real conversion to confirm a drive-letter path yields a - // usable file:// URL on the actual platform (no-op on Unix). - It("handles a Windows drive-letter path", func() { - if runtime.GOOS != "windows" { - Skip("Windows-specific path handling") - } - u, err := LocalPathToURL(`C:\Music`) - Expect(err).ToNot(HaveOccurred()) - Expect(u.Scheme).To(Equal("file")) - }) - }) -}) - -type fakeLocalStorage struct { - Storage - u url.URL -} -type fakeS3Storage struct { - Storage - u url.URL -} diff --git a/core/storage/storagetest/fake_storage.go b/core/storage/storagetest/fake_storage.go deleted file mode 100644 index 1b0d1a6c1..000000000 --- a/core/storage/storagetest/fake_storage.go +++ /dev/null @@ -1,321 +0,0 @@ -//nolint:unused -package storagetest - -import ( - "encoding/json" - "errors" - "fmt" - "io/fs" - "maps" - "net/url" - "path" - "testing/fstest" - "time" - - "github.com/navidrome/navidrome/core/storage" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/metadata" - "github.com/navidrome/navidrome/utils/random" -) - -// FakeStorage is a fake storage that provides a FakeFS. -// It is used for testing purposes. -type FakeStorage struct{ fs *FakeFS } - -// Register registers the FakeStorage for the given scheme. To use it, set the model.Library's Path to "fake:///music", -// and register a FakeFS with schema = "fake". The storage registered will always return the same FakeFS instance. -func Register(schema string, fs *FakeFS) { - storage.Register(schema, func(url url.URL) storage.Storage { return &FakeStorage{fs: fs} }) -} - -func (s FakeStorage) FS() (storage.MusicFS, error) { - return s.fs, nil -} - -// FakeFS is a fake filesystem that can be used for testing purposes. -// It implements the storage.MusicFS interface and keeps all files in memory, by using a fstest.MapFS internally. -// You must NOT add files directly in the MapFS property, but use SetFiles and its other methods instead. -// This is because the FakeFS keeps track of the latest modification time of directories, simulating the -// behavior of a real filesystem, and you should not bypass this logic. -type FakeFS struct { - fstest.MapFS - properInit bool -} - -func (ffs *FakeFS) SetFiles(files fstest.MapFS) { - ffs.properInit = true - ffs.MapFS = files - ffs.createDirTimestamps() -} - -func (ffs *FakeFS) Add(filePath string, file *fstest.MapFile, when ...time.Time) { - if len(when) == 0 { - when = append(when, time.Now()) - } - ffs.MapFS[filePath] = file - ffs.touchContainingFolder(filePath, when[0]) - ffs.createDirTimestamps() -} - -func (ffs *FakeFS) Remove(filePath string, when ...time.Time) *fstest.MapFile { - filePath = path.Clean(filePath) - if len(when) == 0 { - when = append(when, time.Now()) - } - if f, ok := ffs.MapFS[filePath]; ok { - ffs.touchContainingFolder(filePath, when[0]) - delete(ffs.MapFS, filePath) - return f - } - return nil -} - -func (ffs *FakeFS) Move(srcPath string, destPath string, when ...time.Time) { - if len(when) == 0 { - when = append(when, time.Now()) - } - srcPath = path.Clean(srcPath) - destPath = path.Clean(destPath) - ffs.MapFS[destPath] = ffs.MapFS[srcPath] - ffs.touchContainingFolder(destPath, when[0]) - ffs.Remove(srcPath, when...) -} - -// Touch sets the modification time of a file. -func (ffs *FakeFS) Touch(filePath string, when ...time.Time) { - if len(when) == 0 { - when = append(when, time.Now()) - } - filePath = path.Clean(filePath) - file, ok := ffs.MapFS[filePath] - if ok { - file.ModTime = when[0] - } else { - ffs.MapFS[filePath] = &fstest.MapFile{ModTime: when[0]} - } - ffs.touchContainingFolder(filePath, file.ModTime) -} - -func (ffs *FakeFS) touchContainingFolder(filePath string, ts time.Time) { - dir := path.Dir(filePath) - dirFile, ok := ffs.MapFS[dir] - if !ok { - log.Fatal("Directory not found. Forgot to call SetFiles?", "file", filePath) - } - if dirFile.ModTime.Before(ts) { - dirFile.ModTime = ts - } -} - -// SetError sets an error that will be returned when trying to read the file. -func (ffs *FakeFS) SetError(filePath string, err error) { - filePath = path.Clean(filePath) - if ffs.MapFS[filePath] == nil { - ffs.MapFS[filePath] = &fstest.MapFile{Data: []byte{}} - } - ffs.MapFS[filePath].Sys = err - ffs.Touch(filePath) -} - -// ClearError clears the error set by SetError. -func (ffs *FakeFS) ClearError(filePath string) { - filePath = path.Clean(filePath) - if file := ffs.MapFS[filePath]; file != nil { - file.Sys = nil - } - ffs.Touch(filePath) -} - -func (ffs *FakeFS) UpdateTags(filePath string, newTags map[string]any, when ...time.Time) { - f, ok := ffs.MapFS[filePath] - if !ok { - panic(fmt.Errorf("file %s not found", filePath)) - } - var tags map[string]any - err := json.Unmarshal(f.Data, &tags) - if err != nil { - panic(err) - } - maps.Copy(tags, newTags) - data, _ := json.Marshal(tags) - f.Data = data - ffs.Touch(filePath, when...) -} - -// createDirTimestamps loops through all entries and create/updates directories entries in the map with the -// latest ModTime from any children of that directory. -func (ffs *FakeFS) createDirTimestamps() bool { - var changed bool - for filePath, file := range ffs.MapFS { - dir := path.Dir(filePath) - dirFile, ok := ffs.MapFS[dir] - if !ok { - dirFile = &fstest.MapFile{Mode: fs.ModeDir} - ffs.MapFS[dir] = dirFile - } - if dirFile.ModTime.IsZero() { - dirFile.ModTime = file.ModTime - changed = true - } - } - if changed { - // If we updated any directory, we need to re-run the loop to create any parent directories - ffs.createDirTimestamps() - } - return changed -} - -func ModTime(ts string) map[string]any { return map[string]any{fakeFileInfoModTime: ts} } -func BirthTime(ts string) map[string]any { return map[string]any{fakeFileInfoBirthTime: ts} } - -func Template(t ...map[string]any) func(...map[string]any) *fstest.MapFile { - return func(tags ...map[string]any) *fstest.MapFile { - return MP3(append(t, tags...)...) - } -} - -func Track(num int, title string, tags ...map[string]any) map[string]any { - ts := audioProperties("mp3", 320) - ts["title"] = title - ts["track"] = num - for _, t := range tags { - maps.Copy(ts, t) - } - return ts -} - -func MP3(tags ...map[string]any) *fstest.MapFile { - ts := audioProperties("mp3", 320) - if _, ok := ts[fakeFileInfoSize]; !ok { - duration := ts["duration"].(int64) - bitrate := ts["bitrate"].(int) - ts[fakeFileInfoSize] = duration * int64(bitrate) / 8 * 1000 - } - return File(append([]map[string]any{ts}, tags...)...) -} - -func File(tags ...map[string]any) *fstest.MapFile { - ts := map[string]any{} - for _, t := range tags { - maps.Copy(ts, t) - } - modTime := time.Now() - if mt, ok := ts[fakeFileInfoModTime]; !ok { - ts[fakeFileInfoModTime] = time.Now().Format(time.RFC3339) - } else { - modTime, _ = time.Parse(time.RFC3339, mt.(string)) - } - if _, ok := ts[fakeFileInfoBirthTime]; !ok { - ts[fakeFileInfoBirthTime] = time.Now().Format(time.RFC3339) - } - if _, ok := ts[fakeFileInfoMode]; !ok { - ts[fakeFileInfoMode] = fs.ModePerm - } - data, _ := json.Marshal(ts) - if _, ok := ts[fakeFileInfoSize]; !ok { - ts[fakeFileInfoSize] = int64(len(data)) - } - return &fstest.MapFile{Data: data, ModTime: modTime, Mode: ts[fakeFileInfoMode].(fs.FileMode)} -} - -func audioProperties(suffix string, bitrate int) map[string]any { - duration := random.Int64N(300) + 120 - return map[string]any{ - "suffix": suffix, - "bitrate": bitrate, - "duration": duration, - "samplerate": 44100, - "bitdepth": 16, - "channels": 2, - } -} - -func (ffs *FakeFS) ReadTags(paths ...string) (map[string]metadata.Info, error) { - if !ffs.properInit { - log.Fatal("FakeFS not initialized properly. Use SetFiles") - } - result := make(map[string]metadata.Info) - var errs []error - for _, file := range paths { - p, err := ffs.parseFile(file) - if err != nil { - log.Warn("Error reading metadata from file", "file", file, "err", err) - errs = append(errs, err) - } else { - result[file] = *p - } - } - if len(errs) > 0 { - return result, fmt.Errorf("errors reading metadata: %w", errors.Join(errs...)) - } - return result, nil -} - -func (ffs *FakeFS) parseFile(filePath string) (*metadata.Info, error) { - // Check if it should throw an error when reading this file - stat, err := ffs.Stat(filePath) - if err != nil { - return nil, err - } - if stat.Sys() != nil { - return nil, stat.Sys().(error) - } - - // Read the file contents and parse the tags - contents, err := fs.ReadFile(ffs, filePath) - if err != nil { - return nil, err - } - data := map[string]any{} - err = json.Unmarshal(contents, &data) - if err != nil { - return nil, err - } - p := metadata.Info{ - Tags: map[string][]string{}, - AudioProperties: metadata.AudioProperties{}, - HasPicture: data["has_picture"] == "true", - } - if d, ok := data["duration"].(float64); ok { - p.AudioProperties.Duration = time.Duration(d) * time.Second - } - getInt := func(key string) int { v, _ := data[key].(float64); return int(v) } - p.AudioProperties.BitRate = getInt("bitrate") - p.AudioProperties.BitDepth = getInt("bitdepth") - p.AudioProperties.SampleRate = getInt("samplerate") - p.AudioProperties.Channels = getInt("channels") - if codec, ok := data["codec"].(string); ok { - p.AudioProperties.Codec = codec - } - for k, v := range data { - p.Tags[k] = []string{fmt.Sprintf("%v", v)} - } - file := ffs.MapFS[filePath] - p.FileInfo = &fakeFileInfo{path: filePath, tags: data, file: file} - return &p, nil -} - -const ( - fakeFileInfoMode = "_mode" - fakeFileInfoSize = "_size" - fakeFileInfoModTime = "_modtime" - fakeFileInfoBirthTime = "_birthtime" -) - -type fakeFileInfo struct { - path string - file *fstest.MapFile - tags map[string]any -} - -func (ffi *fakeFileInfo) Name() string { return path.Base(ffi.path) } -func (ffi *fakeFileInfo) Size() int64 { v, _ := ffi.tags[fakeFileInfoSize].(float64); return int64(v) } -func (ffi *fakeFileInfo) Mode() fs.FileMode { return ffi.file.Mode } -func (ffi *fakeFileInfo) IsDir() bool { return false } -func (ffi *fakeFileInfo) Sys() any { return nil } -func (ffi *fakeFileInfo) ModTime() time.Time { return ffi.file.ModTime } -func (ffi *fakeFileInfo) BirthTime() time.Time { return ffi.parseTime(fakeFileInfoBirthTime) } -func (ffi *fakeFileInfo) parseTime(key string) time.Time { - t, _ := time.Parse(time.RFC3339, ffi.tags[key].(string)) - return t -} diff --git a/core/storage/storagetest/fake_storage_test.go b/core/storage/storagetest/fake_storage_test.go deleted file mode 100644 index 46deb778a..000000000 --- a/core/storage/storagetest/fake_storage_test.go +++ /dev/null @@ -1,139 +0,0 @@ -//nolint:unused -package storagetest_test - -import ( - "io/fs" - "testing" - "testing/fstest" - "time" - - . "github.com/navidrome/navidrome/core/storage/storagetest" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type _t = map[string]any - -func TestFakeStorage(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Fake Storage Test Suite") -} - -var _ = Describe("FakeFS", func() { - var ffs FakeFS - var startTime time.Time - - BeforeEach(func() { - startTime = time.Now().Add(-time.Hour) - boy := Template(_t{"albumartist": "U2", "album": "Boy", "year": 1980, "genre": "Rock"}) - files := fstest.MapFS{ - "U2/Boy/I Will Follow.mp3": boy(Track(1, "I Will Follow")), - "U2/Boy/Twilight.mp3": boy(Track(2, "Twilight")), - "U2/Boy/An Cat Dubh.mp3": boy(Track(3, "An Cat Dubh")), - } - ffs.SetFiles(files) - }) - - It("should implement a fs.FS", func() { - Expect(fstest.TestFS(ffs, "U2/Boy/I Will Follow.mp3")).To(Succeed()) - }) - - It("should read file info", func() { - props, err := ffs.ReadTags("U2/Boy/I Will Follow.mp3", "U2/Boy/Twilight.mp3") - Expect(err).ToNot(HaveOccurred()) - - prop := props["U2/Boy/Twilight.mp3"] - Expect(prop).ToNot(BeNil()) - Expect(prop.AudioProperties.Channels).To(Equal(2)) - Expect(prop.AudioProperties.BitRate).To(Equal(320)) - Expect(prop.FileInfo.Name()).To(Equal("Twilight.mp3")) - Expect(prop.Tags["albumartist"]).To(ConsistOf("U2")) - Expect(prop.FileInfo.ModTime()).To(BeTemporally(">=", startTime)) - - prop = props["U2/Boy/I Will Follow.mp3"] - Expect(prop).ToNot(BeNil()) - Expect(prop.FileInfo.Name()).To(Equal("I Will Follow.mp3")) - }) - - It("should return ModTime for directories", func() { - root := ffs.MapFS["."] - dirInfo1, err := ffs.Stat("U2") - Expect(err).ToNot(HaveOccurred()) - dirInfo2, err := ffs.Stat("U2/Boy") - Expect(err).ToNot(HaveOccurred()) - Expect(dirInfo1.ModTime()).To(Equal(root.ModTime)) - Expect(dirInfo1.ModTime()).To(BeTemporally(">=", startTime)) - Expect(dirInfo1.ModTime()).To(Equal(dirInfo2.ModTime())) - }) - - When("the file is touched", func() { - It("should only update the file and the file's directory ModTime", func() { - root, _ := ffs.Stat(".") - u2Dir, _ := ffs.Stat("U2") - boyDir, _ := ffs.Stat("U2/Boy") - previousTime := root.ModTime() - - aTimeStamp := previousTime.Add(time.Hour) - ffs.Touch("U2/./Boy/Twilight.mp3", aTimeStamp) - - twilightFile, err := ffs.Stat("U2/Boy/Twilight.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(twilightFile.ModTime()).To(Equal(aTimeStamp)) - - Expect(root.ModTime()).To(Equal(previousTime)) - Expect(u2Dir.ModTime()).To(Equal(previousTime)) - Expect(boyDir.ModTime()).To(Equal(aTimeStamp)) - }) - }) - - When("adding/removing files", func() { - It("should keep the timestamps correct", func() { - root, _ := ffs.Stat(".") - u2Dir, _ := ffs.Stat("U2") - boyDir, _ := ffs.Stat("U2/Boy") - previousTime := root.ModTime() - aTimeStamp := previousTime.Add(time.Hour) - - ffs.Add("U2/Boy/../Boy/Another.mp3", &fstest.MapFile{ModTime: aTimeStamp}, aTimeStamp) - Expect(u2Dir.ModTime()).To(Equal(previousTime)) - Expect(boyDir.ModTime()).To(Equal(aTimeStamp)) - - aTimeStamp = aTimeStamp.Add(time.Hour) - ffs.Remove("U2/./Boy/Twilight.mp3", aTimeStamp) - - _, err := ffs.Stat("U2/Boy/Twilight.mp3") - Expect(err).To(MatchError(fs.ErrNotExist)) - Expect(u2Dir.ModTime()).To(Equal(previousTime)) - Expect(boyDir.ModTime()).To(Equal(aTimeStamp)) - }) - }) - - When("moving files", func() { - It("should allow relative paths", func() { - ffs.Move("U2/../U2/Boy/Twilight.mp3", "./Twilight.mp3") - Expect(ffs.MapFS).To(HaveKey("Twilight.mp3")) - file, err := ffs.Stat("Twilight.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(file.Name()).To(Equal("Twilight.mp3")) - }) - It("should keep the timestamps correct", func() { - root, _ := ffs.Stat(".") - u2Dir, _ := ffs.Stat("U2") - boyDir, _ := ffs.Stat("U2/Boy") - previousTime := root.ModTime() - twilightFile, _ := ffs.Stat("U2/Boy/Twilight.mp3") - filePreviousTime := twilightFile.ModTime() - aTimeStamp := previousTime.Add(time.Hour) - - ffs.Move("U2/Boy/Twilight.mp3", "Twilight.mp3", aTimeStamp) - - Expect(root.ModTime()).To(Equal(aTimeStamp)) - Expect(u2Dir.ModTime()).To(Equal(previousTime)) - Expect(boyDir.ModTime()).To(Equal(aTimeStamp)) - - Expect(ffs.MapFS).ToNot(HaveKey("U2/Boy/Twilight.mp3")) - twilight := ffs.MapFS["Twilight.mp3"] - Expect(twilight.ModTime).To(Equal(filePreviousTime)) - }) - }) -}) diff --git a/core/stream/aliases.go b/core/stream/aliases.go deleted file mode 100644 index af42ac076..000000000 --- a/core/stream/aliases.go +++ /dev/null @@ -1,92 +0,0 @@ -package stream - -import ( - "slices" - "strings" -) - -// containerAliasGroups maps each container alias to a canonical group name. -var containerAliasGroups = func() map[string]string { - groups := [][]string{ - {"aac", "adts", "m4a", "mp4", "m4b", "m4p"}, - {"mpeg", "mp3", "mp2"}, - {"ogg", "oga", "opus"}, - {"aif", "aiff"}, - {"asf", "wma"}, - {"mpc", "mpp"}, - {"wv"}, - } - m := make(map[string]string) - for _, g := range groups { - canonical := g[0] - for _, name := range g { - m[name] = canonical - } - } - return m -}() - -// codecAliasGroups maps each codec alias to a canonical group name. -// Codecs within the same group are considered equivalent. -var codecAliasGroups = func() map[string]string { - groups := [][]string{ - {"aac", "adts"}, - {"ac3", "ac-3"}, - {"eac3", "e-ac3", "e-ac-3", "eac-3"}, - {"mpc7", "musepack7"}, - {"mpc8", "musepack8"}, - {"wma1", "wmav1"}, - {"wma2", "wmav2"}, - {"wmalossless", "wma9lossless"}, - {"wmapro", "wma9pro"}, - {"shn", "shorten"}, - {"mp4als", "als"}, - } - m := make(map[string]string) - for _, g := range groups { - for _, name := range g { - m[name] = g[0] // canonical = first entry - } - } - return m -}() - -// matchesWithAliases checks if a value matches any entry in candidates, -// consulting the alias map for equivalent names. -func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool { - value = strings.ToLower(value) - canonical := aliases[value] - for _, c := range candidates { - c = strings.ToLower(c) - if c == value { - return true - } - if canonical != "" && aliases[c] == canonical { - return true - } - } - return false -} - -// matchesContainer checks if a file suffix matches any of the container names, -// including common aliases. -func matchesContainer(suffix string, containers []string) bool { - return matchesWithAliases(suffix, containers, containerAliasGroups) -} - -// matchesCodec checks if a codec matches any of the codec names, -// including common aliases. -func matchesCodec(codec string, codecs []string) bool { - return matchesWithAliases(codec, codecs, codecAliasGroups) -} - -// IsAACCodec returns true if the given codec or container name resolves to AAC. -func IsAACCodec(name string) bool { - return matchesCodec(name, []string{"aac"}) || matchesContainer(name, []string{"aac"}) -} - -func containsIgnoreCase(slice []string, s string) bool { - return slices.ContainsFunc(slice, func(item string) bool { - return strings.EqualFold(item, s) - }) -} diff --git a/core/stream/aliases_test.go b/core/stream/aliases_test.go deleted file mode 100644 index 72f061810..000000000 --- a/core/stream/aliases_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package stream - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Aliases", func() { - Describe("IsAACCodec", func() { - It("returns true for AAC and its aliases", func() { - Expect(IsAACCodec("aac")).To(BeTrue()) - Expect(IsAACCodec("AAC")).To(BeTrue()) - Expect(IsAACCodec("adts")).To(BeTrue()) - Expect(IsAACCodec("m4a")).To(BeTrue()) - Expect(IsAACCodec("mp4")).To(BeTrue()) - Expect(IsAACCodec("m4b")).To(BeTrue()) - }) - - It("returns false for non-AAC formats", func() { - Expect(IsAACCodec("mp3")).To(BeFalse()) - Expect(IsAACCodec("opus")).To(BeFalse()) - Expect(IsAACCodec("flac")).To(BeFalse()) - Expect(IsAACCodec("ogg")).To(BeFalse()) - }) - - It("returns false for empty string", func() { - Expect(IsAACCodec("")).To(BeFalse()) - }) - }) -}) diff --git a/core/stream/codec.go b/core/stream/codec.go deleted file mode 100644 index 56d163324..000000000 --- a/core/stream/codec.go +++ /dev/null @@ -1,93 +0,0 @@ -package stream - -import "strings" - -// normalizeProbeCodec maps ffprobe codec_name values to the simplified internal -// codec names used throughout Navidrome (matching inferCodecFromSuffix output). -// Most ffprobe names match directly; this handles the exceptions. -func normalizeProbeCodec(codec string) string { - c := strings.ToLower(codec) - // DSD variants: dsd_lsbf_planar, dsd_msbf_planar, dsd_lsbf, dsd_msbf - if strings.HasPrefix(c, "dsd") { - return "dsd" - } - // PCM variants: pcm_s16le, pcm_s24le, pcm_s32be, pcm_f32le, etc. - if strings.HasPrefix(c, "pcm_") { - return "pcm" - } - return c -} - -// isLosslessFormat returns true if the format is a known lossless audio codec/format. -// Detection is based on codec name only, not bit depth — some lossy codecs (e.g. ADPCM) -// report non-zero bits_per_sample in ffprobe, so bit depth alone is not a reliable signal. -// -// Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats -// ffmpeg can produce as output (a smaller set). -func isLosslessFormat(format string) bool { - switch strings.ToLower(format) { - case "flac", "alac", "wav", "aiff", "ape", "wv", "wavpack", "tta", "tak", "shn", "dsd", "pcm": - return true - } - return false -} - -// normalizeSourceSampleRate adjusts the source sample rate for codecs that store -// it differently than PCM. Currently handles DSD (÷8): -// DSD64=2822400→352800, DSD128=5644800→705600, etc. -// For other codecs, returns the rate unchanged. -func normalizeSourceSampleRate(sampleRate int, codec string) int { - if strings.EqualFold(codec, "dsd") && sampleRate > 0 { - return sampleRate / 8 - } - return sampleRate -} - -// targetBitDepth returns the bit depth for a transcoded stream: 0 for lossy -// targets (they have no PCM bit depth), otherwise the source depth, with DSD -// adjusted to the 24-bit PCM that ffmpeg produces. -func targetBitDepth(srcBitDepth int, srcCodec string, targetIsLossless bool) int { - if !targetIsLossless { - return 0 - } - if strings.EqualFold(srcCodec, "dsd") && srcBitDepth == 1 { - return 24 - } - return srcBitDepth -} - -// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs -// that always resample regardless of input (e.g., Opus always outputs 48000Hz). -// Returns 0 if the codec has no fixed output rate. -func codecFixedOutputSampleRate(codec string) int { - switch strings.ToLower(codec) { - case "opus": - return 48000 - } - return 0 -} - -// codecMaxSampleRate returns the hard maximum output sample rate for a codec. -// Returns 0 if the codec has no hard limit. -func codecMaxSampleRate(codec string) int { - switch strings.ToLower(codec) { - case "mp3": - return 48000 - case "aac": - return 96000 - } - 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 deleted file mode 100644 index 97e15bdb5..000000000 --- a/core/stream/codec_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package stream - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Codec", func() { - Describe("isLosslessFormat", func() { - It("returns true for known lossless codecs", func() { - Expect(isLosslessFormat("flac")).To(BeTrue()) - Expect(isLosslessFormat("alac")).To(BeTrue()) - Expect(isLosslessFormat("pcm")).To(BeTrue()) - Expect(isLosslessFormat("wav")).To(BeTrue()) - Expect(isLosslessFormat("dsd")).To(BeTrue()) - Expect(isLosslessFormat("ape")).To(BeTrue()) - Expect(isLosslessFormat("wv")).To(BeTrue()) - Expect(isLosslessFormat("wavpack")).To(BeTrue()) // ffprobe codec_name for WavPack - }) - - It("returns false for lossy codecs", func() { - Expect(isLosslessFormat("mp3")).To(BeFalse()) - Expect(isLosslessFormat("aac")).To(BeFalse()) - Expect(isLosslessFormat("opus")).To(BeFalse()) - Expect(isLosslessFormat("vorbis")).To(BeFalse()) - }) - - It("returns false for unknown codecs", func() { - Expect(isLosslessFormat("unknown_codec")).To(BeFalse()) - }) - - It("is case-insensitive", func() { - Expect(isLosslessFormat("FLAC")).To(BeTrue()) - Expect(isLosslessFormat("Alac")).To(BeTrue()) - }) - }) - - Describe("normalizeProbeCodec", func() { - It("passes through common codec names unchanged", func() { - Expect(normalizeProbeCodec("mp3")).To(Equal("mp3")) - Expect(normalizeProbeCodec("aac")).To(Equal("aac")) - Expect(normalizeProbeCodec("flac")).To(Equal("flac")) - Expect(normalizeProbeCodec("opus")).To(Equal("opus")) - Expect(normalizeProbeCodec("vorbis")).To(Equal("vorbis")) - Expect(normalizeProbeCodec("alac")).To(Equal("alac")) - Expect(normalizeProbeCodec("wmav2")).To(Equal("wmav2")) - }) - - It("normalizes DSD variants to dsd", func() { - Expect(normalizeProbeCodec("dsd_lsbf_planar")).To(Equal("dsd")) - Expect(normalizeProbeCodec("dsd_msbf_planar")).To(Equal("dsd")) - Expect(normalizeProbeCodec("dsd_lsbf")).To(Equal("dsd")) - Expect(normalizeProbeCodec("dsd_msbf")).To(Equal("dsd")) - }) - - It("normalizes PCM variants to pcm", func() { - Expect(normalizeProbeCodec("pcm_s16le")).To(Equal("pcm")) - Expect(normalizeProbeCodec("pcm_s24le")).To(Equal("pcm")) - Expect(normalizeProbeCodec("pcm_s32be")).To(Equal("pcm")) - Expect(normalizeProbeCodec("pcm_f32le")).To(Equal("pcm")) - }) - - It("lowercases input", func() { - Expect(normalizeProbeCodec("MP3")).To(Equal("mp3")) - Expect(normalizeProbeCodec("AAC")).To(Equal("aac")) - 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 deleted file mode 100644 index 38839cdf0..000000000 --- a/core/stream/decider.go +++ /dev/null @@ -1,459 +0,0 @@ -package stream - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/gg" -) - -const fallbackBitrate = 256 // kbps - -// TranscodeDecider is the core service interface for making transcoding decisions -type TranscodeDecider interface { - MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error) - CreateTranscodeParams(decision *TranscodeDecision) (string, error) - ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) - ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request - ResolveClientRequest(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) Request -} - -func NewTranscodeDecider(ds model.DataStore, ff ffmpeg.FFmpeg) TranscodeDecider { - return &deciderService{ - ds: ds, - ff: ff, - } -} - -type deciderService struct { - ds model.DataStore - ff ffmpeg.FFmpeg -} - -func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error) { - decision := &TranscodeDecision{ - MediaID: mf.ID, - SourceUpdatedAt: mf.UpdatedAt, - } - - var probe *ffmpeg.AudioProbeResult - if !opts.SkipProbe { - 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 - } - } - } - - // Build source stream details (uses probe data if available) - decision.SourceStream = buildSourceStream(mf, probe) - src := &decision.SourceStream - - log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container, - "codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels, - "sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name) - - // Check global bitrate constraint first. - if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate { - log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play", - "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate) - decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported") - // Skip direct play profiles entirely — global constraint fails - } else { - // Try direct play profiles, collecting reasons for each failure - for _, profile := range clientInfo.DirectPlayProfiles { - if reason := s.checkDirectPlayProfile(src, &profile, clientInfo); reason == "" { - decision.CanDirectPlay = true - decision.TranscodeReasons = nil // Clear any previously collected reasons - break - } else { - decision.TranscodeReasons = append(decision.TranscodeReasons, reason) - } - } - } - - // If direct play is possible, we're done - if decision.CanDirectPlay { - log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", src.Container, "codec", src.Codec) - return decision, nil - } - - // Try transcoding profiles (in order of preference) - for _, profile := range clientInfo.TranscodingProfiles { - if ts, transcodeFormat := s.computeTranscodedStream(ctx, src, &profile, clientInfo); ts != nil { - decision.CanTranscode = true - decision.TargetFormat = transcodeFormat - decision.TargetBitrate = ts.Bitrate - decision.TargetChannels = ts.Channels - decision.TargetSampleRate = ts.SampleRate - decision.TargetBitDepth = ts.BitDepth - decision.TranscodeStream = ts - break - } - } - - if decision.CanTranscode { - log.Debug(ctx, "Transcode decision: transcode", "mediaID", mf.ID, - "targetFormat", decision.TargetFormat, "targetBitrate", decision.TargetBitrate, - "targetChannels", decision.TargetChannels, "reasons", decision.TranscodeReasons) - } - - // If neither direct play nor transcode is possible - if !decision.CanDirectPlay && !decision.CanTranscode { - decision.ErrorReason = "no compatible playback profile found" - log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID, - "container", src.Container, "codec", src.Codec, "reasons", decision.TranscodeReasons) - } - - return decision, nil -} - -func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Details { - sd := Details{ - Container: mf.Suffix, - Duration: mf.Duration, - Size: mf.Size, - } - - // Use pre-parsed probe result, or fall back to parsing stored probe data - if probe == nil { - probe, _ = parseProbeData(mf.ProbeData) - } - - // Use probe data if available for authoritative values - if probe != nil { - sd.Codec = normalizeProbeCodec(probe.Codec) - sd.Profile = probe.Profile - sd.Bitrate = probe.BitRate - sd.SampleRate = probe.SampleRate - sd.BitDepth = probe.BitDepth - sd.Channels = probe.Channels - } else { - sd.Codec = mf.AudioCodec() - sd.Bitrate = mf.BitRate - sd.SampleRate = mf.SampleRate - sd.BitDepth = gg.V(mf.BitDepth) - sd.Channels = mf.Channels - } - sd.IsLossless = isLosslessFormat(sd.Codec) - - return sd -} - -// applyServerOverride replaces the client-provided profiles with synthetic ones -// matching the server-forced transcoding format and bitrate. -func applyServerOverride(ctx context.Context, original *ClientInfo, trc *model.Transcoding) *ClientInfo { - maxBitRate := trc.DefaultBitRate - if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 { - maxBitRate = player.MaxBitRate - } - - log.Debug(ctx, "Applying server-side transcoding override", - "targetFormat", trc.TargetFormat, "maxBitRate", maxBitRate, - "client", original.Name) - - return &ClientInfo{ - Name: original.Name, - Platform: original.Platform, - MaxAudioBitrate: maxBitRate, - MaxTranscodingAudioBitrate: maxBitRate, - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{trc.TargetFormat}, AudioCodecs: []string{trc.TargetFormat}, Protocols: []string{ProtocolHTTP}}, - }, - TranscodingProfiles: []Profile{ - {Container: trc.TargetFormat, AudioCodec: trc.TargetFormat, Protocol: ProtocolHTTP}, - }, - } -} - -func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) { - if data == "" { - return nil, nil - } - var result ffmpeg.AudioProbeResult - if err := json.Unmarshal([]byte(data), &result); err != nil { - return nil, err - } - 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 { - // Check protocol (only http for now) - if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) { - return "protocol not supported" - } - - // Check container - if len(profile.Containers) > 0 && !matchesContainer(src.Container, profile.Containers) { - 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) && !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 fmt.Sprintf("audio channels %d not supported by profile %s (max %d)", src.Channels, profile, profile.MaxAudioChannels) - } - - // Check codec-specific limitations - for _, codecProfile := range clientInfo.CodecProfiles { - if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(src.Codec, []string{codecProfile.Name}) { - if reason := checkLimitations(src, codecProfile.Limitations); reason != "" { - return reason - } - } - } - - return "" -} - -// computeTranscodedStream attempts to build a valid transcoded stream for the given profile. -// Returns the stream details and the internal transcoding format (which may differ from the -// response container when a codec fallback occurs, e.g., "mp4"→"aac"). -// Returns nil, "" if the profile cannot produce a valid output. -func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Details, profile *Profile, clientInfo *ClientInfo) (*Details, string) { - // Check protocol (only http for now) - if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) { - log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol) - return nil, "" - } - - responseContainer, targetFormat := resolveTargetFormat(profile) - if targetFormat == "" { - return nil, "" - } - - // Verify we have a transcoding command available (DB custom or built-in default) - if LookupTranscodeCommand(ctx, s.ds, targetFormat) == "" { - log.Trace(ctx, "Skipping transcoding profile: no transcoding command available", "targetFormat", targetFormat) - return nil, "" - } - - targetIsLossless := isLosslessFormat(targetFormat) - - // Reject lossy to lossless conversion - if !src.IsLossless && targetIsLossless { - log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat) - return nil, "" - } - - ts := &Details{ - Container: responseContainer, - Codec: strings.ToLower(profile.AudioCodec), - SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec), - Channels: src.Channels, - BitDepth: targetBitDepth(src.BitDepth, src.Codec, targetIsLossless), - IsLossless: targetIsLossless, - } - if ts.Codec == "" { - ts.Codec = targetFormat - } - - // Apply codec-intrinsic sample rate adjustments before codec profile limitations - if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 { - ts.SampleRate = fixedRate - } - 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. 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 - } - - // Apply codec profile limitations to the TARGET codec - if ok := s.applyCodecLimitations(ctx, src.Bitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok { - return nil, "" - } - - return ts, targetFormat -} - -// lookupDefaultBitrate returns the default bitrate for the given format. -// It checks the DB first (for user-customized values), then falls back to -// the built-in defaults, and finally to fallbackBitrate. -func lookupDefaultBitrate(ctx context.Context, ds model.DataStore, format string) int { - if t, err := ds.Transcoding().FindByFormat(ctx, format); err == nil && t.DefaultBitRate > 0 { - return t.DefaultBitRate - } - for _, dt := range consts.DefaultTranscodings { - if dt.TargetFormat == format && dt.DefaultBitRate > 0 { - return dt.DefaultBitRate - } - } - return fallbackBitrate -} - -// LookupTranscodeCommand returns the ffmpeg command for the given format. -// It checks the DB first (for user-customized commands), then falls back to -// the built-in default command. Returns "" if the format is unknown. -func LookupTranscodeCommand(ctx context.Context, ds model.DataStore, format string) string { - t, err := ds.Transcoding().FindByFormat(ctx, format) - if err == nil && t.Command != "" { - return t.Command - } - // Fall back to built-in defaults - for _, dt := range consts.DefaultTranscodings { - if dt.TargetFormat == format { - return dt.Command - } - } - return "" -} - -// resolveTargetFormat determines the response container and internal target format -// from the profile's Container and AudioCodec fields. When an AudioCodec is specified -// it is preferred as targetFormat (e.g. container "mp4" with audioCodec "aac" → targetFormat "aac"). -func resolveTargetFormat(profile *Profile) (responseContainer, targetFormat string) { - responseContainer = strings.ToLower(profile.Container) - targetFormat = responseContainer - - // Prefer the audioCodec as targetFormat when provided (handles container-to-codec - // mapping like "mp4" → "aac", "ogg" → "opus"). - if profile.AudioCodec != "" { - targetFormat = strings.ToLower(profile.AudioCodec) - } - - // If neither container nor audioCodec is set, we can't resolve a format. - if targetFormat == "" { - return "", "" - } - - // When no container was specified, use the targetFormat as container too. - if responseContainer == "" { - responseContainer = targetFormat - } - - return responseContainer, targetFormat -} - -// computeBitrate determines the target bitrate for the transcoded stream. -// Returns false if the profile should be rejected. -func (s *deciderService) computeBitrate(ctx context.Context, src *Details, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool { - if src.IsLossless { - if !targetIsLossless { - if clientInfo.MaxTranscodingAudioBitrate > 0 { - ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate - } else if clientInfo.MaxAudioBitrate > 0 { - ts.Bitrate = clientInfo.MaxAudioBitrate - } else { - ts.Bitrate = lookupDefaultBitrate(ctx, s.ds, targetFormat) - } - } else { - if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate { - log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit", - "targetFormat", targetFormat, "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate) - return false - } - } - } else { - ts.Bitrate = src.Bitrate - } - - // Apply maxAudioBitrate as final cap - if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate { - ts.Bitrate = clientInfo.MaxAudioBitrate - } - return true -} - -// applyCodecLimitations applies codec profile limitations to the transcoded stream. -// Returns false if the profile should be rejected. -func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool { - targetCodec := ts.Codec - for _, codecProfile := range clientInfo.CodecProfiles { - if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) { - continue - } - if !matchesCodec(targetCodec, []string{codecProfile.Name}) { - continue - } - for _, lim := range codecProfile.Limitations { - result := applyLimitation(sourceBitrate, &lim, ts) - if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted { - log.Trace(ctx, "Skipping transcoding profile: cannot adjust bitrate for lossless target", - "targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name) - return false - } - if result == adjustCannotFit { - log.Trace(ctx, "Skipping transcoding profile: codec limitation cannot be satisfied", - "targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name, - "comparison", lim.Comparison, "values", lim.Values) - return false - } - } - } - return true -} - -// ensureProbed runs ffprobe if probe data is missing, persists it, and returns -// the parsed result. Returns (nil, nil) when probing is skipped or data already exists -// (in which case the caller should parse mf.ProbeData). -func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile) (*ffmpeg.AudioProbeResult, error) { - if mf.ProbeData != "" { - return nil, nil - } - if !conf.Server.DevEnableMediaFileProbe { - return nil, nil - } - - result, err := s.ff.ProbeAudioStream(ctx, mf.AbsolutePath()) - if err != nil { - return nil, fmt.Errorf("probing media file %s: %w", mf.ID, err) - } - - data, err := json.Marshal(result) - if err != nil { - return nil, fmt.Errorf("marshaling probe result for %s: %w", mf.ID, err) - } - mf.ProbeData = string(data) - - if err := s.ds.MediaFile().UpdateProbeData(ctx, mf.ID, mf.ProbeData); err != nil { - log.Error(ctx, "Failed to persist probe data", "mediaID", mf.ID, err) - // Don't fail the decision — we have the data in memory - } - - log.Debug(ctx, "Probed media file", "mediaID", mf.ID, "codec", result.Codec, - "profile", result.Profile, "bitRate", result.BitRate, - "sampleRate", result.SampleRate, "bitDepth", result.BitDepth, "channels", result.Channels) - return result, nil -} diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go deleted file mode 100644 index 01fef1249..000000000 --- a/core/stream/decider_test.go +++ /dev/null @@ -1,1295 +0,0 @@ -package stream - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// withProbe pre-populates ProbeData on a MediaFile from its own fields, -// so ensureProbed short-circuits and tests don't need mock ffprobe results. -func withProbe(mf *model.MediaFile) *model.MediaFile { - probe := ffmpeg.AudioProbeResult{ - Codec: mf.AudioCodec(), - BitRate: mf.BitRate, - SampleRate: mf.SampleRate, - BitDepth: gg.V(mf.BitDepth), - Channels: mf.Channels, - } - data, _ := json.Marshal(probe) - mf.ProbeData = string(data) - return mf -} - -var _ = Describe("Decider", func() { - var ( - ds *tests.MockDataStore - ff *tests.MockFFmpeg - svc TranscodeDecider - ctx context.Context - ) - - BeforeEach(func() { - ctx = GinkgoT().Context() - ds = &tests.MockDataStore{ - MockedProperty: &tests.MockedPropertyRepo{}, - MockedTranscoding: &tests.MockTranscodingRepo{}, - } - ff = tests.NewMockFFmpeg("") - auth.Init(ds) - svc = NewTranscodeDecider(ds, ff) - }) - - Describe("MakeDecision", func() { - Context("Direct Play", func() { - It("allows direct play when profile matches", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - Expect(decision.CanTranscode).To(BeFalse()) - Expect(decision.TranscodeReasons).To(BeEmpty()) - }) - - It("rejects direct play when container doesn't match", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.TranscodeReasons).To(ContainElement(And( - ContainSubstring("container 'flac' not supported"), - ContainSubstring("[mp3]"), - ))) - }) - - It("rejects direct play when codec doesn't match", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "ALAC", BitRate: 1000, Channels: 2}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.TranscodeReasons).To(ContainElement(And( - ContainSubstring("audio codec 'alac' not supported"), - ContainSubstring("[m4a/aac]"), - ))) - }) - - It("rejects direct play when channels exceed limit", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - 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() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"aac"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - - It("handles container aliases (mp4 -> m4a)", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - - It("handles container aliases (opus -> ogg)", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "opus", Codec: "Opus", BitRate: 165, Channels: 2, SampleRate: 48000}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP}}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - - It("handles codec aliases (adts -> aac)", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"m4a"}, AudioCodecs: []string{"adts"}, Protocols: []string{ProtocolHTTP}}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - - It("allows when protocol list is empty (any protocol)", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - - It("allows when both container and codec lists are empty (wildcard)", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{}, AudioCodecs: []string{}}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - }) - - Context("MaxAudioBitrate constraint", func() { - It("revokes direct play when bitrate exceeds maxAudioBitrate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2}) - ci := &ClientInfo{ - MaxAudioBitrate: 500, // kbps - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, 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.CanTranscode).To(BeTrue()) - Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported")) - }) - }) - - Context("Transcoding", func() { - It("selects transcoding when direct play isn't possible", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 256, // kbps - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, - }, - TranscodingProfiles: []Profile{ - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - Expect(decision.TargetBitrate).To(Equal(256)) // kbps - Expect(decision.TranscodeReasons).To(ContainElement(And( - ContainSubstring("container 'flac' not supported"), - ContainSubstring("[mp3]"), - ))) - }) - - It("rejects lossy to lossless transcoding", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2}) - ci := &ClientInfo{ - TranscodingProfiles: []Profile{ - {Container: "flac", Protocol: ProtocolHTTP}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeFalse()) - }) - - It("uses default bitrate when client doesn't specify", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: new(16)}) - ci := &ClientInfo{ - TranscodingProfiles: []Profile{ - {Container: "mp3", Protocol: ProtocolHTTP}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetBitrate).To(Equal(160)) // mp3 default from mock transcoding repo - }) - - It("preserves lossy bitrate when under max", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2}) - ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 256, // kbps - TranscodingProfiles: []Profile{ - {Container: "mp3", Protocol: ProtocolHTTP}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetBitrate).To(Equal(192)) // source bitrate in kbps - }) - - It("rejects format with no transcoding command available", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) - ci := &ClientInfo{ - TranscodingProfiles: []Profile{ - {Container: "wav", Protocol: ProtocolHTTP}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeFalse()) - }) - - It("applies maxAudioBitrate as final cap on transcoded stream", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2}) - ci := &ClientInfo{ - MaxAudioBitrate: 96, // kbps - 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.TargetBitrate).To(Equal(96)) // capped by maxAudioBitrate - }) - - It("selects first valid transcoding profile in order", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: new(16)}) - ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 320, - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, - }, - TranscodingProfiles: []Profile{ - {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("opus")) - }) - }) - - Context("Lossless to lossless transcoding", func() { - It("allows lossless to lossless when samplerate needs downsampling", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: new(1)}) - ci := &ClientInfo{ - MaxAudioBitrate: 1000, - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, 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.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - }) - - It("sets IsLossless=true on transcoded stream when target is lossless", func() { - // Transcoding to mp3 (lossy) should result in IsLossless=false. - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) - 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.TranscodeStream.IsLossless).To(BeFalse()) // mp3 is lossy - }) - }) - - Context("No compatible profile", func() { - It("returns error when nothing matches", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6}) - ci := &ClientInfo{} - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeFalse()) - Expect(decision.ErrorReason).To(Equal("no compatible playback profile found")) - }) - }) - - Context("Codec limitations on direct play", func() { - It("rejects direct play when codec limitation fails (required)", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "mp3", - Limitations: []Limitation{ - {Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: true}, - }, - }, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported")) - }) - - It("allows direct play when optional limitation fails", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "mp3", - Limitations: []Limitation{ - {Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: false}, - }, - }, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - - It("handles Equals comparison with multiple values", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "flac", - Limitations: []Limitation{ - {Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true}, - }, - }, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - - It("rejects when Equals comparison doesn't match any value", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "flac", - Limitations: []Limitation{ - {Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true}, - }, - }, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - }) - - It("rejects direct play when audioProfile limitation fails (required)", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "aac", - Limitations: []Limitation{ - {Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: true}, - }, - }, - }, - } - // Source profile is empty (not yet populated from scanner), so Equals("LC") fails - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.TranscodeReasons).To(ContainElement("audio profile not supported")) - }) - - It("allows direct play when audioProfile limitation is optional", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "aac", - Limitations: []Limitation{ - {Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: false}, - }, - }, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - - It("rejects direct play due to samplerate limitation", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "flac", - Limitations: []Limitation{ - {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true}, - }, - }, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.TranscodeReasons).To(ContainElement("audio samplerate not supported")) - }) - }) - - Context("Codec limitations on transcoded output", func() { - It("applies bitrate limitation to transcoded stream", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 192, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - MaxAudioBitrate: 96, // force transcode - TranscodingProfiles: []Profile{ - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "mp3", - Limitations: []Limitation{ - {Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"96"}, Required: true}, - }, - }, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TranscodeStream.Bitrate).To(Equal(96)) - }) - - It("applies channel limitation to transcoded stream", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: new(16)}) - ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 320, - TranscodingProfiles: []Profile{ - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "mp3", - Limitations: []Limitation{ - {Name: LimitationAudioChannels, Comparison: ComparisonLessThanEqual, Values: []string{"2"}, Required: true}, - }, - }, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TranscodeStream.Channels).To(Equal(2)) - }) - - It("applies samplerate limitation to transcoded stream", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) - ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 320, - TranscodingProfiles: []Profile{ - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "mp3", - Limitations: []Limitation{ - {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true}, - }, - }, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) - }) - - It("applies bitdepth limitation to transcoded stream", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) - ci := &ClientInfo{ - TranscodingProfiles: []Profile{ - {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "flac", - Limitations: []Limitation{ - {Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}, - }, - }, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) - Expect(decision.TargetBitDepth).To(Equal(16)) - }) - - It("preserves source bit depth when no limitation applies", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(24)}) - ci := &ClientInfo{ - TranscodingProfiles: []Profile{ - {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) - Expect(decision.TargetBitDepth).To(Equal(24)) - }) - - It("omits bit depth when transcoding to a lossy format", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) - ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 320, - TranscodingProfiles: []Profile{ - {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TranscodeStream.BitDepth).To(BeZero()) - Expect(decision.TargetBitDepth).To(BeZero()) - }) - - It("ignores audioBitdepth limitation when transcoding to a lossy format", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) - ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 320, - TranscodingProfiles: []Profile{ - {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "opus", - Limitations: []Limitation{ - {Name: LimitationAudioBitdepth, Comparison: ComparisonGreaterThanEqual, Values: []string{"32"}, Required: true}, - }, - }, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TranscodeStream.BitDepth).To(BeZero()) - }) - - It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 320, - TranscodingProfiles: []Profile{ - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "mp3", - Limitations: []Limitation{ - {Name: LimitationAudioSamplerate, Comparison: ComparisonGreaterThanEqual, Values: []string{"96000"}, Required: true}, - }, - }, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeFalse()) - }) - }) - - Context("DSD sample rate conversion", func() { - It("converts DSD sample rate to PCM-equivalent in decision", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) - 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")) - // DSD64 2822400 / 8 = 352800, capped by MP3 max of 48000 - Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) - Expect(decision.TargetSampleRate).To(Equal(48000)) - // MP3 is lossy: no bit depth on the transcoded stream - Expect(decision.TranscodeStream.BitDepth).To(BeZero()) - Expect(decision.TargetBitDepth).To(BeZero()) - }) - - It("converts DSD sample rate for FLAC target without codec limit", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) - ci := &ClientInfo{ - TranscodingProfiles: []Profile{ - {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("flac")) - // DSD64 2822400 / 8 = 352800, FLAC has no hard max - Expect(decision.TranscodeStream.SampleRate).To(Equal(352800)) - Expect(decision.TargetSampleRate).To(Equal(352800)) - // DSD 1-bit → 24-bit PCM - Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) - Expect(decision.TargetBitDepth).To(Equal(24)) - }) - - It("applies codec profile limit to DSD-converted FLAC sample rate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) - ci := &ClientInfo{ - TranscodingProfiles: []Profile{ - {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "flac", - Limitations: []Limitation{ - {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true}, - }, - }, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - // DSD64 2822400 / 8 = 352800, capped by codec profile limit of 48000 - Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) - Expect(decision.TargetSampleRate).To(Equal(48000)) - // DSD 1-bit → 24-bit PCM - Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) - Expect(decision.TargetBitDepth).To(Equal(24)) - }) - - It("applies audioBitdepth limitation to DSD-converted bit depth", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) - ci := &ClientInfo{ - TranscodingProfiles: []Profile{ - {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, - }, - CodecProfiles: []CodecProfile{ - { - Type: CodecProfileTypeAudio, - Name: "flac", - Limitations: []Limitation{ - {Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}, - }, - }, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - // DSD 1-bit → 24-bit PCM, then capped by codec profile limit to 16-bit - Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) - Expect(decision.TargetBitDepth).To(Equal(16)) - }) - }) - - 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: new(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: new(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: new(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: new(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" - mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)} - probe := ffmpeg.AudioProbeResult{ - Codec: "wavpack", BitRate: 1000, SampleRate: 44100, BitDepth: 16, Channels: 2, - } - data, _ := json.Marshal(probe) - mf.ProbeData = string(data) - - ci := &ClientInfo{ - TranscodingProfiles: []Profile{ - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, - }, - MaxTranscodingAudioBitrate: 256, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.SourceStream.IsLossless).To(BeTrue()) - Expect(decision.SourceStream.Codec).To(Equal("wavpack")) - // Lossless source transcoding to MP3 should use MaxTranscodingAudioBitrate - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TranscodeStream.Bitrate).To(Equal(256)) - }) - - It("detects lossy from probe codec name", func() { - mf := &model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2, SampleRate: 48000} - probe := ffmpeg.AudioProbeResult{ - Codec: "vorbis", BitRate: 192, SampleRate: 48000, BitDepth: 0, Channels: 2, - } - data, _ := json.Marshal(probe) - mf.ProbeData = string(data) - - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"ogg"}, AudioCodecs: []string{"vorbis"}, Protocols: []string{ProtocolHTTP}}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.SourceStream.IsLossless).To(BeFalse()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - }) - - Context("Opus fixed sample rate", func() { - It("sets Opus output to 48000Hz regardless of input", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 128, - TranscodingProfiles: []Profile{ - {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("opus")) - // Opus always outputs 48000Hz - Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) - Expect(decision.TargetSampleRate).To(Equal(48000)) - }) - - It("sets Opus output to 48000Hz even for 96kHz input", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) - ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 128, - TranscodingProfiles: []Profile{ - {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) - }) - }) - - Context("Container vs format separation", func() { - It("preserves mp4 container when falling back to aac format", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 256, - TranscodingProfiles: []Profile{ - {Container: "mp4", AudioCodec: "aac", Protocol: ProtocolHTTP}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - // TargetFormat is the internal format used for transcoding ("aac") - Expect(decision.TargetFormat).To(Equal("aac")) - // Container in the response preserves what the client asked ("mp4") - Expect(decision.TranscodeStream.Container).To(Equal("mp4")) - Expect(decision.TranscodeStream.Codec).To(Equal("aac")) - }) - - It("uses container as format when container matches transcoding config", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 256, - 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.Container).To(Equal("mp3")) - }) - }) - - Context("MP3 max sample rate", func() { - It("caps sample rate at 48000 for MP3", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) - 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.TranscodeStream.SampleRate).To(Equal(48000)) - }) - - It("preserves sample rate at 44100 for MP3", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(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.TranscodeStream.SampleRate).To(Equal(44100)) - }) - }) - - Context("AAC max sample rate", func() { - It("caps sample rate at 96000 for AAC", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: new(1)}) - ci := &ClientInfo{ - MaxTranscodingAudioBitrate: 320, - TranscodingProfiles: []Profile{ - {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - // DSD64 2822400 / 8 = 352800, capped by AAC max of 96000 - Expect(decision.TranscodeStream.SampleRate).To(Equal(96000)) - }) - }) - - Context("Typed transcode reasons from multiple profiles", func() { - It("collects reasons from each failed direct play profile", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", Codec: "Vorbis", BitRate: 128, Channels: 2, SampleRate: 48000}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, - {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, - {Containers: []string{"m4a", "mp4"}, AudioCodecs: []string{"aac"}, 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(HaveLen(3)) - 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]")) - }) - }) - - Context("Source stream details", func() { - It("populates source stream correctly with kbps bitrate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24), Duration: 300.5, Size: 50000000}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.SourceStream.Container).To(Equal("flac")) - Expect(decision.SourceStream.Codec).To(Equal("flac")) - Expect(decision.SourceStream.Bitrate).To(Equal(1000)) // kbps - Expect(decision.SourceStream.SampleRate).To(Equal(96000)) - Expect(decision.SourceStream.BitDepth).To(Equal(24)) - Expect(decision.SourceStream.Channels).To(Equal(2)) - }) - }) - - Context("Server-side context is ignored by MakeDecision", func() { - It("ignores transcoding override in context", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, - }, - } - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - - It("ignores player MaxBitRate in context", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ci := &ClientInfo{ - Name: "TestClient", - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, - }, - } - playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320}) - decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - }) - - Context("Format-aware default bitrate", func() { - It("uses opus default bitrate from DB", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: new(16)}) - ci := &ClientInfo{ - TranscodingProfiles: []Profile{ - {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetBitrate).To(Equal(96)) // opus default from mock - }) - - It("uses aac default bitrate from DB", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ci := &ClientInfo{ - TranscodingProfiles: []Profile{ - {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP}, - }, - } - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetBitrate).To(Equal(256)) // aac default from mock - }) - - It("falls back to 256 for unknown format", func() { - bitrate := lookupDefaultBitrate(ctx, ds, "xyz") - Expect(bitrate).To(Equal(fallbackBitrate)) - }) - }) - - Context("Player-forced format", func() { - symfonium := func() *ClientInfo { - return &ClientInfo{ - Name: "Symfonium", - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"mp3", "flac", "ogg"}, Protocols: []string{ProtocolHTTP}}, - }, - TranscodingProfiles: []Profile{ - {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, - }, - } - } - - It("direct plays a flac source forced to flac", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ci := symfonium() - Expect(ci.ForceFormat("flac")).To(BeTrue()) - - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - - It("still transcodes a 24-bit flac when the client caps bit depth", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 4600, Channels: 2, SampleRate: 96000, BitDepth: new(24)}) - ci := symfonium() - ci.CodecProfiles = []CodecProfile{{ - Type: CodecProfileTypeAudio, Name: "flac", - Limitations: []Limitation{{Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}}, - }} - Expect(ci.ForceFormat("flac")).To(BeTrue()) - - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) - }) - - It("still transcodes a 320 mp3 forced to mp3 at a lower bitrate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - ci := symfonium() - Expect(ci.ForceFormat("mp3")).To(BeTrue()) - ci.CapBitrate(192) - - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetBitrate).To(Equal(192)) - }) - - It("direct plays a 128 mp3 forced to mp3 at a higher bitrate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100}) - ci := symfonium() - Expect(ci.ForceFormat("mp3")).To(BeTrue()) - ci.CapBitrate(192) - - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - }) - - It("transcodes a flac source forced to mp3", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1026, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ci := symfonium() - Expect(ci.ForceFormat("mp3")).To(BeTrue()) - - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - }) - }) - }) - - Describe("ensureProbed", func() { - var mockMFRepo *tests.MockMediaFileRepo - - BeforeEach(func() { - mockMFRepo = tests.CreateMockMediaFileRepo() - ds.MockedMediaFile = mockMFRepo - }) - - It("calls ffprobe and populates ProbeData when empty", func() { - mf := &model.MediaFile{ID: "probe-1", Suffix: "mp3", BitRate: 320, Channels: 2} - mockMFRepo.SetData(model.MediaFiles{*mf}) - - ff.ProbeAudioResult = &ffmpeg.AudioProbeResult{ - Codec: "mp3", BitRate: 320, SampleRate: 44100, Channels: 2, - } - - svc := NewTranscodeDecider(ds, ff).(*deciderService) - probe, err := svc.ensureProbed(ctx, mf) - Expect(err).ToNot(HaveOccurred()) - Expect(mf.ProbeData).ToNot(BeEmpty()) - Expect(probe).ToNot(BeNil()) - Expect(probe.Codec).To(Equal("mp3")) - Expect(probe.BitRate).To(Equal(320)) - Expect(probe.SampleRate).To(Equal(44100)) - Expect(probe.Channels).To(Equal(2)) - - // Verify persisted to DB - stored := mockMFRepo.Data["probe-1"] - Expect(stored.ProbeData).To(Equal(mf.ProbeData)) - }) - - It("skips ffprobe when ProbeData is already set", func() { - mf := withProbe(&model.MediaFile{ID: "probe-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) - - // Set error on mock — if ffprobe were called, this would fail - ff.Error = fmt.Errorf("should not be called") - - svc := NewTranscodeDecider(ds, ff).(*deciderService) - probe, err := svc.ensureProbed(ctx, mf) - Expect(err).ToNot(HaveOccurred()) - Expect(probe).To(BeNil()) - }) - - It("returns error when ffprobe fails", func() { - mf := &model.MediaFile{ID: "probe-3", Suffix: "mp3"} - ff.Error = fmt.Errorf("ffprobe not found") - - svc := NewTranscodeDecider(ds, ff).(*deciderService) - _, err := svc.ensureProbed(ctx, mf) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("probing media file")) - Expect(mf.ProbeData).To(BeEmpty()) - }) - - It("skips ffprobe when DevEnableMediaFileProbe is false", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DevEnableMediaFileProbe = false - - mf := &model.MediaFile{ID: "probe-4", Suffix: "mp3"} - // Set a result — if ffprobe were called, ProbeData would be populated - ff.ProbeAudioResult = &ffmpeg.AudioProbeResult{Codec: "mp3"} - - svc := NewTranscodeDecider(ds, ff).(*deciderService) - probe, err := svc.ensureProbed(ctx, mf) - Expect(err).ToNot(HaveOccurred()) - Expect(probe).To(BeNil()) - Expect(mf.ProbeData).To(BeEmpty()) - }) - }) - -}) diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go deleted file mode 100644 index 5b732c3e2..000000000 --- a/core/stream/legacy_client.go +++ /dev/null @@ -1,136 +0,0 @@ -package stream - -import ( - "context" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" -) - -// buildLegacyClientInfo translates legacy Subsonic stream/download parameters -// into a ClientInfo for use with MakeDecision. -func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int, playerMaxBitRate int) *ClientInfo { - ci := &ClientInfo{Name: "legacy"} - - // Determine target format for transcoding - var targetFormat string - switch { - case reqFormat != "": - targetFormat = reqFormat - case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "": - targetFormat = conf.Server.DefaultDownsamplingFormat - case playerMaxBitRate > 0 && playerMaxBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "": - // Server-side player MaxBitRate alone forces downsampling, even when the - // client sent no format/bitrate params (issue #5583, legacy /stream path). - targetFormat = conf.Server.DefaultDownsamplingFormat - } - - if targetFormat != "" { - // Add a direct play profile for the source format when no explicit - // format was requested (bitrate-only downsampling) or when the - // requested format matches the source. When the client explicitly - // requests a different format, direct play must not match the - // source — otherwise the source is returned untranscoded. - if reqFormat == "" || strings.EqualFold(reqFormat, mf.Suffix) { - ci.DirectPlayProfiles = []DirectPlayProfile{ - {Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}}, - } - } - ci.TranscodingProfiles = []Profile{ - {Container: targetFormat, AudioCodec: targetFormat, Protocol: ProtocolHTTP}, - } - if reqBitRate > 0 { - ci.MaxAudioBitrate = reqBitRate - ci.MaxTranscodingAudioBitrate = reqBitRate - } - } else { - // No transcoding requested — direct play everything - ci.DirectPlayProfiles = []DirectPlayProfile{ - {Protocols: []string{ProtocolHTTP}}, - } - } - - return ci -} - -// ResolveRequest uses MakeDecision to resolve legacy Subsonic stream parameters -// into a fully specified Request. -func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request { - var req Request - req.Offset = offset - - if reqFormat == "raw" { - req.Format = "raw" - return req - } - - playerMaxBitRate := 0 - if player, ok := request.PlayerFrom(ctx); ok { - playerMaxBitRate = player.MaxBitRate - } - - clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate, playerMaxBitRate) - if resolved, ok := s.resolve(ctx, mf, clientInfo, offset); ok { - return resolved - } - - // No compatible profile for the requested format — retry with DefaultDownsamplingFormat - // TODO: validate DefaultDownsamplingFormat at startup to warn about unsupported values - fallbackFormat := conf.Server.DefaultDownsamplingFormat - if reqFormat != "" && fallbackFormat != "" && !strings.EqualFold(reqFormat, fallbackFormat) { - log.Warn(ctx, "Requested format not available, falling back to default downsampling format", - "requestedFormat", reqFormat, "fallbackFormat", fallbackFormat, "id", mf.ID) - return s.ResolveRequest(ctx, mf, fallbackFormat, reqBitRate, offset) - } - - // Ultimate fallback — raw - req.Format = "raw" - return req -} - -// ResolveClientRequest resolves a stream request for a client that declared its own direct play -// and transcoding profiles, falling back to raw when none fits. -func (s *deciderService) ResolveClientRequest(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) Request { - if req, ok := s.resolve(ctx, mf, clientInfo, offset); ok { - return req - } - return Request{Format: "raw", Offset: offset} -} - -// resolve applies the server-side player overrides to clientInfo and maps the decision to a -// Request. ok is false when no profile fits. -func (s *deciderService) resolve(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, offset int) (Request, bool) { - req := Request{Offset: offset} - if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { - clientInfo = applyServerOverride(ctx, clientInfo, &trc) - } else if player, ok := request.PlayerFrom(ctx); ok { - modified := *clientInfo - if modified.CapBitrate(player.MaxBitRate) { - clientInfo = &modified - log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) - } - } - - decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true}) - if err != nil { - log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) - req.Format = "raw" - return req, true - } - switch { - case decision.CanDirectPlay: - req.Format = "raw" - case decision.CanTranscode: - req.Format = decision.TargetFormat - req.BitRate = decision.TargetBitrate - req.SampleRate = decision.TargetSampleRate - req.BitDepth = decision.TargetBitDepth - req.Channels = decision.TargetChannels - default: - return req, false - } - return req, true -} diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go deleted file mode 100644 index ad3417faf..000000000 --- a/core/stream/legacy_client_test.go +++ /dev/null @@ -1,485 +0,0 @@ -package stream - -import ( - "context" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("buildLegacyClientInfo", func() { - var mf *model.MediaFile - - BeforeEach(func() { - mf = &model.MediaFile{Suffix: "flac", BitRate: 960} - }) - - It("sets transcoding profile for explicit format without bitrate", func() { - ci := buildLegacyClientInfo(mf, "mp3", 0, 0) - - Expect(ci.Name).To(Equal("legacy")) - Expect(ci.TranscodingProfiles).To(HaveLen(1)) - Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) - Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3")) - Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP)) - Expect(ci.MaxAudioBitrate).To(BeZero()) - Expect(ci.MaxTranscodingAudioBitrate).To(BeZero()) - Expect(ci.DirectPlayProfiles).To(BeEmpty()) - }) - - It("does not add direct play profile when explicit format differs from source (no bitrate)", func() { - ci := buildLegacyClientInfo(mf, "opus", 0, 0) - - Expect(ci.TranscodingProfiles).To(HaveLen(1)) - Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus")) - Expect(ci.DirectPlayProfiles).To(BeEmpty()) - }) - - It("adds direct play profile when explicit format matches source format", func() { - ci := buildLegacyClientInfo(mf, "flac", 0, 0) - - Expect(ci.TranscodingProfiles).To(HaveLen(1)) - Expect(ci.TranscodingProfiles[0].Container).To(Equal("flac")) - Expect(ci.DirectPlayProfiles).To(HaveLen(1)) - Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"})) - Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()})) - }) - - It("sets transcoding profile and bitrate for explicit format with bitrate", func() { - ci := buildLegacyClientInfo(mf, "mp3", 192, 0) - - Expect(ci.TranscodingProfiles).To(HaveLen(1)) - Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) - Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3")) - Expect(ci.MaxAudioBitrate).To(Equal(192)) - Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192)) - Expect(ci.DirectPlayProfiles).To(BeEmpty()) - }) - - It("returns direct play profile when no format and no bitrate", func() { - ci := buildLegacyClientInfo(mf, "", 0, 0) - - Expect(ci.DirectPlayProfiles).To(HaveLen(1)) - Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) - Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty()) - Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP})) - Expect(ci.TranscodingProfiles).To(BeEmpty()) - Expect(ci.MaxAudioBitrate).To(BeZero()) - }) - - It("uses default downsampling format for bitrate-only downsampling", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DefaultDownsamplingFormat = "opus" - - ci := buildLegacyClientInfo(mf, "", 128, 0) - - Expect(ci.TranscodingProfiles).To(HaveLen(1)) - Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus")) - Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) - Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP)) - Expect(ci.MaxAudioBitrate).To(Equal(128)) - Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128)) - Expect(ci.DirectPlayProfiles).To(HaveLen(1)) - Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"})) - Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()})) - }) - - It("returns direct play when bitrate >= source bitrate", func() { - ci := buildLegacyClientInfo(mf, "", 960, 0) - - Expect(ci.DirectPlayProfiles).To(HaveLen(1)) - Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) - Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty()) - Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP})) - Expect(ci.TranscodingProfiles).To(BeEmpty()) - Expect(ci.MaxAudioBitrate).To(BeZero()) - }) - - It("uses default downsampling format when player MaxBitRate is below source and no format/bitrate", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DefaultDownsamplingFormat = "opus" - - ci := buildLegacyClientInfo(mf, "", 0, 256) - - Expect(ci.TranscodingProfiles).To(HaveLen(1)) - Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus")) - Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) - Expect(ci.DirectPlayProfiles).To(HaveLen(1)) - Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"})) - }) - - It("does not downsample when player MaxBitRate is >= source bitrate", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DefaultDownsamplingFormat = "opus" - - ci := buildLegacyClientInfo(mf, "", 0, 960) - - Expect(ci.TranscodingProfiles).To(BeEmpty()) - Expect(ci.DirectPlayProfiles).To(HaveLen(1)) - Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) - }) - - It("does not downsample when DefaultDownsamplingFormat is empty even with player cap", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DefaultDownsamplingFormat = "" - - ci := buildLegacyClientInfo(mf, "", 0, 256) - - Expect(ci.TranscodingProfiles).To(BeEmpty()) - Expect(ci.DirectPlayProfiles).To(HaveLen(1)) - Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty()) - }) - - It("prefers explicit request format over player cap", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DefaultDownsamplingFormat = "opus" - - ci := buildLegacyClientInfo(mf, "mp3", 0, 256) - - Expect(ci.TranscodingProfiles).To(HaveLen(1)) - Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) - }) -}) - -var _ = Describe("ResolveRequest", func() { - var ( - svc TranscodeDecider - ctx context.Context - ) - - BeforeEach(func() { - ctx = GinkgoT().Context() - ds := &tests.MockDataStore{ - MockedProperty: &tests.MockedPropertyRepo{}, - MockedTranscoding: &tests.MockTranscodingRepo{}, - } - ff := tests.NewMockFFmpeg("") - auth.Init(ds) - svc = NewTranscodeDecider(ds, ff) - }) - - It("returns raw when format is 'raw'", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(ctx, mf, "raw", 0, 0) - - Expect(req.Format).To(Equal("raw")) - }) - - It("returns raw (direct play) when no format or bitrate specified", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(ctx, mf, "", 0, 0) - - Expect(req.Format).To(Equal("raw")) - }) - - It("transcodes to requested format", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(ctx, mf, "opus", 0, 0) - - Expect(req.Format).To(Equal("opus")) - }) - - It("transcodes to requested format with bitrate limit", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(ctx, mf, "mp3", 128, 0) - - Expect(req.Format).To(Equal("mp3")) - Expect(req.BitRate).To(Equal(128)) - }) - - It("returns raw when requested format matches source and no bitrate reduction", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(ctx, mf, "mp3", 320, 0) - - Expect(req.Format).To(Equal("raw")) - }) - - It("downsamples when only bitrate is specified below source", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DefaultDownsamplingFormat = "opus" - - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(ctx, mf, "", 128, 0) - - Expect(req.Format).To(Equal("opus")) - Expect(req.BitRate).To(Equal(128)) - }) - - It("passes offset through", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(ctx, mf, "opus", 128, 30) - - Expect(req.Format).To(Equal("opus")) - Expect(req.Offset).To(Equal(30)) - }) - - Context("Server-side player transcoding override", func() { - It("forces transcoding when override targets a different format", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) - - Expect(req.Format).To(Equal("mp3")) - Expect(req.BitRate).To(Equal(192)) - }) - - It("allows direct play when source matches forced format and bitrate is within cap", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100}) - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) - - Expect(req.Format).To(Equal("raw")) - }) - - It("transcodes when source bitrate exceeds the forced cap", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) - - Expect(req.Format).To(Equal("mp3")) - Expect(req.BitRate).To(Equal(192)) - }) - - It("uses player MaxBitRate over transcoding DefaultBitRate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) - - Expect(req.Format).To(Equal("mp3")) - Expect(req.BitRate).To(Equal(320)) - }) - - It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0}) - overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) - - Expect(req.Format).To(Equal("mp3")) - // With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock) - Expect(req.BitRate).To(Equal(160)) - }) - - It("does not apply override when no transcoding is in context", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(ctx, mf, "", 0, 0) - - Expect(req.Format).To(Equal("raw")) - }) - }) - - Context("Player MaxBitRate cap", func() { - It("applies player MaxBitRate cap when client has no limit", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(playerCtx, mf, "mp3", 0, 0) - - Expect(req.Format).To(Equal("mp3")) - Expect(req.BitRate).To(Equal(320)) - }) - - It("uses client limit when it is more restrictive than player MaxBitRate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(playerCtx, mf, "mp3", 256, 0) - - Expect(req.Format).To(Equal("mp3")) - Expect(req.BitRate).To(Equal(256)) - }) - - It("does not cap when player MaxBitRate is 0", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(playerCtx, mf, "", 0, 0) - - Expect(req.Format).To(Equal("raw")) - }) - - It("downsamples using DefaultDownsamplingFormat when only player MaxBitRate is set (no format/bitrate)", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DefaultDownsamplingFormat = "opus" - - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(playerCtx, mf, "", 0, 0) - - Expect(req.Format).To(Equal("opus")) - Expect(req.BitRate).To(Equal(256)) - }) - - It("serves raw when only player MaxBitRate is set but no DefaultDownsamplingFormat", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DefaultDownsamplingFormat = "" - - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 256}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(playerCtx, mf, "", 0, 0) - - Expect(req.Format).To(Equal("raw")) - }) - }) - - Context("fallback for unknown format", func() { - It("falls back to DefaultDownsamplingFormat", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DefaultDownsamplingFormat = "opus" - - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0) - - Expect(req.Format).To(Equal("opus")) - }) - - It("falls back to raw when DefaultDownsamplingFormat is empty", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DefaultDownsamplingFormat = "" - - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0) - - Expect(req.Format).To(Equal("raw")) - }) - - It("falls back to raw when DefaultDownsamplingFormat is also invalid", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DefaultDownsamplingFormat = "xyz" - - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(ctx, mf, "xyz", 0, 0) - - Expect(req.Format).To(Equal("raw")) - }) - - It("preserves bitrate when falling back to DefaultDownsamplingFormat", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DefaultDownsamplingFormat = "opus" - - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - - decider := svc.(*deciderService) - req := decider.ResolveRequest(ctx, mf, "xyz", 128, 0) - - Expect(req.Format).To(Equal("opus")) - Expect(req.BitRate).To(Equal(128)) - }) - }) -}) - -var _ = Describe("ResolveClientRequest", func() { - var ( - svc TranscodeDecider - ctx context.Context - ) - mp3Target := []Profile{{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}} - - BeforeEach(func() { - ctx = GinkgoT().Context() - ds := &tests.MockDataStore{ - MockedProperty: &tests.MockedPropertyRepo{}, - MockedTranscoding: &tests.MockTranscodingRepo{}, - } - auth.Init(ds) - svc = NewTranscodeDecider(ds, tests.NewMockFFmpeg("")) - }) - - It("direct plays a source matching a profile through container aliases", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}}}, - TranscodingProfiles: mp3Target, - } - - req := svc.ResolveClientRequest(ctx, mf, ci, 5) - - Expect(req.Format).To(Equal("raw")) - Expect(req.Offset).To(Equal(5)) - }) - - It("transcodes to the client's transcoding profile when no direct play profile matches", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"mp3"}}}, - TranscodingProfiles: mp3Target, - } - - Expect(svc.ResolveClientRequest(ctx, mf, ci, 0).Format).To(Equal("mp3")) - }) - - It("transcodes a direct-playable source over the bitrate cap to the client's target", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ci := &ClientInfo{ - MaxAudioBitrate: 128, - DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}}, - TranscodingProfiles: mp3Target, - } - - req := svc.ResolveClientRequest(ctx, mf, ci, 0) - - Expect(req.Format).To(Equal("mp3")) - Expect(req.BitRate).To(Equal(128)) - }) - - It("applies the player's MaxBitRate cap", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: new(16)}) - ctx = request.WithPlayer(ctx, model.Player{MaxBitRate: 192}) - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}}, - TranscodingProfiles: mp3Target, - } - - req := svc.ResolveClientRequest(ctx, mf, ci, 0) - - Expect(req.Format).To(Equal("mp3")) - Expect(req.BitRate).To(Equal(192)) - }) -}) diff --git a/core/stream/limitations.go b/core/stream/limitations.go deleted file mode 100644 index ab70d6c07..000000000 --- a/core/stream/limitations.go +++ /dev/null @@ -1,171 +0,0 @@ -package stream - -import ( - "strconv" - "strings" -) - -// adjustResult represents the outcome of applying a limitation to a transcoded stream value -type adjustResult int - -const ( - adjustNone adjustResult = iota // Value already satisfies the limitation - adjustAdjusted // Value was changed to fit the limitation - adjustCannotFit // Cannot satisfy the limitation (reject this profile) -) - -// checkLimitations checks codec profile limitations against source stream details. -// Returns "" if all limitations pass, or a typed reason string for the first failure. -func checkLimitations(src *Details, limitations []Limitation) string { - for _, lim := range limitations { - var ok bool - var reason string - - switch lim.Name { - case LimitationAudioChannels: - ok = checkIntLimitation(src.Channels, lim.Comparison, lim.Values) - reason = "audio channels not supported" - case LimitationAudioSamplerate: - ok = checkIntLimitation(src.SampleRate, lim.Comparison, lim.Values) - reason = "audio samplerate not supported" - case LimitationAudioBitrate: - ok = checkIntLimitation(src.Bitrate, lim.Comparison, lim.Values) - reason = "audio bitrate not supported" - case LimitationAudioBitdepth: - ok = checkIntLimitation(src.BitDepth, lim.Comparison, lim.Values) - reason = "audio bitdepth not supported" - case LimitationAudioProfile: - ok = checkStringLimitation(src.Profile, lim.Comparison, lim.Values) - reason = "audio profile not supported" - default: - continue - } - - if !ok && lim.Required { - return reason - } - } - return "" -} - -// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation. -// Returns the adjustment result. -func applyLimitation(sourceBitrate int, lim *Limitation, ts *Details) adjustResult { - switch lim.Name { - case LimitationAudioChannels: - return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v }) - case LimitationAudioBitrate: - current := ts.Bitrate - if current == 0 { - current = sourceBitrate - } - return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v }) - case LimitationAudioSamplerate: - return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v }) - case LimitationAudioBitdepth: - if ts.BitDepth > 0 { - return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v }) - } - case LimitationAudioProfile: - // TODO: implement when audio profile data is available - } - return adjustNone -} - -// applyIntLimitation applies a limitation comparison to a value. -// If the value needs adjusting, calls the setter and returns the result. -func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult { - if len(values) == 0 { - return adjustNone - } - - switch comparison { - case ComparisonLessThanEqual: - limit, ok := parseInt(values[0]) - if !ok { - return adjustNone - } - if current <= limit { - return adjustNone - } - setter(limit) - return adjustAdjusted - case ComparisonGreaterThanEqual: - limit, ok := parseInt(values[0]) - if !ok { - return adjustNone - } - if current >= limit { - return adjustNone - } - // Cannot upscale - return adjustCannotFit - case ComparisonEquals: - // Check if current value matches any allowed value - for _, v := range values { - if limit, ok := parseInt(v); ok && current == limit { - return adjustNone - } - } - // Find the closest allowed value below current (don't upscale) - var closest int - found := false - for _, v := range values { - if limit, ok := parseInt(v); ok && limit < current { - if !found || limit > closest { - closest = limit - found = true - } - } - } - if found { - setter(closest) - return adjustAdjusted - } - return adjustCannotFit - case ComparisonNotEquals: - for _, v := range values { - if limit, ok := parseInt(v); ok && current == limit { - return adjustCannotFit - } - } - return adjustNone - } - - return adjustNone -} - -func checkIntLimitation(value int, comparison string, values []string) bool { - return applyIntLimitation(comparison, values, value, func(int) {}) == adjustNone -} - -// checkStringLimitation checks a string value against a limitation. -// Only Equals and NotEquals comparisons are meaningful for strings. -// LessThanEqual/GreaterThanEqual are not applicable and always pass. -func checkStringLimitation(value string, comparison string, values []string) bool { - switch comparison { - case ComparisonEquals: - for _, v := range values { - if strings.EqualFold(value, v) { - return true - } - } - return false - case ComparisonNotEquals: - for _, v := range values { - if strings.EqualFold(value, v) { - return false - } - } - return true - } - return true -} - -func parseInt(s string) (int, bool) { - v, err := strconv.Atoi(s) - if err != nil || v < 0 { - return 0, false - } - return v, true -} diff --git a/core/stream/limiter.go b/core/stream/limiter.go deleted file mode 100644 index 622fe21cc..000000000 --- a/core/stream/limiter.go +++ /dev/null @@ -1,135 +0,0 @@ -package stream - -import ( - "context" - "errors" - "io" - "sync" - "sync/atomic" -) - -// ErrTooManyTranscodes is returned by TranscodeLimiter.Acquire when the -// configured concurrency cap has been reached. Callers should translate this -// into an HTTP 429 response so well-behaved clients back off and retry. -var ErrTooManyTranscodes = errors.New("too many concurrent transcodes") - -// RetryAfterSeconds is the value returned in the HTTP Retry-After header when -// a request is rejected with ErrTooManyTranscodes. Most transcodes finish well -// within this window, so retrying after this delay typically succeeds. -const RetryAfterSeconds = 5 - -// TranscodeLimiter gates the number of concurrent ffmpeg transcodes. It enforces -// both a global cap (to protect the host from process exhaustion) and an optional -// per-user cap (to keep one client from starving the others). Acquire never -// blocks: it either reserves a slot or returns ErrTooManyTranscodes immediately. -type TranscodeLimiter interface { - // Acquire reserves a slot for the given user. On success it returns a release - // function that must be called exactly once when the transcode is done. - // Calling release more than once is safe and idempotent. - Acquire(ctx context.Context, user string) (release func(), err error) - - // Enabled reports whether the limiter actually enforces any cap. Callers - // can use it to decide whether to bind ffmpeg's lifetime to the request - // context so disconnects free slots quickly, rather than letting the - // process drain to completion in the background. - Enabled() bool -} - -// NewTranscodeLimiter returns a limiter enforcing the given caps. Each cap is -// independent: a value of zero or less disables that cap. When both caps are -// disabled the limiter is a no-op. -func NewTranscodeLimiter(maxConcurrent, maxPerUser int) TranscodeLimiter { - if maxConcurrent <= 0 && maxPerUser <= 0 { - return noopLimiter{} - } - l := &transcodeLimiter{maxPerUser: maxPerUser} - if maxConcurrent > 0 { - l.global = make(chan struct{}, maxConcurrent) - } - if maxPerUser > 0 { - l.perUser = make(map[string]int) - } - return l -} - -// releasingReadCloser wraps an io.ReadCloser so that closing it also releases -// the limiter slot exactly once. release must be the function returned by -// TranscodeLimiter.Acquire; its own idempotency makes double-Close safe too. -type releasingReadCloser struct { - io.ReadCloser - release func() -} - -func (r *releasingReadCloser) Close() error { - err := r.ReadCloser.Close() - r.release() - return err -} - -type noopLimiter struct{} - -func (noopLimiter) Acquire(context.Context, string) (func(), error) { - return func() {}, nil -} - -func (noopLimiter) Enabled() bool { return false } - -type transcodeLimiter struct { - maxPerUser int - global chan struct{} - - mu sync.Mutex - perUser map[string]int -} - -func (*transcodeLimiter) Enabled() bool { return true } - -func (l *transcodeLimiter) Acquire(_ context.Context, user string) (func(), error) { - // Reserve a per-user slot first so a noisy user can't burn through - // global slots only to be rejected later. An empty user key means - // "anonymous" (e.g. public share viewers); we skip the per-user cap - // entirely so unrelated anonymous clients do not share a bucket. - perUserActive := l.maxPerUser > 0 && user != "" - if perUserActive { - l.mu.Lock() - if l.perUser[user] >= l.maxPerUser { - l.mu.Unlock() - return nil, ErrTooManyTranscodes - } - l.perUser[user]++ - l.mu.Unlock() - } - - if l.global != nil { - select { - case l.global <- struct{}{}: - default: - if perUserActive { - l.releasePerUser(user) - } - return nil, ErrTooManyTranscodes - } - } - - var released atomic.Bool - return func() { - if !released.CompareAndSwap(false, true) { - return - } - if l.global != nil { - <-l.global - } - if perUserActive { - l.releasePerUser(user) - } - }, nil -} - -func (l *transcodeLimiter) releasePerUser(user string) { - l.mu.Lock() - defer l.mu.Unlock() - l.perUser[user]-- - if l.perUser[user] <= 0 { - delete(l.perUser, user) - } -} diff --git a/core/stream/limiter_test.go b/core/stream/limiter_test.go deleted file mode 100644 index d278d47c8..000000000 --- a/core/stream/limiter_test.go +++ /dev/null @@ -1,186 +0,0 @@ -package stream_test - -import ( - "context" - "errors" - "sync" - - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/log" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("TranscodeLimiter", func() { - ctx := log.NewContext(context.TODO()) - - Describe("Disabled (both caps <= 0)", func() { - It("never blocks and never returns ErrTooManyTranscodes", func() { - lim := stream.NewTranscodeLimiter(0, 0) - for range 100 { - rel, err := lim.Acquire(ctx, "alice") - Expect(err).ToNot(HaveOccurred()) - Expect(rel).ToNot(BeNil()) - } - }) - }) - - Describe("Per-user cap only (no global cap)", func() { - It("still enforces the per-user limit when MaxConcurrent is disabled", func() { - lim := stream.NewTranscodeLimiter(0, 2) - - rel1, err := lim.Acquire(ctx, "alice") - Expect(err).ToNot(HaveOccurred()) - rel2, err := lim.Acquire(ctx, "alice") - Expect(err).ToNot(HaveOccurred()) - - _, err = lim.Acquire(ctx, "alice") - Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) - - // Other users have their own buckets. - rel3, err := lim.Acquire(ctx, "bob") - Expect(err).ToNot(HaveOccurred()) - - rel1() - _, err = lim.Acquire(ctx, "alice") - Expect(err).ToNot(HaveOccurred()) - - rel2() - rel3() - }) - }) - - Describe("Global cap", func() { - It("rejects requests beyond MaxConcurrent with ErrTooManyTranscodes", func() { - lim := stream.NewTranscodeLimiter(2, 0) - - rel1, err := lim.Acquire(ctx, "alice") - Expect(err).ToNot(HaveOccurred()) - rel2, err := lim.Acquire(ctx, "bob") - Expect(err).ToNot(HaveOccurred()) - - _, err = lim.Acquire(ctx, "carol") - Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) - - rel1() - _, err = lim.Acquire(ctx, "carol") - Expect(err).ToNot(HaveOccurred()) - - rel2() - }) - - It("releases a slot only once even if release is called multiple times", func() { - lim := stream.NewTranscodeLimiter(1, 0) - - rel, err := lim.Acquire(ctx, "alice") - Expect(err).ToNot(HaveOccurred()) - - rel() - rel() - rel() - - // After releases, exactly one slot should be available. - _, err = lim.Acquire(ctx, "alice") - Expect(err).ToNot(HaveOccurred()) - _, err = lim.Acquire(ctx, "alice") - Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) - }) - }) - - Describe("Per-user cap", func() { - It("rejects a user beyond MaxConcurrentPerUser even if global slots remain", func() { - lim := stream.NewTranscodeLimiter(10, 2) - - rel1, err := lim.Acquire(ctx, "alice") - Expect(err).ToNot(HaveOccurred()) - rel2, err := lim.Acquire(ctx, "alice") - Expect(err).ToNot(HaveOccurred()) - - _, err = lim.Acquire(ctx, "alice") - Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) - - // A different user is unaffected. - rel3, err := lim.Acquire(ctx, "bob") - Expect(err).ToNot(HaveOccurred()) - - rel1() - _, err = lim.Acquire(ctx, "alice") - Expect(err).ToNot(HaveOccurred()) - - rel2() - rel3() - }) - - It("skips the per-user cap for anonymous users (empty key)", func() { - // Anonymous requests (e.g. public share viewers) deliberately - // bypass the per-user cap so unrelated anonymous clients are not - // collapsed into a single shared bucket. The global cap remains - // the only ceiling on anonymous traffic. - lim := stream.NewTranscodeLimiter(10, 1) - - rels := make([]func(), 0, 5) - for range 5 { - rel, err := lim.Acquire(ctx, "") - Expect(err).ToNot(HaveOccurred()) - rels = append(rels, rel) - } - for _, rel := range rels { - rel() - } - }) - - It("still applies the global cap to anonymous users", func() { - lim := stream.NewTranscodeLimiter(2, 1) - - rel1, err := lim.Acquire(ctx, "") - Expect(err).ToNot(HaveOccurred()) - rel2, err := lim.Acquire(ctx, "") - Expect(err).ToNot(HaveOccurred()) - - _, err = lim.Acquire(ctx, "") - Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) - - rel1() - rel2() - }) - }) - - Describe("Concurrent safety", func() { - It("survives parallel Acquire/release with consistent counts", func() { - lim := stream.NewTranscodeLimiter(5, 0) - - var wg sync.WaitGroup - var acquired int64 - var rejected int64 - var mu sync.Mutex - - for i := range 50 { - wg.Add(1) - go func(i int) { - defer wg.Done() - rel, err := lim.Acquire(ctx, "alice") - mu.Lock() - if err == nil { - acquired++ - mu.Unlock() - rel() - } else { - rejected++ - mu.Unlock() - } - _ = i - }(i) - } - wg.Wait() - - Expect(acquired + rejected).To(Equal(int64(50))) - // After all releases, all 5 slots should be free again. - for range 5 { - _, err := lim.Acquire(ctx, "alice") - Expect(err).ToNot(HaveOccurred()) - } - _, err := lim.Acquire(ctx, "alice") - Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) - }) - }) -}) diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go deleted file mode 100644 index c250b0c4e..000000000 --- a/core/stream/media_streamer.go +++ /dev/null @@ -1,305 +0,0 @@ -package stream - -import ( - "context" - "errors" - "fmt" - "io" - "mime" - "net/http" - "os" - "strconv" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/cache" - "github.com/navidrome/navidrome/utils/req" -) - -type MediaStreamer interface { - NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) -} - -type TranscodingCache cache.FileCache - -func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer { - return &mediaStreamer{ - ds: ds, - transcoder: t, - cache: cache, - limiter: NewTranscodeLimiter(conf.Server.Transcoding.MaxConcurrent, conf.Server.Transcoding.MaxConcurrentPerUser), - } -} - -type mediaStreamer struct { - ds model.DataStore - transcoder ffmpeg.FFmpeg - cache cache.FileCache - limiter TranscodeLimiter -} - -type streamJob struct { - ms *mediaStreamer - mf *model.MediaFile - filePath string - format string - bitRate int - sampleRate int - bitDepth int - channels int - offset int -} - -func (j *streamJob) Key() string { - return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset) -} - -// NewStream creates a Stream for the given MediaFile and Request. It handles both raw streaming (no transcoding) -// and transcoded streaming based on the requested format and bitrate. It also logs detailed information about -// the streaming request and whether the transcoding result was served from cache or not. -func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) { - var format string - var bitRate int - var cached bool - defer func() { - log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached, - "bitRate", bitRate, "sampleRate", req.SampleRate, "bitDepth", req.BitDepth, "channels", req.Channels, - "user", userName(ctx), "transcoding", format != "raw", - "originalFormat", mf.Suffix, "originalBitRate", mf.BitRate) - }() - - format = req.Format - bitRate = req.BitRate - if format == "" || format == "raw" { - format = "raw" - bitRate = 0 - } - s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate} - filePath := mf.AbsolutePath() - - if format == "raw" { - log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath, - "requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset, - "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, - "selectedBitrate", bitRate, "selectedFormat", format) - f, err := os.Open(filePath) - if err != nil { - return nil, err - } - s.ReadCloser = f - s.Seeker = f - s.format = mf.Suffix - return s, nil - } - - job := &streamJob{ - ms: ms, - mf: mf, - filePath: filePath, - format: format, - bitRate: bitRate, - sampleRate: req.SampleRate, - bitDepth: req.BitDepth, - channels: req.Channels, - offset: req.Offset, - } - r, err := ms.cache.Get(ctx, job) - if err != nil { - // Rate-limit rejections are already logged at warn level by the - // producer; treating them as cache failures here would both - // double-log and mask actual cache problems. - if !errors.Is(err, ErrTooManyTranscodes) { - log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err) - } - return nil, err - } - cached = r.Cached - - s.ReadCloser = r - s.Seeker = r.Seeker - - log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath, - "requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset, - "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, - "selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable()) - - return s, nil -} - -type Stream struct { - ctx context.Context //nolint:containedctx // stream outlives the call that built it; Read has no ctx - mf *model.MediaFile - bitRate int - format string - io.ReadCloser - io.Seeker -} - -func (s *Stream) Seekable() bool { return s.Seeker != nil } -func (s *Stream) Duration() float32 { return s.mf.Duration } -func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) } -func (s *Stream) Name() string { return s.mf.Title + "." + s.format } -func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt } -func (s *Stream) EstimatedContentLength() int { - return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024) -} - -// Serve writes the stream to the HTTP response. For seekable streams it uses http.ServeContent -// (supporting range requests). For non-seekable streams it writes directly and logs any errors. -// Returns the number of bytes written and an error only when it fails with 0 bytes written -// (meaning the HTTP 200 status has not been flushed yet and the caller can still send an error response). -// Once bytes are on the wire it panics with http.ErrAbortHandler instead, aborting the response. -// Empty output (0 bytes, no error) is logged but not treated as an error. -func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request) (int64, error) { - if s.Seekable() { - http.ServeContent(w, r, s.Name(), s.ModTime(), s) - return -1, nil - } - - w.Header().Set("Accept-Ranges", "none") - w.Header().Set("Content-Type", s.ContentType()) - - if req.Params(r).BoolOr("estimateContentLength", false) { - length := strconv.Itoa(s.EstimatedContentLength()) - log.Trace(ctx, "Estimated content-length", "contentLength", length) - w.Header().Set("Content-Length", length) - } - - if r.Method == http.MethodHead { - go func() { _, _ = io.Copy(io.Discard, s) }() - return 0, nil - } - - id := s.mf.ID - c, err := io.Copy(w, s) - if err != nil { - log.Error(ctx, "Error sending transcoded file", "id", id, err) - if c == 0 { - w.Header().Del("Content-Length") - return 0, fmt.Errorf("sending transcoded file: %w", err) - } - // The 200 is already sent, so dropping the connection is the only way to say "truncated". - panic(http.ErrAbortHandler) - } - if c == 0 { - log.Error(ctx, "Transcoding returned empty output, ffmpeg may have failed. "+ - "Check that ffmpeg supports the requested codec. Enable Trace logging for ffmpeg stderr details", - "id", id, "format", s.ContentType()) - } else { - log.Trace(ctx, "Success sending transcoded file", "id", id, "size", c) - } - return c, nil -} - -// NewStream creates a non-seekable Stream from the given components. -func NewStream(mf *model.MediaFile, format string, bitRate int, r io.ReadCloser) *Stream { - return &Stream{ - ctx: context.Background(), - mf: mf, - format: format, - bitRate: bitRate, - ReadCloser: r, - } -} - -var ( - onceTranscodingCache sync.Once - instanceTranscodingCache TranscodingCache -) - -func GetTranscodingCache() TranscodingCache { - onceTranscodingCache.Do(func() { - instanceTranscodingCache = NewTranscodingCache() - }) - return instanceTranscodingCache -} - -func NewTranscodingCache() TranscodingCache { - return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize, - consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems, - func(ctx context.Context, arg cache.Item) (io.Reader, error) { - job := arg.(*streamJob) - command := LookupTranscodeCommand(ctx, job.ms.ds, job.format) - if command == "" { - log.Error(ctx, "No transcoding command available", "format", job.format) - return nil, os.ErrInvalid - } - - release, err := job.ms.limiter.Acquire(ctx, limiterKey(ctx)) - if err != nil { - log.Warn(ctx, "Refusing transcode: concurrent transcode limit reached", - "id", job.mf.ID, "user", userName(ctx), - "maxConcurrent", conf.Server.Transcoding.MaxConcurrent, - "maxPerUser", conf.Server.Transcoding.MaxConcurrentPerUser) - return nil, err - } - - // Choose the context that drives the ffmpeg process. - // - // When the limiter is enabled, force the request context so a - // client disconnect cancels ffmpeg and frees the slot promptly. - // Otherwise a client could open many transcodes, disconnect - // immediately, and still leave the configured cap's worth of - // ffmpeg processes draining in the background — which is exactly - // the DoS the limiter is meant to prevent. - // - // When the limiter is disabled, preserve the legacy behavior - // governed by Transcoding.EnableCancellation so unchanged configs - // keep their previous observable behavior. - var transcodingCtx context.Context - if job.ms.limiter.Enabled() || conf.Server.Transcoding.EnableCancellation { - transcodingCtx = ctx - } else { - transcodingCtx = request.AddValues(context.Background(), ctx) - } - - out, err := job.ms.transcoder.Transcode(transcodingCtx, ffmpeg.TranscodeOptions{ - Command: command, - Format: job.format, - FilePath: job.filePath, - BitRate: job.bitRate, - SampleRate: job.sampleRate, - BitDepth: job.bitDepth, - Channels: job.channels, - Offset: job.offset, - Duration: job.mf.Duration, - }) - if err != nil { - release() - log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err) - return nil, os.ErrInvalid - } - // Tie the slot to the ffmpeg process: copyAndClose calls Close - // on this reader after io.Copy returns, which is exactly when - // ffmpeg has exited (either EOF or context cancellation). - return &releasingReadCloser{ReadCloser: out, release: release}, nil - }) -} - -// userName extracts the username from the context for logging purposes. -func userName(ctx context.Context) string { - if user, ok := request.UserFrom(ctx); !ok { - return "UNKNOWN" - } else { - return user.UserName - } -} - -// limiterKey returns the per-user bucket key used by the transcode limiter. -// For anonymous requests (e.g. public shares) it returns the empty string, -// which signals the limiter to skip the per-user cap entirely — otherwise -// every anonymous viewer of a public share would collide on the same key -// and starve each other within MaxConcurrentPerUser slots. The global cap -// still applies and remains the protection against runaway anonymous load. -func limiterKey(ctx context.Context) string { - if user, ok := request.UserFrom(ctx); ok { - return user.UserName - } - return "" -} diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go deleted file mode 100644 index 5a4bcd480..000000000 --- a/core/stream/media_streamer_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package stream_test - -import ( - "bytes" - "context" - "errors" - "io" - "net/http" - "net/http/httptest" - "os" - "testing/iotest" - "time" - - "github.com/go-chi/chi/v5/middleware" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/stream" - "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" -) - -var _ = Describe("MediaStreamer", func() { - var streamer stream.MediaStreamer - var ds model.DataStore - ffmpeg := tests.NewMockFFmpeg("fake data") - ctx := log.NewContext(context.TODO()) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - cacheDir, _ := os.MkdirTemp("", "file_caches") - conf.Server.CacheFolder = conf.NewDir(cacheDir) - conf.Server.TranscodingCacheSize = "100MB" - ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0}, - }) - testCache := stream.NewTranscodingCache() - Eventually(func() bool { return testCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) - streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache) - }) - AfterEach(func() { - _ = os.RemoveAll(conf.Server.CacheFolder.String()) - }) - - Context("NewStream", func() { - var mf *model.MediaFile - BeforeEach(func() { - var err error - mf, err = ds.MediaFile().Get(ctx, "123") - Expect(err).ToNot(HaveOccurred()) - }) - It("returns a seekable stream if format is 'raw'", func() { - s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "raw"}) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Seekable()).To(BeTrue()) - }) - It("returns a seekable stream if no format is specified (direct play)", func() { - s, err := streamer.NewStream(ctx, mf, stream.Request{}) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Seekable()).To(BeTrue()) - }) - It("returns a NON seekable stream if transcode is required", func() { - s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 64}) - Expect(err).To(BeNil()) - Expect(s.Seekable()).To(BeFalse()) - Expect(s.Duration()).To(Equal(float32(257.0))) - }) - It("rejects transcode requests beyond MaxConcurrent with ErrTooManyTranscodes", func() { - // Use an ffmpeg whose Read blocks indefinitely so the cache's - // background copy can't drain the source and release the slot — - // keeping the single transcode slot pinned for this test. - pr, pw := io.Pipe() - DeferCleanup(func() { _ = pw.Close() }) - blockingFFmpeg := tests.NewMockFFmpeg("") - blockingFFmpeg.Reader = pr - - conf.Server.Transcoding.MaxConcurrent = 1 - conf.Server.Transcoding.MaxConcurrentPerUser = 0 - tightCache := stream.NewTranscodingCache() - Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) - tightStreamer := stream.NewMediaStreamer(ds, blockingFFmpeg, tightCache) - - userCtx := request.WithUsername(ctx, "alice") - s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) - Expect(err).ToNot(HaveOccurred()) - defer s1.Close() - - // Different cache key so it doesn't dedupe with the first request. - _, err = tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96}) - Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) - }) - - It("releases the slot once the stream is closed", func() { - conf.Server.Transcoding.MaxConcurrent = 1 - conf.Server.Transcoding.MaxConcurrentPerUser = 0 - tightCache := stream.NewTranscodingCache() - Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) - tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) - - userCtx := request.WithUsername(ctx, "alice") - s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) - Expect(err).ToNot(HaveOccurred()) - _, _ = io.ReadAll(s1) - _ = s1.Close() - Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue()) - - // Slot should now be free for a different transcode. - s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96}) - Expect(err).ToNot(HaveOccurred()) - defer s2.Close() - }) - - It("does not consume a slot for raw streams", func() { - conf.Server.Transcoding.MaxConcurrent = 1 - conf.Server.Transcoding.MaxConcurrentPerUser = 0 - tightCache := stream.NewTranscodingCache() - Eventually(func() bool { return tightCache.Available(context.TODO()) }, 10*time.Second).Should(BeTrue()) - tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) - - userCtx := request.WithUsername(ctx, "alice") - // First, saturate the single transcode slot. - s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) - Expect(err).ToNot(HaveOccurred()) - defer s1.Close() - - // Raw stream must still succeed. - s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "raw"}) - Expect(err).ToNot(HaveOccurred()) - defer s2.Close() - }) - - It("returns a seekable stream if the file is complete in the cache", func() { - s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32}) - Expect(err).To(BeNil()) - _, _ = io.ReadAll(s) - _ = s.Close() - Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue()) - - s, err = streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32}) - Expect(err).To(BeNil()) - Expect(s.Seekable()).To(BeTrue()) - }) - }) - - Context("Serve", func() { - var mf *model.MediaFile - BeforeEach(func() { - var err error - mf, err = ds.MediaFile().Get(ctx, "123") - Expect(err).ToNot(HaveOccurred()) - }) - - It("keeps empty output a non-error, so callers still reply 200 with an empty body", func() { - s := stream.NewStream(mf, "mp3", 128, io.NopCloser(bytes.NewReader(nil))) - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/", nil) - - n, err := s.Serve(ctx, w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(BeZero()) - Expect(w.Code).To(Equal(http.StatusOK)) - }) - - It("aborts the response when the source fails after sending data", func() { - src := io.NopCloser(io.MultiReader( - bytes.NewReader(bytes.Repeat([]byte("a"), 64*1024)), - iotest.ErrReader(errors.New("transcoder died")), - )) - server := httptest.NewServer(serveHandler(stream.NewStream(mf, "mp3", 128, src))) - DeferCleanup(server.Close) - - resp, err := http.Get(server.URL) - Expect(err).ToNot(HaveOccurred()) - defer resp.Body.Close() - - // A client-side read failure is the only observable proof the response was aborted. - _, err = io.ReadAll(resp.Body) - Expect(err).To(HaveOccurred()) - }) - }) -}) - -// Serve runs behind the real server's Recoverer, which must let ErrAbortHandler through. -func serveHandler(s *stream.Stream) http.Handler { - return middleware.Recoverer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = s.Serve(r.Context(), w, r) - })) -} diff --git a/core/stream/stream_suite_test.go b/core/stream/stream_suite_test.go deleted file mode 100644 index 36e9e7f43..000000000 --- a/core/stream/stream_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package stream - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestStream(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Stream Suite") -} diff --git a/core/stream/token.go b/core/stream/token.go deleted file mode 100644 index 21a26ca93..000000000 --- a/core/stream/token.go +++ /dev/null @@ -1,141 +0,0 @@ -package stream - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/lestrrat-go/jwx/v3/jwt" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -const tokenTTL = 48 * time.Hour - -// params contains the parameters extracted from a transcode token. -// TargetBitrate is in kilobits per second (kbps). -type params struct { - MediaID string - DirectPlay bool - TargetFormat string - TargetBitrate int - TargetChannels int - TargetSampleRate int - TargetBitDepth int - SourceUpdatedAt time.Time -} - -// toClaimsMap converts a Decision into a JWT claims map for token encoding. -// Only non-zero transcode fields are included. -func (d *TranscodeDecision) toClaimsMap() map[string]any { - m := map[string]any{ - "mid": d.MediaID, - "ua": d.SourceUpdatedAt.Truncate(time.Second).Unix(), - jwt.ExpirationKey: time.Now().Add(tokenTTL).UTC().Unix(), - } - if d.CanDirectPlay { - m["dp"] = true - } - if d.CanTranscode && d.TargetFormat != "" { - m["f"] = d.TargetFormat - if d.TargetBitrate != 0 { - m["b"] = d.TargetBitrate - } - if d.TargetChannels != 0 { - m["ch"] = d.TargetChannels - } - if d.TargetSampleRate != 0 { - m["sr"] = d.TargetSampleRate - } - if d.TargetBitDepth != 0 { - m["bd"] = d.TargetBitDepth - } - } - return m -} - -// paramsFromToken extracts and validates Params from a parsed JWT token. -// Returns an error if required claims (media ID, source timestamp) are missing. -func paramsFromToken(token jwt.Token) (*params, error) { - var p params - var mid string - if err := token.Get("mid", &mid); err == nil { - p.MediaID = mid - } - if p.MediaID == "" { - return nil, fmt.Errorf("%w: missing media ID", ErrTokenInvalid) - } - - var dp bool - if err := token.Get("dp", &dp); err == nil { - p.DirectPlay = dp - } - - ua := getIntClaim(token, "ua") - if ua != 0 { - p.SourceUpdatedAt = time.Unix(int64(ua), 0) - } - if p.SourceUpdatedAt.IsZero() { - return nil, fmt.Errorf("%w: missing source timestamp", ErrTokenInvalid) - } - - var f string - if err := token.Get("f", &f); err == nil { - p.TargetFormat = f - } - p.TargetBitrate = getIntClaim(token, "b") - p.TargetChannels = getIntClaim(token, "ch") - p.TargetSampleRate = getIntClaim(token, "sr") - p.TargetBitDepth = getIntClaim(token, "bd") - return &p, nil -} - -// getIntClaim extracts a numeric claim from a JWT token. Numeric claims in a -// parsed token are always deserialized as float64, regardless of the type used -// when encoding. -func getIntClaim(token jwt.Token, key string) int { - var f float64 - if err := token.Get(key, &f); err == nil { - return int(f) - } - return 0 -} - -func (s *deciderService) CreateTranscodeParams(decision *TranscodeDecision) (string, error) { - return auth.EncodeToken(decision.toClaimsMap()) -} - -func (s *deciderService) parseTranscodeParams(tokenStr string) (*params, error) { - token, err := auth.DecodeAndVerifyToken(tokenStr) - if err != nil { - return nil, err - } - return paramsFromToken(token) -} - -func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) { - p, err := s.parseTranscodeParams(token) - if err != nil { - return Request{}, errors.Join(ErrTokenInvalid, err) - } - if p.MediaID != mf.ID { - return Request{}, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mf.ID) - } - if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) { - log.Info(ctx, "Transcode token is stale", "mediaID", mf.ID, - "tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt) - return Request{}, ErrTokenStale - } - - req := Request{Offset: offset} - if !p.DirectPlay && p.TargetFormat != "" { - req.Format = p.TargetFormat - req.BitRate = p.TargetBitrate - req.SampleRate = p.TargetSampleRate - req.BitDepth = p.TargetBitDepth - req.Channels = p.TargetChannels - } - return req, nil -} diff --git a/core/stream/token_test.go b/core/stream/token_test.go deleted file mode 100644 index 4f0d8066c..000000000 --- a/core/stream/token_test.go +++ /dev/null @@ -1,266 +0,0 @@ -package stream - -import ( - "context" - "time" - - "github.com/go-chi/jwtauth/v5" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Token", func() { - var ( - ds *tests.MockDataStore - ff *tests.MockFFmpeg - svc TranscodeDecider - ctx context.Context - ) - - BeforeEach(func() { - ctx = GinkgoT().Context() - ds = &tests.MockDataStore{ - MockedProperty: &tests.MockedPropertyRepo{}, - MockedTranscoding: &tests.MockTranscodingRepo{}, - } - ff = tests.NewMockFFmpeg("") - auth.Init(ds) - svc = NewTranscodeDecider(ds, ff) - }) - - Describe("Token round-trip", func() { - var ( - sourceTime time.Time - impl *deciderService - ) - - BeforeEach(func() { - sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) - impl = svc.(*deciderService) - }) - - It("creates and parses a direct play token", func() { - decision := &TranscodeDecision{ - MediaID: "media-123", - CanDirectPlay: true, - SourceUpdatedAt: sourceTime, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - Expect(token).ToNot(BeEmpty()) - - params, err := impl.parseTranscodeParams(token) - Expect(err).ToNot(HaveOccurred()) - Expect(params.MediaID).To(Equal("media-123")) - Expect(params.DirectPlay).To(BeTrue()) - Expect(params.TargetFormat).To(BeEmpty()) - Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix())) - }) - - It("creates and parses a transcode token with kbps bitrate", func() { - decision := &TranscodeDecision{ - MediaID: "media-456", - CanDirectPlay: false, - CanTranscode: true, - TargetFormat: "mp3", - TargetBitrate: 256, // kbps - TargetChannels: 2, - SourceUpdatedAt: sourceTime, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - - params, err := impl.parseTranscodeParams(token) - Expect(err).ToNot(HaveOccurred()) - Expect(params.MediaID).To(Equal("media-456")) - Expect(params.DirectPlay).To(BeFalse()) - Expect(params.TargetFormat).To(Equal("mp3")) - Expect(params.TargetBitrate).To(Equal(256)) // kbps - Expect(params.TargetChannels).To(Equal(2)) - Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix())) - }) - - It("creates and parses a transcode token with sample rate", func() { - decision := &TranscodeDecision{ - MediaID: "media-789", - CanDirectPlay: false, - CanTranscode: true, - TargetFormat: "flac", - TargetBitrate: 0, - TargetChannels: 2, - TargetSampleRate: 48000, - SourceUpdatedAt: sourceTime, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - - params, err := impl.parseTranscodeParams(token) - Expect(err).ToNot(HaveOccurred()) - Expect(params.MediaID).To(Equal("media-789")) - Expect(params.DirectPlay).To(BeFalse()) - Expect(params.TargetFormat).To(Equal("flac")) - Expect(params.TargetSampleRate).To(Equal(48000)) - Expect(params.TargetChannels).To(Equal(2)) - }) - - It("creates and parses a transcode token with bit depth", func() { - decision := &TranscodeDecision{ - MediaID: "media-bd", - CanDirectPlay: false, - CanTranscode: true, - TargetFormat: "flac", - TargetBitrate: 0, - TargetChannels: 2, - TargetBitDepth: 24, - SourceUpdatedAt: sourceTime, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - - params, err := impl.parseTranscodeParams(token) - Expect(err).ToNot(HaveOccurred()) - Expect(params.MediaID).To(Equal("media-bd")) - Expect(params.TargetBitDepth).To(Equal(24)) - }) - - It("omits bit depth from token when 0", func() { - decision := &TranscodeDecision{ - MediaID: "media-nobd", - CanDirectPlay: false, - CanTranscode: true, - TargetFormat: "mp3", - TargetBitrate: 256, - TargetBitDepth: 0, - SourceUpdatedAt: sourceTime, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - - params, err := impl.parseTranscodeParams(token) - Expect(err).ToNot(HaveOccurred()) - Expect(params.TargetBitDepth).To(Equal(0)) - }) - - It("omits sample rate from token when 0", func() { - decision := &TranscodeDecision{ - MediaID: "media-100", - CanDirectPlay: false, - CanTranscode: true, - TargetFormat: "mp3", - TargetBitrate: 256, - TargetSampleRate: 0, - SourceUpdatedAt: sourceTime, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - - params, err := impl.parseTranscodeParams(token) - Expect(err).ToNot(HaveOccurred()) - Expect(params.TargetSampleRate).To(Equal(0)) - }) - - It("truncates SourceUpdatedAt to seconds", func() { - timeWithNanos := time.Date(2025, 6, 15, 10, 30, 0, 123456789, time.UTC) - decision := &TranscodeDecision{ - MediaID: "media-trunc", - CanDirectPlay: true, - SourceUpdatedAt: timeWithNanos, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - - params, err := impl.parseTranscodeParams(token) - Expect(err).ToNot(HaveOccurred()) - Expect(params.SourceUpdatedAt.Unix()).To(Equal(timeWithNanos.Truncate(time.Second).Unix())) - }) - - It("rejects an invalid token", func() { - _, err := impl.parseTranscodeParams("invalid-token") - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("ResolveRequestFromToken", func() { - var sourceTime time.Time - - BeforeEach(func() { - sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) - }) - - createTokenForMedia := func(mediaID string, updatedAt time.Time) string { - decision := &TranscodeDecision{ - MediaID: mediaID, - CanDirectPlay: true, - SourceUpdatedAt: updatedAt, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - return token - } - - It("returns stream request for valid token", func() { - mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime} - token := createTokenForMedia("song-1", sourceTime) - - req, err := svc.ResolveRequestFromToken(ctx, token, mf, 0) - Expect(err).ToNot(HaveOccurred()) - Expect(req.Format).To(BeEmpty()) // direct play has no target format - }) - - It("returns ErrTokenInvalid for invalid token", func() { - mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime} - _, err := svc.ResolveRequestFromToken(ctx, "bad-token", mf, 0) - Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error()))) - }) - - It("returns ErrTokenInvalid when mediaID does not match token", func() { - mf := &model.MediaFile{ID: "song-2", UpdatedAt: sourceTime} - token := createTokenForMedia("song-1", sourceTime) - - _, err := svc.ResolveRequestFromToken(ctx, token, mf, 0) - Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error()))) - }) - - It("returns ErrTokenStale when media file has changed", func() { - newTime := sourceTime.Add(1 * time.Hour) - mf := &model.MediaFile{ID: "song-1", UpdatedAt: newTime} - token := createTokenForMedia("song-1", sourceTime) - - _, err := svc.ResolveRequestFromToken(ctx, token, mf, 0) - Expect(err).To(MatchError(ErrTokenStale)) - }) - - It("rejects a Jellyfin access token", func() { - mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime} - usr := &model.User{ID: "u1", UserName: "johndoe"} - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - - _, err = svc.ResolveRequestFromToken(ctx, tokenStr, mf, 0) - Expect(err).To(MatchError(ErrTokenInvalid)) - }) - }) - - Describe("paramsFromToken", func() { - It("returns error when media ID is missing", func() { - tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) - token, _, err := tokenAuth.Encode(map[string]any{"ua": int64(1700000000)}) - Expect(err).NotTo(HaveOccurred()) - - _, err = paramsFromToken(token) - Expect(err).To(MatchError(ContainSubstring("missing media ID"))) - }) - - It("returns error when source timestamp is missing", func() { - tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) - token, _, err := tokenAuth.Encode(map[string]any{"mid": "song-5"}) - Expect(err).NotTo(HaveOccurred()) - - _, err = paramsFromToken(token) - Expect(err).To(MatchError(ContainSubstring("missing source timestamp"))) - }) - }) -}) diff --git a/core/stream/types.go b/core/stream/types.go deleted file mode 100644 index 300017c01..000000000 --- a/core/stream/types.go +++ /dev/null @@ -1,199 +0,0 @@ -package stream - -import ( - "errors" - "strings" - "time" -) - -var ( - ErrTokenInvalid = errors.New("invalid or expired transcode token") - ErrTokenStale = errors.New("transcode token is stale: media file has changed") -) - -// TranscodeOptions controls optional behavior of MakeTranscodeDecision. -type TranscodeOptions struct { - // SkipProbe prevents MakeTranscodeDecision from running ffprobe on the media file. - // When true, source stream details are derived from tag metadata only. - SkipProbe bool -} - -// Request contains the resolved parameters for creating a media stream. -type Request struct { - Format string - BitRate int // kbps - SampleRate int - BitDepth int - Channels int - Offset int // seconds -} - -// ClientInfo represents client playback capabilities. -// All bitrate values are in kilobits per second (kbps) -type ClientInfo struct { - Name string - Platform string - MaxAudioBitrate int - MaxTranscodingAudioBitrate int - DirectPlayProfiles []DirectPlayProfile - TranscodingProfiles []Profile - CodecProfiles []CodecProfile -} - -// CapBitrate lowers the client's declared audio bitrate limits to maxKbps, -// never raising them. A zero limit means "unlimited" and is set to maxKbps. -// Returns true if anything changed. No-op when maxKbps <= 0. -func (ci *ClientInfo) CapBitrate(maxKbps int) bool { - if maxKbps <= 0 { - return false - } - changed := false - if ci.MaxAudioBitrate == 0 || maxKbps < ci.MaxAudioBitrate { - ci.MaxAudioBitrate = maxKbps - changed = true - } - if ci.MaxTranscodingAudioBitrate == 0 || maxKbps < ci.MaxTranscodingAudioBitrate { - ci.MaxTranscodingAudioBitrate = maxKbps - changed = true - } - return changed -} - -// ForceFormat narrows the client to transcoding to targetFormat, but only if the -// client already declares a profile for it. All matching profiles are kept so -// negotiation can still pick among them (e.g. by protocol). Direct play is rebuilt -// from those profiles rather than dropped, since declaring a transcoding profile -// for a format is proof the client can play it. Returns false when unsupported. -func (ci *ClientInfo) ForceFormat(targetFormat string) bool { - if targetFormat == "" { - return false - } - var matched []Profile - var directPlay []DirectPlayProfile - for i := range ci.TranscodingProfiles { - p := &ci.TranscodingProfiles[i] - // matchesContainer is alias-aware, so a forced "oga" (legacy Opus - // target_format) still matches a resolved "opus" profile. - container, format := resolveTargetFormat(p) - if !matchesContainer(format, []string{targetFormat}) { - continue - } - matched = append(matched, *p) - directPlay = append(directPlay, DirectPlayProfile{ - Containers: []string{container}, - AudioCodecs: []string{format}, - Protocols: []string{ProtocolHTTP}, - MaxAudioChannels: p.MaxAudioChannels, - }) - } - if len(matched) == 0 { - return false - } - ci.TranscodingProfiles = matched - ci.DirectPlayProfiles = directPlay - return true -} - -// DirectPlayProfile describes a format the client can play directly -type DirectPlayProfile struct { - Containers []string - AudioCodecs []string - Protocols []string - 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 - AudioCodec string - Protocol string - MaxAudioChannels int -} - -// CodecProfile describes codec-specific limitations -type CodecProfile struct { - Type string - Name string - Limitations []Limitation -} - -// Limitation describes a specific codec limitation -type Limitation struct { - Name string - Comparison string - Values []string - Required bool -} - -// Protocol values (OpenSubsonic spec enum) -const ( - ProtocolHTTP = "http" - ProtocolHLS = "hls" -) - -// Comparison operators (OpenSubsonic spec enum) -const ( - ComparisonEquals = "Equals" - ComparisonNotEquals = "NotEquals" - ComparisonLessThanEqual = "LessThanEqual" - ComparisonGreaterThanEqual = "GreaterThanEqual" -) - -// Limitation names (OpenSubsonic spec enum) -const ( - LimitationAudioChannels = "audioChannels" - LimitationAudioBitrate = "audioBitrate" - LimitationAudioProfile = "audioProfile" - LimitationAudioSamplerate = "audioSamplerate" - LimitationAudioBitdepth = "audioBitdepth" -) - -// Codec profile types (OpenSubsonic spec enum) -const ( - CodecProfileTypeAudio = "AudioCodec" -) - -// TranscodeDecision represents the internal decision result. -// All bitrate values are in kilobits per second (kbps). -type TranscodeDecision struct { - MediaID string - CanDirectPlay bool - CanTranscode bool - TranscodeReasons []string - ErrorReason string - TargetFormat string - TargetBitrate int - TargetChannels int - TargetSampleRate int - TargetBitDepth int - SourceStream Details - SourceUpdatedAt time.Time - TranscodeStream *Details -} - -// Details describes audio stream properties. -// Bitrate is in kilobits per second (kbps). -type Details struct { - Container string - Codec string - Profile string // Audio profile (e.g., "LC", "HE-AACv2"). Populated from ffprobe data. - Bitrate int - SampleRate int - BitDepth int - Channels int - Duration float32 - Size int64 - IsLossless bool -} diff --git a/core/stream/types_test.go b/core/stream/types_test.go deleted file mode 100644 index 88ad904a7..000000000 --- a/core/stream/types_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package stream - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("ClientInfo", func() { - Describe("CapBitrate", func() { - It("is a no-op when maxKbps is zero", func() { - ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 320} - Expect(ci.CapBitrate(0)).To(BeFalse()) - Expect(ci.MaxAudioBitrate).To(Equal(320)) - Expect(ci.MaxTranscodingAudioBitrate).To(Equal(320)) - }) - - It("is a no-op when maxKbps is negative", func() { - ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 320} - Expect(ci.CapBitrate(-1)).To(BeFalse()) - Expect(ci.MaxAudioBitrate).To(Equal(320)) - Expect(ci.MaxTranscodingAudioBitrate).To(Equal(320)) - }) - - It("sets both limits when both are zero (unlimited)", func() { - ci := &ClientInfo{} - Expect(ci.CapBitrate(256)).To(BeTrue()) - Expect(ci.MaxAudioBitrate).To(Equal(256)) - Expect(ci.MaxTranscodingAudioBitrate).To(Equal(256)) - }) - - It("lowers limits higher than maxKbps", func() { - ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 500} - Expect(ci.CapBitrate(192)).To(BeTrue()) - Expect(ci.MaxAudioBitrate).To(Equal(192)) - Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192)) - }) - - It("does not raise limits lower than maxKbps", func() { - ci := &ClientInfo{MaxAudioBitrate: 128, MaxTranscodingAudioBitrate: 96} - Expect(ci.CapBitrate(320)).To(BeFalse()) - Expect(ci.MaxAudioBitrate).To(Equal(128)) - Expect(ci.MaxTranscodingAudioBitrate).To(Equal(96)) - }) - - It("reports changed when only one limit is lowered", func() { - ci := &ClientInfo{MaxAudioBitrate: 320, MaxTranscodingAudioBitrate: 128} - Expect(ci.CapBitrate(192)).To(BeTrue()) - Expect(ci.MaxAudioBitrate).To(Equal(192)) - Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128)) - }) - - It("caps only the zero (unlimited) limit", func() { - ci := &ClientInfo{MaxAudioBitrate: 128, MaxTranscodingAudioBitrate: 0} - Expect(ci.CapBitrate(192)).To(BeTrue()) - Expect(ci.MaxAudioBitrate).To(Equal(128)) - Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192)) - }) - }) - - Describe("ForceFormat", func() { - It("restricts direct play to the forced format when supported", func() { - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}}, - TranscodingProfiles: []Profile{ - {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, - {Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP}, - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, - }, - } - ok := ci.ForceFormat("opus") - Expect(ok).To(BeTrue()) - Expect(ci.TranscodingProfiles).To(HaveLen(1)) - Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) - Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{ - Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP}, - })) - }) - - It("keeps direct play for a source already in the forced format", func() { - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}}, - TranscodingProfiles: []Profile{ - {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, - }, - } - ok := ci.ForceFormat("flac") - Expect(ok).To(BeTrue()) - Expect(ci.DirectPlayProfiles).To(ConsistOf(DirectPlayProfile{ - Containers: []string{"flac"}, AudioCodecs: []string{"flac"}, Protocols: []string{ProtocolHTTP}, - })) - }) - - It("carries the channel limit of the forced profile into direct play", func() { - ci := &ClientInfo{ - TranscodingProfiles: []Profile{ - {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP, MaxAudioChannels: 2}, - }, - } - Expect(ci.ForceFormat("flac")).To(BeTrue()) - Expect(ci.DirectPlayProfiles).To(HaveLen(1)) - Expect(ci.DirectPlayProfiles[0].MaxAudioChannels).To(Equal(2)) - }) - - It("matches a container-only forced format (mp3)", func() { - ci := &ClientInfo{ - TranscodingProfiles: []Profile{ - {Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP}, - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, - }, - } - ok := ci.ForceFormat("mp3") - Expect(ok).To(BeTrue()) - Expect(ci.TranscodingProfiles).To(HaveLen(1)) - Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) - }) - - It("matches the forced format against codec aliases (oga/opus)", func() { - ci := &ClientInfo{ - TranscodingProfiles: []Profile{ - {Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP}, - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, - }, - } - // Legacy DBs may store the Opus transcoding as target_format "oga". - ok := ci.ForceFormat("oga") - Expect(ok).To(BeTrue()) - Expect(ci.TranscodingProfiles).To(HaveLen(1)) - Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) - }) - - It("is a no-op when the forced format is not supported by the client", func() { - original := []Profile{{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}} - ci := &ClientInfo{ - DirectPlayProfiles: []DirectPlayProfile{{Containers: []string{"flac"}}}, - TranscodingProfiles: original, - } - ok := ci.ForceFormat("opus") - Expect(ok).To(BeFalse()) - Expect(ci.TranscodingProfiles).To(Equal(original)) - Expect(ci.DirectPlayProfiles).To(HaveLen(1)) - }) - - It("is a no-op for an empty target format", func() { - ci := &ClientInfo{TranscodingProfiles: []Profile{{Container: "mp3", AudioCodec: "mp3"}}} - Expect(ci.ForceFormat("")).To(BeFalse()) - }) - - It("keeps all matching profiles when multiple resolve to the forced format", func() { - first := Profile{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP} - second := Profile{Container: "ogg", AudioCodec: "opus", Protocol: ProtocolHTTP, MaxAudioChannels: 2} - other := Profile{Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP} - ci := &ClientInfo{TranscodingProfiles: []Profile{first, other, second}} - - ok := ci.ForceFormat("opus") - - Expect(ok).To(BeTrue()) - Expect(ci.TranscodingProfiles).To(ConsistOf(first, second)) - }) - }) -}) diff --git a/core/user.go b/core/user.go deleted file mode 100644 index d67d69dcb..000000000 --- a/core/user.go +++ /dev/null @@ -1,54 +0,0 @@ -package core - -import ( - "context" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/model" -) - -// PluginUnloader defines the interface for unloading disabled plugins. -// This is satisfied by plugins.Manager but defined here to avoid import cycles. -type PluginUnloader interface { - UnloadDisabledPlugins(ctx context.Context) -} - -// User provides business logic for user management with plugin coordination. -type User interface { - Repository() rest.Repository[model.User] -} - -type userService struct { - repo *userRepositoryWrapper -} - -// NewUser creates a new User service -func NewUser(ds model.DataStore, pluginManager PluginUnloader) User { - return &userService{ - repo: &userRepositoryWrapper{ - UserRepository: ds.User(), - pluginManager: pluginManager, - }, - } -} - -// Repository returns a REST repository wrapper for user operations. -// The wrapper intercepts Delete operations to coordinate plugin unloading. -func (s *userService) Repository() rest.Repository[model.User] { - return s.repo -} - -type userRepositoryWrapper struct { - model.UserRepository - pluginManager PluginUnloader -} - -var _ rest.Persistable[model.User] = (*userRepositoryWrapper)(nil) - -// Delete unloads plugins even on error: a bulk delete can fail after earlier users were removed -// and their plugins auto-disabled. -func (r *userRepositoryWrapper) Delete(ctx context.Context, ids ...string) error { - err := r.UserRepository.Delete(ctx, ids...) - r.pluginManager.UnloadDisabledPlugins(ctx) - return err -} diff --git a/core/user_test.go b/core/user_test.go deleted file mode 100644 index 880637770..000000000 --- a/core/user_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package core_test - -import ( - "context" - "errors" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("User Service", func() { - var service core.User - var ds *tests.MockDataStore - var userRepo *tests.MockedUserRepo - var pluginManager *mockPluginUnloader - var ctx context.Context - - BeforeEach(func() { - ds = &tests.MockDataStore{} - userRepo = tests.CreateMockUserRepo() - ds.MockedUser = userRepo - pluginManager = &mockPluginUnloader{} - service = core.NewUser(ds, pluginManager) - ctx = GinkgoT().Context() - }) - - Describe("NewRepository", func() { - It("returns a rest.Persistable[model.User]", func() { - repo := service.Repository() - _, ok := repo.(rest.Persistable[model.User]) - Expect(ok).To(BeTrue()) - }) - }) - - Describe("Delete", func() { - var repo rest.Persistable[model.User] - - BeforeEach(func() { - r := service.Repository() - repo = r.(rest.Persistable[model.User]) - - // Add a test user - user := &model.User{ - ID: "user-123", - UserName: "testuser", - IsAdmin: false, - } - user.NewPassword = "password" - Expect(userRepo.Put(ctx, user)).To(Succeed()) - }) - - It("deletes the user successfully", func() { - err := repo.Delete(ctx, "user-123") - Expect(err).NotTo(HaveOccurred()) - - // Verify user is deleted - _, err = userRepo.Get(ctx, "user-123") - Expect(err).To(Equal(model.ErrNotFound)) - }) - - It("calls UnloadDisabledPlugins after successful deletion", func() { - err := repo.Delete(ctx, "user-123") - Expect(err).NotTo(HaveOccurred()) - Expect(pluginManager.unloadCalls).To(Equal(1)) - }) - - It("still calls UnloadDisabledPlugins when deletion fails", func() { - err := repo.Delete(ctx, "non-existent") - Expect(err).To(MatchError(model.ErrNotFound)) - Expect(pluginManager.unloadCalls).To(Equal(1)) - }) - - It("unloads plugins when a bulk delete fails after removing earlier users", func() { - err := repo.Delete(ctx, "user-123", "non-existent") - Expect(err).To(MatchError(model.ErrNotFound)) - - _, err = userRepo.Get(ctx, "user-123") - Expect(err).To(Equal(model.ErrNotFound)) - Expect(pluginManager.unloadCalls).To(Equal(1)) - }) - - It("returns error when repository fails", func() { - userRepo.Error = errors.New("database error") - err := repo.Delete(ctx, "user-123") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("database error")) - Expect(pluginManager.unloadCalls).To(Equal(1)) - }) - }) -}) diff --git a/core/wire_providers.go b/core/wire_providers.go index b3df9b2dc..d7ee2b694 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -3,37 +3,19 @@ package core import ( "github.com/google/wire" "github.com/navidrome/navidrome/core/agents" - "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/quickconnect" "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/core/stream" ) var Set = wire.NewSet( - stream.NewMediaStreamer, - stream.GetTranscodingCache, + NewMediaStreamer, + GetTranscodingCache, NewArchiver, + NewExternalMetadata, NewPlayers, NewShare, - playlists.NewPlaylists, - NewLibrary, - NewUser, - NewMaintenance, - stream.NewTranscodeDecider, - agents.GetAgents, - external.NewProvider, - matcher.New, - wire.Bind(new(external.Agents), new(*agents.Agents)), + NewPlaylists, + agents.New, ffmpeg.New, scrobbler.GetPlayTracker, - playback.GetInstance, - quickconnect.GetInstance, - metrics.GetInstance, - lyrics.NewLyrics, ) diff --git a/db/backup.go b/db/backup.go deleted file mode 100644 index 74cae553e..000000000 --- a/db/backup.go +++ /dev/null @@ -1,184 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "slices" - "strings" - "time" - - "github.com/mattn/go-sqlite3" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" -) - -const ( - backupPrefix = "navidrome_backup" - backupRegexString = "^" + backupPrefix + "_(.+)\\.db$" -) - -var backupRegex = regexp.MustCompile(backupRegexString) - -const backupSuffixLayout = "2006.01.02_15.04.05" - -func backupPath(t time.Time) string { - return filepath.Join( - conf.Server.Backup.Path.MustPath(), - fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)), - ) -} - -func backupOrRestore(ctx context.Context, isBackup bool, path string) error { - // heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/ - existingConn, err := Db().Conn(ctx) - if err != nil { - return fmt.Errorf("getting existing connection: %w", err) - } - defer existingConn.Close() - - // The driver opens with SQLITE_OPEN_CREATE, so without this check a typo in the - // path would create an empty database and "restore" it over the live one. - if !isBackup { - // The driver splits the DSN at '?', so such a path would open a different file. - if strings.ContainsRune(path, '?') { - return fmt.Errorf("backup path cannot contain '?': %s", path) - } - if _, err := os.Stat(path); err != nil { - return fmt.Errorf("backup file not available: %w", err) - } - } - - backupDb, err := sql.Open(Driver, path) - if err != nil { - return fmt.Errorf("opening backup database in '%s': %w", path, err) - } - defer backupDb.Close() - - backupConn, err := backupDb.Conn(ctx) - if err != nil { - return fmt.Errorf("getting backup connection: %w", err) - } - defer backupConn.Close() - - err = existingConn.Raw(func(existing any) error { - return backupConn.Raw(func(backup any) error { - var sourceOk, destOk bool - var sourceConn, destConn *sqlite3.SQLiteConn - - if isBackup { - sourceConn, sourceOk = existing.(*sqlite3.SQLiteConn) - destConn, destOk = backup.(*sqlite3.SQLiteConn) - } else { - sourceConn, sourceOk = backup.(*sqlite3.SQLiteConn) - destConn, destOk = existing.(*sqlite3.SQLiteConn) - } - - if !sourceOk { - return fmt.Errorf("error trying to convert source to sqlite connection") - } - if !destOk { - return fmt.Errorf("error trying to convert destination to sqlite connection") - } - - backupOp, err := destConn.Backup("main", sourceConn, "main") - if err != nil { - return fmt.Errorf("error starting sqlite backup: %w", err) - } - defer backupOp.Close() - - // 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 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 { - return fmt.Errorf("error finishing backup: %w", err) - } - - return nil - }) - }) - - return err -} - -func Backup(ctx context.Context) (string, error) { - destPath := backupPath(time.Now()) - log.Debug(ctx, "Creating backup", "path", destPath) - err := backupOrRestore(ctx, true, destPath) - if err != nil { - return "", err - } - - return destPath, nil -} - -func Restore(ctx context.Context, path string) error { - log.Debug(ctx, "Restoring backup", "path", path) - return backupOrRestore(ctx, false, path) -} - -func Prune(ctx context.Context) (int, error) { - backupDir, err := conf.Server.Backup.Path.Path() - if err != nil { - return 0, fmt.Errorf("backup directory not available: %w", err) - } - files, err := os.ReadDir(backupDir) - if err != nil { - return 0, fmt.Errorf("unable to read database backup entries: %w", err) - } - - var backupTimes []time.Time - - for _, file := range files { - if !file.IsDir() { - submatch := backupRegex.FindStringSubmatch(file.Name()) - if len(submatch) == 2 { - timestamp, err := time.Parse(backupSuffixLayout, submatch[1]) - if err == nil { - backupTimes = append(backupTimes, timestamp) - } - } - } - } - - if len(backupTimes) <= conf.Server.Backup.Count { - return 0, nil - } - - slices.SortFunc(backupTimes, func(a, b time.Time) int { - return b.Compare(a) - }) - - pruneCount := 0 - var errs []error - - for _, timeToPrune := range backupTimes[conf.Server.Backup.Count:] { - log.Debug(ctx, "Pruning backup", "time", timeToPrune) - path := backupPath(timeToPrune) - err = os.Remove(path) - if err != nil { - errs = append(errs, err) - } else { - pruneCount++ - } - } - - if len(errs) > 0 { - err = errors.Join(errs...) - log.Error(ctx, "Failed to delete one or more files", "errors", err) - } - - return pruneCount, err -} diff --git a/db/backup_test.go b/db/backup_test.go deleted file mode 100644 index 609cba3d6..000000000 --- a/db/backup_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package db_test - -import ( - "context" - "database/sql" - "math/rand" - "os" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - . "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/singleton" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func shortTime(year int, month time.Month, day, hour, minute int) time.Time { - return time.Date(year, month, day, hour, minute, 0, 0, time.UTC) -} - -var _ = Describe("database backups", func() { - When("there are a few backup files", func() { - var ctx context.Context - var timesShuffled []time.Time - - timesDecreasingChronologically := []time.Time{ - shortTime(2024, 11, 6, 5, 11), - shortTime(2024, 11, 6, 5, 8), - shortTime(2024, 11, 6, 4, 32), - shortTime(2024, 11, 6, 2, 4), - shortTime(2024, 11, 6, 1, 52), - - shortTime(2024, 11, 5, 23, 0), - shortTime(2024, 11, 5, 6, 4), - shortTime(2024, 11, 4, 2, 4), - shortTime(2024, 11, 3, 8, 5), - shortTime(2024, 11, 2, 5, 24), - shortTime(2024, 11, 1, 5, 24), - - shortTime(2024, 10, 31, 5, 9), - shortTime(2024, 10, 30, 5, 9), - shortTime(2024, 10, 23, 14, 3), - shortTime(2024, 10, 22, 3, 6), - shortTime(2024, 10, 11, 14, 3), - - shortTime(2024, 9, 21, 19, 5), - shortTime(2024, 9, 3, 8, 5), - - shortTime(2024, 7, 5, 1, 1), - - shortTime(2023, 8, 2, 19, 5), - - shortTime(2021, 8, 2, 19, 5), - shortTime(2020, 8, 2, 19, 5), - } - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - - tempFolder, err := os.MkdirTemp("", "navidrome_backup") - Expect(err).ToNot(HaveOccurred()) - conf.Server.Backup.Path = conf.NewDir(tempFolder) - - DeferCleanup(func() { - _ = os.RemoveAll(tempFolder) - }) - - timesShuffled = make([]time.Time, len(timesDecreasingChronologically)) - copy(timesShuffled, timesDecreasingChronologically) - //nolint:gosec // shuffle order is not a security decision - rand.Shuffle(len(timesShuffled), func(i, j int) { - timesShuffled[i], timesShuffled[j] = timesShuffled[j], timesShuffled[i] - }) - - for _, time := range timesShuffled { - path := BackupPath(time) - file, err := os.Create(path) - Expect(err).ToNot(HaveOccurred()) - _ = file.Close() - } - - ctx = context.Background() - }) - - DescribeTable("prune", func(count, expected int) { - conf.Server.Backup.Count = count - pruneCount, err := Prune(ctx) - Expect(err).ToNot(HaveOccurred()) - for idx, time := range timesDecreasingChronologically { - _, err := os.Stat(BackupPath(time)) - shouldExist := idx < conf.Server.Backup.Count - if shouldExist { - Expect(err).ToNot(HaveOccurred()) - } else { - Expect(err).To(MatchError(os.ErrNotExist)) - } - } - - Expect(len(timesDecreasingChronologically) - pruneCount).To(Equal(expected)) - }, - Entry("preserve latest 5 backups", 5, 5), - Entry("delete all files", 0, 0), - Entry("preserve all files when at length", len(timesDecreasingChronologically), len(timesDecreasingChronologically)), - Entry("preserve all files when less than count", 10000, len(timesDecreasingChronologically))) - - It("ignores SQLite sidecar files when counting backups", func() { - for _, suffix := range []string{"-shm", "-wal"} { - file, err := os.Create(BackupPath(timesDecreasingChronologically[0]) + suffix) - Expect(err).ToNot(HaveOccurred()) - _ = file.Close() - } - - conf.Server.Backup.Count = len(timesDecreasingChronologically) - pruneCount, err := Prune(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(pruneCount).To(BeZero()) - }) - }) - - Describe("backup and restore", Ordered, func() { - var ctx context.Context - - BeforeAll(func() { - ctx = context.Background() - DeferCleanup(configtest.SetupConfig()) - - conf.Server.DbPath = "file::memory:?cache=shared&_foreign_keys=on" - DeferCleanup(Init(ctx)) - }) - - BeforeEach(func() { - tempFolder, err := os.MkdirTemp("", "navidrome_backup") - Expect(err).ToNot(HaveOccurred()) - conf.Server.Backup.Path = conf.NewDir(tempFolder) - - DeferCleanup(func() { - _ = os.RemoveAll(tempFolder) - }) - }) - - It("successfully backups the database", func() { - path, err := Backup(ctx) - Expect(err).ToNot(HaveOccurred()) - - backup, err := sql.Open(Driver, path) - Expect(err).ToNot(HaveOccurred()) - Expect(IsSchemaEmpty(ctx, backup)).To(BeFalse()) - }) - - It("successfully restores the database", func() { - path, err := Backup(ctx) - Expect(err).ToNot(HaveOccurred()) - - err = tests.ClearDB() - Expect(err).ToNot(HaveOccurred()) - Expect(IsSchemaEmpty(ctx, Db())).To(BeTrue()) - - err = Restore(ctx, path) - Expect(err).ToNot(HaveOccurred()) - Expect(IsSchemaEmpty(ctx, Db())).To(BeFalse()) - }) - }) - - Describe("backup and restore with a file-based database", Ordered, func() { - var ctx context.Context - var tempFolder string - var dbFilePath string - - BeforeAll(func() { - ctx = context.Background() - DeferCleanup(configtest.SetupConfig()) - - var err error - tempFolder, err = os.MkdirTemp("", "navidrome_restore") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - Close(ctx) - _ = os.RemoveAll(tempFolder) - }) - - // Mimic the production DSN (consts.DefaultDbPath): a file database in WAL mode. - dbFilePath = filepath.Join(tempFolder, "navidrome.db") - conf.Server.DbPath = dbFilePath + "?_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal" - // The previous container's cleanup closed the shared *sql.DB without - // dropping the singleton; force a fresh connection for this container. - singleton.DeleteInstance[*sql.DB]() - DeferCleanup(Init(ctx)) - }) - - It("restores data into a database whose stale WAL sidecar files were left behind", func() { - By("seeding user data in the current database") - _, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at) - VALUES ('u-restore-1', 'drilladmin', 'drilladmin', 'drilladmin@example.com', 'x', 1, datetime('now'), datetime('now'))`) - Expect(err).ToNot(HaveOccurred()) - - By("creating a backup containing the user row") - path, err := Backup(ctx) - Expect(err).ToNot(HaveOccurred()) - - By("simulating the CLI exiting without closing the pool: sidecar files stay behind") - _, err = Db().ExecContext(ctx, "CREATE TABLE IF NOT EXISTS _restore_probe(x)") - Expect(err).ToNot(HaveOccurred()) - singleton.DeleteInstance[*sql.DB]() - - err = tests.ClearDB() - Expect(err).ToNot(HaveOccurred()) - - By("restoring the backup") - Expect(Restore(ctx, path)).To(Succeed()) - - By("verifying the restored data is readable through a fresh connection") - singleton.DeleteInstance[*sql.DB]() - var userName string - Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-1'").Scan(&userName)).To(Succeed()) - Expect(userName).To(Equal("drilladmin")) - }) - - It("fails to restore from a backup file that does not exist, leaving the database intact", func() { - By("seeding user data in the current database") - _, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at) - VALUES ('u-restore-2', 'keepme', 'keepme', 'keepme@example.com', 'x', 1, datetime('now'), datetime('now'))`) - Expect(err).ToNot(HaveOccurred()) - - By("attempting a restore from a nonexistent file") - missingPath := filepath.Join(tempFolder, "does_not_exist.db") - err = Restore(ctx, missingPath) - Expect(err).To(HaveOccurred()) - - By("verifying the database was not wiped") - var userName string - Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-2'").Scan(&userName)).To(Succeed()) - Expect(userName).To(Equal("keepme")) - _, statErr := os.Stat(missingPath) - Expect(statErr).To(MatchError(os.ErrNotExist)) - }) - }) -}) diff --git a/db/db.go b/db/db.go index 66e48cede..cea44974f 100644 --- a/db/db.go +++ b/db/db.go @@ -1,215 +1,113 @@ package db import ( - "context" "database/sql" "embed" - "errors" "fmt" - "sync" - "time" - "github.com/mattn/go-sqlite3" + _ "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/conf" - _ "github.com/navidrome/navidrome/db/migrations" + _ "github.com/navidrome/navidrome/db/migration" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/hasher" - "github.com/navidrome/navidrome/utils/natural" "github.com/navidrome/navidrome/utils/singleton" "github.com/pressly/goose/v3" ) -// NaturalCollation sorts embedded numbers by value. It is registered on every -// connection, but only referenced when conf.Server.EnableNaturalSorting is on. -const NaturalCollation = "NATSORT" - var ( - Dialect = "sqlite3" - Driver = Dialect + "_custom" - Path string + Driver = "sqlite3" + Path string ) -//go:embed migrations/*.sql +//go:embed migration/*.sql var embedMigrations embed.FS -const migrationsFolder = "migrations" - -// sql.Register panics if called twice, so guard it: the singleton instance can be reset -// (tests/benchmarks) and rebuilt, but the driver is process-global and registers only once. -var registerDriverOnce sync.Once +const migrationsFolder = "migration" func Db() *sql.DB { return singleton.GetInstance(func() *sql.DB { - registerDriverOnce.Do(func() { - sql.Register(Driver, &sqlite3.SQLiteDriver{ - ConnectHook: func(conn *sqlite3.SQLiteConn) error { - if err := conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false); err != nil { - return err - } - return conn.RegisterCollation(NaturalCollation, natural.CompareFold) - }, - }) - }) Path = conf.Server.DbPath if Path == ":memory:" { Path = "file::memory:?cache=shared&_foreign_keys=on" conf.Server.DbPath = Path - } else { - conf.Server.DataFolder.MustPath() } log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver) - db, err := sql.Open(Driver, Path) - db.SetMaxOpenConns(conf.MaxOpenConns()) + instance, err := sql.Open(Driver, Path) if err != nil { - log.Fatal("Error opening database", err) + panic(err) } - return db + return instance }) } -func Close(ctx context.Context) { - // Ignore cancellations when closing the DB - ctx = context.WithoutCancel(ctx) - - log.Info(ctx, "Closing Database") - err := Db().Close() - if err != nil { - log.Error(ctx, "Error closing Database", err) - } +func Close() error { + log.Info("Closing Database") + return Db().Close() } -func Init(ctx context.Context) func() { +func Init() { db := Db() // Disable foreign_keys to allow re-creating tables in migrations - _, err := db.ExecContext(ctx, "PRAGMA foreign_keys=off") + _, err := db.Exec("PRAGMA foreign_keys=off") defer func() { - _, err := db.ExecContext(ctx, "PRAGMA foreign_keys=on") + _, err := db.Exec("PRAGMA foreign_keys=on") if err != nil { - log.Error(ctx, "Error re-enabling foreign_keys", err) + log.Error("Error re-enabling foreign_keys", err) } }() if err != nil { - log.Error(ctx, "Error disabling foreign_keys", err) + log.Error("Error disabling foreign_keys", err) } + gooseLogger := &logAdapter{silent: isSchemaEmpty(db)} + goose.SetLogger(gooseLogger) goose.SetBaseFS(embedMigrations) - err = goose.SetDialect(Dialect) + + err = goose.SetDialect(Driver) if err != nil { - log.Fatal(ctx, "Invalid DB driver", "driver", Driver, err) + log.Fatal("Invalid DB driver", "driver", Driver, err) } - schemaEmpty := isSchemaEmpty(ctx, db) - hasSchemaChanges := hasPendingMigrations(ctx, db, migrationsFolder) - if !schemaEmpty && hasSchemaChanges { - log.Info(ctx, "Upgrading DB Schema to latest version") - } - goose.SetLogger(&logAdapter{ctx: ctx, silent: schemaEmpty}) - err = goose.UpContext(ctx, db, migrationsFolder) + err = goose.Up(db, migrationsFolder) if err != nil { - log.Fatal(ctx, "Failed to apply new migrations", err) - } - - if hasSchemaChanges { - log.Debug(ctx, "Running ANALYZE after schema changes") - err = optimizeAt(ctx, db, time.Now()) - if err != nil { - log.Error(ctx, "Error running ANALYZE", err) - } - } - - return func() { - Close(ctx) + log.Fatal("Failed to apply new migrations", err) } } -// ErrorCodes reports the SQLite result code and extended result code carried by err. -// The extended code is what distinguishes errors that share a message: "database is locked" -// is both SQLITE_BUSY, which busy_timeout retries, and SQLITE_BUSY_SNAPSHOT, which it never can. -func ErrorCodes(err error) (code, extended int, ok bool) { - var se sqlite3.Error - if !errors.As(err, &se) { - return 0, 0, false - } - return int(se.Code), int(se.ExtendedCode), true -} - -// IsBusy reports whether err is SQLITE_BUSY, including BUSY_SNAPSHOT, which only a new transaction clears. -func IsBusy(err error) bool { - code, _, ok := ErrorCodes(err) - return ok && code == int(sqlite3.ErrBusy) -} - -type statusLogger struct{ numPending int } - -func (*statusLogger) Fatalf(format string, v ...any) { log.Fatal(fmt.Sprintf(format, v...)) } -func (l *statusLogger) Printf(format string, v ...any) { - if len(v) < 1 { - return - } - if v0, ok := v[0].(string); !ok { - return - } else if v0 == "Pending" { - l.numPending++ - } -} - -func hasPendingMigrations(ctx context.Context, db *sql.DB, folder string) bool { - l := &statusLogger{} - goose.SetLogger(l) - err := goose.StatusContext(ctx, db, folder) +func isSchemaEmpty(db *sql.DB) bool { + rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version';") // nolint:rowserrcheck if err != nil { - log.Fatal(ctx, "Failed to check for pending migrations", err) + log.Fatal("Database could not be opened!", err) } - return l.numPending > 0 -} - -// hasGooseTable reports whether goose's bookkeeping table exists, i.e. whether the -// database has ever been migrated. -func hasGooseTable(ctx context.Context, db *sql.DB) (bool, error) { - var name string - err := db.QueryRowContext(ctx, - "SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version'").Scan(&name) - if errors.Is(err, sql.ErrNoRows) { - return false, nil - } - return err == nil, err -} - -func isSchemaEmpty(ctx context.Context, db *sql.DB) bool { - found, err := hasGooseTable(ctx, db) - if err != nil { - log.Fatal(ctx, "Database could not be opened!", err) - } - return !found + defer rows.Close() + return !rows.Next() } type logAdapter struct { - ctx context.Context //nolint:containedctx // goose logger interface has no ctx silent bool } -func (l *logAdapter) Fatal(v ...any) { - log.Fatal(l.ctx, fmt.Sprint(v...)) +func (l *logAdapter) Fatal(v ...interface{}) { + log.Fatal(fmt.Sprint(v...)) } -func (l *logAdapter) Fatalf(format string, v ...any) { - log.Fatal(l.ctx, fmt.Sprintf(format, v...)) +func (l *logAdapter) Fatalf(format string, v ...interface{}) { + log.Fatal(fmt.Sprintf(format, v...)) } -func (l *logAdapter) Print(v ...any) { +func (l *logAdapter) Print(v ...interface{}) { if !l.silent { - log.Info(l.ctx, fmt.Sprint(v...)) + log.Info(fmt.Sprint(v...)) } } -func (l *logAdapter) Println(v ...any) { +func (l *logAdapter) Println(v ...interface{}) { if !l.silent { - log.Info(l.ctx, fmt.Sprintln(v...)) + log.Info(fmt.Sprintln(v...)) } } -func (l *logAdapter) Printf(format string, v ...any) { +func (l *logAdapter) Printf(format string, v ...interface{}) { if !l.silent { - log.Info(l.ctx, fmt.Sprintf(format, v...)) + log.Info(fmt.Sprintf(format, v...)) } } diff --git a/db/db_test.go b/db/db_test.go index e3a52e1fb..d367984a5 100644 --- a/db/db_test.go +++ b/db/db_test.go @@ -1,15 +1,9 @@ -package db_test +package db import ( - "context" "database/sql" - "errors" - "fmt" "testing" - "github.com/mattn/go-sqlite3" - - "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -23,34 +17,20 @@ func TestDB(t *testing.T) { RunSpecs(t, "DB Suite") } -var _ = DescribeTable("IsBusy", - func(err error, expected bool) { - Expect(db.IsBusy(err)).To(Equal(expected)) - }, - Entry("SQLITE_BUSY", sqlite3.Error{Code: sqlite3.ErrBusy}, true), - Entry("SQLITE_BUSY_SNAPSHOT", sqlite3.Error{Code: sqlite3.ErrBusy, ExtendedCode: sqlite3.ErrBusySnapshot}, true), - Entry("a wrapped SQLITE_BUSY", fmt.Errorf("persisting: %w", sqlite3.Error{Code: sqlite3.ErrBusy}), true), - Entry("another SQLite error", sqlite3.Error{Code: sqlite3.ErrConstraint}, false), - Entry("a non-SQLite error", errors.New("database is locked"), false), - Entry("nil", nil, false), -) - -var _ = Describe("IsSchemaEmpty", func() { - var database *sql.DB - var ctx context.Context +var _ = Describe("isSchemaEmpty", func() { + var db *sql.DB BeforeEach(func() { - ctx = context.Background() path := "file::memory:" - database, _ = sql.Open(db.Dialect, path) + db, _ = sql.Open(Driver, path) }) It("returns false if the goose metadata table is found", func() { - _, err := database.Exec("create table goose_db_version (id primary key);") + _, err := db.Exec("create table goose_db_version (id primary key);") Expect(err).ToNot(HaveOccurred()) - Expect(db.IsSchemaEmpty(ctx, database)).To(BeFalse()) + Expect(isSchemaEmpty(db)).To(BeFalse()) }) It("returns true if the schema is brand new", func() { - Expect(db.IsSchemaEmpty(ctx, database)).To(BeTrue()) + Expect(isSchemaEmpty(db)).To(BeTrue()) }) }) diff --git a/db/export_test.go b/db/export_test.go deleted file mode 100644 index d2034bcdb..000000000 --- a/db/export_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package db - -// Definitions for testing private methods -var ( - EmbedMigrations = embedMigrations - FTSTables = ftsTables - FTSTriggerSuffixes = ftsTriggerSuffixes - FTSSearchMigration = ftsSearchMigration - IsSchemaEmpty = isSchemaEmpty - BackupPath = backupPath - OptimizeDBAt = optimizeAt - OptimizeDBIfNeeded = optimizeIfNeeded - RecordAnalyzeFailure = recordAnalyzeFailure -) diff --git a/db/migrations/20200130083147_create_schema.go b/db/migration/20200130083147_create_schema.go similarity index 94% rename from db/migrations/20200130083147_create_schema.go rename to db/migration/20200130083147_create_schema.go index 250fb00a5..4edf365f3 100644 --- a/db/migrations/20200130083147_create_schema.go +++ b/db/migration/20200130083147_create_schema.go @@ -1,7 +1,6 @@ package migrations import ( - "context" "database/sql" "github.com/navidrome/navidrome/log" @@ -9,12 +8,12 @@ import ( ) func init() { - goose.AddMigrationContext(Up20200130083147, Down20200130083147) + goose.AddMigration(Up20200130083147, Down20200130083147) } -func Up20200130083147(ctx context.Context, tx *sql.Tx) error { +func Up20200130083147(tx *sql.Tx) error { log.Info("Creating DB Schema") - _, err := tx.ExecContext(ctx, ` + _, err := tx.Exec(` create table if not exists album ( id varchar(255) not null @@ -179,6 +178,6 @@ create table if not exists user return err } -func Down20200130083147(_ context.Context, _ *sql.Tx) error { +func Down20200130083147(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200131183653_standardize_item_type.go b/db/migration/20200131183653_standardize_item_type.go similarity index 80% rename from db/migrations/20200131183653_standardize_item_type.go rename to db/migration/20200131183653_standardize_item_type.go index bf7d9d5f7..ee26eee3f 100644 --- a/db/migrations/20200131183653_standardize_item_type.go +++ b/db/migration/20200131183653_standardize_item_type.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20200131183653, Down20200131183653) + goose.AddMigration(Up20200131183653, Down20200131183653) } -func Up20200131183653(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200131183653(tx *sql.Tx) error { + _, err := tx.Exec(` create table search_dg_tmp ( id varchar(255) not null @@ -37,8 +36,8 @@ update annotation set item_type = 'media_file' where item_type = 'mediaFile'; return err } -func Down20200131183653(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Down20200131183653(tx *sql.Tx) error { + _, err := tx.Exec(` create table search_dg_tmp ( id varchar(255) not null diff --git a/db/migrations/20200208222418_add_defaults_to_annotations.go b/db/migration/20200208222418_add_defaults_to_annotations.go similarity index 83% rename from db/migrations/20200208222418_add_defaults_to_annotations.go rename to db/migration/20200208222418_add_defaults_to_annotations.go index 6807c8ad2..9029894b4 100644 --- a/db/migrations/20200208222418_add_defaults_to_annotations.go +++ b/db/migration/20200208222418_add_defaults_to_annotations.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20200208222418, Down20200208222418) + goose.AddMigration(Up20200208222418, Down20200208222418) } -func Up20200208222418(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200208222418(tx *sql.Tx) error { + _, err := tx.Exec(` update annotation set play_count = 0 where play_count is null; update annotation set rating = 0 where rating is null; create table annotation_dg_tmp @@ -51,6 +50,6 @@ create index annotation_starred return err } -func Down20200208222418(_ context.Context, _ *sql.Tx) error { +func Down20200208222418(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200220143731_change_duration_to_float.go b/db/migration/20200220143731_change_duration_to_float.go similarity index 91% rename from db/migrations/20200220143731_change_duration_to_float.go rename to db/migration/20200220143731_change_duration_to_float.go index ea5465ade..9685bb0d4 100644 --- a/db/migrations/20200220143731_change_duration_to_float.go +++ b/db/migration/20200220143731_change_duration_to_float.go @@ -1,19 +1,18 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20200220143731, Down20200220143731) + goose.AddMigration(Up20200220143731, Down20200220143731) } -func Up20200220143731(ctx context.Context, tx *sql.Tx) error { - notice(ctx, tx, "This migration will force the next scan to be a full rescan!") - _, err := tx.ExecContext(ctx, ` +func Up20200220143731(tx *sql.Tx) error { + notice(tx, "This migration will force the next scan to be a full rescan!") + _, err := tx.Exec(` create table media_file_dg_tmp ( id varchar(255) not null @@ -125,6 +124,6 @@ update media_file set updated_at = '0001-01-01'; return err } -func Down20200220143731(_ context.Context, _ *sql.Tx) error { +func Down20200220143731(tx *sql.Tx) error { return nil } diff --git a/db/migration/20200310171621_enable_search_by_albumartist.go b/db/migration/20200310171621_enable_search_by_albumartist.go new file mode 100644 index 000000000..7e1b32c84 --- /dev/null +++ b/db/migration/20200310171621_enable_search_by_albumartist.go @@ -0,0 +1,20 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(Up20200310171621, Down20200310171621) +} + +func Up20200310171621(tx *sql.Tx) error { + notice(tx, "A full rescan will be performed to enable search by Album Artist!") + return forceFullRescan(tx) +} + +func Down20200310171621(tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200310181627_add_transcoding_and_player_tables.go b/db/migration/20200310181627_add_transcoding_and_player_tables.go similarity index 75% rename from db/migrations/20200310181627_add_transcoding_and_player_tables.go rename to db/migration/20200310181627_add_transcoding_and_player_tables.go index ef872c4ae..4377fd483 100644 --- a/db/migrations/20200310181627_add_transcoding_and_player_tables.go +++ b/db/migration/20200310181627_add_transcoding_and_player_tables.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20200310181627, Down20200310181627) + goose.AddMigration(Up20200310181627, Down20200310181627) } -func Up20200310181627(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200310181627(tx *sql.Tx) error { + _, err := tx.Exec(` create table transcoding ( id varchar(255) not null primary key, @@ -45,8 +44,8 @@ create table player return err } -func Down20200310181627(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Down20200310181627(tx *sql.Tx) error { + _, err := tx.Exec(` drop table transcoding; drop table player; `) diff --git a/db/migrations/20200319211049_merge_search_into_main_tables.go b/db/migration/20200319211049_merge_search_into_main_tables.go similarity index 64% rename from db/migrations/20200319211049_merge_search_into_main_tables.go rename to db/migration/20200319211049_merge_search_into_main_tables.go index a7a6ff0f9..c1977cc7e 100644 --- a/db/migrations/20200319211049_merge_search_into_main_tables.go +++ b/db/migration/20200319211049_merge_search_into_main_tables.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20200319211049, Down20200319211049) + goose.AddMigration(Up20200319211049, Down20200319211049) } -func Up20200319211049(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200319211049(tx *sql.Tx) error { + _, err := tx.Exec(` alter table media_file add full_text varchar(255) default ''; create index if not exists media_file_full_text @@ -33,10 +32,10 @@ drop table if exists search; if err != nil { return err } - notice(ctx, tx, "A full rescan will be performed!") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan will be performed!") + return forceFullRescan(tx) } -func Down20200319211049(_ context.Context, _ *sql.Tx) error { +func Down20200319211049(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200325185135_add_album_artist_id.go b/db/migration/20200325185135_add_album_artist_id.go similarity index 56% rename from db/migrations/20200325185135_add_album_artist_id.go rename to db/migration/20200325185135_add_album_artist_id.go index 01537f886..6ac817e76 100644 --- a/db/migrations/20200325185135_add_album_artist_id.go +++ b/db/migration/20200325185135_add_album_artist_id.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20200325185135, Down20200325185135) + goose.AddMigration(Up20200325185135, Down20200325185135) } -func Up20200325185135(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200325185135(tx *sql.Tx) error { + _, err := tx.Exec(` alter table album add album_artist_id varchar(255) default ''; create index album_artist_album_id @@ -26,10 +25,10 @@ create index media_file_artist_album_id if err != nil { return err } - notice(ctx, tx, "A full rescan will be performed!") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan will be performed!") + return forceFullRescan(tx) } -func Down20200325185135(_ context.Context, _ *sql.Tx) error { +func Down20200325185135(tx *sql.Tx) error { return nil } diff --git a/db/migration/20200326090707_fix_album_artists_importing.go b/db/migration/20200326090707_fix_album_artists_importing.go new file mode 100644 index 000000000..c6679fcb0 --- /dev/null +++ b/db/migration/20200326090707_fix_album_artists_importing.go @@ -0,0 +1,20 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(Up20200326090707, Down20200326090707) +} + +func Up20200326090707(tx *sql.Tx) error { + notice(tx, "A full rescan will be performed!") + return forceFullRescan(tx) +} + +func Down20200326090707(tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200327193744_add_year_range_to_album.go b/db/migration/20200327193744_add_year_range_to_album.go similarity index 84% rename from db/migrations/20200327193744_add_year_range_to_album.go rename to db/migration/20200327193744_add_year_range_to_album.go index d9b048e22..f4bd7d72d 100644 --- a/db/migrations/20200327193744_add_year_range_to_album.go +++ b/db/migration/20200327193744_add_year_range_to_album.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20200327193744, Down20200327193744) + goose.AddMigration(Up20200327193744, Down20200327193744) } -func Up20200327193744(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200327193744(tx *sql.Tx) error { + _, err := tx.Exec(` create table album_dg_tmp ( id varchar(255) not null @@ -72,10 +71,10 @@ create index album_max_year if err != nil { return err } - notice(ctx, tx, "A full rescan will be performed!") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan will be performed!") + return forceFullRescan(tx) } -func Down20200327193744(_ context.Context, _ *sql.Tx) error { +func Down20200327193744(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200404214704_add_indexes.go b/db/migration/20200404214704_add_indexes.go similarity index 61% rename from db/migrations/20200404214704_add_indexes.go rename to db/migration/20200404214704_add_indexes.go index 8b8d8607e..2d53c9984 100644 --- a/db/migrations/20200404214704_add_indexes.go +++ b/db/migration/20200404214704_add_indexes.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20200404214704, Down20200404214704) + goose.AddMigration(Up20200404214704, Down20200404214704) } -func Up20200404214704(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200404214704(tx *sql.Tx) error { + _, err := tx.Exec(` create index if not exists media_file_year on media_file (year); @@ -25,6 +24,6 @@ create index if not exists media_file_track_number return err } -func Down20200404214704(_ context.Context, _ *sql.Tx) error { +func Down20200404214704(tx *sql.Tx) error { return nil } diff --git a/db/migration/20200409002249_enable_search_by_tracks_artists.go b/db/migration/20200409002249_enable_search_by_tracks_artists.go new file mode 100644 index 000000000..580d3037c --- /dev/null +++ b/db/migration/20200409002249_enable_search_by_tracks_artists.go @@ -0,0 +1,20 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(Up20200409002249, Down20200409002249) +} + +func Up20200409002249(tx *sql.Tx) error { + notice(tx, "A full rescan will be performed to enable search by individual Artist in an Album!") + return forceFullRescan(tx) +} + +func Down20200409002249(tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go b/db/migration/20200411164603_add_created_and_updated_fields_to_playlists.go similarity index 56% rename from db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go rename to db/migration/20200411164603_add_created_and_updated_fields_to_playlists.go index 4aa502b4b..ddfb10dc9 100644 --- a/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go +++ b/db/migration/20200411164603_add_created_and_updated_fields_to_playlists.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20200411164603, Down20200411164603) + goose.AddMigration(Up20200411164603, Down20200411164603) } -func Up20200411164603(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200411164603(tx *sql.Tx) error { + _, err := tx.Exec(` alter table playlist add created_at datetime; alter table playlist @@ -23,6 +22,6 @@ update playlist return err } -func Down20200411164603(_ context.Context, _ *sql.Tx) error { +func Down20200411164603(tx *sql.Tx) error { return nil } diff --git a/db/migration/20200418110522_reindex_to_fix_album_years.go b/db/migration/20200418110522_reindex_to_fix_album_years.go new file mode 100644 index 000000000..8412aadb3 --- /dev/null +++ b/db/migration/20200418110522_reindex_to_fix_album_years.go @@ -0,0 +1,20 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(Up20200418110522, Down20200418110522) +} + +func Up20200418110522(tx *sql.Tx) error { + notice(tx, "A full rescan will be performed to fix search Albums by year") + return forceFullRescan(tx) +} + +func Down20200418110522(tx *sql.Tx) error { + return nil +} diff --git a/db/migration/20200419222708_reindex_to_change_full_text_search.go b/db/migration/20200419222708_reindex_to_change_full_text_search.go new file mode 100644 index 000000000..c81428fa4 --- /dev/null +++ b/db/migration/20200419222708_reindex_to_change_full_text_search.go @@ -0,0 +1,20 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(Up20200419222708, Down20200419222708) +} + +func Up20200419222708(tx *sql.Tx) error { + notice(tx, "A full rescan will be performed to change the search behaviour") + return forceFullRescan(tx) +} + +func Down20200419222708(tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200423204116_add_sort_fields.go b/db/migration/20200423204116_add_sort_fields.go similarity index 82% rename from db/migrations/20200423204116_add_sort_fields.go rename to db/migration/20200423204116_add_sort_fields.go index a51bb2270..6c8590fcf 100644 --- a/db/migrations/20200423204116_add_sort_fields.go +++ b/db/migration/20200423204116_add_sort_fields.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20200423204116, Down20200423204116) + goose.AddMigration(Up20200423204116, Down20200423204116) } -func Up20200423204116(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200423204116(tx *sql.Tx) error { + _, err := tx.Exec(` alter table artist add order_artist_name varchar(255) collate nocase; alter table artist @@ -57,10 +56,10 @@ create index if not exists media_file_order_artist_name if err != nil { return err } - notice(ctx, tx, "A full rescan will be performed to change the search behaviour") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan will be performed to change the search behaviour") + return forceFullRescan(tx) } -func Down20200423204116(_ context.Context, _ *sql.Tx) error { +func Down20200423204116(tx *sql.Tx) error { return nil } diff --git a/db/migration/20200508093059_add_artist_song_count.go b/db/migration/20200508093059_add_artist_song_count.go new file mode 100644 index 000000000..37e7d53a6 --- /dev/null +++ b/db/migration/20200508093059_add_artist_song_count.go @@ -0,0 +1,27 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(Up20200508093059, Down20200508093059) +} + +func Up20200508093059(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table artist + add song_count integer default 0 not null; +`) + if err != nil { + return err + } + notice(tx, "A full rescan will be performed to calculate artists' song counts") + return forceFullRescan(tx) +} + +func Down20200508093059(tx *sql.Tx) error { + return nil +} diff --git a/db/migration/20200512104202_add_disc_subtitle.go b/db/migration/20200512104202_add_disc_subtitle.go new file mode 100644 index 000000000..96d0f0941 --- /dev/null +++ b/db/migration/20200512104202_add_disc_subtitle.go @@ -0,0 +1,27 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(Up20200512104202, Down20200512104202) +} + +func Up20200512104202(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table media_file + add disc_subtitle varchar(255); + `) + if err != nil { + return err + } + notice(tx, "A full rescan will be performed to import disc subtitles") + return forceFullRescan(tx) +} + +func Down20200512104202(tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200516140647_add_playlist_tracks_table.go b/db/migration/20200516140647_add_playlist_tracks_table.go similarity index 85% rename from db/migrations/20200516140647_add_playlist_tracks_table.go rename to db/migration/20200516140647_add_playlist_tracks_table.go index 59265e410..b2b932f89 100644 --- a/db/migrations/20200516140647_add_playlist_tracks_table.go +++ b/db/migration/20200516140647_add_playlist_tracks_table.go @@ -1,7 +1,6 @@ package migrations import ( - "context" "database/sql" "strings" @@ -10,11 +9,11 @@ import ( ) func init() { - goose.AddMigrationContext(Up20200516140647, Down20200516140647) + goose.AddMigration(Up20200516140647, Down20200516140647) } -func Up20200516140647(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20200516140647(tx *sql.Tx) error { + _, err := tx.Exec(` create table if not exists playlist_tracks ( id integer default 0 not null, @@ -28,7 +27,7 @@ create unique index if not exists playlist_tracks_pos if err != nil { return err } - rows, err := tx.QueryContext(ctx, "select id, tracks from playlist") + rows, err := tx.Query("select id, tracks from playlist") if err != nil { return err } @@ -49,7 +48,7 @@ create unique index if not exists playlist_tracks_pos return err } - _, err = tx.ExecContext(ctx, ` + _, err = tx.Exec(` create table playlist_dg_tmp ( id varchar(255) not null @@ -96,6 +95,6 @@ func Up20200516140647UpdatePlaylistTracks(tx *sql.Tx, id string, tracks string) return nil } -func Down20200516140647(_ context.Context, _ *sql.Tx) error { +func Down20200516140647(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200608153717_referential_integrity.go b/db/migration/20200608153717_referential_integrity.go similarity index 78% rename from db/migrations/20200608153717_referential_integrity.go rename to db/migration/20200608153717_referential_integrity.go index c9c766f7e..f31997ee4 100644 --- a/db/migrations/20200608153717_referential_integrity.go +++ b/db/migration/20200608153717_referential_integrity.go @@ -1,56 +1,55 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20200608153717, Down20200608153717) + goose.AddMigration(Up20200608153717, Down20200608153717) } -func Up20200608153717(ctx context.Context, tx *sql.Tx) error { +func Up20200608153717(tx *sql.Tx) error { // First delete dangling players - _, err := tx.ExecContext(ctx, ` + _, err := tx.Exec(` delete from player where user_name not in (select user_name from user)`) if err != nil { return err } // Also delete dangling players - _, err = tx.ExecContext(ctx, ` + _, err = tx.Exec(` delete from playlist where owner not in (select user_name from user)`) if err != nil { return err } // Also delete dangling playlist tracks - _, err = tx.ExecContext(ctx, ` + _, err = tx.Exec(` delete from playlist_tracks where playlist_id not in (select id from playlist)`) if err != nil { return err } // Add foreign key to player table - err = updatePlayer_20200608153717(ctx, tx) + err = updatePlayer_20200608153717(tx) if err != nil { return err } // Add foreign key to playlist table - err = updatePlaylist_20200608153717(ctx, tx) + err = updatePlaylist_20200608153717(tx) if err != nil { return err } // Add foreign keys to playlist_tracks table - return updatePlaylistTracks_20200608153717(ctx, tx) + return updatePlaylistTracks_20200608153717(tx) } -func updatePlayer_20200608153717(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func updatePlayer_20200608153717(tx *sql.Tx) error { + _, err := tx.Exec(` create table player_dg_tmp ( id varchar(255) not null @@ -77,8 +76,8 @@ alter table player_dg_tmp rename to player; return err } -func updatePlaylist_20200608153717(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func updatePlaylist_20200608153717(tx *sql.Tx) error { + _, err := tx.Exec(` create table playlist_dg_tmp ( id varchar(255) not null @@ -108,8 +107,8 @@ create index playlist_name return err } -func updatePlaylistTracks_20200608153717(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func updatePlaylistTracks_20200608153717(tx *sql.Tx) error { + _, err := tx.Exec(` create table playlist_tracks_dg_tmp ( id integer default 0 not null, @@ -133,6 +132,6 @@ create unique index playlist_tracks_pos return err } -func Down20200608153717(_ context.Context, _ *sql.Tx) error { +func Down20200608153717(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200706231659_add_default_transcodings.go b/db/migration/20200706231659_add_default_transcodings.go similarity index 54% rename from db/migrations/20200706231659_add_default_transcodings.go rename to db/migration/20200706231659_add_default_transcodings.go index e87481ae1..6627405b9 100644 --- a/db/migrations/20200706231659_add_default_transcodings.go +++ b/db/migration/20200706231659_add_default_transcodings.go @@ -1,20 +1,19 @@ package migrations import ( - "context" "database/sql" + "github.com/google/uuid" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model/id" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upAddDefaultTranscodings, downAddDefaultTranscodings) + goose.AddMigration(upAddDefaultTranscodings, downAddDefaultTranscodings) } -func upAddDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { - row := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding") +func upAddDefaultTranscodings(tx *sql.Tx) error { + row := tx.QueryRow("SELECT COUNT(*) FROM transcoding") var count int err := row.Scan(&count) if err != nil { @@ -30,7 +29,7 @@ func upAddDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { } for _, t := range consts.DefaultTranscodings { - _, err := stmt.Exec(id.NewRandom(), t.Name, t.TargetFormat, t.DefaultBitRate, t.Command) + _, err := stmt.Exec(uuid.NewString(), t["name"], t["targetFormat"], t["defaultBitRate"], t["command"]) if err != nil { return err } @@ -38,6 +37,6 @@ func upAddDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { return nil } -func downAddDefaultTranscodings(_ context.Context, _ *sql.Tx) error { +func downAddDefaultTranscodings(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200710211442_add_playlist_path.go b/db/migration/20200710211442_add_playlist_path.go similarity index 51% rename from db/migrations/20200710211442_add_playlist_path.go rename to db/migration/20200710211442_add_playlist_path.go index 32cc8d034..ff407434e 100644 --- a/db/migrations/20200710211442_add_playlist_path.go +++ b/db/migration/20200710211442_add_playlist_path.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upAddPlaylistPath, downAddPlaylistPath) + goose.AddMigration(upAddPlaylistPath, downAddPlaylistPath) } -func upAddPlaylistPath(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddPlaylistPath(tx *sql.Tx) error { + _, err := tx.Exec(` alter table playlist add path string default '' not null; @@ -23,6 +22,6 @@ alter table playlist return err } -func downAddPlaylistPath(_ context.Context, _ *sql.Tx) error { +func downAddPlaylistPath(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200731095603_create_play_queues_table.go b/db/migration/20200731095603_create_play_queues_table.go similarity index 66% rename from db/migrations/20200731095603_create_play_queues_table.go rename to db/migration/20200731095603_create_play_queues_table.go index 7a27137bc..a0fe41b1a 100644 --- a/db/migrations/20200731095603_create_play_queues_table.go +++ b/db/migration/20200731095603_create_play_queues_table.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upCreatePlayQueuesTable, downCreatePlayQueuesTable) + goose.AddMigration(upCreatePlayQueuesTable, downCreatePlayQueuesTable) } -func upCreatePlayQueuesTable(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upCreatePlayQueuesTable(tx *sql.Tx) error { + _, err := tx.Exec(` create table playqueue ( id varchar(255) not null primary key, @@ -32,6 +31,6 @@ create table playqueue return err } -func downCreatePlayQueuesTable(_ context.Context, _ *sql.Tx) error { +func downCreatePlayQueuesTable(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200801101355_create_bookmark_table.go b/db/migration/20200801101355_create_bookmark_table.go similarity index 77% rename from db/migrations/20200801101355_create_bookmark_table.go rename to db/migration/20200801101355_create_bookmark_table.go index df814d7b8..16cc43d74 100644 --- a/db/migrations/20200801101355_create_bookmark_table.go +++ b/db/migration/20200801101355_create_bookmark_table.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upCreateBookmarkTable, downCreateBookmarkTable) + goose.AddMigration(upCreateBookmarkTable, downCreateBookmarkTable) } -func upCreateBookmarkTable(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upCreateBookmarkTable(tx *sql.Tx) error { + _, err := tx.Exec(` create table bookmark ( user_id varchar(255) not null @@ -49,6 +48,6 @@ alter table playqueue_dg_tmp rename to playqueue; return err } -func downCreateBookmarkTable(_ context.Context, _ *sql.Tx) error { +func downCreateBookmarkTable(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20200819111809_drop_email_unique_constraint.go b/db/migration/20200819111809_drop_email_unique_constraint.go similarity index 75% rename from db/migrations/20200819111809_drop_email_unique_constraint.go rename to db/migration/20200819111809_drop_email_unique_constraint.go index 8259ad3fe..47d6d5c6a 100644 --- a/db/migrations/20200819111809_drop_email_unique_constraint.go +++ b/db/migration/20200819111809_drop_email_unique_constraint.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint) + goose.AddMigration(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint) } -func upDropEmailUniqueConstraint(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upDropEmailUniqueConstraint(tx *sql.Tx) error { + _, err := tx.Exec(` create table user_dg_tmp ( id varchar(255) not null @@ -38,6 +37,6 @@ alter table user_dg_tmp rename to user; return err } -func downDropEmailUniqueConstraint(_ context.Context, _ *sql.Tx) error { +func downDropEmailUniqueConstraint(tx *sql.Tx) error { return nil } diff --git a/db/migration/20201003111749_add_starred_at_index.go b/db/migration/20201003111749_add_starred_at_index.go new file mode 100644 index 000000000..ffe824252 --- /dev/null +++ b/db/migration/20201003111749_add_starred_at_index.go @@ -0,0 +1,23 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(Up20201003111749, Down20201003111749) +} + +func Up20201003111749(tx *sql.Tx) error { + _, err := tx.Exec(` +create index if not exists annotation_starred_at + on annotation (starred_at); + `) + return err +} + +func Down20201003111749(tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20201010162350_add_album_size.go b/db/migration/20201010162350_add_album_size.go similarity index 64% rename from db/migrations/20201010162350_add_album_size.go rename to db/migration/20201010162350_add_album_size.go index df1fa8ca2..93bf62d34 100644 --- a/db/migrations/20201010162350_add_album_size.go +++ b/db/migration/20201010162350_add_album_size.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20201010162350, Down20201010162350) + goose.AddMigration(Up20201010162350, Down20201010162350) } -func Up20201010162350(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20201010162350(tx *sql.Tx) error { + _, err := tx.Exec(` alter table album add size integer default 0 not null; create index if not exists album_size @@ -28,7 +27,7 @@ where id not null;`) return err } -func Down20201010162350(ctx context.Context, tx *sql.Tx) error { +func Down20201010162350(tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } diff --git a/db/migrations/20201012210022_add_artist_playlist_size.go b/db/migration/20201012210022_add_artist_playlist_size.go similarity index 74% rename from db/migrations/20201012210022_add_artist_playlist_size.go rename to db/migration/20201012210022_add_artist_playlist_size.go index 1c738dd1e..e666cbe5b 100644 --- a/db/migrations/20201012210022_add_artist_playlist_size.go +++ b/db/migration/20201012210022_add_artist_playlist_size.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20201012210022, Down20201012210022) + goose.AddMigration(Up20201012210022, Down20201012210022) } -func Up20201012210022(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20201012210022(tx *sql.Tx) error { + _, err := tx.Exec(` alter table artist add size integer default 0 not null; create index if not exists artist_size @@ -40,6 +39,6 @@ update playlist set size = ifnull(( return err } -func Down20201012210022(_ context.Context, _ *sql.Tx) error { +func Down20201012210022(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20201021085410_add_mbids.go b/db/migration/20201021085410_add_mbids.go similarity index 74% rename from db/migrations/20201021085410_add_mbids.go rename to db/migration/20201021085410_add_mbids.go index 53001fc73..c0e847104 100644 --- a/db/migrations/20201021085410_add_mbids.go +++ b/db/migration/20201021085410_add_mbids.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20201021085410, Down20201021085410) + goose.AddMigration(Up20201021085410, Down20201021085410) } -func Up20201021085410(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20201021085410(tx *sql.Tx) error { + _, err := tx.Exec(` alter table media_file add mbz_track_id varchar(255); alter table media_file @@ -49,11 +48,11 @@ alter table artist if err != nil { return err } - notice(ctx, tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) } -func Down20201021085410(ctx context.Context, tx *sql.Tx) error { +func Down20201021085410(tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } diff --git a/db/migrations/20201021093209_add_media_file_indexes.go b/db/migration/20201021093209_add_media_file_indexes.go similarity index 60% rename from db/migrations/20201021093209_add_media_file_indexes.go rename to db/migration/20201021093209_add_media_file_indexes.go index 7d6ad4965..a8832bb59 100644 --- a/db/migrations/20201021093209_add_media_file_indexes.go +++ b/db/migration/20201021093209_add_media_file_indexes.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20201021093209, Down20201021093209) + goose.AddMigration(Up20201021093209, Down20201021093209) } -func Up20201021093209(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20201021093209(tx *sql.Tx) error { + _, err := tx.Exec(` create index if not exists media_file_artist on media_file (artist); create index if not exists media_file_album_artist @@ -23,6 +22,6 @@ create index if not exists media_file_mbz_track_id return err } -func Down20201021093209(_ context.Context, _ *sql.Tx) error { +func Down20201021093209(tx *sql.Tx) error { return nil } diff --git a/db/migration/20201021135455_add_media_file_artist_index.go b/db/migration/20201021135455_add_media_file_artist_index.go new file mode 100644 index 000000000..f9e586f6f --- /dev/null +++ b/db/migration/20201021135455_add_media_file_artist_index.go @@ -0,0 +1,23 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(Up20201021135455, Down20201021135455) +} + +func Up20201021135455(tx *sql.Tx) error { + _, err := tx.Exec(` +create index if not exists media_file_artist_id + on media_file (artist_id); +`) + return err +} + +func Down20201021135455(tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20201030162009_add_artist_info_table.go b/db/migration/20201030162009_add_artist_info_table.go similarity index 71% rename from db/migrations/20201030162009_add_artist_info_table.go rename to db/migration/20201030162009_add_artist_info_table.go index e33e15c23..23dabfe6c 100644 --- a/db/migrations/20201030162009_add_artist_info_table.go +++ b/db/migration/20201030162009_add_artist_info_table.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upAddArtistImageUrl, downAddArtistImageUrl) + goose.AddMigration(upAddArtistImageUrl, downAddArtistImageUrl) } -func upAddArtistImageUrl(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddArtistImageUrl(tx *sql.Tx) error { + _, err := tx.Exec(` alter table artist add biography varchar(255) default '' not null; alter table artist @@ -31,6 +30,6 @@ alter table artist return err } -func downAddArtistImageUrl(_ context.Context, _ *sql.Tx) error { +func downAddArtistImageUrl(tx *sql.Tx) error { return nil } diff --git a/db/migration/20201110205344_add_comments_and_lyrics.go b/db/migration/20201110205344_add_comments_and_lyrics.go new file mode 100644 index 000000000..571f42464 --- /dev/null +++ b/db/migration/20201110205344_add_comments_and_lyrics.go @@ -0,0 +1,32 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(Up20201110205344, Down20201110205344) +} + +func Up20201110205344(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table media_file + add comment varchar; +alter table media_file + add lyrics varchar; + +alter table album + add comment varchar; +`) + if err != nil { + return err + } + notice(tx, "A full rescan will be performed to import comments and lyrics") + return forceFullRescan(tx) +} + +func Down20201110205344(tx *sql.Tx) error { + return nil +} diff --git a/db/migration/20201128100726_add_real-path_option.go b/db/migration/20201128100726_add_real-path_option.go new file mode 100644 index 000000000..64d79e57d --- /dev/null +++ b/db/migration/20201128100726_add_real-path_option.go @@ -0,0 +1,23 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(Up20201128100726, Down20201128100726) +} + +func Up20201128100726(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table player + add report_real_path bool default FALSE not null; +`) + return err +} + +func Down20201128100726(tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20201213124814_add_all_artist_ids_to_album.go b/db/migration/20201213124814_add_all_artist_ids_to_album.go similarity index 66% rename from db/migrations/20201213124814_add_all_artist_ids_to_album.go rename to db/migration/20201213124814_add_all_artist_ids_to_album.go index 81c30d611..56630883b 100644 --- a/db/migrations/20201213124814_add_all_artist_ids_to_album.go +++ b/db/migration/20201213124814_add_all_artist_ids_to_album.go @@ -1,20 +1,19 @@ package migrations import ( - "context" "database/sql" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/str" + "github.com/navidrome/navidrome/utils" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(Up20201213124814, Down20201213124814) + goose.AddMigration(Up20201213124814, Down20201213124814) } -func Up20201213124814(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func Up20201213124814(tx *sql.Tx) error { + _, err := tx.Exec(` alter table album add all_artist_ids varchar; @@ -25,11 +24,11 @@ create index if not exists album_all_artist_ids return err } - return updateAlbums20201213124814(ctx, tx) + return updateAlbums20201213124814(tx) } -func updateAlbums20201213124814(ctx context.Context, tx *sql.Tx) error { - rows, err := tx.QueryContext(ctx, ` +func updateAlbums20201213124814(tx *sql.Tx) error { + rows, err := tx.Query(` select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id, ' ') from album a left join media_file mf on a.id = mf.album_id group by a.id `) @@ -50,7 +49,7 @@ select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id, if err != nil { return err } - all := str.SanitizeStrings(artistId, albumArtistId, songArtistIds.String) + all := utils.SanitizeStrings(artistId, albumArtistId, songArtistIds.String) _, err = stmt.Exec(all, id) if err != nil { log.Error("Error setting album's artist_ids", "album", name, "albumId", id, err) @@ -59,6 +58,6 @@ select a.id, a.name, a.artist_id, a.album_artist_id, group_concat(mf.artist_id, return rows.Err() } -func Down20201213124814(_ context.Context, _ *sql.Tx) error { +func Down20201213124814(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210322132848_add_timestamp_indexes.go b/db/migration/20210322132848_add_timestamp_indexes.go similarity index 68% rename from db/migrations/20210322132848_add_timestamp_indexes.go rename to db/migration/20210322132848_add_timestamp_indexes.go index 5ed250fea..e4c24518e 100644 --- a/db/migrations/20210322132848_add_timestamp_indexes.go +++ b/db/migration/20210322132848_add_timestamp_indexes.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upAddTimestampIndexesGo, downAddTimestampIndexesGo) + goose.AddMigration(upAddTimestampIndexesGo, downAddTimestampIndexesGo) } -func upAddTimestampIndexesGo(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddTimestampIndexesGo(tx *sql.Tx) error { + _, err := tx.Exec(` create index if not exists album_updated_at on album (updated_at); create index if not exists album_created_at @@ -29,6 +28,6 @@ create index if not exists media_file_updated_at return err } -func downAddTimestampIndexesGo(_ context.Context, _ *sql.Tx) error { +func downAddTimestampIndexesGo(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210418232815_fix_album_comments.go b/db/migration/20210418232815_fix_album_comments.go similarity index 71% rename from db/migrations/20210418232815_fix_album_comments.go rename to db/migration/20210418232815_fix_album_comments.go index 3c7ed86c1..c6ee31343 100644 --- a/db/migrations/20210418232815_fix_album_comments.go +++ b/db/migration/20210418232815_fix_album_comments.go @@ -1,7 +1,6 @@ package migrations import ( - "context" "database/sql" "strings" @@ -11,13 +10,13 @@ import ( ) func init() { - goose.AddMigrationContext(upFixAlbumComments, downFixAlbumComments) + goose.AddMigration(upFixAlbumComments, downFixAlbumComments) } -func upFixAlbumComments(ctx context.Context, tx *sql.Tx) error { +func upFixAlbumComments(tx *sql.Tx) error { //nolint:gosec - rows, err := tx.QueryContext(ctx, ` - SELECT album.id, group_concat(media_file.comment, '`+consts.Zwsp+`') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id; + rows, err := tx.Query(` + SELECT album.id, group_concat(media_file.comment, '` + consts.Zwsp + `') FROM album, media_file WHERE media_file.album_id = album.id GROUP BY album.id; `) if err != nil { return err @@ -49,7 +48,7 @@ func upFixAlbumComments(ctx context.Context, tx *sql.Tx) error { return rows.Err() } -func downFixAlbumComments(_ context.Context, _ *sql.Tx) error { +func downFixAlbumComments(tx *sql.Tx) error { return nil } diff --git a/db/migration/20210430212322_add_bpm_metadata.go b/db/migration/20210430212322_add_bpm_metadata.go new file mode 100644 index 000000000..ab7e40213 --- /dev/null +++ b/db/migration/20210430212322_add_bpm_metadata.go @@ -0,0 +1,30 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(upAddBpmMetadata, downAddBpmMetadata) +} + +func upAddBpmMetadata(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table media_file + add bpm integer; + +create index if not exists media_file_bpm + on media_file (bpm); +`) + if err != nil { + return err + } + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) +} + +func downAddBpmMetadata(tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20210530121921_create_shares_table.go b/db/migration/20210530121921_create_shares_table.go similarity index 65% rename from db/migrations/20210530121921_create_shares_table.go rename to db/migration/20210530121921_create_shares_table.go index d9e902a43..7cd1fb7e6 100644 --- a/db/migrations/20210530121921_create_shares_table.go +++ b/db/migration/20210530121921_create_shares_table.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upCreateSharesTable, downCreateSharesTable) + goose.AddMigration(upCreateSharesTable, downCreateSharesTable) } -func upCreateSharesTable(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upCreateSharesTable(tx *sql.Tx) error { + _, err := tx.Exec(` create table share ( id varchar(255) not null primary key, @@ -30,6 +29,6 @@ create table share return err } -func downCreateSharesTable(_ context.Context, _ *sql.Tx) error { +func downCreateSharesTable(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210601231734_update_share_fieldnames.go b/db/migration/20210601231734_update_share_fieldnames.go similarity index 53% rename from db/migrations/20210601231734_update_share_fieldnames.go rename to db/migration/20210601231734_update_share_fieldnames.go index 5a459a34c..d22f3aa4c 100644 --- a/db/migrations/20210601231734_update_share_fieldnames.go +++ b/db/migration/20210601231734_update_share_fieldnames.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upUpdateShareFieldNames, downUpdateShareFieldNames) + goose.AddMigration(upUpdateShareFieldNames, downUpdateShareFieldNames) } -func upUpdateShareFieldNames(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upUpdateShareFieldNames(tx *sql.Tx) error { + _, err := tx.Exec(` alter table share rename column expires to expires_at; alter table share rename column created to created_at; alter table share rename column last_visited to last_visited_at; @@ -21,6 +20,6 @@ alter table share rename column last_visited to last_visited_at; return err } -func downUpdateShareFieldNames(_ context.Context, _ *sql.Tx) error { +func downUpdateShareFieldNames(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210616150710_encrypt_all_passwords.go b/db/migration/20210616150710_encrypt_all_passwords.go similarity index 73% rename from db/migrations/20210616150710_encrypt_all_passwords.go rename to db/migration/20210616150710_encrypt_all_passwords.go index dc8a9abd4..a05528553 100644 --- a/db/migrations/20210616150710_encrypt_all_passwords.go +++ b/db/migration/20210616150710_encrypt_all_passwords.go @@ -12,11 +12,11 @@ import ( ) func init() { - goose.AddMigrationContext(upEncodeAllPasswords, downEncodeAllPasswords) + goose.AddMigration(upEncodeAllPasswords, downEncodeAllPasswords) } -func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error { - rows, err := tx.QueryContext(ctx, `SELECT id, user_name, password from user;`) +func upEncodeAllPasswords(tx *sql.Tx) error { + rows, err := tx.Query(`SELECT id, user_name, password from user;`) if err != nil { return err } @@ -38,7 +38,7 @@ func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error { return err } - password, err = utils.Encrypt(ctx, encKey, password) + password, err = utils.Encrypt(context.Background(), encKey, password) if err != nil { log.Error("Error encrypting user's password", "id", id, "username", username, err) } @@ -51,6 +51,6 @@ func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error { return rows.Err() } -func downEncodeAllPasswords(_ context.Context, _ *sql.Tx) error { +func downEncodeAllPasswords(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210619231716_drop_player_name_unique_constraint.go b/db/migration/20210619231716_drop_player_name_unique_constraint.go similarity index 76% rename from db/migrations/20210619231716_drop_player_name_unique_constraint.go rename to db/migration/20210619231716_drop_player_name_unique_constraint.go index 734ffc340..5ec388d7e 100644 --- a/db/migrations/20210619231716_drop_player_name_unique_constraint.go +++ b/db/migration/20210619231716_drop_player_name_unique_constraint.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upDropPlayerNameUniqueConstraint, downDropPlayerNameUniqueConstraint) + goose.AddMigration(upDropPlayerNameUniqueConstraint, downDropPlayerNameUniqueConstraint) } -func upDropPlayerNameUniqueConstraint(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upDropPlayerNameUniqueConstraint(tx *sql.Tx) error { + _, err := tx.Exec(` create table player_dg_tmp ( id varchar(255) not null @@ -43,6 +42,6 @@ create index if not exists player_name return err } -func downDropPlayerNameUniqueConstraint(_ context.Context, _ *sql.Tx) error { +func downDropPlayerNameUniqueConstraint(tx *sql.Tx) error { return nil } diff --git a/db/migration/20210623155401_add_user_prefs_player_scrobbler_enabled.go b/db/migration/20210623155401_add_user_prefs_player_scrobbler_enabled.go new file mode 100644 index 000000000..c39157c56 --- /dev/null +++ b/db/migration/20210623155401_add_user_prefs_player_scrobbler_enabled.go @@ -0,0 +1,44 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(upAddUserPrefsPlayerScrobblerEnabled, downAddUserPrefsPlayerScrobblerEnabled) +} + +func upAddUserPrefsPlayerScrobblerEnabled(tx *sql.Tx) error { + err := upAddUserPrefs(tx) + if err != nil { + return err + } + return upPlayerScrobblerEnabled(tx) +} + +func upAddUserPrefs(tx *sql.Tx) error { + _, err := tx.Exec(` +create table user_props +( + user_id varchar not null, + key varchar not null, + value varchar, + constraint user_props_pk + primary key (user_id, key) +); +`) + return err +} + +func upPlayerScrobblerEnabled(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table player add scrobble_enabled bool default true; +`) + return err +} + +func downAddUserPrefsPlayerScrobblerEnabled(tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20210625223901_add_referential_integrity_to_user_props.go b/db/migration/20210625223901_add_referential_integrity_to_user_props.go similarity index 63% rename from db/migrations/20210625223901_add_referential_integrity_to_user_props.go rename to db/migration/20210625223901_add_referential_integrity_to_user_props.go index b2f93b4e3..7c82a4fd9 100644 --- a/db/migrations/20210625223901_add_referential_integrity_to_user_props.go +++ b/db/migration/20210625223901_add_referential_integrity_to_user_props.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upAddReferentialIntegrityToUserProps, downAddReferentialIntegrityToUserProps) + goose.AddMigration(upAddReferentialIntegrityToUserProps, downAddReferentialIntegrityToUserProps) } -func upAddReferentialIntegrityToUserProps(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddReferentialIntegrityToUserProps(tx *sql.Tx) error { + _, err := tx.Exec(` create table user_props_dg_tmp ( user_id varchar not null @@ -34,6 +33,6 @@ alter table user_props_dg_tmp rename to user_props; return err } -func downAddReferentialIntegrityToUserProps(_ context.Context, _ *sql.Tx) error { +func downAddReferentialIntegrityToUserProps(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210626213026_add_scrobble_buffer.go b/db/migration/20210626213026_add_scrobble_buffer.go similarity index 72% rename from db/migrations/20210626213026_add_scrobble_buffer.go rename to db/migration/20210626213026_add_scrobble_buffer.go index 75d9d681c..300f392d4 100644 --- a/db/migrations/20210626213026_add_scrobble_buffer.go +++ b/db/migration/20210626213026_add_scrobble_buffer.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upAddScrobbleBuffer, downAddScrobbleBuffer) + goose.AddMigration(upAddScrobbleBuffer, downAddScrobbleBuffer) } -func upAddScrobbleBuffer(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddScrobbleBuffer(tx *sql.Tx) error { + _, err := tx.Exec(` create table if not exists scrobble_buffer ( user_id varchar not null @@ -34,6 +33,6 @@ create table if not exists scrobble_buffer return err } -func downAddScrobbleBuffer(_ context.Context, _ *sql.Tx) error { +func downAddScrobbleBuffer(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20210715151153_add_genre_tables.go b/db/migration/20210715151153_add_genre_tables.go similarity index 76% rename from db/migrations/20210715151153_add_genre_tables.go rename to db/migration/20210715151153_add_genre_tables.go index 143f9c72b..76978c83d 100644 --- a/db/migrations/20210715151153_add_genre_tables.go +++ b/db/migration/20210715151153_add_genre_tables.go @@ -1,19 +1,18 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upAddGenreTables, downAddGenreTables) + goose.AddMigration(upAddGenreTables, downAddGenreTables) } -func upAddGenreTables(ctx context.Context, tx *sql.Tx) error { - notice(ctx, tx, "A full rescan will be performed to import multiple genres!") - _, err := tx.ExecContext(ctx, ` +func upAddGenreTables(tx *sql.Tx) error { + notice(tx, "A full rescan will be performed to import multiple genres!") + _, err := tx.Exec(` create table if not exists genre ( id varchar not null primary key, @@ -61,9 +60,9 @@ create table if not exists artist_genres if err != nil { return err } - return forceFullRescan(ctx, tx) + return forceFullRescan(tx) } -func downAddGenreTables(_ context.Context, _ *sql.Tx) error { +func downAddGenreTables(tx *sql.Tx) error { return nil } diff --git a/db/migration/20210821212604_add_mediafile_channels.go b/db/migration/20210821212604_add_mediafile_channels.go new file mode 100644 index 000000000..9590e0966 --- /dev/null +++ b/db/migration/20210821212604_add_mediafile_channels.go @@ -0,0 +1,30 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(upAddMediafileChannels, downAddMediafileChannels) +} + +func upAddMediafileChannels(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table media_file + add channels integer; + +create index if not exists media_file_channels + on media_file (channels); +`) + if err != nil { + return err + } + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) +} + +func downAddMediafileChannels(tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20211008205505_add_smart_playlist.go b/db/migration/20211008205505_add_smart_playlist.go similarity index 71% rename from db/migrations/20211008205505_add_smart_playlist.go rename to db/migration/20211008205505_add_smart_playlist.go index 0d2d1ad4e..fa14237e1 100644 --- a/db/migrations/20211008205505_add_smart_playlist.go +++ b/db/migration/20211008205505_add_smart_playlist.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upAddSmartPlaylist, downAddSmartPlaylist) + goose.AddMigration(upAddSmartPlaylist, downAddSmartPlaylist) } -func upAddSmartPlaylist(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddSmartPlaylist(tx *sql.Tx) error { + _, err := tx.Exec(` alter table playlist add column rules varchar null; alter table playlist @@ -33,6 +32,6 @@ create unique index playlist_fields_idx return err } -func downAddSmartPlaylist(_ context.Context, _ *sql.Tx) error { +func downAddSmartPlaylist(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20211023184825_add_order_title_to_media_file.go b/db/migration/20211023184825_add_order_title_to_media_file.go similarity index 65% rename from db/migrations/20211023184825_add_order_title_to_media_file.go rename to db/migration/20211023184825_add_order_title_to_media_file.go index 4a2ae4047..9374d3332 100644 --- a/db/migrations/20211023184825_add_order_title_to_media_file.go +++ b/db/migration/20211023184825_add_order_title_to_media_file.go @@ -1,7 +1,6 @@ package migrations import ( - "context" "database/sql" "strings" @@ -11,11 +10,11 @@ import ( ) func init() { - goose.AddMigrationContext(upAddOrderTitleToMediaFile, downAddOrderTitleToMediaFile) + goose.AddMigration(upAddOrderTitleToMediaFile, downAddOrderTitleToMediaFile) } -func upAddOrderTitleToMediaFile(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddOrderTitleToMediaFile(tx *sql.Tx) error { + _, err := tx.Exec(` alter table main.media_file add order_title varchar null collate NOCASE; create index if not exists media_file_order_title @@ -25,12 +24,12 @@ create index if not exists media_file_order_title return err } - return upAddOrderTitleToMediaFile_populateOrderTitle(ctx, tx) + return upAddOrderTitleToMediaFile_populateOrderTitle(tx) } //goland:noinspection GoSnakeCaseUsage -func upAddOrderTitleToMediaFile_populateOrderTitle(ctx context.Context, tx *sql.Tx) error { - rows, err := tx.QueryContext(ctx, `select id, title from media_file`) +func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error { + rows, err := tx.Query(`select id, title from media_file`) if err != nil { return err } @@ -57,6 +56,6 @@ func upAddOrderTitleToMediaFile_populateOrderTitle(ctx context.Context, tx *sql. return rows.Err() } -func downAddOrderTitleToMediaFile(_ context.Context, _ *sql.Tx) error { +func downAddOrderTitleToMediaFile(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20211026191915_unescape_lyrics_and_comments.go b/db/migration/20211026191915_unescape_lyrics_and_comments.go similarity index 58% rename from db/migrations/20211026191915_unescape_lyrics_and_comments.go rename to db/migration/20211026191915_unescape_lyrics_and_comments.go index a7969ffed..b14e1f836 100644 --- a/db/migrations/20211026191915_unescape_lyrics_and_comments.go +++ b/db/migration/20211026191915_unescape_lyrics_and_comments.go @@ -1,20 +1,19 @@ package migrations import ( - "context" "database/sql" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/str" + "github.com/navidrome/navidrome/utils" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upUnescapeLyricsAndComments, downUnescapeLyricsAndComments) + goose.AddMigration(upUnescapeLyricsAndComments, downUnescapeLyricsAndComments) } -func upUnescapeLyricsAndComments(ctx context.Context, tx *sql.Tx) error { - rows, err := tx.QueryContext(ctx, `select id, comment, lyrics, title from media_file`) +func upUnescapeLyricsAndComments(tx *sql.Tx) error { + rows, err := tx.Query(`select id, comment, lyrics, title from media_file`) if err != nil { return err } @@ -33,8 +32,8 @@ func upUnescapeLyricsAndComments(ctx context.Context, tx *sql.Tx) error { return err } - newComment := str.SanitizeText(comment.String) - newLyrics := str.SanitizeText(lyrics.String) + newComment := utils.SanitizeText(comment.String) + newLyrics := utils.SanitizeText(lyrics.String) _, err = stmt.Exec(newComment, newLyrics, id) if err != nil { log.Error("Error unescaping media_file's lyrics and comments", "title", title, "id", id, err) @@ -43,6 +42,6 @@ func upUnescapeLyricsAndComments(ctx context.Context, tx *sql.Tx) error { return rows.Err() } -func downUnescapeLyricsAndComments(_ context.Context, _ *sql.Tx) error { +func downUnescapeLyricsAndComments(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20211029213200_add_userid_to_playlist.go b/db/migration/20211029213200_add_userid_to_playlist.go similarity index 84% rename from db/migrations/20211029213200_add_userid_to_playlist.go rename to db/migration/20211029213200_add_userid_to_playlist.go index 909ea1c54..f408a8e45 100644 --- a/db/migrations/20211029213200_add_userid_to_playlist.go +++ b/db/migration/20211029213200_add_userid_to_playlist.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upAddUseridToPlaylist, downAddUseridToPlaylist) + goose.AddMigration(upAddUseridToPlaylist, downAddUseridToPlaylist) } -func upAddUseridToPlaylist(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddUseridToPlaylist(tx *sql.Tx) error { + _, err := tx.Exec(` create table playlist_dg_tmp ( id varchar(255) not null @@ -56,6 +55,6 @@ create index playlist_updated_at return err } -func downAddUseridToPlaylist(_ context.Context, _ *sql.Tx) error { +func downAddUseridToPlaylist(tx *sql.Tx) error { return nil } diff --git a/db/migration/20211102215414_add_alphabetical_by_artist_index.go b/db/migration/20211102215414_add_alphabetical_by_artist_index.go new file mode 100644 index 000000000..3139b7a1e --- /dev/null +++ b/db/migration/20211102215414_add_alphabetical_by_artist_index.go @@ -0,0 +1,23 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(upAddAlphabeticalByArtistIndex, downAddAlphabeticalByArtistIndex) +} + +func upAddAlphabeticalByArtistIndex(tx *sql.Tx) error { + _, err := tx.Exec(` +create index album_alphabetical_by_artist + ON album(compilation, order_album_artist_name, order_album_name) +`) + return err +} + +func downAddAlphabeticalByArtistIndex(tx *sql.Tx) error { + return nil +} diff --git a/db/migration/20211105162746_remove_invalid_artist_ids.go b/db/migration/20211105162746_remove_invalid_artist_ids.go new file mode 100644 index 000000000..6388a4cab --- /dev/null +++ b/db/migration/20211105162746_remove_invalid_artist_ids.go @@ -0,0 +1,22 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(upRemoveInvalidArtistIds, downRemoveInvalidArtistIds) +} + +func upRemoveInvalidArtistIds(tx *sql.Tx) error { + _, err := tx.Exec(` +update media_file set artist_id = '' where not exists(select 1 from artist where id = artist_id) +`) + return err +} + +func downRemoveInvalidArtistIds(tx *sql.Tx) error { + return nil +} diff --git a/db/migration/20220724231849_add_musicbrainz_release_track_id.go b/db/migration/20220724231849_add_musicbrainz_release_track_id.go new file mode 100644 index 000000000..660b41d1a --- /dev/null +++ b/db/migration/20220724231849_add_musicbrainz_release_track_id.go @@ -0,0 +1,28 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(upAddMusicbrainzReleaseTrackId, downAddMusicbrainzReleaseTrackId) +} + +func upAddMusicbrainzReleaseTrackId(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table media_file + add mbz_release_track_id varchar(255); +`) + if err != nil { + return err + } + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) +} + +func downAddMusicbrainzReleaseTrackId(tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/db/migration/20221219112733_add_album_image_paths.go b/db/migration/20221219112733_add_album_image_paths.go new file mode 100644 index 000000000..5654404ab --- /dev/null +++ b/db/migration/20221219112733_add_album_image_paths.go @@ -0,0 +1,26 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(upAddAlbumImagePaths, downAddAlbumImagePaths) +} + +func upAddAlbumImagePaths(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table main.album add image_files varchar; +`) + if err != nil { + return err + } + notice(tx, "A full rescan needs to be performed to import all album images") + return forceFullRescan(tx) +} + +func downAddAlbumImagePaths(tx *sql.Tx) error { + return nil +} diff --git a/db/migration/20221219140528_remove_cover_art_id.go b/db/migration/20221219140528_remove_cover_art_id.go new file mode 100644 index 000000000..7ff7bb3fb --- /dev/null +++ b/db/migration/20221219140528_remove_cover_art_id.go @@ -0,0 +1,27 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(upRemoveCoverArtId, downRemoveCoverArtId) +} + +func upRemoveCoverArtId(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table album drop column cover_art_id; +alter table album rename column cover_art_path to embed_art_path +`) + if err != nil { + return err + } + notice(tx, "A full rescan needs to be performed to import all album images") + return forceFullRescan(tx) +} + +func downRemoveCoverArtId(tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20230112111457_add_album_paths.go b/db/migration/20230112111457_add_album_paths.go similarity index 72% rename from db/migrations/20230112111457_add_album_paths.go rename to db/migration/20230112111457_add_album_paths.go index 2819522a1..d421f4a38 100644 --- a/db/migrations/20230112111457_add_album_paths.go +++ b/db/migration/20230112111457_add_album_paths.go @@ -1,30 +1,29 @@ package migrations import ( - "context" "database/sql" "path/filepath" - "slices" "strings" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/pressly/goose/v3" + "golang.org/x/exp/slices" ) func init() { - goose.AddMigrationContext(upAddAlbumPaths, downAddAlbumPaths) + goose.AddMigration(upAddAlbumPaths, downAddAlbumPaths) } -func upAddAlbumPaths(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `alter table album add paths varchar;`) +func upAddAlbumPaths(tx *sql.Tx) error { + _, err := tx.Exec(`alter table album add paths varchar;`) if err != nil { return err } //nolint:gosec - rows, err := tx.QueryContext(ctx, ` - select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id + rows, err := tx.Query(` + select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id `) if err != nil { return err @@ -63,6 +62,6 @@ func upAddAlbumPathsDirs(filePaths string) string { return strings.Join(dirs, string(filepath.ListSeparator)) } -func downAddAlbumPaths(_ context.Context, _ *sql.Tx) error { +func downAddAlbumPaths(tx *sql.Tx) error { return nil } diff --git a/db/migration/20230114121537_touch_playlists.go b/db/migration/20230114121537_touch_playlists.go new file mode 100644 index 000000000..e07a48c9a --- /dev/null +++ b/db/migration/20230114121537_touch_playlists.go @@ -0,0 +1,20 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(upTouchPlaylists, downTouchPlaylists) +} + +func upTouchPlaylists(tx *sql.Tx) error { + _, err := tx.Exec(`update playlist set updated_at = datetime('now');`) + return err +} + +func downTouchPlaylists(tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20230115103212_create_internet_radio.go b/db/migration/20230115103212_create_internet_radio.go similarity index 60% rename from db/migrations/20230115103212_create_internet_radio.go rename to db/migration/20230115103212_create_internet_radio.go index 3e0da348f..231e9c0ae 100644 --- a/db/migrations/20230115103212_create_internet_radio.go +++ b/db/migration/20230115103212_create_internet_radio.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upCreateInternetRadio, downCreateInternetRadio) + goose.AddMigration(upCreateInternetRadio, downCreateInternetRadio) } -func upCreateInternetRadio(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upCreateInternetRadio(tx *sql.Tx) error { + _, err := tx.Exec(` create table if not exists radio ( id varchar(255) not null primary key, @@ -26,6 +25,6 @@ create table if not exists radio return err } -func downCreateInternetRadio(_ context.Context, _ *sql.Tx) error { +func downCreateInternetRadio(tx *sql.Tx) error { return nil } diff --git a/db/migration/20230117155559_add_replaygain_metadata.go b/db/migration/20230117155559_add_replaygain_metadata.go new file mode 100644 index 000000000..340449dca --- /dev/null +++ b/db/migration/20230117155559_add_replaygain_metadata.go @@ -0,0 +1,34 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(upAddReplaygainMetadata, downAddReplaygainMetadata) +} + +func upAddReplaygainMetadata(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table media_file add + rg_album_gain real; +alter table media_file add + rg_album_peak real; +alter table media_file add + rg_track_gain real; +alter table media_file add + rg_track_peak real; +`) + if err != nil { + return err + } + + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) +} + +func downAddReplaygainMetadata(tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20230117180400_add_album_info.go b/db/migration/20230117180400_add_album_info.go similarity index 70% rename from db/migrations/20230117180400_add_album_info.go rename to db/migration/20230117180400_add_album_info.go index 750d3838f..877fabee5 100644 --- a/db/migrations/20230117180400_add_album_info.go +++ b/db/migration/20230117180400_add_album_info.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upAddAlbumInfo, downAddAlbumInfo) + goose.AddMigration(upAddAlbumInfo, downAddAlbumInfo) } -func upAddAlbumInfo(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddAlbumInfo(tx *sql.Tx) error { + _, err := tx.Exec(` alter table album add description varchar(255) default '' not null; alter table album @@ -29,6 +28,6 @@ alter table album return err } -func downAddAlbumInfo(_ context.Context, _ *sql.Tx) error { +func downAddAlbumInfo(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20230119152657_recreate_share_table.go b/db/migration/20230119152657_recreate_share_table.go similarity index 73% rename from db/migrations/20230119152657_recreate_share_table.go rename to db/migration/20230119152657_recreate_share_table.go index 10eff31ca..44bdf546b 100644 --- a/db/migrations/20230119152657_recreate_share_table.go +++ b/db/migration/20230119152657_recreate_share_table.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upAddMissingShareInfo, downAddMissingShareInfo) + goose.AddMigration(upAddMissingShareInfo, downAddMissingShareInfo) } -func upAddMissingShareInfo(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddMissingShareInfo(tx *sql.Tx) error { + _, err := tx.Exec(` drop table if exists share; create table share ( @@ -37,6 +36,6 @@ create table share return err } -func downAddMissingShareInfo(_ context.Context, _ *sql.Tx) error { +func downAddMissingShareInfo(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20230202143713_change_path_list_separator.go b/db/migration/20230202143713_change_path_list_separator.go similarity index 73% rename from db/migrations/20230202143713_change_path_list_separator.go rename to db/migration/20230202143713_change_path_list_separator.go index fb5f2be1a..be70a0c50 100644 --- a/db/migrations/20230202143713_change_path_list_separator.go +++ b/db/migration/20230202143713_change_path_list_separator.go @@ -1,25 +1,24 @@ package migrations import ( - "context" "database/sql" "path/filepath" - "slices" "strings" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/pressly/goose/v3" + "golang.org/x/exp/slices" ) func init() { - goose.AddMigrationContext(upChangePathListSeparator, downChangePathListSeparator) + goose.AddMigration(upChangePathListSeparator, downChangePathListSeparator) } -func upChangePathListSeparator(ctx context.Context, tx *sql.Tx) error { +func upChangePathListSeparator(tx *sql.Tx) error { //nolint:gosec - rows, err := tx.QueryContext(ctx, ` - select album_id, group_concat(path, '`+consts.Zwsp+`') from media_file group by album_id + rows, err := tx.Query(` + select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id `) if err != nil { return err @@ -58,6 +57,6 @@ func upChangePathListSeparatorDirs(filePaths string) string { return strings.Join(dirs, consts.Zwsp) } -func downChangePathListSeparator(_ context.Context, _ *sql.Tx) error { +func downChangePathListSeparator(tx *sql.Tx) error { return nil } diff --git a/db/migrations/20230209181414_change_image_files_list_separator.go b/db/migration/20230209181414_change_image_files_list_separator.go similarity index 75% rename from db/migrations/20230209181414_change_image_files_list_separator.go rename to db/migration/20230209181414_change_image_files_list_separator.go index e5dc4ab43..b0a3e38cb 100644 --- a/db/migrations/20230209181414_change_image_files_list_separator.go +++ b/db/migration/20230209181414_change_image_files_list_separator.go @@ -1,23 +1,23 @@ package migrations import ( - "context" "database/sql" "path/filepath" - "slices" "strings" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/pressly/goose/v3" + "golang.org/x/exp/slices" ) func init() { - goose.AddMigrationContext(upChangeImageFilesListSeparator, downChangeImageFilesListSeparator) + goose.AddMigration(upChangeImageFilesListSeparator, downChangeImageFilesListSeparator) } -func upChangeImageFilesListSeparator(ctx context.Context, tx *sql.Tx) error { - rows, err := tx.QueryContext(ctx, `select id, image_files from album`) +func upChangeImageFilesListSeparator(tx *sql.Tx) error { + //nolint:gosec + rows, err := tx.Query(`select id, image_files from album`) if err != nil { return err } @@ -54,7 +54,7 @@ func upChangeImageFilesListSeparatorDirs(filePaths string) string { return strings.Join(allPaths, consts.Zwsp) } -func downChangeImageFilesListSeparator(ctx context.Context, tx *sql.Tx) error { +func downChangeImageFilesListSeparator(tx *sql.Tx) error { // This code is executed when the migration is rolled back. return nil } diff --git a/db/migration/20230310222612_add_download_to_share.go b/db/migration/20230310222612_add_download_to_share.go new file mode 100644 index 000000000..f6d6bced8 --- /dev/null +++ b/db/migration/20230310222612_add_download_to_share.go @@ -0,0 +1,23 @@ +package migrations + +import ( + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigration(upAddDownloadToShare, downAddDownloadToShare) +} + +func upAddDownloadToShare(tx *sql.Tx) error { + _, err := tx.Exec(` +alter table share + add downloadable bool not null default false; +`) + return err +} + +func downAddDownloadToShare(tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20230404104309_empty_sql_migration.sql b/db/migration/20230404104309_empty_sql_migration.sql similarity index 100% rename from db/migrations/20230404104309_empty_sql_migration.sql rename to db/migration/20230404104309_empty_sql_migration.sql diff --git a/db/migrations/20230515184510_add_release_date.go b/db/migration/20230515184510_add_release_date.go similarity index 73% rename from db/migrations/20230515184510_add_release_date.go rename to db/migration/20230515184510_add_release_date.go index f22bdfae8..f2aaebe4b 100644 --- a/db/migrations/20230515184510_add_release_date.go +++ b/db/migration/20230515184510_add_release_date.go @@ -1,18 +1,17 @@ package migrations import ( - "context" "database/sql" "github.com/pressly/goose/v3" ) func init() { - goose.AddMigrationContext(upAddRelRecYear, downAddRelRecYear) + goose.AddMigration(upAddRelRecYear, downAddRelRecYear) } -func upAddRelRecYear(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` +func upAddRelRecYear(tx *sql.Tx) error { + _, err := tx.Exec(` alter table media_file add date varchar(255) default '' not null; alter table media_file @@ -41,10 +40,10 @@ alter table album return err } - notice(ctx, tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(ctx, tx) + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) } -func downAddRelRecYear(_ context.Context, _ *sql.Tx) error { +func downAddRelRecYear(tx *sql.Tx) error { return nil } diff --git a/db/migration/migration.go b/db/migration/migration.go new file mode 100644 index 000000000..8e648f1fd --- /dev/null +++ b/db/migration/migration.go @@ -0,0 +1,58 @@ +package migrations + +import ( + "database/sql" + "fmt" + "sync" + + "github.com/navidrome/navidrome/consts" +) + +// Use this in migrations that need to communicate something important (breaking changes, forced reindexes, etc...) +func notice(tx *sql.Tx, msg string) { + if isDBInitialized(tx) { + fmt.Printf(` +************************************************************************************* +NOTICE: %s +************************************************************************************* + +`, msg) + } +} + +// Call this in migrations that requires a full rescan +func forceFullRescan(tx *sql.Tx) error { + _, err := tx.Exec(` +delete from property where id like 'LastScan%'; +update media_file set updated_at = '0001-01-01'; +`) + return err +} + +var ( + once sync.Once + initialized bool +) + +func isDBInitialized(tx *sql.Tx) bool { + once.Do(func() { + rows, err := tx.Query("select count(*) from property where id=?", consts.InitialSetupFlagKey) + checkErr(err) + initialized = checkCount(rows) > 0 + }) + return initialized +} + +func checkCount(rows *sql.Rows) (count int) { + for rows.Next() { + err := rows.Scan(&count) + checkErr(err) + } + return count +} + +func checkErr(err error) { + if err != nil { + panic(err) + } +} diff --git a/db/migrations/20200310171621_enable_search_by_albumartist.go b/db/migrations/20200310171621_enable_search_by_albumartist.go deleted file mode 100644 index 73436c890..000000000 --- a/db/migrations/20200310171621_enable_search_by_albumartist.go +++ /dev/null @@ -1,21 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(Up20200310171621, Down20200310171621) -} - -func Up20200310171621(ctx context.Context, tx *sql.Tx) error { - notice(ctx, tx, "A full rescan will be performed to enable search by Album Artist!") - return forceFullRescan(ctx, tx) -} - -func Down20200310171621(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20200326090707_fix_album_artists_importing.go b/db/migrations/20200326090707_fix_album_artists_importing.go deleted file mode 100644 index 17afe37fe..000000000 --- a/db/migrations/20200326090707_fix_album_artists_importing.go +++ /dev/null @@ -1,21 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(Up20200326090707, Down20200326090707) -} - -func Up20200326090707(ctx context.Context, tx *sql.Tx) error { - notice(ctx, tx, "A full rescan will be performed!") - return forceFullRescan(ctx, tx) -} - -func Down20200326090707(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20200409002249_enable_search_by_tracks_artists.go b/db/migrations/20200409002249_enable_search_by_tracks_artists.go deleted file mode 100644 index 482341a89..000000000 --- a/db/migrations/20200409002249_enable_search_by_tracks_artists.go +++ /dev/null @@ -1,21 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(Up20200409002249, Down20200409002249) -} - -func Up20200409002249(ctx context.Context, tx *sql.Tx) error { - notice(ctx, tx, "A full rescan will be performed to enable search by individual Artist in an Album!") - return forceFullRescan(ctx, tx) -} - -func Down20200409002249(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20200418110522_reindex_to_fix_album_years.go b/db/migrations/20200418110522_reindex_to_fix_album_years.go deleted file mode 100644 index 54e03f4c6..000000000 --- a/db/migrations/20200418110522_reindex_to_fix_album_years.go +++ /dev/null @@ -1,21 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(Up20200418110522, Down20200418110522) -} - -func Up20200418110522(ctx context.Context, tx *sql.Tx) error { - notice(ctx, tx, "A full rescan will be performed to fix search Albums by year") - return forceFullRescan(ctx, tx) -} - -func Down20200418110522(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20200419222708_reindex_to_change_full_text_search.go b/db/migrations/20200419222708_reindex_to_change_full_text_search.go deleted file mode 100644 index 89e3ccee5..000000000 --- a/db/migrations/20200419222708_reindex_to_change_full_text_search.go +++ /dev/null @@ -1,21 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(Up20200419222708, Down20200419222708) -} - -func Up20200419222708(ctx context.Context, tx *sql.Tx) error { - notice(ctx, tx, "A full rescan will be performed to change the search behaviour") - return forceFullRescan(ctx, tx) -} - -func Down20200419222708(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20200508093059_add_artist_song_count.go b/db/migrations/20200508093059_add_artist_song_count.go deleted file mode 100644 index 72a47bc94..000000000 --- a/db/migrations/20200508093059_add_artist_song_count.go +++ /dev/null @@ -1,28 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(Up20200508093059, Down20200508093059) -} - -func Up20200508093059(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table artist - add song_count integer default 0 not null; -`) - if err != nil { - return err - } - notice(ctx, tx, "A full rescan will be performed to calculate artists' song counts") - return forceFullRescan(ctx, tx) -} - -func Down20200508093059(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20200512104202_add_disc_subtitle.go b/db/migrations/20200512104202_add_disc_subtitle.go deleted file mode 100644 index 29734e0c0..000000000 --- a/db/migrations/20200512104202_add_disc_subtitle.go +++ /dev/null @@ -1,28 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(Up20200512104202, Down20200512104202) -} - -func Up20200512104202(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table media_file - add disc_subtitle varchar(255); - `) - if err != nil { - return err - } - notice(ctx, tx, "A full rescan will be performed to import disc subtitles") - return forceFullRescan(ctx, tx) -} - -func Down20200512104202(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20201003111749_add_starred_at_index.go b/db/migrations/20201003111749_add_starred_at_index.go deleted file mode 100644 index b46430743..000000000 --- a/db/migrations/20201003111749_add_starred_at_index.go +++ /dev/null @@ -1,24 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(Up20201003111749, Down20201003111749) -} - -func Up20201003111749(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -create index if not exists annotation_starred_at - on annotation (starred_at); - `) - return err -} - -func Down20201003111749(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20201021135455_add_media_file_artist_index.go b/db/migrations/20201021135455_add_media_file_artist_index.go deleted file mode 100644 index e8f22c3a7..000000000 --- a/db/migrations/20201021135455_add_media_file_artist_index.go +++ /dev/null @@ -1,24 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(Up20201021135455, Down20201021135455) -} - -func Up20201021135455(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -create index if not exists media_file_artist_id - on media_file (artist_id); -`) - return err -} - -func Down20201021135455(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20201110205344_add_comments_and_lyrics.go b/db/migrations/20201110205344_add_comments_and_lyrics.go deleted file mode 100644 index c60917bdd..000000000 --- a/db/migrations/20201110205344_add_comments_and_lyrics.go +++ /dev/null @@ -1,33 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(Up20201110205344, Down20201110205344) -} - -func Up20201110205344(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table media_file - add comment varchar; -alter table media_file - add lyrics varchar; - -alter table album - add comment varchar; -`) - if err != nil { - return err - } - notice(ctx, tx, "A full rescan will be performed to import comments and lyrics") - return forceFullRescan(ctx, tx) -} - -func Down20201110205344(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20201128100726_add_real-path_option.go b/db/migrations/20201128100726_add_real-path_option.go deleted file mode 100644 index 4b3f62128..000000000 --- a/db/migrations/20201128100726_add_real-path_option.go +++ /dev/null @@ -1,24 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(Up20201128100726, Down20201128100726) -} - -func Up20201128100726(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table player - add report_real_path bool default FALSE not null; -`) - return err -} - -func Down20201128100726(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20210430212322_add_bpm_metadata.go b/db/migrations/20210430212322_add_bpm_metadata.go deleted file mode 100644 index 00a0f1447..000000000 --- a/db/migrations/20210430212322_add_bpm_metadata.go +++ /dev/null @@ -1,31 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddBpmMetadata, downAddBpmMetadata) -} - -func upAddBpmMetadata(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table media_file - add bpm integer; - -create index if not exists media_file_bpm - on media_file (bpm); -`) - if err != nil { - return err - } - notice(ctx, tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(ctx, tx) -} - -func downAddBpmMetadata(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go b/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go deleted file mode 100644 index aa5e7a8f0..000000000 --- a/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go +++ /dev/null @@ -1,45 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddUserPrefsPlayerScrobblerEnabled, downAddUserPrefsPlayerScrobblerEnabled) -} - -func upAddUserPrefsPlayerScrobblerEnabled(ctx context.Context, tx *sql.Tx) error { - err := upAddUserPrefs(ctx, tx) - if err != nil { - return err - } - return upPlayerScrobblerEnabled(ctx, tx) -} - -func upAddUserPrefs(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -create table user_props -( - user_id varchar not null, - key varchar not null, - value varchar, - constraint user_props_pk - primary key (user_id, key) -); -`) - return err -} - -func upPlayerScrobblerEnabled(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table player add scrobble_enabled bool default true; -`) - return err -} - -func downAddUserPrefsPlayerScrobblerEnabled(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20210821212604_add_mediafile_channels.go b/db/migrations/20210821212604_add_mediafile_channels.go deleted file mode 100644 index ee18be01b..000000000 --- a/db/migrations/20210821212604_add_mediafile_channels.go +++ /dev/null @@ -1,31 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddMediafileChannels, downAddMediafileChannels) -} - -func upAddMediafileChannels(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table media_file - add channels integer; - -create index if not exists media_file_channels - on media_file (channels); -`) - if err != nil { - return err - } - notice(ctx, tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(ctx, tx) -} - -func downAddMediafileChannels(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20211102215414_add_alphabetical_by_artist_index.go b/db/migrations/20211102215414_add_alphabetical_by_artist_index.go deleted file mode 100644 index f786b69e7..000000000 --- a/db/migrations/20211102215414_add_alphabetical_by_artist_index.go +++ /dev/null @@ -1,24 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddAlphabeticalByArtistIndex, downAddAlphabeticalByArtistIndex) -} - -func upAddAlphabeticalByArtistIndex(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -create index album_alphabetical_by_artist - ON album(compilation, order_album_artist_name, order_album_name) -`) - return err -} - -func downAddAlphabeticalByArtistIndex(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20211105162746_remove_invalid_artist_ids.go b/db/migrations/20211105162746_remove_invalid_artist_ids.go deleted file mode 100644 index 8c9887dd1..000000000 --- a/db/migrations/20211105162746_remove_invalid_artist_ids.go +++ /dev/null @@ -1,23 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upRemoveInvalidArtistIds, downRemoveInvalidArtistIds) -} - -func upRemoveInvalidArtistIds(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -update media_file set artist_id = '' where not exists(select 1 from artist where id = artist_id) -`) - return err -} - -func downRemoveInvalidArtistIds(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20220724231849_add_musicbrainz_release_track_id.go b/db/migrations/20220724231849_add_musicbrainz_release_track_id.go deleted file mode 100644 index 42e13a1e5..000000000 --- a/db/migrations/20220724231849_add_musicbrainz_release_track_id.go +++ /dev/null @@ -1,29 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddMusicbrainzReleaseTrackId, downAddMusicbrainzReleaseTrackId) -} - -func upAddMusicbrainzReleaseTrackId(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table media_file - add mbz_release_track_id varchar(255); -`) - if err != nil { - return err - } - notice(ctx, tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(ctx, tx) -} - -func downAddMusicbrainzReleaseTrackId(ctx context.Context, tx *sql.Tx) error { - // This code is executed when the migration is rolled back. - return nil -} diff --git a/db/migrations/20221219112733_add_album_image_paths.go b/db/migrations/20221219112733_add_album_image_paths.go deleted file mode 100644 index f8ebd40e9..000000000 --- a/db/migrations/20221219112733_add_album_image_paths.go +++ /dev/null @@ -1,27 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddAlbumImagePaths, downAddAlbumImagePaths) -} - -func upAddAlbumImagePaths(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table main.album add image_files varchar; -`) - if err != nil { - return err - } - notice(ctx, tx, "A full rescan needs to be performed to import all album images") - return forceFullRescan(ctx, tx) -} - -func downAddAlbumImagePaths(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20221219140528_remove_cover_art_id.go b/db/migrations/20221219140528_remove_cover_art_id.go deleted file mode 100644 index 30f86297a..000000000 --- a/db/migrations/20221219140528_remove_cover_art_id.go +++ /dev/null @@ -1,28 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upRemoveCoverArtId, downRemoveCoverArtId) -} - -func upRemoveCoverArtId(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table album drop column cover_art_id; -alter table album rename column cover_art_path to embed_art_path -`) - if err != nil { - return err - } - notice(ctx, tx, "A full rescan needs to be performed to import all album images") - return forceFullRescan(ctx, tx) -} - -func downRemoveCoverArtId(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20230114121537_touch_playlists.go b/db/migrations/20230114121537_touch_playlists.go deleted file mode 100644 index 71959b0a8..000000000 --- a/db/migrations/20230114121537_touch_playlists.go +++ /dev/null @@ -1,21 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upTouchPlaylists, downTouchPlaylists) -} - -func upTouchPlaylists(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `update playlist set updated_at = datetime('now');`) - return err -} - -func downTouchPlaylists(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20230117155559_add_replaygain_metadata.go b/db/migrations/20230117155559_add_replaygain_metadata.go deleted file mode 100644 index 3aad70925..000000000 --- a/db/migrations/20230117155559_add_replaygain_metadata.go +++ /dev/null @@ -1,35 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddReplaygainMetadata, downAddReplaygainMetadata) -} - -func upAddReplaygainMetadata(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table media_file add - rg_album_gain real; -alter table media_file add - rg_album_peak real; -alter table media_file add - rg_track_gain real; -alter table media_file add - rg_track_peak real; -`) - if err != nil { - return err - } - - notice(ctx, tx, "A full rescan needs to be performed to import more tags") - return forceFullRescan(ctx, tx) -} - -func downAddReplaygainMetadata(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20230310222612_add_download_to_share.go b/db/migrations/20230310222612_add_download_to_share.go deleted file mode 100644 index 3ee24cc77..000000000 --- a/db/migrations/20230310222612_add_download_to_share.go +++ /dev/null @@ -1,24 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddDownloadToShare, downAddDownloadToShare) -} - -func upAddDownloadToShare(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table share - add downloadable bool not null default false; -`) - return err -} - -func downAddDownloadToShare(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20230616214944_rename_musicbrainz_recording_id.go b/db/migrations/20230616214944_rename_musicbrainz_recording_id.go deleted file mode 100644 index 562a59bb5..000000000 --- a/db/migrations/20230616214944_rename_musicbrainz_recording_id.go +++ /dev/null @@ -1,28 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upRenameMusicbrainzRecordingId, downRenameMusicbrainzRecordingId) -} - -func upRenameMusicbrainzRecordingId(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table media_file - rename column mbz_track_id to mbz_recording_id; -`) - return err -} - -func downRenameMusicbrainzRecordingId(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table media_file - rename column mbz_recording_id to mbz_track_id; -`) - return err -} diff --git a/db/migrations/20231208182311_add_discs_to_album.go b/db/migrations/20231208182311_add_discs_to_album.go deleted file mode 100644 index e4bf9a778..000000000 --- a/db/migrations/20231208182311_add_discs_to_album.go +++ /dev/null @@ -1,36 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddDiscToAlbum, downAddDiscToAlbum) -} - -func upAddDiscToAlbum(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `alter table album add discs JSONB default '{}';`) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, ` -update album set discs = t.discs -from (select album_id, json_group_object(disc_number, disc_subtitle) as discs - from (select distinct album_id, disc_number, disc_subtitle - from media_file - where disc_number > 0 - order by album_id, disc_number) - group by album_id - having discs <> '{"1":""}') as t -where album.id = t.album_id; -`) - return err -} - -func downAddDiscToAlbum(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `alter table album drop discs;`) - return err -} diff --git a/db/migrations/20231209211223_alter_lyric_column.go b/db/migrations/20231209211223_alter_lyric_column.go deleted file mode 100644 index 7f1ad2f38..000000000 --- a/db/migrations/20231209211223_alter_lyric_column.go +++ /dev/null @@ -1,82 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - "encoding/json" - - "github.com/navidrome/navidrome/model" - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAlterLyricColumn, downAlterLyricColumn) -} - -func upAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `alter table media_file rename COLUMN lyrics TO lyrics_old`) - if err != nil { - return err - } - - _, err = tx.ExecContext(ctx, `alter table media_file add lyrics JSONB default '[]';`) - if err != nil { - return err - } - - stmt, err := tx.Prepare(`update media_file SET lyrics = ? where id = ?`) - if err != nil { - return err - } - - rows, err := tx.QueryContext(ctx, `select id, lyrics_old FROM media_file WHERE lyrics_old <> '';`) - if err != nil { - return err - } - - var id string - var lyrics sql.NullString - for rows.Next() { - err = rows.Scan(&id, &lyrics) - if err != nil { - return err - } - - if !lyrics.Valid { - continue - } - - parsed, err := model.ParseLyrics(ctx, ".lrc", "xxx", []byte(lyrics.String)) - if err != nil { - return err - } - - text, err := json.Marshal(parsed) - if err != nil { - return err - } - - _, err = stmt.Exec(string(text), id) - if err != nil { - return err - } - } - - err = rows.Err() - if err != nil { - return err - } - - _, err = tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN lyrics_old;`) - if err != nil { - return err - } - - notice(ctx, tx, "A full rescan should be performed to pick up additional lyrics (existing lyrics have been preserved)") - return nil -} - -func downAlterLyricColumn(ctx context.Context, tx *sql.Tx) error { - // This code is executed when the migration is rolled back. - return nil -} diff --git a/db/migrations/20240122223340_add_default_values_to_null_columns.go.go b/db/migrations/20240122223340_add_default_values_to_null_columns.go.go deleted file mode 100644 index 518d125e4..000000000 --- a/db/migrations/20240122223340_add_default_values_to_null_columns.go.go +++ /dev/null @@ -1,563 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(Up20240122223340, Down20240122223340) -} - -func Up20240122223340(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -drop index if exists album_alphabetical_by_artist; -drop index if exists album_order_album_name; -drop index if exists album_order_album_artist_name; -drop index if exists album_mbz_album_type; - -drop index if exists artist_order_artist_name; - -drop index if exists media_file_order_album_name; -drop index if exists media_file_order_artist_name; -drop index if exists media_file_order_title; -drop index if exists media_file_bpm; -drop index if exists media_file_channels; -drop index if exists media_file_mbz_track_id; - -alter table album - add image_files_new varchar not null default ''; -update album -set image_files_new = image_files -where image_files is not null; -alter table album - drop image_files; -alter table album - rename image_files_new to image_files; - -alter table album - add order_album_name_new varchar not null default ''; -update album -set order_album_name_new = order_album_name -where order_album_name is not null; -alter table album - drop order_album_name; -alter table album - rename order_album_name_new to order_album_name; - -alter table album - add order_album_artist_name_new varchar not null default ''; -update album -set order_album_artist_name_new = order_album_artist_name -where order_album_artist_name is not null; -alter table album - drop order_album_artist_name; -alter table album - rename order_album_artist_name_new to order_album_artist_name; - -alter table album - add sort_album_name_new varchar not null default ''; -update album -set sort_album_name_new = sort_album_name -where sort_album_name is not null; -alter table album - drop sort_album_name; -alter table album - rename sort_album_name_new to sort_album_name; - -alter table album - add sort_artist_name_new varchar not null default ''; -update album -set sort_artist_name_new = sort_artist_name -where sort_artist_name is not null; -alter table album - drop sort_artist_name; -alter table album - rename sort_artist_name_new to sort_artist_name; - -alter table album - add sort_album_artist_name_new varchar not null default ''; -update album -set sort_album_artist_name_new = sort_album_artist_name -where sort_album_artist_name is not null; -alter table album - drop sort_album_artist_name; -alter table album - rename sort_album_artist_name_new to sort_album_artist_name; - -alter table album - add catalog_num_new varchar not null default ''; -update album -set catalog_num_new = catalog_num -where catalog_num is not null; -alter table album - drop catalog_num; -alter table album - rename catalog_num_new to catalog_num; - -alter table album - add comment_new varchar not null default ''; -update album -set comment_new = comment -where comment is not null; -alter table album - drop comment; -alter table album - rename comment_new to comment; - -alter table album - add paths_new varchar not null default ''; -update album -set paths_new = paths -where paths is not null; -alter table album - drop paths; -alter table album - rename paths_new to paths; - -alter table album - add mbz_album_id_new varchar not null default ''; -update album -set mbz_album_id_new = mbz_album_id -where mbz_album_id is not null; -alter table album - drop mbz_album_id; -alter table album - rename mbz_album_id_new to mbz_album_id; - -alter table album - add mbz_album_artist_id_new varchar not null default ''; -update album -set mbz_album_artist_id_new = mbz_album_artist_id -where mbz_album_artist_id is not null; -alter table album - drop mbz_album_artist_id; -alter table album - rename mbz_album_artist_id_new to mbz_album_artist_id; - -alter table album - add mbz_album_type_new varchar not null default ''; -update album -set mbz_album_type_new = mbz_album_type -where mbz_album_type is not null; -alter table album - drop mbz_album_type; -alter table album - rename mbz_album_type_new to mbz_album_type; - -alter table album - add mbz_album_comment_new varchar not null default ''; -update album -set mbz_album_comment_new = mbz_album_comment -where mbz_album_comment is not null; -alter table album - drop mbz_album_comment; -alter table album - rename mbz_album_comment_new to mbz_album_comment; - -alter table album - add discs_new jsonb not null default '{}'; -update album -set discs_new = discs -where discs is not null; -alter table album - drop discs; -alter table album - rename discs_new to discs; - --- ARTIST -alter table artist - add order_artist_name_new varchar not null default ''; -update artist -set order_artist_name_new = order_artist_name -where order_artist_name is not null; -alter table artist - drop order_artist_name; -alter table artist - rename order_artist_name_new to order_artist_name; - -alter table artist - add sort_artist_name_new varchar not null default ''; -update artist -set sort_artist_name_new = sort_artist_name -where sort_artist_name is not null; -alter table artist - drop sort_artist_name; -alter table artist - rename sort_artist_name_new to sort_artist_name; - -alter table artist - add mbz_artist_id_new varchar not null default ''; -update artist -set mbz_artist_id_new = mbz_artist_id -where mbz_artist_id is not null; -alter table artist - drop mbz_artist_id; -alter table artist - rename mbz_artist_id_new to mbz_artist_id; - --- MEDIA_FILE -alter table media_file - add order_album_name_new varchar not null default ''; -update media_file -set order_album_name_new = order_album_name -where order_album_name is not null; -alter table media_file - drop order_album_name; -alter table media_file - rename order_album_name_new to order_album_name; - -alter table media_file - add order_album_artist_name_new varchar not null default ''; -update media_file -set order_album_artist_name_new = order_album_artist_name -where order_album_artist_name is not null; -alter table media_file - drop order_album_artist_name; -alter table media_file - rename order_album_artist_name_new to order_album_artist_name; - -alter table media_file - add order_artist_name_new varchar not null default ''; -update media_file -set order_artist_name_new = order_artist_name -where order_artist_name is not null; -alter table media_file - drop order_artist_name; -alter table media_file - rename order_artist_name_new to order_artist_name; - -alter table media_file - add sort_album_name_new varchar not null default ''; -update media_file -set sort_album_name_new = sort_album_name -where sort_album_name is not null; -alter table media_file - drop sort_album_name; -alter table media_file - rename sort_album_name_new to sort_album_name; - -alter table media_file - add sort_artist_name_new varchar not null default ''; -update media_file -set sort_artist_name_new = sort_artist_name -where sort_artist_name is not null; -alter table media_file - drop sort_artist_name; -alter table media_file - rename sort_artist_name_new to sort_artist_name; - -alter table media_file - add sort_album_artist_name_new varchar not null default ''; -update media_file -set sort_album_artist_name_new = sort_album_artist_name -where sort_album_artist_name is not null; -alter table media_file - drop sort_album_artist_name; -alter table media_file - rename sort_album_artist_name_new to sort_album_artist_name; - -alter table media_file - add sort_title_new varchar not null default ''; -update media_file -set sort_title_new = sort_title -where sort_title is not null; -alter table media_file - drop sort_title; -alter table media_file - rename sort_title_new to sort_title; - -alter table media_file - add disc_subtitle_new varchar not null default ''; -update media_file -set disc_subtitle_new = disc_subtitle -where disc_subtitle is not null; -alter table media_file - drop disc_subtitle; -alter table media_file - rename disc_subtitle_new to disc_subtitle; - -alter table media_file - add catalog_num_new varchar not null default ''; -update media_file -set catalog_num_new = catalog_num -where catalog_num is not null; -alter table media_file - drop catalog_num; -alter table media_file - rename catalog_num_new to catalog_num; - -alter table media_file - add comment_new varchar not null default ''; -update media_file -set comment_new = comment -where comment is not null; -alter table media_file - drop comment; -alter table media_file - rename comment_new to comment; - -alter table media_file - add order_title_new varchar not null default ''; -update media_file -set order_title_new = order_title -where order_title is not null; -alter table media_file - drop order_title; -alter table media_file - rename order_title_new to order_title; - -alter table media_file - add mbz_recording_id_new varchar not null default ''; -update media_file -set mbz_recording_id_new = mbz_recording_id -where mbz_recording_id is not null; -alter table media_file - drop mbz_recording_id; -alter table media_file - rename mbz_recording_id_new to mbz_recording_id; - -alter table media_file - add mbz_album_id_new varchar not null default ''; -update media_file -set mbz_album_id_new = mbz_album_id -where mbz_album_id is not null; -alter table media_file - drop mbz_album_id; -alter table media_file - rename mbz_album_id_new to mbz_album_id; - -alter table media_file - add mbz_artist_id_new varchar not null default ''; -update media_file -set mbz_artist_id_new = mbz_artist_id -where mbz_artist_id is not null; -alter table media_file - drop mbz_artist_id; -alter table media_file - rename mbz_artist_id_new to mbz_artist_id; - -alter table media_file - add mbz_artist_id_new varchar not null default ''; -update media_file -set mbz_artist_id_new = mbz_artist_id -where mbz_artist_id is not null; -alter table media_file - drop mbz_artist_id; -alter table media_file - rename mbz_artist_id_new to mbz_artist_id; - -alter table media_file - add mbz_album_artist_id_new varchar not null default ''; -update media_file -set mbz_album_artist_id_new = mbz_album_artist_id -where mbz_album_artist_id is not null; -alter table media_file - drop mbz_album_artist_id; -alter table media_file - rename mbz_album_artist_id_new to mbz_album_artist_id; - -alter table media_file - add mbz_album_type_new varchar not null default ''; -update media_file -set mbz_album_type_new = mbz_album_type -where mbz_album_type is not null; -alter table media_file - drop mbz_album_type; -alter table media_file - rename mbz_album_type_new to mbz_album_type; - -alter table media_file - add mbz_album_comment_new varchar not null default ''; -update media_file -set mbz_album_comment_new = mbz_album_comment -where mbz_album_comment is not null; -alter table media_file - drop mbz_album_comment; -alter table media_file - rename mbz_album_comment_new to mbz_album_comment; - -alter table media_file - add mbz_release_track_id_new varchar not null default ''; -update media_file -set mbz_release_track_id_new = mbz_release_track_id -where mbz_release_track_id is not null; -alter table media_file - drop mbz_release_track_id; -alter table media_file - rename mbz_release_track_id_new to mbz_release_track_id; - -alter table media_file - add bpm_new integer not null default 0; -update media_file -set bpm_new = bpm -where bpm is not null; -alter table media_file - drop bpm; -alter table media_file - rename bpm_new to bpm; - -alter table media_file - add channels_new integer not null default 0; -update media_file -set channels_new = channels -where channels is not null; -alter table media_file - drop channels; -alter table media_file - rename channels_new to channels; - -alter table media_file - add rg_album_gain_new real not null default 0; -update media_file -set rg_album_gain_new = rg_album_gain -where rg_album_gain is not null; -alter table media_file - drop rg_album_gain; -alter table media_file - rename rg_album_gain_new to rg_album_gain; - -alter table media_file - add rg_album_peak_new real not null default 0; -update media_file -set rg_album_peak_new = rg_album_peak -where rg_album_peak is not null; -alter table media_file - drop rg_album_peak; -alter table media_file - rename rg_album_peak_new to rg_album_peak; - -alter table media_file - add rg_track_gain_new real not null default 0; -update media_file -set rg_track_gain_new = rg_track_gain -where rg_track_gain is not null; -alter table media_file - drop rg_track_gain; -alter table media_file - rename rg_track_gain_new to rg_track_gain; - -alter table media_file - add rg_track_peak_new real not null default 0; -update media_file -set rg_track_peak_new = rg_track_peak -where rg_track_peak is not null; -alter table media_file - drop rg_track_peak; -alter table media_file - rename rg_track_peak_new to rg_track_peak; - -alter table media_file - add lyrics_new jsonb not null default '[]'; -update media_file -set lyrics_new = lyrics -where lyrics is not null; -alter table media_file - drop lyrics; -alter table media_file - rename lyrics_new to lyrics; - --- SHARE -alter table share - add description_new varchar not null default ''; -update share -set description_new = description -where description is not null; -alter table share - drop description; -alter table share - rename description_new to description; - -alter table share - add resource_type_new varchar not null default ''; -update share -set resource_type_new = resource_type -where resource_type is not null; -alter table share - drop resource_type; -alter table share - rename resource_type_new to resource_type; - -alter table share - add contents_new varchar not null default ''; -update share -set contents_new = contents -where contents is not null; -alter table share - drop contents; -alter table share - rename contents_new to contents; - -alter table share - add format_new varchar not null default ''; -update share -set format_new = format -where format is not null; -alter table share - drop format; -alter table share - rename format_new to format; - -alter table share - add max_bit_rate_new integer not null default 0; -update share -set max_bit_rate_new = max_bit_rate -where max_bit_rate is not null; -alter table share - drop max_bit_rate; -alter table share - rename max_bit_rate_new to max_bit_rate; - -alter table share - add visit_count_new integer not null default 0; -update share -set visit_count_new = visit_count -where visit_count is not null; -alter table share - drop visit_count; -alter table share - rename visit_count_new to visit_count; - --- INDEX -create index album_alphabetical_by_artist - on album (compilation, order_album_artist_name, order_album_name); - -create index album_order_album_name - on album (order_album_name); - -create index album_order_album_artist_name - on album (order_album_artist_name); - -create index album_mbz_album_type - on album (mbz_album_type); - -create index artist_order_artist_name - on artist (order_artist_name); - -create index media_file_order_album_name - on media_file (order_album_name); - -create index media_file_order_artist_name - on media_file (order_artist_name); - -create index media_file_order_title - on media_file (order_title); - -create index media_file_bpm - on media_file (bpm); - -create index media_file_channels - on media_file (channels); - -create index media_file_mbz_track_id - on media_file (mbz_recording_id); - -`) - return err -} - -func Down20240122223340(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20240426202913_add_id_to_scrobble_buffer.go b/db/migrations/20240426202913_add_id_to_scrobble_buffer.go deleted file mode 100644 index e3df63595..000000000 --- a/db/migrations/20240426202913_add_id_to_scrobble_buffer.go +++ /dev/null @@ -1,30 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddIdToScrobbleBuffer, downAddIdToScrobbleBuffer) -} - -func upAddIdToScrobbleBuffer(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -delete from scrobble_buffer where user_id <> ''; -alter table scrobble_buffer add id varchar not null default ''; -create unique index scrobble_buffer_id_ix - on scrobble_buffer (id); -`) - return err -} - -func downAddIdToScrobbleBuffer(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -drop index scrobble_buffer_id_ix; -alter table scrobble_buffer drop id; -`) - return err -} diff --git a/db/migrations/20240511210036_add_sample_rate.go b/db/migrations/20240511210036_add_sample_rate.go deleted file mode 100644 index 76b809c36..000000000 --- a/db/migrations/20240511210036_add_sample_rate.go +++ /dev/null @@ -1,29 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddSampleRate, downAddSampleRate) -} - -func upAddSampleRate(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table media_file - add sample_rate integer not null default 0; - -create index if not exists media_file_sample_rate - on media_file (sample_rate); -`) - notice(ctx, tx, "A full rescan should be performed to pick up additional tags") - return err -} - -func downAddSampleRate(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `alter table media_file drop sample_rate;`) - return err -} diff --git a/db/migrations/20240511220020_add_library_table.go b/db/migrations/20240511220020_add_library_table.go deleted file mode 100644 index d7b4aa5c2..000000000 --- a/db/migrations/20240511220020_add_library_table.go +++ /dev/null @@ -1,70 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/navidrome/navidrome/conf" - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddLibraryTable, downAddLibraryTable) -} - -func upAddLibraryTable(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` - create table library ( - id integer primary key autoincrement, - name text not null unique, - path text not null unique, - remote_path text null default '', - last_scan_at datetime not null default '0000-00-00 00:00:00', - updated_at datetime not null default current_timestamp, - created_at datetime not null default current_timestamp - );`) - if err != nil { - return err - } - - _, err = tx.ExecContext(ctx, ` - insert into library(id, name, path) values(1, 'Music Library', ?); - delete from property where id like 'LastScan-%'; -`, conf.Server.MusicFolder) - if err != nil { - return err - } - - _, err = tx.ExecContext(ctx, ` - alter table media_file add column library_id integer not null default 1 - references library(id) on delete cascade; - alter table album add column library_id integer not null default 1 - references library(id) on delete cascade; - - create table if not exists library_artist - ( - library_id integer not null default 1 - references library(id) - on delete cascade, - artist_id varchar not null default null - references artist(id) - on delete cascade, - constraint library_artist_ux - unique (library_id, artist_id) - ); - - insert into library_artist(library_id, artist_id) select 1, id from artist; -`) - - return err -} - -func downAddLibraryTable(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` - alter table media_file drop column library_id; - alter table album drop column library_id; - drop table library_artist; - drop table library; -`) - return err -} diff --git a/db/migrations/20240629152843_remove_annotation_id.go b/db/migrations/20240629152843_remove_annotation_id.go deleted file mode 100644 index 972932e10..000000000 --- a/db/migrations/20240629152843_remove_annotation_id.go +++ /dev/null @@ -1,66 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upRemoveAnnotationId, downRemoveAnnotationId) -} - -func upRemoveAnnotationId(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -create table annotation_dg_tmp -( - user_id varchar(255) default '' not null, - item_id varchar(255) default '' not null, - item_type varchar(255) default '' not null, - play_count integer default 0, - play_date datetime, - rating integer default 0, - starred bool default FALSE not null, - starred_at datetime, - unique (user_id, item_id, item_type) -); - -insert into annotation_dg_tmp(user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at) -select user_id, - item_id, - item_type, - play_count, - play_date, - rating, - starred, - starred_at -from annotation; - -drop table annotation; - -alter table annotation_dg_tmp - rename to annotation; - -create index annotation_play_count - on annotation (play_count); - -create index annotation_play_date - on annotation (play_date); - -create index annotation_rating - on annotation (rating); - -create index annotation_starred - on annotation (starred); - -create index annotation_starred_at - on annotation (starred_at); - -`) - return err -} - -func downRemoveAnnotationId(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20240802044339_player_use_user_id_over_username.go b/db/migrations/20240802044339_player_use_user_id_over_username.go deleted file mode 100644 index 6532f0f45..000000000 --- a/db/migrations/20240802044339_player_use_user_id_over_username.go +++ /dev/null @@ -1,62 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upPlayerUseUserIdOverUsername, downPlayerUseUserIdOverUsername) -} - -func upPlayerUseUserIdOverUsername(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -CREATE TABLE player_dg_tmp -( - id varchar(255) not null - primary key, - name varchar not null, - user_agent varchar, - user_id varchar not null - references user (id) - on update cascade on delete cascade, - client varchar not null, - ip varchar, - last_seen timestamp, - max_bit_rate int default 0, - transcoding_id varchar, - report_real_path bool default FALSE not null, - scrobble_enabled bool default true -); - -INSERT INTO player_dg_tmp( - id, name, user_agent, user_id, client, ip, last_seen, max_bit_rate, - transcoding_id, report_real_path, scrobble_enabled -) -SELECT - id, name, user_agent, - IFNULL( - (select id from user where user_name = player.user_name), 'UNKNOWN_USERNAME' - ), - client, ip_address, last_seen, max_bit_rate, transcoding_id, report_real_path, scrobble_enabled -FROM player; - -DELETE FROM player_dg_tmp WHERE user_id = 'UNKNOWN_USERNAME'; -DROP TABLE player; -ALTER TABLE player_dg_tmp RENAME TO player; - -CREATE INDEX IF NOT EXISTS player_match - on player (client, user_agent, user_id); -CREATE INDEX IF NOT EXISTS player_name - on player (name); -`) - - return err -} - -func downPlayerUseUserIdOverUsername(ctx context.Context, tx *sql.Tx) error { - // This code is executed when the migration is rolled back. - return nil -} diff --git a/db/migrations/20241020003138_add_sort_tags_index.sql b/db/migrations/20241020003138_add_sort_tags_index.sql deleted file mode 100644 index 6d86971e7..000000000 --- a/db/migrations/20241020003138_add_sort_tags_index.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up -create index if not exists media_file_sort_title on media_file(coalesce(nullif(sort_title,''),order_title)); -create index if not exists album_sort_name on album(coalesce(nullif(sort_album_name,''),order_album_name)); -create index if not exists artist_sort_name on artist(coalesce(nullif(sort_artist_name,''),order_artist_name)); - --- +goose Down -drop index if exists media_file_sort_title; -drop index if exists album_sort_name; -drop index if exists artist_sort_name; \ No newline at end of file diff --git a/db/migrations/20241024125533_fix_sort_tags_collation_and_index.sql b/db/migrations/20241024125533_fix_sort_tags_collation_and_index.sql deleted file mode 100644 index d2edde0ec..000000000 --- a/db/migrations/20241024125533_fix_sort_tags_collation_and_index.sql +++ /dev/null @@ -1,512 +0,0 @@ --- +goose Up ---region Artist Table -create table artist_dg_tmp -( - id varchar(255) not null - primary key, - name varchar(255) default '' not null, - album_count integer default 0 not null, - full_text varchar(255) default '', - song_count integer default 0 not null, - size integer default 0 not null, - biography varchar(255) default '' not null, - small_image_url varchar(255) default '' not null, - medium_image_url varchar(255) default '' not null, - large_image_url varchar(255) default '' not null, - similar_artists varchar(255) default '' not null, - external_url varchar(255) default '' not null, - external_info_updated_at datetime, - order_artist_name varchar collate NOCASE default '' not null, - sort_artist_name varchar collate NOCASE default '' not null, - mbz_artist_id varchar default '' not null -); - -insert into artist_dg_tmp(id, name, album_count, full_text, song_count, size, biography, small_image_url, - medium_image_url, large_image_url, similar_artists, external_url, external_info_updated_at, - order_artist_name, sort_artist_name, mbz_artist_id) -select id, - name, - album_count, - full_text, - song_count, - size, - biography, - small_image_url, - medium_image_url, - large_image_url, - similar_artists, - external_url, - external_info_updated_at, - order_artist_name, - sort_artist_name, - mbz_artist_id -from artist; - -drop table artist; - -alter table artist_dg_tmp - rename to artist; - -create index artist_full_text - on artist (full_text); - -create index artist_name - on artist (name); - -create index artist_order_artist_name - on artist (order_artist_name); - -create index artist_size - on artist (size); - -create index artist_sort_name - on artist (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE); - ---endregion - ---region Album Table -create table album_dg_tmp -( - id varchar(255) not null - primary key, - name varchar(255) default '' not null, - artist_id varchar(255) default '' not null, - embed_art_path varchar(255) default '' not null, - artist varchar(255) default '' not null, - album_artist varchar(255) default '' not null, - min_year int default 0 not null, - max_year integer default 0 not null, - compilation bool default FALSE not null, - song_count integer default 0 not null, - duration real default 0 not null, - genre varchar(255) default '' not null, - created_at datetime, - updated_at datetime, - full_text varchar(255) default '', - album_artist_id varchar(255) default '', - size integer default 0 not null, - all_artist_ids varchar, - description varchar(255) default '' not null, - small_image_url varchar(255) default '' not null, - medium_image_url varchar(255) default '' not null, - large_image_url varchar(255) default '' not null, - external_url varchar(255) default '' not null, - external_info_updated_at datetime, - date varchar(255) default '' not null, - min_original_year int default 0 not null, - max_original_year int default 0 not null, - original_date varchar(255) default '' not null, - release_date varchar(255) default '' not null, - releases integer default 0 not null, - image_files varchar default '' not null, - order_album_name varchar collate NOCASE default '' not null, - order_album_artist_name varchar collate NOCASE default '' not null, - sort_album_name varchar collate NOCASE default '' not null, - sort_album_artist_name varchar collate NOCASE default '' not null, - catalog_num varchar default '' not null, - comment varchar default '' not null, - paths varchar default '' not null, - mbz_album_id varchar default '' not null, - mbz_album_artist_id varchar default '' not null, - mbz_album_type varchar default '' not null, - mbz_album_comment varchar default '' not null, - discs jsonb default '{}' not null, - library_id integer default 1 not null - references library - on delete cascade -); - -insert into album_dg_tmp(id, name, artist_id, embed_art_path, artist, album_artist, min_year, max_year, compilation, - song_count, duration, genre, created_at, updated_at, full_text, album_artist_id, size, - all_artist_ids, description, small_image_url, medium_image_url, large_image_url, external_url, - external_info_updated_at, date, min_original_year, max_original_year, original_date, - release_date, releases, image_files, order_album_name, order_album_artist_name, - sort_album_name, sort_album_artist_name, catalog_num, comment, paths, - mbz_album_id, mbz_album_artist_id, mbz_album_type, mbz_album_comment, discs, library_id) -select id, - name, - artist_id, - embed_art_path, - artist, - album_artist, - min_year, - max_year, - compilation, - song_count, - duration, - genre, - created_at, - updated_at, - full_text, - album_artist_id, - size, - all_artist_ids, - description, - small_image_url, - medium_image_url, - large_image_url, - external_url, - external_info_updated_at, - date, - min_original_year, - max_original_year, - original_date, - release_date, - releases, - image_files, - order_album_name, - order_album_artist_name, - sort_album_name, - sort_album_artist_name, - catalog_num, - comment, - paths, - mbz_album_id, - mbz_album_artist_id, - mbz_album_type, - mbz_album_comment, - discs, - library_id -from album; - -drop table album; - -alter table album_dg_tmp - rename to album; - -create index album_all_artist_ids - on album (all_artist_ids); - -create index album_alphabetical_by_artist - on album (compilation, order_album_artist_name, order_album_name); - -create index album_artist - on album (artist); - -create index album_artist_album - on album (artist); - -create index album_artist_album_id - on album (album_artist_id); - -create index album_artist_id - on album (artist_id); - -create index album_created_at - on album (created_at); - -create index album_full_text - on album (full_text); - -create index album_genre - on album (genre); - -create index album_max_year - on album (max_year); - -create index album_mbz_album_type - on album (mbz_album_type); - -create index album_min_year - on album (min_year); - -create index album_name - on album (name); - -create index album_order_album_artist_name - on album (order_album_artist_name); - -create index album_order_album_name - on album (order_album_name); - -create index album_size - on album (size); - -create index album_sort_name - on album (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE); - -create index album_sort_album_artist_name - on album (coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate NOCASE); - -create index album_updated_at - on album (updated_at); ---endregion - ---region Media File Table -create table media_file_dg_tmp -( - id varchar(255) not null - primary key, - path varchar(255) default '' not null, - title varchar(255) default '' not null, - album varchar(255) default '' not null, - artist varchar(255) default '' not null, - artist_id varchar(255) default '' not null, - album_artist varchar(255) default '' not null, - album_id varchar(255) default '' not null, - has_cover_art bool default FALSE not null, - track_number integer default 0 not null, - disc_number integer default 0 not null, - year integer default 0 not null, - size integer default 0 not null, - suffix varchar(255) default '' not null, - duration real default 0 not null, - bit_rate integer default 0 not null, - genre varchar(255) default '' not null, - compilation bool default FALSE not null, - created_at datetime, - updated_at datetime, - full_text varchar(255) default '', - album_artist_id varchar(255) default '', - date varchar(255) default '' not null, - original_year int default 0 not null, - original_date varchar(255) default '' not null, - release_year int default 0 not null, - release_date varchar(255) default '' not null, - order_album_name varchar collate NOCASE default '' not null, - order_album_artist_name varchar collate NOCASE default '' not null, - order_artist_name varchar collate NOCASE default '' not null, - sort_album_name varchar collate NOCASE default '' not null, - sort_artist_name varchar collate NOCASE default '' not null, - sort_album_artist_name varchar collate NOCASE default '' not null, - sort_title varchar collate NOCASE default '' not null, - disc_subtitle varchar default '' not null, - catalog_num varchar default '' not null, - comment varchar default '' not null, - order_title varchar collate NOCASE default '' not null, - mbz_recording_id varchar default '' not null, - mbz_album_id varchar default '' not null, - mbz_artist_id varchar default '' not null, - mbz_album_artist_id varchar default '' not null, - mbz_album_type varchar default '' not null, - mbz_album_comment varchar default '' not null, - mbz_release_track_id varchar default '' not null, - bpm integer default 0 not null, - channels integer default 0 not null, - rg_album_gain real default 0 not null, - rg_album_peak real default 0 not null, - rg_track_gain real default 0 not null, - rg_track_peak real default 0 not null, - lyrics jsonb default '[]' not null, - sample_rate integer default 0 not null, - library_id integer default 1 not null - references library - on delete cascade -); - -insert into media_file_dg_tmp(id, path, title, album, artist, artist_id, album_artist, album_id, has_cover_art, - track_number, disc_number, year, size, suffix, duration, bit_rate, genre, compilation, - created_at, updated_at, full_text, album_artist_id, date, original_year, original_date, - release_year, release_date, order_album_name, order_album_artist_name, order_artist_name, - sort_album_name, sort_artist_name, sort_album_artist_name, sort_title, disc_subtitle, - catalog_num, comment, order_title, mbz_recording_id, mbz_album_id, mbz_artist_id, - mbz_album_artist_id, mbz_album_type, mbz_album_comment, mbz_release_track_id, bpm, - channels, rg_album_gain, rg_album_peak, rg_track_gain, rg_track_peak, lyrics, sample_rate, - library_id) -select id, - path, - title, - album, - artist, - artist_id, - album_artist, - album_id, - has_cover_art, - track_number, - disc_number, - year, - size, - suffix, - duration, - bit_rate, - genre, - compilation, - created_at, - updated_at, - full_text, - album_artist_id, - date, - original_year, - original_date, - release_year, - release_date, - order_album_name, - order_album_artist_name, - order_artist_name, - sort_album_name, - sort_artist_name, - sort_album_artist_name, - sort_title, - disc_subtitle, - catalog_num, - comment, - order_title, - mbz_recording_id, - mbz_album_id, - mbz_artist_id, - mbz_album_artist_id, - mbz_album_type, - mbz_album_comment, - mbz_release_track_id, - bpm, - channels, - rg_album_gain, - rg_album_peak, - rg_track_gain, - rg_track_peak, - lyrics, - sample_rate, - library_id -from media_file; - -drop table media_file; - -alter table media_file_dg_tmp - rename to media_file; - -create index media_file_album_artist - on media_file (album_artist); - -create index media_file_album_id - on media_file (album_id); - -create index media_file_artist - on media_file (artist); - -create index media_file_artist_album_id - on media_file (album_artist_id); - -create index media_file_artist_id - on media_file (artist_id); - -create index media_file_bpm - on media_file (bpm); - -create index media_file_channels - on media_file (channels); - -create index media_file_created_at - on media_file (created_at); - -create index media_file_duration - on media_file (duration); - -create index media_file_full_text - on media_file (full_text); - -create index media_file_genre - on media_file (genre); - -create index media_file_mbz_track_id - on media_file (mbz_recording_id); - -create index media_file_order_album_name - on media_file (order_album_name); - -create index media_file_order_artist_name - on media_file (order_artist_name); - -create index media_file_order_title - on media_file (order_title); - -create index media_file_path - on media_file (path); - -create index media_file_path_nocase - on media_file (path collate NOCASE); - -create index media_file_sample_rate - on media_file (sample_rate); - -create index media_file_sort_title - on media_file (coalesce(nullif(sort_title,''),order_title) collate NOCASE); - -create index media_file_sort_artist_name - on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE); - -create index media_file_sort_album_name - on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE); - -create index media_file_title - on media_file (title); - -create index media_file_track_number - on media_file (disc_number, track_number); - -create index media_file_updated_at - on media_file (updated_at); - -create index media_file_year - on media_file (year); - ---endregion - ---region Radio Table -create table radio_dg_tmp -( - id varchar(255) not null - primary key, - name varchar collate NOCASE not null - unique, - stream_url varchar not null, - home_page_url varchar default '' not null, - created_at datetime, - updated_at datetime -); - -insert into radio_dg_tmp(id, name, stream_url, home_page_url, created_at, updated_at) -select id, name, stream_url, home_page_url, created_at, updated_at -from radio; - -drop table radio; - -alter table radio_dg_tmp - rename to radio; - -create index radio_name - on radio(name); ---endregion - ---region users Table -create table user_dg_tmp -( - id varchar(255) not null - primary key, - user_name varchar(255) default '' not null - unique, - name varchar(255) collate NOCASE default '' not null, - email varchar(255) default '' not null, - password varchar(255) default '' not null, - is_admin bool default FALSE not null, - last_login_at datetime, - last_access_at datetime, - created_at datetime not null, - updated_at datetime not null -); - -insert into user_dg_tmp(id, user_name, name, email, password, is_admin, last_login_at, last_access_at, created_at, - updated_at) -select id, - user_name, - name, - email, - password, - is_admin, - last_login_at, - last_access_at, - created_at, - updated_at -from user; - -drop table user; - -alter table user_dg_tmp - rename to user; - -create index user_username_password - on user(user_name collate NOCASE, password); ---endregion - --- +goose Down -alter table album - add column sort_artist_name varchar default '' not null; diff --git a/db/migrations/20241026183640_support_new_scanner.go b/db/migrations/20241026183640_support_new_scanner.go deleted file mode 100644 index f5899f08f..000000000 --- a/db/migrations/20241026183640_support_new_scanner.go +++ /dev/null @@ -1,319 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - "fmt" - "io/fs" - "os" - "path/filepath" - "strings" - "testing/fstest" - "unicode/utf8" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/run" - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upSupportNewScanner, downSupportNewScanner) -} - -func upSupportNewScanner(ctx context.Context, tx *sql.Tx) error { - execute := createExecuteFunc(ctx, tx) - addColumn := createAddColumnFunc(ctx, tx) - - return run.Sequentially( - upSupportNewScanner_CreateTableFolder(ctx, execute), - upSupportNewScanner_PopulateTableFolder(ctx, tx), - upSupportNewScanner_UpdateTableMediaFile(ctx, execute, addColumn), - upSupportNewScanner_UpdateTableAlbum(ctx, execute), - upSupportNewScanner_UpdateTableArtist(ctx, execute, addColumn), - execute(` -alter table library - add column last_scan_started_at datetime default '0000-00-00 00:00:00' not null; -alter table library - add column full_scan_in_progress boolean default false not null; - -create table if not exists media_file_artists( - media_file_id varchar not null - references media_file (id) - on delete cascade, - artist_id varchar not null - references artist (id) - on delete cascade, - role varchar default '' not null, - sub_role varchar default '' not null, - constraint artist_tracks - unique (artist_id, media_file_id, role, sub_role) -); -create index if not exists media_file_artists_media_file_id - on media_file_artists (media_file_id); -create index if not exists media_file_artists_role - on media_file_artists (role); - -create table if not exists album_artists( - album_id varchar not null - references album (id) - on delete cascade, - artist_id varchar not null - references artist (id) - on delete cascade, - role varchar default '' not null, - sub_role varchar default '' not null, - constraint album_artists - unique (album_id, artist_id, role, sub_role) -); -create index if not exists album_artists_album_id - on album_artists (album_id); -create index if not exists album_artists_role - on album_artists (role); - -create table if not exists tag( - id varchar not null primary key, - tag_name varchar default '' not null, - tag_value varchar default '' not null, - album_count integer default 0 not null, - media_file_count integer default 0 not null, - constraint tags_name_value - unique (tag_name, tag_value) -); - --- Genres are now stored in the tag table -drop table if exists media_file_genres; -drop table if exists album_genres; -drop table if exists artist_genres; -drop table if exists genre; - --- Drop full_text indexes, as they are not being used by SQLite -drop index if exists media_file_full_text; -drop index if exists album_full_text; -drop index if exists artist_full_text; - --- Add PID config to properties -insert into property (id, value) values ('PIDTrack', 'track_legacy') on conflict do nothing; -insert into property (id, value) values ('PIDAlbum', 'album_legacy') on conflict do nothing; -`), - func() error { - notice(ctx, tx, "A full scan will be triggered to populate the new tables. This may take a while.") - return forceFullRescan(ctx, tx) - }, - ) -} - -func upSupportNewScanner_CreateTableFolder(_ context.Context, execute execStmtFunc) execFunc { - return execute(` -create table if not exists folder( - id varchar not null - primary key, - library_id integer not null - references library (id) - on delete cascade, - path varchar default '' not null, - name varchar default '' not null, - missing boolean default false not null, - parent_id varchar default '' not null, - num_audio_files integer default 0 not null, - num_playlists integer default 0 not null, - image_files jsonb default '[]' not null, - images_updated_at datetime default '0000-00-00 00:00:00' not null, - updated_at datetime default (datetime(current_timestamp, 'localtime')) not null, - created_at datetime default (datetime(current_timestamp, 'localtime')) not null -); -create index folder_parent_id on folder(parent_id); -`) -} - -// Use paths from `media_file` table to populate `folder` table. The `folder` table must contain all paths, including -// the ones that do not contain any media_file. We can get all paths from the media_file table to populate a -// fstest.MapFS{}, and then walk the filesystem to insert all folders into the DB, including empty parent ones. -func upSupportNewScanner_PopulateTableFolder(ctx context.Context, tx *sql.Tx) execFunc { - return func() error { - // First, get all folder paths from media_file table - rows, err := tx.QueryContext(ctx, fmt.Sprintf(` -select distinct rtrim(media_file.path, replace(media_file.path, '%s', '')), library_id, library.path -from media_file -join library on media_file.library_id = library.id`, string(os.PathSeparator))) - if err != nil { - return err - } - defer rows.Close() - - // Then create an in-memory filesystem with all paths - var path string - var lib model.Library - fsys := fstest.MapFS{} - - for rows.Next() { - err = rows.Scan(&path, &lib.ID, &lib.Path) - if err != nil { - return err - } - - path = strings.TrimPrefix(path, filepath.Clean(lib.Path)) - path = strings.TrimPrefix(path, string(os.PathSeparator)) - path = filepath.Clean(path) - fsys[path] = &fstest.MapFile{Mode: fs.ModeDir} - } - if err = rows.Err(); err != nil { - return fmt.Errorf("error loading folders from media_file table: %w", err) - } - if len(fsys) == 0 { - return nil - } - - stmt, err := tx.PrepareContext(ctx, - "insert into folder (id, library_id, path, name, parent_id, updated_at) values (?, ?, ?, ?, ?, '0000-00-00 00:00:00')", - ) - if err != nil { - return err - } - - // Finally, walk the in-mem filesystem and insert all folders into the DB. - err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { - if err != nil { - // Don't abort the walk, just log the error - log.Error("error walking folder to DB", "path", path, err) - return nil - } - // Skip entries that are not directories - if !d.IsDir() { - return nil - } - - // Create a folder in the DB - f := model.NewFolder(lib, path) - _, err = stmt.ExecContext(ctx, f.ID, lib.ID, f.Path, f.Name, f.ParentID) - if err != nil { - log.Error("error writing folder to DB", "path", path, err) - } - return err - }) - if err != nil { - return fmt.Errorf("error populating folder table: %w", err) - } - - // Count the number of characters in the library path - libPath := filepath.Clean(lib.Path) - libPathLen := utf8.RuneCountInString(libPath) - - // In one go, update all paths in the media_file table, removing the library path prefix - // and replacing any backslashes with slashes (the path separator used by the io/fs package) - _, err = tx.ExecContext(ctx, fmt.Sprintf(` -update media_file set path = replace(substr(path, %d), '\', '/');`, libPathLen+2)) - if err != nil { - return fmt.Errorf("error updating media_file path: %w", err) - } - - return nil - } -} - -func upSupportNewScanner_UpdateTableMediaFile(_ context.Context, execute execStmtFunc, addColumn addColumnFunc) execFunc { - return func() error { - return run.Sequentially( - execute(` -alter table media_file - add column folder_id varchar default '' not null; -alter table media_file - add column pid varchar default '' not null; -alter table media_file - add column missing boolean default false not null; -alter table media_file - add column mbz_release_group_id varchar default '' not null; -alter table media_file - add column tags jsonb default '{}' not null; -alter table media_file - add column participants jsonb default '{}' not null; -alter table media_file - add column bit_depth integer default 0 not null; -alter table media_file - add column explicit_status varchar default '' not null; -`), - addColumn("media_file", "birth_time", "datetime", "current_timestamp", "created_at"), - execute(` -update media_file - set pid = id where pid = ''; -create index if not exists media_file_birth_time - on media_file (birth_time); -create index if not exists media_file_folder_id - on media_file (folder_id); -create index if not exists media_file_pid - on media_file (pid); -create index if not exists media_file_missing - on media_file (missing); -`), - ) - } -} - -func upSupportNewScanner_UpdateTableAlbum(_ context.Context, execute execStmtFunc) execFunc { - return execute(` -drop index if exists album_all_artist_ids; -alter table album - drop column all_artist_ids; -drop index if exists album_artist; -drop index if exists album_artist_album; -alter table album - drop column artist; -drop index if exists album_artist_id; -alter table album - drop column artist_id; -alter table album - add column imported_at datetime default '0000-00-00 00:00:00' not null; -alter table album - add column missing boolean default false not null; -alter table album - add column mbz_release_group_id varchar default '' not null; -alter table album - add column tags jsonb default '{}' not null; -alter table album - add column participants jsonb default '{}' not null; -alter table album - drop column paths; -alter table album - drop column image_files; -alter table album - add column folder_ids jsonb default '[]' not null; -alter table album - add column explicit_status varchar default '' not null; -create index if not exists album_imported_at - on album (imported_at); -create index if not exists album_mbz_release_group_id - on album (mbz_release_group_id); -`) -} - -func upSupportNewScanner_UpdateTableArtist(_ context.Context, execute execStmtFunc, addColumn addColumnFunc) execFunc { - return func() error { - return run.Sequentially( - execute(` -alter table artist - drop column album_count; -alter table artist - drop column song_count; -drop index if exists artist_size; -alter table artist - drop column size; -alter table artist - add column missing boolean default false not null; -alter table artist - add column stats jsonb default '{"albumartist":{}}' not null; -alter table artist - drop column similar_artists; -alter table artist - add column similar_artists jsonb default '[]' not null; -`), - addColumn("artist", "updated_at", "datetime", "current_time", "(select min(album.updated_at) from album where album_artist_id = artist.id)"), - addColumn("artist", "created_at", "datetime", "current_time", "(select min(album.created_at) from album where album_artist_id = artist.id)"), - execute(`create index if not exists artist_updated_at on artist (updated_at);`), - execute(`update artist set external_info_updated_at = '0000-00-00 00:00:00';`), - ) - } -} - -func downSupportNewScanner(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20250522013904_share_user_id_on_delete.sql b/db/migrations/20250522013904_share_user_id_on_delete.sql deleted file mode 100644 index 91ff653ac..000000000 --- a/db/migrations/20250522013904_share_user_id_on_delete.sql +++ /dev/null @@ -1,36 +0,0 @@ --- +goose Up --- +goose StatementBegin -CREATE TABLE share_tmp -( - id varchar(255) not null - primary key, - expires_at datetime, - last_visited_at datetime, - resource_ids varchar not null, - created_at datetime, - updated_at datetime, - user_id varchar(255) not null - constraint share_user_id_fk - references user - on update cascade on delete cascade, - downloadable bool not null default false, - description varchar not null default '', - resource_type varchar not null default '', - contents varchar not null default '', - format varchar not null default '', - max_bit_rate integer not null default 0, - visit_count integer not null default 0 -); - - -INSERT INTO share_tmp( - id, expires_at, last_visited_at, resource_ids, created_at, updated_at, user_id, downloadable, description, resource_type, contents, format, max_bit_rate, visit_count -) SELECT id, expires_at, last_visited_at, resource_ids, created_at, updated_at, user_id, downloadable, description, resource_type, contents, format, max_bit_rate, visit_count -FROM share; - -DROP TABLE share; - -ALTER TABLE share_tmp RENAME To share; --- +goose StatementEnd - --- +goose Down diff --git a/db/migrations/20250611010101_playqueue_current_to_index.go b/db/migrations/20250611010101_playqueue_current_to_index.go deleted file mode 100644 index 1b83c0b35..000000000 --- a/db/migrations/20250611010101_playqueue_current_to_index.go +++ /dev/null @@ -1,80 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - "strings" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upPlayQueueCurrentToIndex, downPlayQueueCurrentToIndex) -} - -func upPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -create table playqueue_dg_tmp( - id varchar(255) not null, - user_id varchar(255) not null - references user(id) - on update cascade on delete cascade, - current integer not null default 0, - position real, - changed_by varchar(255), - items varchar(255), - created_at datetime, - updated_at datetime -);`) - if err != nil { - return err - } - - rows, err := tx.QueryContext(ctx, `select id, user_id, current, position, changed_by, items, created_at, updated_at from playqueue`) - if err != nil { - return err - } - defer rows.Close() - - stmt, err := tx.PrepareContext(ctx, `insert into playqueue_dg_tmp(id, user_id, current, position, changed_by, items, created_at, updated_at) values(?,?,?,?,?,?,?,?)`) - if err != nil { - return err - } - defer stmt.Close() - - for rows.Next() { - var id, userID, currentID, changedBy, items string - var position sql.NullFloat64 - var createdAt, updatedAt sql.NullString - if err = rows.Scan(&id, &userID, ¤tID, &position, &changedBy, &items, &createdAt, &updatedAt); err != nil { - return err - } - index := 0 - if currentID != "" && items != "" { - parts := strings.Split(items, ",") - for i, p := range parts { - if p == currentID { - index = i - break - } - } - } - _, err = stmt.Exec(id, userID, index, position, changedBy, items, createdAt, updatedAt) - if err != nil { - return err - } - } - if err = rows.Err(); err != nil { - return err - } - - if _, err = tx.ExecContext(ctx, `drop table playqueue;`); err != nil { - return err - } - _, err = tx.ExecContext(ctx, `alter table playqueue_dg_tmp rename to playqueue;`) - return err -} - -func downPlayQueueCurrentToIndex(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20250701010101_add_folder_hash.go b/db/migrations/20250701010101_add_folder_hash.go deleted file mode 100644 index c350d31f5..000000000 --- a/db/migrations/20250701010101_add_folder_hash.go +++ /dev/null @@ -1,21 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddFolderHash, downAddFolderHash) -} - -func upAddFolderHash(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `alter table folder add column hash varchar default '' not null;`) - return err -} - -func downAddFolderHash(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20250701010102_add_annotation_user_foreign_key.sql b/db/migrations/20250701010102_add_annotation_user_foreign_key.sql deleted file mode 100644 index 114de2a88..000000000 --- a/db/migrations/20250701010102_add_annotation_user_foreign_key.sql +++ /dev/null @@ -1,46 +0,0 @@ --- +goose Up --- +goose StatementBegin -CREATE TABLE IF NOT EXISTS annotation_tmp -( - user_id varchar(255) not null - REFERENCES user(id) - ON DELETE CASCADE - ON UPDATE CASCADE, - item_id varchar(255) default '' not null, - item_type varchar(255) default '' not null, - play_count integer default 0, - play_date datetime, - rating integer default 0, - starred bool default FALSE not null, - starred_at datetime, - unique (user_id, item_id, item_type) -); - - -INSERT INTO annotation_tmp( - user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at -) -SELECT user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at -FROM annotation -WHERE user_id IN ( - SELECT id FROM user -); - -DROP TABLE annotation; -ALTER TABLE annotation_tmp RENAME TO annotation; - -CREATE INDEX annotation_play_count - on annotation (play_count); -CREATE INDEX annotation_play_date - on annotation (play_date); -CREATE INDEX annotation_rating - on annotation (rating); -CREATE INDEX annotation_starred - on annotation (starred); -CREATE INDEX annotation_starred_at - on annotation (starred_at); - --- +goose StatementEnd - --- +goose Down - diff --git a/db/migrations/20250701010103_add_library_stats.go b/db/migrations/20250701010103_add_library_stats.go deleted file mode 100644 index a84758a04..000000000 --- a/db/migrations/20250701010103_add_library_stats.go +++ /dev/null @@ -1,48 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddLibraryStats, downAddLibraryStats) -} - -func upAddLibraryStats(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -alter table library add column total_songs integer default 0 not null; -alter table library add column total_albums integer default 0 not null; -alter table library add column total_artists integer default 0 not null; -alter table library add column total_folders integer default 0 not null; - alter table library add column total_files integer default 0 not null; - alter table library add column total_missing_files integer default 0 not null; - alter table library add column total_size integer default 0 not null; -update library set - total_songs = ( - select count(*) from media_file where library_id = library.id and missing = 0 - ), - total_albums = (select count(*) from album where library_id = library.id and missing = 0), - total_artists = ( - select count(*) from library_artist la - join artist a on la.artist_id = a.id - where la.library_id = library.id and a.missing = 0 - ), - total_folders = (select count(*) from folder where library_id = library.id and missing = 0 and num_audio_files > 0), - total_files = ( - select ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) - from folder where library_id = library.id and missing = 0 - ), - total_missing_files = ( - select count(*) from media_file where library_id = library.id and missing = 1 - ), - total_size = (select ifnull(sum(size),0) from album where library_id = library.id and missing = 0); -`) - return err -} - -func downAddLibraryStats(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20250701010104_make_replaygain_fields_nullable.go b/db/migrations/20250701010104_make_replaygain_fields_nullable.go deleted file mode 100644 index c6beb2a51..000000000 --- a/db/migrations/20250701010104_make_replaygain_fields_nullable.go +++ /dev/null @@ -1,49 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upMakeReplaygainFieldsNullable, downMakeReplaygainFieldsNullable) -} - -func upMakeReplaygainFieldsNullable(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` -ALTER TABLE media_file ADD COLUMN rg_album_gain_new real; -ALTER TABLE media_file ADD COLUMN rg_album_peak_new real; -ALTER TABLE media_file ADD COLUMN rg_track_gain_new real; -ALTER TABLE media_file ADD COLUMN rg_track_peak_new real; - -UPDATE media_file SET - rg_album_gain_new = rg_album_gain, - rg_album_peak_new = rg_album_peak, - rg_track_gain_new = rg_track_gain, - rg_track_peak_new = rg_track_peak; - -ALTER TABLE media_file DROP COLUMN rg_album_gain; -ALTER TABLE media_file DROP COLUMN rg_album_peak; -ALTER TABLE media_file DROP COLUMN rg_track_gain; -ALTER TABLE media_file DROP COLUMN rg_track_peak; - -ALTER TABLE media_file RENAME COLUMN rg_album_gain_new TO rg_album_gain; -ALTER TABLE media_file RENAME COLUMN rg_album_peak_new TO rg_album_peak; -ALTER TABLE media_file RENAME COLUMN rg_track_gain_new TO rg_track_gain; -ALTER TABLE media_file RENAME COLUMN rg_track_peak_new TO rg_track_peak; - `) - - if err != nil { - return err - } - - notice(ctx, tx, "Fetching replaygain fields properly will require a full scan") - return nil -} - -func downMakeReplaygainFieldsNullable(ctx context.Context, tx *sql.Tx) error { - // This code is executed when the migration is rolled back. - return nil -} diff --git a/db/migrations/20250701010105_remove_dangling_items.sql b/db/migrations/20250701010105_remove_dangling_items.sql deleted file mode 100644 index aede49b6e..000000000 --- a/db/migrations/20250701010105_remove_dangling_items.sql +++ /dev/null @@ -1,7 +0,0 @@ --- +goose Up --- +goose StatementBegin -update media_file set missing = 1 where folder_id = ''; -update album set missing = 1 where folder_ids = '[]'; --- +goose StatementEnd - --- +goose Down diff --git a/db/migrations/20250701010106_add_participant_stats_to_all_artists.sql b/db/migrations/20250701010106_add_participant_stats_to_all_artists.sql deleted file mode 100644 index 1cd67dc32..000000000 --- a/db/migrations/20250701010106_add_participant_stats_to_all_artists.sql +++ /dev/null @@ -1,65 +0,0 @@ --- +goose Up --- +goose StatementBegin -WITH artist_role_counters AS ( - SELECT jt.atom AS artist_id, - substr( - replace(jt.path, '$.', ''), - 1, - CASE WHEN instr(replace(jt.path, '$.', ''), '[') > 0 - THEN instr(replace(jt.path, '$.', ''), '[') - 1 - ELSE length(replace(jt.path, '$.', '')) - END - ) AS role, - count(DISTINCT mf.album_id) AS album_count, - count(mf.id) AS count, - sum(mf.size) AS size - FROM media_file mf - JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL - GROUP BY jt.atom, role -), -artist_total_counters AS ( - SELECT mfa.artist_id, - 'total' AS role, - count(DISTINCT mf.album_id) AS album_count, - count(DISTINCT mf.id) AS count, - sum(mf.size) AS size - FROM media_file_artists mfa - JOIN media_file mf ON mfa.media_file_id = mf.id - GROUP BY mfa.artist_id -), -artist_participant_counter AS ( - SELECT mfa.artist_id, - 'maincredit' AS role, - count(DISTINCT mf.album_id) AS album_count, - count(DISTINCT mf.id) AS count, - sum(mf.size) AS size - FROM media_file_artists mfa - JOIN media_file mf ON mfa.media_file_id = mf.id - AND mfa.role IN ('albumartist', 'artist') - GROUP BY mfa.artist_id -), -combined_counters AS ( - SELECT artist_id, role, album_count, count, size FROM artist_role_counters - UNION - SELECT artist_id, role, album_count, count, size FROM artist_total_counters - UNION - SELECT artist_id, role, album_count, count, size FROM artist_participant_counter -), -artist_counters AS ( - SELECT artist_id AS id, - json_group_object( - replace(role, '"', ''), - json_object('a', album_count, 'm', count, 's', size) - ) AS counters - FROM combined_counters - GROUP BY artist_id -) -UPDATE artist -SET stats = coalesce((SELECT counters FROM artist_counters ac WHERE ac.id = artist.id), '{}'), - updated_at = datetime(current_timestamp, 'localtime') -WHERE artist.id <> ''; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin --- +goose StatementEnd diff --git a/db/migrations/20250701010107_add_mbid_indexes.sql b/db/migrations/20250701010107_add_mbid_indexes.sql deleted file mode 100644 index f8a5a444b..000000000 --- a/db/migrations/20250701010107_add_mbid_indexes.sql +++ /dev/null @@ -1,27 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- Add indexes for MBID fields to improve lookup performance --- Artists table -create index if not exists artist_mbz_artist_id - on artist (mbz_artist_id); - --- Albums table -create index if not exists album_mbz_album_id - on album (mbz_album_id); - --- Media files table -create index if not exists media_file_mbz_release_track_id - on media_file (mbz_release_track_id); - --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin - --- Remove MBID indexes -drop index if exists artist_mbz_artist_id; -drop index if exists album_mbz_album_id; -drop index if exists media_file_mbz_release_track_id; - --- +goose StatementEnd diff --git a/db/migrations/20250701010108_add_multi_library_support.go b/db/migrations/20250701010108_add_multi_library_support.go deleted file mode 100644 index 654784d09..000000000 --- a/db/migrations/20250701010108_add_multi_library_support.go +++ /dev/null @@ -1,119 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddMultiLibrarySupport, downAddMultiLibrarySupport) -} - -func upAddMultiLibrarySupport(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` - -- Create user_library association table - CREATE TABLE user_library ( - user_id VARCHAR(255) NOT NULL, - library_id INTEGER NOT NULL, - PRIMARY KEY (user_id, library_id), - FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE, - FOREIGN KEY (library_id) REFERENCES library(id) ON DELETE CASCADE - ); - -- Create indexes for performance - CREATE INDEX idx_user_library_user_id ON user_library(user_id); - CREATE INDEX idx_user_library_library_id ON user_library(library_id); - - -- Populate with existing users having access to library ID 1 (existing setup) - -- Admin users get access to all libraries, regular users get access to library 1 - INSERT INTO user_library (user_id, library_id) - SELECT u.id, 1 - FROM user u; - - -- Add total_duration column to library table - ALTER TABLE library ADD COLUMN total_duration real DEFAULT 0; - UPDATE library SET total_duration = ( - SELECT IFNULL(SUM(duration),0) from album where album.library_id = library.id and missing = 0 - ); - - -- Add default_new_users column to library table - ALTER TABLE library ADD COLUMN default_new_users boolean DEFAULT false; - -- Set library ID 1 (default library) as default for new users - UPDATE library SET default_new_users = true WHERE id = 1; - - -- Add stats column to library_artist junction table for per-library artist statistics - ALTER TABLE library_artist ADD COLUMN stats text DEFAULT '{}'; - - -- Migrate existing global artist stats to per-library format in library_artist table - -- For each library_artist association, copy the artist's global stats - UPDATE library_artist - SET stats = ( - SELECT COALESCE(artist.stats, '{}') - FROM artist - WHERE artist.id = library_artist.artist_id - ); - - -- Remove stats column from artist table to eliminate duplication - -- Stats are now stored per-library in library_artist table - ALTER TABLE artist DROP COLUMN stats; - - -- Create library_tag table for per-library tag statistics - CREATE TABLE library_tag ( - tag_id VARCHAR NOT NULL, - library_id INTEGER NOT NULL, - album_count INTEGER DEFAULT 0 NOT NULL, - media_file_count INTEGER DEFAULT 0 NOT NULL, - PRIMARY KEY (tag_id, library_id), - FOREIGN KEY (tag_id) REFERENCES tag(id) ON DELETE CASCADE, - FOREIGN KEY (library_id) REFERENCES library(id) ON DELETE CASCADE - ); - - -- Create indexes for optimal query performance - CREATE INDEX idx_library_tag_tag_id ON library_tag(tag_id); - CREATE INDEX idx_library_tag_library_id ON library_tag(library_id); - - -- Migrate existing tag stats to per-library format in library_tag table - -- For existing installations, copy current global stats to library ID 1 (default library) - INSERT INTO library_tag (tag_id, library_id, album_count, media_file_count) - SELECT t.id, 1, t.album_count, t.media_file_count - FROM tag t - WHERE EXISTS (SELECT 1 FROM library WHERE id = 1); - - -- Remove global stats from tag table as they are now per-library - ALTER TABLE tag DROP COLUMN album_count; - ALTER TABLE tag DROP COLUMN media_file_count; - `) - - return err -} - -func downAddMultiLibrarySupport(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` - -- Restore stats column to artist table before removing from library_artist - ALTER TABLE artist ADD COLUMN stats text DEFAULT '{}'; - - -- Restore global stats by aggregating from library_artist (simplified approach) - -- In a real rollback scenario, this might need more sophisticated logic - UPDATE artist - SET stats = ( - SELECT COALESCE(la.stats, '{}') - FROM library_artist la - WHERE la.artist_id = artist.id - LIMIT 1 - ); - - ALTER TABLE library_artist DROP COLUMN IF EXISTS stats; - DROP INDEX IF EXISTS idx_user_library_library_id; - DROP INDEX IF EXISTS idx_user_library_user_id; - DROP TABLE IF EXISTS user_library; - ALTER TABLE library DROP COLUMN IF EXISTS total_duration; - ALTER TABLE library DROP COLUMN IF EXISTS default_new_users; - - -- Drop library_tag table and its indexes - DROP INDEX IF EXISTS idx_library_tag_library_id; - DROP INDEX IF EXISTS idx_library_tag_tag_id; - DROP TABLE IF EXISTS library_tag; - `) - return err -} diff --git a/db/migrations/20250823142158_make_playqueue_position_int.sql b/db/migrations/20250823142158_make_playqueue_position_int.sql deleted file mode 100644 index de20f0c79..000000000 --- a/db/migrations/20250823142158_make_playqueue_position_int.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up --- +goose StatementBegin -ALTER TABLE playqueue ADD COLUMN position_int integer; -UPDATE playqueue SET position_int = CAST(position as INTEGER) ; -ALTER TABLE playqueue DROP COLUMN position; -ALTER TABLE playqueue RENAME COLUMN position_int TO position; --- +goose StatementEnd - --- +goose Down diff --git a/db/migrations/20251109010105_add_annotation_rating_date.sql b/db/migrations/20251109010105_add_annotation_rating_date.sql deleted file mode 100644 index 9dac46a5e..000000000 --- a/db/migrations/20251109010105_add_annotation_rating_date.sql +++ /dev/null @@ -1,7 +0,0 @@ --- +goose Up --- +goose StatementBegin -ALTER TABLE annotation ADD COLUMN rated_at datetime; --- +goose StatementEnd - --- +goose Down - \ No newline at end of file diff --git a/db/migrations/20251206013022_create_scrobbles_table.sql b/db/migrations/20251206013022_create_scrobbles_table.sql deleted file mode 100644 index 9791c48e3..000000000 --- a/db/migrations/20251206013022_create_scrobbles_table.sql +++ /dev/null @@ -1,20 +0,0 @@ --- +goose Up --- +goose StatementBegin -CREATE TABLE scrobbles( - media_file_id VARCHAR(255) NOT NULL - REFERENCES media_file(id) - ON DELETE CASCADE - ON UPDATE CASCADE, - user_id VARCHAR(255) NOT NULL - REFERENCES user(id) - ON DELETE CASCADE - ON UPDATE CASCADE, - submission_time INTEGER NOT NULL -); -CREATE INDEX scrobbles_date ON scrobbles (submission_time); --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -DROP TABLE scrobbles; --- +goose StatementEnd diff --git a/db/migrations/20260104203627_playlist_case_insensitive_name.sql b/db/migrations/20260104203627_playlist_case_insensitive_name.sql deleted file mode 100644 index 64b079cca..000000000 --- a/db/migrations/20260104203627_playlist_case_insensitive_name.sql +++ /dev/null @@ -1,99 +0,0 @@ --- +goose Up --- Fix case-insensitive sorting for playlist names -create table playlist_dg_tmp -( - id varchar(255) not null - primary key, - name varchar(255) collate NOCASE default '' not null, - comment varchar(255) default '' not null, - duration real default 0 not null, - song_count integer default 0 not null, - public bool default FALSE not null, - created_at datetime, - updated_at datetime, - path string default '' not null, - sync bool default false not null, - size integer default 0 not null, - rules varchar, - evaluated_at datetime, - owner_id varchar(255) not null - constraint playlist_user_user_id_fk - references user - on update cascade on delete cascade -); - -insert into playlist_dg_tmp(id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, - rules, evaluated_at, owner_id) -select id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at, - owner_id -from playlist; - -drop table playlist; - -alter table playlist_dg_tmp - rename to playlist; - -create index playlist_name - on playlist (name); - -create index playlist_created_at - on playlist (created_at); - -create index playlist_updated_at - on playlist (updated_at); - -create index playlist_evaluated_at - on playlist (evaluated_at); - -create index playlist_size - on playlist (size); - --- +goose Down --- Note: Downgrade loses the collation but preserves data -create table playlist_dg_tmp -( - id varchar(255) not null - primary key, - name varchar(255) default '' not null, - comment varchar(255) default '' not null, - duration real default 0 not null, - song_count integer default 0 not null, - public bool default FALSE not null, - created_at datetime, - updated_at datetime, - path string default '' not null, - sync bool default false not null, - size integer default 0 not null, - rules varchar, - evaluated_at datetime, - owner_id varchar(255) not null - constraint playlist_user_user_id_fk - references user - on update cascade on delete cascade -); - -insert into playlist_dg_tmp(id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, - rules, evaluated_at, owner_id) -select id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at, - owner_id -from playlist; - -drop table playlist; - -alter table playlist_dg_tmp - rename to playlist; - -create index playlist_name - on playlist (name); - -create index playlist_created_at - on playlist (created_at); - -create index playlist_updated_at - on playlist (updated_at); - -create index playlist_evaluated_at - on playlist (evaluated_at); - -create index playlist_size - on playlist (size); diff --git a/db/migrations/20260106000620_create_plugin_table.sql b/db/migrations/20260106000620_create_plugin_table.sql deleted file mode 100644 index bcc83be0b..000000000 --- a/db/migrations/20260106000620_create_plugin_table.sql +++ /dev/null @@ -1,19 +0,0 @@ --- +goose Up -CREATE TABLE IF NOT EXISTS plugin ( - id TEXT PRIMARY KEY, - path TEXT NOT NULL, - manifest JSONB NOT NULL, - config JSONB, - users JSONB, - all_users BOOL NOT NULL DEFAULT false, - libraries JSONB, - all_libraries BOOL NOT NULL DEFAULT false, - enabled BOOL NOT NULL DEFAULT false, - last_error TEXT, - sha256 TEXT NOT NULL, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL -); - --- +goose Down -DROP TABLE IF EXISTS plugin; diff --git a/db/migrations/20260117201522_add_avg_rating_column.sql b/db/migrations/20260117201522_add_avg_rating_column.sql deleted file mode 100644 index f5c8d4522..000000000 --- a/db/migrations/20260117201522_add_avg_rating_column.sql +++ /dev/null @@ -1,23 +0,0 @@ --- +goose Up -ALTER TABLE album ADD COLUMN average_rating REAL NOT NULL DEFAULT 0; -ALTER TABLE media_file ADD COLUMN average_rating REAL NOT NULL DEFAULT 0; -ALTER TABLE artist ADD COLUMN average_rating REAL NOT NULL DEFAULT 0; - --- Populate average_rating from existing ratings -UPDATE album SET average_rating = coalesce( - (SELECT round(avg(rating), 2) FROM annotation WHERE item_id = album.id AND item_type = 'album' AND rating > 0), - 0 -); -UPDATE media_file SET average_rating = coalesce( - (SELECT round(avg(rating), 2) FROM annotation WHERE item_id = media_file.id AND item_type = 'media_file' AND rating > 0), - 0 -); -UPDATE artist SET average_rating = coalesce( - (SELECT round(avg(rating), 2) FROM annotation WHERE item_id = artist.id AND item_type = 'artist' AND rating > 0), - 0 -); - --- +goose Down -ALTER TABLE artist DROP COLUMN average_rating; -ALTER TABLE media_file DROP COLUMN average_rating; -ALTER TABLE album DROP COLUMN average_rating; diff --git a/db/migrations/20260220173400_add_fts5_search.go b/db/migrations/20260220173400_add_fts5_search.go deleted file mode 100644 index 6f2bde429..000000000 --- a/db/migrations/20260220173400_add_fts5_search.go +++ /dev/null @@ -1,391 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - "fmt" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddFts5Search, downAddFts5Search) -} - -// stripPunct generates a SQL expression that strips common punctuation from a column or expression. -// Used during migration to approximate the Go normalizeForFTS function for bulk-populating search_normalized. -func stripPunct(col string) string { - return fmt.Sprintf( - `REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(%s, '.', ''), '/', ''), '-', ''), '''', ''), '&', ''), ',', '')`, - col, - ) -} - -func upAddFts5Search(ctx context.Context, tx *sql.Tx) error { - notice(ctx, tx, "Adding FTS5 full-text search indexes. This may take a moment on large libraries.") - - // Step 1: Add search_participants and search_normalized columns to media_file, album, and artist - _, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`) - if err != nil { - return fmt.Errorf("adding search_participants to media_file: %w", err) - } - _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`) - if err != nil { - return fmt.Errorf("adding search_normalized to media_file: %w", err) - } - _, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_participants TEXT NOT NULL DEFAULT ''`) - if err != nil { - return fmt.Errorf("adding search_participants to album: %w", err) - } - _, err = tx.ExecContext(ctx, `ALTER TABLE album ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`) - if err != nil { - return fmt.Errorf("adding search_normalized to album: %w", err) - } - _, err = tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN search_normalized TEXT NOT NULL DEFAULT ''`) - if err != nil { - return fmt.Errorf("adding search_normalized to artist: %w", err) - } - - // Step 2: Populate search_participants from participants JSON. - // Extract all "name" values from the participants JSON structure. - // participants is a JSON object like: {"artist":[{"name":"...","id":"..."}],"albumartist":[...]} - // We use json_each + json_extract to flatten all names into a space-separated string. - _, err = tx.ExecContext(ctx, ` - UPDATE media_file SET search_participants = COALESCE( - (SELECT group_concat(json_extract(je2.value, '$.name'), ' ') - FROM json_each(media_file.participants) AS je1, - json_each(je1.value) AS je2 - WHERE json_extract(je2.value, '$.name') IS NOT NULL), - '' - ) - WHERE participants IS NOT NULL AND participants != '' AND participants != '{}' - `) - if err != nil { - return fmt.Errorf("populating media_file search_participants: %w", err) - } - - _, err = tx.ExecContext(ctx, ` - UPDATE album SET search_participants = COALESCE( - (SELECT group_concat(json_extract(je2.value, '$.name'), ' ') - FROM json_each(album.participants) AS je1, - json_each(je1.value) AS je2 - WHERE json_extract(je2.value, '$.name') IS NOT NULL), - '' - ) - WHERE participants IS NOT NULL AND participants != '' AND participants != '{}' - `) - if err != nil { - return fmt.Errorf("populating album search_participants: %w", err) - } - - // Step 2b: Populate search_normalized using SQL REPLACE chains for common punctuation. - // The Go code will compute the precise value on next scan; this is a best-effort approximation. - _, err = tx.ExecContext(ctx, fmt.Sprintf(` - UPDATE artist SET search_normalized = %s - WHERE name != %s`, - stripPunct("name"), stripPunct("name"))) - if err != nil { - return fmt.Errorf("populating artist search_normalized: %w", err) - } - - _, err = tx.ExecContext(ctx, fmt.Sprintf(` - UPDATE album SET search_normalized = TRIM(%s || ' ' || %s) - WHERE name != %s OR COALESCE(album_artist, '') != %s`, - stripPunct("name"), stripPunct("COALESCE(album_artist, '')"), - stripPunct("name"), stripPunct("COALESCE(album_artist, '')"))) - if err != nil { - return fmt.Errorf("populating album search_normalized: %w", err) - } - - _, err = tx.ExecContext(ctx, fmt.Sprintf(` - UPDATE media_file SET search_normalized = - TRIM(%s || ' ' || %s || ' ' || %s || ' ' || %s) - WHERE title != %s - OR COALESCE(album, '') != %s - OR COALESCE(artist, '') != %s - OR COALESCE(album_artist, '') != %s`, - stripPunct("title"), stripPunct("COALESCE(album, '')"), - stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')"), - stripPunct("title"), stripPunct("COALESCE(album, '')"), - stripPunct("COALESCE(artist, '')"), stripPunct("COALESCE(album_artist, '')"))) - if err != nil { - return fmt.Errorf("populating media_file search_normalized: %w", err) - } - - // Step 3: Create FTS5 virtual tables - _, err = tx.ExecContext(ctx, ` - CREATE VIRTUAL TABLE IF NOT EXISTS media_file_fts USING fts5( - title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized, - content='', content_rowid='rowid', - tokenize='unicode61 remove_diacritics 2' - ) - `) - if err != nil { - return fmt.Errorf("creating media_file_fts: %w", err) - } - - _, err = tx.ExecContext(ctx, ` - CREATE VIRTUAL TABLE IF NOT EXISTS album_fts USING fts5( - name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized, - content='', content_rowid='rowid', - tokenize='unicode61 remove_diacritics 2' - ) - `) - if err != nil { - return fmt.Errorf("creating album_fts: %w", err) - } - - _, err = tx.ExecContext(ctx, ` - CREATE VIRTUAL TABLE IF NOT EXISTS artist_fts USING fts5( - name, sort_artist_name, search_normalized, - content='', content_rowid='rowid', - tokenize='unicode61 remove_diacritics 2' - ) - `) - if err != nil { - return fmt.Errorf("creating artist_fts: %w", err) - } - - // Step 4: Bulk-populate FTS5 indexes from existing data - _, err = tx.ExecContext(ctx, ` - INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized) - SELECT rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - COALESCE(disc_subtitle, ''), COALESCE(search_participants, ''), - COALESCE(search_normalized, '') - FROM media_file - `) - if err != nil { - return fmt.Errorf("populating media_file_fts: %w", err) - } - - _, err = tx.ExecContext(ctx, ` - INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized) - SELECT rowid, name, COALESCE(sort_album_name, ''), COALESCE(album_artist, ''), - COALESCE(search_participants, ''), COALESCE(discs, ''), - COALESCE(catalog_num, ''), - COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') - FROM json_each(album.tags, '$.albumversion') AS je), ''), - COALESCE(search_normalized, '') - FROM album - `) - if err != nil { - return fmt.Errorf("populating album_fts: %w", err) - } - - _, err = tx.ExecContext(ctx, ` - INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) - SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '') - FROM artist - `) - if err != nil { - return fmt.Errorf("populating artist_fts: %w", err) - } - - // Step 5: Create triggers for media_file - _, err = tx.ExecContext(ctx, ` - CREATE TRIGGER media_file_fts_ai AFTER INSERT ON media_file BEGIN - INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized) - VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist, - NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name, - COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''), - COALESCE(NEW.search_normalized, '')); - END - `) - if err != nil { - return fmt.Errorf("creating media_file_fts insert trigger: %w", err) - } - - _, err = tx.ExecContext(ctx, ` - CREATE TRIGGER media_file_fts_ad AFTER DELETE ON media_file BEGIN - INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized) - VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist, - OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name, - COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''), - COALESCE(OLD.search_normalized, '')); - END - `) - if err != nil { - return fmt.Errorf("creating media_file_fts delete trigger: %w", err) - } - - _, err = tx.ExecContext(ctx, ` - CREATE TRIGGER media_file_fts_au AFTER UPDATE ON media_file - WHEN - OLD.title IS NOT NEW.title OR - OLD.album IS NOT NEW.album OR - OLD.artist IS NOT NEW.artist OR - OLD.album_artist IS NOT NEW.album_artist OR - OLD.sort_title IS NOT NEW.sort_title OR - OLD.sort_album_name IS NOT NEW.sort_album_name OR - OLD.sort_artist_name IS NOT NEW.sort_artist_name OR - OLD.sort_album_artist_name IS NOT NEW.sort_album_artist_name OR - OLD.disc_subtitle IS NOT NEW.disc_subtitle OR - OLD.search_participants IS NOT NEW.search_participants OR - OLD.search_normalized IS NOT NEW.search_normalized - BEGIN - INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized) - VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist, - OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name, - COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''), - COALESCE(OLD.search_normalized, '')); - INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized) - VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist, - NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name, - COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''), - COALESCE(NEW.search_normalized, '')); - END - `) - if err != nil { - return fmt.Errorf("creating media_file_fts update trigger: %w", err) - } - - // Step 6: Create triggers for album - _, err = tx.ExecContext(ctx, ` - CREATE TRIGGER album_fts_ai AFTER INSERT ON album BEGIN - INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized) - VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''), - COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''), - COALESCE(NEW.catalog_num, ''), - COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') - FROM json_each(NEW.tags, '$.albumversion') AS je), ''), - COALESCE(NEW.search_normalized, '')); - END - `) - if err != nil { - return fmt.Errorf("creating album_fts insert trigger: %w", err) - } - - _, err = tx.ExecContext(ctx, ` - CREATE TRIGGER album_fts_ad AFTER DELETE ON album BEGIN - INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized) - VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''), - COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''), - COALESCE(OLD.catalog_num, ''), - COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') - FROM json_each(OLD.tags, '$.albumversion') AS je), ''), - COALESCE(OLD.search_normalized, '')); - END - `) - if err != nil { - return fmt.Errorf("creating album_fts delete trigger: %w", err) - } - - _, err = tx.ExecContext(ctx, ` - CREATE TRIGGER album_fts_au AFTER UPDATE ON album - WHEN - OLD.name IS NOT NEW.name OR - OLD.sort_album_name IS NOT NEW.sort_album_name OR - OLD.album_artist IS NOT NEW.album_artist OR - OLD.search_participants IS NOT NEW.search_participants OR - OLD.discs IS NOT NEW.discs OR - OLD.catalog_num IS NOT NEW.catalog_num OR - OLD.tags IS NOT NEW.tags OR - OLD.search_normalized IS NOT NEW.search_normalized - BEGIN - INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized) - VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''), - COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''), - COALESCE(OLD.catalog_num, ''), - COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') - FROM json_each(OLD.tags, '$.albumversion') AS je), ''), - COALESCE(OLD.search_normalized, '')); - INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized) - VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''), - COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''), - COALESCE(NEW.catalog_num, ''), - COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') - FROM json_each(NEW.tags, '$.albumversion') AS je), ''), - COALESCE(NEW.search_normalized, '')); - END - `) - if err != nil { - return fmt.Errorf("creating album_fts update trigger: %w", err) - } - - // Step 7: Create triggers for artist - _, err = tx.ExecContext(ctx, ` - CREATE TRIGGER artist_fts_ai AFTER INSERT ON artist BEGIN - INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) - VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''), - COALESCE(NEW.search_normalized, '')); - END - `) - if err != nil { - return fmt.Errorf("creating artist_fts insert trigger: %w", err) - } - - _, err = tx.ExecContext(ctx, ` - CREATE TRIGGER artist_fts_ad AFTER DELETE ON artist BEGIN - INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized) - VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''), - COALESCE(OLD.search_normalized, '')); - END - `) - if err != nil { - return fmt.Errorf("creating artist_fts delete trigger: %w", err) - } - - _, err = tx.ExecContext(ctx, ` - CREATE TRIGGER artist_fts_au AFTER UPDATE ON artist - WHEN - OLD.name IS NOT NEW.name OR - OLD.sort_artist_name IS NOT NEW.sort_artist_name OR - OLD.search_normalized IS NOT NEW.search_normalized - BEGIN - INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized) - VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''), - COALESCE(OLD.search_normalized, '')); - INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) - VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''), - COALESCE(NEW.search_normalized, '')); - END - `) - if err != nil { - return fmt.Errorf("creating artist_fts update trigger: %w", err) - } - - return nil -} - -func downAddFts5Search(ctx context.Context, tx *sql.Tx) error { - for _, trigger := range []string{ - "media_file_fts_ai", "media_file_fts_ad", "media_file_fts_au", - "album_fts_ai", "album_fts_ad", "album_fts_au", - "artist_fts_ai", "artist_fts_ad", "artist_fts_au", - } { - _, err := tx.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+trigger) - if err != nil { - return fmt.Errorf("dropping trigger %s: %w", trigger, err) - } - } - - for _, table := range []string{"media_file_fts", "album_fts", "artist_fts"} { - _, err := tx.ExecContext(ctx, "DROP TABLE IF EXISTS "+table) - if err != nil { - return fmt.Errorf("dropping table %s: %w", table, err) - } - } - - // Note: We don't drop search_participants columns because SQLite doesn't support DROP COLUMN - // on older versions, and the column is harmless if left in place. - return nil -} diff --git a/db/migrations/20260228020813_add_plugin_allow_write_access.sql b/db/migrations/20260228020813_add_plugin_allow_write_access.sql deleted file mode 100644 index e17d874a5..000000000 --- a/db/migrations/20260228020813_add_plugin_allow_write_access.sql +++ /dev/null @@ -1,5 +0,0 @@ --- +goose Up -ALTER TABLE plugin ADD COLUMN allow_write_access BOOL NOT NULL DEFAULT false; - --- +goose Down -ALTER TABLE plugin DROP COLUMN allow_write_access; diff --git a/db/migrations/20260228172956_add_playlist_image_file.go b/db/migrations/20260228172956_add_playlist_image_file.go deleted file mode 100644 index da2177aba..000000000 --- a/db/migrations/20260228172956_add_playlist_image_file.go +++ /dev/null @@ -1,22 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddPlaylistImageFile, downAddPlaylistImageFile) -} - -func upAddPlaylistImageFile(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `ALTER TABLE playlist ADD COLUMN image_file VARCHAR(255) DEFAULT '';`) - return err -} - -func downAddPlaylistImageFile(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `ALTER TABLE playlist DROP COLUMN image_file;`) - return err -} diff --git a/db/migrations/20260302021413_rename_playlist_image_fields.go b/db/migrations/20260302021413_rename_playlist_image_fields.go deleted file mode 100644 index 1e9754637..000000000 --- a/db/migrations/20260302021413_rename_playlist_image_fields.go +++ /dev/null @@ -1,30 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upRenamePlaylistImageFields, downRenamePlaylistImageFields) -} - -func upRenamePlaylistImageFields(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `ALTER TABLE playlist RENAME COLUMN image_file TO uploaded_image;`) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `ALTER TABLE playlist ADD COLUMN external_image_url VARCHAR(255) DEFAULT '';`) - return err -} - -func downRenamePlaylistImageFields(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `ALTER TABLE playlist DROP COLUMN external_image_url;`) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `ALTER TABLE playlist RENAME COLUMN uploaded_image TO image_file;`) - return err -} diff --git a/db/migrations/20260307175815_add_codec_and_update_transcodings.go b/db/migrations/20260307175815_add_codec_and_update_transcodings.go deleted file mode 100644 index f52f48440..000000000 --- a/db/migrations/20260307175815_add_codec_and_update_transcodings.go +++ /dev/null @@ -1,73 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/navidrome/navidrome/model/id" - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddCodecAndUpdateTranscodings, downAddCodecAndUpdateTranscodings) -} - -func upAddCodecAndUpdateTranscodings(ctx context.Context, tx *sql.Tx) error { - // Add codec column to media_file. - _, err := tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`) - if err != nil { - return err - } - - // Update old AAC default (adts) to new default (ipod with fragmented MP4). - // Only affects users who still have the unmodified old default command. - _, err = tx.ExecContext(ctx, - `UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?`, - "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", - "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", - ) - if err != nil { - return err - } - - // Add FLAC transcoding for existing installations that were seeded before FLAC was added. - var count int - err = tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count) - if err != nil { - return err - } - if count == 0 { - _, err = tx.ExecContext(ctx, - "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)", - id.NewRandom(), "flac audio", "flac", 0, - "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", - ) - if err != nil { - return err - } - } - - // Add probe_data column for caching ffprobe results. - _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`) - if err != nil { - return err - } - return nil -} - -func downAddCodecAndUpdateTranscodings(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN probe_data`) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `DROP INDEX IF EXISTS media_file_codec`) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN codec`) - return err -} diff --git a/db/migrations/20260309120007_fix_probe_data_null.go b/db/migrations/20260309120007_fix_probe_data_null.go deleted file mode 100644 index c76d6ed1a..000000000 --- a/db/migrations/20260309120007_fix_probe_data_null.go +++ /dev/null @@ -1,28 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upFixProbeDataNull, downFixProbeDataNull) -} - -func upFixProbeDataNull(ctx context.Context, tx *sql.Tx) error { - // Recreate probe_data column as NOT NULL with empty string default. - // The previous migration created it with DEFAULT NULL, which causes - // scan errors when reading into Go string fields. - _, err := tx.ExecContext(ctx, `ALTER TABLE media_file DROP COLUMN probe_data`) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, `ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT '' NOT NULL`) - return err -} - -func downFixProbeDataNull(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20260309203355_ensure_default_transcodings.go b/db/migrations/20260309203355_ensure_default_transcodings.go deleted file mode 100644 index 4df66d26d..000000000 --- a/db/migrations/20260309203355_ensure_default_transcodings.go +++ /dev/null @@ -1,44 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model/id" - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upEnsureDefaultTranscodings, downEnsureDefaultTranscodings) -} - -func upEnsureDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { - // Older installations may be missing default transcodings that were added - // after the initial seeding (e.g., aac was added later than mp3/opus). - // Insert any missing defaults without touching user-customized entries. - // Check both target_format and name since both have UNIQUE constraints, - // and older entries may have a different target_format (e.g., 'oga' vs 'opus') - // but the same name. - for _, t := range consts.DefaultTranscodings { - var count int - err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM transcoding WHERE target_format = ? OR name = ?", t.TargetFormat, t.Name).Scan(&count) - if err != nil { - return err - } - if count == 0 { - _, err = tx.ExecContext(ctx, - "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)", - id.NewRandom(), t.Name, t.TargetFormat, t.DefaultBitRate, t.Command, - ) - if err != nil { - return err - } - } - } - return nil -} - -func downEnsureDefaultTranscodings(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20260310113858_fix_aac_transcode_command.go b/db/migrations/20260310113858_fix_aac_transcode_command.go deleted file mode 100644 index a4fa8fcc1..000000000 --- a/db/migrations/20260310113858_fix_aac_transcode_command.go +++ /dev/null @@ -1,30 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upFixAacTranscodeCommand, downFixAacTranscodeCommand) -} - -func upFixAacTranscodeCommand(ctx context.Context, tx *sql.Tx) error { - // The old AAC command used `-f ipod -movflags frag_keyframe+empty_moov` which produces - // corrupt/silent audio when ffmpeg pipes to stdout (confirmed in ffmpeg 8.0+). - // Switch to `-f adts` (raw AAC framing) which works reliably via pipe. - // Only update rows that still have the old default command. - const oldCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -" - const newCommand = "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -" - _, err := tx.ExecContext(ctx, - "UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?", - newCommand, oldCommand, - ) - return err -} - -func downFixAacTranscodeCommand(_ context.Context, _ *sql.Tx) error { - return nil -} diff --git a/db/migrations/20260315233131_add_artist_uploaded_image.go b/db/migrations/20260315233131_add_artist_uploaded_image.go deleted file mode 100644 index 964e346f5..000000000 --- a/db/migrations/20260315233131_add_artist_uploaded_image.go +++ /dev/null @@ -1,22 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddArtistUploadedImage, downAddArtistUploadedImage) -} - -func upAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `ALTER TABLE artist ADD COLUMN uploaded_image VARCHAR(255) DEFAULT ''`) - return err -} - -func downAddArtistUploadedImage(ctx context.Context, tx *sql.Tx) error { - // This code is executed when the migration is rolled back. - return nil -} diff --git a/db/migrations/20260316000000_normalize_timestamps.sql b/db/migrations/20260316000000_normalize_timestamps.sql deleted file mode 100644 index a2e1183e9..000000000 --- a/db/migrations/20260316000000_normalize_timestamps.sql +++ /dev/null @@ -1,74 +0,0 @@ --- +goose Up - --- Normalize T-format timestamps (RFC3339Nano with 'T' separator) to SQLite-compatible format. --- SQLite uses string comparison for ORDER BY on TEXT columns, so 'T' (ASCII 84) > ' ' (ASCII 32) --- causes T-format timestamps to sort after space-format ones, breaking "Recently Added" ordering. - -UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; -UPDATE album SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; -UPDATE album SET imported_at = replace(replace(imported_at, 'T', ' '), 'Z', '+00:00') WHERE imported_at LIKE '%T%'; -UPDATE album SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%'; - -UPDATE media_file SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; -UPDATE media_file SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; -UPDATE media_file SET birth_time = replace(replace(birth_time, 'T', ' '), 'Z', '+00:00') WHERE birth_time LIKE '%T%'; - -UPDATE artist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; -UPDATE artist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; -UPDATE artist SET external_info_updated_at = replace(replace(external_info_updated_at, 'T', ' '), 'Z', '+00:00') WHERE external_info_updated_at LIKE '%T%'; - -UPDATE annotation SET play_date = replace(replace(play_date, 'T', ' '), 'Z', '+00:00') WHERE play_date LIKE '%T%'; -UPDATE annotation SET starred_at = replace(replace(starred_at, 'T', ' '), 'Z', '+00:00') WHERE starred_at LIKE '%T%'; -UPDATE annotation SET rated_at = replace(replace(rated_at, 'T', ' '), 'Z', '+00:00') WHERE rated_at LIKE '%T%'; - -UPDATE playlist SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; -UPDATE playlist SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; -UPDATE playlist SET evaluated_at = replace(replace(evaluated_at, 'T', ' '), 'Z', '+00:00') WHERE evaluated_at LIKE '%T%'; - -UPDATE user SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; -UPDATE user SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; -UPDATE user SET last_login_at = replace(replace(last_login_at, 'T', ' '), 'Z', '+00:00') WHERE last_login_at LIKE '%T%'; -UPDATE user SET last_access_at = replace(replace(last_access_at, 'T', ' '), 'Z', '+00:00') WHERE last_access_at LIKE '%T%'; - -UPDATE player SET last_seen = replace(replace(last_seen, 'T', ' '), 'Z', '+00:00') WHERE last_seen LIKE '%T%'; - -UPDATE playqueue SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; -UPDATE playqueue SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; - -UPDATE bookmark SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; -UPDATE bookmark SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; - -UPDATE share SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; -UPDATE share SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; -UPDATE share SET expires_at = replace(replace(expires_at, 'T', ' '), 'Z', '+00:00') WHERE expires_at LIKE '%T%'; -UPDATE share SET last_visited_at = replace(replace(last_visited_at, 'T', ' '), 'Z', '+00:00') WHERE last_visited_at LIKE '%T%'; - -UPDATE radio SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; -UPDATE radio SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; - -UPDATE folder SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; -UPDATE folder SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; -UPDATE folder SET images_updated_at = replace(replace(images_updated_at, 'T', ' '), 'Z', '+00:00') WHERE images_updated_at LIKE '%T%'; - -UPDATE library SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; -UPDATE library SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; -UPDATE library SET last_scan_at = replace(replace(last_scan_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_at LIKE '%T%'; -UPDATE library SET last_scan_started_at = replace(replace(last_scan_started_at, 'T', ' '), 'Z', '+00:00') WHERE last_scan_started_at LIKE '%T%'; - -UPDATE scrobble_buffer SET play_time = replace(replace(play_time, 'T', ' '), 'Z', '+00:00') WHERE play_time LIKE '%T%'; -UPDATE scrobble_buffer SET enqueue_time = replace(replace(enqueue_time, 'T', ' '), 'Z', '+00:00') WHERE enqueue_time LIKE '%T%'; - -UPDATE plugin SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') WHERE created_at LIKE '%T%'; -UPDATE plugin SET updated_at = replace(replace(updated_at, 'T', ' '), 'Z', '+00:00') WHERE updated_at LIKE '%T%'; - --- Replace plain indexes with expression indexes for datetime()-based sorting -DROP INDEX IF EXISTS album_created_at; -CREATE INDEX album_created_at ON album(datetime(created_at)); -DROP INDEX IF EXISTS album_updated_at; -CREATE INDEX album_updated_at ON album(datetime(updated_at)); - --- +goose Down -DROP INDEX IF EXISTS album_created_at; -CREATE INDEX album_created_at ON album(created_at); -DROP INDEX IF EXISTS album_updated_at; -CREATE INDEX album_updated_at ON album(updated_at); diff --git a/db/migrations/20260318182414_add_radio_uploaded_image.go b/db/migrations/20260318182414_add_radio_uploaded_image.go deleted file mode 100644 index e92a6d2ef..000000000 --- a/db/migrations/20260318182414_add_radio_uploaded_image.go +++ /dev/null @@ -1,22 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddRadioUploadedImage, downAddRadioUploadedImage) -} - -func upAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `ALTER TABLE radio ADD COLUMN uploaded_image VARCHAR(255) NOT NULL DEFAULT ''`) - return err -} - -func downAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error { - // This code is executed when the migration is rolled back. - return nil -} diff --git a/db/migrations/20260405124200_fix_schema_inconsistencies.sql b/db/migrations/20260405124200_fix_schema_inconsistencies.sql deleted file mode 100644 index 15fe95308..000000000 --- a/db/migrations/20260405124200_fix_schema_inconsistencies.sql +++ /dev/null @@ -1,55 +0,0 @@ --- +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 deleted file mode 100644 index ff47eb95f..000000000 --- a/db/migrations/20260410201914_fix_zero_album_created_at.sql +++ /dev/null @@ -1,22 +0,0 @@ --- +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/db/migrations/20260513173954_move_ss_before_input.go b/db/migrations/20260513173954_move_ss_before_input.go deleted file mode 100644 index 472ae7b43..000000000 --- a/db/migrations/20260513173954_move_ss_before_input.go +++ /dev/null @@ -1,55 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upMoveSsBeforeInput, downMoveSsBeforeInput) -} - -// ssSeekPairs maps old commands (output seeking) to new commands (input seeking). -// Index 0 = old (after -i), index 1 = new (before -i). -var ssSeekPairs = [][2]string{ - { - "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -", - "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", - }, - { - "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", - "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", - }, - { - "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", - "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", - }, - { - "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", - "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", - }, - { - "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", - "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", - }, -} - -func upMoveSsBeforeInput(ctx context.Context, tx *sql.Tx) error { - for _, p := range ssSeekPairs { - if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { - return err - } - } - return nil -} - -func downMoveSsBeforeInput(ctx context.Context, tx *sql.Tx) error { - for _, p := range ssSeekPairs { - if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { - return err - } - } - return nil -} diff --git a/db/migrations/20260520211813_add_media_file_artists_composite_index.sql b/db/migrations/20260520211813_add_media_file_artists_composite_index.sql deleted file mode 100644 index f65050d80..000000000 --- a/db/migrations/20260520211813_add_media_file_artists_composite_index.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up -CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id_role - ON media_file_artists (media_file_id, role); -DROP INDEX IF EXISTS media_file_artists_media_file_id; - --- +goose Down -CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id - ON media_file_artists (media_file_id); -DROP INDEX IF EXISTS media_file_artists_media_file_id_role; diff --git a/db/migrations/20260612171826_add_media_file_missing_library_id_index.sql b/db/migrations/20260612171826_add_media_file_missing_library_id_index.sql deleted file mode 100644 index 8bd8ad5b8..000000000 --- a/db/migrations/20260612171826_add_media_file_missing_library_id_index.sql +++ /dev/null @@ -1,13 +0,0 @@ --- +goose Up --- Covering index for the rowid-only pagination query used by search3 with an empty query --- (full library sync). It must cover both `missing` and `library_id` so SQLite never touches --- the (wide) media_file rows while skipping over large offsets. --- Replaces media_file_missing: the composite serves all `missing = ?` lookups via its prefix. -create index if not exists media_file_missing_library_id - on media_file(missing, library_id); -drop index if exists media_file_missing; - --- +goose Down -create index if not exists media_file_missing - on media_file(missing); -drop index if exists media_file_missing_library_id; diff --git a/db/migrations/20260612222838_make_bpm_bitdepth_nullable.sql b/db/migrations/20260612222838_make_bpm_bitdepth_nullable.sql deleted file mode 100644 index c84323158..000000000 --- a/db/migrations/20260612222838_make_bpm_bitdepth_nullable.sql +++ /dev/null @@ -1,35 +0,0 @@ --- +goose Up -drop index if exists media_file_bpm; - -alter table media_file add column bpm_new integer; -alter table media_file add column bit_depth_new integer; - -update media_file set - bpm_new = nullif(bpm, 0), - bit_depth_new = nullif(bit_depth, 0); - -alter table media_file drop column bpm; -alter table media_file drop column bit_depth; - -alter table media_file rename column bpm_new to bpm; -alter table media_file rename column bit_depth_new to bit_depth; - -create index if not exists media_file_bpm on media_file (bpm); - --- +goose Down -drop index if exists media_file_bpm; - -alter table media_file add column bpm_old integer default 0 not null; -alter table media_file add column bit_depth_old integer default 0 not null; - -update media_file set - bpm_old = coalesce(bpm, 0), - bit_depth_old = coalesce(bit_depth, 0); - -alter table media_file drop column bpm; -alter table media_file drop column bit_depth; - -alter table media_file rename column bpm_old to bpm; -alter table media_file rename column bit_depth_old to bit_depth; - -create index if not exists media_file_bpm on media_file (bpm); diff --git a/db/migrations/20260618120509_add_metadata_to_default_transcodings.go b/db/migrations/20260618120509_add_metadata_to_default_transcodings.go deleted file mode 100644 index 2186cda91..000000000 --- a/db/migrations/20260618120509_add_metadata_to_default_transcodings.go +++ /dev/null @@ -1,64 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddMetadataToDefaultTranscodings, downAddMetadataToDefaultTranscodings) -} - -// metadataPairs maps the current default commands (no metadata mapping) to the -// new defaults that preserve source tags. Index 0 = old, index 1 = new. -// -// The new commands add `-map_metadata 0 -map_metadata 0:s:a:0` after `-map 0:a:0`: -// `-map_metadata 0` copies format-level tags (MP3/FLAC sources) and -// `-map_metadata 0:s:a:0` copies tags from the first audio stream (OPUS/OGG -// sources); both are needed because the two source families store tags at -// different levels. Targeting the audio stream explicitly avoids pulling -// metadata from an embedded cover-art/video stream at index 0. -// -// AAC is included for consistency, but its `-f adts` container cannot hold tags, -// so the flags are a no-op there. -// -// Only rows still holding the exact unmodified default are updated, so any -// user-customized command is left untouched. -var metadataPairs = [][2]string{ - { - "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", - "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -", - }, - { - "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", - "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", - }, - { - "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", - "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -c:a aac -f adts -", - }, - { - "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", - "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -v 0 -c:a flac -f flac -", - }, -} - -func upAddMetadataToDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { - for _, p := range metadataPairs { - if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { - return err - } - } - return nil -} - -func downAddMetadataToDefaultTranscodings(ctx context.Context, tx *sql.Tx) error { - for _, p := range metadataPairs { - if _, err := tx.ExecContext(ctx, `UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { - return err - } - } - return nil -} diff --git a/db/migrations/20260629123100_recently_added_plain_indexes.sql b/db/migrations/20260629123100_recently_added_plain_indexes.sql deleted file mode 100644 index 731959e62..000000000 --- a/db/migrations/20260629123100_recently_added_plain_indexes.sql +++ /dev/null @@ -1,28 +0,0 @@ --- +goose Up - --- The "Recently Added" sort now uses the raw timestamp with an id tiebreak --- instead of datetime(), so the indexes become plain composite (col, id) to --- cover it. Timestamps were already normalized to space-format by --- 20260316000000_normalize_timestamps, so raw-string comparison is safe. - -DROP INDEX IF EXISTS album_created_at; -CREATE INDEX album_created_at ON album(created_at, id); -DROP INDEX IF EXISTS album_updated_at; -CREATE INDEX album_updated_at ON album(updated_at, id); - -DROP INDEX IF EXISTS media_file_created_at; -CREATE INDEX media_file_created_at ON media_file(created_at, id); -DROP INDEX IF EXISTS media_file_updated_at; -CREATE INDEX media_file_updated_at ON media_file(updated_at, id); - --- +goose Down - -DROP INDEX IF EXISTS album_created_at; -CREATE INDEX album_created_at ON album(datetime(created_at)); -DROP INDEX IF EXISTS album_updated_at; -CREATE INDEX album_updated_at ON album(datetime(updated_at)); - -DROP INDEX IF EXISTS media_file_created_at; -CREATE INDEX media_file_created_at ON media_file(created_at); -DROP INDEX IF EXISTS media_file_updated_at; -CREATE INDEX media_file_updated_at ON media_file(updated_at); diff --git a/db/migrations/20260702152457_backfill_artist_search_normalized.go b/db/migrations/20260702152457_backfill_artist_search_normalized.go deleted file mode 100644 index 93902e230..000000000 --- a/db/migrations/20260702152457_backfill_artist_search_normalized.go +++ /dev/null @@ -1,58 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - "fmt" - - "github.com/navidrome/navidrome/utils/str" - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upBackfillArtistSearchNormalized, downBackfillArtistSearchNormalized) -} - -// The FTS5 migration back-filled artist.search_normalized with a SQL approximation that -// cannot transliterate atomic letters (Ø, æ, ß, ...), and the scanner never rewrote the -// column, leaving artists like "GØGGS" unfindable by ASCII searches. Recompute it in Go; -// the artist_fts update trigger re-indexes every row that changes. -func upBackfillArtistSearchNormalized(ctx context.Context, tx *sql.Tx) error { - notice(ctx, tx, "Rebuilding artist search index data. This may take a moment on large libraries.") - - rows, err := tx.QueryContext(ctx, "SELECT id, name, search_normalized FROM artist") - if err != nil { - return fmt.Errorf("querying artists: %w", err) - } - defer rows.Close() - - updates := map[string]string{} - for rows.Next() { - var id, name, current string - if err := rows.Scan(&id, &name, ¤t); err != nil { - return fmt.Errorf("scanning artist: %w", err) - } - if expected := str.NormalizeForFTS(name); expected != current { - updates[id] = expected - } - } - if err := rows.Err(); err != nil { - return fmt.Errorf("iterating artists: %w", err) - } - - stmt, err := tx.PrepareContext(ctx, "UPDATE artist SET search_normalized = ? WHERE id = ?") - if err != nil { - return fmt.Errorf("preparing update: %w", err) - } - defer stmt.Close() - for id, normalized := range updates { - if _, err := stmt.ExecContext(ctx, normalized, id); err != nil { - return fmt.Errorf("updating artist %s: %w", id, err) - } - } - return nil -} - -func downBackfillArtistSearchNormalized(context.Context, *sql.Tx) error { - return nil -} diff --git a/db/migrations/20260703013908_optimize_media_file_sort_indexes.sql b/db/migrations/20260703013908_optimize_media_file_sort_indexes.sql deleted file mode 100644 index dd36bf4c2..000000000 --- a/db/migrations/20260703013908_optimize_media_file_sort_indexes.sql +++ /dev/null @@ -1,56 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- Composite indexes matching the media_file sort mappings for album, artist and --- albumArtist. Without them, SQLite cannot satisfy the multi-column ORDER BY and --- falls back to a full scan + temp B-tree sort of the whole table (including all --- its large columns) even for a small LIMIT. -create index if not exists media_file_album_sort - on media_file(order_album_name, album_id, disc_number, track_number, order_artist_name, title); -create index if not exists media_file_artist_sort - on media_file(order_artist_name, order_album_name, release_date, disc_number, track_number); -create index if not exists media_file_album_artist_sort - on media_file(order_album_artist_name, order_album_name, release_date, disc_number, track_number); - --- These two are strict prefixes of the composites above, so they are redundant now. -drop index if exists media_file_order_album_name; -drop index if exists media_file_order_artist_name; - --- No query filters or sorts on these columns: birth_time is only read in Go code; --- artist/album_artist conditions go through the media_file_artists table. -drop index if exists media_file_birth_time; -drop index if exists media_file_artist; -drop index if exists media_file_album_artist; - --- These expression indexes are only usable when PreferSortTags is enabled, a --- config used by ~0.1% of installations (per insights), yet they are maintained --- on every write of every install. Dropping them means those installs fall back --- to a full sort; everyone else saves the space and the scanner write overhead. -drop index if exists media_file_sort_title; -drop index if exists media_file_sort_artist_name; -drop index if exists media_file_sort_album_name; --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -drop index if exists media_file_album_sort; -drop index if exists media_file_artist_sort; -drop index if exists media_file_album_artist_sort; - -create index if not exists media_file_order_album_name - on media_file(order_album_name); -create index if not exists media_file_order_artist_name - on media_file(order_artist_name); -create index if not exists media_file_birth_time - on media_file(birth_time); -create index if not exists media_file_artist - on media_file(artist); -create index if not exists media_file_album_artist - on media_file(album_artist); -create index if not exists media_file_sort_title - on media_file (coalesce(nullif(sort_title,''),order_title) collate NOCASE); -create index if not exists media_file_sort_artist_name - on media_file (coalesce(nullif(sort_artist_name,''),order_artist_name) collate NOCASE); -create index if not exists media_file_sort_album_name - on media_file (coalesce(nullif(sort_album_name,''),order_album_name) collate NOCASE); --- +goose StatementEnd diff --git a/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql b/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql deleted file mode 100644 index 220d7cf75..000000000 --- a/db/migrations/20260712211040_add_primary_key_and_update_index_for_scrobbles.sql +++ /dev/null @@ -1,39 +0,0 @@ --- +goose Up -CREATE TABLE scrobbles_tmp( - id INTEGER PRIMARY KEY, - media_file_id VARCHAR(255) NOT NULL - REFERENCES media_file(id) - ON DELETE CASCADE - ON UPDATE CASCADE, - user_id VARCHAR(255) NOT NULL - REFERENCES user(id) - ON DELETE CASCADE - ON UPDATE CASCADE, - submission_time INTEGER NOT NULL -); -INSERT INTO scrobbles_tmp SELECT ROWID, media_file_id, user_id, submission_time FROM scrobbles; - -DROP INDEX scrobbles_date; -DROP TABLE scrobbles; -ALTER TABLE scrobbles_tmp RENAME TO scrobbles; -CREATE INDEX scrobbles_user_time ON scrobbles(user_id, submission_time); - - --- +goose Down -CREATE TABLE scrobbles_tmp( - media_file_id VARCHAR(255) NOT NULL - REFERENCES media_file(id) - ON DELETE CASCADE - ON UPDATE CASCADE, - user_id VARCHAR(255) NOT NULL - REFERENCES user(id) - ON DELETE CASCADE - ON UPDATE CASCADE, - submission_time INTEGER NOT NULL -); -INSERT INTO scrobbles_tmp SELECT media_file_id, user_id, submission_time FROM scrobbles; - -DROP INDEX scrobbles_user_time; -DROP TABLE scrobbles; -ALTER TABLE scrobbles_tmp RENAME TO scrobbles; -CREATE INDEX scrobbles_date ON scrobbles(submission_time); \ No newline at end of file diff --git a/db/migrations/20260714120000_add_playlist_average_rating.sql b/db/migrations/20260714120000_add_playlist_average_rating.sql deleted file mode 100644 index 5db642986..000000000 --- a/db/migrations/20260714120000_add_playlist_average_rating.sql +++ /dev/null @@ -1,5 +0,0 @@ --- +goose Up -ALTER TABLE playlist ADD COLUMN average_rating REAL NOT NULL DEFAULT 0; - --- +goose Down -ALTER TABLE playlist DROP COLUMN average_rating; diff --git a/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql b/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql deleted file mode 100644 index 18666eef9..000000000 --- a/db/migrations/20260714123822_add_media_file_title_sort_covering_index.sql +++ /dev/null @@ -1,22 +0,0 @@ --- +goose Up --- +goose StatementBegin - --- Covering index for the title-sorted, library-scoped song listing: --- WHERE missing = ? AND library_id = ? ORDER BY order_title LIMIT n OFFSET m --- (Jellyfin clients page through the whole library this way; non-admin native and --- Subsonic song lists produce the same shape.) --- --- Without it, SQLite walks media_file_order_title and must fetch the table row for --- every *skipped* entry just to evaluate the WHERE, so a deep page costs offset+limit --- random row reads (seconds on cold spinning disks). With the filter columns in the --- index the skip is index-only. `id` is included because the annotation/bookmark --- LEFT JOINs run per candidate row and need the join key; without it each skipped --- entry still triggers a row fetch. -create index if not exists media_file_missing_library_order_title - on media_file(missing, library_id, order_title, id); --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -drop index if exists media_file_missing_library_order_title; --- +goose StatementEnd diff --git a/db/migrations/20260719005427_add_album_replaygain.go b/db/migrations/20260719005427_add_album_replaygain.go deleted file mode 100644 index 7f5665947..000000000 --- a/db/migrations/20260719005427_add_album_replaygain.go +++ /dev/null @@ -1,41 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddAlbumReplaygain, downAddAlbumReplaygain) -} - -func upAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { - // Backfill the most-frequent value per album (matching MediaFiles.ToAlbum), staging RG-bearing rows - // into an indexed temp table — a correlated subquery over a windowed CTE re-scans media_file per album. - _, err := tx.ExecContext(ctx, ` -ALTER TABLE album ADD COLUMN rg_album_gain real; -ALTER TABLE album ADD COLUMN rg_album_peak real; - -CREATE TEMP TABLE _rg_backfill AS - SELECT album_id, rg_album_gain, rg_album_peak FROM media_file - WHERE rg_album_gain IS NOT NULL OR rg_album_peak IS NOT NULL; -CREATE INDEX _rg_backfill_album ON _rg_backfill(album_id); - -UPDATE album SET - rg_album_gain = (SELECT rg_album_gain FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_gain IS NOT NULL - GROUP BY rg_album_gain ORDER BY count(*) DESC, rg_album_gain LIMIT 1), - rg_album_peak = (SELECT rg_album_peak FROM _rg_backfill WHERE _rg_backfill.album_id = album.id AND rg_album_peak IS NOT NULL - GROUP BY rg_album_peak ORDER BY count(*) DESC, rg_album_peak LIMIT 1) -WHERE album.id IN (SELECT album_id FROM _rg_backfill); - -DROP TABLE _rg_backfill; - `) - return err -} - -func downAddAlbumReplaygain(ctx context.Context, tx *sql.Tx) error { - // This code is executed when the migration is rolled back. - return nil -} diff --git a/db/migrations/20260720015443_uniform_canonical_ids.go b/db/migrations/20260720015443_uniform_canonical_ids.go deleted file mode 100644 index 346f22050..000000000 --- a/db/migrations/20260720015443_uniform_canonical_ids.go +++ /dev/null @@ -1,310 +0,0 @@ -package migrations - -import ( - "context" - "crypto/md5" - "database/sql" - "encoding/hex" - "encoding/json" - "fmt" - "math/big" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model/id" - "github.com/pressly/goose/v3" -) - -// canonicalID maps any historical Navidrome id shape to the canonical 22-char base62 encoding -// of a 128-bit value; unrecognized shapes (including empty and share ids) pass through unchanged. -func canonicalID(s string) string { - switch len(s) { - case 22: - v, ok := new(big.Int).SetString(s, 62) - if !ok || v.Sign() < 0 || v.BitLen() <= 128 { - return s - } - sum := md5.Sum([]byte(s)) - return id.Encode(sum) - case 32: - b, err := hex.DecodeString(s) - if err != nil { - return s - } - return id.Encode([16]byte(b)) - case 36: - if s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' { - return s - } - b, err := hex.DecodeString(s[:8] + s[9:13] + s[14:18] + s[19:23] + s[24:]) - if err != nil { - return s - } - return id.Encode([16]byte(b)) - } - return s -} - -func init() { - goose.AddMigrationContext(upUniformCanonicalIds, downUniformCanonicalIds) -} - -// idColumns is the exhaustive list of Navidrome-id-bearing columns; anything absent is deliberately exempt. -var idColumns = []struct{ table, col string }{ - {"media_file", "id"}, {"media_file", "pid"}, {"media_file", "artist_id"}, - {"media_file", "album_id"}, {"media_file", "album_artist_id"}, {"media_file", "folder_id"}, - {"album", "id"}, {"album", "album_artist_id"}, - {"artist", "id"}, - {"folder", "id"}, {"folder", "parent_id"}, - {"tag", "id"}, - {"library_artist", "artist_id"}, - {"user", "id"}, - {"user_props", "user_id"}, - {"playlist", "id"}, {"playlist", "owner_id"}, - {"playlist_tracks", "playlist_id"}, {"playlist_tracks", "media_file_id"}, - {"playlist_fields", "playlist_id"}, - {"annotation", "user_id"}, {"annotation", "item_id"}, - {"bookmark", "user_id"}, {"bookmark", "item_id"}, - {"player", "id"}, {"player", "user_id"}, {"player", "transcoding_id"}, - {"transcoding", "id"}, - {"radio", "id"}, - {"share", "user_id"}, - {"scrobble_buffer", "id"}, {"scrobble_buffer", "user_id"}, {"scrobble_buffer", "media_file_id"}, - {"playqueue", "id"}, {"playqueue", "user_id"}, - {"user_library", "user_id"}, - {"scrobbles", "user_id"}, {"scrobbles", "media_file_id"}, - {"media_file_artists", "media_file_id"}, {"media_file_artists", "artist_id"}, - {"album_artists", "album_id"}, {"album_artists", "artist_id"}, - {"library_tag", "tag_id"}, -} - -// embeddedIDColumns holds ids nested inside a larger value; the id-columns guard checks this -// list against the schema, so every JSON column must appear here or be exempted there. -var embeddedIDColumns = []struct { - table, col string - transform func(string) (string, bool) -}{ - {"playqueue", "items", canonicalizeIDList}, - {"share", "resource_ids", canonicalizeIDList}, - {"plugin", "users", canonicalizePluginUsers}, - {"playlist", "rules", canonicalizePlaylistRules}, -} - -func upUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error { - if err := buildIDMap(ctx, tx); err != nil { - return err - } - for _, tc := range idColumns { - if err := applyIDMap(ctx, tx, tc.table, tc.col); err != nil { - return fmt.Errorf("canonicalizing %s.%s: %w", tc.table, tc.col, err) - } - } - for _, tc := range embeddedIDColumns { - if err := rewriteColumn(ctx, tx, tc.table, tc.col, tc.transform); err != nil { - return fmt.Errorf("canonicalizing %s.%s: %w", tc.table, tc.col, err) - } - } - // Legacy PID specs embed old-shaped album/track ids into composite pids; a full rescan - // rewrites every pid with the new encoding so path-based move matching stays consistent. - if strings.Contains(conf.Server.PID.Track, "legacy") || strings.Contains(conf.Server.PID.Album, "legacy") { - if err := forceFullRescan(ctx, tx); err != nil { - return err - } - } - if err := rotateSessionSecret(ctx, tx); err != nil { - return err - } - _, err := tx.ExecContext(ctx, "DROP TABLE _id_map") - return err -} - -// rotateSessionSecret renames the session JWT secret to the public key, so public-link tokens -// keep verifying while auth.Init mints a fresh session secret, killing every stale session. -func rotateSessionSecret(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, - `UPDATE property SET id = ? WHERE id = ? AND NOT EXISTS (SELECT 1 FROM property WHERE id = ?)`, - consts.JWTPublicSecretKey, consts.JWTSecretKey, consts.JWTPublicSecretKey) - return err -} - -// buildIDMap stages old->new pairs for every id that changes, indexed for the update joins. -func buildIDMap(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, - "CREATE TEMP TABLE _id_map (old_id TEXT PRIMARY KEY, new_id TEXT NOT NULL) WITHOUT ROWID") - if err != nil { - return err - } - ins, err := tx.PrepareContext(ctx, "INSERT OR IGNORE INTO _id_map (old_id, new_id) VALUES (?, ?)") - if err != nil { - return err - } - defer ins.Close() - for _, tc := range idColumns { - if err := collectColumn(ctx, tx, ins, tc.table, tc.col); err != nil { - return fmt.Errorf("collecting %s.%s: %w", tc.table, tc.col, err) - } - } - return nil -} - -func collectColumn(ctx context.Context, tx *sql.Tx, ins *sql.Stmt, table, col string) error { - rows, err := tx.QueryContext(ctx, fmt.Sprintf( - "SELECT DISTINCT %[2]s FROM %[1]s WHERE %[2]s IS NOT NULL AND %[2]s <> ''", table, col)) - if err != nil { - return err - } - defer rows.Close() - for rows.Next() { - var old string - if err := rows.Scan(&old); err != nil { - return err - } - if newID := canonicalID(old); newID != old { - if _, err := ins.ExecContext(ctx, old, newID); err != nil { - return err - } - } - } - return rows.Err() -} - -func applyIDMap(ctx context.Context, tx *sql.Tx, table, col string) error { - _, err := tx.ExecContext(ctx, fmt.Sprintf( - `UPDATE %[1]s SET %[2]s = (SELECT new_id FROM _id_map WHERE old_id = %[1]s.%[2]s) - WHERE %[2]s IN (SELECT old_id FROM _id_map)`, table, col)) - return err -} - -// canonicalizeIDList canonicalizes a comma-separated id list element-wise. -func canonicalizeIDList(s string) (string, bool) { - parts := strings.Split(s, ",") - changed := false - for i, p := range parts { - if n := canonicalID(p); n != p { - parts[i] = n - changed = true - } - } - if !changed { - return s, false - } - return strings.Join(parts, ","), true -} - -// rewriteColumn applies transform to each non-empty cell, updating only rows it changed. -func rewriteColumn(ctx context.Context, tx *sql.Tx, table, col string, transform func(string) (string, bool)) error { - rows, err := tx.QueryContext(ctx, fmt.Sprintf( - "SELECT rowid, %[2]s FROM %[1]s WHERE ifnull(%[2]s, '') <> ''", table, col)) - if err != nil { - return err - } - type change struct { - rowid int64 - val string - } - var changes []change - for rows.Next() { - var rowid int64 - var val string - if err := rows.Scan(&rowid, &val); err != nil { - _ = rows.Close() - return err - } - if newVal, ok := transform(val); ok { - changes = append(changes, change{rowid, newVal}) - } - } - if err := rows.Err(); err != nil { - _ = rows.Close() - return err - } - _ = rows.Close() - if len(changes) == 0 { - return nil - } - upd, err := tx.PrepareContext(ctx, fmt.Sprintf("UPDATE %s SET %s = ? WHERE rowid = ?", table, col)) - if err != nil { - return err - } - defer upd.Close() - for _, c := range changes { - if _, err := upd.ExecContext(ctx, c.val, c.rowid); err != nil { - return err - } - } - return nil -} - -// canonicalizePluginUsers maps a JSON array of user ids; malformed JSON passes through untouched. -func canonicalizePluginUsers(s string) (string, bool) { - var users []string - if err := json.Unmarshal([]byte(s), &users); err != nil { - return s, false - } - changed := false - for i, u := range users { - if n := canonicalID(u); n != u { - users[i] = n - changed = true - } - } - if !changed { - return s, false - } - out, err := json.Marshal(users) - if err != nil { - return s, false - } - return string(out), true -} - -// canonicalizePlaylistRules rewrites inPlaylist/notInPlaylist ids in smart-playlist criteria; malformed JSON passes through. -func canonicalizePlaylistRules(s string) (string, bool) { - var root map[string]any - if err := json.Unmarshal([]byte(s), &root); err != nil { - return s, false - } - if !canonicalizeRulesNode(root) { - return s, false - } - out, err := json.Marshal(root) - if err != nil { - return s, false - } - return string(out), true -} - -// canonicalizeRulesNode walks the criteria tree, canonicalizing the id of any inPlaylist/notInPlaylist object. -func canonicalizeRulesNode(node any) bool { - changed := false - switch v := node.(type) { - case map[string]any: - for k, val := range v { - if lk := strings.ToLower(k); lk == "inplaylist" || lk == "notinplaylist" { - if obj, ok := val.(map[string]any); ok { - if id, ok := obj["id"].(string); ok { - if n := canonicalID(id); n != id { - obj["id"] = n - changed = true - } - } - } - } - if canonicalizeRulesNode(val) { - changed = true - } - } - case []any: - for _, item := range v { - if canonicalizeRulesNode(item) { - changed = true - } - } - } - return changed -} - -func downUniformCanonicalIds(ctx context.Context, tx *sql.Tx) error { - return nil // irreversible data migration -} diff --git a/db/migrations/20260722023032_add_artwork_tables.sql b/db/migrations/20260722023032_add_artwork_tables.sql deleted file mode 100644 index e307d13e8..000000000 --- a/db/migrations/20260722023032_add_artwork_tables.sql +++ /dev/null @@ -1,46 +0,0 @@ --- +goose Up -CREATE TABLE artwork ( - hash TEXT PRIMARY KEY, - mime TEXT NOT NULL, - width INTEGER NOT NULL DEFAULT 0, - height INTEGER NOT NULL DEFAULT 0, - size_bytes INTEGER NOT NULL DEFAULT 0, - blur_hash TEXT NOT NULL DEFAULT '', - thumb_hash TEXT NOT NULL DEFAULT '', - dominant_color TEXT NOT NULL DEFAULT '', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE item_artwork ( - item_kind TEXT NOT NULL, - item_id TEXT NOT NULL, - image_type TEXT NOT NULL DEFAULT 'primary', - hash TEXT NOT NULL DEFAULT '', - source TEXT NOT NULL DEFAULT '', - source_path TEXT NOT NULL DEFAULT '', - ref_mtime INTEGER NOT NULL DEFAULT 0, - attempted_at TIMESTAMP, - updated_at TIMESTAMP, - PRIMARY KEY (item_kind, item_id, image_type) -) WITHOUT ROWID; -CREATE INDEX ix_item_artwork_hash ON item_artwork(hash); - -CREATE TABLE artwork_queue ( - item_kind TEXT NOT NULL, - item_id TEXT NOT NULL, - image_type TEXT NOT NULL DEFAULT 'primary', - priority INTEGER NOT NULL DEFAULT 0, - attempts INTEGER NOT NULL DEFAULT 0, - retry_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - enqueued_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (item_kind, item_id, image_type) -) WITHOUT ROWID; --- Ordered to match DequeueBatch (priority DESC, enqueued_at) so drains stop after n rows; retry_at makes it covering. --- item_kind leads: each drain pool dequeues only its own kinds, so it must seek straight to --- them rather than scan past another pool's backlog. -CREATE INDEX ix_artwork_queue_drain ON artwork_queue(item_kind, priority DESC, enqueued_at, retry_at); - --- +goose Down -DROP TABLE artwork_queue; -DROP TABLE item_artwork; -DROP TABLE artwork; diff --git a/db/migrations/20260810143445_reorder_album_artists_unique_constraint.sql b/db/migrations/20260810143445_reorder_album_artists_unique_constraint.sql deleted file mode 100644 index fd1598e93..000000000 --- a/db/migrations/20260810143445_reorder_album_artists_unique_constraint.sql +++ /dev/null @@ -1,59 +0,0 @@ --- +goose Up --- +goose StatementBegin --- Lead the unique constraint with artist_id (mirroring media_file_artists) so artist-driven --- filters and the artist-delete cascade get an index seek; album_id lookups keep album_artists_album_id. -CREATE TABLE album_artists_tmp -( - album_id varchar not null - REFERENCES album (id) - ON DELETE CASCADE, - artist_id varchar not null - REFERENCES artist (id) - ON DELETE CASCADE, - role varchar default '' not null, - sub_role varchar default '' not null, - CONSTRAINT album_artists - UNIQUE (artist_id, album_id, role, sub_role) -); - -INSERT INTO album_artists_tmp(album_id, artist_id, role, sub_role) -SELECT album_id, artist_id, role, sub_role -FROM album_artists; - -DROP TABLE album_artists; -ALTER TABLE album_artists_tmp RENAME TO album_artists; - -CREATE INDEX album_artists_album_id - ON album_artists (album_id); -CREATE INDEX album_artists_role - ON album_artists (role); --- +goose StatementEnd - --- +goose Down --- +goose StatementBegin -CREATE TABLE album_artists_tmp -( - album_id varchar not null - REFERENCES album (id) - ON DELETE CASCADE, - artist_id varchar not null - REFERENCES artist (id) - ON DELETE CASCADE, - role varchar default '' not null, - sub_role varchar default '' not null, - CONSTRAINT album_artists - UNIQUE (album_id, artist_id, role, sub_role) -); - -INSERT INTO album_artists_tmp(album_id, artist_id, role, sub_role) -SELECT album_id, artist_id, role, sub_role -FROM album_artists; - -DROP TABLE album_artists; -ALTER TABLE album_artists_tmp RENAME TO album_artists; - -CREATE INDEX album_artists_album_id - ON album_artists (album_id); -CREATE INDEX album_artists_role - ON album_artists (role); --- +goose StatementEnd diff --git a/db/migrations/20260811023338_add_item_genre_tag_indexes.sql b/db/migrations/20260811023338_add_item_genre_tag_indexes.sql deleted file mode 100644 index 1a7d75190..000000000 --- a/db/migrations/20260811023338_add_item_genre_tag_indexes.sql +++ /dev/null @@ -1,34 +0,0 @@ --- +goose Up -create table if not exists media_file_tags( - media_file_id varchar not null - references media_file (id) on delete cascade, - tag_id varchar not null - references tag (id) on delete cascade, - constraint media_file_tags unique (media_file_id, tag_id) -); -create index if not exists media_file_tags_tag_id on media_file_tags (tag_id); - -create table if not exists album_tags( - album_id varchar not null - references album (id) on delete cascade, - tag_id varchar not null - references tag (id) on delete cascade, - constraint album_tags unique (album_id, tag_id) -); -create index if not exists album_tags_tag_id on album_tags (tag_id); - --- Backfill genre rows from the per-row `tags` JSON. json_tree over the "$.genre" subtree yields one --- row per node; the "id" key nodes carry the tag ids. Single scan per table, no correlated subquery. -insert or ignore into media_file_tags (media_file_id, tag_id) -select mf.id, jt.value -from media_file mf, json_tree(mf.tags, '$.genre') jt -where jt.key = 'id' and jt.atom is not null; - -insert or ignore into album_tags (album_id, tag_id) -select al.id, jt.value -from album al, json_tree(al.tags, '$.genre') jt -where jt.key = 'id' and jt.atom is not null; - --- +goose Down -drop table if exists media_file_tags; -drop table if exists album_tags; diff --git a/db/migrations/20260815015320_add_user_scrobble_filter.sql b/db/migrations/20260815015320_add_user_scrobble_filter.sql deleted file mode 100644 index 82ed95f3d..000000000 --- a/db/migrations/20260815015320_add_user_scrobble_filter.sql +++ /dev/null @@ -1,5 +0,0 @@ --- +goose Up -alter table user add column scrobble_filter varchar default '' not null; - --- +goose Down -alter table user drop column scrobble_filter; diff --git a/db/migrations/20260816180040_renormalize_album_created_at.sql b/db/migrations/20260816180040_renormalize_album_created_at.sql deleted file mode 100644 index 9af7b2e5a..000000000 --- a/db/migrations/20260816180040_renormalize_album_created_at.sql +++ /dev/null @@ -1,11 +0,0 @@ --- +goose Up - --- Repairs album.created_at values stored in RFC3339 T-format by CopyAttributes. --- These values sort incorrectly in "Recently Added", which compares timestamps as raw strings. - -UPDATE album SET created_at = replace(replace(created_at, 'T', ' '), 'Z', '+00:00') -WHERE created_at LIKE '%T%'; - --- +goose Down - -SELECT 1; diff --git a/db/migrations/20260818002312_add_playlist_imported_hash.sql b/db/migrations/20260818002312_add_playlist_imported_hash.sql deleted file mode 100644 index aab729153..000000000 --- a/db/migrations/20260818002312_add_playlist_imported_hash.sql +++ /dev/null @@ -1,5 +0,0 @@ --- +goose Up -alter table playlist add imported_hash varchar default '' not null; - --- +goose Down -alter table playlist drop column imported_hash; diff --git a/db/migrations/20260819204637_add_artwork_trace_columns.sql b/db/migrations/20260819204637_add_artwork_trace_columns.sql deleted file mode 100644 index 90fbf9725..000000000 --- a/db/migrations/20260819204637_add_artwork_trace_columns.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up -ALTER TABLE item_artwork ADD COLUMN trace jsonb NOT NULL DEFAULT '[]'; -ALTER TABLE item_artwork ADD COLUMN last_failure jsonb NOT NULL DEFAULT '[]'; -ALTER TABLE artwork_queue ADD COLUMN trace jsonb NOT NULL DEFAULT '[]'; - --- +goose Down -ALTER TABLE artwork_queue DROP COLUMN trace; -ALTER TABLE item_artwork DROP COLUMN last_failure; -ALTER TABLE item_artwork DROP COLUMN trace; diff --git a/db/migrations/20260822062750_add_user_token_epoch.sql b/db/migrations/20260822062750_add_user_token_epoch.sql deleted file mode 100644 index bd37ddeb4..000000000 --- a/db/migrations/20260822062750_add_user_token_epoch.sql +++ /dev/null @@ -1,7 +0,0 @@ --- +goose Up - -ALTER TABLE user ADD COLUMN token_epoch INTEGER NOT NULL DEFAULT 0; - --- +goose Down - -ALTER TABLE user DROP COLUMN token_epoch; diff --git a/db/migrations/20260901225726_normalize_artwork_last_failure.sql b/db/migrations/20260901225726_normalize_artwork_last_failure.sql deleted file mode 100644 index 6de12e757..000000000 --- a/db/migrations/20260901225726_normalize_artwork_last_failure.sql +++ /dev/null @@ -1,9 +0,0 @@ --- +goose Up --- 20260819204637 added last_failure with DEFAULT '[]', so every row already in the table got a --- non-empty value. That is how a give-up is now told apart from a definitive "no image", which --- would report every pre-existing absent row as failed. -UPDATE item_artwork SET last_failure = '' WHERE last_failure = '[]'; - --- +goose Down --- Irreversible: a genuine give-up and a backfilled default are indistinguishable once normalized. -SELECT 1; diff --git a/db/migrations/20260922230428_clamp_media_file_int32_tag_numbers.sql b/db/migrations/20260922230428_clamp_media_file_int32_tag_numbers.sql deleted file mode 100644 index dced5d744..000000000 --- a/db/migrations/20260922230428_clamp_media_file_int32_tag_numbers.sql +++ /dev/null @@ -1,24 +0,0 @@ --- +goose Up --- +goose StatementBegin --- 32-bit builds cannot read values above the int32 range written by 64-bit builds. -update media_file set track_number = 0 -where track_number < 0 or track_number > 2147483647; - -update media_file set disc_number = 0 -where disc_number < 0 or disc_number > 2147483647; - -update media_file set bpm = null -where bpm < 0 or bpm > 2147483647; - -update album set discs = ( - select json_group_object(key, value) from json_each(album.discs) - where cast(key as integer) between 0 and 2147483647 -) -where json_valid(discs) and exists ( - select 1 from json_each(album.discs) - where cast(key as integer) not between 0 and 2147483647 -); --- +goose StatementEnd - --- +goose Down -SELECT 1; diff --git a/db/migrations/20260924010054_add_player_api_key_hash.sql b/db/migrations/20260924010054_add_player_api_key_hash.sql deleted file mode 100644 index bbc4cf4d9..000000000 --- a/db/migrations/20260924010054_add_player_api_key_hash.sql +++ /dev/null @@ -1,8 +0,0 @@ --- +goose Up --- +goose StatementBegin -alter table player add column api_key_hash varchar default null; -create unique index if not exists player_api_key_hash on player(api_key_hash); --- +goose StatementEnd - --- +goose Down -SELECT 1; diff --git a/db/migrations/20260929221042_add_library_pid_columns.sql b/db/migrations/20260929221042_add_library_pid_columns.sql deleted file mode 100644 index 487512287..000000000 --- a/db/migrations/20260929221042_add_library_pid_columns.sql +++ /dev/null @@ -1,18 +0,0 @@ --- +goose Up --- +goose StatementBegin -alter table library add column pid_album varchar default '' not null; -alter table library add column pid_track varchar default '' not null; -alter table library add column scanned_pid_album varchar default '' not null; -alter table library add column scanned_pid_track varchar default '' not null; - --- Every library was scanned with the global PID config, so seed it as their scanned config. --- This way the upgrade does not trigger a full rescan. -update library set - scanned_pid_album = coalesce((select value from property where id = 'PIDAlbum'), ''), - scanned_pid_track = coalesce((select value from property where id = 'PIDTrack'), ''); - -delete from property where id in ('PIDAlbum', 'PIDTrack'); --- +goose StatementEnd - --- +goose Down -SELECT 1; diff --git a/db/migrations/id_canonical_test.go b/db/migrations/id_canonical_test.go deleted file mode 100644 index 0befbe469..000000000 --- a/db/migrations/id_canonical_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package migrations - -import ( - "strings" - - "github.com/navidrome/navidrome/model/id" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("canonicalID", func() { - DescribeTable("transforms each historical id shape", - func(in, want string) { - Expect(canonicalID(in)).To(Equal(want)) - }, - Entry("hash-family id (fits 128 bits) is kept", "5cLJPkLA5DK2BADhoeotPk", "5cLJPkLA5DK2BADhoeotPk"), - Entry("overflowing random id is remapped via md5", "zzzzzzzzzzzzzzzzzzzzzz", "3LyqmwQBm5IRqlVjNYASwb"), - Entry("legacy 32-hex is re-encoded value-preserving", "e3b7fc2ae9447bbec37a13bf916e3cf6", "6VHl3uR4kss6sUPKA8Cwnk"), - Entry("playlist uuid is re-encoded value-preserving", "f47ac10b-58cc-4372-a567-0e02b2c3d479", "7rke2SAWaicSeSYzkhww6R"), - Entry("empty string passes through", "", ""), - Entry("share id (10 chars) passes through", "aB3xY9kQz1", "aB3xY9kQz1"), - Entry("truncated Finamp id (16 chars) passes through", "0123456789abcdef", "0123456789abcdef"), - Entry("22 chars with non-base62 char passes through", "!!!!!!!!!!!!!!!!!!!!!!", "!!!!!!!!!!!!!!!!!!!!!!"), - Entry("32 chars non-hex passes through", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"), - Entry("36 chars without uuid dashes passes through", "000000000000000000000000000000000000", "000000000000000000000000000000000000"), - ) - - // The exemptions for participants/tags/folder_ids/similar_artists rest on this invariant. - It("is the identity on every NewHash id", func() { - for _, parts := range [][]string{ - {""}, {"a"}, {"The Beatles"}, {"genre", "electronic"}, - {"/music/Artist/Album", "1"}, {strings.Repeat("x", 500)}, - } { - h := id.NewHash(parts...) - Expect(h).To(HaveLen(22)) - Expect(canonicalID(h)).To(Equal(h), "NewHash(%v) = %q was rewritten", parts, h) - } - }) - - It("is idempotent for every shape", func() { - for _, s := range []string{"5cLJPkLA5DK2BADhoeotPk", "zzzzzzzzzzzzzzzzzzzzzz", - "e3b7fc2ae9447bbec37a13bf916e3cf6", "f47ac10b-58cc-4372-a567-0e02b2c3d479"} { - once := canonicalID(s) - Expect(canonicalID(once)).To(Equal(once)) - } - }) -}) diff --git a/db/migrations/id_columns_guard_test.go b/db/migrations/id_columns_guard_test.go deleted file mode 100644 index c4cb3b241..000000000 --- a/db/migrations/id_columns_guard_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - "os" - "strings" - - _ "github.com/mattn/go-sqlite3" - "github.com/pressly/goose/v3" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Version of the uniform-canonical-ids migration whose idColumns list this guard protects. -const idColumnsMigrationVersion = 20260720015443 - -var _ = Describe("idColumns inventory", func() { - It("covers every id-bearing column present at the migration version", func() { - ctx := context.Background() - db, err := sql.Open("sqlite3", "file::memory:") - Expect(err).ToNot(HaveOccurred()) - db.SetMaxOpenConns(1) // non-shared :memory: — a second conn would be an empty DB - DeferCleanup(func() { _ = db.Close() }) - _, err = db.ExecContext(ctx, "PRAGMA foreign_keys=off") - Expect(err).ToNot(HaveOccurred()) - - goose.SetBaseFS(os.DirFS(".")) - DeferCleanup(func() { goose.SetBaseFS(nil) }) - Expect(goose.SetDialect("sqlite3")).To(Succeed()) - Expect(goose.UpToContext(ctx, db, ".", idColumnsMigrationVersion)).To(Succeed()) - - covered := map[string]bool{} - for _, tc := range idColumns { - covered[tc.table+"."+tc.col] = true - } - for _, tc := range embeddedIDColumns { - covered[tc.table+"."+tc.col] = true - } - // Columns that are id-named or JSON but need no rewrite. - exempt := map[string]string{ - "share.id": "public share URLs, generated separately", - "property.id": "property key, not an entity id", - "plugin.id": "plugin name, not an entity id", - "media_file.participants": "hash-family artist ids, unchanged", - "album.participants": "hash-family artist ids, unchanged", - "media_file.tags": "hash-family tag ids, unchanged", - "album.tags": "hash-family tag ids, unchanged", - "album.folder_ids": "hash-family folder ids, unchanged", - "artist.similar_artists": "hash-family artist ids, unchanged", - "media_file.search_participants": "participant names for FTS, not ids", - "album.search_participants": "participant names for FTS, not ids", - "album.discs": "disc number -> title map", - "media_file.lyrics": "synced lyrics, no ids", - "folder.image_files": "image file names, no ids", - "plugin.manifest": "plugin-authored manifest, no Navidrome ids", - "plugin.config": "free-form plugin config, must not be rewritten", - "plugin.libraries": "integer library ids", - } - - tables, err := queryColumn(ctx, db, "SELECT name FROM sqlite_master WHERE type='table'") - Expect(err).ToNot(HaveOccurred()) - - var dangling []string - for _, table := range tables { - if strings.HasPrefix(table, "sqlite_") || table == "goose_db_version" || strings.Contains(table, "_fts") { - continue - } - rows, err := db.QueryContext(ctx, "SELECT name, type FROM pragma_table_info(?)", table) - Expect(err).ToNot(HaveOccurred()) - for rows.Next() { - var name, typ string - Expect(rows.Scan(&name, &typ)).To(Succeed()) - lname := strings.ToLower(name) - utyp := strings.ToUpper(typ) - // JSON can hide an id under any key, so every JSON column needs a verdict. - isJSON := strings.Contains(utyp, "JSON") - isIDName := lname == "id" || lname == "pid" || - strings.HasSuffix(lname, "_id") || strings.HasSuffix(lname, "_ids") - if !isJSON && !isIDName { - continue - } - if !isJSON && !strings.Contains(utyp, "TEXT") && !strings.Contains(utyp, "CHAR") { - continue // INTEGER ids (rowid PKs, library.id) are not canonical ids - } - if strings.HasPrefix(lname, "mbz_") { - continue // MusicBrainz UUIDs, not Navidrome ids - } - key := table + "." + name - if covered[key] || exempt[key] != "" { - continue - } - dangling = append(dangling, key) - } - Expect(rows.Err()).ToNot(HaveOccurred()) - Expect(rows.Close()).To(Succeed()) - } - Expect(dangling).To(BeEmpty(), - "id-bearing columns missing from idColumns; add them to the migration or exempt with a reason: %v", dangling) - }) -}) - -func queryColumn(ctx context.Context, db *sql.DB, query string) ([]string, error) { - rows, err := db.QueryContext(ctx, query) - if err != nil { - return nil, err - } - defer rows.Close() - var out []string - for rows.Next() { - var s string - if err := rows.Scan(&s); err != nil { - return nil, err - } - out = append(out, s) - } - return out, rows.Err() -} diff --git a/db/migrations/migration.go b/db/migrations/migration.go deleted file mode 100644 index df1c392a5..000000000 --- a/db/migrations/migration.go +++ /dev/null @@ -1,115 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - "fmt" - "strings" - "sync" - - "github.com/navidrome/navidrome/consts" -) - -// Use this in migrations that need to communicate something important (breaking changes, forced reindexes, etc...) -func notice(ctx context.Context, tx *sql.Tx, msg string) { - if isDBInitialized(ctx, tx) { - line := strings.Repeat("*", len(msg)+8) - fmt.Printf("\n%s\nNOTICE: %s\n%s\n\n", line, msg, line) - } -} - -// Call this in migrations that requires a full rescan -func forceFullRescan(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, fmt.Sprintf(` -INSERT OR REPLACE into property (id, value) values ('%s', '1'); -`, consts.FullScanAfterMigrationFlagKey)) - return err -} - -// sq := Update(r.tableName). -// Set("last_scan_started_at", time.Now()). -// Set("full_scan_in_progress", fullScan). -// Where(Eq{"id": id}) - -var ( - once sync.Once - initialized bool -) - -func isDBInitialized(ctx context.Context, tx *sql.Tx) bool { - once.Do(func() { - rows, err := tx.QueryContext(ctx, "select count(*) from property where id=?", consts.InitialSetupFlagKey) - checkErr(err) - initialized = checkCount(rows) > 0 - }) - return initialized -} - -func checkCount(rows *sql.Rows) (count int) { - for rows.Next() { - err := rows.Scan(&count) - checkErr(err) - } - return count -} - -func checkErr(err error) { - if err != nil { - panic(err) - } -} - -type ( - execFunc func() error - execStmtFunc func(stmt string) execFunc - addColumnFunc func(tableName, columnName, columnType, defaultValue, initialValue string) execFunc -) - -func createExecuteFunc(ctx context.Context, tx *sql.Tx) execStmtFunc { - return func(stmt string) execFunc { - return func() error { - _, err := tx.ExecContext(ctx, stmt) - return err - } - } -} - -// Hack way to add a new `not null` column to a table, setting the initial value for existing rows based on a -// SQL expression. It is done in 3 steps: -// 1. Add the column as nullable. Due to the way SQLite manipulates the DDL in memory, we need to add extra padding -// to the default value to avoid truncating it when changing the column to not null -// 2. Update the column with the initial value -// 3. Change the column to not null with the default value -// -// Based on https://stackoverflow.com/a/25917323 -func createAddColumnFunc(ctx context.Context, tx *sql.Tx) addColumnFunc { - return func(tableName, columnName, columnType, defaultValue, initialValue string) execFunc { - return func() error { - // Format the `default null` value to have the same length as the final defaultValue - finalLen := len(fmt.Sprintf(`%s not`, defaultValue)) - tempDefault := fmt.Sprintf(`default %s null`, strings.Repeat(" ", finalLen)) - _, err := tx.ExecContext(ctx, fmt.Sprintf(` -alter table %s add column %s %s %s;`, tableName, columnName, columnType, tempDefault)) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, fmt.Sprintf(` -update %s set %s = %s where %[2]s is null;`, tableName, columnName, initialValue)) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, fmt.Sprintf(` -PRAGMA writable_schema = on; -UPDATE sqlite_master -SET sql = replace(sql, '%[1]s %[2]s %[5]s', '%[1]s %[2]s default %[3]s not null') -WHERE type = 'table' - AND name = '%[4]s'; -PRAGMA writable_schema = off; -`, columnName, columnType, defaultValue, tableName, tempDefault)) - if err != nil { - return err - } - return err - } - } -} diff --git a/db/migrations/migrations_suite_test.go b/db/migrations/migrations_suite_test.go deleted file mode 100644 index bb08ad8aa..000000000 --- a/db/migrations/migrations_suite_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package migrations - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// tests.Init is omitted: the tests package imports db, which imports this package. -func TestMigrations(t *testing.T) { - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Migrations Suite") -} diff --git a/db/migrations/uniform_canonical_ids_test.go b/db/migrations/uniform_canonical_ids_test.go deleted file mode 100644 index ced75cb7e..000000000 --- a/db/migrations/uniform_canonical_ids_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - "encoding/json" - - _ "github.com/mattn/go-sqlite3" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("upUniformCanonicalIds", func() { - var db *sql.DB - var tx *sql.Tx - ctx := context.Background() - - const ( - hashID = "5cLJPkLA5DK2BADhoeotPk" // canonical, kept - randOld = "zzzzzzzzzzzzzzzzzzzzzz" // overflows -> remapped - randNew = "3LyqmwQBm5IRqlVjNYASwb" - legacyOld = "e3b7fc2ae9447bbec37a13bf916e3cf6" // 32-hex -> re-encoded - legacyNew = "6VHl3uR4kss6sUPKA8Cwnk" - uuidOld = "f47ac10b-58cc-4372-a567-0e02b2c3d479" // uuid -> re-encoded - uuidNew = "7rke2SAWaicSeSYzkhww6R" - shareID = "aB3xY9kQz1" // exempt family - - sessionSecret = "encrypted-session-secret-sentinel" - ) - - BeforeEach(func() { - var err error - db, err = sql.Open("sqlite3", "file::memory:") - Expect(err).ToNot(HaveOccurred()) - db.SetMaxOpenConns(1) // non-shared :memory: — every new conn is a fresh empty DB - DeferCleanup(func() { _ = db.Close() }) - - // Minimal fixture: every table/column idColumns and the list rewrites touch. - _, err = db.Exec(` - CREATE TABLE media_file (id text, pid text, artist_id text, album_id text, album_artist_id text, folder_id text, mbz_recording_id text); - CREATE TABLE album (id text, album_artist_id text); - CREATE TABLE artist (id text); - CREATE TABLE folder (id text, parent_id text); - CREATE TABLE tag (id text); - CREATE TABLE library_artist (artist_id text); - CREATE TABLE user (id text); - CREATE TABLE user_props (user_id text); - CREATE TABLE playlist (id text, owner_id text, rules text); - CREATE TABLE playlist_tracks (playlist_id text, media_file_id text); - CREATE TABLE playlist_fields (playlist_id text); - CREATE TABLE annotation (user_id text, item_id text, item_type text); - CREATE TABLE bookmark (user_id text, item_id text); - CREATE TABLE player (id text, user_id text, transcoding_id text); - CREATE TABLE transcoding (id text); - CREATE TABLE radio (id text); - CREATE TABLE share (id text, user_id text, resource_ids text, contents text); - CREATE TABLE scrobble_buffer (id text, user_id text, media_file_id text); - CREATE TABLE playqueue (id text, user_id text, items text); - CREATE TABLE user_library (user_id text, library_id integer); - CREATE TABLE scrobbles (id integer, media_file_id text, user_id text); - CREATE TABLE media_file_artists (media_file_id text, artist_id text); - CREATE TABLE album_artists (album_id text, artist_id text); - CREATE TABLE library_tag (tag_id text, library_id integer); - CREATE TABLE plugin (id text, users text); - CREATE TABLE property (id text primary key, value text); - `) - Expect(err).ToNot(HaveOccurred()) - - seed := func(query string, args ...any) { - _, err := db.Exec(query, args...) - ExpectWithOffset(1, err).ToNot(HaveOccurred()) - } - // media_file: legacy id/pid, hash artist, legacy album, mbz uuid must stay untouched - seed(`INSERT INTO media_file VALUES (?, ?, ?, ?, '', '', ?)`, legacyOld, legacyOld, hashID, legacyOld, uuidOld) - seed(`INSERT INTO album VALUES (?, ?)`, legacyOld, hashID) - seed(`INSERT INTO user VALUES (?)`, randOld) - // uuid id, random owner, smart-playlist rules with an embedded inPlaylist id and a sibling operator - seed(`INSERT INTO playlist VALUES (?, ?, ?)`, uuidOld, randOld, `{"all":[{"inPlaylist":{"id":"`+uuidOld+`"}},{"inTheLast":{"lastPlayed":30}}]}`) - seed(`INSERT INTO annotation VALUES (?, ?, 'media_file')`, randOld, legacyOld) - seed(`INSERT INTO playqueue VALUES (?, ?, ?)`, randOld, randOld, legacyOld+","+hashID) - seed(`INSERT INTO share VALUES (?, ?, ?, 'Album Foo...')`, shareID, randOld, legacyOld+","+uuidOld) - seed(`INSERT INTO user_library VALUES (?, 1)`, randOld) - seed(`INSERT INTO scrobbles VALUES (1, ?, ?)`, legacyOld, randOld) - seed(`INSERT INTO media_file_artists VALUES (?, ?)`, legacyOld, hashID) - seed(`INSERT INTO album_artists VALUES (?, ?)`, legacyOld, hashID) - seed(`INSERT INTO library_tag VALUES (?, 1)`, hashID) - seed(`INSERT INTO plugin VALUES ('lastfm', ?)`, `["`+randOld+`","`+hashID+`"]`) - seed(`INSERT INTO plugin VALUES ('empty', '[]')`) // exempt: empty user list untouched - // malformed JSON in both a plugin list and a playlist rule: must pass through byte-for-byte - seed(`INSERT INTO plugin VALUES ('broken', 'not-json')`) - seed(`INSERT INTO playlist VALUES (?, ?, '{broken')`, hashID, hashID) - seed(`INSERT INTO property VALUES (?, ?)`, consts.JWTSecretKey, sessionSecret) - }) - - JustBeforeEach(func() { - var err error - tx, err = db.Begin() - Expect(err).ToNot(HaveOccurred()) - Expect(upUniformCanonicalIds(ctx, tx)).To(Succeed()) - Expect(tx.Commit()).To(Succeed()) - }) - - get := func(query string) string { - var s string - ExpectWithOffset(1, db.QueryRow(query).Scan(&s)).To(Succeed()) - return s - } - - It("canonicalizes every id family and keeps references consistent", func() { - Expect(get(`SELECT id FROM media_file`)).To(Equal(legacyNew)) - Expect(get(`SELECT pid FROM media_file`)).To(Equal(legacyNew)) - Expect(get(`SELECT artist_id FROM media_file`)).To(Equal(hashID)) - Expect(get(`SELECT album_id FROM media_file`)).To(Equal(legacyNew)) - Expect(get(`SELECT id FROM album`)).To(Equal(legacyNew)) - Expect(get(`SELECT id FROM user`)).To(Equal(randNew)) - Expect(get(`SELECT id FROM playlist WHERE owner_id='` + randNew + `'`)).To(Equal(uuidNew)) - Expect(get(`SELECT owner_id FROM playlist WHERE id='` + uuidNew + `'`)).To(Equal(randNew)) - Expect(get(`SELECT item_id FROM annotation`)).To(Equal(legacyNew)) - Expect(get(`SELECT user_id FROM annotation`)).To(Equal(randNew)) - }) - - It("rewrites list columns element-wise", func() { - Expect(get(`SELECT items FROM playqueue`)).To(Equal(legacyNew + "," + hashID)) - Expect(get(`SELECT resource_ids FROM share`)).To(Equal(legacyNew + "," + uuidNew)) - }) - - It("leaves exempt columns alone", func() { - Expect(get(`SELECT id FROM share`)).To(Equal(shareID)) - Expect(get(`SELECT contents FROM share`)).To(Equal("Album Foo...")) - Expect(get(`SELECT mbz_recording_id FROM media_file`)).To(Equal(uuidOld)) - }) - - It("canonicalizes junction and membership tables", func() { - Expect(get(`SELECT user_id FROM user_library`)).To(Equal(randNew)) - Expect(get(`SELECT media_file_id FROM scrobbles`)).To(Equal(legacyNew)) - Expect(get(`SELECT user_id FROM scrobbles`)).To(Equal(randNew)) - Expect(get(`SELECT media_file_id FROM media_file_artists`)).To(Equal(legacyNew)) - Expect(get(`SELECT artist_id FROM media_file_artists`)).To(Equal(hashID)) // hash-family, unchanged - Expect(get(`SELECT album_id FROM album_artists`)).To(Equal(legacyNew)) - Expect(get(`SELECT artist_id FROM album_artists`)).To(Equal(hashID)) // hash-family, unchanged - Expect(get(`SELECT tag_id FROM library_tag`)).To(Equal(hashID)) // hash-family, unchanged - }) - - It("rewrites JSON columns element-wise", func() { - Expect(get(`SELECT users FROM plugin WHERE id='lastfm'`)).To(Equal(`["` + randNew + `","` + hashID + `"]`)) - Expect(get(`SELECT id FROM plugin WHERE id='lastfm'`)).To(Equal("lastfm")) // plugin name, untouched - - var rules map[string]any - Expect(json.Unmarshal([]byte(get(`SELECT rules FROM playlist WHERE id='`+uuidNew+`'`)), &rules)).To(Succeed()) - all, ok := rules["all"].([]any) - Expect(ok).To(BeTrue()) - Expect(all).To(HaveLen(2)) - inPl := all[0].(map[string]any)["inPlaylist"].(map[string]any) - Expect(inPl["id"]).To(Equal(uuidNew)) - }) - - It("preserves sibling operators alongside a rewritten inPlaylist id", func() { - var rules map[string]any - Expect(json.Unmarshal([]byte(get(`SELECT rules FROM playlist WHERE id='`+uuidNew+`'`)), &rules)).To(Succeed()) - all := rules["all"].([]any) - var sawInPlaylist, sawInTheLast bool - for _, e := range all { - op := e.(map[string]any) - if pl, ok := op["inPlaylist"].(map[string]any); ok { - Expect(pl["id"]).To(Equal(uuidNew)) - sawInPlaylist = true - } - if last, ok := op["inTheLast"].(map[string]any); ok { - Expect(last["lastPlayed"]).To(Equal(float64(30))) - sawInTheLast = true - } - } - Expect(sawInPlaylist).To(BeTrue()) - Expect(sawInTheLast).To(BeTrue()) - }) - - It("leaves exempt JSON rows untouched", func() { - Expect(get(`SELECT users FROM plugin WHERE id='empty'`)).To(Equal("[]")) - }) - - It("passes malformed JSON columns through byte-for-byte", func() { - Expect(get(`SELECT users FROM plugin WHERE id='broken'`)).To(Equal("not-json")) - Expect(get(`SELECT rules FROM playlist WHERE id='` + hashID + `'`)).To(Equal("{broken")) - }) - - rescanCount := func() int { - var count int - ExpectWithOffset(1, db.QueryRow( - `SELECT count(*) FROM property WHERE id = ?`, consts.FullScanAfterMigrationFlagKey).Scan(&count)).To(Succeed()) - return count - } - - It("does not force a full rescan for the default PID config", func() { - Expect(rescanCount()).To(Equal(0)) - }) - - Context("with a legacy PID configuration", func() { - BeforeEach(func() { - prev := conf.Server.PID.Album - conf.Server.PID.Album = "album_legacy" - DeferCleanup(func() { conf.Server.PID.Album = prev }) - }) - - It("forces a full rescan so composite pids are rewritten", func() { - Expect(rescanCount()).To(Equal(1)) - }) - }) - - propCount := func(key string) int { - var count int - ExpectWithOffset(1, db.QueryRow(`SELECT count(*) FROM property WHERE id = ?`, key).Scan(&count)).To(Succeed()) - return count - } - - It("rotates the session secret to the public key", func() { - Expect(propCount(consts.JWTSecretKey)).To(Equal(0)) - Expect(get(`SELECT value FROM property WHERE id = '` + consts.JWTPublicSecretKey + `'`)).To(Equal(sessionSecret)) - }) - - Context("with no stored session secret", func() { - BeforeEach(func() { - _, err := db.Exec(`DELETE FROM property WHERE id = ?`, consts.JWTSecretKey) - Expect(err).ToNot(HaveOccurred()) - }) - - It("is a no-op", func() { - Expect(propCount(consts.JWTSecretKey)).To(Equal(0)) - Expect(propCount(consts.JWTPublicSecretKey)).To(Equal(0)) - }) - }) -}) diff --git a/db/optimize.go b/db/optimize.go deleted file mode 100644 index aff36e8fd..000000000 --- a/db/optimize.go +++ /dev/null @@ -1,274 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strconv" - "strings" - "sync" - "time" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" -) - -var analyzeMux sync.Mutex - -// Optimize refreshes the query-planner statistics with a full ANALYZE. PRAGMA optimize is avoided -// because its limited analysis misestimates Navidrome's low-cardinality indexes. -func Optimize(ctx context.Context) error { - analyzeMux.Lock() - defer analyzeMux.Unlock() - start := time.Now() - if err := optimizeAt(ctx, Db(), start); err != nil { - return err - } - log.Info(ctx, "DB analysis complete", "elapsed", time.Since(start)) - return nil -} - -// OptimizeIfNeeded refreshes statistics when they are stale or a database-changing operation -// marked them for refresh. -func OptimizeIfNeeded(ctx context.Context) (bool, error) { - analyzeMux.Lock() - defer analyzeMux.Unlock() - start := time.Now() - ran, err := optimizeIfNeeded(ctx, Db(), start) - if err != nil || !ran { - return ran, err - } - log.Info(ctx, "DB analysis complete", "elapsed", time.Since(start)) - return true, nil -} - -func optimizeIfNeeded(ctx context.Context, db *sql.DB, now time.Time) (bool, error) { - due, err := optimizeDue(ctx, db, now) - if err != nil || !due { - return false, err - } - return true, optimizeAt(ctx, db, now) -} - -func optimizeDue(ctx context.Context, db *sql.DB, now time.Time) (bool, error) { - backingOff, err := analyzeRetryBackoffActive(ctx, db, now) - if err != nil || backingOff { - return false, err - } - - pending, found, err := getProperty(ctx, db, consts.DBAnalyzePendingKey) - if err != nil { - return false, err - } - if found && pending == "1" { - return true, nil - } - - value, found, err := getProperty(ctx, db, consts.LastDBAnalyzeAtKey) - if err != nil { - return false, err - } - if !found { - return true, nil - } - - lastAnalyze, valid := parseAnalyzeTime(value) - if !valid || lastAnalyze.After(now) { - return true, nil - } - return now.Sub(lastAnalyze) >= consts.DBAnalyzeMaxAge, nil -} - -func parseAnalyzeTime(value string) (time.Time, bool) { - parsed, err := time.Parse(time.RFC3339Nano, value) - return parsed, err == nil -} - -func analyzeRetryBackoffActive(ctx context.Context, db *sql.DB, now time.Time) (bool, error) { - value, found, err := getProperty(ctx, db, consts.DBAnalyzeFailureCountKey) - if err != nil || !found { - return false, err - } - failures, _ := strconv.Atoi(value) - if failures < 1 { - return false, nil - } - - value, found, err = getProperty(ctx, db, consts.LastDBAnalyzeAttemptAtKey) - if err != nil || !found { - return false, err - } - lastAttempt, valid := parseAnalyzeTime(value) - if !valid || lastAttempt.After(now) { - return false, nil - } - return now.Sub(lastAttempt) < analyzeRetryDelay(failures), nil -} - -func analyzeRetryDelay(failures int) time.Duration { - switch failures { - case 1: - return 30 * time.Minute - case 2: - return time.Hour - case 3: - return 2 * time.Hour - default: - return 24 * time.Hour - } -} - -// MarkOptimizePending requests a statistics refresh on the next scheduled maintenance check. -func MarkOptimizePending(ctx context.Context) error { - analyzeMux.Lock() - defer analyzeMux.Unlock() - return markOptimizePending(ctx, Db()) -} - -func markOptimizePending(ctx context.Context, db *sql.DB) error { - return putProperty(ctx, db, consts.DBAnalyzePendingKey, "1") -} - -func optimizeAt(ctx context.Context, db *sql.DB, now time.Time) error { - if err := markOptimizePending(ctx, db); err != nil { - return recordAnalyzeError(ctx, db, now, fmt.Errorf("marking ANALYZE pending: %w", err)) - } - log.Debug(ctx, "Refreshing query planner statistics") - if err := analyzeInSteps(ctx, db); err != nil { - return recordAnalyzeError(ctx, db, now, fmt.Errorf("running ANALYZE: %w", err)) - } - if err := recordAnalyzeSuccess(ctx, db, now); err != nil { - return recordAnalyzeError(ctx, db, now, err) - } - return nil -} - -// One ANALYZE per index (whole table if WITHOUT ROWID or lacking a non-partial index) yields the -// same sqlite_stat1 rows as a full ANALYZE, but frees the write lock between steps. -const analyzeTargetsSQL = ` -SELECT i.name FROM sqlite_schema i JOIN pragma_table_list t ON t.schema = 'main' AND t.name = i.tbl_name -WHERE i.type = 'index' AND t.wr = 0 AND EXISTS (SELECT 1 FROM pragma_index_list(t.name) l WHERE l.partial = 0) -UNION ALL -SELECT t.name FROM pragma_table_list t -WHERE t.schema = 'main' AND t.type IN ('table', 'shadow') AND t.name NOT LIKE 'sqlite_%' - AND (t.wr = 1 OR NOT EXISTS (SELECT 1 FROM pragma_index_list(t.name) l WHERE l.partial = 0))` - -// analyzeMaxYield is just above SQLite's longest busy-handler sleep, so every waiting writer -// retries during the pause. -const analyzeMaxYield = 150 * time.Millisecond - -func analyzeInSteps(ctx context.Context, db *sql.DB) error { - targets, err := analyzeTargets(ctx, db) - if err != nil { - return err - } - for _, target := range targets { - start := time.Now() - if _, err := db.ExecContext(ctx, `ANALYZE "`+strings.ReplaceAll(target, `"`, `""`)+`"`); err != nil { - return fmt.Errorf("analyzing %s: %w", target, err) - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(min(time.Since(start), analyzeMaxYield)): - } - } - return nil -} - -func analyzeTargets(ctx context.Context, db *sql.DB) ([]string, error) { - rows, err := db.QueryContext(ctx, analyzeTargetsSQL) - if err != nil { - return nil, fmt.Errorf("listing ANALYZE targets: %w", err) - } - defer rows.Close() - var targets []string - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - return nil, fmt.Errorf("listing ANALYZE targets: %w", err) - } - targets = append(targets, name) - } - return targets, rows.Err() -} - -func recordAnalyzeSuccess(ctx context.Context, db *sql.DB, now time.Time) error { - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("recording ANALYZE time: %w", err) - } - defer func() { _ = tx.Rollback() }() - if err = putProperty(ctx, tx, consts.LastDBAnalyzeAtKey, now.UTC().Format(time.RFC3339Nano)); err != nil { - return fmt.Errorf("recording ANALYZE time: %w", err) - } - if err = putProperty(ctx, tx, consts.DBAnalyzePendingKey, "0"); err != nil { - return fmt.Errorf("clearing pending ANALYZE: %w", err) - } - if err = putProperty(ctx, tx, consts.DBAnalyzeFailureCountKey, "0"); err != nil { - return fmt.Errorf("clearing ANALYZE failure count: %w", err) - } - if err = tx.Commit(); err != nil { - return fmt.Errorf("recording ANALYZE state: %w", err) - } - return nil -} - -func recordAnalyzeError(ctx context.Context, db *sql.DB, now time.Time, analyzeErr error) error { - if err := recordAnalyzeFailure(ctx, db, now); err != nil { - return errors.Join(analyzeErr, fmt.Errorf("recording ANALYZE failure: %w", err)) - } - return analyzeErr -} - -func recordAnalyzeFailure(ctx context.Context, db *sql.DB, now time.Time) error { - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - - value, found, err := getProperty(ctx, tx, consts.DBAnalyzeFailureCountKey) - if err != nil { - return err - } - failures := 0 - if found { - failures, _ = strconv.Atoi(value) - failures = max(failures, 0) - } - if err = putProperty(ctx, tx, consts.DBAnalyzePendingKey, "1"); err != nil { - return err - } - if err = putProperty(ctx, tx, consts.DBAnalyzeFailureCountKey, strconv.Itoa(failures+1)); err != nil { - return err - } - if err = putProperty(ctx, tx, consts.LastDBAnalyzeAttemptAtKey, now.UTC().Format(time.RFC3339Nano)); err != nil { - return err - } - return tx.Commit() -} - -type sqlExecer interface { - ExecContext(context.Context, string, ...any) (sql.Result, error) -} - -type sqlQueryer interface { - QueryRowContext(context.Context, string, ...any) *sql.Row -} - -func putProperty(ctx context.Context, db sqlExecer, key, value string) error { - _, err := db.ExecContext(ctx, `insert into property(id, value) values(?, ?) - on conflict(id) do update set value=excluded.value`, key, value) - return err -} - -func getProperty(ctx context.Context, db sqlQueryer, key string) (string, bool, error) { - var value string - err := db.QueryRowContext(ctx, "select value from property where id=?", key).Scan(&value) - if errors.Is(err, sql.ErrNoRows) { - return "", false, nil - } - return value, err == nil, err -} diff --git a/db/optimize_test.go b/db/optimize_test.go deleted file mode 100644 index 6ad9b9cb4..000000000 --- a/db/optimize_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package db_test - -import ( - "context" - "database/sql" - "time" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/db" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Optimize", func() { - var ( - ctx context.Context - database *sql.DB - now time.Time - ) - - BeforeEach(func() { - ctx = context.Background() - now = time.Date(2026, time.July, 9, 12, 0, 0, 0, time.UTC) - var err error - database, err = sql.Open(db.Dialect, "file::memory:") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(database.Close) - - _, err = database.Exec(`create table property( - id varchar(255) primary key, - value varchar(255) not null default '' - )`) - Expect(err).ToNot(HaveOccurred()) - _, err = database.Exec("create table analyze_probe(id integer primary key, flag int)") - Expect(err).ToNot(HaveOccurred()) - _, err = database.Exec(`insert into analyze_probe(flag) - with recursive s(x) as (select 1 union all select x+1 from s where x < 3000) - select 0 from s`) - Expect(err).ToNot(HaveOccurred()) - _, err = database.Exec("create index probe_flag on analyze_probe(flag)") - Expect(err).ToNot(HaveOccurred()) - _, err = database.Exec("analyze") - Expect(err).ToNot(HaveOccurred()) - }) - - putProperty := func(key, value string) { - _, err := database.Exec(`insert into property(id, value) values(?, ?) - on conflict(id) do update set value=excluded.value`, key, value) - Expect(err).ToNot(HaveOccurred()) - } - - getProperty := func(key string) string { - var value string - Expect(database.QueryRow("select value from property where id=?", key).Scan(&value)).To(Succeed()) - return value - } - - poisonStats := func() { - _, err := database.Exec("update sqlite_stat1 set stat='3000 50' where idx='probe_flag'") - Expect(err).ToNot(HaveOccurred()) - } - - It("replaces poisoned planner statistics with full-quality ones", func() { - poisonStats() - putProperty(consts.DBAnalyzePendingKey, "1") - - Expect(db.OptimizeDBAt(ctx, database, now)).To(Succeed()) - - var stat string - err := database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat) - Expect(err).ToNot(HaveOccurred()) - // A full ANALYZE sees all 3000 rows share one value: avg rows per key = row count. - Expect(stat).To(Equal("3000 3000")) - Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano))) - Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) - }) - - It("produces the same statistics as a single full ANALYZE", func() { - putProperty(consts.DBAnalyzePendingKey, "1") - for _, stmt := range []string{ - "create table unindexed(id integer primary key, v int)", - "insert into unindexed(v) select flag from analyze_probe", - "create table no_rowid(k text primary key, v int) without rowid", - "insert into no_rowid select 'k' || id, id % 7 from analyze_probe", - "create index no_rowid_v on no_rowid(v)", - "create table partial_only(id integer primary key, v int)", - "insert into partial_only(v) select id % 5 from analyze_probe", - "create index partial_only_v on partial_only(v) where v = 1", - "analyze", - } { - _, err := database.Exec(stmt) - Expect(err).ToNot(HaveOccurred()) - } - statRows := func() []string { - rows, err := database.Query("select tbl || '|' || coalesce(idx, '') || '|' || stat from sqlite_stat1 order by 1") - Expect(err).ToNot(HaveOccurred()) - defer rows.Close() - var res []string - for rows.Next() { - var s string - Expect(rows.Scan(&s)).To(Succeed()) - res = append(res, s) - } - return res - } - fullAnalyze := statRows() - _, err := database.Exec("delete from sqlite_stat1") - Expect(err).ToNot(HaveOccurred()) - - Expect(db.OptimizeDBAt(ctx, database, now)).To(Succeed()) - - Expect(statRows()).To(Equal(fullAnalyze)) - }) - - It("runs when no previous analysis was recorded", func() { - ran, err := db.OptimizeDBIfNeeded(ctx, database, now) - Expect(err).ToNot(HaveOccurred()) - Expect(ran).To(BeTrue()) - Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano))) - }) - - It("skips a recent analysis when no refresh is pending", func() { - lastAnalyze := now.Add(-23 * time.Hour) - putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze.Format(time.RFC3339Nano)) - putProperty(consts.DBAnalyzePendingKey, "0") - poisonStats() - - ran, err := db.OptimizeDBIfNeeded(ctx, database, now) - Expect(err).ToNot(HaveOccurred()) - Expect(ran).To(BeFalse()) - Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze.Format(time.RFC3339Nano))) - - var stat string - Expect(database.QueryRow("select stat from sqlite_stat1 where idx='probe_flag'").Scan(&stat)).To(Succeed()) - Expect(stat).To(Equal("3000 50")) - }) - - It("runs when the previous analysis is stale", func() { - putProperty(consts.LastDBAnalyzeAtKey, now.Add(-consts.DBAnalyzeMaxAge).Format(time.RFC3339Nano)) - putProperty(consts.DBAnalyzePendingKey, "0") - - ran, err := db.OptimizeDBIfNeeded(ctx, database, now) - Expect(err).ToNot(HaveOccurred()) - Expect(ran).To(BeTrue()) - Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(now.Format(time.RFC3339Nano))) - }) - - It("runs when a refresh is pending even if the previous analysis is recent", func() { - putProperty(consts.LastDBAnalyzeAtKey, now.Format(time.RFC3339Nano)) - putProperty(consts.DBAnalyzePendingKey, "1") - - ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(time.Hour)) - Expect(err).ToNot(HaveOccurred()) - Expect(ran).To(BeTrue()) - Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) - }) - - DescribeTable("backs off after consecutive analysis failures", - func(failures string, retryDelay time.Duration) { - putProperty(consts.DBAnalyzePendingKey, "1") - putProperty(consts.DBAnalyzeFailureCountKey, failures) - putProperty(consts.LastDBAnalyzeAttemptAtKey, now.Format(time.RFC3339Nano)) - - ran, err := db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay-time.Nanosecond)) - Expect(err).ToNot(HaveOccurred()) - Expect(ran).To(BeFalse()) - - ran, err = db.OptimizeDBIfNeeded(ctx, database, now.Add(retryDelay)) - Expect(err).ToNot(HaveOccurred()) - Expect(ran).To(BeTrue()) - Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("0")) - Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("0")) - }, - Entry("for 30 minutes after the first failure", "1", 30*time.Minute), - Entry("for one hour after the second failure", "2", time.Hour), - Entry("for two hours after the third failure", "3", 2*time.Hour), - Entry("for 24 hours after the fourth failure", "4", 24*time.Hour), - ) - - It("records consecutive analysis failures", func() { - putProperty(consts.DBAnalyzeFailureCountKey, "2") - - Expect(db.RecordAnalyzeFailure(ctx, database, now)).To(Succeed()) - - Expect(getProperty(consts.DBAnalyzeFailureCountKey)).To(Equal("3")) - Expect(getProperty(consts.LastDBAnalyzeAttemptAtKey)).To(Equal(now.Format(time.RFC3339Nano))) - Expect(getProperty(consts.DBAnalyzePendingKey)).To(Equal("1")) - }) - - It("does not record success when analysis fails", func() { - lastAnalyze := now.Add(-48 * time.Hour).Format(time.RFC3339Nano) - putProperty(consts.LastDBAnalyzeAtKey, lastAnalyze) - canceledCtx, cancel := context.WithCancel(ctx) - cancel() - - Expect(db.OptimizeDBAt(canceledCtx, database, now)).To(MatchError(ContainSubstring("context canceled"))) - Expect(getProperty(consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze)) - }) -}) diff --git a/db/repair.go b/db/repair.go deleted file mode 100644 index 7f8b8d044..000000000 --- a/db/repair.go +++ /dev/null @@ -1,367 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "errors" - "fmt" - "slices" - "strings" -) - -var ftsTables = []string{"media_file_fts", "album_fts", "artist_fts"} - -var ftsTriggerSuffixes = []string{"_ai", "_ad", "_au"} - -// integrityCheckMaxIssues bounds the problems reported; IntegrityCheck asks the -// pragma for one extra row, because it truncates without emitting any marker. -const integrityCheckMaxIssues = 100 - -// IntegrityCheck runs PRAGMA integrity_check and returns the problems it reports, or -// an empty slice when healthy. The second value marks a list that was cut short. -func IntegrityCheck(ctx context.Context, database *sql.DB) ([]string, bool, error) { - rows, err := database.QueryContext(ctx, - fmt.Sprintf("PRAGMA integrity_check(%d)", integrityCheckMaxIssues+1)) - if err != nil { - return nil, false, fmt.Errorf("running integrity_check: %w", err) - } - defer rows.Close() - - var issues []string - for rows.Next() { - var line string - if err := rows.Scan(&line); err != nil { - return nil, false, fmt.Errorf("reading integrity_check results: %w", err) - } - issues = append(issues, line) - } - if err := rows.Err(); err != nil { - return nil, false, fmt.Errorf("reading integrity_check results: %w", err) - } - if len(issues) == 1 && issues[0] == "ok" { - return nil, false, nil - } - if len(issues) > integrityCheckMaxIssues { - return issues[:integrityCheckMaxIssues], true, nil - } - return issues, false, nil -} - -// FKViolation counts the rows in Table that reference missing rows in Parent. -type FKViolation struct { - Table string - Parent string - Count int64 -} - -// ForeignKeyCheck runs PRAGMA foreign_key_check, aggregated per (table, parent) pair -// because the raw pragma emits one row per orphan, unbounded on a large library. -func ForeignKeyCheck(ctx context.Context, database *sql.DB) ([]FKViolation, error) { - rows, err := database.QueryContext(ctx, - `SELECT "table", "parent", count(*) FROM pragma_foreign_key_check GROUP BY "table", "parent"`) - if err != nil { - return nil, fmt.Errorf("running foreign_key_check: %w", err) - } - defer rows.Close() - - var violations []FKViolation - for rows.Next() { - var v FKViolation - if err := rows.Scan(&v.Table, &v.Parent, &v.Count); err != nil { - return nil, fmt.Errorf("reading foreign_key_check results: %w", err) - } - violations = append(violations, v) - } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("reading foreign_key_check results: %w", err) - } - return violations, nil -} - -// IsFTSCorruptionOnly reports whether every integrity issue refers to one of the -// FTS5 search tables, meaning RebuildFTS can fully repair the database. -func IsFTSCorruptionOnly(issues []string) bool { - if len(issues) == 0 { - return false - } - for _, line := range issues { - if !slices.ContainsFunc(ftsTables, func(table string) bool { return strings.Contains(line, table) }) { - return false - } - } - return true -} - -// execer is the subset of *sql.DB and *sql.Tx that verifyFTS needs. -type execer interface { - ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) -} - -// VerifyFTS runs the FTS5 'integrity-check' command on each search table. Unlike a -// full PRAGMA integrity_check, it reads only the FTS indexes, not the whole database. -func VerifyFTS(ctx context.Context, database *sql.DB) error { - return verifyFTS(ctx, database) -} - -func verifyFTS(ctx context.Context, database execer) error { - for _, table := range ftsTables { - stmt := fmt.Sprintf("INSERT INTO %[1]s(%[1]s) VALUES('integrity-check')", table) //nolint:gosec // fixed table list - if _, err := database.ExecContext(ctx, stmt); err != nil { - return fmt.Errorf("verifying %s: %w", table, err) - } - } - return nil -} - -const ftsSearchMigration int64 = 20260220173400 - -var errNotMigrated = errors.New("the FTS search migration has not been applied yet; start Navidrome once to migrate the database first") - -// requireFTSMigration fails unless the FTS search migration has run. The goose table -// is probed separately because a query against a missing table fails at prepare time. -func requireFTSMigration(ctx context.Context, database *sql.DB) error { - migrated, err := hasGooseTable(ctx, database) - if err != nil { - return fmt.Errorf("checking FTS migration status: %w", err) - } - if !migrated { - return errNotMigrated - } - var applied int - if err := database.QueryRowContext(ctx, - "SELECT count(*) FROM goose_db_version WHERE version_id = ?", ftsSearchMigration).Scan(&applied); err != nil { - return fmt.Errorf("checking FTS migration status: %w", err) - } - if applied == 0 { - return errNotMigrated - } - return nil -} - -// RebuildFTS drops the FTS5 search tables and their triggers, recreates them from the -// base tables, and verifies the result before committing. The tables are contentless, -// so no user data is lost. It needs only the FTS migration, not a fully migrated -// schema, because a corrupted DB often cannot run pending migrations. -func RebuildFTS(ctx context.Context, database *sql.DB) error { - if err := requireFTSMigration(ctx, database); err != nil { - return err - } - - tx, err := database.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("starting FTS rebuild transaction: %w", err) - } - defer func() { _ = tx.Rollback() }() - - var stmts []string - for _, table := range ftsTables { - for _, suffix := range ftsTriggerSuffixes { - stmts = append(stmts, "DROP TRIGGER IF EXISTS "+table+suffix) - } - stmts = append(stmts, "DROP TABLE IF EXISTS "+table) - } - stmts = append(stmts, ftsSchemaDDL...) - for _, stmt := range stmts { - if _, err := tx.ExecContext(ctx, stmt); err != nil { - return fmt.Errorf("rebuilding FTS schema: %w", err) - } - } - if err := verifyFTS(ctx, tx); err != nil { - return fmt.Errorf("the rebuilt search index did not verify: %w", err) - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("committing FTS rebuild: %w", err) - } - return nil -} - -// ftsSchemaDDL must reproduce what the full migration chain produces, not what any -// single migration does; the schema comparison in repair_test.go guards the drift. -var ftsSchemaDDL = []string{ - ` - CREATE VIRTUAL TABLE IF NOT EXISTS media_file_fts USING fts5( - title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized, - content='', content_rowid='rowid', - tokenize='unicode61 remove_diacritics 2' - ) - `, - ` - CREATE VIRTUAL TABLE IF NOT EXISTS album_fts USING fts5( - name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized, - content='', content_rowid='rowid', - tokenize='unicode61 remove_diacritics 2' - ) - `, - ` - CREATE VIRTUAL TABLE IF NOT EXISTS artist_fts USING fts5( - name, sort_artist_name, search_normalized, - content='', content_rowid='rowid', - tokenize='unicode61 remove_diacritics 2' - ) - `, - ` - INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized) - SELECT rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - COALESCE(disc_subtitle, ''), COALESCE(search_participants, ''), - COALESCE(search_normalized, '') - FROM media_file - `, - ` - INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized) - SELECT rowid, name, COALESCE(sort_album_name, ''), COALESCE(album_artist, ''), - COALESCE(search_participants, ''), COALESCE(discs, ''), - COALESCE(catalog_num, ''), - COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') - FROM json_each(album.tags, '$.albumversion') AS je), ''), - COALESCE(search_normalized, '') - FROM album - `, - ` - INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) - SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '') - FROM artist - `, - ` - CREATE TRIGGER media_file_fts_ai AFTER INSERT ON media_file BEGIN - INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized) - VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist, - NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name, - COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''), - COALESCE(NEW.search_normalized, '')); - END - `, - ` - CREATE TRIGGER media_file_fts_ad AFTER DELETE ON media_file BEGIN - INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized) - VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist, - OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name, - COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''), - COALESCE(OLD.search_normalized, '')); - END - `, - ` - CREATE TRIGGER media_file_fts_au AFTER UPDATE ON media_file - WHEN - OLD.title IS NOT NEW.title OR - OLD.album IS NOT NEW.album OR - OLD.artist IS NOT NEW.artist OR - OLD.album_artist IS NOT NEW.album_artist OR - OLD.sort_title IS NOT NEW.sort_title OR - OLD.sort_album_name IS NOT NEW.sort_album_name OR - OLD.sort_artist_name IS NOT NEW.sort_artist_name OR - OLD.sort_album_artist_name IS NOT NEW.sort_album_artist_name OR - OLD.disc_subtitle IS NOT NEW.disc_subtitle OR - OLD.search_participants IS NOT NEW.search_participants OR - OLD.search_normalized IS NOT NEW.search_normalized - BEGIN - INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized) - VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist, - OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name, - COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''), - COALESCE(OLD.search_normalized, '')); - INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, - sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, - disc_subtitle, search_participants, search_normalized) - VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist, - NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name, - COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''), - COALESCE(NEW.search_normalized, '')); - END - `, - ` - CREATE TRIGGER album_fts_ai AFTER INSERT ON album BEGIN - INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized) - VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''), - COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''), - COALESCE(NEW.catalog_num, ''), - COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') - FROM json_each(NEW.tags, '$.albumversion') AS je), ''), - COALESCE(NEW.search_normalized, '')); - END - `, - ` - CREATE TRIGGER album_fts_ad AFTER DELETE ON album BEGIN - INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized) - VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''), - COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''), - COALESCE(OLD.catalog_num, ''), - COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') - FROM json_each(OLD.tags, '$.albumversion') AS je), ''), - COALESCE(OLD.search_normalized, '')); - END - `, - ` - CREATE TRIGGER album_fts_au AFTER UPDATE ON album - WHEN - OLD.name IS NOT NEW.name OR - OLD.sort_album_name IS NOT NEW.sort_album_name OR - OLD.album_artist IS NOT NEW.album_artist OR - OLD.search_participants IS NOT NEW.search_participants OR - OLD.discs IS NOT NEW.discs OR - OLD.catalog_num IS NOT NEW.catalog_num OR - OLD.tags IS NOT NEW.tags OR - OLD.search_normalized IS NOT NEW.search_normalized - BEGIN - INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized) - VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''), - COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''), - COALESCE(OLD.catalog_num, ''), - COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') - FROM json_each(OLD.tags, '$.albumversion') AS je), ''), - COALESCE(OLD.search_normalized, '')); - INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, - search_participants, discs, catalog_num, album_version, search_normalized) - VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''), - COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''), - COALESCE(NEW.catalog_num, ''), - COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') - FROM json_each(NEW.tags, '$.albumversion') AS je), ''), - COALESCE(NEW.search_normalized, '')); - END - `, - ` - CREATE TRIGGER artist_fts_ai AFTER INSERT ON artist BEGIN - INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) - VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''), - COALESCE(NEW.search_normalized, '')); - END - `, - ` - CREATE TRIGGER artist_fts_ad AFTER DELETE ON artist BEGIN - INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized) - VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''), - COALESCE(OLD.search_normalized, '')); - END - `, - ` - CREATE TRIGGER artist_fts_au AFTER UPDATE ON artist - WHEN - OLD.name IS NOT NEW.name OR - OLD.sort_artist_name IS NOT NEW.sort_artist_name OR - OLD.search_normalized IS NOT NEW.search_normalized - BEGIN - INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized) - VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''), - COALESCE(OLD.search_normalized, '')); - INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) - VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''), - COALESCE(NEW.search_normalized, '')); - END - `, -} diff --git a/db/repair_test.go b/db/repair_test.go deleted file mode 100644 index eb7321939..000000000 --- a/db/repair_test.go +++ /dev/null @@ -1,309 +0,0 @@ -package db_test - -import ( - "context" - "database/sql" - "fmt" - "path/filepath" - "regexp" - "strings" - - "github.com/navidrome/navidrome/db" - "github.com/pressly/goose/v3" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// newDB returns an in-memory database migrated up to the given goose version -// (0 = fully migrated). -func newDB(ctx context.Context, upTo int64) *sql.DB { - GinkgoHelper() - d, err := sql.Open(db.Dialect, "file::memory:") - Expect(err).ToNot(HaveOccurred()) - d.SetMaxOpenConns(1) // non-shared :memory:, a second conn would be an empty DB - DeferCleanup(func() { _ = d.Close() }) - - _, err = d.ExecContext(ctx, "PRAGMA foreign_keys=off") - Expect(err).ToNot(HaveOccurred()) - goose.SetBaseFS(db.EmbedMigrations) - goose.SetLogger(goose.NopLogger()) - DeferCleanup(func() { goose.SetBaseFS(nil) }) - Expect(goose.SetDialect(db.Dialect)).To(Succeed()) - if upTo == 0 { - Expect(goose.UpContext(ctx, d, "migrations")).To(Succeed()) - } else { - Expect(goose.UpToContext(ctx, d, "migrations", upTo)).To(Succeed()) - } - return d -} - -// openMismatchedIndexDB builds a database whose index is declared over a different -// column than the one it was populated from, so integrity_check reports one issue per row. -func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB { - GinkgoHelper() - path := filepath.Join(GinkgoT().TempDir(), "mismatched.db") - open := func() *sql.DB { - d, err := sql.Open(db.Dialect, path) - Expect(err).ToNot(HaveOccurred()) - d.SetMaxOpenConns(1) - return d - } - d := open() - for _, stmt := range []string{ - `create table t(a, b)`, - fmt.Sprintf(`with recursive s(x) as (select 1 union all select x+1 from s where x < %d) - insert into t select x, x + 10000 from s`, rows), - `create index i on t(a)`, - `pragma writable_schema=on`, - `update sqlite_master set sql = 'CREATE INDEX i ON t(b)' where name = 'i'`, - } { - _, err := d.ExecContext(ctx, stmt) - Expect(err).ToNot(HaveOccurred()) - } - Expect(d.Close()).To(Succeed()) // reopen so SQLite reparses the doctored schema - - d = open() - DeferCleanup(func() { _ = d.Close() }) - return d -} - -var _ = Describe("IsFTSCorruptionOnly", func() { - It("is true when every issue mentions an FTS search table", func() { - Expect(db.IsFTSCorruptionOnly([]string{ - `fts5: corruption found reading blob 42 from table "media_file_fts"`, - `malformed inverted index for FTS5 table main.album_fts`, - `fts5: corruption in "artist_fts"`, - })).To(BeTrue()) - }) - - It("is false when any issue is outside the FTS search tables", func() { - Expect(db.IsFTSCorruptionOnly([]string{ - `fts5: corruption found reading blob 42 from table "media_file_fts"`, - `*** in database main ***`, - })).To(BeFalse()) - }) - - It("is false when there are no issues", func() { - Expect(db.IsFTSCorruptionOnly(nil)).To(BeFalse()) - }) -}) - -var _ = Describe("RebuildFTS schema guard", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - }) - - It("refuses to run on a schema older than the FTS migration", func() { - old := newDB(ctx, db.FTSSearchMigration-1) - - err := db.RebuildFTS(ctx, old) - Expect(err).To(MatchError(ContainSubstring("migration"))) - }) - - It("refuses to run on a database that was never migrated", func() { - empty, err := sql.Open(db.Dialect, "file::memory:") - Expect(err).ToNot(HaveOccurred()) - empty.SetMaxOpenConns(1) - DeferCleanup(func() { _ = empty.Close() }) - - Expect(db.RebuildFTS(ctx, empty)).To(MatchError(ContainSubstring("start Navidrome once"))) - }) - - It("runs on a post-FTS schema even when newer migrations are pending", func() { - behind := newDB(ctx, 20260702152457) - - Expect(db.RebuildFTS(ctx, behind)).To(Succeed()) - }) -}) - -var _ = Describe("Repair", func() { - var ( - ctx context.Context - database *sql.DB - ) - - BeforeEach(func() { - ctx = context.Background() - database = newDB(ctx, 0) - - for _, stmt := range []string{ - `insert into artist(id, name, search_normalized) values ('ar-1', 'Ramones', 'ramones')`, - `insert into album(id, name, search_normalized) values ('al-1', 'Rocket to Russia', 'rocket to russia')`, - `insert into media_file(id, title, search_normalized) values ('mf-1', 'Teenage Lobotomy', 'teenage lobotomy')`, - `insert into media_file(id, title, search_normalized) values ('mf-2', 'Rockaway Beach', 'rockaway beach')`, - } { - _, err := database.ExecContext(ctx, stmt) - Expect(err).ToNot(HaveOccurred()) - } - }) - - corruptFTS := func(table string) { - // 8+ bytes of garbage: a 4-byte blob still parses as a valid empty structure record - _, err := database.ExecContext(ctx, `update `+table+`_data set block = x'deadbeefdeadbeef' where id > 1`) //nolint:gosec - Expect(err).ToNot(HaveOccurred()) - } - - searchFTS := func(table, term string) int { - var count int - err := database.QueryRowContext(ctx, `select count(*) from `+table+` where `+table+` match ?`, term).Scan(&count) - Expect(err).ToNot(HaveOccurred()) - return count - } - - // ftsSchema returns name -> whitespace-normalized DDL for the FTS tables, - // their shadow tables, and their triggers. - ftsSchema := func() map[string]string { - rows, err := database.QueryContext(ctx, - `select name, sql from sqlite_master where name like '%_fts%' and sql is not null`) - Expect(err).ToNot(HaveOccurred()) - defer rows.Close() - ws := regexp.MustCompile(`\s+`) - schema := map[string]string{} - for rows.Next() { - var name, ddl string - Expect(rows.Scan(&name, &ddl)).To(Succeed()) - schema[name] = ws.ReplaceAllString(ddl, " ") - } - Expect(rows.Err()).ToNot(HaveOccurred()) - return schema - } - - Describe("IntegrityCheck", func() { - It("returns no issues for a healthy database", func() { - issues, truncated, err := db.IntegrityCheck(ctx, database) - Expect(err).ToNot(HaveOccurred()) - Expect(issues).To(BeEmpty()) - Expect(truncated).To(BeFalse()) - }) - - It("reports corruption in an FTS index", func() { - corruptFTS("media_file_fts") - issues, truncated, err := db.IntegrityCheck(ctx, database) - Expect(err).ToNot(HaveOccurred()) - Expect(issues).ToNot(BeEmpty()) - Expect(strings.Join(issues, "\n")).To(ContainSubstring("media_file_fts")) - Expect(truncated).To(BeFalse()) - }) - - It("flags the issue list as truncated when there are more issues than the limit", func() { - broken := openMismatchedIndexDB(ctx, 300) - - issues, truncated, err := db.IntegrityCheck(ctx, broken) - Expect(err).ToNot(HaveOccurred()) - Expect(issues).To(HaveLen(100)) - Expect(truncated).To(BeTrue()) - }) - - It("does not flag truncation when the issues exactly fill the limit", func() { - broken := openMismatchedIndexDB(ctx, 100) - - issues, truncated, err := db.IntegrityCheck(ctx, broken) - Expect(err).ToNot(HaveOccurred()) - Expect(issues).To(HaveLen(100)) - Expect(truncated).To(BeFalse()) - }) - }) - - Describe("ForeignKeyCheck", func() { - It("returns no violations for a healthy database", func() { - violations, err := db.ForeignKeyCheck(ctx, database) - Expect(err).ToNot(HaveOccurred()) - Expect(violations).To(BeEmpty()) - }) - - It("reports rows referencing missing parents", func() { - _, err := database.ExecContext(ctx, - `insert into media_file(id, title, library_id) values ('mf-bad', 'Orphan', 999)`) - Expect(err).ToNot(HaveOccurred()) - - violations, err := db.ForeignKeyCheck(ctx, database) - Expect(err).ToNot(HaveOccurred()) - Expect(violations).To(HaveLen(1)) - Expect(violations[0].Table).To(Equal("media_file")) - Expect(violations[0].Parent).To(Equal("library")) - Expect(violations[0].Count).To(BeNumerically("==", 1)) - }) - }) - - Describe("VerifyFTS", func() { - It("passes on a healthy index", func() { - Expect(db.VerifyFTS(ctx, database)).To(Succeed()) - }) - - It("fails on a corrupted index, naming the table", func() { - corruptFTS("album_fts") - Expect(db.VerifyFTS(ctx, database)).To(MatchError(ContainSubstring("album_fts"))) - }) - }) - - Describe("RebuildFTS", func() { - It("repairs a corrupted FTS index", func() { - corruptFTS("media_file_fts") - - Expect(db.RebuildFTS(ctx, database)).To(Succeed()) - - issues, _, err := db.IntegrityCheck(ctx, database) - Expect(err).ToNot(HaveOccurred()) - Expect(issues).To(BeEmpty()) - Expect(db.VerifyFTS(ctx, database)).To(Succeed()) - Expect(searchFTS("media_file_fts", "lobotomy")).To(Equal(1)) - Expect(searchFTS("album_fts", "russia")).To(Equal(1)) - Expect(searchFTS("artist_fts", "ramones")).To(Equal(1)) - }) - - It("recreates tables and triggers dropped by hand", func() { - for _, table := range db.FTSTables { - for _, suffix := range db.FTSTriggerSuffixes { - _, err := database.ExecContext(ctx, "drop trigger "+table+suffix) - Expect(err).ToNot(HaveOccurred()) - } - _, err := database.ExecContext(ctx, "drop table "+table) - Expect(err).ToNot(HaveOccurred()) - } - - Expect(db.RebuildFTS(ctx, database)).To(Succeed()) - - Expect(searchFTS("media_file_fts", "rockaway")).To(Equal(1)) - }) - - It("rolls back and keeps the old index when the rebuild fails", func() { - // Triggers go first: SQLite refuses to drop a column they reference. - for _, suffix := range db.FTSTriggerSuffixes { - _, err := database.ExecContext(ctx, "drop trigger media_file_fts"+suffix) - Expect(err).ToNot(HaveOccurred()) - } - _, err := database.ExecContext(ctx, `alter table media_file drop column disc_subtitle`) - Expect(err).ToNot(HaveOccurred()) - - Expect(db.RebuildFTS(ctx, database)).ToNot(Succeed()) - - Expect(searchFTS("media_file_fts", "lobotomy")).To(Equal(1)) - Expect(searchFTS("album_fts", "russia")).To(Equal(1)) - }) - - It("produces the same schema as the migration", func() { - migrated := ftsSchema() - Expect(migrated).ToNot(BeEmpty()) - - Expect(db.RebuildFTS(ctx, database)).To(Succeed()) - - Expect(ftsSchema()).To(Equal(migrated)) - }) - - It("leaves working triggers behind", func() { - Expect(db.RebuildFTS(ctx, database)).To(Succeed()) - - _, err := database.ExecContext(ctx, - `insert into artist(id, name, search_normalized) values ('ar-2', 'Blondie', 'blondie')`) - Expect(err).ToNot(HaveOccurred()) - Expect(searchFTS("artist_fts", "blondie")).To(Equal(1)) - - _, err = database.ExecContext(ctx, `delete from artist where id = 'ar-2'`) - Expect(err).ToNot(HaveOccurred()) - Expect(searchFTS("artist_fts", "blondie")).To(BeZero()) - }) - }) -}) diff --git a/git/pre-commit b/git/pre-commit index 39ec8797f..12bb32d49 100755 --- a/git/pre-commit +++ b/git/pre-commit @@ -10,9 +10,9 @@ # # This script does not handle file names that contain spaces. -gofmtcmd="go tool goimports" +gofmtcmd="go run golang.org/x/tools/cmd/goimports" -gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$' | grep -v '_gen.go$' | grep -v '.pb.go$') +gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$' | grep -v '_gen.go$') [ -z "$gofiles" ] && exit 0 unformatted=$($gofmtcmd -l $gofiles) diff --git a/go.mod b/go.mod index e96b8c8b3..27c4b0e63 100644 --- a/go.mod +++ b/go.mod @@ -1,155 +1,137 @@ module github.com/navidrome/navidrome -go 1.27 - -// Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb +go 1.19 require ( + code.cloudfoundry.org/go-diodes v0.0.0-20190809170250-f77fb823c7ee github.com/Masterminds/squirrel v1.5.4 - github.com/andybalholm/cascadia v1.3.5 - github.com/bmatcuk/doublestar/v4 v4.10.2 - github.com/deluan/rest v1.0.1 - github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 - github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 + github.com/ReneKroon/ttlcache/v2 v2.11.0 + github.com/beego/beego/v2 v2.0.7 + github.com/bradleyjkemp/cupaloy/v2 v2.8.0 + github.com/deepmap/oapi-codegen v1.12.5-0.20230513000919-14548c7e7bbe + github.com/deluan/rest v0.0.0-20211101235434-380523c4bb47 + github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 + github.com/dhowden/tag v0.0.0-20220618230019-adf36e896086 + github.com/disintegration/imaging v1.6.2 github.com/djherbis/atime v1.1.0 - github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22 - github.com/djherbis/stream v1.5.1 - github.com/djherbis/times v1.6.0 - github.com/dustin/go-humanize v1.1.0 - github.com/extism/go-sdk v1.7.1 + github.com/djherbis/fscache v0.10.2-0.20220222230828-2909c950912d + github.com/djherbis/stream v1.4.0 + github.com/dustin/go-humanize v1.0.1 github.com/fatih/structs v1.1.0 - github.com/gen2brain/webp v0.6.4 - github.com/getkin/kin-openapi v0.149.0 - github.com/go-chi/chi/v5 v5.3.2 - github.com/go-chi/cors v1.2.2 - github.com/go-chi/httprate v0.16.0 - github.com/go-chi/jwtauth/v5 v5.4.0 - github.com/go-viper/encoding/ini v0.1.1 - github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/gohugoio/hashstructure v1.1.0 - github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc - github.com/google/uuid v1.6.0 - github.com/google/wire v0.7.0 - github.com/gorilla/websocket v1.5.3 + github.com/getkin/kin-openapi v0.116.0 + github.com/go-chi/chi/v5 v5.0.8 + github.com/go-chi/cors v1.2.1 + github.com/go-chi/httprate v0.7.1 + github.com/go-chi/jwtauth/v5 v5.1.0 + github.com/google/uuid v1.3.0 + github.com/google/wire v0.5.0 github.com/hashicorp/go-multierror v1.1.1 - github.com/jellydator/ttlcache/v3 v3.4.1 - github.com/kardianos/service v1.3.0 github.com/kr/pretty v0.3.1 - github.com/lestrrat-go/jwx/v3 v3.3.0 - github.com/mattn/go-sqlite3 v1.14.52 - github.com/microcosm-cc/bluemonday v1.0.27 - github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.33.0 - github.com/onsi/gomega v1.44.0 - github.com/pelletier/go-toml/v2 v2.4.3 - github.com/pmezard/go-difflib v1.0.0 - github.com/pocketbase/dbx v1.12.0 - github.com/pressly/goose/v3 v3.28.0 - github.com/prometheus/client_golang v1.24.1 - github.com/rjeczalik/notify v0.9.3 + github.com/lestrrat-go/jwx/v2 v2.0.9 + github.com/matoous/go-nanoid/v2 v2.0.0 + github.com/mattn/go-sqlite3 v1.14.16 + github.com/mattn/go-zglob v0.0.3 + github.com/microcosm-cc/bluemonday v1.0.23 + github.com/mileusna/useragent v1.2.1 + github.com/onsi/ginkgo/v2 v2.9.2 + github.com/onsi/gomega v1.27.6 + github.com/pressly/goose/v3 v3.10.0 + github.com/prometheus/client_golang v1.14.0 github.com/robfig/cron/v3 v3.0.1 - github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 - github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 - github.com/sirupsen/logrus v1.10.2 - github.com/spf13/cobra v1.10.2 - github.com/spf13/viper v1.21.0 - github.com/stretchr/testify v1.12.1 - github.com/tetratelabs/wazero v1.12.0 - github.com/unrolled/secure v1.17.0 - github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 - github.com/zeebo/xxh3 v1.1.0 - go.senan.xyz/taglib v0.11.1 - go.uber.org/goleak v1.3.0 - golang.org/x/image v0.46.0 - golang.org/x/net v0.59.0 - golang.org/x/sync v0.23.0 - golang.org/x/sys v0.48.0 - golang.org/x/term v0.46.0 - golang.org/x/text v0.42.0 - golang.org/x/time v0.16.0 + github.com/sirupsen/logrus v1.9.0 + github.com/spf13/cobra v1.7.0 + github.com/spf13/viper v1.15.0 + github.com/stretchr/testify v1.8.2 + github.com/unrolled/secure v1.13.0 + github.com/xrash/smetrics v0.0.0-20200730060457-89a2a8a1fb0b + golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e + golang.org/x/image v0.7.0 + golang.org/x/sync v0.1.0 + golang.org/x/text v0.9.0 + golang.org/x/tools v0.8.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - dario.cat/mergo v1.0.2 // indirect - github.com/Masterminds/semver/v3 v3.5.0 // indirect - github.com/atombender/go-jsonschema v0.20.0 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/reflex v0.3.2 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/creack/pty v1.1.24 // indirect - github.com/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.11.1 // indirect - github.com/fsnotify/fsnotify v1.10.1 // indirect - github.com/go-logr/logr v1.4.4 // indirect - github.com/go-openapi/jsonpointer v0.22.5 // indirect - github.com/go-openapi/swag/jsonname v0.25.5 // indirect - github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/gobwas/glob v1.0.0 // indirect - github.com/goccy/go-json v0.10.6 // indirect - github.com/goccy/go-yaml v1.19.2 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe // indirect - github.com/google/subcommands v1.2.0 // indirect - github.com/gorilla/css v1.0.1 // indirect + github.com/bytedance/sonic v1.8.0 // indirect + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect + github.com/fsnotify/fsnotify v1.6.0 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.9.0 // indirect + github.com/go-logr/logr v1.2.3 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/swag v0.21.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.11.2 // indirect + github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-cmp v0.5.9 // indirect + github.com/google/pprof v0.0.0-20230323073829-e72429f035bd // indirect + github.com/gorilla/css v1.0.0 // indirect github.com/gorilla/mux v1.8.0 // indirect - github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724 // indirect + github.com/hashicorp/errwrap v1.0.0 // indirect + github.com/hashicorp/golang-lru v0.5.4 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/klauspost/cpuid/v2 v2.4.0 // indirect + github.com/invopop/yaml v0.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.0.9 // indirect github.com/kr/text v0.2.0 // indirect + github.com/labstack/echo/v4 v4.10.2 // indirect + github.com/labstack/gommon v0.4.0 // indirect 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.4.0 // indirect - github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect + github.com/leodido/go-urn v1.2.1 // indirect + github.com/lestrrat-go/blackmagic v1.0.1 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc/v3 v3.0.6 // 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 - github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/oasdiff/yaml v0.1.1 // indirect - github.com/oasdiff/yaml3 v0.0.14 // indirect - github.com/ogier/pflag v0.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.4 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/onsi/ginkgo v1.16.5 // indirect + github.com/pelletier/go-toml/v2 v2.0.6 // indirect + github.com/perimeterx/marshmallow v1.1.4 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.70.1 // indirect - github.com/prometheus/procfs v0.22.0 // indirect - github.com/rogpeppe/go-internal v1.16.0 // indirect - github.com/sagikazarmark/locafero v0.12.0 // indirect - github.com/sanity-io/litter v1.5.8 // indirect - github.com/segmentio/asm v1.2.1 // indirect - github.com/sethvargo/go-retry v0.4.0 // indirect - github.com/sosodev/duration v1.3.1 // indirect - github.com/spf13/afero v1.15.0 // indirect - github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/stretchr/objx v0.5.3 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect - github.com/valyala/fastjson v1.6.10 // indirect - go.opentelemetry.io/proto/otlp v1.11.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.57.0 // indirect - golang.org/x/mod v0.41.0 // indirect - golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 // indirect - golang.org/x/tools v0.50.0 // indirect - google.golang.org/protobuf v1.36.12 // indirect - gopkg.in/ini.v1 v1.67.3 // indirect - gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect -) - -tool ( - github.com/atombender/go-jsonschema - github.com/cespare/reflex - github.com/google/wire/cmd/wire - github.com/onsi/ginkgo/v2/ginkgo - golang.org/x/tools/cmd/goimports + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.3.0 // indirect + github.com/prometheus/common v0.37.0 // indirect + github.com/prometheus/procfs v0.9.0 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect + github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 // indirect + github.com/spf13/afero v1.9.3 // indirect + github.com/spf13/cast v1.5.0 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.5.0 // indirect + github.com/subosito/gotenv v1.4.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.9 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + go.uber.org/goleak v1.1.11 // indirect + golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect + golang.org/x/crypto v0.7.0 // indirect + golang.org/x/mod v0.10.0 // indirect + golang.org/x/net v0.9.0 // indirect + golang.org/x/sys v0.7.0 // indirect + google.golang.org/protobuf v1.28.1 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index fe6dbbc3f..bfdaa2678 100644 --- a/go.sum +++ b/go.sum @@ -1,360 +1,896 @@ -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.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= -github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +code.cloudfoundry.org/go-diodes v0.0.0-20190809170250-f77fb823c7ee h1:iAAPf9s7/+BIiGf+RjgcXLm3NoZaLIJsBXJuUa63Lx8= +code.cloudfoundry.org/go-diodes v0.0.0-20190809170250-f77fb823c7ee/go.mod h1:Jzi+ccHgo/V/PLQUaQ6hnZcC1c4BS790gx21LRRui4g= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= 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.5 h1:RLjq12WJy58dN6eCIQrz0bAGZkztHWsEPFxP53Y7Ms8= -github.com/andybalholm/cascadia v1.3.5/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= -github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY= -github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/ReneKroon/ttlcache/v2 v2.11.0 h1:OvlcYFYi941SBN3v9dsDcC2N8vRxyHcCmJb3Vl4QMoM= +github.com/ReneKroon/ttlcache/v2 v2.11.0/go.mod h1:mBxvsNY+BT8qLLd6CuAJubbKo6r0jh3nb5et22bbfGY= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/beego/beego/v2 v2.0.7 h1:9KNnUM40tn3pbCOFfe6SJ1oOL0oTi/oBS/C/wCEdAXA= +github.com/beego/beego/v2 v2.0.7/go.mod h1:f0uOEkmJWgAuDTlTxUdgJzwG3PDSIf3UWF3NpMohbFE= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 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.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw= -github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/cespare/reflex v0.3.2 h1:SBN/trM94Ifs/ozz77cR3KxKm4dNE22zfG+0+54y5bQ= -github.com/cespare/reflex v0.3.2/go.mod h1:3hfHPnuDWHtNWk0aLKwwP6pomRkS3r2nM127108jY/4= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +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/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.8.0 h1:ea0Xadu+sHlu7x5O3gKhRpQ1IKiMrSiHttPF0ybECuA= +github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= -github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/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-20260913142955-d55e0c9353cb h1:CGVY6RtDsqaleUFogGP03m3a/9OKi3ZMDr5nhm51Emk= -github.com/deluan/go-taglib v0.0.0-20260913142955-d55e0c9353cb/go.mod h1:QGxQ4Z1IWyY9w56xNEFjYAaWE8uSxA/gneQ7RPcFJrY= -github.com/deluan/rest v1.0.1 h1:Enuzzfd88C1/lG6Jqr2NgRreJoClaFuWcOuspeKWHZ8= -github.com/deluan/rest v1.0.1/go.mod h1:r0yO0VgBWOb5Xb7aCPIedtcOuwmA2Oe5lGailC5pvc4= -github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4= -github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E= -github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36 h1:KtPfdSST6e0vJbMzMmVqPa5mO1u8vMBlybRCW2ieXpA= -github.com/dexterlb/mpvipc v0.0.0-20260722094525-0cf47d745b36/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc= +github.com/deepmap/oapi-codegen v1.12.5-0.20230513000919-14548c7e7bbe h1:lgPRUK8nlnjJeXJ63G3zKxk0n1SLr90cLGWerslmFQE= +github.com/deepmap/oapi-codegen v1.12.5-0.20230513000919-14548c7e7bbe/go.mod h1:rey/E8Zmlg0o3jo02vrDZMSv6YeWY/I8j3FTeR+78EU= +github.com/deluan/rest v0.0.0-20211101235434-380523c4bb47 h1:IhGAYGDi212gspq0XkYAI+DN5e9lfAIm8Qgu1wj9yN4= +github.com/deluan/rest v0.0.0-20211101235434-380523c4bb47/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= +github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 h1:mGvOb3zxl4vCLv+dbf7JA6CAaM2UH/AGP1KX4DsJmTI= +github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E= +github.com/dhowden/tag v0.0.0-20220618230019-adf36e896086 h1:ORubSQoKnncsBnR4zD9CuYFJCPOCuSNEpWEZrDdBXkc= +github.com/dhowden/tag v0.0.0-20220618230019-adf36e896086/go.mod h1:Z3Lomva4pyMWYezjMAU5QWRh0p1VvO4199OHlFnyKkM= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= -github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22 h1:GNKxzBirvK9arfVRGVebhFYBp3tnOZG3nlIog6N5/6I= -github.com/djherbis/fscache v0.10.2-0.20260829235704-6d85d5878c22/go.mod h1:Bbk9SqpJcg/saiPfG6byM1G4G/LQndknrsLVOQ+VJqY= -github.com/djherbis/stream v1.5.1 h1:9AoCl0bnm7imWT2uUORqT8aLuTh+YllyynYpolpjIqY= -github.com/djherbis/stream v1.5.1/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= -github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= -github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= -github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= -github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= -github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= -github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0= -github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= -github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= -github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= +github.com/djherbis/fscache v0.10.2-0.20220222230828-2909c950912d h1:eAikRiT337jlFa/NSKGb7K0uoP8/cana3EXzIDyFI6E= +github.com/djherbis/fscache v0.10.2-0.20220222230828-2909c950912d/go.mod h1:+uJNKpxCg52qVRGr+srICjiY8QvV0riatTzCGMUuSEY= +github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE= +github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -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.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= -github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU= -github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE= -github.com/getkin/kin-openapi v0.149.0 h1:ZbhmVJ4yq5RZDUsyP8lcBcGMsjsaTqXEFt6isdtMDfA= -github.com/getkin/kin-openapi v0.149.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY= -github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= -github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= -github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= -github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= -github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= -github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= -github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= -github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= -github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= -github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= -github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8= -github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I= -github.com/go-chi/jwtauth/v5 v5.4.0 h1:Ieh0xMJsFvqylqJ02/mQHKzbbKO9DYNBh4DPKCwTwYI= -github.com/go-chi/jwtauth/v5 v5.4.0/go.mod h1:w6yjqUUXz1b8+oiJel64Sz1KJwduQM6qUA5QNzO5+bQ= -github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= -github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= -github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= -github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= -github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= -github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM= -github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= -github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= -github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs= -github.com/go-viper/encoding/ini v0.1.1/go.mod h1:Pfi4M2V1eAGJVZ5q6FrkHPhtHED2YgLlXhvgMVrB+YQ= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gobwas/glob v1.0.0 h1:p+FKbLEIsK1yZ39/OINwFvqNb5oyPY4H8xcy6uYu8dg= -github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5rkc= -github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= -github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= -github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gohugoio/hashstructure v1.1.0 h1:38yUfZBca6qXSbUpteLhjDGLNskclHaguFBYpjaRjf4= -github.com/gohugoio/hashstructure v1.1.0/go.mod h1:Pz8dcwjZs6FBKWu9x/ZIChrTHIM175zfUJK0KLvC1z8= +github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/getkin/kin-openapi v0.116.0 h1:o986hwgMzR972JzOG5j6+WTwWqllZLs1EJKMKCivs2E= +github.com/getkin/kin-openapi v0.116.0/go.mod h1:l5e9PaFUo9fyLJCPGQeXI2ML8c3P8BHOEV2VaAVf/pc= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.0 h1:OjyFBKICoexlu99ctXNR2gg+c5pKrKMuyjgARg9qeY8= +github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k= +github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= +github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/httprate v0.7.1 h1:d5kXARdms2PREQfU4pHvq44S6hJ1hPu4OXLeBKmCKWs= +github.com/go-chi/httprate v0.7.1/go.mod h1:6GOYBSwnpra4CQfAKXu8sQZg+nZ0M1g9QnyFvxrAB8A= +github.com/go-chi/jwtauth/v5 v5.1.0 h1:wJyf2YZ/ohPvNJBwPOzZaQbyzwgMZZceE1m8FOzXLeA= +github.com/go-chi/jwtauth/v5 v5.1.0/go.mod h1:MA93hc1au3tAQwCKry+fI4LqJ5MIVN4XSsglOo+lSc8= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= +github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.21.1 h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrKU= +github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU= +github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= -github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe h1:QAinXoAFJdGQYztXn3VpFey7KCwpedbZ/EkzbplQ0cY= -github.com/google/pprof v0.0.0-20260906184651-6331bc6350fe/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= -github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= -github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= -github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= -github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20230323073829-e72429f035bd h1:r8yyd+DJDmsUhGrRBxH5Pj7KeFK5l+Y3FsgT8keqKtk= +github.com/google/pprof v0.0.0-20230323073829-e72429f035bd/go.mod h1:79YE0hCXdHag9sBkw2o+N/YnZtTkXi0UT9Nnixa5eYk= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8= +github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= -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-20260724033716-83e58baca724 h1:QixF8Mcbe87ET7pK/fPbBJ9GXFddmEY8yYMepzMzo30= -github.com/ianlancetaylor/demangle v0.0.0-20260724033716-83e58baca724/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= -github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= -github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= -github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= -github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI= -github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= +github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc= +github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 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.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= -github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= -github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M= +github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k= +github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= +github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= 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.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8= -github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo= -github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= -github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/lestrrat-go/blackmagic v1.0.1 h1:lS5Zts+5HIC/8og6cGHb0uCcNCa3OUt1ygh3Qz2Fe80= +github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= -github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= -github.com/lestrrat-go/jwx/v3 v3.3.0 h1:OXcYvQOQ7cxWzeZ/Q9sYk8ABe/kCSI371WmuACiCT+4= -github.com/lestrrat-go/jwx/v3 v3.3.0/go.mod h1:eIJhDcKHBwcgxqv8RiIylV67TVl1wJp/265IAHY1Db8= -github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= -github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= -github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= -github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= -github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= -github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U= -github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= -github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= -github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= -github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= -github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= -github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= -github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws= -github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc= -github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= -github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= -github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= -github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= -github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= -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.33.0 h1:C8gBA6Uc2ZEubiV+SXiu5tZnMTwEmXHgkJwGozKtZf8= -github.com/onsi/ginkgo/v2 v2.33.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.44.0 h1:eAiGl3Pw5jz5GQdDff0BcxYpAX1JxW8xD7mFUuwNfZQ= -github.com/onsi/gomega v1.44.0/go.mod h1:e/C2HwaZ1DhvjzXXuFhcR7hY7Sh9pl7MmoWKEjzwcdA= -github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= -github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8= +github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.0.9 h1:TRX4Q630UXxPVLvP5vGaqVJO7S+0PE6msRZUsFSBoC8= +github.com/lestrrat-go/jwx/v2 v2.0.9/go.mod h1:K68euYaR95FnL0hIQB8VvzL70vB7pSifbJUydCTPmgM= +github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/matoous/go-nanoid v1.5.0/go.mod h1:zyD2a71IubI24efhpvkJz+ZwfwagzgSO6UNiFsZKN7U= +github.com/matoous/go-nanoid/v2 v2.0.0 h1:d19kur2QuLeHmJBkvYkFdhFBzLoo1XVm2GgTpL+9Tj0= +github.com/matoous/go-nanoid/v2 v2.0.0/go.mod h1:FtS4aGPVfEkxKxhdWPAspZpZSh1cOjtM7Ej/So3hR0g= +github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-zglob v0.0.3 h1:6Ry4EYsScDyt5di4OI6xw1bYhOqfE5S33Z1OPy+d+To= +github.com/mattn/go-zglob v0.0.3/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/microcosm-cc/bluemonday v1.0.23 h1:SMZe2IGa0NuHvnVNAZ+6B38gsTbi5e4sViiWJyDDqFY= +github.com/microcosm-cc/bluemonday v1.0.23/go.mod h1:mN70sk7UkkF8TUr2IGBpNN0jAgStuPzlK76QuruE/z4= +github.com/mileusna/useragent v1.2.1 h1:p3RJWhi3LfuI6BHdddojREyK3p6qX67vIfOVMnUIVr0= +github.com/mileusna/useragent v1.2.1/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU= +github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= +github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= +github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= +github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= +github.com/perimeterx/marshmallow v1.1.4 h1:pZLDH9RjlLGGorbXhcaQLhfuV0pFMNfPO55FuFkxqLw= +github.com/perimeterx/marshmallow v1.1.4/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= 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/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.28.0 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc= -github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk= -github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= -github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= -github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= -github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= -github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= +github.com/pressly/goose/v3 v3.10.0 h1:Gn5E9CkPqTtWvfaDVqtJqMjYtsrZ9K5mU/8wzTsvg04= +github.com/pressly/goose/v3 v3.10.0/go.mod h1:c5D3a7j66cT0fhRPj7KsXolfduVrhLlxKZjmCVSey5w= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= +github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= +github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= +github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= +github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= +github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= +github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= +github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= +github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE= +github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= +github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= +github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= +github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= 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= -github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= -github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= -github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= -github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= -github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= -github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= -github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= -github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw= -github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg= -github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= -github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= -github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= -github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 h1:DAYUYH5869yV94zvCES9F51oYtN5oGlwjxJJz7ZCnik= +github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18/go.mod h1:nkxAfR/5quYxwPZhyDxgasBMnRtBZd0FCEpawpjMUFg= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk= +github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= +github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= +github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.15.0 h1:js3yy885G8xwJa6iOISGFwd+qlUo5AvyXb7CiihdtiU= +github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= -github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= -github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= -github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= -github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= -github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= -github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= -github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= -github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= -github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= -github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= -github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= -github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= -github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= -github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= -go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= -go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= -go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= -go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= +github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU= +github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/unrolled/secure v1.13.0 h1:sdr3Phw2+f8Px8HE5sd1EHdj1aV3yUwed/uZXChLFsk= +github.com/unrolled/secure v1.13.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/xrash/smetrics v0.0.0-20200730060457-89a2a8a1fb0b h1:tnWgqoOBmInkt5pbLjagwNVjjT4RdJhFHzL1ebCSRh8= +github.com/xrash/smetrics v0.0.0-20200730060457-89a2a8a1fb0b/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= -golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= -golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= +golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.7.0 h1:gzS29xtG1J5ybQlv0PuyfE3nmc6R4qB73m6LUUmvFuw= +golang.org/x/image v0.7.0/go.mod h1:nd/q4ef1AKKYl/4kft7g+6UyGbdiqWqTP1ZAbRoV7Rg= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +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.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk= +golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= -golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 h1:F5BWKvW126NXR74uxkxuc1jQHhm/rwm/J3rSiFyuRs4= -golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= -golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= -golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/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.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= -golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU= -golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190422233926-fe54fb35175b/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210112230658-8b4aab62c064/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +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.8.0 h1:vSDcovVPld282ceKgDimkRSC8kpaH1dgyc9UMzlt84Y= +golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= -google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/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.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw= -gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= -gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= -gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/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.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= -modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= -modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= -modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= +modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= +modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= +modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/sqlite v1.21.0 h1:4aP4MdUf15i3R3M2mx6Q90WHKz3nZLoz96zlB6tNdow= +modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/log/formatters.go b/log/formatters.go index 5d864dae4..0e34cad7e 100644 --- a/log/formatters.go +++ b/log/formatters.go @@ -1,15 +1,8 @@ package log import ( - "fmt" - "io" - "iter" - "reflect" - "slices" "strings" "time" - - "github.com/navidrome/navidrome/utils/slice" ) func ShortDur(d time.Duration) string { @@ -26,56 +19,11 @@ func ShortDur(d time.Duration) string { default: s = d.String() } - // Drop whole zero-valued trailing components ("4h0m0s" -> "4h"). The suffix has to include - // the preceding unit, or a value that merely ends in a zero digit loses it: "10s" -> "1". if strings.HasSuffix(s, "m0s") { - s = strings.TrimSuffix(s, "0s") + s = s[:len(s)-2] } if strings.HasSuffix(s, "h0m") { - s = strings.TrimSuffix(s, "0m") + s = s[:len(s)-2] } return s } - -func StringerValue(s fmt.Stringer) string { - v := reflect.ValueOf(s) - if v.Kind() == reflect.Pointer && v.IsNil() { - return "nil" - } - return s.String() -} - -func formatSeq[T any](v iter.Seq[T]) string { - return formatSlice(slices.Collect(v)) -} - -func formatSlice[T any](v []T) string { - s := slice.Map(v, func(x T) string { return fmt.Sprintf("%v", x) }) - return fmt.Sprintf("[`%s`]", strings.Join(s, "`,`")) -} - -func CRLFWriter(w io.Writer) io.Writer { - return &crlfWriter{w: w} -} - -type crlfWriter struct { - w io.Writer - lastByte byte -} - -func (cw *crlfWriter) Write(p []byte) (int, error) { - var written int - for _, b := range p { - if b == '\n' && cw.lastByte != '\r' { - if _, err := cw.w.Write([]byte{'\r'}); err != nil { - return written, err - } - } - if _, err := cw.w.Write([]byte{b}); err != nil { - return written, err - } - written++ - cw.lastByte = b - } - return written, nil -} diff --git a/log/formatters_test.go b/log/formatters_test.go index 64d72bdbd..61967dc25 100644 --- a/log/formatters_test.go +++ b/log/formatters_test.go @@ -1,76 +1,35 @@ -package log_test +package log import ( - "bytes" - "io" "time" - "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = DescribeTable("ShortDur", - func(d time.Duration, expected string) { - Expect(log.ShortDur(d)).To(Equal(expected)) - }, - Entry("1ns", 1*time.Nanosecond, "1ns"), - Entry("9µs", 9*time.Microsecond, "9µs"), - Entry("2ms", 2*time.Millisecond, "2ms"), - Entry("5ms", 5*time.Millisecond, "5ms"), - Entry("5.2ms", 5*time.Millisecond+240*time.Microsecond, "5.2ms"), - Entry("1s", 1*time.Second, "1s"), - Entry("1.26s", 1*time.Second+263*time.Millisecond, "1.26s"), - Entry("4m", 4*time.Minute, "4m"), - Entry("4m3s", 4*time.Minute+3*time.Second, "4m3s"), - Entry("4h", 4*time.Hour, "4h"), - Entry("4h", 4*time.Hour+2*time.Second, "4h"), - // A trailing zero digit is significant: only a whole zero-valued component may be dropped. - Entry("zero", time.Duration(0), "0s"), - Entry("10s", 10*time.Second, "10s"), - Entry("20s", 20*time.Second, "20s"), - Entry("1m30s", time.Minute+30*time.Second, "1m30s"), - Entry("2h30m", 2*time.Hour+30*time.Minute, "2h30m"), - Entry("4h2m", 4*time.Hour+2*time.Minute+5*time.Second+200*time.Millisecond, "4h2m"), -) - -var _ = Describe("StringerValue", func() { - It("should return the string representation of a fmt.Stringer", func() { - Expect(log.StringerValue(time.Second)).To(Equal("1s")) +var _ = Describe("ShortDur", func() { + It("formats microseconds", func() { + Expect(ShortDur(9 * time.Microsecond)).To(Equal("9µs")) + Expect(ShortDur(2 * time.Microsecond)).To(Equal("2µs")) }) - It("should return 'nil' for a nil fmt.Stringer", func() { - v := (*time.Time)(nil) - Expect(log.StringerValue(v)).To(Equal("nil")) - }) -}) - -var _ = Describe("CRLFWriter", func() { - var ( - buffer *bytes.Buffer - writer io.Writer - ) - - BeforeEach(func() { - buffer = new(bytes.Buffer) - writer = log.CRLFWriter(buffer) - }) - - Describe("Write", func() { - It("should convert all LFs to CRLFs", func() { - n, err := writer.Write([]byte("hello\nworld\nagain\n")) - Expect(err).NotTo(HaveOccurred()) - Expect(n).To(Equal(18)) - Expect(buffer.String()).To(Equal("hello\r\nworld\r\nagain\r\n")) - }) - - It("should not convert LF to CRLF if preceded by CR", func() { - n, err := writer.Write([]byte("hello\r")) - Expect(n).To(Equal(6)) - Expect(err).NotTo(HaveOccurred()) - n, err = writer.Write([]byte("\nworld\n")) - Expect(n).To(Equal(7)) - Expect(err).NotTo(HaveOccurred()) - Expect(buffer.String()).To(Equal("hello\r\nworld\r\n")) - }) + It("rounds milliseconds", func() { + Expect(ShortDur(5*time.Millisecond + 10*time.Microsecond)).To(Equal("5ms")) + Expect(ShortDur(5*time.Millisecond + 240*time.Microsecond)).To(Equal("5.2ms")) + }) + It("rounds seconds", func() { + Expect(ShortDur(time.Second + 263*time.Millisecond)).To(Equal("1.26s")) + }) + It("removes 0 secs", func() { + Expect(ShortDur(4 * time.Minute)).To(Equal("4m")) + }) + It("rounds to seconds", func() { + Expect(ShortDur(4*time.Minute + 3*time.Second)).To(Equal("4m3s")) + }) + It("removes 0 minutes", func() { + Expect(ShortDur(4 * time.Hour)).To(Equal("4h")) + }) + It("round big durations to the minute", func() { + Expect(ShortDur(4*time.Hour + 2*time.Minute + 5*time.Second + 200*time.Millisecond)). + To(Equal("4h2m")) }) }) diff --git a/log/journal.go b/log/journal.go deleted file mode 100644 index dd7cf5400..000000000 --- a/log/journal.go +++ /dev/null @@ -1,41 +0,0 @@ -package log - -import ( - "fmt" - - "github.com/sirupsen/logrus" -) - -// journalFormatter wraps a logrus.Formatter and prepends a syslog priority -// prefix () to each log line. When stderr is captured by systemd-journald, -// this prefix tells journald the correct severity for each message. -// -// See https://www.freedesktop.org/software/systemd/man/sd-daemon.html -type journalFormatter struct { - inner logrus.Formatter -} - -// levelToPriority maps logrus levels to syslog priority values. -// The mapping follows RFC 5424 severity levels. -var levelToPriority = map[logrus.Level]int{ - logrus.PanicLevel: 0, // emerg - logrus.FatalLevel: 2, // crit - logrus.ErrorLevel: 3, // err - logrus.WarnLevel: 4, // warning - logrus.InfoLevel: 6, // info - logrus.DebugLevel: 7, // debug - logrus.TraceLevel: 7, // debug -} - -func (f *journalFormatter) Format(entry *logrus.Entry) ([]byte, error) { - formatted, err := f.inner.Format(entry) - if err != nil { - return formatted, err - } - priority, ok := levelToPriority[entry.Level] - if !ok { - priority = 6 // default to info for unknown levels - } - prefix := fmt.Appendf(nil, "<%d>", priority) - return append(prefix, formatted...), nil -} diff --git a/log/journal_test.go b/log/journal_test.go deleted file mode 100644 index 770f12b6d..000000000 --- a/log/journal_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package log - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/sirupsen/logrus" -) - -var _ = Describe("journalFormatter", func() { - var formatter *journalFormatter - - BeforeEach(func() { - inner := &logrus.TextFormatter{ - DisableTimestamp: true, - DisableColors: true, - } - formatter = &journalFormatter{inner: inner} - }) - - DescribeTable("prefixes log lines with syslog priority", - func(level logrus.Level, expectedPrefix string) { - entry := &logrus.Entry{ - Logger: logrus.New(), - Level: level, - Message: "test message", - Data: logrus.Fields{}, - } - out, err := formatter.Format(entry) - Expect(err).ToNot(HaveOccurred()) - Expect(string(out)).To(HavePrefix(expectedPrefix)) - }, - Entry("error", logrus.ErrorLevel, "<3>"), - Entry("warning", logrus.WarnLevel, "<4>"), - Entry("info", logrus.InfoLevel, "<6>"), - Entry("debug", logrus.DebugLevel, "<7>"), - Entry("trace", logrus.TraceLevel, "<7>"), - Entry("fatal", logrus.FatalLevel, "<2>"), - Entry("panic", logrus.PanicLevel, "<0>"), - Entry("unknown level defaults to info", logrus.Level(99), "<6>"), - ) -}) diff --git a/log/log.go b/log/log.go index da1d7622e..f4b58aae8 100644 --- a/log/log.go +++ b/log/log.go @@ -1,42 +1,36 @@ package log import ( - "cmp" "context" "errors" "fmt" - "io" - "iter" "net/http" "os" "runtime" - "slices" + "sort" "strings" - "sync" - "sync/atomic" "time" "github.com/sirupsen/logrus" ) -type Level uint32 +type Level uint8 -type LevelFunc = func(ctx any, msg any, keyValuePairs ...any) +type LevelFunc = func(ctx interface{}, msg interface{}, keyValuePairs ...interface{}) var redacted = &Hook{ AcceptedLevels: logrus.AllLevels, RedactionList: []string{ // Keys from the config - "(ApiKey:[\\s]*\")[\\w]*", - "(Secret:[\\s]*\")[\\w]*", + "(ApiKey:\")[\\w]*", + "(Secret:\")[\\w]*", + "(Spotify.*ID:\")[\\w]*", "(PasswordEncryptionKey:[\\s]*\")[^\"]*", - "(UserHeader:[\\s]*\")[^\"]*", - "(TrustedSources:[\\s]*\")[^\"]*", + "(ReverseProxyUserHeader:[\\s]*\")[^\"]*", + "(ReverseProxyWhitelist:[\\s]*\")[^\"]*", "(MetricsPath:[\\s]*\")[^\"]*", "(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*", "(DevAutoLoginUsername:[\\s]*\")[^\"]*", - // Prometheus.Password. Any character is allowed, so skip escaped quotes in the value - `(Password:[\s]*")(?:[^"\\]|\\.)*`, // UI appConfig "(subsonicToken:)[\\w]+(\\s)", @@ -49,15 +43,8 @@ var redacted = &Hook{ "([^\\w]p=)[^&]+", "([^\\w]jwt=)[^&]+", - // External services query params. Values can be JWTs (dots, dashes), so match everything up - // to the next query separator or whitespace, not just word chars. A [\w]+ class would stop - // at a JWT's first '.' and leak its payload and signature. Case-insensitive with an - // optional underscore: the API accepts api_key, apikey and ApiKey alike. - "(?i)([^\\w]api_?key=)[^&\\s]+", - - // Sensitive request headers, logged as a JSON blob at trace level and never matched by the - // query-param patterns above. Blank the whole value array; values may hold escaped quotes. - `(?i)("(?:Authorization|X-Emby-Token|X-MediaBrowser-Token|X-Nd-Authorization)":\[")[^\]]*("\])`, + // External services query params + "([^\\w]api_key=)[\\w]+", }, } @@ -80,30 +67,26 @@ type levelPath struct { } var ( - currentLevel atomic.Uint32 - hasLogLevelOverrides atomic.Bool - loggerMu sync.RWMutex - defaultLogger = logrus.New() - logSourceLine = false - rootPath string - logLevels []levelPath + currentLevel Level + defaultLogger = logrus.New() + logSourceLine = false + rootPath string + logLevels []levelPath ) // SetLevel sets the global log level used by the simple logger. func SetLevel(l Level) { - loggerMu.Lock() - currentLevel.Store(uint32(l)) + currentLevel = l defaultLogger.Level = logrus.TraceLevel - loggerMu.Unlock() logrus.SetLevel(logrus.Level(l)) } func SetLevelString(l string) { - level := ParseLogLevel(l) + level := levelFromString(l) SetLevel(level) } -func ParseLogLevel(l string) Level { +func levelFromString(l string) Level { envLevel := strings.ToLower(l) var level Level switch envLevel { @@ -123,18 +106,13 @@ func ParseLogLevel(l string) Level { return level } -// SetLogLevels sets the log levels for specific paths in the codebase. func SetLogLevels(levels map[string]string) { - loggerMu.Lock() - defer loggerMu.Unlock() - logLevels = nil for k, v := range levels { - logLevels = append(logLevels, levelPath{path: k, level: ParseLogLevel(v)}) + logLevels = append(logLevels, levelPath{path: k, level: levelFromString(v)}) } - slices.SortFunc(logLevels, func(a, b levelPath) int { - return cmp.Compare(b.path, a.path) + sort.Slice(logLevels, func(i, j int) bool { + return logLevels[i].path > logLevels[j].path }) - hasLogLevelOverrides.Store(len(logLevels) != 0) } func SetLogSourceLine(enabled bool) { @@ -143,37 +121,17 @@ func SetLogSourceLine(enabled bool) { func SetRedacting(enabled bool) { if enabled { - loggerMu.Lock() - defer loggerMu.Unlock() defaultLogger.AddHook(redacted) } } -func SetOutput(w io.Writer) { - if runtime.GOOS == "windows" { - w = CRLFWriter(w) - } - loggerMu.Lock() - defer loggerMu.Unlock() - defaultLogger.SetOutput(w) -} - -// EnableJournalFormat wraps the current logger formatter with syslog -// priority prefixes for systemd-journald. Only call this when output -// goes to stderr and JOURNAL_STREAM is set. -func EnableJournalFormat() { - loggerMu.Lock() - defer loggerMu.Unlock() - defaultLogger.Formatter = &journalFormatter{inner: defaultLogger.Formatter} -} - // Redact applies redaction to a single string func Redact(msg string) string { r, _ := redacted.redact(msg) return r } -func NewContext(ctx context.Context, keyValuePairs ...any) context.Context { +func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Context { if ctx == nil { ctx = context.Background() } @@ -188,89 +146,62 @@ func NewContext(ctx context.Context, keyValuePairs ...any) context.Context { return ctx } -// SetDefaultLogger swaps the process-wide logger and returns the previous one, -// so tests can restore the original (with its hooks and formatter) on cleanup. -func SetDefaultLogger(l *logrus.Logger) *logrus.Logger { - loggerMu.Lock() - defer loggerMu.Unlock() - prev := defaultLogger +func SetDefaultLogger(l *logrus.Logger) { defaultLogger = l - return prev } func CurrentLevel() Level { - return Level(currentLevel.Load()) + return currentLevel } -// IsGreaterOrEqualTo returns true if the caller's current log level is equal or greater than the provided level. -func IsGreaterOrEqualTo(level Level) bool { - return shouldLog(level, 2) -} - -func Fatal(args ...any) { +func Fatal(args ...interface{}) { log(LevelFatal, args...) os.Exit(1) } -func Error(args ...any) { +func Error(args ...interface{}) { log(LevelError, args...) } -func Warn(args ...any) { +func Warn(args ...interface{}) { log(LevelWarn, args...) } -func Info(args ...any) { +func Info(args ...interface{}) { log(LevelInfo, args...) } -func Debug(args ...any) { +func Debug(args ...interface{}) { log(LevelDebug, args...) } -func Trace(args ...any) { +func Trace(args ...interface{}) { log(LevelTrace, args...) } -func Log(level Level, args ...any) { - log(level, args...) -} - -func log(level Level, args ...any) { - if !shouldLog(level, 3) { +func log(level Level, args ...interface{}) { + if !shouldLog(level) { return } - logger, msg := parseArgs(args) logger.Log(logrus.Level(level), msg) } -func Writer() io.Writer { - loggerMu.RLock() - defer loggerMu.RUnlock() - return defaultLogger.Writer() -} - -func shouldLog(requiredLevel Level, skip int) bool { - level := Level(currentLevel.Load()) - if level >= requiredLevel { +func shouldLog(requiredLevel Level) bool { + if currentLevel >= requiredLevel { return true } - if !hasLogLevelOverrides.Load() { + if len(logLevels) == 0 { return false } - loggerMu.RLock() - levels := logLevels - loggerMu.RUnlock() - - _, file, _, ok := runtime.Caller(skip) + _, file, _, ok := runtime.Caller(3) if !ok { return false } file = strings.TrimPrefix(file, rootPath) - for _, lp := range levels { + for _, lp := range logLevels { if strings.HasPrefix(file, lp.path) { return lp.level >= requiredLevel } @@ -278,7 +209,7 @@ func shouldLog(requiredLevel Level, skip int) bool { return false } -func parseArgs(args []any) (*logrus.Entry, string) { +func parseArgs(args []interface{}) (*logrus.Entry, string) { var l *logrus.Entry var err error if args[0] == nil { @@ -317,7 +248,7 @@ func parseArgs(args []any) (*logrus.Entry, string) { return l, "" } -func addFields(logger *logrus.Entry, keyValuePairs []any) *logrus.Entry { +func addFields(logger *logrus.Entry, keyValuePairs []interface{}) *logrus.Entry { for i := 0; i < len(keyValuePairs); i += 2 { switch name := keyValuePairs[i].(type) { case error: @@ -330,11 +261,7 @@ func addFields(logger *logrus.Entry, keyValuePairs []any) *logrus.Entry { case time.Duration: logger = logger.WithField(name, ShortDur(v)) case fmt.Stringer: - logger = logger.WithField(name, StringerValue(v)) - case iter.Seq[string]: - logger = logger.WithField(name, formatSeq(v)) - case []string: - logger = logger.WithField(name, formatSlice(v)) + logger = logger.WithField(name, v.String()) default: logger = logger.WithField(name, v) } @@ -344,7 +271,7 @@ func addFields(logger *logrus.Entry, keyValuePairs []any) *logrus.Entry { return logger } -func extractLogger(ctx any) (*logrus.Entry, error) { +func extractLogger(ctx interface{}) (*logrus.Entry, error) { switch ctx := ctx.(type) { case *logrus.Entry: return ctx, nil @@ -363,8 +290,6 @@ func extractLogger(ctx any) (*logrus.Entry, error) { func createNewLogger() *logrus.Entry { //logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true}) //l.Formatter = &logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true} - loggerMu.RLock() - defer loggerMu.RUnlock() logger := logrus.NewEntry(defaultLogger) return logger } diff --git a/log/log_test.go b/log/log_test.go index 184ff57db..545803a79 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -2,14 +2,10 @@ package log import ( "context" - "encoding/json" "errors" - "net/http" "net/http/httptest" "testing" - "time" - "github.com/kr/pretty" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/sirupsen/logrus" @@ -95,20 +91,9 @@ var _ = Describe("Logger", func() { SetLogSourceLine(true) Error("A crash happened") // NOTE: This assertion breaks if the line number above changes - Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:96")) + Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:92")) Expect(hook.LastEntry().Message).To(Equal("A crash happened")) }) - - It("logs fmt.Stringer as a string", func() { - t := time.Now() - Error("Simple Message", "key1", t) - Expect(hook.LastEntry().Data["key1"]).To(Equal(t.String())) - }) - It("logs nil fmt.Stringer as nil", func() { - var t *time.Time - Error("Simple Message", "key1", t) - Expect(hook.LastEntry().Data["key1"]).To(Equal("nil")) - }) }) Describe("Levels", func() { @@ -138,66 +123,17 @@ var _ = Describe("Logger", func() { }) Describe("LogLevels", func() { - BeforeEach(func() { - SetLevel(LevelFatal) - SetLogLevels(nil) - }) - - DescribeTable("logs at specific levels", func(logger func(...any), level Level) { - logger("message 1") + It("logs at specific levels", func() { + SetLevel(LevelError) + Debug("message 1") Expect(hook.LastEntry()).To(BeNil()) - Log(level, "message 1.5") - Expect(hook.LastEntry()).To(BeNil()) - - SetLogLevels(map[string]string{"log/log_test": "trace"}) - - logger("message 2") - Expect(hook.LastEntry().Message).To(Equal("message 2")) - - Log(level, "message 2.5") - Expect(hook.LastEntry().Message).To(Equal("message 2.5")) - }, - Entry("Error", Error, LevelError), - Entry("Warn", Warn, LevelWarn), - Entry("Info", Info, LevelInfo), - Entry("Debug", Debug, LevelDebug), - Entry("Trace", Trace, LevelTrace), - ) - }) - - Describe("IsGreaterOrEqualTo", func() { - BeforeEach(func() { - SetLogLevels(nil) - }) - - It("returns false if log level is below provided level", func() { - SetLevel(LevelError) - Expect(IsGreaterOrEqualTo(LevelWarn)).To(BeFalse()) - }) - - It("returns true if log level is equal to provided level", func() { - SetLevel(LevelWarn) - Expect(IsGreaterOrEqualTo(LevelWarn)).To(BeTrue()) - }) - - It("returns true if log level is above provided level", func() { - SetLevel(LevelTrace) - Expect(IsGreaterOrEqualTo(LevelDebug)).To(BeTrue()) - }) - - It("returns true if log level for the current code path is equal provided level", func() { - SetLevel(LevelError) SetLogLevels(map[string]string{ "log/log_test": "debug", }) - // Need to nest it in a function to get the correct code path - var result = func() bool { - return IsGreaterOrEqualTo(LevelDebug) - }() - - Expect(result).To(BeTrue()) + Debug("message 2") + Expect(hook.LastEntry().Message).To(Equal("message 2")) }) }) @@ -262,99 +198,5 @@ var _ = Describe("Logger", func() { msg := "getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=first%20and%20other%20words&title=Title" Expect(Redact(msg)).To(Equal("getLyrics.view?v=1.2.0&c=iSub&u=user_name&p=[REDACTED]&title=Title")) }) - - It("redacts a whole JWT in api_key, not just up to its first dot", func() { - msg := "/jellyfin/Audio/abc/universal?static=true&api_key=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.c2ln-X_1&other=1" - Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/universal?static=true&api_key=[REDACTED]&other=1")) - }) - - DescribeTable("redacts every api_key spelling the Jellyfin API accepts", - func(param string) { - msg := "/jellyfin/Audio/abc/File?" + param + "=SECRET&other=1" - Expect(Redact(msg)).To(Equal("/jellyfin/Audio/abc/File?" + param + "=[REDACTED]&other=1")) - }, - Entry("api_key", "api_key"), - Entry("apikey", "apikey"), - Entry("ApiKey", "ApiKey"), - Entry("APIKEY", "APIKEY"), - ) - - It("redacts sensitive request headers in a logged header blob", func() { - h := http.Header{ - "Authorization": {`MediaBrowser Client="Finamp", Token="jwt-secret"`}, - "X-Emby-Token": {"emby-secret"}, - "X-Mediabrowser-Token": {"mb-secret"}, - "X-Nd-Authorization": {"Bearer nd-secret"}, - "User-Agent": {"Finamp/1.0"}, - } - blob, _ := json.Marshal(h) - got := Redact(string(blob)) - Expect(got).ToNot(ContainSubstring("secret")) - Expect(got).To(ContainSubstring(`"User-Agent":["Finamp/1.0"]`)) - }) - - // https://github.com/navidrome/navidrome/discussions/6232 - DescribeTable("redacts config keys in the startup Configuration dump", - func(line, expected string) { - Expect(Redact(line)).To(Equal(expected)) - }, - Entry("unpadded ApiKey", `ApiKey:"0123456789abcdef0123456789abcdef"`, `ApiKey:"[REDACTED]"`), - Entry("unpadded Secret", `Secret:"fedcba9876543210fedcba9876543210"`, `Secret:"[REDACTED]"`), - Entry("padded ApiKey", ` ApiKey: "0123456789abcdef0123456789abcdef",`, - ` ApiKey: "[REDACTED]",`), - Entry("padded Secret", ` Secret: "fedcba9876543210fedcba9876543210",`, - ` Secret: "[REDACTED]",`), - Entry("unpadded Prometheus Password", `Password:"p@ss w0rd!"`, `Password:"[REDACTED]"`), - Entry("padded Prometheus Password", ` Password: "p@ss w0rd!",`, ` Password: "[REDACTED]",`), - Entry("Prometheus Password with escaped quotes", ` Password: "a\"b\\\"c",`, - ` Password: "[REDACTED]",`), - ) - - It("redacts secrets in a pretty-printed config struct", func() { - // Mirrors conf.lastfmOptions and conf.prometheusOptions (conf imports log, so it can't be - // used here). pretty only breaks a struct into padded lines when it is long enough, so - // keep all the fields. - type lastfmOptions struct { - Enabled bool - ApiKey string - Secret string - Language string - ScrobbleFirstArtistOnly bool - Languages []string - } - type prometheusOptions struct { - Enabled bool - MetricsPath string - Password string - } - type configOptions struct { - Address string - LastFM lastfmOptions - Prometheus prometheusOptions - } - cfg := configOptions{ - Address: "0.0.0.0", - LastFM: lastfmOptions{ //nolint:gosec - Enabled: true, - ApiKey: "0123456789abcdef0123456789abcdef", - Secret: "fedcba9876543210fedcba9876543210", - Language: "en", - Languages: []string{"en"}, - }, - Prometheus: prometheusOptions{ //nolint:gosec - Enabled: true, - MetricsPath: "/metrics", - Password: `prom"pass-tail`, - }, - } - dump := pretty.Sprintf("Configuration: %# v", cfg) - Expect(dump).To(MatchRegexp(`ApiKey:\s{2,}"`), "the dump must use the padded layout") - - got := Redact(dump) - Expect(got).ToNot(ContainSubstring(cfg.LastFM.ApiKey)) - Expect(got).ToNot(ContainSubstring(cfg.LastFM.Secret)) - Expect(got).ToNot(ContainSubstring("pass-tail")) - Expect(got).To(ContainSubstring(`"en"`)) - }) }) }) diff --git a/log/redactrus.go b/log/redactrus.go index 6e17243e7..d743e3f2d 100755 --- a/log/redactrus.go +++ b/log/redactrus.go @@ -42,9 +42,8 @@ func (h *Hook) Fire(e *logrus.Entry) error { e.Data[k] = "[REDACTED]" continue } - if v == nil { - continue - } + + // Redact based on value matching in Data fields switch reflect.TypeOf(v).Kind() { case reflect.String: e.Data[k] = re.ReplaceAllString(v.(string), "$1[REDACTED]$2") diff --git a/main.go b/main.go index b5fb508b4..084cf8f2b 100644 --- a/main.go +++ b/main.go @@ -4,17 +4,8 @@ import ( _ "net/http/pprof" //nolint:gosec "github.com/navidrome/navidrome/cmd" - "github.com/navidrome/navidrome/conf/buildtags" ) -//goland:noinspection GoBoolExpressions func main() { - // These references force the inclusion of build tags when compiling the project. - // If you get compilation errors like "undefined: buildtags.NETGO", this means you forgot to specify - // the required build tags when compiling the project. - // To avoid these kind of errors, you should use `make build` to compile the project. - _ = buildtags.NETGO - _ = buildtags.SQLITE_FTS5 - cmd.Execute() } diff --git a/model/album.go b/model/album.go index a43195419..3d2c9b7d1 100644 --- a/model/album.go +++ b/model/album.go @@ -1,130 +1,67 @@ package model import ( - "context" - "iter" - "math" - "sync" "time" - "github.com/deluan/rest" - "github.com/gohugoio/hashstructure" - "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/utils/slice" + "golang.org/x/exp/slices" ) type Album struct { - Annotations `structs:"-" hash:"ignore"` - ItemImage `structs:"-" hash:"ignore"` + Annotations `structs:"-"` - ID string `structs:"id" json:"id"` - LibraryID int `structs:"library_id" json:"libraryId"` - LibraryPath string `structs:"-" json:"libraryPath" hash:"ignore"` - LibraryName string `structs:"-" json:"libraryName" hash:"ignore"` - Name string `structs:"name" json:"name"` - EmbedArtPath string `structs:"embed_art_path" json:"-"` - AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated, use Participants - // AlbumArtist is the display name used for the album artist. - AlbumArtist string `structs:"album_artist" json:"albumArtist"` - MaxYear int `structs:"max_year" json:"maxYear"` - MinYear int `structs:"min_year" json:"minYear"` - Date string `structs:"date" json:"date,omitempty"` - MaxOriginalYear int `structs:"max_original_year" json:"maxOriginalYear"` - MinOriginalYear int `structs:"min_original_year" json:"minOriginalYear"` - OriginalDate string `structs:"original_date" json:"originalDate,omitempty"` - ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"` - Compilation bool `structs:"compilation" json:"compilation"` - Comment string `structs:"comment" json:"comment,omitempty"` - SongCount int `structs:"song_count" json:"songCount"` - Duration float32 `structs:"duration" json:"duration"` - Size int64 `structs:"size" json:"size"` - Discs Discs `structs:"discs" json:"discs,omitempty"` - SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"` - SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` - OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"` - OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` - CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"` - MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty"` - MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty"` - MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"` - MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"` - MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"` - FolderIDs []string `structs:"folder_ids" json:"-" hash:"set"` // All folders that contain media_files for this album - ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` - RGAlbumGain *float64 `structs:"rg_album_gain" json:"rgAlbumGain"` - RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"` - - // External metadata fields - Description string `structs:"description" json:"description,omitempty" hash:"ignore"` - SmallImageUrl string `structs:"small_image_url" json:"smallImageUrl,omitempty" hash:"ignore"` - MediumImageUrl string `structs:"medium_image_url" json:"mediumImageUrl,omitempty" hash:"ignore"` - LargeImageUrl string `structs:"large_image_url" json:"largeImageUrl,omitempty" hash:"ignore"` - ExternalUrl string `structs:"external_url" json:"externalUrl,omitempty" hash:"ignore"` - ExternalInfoUpdatedAt *time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt" hash:"ignore"` - - Genre string `structs:"genre" json:"genre" hash:"ignore"` // Easy access to the most common genre - Genres Genres `structs:"-" json:"genres" hash:"ignore"` // Easy access to all genres for this album - Tags Tags `structs:"tags" json:"tags,omitempty" hash:"ignore"` // All imported tags for this album - Participants Participants `structs:"participants" json:"participants" hash:"ignore"` // All artists that participated in this album - - Missing bool `structs:"missing" json:"missing"` // If all file of the album ar missing - ImportedAt time.Time `structs:"imported_at" json:"importedAt" hash:"ignore"` // When this album was imported/updated - CreatedAt time.Time `structs:"created_at" json:"createdAt"` // Oldest CreatedAt for all songs in this album - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Newest UpdatedAt for all songs in this album + ID string `structs:"id" json:"id" orm:"column(id)"` + Name string `structs:"name" json:"name"` + EmbedArtPath string `structs:"embed_art_path" json:"embedArtPath"` + ArtistID string `structs:"artist_id" json:"artistId" orm:"column(artist_id)"` + Artist string `structs:"artist" json:"artist"` + AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId" orm:"column(album_artist_id)"` + AlbumArtist string `structs:"album_artist" json:"albumArtist"` + AllArtistIDs string `structs:"all_artist_ids" json:"allArtistIds" orm:"column(all_artist_ids)"` + MaxYear int `structs:"max_year" json:"maxYear"` + MinYear int `structs:"min_year" json:"minYear"` + Date string `structs:"date" json:"date,omitempty"` + MaxOriginalYear int `structs:"max_original_year" json:"maxOriginalYear"` + MinOriginalYear int `structs:"min_original_year" json:"minOriginalYear"` + OriginalDate string `structs:"original_date" json:"originalDate,omitempty"` + ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"` + Releases int `structs:"releases" json:"releases"` + Compilation bool `structs:"compilation" json:"compilation"` + Comment string `structs:"comment" json:"comment,omitempty"` + SongCount int `structs:"song_count" json:"songCount"` + Duration float32 `structs:"duration" json:"duration"` + Size int64 `structs:"size" json:"size"` + Genre string `structs:"genre" json:"genre"` + Genres Genres `structs:"-" json:"genres"` + FullText string `structs:"full_text" json:"fullText"` + SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"` + SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"` + SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` + OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"` + OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` + CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"` + MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty" orm:"column(mbz_album_id)"` + MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty" orm:"column(mbz_album_artist_id)"` + MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"` + MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"` + ImageFiles string `structs:"image_files" json:"imageFiles,omitempty"` + Paths string `structs:"paths" json:"paths,omitempty"` + Description string `structs:"description" json:"description,omitempty"` + SmallImageUrl string `structs:"small_image_url" json:"smallImageUrl,omitempty"` + MediumImageUrl string `structs:"medium_image_url" json:"mediumImageUrl,omitempty"` + LargeImageUrl string `structs:"large_image_url" json:"largeImageUrl,omitempty"` + ExternalUrl string `structs:"external_url" json:"externalUrl,omitempty" orm:"column(external_url)"` + ExternalInfoUpdatedAt time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` } func (a Album) CoverArtID() ArtworkID { return artworkIDFromAlbum(a) } -func (a Album) FullName() string { - if v := a.Tags.First(TagAlbumVersion); conf.Server.Subsonic.AppendAlbumVersion && v != "" { - return appendSuffix(a.Name, v) - } - return a.Name -} - -// Equals compares two Album structs, ignoring calculated fields -func (a Album) Equals(other Album) bool { - // Normalize float32 values to avoid false negatives - a.Duration = float32(math.Floor(float64(a.Duration))) - other.Duration = float32(math.Floor(float64(other.Duration))) - - opts := &hashstructure.HashOptions{ - IgnoreZeroValue: true, - ZeroNil: true, - } - hash1, _ := hashstructure.Hash(a, opts) - hash2, _ := hashstructure.Hash(other, opts) - - return hash1 == hash2 -} - -// AlbumLevelTags contains all Tags marked as `album: true` in the mappings.yml file. They are not -// "first-class citizens" in the Album struct, but are still stored in the album table, in the `tags` column. -var AlbumLevelTags = sync.OnceValue(func() map[TagName]struct{} { - tags := make(map[TagName]struct{}) - m := TagMappings() - for t, conf := range m { - if conf.Album { - tags[t] = struct{}{} - } - } - return tags -}) - -func (a *Album) SetTags(tags TagList) { - a.Tags = tags.GroupByFrequency() - for k := range a.Tags { - if _, ok := AlbumLevelTags()[k]; !ok { - delete(a.Tags, k) - } - } -} - -type Discs map[int]string - -func (d Discs) Add(discNumber int, discSubtitle string) { - d[discNumber] = discSubtitle +func (a Album) ArtistIDs() []string { + return []string{a.ArtistID, a.AlbumArtistID} } type DiscID struct { @@ -135,29 +72,44 @@ type DiscID struct { type Albums []Album -type AlbumCursor iter.Seq2[Album, error] +// ToAlbumArtist creates an Artist object based on the attributes of this Albums collection. +// It assumes all albums have the same AlbumArtist, or else results are unpredictable. +func (als Albums) ToAlbumArtist() Artist { + a := Artist{AlbumCount: len(als)} + var mbzArtistIds []string + for _, al := range als { + a.ID = al.AlbumArtistID + a.Name = al.AlbumArtist + a.SortArtistName = al.SortAlbumArtistName + a.OrderArtistName = al.OrderAlbumArtistName + + a.SongCount += al.SongCount + a.Size += al.Size + a.Genres = append(a.Genres, al.Genres...) + mbzArtistIds = append(mbzArtistIds, al.MbzAlbumArtistID) + } + slices.SortFunc(a.Genres, func(a, b Genre) bool { return a.ID < b.ID }) + a.Genres = slices.Compact(a.Genres) + a.MbzArtistID = slice.MostFrequent(mbzArtistIds) + + return a +} + +func (als Albums) ArtistIDs() []string { + var ids []string + for _, al := range als { + ids = append(ids, al.ArtistIDs()...) + } + return ids +} type AlbumRepository interface { - rest.Repository[Album] - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Exists(ctx context.Context, id string) (bool, error) - Put(ctx context.Context, m *Album) error - UpdateExternalInfo(ctx context.Context, m *Album) error - Get(ctx context.Context, id string) (*Album, error) - GetAll(ctx context.Context, options ...QueryOptions) (Albums, error) - // GetSoleAlbumArtistIDsInSubtrees returns the sole album artists of the albums with folders in - // any of the given library-relative subtrees. - GetSoleAlbumArtistIDsInSubtrees(ctx context.Context, lib Library, paths ...string) ([]string, error) - GetCursor(ctx context.Context, options ...QueryOptions) (AlbumCursor, error) - GetYears(ctx context.Context, libraryIDs ...int) ([]int, error) - - // The following methods are used exclusively by the scanner: - Touch(ctx context.Context, ids ...string) error - TouchByMissingFolder(ctx context.Context) (int64, error) - GetTouchedAlbums(ctx context.Context, libID int) (AlbumCursor, error) - RefreshPlayCounts(ctx context.Context) (int64, error) - CopyAttributes(ctx context.Context, fromID, toID string, columns ...string) error - + CountAll(...QueryOptions) (int64, error) + Exists(id string) (bool, error) + Put(*Album) error + Get(id string) (*Album, error) + GetAll(...QueryOptions) (Albums, error) + GetAllWithoutGenres(...QueryOptions) (Albums, error) + Search(q string, offset int, size int) (Albums, error) AnnotatedRepository - SearchableRepository[Albums] } diff --git a/model/album_test.go b/model/album_test.go index ad2ca1cb6..81956b437 100644 --- a/model/album_test.go +++ b/model/album_test.go @@ -1,53 +1,87 @@ package model_test import ( - "encoding/json" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" . "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("Album", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - DescribeTable("FullName", - func(enabled bool, tags Tags, expected string) { - conf.Server.Subsonic.AppendAlbumVersion = enabled - a := Album{Name: "Album", Tags: tags} - Expect(a.FullName()).To(Equal(expected)) - }, - Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album (Remastered)"), - Entry("returns just name when disabled", false, Tags{TagAlbumVersion: []string{"Remastered"}}, "Album"), - Entry("returns just name when tag is absent", true, Tags{}, "Album"), - Entry("returns just name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), - Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Remastered)"}}, "Album (Remastered)"), - Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Remastered]"}}, "Album [Remastered]"), - ) -}) - var _ = Describe("Albums", func() { var albums Albums - Context("JSON Marshalling", func() { - When("we have a valid Albums object", func() { + Context("Simple attributes", func() { + BeforeEach(func() { + albums = Albums{ + {ID: "1", AlbumArtist: "Artist", AlbumArtistID: "11", SortAlbumArtistName: "SortAlbumArtistName", OrderAlbumArtistName: "OrderAlbumArtistName"}, + {ID: "2", AlbumArtist: "Artist", AlbumArtistID: "11", SortAlbumArtistName: "SortAlbumArtistName", OrderAlbumArtistName: "OrderAlbumArtistName"}, + } + }) + + It("sets the single values correctly", func() { + artist := albums.ToAlbumArtist() + Expect(artist.ID).To(Equal("11")) + Expect(artist.Name).To(Equal("Artist")) + Expect(artist.SortArtistName).To(Equal("SortAlbumArtistName")) + Expect(artist.OrderArtistName).To(Equal("OrderAlbumArtistName")) + }) + }) + + Context("Aggregated attributes", func() { + When("we have multiple songs", func() { BeforeEach(func() { albums = Albums{ - {ID: "1", AlbumArtist: "Artist", AlbumArtistID: "11", SortAlbumArtistName: "SortAlbumArtistName", OrderAlbumArtistName: "OrderAlbumArtistName"}, - {ID: "2", AlbumArtist: "Artist", AlbumArtistID: "11", SortAlbumArtistName: "SortAlbumArtistName", OrderAlbumArtistName: "OrderAlbumArtistName"}, + {ID: "1", SongCount: 4, Size: 1024}, + {ID: "2", SongCount: 6, Size: 2048}, } }) - It("marshals correctly", func() { - data, err := json.Marshal(albums) - Expect(err).To(BeNil()) + It("calculates the aggregates correctly", func() { + artist := albums.ToAlbumArtist() + Expect(artist.AlbumCount).To(Equal(2)) + Expect(artist.SongCount).To(Equal(10)) + Expect(artist.Size).To(Equal(int64(3072))) + }) + }) + }) - var albums2 Albums - err = json.Unmarshal(data, &albums2) - Expect(err).To(BeNil()) - Expect(albums2).To(Equal(albums)) + Context("Calculated attributes", func() { + Context("Genres", func() { + When("we have only one Genre", func() { + BeforeEach(func() { + albums = Albums{{Genres: Genres{{ID: "g1", Name: "Rock"}}}} + }) + It("sets the correct Genre", func() { + artist := albums.ToAlbumArtist() + Expect(artist.Genres).To(ConsistOf(Genre{ID: "g1", Name: "Rock"})) + }) + }) + When("we have multiple Genres", func() { + BeforeEach(func() { + albums = Albums{{Genres: Genres{{ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}, {ID: "g3", Name: "Alternative"}, {ID: "g2", Name: "Punk"}}}} + }) + It("sets the correct Genres", func() { + artist := albums.ToAlbumArtist() + Expect(artist.Genres).To(Equal(Genres{{ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}, {ID: "g3", Name: "Alternative"}})) + }) + }) + }) + Context("MbzArtistID", func() { + When("we have only one MbzArtistID", func() { + BeforeEach(func() { + albums = Albums{{MbzAlbumArtistID: "id1"}} + }) + It("sets the correct MbzArtistID", func() { + artist := albums.ToAlbumArtist() + Expect(artist.MbzArtistID).To(Equal("id1")) + }) + }) + When("we have multiple MbzArtistID", func() { + BeforeEach(func() { + albums = Albums{{MbzAlbumArtistID: "id1"}, {MbzAlbumArtistID: "id2"}, {MbzAlbumArtistID: "id1"}} + }) + It("sets the correct MbzArtistID", func() { + artist := albums.ToAlbumArtist() + Expect(artist.MbzArtistID).To(Equal("id1")) + }) }) }) }) diff --git a/model/annotation.go b/model/annotation.go index 64b8ddc17..b470da2a5 100644 --- a/model/annotation.go +++ b/model/annotation.go @@ -1,23 +1,17 @@ package model -import ( - "context" - "time" -) +import "time" type Annotations struct { - PlayCount int64 `structs:"play_count" json:"playCount,omitempty"` - PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" ` - Rating int `structs:"rating" json:"rating,omitempty" ` - RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" ` - Starred bool `structs:"starred" json:"starred,omitempty" ` - StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"` - AverageRating float64 `structs:"average_rating" json:"averageRating,omitempty"` + PlayCount int64 `structs:"-" json:"playCount"` + PlayDate time.Time `structs:"-" json:"playDate" ` + Rating int `structs:"-" json:"rating" ` + Starred bool `structs:"-" json:"starred" ` + StarredAt time.Time `structs:"-" json:"starredAt"` } type AnnotatedRepository interface { - IncPlayCount(ctx context.Context, itemID string, ts time.Time) error - SetStar(ctx context.Context, starred bool, itemIDs ...string) error - SetRating(ctx context.Context, rating int, itemID string) error - ReassignAnnotation(ctx context.Context, prevID string, newID string) error + IncPlayCount(itemID string, ts time.Time) error + SetStar(starred bool, itemIDs ...string) error + SetRating(rating int, itemID string) error } diff --git a/model/artist.go b/model/artist.go index 985cc6c7b..1a3ba4968 100644 --- a/model/artist.go +++ b/model/artist.go @@ -1,55 +1,27 @@ package model -import ( - "context" - "iter" - "maps" - "slices" - "time" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/consts" -) +import "time" type Artist struct { Annotations `structs:"-"` - ItemImage `structs:"-"` - ID string `structs:"id" json:"id"` - - // Data based on tags - Name string `structs:"name" json:"name"` - SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"` - OrderArtistName string `structs:"order_artist_name" json:"orderArtistName,omitempty"` - MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty"` - - // Data calculated from files - Stats map[Role]ArtistStats `structs:"-" json:"stats,omitempty"` - Size int64 `structs:"-" json:"size,omitempty"` - AlbumCount int `structs:"-" json:"albumCount,omitempty"` - SongCount int `structs:"-" json:"songCount,omitempty"` - - // Data imported from external sources - Biography string `structs:"biography" json:"biography,omitempty"` - SmallImageUrl string `structs:"small_image_url" json:"smallImageUrl,omitempty"` - MediumImageUrl string `structs:"medium_image_url" json:"mediumImageUrl,omitempty"` - LargeImageUrl string `structs:"large_image_url" json:"largeImageUrl,omitempty"` - ExternalUrl string `structs:"external_url" json:"externalUrl,omitempty"` - SimilarArtists Artists `structs:"similar_artists" json:"-"` - ExternalInfoUpdatedAt *time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt,omitempty"` - - Missing bool `structs:"missing" json:"missing"` - - UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"` - - CreatedAt *time.Time `structs:"created_at" json:"createdAt,omitempty"` - UpdatedAt *time.Time `structs:"updated_at" json:"updatedAt,omitempty"` -} - -type ArtistStats struct { - SongCount int `json:"songCount"` - AlbumCount int `json:"albumCount"` - Size int64 `json:"size"` + ID string `structs:"id" json:"id" orm:"column(id)"` + Name string `structs:"name" json:"name"` + AlbumCount int `structs:"album_count" json:"albumCount"` + SongCount int `structs:"song_count" json:"songCount"` + Genres Genres `structs:"-" json:"genres"` + FullText string `structs:"full_text" json:"fullText"` + SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"` + OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"` + Size int64 `structs:"size" json:"size"` + MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty" orm:"column(mbz_artist_id)"` + Biography string `structs:"biography" json:"biography,omitempty"` + SmallImageUrl string `structs:"small_image_url" json:"smallImageUrl,omitempty"` + MediumImageUrl string `structs:"medium_image_url" json:"mediumImageUrl,omitempty"` + LargeImageUrl string `structs:"large_image_url" json:"largeImageUrl,omitempty"` + ExternalUrl string `structs:"external_url" json:"externalUrl,omitempty" orm:"column(external_url)"` + SimilarArtists Artists `structs:"-" json:"-" orm:"-"` + ExternalInfoUpdatedAt time.Time `structs:"external_info_updated_at" json:"externalInfoUpdatedAt"` } func (a Artist) ArtistImageUrl() string { @@ -66,15 +38,6 @@ func (a Artist) CoverArtID() ArtworkID { return artworkIDFromArtist(a) } -func (a Artist) UploadedImagePath() string { - return UploadedImagePath(consts.EntityArtist, a.UploadedImage) -} - -// Roles returns the roles this artist has participated in., based on the Stats field -func (a Artist) Roles() []Role { - return slices.Collect(maps.Keys(a.Stats)) -} - type Artists []Artist type ArtistIndex struct { @@ -83,23 +46,13 @@ type ArtistIndex struct { } type ArtistIndexes []ArtistIndex -type ArtistCursor iter.Seq2[Artist, error] - type ArtistRepository interface { - rest.Repository[Artist] - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Exists(ctx context.Context, id string) (bool, error) - Put(ctx context.Context, m *Artist, colsToUpdate ...string) error - UpdateExternalInfo(ctx context.Context, a *Artist) error - Get(ctx context.Context, id string) (*Artist, error) - GetAll(ctx context.Context, options ...QueryOptions) (Artists, error) - GetCursor(ctx context.Context, options ...QueryOptions) (ArtistCursor, error) - GetIndex(ctx context.Context, includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) - - // The following methods are used exclusively by the scanner: - RefreshPlayCounts(ctx context.Context) (int64, error) - RefreshStats(ctx context.Context, allArtists bool) (int64, error) - + CountAll(options ...QueryOptions) (int64, error) + Exists(id string) (bool, error) + Put(m *Artist) error + Get(id string) (*Artist, error) + GetAll(options ...QueryOptions) (Artists, error) + Search(q string, offset int, size int) (Artists, error) + GetIndex() (ArtistIndexes, error) AnnotatedRepository - SearchableRepository[Artists] } diff --git a/model/artist_test.go b/model/artist_test.go deleted file mode 100644 index db897d3d5..000000000 --- a/model/artist_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package model_test - -import ( - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Artist", func() { - Describe("UploadedImagePath", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir("/data") - }) - - It("returns empty string when no image uploaded", func() { - a := model.Artist{ID: "ar-1"} - Expect(a.UploadedImagePath()).To(BeEmpty()) - }) - - It("returns full path when image is set", func() { - a := model.Artist{ID: "ar-1", UploadedImage: "ar-1_test.jpg"} - Expect(a.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "artist", "ar-1_test.jpg"))) - }) - }) -}) diff --git a/model/artwork.go b/model/artwork.go deleted file mode 100644 index d5f62ab13..000000000 --- a/model/artwork.go +++ /dev/null @@ -1,182 +0,0 @@ -package model - -import ( - "context" - "time" -) - -// Artwork is one unique image, identified by the XXH3-64 hash of its bytes. -type Artwork struct { - Hash string `structs:"hash"` - Mime string `structs:"mime"` - Width int `structs:"width"` - Height int `structs:"height"` - SizeBytes int64 `structs:"size_bytes"` - BlurHash string `structs:"blur_hash"` - ThumbHash string `structs:"thumb_hash"` - // DominantColor is "#rrggbb": a flat placeholder clients can paint before any decode. - DominantColor string `structs:"dominant_color"` - CreatedAt time.Time `structs:"created_at"` -} - -const ImageTypePrimary = "primary" - -// ArtworkSourceFailed is a pseudo-source selecting absent states that exhausted the retry budget -// rather than being answered. The "!" keeps it from colliding with a stored source value. -const ArtworkSourceFailed = "!failed" - -// ItemImage is per-entity artwork state hydrated at query time; never persisted. -type ItemImage struct { - ImageHash string `structs:"-" json:"imageHash,omitempty"` - ImageAbsent bool `structs:"-" json:"imageAbsent,omitempty"` - // BlurHash is Jellyfin's; its mappers read this field directly, so it stays off native JSON. - BlurHash string `structs:"-" json:"-"` - ThumbHash string `structs:"-" json:"thumbHash,omitempty"` - // DominantColor is the only placeholder needing no decode, so it can paint on the first frame. - DominantColor string `structs:"-" json:"dominantColor,omitempty"` - // A thumbhash's own aspect is quantised, so clients need these to shape the placeholder exactly. - ImageWidth int `structs:"-" json:"imageWidth,omitempty"` - ImageHeight int `structs:"-" json:"imageHeight,omitempty"` -} - -// AspectRatio is the image's width/height, or nil when the image or its dimensions are unknown. -func (i ItemImage) AspectRatio() *float64 { - if i.ImageAbsent || i.ImageWidth <= 0 || i.ImageHeight <= 0 { - return nil - } - return new(float64(i.ImageWidth) / float64(i.ImageHeight)) -} - -// ItemArtwork is an entity's resolved artwork state. Hash=="" means known absent. -type ItemArtwork struct { - ItemKind string `structs:"item_kind"` - ItemID string `structs:"item_id"` - ImageType string `structs:"image_type"` - Hash string `structs:"hash"` - Source string `structs:"source"` - // SourcePath is the backing file (folder/upload: the image; embedded: the audio file); "" otherwise. - SourcePath string `structs:"source_path"` - // RefMtime is SourcePath's mtime (unix-nanoseconds) at resolution; 0 when there is no SourcePath. - RefMtime int64 `structs:"ref_mtime"` - // Trace is the encoded walk that produced this state; LastFailure is the walk of the attempt - // that exhausted the retry budget. Both are JSON, read back with artwork.DecodeTrace. - Trace string `structs:"trace"` - LastFailure string `structs:"last_failure"` - // Nullable in the schema, but every insert must set them: these non-pointer fields cannot scan NULL. - AttemptedAt time.Time `structs:"attempted_at"` - UpdatedAt time.Time `structs:"updated_at"` -} - -// ItemArtworkInfo is the list-hydration projection (item_artwork joined with artwork). -type ItemArtworkInfo struct { - ItemID string - Hash string - BlurHash string - ThumbHash string - DominantColor string - Width int - Height int -} - -// Absent reports a known-absent artwork state (resolved, no image). -func (i ItemArtworkInfo) Absent() bool { return i.Hash == "" } - -// Image projects the hydration entry onto the entity-facing struct. -func (i ItemArtworkInfo) Image() ItemImage { - return ItemImage{ - ImageHash: i.Hash, - ImageAbsent: i.Absent(), - BlurHash: i.BlurHash, - ThumbHash: i.ThumbHash, - DominantColor: i.DominantColor, - ImageWidth: i.Width, - ImageHeight: i.Height, - } -} - -type ArtworkQueueItem struct { - ItemKind string `structs:"item_kind"` - ItemID string `structs:"item_id"` - ImageType string `structs:"image_type"` - Priority int `structs:"priority"` - Attempts int `structs:"attempts"` - // RetryAt is the earliest time the drain may take this row, not when it will run. - RetryAt time.Time `structs:"retry_at"` - EnqueuedAt time.Time `structs:"enqueued_at"` - // Trace is why the last attempt failed. Only Get reads it; the drain projects it away. - Trace string `structs:"trace"` -} - -// Queue priorities: higher drains first. -const ( - ArtworkPriorityRecheck = 0 - // ArtworkPriorityBackfill sits between the hourly sweep and scan-driven work. Nothing enqueues - // it today; it stays named so a row still carrying it can be reported and cancelled. - ArtworkPriorityBackfill = 10 - ArtworkPriorityScan = 50 - ArtworkPriorityBump = 100 -) - -// Delete* takes the rows to remove; Purge* finds them itself and reports how many went. -type ArtworkRepository interface { - GetImage(ctx context.Context, hash string) (*Artwork, error) - PutImage(ctx context.Context, a *Artwork) error - // PurgeOrphans deletes rows referenced by no item_artwork row and older than cutoff. - PurgeOrphans(ctx context.Context, createdBefore time.Time) (int64, error) - GetItemArtwork(ctx context.Context, kind Kind, id, imageType string) (*ItemArtwork, error) - PutItemArtwork(ctx context.Context, ia *ItemArtwork) error - // PutLastFailure records the trace of the attempt that exhausted the retry budget. - PutLastFailure(ctx context.Context, kind Kind, id, imageType, trace string) error - DeleteForItems(ctx context.Context, kind Kind, ids []string) error - // GetInfoForItems hydrates a page in one batched query. - GetInfoForItems(ctx context.Context, kind Kind, ids []string) (map[string]ItemArtworkInfo, error) - // GetMimeByHash returns hash -> current mime for every stored artwork. - GetMimeByHash(ctx context.Context) (map[string]string, error) - // PurgeDanglingItems removes state rows whose entity no longer exists. - PurgeDanglingItems(ctx context.Context) (int64, error) -} - -type ArtworkQueueRepository interface { - // Get returns the pending row for an item, or ErrNotFound when it is not queued. - Get(ctx context.Context, kind Kind, id, imageType string) (*ArtworkQueueItem, error) - // Enqueue upserts; an existing row keeps the higher priority and has its retry_at reset. - Enqueue(ctx context.Context, items ...ArtworkQueueItem) error - // EnqueuePreservingBackoff upserts like Enqueue but preserves an existing row's retry_at, so a - // request-triggered read-through never resets a failed resolution's backoff. - EnqueuePreservingBackoff(ctx context.Context, items ...ArtworkQueueItem) error - // EnqueueAllMissing inserts queue rows for all entities with no item_artwork row, at the given priority. - EnqueueAllMissing(ctx context.Context, kind Kind, priority int) (int64, error) - // EnqueueIfMissing inserts only for items with no item_artwork row yet. - EnqueueIfMissing(ctx context.Context, items ...ArtworkQueueItem) error - // CountBySource reports how many items of a kind currently resolve from the given sources. - // An empty sources slice means every source; "" matches absent state, and the pseudo-source - // ArtworkSourceFailed matches the absent states that gave up. - CountBySource(ctx context.Context, kind Kind, sources []string) (int64, error) - // SourcesInUse lists the distinct sources items of a kind currently resolve from, "" included. - SourcesInUse(ctx context.Context, kind Kind) ([]string, error) - // EnqueueBySource inserts queue rows for items of a kind whose current source matches. - // It does not clear existing artwork state: the current image stays until it is replaced. - EnqueueBySource(ctx context.Context, kind Kind, sources []string, priority int) (int64, error) - // DequeueBatch returns up to n items with retry_at <= now, priority desc, enqueued_at asc. - // Restricted to the given kinds when any are passed, so one kind cannot block another's drain. - DequeueBatch(ctx context.Context, n int, kinds ...string) ([]ArtworkQueueItem, error) - // MarkFailedIfUnchanged applies the failure backoff only while retry_at still matches - // seenRetryAt, so a concurrent re-enqueue keeps its fresh eligibility. - MarkFailedIfUnchanged(ctx context.Context, kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error - // DeleteIfUnchanged deletes only while retry_at still matches, sparing a concurrent re-enqueue. - DeleteIfUnchanged(ctx context.Context, kind, id, imageType string, retryAt time.Time) error - Count(ctx context.Context) (int64, error) - // CountQueued reports the pending rows matching the kinds and priorities, grouped by both; - // an empty filter means every one. - CountQueued(ctx context.Context, kinds []Kind, priorities []int) ([]ArtworkQueueStat, error) - // PurgeDangling removes queue rows whose entity no longer exists. - PurgeDangling(ctx context.Context) (int64, error) - // PurgeQueued removes pending rows matching the kinds and priorities; an empty filter means every one. - PurgeQueued(ctx context.Context, kinds []Kind, priorities []int) (int64, error) -} - -type ArtworkQueueStat struct { - ItemKind string - Priority int - Count int64 -} diff --git a/model/artwork_id.go b/model/artwork_id.go index e827e935a..36026dd03 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -6,8 +6,6 @@ import ( "strconv" "strings" "time" - - "github.com/navidrome/navidrome/utils/slice" ) type Kind struct { @@ -19,18 +17,11 @@ func (k Kind) String() string { return k.name } -// Prefix is the short token used in artwork ids and the item_artwork.item_kind column. -func (k Kind) Prefix() string { - return k.prefix -} - var ( KindMediaFileArtwork = Kind{"mf", "media_file"} KindArtistArtwork = Kind{"ar", "artist"} KindAlbumArtwork = Kind{"al", "album"} KindPlaylistArtwork = Kind{"pl", "playlist"} - KindDiscArtwork = Kind{"dc", "disc"} - KindRadioArtwork = Kind{"ra", "radio"} ) var artworkKindMap = map[string]Kind{ @@ -38,27 +29,12 @@ var artworkKindMap = map[string]Kind{ KindArtistArtwork.prefix: KindArtistArtwork, KindAlbumArtwork.prefix: KindAlbumArtwork, KindPlaylistArtwork.prefix: KindPlaylistArtwork, - KindDiscArtwork.prefix: KindDiscArtwork, - KindRadioArtwork.prefix: KindRadioArtwork, -} - -// KindPrefixes leaves the typed Kind domain for the item_kind column, or for a help string. -func KindPrefixes(kinds []Kind) []string { - return slice.Map(kinds, func(k Kind) string { return k.prefix }) -} - -// ParseKind resolves an item_kind prefix (e.g. "al") to its Kind, reporting whether it was known. -// Use it at string boundaries — URL params, the item_kind column — to enter the typed Kind domain. -func ParseKind(prefix string) (Kind, bool) { - k, ok := artworkKindMap[prefix] - return k, ok } type ArtworkID struct { Kind Kind ID string - Hash string // content-hash suffix; "" = unknown/none - LastUpdate time.Time // legacy: populated only when parsing old _ tokens + LastUpdate time.Time } func (id ArtworkID) String() string { @@ -66,14 +42,14 @@ func (id ArtworkID) String() string { return "" } s := fmt.Sprintf("%s-%s", id.Kind.prefix, id.ID) - if id.Hash != "" { - return s + "_" + id.Hash + if lu := id.LastUpdate.Unix(); lu > 0 { + return fmt.Sprintf("%s_%x", s, lu) } - return s + return s + "_0" } func NewArtworkID(kind Kind, id string, lastUpdate *time.Time) ArtworkID { - artID := ArtworkID{Kind: kind, ID: id} + artID := ArtworkID{kind, id, time.Time{}} if lastUpdate != nil { artID.LastUpdate = *lastUpdate } @@ -95,34 +71,18 @@ func ParseArtworkID(id string) (ArtworkID, error) { } parts = strings.SplitN(parts[1], "_", 2) if len(parts) == 2 { - parsedID.ID = parts[0] - suffix := parts[1] - switch { - // Hash detection must come first: a 16-hex value with the high bit set overflows int64. - case isImageHash(suffix): - parsedID.Hash = suffix - case suffix != "0": - if lastUpdate, err := strconv.ParseInt(suffix, 16, 64); err == nil { - parsedID.LastUpdate = time.Unix(lastUpdate, 0) + if parts[1] != "0" { + lastUpdate, err := strconv.ParseInt(parts[1], 16, 64) + if err != nil { + return ArtworkID{}, err } + parsedID.LastUpdate = time.Unix(lastUpdate, 0) } + parsedID.ID = parts[0] } return parsedID, nil } -// isImageHash reports whether s is a 16-char lowercase-hex XXH3-64 content hash. -func isImageHash(s string) bool { - if len(s) != 16 { - return false - } - for _, c := range s { - if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { - return false - } - } - return true -} - func MustParseArtworkID(id string) ArtworkID { artID, err := ParseArtworkID(id) if err != nil { @@ -131,38 +91,33 @@ func MustParseArtworkID(id string) ArtworkID { return artID } -func DiscArtworkID(albumID string, discNumber int) string { - return fmt.Sprintf("%s:%d", albumID, discNumber) -} - -func ParseDiscArtworkID(id string) (albumID string, discNumber int, err error) { - parts := strings.SplitN(id, ":", 2) - if len(parts) != 2 || parts[1] == "" { - return "", 0, errors.New("invalid disc artwork id") - } - num, err := strconv.Atoi(parts[1]) - if err != nil { - return "", 0, fmt.Errorf("invalid disc number in artwork id: %w", err) - } - return parts[0], num, nil -} - func artworkIDFromAlbum(al Album) ArtworkID { - return ArtworkID{Kind: KindAlbumArtwork, ID: al.ID, Hash: al.ImageHash} + return ArtworkID{ + Kind: KindAlbumArtwork, + ID: al.ID, + LastUpdate: al.UpdatedAt, + } } func artworkIDFromMediaFile(mf MediaFile) ArtworkID { - return ArtworkID{Kind: KindMediaFileArtwork, ID: mf.ID, Hash: mf.ImageHash} + return ArtworkID{ + Kind: KindMediaFileArtwork, + ID: mf.ID, + LastUpdate: mf.UpdatedAt, + } } func artworkIDFromPlaylist(pls Playlist) ArtworkID { - return ArtworkID{Kind: KindPlaylistArtwork, ID: pls.ID, Hash: pls.ImageHash} + return ArtworkID{ + Kind: KindPlaylistArtwork, + ID: pls.ID, + LastUpdate: pls.UpdatedAt, + } } func artworkIDFromArtist(ar Artist) ArtworkID { - return ArtworkID{Kind: KindArtistArtwork, ID: ar.ID, Hash: ar.ImageHash} -} - -func artworkIDFromRadio(r Radio) ArtworkID { - return ArtworkID{Kind: KindRadioArtwork, ID: r.ID, Hash: r.ImageHash} + return ArtworkID{ + Kind: KindArtistArtwork, + ID: ar.ID, + } } diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go index af6a12ffb..2f42217f9 100644 --- a/model/artwork_id_test.go +++ b/model/artwork_id_test.go @@ -9,31 +9,15 @@ import ( ) var _ = Describe("ArtworkID", func() { - Describe("String()", func() { - It("returns a bare id when there is no hash", func() { - id := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1234"} - Expect(id.String()).To(Equal("al-1234")) - }) - It("appends the hash suffix when set", func() { - id := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1234", Hash: "abcdef0123456789"} - Expect(id.String()).To(Equal("al-1234_abcdef0123456789")) - }) - It("never emits a legacy timestamp/_0 suffix", func() { - id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) - Expect(id.String()).To(Equal("al-1234")) - }) - It("returns empty string for an empty id", func() { - Expect(model.ArtworkID{Kind: model.KindAlbumArtwork}.String()).To(BeEmpty()) - }) - }) - Describe("NewArtworkID()", func() { - It("round-trips Kind and ID through String()", func() { - id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) + It("creates a valid parseable ArtworkID", func() { + now := time.Now() + id := model.NewArtworkID(model.KindAlbumArtwork, "1234", &now) parsedId, err := model.ParseArtworkID(id.String()) Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) Expect(parsedId.ID).To(Equal(id.ID)) + Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix())) }) It("creates a valid ArtworkID without lastUpdate info", func() { id := model.NewArtworkID(model.KindPlaylistArtwork, "1234", nil) @@ -41,48 +25,15 @@ var _ = Describe("ArtworkID", func() { Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) Expect(parsedId.ID).To(Equal(id.ID)) + Expect(parsedId.LastUpdate.Unix()).To(Equal(id.LastUpdate.Unix())) }) }) - - Describe("ParseArtworkID - disc kind", func() { - It("parses a disc artwork ID with dc prefix", func() { - id := model.NewArtworkID(model.KindDiscArtwork, "albumid123:2", nil) - parsedId, err := model.ParseArtworkID(id.String()) - Expect(err).ToNot(HaveOccurred()) - Expect(parsedId.Kind).To(Equal(model.KindDiscArtwork)) - Expect(parsedId.ID).To(Equal("albumid123:2")) - }) - }) - - Describe("ParseDiscArtworkID", func() { - DescribeTable("parses composite disc artwork IDs", - func(id string, expectedAlbum string, expectedDisc int, expectErr bool) { - albumID, discNumber, err := model.ParseDiscArtworkID(id) - if expectErr { - Expect(err).To(HaveOccurred()) - } else { - Expect(err).ToNot(HaveOccurred()) - Expect(albumID).To(Equal(expectedAlbum)) - Expect(discNumber).To(Equal(expectedDisc)) - } - }, - Entry("valid id", "albumid123:2", "albumid123", 2, false), - Entry("disc number 1", "abc:1", "abc", 1, false), - Entry("large disc number", "abc:10", "abc", 10, false), - Entry("missing colon", "albumid123", "", 0, true), - Entry("missing disc number", "albumid123:", "", 0, true), - Entry("non-numeric disc", "albumid123:abc", "", 0, true), - Entry("empty string", "", "", 0, true), - ) - }) - Describe("ParseArtworkID()", func() { It("parses album artwork ids", func() { id, err := model.ParseArtworkID("al-1234") Expect(err).ToNot(HaveOccurred()) Expect(id.Kind).To(Equal(model.KindAlbumArtwork)) Expect(id.ID).To(Equal("1234")) - Expect(id.Hash).To(BeEmpty()) }) It("parses media file artwork ids", func() { id, err := model.ParseArtworkID("mf-a6f8d2b1") @@ -90,45 +41,12 @@ var _ = Describe("ArtworkID", func() { Expect(id.Kind).To(Equal(model.KindMediaFileArtwork)) Expect(id.ID).To(Equal("a6f8d2b1")) }) - It("parses playlist artwork ids with dashed UUID", func() { + It("parses playlists artwork ids", func() { id, err := model.ParseArtworkID("pl-18690de0-151b-4d86-81cb-f418a907315a") Expect(err).ToNot(HaveOccurred()) Expect(id.Kind).To(Equal(model.KindPlaylistArtwork)) Expect(id.ID).To(Equal("18690de0-151b-4d86-81cb-f418a907315a")) }) - It("captures a 16-hex suffix as Hash", func() { - id, err := model.ParseArtworkID("al-1234_abcdef0123456789") - Expect(err).ToNot(HaveOccurred()) - Expect(id.ID).To(Equal("1234")) - Expect(id.Hash).To(Equal("abcdef0123456789")) - Expect(id.LastUpdate.IsZero()).To(BeTrue()) - }) - It("captures a high-bit 16-hex suffix as Hash without error", func() { - id, err := model.ParseArtworkID("al-1234_ffffffffffffffff") - Expect(err).ToNot(HaveOccurred()) - Expect(id.ID).To(Equal("1234")) - Expect(id.Hash).To(Equal("ffffffffffffffff")) - }) - It("parses a legacy hex-timestamp suffix as LastUpdate", func() { - id, err := model.ParseArtworkID("al-123_688a1b2c") - Expect(err).ToNot(HaveOccurred()) - Expect(id.ID).To(Equal("123")) - Expect(id.Hash).To(BeEmpty()) - Expect(id.LastUpdate.Unix()).To(Equal(int64(0x688a1b2c))) - }) - It("parses a legacy _0 suffix", func() { - id, err := model.ParseArtworkID("al-123_0") - Expect(err).ToNot(HaveOccurred()) - Expect(id.ID).To(Equal("123")) - Expect(id.Hash).To(BeEmpty()) - Expect(id.LastUpdate.IsZero()).To(BeTrue()) - }) - It("silently drops a garbage suffix", func() { - id, err := model.ParseArtworkID("al-123_zz") - Expect(err).ToNot(HaveOccurred()) - Expect(id.ID).To(Equal("123")) - Expect(id.Hash).To(BeEmpty()) - }) It("fails to parse malformed ids", func() { _, err := model.ParseArtworkID("a6f8d2b1") Expect(err).To(MatchError("invalid artwork id")) diff --git a/model/artwork_test.go b/model/artwork_test.go deleted file mode 100644 index baf741e68..000000000 --- a/model/artwork_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package model_test - -import ( - "encoding/json" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func jsonOf(v any) map[string]any { - GinkgoHelper() - data, err := json.Marshal(v) - Expect(err).ToNot(HaveOccurred()) - var out map[string]any - Expect(json.Unmarshal(data, &out)).To(Succeed()) - return out -} - -var _ = Describe("ItemImage JSON", func() { - It("exposes the artwork state a client needs to render a placeholder", func() { - al := model.Album{ID: "al-1", Name: "Album"} - al.ImageHash = "0123456789abcdef" - al.ThumbHash = "1QcSHQRn" - al.ImageWidth, al.ImageHeight = 1200, 800 - - Expect(jsonOf(al)).To(SatisfyAll( - HaveKeyWithValue("imageHash", "0123456789abcdef"), - HaveKeyWithValue("thumbHash", "1QcSHQRn"), - HaveKeyWithValue("imageWidth", BeNumerically("==", 1200)), - HaveKeyWithValue("imageHeight", BeNumerically("==", 800)), - )) - }) - - It("keeps the blurhash off the native API, where nothing consumes it", func() { - al := model.Album{ID: "al-1", Name: "Album"} - al.BlurHash = "LEHV6nWB2yk8" - Expect(jsonOf(al)).ToNot(HaveKey("blurHash")) - }) - - It("omits every artwork field when the entity has none", func() { - out := jsonOf(model.Album{ID: "al-2", Name: "Album"}) - for _, key := range []string{ - "imageHash", "blurHash", "thumbHash", "imageAbsent", "imageWidth", "imageHeight", - } { - Expect(out).ToNot(HaveKey(key)) - } - }) - - It("exposes known-absent artwork so clients can skip the request", func() { - ar := model.Artist{ID: "ar-1", Name: "Artist"} - ar.ImageAbsent = true - Expect(jsonOf(ar)).To(HaveKeyWithValue("imageAbsent", true)) - }) - - Describe("AspectRatio", func() { - It("returns width/height", func() { - img := model.ItemImage{ImageHash: "abc", ImageWidth: 1200, ImageHeight: 800} - Expect(*img.AspectRatio()).To(BeNumerically("~", 1.5, 0.0001)) - }) - - It("returns nil when a dimension is missing, so callers never guess a ratio", func() { - Expect(model.ItemImage{ImageHash: "abc"}.AspectRatio()).To(BeNil()) - Expect(model.ItemImage{ImageHash: "abc", ImageWidth: 1200}.AspectRatio()).To(BeNil()) - Expect(model.ItemImage{ImageHash: "abc", ImageHeight: 800}.AspectRatio()).To(BeNil()) - }) - - It("returns nil for a known-absent image, whatever the dimensions say", func() { - img := model.ItemImage{ImageAbsent: true, ImageWidth: 1200, ImageHeight: 800} - Expect(img.AspectRatio()).To(BeNil()) - }) - }) -}) diff --git a/model/bookmark.go b/model/bookmark.go index 768d4a462..7c6637ce9 100644 --- a/model/bookmark.go +++ b/model/bookmark.go @@ -1,18 +1,15 @@ package model -import ( - "context" - "time" -) +import "time" type Bookmarkable struct { BookmarkPosition int64 `structs:"-" json:"bookmarkPosition"` } type BookmarkableRepository interface { - AddBookmark(ctx context.Context, id, comment string, position int64) error - DeleteBookmark(ctx context.Context, id string) error - GetBookmarks(ctx context.Context) (Bookmarks, error) + AddBookmark(id, comment string, position int64) error + DeleteBookmark(id string) error + GetBookmarks() (Bookmarks, error) } type Bookmark struct { diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index e6db3b481..bcd72cb79 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -1,105 +1,68 @@ -// Package criteria implements the smart playlist criteria DSL. +// Package criteria implements a Criteria API based on Masterminds/squirrel package criteria import ( "encoding/json" "errors" - "fmt" - "slices" - "time" + "strings" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils" ) -type Expression interface { - fields() map[string]any -} +type Expression = squirrel.Sqlizer type Criteria struct { Expression - Sort string - Order string - Limit int - LimitPercent int - Offset int - RefreshDelay time.Duration // 0 = use conf.Server.SmartPlaylistRefreshDelay + Sort string + Order string + Limit int + Offset int } -// EffectiveLimit resolves the effective limit for a query. If a fixed Limit is -// set it takes precedence. Otherwise, if LimitPercent is set, the limit is -// computed as a percentage of totalCount (minimum 1 when totalCount > 0). -// Returns 0 when no limit applies. -func (c Criteria) EffectiveLimit(totalCount int64) int { - if c.Limit > 0 { - return c.Limit +func (c Criteria) OrderBy() string { + if c.Sort == "" { + c.Sort = "title" } - if c.LimitPercent > 0 && c.LimitPercent <= 100 { - if totalCount <= 0 { - return 0 + f := fieldMap[strings.ToLower(c.Sort)] + var mapped string + if f == nil { + log.Error("Invalid field in 'sort' field. Using 'title'", "sort", c.Sort) + mapped = fieldMap["title"].field + } else { + if f.order == "" { + mapped = f.field + } else { + mapped = f.order } - result := int(totalCount) * c.LimitPercent / 100 - if result < 1 { - return 1 + } + if c.Order != "" { + if strings.EqualFold(c.Order, "asc") || strings.EqualFold(c.Order, "desc") { + mapped = mapped + " " + c.Order + } else { + log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order) } - return result } - return 0 + return mapped } -// 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) ChildPlaylistIds() []string { - return c.childPlaylistRefs(conjunction.ChildPlaylistIds) -} - -func (c Criteria) ChildPlaylistPaths() []string { - return c.childPlaylistRefs(conjunction.ChildPlaylistPaths) -} - -func (c Criteria) childPlaylistRefs(extract func(conjunction) []string) []string { - parent, ok := c.Expression.(conjunction) - if !ok { - return nil - } - refs := extract(parent) - slices.Sort(refs) - return slices.Compact(refs) +func (c Criteria) ToSql() (sql string, args []interface{}, err error) { + return c.Expression.ToSql() } func (c Criteria) MarshalJSON() ([]byte, error) { aux := struct { - All []Expression `json:"all,omitempty"` - Any []Expression `json:"any,omitempty"` - Sort string `json:"sort,omitempty"` - Order string `json:"order,omitempty"` - Limit int `json:"limit,omitempty"` - LimitPercent int `json:"limitPercent,omitempty"` - Offset int `json:"offset,omitempty"` - RefreshDelay string `json:"refreshDelay,omitempty"` + All []Expression `json:"all,omitempty"` + Any []Expression `json:"any,omitempty"` + Sort string `json:"sort,omitempty"` + Order string `json:"order,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` }{ - Sort: c.Sort, - Order: c.Order, - Limit: c.Limit, - LimitPercent: c.LimitPercent, - Offset: c.Offset, - } - if c.RefreshDelay > 0 { - aux.RefreshDelay = utils.FormatDuration(c.RefreshDelay) + Sort: c.Sort, + Order: c.Order, + Limit: c.Limit, + Offset: c.Offset, } switch rules := c.Expression.(type) { case Any: @@ -114,27 +77,20 @@ func (c Criteria) MarshalJSON() ([]byte, error) { func (c *Criteria) UnmarshalJSON(data []byte) error { var aux struct { - All optionalConjunction `json:"all"` - Any optionalConjunction `json:"any"` - Sort string `json:"sort"` - Order string `json:"order"` - Limit int `json:"limit"` - LimitPercent int `json:"limitPercent"` - Offset int `json:"offset"` - RefreshDelay string `json:"refreshDelay"` + All unmarshalConjunctionType `json:"all"` + Any unmarshalConjunctionType `json:"any"` + Sort string `json:"sort"` + Order string `json:"order"` + Limit int `json:"limit"` + Offset int `json:"offset"` } if err := json.Unmarshal(data, &aux); err != nil { return err } - // A Criteria has a single top-level group. Reject files that provide both keys - // (even when one is [] or null) rather than silently dropping one of them. - if aux.All.present && aux.Any.present { - return errors.New("invalid criteria json: 'all' and 'any' cannot both be used at the top level; nest one inside the other instead") - } - if len(aux.Any.rules) > 0 { - c.Expression = Any(aux.Any.rules) - } else if len(aux.All.rules) > 0 { - c.Expression = All(aux.All.rules) + if len(aux.Any) > 0 { + c.Expression = Any(aux.Any) + } else if len(aux.All) > 0 { + c.Expression = All(aux.All) } else { return errors.New("invalid criteria json. missing rules (key 'all' or 'any')") } @@ -142,23 +98,5 @@ func (c *Criteria) UnmarshalJSON(data []byte) error { c.Order = aux.Order c.Limit = aux.Limit c.Offset = aux.Offset - - if aux.RefreshDelay != "" { - d, err := utils.ParseDuration(aux.RefreshDelay) - if err != nil { - return fmt.Errorf("invalid refreshDelay: %w", err) - } - c.RefreshDelay = d - } - - // Clamp LimitPercent to [0, 100] - if aux.LimitPercent < 0 { - log.Warn("limitPercent value out of range, clamping to 0", "value", aux.LimitPercent) - aux.LimitPercent = 0 - } else if aux.LimitPercent > 100 { - log.Warn("limitPercent value out of range, clamping to 100", "value", aux.LimitPercent) - aux.LimitPercent = 100 - } - c.LimitPercent = aux.LimitPercent return nil } diff --git a/model/criteria/criteria_suite_test.go b/model/criteria/criteria_suite_test.go index 36e74cfa4..52175ae9c 100644 --- a/model/criteria/criteria_suite_test.go +++ b/model/criteria/criteria_suite_test.go @@ -12,6 +12,5 @@ import ( func TestCriteria(t *testing.T) { log.SetLevel(log.LevelFatal) gomega.RegisterFailHandler(Fail) - // Register `genre` as a tag name, so we can use it in tests RunSpecs(t, "Criteria Suite") } diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index de5124568..839989221 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -3,9 +3,7 @@ package criteria import ( "bytes" "encoding/json" - "time" - "github.com/google/uuid" . "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) @@ -13,31 +11,27 @@ import ( var _ = Describe("Criteria", func() { var goObj Criteria var jsonObj string - - Context("with a complex criteria", func() { - BeforeEach(func() { - goObj = Criteria{ - Expression: All{ - Contains{"title": "love"}, - NotContains{"title": "hate"}, - Any{ - IsNot{"artist": "u2"}, - Is{"album": "best of"}, - }, - All{ - StartsWith{"comment": "this"}, - InTheRange{"year": []int{1980, 1990}}, - IsNot{"genre": "Rock"}, - Gt{"albumrating": 3}, - }, + BeforeEach(func() { + goObj = Criteria{ + Expression: All{ + Contains{"title": "love"}, + NotContains{"title": "hate"}, + Any{ + IsNot{"artist": "u2"}, + Is{"album": "best of"}, }, - Sort: "title", - Order: "asc", - Limit: 20, - Offset: 10, - } - var b bytes.Buffer - err := json.Compact(&b, []byte(` + All{ + StartsWith{"comment": "this"}, + InTheRange{"year": []int{1980, 1990}}, + }, + }, + Sort: "title", + Order: "asc", + Limit: 20, + Offset: 10, + } + var b bytes.Buffer + err := json.Compact(&b, []byte(` { "all": [ { "contains": {"title": "love"} }, @@ -49,9 +43,7 @@ var _ = Describe("Criteria", func() { }, { "all": [ { "startsWith": {"comment": "this"} }, - { "inTheRange": {"year":[1980,1990]} }, - { "isNot": { "genre": "Rock" }}, - { "gt": { "albumrating": 3 } } + { "inTheRange": {"year":[1980,1990]} } ] } ], @@ -61,366 +53,38 @@ var _ = Describe("Criteria", func() { "offset": 10 } `)) - if err != nil { - panic(err) - } - jsonObj = b.String() - }) - It("marshals to JSON", func() { - j, err := json.Marshal(goObj) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(string(j)).To(gomega.Equal(jsonObj)) - }) - It("is reversible to/from JSON", func() { - var newObj Criteria - err := json.Unmarshal([]byte(jsonObj), &newObj) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - j, err := json.Marshal(newObj) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(string(j)).To(gomega.Equal(jsonObj)) - }) - }) - - Context("with both top-level 'all' and 'any'", func() { - It("returns an error instead of silently dropping one of the groups", func() { - jsonStr := `{"any":[{"inPlaylist":{"path":"a.nsp"}}],"all":[{"notInPlaylist":{"path":"b.nsp"}}]}` - var c Criteria - err := json.Unmarshal([]byte(jsonStr), &c) - gomega.Expect(err).To(gomega.HaveOccurred()) - gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any"))) - }) - - DescribeTable("rejects both keys even when one group is present but empty", - func(jsonStr string) { - var c Criteria - err := json.Unmarshal([]byte(jsonStr), &c) - gomega.Expect(err).To(gomega.HaveOccurred()) - gomega.Expect(err.Error()).To(gomega.And(gomega.ContainSubstring("all"), gomega.ContainSubstring("any"))) - }, - Entry("empty any", `{"any":[],"all":[{"is":{"loved":true}}]}`), - Entry("empty all", `{"all":[],"any":[{"is":{"loved":true}}]}`), - Entry("null any", `{"any":null,"all":[{"is":{"loved":true}}]}`), - ) - }) - - Describe("LimitPercent", func() { - Describe("JSON round-trip", func() { - It("marshals and unmarshals limitPercent", func() { - goObj := Criteria{ - Expression: All{Contains{"title": "love"}}, - Sort: "title", - Order: "asc", - LimitPercent: 10, - } - j, err := json.Marshal(goObj) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limitPercent":10`)) - gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`"limit"`)) - - var newObj Criteria - err = json.Unmarshal(j, &newObj) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(newObj.LimitPercent).To(gomega.Equal(10)) - gomega.Expect(newObj.Limit).To(gomega.Equal(0)) - }) - - It("does not include limitPercent when zero", func() { - goObj := Criteria{ - Expression: All{Contains{"title": "love"}}, - Limit: 50, - } - j, err := json.Marshal(goObj) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(string(j)).To(gomega.ContainSubstring(`"limit":50`)) - gomega.Expect(string(j)).ToNot(gomega.ContainSubstring(`limitPercent`)) - }) - - It("backward compatible: JSON with only limit still works", func() { - jsonStr := `{"all":[{"contains":{"title":"love"}}],"limit":20}` - var c Criteria - err := json.Unmarshal([]byte(jsonStr), &c) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(c.Limit).To(gomega.Equal(20)) - gomega.Expect(c.LimitPercent).To(gomega.Equal(0)) - }) - }) - - Describe("UnmarshalJSON clamping", func() { - It("clamps values above 100 to 100", func() { - jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":150}` - var c Criteria - err := json.Unmarshal([]byte(jsonStr), &c) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(c.LimitPercent).To(gomega.Equal(100)) - }) - - It("clamps negative values to 0", func() { - jsonStr := `{"all":[{"contains":{"title":"love"}}],"limitPercent":-5}` - var c Criteria - err := json.Unmarshal([]byte(jsonStr), &c) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(c.LimitPercent).To(gomega.Equal(0)) - }) - }) - - Describe("EffectiveLimit", func() { - It("returns fixed limit when Limit is set", func() { - c := Criteria{Limit: 50, LimitPercent: 10} - gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(50)) - }) - - It("returns percentage-based limit", func() { - c := Criteria{LimitPercent: 10} - gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(45)) - }) - - It("returns minimum 1 when totalCount > 0 and percentage rounds to 0", func() { - c := Criteria{LimitPercent: 1} - gomega.Expect(c.EffectiveLimit(5)).To(gomega.Equal(1)) - }) - - It("returns 0 when totalCount is 0", func() { - c := Criteria{LimitPercent: 10} - gomega.Expect(c.EffectiveLimit(0)).To(gomega.Equal(0)) - }) - - It("returns 0 when no limit is set", func() { - c := Criteria{} - gomega.Expect(c.EffectiveLimit(1000)).To(gomega.Equal(0)) - }) - - It("returns full count for 100%", func() { - c := Criteria{LimitPercent: 100} - gomega.Expect(c.EffectiveLimit(450)).To(gomega.Equal(450)) - }) - - It("returns 1 for 1% of 50 items", func() { - c := Criteria{LimitPercent: 1} - gomega.Expect(c.EffectiveLimit(50)).To(gomega.Equal(1)) - }) - }) - - 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} - gomega.Expect(c.IsPercentageLimit()).To(gomega.BeTrue()) - }) - - It("returns false when Limit is set", func() { - c := Criteria{Limit: 50, LimitPercent: 10} - gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse()) - }) - - It("returns false when neither is set", func() { - c := Criteria{} - gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse()) - }) - - It("returns false when LimitPercent is out of range", func() { - c := Criteria{LimitPercent: 150} - gomega.Expect(c.IsPercentageLimit()).To(gomega.BeFalse()) - }) - }) - }) - - Describe("refreshDelay", func() { - newCriteria := func(extra string) []byte { - return []byte(`{"all":[{"is":{"loved":true}}]` + extra + `}`) + if err != nil { + panic(err) } - - It("unmarshals a valid refreshDelay", func() { - var c Criteria - gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1d"`), &c)).To(gomega.Succeed()) - gomega.Expect(c.RefreshDelay).To(gomega.Equal(24 * time.Hour)) - }) - - It("supports week units", func() { - var c Criteria - gomega.Expect(json.Unmarshal(newCriteria(`,"refreshDelay":"1w"`), &c)).To(gomega.Succeed()) - gomega.Expect(c.RefreshDelay).To(gomega.Equal(7 * 24 * time.Hour)) - }) - - It("leaves RefreshDelay zero when absent", func() { - var c Criteria - gomega.Expect(json.Unmarshal(newCriteria(``), &c)).To(gomega.Succeed()) - gomega.Expect(c.RefreshDelay).To(gomega.BeZero()) - }) - - It("rejects an invalid refreshDelay", func() { - var c Criteria - err := json.Unmarshal(newCriteria(`,"refreshDelay":"tomorrow"`), &c) - gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay"))) - }) - - It("rejects a negative refreshDelay", func() { - var c Criteria - err := json.Unmarshal(newCriteria(`,"refreshDelay":"-1h"`), &c) - gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("refreshDelay"))) - }) - - It("marshals RefreshDelay back as a duration string", func() { - c := Criteria{ - Expression: All{Is{"loved": true}}, - RefreshDelay: 24 * time.Hour, - } - j, err := json.Marshal(c) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(string(j)).To(gomega.ContainSubstring(`"refreshDelay":"1d"`)) - }) - - It("omits refreshDelay from JSON when zero", func() { - c := Criteria{Expression: All{Is{"loved": true}}} - j, err := json.Marshal(c) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(string(j)).ToNot(gomega.ContainSubstring("refreshDelay")) - }) - - It("round-trips through marshal and unmarshal", func() { - c := Criteria{ - Expression: All{Is{"loved": true}}, - RefreshDelay: 36 * time.Hour, - } - j, err := json.Marshal(c) - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - var c2 Criteria - gomega.Expect(json.Unmarshal(j, &c2)).To(gomega.Succeed()) - gomega.Expect(c2.RefreshDelay).To(gomega.Equal(36 * time.Hour)) - }) + jsonObj = b.String() }) - Context("with child playlists", func() { - var ( - topLevelInPlaylistID string - topLevelInPlaylistPath string - topLevelNotInPlaylistID string - nestedAnyInPlaylistID string - nestedAnyNotInPlaylistID string - nestedAllInPlaylistID string - nestedAllNotInPlaylistID string - nestedAnyNotInPlaylistPath string - ) - BeforeEach(func() { - topLevelInPlaylistID = uuid.NewString() - topLevelInPlaylistPath = "./test.nsp" - topLevelNotInPlaylistID = uuid.NewString() - - nestedAnyInPlaylistID = uuid.NewString() - nestedAnyNotInPlaylistID = uuid.NewString() - nestedAnyNotInPlaylistPath = "../not-in-playlist.m3u" - - nestedAllInPlaylistID = uuid.NewString() - nestedAllNotInPlaylistID = uuid.NewString() - - goObj = Criteria{ - Expression: All{ - InPlaylist{"id": topLevelInPlaylistID}, - InPlaylist{"path": topLevelInPlaylistPath}, - NotInPlaylist{"id": topLevelNotInPlaylistID}, - Any{ - InPlaylist{"id": nestedAnyInPlaylistID}, - NotInPlaylist{"id": nestedAnyNotInPlaylistID}, - NotInPlaylist{"path": nestedAnyNotInPlaylistPath}, - }, - All{ - InPlaylist{"id": nestedAllInPlaylistID}, - NotInPlaylist{"id": nestedAllNotInPlaylistID}, - }, - }, - } - }) - It("extracts all child smart playlist IDs from expression criteria", func() { - ids := goObj.ChildPlaylistIds() - gomega.Expect(ids).To(gomega.ConsistOf(topLevelInPlaylistID, topLevelNotInPlaylistID, nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID)) - }) - It("extracts all child smart playlist paths from expression criteria", func() { - paths := goObj.ChildPlaylistPaths() - gomega.Expect(paths).To(gomega.ConsistOf(topLevelInPlaylistPath, nestedAnyNotInPlaylistPath)) - }) - It("ignores empty child playlist paths", func() { - c := Criteria{Expression: All{InPlaylist{"path": ""}, NotInPlaylist{"path": ""}}} - gomega.Expect(c.ChildPlaylistPaths()).To(gomega.BeEmpty()) - }) - It("ignores empty child playlist ids", func() { - c := Criteria{Expression: All{InPlaylist{"id": ""}, NotInPlaylist{"id": ""}}} - gomega.Expect(c.ChildPlaylistIds()).To(gomega.BeEmpty()) - }) - It("extracts child smart playlist IDs from deeply nested expression", func() { - goObj = Criteria{ - Expression: Any{ - Any{ - All{ - Any{ - InPlaylist{"id": nestedAnyInPlaylistID}, - NotInPlaylist{"id": nestedAnyNotInPlaylistID}, - Any{ - All{ - InPlaylist{"id": nestedAllInPlaylistID}, - NotInPlaylist{"id": nestedAllNotInPlaylistID}, - }, - }, - }, - }, - }, - }, - } - - ids := goObj.ChildPlaylistIds() - gomega.Expect(ids).To(gomega.ConsistOf(nestedAnyInPlaylistID, nestedAnyNotInPlaylistID, nestedAllInPlaylistID, nestedAllNotInPlaylistID)) - }) - It("returns empty list when no child playlist IDs are present", 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})) - }) + 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 (media_file.artist <> ? OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?)))")) + gomega.Expect(args).To(gomega.ConsistOf("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990)) }) + + It("marshals to JSON", func() { + j, err := json.Marshal(goObj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(string(j)).To(gomega.Equal(jsonObj)) + }) + + It("is reversible to/from JSON", func() { + var newObj Criteria + err := json.Unmarshal([]byte(jsonObj), &newObj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + j, err := json.Marshal(newObj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(string(j)).To(gomega.Equal(jsonObj)) + }) + + It("allows sort by random", func() { + newObj := goObj + newObj.Sort = "random" + gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc")) + }) + }) diff --git a/model/criteria/export_test.go b/model/criteria/export_test.go deleted file mode 100644 index e2109aa1a..000000000 --- a/model/criteria/export_test.go +++ /dev/null @@ -1,3 +0,0 @@ -package criteria - -type UnmarshalConjunctionType = unmarshalConjunctionType diff --git a/model/criteria/fields.go b/model/criteria/fields.go index a9a634c01..beb0456df 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -1,180 +1,67 @@ package criteria -import "strings" +import ( + "strings" -// 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 - // Nullable: isMissing/isPresent are supported on this column field. For numeric/boolean - // fields, missing means NULL; for string fields it means NULL or empty string. - Nullable bool + "github.com/navidrome/navidrome/log" +) - tagAlias string // If set, a tag name from mappings.yaml that resolves to this field - name string // Canonical name, populated by LookupField from the map key +var fieldMap = map[string]*mappedField{ + "title": {field: "media_file.title"}, + "album": {field: "media_file.album"}, + "artist": {field: "media_file.artist"}, + "albumartist": {field: "media_file.album_artist"}, + "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"}, + "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"}, + "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"}, + "albumtype": {field: "media_file.mbz_album_type"}, + "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"}, + "bpm": {field: "media_file.bpm"}, + "channels": {field: "media_file.channels"}, + "genre": {field: "genre.name"}, + "loved": {field: "COALESCE(annotation.starred, false)"}, + "dateloved": {field: "annotation.starred_at"}, + "lastplayed": {field: "annotation.play_date"}, + "playcount": {field: "COALESCE(annotation.play_count, 0)"}, + "rating": {field: "COALESCE(annotation.rating, 0)"}, + "random": {field: "", order: "random()"}, } -// Name returns the canonical field name (the map key used to register this field). -func (f FieldInfo) Name() string { - return f.name +type mappedField struct { + field string + order string } -var fieldMap = map[string]FieldInfo{ - "title": {}, - "album": {Nullable: true}, - "hascoverart": {Boolean: true}, - "tracknumber": {}, - "discnumber": {}, - "year": {}, - "date": {tagAlias: "recordingdate"}, - "originalyear": {}, - "originaldate": {}, - "releaseyear": {}, - "releasedate": {}, - "size": {}, - "compilation": {Boolean: true}, - "missing": {Boolean: true}, - "explicitstatus": {Nullable: true}, - "dateadded": {}, - "datemodified": {}, - "discsubtitle": {Nullable: true}, - "comment": {Nullable: true}, - "lyrics": {Nullable: true}, - "sorttitle": {Nullable: true}, - "sortalbum": {Nullable: true}, - "sortartist": {Nullable: true}, - "sortalbumartist": {Nullable: true}, - "albumcomment": {Nullable: true}, - "catalognumber": {Nullable: true}, - "filepath": {}, - "filetype": {}, - "codec": {}, - "duration": {}, - "bitrate": {}, - "bitdepth": {Numeric: true, Nullable: true}, - "samplerate": {}, - "bpm": {Numeric: true, Nullable: true}, - "channels": {}, - "loved": {Boolean: true}, - "dateloved": {}, - "lastplayed": {}, - "daterated": {}, - "playcount": {}, - "rating": {}, - "averagerating": {Numeric: true}, - "albumrating": {}, - "albumloved": {Boolean: true}, - "albumplaycount": {}, - "albumlastplayed": {}, - "albumdateloved": {}, - "albumdaterated": {}, - "albumdateadded": {}, - "albumdatemodified": {}, - "albumduration": {}, - "albumsongcount": {}, - "albumsize": {}, - "artistrating": {}, - "artistloved": {Boolean: true}, - "artistplaycount": {}, - "artistlastplayed": {}, - "artistdateloved": {}, - "artistdaterated": {}, - "mbz_album_id": {Nullable: true}, - "mbz_album_artist_id": {Nullable: true}, - "mbz_artist_id": {Nullable: true}, - "mbz_recording_id": {Nullable: true}, - "mbz_release_track_id": {Nullable: true}, - "mbz_release_group_id": {Nullable: true}, - "rgalbumgain": {Numeric: true, Nullable: true}, - "rgalbumpeak": {Numeric: true, Nullable: true}, - "rgtrackgain": {Numeric: true, Nullable: true}, - "rgtrackpeak": {Numeric: true, Nullable: true}, - "library_id": {Numeric: true}, - - // Backward compatibility: albumtype is an alias for the releasetype tag. - "albumtype": {Alias: "releasetype", IsTag: true}, - - // Backward compatibility: the replaygain_* tag names (as written in metadata and in the - // PR #5256 example) are aliases for the canonical rg* column fields. Without these, the tag - // names would be registered as empty tags from mappings.yaml and isMissing would always match. - "replaygain_album_gain": {Alias: "rgalbumgain", Numeric: true, Nullable: true}, - "replaygain_album_peak": {Alias: "rgalbumpeak", Numeric: true, Nullable: true}, - "replaygain_track_gain": {Alias: "rgtrackgain", Numeric: true, Nullable: true}, - "replaygain_track_peak": {Alias: "rgtrackpeak", Numeric: true, Nullable: 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 +func mapFields(expr map[string]interface{}) map[string]interface{} { + m := make(map[string]interface{}) + for f, v := range expr { + if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" { + m[dbf.field] = v } else { - f.name = key - } - } - 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. -func AddRoles(roles []string) { - for _, role := range roles { - name := strings.ToLower(role) - if _, ok := fieldMap[name]; ok { - continue - } - fieldMap[name] = FieldInfo{IsRole: true} - } -} - -// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yaml` -// configuration file. -func AddTagNames(tagNames []string) { - for _, tagName := range tagNames { - name := strings.ToLower(tagName) - if _, ok := fieldMap[name]; ok { - continue - } - 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] = FieldInfo{IsTag: true} - } - } -} - -// AddNumericTags adds tags that should be treated as numbers. -func AddNumericTags(tagNames []string) { - for _, tagName := range tagNames { - name := strings.ToLower(tagName) - if fm, ok := fieldMap[name]; ok { - fm.Numeric = true - fieldMap[name] = fm - } else { - fieldMap[name] = FieldInfo{IsTag: true, Numeric: true} + log.Error("Invalid field in criteria", "field", f) } } + return m } diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index 2367101a8..2828dbda4 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -6,86 +6,11 @@ import ( ) var _ = Describe("fields", func() { - 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")) + Describe("mapFields", func() { + It("ignores random fields", func() { + m := map[string]interface{}{"random": "123"} + m = mapFields(m) + gomega.Expect(m).To(gomega.BeEmpty()) }) - - 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()) - }) - - It("marks ReplayGain column fields as nullable", func() { - field, ok := LookupField("rgAlbumGain") - - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name()).To(gomega.Equal("rgalbumgain")) - gomega.Expect(field.Nullable).To(gomega.BeTrue()) - gomega.Expect(field.IsTag).To(gomega.BeFalse()) - }) - - It("resolves replaygain_* tag names as aliases to nullable column fields", func() { - // AddTagNames skips names already in the field map, so the startup tag registration - // (from mappings.yaml) must not convert the pre-registered alias into a tag field. - AddTagNames([]string{"replaygain_album_gain"}) - - field, ok := LookupField("replaygain_album_gain") - - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name()).To(gomega.Equal("rgalbumgain")) - gomega.Expect(field.Nullable).To(gomega.BeTrue()) - gomega.Expect(field.IsTag).To(gomega.BeFalse()) - }) - - It("marks mbz_* and lyrics string fields as nullable (empty means missing)", func() { - for _, name := range []string{"mbz_album_id", "mbz_album_artist_id", "mbz_artist_id", - "mbz_recording_id", "mbz_release_track_id", "mbz_release_group_id", "lyrics", - "album", "comment", "catalognumber", "discsubtitle", "albumcomment", - "sorttitle", "sortalbum", "sortartist", "sortalbumartist", "explicitstatus"} { - field, ok := LookupField(name) - gomega.Expect(ok).To(gomega.BeTrue(), name) - gomega.Expect(field.Nullable).To(gomega.BeTrue(), name) - gomega.Expect(field.Numeric).To(gomega.BeFalse(), name) - } - }) - }) }) diff --git a/model/criteria/json.go b/model/criteria/json.go index d0f453524..f1f1e2015 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -3,8 +3,8 @@ package criteria import ( "encoding/json" "fmt" - "strconv" "strings" + "time" ) type unmarshalConjunctionType []Expression @@ -24,7 +24,7 @@ func (uc *unmarshalConjunctionType) UnmarshalJSON(data []byte) error { expr = unmarshalConjunction(k, v) } if expr == nil { - return fmt.Errorf(`invalid expression key '%s'`, k) + return fmt.Errorf(`invalid expression key %s`, k) } es = append(es, expr) } @@ -33,27 +33,12 @@ func (uc *unmarshalConjunctionType) UnmarshalJSON(data []byte) error { return nil } -// optionalConjunction is a top-level "all"/"any" value that remembers whether its -// key was present at all, so a Criteria providing both can be rejected. encoding/json -// calls UnmarshalJSON even for a JSON null, so present is set whenever the key appears -// — including as [] or null — while an absent key leaves it false. -type optionalConjunction struct { - present bool - rules unmarshalConjunctionType -} - -func (o *optionalConjunction) UnmarshalJSON(data []byte) error { - o.present = true - return json.Unmarshal(data, &o.rules) -} - func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { - m := make(map[string]any) + m := make(map[string]interface{}) err := json.Unmarshal(rawValue, &m) if err != nil { return nil } - normalizeBoolFields(m) switch opName { case "is": return Is(m) @@ -81,64 +66,10 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { return InTheLast(m) case "notinthelast": return NotInTheLast(m) - case "inplaylist": - 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) - } - } -} - -// ToBool coerces a criteria value to a bool, accepting the forms criteria values take: a real bool, -// a strconv.ParseBool-parseable string, or a JSON number that is exactly 0 or 1. Any other value -// (other numbers, slices, nil, unparseable strings) returns ok=false so callers can handle it. -func ToBool(v any) (bool, bool) { - switch val := v.(type) { - case bool: - return val, true - case string: - b, err := strconv.ParseBool(val) - return b, err == nil - case float64: - switch val { - case 1: - return true, true - case 0: - return false, true - } - } - return false, false -} - -// normalizeBoolValue leaves non-boolean values unchanged so they flow through to their own validation. -func normalizeBoolValue(v any) any { - if b, ok := ToBool(v); ok { - return b - } - return v -} - func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression { var items unmarshalConjunctionType err := json.Unmarshal(rawValue, &items) @@ -154,7 +85,7 @@ func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression return nil } -func marshalExpression(name string, value map[string]any) ([]byte, error) { +func marshalExpression(name string, value map[string]interface{}) ([]byte, error) { if len(value) != 1 { return nil, fmt.Errorf(`invalid %s expression length %d for values %v`, name, len(value), value) } @@ -185,3 +116,10 @@ func marshalConjunction(name string, conj []Expression) ([]byte, error) { } return json.Marshal(aux) } + +type date time.Time + +func (t date) MarshalJSON() ([]byte, error) { + stamp := fmt.Sprintf(`"%s"`, time.Time(t).Format("2006-01-02")) + return []byte(stamp), nil +} diff --git a/model/criteria/operators.go b/model/criteria/operators.go index ec32f2d16..2ebca2b61 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,192 +1,229 @@ package criteria -// Conjunctions need to implement this interface, to allow Criteria to extract child playlist references recursively -type conjunction interface { - ChildPlaylistIds() []string - ChildPlaylistPaths() []string -} +import ( + "fmt" + "reflect" + "strconv" + "time" + + "github.com/Masterminds/squirrel" +) type ( - All []Expression + All squirrel.And And = All ) -func (All) fields() map[string]any { return nil } +func (all All) ToSql() (sql string, args []interface{}, err error) { + return squirrel.And(all).ToSql() +} func (all All) MarshalJSON() ([]byte, error) { return marshalConjunction("all", all) } -func (all All) ChildPlaylistIds() []string { return extractPlaylistField(all, "id") } - -func (all All) ChildPlaylistPaths() []string { return extractPlaylistField(all, "path") } - type ( - Any []Expression + Any squirrel.Or Or = Any ) -func (Any) fields() map[string]any { return nil } +func (any Any) ToSql() (sql string, args []interface{}, err error) { + return squirrel.Or(any).ToSql() +} func (any Any) MarshalJSON() ([]byte, error) { return marshalConjunction("any", any) } -func (any Any) ChildPlaylistIds() []string { return extractPlaylistField(any, "id") } - -func (any Any) ChildPlaylistPaths() []string { return extractPlaylistField(any, "path") } - -type Is map[string]any +type Is squirrel.Eq type Eq = Is +func (is Is) ToSql() (sql string, args []interface{}, err error) { + return squirrel.Eq(mapFields(is)).ToSql() +} + func (is Is) MarshalJSON() ([]byte, error) { return marshalExpression("is", is) } -func (is Is) fields() map[string]any { return is } +type IsNot squirrel.NotEq -type IsNot map[string]any - -func (isn IsNot) MarshalJSON() ([]byte, error) { - return marshalExpression("isNot", isn) +func (in IsNot) ToSql() (sql string, args []interface{}, err error) { + return squirrel.NotEq(mapFields(in)).ToSql() } -func (isn IsNot) fields() map[string]any { return isn } +func (in IsNot) MarshalJSON() ([]byte, error) { + return marshalExpression("isNot", in) +} -type Gt map[string]any +type Gt squirrel.Gt + +func (gt Gt) ToSql() (sql string, args []interface{}, err error) { + return squirrel.Gt(mapFields(gt)).ToSql() +} func (gt Gt) MarshalJSON() ([]byte, error) { return marshalExpression("gt", gt) } -func (gt Gt) fields() map[string]any { return gt } +type Lt squirrel.Lt -type Lt map[string]any +func (lt Lt) ToSql() (sql string, args []interface{}, err error) { + return squirrel.Lt(mapFields(lt)).ToSql() +} func (lt Lt) MarshalJSON() ([]byte, error) { return marshalExpression("lt", lt) } -func (lt Lt) fields() map[string]any { return lt } +type Before squirrel.Lt -type Before map[string]any +func (bf Before) ToSql() (sql string, args []interface{}, err error) { + return squirrel.Lt(mapFields(bf)).ToSql() +} func (bf Before) MarshalJSON() ([]byte, error) { return marshalExpression("before", bf) } -func (bf Before) fields() map[string]any { return bf } +type After squirrel.Gt -type After Gt +func (af After) ToSql() (sql string, args []interface{}, err error) { + return squirrel.Gt(mapFields(af)).ToSql() +} func (af After) MarshalJSON() ([]byte, error) { return marshalExpression("after", af) } -func (af After) fields() map[string]any { return af } +type Contains map[string]interface{} -type Contains map[string]any +func (ct Contains) ToSql() (sql string, args []interface{}, err error) { + lk := squirrel.Like{} + for f, v := range mapFields(ct) { + lk[f] = fmt.Sprintf("%%%s%%", v) + } + return lk.ToSql() +} func (ct Contains) MarshalJSON() ([]byte, error) { return marshalExpression("contains", ct) } -func (ct Contains) fields() map[string]any { return ct } +type NotContains map[string]interface{} -type NotContains map[string]any +func (nct NotContains) ToSql() (sql string, args []interface{}, err error) { + lk := squirrel.NotLike{} + for f, v := range mapFields(nct) { + lk[f] = fmt.Sprintf("%%%s%%", v) + } + return lk.ToSql() +} func (nct NotContains) MarshalJSON() ([]byte, error) { return marshalExpression("notContains", nct) } -func (nct NotContains) fields() map[string]any { return nct } +type StartsWith map[string]interface{} -type StartsWith map[string]any +func (sw StartsWith) ToSql() (sql string, args []interface{}, err error) { + lk := squirrel.Like{} + for f, v := range mapFields(sw) { + lk[f] = fmt.Sprintf("%s%%", v) + } + return lk.ToSql() +} func (sw StartsWith) MarshalJSON() ([]byte, error) { return marshalExpression("startsWith", sw) } -func (sw StartsWith) fields() map[string]any { return sw } +type EndsWith map[string]interface{} -type EndsWith map[string]any - -func (ew EndsWith) MarshalJSON() ([]byte, error) { - return marshalExpression("endsWith", ew) +func (sw EndsWith) ToSql() (sql string, args []interface{}, err error) { + lk := squirrel.Like{} + for f, v := range mapFields(sw) { + lk[f] = fmt.Sprintf("%%%s", v) + } + return lk.ToSql() } -func (ew EndsWith) fields() map[string]any { return ew } +func (sw EndsWith) MarshalJSON() ([]byte, error) { + return marshalExpression("endsWith", sw) +} -type InTheRange map[string]any +type InTheRange map[string]interface{} + +func (itr InTheRange) ToSql() (sql string, args []interface{}, err error) { + var 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) } -func (itr InTheRange) fields() map[string]any { return itr } +type InTheLast map[string]interface{} -type InTheLast map[string]any +func (itl InTheLast) ToSql() (sql string, args []interface{}, err error) { + exp, err := inPeriod(itl, false) + if err != nil { + return "", nil, err + } + return exp.ToSql() +} func (itl InTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("inTheLast", itl) } -func (itl InTheLast) fields() map[string]any { return itl } +type NotInTheLast map[string]interface{} -type NotInTheLast map[string]any +func (nitl NotInTheLast) ToSql() (sql string, args []interface{}, err error) { + exp, err := inPeriod(nitl, true) + if err != nil { + return "", nil, err + } + return exp.ToSql() +} func (nitl NotInTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("notInTheLast", nitl) } -func (nitl NotInTheLast) fields() map[string]any { return nitl } - -type InPlaylist map[string]any - -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 (nipl NotInPlaylist) MarshalJSON() ([]byte, error) { - return marshalExpression("notInPlaylist", nipl) -} - -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 (im IsMissing) fields() map[string]any { return im } - -type IsPresent map[string]any - -func (ip IsPresent) MarshalJSON() ([]byte, error) { - return marshalExpression("isPresent", ip) -} - -func (ip IsPresent) fields() map[string]any { return ip } - -func extractPlaylistField(inputRule any, field string) (values []string) { - switch rule := inputRule.(type) { - case Any: - for _, rules := range rule { - values = append(values, extractPlaylistField(rules, field)...) - } - case All: - for _, rules := range rule { - values = append(values, extractPlaylistField(rules, field)...) - } - case InPlaylist, NotInPlaylist: - if value, ok := rule.(Expression).fields()[field].(string); ok && value != "" { - values = append(values, value) - } +func inPeriod(m map[string]interface{}, negate bool) (Expression, error) { + var field string + var value interface{} + for f, v := range mapFields(m) { + field, value = f, v + break } - return + 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 startOfPeriod(numDays int64, from time.Time) string { + return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") } diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index e5c8e1763..5b7bc0426 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -1,21 +1,43 @@ -package criteria_test +package criteria import ( "encoding/json" "fmt" + "time" - . "github.com/navidrome/navidrome/model/criteria" . "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) -var _ = BeforeSuite(func() { - AddRoles([]string{"artist", "composer"}) - AddTagNames([]string{"genre"}) - AddNumericTags([]string{"rate"}) -}) - var _ = Describe("Operators", func() { + rangeStart := date(time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local)) + rangeEnd := date(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.ConsistOf(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("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"), + 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": []date{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), + // TODO These may be flaky + 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())), + ) + DescribeTable("JSON Marshaling", func(op Expression, jsonString string) { obj := And{op} @@ -23,7 +45,7 @@ var _ = Describe("Operators", func() { gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(string(newJs)).To(gomega.Equal(fmt.Sprintf(`{"all":[%s]}`, jsonString))) - var unmarshalObj UnmarshalConjunctionType + var unmarshalObj unmarshalConjunctionType js := "[" + jsonString + "]" err = json.Unmarshal([]byte(js), &unmarshalObj) gomega.Expect(err).ToNot(gomega.HaveOccurred()) @@ -31,7 +53,6 @@ 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}}`), @@ -39,112 +60,11 @@ var _ = Describe("Operators", func() { Entry("notContains", NotContains{"title": "Low Rider"}, `{"notContains":{"title":"Low Rider"}}`), Entry("startsWith", StartsWith{"title": "Low Rider"}, `{"startsWith":{"title":"Low Rider"}}`), Entry("endsWith", EndsWith{"title": "Low Rider"}, `{"endsWith":{"title":"Low Rider"}}`), - Entry("inTheRange [number]", InTheRange{"year": []any{1980.0, 1990.0}}, `{"inTheRange":{"year":[1980,1990]}}`), - Entry("inTheRange [date]", InTheRange{"lastPlayed": []any{"2021-10-01", "2021-11-01"}}, `{"inTheRange":{"lastPlayed":["2021-10-01","2021-11-01"]}}`), + Entry("inTheRange [number]", InTheRange{"year": []interface{}{1980.0, 1990.0}}, `{"inTheRange":{"year":[1980,1990]}}`), + Entry("inTheRange [date]", InTheRange{"lastPlayed": []interface{}{"2021-10-01", "2021-11-01"}}, `{"inTheRange":{"lastPlayed":["2021-10-01","2021-11-01"]}}`), Entry("before", Before{"lastPlayed": "2021-10-01"}, `{"before":{"lastPlayed":"2021-10-01"}}`), Entry("after", After{"lastPlayed": "2021-10-01"}, `{"after":{"lastPlayed":"2021-10-01"}}`), Entry("inTheLast", InTheLast{"lastPlayed": 30.0}, `{"inTheLast":{"lastPlayed":30}}`), 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})) - }) - }) - - DescribeTable("ToBool", - func(in any, wantVal, wantOk bool) { - got, ok := ToBool(in) - gomega.Expect(ok).To(gomega.Equal(wantOk)) - gomega.Expect(got).To(gomega.Equal(wantVal)) - }, - Entry("real bool true", true, true, true), - Entry("real bool false", false, false, true), - Entry("string true", "true", true, true), - Entry("string false", "false", false, true), - Entry("string 1", "1", true, true), - Entry("string t", "t", true, true), - Entry("string 0", "0", false, true), - Entry("string unparseable", "yes", false, false), - Entry("float64 1", float64(1), true, true), - Entry("float64 0", float64(0), false, true), - Entry("float64 other", float64(2), false, false), - Entry("slice", []any{true}, false, false), - Entry("nil", nil, false, false), ) }) diff --git a/model/criteria/sort.go b/model/criteria/sort.go deleted file mode 100644 index e38fe0551..000000000 --- a/model/criteria/sort.go +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 35db88549..000000000 --- a/model/criteria/sort_test.go +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index 7445c2aef..000000000 --- a/model/criteria/walk.go +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 2e0f12f8d..000000000 --- a/model/criteria/walk_test.go +++ /dev/null @@ -1,64 +0,0 @@ -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/datastore.go b/model/datastore.go index 6ded8c575..844c70d6f 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -4,6 +4,7 @@ import ( "context" "github.com/Masterminds/squirrel" + "github.com/deluan/rest" ) type QueryOptions struct { @@ -12,36 +13,31 @@ type QueryOptions struct { Max int Offset int Filters squirrel.Sqlizer - Seed string // for random sorting +} + +type ResourceRepository interface { + rest.Repository } type DataStore interface { - Library() LibraryRepository - Folder() FolderRepository - Album() AlbumRepository - Artist() ArtistRepository - MediaFile() MediaFileRepository - Genre() GenreRepository - Tag() TagRepository - Playlist() PlaylistRepository - PlayQueue() PlayQueueRepository - Transcoding() TranscodingRepository - Player() PlayerRepository - Radio() RadioRepository - Share() ShareRepository - Property() PropertyRepository - User() UserRepository - UserProps() UserPropsRepository - ScrobbleBuffer() ScrobbleBufferRepository - Scrobble() ScrobbleRepository - Plugin() PluginRepository - Artwork() ArtworkRepository - ArtworkQueue() ArtworkQueueRepository + Album(ctx context.Context) AlbumRepository + Artist(ctx context.Context) ArtistRepository + MediaFile(ctx context.Context) MediaFileRepository + MediaFolder(ctx context.Context) MediaFolderRepository + Genre(ctx context.Context) GenreRepository + Playlist(ctx context.Context) PlaylistRepository + PlayQueue(ctx context.Context) PlayQueueRepository + Transcoding(ctx context.Context) TranscodingRepository + Player(ctx context.Context) PlayerRepository + Radio(ctx context.Context) RadioRepository + Share(ctx context.Context) ShareRepository + Property(ctx context.Context) PropertyRepository + User(ctx context.Context) UserRepository + UserProps(ctx context.Context) UserPropsRepository + ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository - WithTx(block func(tx DataStore) error, scope ...string) error - WithTxImmediate(block func(tx DataStore) error, scope ...string) error - // WithTxRetry runs block in a transaction, rerunning it while SQLite reports the database busy. - // For background work only (it can take minutes), and block must be safe to rerun after a rollback. - WithTxRetry(ctx context.Context, block func(ctx context.Context, tx DataStore) error, scope ...string) error - GC(ctx context.Context, libraryIDs ...int) error + Resource(ctx context.Context, model interface{}) ResourceRepository + + WithTx(func(tx DataStore) error) error + GC(ctx context.Context, rootFolder string) error } diff --git a/model/errors.go b/model/errors.go index b3f07565f..ff4be5723 100644 --- a/model/errors.go +++ b/model/errors.go @@ -1,17 +1,11 @@ package model -import ( - "errors" - - "github.com/deluan/rest" -) +import "errors" var ( - ErrNotFound = rest.ErrNotFound - ErrInvalidAuth = errors.New("invalid authentication") - ErrNotAuthorized = rest.ErrPermissionDenied - ErrExpired = errors.New("access expired") - ErrNotAvailable = errors.New("functionality not available") - ErrValidation = errors.New("validation error") - ErrPlaylistNotEditable = errors.New("playlist tracks are not editable") + ErrNotFound = errors.New("data not found") + ErrInvalidAuth = errors.New("invalid authentication") + ErrNotAuthorized = errors.New("not authorized") + ErrExpired = errors.New("access expired") + ErrNotAvailable = errors.New("functionality not available") ) diff --git a/model/file_types.go b/model/file_types.go index 31590dd78..78dd7eaff 100644 --- a/model/file_types.go +++ b/model/file_types.go @@ -3,12 +3,12 @@ package model import ( "mime" "path/filepath" - "slices" "strings" + + "golang.org/x/exp/slices" ) var excludeAudioType = []string{ - "audio/mpegurl", "audio/x-mpegurl", "audio/x-scpls", } diff --git a/model/file_types_test.go b/model/file_types_test.go index 07dac645e..93301e151 100644 --- a/model/file_types_test.go +++ b/model/file_types_test.go @@ -18,10 +18,6 @@ var _ = Describe("File Types()", func() { Expect(model.IsAudioFile("test.flac")).To(BeTrue()) }) - It("returns true for a WebM file", func() { - Expect(model.IsAudioFile("test.webm")).To(BeTrue()) - }) - It("returns false for a non-audio file", func() { Expect(model.IsAudioFile("test.jpg")).To(BeFalse()) }) diff --git a/model/folder.go b/model/folder.go deleted file mode 100644 index 701fca8cd..000000000 --- a/model/folder.go +++ /dev/null @@ -1,102 +0,0 @@ -package model - -import ( - "context" - "fmt" - "iter" - "os" - "path" - "path/filepath" - "strings" - "time" - - "github.com/navidrome/navidrome/model/id" -) - -// Folder represents a folder in the library. Its path is relative to the library root. -// ALWAYS use NewFolder to create a new instance. -type Folder struct { - ID string `structs:"id"` - LibraryID int `structs:"library_id"` - LibraryPath string `structs:"-" json:"-" hash:"ignore"` - Path string `structs:"path"` - Name string `structs:"name"` - ParentID string `structs:"parent_id"` - NumAudioFiles int `structs:"num_audio_files"` - NumPlaylists int `structs:"num_playlists"` - ImageFiles []string `structs:"image_files"` - ImagesUpdatedAt time.Time `structs:"images_updated_at"` - Hash string `structs:"hash"` - Missing bool `structs:"missing"` - UpdateAt time.Time `structs:"updated_at"` - CreatedAt time.Time `structs:"created_at"` -} - -func (f Folder) AbsolutePath() string { - return filepath.Join(f.LibraryPath, f.Path, f.Name) -} - -func (f Folder) String() string { - return f.AbsolutePath() -} - -// FolderID generates a unique ID for a folder in a library. -// The ID is generated based on the library ID and the folder path relative to the library root. -// Any leading or trailing slashes are removed from the folder path. -func FolderID(lib Library, path string) string { - path = strings.TrimPrefix(path, lib.Path) - path = strings.TrimPrefix(path, string(os.PathSeparator)) - path = filepath.Clean(path) - key := fmt.Sprintf("%d:%s", lib.ID, path) - return id.NewHash(key) -} - -func NewFolder(lib Library, folderPath string) *Folder { - newID := FolderID(lib, folderPath) - dir, name := path.Split(folderPath) - dir = path.Clean(dir) - var parentID string - if dir == "." && name == "." { - dir = "" - parentID = "" - } else { - parentID = FolderID(lib, dir) - } - return &Folder{ - LibraryID: lib.ID, - ID: newID, - Path: dir, - Name: name, - ParentID: parentID, - ImageFiles: []string{}, - UpdateAt: time.Now(), - CreatedAt: time.Now(), - } -} - -type FolderCursor iter.Seq2[Folder, error] - -type FolderUpdateInfo struct { - UpdatedAt time.Time - Hash string - ImageFiles []string - ImagesUpdatedAt time.Time -} - -type FolderRepository interface { - Get(ctx context.Context, id string) (*Folder, error) - GetByPath(ctx context.Context, lib Library, path string) (*Folder, error) - GetAll(ctx context.Context, options ...QueryOptions) ([]Folder, error) - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - GetFolderUpdateInfo(ctx context.Context, lib Library, targetPaths ...string) (map[string]FolderUpdateInfo, error) - // HasAudioOutsideFolders reports whether any folder in parent's subtree - // (including parent itself) contains audio files and is not one of the - // given folder IDs. - HasAudioOutsideFolders(ctx context.Context, parent Folder, excludeFolderIDs []string) (bool, error) - Put(ctx context.Context, f *Folder) error - MarkMissing(ctx context.Context, missing bool, ids ...string) error - GetTouchedWithPlaylists(ctx context.Context) (FolderCursor, error) - // GetAllWithPlaylists returns all non-missing folders with playlists, ignoring - // the scan-timestamp gate used by GetTouchedWithPlaylists. - GetAllWithPlaylists(ctx context.Context) (FolderCursor, error) -} diff --git a/model/folder_test.go b/model/folder_test.go deleted file mode 100644 index 4c1b4c2b7..000000000 --- a/model/folder_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package model_test - -import ( - "path" - "path/filepath" - "time" - - "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" -) - -var _ = Describe("Folder", func() { - var ( - lib model.Library - ) - - BeforeEach(func() { - lib = model.Library{ - ID: 1, - Path: filepath.FromSlash("/music"), - } - }) - - Describe("FolderID", func() { - When("the folder path is the library root", func() { - It("should return the correct folder ID", func() { - folderPath := lib.Path - expectedID := id.NewHash("1:.") - Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID)) - }) - }) - - When("the folder path is '.' (library root)", func() { - It("should return the correct folder ID", func() { - folderPath := "." - expectedID := id.NewHash("1:.") - Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID)) - }) - }) - - When("the folder path is relative", func() { - It("should return the correct folder ID", func() { - folderPath := "rock" - expectedID := id.NewHash("1:rock") - Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID)) - }) - }) - - When("the folder path starts with '.'", func() { - It("should return the correct folder ID", func() { - folderPath := "./rock" - expectedID := id.NewHash("1:rock") - Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID)) - }) - }) - - When("the folder path is absolute", func() { - It("should return the correct folder ID", func() { - folderPath := filepath.FromSlash("/music/rock") - expectedID := id.NewHash("1:rock") - Expect(model.FolderID(lib, folderPath)).To(Equal(expectedID)) - }) - }) - - 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)) - }) - }) - }) - - 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) - - Expect(folder.LibraryID).To(Equal(lib.ID)) - Expect(folder.ID).To(Equal(model.FolderID(lib, folderPath))) - Expect(folder.Path).To(Equal(path.Clean("rock"))) - Expect(folder.Name).To(Equal("metal")) - Expect(folder.ParentID).To(Equal(model.FolderID(lib, "rock"))) - Expect(folder.ImageFiles).To(BeEmpty()) - Expect(folder.UpdateAt).To(BeTemporally("~", time.Now(), time.Second)) - Expect(folder.CreatedAt).To(BeTemporally("~", time.Now(), time.Second)) - }) - - It("should create a new Folder with the correct attributes", func() { - folderPath := "rock" - folder := model.NewFolder(lib, folderPath) - - Expect(folder.LibraryID).To(Equal(lib.ID)) - Expect(folder.ID).To(Equal(model.FolderID(lib, folderPath))) - Expect(folder.Path).To(Equal(path.Clean("."))) - Expect(folder.Name).To(Equal("rock")) - Expect(folder.ParentID).To(Equal(model.FolderID(lib, "."))) - Expect(folder.ImageFiles).To(BeEmpty()) - Expect(folder.UpdateAt).To(BeTemporally("~", time.Now(), time.Second)) - Expect(folder.CreatedAt).To(BeTemporally("~", time.Now(), time.Second)) - }) - - It("should handle the root folder correctly", func() { - folderPath := "." - folder := model.NewFolder(lib, folderPath) - - Expect(folder.LibraryID).To(Equal(lib.ID)) - Expect(folder.ID).To(Equal(model.FolderID(lib, folderPath))) - Expect(folder.Path).To(Equal("")) - Expect(folder.Name).To(Equal(".")) - Expect(folder.ParentID).To(Equal("")) - Expect(folder.ImageFiles).To(BeEmpty()) - Expect(folder.UpdateAt).To(BeTemporally("~", time.Now(), time.Second)) - Expect(folder.CreatedAt).To(BeTemporally("~", time.Now(), time.Second)) - }) - }) -}) diff --git a/model/genre.go b/model/genre.go index 147422e67..edb4a55c1 100644 --- a/model/genre.go +++ b/model/genre.go @@ -1,22 +1,15 @@ package model -import ( - "context" - - "github.com/deluan/rest" -) - type Genre struct { - ID string `structs:"id" json:"id,omitempty" toml:"id,omitempty" yaml:"id,omitempty"` + ID string `structs:"id" json:"id" orm:"column(id)"` Name string `structs:"name" json:"name"` - SongCount int `structs:"-" json:"-" toml:"-" yaml:"-"` - AlbumCount int `structs:"-" json:"-" toml:"-" yaml:"-"` + SongCount int `structs:"-" json:"-"` + AlbumCount int `structs:"-" json:"-"` } type Genres []Genre type GenreRepository interface { - rest.Repository[Genre] - GetAll(ctx context.Context, options ...QueryOptions) (Genres, error) - Get(ctx context.Context, id string) (*Genre, error) + GetAll(...QueryOptions) (Genres, error) + Put(*Genre) error } diff --git a/model/get_entity.go b/model/get_entity.go index cfb3968ef..f51d8c36a 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -2,41 +2,25 @@ package model import ( "context" - "errors" ) // TODO: Should the type be encoded in the ID? -func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { - entity, _, err := getEntity(ctx, ds, id) - return entity, err -} - -// GetEntityKindByID resolves a bare entity id to its artwork Kind, searching the same tables as -// GetEntityByID. It reports ErrNotFound when no entity owns the id. -func GetEntityKindByID(ctx context.Context, ds DataStore, id string) (Kind, error) { - _, kind, err := getEntity(ctx, ds, id) - return kind, err -} - -func getEntity(ctx context.Context, ds DataStore, id string) (any, Kind, error) { - getters := []struct { - kind Kind - get func() (any, error) - }{ - {KindArtistArtwork, func() (any, error) { return ds.Artist().Get(ctx, id) }}, - {KindAlbumArtwork, func() (any, error) { return ds.Album().Get(ctx, id) }}, - {KindPlaylistArtwork, func() (any, error) { return ds.Playlist().Get(ctx, id) }}, - {KindMediaFileArtwork, func() (any, error) { return ds.MediaFile().Get(ctx, id) }}, - {KindRadioArtwork, func() (any, error) { return ds.Radio().Get(ctx, id) }}, +func GetEntityByID(ctx context.Context, ds DataStore, id string) (interface{}, error) { + ar, err := ds.Artist(ctx).Get(id) + if err == nil { + return ar, nil } - for _, g := range getters { - entity, err := g.get() - if err == nil { - return entity, g.kind, nil - } - if !errors.Is(err, ErrNotFound) { - return nil, Kind{}, err - } + al, err := ds.Album(ctx).Get(id) + if err == nil { + return al, nil } - return nil, Kind{}, ErrNotFound + pls, err := ds.Playlist(ctx).Get(id) + if err == nil { + return pls, nil + } + mf, err := ds.MediaFile(ctx).Get(id) + if err == nil { + return mf, nil + } + return nil, err } diff --git a/model/get_entity_test.go b/model/get_entity_test.go deleted file mode 100644 index 73e21a036..000000000 --- a/model/get_entity_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package model_test - -import ( - "context" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("GetEntityByID", func() { - var ds *tests.MockDataStore - var ctx context.Context - - BeforeEach(func() { - ds = &tests.MockDataStore{} - ctx = GinkgoT().Context() - }) - - It("returns the entity matching the id", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1", Name: "One"}}) - entity, err := model.GetEntityByID(ctx, ds, "a1") - Expect(err).ToNot(HaveOccurred()) - Expect(entity).To(BeAssignableToTypeOf(&model.Album{})) - Expect(entity.(*model.Album).ID).To(Equal("a1")) - }) - - It("returns ErrNotFound when no entity matches", func() { - _, err := model.GetEntityByID(ctx, ds, "missing") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("propagates unexpected repository errors instead of reporting not-found", func() { - ds.Album().(*tests.MockAlbumRepo).SetError(true) - _, err := model.GetEntityByID(ctx, ds, "a1") - Expect(err).To(HaveOccurred()) - Expect(err).ToNot(MatchError(model.ErrNotFound)) - }) -}) - -var _ = Describe("GetEntityKindByID", func() { - var ds *tests.MockDataStore - var ctx context.Context - - BeforeEach(func() { - ds = &tests.MockDataStore{} - ctx = GinkgoT().Context() - }) - - It("returns the artwork kind for the matching id", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "a1"}}) - kind, err := model.GetEntityKindByID(ctx, ds, "a1") - Expect(err).ToNot(HaveOccurred()) - Expect(kind).To(Equal(model.KindAlbumArtwork)) - }) - - It("returns ErrNotFound when no entity matches", func() { - _, err := model.GetEntityKindByID(ctx, ds, "missing") - Expect(err).To(MatchError(model.ErrNotFound)) - }) -}) diff --git a/model/id/id.go b/model/id/id.go deleted file mode 100644 index 2885bc6ad..000000000 --- a/model/id/id.go +++ /dev/null @@ -1,48 +0,0 @@ -package id - -import ( - "crypto/md5" - "crypto/rand" - "fmt" - "math/big" - "strings" -) - -func NewRandom() string { - var b [16]byte - _, _ = rand.Read(b[:]) - return Encode(b) -} - -// Encode renders a 16-byte value as the canonical 22-char zero-padded base62 id. -func Encode(b [16]byte) string { - return fmt.Sprintf("%022s", new(big.Int).SetBytes(b[:]).Text(62)) -} - -// Decode is the exact inverse of Encode. -func Decode(s string) ([]byte, error) { - if len(s) != 22 { - return nil, fmt.Errorf("invalid id length %d", len(s)) - } - v, ok := new(big.Int).SetString(s, 62) - if !ok || v.Sign() < 0 { - return nil, fmt.Errorf("invalid base62 id %q", s) - } - if v.BitLen() > 128 { - return nil, fmt.Errorf("id %q overflows 128 bits", s) - } - return v.FillBytes(make([]byte, 16)), nil -} - -func NewHash(data ...string) string { - hash := md5.New() - for _, d := range data { - hash.Write([]byte(d)) - hash.Write([]byte(string('\u200b'))) - } - return Encode([16]byte(hash.Sum(nil))) -} - -func NewTagID(name, value string) string { - return NewHash(strings.ToLower(name), strings.ToLower(value)) -} diff --git a/model/id/id_suite_test.go b/model/id/id_suite_test.go deleted file mode 100644 index e8a41f4e5..000000000 --- a/model/id/id_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package id_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestID(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "ID Suite") -} diff --git a/model/id/id_test.go b/model/id/id_test.go deleted file mode 100644 index 407560a75..000000000 --- a/model/id/id_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package id_test - -import ( - "github.com/navidrome/navidrome/model/id" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Encode/Decode", func() { - It("encodes 16 bytes as 22-char zero-padded base62", func() { - Expect(id.Encode([16]byte{})).To(Equal("0000000000000000000000")) - allFF := [16]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff} - Expect(id.Encode(allFF)).To(Equal("7N42dgm5tFLK9N8MT7fHC7")) - }) - - It("round-trips arbitrary 16-byte values", func() { - b := [16]byte{0xe3, 0xb7, 0xfc, 0x2a, 0xe9, 0x44, 0x7b, 0xbe, - 0xc3, 0x7a, 0x13, 0xbf, 0x91, 0x6e, 0x3c, 0xf6} - s := id.Encode(b) - Expect(s).To(Equal("6VHl3uR4kss6sUPKA8Cwnk")) - Expect(id.Decode(s)).To(Equal(b[:])) - }) - - It("rejects invalid input", func() { - _, err := id.Decode("short") - Expect(err).To(HaveOccurred()) - _, err = id.Decode("!!!!!!!!!!!!!!!!!!!!!!") // 22 chars, not base62 - Expect(err).To(HaveOccurred()) - _, err = id.Decode("-000000000000000000001") // sign is not part of the alphabet - Expect(err).To(HaveOccurred()) - _, err = id.Decode("zzzzzzzzzzzzzzzzzzzzzz") // > 2^128 - Expect(err).To(HaveOccurred()) - }) -}) - -var _ = Describe("NewRandom", func() { - It("emits 22-char canonical ids that always fit 128 bits", func() { - seen := make(map[string]struct{}) - for range 1000 { - s := id.NewRandom() - Expect(s).To(HaveLen(22)) - _, err := id.Decode(s) - Expect(err).ToNot(HaveOccurred(), "id %q must decode to 128 bits", s) - seen[s] = struct{}{} - } - Expect(seen).To(HaveLen(1000)) - }) -}) - -var _ = Describe("NewHash", func() { - It("keeps its historical output byte-for-byte (golden)", func() { - Expect(id.NewHash("test")).To(Equal("5cLJPkLA5DK2BADhoeotPk")) - Expect(id.NewHash("[unknown artist]")).To(Equal("7lsE5pS09fPS1VuFqwXbia")) - Expect(id.NewTagID("genre", "electronic")).To(Equal("7bLYq0Np81m1Wgy5N31nuG")) - }) - - It("always emits 22 decodable chars", func() { - h := id.NewHash("anything", "at", "all") - Expect(h).To(HaveLen(22)) - _, err := id.Decode(h) - Expect(err).ToNot(HaveOccurred()) - }) -}) diff --git a/model/image.go b/model/image.go deleted file mode 100644 index 30307fcea..000000000 --- a/model/image.go +++ /dev/null @@ -1,17 +0,0 @@ -package model - -import ( - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" -) - -// UploadedImagePath returns the absolute filesystem path for a manually uploaded -// entity cover image. Returns empty string if filename is empty. -func UploadedImagePath(entityType, filename string) string { - if filename == "" { - return "" - } - return filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, entityType, filename) -} diff --git a/model/library.go b/model/library.go deleted file mode 100644 index e80d22c89..000000000 --- a/model/library.go +++ /dev/null @@ -1,101 +0,0 @@ -package model - -import ( - "cmp" - "context" - "strings" - "time" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/utils/slice" -) - -type Library struct { - ID int `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Path string `json:"path" db:"path"` - RemotePath string `json:"remotePath" db:"remote_path"` - LastScanAt time.Time `json:"lastScanAt" db:"last_scan_at"` - LastScanStartedAt time.Time `json:"lastScanStartedAt" db:"last_scan_started_at"` - FullScanInProgress bool `json:"fullScanInProgress" db:"full_scan_in_progress"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - TotalSongs int `json:"totalSongs" db:"total_songs"` - TotalAlbums int `json:"totalAlbums" db:"total_albums"` - TotalArtists int `json:"totalArtists" db:"total_artists"` - TotalFolders int `json:"totalFolders" db:"total_folders"` - TotalFiles int `json:"totalFiles" db:"total_files"` - TotalMissingFiles int `json:"totalMissingFiles" db:"total_missing_files"` - TotalSize int64 `json:"totalSize" db:"total_size"` - TotalDuration float64 `json:"totalDuration" db:"total_duration"` - DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"` - PIDAlbum string `json:"pidAlbum" db:"pid_album"` - PIDTrack string `json:"pidTrack" db:"pid_track"` - ScannedPIDAlbum string `json:"-" db:"scanned_pid_album"` - ScannedPIDTrack string `json:"-" db:"scanned_pid_track"` -} - -// PIDConfig holds the persistent ID specs used to compute track and album IDs. -type PIDConfig struct { - Track string - Album string -} - -// EffectivePID returns the PID specs in effect for this library: its own overrides, falling back to -// the global config. -func (l Library) EffectivePID() PIDConfig { - return PIDConfig{ - Track: cmp.Or(l.PIDTrack, conf.Server.PID.Track), - Album: cmp.Or(l.PIDAlbum, conf.Server.PID.Album), - } -} - -// PIDChanged reports whether the effective PID specs differ from the ones used by the last finished -// scan of this library. A library that was never scanned counts as changed. -func (l Library) PIDChanged() bool { - pid := l.EffectivePID() - return !strings.EqualFold(l.ScannedPIDAlbum, pid.Album) || !strings.EqualFold(l.ScannedPIDTrack, pid.Track) -} - -// NeedsPIDRescan reports whether the library has content imported with an old PID config, so it must be -// rescanned in full. A library that never finished a scan has nothing to regroup. -func (l Library) NeedsPIDRescan() bool { - return !l.LastScanAt.IsZero() && l.PIDChanged() -} - -const ( - DefaultLibraryID = 1 - DefaultLibraryName = "Music Library" -) - -type Libraries []Library - -func (l Libraries) IDs() []int { - return slice.Map(l, func(lib Library) int { return lib.ID }) -} - -type LibraryRepository interface { - rest.Repository[Library] - Get(ctx context.Context, id int) (*Library, error) - // GetPath returns the path of the library with the given ID. - // Its implementation must be optimized to avoid unnecessary queries. - GetPath(ctx context.Context, id int) (string, error) - GetAll(ctx context.Context, options ...QueryOptions) (Libraries, error) - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Put(ctx context.Context, l *Library, colsToUpdate ...string) error - Delete(ctx context.Context, id int) error - StoreMusicFolder(ctx context.Context) error - AddArtist(ctx context.Context, id int, artistID string) error - - // User-library association methods - GetUsersWithLibraryAccess(ctx context.Context, libraryID int) (Users, error) - - // TODO These methods should be moved to a core service - ScanBegin(ctx context.Context, id int, fullScan bool) error - ScanEnd(ctx context.Context, id int) error - // SetScannedPID records the PID specs used by the last finished scan of the library - SetScannedPID(ctx context.Context, id int, pid PIDConfig) error - ScanInProgress(ctx context.Context) (bool, error) - RefreshStats(ctx context.Context, id int) error -} diff --git a/model/library_matcher.go b/model/library_matcher.go deleted file mode 100644 index 83af96f9f..000000000 --- a/model/library_matcher.go +++ /dev/null @@ -1,57 +0,0 @@ -package model - -import ( - "cmp" - "path/filepath" - "slices" - "strings" -) - -// LibraryMatcher finds the library that contains an absolute path. -type LibraryMatcher struct { - libraries Libraries - cleanedPaths []string -} - -// NewLibraryMatcher sorts the libraries longest path first, so /music-classical is checked before /music. -func NewLibraryMatcher(libs Libraries) *LibraryMatcher { - libs = slices.Clone(libs) - slices.SortFunc(libs, func(i, j Library) int { - return cmp.Compare(len(j.Path), len(i.Path)) - }) - cleanedPaths := make([]string, len(libs)) - for i, lib := range libs { - cleanedPaths[i] = filepath.Clean(lib.Path) - } - return &LibraryMatcher{libraries: libs, cleanedPaths: cleanedPaths} -} - -// FindLibrary returns the library whose path contains absolutePath. -func (lm *LibraryMatcher) FindLibrary(absolutePath string) (Library, bool) { - for i, libPath := range lm.cleanedPaths { - // A cleaned path only ends with a separator when it is a filesystem root - if strings.HasPrefix(absolutePath, libPath) && (len(absolutePath) == len(libPath) || - absolutePath[len(libPath)] == filepath.Separator || strings.HasSuffix(libPath, string(filepath.Separator))) { - return lm.libraries[i], true - } - } - return Library{}, false -} - -// LibraryRelativePath rebases an absolute path onto the library root, as the scanner's io/fs sees it -// (forward slashes). Relative paths, and absolute paths outside the library root, are returned unchanged. -func LibraryRelativePath(libPath, path string) string { - if !filepath.IsAbs(path) { - return path - } - // The library root may be relative (e.g. the default "./music"); it resolves against the same cwd - absLib, err := filepath.Abs(libPath) - if err != nil { - return path - } - rel, err := filepath.Rel(absLib, path) - if err != nil || !filepath.IsLocal(rel) { - return path - } - return filepath.ToSlash(rel) -} diff --git a/model/library_matcher_test.go b/model/library_matcher_test.go deleted file mode 100644 index 09e6f7e33..000000000 --- a/model/library_matcher_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package model_test - -import ( - "os" - "path/filepath" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("LibraryMatcher", func() { - // Paths are written Unix-style and converted, so they use the OS separator, as filepath.Abs output does - find := func(libs model.Libraries, path string) int { - for i := range libs { - libs[i].Path = filepath.FromSlash(libs[i].Path) - } - lib, ok := model.NewLibraryMatcher(libs).FindLibrary(filepath.FromSlash(path)) - if !ok { - return 0 - } - return lib.ID - } - - DescribeTable("matches the longest library path", - func(libs model.Libraries, path string, expectedID int) { - Expect(find(libs, path)).To(Equal(expectedID)) - }, - Entry("nested library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}, {ID: 3, Path: "/music-classical/opera"}}, "/music-classical/opera/subdir/track.mp3", 3), - Entry("sibling with a shared prefix", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical/track.mp3", 2), - Entry("shorter library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music/track.mp3", 1), - Entry("exact library root", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical", 2), - Entry("deeply nested libraries", model.Libraries{{ID: 1, Path: "/media"}, {ID: 2, Path: "/media/audio"}, {ID: 3, Path: "/media/audio/classical"}, {ID: 4, Path: "/media/audio/classical/baroque"}}, "/media/audio/classical/mozart/track.mp3", 3), - Entry("prefix that is not a path boundary", model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/ab"}, {ID: 3, Path: "/abc"}}, "/ab/file.mp3", 2), - Entry("special characters match literally", model.Libraries{{ID: 1, Path: "/music[test]"}, {ID: 2, Path: "/music(backup)"}}, "/music[test]/track.mp3", 1), - Entry("library path with a trailing slash", model.Libraries{{ID: 1, Path: "/music/"}}, "/music/track.mp3", 1), - Entry("library at the filesystem root", model.Libraries{{ID: 1, Path: "/"}}, "/music/track.mp3", 1), - Entry("nested library under a root library", model.Libraries{{ID: 1, Path: "/"}, {ID: 2, Path: "/music"}}, "/music/track.mp3", 2), - ) - - It("does not match a path outside every library", func() { - Expect(find(model.Libraries{{ID: 1, Path: "/music"}}, "/music-backup/track.mp3")).To(BeZero()) - }) - - It("does not match anything without libraries", func() { - Expect(find(nil, "/music/track.mp3")).To(BeZero()) - }) - - It("does not reorder the caller's libraries", func() { - libs := model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/abc"}} - model.NewLibraryMatcher(libs) - Expect(libs.IDs()).To(Equal([]int{1, 2})) - }) -}) - -var _ = Describe("LibraryRelativePath", func() { - // Paths are built with filepath so the "absolute" cases stay absolute on every OS - // (a Unix-style "/foo" is not absolute on Windows). - libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection")) - outside, _ := filepath.Abs(filepath.Join("somewhere", "else")) - - It("returns a relative path unchanged", func() { - Expect(model.LibraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection")) - }) - - It("rebases an absolute target when the library root is relative", func() { - cwd, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - Expect(model.LibraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock")) - }) - - It("rebases an absolute path that equals the library root to '.'", func() { - Expect(model.LibraryRelativePath(libRoot, libRoot)).To(Equal(".")) - }) - - It("rebases an absolute path under the library root", func() { - Expect(model.LibraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) - }) - - It("handles a trailing slash on the library path", func() { - Expect(model.LibraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection")) - }) - - It("leaves an absolute path outside the library root unchanged", func() { - Expect(model.LibraryRelativePath(libRoot, outside)).To(Equal(outside)) - }) - - It("returns an empty path unchanged", func() { - Expect(model.LibraryRelativePath(libRoot, "")).To(Equal("")) - }) -}) diff --git a/model/library_test.go b/model/library_test.go deleted file mode 100644 index 4e799e13f..000000000 --- a/model/library_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package model_test - -import ( - "encoding/json" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Library PID config", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.PID.Album = "global_album" - conf.Server.PID.Track = "global_track" - }) - - Describe("EffectivePID", func() { - It("falls back to the global config", func() { - Expect(model.Library{}.EffectivePID()).To(Equal(model.PIDConfig{Track: "global_track", Album: "global_album"})) - }) - It("uses the library overrides", func() { - lib := model.Library{PIDAlbum: "folder", PIDTrack: "title"} - Expect(lib.EffectivePID()).To(Equal(model.PIDConfig{Track: "title", Album: "folder"})) - }) - }) - - Describe("PIDChanged", func() { - It("is false when the scanned specs match, ignoring case", func() { - lib := model.Library{ScannedPIDAlbum: "GLOBAL_ALBUM", ScannedPIDTrack: "global_track"} - Expect(lib.PIDChanged()).To(BeFalse()) - }) - It("is true when the album override differs from the scanned spec", func() { - lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"} - Expect(lib.PIDChanged()).To(BeTrue()) - }) - It("is true when only the track spec changed", func() { - lib := model.Library{PIDTrack: "title", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"} - Expect(lib.PIDChanged()).To(BeTrue()) - }) - It("is true when the global config changed for a library without overrides", func() { - lib := model.Library{ScannedPIDAlbum: "old_album", ScannedPIDTrack: "global_track"} - Expect(lib.PIDChanged()).To(BeTrue()) - }) - It("is true for a library that was never scanned", func() { - Expect(model.Library{}.PIDChanged()).To(BeTrue()) - }) - }) - - Describe("NeedsPIDRescan", func() { - It("is false for a library that never finished a scan", func() { - Expect(model.Library{PIDAlbum: "folder"}.NeedsPIDRescan()).To(BeFalse()) - }) - It("is true for a scanned library whose PID config changed", func() { - lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()} - Expect(lib.NeedsPIDRescan()).To(BeTrue()) - }) - It("is false for a scanned library whose PID config did not change", func() { - lib := model.Library{ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()} - Expect(lib.NeedsPIDRescan()).To(BeFalse()) - }) - }) - - It("does not expose the scanned specs in JSON", func() { - data, err := json.Marshal(model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "secret_album", ScannedPIDTrack: "secret_track"}) - Expect(err).ToNot(HaveOccurred()) - Expect(string(data)).To(ContainSubstring(`"pidAlbum":"folder"`)) - Expect(string(data)).ToNot(ContainSubstring("secret_")) - }) -}) diff --git a/model/lyrics.go b/model/lyrics.go deleted file mode 100644 index 111b1c2a9..000000000 --- a/model/lyrics.go +++ /dev/null @@ -1,93 +0,0 @@ -package model - -import ( - "encoding/json" - "strings" -) - -type Cue struct { - Start *int64 `structs:"start,omitempty" json:"start,omitempty"` - End *int64 `structs:"end,omitempty" json:"end,omitempty"` - Value string `structs:"value" json:"value"` - ByteStart int `structs:"byteStart" json:"byteStart"` - ByteEnd int `structs:"byteEnd" json:"byteEnd"` - AgentID string `structs:"agentId,omitempty" json:"agentId,omitempty"` -} - -type Agent struct { - ID string `structs:"id" json:"id"` - Role string `structs:"role" json:"role"` - Name string `structs:"name,omitempty" json:"name,omitempty"` -} - -type Line struct { - Start *int64 `structs:"start,omitempty" json:"start,omitempty"` - End *int64 `structs:"end,omitempty" json:"end,omitempty"` - Value string `structs:"value" json:"value"` - Cue []Cue `structs:"cue,omitempty" json:"cue,omitempty"` -} - -type Lyrics struct { - DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"` - DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"` - Kind string `structs:"kind,omitempty" json:"kind,omitempty"` - Lang string `structs:"lang" json:"lang"` - Agents []Agent `structs:"agents,omitempty" json:"agents,omitempty"` - Line []Line `structs:"line" json:"line"` - Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"` - Synced bool `structs:"synced" json:"synced"` -} - -// Lyric kinds, as defined by the OpenSubsonic songLyrics v2 contract. These are -// the canonical wire values; keep them in sync with the spec. -const ( - LyricKindMain = "main" - LyricKindTranslation = "translation" - LyricKindPronunciation = "pronunciation" -) - -func (l Lyrics) IsEmpty() bool { - return len(l.Line) == 0 -} - -// IsMainKind reports whether the lyric is the main track. A blank kind is an -// untyped (single-track) lyric, which the contract treats as main. -func (l Lyrics) IsMainKind() bool { - return l.EffectiveKind() == LyricKindMain -} - -// EffectiveKind returns the lyric kind, defaulting to LyricKindMain when blank. -// A blank kind means an untyped (single-track) lyric, which the contract treats -// as main. -func (l Lyrics) EffectiveKind() string { - if strings.TrimSpace(l.Kind) == "" { - return LyricKindMain - } - return l.Kind -} - -type LyricList []Lyrics - -// MarshalJSON keeps the lyrics column invariant: empty/nil serializes to [], never null. -func (ll LyricList) MarshalJSON() ([]byte, error) { - if len(ll) == 0 { - return []byte("[]"), nil - } - return json.Marshal([]Lyrics(ll)) -} - -// Main returns the main-kind lyric, falling back to the first entry so untyped -// lyrics still resolve. The bool is false only when the list is empty. It is -// used to surface a single lyric through the plain-text legacy getLyrics -// endpoint, which has no notion of translation/pronunciation tracks. -func (ll LyricList) Main() (Lyrics, bool) { - if len(ll) == 0 { - return Lyrics{}, false - } - for _, l := range ll { - if l.IsMainKind() { - return l, true - } - } - return ll[0], true -} diff --git a/model/lyrics_benchmark_test.go b/model/lyrics_benchmark_test.go deleted file mode 100644 index 5a7d7871e..000000000 --- a/model/lyrics_benchmark_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package model - -import ( - "os" - "path/filepath" - "testing" -) - -// Benchmark payloads are real public-domain lyrics ("Auld Lang Syne", Robert -// Burns, 1788) rendered into every supported format, so the numbers reflect -// realistic content and sizing. The same song across formats makes per-format -// cost directly comparable. Fixtures live in tests/fixtures/lyrics/. -func loadLyricFixture(b *testing.B, name string) []byte { - b.Helper() - contents, err := os.ReadFile(filepath.Join("..", "tests", "fixtures", "lyrics", name)) - if err != nil { - b.Fatal(err) - } - return contents -} - -func benchmarkParse(b *testing.B, suffix, fixture string) { - contents := loadLyricFixture(b, fixture) - b.ReportAllocs() - b.SetBytes(int64(len(contents))) - for b.Loop() { - if _, err := ParseLyrics(b.Context(), suffix, "eng", contents); err != nil { - b.Fatal(err) - } - } -} - -func BenchmarkParseLyrics_LRC(b *testing.B) { benchmarkParse(b, ".lrc", "auld-lang-syne.lrc") } -func BenchmarkParseLyrics_Plain(b *testing.B) { benchmarkParse(b, ".txt", "auld-lang-syne.txt") } -func BenchmarkParseLyrics_EnhancedLRC(b *testing.B) { benchmarkParse(b, ".lrc", "auld-lang-syne.elrc") } -func BenchmarkParseLyrics_SRT(b *testing.B) { benchmarkParse(b, ".srt", "auld-lang-syne.srt") } -func BenchmarkParseLyrics_TTML(b *testing.B) { benchmarkParse(b, ".ttml", "auld-lang-syne.ttml") } -func BenchmarkParseLyrics_YAML(b *testing.B) { benchmarkParse(b, ".yaml", "auld-lang-syne.yaml") } - -// Content-sniff path (empty suffix) — what embedded tags and plugins hit. -func BenchmarkParseLyrics_SniffTTML(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.ttml") } -func BenchmarkParseLyrics_SniffSRT(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.srt") } -func BenchmarkParseLyrics_SniffYAML(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.yaml") } -func BenchmarkParseLyrics_SniffLRC(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.lrc") } -func BenchmarkParseLyrics_SniffPlain(b *testing.B) { benchmarkParse(b, "", "auld-lang-syne.txt") } diff --git a/model/lyrics_lrc.go b/model/lyrics_lrc.go deleted file mode 100644 index e5bd8d4f5..000000000 --- a/model/lyrics_lrc.go +++ /dev/null @@ -1,423 +0,0 @@ -package model - -import ( - "cmp" - "regexp" - "slices" - "strconv" - "strings" - "unicode" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/str" -) - -// support the standard [mm:ss.mm], as well as [hh:*] and [*.mmm] -const timeRegexString = `\[([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?\]` - -var ( - // Should either be at the beginning of file, or beginning of line - syncRegex = regexp.MustCompile(`(^|\n)\s*` + timeRegexString) - timeRegex = regexp.MustCompile(timeRegexString) - lrcIdRegex = regexp.MustCompile(`\[(ar|ti|offset|lang):([^]]+)]`) - - // Non-standard background-vocal tag: [bg: word...] attaches to the preceding line - bgTagRegex = regexp.MustCompile(`^\[bg:(.*)]$`) - - // Any other [name:value] line is a tag we don't support (e.g. [al:], [by:]) and must be skipped whole - unknownTagRegex = regexp.MustCompile(`^\[[A-Za-z][^]:]*:[^]]*]`) - - // Enhanced LRC: inline word-level timing markers like <00:12.34> - enhancedLRCTimeString = `<([0-9]{1,2}:)?([0-9]{1,2}):([0-9]{1,2})(\.[0-9]{1,3})?>` - enhancedLRCRegex = regexp.MustCompile(enhancedLRCTimeString) -) - -func parseLRC(language, text string) (*Lyrics, error) { - text = str.SanitizeText(text) - - lines := strings.Split(text, "\n") - structuredLines := make([]Line, 0, len(lines)*2) - - artist := "" - title := "" - var offset *int64 = nil - - synced := syncRegex.MatchString(text) - priorLine := "" - validLine := false - repeated := false - hasBg := false - var timestamps []int64 - var pendingBg []string - - flushLine := func() { - value, cues := parseEnhancedLine(priorLine) - var merged bool - value, cues, merged = mergeBgLayers(value, cues, pendingBg) - hasBg = hasBg || merged - for idx := range timestamps { - startCopy := timestamps[idx] - structuredLines = append(structuredLines, Line{ - Start: &startCopy, - Value: value, - Cue: shiftELRCCues(cues, timestamps[idx]-timestamps[0]), - }) - } - timestamps = nil - pendingBg = nil - } - - for _, line := range lines { - line := strings.TrimSpace(line) - if line == "" { - if validLine { - priorLine += "\n" - } - continue - } - var text string - var time *int64 = nil - - if synced { - idTag := lrcIdRegex.FindStringSubmatch(line) - if idTag != nil { - switch idTag[1] { - case "ar": - artist = str.SanitizeText(strings.TrimSpace(idTag[2])) - case "lang": - language = str.SanitizeText(strings.TrimSpace(idTag[2])) - case "offset": - { - off, err := strconv.ParseInt(strings.TrimSpace(idTag[2]), 10, 64) - if err != nil { - log.Warn("Error parsing offset", "offset", idTag[2], "error", err) - } else { - offset = &off - } - } - case "ti": - title = str.SanitizeText(strings.TrimSpace(idTag[2])) - } - - continue - } - - if bgMatch := bgTagRegex.FindStringSubmatch(line); bgMatch != nil { - if validLine { - pendingBg = append(pendingBg, bgMatch[1]) - } - continue - } - - if unknownTagRegex.MatchString(line) { - continue - } - - times := timeRegex.FindAllStringSubmatchIndex(line, -1) - if len(times) > 1 { - repeated = true - } - - // The second condition is for when there is a timestamp in the middle of - // a line (after any text) - if len(times) == 0 || times[0][0] != 0 { - if validLine { - priorLine += "\n" + line - } - continue - } - - if validLine { - flushLine() - } - - end := 0 - - // [fullStart, fullEnd, hourStart, hourEnd, minStart, minEnd, secStart, secEnd, msStart, msEnd] - for _, match := range times { - // for multiple matches, we need to check that later matches are not - // in the middle of the string - if end != 0 { - middle := strings.TrimSpace(line[end:match[0]]) - if middle != "" { - break - } - } - - end = match[1] - timeInMillis, err := parseTime(line, match) - if err != nil { - return nil, err - } - - timestamps = append(timestamps, timeInMillis) - } - - if end >= len(line) { - priorLine = "" - } else { - priorLine = strings.TrimSpace(line[end:]) - } - - validLine = true - } else { - text = line - structuredLines = append(structuredLines, Line{ - Start: time, - Value: text, - }) - } - } - - if validLine { - flushLine() - } - - if hasBg { - fillMainAgentID(structuredLines) - } - - // If there are repeated values, there is no guarantee that they are in order - // In this, case, sort the lyrics by start time - if repeated { - slices.SortFunc(structuredLines, func(a, b Line) int { - return cmp.Compare(*a.Start, *b.Start) - }) - } - - lyrics := Lyrics{ - DisplayArtist: artist, - DisplayTitle: title, - Lang: language, - Line: normalizeCueLines(structuredLines), - Offset: offset, - Synced: synced, - } - if hasBg { - lyrics.Agents = []Agent{ - {ID: "main", Role: "main"}, - {ID: backgroundAgentID("main"), Role: "bg"}, - } - } - return &lyrics, nil -} - -// mergeBgLayers appends parsed [bg:] content to a line's value and cues as a -// background agent layer, mirroring the TTML background-vocal representation. -func mergeBgLayers(value string, cues []Cue, bgLines []string) (string, []Cue, bool) { - merged := false - for _, bg := range bgLines { - bgValue, bgCues := parseEnhancedLine(bg) - if len(bgCues) == 0 { - continue - } - offset := 0 - if value != "" { - offset = len(value) + 1 - value += " " - } - value += bgValue - for _, c := range bgCues { - c.ByteStart += offset - c.ByteEnd += offset - c.AgentID = backgroundAgentID("main") - cues = append(cues, c) - } - merged = true - } - return value, cues, merged -} - -func fillMainAgentID(lines []Line) { - for i := range lines { - for j := range lines[i].Cue { - if lines[i].Cue[j].AgentID == "" { - lines[i].Cue[j].AgentID = "main" - } - } - } -} - -// parseEnhancedLine extracts word-level timing cues from Enhanced LRC inline markers -// and computes UTF-8 byte offsets against the final stripped line value. -func parseEnhancedLine(text string) (string, []Cue) { - matches := enhancedLRCRegex.FindAllStringSubmatchIndex(text, -1) - if len(matches) == 0 { - return strings.TrimSpace(text), nil - } - - type segment struct { - start int64 - rawStart int - rawEnd int - } - - segments := make([]segment, 0, len(matches)) - var rawValue strings.Builder - var trailingEnd *int64 - for i, match := range matches { - timeMs, err := parseTime( - // Rewrite <...> as [...] so parseTime can handle it with the same logic - "["+text[match[0]+1:match[1]-1]+"]", - // Adjust match indices to point into our rewritten string (need start/end pairs for each group) - []int{ - 0, match[1] - match[0], - adjustGroup(match, 2), adjustGroup(match, 3), - adjustGroup(match, 4), adjustGroup(match, 5), - adjustGroup(match, 6), adjustGroup(match, 7), - adjustGroup(match, 8), adjustGroup(match, 9), - }, - ) - if err != nil { - continue - } - - // Text runs from after this marker to the start of the next marker (or end of string) - textStart := match[1] - var textEnd int - if i+1 < len(matches) { - textEnd = matches[i+1][0] - } else { - textEnd = len(text) - } - - word := text[textStart:textEnd] - if word == "" { - if i == len(matches)-1 { - trailingEnd = &timeMs - } - continue - } - - rawStart := rawValue.Len() - rawValue.WriteString(word) - segments = append(segments, segment{ - start: timeMs, - rawStart: rawStart, - rawEnd: rawValue.Len(), - }) - } - - if len(segments) == 0 { - return strings.TrimSpace(stripEnhancedMarkers(text)), nil - } - - finalRaw := rawValue.String() - leftTrimBytes := len(finalRaw) - len(strings.TrimLeftFunc(finalRaw, unicode.IsSpace)) - rightTrimBytes := len(finalRaw) - len(strings.TrimRightFunc(finalRaw, unicode.IsSpace)) - trimmedEnd := len(finalRaw) - rightTrimBytes - if trimmedEnd < leftTrimBytes { - trimmedEnd = leftTrimBytes - } - - cues := make([]Cue, 0, len(segments)) - for _, seg := range segments { - start := seg.start - byteStart := max(seg.rawStart, leftTrimBytes) - byteEnd := min(seg.rawEnd, trimmedEnd) - if byteStart >= byteEnd { - continue - } - - cues = append(cues, Cue{ - Start: &start, - Value: finalRaw[byteStart:byteEnd], - ByteStart: byteStart - leftTrimBytes, - ByteEnd: byteEnd - leftTrimBytes - 1, - }) - } - - if trailingEnd != nil && len(cues) > 0 { - cues[len(cues)-1].End = trailingEnd - } - - return strings.TrimSpace(finalRaw), cues -} - -// adjustGroup remaps a capture group index from the original match to our rewritten "[...]" string. -// The rewrite shifts by -1 (removed '<', added '[') so positions within the brackets stay the same. -func adjustGroup(match []int, groupIdx int) int { - orig := match[groupIdx] - if orig == -1 { - return -1 - } - // Offset is: original position minus the position of '<' in the original, plus 1 for '[' - return orig - match[0] -} - -// stripEnhancedMarkers removes all inline markers from text, -// returning the plain lyric text. -func stripEnhancedMarkers(text string) string { - return enhancedLRCRegex.ReplaceAllString(text, "") -} - -// shiftELRCCues returns a deep copy of baseCues with each cue's Start/End -// timestamps shifted by offsetMs. Inline ELRC word markers parse to absolute -// timestamps anchored at the line's first occurrence, so repeated-line LRC -// inputs of the form `[t0][t1]...` must shift the cues by (t1-t0) for the -// second occurrence to point at the correct moment. Returned *int64 pointers -// are freshly allocated so the input slice is never aliased into the result. -func shiftELRCCues(baseCues []Cue, offsetMs int64) []Cue { - if len(baseCues) == 0 { - return nil - } - out := make([]Cue, len(baseCues)) - for i, c := range baseCues { - out[i] = c - if c.Start != nil { - s := *c.Start + offsetMs - out[i].Start = &s - } - if c.End != nil { - e := *c.End + offsetMs - out[i].End = &e - } - } - return out -} - -func parseTime(line string, match []int) (int64, error) { - var hours, millis int64 - var err error - - hourStart := match[2] - if hourStart != -1 { - // subtract 1 because group has : at the end - hourEnd := match[3] - 1 - hours, err = strconv.ParseInt(line[hourStart:hourEnd], 10, 64) - if err != nil { - return 0, err - } - } - - minutes, err := strconv.ParseInt(line[match[4]:match[5]], 10, 64) - if err != nil { - return 0, err - } - - sec, err := strconv.ParseInt(line[match[6]:match[7]], 10, 64) - if err != nil { - return 0, err - } - - msStart := match[8] - if msStart != -1 { - msEnd := match[9] - // +1 offset since this capture group contains . - millis, err = strconv.ParseInt(line[msStart+1:msEnd], 10, 64) - if err != nil { - return 0, err - } - - length := msEnd - msStart - - if length == 3 { - millis *= 10 - } else if length == 2 { - millis *= 100 - } - } - - timeInMillis := (((((hours * 60) + minutes) * 60) + sec) * 1000) + millis - return timeInMillis, nil -} diff --git a/model/lyrics_lrc_test.go b/model/lyrics_lrc_test.go deleted file mode 100644 index e8cd05352..000000000 --- a/model/lyrics_lrc_test.go +++ /dev/null @@ -1,298 +0,0 @@ -package model - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("parseLRC", func() { - It("should parse tags with spaces", func() { - lyrics, err := parseLRC("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Lang).To(Equal("eng")) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.DisplayArtist).To(Equal("An artist")) - Expect(lyrics.DisplayTitle).To(Equal("A title")) - Expect(lyrics.Offset).To(Equal(new(int64(1551)))) - }) - - It("Should ignore bad offset", func() { - lyrics, err := parseLRC("xxx", "[offset: NotANumber ]\n[00:00.00]Hi there") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Offset).To(BeNil()) - }) - - It("should accept lines with no text and weird times", func() { - lyrics, err := parseLRC("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Hi there"}, - {Start: new(int64(10040)), Value: ""}, - {Start: new(int64(40000)), Value: "Test"}, - {Start: new(int64(1000 * 60 * 60)), Value: "late"}, - })) - }) - - It("Should support multiple timestamps per line", func() { - lyrics, err := parseLRC("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Repeated"}, - {Start: new(int64(10000)), Value: "Repeated"}, - {Start: new(int64(13 * 60 * 1000)), Value: ""}, - {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""}, - })) - }) - - It("Should support parsing multiline string", func() { - lyrics, err := parseLRC("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"}, - {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"}, - })) - }) - - It("Does not match timestamp in middle of line", func() { - lyrics, err := parseLRC("xxx", "This could [00:00:00] be a synced file") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeFalse()) - Expect(lyrics.Line).To(Equal([]Line{ - {Value: "This could [00:00:00] be a synced file"}, - })) - }) - - It("Allows timestamp in middle of line if also at beginning", func() { - lyrics, err := parseLRC("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"}, - {Start: new(int64(1000)), Value: "Line 2"}, - })) - }) - - It("Ignores lines in synchronized lyric prior to first timestamp", func() { - lyrics, err := parseLRC("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Text"}, - })) - }) - - It("Handles all possible ms cases", func() { - lyrics, err := parseLRC("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(1)), Value: "a"}, - {Start: new(int64(10)), Value: "b"}, - {Start: new(int64(100)), Value: "c"}, - })) - }) - - It("Properly sorts repeated lyrics out of order", func() { - lyrics, err := parseLRC("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(0)), Value: "Repeated"}, - {Start: new(int64(10000)), Value: "Test"}, - {Start: new(int64(40000)), Value: "Not repeated"}, - {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"}, - {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, - })) - }) - - It("should parse Enhanced LRC with word-level timing", func() { - lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here\n[00:03.00]<00:03.00>More <00:03.50>words") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(HaveLen(2)) - - t1000, t1500, t2000, t3000, t3500 := int64(1000), int64(1500), int64(2000), int64(3000), int64(3500) - - line0 := lyrics.Line[0] - Expect(line0.Start).To(Equal(&t1000)) - Expect(line0.End).To(Equal(&t3000)) - Expect(line0.Value).To(Equal("Some lyrics here")) - Expect(line0.Cue).To(Equal([]Cue{ - {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, - {Start: &t1500, End: &t2000, Value: "lyrics ", ByteStart: 5, ByteEnd: 11}, - {Start: &t2000, End: &t3000, Value: "here", ByteStart: 12, ByteEnd: 15}, - })) - - line1 := lyrics.Line[1] - Expect(line1.Start).To(Equal(&t3000)) - Expect(line1.End).To(Equal(&t3500)) - Expect(line1.Value).To(Equal("More words")) - Expect(line1.Cue).To(Equal([]Cue{ - {Start: &t3000, Value: "More ", ByteStart: 0, ByteEnd: 4}, - {Start: &t3500, Value: "words", ByteStart: 5, ByteEnd: 9}, - })) - - Expect(line1.Cue[1].End).To(BeNil()) - }) - - It("should not parse malformed Enhanced LRC timing markers", func() { - lyrics, err := parseLRC("xxx", "[00:01.00]<00:01a50>Not a marker") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(Equal([]Line{ - {Start: new(int64(1000)), Value: "<00:01a50>Not a marker"}, - })) - }) - - It("should skip unknown tag lines instead of merging them into the prior line", func() { - lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[by: someone]\n[al: An album]\n[00:03.00]Next") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Synced).To(BeTrue()) - Expect(lyrics.Line).To(HaveLen(2)) - - t1000, t1500, t3000 := int64(1000), int64(1500), int64(3000) - Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) - Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ - {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, - {Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, - })) - Expect(lyrics.Line[1].Value).To(Equal("Next")) - }) - - It("should attach [bg:] background vocals to the previous line as a bg agent layer", func() { - lyrics, err := parseLRC("xxx", "[00:52.00]<00:52.00>Main <00:52.50>line\n[bg: <00:53.00>Okay<00:53.50>]\n[00:55.00]Next") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Agents).To(Equal([]Agent{ - {ID: "main", Role: "main"}, - {ID: "__nd_bg__|main", Role: "bg"}, - })) - Expect(lyrics.Line).To(HaveLen(2)) - - line := lyrics.Line[0] - Expect(line.Value).To(Equal("Main line Okay")) - Expect(line.Cue).To(Equal([]Cue{ - {Start: new(int64(52000)), End: new(int64(52500)), Value: "Main ", ByteStart: 0, ByteEnd: 4, AgentID: "main"}, - {Start: new(int64(52500)), End: new(int64(55000)), Value: "line", ByteStart: 5, ByteEnd: 8, AgentID: "main"}, - {Start: new(int64(53000)), End: new(int64(53500)), Value: "Okay", ByteStart: 10, ByteEnd: 13, AgentID: "__nd_bg__|main"}, - })) - Expect(lyrics.Line[1].Value).To(Equal("Next")) - }) - - It("should skip a [bg:] line that has no inline timing", func() { - lyrics, err := parseLRC("xxx", "[00:01.00]Hi\n[bg: untimed]\n[00:03.00]Next") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Agents).To(BeNil()) - Expect(lyrics.Line).To(HaveLen(2)) - Expect(lyrics.Line[0].Value).To(Equal("Hi")) - Expect(lyrics.Line[1].Value).To(Equal("Next")) - }) - - It("should handle mixed Enhanced and plain LRC lines", func() { - lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics\n[00:03.00]Plain line\n[00:05.00]<00:05.00>More <00:05.50>words") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Line).To(HaveLen(3)) - - t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500) - t3000 := int64(3000) - - Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ - {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, - {Start: &t1500, End: &t3000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, - })) - Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) - Expect(lyrics.Line[0].End).To(Equal(&t3000)) - - Expect(lyrics.Line[1].Cue).To(BeNil()) - Expect(lyrics.Line[1].Value).To(Equal("Plain line")) - - Expect(lyrics.Line[2].Cue).To(Equal([]Cue{ - {Start: &t5000, Value: "More ", ByteStart: 0, ByteEnd: 4}, - {Start: &t5500, Value: "words", ByteStart: 5, ByteEnd: 9}, - })) - Expect(lyrics.Line[2].Value).To(Equal("More words")) - }) - - It("should preserve byte offsets for Enhanced LRC cues", func() { - lyrics, err := parseLRC("xxx", "[00:00.00]<00:00.00>Oh <00:00.90>love<00:01.30> me <00:01.60>tonight") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Line).To(HaveLen(1)) - - t0, t900, t1300, t1600 := int64(0), int64(900), int64(1300), int64(1600) - line := lyrics.Line[0] - Expect(line.Value).To(Equal("Oh love me tonight")) - Expect(line.Cue).To(Equal([]Cue{ - {Start: &t0, Value: "Oh ", ByteStart: 0, ByteEnd: 2}, - {Start: &t900, Value: "love", ByteStart: 3, ByteEnd: 6}, - {Start: &t1300, Value: " me ", ByteStart: 7, ByteEnd: 10}, - {Start: &t1600, Value: "tonight", ByteStart: 11, ByteEnd: 17}, - })) - }) - - It("should use a trailing Enhanced LRC marker as the end of the last word", func() { - lyrics, err := parseLRC("xxx", "[00:01.00]<00:01.00>Some <00:01.50>lyrics<00:02.00>\n[00:30.00]Instrumental over") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Line).To(HaveLen(2)) - - t1000, t1500, t2000 := int64(1000), int64(1500), int64(2000) - line := lyrics.Line[0] - Expect(line.Value).To(Equal("Some lyrics")) - Expect(line.End).To(Equal(&t2000)) - Expect(line.Cue).To(Equal([]Cue{ - {Start: &t1000, End: &t1500, Value: "Some ", ByteStart: 0, ByteEnd: 4}, - {Start: &t1500, End: &t2000, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, - })) - }) - - It("should shift a trailing Enhanced LRC marker for repeated line occurrences", func() { - lyrics, err := parseLRC("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world<00:10.90>") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Line).To(HaveLen(2)) - - t10100, t10500, t10900 := int64(10100), int64(10500), int64(10900) - t30100, t30500, t30900 := int64(30100), int64(30500), int64(30900) - - Expect(lyrics.Line[0].End).To(Equal(&t10900)) - Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ - {Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, - {Start: &t10500, End: &t10900, Value: "world", ByteStart: 6, ByteEnd: 10}, - })) - - Expect(lyrics.Line[1].End).To(Equal(&t30900)) - Expect(lyrics.Line[1].Cue).To(Equal([]Cue{ - {Start: &t30100, End: &t30500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, - {Start: &t30500, End: &t30900, Value: "world", ByteStart: 6, ByteEnd: 10}, - })) - }) - - It("should shift inline ELRC word timestamps for each repeated line occurrence", func() { - lyrics, err := parseLRC("xxx", "[00:10.00][00:30.00]<00:10.10>Hello <00:10.50>world") - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics.Line).To(HaveLen(2)) - - t10000 := int64(10000) - t10100 := int64(10100) - t10500 := int64(10500) - t30000 := int64(30000) - t30100 := int64(30100) - t30500 := int64(30500) - - Expect(lyrics.Line[0].Start).To(Equal(&t10000)) - Expect(lyrics.Line[0].End).To(Equal(&t30000)) - Expect(lyrics.Line[0].Value).To(Equal("Hello world")) - Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ - {Start: &t10100, End: &t10500, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, - {Start: &t10500, End: &t30000, Value: "world", ByteStart: 6, ByteEnd: 10}, - })) - - Expect(lyrics.Line[1].Start).To(Equal(&t30000)) - Expect(lyrics.Line[1].End).To(Equal(&t30500)) - Expect(lyrics.Line[1].Value).To(Equal("Hello world")) - Expect(lyrics.Line[1].Cue).To(Equal([]Cue{ - {Start: &t30100, Value: "Hello ", ByteStart: 0, ByteEnd: 5}, - {Start: &t30500, Value: "world", ByteStart: 6, ByteEnd: 10}, - })) - }) -}) diff --git a/model/lyrics_lyricsfile.go b/model/lyrics_lyricsfile.go deleted file mode 100644 index 49d416f3f..000000000 --- a/model/lyrics_lyricsfile.go +++ /dev/null @@ -1,283 +0,0 @@ -package model - -import ( - "bytes" - "fmt" - "strings" - - "github.com/navidrome/navidrome/utils/str" - "gopkg.in/yaml.v3" -) - -// parseLyricsfile parses a LRCLIB Lyricsfile YAML document -// (see https://github.com/tranxuanthang/lrcget/blob/main/LYRICSFILE_CONCEPT.md) -// into a model.LyricList containing a single main Lyrics entry. Returns -// (nil, nil) when the input parses as YAML but does not declare Lyricsfile -// version 1.0. -// -// When the source contains per-word timing via lines[].words[], each word -// becomes a model.Cue with inclusive UTF-8 byte offsets into Line.Value, and -// overlapping lines are attributed to synthetic voice agents via lowest-free -// voice ID assignment so the OpenSubsonic v2 enhanced response can split -// parallel vocals. -func parseLyricsfile(lang string, contents []byte) (LyricList, error) { - var doc lyricsfileDocument - dec := yaml.NewDecoder(bytes.NewReader(contents)) - dec.KnownFields(false) - if err := dec.Decode(&doc); err != nil { - return nil, fmt.Errorf("not a valid Lyricsfile YAML: %w", err) - } - - if strings.TrimSpace(doc.Version) != lyricsfileVersion { - return nil, nil - } - - // Fall back to the caller's language when the document omits its own, matching - // the SRT/TTML parsers; normalizeLyricLang yields "xxx" only if both are empty. - docLang := doc.Metadata.Language - if strings.TrimSpace(docLang) == "" { - docLang = lang - } - lyrics := Lyrics{ - DisplayArtist: str.SanitizeText(doc.Metadata.Artist), - DisplayTitle: str.SanitizeText(doc.Metadata.Title), - Lang: normalizeLyricLang(docLang), - Kind: LyricKindMain, - } - if doc.Metadata.OffsetMs != 0 { - off := doc.Metadata.OffsetMs - lyrics.Offset = &off - } - - if doc.Metadata.Instrumental { - return LyricList{normalizeLyrics(lyrics)}, nil - } - - if len(doc.Lines) == 0 { - lines := buildPlainLyricsfileLines(doc.Plain) - if len(lines) == 0 { - return nil, nil - } - lyrics.Line = lines - return LyricList{normalizeLyrics(lyrics)}, nil - } - - lines, agents := buildLyricsfileLines(doc.Lines) - lyrics.Line = lines - lyrics.Agents = agents - lyrics.Synced = true - return LyricList{normalizeLyrics(lyrics)}, nil -} - -const lyricsfileVersion = "1.0" - -type lyricsfileDocument struct { - Version string `yaml:"version"` - Metadata lyricsfileMetadata `yaml:"metadata"` - Lines []lyricsfileLineEntry `yaml:"lines"` - Plain string `yaml:"plain"` -} - -type lyricsfileMetadata struct { - Title string `yaml:"title"` - Artist string `yaml:"artist"` - Album string `yaml:"album"` - DurationMs int64 `yaml:"duration_ms"` - OffsetMs int64 `yaml:"offset_ms"` - Language string `yaml:"language"` - Instrumental bool `yaml:"instrumental"` -} - -type lyricsfileLineEntry struct { - Text string `yaml:"text"` - StartMs int64 `yaml:"start_ms"` - EndMs *int64 `yaml:"end_ms"` - Words []lyricsfileWordEntry `yaml:"words"` -} - -type lyricsfileWordEntry struct { - Text string `yaml:"text"` - StartMs int64 `yaml:"start_ms"` - EndMs *int64 `yaml:"end_ms"` -} - -// buildLyricsfileLines converts YAML line entries to model.Line entries with -// per-cue AgentIDs assigned by streaming overlap clustering (lowest-free -// voice ID). The Agents slice is emitted only when at least one cue carries -// attribution AND more than one voice is used; otherwise AgentIDs are -// stripped so the wire shape stays simple per the OpenSubsonic spec rule -// "agents should not be emitted without cueLine data". -func buildLyricsfileLines(entries []lyricsfileLineEntry) ([]Line, []Agent) { - if len(entries) == 0 { - return nil, nil - } - - // Resolved end timestamps per entry: explicit end_ms, final word end_ms, - // then the next entry's start. The last entry's end stays nil when no - // explicit or word-level end is available. - ends := make([]*int64, len(entries)) - for i := range entries { - var nextStart *int64 - if i+1 < len(entries) { - v := entries[i+1].StartMs - nextStart = &v - } - ends[i] = lyricsfileLineEnd(entries[i], nextStart) - } - - active := map[int]int64{} - maxVoice := -1 - anyCues := false - lines := make([]Line, 0, len(entries)) - - for i, entry := range entries { - for vID, vEnd := range active { - if vEnd <= entry.StartMs { - delete(active, vID) - } - } - - voiceID := 0 - for { - if _, busy := active[voiceID]; !busy { - break - } - voiceID++ - } - if voiceID > maxVoice { - maxVoice = voiceID - } - - agentID := fmt.Sprintf("voice-%d", voiceID) - cues, value := wordsToLineCues(entry, agentID) - if len(cues) > 0 { - anyCues = true - } - - startMs := entry.StartMs - line := Line{ - Start: &startMs, - End: ends[i], - Value: value, - Cue: cues, - } - lines = append(lines, line) - - var endMs int64 - if ends[i] != nil { - endMs = *ends[i] - } else { - endMs = entry.StartMs - } - active[voiceID] = endMs - } - - // Monophonic source, or attribution that has nowhere to land: emit no - // agents and strip per-cue AgentIDs to keep the wire shape simple. - if maxVoice <= 0 || !anyCues { - for i := range lines { - for j := range lines[i].Cue { - lines[i].Cue[j].AgentID = "" - } - } - return lines, nil - } - - agents := make([]Agent, 0, maxVoice+1) - for v := 0; v <= maxVoice; v++ { - role := "voice" - if v == 0 { - role = "main" - } - agents = append(agents, Agent{ - ID: fmt.Sprintf("voice-%d", v), - Role: role, - }) - } - return lines, agents -} - -func lyricsfileLineEnd(entry lyricsfileLineEntry, nextStart *int64) *int64 { - if entry.EndMs != nil { - v := *entry.EndMs - return &v - } - if len(entry.Words) > 0 { - lastWord := entry.Words[len(entry.Words)-1] - if lastWord.EndMs != nil { - v := *lastWord.EndMs - return &v - } - } - if nextStart != nil { - v := *nextStart - return &v - } - return nil -} - -func buildPlainLyricsfileLines(plain string) []Line { - plain = str.SanitizeText(plain) - rawLines := strings.Split(plain, "\n") - lines := make([]Line, 0, len(rawLines)) - for _, raw := range rawLines { - value := strings.TrimSpace(raw) - if value == "" { - continue - } - lines = append(lines, Line{Value: value}) - } - return lines -} - -// wordsToLineCues converts a Lyricsfile line entry's words[] into model.Cue -// entries with inclusive UTF-8 byte offsets into the reconstructed line -// value. The line value is built from cue text concatenation rather than -// trusting entry.Text, because the Lyricsfile spec only requires word.text -// to "approximate" line.text - byte offsets must always land inside -// Line.Value. -func wordsToLineCues(entry lyricsfileLineEntry, agentID string) ([]Cue, string) { - if len(entry.Words) == 0 { - return nil, str.SanitizeText(entry.Text) - } - - var sb strings.Builder - for _, w := range entry.Words { - sb.WriteString(w.Text) - } - lineValue := sb.String() - - cues := make([]Cue, len(entry.Words)) - cursor := 0 - for i, w := range entry.Words { - valueBytes := len(w.Text) - bs := cursor - be := bs - if valueBytes > 0 { - be = bs + valueBytes - 1 - cursor = be + 1 - } - - s := w.StartMs - cue := Cue{ - Start: &s, - Value: w.Text, - ByteStart: bs, - ByteEnd: be, - AgentID: agentID, - } - if w.EndMs != nil { - e := *w.EndMs - cue.End = &e - } - cues[i] = cue - } - - for i := 0; i < len(cues)-1; i++ { - if cues[i].End == nil && cues[i+1].Start != nil { - v := *cues[i+1].Start - cues[i].End = &v - } - } - return cues, lineValue -} diff --git a/model/lyrics_lyricsfile_test.go b/model/lyrics_lyricsfile_test.go deleted file mode 100644 index 45899cda7..000000000 --- a/model/lyrics_lyricsfile_test.go +++ /dev/null @@ -1,300 +0,0 @@ -package model - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("parseLyricsfile", func() { - DescribeTable("returns nil,nil for YAML without the Lyricsfile version marker", - func(input string) { - lyrics, err := parseLyricsfile("", []byte(input)) - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics).To(BeNil()) - }, - Entry("arbitrary YAML", "hello: world\n"), - Entry("Lyricsfile-shaped but unversioned", `metadata: - title: 'Looks close' -lines: - - text: "But should not be claimed" - start_ms: 1000 -`), - ) - - It("returns an error for invalid YAML", func() { - _, err := parseLyricsfile("", []byte("not: valid: yaml: [")) - Expect(err).To(HaveOccurred()) - }) - - It("parses line-level metadata without cues", func() { - input := `version: '1.0' -metadata: - title: 'Sample Track' - artist: 'Test Artist' - language: 'eng' - offset_ms: -100 -lines: - - text: "We're no strangers to love" - start_ms: 18800 - - text: "You know the rules and so do I" - start_ms: 22801 -` - lyrics, err := parseLyricsfile("", []byte(input)) - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics).To(HaveLen(1)) - - l := lyrics[0] - Expect(l.Kind).To(Equal("main")) - Expect(l.Lang).To(Equal("eng")) - Expect(l.DisplayArtist).To(Equal("Test Artist")) - Expect(l.DisplayTitle).To(Equal("Sample Track")) - Expect(l.Synced).To(BeTrue()) - Expect(l.Offset).ToNot(BeNil()) - Expect(*l.Offset).To(Equal(int64(-100))) - Expect(l.Agents).To(BeNil()) - - Expect(l.Line).To(HaveLen(2)) - Expect(*l.Line[0].Start).To(Equal(int64(18800))) - Expect(l.Line[0].End).ToNot(BeNil()) - Expect(*l.Line[0].End).To(Equal(int64(22801))) - Expect(l.Line[0].Value).To(Equal("We're no strangers to love")) - Expect(l.Line[0].Cue).To(BeNil()) - - Expect(*l.Line[1].Start).To(Equal(int64(22801))) - Expect(l.Line[1].End).To(BeNil()) - Expect(l.Line[1].Value).To(Equal("You know the rules and so do I")) - Expect(l.Line[1].Cue).To(BeNil()) - }) - - DescribeTable("resolves the lyric language", - func(metaLanguage, callerLang, want string) { - input := "version: '1.0'\nmetadata:\n title: 'T'\n" - if metaLanguage != "" { - input += " language: '" + metaLanguage + "'\n" - } - input += "lines:\n - text: \"line\"\n start_ms: 0\n" - - lyrics, err := parseLyricsfile(callerLang, []byte(input)) - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics).To(HaveLen(1)) - Expect(lyrics[0].Lang).To(Equal(want)) - }, - Entry("prefers the document's own language", "eng", "deu", "eng"), - Entry("falls back to the caller language when metadata omits it", "", "deu", "deu"), - Entry("uses xxx when neither is provided", "", "", "xxx"), - ) - - It("parses plain-only Lyricsfile lyrics as unsynced lines", func() { - input := `version: '1.0' -metadata: - title: 'Plain Track' - artist: 'Plain Artist' - language: 'en' -lines: [] -plain: | - [Verse 1] - First line - - Second line -` - lyrics, err := parseLyricsfile("", []byte(input)) - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics).To(HaveLen(1)) - - l := lyrics[0] - Expect(l.Kind).To(Equal("main")) - Expect(l.Lang).To(Equal("en")) - Expect(l.DisplayArtist).To(Equal("Plain Artist")) - Expect(l.DisplayTitle).To(Equal("Plain Track")) - Expect(l.Synced).To(BeFalse()) - Expect(l.Agents).To(BeNil()) - Expect(l.Line).To(Equal([]Line{ - {Value: "[Verse 1]"}, - {Value: "First line"}, - {Value: "Second line"}, - })) - }) - - It("produces word cues with inclusive UTF-8 byte offsets for monophonic word data", func() { - input := `version: '1.0' -metadata: - title: 'Karaoke' - artist: 'Singer' - language: 'eng' -lines: - - text: "Hello world" - start_ms: 1000 - end_ms: 3000 - words: - - text: "Hello " - start_ms: 1000 - end_ms: 1500 - - text: "world" - start_ms: 1500 - end_ms: 3000 -` - lyrics, err := parseLyricsfile("", []byte(input)) - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics).To(HaveLen(1)) - - l := lyrics[0] - Expect(l.Synced).To(BeTrue()) - Expect(l.Agents).To(BeNil()) - Expect(l.Line).To(HaveLen(1)) - - line := l.Line[0] - Expect(*line.Start).To(Equal(int64(1000))) - Expect(*line.End).To(Equal(int64(3000))) - Expect(line.Value).To(Equal("Hello world")) - Expect(line.Cue).To(HaveLen(2)) - - Expect(*line.Cue[0].Start).To(Equal(int64(1000))) - Expect(*line.Cue[0].End).To(Equal(int64(1500))) - Expect(line.Cue[0].Value).To(Equal("Hello ")) - Expect(line.Cue[0].ByteStart).To(Equal(0)) - Expect(line.Cue[0].ByteEnd).To(Equal(5)) - Expect(line.Cue[0].AgentID).To(Equal("")) - - Expect(*line.Cue[1].Start).To(Equal(int64(1500))) - Expect(*line.Cue[1].End).To(Equal(int64(3000))) - Expect(line.Cue[1].Value).To(Equal("world")) - Expect(line.Cue[1].ByteStart).To(Equal(6)) - Expect(line.Cue[1].ByteEnd).To(Equal(10)) - Expect(line.Cue[1].AgentID).To(Equal("")) - }) - - It("prefers final word end_ms over next line start when inferring line end", func() { - input := `version: '1.0' -metadata: - title: 'Overlap From Words' -lines: - - text: "Long vocal" - start_ms: 1000 - words: - - text: "Long " - start_ms: 1000 - end_ms: 2000 - - text: "vocal" - start_ms: 2000 - end_ms: 4000 - - text: "echo" - start_ms: 3000 - end_ms: 3500 - words: - - text: "echo" - start_ms: 3000 - end_ms: 3500 -` - lyrics, err := parseLyricsfile("", []byte(input)) - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics).To(HaveLen(1)) - - l := lyrics[0] - Expect(l.Agents).To(Equal([]Agent{ - {ID: "voice-0", Role: "main"}, - {ID: "voice-1", Role: "voice"}, - })) - Expect(l.Line).To(HaveLen(2)) - Expect(l.Line[0].End).ToNot(BeNil()) - Expect(*l.Line[0].End).To(Equal(int64(4000))) - Expect(l.Line[0].Cue[1].End).To(Equal(l.Line[0].End)) - Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1")) - }) - - It("synthesises voice agents for overlapping lines and attributes per-cue", func() { - input := `version: '1.0' -metadata: - title: 'Duet' -lines: - - text: "Lead vocal" - start_ms: 1000 - end_ms: 4000 - words: - - text: "Lead " - start_ms: 1000 - end_ms: 2000 - - text: "vocal" - start_ms: 2000 - end_ms: 4000 - - text: "echo" - start_ms: 2000 - end_ms: 3000 - words: - - text: "echo" - start_ms: 2000 - end_ms: 3000 -` - lyrics, err := parseLyricsfile("", []byte(input)) - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics).To(HaveLen(1)) - - l := lyrics[0] - Expect(l.Agents).To(Equal([]Agent{ - {ID: "voice-0", Role: "main"}, - {ID: "voice-1", Role: "voice"}, - })) - Expect(l.Line).To(HaveLen(2)) - - Expect(l.Line[0].Value).To(Equal("Lead vocal")) - Expect(*l.Line[0].Start).To(Equal(int64(1000))) - Expect(*l.Line[0].End).To(Equal(int64(4000))) - Expect(l.Line[0].Cue).To(HaveLen(2)) - Expect(l.Line[0].Cue[0].AgentID).To(Equal("voice-0")) - Expect(l.Line[0].Cue[1].AgentID).To(Equal("voice-0")) - Expect(l.Line[0].Cue[0].ByteStart).To(Equal(0)) - Expect(l.Line[0].Cue[0].ByteEnd).To(Equal(4)) - Expect(l.Line[0].Cue[1].ByteStart).To(Equal(5)) - Expect(l.Line[0].Cue[1].ByteEnd).To(Equal(9)) - - Expect(l.Line[1].Value).To(Equal("echo")) - Expect(*l.Line[1].Start).To(Equal(int64(2000))) - Expect(*l.Line[1].End).To(Equal(int64(3000))) - Expect(l.Line[1].Cue).To(HaveLen(1)) - Expect(l.Line[1].Cue[0].AgentID).To(Equal("voice-1")) - Expect(l.Line[1].Cue[0].ByteStart).To(Equal(0)) - Expect(l.Line[1].Cue[0].ByteEnd).To(Equal(3)) - }) - - It("emits empty lines with Synced=false for instrumental tracks", func() { - input := `version: '1.0' -metadata: - title: 'Solo Piano' - artist: 'Composer' - language: 'eng' - instrumental: true -` - lyrics, err := parseLyricsfile("", []byte(input)) - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics).To(HaveLen(1)) - - l := lyrics[0] - Expect(l.Kind).To(Equal("main")) - Expect(l.Lang).To(Equal("eng")) - Expect(l.DisplayArtist).To(Equal("Composer")) - Expect(l.DisplayTitle).To(Equal("Solo Piano")) - Expect(l.Synced).To(BeFalse()) - Expect(l.Line).To(BeEmpty()) - Expect(l.Agents).To(BeNil()) - }) - - It("strips agent attribution when overlapping lines carry no cues", func() { - input := `version: '1.0' -lines: - - text: "Lead" - start_ms: 1000 - end_ms: 4000 - - text: "echo" - start_ms: 2000 - end_ms: 3000 -` - lyrics, err := parseLyricsfile("", []byte(input)) - Expect(err).ToNot(HaveOccurred()) - Expect(lyrics).To(HaveLen(1)) - - l := lyrics[0] - Expect(l.Line).To(HaveLen(2)) - Expect(l.Agents).To(BeNil()) - Expect(l.Line[0].Cue).To(BeNil()) - Expect(l.Line[1].Cue).To(BeNil()) - }) -}) diff --git a/model/lyrics_normalize.go b/model/lyrics_normalize.go deleted file mode 100644 index a7d4b4e2a..000000000 --- a/model/lyrics_normalize.go +++ /dev/null @@ -1,171 +0,0 @@ -package model - -import ( - "slices" - - "github.com/navidrome/navidrome/utils/gg" -) - -func normalizeLyrics(lyrics Lyrics) Lyrics { - lyrics.Line = normalizeCueLines(lyrics.Line) - if len(lyrics.Agents) == 0 { - lyrics.Agents = nil - } - return lyrics -} - -func normalizeCueLines(lines []Line) []Line { - if len(lines) == 0 { - return lines - } - - normalized := make([]Line, len(lines)) - copy(normalized, lines) - - for i := range normalized { - if len(normalized[i].Cue) > 0 { - normalized[i].Cue = slices.Clone(normalized[i].Cue) - } - - var fallbackEnd *int64 - if normalized[i].End != nil { - v := *normalized[i].End - fallbackEnd = &v - } else if i+1 < len(normalized) && normalized[i+1].Start != nil { - v := *normalized[i+1].Start - fallbackEnd = &v - } - - normalized[i] = normalizeCueLine(normalized[i], fallbackEnd) - } - - return normalized -} - -func normalizeLineTiming(line Line) Line { - if len(line.Cue) == 0 { - return line - } - - var earliestStart *int64 - var latestEnd *int64 - for i := range line.Cue { - token := line.Cue[i] - if token.Start != nil { - if earliestStart == nil || *token.Start < *earliestStart { - v := *token.Start - earliestStart = &v - } - } - - candidateEnd := token.End - if candidateEnd == nil { - candidateEnd = token.Start - } - if candidateEnd != nil { - if latestEnd == nil || *candidateEnd > *latestEnd { - v := *candidateEnd - latestEnd = &v - } - } - } - - if line.Start == nil && earliestStart != nil { - v := *earliestStart - line.Start = &v - } - if line.End == nil && latestEnd != nil { - v := *latestEnd - line.End = &v - } - return line -} - -func normalizeCueLine(line Line, fallbackEnd *int64) Line { - if len(line.Cue) == 0 { - return line - } - line.Cue = normalizeCueEndsByAgent(line.Cue, fallbackEnd) - return normalizeLineTiming(line) -} - -// normalizeCueEndsByAgent resolves cue end times independently per agent so that -// background (or other parallel) layers, whose cues interleave with the main -// timeline but are stored together in document order, do not clamp each other's -// ends. Each agent group is normalized in its own document order; results are -// reassembled into the original cue positions. -func normalizeCueEndsByAgent(cues []Cue, fallbackEnd *int64) []Cue { - groups := make(map[string][]int) - order := make([]string, 0, 2) - for i := range cues { - id := cues[i].AgentID - if _, ok := groups[id]; !ok { - order = append(order, id) - } - groups[id] = append(groups[id], i) - } - - // Single agent: the document order already matches the timeline, so the - // straightforward normalization applies without regrouping. - if len(order) <= 1 { - return NormalizeCueEnds(cues, fallbackEnd) - } - - out := slices.Clone(cues) - for _, id := range order { - idxs := groups[id] - group := make([]Cue, len(idxs)) - for gi, pos := range idxs { - group[gi] = cues[pos] - } - group = NormalizeCueEnds(group, fallbackEnd) - for gi, pos := range idxs { - out[pos] = group[gi] - } - } - return out -} - -// NormalizeCueEnds resolves missing cue end times within a single ordered cue -// group: each end is filled from the next cue's start, then from fallbackEnd, -// and is clamped so it never precedes the cue's own start nor overruns the next -// cue. End times are all-or-none — if any cue still lacks an end afterwards, all -// ends in the group are cleared. The input slice is never mutated. -// -// Exported because the Subsonic enhanced-lyrics serializer resolves cue ends -// per agent group while building the response; all other normalization is -// package-internal. -func NormalizeCueEnds(cues []Cue, fallbackEnd *int64) []Cue { - if len(cues) == 0 { - return cues - } - - out := slices.Clone(cues) - for i := range out { - end := out[i].End - if end == nil { - if i+1 < len(out) && out[i+1].Start != nil { - end = out[i+1].Start - } else { - end = fallbackEnd - } - } - if end != nil && i+1 < len(out) && out[i+1].Start != nil && *end > *out[i+1].Start { - end = out[i+1].Start - } - if end != nil && out[i].Start != nil && *end < *out[i].Start { - end = out[i].Start - } - out[i].End = gg.Clone(end) - } - - for i := range out { - if out[i].End == nil { - for j := range out { - out[j].End = nil - } - break - } - } - return out -} diff --git a/model/lyrics_normalize_test.go b/model/lyrics_normalize_test.go deleted file mode 100644 index 24faffd54..000000000 --- a/model/lyrics_normalize_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package model - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("normalizeCueLines", func() { - It("should not mutate caller cue slices when filling missing cue end times", func() { - start0, start1, nextLineStart := int64(1000), int64(1500), int64(3000) - lines := []Line{ - { - Start: &start0, - Value: "Some lyrics", - Cue: []Cue{ - {Start: &start0, Value: "Some ", ByteStart: 0, ByteEnd: 4}, - {Start: &start1, Value: "lyrics", ByteStart: 5, ByteEnd: 10}, - }, - }, - { - Start: &nextLineStart, - Value: "Next line", - }, - } - - normalized := normalizeCueLines(lines) - - Expect(normalized[0].Cue[0].End).To(Equal(&start1)) - Expect(normalized[0].Cue[1].End).To(Equal(&nextLineStart)) - Expect(lines[0].Cue[0].End).To(BeNil()) - Expect(lines[0].Cue[1].End).To(BeNil()) - }) -}) - -var _ = Describe("NormalizeCueEnds", func() { - // p returns a fresh pointer so cases don't share *int64 state. - p := func(v int64) *int64 { return &v } - - // endsOf extracts the resolved end times (nil-safe) for compact assertions. - endsOf := func(cues []Cue) []*int64 { - out := make([]*int64, len(cues)) - for i := range cues { - out[i] = cues[i].End - } - return out - } - - It("returns the input as-is when empty", func() { - Expect(NormalizeCueEnds(nil, p(1000))).To(BeNil()) - Expect(NormalizeCueEnds([]Cue{}, p(1000))).To(BeEmpty()) - }) - - It("fills a missing end from the next cue's start", func() { - cues := []Cue{ - {Start: p(1000)}, - {Start: p(1500)}, - } - - out := NormalizeCueEnds(cues, p(3000)) - - Expect(endsOf(out)).To(Equal([]*int64{p(1500), p(3000)})) - }) - - It("fills the last cue's missing end from fallbackEnd", func() { - cues := []Cue{ - {Start: p(1000), End: p(1200)}, - {Start: p(1500)}, - } - - out := NormalizeCueEnds(cues, p(3000)) - - Expect(endsOf(out)).To(Equal([]*int64{p(1200), p(3000)})) - }) - - It("clamps an end that overruns the next cue's start", func() { - cues := []Cue{ - {Start: p(1000), End: p(9999)}, - {Start: p(1500), End: p(2000)}, - } - - out := NormalizeCueEnds(cues, p(3000)) - - Expect(endsOf(out)).To(Equal([]*int64{p(1500), p(2000)})) - }) - - It("clamps an end that precedes the cue's own start", func() { - cues := []Cue{ - {Start: p(1000), End: p(500)}, - } - - out := NormalizeCueEnds(cues, p(3000)) - - Expect(endsOf(out)).To(Equal([]*int64{p(1000)})) - }) - - It("clears all ends when any cue still lacks one (all-or-none)", func() { - // The last cue has no end and there is no fallback, so it stays nil and - // every end in the group is cleared. - cues := []Cue{ - {Start: p(1000), End: p(1200)}, - {Start: p(1500)}, - } - - out := NormalizeCueEnds(cues, nil) - - Expect(endsOf(out)).To(Equal([]*int64{nil, nil})) - }) - - It("does not mutate the input slice", func() { - cues := []Cue{ - {Start: p(1000)}, - {Start: p(1500)}, - } - - _ = NormalizeCueEnds(cues, p(3000)) - - Expect(cues[0].End).To(BeNil()) - Expect(cues[1].End).To(BeNil()) - }) -}) diff --git a/model/lyrics_parse.go b/model/lyrics_parse.go deleted file mode 100644 index 8aa095c50..000000000 --- a/model/lyrics_parse.go +++ /dev/null @@ -1,84 +0,0 @@ -package model - -import ( - "bytes" - "context" - "fmt" - "slices" - "strings" - - "github.com/navidrome/navidrome/log" -) - -// lyricParser returns an empty list (not an error) when the input is not its -// format, so parsers can be tried in order. lang is the default for formats that -// do not carry their own. -type lyricParser func(lang string, contents []byte) (LyricList, error) - -// lyricFormats is the structured formats in content-sniff probe order; each -// row's suffixes drive sidecar dispatch. LRC/plain is the unlisted fallback floor. -var lyricFormats = []struct { - suffixes []string - parse lyricParser -}{ - {[]string{".ttml"}, parseTTML}, - {[]string{".srt"}, parseSRT}, - {[]string{".yaml", ".yml"}, parseLyricsfile}, -} - -// ParseLyrics is the single entry point for parsing lyrics. A known suffix routes -// to that format's parser; an empty or "auto" suffix content-sniffs. Either way, -// a structured parser that does not match falls back to the LRC/plain-text floor. -// -// Parse failures are logged through ctx; callers that know the source should -// attach it for attribution, e.g. log.NewContext(ctx, "file", path). -func ParseLyrics(ctx context.Context, suffix, lang string, contents []byte) (LyricList, error) { - contents = stripBOM(contents) - suffix = strings.ToLower(suffix) - sniff := suffix == "" || suffix == "auto" - - // Sniffing tries every format in order; a known suffix selects just its own. - // Unmatched suffixes leave no candidates, so parseFirstMatch falls to plain. - candidates := make([]lyricParser, 0, len(lyricFormats)) - for _, f := range lyricFormats { - if sniff || slices.Contains(f.suffixes, suffix) { - candidates = append(candidates, f.parse) - } - } - return parseFirstMatch(ctx, sniff, lang, contents, candidates...) -} - -func parseFirstMatch(ctx context.Context, sniff bool, lang string, contents []byte, candidates ...lyricParser) (LyricList, error) { - for _, parse := range candidates { - list, err := parse(lang, contents) - if err == nil && len(list) > 0 { - return list, nil - } - if err != nil { - // While sniffing, a probe rejecting content it does not own is expected - // control flow, so keep it at trace. A failure under an explicit suffix - // means the declared format is malformed and deserves a warning. - if sniff { - log.Trace(ctx, "Lyrics probe did not match, trying next format", err) - } else { - log.Warn(ctx, "Error parsing lyrics, falling back to plain text", err) - } - } - } - return plainLRC(lang, contents) -} - -func plainLRC(lang string, contents []byte) (LyricList, error) { - lyric, err := parseLRC(lang, string(contents)) - if err != nil { - return nil, fmt.Errorf("parsing lyrics: %w", err) - } - if lyric == nil || lyric.IsEmpty() { - return nil, nil - } - return LyricList{*lyric}, nil -} - -func stripBOM(contents []byte) []byte { - return bytes.TrimPrefix(contents, []byte("\ufeff")) -} diff --git a/model/lyrics_parse_test.go b/model/lyrics_parse_test.go deleted file mode 100644 index 0b47e55e9..000000000 --- a/model/lyrics_parse_test.go +++ /dev/null @@ -1,269 +0,0 @@ -package model - -import ( - "strings" - - "github.com/navidrome/navidrome/log" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/sirupsen/logrus" - "github.com/sirupsen/logrus/hooks/test" -) - -var _ = Describe("ParseLyrics", func() { - DescribeTable("known suffix routes to the matching parser", - func(suffix, contents string, wantSynced bool, wantFirst string) { - list, err := ParseLyrics(GinkgoT().Context(), suffix, "eng", []byte(contents)) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Synced).To(Equal(wantSynced)) - Expect(list[0].Line[0].Value).To(Equal(wantFirst)) - }, - Entry(".lrc", ".lrc", "[00:01.00]lrc line", true, "lrc line"), - Entry(".txt plain", ".txt", "plain line", false, "plain line"), - Entry(".srt", ".srt", "1\n00:00:01,000 --> 00:00:02,000\nsrt line\n", true, "srt line"), - Entry(".ttml", ".ttml", `

ttml line

`, true, "ttml line"), - Entry(".yaml", ".yaml", "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: yaml line\n start_ms: 1000\n", true, "yaml line"), - ) - - It("empty suffix content-sniffs (TTML)", func() { - ttml := `

auto ttml

` - list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(ttml)) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line[0].Value).To(Equal("auto ttml")) - }) - - It("empty suffix content-sniffs (YAML)", func() { - yaml := "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: auto yaml\n start_ms: 1000\n" - list, err := ParseLyrics(GinkgoT().Context(), "auto", "eng", []byte(yaml)) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line[0].Value).To(Equal("auto yaml")) - }) - - It("falls back to plain text when a known suffix fails to parse structurally", func() { - list, err := ParseLyrics(GinkgoT().Context(), ".srt", "eng", []byte("not actually an srt file")) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Synced).To(BeFalse()) - Expect(list[0].Line[0].Value).To(Equal("not actually an srt file")) - }) - - Describe("logging on parser probe failures", func() { - var hook *test.Hook - - BeforeEach(func() { - prevLevel := log.CurrentLevel() - l, h := test.NewNullLogger() - hook = h - // Swap the logger before raising the level: SetLevel also forces the - // current default logger to logrus.TraceLevel, and the null logger would - // otherwise stay at Info and drop Trace entries before the hook sees them. - prevLogger := log.SetDefaultLogger(l) - log.SetLevel(log.LevelTrace) - DeferCleanup(func() { - log.SetDefaultLogger(prevLogger) - log.SetLevel(prevLevel) - }) - }) - - // This is the source of the full-scan log spam: embedded lyrics are parsed - // with an empty suffix (sniff mode), so every plain-text lyric fails the - // YAML/SRT/TTML probes on its way to the plain-text fallback. A probe miss - // during sniffing is expected control flow, not a warning. - It("logs sniff probe misses at trace only, with file attribution", func() { - ctx := log.NewContext(GinkgoT().Context(), "file", "/music/song.mp3") - list, err := ParseLyrics(ctx, "", "eng", []byte("Just a plain\nlyric line\n")) - - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line[0].Value).To(Equal("Just a plain")) - entries := hook.AllEntries() - Expect(entries).ToNot(BeEmpty(), "probe misses should be observable at trace") - for _, e := range entries { - Expect(e.Level).To(Equal(logrus.TraceLevel), - "sniff-mode probe misses must not be logged above Trace") - Expect(e.Data).To(HaveKeyWithValue("file", "/music/song.mp3")) - } - }) - - // A specific suffix means the user declared the format, so a structural - // failure is worth surfacing loudly — and it must name the file. - It("warns and names the file when a requested suffix fails to parse", func() { - ctx := log.NewContext(GinkgoT().Context(), "file", "/music/song.yaml") - list, err := ParseLyrics(ctx, ".yaml", "eng", []byte("not: [valid, yaml\n")) - - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) // still falls back to plain text - entry := hook.LastEntry() - Expect(entry).ToNot(BeNil()) - Expect(entry.Level).To(Equal(logrus.WarnLevel)) - Expect(entry.Data).To(HaveKeyWithValue("file", "/music/song.yaml")) - }) - }) -}) - -var _ = Describe("ParseLyrics content-sniffing", func() { - It("should parse embedded TTML with the tag language as the default", func() { - content := ` - - - - Lead Vocal - - - - -
-

- Hello world -

-
- -
` - - list, err := ParseLyrics(GinkgoT().Context(), "", "ENG", []byte(content)) - - // ParseLyrics's job is to detect TTML and apply the tag language as the - // default; the parser's cue/agent details are covered in lyrics_ttml_test.go. - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Kind).To(Equal("main")) - Expect(list[0].Lang).To(Equal("eng")) - Expect(list[0].Synced).To(BeTrue()) - Expect(list[0].Line[0].Value).To(Equal("Hello world")) - }) - - It("should preserve embedded TTML translation and pronunciation tracks", func() { - content := ` - - - - - - Hola - - - - - konni - - - - - - -
-

こんにちは

-
- -
` - - list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content)) - - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(3)) - Expect(list[0].Kind).To(Equal("main")) - Expect(list[0].Lang).To(Equal("ja")) - Expect(list[0].Line[0].Value).To(Equal("こんにちは")) - Expect(list[1].Kind).To(Equal("translation")) - Expect(list[1].Lang).To(Equal("es")) - Expect(list[1].Line[0].Value).To(Equal("Hola")) - Expect(list[2].Kind).To(Equal("pronunciation")) - Expect(list[2].Lang).To(Equal("ja-latn")) - Expect(list[2].Line[0].Value).To(Equal("konni")) - Expect(list[2].Line[0].Cue).To(HaveLen(2)) - }) - - It("should parse embedded SRT with the tag language", func() { - content := `1 -00:00:18,800 --> 00:00:22,800 -We're from subtitles - -2 -00:00:22,801 --> 00:00:26,000 -Another subtitle line` - - list, err := ParseLyrics(GinkgoT().Context(), "", "POR", []byte(content)) - - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(Equal(LyricList{ - { - Lang: "por", - Line: []Line{ - { - Start: new(int64(18800)), - End: new(int64(22800)), - Value: "We're from subtitles", - }, - { - Start: new(int64(22801)), - End: new(int64(26000)), - Value: "Another subtitle line", - }, - }, - Synced: true, - }, - })) - }) - - It("should parse embedded SRT blocks separated by whitespace-only blank lines", func() { - content := "1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n \n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle" - - list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content)) - - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line).To(Equal([]Line{ - {Start: new(int64(1000)), End: new(int64(2000)), Value: "First subtitle"}, - {Start: new(int64(3000)), End: new(int64(4000)), Value: "Second subtitle"}, - })) - }) - - It("should keep embedded enhanced LRC cues", func() { - content := "[00:01.00]<00:01.00>Lead <00:01.50>words" - - list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content)) - - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Lang).To(Equal("eng")) - Expect(list[0].Synced).To(BeTrue()) - Expect(list[0].Line[0].Value).To(Equal("Lead words")) - Expect(list[0].Line[0].Cue).To(HaveLen(2)) - }) - - It("should fall back to plain lyrics when embedded TTML is invalid", func() { - content := ` - -

Broken

- -
` - - list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(content)) - - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Lang).To(Equal("eng")) - Expect(list[0].Synced).To(BeFalse()) - Expect(list[0].Line).ToNot(BeEmpty()) - values := make([]string, 0, len(list[0].Line)) - for _, line := range list[0].Line { - values = append(values, line.Value) - } - Expect(strings.Join(values, "\n")).To(ContainSubstring("Broken")) - }) - - It("detects a Lyricsfile YAML payload via content-sniffing", func() { - yaml := "version: \"1.0\"\nmetadata:\n title: Song\n language: eng\nlines:\n - text: sniffed yaml line\n start_ms: 1000\n" - - list, err := ParseLyrics(GinkgoT().Context(), "", "eng", []byte(yaml)) - - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Synced).To(BeTrue()) - Expect(list[0].Line).To(HaveLen(1)) - Expect(list[0].Line[0].Value).To(Equal("sniffed yaml line")) - }) -}) diff --git a/model/lyrics_srt.go b/model/lyrics_srt.go deleted file mode 100644 index 319a59961..000000000 --- a/model/lyrics_srt.go +++ /dev/null @@ -1,158 +0,0 @@ -package model - -import ( - "regexp" - "strconv" - "strings" - - "github.com/navidrome/navidrome/utils/str" -) - -var ( - srtTimeRegex = regexp.MustCompile(`^\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})\s*$`) - srtBlockSeparatorRegex = regexp.MustCompile(`\n\s*\n`) -) - -func parseSRT(language string, contents []byte) (LyricList, error) { - raw := strings.ReplaceAll(string(contents), "\r\n", "\n") - raw = strings.ReplaceAll(raw, "\r", "\n") - - blocks := splitSRTBlocks(raw) - lines := make([]Line, 0, len(blocks)) - - for _, block := range blocks { - line, ok, err := parseSRTBlock(block) - if err != nil { - return nil, err - } - if ok { - lines = append(lines, line) - } - } - - if len(lines) == 0 { - return nil, nil - } - - lyrics := normalizeLyrics(Lyrics{ - Lang: normalizeLyricLang(language), - Line: lines, - Synced: true, - }) - return LyricList{lyrics}, nil -} - -func splitSRTBlocks(raw string) []string { - raw = strings.TrimSpace(raw) - if raw == "" { - return nil - } - - parts := srtBlockSeparatorRegex.Split(raw, -1) - blocks := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part != "" { - blocks = append(blocks, part) - } - } - return blocks -} - -func parseSRTBlock(block string) (Line, bool, error) { - rawLines := strings.Split(block, "\n") - lines := make([]string, 0, len(rawLines)) - for _, line := range rawLines { - lines = append(lines, strings.TrimSpace(line)) - } - - if len(lines) == 0 { - return Line{}, false, nil - } - - startIdx := 0 - if digitsOnly(lines[0]) { - startIdx = 1 - } - if startIdx >= len(lines) { - return Line{}, false, nil - } - - timing := strings.Split(lines[startIdx], "-->") - if len(timing) != 2 { - return Line{}, false, nil - } - - startMs, err := parseSRTTime(timing[0]) - if err != nil { - return Line{}, false, err - } - endMs, err := parseSRTTime(timing[1]) - if err != nil { - return Line{}, false, err - } - - textLines := make([]string, 0, len(lines)-startIdx-1) - for _, line := range lines[startIdx+1:] { - if line == "" { - continue - } - textLines = append(textLines, line) - } - - value := str.SanitizeText(strings.Join(textLines, "\n")) - if value == "" { - return Line{}, false, nil - } - - return Line{ - Start: &startMs, - End: &endMs, - Value: value, - }, true, nil -} - -func parseSRTTime(value string) (int64, error) { - match := srtTimeRegex.FindStringSubmatch(strings.TrimSpace(value)) - if match == nil { - return 0, strconv.ErrSyntax - } - - hours, err := strconv.ParseInt(match[1], 10, 64) - if err != nil { - return 0, err - } - minutes, err := strconv.ParseInt(match[2], 10, 64) - if err != nil { - return 0, err - } - seconds, err := strconv.ParseInt(match[3], 10, 64) - if err != nil { - return 0, err - } - millis, err := strconv.ParseInt(match[4], 10, 64) - if err != nil { - return 0, err - } - - switch len(match[4]) { - case 1: - millis *= 100 - case 2: - millis *= 10 - } - - return (((hours*60)+minutes)*60+seconds)*1000 + millis, nil -} - -func digitsOnly(value string) bool { - if value == "" { - return false - } - for _, ch := range value { - if ch < '0' || ch > '9' { - return false - } - } - return true -} diff --git a/model/lyrics_srt_test.go b/model/lyrics_srt_test.go deleted file mode 100644 index 2c0ab2242..000000000 --- a/model/lyrics_srt_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package model - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("parseSRT", func() { - It("parses SRT blocks with the default language", func() { - content := []byte("1\n00:00:01,000 --> 00:00:02,000\nFirst subtitle\n\n2\n00:00:03,000 --> 00:00:04,000\nSecond subtitle") - - list, err := parseSRT("xxx", content) - - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Lang).To(Equal("xxx")) - Expect(list[0].Synced).To(BeTrue()) - Expect(list[0].Line).To(Equal([]Line{ - {Start: new(int64(1000)), End: new(int64(2000)), Value: "First subtitle"}, - {Start: new(int64(3000)), End: new(int64(4000)), Value: "Second subtitle"}, - })) - }) - - It("returns nil for input with no valid blocks", func() { - list, err := parseSRT("xxx", []byte("not actually an srt file")) - - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(BeNil()) - }) -}) diff --git a/model/lyrics_test.go b/model/lyrics_test.go deleted file mode 100644 index fd954ad26..000000000 --- a/model/lyrics_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package model - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Lyrics.EffectiveKind", func() { - It("defaults a blank kind to main", func() { - Expect(Lyrics{}.EffectiveKind()).To(Equal(LyricKindMain)) - Expect(Lyrics{Kind: " "}.EffectiveKind()).To(Equal(LyricKindMain)) - }) - - It("returns the kind as-is when set", func() { - Expect(Lyrics{Kind: LyricKindTranslation}.EffectiveKind()).To(Equal(LyricKindTranslation)) - }) -}) - -var _ = Describe("Lyrics.IsMainKind", func() { - It("is true for a blank (untyped) kind", func() { - Expect(Lyrics{}.IsMainKind()).To(BeTrue()) - }) - - It("is true for the main kind", func() { - Expect(Lyrics{Kind: LyricKindMain}.IsMainKind()).To(BeTrue()) - }) - - It("is false for translation and pronunciation kinds", func() { - Expect(Lyrics{Kind: LyricKindTranslation}.IsMainKind()).To(BeFalse()) - Expect(Lyrics{Kind: LyricKindPronunciation}.IsMainKind()).To(BeFalse()) - }) -}) - -var _ = Describe("LyricList.Main", func() { - It("returns false when the list is empty", func() { - _, ok := LyricList{}.Main() - Expect(ok).To(BeFalse()) - }) - - It("returns the main-kind entry when present", func() { - list := LyricList{ - {Kind: LyricKindTranslation, Lang: "en"}, - {Kind: LyricKindMain, Lang: "xxx"}, - } - main, ok := list.Main() - Expect(ok).To(BeTrue()) - Expect(main.Kind).To(Equal(LyricKindMain)) - }) - - It("falls back to the first entry when no main kind exists", func() { - list := LyricList{ - {Kind: LyricKindTranslation, Lang: "en"}, - {Kind: LyricKindPronunciation, Lang: "ja"}, - } - main, ok := list.Main() - Expect(ok).To(BeTrue()) - Expect(main.Lang).To(Equal("en")) - }) - - It("treats a blank kind as main", func() { - list := LyricList{ - {Kind: LyricKindTranslation, Lang: "en"}, - {Lang: "xxx"}, - } - main, ok := list.Main() - Expect(ok).To(BeTrue()) - Expect(main.Lang).To(Equal("xxx")) - }) -}) diff --git a/model/lyrics_ttml.go b/model/lyrics_ttml.go deleted file mode 100644 index 43d4699af..000000000 --- a/model/lyrics_ttml.go +++ /dev/null @@ -1,1296 +0,0 @@ -package model - -import ( - "bytes" - "encoding/xml" - "errors" - "io" - "math" - "regexp" - "sort" - "strconv" - "strings" - "unicode" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/gg" - "github.com/navidrome/navidrome/utils/str" -) - -const ( - defaultTTMLFrameRate = 30.0 - defaultTTMLSubFrameRate = 1.0 - defaultTTMLTickRate = 1.0 - - ttmlBackgroundAgentPrefix = "__nd_bg__|" -) - -var offsetTimeRegex = regexp.MustCompile(`^([0-9]+(?:\.[0-9]+)?)(h|m|s|ms|f|t)$`) -var xmlEncodingRegex = regexp.MustCompile(`(?i)<\?xml([^>]*?)encoding\s*=\s*["'][^"']+["']([^>]*)\?>`) - -type ttmlTimeKind int - -const ( - ttmlTimeAbsolute ttmlTimeKind = iota - ttmlTimeOffset - ttmlTimeAmbiguous -) - -type ttmlTimingParams struct { - frameRate float64 - subFrameRate float64 - tickRate float64 -} - -type ttmlTimingContext struct { - lang string - role string - agentID string - begin int64 - hasBegin bool - end int64 - hasEnd bool - invalid bool -} - -type ttmlLineRef struct { - order int - line Line -} - -type ttmlMetadataEntry struct { - key string - line Line - seq int -} - -type ttmlResolvedMetadataLine struct { - order int - seq int - line Line -} - -type ttmlDefinedAgent struct { - ID string - Type string - Name string -} - -type ttmlPiece struct { - raw string - cue *Cue - isBreak bool -} - -type ttmlParser struct { - decoder *xml.Decoder - params ttmlTimingParams - - mainLangOrder []string - mainLinesByLang map[string][]Line - - mainLineRefsByKey map[string]ttmlLineRef - mainLineOrder int - - translationLangOrder []string - translationEntriesByLg map[string][]ttmlMetadataEntry - - pronunciationLangOrder []string - pronunciationEntriesByLg map[string][]ttmlMetadataEntry - - definedAgents map[string]ttmlDefinedAgent - - metadataSeq int -} - -func isTTMLDocument(contents []byte) bool { - decoder := xml.NewDecoder(bytes.NewReader(bytes.TrimSpace(contents))) - for { - token, err := decoder.Token() - if err != nil { - return false - } - if start, ok := token.(xml.StartElement); ok { - return strings.EqualFold(start.Name.Local, "tt") - } - } -} - -func parseTTML(defaultLang string, contents []byte) (LyricList, error) { - contents = xmlEncodingRegex.ReplaceAll(contents, []byte(``)) - - // Skip non-TTML content so sniffing doesn't run the full TTML parse on plain - // text — isTTMLDocument does a cheap decode that stops at the first element. - // Checked after the encoding fixup so UTF-16-declared documents are recognized. - if !isTTMLDocument(contents) { - return nil, nil - } - - p := ttmlParser{ - decoder: xml.NewDecoder(bytes.NewReader(contents)), - params: ttmlTimingParams{ - frameRate: defaultTTMLFrameRate, - subFrameRate: defaultTTMLSubFrameRate, - tickRate: defaultTTMLTickRate, - }, - mainLinesByLang: make(map[string][]Line), - mainLineRefsByKey: make(map[string]ttmlLineRef), - translationEntriesByLg: make(map[string][]ttmlMetadataEntry), - pronunciationEntriesByLg: make(map[string][]ttmlMetadataEntry), - definedAgents: make(map[string]ttmlDefinedAgent), - } - - root := ttmlTimingContext{lang: normalizeLyricLang(defaultLang)} - - for { - token, err := p.decoder.Token() - if errors.Is(err, io.EOF) { - break - } - if err != nil { - return nil, err - } - - start, ok := token.(xml.StartElement) - if !ok { - continue - } - - if err := p.parseElement(start, root); err != nil { - return nil, err - } - } - - return p.toLyricList(), nil -} - -func (p *ttmlParser) parseElement(start xml.StartElement, parent ttmlTimingContext) error { - local := strings.ToLower(start.Name.Local) - if local == "tt" { - p.updateTimingParams(start.Attr) - } - - switch local { - case "translation": - return p.parseMetadataTrack(start, parent, LyricKindTranslation) - case "transliteration": - return p.parseMetadataTrack(start, parent, LyricKindPronunciation) - case "agent": - return p.parseAgentDefinition(start) - } - - ctx := p.childContext(start.Attr, parent) - if local == "p" { - lineText, tokens, err := p.parseParagraph(ctx) - if err != nil { - return err - } - if ctx.invalid || lineText == "" { - return nil - } - - parsedLine := Line{Value: lineText} - if ctx.hasBegin { - startMs := ctx.begin - parsedLine.Start = &startMs - } - if ctx.hasEnd { - endMs := ctx.end - parsedLine.End = &endMs - } - if len(tokens) > 0 { - parsedLine.Cue = tokens - } - parsedLine = normalizeLineTiming(parsedLine) - - lineKey, _ := attrValue(start.Attr, "key") - p.addMainLine(ctx.lang, lineKey, parsedLine) - return nil - } - - for { - token, err := p.decoder.Token() - if err != nil { - return err - } - - switch t := token.(type) { - case xml.StartElement: - nextParent := ctx - if ctx.invalid { - // Best effort: ignore invalid timing in container elements, and - // continue traversing descendants with parent context. - nextParent = parent - } - if err := p.parseElement(t, nextParent); err != nil { - return err - } - case xml.EndElement: - if strings.EqualFold(t.Name.Local, start.Name.Local) { - return nil - } - } - } -} - -func (p *ttmlParser) parseMetadataTrack(start xml.StartElement, parent ttmlTimingContext, kind string) error { - ctx := p.childContext(start.Attr, parent) - lang := normalizeLyricLang(ctx.lang) - - for { - token, err := p.decoder.Token() - if err != nil { - return err - } - - switch t := token.(type) { - case xml.StartElement: - if strings.EqualFold(t.Name.Local, "text") { - entry, ok, err := p.parseMetadataText(t, ctx) - if err != nil { - return err - } - if ok { - p.addMetadataEntry(kind, lang, entry) - } - continue - } - - nextParent := ctx - if ctx.invalid { - nextParent = parent - } - if err := p.parseElement(t, nextParent); err != nil { - return err - } - case xml.EndElement: - if strings.EqualFold(t.Name.Local, start.Name.Local) { - return nil - } - } - } -} - -func (p *ttmlParser) parseAgentDefinition(start xml.StartElement) error { - id, ok := attrValue(start.Attr, "id") - id = strings.TrimSpace(id) - if !ok || id == "" { - return p.skipElement(start) - } - - agent := ttmlDefinedAgent{ - ID: id, - Type: strings.ToLower(strings.TrimSpace(attrOrEmpty(start.Attr, "type"))), - } - - for { - token, err := p.decoder.Token() - if err != nil { - return err - } - - switch t := token.(type) { - case xml.StartElement: - if strings.EqualFold(t.Name.Local, "name") { - name, err := p.collectElementText(t) - if err != nil { - return err - } - name = sanitizeTTMLText(name) - if name != "" && agent.Name == "" { - agent.Name = name - } - continue - } - if err := p.skipElement(t); err != nil { - return err - } - case xml.EndElement: - if strings.EqualFold(t.Name.Local, start.Name.Local) { - p.definedAgents[agent.ID] = agent - return nil - } - } - } -} - -func (p *ttmlParser) parseMetadataText(start xml.StartElement, parent ttmlTimingContext) (ttmlMetadataEntry, bool, error) { - forKey, hasFor := attrValue(start.Attr, "for") - forKey = strings.TrimSpace(forKey) - - pieces, err := p.parseInlineElement(start, parent) - if err != nil { - return ttmlMetadataEntry{}, false, err - } - if !hasFor || forKey == "" { - return ttmlMetadataEntry{}, false, nil - } - - ctx := p.childContext(start.Attr, parent) - if ctx.invalid { - return ttmlMetadataEntry{}, false, nil - } - - value, tokens := buildTTMLLineFromPieces(pieces) - line := Line{Value: value} - if ctx.hasBegin { - startMs := ctx.begin - line.Start = &startMs - } - if ctx.hasEnd { - endMs := ctx.end - line.End = &endMs - } - if len(tokens) > 0 { - line.Cue = tokens - } - line = normalizeLineTiming(line) - - if line.Value == "" && len(line.Cue) == 0 { - return ttmlMetadataEntry{}, false, nil - } - - return ttmlMetadataEntry{key: forKey, line: line}, true, nil -} - -func (p *ttmlParser) parseParagraph(parent ttmlTimingContext) (string, []Cue, error) { - var pieces []ttmlPiece - - for { - token, err := p.decoder.Token() - if err != nil { - return "", nil, err - } - - switch t := token.(type) { - case xml.StartElement: - inlinePieces, err := p.parseInlineElement(t, parent) - if err != nil { - return "", nil, err - } - pieces = append(pieces, inlinePieces...) - case xml.EndElement: - if strings.EqualFold(t.Name.Local, "p") { - value, tokens := buildTTMLLineFromPieces(pieces) - return value, tokens, nil - } - case xml.CharData: - pieces = append(pieces, ttmlPiece{raw: string(t)}) - } - } -} - -func (p *ttmlParser) parseInlineElement(start xml.StartElement, parent ttmlTimingContext) ([]ttmlPiece, error) { - local := strings.ToLower(start.Name.Local) - if local == "br" { - return []ttmlPiece{{isBreak: true}}, nil - } - - ctx := p.childContext(start.Attr, parent) - _, hasBegin := attrValue(start.Attr, "begin") - _, hasEnd := attrValue(start.Attr, "end") - _, hasDur := attrValue(start.Attr, "dur") - hasOwnTiming := hasBegin || hasEnd || hasDur - - var pieces []ttmlPiece - - for { - token, err := p.decoder.Token() - if err != nil { - return nil, err - } - - switch t := token.(type) { - case xml.StartElement: - inlinePieces, err := p.parseInlineElement(t, ctx) - if err != nil { - return nil, err - } - pieces = append(pieces, inlinePieces...) - case xml.EndElement: - if !strings.EqualFold(t.Name.Local, start.Name.Local) { - continue - } - - if local == "span" && hasOwnTiming && !ctx.invalid && !ttmlPiecesContainCue(pieces) { - rawValue := concatTTMLPieceRaw(pieces) - tokenText := sanitizeTTMLText(rawValue) - if tokenText != "" { - parsedToken := Cue{ - AgentID: p.resolveCueAgentID(ctx), - } - if ctx.hasBegin { - startMs := ctx.begin - parsedToken.Start = &startMs - } - if ctx.hasEnd { - endMs := ctx.end - parsedToken.End = &endMs - } - - return []ttmlPiece{{ - raw: rawValue, - cue: &parsedToken, - }}, nil - } - } - - return pieces, nil - case xml.CharData: - pieces = append(pieces, ttmlPiece{raw: string(t)}) - } - } -} - -func buildTTMLLineFromPieces(pieces []ttmlPiece) (string, []Cue) { - finalized := finalizeTTMLLines(splitTTMLPiecesByBreak(pieces)) - for len(finalized) > 0 && finalized[0].text == "" && len(finalized[0].cues) == 0 { - finalized = finalized[1:] - } - for len(finalized) > 0 { - last := finalized[len(finalized)-1] - if last.text != "" || len(last.cues) > 0 { - break - } - finalized = finalized[:len(finalized)-1] - } - - var value strings.Builder - cues := make([]Cue, 0, 8) - byteOffset := 0 - for i, line := range finalized { - if i > 0 { - value.WriteByte('\n') - byteOffset++ - } - value.WriteString(line.text) - for _, cue := range line.cues { - cue.ByteStart += byteOffset - cue.ByteEnd += byteOffset - cues = append(cues, cue) - } - byteOffset += len(line.text) - } - - return value.String(), cues -} - -type ttmlFinalLine struct { - text string - cues []Cue -} - -func finalizeTTMLLines(lines [][]ttmlPiece) []ttmlFinalLine { - finalized := make([]ttmlFinalLine, 0, len(lines)) - for _, line := range lines { - text, cues := finalizeTTMLLogicalLine(line) - finalized = append(finalized, ttmlFinalLine{text: text, cues: cues}) - } - return finalized -} - -func splitTTMLPiecesByBreak(pieces []ttmlPiece) [][]ttmlPiece { - lines := [][]ttmlPiece{{}} - prevEndedWithSpace := true // leading whitespace on a fresh line is dropped - for _, piece := range pieces { - if piece.isBreak { - lines = append(lines, []ttmlPiece{}) - prevEndedWithSpace = true - continue - } - - raw := normalizeTTMLPieceRaw(piece.raw) - // Collapse whitespace across piece boundaries: a piece's leading space is - // redundant when the text emitted so far already ends with one. - if prevEndedWithSpace { - raw = strings.TrimPrefix(raw, " ") - } - if raw == "" { - continue - } - lines[len(lines)-1] = append(lines[len(lines)-1], ttmlPiece{ - raw: raw, - cue: gg.Clone(piece.cue), - }) - prevEndedWithSpace = strings.HasSuffix(raw, " ") - } - return lines -} - -func finalizeTTMLLogicalLine(line []ttmlPiece) (string, []Cue) { - rawLine := concatTTMLPieceRaw(line) - if rawLine == "" { - return "", nil - } - - leftTrimBytes := len(rawLine) - len(strings.TrimLeftFunc(rawLine, unicode.IsSpace)) - rightTrimBytes := len(rawLine) - len(strings.TrimRightFunc(rawLine, unicode.IsSpace)) - trimmedEnd := len(rawLine) - rightTrimBytes - if trimmedEnd < leftTrimBytes { - trimmedEnd = leftTrimBytes - } - - trimmed := strings.TrimSpace(rawLine) - cues := make([]Cue, 0, len(line)) - cursor := 0 - for _, piece := range line { - pieceEnd := cursor + len(piece.raw) - if piece.cue != nil { - byteStart := max(cursor, leftTrimBytes) - byteEnd := min(pieceEnd, trimmedEnd) - if byteStart < byteEnd { - cue := *piece.cue - cue.Value = rawLine[byteStart:byteEnd] - cue.ByteStart = byteStart - leftTrimBytes - cue.ByteEnd = byteEnd - leftTrimBytes - 1 - cues = append(cues, cue) - } - } - cursor = pieceEnd - } - - return trimmed, cues -} - -// normalizeTTMLPieceRaw collapses whitespace following TTML's default mode -// (xml:space="default", the root default per TTML2 §8.1.1): per §8.2.10 that -// means linefeed-treatment="treat-as-space" and white-space-collapse="true", so -// linefeeds and other whitespace runs collapse to a single space. Collapsing is -// applied unconditionally; xml:space="preserve" is not supported (no lyric -// source in practice relies on it). Hard line breaks come only from
-// (§8.1.7), tracked separately via ttmlPiece.isBreak, so pretty-printed -// indentation between elements does not inject spurious newlines. -func normalizeTTMLPieceRaw(raw string) string { - raw = str.SanitizeText(raw) - return collapseTTMLWhitespace(raw) -} - -func collapseTTMLWhitespace(raw string) string { - var b strings.Builder - b.Grow(len(raw)) - prevSpace := false - for _, r := range raw { - // Only the XML S production (space, tab, CR, LF) is collapsible whitespace. - // Other Unicode spaces (e.g. NBSP, U+3000) are content characters, not - // whitespace, so they pass through unchanged. - if r == ' ' || r == '\t' || r == '\n' || r == '\r' { - if !prevSpace { - b.WriteByte(' ') - prevSpace = true - } - continue - } - b.WriteRune(r) - prevSpace = false - } - return b.String() -} - -func concatTTMLPieceRaw(pieces []ttmlPiece) string { - var raw strings.Builder - for _, piece := range pieces { - raw.WriteString(normalizeTTMLPieceRaw(piece.raw)) - } - return raw.String() -} - -func ttmlPiecesContainCue(pieces []ttmlPiece) bool { - for _, piece := range pieces { - if piece.cue != nil { - return true - } - } - return false -} - -func (p *ttmlParser) toLyricList() LyricList { - res := make(LyricList, 0, len(p.mainLangOrder)+len(p.translationLangOrder)+len(p.pronunciationLangOrder)) - for _, lang := range p.mainLangOrder { - lines := p.mainLinesByLang[lang] - if len(lines) == 0 { - continue - } - res = append(res, p.finalizeLyrics(Lyrics{ - Kind: LyricKindMain, - Lang: lang, - Line: lines, - Synced: linesAreSynced(lines), - })) - } - - res = append(res, p.buildMetadataLyrics(LyricKindTranslation, p.translationLangOrder, p.translationEntriesByLg)...) - res = append(res, p.buildMetadataLyrics(LyricKindPronunciation, p.pronunciationLangOrder, p.pronunciationEntriesByLg)...) - return res -} - -func (p *ttmlParser) buildMetadataLyrics(kind string, langOrder []string, entriesByLang map[string][]ttmlMetadataEntry) LyricList { - res := make(LyricList, 0, len(langOrder)) - - for _, lang := range langOrder { - entries := entriesByLang[lang] - if len(entries) == 0 { - continue - } - - seenKeys := make(map[string]struct{}, len(entries)) - resolved := make([]ttmlResolvedMetadataLine, 0, len(entries)) - for _, entry := range entries { - if _, exists := seenKeys[entry.key]; exists { - continue - } - seenKeys[entry.key] = struct{}{} - - ref, ok := p.mainLineRefsByKey[entry.key] - if !ok { - log.Warn("Skipping TTML metadata line without matching key", "kind", kind, "lang", lang, "key", entry.key) - continue - } - - line := entry.line - if line.Start == nil && ref.line.Start != nil { - startMs := *ref.line.Start - line.Start = &startMs - } - if line.End == nil && ref.line.End != nil { - endMs := *ref.line.End - line.End = &endMs - } - line = normalizeLineTiming(line) - - if line.Value == "" && len(line.Cue) == 0 { - continue - } - - resolved = append(resolved, ttmlResolvedMetadataLine{ - order: ref.order, - seq: entry.seq, - line: line, - }) - } - - if len(resolved) == 0 { - continue - } - - sort.SliceStable(resolved, func(i, j int) bool { - if resolved[i].order != resolved[j].order { - return resolved[i].order < resolved[j].order - } - return resolved[i].seq < resolved[j].seq - }) - - lines := make([]Line, len(resolved)) - for i := range resolved { - lines[i] = resolved[i].line - } - - res = append(res, p.finalizeLyrics(Lyrics{ - Kind: kind, - Lang: lang, - Line: lines, - Synced: linesAreSynced(lines), - })) - } - - return res -} - -func (p *ttmlParser) finalizeLyrics(lyrics Lyrics) Lyrics { - lyrics.Line, lyrics.Agents = p.resolveAgents(lyrics.Line) - return normalizeLyrics(lyrics) -} - -func (p *ttmlParser) resolveAgents(lines []Line) ([]Line, []Agent) { - if len(lines) == 0 { - return lines, nil - } - - usedOrder := make([]string, 0, 4) - usedSet := make(map[string]struct{}, 4) - sawEmptyCue := false - - for i := range lines { - for j := range lines[i].Cue { - agentID := strings.TrimSpace(lines[i].Cue[j].AgentID) - if agentID == "" { - sawEmptyCue = true - continue - } - if _, exists := usedSet[agentID]; !exists { - usedSet[agentID] = struct{}{} - usedOrder = append(usedOrder, agentID) - } - } - } - - if len(usedOrder) == 0 { - return lines, nil - } - - mainID := "" - for _, agentID := range usedOrder { - role := p.baseRoleForAgent(agentID) - if role != "bg" && role != "group" { - mainID = agentID - break - } - } - if mainID == "" && sawEmptyCue { - mainID = "main" - } - if mainID == "" { - for _, agentID := range usedOrder { - if p.baseRoleForAgent(agentID) != "bg" { - mainID = agentID - break - } - } - } - if mainID == "" { - mainID = usedOrder[0] - } - - if _, exists := usedSet[mainID]; !exists { - usedSet[mainID] = struct{}{} - usedOrder = append([]string{mainID}, usedOrder...) - } - - for i := range lines { - for j := range lines[i].Cue { - if strings.TrimSpace(lines[i].Cue[j].AgentID) == "" { - lines[i].Cue[j].AgentID = mainID - } - } - } - - agents := make([]Agent, 0, len(usedOrder)) - for _, agentID := range usedOrder { - role := p.baseRoleForAgent(agentID) - if agentID == mainID { - role = "main" - } - agent := Agent{ - ID: agentID, - Role: role, - Name: p.agentNameForID(agentID), - } - agents = append(agents, agent) - } - - return lines, agents -} - -func (p *ttmlParser) resolveCueAgentID(ctx ttmlTimingContext) string { - agentID := strings.TrimSpace(ctx.agentID) - if contextHasRole(ctx.role, "x-bg") { - if agentID == "" { - agentID = "main" - } - return backgroundAgentID(agentID) - } - return agentID -} - -func (p *ttmlParser) baseRoleForAgent(agentID string) string { - if isBackgroundAgentID(agentID) { - return "bg" - } - - if agent, ok := p.definedAgents[agentID]; ok { - switch agent.Type { - case "group": - return "group" - default: - return "voice" - } - } - - return "voice" -} - -func (p *ttmlParser) agentNameForID(agentID string) string { - if isBackgroundAgentID(agentID) { - baseID := strings.TrimPrefix(agentID, ttmlBackgroundAgentPrefix) - if baseID == "main" { - return "" - } - if agent, ok := p.definedAgents[baseID]; ok { - return agent.Name - } - return "" - } - - if agent, ok := p.definedAgents[agentID]; ok { - return agent.Name - } - - return "" -} - -func backgroundAgentID(agentID string) string { - return ttmlBackgroundAgentPrefix + agentID -} - -func isBackgroundAgentID(agentID string) bool { - return strings.HasPrefix(agentID, ttmlBackgroundAgentPrefix) -} - -func contextHasRole(roles string, role string) bool { - lowerRole := strings.ToLower(role) - for _, candidate := range strings.Fields(strings.ToLower(roles)) { - if candidate == lowerRole { - return true - } - } - return false -} - -func appendTTMLRoles(existing string, roles string) string { - for _, role := range strings.Fields(roles) { - if contextHasRole(existing, role) { - continue - } - if existing == "" { - existing = role - } else { - existing += " " + role - } - } - return existing -} - -func (p *ttmlParser) addMainLine(lang string, lineKey string, line Line) { - lang = normalizeLyricLang(lang) - if _, ok := p.mainLinesByLang[lang]; !ok { - p.mainLangOrder = append(p.mainLangOrder, lang) - } - p.mainLinesByLang[lang] = append(p.mainLinesByLang[lang], line) - - lineKey = strings.TrimSpace(lineKey) - if lineKey != "" { - if _, exists := p.mainLineRefsByKey[lineKey]; !exists { - p.mainLineRefsByKey[lineKey] = ttmlLineRef{ - order: p.mainLineOrder, - line: line, - } - } - } - p.mainLineOrder++ -} - -func (p *ttmlParser) addMetadataEntry(kind string, lang string, entry ttmlMetadataEntry) { - lang = normalizeLyricLang(lang) - entry.seq = p.metadataSeq - p.metadataSeq++ - - switch kind { - case LyricKindTranslation: - if _, ok := p.translationEntriesByLg[lang]; !ok { - p.translationLangOrder = append(p.translationLangOrder, lang) - } - p.translationEntriesByLg[lang] = append(p.translationEntriesByLg[lang], entry) - case LyricKindPronunciation: - if _, ok := p.pronunciationEntriesByLg[lang]; !ok { - p.pronunciationLangOrder = append(p.pronunciationLangOrder, lang) - } - p.pronunciationEntriesByLg[lang] = append(p.pronunciationEntriesByLg[lang], entry) - } -} - -func (p *ttmlParser) childContext(attrs []xml.Attr, parent ttmlTimingContext) ttmlTimingContext { - ctx := parent - - if lang, ok := attrValue(attrs, "lang"); ok { - ctx.lang = normalizeLyricLang(lang) - } - if agentID, ok := attrValue(attrs, "agent"); ok { - ctx.agentID = strings.TrimSpace(agentID) - } - if role, ok := attrValue(attrs, "role"); ok { - role = strings.TrimSpace(role) - if role != "" { - ctx.role = appendTTMLRoles(ctx.role, role) - } - } - - beginExpr, hasBegin := attrValue(attrs, "begin") - endExpr, hasEnd := attrValue(attrs, "end") - durExpr, hasDur := attrValue(attrs, "dur") - - if hasBegin { - begin, kind, ok := parseTTMLTimeExpression(beginExpr, p.params) - if !ok { - ctx.invalid = true - return ctx - } - - base := int64(0) - if parent.hasBegin { - base = parent.begin - } - ctx.begin = resolveTTMLTime(begin, kind, base, parent) - ctx.hasBegin = true - } else { - ctx.begin = parent.begin - ctx.hasBegin = parent.hasBegin - } - - var calculatedEnd int64 - calculatedHasEnd := false - - if hasEnd { - end, kind, ok := parseTTMLTimeExpression(endExpr, p.params) - if !ok { - ctx.invalid = true - return ctx - } - - base := ctx.begin - if !ctx.hasBegin { - base = parent.begin - } - calculatedEnd = resolveTTMLTime(end, kind, base, parent) - calculatedHasEnd = true - } - - if hasDur { - dur, ok := parseTTMLDurationExpression(durExpr, p.params) - if !ok { - ctx.invalid = true - return ctx - } - if ctx.hasBegin { - durEnd := ctx.begin + dur - if !calculatedHasEnd || durEnd < calculatedEnd { - calculatedEnd = durEnd - calculatedHasEnd = true - } - } - } - - if !calculatedHasEnd && parent.hasEnd { - calculatedEnd = parent.end - calculatedHasEnd = true - } - - ctx.end = calculatedEnd - ctx.hasEnd = calculatedHasEnd - return ctx -} - -func (p *ttmlParser) updateTimingParams(attrs []xml.Attr) { - frameRate := p.params.frameRate - if value, ok := attrValue(attrs, "frameRate"); ok { - if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 { - frameRate = parsed - } - } - - if value, ok := attrValue(attrs, "frameRateMultiplier"); ok { - parts := strings.Fields(value) - if len(parts) == 2 { - numerator, errA := strconv.ParseFloat(parts[0], 64) - denominator, errB := strconv.ParseFloat(parts[1], 64) - if errA == nil && errB == nil && denominator > 0 { - frameRate = frameRate * (numerator / denominator) - } - } - } - - subFrameRate := p.params.subFrameRate - if value, ok := attrValue(attrs, "subFrameRate"); ok { - if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 { - subFrameRate = parsed - } - } - - tickRate := p.params.tickRate - if value, ok := attrValue(attrs, "tickRate"); ok { - if parsed, err := strconv.ParseFloat(value, 64); err == nil && parsed > 0 { - tickRate = parsed - } - } - - p.params.frameRate = gg.If(frameRate > 0, frameRate, defaultTTMLFrameRate) - p.params.subFrameRate = gg.If(subFrameRate > 0, subFrameRate, defaultTTMLSubFrameRate) - p.params.tickRate = gg.If(tickRate > 0, tickRate, defaultTTMLTickRate) -} - -func parseTTMLDurationExpression(expr string, params ttmlTimingParams) (int64, bool) { - value, _, ok := parseTTMLTimeExpression(expr, params) - return value, ok -} - -func resolveTTMLTime(value int64, kind ttmlTimeKind, base int64, parent ttmlTimingContext) int64 { - switch kind { - case ttmlTimeAbsolute: - return value - case ttmlTimeOffset: - return base + value - case ttmlTimeAmbiguous: - absolute := value - offset := base + value - - // No parent timing context → no reference frame for offsets. - // Prefer absolute when offset differs (i.e., base > 0). - if !parent.hasBegin && !parent.hasEnd && base != 0 { - return absolute - } - - if parent.hasBegin && parent.hasEnd { - absoluteInParent := absolute >= parent.begin && absolute <= parent.end - offsetInParent := offset >= parent.begin && offset <= parent.end - if absoluteInParent && !offsetInParent { - return absolute - } - if offsetInParent && !absoluteInParent { - return offset - } - } - - if parent.hasBegin { - if absolute < parent.begin && offset >= parent.begin { - return offset - } - if absolute >= parent.begin && offset > absolute { - return absolute - } - } - return offset - default: - return base + value - } -} - -func parseTTMLTimeExpression(expr string, params ttmlTimingParams) (int64, ttmlTimeKind, bool) { - expr = strings.TrimSpace(expr) - if expr == "" { - return 0, ttmlTimeOffset, false - } - - lower := strings.ToLower(expr) - if strings.Contains(lower, "wallclock(") || - strings.Contains(lower, ".begin") || - strings.Contains(lower, ".end") { - log.Warn("Unsupported TTML time expression", "value", expr) - return 0, ttmlTimeOffset, false - } - - // Best-effort support for non-standard TTML seen in the wild where a - // bare decimal value is used (implicitly seconds), e.g. "0.170". - if value, err := strconv.ParseFloat(lower, 64); err == nil && value >= 0 { - return int64(math.Round(value * 1000)), ttmlTimeAmbiguous, true - } - - if matches := offsetTimeRegex.FindStringSubmatch(lower); len(matches) == 3 { - value, err := strconv.ParseFloat(matches[1], 64) - if err != nil { - return 0, ttmlTimeOffset, false - } - - unit := matches[2] - seconds := 0.0 - switch unit { - case "h": - seconds = value * 60 * 60 - case "m": - seconds = value * 60 - case "s": - seconds = value - case "ms": - seconds = value / 1000 - case "f": - seconds = value / params.frameRate - case "t": - seconds = value / params.tickRate - default: - return 0, ttmlTimeOffset, false - } - - return int64(math.Round(seconds * 1000)), ttmlTimeOffset, true - } - - colonCount := strings.Count(expr, ":") - switch colonCount { - case 1, 2: - clockMs, ok := parseTTMLClockTime(expr) - if !ok { - return 0, ttmlTimeAbsolute, false - } - return clockMs, ttmlTimeAbsolute, true - case 3: - framesMs, ok := parseTTMLFrameTime(expr, params) - if !ok { - return 0, ttmlTimeAbsolute, false - } - return framesMs, ttmlTimeAbsolute, true - default: - log.Warn("Unsupported TTML time expression", "value", expr) - return 0, ttmlTimeOffset, false - } -} - -func parseTTMLClockTime(value string) (int64, bool) { - parts := strings.Split(value, ":") - if len(parts) != 2 && len(parts) != 3 { - return 0, false - } - - hours := int64(0) - minutesIdx := 0 - if len(parts) == 3 { - h, err := strconv.ParseInt(parts[0], 10, 64) - if err != nil { - return 0, false - } - hours = h - minutesIdx = 1 - } - - minutes, err := strconv.ParseInt(parts[minutesIdx], 10, 64) - if err != nil { - return 0, false - } - - seconds, err := strconv.ParseFloat(parts[minutesIdx+1], 64) - if err != nil { - return 0, false - } - - totalSeconds := float64(hours*60*60+minutes*60) + seconds - return int64(math.Round(totalSeconds * 1000)), true -} - -func parseTTMLFrameTime(value string, params ttmlTimingParams) (int64, bool) { - parts := strings.Split(value, ":") - if len(parts) != 4 { - return 0, false - } - - hours, err := strconv.ParseInt(parts[0], 10, 64) - if err != nil { - return 0, false - } - - minutes, err := strconv.ParseInt(parts[1], 10, 64) - if err != nil { - return 0, false - } - - seconds, err := strconv.ParseInt(parts[2], 10, 64) - if err != nil { - return 0, false - } - - frameParts := strings.SplitN(parts[3], ".", 2) - frames, err := strconv.ParseFloat(frameParts[0], 64) - if err != nil { - return 0, false - } - - subFrames := 0.0 - if len(frameParts) == 2 { - subFrames, err = strconv.ParseFloat(frameParts[1], 64) - if err != nil { - return 0, false - } - } - - totalSeconds := float64(hours*60*60 + minutes*60 + seconds) - totalSeconds += frames / params.frameRate - totalSeconds += subFrames / (params.subFrameRate * params.frameRate) - - return int64(math.Round(totalSeconds * 1000)), true -} - -func attrValue(attrs []xml.Attr, key string) (string, bool) { - for _, attr := range attrs { - if strings.EqualFold(attr.Name.Local, key) { - return strings.TrimSpace(attr.Value), true - } - } - return "", false -} - -func attrOrEmpty(attrs []xml.Attr, key string) string { - value, _ := attrValue(attrs, key) - return value -} - -func (p *ttmlParser) collectElementText(start xml.StartElement) (string, error) { - var text strings.Builder - - for { - token, err := p.decoder.Token() - if err != nil { - return "", err - } - - switch t := token.(type) { - case xml.StartElement: - value, err := p.collectElementText(t) - if err != nil { - return "", err - } - text.WriteString(value) - case xml.EndElement: - if strings.EqualFold(t.Name.Local, start.Name.Local) { - return text.String(), nil - } - case xml.CharData: - text.WriteString(string(t)) - } - } -} - -func (p *ttmlParser) skipElement(_ xml.StartElement) error { - depth := 1 - for depth > 0 { - token, err := p.decoder.Token() - if err != nil { - return err - } - - switch token.(type) { - case xml.StartElement: - depth++ - case xml.EndElement: - depth-- - } - } - return nil -} - -func normalizeLyricLang(lang string) string { - lang = strings.ToLower(strings.TrimSpace(lang)) - if lang == "" { - return "xxx" - } - return lang -} - -func sanitizeTTMLText(raw string) string { - raw = str.SanitizeText(raw) - raw = strings.ReplaceAll(raw, "\r\n", "\n") - raw = strings.ReplaceAll(raw, "\r", "\n") - - lines := strings.Split(raw, "\n") - for i := range lines { - lines[i] = strings.TrimSpace(lines[i]) - } - return strings.TrimSpace(strings.Join(lines, "\n")) -} - -func linesAreSynced(lines []Line) bool { - for i := range lines { - if lines[i].Start != nil { - return true - } - for j := range lines[i].Cue { - if lines[i].Cue[j].Start != nil { - return true - } - } - } - return false -} diff --git a/model/lyrics_ttml_test.go b/model/lyrics_ttml_test.go deleted file mode 100644 index bbdf5c7c4..000000000 --- a/model/lyrics_ttml_test.go +++ /dev/null @@ -1,551 +0,0 @@ -package model - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("parseTTML", func() { - Describe("Multi-language and timing", func() { - It("should parse multiple language divs with inherited offsets and frame/tick timing", func() { - content := []byte(` - - -
-

Line one

-

Line two
with break

-
-
-

Linha

-
- -
`) - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(2)) - - By("parsing the English track") - eng := list[0] - Expect(eng.Lang).To(Equal("eng")) - Expect(eng.Synced).To(BeTrue()) - Expect(eng.Line[0].Start).To(Equal(new(int64(3000)))) - Expect(eng.Line[0].Value).To(Equal("Line one")) - Expect(eng.Line[1].Start).To(Equal(new(int64(4517)))) - Expect(eng.Line[1].Value).To(Equal("Line two\nwith break")) - - By("parsing the Portuguese track") - por := list[1] - Expect(por.Lang).To(Equal("por")) - Expect(por.Line[0].Start).To(Equal(new(int64(4500)))) - Expect(por.Line[0].Value).To(Equal("Linha")) - }) - }) - - Describe("Unsupported cue handling", func() { - It("should skip wallclock cues and keep valid ones", func() { - content := []byte(` - - -
-

Skip me

-

Keep me

-
- -
`) - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line).To(HaveLen(1)) - Expect(list[0].Line[0].Start).To(Equal(new(int64(1000)))) - Expect(list[0].Line[0].Value).To(Equal("Keep me")) - }) - }) - - Describe("Begin/End/Dur with inheritance", func() { - It("should correctly accumulate nested timing from body, div, and p elements", func() { - content := []byte(` - - -
-

First line

-

Second line

-
- -
`) - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Lang).To(Equal("eng")) - Expect(list[0].Line).To(HaveLen(2)) - Expect(list[0].Line[0].Start).To(Equal(new(int64(16000)))) - Expect(list[0].Line[0].Value).To(Equal("First line")) - Expect(list[0].Line[1].Start).To(Equal(new(int64(18000)))) - Expect(list[0].Line[1].Value).To(Equal("Second line")) - }) - }) - - Describe("Non-standard bare second offsets", func() { - It("should parse bare decimal numbers as seconds", func() { - content := []byte(` - - -
-

First line

-

Second line

-
- -
`) - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line).To(HaveLen(2)) - Expect(list[0].Line[0].Start).To(Equal(new(int64(10170)))) - Expect(list[0].Line[0].Value).To(Equal("First line")) - Expect(list[0].Line[1].Start).To(Equal(new(int64(13710)))) - Expect(list[0].Line[1].Value).To(Equal("Second line")) - }) - }) - - Describe("Word timing tokens", func() { - It("should extract timed tokens from spans including background role", func() { - content := []byte(` - - -
-

- Hello - echo -

-
- -
`) - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Agents).To(Equal([]Agent{ - {ID: "main", Role: "main"}, - {ID: "__nd_bg__|main", Role: "bg"}, - })) - Expect(list[0].Line).To(HaveLen(1)) - - line := list[0].Line[0] - Expect(line.Start).To(Equal(new(int64(1000)))) - Expect(line.Value).To(Equal("Hello echo")) - Expect(line.End).To(Equal(new(int64(3000)))) - Expect(line.Cue).To(HaveLen(3)) - - Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(1000)), End: new(int64(1400)), Value: "He", ByteStart: 0, ByteEnd: 1, AgentID: "main"})) - Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(1400)), End: new(int64(1800)), Value: "llo", ByteStart: 2, ByteEnd: 4, AgentID: "main"})) - Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(2000)), End: new(int64(2500)), Value: "echo", ByteStart: 6, ByteEnd: 9, AgentID: "__nd_bg__|main"})) - }) - - It("should append role tokens exactly instead of using substring matches", func() { - content := []byte(` - - -
-

LeadEcho

-
- -
`) - - list, err := parseTTML("xxx", content) - - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Agents).To(Equal([]Agent{ - {ID: "main", Role: "main"}, - {ID: "__nd_bg__|main", Role: "bg"}, - })) - Expect(list[0].Line).To(HaveLen(1)) - Expect(list[0].Line[0].Cue).To(HaveLen(2)) - Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("main")) - Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("__nd_bg__|main")) - }) - - It("should parse named TTML agents into main, voice, and group roles", func() { - content := []byte(` - - - - Chris Martin - Jin - All - - - -
-

You

-

and

-

All

-
- -
`) - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Agents).To(Equal([]Agent{ - {ID: "v1", Role: "main", Name: "Chris Martin"}, - {ID: "v2", Role: "voice", Name: "Jin"}, - {ID: "v1000", Role: "group", Name: "All"}, - })) - Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("v1")) - Expect(list[0].Line[1].Cue[0].AgentID).To(Equal("v2")) - Expect(list[0].Line[2].Cue[0].AgentID).To(Equal("v1000")) - }) - - It("should avoid collisions between derived background agents and explicit TTML agent ids", func() { - content := []byte(` - - - - Lead - Existing Background Id - - - -
-

- Lead - Echo -

-

- Named -

-
- -
`) - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Agents).To(Equal([]Agent{ - {ID: "lead", Role: "main", Name: "Lead"}, - {ID: "__nd_bg__|lead", Role: "bg", Name: "Lead"}, - {ID: "lead__bg", Role: "voice", Name: "Existing Background Id"}, - })) - Expect(list[0].Line).To(HaveLen(2)) - Expect(list[0].Line[0].Cue).To(HaveLen(2)) - Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("lead")) - Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("__nd_bg__|lead")) - Expect(list[0].Line[1].Cue).To(HaveLen(1)) - Expect(list[0].Line[1].Cue[0].AgentID).To(Equal("lead__bg")) - }) - - It("should fill missing cue agent ids with the resolved main agent", func() { - content := []byte(` - - - - Guest Vocal - - - -
-

- Lead - Guest -

-
- -
`) - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Agents).To(Equal([]Agent{ - {ID: "guest", Role: "main", Name: "Guest Vocal"}, - })) - Expect(list[0].Line).To(HaveLen(1)) - Expect(list[0].Line[0].Cue).To(HaveLen(2)) - Expect(list[0].Line[0].Cue[0].AgentID).To(Equal("guest")) - Expect(list[0].Line[0].Cue[1].AgentID).To(Equal("guest")) - }) - }) - - Describe("Whitespace handling", func() { - It("should collapse pretty-print indentation between spans into single spaces, not line breaks", func() { - content := []byte(` - - -
-

- It - in, - - (When you - slide) - -

-
- -
`) - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line).To(HaveLen(1)) - - line := list[0].Line[0] - Expect(line.Value).To(Equal("It in, (When you slide)")) - Expect(line.Value).ToNot(ContainSubstring("\n")) - Expect(line.Cue).To(HaveLen(4)) - Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(82889)), End: new(int64(83127)), Value: "It", ByteStart: 0, ByteEnd: 1, AgentID: "v2"})) - Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(83374)), End: new(int64(83938)), Value: "in,", ByteStart: 3, ByteEnd: 5, AgentID: "v2"})) - Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(83881)), End: new(int64(84243)), Value: "(When you", ByteStart: 7, ByteEnd: 15, AgentID: "__nd_bg__|v2"})) - Expect(line.Cue[3]).To(Equal(Cue{Start: new(int64(86232)), End: new(int64(86859)), Value: "slide)", ByteStart: 17, ByteEnd: 22, AgentID: "__nd_bg__|v2"})) - }) - - It("should preserve explicit
as a line break", func() { - content := []byte(` - - -
-

- first -
- second -

-
- -
`) - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line).To(HaveLen(1)) - Expect(list[0].Line[0].Value).To(Equal("first\nsecond")) - }) - - It("should only collapse XML whitespace, leaving other Unicode spaces intact", func() { - // Whitespace collapsing only touches the XML S characters - // (space/tab/CR/LF). Other Unicode spaces like U+3000 are left as-is: - // the U+3000 inside a span survives, while the pretty-print newline - // between spans still collapses to a single space. - content := []byte("\n" + - "\n" + - " \n" + - "
\n" + - "

\n" + - " あ い\n" + - " う\n" + - "

\n" + - "
\n" + - " \n" + - "
") - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line).To(HaveLen(1)) - Expect(list[0].Line[0].Value).To(Equal("あ い う")) - Expect(list[0].Line[0].Cue[0].Value).To(Equal("あ い")) - }) - }) - - Describe("Interleaved background cue timing", func() { - It("should not corrupt a main cue's end time when a background cue is earlier in time", func() { - // Background spans (x-bg) appear after the main spans in document order - // but their timings interleave with the main timeline. End-time - // normalization must be per agent so the last main cue keeps its real - // end instead of collapsing to its own start. - content := []byte(` - - -
-

real slow (When you slide)

-
- -
`) - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line).To(HaveLen(1)) - - line := list[0].Line[0] - Expect(line.Cue).To(HaveLen(4)) - - cuesByAgent := map[string][]Cue{} - for _, c := range line.Cue { - cuesByAgent[c.AgentID] = append(cuesByAgent[c.AgentID], c) - } - - mainCues := cuesByAgent["v2"] - Expect(mainCues).To(HaveLen(2)) - Expect(*mainCues[0].End).To(Equal(int64(85934))) // "real" - // "slow" must keep its real end (86751), not collapse to its start. - Expect(*mainCues[1].Start).To(Equal(int64(85934))) - Expect(*mainCues[1].End).To(Equal(int64(86751))) - - bgCues := cuesByAgent["__nd_bg__|v2"] - Expect(bgCues).To(HaveLen(2)) - Expect(*bgCues[0].End).To(Equal(int64(84243))) // "(When you" - Expect(*bgCues[1].End).To(Equal(int64(86859))) // "slide)" - }) - }) - - Describe("Ambiguous decimal timing", func() { - It("should prefer absolute timing when values fall inside parent window", func() { - content := []byte(` - - -
-

- go - go -

-
- -
`) - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Line).To(HaveLen(1)) - - line := list[0].Line[0] - Expect(line.Start).To(Equal(new(int64(43444)))) - Expect(line.Value).To(Equal("go go")) - Expect(line.End).To(Equal(new(int64(45570)))) - Expect(line.Cue).To(HaveLen(2)) - Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(43444)), End: new(int64(43716)), Value: "go", ByteStart: 0, ByteEnd: 1})) - Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(43716)), End: new(int64(43887)), Value: "go", ByteStart: 3, ByteEnd: 4})) - }) - }) - - Describe("Unsynced fallback", func() { - It("should return unsynced lyrics when no timing is present", func() { - content := []byte(` - - -
-

No timing here

-
- -
`) - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(1)) - Expect(list[0].Lang).To(Equal("xxx")) - Expect(list[0].Synced).To(BeFalse()) - Expect(list[0].Line).To(HaveLen(1)) - Expect(list[0].Line[0].Start).To(BeNil()) - Expect(list[0].Line[0].Value).To(Equal("No timing here")) - }) - }) - - Describe("Metadata tracks", func() { - It("should produce main, translation, and pronunciation tracks from iTunesMetadata", func() { - content := []byte(` - - - - - - - Hola - Skip me - - - - - konni - - - - - - -
-

こんにちは

-

こんばんは

-
- -
`) - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - Expect(list).To(HaveLen(3)) - - By("checking the main track") - main := list[0] - Expect(main.Kind).To(Equal("main")) - Expect(main.Lang).To(Equal("ja")) - Expect(main.Line).To(HaveLen(2)) - - By("checking the translation track") - translation := list[1] - Expect(translation.Kind).To(Equal("translation")) - Expect(translation.Lang).To(Equal("es")) - Expect(translation.Line).To(HaveLen(1)) - Expect(translation.Line[0].Start).To(Equal(new(int64(1000)))) - Expect(translation.Line[0].Value).To(Equal("Hola")) - Expect(translation.Line[0].End).To(Equal(new(int64(1500)))) - - By("checking the pronunciation track") - pronunciation := list[2] - Expect(pronunciation.Kind).To(Equal("pronunciation")) - Expect(pronunciation.Lang).To(Equal("ja-latn")) - Expect(pronunciation.Line).To(HaveLen(1)) - Expect(pronunciation.Line[0].Start).To(Equal(new(int64(2000)))) - Expect(pronunciation.Line[0].Value).To(Equal("konni")) - Expect(pronunciation.Line[0].End).To(Equal(new(int64(2600)))) - Expect(pronunciation.Line[0].Cue).To(HaveLen(2)) - Expect(pronunciation.Line[0].Cue[0]).To(Equal(Cue{Start: new(int64(2000)), End: new(int64(2300)), Value: "ko", ByteStart: 0, ByteEnd: 1})) - Expect(pronunciation.Line[0].Cue[1]).To(Equal(Cue{Start: new(int64(2300)), End: new(int64(2600)), Value: "nni", ByteStart: 2, ByteEnd: 4})) - }) - }) - - Describe("Pronunciation with bare decimal end times", func() { - It("should correctly parse bare decimal times in transliteration spans", func() { - content := []byte(` - - - - - - - I woke up - - - - - - -
-

起きた

-
- -
`) - - list, err := parseTTML("xxx", content) - Expect(err).ToNot(HaveOccurred()) - - var pronunciation *Lyrics - for i := range list { - if list[i].Kind == "pronunciation" { - pronunciation = &list[i] - break - } - } - Expect(pronunciation).ToNot(BeNil()) - Expect(pronunciation.Line).To(HaveLen(1)) - - line := pronunciation.Line[0] - Expect(line.Start).To(Equal(new(int64(2747)))) - Expect(line.Value).To(Equal("I woke up")) - Expect(line.Cue).To(HaveLen(3)) - Expect(line.Cue[0]).To(Equal(Cue{Start: new(int64(2747)), End: new(int64(3018)), Value: "I", ByteStart: 0, ByteEnd: 0})) - Expect(line.Cue[1]).To(Equal(Cue{Start: new(int64(3018)), End: new(int64(3179)), Value: "woke", ByteStart: 2, ByteEnd: 5})) - Expect(line.Cue[2]).To(Equal(Cue{Start: new(int64(3179)), End: new(int64(3582)), Value: "up", ByteStart: 7, ByteEnd: 8})) - }) - }) -}) diff --git a/model/mediafile.go b/model/mediafile.go index 0c56b4825..891193e80 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -1,133 +1,78 @@ package model import ( - "cmp" - "context" - "encoding/json" - "fmt" - "iter" "mime" "path/filepath" - "slices" + "sort" "strings" "time" - "github.com/deluan/rest" - "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/slice" - "github.com/zeebo/xxh3" + "golang.org/x/exp/slices" ) type MediaFile struct { - Annotations `structs:"-" hash:"ignore"` - Bookmarkable `structs:"-" hash:"ignore"` - ItemImage `structs:"-" hash:"ignore"` + Annotations `structs:"-"` + Bookmarkable `structs:"-"` - // AlbumImage is the parent album's artwork state, hydrated alongside the track's own so a - // song's Jellyfin album-art tag can be pixel-versioned without a second query. - AlbumImage ItemImage `structs:"-" json:"-" hash:"ignore"` + ID string `structs:"id" json:"id" orm:"pk;column(id)"` + Path string `structs:"path" json:"path"` + Title string `structs:"title" json:"title"` + Album string `structs:"album" json:"album"` + ArtistID string `structs:"artist_id" json:"artistId" orm:"pk;column(artist_id)"` + Artist string `structs:"artist" json:"artist"` + AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId" orm:"pk;column(album_artist_id)"` + AlbumArtist string `structs:"album_artist" json:"albumArtist"` + AlbumID string `structs:"album_id" json:"albumId" orm:"pk;column(album_id)"` + HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"` + TrackNumber int `structs:"track_number" json:"trackNumber"` + DiscNumber int `structs:"disc_number" json:"discNumber"` + DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"` + Year int `structs:"year" json:"year"` + Date string `structs:"date" json:"date,omitempty"` + OriginalYear int `structs:"original_year" json:"originalYear"` + OriginalDate string `structs:"original_date" json:"originalDate,omitempty"` + ReleaseYear int `structs:"release_year" json:"releaseYear"` + ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"` + Size int64 `structs:"size" json:"size"` + Suffix string `structs:"suffix" json:"suffix"` + Duration float32 `structs:"duration" json:"duration"` + BitRate int `structs:"bit_rate" json:"bitRate"` + Channels int `structs:"channels" json:"channels"` + Genre string `structs:"genre" json:"genre"` + Genres Genres `structs:"-" json:"genres"` + FullText string `structs:"full_text" json:"fullText"` + SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"` + SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"` + SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"` + SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` + OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"` + OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"` + OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"` + OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` + Compilation bool `structs:"compilation" json:"compilation"` + Comment string `structs:"comment" json:"comment,omitempty"` + Lyrics string `structs:"lyrics" json:"lyrics,omitempty"` + Bpm int `structs:"bpm" json:"bpm,omitempty"` + CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"` + MbzTrackID string `structs:"mbz_track_id" json:"mbzTrackId,omitempty" orm:"column(mbz_track_id)"` + MbzReleaseTrackID string `structs:"mbz_release_track_id" json:"mbzReleaseTrackId,omitempty" orm:"column(mbz_release_track_id)"` + MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty" orm:"column(mbz_album_id)"` + MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty" orm:"column(mbz_artist_id)"` + MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty" orm:"column(mbz_album_artist_id)"` + MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"` + MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"` + RGAlbumGain float64 `structs:"rg_album_gain" json:"rgAlbumGain" orm:"column(rg_album_gain)"` + RGAlbumPeak float64 `structs:"rg_album_peak" json:"rgAlbumPeak" orm:"column(rg_album_peak)"` + RGTrackGain float64 `structs:"rg_track_gain" json:"rgTrackGain" orm:"column(rg_track_gain)"` + RGTrackPeak float64 `structs:"rg_track_peak" json:"rgTrackPeak" orm:"column(rg_track_peak)"` - ID string `structs:"id" json:"id" hash:"ignore"` - PID string `structs:"pid" json:"-" hash:"ignore"` - LibraryID int `structs:"library_id" json:"libraryId" hash:"ignore"` - LibraryPath string `structs:"-" json:"libraryPath" hash:"ignore"` - LibraryName string `structs:"-" json:"libraryName" hash:"ignore"` - FolderID string `structs:"folder_id" json:"folderId" hash:"ignore"` - Path string `structs:"path" json:"path" hash:"ignore"` - Title string `structs:"title" json:"title"` - Album string `structs:"album" json:"album"` - ArtistID string `structs:"artist_id" json:"artistId"` // Deprecated: Use Participants instead - // Artist is the display name used for the artist. - Artist string `structs:"artist" json:"artist"` - AlbumArtistID string `structs:"album_artist_id" json:"albumArtistId"` // Deprecated: Use Participants instead - // AlbumArtist is the display name used for the album artist. - AlbumArtist string `structs:"album_artist" json:"albumArtist"` - AlbumID string `structs:"album_id" json:"albumId" hash:"ignore"` - HasCoverArt bool `structs:"has_cover_art" json:"hasCoverArt"` - TrackNumber int `structs:"track_number" json:"trackNumber"` - DiscNumber int `structs:"disc_number" json:"discNumber"` - DiscSubtitle string `structs:"disc_subtitle" json:"discSubtitle,omitempty"` - Year int `structs:"year" json:"year"` - Date string `structs:"date" json:"date,omitempty"` - OriginalYear int `structs:"original_year" json:"originalYear"` - OriginalDate string `structs:"original_date" json:"originalDate,omitempty"` - ReleaseYear int `structs:"release_year" json:"releaseYear"` - ReleaseDate string `structs:"release_date" json:"releaseDate,omitempty"` - Size int64 `structs:"size" json:"size"` - Suffix string `structs:"suffix" json:"suffix"` - Duration float32 `structs:"duration" json:"duration"` - BitRate int `structs:"bit_rate" json:"bitRate"` - SampleRate int `structs:"sample_rate" json:"sampleRate"` - BitDepth *int `structs:"bit_depth" json:"bitDepth,omitempty"` - Channels int `structs:"channels" json:"channels"` - Codec string `structs:"codec" json:"codec"` - ProbeData string `structs:"probe_data" json:"-" hash:"ignore"` - Genre string `structs:"genre" json:"genre"` - Genres Genres `structs:"-" json:"genres,omitempty"` - SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"` - SortAlbumName string `structs:"sort_album_name" json:"sortAlbumName,omitempty"` - SortArtistName string `structs:"sort_artist_name" json:"sortArtistName,omitempty"` // Deprecated: Use Participants instead - SortAlbumArtistName string `structs:"sort_album_artist_name" json:"sortAlbumArtistName,omitempty"` // Deprecated: Use Participants instead - OrderTitle string `structs:"order_title" json:"orderTitle,omitempty"` - OrderAlbumName string `structs:"order_album_name" json:"orderAlbumName"` - OrderArtistName string `structs:"order_artist_name" json:"orderArtistName"` // Deprecated: Use Participants instead - OrderAlbumArtistName string `structs:"order_album_artist_name" json:"orderAlbumArtistName"` // Deprecated: Use Participants instead - Compilation bool `structs:"compilation" json:"compilation"` - Comment string `structs:"comment" json:"comment,omitempty"` - Lyrics string `structs:"lyrics" json:"lyrics"` - BPM *int `structs:"bpm" json:"bpm,omitempty"` - ExplicitStatus string `structs:"explicit_status" json:"explicitStatus"` - CatalogNum string `structs:"catalog_num" json:"catalogNum,omitempty"` - MbzRecordingID string `structs:"mbz_recording_id" json:"mbzRecordingID,omitempty"` - MbzReleaseTrackID string `structs:"mbz_release_track_id" json:"mbzReleaseTrackId,omitempty"` - MbzAlbumID string `structs:"mbz_album_id" json:"mbzAlbumId,omitempty"` - MbzReleaseGroupID string `structs:"mbz_release_group_id" json:"mbzReleaseGroupId,omitempty"` - MbzArtistID string `structs:"mbz_artist_id" json:"mbzArtistId,omitempty"` // Deprecated: Use Participants instead - MbzAlbumArtistID string `structs:"mbz_album_artist_id" json:"mbzAlbumArtistId,omitempty"` // Deprecated: Use Participants instead - MbzAlbumType string `structs:"mbz_album_type" json:"mbzAlbumType,omitempty"` - MbzAlbumComment string `structs:"mbz_album_comment" json:"mbzAlbumComment,omitempty"` - RGAlbumGain *float64 `structs:"rg_album_gain" json:"rgAlbumGain"` - RGAlbumPeak *float64 `structs:"rg_album_peak" json:"rgAlbumPeak"` - RGTrackGain *float64 `structs:"rg_track_gain" json:"rgTrackGain"` - RGTrackPeak *float64 `structs:"rg_track_peak" json:"rgTrackPeak"` - - Tags Tags `structs:"tags" json:"tags,omitempty" hash:"ignore"` // All imported tags from the original file - Participants Participants `structs:"participants" json:"participants" hash:"ignore"` // All artists that participated in this track - - Missing bool `structs:"missing" json:"missing" hash:"ignore"` // If the file is not found in the library's FS - BirthTime time.Time `structs:"birth_time" json:"birthTime" hash:"ignore"` // Time of file creation (ctime) - CreatedAt time.Time `structs:"created_at" json:"createdAt" hash:"ignore"` // Time this entry was created in the DB - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt" hash:"ignore"` // Time of file last update (mtime) -} - -func (mf MediaFile) FullTitle() string { - if s := mf.Tags.First(TagSubtitle); conf.Server.Subsonic.AppendSubtitle && s != "" { - return appendSuffix(mf.Title, s) - } - return mf.Title -} - -func (mf MediaFile) FullAlbumName() string { - if v := mf.Tags.First(TagAlbumVersion); conf.Server.Subsonic.AppendAlbumVersion && v != "" { - return appendSuffix(mf.Album, v) - } - return mf.Album -} - -var bracketPairs = map[byte]byte{'(': ')', '[': ']', '{': '}', '<': '>'} - -func appendSuffix(base, suffix string) string { - suffix = strings.TrimSpace(suffix) - if len(suffix) >= 2 && bracketPairs[suffix[0]] == suffix[len(suffix)-1] { - return base + " " + suffix - } - return base + " (" + suffix + ")" + CreatedAt time.Time `structs:"created_at" json:"createdAt"` // Time this entry was created in the DB + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // Time of file last update (mtime) } func (mf MediaFile) ContentType() string { @@ -139,216 +84,51 @@ func (mf MediaFile) CoverArtID() ArtworkID { if mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt { return artworkIDFromMediaFile(mf) } - // Otherwise fallback to disc (if available) or album cover - return mf.DiscCoverArtID() -} - -// DiscCoverArtID returns the disc artwork ID when the media file has a disc number, -// otherwise it returns the album artwork ID. -func (mf MediaFile) DiscCoverArtID() ArtworkID { - if mf.DiscNumber > 0 { - return ArtworkID{Kind: KindDiscArtwork, ID: DiscArtworkID(mf.AlbumID, mf.DiscNumber), Hash: mf.ImageHash} - } + // if it does not have a coverArt, fallback to the album cover return mf.AlbumCoverArtID() } -// AlbumCoverArtID uses AlbumImage, not the track's own ItemImage: an album id must carry the -// album's content hash even when the track resolved art of its own. func (mf MediaFile) AlbumCoverArtID() ArtworkID { - return artworkIDFromAlbum(Album{ID: mf.AlbumID, ItemImage: mf.AlbumImage}) -} - -func (mf MediaFile) StructuredLyrics() (LyricList, error) { - var lyrics LyricList - err := json.Unmarshal([]byte(mf.Lyrics), &lyrics) - if err != nil { - return nil, err - } - return lyrics, nil -} - -// HasEmbeddedLyrics reports whether the lyrics column holds any lyrics. It is never "" post-scan; -// no-lyrics is normalized to the "[]" sentinel, so string emptiness alone is meaningless. -func (mf MediaFile) HasEmbeddedLyrics() bool { - return mf.Lyrics != "" && mf.Lyrics != "[]" -} - -// String is mainly used for debugging -func (mf MediaFile) String() string { - return mf.Path -} - -type Work struct { - Name string - MbzWorkID string -} - -type Movement struct { - Name string - Number int32 - Count int32 -} - -func (mf MediaFile) Works() []Work { - names := mf.Tags.Values(TagWork) - if len(names) == 0 { - return nil - } - ids := mf.Tags.Values(TagMusicBrainzWorkID) - works := make([]Work, 0, len(names)) - for i, name := range names { - w := Work{Name: name} - if i < len(ids) { - w.MbzWorkID = ids[i] - } - works = append(works, w) - } - return works -} - -func (mf MediaFile) Movements() []Movement { - names := mf.Tags.Values(TagMovementName) - if len(names) == 0 { - return nil - } - numbers := mf.Tags.Values(TagMovementNumber) - counts := mf.Tags.Values(TagMovementTotal) - movements := make([]Movement, 0, len(names)) - for i, name := range names { - m := Movement{Name: name} - if i < len(numbers) { - m.Number = number.ParseInt[int32](numbers[i]) - } - if i < len(counts) { - m.Count = number.ParseInt[int32](counts[i]) - } - movements = append(movements, m) - } - return movements -} - -// Hash returns a hash of the MediaFile based on its tags and audio properties -func (mf MediaFile) Hash() string { - opts := &hashstructure.HashOptions{ - IgnoreZeroValue: true, - ZeroNil: true, - } - hash, _ := hashstructure.Hash(mf, opts) - sum := xxh3.New() - sum.Write(fmt.Appendf(nil, "%d", hash)) - sum.Write(mf.Tags.Hash()) - sum.Write(mf.Participants.Hash()) - return fmt.Sprintf("%x", sum.Sum(nil)) -} - -// Equals compares two MediaFiles by their hash. It does not consider the ID, PID, Path and other identifier fields. -// Check the structure for the fields that are marked with `hash:"ignore"`. -func (mf MediaFile) Equals(other MediaFile) bool { - return mf.Hash() == other.Hash() -} - -// IsEquivalent compares two MediaFiles by path only. Used for matching missing tracks. -func (mf MediaFile) IsEquivalent(other MediaFile) bool { - return utils.BaseName(mf.Path) == utils.BaseName(other.Path) -} - -func (mf MediaFile) AbsolutePath() string { - return filepath.Join(mf.LibraryPath, mf.Path) -} - -// AudioCodec returns the audio codec for this file. -// Uses the stored Codec field if available, otherwise infers from Suffix and audio properties. -func (mf MediaFile) AudioCodec() string { - // If we have a stored codec from scanning, normalize and return it - if mf.Codec != "" { - return strings.ToLower(mf.Codec) - } - // Fallback: infer from Suffix + BitDepth - return mf.inferCodecFromSuffix() -} - -// inferCodecFromSuffix infers the codec from the file extension when Codec field is empty. -func (mf MediaFile) inferCodecFromSuffix() string { - switch strings.ToLower(mf.Suffix) { - case "mp3", "mpga": - return "mp3" - case "mp2": - return "mp2" - case "ogg", "oga": - return "vorbis" - case "opus": - return "opus" - case "mpc": - return "mpc" - case "wma": - return "wma" - case "flac": - return "flac" - case "wav": - return "pcm" - case "aif", "aiff", "aifc": - return "pcm" - case "ape": - return "ape" - case "wv", "wvp": - return "wv" - case "tta": - return "tta" - case "tak": - return "tak" - case "shn": - return "shn" - case "dsf", "dff": - return "dsd" - case "m4a": - // AAC if BitDepth==0, ALAC if BitDepth>0 - if gg.V(mf.BitDepth) > 0 { - return "alac" - } - return "aac" - case "m4b", "m4p", "m4r": - return "aac" - default: - return "" - } + return artworkIDFromAlbum(Album{ID: mf.AlbumID}) } type MediaFiles []MediaFile -// ToAlbum creates an Album object based on the attributes of this MediaFiles collection. -// It assumes all mediafiles have the same Album (same ID), or else results are unpredictable. -func (mfs MediaFiles) ToAlbum() Album { - if len(mfs) == 0 { - return Album{} +// Dirs returns a deduped list of all directories from the MediaFiles' paths +func (mfs MediaFiles) Dirs() []string { + var dirs []string + for _, mf := range mfs { + dir, _ := filepath.Split(mf.Path) + dirs = append(dirs, filepath.Clean(dir)) } - a := Album{SongCount: len(mfs), Tags: make(Tags), Participants: make(Participants), Discs: Discs{1: ""}} + slices.Sort(dirs) + return slices.Compact(dirs) +} - // Sorting the mediafiles ensure the results will be consistent - slices.SortFunc(mfs, func(a, b MediaFile) int { return cmp.Compare(a.Path, b.Path) }) - - mbzAlbumIds := make([]string, 0, len(mfs)) - mbzReleaseGroupIds := make([]string, 0, len(mfs)) - comments := make([]string, 0, len(mfs)) - years := make([]int, 0, len(mfs)) - dates := make([]string, 0, len(mfs)) - originalYears := make([]int, 0, len(mfs)) - originalDates := make([]string, 0, len(mfs)) - releaseDates := make([]string, 0, len(mfs)) - rgAlbumGains := make([]*float64, 0, len(mfs)) - rgAlbumPeaks := make([]*float64, 0, len(mfs)) - tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs)) - - a.Missing = true - embedArtPath := "" - embedArtDisc := 0 +// ToAlbum creates an Album object based on the attributes of this MediaFiles collection. +// It assumes all mediafiles have the same Album, or else results are unpredictable. +func (mfs MediaFiles) ToAlbum() Album { + a := Album{SongCount: len(mfs)} + var fullText []string + var albumArtistIds []string + var songArtistIds []string + var mbzAlbumIds []string + var comments []string + var years []int + var dates []string + var originalYears []int + var originalDates []string + var releaseDates []string for _, m := range mfs { - // We assume these attributes are all the same for all songs in an album + // We assume these attributes are all the same for all songs on an album a.ID = m.AlbumID - a.LibraryID = m.LibraryID a.Name = m.Album + a.Artist = m.Artist + a.ArtistID = m.ArtistID a.AlbumArtist = m.AlbumArtist a.AlbumArtistID = m.AlbumArtistID a.SortAlbumName = m.SortAlbumName + a.SortArtistName = m.SortArtistName a.SortAlbumArtistName = m.SortAlbumArtistName a.OrderAlbumName = m.OrderAlbumName a.OrderAlbumArtistName = m.OrderAlbumArtistName @@ -356,7 +136,7 @@ func (mfs MediaFiles) ToAlbum() Album { a.MbzAlbumType = m.MbzAlbumType a.MbzAlbumComment = m.MbzAlbumComment a.CatalogNum = m.CatalogNum - a.Compilation = a.Compilation || m.Compilation + a.Compilation = m.Compilation // Calculated attributes based on aggregations a.Duration += m.Duration @@ -366,97 +146,83 @@ func (mfs MediaFiles) ToAlbum() Album { originalYears = append(originalYears, m.OriginalYear) originalDates = append(originalDates, m.OriginalDate) releaseDates = append(releaseDates, m.ReleaseDate) - rgAlbumGains = append(rgAlbumGains, m.RGAlbumGain) - rgAlbumPeaks = append(rgAlbumPeaks, m.RGAlbumPeak) - comments = append(comments, m.Comment) - mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID) - mbzReleaseGroupIds = append(mbzReleaseGroupIds, m.MbzReleaseGroupID) - if m.DiscNumber > 0 { - a.Discs.Add(m.DiscNumber, m.DiscSubtitle) - } - tags = append(tags, m.Tags.FlattenAll()...) - a.Participants.Merge(m.Participants) - - // Find the MediaFile with cover art and the lowest disc number to use for album cover - embedArtPath, embedArtDisc = firstArtPath(embedArtPath, embedArtDisc, m) - - if m.ExplicitStatus == "c" && a.ExplicitStatus != "e" { - a.ExplicitStatus = "c" - } else if m.ExplicitStatus == "e" { - a.ExplicitStatus = "e" - } - a.UpdatedAt = newer(a.UpdatedAt, m.UpdatedAt) - a.CreatedAt = older(a.CreatedAt, m.BirthTime) - a.Missing = a.Missing && m.Missing + a.CreatedAt = older(a.CreatedAt, m.CreatedAt) + a.Genres = append(a.Genres, m.Genres...) + comments = append(comments, m.Comment) + albumArtistIds = append(albumArtistIds, m.AlbumArtistID) + songArtistIds = append(songArtistIds, m.ArtistID) + mbzAlbumIds = append(mbzAlbumIds, m.MbzAlbumID) + fullText = append(fullText, + m.Album, m.AlbumArtist, m.Artist, + m.SortAlbumName, m.SortAlbumArtistName, m.SortArtistName, + m.DiscSubtitle) + if m.HasCoverArt && a.EmbedArtPath == "" { + a.EmbedArtPath = m.Path + } } - a.EmbedArtPath = embedArtPath - a.SetTags(tags) - a.FolderIDs = slice.Unique(slice.Map(mfs, func(m MediaFile) string { return m.FolderID })) + a.Paths = strings.Join(mfs.Dirs(), consts.Zwsp) a.Date, _ = allOrNothing(dates) a.OriginalDate, _ = allOrNothing(originalDates) - a.ReleaseDate, _ = allOrNothing(releaseDates) + a.ReleaseDate, a.Releases = allOrNothing(releaseDates) a.MinYear, a.MaxYear = minMax(years) a.MinOriginalYear, a.MaxOriginalYear = minMax(originalYears) a.Comment, _ = allOrNothing(comments) + a.Comment, _ = allOrNothing(comments) + a.Genre = slice.MostFrequent(a.Genres).Name + slices.SortFunc(a.Genres, func(a, b Genre) bool { return a.ID < b.ID }) + a.Genres = slices.Compact(a.Genres) + a.FullText = " " + utils.SanitizeStrings(fullText...) + a = fixAlbumArtist(a, albumArtistIds) + songArtistIds = append(songArtistIds, a.AlbumArtistID, a.ArtistID) + slices.Sort(songArtistIds) + a.AllArtistIDs = strings.Join(slices.Compact(songArtistIds), " ") a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds) - a.MbzReleaseGroupID = slice.MostFrequent(mbzReleaseGroupIds) - a.RGAlbumGain = mostFrequentPtr(rgAlbumGains) - a.RGAlbumPeak = mostFrequentPtr(rgAlbumPeaks) - fixAlbumArtist(&a) return a } +func (mfs MediaFiles) ArtistIDs() []string { + var ids []string + for _, mf := range mfs { + ids = append(ids, mf.ArtistID, mf.AlbumArtistID) + } + return ids +} + +func (mfs MediaFiles) AlbumIDs() []string { + var ids []string + for _, mf := range mfs { + ids = append(ids, mf.AlbumID) + } + return ids +} + func allOrNothing(items []string) (string, int) { - if len(items) == 0 { - return "", 0 + items = slices.Compact(items) + if len(items) == 1 { + return items[0], 1 } - items = slice.Unique(items) - if len(items) != 1 { - return "", len(items) + if len(items) > 1 { + sort.Strings(items) + return "", len(slices.Compact(items)) } - return items[0], 1 + return "", 0 } func minMax(items []int) (int, int) { - var mn, mx = items[0], items[0] + var max = items[0] + var min = items[0] for _, value := range items { - mx = max(mx, value) - if mn == 0 { - mn = value + max = number.Max(max, value) + if min == 0 { + min = value } else if value > 0 { - mn = min(mn, value) + min = number.Min(min, value) } } - return mn, mx -} - -// mostFrequentPtr returns a pointer to the most common non-nil value, or nil if -// none. It counts by dereferenced value so a genuine 0.0 is a real candidate -// (slice.MostFrequent skips the zero value and compares pointers by identity). -func mostFrequentPtr(items []*float64) *float64 { - var counts map[float64]int - var best float64 - var bestCount int - for _, it := range items { - if it == nil { - continue - } - if counts == nil { - counts = map[float64]int{} - } - counts[*it]++ - if counts[*it] > bestCount { - bestCount = counts[*it] - best = *it - } - } - if bestCount == 0 { - return nil - } - return &best + return min, max } func newer(t1, t2 time.Time) time.Time { @@ -470,114 +236,44 @@ func older(t1, t2 time.Time) time.Time { if t1.IsZero() { return t2 } - if t2.IsZero() { - return t1 - } if t1.After(t2) { return t2 } return t1 } -// fixAlbumArtist sets the AlbumArtist to "Various Artists" if the album has more than one artist -// or if it is a compilation -func fixAlbumArtist(a *Album) { +func fixAlbumArtist(a Album, albumArtistIds []string) Album { if !a.Compilation { if a.AlbumArtistID == "" { - artist := a.Participants.First(RoleArtist) - a.AlbumArtistID = artist.ID - a.AlbumArtist = artist.Name + a.AlbumArtistID = a.ArtistID + a.AlbumArtist = a.Artist } - return + return a } - albumArtistIds := slice.Map(a.Participants[RoleAlbumArtist], func(p Participant) string { return p.ID }) - if len(slice.Unique(albumArtistIds)) > 1 { + + albumArtistIds = slices.Compact(albumArtistIds) + if len(albumArtistIds) > 1 { a.AlbumArtist = consts.VariousArtists a.AlbumArtistID = consts.VariousArtistsID } + return a } -// firstArtPath determines which media file path should be used for album artwork -// based on disc number (preferring lower disc numbers) and path (for consistency) -func firstArtPath(currentPath string, currentDisc int, m MediaFile) (string, int) { - if !m.HasCoverArt { - return currentPath, currentDisc - } - - // If current has no disc number (currentDisc == 0) or new file has lower disc number - if currentDisc == 0 || (m.DiscNumber < currentDisc && m.DiscNumber > 0) { - return m.Path, m.DiscNumber - } - - // If disc numbers are equal, use path for ordering - if m.DiscNumber == currentDisc { - if m.Path < currentPath || currentPath == "" { - return m.Path, m.DiscNumber - } - } - - return currentPath, currentDisc -} - -// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in -// https://docs.fileformat.com/audio/m3u/#extended-m3u -func (mfs MediaFiles) ToM3U8(title string, absolutePaths bool) string { - buf := strings.Builder{} - buf.WriteString("#EXTM3U\n") - buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", title)) - for _, t := range mfs { - buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title)) - if absolutePaths { - buf.WriteString(t.AbsolutePath() + "\n") - } else { - buf.WriteString(t.Path + "\n") - } - } - return buf.String() -} - -type MediaFileCursor iter.Seq2[MediaFile, error] - type MediaFileRepository interface { - rest.Repository[MediaFile] - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - CountBySuffix(ctx context.Context, options ...QueryOptions) (map[string]int64, error) - Exists(ctx context.Context, id string) (bool, error) - Put(ctx context.Context, m *MediaFile) error - UpdateProbeData(ctx context.Context, id string, data string) error - Get(ctx context.Context, id string) (*MediaFile, error) - GetWithParticipants(ctx context.Context, id string) (*MediaFile, error) - GetAll(ctx context.Context, options ...QueryOptions) (MediaFiles, error) - // GetRandom returns up to options.Max media files in random order, applying the same - // filters as GetAll. Sort/Order are ignored. - GetRandom(ctx context.Context, options ...QueryOptions) (MediaFiles, error) - GetAllByTags(ctx context.Context, tag TagName, values []string, options ...QueryOptions) (MediaFiles, error) - // MatchesCriteria reports whether the media file matches the criteria's rule - // expression, using the logged user's annotations. Limit and offset are ignored. - MatchesCriteria(ctx context.Context, id string, c criteria.Criteria) (bool, error) - GetCursor(ctx context.Context, options ...QueryOptions) (MediaFileCursor, error) - // GetAlbumIDsByFolder returns the distinct IDs of albums with non-missing tracks in the given - // folders or their direct children. - GetAlbumIDsByFolder(ctx context.Context, lib Library, folderIDs ...string) ([]string, error) - // GetCursorWithArtwork streams like GetCursor, hydrated, so callers that render images don't - // pay the scanner's per-row cost; it uses the same id pre-pass as the other cursors. - GetCursorWithArtwork(ctx context.Context, options ...QueryOptions) (MediaFileCursor, error) - Delete(ctx context.Context, id string) error - DeleteMissing(ctx context.Context, ids []string) error - DeleteAllMissing(ctx context.Context) (int64, error) - FindByPaths(ctx context.Context, paths []string) (MediaFiles, error) - // ReassignReferences moves annotations, bookmarks and playlist entries from prevID to newID, - // keeping newID's own row wherever a user has both. - ReassignReferences(ctx context.Context, prevID, newID string) error + CountAll(options ...QueryOptions) (int64, error) + Exists(id string) (bool, error) + Put(m *MediaFile) error + Get(id string) (*MediaFile, error) + GetAll(options ...QueryOptions) (MediaFiles, error) + Search(q string, offset int, size int) (MediaFiles, error) + Delete(id string) error - // The following methods are used exclusively by the scanner: - MarkMissing(ctx context.Context, missing bool, mfs ...*MediaFile) error - MarkMissingByFolder(ctx context.Context, missing bool, folderIDs ...string) error - GetMissingAndMatching(ctx context.Context, libId int) (MediaFileCursor, error) - FindRecentFilesByMBZTrackID(ctx context.Context, missing MediaFile, since time.Time) (MediaFiles, error) - FindRecentFilesByProperties(ctx context.Context, missing MediaFile, since time.Time) (MediaFiles, error) + // Queries by path to support the scanner, no Annotations or Bookmarks required in the response + FindAllByPath(path string) (MediaFiles, error) + FindByPath(path string) (*MediaFile, error) + FindPathsRecursively(basePath string) ([]string, error) + DeleteByPath(path string) (int64, error) AnnotatedRepository BookmarkableRepository - SearchableRepository[MediaFiles] } diff --git a/model/mediafile_internal_test.go b/model/mediafile_internal_test.go index 6b7d70750..2f902f8e7 100644 --- a/model/mediafile_internal_test.go +++ b/model/mediafile_internal_test.go @@ -9,24 +9,25 @@ import ( var _ = Describe("fixAlbumArtist", func() { var album Album BeforeEach(func() { - album = Album{Participants: Participants{}} + album = Album{} }) Context("Non-Compilations", func() { BeforeEach(func() { album.Compilation = false - album.Participants.Add(RoleArtist, Artist{ID: "ar-123", Name: "Sparks"}) + album.Artist = "Sparks" + album.ArtistID = "ar-123" }) It("returns the track artist if no album artist is specified", func() { - fixAlbumArtist(&album) - Expect(album.AlbumArtistID).To(Equal("ar-123")) - Expect(album.AlbumArtist).To(Equal("Sparks")) + al := fixAlbumArtist(album, nil) + Expect(al.AlbumArtistID).To(Equal("ar-123")) + Expect(al.AlbumArtist).To(Equal("Sparks")) }) It("returns the album artist if it is specified", func() { album.AlbumArtist = "Sparks Brothers" album.AlbumArtistID = "ar-345" - fixAlbumArtist(&album) - Expect(album.AlbumArtistID).To(Equal("ar-345")) - Expect(album.AlbumArtist).To(Equal("Sparks Brothers")) + al := fixAlbumArtist(album, nil) + Expect(al.AlbumArtistID).To(Equal("ar-345")) + Expect(al.AlbumArtist).To(Equal("Sparks Brothers")) }) }) Context("Compilations", func() { @@ -38,18 +39,15 @@ var _ = Describe("fixAlbumArtist", func() { }) It("returns VariousArtists if there's more than one album artist", func() { - album.Participants.Add(RoleAlbumArtist, Artist{ID: "ar-123", Name: "Sparks"}) - album.Participants.Add(RoleAlbumArtist, Artist{ID: "ar-345", Name: "The Beach"}) - fixAlbumArtist(&album) - Expect(album.AlbumArtistID).To(Equal(consts.VariousArtistsID)) - Expect(album.AlbumArtist).To(Equal(consts.VariousArtists)) + al := fixAlbumArtist(album, []string{"ar-123", "ar-345"}) + Expect(al.AlbumArtistID).To(Equal(consts.VariousArtistsID)) + Expect(al.AlbumArtist).To(Equal(consts.VariousArtists)) }) It("returns the sole album artist if they are the same", func() { - album.Participants.Add(RoleAlbumArtist, Artist{ID: "ar-000", Name: "The Beatles"}) - fixAlbumArtist(&album) - Expect(album.AlbumArtistID).To(Equal("ar-000")) - Expect(album.AlbumArtist).To(Equal("The Beatles")) + al := fixAlbumArtist(album, []string{"ar-000", "ar-000"}) + Expect(al.AlbumArtistID).To(Equal("ar-000")) + Expect(al.AlbumArtist).To(Equal("The Beatles")) }) }) }) diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 9ca3489bb..af0d185c4 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -5,8 +5,8 @@ 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/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -14,505 +14,228 @@ import ( var _ = Describe("MediaFiles", func() { var mfs MediaFiles - Describe("ToAlbum", func() { - Context("Simple attributes", func() { + Context("Simple attributes", func() { + BeforeEach(func() { + mfs = MediaFiles{ + { + ID: "1", AlbumID: "AlbumID", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", + SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName", + OrderAlbumName: "OrderAlbumName", OrderAlbumArtistName: "OrderAlbumArtistName", + MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment", + Compilation: false, CatalogNum: "", Path: "/music1/file1.mp3", + }, + { + ID: "2", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", AlbumID: "AlbumID", + SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName", + OrderAlbumName: "OrderAlbumName", OrderArtistName: "OrderArtistName", OrderAlbumArtistName: "OrderAlbumArtistName", + MbzAlbumArtistID: "MbzAlbumArtistID", MbzAlbumType: "MbzAlbumType", MbzAlbumComment: "MbzAlbumComment", + Compilation: true, CatalogNum: "CatalogNum", HasCoverArt: true, Path: "/music2/file2.mp3", + }, + } + }) + + It("sets the single values correctly", func() { + album := mfs.ToAlbum() + Expect(album.ID).To(Equal("AlbumID")) + Expect(album.Name).To(Equal("Album")) + Expect(album.Artist).To(Equal("Artist")) + Expect(album.ArtistID).To(Equal("ArtistID")) + Expect(album.AlbumArtist).To(Equal("AlbumArtist")) + Expect(album.AlbumArtistID).To(Equal("AlbumArtistID")) + Expect(album.SortAlbumName).To(Equal("SortAlbumName")) + Expect(album.SortArtistName).To(Equal("SortArtistName")) + Expect(album.SortAlbumArtistName).To(Equal("SortAlbumArtistName")) + Expect(album.OrderAlbumName).To(Equal("OrderAlbumName")) + Expect(album.OrderAlbumArtistName).To(Equal("OrderAlbumArtistName")) + Expect(album.MbzAlbumArtistID).To(Equal("MbzAlbumArtistID")) + Expect(album.MbzAlbumType).To(Equal("MbzAlbumType")) + Expect(album.MbzAlbumComment).To(Equal("MbzAlbumComment")) + Expect(album.CatalogNum).To(Equal("CatalogNum")) + Expect(album.Compilation).To(BeTrue()) + Expect(album.EmbedArtPath).To(Equal("/music2/file2.mp3")) + Expect(album.Paths).To(Equal("/music1" + consts.Zwsp + "/music2")) + }) + }) + Context("Aggregated attributes", func() { + When("we have only one song", func() { BeforeEach(func() { mfs = MediaFiles{ - { - ID: "1", AlbumID: "AlbumID", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", - 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", - }, - { - ID: "2", Album: "Album", ArtistID: "ArtistID", Artist: "Artist", AlbumArtistID: "AlbumArtistID", AlbumArtist: "AlbumArtist", AlbumID: "AlbumID", - SortAlbumName: "SortAlbumName", SortArtistName: "SortArtistName", SortAlbumArtistName: "SortAlbumArtistName", - 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", - }, + {Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")}, } }) - - It("sets the single values correctly", func() { + It("calculates the aggregates correctly", func() { album := mfs.ToAlbum() - Expect(album.ID).To(Equal("AlbumID")) - Expect(album.Name).To(Equal("Album")) - Expect(album.AlbumArtist).To(Equal("AlbumArtist")) - Expect(album.AlbumArtistID).To(Equal("AlbumArtistID")) - Expect(album.SortAlbumName).To(Equal("SortAlbumName")) - Expect(album.SortAlbumArtistName).To(Equal("SortAlbumArtistName")) - Expect(album.OrderAlbumName).To(Equal("OrderAlbumName")) - Expect(album.OrderAlbumArtistName).To(Equal("OrderAlbumArtistName")) - Expect(album.MbzAlbumArtistID).To(Equal("MbzAlbumArtistID")) - Expect(album.MbzAlbumType).To(Equal("MbzAlbumType")) - Expect(album.MbzAlbumComment).To(Equal("MbzAlbumComment")) - 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.FolderIDs).To(ConsistOf("Folder1", "Folder2")) + Expect(album.Duration).To(Equal(float32(100.2))) + Expect(album.Size).To(Equal(int64(1024))) + Expect(album.MinYear).To(Equal(1985)) + Expect(album.MaxYear).To(Equal(1985)) + Expect(album.Date).To(Equal("1985-01-02")) + Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:30"))) + Expect(album.CreatedAt).To(Equal(t("2022-12-19 08:30"))) }) }) - Context("Aggregated attributes", func() { - When("we don't have any songs", func() { - BeforeEach(func() { - mfs = MediaFiles{} - }) - It("returns an empty album", func() { - album := mfs.ToAlbum() - Expect(album.Duration).To(Equal(float32(0))) - Expect(album.Size).To(Equal(int64(0))) - Expect(album.MinYear).To(Equal(0)) - Expect(album.MaxYear).To(Equal(0)) - Expect(album.Date).To(BeEmpty()) - Expect(album.UpdatedAt).To(BeZero()) - Expect(album.CreatedAt).To(BeZero()) - }) - }) - When("we have only one song", func() { - BeforeEach(func() { - mfs = MediaFiles{ - {Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), BirthTime: t("2022-12-19 08:30")}, - } - }) - It("calculates the aggregates correctly", func() { - album := mfs.ToAlbum() - Expect(album.Duration).To(Equal(float32(100.2))) - Expect(album.Size).To(Equal(int64(1024))) - Expect(album.MinYear).To(Equal(1985)) - Expect(album.MaxYear).To(Equal(1985)) - Expect(album.Date).To(Equal("1985-01-02")) - Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:30"))) - Expect(album.CreatedAt).To(Equal(t("2022-12-19 08:30"))) - }) - }) - When("we have multiple songs with different dates", func() { - BeforeEach(func() { - mfs = MediaFiles{ - {Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), BirthTime: t("2022-12-19 08:30")}, - {Duration: 200.2, Size: 2048, Year: 0, Date: "", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 08:30")}, - {Duration: 150.6, Size: 1000, Year: 1986, Date: "1986-01-02", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 07:30")}, - } + When("we have multiple songs with different dates", func() { + BeforeEach(func() { + mfs = MediaFiles{ + {Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")}, + {Duration: 200.2, Size: 2048, Year: 0, Date: "", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 08:30")}, + {Duration: 150.6, Size: 1000, Year: 1986, Date: "1986-01-02", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 07:30")}, + } + }) + It("calculates the aggregates correctly", func() { + album := mfs.ToAlbum() + Expect(album.Duration).To(Equal(float32(451.0))) + Expect(album.Size).To(Equal(int64(4072))) + Expect(album.MinYear).To(Equal(1985)) + Expect(album.MaxYear).To(Equal(1986)) + Expect(album.Date).To(BeEmpty()) + Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:45"))) + Expect(album.CreatedAt).To(Equal(t("2022-12-19 07:30"))) + }) + Context("MinYear", func() { + It("returns 0 when all values are 0", func() { + mfs = MediaFiles{{Year: 0}, {Year: 0}, {Year: 0}} + a := mfs.ToAlbum() + Expect(a.MinYear).To(Equal(0)) }) - It("calculates the aggregates correctly", func() { - album := mfs.ToAlbum() - Expect(album.Duration).To(Equal(float32(451.0))) - Expect(album.Size).To(Equal(int64(4072))) - Expect(album.MinYear).To(Equal(1985)) - Expect(album.MaxYear).To(Equal(1986)) - Expect(album.Date).To(BeEmpty()) - Expect(album.UpdatedAt).To(Equal(t("2022-12-19 09:45"))) - Expect(album.CreatedAt).To(Equal(t("2022-12-19 07:30"))) - }) - Context("MinYear", func() { - It("returns 0 when all values are 0", func() { - mfs = MediaFiles{{Year: 0}, {Year: 0}, {Year: 0}} - a := mfs.ToAlbum() - Expect(a.MinYear).To(Equal(0)) - }) - It("returns the smallest value from the list, not counting 0", func() { - mfs = MediaFiles{{Year: 2000}, {Year: 0}, {Year: 1999}} - a := mfs.ToAlbum() - 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()) - }) + It("returns the smallest value from the list, not counting 0", func() { + mfs = MediaFiles{{Year: 2000}, {Year: 0}, {Year: 1999}} + a := mfs.ToAlbum() + Expect(a.MinYear).To(Equal(1999)) }) }) - When("we have multiple songs with same dates", func() { - BeforeEach(func() { - mfs = MediaFiles{ - {Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), BirthTime: t("2022-12-19 08:30")}, - {Duration: 200.2, Size: 2048, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 08:30")}, - {Duration: 150.6, Size: 1000, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:45"), BirthTime: t("2022-12-19 07:30")}, - } - }) - It("sets the date field correctly", func() { - album := mfs.ToAlbum() - Expect(album.Date).To(Equal("1985-01-02")) - Expect(album.MinYear).To(Equal(1985)) - Expect(album.MaxYear).To(Equal(1985)) - }) - }) - DescribeTable("explicitStatus", - func(mfs MediaFiles, status string) { - Expect(mfs.ToAlbum().ExplicitStatus).To(Equal(status)) - }, - Entry("sets the album to clean when a clean song is present", MediaFiles{{ExplicitStatus: ""}, {ExplicitStatus: "c"}, {ExplicitStatus: ""}}, "c"), - Entry("sets the album to explicit when an explicit song is present", MediaFiles{{ExplicitStatus: ""}, {ExplicitStatus: "e"}, {ExplicitStatus: ""}}, "e"), - Entry("takes precedence of explicit songs over clean ones", MediaFiles{{ExplicitStatus: "e"}, {ExplicitStatus: "c"}, {ExplicitStatus: ""}}, "e"), - ) }) - Context("Calculated attributes", func() { - Context("Discs", func() { - When("we have no discs info", func() { - BeforeEach(func() { - mfs = MediaFiles{{Album: "Album1"}, {Album: "Album1"}, {Album: "Album1"}} - }) - It("adds 1 disc without subtitle", func() { - album := mfs.ToAlbum() - Expect(album.Discs).To(Equal(Discs{1: ""})) - }) - }) - When("we have only one disc", func() { - BeforeEach(func() { - mfs = MediaFiles{{DiscNumber: 1, DiscSubtitle: "DiscSubtitle"}} - }) - It("sets the correct Discs", func() { - album := mfs.ToAlbum() - Expect(album.Discs).To(Equal(Discs{1: "DiscSubtitle"})) - }) - }) - When("we have multiple discs", func() { - BeforeEach(func() { - mfs = MediaFiles{{DiscNumber: 1, DiscSubtitle: "DiscSubtitle"}, {DiscNumber: 2, DiscSubtitle: "DiscSubtitle2"}, {DiscNumber: 1, DiscSubtitle: "DiscSubtitle"}} - }) - It("sets the correct Discs", func() { - album := mfs.ToAlbum() - Expect(album.Discs).To(Equal(Discs{1: "DiscSubtitle", 2: "DiscSubtitle2"})) - }) - }) + When("we have multiple songs with same dates", func() { + BeforeEach(func() { + mfs = MediaFiles{ + {Duration: 100.2, Size: 1024, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:30"), CreatedAt: t("2022-12-19 08:30")}, + {Duration: 200.2, Size: 2048, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 08:30")}, + {Duration: 150.6, Size: 1000, Year: 1985, Date: "1985-01-02", UpdatedAt: t("2022-12-19 09:45"), CreatedAt: t("2022-12-19 07:30")}, + } }) - - Context("Genres/tags", func() { - When("we don't have any tags", func() { - BeforeEach(func() { - mfs = MediaFiles{{}} - }) - It("sets the correct Genre", func() { - album := mfs.ToAlbum() - Expect(album.Tags).To(BeEmpty()) - }) - }) - When("we have only one Genre", func() { - BeforeEach(func() { - mfs = MediaFiles{{Tags: Tags{"genre": []string{"Rock"}}}} - }) - It("sets the correct Genre", func() { - album := mfs.ToAlbum() - Expect(album.Tags).To(HaveLen(1)) - Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock"})) - }) - }) - When("we have multiple Genres", func() { - BeforeEach(func() { - mfs = MediaFiles{ - {Tags: Tags{"genre": []string{"Punk"}, "mood": []string{"Happy", "Chill"}}}, - {Tags: Tags{"genre": []string{"Rock"}}}, - {Tags: Tags{"genre": []string{"Alternative", "Rock"}}}, - } - }) - It("sets the correct Genre, sorted by frequency, then by order of appearance", func() { - album := mfs.ToAlbum() - Expect(album.Tags).To(HaveLen(2)) - Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock", "Punk", "Alternative"})) - Expect(album.Tags).To(HaveKeyWithValue(TagMood, []string{"Happy", "Chill"})) - }) - }) - When("we have tags with mismatching case", func() { - BeforeEach(func() { - mfs = MediaFiles{ - {Tags: Tags{"genre": []string{"synthwave"}}}, - {Tags: Tags{"genre": []string{"Synthwave"}}}, - } - }) - It("normalizes the tags in just one", func() { - album := mfs.ToAlbum() - Expect(album.Tags).To(HaveLen(1)) - Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Synthwave"})) - }) - }) - }) - Context("Comments", func() { - When("we have only one Comment", func() { - BeforeEach(func() { - mfs = MediaFiles{{Comment: "comment1"}} - }) - It("sets the correct Comment", func() { - album := mfs.ToAlbum() - Expect(album.Comment).To(Equal("comment1")) - }) - }) - When("we have multiple equal comments", func() { - BeforeEach(func() { - mfs = MediaFiles{{Comment: "comment1"}, {Comment: "comment1"}, {Comment: "comment1"}} - }) - It("sets the correct Comment", func() { - album := mfs.ToAlbum() - Expect(album.Comment).To(Equal("comment1")) - }) - }) - When("we have different comments", func() { - BeforeEach(func() { - mfs = MediaFiles{{Comment: "comment1"}, {Comment: "not the same"}, {Comment: "comment1"}} - }) - It("sets the correct comment", func() { - album := mfs.ToAlbum() - Expect(album.Comment).To(BeEmpty()) - }) - }) - }) - Context("ReplayGain", func() { - It("picks the most frequent non-nil album gain and peak", func() { - mfs := MediaFiles{ - {Path: "a", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)}, - {Path: "b", RGAlbumGain: new(-8.0), RGAlbumPeak: new(0.9)}, - {Path: "c", RGAlbumGain: new(-5.0), RGAlbumPeak: new(1.0)}, - } - album := mfs.ToAlbum() - Expect(album.RGAlbumGain).ToNot(BeNil()) - Expect(*album.RGAlbumGain).To(Equal(-8.0)) - Expect(album.RGAlbumPeak).ToNot(BeNil()) - Expect(*album.RGAlbumPeak).To(Equal(0.9)) - }) - It("keeps a genuine 0.0 gain instead of dropping it", func() { - mfs := MediaFiles{ - {Path: "a", RGAlbumGain: new(0.0)}, - {Path: "b", RGAlbumGain: new(0.0)}, - } - album := mfs.ToAlbum() - Expect(album.RGAlbumGain).ToNot(BeNil()) - Expect(*album.RGAlbumGain).To(Equal(0.0)) - }) - It("leaves gain and peak nil when no track has a value", func() { - mfs := MediaFiles{{Path: "a"}, {Path: "b"}} - album := mfs.ToAlbum() - Expect(album.RGAlbumGain).To(BeNil()) - Expect(album.RGAlbumPeak).To(BeNil()) - }) - }) - Context("Participants", func() { - var album Album - BeforeEach(func() { - mfs = MediaFiles{ - { - Album: "Album1", AlbumArtistID: "AA1", AlbumArtist: "Display AlbumArtist1", Artist: "Artist1", - DiscSubtitle: "DiscSubtitle1", SortAlbumName: "SortAlbumName1", - Participants: Participants{ - RoleAlbumArtist: ParticipantList{_p("AA1", "AlbumArtist1", "SortAlbumArtistName1")}, - RoleArtist: ParticipantList{_p("A1", "Artist1", "SortArtistName1")}, - }, - }, - { - Album: "Album1", AlbumArtistID: "AA1", AlbumArtist: "Display AlbumArtist1", Artist: "Artist2", - DiscSubtitle: "DiscSubtitle2", SortAlbumName: "SortAlbumName1", - Participants: Participants{ - RoleAlbumArtist: ParticipantList{_p("AA1", "AlbumArtist1", "SortAlbumArtistName1")}, - RoleArtist: ParticipantList{_p("A2", "Artist2", "SortArtistName2")}, - RoleComposer: ParticipantList{_p("C1", "Composer1")}, - }, - }, - } - album = mfs.ToAlbum() - }) - It("gets all participants from all tracks", func() { - Expect(album.Participants).To(HaveKeyWithValue(RoleAlbumArtist, ParticipantList{_p("AA1", "AlbumArtist1", "SortAlbumArtistName1")})) - Expect(album.Participants).To(HaveKeyWithValue(RoleComposer, ParticipantList{_p("C1", "Composer1")})) - Expect(album.Participants).To(HaveKeyWithValue(RoleArtist, ParticipantList{ - _p("A1", "Artist1", "SortArtistName1"), _p("A2", "Artist2", "SortArtistName2"), - })) - }) - }) - Context("MbzAlbumID", func() { - When("we have only one MbzAlbumID", func() { - BeforeEach(func() { - mfs = MediaFiles{{MbzAlbumID: "id1"}} - }) - It("sets the correct MbzAlbumID", func() { - album := mfs.ToAlbum() - Expect(album.MbzAlbumID).To(Equal("id1")) - }) - }) - When("we have multiple MbzAlbumID", func() { - BeforeEach(func() { - mfs = MediaFiles{{MbzAlbumID: "id1"}, {MbzAlbumID: "id2"}, {MbzAlbumID: "id1"}} - }) - It("uses the most frequent MbzAlbumID", func() { - album := mfs.ToAlbum() - Expect(album.MbzAlbumID).To(Equal("id1")) - }) - }) - }) - Context("Album Art", func() { - When("we have media files with cover art from multiple discs", func() { - BeforeEach(func() { - mfs = MediaFiles{ - { - Path: "Artist/Album/Disc2/01.mp3", - HasCoverArt: true, - DiscNumber: 2, - }, - { - Path: "Artist/Album/Disc1/01.mp3", - HasCoverArt: true, - DiscNumber: 1, - }, - { - Path: "Artist/Album/Disc3/01.mp3", - HasCoverArt: true, - DiscNumber: 3, - }, - } - }) - It("selects the cover art from the lowest disc number", func() { - album := mfs.ToAlbum() - Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc1/01.mp3")) - }) - }) - - When("we have media files with cover art from the same disc number", func() { - BeforeEach(func() { - mfs = MediaFiles{ - { - Path: "Artist/Album/Disc1/02.mp3", - HasCoverArt: true, - DiscNumber: 1, - }, - { - Path: "Artist/Album/Disc1/01.mp3", - HasCoverArt: true, - DiscNumber: 1, - }, - } - }) - It("selects the cover art with the lowest path alphabetically", func() { - album := mfs.ToAlbum() - Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc1/01.mp3")) - }) - }) - - When("we have media files with some missing cover art", func() { - BeforeEach(func() { - mfs = MediaFiles{ - { - Path: "Artist/Album/Disc1/01.mp3", - HasCoverArt: false, - DiscNumber: 1, - }, - { - Path: "Artist/Album/Disc2/01.mp3", - HasCoverArt: true, - DiscNumber: 2, - }, - } - }) - It("selects the file with cover art even if from a higher disc number", func() { - album := mfs.ToAlbum() - Expect(album.EmbedArtPath).To(Equal("Artist/Album/Disc2/01.mp3")) - }) - }) - - When("we have media files with path names that don't correlate with disc numbers", func() { - BeforeEach(func() { - mfs = MediaFiles{ - { - Path: "Artist/Album/file-z.mp3", // Path would be sorted last alphabetically - HasCoverArt: true, - DiscNumber: 1, // But it has lowest disc number - }, - { - Path: "Artist/Album/file-a.mp3", // Path would be sorted first alphabetically - HasCoverArt: true, - DiscNumber: 2, // But it has higher disc number - }, - { - Path: "Artist/Album/file-m.mp3", - HasCoverArt: true, - DiscNumber: 3, - }, - } - }) - It("selects the cover art from the lowest disc number regardless of path", func() { - album := mfs.ToAlbum() - Expect(album.EmbedArtPath).To(Equal("Artist/Album/file-z.mp3")) - }) - }) + It("sets the date field correctly", func() { + album := mfs.ToAlbum() + Expect(album.Date).To(Equal("1985-01-02")) + Expect(album.MinYear).To(Equal(1985)) + Expect(album.MaxYear).To(Equal(1985)) }) }) }) - - Describe("ToM3U8", func() { - It("returns header only for empty MediaFiles", func() { - mfs = MediaFiles{} - result := mfs.ToM3U8("My Playlist", false) - Expect(result).To(Equal("#EXTM3U\n#PLAYLIST:My Playlist\n")) + Context("Calculated attributes", func() { + Context("Genres", func() { + When("we have only one Genre", func() { + BeforeEach(func() { + mfs = MediaFiles{{Genres: Genres{{ID: "g1", Name: "Rock"}}}} + }) + It("sets the correct Genre", func() { + album := mfs.ToAlbum() + Expect(album.Genre).To(Equal("Rock")) + Expect(album.Genres).To(ConsistOf(Genre{ID: "g1", Name: "Rock"})) + }) + }) + When("we have multiple Genres", func() { + BeforeEach(func() { + mfs = MediaFiles{{Genres: Genres{{ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}, {ID: "g3", Name: "Alternative"}}}} + }) + It("sets the correct Genre", func() { + album := mfs.ToAlbum() + Expect(album.Genre).To(Equal("Rock")) + Expect(album.Genres).To(Equal(Genres{{ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}, {ID: "g3", Name: "Alternative"}})) + }) + }) + When("we have one predominant Genre", func() { + var album Album + BeforeEach(func() { + mfs = MediaFiles{{Genres: Genres{{ID: "g2", Name: "Punk"}, {ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}}}} + album = mfs.ToAlbum() + }) + It("sets the correct Genre", func() { + Expect(album.Genre).To(Equal("Punk")) + }) + It("removes duplications from Genres", func() { + Expect(album.Genres).To(Equal(Genres{{ID: "g1", Name: "Rock"}, {ID: "g2", Name: "Punk"}})) + }) + }) }) - - DescribeTable("duration formatting", - func(duration float32, expected string) { - mfs = MediaFiles{{Title: "Song", Artist: "Artist", Duration: duration, Path: "song.mp3"}} - result := mfs.ToM3U8("Test", false) - Expect(result).To(ContainSubstring(expected)) - }, - Entry("zero duration", float32(0.0), "#EXTINF:0,"), - Entry("whole number", float32(120.0), "#EXTINF:120,"), - Entry("rounds 0.5 down", float32(180.5), "#EXTINF:180,"), - Entry("rounds 0.6 up", float32(240.6), "#EXTINF:241,"), - ) - - Context("multiple tracks", func() { + Context("Comments", func() { + When("we have only one Comment", func() { + BeforeEach(func() { + mfs = MediaFiles{{Comment: "comment1"}} + }) + It("sets the correct Comment", func() { + album := mfs.ToAlbum() + Expect(album.Comment).To(Equal("comment1")) + }) + }) + When("we have multiple equal comments", func() { + BeforeEach(func() { + mfs = MediaFiles{{Comment: "comment1"}, {Comment: "comment1"}, {Comment: "comment1"}} + }) + It("sets the correct Comment", func() { + album := mfs.ToAlbum() + Expect(album.Comment).To(Equal("comment1")) + }) + }) + When("we have different comments", func() { + BeforeEach(func() { + mfs = MediaFiles{{Comment: "comment1"}, {Comment: "not the same"}, {Comment: "comment1"}} + }) + It("sets the correct Genre", func() { + album := mfs.ToAlbum() + Expect(album.Comment).To(BeEmpty()) + }) + }) + }) + Context("AllArtistIds", func() { BeforeEach(func() { mfs = MediaFiles{ - {Title: "Song One", Artist: "Artist A", Duration: 120, Path: "a/song1.mp3", LibraryPath: "/music"}, - {Title: "Song Two", Artist: "Artist B", Duration: 241, Path: "b/song2.mp3", LibraryPath: "/music"}, - {Title: "Song with \"quotes\" & ampersands", Artist: "Artist with Ümläuts", Duration: 90, Path: "special/file.mp3", LibraryPath: "/música"}, + {AlbumArtistID: "22", ArtistID: "11"}, + {AlbumArtistID: "22", ArtistID: "33"}, + {AlbumArtistID: "22", ArtistID: "11"}, } }) - - 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)) - }, - Entry("relative paths", - false, - "#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n", - ), - Entry("absolute paths", - true, - "#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\n/music/a/song1.mp3\n#EXTINF:241,Artist B - Song Two\n/music/b/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\n/música/special/file.mp3\n", - ), - Entry("special characters", - false, - "#EXTM3U\n#PLAYLIST:Multi Track\n#EXTINF:120,Artist A - Song One\na/song1.mp3\n#EXTINF:241,Artist B - Song Two\nb/song2.mp3\n#EXTINF:90,Artist with Ümläuts - Song with \"quotes\" & ampersands\nspecial/file.mp3\n", - ), - ) + It("removes duplications", func() { + album := mfs.ToAlbum() + Expect(album.AllArtistIDs).To(Equal("11 22 33")) + }) }) - - Context("path variations", func() { - It("handles different path structures", func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-model)") + Context("FullText", func() { + BeforeEach(func() { 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"}, + { + Album: "Album1", AlbumArtist: "AlbumArtist1", Artist: "Artist1", DiscSubtitle: "DiscSubtitle1", + SortAlbumName: "SortAlbumName1", SortAlbumArtistName: "SortAlbumArtistName1", SortArtistName: "SortArtistName1", + }, + { + Album: "Album1", AlbumArtist: "AlbumArtist1", Artist: "Artist2", DiscSubtitle: "DiscSubtitle2", + SortAlbumName: "SortAlbumName1", SortAlbumArtistName: "SortAlbumArtistName1", SortArtistName: "SortArtistName2", + }, } - - relativeResult := mfs.ToM3U8("Test", false) - Expect(relativeResult).To(ContainSubstring("song.mp3\n")) - Expect(relativeResult).To(ContainSubstring("deep/nested/song.mp3\n")) - - absoluteResult := mfs.ToM3U8("Test", true) - Expect(absoluteResult).To(ContainSubstring("/lib/song.mp3\n")) - Expect(absoluteResult).To(ContainSubstring("/lib/deep/nested/song.mp3\n")) + }) + It("fills the fullText attribute correctly", func() { + album := mfs.ToAlbum() + Expect(album.FullText).To(Equal(" album1 albumartist1 artist1 artist2 discsubtitle1 discsubtitle2 sortalbumartistname1 sortalbumname1 sortartistname1 sortartistname2")) + }) + }) + Context("MbzAlbumID", func() { + When("we have only one MbzAlbumID", func() { + BeforeEach(func() { + mfs = MediaFiles{{MbzAlbumID: "id1"}} + }) + It("sets the correct MbzAlbumID", func() { + album := mfs.ToAlbum() + Expect(album.MbzAlbumID).To(Equal("id1")) + }) + }) + When("we have multiple MbzAlbumID", func() { + BeforeEach(func() { + mfs = MediaFiles{{MbzAlbumID: "id1"}, {MbzAlbumID: "id2"}, {MbzAlbumID: "id1"}} + }) + It("sets the correct MbzAlbumID", func() { + album := mfs.ToAlbum() + Expect(album.MbzAlbumID).To(Equal("id1")) + }) }) }) }) @@ -523,64 +246,20 @@ var _ = Describe("MediaFile", func() { DeferCleanup(configtest.SetupConfig()) conf.Server.EnableMediaFileCoverArt = true }) - DescribeTable("FullTitle", - func(enabled bool, tags Tags, expected string) { - conf.Server.Subsonic.AppendSubtitle = enabled - mf := MediaFile{Title: "Song", Tags: tags} - Expect(mf.FullTitle()).To(Equal(expected)) - }, - Entry("appends subtitle when enabled and tag is present", true, Tags{TagSubtitle: []string{"Live"}}, "Song (Live)"), - Entry("returns just title when disabled", false, Tags{TagSubtitle: []string{"Live"}}, "Song"), - Entry("returns just title when tag is absent", true, Tags{}, "Song"), - Entry("returns just title when tag is an empty slice", true, Tags{TagSubtitle: []string{}}, "Song"), - Entry("does not double parentheses when subtitle is already parenthesized", true, Tags{TagSubtitle: []string{"(non-explicit version)"}}, "Song (non-explicit version)"), - Entry("does not add parentheses when subtitle is wrapped in square brackets", true, Tags{TagSubtitle: []string{"[Live]"}}, "Song [Live]"), - Entry("does not add parentheses when subtitle is wrapped in curly braces", true, Tags{TagSubtitle: []string{"{Remix}"}}, "Song {Remix}"), - Entry("does not add parentheses when subtitle is wrapped in angle brackets", true, Tags{TagSubtitle: []string{""}}, "Song "), - Entry("adds parentheses when brackets do not match", true, Tags{TagSubtitle: []string{"[Live)"}}, "Song ([Live))"), - Entry("trims surrounding whitespace before wrapping", true, Tags{TagSubtitle: []string{" Live "}}, "Song (Live)"), - Entry("trims whitespace around an already-bracketed subtitle", true, Tags{TagSubtitle: []string{" (Live) "}}, "Song (Live)"), - ) - DescribeTable("FullAlbumName", - func(enabled bool, tags Tags, expected string) { - conf.Server.Subsonic.AppendAlbumVersion = enabled - mf := MediaFile{Album: "Album", Tags: tags} - Expect(mf.FullAlbumName()).To(Equal(expected)) - }, - Entry("appends version when enabled and tag is present", true, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album (Deluxe Edition)"), - Entry("returns just album name when disabled", false, Tags{TagAlbumVersion: []string{"Deluxe Edition"}}, "Album"), - Entry("returns just album name when tag is absent", true, Tags{}, "Album"), - Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), - Entry("does not double parentheses when version is already parenthesized", true, Tags{TagAlbumVersion: []string{"(Deluxe Edition)"}}, "Album (Deluxe Edition)"), - Entry("does not add parentheses when version is wrapped in square brackets", true, Tags{TagAlbumVersion: []string{"[Deluxe Edition]"}}, "Album [Deluxe Edition]"), - ) - Describe("CoverArtId", func() { + Describe(".CoverArtId()", func() { It("returns its own id if it HasCoverArt", func() { mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true} id := mf.CoverArtID() Expect(id.Kind).To(Equal(KindMediaFileArtwork)) Expect(id.ID).To(Equal(mf.ID)) }) - It("returns disc art id if HasCoverArt is false and DiscNumber > 0", func() { - mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false, DiscNumber: 2} - id := mf.CoverArtID() - Expect(id.Kind).To(Equal(KindDiscArtwork)) - Expect(id.ID).To(Equal("1:2")) - }) - It("returns its album id if HasCoverArt is false and DiscNumber is 0", func() { + It("returns its album id if HasCoverArt is false", func() { mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: false} id := mf.CoverArtID() Expect(id.Kind).To(Equal(KindAlbumArtwork)) Expect(id.ID).To(Equal(mf.AlbumID)) }) - It("returns disc art id if EnableMediaFileCoverArt is disabled and DiscNumber > 0", func() { - conf.Server.EnableMediaFileCoverArt = false - mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true, DiscNumber: 3} - id := mf.CoverArtID() - Expect(id.Kind).To(Equal(KindDiscArtwork)) - Expect(id.ID).To(Equal("1:3")) - }) - It("returns its album id if EnableMediaFileCoverArt is disabled and DiscNumber is 0", func() { + It("returns its album id if EnableMediaFileCoverArt is disabled", func() { conf.Server.EnableMediaFileCoverArt = false mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true} id := mf.CoverArtID() @@ -588,143 +267,6 @@ var _ = Describe("MediaFile", func() { Expect(id.ID).To(Equal(mf.AlbumID)) }) }) - - Describe("AudioCodec", func() { - It("returns normalized stored codec when available", func() { - mf := MediaFile{Codec: "AAC", Suffix: "m4a"} - Expect(mf.AudioCodec()).To(Equal("aac")) - }) - - It("returns stored codec lowercased", func() { - mf := MediaFile{Codec: "ALAC", Suffix: "m4a"} - Expect(mf.AudioCodec()).To(Equal("alac")) - }) - - DescribeTable("infers codec from suffix when Codec field is empty", - func(suffix string, bitDepth int, expected string) { - mf := MediaFile{Suffix: suffix, BitDepth: new(bitDepth)} - Expect(mf.AudioCodec()).To(Equal(expected)) - }, - Entry("mp3", "mp3", 0, "mp3"), - Entry("mpga", "mpga", 0, "mp3"), - Entry("mp2", "mp2", 0, "mp2"), - Entry("ogg", "ogg", 0, "vorbis"), - Entry("oga", "oga", 0, "vorbis"), - Entry("opus", "opus", 0, "opus"), - Entry("mpc", "mpc", 0, "mpc"), - Entry("wma", "wma", 0, "wma"), - Entry("flac", "flac", 0, "flac"), - Entry("wav", "wav", 0, "pcm"), - Entry("aif", "aif", 0, "pcm"), - Entry("aiff", "aiff", 0, "pcm"), - Entry("aifc", "aifc", 0, "pcm"), - Entry("ape", "ape", 0, "ape"), - Entry("wv", "wv", 0, "wv"), - Entry("wvp", "wvp", 0, "wv"), - Entry("tta", "tta", 0, "tta"), - Entry("tak", "tak", 0, "tak"), - Entry("shn", "shn", 0, "shn"), - Entry("dsf", "dsf", 0, "dsd"), - Entry("dff", "dff", 0, "dsd"), - Entry("m4a with BitDepth=0 (AAC)", "m4a", 0, "aac"), - Entry("m4a with BitDepth>0 (ALAC)", "m4a", 16, "alac"), - Entry("m4b", "m4b", 0, "aac"), - Entry("m4p", "m4p", 0, "aac"), - Entry("m4r", "m4r", 0, "aac"), - Entry("unknown suffix", "xyz", 0, ""), - ) - - It("prefers stored codec over suffix inference", func() { - mf := MediaFile{Codec: "ALAC", Suffix: "m4a"} - Expect(mf.AudioCodec()).To(Equal("alac")) - }) - }) - -}) - -var _ = DescribeTable("MediaFile.HasEmbeddedLyrics", - func(lyrics string, expected bool) { - Expect(MediaFile{Lyrics: lyrics}.HasEmbeddedLyrics()).To(Equal(expected)) - }, - Entry("empty string (never-scanned zero value)", "", false), - Entry(`the post-scan "[]" no-lyrics sentinel`, "[]", false), - Entry("a stored lyric list", `[{"lang":"eng","line":[{"value":"la"}]}]`, true), -) - -var _ = Describe("MediaFile.Works", func() { - It("returns nil when there are no work tags", func() { - mf := MediaFile{} - Expect(mf.Works()).To(BeNil()) - }) - - It("pairs a work name with its MbzWorkID", func() { - mf := MediaFile{Tags: Tags{ - TagWork: {"Symphony No. 5"}, - TagMusicBrainzWorkID: {"abc-123"}, - }} - Expect(mf.Works()).To(Equal([]Work{ - {Name: "Symphony No. 5", MbzWorkID: "abc-123"}, - })) - }) - - It("leaves MbzWorkID empty when no id is present", func() { - mf := MediaFile{Tags: Tags{TagWork: {"Symphony No. 5"}}} - Expect(mf.Works()).To(Equal([]Work{ - {Name: "Symphony No. 5"}, - })) - }) - - It("pairs by index and ignores extra ids", func() { - mf := MediaFile{Tags: Tags{ - TagWork: {"Work A", "Work B"}, - TagMusicBrainzWorkID: {"id-a"}, - }} - Expect(mf.Works()).To(Equal([]Work{ - {Name: "Work A", MbzWorkID: "id-a"}, - {Name: "Work B"}, - })) - }) -}) - -var _ = Describe("MediaFile.Movements", func() { - It("returns nil when there are no movement tags", func() { - mf := MediaFile{} - Expect(mf.Movements()).To(BeNil()) - }) - - It("builds a movement with name, number and count", func() { - mf := MediaFile{Tags: Tags{ - TagMovementName: {"I. Allegro"}, - TagMovementNumber: {"1"}, - TagMovementTotal: {"4"}, - }} - Expect(mf.Movements()).To(Equal([]Movement{ - {Name: "I. Allegro", Number: 1, Count: 4}, - })) - }) - - It("non-numeric number/count yields 0", func() { - mf := MediaFile{Tags: Tags{ - TagMovementName: {"I. Allegro"}, - TagMovementNumber: {"not-a-number"}, - }} - Expect(mf.Movements()).To(Equal([]Movement{ - {Name: "I. Allegro"}, - })) - }) -}) - -var _ = Describe("MediaFile.Hash", func() { - // Pins the hash formula: an accidental change spuriously re-imports every file on the next scan. - It("hashes to a stable value", func() { - Expect(MediaFile{Title: "Song"}.Hash()).To(Equal("05fdf70bb0cbe090")) - Expect(MediaFile{Title: "Song", BPM: new(120), BitDepth: new(16)}.Hash()).To(Equal("b5daf6ac1009a538")) - }) - It("changes the hash when a pointer field has a value", func() { - base := MediaFile{Title: "Song"} - Expect(base.Equals(MediaFile{Title: "Song", BPM: new(120)})).To(BeFalse()) - Expect(base.Equals(MediaFile{Title: "Song", BitDepth: new(24)})).To(BeFalse()) - }) }) func t(v string) time.Time { diff --git a/model/mediafolder.go b/model/mediafolder.go new file mode 100644 index 000000000..f9bddd3e8 --- /dev/null +++ b/model/mediafolder.go @@ -0,0 +1,23 @@ +package model + +import ( + "io/fs" + "os" +) + +type MediaFolder struct { + ID int32 + Name string + Path string +} + +func (f MediaFolder) FS() fs.FS { + return os.DirFS(f.Path) +} + +type MediaFolders []MediaFolder + +type MediaFolderRepository interface { + Get(id int32) (*MediaFolder, error) + GetAll() (MediaFolders, error) +} diff --git a/model/metadata/legacy_ids.go b/model/metadata/legacy_ids.go deleted file mode 100644 index 7156c4f50..000000000 --- a/model/metadata/legacy_ids.go +++ /dev/null @@ -1,60 +0,0 @@ -package metadata - -import ( - "cmp" - "crypto/md5" - "fmt" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" -) - -// These legacy ID functions hash the same inputs as the original Navidrome ID generation, -// now emitted in the canonical base62 encoding (matching what the uniform-ids migration stores). - -func legacyTrackID(mf model.MediaFile, prependLibId bool) string { - key := mf.Path - if prependLibId && mf.LibraryID != model.DefaultLibraryID { - key = fmt.Sprintf("%d\\%s", mf.LibraryID, key) - } - sum := md5.Sum([]byte(key)) - return id.Encode(sum) -} - -func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string { - _, _, releaseDate := md.mapDates() - albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", legacyMapAlbumArtistName(md), legacyMapAlbumName(md))) - if !conf.Server.Scanner.GroupAlbumReleases { - if len(releaseDate) != 0 { - albumPath = fmt.Sprintf("%s\\%s", albumPath, releaseDate) - } - } - if prependLibId && mf.LibraryID != model.DefaultLibraryID { - albumPath = fmt.Sprintf("%d\\%s", mf.LibraryID, albumPath) - } - sum := md5.Sum([]byte(albumPath)) - return id.Encode(sum) -} - -func legacyMapAlbumArtistName(md Metadata) string { - values := []string{ - md.String(model.TagAlbumArtist), - "", - md.String(model.TagTrackArtist), - consts.UnknownArtist, - } - if md.Bool(model.TagCompilation) { - values[1] = consts.VariousArtists - } - return cmp.Or(values...) -} - -func legacyMapAlbumName(md Metadata) string { - return cmp.Or( - md.String(model.TagAlbum), - consts.UnknownAlbum, - ) -} diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go deleted file mode 100644 index 2135824d2..000000000 --- a/model/metadata/map_mediafile.go +++ /dev/null @@ -1,196 +0,0 @@ -package metadata - -import ( - "cmp" - "context" - "encoding/json" - "maps" - "math" - "strconv" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/str" -) - -func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFile { - mf := model.MediaFile{ - LibraryID: lib.ID, - FolderID: folderID, - Tags: maps.Clone(md.tags), - } - - // Title and Album - mf.Title = md.mapTrackTitle() - mf.Album = md.mapAlbumName() - mf.SortTitle = md.String(model.TagTitleSort) - mf.SortAlbumName = md.String(model.TagAlbumSort) - mf.OrderTitle = str.SanitizeFieldForSorting(mf.Title) - mf.OrderAlbumName = str.SanitizeFieldForSortingNoArticle(mf.Album) - mf.Compilation = md.Bool(model.TagCompilation) - - // Disc and Track info - mf.TrackNumber, _ = md.NumAndTotal(model.TagTrackNumber) - mf.DiscNumber, _ = md.NumAndTotal(model.TagDiscNumber) - mf.DiscSubtitle = md.String(model.TagDiscSubtitle) - mf.CatalogNum = md.String(model.TagCatalogNumber) - mf.Comment = md.String(model.TagComment) - if f := md.NullableFloat(model.TagBPM); f != nil { - if r := math.Round(*f); r > 0 && r <= math.MaxInt32 { - mf.BPM = new(int(r)) - } - } - mf.Lyrics = md.mapLyrics() - mf.ExplicitStatus = md.mapExplicitStatusTag() - - // Dates - date, origDate, relDate := md.mapDates() - mf.OriginalYear, mf.OriginalDate = origDate.Year(), string(origDate) - mf.ReleaseYear, mf.ReleaseDate = relDate.Year(), string(relDate) - mf.Year, mf.Date = date.Year(), string(date) - - // MBIDs - mf.MbzRecordingID = md.String(model.TagMusicBrainzRecordingID) - mf.MbzReleaseTrackID = md.String(model.TagMusicBrainzTrackID) - mf.MbzAlbumID = md.String(model.TagMusicBrainzAlbumID) - mf.MbzReleaseGroupID = md.String(model.TagMusicBrainzReleaseGroupID) - mf.MbzAlbumType = md.String(model.TagReleaseType) - - // ReplayGain - mf.RGAlbumPeak = md.NullableFloat(model.TagReplayGainAlbumPeak) - mf.RGAlbumGain = md.mapGain(model.TagReplayGainAlbumGain, model.TagR128AlbumGain) - mf.RGTrackPeak = md.NullableFloat(model.TagReplayGainTrackPeak) - mf.RGTrackGain = md.mapGain(model.TagReplayGainTrackGain, model.TagR128TrackGain) - - // General properties - mf.HasCoverArt = md.HasPicture() - mf.Duration = md.Length() - mf.BitRate = md.AudioProperties().BitRate - mf.SampleRate = md.AudioProperties().SampleRate - if bd := md.AudioProperties().BitDepth; bd > 0 { - mf.BitDepth = new(bd) - } - mf.Channels = md.AudioProperties().Channels - mf.Codec = md.AudioProperties().Codec - mf.Path = md.FilePath() - mf.Suffix = md.Suffix() - mf.Size = md.Size() - mf.BirthTime = md.BirthTime() - mf.UpdatedAt = md.ModTime() - - mf.Participants = md.mapParticipants() - mf.Artist = md.mapDisplayArtist() - mf.AlbumArtist = md.mapDisplayAlbumArtist(mf) - - // Persistent IDs - pid := lib.EffectivePID() - mf.PID = md.trackPID(mf, pid) - mf.AlbumID = md.albumID(mf, pid.Album) - - // BFR These IDs will go away once the UI handle multiple participants. - // BFR For Legacy Subsonic compatibility, we will set them in the API handlers - mf.ArtistID = mf.Participants.First(model.RoleArtist).ID - mf.AlbumArtistID = mf.Participants.First(model.RoleAlbumArtist).ID - - // BFR What to do with sort/order artist names? - mf.OrderArtistName = mf.Participants.First(model.RoleArtist).OrderArtistName - mf.OrderAlbumArtistName = mf.Participants.First(model.RoleAlbumArtist).OrderArtistName - mf.SortArtistName = mf.Participants.First(model.RoleArtist).SortArtistName - mf.SortAlbumArtistName = mf.Participants.First(model.RoleAlbumArtist).SortArtistName - - // Don't store tags that are first-class fields (and are not album-level tags) in the - // MediaFile struct. This is to avoid redundancy in the DB - // - // Remove all tags from the main section that are not flagged as album tags - for tag, conf := range model.TagMainMappings() { - if !conf.Album { - delete(mf.Tags, tag) - } - } - - return mf -} - -func (md Metadata) AlbumID(mf model.MediaFile, pidConf string) string { - return md.albumID(mf, pidConf) -} - -func (md Metadata) mapGain(rg, r128 model.TagName) *float64 { - v := md.Gain(rg) - if v != nil { - return v - } - r128value := md.String(r128) - if r128value != "" { - var v, err = strconv.Atoi(r128value) - if err != nil { - return nil - } - // Convert Q7.8 to float - value := float64(v) / 256.0 - // Adding 5 dB to normalize with ReplayGain level - value += 5 - return &value - } - return nil -} - -func (md Metadata) mapLyrics() string { - rawLyrics := md.Pairs(model.TagLyrics) - - lyricList := make(model.LyricList, 0, len(rawLyrics)) - - ctx := log.NewContext(context.Background(), "file", md.filePath) - for _, raw := range rawLyrics { - lang := raw.Key() - text := raw.Value() - - lyrics, err := model.ParseLyrics(ctx, "", lang, []byte(text)) - if err != nil { - log.Warn(ctx, "Unexpected failure occurred when parsing lyrics", err) - continue - } - for _, lyric := range lyrics { - if !lyric.IsEmpty() { - lyricList = append(lyricList, lyric) - } - } - } - - res, err := json.Marshal(lyricList) - if err != nil { - log.Warn("Unexpected error occurred when serializing lyrics", "file", md.filePath, err) - return "" - } - return string(res) -} - -func (md Metadata) mapExplicitStatusTag() string { - switch md.first(model.TagExplicitStatus) { - case "1", "4": - return "e" - case "2": - return "c" - default: - return "" - } -} - -func (md Metadata) mapDates() (date Date, originalDate Date, releaseDate Date) { - // Start with defaults - date = md.Date(model.TagRecordingDate) - originalDate = md.Date(model.TagOriginalDate) - releaseDate = md.Date(model.TagReleaseDate) - - // For some historic reason, taggers have been writing the Release Date of an album to the Date tag, - // and leave the Release Date tag empty. - legacyMappings := (originalDate != "") && - (releaseDate == "") && - (date >= originalDate) - if legacyMappings { - return originalDate, originalDate, date - } - // when there's no Date, first fall back to Original Date, then to Release Date. - date = cmp.Or(date, originalDate, releaseDate) - return date, originalDate, releaseDate -} diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go deleted file mode 100644 index c19398841..000000000 --- a/model/metadata/map_mediafile_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package metadata_test - -import ( - "encoding/json" - "os" - "sort" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/metadata" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("ToMediaFile", func() { - var ( - props metadata.Info - md metadata.Metadata - mf model.MediaFile - ) - - BeforeEach(func() { - _, filePath, _ := tests.TempFile(GinkgoT(), "test", ".mp3") - fileInfo, _ := os.Stat(filePath) - props = metadata.Info{ - FileInfo: testFileInfo{fileInfo}, - } - }) - - var toMediaFile = func(tags model.RawTags) model.MediaFile { - props.Tags = tags - md = metadata.New("filepath", props) - return md.ToMediaFile(model.Library{ID: 1}, "folderID") - } - - Describe("Persistent IDs", func() { - It("uses the library PID config for the album ID and for albumid in the track spec", func() { - props.Tags = model.RawTags{"ALBUM": {"Kind of Blue"}, "TITLE": {"So What"}} - md = metadata.New("Jazz/Loose/01.mp3", props) - - byTags := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "album", PIDTrack: "albumid,title"}, "folderID") - byFolder := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "folder", PIDTrack: "albumid,title"}, "folderID") - - Expect(byFolder.AlbumID).ToNot(Equal(byTags.AlbumID)) - Expect(byFolder.AlbumID).To(Equal(md.AlbumID(byFolder, "folder"))) - Expect(byFolder.PID).ToNot(Equal(byTags.PID)) - }) - }) - - Describe("Dates", func() { - It("should parse properly tagged dates ", func() { - mf = toMediaFile(model.RawTags{ - "ORIGINALDATE": {"1978-09-10"}, - "DATE": {"1977-03-04"}, - "RELEASEDATE": {"2002-01-02"}, - }) - - Expect(mf.Year).To(Equal(1977)) - Expect(mf.Date).To(Equal("1977-03-04")) - Expect(mf.OriginalYear).To(Equal(1978)) - Expect(mf.OriginalDate).To(Equal("1978-09-10")) - Expect(mf.ReleaseYear).To(Equal(2002)) - Expect(mf.ReleaseDate).To(Equal("2002-01-02")) - }) - - It("should parse dates with only year", func() { - mf = toMediaFile(model.RawTags{ - "ORIGINALYEAR": {"1978"}, - "DATE": {"1977"}, - "RELEASEDATE": {"2002"}, - }) - - Expect(mf.Year).To(Equal(1977)) - Expect(mf.Date).To(Equal("1977")) - Expect(mf.OriginalYear).To(Equal(1978)) - Expect(mf.OriginalDate).To(Equal("1978")) - Expect(mf.ReleaseYear).To(Equal(2002)) - Expect(mf.ReleaseDate).To(Equal("2002")) - }) - - It("should parse dates tagged the legacy way (no release date)", func() { - mf = toMediaFile(model.RawTags{ - "DATE": {"2014"}, - "ORIGINALDATE": {"1966"}, - }) - - Expect(mf.Year).To(Equal(1966)) - Expect(mf.OriginalYear).To(Equal(1966)) - Expect(mf.ReleaseYear).To(Equal(2014)) - }) - DescribeTable("legacyReleaseDate (TaggedLikePicard old behavior)", - func(recordingDate, originalDate, releaseDate, expected string) { - mf := toMediaFile(model.RawTags{ - "DATE": {recordingDate}, - "ORIGINALDATE": {originalDate}, - "RELEASEDATE": {releaseDate}, - }) - - Expect(mf.ReleaseDate).To(Equal(expected)) - }, - Entry("regular mapping", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"), - Entry("legacy mapping", "2020-05-15", "2019-02-10", "", "2020-05-15"), - Entry("legacy mapping, originalYear < year", "2018-05-15", "2019-02-10", "2021-01-01", "2021-01-01"), - Entry("legacy mapping, originalYear empty", "2020-05-15", "", "2021-01-01", "2021-01-01"), - Entry("legacy mapping, releaseYear", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"), - Entry("legacy mapping, same dates", "2020-05-15", "2020-05-15", "", "2020-05-15"), - ) - }) - - Describe("Lyrics", func() { - It("should parse the lyrics", func() { - mf = toMediaFile(model.RawTags{ - "LYRICS:XXX": {"Lyrics"}, - "LYRICS:ENG": { - "[00:00.00]This is\n[00:02.50]English SYLT\n", - }, - }) - var actual model.LyricList - err := json.Unmarshal([]byte(mf.Lyrics), &actual) - Expect(err).ToNot(HaveOccurred()) - - expected := model.LyricList{ - {Lang: "eng", Line: []model.Line{ - {Value: "This is", Start: new(int64(0))}, - {Value: "English SYLT", Start: new(int64(2500))}, - }, Synced: true}, - {Lang: "xxx", Line: []model.Line{{Value: "Lyrics"}}, Synced: false}, - } - sort.Slice(actual, func(i, j int) bool { return actual[i].Lang < actual[j].Lang }) - sort.Slice(expected, func(i, j int) bool { return expected[i].Lang < expected[j].Lang }) - Expect(actual).To(Equal(expected)) - }) - }) - - Describe("BPM", func() { - It("maps the BPM tag rounded to the nearest integer", func() { - mf = toMediaFile(model.RawTags{"BPM": {"120.6"}}) - Expect(mf.BPM).To(Equal(new(121))) - }) - It("leaves BPM nil when the tag is absent", func() { - mf = toMediaFile(model.RawTags{}) - Expect(mf.BPM).To(BeNil()) - }) - It("leaves BPM nil when the tag is zero or unparseable", func() { - Expect(toMediaFile(model.RawTags{"BPM": {"0"}}).BPM).To(BeNil()) - Expect(toMediaFile(model.RawTags{"BPM": {"fast"}}).BPM).To(BeNil()) - }) - It("leaves BPM nil when the tag does not fit in 32 bits", func() { - Expect(toMediaFile(model.RawTags{"BPM": {"4294967295"}}).BPM).To(BeNil()) - }) - It("leaves BPM nil when the tag is negative", func() { - Expect(toMediaFile(model.RawTags{"BPM": {"-120"}}).BPM).To(BeNil()) - }) - It("keeps the largest 32-bit BPM value", func() { - Expect(toMediaFile(model.RawTags{"BPM": {"2147483647"}}).BPM).To(Equal(new(2147483647))) - }) - }) - - Describe("BitDepth", func() { - It("maps the bit depth when present", func() { - props.AudioProperties = metadata.AudioProperties{BitDepth: 24} - mf = toMediaFile(model.RawTags{}) - Expect(mf.BitDepth).To(Equal(new(24))) - }) - It("leaves BitDepth nil when zero (lossy codecs have no bit depth)", func() { - props.AudioProperties = metadata.AudioProperties{BitDepth: 0} - mf = toMediaFile(model.RawTags{}) - Expect(mf.BitDepth).To(BeNil()) - }) - }) -}) diff --git a/model/metadata/map_participants.go b/model/metadata/map_participants.go deleted file mode 100644 index 35f112a92..000000000 --- a/model/metadata/map_participants.go +++ /dev/null @@ -1,242 +0,0 @@ -package metadata - -import ( - "cmp" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/str" - "golang.org/x/text/cases" - "golang.org/x/text/language" -) - -type roleTags struct { - name model.TagName - sort model.TagName - mbid model.TagName -} - -var roleMappings = map[model.Role]roleTags{ - model.RoleComposer: {name: model.TagComposer, sort: model.TagComposerSort, mbid: model.TagMusicBrainzComposerID}, - model.RoleLyricist: {name: model.TagLyricist, sort: model.TagLyricistSort, mbid: model.TagMusicBrainzLyricistID}, - model.RoleConductor: {name: model.TagConductor, mbid: model.TagMusicBrainzConductorID}, - model.RoleArranger: {name: model.TagArranger, mbid: model.TagMusicBrainzArrangerID}, - model.RoleDirector: {name: model.TagDirector, mbid: model.TagMusicBrainzDirectorID}, - model.RoleProducer: {name: model.TagProducer, mbid: model.TagMusicBrainzProducerID}, - model.RoleEngineer: {name: model.TagEngineer, mbid: model.TagMusicBrainzEngineerID}, - model.RoleMixer: {name: model.TagMixer, mbid: model.TagMusicBrainzMixerID}, - model.RoleRemixer: {name: model.TagRemixer, mbid: model.TagMusicBrainzRemixerID}, - model.RoleDJMixer: {name: model.TagDJMixer, mbid: model.TagMusicBrainzDJMixerID}, -} - -func (md Metadata) mapParticipants() model.Participants { - participants := make(model.Participants) - - // Parse track artists - artists := md.parseArtists( - model.TagTrackArtist, model.TagTrackArtists, - model.TagTrackArtistSort, model.TagTrackArtistsSort, - model.TagMusicBrainzArtistID, - ) - participants.Add(model.RoleArtist, artists...) - - // Parse album artists - albumArtists := md.parseArtists( - model.TagAlbumArtist, model.TagAlbumArtists, - model.TagAlbumArtistSort, model.TagAlbumArtistsSort, - model.TagMusicBrainzAlbumArtistID, - ) - if len(albumArtists) == 1 && albumArtists[0].Name == consts.UnknownArtist { - if md.Bool(model.TagCompilation) { - albumArtists = md.buildArtists([]string{consts.VariousArtists}, nil, []string{consts.VariousArtistsMbzId}) - } else { - albumArtists = artists - } - } - participants.Add(model.RoleAlbumArtist, albumArtists...) - - // Parse all other roles - for role, info := range roleMappings { - names := md.getRoleValues(info.name) - if len(names) > 0 { - sorts := md.Strings(info.sort) - mbids := md.Strings(info.mbid) - artists := md.buildArtists(names, sorts, mbids) - participants.Add(role, artists...) - } - } - - rolesMbzIdMap := md.buildRoleMbidMaps() - md.processPerformers(participants, rolesMbzIdMap) - md.syncMissingMbzIDs(participants) - - return participants -} - -// buildRoleMbidMaps creates a map of roles to MBZ IDs -func (md Metadata) buildRoleMbidMaps() map[string][]string { - titleCaser := cases.Title(language.Und) - rolesMbzIdMap := make(map[string][]string) - for _, mbid := range md.Pairs(model.TagMusicBrainzPerformerID) { - role := titleCaser.String(mbid.Key()) - rolesMbzIdMap[role] = append(rolesMbzIdMap[role], mbid.Value()) - } - - return rolesMbzIdMap -} - -func (md Metadata) processPerformers(participants model.Participants, rolesMbzIdMap map[string][]string) { - // roleIdx keeps track of the index of the MBZ ID for each role - roleIdx := make(map[string]int) - for role := range rolesMbzIdMap { - roleIdx[role] = 0 - } - - conf := model.TagRolesConf().WithParticipantExceptions(model.TagPerformer) - titleCaser := cases.Title(language.Und) - for _, performer := range md.Pairs(model.TagPerformer) { - subRole := titleCaser.String(performer.Key()) - names := splitParticipantValues(conf, []string{performer.Value()}) - for _, name := range names { - artist := model.Artist{ - ID: md.artistID(name), - Name: name, - OrderArtistName: str.SanitizeFieldForSortingNoArticle(name), - MbzArtistID: md.getPerformerMbid(subRole, rolesMbzIdMap, roleIdx), - } - participants.AddWithSubRole(model.RolePerformer, subRole, artist) - } - } -} - -// getPerformerMbid returns the MBZ ID for a performer, based on the subrole -func (md Metadata) getPerformerMbid(subRole string, rolesMbzIdMap map[string][]string, roleIdx map[string]int) string { - if mbids, exists := rolesMbzIdMap[subRole]; exists && roleIdx[subRole] < len(mbids) { - defer func() { roleIdx[subRole]++ }() - return mbids[roleIdx[subRole]] - } - return "" -} - -// syncMissingMbzIDs fills in missing MBZ IDs for artists that have been previously parsed -func (md Metadata) syncMissingMbzIDs(participants model.Participants) { - artistMbzIDMap := make(map[string]string) - for _, artist := range append(participants[model.RoleArtist], participants[model.RoleAlbumArtist]...) { - if artist.MbzArtistID != "" { - artistMbzIDMap[artist.Name] = artist.MbzArtistID - } - } - - for role, list := range participants { - for i, artist := range list { - if artist.MbzArtistID == "" { - if mbzID, exists := artistMbzIDMap[artist.Name]; exists { - participants[role][i].MbzArtistID = mbzID - } - } - } - } -} - -func (md Metadata) parseArtists( - name model.TagName, names model.TagName, sort model.TagName, - sorts model.TagName, mbid model.TagName, -) []model.Artist { - nameValues := md.getArtistValues(name, names) - sortValues := md.getArtistValues(sort, sorts) - mbids := md.Strings(mbid) - if len(nameValues) == 0 { - nameValues = []string{consts.UnknownArtist} - } - return md.buildArtists(nameValues, sortValues, mbids) -} - -func (md Metadata) buildArtists(names, sorts, mbids []string) []model.Artist { - var artists []model.Artist - for i, name := range names { - id := md.artistID(name) - artist := model.Artist{ - ID: id, - Name: name, - OrderArtistName: str.SanitizeFieldForSortingNoArticle(name), - } - if i < len(sorts) { - artist.SortArtistName = sorts[i] - } - if i < len(mbids) { - artist.MbzArtistID = mbids[i] - } - artists = append(artists, artist) - } - return artists -} - -// splitParticipantValues splits values by the conf separators, dropping -// duplicated or empty entries. Values are returned unchanged when the conf -// has no separators. -func splitParticipantValues(conf model.TagConf, values []string) []string { - if len(conf.Split) == 0 { - return values - } - return filterDuplicatedOrEmptyValues(conf.SplitTagValue(values)) -} - -// getRoleValues returns the values of a role tag, splitting them if necessary -func (md Metadata) getRoleValues(role model.TagName) []string { - values := md.Strings(role) - if len(values) == 0 { - return nil - } - conf := model.TagMainMappings()[role] - if conf.Split == nil { - conf = model.TagRolesConf() - } - conf = conf.WithParticipantExceptions(role) - return splitParticipantValues(conf, values) -} - -// getArtistValues returns the values of a single or multi artist tag, splitting them if necessary -func (md Metadata) getArtistValues(single, multi model.TagName) []string { - vMulti := md.Strings(multi) - if len(vMulti) > 0 { - return vMulti - } - vSingle := md.Strings(single) - if len(vSingle) != 1 { - return vSingle - } - conf := model.TagMainMappings()[single] - if conf.Split == nil { - conf = model.TagArtistsConf() - } - conf = conf.WithParticipantExceptions(single) - return splitParticipantValues(conf, vSingle) -} - -func (md Metadata) mapDisplayName(singularTagName, pluralTagName model.TagName) string { - return cmp.Or( - strings.Join(md.tags[singularTagName], conf.Server.Scanner.ArtistJoiner), - strings.Join(md.tags[pluralTagName], conf.Server.Scanner.ArtistJoiner), - ) -} - -func (md Metadata) mapDisplayArtist() string { - return cmp.Or( - md.mapDisplayName(model.TagTrackArtist, model.TagTrackArtists), - consts.UnknownArtist, - ) -} - -func (md Metadata) mapDisplayAlbumArtist(mf model.MediaFile) string { - fallbackName := consts.UnknownArtist - if md.Bool(model.TagCompilation) { - fallbackName = consts.VariousArtists - } - return cmp.Or( - md.mapDisplayName(model.TagAlbumArtist, model.TagAlbumArtists), - mf.Participants.First(model.RoleAlbumArtist).Name, - fallbackName, - ) -} diff --git a/model/metadata/map_participants_test.go b/model/metadata/map_participants_test.go deleted file mode 100644 index db652fb8b..000000000 --- a/model/metadata/map_participants_test.go +++ /dev/null @@ -1,894 +0,0 @@ -package metadata_test - -import ( - "os" - - "github.com/google/uuid" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/metadata" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - . "github.com/onsi/gomega/gstruct" - "github.com/onsi/gomega/types" -) - -var _ = Describe("Participants", func() { - var ( - props metadata.Info - md metadata.Metadata - mf model.MediaFile - mbid1, mbid2, mbid3 string - ) - - BeforeEach(func() { - _, filePath, _ := tests.TempFile(GinkgoT(), "test", ".mp3") - fileInfo, _ := os.Stat(filePath) - mbid1 = uuid.NewString() - mbid2 = uuid.NewString() - mbid3 = uuid.NewString() - props = metadata.Info{ - FileInfo: testFileInfo{fileInfo}, - } - }) - - var toMediaFile = func(tags model.RawTags) model.MediaFile { - props.Tags = tags - md = metadata.New("filepath", props) - return md.ToMediaFile(model.Library{ID: 1}, "folderID") - } - - Describe("ARTIST(S) tags", func() { - Context("No ARTIST/ARTISTS tags", func() { - BeforeEach(func() { - mf = toMediaFile(model.RawTags{}) - }) - - It("should set the display name to Unknown Artist", func() { - Expect(mf.Artist).To(Equal("[Unknown Artist]")) - }) - - It("should set artist to Unknown Artist", func() { - Expect(mf.Artist).To(Equal("[Unknown Artist]")) - }) - - It("should add an Unknown Artist to participants", func() { - participants := mf.Participants - Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST - - artist := participants[model.RoleArtist][0] - Expect(artist.ID).ToNot(BeEmpty()) - Expect(artist.Name).To(Equal("[Unknown Artist]")) - Expect(artist.OrderArtistName).To(Equal("[unknown artist]")) - Expect(artist.SortArtistName).To(BeEmpty()) - Expect(artist.MbzArtistID).To(BeEmpty()) - }) - }) - - Context("Single-valued ARTIST tags, no ARTISTS tags", func() { - BeforeEach(func() { - mf = toMediaFile(model.RawTags{ - "ARTIST": {"Artist Name"}, - "ARTISTSORT": {"Name, Artist"}, - "MUSICBRAINZ_ARTISTID": {mbid1}, - }) - }) - - It("should use the artist tag as display name", func() { - Expect(mf.Artist).To(Equal("Artist Name")) - }) - - It("should populate the participants", func() { - participants := mf.Participants - Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST - Expect(participants).To(SatisfyAll( - HaveKeyWithValue(model.RoleArtist, HaveLen(1)), - )) - Expect(mf.Artist).To(Equal("Artist Name")) - - artist := participants[model.RoleArtist][0] - - Expect(artist.ID).ToNot(BeEmpty()) - Expect(artist.Name).To(Equal("Artist Name")) - Expect(artist.OrderArtistName).To(Equal("artist name")) - Expect(artist.SortArtistName).To(Equal("Name, Artist")) - Expect(artist.MbzArtistID).To(Equal(mbid1)) - }) - }) - - Context("Multiple values in a Single-valued ARTIST tags, no ARTISTS tags", func() { - BeforeEach(func() { - mf = toMediaFile(model.RawTags{ - "ARTIST": {"Artist Name feat. Someone Else"}, - "ARTISTSORT": {"Name, Artist feat. Else, Someone"}, - "MUSICBRAINZ_ARTISTID": {mbid1}, - }) - }) - - It("should use the full string as display name", func() { - Expect(mf.Artist).To(Equal("Artist Name feat. Someone Else")) - Expect(mf.SortArtistName).To(Equal("Name, Artist")) - Expect(mf.OrderArtistName).To(Equal("artist name")) - }) - - It("should split the tag", func() { - participants := mf.Participants - Expect(participants).To(SatisfyAll( - HaveKeyWithValue(model.RoleArtist, HaveLen(2)), - )) - - By("adding the first artist to the participants") - artist0 := participants[model.RoleArtist][0] - Expect(artist0.ID).ToNot(BeEmpty()) - Expect(artist0.Name).To(Equal("Artist Name")) - Expect(artist0.OrderArtistName).To(Equal("artist name")) - Expect(artist0.SortArtistName).To(Equal("Name, Artist")) - - By("assuming the MBID is for the first artist") - Expect(artist0.MbzArtistID).To(Equal(mbid1)) - - By("adding the second artist to the participants") - artist1 := participants[model.RoleArtist][1] - Expect(artist1.ID).ToNot(BeEmpty()) - Expect(artist1.Name).To(Equal("Someone Else")) - Expect(artist1.OrderArtistName).To(Equal("someone else")) - Expect(artist1.SortArtistName).To(Equal("Else, Someone")) - Expect(artist1.MbzArtistID).To(BeEmpty()) - }) - - It("should split the tag using case-insensitive separators", func() { - mf = toMediaFile(model.RawTags{ - "ARTIST": {"A1 FEAT. A2"}, - }) - participants := mf.Participants - Expect(participants).To(SatisfyAll( - HaveKeyWithValue(model.RoleArtist, HaveLen(2)), - )) - - artist1 := participants[model.RoleArtist][0] - Expect(artist1.Name).To(Equal("A1")) - artist2 := participants[model.RoleArtist][1] - Expect(artist2.Name).To(Equal("A2")) - }) - - It("should not add an empty artist after split", func() { - mf = toMediaFile(model.RawTags{ - "ARTIST": {"John Doe / / Jane Doe"}, - }) - - participants := mf.Participants - Expect(participants).To(HaveKeyWithValue(model.RoleArtist, HaveLen(2))) - artists := participants[model.RoleArtist] - Expect(artists[0].Name).To(Equal("John Doe")) - Expect(artists[1].Name).To(Equal("Jane Doe")) - }) - }) - - Context("Multi-valued ARTIST tags, no ARTISTS tags", func() { - BeforeEach(func() { - mf = toMediaFile(model.RawTags{ - "ARTIST": {"First Artist", "Second Artist"}, - "ARTISTSORT": {"Name, First Artist", "Name, Second Artist"}, - "MUSICBRAINZ_ARTISTID": {mbid1, mbid2}, - }) - }) - - It("should concatenate all ARTIST values as display name", func() { - Expect(mf.Artist).To(Equal("First Artist • Second Artist")) - }) - - It("should populate the participants with all artists", func() { - participants := mf.Participants - Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST - Expect(participants).To(SatisfyAll( - HaveKeyWithValue(model.RoleArtist, HaveLen(2)), - )) - - artist0 := participants[model.RoleArtist][0] - Expect(artist0.ID).ToNot(BeEmpty()) - Expect(artist0.Name).To(Equal("First Artist")) - Expect(artist0.OrderArtistName).To(Equal("first artist")) - Expect(artist0.SortArtistName).To(Equal("Name, First Artist")) - Expect(artist0.MbzArtistID).To(Equal(mbid1)) - - artist1 := participants[model.RoleArtist][1] - Expect(artist1.ID).ToNot(BeEmpty()) - Expect(artist1.Name).To(Equal("Second Artist")) - Expect(artist1.OrderArtistName).To(Equal("second artist")) - Expect(artist1.SortArtistName).To(Equal("Name, Second Artist")) - Expect(artist1.MbzArtistID).To(Equal(mbid2)) - }) - }) - - Context("Single-valued ARTIST tag, single-valued ARTISTS tag, same values", func() { - BeforeEach(func() { - mf = toMediaFile(model.RawTags{ - "ARTIST": {"Artist Name"}, - "ARTISTS": {"Artist Name"}, - "ARTISTSORT": {"Name, Artist"}, - "MUSICBRAINZ_ARTISTID": {mbid1}, - }) - }) - - It("should use the ARTIST tag as display name", func() { - Expect(mf.Artist).To(Equal("Artist Name")) - }) - - It("should populate the participants with the ARTIST", func() { - participants := mf.Participants - Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST - Expect(participants).To(SatisfyAll( - HaveKeyWithValue(model.RoleArtist, HaveLen(1)), - )) - - artist := participants[model.RoleArtist][0] - Expect(artist.ID).ToNot(BeEmpty()) - Expect(artist.Name).To(Equal("Artist Name")) - Expect(artist.OrderArtistName).To(Equal("artist name")) - Expect(artist.SortArtistName).To(Equal("Name, Artist")) - Expect(artist.MbzArtistID).To(Equal(mbid1)) - }) - }) - - Context("Single-valued ARTIST tag, single-valued ARTISTS tag, different values", func() { - BeforeEach(func() { - mf = toMediaFile(model.RawTags{ - "ARTIST": {"Artist Name"}, - "ARTISTS": {"Artist Name 2"}, - "ARTISTSORT": {"Name, Artist"}, - "MUSICBRAINZ_ARTISTID": {mbid1}, - }) - }) - - It("should use the ARTIST tag as display name", func() { - Expect(mf.Artist).To(Equal("Artist Name")) - }) - - It("should use only artists from ARTISTS", func() { - participants := mf.Participants - Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST - Expect(participants).To(SatisfyAll( - HaveKeyWithValue(model.RoleArtist, HaveLen(1)), - )) - - artist := participants[model.RoleArtist][0] - Expect(artist.ID).ToNot(BeEmpty()) - Expect(artist.Name).To(Equal("Artist Name 2")) - Expect(artist.OrderArtistName).To(Equal("artist name 2")) - Expect(artist.SortArtistName).To(Equal("Name, Artist")) - Expect(artist.MbzArtistID).To(Equal(mbid1)) - }) - }) - - Context("No ARTIST tag, multi-valued ARTISTS tag", func() { - BeforeEach(func() { - mf = toMediaFile(model.RawTags{ - "ARTISTS": {"First Artist", "Second Artist"}, - "ARTISTSSORT": {"Name, First Artist", "Name, Second Artist"}, - }) - }) - - It("should concatenate ARTISTS as display name", func() { - Expect(mf.Artist).To(Equal("First Artist • Second Artist")) - }) - - It("should populate the participants with all artists", func() { - participants := mf.Participants - Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST - Expect(participants).To(SatisfyAll( - HaveKeyWithValue(model.RoleArtist, HaveLen(2)), - )) - - artist0 := participants[model.RoleArtist][0] - Expect(artist0.ID).ToNot(BeEmpty()) - Expect(artist0.Name).To(Equal("First Artist")) - Expect(artist0.OrderArtistName).To(Equal("first artist")) - Expect(artist0.SortArtistName).To(Equal("Name, First Artist")) - Expect(artist0.MbzArtistID).To(BeEmpty()) - - artist1 := participants[model.RoleArtist][1] - Expect(artist1.ID).ToNot(BeEmpty()) - Expect(artist1.Name).To(Equal("Second Artist")) - Expect(artist1.OrderArtistName).To(Equal("second artist")) - Expect(artist1.SortArtistName).To(Equal("Name, Second Artist")) - Expect(artist1.MbzArtistID).To(BeEmpty()) - }) - }) - - Context("Single-valued ARTIST tags, multi-valued ARTISTS tags", func() { - BeforeEach(func() { - mf = toMediaFile(model.RawTags{ - "ARTIST": {"First Artist & Second Artist"}, - "ARTISTSORT": {"Name, First Artist & Name, Second Artist"}, - "MUSICBRAINZ_ARTISTID": {mbid1, mbid2}, - "ARTISTS": {"First Artist", "Second Artist"}, - "ARTISTSSORT": {"Name, First Artist", "Name, Second Artist"}, - }) - }) - - It("should use the single-valued tag as display name", func() { - Expect(mf.Artist).To(Equal("First Artist & Second Artist")) - }) - - It("should prioritize multi-valued tags over single-valued tags", func() { - participants := mf.Participants - Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST - Expect(participants).To(SatisfyAll( - HaveKeyWithValue(model.RoleArtist, HaveLen(2)), - )) - artist0 := participants[model.RoleArtist][0] - Expect(artist0.ID).ToNot(BeEmpty()) - Expect(artist0.Name).To(Equal("First Artist")) - Expect(artist0.OrderArtistName).To(Equal("first artist")) - Expect(artist0.SortArtistName).To(Equal("Name, First Artist")) - Expect(artist0.MbzArtistID).To(Equal(mbid1)) - - artist1 := participants[model.RoleArtist][1] - Expect(artist1.ID).ToNot(BeEmpty()) - Expect(artist1.Name).To(Equal("Second Artist")) - Expect(artist1.OrderArtistName).To(Equal("second artist")) - Expect(artist1.SortArtistName).To(Equal("Name, Second Artist")) - Expect(artist1.MbzArtistID).To(Equal(mbid2)) - }) - }) - - // Not a good tagging strategy, but supported anyway. - Context("Multi-valued ARTIST tags, multi-valued ARTISTS tags", func() { - BeforeEach(func() { - mf = toMediaFile(model.RawTags{ - "ARTIST": {"First Artist", "Second Artist"}, - "ARTISTSORT": {"Name, First Artist", "Name, Second Artist"}, - "MUSICBRAINZ_ARTISTID": {mbid1, mbid2}, - "ARTISTS": {"First Artist 2", "Second Artist 2"}, - "ARTISTSSORT": {"2, First Artist Name", "2, Second Artist Name"}, - }) - }) - - It("should use ARTIST values concatenated as a display name ", func() { - Expect(mf.Artist).To(Equal("First Artist • Second Artist")) - }) - - It("should prioritize ARTISTS tags", func() { - participants := mf.Participants - Expect(participants).To(HaveLen(2)) // ARTIST and ALBUMARTIST - Expect(participants).To(SatisfyAll( - HaveKeyWithValue(model.RoleArtist, HaveLen(2)), - )) - artist0 := participants[model.RoleArtist][0] - Expect(artist0.ID).ToNot(BeEmpty()) - Expect(artist0.Name).To(Equal("First Artist 2")) - Expect(artist0.OrderArtistName).To(Equal("first artist 2")) - Expect(artist0.SortArtistName).To(Equal("2, First Artist Name")) - Expect(artist0.MbzArtistID).To(Equal(mbid1)) - - artist1 := participants[model.RoleArtist][1] - Expect(artist1.ID).ToNot(BeEmpty()) - Expect(artist1.Name).To(Equal("Second Artist 2")) - Expect(artist1.OrderArtistName).To(Equal("second artist 2")) - Expect(artist1.SortArtistName).To(Equal("2, Second Artist Name")) - Expect(artist1.MbzArtistID).To(Equal(mbid2)) - }) - }) - }) - - Describe("ALBUMARTIST(S) tags", func() { - // Only test specific scenarios for ALBUMARTIST(S) tags, as the logic is the same as for ARTIST(S) tags. - Context("No ALBUMARTIST/ALBUMARTISTS tags", func() { - When("the COMPILATION tag is not set", func() { - BeforeEach(func() { - mf = toMediaFile(model.RawTags{ - "ARTIST": {"Artist Name"}, - "ARTISTSORT": {"Name, Artist"}, - "MUSICBRAINZ_ARTISTID": {mbid1}, - }) - }) - - It("should use the ARTIST as ALBUMARTIST", func() { - Expect(mf.AlbumArtist).To(Equal("Artist Name")) - }) - - It("should add the ARTIST to participants as ALBUMARTIST", func() { - participants := mf.Participants - Expect(participants).To(HaveLen(2)) - Expect(participants).To(SatisfyAll( - HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(1)), - )) - - albumArtist := participants[model.RoleAlbumArtist][0] - Expect(albumArtist.ID).ToNot(BeEmpty()) - Expect(albumArtist.Name).To(Equal("Artist Name")) - Expect(albumArtist.OrderArtistName).To(Equal("artist name")) - Expect(albumArtist.SortArtistName).To(Equal("Name, Artist")) - Expect(albumArtist.MbzArtistID).To(Equal(mbid1)) - }) - }) - - When("the COMPILATION tag is not set and there is no ALBUMARTIST tag", func() { - BeforeEach(func() { - mf = toMediaFile(model.RawTags{ - "ARTIST": {"Artist Name", "Another Artist"}, - "ARTISTSORT": {"Name, Artist", "Artist, Another"}, - }) - }) - - It("should use the first ARTIST as ALBUMARTIST", func() { - Expect(mf.AlbumArtist).To(Equal("Artist Name")) - }) - - It("should add the ARTIST to participants as ALBUMARTIST", func() { - participants := mf.Participants - Expect(participants).To(HaveLen(2)) - Expect(participants).To(SatisfyAll( - HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(2)), - )) - - albumArtist := participants[model.RoleAlbumArtist][0] - Expect(albumArtist.Name).To(Equal("Artist Name")) - Expect(albumArtist.SortArtistName).To(Equal("Name, Artist")) - - albumArtist = participants[model.RoleAlbumArtist][1] - Expect(albumArtist.Name).To(Equal("Another Artist")) - Expect(albumArtist.SortArtistName).To(Equal("Artist, Another")) - }) - }) - - When("the COMPILATION tag is true", func() { - BeforeEach(func() { - mf = toMediaFile(model.RawTags{ - "COMPILATION": {"1"}, - }) - }) - - It("should use the Various Artists as display name", func() { - Expect(mf.AlbumArtist).To(Equal("Various Artists")) - }) - - It("should add the Various Artists to participants as ALBUMARTIST", func() { - participants := mf.Participants - Expect(participants).To(HaveLen(2)) - Expect(participants).To(SatisfyAll( - HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(1)), - )) - - albumArtist := participants[model.RoleAlbumArtist][0] - Expect(albumArtist.ID).ToNot(BeEmpty()) - Expect(albumArtist.Name).To(Equal("Various Artists")) - Expect(albumArtist.OrderArtistName).To(Equal("various artists")) - Expect(albumArtist.SortArtistName).To(BeEmpty()) - Expect(albumArtist.MbzArtistID).To(Equal(consts.VariousArtistsMbzId)) - }) - }) - - When("the COMPILATION tag is true and there are ALBUMARTIST tags", func() { - BeforeEach(func() { - mf = toMediaFile(model.RawTags{ - "COMPILATION": {"1"}, - "ALBUMARTIST": {"Album Artist Name 1", "Album Artist Name 2"}, - }) - }) - - It("should use the ALBUMARTIST names as display name", func() { - Expect(mf.AlbumArtist).To(Equal("Album Artist Name 1 • Album Artist Name 2")) - }) - }) - }) - - Context("ALBUMARTIST tag is set", func() { - BeforeEach(func() { - mf = toMediaFile(model.RawTags{ - "ARTIST": {"Track Artist Name"}, - "ARTISTSORT": {"Name, Track Artist"}, - "MUSICBRAINZ_ARTISTID": {mbid1}, - "ALBUMARTIST": {"Album Artist Name"}, - "ALBUMARTISTSORT": {"Album Artist Sort Name"}, - "MUSICBRAINZ_ALBUMARTISTID": {mbid2}, - }) - }) - - It("should use the ALBUMARTIST as display name", func() { - Expect(mf.AlbumArtist).To(Equal("Album Artist Name")) - }) - - It("should populate the participants with the ALBUMARTIST", func() { - participants := mf.Participants - Expect(participants).To(HaveLen(2)) - Expect(participants).To(SatisfyAll( - HaveKeyWithValue(model.RoleAlbumArtist, HaveLen(1)), - )) - - albumArtist := participants[model.RoleAlbumArtist][0] - Expect(albumArtist.ID).ToNot(BeEmpty()) - Expect(albumArtist.Name).To(Equal("Album Artist Name")) - Expect(albumArtist.OrderArtistName).To(Equal("album artist name")) - Expect(albumArtist.SortArtistName).To(Equal("Album Artist Sort Name")) - Expect(albumArtist.MbzArtistID).To(Equal(mbid2)) - }) - }) - }) - - Describe("COMPOSER and LYRICIST tags (with sort names)", func() { - DescribeTable("should return the correct participation", - func(role model.Role, nameTag, sortTag string) { - mf = toMediaFile(model.RawTags{ - nameTag: {"First Name", "Second Name"}, - sortTag: {"Name, First", "Name, Second"}, - }) - - participants := mf.Participants - Expect(participants).To(HaveKeyWithValue(role, HaveLen(2))) - - p := participants[role] - Expect(p[0].ID).ToNot(BeEmpty()) - Expect(p[0].Name).To(Equal("First Name")) - Expect(p[0].SortArtistName).To(Equal("Name, First")) - Expect(p[0].OrderArtistName).To(Equal("first name")) - Expect(p[1].ID).ToNot(BeEmpty()) - Expect(p[1].Name).To(Equal("Second Name")) - Expect(p[1].SortArtistName).To(Equal("Name, Second")) - Expect(p[1].OrderArtistName).To(Equal("second name")) - }, - Entry("COMPOSER", model.RoleComposer, "COMPOSER", "COMPOSERSORT"), - Entry("LYRICIST", model.RoleLyricist, "LYRICIST", "LYRICISTSORT"), - ) - }) - - Describe("PERFORMER tags", func() { - When("PERFORMER tag is set", func() { - matchPerformer := func(name, orderName, subRole string) types.GomegaMatcher { - return MatchFields(IgnoreExtras, Fields{ - "Artist": MatchFields(IgnoreExtras, Fields{ - "Name": Equal(name), - "OrderArtistName": Equal(orderName), - }), - "SubRole": Equal(subRole), - }) - } - - It("should return the correct participation", func() { - mf = toMediaFile(model.RawTags{ - "PERFORMER:GUITAR": {"Eric Clapton", "B.B. King"}, - "PERFORMER:BASS": {"Nathan East"}, - "PERFORMER:HAMMOND ORGAN": {"Tim Carmon"}, - }) - - participants := mf.Participants - Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(4))) - - p := participants[model.RolePerformer] - Expect(p).To(ContainElements( - matchPerformer("Eric Clapton", "eric clapton", "Guitar"), - matchPerformer("B.B. King", "b.b. king", "Guitar"), - matchPerformer("Nathan East", "nathan east", "Bass"), - matchPerformer("Tim Carmon", "tim carmon", "Hammond Organ"), - )) - }) - - It("should split multiple names in a single value", func() { - mf = toMediaFile(model.RawTags{ - "PERFORMER:GUITAR": {"Eric Clapton/B.B. King"}, - "PERFORMER:BASS": {"Nathan East"}, - }) - - participants := mf.Participants - Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(3))) - - p := participants[model.RolePerformer] - Expect(p).To(ContainElements( - matchPerformer("Eric Clapton", "eric clapton", "Guitar"), - matchPerformer("B.B. King", "b.b. king", "Guitar"), - matchPerformer("Nathan East", "nathan east", "Bass"), - )) - }) - - It("should assign MBIDs in order to names split from a single value", func() { - mf = toMediaFile(model.RawTags{ - "PERFORMER:GUITAR": {"Eric Clapton/B.B. King"}, - "MUSICBRAINZ_PERFORMERID:GUITAR": {mbid1, mbid2}, - }) - - p := mf.Participants[model.RolePerformer] - Expect(p).To(HaveLen(2)) - Expect(p[0].Name).To(Equal("Eric Clapton")) - Expect(p[0].MbzArtistID).To(Equal(mbid1)) - Expect(p[1].Name).To(Equal("B.B. King")) - Expect(p[1].MbzArtistID).To(Equal(mbid2)) - }) - }) - - When("MUSICBRAINZ_PERFORMERID tag is set", func() { - matchPerformer := func(name, orderName, subRole, mbid string) types.GomegaMatcher { - return MatchFields(IgnoreExtras, Fields{ - "Artist": MatchFields(IgnoreExtras, Fields{ - "Name": Equal(name), - "OrderArtistName": Equal(orderName), - "MbzArtistID": Equal(mbid), - }), - "SubRole": Equal(subRole), - }) - } - - It("should map MBIDs to the correct performer", func() { - mf = toMediaFile(model.RawTags{ - "PERFORMER:GUITAR": {"Eric Clapton", "B.B. King"}, - "PERFORMER:BASS": {"Nathan East"}, - "MUSICBRAINZ_PERFORMERID:GUITAR": {"mbid1", "mbid2"}, - "MUSICBRAINZ_PERFORMERID:BASS": {"mbid3"}, - }) - - participants := mf.Participants - Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(3))) - - p := participants[model.RolePerformer] - Expect(p).To(ContainElements( - matchPerformer("Eric Clapton", "eric clapton", "Guitar", "mbid1"), - matchPerformer("B.B. King", "b.b. king", "Guitar", "mbid2"), - matchPerformer("Nathan East", "nathan east", "Bass", "mbid3"), - )) - }) - - It("should handle mismatched performer names and MBIDs for sub-roles", func() { - mf = toMediaFile(model.RawTags{ - "PERFORMER:VOCALS": {"Singer A", "Singer B", "Singer C"}, - "MUSICBRAINZ_PERFORMERID:VOCALS": {"mbid_vocals_a", "mbid_vocals_b"}, // Fewer MBIDs - "PERFORMER:DRUMS": {"Drummer X"}, - "MUSICBRAINZ_PERFORMERID:DRUMS": {"mbid_drums_x", "mbid_drums_y"}, // More MBIDs - }) - - participants := mf.Participants - Expect(participants).To(HaveKeyWithValue(model.RolePerformer, HaveLen(4))) // 3 vocalists + 1 drummer - - p := participants[model.RolePerformer] - Expect(p).To(ContainElements( - matchPerformer("Singer A", "singer a", "Vocals", "mbid_vocals_a"), - matchPerformer("Singer B", "singer b", "Vocals", "mbid_vocals_b"), - matchPerformer("Singer C", "singer c", "Vocals", ""), - matchPerformer("Drummer X", "drummer x", "Drums", "mbid_drums_x"), - )) - }) - }) - }) - - Describe("Other tags", func() { - DescribeTable("should return the correct participation", - func(role model.Role, tag string) { - mf = toMediaFile(model.RawTags{ - tag: {"John Doe", "Jane Doe"}, - }) - - participants := mf.Participants - Expect(participants).To(HaveKeyWithValue(role, HaveLen(2))) - - p := participants[role] - Expect(p[0].ID).ToNot(BeEmpty()) - Expect(p[0].Name).To(Equal("John Doe")) - Expect(p[0].OrderArtistName).To(Equal("john doe")) - Expect(p[1].ID).ToNot(BeEmpty()) - Expect(p[1].Name).To(Equal("Jane Doe")) - Expect(p[1].OrderArtistName).To(Equal("jane doe")) - }, - Entry("CONDUCTOR", model.RoleConductor, "CONDUCTOR"), - Entry("ARRANGER", model.RoleArranger, "ARRANGER"), - Entry("PRODUCER", model.RoleProducer, "PRODUCER"), - Entry("ENGINEER", model.RoleEngineer, "ENGINEER"), - Entry("MIXER", model.RoleMixer, "MIXER"), - Entry("REMIXER", model.RoleRemixer, "REMIXER"), - Entry("DJMIXER", model.RoleDJMixer, "DJMIXER"), - Entry("DIRECTOR", model.RoleDirector, "DIRECTOR"), - ) - }) - - Describe("Role value splitting", func() { - When("the tag is single valued", func() { - It("should split the values by the configured separator", func() { - mf = toMediaFile(model.RawTags{ - "COMPOSER": {"John Doe/Someone Else/The Album Artist"}, - }) - - participants := mf.Participants - Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3))) - composers := participants[model.RoleComposer] - Expect(composers[0].Name).To(Equal("John Doe")) - Expect(composers[1].Name).To(Equal("Someone Else")) - Expect(composers[2].Name).To(Equal("The Album Artist")) - }) - It("should not add an empty participant after split", func() { - mf = toMediaFile(model.RawTags{ - "COMPOSER": {"John Doe/"}, - }) - - participants := mf.Participants - Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(1))) - composers := participants[model.RoleComposer] - Expect(composers[0].Name).To(Equal("John Doe")) - }) - It("should trim the values", func() { - mf = toMediaFile(model.RawTags{ - "COMPOSER": {"John Doe / Someone Else / The Album Artist"}, - }) - - participants := mf.Participants - Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3))) - composers := participants[model.RoleComposer] - Expect(composers[0].Name).To(Equal("John Doe")) - Expect(composers[1].Name).To(Equal("Someone Else")) - Expect(composers[2].Name).To(Equal("The Album Artist")) - }) - }) - - // Sibling fix to https://github.com/navidrome/navidrome/issues/5065: when - // multiple frames map to the same role tag (e.g. TIPL producer entries), - // the configured split separator must still apply to each value. - When("the tag has multiple values", func() { - It("should split each value individually", func() { - mf = toMediaFile(model.RawTags{ - "COMPOSER": {"John Doe/Jane Doe", "Someone Else"}, - }) - - participants := mf.Participants - Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3))) - composers := participants[model.RoleComposer] - Expect(composers).To(ConsistOf( - HaveField("Name", "John Doe"), - HaveField("Name", "Jane Doe"), - HaveField("Name", "Someone Else"), - )) - }) - }) - }) - - Describe("MBID tags", func() { - It("should set the MBID for the artist based on the track/album artist", func() { - mf = toMediaFile(model.RawTags{ - "ARTIST": {"John Doe", "Jane Doe"}, - "MUSICBRAINZ_ARTISTID": {mbid1, mbid2}, - "ALBUMARTIST": {"The Album Artist"}, - "MUSICBRAINZ_ALBUMARTISTID": {mbid3}, - "COMPOSER": {"John Doe", "Someone Else", "The Album Artist"}, - "PRODUCER": {"Jane Doe", "John Doe"}, - }) - - participants := mf.Participants - Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3))) - composers := participants[model.RoleComposer] - Expect(composers[0].MbzArtistID).To(Equal(mbid1)) - Expect(composers[1].MbzArtistID).To(BeEmpty()) - Expect(composers[2].MbzArtistID).To(Equal(mbid3)) - - Expect(participants).To(HaveKeyWithValue(model.RoleProducer, HaveLen(2))) - producers := participants[model.RoleProducer] - Expect(producers[0].MbzArtistID).To(Equal(mbid2)) - Expect(producers[1].MbzArtistID).To(Equal(mbid1)) - }) - }) - - Describe("Non-standard MBID tags", func() { - var allMappings = map[model.Role]model.TagName{ - model.RoleComposer: model.TagMusicBrainzComposerID, - model.RoleLyricist: model.TagMusicBrainzLyricistID, - model.RoleConductor: model.TagMusicBrainzConductorID, - model.RoleArranger: model.TagMusicBrainzArrangerID, - model.RoleDirector: model.TagMusicBrainzDirectorID, - model.RoleProducer: model.TagMusicBrainzProducerID, - model.RoleEngineer: model.TagMusicBrainzEngineerID, - model.RoleMixer: model.TagMusicBrainzMixerID, - model.RoleRemixer: model.TagMusicBrainzRemixerID, - model.RoleDJMixer: model.TagMusicBrainzDJMixerID, - } - - It("should handle more artists than mbids", func() { - for key := range allMappings { - mf = toMediaFile(map[string][]string{ - key.String(): {"a", "b", "c"}, - allMappings[key].String(): {"f634bf6d-d66a-425d-888a-28ad39392759", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"}, - }) - - participants := mf.Participants - Expect(participants).To(HaveKeyWithValue(key, HaveLen(3))) - roles := participants[key] - - Expect(roles[0].Name).To(Equal("a")) - Expect(roles[1].Name).To(Equal("b")) - Expect(roles[2].Name).To(Equal("c")) - - Expect(roles[0].MbzArtistID).To(Equal("f634bf6d-d66a-425d-888a-28ad39392759")) - Expect(roles[1].MbzArtistID).To(Equal("3dfa3c70-d7d3-4b97-b953-c298dd305e12")) - Expect(roles[2].MbzArtistID).To(Equal("")) - } - }) - - It("should handle more mbids than artists", func() { - for key := range allMappings { - mf = toMediaFile(map[string][]string{ - key.String(): {"a", "b"}, - allMappings[key].String(): {"f634bf6d-d66a-425d-888a-28ad39392759", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"}, - }) - - participants := mf.Participants - Expect(participants).To(HaveKeyWithValue(key, HaveLen(2))) - roles := participants[key] - - Expect(roles[0].Name).To(Equal("a")) - Expect(roles[1].Name).To(Equal("b")) - - Expect(roles[0].MbzArtistID).To(Equal("f634bf6d-d66a-425d-888a-28ad39392759")) - Expect(roles[1].MbzArtistID).To(Equal("3dfa3c70-d7d3-4b97-b953-c298dd305e12")) - } - }) - - It("should refuse duplicate names if no mbid specified", func() { - for key := range allMappings { - mf = toMediaFile(map[string][]string{ - key.String(): {"a", "b", "a", "a"}, - }) - - participants := mf.Participants - Expect(participants).To(HaveKeyWithValue(key, HaveLen(2))) - roles := participants[key] - - Expect(roles[0].Name).To(Equal("a")) - Expect(roles[0].MbzArtistID).To(Equal("")) - Expect(roles[1].Name).To(Equal("b")) - Expect(roles[1].MbzArtistID).To(Equal("")) - } - }) - }) - - Describe("Artist split exceptions", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("does not split a whitelisted artist name on the default separators", func() { - // " feat. " is a default artists separator (mappings.yaml) - conf.Server.Scanner.ArtistSplitExceptions = []string{"Someone feat. Else"} - mf = toMediaFile(model.RawTags{ - "ARTIST": {"Artist Name feat. Someone feat. Else"}, - }) - - artists := mf.Participants[model.RoleArtist] - Expect(artists).To(HaveLen(2)) - Expect(artists[0].Name).To(Equal("Artist Name")) - Expect(artists[1].Name).To(Equal("Someone feat. Else")) - }) - - It("does not split a whitelisted name in role tags", func() { - // "/" is a default roles separator (mappings.yaml) - conf.Server.Scanner.ArtistSplitExceptions = []string{"AC/DC"} - mf = toMediaFile(model.RawTags{ - "COMPOSER": {"AC/DC/John Doe"}, - }) - - composers := mf.Participants[model.RoleComposer] - Expect(composers).To(HaveLen(2)) - Expect(composers[0].Name).To(Equal("AC/DC")) - Expect(composers[1].Name).To(Equal("John Doe")) - }) - - It("splits normally when the exception does not match", func() { - conf.Server.Scanner.ArtistSplitExceptions = []string{"Iron and Wine"} - mf = toMediaFile(model.RawTags{ - "ARTIST": {"Artist Name feat. Someone Else"}, - }) - - artists := mf.Participants[model.RoleArtist] - Expect(artists).To(HaveLen(2)) - Expect(artists[0].Name).To(Equal("Artist Name")) - Expect(artists[1].Name).To(Equal("Someone Else")) - }) - - It("does not split a whitelisted name in performer tags", func() { - conf.Server.Scanner.ArtistSplitExceptions = []string{"AC/DC"} - mf = toMediaFile(model.RawTags{ - "PERFORMER:GUITAR": {"AC/DC/Brian Johnson"}, - }) - - performers := mf.Participants[model.RolePerformer] - Expect(performers).To(HaveLen(2)) - Expect(performers[0].Name).To(Equal("AC/DC")) - Expect(performers[1].Name).To(Equal("Brian Johnson")) - }) - }) -}) diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go deleted file mode 100644 index 7843e7010..000000000 --- a/model/metadata/metadata.go +++ /dev/null @@ -1,414 +0,0 @@ -package metadata - -import ( - "cmp" - "io/fs" - "math" - "path" - "regexp" - "strconv" - "strings" - "time" - "unicode/utf8" - - "github.com/google/uuid" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" -) - -type Info struct { - FileInfo FileInfo - Tags model.RawTags - AudioProperties AudioProperties - HasPicture bool -} - -type FileInfo interface { - fs.FileInfo - BirthTime() time.Time -} - -type AudioProperties struct { - Duration time.Duration - BitRate int - BitDepth int - SampleRate int - Channels int - Codec string -} - -type Date string - -func (d Date) Year() int { - if d == "" { - return 0 - } - y, _ := strconv.Atoi(string(d[:4])) - return y -} - -type Pair string - -func (p Pair) Key() string { return p.parse(0) } -func (p Pair) Value() string { return p.parse(1) } -func (p Pair) parse(i int) string { - parts := strings.SplitN(string(p), consts.Zwsp, 2) - if len(parts) > i { - return parts[i] - } - return "" -} -func (p Pair) String() string { - return string(p) -} -func NewPair(key, value string) string { - return key + consts.Zwsp + value -} - -func New(filePath string, info Info) Metadata { - return Metadata{ - filePath: filePath, - fileInfo: info.FileInfo, - tags: clean(filePath, info.Tags), - audioProps: info.AudioProperties, - hasPicture: info.HasPicture, - } -} - -type Metadata struct { - filePath string - fileInfo FileInfo - tags model.Tags - audioProps AudioProperties - hasPicture bool -} - -func (md Metadata) FilePath() string { return md.filePath } -func (md Metadata) ModTime() time.Time { return md.fileInfo.ModTime() } -func (md Metadata) BirthTime() time.Time { return md.fileInfo.BirthTime() } -func (md Metadata) Size() int64 { return md.fileInfo.Size() } -func (md Metadata) Suffix() string { - return strings.ToLower(strings.TrimPrefix(path.Ext(md.filePath), ".")) -} -func (md Metadata) AudioProperties() AudioProperties { return md.audioProps } -func (md Metadata) Length() float32 { return float32(md.audioProps.Duration.Milliseconds()) / 1000 } -func (md Metadata) HasPicture() bool { return md.hasPicture } -func (md Metadata) All() model.Tags { return md.tags } -func (md Metadata) Strings(key model.TagName) []string { return md.tags[key] } -func (md Metadata) String(key model.TagName) string { return md.first(key) } -func (md Metadata) Int(key model.TagName) int64 { v, _ := strconv.Atoi(md.first(key)); return int64(v) } -func (md Metadata) Bool(key model.TagName) bool { v, _ := strconv.ParseBool(md.first(key)); return v } -func (md Metadata) Date(key model.TagName) Date { return md.date(key) } -func (md Metadata) NumAndTotal(key model.TagName) (int, int) { return md.tuple(key) } -func (md Metadata) Float(key model.TagName, def ...float64) float64 { - return float(md.first(key), def...) -} -func (md Metadata) NullableFloat(key model.TagName) *float64 { return nullableFloat(md.first(key)) } - -func (md Metadata) Gain(key model.TagName) *float64 { - v := strings.TrimSpace(strings.Replace(md.first(key), "dB", "", 1)) - return nullableFloat(v) -} -func (md Metadata) Pairs(key model.TagName) []Pair { - values := md.tags[key] - return slice.Map(values, func(v string) Pair { return Pair(v) }) -} -func (md Metadata) first(key model.TagName) string { - if v, ok := md.tags[key]; ok && len(v) > 0 { - return v[0] - } - return "" -} - -func float(value string, def ...float64) float64 { - v := nullableFloat(value) - if v != nil { - return *v - } - if len(def) > 0 { - return def[0] - } - return 0 -} - -func nullableFloat(value string) *float64 { - v, err := strconv.ParseFloat(value, 64) - if err != nil || v == math.Inf(-1) || math.IsInf(v, 1) || math.IsNaN(v) { - return nil - } - return &v -} - -// Used for tracks and discs -func (md Metadata) tuple(key model.TagName) (int, int) { - tag := md.first(key) - if tag == "" { - return 0, 0 - } - tuple := strings.Split(tag, "/") - total := md.first(key + "total") - if len(tuple) > 1 { - total = tuple[1] - } - return tagNumber(tuple[0]), tagNumber(total) -} - -// tagNumber rejects negatives and values above int32, so the DB stays readable by 32-bit builds. -func tagNumber(s string) int { - v, err := strconv.ParseInt(s, 10, 32) - if err != nil || v < 0 { - return 0 - } - return int(v) -} - -var dateRegex = regexp.MustCompile(`([12]\d\d\d)`) - -func (md Metadata) date(tagName model.TagName) Date { - return Date(md.first(tagName)) -} - -// date tries to parse a date from a tag, it tries to get at least the year. See the tests for examples. -func parseDate(filePath string, tagName model.TagName, tagValue string) string { - if len(tagValue) < 4 { - return "" - } - - // first get just the year - match := dateRegex.FindStringSubmatch(tagValue) - if len(match) == 0 { - log.Debug("Error parsing date", "file", filePath, "tag", tagName, "date", tagValue) - return "" - } - - // if the tag is just the year, return it - if len(tagValue) < 5 { - return match[1] - } - - // if the tag is too long, truncate it - tagValue = tagValue[:min(10, len(tagValue))] - - // then try to parse the full date - for _, mask := range []string{"2006-01-02", "2006-01"} { - _, err := time.Parse(mask, tagValue) - if err == nil { - return tagValue - } - } - log.Debug("Error parsing month and day from date", "file", filePath, "tag", tagName, "date", tagValue) - return match[1] -} - -// clean filters out tags that are not in the mappings or are empty, -// combine equivalent tags and remove duplicated values. -// It keeps the order of the tags names as they are defined in the mappings. -func clean(filePath string, tags model.RawTags) model.Tags { - lowered := lowerTags(tags) - mappings := model.TagMappings() - cleaned := make(model.Tags, len(mappings)) - - for name, mapping := range mappings { - mapping = mapping.WithParticipantExceptions(name) - var values []string - switch mapping.Type { - case model.TagTypePair: - values = processPairMapping(name, mapping, lowered) - default: - values = processRegularMapping(mapping, lowered) - } - cleaned[name] = values - } - - cleaned = filterEmptyTags(cleaned) - return sanitizeAll(filePath, cleaned) -} - -func processRegularMapping(mapping model.TagConf, lowered model.Tags) []string { - var values []string - for _, alias := range mapping.Aliases { - if vs, ok := lowered[model.TagName(alias)]; ok { - splitValues := mapping.SplitTagValue(vs) - values = append(values, splitValues...) - } - } - return values -} - -func lowerTags(tags model.RawTags) model.Tags { - lowered := make(model.Tags, len(tags)) - for k, v := range tags { - lowered[model.TagName(strings.ToLower(k))] = v - } - return lowered -} - -func processPairMapping(name model.TagName, mapping model.TagConf, lowered model.Tags) []string { - var aliasValues []string - for _, alias := range mapping.Aliases { - if vs, ok := lowered[model.TagName(alias)]; ok { - aliasValues = append(aliasValues, vs...) - } - } - - // always parse id3 pairs. For lyrics, Taglib appears to always provide lyrics:xxx - // Prefer that over format-specific tags - id3Base := parseID3Pairs(name, lowered) - - if len(aliasValues) > 0 { - // For lyrics, don't use parseVorbisPairs as parentheses in lyrics content - // should not be interpreted as language keys (e.g. "(intro)" is not a language) - if name == model.TagLyrics { - for _, v := range aliasValues { - id3Base = append(id3Base, NewPair("xxx", v)) - } - } else { - id3Base = append(id3Base, parseVorbisPairs(aliasValues)...) - } - } - return id3Base -} - -func parseID3Pairs(name model.TagName, lowered model.Tags) []string { - var pairs []string - prefix := string(name) + ":" - for tagKey, tagValues := range lowered { - keyStr := string(tagKey) - if after, ok := strings.CutPrefix(keyStr, prefix); ok { - keyPart := after - if keyPart == string(name) { - keyPart = "" - } - for _, v := range tagValues { - pairs = append(pairs, NewPair(keyPart, v)) - } - } - } - return pairs -} - -var vorbisPairRegex = regexp.MustCompile(`\(([^()]+(?:\([^()]*\)[^()]*)*)\)`) - -// parseVorbisPairs, from -// -// "Salaam Remi (drums (drum set) and organ)", -// -// to -// -// "drums (drum set) and organ" -> "Salaam Remi", -func parseVorbisPairs(values []string) []string { - pairs := make([]string, 0, len(values)) - for _, value := range values { - matches := vorbisPairRegex.FindAllStringSubmatch(value, -1) - if len(matches) == 0 { - pairs = append(pairs, NewPair("", value)) - continue - } - key := strings.TrimSpace(matches[0][1]) - key = strings.ToLower(key) - valueWithoutKey := strings.TrimSpace(strings.Replace(value, "("+matches[0][1]+")", "", 1)) - pairs = append(pairs, NewPair(key, valueWithoutKey)) - } - return pairs -} - -func filterEmptyTags(tags model.Tags) model.Tags { - for k, v := range tags { - clean := filterDuplicatedOrEmptyValues(v) - if len(clean) == 0 { - delete(tags, k) - } else { - tags[k] = clean - } - } - return tags -} - -func filterDuplicatedOrEmptyValues(values []string) []string { - seen := make(map[string]struct{}, len(values)) - var result []string - for _, v := range values { - if v == "" { - continue - } - if _, ok := seen[v]; ok { - continue - } - seen[v] = struct{}{} - result = append(result, v) - } - return result -} - -func sanitizeAll(filePath string, tags model.Tags) model.Tags { - cleaned := model.Tags{} - for k, v := range tags { - tag, found := model.TagMappings()[k] - if !found { - continue - } - - var values []string - for _, value := range v { - cleanedValue := sanitize(filePath, k, tag, value) - if cleanedValue != "" { - values = append(values, cleanedValue) - } - } - if len(values) > 0 { - cleaned[k] = values - } - } - return cleaned -} - -const defaultMaxTagLength = 1024 - -func sanitize(filePath string, tagName model.TagName, tag model.TagConf, value string) string { - // First truncate the value to the maximum length - maxLength := cmp.Or(tag.MaxLength, defaultMaxTagLength) - if len(value) > maxLength { - log.Trace("Truncated tag value", "tag", tagName, "value", value, "length", len(value), "maxLength", maxLength) - value = value[:maxLength] - // Drop the partial rune the cut may have left: at most 3 trailing bytes, - // so a pre-existing invalid run elsewhere is never consumed. - for range 3 { - if r, size := utf8.DecodeLastRuneInString(value); r != utf8.RuneError || size != 1 { - break - } - value = value[:len(value)-1] - } - } - - switch tag.Type { - case model.TagTypeDate: - value = parseDate(filePath, tagName, value) - if value == "" { - log.Trace("Invalid date tag value", "tag", tagName, "value", value) - } - case model.TagTypeInteger: - _, err := strconv.Atoi(value) - if err != nil { - log.Trace("Invalid integer tag value", "tag", tagName, "value", value) - return "" - } - case model.TagTypeFloat: - _, err := strconv.ParseFloat(value, 64) - if err != nil { - log.Trace("Invalid float tag value", "tag", tagName, "value", value) - return "" - } - case model.TagTypeUUID: - u, err := uuid.Parse(value) - if err != nil { - log.Trace("Invalid UUID tag value", "tag", tagName, "value", value) - return "" - } - // Store the canonical form: uuid.Parse accepts braces, urn: prefixes and any - // two-byte wrapper, and a wrapped value would never match an exact-match query - value = u.String() - } - return value -} diff --git a/model/metadata/metadata_internal_test.go b/model/metadata/metadata_internal_test.go deleted file mode 100644 index 99fbba328..000000000 --- a/model/metadata/metadata_internal_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package metadata - -import ( - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = DescribeTable("sanitize truncation with a tiny MaxLength", - // A value of only continuation bytes drains the partial-rune loop to empty; the loop - // must stop there instead of slicing value[:-1] and panicking. - func(maxLength int, value string) { - Expect(func() { - Expect(sanitize("file.mp3", "title", model.TagConf{MaxLength: maxLength}, value)).To(Equal("")) - }).NotTo(Panic()) - }, - Entry("maxLength 1", 1, "\x80\x80"), - Entry("maxLength 2", 2, "\x80\x80\x80"), - Entry("maxLength 3", 3, "\x80\x80\x80\x80"), -) diff --git a/model/metadata/metadata_suite_test.go b/model/metadata/metadata_suite_test.go deleted file mode 100644 index fc299c7e9..000000000 --- a/model/metadata/metadata_suite_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package metadata_test - -import ( - "io/fs" - "testing" - "time" - - "github.com/djherbis/times" - _ "github.com/mattn/go-sqlite3" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestMetadata(t *testing.T) { - tests.Init(t, true) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Metadata Suite") -} - -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() -} diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go deleted file mode 100644 index a1a675006..000000000 --- a/model/metadata/metadata_test.go +++ /dev/null @@ -1,367 +0,0 @@ -package metadata_test - -import ( - "os" - "strings" - "time" - "unicode/utf8" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/metadata" - "github.com/navidrome/navidrome/utils" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Metadata", func() { - var ( - filePath string - fileInfo os.FileInfo - props metadata.Info - md metadata.Metadata - ) - - BeforeEach(func() { - // It is easier to have a real file to test the mod and birth times - filePath = utils.TempFileName("test", ".mp3") - f, _ := os.Create(filePath) - DeferCleanup(func() { - _ = f.Close() - _ = os.Remove(filePath) - }) - - fileInfo, _ = os.Stat(filePath) - props = metadata.Info{ - AudioProperties: metadata.AudioProperties{ - Duration: time.Minute * 3, - BitRate: 320, - }, - HasPicture: true, - FileInfo: testFileInfo{fileInfo}, - } - }) - - Describe("Metadata", func() { - Describe("New", func() { - It("should create a new Metadata object with the correct properties", func() { - props.Tags = model.RawTags{ - "©ART": {"First Artist", "Second Artist"}, - "----:com.apple.iTunes:CATALOGNUMBER": {"1234"}, - "tbpm": {"120.6"}, - "WM/IsCompilation": {"1"}, - } - md = metadata.New(filePath, props) - - Expect(md.FilePath()).To(Equal(filePath)) - Expect(md.ModTime()).To(Equal(fileInfo.ModTime())) - Expect(md.BirthTime()).To(BeTemporally("~", md.ModTime(), time.Second)) - Expect(md.Size()).To(Equal(fileInfo.Size())) - Expect(md.Suffix()).To(Equal("mp3")) - Expect(md.AudioProperties()).To(Equal(props.AudioProperties)) - Expect(md.Length()).To(Equal(float32(3 * 60))) - Expect(md.HasPicture()).To(Equal(props.HasPicture)) - Expect(md.Strings(model.TagTrackArtist)).To(Equal([]string{"First Artist", "Second Artist"})) - Expect(md.String(model.TagTrackArtist)).To(Equal("First Artist")) - Expect(md.Int(model.TagCatalogNumber)).To(Equal(int64(1234))) - Expect(md.Float(model.TagBPM)).To(Equal(120.6)) - Expect(md.Bool(model.TagCompilation)).To(BeTrue()) - Expect(md.All()).To(SatisfyAll( - HaveLen(4), - HaveKeyWithValue(model.TagTrackArtist, []string{"First Artist", "Second Artist"}), - HaveKeyWithValue(model.TagBPM, []string{"120.6"}), - HaveKeyWithValue(model.TagCompilation, []string{"1"}), - HaveKeyWithValue(model.TagCatalogNumber, []string{"1234"}), - )) - - }) - - It("should clean the tags map correctly", func() { - const unknownTag = "UNKNOWN_TAG" - props.Tags = model.RawTags{ - "TPE1": {"Artist Name", "Artist Name", ""}, - "©ART": {"Second Artist"}, - "CatalogNumber": {""}, - "Album": {"Album Name", "", "Album Name"}, - "Date": {"2022-10-02 12:15:01"}, - "Year": {"2022", "2022", ""}, - "Genre": {"Pop", "", "Pop", "Rock"}, - "Track": {"1/10", "1/10", ""}, - unknownTag: {"value"}, - } - md = metadata.New(filePath, props) - - Expect(md.All()).To(SatisfyAll( - Not(HaveKey(unknownTag)), - HaveKeyWithValue(model.TagTrackArtist, []string{"Artist Name", "Second Artist"}), - HaveKeyWithValue(model.TagAlbum, []string{"Album Name"}), - HaveKeyWithValue(model.TagRecordingDate, []string{"2022-10-02"}), - HaveKeyWithValue(model.TagReleaseDate, []string{"2022"}), - HaveKeyWithValue(model.TagGenre, []string{"Pop", "Rock"}), - HaveKeyWithValue(model.TagTrackNumber, []string{"1/10"}), - HaveLen(6), - )) - }) - - It("should truncate long strings", func() { - props.Tags = model.RawTags{ - "Title": {strings.Repeat("a", 2048)}, - "Comment": {strings.Repeat("a", 8192)}, - "lyrics:xxx": {strings.Repeat("a", 2_000_000)}, - } - md = metadata.New(filePath, props) - - Expect(md.String(model.TagTitle)).To(HaveLen(1024)) - Expect(md.String(model.TagComment)).To(HaveLen(4096)) - pair := md.Pairs(model.TagLyrics) - - Expect(pair).To(HaveLen(1)) - Expect(pair[0].Key()).To(Equal("xxx")) - - // Lyrics keep a much larger cap so word-timed karaoke survives. - // Note: a total of 6 characters are lost from maxLength from - // the key portion and separator. - Expect(pair[0].Value()).To(HaveLen(1048570)) - }) - - It("should not split a multi-byte character when truncating", func() { - // 1024 is not a multiple of 3, so a byte-wise cut lands mid-rune. - props.Tags = model.RawTags{ - "Title": {strings.Repeat("日", 2048)}, - } - md = metadata.New(filePath, props) - - title := md.String(model.TagTitle) - Expect(utf8.ValidString(title)).To(BeTrue(), "truncation produced invalid UTF-8") - Expect(len(title)).To(BeNumerically("<=", 1024)) - }) - - It("should keep invalid bytes that are not at the truncation point", func() { - props.Tags = model.RawTags{ - "Title": {"a\xffb" + strings.Repeat("c", 2048)}, - } - md = metadata.New(filePath, props) - - Expect(md.String(model.TagTitle)).To(HaveLen(1024)) - }) - - DescribeTable("should normalize UUID tags to their canonical form", - func(raw, expected string) { - props.Tags = model.RawTags{"musicbrainz_artistid": {raw}} - md = metadata.New(filePath, props) - - Expect(md.String(model.TagMusicBrainzArtistID)).To(Equal(expected)) - }, - Entry("canonical", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), - Entry("uppercase", "F81D4FAE-7DEC-11D0-A765-00A0C91E6BF6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), - Entry("braced", "{f81d4fae-7dec-11d0-a765-00a0c91e6bf6}", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), - Entry("urn prefix", "urn:uuid:f81d4fae-7dec-11d0-a765-00a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), - Entry("quoted", `"f81d4fae-7dec-11d0-a765-00a0c91e6bf6"`, "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), - Entry("no dashes", "f81d4fae7dec11d0a76500a0c91e6bf6", "f81d4fae-7dec-11d0-a765-00a0c91e6bf6"), - Entry("not a uuid", "the beatles", ""), - ) - - It("should split multiple values", func() { - props.Tags = model.RawTags{ - "Genre": {"Rock/Pop;;Punk"}, - } - md = metadata.New(filePath, props) - - Expect(md.Strings(model.TagGenre)).To(Equal([]string{"Rock", "Pop", "Punk"})) - }) - - // Regression test for https://github.com/navidrome/navidrome/issues/5065 - // - // MP3s with both an ID3v2 TMOO frame and a TXXX:MOOD frame are surfaced by - // TagLib's PropertyMap as a single "mood" key with multiple values. The split - // configuration must still apply to each value individually. - It("should split values from multiple frames mapping to the same tag", func() { - props.Tags = model.RawTags{ - // Same shape as the bug report: two frames, comma-separated content. - "mood": {"Love, Emotional, Ballad", "Love; Emotional; Ballad"}, - } - md = metadata.New(filePath, props) - - Expect(md.Strings(model.TagMood)).To(ConsistOf("Love", "Emotional", "Ballad")) - }) - }) - - DescribeTable("Date", - func(value string, expectedYear int, expectedDate string) { - props.Tags = model.RawTags{ - "date": {value}, - } - md = metadata.New(filePath, props) - - testDate := md.Date(model.TagRecordingDate) - Expect(string(testDate)).To(Equal(expectedDate)) - Expect(testDate.Year()).To(Equal(expectedYear)) - }, - Entry(nil, "1985", 1985, "1985"), - Entry(nil, "2002-01", 2002, "2002-01"), - Entry(nil, "1969.06", 1969, "1969"), - Entry(nil, "1980.07.25", 1980, "1980"), - Entry(nil, "2004-00-00", 2004, "2004"), - Entry(nil, "2016-12-31", 2016, "2016-12-31"), - Entry(nil, "2016-12-31 12:15", 2016, "2016-12-31"), - Entry(nil, "2013-May-12", 2013, "2013"), - Entry(nil, "May 12, 2016", 2016, "2016"), - Entry(nil, "01/10/1990", 1990, "1990"), - Entry(nil, "invalid", 0, ""), - ) - - DescribeTable("NumAndTotal", - func(num, total string, expectedNum int, expectedTotal int) { - props.Tags = model.RawTags{ - "Track": {num}, - "TrackTotal": {total}, - } - md = metadata.New(filePath, props) - - testNum, testTotal := md.NumAndTotal(model.TagTrackNumber) - Expect(testNum).To(Equal(expectedNum)) - Expect(testTotal).To(Equal(expectedTotal)) - }, - Entry(nil, "2", "", 2, 0), - Entry(nil, "2", "10", 2, 10), - Entry(nil, "2/10", "", 2, 10), - Entry(nil, "", "", 0, 0), - Entry(nil, "A", "", 0, 0), - Entry("ignores values that do not fit in 32 bits", "4294967295", "4294967296", 0, 0), - Entry("ignores a total that does not fit in 32 bits", "2/4294967295", "", 2, 0), - Entry("keeps the largest 32-bit value", "2147483647", "", 2147483647, 0), - Entry("ignores negative values", "-1", "-2", 0, 0), - ) - - Describe("Performers", func() { - Describe("ID3", func() { - BeforeEach(func() { - props.Tags = model.RawTags{ - "PERFORMER:GUITAR": {"Guitarist 1", "Guitarist 2"}, - "PERFORMER:BACKGROUND VOCALS": {"Backing Singer"}, - "PERFORMER:PERFORMER": {"Wonderlove", "Lovewonder"}, - } - }) - - It("should return the performers", func() { - md = metadata.New(filePath, props) - - Expect(md.All()).To(HaveKey(model.TagPerformer)) - Expect(md.Strings(model.TagPerformer)).To(ConsistOf( - metadata.NewPair("guitar", "Guitarist 1"), - metadata.NewPair("guitar", "Guitarist 2"), - metadata.NewPair("background vocals", "Backing Singer"), - metadata.NewPair("", "Wonderlove"), - metadata.NewPair("", "Lovewonder"), - )) - }) - }) - - Describe("Vorbis", func() { - BeforeEach(func() { - props.Tags = model.RawTags{ - "PERFORMER": { - "John Adams (Rhodes piano)", - "Vincent Henry (alto saxophone, baritone saxophone and tenor saxophone)", - "Salaam Remi (drums (drum set) and organ)", - "Amy Winehouse (guitar)", - "Amy Winehouse (vocals)", - "Wonderlove", - }, - } - }) - - It("should return the performers", func() { - md = metadata.New(filePath, props) - - Expect(md.All()).To(HaveKey(model.TagPerformer)) - Expect(md.Strings(model.TagPerformer)).To(ConsistOf( - metadata.NewPair("rhodes piano", "John Adams"), - metadata.NewPair("alto saxophone, baritone saxophone and tenor saxophone", "Vincent Henry"), - metadata.NewPair("drums (drum set) and organ", "Salaam Remi"), - metadata.NewPair("guitar", "Amy Winehouse"), - metadata.NewPair("vocals", "Amy Winehouse"), - metadata.NewPair("", "Wonderlove"), - )) - }) - }) - }) - - Describe("Lyrics", func() { - BeforeEach(func() { - props.Tags = model.RawTags{ - "LYRICS:POR": {"Letras"}, - "LYRICS:ENG": {"Lyrics"}, - } - }) - - It("should return the lyrics", func() { - md = metadata.New(filePath, props) - - Expect(md.All()).To(HaveKey(model.TagLyrics)) - Expect(md.Strings(model.TagLyrics)).To(ContainElements( - metadata.NewPair("por", "Letras"), - metadata.NewPair("eng", "Lyrics"), - )) - }) - - It("should preserve lyrics starting with parentheses from alias tags", func() { - props.Tags = model.RawTags{ - "LYRICS": {"(line one)\nline two\nline three"}, - } - md = metadata.New(filePath, props) - - Expect(md.All()).To(HaveKey(model.TagLyrics)) - Expect(md.Strings(model.TagLyrics)).To(ContainElements( - metadata.NewPair("xxx", "(line one)\nline two\nline three"), - )) - }) - }) - - Describe("ReplayGain", func() { - createMF := func(tag, tagValue string) model.MediaFile { - props.Tags = model.RawTags{ - tag: {tagValue}, - } - md = metadata.New(filePath, props) - return md.ToMediaFile(model.Library{}, "0") - } - - DescribeTable("Gain", - func(tagValue string, expected *float64) { - mf := createMF("replaygain_track_gain", tagValue) - Expect(mf.RGTrackGain).To(Equal(expected)) - }, - Entry("0", "0", new(0.0)), - Entry("1.2dB", "1.2dB", new(1.2)), - Entry("Infinity", "Infinity", nil), - Entry("Invalid value", "INVALID VALUE", nil), - Entry("NaN", "NaN", nil), - ) - DescribeTable("Peak", - func(tagValue string, expected *float64) { - mf := createMF("replaygain_track_peak", tagValue) - Expect(mf.RGTrackPeak).To(Equal(expected)) - }, - Entry("0", "0", new(0.0)), - Entry("1.0", "1.0", new(1.0)), - Entry("0.5", "0.5", new(0.5)), - Entry("Invalid dB suffix", "0.7dB", nil), - Entry("Infinity", "Infinity", nil), - Entry("Invalid value", "INVALID VALUE", nil), - Entry("NaN", "NaN", nil), - ) - DescribeTable("getR128GainValue", - func(tagValue string, expected *float64) { - mf := createMF("r128_track_gain", tagValue) - Expect(mf.RGTrackGain).To(Equal(expected)) - - }, - Entry("0", "0", new(5.0)), - Entry("-3776", "-3776", new(-9.75)), - Entry("Infinity", "Infinity", nil), - Entry("Invalid value", "INVALID VALUE", nil), - ) - }) - - }) -}) diff --git a/model/metadata/persistent_ids.go b/model/metadata/persistent_ids.go deleted file mode 100644 index b66ce824a..000000000 --- a/model/metadata/persistent_ids.go +++ /dev/null @@ -1,141 +0,0 @@ -package metadata - -import ( - "cmp" - "errors" - "fmt" - "path/filepath" - "strings" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/str" -) - -type hashFunc = func(...string) string - -// 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. albumSpec is the album PID -// spec used to resolve the `albumid` attribute. -// -// 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, albumSpec 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, albumSpec, hash) - if v != "" { - hasValue = true - } - values[i] = v - } - if hasValue { - pid += strings.Join(values, "\\") - break - } - } - if prependLibId { - pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid) - } - return hash(pid) -} - -func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec, albumSpec string, hash hashFunc) string { - attr = strings.TrimSpace(strings.ToLower(attr)) - switch attr { - case "albumid": - if spec == albumSpec { - log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec) - return "" - } - return computePID(mf, md, albumSpec, albumSpec, 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)) -} - -// ValidatePIDSpec checks a PID override before it is stored; empty means "use the global config". -// Aliases resolve to empty at scan time: accepted only in track specs, because the default one uses them. -func ValidatePIDSpec(spec string, isAlbum bool) error { - switch { - case spec == "", isAlbum && spec == "album_legacy", !isAlbum && spec == "track_legacy": - return nil - } - for field := range strings.SplitSeq(spec, "|") { - for attr := range strings.SplitSeq(field, ",") { - attr = strings.TrimSpace(strings.ToLower(attr)) - switch attr { - case "": - return fmt.Errorf("empty attribute in %q", spec) - case "albumid": - if isAlbum { - return errors.New("albumid cannot be used in an album PID") - } - case "folder", "albumartistid": - default: - name, ok := model.CanonicalTagName(attr) - if !ok { - return fmt.Errorf("unknown attribute %q", attr) - } - if isAlbum && string(name) != attr { - return fmt.Errorf("use the tag name %q instead of its alias %q", name, attr) - } - } - } - } - return nil -} - -func (md Metadata) trackPID(mf model.MediaFile, pid model.PIDConfig) string { - return computePID(mf, md, pid.Track, pid.Album, true, id.NewHash) -} - -func (md Metadata) albumID(mf model.MediaFile, pidConf string) string { - return computePID(mf, md, pidConf, pidConf, true, id.NewHash) -} - -// BFR Must be configurable? -func (md Metadata) artistID(name string) string { - mf := model.MediaFile{AlbumArtist: name} - return computePID(mf, md, "albumartistid", "", false, id.NewHash) -} - -func (md Metadata) mapTrackTitle() string { - if title := md.String(model.TagTitle); title != "" { - return title - } - return utils.BaseName(md.FilePath()) -} - -func (md Metadata) mapAlbumName() string { - return cmp.Or( - md.String(model.TagAlbum), - consts.UnknownAlbum, - ) -} diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go deleted file mode 100644 index 9f6eaf1f4..000000000 --- a/model/metadata/persistent_ids_test.go +++ /dev/null @@ -1,338 +0,0 @@ -package metadata - -import ( - "strings" - - "github.com/navidrome/navidrome/consts" - "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 - albumSpec string - ) - getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { - return computePID(mf, md, spec, albumSpec, prependLibId, sum) - } - - BeforeEach(func() { - sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" } - albumSpec = consts.DefaultAlbumPID - }) - - Context("attributes are tags", func() { - spec := "musicbrainz_trackid|album,discnumber,tracknumber" - When("no attributes were present", func() { - It("should return empty pid", func() { - md.tags = map[model.TagName][]string{} - pid := getPID(mf, md, spec, false) - Expect(pid).To(Equal("()")) - }) - }) - When("all fields are present", func() { - It("should return the pid", func() { - md.tags = map[model.TagName][]string{ - "musicbrainz_trackid": {"mbtrackid"}, - "album": {"album name"}, - "discnumber": {"1"}, - "tracknumber": {"1"}, - } - Expect(getPID(mf, md, spec, false)).To(Equal("(mbtrackid)")) - }) - }) - When("only first field is present", func() { - It("should return the pid", func() { - md.tags = map[model.TagName][]string{ - "musicbrainz_trackid": {"mbtrackid"}, - } - Expect(getPID(mf, md, spec, false)).To(Equal("(mbtrackid)")) - }) - }) - When("first is empty, but second field is present", func() { - It("should return the pid", func() { - md.tags = map[model.TagName][]string{ - "album": {"album name"}, - "discnumber": {"1"}, - } - Expect(getPID(mf, md, spec, false)).To(Equal("(album name\\1\\)")) - }) - }) - }) - - Context("calculated attributes", func() { - BeforeEach(func() { - albumSpec = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" - }) - When("field is title", func() { - It("should return the pid", func() { - spec := "title|folder" - md.tags = map[model.TagName][]string{"title": {"title"}} - md.filePath = "/path/to/file.mp3" - mf.Title = "Title" - Expect(getPID(mf, md, spec, false)).To(Equal("(Title)")) - }) - }) - 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" - Expect(getPID(mf, md, spec, false)).To(Equal("(/path/to)")) - }) - }) - When("field is albumid", func() { - It("should return the pid", func() { - spec := "albumid|title" - md.tags = map[model.TagName][]string{ - "title": {"title"}, - "album": {"album name"}, - "albumversion": {"deluxe edition"}, - "releasedate": {"2021-01-01"}, - } - mf.AlbumArtist = "Album Artist" - Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\deluxe edition\\2021-01-01))")) - }) - }) - When("field is albumartistid", func() { - It("should return the pid", func() { - spec := "musicbrainz_albumartistid|albumartistid" - md.tags = map[model.TagName][]string{ - "albumartist": {"Album Artist"}, - } - mf.AlbumArtist = "Album Artist" - Expect(getPID(mf, md, spec, false)).To(Equal("((album artist))")) - }) - }) - When("field is album", func() { - It("should return the pid", func() { - spec := "album|title" - md.tags = map[model.TagName][]string{"album": {"Album Name"}} - Expect(getPID(mf, md, spec, false)).To(Equal("(album name)")) - }) - }) - - When("albumid configuration refers to albumid recursively", func() { - It("should avoid infinite recursion", func() { - // Reproduce the issue from #4920 - albumSpec = "albumid,album,albumversion,releasedate" - spec := albumSpec - md.tags = map[model.TagName][]string{ - "album": {"Album Name"}, - "albumversion": {"Version"}, - "releasedate": {"2022"}, - } - // Should not panic and return a valid PID ignoring the recursive "albumid" - Expect(func() { - pid := getPID(mf, md, spec, false) - Expect(pid).To(Equal("(\\album name\\Version\\2022)")) - }).To(Not(Panic())) - }) - }) - }) - - Context("edge cases", func() { - When("the spec has spaces between groups", func() { - It("should return the pid", func() { - spec := "albumartist| Album" - md.tags = map[model.TagName][]string{ - "album": {"album name"}, - } - Expect(getPID(mf, md, spec, false)).To(Equal("(album name)")) - }) - }) - When("the spec has spaces", func() { - It("should return the pid", func() { - spec := "albumartist, album" - md.tags = map[model.TagName][]string{ - "albumartist": {"Album Artist"}, - "album": {"album name"}, - } - Expect(getPID(mf, md, spec, false)).To(Equal("(Album Artist\\album name)")) - }) - }) - When("the spec has mixed case fields", func() { - It("should return the pid", func() { - spec := "albumartist,Album" - md.tags = map[model.TagName][]string{ - "albumartist": {"Album Artist"}, - "album": {"album name"}, - } - Expect(getPID(mf, md, spec, false)).To(Equal("(Album Artist\\album name)")) - }) - }) - }) - - Context("prependLibId functionality", func() { - BeforeEach(func() { - mf.LibraryID = 42 - }) - When("prependLibId is true", func() { - It("should prepend library ID to the hash input", func() { - spec := "album" - md.tags = map[model.TagName][]string{"album": {"Test Album"}} - pid := getPID(mf, md, spec, true) - // The hash function should receive "42\test album" as input - Expect(pid).To(Equal("(42\\test album)")) - }) - }) - When("prependLibId is false", func() { - It("should not prepend library ID to the hash input", func() { - spec := "album" - md.tags = map[model.TagName][]string{"album": {"Test Album"}} - pid := getPID(mf, md, spec, false) - // The hash function should receive "test album" as input - Expect(pid).To(Equal("(test album)")) - }) - }) - When("prependLibId is true with complex spec", func() { - It("should prepend library ID to the final hash input", func() { - spec := "musicbrainz_trackid|album,tracknumber" - md.tags = map[model.TagName][]string{ - "album": {"Test Album"}, - "tracknumber": {"1"}, - } - pid := getPID(mf, md, spec, true) - // Should use the fallback field and prepend library ID - Expect(pid).To(Equal("(42\\test album\\1)")) - }) - }) - When("prependLibId is true with nested albumid", func() { - It("should handle nested albumid calls correctly", func() { - albumSpec = "album" - spec := "albumid" - md.tags = map[model.TagName][]string{"album": {"Test Album"}} - mf.AlbumArtist = "Test Artist" - pid := getPID(mf, md, spec, true) - // The albumid call should also use prependLibId=true - Expect(pid).To(Equal("(42\\(42\\test album))")) - }) - }) - }) - - Context("legacy specs", func() { - It("emits canonical 22-char base62 ids", func() { - mf := model.MediaFile{Path: "/music/a.mp3", LibraryID: 1} - // md5("/music/a.mp3") = e3b7fc2ae9447bbec37a13bf916e3cf6 re-encoded as base62 - Expect(legacyTrackID(mf, false)).To(Equal("6VHl3uR4kss6sUPKA8Cwnk")) - }) - It("prepends the library id for a non-default library", func() { - mf := model.MediaFile{Path: "/music/a.mp3", LibraryID: 2} - // id.Encode(md5.Sum([]byte("2\\/music/a.mp3"))) - Expect(legacyTrackID(mf, true)).To(Equal("4EK5DHQBMeFuDHw6S3iooO")) - }) - It("emits a canonical album id (golden)", func() { - mf := model.MediaFile{LibraryID: 1} - // id.Encode(md5.Sum([]byte("[unknown artist]\\[unknown album]"))) - Expect(legacyAlbumID(mf, Metadata{}, false)).To(Equal("6xBmxSAUFJSQuW7UvwCq8X")) - }) - Context("track_legacy", func() { - When("library ID is default (1)", func() { - It("should not prepend library ID even when prependLibId is true", func() { - mf.Path = "/path/to/track.mp3" - mf.LibraryID = 1 // Default library ID - // With default library, both should be the same - pidTrue := getPID(mf, md, "track_legacy", true) - pidFalse := getPID(mf, md, "track_legacy", false) - Expect(pidTrue).To(Equal(pidFalse)) - Expect(pidTrue).NotTo(BeEmpty()) - }) - }) - When("library ID is non-default", func() { - It("should prepend library ID when prependLibId is true", func() { - mf.Path = "/path/to/track.mp3" - mf.LibraryID = 2 // Non-default library ID - pidTrue := getPID(mf, md, "track_legacy", true) - pidFalse := getPID(mf, md, "track_legacy", false) - Expect(pidTrue).NotTo(Equal(pidFalse)) - Expect(pidTrue).NotTo(BeEmpty()) - Expect(pidFalse).NotTo(BeEmpty()) - }) - }) - When("library ID is non-default but prependLibId is false", func() { - It("should not prepend library ID", func() { - mf.Path = "/path/to/track.mp3" - mf.LibraryID = 3 - mf2 := mf - mf2.LibraryID = 1 // Default library - pidNonDefault := getPID(mf, md, "track_legacy", false) - pidDefault := getPID(mf2, md, "track_legacy", false) - // Should be the same since prependLibId=false - Expect(pidNonDefault).To(Equal(pidDefault)) - }) - }) - }) - Context("album_legacy", func() { - When("library ID is default (1)", func() { - It("should not prepend library ID even when prependLibId is true", func() { - md.tags = map[model.TagName][]string{"album": {"Test Album"}} - mf.LibraryID = 1 // Default library ID - pidTrue := getPID(mf, md, "album_legacy", true) - pidFalse := getPID(mf, md, "album_legacy", false) - Expect(pidTrue).To(Equal(pidFalse)) - Expect(pidTrue).NotTo(BeEmpty()) - }) - }) - When("library ID is non-default", func() { - It("should prepend library ID when prependLibId is true", func() { - md.tags = map[model.TagName][]string{"album": {"Test Album"}} - mf.LibraryID = 2 // Non-default library ID - pidTrue := getPID(mf, md, "album_legacy", true) - pidFalse := getPID(mf, md, "album_legacy", false) - Expect(pidTrue).NotTo(Equal(pidFalse)) - Expect(pidTrue).NotTo(BeEmpty()) - Expect(pidFalse).NotTo(BeEmpty()) - }) - }) - When("library ID is non-default but prependLibId is false", func() { - It("should not prepend library ID", func() { - md.tags = map[model.TagName][]string{"album": {"Test Album"}} - mf.LibraryID = 3 - mf2 := mf - mf2.LibraryID = 1 // Default library - pidNonDefault := getPID(mf, md, "album_legacy", false) - pidDefault := getPID(mf2, md, "album_legacy", false) - // Should be the same since prependLibId=false - Expect(pidNonDefault).To(Equal(pidDefault)) - }) - }) - }) - }) -}) - -var _ = Describe("ValidatePIDSpec", func() { - DescribeTable("accepts valid specs", - func(spec string, isAlbum bool) { - Expect(ValidatePIDSpec(spec, isAlbum)).To(Succeed()) - }, - Entry("empty, meaning the global config", "", true), - Entry("default album spec", consts.DefaultAlbumPID, true), - Entry("default track spec, which uses tag aliases", consts.DefaultTrackPID, false), - Entry("folder", "folder", true), - Entry("album legacy", "album_legacy", true), - Entry("track legacy", "track_legacy", false), - Entry("computed attributes", "albumartistid,album|title", true), - Entry("albumid in a track spec", "albumid,title", false), - Entry("spaces and mixed case", "MusicBrainz_AlbumID | Folder", true), - ) - - DescribeTable("rejects invalid specs", - func(spec string, isAlbum bool, msg string) { - Expect(ValidatePIDSpec(spec, isAlbum)).To(MatchError(ContainSubstring(msg))) - }, - Entry("unknown tag", "albmversion", true, `unknown attribute "albmversion"`), - Entry("empty field", "album||title", true, "empty attribute"), - Entry("empty attribute", "album,,title", true, "empty attribute"), - Entry("trailing separator", "album|", true, "empty attribute"), - Entry("albumid in an album spec", "albumid,album", true, "albumid"), - Entry("tag alias in an album spec", "talb", true, `use the tag name "album" instead of its alias "talb"`), - Entry("track legacy in an album spec", "track_legacy", true, `unknown attribute "track_legacy"`), - Entry("album legacy in a track spec", "album_legacy", false, `unknown attribute "album_legacy"`), - ) -}) diff --git a/model/participants.go b/model/participants.go deleted file mode 100644 index b5390d22c..000000000 --- a/model/participants.go +++ /dev/null @@ -1,199 +0,0 @@ -package model - -import ( - "cmp" - "fmt" - "slices" - "strings" - - "github.com/navidrome/navidrome/utils/slice" - "github.com/zeebo/xxh3" -) - -var ( - RoleInvalid = Role{"invalid"} - RoleArtist = Role{"artist"} - RoleAlbumArtist = Role{"albumartist"} - RoleComposer = Role{"composer"} - RoleConductor = Role{"conductor"} - RoleLyricist = Role{"lyricist"} - RoleArranger = Role{"arranger"} - RoleProducer = Role{"producer"} - RoleDirector = Role{"director"} - RoleEngineer = Role{"engineer"} - RoleMixer = Role{"mixer"} - RoleRemixer = Role{"remixer"} - RoleDJMixer = Role{"djmixer"} - RolePerformer = Role{"performer"} - // RoleMainCredit is a credit where the artist is an album artist or artist - RoleMainCredit = Role{"maincredit"} -) - -var AllRoles = map[string]Role{ - RoleArtist.role: RoleArtist, - RoleAlbumArtist.role: RoleAlbumArtist, - RoleComposer.role: RoleComposer, - RoleConductor.role: RoleConductor, - RoleLyricist.role: RoleLyricist, - RoleArranger.role: RoleArranger, - RoleProducer.role: RoleProducer, - RoleDirector.role: RoleDirector, - RoleEngineer.role: RoleEngineer, - RoleMixer.role: RoleMixer, - RoleRemixer.role: RoleRemixer, - RoleDJMixer.role: RoleDJMixer, - RolePerformer.role: RolePerformer, - RoleMainCredit.role: RoleMainCredit, -} - -// Role represents the role of an artist in a track or album. -type Role struct { - role string -} - -func (r Role) String() string { - return r.role -} - -func (r Role) MarshalText() (text []byte, err error) { - return []byte(r.role), nil -} - -func (r *Role) UnmarshalText(text []byte) error { - role := RoleFromString(string(text)) - if role == RoleInvalid { - return fmt.Errorf("invalid role: %s", text) - } - *r = role - return nil -} - -func RoleFromString(role string) Role { - if r, ok := AllRoles[role]; ok { - return r - } - return RoleInvalid -} - -type Participant struct { - Artist - SubRole string `json:"subRole,omitempty"` -} - -type ParticipantList []Participant - -func (p ParticipantList) Join(sep string) string { - return strings.Join(slice.Map(p, func(p Participant) string { - if p.SubRole != "" { - return p.Name + " (" + p.SubRole + ")" - } - return p.Name - }), sep) -} - -type Participants map[Role]ParticipantList - -// Add adds the artists to the role, ignoring duplicates. -func (p Participants) Add(role Role, artists ...Artist) { - participants := slice.Map(artists, func(artist Artist) Participant { - return Participant{Artist: artist} - }) - p.add(role, participants...) -} - -// AddWithSubRole adds the artists to the role, ignoring duplicates. -func (p Participants) AddWithSubRole(role Role, subRole string, artists ...Artist) { - participants := slice.Map(artists, func(artist Artist) Participant { - return Participant{Artist: artist, SubRole: subRole} - }) - p.add(role, participants...) -} - -func (p Participants) Sort() { - for _, artists := range p { - slices.SortFunc(artists, func(a1, a2 Participant) int { - return cmp.Compare(a1.Name, a2.Name) - }) - } -} - -// First returns the first artist for the role, or an empty artist if the role is not present. -func (p Participants) First(role Role) Artist { - if artists, ok := p[role]; ok && len(artists) > 0 { - return artists[0].Artist - } - return Artist{} -} - -// Merge merges the other Participants into this one. -func (p Participants) Merge(other Participants) { - for role, artists := range other { - p.add(role, artists...) - } -} - -func (p Participants) add(role Role, participants ...Participant) { - seen := make(map[string]struct{}, len(p[role])) - for _, artist := range p[role] { - seen[artist.ID+artist.SubRole] = struct{}{} - } - for _, participant := range participants { - key := participant.ID + participant.SubRole - if _, ok := seen[key]; !ok { - seen[key] = struct{}{} - p[role] = append(p[role], participant) - } - } -} - -// AllArtists returns all artists found in the Participants. -func (p Participants) AllArtists() []Artist { - // First count the total number of artists to avoid reallocations. - totalArtists := 0 - for _, roleArtists := range p { - totalArtists += len(roleArtists) - } - artists := make(Artists, 0, totalArtists) - for _, roleArtists := range p { - artists = append(artists, slice.Map(roleArtists, func(p Participant) Artist { return p.Artist })...) - } - slices.SortStableFunc(artists, func(a1, a2 Artist) int { - return cmp.Compare(a1.ID, a2.ID) - }) - return slices.CompactFunc(artists, func(a1, a2 Artist) bool { - return a1.ID == a2.ID - }) -} - -// AllIDs returns all artist IDs found in the Participants. -func (p Participants) AllIDs() []string { - artists := p.AllArtists() - return slice.Map(artists, func(a Artist) string { return a.ID }) -} - -// AllNames returns all artist names found in the Participants, including SortArtistNames. -func (p Participants) AllNames() []string { - names := make([]string, 0, len(p)) - for _, artists := range p { - for _, artist := range artists { - names = append(names, artist.Name) - if artist.SortArtistName != "" { - names = append(names, artist.SortArtistName) - } - } - } - return slice.Unique(names) -} - -func (p Participants) Hash() []byte { - flattened := make([]string, 0, len(p)) - for role, artists := range p { - ids := slice.Map(artists, func(participant Participant) string { return participant.SubRole + ":" + participant.ID }) - slices.Sort(ids) - flattened = append(flattened, role.String()+":"+strings.Join(ids, "/")) - } - slices.Sort(flattened) - sum := xxh3.New() - sum.Write([]byte(strings.Join(flattened, "|"))) - return sum.Sum(nil) -} diff --git a/model/participants_test.go b/model/participants_test.go deleted file mode 100644 index dad84b6dd..000000000 --- a/model/participants_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package model_test - -import ( - "encoding/json" - - . "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Participants", func() { - Describe("JSON Marshalling", func() { - When("we have a valid Albums object", func() { - var participants Participants - BeforeEach(func() { - participants = Participants{ - RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")}, - RoleAlbumArtist: []Participant{_p("3", "AlbumArtist1"), _p("4", "AlbumArtist2")}, - } - }) - - It("marshals correctly", func() { - data, err := json.Marshal(participants) - Expect(err).To(BeNil()) - - var afterConversion Participants - err = json.Unmarshal(data, &afterConversion) - Expect(err).To(BeNil()) - Expect(afterConversion).To(Equal(participants)) - }) - - It("returns unmarshal error when the role is invalid", func() { - err := json.Unmarshal([]byte(`{"unknown": []}`), &participants) - Expect(err).To(MatchError("invalid role: unknown")) - }) - }) - }) - - Describe("First", func() { - var participants Participants - BeforeEach(func() { - participants = Participants{ - RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")}, - RoleAlbumArtist: []Participant{_p("3", "AlbumArtist1"), _p("4", "AlbumArtist2")}, - } - }) - It("returns the first artist of the role", func() { - Expect(participants.First(RoleArtist)).To(Equal(Artist{ID: "1", Name: "Artist1"})) - }) - It("returns an empty artist when the role is not present", func() { - Expect(participants.First(RoleComposer)).To(Equal(Artist{})) - }) - }) - - Describe("Add", func() { - var participants Participants - BeforeEach(func() { - participants = Participants{ - RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")}, - } - }) - It("adds the artist to the role", func() { - participants.Add(RoleArtist, Artist{ID: "5", Name: "Artist5"}) - Expect(participants).To(Equal(Participants{ - RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2"), _p("5", "Artist5")}, - })) - }) - It("creates a new role if it doesn't exist", func() { - participants.Add(RoleComposer, Artist{ID: "5", Name: "Artist5"}) - Expect(participants).To(Equal(Participants{ - RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")}, - RoleComposer: []Participant{_p("5", "Artist5")}, - })) - }) - It("should not add duplicate artists", func() { - participants.Add(RoleArtist, Artist{ID: "1", Name: "Artist1"}) - Expect(participants).To(Equal(Participants{ - RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")}, - })) - }) - It("adds the artist with and without subrole", func() { - participants = Participants{} - participants.Add(RolePerformer, Artist{ID: "3", Name: "Artist3"}) - participants.AddWithSubRole(RolePerformer, "SubRole", Artist{ID: "3", Name: "Artist3"}) - - artist3 := _p("3", "Artist3") - artist3WithSubRole := artist3 - artist3WithSubRole.SubRole = "SubRole" - - Expect(participants[RolePerformer]).To(HaveLen(2)) - Expect(participants).To(Equal(Participants{ - RolePerformer: []Participant{ - artist3, - artist3WithSubRole, - }, - })) - }) - }) - - Describe("Merge", func() { - var participations1, participations2 Participants - BeforeEach(func() { - participations1 = Participants{ - RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Duplicated Artist")}, - RoleAlbumArtist: []Participant{_p("3", "AlbumArtist1"), _p("4", "AlbumArtist2")}, - } - participations2 = Participants{ - RoleArtist: []Participant{_p("5", "Artist3"), _p("6", "Artist4"), _p("2", "Duplicated Artist")}, - RoleAlbumArtist: []Participant{_p("7", "AlbumArtist3"), _p("8", "AlbumArtist4")}, - } - }) - It("merges correctly, skipping duplicated artists", func() { - participations1.Merge(participations2) - Expect(participations1).To(Equal(Participants{ - RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Duplicated Artist"), _p("5", "Artist3"), _p("6", "Artist4")}, - RoleAlbumArtist: []Participant{_p("3", "AlbumArtist1"), _p("4", "AlbumArtist2"), _p("7", "AlbumArtist3"), _p("8", "AlbumArtist4")}, - })) - }) - }) - - Describe("Hash", func() { - It("should return the same hash for the same participants", func() { - p1 := Participants{ - RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")}, - RoleAlbumArtist: []Participant{_p("3", "AlbumArtist1"), _p("4", "AlbumArtist2")}, - } - p2 := Participants{ - RoleArtist: []Participant{_p("2", "Artist2"), _p("1", "Artist1")}, - RoleAlbumArtist: []Participant{_p("4", "AlbumArtist2"), _p("3", "AlbumArtist1")}, - } - Expect(p1.Hash()).To(Equal(p2.Hash())) - }) - It("should return different hashes for different participants", func() { - p1 := Participants{ - RoleArtist: []Participant{_p("1", "Artist1")}, - } - p2 := Participants{ - RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")}, - } - Expect(p1.Hash()).ToNot(Equal(p2.Hash())) - }) - }) - - Describe("All", func() { - var participants Participants - BeforeEach(func() { - participants = Participants{ - RoleArtist: []Participant{_p("1", "Artist1"), _p("2", "Artist2")}, - RoleAlbumArtist: []Participant{_p("3", "AlbumArtist1"), _p("4", "AlbumArtist2")}, - RoleProducer: []Participant{_p("5", "Producer", "SortProducerName")}, - RoleComposer: []Participant{_p("1", "Artist1")}, - } - }) - - Describe("All", func() { - It("returns all artists found in the Participants", func() { - artists := participants.AllArtists() - Expect(artists).To(ConsistOf( - Artist{ID: "1", Name: "Artist1"}, - Artist{ID: "2", Name: "Artist2"}, - Artist{ID: "3", Name: "AlbumArtist1"}, - Artist{ID: "4", Name: "AlbumArtist2"}, - Artist{ID: "5", Name: "Producer", SortArtistName: "SortProducerName"}, - )) - }) - }) - - Describe("AllIDs", func() { - It("returns all artist IDs found in the Participants", func() { - ids := participants.AllIDs() - Expect(ids).To(ConsistOf("1", "2", "3", "4", "5")) - }) - }) - - Describe("AllNames", func() { - It("returns all artist names found in the Participants", func() { - names := participants.AllNames() - Expect(names).To(ConsistOf("Artist1", "Artist2", "AlbumArtist1", "AlbumArtist2", - "Producer", "SortProducerName")) - }) - }) - }) -}) - -var _ = Describe("ParticipantList", func() { - Describe("Join", func() { - It("joins the participants with the given separator", func() { - list := ParticipantList{ - _p("1", "Artist 1"), - _p("3", "Artist 2"), - } - list[0].SubRole = "SubRole" - Expect(list.Join(", ")).To(Equal("Artist 1 (SubRole), Artist 2")) - }) - - It("returns the sole participant if there is only one", func() { - list := ParticipantList{_p("1", "Artist 1")} - Expect(list.Join(", ")).To(Equal("Artist 1")) - }) - - It("returns empty string if there are no participants", func() { - var list ParticipantList - Expect(list.Join(", ")).To(Equal("")) - }) - }) -}) - -func _p(id, name string, sortName ...string) Participant { - p := Participant{Artist: Artist{ID: id, Name: name}} - if len(sortName) > 0 { - p.Artist.SortArtistName = sortName[0] - } - return p -} diff --git a/model/player.go b/model/player.go index c03058419..372ad570c 100644 --- a/model/player.go +++ b/model/player.go @@ -1,40 +1,28 @@ package model import ( - "context" "time" - - "github.com/deluan/rest" ) type Player struct { - Username string `structs:"-" json:"userName"` - - ID string `structs:"id" json:"id"` + ID string `structs:"id" json:"id" orm:"column(id)"` Name string `structs:"name" json:"name"` UserAgent string `structs:"user_agent" json:"userAgent"` - UserId string `structs:"user_id" json:"userId"` + UserName string `structs:"user_name" json:"userName"` Client string `structs:"client" json:"client"` - IP string `structs:"ip" json:"ip"` + IPAddress string `structs:"ip_address" json:"ipAddress"` LastSeen time.Time `structs:"last_seen" json:"lastSeen"` TranscodingId string `structs:"transcoding_id" json:"transcodingId"` MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate"` ReportRealPath bool `structs:"report_real_path" json:"reportRealPath"` ScrobbleEnabled bool `structs:"scrobble_enabled" json:"scrobbleEnabled"` - HasAPIKey bool `structs:"-" db:"has_api_key" json:"hasApiKey"` - APIKey *string `structs:"-" json:"apiKey,omitempty"` } type Players []Player type PlayerRepository interface { - rest.Repository[Player] - rest.Persistable[Player] - Get(ctx context.Context, id string) (*Player, error) - FindMatch(ctx context.Context, userId, client, userAgent string) (*Player, error) - Put(ctx context.Context, p *Player) error - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - CountByClient(ctx context.Context, options ...QueryOptions) (map[string]int64, error) - FindByAPIKey(ctx context.Context, key string) (*Player, error) - SetAPIKey(ctx context.Context, playerID, key string) error + Get(id string) (*Player, error) + FindMatch(userName, client, typ string) (*Player, error) + Put(p *Player) error + // TODO: Add CountAll method. Useful at least for metrics. } diff --git a/model/playlist.go b/model/playlist.go index d2ed97682..f9f7288de 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -1,67 +1,40 @@ package model import ( - "context" - "iter" - "maps" - "os" - "path/filepath" - "slices" + "fmt" "strconv" + "strings" "time" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/criteria" + "golang.org/x/exp/slices" ) type Playlist struct { - Annotations `structs:"-"` - ItemImage `structs:"-"` - - ID string `structs:"id" json:"id"` - Name string `structs:"name" json:"name"` - Comment string `structs:"comment" json:"comment"` - Duration float32 `structs:"duration" json:"duration"` - Size int64 `structs:"size" json:"size"` - SongCount int `structs:"song_count" json:"songCount"` - OwnerName string `structs:"-" json:"ownerName"` - OwnerID string `structs:"owner_id" json:"ownerId"` - Public bool `structs:"public" json:"public"` - Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"` - Path string `structs:"path" json:"path"` - Sync bool `structs:"sync" json:"sync"` - UploadedImage string `structs:"uploaded_image" json:"uploadedImage"` - ExternalImageURL string `structs:"external_image_url" json:"externalImageUrl,omitempty"` - CreatedAt time.Time `structs:"created_at" json:"createdAt"` - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` - ImportedHash string `structs:"imported_hash" json:"-"` + ID string `structs:"id" json:"id" orm:"column(id)"` + Name string `structs:"name" json:"name"` + Comment string `structs:"comment" json:"comment"` + Duration float32 `structs:"duration" json:"duration"` + Size int64 `structs:"size" json:"size"` + SongCount int `structs:"song_count" json:"songCount"` + OwnerName string `structs:"-" json:"ownerName"` + OwnerID string `structs:"owner_id" json:"ownerId" orm:"column(owner_id)"` + Public bool `structs:"public" json:"public"` + Tracks PlaylistTracks `structs:"-" json:"tracks,omitempty"` + Path string `structs:"path" json:"path"` + Sync bool `structs:"sync" json:"sync"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // SmartPlaylist attributes - Rules *criteria.Criteria `structs:"rules" json:"rules"` - EvaluatedAt *time.Time `structs:"evaluated_at" json:"evaluatedAt"` + Rules *criteria.Criteria `structs:"-" json:"rules"` + EvaluatedAt time.Time `structs:"evaluated_at" json:"evaluatedAt"` } func (pls Playlist) IsSmartPlaylist() bool { return pls.Rules != nil && pls.Rules.Expression != nil } -// TracksEditable reports whether the track list is user-owned rather than server-managed. -func (pls Playlist) TracksEditable() bool { - return !pls.IsSmartPlaylist() && !pls.Sync -} - -// RefreshDelay returns the playlist's own refresh window when set, falling -// back to the global SmartPlaylistRefreshDelay. -func (pls Playlist) RefreshDelay() time.Duration { - if pls.IsSmartPlaylist() && pls.Rules.RefreshDelay > 0 { - return pls.Rules.RefreshDelay - } - return conf.Server.SmartPlaylistRefreshDelay -} - func (pls Playlist) MediaFiles() MediaFiles { if len(pls.Tracks) == 0 { return nil @@ -69,21 +42,6 @@ func (pls Playlist) MediaFiles() MediaFiles { return pls.Tracks.MediaFiles() } -func (pls *Playlist) refreshStats() { - pls.SongCount = len(pls.Tracks) - pls.Duration = 0 - pls.Size = 0 - for _, t := range pls.Tracks { - pls.Duration += t.MediaFile.Duration - pls.Size += t.MediaFile.Size - } -} - -func (pls *Playlist) SetTracks(tracks PlaylistTracks) { - pls.Tracks = tracks - pls.refreshStats() -} - func (pls *Playlist) RemoveTracks(idxToRemove []int) { var newTracks PlaylistTracks for i, t := range pls.Tracks { @@ -93,15 +51,22 @@ func (pls *Playlist) RemoveTracks(idxToRemove []int) { newTracks = append(newTracks, t) } pls.Tracks = newTracks - pls.refreshStats() } -// ToM3U8 exports the playlist to the Extended M3U8 format +// ToM3U8 exports the playlist to the Extended M3U8 format, as specified in +// https://docs.fileformat.com/audio/m3u/#extended-m3u func (pls *Playlist) ToM3U8() string { - return pls.MediaFiles().ToM3U8(pls.Name, true) + buf := strings.Builder{} + buf.WriteString("#EXTM3U\n") + buf.WriteString(fmt.Sprintf("#PLAYLIST:%s\n", pls.Name)) + for _, t := range pls.Tracks { + buf.WriteString(fmt.Sprintf("#EXTINF:%.f,%s - %s\n", t.Duration, t.Artist, t.Title)) + buf.WriteString(t.Path + "\n") + } + return buf.String() } -func (pls *Playlist) AddMediaFilesByID(mediaFileIds []string) { +func (pls *Playlist) AddTracks(mediaFileIds []string) { pos := len(pls.Tracks) for _, mfId := range mediaFileIds { pos++ @@ -113,7 +78,6 @@ func (pls *Playlist) AddMediaFilesByID(mediaFileIds []string) { } pls.Tracks = append(pls.Tracks, t) } - pls.refreshStats() } func (pls *Playlist) AddMediaFiles(mfs MediaFiles) { @@ -128,105 +92,31 @@ func (pls *Playlist) AddMediaFiles(mfs MediaFiles) { } pls.Tracks = append(pls.Tracks, t) } - pls.refreshStats() } func (pls Playlist) CoverArtID() ArtworkID { return artworkIDFromPlaylist(pls) } -// UploadedImagePath returns the absolute filesystem path for a manually uploaded -// playlist cover image. Returns empty string if no image has been uploaded. -// This does NOT cover sidecar images or external URLs — those are resolved -// by the artwork reader's fallback chain. -func (pls Playlist) UploadedImagePath() string { - return UploadedImagePath(consts.EntityPlaylist, pls.UploadedImage) -} - -// NormalizedRules returns the rules with child playlist paths resolved to absolute, OS-native paths. -func (pls Playlist) NormalizedRules() *criteria.Criteria { - if pls.Rules == nil || pls.Rules.Expression == nil { - return pls.Rules - } - - rules := *pls.Rules - rules.Expression = normalizePlaylistPaths(pls.Rules.Expression, pls.Path) - return &rules -} - -func normalizePlaylistPaths(inputRule criteria.Expression, referencingPlaylistPath string) criteria.Expression { - switch rule := inputRule.(type) { - case criteria.Any: - anyCriteria := make(criteria.Any, len(rule)) - for i, rules := range rule { - anyCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath) - } - return anyCriteria - case criteria.All: - allCriteria := make(criteria.All, len(rule)) - for i, rules := range rule { - allCriteria[i] = normalizePlaylistPaths(rules, referencingPlaylistPath) - } - return allCriteria - case criteria.InPlaylist: - return criteria.InPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath)) - case criteria.NotInPlaylist: - return criteria.NotInPlaylist(normalizeChildPathRule(rule, referencingPlaylistPath)) - } - - return inputRule -} - -func normalizeChildPathRule(rule map[string]any, referencingPlaylistPath string) map[string]any { - path, ok := rule["path"].(string) - if !ok || path == "" { - return rule - } - - // References use forward slashes to stay portable, while Playlist.Path is OS-native. - path = filepath.FromSlash(path) - switch { - case isAbsPlaylistRef(path): - path = filepath.Clean(path) - case referencingPlaylistPath != "": - path = filepath.Join(filepath.Dir(referencingPlaylistPath), path) - default: - log.Warn("Cannot resolve relative playlist reference: playlist has no file path", "reference", path) - } - normalized := maps.Clone(rule) - normalized["path"] = path - return normalized -} - -// filepath.IsAbs rejects a bare leading separator on Windows, but that is how Unix spells absolute. -func isAbsPlaylistRef(path string) bool { - return filepath.IsAbs(path) || os.IsPathSeparator(path[0]) -} - type Playlists []Playlist -type PlaylistCursor iter.Seq2[Playlist, error] - type PlaylistRepository interface { - rest.Repository[Playlist] - rest.Persistable[Playlist] - AnnotatedRepository - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Exists(ctx context.Context, id string) (bool, error) - Put(ctx context.Context, pls *Playlist, cols ...string) error - Get(ctx context.Context, id string) (*Playlist, error) - GetWithTracks(ctx context.Context, id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) - GetAll(ctx context.Context, options ...QueryOptions) (Playlists, error) - GetCursor(ctx context.Context, options ...QueryOptions) (PlaylistCursor, error) - FindByPath(ctx context.Context, path string) (*Playlist, error) - Tracks(ctx context.Context, playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository - GetPlaylists(ctx context.Context, mediaFileId string) (Playlists, error) + ResourceRepository + CountAll(options ...QueryOptions) (int64, error) + Exists(id string) (bool, error) + Put(pls *Playlist) error + Get(id string) (*Playlist, error) + GetWithTracks(id string, refreshSmartPlaylist bool) (*Playlist, error) + GetAll(options ...QueryOptions) (Playlists, error) + FindByPath(path string) (*Playlist, error) + Delete(id string) error + Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository } type PlaylistTrack struct { - ID string `json:"id"` - MediaFileID string `json:"mediaFileId"` - PlaylistID string `json:"playlistId"` + ID string `json:"id" orm:"column(id)"` + MediaFileID string `json:"mediaFileId" orm:"column(media_file_id)"` + PlaylistID string `json:"playlistId" orm:"column(playlist_id)"` MediaFile } @@ -240,21 +130,15 @@ func (plt PlaylistTracks) MediaFiles() MediaFiles { return mfs } -type PlaylistTrackCursor iter.Seq2[PlaylistTrack, error] - type PlaylistTrackRepository interface { - rest.Repository[PlaylistTrack] - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - GetAll(ctx context.Context, options ...QueryOptions) (PlaylistTracks, error) - GetCursor(ctx context.Context, options ...QueryOptions) (PlaylistTrackCursor, error) - GetAlbumIDs(ctx context.Context, options ...QueryOptions) ([]string, error) - GetMediaFileIDs(ctx context.Context, options ...QueryOptions) ([]string, error) - Add(ctx context.Context, mediaFileIds []string) (int, error) - Insert(ctx context.Context, mediaFileIds []string, pos int) (int, error) - AddAlbums(ctx context.Context, albumIds []string) (int, error) - AddArtists(ctx context.Context, artistIds []string) (int, error) - AddDiscs(ctx context.Context, discs []DiscID) (int, error) - Delete(ctx context.Context, id ...string) error - DeleteAll(ctx context.Context) error - Reorder(ctx context.Context, pos int, newPos int) error + ResourceRepository + GetAll(options ...QueryOptions) (PlaylistTracks, error) + GetAlbumIDs(options ...QueryOptions) ([]string, error) + Add(mediaFileIds []string) (int, error) + AddAlbums(albumIds []string) (int, error) + AddArtists(artistIds []string) (int, error) + AddDiscs(discs []DiscID) (int, error) + Delete(id ...string) error + DeleteAll() error + Reorder(pos int, newPos int) error } diff --git a/model/playlist_test.go b/model/playlist_test.go deleted file mode 100644 index 7ffb69b63..000000000 --- a/model/playlist_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package model_test - -import ( - "path/filepath" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Playlist", func() { - Describe("ToM3U8()", func() { - var pls model.Playlist - BeforeEach(func() { - pls = model.Playlist{Name: "Mellow sunset"} - pls.Tracks = model.PlaylistTracks{ - {MediaFile: model.MediaFile{Artist: "Morcheeba feat. Kurt Wagner", Title: "What New York Couples Fight About", - Duration: 377.84, - LibraryPath: "/music/library", Path: "Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}}, - {MediaFile: model.MediaFile{Artist: "A Tribe Called Quest", Title: "Description of a Fool (Groove Armada's Acoustic mix)", - Duration: 374.49, - LibraryPath: "/music/library", Path: "Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}}, - {MediaFile: model.MediaFile{Artist: "Lou Reed", Title: "Walk on the Wild Side", - Duration: 253.1, - LibraryPath: "/music/library", Path: "Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}}, - {MediaFile: model.MediaFile{Artist: "Legião Urbana", Title: "On the Way Home", - Duration: 163.89, - LibraryPath: "/music/library", Path: "Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}}, - } - }) - 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 -/music/library/Morcheeba/Charango/01-06 What New York Couples Fight About.mp3 -#EXTINF:374,A Tribe Called Quest - Description of a Fool (Groove Armada's Acoustic mix) -/music/library/Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3 -#EXTINF:253,Lou Reed - Walk on the Wild Side -/music/library/Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a -#EXTINF:164,Legião Urbana - On the Way Home -/music/library/Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3 -` - Expect(pls.ToM3U8()).To(Equal(expected)) - }) - }) - - Describe("RefreshDelay", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SmartPlaylistRefreshDelay = 5 * time.Second - }) - - It("returns the global config value when rules have no refreshDelay", func() { - pls := model.Playlist{Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}}} - Expect(pls.RefreshDelay()).To(Equal(5 * time.Second)) - }) - - It("returns the per-playlist value when set", func() { - pls := model.Playlist{Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.Is{"loved": true}}, - RefreshDelay: 24 * time.Hour, - }} - Expect(pls.RefreshDelay()).To(Equal(24 * time.Hour)) - }) - - It("returns the global value for non-smart playlists", func() { - pls := model.Playlist{} - Expect(pls.RefreshDelay()).To(Equal(5 * time.Second)) - }) - }) - - Describe("TracksEditable", func() { - It("is true for a plain playlist", func() { - Expect(model.Playlist{}.TracksEditable()).To(BeTrue()) - }) - - It("is false for a smart playlist", func() { - pls := model.Playlist{Rules: &criteria.Criteria{Expression: criteria.Is{"loved": true}}} - Expect(pls.TracksEditable()).To(BeFalse()) - }) - - It("is false for a synced playlist", func() { - Expect(model.Playlist{Sync: true}.TracksEditable()).To(BeFalse()) - }) - }) - - Describe("NormalizedRules()", func() { - // absPath builds an OS-native absolute path so these specs also run on Windows. - absPath := func(parts ...string) string { - abs, err := filepath.Abs(filepath.Join(parts...)) - Expect(err).ToNot(HaveOccurred()) - return abs - } - normalize := func(pls model.Playlist) criteria.Expression { - return pls.NormalizedRules().Expression - } - - It("resolves relative references against the playlist folder", func() { - pls := model.Playlist{ - Path: absPath("test", "nested", "my-playlist.nsp"), - Rules: &criteria.Criteria{Expression: criteria.All{ - criteria.InPlaylist{"path": "../up.m3u"}, - criteria.NotInPlaylist{"path": "./sibling.nsp"}, - criteria.Any{criteria.InPlaylist{"path": "sub/deep.nsp"}}, - }}, - } - Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ - criteria.InPlaylist{"path": absPath("test", "up.m3u")}, - criteria.NotInPlaylist{"path": absPath("test", "nested", "sibling.nsp")}, - criteria.Any{criteria.InPlaylist{"path": absPath("test", "nested", "sub", "deep.nsp")}}, - })) - }) - - It("cleans absolute references", func() { - dirty := absPath("music") + string(filepath.Separator) + "." + string(filepath.Separator) + "child.nsp" - pls := model.Playlist{ - Path: absPath("test", "my-playlist.nsp"), - Rules: &criteria.Criteria{Expression: criteria.All{criteria.NotInPlaylist{"path": dirty}}}, - } - Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ - criteria.NotInPlaylist{"path": absPath("music", "child.nsp")}, - })) - }) - - It("treats a leading slash as absolute on every OS", func() { - pls := model.Playlist{ - Path: absPath("test", "my-playlist.nsp"), - Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "/other/./root.m3u"}}}, - } - Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ - criteria.InPlaylist{"path": filepath.FromSlash("/other/root.m3u")}, - })) - }) - - It("leaves empty paths and id references untouched", func() { - pls := model.Playlist{ - Path: absPath("test", "my-playlist.nsp"), - Rules: &criteria.Criteria{Expression: criteria.All{ - criteria.InPlaylist{"path": ""}, - criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"}, - criteria.Eq{"artist": "Bob Dealin"}, - }}, - } - Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ - criteria.InPlaylist{"path": ""}, - criteria.InPlaylist{"id": "94d8ba52-7aca-40e2-af82-4cb09c43d710"}, - criteria.Eq{"artist": "Bob Dealin"}, - })) - }) - - It("skips relative references when the playlist has no path", func() { - pls := model.Playlist{ - Rules: &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"path": "../up.m3u"}}}, - } - Expect(normalize(pls)).To(BeEquivalentTo(criteria.All{ - criteria.InPlaylist{"path": filepath.FromSlash("../up.m3u")}, - })) - }) - - It("preserves every other criteria field", func() { - rules := criteria.Criteria{ - Expression: criteria.All{criteria.InPlaylist{"path": "child.nsp"}}, - Sort: "title", - Order: "desc", - Limit: 10, - LimitPercent: 25, - Offset: 5, - RefreshDelay: 3 * time.Hour, - } - pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &rules} - - normalized := *pls.NormalizedRules() - normalized.Expression = rules.Expression - Expect(normalized).To(Equal(rules)) - }) - - It("does not mutate the original playlist rules", func() { - original := criteria.All{criteria.InPlaylist{"path": "child.nsp"}} - pls := model.Playlist{Path: absPath("test", "my-playlist.nsp"), Rules: &criteria.Criteria{Expression: original}} - _ = pls.NormalizedRules() - Expect(original[0]).To(BeEquivalentTo(criteria.InPlaylist{"path": "child.nsp"})) - }) - }) -}) diff --git a/model/playlists_test.go b/model/playlists_test.go new file mode 100644 index 000000000..600e116cc --- /dev/null +++ b/model/playlists_test.go @@ -0,0 +1,40 @@ +package model_test + +import ( + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Playlist", func() { + Describe("ToM3U8()", func() { + var pls model.Playlist + BeforeEach(func() { + pls = model.Playlist{Name: "Mellow sunset"} + pls.Tracks = model.PlaylistTracks{ + {MediaFile: model.MediaFile{Artist: "Morcheeba feat. Kurt Wagner", Title: "What New York Couples Fight About", + Duration: 377.84, Path: "/music/library/Morcheeba/Charango/01-06 What New York Couples Fight About.mp3"}}, + {MediaFile: model.MediaFile{Artist: "A Tribe Called Quest", Title: "Description of a Fool (Groove Armada's Acoustic mix)", + Duration: 374.49, Path: "/music/library/Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3"}}, + {MediaFile: model.MediaFile{Artist: "Lou Reed", Title: "Walk on the Wild Side", + Duration: 253.1, Path: "/music/library/Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a"}}, + {MediaFile: model.MediaFile{Artist: "Legião Urbana", Title: "On the Way Home", + Duration: 163.89, Path: "/music/library/Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3"}}, + } + }) + It("generates the correct M3U format", func() { + expected := `#EXTM3U +#PLAYLIST:Mellow sunset +#EXTINF:378,Morcheeba feat. Kurt Wagner - What New York Couples Fight About +/music/library/Morcheeba/Charango/01-06 What New York Couples Fight About.mp3 +#EXTINF:374,A Tribe Called Quest - Description of a Fool (Groove Armada's Acoustic mix) +/music/library/Groove Armada/Back to Mine_ Groove Armada/01-01 Description of a Fool (Groove Armada's Acoustic mix).mp3 +#EXTINF:253,Lou Reed - Walk on the Wild Side +/music/library/Lou Reed/Walk on the Wild Side_ The Best of Lou Reed/01-06 Walk on the Wild Side.m4a +#EXTINF:164,Legião Urbana - On the Way Home +/music/library/Legião Urbana/Música p_ acampamentos/02-05 On the Way Home.mp3 +` + Expect(pls.ToM3U8()).To(Equal(expected)) + }) + }) +}) diff --git a/model/playqueue.go b/model/playqueue.go index ddc53d7da..3c9786fd6 100644 --- a/model/playqueue.go +++ b/model/playqueue.go @@ -1,14 +1,13 @@ package model import ( - "context" "time" ) type PlayQueue struct { - ID string `structs:"id" json:"id"` - UserID string `structs:"user_id" json:"userId"` - Current int `structs:"current" json:"current"` + ID string `structs:"id" json:"id" orm:"column(id)"` + UserID string `structs:"user_id" json:"userId" orm:"column(user_id)"` + Current string `structs:"current" json:"current"` Position int64 `structs:"position" json:"position"` ChangedBy string `structs:"changed_by" json:"changedBy"` Items MediaFiles `structs:"-" json:"items,omitempty"` @@ -19,11 +18,6 @@ type PlayQueue struct { type PlayQueues []PlayQueue type PlayQueueRepository interface { - Store(ctx context.Context, queue *PlayQueue, colNames ...string) error - // Retrieve returns the playqueue without loading the full MediaFiles - // (Items only contain IDs) - Retrieve(ctx context.Context, userId string) (*PlayQueue, error) - // RetrieveWithMediaFiles returns the playqueue with full MediaFiles loaded - RetrieveWithMediaFiles(ctx context.Context, userId string) (*PlayQueue, error) - Clear(ctx context.Context, userId string) error + Store(queue *PlayQueue) error + Retrieve(userId string) (*PlayQueue, error) } diff --git a/model/plugin.go b/model/plugin.go deleted file mode 100644 index a448a9633..000000000 --- a/model/plugin.go +++ /dev/null @@ -1,37 +0,0 @@ -package model - -import ( - "context" - "time" - - "github.com/deluan/rest" -) - -type Plugin struct { - ID string `structs:"id" json:"id"` - Path string `structs:"path" json:"path"` - Manifest string `structs:"manifest" json:"manifest"` - Config string `structs:"config" json:"config,omitempty"` - Users string `structs:"users" json:"users,omitempty"` - AllUsers bool `structs:"all_users" json:"allUsers,omitempty"` - Libraries string `structs:"libraries" json:"libraries,omitempty"` - AllLibraries bool `structs:"all_libraries" json:"allLibraries,omitempty"` - AllowWriteAccess bool `structs:"allow_write_access" json:"allowWriteAccess,omitempty"` - Enabled bool `structs:"enabled" json:"enabled"` - LastError string `structs:"last_error" json:"lastError,omitempty"` - SHA256 string `structs:"sha256" json:"sha256"` - CreatedAt time.Time `structs:"created_at" json:"createdAt"` - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` -} - -type Plugins []Plugin - -type PluginRepository interface { - rest.Repository[Plugin] - ClearErrors(ctx context.Context) error - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Delete(ctx context.Context, id string) error - Get(ctx context.Context, id string) (*Plugin, error) - GetAll(ctx context.Context, options ...QueryOptions) (Plugins, error) - Put(ctx context.Context, p *Plugin) error -} diff --git a/model/properties.go b/model/properties.go index 24b56db26..1247edec7 100644 --- a/model/properties.go +++ b/model/properties.go @@ -1,10 +1,13 @@ package model -import "context" +const ( + // TODO Move other prop keys to here + PropLastScan = "LastScan" +) type PropertyRepository interface { - Put(ctx context.Context, id string, value string) error - Get(ctx context.Context, id string) (string, error) - Delete(ctx context.Context, id string) error - DefaultGet(ctx context.Context, id string, defaultValue string) (string, error) + Put(id string, value string) error + Get(id string) (string, error) + Delete(id string) error + DefaultGet(id string, defaultValue string) (string, error) } diff --git a/model/radio.go b/model/radio.go index a7e0e9218..b07065777 100644 --- a/model/radio.go +++ b/model/radio.go @@ -1,41 +1,23 @@ package model -import ( - "context" - "time" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/consts" -) +import "time" type Radio struct { - ItemImage `structs:"-"` - - ID string `structs:"id" json:"id"` - StreamUrl string `structs:"stream_url" json:"streamUrl"` - Name string `structs:"name" json:"name"` - HomePageUrl string `structs:"home_page_url" json:"homePageUrl"` - UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"` - CreatedAt time.Time `structs:"created_at" json:"createdAt"` - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` -} - -func (r Radio) CoverArtID() ArtworkID { - return artworkIDFromRadio(r) -} - -func (r Radio) UploadedImagePath() string { - return UploadedImagePath(consts.EntityRadio, r.UploadedImage) + ID string `structs:"id" json:"id" orm:"pk;column(id)"` + StreamUrl string `structs:"stream_url" json:"streamUrl"` + Name string `structs:"name" json:"name"` + HomePageUrl string `structs:"home_page_url" json:"homePageUrl" orm:"column(home_page_url)"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` } type Radios []Radio type RadioRepository interface { - rest.Repository[Radio] - rest.Persistable[Radio] - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Exists(ctx context.Context, id string) (bool, error) - Get(ctx context.Context, id string) (*Radio, error) - GetAll(ctx context.Context, options ...QueryOptions) (Radios, error) - Put(ctx context.Context, u *Radio, colsToUpdate ...string) error + ResourceRepository + CountAll(options ...QueryOptions) (int64, error) + Delete(id string) error + Get(id string) (*Radio, error) + GetAll(options ...QueryOptions) (Radios, error) + Put(u *Radio) error } diff --git a/model/radio_test.go b/model/radio_test.go deleted file mode 100644 index 6ae74c3f4..000000000 --- a/model/radio_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package model_test - -import ( - "path/filepath" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Radio", func() { - Describe("CoverArtID", func() { - It("returns a radio artwork ID", func() { - r := model.Radio{ID: "rd-1", UpdatedAt: time.Now()} - artID := r.CoverArtID() - Expect(artID.Kind).To(Equal(model.KindRadioArtwork)) - Expect(artID.ID).To(Equal("rd-1")) - Expect(artID.LastUpdate.IsZero()).To(BeTrue()) - }) - }) - - Describe("UploadedImagePath", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir("/data") - }) - - It("returns empty string when no image uploaded", func() { - r := model.Radio{ID: "rd-1"} - Expect(r.UploadedImagePath()).To(BeEmpty()) - }) - - It("returns full path when image is set", func() { - r := model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} - Expect(r.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "radio", "rd-1_test.jpg"))) - }) - }) -}) diff --git a/model/request/request.go b/model/request/request.go index 33c49f797..eead56d54 100644 --- a/model/request/request.go +++ b/model/request/request.go @@ -2,7 +2,6 @@ package request import ( "context" - "sync/atomic" "github.com/navidrome/navidrome/model" ) @@ -10,32 +9,15 @@ import ( type contextKey string const ( - User = contextKey("user") - Username = contextKey("username") - Client = contextKey("client") - Version = contextKey("version") - Player = contextKey("player") - Transcoding = contextKey("transcoding") - ClientUniqueId = contextKey("clientUniqueId") - ReverseProxyIp = contextKey("reverseProxyIp") - InternalAuth = contextKey("internalAuth") // Used for internal API calls, e.g., from the plugins - TokenEpochHolder = contextKey("tokenEpochHolder") - ServerAddress = contextKey("serverAddress") + User = contextKey("user") + Username = contextKey("username") + Client = contextKey("client") + Version = contextKey("version") + Player = contextKey("player") + Transcoding = contextKey("transcoding") + ClientUniqueId = contextKey("clientUniqueId") ) -var allKeys = []contextKey{ - User, - Username, - Client, - Version, - Player, - Transcoding, - ClientUniqueId, - ReverseProxyIp, - InternalAuth, - ServerAddress, -} - func WithUser(ctx context.Context, u model.User) context.Context { return context.WithValue(ctx, User, u) } @@ -64,33 +46,6 @@ func WithClientUniqueId(ctx context.Context, clientUniqueId string) context.Cont return context.WithValue(ctx, ClientUniqueId, clientUniqueId) } -func WithReverseProxyIp(ctx context.Context, reverseProxyIp string) context.Context { - return context.WithValue(ctx, ReverseProxyIp, reverseProxyIp) -} - -func WithInternalAuth(ctx context.Context, username string) context.Context { - return context.WithValue(ctx, InternalAuth, username) -} - -// serverAddress is the public scheme and host the client used to reach this server, -// so code running without an http.Request can still build absolute URLs. -type serverAddress struct { - scheme string - host string -} - -func WithServerAddress(ctx context.Context, scheme, host string) context.Context { - return context.WithValue(ctx, ServerAddress, serverAddress{scheme: scheme, host: host}) -} - -func ServerAddressFrom(ctx context.Context) (scheme, host string, ok bool) { - a, ok := ctx.Value(ServerAddress).(serverAddress) - if !ok || a.host == "" { - return "", "", false - } - return a.scheme, a.host, true -} - func UserFrom(ctx context.Context) (model.User, bool) { v, ok := ctx.Value(User).(model.User) return v, ok @@ -125,55 +80,3 @@ func ClientUniqueIdFrom(ctx context.Context) (string, bool) { v, ok := ctx.Value(ClientUniqueId).(string) return v, ok } - -func ReverseProxyIpFrom(ctx context.Context) (string, bool) { - v, ok := ctx.Value(ReverseProxyIp).(string) - return v, ok -} - -func InternalAuthFrom(ctx context.Context) (string, bool) { - if v := ctx.Value(InternalAuth); v != nil { - if username, ok := v.(string); ok { - return username, true - } - } - return "", false -} - -func AddValues(ctx, requestCtx context.Context) context.Context { - for _, key := range allKeys { - if v := requestCtx.Value(key); v != nil { - ctx = context.WithValue(ctx, key, v) - } - } - return ctx -} - -type tokenEpochHolder struct { - value atomic.Int64 -} - -// WithTokenEpochHolder installs a slot a handler can use to report a bumped token epoch -// back to middleware that has already returned from the handler's perspective. -func WithTokenEpochHolder(ctx context.Context) context.Context { - h := &tokenEpochHolder{} - h.value.Store(-1) - return context.WithValue(ctx, TokenEpochHolder, h) -} - -func SetTokenEpoch(ctx context.Context, epoch int) { - if h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder); ok { - h.value.Store(int64(epoch)) - } -} - -func TokenEpochFrom(ctx context.Context) (int, bool) { - h, ok := ctx.Value(TokenEpochHolder).(*tokenEpochHolder) - if !ok { - return 0, false - } - if v := h.value.Load(); v >= 0 { - return int(v), true - } - return 0, false -} diff --git a/model/request/request_suite_test.go b/model/request/request_suite_test.go deleted file mode 100644 index 643ca76d7..000000000 --- a/model/request/request_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package request - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// tests.Init is not used here: the tests package imports model/request, so importing it -// back would create an import cycle. -func TestRequest(t *testing.T) { - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Request Suite") -} diff --git a/model/request/request_test.go b/model/request/request_test.go deleted file mode 100644 index da819f9c0..000000000 --- a/model/request/request_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package request - -import ( - "context" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Token epoch holder", func() { - It("reports nothing when unset", func() { - ctx := WithTokenEpochHolder(context.TODO()) - _, ok := TokenEpochFrom(ctx) - Expect(ok).To(BeFalse()) - }) - - It("round-trips a value set by the handler", func() { - ctx := WithTokenEpochHolder(context.TODO()) - SetTokenEpoch(ctx, 7) - - epoch, ok := TokenEpochFrom(ctx) - Expect(ok).To(BeTrue()) - Expect(epoch).To(Equal(7)) - }) - - It("survives being wrapped in a derived context", func() { - ctx := WithTokenEpochHolder(context.TODO()) - SetTokenEpoch(context.WithValue(ctx, contextKey("unrelated"), 1), 3) - - epoch, ok := TokenEpochFrom(ctx) - Expect(ok).To(BeTrue()) - Expect(epoch).To(Equal(3)) - }) - - It("is a no-op with no holder installed", func() { - Expect(func() { SetTokenEpoch(context.TODO(), 5) }).ToNot(Panic()) - _, ok := TokenEpochFrom(context.TODO()) - Expect(ok).To(BeFalse()) - }) -}) - -var _ = Describe("Server address", func() { - It("reports nothing when unset", func() { - _, _, ok := ServerAddressFrom(context.TODO()) - Expect(ok).To(BeFalse()) - }) - - It("round-trips the scheme and host", func() { - ctx := WithServerAddress(context.TODO(), "https", "music.example.com") - - scheme, host, ok := ServerAddressFrom(ctx) - Expect(ok).To(BeTrue()) - Expect(scheme).To(Equal("https")) - Expect(host).To(Equal("music.example.com")) - }) - - It("reports nothing when the host is empty", func() { - ctx := WithServerAddress(context.TODO(), "https", "") - - _, _, ok := ServerAddressFrom(ctx) - Expect(ok).To(BeFalse()) - }) - - It("is carried over to a background context by AddValues", func() { - reqCtx := WithServerAddress(context.TODO(), "https", "music.example.com") - - _, host, ok := ServerAddressFrom(AddValues(context.Background(), reqCtx)) - Expect(ok).To(BeTrue()) - Expect(host).To(Equal("music.example.com")) - }) -}) diff --git a/model/scanner.go b/model/scanner.go deleted file mode 100644 index d22c3d0d6..000000000 --- a/model/scanner.go +++ /dev/null @@ -1,84 +0,0 @@ -package model - -import ( - "context" - "errors" - "fmt" - "strconv" - "strings" - "time" -) - -var ErrAlreadyScanning = errors.New("already scanning") - -// ScanTarget represents a specific folder within a library to be scanned. -// NOTE: This struct is used as a map key, so it should only contain comparable types. -type ScanTarget struct { - LibraryID int - FolderPath string // Path within the library (relative or absolute), or "" for entire library -} - -func (st ScanTarget) String() string { - return fmt.Sprintf("%d:%s", st.LibraryID, st.FolderPath) -} - -// ScannerStatus holds information about the current scan status -type ScannerStatus struct { - Scanning bool - LastScan time.Time - Count uint32 - FolderCount uint32 - LastError string - ScanType string - ElapsedTime time.Duration -} - -type Scanner interface { - // ScanAll starts a scan of all libraries. This is a blocking operation. - ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) - // ScanFolders scans specific library/folder pairs, recursing into subdirectories. - // If targets is nil, it scans all libraries. This is a blocking operation. - ScanFolders(ctx context.Context, fullScan bool, targets []ScanTarget) (warnings []string, err error) - Status(context.Context) (*ScannerStatus, error) -} - -// ParseTargets parses scan targets strings into ScanTarget structs. -// Example: []string{"1:Music/Rock", "2:Classical"} -func ParseTargets(libFolders []string) ([]ScanTarget, error) { - targets := make([]ScanTarget, 0, len(libFolders)) - - for _, part := range libFolders { - part = strings.TrimSpace(part) - if part == "" { - continue - } - - // Split by the first colon - before, after, ok := strings.Cut(part, ":") - if !ok { - return nil, fmt.Errorf("invalid target format: %q (expected libraryID:folderPath)", part) - } - - libIDStr := before - folderPath := after - - libID, err := strconv.Atoi(libIDStr) - if err != nil { - return nil, fmt.Errorf("invalid library ID %q: %w", libIDStr, err) - } - if libID <= 0 { - return nil, fmt.Errorf("invalid library ID %q", libIDStr) - } - - targets = append(targets, ScanTarget{ - LibraryID: libID, - FolderPath: folderPath, - }) - } - - if len(targets) == 0 { - return nil, fmt.Errorf("no valid targets found") - } - - return targets, nil -} diff --git a/model/scanner_test.go b/model/scanner_test.go deleted file mode 100644 index 8ca0c53fa..000000000 --- a/model/scanner_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package model_test - -import ( - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("ParseTargets", func() { - It("parses multiple entries in slice", func() { - targets, err := model.ParseTargets([]string{"1:Music/Rock", "1:Music/Jazz", "2:Classical"}) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(HaveLen(3)) - Expect(targets[0].LibraryID).To(Equal(1)) - Expect(targets[0].FolderPath).To(Equal("Music/Rock")) - Expect(targets[1].LibraryID).To(Equal(1)) - Expect(targets[1].FolderPath).To(Equal("Music/Jazz")) - Expect(targets[2].LibraryID).To(Equal(2)) - Expect(targets[2].FolderPath).To(Equal("Classical")) - }) - - It("handles empty folder paths", func() { - targets, err := model.ParseTargets([]string{"1:", "2:"}) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(HaveLen(2)) - Expect(targets[0].FolderPath).To(Equal("")) - Expect(targets[1].FolderPath).To(Equal("")) - }) - - It("trims whitespace from entries", func() { - targets, err := model.ParseTargets([]string{" 1:Music/Rock", " 2:Classical "}) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(HaveLen(2)) - Expect(targets[0].LibraryID).To(Equal(1)) - Expect(targets[0].FolderPath).To(Equal("Music/Rock")) - Expect(targets[1].LibraryID).To(Equal(2)) - Expect(targets[1].FolderPath).To(Equal("Classical")) - }) - - It("skips empty strings", func() { - targets, err := model.ParseTargets([]string{"1:Music/Rock", "", "2:Classical"}) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(HaveLen(2)) - }) - - It("handles paths with colons", func() { - targets, err := model.ParseTargets([]string{"1:C:/Music/Rock", "2:/path:with:colons"}) - Expect(err).ToNot(HaveOccurred()) - Expect(targets).To(HaveLen(2)) - Expect(targets[0].FolderPath).To(Equal("C:/Music/Rock")) - Expect(targets[1].FolderPath).To(Equal("/path:with:colons")) - }) - - It("returns error for invalid format without colon", func() { - _, err := model.ParseTargets([]string{"1Music/Rock"}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid target format")) - }) - - It("returns error for non-numeric library ID", func() { - _, err := model.ParseTargets([]string{"abc:Music/Rock"}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid library ID")) - }) - - It("returns error for negative library ID", func() { - _, err := model.ParseTargets([]string{"-1:Music/Rock"}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid library ID")) - }) - - It("returns error for zero library ID", func() { - _, err := model.ParseTargets([]string{"0:Music/Rock"}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid library ID")) - }) - - It("returns error for empty input", func() { - _, err := model.ParseTargets([]string{}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("no valid targets found")) - }) - - It("returns error for all empty strings", func() { - _, err := model.ParseTargets([]string{"", " ", ""}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("no valid targets found")) - }) -}) diff --git a/model/scrobble.go b/model/scrobble.go deleted file mode 100644 index b99d25b6a..000000000 --- a/model/scrobble.go +++ /dev/null @@ -1,25 +0,0 @@ -package model - -import ( - "context" - "time" - - "github.com/deluan/rest" -) - -type Scrobble struct { - ID int64 `structs:"id" json:"id"` - MediaFileID string `structs:"media_file_id" json:"mediaFileId"` - UserID string `json:"-"` - SubmissionTime int64 `structs:"submission_time" json:"submissionTime"` -} - -type ScrobbleRepository interface { - rest.Repository[Scrobble] - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Get(ctx context.Context, id string) (*Scrobble, error) - GetAll(ctx context.Context, options ...QueryOptions) (Scrobbles, error) - RecordScrobble(ctx context.Context, mediaFileID string, submissionTime time.Time) error -} - -type Scrobbles []Scrobble diff --git a/model/scrobble_buffer.go b/model/scrobble_buffer.go index dda8dfce3..9b2c754a7 100644 --- a/model/scrobble_buffer.go +++ b/model/scrobble_buffer.go @@ -1,27 +1,21 @@ package model -import ( - "context" - "time" -) +import "time" type ScrobbleEntry struct { - ID string + MediaFile Service string - UserID string + UserID string `structs:"user_id" orm:"column(user_id)"` PlayTime time.Time EnqueueTime time.Time - MediaFileID string - MediaFile } type ScrobbleEntries []ScrobbleEntry type ScrobbleBufferRepository interface { - UserIDs(ctx context.Context, service string) ([]string, error) - Enqueue(ctx context.Context, service, userId, mediaFileId string, playTime time.Time) error - Next(ctx context.Context, service string, userId string) (*ScrobbleEntry, error) - Dequeue(ctx context.Context, entry *ScrobbleEntry) error - Length(ctx context.Context) (int64, error) - Discard(ctx context.Context, service string) error + UserIDs(service string) ([]string, error) + Enqueue(service, userId, mediaFileId string, playTime time.Time) error + Next(service string, userId string) (*ScrobbleEntry, error) + Dequeue(entry *ScrobbleEntry) error + Length() (int64, error) } diff --git a/model/searchable.go b/model/searchable.go deleted file mode 100644 index 6dba989c1..000000000 --- a/model/searchable.go +++ /dev/null @@ -1,7 +0,0 @@ -package model - -import "context" - -type SearchableRepository[T any] interface { - Search(ctx context.Context, q string, options ...QueryOptions) (T, error) -} diff --git a/model/share.go b/model/share.go index 952fd9759..4486fdf44 100644 --- a/model/share.go +++ b/model/share.go @@ -1,39 +1,39 @@ package model import ( - "cmp" - "context" "strings" "time" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/utils/random" + "github.com/navidrome/navidrome/utils/number" ) type Share struct { - ID string `structs:"id" json:"id,omitempty"` - UserID string `structs:"user_id" json:"userId,omitempty"` - Username string `structs:"-" json:"username,omitempty"` + ID string `structs:"id" json:"id,omitempty" orm:"column(id)"` + UserID string `structs:"user_id" json:"userId,omitempty" orm:"column(user_id)"` + Username string `structs:"-" json:"username,omitempty" orm:"-"` Description string `structs:"description" json:"description,omitempty"` Downloadable bool `structs:"downloadable" json:"downloadable"` - ExpiresAt *time.Time `structs:"expires_at" json:"expiresAt,omitempty"` - LastVisitedAt *time.Time `structs:"last_visited_at" json:"lastVisitedAt,omitempty"` - ResourceIDs string `structs:"resource_ids" json:"resourceIds,omitempty"` + ExpiresAt time.Time `structs:"expires_at" json:"expiresAt,omitempty"` + LastVisitedAt time.Time `structs:"last_visited_at" json:"lastVisitedAt,omitempty"` + ResourceIDs string `structs:"resource_ids" json:"resourceIds,omitempty" orm:"column(resource_ids)"` ResourceType string `structs:"resource_type" json:"resourceType,omitempty"` Contents string `structs:"contents" json:"contents,omitempty"` Format string `structs:"format" json:"format,omitempty"` MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate,omitempty"` VisitCount int `structs:"visit_count" json:"visitCount,omitempty"` - CreatedAt time.Time `structs:"created_at" json:"createdAt"` - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` - Tracks MediaFiles `structs:"-" json:"tracks,omitempty"` - Albums Albums `structs:"-" json:"albums,omitempty"` - URL string `structs:"-" json:"-"` - ImageURL string `structs:"-" json:"-"` + CreatedAt time.Time `structs:"created_at" json:"createdAt,omitempty"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt,omitempty"` + Tracks MediaFiles `structs:"-" json:"tracks,omitempty" orm:"-"` + Albums Albums `structs:"-" json:"albums,omitempty" orm:"-"` + URL string `structs:"-" json:"-" orm:"-"` + ImageURL string `structs:"-" json:"-" orm:"-"` } func (s Share) CoverArtID() ArtworkID { ids := strings.SplitN(s.ResourceIDs, ",", 2) + if len(ids) == 0 { + return ArtworkID{} + } switch s.ResourceType { case "album": return Album{ID: ids[0]}.CoverArtID() @@ -42,26 +42,14 @@ func (s Share) CoverArtID() ArtworkID { case "artist": return Artist{ID: ids[0]}.CoverArtID() } - // Tracks can be empty when they went missing or the owner lost access to their library. - if len(s.Tracks) == 0 { - return ArtworkID{} - } - rnd := random.Int64N(len(s.Tracks)) + rnd := number.RandomInt64(int64(len(s.Tracks))) return s.Tracks[rnd].CoverArtID() } type Shares []Share -// ToM3U8 exports the share to the Extended M3U8 format. -func (s Share) ToM3U8() string { - return s.Tracks.ToM3U8(cmp.Or(s.Description, s.ID), false) -} - type ShareRepository interface { - rest.Repository[Share] - rest.Persistable[Share] - Exists(ctx context.Context, id string) (bool, error) - Get(ctx context.Context, id string) (*Share, error) - GetAll(ctx context.Context, options ...QueryOptions) (Shares, error) - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) + Exists(id string) (bool, error) + Get(id string) (*Share, error) + GetAll(options ...QueryOptions) (Shares, error) } diff --git a/model/share_test.go b/model/share_test.go deleted file mode 100644 index b5e38af59..000000000 --- a/model/share_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package model_test - -import ( - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Share.CoverArtID", func() { - It("returns an empty artwork ID for a media file share with no visible tracks", func() { - s := model.Share{ResourceType: "media_file", ResourceIDs: "mf-1"} - Expect(s.CoverArtID()).To(Equal(model.ArtworkID{})) - }) - - It("picks a track's cover for a media file share", func() { - s := model.Share{ResourceType: "media_file", ResourceIDs: "mf-1", Tracks: model.MediaFiles{{ID: "mf-1"}}} - Expect(s.CoverArtID()).To(Equal(model.MediaFile{ID: "mf-1"}.CoverArtID())) - }) -}) diff --git a/model/tag.go b/model/tag.go deleted file mode 100644 index 60fa252dd..000000000 --- a/model/tag.go +++ /dev/null @@ -1,275 +0,0 @@ -package model - -import ( - "cmp" - "context" - "fmt" - "slices" - "strings" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" - "github.com/zeebo/xxh3" -) - -type Tag struct { - ID string `json:"id,omitempty"` - TagName TagName `json:"tagName,omitempty"` - TagValue string `json:"tagValue,omitempty"` - AlbumCount int `json:"albumCount,omitempty"` - SongCount int `json:"songCount,omitempty"` -} - -type TagList []Tag - -func (l TagList) GroupByFrequency() Tags { - grouped := map[string]map[string]int{} - values := map[string]string{} - firstSeen := map[string]int{} - for i, t := range l { - if m, ok := grouped[string(t.TagName)]; !ok { - grouped[string(t.TagName)] = map[string]int{t.ID: 1} - } else { - m[t.ID]++ - } - values[t.ID] = t.TagValue - if _, ok := firstSeen[t.ID]; !ok { - firstSeen[t.ID] = i - } - } - - tags := Tags{} - for name, counts := range grouped { - idList := make([]string, 0, len(counts)) - for tid := range counts { - idList = append(idList, tid) - } - slices.SortFunc(idList, func(a, b string) int { - return cmp.Or( - cmp.Compare(counts[b], counts[a]), - cmp.Compare(firstSeen[a], firstSeen[b]), - ) - }) - tags[TagName(name)] = slice.Map(idList, func(id string) string { return values[id] }) - } - return tags -} - -func (t Tag) String() string { - return fmt.Sprintf("%s=%s", t.TagName, t.TagValue) -} - -func NewTag(name TagName, value string) Tag { - name = name.ToLower() - hashID := tagID(name, value) - return Tag{ - ID: hashID, - TagName: name, - TagValue: value, - } -} - -func tagID(name TagName, value string) string { - return id.NewTagID(string(name), value) -} - -type RawTags map[string][]string - -type Tags map[TagName][]string - -func (t Tags) Values(name TagName) []string { - return t[name] -} - -func (t Tags) First(name TagName) string { - if v := t[name]; len(v) > 0 { - return v[0] - } - return "" -} - -func (t Tags) IDs() []string { - var ids []string - for name, tag := range t { - name = name.ToLower() - for _, v := range tag { - ids = append(ids, tagID(name, v)) - } - } - return ids -} - -func (t Tags) Flatten(name TagName) TagList { - var tags TagList - for _, v := range t[name] { - tags = append(tags, NewTag(name, v)) - } - return tags -} - -func (t Tags) FlattenAll() TagList { - var tags TagList - for name, values := range t { - for _, v := range values { - tags = append(tags, NewTag(name, v)) - } - } - return tags -} - -func (t Tags) Sort() { - for _, values := range t { - slices.Sort(values) - } -} - -func (t Tags) Hash() []byte { - if len(t) == 0 { - return nil - } - ids := t.IDs() - slices.Sort(ids) - sum := xxh3.New() - sum.Write([]byte(strings.Join(ids, "|"))) - return sum.Sum(nil) -} - -func (t Tags) ToGenres() (string, Genres) { - values := t.Values("genre") - if len(values) == 0 { - return "", nil - } - genres := slice.Map(values, func(g string) Genre { - t := NewTag("genre", g) - return Genre{ID: t.ID, Name: g} - }) - return genres[0].Name, genres -} - -// Merge merges the tags from another Tags object into this one, removing any duplicates -func (t Tags) Merge(tags Tags) { - for name, values := range tags { - for _, v := range values { - t.Add(name, v) - } - } -} - -func (t Tags) Add(name TagName, v string) { - if slices.Contains(t[name], v) { - return - } - t[name] = append(t[name], v) -} - -type TagRepository interface { - rest.Repository[Tag] - Add(ctx context.Context, libraryID int, tags ...Tag) error - UpdateCounts(ctx context.Context) error - GetAll(ctx context.Context, name TagName, options ...QueryOptions) (TagList, error) -} - -type TagName string - -func (t TagName) ToLower() TagName { - return TagName(strings.ToLower(string(t))) -} - -func (t TagName) String() string { - return string(t) -} - -// Tag names, as defined in the mappings.yaml file -const ( - TagAlbum TagName = "album" - TagTitle TagName = "title" - TagTrackNumber TagName = "track" - TagDiscNumber TagName = "disc" - TagTotalTracks TagName = "tracktotal" - TagTotalDiscs TagName = "disctotal" - TagDiscSubtitle TagName = "discsubtitle" - TagSubtitle TagName = "subtitle" - TagGenre TagName = "genre" - TagMood TagName = "mood" - TagComment TagName = "comment" - TagAlbumSort TagName = "albumsort" - TagAlbumVersion TagName = "albumversion" - TagTitleSort TagName = "titlesort" - TagCompilation TagName = "compilation" - TagGrouping TagName = "grouping" - TagLyrics TagName = "lyrics" - TagRecordLabel TagName = "recordlabel" - TagReleaseType TagName = "releasetype" - TagReleaseCountry TagName = "releasecountry" - TagMedia TagName = "media" - TagCatalogNumber TagName = "catalognumber" - TagISRC TagName = "isrc" - TagBPM TagName = "bpm" - TagExplicitStatus TagName = "explicitstatus" - TagWork TagName = "work" - TagMovementName TagName = "movementname" - TagMovementNumber TagName = "movement" - TagMovementTotal TagName = "movementtotal" - - // Dates and years - - TagOriginalDate TagName = "originaldate" - TagReleaseDate TagName = "releasedate" - TagRecordingDate TagName = "recordingdate" - - // Artists and roles - - TagAlbumArtist TagName = "albumartist" - TagAlbumArtists TagName = "albumartists" - TagAlbumArtistSort TagName = "albumartistsort" - TagAlbumArtistsSort TagName = "albumartistssort" - TagTrackArtist TagName = "artist" - TagTrackArtists TagName = "artists" - TagTrackArtistSort TagName = "artistsort" - TagTrackArtistsSort TagName = "artistssort" - TagComposer TagName = "composer" - TagComposerSort TagName = "composersort" - TagLyricist TagName = "lyricist" - TagLyricistSort TagName = "lyricistsort" - TagDirector TagName = "director" - TagProducer TagName = "producer" - TagEngineer TagName = "engineer" - TagMixer TagName = "mixer" - TagRemixer TagName = "remixer" - TagDJMixer TagName = "djmixer" - TagConductor TagName = "conductor" - TagArranger TagName = "arranger" - TagPerformer TagName = "performer" - - // ReplayGain - - TagReplayGainAlbumGain TagName = "replaygain_album_gain" - TagReplayGainAlbumPeak TagName = "replaygain_album_peak" - TagReplayGainTrackGain TagName = "replaygain_track_gain" - TagReplayGainTrackPeak TagName = "replaygain_track_peak" - TagR128AlbumGain TagName = "r128_album_gain" - TagR128TrackGain TagName = "r128_track_gain" - - // MusicBrainz - - TagMusicBrainzArtistID TagName = "musicbrainz_artistid" - TagMusicBrainzRecordingID TagName = "musicbrainz_recordingid" - TagMusicBrainzTrackID TagName = "musicbrainz_trackid" - TagMusicBrainzAlbumArtistID TagName = "musicbrainz_albumartistid" - TagMusicBrainzAlbumID TagName = "musicbrainz_albumid" - TagMusicBrainzReleaseGroupID TagName = "musicbrainz_releasegroupid" - TagMusicBrainzWorkID TagName = "musicbrainz_workid" - - TagMusicBrainzComposerID TagName = "musicbrainz_composerid" - TagMusicBrainzLyricistID TagName = "musicbrainz_lyricistid" - TagMusicBrainzDirectorID TagName = "musicbrainz_directorid" - TagMusicBrainzProducerID TagName = "musicbrainz_producerid" - TagMusicBrainzEngineerID TagName = "musicbrainz_engineerid" - TagMusicBrainzMixerID TagName = "musicbrainz_mixerid" - TagMusicBrainzRemixerID TagName = "musicbrainz_remixerid" - TagMusicBrainzDJMixerID TagName = "musicbrainz_djmixerid" - TagMusicBrainzConductorID TagName = "musicbrainz_conductorid" - TagMusicBrainzArrangerID TagName = "musicbrainz_arrangerid" - TagMusicBrainzPerformerID TagName = "musicbrainz_performerid" -) diff --git a/model/tag_mappings.go b/model/tag_mappings.go deleted file mode 100644 index 5a8168754..000000000 --- a/model/tag_mappings.go +++ /dev/null @@ -1,394 +0,0 @@ -package model - -import ( - "cmp" - "maps" - "regexp" - "slices" - "strings" - "sync" - "sync/atomic" - "unicode" - "unicode/utf8" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/criteria" - "github.com/navidrome/navidrome/resources" - "gopkg.in/yaml.v3" -) - -type mappingsConf struct { - Main tagMappings `yaml:"main"` - Additional tagMappings `yaml:"additional"` - Roles TagConf `yaml:"roles"` - Artists TagConf `yaml:"artists"` -} - -type tagMappings map[TagName]TagConf - -type TagConf struct { - Aliases []string `yaml:"aliases"` - Type TagType `yaml:"type"` - MaxLength int `yaml:"maxLength"` - Split []string `yaml:"split"` - Album bool `yaml:"album"` - SplitRx *regexp.Regexp `yaml:"-"` - ExceptionsRx *regexp.Regexp `yaml:"-"` -} - -// SplitTagValue splits tag values by the configured split separators. -// Each value in the input slice is individually split and trimmed. -func (c TagConf) SplitTagValue(values []string) []string { - if c.SplitRx == nil || len(values) == 0 { - return values - } - - var result []string - for _, tag := range values { - result = append(result, c.splitValue(tag)...) - } - return result -} - -func (c TagConf) splitValue(tag string) []string { - protected := protectedSpans(tag, c.ExceptionsRx) - var parts []string - start := 0 - for _, sep := range c.SplitRx.FindAllStringIndex(tag, -1) { - if overlapsAny(sep, protected) { - continue - } - parts = append(parts, strings.TrimSpace(tag[start:sep[0]])) - start = sep[1] - } - return append(parts, strings.TrimSpace(tag[start:])) -} - -// protectedSpans returns the spans of rx matches that sit on word boundaries. -// Boundaries are checked here, rune-aware, because RE2's \b is ASCII-only and -// would silently never match names starting/ending with accented letters. -func protectedSpans(tag string, rx *regexp.Regexp) [][]int { - if rx == nil { - return nil - } - var spans [][]int - for _, span := range rx.FindAllStringIndex(tag, -1) { - if isWordBounded(tag, span[0], span[1]) { - spans = append(spans, span) - } - } - return spans -} - -func isWordBounded(s string, start, end int) bool { - isWord := func(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) } - before, _ := utf8.DecodeLastRuneInString(s[:start]) - after, _ := utf8.DecodeRuneInString(s[end:]) - return !isWord(before) && !isWord(after) -} - -func overlapsAny(span []int, spans [][]int) bool { - for _, s := range spans { - if span[0] < s[1] && s[0] < span[1] { - return true - } - } - return false -} - -// compileExceptionsRegex builds a case-insensitive regex matching any of the -// given literal names, or nil if there are none. -func compileExceptionsRegex(exceptions []string) *regexp.Regexp { - var names []string - for _, e := range exceptions { - if e = strings.TrimSpace(e); e != "" { - names = append(names, e) - } - } - if len(names) == 0 { - return nil - } - // Longest-first: Go regex alternation is leftmost-first, so with overlapping - // entries (e.g. "Iron and Wine Duo" vs "Iron and Wine") the longer name must - // come first to win. Ties broken lexicographically for determinism. - slices.SortFunc(names, func(a, b string) int { - if c := cmp.Compare(len(b), len(a)); c != 0 { - return c - } - return cmp.Compare(a, b) - }) - escaped := make([]string, len(names)) - for i, name := range names { - escaped[i] = regexp.QuoteMeta(name) - } - rx, err := regexp.Compile("(?i)(" + strings.Join(escaped, "|") + ")") - if err != nil { - log.Warn("Error compiling split exceptions regexp", "exceptions", exceptions, err) - return nil - } - return rx -} - -type artistSplitExceptionsCache struct { - names []string - rx *regexp.Regexp -} - -var artistSplitExceptions atomic.Pointer[artistSplitExceptionsCache] - -// artistSplitExceptionsRx returns the regex for Scanner.ArtistSplitExceptions, -// or nil if none are configured. Compiled lazily (config hooks only run once -// per process, before tests can override the option) and cached until the -// configured list changes. Lock-free on the cache-hit path, as this is called -// per tag mapping per scanned file, across concurrent scanner goroutines. -func artistSplitExceptionsRx() *regexp.Regexp { - names := conf.Server.Scanner.ArtistSplitExceptions - if c := artistSplitExceptions.Load(); c != nil && slices.Equal(c.names, names) { - return c.rx - } - c := &artistSplitExceptionsCache{names: slices.Clone(names), rx: compileExceptionsRegex(names)} - artistSplitExceptions.Store(c) - return c.rx -} - -// participantTagNames are the tags that hold artist names (or their sort -// values), where split exceptions apply. -var participantTagNames = sync.OnceValue(func() map[TagName]struct{} { - names := []TagName{ - TagTrackArtist, TagTrackArtists, TagTrackArtistSort, TagTrackArtistsSort, - TagAlbumArtist, TagAlbumArtists, TagAlbumArtistSort, TagAlbumArtistsSort, - } - set := make(map[TagName]struct{}, len(names)+2*len(AllRoles)) - for _, n := range names { - set[n] = struct{}{} - } - for role := range AllRoles { - set[TagName(role)] = struct{}{} - set[TagName(role+"sort")] = struct{}{} - } - return set -}) - -// WithParticipantExceptions returns the conf with the global artist split -// exceptions attached when name is a participant (artist/role) tag. -func (c TagConf) WithParticipantExceptions(name TagName) TagConf { - if _, ok := participantTagNames()[name]; ok { - c.ExceptionsRx = artistSplitExceptionsRx() - } - return c -} - -type TagType string - -const ( - TagTypeString TagType = "string" - TagTypeInteger TagType = "int" - TagTypeFloat TagType = "float" - TagTypeDate TagType = "date" - TagTypeUUID TagType = "uuid" - TagTypePair TagType = "pair" -) - -func TagMappings() map[TagName]TagConf { - mappings, _ := parseMappings() - return mappings -} - -// CanonicalTagName returns the mapped tag that name is, or is an alias of. Tags are stored under this name. -func CanonicalTagName(name string) (TagName, bool) { - tagName, ok := tagNameIndex()[TagName(name).ToLower()] - return tagName, ok -} - -// tagNameIndex maps every tag name and alias to its tag name. Names are added last, so they win over aliases -// (musicbrainz_trackid is a tag and also an alias of musicbrainz_recordingid). -var tagNameIndex = sync.OnceValue(func() map[TagName]TagName { - mappings := TagMappings() - index := make(map[TagName]TagName, len(mappings)) - for name, tag := range mappings { - for _, alias := range tag.Aliases { - index[TagName(alias)] = name - } - } - for name := range mappings { - index[name] = name - } - return index -}) - -func TagRolesConf() TagConf { - _, cfg := parseMappings() - return cfg.Roles -} - -func TagArtistsConf() TagConf { - _, cfg := parseMappings() - return cfg.Artists -} - -func TagMainMappings() map[TagName]TagConf { - _, mappings := parseMappings() - return mappings.Main -} - -var _mappings mappingsConf - -var parseMappings = sync.OnceValues(func() (map[TagName]TagConf, mappingsConf) { - _mappings.Artists.SplitRx = compileSplitRegex("artists", _mappings.Artists.Split) - _mappings.Roles.SplitRx = compileSplitRegex("roles", _mappings.Roles.Split) - - normalized := tagMappings{} - collectTags(_mappings.Main, normalized) - _mappings.Main = normalized - - normalized = tagMappings{} - collectTags(_mappings.Additional, normalized) - _mappings.Additional = normalized - - // Merge main and additional mappings, log an error if a tag is found in both - for k, v := range _mappings.Main { - if _, ok := _mappings.Additional[k]; ok { - log.Error("Tag found in both main and additional mappings", "tag", k) - } - normalized[k] = v - } - return normalized, _mappings -}) - -func collectTags(tagMappings, normalized map[TagName]TagConf) { - for k, v := range tagMappings { - var aliases []string - for _, val := range v.Aliases { - aliases = append(aliases, strings.ToLower(val)) - } - if v.Split != nil { - if v.Type != "" && v.Type != TagTypeString { - log.Error("Tag splitting only available for string types", "tag", k, "split", v.Split, - "type", string(v.Type)) - v.Split = nil - } else { - v.SplitRx = compileSplitRegex(k, v.Split) - } - } - v.Aliases = aliases - normalized[k.ToLower()] = v - } -} - -func compileSplitRegex(tagName TagName, split []string) *regexp.Regexp { - // Build a list of escaped, non-empty separators. - var escaped []string - for _, s := range split { - if s == "" { - continue - } - escaped = append(escaped, regexp.QuoteMeta(s)) - } - // If no valid separators remain, return the original value. - if len(escaped) == 0 { - if len(split) > 0 { - log.Warn("No valid separators found in split list", "split", split, "tag", tagName) - } - return nil - } - - // Create one regex that matches any of the separators (case-insensitive). - pattern := "(?i)(" + strings.Join(escaped, "|") + ")" - re, err := regexp.Compile(pattern) - if err != nil { - log.Warn("Error compiling regexp for split list", "pattern", pattern, "tag", tagName, "split", split, err) - return nil - } - return re -} - -func tagNames() []string { - mappings := TagMappings() - names := make([]string, 0, len(mappings)) - for k := range mappings { - names = append(names, string(k)) - } - return names -} - -func numericTagNames() []string { - mappings := TagMappings() - names := make([]string, 0) - for k, cfg := range mappings { - if cfg.Type == TagTypeInteger || cfg.Type == TagTypeFloat { - names = append(names, string(k)) - } - } - return names -} - -func loadTagMappings() { - mappingsFile, err := resources.FS().Open("mappings.yaml") - if err != nil { - log.Error("Error opening mappings.yaml", err) - } - decoder := yaml.NewDecoder(mappingsFile) - err = decoder.Decode(&_mappings) - if err != nil { - log.Error("Error decoding mappings.yaml", err) - } - if len(_mappings.Main) == 0 { - log.Error("No tag mappings found in mappings.yaml, check the format") - } - - // Use Scanner.GenreSeparators if specified and Tags.genre is not defined - if conf.Server.Scanner.GenreSeparators != "" && len(conf.Server.Tags["genre"].Aliases) == 0 { - genreConf := _mappings.Main[TagName("genre")] - genreConf.Split = strings.Split(conf.Server.Scanner.GenreSeparators, "") - genreConf.SplitRx = compileSplitRegex("genre", genreConf.Split) - _mappings.Main[TagName("genre")] = genreConf - log.Debug("Loading deprecated list of genre separators", "separators", genreConf.Split) - } - - // Overwrite the default mappings with the ones from the config - for tag, cfg := range conf.Server.Tags { - if cfg.Ignore { - delete(_mappings.Main, TagName(tag)) - delete(_mappings.Additional, TagName(tag)) - continue - } - oldValue, ok := _mappings.Main[TagName(tag)] - if !ok { - oldValue = _mappings.Additional[TagName(tag)] - } - aliases := cfg.Aliases - if len(aliases) == 0 { - aliases = oldValue.Aliases - } - split := cfg.Split - if split == nil { - split = oldValue.Split - } - c := TagConf{ - Aliases: aliases, - Split: split, - Type: cmp.Or(TagType(cfg.Type), oldValue.Type), - MaxLength: cmp.Or(cfg.MaxLength, oldValue.MaxLength), - Album: cmp.Or(cfg.Album, oldValue.Album), - } - c.SplitRx = compileSplitRegex(TagName(tag), c.Split) - if _, ok := _mappings.Main[TagName(tag)]; ok { - _mappings.Main[TagName(tag)] = c - } else { - _mappings.Additional[TagName(tag)] = c - } - } -} - -func init() { - conf.AddHook(func() { - loadTagMappings() - - // This is here to avoid cyclic imports. The criteria package needs to know all tag names, so they can be - // used in smart playlists - criteria.AddRoles(slices.Collect(maps.Keys(AllRoles))) - criteria.AddTagNames(tagNames()) - criteria.AddNumericTags(numericTagNames()) - }) -} diff --git a/model/tag_mappings_test.go b/model/tag_mappings_test.go deleted file mode 100644 index 91e54e5d4..000000000 --- a/model/tag_mappings_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package model - -import ( - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("TagConf", func() { - Describe("SplitTagValue", func() { - var conf TagConf - - BeforeEach(func() { - conf = TagConf{Split: []string{";", "/", ","}} - conf.SplitRx = compileSplitRegex("test", conf.Split) - }) - - It("splits a single value on configured separators", func() { - Expect(conf.SplitTagValue([]string{"Rock/Pop;Punk"})).To(Equal([]string{"Rock", "Pop", "Punk"})) - }) - - It("trims whitespace around split values", func() { - Expect(conf.SplitTagValue([]string{"Love, Emotional, Ballad"})).To(Equal([]string{"Love", "Emotional", "Ballad"})) - }) - - // Regression test for https://github.com/navidrome/navidrome/issues/5065 - // - // When multiple ID3v2 frames map to the same logical tag (e.g. TMOO + TXXX:MOOD), - // TagLib's PropertyMap merges them into a slice with several entries. Previously - // SplitTagValue had a `len(values) != 1` guard that skipped splitting in this case. - It("splits each value individually when given multiple inputs", func() { - input := []string{"Love, Emotional, Ballad", "Love; Emotional; Ballad"} - Expect(conf.SplitTagValue(input)).To(Equal([]string{ - "Love", "Emotional", "Ballad", - "Love", "Emotional", "Ballad", - })) - }) - - It("matches separators case-insensitively when the split pattern allows", func() { - c := TagConf{Split: []string{" AND "}} - c.SplitRx = compileSplitRegex("test", c.Split) - Expect(c.SplitTagValue([]string{"foo and bar AND baz"})).To(Equal([]string{"foo", "bar", "baz"})) - }) - - It("returns values unchanged when no separators are configured", func() { - c := TagConf{} - Expect(c.SplitTagValue([]string{"Foo, Bar"})).To(Equal([]string{"Foo, Bar"})) - Expect(c.SplitTagValue([]string{"a", "b"})).To(Equal([]string{"a", "b"})) - }) - - It("returns an empty slice for empty input", func() { - Expect(conf.SplitTagValue([]string{})).To(BeEmpty()) - }) - - It("handles a value with no separator as a single-element result", func() { - Expect(conf.SplitTagValue([]string{"JustOneMood"})).To(Equal([]string{"JustOneMood"})) - }) - - It("produces empty strings when separators are adjacent (dedup happens downstream)", func() { - // SplitTagValue itself does not filter empties; that is the job of - // filterDuplicatedOrEmptyValues in the metadata pipeline. - Expect(conf.SplitTagValue([]string{"Rock//Pop"})).To(Equal([]string{"Rock", "", "Pop"})) - }) - - Context("with split exceptions", func() { - BeforeEach(func() { - conf = TagConf{Split: []string{" and ", ";", "/"}} - conf.SplitRx = compileSplitRegex("test", conf.Split) - conf.ExceptionsRx = compileExceptionsRegex([]string{ - "Iron and Wine", - "Iron and Wine Duo", - "Ella and Louis", - "AC/DC", - "Ólafur Arnalds and Nils Frahm", - }) - }) - - It("does not split a value that is exactly an exception", func() { - Expect(conf.SplitTagValue([]string{"Iron and Wine"})).To(Equal([]string{"Iron and Wine"})) - }) - - It("protects an exception embedded in a multi-artist value", func() { - Expect(conf.SplitTagValue([]string{"Iron and Wine and Bob"})). - To(Equal([]string{"Iron and Wine", "Bob"})) - }) - - It("protects every occurrence, not just the first", func() { - Expect(conf.SplitTagValue([]string{"Iron and Wine; Bob; Iron and Wine"})). - To(Equal([]string{"Iron and Wine", "Bob", "Iron and Wine"})) - }) - - It("matches exceptions case-insensitively and keeps the tag's casing", func() { - Expect(conf.SplitTagValue([]string{"IRON AND WINE and Bob"})). - To(Equal([]string{"IRON AND WINE", "Bob"})) - }) - - It("prefers the longest exception when entries overlap", func() { - Expect(conf.SplitTagValue([]string{"Iron and Wine Duo and Bob"})). - To(Equal([]string{"Iron and Wine Duo", "Bob"})) - }) - - It("protects exceptions containing separator characters", func() { - Expect(conf.SplitTagValue([]string{"AC/DC/Queen"})). - To(Equal([]string{"AC/DC", "Queen"})) - }) - - It("does not protect an exception embedded in a longer word", func() { - // "Ella and Louis" must not match inside "Ella and Louise" - Expect(conf.SplitTagValue([]string{"Ella and Louise"})). - To(Equal([]string{"Ella", "Louise"})) - }) - - It("handles names with non-ASCII edges", func() { - Expect(conf.SplitTagValue([]string{"Ólafur Arnalds and Nils Frahm and Bob"})). - To(Equal([]string{"Ólafur Arnalds and Nils Frahm", "Bob"})) - }) - - It("splits normally when no exception matches", func() { - Expect(conf.SplitTagValue([]string{"Foo and Bar"})).To(Equal([]string{"Foo", "Bar"})) - }) - }) - }) - - Describe("compileExceptionsRegex", func() { - It("returns nil for an empty list", func() { - Expect(compileExceptionsRegex(nil)).To(BeNil()) - Expect(compileExceptionsRegex([]string{})).To(BeNil()) - }) - - It("returns nil when all entries are blank", func() { - Expect(compileExceptionsRegex([]string{"", " "})).To(BeNil()) - }) - - It("escapes regex metacharacters in names", func() { - rx := compileExceptionsRegex([]string{"Sigur (Rós)"}) - Expect(rx.FindString("Sigur (Rós)")).To(Equal("Sigur (Rós)")) - Expect(rx.MatchString("Sigur xRósx")).To(BeFalse()) - }) - - It("matches case-insensitively", func() { - rx := compileExceptionsRegex([]string{"Iron and Wine"}) - Expect(rx.MatchString("IRON AND WINE")).To(BeTrue()) - }) - }) - - Describe("artistSplitExceptionsRx", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("returns nil when no exceptions are configured", func() { - conf.Server.Scanner.ArtistSplitExceptions = nil - Expect(artistSplitExceptionsRx()).To(BeNil()) - }) - - It("compiles the configured exceptions", func() { - conf.Server.Scanner.ArtistSplitExceptions = []string{"Iron and Wine"} - rx := artistSplitExceptionsRx() - Expect(rx).ToNot(BeNil()) - Expect(rx.MatchString("iron and wine")).To(BeTrue()) - }) - - It("caches the compiled regex until the configuration changes", func() { - conf.Server.Scanner.ArtistSplitExceptions = []string{"Iron and Wine"} - first := artistSplitExceptionsRx() - Expect(artistSplitExceptionsRx()).To(BeIdenticalTo(first)) - - conf.Server.Scanner.ArtistSplitExceptions = []string{"AC/DC"} - second := artistSplitExceptionsRx() - Expect(second).ToNot(BeIdenticalTo(first)) - Expect(second.MatchString("AC/DC")).To(BeTrue()) - }) - }) - - Describe("WithParticipantExceptions", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Scanner.ArtistSplitExceptions = []string{"Iron and Wine"} - }) - - It("attaches the exceptions regex to participant tags", func() { - for _, tag := range []TagName{"artist", "albumartist", "artists", "artistsort", "composer", "lyricist", "composersort"} { - Expect(TagConf{}.WithParticipantExceptions(tag).ExceptionsRx).ToNot(BeNil(), string(tag)) - } - }) - - It("does not attach the exceptions regex to non-participant tags", func() { - for _, tag := range []TagName{"genre", "mood", "title", "releasetype"} { - Expect(TagConf{}.WithParticipantExceptions(tag).ExceptionsRx).To(BeNil(), string(tag)) - } - }) - }) -}) - -var _ = Describe("CanonicalTagName", func() { - DescribeTable("resolves tag names and aliases", - func(name string, expected TagName) { - tagName, ok := CanonicalTagName(name) - Expect(ok).To(BeTrue()) - Expect(tagName).To(Equal(expected)) - }, - Entry("tag name", "album", TagAlbum), - Entry("alias", "talb", TagAlbum), - Entry("mixed case alias", "TALB", TagAlbum), - Entry("tag name that is also an alias of another tag", "musicbrainz_trackid", TagMusicBrainzTrackID), - ) - - It("does not resolve an unknown name", func() { - _, ok := CanonicalTagName("nosuchtag") - Expect(ok).To(BeFalse()) - }) -}) diff --git a/model/tag_test.go b/model/tag_test.go deleted file mode 100644 index c5a941b4e..000000000 --- a/model/tag_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package model - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Tag", func() { - Describe("NewTag", func() { - It("should create a new tag", func() { - tag := NewTag("genre", "Rock") - tag2 := NewTag("Genre", "Rock") - tag3 := NewTag("Genre", "rock") - Expect(tag2.ID).To(Equal(tag.ID)) - Expect(tag3.ID).To(Equal(tag.ID)) - }) - }) - - Describe("Tags", func() { - var tags Tags - BeforeEach(func() { - tags = Tags{ - "genre": {"Rock", "Pop"}, - "artist": {"The Beatles"}, - } - }) - It("should flatten tags by name", func() { - flat := tags.Flatten("genre") - Expect(flat).To(ConsistOf( - NewTag("genre", "Rock"), - NewTag("genre", "Pop"), - )) - }) - It("should flatten tags", func() { - flat := tags.FlattenAll() - Expect(flat).To(ConsistOf( - NewTag("genre", "Rock"), - NewTag("genre", "Pop"), - NewTag("artist", "The Beatles"), - )) - }) - It("should get values by name", func() { - Expect(tags.Values("genre")).To(ConsistOf("Rock", "Pop")) - Expect(tags.Values("artist")).To(ConsistOf("The Beatles")) - }) - It("should get the first value by name", func() { - Expect(tags.First("genre")).To(Equal("Rock")) - Expect(tags.First("missing")).To(BeEmpty()) - }) - - Describe("Hash", func() { - It("should always return the same value for the same tags ", func() { - tags1 := Tags{ - "genre": {"Rock", "Pop"}, - } - tags2 := Tags{ - "Genre": {"pop", "rock"}, - } - Expect(tags1.Hash()).To(Equal(tags2.Hash())) - }) - It("should return different values for different tags", func() { - tags1 := Tags{ - "genre": {"Rock", "Pop"}, - } - tags2 := Tags{ - "artist": {"The Beatles"}, - } - Expect(tags1.Hash()).ToNot(Equal(tags2.Hash())) - }) - }) - }) - - Describe("TagList", func() { - Describe("GroupByFrequency", func() { - It("should return an empty Tags map for an empty TagList", func() { - tagList := TagList{} - - groupedTags := tagList.GroupByFrequency() - - Expect(groupedTags).To(BeEmpty()) - }) - - It("should handle tags with different frequencies correctly", func() { - tagList := TagList{ - NewTag("genre", "Jazz"), - NewTag("genre", "Rock"), - NewTag("genre", "Pop"), - NewTag("genre", "Rock"), - NewTag("artist", "The Rolling Stones"), - NewTag("artist", "The Beatles"), - NewTag("artist", "The Beatles"), - } - - groupedTags := tagList.GroupByFrequency() - - Expect(groupedTags).To(HaveKeyWithValue(TagName("genre"), []string{"Rock", "Jazz", "Pop"})) - Expect(groupedTags).To(HaveKeyWithValue(TagName("artist"), []string{"The Beatles", "The Rolling Stones"})) - }) - - It("should keep the order the values appeared in when frequency is the same", func() { - tagList := TagList{ - NewTag("genre", "Jazz"), - NewTag("genre", "Rock"), - NewTag("genre", "Alternative"), - NewTag("genre", "Pop"), - } - - groupedTags := tagList.GroupByFrequency() - - Expect(groupedTags).To(HaveKeyWithValue(TagName("genre"), []string{"Jazz", "Rock", "Alternative", "Pop"})) - }) - It("should normalize casing", func() { - tagList := TagList{ - NewTag("genre", "Synthwave"), - NewTag("genre", "synthwave"), - } - - groupedTags := tagList.GroupByFrequency() - - Expect(groupedTags).To(HaveKeyWithValue(TagName("genre"), []string{"synthwave"})) - }) - }) - }) -}) diff --git a/model/transcoding.go b/model/transcoding.go index da5e09e91..64dba662f 100644 --- a/model/transcoding.go +++ b/model/transcoding.go @@ -1,13 +1,7 @@ package model -import ( - "context" - - "github.com/deluan/rest" -) - type Transcoding struct { - ID string `structs:"id" json:"id"` + ID string `structs:"id" json:"id" orm:"column(id)"` Name string `structs:"name" json:"name"` TargetFormat string `structs:"target_format" json:"targetFormat"` Command string `structs:"command" json:"command"` @@ -17,10 +11,8 @@ type Transcoding struct { type Transcodings []Transcoding type TranscodingRepository interface { - rest.Repository[Transcoding] - rest.Persistable[Transcoding] - Get(ctx context.Context, id string) (*Transcoding, error) - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Put(ctx context.Context, t *Transcoding) error - FindByFormat(ctx context.Context, format string) (*Transcoding, error) + Get(id string) (*Transcoding, error) + CountAll(...QueryOptions) (int64, error) + Put(*Transcoding) error + FindByFormat(format string) (*Transcoding, error) } diff --git a/model/user.go b/model/user.go index bbeb4a05d..fa0a42eab 100644 --- a/model/user.go +++ b/model/user.go @@ -1,69 +1,38 @@ package model -import ( - "context" - "time" - - "github.com/deluan/rest" -) +import "time" type User struct { - ID string `structs:"id" json:"id"` - UserName string `structs:"user_name" json:"userName"` - Name string `structs:"name" json:"name"` - Email string `structs:"email" json:"email"` - IsAdmin bool `structs:"is_admin" json:"isAdmin"` - LastLoginAt *time.Time `structs:"last_login_at" json:"lastLoginAt"` - LastAccessAt *time.Time `structs:"last_access_at" json:"lastAccessAt"` - CreatedAt time.Time `structs:"created_at" json:"createdAt"` - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` - // Smart-playlist criteria JSON; matching songs are not sent to external scrobblers - ScrobbleFilter string `structs:"scrobble_filter" json:"scrobbleFilter"` - - // Library associations (many-to-many relationship) - Libraries Libraries `structs:"-" json:"libraries,omitempty"` + ID string `structs:"id" json:"id" orm:"column(id)"` + UserName string `structs:"user_name" json:"userName"` + Name string `structs:"name" json:"name"` + Email string `structs:"email" json:"email"` + IsAdmin bool `structs:"is_admin" json:"isAdmin"` + LastLoginAt time.Time `structs:"last_login_at" json:"lastLoginAt"` + LastAccessAt time.Time `structs:"last_access_at" json:"lastAccessAt"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` // This is only available on the backend, and it is never sent over the wire Password string `structs:"-" json:"-"` - // Bumped on password change to invalidate every issued token for this user. - TokenEpoch int `structs:"-" json:"-"` // This is used to set or change a password when calling Put. If it is empty, the password is not changed. // It is received from the UI with the name "password" - NewPassword string `structs:"password,omitempty" json:"password,omitempty"` //nolint:gosec + NewPassword string `structs:"password,omitempty" json:"password,omitempty"` // If changing the password, this is also required CurrentPassword string `structs:"current_password,omitempty" json:"currentPassword,omitempty"` } -func (u User) HasLibraryAccess(libraryID int) bool { - if u.IsAdmin { - return true // Admin users have access to all libraries - } - for _, lib := range u.Libraries { - if lib.ID == libraryID { - return true - } - } - return false -} - type Users []User type UserRepository interface { - rest.Repository[User] - rest.Persistable[User] - CountAll(ctx context.Context, options ...QueryOptions) (int64, error) - Get(ctx context.Context, id string) (*User, error) - GetAll(ctx context.Context, options ...QueryOptions) (Users, error) - Put(ctx context.Context, u *User) error - UpdateLastLoginAt(ctx context.Context, id string) error - UpdateLastAccessAt(ctx context.Context, id string) error - FindFirstAdmin(ctx context.Context) (*User, error) + CountAll(...QueryOptions) (int64, error) + Get(id string) (*User, error) + Put(*User) error + UpdateLastLoginAt(id string) error + UpdateLastAccessAt(id string) error + FindFirstAdmin() (*User, error) // FindByUsername must be case-insensitive - FindByUsername(ctx context.Context, username string) (*User, error) + FindByUsername(username string) (*User, error) // FindByUsernameWithPassword is the same as above, but also returns the decrypted password - FindByUsernameWithPassword(ctx context.Context, username string) (*User, error) - - // Library association methods - GetUserLibraries(ctx context.Context, userID string) (Libraries, error) - SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error + FindByUsernameWithPassword(username string) (*User, error) } diff --git a/model/user_props.go b/model/user_props.go index af19f34f1..c2eb536ec 100644 --- a/model/user_props.go +++ b/model/user_props.go @@ -1,10 +1,8 @@ package model -import "context" - type UserPropsRepository interface { - Put(ctx context.Context, userId, key string, value string) error - Get(ctx context.Context, userId, key string) (string, error) - Delete(ctx context.Context, userId, key string) error - DefaultGet(ctx context.Context, userId, key string, defaultValue string) (string, error) + Put(userId, key string, value string) error + Get(userId, key string) (string, error) + Delete(userId, key string) error + DefaultGet(userId, key string, defaultValue string) (string, error) } diff --git a/model/user_test.go b/model/user_test.go deleted file mode 100644 index ab66a29a9..000000000 --- a/model/user_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package model_test - -import ( - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("User", func() { - var user model.User - var libraries model.Libraries - - BeforeEach(func() { - libraries = model.Libraries{ - {ID: 1, Name: "Rock Library", Path: "/music/rock"}, - {ID: 2, Name: "Jazz Library", Path: "/music/jazz"}, - {ID: 3, Name: "Classical Library", Path: "/music/classical"}, - } - - user = model.User{ - ID: "user1", - UserName: "testuser", - Name: "Test User", - Email: "test@example.com", - IsAdmin: false, - Libraries: libraries, - } - }) - - Describe("HasLibraryAccess", func() { - Context("when user is admin", func() { - BeforeEach(func() { - user.IsAdmin = true - }) - - It("returns true for any library ID", func() { - Expect(user.HasLibraryAccess(1)).To(BeTrue()) - Expect(user.HasLibraryAccess(99)).To(BeTrue()) - Expect(user.HasLibraryAccess(-1)).To(BeTrue()) - }) - - It("returns true even when user has no libraries assigned", func() { - user.Libraries = nil - Expect(user.HasLibraryAccess(1)).To(BeTrue()) - }) - }) - - Context("when user is not admin", func() { - BeforeEach(func() { - user.IsAdmin = false - }) - - It("returns true for libraries the user has access to", func() { - Expect(user.HasLibraryAccess(1)).To(BeTrue()) - Expect(user.HasLibraryAccess(2)).To(BeTrue()) - Expect(user.HasLibraryAccess(3)).To(BeTrue()) - }) - - It("returns false for libraries the user does not have access to", func() { - Expect(user.HasLibraryAccess(4)).To(BeFalse()) - Expect(user.HasLibraryAccess(99)).To(BeFalse()) - Expect(user.HasLibraryAccess(-1)).To(BeFalse()) - Expect(user.HasLibraryAccess(0)).To(BeFalse()) - }) - - It("returns false when user has no libraries assigned", func() { - user.Libraries = nil - Expect(user.HasLibraryAccess(1)).To(BeFalse()) - }) - - It("handles duplicate library IDs correctly", func() { - user.Libraries = model.Libraries{ - {ID: 1, Name: "Library 1", Path: "/music1"}, - {ID: 1, Name: "Library 1 Duplicate", Path: "/music1-dup"}, - {ID: 2, Name: "Library 2", Path: "/music2"}, - } - Expect(user.HasLibraryAccess(1)).To(BeTrue()) - Expect(user.HasLibraryAccess(2)).To(BeTrue()) - Expect(user.HasLibraryAccess(3)).To(BeFalse()) - }) - }) - }) -}) diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6a940a320..08e7e17aa 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -2,165 +2,64 @@ package persistence import ( "context" - "encoding/json" - "errors" "fmt" - "iter" - "maps" - "slices" "strings" - "sync" - "time" . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "github.com/navidrome/navidrome/utils/str" - "github.com/pocketbase/dbx" ) type albumRepository struct { sqlRepository + sqlRestful } -type dbAlbum struct { - *model.Album `structs:",flatten"` - Discs string `structs:"-" json:"discs"` - Participants string `structs:"-" json:"-"` - Tags string `structs:"-" json:"-"` - FolderIDs string `structs:"-" json:"-"` - // dbx maps columns to fields by name; RGAlbumGain doesn't convert to - // rg_album_gain, so shim fields carry the read and PostScan copies them over. - RgAlbumGain *float64 `structs:"-" json:"-"` - RgAlbumPeak *float64 `structs:"-" json:"-"` -} - -func (a *dbAlbum) PostScan() error { - var err error - if a.Discs != "" { - if err = json.Unmarshal([]byte(a.Discs), &a.Album.Discs); err != nil { - return fmt.Errorf("parsing album discs from db: %w", err) - } - } - a.Album.Participants, err = unmarshalParticipants(a.Participants) - if err != nil { - return fmt.Errorf("parsing album from db: %w", err) - } - if a.Tags != "" { - a.Album.Tags, err = unmarshalTags(a.Tags) - if err != nil { - return fmt.Errorf("parsing album from db: %w", err) - } - a.Genre, a.Genres = a.Album.Tags.ToGenres() - } - if a.FolderIDs != "" { - var ids []string - if err = json.Unmarshal([]byte(a.FolderIDs), &ids); err != nil { - return fmt.Errorf("parsing album folder_ids from db: %w", err) - } - a.Album.FolderIDs = ids - } - a.Album.RGAlbumGain = a.RgAlbumGain - a.Album.RGAlbumPeak = a.RgAlbumPeak - return nil -} - -func (a *dbAlbum) PostMapArgs(args map[string]any) error { - fullText := []string{a.Name, a.SortAlbumName, a.AlbumArtist} - participantNames := a.Album.Participants.AllNames() - fullText = append(fullText, participantNames...) - fullText = append(fullText, slices.Collect(maps.Values(a.Album.Discs))...) - fullText = append(fullText, a.Album.Tags[model.TagAlbumVersion]...) - fullText = append(fullText, a.Album.Tags[model.TagCatalogNumber]...) - args["full_text"] = formatFullText(fullText...) - args["search_participants"] = strings.Join(participantNames, " ") - args["search_normalized"] = str.NormalizeForFTS(a.Name, a.AlbumArtist) - - args["tags"] = marshalTags(a.Album.Tags) - args["participants"] = marshalParticipants(a.Album.Participants) - - folderIDs, err := json.Marshal(a.Album.FolderIDs) - if err != nil { - return fmt.Errorf("marshalling album folder_ids: %w", err) - } - args["folder_ids"] = string(folderIDs) - - b, err := json.Marshal(a.Album.Discs) - if err != nil { - return fmt.Errorf("marshalling album discs: %w", err) - } - args["discs"] = string(b) - return nil -} - -type dbAlbums []dbAlbum - -func (as dbAlbums) toModels() model.Albums { - return slice.Map(as, func(a dbAlbum) model.Album { return *a.Album }) -} - -func NewAlbumRepository(db dbx.Builder) model.AlbumRepository { +func NewAlbumRepository(ctx context.Context, o orm.QueryExecutor) model.AlbumRepository { r := &albumRepository{} - r.db = db + r.ctx = ctx + r.ormer = o r.tableName = "album" - r.registerModel(&model.Album{}, albumFilters()) - r.setSortMappings(map[string]string{ - "name": "order_album_name, order_album_artist_name", - "artist": "compilation, order_album_artist_name, order_album_name", - "album_artist": "compilation, order_album_artist_name, order_album_name", - // TODO Rename this to just year (or date) - "max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, " + naturalSort("album.name"), - "random": "random", + r.sortMappings = map[string]string{ + "name": "order_album_name asc, order_album_artist_name asc", + "artist": "compilation asc, order_album_artist_name asc, order_album_name asc", + "random": "RANDOM()", + "max_year": "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, name, order_album_name asc", "recently_added": recentlyAddedSort(), - "starred_at": "starred, starred_at", - "rated_at": "rating, rated_at", - }) - return r -} - -var albumFilters = sync.OnceValue(func() map[string]filterFunc { - filters := map[string]filterFunc{ - "id": idFilter("album"), - "name": fullTextFilter("album", "mbz_album_id", "mbz_release_group_id"), + } + r.filterMappings = map[string]filterFunc{ + "id": idFilter(r.tableName), + "name": fullTextFilter, "compilation": booleanFilter, "artist_id": artistFilter, "year": yearFilter, "recently_played": recentlyPlayedFilter, - "starred": annotationBoolFilter("starred"), - "has_rating": annotationBoolFilter("rating"), - "played": annotationBoolFilter("play_count"), - "missing": booleanFilter, - "genre_id": genreFilter(AlbumGenres), - "role_total_id": allRolesFilter, - "library_id": libraryIdFilter, - } - // Add all album tags as filters - for tag := range model.AlbumLevelTags() { - filters[string(tag)] = tagIDFilter + "starred": booleanFilter, + "has_rating": hasRatingFilter, } - for role := range model.AllRoles { - filters["role_"+role+"_id"] = artistRoleFilter - } - - return filters -}) + return r +} func recentlyAddedSort() string { if conf.Server.RecentlyAddedByModTime { - return "album.updated_at, album.id" + return "updated_at" } - return "album.created_at, album.id" + return "created_at" } -func recentlyPlayedFilter(string, any) Sqlizer { +func recentlyPlayedFilter(field string, value interface{}) Sqlizer { return Gt{"play_count": 0} } -func yearFilter(_ string, value any) Sqlizer { +func hasRatingFilter(field string, value interface{}) Sqlizer { + return Gt{"rating": 0} +} + +func yearFilter(field string, value interface{}) Sqlizer { return Or{ And{ Gt{"min_year": 0}, @@ -171,310 +70,112 @@ func yearFilter(_ string, value any) Sqlizer { } } -func artistFilter(_ string, value any) Sqlizer { - return ParticipantIDFilter("album", value, model.RoleAlbumArtist, model.RoleArtist) +func artistFilter(field string, value interface{}) Sqlizer { + return Like{"all_artist_ids": fmt.Sprintf("%%%s%%", value)} } -func artistRoleFilter(name string, value any) Sqlizer { - roleName := strings.TrimSuffix(strings.TrimPrefix(name, "role_"), "_id") +func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) { + sql := r.newSelectWithAnnotation("album.id") + sql = r.withGenres(sql) + return r.count(sql, options...) +} - // Check if the role name is valid. If not, return an invalid filter - role, ok := model.AllRoles[roleName] - if !ok { - return Gt{"": nil} +func (r *albumRepository) Exists(id string) (bool, error) { + return r.exists(Select().Where(Eq{"album.id": id})) +} + +func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder { + sql := r.newSelectWithAnnotation("album.id", options...).Columns("album.*") + if len(options) > 0 && options[0].Filters != nil { + s, _, _ := options[0].Filters.ToSql() + // If there's any reference of genre in the filter, joins with genre + if strings.Contains(s, "genre") { + sql = r.withGenres(sql) + // If there's no filter on genre_id, group the results by media_file.id + if !strings.Contains(s, "genre_id") { + sql = sql.GroupBy("album.id") + } + } } - return ParticipantIDFilter("album", value, role) + return sql } -func allRolesFilter(_ string, value any) Sqlizer { - return ParticipantIDFilter("album", value) -} - -func (r *albumRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - query := r.newSelect(ctx) - query = r.applyLibraryFilter(ctx, query) - if filtersNeedAnnotation(r.applyFilters(query, options...)) { - query = r.withAnnotation(ctx, query, "album.id") - } - return r.count(ctx, query, options...) -} - -func (r *albumRepository) Exists(ctx context.Context, id string) (bool, error) { - // The exists() helper applies no library filter, so it would report rows the caller cannot see. - c, err := r.count(ctx, r.applyLibraryFilter(ctx, r.newSelect(ctx).Where(Eq{"album.id": id}))) - return c > 0, err -} - -func (r *albumRepository) Put(ctx context.Context, al *model.Album) error { - al.ImportedAt = time.Now() - id, err := r.put(ctx, al.ID, &dbAlbum{Album: al}) - if err != nil { - return err - } - al.ID = id - if err := r.updateParticipants(ctx, al.ID, al.Participants); err != nil { - return err - } - return r.updateTags(ctx, al.ID, al.Tags) -} - -// TODO Move external metadata to a separated table -func (r *albumRepository) UpdateExternalInfo(ctx context.Context, al *model.Album) error { - _, err := r.put(ctx, al.ID, &dbAlbum{Album: al}, "description", "small_image_url", "medium_image_url", "large_image_url", "external_url", "external_info_updated_at") - return err -} - -func (r *albumRepository) selectAlbum(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - sql := r.newSelect(ctx, options...).Columns("album.*", "library.path as library_path", "library.name as library_name"). - LeftJoin("library on album.library_id = library.id") - sql = r.withAnnotation(ctx, sql, "album.id") - return r.applyLibraryFilter(ctx, sql) -} - -func (r *albumRepository) Get(ctx context.Context, id string) (*model.Album, error) { - res, err := r.GetAll(ctx, model.QueryOptions{Filters: Eq{"album.id": id}}) - if err != nil { +func (r *albumRepository) Get(id string) (*model.Album, error) { + sq := r.selectAlbum().Where(Eq{"album.id": id}) + var res model.Albums + if err := r.queryAll(sq, &res); err != nil { return nil, err } if len(res) == 0 { return nil, model.ErrNotFound } - return &res[0], nil + err := r.loadAlbumGenres(&res) + return &res[0], err } -func (r *albumRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Albums, error) { - sq := r.selectAlbum(ctx, options...) - var res dbAlbums - err := r.queryAll(ctx, sq, &res) +func (r *albumRepository) Put(m *model.Album) error { + _, err := r.put(m.ID, m) + if err != nil { + return err + } + return r.updateGenres(m.ID, r.tableName, m.Genres) +} + +func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, error) { + res, err := r.GetAllWithoutGenres(options...) if err != nil { return nil, err } - albums := res.toModels() - r.hydrateArtwork(ctx, albums) - return albums, nil + err = r.loadAlbumGenres(&res) + return res, err } -func (r *albumRepository) hydrateArtwork(ctx context.Context, albums model.Albums) { - hydrateItems(ctx, r.db, model.KindAlbumArtwork, albums, - func(a *model.Album) (string, *model.ItemImage) { return a.ID, &a.ItemImage }) -} - -// getAllIDs returns the IDs of GetAll's row set, skipping its column projection and JSON decoding. -func (r *albumRepository) getAllIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { - sq := r.applyLibraryFilter(ctx, r.newSelect(ctx, options...).Columns("album.id")) - if filtersNeedAnnotation(sq) { - sq = r.withAnnotation(ctx, sq, "album.id") +func (r *albumRepository) GetAllWithoutGenres(options ...model.QueryOptions) (model.Albums, error) { + sq := r.selectAlbum(options...) + res := model.Albums{} + err := r.queryAll(sq, &res) + if err != nil { + return nil, err } - ids := []string{} - err := r.queryAllSlice(ctx, sq, &ids) - return ids, err + return res, err } -// soleAlbumArtistFilter matches albums with exactly one album artist. The artist artwork -// resolver and the scanner's image-change enqueue must select the same albums. -var soleAlbumArtistFilter = Eq{"json_array_length(participants, '$.albumartist')": 1} - -// SoleAlbumArtistFilter matches the albums where the given artist is the only album artist. -// Matches by album-artist participation, not the deprecated album_artist_id column. -func SoleAlbumArtistFilter(artistID string) Sqlizer { - return And{ParticipantIDFilter("album", artistID, model.RoleAlbumArtist), soleAlbumArtistFilter} -} - -// GetSoleAlbumArtistIDsInSubtrees matches albums by their own folder_ids, which is the resolver's -// notion of an album's folders. -func (r *albumRepository) GetSoleAlbumArtistIDsInSubtrees(ctx context.Context, lib model.Library, paths ...string) ([]string, error) { - if len(paths) == 0 { - return nil, nil - } - ids := []string{} - // Repeated IDs across chunks are fine: the queue upserts by PK. - for chunk := range slices.Chunk(paths, subtreePathChunkSize) { - inSubtree := Exists("json_each(album.folder_ids) je join folder on folder.id = je.value", - folderSubtreeFilter(lib, chunk)) - // Sole album artist, so participants[0] is the only one. - sq := Select("distinct json_extract(participants, '$.albumartist[0].id')").From("album"). - Where(And{soleAlbumArtistFilter, inSubtree}) - var chunkIDs []string - if err := r.queryAllSlice(ctx, sq, &chunkIDs); err != nil { - return nil, err +func (r *albumRepository) purgeEmpty() error { + del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)") + c, err := r.executeSQL(del) + if err == nil { + if c > 0 { + log.Debug(r.ctx, "Purged empty albums", "totalDeleted", c) } - ids = append(ids, chunkIDs...) } - return ids, nil -} - -func (r *albumRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.AlbumCursor, error) { - ids, err := r.getAllIDs(ctx, options...) - if err != nil { - return nil, err - } - opts := chunkOptions(options, "album.id") - return model.AlbumCursor(streamByIDs(ids, func(chunk []string) (model.Albums, error) { - return r.GetAll(ctx, opts(chunk)) - })), nil -} - -func (r *albumRepository) GetYears(ctx context.Context, libraryIDs ...int) ([]int, error) { - cond := And{Gt{"max_year": 0}, Eq{"missing": false}} - if len(libraryIDs) > 0 { - cond = append(cond, Eq{"library_id": libraryIDs}) - } - sq := r.applyLibraryFilter(ctx, Select("distinct max_year").From("album").Where(cond).OrderBy("max_year")) - years := []int{} - err := r.queryAllSlice(ctx, sq, &years) - if err != nil && !errors.Is(err, model.ErrNotFound) { - return nil, err - } - return years, nil -} - -func (r *albumRepository) CopyAttributes(ctx context.Context, fromID, toID string, columns ...string) error { - // Cast values to text so go-sqlite3 does not decode datetime columns as time.Time - // and reformat them as RFC3339 when written back. - sel := slice.Map(columns, func(c string) string { return fmt.Sprintf("cast(%[1]s as text) as %[1]s", c) }) - var from dbx.NullStringMap - err := r.queryOne(ctx, Select(sel...).From(r.tableName).Where(Eq{"id": fromID}), &from) - if err != nil { - return fmt.Errorf("getting album to copy fields from: %w", err) - } - to := make(map[string]any) - for _, col := range columns { - 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(ctx, Update(r.tableName).SetMap(to).Where(Eq{"id": toID})) return err } -// Touch flags an album as being scanned by the scanner, but not necessarily updated. -// This is used for when missing tracks are detected for an album during scan. -func (r *albumRepository) Touch(ctx context.Context, ids ...string) error { - if len(ids) == 0 { - return nil - } - for ids := range slices.Chunk(ids, 200) { - upd := Update(r.tableName).Set("imported_at", time.Now()).Where(Eq{"id": ids}) - c, err := r.executeSQL(ctx, upd) - if err != nil { - return fmt.Errorf("error touching albums: %w", err) - } - log.Debug(ctx, "Touching albums", "ids", ids, "updated", c) - } - return nil +func (r *albumRepository) Search(q string, offset int, size int) (model.Albums, error) { + results := model.Albums{} + err := r.doSearch(q, offset, size, &results, "name") + return results, err } -// TouchByMissingFolder touches all albums that have missing folders -func (r *albumRepository) TouchByMissingFolder(ctx context.Context) (int64, error) { - upd := Update(r.tableName).Set("imported_at", time.Now()). - Where(And{ - NotEq{"folder_ids": nil}, - ConcatExpr("EXISTS (SELECT 1 FROM json_each(folder_ids) AS je JOIN main.folder AS f ON je.value = f.id WHERE f.missing = true)"), - }) - c, err := r.executeSQL(ctx, upd) - if err != nil { - return 0, fmt.Errorf("error touching albums by missing folder: %w", err) - } - return c, nil +func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(options...)) } -// GetTouchedAlbums returns all albums that were touched by the scanner for a given library, in the -// current library scan run. -// It does not need to load participants, as they are not used by the scanner. -func (r *albumRepository) GetTouchedAlbums(ctx context.Context, libID int) (model.AlbumCursor, error) { - query := r.selectAlbum(ctx). - Where(And{ - Eq{"library.id": libID}, - ConcatExpr("album.imported_at > library.last_scan_at"), - }) - cursor, err := queryWithStableResults[dbAlbum](ctx, r.sqlRepository, query) - if err != nil { - return nil, err - } - return wrapAlbumCursor(cursor), nil +func (r *albumRepository) Read(id string) (interface{}, error) { + return r.Get(id) } -func wrapAlbumCursor(cursor iter.Seq2[dbAlbum, error]) model.AlbumCursor { - return model.AlbumCursor(wrapCursor(cursor, func(a dbAlbum) *model.Album { return a.Album })) +func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return r.GetAll(r.parseRestOptions(options...)) } -// RefreshPlayCounts updates the play count and last play date annotations for all albums, based -// on the media files associated with them. -func (r *albumRepository) RefreshPlayCounts(ctx context.Context) (int64, error) { - query := Expr(` -with play_counts as ( - select user_id, album_id, sum(play_count) as total_play_count, max(play_date) as last_play_date - from media_file - join annotation on item_id = media_file.id - group by user_id, album_id -) -insert into annotation (user_id, item_id, item_type, play_count, play_date) -select user_id, album_id, 'album', total_play_count, last_play_date -from play_counts -where total_play_count > 0 -on conflict (user_id, item_id, item_type) do update - set play_count = excluded.play_count, - play_date = excluded.play_date; -`) - return r.executeSQL(ctx, query) +func (r *albumRepository) EntityName() string { + return "album" } -func (r *albumRepository) purgeEmpty(ctx context.Context, libraryIDs ...int) error { - del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)") - // If libraryIDs are specified, only purge albums from those libraries - if len(libraryIDs) > 0 { - del = del.Where(Eq{"library_id": libraryIDs}) - } - c, err := r.executeSQL(ctx, del) - if err != nil { - return fmt.Errorf("purging empty albums: %w", err) - } - if c > 0 { - log.Debug(ctx, "Purged empty albums", "totalDeleted", c) - } - return nil -} - -var albumSearchConfig = searchConfig{ - NaturalOrder: "album.rowid", - OrderBy: []string{"name"}, - MBIDFields: []string{"mbz_album_id", "mbz_release_group_id"}, -} - -func (r *albumRepository) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.Albums, error) { - var opts model.QueryOptions - if len(options) > 0 { - opts = options[0] - } - var res dbAlbums - err := r.doSearch(ctx, r.selectAlbum(ctx, options...), q, &res, albumSearchConfig, opts) - if err != nil { - return nil, fmt.Errorf("searching album %q: %w", q, err) - } - albums := res.toModels() - r.hydrateArtwork(ctx, albums) - return albums, nil -} - -func (r *albumRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) -} - -func (r *albumRepository) Read(ctx context.Context, id string) (*model.Album, error) { - return r.Get(ctx, id) -} - -func (r *albumRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Album, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *albumRepository) NewInstance() interface{} { + return &model.Album{} } var _ model.AlbumRepository = (*albumRepository)(nil) -var _ rest.Repository[model.Album] = (*albumRepository)(nil) +var _ model.ResourceRepository = (*albumRepository)(nil) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go index 0235805c0..e7b9fafc6 100644 --- a/persistence/album_repository_test.go +++ b/persistence/album_repository_test.go @@ -2,1320 +2,58 @@ package persistence import ( "context" - "errors" - "fmt" - "sort" - "sync" - "time" - "github.com/Masterminds/squirrel" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" + "github.com/beego/beego/v2/client/orm" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -// rawColumn returns a column exactly as stored, bypassing go-sqlite3's decoding of -// `datetime` columns into time.Time. -func rawColumn(ctx context.Context, r sqlRepository, id, column string) string { - var res struct{ Value string } - sel := squirrel.Select("cast(" + column + " as text) as value"). - From(r.tableName).Where(squirrel.Eq{"id": id}) - ExpectWithOffset(1, r.queryOne(ctx, sel, &res)).To(Succeed()) - return res.Value -} - var _ = Describe("AlbumRepository", func() { - var albumRepo *albumRepository - var ctx context.Context + var repo model.AlbumRepository BeforeEach(func() { - ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) - albumRepo = NewAlbumRepository(GetDBXBuilder()).(*albumRepository) - }) - - Describe("natural sorting", func() { - var ids []string - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ids = nil - for _, n := range []string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"} { - aid := "nat-" + n - ids = append(ids, aid) - Expect(albumRepo.Put(ctx, &model.Album{ - ID: aid, LibraryID: 1, Name: n, OrderAlbumName: n, - })).To(Succeed()) - } - DeferCleanup(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": ids})) - }) - }) - - DescribeTable("sorts albums by name", - func(naturalSorting, preferSortTags bool, expected []string) { - conf.Server.EnableNaturalSorting = naturalSorting - conf.Server.PreferSortTags = preferSortTags - albumRepo = NewAlbumRepository(GetDBXBuilder()).(*albumRepository) - albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ - Sort: "name", Filters: squirrel.Eq{"album.id": ids}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(slice.Map(albums, func(a model.Album) string { return a.Name })).To(Equal(expected)) - }, - Entry("lexicographically by default", false, false, - []string{"foo 1", "foo 10", "foo 2", "foo 20", "foo 3"}), - Entry("by number value when natural sorting is enabled", true, false, - []string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}), - Entry("by number value with sort tags preferred too", true, true, - []string{"foo 1", "foo 2", "foo 3", "foo 10", "foo 20"}), - ) + ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe"}) + repo = NewAlbumRepository(ctx, orm.NewOrm()) }) Describe("Get", func() { - var Get = func(id string) (*model.Album, error) { - album, err := albumRepo.Get(ctx, id) - if album != nil { - album.ImportedAt = time.Time{} - } - return album, err - } It("returns an existent album", func() { - Expect(Get("103")).To(Equal(&albumRadioactivity)) + Expect(repo.Get("103")).To(Equal(&albumRadioactivity)) }) It("returns ErrNotFound when the album does not exist", func() { - _, err := Get("666") + _, err := repo.Get("666") Expect(err).To(MatchError(model.ErrNotFound)) }) }) - 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(ctx, &model.Album{ID: "copy-src", Name: "src", LibraryID: 1, CreatedAt: srcTime})).To(Succeed()) - Expect(albumRepo.Put(ctx, &model.Album{ID: "copy-dst", Name: "dst", LibraryID: 1, CreatedAt: dstTime})).To(Succeed()) - Expect(albumRepo.Put(ctx, &model.Album{ID: "copy-zero", Name: "zero", LibraryID: 1})).To(Succeed()) - DeferCleanup(func() { - _, _ = albumRepo.executeSQL(ctx, 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(ctx, "copy-src", "copy-dst", "created_at")).To(Succeed()) - got, err := albumRepo.Get(ctx, "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(ctx, "copy-zero", "copy-dst", "created_at")).To(Succeed()) - got, err := albumRepo.Get(ctx, "copy-dst") - Expect(err).ToNot(HaveOccurred()) - Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second)) - }) - It("returns not found and leaves destination untouched when source does not exist", func() { - err := albumRepo.CopyAttributes(ctx, "copy-missing", "copy-dst", "created_at") - Expect(errors.Is(err, model.ErrNotFound)).To(BeTrue()) - got, getErr := albumRepo.Get(ctx, "copy-dst") - Expect(getErr).ToNot(HaveOccurred()) - Expect(got.CreatedAt).To(BeTemporally("~", dstTime, time.Second)) - }) - It("keeps the copied created_at in the driver's space-separated format", func() { - // Copying through a Go string would rewrite it as RFC3339 ("2020-01-02T03:04:05Z"), - // which string-sorts above every space-format timestamp and pins the album to the - // top of "Recently Added". - Expect(albumRepo.CopyAttributes(ctx, "copy-src", "copy-dst", "created_at")).To(Succeed()) - Expect(rawColumn(ctx, albumRepo.sqlRepository, "copy-dst", "created_at")). - To(Equal(rawColumn(ctx, albumRepo.sqlRepository, "copy-src", "created_at"))) - Expect(rawColumn(ctx, albumRepo.sqlRepository, "copy-dst", "created_at")).ToNot(ContainSubstring("T")) - }) - }) - - Describe("GetCursor", func() { - It("yields the same albums as GetAll", func() { - opts := model.QueryOptions{Sort: "name"} - want, err := albumRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(albumRepo.GetCursor(ctx, opts))).To(Equal([]model.Album(want))) - }) - - It("honors Max/Offset like GetAll", func() { - opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1} - want, err := albumRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(albumRepo.GetCursor(ctx, opts))).To(Equal([]model.Album(want))) - }) - }) - - Describe("getAllIDs", func() { - It("returns the same id set as GetAll", func() { - want, err := albumRepo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - ids, err := albumRepo.getAllIDs(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) - }) - }) - - Describe("GetSoleAlbumArtistIDsInSubtrees", func() { - It("returns the sole album artists of albums with folders in the subtree", func() { - folderRepo := newFolderRepository(GetDBXBuilder()) - lib, err := NewLibraryRepository(GetDBXBuilder()).Get(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - inTree := model.NewFolder(*lib, "SubtreeAlbums/Artist") - outTree := model.NewFolder(*lib, "OtherTree/Artist") - Expect(folderRepo.Put(ctx, inTree)).To(Succeed()) - Expect(folderRepo.Put(ctx, outTree)).To(Succeed()) - - // album_artist_id is deliberately wrong: the artist must come from participants - inAl := model.Album{ID: "subtree-in-al", Name: "In", LibraryID: 1, AlbumArtistID: "999", FolderIDs: []string{inTree.ID}, - Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}}}} - outAl := model.Album{ID: "subtree-out-al", Name: "Out", LibraryID: 1, AlbumArtistID: "3", FolderIDs: []string{outTree.ID}, - Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistBeatles}}}} - duoAl := model.Album{ID: "subtree-duo-al", Name: "Duo", LibraryID: 1, AlbumArtistID: "5", FolderIDs: []string{inTree.ID}, - Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistPunctuation}, {Artist: artistBeatles}}}} - for _, al := range []model.Album{inAl, outAl, duoAl} { - Expect(albumRepo.Put(ctx, &al)).To(Succeed()) - } - DeferCleanup(func() { - _, _ = GetDBXBuilder().NewQuery("DELETE FROM album WHERE id LIKE 'subtree-%'").Execute() - _, _ = GetDBXBuilder().NewQuery("DELETE FROM folder WHERE path LIKE 'SubtreeAlbums%' OR path LIKE 'OtherTree%'").Execute() - }) - - ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(ctx, *lib, "SubtreeAlbums") - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(ConsistOf("2")) // sole artist in the subtree; the duo and the outside album are excluded - }) - - It("stays under SQLite's expression tree depth limit with many paths", func() { - lib, err := NewLibraryRepository(GetDBXBuilder()).Get(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - paths := make([]string, 200) - for i := range paths { - paths[i] = fmt.Sprintf("DepthProbe/Folder%d", i) - } - - _, err = albumRepo.GetSoleAlbumArtistIDsInSubtrees(ctx, *lib, paths...) - Expect(err).ToNot(HaveOccurred()) - }) - - It("returns nothing when given no paths", func() { - lib, err := NewLibraryRepository(GetDBXBuilder()).Get(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - ids, err := albumRepo.GetSoleAlbumArtistIDsInSubtrees(ctx, *lib) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(BeEmpty()) - }) - }) - - Describe("SoleAlbumArtistFilter", func() { - It("matches only albums where the artist is the sole album artist", func() { - // album_artist_id is deliberately wrong: matching must come from participation - sole := model.Album{ID: "sole-artist-al", Name: "Sole", LibraryID: 1, AlbumArtistID: "999", - Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}}}} - duo := model.Album{ID: "duo-artist-al", Name: "Duo", LibraryID: 1, AlbumArtistID: "999", - Participants: model.Participants{model.RoleAlbumArtist: []model.Participant{{Artist: artistKraftwerk}, {Artist: artistBeatles}}}} - Expect(albumRepo.Put(ctx, &sole)).To(Succeed()) - Expect(albumRepo.Put(ctx, &duo)).To(Succeed()) - DeferCleanup(func() { - _, _ = GetDBXBuilder().NewQuery("DELETE FROM album WHERE id IN ('sole-artist-al', 'duo-artist-al')").Execute() - }) - - als, err := albumRepo.GetAll(ctx, model.QueryOptions{Filters: SoleAlbumArtistFilter("2")}) - Expect(err).ToNot(HaveOccurred()) - Expect(als).To(HaveLen(1)) - Expect(als[0].ID).To(Equal(sole.ID)) - }) - }) - Describe("GetAll", func() { - var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { - albums, err := albumRepo.GetAll(ctx, opts...) - for i := range albums { - albums[i].ImportedAt = time.Time{} - } - return albums, err - } - It("returns all records", func() { - Expect(GetAll()).To(Equal(testAlbums)) + Expect(repo.GetAll()).To(Equal(testAlbums)) }) It("returns all records sorted", func() { - Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{ + Expect(repo.GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{ albumAbbeyRoad, - albumWithVersion, - albumCJK, - albumMultiDisc, albumRadioactivity, albumSgtPeppers, - albumPunctuation, })) }) It("returns all records sorted desc", func() { - Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{ - albumPunctuation, + Expect(repo.GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{ albumSgtPeppers, albumRadioactivity, - albumMultiDisc, - albumCJK, - albumWithVersion, albumAbbeyRoad, })) }) It("paginates the result", func() { - Expect(GetAll(model.QueryOptions{Offset: 1, Max: 1})).To(Equal(model.Albums{ + Expect(repo.GetAll(model.QueryOptions{Offset: 1, Max: 1})).To(Equal(model.Albums{ albumAbbeyRoad, })) }) }) - - Describe("recently_added sort", func() { - AfterEach(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album"). - Where(squirrel.Like{"id": "ra-%"})) - }) - - // Sub-second precision must survive, and ties must break deterministically - // so the order is independent of any filter (issue #5673). - indexOf := func(albums model.Albums, id string) int { - for i, a := range albums { - if a.ID == id { - return i - } - } - return -1 - } - - It("orders by sub-second precision, not truncated to the second", func() { - // Same second, different nanoseconds: datetime() would tie these. - earlier := &model.Album{LibraryID: 1, ID: "ra-earlier", Name: "Earlier"} - later := &model.Album{LibraryID: 1, ID: "ra-later", Name: "Later"} - Expect(albumRepo.Put(ctx, earlier)).To(Succeed()) - Expect(albumRepo.Put(ctx, later)).To(Succeed()) - _, err := albumRepo.executeSQL(ctx, squirrel.Update("album"). - Set("created_at", "2024-01-15 10:00:00.100000000+00:00"). - Where(squirrel.Eq{"id": "ra-earlier"})) - Expect(err).ToNot(HaveOccurred()) - _, err = albumRepo.executeSQL(ctx, squirrel.Update("album"). - Set("created_at", "2024-01-15 10:00:00.900000000+00:00"). - Where(squirrel.Eq{"id": "ra-later"})) - Expect(err).ToNot(HaveOccurred()) - - albums, err := albumRepo.GetAll(ctx, model.QueryOptions{Sort: "recently_added", Order: "desc"}) - Expect(err).ToNot(HaveOccurred()) - Expect(indexOf(albums, "ra-later")).To(BeNumerically("<", indexOf(albums, "ra-earlier")), - ".900 should sort before .100 in desc order") - }) - - It("breaks ties deterministically and consistently across filters", func() { - // All sharing one created_at: the relative order of any subset must - // match the unfiltered order (the inversion mechanism in #5673). - ids := []string{"ra-t1", "ra-t2", "ra-t3", "ra-t4"} - for _, aid := range ids { - Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: aid, Name: aid})).To(Succeed()) - } - _, err := albumRepo.executeSQL(ctx, squirrel.Update("album"). - Set("created_at", "2024-02-20 12:00:00+00:00"). - Where(squirrel.Eq{"id": ids})) - Expect(err).ToNot(HaveOccurred()) - - all, err := albumRepo.GetAll(ctx, model.QueryOptions{Sort: "recently_added", Order: "desc"}) - Expect(err).ToNot(HaveOccurred()) - - subset, err := albumRepo.GetAll(ctx, model.QueryOptions{ - Sort: "recently_added", Order: "desc", - Filters: squirrel.Eq{"album.id": []string{"ra-t1", "ra-t3"}}}) - Expect(err).ToNot(HaveOccurred()) - - Expect(indexOf(all, "ra-t1") < indexOf(all, "ra-t3")). - To(Equal(indexOf(subset, "ra-t1") < indexOf(subset, "ra-t3")), - "tied albums must keep the same relative order with and without a filter") - }) - }) - - Context("Filters", func() { - var albumWithoutAnnotation model.Album - - BeforeEach(func() { - // Create album without any annotation (no star, no rating) - albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1} - Expect(albumRepo.Put(ctx, &albumWithoutAnnotation)).To(Succeed()) - }) - - AfterEach(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID})) - }) - - Describe("starred", func() { - It("false includes items without annotations", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"starred": "false"}, - }) - Expect(err).ToNot(HaveOccurred()) - albums := res - - var found bool - for _, a := range albums { - if a.ID == albumWithoutAnnotation.ID { - found = true - break - } - } - Expect(found).To(BeTrue(), "Album without annotation should be included in starred=false filter") - }) - - It("true excludes items without annotations", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"starred": "true"}, - }) - Expect(err).ToNot(HaveOccurred()) - albums := res - - for _, a := range albums { - Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID)) - } - }) - }) - - Describe("has_rating", func() { - It("false includes items without annotations", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"has_rating": "false"}, - }) - Expect(err).ToNot(HaveOccurred()) - albums := res - - var found bool - for _, a := range albums { - if a.ID == albumWithoutAnnotation.ID { - found = true - break - } - } - Expect(found).To(BeTrue(), "Album without annotation should be included in has_rating=false filter") - }) - - It("true excludes items without annotations", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"has_rating": "true"}, - }) - Expect(err).ToNot(HaveOccurred()) - albums := res - - for _, a := range albums { - Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID)) - } - }) - }) - - Describe("played", func() { - var playedAlbum model.Album - - BeforeEach(func() { - playedAlbum = model.Album{ID: "played-album", Name: "Played Album", LibraryID: 1, SongCount: 1} - Expect(albumRepo.Put(ctx, &playedAlbum)).To(Succeed()) - Expect(albumRepo.IncPlayCount(ctx, playedAlbum.ID, time.Now())).To(Succeed()) - }) - - AfterEach(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": playedAlbum.ID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": playedAlbum.ID})) - }) - - It("false includes items without annotations", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"played": "false"}, - }) - Expect(err).ToNot(HaveOccurred()) - albums := res - - var found bool - for _, a := range albums { - if a.ID == albumWithoutAnnotation.ID { - found = true - break - } - } - Expect(found).To(BeTrue(), "Album without annotation should be included in played=false filter") - }) - - It("true excludes items without annotations", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"played": "true"}, - }) - Expect(err).ToNot(HaveOccurred()) - albums := res - - for _, a := range albums { - Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID)) - } - }) - - It("true includes items with play count", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"played": "true"}, - }) - Expect(err).ToNot(HaveOccurred()) - albums := res - - var found bool - for _, a := range albums { - if a.ID == playedAlbum.ID { - found = true - Expect(a.PlayCount).To(BeNumerically(">", 0)) - break - } - } - Expect(found).To(BeTrue(), "Album with play count should be included in played=true filter") - }) - - It("false excludes items with play count", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"played": "false"}, - }) - Expect(err).ToNot(HaveOccurred()) - albums := res - - for _, a := range albums { - Expect(a.ID).ToNot(Equal(playedAlbum.ID)) - } - }) - }) - }) - - Describe("Album.PlayCount", func() { - // Implementation is in withAnnotation() method - DescribeTable("normalizes play count when AlbumPlayCountMode is absolute", - func(songCount, playCount, expected int) { - conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeAbsolute - - newID := id.NewRandom() - Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) - for range playCount { - Expect(albumRepo.IncPlayCount(ctx, newID, time.Now())).To(Succeed()) - } - - album, err := albumRepo.Get(ctx, newID) - Expect(err).ToNot(HaveOccurred()) - Expect(album.PlayCount).To(Equal(int64(expected))) - }, - Entry("1 song, 0 plays", 1, 0, 0), - Entry("1 song, 4 plays", 1, 4, 4), - Entry("3 songs, 6 plays", 3, 6, 6), - Entry("10 songs, 6 plays", 10, 6, 6), - Entry("70 songs, 70 plays", 70, 70, 70), - Entry("10 songs, 50 plays", 10, 50, 50), - Entry("120 songs, 121 plays", 120, 121, 121), - ) - - DescribeTable("normalizes play count when AlbumPlayCountMode is normalized", - func(songCount, playCount, expected int) { - conf.Server.AlbumPlayCountMode = consts.AlbumPlayCountModeNormalized - - newID := id.NewRandom() - Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) - for range playCount { - Expect(albumRepo.IncPlayCount(ctx, newID, time.Now())).To(Succeed()) - } - - album, err := albumRepo.Get(ctx, newID) - Expect(err).ToNot(HaveOccurred()) - Expect(album.PlayCount).To(Equal(int64(expected))) - }, - Entry("1 song, 0 plays", 1, 0, 0), - Entry("1 song, 4 plays", 1, 4, 4), - Entry("3 songs, 6 plays", 3, 6, 2), - Entry("10 songs, 6 plays", 10, 6, 1), - Entry("70 songs, 70 plays", 70, 70, 1), - Entry("10 songs, 50 plays", 10, 50, 5), - Entry("120 songs, 121 plays", 120, 121, 1), - ) - }) - - Describe("Album.AverageRating", func() { - It("returns 0 when no ratings exist", func() { - newID := id.NewRandom() - Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "no ratings album"})).To(Succeed()) - - album, err := albumRepo.Get(ctx, newID) - Expect(err).ToNot(HaveOccurred()) - Expect(album.AverageRating).To(Equal(0.0)) - - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) - }) - - It("returns the user's rating as average when only one user rated", func() { - newID := id.NewRandom() - Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "single rating album"})).To(Succeed()) - Expect(albumRepo.SetRating(ctx, 4, newID)).To(Succeed()) - - album, err := albumRepo.Get(ctx, newID) - Expect(err).ToNot(HaveOccurred()) - Expect(album.AverageRating).To(Equal(4.0)) - - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) - }) - - It("calculates average across multiple users", func() { - newID := id.NewRandom() - Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "multi rating album"})).To(Succeed()) - - Expect(albumRepo.SetRating(ctx, 4, newID)).To(Succeed()) - - user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) - Expect(albumRepo.SetRating(user2Ctx, 5, newID)).To(Succeed()) - - album, err := albumRepo.Get(ctx, newID) - Expect(err).ToNot(HaveOccurred()) - Expect(album.AverageRating).To(Equal(4.5)) - - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) - }) - - It("excludes zero ratings from average calculation", func() { - newID := id.NewRandom() - Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "zero rating excluded album"})).To(Succeed()) - Expect(albumRepo.SetRating(ctx, 3, newID)).To(Succeed()) - - user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) - Expect(albumRepo.SetRating(user2Ctx, 0, newID)).To(Succeed()) - - album, err := albumRepo.Get(ctx, newID) - Expect(err).ToNot(HaveOccurred()) - Expect(album.AverageRating).To(Equal(3.0)) - - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) - }) - - It("rounds to 2 decimal places", func() { - newID := id.NewRandom() - Expect(albumRepo.Put(ctx, &model.Album{LibraryID: 1, ID: newID, Name: "rounding test album"})).To(Succeed()) - - Expect(albumRepo.SetRating(ctx, 5, newID)).To(Succeed()) - - user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) - Expect(albumRepo.SetRating(user2Ctx, 4, newID)).To(Succeed()) - - user3Ctx := request.WithUser(GinkgoT().Context(), thirdUser) - Expect(albumRepo.SetRating(user3Ctx, 4, newID)).To(Succeed()) - - album, err := albumRepo.Get(ctx, newID) - Expect(err).ToNot(HaveOccurred()) - Expect(album.AverageRating).To(Equal(4.33)) // (5 + 4 + 4) / 3 = 4.333... - - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": newID})) - }) - }) - - Describe("dbAlbum mapping", func() { - var ( - a model.Album - dba *dbAlbum - args map[string]any - ) - - BeforeEach(func() { - a = al(model.Album{ID: "1", Name: "name"}) - dba = &dbAlbum{Album: &a, Participants: "{}"} - args = make(map[string]any) - }) - - Describe("PostScan", func() { - It("parses Discs correctly", func() { - dba.Discs = `{"1":"disc1","2":"disc2"}` - Expect(dba.PostScan()).To(Succeed()) - Expect(dba.Album.Discs).To(Equal(model.Discs{1: "disc1", 2: "disc2"})) - }) - - It("parses Participants correctly", func() { - dba.Participants = `{"composer":[{"id":"1","name":"Composer 1"}],` + - `"artist":[{"id":"2","name":"Artist 2"},{"id":"3","name":"Artist 3","subRole":"subRole"}]}` - Expect(dba.PostScan()).To(Succeed()) - Expect(dba.Album.Participants).To(HaveLen(2)) - Expect(dba.Album.Participants).To(HaveKeyWithValue( - model.RoleFromString("composer"), - model.ParticipantList{{Artist: model.Artist{ID: "1", Name: "Composer 1"}}}, - )) - Expect(dba.Album.Participants).To(HaveKeyWithValue( - model.RoleFromString("artist"), - model.ParticipantList{{Artist: model.Artist{ID: "2", Name: "Artist 2"}}, {Artist: model.Artist{ID: "3", Name: "Artist 3"}, SubRole: "subRole"}}, - )) - }) - - It("parses Tags correctly", func() { - dba.Tags = `{"genre":[{"id":"1","value":"rock"},{"id":"2","value":"pop"}],"mood":[{"id":"3","value":"happy"}]}` - Expect(dba.PostScan()).To(Succeed()) - Expect(dba.Album.Tags).To(HaveKeyWithValue( - model.TagName("mood"), []string{"happy"}, - )) - Expect(dba.Album.Tags).To(HaveKeyWithValue( - model.TagName("genre"), []string{"rock", "pop"}, - )) - Expect(dba.Album.Genre).To(Equal("rock")) - Expect(dba.Album.Genres).To(HaveLen(2)) - }) - - It("parses Paths correctly", func() { - dba.FolderIDs = `["folder1","folder2"]` - Expect(dba.PostScan()).To(Succeed()) - Expect(dba.Album.FolderIDs).To(Equal([]string{"folder1", "folder2"})) - }) - }) - - Describe("PostMapArgs", func() { - It("maps full_text correctly", func() { - Expect(dba.PostMapArgs(args)).To(Succeed()) - Expect(args).To(HaveKeyWithValue("full_text", " name")) - }) - - It("maps tags correctly", func() { - dba.Album.Tags = model.Tags{"genre": {"rock", "pop"}, "mood": {"happy"}} - Expect(dba.PostMapArgs(args)).To(Succeed()) - Expect(args).To(HaveKeyWithValue("tags", - `{"genre":[{"id":"5qDZoz1FBC36K73YeoJ2lF","value":"rock"},{"id":"4H0KjnlS2ob9nKLL0zHOqB",`+ - `"value":"pop"}],"mood":[{"id":"1F4tmb516DIlHKFT1KzE1Z","value":"happy"}]}`, - )) - }) - - It("maps participants correctly", func() { - dba.Album.Participants = model.Participants{ - model.RoleAlbumArtist: model.ParticipantList{_p("AA1", "AlbumArtist1")}, - model.RoleComposer: model.ParticipantList{{Artist: model.Artist{ID: "C1", Name: "Composer1"}, SubRole: "composer"}}, - } - Expect(dba.PostMapArgs(args)).To(Succeed()) - Expect(args).To(HaveKeyWithValue( - "participants", - `{"albumartist":[{"id":"AA1","name":"AlbumArtist1"}],`+ - `"composer":[{"id":"C1","name":"Composer1","subRole":"composer"}]}`, - )) - }) - - It("maps discs correctly", func() { - dba.Album.Discs = model.Discs{1: "disc1", 2: "disc2"} - Expect(dba.PostMapArgs(args)).To(Succeed()) - Expect(args).To(HaveKeyWithValue("discs", `{"1":"disc1","2":"disc2"}`)) - }) - - It("maps paths correctly", func() { - dba.Album.FolderIDs = []string{"folder1", "folder2"} - Expect(dba.PostMapArgs(args)).To(Succeed()) - Expect(args).To(HaveKeyWithValue("folder_ids", `["folder1","folder2"]`)) - }) - }) - }) - - Describe("dbAlbums.toModels", func() { - It("converts dbAlbums to model.Albums", func() { - dba := dbAlbums{ - {Album: &model.Album{ID: "1", Name: "name", SongCount: 2, Annotations: model.Annotations{PlayCount: 4}}}, - {Album: &model.Album{ID: "2", Name: "name2", SongCount: 3, Annotations: model.Annotations{PlayCount: 6}}}, - } - albums := dba.toModels() - for i := range dba { - Expect(albums[i].ID).To(Equal(dba[i].Album.ID)) - Expect(albums[i].Name).To(Equal(dba[i].Album.Name)) - Expect(albums[i].SongCount).To(Equal(dba[i].Album.SongCount)) - Expect(albums[i].PlayCount).To(Equal(dba[i].Album.PlayCount)) - } - }) - }) - - Describe("artistRoleFilter", func() { - DescribeTable("creates correct SQL expressions for artist roles", - func(filterName, artistID, expectedRole string) { - sqlizer := artistRoleFilter(filterName, artistID) - sql, args, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal("album.id IN (SELECT album_id FROM album_artists WHERE artist_id = ? AND role IN (?))")) - Expect(args).To(Equal([]any{artistID, expectedRole})) - }, - Entry("artist role", "role_artist_id", "123", "artist"), - Entry("albumartist role", "role_albumartist_id", "456", "albumartist"), - Entry("composer role", "role_composer_id", "789", "composer"), - ) - - It("works with the actual filter map", func() { - filters := albumFilters() - - for roleName := range model.AllRoles { - filterName := "role_" + roleName + "_id" - filterFunc, exists := filters[filterName] - Expect(exists).To(BeTrue(), fmt.Sprintf("Filter %s should exist", filterName)) - - sqlizer := filterFunc(filterName, "test-id") - sql, args, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal("album.id IN (SELECT album_id FROM album_artists WHERE artist_id = ? AND role IN (?))")) - Expect(args).To(Equal([]any{"test-id", roleName})) - } - }) - - It("rejects invalid roles", func() { - sqlizer := artistRoleFilter("role_invalid_id", "123") - _, _, err := sqlizer.ToSql() - Expect(err).To(HaveOccurred()) - }) - - It("rejects invalid filter names", func() { - sqlizer := artistRoleFilter("invalid_name", "123") - _, _, err := sqlizer.ToSql() - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("Participant Foreign Key Handling", func() { - // albumArtistRecord represents a record in the album_artists table - type albumArtistRecord struct { - ArtistID string `db:"artist_id"` - Role string `db:"role"` - SubRole string `db:"sub_role"` - } - - var artistRepo *artistRepository - var artistCtx context.Context - - BeforeEach(func() { - artistCtx = request.WithUser(ctx, adminUser) - artistRepo = NewArtistRepository(GetDBXBuilder()).(*artistRepository) - }) - - // Helper to verify album_artists records - verifyAlbumArtists := func(albumID string, expected []albumArtistRecord) { - GinkgoHelper() - var actual []albumArtistRecord - sq := squirrel.Select("artist_id", "role", "sub_role"). - From("album_artists"). - Where(squirrel.Eq{"album_id": albumID}). - OrderBy("role", "artist_id", "sub_role") - - err := albumRepo.queryAll(ctx, sq, &actual) - Expect(err).ToNot(HaveOccurred()) - Expect(actual).To(Equal(expected)) - } - - It("verifies that participant records are actually inserted into database", func() { - // Create a real artist in the database first - artist := &model.Artist{ - ID: "real-artist-1", - Name: "Real Artist", - OrderArtistName: "real artist", - SortArtistName: "Artist, Real", - } - err := createArtistWithLibrary(artistCtx, artistRepo, artist, 1) - Expect(err).ToNot(HaveOccurred()) - - // Create an album with participants that reference the real artist - album := &model.Album{ - LibraryID: 1, - ID: "test-album-db-insert", - Name: "Test Album DB Insert", - AlbumArtistID: "real-artist-1", - AlbumArtist: "Real Artist", - Participants: model.Participants{ - model.RoleArtist: { - {Artist: model.Artist{ID: "real-artist-1", Name: "Real Artist"}}, - }, - model.RoleComposer: { - {Artist: model.Artist{ID: "real-artist-1", Name: "Real Artist"}, SubRole: "primary"}, - }, - }, - } - - // Insert the album - err = albumRepo.Put(ctx, album) - Expect(err).ToNot(HaveOccurred()) - - // Verify that participant records were actually inserted into album_artists table - expected := []albumArtistRecord{ - {ArtistID: "real-artist-1", Role: "artist", SubRole: ""}, - {ArtistID: "real-artist-1", Role: "composer", SubRole: "primary"}, - } - verifyAlbumArtists(album.ID, expected) - - // Clean up the test artist and album created for this test - _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) - }) - - It("finds albums through the participant-based filters", func() { - artist := &model.Artist{ID: "filter-artist-1", Name: "Filter Artist", OrderArtistName: "filter artist"} - Expect(createArtistWithLibrary(artistCtx, artistRepo, artist, 1)).To(Succeed()) - - album := &model.Album{ - LibraryID: 1, - ID: "filter-album-1", - Name: "Filter Album", - AlbumArtistID: artist.ID, - AlbumArtist: artist.Name, - Participants: model.Participants{ - model.RoleAlbumArtist: {{Artist: model.Artist{ID: artist.ID, Name: artist.Name}}}, - model.RoleComposer: {{Artist: model.Artist{ID: artist.ID, Name: artist.Name}}}, - }, - } - Expect(albumRepo.Put(ctx, album)).To(Succeed()) - - byArtist, err := albumRepo.GetAll(ctx, model.QueryOptions{Filters: artistFilter("artist_id", artist.ID)}) - Expect(err).ToNot(HaveOccurred()) - Expect(byArtist).To(HaveLen(1)) - Expect(byArtist[0].ID).To(Equal(album.ID)) - - byComposer, err := albumRepo.GetAll(ctx, model.QueryOptions{Filters: artistRoleFilter("role_composer_id", artist.ID)}) - Expect(err).ToNot(HaveOccurred()) - Expect(byComposer).To(HaveLen(1)) - - byLyricist, err := albumRepo.GetAll(ctx, model.QueryOptions{Filters: artistRoleFilter("role_lyricist_id", artist.ID)}) - Expect(err).ToNot(HaveOccurred()) - Expect(byLyricist).To(BeEmpty()) - - byAnyRole, err := albumRepo.GetAll(ctx, model.QueryOptions{Filters: allRolesFilter("role_total_id", artist.ID)}) - Expect(err).ToNot(HaveOccurred()) - Expect(byAnyRole).To(HaveLen(1)) - - count, err := albumRepo.CountAll(ctx, model.QueryOptions{Filters: artistFilter("artist_id", artist.ID)}) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(1))) - - _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) - }) - - It("clears album_artists rows when saved with empty participants", func() { - artist := &model.Artist{ID: "clear-artist-1", Name: "Clear Artist", OrderArtistName: "clear artist"} - Expect(createArtistWithLibrary(artistCtx, artistRepo, artist, 1)).To(Succeed()) - - album := &model.Album{ - LibraryID: 1, - ID: "clear-album-1", - Name: "Clear Album", - AlbumArtistID: artist.ID, - AlbumArtist: artist.Name, - Participants: model.Participants{ - model.RoleAlbumArtist: {{Artist: model.Artist{ID: artist.ID, Name: artist.Name}}}, - }, - } - DeferCleanup(func() { - _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) - }) - Expect(albumRepo.Put(ctx, album)).To(Succeed()) - verifyAlbumArtists(album.ID, []albumArtistRecord{{ArtistID: artist.ID, Role: "albumartist", SubRole: ""}}) - - album.Participants = model.Participants{} - Expect(albumRepo.Put(ctx, album)).To(Succeed()) - verifyAlbumArtists(album.ID, []albumArtistRecord{}) - }) - - It("filters out invalid artist IDs leaving only valid participants in database", func() { - // Create two real artists in the database - artist1 := &model.Artist{ - ID: "real-artist-mix-1", - Name: "Real Artist 1", - OrderArtistName: "real artist 1", - } - artist2 := &model.Artist{ - ID: "real-artist-mix-2", - Name: "Real Artist 2", - OrderArtistName: "real artist 2", - } - err := createArtistWithLibrary(artistCtx, artistRepo, artist1, 1) - Expect(err).ToNot(HaveOccurred()) - err = createArtistWithLibrary(artistCtx, artistRepo, artist2, 1) - Expect(err).ToNot(HaveOccurred()) - - // Create an album with mix of valid and invalid artist IDs - album := &model.Album{ - LibraryID: 1, - ID: "test-album-mixed-validity", - Name: "Test Album Mixed Validity", - AlbumArtistID: "real-artist-mix-1", - AlbumArtist: "Real Artist 1", - Participants: model.Participants{ - model.RoleArtist: { - {Artist: model.Artist{ID: "real-artist-mix-1", Name: "Real Artist 1"}}, - {Artist: model.Artist{ID: "non-existent-mix-1", Name: "Non Existent 1"}}, - {Artist: model.Artist{ID: "real-artist-mix-2", Name: "Real Artist 2"}}, - }, - model.RoleComposer: { - {Artist: model.Artist{ID: "non-existent-mix-2", Name: "Non Existent 2"}}, - {Artist: model.Artist{ID: "real-artist-mix-1", Name: "Real Artist 1"}}, - }, - }, - } - - // This should not fail - only valid artists should be inserted - err = albumRepo.Put(ctx, album) - Expect(err).ToNot(HaveOccurred()) - - // Verify that only valid artist IDs were inserted into album_artists table - // Non-existent artists should be filtered out by the INNER JOIN - expected := []albumArtistRecord{ - {ArtistID: "real-artist-mix-1", Role: "artist", SubRole: ""}, - {ArtistID: "real-artist-mix-2", Role: "artist", SubRole: ""}, - {ArtistID: "real-artist-mix-1", Role: "composer", SubRole: ""}, - } - verifyAlbumArtists(album.ID, expected) - - // Clean up the test artists and album created for this test - artistIDs := []string{artist1.ID, artist2.ID} - _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) - }) - - It("handles complex nested JSON with multiple roles and sub-roles", func() { - // Create 4 artists for this test - artists := []*model.Artist{ - {ID: "complex-artist-1", Name: "Lead Vocalist", OrderArtistName: "lead vocalist"}, - {ID: "complex-artist-2", Name: "Guitarist", OrderArtistName: "guitarist"}, - {ID: "complex-artist-3", Name: "Producer", OrderArtistName: "producer"}, - {ID: "complex-artist-4", Name: "Engineer", OrderArtistName: "engineer"}, - } - - for _, artist := range artists { - err := createArtistWithLibrary(artistCtx, artistRepo, artist, 1) - Expect(err).ToNot(HaveOccurred()) - } - - // Create album with complex participant structure - album := &model.Album{ - LibraryID: 1, - ID: "test-album-complex-json", - Name: "Test Album Complex JSON", - AlbumArtistID: "complex-artist-1", - AlbumArtist: "Lead Vocalist", - Participants: model.Participants{ - model.RoleArtist: { - {Artist: model.Artist{ID: "complex-artist-1", Name: "Lead Vocalist"}}, - {Artist: model.Artist{ID: "complex-artist-2", Name: "Guitarist"}, SubRole: "lead guitar"}, - {Artist: model.Artist{ID: "complex-artist-2", Name: "Guitarist"}, SubRole: "rhythm guitar"}, - }, - model.RoleProducer: { - {Artist: model.Artist{ID: "complex-artist-3", Name: "Producer"}, SubRole: "executive"}, - }, - model.RoleEngineer: { - {Artist: model.Artist{ID: "complex-artist-4", Name: "Engineer"}, SubRole: "mixing"}, - {Artist: model.Artist{ID: "complex-artist-4", Name: "Engineer"}, SubRole: "mastering"}, - }, - }, - } - - err := albumRepo.Put(ctx, album) - Expect(err).ToNot(HaveOccurred()) - - // Verify complex JSON structure was correctly parsed and inserted - expected := []albumArtistRecord{ - {ArtistID: "complex-artist-1", Role: "artist", SubRole: ""}, - {ArtistID: "complex-artist-2", Role: "artist", SubRole: "lead guitar"}, - {ArtistID: "complex-artist-2", Role: "artist", SubRole: "rhythm guitar"}, - {ArtistID: "complex-artist-4", Role: "engineer", SubRole: "mastering"}, - {ArtistID: "complex-artist-4", Role: "engineer", SubRole: "mixing"}, - {ArtistID: "complex-artist-3", Role: "producer", SubRole: "executive"}, - } - verifyAlbumArtists(album.ID, expected) - - // Clean up the test artists and album created for this test - artistIDs := make([]string, len(artists)) - for i, artist := range artists { - artistIDs[i] = artist.ID - } - _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) - }) - - It("handles albums with non-existent artist IDs without constraint errors", func() { - // Regression test for foreign key constraint error when album participants - // contain artist IDs that don't exist in the artist table - - // Create an album with participants that reference non-existent artist IDs - album := &model.Album{ - LibraryID: 1, - ID: "test-album-fk-constraints", - Name: "Test Album with Invalid Artist References", - AlbumArtistID: "non-existent-artist-1", - AlbumArtist: "Non Existent Album Artist", - Participants: model.Participants{ - model.RoleArtist: { - {Artist: model.Artist{ID: "non-existent-artist-1", Name: "Non Existent Artist 1"}}, - {Artist: model.Artist{ID: "non-existent-artist-2", Name: "Non Existent Artist 2"}}, - }, - model.RoleComposer: { - {Artist: model.Artist{ID: "non-existent-composer-1", Name: "Non Existent Composer 1"}}, - {Artist: model.Artist{ID: "non-existent-composer-2", Name: "Non Existent Composer 2"}}, - }, - model.RoleAlbumArtist: { - {Artist: model.Artist{ID: "non-existent-album-artist-1", Name: "Non Existent Album Artist 1"}}, - }, - }, - } - - // This should not fail with foreign key constraint error - // The updateParticipants method should handle non-existent artist IDs gracefully - err := albumRepo.Put(ctx, album) - Expect(err).ToNot(HaveOccurred()) - - // Verify that no participant records were inserted since all artist IDs were invalid - // The INNER JOIN with the artist table should filter out all non-existent artists - verifyAlbumArtists(album.ID, []albumArtistRecord{}) - - // Clean up the test album created for this test - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) - }) - - It("removes stale role associations when artist role changes", func() { - // Regression test for issue #4242: Composers displayed in albumartist list - // This happens when an artist's role changes (e.g., was both albumartist and composer, - // now only composer) and the old role association isn't properly removed. - - // Create an artist that will have changing roles - artist := &model.Artist{ - ID: "role-change-artist-1", - Name: "Role Change Artist", - OrderArtistName: "role change artist", - } - err := createArtistWithLibrary(artistCtx, artistRepo, artist, 1) - Expect(err).ToNot(HaveOccurred()) - - // Create album with artist as both albumartist and composer - album := &model.Album{ - LibraryID: 1, - ID: "test-album-role-change", - Name: "Test Album Role Change", - AlbumArtistID: "role-change-artist-1", - AlbumArtist: "Role Change Artist", - Participants: model.Participants{ - model.RoleAlbumArtist: { - {Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}}, - }, - model.RoleComposer: { - {Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}}, - }, - }, - } - - err = albumRepo.Put(ctx, album) - Expect(err).ToNot(HaveOccurred()) - - // Verify initial state: artist has both albumartist and composer roles - expected := []albumArtistRecord{ - {ArtistID: "role-change-artist-1", Role: "albumartist", SubRole: ""}, - {ArtistID: "role-change-artist-1", Role: "composer", SubRole: ""}, - } - verifyAlbumArtists(album.ID, expected) - - // Now update album so artist is ONLY a composer (remove albumartist role) - album.Participants = model.Participants{ - model.RoleComposer: { - {Artist: model.Artist{ID: "role-change-artist-1", Name: "Role Change Artist"}}, - }, - } - - err = albumRepo.Put(ctx, album) - Expect(err).ToNot(HaveOccurred()) - - // Verify that the albumartist role was removed - only composer should remain - // This is the key test: before the fix, the albumartist role would remain - // causing composers to appear in the albumartist filter - expectedAfter := []albumArtistRecord{ - {ArtistID: "role-change-artist-1", Role: "composer", SubRole: ""}, - } - verifyAlbumArtists(album.ID, expectedAfter) - - // Clean up - _, _ = artistRepo.executeSQL(artistCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) - }) - }) - - Describe("GetYears", func() { - It("returns distinct album years ascending, excluding zero", func() { - years, err := albumRepo.GetYears(ctx) - Expect(err).ToNot(HaveOccurred()) - // Sorted ascending, no duplicates, no zero-year entries. - Expect(sort.IsSorted(sort.IntSlice(years))).To(BeTrue()) - Expect(years).ToNot(ContainElement(0)) - for i := 1; i < len(years); i++ { - Expect(years[i]).To(BeNumerically(">", years[i-1])) // strictly increasing = distinct - } - }) - - It("deduplicates repeated years", func() { - // Regression test: verify that DISTINCT is applied in the SQL. - // Insert two albums with the same non-zero max_year (2005). - album1 := &model.Album{LibraryID: 1, ID: "dedup-test-1", Name: "Album 1", MaxYear: 2005} - album2 := &model.Album{LibraryID: 1, ID: "dedup-test-2", Name: "Album 2", MaxYear: 2005} - Expect(albumRepo.Put(ctx, album1)).To(Succeed()) - Expect(albumRepo.Put(ctx, album2)).To(Succeed()) - DeferCleanup(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"dedup-test-1", "dedup-test-2"}})) - }) - - years, err := albumRepo.GetYears(ctx) - Expect(err).ToNot(HaveOccurred()) - - // Count occurrences of 2005 in the result - count := 0 - for _, y := range years { - if y == 2005 { - count++ - } - } - Expect(count).To(Equal(1), "year 2005 should appear exactly once despite two albums having it") - }) - - It("scopes years to the given libraries", func() { - all, err := albumRepo.GetYears(ctx) - Expect(err).ToNot(HaveOccurred()) - // A library with no albums yields no years. - scoped, err := albumRepo.GetYears(ctx, 99999) - Expect(err).ToNot(HaveOccurred()) - Expect(scoped).To(BeEmpty()) - Expect(all).ToNot(BeEmpty()) - }) - - It("excludes years that belong only to missing albums", func() { - gone := &model.Album{LibraryID: 1, ID: "missing-year-1", Name: "Gone", MaxYear: 1911, Missing: true} - Expect(albumRepo.Put(ctx, gone)).To(Succeed()) - DeferCleanup(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": "missing-year-1"})) - }) - - years, err := albumRepo.GetYears(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(years).ToNot(ContainElement(1911)) - }) - }) - - Describe("wrapAlbumCursor", func() { - It("does not panic when the cursor yields a dbAlbum with nil Album", func() { - // Simulate what queryWithStableResults does on the rows.Err() path: - // it yields a zero-value dbAlbum (where Album is nil) with an error. - dbErr := fmt.Errorf("database is locked") - cursor := func(yield func(dbAlbum, error) bool) { - var empty dbAlbum // Album pointer is nil - yield(empty, dbErr) - } - - // wrapAlbumCursor should handle the nil Album without panicking - wrappedCursor := wrapAlbumCursor(cursor) - var gotErr error - Expect(func() { - for _, err := range wrappedCursor { - gotErr = err - } - }).ToNot(Panic()) - Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Album")) - Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") - }) - - It("yields albums from a valid cursor", func() { - album := &model.Album{ID: "a1", Name: "Test"} - cursor := func(yield func(dbAlbum, error) bool) { - yield(dbAlbum{Album: album}, nil) - } - - wrappedCursor := wrapAlbumCursor(cursor) - var albums []model.Album - for a, err := range wrappedCursor { - Expect(err).ToNot(HaveOccurred()) - albums = append(albums, a) - } - Expect(albums).To(HaveLen(1)) - Expect(albums[0].ID).To(Equal("a1")) - }) - }) - - Describe("ReplayGain", func() { - BeforeEach(func() { - DeferCleanup(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"rg-1", "rg-2"}})) - }) - }) - It("round-trips album ReplayGain gain and peak", func() { - Expect(albumRepo.Put(ctx, &model.Album{ - ID: "rg-1", Name: "rg", LibraryID: 1, - RGAlbumGain: new(-7.5), RGAlbumPeak: new(0.98), - })).To(Succeed()) - got, err := albumRepo.Get(ctx, "rg-1") - Expect(err).ToNot(HaveOccurred()) - Expect(got.RGAlbumGain).ToNot(BeNil()) - Expect(*got.RGAlbumGain).To(Equal(-7.5)) - Expect(got.RGAlbumPeak).ToNot(BeNil()) - Expect(*got.RGAlbumPeak).To(Equal(0.98)) - }) - It("reads nil when ReplayGain is unset", func() { - Expect(albumRepo.Put(ctx, &model.Album{ID: "rg-2", Name: "rg2", LibraryID: 1})).To(Succeed()) - got, err := albumRepo.Get(ctx, "rg-2") - Expect(err).ToNot(HaveOccurred()) - Expect(got.RGAlbumGain).To(BeNil()) - Expect(got.RGAlbumPeak).To(BeNil()) - }) - }) - - // Exists must apply the same library filter as Get/GetAll/CountAll. - Describe("Exists library visibility", func() { - It("hides an album the user has no library access to", func() { - Expect(albumRepo.Put(ctx, &model.Album{ID: "vis-album", Name: "Vis", LibraryID: 1})).To(Succeed()) - DeferCleanup(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": "vis-album"})) - }) - - Expect(albumRepo.Exists(ctx, "vis-album")).To(BeTrue(), "admin sees it") - - restricted := model.User{ID: "restricted_album_user", UserName: "ra", Name: "RA", Email: "ra@t.com"} - rctx := request.WithUser(GinkgoT().Context(), restricted) - Expect(albumRepo.Exists(rctx, "vis-album")).To(BeFalse()) - }) - - It("keeps per-user library visibility separate on a shared repository", func() { - adminCtx := request.WithUser(ctx, adminUser) - adminCount, err := albumRepo.CountAll(adminCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(adminCount).To(BeNumerically(">", 0)) - - // A user with no library grants, so its visibility can't drift with other specs - restrictedCtx := request.WithUser(ctx, model.User{ID: "shared-repo-restricted"}) - restrictedCount, err := albumRepo.CountAll(restrictedCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(restrictedCount).To(BeZero()) - - var wg sync.WaitGroup - for i := range 20 { - wg.Add(1) - go func(i int) { - defer GinkgoRecover() - defer wg.Done() - c, want := adminCtx, adminCount - if i%2 == 1 { - c, want = restrictedCtx, restrictedCount - } - got, err := albumRepo.CountAll(c) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(Equal(want)) - }(i) - } - wg.Wait() - }) - }) }) - -func _p(id, name string, sortName ...string) model.Participant { - p := model.Participant{Artist: model.Artist{ID: id, Name: name}} - if len(sortName) > 0 { - p.Artist.SortArtistName = sortName[0] - } - return p -} diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index b90a48c48..bd3a506b9 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -1,753 +1,220 @@ package persistence import ( - "cmp" "context" - "encoding/json" - "errors" "fmt" - "maps" - "os" - "slices" + "net/url" + "sort" "strings" - "time" . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/slice" - "github.com/navidrome/navidrome/utils/str" - "github.com/pocketbase/dbx" ) type artistRepository struct { sqlRepository + sqlRestful indexGroups utils.IndexGroups } type dbArtist struct { - *model.Artist `structs:",flatten"` - SimilarArtists string `structs:"-" json:"-"` - LibraryStatsJSON string `structs:"-" json:"-"` + model.Artist `structs:",flatten"` + SimilarArtists string `structs:"similar_artists" json:"similarArtists"` } -type dbSimilarArtist struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` -} - -func (a *dbArtist) PostScan() error { - a.Artist.Stats = make(map[model.Role]model.ArtistStats) - - if a.LibraryStatsJSON != "" { - var rawLibStats map[string]map[string]map[string]int64 - if err := json.Unmarshal([]byte(a.LibraryStatsJSON), &rawLibStats); err != nil { - return fmt.Errorf("parsing artist stats from db: %w", err) - } - - for _, stats := range rawLibStats { - // Sum all libraries roles stats - for key, stat := range stats { - // Aggregate stats into the main Artist.Stats map - artistStats := model.ArtistStats{ - SongCount: int(stat["m"]), - AlbumCount: int(stat["a"]), - Size: stat["s"], - } - - // Store total stats into the main attributes - if key == "total" { - a.Artist.Size += artistStats.Size - a.Artist.SongCount += artistStats.SongCount - a.Artist.AlbumCount += artistStats.AlbumCount - } - - role := model.RoleFromString(key) - if role == model.RoleInvalid { - continue - } - - current := a.Artist.Stats[role] - current.Size += artistStats.Size - current.SongCount += artistStats.SongCount - current.AlbumCount += artistStats.AlbumCount - a.Artist.Stats[role] = current - } - } - } - - a.Artist.SimilarArtists = nil - if a.SimilarArtists == "" { - return nil - } - var sa []dbSimilarArtist - if err := json.Unmarshal([]byte(a.SimilarArtists), &sa); err != nil { - return fmt.Errorf("parsing similar artists from db: %w", err) - } - for _, s := range sa { - a.Artist.SimilarArtists = append(a.Artist.SimilarArtists, model.Artist{ - ID: s.ID, - Name: s.Name, - }) - } - return nil -} - -func (a *dbArtist) PostMapArgs(m map[string]any) error { - sa := make([]dbSimilarArtist, 0) - for _, s := range a.Artist.SimilarArtists { - sa = append(sa, dbSimilarArtist{ID: s.ID, Name: s.Name}) - } - similarArtists, _ := json.Marshal(sa) - m["similar_artists"] = string(similarArtists) - // When adding a derived column here, also add it to the scanner's artist Put column list - // in phase_1_folders.go, or rescans will never update it (how search_normalized went stale). - m["full_text"] = formatFullText(a.Name, a.SortArtistName) - m["search_normalized"] = str.NormalizeForFTS(a.Name) - - // Do not override the sort_artist_name and mbz_artist_id fields if they are empty - // TODO: Better way to handle this? - if v, ok := m["sort_artist_name"]; !ok || v.(string) == "" { - delete(m, "sort_artist_name") - } - if v, ok := m["mbz_artist_id"]; !ok || v.(string) == "" { - delete(m, "mbz_artist_id") - } - return nil -} - -type dbArtists []dbArtist - -func (dba dbArtists) toModels() model.Artists { - res := make(model.Artists, len(dba)) - for i := range dba { - res[i] = *dba[i].Artist - } - return res -} - -func NewArtistRepository(db dbx.Builder) model.ArtistRepository { +func NewArtistRepository(ctx context.Context, o orm.QueryExecutor) model.ArtistRepository { r := &artistRepository{} - r.db = db + r.ctx = ctx + r.ormer = o r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups) - r.tableName = "artist" // To be used by the idFilter below - r.registerModel(&model.Artist{}, map[string]filterFunc{ - "id": idFilter(r.tableName), - "name": fullTextFilter(r.tableName, "mbz_artist_id"), - "starred": annotationBoolFilter("starred"), - "has_rating": annotationBoolFilter("rating"), - "role": roleFilter, - "missing": booleanFilter, - "library_id": artistLibraryIdFilter, - }) - r.setSortMappings(map[string]string{ //nolint:gosec - "name": "order_artist_name", - "starred_at": "starred, starred_at", - "rated_at": "rating, rated_at", - "song_count": "stats->>'total'->>'m'", - "album_count": "stats->>'total'->>'a'", - "size": "stats->>'total'->>'s'", - - // Stats by credits that are currently available - "maincredit_song_count": "sum(stats->>'maincredit'->>'m')", - "maincredit_album_count": "sum(stats->>'maincredit'->>'a')", - "maincredit_size": "sum(stats->>'maincredit'->>'s')", - }) + r.tableName = "artist" + r.sortMappings = map[string]string{ + "name": "order_artist_name", + } + r.filterMappings = map[string]filterFunc{ + "id": idFilter(r.tableName), + "name": fullTextFilter, + "starred": booleanFilter, + } return r } -func roleFilter(_ string, role any) Sqlizer { - if role, ok := role.(string); ok { - if safe, ok := sanitizeArtistStatsRole(role); ok && safe != "total" { - return Expr("JSON_EXTRACT(library_artist.stats, '$." + safe + ".m') IS NOT NULL") - } +func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder { + sql := r.newSelectWithAnnotation("artist.id", options...).Columns("artist.*") + return r.withGenres(sql).GroupBy("artist.id") +} + +func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) { + sql := r.newSelectWithAnnotation("artist.id") + sql = r.withGenres(sql) + return r.count(sql, options...) +} + +func (r *artistRepository) Exists(id string) (bool, error) { + return r.exists(Select().Where(Eq{"artist.id": id})) +} + +func (r *artistRepository) Put(a *model.Artist) error { + a.FullText = getFullText(a.Name, a.SortArtistName) + dba := r.fromModel(a) + _, err := r.put(dba.ID, dba) + if err != nil { + return err } - return Eq{"1": 2} -} - -// sanitizeArtistStatsRole allowlists values interpolated into JSON paths for artist -// stats (filter and sort). "total" is the aggregate key stored by the scanner. -// Unknown values must not reach SQL string concatenation. -func sanitizeArtistStatsRole(role string) (string, bool) { - if role == "" || role == "total" { - return "total", true + if a.ID == consts.VariousArtistsID { + return r.updateGenres(a.ID, r.tableName, nil) } - if _, ok := model.AllRoles[role]; ok { - return role, true - } - return "", false + return r.updateGenres(a.ID, r.tableName, a.Genres) } -// artistLibraryIdFilter filters artists based on library access through the library_artist table -func artistLibraryIdFilter(_ string, value any) Sqlizer { - return Eq{"library_artist.library_id": value} -} - -// applyLibraryFilterToArtistQuery applies library filtering to artist queries through the library_artist junction table -func (r *artistRepository) applyLibraryFilterToArtistQuery(ctx context.Context, query SelectBuilder) SelectBuilder { - user := loggedUser(ctx) - // Join with library_artist first to ensure only artists with content in libraries are included - // Exclude artists with empty stats (no actual content in the library) - query = query.Join("library_artist on library_artist.artist_id = artist.id") - //query = query.Join("library_artist on library_artist.artist_id = artist.id AND library_artist.stats != '{}'") - - // Admin users see all artists from all libraries, no additional filtering needed - if user.ID != invalidUserId && !user.IsAdmin { - // Apply library filtering only for non-admin users by joining with their accessible libraries - query = query.Join("user_library on user_library.library_id = library_artist.library_id AND user_library.user_id = ?", user.ID) - } - - return query -} - -func (r *artistRepository) selectArtist(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - // Stats Format: {"1": {"albumartist": {"m": 10, "a": 5, "s": 1024}, "artist": {...}}, "2": {...}} - query := r.newSelect(ctx, options...).Columns("artist.*", - "JSON_GROUP_OBJECT(library_artist.library_id, JSONB(library_artist.stats)) as library_stats_json") - - query = r.applyLibraryFilterToArtistQuery(ctx, query) - query = query.GroupBy("artist.id") - return r.withAnnotation(ctx, query, "artist.id") -} - -func (r *artistRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - query := r.newSelect(ctx) - query = r.applyLibraryFilterToArtistQuery(ctx, query) - // Only the annotation join is gated; the library_artist join above (and its count(distinct)) - // must stay, since an artist can span multiple libraries. - if filtersNeedAnnotation(r.applyFilters(query, options...)) { - query = r.withAnnotation(ctx, query, "artist.id") - } - return r.count(ctx, query, options...) -} - -// Exists checks if an artist with the given ID exists in the database and is accessible by the current user. -func (r *artistRepository) Exists(ctx context.Context, id string) (bool, error) { - // Create a query using the same library filtering logic as selectArtist() - query := r.newSelect(ctx).Columns("count(distinct artist.id) as exist").Where(Eq{"artist.id": id}) - query = r.applyLibraryFilterToArtistQuery(ctx, query) - - var res struct{ Exist int64 } - err := r.queryOne(ctx, query, &res) - return res.Exist > 0, err -} - -func (r *artistRepository) Put(ctx context.Context, a *model.Artist, colsToUpdate ...string) error { - dba := &dbArtist{Artist: a} - dba.CreatedAt = new(time.Now()) - dba.UpdatedAt = dba.CreatedAt - _, err := r.put(ctx, dba.ID, dba, colsToUpdate...) - return err -} - -func (r *artistRepository) UpdateExternalInfo(ctx context.Context, a *model.Artist) error { - dba := &dbArtist{Artist: a} - _, err := r.put(ctx, a.ID, dba, - "biography", "small_image_url", "medium_image_url", "large_image_url", - "similar_artists", "external_url", "external_info_updated_at") - return err -} - -func (r *artistRepository) Get(ctx context.Context, id string) (*model.Artist, error) { - sel := r.selectArtist(ctx).Where(Eq{"artist.id": id}) - var dba dbArtists - if err := r.queryAll(ctx, sel, &dba); err != nil { +func (r *artistRepository) Get(id string) (*model.Artist, error) { + sel := r.selectArtist().Where(Eq{"artist.id": id}) + var dba []dbArtist + if err := r.queryAll(sel, &dba); err != nil { return nil, err } if len(dba) == 0 { return nil, model.ErrNotFound } - res := dba.toModels() - r.hydrateArtwork(ctx, res) - return &res[0], nil + res := r.toModels(dba) + err := r.loadArtistGenres(&res) + return &res[0], err } -func (r *artistRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Artists, error) { - sel := r.selectArtist(ctx, options...) - var dba dbArtists - err := r.queryAll(ctx, sel, &dba) +func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, error) { + sel := r.selectArtist(options...) + var dba []dbArtist + err := r.queryAll(sel, &dba) if err != nil { return nil, err } - res := dba.toModels() - r.hydrateArtwork(ctx, res) + res := r.toModels(dba) + err = r.loadArtistGenres(&res) return res, err } -// getAllIDs returns just the artist IDs for the same row set as GetAll, skipping the -// heavy stats columns and JSON post-processing. -func (r *artistRepository) getAllIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { - sq := r.applyLibraryFilterToArtistQuery(ctx, r.newSelect(ctx, options...).Columns("artist.id")).GroupBy("artist.id") - if filtersNeedAnnotation(sq) { - sq = r.withAnnotation(ctx, sq, "artist.id") +func (r *artistRepository) toModels(dba []dbArtist) model.Artists { + res := model.Artists{} + for i := range dba { + a := dba[i] + res = append(res, *r.toModel(&a)) } - ids := []string{} - err := r.queryAllSlice(ctx, sq, &ids) - return ids, err + return res } -// hydrateArtwork fills each artist's ImageHash/ImageAbsent from one batched item_artwork lookup. -func (r *artistRepository) hydrateArtwork(ctx context.Context, artists model.Artists) { - hydrateItems(ctx, r.db, model.KindArtistArtwork, artists, - func(a *model.Artist) (string, *model.ItemImage) { return a.ID, &a.ItemImage }) +func (r *artistRepository) toModel(dba *dbArtist) *model.Artist { + a := dba.Artist + a.SimilarArtists = nil + for _, s := range strings.Split(dba.SimilarArtists, ";") { + fields := strings.Split(s, ":") + if len(fields) != 2 { + continue + } + name, _ := url.QueryUnescape(fields[1]) + a.SimilarArtists = append(a.SimilarArtists, model.Artist{ + ID: fields[0], + Name: name, + }) + } + return &a } -func (r *artistRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.ArtistCursor, error) { - ids, err := r.getAllIDs(ctx, options...) - if err != nil { - return nil, err +func (r *artistRepository) fromModel(a *model.Artist) *dbArtist { + dba := &dbArtist{Artist: *a} + var sa []string + + for _, s := range a.SimilarArtists { + sa = append(sa, fmt.Sprintf("%s:%s", s.ID, url.QueryEscape(s.Name))) } - opts := chunkOptions(options, "artist.id") - return model.ArtistCursor(streamByIDs(ids, func(chunk []string) (model.Artists, error) { - return r.GetAll(ctx, opts(chunk)) - })), nil + + dba.SimilarArtists = strings.Join(sa, ";") + return dba } -func (r *artistRepository) getIndexKey(a model.Artist) string { - source := a.OrderArtistName - if conf.Server.PreferSortTags { - source = cmp.Or(a.SortArtistName, a.OrderArtistName) - } - name := strings.ToLower(source) +func (r *artistRepository) getIndexKey(a *model.Artist) string { + name := strings.ToLower(utils.NoArticle(a.Name)) for k, v := range r.indexGroups { - if strings.HasPrefix(name, strings.ToLower(k)) { + key := strings.ToLower(k) + if strings.HasPrefix(name, key) { return v } } return "#" } -// GetIndex returns a list of artists grouped by the first letter of their name, or by the index group if configured. -// It can filter by roles and libraries, and optionally include artists that are missing (i.e., have no albums). -// TODO Cache the index (recalculate at scan time) -func (r *artistRepository) GetIndex(ctx context.Context, includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) { - // Validate library IDs. If no library IDs are provided, return an empty index. - if len(libraryIds) == 0 { - return nil, nil - } - - options := model.QueryOptions{Sort: "name"} - if len(roles) > 0 { - roleFilters := slice.Map(roles, func(r model.Role) Sqlizer { - return roleFilter("role", r.String()) - }) - options.Filters = Or(roleFilters) - } - if !includeMissing { - if options.Filters == nil { - options.Filters = Eq{"artist.missing": false} - } else { - options.Filters = And{options.Filters, Eq{"artist.missing": false}} - } - } - - libFilter := artistLibraryIdFilter("library_id", libraryIds) - if options.Filters == nil { - options.Filters = libFilter - } else { - options.Filters = And{options.Filters, libFilter} - } - - artists, err := r.GetAll(ctx, options) +// TODO Cache the index (recalculate when there are changes to the DB) +func (r *artistRepository) GetIndex() (model.ArtistIndexes, error) { + all, err := r.GetAll(model.QueryOptions{Sort: "order_artist_name"}) if err != nil { return nil, err } - var result model.ArtistIndexes - for k, v := range slice.Group(artists, r.getIndexKey) { - result = append(result, model.ArtistIndex{ID: k, Artists: v}) + fullIdx := make(map[string]*model.ArtistIndex) + for i := range all { + a := all[i] + ax := r.getIndexKey(&a) + idx, ok := fullIdx[ax] + if !ok { + idx = &model.ArtistIndex{ID: ax} + fullIdx[ax] = idx + } + idx.Artists = append(idx.Artists, a) } - slices.SortFunc(result, func(a, b model.ArtistIndex) int { - return cmp.Compare(a.ID, b.ID) + var result model.ArtistIndexes + for _, idx := range fullIdx { + result = append(result, *idx) + } + sort.Slice(result, func(i, j int) bool { + return result[i].ID < result[j].ID }) return result, nil } -func (r *artistRepository) purgeEmpty(ctx context.Context) error { - orphanFilter := "id not in (select artist_id from album_artists)" - - // Collect uploaded image filenames before deleting - sel := Select("uploaded_image").From(r.tableName). - Where(orphanFilter). - Where("uploaded_image != ''") - var imageFiles []string - if err := r.queryAllSlice(ctx, sel, &imageFiles); err != nil && !errors.Is(err, model.ErrNotFound) { - return fmt.Errorf("collecting artist images for cleanup: %w", err) +func (r *artistRepository) purgeEmpty() error { + del := Delete(r.tableName).Where("id not in (select distinct(album_artist_id) from album)") + c, err := r.executeSQL(del) + if err == nil { + if c > 0 { + log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c) + } } + return err +} - // Delete orphan artists - del := Delete(r.tableName).Where(orphanFilter) - c, err := r.executeSQL(ctx, del) +func (r *artistRepository) Search(q string, offset int, size int) (model.Artists, error) { + var dba []dbArtist + err := r.doSearch(q, offset, size, &dba, "name") if err != nil { - return fmt.Errorf("purging empty artists: %w", err) + return nil, err } - if c > 0 { - log.Debug(ctx, "Purged empty artists", "totalDeleted", c) - } - - if len(imageFiles) == 0 { - return nil - } - - // Best-effort cleanup of uploaded image files - log.Debug(ctx, "Cleaning up artist images", "totalImages", len(imageFiles)) - for _, filename := range imageFiles { - path := model.UploadedImagePath(consts.EntityArtist, filename) - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - log.Warn(ctx, "Failed to remove artist image during GC", "path", path, err) - } - } - return nil + return r.toModels(dba), nil } -// markOrphansMissing flags as missing any non-missing artist with no library_artist row, keeping the -// search fast-path's `missing = false` filter correct (see searchCfg). Called wherever such a row can -// be dropped: RefreshStats cleanup and library deletion cascade. -func (r *artistRepository) markOrphansMissing(ctx context.Context) error { - _, err := r.executeSQL(ctx, Expr( - "update artist set missing = true where missing = false "+ - "and not exists (select 1 from library_artist where library_artist.artist_id = artist.id)")) - if err != nil { - return fmt.Errorf("marking orphaned artists missing: %w", err) - } - return nil +func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(options...)) } -// markMissing marks artists as missing if all their albums are missing. -func (r *artistRepository) markMissing(ctx context.Context) error { - q := Expr(` -with artists_with_non_missing_albums as ( - select distinct aa.artist_id - from album_artists aa - join album a on aa.album_id = a.id - where a.missing = false -) -update artist -set missing = (artist.id not in (select artist_id from artists_with_non_missing_albums)); - `) - _, err := r.executeSQL(ctx, q) - if err != nil { - return fmt.Errorf("marking missing artists: %w", err) - } - return nil +func (r *artistRepository) Read(id string) (interface{}, error) { + return r.Get(id) } -// RefreshPlayCounts updates the play count and last play date annotations for all artists, based -// on the media files associated with them. -func (r *artistRepository) RefreshPlayCounts(ctx context.Context) (int64, error) { - query := Expr(` -with play_counts as ( - select user_id, atom as artist_id, sum(play_count) as total_play_count, max(play_date) as last_play_date - from media_file - join annotation on item_id = media_file.id - left join json_tree(participants, '$.artist') as jt - where atom is not null and key = 'id' - group by user_id, atom -) -insert into annotation (user_id, item_id, item_type, play_count, play_date) -select user_id, artist_id, 'artist', total_play_count, last_play_date -from play_counts -where total_play_count > 0 -on conflict (user_id, item_id, item_type) do update - set play_count = excluded.play_count, - play_date = excluded.play_date; -`) - return r.executeSQL(ctx, query) +func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return r.GetAll(r.parseRestOptions(options...)) } -// RefreshStats updates the stats field for artists whose associated media files were updated after the oldest recorded library scan time. -// When allArtists is true, it refreshes stats for all artists. It processes artists in batches to handle potentially large updates. -// This method now calculates per-library statistics and stores them in the library_artist junction table. -func (r *artistRepository) RefreshStats(ctx context.Context, allArtists bool) (int64, error) { - var allTouchedArtistIDs []string - if allArtists { - // Refresh stats for all artists - allArtistsQuerySQL := `SELECT DISTINCT id FROM artist WHERE id <> ''` - if err := r.db.NewQuery(allArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil { - return 0, fmt.Errorf("fetching all artist IDs: %w", err) - } - log.Debug(ctx, "RefreshStats: Refreshing all artists.", "count", len(allTouchedArtistIDs)) - } else { - // Only refresh artists with updated timestamps - touchedArtistsQuerySQL := ` - SELECT DISTINCT id - FROM artist - WHERE updated_at > (SELECT last_scan_at FROM library ORDER BY last_scan_at ASC LIMIT 1) - ` - if err := r.db.NewQuery(touchedArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil { - return 0, fmt.Errorf("fetching touched artist IDs: %w", err) - } - log.Debug(ctx, "RefreshStats: Refreshing touched artists.", "count", len(allTouchedArtistIDs)) - } - - if len(allTouchedArtistIDs) == 0 { - log.Debug(ctx, "RefreshStats: No artists to update.") - return 0, nil - } - - // Template for the batch update with placeholder markers that we'll replace - // This now calculates per-library statistics and stores them in library_artist.stats - batchUpdateStatsSQL := ` - WITH artist_role_counters AS ( - SELECT mfa.artist_id, - mf.library_id, - mfa.role, - count(DISTINCT mf.album_id) AS album_count, - count(DISTINCT mf.id) AS count, - sum(mf.size) AS size - FROM media_file_artists mfa - JOIN media_file mf ON mfa.media_file_id = mf.id - WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders - GROUP BY mfa.artist_id, mf.library_id, mfa.role - ), - artist_total_counters AS ( - SELECT mfa.artist_id, - mf.library_id, - 'total' AS role, - count(DISTINCT mf.album_id) AS album_count, - count(DISTINCT mf.id) AS count, - sum(mf.size) AS size - FROM media_file_artists mfa - JOIN media_file mf ON mfa.media_file_id = mf.id - WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders - GROUP BY mfa.artist_id, mf.library_id - ), - artist_participant_counter AS ( - SELECT mfa.artist_id, - mf.library_id, - 'maincredit' AS role, - count(DISTINCT mf.album_id) AS album_count, - count(DISTINCT mf.id) AS count, - sum(mf.size) AS size - FROM media_file_artists mfa - JOIN media_file mf ON mfa.media_file_id = mf.id - WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders - AND mfa.role IN ('albumartist', 'artist') - GROUP BY mfa.artist_id, mf.library_id - ), - combined_counters AS ( - SELECT artist_id, library_id, role, album_count, count, size FROM artist_role_counters - UNION ALL - SELECT artist_id, library_id, role, album_count, count, size FROM artist_total_counters - UNION ALL - SELECT artist_id, library_id, role, album_count, count, size FROM artist_participant_counter - ), - library_artist_counters AS ( - SELECT artist_id, - library_id, - json_group_object( - role, - json_object('a', album_count, 'm', count, 's', size) - ) AS counters - FROM combined_counters - GROUP BY artist_id, library_id - ) - UPDATE library_artist - SET stats = coalesce((SELECT counters FROM library_artist_counters lac - WHERE lac.artist_id = library_artist.artist_id - AND lac.library_id = library_artist.library_id), '{}') - WHERE library_artist.artist_id IN (ROLE_IDS_PLACEHOLDER);` // Will replace with actual placeholders - - var totalRowsAffected int64 = 0 - const batchSize = 1000 - - batchCounter := 0 - for artistIDBatch := range slice.CollectChunks(slices.Values(allTouchedArtistIDs), batchSize) { - batchCounter++ - log.Trace(ctx, "RefreshStats: Processing batch", "batchNum", batchCounter, "batchSize", len(artistIDBatch)) - - // Create placeholders for each ID in the IN clauses - placeholders := make([]string, len(artistIDBatch)) - for i := range artistIDBatch { - placeholders[i] = "?" - } - // Don't add extra parentheses, the IN clause already expects them in SQL syntax - inClause := strings.Join(placeholders, ",") - - // Replace the placeholder markers with actual SQL placeholders - batchSQL := strings.Replace(batchUpdateStatsSQL, "ROLE_IDS_PLACEHOLDER", inClause, 4) - - // Create a single parameter array with all IDs (repeated 4 times for each IN clause) - // We need to repeat each ID 4 times (once for each IN clause) - args := make([]any, 4*len(artistIDBatch)) - for idx, id := range artistIDBatch { - for i := range 4 { - startIdx := i * len(artistIDBatch) - args[startIdx+idx] = id - } - } - - // Now use Expr with the expanded SQL and all parameters - sqlizer := Expr(batchSQL, args...) - - rowsAffected, err := r.executeSQL(ctx, sqlizer) - if err != nil { - return totalRowsAffected, fmt.Errorf("executing batch update for artist stats (batch %d): %w", batchCounter, err) - } - totalRowsAffected += rowsAffected - } - - // Remove library_artist entries for artists that no longer have any content in a library. - cleanupSQL := Delete("library_artist").Where("stats = '{}'") - cleanupRows, err := r.executeSQL(ctx, cleanupSQL) - if err != nil { - log.Warn(ctx, "Failed to cleanup empty library_artist entries", err) - } else { - if cleanupRows > 0 { - log.Debug(ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows) - } - // Reconcile orphans whenever the cleanup removed rows, and on a full refresh so a full scan - // also heals any left by older versions. - if cleanupRows > 0 || allArtists { - if err := r.markOrphansMissing(ctx); err != nil { - log.Warn(ctx, "Failed to mark orphaned artists missing after library_artist cleanup", err) - } - } - } - - log.Debug(ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected) - return totalRowsAffected, nil +func (r *artistRepository) EntityName() string { + return "artist" } -// searchCfg builds the per-search config. scope is the set of library IDs the rowid Phase 1 must -// restrict artists to, or nil to skip the filter (fast-path). See [artistRepository.searchScope]. -func (r *artistRepository) searchCfg(scope []int) searchConfig { - return searchConfig{ - // Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist - NaturalOrder: "artist.id", - OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"}, - MBIDFields: []string{"mbz_artist_id"}, - // scope==nil is the fast-path: no filter (and orphans must not exist — see markOrphansMissing). - // Otherwise the join-free [artistLibraryFilter]. - LibraryFilter: func(query SelectBuilder) SelectBuilder { - if scope == nil { - return query - } - return query.Where(artistLibraryFilter(scope)) - }, - } -} - -// artistLibraryFilter restricts artists to the given libraries via a correlated EXISTS over the -// library_artist junction, staying join-free so it can scope the join-free search Phase 1 (a JOIN -// would fan out rowids and corrupt offset pagination). The inner LIMIT 1 is load-bearing: it stops -// SQLite from flattening the EXISTS back into a fan-out join, while still using the -// (library_id, artist_id) UNIQUE autoindex. -func artistLibraryFilter(libraryIDs []int) Sqlizer { - if len(libraryIDs) == 0 { - return Eq{"1": 2} // match nothing, without a degenerate `IN ()` subquery - } - sub, args, _ := Select("1").From("library_artist"). - Where(And{ - Expr("library_artist.artist_id = artist.id"), - Eq{"library_artist.library_id": libraryIDs}, - }).Limit(1).ToSql() - return Expr("EXISTS ("+sub+")", args...) -} - -func (r *artistRepository) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.Artists, error) { - var opts model.QueryOptions - if len(options) > 0 { - opts = options[0] - } - // Artists have no library_id column, so the library_id filter callers pass (same as albums/songs) - // can't be applied directly: consume it and realize it as a join-free Phase-1 scope (searchCfg). - scope := r.searchScope(ctx, opts.Filters) - if isLibraryIDFilter(opts.Filters) { - opts.Filters = nil - } - var res dbArtists - err := r.doSearch(ctx, r.selectArtist(ctx, opts), q, &res, r.searchCfg(scope), opts) - if err != nil { - return nil, fmt.Errorf("searching artist %q: %w", q, err) - } - artists := res.toModels() - r.hydrateArtwork(ctx, artists) - return artists, nil -} - -// searchScope returns the library IDs the search must be restricted to, or nil to skip the filter -// entirely (the fast-path: the user sees everything the search could return, so a filter would be -// pure O(offset) overhead). It intersects the requested libraries with what the user can see. -func (r *artistRepository) searchScope(ctx context.Context, filter Sqlizer) []int { - visible, err := r.visibleLibraryIDs(ctx) - if err != nil { - return r.requestedLibraryIDs(filter) // fail safe: narrow to the request rather than widen - } - requested := r.requestedLibraryIDs(filter) - if requested == nil { - // No explicit request: scope to the visible set, unless the user sees everything. - if r.userSeesAllLibraries(ctx, visible) { - return nil - } - return visible - } - // Narrow unless the request already covers everything the user can see. Compare by membership, - // not length: the requested IDs may contain duplicates. - requestedSet := slice.ToSet(requested) - if slices.ContainsFunc(visible, func(id int) bool { _, ok := requestedSet[id]; return !ok }) { - return requested - } - return nil -} - -// requestedLibraryIDs extracts the []int from an Eq{"library_id": ids} filter, or nil if filter is -// not that shape. -func (r *artistRepository) requestedLibraryIDs(filter Sqlizer) []int { - eq, ok := filter.(Eq) - if !ok { - return nil - } - ids, _ := eq["library_id"].([]int) - return ids -} - -// isLibraryIDFilter reports whether the filter is an Eq carrying a library_id key, so Search can -// consume it before it reaches the bare artist table (which has no library_id column). -func isLibraryIDFilter(filter Sqlizer) bool { - eq, ok := filter.(Eq) - if !ok { - return false - } - _, ok = eq["library_id"] - return ok -} - -func (r *artistRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) -} - -func (r *artistRepository) Read(ctx context.Context, id string) (*model.Artist, error) { - return r.Get(ctx, id) -} - -// sortMappingsForRole copies the shared mappings so a role-specific sort never leaks into them. -// Anything sanitizeArtistStatsRole rejects falls back to the "total" aggregate. -func (r *artistRepository) sortMappingsForRole(options ...rest.QueryOptions) map[string]string { - role := "total" - if len(options) > 0 { - if v, ok := options[0].Filters["role"].(string); ok { - if safe, ok := sanitizeArtistStatsRole(v); ok { - role = safe - } - } - } - mappings := maps.Clone(r.sortMappings) - mappings["song_count"] = "sum(stats->>'" + role + "'->>'m')" - mappings["album_count"] = "sum(stats->>'" + role + "'->>'a')" - mappings["size"] = "sum(stats->>'" + role + "'->>'s')" - return mappings -} - -func (r *artistRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Artist, error) { - scoped := *r - scoped.sortMappings = r.sortMappingsForRole(options...) - return scoped.GetAll(ctx, scoped.parseRestOptions(ctx, options...)) +func (r *artistRepository) NewInstance() interface{} { + return &model.Artist{} } var _ model.ArtistRepository = (*artistRepository)(nil) -var _ rest.Repository[model.Artist] = (*artistRepository)(nil) +var _ model.ResourceRepository = (*artistRepository)(nil) diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 914c2f89a..6d35191b7 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -2,1361 +2,86 @@ package persistence import ( "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sync" - "github.com/Masterminds/squirrel" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" + "github.com/beego/beego/v2/client/orm" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" ) -// Test helper functions to reduce duplication -func createTestArtistWithMBID(id, name, mbid string) model.Artist { - return model.Artist{ - ID: id, - Name: name, - MbzArtistID: mbid, - } -} - -func createUserWithLibraries(userID string, libraryIDs []int) model.User { - user := model.User{ - ID: userID, - UserName: userID, - Name: userID, - Email: userID + "@test.com", - IsAdmin: false, - } - - if len(libraryIDs) > 0 { - user.Libraries = make(model.Libraries, len(libraryIDs)) - for i, libID := range libraryIDs { - user.Libraries[i] = model.Library{ID: libID, Name: "Test Library", Path: "/test"} - } - } - - return user -} - var _ = Describe("ArtistRepository", func() { - var ctx context.Context + var repo model.ArtistRepository BeforeEach(func() { - ctx = GinkgoT().Context() + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid"}) + repo = NewArtistRepository(ctx, orm.NewOrm()) }) - Context("Core Functionality", func() { - Describe("GetIndexKey", func() { - // Note: OrderArtistName should never be empty, so we don't need to test for that - r := artistRepository{indexGroups: utils.ParseIndexGroups(conf.Server.IndexGroups)} + Describe("Count", func() { + It("returns the number of artists in the DB", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + }) - DescribeTable("returns correct index key based on PreferSortTags setting", - func(preferSortTags bool, sortArtistName, orderArtistName, expectedKey string) { - DeferCleanup(configtest.SetupConfig()) - conf.Server.PreferSortTags = preferSortTags - a := model.Artist{SortArtistName: sortArtistName, OrderArtistName: orderArtistName, Name: "Test"} - idx := GetIndexKey(&r, a) - Expect(idx).To(Equal(expectedKey)) + Describe("Exists", func() { + It("returns true for an artist that is in the DB", func() { + Expect(repo.Exists("3")).To(BeTrue()) + }) + It("returns false for an artist that is in the DB", func() { + Expect(repo.Exists("666")).To(BeFalse()) + }) + }) + + Describe("Get", func() { + It("saves and retrieves data", func() { + Expect(repo.Get("2")).To(Equal(&artistKraftwerk)) + }) + }) + + Describe("GetIndex", func() { + It("returns the index", func() { + idx, err := repo.GetIndex() + Expect(err).To(BeNil()) + Expect(idx).To(Equal(model.ArtistIndexes{ + { + ID: "B", + Artists: model.Artists{ + artistBeatles, + }, }, - Entry("PreferSortTags=false, SortArtistName empty -> uses OrderArtistName", false, "", "Bar", "B"), - Entry("PreferSortTags=false, SortArtistName not empty -> still uses OrderArtistName", false, "Foo", "Bar", "B"), - Entry("PreferSortTags=true, SortArtistName not empty -> uses SortArtistName", true, "Foo", "Bar", "F"), - Entry("PreferSortTags=true, SortArtistName empty -> falls back to OrderArtistName", true, "", "Bar", "B"), - ) - }) - - Describe("roleFilter", func() { - DescribeTable("validates roles and returns appropriate SQL expressions", - func(role string, shouldBeValid bool) { - result := roleFilter("", role) - if shouldBeValid { - expectedExpr := squirrel.Expr("JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL") - Expect(result).To(Equal(expectedExpr)) - } else { - expectedInvalid := squirrel.Eq{"1": 2} - Expect(result).To(Equal(expectedInvalid)) - } + { + ID: "K", + Artists: model.Artists{ + artistKraftwerk, + }, }, - // Valid roles from model.AllRoles - Entry("artist role", "artist", true), - Entry("albumartist role", "albumartist", true), - Entry("composer role", "composer", true), - Entry("conductor role", "conductor", true), - Entry("lyricist role", "lyricist", true), - Entry("arranger role", "arranger", true), - Entry("producer role", "producer", true), - Entry("director role", "director", true), - Entry("engineer role", "engineer", true), - Entry("mixer role", "mixer", true), - Entry("remixer role", "remixer", true), - Entry("djmixer role", "djmixer", true), - Entry("performer role", "performer", true), - Entry("maincredit role", "maincredit", true), - // Invalid roles - Entry("invalid role - wizard", "wizard", false), - Entry("invalid role - songanddanceman", "songanddanceman", false), - Entry("empty string", "", false), - Entry("SQL injection attempt", "artist') SELECT LIKE(CHAR(65,66,67,68,69,70,71),UPPER(HEX(RANDOMBLOB(500000000/2))))--", false), - ) - - It("handles non-string input types", func() { - expectedInvalid := squirrel.Eq{"1": 2} - Expect(roleFilter("", 123)).To(Equal(expectedInvalid)) - Expect(roleFilter("", nil)).To(Equal(expectedInvalid)) - Expect(roleFilter("", []string{"artist"})).To(Equal(expectedInvalid)) - }) - }) - - Describe("sanitizeArtistStatsRole", func() { - It("allowlists total and registered roles", func() { - role, ok := sanitizeArtistStatsRole("total") - Expect(ok).To(BeTrue()) - Expect(role).To(Equal("total")) - role, ok = sanitizeArtistStatsRole("albumartist") - Expect(ok).To(BeTrue()) - Expect(role).To(Equal("albumartist")) - }) - - It("rejects SQL injection payloads used in sort mappings", func() { - payload := "total'||(SELECT password FROM user LIMIT 1)||'" - role, ok := sanitizeArtistStatsRole(payload) - Expect(ok).To(BeFalse()) - Expect(role).To(BeEmpty()) - }) - }) - - Describe("ReadAll role sort", func() { - payload := "total') OR 1=1--" - - songCountSortFor := func(role any) string { - repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository) - return repo.sortMappingsForRole(rest.QueryOptions{Filters: map[string]any{"role": role}})["song_count"] - } - - It("falls back to the total stats role for an attacker-controlled role", func() { - Expect(songCountSortFor(payload)).To(Equal("sum(stats->>'total'->>'m')")) - Expect(songCountSortFor("bogus")).To(Equal("sum(stats->>'total'->>'m')")) - Expect(songCountSortFor(42)).To(Equal("sum(stats->>'total'->>'m')")) - }) - - It("keeps valid role sort paths", func() { - Expect(songCountSortFor("composer")).To(Equal("sum(stats->>'composer'->>'m')")) - Expect(songCountSortFor("albumartist")).To(Equal("sum(stats->>'albumartist'->>'m')")) - }) - - It("leaves the shared mappings untouched", func() { - repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository) - Expect(repo.sortMappingsForRole(rest.QueryOptions{Filters: map[string]any{"role": "composer"}})). - ToNot(Equal(repo.sortMappings)) - Expect(repo.sortMappings["song_count"]).To(Equal("stats->>'total'->>'m'")) - }) - - It("orders by the requested role's stats, not the total", func() { - ctx := request.WithUser(GinkgoT().Context(), adminUser) - repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository) - // Composer and total counts rank the two artists in opposite orders, so the - // resulting order alone proves which mapping the sort used. - seed := func(artistID, stats string) { - _, err := repo.executeSQL(ctx, squirrel.Insert("library_artist"). - Columns("library_id", "artist_id", "stats"). - Values(1, artistID, stats). - Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats")) - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - _, _ = repo.executeSQL(ctx, squirrel.Update("library_artist").Set("stats", "{}"). - Where(squirrel.Eq{"library_id": 1, "artist_id": artistID})) - }) - } - seed(artistBeatles.ID, `{"composer": {"s": 1, "m": 1, "a": 1}, "total": {"s": 1, "m": 100, "a": 1}}`) - seed(artistKraftwerk.ID, `{"composer": {"s": 1, "m": 9, "a": 1}, "total": {"s": 1, "m": 2, "a": 1}}`) - - res, err := repo.ReadAll(ctx, rest.QueryOptions{ - Sort: "songCount", - Order: "DESC", - Filters: map[string]any{"role": "composer"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(slice.Map(res, func(a model.Artist) string { return a.ID })). - To(Equal([]string{artistKraftwerk.ID, artistBeatles.ID})) - }) - - It("still returns results when the role is an injection payload", func() { - ctx := request.WithUser(GinkgoT().Context(), adminUser) - repo := NewArtistRepository(GetDBXBuilder()).(*artistRepository) - _, err := repo.ReadAll(ctx, rest.QueryOptions{ - Sort: "songCount", - Order: "ASC", - Filters: map[string]any{"role": payload}, - }) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("searchScope", func() { - // Resolves the library IDs a search must be restricted to (nil = fast-path / no filter), - // the way Search() does, for a repo whose context carries the given user. - scope := func(user model.User, filter squirrel.Sqlizer) []int { - ctx := request.WithUser(GinkgoT().Context(), user) - r := NewArtistRepository(GetDBXBuilder()).(*artistRepository) - return r.searchScope(ctx, filter) - } - subsetUser := model.User{ID: "u", Libraries: model.Libraries{{ID: 1}, {ID: 2}, {ID: 3}}} - - It("scopes to a strict subset of the user's libraries", func() { - Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 2}})).To(Equal([]int{1, 2})) - }) - - It("treats duplicate IDs as a set so a real subset still narrows", func() { - // {1,1,2} has 3 entries but is a strict subset of the user's 3 libraries. - Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 1, 2}})).To(Equal([]int{1, 1, 2})) - }) - - It("returns nil (fast-path) when the request covers all the user's libraries", func() { - Expect(scope(subsetUser, squirrel.Eq{"library_id": []int{1, 2, 3}})).To(BeNil()) - }) - - It("scopes to the user's libraries when no library filter is given", func() { - // A restricted user (strictly fewer libs than exist) with no musicFolderId is still - // confined to their granted libs. Build the user with total-1 libraries derived from - // the real DB total, so the "sees all" fast-path can't kick in regardless of count. - total, err := NewLibraryRepository(GetDBXBuilder()).CountAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(total).To(BeNumerically(">", 0)) - libs := make(model.Libraries, 0, total-1) - for i := int64(1); i < total; i++ { // total-1 distinct libraries → a strict subset - libs = append(libs, model.Library{ID: int(i)}) - } - restricted := model.User{ID: "r", Libraries: libs} - got := scope(restricted, nil) - Expect(got).To(HaveLen(int(total) - 1)) - }) - - It("returns nil (fast-path) for an admin requesting all existing libraries", func() { - // Admins see every library, so the visible set is the whole library table — derive - // it from the DB rather than assuming a count. - var allLibs []int - Expect(NewLibraryRepository(GetDBXBuilder()).(*libraryRepository). - queryAllSlice(ctx, squirrel.Select("id").From("library"), &allLibs)).To(Succeed()) - admin := model.User{ID: "a", IsAdmin: true} - Expect(scope(admin, squirrel.Eq{"library_id": allLibs})).To(BeNil()) - Expect(scope(admin, nil)).To(BeNil()) - }) - - It("narrows for an admin explicitly requesting a subset via musicFolderId", func() { - // An admin scoping to a single, non-existent-as-the-whole-set library must still be - // narrowed (regression: search3?musicFolderId=lib2 was leaking lib1 content). - admin := model.User{ID: "a", IsAdmin: true} - Expect(scope(admin, squirrel.Eq{"library_id": []int{-1}})).To(Equal([]int{-1})) - }) - - It("returns nil for a non-library_id filter (no library scoping requested)", func() { - // Such a filter carries no library intent; for this fully-granted-style user the - // search needs no extra library restriction. - allUser := model.User{ID: "u2", IsAdmin: true} - Expect(scope(allUser, squirrel.Eq{"name": "x"})).To(BeNil()) - }) - - It("falls back to the visible scope for a malformed library_id value (no crash)", func() { - // A library_id filter whose value isn't []int is still recognized as a library - // filter (so Search consumes it and it never reaches the bare artist table), and - // searchScope falls back to exactly the no-filter behavior rather than crashing. - malformed := squirrel.Eq{"library_id": "not-a-slice"} - Expect(isLibraryIDFilter(malformed)).To(BeTrue()) - Expect(scope(subsetUser, malformed)).To(Equal(scope(subsetUser, nil))) - }) - }) - - Describe("dbArtist mapping", func() { - var ( - artist *model.Artist - dba *dbArtist - ) - - BeforeEach(func() { - artist = &model.Artist{ID: "1", Name: "Eddie Van Halen", SortArtistName: "Van Halen, Eddie"} - dba = &dbArtist{Artist: artist} - }) - - Describe("PostScan", func() { - It("parses stats and similar artists correctly", func() { - stats := map[string]map[string]map[string]int64{ - "1": { - "total": {"s": 1000, "m": 10, "a": 2}, - "composer": {"s": 500, "m": 5, "a": 1}, - }, - } - statsJSON, _ := json.Marshal(stats) - dba.LibraryStatsJSON = string(statsJSON) - dba.SimilarArtists = `[{"id":"2","Name":"AC/DC"},{"name":"Test;With:Sep,Chars"}]` - - err := dba.PostScan() - Expect(err).ToNot(HaveOccurred()) - Expect(dba.Artist.Size).To(Equal(int64(1000))) - Expect(dba.Artist.SongCount).To(Equal(10)) - Expect(dba.Artist.AlbumCount).To(Equal(2)) - Expect(dba.Artist.Stats).To(HaveLen(1)) - Expect(dba.Artist.Stats[model.RoleFromString("composer")].Size).To(Equal(int64(500))) - Expect(dba.Artist.Stats[model.RoleFromString("composer")].SongCount).To(Equal(5)) - Expect(dba.Artist.Stats[model.RoleFromString("composer")].AlbumCount).To(Equal(1)) - Expect(dba.Artist.SimilarArtists).To(HaveLen(2)) - Expect(dba.Artist.SimilarArtists[0].ID).To(Equal("2")) - Expect(dba.Artist.SimilarArtists[0].Name).To(Equal("AC/DC")) - Expect(dba.Artist.SimilarArtists[1].ID).To(BeEmpty()) - Expect(dba.Artist.SimilarArtists[1].Name).To(Equal("Test;With:Sep,Chars")) - }) - }) - - Describe("PostMapArgs", func() { - It("maps empty similar artists correctly", func() { - m := make(map[string]any) - err := dba.PostMapArgs(m) - Expect(err).ToNot(HaveOccurred()) - Expect(m).To(HaveKeyWithValue("similar_artists", "[]")) - }) - - It("maps similar artists and full text correctly", func() { - artist.SimilarArtists = []model.Artist{ - {ID: "2", Name: "AC/DC"}, - {Name: "Test;With:Sep,Chars"}, - } - m := make(map[string]any) - err := dba.PostMapArgs(m) - Expect(err).ToNot(HaveOccurred()) - Expect(m).To(HaveKeyWithValue("similar_artists", `[{"id":"2","name":"AC/DC"},{"name":"Test;With:Sep,Chars"}]`)) - Expect(m).To(HaveKeyWithValue("full_text", " eddie halen van")) - }) - - It("does not override empty sort_artist_name and mbz_artist_id", func() { - m := map[string]any{ - "sort_artist_name": "", - "mbz_artist_id": "", - } - err := dba.PostMapArgs(m) - Expect(err).ToNot(HaveOccurred()) - Expect(m).ToNot(HaveKey("sort_artist_name")) - Expect(m).ToNot(HaveKey("mbz_artist_id")) - }) - }) + })) }) }) - Context("Admin User Operations", func() { - var repo model.ArtistRepository - + Describe("dbArtist mapping", func() { + var a *model.Artist BeforeEach(func() { - ctx = request.WithUser(ctx, adminUser) - repo = NewArtistRepository(GetDBXBuilder()) + a = &model.Artist{ID: "1", Name: "Van Halen", SimilarArtists: []model.Artist{ + {ID: "2", Name: "AC/DC"}, {ID: "-1", Name: "Test;With:Sep,Chars"}, + }} }) - - Describe("ReadAll with role sort", func() { - It("does not change the shared sort mappings", func() { - original := repo.(*artistRepository).sortMappings["song_count"] - var wg sync.WaitGroup - for i := 0; i < 20; i++ { - role := "artist" - if i%2 == 1 { - role = "composer" - } - wg.Add(1) - go func() { - defer GinkgoRecover() - defer wg.Done() - _, err := repo.ReadAll(ctx, rest.QueryOptions{Sort: "song_count", Filters: map[string]any{"role": role}}) - Expect(err).ToNot(HaveOccurred()) - }() - } - wg.Wait() - Expect(repo.(*artistRepository).sortMappings["song_count"]).To(Equal(original)) - }) - }) - - Describe("GetCursor", func() { - It("yields the same artists as GetAll", func() { - opts := model.QueryOptions{Sort: "name"} - want, err := repo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.Artist(want))) - }) - - It("honors Max/Offset like GetAll", func() { - opts := model.QueryOptions{Sort: "name", Max: 2, Offset: 1} - want, err := repo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.Artist(want))) - }) - }) - - Describe("getAllIDs", func() { - It("returns the same id set as GetAll", func() { - want, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - ids, err := repo.(*artistRepository).getAllIDs(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(ConsistOf(slice.Map(want, func(a model.Artist) string { return a.ID }))) - }) - }) - - Describe("Basic Operations", func() { - Describe("Count", func() { - It("returns the number of artists in the DB", func() { - Expect(repo.CountAll(ctx)).To(Equal(int64(4))) - }) - - It("counts starred artists when an annotation filter is present", func() { - // The Beatles (id 3) is starred for the admin user in the seed data - count, err := repo.CountAll(ctx, model.QueryOptions{ - Filters: annotationBoolFilter("starred")("starred", "true"), - }) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(1))) - }) - - It("counts with has_rating=false without a 'no such column' error (join kept)", func() { - count, err := repo.CountAll(ctx, model.QueryOptions{ - Filters: annotationBoolFilter("rating")("rating", "false"), - }) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(4))) - }) - }) - - Describe("Exists", func() { - It("returns true for an artist that is in the DB", func() { - Expect(repo.Exists(ctx, "3")).To(BeTrue()) - }) - It("returns false for an artist that is NOT in the DB", func() { - Expect(repo.Exists(ctx, "666")).To(BeFalse()) - }) - }) - - Describe("Get", func() { - It("retrieves existing artist data", func() { - artist, err := repo.Get(ctx, "2") - Expect(err).ToNot(HaveOccurred()) - Expect(artist.Name).To(Equal(artistKraftwerk.Name)) - }) - }) - }) - - Describe("GetIndex", func() { - When("PreferSortTags is true", func() { - BeforeEach(func() { - conf.Server.PreferSortTags = true - }) - It("returns the index when PreferSortTags is true and SortArtistName is not empty", func() { - // Set SortArtistName to "Foo" for Beatles - artistBeatles.SortArtistName = "Foo" - er := repo.Put(ctx, &artistBeatles) - Expect(er).To(BeNil()) - - idx, err := repo.GetIndex(ctx, false, []int{1}) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(4)) - Expect(idx[0].ID).To(Equal("F")) - Expect(idx[0].Artists).To(HaveLen(1)) - Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) - Expect(idx[1].ID).To(Equal("K")) - Expect(idx[1].Artists).To(HaveLen(1)) - Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) - Expect(idx[2].ID).To(Equal("R")) - Expect(idx[2].Artists).To(HaveLen(1)) - Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name)) - Expect(idx[3].ID).To(Equal("S")) - Expect(idx[3].Artists).To(HaveLen(1)) - Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name)) - - // Restore the original value - artistBeatles.SortArtistName = "" - er = repo.Put(ctx, &artistBeatles) - Expect(er).To(BeNil()) - }) - - // BFR Empty SortArtistName is not saved in the DB anymore - XIt("returns the index when PreferSortTags is true and SortArtistName is empty", func() { - idx, err := repo.GetIndex(ctx, false, []int{1}) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(4)) - Expect(idx[0].ID).To(Equal("B")) - Expect(idx[0].Artists).To(HaveLen(1)) - Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) - Expect(idx[1].ID).To(Equal("K")) - Expect(idx[1].Artists).To(HaveLen(1)) - Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) - Expect(idx[2].ID).To(Equal("R")) - Expect(idx[2].Artists).To(HaveLen(1)) - Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name)) - Expect(idx[3].ID).To(Equal("S")) - Expect(idx[3].Artists).To(HaveLen(1)) - Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name)) - }) - }) - - When("PreferSortTags is false", func() { - BeforeEach(func() { - conf.Server.PreferSortTags = false - }) - It("returns the index when SortArtistName is NOT empty", func() { - // Set SortArtistName to "Foo" for Beatles - artistBeatles.SortArtistName = "Foo" - er := repo.Put(ctx, &artistBeatles) - Expect(er).To(BeNil()) - - idx, err := repo.GetIndex(ctx, false, []int{1}) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(4)) - Expect(idx[0].ID).To(Equal("B")) - Expect(idx[0].Artists).To(HaveLen(1)) - Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) - Expect(idx[1].ID).To(Equal("K")) - Expect(idx[1].Artists).To(HaveLen(1)) - Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) - Expect(idx[2].ID).To(Equal("R")) - Expect(idx[2].Artists).To(HaveLen(1)) - Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name)) - Expect(idx[3].ID).To(Equal("S")) - Expect(idx[3].Artists).To(HaveLen(1)) - Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name)) - - // Restore the original value - artistBeatles.SortArtistName = "" - er = repo.Put(ctx, &artistBeatles) - Expect(er).To(BeNil()) - }) - - It("returns the index when SortArtistName is empty", func() { - idx, err := repo.GetIndex(ctx, false, []int{1}) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(4)) - Expect(idx[0].ID).To(Equal("B")) - Expect(idx[0].Artists).To(HaveLen(1)) - Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) - Expect(idx[1].ID).To(Equal("K")) - Expect(idx[1].Artists).To(HaveLen(1)) - Expect(idx[1].Artists[0].Name).To(Equal(artistKraftwerk.Name)) - Expect(idx[2].ID).To(Equal("R")) - Expect(idx[2].Artists).To(HaveLen(1)) - Expect(idx[2].Artists[0].Name).To(Equal(artistPunctuation.Name)) - Expect(idx[3].ID).To(Equal("S")) - Expect(idx[3].Artists).To(HaveLen(1)) - Expect(idx[3].Artists[0].Name).To(Equal(artistCJK.Name)) - }) - }) - - When("filtering by role", func() { - var raw *artistRepository - - BeforeEach(func() { - raw = repo.(*artistRepository) - // Add stats to library_artist table since stats are now stored per-library - composerStats := `{"composer": {"s": 1000, "m": 5, "a": 2}}` - producerStats := `{"producer": {"s": 500, "m": 3, "a": 1}}` - - // Set Beatles as composer in library 1 - _, err := raw.executeSQL(ctx, squirrel.Insert("library_artist"). - Columns("library_id", "artist_id", "stats"). - Values(1, artistBeatles.ID, composerStats). - Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats")) - Expect(err).ToNot(HaveOccurred()) - - // Set Kraftwerk as producer in library 1 - _, err = raw.executeSQL(ctx, squirrel.Insert("library_artist"). - Columns("library_id", "artist_id", "stats"). - Values(1, artistKraftwerk.ID, producerStats). - Suffix("ON CONFLICT(library_id, artist_id) DO UPDATE SET stats = excluded.stats")) - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - // Clean up stats from library_artist table - _, _ = raw.executeSQL(ctx, squirrel.Update("library_artist"). - Set("stats", "{}"). - Where(squirrel.Eq{"artist_id": artistBeatles.ID, "library_id": 1})) - _, _ = raw.executeSQL(ctx, squirrel.Update("library_artist"). - Set("stats", "{}"). - Where(squirrel.Eq{"artist_id": artistKraftwerk.ID, "library_id": 1})) - }) - - It("returns only artists with the specified role", func() { - idx, err := repo.GetIndex(ctx, false, []int{1}, model.RoleComposer) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(1)) - Expect(idx[0].ID).To(Equal("B")) - Expect(idx[0].Artists).To(HaveLen(1)) - Expect(idx[0].Artists[0].Name).To(Equal(artistBeatles.Name)) - }) - - It("returns artists with any of the specified roles", func() { - idx, err := repo.GetIndex(ctx, false, []int{1}, model.RoleComposer, model.RoleProducer) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(2)) - - // Find Beatles and Kraftwerk in the results - var beatlesFound, kraftwerkFound bool - for _, index := range idx { - for _, artist := range index.Artists { - if artist.Name == artistBeatles.Name { - beatlesFound = true - } - if artist.Name == artistKraftwerk.Name { - kraftwerkFound = true - } - } - } - Expect(beatlesFound).To(BeTrue()) - Expect(kraftwerkFound).To(BeTrue()) - }) - - It("returns empty index when no artists have the specified role", func() { - idx, err := repo.GetIndex(ctx, false, []int{1}, model.RoleDirector) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(0)) - }) - }) - - When("validating library IDs", func() { - It("returns nil when no library IDs are provided", func() { - idx, err := repo.GetIndex(ctx, false, []int{}) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(BeNil()) - }) - - It("returns artists when library IDs are provided (admin user sees all content)", func() { - // Admin users can see all content when valid library IDs are provided - idx, err := repo.GetIndex(ctx, false, []int{1}) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(4)) - - // With non-existent library ID, admin users see no content because no artists are associated with that library - idx, err = repo.GetIndex(ctx, false, []int{999}) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(0)) // Even admin users need valid library associations - }) - }) - }) - - Describe("Filters", func() { - var artistWithoutAnnotation model.Artist - - BeforeEach(func() { - // Create artist without any annotation - artistWithoutAnnotation = model.Artist{ID: "no-annotation-artist", Name: "No Annotation Artist"} - err := createArtistWithLibrary(ctx, repo, &artistWithoutAnnotation, 1) - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - if raw, ok := repo.(*artistRepository); ok { - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithoutAnnotation.ID})) - } - }) - - Describe("starred", func() { - It("false includes items without annotations", func() { - res, err := repo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"starred": "false"}, - }) - Expect(err).ToNot(HaveOccurred()) - artists := res - - var found bool - for _, a := range artists { - if a.ID == artistWithoutAnnotation.ID { - found = true - break - } - } - Expect(found).To(BeTrue(), "Artist without annotation should be included in starred=false filter") - }) - - It("true excludes items without annotations", func() { - res, err := repo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"starred": "true"}, - }) - Expect(err).ToNot(HaveOccurred()) - artists := res - - for _, a := range artists { - Expect(a.ID).ToNot(Equal(artistWithoutAnnotation.ID)) - } - }) - }) - }) - - Describe("MBID and Text Search", func() { - var lib2 model.Library - var lr model.LibraryRepository - var lrCtx context.Context - var restrictedUser model.User - var restrictedRepo model.ArtistRepository - var restrictedCtx context.Context - var headlessRepo model.ArtistRepository - var headlessCtx context.Context - - BeforeEach(func() { - // Set up headless repo (no user context) - headlessCtx = GinkgoT().Context() - headlessRepo = NewArtistRepository(GetDBXBuilder()) - - // Create library for testing access restrictions - lib2 = model.Library{ID: 0, Name: "Artist Test Library", Path: "/artist/test/lib"} - lrCtx = request.WithUser(ctx, adminUser) - lr = NewLibraryRepository(GetDBXBuilder()) - err := lr.Put(lrCtx, &lib2) - Expect(err).ToNot(HaveOccurred()) - - // Create a user with access to only library 1 - restrictedUser = createUserWithLibraries("search_user", []int{1}) - - // Create repository context for the restricted user - restrictedCtx = request.WithUser(ctx, restrictedUser) - restrictedRepo = NewArtistRepository(GetDBXBuilder()) - - // Ensure both test artists are associated with library 1 - err = lr.AddArtist(lrCtx, 1, artistBeatles.ID) - Expect(err).ToNot(HaveOccurred()) - err = lr.AddArtist(lrCtx, 1, artistKraftwerk.ID) - Expect(err).ToNot(HaveOccurred()) - - // Create the restricted user in the database - urCtx := request.WithUser(ctx, adminUser) - ur := NewUserRepository(GetDBXBuilder()) - err = ur.Put(urCtx, &restrictedUser) - Expect(err).ToNot(HaveOccurred()) - err = ur.SetUserLibraries(urCtx, restrictedUser.ID, []int{1}) - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - // Clean up library 2 - lr := NewLibraryRepository(GetDBXBuilder()) - _ = lr.(*libraryRepository).delete(ctx, squirrel.Eq{"id": lib2.ID}) - }) - - DescribeTable("MBID search behavior across different user types", - func(testRepo *model.ArtistRepository, testCtx *context.Context, shouldFind bool, testDesc string) { - // Create test artist with MBID - artistWithMBID := createTestArtistWithMBID("test-mbid-artist", "Test MBID Artist", "550e8400-e29b-41d4-a716-446655440010") - - err := createArtistWithLibrary(*testCtx, *testRepo, &artistWithMBID, 1) - Expect(err).ToNot(HaveOccurred()) - - // Test the search - results, err := (*testRepo).Search(*testCtx, "550e8400-e29b-41d4-a716-446655440010", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - - if shouldFind { - Expect(results).To(HaveLen(1), testDesc) - Expect(results[0].ID).To(Equal("test-mbid-artist")) - } else { - Expect(results).To(BeEmpty(), testDesc) - } - - // Clean up - if raw, ok := (*testRepo).(*artistRepository); ok { - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID})) - } - }, - Entry("Admin user can find artist by MBID", &repo, &ctx, true, "Admin should find MBID artist"), - Entry("Restricted user can find artist by MBID in accessible library", &restrictedRepo, &restrictedCtx, true, "Restricted user should find MBID artist in accessible library"), - Entry("Headless process can find artist by MBID", &headlessRepo, &headlessCtx, true, "Headless process should find MBID artist"), - ) - - It("prevents restricted user from finding artist by MBID when not in accessible library", func() { - // Create an artist in library 2 (not accessible to restricted user) - inaccessibleArtist := createTestArtistWithMBID("inaccessible-mbid-artist", "Inaccessible MBID Artist", "a74b1b7f-71a5-4011-9441-d0b5e4122711") - err := repo.Put(ctx, &inaccessibleArtist) - Expect(err).ToNot(HaveOccurred()) - - // Add to library 2 (not accessible to restricted user) - err = lr.AddArtist(lrCtx, lib2.ID, inaccessibleArtist.ID) - Expect(err).ToNot(HaveOccurred()) - - // Restricted user should not find this artist - results, err := restrictedRepo.Search(restrictedCtx, "a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - - // But admin should find it - results, err = repo.Search(ctx, "a74b1b7f-71a5-4011-9441-d0b5e4122711", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - - // Clean up - if raw, ok := repo.(*artistRepository); ok { - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) - } - }) - - Context("Text Search", func() { - It("allows admin to find artists by name regardless of library", func() { - results, err := repo.Search(ctx, "Beatles", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].Name).To(Equal("The Beatles")) - }) - - It("correctly prevents restricted user from finding artists by name when not in accessible library", func() { - // Create an artist in library 2 (not accessible to restricted user) - inaccessibleArtist := model.Artist{ - ID: "inaccessible-text-artist", - Name: "Unique Search Name Artist", - } - err := repo.Put(ctx, &inaccessibleArtist) - Expect(err).ToNot(HaveOccurred()) - - // Add to library 2 (not accessible to restricted user) - err = lr.AddArtist(lrCtx, lib2.ID, inaccessibleArtist.ID) - Expect(err).ToNot(HaveOccurred()) - - // Restricted user should not find this artist - results, err := restrictedRepo.Search(restrictedCtx, "Unique Search Name", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty(), "Text search should respect library filtering") - - // Clean up - if raw, ok := repo.(*artistRepository); ok { - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.ID})) - } - }) - }) - - Context("Empty Query (sync pagination)", func() { - It("does not duplicate artists that belong to multiple libraries", func() { - // An artist in two libraries has two library_artist rows; pagination - // must still enumerate it exactly once, at a stable offset. - Expect(lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID)).To(Succeed()) - - all, err := repo.Search(ctx, "", model.QueryOptions{Max: 1000}) - Expect(err).ToNot(HaveOccurred()) - - seen := map[string]bool{} - var paged model.Artists - for offset := range len(all) { - page, err := repo.Search(ctx, "", model.QueryOptions{Max: 1, Offset: offset}) - Expect(err).ToNot(HaveOccurred()) - for _, a := range page { - Expect(seen[a.ID]).To(BeFalse(), fmt.Sprintf("artist %s returned twice", a.ID)) - seen[a.ID] = true - } - paged = append(paged, page...) - } - Expect(paged).To(HaveLen(len(all))) - }) - - It("paginates all artists in natural order without overlaps or gaps", func() { - all, err := repo.Search(ctx, "", model.QueryOptions{Max: 1000}) - Expect(err).ToNot(HaveOccurred()) - Expect(len(all)).To(BeNumerically(">", 1)) - - var paged model.Artists - pageSize := 2 - for offset := 0; offset < len(all); offset += pageSize { - page, err := repo.Search(ctx, "", model.QueryOptions{Max: pageSize, Offset: offset}) - Expect(err).ToNot(HaveOccurred()) - paged = append(paged, page...) - } - Expect(paged).To(HaveLen(len(all))) - for i := range all { - Expect(paged[i].ID).To(Equal(all[i].ID)) - } - }) - - It("respects library filtering for restricted users", func() { - // Create an artist only in library 2 (not accessible to restricted user) - lib2Artist := model.Artist{ID: "empty-query-lib2-artist", Name: "Empty Query Lib2 Artist"} - Expect(repo.Put(ctx, &lib2Artist)).To(Succeed()) - Expect(lr.AddArtist(lrCtx, lib2.ID, lib2Artist.ID)).To(Succeed()) - - results, err := restrictedRepo.Search(restrictedCtx, "", model.QueryOptions{Max: 1000}) - Expect(err).ToNot(HaveOccurred()) - for _, a := range results { - Expect(a.ID).ToNot(Equal(lib2Artist.ID), "Empty query search should respect library filtering") - } - - // Clean up - if raw, ok := repo.(*artistRepository); ok { - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID})) - } - }) - - It("paginates a restricted user's visible artists without gaps", func() { - // ID "25" sorts between base fixtures "2" and "3", so this lib2-only artist lands - // inside the restricted user's visible range — exercising the no-gap guarantee. - lib2Artist := model.Artist{ID: "25", Name: "Restricted Lib2 Artist"} - Expect(repo.Put(ctx, &lib2Artist)).To(Succeed()) - Expect(lr.AddArtist(lrCtx, lib2.ID, lib2Artist.ID)).To(Succeed()) - DeferCleanup(func() { - if raw, ok := repo.(*artistRepository); ok { - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID})) - } - }) - - all, err := restrictedRepo.Search(restrictedCtx, "", model.QueryOptions{Max: 1000}) - Expect(err).ToNot(HaveOccurred()) - Expect(len(all)).To(BeNumerically(">", 1)) - for _, a := range all { - Expect(a.ID).ToNot(Equal(lib2Artist.ID)) - } - - var paged model.Artists - for offset := range len(all) { - page, err := restrictedRepo.Search(restrictedCtx, "", model.QueryOptions{Max: 1, Offset: offset}) - Expect(err).ToNot(HaveOccurred()) - Expect(page).To(HaveLen(1), fmt.Sprintf("page at offset %d should be full", offset)) - paged = append(paged, page...) - } - Expect(paged).To(HaveLen(len(all))) - for i := range all { - Expect(paged[i].ID).To(Equal(all[i].ID)) - } - }) - }) - - Context("Headless Processes (No User Context)", func() { - It("should see all artists from all libraries when no user is in context", func() { - // Add artists to different libraries - err := lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID) - Expect(err).ToNot(HaveOccurred()) - - // Headless processes should see all artists regardless of library - artists, err := headlessRepo.GetAll(headlessCtx) - Expect(err).ToNot(HaveOccurred()) - - // Should see all artists from all libraries - found := false - for _, artist := range artists { - if artist.ID == artistBeatles.ID { - found = true - break - } - } - Expect(found).To(BeTrue(), "Headless process should see artists from all libraries") - }) - - It("should allow headless processes to apply explicit library_id filters", func() { - // Add artists to different libraries - err := lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID) - Expect(err).ToNot(HaveOccurred()) - - // Filter by specific library - artists, err := headlessRepo.GetAll(headlessCtx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib2.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - - // Should see only artists from the specified library - for _, artist := range artists { - if artist.ID == artistBeatles.ID { - return // Found the expected artist - } - } - Expect(false).To(BeTrue(), "Should find artist from specified library") - }) - - It("should get individual artists when no user is in context", func() { - // Add artist to a library - err := lr.AddArtist(lrCtx, lib2.ID, artistBeatles.ID) - Expect(err).ToNot(HaveOccurred()) - - // Headless process should be able to get the artist - artist, err := headlessRepo.Get(headlessCtx, artistBeatles.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(artist.ID).To(Equal(artistBeatles.ID)) - }) - }) - }) - - Describe("Admin User Library Access", func() { - It("sees all artists regardless of library permissions", func() { - count, err := repo.CountAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(4))) - - artists, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(HaveLen(4)) - - exists, err := repo.Exists(ctx, artistBeatles.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - }) - }) - - Describe("Missing Artist Handling", func() { - var missingArtist model.Artist - var raw *artistRepository - - BeforeEach(func() { - raw = repo.(*artistRepository) - missingArtist = model.Artist{ID: "missing_test", Name: "Missing Artist", OrderArtistName: "missing artist"} - - // Create and mark as missing - err := createArtistWithLibrary(ctx, repo, &missingArtist, 1) - Expect(err).ToNot(HaveOccurred()) - - _, err = raw.executeSQL(ctx, squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missingArtist.ID})) - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingArtist.ID})) - }) - - It("missing artists are never returned by search", func() { - // Should see missing artist in GetAll by default for admin users - artists, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(HaveLen(5)) // Including the missing artist - - // Search never returns missing artists (hardcoded behavior) - results, err := repo.Search(ctx, "Missing Artist", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) - }) - }) - - Context("Regular User Operations", func() { - var restrictedRepo model.ArtistRepository - var restrictedCtx context.Context - var unauthorizedUser model.User - - BeforeEach(func() { - // Create a user without access to any libraries - unauthorizedUser = model.User{ID: "restricted_user", UserName: "restricted", Name: "Restricted User", Email: "restricted@test.com", IsAdmin: false} - - // Create repository context for the unauthorized user - restrictedCtx = request.WithUser(ctx, unauthorizedUser) - restrictedRepo = NewArtistRepository(GetDBXBuilder()) - }) - - Describe("Library Access Restrictions", func() { - It("CountAll returns 0 for users without library access", func() { - count, err := restrictedRepo.CountAll(restrictedCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(0))) - }) - - It("GetAll returns empty list for users without library access", func() { - artists, err := restrictedRepo.GetAll(restrictedCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(BeEmpty()) - }) - - It("Exists returns false for existing artists when user has no library access", func() { - // These artists exist in the DB but the user has no access to them - exists, err := restrictedRepo.Exists(restrictedCtx, artistBeatles.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - - exists, err = restrictedRepo.Exists(restrictedCtx, artistKraftwerk.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - }) - - It("Get returns ErrNotFound for existing artists when user has no library access", func() { - _, err := restrictedRepo.Get(restrictedCtx, artistBeatles.ID) - Expect(err).To(Equal(model.ErrNotFound)) - - _, err = restrictedRepo.Get(restrictedCtx, artistKraftwerk.ID) - Expect(err).To(Equal(model.ErrNotFound)) - }) - - It("Search returns empty results for users without library access", func() { - results, err := restrictedRepo.Search(restrictedCtx, "Beatles", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - - results, err = restrictedRepo.Search(restrictedCtx, "Kraftwerk", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) - - It("GetIndex returns empty index for users without library access", func() { - idx, err := restrictedRepo.GetIndex(restrictedCtx, false, []int{1}) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(0)) - }) - }) - - Context("when user gains library access", func() { - BeforeEach(func() { - // Give the user access to library 1 - urCtx := request.WithUser(ctx, adminUser) - ur := NewUserRepository(GetDBXBuilder()) - - // First create the user if not exists - err := ur.Put(urCtx, &unauthorizedUser) - Expect(err).ToNot(HaveOccurred()) - - // Then add library access - err = ur.SetUserLibraries(urCtx, unauthorizedUser.ID, []int{1}) - Expect(err).ToNot(HaveOccurred()) - - // Update the user object with the libraries to simulate middleware behavior - libraries, err := ur.GetUserLibraries(urCtx, unauthorizedUser.ID) - Expect(err).ToNot(HaveOccurred()) - unauthorizedUser.Libraries = libraries - - // Recreate repository context with updated user - restrictedCtx = request.WithUser(ctx, unauthorizedUser) - restrictedRepo = NewArtistRepository(GetDBXBuilder()) - }) - - AfterEach(func() { - // Clean up: remove the user's library access - urCtx := request.WithUser(ctx, adminUser) - ur := NewUserRepository(GetDBXBuilder()) - _ = ur.SetUserLibraries(urCtx, unauthorizedUser.ID, []int{}) - }) - - It("CountAll returns correct count after gaining access", func() { - count, err := restrictedRepo.CountAll(restrictedCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(4))) // Beatles, Kraftwerk, Seatbelts, and The Roots - }) - - It("GetAll returns artists after gaining access", func() { - artists, err := restrictedRepo.GetAll(restrictedCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(HaveLen(4)) - - var names []string - for _, artist := range artists { - names = append(names, artist.Name) - } - Expect(names).To(ContainElements("The Beatles", "Kraftwerk", "シートベルツ", "The Roots")) - }) - - It("Exists returns true for accessible artists", func() { - exists, err := restrictedRepo.Exists(restrictedCtx, artistBeatles.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - - exists, err = restrictedRepo.Exists(restrictedCtx, artistKraftwerk.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - }) - - It("GetIndex returns artists with proper library filtering", func() { - // With valid library access, should see artists - idx, err := restrictedRepo.GetIndex(restrictedCtx, false, []int{1}) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(4)) - - // With non-existent library ID, should see nothing (non-admin user) - idx, err = restrictedRepo.GetIndex(restrictedCtx, false, []int{999}) - Expect(err).ToNot(HaveOccurred()) - Expect(idx).To(HaveLen(0)) - }) - - It("takes the unfiltered fast-path when the user can access every library", func() { - // The fixture DB has a single library and the user was granted it, so it has access - // to all libraries: search results must match what an admin sees. - adminCtx := request.WithUser(ctx, adminUser) - adminRepo := NewArtistRepository(GetDBXBuilder()) - adminAll, err := adminRepo.Search(adminCtx, "", model.QueryOptions{Max: 1000}) - Expect(err).ToNot(HaveOccurred()) - - userAll, err := restrictedRepo.Search(restrictedCtx, "", model.QueryOptions{Max: 1000}) - Expect(err).ToNot(HaveOccurred()) - - ids := func(artists model.Artists) []string { - out := make([]string, len(artists)) - for i, a := range artists { - out[i] = a.ID - } - return out - } - Expect(ids(userAll)).To(Equal(ids(adminAll))) - Expect(userAll).ToNot(BeEmpty()) - }) - - It("detects all-library access regardless of result equivalence", func() { - // userSeesAllLibraries drives the search fast-path for a non-admin: true when the - // visible-library count reaches the DB total. Derive the total from the DB so the - // assertion doesn't depend on how many libraries other specs left behind. - raw := restrictedRepo.(*artistRepository) // context carries a non-admin user - total, err := NewLibraryRepository(GetDBXBuilder()).CountAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(total).To(BeNumerically(">", 0)) - - allLibs := make([]int, total) - for i := range allLibs { - allLibs[i] = i + 1 - } - Expect(raw.userSeesAllLibraries(restrictedCtx, allLibs)).To(BeTrue()) - Expect(raw.userSeesAllLibraries(restrictedCtx, allLibs[:total-1])).To(BeFalse()) - Expect(raw.userSeesAllLibraries(restrictedCtx, []int{})).To(BeFalse()) - }) - }) - }) - - Describe("purgeEmpty", func() { - var repo *artistRepository - var tmpDir string - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = conf.NewDir(tmpDir) - - ctx = request.WithUser(ctx, adminUser) - repo = NewArtistRepository(GetDBXBuilder()).(*artistRepository) - }) - - // Helper to create an artist image file on disk and return its path - createImageFile := func(filename string) string { - dir := filepath.Join(tmpDir, consts.ArtworkFolder, consts.EntityArtist) - Expect(os.MkdirAll(dir, 0755)).To(Succeed()) - path := filepath.Join(dir, filename) - Expect(os.WriteFile(path, []byte("fake image data"), 0600)).To(Succeed()) - return path - } - - It("removes uploaded image files for purged artists", func() { - // Create an orphan artist (not in album_artists) with an uploaded image - orphanArtist := model.Artist{ID: "orphan-with-image", Name: "Orphan Artist", UploadedImage: "orphan-with-image_Orphan_Artist.jpg"} - Expect(repo.Put(ctx, &orphanArtist)).To(Succeed()) - imgPath := createImageFile("orphan-with-image_Orphan_Artist.jpg") - - Expect(repo.purgeEmpty(ctx)).To(Succeed()) - - // Artist should be gone from DB - exists, err := repo.Exists(ctx, "orphan-with-image") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - - // Image file should be removed from disk - _, err = os.Stat(imgPath) - Expect(os.IsNotExist(err)).To(BeTrue()) - }) - - It("handles missing image files gracefully", func() { - // Artist has UploadedImage set but no actual file on disk - orphanArtist := model.Artist{ID: "orphan-no-file", Name: "Ghost Image", UploadedImage: "orphan-no-file_Ghost_Image.jpg"} - Expect(repo.Put(ctx, &orphanArtist)).To(Succeed()) - - Expect(repo.purgeEmpty(ctx)).To(Succeed()) - - // Artist should be gone from DB - exists, err := repo.Exists(ctx, "orphan-no-file") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - }) - - It("does not delete images for artists that are kept", func() { - // Create an artist with an uploaded image AND an album_artists entry so it won't be purged - keptArtist := model.Artist{ID: "kept-artist", Name: "Kept Artist", UploadedImage: "kept-artist_Kept_Artist.jpg"} - Expect(repo.Put(ctx, &keptArtist)).To(Succeed()) - imgPath := createImageFile("kept-artist_Kept_Artist.jpg") - - // Insert an album_artists record to keep this artist from being purged - _, err := repo.executeSQL(ctx, squirrel.Insert("album_artists"). - SetMap(map[string]any{"album_id": "101", "artist_id": "kept-artist", "role": "artist", "sub_role": ""})) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _, _ = repo.executeSQL(ctx, squirrel.Delete("album_artists").Where(squirrel.Eq{"artist_id": "kept-artist"})) - _ = repo.delete(ctx, squirrel.Eq{"id": "kept-artist"}) - }) - - Expect(repo.purgeEmpty(ctx)).To(Succeed()) - - // Artist should still exist (check directly, bypassing library filter) - var ids []string - err = repo.queryAllSlice(ctx, squirrel.Select("id").From("artist").Where(squirrel.Eq{"id": "kept-artist"}), &ids) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(HaveLen(1)) - - // Image file should still be on disk - _, err = os.Stat(imgPath) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("RefreshStats", func() { - var repo *artistRepository - - missing := func(id string) bool { - var vals []bool - Expect(repo.queryAllSlice(ctx, squirrel.Select("missing").From("artist").Where(squirrel.Eq{"id": id}), &vals)).To(Succeed()) - Expect(vals).To(HaveLen(1)) - return vals[0] - } - - BeforeEach(func() { - ctx = request.WithUser(ctx, adminUser) - repo = NewArtistRepository(GetDBXBuilder()).(*artistRepository) - }) - - It("marks artists missing when the empty-stats cleanup drops their last library_artist row", func() { - // A library_artist row with stats '{}' (no content) gets deleted by the cleanup, - // which would orphan this non-missing artist. - emptyArtist := model.Artist{ID: "refresh-empty", Name: "No Content Artist"} - Expect(repo.Put(ctx, &emptyArtist)).To(Succeed()) - _, err := repo.executeSQL(ctx, squirrel.Insert("library_artist"). - SetMap(map[string]any{"library_id": 1, "artist_id": emptyArtist.ID, "stats": "{}"})) - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - _, _ = repo.executeSQL(ctx, squirrel.Delete("library_artist").Where(squirrel.Eq{"artist_id": emptyArtist.ID})) - _ = repo.delete(ctx, squirrel.Eq{"id": emptyArtist.ID}) - }) - - Expect(missing(emptyArtist.ID)).To(BeFalse()) - - _, err = repo.RefreshStats(ctx, true) - Expect(err).ToNot(HaveOccurred()) - - Expect(missing(emptyArtist.ID)).To(BeTrue()) - var orphanIDs []string - Expect(repo.queryAllSlice(ctx, squirrel.Select("id").From("artist"). - Where("missing = false"). - Where("id not in (select artist_id from library_artist)"), &orphanIDs)).To(Succeed()) - Expect(orphanIDs).ToNot(ContainElement(emptyArtist.ID)) - }) - - It("heals a pre-existing orphan (no library_artist row) on a full refresh", func() { - // A legacy orphan left by an older version: non-missing, with no library_artist row at - // all. The cleanup deletes nothing for it, so a full refresh (allArtists) must still - // reconcile it. - legacyOrphan := model.Artist{ID: "refresh-legacy-orphan", Name: "Legacy Orphan"} - Expect(repo.Put(ctx, &legacyOrphan)).To(Succeed()) - DeferCleanup(func() { - _ = repo.delete(ctx, squirrel.Eq{"id": legacyOrphan.ID}) - }) - - Expect(missing(legacyOrphan.ID)).To(BeFalse()) - - _, err := repo.RefreshStats(ctx, true) - Expect(err).ToNot(HaveOccurred()) - - Expect(missing(legacyOrphan.ID)).To(BeTrue()) + It("maps fields", func() { + dba := repo.(*artistRepository).fromModel(a) + actual := repo.(*artistRepository).toModel(dba) + Expect(*actual).To(MatchFields(IgnoreExtras, Fields{ + "ID": Equal(a.ID), + "Name": Equal(a.Name), + })) + Expect(actual.SimilarArtists).To(HaveLen(2)) + Expect(actual.SimilarArtists[0].ID).To(Equal("2")) + Expect(actual.SimilarArtists[0].Name).To(Equal("AC/DC")) + Expect(actual.SimilarArtists[1].ID).To(Equal("-1")) + Expect(actual.SimilarArtists[1].Name).To(Equal("Test;With:Sep,Chars")) }) }) }) - -// Helper function to create an artist with proper library association. -// This ensures test artists always have library_artist associations to avoid orphaned artists in tests. -func createArtistWithLibrary(ctx context.Context, repo model.ArtistRepository, artist *model.Artist, libraryID int) error { - err := repo.Put(ctx, artist) - if err != nil { - return err - } - - // Add the artist to the specified library - lr := NewLibraryRepository(GetDBXBuilder()) - return lr.AddArtist(request.WithUser(ctx, adminUser), libraryID, artist.ID) -} diff --git a/persistence/artwork_hydration.go b/persistence/artwork_hydration.go deleted file mode 100644 index 76646ec74..000000000 --- a/persistence/artwork_hydration.go +++ /dev/null @@ -1,181 +0,0 @@ -package persistence - -import ( - "context" - "iter" - "slices" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/pocketbase/dbx" -) - -// Keeps the id IN-list under SQLite's bound-parameter limit; a whole multiple of artworkBatchSize -// so a page re-chunks into even hydration batches. -const artworkChunkSize = artworkBatchSize * 3 - -// streamByIDs yields rows in id chunks through the caller's hydrating fetch. Resolving ids first -// keeps OFFSET out of the joined query. -func streamByIDs[S ~[]T, T any](ids []string, fetch func(chunk []string) (S, error)) iter.Seq2[T, error] { - return func(yield func(T, error) bool) { - for chunk := range slices.Chunk(ids, artworkChunkSize) { - rows, err := fetch(chunk) - if err != nil { - var zero T - yield(zero, err) - return - } - for _, row := range rows { - if !yield(row, nil) { - return - } - } - } - } -} - -// chunkOptions narrows options to a chunk of ids, dropping Max/Offset (the id pre-pass already -// consumed them) and reusing Sort, which may be a seeded random expression by now. -func chunkOptions(options []model.QueryOptions, idField string) func([]string) model.QueryOptions { - var base model.QueryOptions - if len(options) > 0 { - base = model.QueryOptions{Sort: options[0].Sort, Order: options[0].Order, Filters: options[0].Filters} - } - return func(chunk []string) model.QueryOptions { - opts := base - opts.Filters = Eq{idField: chunk} - if base.Filters != nil { - opts.Filters = And{base.Filters, opts.Filters} - } - return opts - } -} - -// hydrateItemImages returns per-item artwork info in one batched query per kind. On error it logs -// and returns an empty map, so the page still renders. -func hydrateItemImages(ctx context.Context, db dbx.Builder, kind model.Kind, ids []string) map[string]model.ItemArtworkInfo { - if len(ids) == 0 { - return map[string]model.ItemArtworkInfo{} - } - infos, err := NewArtworkRepository(db).GetInfoForItems(ctx, kind, ids) - if err != nil { - log.Error(ctx, "Failed to hydrate artwork info onto page", "kind", kind, err) - return map[string]model.ItemArtworkInfo{} - } - return infos -} - -// applyItemImage copies a hydration entry onto img; a missing entry leaves it zero (unresolved). -func applyItemImage(infos map[string]model.ItemArtworkInfo, id string, img *model.ItemImage) { - if info, ok := infos[id]; ok { - *img = info.Image() - } -} - -// hydrateItems fills every item's embedded ItemImage from one batched lookup. ref yields an -// item's id and the ItemImage to fill, which is all that differs between the entity kinds. -func hydrateItems[T any](ctx context.Context, db dbx.Builder, kind model.Kind, items []T, - ref func(*T) (string, *model.ItemImage)) { - ids := make([]string, len(items)) - for i := range items { - ids[i], _ = ref(&items[i]) - } - infos := hydrateItemImages(ctx, db, kind, ids) - for i := range items { - id, img := ref(&items[i]) - applyItemImage(infos, id, img) - } -} - -// hydrateMediaFileArtwork mirrors MediaFile.CoverArtID: an embedded-eligible file with resolved own -// art uses it, else it falls back to the album's. -func hydrateMediaFileArtwork(ctx context.Context, db dbx.Builder, mfs model.MediaFiles) { - if len(mfs) == 0 { - return - } - albumIDs := make([]string, len(mfs)) - var eligibleIDs []string - for i := range mfs { - albumIDs[i] = mfs[i].AlbumID - if mfs[i].HasCoverArt && conf.Server.EnableMediaFileCoverArt { - eligibleIDs = append(eligibleIDs, mfs[i].ID) - } - } - albumInfos := hydrateItemImages(ctx, db, model.KindAlbumArtwork, albumIDs) - mfInfos := hydrateItemImages(ctx, db, model.KindMediaFileArtwork, eligibleIDs) - for i := range mfs { - mf := &mfs[i] - applyItemImage(albumInfos, mf.AlbumID, &mf.AlbumImage) - eligible := mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt - ownInfo, ownResolved := mfInfos[mf.ID] - if eligible && ownResolved && !ownInfo.Absent() { - mf.ItemImage = ownInfo.Image() - continue - } - ownWontResolve := !eligible || (ownResolved && ownInfo.Absent()) - // Inherit the album hash only when serving returns those exact bytes: a multi-disc track is - // served disc art, and an eligible-but-unresolved one still extracts its own embedded image. - if album, ok := albumInfos[mf.AlbumID]; ok && !album.Absent() { - if mf.DiscNumber == 0 && ownWontResolve { - mf.ItemImage = album.Image() - } - continue - } - // Mark absent only when serving would definitively yield a placeholder; disc art and - // still-extractable embedded art both keep a track requestable. - if mf.DiscNumber > 0 { - continue - } - if album, ok := albumInfos[mf.AlbumID]; ok && album.Absent() && ownWontResolve { - mf.ImageAbsent = true - } - } -} - -// hydrateCursor hydrates a streamed cursor in batches, avoiding a per-row query. -func hydrateCursor[T any](cursor iter.Seq2[T, error], hydrate func([]T)) iter.Seq2[T, error] { - return func(yield func(T, error) bool) { - buf := make([]T, 0, artworkBatchSize) - flush := func() bool { - hydrate(buf) - for i := range buf { - if !yield(buf[i], nil) { - return false - } - } - buf = buf[:0] - return true - } - for row, err := range cursor { - if err != nil { - var zero T - yield(zero, err) - return - } - buf = append(buf, row) - if len(buf) == artworkBatchSize && !flush() { - return - } - } - if len(buf) > 0 { - flush() - } - } -} - -// hydratePlaylistTrackArtwork hydrates the MediaFile embedded in each playlist track. -func hydratePlaylistTrackArtwork(ctx context.Context, db dbx.Builder, tracks model.PlaylistTracks) { - if len(tracks) == 0 { - return - } - mfs := make(model.MediaFiles, len(tracks)) - for i := range tracks { - mfs[i] = tracks[i].MediaFile - } - hydrateMediaFileArtwork(ctx, db, mfs) - for i := range tracks { - tracks[i].MediaFile = mfs[i] - } -} diff --git a/persistence/artwork_hydration_test.go b/persistence/artwork_hydration_test.go deleted file mode 100644 index 89182d6cc..000000000 --- a/persistence/artwork_hydration_test.go +++ /dev/null @@ -1,773 +0,0 @@ -package persistence - -import ( - "context" - "errors" - "fmt" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" -) - -// seedAnnotations gives each id distinct annotation values, writing them directly so that -// SetRating's average_rating update does not outlive the cleanup. -func seedAnnotations(itemType string, ids ...string) { - GinkgoHelper() - when := time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC) - for i, id := range ids { - _, err := GetDBXBuilder().NewQuery(`insert or replace into annotation - (user_id, item_id, item_type, play_count, play_date, rating, rated_at, starred, starred_at) - values ({:u}, {:id}, {:t}, {:pc}, {:d}, {:r}, {:d}, 1, {:d})`). - Bind(dbx.Params{"u": adminUser.ID, "id": id, "t": itemType, - "pc": i + 1, "r": 4 + i, "d": when.Add(time.Duration(i) * time.Hour)}).Execute() - Expect(err).ToNot(HaveOccurred()) - } - DeferCleanup(func() { - for _, id := range ids { - _, err := GetDBXBuilder().NewQuery("delete from annotation where user_id={:u} and item_type={:t} and item_id={:id}"). - Bind(dbx.Params{"u": adminUser.ID, "t": itemType, "id": id}).Execute() - Expect(err).ToNot(HaveOccurred()) - } - }) -} - -var _ = Describe("Artwork hydration", func() { - var ctx context.Context - var aw model.ArtworkRepository - - putInfo := func(kind, id, hash string) { - Expect(aw.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary, Hash: hash, - })).To(Succeed()) - } - - BeforeEach(func() { - clearArtworkTables() - DeferCleanup(clearArtworkTables) - ctx = request.WithUser(log.NewContext(context.Background()), adminUser) - aw = NewArtworkRepository(GetDBXBuilder()) - }) - - Describe("albums", func() { - var repo model.AlbumRepository - BeforeEach(func() { repo = NewAlbumRepository(GetDBXBuilder()) }) - - It("hydrates the found / known-absent / unresolved states", func() { - putInfo("al", albumSgtPeppers.ID, "althash11111111") - putInfo("al", albumAbbeyRoad.ID, "") - // albumRadioactivity: no row -> unresolved - - all, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - byID := slice.ToMap(all, func(a model.Album) (string, model.Album) { return a.ID, a }) - - Expect(byID[albumSgtPeppers.ID].ImageHash).To(Equal("althash11111111")) - Expect(byID[albumSgtPeppers.ID].ImageAbsent).To(BeFalse()) - Expect(byID[albumAbbeyRoad.ID].ImageHash).To(BeEmpty()) - Expect(byID[albumAbbeyRoad.ID].ImageAbsent).To(BeTrue()) - Expect(byID[albumRadioactivity.ID].ImageHash).To(BeEmpty()) - Expect(byID[albumRadioactivity.ID].ImageAbsent).To(BeFalse()) - }) - - It("hydrates Get", func() { - putInfo("al", albumSgtPeppers.ID, "gethash22222222") - got, err := repo.Get(ctx, albumSgtPeppers.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.ImageHash).To(Equal("gethash22222222")) - }) - - It("hydrates Search", func() { - putInfo("al", albumSgtPeppers.ID, "srchash33333333") - res, err := repo.Search(ctx, "Peppers", model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(res).ToNot(BeEmpty()) - Expect(res[0].ImageHash).To(Equal("srchash33333333")) - }) - - It("does not persist ImageHash/ImageAbsent on Put", func() { - al := albumSgtPeppers - al.ImageHash = "shouldnotpersist" - al.ImageAbsent = true - Expect(repo.(*albumRepository).Put(ctx, &al)).To(Succeed()) - - got, err := repo.Get(ctx, al.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.ImageHash).To(BeEmpty()) - Expect(got.ImageAbsent).To(BeFalse()) - }) - }) - - Describe("artists", func() { - var repo model.ArtistRepository - BeforeEach(func() { repo = NewArtistRepository(GetDBXBuilder()) }) - - It("hydrates the found / known-absent / unresolved states", func() { - putInfo("ar", artistBeatles.ID, "arhash444444444") - putInfo("ar", artistKraftwerk.ID, "") - // artistCJK: no row -> unresolved - - all, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - byID := slice.ToMap(all, func(a model.Artist) (string, model.Artist) { return a.ID, a }) - - Expect(byID[artistBeatles.ID].ImageHash).To(Equal("arhash444444444")) - Expect(byID[artistBeatles.ID].ImageAbsent).To(BeFalse()) - Expect(byID[artistKraftwerk.ID].ImageHash).To(BeEmpty()) - Expect(byID[artistKraftwerk.ID].ImageAbsent).To(BeTrue()) - Expect(byID[artistCJK.ID].ImageHash).To(BeEmpty()) - Expect(byID[artistCJK.ID].ImageAbsent).To(BeFalse()) - }) - - It("hydrates Get", func() { - putInfo("ar", artistBeatles.ID, "arget5555555555") - got, err := repo.Get(ctx, artistBeatles.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.ImageHash).To(Equal("arget5555555555")) - }) - - It("hydrates Search", func() { - putInfo("ar", artistBeatles.ID, "arsrch666666666") - res, err := repo.Search(ctx, "Beatles", model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(res).ToNot(BeEmpty()) - Expect(res[0].ImageHash).To(Equal("arsrch666666666")) - }) - }) - - Describe("playlists", func() { - var repo model.PlaylistRepository - BeforeEach(func() { repo = NewPlaylistRepository(GetDBXBuilder()) }) - - It("hydrates the found / known-absent states", func() { - putInfo("pl", plsBest.ID, "plhash777777777") - putInfo("pl", plsCool.ID, "") - - all, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - byID := slice.ToMap(all, func(p model.Playlist) (string, model.Playlist) { return p.ID, p }) - - Expect(byID[plsBest.ID].ImageHash).To(Equal("plhash777777777")) - Expect(byID[plsBest.ID].ImageAbsent).To(BeFalse()) - Expect(byID[plsCool.ID].ImageHash).To(BeEmpty()) - Expect(byID[plsCool.ID].ImageAbsent).To(BeTrue()) - }) - - It("hydrates Get", func() { - putInfo("pl", plsBest.ID, "plget8888888888") - got, err := repo.Get(ctx, plsBest.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.ImageHash).To(Equal("plget8888888888")) - }) - - It("hydrates the tracks reached through a playlist", func() { - Expect(aw.PutImage(ctx, &model.Artwork{Hash: "pltrackhash1234", Mime: "image/jpeg", BlurHash: "LPLBLURhash"})).To(Succeed()) - putInfo("al", songDayInALife.AlbumID, "pltrackhash1234") - - pls, err := repo.GetWithTracks(ctx, plsBest.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - tracks := pls.Tracks - Expect(tracks).ToNot(BeEmpty()) - byID := slice.ToMap(tracks, func(t model.PlaylistTrack) (string, model.PlaylistTrack) { return t.MediaFile.ID, t }) - Expect(byID).To(HaveKey(songDayInALife.ID)) - Expect(byID[songDayInALife.ID].AlbumImage.ImageHash).To(Equal("pltrackhash1234")) - Expect(byID[songDayInALife.ID].BlurHash).To(Equal("LPLBLURhash")) - - cursor, err := repo.Tracks(ctx, plsBest.ID, true).GetCursor(ctx) - Expect(err).ToNot(HaveOccurred()) - var streamed *model.PlaylistTrack - for t, err := range cursor { - Expect(err).ToNot(HaveOccurred()) - if t.MediaFile.ID == songDayInALife.ID { - streamed = &t - } - } - Expect(streamed).ToNot(BeNil()) - Expect(streamed.AlbumImage.ImageHash).To(Equal("pltrackhash1234"), - "the streamed cursor Jellyfin uses must hydrate too") - }) - }) - - Describe("radios", func() { - var repo model.RadioRepository - BeforeEach(func() { repo = NewRadioRepository(GetDBXBuilder()) }) - - It("hydrates the found / known-absent states", func() { - putInfo("ra", radioWithHomePage.ID, "rahash999999999") - putInfo("ra", radioWithoutHomePage.ID, "") - - all, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - byID := slice.ToMap(all, func(rd model.Radio) (string, model.Radio) { return rd.ID, rd }) - - Expect(byID[radioWithHomePage.ID].ImageHash).To(Equal("rahash999999999")) - Expect(byID[radioWithHomePage.ID].ImageAbsent).To(BeFalse()) - Expect(byID[radioWithoutHomePage.ID].ImageHash).To(BeEmpty()) - Expect(byID[radioWithoutHomePage.ID].ImageAbsent).To(BeTrue()) - }) - - It("hydrates Get", func() { - putInfo("ra", radioWithHomePage.ID, "ragetaaaaaaaaaa") - got, err := repo.Get(ctx, radioWithHomePage.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.ImageHash).To(Equal("ragetaaaaaaaaaa")) - }) - }) - - Describe("mediafiles", func() { - var repo model.MediaFileRepository - - setCover := func(id string, v bool) { - _, err := GetDBXBuilder().NewQuery("UPDATE media_file SET has_cover_art={:v} WHERE id={:id}"). - Bind(dbx.Params{"v": v, "id": id}).Execute() - Expect(err).ToNot(HaveOccurred()) - } - - getByID := func() map[string]model.MediaFile { - all, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - return slice.ToMap(all, func(mf model.MediaFile) (string, model.MediaFile) { return mf.ID, mf }) - } - - BeforeEach(func() { - repo = NewMediaFileRepository(GetDBXBuilder()) - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableMediaFileCoverArt = true - }) - - It("resolves the embedded-eligible fallback matrix", func() { - setCover("1001", true) // eligible, own hash - setCover("1002", true) // eligible, but embedded art absent -> album - DeferCleanup(func() { setCover("1001", false); setCover("1002", false) }) - - putInfo("al", "101", "alh101xxxxxxxxxx") // song 1001's album (found) - putInfo("al", "102", "alh102xxxxxxxxxx") // song 1002's album (found) - putInfo("al", "103", "") // songs 1003/1004 album known-absent - putInfo("mf", "1001", "mfh1001xxxxxxxx") - putInfo("mf", "1002", "") // embedded resolved absent - - byID := getByID() - - Expect(byID["1001"].ImageHash).To(Equal("mfh1001xxxxxxxx")) - Expect(byID["1001"].ImageAbsent).To(BeFalse()) - Expect(byID["1002"].ImageHash).To(Equal("alh102xxxxxxxxxx")) - Expect(byID["1002"].ImageAbsent).To(BeFalse()) - Expect(byID["1003"].ImageHash).To(BeEmpty()) - Expect(byID["1003"].ImageAbsent).To(BeTrue()) - // 2002: not eligible, and its album has no row at all -> unresolved - Expect(byID["2002"].ImageHash).To(BeEmpty()) - Expect(byID["2002"].ImageAbsent).To(BeFalse()) - }) - - It("populates AlbumImage from hydrateArtwork regardless of which continue branch a track takes", func() { - setCover("1001", true) // eligible, resolves its own art -> own-art-wins continue - DeferCleanup(func() { setCover("1001", false) }) - - putInfo("al", "101", "alh101albimgxxxx") // 1001's album: own-art-wins branch - putInfo("al", "102", "alh102albimgxxxx") // 1002's album: single-disc inheritance branch - putInfo("al", "104", "") // 2002's album: known-absent, multi-disc branch - putInfo("mf", "1001", "mfh1001albimgxxx") - - byID := getByID() - - Expect(byID["1001"].ImageHash).To(Equal("mfh1001albimgxxx")) - Expect(byID["1001"].AlbumImage.ImageHash).To(Equal("alh101albimgxxxx")) - - Expect(byID["1002"].ImageHash).To(Equal("alh102albimgxxxx")) - Expect(byID["1002"].AlbumImage.ImageHash).To(Equal("alh102albimgxxxx")) - - Expect(byID["2002"].ImageHash).To(BeEmpty()) - Expect(byID["2002"].ImageAbsent).To(BeFalse()) - Expect(byID["2002"].AlbumImage.ImageAbsent).To(BeTrue()) - }) - - It("carries both hashes and the dimensions alongside the hash in the own-art and inherited branches", func() { - setCover("1001", true) // eligible, resolves its own art -> own-art-wins branch - DeferCleanup(func() { setCover("1001", false) }) - - Expect(aw.PutImage(ctx, &model.Artwork{Hash: "mfh1001blurxxxxx", Mime: "image/jpeg", BlurHash: "LTRACKblur", ThumbHash: "THtrack", Width: 640, Height: 480})).To(Succeed()) - Expect(aw.PutImage(ctx, &model.Artwork{Hash: "alh102blurxxxxxx", Mime: "image/jpeg", BlurHash: "LALBUMblur", ThumbHash: "THalbum", Width: 1200, Height: 800})).To(Succeed()) - putInfo("mf", "1001", "mfh1001blurxxxxx") - putInfo("al", "102", "alh102blurxxxxxx") // 1002's album: single-disc inheritance branch - - byID := getByID() - - Expect(byID["1001"].ImageHash).To(Equal("mfh1001blurxxxxx")) - Expect(byID["1001"].BlurHash).To(Equal("LTRACKblur")) - Expect(byID["1001"].ThumbHash).To(Equal("THtrack")) - Expect(byID["1001"].ImageWidth).To(Equal(640)) - Expect(byID["1001"].ImageHeight).To(Equal(480)) - - Expect(byID["1002"].ImageHash).To(Equal("alh102blurxxxxxx")) - Expect(byID["1002"].BlurHash).To(Equal("LALBUMblur")) - Expect(byID["1002"].ThumbHash).To(Equal("THalbum")) - Expect(byID["1002"].ImageWidth).To(Equal(1200)) - Expect(byID["1002"].ImageHeight).To(Equal(800)) - }) - - It("keeps an eligible file optimistic when its own art is unresolved, even if the album is absent", func() { - setCover("1004", true) - DeferCleanup(func() { setCover("1004", false) }) - putInfo("al", "103", "") // album known-absent - - byID := getByID() - - // 1004's own embedded art is still unresolved, so coverArt must stay requestable. - Expect(byID["1004"].ImageAbsent).To(BeFalse()) - Expect(byID["1004"].ImageHash).To(BeEmpty()) - // 1003 is not eligible, so it still inherits the album's absence. - Expect(byID["1003"].ImageAbsent).To(BeTrue()) - }) - - It("does not stamp a found album hash onto a multi-disc track (its dc- id is disc-served)", func() { - putInfo("al", "104", "alh104foundxxxxx") // songs 2002/2004 album is found - - byID := getByID() - - Expect(byID["2002"].ImageHash).To(BeEmpty()) - Expect(byID["2002"].ImageAbsent).To(BeFalse()) - }) - - It("keeps a multi-disc track requestable when its album is absent (disc art may resolve)", func() { - putInfo("al", "104", "") // songs 2002/2004 album known-absent - - byID := getByID() - - Expect(byID["2002"].ImageAbsent).To(BeFalse()) - Expect(byID["2002"].ImageHash).To(BeEmpty()) - }) - - // serveMediaFile serves this track's own embedded art, so the album's hash would - // advertise a content-version for bytes nobody will serve. - It("leaves the hash bare for an eligible file whose own art is unresolved", func() { - setCover("1004", true) - DeferCleanup(func() { setCover("1004", false) }) - putInfo("al", "103", "alh103found11111") - - byID := getByID() - Expect(byID["1004"].ImageAbsent).To(BeFalse()) - Expect(byID["1004"].ImageHash).To(BeEmpty()) - Expect(byID["1004"].AlbumImage.ImageHash).To(Equal("alh103found11111"), - "the album's own hash still hydrates, for AlbumCoverArtID") - }) - - It("uses album info for an eligible file when EnableMediaFileCoverArt is off", func() { - conf.Server.EnableMediaFileCoverArt = false - setCover("1001", true) - DeferCleanup(func() { setCover("1001", false) }) - - putInfo("al", "101", "alh101offxxxxxxx") - putInfo("mf", "1001", "mfh1001offxxxxx") - - byID := getByID() - Expect(byID["1001"].ImageHash).To(Equal("alh101offxxxxxxx")) - Expect(byID["1001"].ImageAbsent).To(BeFalse()) - }) - - It("hydrates Search", func() { - putInfo("al", "101", "alsrchhhhhhhhhhh") - res, err := repo.Search(ctx, "A Day In A Life", model.QueryOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(res).ToNot(BeEmpty()) - Expect(res[0].ImageHash).To(Equal("alsrchhhhhhhhhhh")) - }) - }) - - Describe("GetCursor", func() { - var albumRepo model.AlbumRepository - var artistRepo model.ArtistRepository - var playlistRepo model.PlaylistRepository - var onlyAlbums, onlyArtists, onlyPlaylists squirrel.Eq - - scoped := func(opts model.QueryOptions, only squirrel.Eq) model.QueryOptions { - if opts.Filters == nil { - opts.Filters = only - } else { - opts.Filters = squirrel.And{only, opts.Filters} - } - return opts - } - - BeforeEach(func() { - albumRepo = NewAlbumRepository(GetDBXBuilder()) - artistRepo = NewArtistRepository(GetDBXBuilder()) - playlistRepo = NewPlaylistRepository(GetDBXBuilder()) - // Other specs leave rows behind, so scope every cursor spec to the fixtures. - onlyAlbums = squirrel.Eq{"album.id": []string{albumSgtPeppers.ID, albumAbbeyRoad.ID, - albumRadioactivity.ID, albumMultiDisc.ID, albumCJK.ID, albumPunctuation.ID}} - onlyArtists = squirrel.Eq{"artist.id": []string{artistKraftwerk.ID, artistBeatles.ID, - artistCJK.ID, artistPunctuation.ID}} - // Both fixture playlists share an owner, leaving the owner_name sort a single value to - // order by; this one is also private, which the non-admin visibility spec needs. - foreign := model.Playlist{Name: "Foreign", OwnerID: thirdUser.ID, OwnerName: thirdUser.UserName} - Expect(playlistRepo.Put(ctx, &foreign)).To(Succeed()) - DeferCleanup(func() { Expect(playlistRepo.Delete(ctx, foreign.ID)).To(Succeed()) }) - onlyPlaylists = squirrel.Eq{"playlist.id": []string{plsBest.ID, plsCool.ID, foreign.ID}} - - // The suite annotates a single album and artist, leaving the annotation-backed sorts - // nothing to order. - seedAnnotations("album", albumSgtPeppers.ID, albumAbbeyRoad.ID) - seedAnnotations("artist", artistKraftwerk.ID, artistCJK.ID) - - Expect(aw.PutImage(ctx, &model.Artwork{ - Hash: "curhash11111111", Mime: "image/jpeg", BlurHash: "LEHV6nWB2yk8", - })).To(Succeed()) - putInfo("al", albumSgtPeppers.ID, "curhash11111111") - putInfo("al", albumAbbeyRoad.ID, "") - putInfo("ar", artistBeatles.ID, "curhash11111111") - putInfo("pl", plsBest.ID, "curhash11111111") - }) - - It("hydrates every streamed album, like GetAll", func() { - opts := model.QueryOptions{Sort: "name", Filters: onlyAlbums} - want, err := albumRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - - got := collectCursor(albumRepo.GetCursor(ctx, opts)) - - Expect(got).To(ConsistOf(want)) - byID := map[string]model.Album{} - for _, al := range got { - byID[al.ID] = al - } - Expect(byID[albumSgtPeppers.ID].ImageHash).To(Equal("curhash11111111")) - Expect(byID[albumSgtPeppers.ID].BlurHash).To(Equal("LEHV6nWB2yk8")) - Expect(byID[albumAbbeyRoad.ID].ImageAbsent).To(BeTrue()) - Expect(byID[albumRadioactivity.ID].ImageHash).To(BeEmpty()) - Expect(byID[albumRadioactivity.ID].ImageAbsent).To(BeFalse()) - }) - - It("hydrates every streamed artist, like GetAll", func() { - opts := model.QueryOptions{Sort: "name", Filters: onlyArtists} - want, err := artistRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - - got := collectCursor(artistRepo.GetCursor(ctx, opts)) - - Expect(got).To(ConsistOf(want)) - Expect(slice.Map(got, func(a model.Artist) string { return a.ImageHash })). - To(ContainElement("curhash11111111")) - }) - - It("hydrates every streamed playlist, like GetAll", func() { - opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists} - want, err := playlistRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - - got := collectCursor(playlistRepo.GetCursor(ctx, opts)) - - Expect(got).To(ConsistOf(want)) - Expect(slice.Map(got, func(p model.Playlist) string { return p.ImageHash })). - To(ContainElement("curhash11111111")) - }) - - It("honors Max and Offset exactly once", func() { - opts := model.QueryOptions{Sort: "name", Filters: onlyAlbums, Max: 2, Offset: 1} - want, err := albumRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).To(HaveLen(2)) - - got := collectCursor(albumRepo.GetCursor(ctx, opts)) - - Expect(slice.Map(got, func(a model.Album) string { return a.ID })). - To(Equal(slice.Map(want, func(a model.Album) string { return a.ID }))) - }) - - // The sorts the Jellyfin list endpoints issue; comparing sort keys, not ids, keeps ties out. - DescribeTable("orders albums like GetAll", - func(opts model.QueryOptions, key func(model.Album) string) { - opts = scoped(opts, onlyAlbums) - want, err := albumRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - - got := collectCursor(albumRepo.GetCursor(ctx, opts)) - - Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key))) - Expect(slice.Map(got, func(a model.Album) string { return a.ID })). - To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) - }, - Entry("by name", model.QueryOptions{Sort: "name"}, - func(a model.Album) string { return a.OrderAlbumName }), - Entry("by artist", model.QueryOptions{Sort: "artist"}, - func(a model.Album) string { return a.OrderAlbumArtistName }), - Entry("by recently added", model.QueryOptions{Sort: "recently_added", Order: "desc"}, - func(a model.Album) string { return fmt.Sprint(a.CreatedAt) }), - Entry("by year", model.QueryOptions{Sort: "max_year"}, - func(a model.Album) string { return fmt.Sprint(a.MaxYear) }), - Entry("by play count", model.QueryOptions{Sort: "play_count", Order: "desc"}, - func(a model.Album) string { return fmt.Sprint(a.PlayCount) }), - Entry("by last played", model.QueryOptions{Sort: "play_date", Order: "desc"}, - func(a model.Album) string { return fmt.Sprint(a.PlayDate) }), - Entry("by rating", model.QueryOptions{Sort: "rating", Order: "desc"}, - func(a model.Album) string { return fmt.Sprint(a.Rating) }), - Entry("starred only", model.QueryOptions{Sort: "starred_at", Order: "desc", - Filters: squirrel.Eq{"starred": true}}, - func(a model.Album) string { return fmt.Sprint(a.StarredAt) }), - ) - - DescribeTable("orders artists like GetAll", - func(opts model.QueryOptions, key func(model.Artist) string) { - opts = scoped(opts, onlyArtists) - want, err := artistRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - - got := collectCursor(artistRepo.GetCursor(ctx, opts)) - - Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key))) - Expect(slice.Map(got, func(a model.Artist) string { return a.ID })). - To(ConsistOf(slice.Map(want, func(a model.Artist) string { return a.ID }))) - }, - Entry("by name", model.QueryOptions{Sort: "name"}, - func(a model.Artist) string { return a.OrderArtistName }), - Entry("by album count", model.QueryOptions{Sort: "album_count", Order: "desc"}, - func(a model.Artist) string { return fmt.Sprint(a.AlbumCount) }), - Entry("by song count", model.QueryOptions{Sort: "song_count", Order: "desc"}, - func(a model.Artist) string { return fmt.Sprint(a.SongCount) }), - Entry("by play count", model.QueryOptions{Sort: "play_count", Order: "desc"}, - func(a model.Artist) string { return fmt.Sprint(a.PlayCount) }), - Entry("starred only", model.QueryOptions{Sort: "starred_at", Order: "desc", - Filters: squirrel.Eq{"starred": true}}, - func(a model.Artist) string { return fmt.Sprint(a.StarredAt) }), - ) - - DescribeTable("orders playlists like GetAll", - func(opts model.QueryOptions, key func(model.Playlist) string) { - opts = scoped(opts, onlyPlaylists) - want, err := playlistRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - - got := collectCursor(playlistRepo.GetCursor(ctx, opts)) - - Expect(slice.Map(got, key)).To(Equal(slice.Map(want, key))) - Expect(slice.Map(got, func(p model.Playlist) string { return p.ID })). - To(ConsistOf(slice.Map(want, func(p model.Playlist) string { return p.ID }))) - }, - Entry("by name", model.QueryOptions{Sort: "name"}, - func(p model.Playlist) string { return p.Name }), - Entry("by creation date", model.QueryOptions{Sort: "created_at", Order: "desc"}, - func(p model.Playlist) string { return fmt.Sprint(p.CreatedAt) }), - Entry("by owner", model.QueryOptions{Sort: "owner_name"}, - func(p model.Playlist) string { return p.OwnerName }), - ) - - It("streams every album exactly once when sorted randomly", func() { - opts := model.QueryOptions{Sort: "random", Filters: onlyAlbums} - want, err := albumRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - - got := collectCursor(albumRepo.GetCursor(ctx, opts)) - - Expect(slice.Map(got, func(a model.Album) string { return a.ID })). - To(ConsistOf(slice.Map(want, func(a model.Album) string { return a.ID }))) - }) - - It("keeps a non-admin from streaming another user's private playlists", func() { - otherCtx := request.WithUser(log.NewContext(context.Background()), regularUser) - repo := NewPlaylistRepository(GetDBXBuilder()) - opts := model.QueryOptions{Sort: "name", Filters: onlyPlaylists} - - // Both phases must filter on their own: the id pre-pass and the chunk fetch. - Expect(repo.(*playlistRepository).getAllIDs(otherCtx, opts)).To(ConsistOf(plsBest.ID)) - all, err := repo.GetAll(otherCtx, model.QueryOptions{Filters: onlyPlaylists}) - Expect(err).ToNot(HaveOccurred()) - Expect(slice.Map(all, func(p model.Playlist) string { return p.ID })).To(ConsistOf(plsBest.ID)) - - got := collectCursor(repo.GetCursor(otherCtx, opts)) - - Expect(slice.Map(got, func(p model.Playlist) string { return p.Name })). - To(ConsistOf(plsBest.Name)) - }) - }) - - Describe("GetCursorWithArtwork", func() { - var mfRepo model.MediaFileRepository - var onlySongs squirrel.Eq - - BeforeEach(func() { - mfRepo = NewMediaFileRepository(GetDBXBuilder()) - putInfo("al", albumSgtPeppers.ID, "curhash11111111") - // Distinct titles only: other fixture songs share titles (e.g. "Antenna" x3), which - // would make the positional comparisons against GetAll pass by tie-order coincidence. - onlySongs = squirrel.Eq{"media_file.id": []string{songDayInALife.ID, songComeTogether.ID, - songRadioactivity.ID, songAntenna.ID, songDisc1Track01.ID, songCJK.ID, songPunctuation.ID}} - }) - - It("hydrates artwork onto every streamed track, unlike GetCursor", func() { - opts := model.QueryOptions{Sort: "title", Filters: onlySongs} - want, err := mfRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - - cursor, err := mfRepo.GetCursorWithArtwork(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - var got model.MediaFiles - cursor(func(mf model.MediaFile, err error) bool { - Expect(err).ToNot(HaveOccurred()) - got = append(got, mf) - return true - }) - - Expect(got).To(HaveLen(len(want))) - for i := range want { - Expect(got[i].ID).To(Equal(want[i].ID)) - Expect(got[i].ImageHash).To(Equal(want[i].ImageHash)) - Expect(got[i].ImageAbsent).To(Equal(want[i].ImageAbsent)) - Expect(got[i].AlbumImage.ImageHash).To(Equal(want[i].AlbumImage.ImageHash)) - Expect(got[i].AlbumImage.BlurHash).To(Equal(want[i].AlbumImage.BlurHash)) - } - Expect(want).To(ContainElement(HaveField("AlbumImage.ImageHash", Not(BeEmpty()))), - "fixture must include at least one track with album artwork, or this proves nothing") - }) - - It("leaves the scanner's GetCursor unhydrated", func() { - cursor, err := mfRepo.GetCursor(ctx, model.QueryOptions{Sort: "title"}) - Expect(err).ToNot(HaveOccurred()) - var seen int - cursor(func(mf model.MediaFile, err error) bool { - Expect(err).ToNot(HaveOccurred()) - Expect(mf.AlbumImage.ImageHash).To(BeEmpty(), "GetCursor must stay unhydrated for the scanner") - seen++ - return true - }) - Expect(seen).To(BeNumerically(">", 0)) - }) - - It("streams the same ids in the same order as GetAll", func() { - opts := model.QueryOptions{Sort: "title", Filters: onlySongs} - want, err := mfRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - - got := collectCursor(mfRepo.GetCursorWithArtwork(ctx, opts)) - - Expect(slice.Map(got, func(mf model.MediaFile) string { return mf.ID })). - To(Equal(slice.Map(want, func(mf model.MediaFile) string { return mf.ID }))) - }) - - It("honors Max and Offset exactly once", func() { - opts := model.QueryOptions{Sort: "title", Filters: onlySongs, Max: 2, Offset: 1} - want, err := mfRepo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).To(HaveLen(2)) - - got := collectCursor(mfRepo.GetCursorWithArtwork(ctx, opts)) - - Expect(slice.Map(got, func(mf model.MediaFile) string { return mf.ID })). - To(Equal(slice.Map(want, func(mf model.MediaFile) string { return mf.ID }))) - }) - }) - - Describe("chunkOptions", func() { - It("carries Sort, Order and the caller's filters, but never Max/Offset", func() { - base := model.QueryOptions{Sort: "name", Order: "desc", Max: 10, Offset: 20, - Filters: squirrel.Eq{"missing": false}} - - got := chunkOptions([]model.QueryOptions{base}, "album.id")([]string{"al-1", "al-2"}) - - Expect(got.Max).To(BeZero()) - Expect(got.Offset).To(BeZero()) - Expect(got.Sort).To(Equal("name")) - Expect(got.Order).To(Equal("desc")) - Expect(got.Filters).To(Equal(squirrel.And{ - base.Filters, squirrel.Eq{"album.id": []string{"al-1", "al-2"}}, - })) - }) - - It("filters by ids alone when the caller passed no filters", func() { - got := chunkOptions(nil, "artist.id")([]string{"ar-1"}) - Expect(got.Filters).To(Equal(squirrel.Eq{"artist.id": []string{"ar-1"}})) - }) - }) - - Describe("streamByIDs", func() { - It("fetches in chunks and yields every row in order", func() { - ids := make([]string, artworkChunkSize+3) - for i := range ids { - ids[i] = fmt.Sprintf("id-%d", i) - } - var chunks [][]string - got := collectCursor(streamByIDs(ids, func(chunk []string) ([]string, error) { - chunks = append(chunks, chunk) - return chunk, nil - }), nil) - - Expect(chunks).To(HaveLen(2)) - Expect(chunks[0]).To(HaveLen(artworkChunkSize)) - Expect(chunks[1]).To(HaveLen(3)) - Expect(got).To(Equal(ids)) - }) - - It("yields the fetch error and stops", func() { - ids := make([]string, artworkChunkSize+1) - calls := 0 - var errs []error - for _, err := range streamByIDs(ids, func(chunk []string) ([]string, error) { - calls++ - return nil, errors.New("boom") - }) { - errs = append(errs, err) - } - - Expect(calls).To(Equal(1)) - Expect(errs).To(HaveLen(1)) - Expect(errs[0]).To(MatchError("boom")) - }) - - It("stops fetching when the consumer breaks", func() { - ids := make([]string, artworkChunkSize+1) - calls := 0 - for range streamByIDs(ids, func(chunk []string) ([]string, error) { - calls++ - return chunk, nil - }) { - break - } - - Expect(calls).To(Equal(1)) - }) - }) - - Describe("applyItemImage", func() { - It("copies hash, absence, blurhash and dimensions onto the item", func() { - infos := map[string]model.ItemArtworkInfo{ - "al-1": {ItemID: "al-1", Hash: "0123456789abcdef", BlurHash: "LEHV6nWB2yk8", ThumbHash: "1QcSHQRn", Width: 1200, Height: 800}, - } - var img model.ItemImage - applyItemImage(infos, "al-1", &img) - Expect(img.ImageHash).To(Equal("0123456789abcdef")) - Expect(img.ImageAbsent).To(BeFalse()) - Expect(img.BlurHash).To(Equal("LEHV6nWB2yk8")) - Expect(img.ThumbHash).To(Equal("1QcSHQRn")) - Expect(img.ImageWidth).To(Equal(1200)) - Expect(img.ImageHeight).To(Equal(800)) - }) - - It("marks a hashless entry absent and carries no blurhash", func() { - infos := map[string]model.ItemArtworkInfo{"al-2": {ItemID: "al-2"}} - var img model.ItemImage - applyItemImage(infos, "al-2", &img) - Expect(img.ImageAbsent).To(BeTrue()) - Expect(img.BlurHash).To(BeEmpty()) - Expect(img.ThumbHash).To(BeEmpty()) - }) - - It("leaves an unresolved item zero-valued", func() { - var img model.ItemImage - applyItemImage(map[string]model.ItemArtworkInfo{}, "al-3", &img) - Expect(img.ImageHash).To(BeEmpty()) - Expect(img.ImageAbsent).To(BeFalse()) - Expect(img.BlurHash).To(BeEmpty()) - }) - }) -}) diff --git a/persistence/artwork_queue_repository.go b/persistence/artwork_queue_repository.go deleted file mode 100644 index 321fe8a95..000000000 --- a/persistence/artwork_queue_repository.go +++ /dev/null @@ -1,235 +0,0 @@ -package persistence - -import ( - "cmp" - "context" - "fmt" - "slices" - "strings" - "time" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "github.com/pocketbase/dbx" -) - -// Keeps each multi-row insert under SQLite's bind-variable limit (at most 7 vars per row). -const enqueueChunkSize = 100 - -// Every insert writes these, in this order; the INSERT..SELECT forms must project them to match. -// DequeueBatch also selects exactly these, to leave the drain's rows free of the trace it never reads. -var enqueueColumns = []string{"item_kind", "item_id", "image_type", "priority", "attempts", "retry_at", "enqueued_at"} - -type artworkQueueRepository struct { - sqlRepository -} - -func NewArtworkQueueRepository(db dbx.Builder) model.ArtworkQueueRepository { - r := &artworkQueueRepository{} - r.db = db - r.tableName = "artwork_queue" - return r -} - -func (r *artworkQueueRepository) Get(ctx context.Context, kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) { - var res model.ArtworkQueueItem - err := r.queryOne(ctx, Select("*").From(r.tableName). - Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}), &res) - if err != nil { - return nil, err - } - return &res, nil -} - -// Enqueue starts a fresh lifecycle: it resets enqueued_at (so a fresh request does not inherit an old -// row's spent retry budget) and clears trace (so explain does not show a prior failure at attempts 0). -func (r *artworkQueueRepository) Enqueue(ctx context.Context, items ...model.ArtworkQueueItem) error { - return r.enqueue(ctx, `ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET - priority = MAX(priority, excluded.priority), retry_at = excluded.retry_at, - attempts = 0, enqueued_at = excluded.enqueued_at, trace = '[]'`, items) -} - -func (r *artworkQueueRepository) EnqueuePreservingBackoff(ctx context.Context, items ...model.ArtworkQueueItem) error { - return r.enqueue(ctx, `ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET - priority = MAX(priority, excluded.priority)`, items) -} - -func (r *artworkQueueRepository) EnqueueAllMissing(ctx context.Context, kind model.Kind, priority int) (int64, error) { - entityTable, ok := artworkOwnerTables[kind] - if !ok { - return 0, fmt.Errorf("artwork queue: no entity table for kind %q", kind.Prefix()) - } - now := time.Now() - return r.insertIfNotQueued(ctx, "", `SELECT ?, id, ?, ?, 0, ?, ? - FROM `+entityTable+` - WHERE id NOT IN (SELECT item_id FROM `+itemArtworkTable+` WHERE item_kind = ?)`, - kind.Prefix(), model.ImageTypePrimary, priority, now, now, kind.Prefix()) -} - -func (r *artworkQueueRepository) EnqueueIfMissing(ctx context.Context, items ...model.ArtworkQueueItem) error { - now := time.Now() - for chunk := range slices.Chunk(items, enqueueChunkSize) { - rows := make([]string, 0, len(chunk)) - args := make([]any, 0, len(chunk)*4+2) - for _, it := range chunk { - rows = append(rows, "(?,?,?,?)") - args = append(args, it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary), it.Priority) - } - args = append(args, now, now) - _, err := r.insertIfNotQueued(ctx, - `WITH new_items(item_kind, item_id, image_type, priority) AS (VALUES `+strings.Join(rows, ",")+`) `, - `SELECT n.item_kind, n.item_id, n.image_type, n.priority, 0, ?, ? - FROM new_items n - WHERE NOT EXISTS ( - SELECT 1 FROM `+itemArtworkTable+` ia - WHERE ia.item_kind = n.item_kind AND ia.item_id = n.item_id AND ia.image_type = n.image_type)`, - args...) - if err != nil { - return err - } - } - return nil -} - -// DO NOTHING is deliberate: a recheck must not bump the priority or retry_at of an already-queued item. -const skipIfQueued = ` ON CONFLICT (item_kind, item_id, image_type) DO NOTHING` - -// insertIfNotQueued inserts the rows selected by the given SQL, optionally prefixed by a CTE. -func (r *artworkQueueRepository) insertIfNotQueued(ctx context.Context, with, sql string, args ...any) (int64, error) { - return r.executeSQL(ctx, Expr(with+`INSERT INTO `+r.tableName+ - ` (`+strings.Join(enqueueColumns, ", ")+`) `+sql+skipIfQueued, args...)) -} - -// artworkSourceFilter selects item_artwork rows of a kind; no sources means every source, "" the -// absent state, and ArtworkSourceFailed the absent states that gave up. Several are a union, so -// asking for both absent and failed is just absent. -func artworkSourceFilter(kind model.Kind, sources []string) Sqlizer { - f := And{Eq{"item_kind": kind.Prefix()}} - if len(sources) == 0 { - return f - } - stored := slices.DeleteFunc(slices.Clone(sources), func(s string) bool { return s == model.ArtworkSourceFailed }) - var match Or - if len(stored) > 0 { - match = append(match, Eq{"source": stored}) - } - if len(stored) != len(sources) { - match = append(match, And{Eq{"hash": ""}, NotEq{"last_failure": ""}}) - } - return append(f, match) -} - -func (r *artworkQueueRepository) CountBySource(ctx context.Context, kind model.Kind, sources []string) (int64, error) { - var res struct{ Count int64 } - err := r.queryOne(ctx, Select("count(*) as count").From(itemArtworkTable). - Where(artworkSourceFilter(kind, sources)), &res) - return res.Count, err -} - -func (r *artworkQueueRepository) SourcesInUse(ctx context.Context, kind model.Kind) ([]string, error) { - var res []struct{ Source string } - err := r.queryAll(ctx, Select("distinct source").From(itemArtworkTable). - Where(Eq{"item_kind": kind.Prefix()}), &res) - if err != nil { - return nil, err - } - return slice.Map(res, func(s struct{ Source string }) string { return s.Source }), nil -} - -// EnqueueBySource deliberately leaves item_artwork alone: clearing state in bulk would blank the -// library's artwork until every item is resolved again. -func (r *artworkQueueRepository) EnqueueBySource(ctx context.Context, kind model.Kind, sources []string, priority int) (int64, error) { - now := time.Now() - sel := Select("item_kind", "item_id", "image_type"). - Column(Expr("?", priority)).Column("0").Column(Expr("?", now)).Column(Expr("?", now)). - From(itemArtworkTable).Where(artworkSourceFilter(kind, sources)) - return r.executeSQL(ctx, Insert(r.tableName).Columns(enqueueColumns...).Select(sel).Suffix(skipIfQueued)) -} - -func (r *artworkQueueRepository) enqueue(ctx context.Context, conflict string, items []model.ArtworkQueueItem) error { - now := time.Now() - for chunk := range slices.Chunk(items, enqueueChunkSize) { - ins := Insert(r.tableName).Columns(enqueueColumns...) - for _, it := range chunk { - ins = ins.Values(it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary), it.Priority, 0, now, now) - } - ins = ins.Suffix(conflict) - if _, err := r.executeSQL(ctx, ins); err != nil { - return err - } - } - return nil -} - -func (r *artworkQueueRepository) DequeueBatch(ctx context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) { - sel := Select(enqueueColumns...).From(r.tableName). - Where(LtOrEq{"retry_at": time.Now()}). - OrderBy("priority DESC", "enqueued_at ASC"). - Limit(uint64(n)) - if len(kinds) > 0 { - sel = sel.Where(Eq{"item_kind": kinds}) - } - var res []model.ArtworkQueueItem - err := r.queryAll(ctx, sel, &res) - return res, err -} - -func (r *artworkQueueRepository) MarkFailedIfUnchanged(ctx context.Context, kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error { - upd := Update(r.tableName). - Set("attempts", Expr("attempts + 1")). - Set("retry_at", retryAt). - Set("trace", trace). - Where(Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": seenRetryAt}) - _, err := r.executeSQL(ctx, upd) - return err -} - -func (r *artworkQueueRepository) DeleteIfUnchanged(ctx context.Context, kind, id, imageType string, retryAt time.Time) error { - return r.delete(ctx, Eq{"item_kind": kind, "item_id": id, "image_type": imageType, "retry_at": retryAt}) -} - -func (r *artworkQueueRepository) PurgeDangling(ctx context.Context) (int64, error) { - return purgeDangling(ctx, r.sqlRepository) -} - -// artworkQueueFilter returns no conditions for an empty filter, so an unfiltered DELETE keeps -// SQLite's truncate path. It ignores retry_at: a backing-off row is pending work too. -func artworkQueueFilter(kinds []model.Kind, priorities []int) And { - var f And - if len(kinds) > 0 { - f = append(f, Eq{"item_kind": model.KindPrefixes(kinds)}) - } - if len(priorities) > 0 { - f = append(f, Eq{"priority": priorities}) - } - return f -} - -// CountQueued shares its filter with PurgeQueued, so a preview cannot count rows the delete misses. -func (r *artworkQueueRepository) CountQueued(ctx context.Context, kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) { - sel := Select("item_kind", "priority", "count(*) as count").From(r.tableName). - GroupBy("item_kind", "priority").OrderBy("item_kind", "priority desc") - if f := artworkQueueFilter(kinds, priorities); len(f) > 0 { - sel = sel.Where(f) - } - var res []model.ArtworkQueueStat - err := r.queryAll(ctx, sel, &res) - return res, err -} - -func (r *artworkQueueRepository) PurgeQueued(ctx context.Context, kinds []model.Kind, priorities []int) (int64, error) { - del := Delete(r.tableName) - if f := artworkQueueFilter(kinds, priorities); len(f) > 0 { - del = del.Where(f) - } - return r.executeSQL(ctx, del) -} - -func (r *artworkQueueRepository) Count(ctx context.Context) (int64, error) { - var res struct{ Count int64 } - err := r.queryOne(ctx, Select("count(*) as count").From(r.tableName), &res) - return res.Count, err -} - -var _ model.ArtworkQueueRepository = (*artworkQueueRepository)(nil) diff --git a/persistence/artwork_queue_repository_test.go b/persistence/artwork_queue_repository_test.go deleted file mode 100644 index 6ce4be8e2..000000000 --- a/persistence/artwork_queue_repository_test.go +++ /dev/null @@ -1,516 +0,0 @@ -package persistence - -import ( - "context" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" -) - -var _ = Describe("ArtworkQueueRepository", func() { - var repo model.ArtworkQueueRepository - var ctx context.Context - - item := func(kind, id string, prio int) model.ArtworkQueueItem { - return model.ArtworkQueueItem{ItemKind: kind, ItemID: id, - ImageType: model.ImageTypePrimary, Priority: prio} - } - - // Writes the post-failure state directly: the production path, MarkFailedIfUnchanged, needs - // a retry_at only a dequeue can hand it. - backOff := func(kind, id string, retryAt time.Time) { - GinkgoHelper() - r := repo.(*artworkQueueRepository) - _, err := r.executeSQL(ctx, squirrel.Update(r.tableName). - Set("attempts", squirrel.Expr("attempts + 1")). - Set("retry_at", retryAt). - Where(squirrel.Eq{"item_kind": kind, "item_id": id, "image_type": model.ImageTypePrimary})) - Expect(err).ToNot(HaveOccurred()) - } - - remove := func(kind, id string) { - GinkgoHelper() - r := repo.(*artworkQueueRepository) - Expect(r.delete(ctx, squirrel.Eq{"item_kind": kind, "item_id": id, "image_type": model.ImageTypePrimary})).To(Succeed()) - } - - BeforeEach(func() { - ctx = GinkgoT().Context() - clearArtworkTables() - DeferCleanup(clearArtworkTables) - repo = NewArtworkQueueRepository(GetDBXBuilder()) - }) - - It("enqueues and dequeues by priority then FIFO", func() { - Expect(repo.Enqueue(ctx, item("al", "low", model.ArtworkPriorityBackfill))).To(Succeed()) - Expect(repo.Enqueue(ctx, item("ar", "high", model.ArtworkPriorityBump))).To(Succeed()) - - got, err := repo.DequeueBatch(ctx, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(HaveLen(2)) - Expect(got[0].ItemID).To(Equal("high")) - }) - - It("Get returns a queued row, including one still backing off", func() { - Expect(repo.Enqueue(ctx, item("ar", "g1", model.ArtworkPriorityScan))).To(Succeed()) - backOff("ar", "g1", time.Now().Add(time.Hour)) - - got, err := repo.Get(ctx, model.KindArtistArtwork, "g1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Priority).To(Equal(model.ArtworkPriorityScan)) - Expect(got.Attempts).To(Equal(1)) - Expect(got.RetryAt).To(BeTemporally(">", time.Now())) - }) - - It("Get reports ErrNotFound when the item is not queued", func() { - _, err := repo.Get(ctx, model.KindArtistArtwork, "nope", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("keeps the higher priority on duplicate enqueue", func() { - Expect(repo.Enqueue(ctx, item("al", "a1", model.ArtworkPriorityBump))).To(Succeed()) - Expect(repo.Enqueue(ctx, item("al", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) - got, _ := repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1)) - Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump)) - }) - - It("EnqueuePreservingBackoff raises priority without resetting a backing-off row's retry_at", func() { - Expect(repo.Enqueue(ctx, item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) - backOff("al", "b1", time.Now().Add(time.Hour)) - Expect(repo.DequeueBatch(ctx, 10)).To(BeEmpty()) - - Expect(repo.EnqueuePreservingBackoff(ctx, item("al", "b1", model.ArtworkPriorityBump))).To(Succeed()) - Expect(repo.DequeueBatch(ctx, 10)).To(BeEmpty(), "bump must not reset retry_at") - - // Enqueue (scan/manual), by contrast, resets retry_at and makes it eligible now. - Expect(repo.Enqueue(ctx, item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) - got, _ := repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1)) - Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "bump's higher priority is preserved") - }) - - It("EnqueuePreservingBackoff inserts a brand-new row eligible immediately", func() { - Expect(repo.EnqueuePreservingBackoff(ctx, item("ar", "n1", model.ArtworkPriorityBump))).To(Succeed()) - got, _ := repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1)) - Expect(got[0].ItemID).To(Equal("n1")) - }) - - It("hides failed items until retry_at", func() { - Expect(repo.Enqueue(ctx, item("al", "f1", model.ArtworkPriorityScan))).To(Succeed()) - backOff("al", "f1", time.Now().Add(time.Hour)) - - got, err := repo.DequeueBatch(ctx, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(BeEmpty()) - - backOff("al", "f1", time.Now().Add(-time.Minute)) - got, _ = repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1)) - Expect(got[0].Attempts).To(Equal(2)) - }) - - It("MarkFailedIfUnchanged applies backoff only while retry_at is unchanged", func() { - Expect(repo.Enqueue(ctx, item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) - // Anchor retry_at in the past so it can never collide with the re-enqueue's now. - backOff("al", "m1", time.Now().Add(-time.Hour)) - got, err := repo.DequeueBatch(ctx, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(HaveLen(1)) - original := got[0].RetryAt - - // A concurrent scan re-enqueues, resetting retry_at to now. - Expect(repo.Enqueue(ctx, item("al", "m1", model.ArtworkPriorityScan))).To(Succeed()) - - future := time.Now().Add(48 * time.Hour) - Expect(repo.MarkFailedIfUnchanged(ctx, "al", "m1", model.ImageTypePrimary, original, future, "[]")).To(Succeed()) - got, _ = repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1), "the fresh re-enqueue stays immediately eligible") - Expect(got[0].Attempts).To(BeZero(), "re-enqueue clears attempts, and the stale failure must not bump them") - current := got[0].RetryAt - - Expect(repo.MarkFailedIfUnchanged(ctx, "al", "m1", model.ImageTypePrimary, current, future, `[{"c":"read","o":"error"}]`)).To(Succeed()) - got, _ = repo.DequeueBatch(ctx, 10) - Expect(got).To(BeEmpty(), "backed-off row is hidden until the future retry_at") - all, _ := repo.Count(ctx) - Expect(all).To(Equal(int64(1))) - }) - - It("Enqueue clears a prior lifecycle's failure trace; EnqueuePreservingBackoff keeps it", func() { - // Fail an attempt so the queue row carries a failure trace. - Expect(repo.Enqueue(ctx, item("al", "t1", model.ArtworkPriorityScan))).To(Succeed()) - backOff("al", "t1", time.Now().Add(-time.Hour)) - got, _ := repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1)) - future := time.Now().Add(48 * time.Hour) - Expect(repo.MarkFailedIfUnchanged(ctx, "al", "t1", model.ImageTypePrimary, got[0].RetryAt, future, `[{"c":"read","o":"error"}]`)).To(Succeed()) - - // A continuation of the same lifecycle must retain the trace. - Expect(repo.EnqueuePreservingBackoff(ctx, item("al", "t1", model.ArtworkPriorityBump))).To(Succeed()) - kept, err := repo.Get(ctx, model.KindAlbumArtwork, "t1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(kept.Trace).To(Equal(`[{"c":"read","o":"error"}]`)) - - // A fresh Enqueue resets attempts to 0, so the stale failure trace must be cleared with it. - Expect(repo.Enqueue(ctx, item("al", "t1", model.ArtworkPriorityScan))).To(Succeed()) - fresh, err := repo.Get(ctx, model.KindAlbumArtwork, "t1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(fresh.Attempts).To(BeZero()) - Expect(fresh.Trace).To(Equal("[]"), "a fresh lifecycle has no last-attempt trace") - }) - - It("Enqueue restarts the retry budget an existing row had spent", func() { - Expect(repo.Enqueue(ctx, item("al", "e1", model.ArtworkPriorityScan))).To(Succeed()) - backOff("al", "e1", time.Now().Add(-time.Hour)) - stale := time.Now().Add(-48 * time.Hour) - _, err := GetDBXBuilder().NewQuery("UPDATE artwork_queue SET enqueued_at = {:t} WHERE item_id = 'e1'"). - Bind(dbx.Params{"t": stale}).Execute() - Expect(err).ToNot(HaveOccurred()) - - Expect(repo.Enqueue(ctx, item("al", "e1", model.ArtworkPriorityScan))).To(Succeed()) - - got, err := repo.DequeueBatch(ctx, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(HaveLen(1)) - Expect(got[0].EnqueuedAt).To(BeTemporally("~", time.Now(), time.Minute), - "a re-request must not inherit a spent 12h window and give up on its first attempt") - Expect(got[0].Attempts).To(BeZero()) - }) - - It("deletes on completion and counts", func() { - Expect(repo.Enqueue(ctx, item("al", "c1", 0))).To(Succeed()) - n, _ := repo.Count(ctx) - Expect(n).To(Equal(int64(1))) - remove("al", "c1") - n, _ = repo.Count(ctx) - Expect(n).To(BeZero()) - }) - - It("DeleteIfUnchanged deletes only while retry_at is unchanged", func() { - Expect(repo.Enqueue(ctx, item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) - // Anchor retry_at in the past so it can never collide with the re-enqueue's now. - backOff("al", "d1", time.Now().Add(-time.Hour)) - got, err := repo.DequeueBatch(ctx, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(HaveLen(1)) - original := got[0].RetryAt - - // A concurrent scan re-enqueues, resetting retry_at to now. - Expect(repo.Enqueue(ctx, item("al", "d1", model.ArtworkPriorityScan))).To(Succeed()) - - // Deleting with the stale retry_at is a no-op: the re-enqueued row survives. - Expect(repo.DeleteIfUnchanged(ctx, "al", "d1", model.ImageTypePrimary, original)).To(Succeed()) - n, _ := repo.Count(ctx) - Expect(n).To(Equal(int64(1))) - - got, _ = repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1)) - Expect(repo.DeleteIfUnchanged(ctx, "al", "d1", model.ImageTypePrimary, got[0].RetryAt)).To(Succeed()) - n, _ = repo.Count(ctx) - Expect(n).To(BeZero()) - }) - - It("purges queue rows whose entity no longer exists, per kind", func() { - Expect(repo.Enqueue(ctx, - item("al", albumSgtPeppers.ID, model.ArtworkPriorityScan), - item("al", "no-such-album", model.ArtworkPriorityScan), - item("ar", artistKraftwerk.ID, model.ArtworkPriorityScan), - item("ar", "no-such-artist", model.ArtworkPriorityScan), - item("pl", plsBest.ID, model.ArtworkPriorityScan), - item("pl", "no-such-playlist", model.ArtworkPriorityScan), - item("ra", radioWithHomePage.ID, model.ArtworkPriorityScan), - item("ra", "no-such-radio", model.ArtworkPriorityScan), - item("mf", songDayInALife.ID, model.ArtworkPriorityScan), - item("mf", "no-such-mediafile", model.ArtworkPriorityScan), - )).To(Succeed()) - - purged, err := repo.PurgeDangling(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(purged).To(Equal(int64(5))) - - got, _ := repo.DequeueBatch(ctx, 100) - ids := slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) - Expect(ids).To(ConsistOf(albumSgtPeppers.ID, artistKraftwerk.ID, plsBest.ID, radioWithHomePage.ID, songDayInALife.ID)) - }) - - It("enqueues entities that have no item_artwork row at all", func() { - awRepo := NewArtworkRepository(GetDBXBuilder()) - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: albumAbbeyRoad.ID, ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) - - n, err := repo.EnqueueAllMissing(ctx, model.KindAlbumArtwork, model.ArtworkPriorityRecheck) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(BeNumerically(">=", 1)) - - got, err := repo.DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - ids := make([]string, 0, len(got)) - for _, it := range got { - Expect(it.ItemKind).To(Equal("al")) - Expect(it.Priority).To(Equal(model.ArtworkPriorityRecheck)) - ids = append(ids, it.ItemID) - } - Expect(ids).To(ContainElement(albumRadioactivity.ID), "an album with no row must be enqueued") - Expect(ids).ToNot(ContainElement(albumSgtPeppers.ID), "a resolved album must not be re-enqueued") - Expect(ids).ToNot(ContainElement(albumAbbeyRoad.ID), "an absent-state album must not be enqueued as missing") - }) - - It("EnqueueIfMissing skips items that already have an item_artwork row", func() { - awRepo := NewArtworkRepository(GetDBXBuilder()) - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "resolved", ImageType: model.ImageTypePrimary, Hash: "hX", AttemptedAt: time.Now()})).To(Succeed()) - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "absent", ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) - - Expect(repo.EnqueueIfMissing(ctx, - item("al", "resolved", model.ArtworkPriorityScan), - item("al", "absent", model.ArtworkPriorityScan), - item("al", "brandnew", model.ArtworkPriorityScan), - )).To(Succeed()) - - got, err := repo.DequeueBatch(ctx, 100) - Expect(err).ToNot(HaveOccurred()) - ids := slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) - Expect(ids).To(ConsistOf("brandnew"), "only an item with no state row may be enqueued") - }) - - It("EnqueueIfMissing leaves an already-queued row untouched", func() { - Expect(repo.Enqueue(ctx, item("al", "queued", model.ArtworkPriorityBump))).To(Succeed()) - - Expect(repo.EnqueueIfMissing(ctx, item("al", "queued", model.ArtworkPriorityScan))).To(Succeed()) - - got, _ := repo.DequeueBatch(ctx, 100) - Expect(got).To(HaveLen(1)) - Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump), "the existing priority must survive") - }) - - Describe("EnqueueBySource", func() { - BeforeEach(func() { - artRepo := NewArtworkRepository(GetDBXBuilder()) - for _, ia := range []model.ItemArtwork{ - {ItemKind: "ar", ItemID: "ar1", ImageType: model.ImageTypePrimary, Hash: "h1", Source: "external:deezer"}, - {ItemKind: "ar", ItemID: "ar2", ImageType: model.ImageTypePrimary, Hash: "h2", Source: "external:lastfm"}, - {ItemKind: "ar", ItemID: "ar3", ImageType: model.ImageTypePrimary, Hash: "", Source: ""}, - {ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, Hash: "h4", Source: "external:deezer"}, - } { - Expect(artRepo.PutItemArtwork(ctx, &ia)).To(Succeed()) - } - }) - - It("enqueues only the matching source within the kind", func() { - n, err := repo.EnqueueBySource(ctx, model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(int64(1)), "al1 is a different kind and must not be touched") - - got, err := repo.DequeueBatch(ctx, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar1")) - }) - - It("treats the empty source as absent", func() { - n, err := repo.EnqueueBySource(ctx, model.KindArtistArtwork, []string{""}, model.ArtworkPriorityRecheck) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(int64(1))) - - got, _ := repo.DequeueBatch(ctx, 10) - Expect(slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID })).To(ConsistOf("ar3")) - }) - - It("enqueues every source when none is given", func() { - n, err := repo.EnqueueBySource(ctx, model.KindArtistArtwork, nil, model.ArtworkPriorityRecheck) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(int64(3))) - }) - - It("leaves the current artwork state in place", func() { - _, err := repo.EnqueueBySource(ctx, model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) - Expect(err).ToNot(HaveOccurred()) - - artRepo := NewArtworkRepository(GetDBXBuilder()) - ia, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Hash).To(Equal("h1"), "the current image must survive until it is replaced") - Expect(ia.Source).To(Equal("external:deezer")) - }) - - It("does not disturb an already-queued row", func() { - Expect(repo.Enqueue(ctx, item("ar", "ar1", model.ArtworkPriorityBump))).To(Succeed()) - - n, err := repo.EnqueueBySource(ctx, model.KindArtistArtwork, []string{"external:deezer"}, model.ArtworkPriorityRecheck) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(BeZero()) - - got, _ := repo.DequeueBatch(ctx, 10) - Expect(got).To(HaveLen(1)) - Expect(got[0].Priority).To(Equal(model.ArtworkPriorityBump)) - }) - - It("counts without enqueueing", func() { - n, err := repo.CountBySource(ctx, model.KindArtistArtwork, []string{"external:deezer"}) - Expect(err).ToNot(HaveOccurred()) - Expect(n).To(Equal(int64(1))) - - queued, err := repo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).To(BeZero(), "CountBySource must not enqueue") - }) - - It("counts the absent source and every source", func() { - Expect(repo.CountBySource(ctx, model.KindArtistArtwork, []string{""})).To(Equal(int64(1))) - Expect(repo.CountBySource(ctx, model.KindArtistArtwork, nil)).To(Equal(int64(3))) - }) - - It("lists the distinct sources in use by a kind", func() { - Expect(repo.SourcesInUse(ctx, model.KindArtistArtwork)).To(ConsistOf("", "external:deezer", "external:lastfm")) - Expect(repo.SourcesInUse(ctx, model.KindAlbumArtwork)).To(ConsistOf("external:deezer")) - Expect(repo.SourcesInUse(ctx, model.KindRadioArtwork)).To(BeEmpty()) - }) - }) - - It("does not disturb an already-queued entity when enqueueing missing rows", func() { - Expect(repo.Enqueue(ctx, item("al", albumRadioactivity.ID, model.ArtworkPriorityBump))).To(Succeed()) - - _, err := repo.EnqueueAllMissing(ctx, model.KindAlbumArtwork, model.ArtworkPriorityRecheck) - Expect(err).ToNot(HaveOccurred()) - - got, _ := repo.DequeueBatch(ctx, 1000) - var count int - for _, it := range got { - if it.ItemID == albumRadioactivity.ID { - count++ - Expect(it.Priority).To(Equal(model.ArtworkPriorityBump), "existing bump priority must survive") - } - } - Expect(count).To(Equal(1), "the already-queued row must not be duplicated") - }) - - Describe("status counters", func() { - It("groups queue rows by kind and priority", func() { - Expect(repo.Enqueue(ctx, item("ar", "a1", model.ArtworkPriorityBackfill))).To(Succeed()) - Expect(repo.Enqueue(ctx, item("ar", "a2", model.ArtworkPriorityBackfill))).To(Succeed()) - Expect(repo.Enqueue(ctx, item("ar", "a3", model.ArtworkPriorityBump))).To(Succeed()) - Expect(repo.Enqueue(ctx, item("al", "b1", model.ArtworkPriorityScan))).To(Succeed()) - - Expect(repo.CountQueued(ctx, nil, nil)).To(ConsistOf( - model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBackfill, Count: 2}, - model.ArtworkQueueStat{ItemKind: "ar", Priority: model.ArtworkPriorityBump, Count: 1}, - model.ArtworkQueueStat{ItemKind: "al", Priority: model.ArtworkPriorityScan, Count: 1}, - )) - }) - - It("selects only the absent states that gave up, not those a source answered", func() { - awRepo := NewArtworkRepository(GetDBXBuilder()) - for _, ia := range []model.ItemArtwork{ - {ItemKind: "ar", ItemID: "gaveup", ImageType: model.ImageTypePrimary, LastFailure: "[]"}, - {ItemKind: "ar", ItemID: "toldno", ImageType: model.ImageTypePrimary}, - {ItemKind: "ar", ItemID: "hasart", ImageType: model.ImageTypePrimary, Hash: "hX", LastFailure: "[]"}, - } { - Expect(awRepo.PutItemArtwork(ctx, &ia)).To(Succeed()) - } - - Expect(repo.CountBySource(ctx, model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(1)), - "an item still serving art is not absent, however its last attempt went") - - // A later success rewrites the row, clearing the record. - Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "gaveup", - ImageType: model.ImageTypePrimary, Hash: "hZ"})).To(Succeed()) - Expect(repo.CountBySource(ctx, model.KindArtistArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) - }) - - It("unions the failed pseudo-source with a real one, so absent plus failed is just absent", func() { - awRepo := NewArtworkRepository(GetDBXBuilder()) - for _, ia := range []model.ItemArtwork{ - {ItemKind: "ar", ItemID: "gaveup", ImageType: model.ImageTypePrimary, LastFailure: "[]"}, - {ItemKind: "ar", ItemID: "toldno", ImageType: model.ImageTypePrimary}, - {ItemKind: "ar", ItemID: "folder", ImageType: model.ImageTypePrimary, Hash: "hX", Source: "folder"}, - } { - Expect(awRepo.PutItemArtwork(ctx, &ia)).To(Succeed()) - } - failedAndAbsent := []string{model.ArtworkSourceFailed, ""} - Expect(repo.CountBySource(ctx, model.KindArtistArtwork, failedAndAbsent)).To(Equal(int64(2)), - "failed is a subset of absent, so asking for both is asking for absent") - Expect(repo.CountBySource(ctx, model.KindArtistArtwork, []string{model.ArtworkSourceFailed, "folder"})). - To(Equal(int64(2)), "a pseudo-source and a stored source combine as a union") - }) - - It("reports a kind with nothing failed as zero", func() { - Expect(repo.CountBySource(ctx, model.KindRadioArtwork, []string{model.ArtworkSourceFailed})).To(Equal(int64(0))) - }) - - It("reports an empty queue as no rows", func() { - Expect(repo.CountQueued(ctx, nil, nil)).To(BeEmpty()) - }) - - }) - - Describe("PurgeQueued", func() { - queuedIDs := func() []string { - GinkgoHelper() - got, err := repo.DequeueBatch(ctx, 100) - Expect(err).ToNot(HaveOccurred()) - return slice.Map(got, func(it model.ArtworkQueueItem) string { return it.ItemID }) - } - - BeforeEach(func() { - Expect(repo.Enqueue(ctx, - item("ar", "ar-backfill", model.ArtworkPriorityBackfill), - item("ar", "ar-bump", model.ArtworkPriorityBump), - item("al", "al-backfill", model.ArtworkPriorityBackfill), - item("mf", "mf-scan", model.ArtworkPriorityScan), - )).To(Succeed()) - }) - - // CountQueued feeds the preview and PurgeQueued does the delete; they share one filter, so - // every selection must count exactly what it deletes. - DescribeTable("selects the same rows to count and to delete", - func(kinds []model.Kind, priorities []int, deleted int, remaining []string) { - counted, err := repo.CountQueued(ctx, kinds, priorities) - Expect(err).ToNot(HaveOccurred()) - var total int64 - for _, s := range counted { - total += s.Count - } - Expect(total).To(BeNumerically("==", deleted), "the preview must match the delete") - - Expect(repo.PurgeQueued(ctx, kinds, priorities)).To(BeNumerically("==", deleted)) - Expect(queuedIDs()).To(ConsistOf(remaining)) - }, - Entry("only the given kinds", []model.Kind{model.KindArtistArtwork}, nil, - 2, []string{"al-backfill", "mf-scan"}), - Entry("only the given priorities", nil, []int{model.ArtworkPriorityBackfill}, - 2, []string{"ar-bump", "mf-scan"}), - Entry("the intersection of both", []model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBackfill}, - 1, []string{"ar-bump", "al-backfill", "mf-scan"}), - Entry("everything, when neither filter is given", nil, nil, - 4, []string{}), - Entry("several kinds and priorities at once", - []model.Kind{model.KindArtistArtwork, model.KindMediaFileArtwork}, - []int{model.ArtworkPriorityBackfill, model.ArtworkPriorityScan}, - 2, []string{"ar-bump", "al-backfill"}), - Entry("nothing, leaving the queue alone", []model.Kind{model.KindPlaylistArtwork}, nil, - 0, []string{"ar-backfill", "ar-bump", "al-backfill", "mf-scan"}), - ) - - It("deletes a row that is still backing off", func() { - backOff("ar", "ar-bump", time.Now().Add(time.Hour)) - - Expect(repo.PurgeQueued(ctx, []model.Kind{model.KindArtistArtwork}, nil)).To(BeNumerically("==", 2)) - Expect(repo.Get(ctx, model.KindArtistArtwork, "ar-bump", model.ImageTypePrimary)). - Error().To(MatchError(model.ErrNotFound)) - }) - - // A WHERE clause, even one that matches everything, costs SQLite its truncate optimization - // and turns `artwork cancel --all` into a full scan of the queue. - It("adds no conditions at all for an empty filter", func() { - Expect(artworkQueueFilter(nil, nil)).To(BeEmpty()) - Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, nil)).To(HaveLen(1)) - Expect(artworkQueueFilter(nil, []int{model.ArtworkPriorityBump})).To(HaveLen(1)) - Expect(artworkQueueFilter([]model.Kind{model.KindArtistArtwork}, []int{model.ArtworkPriorityBump})). - To(HaveLen(2)) - }) - }) -}) diff --git a/persistence/artwork_repository.go b/persistence/artwork_repository.go deleted file mode 100644 index bd3a3d87c..000000000 --- a/persistence/artwork_repository.go +++ /dev/null @@ -1,184 +0,0 @@ -package persistence - -import ( - "cmp" - "context" - "slices" - "time" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - "github.com/pocketbase/dbx" -) - -const ( - itemArtworkTable = "item_artwork" - artworkBatchSize = 200 -) - -type artworkRepository struct { - sqlRepository - items sqlRepository -} - -func NewArtworkRepository(db dbx.Builder) model.ArtworkRepository { - r := &artworkRepository{} - r.db = db - r.tableName = "artwork" - r.items.db = db - r.items.tableName = itemArtworkTable - return r -} - -func (r *artworkRepository) GetImage(ctx context.Context, hash string) (*model.Artwork, error) { - sel := Select("*").From(r.tableName).Where(Eq{"hash": hash}) - var res model.Artwork - if err := r.queryOne(ctx, sel, &res); err != nil { - return nil, err - } - return &res, nil -} - -func (r *artworkRepository) PutImage(ctx context.Context, a *model.Artwork) error { - // created_at is the last-acquisition-write time the prune grace window keys on. - a.CreatedAt = time.Now() - values, err := toSQLArgs(*a) - if err != nil { - return err - } - // created_at=excluded.created_at: reacquiring an orphan must reset the prune grace window. - ins := Insert(r.tableName).SetMap(values).Suffix(`ON CONFLICT (hash) DO UPDATE SET mime=excluded.mime, width=excluded.width, - height=excluded.height, size_bytes=excluded.size_bytes, blur_hash=excluded.blur_hash, - thumb_hash=excluded.thumb_hash, dominant_color=excluded.dominant_color, - created_at=excluded.created_at`) - _, err = r.executeSQL(ctx, ins) - return err -} - -func (r *artworkRepository) GetMimeByHash(ctx context.Context) (map[string]string, error) { - sel := Select("hash", "mime").From(r.tableName) - var rows []struct { - Hash string - Mime string - } - if err := r.queryAll(ctx, sel, &rows); err != nil { - return nil, err - } - res := make(map[string]string, len(rows)) - for _, row := range rows { - res[row.Hash] = row.Mime - } - return res, nil -} - -func (r *artworkRepository) PurgeOrphans(ctx context.Context, createdBefore time.Time) (int64, error) { - del := Delete(r.tableName).Where(And{ - Lt{"created_at": createdBefore}, - Expr("hash NOT IN (SELECT hash FROM " + itemArtworkTable + " WHERE hash <> '')"), - }) - return r.executeSQL(ctx, del) -} - -// artworkOwnerTables maps an artwork kind to the table that owns the entity. -var artworkOwnerTables = map[model.Kind]string{ - model.KindAlbumArtwork: "album", - model.KindArtistArtwork: "artist", - model.KindPlaylistArtwork: "playlist", - model.KindRadioArtwork: "radio", - model.KindMediaFileArtwork: "media_file", -} - -// purgeDangling deletes rows in r's table whose owning entity is gone, one statement per kind. -func purgeDangling(ctx context.Context, r sqlRepository) (int64, error) { - var total int64 - for kind, entityTable := range artworkOwnerTables { - del := Delete(r.tableName).Where(And{ - Eq{"item_kind": kind.Prefix()}, - Expr("item_id NOT IN (SELECT id FROM " + entityTable + ")"), - }) - c, err := r.executeSQL(ctx, del) - if err != nil { - return total, err - } - total += c - } - return total, nil -} - -func (r *artworkRepository) PurgeDanglingItems(ctx context.Context) (int64, error) { - return purgeDangling(ctx, r.items) -} - -func (r *artworkRepository) GetItemArtwork(ctx context.Context, kind model.Kind, id, imageType string) (*model.ItemArtwork, error) { - sel := Select("*").From(itemArtworkTable). - Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}) - var res model.ItemArtwork - if err := r.items.queryOne(ctx, sel, &res); err != nil { - return nil, err - } - return &res, nil -} - -func (r *artworkRepository) PutItemArtwork(ctx context.Context, ia *model.ItemArtwork) error { - ia.ImageType = cmp.Or(ia.ImageType, model.ImageTypePrimary) - ia.UpdatedAt = time.Now() - // PutItemArtwork records the outcome of an attempt, so an unset attempted_at is now. - if ia.AttemptedAt.IsZero() { - ia.AttemptedAt = ia.UpdatedAt - } - values, err := toSQLArgs(*ia) - if err != nil { - return err - } - ins := Insert(itemArtworkTable).SetMap(values).Suffix(`ON CONFLICT (item_kind, item_id, image_type) DO UPDATE SET - hash=excluded.hash, source=excluded.source, source_path=excluded.source_path, ref_mtime=excluded.ref_mtime, - trace=excluded.trace, last_failure=excluded.last_failure, - attempted_at=excluded.attempted_at, updated_at=excluded.updated_at`) - _, err = r.items.executeSQL(ctx, ins) - return err -} - -// PutLastFailure records why an item exhausted its retry budget. It only updates an existing row: -// inserting one would write an empty hash, which the rest of the system reads as a settled absent. -func (r *artworkRepository) PutLastFailure(ctx context.Context, kind model.Kind, id, imageType, trace string) error { - upd := Update(itemArtworkTable).Set("last_failure", trace). - Where(Eq{"item_kind": kind.Prefix(), "item_id": id, "image_type": imageType}) - _, err := r.items.executeSQL(ctx, upd) - return err -} - -func (r *artworkRepository) DeleteForItems(ctx context.Context, kind model.Kind, ids []string) error { - for chunk := range slices.Chunk(ids, artworkBatchSize) { - if err := r.items.delete(ctx, Eq{"item_kind": kind.Prefix(), "item_id": chunk}); err != nil { - return err - } - } - return nil -} - -func (r *artworkRepository) GetInfoForItems(ctx context.Context, kind model.Kind, ids []string) (map[string]model.ItemArtworkInfo, error) { - res := map[string]model.ItemArtworkInfo{} - for chunk := range slices.Chunk(ids, artworkBatchSize) { - sel := Select("ia.item_id", "ia.hash", "COALESCE(a.blur_hash, '') as blur_hash", - "COALESCE(a.thumb_hash, '') as thumb_hash", - "COALESCE(a.dominant_color, '') as dominant_color", - "COALESCE(a.width, 0) as width", "COALESCE(a.height, 0) as height"). - From(itemArtworkTable + " ia"). - LeftJoin("artwork a ON a.hash = ia.hash"). - Where(And{ - Eq{"ia.item_kind": kind.Prefix()}, - Eq{"ia.image_type": model.ImageTypePrimary}, - Eq{"ia.item_id": chunk}, - }) - var rows []model.ItemArtworkInfo - if err := r.items.queryAll(ctx, sel, &rows); err != nil { - return nil, err - } - for _, row := range rows { - res[row.ItemID] = row - } - } - return res, nil -} - -var _ model.ArtworkRepository = (*artworkRepository)(nil) diff --git a/persistence/artwork_repository_test.go b/persistence/artwork_repository_test.go deleted file mode 100644 index 338a7c3ab..000000000 --- a/persistence/artwork_repository_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package persistence - -import ( - "context" - "fmt" - "time" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" -) - -func clearArtworkTables() { - db := GetDBXBuilder() - for _, t := range []string{"artwork_queue", "item_artwork", "artwork"} { - _, err := db.NewQuery("DELETE FROM " + t).Execute() - Expect(err).ToNot(HaveOccurred()) - } -} - -var _ = Describe("ArtworkRepository", func() { - var ctx context.Context - var repo model.ArtworkRepository - - BeforeEach(func() { - ctx = GinkgoT().Context() - clearArtworkTables() - DeferCleanup(clearArtworkTables) - repo = NewArtworkRepository(GetDBXBuilder()) - }) - - Context("resolution traces", func() { - const traceJSON = `[{"c":"cover.*","o":"hit"}]` - - It("round-trips the trace with the state row", func() { - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "t1", - ImageType: model.ImageTypePrimary, Hash: "h1", Trace: traceJSON})).To(Succeed()) - - got, err := repo.GetItemArtwork(ctx, model.KindAlbumArtwork, "t1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Trace).To(Equal(traceJSON)) - Expect(got.LastFailure).To(BeEmpty()) - }) - - It("replaces the trace when the item is resolved again", func() { - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "t2", - ImageType: model.ImageTypePrimary, Trace: traceJSON})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "t2", - ImageType: model.ImageTypePrimary, Trace: `[{"c":"embedded","o":"hit"}]`})).To(Succeed()) - - got, _ := repo.GetItemArtwork(ctx, model.KindAlbumArtwork, "t2", model.ImageTypePrimary) - Expect(got.Trace).To(Equal(`[{"c":"embedded","o":"hit"}]`)) - }) - - It("records a last failure on an existing row", func() { - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "t3", - ImageType: model.ImageTypePrimary, Hash: "h3"})).To(Succeed()) - - Expect(repo.PutLastFailure(ctx, model.KindAlbumArtwork, "t3", model.ImageTypePrimary, - `[{"c":"decode","o":"error"}]`)).To(Succeed()) - - got, _ := repo.GetItemArtwork(ctx, model.KindAlbumArtwork, "t3", model.ImageTypePrimary) - Expect(got.LastFailure).To(Equal(`[{"c":"decode","o":"error"}]`)) - Expect(got.Hash).To(Equal("h3"), "recording a failure must not disturb the served artwork") - }) - - // Inserting here would write hash='', which every reader treats as a settled absent. - It("never creates a row for an item that has no state", func() { - Expect(repo.PutLastFailure(ctx, model.KindAlbumArtwork, "ghost", model.ImageTypePrimary, - `[{"c":"decode","o":"error"}]`)).To(Succeed()) - - _, err := repo.GetItemArtwork(ctx, model.KindAlbumArtwork, "ghost", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Context("image identity", func() { - It("stores and retrieves an artwork by hash", func() { - a := &model.Artwork{Hash: "abc123", Mime: "image/jpeg", Width: 500, Height: 500, SizeBytes: 1234, BlurHash: "LKO2?U%2Tw=w"} - Expect(repo.PutImage(ctx, a)).To(Succeed()) - - got, err := repo.GetImage(ctx, "abc123") - Expect(err).ToNot(HaveOccurred()) - Expect(got.Mime).To(Equal("image/jpeg")) - Expect(got.BlurHash).To(Equal("LKO2?U%2Tw=w")) - Expect(got.CreatedAt).ToNot(BeZero()) - }) - - It("round-trips the thumbhash alongside the blurhash", func() { - a := &model.Artwork{Hash: "both1", Mime: "image/jpeg", BlurHash: "LKO2?U%2Tw=w", - ThumbHash: "1QcSHQRnh493V4dIh4eXh1h4kJUI", DominantColor: "#336699"} - Expect(repo.PutImage(ctx, a)).To(Succeed()) - - got, err := repo.GetImage(ctx, "both1") - Expect(err).ToNot(HaveOccurred()) - Expect(got.BlurHash).To(Equal("LKO2?U%2Tw=w")) - Expect(got.ThumbHash).To(Equal("1QcSHQRnh493V4dIh4eXh1h4kJUI")) - Expect(got.DominantColor).To(Equal("#336699")) - }) - - It("overwrites the thumbhash on re-acquisition", func() { - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "th2", Mime: "image/png", ThumbHash: "first", DominantColor: "#111111"})).To(Succeed()) - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "th2", Mime: "image/png", ThumbHash: "second", DominantColor: "#222222"})).To(Succeed()) - - got, err := repo.GetImage(ctx, "th2") - Expect(err).ToNot(HaveOccurred()) - Expect(got.ThumbHash).To(Equal("second")) - Expect(got.DominantColor).To(Equal("#222222")) - }) - - It("is idempotent on Put (upsert by hash)", func() { - a := &model.Artwork{Hash: "dup1", Mime: "image/png"} - Expect(repo.PutImage(ctx, a)).To(Succeed()) - a.BlurHash = "XYZ" - Expect(repo.PutImage(ctx, a)).To(Succeed()) - got, _ := repo.GetImage(ctx, "dup1") - Expect(got.BlurHash).To(Equal("XYZ")) - }) - - It("refreshes created_at when reacquiring an existing hash", func() { - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "reacq", Mime: "image/jpeg"})).To(Succeed()) - _, err := GetDBXBuilder().NewQuery("UPDATE artwork SET created_at={:t} WHERE hash='reacq'"). - Bind(dbx.Params{"t": "2000-01-01 00:00:00"}).Execute() - Expect(err).ToNot(HaveOccurred()) - - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "reacq", Mime: "image/png"})).To(Succeed()) - - got, err := repo.GetImage(ctx, "reacq") - Expect(err).ToNot(HaveOccurred()) - Expect(got.CreatedAt).To(BeTemporally(">", time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC))) - }) - - It("returns ErrNotFound for a missing hash", func() { - _, err := repo.GetImage(ctx, "nope") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("returns every stored hash with its current mime", func() { - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "all1", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "all2", Mime: "image/png"})).To(Succeed()) - mimes, err := repo.GetMimeByHash(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(mimes).To(HaveKeyWithValue("all1", "image/jpeg")) - Expect(mimes).To(HaveKeyWithValue("all2", "image/png")) - }) - - It("deletes only unreferenced rows older than the cutoff, reporting the count", func() { - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "d1", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "dref", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "a1", - ImageType: model.ImageTypePrimary, Hash: "dref", Source: "folder"})).To(Succeed()) - - Expect(repo.PurgeOrphans(ctx, time.Now().Add(time.Minute))).To(BeNumerically("==", 1)) - - _, err := repo.GetImage(ctx, "d1") - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = repo.GetImage(ctx, "dref") - Expect(err).ToNot(HaveOccurred()) - }) - - It("spares an unreferenced row younger than the cutoff", func() { - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "young", Mime: "image/jpeg"})).To(Succeed()) - Expect(repo.PurgeOrphans(ctx, time.Now().Add(-time.Hour))).To(BeNumerically("==", 0)) - _, err := repo.GetImage(ctx, "young") - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Context("dangling state cleanup", func() { - It("purges item_artwork rows per kind whose entity no longer exists, summing counts", func() { - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: albumSgtPeppers.ID, ImageType: model.ImageTypePrimary, Hash: "keepAl"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "no-such-album", ImageType: model.ImageTypePrimary, Hash: "danglingAl"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: artistKraftwerk.ID, ImageType: model.ImageTypePrimary, Hash: "keepAr"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "no-such-artist", ImageType: model.ImageTypePrimary, Hash: "danglingAr"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "pl", ItemID: plsBest.ID, ImageType: model.ImageTypePrimary, Hash: "keepPl"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "pl", ItemID: "no-such-playlist", ImageType: model.ImageTypePrimary, Hash: "danglingPl"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ra", ItemID: radioWithHomePage.ID, ImageType: model.ImageTypePrimary, Hash: "keepRa"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ra", ItemID: "no-such-radio", ImageType: model.ImageTypePrimary, Hash: "danglingRa"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: songDayInALife.ID, ImageType: model.ImageTypePrimary, Hash: "keepMf"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "no-such-mediafile", ImageType: model.ImageTypePrimary, Hash: "danglingMf"})).To(Succeed()) - - purged, err := repo.PurgeDanglingItems(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(purged).To(Equal(int64(5))) - - for _, kept := range []model.ItemArtwork{ - {ItemKind: "al", ItemID: albumSgtPeppers.ID}, - {ItemKind: "ar", ItemID: artistKraftwerk.ID}, - {ItemKind: "pl", ItemID: plsBest.ID}, - {ItemKind: "ra", ItemID: radioWithHomePage.ID}, - {ItemKind: "mf", ItemID: songDayInALife.ID}, - } { - k, _ := model.ParseKind(kept.ItemKind) - _, err := repo.GetItemArtwork(ctx, k, kept.ItemID, model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - } - for _, gone := range []model.ItemArtwork{ - {ItemKind: "al", ItemID: "no-such-album"}, - {ItemKind: "ar", ItemID: "no-such-artist"}, - {ItemKind: "pl", ItemID: "no-such-playlist"}, - {ItemKind: "ra", ItemID: "no-such-radio"}, - {ItemKind: "mf", ItemID: "no-such-mediafile"}, - } { - k, _ := model.ParseKind(gone.ItemKind) - _, err := repo.GetItemArtwork(ctx, k, gone.ItemID, model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - } - }) - }) - - Context("item state", func() { - It("upserts and reads state, including per-item provenance", func() { - ia := &model.ItemArtwork{ItemKind: "al", ItemID: "al1", ImageType: model.ImageTypePrimary, - Hash: "h1", Source: "folder", SourcePath: "/music/a/cover.jpg", RefMtime: 111, AttemptedAt: time.Now()} - Expect(repo.PutItemArtwork(ctx, ia)).To(Succeed()) - ia.Source = "embedded" - ia.SourcePath = "/music/a/track.mp3" - ia.RefMtime = 222 - Expect(repo.PutItemArtwork(ctx, ia)).To(Succeed()) - - got, err := repo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Source).To(Equal("embedded")) - Expect(got.SourcePath).To(Equal("/music/a/track.mp3")) - Expect(got.RefMtime).To(Equal(int64(222))) - Expect(got.UpdatedAt).ToNot(BeZero()) - }) - - It("defaults attempted_at to now when unset", func() { - before := time.Now().Add(-time.Second) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "noattempt", - ImageType: model.ImageTypePrimary, Hash: ""})).To(Succeed()) - got, err := repo.GetItemArtwork(ctx, model.KindArtistArtwork, "noattempt", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(got.AttemptedAt).To(BeTemporally(">", before)) - }) - - It("represents known-absent as empty hash", func() { - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "ar1", - ImageType: model.ImageTypePrimary, Hash: "", AttemptedAt: time.Now()})).To(Succeed()) - got, err := repo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Hash).To(BeEmpty()) - }) - - It("hydrates a page in one batch, including blurhash, dimensions and absence", func() { - Expect(repo.PutImage(ctx, &model.Artwork{Hash: "h9", Mime: "image/jpeg", BlurHash: "BH9", - DominantColor: "#abcdef", Width: 1200, Height: 800})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "x1", ImageType: model.ImageTypePrimary, Hash: "h9", Source: "folder"})).To(Succeed()) - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "x2", ImageType: model.ImageTypePrimary, Hash: "", Source: ""})).To(Succeed()) - - info, err := repo.GetInfoForItems(ctx, model.KindAlbumArtwork, []string{"x1", "x2", "x3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(info).To(HaveLen(2)) - Expect(info["x1"].Hash).To(Equal("h9")) - Expect(info["x1"].BlurHash).To(Equal("BH9")) - Expect(info["x1"].DominantColor).To(Equal("#abcdef")) - Expect(info["x1"].Width).To(Equal(1200)) - Expect(info["x1"].Height).To(Equal(800)) - Expect(info["x1"].Absent()).To(BeFalse()) - Expect(info["x2"].Absent()).To(BeTrue()) - _, unresolved := info["x3"] - Expect(unresolved).To(BeFalse()) - }) - - It("deletes all rows for a single item", func() { - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "pl", ItemID: "p1", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) - Expect(repo.DeleteForItems(ctx, model.KindPlaylistArtwork, []string{"p1"})).To(Succeed()) - _, err := repo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "p1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("deletes rows for many items in chunks, leaving others untouched", func() { - const n = artworkBatchSize + 5 - ids := make([]string, n) - for i := range n { - id := fmt.Sprintf("mf-%d", i) - ids[i] = id - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: id, ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) - } - Expect(repo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "keep", ImageType: model.ImageTypePrimary, Hash: "h1"})).To(Succeed()) - - Expect(repo.DeleteForItems(ctx, model.KindMediaFileArtwork, ids)).To(Succeed()) - - for _, id := range ids { - _, err := repo.GetItemArtwork(ctx, model.KindMediaFileArtwork, id, model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - } - kept, err := repo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "keep", model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(kept.ItemID).To(Equal("keep")) - }) - }) -}) diff --git a/persistence/collation_test.go b/persistence/collation_test.go deleted file mode 100644 index dff91148e..000000000 --- a/persistence/collation_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package persistence - -import ( - "database/sql" - "errors" - "fmt" - "regexp" - - "github.com/navidrome/navidrome/db" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// When creating migrations that change existing columns, it is easy to miss the original collation of a column. -// These tests enforce that the required collation of the columns and indexes in the database are kept in place. -// This is important to ensure that the database can perform fast case-insensitive searches and sorts. -var _ = Describe("Collation", func() { - conn := db.Db() - DescribeTable("Column collation", - func(table, column string) { - Expect(checkCollation(conn, table, column)).To(Succeed()) - }, - Entry("artist.order_artist_name", "artist", "order_artist_name"), - Entry("artist.sort_artist_name", "artist", "sort_artist_name"), - Entry("album.order_album_name", "album", "order_album_name"), - Entry("album.order_album_artist_name", "album", "order_album_artist_name"), - Entry("album.sort_album_name", "album", "sort_album_name"), - Entry("album.sort_album_artist_name", "album", "sort_album_artist_name"), - Entry("media_file.order_title", "media_file", "order_title"), - Entry("media_file.order_album_name", "media_file", "order_album_name"), - Entry("media_file.order_artist_name", "media_file", "order_artist_name"), - Entry("media_file.sort_title", "media_file", "sort_title"), - Entry("media_file.sort_album_name", "media_file", "sort_album_name"), - Entry("media_file.sort_artist_name", "media_file", "sort_artist_name"), - Entry("playlist.name", "playlist", "name"), - Entry("radio.name", "radio", "name"), - Entry("user.name", "user", "name"), - ) - - DescribeTable("Index collation", - func(table, column string) { - Expect(checkIndexUsage(conn, table, column)).To(Succeed()) - }, - Entry("artist.order_artist_name", "artist", "order_artist_name collate nocase"), - Entry("artist.sort_artist_name", "artist", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"), - Entry("album.order_album_name", "album", "order_album_name collate nocase"), - Entry("album.order_album_artist_name", "album", "order_album_artist_name collate nocase"), - Entry("album.sort_album_name", "album", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"), - Entry("album.sort_album_artist_name", "album", "coalesce(nullif(sort_album_artist_name,''),order_album_artist_name) collate nocase"), - Entry("media_file.order_title", "media_file", "order_title collate nocase"), - Entry("media_file.order_album_name", "media_file", "order_album_name collate nocase"), - Entry("media_file.order_artist_name", "media_file", "order_artist_name collate nocase"), - Entry("media_file.path", "media_file", "path collate nocase"), - Entry("playlist.name", "playlist", "name collate nocase"), - Entry("radio.name", "radio", "name collate nocase"), - Entry("user.user_name", "user", "user_name collate nocase"), - ) -}) - -func checkIndexUsage(conn *sql.DB, table string, column string) error { - rows, err := conn.Query(fmt.Sprintf(` -explain query plan select * from %[1]s -where %[2]s = 'test' -order by %[2]s`, table, column)) - if err != nil { - return err - } - defer rows.Close() - - err = rows.Err() - if err != nil { - return err - } - - if rows.Next() { - var dummy int - var detail string - err = rows.Scan(&dummy, &dummy, &dummy, &detail) - if err != nil { - return nil - } - if ok, _ := regexp.MatchString("SEARCH.*USING INDEX", detail); ok { - return nil - } else { - return fmt.Errorf("INDEX for '%s' not used: %s", column, detail) - } - } - return errors.New("no rows returned") -} - -func checkCollation(conn *sql.DB, table string, column string) error { - rows, err := conn.Query(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND tbl_name='%s'", table)) - if err != nil { - return err - } - defer rows.Close() - - err = rows.Err() - if err != nil { - return err - } - - if rows.Next() { - var res string - err = rows.Scan(&res) - if err != nil { - return err - } - re := regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*varchar`, column)) - if !re.MatchString(res) { - return fmt.Errorf("column '%s' not found in table '%s'", column, table) - } - re = regexp.MustCompile(fmt.Sprintf(`(?i)\b%s\b.*collate\s+NOCASE`, column)) - if re.MatchString(res) { - return nil - } - } else { - return fmt.Errorf("table '%s' not found", table) - } - return fmt.Errorf("column '%s' in table '%s' does not have NOCASE collation", column, table) -} diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go deleted file mode 100644 index d2f817438..000000000 --- a/persistence/criteria_sql.go +++ /dev/null @@ -1,904 +0,0 @@ -package persistence - -import ( - "errors" - "fmt" - "maps" - "reflect" - "slices" - "strconv" - "strings" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" - "golang.org/x/text/unicode/norm" -) - -type smartPlaylistJoinType int - -const ( - smartPlaylistJoinNone smartPlaylistJoinType = 0 - smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota - smartPlaylistJoinArtistAnnotation - smartPlaylistJoinAlbum -) - -func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool { - return j&other != 0 -} - -type smartPlaylistField struct { - expr string - order string - joinType smartPlaylistJoinType - emptyValues []string // additional values that encode "missing" for string columns (e.g. '[]' for lyrics) - coalesceDefault any // missing-row default for a nullable annotation column; nil = none. See annotationCond. -} - -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", emptyValues: []string{"[]"}}, - "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: "annotation.starred", coalesceDefault: false}, - "dateloved": {expr: "annotation.starred_at"}, - "lastplayed": {expr: "annotation.play_date"}, - "daterated": {expr: "annotation.rated_at"}, - "playcount": {expr: "annotation.play_count", coalesceDefault: 0}, - "rating": {expr: "annotation.rating", coalesceDefault: 0}, - "averagerating": {expr: "media_file.average_rating"}, - "albumrating": {expr: "album_annotation.rating", coalesceDefault: 0, joinType: smartPlaylistJoinAlbumAnnotation}, - "albumloved": {expr: "album_annotation.starred", coalesceDefault: false, joinType: smartPlaylistJoinAlbumAnnotation}, - "albumplaycount": {expr: "album_annotation.play_count", coalesceDefault: 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}, - "albumdateadded": {expr: "album.created_at", joinType: smartPlaylistJoinAlbum}, - "albumdatemodified": {expr: "album.updated_at", joinType: smartPlaylistJoinAlbum}, - "albumduration": {expr: "album.duration", joinType: smartPlaylistJoinAlbum}, - "albumsongcount": {expr: "album.song_count", joinType: smartPlaylistJoinAlbum}, - "albumsize": {expr: "album.size", joinType: smartPlaylistJoinAlbum}, - "artistrating": {expr: "artist_annotation.rating", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation}, - "artistloved": {expr: "artist_annotation.starred", coalesceDefault: false, joinType: smartPlaylistJoinArtistAnnotation}, - "artistplaycount": {expr: "artist_annotation.play_count", coalesceDefault: 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 mergeNegatedJsonConds(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 mergeJsonConds(or), nil - case criteria.Is: - return comparisonExpr(e, cmpEq) - case criteria.IsNot: - return isNotExpr(e) - case criteria.Gt: - return comparisonExpr(e, cmpGt) - case criteria.Lt: - return comparisonExpr(e, cmpLt) - case criteria.Before: - return comparisonExpr(e, cmpLt) - case criteria.After: - return comparisonExpr(e, cmpGt) - 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 - } - return comparisonExpr(values, cmpNe) -} - -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) - } - b, ok := value.(bool) - if !ok { - return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value) - } - negate := checkAbsence == b - - switch { - case info.IsTag || info.IsRole: - return jsonExpr(info, nil, negate), nil - case info.Nullable: - // Nullable column fields are stored in dedicated columns, not in the tags JSON, so - // "missing" maps to a column check rather than a json_tree lookup. Numeric/boolean - // columns (e.g. ReplayGain, BPM) encode absence as NULL only; string columns (e.g. - // mbz_* IDs, lyrics) additionally treat empty string — and any field-specific empty - // encodings (e.g. '[]' for lyrics) — as missing. The unified flow below handles both: - // numeric/boolean fields simply have no empties, so the loops are no-ops. - f, ok := smartPlaylistFields[info.Name()] - if !ok || f.expr == "" { - return nil, fmt.Errorf("invalid field in criteria: %s", field) - } - col := f.expr - var empties []string - if !info.Numeric && !info.Boolean { - empties = append([]string{""}, f.emptyValues...) - } - missing := squirrel.Or{squirrel.Eq{col: nil}} - present := squirrel.And{squirrel.NotEq{col: nil}} - for _, e := range empties { - missing = append(missing, squirrel.Eq{col: e}) - present = append(present, squirrel.NotEq{col: e}) - } - if negate { - return missing, nil - } - return present, nil - default: - return nil, fmt.Errorf("isMissing/isPresent operator is not supported for field: %s", field) - } -} - -func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqlizer, error) { - if _, value, info, ok := singleField(values); ok { - if info.IsTag || info.IsRole { - return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil - } - // LIKE can't use the column index, so annotation fields keep the COALESCE form: a NULL - // column never matches LIKE, which would silently drop missing-annotation rows the original - // COALESCE form included. - if f, isAnnotation := annotationField(info); isAnnotation { - return likeCond(f.coalesced(), 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 likeCond(col, pattern string, negate bool) squirrel.Sqlizer { - if negate { - return squirrel.NotLike{col: pattern} - } - return squirrel.Like{col: pattern} -} - -func rangeExpr(values map[string]any) (squirrel.Sqlizer, error) { - field, value, info, ok := singleField(values) - if !ok { - return nil, fmt.Errorf("invalid field in criteria: %s", field) - } - if info.IsTag || info.IsRole { - // Tags/roles are multi-valued JSON, so splitting a range into two independent EXISTS - // subqueries would let different values satisfy each bound. Ranges are unsupported there. - return nil, fmt.Errorf("range operator not supported for tag/role field: %s", field) - } - s := reflect.ValueOf(value) - if s.Kind() != reflect.Slice || s.Len() != 2 { - return nil, fmt.Errorf("range criteria for %q must be a [min, max] pair, got: %v", field, value) - } - low, err := comparisonExpr(map[string]any{field: s.Index(0).Interface()}, cmpGe) - if err != nil { - return nil, err - } - high, err := comparisonExpr(map[string]any{field: s.Index(1).Interface()}, cmpLe) - if err != nil { - return nil, err - } - return squirrel.And{low, high}, 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) { - var condition squirrel.Sqlizer - if playlistId, ok := values["id"].(string); ok && playlistId != "" { - condition = squirrel.Eq{"pl.playlist_id": playlistId} - } else if playlistPath, ok := values["path"].(string); ok && playlistPath != "" { - condition = squirrel.Eq{"playlist.path": pathVariants(playlistPath)} - } else { - return nil, errors.New("playlist id or path not given") - } - filters := squirrel.And{condition} - 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 -} - -// Filesystems disagree on the Unicode form of a name, so match the path in NFC and NFD. -func pathVariants(path string) []string { - variants := []string{path} - if alt := norm.NFC.String(path); alt != path { - variants = append(variants, alt) - } - if alt := norm.NFD.String(path); alt != path { - variants = append(variants, alt) - } - return variants -} - -func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - if info.IsRole { - return roleCond{role: info.Name(), cond: cond, not: negate} - } - 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 - if e.cond != nil { - innerSQL, innerArgs, err := roleCondSQL(e.cond) - if err != nil { - return "", nil, err - } - cond = roleExistsSQL(innerSQL) - args = append([]any{e.role}, innerArgs...) - } else { - cond = "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)" - args = []any{e.role} - } - if e.not { - cond = "not " + cond - } - return cond, args, nil -} - -// roleCondSQL extracts SQL from a squirrel condition and rewrites the placeholder column name. -func roleCondSQL(cond squirrel.Sqlizer) (string, []any, error) { - sql, args, err := cond.ToSql() - if err != nil { - return "", nil, err - } - return strings.ReplaceAll(sql, "value", "artist.name"), args, nil -} - -// roleExistsSQL wraps a condition fragment in the standard role EXISTS subquery. -func roleExistsSQL(innerCond string) string { - return fmt.Sprintf("exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id "+ - "where mfa.media_file_id = media_file.id and mfa.role = ? and %s)", innerCond) -} - -// jsonCondBatchSize limits how many conditions are ORed inside a single EXISTS subquery -// to stay within SQLite's expression tree depth limit (max 1000). The EXISTS wrapper -// consumes ~4 levels; each ORed condition adds 1 level. Empirically, 496 is the maximum. -const jsonCondBatchSize = 350 - -// mergeJsonConds collapses multiple non-negated roleCond or tagCond entries for the same -// field within an OR group into batched EXISTS subqueries with the conditions ORed inside. -// This turns N separate correlated subqueries into ceil(N/batchSize), dramatically -// improving performance for smart playlists with many patterns. -func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { - if merged, ok := mergeSameFieldConds(or, false); ok { - return squirrel.Or(merged) - } - return or -} - -// mergeNegatedJsonConds is the AND-group counterpart to mergeJsonConds, merging negated -// conditions. By De Morgan, "NOT EXISTS(X) AND NOT EXISTS(Y)" == "NOT EXISTS(X OR Y)". -func mergeNegatedJsonConds(and squirrel.And) squirrel.Sqlizer { - if merged, ok := mergeSameFieldConds(and, true); ok { - return squirrel.And(merged) - } - return and -} - -// mergeSameFieldConds groups roleCond/tagCond entries that share a field and the requested -// polarity, replacing each group of 2+ with batched roleCondGroup/tagCondGroup subqueries. -// Returns the rewritten conditions and whether any merge happened. -func mergeSameFieldConds(conds []squirrel.Sqlizer, negated bool) ([]squirrel.Sqlizer, bool) { - type condEntry struct { - index int - cond squirrel.Sqlizer - } - type group struct { - entries []condEntry - isRole bool - numeric bool - tag string - } - groups := make(map[string]*group) - for i, s := range conds { - switch c := s.(type) { - case roleCond: - if c.not != negated || c.cond == nil { - continue - } - g, exists := groups["role:"+c.role] - if !exists { - g = &group{isRole: true} - groups["role:"+c.role] = g - } - g.entries = append(g.entries, condEntry{index: i, cond: c.cond}) - case tagCond: - if c.not != negated || c.cond == nil { - continue - } - g, exists := groups["tag:"+c.tag] - if !exists { - g = &group{tag: c.tag, numeric: c.numeric} - groups["tag:"+c.tag] = g - } - g.entries = append(g.entries, condEntry{index: i, cond: c.cond}) - } - } - - remove := make(map[int]bool) - var additions []squirrel.Sqlizer - for _, key := range slices.Sorted(maps.Keys(groups)) { - g := groups[key] - if len(g.entries) < 2 { - continue - } - batchConds := make([]squirrel.Sqlizer, len(g.entries)) - for i, e := range g.entries { - remove[e.index] = true - batchConds[i] = e.cond - } - if g.isRole { - role := key[len("role:"):] - for batch := range slices.Chunk(batchConds, jsonCondBatchSize) { - additions = append(additions, roleCondGroup{role: role, conds: batch, not: negated}) - } - } else { - for batch := range slices.Chunk(batchConds, jsonCondBatchSize) { - additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch, not: negated}) - } - } - } - - if len(remove) == 0 { - return conds, false - } - - result := make([]squirrel.Sqlizer, 0, len(conds)-len(remove)+len(additions)) - for i, s := range conds { - if !remove[i] { - result = append(result, s) - } - } - return append(result, additions...), true -} - -// roleCondGroup represents multiple role conditions for the same role, merged into a single -// (optionally negated) EXISTS subquery for performance. -type roleCondGroup struct { - role string - conds []squirrel.Sqlizer - not bool -} - -func (g roleCondGroup) ToSql() (string, []any, error) { - innerParts := make([]string, 0, len(g.conds)) - allArgs := []any{g.role} - for _, c := range g.conds { - part, args, err := roleCondSQL(c) - if err != nil { - return "", nil, err - } - innerParts = append(innerParts, part) - allArgs = append(allArgs, args...) - } - cond := roleExistsSQL("(" + strings.Join(innerParts, " OR ") + ")") - if g.not { - cond = "not " + cond - } - return cond, allArgs, nil -} - -// tagCondGroup represents multiple tag conditions for the same tag, merged into a single -// (optionally negated) EXISTS subquery for performance. -type tagCondGroup struct { - tag string - numeric bool - conds []squirrel.Sqlizer - not bool -} - -func (g tagCondGroup) ToSql() (string, []any, error) { - innerParts := make([]string, 0, len(g.conds)) - var allArgs []any - for _, c := range g.conds { - part, args, err := c.ToSql() - if err != nil { - return "", nil, err - } - if g.numeric { - part = strings.ReplaceAll(part, "value", "CAST(value AS REAL)") - } - innerParts = append(innerParts, part) - allArgs = append(allArgs, args...) - } - cond := fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and (%s))", - g.tag, strings.Join(innerParts, " OR ")) - if g.not { - cond = "not " + cond - } - return cond, allArgs, nil -} - -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 -} - -// comparator is a scalar SQL comparison operator used by smart playlist criteria. -type comparator struct { - build func(map[string]any) squirrel.Sqlizer - satisfy func(a, b float64) bool // the operator as a predicate, to reason about a column's COALESCE default - // ordering is false only for = and <>, the only operators with a clean bare form over bool columns. - ordering bool -} - -var ( - cmpEq = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Eq(f) }, satisfy: func(a, b float64) bool { return a == b }} - cmpNe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.NotEq(f) }, satisfy: func(a, b float64) bool { return a != b }} - cmpGt = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Gt(f) }, satisfy: func(a, b float64) bool { return a > b }, ordering: true} - cmpGe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.GtOrEq(f) }, satisfy: func(a, b float64) bool { return a >= b }, ordering: true} - cmpLt = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Lt(f) }, satisfy: func(a, b float64) bool { return a < b }, ordering: true} - cmpLe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.LtOrEq(f) }, satisfy: func(a, b float64) bool { return a <= b }, ordering: true} -) - -// annotationField returns the field definition only for nullable annotation columns that have a -// COALESCE default (playcount, rating, loved). Date annotation columns (lastplayed, dateloved, ...) -// have no default and return ok=false. -func annotationField(info criteria.FieldInfo) (smartPlaylistField, bool) { - f, ok := smartPlaylistFields[info.Name()] - if !ok || f.coalesceDefault == nil { - return smartPlaylistField{}, false - } - return f, true -} - -// coalesced wraps the field in COALESCE(col, default) (bare expression if it has no default). Used -// where index-friendliness does not apply (ORDER BY, list comparisons) and the missing-row-as-default -// semantics must be kept. -func (f smartPlaylistField) coalesced() string { - if f.coalesceDefault == nil { - return f.expr - } - return fmt.Sprintf("COALESCE(%s, %s)", f.expr, sqlLiteral(f.coalesceDefault)) -} - -func comparisonExpr(values map[string]any, cmp comparator) (squirrel.Sqlizer, error) { - if _, value, info, ok := singleField(values); ok { - if info.IsTag || info.IsRole { - return jsonExpr(info, cmp.build(map[string]any{"value": value}), false), nil - } - if f, isAnnotation := annotationField(info); isAnnotation { - return annotationCond(f, cmp, value), nil - } - } - fields, err := sqlFields(values) - if err != nil { - return nil, err - } - return cmp.build(fields), nil -} - -// annotationCond builds a comparison against a nullable annotation column (see smartPlaylistField -// for why). When bareNullInclusion can reason about the comparison exactly it emits the -// index-friendly bare `col ?`, adding `OR col IS NULL` only when the default would match; -// otherwise it falls back to the COALESCE form, which can't use the index but is always equivalent. -func annotationCond(f smartPlaylistField, cmp comparator, value any) squirrel.Sqlizer { - wrapNull, ok := bareNullInclusion(f.coalesceDefault, cmp, value) - if !ok { - return cmp.build(map[string]any{f.coalesced(): value}) - } - base := cmp.build(map[string]any{f.expr: value}) - if !wrapNull { - return base - } - // The default satisfies the predicate, so missing rows (NULL) must be included. All comparators - // (including <>) evaluate to false against NULL, so an explicit IS NULL restores them. - return squirrel.Or{base, squirrel.Eq{f.expr: nil}} -} - -// bareNullInclusion decides whether the index-friendly bare form is safe for this comparison and, -// if so, whether missing (NULL) rows must be re-included via `OR col IS NULL`. It returns ok=false -// when the bare form can't be proven equivalent to COALESCE(col, default) ? — i.e. the value -// isn't a scalar the comparator can order exactly (lists, bool ordering, unparseable values) — in -// which case the caller keeps the COALESCE form. When ok=true, wrapNull is true iff the default -// value itself satisfies the predicate (so missing rows would match and must be preserved). -func bareNullInclusion(defaultVal any, cmp comparator, value any) (wrapNull, ok bool) { - if b, isBool := defaultVal.(bool); isBool { - // Bool columns have an exact bare form only for equality; ordering operators fall back to - // COALESCE. Mapping both bools to 0/1 lets cmp.satisfy reuse the numeric eq/ne predicate. - if cmp.ordering { - return false, false - } - v, okV := criteria.ToBool(value) - if !okV { - return false, false - } - return cmp.satisfy(boolToFloat(b), boolToFloat(v)), true - } - d, okD := toFloat(defaultVal) - v, okV := toFloat(value) - if !okD || !okV { - // Non-scalar or unparseable value: no exact bare form, keep COALESCE. - return false, false - } - return cmp.satisfy(d, v), true -} - -func boolToFloat(b bool) float64 { - if b { - return 1 - } - return 0 -} - -// toFloat coerces a scalar criteria value to float64. Criteria values come from JSON (float64, -// string) or are built in Go (int, float64), so only those types are handled; anything else — -// including a slice or an unparseable string — reports ok=false so the caller keeps the COALESCE -// form instead of an index-friendly bare comparison. -func toFloat(v any) (float64, bool) { - switch n := v.(type) { - case float64: - return n, true - case int: - return float64(n), true - case int64: - return float64(n), true - case string: - f, err := strconv.ParseFloat(n, 64) - return f, err == nil - default: - return 0, false - } -} - -// sqlLiteral renders an annotation field's COALESCE default (0 or false) as a SQL literal for ORDER -// BY. %v renders both bool and numeric defaults correctly (false/true, 0). -func sqlLiteral(v any) string { - return fmt.Sprintf("%v", v) -} - -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 -} - -// applyExpressionJoins adds every join the criteria's WHERE clause resolves against. -func (c smartPlaylistCriteria) applyExpressionJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder { - return c.applyJoins(sq, c.expressionJoins(), userID) -} - -// applyRequiredJoins adds the WHERE joins plus any the ORDER BY resolves against. -func (c smartPlaylistCriteria) applyRequiredJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder { - return c.applyJoins(sq, c.requiredJoins(), userID) -} - -// applyJoins joins the media_file annotation unconditionally — annotation fields -// COALESCE a missing row to a default, so the row has to be reachable to be absent. -func (c smartPlaylistCriteria) applyJoins(sq squirrel.SelectBuilder, joins smartPlaylistJoinType, userID string) squirrel.SelectBuilder { - sq = sq.LeftJoin("annotation on ("+ - "annotation.item_id = media_file.id"+ - " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = ?)", userID) - if joins.has(smartPlaylistJoinAlbumAnnotation) { - sq = sq.LeftJoin("annotation AS album_annotation ON ("+ - "album_annotation.item_id = media_file.album_id"+ - " AND album_annotation.item_type = 'album'"+ - " AND album_annotation.user_id = ?)", userID) - } - if joins.has(smartPlaylistJoinArtistAnnotation) { - sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ - "artist_annotation.item_id = media_file.artist_id"+ - " AND artist_annotation.item_type = 'artist'"+ - " AND artist_annotation.user_id = ?)", userID) - } - if joins.has(smartPlaylistJoinAlbum) { - sq = sq.LeftJoin("album ON album.id = media_file.album_id") - } - return sq -} - -func (c smartPlaylistCriteria) orderBy() string { - sortFields := c.Criteria.OrderByFields() - parts := make([]string, 0, len(sortFields)) - for _, sf := range sortFields { - 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 - } - // Sorting keeps the COALESCE default so missing-annotation rows sort as that default - // (filtering drops COALESCE for index use, but ORDER BY has no index to preserve here). - mapped = field.coalesced() - } - if info.Numeric { - mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped) - } - return mapped, true -} diff --git a/persistence/criteria_sql_benchmark_test.go b/persistence/criteria_sql_benchmark_test.go deleted file mode 100644 index 30c1ba100..000000000 --- a/persistence/criteria_sql_benchmark_test.go +++ /dev/null @@ -1,291 +0,0 @@ -package persistence - -import ( - "context" - "encoding/json" - "fmt" - "path/filepath" - "strings" - "testing" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "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/pocketbase/dbx" -) - -const ( - benchNumArtists = 1_000 - benchNumTracks = 40_000 - benchNumPatterns = 500 - benchArtistsPerTrack = 3 -) - -// BenchmarkSmartPlaylistRole compares role-based smart playlist query performance -// between the current implementation (merged join-table via criteria pipeline) and -// the old baseline (unmerged json_tree subqueries). -func BenchmarkSmartPlaylistRole(b *testing.B) { - configtest.SetupConfig() - tmpDir := b.TempDir() - conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl.db") - cleanup := db.Init(context.Background()) - defer cleanup() - log.SetLevel(log.LevelFatal) - - conn := dbx.NewFromDB(db.Db(), db.Dialect) - ctx := log.NewContext(context.Background()) - user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true} - ctx = request.WithUser(ctx, user) - - setupBenchData(b, ctx, conn, user) - criteria.AddRoles([]string{"artist"}) - - // Build the criteria expression: 500 "contains artist" patterns in an OR group - anyExprs := make(criteria.Any, benchNumPatterns) - for i := range benchNumPatterns { - anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist %04d", i)} - } - expr := criteria.Criteria{Expression: anyExprs, Sort: "title", Limit: 500} - - b.Run("Current", func(b *testing.B) { - benchmarkCriteriaPipeline(b, ctx, expr) - }) - b.Run("Baseline_UnmergedJSONTree", func(b *testing.B) { - benchmarkUnmergedJSONTree(b, ctx) - }) -} - -// BenchmarkSmartPlaylistNegatedRole compares performance for smart playlists with many -// negated role conditions ANDed together (e.g. 500 "isNot artist" rules, issue #5511) -// between the current implementation (merged NOT EXISTS via criteria pipeline) and the -// old baseline (one separate NOT EXISTS subquery per pattern). -func BenchmarkSmartPlaylistNegatedRole(b *testing.B) { - configtest.SetupConfig() - tmpDir := b.TempDir() - conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl-neg.db") - cleanup := db.Init(context.Background()) - defer cleanup() - log.SetLevel(log.LevelFatal) - - conn := dbx.NewFromDB(db.Db(), db.Dialect) - ctx := log.NewContext(context.Background()) - user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true} - ctx = request.WithUser(ctx, user) - - setupBenchData(b, ctx, conn, user) - criteria.AddRoles([]string{"artist"}) - - // Build the criteria expression: 500 "isNot artist" patterns in an AND group - allExprs := make(criteria.All, benchNumPatterns) - for i := range benchNumPatterns { - allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist %04d", i)} - } - expr := criteria.Criteria{Expression: allExprs, Sort: "title", Limit: 500} - - b.Run("Current", func(b *testing.B) { - benchmarkCriteriaPipeline(b, ctx, expr) - }) - b.Run("Baseline_UnmergedNotExists", func(b *testing.B) { - benchmarkUnmergedNegatedJSONTree(b, ctx) - }) -} - -// benchmarkUnmergedNegatedJSONTree builds the old-style query with N separate negated -// json_tree EXISTS subqueries ANDed together (the pre-optimization baseline). -func benchmarkUnmergedNegatedJSONTree(b *testing.B, ctx context.Context) { - b.Helper() - - var sb strings.Builder - sb.WriteString("SELECT media_file.id FROM media_file WHERE (") - args := make([]any, 0, benchNumPatterns) - for i := range benchNumPatterns { - if i > 0 { - sb.WriteString(" AND ") - } - sb.WriteString("not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)") - args = append(args, fmt.Sprintf("Artist %04d", i)) - } - sb.WriteString(") ORDER BY media_file.title LIMIT 500") - - runBenchQuery(b, ctx, sb.String(), args) -} - -// benchmarkCriteriaPipeline runs the criteria through the actual production code path: -// newSmartPlaylistCriteria → Where() → ToSql(), then executes the resulting query. -func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.Criteria) { - b.Helper() - - cSQL := newSmartPlaylistCriteria(expr) - - // Build the full query matching buildSmartPlaylistQuery + addCriteria - sq := squirrel.Select("media_file.id").From("media_file") - cond, err := cSQL.where() - if err != nil { - b.Fatal(err) - } - sq = sq.Where(cond) - if expr.Limit > 0 { - sq = sq.Limit(uint64(expr.Limit)) - } - if order := cSQL.orderBy(); order != "" { - sq = sq.OrderBy(order) - } - - query, args, err := sq.PlaceholderFormat(squirrel.Question).ToSql() - if err != nil { - b.Fatal(err) - } - - runBenchQuery(b, ctx, query, args) -} - -// benchmarkUnmergedJSONTree builds the old-style query with N separate json_tree EXISTS -// subqueries (the pre-optimization baseline). -func benchmarkUnmergedJSONTree(b *testing.B, ctx context.Context) { - b.Helper() - - var sb strings.Builder - sb.WriteString("SELECT media_file.id FROM media_file WHERE (") - args := make([]any, 0, benchNumPatterns) - for i := range benchNumPatterns { - if i > 0 { - sb.WriteString(" OR ") - } - sb.WriteString("exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)") - args = append(args, fmt.Sprintf("%%Artist %04d%%", i)) - } - sb.WriteString(") ORDER BY media_file.title LIMIT 500") - - runBenchQuery(b, ctx, sb.String(), args) -} - -func runBenchQuery(b *testing.B, ctx context.Context, query string, args []any) { - b.Helper() - sqlDB := db.Db() - b.ResetTimer() - for range b.N { - rows, err := sqlDB.QueryContext(ctx, query, args...) - if err != nil { - b.Fatal(err) - } - for rows.Next() { - var id string - _ = rows.Scan(&id) - } - rows.Close() - if err := rows.Err(); err != nil { - b.Fatal(err) - } - } -} - -func setupBenchData(b *testing.B, ctx context.Context, conn *dbx.DB, user model.User) { - b.Helper() - - sqlDB := db.Db() - - ur := NewUserRepository(conn) - if err := ur.Put(ctx, &user); err != nil { - b.Fatal(err) - } - if err := ur.SetUserLibraries(ctx, user.ID, []int{1}); err != nil { - b.Fatal(err) - } - - tx, err := sqlDB.Begin() - if err != nil { - b.Fatal(err) - } - - // Create artists - artistStmt, err := tx.Prepare("INSERT INTO artist (id, name) VALUES (?, ?)") - if err != nil { - b.Fatal(err) - } - for i := range benchNumArtists { - if _, err := artistStmt.Exec(fmt.Sprintf("artist-%04d", i), fmt.Sprintf("Artist %04d", i)); err != nil { - b.Fatal(err) - } - } - artistStmt.Close() - - // Ensure folder exists - folderID := "bench-folder" - if _, err := tx.Exec("INSERT OR IGNORE INTO folder (id, library_id, path, name, parent_id) VALUES (?, 1, '.', '.', '')", folderID); err != nil { - b.Fatal(err) - } - - // Create media files with participants JSON, cycling through artists - mfStmt, err := tx.Prepare(`INSERT INTO media_file (id, path, title, album, artist, artist_id, album_id, - duration, year, size, suffix, tags, participants, lyrics, library_id, folder_id, pid, codec) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) - if err != nil { - b.Fatal(err) - } - - // Populate media_file_artists join table - mfaStmt, err := tx.Prepare("INSERT INTO media_file_artists (media_file_id, artist_id, role, sub_role) VALUES (?, ?, ?, ?)") - if err != nil { - b.Fatal(err) - } - - for i := range benchNumTracks { - trackID := fmt.Sprintf("track-%05d", i) - - // Assign benchArtistsPerTrack artists to each track, cycling through the pool - artistEntries := make([]map[string]string, benchArtistsPerTrack) - for a := range benchArtistsPerTrack { - artistIdx := (i + a) % benchNumArtists - artistEntries[a] = map[string]string{ - "id": fmt.Sprintf("artist-%04d", artistIdx), - "name": fmt.Sprintf("Artist %04d", artistIdx), - } - } - primaryArtistIdx := i % benchNumArtists - primaryArtistID := fmt.Sprintf("artist-%04d", primaryArtistIdx) - primaryArtistName := fmt.Sprintf("Artist %04d", primaryArtistIdx) - - participants := map[string][]map[string]string{"artist": artistEntries} - participantsJSON, _ := json.Marshal(participants) - - if _, err := mfStmt.Exec( - trackID, - fmt.Sprintf("music/%s.mp3", trackID), - fmt.Sprintf("Track %05d", i), - "Bench Album", - primaryArtistName, - primaryArtistID, - "bench-album", - 180, 2024, 5000000, "mp3", - "{}", - string(participantsJSON), - "[]", - 1, folderID, trackID, "mp3", - ); err != nil { - b.Fatal(err) - } - - // Insert all artist associations into the join table - for a := range benchArtistsPerTrack { - artistIdx := (i + a) % benchNumArtists - artistID := fmt.Sprintf("artist-%04d", artistIdx) - if _, err := mfaStmt.Exec(trackID, artistID, "artist", ""); err != nil { - b.Fatal(err) - } - } - } - mfStmt.Close() - mfaStmt.Close() - - if err := tx.Commit(); err != nil { - b.Fatal(err) - } - - b.Logf("Setup complete: %d artists, %d tracks (%d artists/track), %d patterns", - benchNumArtists, benchNumTracks, benchArtistsPerTrack, benchNumPatterns) -} diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go deleted file mode 100644 index ef91ec989..000000000 --- a/persistence/criteria_sql_test.go +++ /dev/null @@ -1,647 +0,0 @@ -package persistence - -import ( - "fmt" - "strings" - "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", "replaygain_album_gain"}) - 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 annotation.rating > ?)", "%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}, "annotation.starred = ?", 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}, "annotation.play_count > ?", 10), - Entry("lt", criteria.Lt{"playCount": 10}, "(annotation.play_count < ? OR annotation.play_count IS NULL)", 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 [path]", criteria.InPlaylist{"path": "lacuslacus.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "lacuslacus.nsp", 1), - Entry("in playlist [id]", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), - Entry("in playlist [empty id falls back to path]", criteria.InPlaylist{"id": "", "path": "/music/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?) AND playlist.public = ?))", "/music/x.nsp", 1), - Entry("in playlist [decomposed unicode path]", criteria.InPlaylist{"path": "/m\u00fasica/x.nsp"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (playlist.path IN (?,?) AND playlist.public = ?))", "/m\u00fasica/x.nsp", "/mu\u0301sica/x.nsp", 1), - Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), - Entry("album annotation", criteria.Gt{"albumRating": 3}, "album_annotation.rating > ?", 3), - Entry("artist annotation", criteria.Is{"artistLoved": true}, "artist_annotation.starred = ?", true), - Entry("album column", criteria.Gt{"albumSongCount": 5}, "album.song_count > ?", 5), - Entry("album duration column", criteria.Lt{"albumDuration": 600}, "album.duration < ?", 600), - Entry("album size column", criteria.Gt{"albumSize": 1000}, "album.size > ?", 1000), - Entry("album date column", criteria.After{"albumDateAdded": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "album.created_at > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), - Entry("album modified column", criteria.Before{"albumDateModified": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "album.updated_at < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), - // Annotation fields use a COALESCE default (0 for numeric, false for bool) so that tracks - // with no annotation row behave as that default. To keep the annotation index usable, the - // COALESCE is dropped when the compared value cannot match the default (the missing-row - // case is then naturally excluded); otherwise an explicit `OR col IS NULL` preserves it. - Entry("is safe (value != default)", criteria.Is{"playCount": 3}, "annotation.play_count = ?", 3), - Entry("is unsafe (value == default)", criteria.Is{"playCount": 0}, - "(annotation.play_count = ? OR annotation.play_count IS NULL)", 0), - Entry("is bool false (value == default)", criteria.Is{"loved": false}, - "(annotation.starred = ? OR annotation.starred IS NULL)", false), - Entry("gt safe (value >= default)", criteria.Gt{"playCount": 0}, "annotation.play_count > ?", 0), - Entry("gt unsafe (value < default)", criteria.Gt{"playCount": -1}, - "(annotation.play_count > ? OR annotation.play_count IS NULL)", -1), - Entry("lt safe (value <= default)", criteria.Lt{"playCount": 0}, "annotation.play_count < ?", 0), - Entry("lt unsafe (value > default)", criteria.Lt{"playCount": 5}, - "(annotation.play_count < ? OR annotation.play_count IS NULL)", 5), - Entry("isNot annotation keeps null match", criteria.IsNot{"playCount": 3}, - "(annotation.play_count <> ? OR annotation.play_count IS NULL)", 3), - Entry("isNot annotation value == default", criteria.IsNot{"playCount": 0}, - "annotation.play_count <> ?", 0), - Entry("in range spanning default", criteria.InTheRange{"playCount": []int{-1, 5}}, - "((annotation.play_count >= ? OR annotation.play_count IS NULL) AND (annotation.play_count <= ? OR annotation.play_count IS NULL))", -1, 5), - Entry("in range above default", criteria.InTheRange{"playCount": []int{1, 5}}, - "(annotation.play_count >= ? AND (annotation.play_count <= ? OR annotation.play_count IS NULL))", 1, 5), - // A list value can't drive the index and a default-inclusive list has per-element NULL - // semantics, so the COALESCE form is kept to stay equivalent to the original. - Entry("is list keeps coalesce", criteria.Is{"playCount": []int{0, 3}}, - "COALESCE(annotation.play_count, 0) IN (?,?)", 0, 3), - // LIKE operators can't use the column index, so annotation fields keep the COALESCE form to - // match missing-annotation rows exactly as before (a NULL column never matches LIKE). - Entry("contains annotation keeps coalesce", criteria.Contains{"playCount": 0}, - "COALESCE(annotation.play_count, 0) LIKE ?", "%0%"), - Entry("starts with annotation keeps coalesce", criteria.StartsWith{"rating": 5}, - "COALESCE(annotation.rating, 0) LIKE ?", "5%"), - Entry("not contains annotation keeps coalesce", criteria.NotContains{"playCount": 0}, - "COALESCE(annotation.play_count, 0) NOT LIKE ?", "%0%"), - // Bool annotation fields only have a clean index-friendly form for equality; ordering - // comparators keep the COALESCE form so the missing-row default is honored exactly. - Entry("gt bool keeps coalesce", criteria.Gt{"loved": false}, - "COALESCE(annotation.starred, false) > ?", false), - // A list value on a bool field is non-scalar, so it keeps the COALESCE form too (same as the - // numeric list case) — otherwise a NULL column would diverge from the original. - Entry("is bool list keeps coalesce", criteria.Is{"loved": []any{true}}, - "COALESCE(annotation.starred, false) IN (?)", 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 media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name = ?)", "artist", "u2"), - Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "composer", "%Lennon%"), - Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "artist", "%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 media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"), - Entry("isMissing role [false]", criteria.IsMissing{"artist": false}, - "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"), - // 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 media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), - Entry("isPresent role [false]", criteria.IsPresent{"composer": false}, - "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), - // isMissing/isPresent — nullable column fields (ReplayGain) - Entry("isMissing rgAlbumGain [true]", criteria.IsMissing{"rgAlbumGain": true}, - "(media_file.rg_album_gain IS NULL)"), - Entry("isMissing rgAlbumGain [false]", criteria.IsMissing{"rgAlbumGain": false}, - "(media_file.rg_album_gain IS NOT NULL)"), - Entry("isPresent rgTrackPeak [true]", criteria.IsPresent{"rgTrackPeak": true}, - "(media_file.rg_track_peak IS NOT NULL)"), - Entry("isPresent rgTrackPeak [false]", criteria.IsPresent{"rgTrackPeak": false}, - "(media_file.rg_track_peak IS NULL)"), - // isMissing — replaygain_* tag-name alias resolves to the nullable column (issue #5584) - Entry("isMissing replaygain_album_gain alias [true]", criteria.IsMissing{"replaygain_album_gain": true}, - "(media_file.rg_album_gain IS NULL)"), - Entry("isPresent replaygain_album_gain alias [true]", criteria.IsPresent{"replaygain_album_gain": true}, - "(media_file.rg_album_gain IS NOT NULL)"), - // isMissing/isPresent — string column fields (empty string means missing) - Entry("isMissing mbz_recording_id [true]", criteria.IsMissing{"mbz_recording_id": true}, - "(media_file.mbz_recording_id IS NULL OR media_file.mbz_recording_id = ?)", ""), - Entry("isMissing mbz_recording_id [false]", criteria.IsMissing{"mbz_recording_id": false}, - "(media_file.mbz_recording_id IS NOT NULL AND media_file.mbz_recording_id <> ?)", ""), - Entry("isPresent mbz_album_id [true]", criteria.IsPresent{"mbz_album_id": true}, - "(media_file.mbz_album_id IS NOT NULL AND media_file.mbz_album_id <> ?)", ""), - Entry("isPresent mbz_album_id [false]", criteria.IsPresent{"mbz_album_id": false}, - "(media_file.mbz_album_id IS NULL OR media_file.mbz_album_id = ?)", ""), - // lyrics: absence is encoded as '' or '[]' (empty serialized LyricList) - Entry("isMissing lyrics [true]", criteria.IsMissing{"lyrics": true}, - "(media_file.lyrics IS NULL OR media_file.lyrics = ? OR media_file.lyrics = ?)", "", "[]"), - Entry("isPresent lyrics [true]", criteria.IsPresent{"lyrics": true}, - "(media_file.lyrics IS NOT NULL AND media_file.lyrics <> ? AND media_file.lyrics <> ?)", "", "[]"), - Entry("isMissing lyrics [false]", criteria.IsMissing{"lyrics": false}, - "(media_file.lyrics IS NOT NULL AND media_file.lyrics <> ? AND media_file.lyrics <> ?)", "", "[]"), - Entry("isPresent lyrics [false]", criteria.IsPresent{"lyrics": false}, - "(media_file.lyrics IS NULL OR media_file.lyrics = ? OR media_file.lyrics = ?)", "", "[]"), - // isMissing/isPresent — nullable numeric columns (BPM, BitDepth) - Entry("isMissing bpm [true]", criteria.IsMissing{"bpm": true}, - "(media_file.bpm IS NULL)"), - Entry("isPresent bpm [true]", criteria.IsPresent{"bpm": true}, - "(media_file.bpm IS NOT NULL)"), - Entry("isMissing bitdepth [true]", criteria.IsMissing{"bitdepth": true}, - "(media_file.bit_depth IS NULL)"), - Entry("isPresent bitdepth [false]", criteria.IsPresent{"bitdepth": false}, - "(media_file.bit_depth IS NULL)"), - // isMissing/isPresent — more string column fields (empty string means missing) - Entry("isMissing album [true]", criteria.IsMissing{"album": true}, - "(media_file.album IS NULL OR media_file.album = ?)", ""), - Entry("isMissing comment [true]", criteria.IsMissing{"comment": true}, - "(media_file.comment IS NULL OR media_file.comment = ?)", ""), - Entry("isMissing catalognumber [true]", criteria.IsMissing{"catalognumber": true}, - "(media_file.catalog_num IS NULL OR media_file.catalog_num = ?)", ""), - Entry("isMissing discsubtitle [true]", criteria.IsMissing{"discsubtitle": true}, - "(media_file.disc_subtitle IS NULL OR media_file.disc_subtitle = ?)", ""), - Entry("isMissing albumcomment [true]", criteria.IsMissing{"albumcomment": true}, - "(media_file.mbz_album_comment IS NULL OR media_file.mbz_album_comment = ?)", ""), - Entry("isMissing sorttitle [true]", criteria.IsMissing{"sorttitle": true}, - "(media_file.sort_title IS NULL OR media_file.sort_title = ?)", ""), - Entry("isMissing sortalbum [true]", criteria.IsMissing{"sortalbum": true}, - "(media_file.sort_album_name IS NULL OR media_file.sort_album_name = ?)", ""), - Entry("isMissing sortartist [true]", criteria.IsMissing{"sortartist": true}, - "(media_file.sort_artist_name IS NULL OR media_file.sort_artist_name = ?)", ""), - Entry("isMissing sortalbumartist [true]", criteria.IsMissing{"sortalbumartist": true}, - "(media_file.sort_album_artist_name IS NULL OR media_file.sort_album_artist_name = ?)", ""), - Entry("isMissing explicitstatus [true]", criteria.IsMissing{"explicitstatus": true}, - "(media_file.explicit_status IS NULL OR media_file.explicit_status = ?)", ""), - Entry("isPresent comment [true]", criteria.IsPresent{"comment": true}, - "(media_file.comment IS NOT NULL AND media_file.comment <> ?)", ""), - ) - - 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 not supported for field"))) - }) - - It("returns an error when isPresent is used with a regular field", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).where() - Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field"))) - }) - - It("returns an error when isMissing has a non-boolean value", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).where() - Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression"))) - }) - - It("returns an error when inPlaylist has empty path", func() { - _, err := newSmartPlaylistCriteria( - criteria.Criteria{Expression: criteria.InPlaylist{"path": ""}}, - withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false})).where() - Expect(err).To(MatchError(ContainSubstring("playlist id or path not given"))) - }) - - It("returns an error for a range over a tag/role field", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"rate": []int{1, 5}}}).where() - Expect(err).To(MatchError(ContainSubstring("range operator not supported for tag/role field"))) - }) - - It("returns a clear error for a malformed range value", func() { - _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheRange{"playCount": []int{1, 2, 3}}}).where() - Expect(err).To(MatchError(ContainSubstring("must be a [min, max] pair"))) - }) - - Describe("sort", func() { - It("sorts by regular fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).orderBy()).To(Equal("media_file.title asc")) - }) - - 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 album columns bare, with no COALESCE default", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-albumDateAdded,trackNumber"}).orderBy()). - To(Equal("album.created_at desc, media_file.track_number asc")) - }) - - It("sorts by multiple fields", func() { - Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).orderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc")) - }) - - 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()) - } - }) - - It("declares a joinType matching the table each field selects from", func() { - // Omitting the joinType still compiles, so without this the field would only fail at - // refresh time with "no such column". - joinByTable := map[string]smartPlaylistJoinType{ - "media_file": smartPlaylistJoinNone, - "annotation": smartPlaylistJoinNone, - "album": smartPlaylistJoinAlbum, - "album_annotation": smartPlaylistJoinAlbumAnnotation, - "artist_annotation": smartPlaylistJoinArtistAnnotation, - } - for name, field := range smartPlaylistFields { - if field.expr == "" { - continue - } - table, _, ok := strings.Cut(field.expr, ".") - Expect(ok).To(BeTrue(), "field %q has expr %q with no table prefix", name, field.expr) - want, known := joinByTable[table] - Expect(known).To(BeTrue(), "field %q selects from unknown table %q", name, table) - Expect(field.joinType).To(Equal(want), "field %q selects from %q but declares the wrong joinType", name, table) - } - }) - - Describe("JSON condition merging", func() { - It("merges multiple role conditions in an OR group into a single EXISTS", func() { - expr := criteria.Any{ - criteria.Contains{"artist": "Beatles"}, - criteria.Contains{"artist": "Kraftwerk"}, - criteria.Contains{"artist": "Pink Floyd"}, - } - 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("(exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and (artist.name LIKE ? OR artist.name LIKE ? OR artist.name LIKE ?)))")) - Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%", "%Pink Floyd%")) - }) - - It("does not merge role conditions from different roles", func() { - expr := criteria.Any{ - criteria.Contains{"artist": "Beatles"}, - criteria.Contains{"composer": "Lennon"}, - } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() - Expect(err).ToNot(HaveOccurred()) - - sql, _, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("mfa.role = ?")) - // Two separate EXISTS since roles differ - Expect(strings.Count(sql, "exists")).To(Equal(2)) - }) - - It("does not merge negated role conditions", func() { - expr := criteria.Any{ - criteria.NotContains{"artist": "Beatles"}, - criteria.NotContains{"artist": "Kraftwerk"}, - } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() - Expect(err).ToNot(HaveOccurred()) - - sql, _, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - // Two separate "not exists" since they are negated - Expect(strings.Count(sql, "not exists")).To(Equal(2)) - }) - - It("batches large groups to avoid SQLite expression tree depth limit", func() { - // Create jsonCondBatchSize + 1 conditions to trigger batching into 2 groups - anyExprs := make(criteria.Any, jsonCondBatchSize+1) - for i := range anyExprs { - anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist%d", i)} - } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).where() - Expect(err).ToNot(HaveOccurred()) - - sql, args, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - // Should produce 2 EXISTS subqueries (one batch of jsonCondBatchSize, one of 1) - Expect(strings.Count(sql, "exists")).To(Equal(2)) - // First batch has jsonCondBatchSize patterns, second has 1 => total args: - // 2 roles + (jsonCondBatchSize + 1) patterns - Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1)) - }) - - It("merges role conditions while preserving non-role conditions", func() { - expr := criteria.Any{ - criteria.Contains{"title": "Love"}, - criteria.Contains{"artist": "Beatles"}, - criteria.Contains{"artist": "Kraftwerk"}, - } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() - Expect(err).ToNot(HaveOccurred()) - - sql, args, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("media_file.title LIKE ?")) - Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) - Expect(args).To(HaveExactElements("%Love%", "artist", "%Beatles%", "%Kraftwerk%")) - }) - - It("merges multiple tag conditions in an OR group into a single EXISTS", func() { - expr := criteria.Any{ - criteria.Contains{"genre": "Rock"}, - criteria.Contains{"genre": "Metal"}, - criteria.Contains{"genre": "Punk"}, - } - 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("(exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and (value LIKE ? OR value LIKE ? OR value LIKE ?)))")) - Expect(args).To(HaveExactElements("%Rock%", "%Metal%", "%Punk%")) - }) - - It("does not merge tag conditions from different tags", func() { - expr := criteria.Any{ - criteria.Contains{"genre": "Rock"}, - criteria.Contains{"mood": "Happy"}, - } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() - Expect(err).ToNot(HaveOccurred()) - - sql, _, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(strings.Count(sql, "exists")).To(Equal(2)) - }) - - It("does not merge negated tag conditions", func() { - expr := criteria.Any{ - criteria.NotContains{"genre": "Rock"}, - criteria.NotContains{"genre": "Metal"}, - } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() - Expect(err).ToNot(HaveOccurred()) - - sql, _, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(strings.Count(sql, "not exists")).To(Equal(2)) - }) - - It("merges role and tag conditions independently", func() { - expr := criteria.Any{ - criteria.Contains{"artist": "Beatles"}, - criteria.Contains{"artist": "Kraftwerk"}, - criteria.Contains{"genre": "Rock"}, - criteria.Contains{"genre": "Metal"}, - } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() - Expect(err).ToNot(HaveOccurred()) - - sql, args, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - // Two merged EXISTS: one for roles, one for tags - Expect(strings.Count(sql, "exists")).To(Equal(2)) - Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) - Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?")) - Expect(args).To(HaveLen(2 + 2 + 1)) // 2 tag patterns + 2 role patterns + 1 role name - }) - - It("merges negated role conditions in an AND group into a single NOT EXISTS", func() { - expr := criteria.All{ - criteria.IsNot{"artist": "Beatles"}, - criteria.IsNot{"artist": "Kraftwerk"}, - } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() - Expect(err).ToNot(HaveOccurred()) - - sql, args, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - // A single NOT EXISTS with both names ORed inside (De Morgan) - Expect(strings.Count(sql, "not exists")).To(Equal(1)) - Expect(sql).To(ContainSubstring("artist.name = ? OR artist.name = ?")) - Expect(args).To(HaveExactElements("artist", "Beatles", "Kraftwerk")) - }) - - It("merges negated notContains role conditions in an AND group", func() { - expr := criteria.All{ - criteria.NotContains{"artist": "Beatles"}, - criteria.NotContains{"artist": "Kraftwerk"}, - } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() - Expect(err).ToNot(HaveOccurred()) - - sql, args, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(strings.Count(sql, "not exists")).To(Equal(1)) - Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) - Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%")) - }) - - It("merges negated tag conditions in an AND group into a single NOT EXISTS", func() { - expr := criteria.All{ - criteria.NotContains{"genre": "Rock"}, - criteria.NotContains{"genre": "Metal"}, - } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() - Expect(err).ToNot(HaveOccurred()) - - sql, args, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(strings.Count(sql, "not exists")).To(Equal(1)) - Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?")) - Expect(args).To(HaveExactElements("%Rock%", "%Metal%")) - }) - - It("does not merge a single negated condition with a positive one of the same role in AND", func() { - // AND of mixed polarity must not be collapsed: NOT EXISTS(a) AND EXISTS(b) - // is not equivalent to any single merged subquery. - expr := criteria.All{ - criteria.Contains{"artist": "Beatles"}, - criteria.IsNot{"artist": "Kraftwerk"}, - } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() - Expect(err).ToNot(HaveOccurred()) - - sql, _, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - // One positive EXISTS and one negated NOT EXISTS, kept separate - Expect(strings.Count(sql, "not exists")).To(Equal(1)) - Expect(strings.Count(sql, "exists")).To(Equal(2)) // "not exists" contains "exists" - }) - - It("does not merge negated conditions of different roles in AND", func() { - expr := criteria.All{ - criteria.IsNot{"artist": "Beatles"}, - criteria.IsNot{"composer": "Lennon"}, - } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).where() - Expect(err).ToNot(HaveOccurred()) - - sql, _, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(strings.Count(sql, "not exists")).To(Equal(2)) - }) - - It("batches large negated AND groups to avoid SQLite expression tree depth limit", func() { - allExprs := make(criteria.All, jsonCondBatchSize+1) - for i := range allExprs { - allExprs[i] = criteria.IsNot{"artist": fmt.Sprintf("Artist%d", i)} - } - sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: allExprs}).where() - Expect(err).ToNot(HaveOccurred()) - - sql, args, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - // Two NOT EXISTS subqueries (one batch of jsonCondBatchSize, one of 1) - Expect(strings.Count(sql, "not exists")).To(Equal(2)) - Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1)) - }) - }) - - 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()) - }) - - It("keeps a sort-only album join out of the expression joins", func() { - c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-albumDateAdded"} - cSQL := newSmartPlaylistCriteria(c) - - Expect(cSQL.expressionJoins()).To(Equal(smartPlaylistJoinNone)) - Expect(cSQL.requiredJoins().has(smartPlaylistJoinAlbum)).To(BeTrue()) - }) - - It("distinguishes the album join from the album annotation join", func() { - c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}} - joins := newSmartPlaylistCriteria(c).requiredJoins() - - Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) - Expect(joins.has(smartPlaylistJoinAlbum)).To(BeFalse()) - }) - }) -}) diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go deleted file mode 100644 index a757901d3..000000000 --- a/persistence/e2e/e2e_suite_test.go +++ /dev/null @@ -1,350 +0,0 @@ -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/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", - "replaygain_album_gain": "-6.5 dB", - "replaygain_album_peak": "0.98", - }) - 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", - "replaygain_track_gain": "-7.1 dB", "replaygain_track_peak": "0.95"})), - "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", - _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks", - "replaygain_track_gain": "-6.0 dB", "replaygain_track_peak": "0.92"})), - // Stairway To Heaven has track gain but no album gain, to distinguish the two fields - "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, - "replaygain_track_gain": "-8.25 dB", "replaygain_track_peak": "0.99"})), - "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().GetAll(ctx, 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().Put(userCtx, pls) - Expect(err).ToNot(HaveOccurred()) - - loaded, err := ds.Playlist().GetWithTracks(userCtx, 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().Put(ctx, 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().Put(ctx, 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().Put(ctx, &userWithPass)).To(Succeed()) - - regularUserWithPass := regularUser - regularUserWithPass.NewPassword = "password" - Expect(initDS.User().Put(ctx, ®ularUserWithPass)).To(Succeed()) - - lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} - Expect(initDS.Library().Put(ctx, &lib)).To(Succeed()) - Expect(initDS.User().SetUserLibraries(ctx, adminUser.ID, []int{lib.ID})).To(Succeed()) - Expect(initDS.User().SetUserLibraries(ctx, regularUser.ID, []int{lib.ID})).To(Succeed()) - - loadedUser, err := initDS.User().FindByUsername(ctx, adminUser.UserName) - Expect(err).ToNot(HaveOccurred()) - adminUser.Libraries = loadedUser.Libraries - - loadedOther, err := initDS.User().FindByUsername(ctx, regularUser.UserName) - Expect(err).ToNot(HaveOccurred()) - regularUser.Libraries = loadedOther.Libraries - - ctx = request.WithUser(GinkgoT().Context(), adminUser) - - buildTestFS() - s := scanner.New(ctx, initDS, events.NoopBroker(), - playlists.NewPlaylists(initDS, artwork.NewUploader(initDS)), 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().SetStar(ctx, true, comeTogetherID)).To(Succeed()) - Expect(ds.MediaFile().SetStar(ctx, true, findMediaFileByTitle("So What"))).To(Succeed()) - Expect(ds.MediaFile().SetRating(ctx, 3, findMediaFileByTitle("Stairway To Heaven"))).To(Succeed()) - Expect(ds.MediaFile().SetRating(ctx, 5, findMediaFileByTitle("Bohemian Rhapsody"))).To(Succeed()) - for range 10 { - Expect(ds.MediaFile().IncPlayCount(ctx, comeTogetherID, time.Now())).To(Succeed()) - } - Expect(ds.MediaFile().IncPlayCount(ctx, 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 deleted file mode 100644 index 51442c822..000000000 --- a/persistence/e2e/smartplaylist_test.go +++ /dev/null @@ -1,468 +0,0 @@ -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("Album aggregate fields", func() { - // Abbey Road and IV have two tracks each; the other four albums have one. - It("matches albums with more than one track", func() { - results := evaluateRule(`{"all":[{"gt":{"albumsongcount":1}}]}`) - Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog")) - }) - - It("matches single-track albums", func() { - results := evaluateRule(`{"all":[{"is":{"albumsongcount":1}}]}`) - Expect(results).To(ConsistOf("So What", "Bohemian Rhapsody", "All Along the Watchtower", - "We Are the Champions")) - }) - - It("matches albumDateAdded inTheLast 1 day", func() { - results := evaluateRule(`{"all":[{"inTheLast":{"albumdateadded":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("matches albumDateModified before a far-future date", func() { - results := evaluateRule(`{"all":[{"before":{"albumdatemodified":"2099-01-01"}}]}`) - Expect(results).To(HaveLen(8)) - }) - - // Fixture durations are randomized, so only the aggregate being populated can be asserted. - It("resolves albumDuration and albumSize to non-zero aggregates", func() { - Expect(evaluateRule(`{"all":[{"gt":{"albumduration":0}}]}`)).To(HaveLen(8)) - Expect(evaluateRule(`{"all":[{"gt":{"albumsize":0}}]}`)).To(HaveLen(8)) - }) - - It("groups by album date and orders within the album (issue #5347)", func() { - results := evaluateRuleOrdered( - `{"all":[{"is":{"album":"Abbey Road"}}],"sort":"-albumdateadded,tracknumber"}`) - Expect(results).To(Equal([]string{"Come Together", "Something"})) - }) - }) - - 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().GetWithTracks(ctx, smartAID, true, false) - Expect(err).ToNot(HaveOccurred()) - Expect(loadedA.Tracks).To(BeEmpty()) - Expect(loadedA.EvaluatedAt).To(BeNil()) - - loadedB, err := ds.Playlist().Get(ctx, 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")) - }) - }) - - // ReplayGain values are stored in nullable media_file columns (not in the tags JSON), so - // isMissing/isPresent translate to IS [NOT] NULL checks on those columns (issue #5584). - Describe("isMissing/isPresent on ReplayGain fields", func() { - It("isMissing finds tracks without album gain", func() { - results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":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 album gain", func() { - results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":false}}]}`) - Expect(results).To(ConsistOf("Come Together", "Something")) - }) - - It("isPresent finds tracks with album gain", func() { - results := evaluateRule(`{"all":[{"isPresent":{"rgalbumgain":true}}]}`) - Expect(results).To(ConsistOf("Come Together", "Something")) - }) - - It("isPresent finds tracks with album peak", func() { - results := evaluateRule(`{"all":[{"isPresent":{"rgalbumpeak":true}}]}`) - Expect(results).To(ConsistOf("Come Together", "Something")) - }) - - It("isMissing distinguishes track gain from album gain", func() { - results := evaluateRule(`{"all":[{"isMissing":{"rgtrackgain":true}}]}`) - Expect(results).To(ConsistOf("Black Dog", "So What", "Bohemian Rhapsody", - "All Along the Watchtower", "We Are the Champions")) - }) - - It("isPresent finds tracks with track gain", func() { - results := evaluateRule(`{"all":[{"isPresent":{"rgtrackgain":true}}]}`) - Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven")) - }) - - It("resolves the replaygain_album_gain alias to the rgalbumgain column", func() { - results := evaluateRule(`{"all":[{"isMissing":{"replaygain_album_gain":true}}]}`) - Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", - "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) - }) - - It("resolves the replaygain_track_gain alias to the rgtrackgain column", func() { - results := evaluateRule(`{"all":[{"isPresent":{"replaygain_track_gain":true}}]}`) - Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven")) - }) - - It("supports numeric comparisons through the replaygain_* alias", func() { - results := evaluateRule(`{"all":[{"gt":{"replaygain_track_gain":-7.5}}]}`) - Expect(results).To(ConsistOf("Come Together", "Something")) - }) - - It("combines isMissing on ReplayGain with other operators", func() { - results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":true}},{"is":{"genre":"Blues"}}]}`) - Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower")) - }) - }) -}) diff --git a/persistence/export_test.go b/persistence/export_test.go deleted file mode 100644 index 402baf24a..000000000 --- a/persistence/export_test.go +++ /dev/null @@ -1,4 +0,0 @@ -package persistence - -// Definitions for testing private methods -var GetIndexKey = (*artistRepository).getIndexKey diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go deleted file mode 100644 index b4f7c9069..000000000 --- a/persistence/folder_repository.go +++ /dev/null @@ -1,327 +0,0 @@ -package persistence - -import ( - "context" - "encoding/json" - "fmt" - "iter" - "maps" - "path" - "slices" - "strings" - "time" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "github.com/pocketbase/dbx" -) - -type folderRepository struct { - sqlRepository -} - -type dbFolder struct { - *model.Folder `structs:",flatten"` - ImageFiles string `structs:"-" json:"-"` -} - -// String guards the promoted Folder.String(), which would dereference a nil Folder. -func (f dbFolder) String() string { - if f.Folder == nil { - return "" - } - return f.Folder.String() -} - -func (f *dbFolder) PostScan() error { - var err error - if f.ImageFiles != "" { - if err = json.Unmarshal([]byte(f.ImageFiles), &f.Folder.ImageFiles); err != nil { - return fmt.Errorf("parsing folder image files from db: %w", err) - } - } - return nil -} - -func (f *dbFolder) PostMapArgs(args map[string]any) error { - if f.Folder.ImageFiles == nil { - args["image_files"] = "[]" - } else { - imgFiles, err := json.Marshal(f.Folder.ImageFiles) - if err != nil { - return fmt.Errorf("marshalling image files: %w", err) - } - args["image_files"] = string(imgFiles) - } - return nil -} - -type dbFolders []dbFolder - -func (fs dbFolders) toModels() []model.Folder { - return slice.Map(fs, func(f dbFolder) model.Folder { return *f.Folder }) -} - -func newFolderRepository(db dbx.Builder) model.FolderRepository { - r := &folderRepository{} - r.db = db - r.tableName = "folder" - return r -} - -func (r folderRepository) selectFolder(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - sql := r.newSelect(ctx, options...).Columns("folder.*", "library.path as library_path"). - Join("library on library.id = folder.library_id") - return r.applyLibraryFilter(ctx, sql) -} - -func (r folderRepository) Get(ctx context.Context, id string) (*model.Folder, error) { - sq := r.selectFolder(ctx).Where(Eq{"folder.id": id}) - var res dbFolder - err := r.queryOne(ctx, sq, &res) - return res.Folder, err -} - -func (r folderRepository) GetByPath(ctx context.Context, lib model.Library, path string) (*model.Folder, error) { - id := model.NewFolder(lib, path).ID - return r.Get(ctx, id) -} - -func (r folderRepository) GetAll(ctx context.Context, opt ...model.QueryOptions) ([]model.Folder, error) { - sq := r.selectFolder(ctx, opt...) - var res dbFolders - err := r.queryAll(ctx, sq, &res) - return res.toModels(), err -} - -func (r folderRepository) CountAll(ctx context.Context, opt ...model.QueryOptions) (int64, error) { - query := r.newSelect(ctx, opt...).Columns("count(*)") - query = r.applyLibraryFilter(ctx, query) - return r.count(ctx, query) -} - -func (r folderRepository) GetFolderUpdateInfo(ctx context.Context, lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) { - // If no specific paths, return all folders in the library - if len(targetPaths) == 0 { - return r.getFolderUpdateInfoAll(ctx, lib) - } - - // Check if any path is root (return all folders) - for _, targetPath := range targetPaths { - if targetPath == "" || targetPath == "." { - return r.getFolderUpdateInfoAll(ctx, lib) - } - } - - // Process paths in batches to avoid SQLite's expression tree depth limit (max 1000). - // Each path generates ~3 conditions, so batch size of 100 keeps us well under the limit. - const batchSize = 100 - result := make(map[string]model.FolderUpdateInfo) - - for batch := range slices.Chunk(targetPaths, batchSize) { - batchResult, err := r.getFolderUpdateInfoBatch(ctx, lib, batch) - if err != nil { - return nil, err - } - maps.Copy(result, batchResult) - } - - return result, nil -} - -// getFolderUpdateInfoAll returns update info for all non-missing folders in the library -func (r folderRepository) getFolderUpdateInfoAll(ctx context.Context, lib model.Library) (map[string]model.FolderUpdateInfo, error) { - where := And{ - Eq{"library_id": lib.ID}, - Eq{"missing": false}, - } - return r.queryFolderUpdateInfo(ctx, where) -} - -// getFolderUpdateInfoBatch returns update info for a batch of target paths and their descendants -func (r folderRepository) getFolderUpdateInfoBatch(ctx context.Context, lib model.Library, targetPaths []string) (map[string]model.FolderUpdateInfo, error) { - where := And{ - Eq{"library_id": lib.ID}, - Eq{"missing": false}, - } - - // Collect folder IDs for exact target folders and path conditions for descendants - folderIDs := make([]string, 0, len(targetPaths)) - pathConditions := make(Or, 0, len(targetPaths)*2) - - for _, targetPath := range targetPaths { - // Slash-form like the stored paths; filepath.Clean would backslash them on Windows. - cleanPath := path.Clean(strings.TrimPrefix(targetPath, "/")) - - // Include the target folder itself by ID - folderIDs = append(folderIDs, model.FolderID(lib, cleanPath)) - - // Include all descendants: folders whose path field equals or starts with the target path - // Note: Folder.Path is the directory path, so children have path = targetPath - pathConditions = append(pathConditions, Eq{"path": cleanPath}) - pathConditions = append(pathConditions, Like{"path": cleanPath + "/%"}) - } - - // Combine conditions: exact folder IDs OR descendant path patterns - if len(folderIDs) > 0 { - where = append(where, Or{Eq{"id": folderIDs}, pathConditions}) - } else if len(pathConditions) > 0 { - where = append(where, pathConditions) - } - - return r.queryFolderUpdateInfo(ctx, where) -} - -// queryFolderUpdateInfo executes the query and returns the result map -func (r folderRepository) queryFolderUpdateInfo(ctx context.Context, where And) (map[string]model.FolderUpdateInfo, error) { - sq := r.newSelect(ctx).Columns("id", "updated_at", "hash", "image_files", "images_updated_at").Where(where) - var res []struct { - ID string - UpdatedAt time.Time - Hash string - ImageFiles string - ImagesUpdatedAt time.Time - } - err := r.queryAll(ctx, sq, &res) - if err != nil { - return nil, err - } - m := make(map[string]model.FolderUpdateInfo, len(res)) - for _, f := range res { - info := model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash, ImagesUpdatedAt: f.ImagesUpdatedAt} - if f.ImageFiles != "" { - if err := json.Unmarshal([]byte(f.ImageFiles), &info.ImageFiles); err != nil { - return nil, fmt.Errorf("parsing folder image_files: %w", err) - } - } - m[f.ID] = info - } - return m, nil -} - -// subtreePathChunkSize bounds how many paths one folderSubtreeFilter may expand into: each adds -// 3 OR terms, and SQLite rejects an expression tree deeper than 1000 (measured: 166 paths). -const subtreePathChunkSize = 100 - -// folderSubtreeFilter matches the folders at the given library-relative paths and all their -// descendants. A path of "" or "." selects the whole library, so it drops the path conditions. -func folderSubtreeFilter(lib model.Library, paths []string) Sqlizer { - conds := make(Or, 0, len(paths)*3) - for _, p := range paths { - // Paths are io/fs slash-form; filepath.Clean would backslash them on Windows. - cleanPath := path.Clean(strings.TrimPrefix(p, "/")) - if cleanPath == "." { - return And{Eq{"folder.library_id": lib.ID}, Eq{"folder.missing": false}} - } - conds = append(conds, - Eq{"folder.id": model.FolderID(lib, cleanPath)}, - // Direct children have path = cleanPath; deeper descendants match the prefix - Eq{"folder.path": cleanPath}, - Expr(`folder.path LIKE ? ESCAPE '\'`, escapeLikePrefix(cleanPath)+"/%"), - ) - } - return And{Eq{"folder.library_id": lib.ID}, Eq{"folder.missing": false}, conds} -} - -// HasAudioOutsideFolders reports whether any folder in parent's subtree -// (including parent itself) contains audio files and is not one of the given -// folder IDs. LIKE wildcards in the parent path are escaped, so it is always -// matched as a literal prefix. -func (r folderRepository) HasAudioOutsideFolders(ctx context.Context, parent model.Folder, excludeFolderIDs []string) (bool, error) { - if parent.NumAudioFiles > 0 { - return true, nil - } - parentPath := strings.TrimPrefix(path.Join(parent.Path, parent.Name), "/") - return r.exists(ctx, And{ - Eq{"library_id": parent.LibraryID, "missing": false}, - Gt{"num_audio_files": 0}, - NotEq{"id": excludeFolderIDs}, - Or{ - // Direct children have path = parentPath; deeper descendants match the prefix - Eq{"path": parentPath}, - Expr(`path LIKE ? ESCAPE '\'`, escapeLikePrefix(parentPath)+"/%"), - }, - }) -} - -// escapeLikePrefix escapes SQL LIKE wildcards so a string can be used as a -// literal prefix in a LIKE pattern (with ESCAPE '\'). -func escapeLikePrefix(s string) string { - return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s) -} - -func (r folderRepository) Put(ctx context.Context, f *model.Folder) error { - dbf := dbFolder{Folder: f} - _, err := r.put(ctx, dbf.ID, &dbf) - return err -} - -func (r folderRepository) MarkMissing(ctx context.Context, missing bool, ids ...string) error { - log.Debug(ctx, "Marking folders as missing", "ids", ids, "missing", missing) - for chunk := range slices.Chunk(ids, 200) { - sq := Update(r.tableName). - Set("missing", missing). - Set("updated_at", time.Now()). - Where(Eq{"id": chunk}) - _, err := r.executeSQL(ctx, sq) - if err != nil { - return err - } - } - return nil -} - -func (r folderRepository) GetTouchedWithPlaylists(ctx context.Context) (model.FolderCursor, error) { - query := r.selectFolder(ctx).Where(And{ - Eq{"missing": false}, - Gt{"num_playlists": 0}, - ConcatExpr("folder.updated_at > library.last_scan_at"), - }) - cursor, err := queryWithStableResults[dbFolder](ctx, r.sqlRepository, query) - if err != nil { - return nil, err - } - return wrapFolderCursor(cursor), nil -} - -func (r folderRepository) GetAllWithPlaylists(ctx context.Context) (model.FolderCursor, error) { - query := r.selectFolder(ctx).Where(And{ - Eq{"missing": false}, - Gt{"num_playlists": 0}, - }) - cursor, err := queryWithStableResults[dbFolder](ctx, r.sqlRepository, query) - if err != nil { - return nil, err - } - return wrapFolderCursor(cursor), nil -} - -func wrapFolderCursor(cursor iter.Seq2[dbFolder, error]) model.FolderCursor { - return model.FolderCursor(wrapCursor(cursor, func(f dbFolder) *model.Folder { return f.Folder })) -} - -func (r folderRepository) purgeEmpty(ctx context.Context, libraryIDs ...int) error { - sq := Delete(r.tableName).Where(And{ - Eq{"num_audio_files": 0}, - Eq{"num_playlists": 0}, - Eq{"image_files": "[]"}, - ConcatExpr("id not in (select parent_id from folder)"), - ConcatExpr("id not in (select folder_id from media_file)"), - }) - // If libraryIDs are specified, only purge folders from those libraries - if len(libraryIDs) > 0 { - sq = sq.Where(Eq{"library_id": libraryIDs}) - } - c, err := r.executeSQL(ctx, sq) - if err != nil { - return fmt.Errorf("purging empty folders: %w", err) - } - if c > 0 { - log.Debug(ctx, "Purging empty folders", "totalDeleted", c) - } - return nil -} - -var _ model.FolderRepository = (*folderRepository)(nil) diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go deleted file mode 100644 index 4b1c54a3d..000000000 --- a/persistence/folder_repository_test.go +++ /dev/null @@ -1,391 +0,0 @@ -package persistence - -import ( - "context" - "errors" - "fmt" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" -) - -var _ = Describe("FolderRepository", func() { - var repo model.FolderRepository - var ctx context.Context - var conn *dbx.DB - var testLib, otherLib model.Library - - BeforeEach(func() { - ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) - conn = GetDBXBuilder() - repo = newFolderRepository(conn) - - // Use existing library ID 1 from test fixtures - libRepo := NewLibraryRepository(conn) - lib, err := libRepo.Get(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - testLib = *lib - - // Create a second library with its own folder to verify isolation - otherLib = model.Library{Name: "Other Library", Path: "/other/path"} - Expect(libRepo.Put(ctx, &otherLib)).To(Succeed()) - }) - - AfterEach(func() { - // Clean up only test folders created by our tests (paths starting with "Test") - // This prevents interference with fixture data needed by other tests - _, _ = conn.NewQuery("DELETE FROM folder WHERE library_id = 1 AND path LIKE 'Test%'").Execute() - _, _ = conn.NewQuery(fmt.Sprintf("DELETE FROM library WHERE id = %d", otherLib.ID)).Execute() - }) - - Describe("folderSubtreeFilter", func() { - var parent, child, grandchild, other *model.Folder - - matching := func(paths ...string) []string { - GinkgoHelper() - folders, err := repo.GetAll(ctx, model.QueryOptions{Filters: folderSubtreeFilter(testLib, paths)}) - Expect(err).ToNot(HaveOccurred()) - return slice.Map(folders, func(f model.Folder) string { return f.ID }) - } - - BeforeEach(func() { - parent = model.NewFolder(testLib, "TestSubtree") - child = model.NewFolder(testLib, "TestSubtree/Child") - grandchild = model.NewFolder(testLib, "TestSubtree/Child/Grandchild") - other = model.NewFolder(testLib, "TestSubtreeOther") - for _, f := range []*model.Folder{parent, child, grandchild, other} { - Expect(repo.Put(ctx, f)).To(Succeed()) - } - DeferCleanup(func() { - _, _ = conn.NewQuery("DELETE FROM folder WHERE name LIKE 'TestSubtree%' OR path LIKE 'TestSubtree%'").Execute() - }) - }) - - It("matches a folder and all its descendants", func() { - Expect(matching("TestSubtree")).To(ConsistOf(parent.ID, child.ID, grandchild.ID)) - }) - - It("matches the descendants of a nested slash-form path", func() { - Expect(matching("TestSubtree/Child")).To(ConsistOf(child.ID, grandchild.ID)) - }) - - It("matches the whole library for the root path", func() { - Expect(matching(".")).To(ContainElements(parent.ID, child.ID, grandchild.ID, other.ID)) - }) - }) - - Describe("GetFolderUpdateInfo", func() { - Context("with no target paths", func() { - It("returns all folders in the library", func() { - // Create test folders with unique names to avoid conflicts - folder1 := model.NewFolder(testLib, "TestGetLastUpdates/Folder1") - folder2 := model.NewFolder(testLib, "TestGetLastUpdates/Folder2") - - err := repo.Put(ctx, folder1) - Expect(err).ToNot(HaveOccurred()) - err = repo.Put(ctx, folder2) - Expect(err).ToNot(HaveOccurred()) - - otherFolder := model.NewFolder(otherLib, "TestOtherLib/Folder") - err = repo.Put(ctx, otherFolder) - Expect(err).ToNot(HaveOccurred()) - - // Query all folders (no target paths) - should only return folders from testLib - results, err := repo.GetFolderUpdateInfo(ctx, testLib) - Expect(err).ToNot(HaveOccurred()) - // Should include folders from testLib - Expect(results).To(HaveKey(folder1.ID)) - Expect(results).To(HaveKey(folder2.ID)) - // Should NOT include folders from other library - Expect(results).ToNot(HaveKey(otherFolder.ID)) - }) - }) - - Context("with specific target paths", func() { - It("returns folder info for existing folders", func() { - // Create test folders with unique names - folder1 := model.NewFolder(testLib, "TestSpecific/Rock") - folder2 := model.NewFolder(testLib, "TestSpecific/Jazz") - folder3 := model.NewFolder(testLib, "TestSpecific/Classical") - - err := repo.Put(ctx, folder1) - Expect(err).ToNot(HaveOccurred()) - err = repo.Put(ctx, folder2) - Expect(err).ToNot(HaveOccurred()) - err = repo.Put(ctx, folder3) - Expect(err).ToNot(HaveOccurred()) - - // Query specific paths - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestSpecific/Rock", "TestSpecific/Classical") - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(2)) - - // Verify folder IDs are in results - Expect(results).To(HaveKey(folder1.ID)) - Expect(results).To(HaveKey(folder3.ID)) - Expect(results).ToNot(HaveKey(folder2.ID)) - - // Verify update info is populated - Expect(results[folder1.ID].UpdatedAt).ToNot(BeZero()) - Expect(results[folder1.ID].Hash).To(Equal(folder1.Hash)) - }) - - It("includes all child folders when querying parent", func() { - // Create a parent folder with multiple children - parent := model.NewFolder(testLib, "TestParent/Music") - child1 := model.NewFolder(testLib, "TestParent/Music/Rock/Queen") - child2 := model.NewFolder(testLib, "TestParent/Music/Jazz") - otherParent := model.NewFolder(testLib, "TestParent2/Music/Jazz") - - Expect(repo.Put(ctx, parent)).To(Succeed()) - Expect(repo.Put(ctx, child1)).To(Succeed()) - Expect(repo.Put(ctx, child2)).To(Succeed()) - - // Query the parent folder - should return parent and all children - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestParent/Music") - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(3)) - Expect(results).To(HaveKey(parent.ID)) - Expect(results).To(HaveKey(child1.ID)) - Expect(results).To(HaveKey(child2.ID)) - Expect(results).ToNot(HaveKey(otherParent.ID)) - }) - - It("excludes children from other libraries", func() { - // Create parent in testLib - parent := model.NewFolder(testLib, "TestIsolation/Parent") - child := model.NewFolder(testLib, "TestIsolation/Parent/Child") - - Expect(repo.Put(ctx, parent)).To(Succeed()) - Expect(repo.Put(ctx, child)).To(Succeed()) - - // Create similar path in other library - otherParent := model.NewFolder(otherLib, "TestIsolation/Parent") - otherChild := model.NewFolder(otherLib, "TestIsolation/Parent/Child") - - Expect(repo.Put(ctx, otherParent)).To(Succeed()) - Expect(repo.Put(ctx, otherChild)).To(Succeed()) - - // Query should only return folders from testLib - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestIsolation/Parent") - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(2)) - Expect(results).To(HaveKey(parent.ID)) - Expect(results).To(HaveKey(child.ID)) - Expect(results).ToNot(HaveKey(otherParent.ID)) - Expect(results).ToNot(HaveKey(otherChild.ID)) - }) - - It("excludes missing children when querying parent", func() { - // Create parent and children, mark one as missing - parent := model.NewFolder(testLib, "TestMissingChild/Parent") - child1 := model.NewFolder(testLib, "TestMissingChild/Parent/Child1") - child2 := model.NewFolder(testLib, "TestMissingChild/Parent/Child2") - child2.Missing = true - - Expect(repo.Put(ctx, parent)).To(Succeed()) - Expect(repo.Put(ctx, child1)).To(Succeed()) - Expect(repo.Put(ctx, child2)).To(Succeed()) - - // Query parent - should only return parent and non-missing child - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestMissingChild/Parent") - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(2)) - Expect(results).To(HaveKey(parent.ID)) - Expect(results).To(HaveKey(child1.ID)) - Expect(results).ToNot(HaveKey(child2.ID)) - }) - - It("handles mix of existing and non-existing target paths", func() { - // Create folders for one path but not the other - existingParent := model.NewFolder(testLib, "TestMixed/Exists") - existingChild := model.NewFolder(testLib, "TestMixed/Exists/Child") - - Expect(repo.Put(ctx, existingParent)).To(Succeed()) - Expect(repo.Put(ctx, existingChild)).To(Succeed()) - - // Query both existing and non-existing paths - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestMixed/Exists", "TestMixed/DoesNotExist") - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(2)) - Expect(results).To(HaveKey(existingParent.ID)) - Expect(results).To(HaveKey(existingChild.ID)) - }) - - It("handles empty folder path as root", func() { - // Test querying for root folder without creating it (fixtures should have one) - rootFolderID := model.FolderID(testLib, ".") - - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "") - Expect(err).ToNot(HaveOccurred()) - // Should return the root folder if it exists - if len(results) > 0 { - Expect(results).To(HaveKey(rootFolderID)) - } - }) - - It("returns empty map for non-existent folders", func() { - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "NonExistent/Path") - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) - - It("skips missing folders", func() { - // Create a folder and mark it as missing - folder := model.NewFolder(testLib, "TestMissing/Folder") - folder.Missing = true - err := repo.Put(ctx, folder) - Expect(err).ToNot(HaveOccurred()) - - results, err := repo.GetFolderUpdateInfo(ctx, testLib, "TestMissing/Folder") - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) - }) - }) - - Describe("HasAudioOutsideFolders", func() { - var albumRoot, disc1, disc2 *model.Folder - - // TestHasAudio/Album/ - // ├── CD1/ (audio, belongs to the album) - // └── CD2/ (audio, belongs to the album) - BeforeEach(func() { - albumRoot = model.NewFolder(testLib, "TestHasAudio/Album") - disc1 = model.NewFolder(testLib, "TestHasAudio/Album/CD1") - disc1.NumAudioFiles = 5 - disc2 = model.NewFolder(testLib, "TestHasAudio/Album/CD2") - disc2.NumAudioFiles = 5 - for _, f := range []*model.Folder{albumRoot, disc1, disc2} { - Expect(repo.Put(ctx, f)).To(Succeed()) - } - }) - - It("returns false when all audio under the parent belongs to the given folders", func() { - Expect(repo.HasAudioOutsideFolders(ctx, *albumRoot, []string{disc1.ID, disc2.ID})).To(BeFalse()) - }) - - It("returns true when another folder under the parent has audio", func() { - bonus := model.NewFolder(testLib, "TestHasAudio/Album/Bonus") - bonus.NumAudioFiles = 1 - Expect(repo.Put(ctx, bonus)).To(Succeed()) - - Expect(repo.HasAudioOutsideFolders(ctx, *albumRoot, []string{disc1.ID, disc2.ID})).To(BeTrue()) - }) - - It("returns true when the parent itself contains audio files", func() { - albumRoot.NumAudioFiles = 2 - - Expect(repo.HasAudioOutsideFolders(ctx, *albumRoot, []string{disc1.ID, disc2.ID})).To(BeTrue()) - }) - - It("ignores audio outside the parent's subtree", func() { - other := model.NewFolder(testLib, "TestHasAudio/Other Album") - other.NumAudioFiles = 10 - Expect(repo.Put(ctx, other)).To(Succeed()) - - Expect(repo.HasAudioOutsideFolders(ctx, *albumRoot, []string{disc1.ID, disc2.ID})).To(BeFalse()) - }) - - It("ignores missing folders", func() { - gone := model.NewFolder(testLib, "TestHasAudio/Album/Gone") - gone.NumAudioFiles = 3 - gone.Missing = true - Expect(repo.Put(ctx, gone)).To(Succeed()) - - Expect(repo.HasAudioOutsideFolders(ctx, *albumRoot, []string{disc1.ID, disc2.ID})).To(BeFalse()) - }) - - It("does not treat LIKE wildcards in the parent path as patterns", func() { - // "TestHas_udio" would LIKE-match "TestHasAudio" if "_" were not escaped - wildcardRoot := model.NewFolder(testLib, "TestHas_udio/Album") - Expect(repo.Put(ctx, wildcardRoot)).To(Succeed()) - - Expect(repo.HasAudioOutsideFolders(ctx, *wildcardRoot, []string{"none"})).To(BeFalse()) - }) - }) - - Describe("dbFolder.String", func() { - It("does not dereference a nil Folder", func() { - Expect(fmt.Sprint(dbFolder{})).To(Equal("")) - Expect(fmt.Sprint(&dbFolder{})).To(Equal("")) - }) - }) - - Describe("wrapFolderCursor", func() { - It("does not panic when the cursor yields a dbFolder with nil Folder", func() { - // Simulate what queryWithStableResults does on the rows.Err() path: - // it yields a zero-value dbFolder (where Folder is nil) with an error. - dbErr := fmt.Errorf("database is locked") - cursor := func(yield func(dbFolder, error) bool) { - var empty dbFolder // Folder pointer is nil - yield(empty, dbErr) - } - - // wrapFolderCursor should handle the nil Folder without panicking - wrappedCursor := wrapFolderCursor(cursor) - var gotErr error - Expect(func() { - for _, err := range wrappedCursor { - gotErr = err - } - }).ToNot(Panic()) - Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.Folder")) - Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") - }) - - It("yields folders from a valid cursor", func() { - folder := &model.Folder{ID: "f1", Name: "Test"} - cursor := func(yield func(dbFolder, error) bool) { - yield(dbFolder{Folder: folder}, nil) - } - - wrappedCursor := wrapFolderCursor(cursor) - var folders []model.Folder - for f, err := range wrappedCursor { - Expect(err).ToNot(HaveOccurred()) - folders = append(folders, f) - } - Expect(folders).To(HaveLen(1)) - Expect(folders[0].ID).To(Equal("f1")) - }) - }) - - Describe("GetAllWithPlaylists", func() { - It("returns all non-missing folders with playlists, ignoring the scan-timestamp gate", func() { - withPls := model.NewFolder(testLib, "TestAllPls/WithPls") - withPls.NumPlaylists = 2 - noPls := model.NewFolder(testLib, "TestAllPls/NoPls") - noPls.NumPlaylists = 0 - missingWithPls := model.NewFolder(testLib, "TestAllPls/Missing") - missingWithPls.NumPlaylists = 1 - missingWithPls.Missing = true - - Expect(repo.Put(ctx, withPls)).To(Succeed()) - Expect(repo.Put(ctx, noPls)).To(Succeed()) - Expect(repo.Put(ctx, missingWithPls)).To(Succeed()) - - // Force the folder's updated_at to the past so GetTouchedWithPlaylists - // (which gates on updated_at > last_scan_at) would NOT return it. - _, err := conn.NewQuery("UPDATE folder SET updated_at = {:t} WHERE id = {:id}"). - Bind(dbx.Params{"t": "2000-01-01 00:00:00", "id": withPls.ID}).Execute() - Expect(err).ToNot(HaveOccurred()) - - var ids []string - cursor, err := repo.GetAllWithPlaylists(ctx) - Expect(err).ToNot(HaveOccurred()) - for f, err := range cursor { - Expect(err).ToNot(HaveOccurred()) - ids = append(ids, f.ID) - } - - Expect(ids).To(ConsistOf(withPls.ID)) // only the non-missing folder with playlists - }) - }) -}) diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go index d88cb8672..fe6c6f52e 100644 --- a/persistence/genre_repository.go +++ b/persistence/genre_repository.go @@ -3,49 +3,101 @@ package persistence import ( "context" + "github.com/google/uuid" + . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "github.com/deluan/rest" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/pocketbase/dbx" ) type genreRepository struct { - *baseTagRepository + sqlRepository + sqlRestful } -func NewGenreRepository(db dbx.Builder) model.GenreRepository { - return &genreRepository{ - baseTagRepository: newBaseTagRepository(db, new(model.TagGenre)), +func NewGenreRepository(ctx context.Context, o orm.QueryExecutor) model.GenreRepository { + r := &genreRepository{} + r.ctx = ctx + r.ormer = o + r.tableName = "genre" + r.filterMappings = map[string]filterFunc{ + "name": containsFilter, } + return r } -func (r *genreRepository) selectGenre(ctx context.Context, opt ...model.QueryOptions) SelectBuilder { - return r.newSelect(ctx, opt...).Columns("tag.tag_value as name") -} - -func (r *genreRepository) GetAll(ctx context.Context, opt ...model.QueryOptions) (model.Genres, error) { - sq := r.selectGenre(ctx, opt...) +func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error) { + sq := r.newSelect(opt...).Columns("genre.id", "genre.name", "a.album_count", "m.song_count"). + LeftJoin("(select ag.genre_id, count(ag.album_id) as album_count from album_genres ag group by ag.genre_id) a on a.genre_id = genre.id"). + LeftJoin("(select mg.genre_id, count(mg.media_file_id) as song_count from media_file_genres mg group by mg.genre_id) m on m.genre_id = genre.id") res := model.Genres{} - err := r.queryAll(ctx, sq, &res) + err := r.queryAll(sq, &res) return res, err } -func (r *genreRepository) Get(ctx context.Context, id string) (*model.Genre, error) { - sel := r.selectGenre(ctx).Where(Eq{"tag.id": id}) +// Put is an Upsert operation, based on the name of the genre: If the name already exists, returns its ID, or else +// insert the new genre in the DB and returns its new created ID. +func (r *genreRepository) Put(m *model.Genre) error { + if m.ID == "" { + m.ID = uuid.NewString() + } + sql := Insert("genre").Columns("id", "name").Values(m.ID, m.Name). + Suffix("on conflict (name) do update set name=excluded.name returning id") + resp := model.Genre{} + err := r.queryOne(sql, &resp) + if err != nil { + return err + } + m.ID = resp.ID + return nil +} + +func (r *genreRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.count(Select(), r.parseRestOptions(options...)) +} + +func (r *genreRepository) Read(id string) (interface{}, error) { + sel := r.newSelect().Columns("*").Where(Eq{"id": id}) var res model.Genre - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) return &res, err } -// Override the base tag REST methods to return Genre objects instead of Tag objects - -func (r *genreRepository) Read(ctx context.Context, id string) (*model.Genre, error) { - return r.Get(ctx, id) +func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + sel := r.newSelect(r.parseRestOptions(options...)).Columns("*") + res := model.Genres{} + err := r.queryAll(sel, &res) + return res, err } -func (r *genreRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Genre, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *genreRepository) EntityName() string { + return r.tableName +} + +func (r *genreRepository) NewInstance() interface{} { + return &model.Genre{} +} + +func (r *genreRepository) purgeEmpty() error { + del := Delete(r.tableName).Where(`id in ( +select genre.id from genre +left join album_genres ag on genre.id = ag.genre_id +left join artist_genres a on genre.id = a.genre_id +left join media_file_genres mfg on genre.id = mfg.genre_id +where ag.genre_id is null +and a.genre_id is null +and mfg.genre_id is null +)`) + c, err := r.executeSQL(del) + if err == nil { + if c > 0 { + log.Debug(r.ctx, "Purged unused genres", "totalDeleted", c) + } + } + return err } var _ model.GenreRepository = (*genreRepository)(nil) -var _ rest.Repository[model.Genre] = (*genreRepository)(nil) +var _ model.ResourceRepository = (*genreRepository)(nil) diff --git a/persistence/genre_repository_test.go b/persistence/genre_repository_test.go index 52bebf446..212e1a1ba 100644 --- a/persistence/genre_repository_test.go +++ b/persistence/genre_repository_test.go @@ -1,314 +1,57 @@ -package persistence +package persistence_test import ( "context" - "slices" - "strings" - "github.com/Masterminds/squirrel" - "github.com/deluan/rest" + "github.com/beego/beego/v2/client/orm" + "github.com/google/uuid" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("GenreRepository", func() { var repo model.GenreRepository - var restRepo rest.Repository[model.Genre] - var tagRepo model.TagRepository - var ctx context.Context BeforeEach(func() { - ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) - genreRepo := NewGenreRepository(GetDBXBuilder()) - repo = genreRepo - restRepo = genreRepo - tagRepo = NewTagRepository(GetDBXBuilder()) - - // Clear any existing tags to ensure test isolation - db := GetDBXBuilder() - _, err := db.NewQuery("DELETE FROM tag").Execute() - Expect(err).ToNot(HaveOccurred()) - - // Ensure library 1 exists and user has access to it - _, err = db.NewQuery("INSERT OR IGNORE INTO library (id, name, path, default_new_users) VALUES (1, 'Test Library', '/test', true)").Execute() - Expect(err).ToNot(HaveOccurred()) - _, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 1)").Execute() - Expect(err).ToNot(HaveOccurred()) - - // Add comprehensive test data that covers all test scenarios - newTag := func(name, value string) model.Tag { - return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} - } - - err = tagRepo.Add(ctx, 1, - newTag("genre", "rock"), - newTag("genre", "pop"), - newTag("genre", "jazz"), - newTag("genre", "electronic"), - newTag("genre", "classical"), - newTag("genre", "ambient"), - newTag("genre", "techno"), - newTag("genre", "house"), - newTag("genre", "trance"), - newTag("genre", "Alternative Rock"), - newTag("genre", "Blues"), - newTag("genre", "Country"), - // These should not be counted as genres - newTag("mood", "happy"), - newTag("mood", "ambient"), - ) - Expect(err).ToNot(HaveOccurred()) + repo = persistence.NewGenreRepository(log.NewContext(context.TODO()), orm.NewOrm()) }) - Describe("GetAll", func() { - It("should return all genres", func() { - genres, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(genres).To(HaveLen(12)) - - // Verify that all returned items are genres (TagName = "genre") - genreNames := make([]string, len(genres)) - for i, genre := range genres { - genreNames[i] = genre.Name - } - Expect(genreNames).To(ContainElement("rock")) - Expect(genreNames).To(ContainElement("pop")) - Expect(genreNames).To(ContainElement("jazz")) - // Should not contain mood tags - Expect(genreNames).ToNot(ContainElement("happy")) - }) - - It("should support query options", func() { - // Test with limiting results - genres, err := repo.GetAll(ctx, model.QueryOptions{Max: 1}) - Expect(err).ToNot(HaveOccurred()) - Expect(genres).To(HaveLen(1)) - }) - - It("should handle empty results gracefully", func() { - // Clear all genre tags - _, err := GetDBXBuilder().NewQuery("DELETE FROM tag WHERE tag_name = 'genre'").Execute() - Expect(err).ToNot(HaveOccurred()) - - genres, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(genres).To(BeEmpty()) - }) - Describe("filtering and sorting", func() { - It("should filter by name using like match", func() { - // Test filtering by partial name match using the "name" filter which maps to containsFilter("tag_value") - options := model.QueryOptions{ - Filters: squirrel.Like{"tag_value": "%rock%"}, // Direct field access - } - genres, err := repo.GetAll(ctx, options) - Expect(err).ToNot(HaveOccurred()) - Expect(genres).To(HaveLen(2)) // Should match "rock" and "Alternative Rock" - - // Verify all returned genres contain "rock" in their name - for _, genre := range genres { - Expect(strings.ToLower(genre.Name)).To(ContainSubstring("rock")) - } - }) - - It("should sort by name in ascending order", func() { - // Test sorting by name with the fixed mapping - options := model.QueryOptions{ - Filters: squirrel.Like{"tag_value": "%e%"}, // Should match genres containing "e" - Sort: "name", - } - genres, err := repo.GetAll(ctx, options) - Expect(err).ToNot(HaveOccurred()) - Expect(genres).To(HaveLen(7)) - - Expect(slices.IsSortedFunc(genres, func(a, b model.Genre) int { - return strings.Compare(b.Name, a.Name) // Inverted to check descending order - })) - }) - - It("should sort by name in descending order", func() { - // Test sorting by name in descending order - options := model.QueryOptions{ - Filters: squirrel.Like{"tag_value": "%e%"}, // Should match genres containing "e" - Sort: "name", - Order: "desc", - } - genres, err := repo.GetAll(ctx, options) - Expect(err).ToNot(HaveOccurred()) - Expect(genres).To(HaveLen(7)) - - Expect(slices.IsSortedFunc(genres, func(a, b model.Genre) int { - return strings.Compare(a.Name, b.Name) - })) - }) + Describe("GetAll()", func() { + It("returns all records", func() { + genres, err := repo.GetAll() + Expect(err).To(BeNil()) + Expect(genres).To(ConsistOf( + model.Genre{ID: "gn-1", Name: "Electronic", AlbumCount: 1, SongCount: 2}, + model.Genre{ID: "gn-2", Name: "Rock", AlbumCount: 3, SongCount: 3}, + )) }) }) + Describe("Put()", Ordered, func() { + It("does not insert existing genre names", func() { + g := model.Genre{Name: "Rock"} + err := repo.Put(&g) + Expect(err).To(BeNil()) + Expect(g.ID).To(Equal("gn-2")) - Describe("Count", func() { - It("should return correct count of genres", func() { - count, err := restRepo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(12))) // We have 12 genre tags + genres, _ := repo.GetAll() + Expect(genres).To(HaveLen(2)) }) - It("should handle zero count", func() { - // Clear all genre tags - _, err := GetDBXBuilder().NewQuery("DELETE FROM tag WHERE tag_name = 'genre'").Execute() - Expect(err).ToNot(HaveOccurred()) + It("insert non-existent genre names", func() { + g := model.Genre{Name: "Reggae"} + err := repo.Put(&g) + Expect(err).To(BeNil()) - count, err := restRepo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeZero()) - }) + // ID is a uuid + _, err = uuid.Parse(g.ID) + Expect(err).To(BeNil()) - It("should only count genre tags", func() { - // Add a non-genre tag - nonGenreTag := model.Tag{ - ID: id.NewTagID("mood", "energetic"), - TagName: "mood", - TagValue: "energetic", - } - err := tagRepo.Add(ctx, 1, nonGenreTag) - Expect(err).ToNot(HaveOccurred()) - - count, err := restRepo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - // Count should not include the mood tag - Expect(count).To(Equal(int64(12))) // Should still be 12 genre tags - }) - - It("should filter by name using like match", func() { - // Test filtering by partial name match using the "name" filter which maps to containsFilter("tag_value") - options := rest.QueryOptions{ - Filters: map[string]any{"name": "%rock%"}, - } - count, err := restRepo.Count(ctx, options) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeNumerically("==", 2)) - }) - }) - - Describe("Read", func() { - It("should return existing genre", func() { - // Use one of the existing genres from our consolidated dataset - genreID := id.NewTagID("genre", "rock") - genre, err := restRepo.Read(ctx, genreID) - Expect(err).ToNot(HaveOccurred()) - Expect(genre.ID).To(Equal(genreID)) - Expect(genre.Name).To(Equal("rock")) - }) - - It("should return error for non-existent genre", func() { - _, err := restRepo.Read(ctx, "non-existent-id") - Expect(err).To(HaveOccurred()) - }) - - It("should not return non-genre tags", func() { - moodID := id.NewTagID("mood", "happy") // This exists as a mood tag, not genre - _, err := restRepo.Read(ctx, moodID) - Expect(err).To(HaveOccurred()) // Should not find it as a genre - }) - }) - - Describe("ReadAll", func() { - It("should return all genres through ReadAll", func() { - genres, err := restRepo.ReadAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(genres).To(HaveLen(12)) // We have 12 genre tags - - genreNames := make([]string, len(genres)) - for i, genre := range genres { - genreNames[i] = genre.Name - } - // Check for some of our consolidated dataset genres - Expect(genreNames).To(ContainElement("rock")) - Expect(genreNames).To(ContainElement("pop")) - Expect(genreNames).To(ContainElement("jazz")) - }) - - It("should support rest query options", func() { - result, err := restRepo.ReadAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - }) - }) - - Describe("Library Filtering", func() { - Context("Headless Processes (No User Context)", func() { - var headlessRepo model.GenreRepository - var headlessRestRepo rest.Repository[model.Genre] - var headlessCtx context.Context - - BeforeEach(func() { - headlessCtx = GinkgoT().Context() - // Create a repository with no user context (headless) - headlessGenreRepo := NewGenreRepository(GetDBXBuilder()) - headlessRepo = headlessGenreRepo - headlessRestRepo = headlessGenreRepo - - // Add genres to different libraries - db := GetDBXBuilder() - _, err := db.NewQuery("INSERT OR IGNORE INTO library (id, name, path) VALUES (2, 'Test Library 2', '/test2')").Execute() - Expect(err).ToNot(HaveOccurred()) - - // Add tags to different libraries - newTag := func(name, value string) model.Tag { - return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} - } - - err = tagRepo.Add(ctx, 2, newTag("genre", "jazz")) - Expect(err).ToNot(HaveOccurred()) - }) - - It("should see all genres from all libraries when no user is in context", func() { - // Headless processes should see all genres regardless of library - genres, err := headlessRepo.GetAll(headlessCtx) - Expect(err).ToNot(HaveOccurred()) - - // Should see genres from all libraries - var genreNames []string - for _, genre := range genres { - genreNames = append(genreNames, genre.Name) - } - - // Should include both rock (library 1) and jazz (library 2) - Expect(genreNames).To(ContainElement("rock")) - Expect(genreNames).To(ContainElement("jazz")) - }) - - It("should count all genres from all libraries when no user is in context", func() { - count, err := headlessRestRepo.Count(headlessCtx) - Expect(err).ToNot(HaveOccurred()) - - // Should count all genres from all libraries - Expect(count).To(BeNumerically(">=", 2)) - }) - - It("should allow headless processes to apply explicit library_id filters", func() { - // Filter by specific library - genreList, err := headlessRestRepo.ReadAll(headlessCtx, rest.QueryOptions{ - Filters: map[string]any{"library_id": 2}, - }) - Expect(err).ToNot(HaveOccurred()) - - // Should see only genres from library 2 - Expect(genreList).To(HaveLen(1)) - Expect(genreList[0].Name).To(Equal("jazz")) - }) - - It("should get individual genres when no user is in context", func() { - // Get all genres first to find an ID - genres, err := headlessRepo.GetAll(headlessCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(genres).ToNot(BeEmpty()) - - // Headless process should be able to get the genre - genre, err := headlessRestRepo.Read(headlessCtx, genres[0].ID) - Expect(err).ToNot(HaveOccurred()) - Expect(genre).ToNot(BeNil()) - }) + genres, _ := repo.GetAll() + Expect(genres).To(HaveLen(3)) + Expect(genres).To(ContainElement(model.Genre{ID: g.ID, Name: "Reggae", AlbumCount: 0, SongCount: 0})) }) }) }) diff --git a/persistence/helpers.go b/persistence/helpers.go index 1da31cf02..4d756dce8 100644 --- a/persistence/helpers.go +++ b/persistence/helpers.go @@ -1,7 +1,6 @@ package persistence import ( - "database/sql/driver" "fmt" "regexp" "strings" @@ -9,34 +8,16 @@ import ( "github.com/Masterminds/squirrel" "github.com/fatih/structs" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/db" ) -type PostMapper interface { - PostMapArgs(map[string]any) error -} - -func toSQLArgs(rec any) (map[string]any, error) { +func toSqlArgs(rec interface{}) (map[string]interface{}, error) { m := structs.Map(rec) for k, v := range m { - switch t := v.(type) { - case *time.Time: - if t != nil { - m[k] = *t - } - case driver.Valuer: - var err error - m[k], err = t.Value() - if err != nil { - return nil, err - } + if t, ok := v.(time.Time); ok { + m[k] = t.Format(time.RFC3339Nano) } - } - if r, ok := rec.(PostMapper); ok { - err := r.PostMapArgs(m) - if err != nil { - return nil, err + if t, ok := v.(*time.Time); ok && t != nil { + m[k] = t.Format(time.RFC3339Nano) } } return m, nil @@ -51,19 +32,11 @@ func toSnakeCase(str string) string { return strings.ToLower(snake) } -var matchUnderscore = regexp.MustCompile("_([A-Za-z])") - -func toCamelCase(str string) string { - return matchUnderscore.ReplaceAllStringFunc(str, func(s string) string { - return strings.ToUpper(strings.Replace(s, "_", "", -1)) - }) -} - -func Exists(subTable string, cond squirrel.Sqlizer) existsCond { +func exists(subTable string, cond squirrel.Sqlizer) existsCond { return existsCond{subTable: subTable, cond: cond, not: false} } -func NotExists(subTable string, cond squirrel.Sqlizer) existsCond { +func notExists(subTable string, cond squirrel.Sqlizer) existsCond { return existsCond{subTable: subTable, cond: cond, not: true} } @@ -73,7 +46,7 @@ type existsCond struct { not bool } -func (e existsCond) ToSql() (string, []any, error) { +func (e existsCond) ToSql() (string, []interface{}, error) { sql, args, err := e.cond.ToSql() sql = fmt.Sprintf("exists (select 1 from %s where %s)", e.subTable, sql) if e.not { @@ -81,31 +54,3 @@ func (e existsCond) ToSql() (string, []any, error) { } return sql, args, err } - -var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`) - -// naturalSort makes a plain text column sort numbers by value, leaving it alone -// otherwise so it keeps its declared collation. Parens guard buildSortOrder's space split. -func naturalSort(col string) string { - if !conf.Server.EnableNaturalSorting { - return col - } - return fmt.Sprintf("(%s collate %s)", col, db.NaturalCollation) -} - -// Convert the order_* columns to a collated sort expression, falling back to the -// sort_* column when those are preferred. Example: -// order_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase) -// It finds order column names anywhere in the substring -func mapSortOrder(tableName, order string) string { - col := tableName + ".order_$1" - if conf.Server.PreferSortTags { - col = fmt.Sprintf("coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1)", tableName) - } - collation := "nocase" - if conf.Server.EnableNaturalSorting { - collation = db.NaturalCollation - } - repl := fmt.Sprintf("(%s collate %s)", col, collation) - return sortOrderRegex.ReplaceAllString(strings.ToLower(order), repl) -} diff --git a/persistence/helpers_test.go b/persistence/helpers_test.go index 3019609f3..ba3d1c499 100644 --- a/persistence/helpers_test.go +++ b/persistence/helpers_test.go @@ -4,8 +4,6 @@ import ( "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -25,21 +23,7 @@ var _ = Describe("Helpers", func() { Expect(toSnakeCase("snake_case")).To(Equal("snake_case")) }) }) - Describe("toCamelCase", func() { - It("converts snake_case", func() { - Expect(toCamelCase("snake_case")).To(Equal("snakeCase")) - }) - It("converts PascalCase", func() { - Expect(toCamelCase("PascalCase")).To(Equal("PascalCase")) - }) - It("converts camelCase", func() { - Expect(toCamelCase("camelCase")).To(Equal("camelCase")) - }) - It("converts ALLCAPS", func() { - Expect(toCamelCase("ALLCAPS")).To(Equal("ALLCAPS")) - }) - }) - Describe("toSQLArgs", func() { + Describe("toSqlArgs", func() { type Embed struct{} type Model struct { Embed `structs:"-"` @@ -53,22 +37,19 @@ var _ = Describe("Helpers", func() { It("returns a map with snake_case keys", func() { now := time.Now() m := &Model{ID: "123", AlbumId: "456", CreatedAt: now, UpdatedAt: &now, PlayCount: 2} - args, err := toSQLArgs(m) + args, err := toSqlArgs(m) Expect(err).To(BeNil()) - Expect(args).To(SatisfyAll( - HaveKeyWithValue("id", "123"), - HaveKeyWithValue("album_id", "456"), - HaveKeyWithValue("play_count", 2), - HaveKeyWithValue("updated_at", BeTemporally("~", now)), - HaveKeyWithValue("created_at", BeTemporally("~", now)), - Not(HaveKey("Embed")), - )) + Expect(args).To(HaveKeyWithValue("id", "123")) + Expect(args).To(HaveKeyWithValue("album_id", "456")) + Expect(args).To(HaveKeyWithValue("updated_at", now.Format(time.RFC3339Nano))) + Expect(args).To(HaveKeyWithValue("created_at", now.Format(time.RFC3339Nano))) + Expect(args).ToNot(HaveKey("Embed")) }) }) - Describe("Exists", func() { + Describe("exists", func() { It("constructs the correct EXISTS query", func() { - e := Exists("album", squirrel.Eq{"id": 1}) + e := exists("album", squirrel.Eq{"id": 1}) sql, args, err := e.ToSql() Expect(sql).To(Equal("exists (select 1 from album where id = ?)")) Expect(args).To(ConsistOf(1)) @@ -76,62 +57,13 @@ var _ = Describe("Helpers", func() { }) }) - Describe("NotExists", func() { + Describe("notExists", func() { It("constructs the correct NOT EXISTS query", func() { - e := NotExists("artist", squirrel.ConcatExpr("id = artist_id")) + e := notExists("artist", squirrel.ConcatExpr("id = artist_id")) sql, args, err := e.ToSql() Expect(sql).To(Equal("not exists (select 1 from artist where id = artist_id)")) Expect(args).To(BeEmpty()) Expect(err).To(BeNil()) }) }) - - Describe("mapSortOrder", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("does not change the sort string if there are no order columns", func() { - Expect(mapSortOrder("album", "album_name asc")).To(Equal("album_name asc")) - }) - - DescribeTable("maps order columns to a collated expression", - func(preferSortTags, naturalSorting bool, expected string) { - conf.Server.PreferSortTags = preferSortTags - conf.Server.EnableNaturalSorting = naturalSorting - Expect(mapSortOrder("album", "ORDER_ALBUM_NAME asc")).To(Equal(expected)) - }, - Entry("qualified column", false, false, - "(album.order_album_name collate nocase) asc"), - Entry("natural collation", false, true, - "(album.order_album_name collate NATSORT) asc"), - Entry("sort tags preferred", true, false, - `(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate nocase) asc`), - Entry("sort tags preferred, natural collation", true, true, - `(coalesce(nullif(album.sort_album_name,''),album.order_album_name) collate NATSORT) asc`), - ) - - It("changes multiple order columns to sort expressions", func() { - conf.Server.PreferSortTags = true - sort := "compilation, order_title asc, order_album_artist_name desc, year desc" - Expect(mapSortOrder("album", sort)).To(Equal( - `compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` + - ` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`)) - }) - }) - - Describe("naturalSort", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("leaves the column alone by default, keeping its declared collation", func() { - Expect(naturalSort("media_file.title")).To(Equal("media_file.title")) - }) - - It("applies the natural collation when enabled", func() { - conf.Server.EnableNaturalSorting = true - Expect(naturalSort("media_file.title")).To(Equal("(media_file.title collate NATSORT)")) - }) - }) }) diff --git a/persistence/item_tags_test.go b/persistence/item_tags_test.go deleted file mode 100644 index 1106c7e9c..000000000 --- a/persistence/item_tags_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package persistence - -import ( - "context" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" -) - -var _ = Describe("item genre tag indexes", func() { - var conn *dbx.DB - var mr model.MediaFileRepository - var ar model.AlbumRepository - var rock, jazz model.Tag - var ctx context.Context - - BeforeEach(func() { - ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid"}) - conn = GetDBXBuilder() - mr = NewMediaFileRepository(conn) - ar = NewAlbumRepository(conn) - // Test-only genre values, so they can't collide with the golden fixtures. - rock = model.NewTag(model.TagGenre, "GenreIdxRock") - jazz = model.NewTag(model.TagGenre, "GenreIdxJazz") - // The join tables FK to tag(id); the scanner adds tags before saving items. - Expect(NewTagRepository(conn).Add(ctx, 1, rock, jazz)).To(Succeed()) - // The suite shares one golden DB with no per-test restore, so undo the rows we add - // (media_file/album deletes cascade to the *_tags join rows; tag deletes cascade too). - DeferCleanup(func() { - _, _ = conn.NewQuery("DELETE FROM media_file WHERE id LIKE 'mf-%'").Execute() - _, _ = conn.NewQuery("DELETE FROM album WHERE id LIKE 'al-%'").Execute() - _, _ = conn.NewQuery("DELETE FROM tag WHERE id={:r} OR id={:j}"). - Bind(dbx.Params{"r": rock.ID, "j": jazz.ID}).Execute() - }) - }) - - tagIDsFor := func(table, col, id string) []string { - var rows []struct { - TagID string `db:"tag_id"` - } - err := conn.NewQuery("SELECT tag_id FROM " + table + " WHERE " + col + "={:id}"). - Bind(dbx.Params{"id": id}).All(&rows) - Expect(err).ToNot(HaveOccurred()) - ids := make([]string, len(rows)) - for i, r := range rows { - ids[i] = r.TagID - } - return ids - } - - Describe("media files", func() { - It("writes a media_file_tags row for each genre when the track is saved", func() { - mf := model.MediaFile{ID: "mf-g1", LibraryID: 1, Path: "/m/g1.mp3", Title: "G1", - Tags: model.Tags{model.TagGenre: []string{rock.TagValue, jazz.TagValue}}} - Expect(mr.Put(ctx, &mf)).To(Succeed()) - Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g1")).To(ConsistOf(rock.ID, jazz.ID)) - }) - - It("replaces the rows when the genres change", func() { - mf := model.MediaFile{ID: "mf-g2", LibraryID: 1, Path: "/m/g2.mp3", Title: "G2", - Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} - Expect(mr.Put(ctx, &mf)).To(Succeed()) - mf.Tags = model.Tags{model.TagGenre: []string{jazz.TagValue}} - Expect(mr.Put(ctx, &mf)).To(Succeed()) - Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g2")).To(ConsistOf(jazz.ID)) - }) - - It("clears the rows when all genres are removed", func() { - mf := model.MediaFile{ID: "mf-g3", LibraryID: 1, Path: "/m/g3.mp3", Title: "G3", - Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} - Expect(mr.Put(ctx, &mf)).To(Succeed()) - mf.Tags = model.Tags{} - Expect(mr.Put(ctx, &mf)).To(Succeed()) - Expect(tagIDsFor("media_file_tags", "media_file_id", "mf-g3")).To(BeEmpty()) - }) - }) - - Describe("albums", func() { - It("writes an album_tags row for each genre when the album is saved", func() { - al := model.Album{ID: "al-g1", LibraryID: 1, Name: "AG1", - Tags: model.Tags{model.TagGenre: []string{rock.TagValue, jazz.TagValue}}} - Expect(ar.Put(ctx, &al)).To(Succeed()) - Expect(tagIDsFor("album_tags", "album_id", "al-g1")).To(ConsistOf(rock.ID, jazz.ID)) - }) - }) - - // The native (REST) API filters by genre_id; it must resolve through the join table too. - Describe("native genre_id filter", func() { - It("filters media files by genre_id", func() { - mf := model.MediaFile{ID: "mf-nat1", LibraryID: 1, Path: "/m/nat1.mp3", Title: "Nat1", - Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} - Expect(mr.Put(ctx, &mf)).To(Succeed()) - res, err := mr.ReadAll(ctx, rest.QueryOptions{Filters: map[string]any{"genre_id": rock.ID}}) - Expect(err).ToNot(HaveOccurred()) - Expect(res).To(ContainElement(HaveField("ID", "mf-nat1"))) - }) - - It("filters albums by genre_id", func() { - al := model.Album{ID: "al-nat1", LibraryID: 1, Name: "ANat1", - Tags: model.Tags{model.TagGenre: []string{rock.TagValue}}} - Expect(ar.Put(ctx, &al)).To(Succeed()) - res, err := ar.ReadAll(ctx, rest.QueryOptions{Filters: map[string]any{"genre_id": rock.ID}}) - Expect(err).ToNot(HaveOccurred()) - Expect(res).To(ContainElement(HaveField("ID", "al-nat1"))) - }) - }) -}) diff --git a/persistence/library_repository.go b/persistence/library_repository.go deleted file mode 100644 index 85da65cbc..000000000 --- a/persistence/library_repository.go +++ /dev/null @@ -1,329 +0,0 @@ -package persistence - -import ( - "context" - "fmt" - "strconv" - "sync" - "time" - - . "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/utils/run" - "github.com/pocketbase/dbx" -) - -type libraryRepository struct { - sqlRepository -} - -var ( - libCache = map[int]string{} - libLock sync.RWMutex -) - -func NewLibraryRepository(db dbx.Builder) model.LibraryRepository { - r := &libraryRepository{} - r.db = db - r.registerModel(&model.Library{}, nil) - return r -} - -func (r *libraryRepository) Get(ctx context.Context, id int) (*model.Library, error) { - sq := r.newSelect(ctx).Columns("*").Where(Eq{"id": id}) - var res model.Library - err := r.queryOne(ctx, sq, &res) - return &res, err -} - -func (r *libraryRepository) GetPath(ctx context.Context, id int) (string, error) { - l := func() string { - libLock.RLock() - defer libLock.RUnlock() - if l, ok := libCache[id]; ok { - return l - } - return "" - }() - if l != "" { - return l, nil - } - - libLock.Lock() - defer libLock.Unlock() - libs, err := r.GetAll(ctx) - if err != nil { - log.Error(ctx, "Error loading libraries from DB", err) - return "", err - } - for _, l := range libs { - libCache[l.ID] = l.Path - } - if l, ok := libCache[id]; ok { - return l, nil - } else { - return "", model.ErrNotFound - } -} - -func (r *libraryRepository) Put(ctx context.Context, l *model.Library, colsToUpdate ...string) error { - if l.ID == model.DefaultLibraryID { - currentLib, err := r.Get(ctx, 1) - // if we are creating it, it's ok. - if err == nil { // it exists, so we are updating it - if currentLib.Path != l.Path { - return fmt.Errorf("%w: path for library with ID 1 cannot be changed", model.ErrValidation) - } - } - } - - var err error - l.UpdatedAt = time.Now() - if l.ID == 0 { - // Insert with autoassigned ID - l.CreatedAt = time.Now() - err = r.db.Model(l).Insert() - } else { - // Try to update first - cols := selectUpdateColumns(map[string]any{ - "name": l.Name, - "path": l.Path, - "remote_path": l.RemotePath, - "default_new_users": l.DefaultNewUsers, - "pid_album": l.PIDAlbum, - "pid_track": l.PIDTrack, - }, colsToUpdate...) - cols["updated_at"] = l.UpdatedAt - sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID}) - rowsAffected, updateErr := r.executeSQL(ctx, sq) - if updateErr != nil { - return updateErr - } - - // If no rows were affected, the record doesn't exist, so insert it - if rowsAffected == 0 { - l.CreatedAt = time.Now() - l.UpdatedAt = time.Now() - err = r.db.Model(l).Insert() - } - } - if err != nil { - return err - } - - // Auto-assign all libraries to all admin users - sql := Expr(` -INSERT INTO user_library (user_id, library_id) -SELECT u.id, l.id -FROM user u -CROSS JOIN library l -WHERE u.is_admin = true -ON CONFLICT (user_id, library_id) DO NOTHING;`, - ) - if _, err = r.executeSQL(ctx, sql); err != nil { - return fmt.Errorf("failed to assign library to admin users: %w", err) - } - - libLock.Lock() - defer libLock.Unlock() - libCache[l.ID] = l.Path - return nil -} - -// TODO Remove this method when we have a proper UI to add libraries -// This is a temporary method to store the music folder path from the config in the DB -func (r *libraryRepository) StoreMusicFolder(ctx context.Context) error { - sq := Update(r.tableName).Set("path", conf.Server.MusicFolder). - Set("updated_at", time.Now()). - Where(Eq{"id": model.DefaultLibraryID}). - Where(NotEq{"path": conf.Server.MusicFolder}) - rowsAffected, err := r.executeSQL(ctx, sq) - if err == nil && rowsAffected > 0 { - libLock.Lock() - defer libLock.Unlock() - libCache[model.DefaultLibraryID] = conf.Server.MusicFolder - } - return err -} - -func (r *libraryRepository) AddArtist(ctx context.Context, id int, artistID string) error { - sq := Insert("library_artist").Columns("library_id", "artist_id").Values(id, artistID). - Suffix(`on conflict(library_id, artist_id) do nothing`) - _, err := r.executeSQL(ctx, sq) - if err != nil { - return err - } - return nil -} - -func (r *libraryRepository) ScanBegin(ctx context.Context, id int, fullScan bool) error { - sq := Update(r.tableName). - Set("last_scan_started_at", time.Now()). - Set("full_scan_in_progress", fullScan). - Where(Eq{"id": id}) - _, err := r.executeSQL(ctx, sq) - return err -} - -func (r *libraryRepository) ScanEnd(ctx context.Context, id int) error { - sq := Update(r.tableName). - Set("last_scan_at", time.Now()). - Set("full_scan_in_progress", false). - Set("last_scan_started_at", time.Time{}). - Where(Eq{"id": id}) - _, err := r.executeSQL(ctx, sq) - return err -} - -func (r *libraryRepository) SetScannedPID(ctx context.Context, id int, pid model.PIDConfig) error { - sq := Update(r.tableName). - Set("scanned_pid_album", pid.Album). - Set("scanned_pid_track", pid.Track). - Where(Eq{"id": id}) - _, err := r.executeSQL(ctx, sq) - return err -} - -func (r *libraryRepository) ScanInProgress(ctx context.Context) (bool, error) { - query := r.newSelect(ctx).Where(NotEq{"last_scan_started_at": time.Time{}}) - count, err := r.count(ctx, query) - return count > 0, err -} - -func (r *libraryRepository) RefreshStats(ctx context.Context, id int) error { - var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 } - var sizeRes struct{ Sum int64 } - var durationRes struct{ Sum float64 } - - err := run.Parallel( - func() error { - return r.queryOne(ctx, Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": false}), &songsRes) - }, - func() error { - return r.queryOne(ctx, Select("count(*) as count").From("album").Where(Eq{"library_id": id, "missing": false}), &albumsRes) - }, - func() error { - return r.queryOne(ctx, Select("count(*) as count").From("library_artist la"). - Join("artist a on la.artist_id = a.id"). - Where(Eq{"la.library_id": id, "a.missing": false}), &artistsRes) - }, - func() error { - return r.queryOne(ctx, Select("count(*) as count").From("folder"). - Where(And{ - Eq{"library_id": id, "missing": false}, - Gt{"num_audio_files": 0}, - }), &foldersRes) - }, - func() error { - return r.queryOne(ctx, Select("ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) as count"). - From("folder").Where(Eq{"library_id": id, "missing": false}), &filesRes) - }, - func() error { - return r.queryOne(ctx, Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": true}), &missingRes) - }, - func() error { - return r.queryOne(ctx, Select("ifnull(sum(size),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &sizeRes) - }, - func() error { - return r.queryOne(ctx, Select("ifnull(sum(duration),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &durationRes) - }, - )() - if err != nil { - return err - } - - sq := Update(r.tableName). - Set("total_songs", songsRes.Count). - Set("total_albums", albumsRes.Count). - Set("total_artists", artistsRes.Count). - Set("total_folders", foldersRes.Count). - Set("total_files", filesRes.Count). - Set("total_missing_files", missingRes.Count). - Set("total_size", sizeRes.Sum). - Set("total_duration", durationRes.Sum). - Set("updated_at", time.Now()). - Where(Eq{"id": id}) - _, err = r.executeSQL(ctx, sq) - return err -} - -func (r *libraryRepository) Delete(ctx context.Context, id int) error { - if !loggedUser(ctx).IsAdmin { - return model.ErrNotAuthorized - } - if id == 1 { - return fmt.Errorf("%w: library with ID 1 cannot be deleted", model.ErrValidation) - } - - err := r.delete(ctx, Eq{"id": id}) - if err != nil { - return err - } - - // The cascade above can drop an artist's last library_artist row; reconcile any such orphans. - if err := NewArtistRepository(r.db).(*artistRepository).markOrphansMissing(ctx); err != nil { - return fmt.Errorf("marking orphaned artists missing after deleting library %d: %w", id, err) - } - - // Clear cache entry for this library only if DB operation was successful - libLock.Lock() - defer libLock.Unlock() - delete(libCache, id) - - // Clean up orphaned plugin references for the deleted library - if err := cleanupPluginLibraryReferences(r.db, id); err != nil { - log.Error(ctx, "Failed to cleanup plugin library references", "libraryID", id, err) - } - return nil -} - -func (r *libraryRepository) GetAll(ctx context.Context, ops ...model.QueryOptions) (model.Libraries, error) { - sq := r.newSelect(ctx, ops...).Columns("*") - res := model.Libraries{} - err := r.queryAll(ctx, sq, &res) - return res, err -} - -func (r *libraryRepository) CountAll(ctx context.Context, ops ...model.QueryOptions) (int64, error) { - sq := r.newSelect(ctx, ops...) - return r.count(ctx, sq) -} - -// User-library association methods - -func (r *libraryRepository) GetUsersWithLibraryAccess(ctx context.Context, libraryID int) (model.Users, error) { - sel := Select("u.*"). - From("user u"). - Join("user_library ul ON u.id = ul.user_id"). - Where(Eq{"ul.library_id": libraryID}). - OrderBy("u.name") - - var res model.Users - err := r.queryAll(ctx, sel, &res) - return res, err -} - -// REST interface methods - -func (r *libraryRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) -} - -func (r *libraryRepository) Read(ctx context.Context, id string) (*model.Library, error) { - idInt, err := strconv.Atoi(id) - if err != nil { - log.Trace(ctx, "invalid library id: %s", id, err) - return nil, rest.ErrNotFound - } - return r.Get(ctx, idInt) -} - -func (r *libraryRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Library, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) -} - -var _ model.LibraryRepository = (*libraryRepository)(nil) -var _ rest.Repository[model.Library] = (*libraryRepository)(nil) diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go deleted file mode 100644 index bf485a06f..000000000 --- a/persistence/library_repository_test.go +++ /dev/null @@ -1,350 +0,0 @@ -package persistence - -import ( - "context" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" -) - -var _ = Describe("LibraryRepository", func() { - var repo model.LibraryRepository - var ctx context.Context - var conn *dbx.DB - - BeforeEach(func() { - ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) - conn = GetDBXBuilder() - repo = NewLibraryRepository(conn) - }) - - AfterEach(func() { - // Clean up test libraries (keep ID 1 which is the default library) - _, _ = conn.NewQuery("DELETE FROM library WHERE id > 1").Execute() - }) - - Describe("Put", func() { - Context("when ID is 0", func() { - It("inserts a new library with autoassigned ID", func() { - lib := &model.Library{ - ID: 0, - Name: "Test Library", - Path: "/music/test", - } - - err := repo.Put(ctx, lib) - Expect(err).ToNot(HaveOccurred()) - Expect(lib.ID).To(BeNumerically(">", 0)) - Expect(lib.CreatedAt).ToNot(BeZero()) - Expect(lib.UpdatedAt).ToNot(BeZero()) - - // Verify it was inserted - savedLib, err := repo.Get(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(savedLib.Name).To(Equal("Test Library")) - Expect(savedLib.Path).To(Equal("/music/test")) - }) - }) - - Context("when colsToUpdate is specified", func() { - It("only writes the requested columns", func() { - lib := &model.Library{ - Name: "Original Library", - Path: "/music/original", - RemotePath: "/remote/original", - DefaultNewUsers: true, - } - Expect(repo.Put(ctx, lib)).To(Succeed()) - - Expect(repo.Put(ctx, &model.Library{ID: lib.ID, Name: "Renamed", Path: lib.Path}, "name", "path")).To(Succeed()) - - saved, err := repo.Get(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(saved.Name).To(Equal("Renamed")) - Expect(saved.RemotePath).To(Equal("/remote/original")) - Expect(saved.DefaultNewUsers).To(BeTrue()) - }) - }) - - Context("when ID is non-zero and record exists", func() { - It("updates the existing record", func() { - // First create a library - lib := &model.Library{ - ID: 0, - Name: "Original Library", - Path: "/music/original", - } - err := repo.Put(ctx, lib) - Expect(err).ToNot(HaveOccurred()) - - 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" - err = repo.Put(ctx, lib) - Expect(err).ToNot(HaveOccurred()) - - // Verify it was updated, not inserted - Expect(lib.ID).To(Equal(originalID)) - Expect(lib.CreatedAt).To(Equal(originalCreatedAt)) - Expect(lib.UpdatedAt).To(BeTemporally(">", originalCreatedAt)) - - // Verify the changes were saved - savedLib, err := repo.Get(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(savedLib.Name).To(Equal("Updated Library")) - Expect(savedLib.Path).To(Equal("/music/updated")) - }) - }) - - Context("when ID is non-zero but record doesn't exist", func() { - It("inserts a new record with the specified ID", func() { - lib := &model.Library{ - ID: 999, - Name: "New Library with ID", - Path: "/music/new", - } - - // Ensure the record doesn't exist - _, err := repo.Get(ctx, 999) - Expect(err).To(HaveOccurred()) - - // Put should insert it - err = repo.Put(ctx, lib) - Expect(err).ToNot(HaveOccurred()) - Expect(lib.ID).To(Equal(999)) - Expect(lib.CreatedAt).ToNot(BeZero()) - Expect(lib.UpdatedAt).ToNot(BeZero()) - - // Verify it was inserted with the correct ID - savedLib, err := repo.Get(ctx, 999) - Expect(err).ToNot(HaveOccurred()) - Expect(savedLib.ID).To(Equal(999)) - Expect(savedLib.Name).To(Equal("New Library with ID")) - Expect(savedLib.Path).To(Equal("/music/new")) - }) - }) - }) - - Describe("StoreMusicFolder", func() { - var libBefore *model.Library - - BeforeEach(func() { - var err error - libBefore, err = repo.Get(ctx, model.DefaultLibraryID) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(configtest.SetupConfig()) - DeferCleanup(func() { - _, _ = conn.NewQuery("update library set path = {:path}, updated_at = {:updated_at} where id = {:id}"). - Bind(dbx.Params{"path": libBefore.Path, "updated_at": libBefore.UpdatedAt, "id": model.DefaultLibraryID}). - Execute() - libLock.Lock() - defer libLock.Unlock() - delete(libCache, model.DefaultLibraryID) - }) - }) - - It("skips updating the default library when the configured path is unchanged", func() { - conf.Server.MusicFolder = libBefore.Path - Expect(repo.StoreMusicFolder(ctx)).To(Succeed()) - - libAfter, err := repo.Get(ctx, model.DefaultLibraryID) - Expect(err).ToNot(HaveOccurred()) - Expect(libAfter.Path).To(Equal(libBefore.Path)) - Expect(libAfter.UpdatedAt).To(Equal(libBefore.UpdatedAt)) - }) - - It("updates the default library only when the configured path changes", func() { - conf.Server.MusicFolder = libBefore.Path + "-updated" - Expect(repo.StoreMusicFolder(ctx)).To(Succeed()) - - libAfter, err := repo.Get(ctx, model.DefaultLibraryID) - Expect(err).ToNot(HaveOccurred()) - Expect(libAfter.Path).To(Equal(conf.Server.MusicFolder)) - Expect(libAfter.UpdatedAt).ToNot(Equal(libBefore.UpdatedAt)) - }) - }) - - It("refreshes stats", func() { - libBefore, err := repo.Get(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(repo.RefreshStats(ctx, 1)).To(Succeed()) - libAfter, err := repo.Get(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(libAfter.UpdatedAt).To(BeTemporally(">", libBefore.UpdatedAt)) - - var songsRes, albumsRes, artistsRes, foldersRes, filesRes, missingRes struct{ Count int64 } - var sizeRes struct{ Sum int64 } - var durationRes struct{ Sum float64 } - - Expect(conn.NewQuery("select count(*) as count from media_file where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&songsRes)).To(Succeed()) - Expect(conn.NewQuery("select count(*) as count from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&albumsRes)).To(Succeed()) - Expect(conn.NewQuery("select count(*) as count from library_artist la join artist a on la.artist_id = a.id where la.library_id = {:id} and a.missing = 0").Bind(dbx.Params{"id": 1}).One(&artistsRes)).To(Succeed()) - Expect(conn.NewQuery("select count(*) as count from folder where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&foldersRes)).To(Succeed()) - Expect(conn.NewQuery("select ifnull(sum(num_audio_files + num_playlists + json_array_length(image_files)),0) as count from folder where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&filesRes)).To(Succeed()) - Expect(conn.NewQuery("select count(*) as count from media_file where library_id = {:id} and missing = 1").Bind(dbx.Params{"id": 1}).One(&missingRes)).To(Succeed()) - Expect(conn.NewQuery("select ifnull(sum(size),0) as sum from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&sizeRes)).To(Succeed()) - Expect(conn.NewQuery("select ifnull(sum(duration),0) as sum from album where library_id = {:id} and missing = 0").Bind(dbx.Params{"id": 1}).One(&durationRes)).To(Succeed()) - - Expect(libAfter.TotalSongs).To(Equal(int(songsRes.Count))) - Expect(libAfter.TotalAlbums).To(Equal(int(albumsRes.Count))) - Expect(libAfter.TotalArtists).To(Equal(int(artistsRes.Count))) - Expect(libAfter.TotalFolders).To(Equal(int(foldersRes.Count))) - Expect(libAfter.TotalFiles).To(Equal(int(filesRes.Count))) - Expect(libAfter.TotalMissingFiles).To(Equal(int(missingRes.Count))) - Expect(libAfter.TotalSize).To(Equal(sizeRes.Sum)) - Expect(libAfter.TotalDuration).To(Equal(durationRes.Sum)) - }) - - Describe("ScanBegin and ScanEnd", func() { - var lib *model.Library - - BeforeEach(func() { - lib = &model.Library{ - ID: 0, - Name: "Test Scan Library", - Path: "/music/test-scan", - } - err := repo.Put(ctx, lib) - Expect(err).ToNot(HaveOccurred()) - }) - - DescribeTable("ScanBegin", - func(fullScan bool, expectedFullScanInProgress bool) { - err := repo.ScanBegin(ctx, lib.ID, fullScan) - Expect(err).ToNot(HaveOccurred()) - - updatedLib, err := repo.Get(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(updatedLib.LastScanStartedAt).ToNot(BeZero()) - Expect(updatedLib.FullScanInProgress).To(Equal(expectedFullScanInProgress)) - }, - Entry("sets FullScanInProgress to true for full scan", true, true), - Entry("sets FullScanInProgress to false for quick scan", false, false), - ) - - Context("ScanEnd", func() { - BeforeEach(func() { - err := repo.ScanBegin(ctx, lib.ID, true) - Expect(err).ToNot(HaveOccurred()) - }) - - It("sets LastScanAt and clears FullScanInProgress and LastScanStartedAt", func() { - err := repo.ScanEnd(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - - updatedLib, err := repo.Get(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(updatedLib.LastScanAt).ToNot(BeZero()) - Expect(updatedLib.FullScanInProgress).To(BeFalse()) - Expect(updatedLib.LastScanStartedAt).To(BeZero()) - }) - - It("sets LastScanAt to be after LastScanStartedAt", func() { - libBefore, err := repo.Get(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - - err = repo.ScanEnd(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - - libAfter, err := repo.Get(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(libAfter.LastScanAt).To(BeTemporally(">=", libBefore.LastScanStartedAt)) - }) - }) - }) - - Describe("PID config", func() { - It("stores the overrides, and Put never touches the scanned specs", func() { - lib := &model.Library{Name: "PID Library", Path: "/music/pid", PIDAlbum: "folder", PIDTrack: "title"} - Expect(repo.Put(ctx, lib)).To(Succeed()) - Expect(repo.SetScannedPID(ctx, lib.ID, model.PIDConfig{Album: "folder", Track: "title"})).To(Succeed()) - - // An update coming from the REST API has no scanned specs. It must not clear them - update := &model.Library{ID: lib.ID, Name: "PID Library", Path: "/music/pid", PIDTrack: "title"} - Expect(repo.Put(ctx, update)).To(Succeed()) - - saved, err := repo.Get(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(saved.PIDAlbum).To(BeEmpty()) - Expect(saved.PIDTrack).To(Equal("title")) - Expect(saved.ScannedPIDAlbum).To(Equal("folder")) - Expect(saved.ScannedPIDTrack).To(Equal("title")) - }) - - It("keeps the overrides when a partial update does not send them", func() { - lib := &model.Library{Name: "Partial", Path: "/music/partial", PIDAlbum: "folder", PIDTrack: "title"} - Expect(repo.Put(ctx, lib)).To(Succeed()) - - Expect(repo.Put(ctx, &model.Library{ID: lib.ID, Name: "Renamed"}, "name")).To(Succeed()) - - saved, err := repo.Get(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(saved.Name).To(Equal("Renamed")) - Expect(saved.PIDAlbum).To(Equal("folder")) - Expect(saved.PIDTrack).To(Equal("title")) - }) - }) - - Describe("Delete", func() { - var adminRepo model.LibraryRepository - var artistRepo model.ArtistRepository - var adminCtx context.Context - - artistMissing := func(id string) bool { - var missing bool - err := conn.NewQuery("SELECT missing FROM artist WHERE id = {:id}"). - Bind(dbx.Params{"id": id}).Row(&missing) - Expect(err).ToNot(HaveOccurred()) - return missing - } - - BeforeEach(func() { - adminCtx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - adminRepo = NewLibraryRepository(conn) - artistRepo = NewArtistRepository(conn) - }) - - It("marks artists orphaned by the delete as missing", func() { - lib := model.Library{Name: "Doomed Library", Path: "/doomed"} - Expect(adminRepo.Put(adminCtx, &lib)).To(Succeed()) - - orphanArtist := model.Artist{ID: "delete-orphan", Name: "Orphan To Be"} - sharedArtist := model.Artist{ID: "delete-shared", Name: "Shared Artist"} - Expect(artistRepo.Put(adminCtx, &orphanArtist)).To(Succeed()) - Expect(artistRepo.Put(adminCtx, &sharedArtist)).To(Succeed()) - Expect(adminRepo.AddArtist(adminCtx, lib.ID, orphanArtist.ID)).To(Succeed()) - Expect(adminRepo.AddArtist(adminCtx, lib.ID, sharedArtist.ID)).To(Succeed()) - Expect(adminRepo.AddArtist(adminCtx, 1, sharedArtist.ID)).To(Succeed()) - DeferCleanup(func() { - if raw, ok := artistRepo.(*artistRepository); ok { - _, _ = raw.executeSQL(adminCtx, squirrel.Delete("artist"). - Where(squirrel.Eq{"id": []string{orphanArtist.ID, sharedArtist.ID}})) - } - }) - - Expect(artistMissing(orphanArtist.ID)).To(BeFalse()) - - Expect(adminRepo.Delete(adminCtx, lib.ID)).To(Succeed()) - - Expect(artistMissing(orphanArtist.ID)).To(BeTrue(), "orphaned artist should be marked missing") - Expect(artistMissing(sharedArtist.ID)).To(BeFalse(), "artist still in another library must stay visible") - }) - }) -}) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index b9a5c3374..d487b6445 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -3,215 +3,106 @@ package persistence import ( "context" "fmt" - "iter" - "maps" - "slices" - "strconv" + "os" + "path/filepath" "strings" - "sync" - "time" + "unicode/utf8" . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "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/navidrome/navidrome/utils/slice" - "github.com/navidrome/navidrome/utils/str" - "github.com/pocketbase/dbx" ) type mediaFileRepository struct { sqlRepository + sqlRestful } -type dbMediaFile struct { - *model.MediaFile `structs:",flatten"` - Participants string `structs:"-" json:"-"` - Tags string `structs:"-" json:"-"` - // These are necessary to map the correct names (rg_*) to the correct fields (RG*) - // without using `db` struct tags in the model.MediaFile struct - RgAlbumGain *float64 `structs:"-" json:"-"` - RgAlbumPeak *float64 `structs:"-" json:"-"` - RgTrackGain *float64 `structs:"-" json:"-"` - RgTrackPeak *float64 `structs:"-" json:"-"` -} - -// String guards the promoted MediaFile.String(), which would dereference a nil MediaFile. -func (m dbMediaFile) String() string { - if m.MediaFile == nil { - return "" - } - return m.MediaFile.String() -} - -func (m *dbMediaFile) PostScan() error { - m.RGTrackGain = m.RgTrackGain - m.RGTrackPeak = m.RgTrackPeak - m.RGAlbumGain = m.RgAlbumGain - m.RGAlbumPeak = m.RgAlbumPeak - var err error - m.MediaFile.Participants, err = unmarshalParticipants(m.Participants) - if err != nil { - return fmt.Errorf("parsing media_file from db: %w", err) - } - if m.Tags != "" { - m.MediaFile.Tags, err = unmarshalTags(m.Tags) - if err != nil { - return fmt.Errorf("parsing media_file from db: %w", err) - } - m.Genre, m.Genres = m.MediaFile.Tags.ToGenres() - } - return nil -} - -func (m *dbMediaFile) PostMapArgs(args map[string]any) error { - fullText := []string{m.FullTitle(), m.Album, m.Artist, m.AlbumArtist, - m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle} - participantNames := m.MediaFile.Participants.AllNames() - fullText = append(fullText, participantNames...) - args["full_text"] = formatFullText(fullText...) - args["search_participants"] = strings.Join(participantNames, " ") - args["search_normalized"] = str.NormalizeForFTS(m.FullTitle(), m.Album, m.Artist, m.AlbumArtist) - args["tags"] = marshalTags(m.MediaFile.Tags) - args["participants"] = marshalParticipants(m.MediaFile.Participants) - return nil -} - -type dbMediaFiles []dbMediaFile - -func (m dbMediaFiles) toModels() model.MediaFiles { - return slice.Map(m, func(mf dbMediaFile) model.MediaFile { return *mf.MediaFile }) -} - -func NewMediaFileRepository(db dbx.Builder) model.MediaFileRepository { +func NewMediaFileRepository(ctx context.Context, o orm.QueryExecutor) *mediaFileRepository { r := &mediaFileRepository{} - r.db = db + r.ctx = ctx + r.ormer = o r.tableName = "media_file" - r.registerModel(&model.MediaFile{}, mediaFileFilter()) - r.setSortMappings(map[string]string{ - "title": "order_title", - "artist": "order_artist_name, order_album_name, release_date, disc_number, track_number", - "album_artist": "order_album_artist_name, order_album_name, release_date, disc_number, track_number", - "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("media_file.title"), - "random": "random", - "created_at": "media_file.created_at", - "recently_added": mediaFileRecentlyAddedSort(), - "starred_at": "starred, starred_at", - "rated_at": "rating, rated_at", - "year": "year", - "genre": "genre", - "duration": "duration", - "channels": "channels", - "bpm": "bpm", - "path": "path", - "comment": "comment", - "play_count": "play_count", - "play_date": "play_date", - "rating": "rating", - }) + r.sortMappings = map[string]string{ + "artist": "order_artist_name asc, order_album_name asc, release_date asc, disc_number asc, track_number asc", + "album": "order_album_name asc, release_date asc, disc_number asc, track_number asc, order_artist_name asc, title asc", + "random": "RANDOM()", + } + r.filterMappings = map[string]filterFunc{ + "id": idFilter(r.tableName), + "title": fullTextFilter, + "starred": booleanFilter, + } return r } -var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { - filters := map[string]filterFunc{ - "id": idFilter("media_file"), - "title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"), - "starred": annotationBoolFilter("starred"), - "has_rating": annotationBoolFilter("rating"), - "genre_id": genreFilter(SongGenres), - "missing": booleanFilter, - "artists_id": mediaFileArtistFilter, - "library_id": libraryIdFilter, - "path": startsWithFilter("media_file.path"), +func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) { + sql := r.newSelectWithAnnotation("media_file.id") + sql = r.withGenres(sql) + return r.count(sql, options...) +} + +func (r *mediaFileRepository) Exists(id string) (bool, error) { + return r.exists(Select().Where(Eq{"media_file.id": id})) +} + +func (r *mediaFileRepository) Put(m *model.MediaFile) error { + m.FullText = getFullText(m.Title, m.Album, m.Artist, m.AlbumArtist, + m.SortTitle, m.SortAlbumName, m.SortArtistName, m.SortAlbumArtistName, m.DiscSubtitle) + _, err := r.put(m.ID, m) + if err != nil { + return err } - // Add all album tags as filters - for tag := range model.TagMappings() { - if _, exists := filters[string(tag)]; !exists { - filters[string(tag)] = tagIDFilter + return r.updateGenres(m.ID, r.tableName, m.Genres) +} + +func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder { + sql := r.newSelectWithAnnotation("media_file.id", options...).Columns("media_file.*") + sql = r.withBookmark(sql, "media_file.id") + if len(options) > 0 && options[0].Filters != nil { + s, _, _ := options[0].Filters.ToSql() + // If there's any reference of genre in the filter, joins with genre + if strings.Contains(s, "genre") { + sql = r.withGenres(sql) + // If there's no filter on genre_id, group the results by media_file.id + if !strings.Contains(s, "genre_id") { + sql = sql.GroupBy("media_file.id") + } } } - return filters -}) - -func mediaFileArtistFilter(_ string, value any) Sqlizer { - return ParticipantIDFilter("media_file", value, model.RoleAlbumArtist, model.RoleArtist) + return sql } -func mediaFileRecentlyAddedSort() string { - if conf.Server.RecentlyAddedByModTime { - return "media_file.updated_at, media_file.id" +func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) { + sel := r.selectMediaFile().Where(Eq{"media_file.id": id}) + var res model.MediaFiles + if err := r.queryAll(sel, &res); err != nil { + return nil, err } - return "media_file.created_at, media_file.id" + if len(res) == 0 { + return nil, model.ErrNotFound + } + err := r.loadMediaFileGenres(&res) + return &res[0], err } -func (r *mediaFileRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - query := r.newSelect(ctx) - query = r.applyLibraryFilter(ctx, query) - // The annotation join is expensive with count(distinct) and pointless unless a filter uses it. - if filtersNeedAnnotation(r.applyFilters(query, options...)) { - query = r.withAnnotation(ctx, query, "media_file.id") - } - return r.count(ctx, query, options...) -} - -func (r *mediaFileRepository) CountBySuffix(ctx context.Context, options ...model.QueryOptions) (map[string]int64, error) { - sel := r.newSelect(ctx, options...). - Columns("lower(suffix) as suffix", "count(*) as count"). - GroupBy("lower(suffix)") - var res []struct { - Suffix string - Count int64 - } - err := r.queryAll(ctx, sel, &res) +func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { + sq := r.selectMediaFile(options...) + res := model.MediaFiles{} + err := r.queryAll(sq, &res) if err != nil { return nil, err } - counts := make(map[string]int64, len(res)) - for _, c := range res { - counts[c.Suffix] = c.Count - } - return counts, nil + err = r.loadMediaFileGenres(&res) + return res, err } -func (r *mediaFileRepository) Exists(ctx context.Context, id string) (bool, error) { - // The exists() helper applies no library filter, so it would report rows the caller cannot see. - c, err := r.count(ctx, r.applyLibraryFilter(ctx, r.newSelect(ctx).Where(Eq{"media_file.id": id}))) - return c > 0, err -} - -func (r *mediaFileRepository) Put(ctx context.Context, m *model.MediaFile) error { - if m.CreatedAt.IsZero() { - m.CreatedAt = time.Now() - } - id, err := r.putByMatch(ctx, Eq{"path": m.Path, "library_id": m.LibraryID}, m.ID, &dbMediaFile{MediaFile: m}) - if err != nil { - return err - } - m.ID = id - if err := r.updateParticipants(ctx, m.ID, m.Participants); err != nil { - return err - } - return r.updateTags(ctx, m.ID, m.Tags) -} - -func (r *mediaFileRepository) UpdateProbeData(ctx context.Context, id string, data string) error { - _, err := r.executeSQL(ctx, Update(r.tableName).Set("probe_data", data).Where(Eq{"id": id})) - return err -} - -func (r *mediaFileRepository) selectMediaFile(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - sql := r.newSelect(ctx, options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). - LeftJoin("library on media_file.library_id = library.id") - sql = r.withAnnotation(ctx, sql, "media_file.id") - sql = r.withBookmark(ctx, sql, "media_file.id") - return r.applyLibraryFilter(ctx, sql) -} - -func (r *mediaFileRepository) Get(ctx context.Context, id string) (*model.MediaFile, error) { - res, err := r.GetAll(ctx, model.QueryOptions{Filters: Eq{"media_file.id": id}}) - if err != nil { +func (r *mediaFileRepository) FindByPath(path string) (*model.MediaFile, error) { + sel := r.newSelect().Columns("*").Where(Like{"path": path}) + var res model.MediaFiles + if err := r.queryAll(sel, &res); err != nil { return nil, err } if len(res) == 0 { @@ -220,403 +111,104 @@ func (r *mediaFileRepository) Get(ctx context.Context, id string) (*model.MediaF return &res[0], nil } -func (r *mediaFileRepository) GetWithParticipants(ctx context.Context, id string) (*model.MediaFile, error) { - m, err := r.Get(ctx, id) - if err != nil { - return nil, err +func cleanPath(path string) string { + path = filepath.Clean(path) + if !strings.HasSuffix(path, string(os.PathSeparator)) { + path += string(os.PathSeparator) } - m.Participants, err = r.getParticipants(ctx, m) - return m, err + return path } -func (r *mediaFileRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { - sq := r.selectMediaFile(ctx, options...) - var res dbMediaFiles - err := r.queryAll(ctx, sq, &res, options...) - if err != nil { - return nil, err - } - mfs := res.toModels() - r.hydrateArtwork(ctx, mfs) - return mfs, nil +func pathStartsWith(path string) Eq { + substr := fmt.Sprintf("substr(path, 1, %d)", utf8.RuneCountInString(path)) + return Eq{substr: path} } -func (r *mediaFileRepository) hydrateArtwork(ctx context.Context, mfs model.MediaFiles) { - hydrateMediaFileArtwork(ctx, r.db, mfs) +// FindAllByPath only return mediafiles that are direct children of requested path +func (r *mediaFileRepository) FindAllByPath(path string) (model.MediaFiles, error) { + // Query by path based on https://stackoverflow.com/a/13911906/653632 + path = cleanPath(path) + pathLen := utf8.RuneCountInString(path) + sel0 := r.newSelect().Columns("media_file.*", fmt.Sprintf("substr(path, %d) AS item", pathLen+2)). + Where(pathStartsWith(path)) + sel := r.newSelect().Columns("*", "item NOT GLOB '*"+string(os.PathSeparator)+"*' AS isLast"). + Where(Eq{"isLast": 1}).FromSelect(sel0, "sel0") + + res := model.MediaFiles{} + err := r.queryAll(sel, &res) + return res, err } -// GetRandom uses two passes so the random sort runs over a narrow rowid index instead of the -// wide media_file row: pick random rowids first, then hydrate only those. -func (r *mediaFileRepository) GetRandom(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { - var opt model.QueryOptions - if len(options) > 0 { - opt = options[0] - } - - rowidQuery := Select("media_file.rowid").From(r.tableName) - rowidQuery = r.applyFilters(rowidQuery, model.QueryOptions{Filters: opt.Filters}) - rowidQuery = r.applyLibraryFilter(ctx, rowidQuery) - rowidQuery = rowidQuery.OrderBy("random()") - if opt.Max > 0 { - rowidQuery = rowidQuery.Limit(uint64(opt.Max)) - } - - var rowids []int64 - if err := r.queryAllSlice(ctx, rowidQuery, &rowids); err != nil { - return nil, err - } - if len(rowids) == 0 { - return model.MediaFiles{}, nil - } - - // Re-shuffle in Phase 2: `WHERE rowid IN (...)` returns rows in ascending rowid order, not - // the random order from Phase 1. Sorting only the (<=Max) hydrated rows is negligible. - sq := r.selectMediaFile(ctx).Where(Eq{"media_file.rowid": rowids}).OrderBy("random()") - var res dbMediaFiles - if err := r.queryAll(ctx, sq, &res); err != nil { - return nil, err - } - mfs := res.toModels() - r.hydrateArtwork(ctx, mfs) - return mfs, nil +// FindPathsRecursively returns a list of all subfolders of basePath, recursively +func (r *mediaFileRepository) FindPathsRecursively(basePath string) ([]string, error) { + path := cleanPath(basePath) + // Query based on https://stackoverflow.com/a/38330814/653632 + sel := r.newSelect().Columns(fmt.Sprintf("distinct rtrim(path, replace(path, '%s', ''))", string(os.PathSeparator))). + Where(pathStartsWith(path)) + var res []string + err := r.queryAll(sel, &res) + return res, err } -func (r *mediaFileRepository) GetAllByTags(ctx context.Context, tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) { - placeholders := make([]string, len(values)) - args := make([]any, len(values)) - for i, v := range values { - placeholders[i] = "?" - args[i] = v - } - tagFilter := Expr( - fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and value in (%s))", - tag, strings.Join(placeholders, ",")), - args..., - ) - - var opts model.QueryOptions - if len(options) > 0 { - opts = options[0] - } - if opts.Filters != nil { - opts.Filters = And{tagFilter, opts.Filters} - } else { - opts.Filters = tagFilter - } - return r.GetAll(ctx, opts) -} - -func (r *mediaFileRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.MediaFileCursor, error) { - sq := r.selectMediaFile(ctx, options...) - cursor, err := queryWithStableResults[dbMediaFile](ctx, r.sqlRepository, sq) - if err != nil { - return nil, err - } - return wrapMediaFileCursor(cursor), nil -} - -// getAllIDs returns the IDs of GetAll's row set, skipping its wide column projection. -func (r *mediaFileRepository) getAllIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { - sq := r.applyLibraryFilter(ctx, r.newSelect(ctx, options...).Columns("media_file.id")) - if filtersNeedAnnotation(sq) { - sq = r.withAnnotation(ctx, sq, "media_file.id") - } - ids := []string{} - err := r.queryAllSlice(ctx, sq, &ids) - return ids, err -} - -func (r *mediaFileRepository) GetAlbumIDsByFolder(ctx context.Context, lib model.Library, folderIDs ...string) ([]string, error) { - ids := []string{} - for chunk := range slices.Chunk(folderIDs, 200) { - // A folder's own cover also covers albums whose tracks sit in its disc subfolders. - inFolders := Select("f.id").From("folder f").Where(And{ - Eq{"f.library_id": lib.ID}, - Eq{"f.missing": false}, - Or{Eq{"f.id": chunk}, Eq{"f.parent_id": chunk}}, - }) - sq := Select("distinct album_id").From("media_file"). - Where(And{Eq{"missing": false}, ConcatExpr("folder_id IN (", inFolders, ")")}) - var chunkIDs []string - if err := r.queryAllSlice(ctx, sq, &chunkIDs); err != nil { - return nil, err +func (r *mediaFileRepository) deleteNotInPath(basePath string) error { + path := cleanPath(basePath) + sel := Delete(r.tableName).Where(NotEq(pathStartsWith(path))) + c, err := r.executeSQL(sel) + if err == nil { + if c > 0 { + log.Debug(r.ctx, "Deleted dangling tracks", "totalDeleted", c) } - ids = append(ids, chunkIDs...) } - return ids, nil + return err } -// GetCursorWithArtwork streams the same rows as GetCursor, hydrated, via an id pre-pass. -func (r *mediaFileRepository) GetCursorWithArtwork(ctx context.Context, options ...model.QueryOptions) (model.MediaFileCursor, error) { - ids, err := r.getAllIDs(ctx, options...) - if err != nil { - return nil, err - } - opts := chunkOptions(options, "media_file.id") - return model.MediaFileCursor(streamByIDs(ids, func(chunk []string) (model.MediaFiles, error) { - return r.GetAll(ctx, opts(chunk)) - })), nil +func (r *mediaFileRepository) Delete(id string) error { + return r.delete(Eq{"id": id}) } -// FindByPaths finds media files by their paths. -// The paths can be library-qualified (format: "libraryID:path") or unqualified ("path"). -// Library-qualified paths search within the specified library, while unqualified paths -// search across all libraries for backward compatibility. -func (r *mediaFileRepository) FindByPaths(ctx context.Context, paths []string) (model.MediaFiles, error) { - // One IN list per library instead of one OR term per path: SQLite abandons the - // path index at just two OR-ed equality terms and scans the whole table. - byLibrary := map[int][]string{} - var unqualified []string - - for _, path := range paths { - // A numeric prefix is ambiguous: "1:foo.mp3" qualifies a library, but "1999: A Life/01.mp3" - // is a plain path. Search both ways rather than guessing. - if id, rest, ok := strings.Cut(path, ":"); ok { - if libraryID, err := strconv.Atoi(id); err == nil { - byLibrary[libraryID] = append(byLibrary[libraryID], rest) - } - } - unqualified = append(unqualified, path) - } - - query := Or{} - for _, libraryID := range slices.Sorted(maps.Keys(byLibrary)) { - query = append(query, And{ - Eq{"path collate nocase": byLibrary[libraryID]}, - Eq{"library_id": libraryID}, - }) - } - if len(unqualified) > 0 { - query = append(query, Eq{"path collate nocase": unqualified}) - } - - if len(query) == 0 { - return model.MediaFiles{}, nil - } - - sel := r.applyLibraryFilter(ctx, r.newSelect(ctx).Columns("*").Where(query)) - var res dbMediaFiles - if err := r.queryAll(ctx, sel, &res); err != nil { - return nil, err - } - - return res.toModels(), nil +// DeleteByPath delete from the DB all mediafiles that are direct children of path +func (r *mediaFileRepository) DeleteByPath(basePath string) (int64, error) { + path := cleanPath(basePath) + pathLen := utf8.RuneCountInString(path) + del := Delete(r.tableName). + Where(And{pathStartsWith(path), + Eq{fmt.Sprintf("substr(path, %d) glob '*%s*'", pathLen+2, string(os.PathSeparator)): 0}}) + log.Debug(r.ctx, "Deleting mediafiles by path", "path", path) + return r.executeSQL(del) } -func (r *mediaFileRepository) Delete(ctx context.Context, id string) error { - return r.delete(ctx, Eq{"id": id}) +func (r *mediaFileRepository) removeNonAlbumArtistIds() error { + upd := Update(r.tableName).Set("artist_id", "").Where(notExists("artist", ConcatExpr("id = artist_id"))) + log.Debug(r.ctx, "Removing non-album artist_ids") + _, err := r.executeSQL(upd) + return err } -func (r *mediaFileRepository) ReassignReferences(ctx context.Context, prevID, newID string) error { - if err := r.ReassignAnnotation(ctx, prevID, newID); err != nil { - return fmt.Errorf("reassigning annotations: %w", err) - } - if err := r.reassignBookmark(ctx, prevID, newID); err != nil { - return fmt.Errorf("reassigning bookmarks: %w", err) - } - upd := Update("playlist_tracks").Set("media_file_id", newID).Where(Eq{"media_file_id": prevID}) - if _, err := r.executeSQL(ctx, upd); err != nil { - return fmt.Errorf("reassigning playlist tracks: %w", err) - } - upd = Update("scrobbles").Set("media_file_id", newID).Where(Eq{"media_file_id": prevID}) - if _, err := r.executeSQL(ctx, upd); err != nil { - return fmt.Errorf("reassigning scrobbles: %w", err) - } - // OR IGNORE: scrobble_buffer is unique on (user_id, service, media_file_id, play_time) - buf := Expr("update or ignore scrobble_buffer set media_file_id = ? where media_file_id = ?", newID, prevID) - if _, err := r.executeSQL(ctx, buf); err != nil { - return fmt.Errorf("reassigning buffered scrobbles: %w", err) - } - return nil +func (r *mediaFileRepository) Search(q string, offset int, size int) (model.MediaFiles, error) { + results := model.MediaFiles{} + err := r.doSearch(q, offset, size, &results, "title") + return results, err } -func (r *mediaFileRepository) DeleteAllMissing(ctx context.Context) (int64, error) { - user := loggedUser(ctx) - if !user.IsAdmin { - return 0, rest.ErrPermissionDenied - } - del := Delete(r.tableName).Where(Eq{"missing": true}) - return r.executeSQL(ctx, del) +func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(options...)) } -func (r *mediaFileRepository) DeleteMissing(ctx context.Context, ids []string) error { - user := loggedUser(ctx) - if !user.IsAdmin { - return rest.ErrPermissionDenied - } - return r.delete(ctx, - And{ - Eq{"missing": true}, - Eq{"id": ids}, - }, - ) +func (r *mediaFileRepository) Read(id string) (interface{}, error) { + return r.Get(id) } -func (r *mediaFileRepository) MarkMissing(ctx context.Context, missing bool, mfs ...*model.MediaFile) error { - ids := slice.SeqFunc(mfs, func(m *model.MediaFile) string { return m.ID }) - for chunk := range slice.CollectChunks(ids, 200) { - upd := Update(r.tableName). - Set("missing", missing). - Set("updated_at", time.Now()). - Where(Eq{"id": chunk}) - c, err := r.executeSQL(ctx, upd) - if err != nil || c == 0 { - log.Error(ctx, "Error setting mediafile missing flag", "ids", chunk, err) - return err - } - log.Debug(ctx, "Marked missing mediafiles", "total", c, "ids", chunk) - } - return nil +func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return r.GetAll(r.parseRestOptions(options...)) } -func (r *mediaFileRepository) MarkMissingByFolder(ctx context.Context, missing bool, folderIDs ...string) error { - for chunk := range slices.Chunk(folderIDs, 200) { - upd := Update(r.tableName). - Set("missing", missing). - Set("updated_at", time.Now()). - Where(And{ - Eq{"folder_id": chunk}, - Eq{"missing": !missing}, - }) - c, err := r.executeSQL(ctx, upd) - if err != nil { - log.Error(ctx, "Error setting mediafile missing flag", "folderIDs", chunk, err) - return err - } - log.Debug(ctx, "Marked missing mediafiles from missing folders", "total", c, "folders", chunk) - } - return nil +func (r *mediaFileRepository) EntityName() string { + return "mediafile" } -// GetMissingAndMatching returns all mediafiles that are missing and their potential matches (comparing PIDs) -// that were added/updated after the last scan started. The result is ordered by PID. -// It does not need to load bookmarks, annotations and participants, as they are not used by the scanner. -func (r *mediaFileRepository) GetMissingAndMatching(ctx context.Context, libId int) (model.MediaFileCursor, error) { - subQ := r.newSelect(ctx).Columns("pid"). - Where(And{ - Eq{"media_file.missing": true}, - Eq{"library_id": libId}, - }) - subQText, subQArgs, err := subQ.PlaceholderFormat(Question).ToSql() - if err != nil { - return nil, err - } - sel := r.newSelect(ctx).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). - LeftJoin("library on media_file.library_id = library.id"). - Where("pid in ("+subQText+")", subQArgs...). - Where(Or{ - Eq{"missing": true}, - ConcatExpr("media_file.created_at > library.last_scan_started_at"), - }). - OrderBy("pid") - cursor, err := queryWithStableResults[dbMediaFile](ctx, r.sqlRepository, sel) - if err != nil { - return nil, err - } - return wrapMediaFileCursor(cursor), nil -} - -func wrapMediaFileCursor(cursor iter.Seq2[dbMediaFile, error]) model.MediaFileCursor { - return model.MediaFileCursor(wrapCursor(cursor, func(m dbMediaFile) *model.MediaFile { return m.MediaFile })) -} - -// FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries -// It uses a lightweight query without annotation/bookmark joins since those are not needed for matching -func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(ctx context.Context, missing model.MediaFile, since time.Time) (model.MediaFiles, error) { - sel := r.newSelect(ctx).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). - LeftJoin("library on media_file.library_id = library.id"). - Where(And{ - NotEq{"media_file.library_id": missing.LibraryID}, - Eq{"media_file.mbz_release_track_id": missing.MbzReleaseTrackID}, - NotEq{"media_file.mbz_release_track_id": ""}, // Exclude empty MBZ Track IDs - Eq{"media_file.suffix": missing.Suffix}, - Gt{"media_file.created_at": since}, - Eq{"media_file.missing": false}, - }).OrderBy("media_file.created_at DESC") - - var res dbMediaFiles - err := r.queryAll(ctx, sel, &res) - if err != nil { - return nil, err - } - return res.toModels(), nil -} - -// FindRecentFilesByProperties finds recently added files by intrinsic properties in other libraries -// It uses a lightweight query without annotation/bookmark joins since those are not needed for matching -func (r *mediaFileRepository) FindRecentFilesByProperties(ctx context.Context, missing model.MediaFile, since time.Time) (model.MediaFiles, error) { - sel := r.newSelect(ctx).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). - LeftJoin("library on media_file.library_id = library.id"). - Where(And{ - NotEq{"media_file.library_id": missing.LibraryID}, - Eq{"media_file.title": missing.Title}, - Eq{"media_file.size": missing.Size}, - Eq{"media_file.suffix": missing.Suffix}, - Eq{"media_file.disc_number": missing.DiscNumber}, - Eq{"media_file.track_number": missing.TrackNumber}, - Eq{"media_file.album": missing.Album}, - Eq{"media_file.mbz_release_track_id": ""}, // Exclude files with MBZ Track ID - Gt{"media_file.created_at": since}, - Eq{"media_file.missing": false}, - }).OrderBy("media_file.created_at DESC") - - var res dbMediaFiles - err := r.queryAll(ctx, sel, &res) - if err != nil { - return nil, err - } - return res.toModels(), nil -} - -var mediaFileSearchConfig = searchConfig{ - NaturalOrder: "media_file.rowid", - OrderBy: []string{"title"}, - MBIDFields: []string{"mbz_recording_id", "mbz_release_track_id"}, -} - -func (r *mediaFileRepository) MatchesCriteria(ctx context.Context, id string, c criteria.Criteria) (bool, error) { - usr := loggedUser(ctx) - rulesSQL := newSmartPlaylistCriteria(c, withSmartPlaylistOwner(*usr)) - cond, err := rulesSQL.where() - if err != nil { - return false, err - } - sq := Select("count(*) as count").From("media_file") - sq = rulesSQL.applyExpressionJoins(sq, usr.ID) - sq = sq.Where(And{Eq{"media_file.id": id}, cond}) - var res struct{ Count int64 } - if err := r.queryOne(ctx, sq, &res); err != nil { - return false, err - } - return res.Count > 0, nil -} - -func (r *mediaFileRepository) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.MediaFiles, error) { - var opts model.QueryOptions - if len(options) > 0 { - opts = options[0] - } - var res dbMediaFiles - err := r.doSearch(ctx, r.selectMediaFile(ctx, options...), q, &res, mediaFileSearchConfig, opts) - if err != nil { - return nil, fmt.Errorf("searching media_file %q: %w", q, err) - } - mfs := res.toModels() - r.hydrateArtwork(ctx, mfs) - return mfs, nil -} - -func (r *mediaFileRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) -} - -func (r *mediaFileRepository) Read(ctx context.Context, id string) (*model.MediaFile, error) { - return r.Get(ctx, id) -} - -func (r *mediaFileRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.MediaFile, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *mediaFileRepository) NewInstance() interface{} { + return &model.MediaFile{} } var _ model.MediaFileRepository = (*mediaFileRepository)(nil) -var _ rest.Repository[model.MediaFile] = (*mediaFileRepository)(nil) +var _ model.ResourceRepository = (*mediaFileRepository)(nil) diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 14590090c..e6fa84f1d 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -2,509 +2,182 @@ package persistence import ( "context" - "errors" - "fmt" - "reflect" "time" "github.com/Masterminds/squirrel" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" + "github.com/beego/beego/v2/client/orm" + "github.com/google/uuid" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" - "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" ) var _ = Describe("MediaRepository", func() { var mr model.MediaFileRepository - var ctx context.Context BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid"}) - mr = NewMediaFileRepository(GetDBXBuilder()) - }) - - Describe("GetAlbumIDsByFolder", func() { - var lib model.Library - var albumRoot, disc1, sibling *model.Folder - - BeforeEach(func() { - ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) - libPtr, err := NewLibraryRepository(GetDBXBuilder()).Get(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - lib = *libPtr - - folderRepo := newFolderRepository(GetDBXBuilder()) - albumRoot = model.NewFolder(lib, "ByFolder/Album") - disc1 = model.NewFolder(lib, "ByFolder/Album/CD1") - sibling = model.NewFolder(lib, "ByFolder/Other") - for _, f := range []*model.Folder{albumRoot, disc1, sibling} { - Expect(folderRepo.Put(ctx, f)).To(Succeed()) - } - // Tracks live in the disc subfolder; the sibling album is the negative control. - Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-1", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/1.mp3"})).To(Succeed()) - Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-2", LibraryID: 1, AlbumID: "fol-al-1", FolderID: disc1.ID, Path: "t/2.mp3"})).To(Succeed()) - Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-3", LibraryID: 1, AlbumID: "fol-al-2", FolderID: sibling.ID, Path: "t/3.mp3"})).To(Succeed()) - Expect(mr.Put(ctx, &model.MediaFile{ID: "fol-mf-4", LibraryID: 1, AlbumID: "fol-al-3", FolderID: disc1.ID, Path: "t/4.mp3", Missing: true})).To(Succeed()) - DeferCleanup(func() { - _, _ = GetDBXBuilder().NewQuery("DELETE FROM media_file WHERE id LIKE 'fol-mf-%'").Execute() - _, _ = GetDBXBuilder().NewQuery("DELETE FROM folder WHERE path LIKE 'ByFolder%' OR name = 'ByFolder'").Execute() - }) - }) - - It("returns the distinct album IDs of non-missing tracks in the folder", func() { - ids, err := mr.GetAlbumIDsByFolder(ctx, lib, disc1.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(ConsistOf("fol-al-1")) - }) - - It("also matches albums whose tracks are in a direct child of the folder", func() { - // A cover in the album root must reach the album whose tracks sit in CD1 - ids, err := mr.GetAlbumIDsByFolder(ctx, lib, albumRoot.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(ConsistOf("fol-al-1")) - }) - - It("does not match albums outside the folder", func() { - ids, err := mr.GetAlbumIDsByFolder(ctx, lib, albumRoot.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).ToNot(ContainElement("fol-al-2")) - }) - }) - - Describe("GetCursor", func() { - It("yields the same media files as GetAll", func() { - opts := model.QueryOptions{Sort: "title"} - want, err := mr.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(mr.GetCursor(ctx, opts))).To(Equal([]model.MediaFile(want))) - }) - - It("honors Max/Offset like GetAll", func() { - opts := model.QueryOptions{Sort: "title", Max: 2, Offset: 1} - want, err := mr.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(mr.GetCursor(ctx, opts))).To(Equal([]model.MediaFile(want))) - }) + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid"}) + mr = NewMediaFileRepository(ctx, orm.NewOrm()) }) It("gets mediafile from the DB", func() { - actual, err := mr.Get(ctx, "1004") - Expect(err).ToNot(HaveOccurred()) - actual.CreatedAt = time.Time{} - Expect(actual).To(Equal(&songAntenna)) + Expect(mr.Get("1004")).To(Equal(&songAntenna)) }) It("returns ErrNotFound", func() { - _, err := mr.Get(ctx, "56") + _, err := mr.Get("56") Expect(err).To(MatchError(model.ErrNotFound)) }) It("counts the number of mediafiles in the DB", func() { - Expect(mr.CountAll(ctx)).To(Equal(int64(13))) - }) - - Describe("CountAll annotation-join gating", func() { - var adminRepo model.MediaFileRepository - var adminCtx context.Context - - BeforeEach(func() { - adminCtx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true}) - adminRepo = NewMediaFileRepository(GetDBXBuilder()) - }) - - It("counts starred songs when an annotation filter is present", func() { - // Come Together (id 1002) is starred for the admin user in the seed data - count, err := adminRepo.CountAll(adminCtx, model.QueryOptions{ - Filters: annotationBoolFilter("starred")("starred", "true"), - }) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(1))) - }) - - It("counts with starred=false without a 'no such column' error (join kept)", func() { - count, err := adminRepo.CountAll(adminCtx, model.QueryOptions{ - Filters: annotationBoolFilter("starred")("starred", "false"), - }) - Expect(err).ToNot(HaveOccurred()) - // All songs except the one starred one - Expect(count).To(Equal(int64(12))) - }) - - It("counts unfiltered with the join dropped", func() { - Expect(adminRepo.CountAll(adminCtx)).To(Equal(int64(13))) - }) - }) - - Describe("CountBySuffix", 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"} - - Expect(mr.Put(ctx, &mp3File)).To(Succeed()) - Expect(mr.Put(ctx, &flacFile1)).To(Succeed()) - Expect(mr.Put(ctx, &flacFile2)).To(Succeed()) - Expect(mr.Put(ctx, &flacUpperFile)).To(Succeed()) - }) - - AfterEach(func() { - _ = mr.Delete(ctx, mp3File.ID) - _ = mr.Delete(ctx, flacFile1.ID) - _ = mr.Delete(ctx, flacFile2.ID) - _ = mr.Delete(ctx, flacUpperFile.ID) - }) - - It("counts media files grouped by suffix with lowercase normalization", func() { - counts, err := mr.CountBySuffix(ctx) - Expect(err).ToNot(HaveOccurred()) - - // Should have lowercase keys only - Expect(counts).To(HaveKey("mp3")) - Expect(counts).To(HaveKey("flac")) - Expect(counts).ToNot(HaveKey("FLAC")) - - // mp3: 1 file - Expect(counts["mp3"]).To(Equal(int64(1))) - // flac: 3 files (2 lowercase + 1 uppercase normalized) - Expect(counts["flac"]).To(Equal(int64(3))) - }) - }) - - It("returns songs ordered by lyrics with a specific title/artist", func() { - // attempt to mimic filters.SongsByArtistTitleWithLyricsFirst, except we want all items - results, err := mr.GetAll(ctx, model.QueryOptions{ - Sort: "lyrics, updated_at", - Order: "desc", - Filters: squirrel.And{ - squirrel.Eq{"title": "Antenna"}, - squirrel.Or{ - Exists("json_tree(participants, '$.albumartist')", squirrel.Eq{"value": "Kraftwerk"}), - Exists("json_tree(participants, '$.artist')", squirrel.Eq{"value": "Kraftwerk"}), - }, - }, - }) - - Expect(err).To(BeNil()) - Expect(results).To(HaveLen(3)) - Expect(results[0].Lyrics).To(Equal(`[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`)) - for _, item := range results[1:] { - Expect(item.Lyrics).To(Equal("[]")) - Expect(item.Title).To(Equal("Antenna")) - Expect(item.Participants[model.RoleArtist][0].Name).To(Equal("Kraftwerk")) - } - }) - - Describe("GetRandom", func() { - It("returns the requested number of distinct, fully-hydrated media files", func() { - results, err := mr.GetRandom(ctx, model.QueryOptions{Max: 5}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(5)) - - // Each returned row must match its GetAll counterpart exactly — proves Phase 2 - // hydrates full rows (not bare rowids) — and ids must be distinct. - byID := map[string]model.MediaFile{} - all, err := mr.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - for _, mf := range all { - byID[mf.ID] = mf - } - seen := map[string]bool{} - for _, mf := range results { - expected, ok := byID[mf.ID] - Expect(ok).To(BeTrue(), "returned id must be a real media file") - Expect(mf.Title).To(Equal(expected.Title), "row must be fully hydrated") - Expect(seen[mf.ID]).To(BeFalse(), "no duplicate rows") - seen[mf.ID] = true - } - }) - - It("returns all matching files when Max exceeds the total", func() { - results, err := mr.GetRandom(ctx, model.QueryOptions{Max: 1000}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(13)) - }) - - It("honors filters", func() { - results, err := mr.GetRandom(ctx, model.QueryOptions{ - Max: 10, - Filters: squirrel.Eq{"media_file.title": "Antenna"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(results).ToNot(BeEmpty()) - for _, mf := range results { - Expect(mf.Title).To(Equal("Antenna")) - } - }) - - It("returns varying results across calls", func() { - // Retry a few times: two random draws of 5 from 13 rows differ with near-certainty. - first, err := mr.GetRandom(ctx, model.QueryOptions{Max: 5}) - Expect(err).ToNot(HaveOccurred()) - firstIDs := func() []string { - ids := make([]string, len(first)) - for i, mf := range first { - ids[i] = mf.ID - } - return ids - }() - differed := false - for range 10 { - next, err := mr.GetRandom(ctx, model.QueryOptions{Max: 5}) - Expect(err).ToNot(HaveOccurred()) - nextIDs := make([]string, len(next)) - for i, mf := range next { - nextIDs[i] = mf.ID - } - if !reflect.DeepEqual(firstIDs, nextIDs) { - differed = true - break - } - } - Expect(differed).To(BeTrue(), "GetRandom should not return an identical set every call") - }) - - It("randomizes order even when Max exceeds the total", func() { - // Same set of rows every time (all 13), but the order must still be shuffled — - // guards against Phase 2's `rowid IN (...)` returning rows in rowid order. - first, err := mr.GetRandom(ctx, model.QueryOptions{Max: 100}) - Expect(err).ToNot(HaveOccurred()) - Expect(first).To(HaveLen(13)) - firstIDs := make([]string, len(first)) - for i, mf := range first { - firstIDs[i] = mf.ID - } - differed := false - for range 10 { - next, err := mr.GetRandom(ctx, model.QueryOptions{Max: 100}) - Expect(err).ToNot(HaveOccurred()) - nextIDs := make([]string, len(next)) - for i, mf := range next { - nextIDs[i] = mf.ID - } - if !reflect.DeepEqual(firstIDs, nextIDs) { - differed = true - break - } - } - Expect(differed).To(BeTrue(), "order must vary even when returning all rows") - }) - }) - - Describe("Put CreatedAt behavior (#5050)", func() { - It("sets CreatedAt to now when inserting a new file with zero CreatedAt", func() { - before := time.Now().Add(-time.Second) - newFile := model.MediaFile{ID: id.NewRandom(), LibraryID: 1, Path: "test/created-at-zero.mp3"} - Expect(mr.Put(ctx, &newFile)).To(Succeed()) - - retrieved, err := mr.Get(ctx, newFile.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(retrieved.CreatedAt).To(BeTemporally(">", before)) - - _ = mr.Delete(ctx, newFile.ID) - }) - - It("preserves CreatedAt when inserting a new file with non-zero CreatedAt", func() { - originalTime := time.Date(2020, 3, 15, 10, 30, 0, 0, time.UTC) - newFile := model.MediaFile{ - ID: id.NewRandom(), - LibraryID: 1, - Path: "test/created-at-preserved.mp3", - CreatedAt: originalTime, - } - Expect(mr.Put(ctx, &newFile)).To(Succeed()) - - retrieved, err := mr.Get(ctx, newFile.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second)) - - _ = mr.Delete(ctx, newFile.ID) - }) - - It("does not reset CreatedAt when updating an existing file", func() { - originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC) - fileID := id.NewRandom() - newFile := model.MediaFile{ - ID: fileID, - LibraryID: 1, - Path: "test/created-at-update.mp3", - Title: "Original Title", - CreatedAt: originalTime, - } - Expect(mr.Put(ctx, &newFile)).To(Succeed()) - - // Update the file with a new title but zero CreatedAt - updatedFile := model.MediaFile{ - ID: fileID, - LibraryID: 1, - Path: "test/created-at-update.mp3", - Title: "Updated Title", - // CreatedAt is zero - should NOT overwrite the stored value - } - Expect(mr.Put(ctx, &updatedFile)).To(Succeed()) - - retrieved, err := mr.Get(ctx, fileID) - Expect(err).ToNot(HaveOccurred()) - Expect(retrieved.Title).To(Equal("Updated Title")) - // CreatedAt should still be the original time (not reset) - Expect(retrieved.CreatedAt).To(BeTemporally("~", originalTime, time.Second)) - - _ = mr.Delete(ctx, fileID) - }) + Expect(mr.CountAll()).To(Equal(int64(4))) }) It("checks existence of mediafiles in the DB", func() { - Expect(mr.Exists(ctx, songAntenna.ID)).To(BeTrue()) - Expect(mr.Exists(ctx, "666")).To(BeFalse()) + Expect(mr.Exists(songAntenna.ID)).To(BeTrue()) + Expect(mr.Exists("666")).To(BeFalse()) + }) + + It("finds tracks by path when using wildcards chars", func() { + Expect(mr.Put(&model.MediaFile{ID: "7001", Path: P("/Find:By'Path/_/123.mp3")})).To(BeNil()) + Expect(mr.Put(&model.MediaFile{ID: "7002", Path: P("/Find:By'Path/1/123.mp3")})).To(BeNil()) + + found, err := mr.FindAllByPath(P("/Find:By'Path/_/")) + Expect(err).To(BeNil()) + Expect(found).To(HaveLen(1)) + Expect(found[0].ID).To(Equal("7001")) + }) + + It("finds tracks by path when using UTF8 chars", func() { + Expect(mr.Put(&model.MediaFile{ID: "7010", Path: P("/Пётр Ильич Чайковский/123.mp3")})).To(BeNil()) + Expect(mr.Put(&model.MediaFile{ID: "7011", Path: P("/Пётр Ильич Чайковский/222.mp3")})).To(BeNil()) + + found, err := mr.FindAllByPath(P("/Пётр Ильич Чайковский/")) + Expect(err).To(BeNil()) + Expect(found).To(HaveLen(2)) + }) + + It("finds tracks by path case sensitively", func() { + Expect(mr.Put(&model.MediaFile{ID: "7003", Path: P("/Casesensitive/file1.mp3")})).To(BeNil()) + Expect(mr.Put(&model.MediaFile{ID: "7004", Path: P("/casesensitive/file2.mp3")})).To(BeNil()) + + found, err := mr.FindAllByPath(P("/Casesensitive")) + Expect(err).To(BeNil()) + Expect(found).To(HaveLen(1)) + Expect(found[0].ID).To(Equal("7003")) + + found, err = mr.FindAllByPath(P("/casesensitive/")) + Expect(err).To(BeNil()) + Expect(found).To(HaveLen(1)) + Expect(found[0].ID).To(Equal("7004")) }) It("delete tracks by id", func() { - newID := id.NewRandom() - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed()) + id := uuid.NewString() + Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil()) - Expect(mr.Delete(ctx, newID)).To(Succeed()) + Expect(mr.Delete(id)).To(BeNil()) - _, err := mr.Get(ctx, newID) + _, err := mr.Get(id) Expect(err).To(MatchError(model.ErrNotFound)) }) - It("deletes all missing files", func() { - new1 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1} - new2 := model.MediaFile{ID: id.NewRandom(), LibraryID: 1} - Expect(mr.Put(ctx, &new1)).To(Succeed()) - Expect(mr.Put(ctx, &new2)).To(Succeed()) - Expect(mr.MarkMissing(ctx, true, &new1, &new2)).To(Succeed()) + It("delete tracks by path", func() { + id1 := "6001" + Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/abc/123/" + id1 + ".mp3")})).To(BeNil()) + id2 := "6002" + Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/abc/123/" + id2 + ".mp3")})).To(BeNil()) + id3 := "6003" + Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/ab_/" + id3 + ".mp3")})).To(BeNil()) + id4 := "6004" + Expect(mr.Put(&model.MediaFile{ID: id4, Path: P("/abc/" + id4 + ".mp3")})).To(BeNil()) + id5 := "6005" + Expect(mr.Put(&model.MediaFile{ID: id5, Path: P("/Ab_/" + id5 + ".mp3")})).To(BeNil()) - adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true}) - adminRepo := NewMediaFileRepository(GetDBXBuilder()) + Expect(mr.DeleteByPath(P("/ab_"))).To(Equal(int64(1))) - // Ensure the files are marked as missing and we have 2 of them - count, err := adminRepo.CountAll(adminCtx, model.QueryOptions{Filters: squirrel.Eq{"missing": true}}) - Expect(count).To(BeNumerically("==", 2)) - Expect(err).ToNot(HaveOccurred()) - - count, err = adminRepo.DeleteAllMissing(adminCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeNumerically("==", 2)) - - _, err = mr.Get(ctx, new1.ID) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = mr.Get(ctx, new2.ID) + Expect(mr.Get(id1)).ToNot(BeNil()) + Expect(mr.Get(id2)).ToNot(BeNil()) + Expect(mr.Get(id4)).ToNot(BeNil()) + Expect(mr.Get(id5)).ToNot(BeNil()) + _, err := mr.Get(id3) Expect(err).To(MatchError(model.ErrNotFound)) }) + It("delete tracks by path containing UTF8 chars", func() { + id1 := "6011" + Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/Legião Urbana/" + id1 + ".mp3")})).To(BeNil()) + id2 := "6012" + Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/Legião Urbana/" + id2 + ".mp3")})).To(BeNil()) + id3 := "6003" + Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/Legião Urbana/" + id3 + ".mp3")})).To(BeNil()) + + Expect(mr.FindAllByPath(P("/Legião Urbana"))).To(HaveLen(3)) + Expect(mr.DeleteByPath(P("/Legião Urbana"))).To(Equal(int64(3))) + Expect(mr.FindAllByPath(P("/Legião Urbana"))).To(HaveLen(0)) + }) + + It("only deletes tracks that match exact path", func() { + id1 := "6021" + Expect(mr.Put(&model.MediaFile{ID: id1, Path: P("/music/overlap/Ella Fitzgerald/" + id1 + ".mp3")})).To(BeNil()) + id2 := "6022" + Expect(mr.Put(&model.MediaFile{ID: id2, Path: P("/music/overlap/Ella Fitzgerald/" + id2 + ".mp3")})).To(BeNil()) + id3 := "6023" + Expect(mr.Put(&model.MediaFile{ID: id3, Path: P("/music/overlap/Ella Fitzgerald & Louis Armstrong - They Can't Take That Away From Me.mp3")})).To(BeNil()) + + Expect(mr.FindAllByPath(P("/music/overlap/Ella Fitzgerald"))).To(HaveLen(2)) + Expect(mr.DeleteByPath(P("/music/overlap/Ella Fitzgerald"))).To(Equal(int64(2))) + Expect(mr.FindAllByPath(P("/music/overlap"))).To(HaveLen(1)) + }) + + It("filters by genre", func() { + Expect(mr.GetAll(model.QueryOptions{ + Sort: "genre.name asc, title asc", + Filters: squirrel.Eq{"genre.name": "Rock"}, + })).To(Equal(model.MediaFiles{ + songDayInALife, + songAntenna, + songComeTogether, + })) + }) + Context("Annotations", func() { It("increments play count when the tracks does not have annotations", func() { id := "incplay.firsttime" - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) + Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil()) playDate := time.Now() - Expect(mr.IncPlayCount(ctx, id, playDate)).To(BeNil()) + Expect(mr.IncPlayCount(id, playDate)).To(BeNil()) - mf, err := mr.Get(ctx, id) + mf, err := mr.Get(id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix())) Expect(mf.PlayCount).To(Equal(int64(1))) }) - Describe("AverageRating", func() { - var raw *mediaFileRepository - - BeforeEach(func() { - raw = mr.(*mediaFileRepository) - }) - - It("returns 0 when no ratings exist", func() { - newID := id.NewRandom() - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/no-rating.mp3"})).To(Succeed()) - - mf, err := mr.Get(ctx, newID) - Expect(err).ToNot(HaveOccurred()) - Expect(mf.AverageRating).To(Equal(0.0)) - - _, _ = raw.executeSQL(ctx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) - }) - - It("returns the user's rating as average when only one user rated", func() { - newID := id.NewRandom() - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/single-rating.mp3"})).To(Succeed()) - Expect(mr.SetRating(ctx, 5, newID)).To(Succeed()) - - mf, err := mr.Get(ctx, newID) - Expect(err).ToNot(HaveOccurred()) - Expect(mf.AverageRating).To(Equal(5.0)) - - _, _ = raw.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = raw.executeSQL(ctx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) - }) - - It("calculates average across multiple users", func() { - newID := id.NewRandom() - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/multi-rating.mp3"})).To(Succeed()) - - Expect(mr.SetRating(ctx, 3, newID)).To(Succeed()) - - user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) - user2Repo := NewMediaFileRepository(GetDBXBuilder()) - Expect(user2Repo.SetRating(user2Ctx, 5, newID)).To(Succeed()) - - mf, err := mr.Get(ctx, newID) - Expect(err).ToNot(HaveOccurred()) - Expect(mf.AverageRating).To(Equal(4.0)) - - _, _ = raw.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = raw.executeSQL(ctx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) - }) - - It("excludes zero ratings from average calculation", func() { - newID := id.NewRandom() - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: newID, Path: "test/zero-excluded.mp3"})).To(Succeed()) - - Expect(mr.SetRating(ctx, 4, newID)).To(Succeed()) - - user2Ctx := request.WithUser(GinkgoT().Context(), regularUser) - user2Repo := NewMediaFileRepository(GetDBXBuilder()) - Expect(user2Repo.SetRating(user2Ctx, 0, newID)).To(Succeed()) - - mf, err := mr.Get(ctx, newID) - Expect(err).ToNot(HaveOccurred()) - Expect(mf.AverageRating).To(Equal(4.0)) - - _, _ = raw.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID})) - _, _ = raw.executeSQL(ctx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID})) - }) - }) - It("preserves play date if and only if provided date is older", func() { id := "incplay.playdate" - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) + Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil()) playDate := time.Now() - Expect(mr.IncPlayCount(ctx, id, playDate)).To(BeNil()) - mf, err := mr.Get(ctx, id) + Expect(mr.IncPlayCount(id, playDate)).To(BeNil()) + mf, err := mr.Get(id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix())) Expect(mf.PlayCount).To(Equal(int64(1))) playDateLate := playDate.AddDate(0, 0, 1) - Expect(mr.IncPlayCount(ctx, id, playDateLate)).To(BeNil()) - mf, err = mr.Get(ctx, id) + Expect(mr.IncPlayCount(id, playDateLate)).To(BeNil()) + mf, err = mr.Get(id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDateLate.Unix())) Expect(mf.PlayCount).To(Equal(int64(2))) playDateEarly := playDate.AddDate(0, 0, -1) - Expect(mr.IncPlayCount(ctx, id, playDateEarly)).To(BeNil()) - mf, err = mr.Get(ctx, id) + Expect(mr.IncPlayCount(id, playDateEarly)).To(BeNil()) + mf, err = mr.Get(id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDateLate.Unix())) Expect(mf.PlayCount).To(Equal(int64(3))) @@ -512,851 +185,16 @@ var _ = Describe("MediaRepository", func() { It("increments play count on newly starred items", func() { id := "star.incplay" - Expect(mr.Put(ctx, &model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) - Expect(mr.SetStar(ctx, true, id)).To(BeNil()) + Expect(mr.Put(&model.MediaFile{ID: id})).To(BeNil()) + Expect(mr.SetStar(true, id)).To(BeNil()) playDate := time.Now() - Expect(mr.IncPlayCount(ctx, id, playDate)).To(BeNil()) + Expect(mr.IncPlayCount(id, playDate)).To(BeNil()) - mf, err := mr.Get(ctx, id) + mf, err := mr.Get(id) Expect(err).To(BeNil()) Expect(mf.PlayDate.Unix()).To(Equal(playDate.Unix())) Expect(mf.PlayCount).To(Equal(int64(1))) }) }) - - Context("Sort options", func() { - Context("recently_added sort", func() { - var testMediaFiles []model.MediaFile - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - - // Create test media files with specific timestamps - testMediaFiles = []model.MediaFile{ - { - ID: id.NewRandom(), - LibraryID: 1, - Title: "Old Song", - Path: "test/old.mp3", - }, - { - ID: id.NewRandom(), - LibraryID: 1, - Title: "Middle Song", - Path: "test/middle.mp3", - }, - { - ID: id.NewRandom(), - LibraryID: 1, - Title: "New Song", - Path: "test/new.mp3", - }, - } - - // Insert test data first - for i := range testMediaFiles { - Expect(mr.Put(ctx, &testMediaFiles[i])).To(Succeed()) - } - - // Then manually update timestamps using direct SQL to bypass the repository logic - db := GetDBXBuilder() - - // Set specific timestamps for testing - oldTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) - middleTime := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) - newTime := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC) - - // Update "Old Song": created long ago, updated recently - _, err := db.Update("media_file", - map[string]any{ - "created_at": oldTime, - "updated_at": newTime, - }, - dbx.HashExp{"id": testMediaFiles[0].ID}).Execute() - Expect(err).ToNot(HaveOccurred()) - - // Update "Middle Song": created and updated at the same middle time - _, err = db.Update("media_file", - map[string]any{ - "created_at": middleTime, - "updated_at": middleTime, - }, - dbx.HashExp{"id": testMediaFiles[1].ID}).Execute() - Expect(err).ToNot(HaveOccurred()) - - // Update "New Song": created recently, updated long ago - _, err = db.Update("media_file", - map[string]any{ - "created_at": newTime, - "updated_at": oldTime, - }, - dbx.HashExp{"id": testMediaFiles[2].ID}).Execute() - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - // Clean up test data - for _, mf := range testMediaFiles { - _ = mr.Delete(ctx, mf.ID) - } - }) - - When("RecentlyAddedByModTime is false", func() { - var testRepo model.MediaFileRepository - - BeforeEach(func() { - conf.Server.RecentlyAddedByModTime = false - // Create repository AFTER setting config - testRepo = NewMediaFileRepository(GetDBXBuilder()) - }) - - It("sorts by created_at", func() { - // Get results sorted by recently_added (should use created_at) - results, err := testRepo.GetAll(ctx, model.QueryOptions{ - Sort: "recently_added", - Order: "desc", - Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(3)) - - // Verify sorting by created_at (newest first in descending order) - Expect(results[0].Title).To(Equal("New Song")) // created 2022 - Expect(results[1].Title).To(Equal("Middle Song")) // created 2021 - Expect(results[2].Title).To(Equal("Old Song")) // created 2020 - }) - - It("sorts in ascending order when specified", func() { - // Get results sorted by recently_added in ascending order - results, err := testRepo.GetAll(ctx, model.QueryOptions{ - Sort: "recently_added", - Order: "asc", - Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(3)) - - // Verify sorting by created_at (oldest first) - Expect(results[0].Title).To(Equal("Old Song")) // created 2020 - Expect(results[1].Title).To(Equal("Middle Song")) // created 2021 - Expect(results[2].Title).To(Equal("New Song")) // created 2022 - }) - }) - - When("RecentlyAddedByModTime is true", func() { - var testRepo model.MediaFileRepository - - BeforeEach(func() { - conf.Server.RecentlyAddedByModTime = true - // Create repository AFTER setting config - testRepo = NewMediaFileRepository(GetDBXBuilder()) - }) - - It("sorts by updated_at", func() { - // Get results sorted by recently_added (should use updated_at) - results, err := testRepo.GetAll(ctx, model.QueryOptions{ - Sort: "recently_added", - Order: "desc", - Filters: squirrel.Eq{"media_file.id": []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID}}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(3)) - - // Verify sorting by updated_at (newest first in descending order) - Expect(results[0].Title).To(Equal("Old Song")) // updated 2022 - Expect(results[1].Title).To(Equal("Middle Song")) // updated 2021 - Expect(results[2].Title).To(Equal("New Song")) // updated 2020 - }) - }) - - It("breaks ties deterministically when files share the same created_at", func() { - conf.Server.RecentlyAddedByModTime = false - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid"}) - repo := NewMediaFileRepository(GetDBXBuilder()) - - ids := []string{testMediaFiles[0].ID, testMediaFiles[1].ID, testMediaFiles[2].ID} - sameTime := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC) - _, err := GetDBXBuilder().Update("media_file", - dbx.Params{"created_at": sameTime}, - dbx.In("id", ids[0], ids[1], ids[2])).Execute() - Expect(err).ToNot(HaveOccurred()) - - order := func() []string { - res, err := repo.GetAll(ctx, model.QueryOptions{ - Sort: "recently_added", Order: "desc", - Filters: squirrel.Eq{"media_file.id": ids}}) - Expect(err).ToNot(HaveOccurred()) - out := make([]string, len(res)) - for i, mf := range res { - out[i] = mf.ID - } - return out - } - // Stable across repeated queries (no query-plan-dependent reordering). - Expect(order()).To(Equal(order())) - }) - - }) - }) - - Context("Filters", func() { - var mfWithoutAnnotation model.MediaFile - - BeforeEach(func() { - mfWithoutAnnotation = model.MediaFile{ID: "no-annotation-file", LibraryID: 1, Path: "test/no-annotation.mp3", Title: "No Annotation"} - Expect(mr.Put(ctx, &mfWithoutAnnotation)).To(Succeed()) - }) - - AfterEach(func() { - _ = mr.Delete(ctx, mfWithoutAnnotation.ID) - }) - - Describe("starred", func() { - It("false includes items without annotations", func() { - res, err := mr.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"starred": "false"}, - }) - Expect(err).ToNot(HaveOccurred()) - files := res - - var found bool - for _, f := range files { - if f.ID == mfWithoutAnnotation.ID { - found = true - break - } - } - Expect(found).To(BeTrue(), "MediaFile without annotation should be included in starred=false filter") - }) - - It("true excludes items without annotations", func() { - res, err := mr.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"starred": "true"}, - }) - Expect(err).ToNot(HaveOccurred()) - files := res - - for _, f := range files { - Expect(f.ID).ToNot(Equal(mfWithoutAnnotation.ID)) - } - }) - }) - - Describe("path", func() { - It("matches files whose path starts with the given prefix", func() { - res, err := mr.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"path": "test/"}, - }) - Expect(err).ToNot(HaveOccurred()) - files := res - - var found bool - for _, f := range files { - Expect(f.Path).To(HavePrefix("test/")) - if f.ID == mfWithoutAnnotation.ID { - found = true - } - } - Expect(found).To(BeTrue(), "MediaFile with matching path prefix should be included") - }) - - It("excludes files whose path does not start with the given prefix", func() { - res, err := mr.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"path": "no-such-prefix/"}, - }) - Expect(err).ToNot(HaveOccurred()) - files := res - Expect(files).To(BeEmpty()) - }) - }) - }) - - Describe("Search", func() { - Context("text search", func() { - It("finds media files by title", func() { - results, err := mr.Search(ctx, "Antenna", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(3)) // songAntenna, songAntennaWithLyrics, songAntenna2 - for _, result := range results { - Expect(result.Title).To(Equal("Antenna")) - } - }) - - It("finds media files case insensitively", func() { - results, err := mr.Search(ctx, "antenna", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(3)) - for _, result := range results { - Expect(result.Title).To(Equal("Antenna")) - } - }) - - It("returns empty result when no matches found", func() { - results, err := mr.Search(ctx, "nonexistent", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) - }) - - Context("MBID search", func() { - var mediaFileWithMBID model.MediaFile - var raw *mediaFileRepository - - BeforeEach(func() { - raw = mr.(*mediaFileRepository) - // Create a test media file with MBID - mediaFileWithMBID = model.MediaFile{ - ID: "test-mbid-mediafile", - Title: "Test MBID MediaFile", - 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", - } - - // Insert the test media file into the database - err := mr.Put(ctx, &mediaFileWithMBID) - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - // Clean up test data using direct SQL - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": mediaFileWithMBID.ID})) - }) - - It("finds media file by mbz_recording_id", func() { - results, err := mr.Search(ctx, "550e8400-e29b-41d4-a716-446655440020", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("test-mbid-mediafile")) - Expect(results[0].Title).To(Equal("Test MBID MediaFile")) - }) - - It("finds media file by mbz_release_track_id", func() { - results, err := mr.Search(ctx, "550e8400-e29b-41d4-a716-446655440021", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("test-mbid-mediafile")) - Expect(results[0].Title).To(Equal("Test MBID MediaFile")) - }) - - It("returns empty result when MBID is not found", func() { - results, err := mr.Search(ctx, "550e8400-e29b-41d4-a716-446655440099", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) - - It("missing media files are never returned by search", func() { - // Create a missing media file with MBID - missingMediaFile := model.MediaFile{ - ID: "test-missing-mbid-mediafile", - Title: "Test Missing MBID MediaFile", - MbzRecordingID: "550e8400-e29b-41d4-a716-446655440022", - LibraryID: 1, - Path: "test/path/missing.mp3", - Missing: true, - } - - err := mr.Put(ctx, &missingMediaFile) - Expect(err).ToNot(HaveOccurred()) - - // Search never returns missing media files (hardcoded behavior) - results, err := mr.Search(ctx, "550e8400-e29b-41d4-a716-446655440022", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - - // Clean up - _, _ = raw.executeSQL(ctx, squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID})) - }) - }) - - Context("empty query (natural order pagination)", func() { - It("returns all non-missing files in natural order", func() { - results, err := mr.Search(ctx, "", model.QueryOptions{Max: 1000}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).ToNot(BeEmpty()) - for _, result := range results { - Expect(result.Missing).To(BeFalse()) - } - }) - - It(`treats quoted empty query ("") the same as empty`, func() { - all, err := mr.Search(ctx, "", model.QueryOptions{Max: 1000}) - Expect(err).ToNot(HaveOccurred()) - quoted, err := mr.Search(ctx, `""`, model.QueryOptions{Max: 1000}) - Expect(err).ToNot(HaveOccurred()) - Expect(quoted).To(HaveLen(len(all))) - }) - - It("paginates without overlaps or gaps", func() { - all, err := mr.Search(ctx, "", model.QueryOptions{Max: 1000}) - Expect(err).ToNot(HaveOccurred()) - Expect(len(all)).To(BeNumerically(">", 3)) - - var paged model.MediaFiles - pageSize := 3 - for offset := 0; offset < len(all); offset += pageSize { - page, err := mr.Search(ctx, "", model.QueryOptions{Max: pageSize, Offset: offset}) - Expect(err).ToNot(HaveOccurred()) - paged = append(paged, page...) - } - Expect(paged).To(HaveLen(len(all))) - for i := range all { - Expect(paged[i].ID).To(Equal(all[i].ID), fmt.Sprintf("row %d differs", i)) - } - }) - - It("returns empty page when offset is beyond the total", func() { - results, err := mr.Search(ctx, "", model.QueryOptions{Max: 10, Offset: 100000}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) - }) - }) - - Describe("ReassignReferences", func() { - var prev, next model.MediaFile - var pr model.PlaylistRepository - var pls model.Playlist - - BeforeEach(func() { - ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) - pr = NewPlaylistRepository(GetDBXBuilder()) - prev = model.MediaFile{ID: "reassign-prev", LibraryID: 1, Path: "reassign/prev.mp3", Title: "Prev"} - next = model.MediaFile{ID: "reassign-next", LibraryID: 1, Path: "reassign/next.mp3", Title: "Next"} - Expect(mr.Put(ctx, &prev)).To(Succeed()) - Expect(mr.Put(ctx, &next)).To(Succeed()) - pls = model.Playlist{Name: "Reassign", OwnerID: "userid"} - pls.AddMediaFilesByID([]string{prev.ID}) - Expect(pr.Put(ctx, &pls)).To(Succeed()) - }) - - AfterEach(func() { - _ = pr.Delete(ctx, pls.ID) - _ = mr.Delete(ctx, prev.ID) - _ = mr.Delete(ctx, next.ID) - _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) - _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("bookmark").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) - _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("scrobbles").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) - _, _ = mr.(*mediaFileRepository).executeSQL(ctx, squirrel.Delete("scrobble_buffer").Where(squirrel.Eq{"media_file_id": []string{prev.ID, next.ID}})) - }) - - It("moves annotations, bookmarks and playlist entries onto the new id", func() { - Expect(mr.SetRating(ctx, 5, prev.ID)).To(Succeed()) - Expect(mr.AddBookmark(ctx, prev.ID, "here", 42)).To(Succeed()) - - Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) - - got, err := mr.Get(ctx, next.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Rating).To(Equal(5)) - - bookmarks, err := mr.GetBookmarks(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(bookmarks).To(ContainElement(HaveField("Item.ID", next.ID))) - - withTracks, err := pr.GetWithTracks(ctx, pls.ID, false, false) - Expect(err).ToNot(HaveOccurred()) - Expect(withTracks.Tracks).To(HaveLen(1)) - Expect(withTracks.Tracks[0].MediaFileID).To(Equal(next.ID)) - }) - - It("moves scrobbles and buffered scrobbles onto the new id", func() { - ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"}) - scrobbles := NewScrobbleRepository(GetDBXBuilder()) - buffer := NewScrobbleBufferRepository(GetDBXBuilder()) - Expect(scrobbles.RecordScrobble(ctx, prev.ID, time.Now())).To(Succeed()) - Expect(buffer.Enqueue(ctx, "lastfm", "userid", prev.ID, time.Now())).To(Succeed()) - - Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) - Expect(mr.Delete(ctx, prev.ID)).To(Succeed()) - - all, err := scrobbles.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - mine := slice.Map(slice.Filter(all, func(sc model.Scrobble) bool { - return sc.MediaFileID == prev.ID || sc.MediaFileID == next.ID - }), func(sc model.Scrobble) string { return sc.MediaFileID }) - Expect(mine).To(ConsistOf(next.ID)) - - entry, err := buffer.Next(ctx, "lastfm", "userid") - Expect(err).ToNot(HaveOccurred()) - Expect(entry).ToNot(BeNil()) - Expect(entry.MediaFile.ID).To(Equal(next.ID)) - }) - - It("recomputes the average rating after merging another user's annotation", func() { - otherCtx := request.WithUser(ctx, model.User{ID: "2222"}) - other := NewMediaFileRepository(GetDBXBuilder()) - Expect(mr.SetRating(ctx, 5, next.ID)).To(Succeed()) - Expect(other.SetRating(otherCtx, 3, prev.ID)).To(Succeed()) - - Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) - - got, err := mr.Get(ctx, next.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.AverageRating).To(Equal(4.0)) - }) - - It("keeps the new id's own annotation and bookmark when both exist", func() { - Expect(mr.SetRating(ctx, 5, prev.ID)).To(Succeed()) - Expect(mr.SetRating(ctx, 1, next.ID)).To(Succeed()) - Expect(mr.AddBookmark(ctx, prev.ID, "prev", 42)).To(Succeed()) - Expect(mr.AddBookmark(ctx, next.ID, "next", 7)).To(Succeed()) - - Expect(mr.ReassignReferences(ctx, prev.ID, next.ID)).To(Succeed()) - - got, err := mr.Get(ctx, next.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Rating).To(Equal(1)) - bookmarks, err := mr.GetBookmarks(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(bookmarks).To(ContainElement(SatisfyAll(HaveField("Item.ID", next.ID), HaveField("Comment", "next")))) - }) - }) - - Describe("FindByPaths", func() { - // Test fixtures for Unicode and case-sensitivity tests - var testFiles []model.MediaFile - - BeforeEach(func() { - testFiles = []model.MediaFile{ - {ID: "findpath-1", LibraryID: 1, Path: "artist/Album/track.mp3", Title: "Track"}, - {ID: "findpath-2", LibraryID: 1, Path: "artist/Album/UPPER.mp3", Title: "Upper"}, - // Fullwidth uppercase: ACROSS (U+FF21 U+FF23 U+FF32 U+FF2F U+FF33 U+FF33) - {ID: "findpath-3", LibraryID: 1, Path: "plex/02 - ACROSS.flac", Title: "Fullwidth"}, - // French diacritic: è (U+00E8, can decompose to e + combining grave) - {ID: "findpath-4", LibraryID: 1, Path: "artist/Michèle/song.mp3", Title: "French"}, - {ID: "findpath-5", LibraryID: 1, Path: "Bach: Goldberg Variations/01.mp3", Title: "Colon"}, - {ID: "findpath-6", LibraryID: 1, Path: "1999: A Different Life/01.mp3", Title: "Numeric colon"}, - } - for _, mf := range testFiles { - Expect(mr.Put(ctx, &mf)).To(Succeed()) - } - }) - - AfterEach(func() { - for _, mf := range testFiles { - _ = mr.Delete(ctx, mf.ID) - } - }) - - It("treats a path whose prefix is not a library id as unqualified", func() { - results, err := mr.FindByPaths(ctx, []string{"Bach: Goldberg Variations/01.mp3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("findpath-5")) - }) - - It("finds a plain path whose colon prefix looks like a library id", func() { - results, err := mr.FindByPaths(ctx, []string{"1999: A Different Life/01.mp3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("findpath-6")) - }) - - It("splits only the first colon of a library-qualified path", func() { - results, err := mr.FindByPaths(ctx, []string{"1:Bach: Goldberg Variations/01.mp3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("findpath-5")) - }) - - It("finds files by exact path", func() { - results, err := mr.FindByPaths(ctx, []string{"1:artist/Album/track.mp3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("findpath-1")) - }) - - It("finds files case-insensitively for ASCII characters (NOCASE)", func() { - // SQLite's COLLATE NOCASE handles ASCII case-insensitivity - results, err := mr.FindByPaths(ctx, []string{"1:ARTIST/ALBUM/TRACK.MP3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("findpath-1")) - }) - - It("finds fullwidth characters only with exact case match (SQLite NOCASE limitation)", func() { - // SQLite's NOCASE does NOT handle fullwidth uppercase/lowercase equivalence - // The DB has fullwidth uppercase ACROSS, searching with exact match should work - results, err := mr.FindByPaths(ctx, []string{"1:plex/02 - ACROSS.flac"}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("findpath-3")) - - // Searching with fullwidth lowercase across should NOT match - // (this is the SQLite limitation that requires exact matching for non-ASCII) - results, err = mr.FindByPaths(ctx, []string{"1:plex/02 - across.flac"}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) - - It("returns multiple files when querying multiple paths", func() { - results, err := mr.FindByPaths(ctx, []string{ - "1:artist/Album/track.mp3", - "1:artist/Album/UPPER.mp3", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(2)) - }) - - It("returns empty slice for non-existent paths", func() { - results, err := mr.FindByPaths(ctx, []string{"1:nonexistent/path.mp3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) - - It("returns empty slice for empty input", func() { - results, err := mr.FindByPaths(ctx, []string{}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) - - It("handles library-qualified paths correctly", func() { - // Library 1 should find the file - results, err := mr.FindByPaths(ctx, []string{"1:artist/Album/track.mp3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - - // Library 2 should NOT find it (file is in library 1) - results, err = mr.FindByPaths(ctx, []string{"2:artist/Album/track.mp3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) - - Context("when the user has restricted library access", func() { - var otherLib model.Library - var restrictedUser model.User - - BeforeEach(func() { - adminCtx := request.WithUser(GinkgoT().Context(), adminUser) - lr := NewLibraryRepository(GetDBXBuilder()) - - // A second library the restricted user has no access to - otherLib = model.Library{ID: 0, Name: "Other Library", Path: "/other/lib"} - Expect(lr.Put(adminCtx, &otherLib)).To(Succeed()) - - // A track that lives only in the other library (created as admin) - adminMr := NewMediaFileRepository(GetDBXBuilder()) - Expect(adminMr.Put(adminCtx, &model.MediaFile{ - ID: "otherlib-track", LibraryID: otherLib.ID, - Path: "hidden/test.mp3", Title: "Hidden", - })).To(Succeed()) - - // Non-admin user with access to library 1 ONLY - restrictedUser = createUserWithLibraries("restricted-finder", []int{1}) - ur := NewUserRepository(GetDBXBuilder()) - Expect(ur.Put(adminCtx, &restrictedUser)).To(Succeed()) - Expect(ur.SetUserLibraries(adminCtx, restrictedUser.ID, []int{1})).To(Succeed()) - }) - - AfterEach(func() { - adminCtx := request.WithUser(GinkgoT().Context(), adminUser) - _ = NewMediaFileRepository(GetDBXBuilder()).Delete(adminCtx, "otherlib-track") - lr := NewLibraryRepository(GetDBXBuilder()).(*libraryRepository) - _ = lr.delete(adminCtx, squirrel.Eq{"id": otherLib.ID}) - _ = NewUserRepository(GetDBXBuilder()).Delete(adminCtx, restrictedUser.ID) - }) - - It("does not resolve paths in libraries the user cannot access", func() { - userCtx := request.WithUser(ctx, restrictedUser) - userMr := NewMediaFileRepository(GetDBXBuilder()) - qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID) - results, err := userMr.FindByPaths(userCtx, []string{qualified}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty(), "a track outside the user's libraries must not be resolvable") - }) - - It("still resolves the path for an admin", func() { - adminCtx := request.WithUser(ctx, adminUser) - adminMr := NewMediaFileRepository(GetDBXBuilder()) - qualified := fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID) - results, err := adminMr.FindByPaths(adminCtx, []string{qualified}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal("otherlib-track")) - }) - - It("resolves paths from multiple libraries in a single call", func() { - adminCtx := request.WithUser(ctx, adminUser) - adminMr := NewMediaFileRepository(GetDBXBuilder()) - results, err := adminMr.FindByPaths(adminCtx, []string{ - "1:artist/Album/track.mp3", - fmt.Sprintf("%d:hidden/test.mp3", otherLib.ID), - }) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(2)) - Expect([]string{results[0].ID, results[1].ID}).To(ConsistOf("findpath-1", "otherlib-track")) - }) - - It("keeps each path scoped to its own library when several are queried", func() { - adminCtx := request.WithUser(ctx, adminUser) - adminMr := NewMediaFileRepository(GetDBXBuilder()) - // Each path exists, but under the other library's ID, so neither must match. - results, err := adminMr.FindByPaths(adminCtx, []string{ - fmt.Sprintf("%d:artist/Album/track.mp3", otherLib.ID), - "1:hidden/test.mp3", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) - }) - }) - - Describe("dbMediaFile.String", func() { - It("does not dereference a nil MediaFile", func() { - Expect(fmt.Sprint(dbMediaFile{})).To(Equal("")) - Expect(fmt.Sprint(&dbMediaFile{})).To(Equal("")) - }) - }) - - Describe("wrapMediaFileCursor", func() { - It("does not panic when the cursor yields a dbMediaFile with nil MediaFile", func() { - // Simulate what queryWithStableResults does on the rows.Err() path: - // it yields a zero-value dbMediaFile (where MediaFile is nil) with an error. - dbErr := fmt.Errorf("database is locked") - cursor := func(yield func(dbMediaFile, error) bool) { - var empty dbMediaFile // MediaFile pointer is nil - yield(empty, dbErr) - } - - // wrapMediaFileCursor should handle the nil MediaFile without panicking - wrappedCursor := wrapMediaFileCursor(cursor) - var gotErr error - Expect(func() { - for _, err := range wrappedCursor { - gotErr = err - } - }).ToNot(Panic()) - Expect(gotErr).To(HaveOccurred()) - Expect(gotErr.Error()).To(ContainSubstring("unexpected nil model.MediaFile")) - Expect(errors.Is(gotErr, dbErr)).To(BeTrue(), "should wrap the original cursor error") - }) - - It("yields mediafiles from a valid cursor", func() { - mf := &model.MediaFile{ID: "mf1", Title: "Test"} - cursor := func(yield func(dbMediaFile, error) bool) { - yield(dbMediaFile{MediaFile: mf}, nil) - } - - wrappedCursor := wrapMediaFileCursor(cursor) - var mediafiles []model.MediaFile - for m, err := range wrappedCursor { - Expect(err).ToNot(HaveOccurred()) - mediafiles = append(mediafiles, m) - } - Expect(mediafiles).To(HaveLen(1)) - Expect(mediafiles[0].ID).To(Equal("mf1")) - }) - }) - - Describe("BPM and BitDepth nullable round-trip", func() { - It("stores nil BPM and BitDepth as NULL and retrieves them as nil", func() { - newID := id.NewRandom() - mf := model.MediaFile{LibraryID: 1, ID: newID, Path: "test/bpm-nil.mp3"} - Expect(mr.Put(ctx, &mf)).To(Succeed()) - - retrieved, err := mr.Get(ctx, newID) - Expect(err).ToNot(HaveOccurred()) - Expect(retrieved.BPM).To(BeNil()) - Expect(retrieved.BitDepth).To(BeNil()) - - // Also verify via raw SQL that the columns are truly NULL (not 0) - db := GetDBXBuilder() - var row struct { - BPM *int `db:"bpm"` - BitDepth *int `db:"bit_depth"` - } - err = db.NewQuery("SELECT bpm, bit_depth FROM media_file WHERE id={:id}"). - Bind(dbx.Params{"id": newID}). - One(&row) - Expect(err).ToNot(HaveOccurred()) - Expect(row.BPM).To(BeNil(), "bpm should be stored as NULL in the database") - Expect(row.BitDepth).To(BeNil(), "bit_depth should be stored as NULL in the database") - - _ = mr.Delete(ctx, newID) - }) - - It("stores non-nil BPM and BitDepth and retrieves correct values", func() { - newID := id.NewRandom() - bpm := 120 - bitDepth := 24 - mf := model.MediaFile{LibraryID: 1, ID: newID, Path: "test/bpm-set.mp3", BPM: &bpm, BitDepth: &bitDepth} - Expect(mr.Put(ctx, &mf)).To(Succeed()) - - retrieved, err := mr.Get(ctx, newID) - Expect(err).ToNot(HaveOccurred()) - Expect(retrieved.BPM).ToNot(BeNil()) - Expect(*retrieved.BPM).To(Equal(120)) - Expect(retrieved.BitDepth).ToNot(BeNil()) - Expect(*retrieved.BitDepth).To(Equal(24)) - - _ = mr.Delete(ctx, newID) - }) - }) - - Describe("AlbumImage hydration", func() { - It("carries the parent album's artwork state onto each track", func() { - mfs := model.MediaFiles{{ID: "mf-1", AlbumID: "al-1"}} - infos := map[string]model.ItemArtworkInfo{ - "al-1": {ItemID: "al-1", Hash: "0123456789abcdef", BlurHash: "LEHV6nWB2yk8"}, - } - applyItemImage(infos, mfs[0].AlbumID, &mfs[0].AlbumImage) - - Expect(mfs[0].AlbumImage.ImageHash).To(Equal("0123456789abcdef")) - Expect(mfs[0].AlbumImage.BlurHash).To(Equal("LEHV6nWB2yk8")) - Expect(mfs[0].AlbumImage.ImageAbsent).To(BeFalse()) - }) - - It("keeps the album state independent of the track's own art", func() { - mf := model.MediaFile{ID: "mf-2", AlbumID: "al-2"} - mf.ImageHash = "aaaaaaaaaaaaaaaa" // the track's own art - applyItemImage( - map[string]model.ItemArtworkInfo{"al-2": {ItemID: "al-2", Hash: "bbbbbbbbbbbbbbbb"}}, - mf.AlbumID, &mf.AlbumImage, - ) - Expect(mf.ImageHash).To(Equal("aaaaaaaaaaaaaaaa")) - Expect(mf.AlbumImage.ImageHash).To(Equal("bbbbbbbbbbbbbbbb")) - }) - }) - - // Exists must apply the same library filter as Get/GetAll/CountAll. - Describe("Exists library visibility", func() { - It("hides a track the user has no library access to", func() { - restricted := model.User{ID: "restricted_mf_user", UserName: "rm", Name: "RM", Email: "rm@t.com"} - rctx := request.WithUser(GinkgoT().Context(), restricted) - - Expect(mr.Exists(ctx, songAntenna.ID)).To(BeTrue(), "admin sees it") - Expect(NewMediaFileRepository(GetDBXBuilder()).Exists(rctx, songAntenna.ID)).To(BeFalse()) - }) - }) - - Describe("MatchesCriteria", func() { - It("returns true when the track matches", func() { - c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Day"}}} - match, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) - Expect(err).ToNot(HaveOccurred()) - Expect(match).To(BeTrue()) - }) - It("returns false when the track does not match", func() { - c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "Nickelback"}}} - match, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) - Expect(err).ToNot(HaveOccurred()) - Expect(match).To(BeFalse()) - }) - It("treats missing annotations as their COALESCE default", func() { - // unrated track: rating coalesces to 0, so "rating < 4" matches - c := criteria.Criteria{Expression: criteria.All{criteria.Lt{"rating": 4}}} - match, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) - Expect(err).ToNot(HaveOccurred()) - Expect(match).To(BeTrue()) - }) - It("returns an error for an invalid field", func() { - c := criteria.Criteria{Expression: criteria.All{criteria.Is{"bogusfield": 1}}} - _, err := mr.MatchesCriteria(ctx, songDayInALife.ID, c) - Expect(err).To(HaveOccurred()) - }) - }) }) diff --git a/persistence/mediafolders_repository.go b/persistence/mediafolders_repository.go new file mode 100644 index 000000000..f858c5ad1 --- /dev/null +++ b/persistence/mediafolders_repository.go @@ -0,0 +1,37 @@ +package persistence + +import ( + "context" + + "github.com/beego/beego/v2/client/orm" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" +) + +type mediaFolderRepository struct { + ctx context.Context +} + +func NewMediaFolderRepository(ctx context.Context, o orm.QueryExecutor) model.MediaFolderRepository { + return &mediaFolderRepository{ctx} +} + +func (r *mediaFolderRepository) Get(id int32) (*model.MediaFolder, error) { + mediaFolder := hardCoded() + return &mediaFolder, nil +} + +func (*mediaFolderRepository) GetAll() (model.MediaFolders, error) { + mediaFolder := hardCoded() + result := make(model.MediaFolders, 1) + result[0] = mediaFolder + return result, nil +} + +func hardCoded() model.MediaFolder { + mediaFolder := model.MediaFolder{ID: 0, Path: conf.Server.MusicFolder} + mediaFolder.Name = "Music Library" + return mediaFolder +} + +var _ model.MediaFolderRepository = (*mediaFolderRepository)(nil) diff --git a/persistence/persistence.go b/persistence/persistence.go index 44e944bff..277cd9659 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -3,277 +3,181 @@ package persistence import ( "context" "database/sql" - "fmt" - "sync" - "time" + "reflect" + "github.com/beego/beego/v2/client/orm" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/run" - "github.com/pocketbase/dbx" ) type SQLStore struct { - db dbx.Builder - library func() model.LibraryRepository - folder func() model.FolderRepository - album func() model.AlbumRepository - artist func() model.ArtistRepository - mediaFile func() model.MediaFileRepository - genre func() model.GenreRepository - tag func() model.TagRepository - playlist func() model.PlaylistRepository - playQueue func() model.PlayQueueRepository - transcoding func() model.TranscodingRepository - player func() model.PlayerRepository - radio func() model.RadioRepository - share func() model.ShareRepository - property func() model.PropertyRepository - user func() model.UserRepository - userProps func() model.UserPropsRepository - scrobbleBuf func() model.ScrobbleBufferRepository - scrobble func() model.ScrobbleRepository - plugin func() model.PluginRepository - artwork func() model.ArtworkRepository - artworkQueue func() model.ArtworkQueueRepository + orm orm.QueryExecutor + db *sql.DB } -// Repositories are built on first use, so a transaction store only pays for the ones its block touches. -func newSQLStore(db dbx.Builder) *SQLStore { - return &SQLStore{ - db: db, - library: sync.OnceValue(func() model.LibraryRepository { return NewLibraryRepository(db) }), - folder: sync.OnceValue(func() model.FolderRepository { return newFolderRepository(db) }), - album: sync.OnceValue(func() model.AlbumRepository { return NewAlbumRepository(db) }), - artist: sync.OnceValue(func() model.ArtistRepository { return NewArtistRepository(db) }), - mediaFile: sync.OnceValue(func() model.MediaFileRepository { return NewMediaFileRepository(db) }), - genre: sync.OnceValue(func() model.GenreRepository { return NewGenreRepository(db) }), - tag: sync.OnceValue(func() model.TagRepository { return NewTagRepository(db) }), - playlist: sync.OnceValue(func() model.PlaylistRepository { return NewPlaylistRepository(db) }), - playQueue: sync.OnceValue(func() model.PlayQueueRepository { return NewPlayQueueRepository(db) }), - transcoding: sync.OnceValue(func() model.TranscodingRepository { return NewTranscodingRepository(db) }), - player: sync.OnceValue(func() model.PlayerRepository { return NewPlayerRepository(db) }), - radio: sync.OnceValue(func() model.RadioRepository { return NewRadioRepository(db) }), - share: sync.OnceValue(func() model.ShareRepository { return NewShareRepository(db) }), - property: sync.OnceValue(func() model.PropertyRepository { return NewPropertyRepository(db) }), - user: sync.OnceValue(func() model.UserRepository { return NewUserRepository(db) }), - userProps: sync.OnceValue(func() model.UserPropsRepository { return NewUserPropsRepository(db) }), - scrobbleBuf: sync.OnceValue(func() model.ScrobbleBufferRepository { return NewScrobbleBufferRepository(db) }), - scrobble: sync.OnceValue(func() model.ScrobbleRepository { return NewScrobbleRepository(db) }), - plugin: sync.OnceValue(func() model.PluginRepository { return NewPluginRepository(db) }), - artwork: sync.OnceValue(func() model.ArtworkRepository { return NewArtworkRepository(db) }), - artworkQueue: sync.OnceValue(func() model.ArtworkQueueRepository { return NewArtworkQueueRepository(db) }), +func New(db *sql.DB) model.DataStore { + return &SQLStore{db: db} +} + +func (s *SQLStore) Album(ctx context.Context) model.AlbumRepository { + return NewAlbumRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) Artist(ctx context.Context) model.ArtistRepository { + return NewArtistRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) MediaFile(ctx context.Context) model.MediaFileRepository { + return NewMediaFileRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) MediaFolder(ctx context.Context) model.MediaFolderRepository { + return NewMediaFolderRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) Genre(ctx context.Context) model.GenreRepository { + return NewGenreRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) PlayQueue(ctx context.Context) model.PlayQueueRepository { + return NewPlayQueueRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) Playlist(ctx context.Context) model.PlaylistRepository { + return NewPlaylistRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) Property(ctx context.Context) model.PropertyRepository { + return NewPropertyRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) Radio(ctx context.Context) model.RadioRepository { + return NewRadioRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) UserProps(ctx context.Context) model.UserPropsRepository { + return NewUserPropsRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) Share(ctx context.Context) model.ShareRepository { + return NewShareRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) User(ctx context.Context) model.UserRepository { + return NewUserRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository { + return NewTranscodingRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) Player(ctx context.Context) model.PlayerRepository { + return NewPlayerRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepository { + return NewScrobbleBufferRepository(ctx, s.getOrmer()) +} + +func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository { + switch m.(type) { + case model.User: + return s.User(ctx).(model.ResourceRepository) + case model.Transcoding: + return s.Transcoding(ctx).(model.ResourceRepository) + case model.Player: + return s.Player(ctx).(model.ResourceRepository) + case model.Artist: + return s.Artist(ctx).(model.ResourceRepository) + case model.Album: + return s.Album(ctx).(model.ResourceRepository) + case model.MediaFile: + return s.MediaFile(ctx).(model.ResourceRepository) + case model.Genre: + return s.Genre(ctx).(model.ResourceRepository) + case model.Playlist: + return s.Playlist(ctx).(model.ResourceRepository) + case model.Radio: + return s.Radio(ctx).(model.ResourceRepository) + case model.Share: + return s.Share(ctx).(model.ResourceRepository) } + log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name()) + return nil } -func New(conn *sql.DB) model.DataStore { - return newSQLStore(dbx.NewFromDB(conn, db.Driver)) -} - -func (s *SQLStore) Album() model.AlbumRepository { - return s.album() -} - -func (s *SQLStore) Artist() model.ArtistRepository { - return s.artist() -} - -func (s *SQLStore) MediaFile() model.MediaFileRepository { - return s.mediaFile() -} - -func (s *SQLStore) Library() model.LibraryRepository { - return s.library() -} - -func (s *SQLStore) Folder() model.FolderRepository { - return s.folder() -} - -func (s *SQLStore) Genre() model.GenreRepository { - return s.genre() -} - -func (s *SQLStore) Tag() model.TagRepository { - return s.tag() -} - -func (s *SQLStore) PlayQueue() model.PlayQueueRepository { - return s.playQueue() -} - -func (s *SQLStore) Playlist() model.PlaylistRepository { - return s.playlist() -} - -func (s *SQLStore) Property() model.PropertyRepository { - return s.property() -} - -func (s *SQLStore) Radio() model.RadioRepository { - return s.radio() -} - -func (s *SQLStore) UserProps() model.UserPropsRepository { - return s.userProps() -} - -func (s *SQLStore) Share() model.ShareRepository { - return s.share() -} - -func (s *SQLStore) User() model.UserRepository { - return s.user() -} - -func (s *SQLStore) Transcoding() model.TranscodingRepository { - return s.transcoding() -} - -func (s *SQLStore) Player() model.PlayerRepository { - return s.player() -} - -func (s *SQLStore) ScrobbleBuffer() model.ScrobbleBufferRepository { - return s.scrobbleBuf() -} - -func (s *SQLStore) Scrobble() model.ScrobbleRepository { - return s.scrobble() -} - -func (s *SQLStore) Plugin() model.PluginRepository { - return s.plugin() -} - -func (s *SQLStore) Artwork() model.ArtworkRepository { - return s.artwork() -} - -func (s *SQLStore) ArtworkQueue() model.ArtworkQueueRepository { - return s.artworkQueue() -} - -func scopeLabel(scope []string) string { - if len(scope) > 0 { - return scope[0] - } - return "" -} - -func (s *SQLStore) WithTx(block func(tx model.DataStore) error, scope ...string) error { - msg := scopeLabel(scope) - start := time.Now() - conn, inTx := s.db.(*dbx.DB) - if !inTx { - log.Trace("Nested Transaction started", "scope", msg) - conn = dbx.NewFromDB(db.Db(), db.Driver) - } else { - log.Trace("Transaction started", "scope", msg) - } - return conn.Transactional(func(tx *dbx.Tx) error { - newDb := newSQLStore(tx) - err := block(newDb) - if !inTx { - log.Trace("Nested Transaction finished", "scope", msg, "elapsed", time.Since(start), err) - } else { - log.Trace("Transaction finished", "scope", msg, "elapsed", time.Since(start), err) - } +func (s *SQLStore) WithTx(block func(tx model.DataStore) error) error { + o, err := orm.NewOrmWithDB(db.Driver, "default", s.db) + if err != nil { return err + } + return o.DoTx(func(ctx context.Context, txOrm orm.TxOrmer) error { + newDb := &SQLStore{orm: txOrm} + return block(newDb) }) } -func (s *SQLStore) WithTxImmediate(block func(tx model.DataStore) error, scope ...string) error { - ctx := context.Background() - return s.WithTx(func(tx model.DataStore) error { - // Workaround to force the transaction to be upgraded to immediate mode to avoid deadlocks - // See https://berthub.eu/articles/posts/a-brief-post-on-sqlite3-database-locked-despite-timeout/ - _ = tx.Property().Put(ctx, "tmp_lock_flag", "") - defer func() { - _ = tx.Property().Delete(ctx, "tmp_lock_flag") - }() - - return block(tx) - }, scope...) -} - -// txRetryDelay spaces out reruns of a busy transaction. Each attempt has already waited out the -// busy timeout, so WithTxRetry gives up only after a sustained lock. -var txRetryDelay = 5 * time.Second - -const txMaxRetries = 3 - -func (s *SQLStore) WithTxRetry(ctx context.Context, block func(ctx context.Context, tx model.DataStore) error, scope ...string) error { - // Inside a transaction, join it: the outer one holds the lock and owns commit and rollback - if _, ok := s.db.(*dbx.DB); !ok { - return block(ctx, s) - } - for attempt := 0; ; attempt++ { - attemptCtx := ctx - if attempt < txMaxRetries { - attemptCtx = withBusyRetry(ctx) - } - err := s.WithTx(func(tx model.DataStore) error { return block(attemptCtx, tx) }, scope...) - if attempt == txMaxRetries || !db.IsBusy(err) { - return err - } - log.Warn(ctx, "Database busy, retrying transaction", "scope", scopeLabel(scope), "attempt", attempt+1, err) - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(time.Duration(attempt+1) * txRetryDelay): - } - } -} - -type busyRetryKey struct{} - -// withBusyRetry marks a transaction attempt that WithTxRetry will rerun, so a busy statement in it -// is logged as a warning rather than an error. -func withBusyRetry(ctx context.Context) context.Context { - return context.WithValue(ctx, busyRetryKey{}, true) -} - -func hasBusyRetry(ctx context.Context) bool { - if ctx == nil { - return false - } - retry, _ := ctx.Value(busyRetryKey{}).(bool) - return retry -} - -func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error { - trace := func(ctx context.Context, msg string, f func() error) func() error { - return func() error { - start := time.Now() - err := f() - log.Debug(ctx, "GC: "+msg, "elapsed", time.Since(start), err) - return err - } - } - - // If libraryIDs are provided, scope operations to those libraries where possible - scoped := len(libraryIDs) > 0 - if scoped { - log.Debug(ctx, "GC: Running selective garbage collection", "libraryIDs", libraryIDs) - } - - err := run.Sequentially( - trace(ctx, "purge empty albums", func() error { return s.album().(*albumRepository).purgeEmpty(ctx, libraryIDs...) }), - trace(ctx, "purge empty artists", func() error { return s.artist().(*artistRepository).purgeEmpty(ctx) }), - trace(ctx, "mark missing artists", func() error { return s.artist().(*artistRepository).markMissing(ctx) }), - trace(ctx, "purge empty folders", func() error { return s.folder().(*folderRepository).purgeEmpty(ctx, libraryIDs...) }), - trace(ctx, "clean album annotations", func() error { return s.album().(*albumRepository).cleanAnnotations(ctx) }), - trace(ctx, "clean artist annotations", func() error { return s.artist().(*artistRepository).cleanAnnotations(ctx) }), - trace(ctx, "clean media file annotations", func() error { return s.mediaFile().(*mediaFileRepository).cleanAnnotations(ctx) }), - trace(ctx, "clean playlist annotations", func() error { return s.playlist().(*playlistRepository).cleanAnnotations(ctx) }), - trace(ctx, "clean media file bookmarks", func() error { return s.mediaFile().(*mediaFileRepository).cleanBookmarks(ctx) }), - trace(ctx, "purge non used tags", func() error { return s.tag().(*tagRepository).purgeUnused(ctx) }), - trace(ctx, "remove orphan playlist tracks", func() error { return s.playlist().(*playlistRepository).removeOrphans(ctx) }), - ) +func (s *SQLStore) GC(ctx context.Context, rootFolder string) error { + err := s.MediaFile(ctx).(*mediaFileRepository).deleteNotInPath(rootFolder) if err != nil { - return fmt.Errorf("tidying up database: %w", err) + log.Error(ctx, "Error removing dangling tracks", err) + return err } - return nil + err = s.MediaFile(ctx).(*mediaFileRepository).removeNonAlbumArtistIds() + if err != nil { + log.Error(ctx, "Error removing non-album artist_ids", err) + return err + } + err = s.Album(ctx).(*albumRepository).purgeEmpty() + if err != nil { + log.Error(ctx, "Error removing empty albums", err) + return err + } + err = s.Artist(ctx).(*artistRepository).purgeEmpty() + if err != nil { + log.Error(ctx, "Error removing empty artists", err) + return err + } + err = s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations() + if err != nil { + log.Error(ctx, "Error removing orphan mediafile annotations", err) + return err + } + err = s.Album(ctx).(*albumRepository).cleanAnnotations() + if err != nil { + log.Error(ctx, "Error removing orphan album annotations", err) + return err + } + err = s.Artist(ctx).(*artistRepository).cleanAnnotations() + if err != nil { + log.Error(ctx, "Error removing orphan artist annotations", err) + return err + } + err = s.MediaFile(ctx).(*mediaFileRepository).cleanBookmarks() + if err != nil { + log.Error(ctx, "Error removing orphan bookmarks", err) + return err + } + err = s.Playlist(ctx).(*playlistRepository).removeOrphans() + if err != nil { + log.Error(ctx, "Error tidying up playlists", err) + } + err = s.Genre(ctx).(*genreRepository).purgeEmpty() + if err != nil { + log.Error(ctx, "Error removing unused genres", err) + return err + } + return err +} + +func (s *SQLStore) getOrmer() orm.QueryExecutor { + if s.orm == nil { + o, err := orm.NewOrmWithDB(db.Driver, "default", s.db) + if err != nil { + log.Error("Error obtaining new orm instance", err) + } + return o + } + return s.orm } diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index ee2794454..e0f033880 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -4,9 +4,8 @@ import ( "context" "path/filepath" "testing" - "time" - "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" _ "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/db" @@ -16,7 +15,6 @@ import ( "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" ) func TestPersistence(t *testing.T) { @@ -24,118 +22,50 @@ func TestPersistence(t *testing.T) { //os.Remove("./test-123.db") //conf.Server.DbPath = "./test-123.db" - conf.Server.DbPath = "file::memory:?cache=shared&_foreign_keys=on" - defer db.Init(context.Background())() - log.SetLevel(log.LevelFatal) + conf.Server.DbPath = "file::memory:?cache=shared" + _ = orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath) + db.Init() + log.SetLevel(log.LevelError) RegisterFailHandler(Fail) RunSpecs(t, "Persistence Suite") } -func mf(mf model.MediaFile) model.MediaFile { - mf.Tags = model.Tags{} - mf.LibraryID = 1 - mf.LibraryPath = "music" // Default folder - mf.LibraryName = "Music Library" - mf.Participants = model.Participants{ - model.RoleArtist: model.ParticipantList{ - model.Participant{Artist: model.Artist{ID: mf.ArtistID, Name: mf.Artist}}, - }, - } - if mf.Lyrics == "" { - mf.Lyrics = "[]" - } - return mf -} - -func al(al model.Album) model.Album { - al.LibraryID = 1 - al.LibraryPath = "music" - al.LibraryName = "Music Library" - al.Discs = model.Discs{} - al.Tags = model.Tags{} - al.Participants = model.Participants{} - return al -} - -func alWithTags(a model.Album, tags model.Tags) model.Album { - a = al(a) - a.Tags = tags - return a -} +var ( + genreElectronic = model.Genre{ID: "gn-1", Name: "Electronic"} + genreRock = model.Genre{ID: "gn-2", Name: "Rock"} + testGenres = model.Genres{genreElectronic, genreRock} +) var ( - artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"} - artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"} - artistCJK = model.Artist{ID: "4", Name: "シートベルツ", SortArtistName: "Seatbelts", OrderArtistName: "seatbelts"} - artistPunctuation = model.Artist{ID: "5", Name: "The Roots", OrderArtistName: "roots"} - testArtists = model.Artists{ + artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", AlbumCount: 1, FullText: " kraftwerk"} + artistBeatles = model.Artist{ID: "3", Name: "The Beatles", AlbumCount: 2, FullText: " beatles the"} + testArtists = model.Artists{ artistKraftwerk, artistBeatles, - artistCJK, - artistPunctuation, } ) 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}, - model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}) - albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("roots/things/track1.mp3"), SongCount: 1}) - testAlbums = model.Albums{ + albumSgtPeppers = model.Album{ID: "101", Name: "Sgt Peppers", Artist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, EmbedArtPath: P("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967, FullText: " beatles peppers sgt the"} + albumAbbeyRoad = model.Album{ID: "102", Name: "Abbey Road", Artist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", Genre: "Rock", Genres: model.Genres{genreRock}, EmbedArtPath: P("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969, FullText: " abbey beatles road the"} + albumRadioactivity = model.Album{ID: "103", Name: "Radioactivity", Artist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", Genre: "Electronic", Genres: model.Genres{genreElectronic, genreRock}, EmbedArtPath: P("/kraft/radio/radio.mp3"), SongCount: 2, FullText: " kraftwerk radioactivity"} + testAlbums = model.Albums{ albumSgtPeppers, albumAbbeyRoad, albumRadioactivity, - albumMultiDisc, - albumCJK, - albumWithVersion, - albumPunctuation, } ) 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")}) - songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", - AlbumID: "103", - Path: p("kraft/radio/antenna.mp3"), - RGAlbumGain: new(1.0), RGAlbumPeak: new(2.0), RGTrackGain: new(3.0), RGTrackPeak: new(4.0), - }) - songAntennaWithLyrics = mf(model.MediaFile{ - ID: "1005", - Title: "Antenna", - ArtistID: "2", - Artist: "Kraftwerk", - AlbumID: "103", - Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`, - }) - 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")}) - testSongs = model.MediaFiles{ + songDayInALife = model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Genre: "Rock", Genres: model.Genres{genreRock}, Path: P("/beatles/1/sgt/a day.mp3"), FullText: " a beatles day in life peppers sgt the"} + songComeTogether = model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Genre: "Rock", Genres: model.Genres{genreRock}, Path: P("/beatles/1/come together.mp3"), FullText: " abbey beatles come road the together"} + songRadioactivity = model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Genre: "Electronic", Genres: model.Genres{genreElectronic}, Path: P("/kraft/radio/radio.mp3"), FullText: " kraftwerk radioactivity"} + songAntenna = model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Genre: "Electronic", Genres: model.Genres{genreElectronic, genreRock}, Path: P("/kraft/radio/antenna.mp3"), FullText: " antenna kraftwerk"} + testSongs = model.MediaFiles{ songDayInALife, songComeTogether, songRadioactivity, songAntenna, - songAntennaWithLyrics, - songAntenna2, - songDisc2Track11, - songDisc1Track01, - songDisc2Track01, - songDisc1Track02, - songCJK, - songVersioned, - songPunctuation, } ) @@ -151,124 +81,64 @@ var ( testPlaylists []*model.Playlist ) -var ( - adminUser = model.User{ID: "userid", UserName: "userid", Name: "admin", Email: "admin@email.com", IsAdmin: true} - regularUser = model.User{ID: "2222", UserName: "regular-user", Name: "Regular User", Email: "regular@example.com"} - thirdUser = model.User{ID: "3333", UserName: "third-user", Name: "Third User", Email: "third@example.com"} - testUsers = model.Users{adminUser, regularUser, thirdUser} -) - -var ( - firstScrobble = model.Scrobble{ID: 1, MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Unix()} - secondScrobble = model.Scrobble{ID: 2, MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC).Unix()} - thirdScrobble = model.Scrobble{ID: 3, MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC).Unix()} - scrobbles = model.Scrobbles{firstScrobble, secondScrobble, thirdScrobble} -) - -func p(path string) string { +func P(path string) string { return filepath.FromSlash(path) } -// restrictedFixture creates a second library plus a non-admin user granted library 1 only, so -// specs can assert that a query filters by library. Cleans itself up after the spec. -func restrictedFixture(name string) (context.Context, model.Library, model.User) { - adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - db := GetDBXBuilder() - - lib := model.Library{Name: name + " Library", Path: "/" + name} - lr := NewLibraryRepository(db) - Expect(lr.Put(adminCtx, &lib)).To(Succeed()) - - user := createUserWithLibraries(name+"-restricted", []int{1}) - ur := NewUserRepository(db) - Expect(ur.Put(adminCtx, &user)).To(Succeed()) - Expect(ur.SetUserLibraries(adminCtx, user.ID, []int{1})).To(Succeed()) - - DeferCleanup(func() { - _ = NewUserRepository(db).Delete(adminCtx, user.ID) - _ = NewLibraryRepository(db).(*libraryRepository).delete(adminCtx, squirrel.Eq{"id": lib.ID}) - }) - return adminCtx, lib, user -} - +// Initialize test DB +// TODO Load this data setup from file(s) var _ = BeforeSuite(func() { - conn := GetDBXBuilder() + o := orm.NewOrm() ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, adminUser) + user := model.User{ID: "userid", UserName: "userid", IsAdmin: true} + ctx = request.WithUser(ctx, user) - ur := NewUserRepository(conn) - for i := range testUsers { - err := ur.Put(ctx, &testUsers[i]) + ur := NewUserRepository(ctx, o) + err := ur.Put(&user) + if err != nil { + panic(err) + } + + gr := NewGenreRepository(ctx, o) + for i := range testGenres { + g := testGenres[i] + err := gr.Put(&g) if err != nil { panic(err) } } - // Associate users with library 1 (default test library) - for i := range testUsers { - err := ur.SetUserLibraries(ctx, testUsers[i].ID, []int{1}) + mr := NewMediaFileRepository(ctx, o) + for i := range testSongs { + s := testSongs[i] + err := mr.Put(&s) if err != nil { panic(err) } } - alr := NewAlbumRepository(conn).(*albumRepository) - for i := range testAlbums { - err := alr.Put(ctx, new(testAlbums[i])) - if err != nil { - panic(err) - } - } - - arr := NewArtistRepository(conn) - for i := range testArtists { - err := arr.Put(ctx, new(testArtists[i])) - if err != nil { - panic(err) - } - } - - // Associate artists with library 1 (default test library) - lr := NewLibraryRepository(conn) - for i := range testArtists { - err := lr.AddArtist(ctx, 1, testArtists[i].ID) - if err != nil { - panic(err) - } - } - - // Populate album_artists based on the AlbumArtistID relationships in testAlbums - artistIDs := map[string]bool{} - for _, a := range testArtists { - artistIDs[a.ID] = true - } + alr := NewAlbumRepository(ctx, o).(*albumRepository) for i := range testAlbums { a := testAlbums[i] - if a.AlbumArtistID == "" || !artistIDs[a.AlbumArtistID] { - continue - } - _, err := alr.executeSQL(ctx, squirrel.Insert("album_artists").SetMap(map[string]any{ - "album_id": a.ID, - "artist_id": a.AlbumArtistID, - "role": "artist", - "sub_role": "", - })) + err := alr.Put(&a) if err != nil { panic(err) } } - mr := NewMediaFileRepository(conn) - for i := range testSongs { - err := mr.Put(ctx, &testSongs[i]) + arr := NewArtistRepository(ctx, o) + for i := range testArtists { + a := testArtists[i] + err := arr.Put(&a) if err != nil { panic(err) } } - rar := NewRadioRepository(conn) + rar := NewRadioRepository(ctx, o) for i := range testRadios { - err := rar.Put(ctx, new(testRadios[i])) + r := testRadios[i] + err := rar.Put(&r) if err != nil { panic(err) } @@ -282,85 +152,41 @@ var _ = BeforeSuite(func() { Public: true, SongCount: 2, } - plsBest.AddMediaFilesByID([]string{"1001", "1003"}) + plsBest.AddTracks([]string{"1001", "1003"}) plsCool = model.Playlist{Name: "Cool", OwnerID: "userid", OwnerName: "userid"} - plsCool.AddMediaFilesByID([]string{"1004"}) + plsCool.AddTracks([]string{"1004"}) testPlaylists = []*model.Playlist{&plsBest, &plsCool} - pr := NewPlaylistRepository(conn) + pr := NewPlaylistRepository(ctx, o) for i := range testPlaylists { - err := pr.Put(ctx, testPlaylists[i]) + err := pr.Put(testPlaylists[i]) if err != nil { panic(err) } } // Prepare annotations - if err := arr.SetStar(ctx, true, artistBeatles.ID); err != nil { + if err := arr.SetStar(true, artistBeatles.ID); err != nil { panic(err) } - ar, err := arr.Get(ctx, artistBeatles.ID) - if err != nil { - panic(err) - } - if ar == nil { - panic("artist not found after SetStar") - } + ar, _ := arr.Get(artistBeatles.ID) artistBeatles.Starred = true artistBeatles.StarredAt = ar.StarredAt testArtists[1] = artistBeatles - if err := alr.SetStar(ctx, true, albumRadioactivity.ID); err != nil { + if err := alr.SetStar(true, albumRadioactivity.ID); err != nil { panic(err) } - al, err := alr.Get(ctx, albumRadioactivity.ID) - if err != nil { - panic(err) - } - if al == nil { - panic("album not found after SetStar") - } + al, _ := alr.Get(albumRadioactivity.ID) albumRadioactivity.Starred = true albumRadioactivity.StarredAt = al.StarredAt testAlbums[2] = albumRadioactivity - if err := mr.SetStar(ctx, true, songComeTogether.ID); err != nil { - panic(err) - } - mf, err := mr.Get(ctx, songComeTogether.ID) - if err != nil { + if err := mr.SetStar(true, songComeTogether.ID); err != nil { panic(err) } + mf, _ := mr.Get(songComeTogether.ID) songComeTogether.Starred = true songComeTogether.StarredAt = mf.StarredAt testSongs[1] = songComeTogether - - scrobbleRepo := NewScrobbleRepository(conn).(*scrobbleRepository) - for _, s := range scrobbles { - _, err := scrobbleRepo.executeSQL(ctx, squirrel.Insert("scrobbles").SetMap(map[string]any{ - "media_file_id": s.MediaFileID, - "user_id": s.UserID, - "submission_time": s.SubmissionTime, - })) - if err != nil { - panic(err) - } - } }) - -func GetDBXBuilder() *dbx.DB { - return dbx.NewFromDB(db.Db(), db.Dialect) -} - -// collectCursor takes the cursor's underlying func type so the named cursor types -// (model.AlbumCursor, ...) infer T. -func collectCursor[T any](cursor func(func(T, error) bool), err error) []T { - GinkgoHelper() - Expect(err).ToNot(HaveOccurred()) - var out []T - for item, err := range cursor { - Expect(err).ToNot(HaveOccurred()) - out = append(out, item) - } - return out -} diff --git a/persistence/persistence_test.go b/persistence/persistence_test.go index 43d2e81ed..ec9ea3408 100644 --- a/persistence/persistence_test.go +++ b/persistence/persistence_test.go @@ -2,11 +2,9 @@ package persistence import ( "context" - "errors" - "time" - "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -18,114 +16,48 @@ var _ = Describe("SQLStore", func() { BeforeEach(func() { ds = New(db.Db()) ctx = context.Background() + log.SetLevel(log.LevelFatal) + }) + AfterEach(func() { + log.SetLevel(log.LevelError) }) Describe("WithTx", func() { Context("When block returns nil", func() { It("commits changes to the DB", func() { err := ds.WithTx(func(tx model.DataStore) error { - pl := tx.Player() - err := pl.Put(ctx, &model.Player{ID: "666", UserId: "userid"}) + pl := tx.Player(ctx) + err := pl.Put(&model.Player{ID: "666", UserName: "userid"}) Expect(err).ToNot(HaveOccurred()) - pr := tx.Property() - err = pr.Put(ctx, "777", "value") + pr := tx.Property(ctx) + err = pr.Put("777", "value") Expect(err).ToNot(HaveOccurred()) return nil }) Expect(err).ToNot(HaveOccurred()) - Expect(ds.Player().Get(ctx, "666")).To(Equal(&model.Player{ID: "666", UserId: "userid", Username: "userid"})) - Expect(ds.Property().Get(ctx, "777")).To(Equal("value")) + Expect(ds.Player(ctx).Get("666")).To(Equal(&model.Player{ID: "666", UserName: "userid"})) + Expect(ds.Property(ctx).Get("777")).To(Equal("value")) }) }) Context("When block returns an error", func() { It("rollbacks changes to the DB", func() { err := ds.WithTx(func(tx model.DataStore) error { - pr := tx.Property() - err := pr.Put(ctx, "999", "value") + pr := tx.Property(ctx) + err := pr.Put("999", "value") Expect(err).ToNot(HaveOccurred()) // Will fail as it is missing the UserName - pl := tx.Player() - err = pl.Put(ctx, &model.Player{ID: "888"}) + pl := tx.Player(ctx) + err = pl.Put(&model.Player{ID: "888"}) Expect(err).To(HaveOccurred()) return err }) Expect(err).To(HaveOccurred()) - _, err = ds.Property().Get(ctx, "999") + _, err = ds.Property(ctx).Get("999") Expect(err).To(MatchError(model.ErrNotFound)) - _, err = ds.Player().Get(ctx, "888") + _, err = ds.Player(ctx).Get("888") Expect(err).To(MatchError(model.ErrNotFound)) }) }) }) - - Describe("WithTxRetry", func() { - busy := sqlite3.Error{Code: sqlite3.ErrBusy} - BeforeEach(func() { - DeferCleanup(func(d time.Duration) { txRetryDelay = d }, txRetryDelay) - txRetryDelay = 0 - }) - - It("reruns a busy transaction from a clean rollback", func() { - var attempts []bool - err := ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - attempts = append(attempts, hasBusyRetry(ctx)) - Expect(tx.Property().Put(ctx, "retry-key", "attempt")).To(Succeed()) - if len(attempts) < 3 { - return busy - } - return nil - }) - Expect(err).ToNot(HaveOccurred()) - Expect(attempts).To(Equal([]bool{true, true, true})) - Expect(ds.Property().Get(ctx, "retry-key")).To(Equal("attempt")) - }) - - It("gives up after the last retry, which is not marked as retried", func() { - var attempts []bool - err := ds.WithTxRetry(ctx, func(ctx context.Context, _ model.DataStore) error { - attempts = append(attempts, hasBusyRetry(ctx)) - return busy - }) - Expect(db.IsBusy(err)).To(BeTrue()) - Expect(attempts).To(Equal([]bool{true, true, true, false})) - }) - - It("does not rerun on other errors", func() { - calls := 0 - err := ds.WithTxRetry(ctx, func(context.Context, model.DataStore) error { - calls++ - return sqlite3.Error{Code: sqlite3.ErrConstraint} - }) - Expect(err).To(HaveOccurred()) - Expect(calls).To(Equal(1)) - }) - - It("does not rerun when called inside a transaction", func() { - calls := 0 - err := ds.WithTx(func(tx model.DataStore) error { - return tx.WithTxRetry(ctx, func(context.Context, model.DataStore) error { - calls++ - return busy - }) - }) - Expect(db.IsBusy(err)).To(BeTrue()) - Expect(calls).To(Equal(1)) - }) - - It("joins the enclosing transaction instead of opening another", func() { - rollback := errors.New("rollback") - err := ds.WithTx(func(tx model.DataStore) error { - Expect(tx.Property().Put(ctx, "outer-key", "v")).To(Succeed()) - Expect(tx.WithTxRetry(ctx, func(ctx context.Context, inner model.DataStore) error { - Expect(inner.Property().Get(ctx, "outer-key")).To(Equal("v")) - return inner.Property().Put(ctx, "inner-key", "v") - })).To(Succeed()) - return rollback - }) - Expect(err).To(MatchError(rollback)) - _, err = ds.Property().Get(ctx, "inner-key") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) }) diff --git a/persistence/player_repository.go b/persistence/player_repository.go index ba5d26794..7e7eff4c2 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -2,230 +2,135 @@ package persistence import ( "context" - "crypto/sha256" - "encoding/hex" - "regexp" - "strings" + "errors" . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "github.com/deluan/rest" - "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/pocketbase/dbx" ) type playerRepository struct { sqlRepository + sqlRestful } -func NewPlayerRepository(db dbx.Builder) model.PlayerRepository { +func NewPlayerRepository(ctx context.Context, o orm.QueryExecutor) model.PlayerRepository { r := &playerRepository{} - r.db = db - r.registerModel(&model.Player{}, map[string]filterFunc{ - "name": containsFilter("player.name"), - "hasapikey": hasAPIKeyFilter, - }) - r.setSortMappings(map[string]string{ - "user_name": "username", //TODO rename all user_name and userName to username - }) + r.ctx = ctx + r.ormer = o + r.tableName = "player" + r.filterMappings = map[string]filterFunc{ + "name": containsFilter, + } return r } -func hasAPIKeyFilter(_ string, value any) Sqlizer { - if v, _ := value.(string); strings.EqualFold(v, "true") { - return NotEq{"player.api_key_hash": nil} - } - return Eq{"player.api_key_hash": nil} -} - -func (r *playerRepository) Put(ctx context.Context, p *model.Player) error { - _, err := r.put(ctx, p.ID, p) +func (r *playerRepository) Put(p *model.Player) error { + _, err := r.put(p.ID, p) return err } -func (r *playerRepository) selectPlayer(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - return r.newSelect(ctx, options...). - Columns("player.*", "player.api_key_hash is not null as has_api_key"). - Join("user ON player.user_id = user.id"). - Columns("user.user_name username") -} - -func (r *playerRepository) Get(ctx context.Context, id string) (*model.Player, error) { - sel := r.selectPlayer(ctx).Where(Eq{"player.id": id}) +func (r *playerRepository) Get(id string) (*model.Player, error) { + sel := r.newSelect().Columns("*").Where(Eq{"id": id}) var res model.Player - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) return &res, err } -func (r *playerRepository) FindMatch(ctx context.Context, userId, client, userAgent string) (*model.Player, error) { - sel := r.selectPlayer(ctx).Where(And{ +func (r *playerRepository) FindMatch(userName, client, userAgent string) (*model.Player, error) { + sel := r.newSelect().Columns("*").Where(And{ Eq{"client": client}, Eq{"user_agent": userAgent}, - Eq{"user_id": userId}, + Eq{"user_name": userName}, }) var res model.Player - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) return &res, err } -func (r *playerRepository) newRestSelect(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - s := r.selectPlayer(ctx, options...) - return s.Where(r.addRestriction(ctx)) +func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder { + s := r.newSelect(options...) + return s.Where(r.addRestriction()) } -func (r *playerRepository) CountByClient(ctx context.Context, options ...model.QueryOptions) (map[string]int64, error) { - sel := r.newSelect(ctx, options...). - Columns( - "case when client = 'NavidromeUI' then name else client end as player", - "count(*) as count", - ).GroupBy("client") - var res []struct { - Player string - Count int64 +func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer { + s := And{} + if len(sql) > 0 { + s = append(s, sql[0]) } - err := r.queryAll(ctx, sel, &res) - if err != nil { - return nil, err + u := loggedUser(r.ctx) + if u.IsAdmin { + return s } - counts := make(map[string]int64, len(res)) - for _, c := range res { - counts[c.Player] = c.Count - } - return counts, nil + return append(s, Eq{"user_name": u.UserName}) } -func (r *playerRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - return r.count(ctx, r.newRestSelect(ctx), options...) +func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.count(r.newRestSelect(), r.parseRestOptions(options...)) } -func (r *playerRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) -} - -func (r *playerRepository) Read(ctx context.Context, id string) (*model.Player, error) { - sel := r.newRestSelect(ctx).Where(Eq{"player.id": id}) +func (r *playerRepository) Read(id string) (interface{}, error) { + sel := r.newRestSelect().Columns("*").Where(Eq{"id": id}) var res model.Player - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) return &res, err } -func (r *playerRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Player, error) { - sel := r.newRestSelect(ctx, r.parseRestOptions(ctx, options...)) +func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + sel := r.newRestSelect(r.parseRestOptions(options...)).Columns("*") res := model.Players{} - err := r.queryAll(ctx, sel, &res) + err := r.queryAll(sel, &res) return res, err } -var apiKeyFormat = regexp.MustCompile(`^` + consts.APIKeyPrefix + `[0-9A-Za-z]{22}$`) - -func apiKeyValidationError(msg string) error { - return &rest.ValidationError{Errors: map[string]string{"apiKey": msg}} +func (r *playerRepository) EntityName() string { + return "player" } -func validateAPIKey(key string) error { - if !apiKeyFormat.MatchString(key) { - return apiKeyValidationError("resources.player.validation.apiKeyFormat") - } - return nil +func (r *playerRepository) NewInstance() interface{} { + return &model.Player{} } -func (r *playerRepository) Save(ctx context.Context, t *model.Player) (string, error) { - u := loggedUser(ctx) - if t.UserId == "" && u.ID != invalidUserId { - t.UserId = u.ID - } - if t.UserId != u.ID { +func (r *playerRepository) isPermitted(p *model.Player) bool { + u := loggedUser(r.ctx) + return u.IsAdmin || p.UserName == u.UserName +} + +func (r *playerRepository) Save(entity interface{}) (string, error) { + t := entity.(*model.Player) + if !r.isPermitted(t) { return "", rest.ErrPermissionDenied } - // Hand-made players are only reachable through a key, so one is required - if t.APIKey == nil || *t.APIKey == "" { - return "", apiKeyValidationError("ra.validation.required") + id, err := r.put(t.ID, t) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound } - if err := validateAPIKey(*t.APIKey); err != nil { - return "", err - } - values, err := toSQLArgs(t) - if err != nil { - return "", err - } - // Save only creates, so the key hash goes in the same INSERT and the unique index settles races - values["id"] = id.NewRandom() - values["api_key_hash"] = hashAPIKey(*t.APIKey) - _, err = r.executeSQL(ctx, Insert(r.tableName).SetMap(values)) - if isUniqueViolation(err) { - return "", apiKeyValidationError("ra.validation.unique") - } - if err != nil { - return "", err - } - return values["id"].(string), nil + return id, err } -func (r *playerRepository) Update(ctx context.Context, id string, entity model.Player, cols ...string) error { - t := &entity +func (r *playerRepository) Update(id string, entity interface{}, cols ...string) error { + t := entity.(*model.Player) t.ID = id - if t.APIKey == nil { - return r.updateOwned(ctx, id, t, cols...) + if !r.isPermitted(t) { + return rest.ErrPermissionDenied } - // The key and the other columns are two writes; commit both or neither - return r.inTx(func(tx *playerRepository) error { - if err := tx.SetAPIKey(ctx, id, *t.APIKey); err != nil { - return err - } - return tx.updateOwned(ctx, id, t, cols...) - }) -} - -func (r *playerRepository) inTx(block func(tx *playerRepository) error) error { - conn, ok := r.db.(*dbx.DB) - if !ok { - return block(r) // already inside a transaction - } - return conn.Transactional(func(tx *dbx.Tx) error { - return block(NewPlayerRepository(tx).(*playerRepository)) - }) -} - -func (r *playerRepository) Delete(ctx context.Context, ids ...string) error { - return r.deleteOwnedAll(ctx, ids...) -} - -// Keys are long random strings, not user-chosen passwords, so a fast unsalted hash is enough and keeps lookups indexed. -func hashAPIKey(key string) string { - sum := sha256.Sum256([]byte(key)) - return hex.EncodeToString(sum[:]) -} - -func (r *playerRepository) FindByAPIKey(ctx context.Context, key string) (*model.Player, error) { - sel := r.selectPlayer(ctx).Where(Eq{"player.api_key_hash": hashAPIKey(key)}) - var res model.Player - if err := r.queryOne(ctx, sel, &res); err != nil { - return nil, err - } - return &res, nil -} - -// SetAPIKey stores the key's hash, or revokes it when key is empty. Setting is owner-only, even for -// admins, so nobody can mint a login for someone else. -func (r *playerRepository) SetAPIKey(ctx context.Context, playerID, key string) error { - if key == "" { - return r.updateOwnedRow(ctx, playerID, ownerOrAdmin, map[string]any{"api_key_hash": nil}) - } - if err := validateAPIKey(key); err != nil { - return err - } - err := r.updateOwnedRow(ctx, playerID, ownerOnly, map[string]any{"api_key_hash": hashAPIKey(key)}) - if isUniqueViolation(err) { - return apiKeyValidationError("ra.validation.unique") + _, err := r.put(id, t, cols...) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound } return err } -func isUniqueViolation(err error) bool { - return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") +func (r *playerRepository) Delete(id string) error { + filter := r.addRestriction(And{Eq{"id": id}}) + err := r.delete(filter) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err } var _ model.PlayerRepository = (*playerRepository)(nil) -var _ rest.Repository[model.Player] = (*playerRepository)(nil) -var _ rest.Persistable[model.Player] = (*playerRepository)(nil) +var _ rest.Repository = (*playerRepository)(nil) +var _ rest.Persistable = (*playerRepository)(nil) diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go deleted file mode 100644 index 69afa9556..000000000 --- a/persistence/player_repository_test.go +++ /dev/null @@ -1,621 +0,0 @@ -package persistence - -import ( - "context" - "errors" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" -) - -const testAPIKey = "nds_0123456789abcdefghijkl" - -func expectAPIKeyError(err error, msg string) { - var verr *rest.ValidationError - ExpectWithOffset(1, errors.As(err, &verr)).To(BeTrue()) - ExpectWithOffset(1, verr.Errors).To(HaveKeyWithValue("apiKey", msg)) -} - -var _ = Describe("PlayerRepository", func() { - var adminRepo *playerRepository - var database *dbx.DB - var ctx context.Context - - var ( - adminPlayer1 = model.Player{ID: "1", Name: "NavidromeUI [Firefox/Linux]", UserAgent: "Firefox/Linux", UserId: adminUser.ID, Username: adminUser.UserName, Client: "NavidromeUI", IP: "127.0.0.1", ReportRealPath: true, ScrobbleEnabled: true} - adminPlayer2 = model.Player{ID: "2", Name: "GenericClient [Chrome/Windows]", IP: "192.168.0.5", UserAgent: "Chrome/Windows", UserId: adminUser.ID, Username: adminUser.UserName, Client: "GenericClient", MaxBitRate: 128} - regularPlayer = model.Player{ID: "3", Name: "NavidromeUI [Safari/macOS]", UserAgent: "Safari/macOS", UserId: regularUser.ID, Username: regularUser.UserName, Client: "NavidromeUI", ReportRealPath: true, ScrobbleEnabled: false} - - players = model.Players{adminPlayer1, adminPlayer2, regularPlayer} - ) - - BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - - database = GetDBXBuilder() - adminRepo = NewPlayerRepository(database).(*playerRepository) - - for idx := range players { - err := adminRepo.Put(ctx, &players[idx]) - Expect(err).To(BeNil()) - } - }) - - AfterEach(func() { - players, err := adminRepo.ReadAll(ctx) - Expect(err).To(BeNil()) - for i := range players { - err = adminRepo.Delete(ctx, players[i].ID) - Expect(err).To(BeNil()) - } - }) - - Describe("FindMatch", func() { - It("finds existing match", func() { - player, err := adminRepo.FindMatch(ctx, adminUser.ID, "NavidromeUI", "Firefox/Linux") - Expect(err).To(BeNil()) - Expect(*player).To(Equal(adminPlayer1)) - }) - - It("doesn't find bad match", func() { - _, err := adminRepo.FindMatch(ctx, regularUser.ID, "NavidromeUI", "Firefox/Linux") - Expect(err).To(Equal(model.ErrNotFound)) - }) - }) - - Describe("Get", func() { - It("Gets an existing item from user", func() { - player, err := adminRepo.Get(ctx, adminPlayer1.ID) - Expect(err).To(BeNil()) - Expect(*player).To(Equal(adminPlayer1)) - }) - - It("Gets an existing item from another user", func() { - player, err := adminRepo.Get(ctx, regularPlayer.ID) - Expect(err).To(BeNil()) - Expect(*player).To(Equal(regularPlayer)) - }) - - It("does not get nonexistent item", func() { - _, err := adminRepo.Get(ctx, "i don't exist") - Expect(err).To(Equal(model.ErrNotFound)) - }) - }) - - DescribeTableSubtree("per context", func(admin bool, players model.Players, userPlayer model.Player, otherPlayer model.Player) { - var repo *playerRepository - var repoCtx context.Context - - BeforeEach(func() { - repoCtx = ctx - if admin { - repo = adminRepo - } else { - repoCtx = request.WithUser(ctx, regularUser) - repo = NewPlayerRepository(database).(*playerRepository) - } - }) - - baseCount := int64(len(players)) - - Describe("Count", func() { - It("should return all", func() { - count, err := repo.Count(repoCtx) - Expect(err).To(BeNil()) - Expect(count).To(Equal(baseCount)) - }) - }) - - Describe("Delete", func() { - It("deletes a player owned by the current user", func() { - err := repo.Delete(repoCtx, userPlayer.ID) - Expect(err).To(BeNil()) - - count, err := repo.Count(repoCtx) - Expect(err).To(BeNil()) - Expect(count).To(Equal(baseCount - 1)) - - _, err = repo.Get(repoCtx, userPlayer.ID) - Expect(err).To(Equal(model.ErrNotFound)) - }) - - It("does not delete another user's player when not admin", func() { - err := repo.Delete(repoCtx, otherPlayer.ID) - - if admin { - // Admins may delete any player. - Expect(err).To(BeNil()) - Expect(repo.Count(repoCtx)).To(Equal(baseCount - 1)) - _, err = repo.Get(repoCtx, otherPlayer.ID) - Expect(err).To(Equal(model.ErrNotFound)) - } else { - // The ownership-restricted delete matches no owned row, so it reports - // permission-denied and leaves the other user's player untouched. - Expect(err).To(Equal(rest.ErrPermissionDenied)) - Expect(repo.Count(repoCtx)).To(Equal(baseCount)) - item, err := repo.Get(repoCtx, otherPlayer.ID) - Expect(err).To(BeNil()) - Expect(*item).To(Equal(otherPlayer)) - } - }) - - It("returns not-found for a nonexistent player", func() { - err := repo.Delete(repoCtx, "i don't exist") - Expect(err).To(Equal(rest.ErrNotFound)) - Expect(repo.Count(repoCtx)).To(Equal(baseCount)) - }) - }) - - Describe("Read", func() { - It("can read from current user", func() { - player, err := repo.Read(repoCtx, userPlayer.ID) - Expect(err).To(BeNil()) - Expect(player).To(Equal(&userPlayer)) - }) - - It("can read from other user or fail if not admin", func() { - player, err := repo.Read(repoCtx, otherPlayer.ID) - if admin { - Expect(err).To(BeNil()) - Expect(player).To(Equal(&otherPlayer)) - } else { - Expect(err).To(Equal(model.ErrNotFound)) - } - }) - - It("does not get nonexistent item", func() { - _, err := repo.Read(repoCtx, "i don't exist") - Expect(err).To(Equal(model.ErrNotFound)) - }) - }) - - Describe("ReadAll", func() { - It("should get all items", func() { - data, err := repo.ReadAll(repoCtx) - Expect(err).To(BeNil()) - Expect(model.Players(data)).To(Equal(players)) - }) - }) - - Describe("Save", func() { - DescribeTable("item type", func(player model.Player) { - clone := player - clone.ID = "" - clone.IP = "192.168.1.1" - clone.APIKey = new(testAPIKey) - id, err := repo.Save(repoCtx, &clone) - - if clone.UserId == "" { - Expect(err).To(HaveOccurred()) - } else if player.UserId != userPlayer.UserId { - Expect(err).To(Equal(rest.ErrPermissionDenied)) - clone.UserId = "" - } else { - Expect(err).To(BeNil()) - Expect(id).ToNot(BeEmpty()) - } - - count, err := repo.Count(repoCtx) - Expect(err).To(BeNil()) - - clone.ID = id - newItem, err := repo.Get(repoCtx, id) - - if clone.UserId == "" { - Expect(count).To(Equal(baseCount)) - Expect(err).To(Equal(model.ErrNotFound)) - } else { - Expect(count).To(Equal(baseCount + 1)) - Expect(err).To(BeNil()) - clone.APIKey = nil - clone.HasAPIKey = true - Expect(*newItem).To(Equal(clone)) - } - }, - Entry("same user", userPlayer), - Entry("other item", otherPlayer), - ) - }) - - Describe("Update", func() { - DescribeTable("item type", func(player model.Player) { - clone := player - clone.IP = "192.168.1.1" - clone.MaxBitRate = 10000 - err := repo.Update(repoCtx, clone.ID, clone, "ip") - - if player.UserId == "" { - Expect(err).To(HaveOccurred()) - } else if !admin && player.Username == adminPlayer1.Username { - // A non-admin cannot target another user's player: the ownership-restricted - // update matches no owned row, so it reports permission-denied rather than - // touching it. - Expect(err).To(Equal(rest.ErrPermissionDenied)) - clone.IP = player.IP - } else { - Expect(err).To(BeNil()) - } - - clone.MaxBitRate = player.MaxBitRate - newItem, err := repo.Get(repoCtx, clone.ID) - - if player.UserId == "" { - Expect(err).To(Equal(model.ErrNotFound)) - } else if !admin && player.UserId == adminUser.ID { - Expect(*newItem).To(Equal(player)) - } else { - Expect(*newItem).To(Equal(clone)) - } - }, - Entry("same user", userPlayer), - Entry("other item", otherPlayer), - Entry("fake item", model.Player{}), - ) - }) - }, - Entry("admin context", true, players, adminPlayer1, regularPlayer), - Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1), - ) - - Describe("API keys", func() { - const key = testAPIKey - const otherKey = "nds_ABCDEFGHIJKLMNOPQRSTUV" - var ownerCtx, otherCtx context.Context - - BeforeEach(func() { - ownerCtx = request.WithUser(log.NewContext(GinkgoT().Context()), regularUser) - otherCtx = request.WithUser(log.NewContext(GinkgoT().Context()), thirdUser) - }) - - storedHash := func(id string) string { - var row struct { - Hash string `db:"api_key_hash"` - } - Expect(database.NewQuery("select coalesce(api_key_hash, '') as api_key_hash from player where id = {:id}"). - Bind(dbx.Params{"id": id}).One(&row)).To(Succeed()) - return row.Hash - } - - Describe("SetAPIKey", func() { - It("stores only the hash and finds the player by the key", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - - Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) - plr, err := adminRepo.FindByAPIKey(ctx, key) - Expect(err).ToNot(HaveOccurred()) - Expect(plr.ID).To(Equal(regularPlayer.ID)) - Expect(plr.HasAPIKey).To(BeTrue()) - Expect(plr.APIKey).To(BeNil()) - }) - - It("replaces the previous key", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, otherKey)).To(Succeed()) - - _, err := adminRepo.FindByAPIKey(ctx, key) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = adminRepo.FindByAPIKey(ctx, otherKey) - Expect(err).ToNot(HaveOccurred()) - }) - - DescribeTable("rejects malformed keys", - func(bad string) { - err := adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, bad) - expectAPIKeyError(err, "resources.player.validation.apiKeyFormat") - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - }, - Entry("no prefix", "0123456789abcdefghijklmn"), - Entry("too short", "nds_short"), - Entry("too long", key+"x"), - Entry("bad chars", "nds_0123456789abcdefghij-!"), - ) - - It("revokes with an empty key, by the owner or an admin", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed()) - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, "")).To(Succeed()) - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - }) - - It("accepts revoking a player that has no key", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed()) - }) - - It("does not let an admin set a key on another user's player", func() { - Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, key)).To(MatchError(rest.ErrPermissionDenied)) - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - }) - - It("does not let another user set or revoke", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, otherKey)).To(MatchError(rest.ErrPermissionDenied)) - Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, "")).To(MatchError(rest.ErrPermissionDenied)) - Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) - }) - - It("returns not found for a missing player", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, "missing", key)).To(MatchError(rest.ErrNotFound)) - Expect(adminRepo.SetAPIKey(ownerCtx, "missing", "")).To(MatchError(rest.ErrNotFound)) - }) - - It("does not find unknown or empty keys", func() { - _, err := adminRepo.FindByAPIKey(ctx, otherKey) - Expect(err).To(MatchError(model.ErrNotFound)) - _, err = adminRepo.FindByAPIKey(ctx, "") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("drops the key with the player", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - Expect(adminRepo.Delete(ownerCtx, regularPlayer.ID)).To(Succeed()) - _, err := adminRepo.FindByAPIKey(ctx, key) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("hasApiKey filter", func() { - BeforeEach(func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - }) - - filtered := func(value string) []string { - res, err := adminRepo.ReadAll(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": value}}) - Expect(err).ToNot(HaveOccurred()) - var ids []string - for _, p := range res { - ids = append(ids, p.ID) - } - return ids - } - - It("lists only players with a key", func() { - Expect(filtered("true")).To(ConsistOf(regularPlayer.ID)) - count, err := adminRepo.Count(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": "true"}}) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(1))) - }) - - It("lists only players without a key", func() { - Expect(filtered("false")).To(ConsistOf(adminPlayer1.ID, adminPlayer2.ID)) - }) - }) - - Describe("Save (create)", func() { - It("creates the player with the key, owned by the logged-in user", func() { - id, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Manual player", APIKey: new(key)}) - Expect(err).ToNot(HaveOccurred()) - - plr, err := adminRepo.FindByAPIKey(ctx, key) - Expect(err).ToNot(HaveOccurred()) - Expect(plr.ID).To(Equal(id)) - Expect(plr.UserId).To(Equal(regularUser.ID)) - }) - - It("requires a key", func() { - count, _ := adminRepo.CountAll(ctx) - _, err := adminRepo.Save(ownerCtx, &model.Player{Name: "No key"}) - expectAPIKeyError(err, "ra.validation.required") - - _, err = adminRepo.Save(ownerCtx, &model.Player{Name: "Empty key", APIKey: new("")}) - expectAPIKeyError(err, "ra.validation.required") - Expect(adminRepo.CountAll(ctx)).To(Equal(count)) - }) - - It("rejects a malformed key without creating the player", func() { - count, _ := adminRepo.CountAll(ctx) - _, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Bad", APIKey: new("nds_bad")}) - expectAPIKeyError(err, "resources.player.validation.apiKeyFormat") - Expect(adminRepo.CountAll(ctx)).To(Equal(count)) - }) - - It("does not let an admin create a keyed player for another user", func() { - count, _ := adminRepo.CountAll(ctx) - _, err := adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID, APIKey: new(key)}) - Expect(err).To(MatchError(rest.ErrPermissionDenied)) - _, err = adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID}) - Expect(err).To(MatchError(rest.ErrPermissionDenied)) - Expect(adminRepo.CountAll(ctx)).To(Equal(count)) - }) - - It("rejects a key already used by another player without creating the player", func() { - Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed()) - count, _ := adminRepo.CountAll(ctx) - _, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Duplicate", APIKey: new(key)}) - expectAPIKeyError(err, "ra.validation.unique") - Expect(adminRepo.CountAll(ctx)).To(Equal(count)) - }) - }) - - Describe("Update (edit)", func() { - It("rolls back the key change when the rest of the edit fails", func() { - _, err := database.NewQuery(`create trigger fail_player_rename before update of name on player - when new.name = 'boom' begin select raise(abort, 'boom'); end`).Execute() - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - _, _ = database.NewQuery("drop trigger if exists fail_player_rename").Execute() - }) - - plr := regularPlayer - plr.Name = "boom" - plr.APIKey = new(key) - Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).ToNot(Succeed()) - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - }) - - It("keeps the key when apiKey is absent (a normal edit)", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - - plr := regularPlayer - plr.Name = "Renamed" - Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "hasApiKey")).To(Succeed()) - Expect(adminRepo.Update(ownerCtx, plr.ID, plr)).To(Succeed()) - - found, err := adminRepo.FindByAPIKey(ctx, key) - Expect(err).ToNot(HaveOccurred()) - Expect(found.Name).To(Equal("Renamed")) - }) - - It("sets a new key when apiKey has a value", func() { - plr := regularPlayer - plr.APIKey = new(key) - Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).To(Succeed()) - Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) - }) - - It("revokes the key when apiKey is empty", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - plr := regularPlayer - plr.APIKey = new("") - Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "apiKey")).To(Succeed()) - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - }) - - It("lets an admin edit another user's keyed player without touching the key", func() { - Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) - plr := regularPlayer - plr.MaxBitRate = 192 - Expect(adminRepo.Update(ctx, plr.ID, plr, "maxBitRate", "hasApiKey")).To(Succeed()) - Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) - }) - - It("refuses an admin setting a key on another user's player and leaves other columns alone", func() { - plr := regularPlayer - plr.Name = "Hijacked" - plr.APIKey = new(key) - Expect(adminRepo.Update(ctx, plr.ID, plr, "name", "apiKey")).To(MatchError(rest.ErrPermissionDenied)) - - got, err := adminRepo.Get(ctx, regularPlayer.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Name).To(Equal(regularPlayer.Name)) - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - }) - - It("refuses a key already used by another player and leaves other columns alone", func() { - Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed()) - plr := regularPlayer - plr.Name = "Renamed" - plr.APIKey = new(key) - err := adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey") - expectAPIKeyError(err, "ra.validation.unique") - - got, err := adminRepo.Get(ctx, regularPlayer.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Name).To(Equal(regularPlayer.Name)) - Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) - Expect(storedHash(adminPlayer1.ID)).To(Equal(hashAPIKey(key))) - }) - }) - }) - - Describe("Ownership enforcement (cross-tenant write protection)", func() { - var regularRepo *playerRepository - var regularCtx context.Context - - BeforeEach(func() { - regularCtx = request.WithUser(ctx, regularUser) - regularRepo = NewPlayerRepository(database).(*playerRepository) - }) - - It("does not let a regular user hijack another user's player by spoofing userId in the body", func() { - // Attacker (regularUser) targets the victim's (adminUser) player by URL id, - // but sets userId in the body to their own id to try to pass the permission check. - spoofed := model.Player{ - ID: adminPlayer1.ID, - Name: "HIJACKED", - UserId: regularUser.ID, // attacker's own id, spoofed in the body - MaxBitRate: 1, - } - - // The ownership-restricted update matches no row owned by the attacker, so the write - // targets nothing and reports permission-denied rather than overwriting the victim's row. - err := regularRepo.Update(regularCtx, adminPlayer1.ID, spoofed, "name", "user_id", "max_bit_rate") - Expect(err).To(Equal(rest.ErrPermissionDenied)) - - // The victim's player must remain untouched. - stored, err := adminRepo.Get(ctx, adminPlayer1.ID) - Expect(err).To(BeNil()) - Expect(*stored).To(Equal(adminPlayer1)) - }) - - It("does not let a regular user overwrite another user's player via Save with a spoofed id", func() { - spoofed := model.Player{ - ID: adminPlayer1.ID, - Name: "HIJACKED", - UserId: regularUser.ID, - ReportRealPath: true, - APIKey: new(testAPIKey), - } - - id, err := regularRepo.Save(regularCtx, &spoofed) - Expect(err).To(BeNil()) - Expect(id).ToNot(Equal(adminPlayer1.ID)) - - stored, err := adminRepo.Get(ctx, adminPlayer1.ID) - Expect(err).To(BeNil()) - Expect(*stored).To(Equal(adminPlayer1)) - - created, err := adminRepo.Get(ctx, id) - Expect(err).To(BeNil()) - Expect(created.UserId).To(Equal(regularUser.ID)) - }) - - It("does not let a regular user reassign their own player to another user", func() { - // Owner updates their own player but tries to give it away to the admin. The update - // succeeds for the other fields, but user_id is never written, so ownership stays put. - reassign := regularPlayer - reassign.UserId = adminUser.ID - reassign.Name = "given-away" - - err := regularRepo.Update(regularCtx, regularPlayer.ID, reassign, "name", "user_id") - Expect(err).To(BeNil()) - - // Ownership must not have changed. - stored, err := adminRepo.Get(ctx, regularPlayer.ID) - Expect(err).To(BeNil()) - Expect(stored.UserId).To(Equal(regularUser.ID)) - }) - - It("does not let an admin reassign a player to another user", func() { - // Even an admin cannot change a player's owner via update. - reassign := regularPlayer - reassign.UserId = adminUser.ID - reassign.Name = "admin-renamed" - - err := adminRepo.Update(regularCtx, regularPlayer.ID, reassign, "name", "user_id") - Expect(err).To(BeNil()) - - // The name change applies, but ownership must not have moved. - stored, err := adminRepo.Get(ctx, regularPlayer.ID) - Expect(err).To(BeNil()) - Expect(stored.Name).To(Equal("admin-renamed")) - Expect(stored.UserId).To(Equal(regularUser.ID)) - }) - - It("lets the owner update their own player", func() { - update := regularPlayer - update.Name = "renamed-by-owner" - - err := regularRepo.Update(regularCtx, regularPlayer.ID, update, "name") - Expect(err).To(BeNil()) - - stored, err := adminRepo.Get(ctx, regularPlayer.ID) - Expect(err).To(BeNil()) - Expect(stored.Name).To(Equal("renamed-by-owner")) - Expect(stored.UserId).To(Equal(regularUser.ID)) - }) - - It("returns not found when updating a nonexistent player", func() { - ghost := model.Player{ID: "does-not-exist", Name: "ghost", UserId: regularUser.ID} - err := regularRepo.Update(regularCtx, "does-not-exist", ghost, "name") - Expect(err).To(Equal(rest.ErrNotFound)) - }) - }) -}) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 41b75266c..fe1ecd5e7 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -2,86 +2,58 @@ package persistence import ( "context" - "database/sql" "encoding/json" "errors" - "fmt" - "slices" + "strings" "time" . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "github.com/pocketbase/dbx" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/utils" ) type playlistRepository struct { sqlRepository + sqlRestful } type dbPlaylist struct { model.Playlist `structs:",flatten"` - Rules sql.NullString `structs:"-"` + RawRules string `structs:"rules" orm:"column(rules)"` } -func (p *dbPlaylist) PostScan() error { - if p.Rules.String != "" { - return json.Unmarshal([]byte(p.Rules.String), &p.Playlist.Rules) - } - return nil -} - -func (p dbPlaylist) PostMapArgs(args map[string]any) error { - var err error - if p.Playlist.IsSmartPlaylist() { - args["rules"], err = json.Marshal(p.Playlist.Rules) - if err != nil { - return fmt.Errorf("invalid criteria expression: %w", err) - } - // Smart playlist counters are owned by refreshCounters (evaluation), never by callers - delete(args, "song_count") - delete(args, "duration") - delete(args, "size") - return nil - } - delete(args, "rules") - return nil -} - -func NewPlaylistRepository(db dbx.Builder) model.PlaylistRepository { +func NewPlaylistRepository(ctx context.Context, o orm.QueryExecutor) model.PlaylistRepository { r := &playlistRepository{} - r.db = db - r.registerModel(&model.Playlist{}, map[string]filterFunc{ - "id": idFilter("playlist"), - "q": playlistFilter, - "smart": smartPlaylistFilter, - "starred": annotationBoolFilter("starred"), - }) - r.setSortMappings(map[string]string{ - "name": naturalSort("playlist.name"), - "owner_name": naturalSort("owner_name"), - }) + r.ctx = ctx + r.ormer = o + r.tableName = "playlist" + r.filterMappings = map[string]filterFunc{ + "q": playlistFilter, + "smart": smartPlaylistFilter, + } return r } -func playlistFilter(_ string, value any) Sqlizer { +func playlistFilter(_ string, value interface{}) Sqlizer { return Or{ substringFilter("playlist.name", value), substringFilter("playlist.comment", value), } } -func smartPlaylistFilter(string, any) Sqlizer { +func smartPlaylistFilter(string, interface{}) Sqlizer { return Or{ Eq{"rules": ""}, Eq{"rules": nil}, } } -func (r *playlistRepository) userFilter(ctx context.Context) Sqlizer { - user := loggedUser(ctx) +func (r *playlistRepository) userFilter() Sqlizer { + user := loggedUser(r.ctx) if user.IsAdmin { return And{} } @@ -91,91 +63,97 @@ func (r *playlistRepository) userFilter(ctx context.Context) Sqlizer { } } -func (r *playlistRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - query := Select().Where(r.userFilter(ctx)) - if filtersNeedAnnotation(r.applyFilters(query, options...)) { - query = r.withAnnotation(ctx, query, "playlist.id") - } - return r.count(ctx, query, options...) +func (r *playlistRepository) CountAll(options ...model.QueryOptions) (int64, error) { + sql := Select().Where(r.userFilter()) + return r.count(sql, options...) } -func (r *playlistRepository) Exists(ctx context.Context, id string) (bool, error) { - return r.exists(ctx, And{Eq{"id": id}, r.userFilter(ctx)}) +func (r *playlistRepository) Exists(id string) (bool, error) { + return r.exists(Select().Where(And{Eq{"id": id}, r.userFilter()})) } -func (r *playlistRepository) Delete(ctx context.Context, ids ...string) error { - return r.delete(ctx, And{Eq{"id": ids}, r.userFilter(ctx)}) -} - -func (r *playlistRepository) Put(ctx context.Context, 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") +func (r *playlistRepository) Delete(id string) error { + usr := loggedUser(r.ctx) + if !usr.IsAdmin { + pls, err := r.Get(id) + if err != nil { + return err + } + if pls.OwnerID != usr.ID { + return rest.ErrPermissionDenied } - _, err := r.put(ctx, pls.ID, pls, cols...) - return err } - isNew := pls.ID == "" - if isNew { + return r.delete(And{Eq{"id": id}, r.userFilter()}) +} + +func (r *playlistRepository) Put(p *model.Playlist) error { + pls := dbPlaylist{Playlist: *p} + if p.IsSmartPlaylist() { + j, err := json.Marshal(p.Rules) + if err != nil { + return err + } + pls.RawRules = string(j) + } + if pls.ID == "" { pls.CreatedAt = time.Now() + } else { + ok, err := r.Exists(pls.ID) + if err != nil { + return err + } + if !ok { + return model.ErrNotAuthorized + } } pls.UpdatedAt = time.Now() - id, err := r.put(ctx, pls.ID, pls) + id, err := r.put(pls.ID, pls) if err != nil { return err } p.ID = id if p.IsSmartPlaylist() { - // Do not update tracks at this point, as it may take a long time and lock the DB, breaking the scan process + r.refreshSmartPlaylist(p) return nil } // Only update tracks if they were specified if len(pls.Tracks) > 0 { - return r.updateTracks(ctx, id, p.MediaFiles()) + return r.updateTracks(id, p.MediaFiles()) } - pls.ID = id // r.put assigns the generated id to p, not to this copy - if isNew { - // Even a trackless new playlist has art to find (an imported m3u can carry an - // ExternalImageURL); an update landing here changed only metadata, so leave its cover be. - r.enqueueCoverRebuild(ctx, id) - } - return r.refreshCounters(ctx, &pls.Playlist) + return r.refreshCounters(&pls.Playlist) } -func (r *playlistRepository) Get(ctx context.Context, id string) (*model.Playlist, error) { - return r.findBy(ctx, And{Eq{"playlist.id": id}, r.userFilter(ctx)}) +func (r *playlistRepository) Get(id string) (*model.Playlist, error) { + return r.findBy(And{Eq{"playlist.id": id}, r.userFilter()}) } -func (r *playlistRepository) GetWithTracks(ctx context.Context, id string, refreshSmartPlaylist, includeMissing bool) (*model.Playlist, error) { - pls, err := r.Get(ctx, id) +func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist bool) (*model.Playlist, error) { + pls, err := r.Get(id) if err != nil { return nil, err } if refreshSmartPlaylist { - r.refreshSmartPlaylist(ctx, pls) + r.refreshSmartPlaylist(pls) } - tracks, err := r.loadTracks(ctx, Select().From("playlist_tracks"). - Where(Eq{"missing": false}). - OrderBy("playlist_tracks.id"), id) + tracks, err := r.loadTracks(Select().From("playlist_tracks"), id) if err != nil { - log.Error(ctx, "Error loading playlist tracks ", "playlist", pls.Name, "id", pls.ID, err) + log.Error(r.ctx, "Error loading playlist tracks ", "playlist", pls.Name, "id", pls.ID, err) return nil, err } - pls.SetTracks(tracks) + pls.Tracks = tracks return pls, nil } -func (r *playlistRepository) FindByPath(ctx context.Context, path string) (*model.Playlist, error) { - return r.findBy(ctx, Eq{"path": path}) +func (r *playlistRepository) FindByPath(path string) (*model.Playlist, error) { + return r.findBy(Eq{"path": path}) } -func (r *playlistRepository) findBy(ctx context.Context, sql Sqlizer) (*model.Playlist, error) { - sel := r.selectPlaylist(ctx).Where(sql) +func (r *playlistRepository) findBy(sql Sqlizer) (*model.Playlist, error) { + sel := r.selectPlaylist().Where(sql) var pls []dbPlaylist - err := r.queryAll(ctx, sel, &pls) + err := r.queryAll(sel, &pls) if err != nil { return nil, err } @@ -183,263 +161,258 @@ func (r *playlistRepository) findBy(ctx context.Context, sql Sqlizer) (*model.Pl return nil, model.ErrNotFound } - list := model.Playlists{pls[0].Playlist} - r.hydrateArtwork(ctx, list) - return &list[0], nil + return r.toModel(pls[0]) } -func (r *playlistRepository) hydrateArtwork(ctx context.Context, playlists model.Playlists) { - hydrateItems(ctx, r.db, model.KindPlaylistArtwork, playlists, - func(p *model.Playlist) (string, *model.ItemImage) { return p.ID, &p.ItemImage }) +func (r *playlistRepository) toModel(pls dbPlaylist) (*model.Playlist, error) { + var err error + if strings.TrimSpace(pls.RawRules) != "" { + var c criteria.Criteria + err = json.Unmarshal([]byte(pls.RawRules), &c) + if err != nil { + return nil, err + } + pls.Playlist.Rules = &c + } else { + pls.Playlist.Rules = nil + } + return &pls.Playlist, err } -func (r *playlistRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Playlists, error) { - sel := r.selectPlaylist(ctx, options...).Where(r.userFilter(ctx)) +func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) { + sel := r.selectPlaylist(options...).Where(r.userFilter()) var res []dbPlaylist - err := r.queryAll(ctx, sel, &res) + err := r.queryAll(sel, &res) if err != nil { return nil, err } playlists := make(model.Playlists, len(res)) for i, p := range res { - playlists[i] = p.Playlist + pls, err := r.toModel(p) + if err != nil { + return nil, err + } + playlists[i] = *pls } - r.hydrateArtwork(ctx, playlists) return playlists, err } -// getAllIDs returns the IDs of GetAll's row set, skipping its per-row processing. -func (r *playlistRepository) getAllIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { - // Joins a projection of user, not the table: its name/created_at columns would make an ORDER BY - // on the playlist's own ambiguous. - sq := r.newSelect(ctx, options...).Columns("playlist.id", "user.user_name as owner_name"). - Join("(select id, user_name from user) user on user.id = owner_id").Where(r.userFilter(ctx)) - if filtersNeedAnnotation(sq) { - sq = r.withAnnotation(ctx, sq, "playlist.id") - } - ids := []string{} - err := r.queryAllSlice(ctx, sq, &ids) - return ids, err -} - -func (r *playlistRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.PlaylistCursor, error) { - // Both passes apply userFilter, so a visibility change between them cannot widen the cursor. - ids, err := r.getAllIDs(ctx, options...) - if err != nil { - return nil, err - } - opts := chunkOptions(options, "playlist.id") - return model.PlaylistCursor(streamByIDs(ids, func(chunk []string) (model.Playlists, error) { - return r.GetAll(ctx, opts(chunk)) - })), nil -} - -func (r *playlistRepository) GetPlaylists(ctx context.Context, mediaFileId string) (model.Playlists, error) { - sel := r.selectPlaylist(ctx, model.QueryOptions{Sort: "name"}). - Join("playlist_tracks on playlist.id = playlist_tracks.playlist_id"). - Where(And{Eq{"playlist_tracks.media_file_id": mediaFileId}, r.userFilter(ctx)}) - var res []dbPlaylist - err := r.queryAll(ctx, sel, &res) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - return model.Playlists{}, nil - } - return nil, err - } - playlists := make(model.Playlists, len(res)) - for i, p := range res { - playlists[i] = p.Playlist - } - r.hydrateArtwork(ctx, playlists) - return playlists, nil -} - -func (r *playlistRepository) selectPlaylist(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - sel := r.newSelect(ctx, options...).Join("user on user.id = owner_id"). +func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder { + return r.newSelect(options...).Join("user on user.id = owner_id"). Columns(r.tableName+".*", "user.user_name as owner_name") - return r.withAnnotation(ctx, sel, r.tableName+".id") } -func (r *playlistRepository) updateTracks(ctx context.Context, id string, tracks model.MediaFiles) error { +func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { + // Only refresh if it is a smart playlist and was not refreshed in the last 5 seconds + if !pls.IsSmartPlaylist() || time.Since(pls.EvaluatedAt) < 5*time.Second { + return false + } + + // Never refresh other users' playlists + usr := loggedUser(r.ctx) + if pls.OwnerID != usr.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 { + return false + } + + // Re-populate playlist based on Smart Playlist criteria + rules := *pls.Rules + sql := 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 = '" + userId(r.ctx) + "')"). + LeftJoin("media_file_genres ag on media_file.id = ag.media_file_id"). + LeftJoin("genre on ag.genre_id = genre.id").GroupBy("media_file.id") + sql = r.addCriteria(sql, rules) + insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sql) + _, 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) + updSql := Update(r.tableName).Set("evaluated_at", time.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 + } + + log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) + + return true +} + +func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) SelectBuilder { + sql = sql.Where(c.ToSql()) + 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 { ids[i] = tracks[i].ID } - return r.updatePlaylist(ctx, id, ids) + return r.updatePlaylist(id, ids) } -func (r *playlistRepository) updatePlaylist(ctx context.Context, playlistId string, mediaFileIds []string) error { +func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []string) error { + if !r.isWritable(playlistId) { + return rest.ErrPermissionDenied + } + // Remove old tracks del := Delete("playlist_tracks").Where(Eq{"playlist_id": playlistId}) - _, err := r.executeSQL(ctx, del) + _, err := r.executeSQL(del) if err != nil { return err } - _, err = r.addTracks(ctx, playlistId, 1, mediaFileIds) - return err + return r.addTracks(playlistId, 1, mediaFileIds) } -// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns -// the library check: every caller, including a full replace through Put, goes through it. -func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, startingPos int, mediaFileIds []string) (int, error) { - mediaFileIds, err := r.keepAccessible(ctx, mediaFileIds) - if err != nil { - return 0, err - } - // Break the track list in chunks to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit +func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) error { + // Break the track list in chunks to avoid hitting SQLITE_MAX_FUNCTION_ARG limit + chunks := utils.BreakUpStringSlice(mediaFileIds, 200) + // Add new tracks, chunk by chunk pos := startingPos - for chunk := range slices.Chunk(mediaFileIds, 200) { + for i := range chunks { ins := Insert("playlist_tracks").Columns("playlist_id", "media_file_id", "id") - for _, t := range chunk { + for _, t := range chunks[i] { ins = ins.Values(playlistId, t, pos) pos++ } - if _, err := r.executeSQL(ctx, ins); err != nil { - return 0, err + _, err := r.executeSQL(ins) + if err != nil { + return err } } - r.enqueueCoverRebuild(ctx, playlistId) - return len(mediaFileIds), r.refreshCounters(ctx, &model.Playlist{ID: playlistId}) + return r.refreshCounters(&model.Playlist{ID: playlistId}) } -// keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked -// because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER. -func (r *playlistRepository) keepAccessible(ctx context.Context, mediaFileIds []string) ([]string, error) { - if visible, err := r.visibleLibraryIDs(ctx); err == nil && r.userSeesAllLibraries(ctx, visible) { - return mediaFileIds, nil - } - accessible := make(map[string]struct{}, len(mediaFileIds)) - for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) { - sq := r.applyLibraryFilter(ctx, Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file") - var found []string - if err := r.queryAllSlice(ctx, sq, &found); err != nil { - return nil, err - } - for _, id := range found { - accessible[id] = struct{}{} - } - } - return slice.Filter(mediaFileIds, func(id string) bool { - _, ok := accessible[id] - return ok - }), nil -} - -// refreshCounters updates total playlist duration, size and count -func (r *playlistRepository) refreshCounters(ctx context.Context, pls *model.Playlist) error { - statsSql := Select( - "coalesce(sum(duration), 0) as duration", - "coalesce(sum(size), 0) as size", - "count(*) as count", - ). +// RefreshStatus updates total playlist duration, size and count +func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { + statsSql := Select("sum(duration) as duration", "sum(size) as size", "count(*) as count"). From("media_file"). Join("playlist_tracks f on f.media_file_id = media_file.id"). Where(Eq{"playlist_id": pls.ID}) var res struct{ Duration, Size, Count float32 } - err := r.queryOne(ctx, statsSql, &res) + err := r.queryOne(statsSql, &res) if err != nil { return err } // Update playlist's total duration, size and count - now := time.Now() upd := Update("playlist"). Set("duration", res.Duration). Set("size", res.Size). Set("song_count", res.Count). - Set("updated_at", now). + Set("updated_at", time.Now()). Where(Eq{"id": pls.ID}) - _, err = r.executeSQL(ctx, upd) + _, err = r.executeSQL(upd) if err != nil { return err } pls.SongCount = int(res.Count) pls.Duration = res.Duration pls.Size = int64(res.Size) - pls.UpdatedAt = now return nil } -// enqueueCoverRebuild re-resolves the generated 2x2 grid. Call it only when the track set changes: -// the grid samples albums at random, so rebuilding after a mere rename would change the cover. -func (r *playlistRepository) enqueueCoverRebuild(ctx context.Context, id string) { - item := model.ArtworkQueueItem{ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: id, - ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityScan} - if err := NewArtworkQueueRepository(r.db).Enqueue(ctx, item); err != nil { - log.Warn(ctx, "could not enqueue playlist artwork after content change", "id", id, err) - } -} - -// tracksQuery is shared by loadTracks and GetCursor, so both hydrate rows identically. -func (r *playlistRepository) tracksQuery(ctx context.Context, query SelectBuilder, id string) SelectBuilder { - query = r.applyLibraryFilter(ctx, query, "f") - userID := loggedUser(ctx).ID - return query. - Columns( - "coalesce(starred, 0) as starred", - "starred_at", - "coalesce(play_count, 0) as play_count", - "play_date", - "coalesce(rating, 0) as rating", - "rated_at", - "f.*", - "playlist_tracks.*", - "library.path as library_path", - "library.name as library_name", - ). +func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.PlaylistTracks, error) { + tracksQuery := sel. + Columns("starred", "starred_at", "play_count", "play_date", "rating", "f.*", "playlist_tracks.*"). LeftJoin("annotation on (" + "annotation.item_id = media_file_id" + " AND annotation.item_type = 'media_file'" + - " AND annotation.user_id = '" + userID + "')"). + " AND annotation.user_id = '" + userId(r.ctx) + "')"). Join("media_file f on f.id = media_file_id"). - Join("library on f.library_id = library.id"). - Where(Eq{"playlist_id": id}) -} - -func (r *playlistRepository) loadTracks(ctx context.Context, query SelectBuilder, id string) (model.PlaylistTracks, error) { - tracks := dbPlaylistTracks{} - err := r.queryAll(ctx, r.tracksQuery(ctx, query, id), &tracks) - if err != nil { - return nil, err + Where(Eq{"playlist_id": id}).OrderBy("playlist_tracks.id") + tracks := model.PlaylistTracks{} + err := r.queryAll(tracksQuery, &tracks) + for i, t := range tracks { + tracks[i].MediaFile.ID = t.MediaFileID } - res := tracks.toModels() - hydratePlaylistTrackArtwork(ctx, r.db, res) - return res, err + return tracks, err } -func (r *playlistRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(options...)) } -func (r *playlistRepository) Read(ctx context.Context, id string) (*model.Playlist, error) { - return r.Get(ctx, id) +func (r *playlistRepository) Read(id string) (interface{}, error) { + return r.Get(id) } -func (r *playlistRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Playlist, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return r.GetAll(r.parseRestOptions(options...)) } -func (r *playlistRepository) Save(ctx context.Context, pls *model.Playlist) (string, error) { - pls.ID = "" // Force new creation - err := r.Put(ctx, pls) +func (r *playlistRepository) EntityName() string { + return "playlist" +} + +func (r *playlistRepository) NewInstance() interface{} { + return &model.Playlist{} +} + +func (r *playlistRepository) Save(entity interface{}) (string, error) { + pls := entity.(*model.Playlist) + pls.OwnerID = loggedUser(r.ctx).ID + pls.ID = "" // Make sure we don't override an existing playlist + err := r.Put(pls) if err != nil { return "", err } return pls.ID, err } -func (r *playlistRepository) Update(ctx context.Context, id string, entity model.Playlist, cols ...string) error { - pls := dbPlaylist{Playlist: entity} +func (r *playlistRepository) Update(id string, entity interface{}, cols ...string) error { + current, err := r.Get(id) + if err != nil { + return err + } + usr := loggedUser(r.ctx) + if !usr.IsAdmin && current.OwnerID != usr.ID { + return rest.ErrPermissionDenied + } + pls := entity.(*model.Playlist) pls.ID = id pls.UpdatedAt = time.Now() - _, err := r.put(ctx, id, pls, append(cols, "updatedAt")...) + _, err = r.put(id, pls, append(cols, "updatedAt")...) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } return err } -func (r *playlistRepository) removeOrphans(ctx context.Context) error { +func (r *playlistRepository) removeOrphans() error { sel := Select("playlist_tracks.playlist_id as id", "p.name").From("playlist_tracks"). Join("playlist p on playlist_tracks.playlist_id = p.id"). LeftJoin("media_file mf on playlist_tracks.media_file_id = mf.id"). @@ -447,59 +420,50 @@ func (r *playlistRepository) removeOrphans(ctx context.Context) error { GroupBy("playlist_tracks.playlist_id") var pls []struct{ Id, Name string } - err := r.queryAll(ctx, sel, &pls) + err := r.queryAll(sel, &pls) if err != nil { - return fmt.Errorf("fetching playlists with orphan tracks: %w", err) + return err } for _, pl := range pls { - log.Debug(ctx, "Cleaning-up orphan tracks from playlist", "id", pl.Id, "name", pl.Name) + log.Debug(r.ctx, "Cleaning-up orphan tracks from playlist", "id", pl.Id, "name", pl.Name) del := Delete("playlist_tracks").Where(And{ ConcatExpr("media_file_id not in (select id from media_file)"), Eq{"playlist_id": pl.Id}, }) - n, err := r.executeSQL(ctx, del) + n, err := r.executeSQL(del) if n == 0 || err != nil { - return fmt.Errorf("deleting orphan tracks from playlist %s: %w", pl.Name, err) + return err } - log.Debug(ctx, "Deleted tracks, now reordering", "id", pl.Id, "name", pl.Name, "deleted", n) + log.Debug(r.ctx, "Deleted tracks, now reordering", "id", pl.Id, "name", pl.Name, "deleted", n) // Renumber the playlist if any track was removed - if err := r.renumber(ctx, pl.Id); err != nil { - return fmt.Errorf("renumbering playlist %s: %w", pl.Name, err) + if err := r.renumber(pl.Id); err != nil { + return err } } return nil } -// renumber updates the position of all tracks in the playlist to be sequential starting from 1, ordered by their -// current position. This is needed after removing orphan tracks, to ensure there are no gaps in the track numbering. -// The two-step approach (negate then reassign via CTE) avoids UNIQUE constraint violations on (playlist_id, id). -func (r *playlistRepository) renumber(ctx context.Context, id string) error { - // Step 1: Negate all IDs to clear the positive ID space - _, err := r.executeSQL(ctx, Expr( - `UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id > 0`, id)) +func (r *playlistRepository) renumber(id string) error { + var ids []string + sql := Select("media_file_id").From("playlist_tracks").Where(Eq{"playlist_id": id}).OrderBy("id") + err := r.queryAll(sql, &ids) if err != nil { return err } - // Step 2: Assign new sequential positive IDs using UPDATE...FROM with a CTE. - // The CTE is fully materialized before the UPDATE begins, avoiding self-referencing issues. - // ORDER BY id DESC restores original order since IDs are now negative. - _, err = r.executeSQL(ctx, Expr( - `WITH new_ids AS ( - SELECT rowid as rid, ROW_NUMBER() OVER (ORDER BY id DESC) as new_id - FROM playlist_tracks WHERE playlist_id = ? - ) - UPDATE playlist_tracks SET id = new_ids.new_id - FROM new_ids - WHERE playlist_tracks.rowid = new_ids.rid AND playlist_tracks.playlist_id = ?`, id, id)) - if err != nil { - return err + return r.updatePlaylist(id, ids) +} + +func (r *playlistRepository) isWritable(playlistId string) bool { + usr := loggedUser(r.ctx) + if usr.IsAdmin { + return true } - r.enqueueCoverRebuild(ctx, id) - return r.refreshCounters(ctx, &model.Playlist{ID: id}) + pls, err := r.Get(playlistId) + return err == nil && pls.OwnerID == usr.ID } var _ model.PlaylistRepository = (*playlistRepository)(nil) -var _ rest.Repository[model.Playlist] = (*playlistRepository)(nil) -var _ rest.Persistable[model.Playlist] = (*playlistRepository)(nil) +var _ rest.Repository = (*playlistRepository)(nil) +var _ rest.Persistable = (*playlistRepository)(nil) diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 93d37c928..01672c512 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -2,102 +2,42 @@ package persistence import ( "context" - "slices" - "github.com/Masterminds/squirrel" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" + "github.com/beego/beego/v2/client/orm" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" ) var _ = Describe("PlaylistRepository", func() { var repo model.PlaylistRepository - var ctx context.Context BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPlaylistRepository(GetDBXBuilder()) - }) - - Describe("natural sorting", func() { - var ids []string - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableNaturalSorting = true - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPlaylistRepository(GetDBXBuilder()) - - ids = nil - for _, n := range []string{"mix 1", "mix 10", "mix 2"} { - pls := model.Playlist{Name: n, OwnerID: "userid"} - Expect(repo.Put(ctx, &pls)).To(Succeed()) - ids = append(ids, pls.ID) - } - DeferCleanup(func() { - for _, id := range ids { - _ = repo.Delete(ctx, id) - } - }) - }) - - It("sorts playlist names by number value", func() { - all, err := repo.GetAll(ctx, model.QueryOptions{ - Sort: "name", Filters: squirrel.Eq{"playlist.id": ids}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(slice.Map(all, func(p model.Playlist) string { return p.Name })).To( - Equal([]string{"mix 1", "mix 2", "mix 10"})) - }) + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(ctx, orm.NewOrm()) }) Describe("Count", func() { It("returns the number of playlists in the DB", func() { - Expect(repo.CountAll(ctx)).To(Equal(int64(2))) - }) - }) - - Describe("GetCursor", func() { - It("yields the same playlists as GetAll", func() { - opts := model.QueryOptions{Sort: "name"} - want, err := repo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.Playlist(want))) - }) - }) - - Describe("getAllIDs", func() { - It("returns the same id set as GetAll", func() { - want, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(want).ToNot(BeEmpty()) - ids, err := repo.(*playlistRepository).getAllIDs(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(ConsistOf(slice.Map(want, func(p model.Playlist) string { return p.ID }))) + Expect(repo.CountAll()).To(Equal(int64(2))) }) }) Describe("Exists", func() { It("returns true for an existing playlist", func() { - Expect(repo.Exists(ctx, plsCool.ID)).To(BeTrue()) + Expect(repo.Exists(plsCool.ID)).To(BeTrue()) }) It("returns false for a non-existing playlist", func() { - Expect(repo.Exists(ctx, "666")).To(BeFalse()) + Expect(repo.Exists("666")).To(BeFalse()) }) }) Describe("Get", func() { It("returns an existing playlist", func() { - p, err := repo.Get(ctx, plsBest.ID) + p, err := repo.Get(plsBest.ID) Expect(err).To(BeNil()) // Compare all but Tracks and timestamps p2 := *p @@ -111,12 +51,12 @@ var _ = Describe("PlaylistRepository", func() { } }) It("returns ErrNotFound for a non-existing playlist", func() { - _, err := repo.Get(ctx, "666") + _, err := repo.Get("666") Expect(err).To(MatchError(model.ErrNotFound)) }) It("returns all tracks", func() { - pls, err := repo.GetWithTracks(ctx, plsBest.ID, true, false) - Expect(err).ToNot(HaveOccurred()) + pls, err := repo.GetWithTracks(plsBest.ID, true) + Expect(err).To(BeNil()) Expect(pls.Name).To(Equal(plsBest.Name)) Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].ID).To(Equal("1")) @@ -134,361 +74,39 @@ var _ = Describe("PlaylistRepository", func() { }) }) - Describe("Annotations", func() { - var plsID string - - BeforeEach(func() { - pls := model.Playlist{Name: "Annotated", OwnerID: "userid"} - Expect(repo.Put(ctx, &pls)).To(Succeed()) - plsID = pls.ID - }) - - countAnnotations := func() int { - var count int - Expect(GetDBXBuilder().NewQuery( - "SELECT count(*) FROM annotation WHERE item_type = 'playlist' AND item_id = {:id}"). - Bind(dbx.Params{"id": plsID}).Row(&count)).To(Succeed()) - return count - } - - It("stores and reads back starred", func() { - Expect(repo.SetStar(ctx, true, plsID)).To(Succeed()) - - p, err := repo.Get(ctx, plsID) - Expect(err).ToNot(HaveOccurred()) - Expect(p.Starred).To(BeTrue()) - Expect(p.StarredAt).ToNot(BeNil()) - }) - - It("stores and reads back rating and average_rating", func() { - Expect(repo.SetRating(ctx, 4, plsID)).To(Succeed()) - - p, err := repo.Get(ctx, plsID) - Expect(err).ToNot(HaveOccurred()) - Expect(p.Rating).To(Equal(4)) - Expect(p.RatedAt).ToNot(BeNil()) - Expect(p.AverageRating).To(Equal(4.0)) - }) - - It("keeps annotations isolated per user", func() { - Expect(repo.SetStar(ctx, true, plsID)).To(Succeed()) - - otherCtx := request.WithUser(log.NewContext(GinkgoT().Context()), - model.User{ID: "otheruser", UserName: "otheruser", IsAdmin: true}) - otherRepo := NewPlaylistRepository(GetDBXBuilder()) - - p, err := otherRepo.Get(otherCtx, plsID) - Expect(err).ToNot(HaveOccurred()) - Expect(p.Starred).To(BeFalse()) - }) - - It("reads starred back through GetAll", func() { - Expect(repo.SetStar(ctx, true, plsID)).To(Succeed()) - - all, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - idx := slices.IndexFunc(all, func(p model.Playlist) bool { return p.ID == plsID }) - Expect(idx).To(BeNumerically(">=", 0)) - Expect(all[idx].Starred).To(BeTrue()) - }) - - It("counts playlists using annotation filters", func() { - Expect(repo.SetStar(ctx, true, plsID)).To(Succeed()) - - options := model.QueryOptions{Filters: squirrel.Eq{"starred": true}} - starred, err := repo.GetAll(ctx, options) - Expect(err).ToNot(HaveOccurred()) - Expect(starred).To(ContainElement(HaveField("ID", plsID))) - - count, err := repo.CountAll(ctx, options) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(len(starred)))) - }) - - It("filters starred playlists through the registered REST filter", func() { - Expect(repo.SetStar(ctx, true, plsID)).To(Succeed()) - - res, err := repo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"starred": "true"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(res).To(ContainElement(HaveField("ID", plsID))) - for _, p := range res { - Expect(p.Starred).To(BeTrue()) - } - - res, err = repo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"starred": "false"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(res).ToNot(ContainElement(HaveField("ID", plsID))) - }) - - It("reads a playlist by id through the REST id filter without ambiguity", func() { - res, err := repo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"id": plsID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(res).To(ContainElement(HaveField("ID", plsID))) - }) - - It("does not leak an annotation row of another item_type sharing the playlist id", func() { - // Older builds (and the star fallthrough) can leave a media_file-typed row - // under a playlist id; the item_type-scoped join must not surface or dupe it. - _, err := GetDBXBuilder().NewQuery( - "INSERT INTO annotation (user_id, item_id, item_type, starred) VALUES ({:uid}, {:id}, 'media_file', 1)"). - Bind(dbx.Params{"uid": "userid", "id": plsID}).Execute() - Expect(err).ToNot(HaveOccurred()) - - p, err := repo.Get(ctx, plsID) - Expect(err).ToNot(HaveOccurred()) - Expect(p.Starred).To(BeFalse()) - - all, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - matches := 0 - for _, pl := range all { - if pl.ID == plsID { - matches++ - } - } - Expect(matches).To(Equal(1)) - }) - - It("relies on the annotation sweep, not Delete, to clean up annotations", func() { - Expect(repo.SetStar(ctx, true, plsID)).To(Succeed()) - - Expect(repo.Delete(ctx, plsID)).To(Succeed()) - Expect(countAnnotations()).To(Equal(1)) - - Expect(repo.(*playlistRepository).cleanAnnotations(ctx)).To(Succeed()) - Expect(countAnnotations()).To(Equal(0)) - }) - }) - - Describe("Put", func() { - It("does not overwrite counters when saving a smart playlist", func() { - pls := model.Playlist{Name: "Smart Counters", OwnerID: "userid", Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.Contains{"title": "love"}}, - }} - Expect(repo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { Expect(repo.Delete(ctx, pls.ID)).To(Succeed()) }) - - // Simulate a previous evaluation having stored the counters - _, err := GetDBXBuilder().NewQuery("update playlist set song_count = 42, duration = 123, size = 456 where id = {:id}"). - Bind(dbx.Params{"id": pls.ID}).Execute() - Expect(err).ToNot(HaveOccurred()) - - pls.SongCount = 0 - pls.Duration = 0 - pls.Size = 0 - Expect(repo.Put(ctx, &pls)).To(Succeed()) - - saved, err := repo.Get(ctx, pls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(saved.SongCount).To(Equal(42)) - Expect(saved.Duration).To(Equal(float32(123))) - Expect(saved.Size).To(Equal(int64(456))) - }) - }) - It("Put/Exists/Delete", func() { By("saves the playlist to the DB") newPls := model.Playlist{Name: "Great!", OwnerID: "userid"} - newPls.AddMediaFilesByID([]string{"1004", "1003"}) + newPls.AddTracks([]string{"1004", "1003"}) By("saves the playlist to the DB") - Expect(repo.Put(ctx, &newPls)).To(BeNil()) + Expect(repo.Put(&newPls)).To(BeNil()) By("adds repeated songs to a playlist and keeps the order") - newPls.AddMediaFilesByID([]string{"1004"}) - Expect(repo.Put(ctx, &newPls)).To(BeNil()) - saved, _ := repo.GetWithTracks(ctx, newPls.ID, true, false) + newPls.AddTracks([]string{"1004"}) + Expect(repo.Put(&newPls)).To(BeNil()) + saved, _ := repo.GetWithTracks(newPls.ID, true) Expect(saved.Tracks).To(HaveLen(3)) Expect(saved.Tracks[0].MediaFileID).To(Equal("1004")) Expect(saved.Tracks[1].MediaFileID).To(Equal("1003")) Expect(saved.Tracks[2].MediaFileID).To(Equal("1004")) By("returns the newly created playlist") - Expect(repo.Exists(ctx, newPls.ID)).To(BeTrue()) + Expect(repo.Exists(newPls.ID)).To(BeTrue()) By("returns deletes the playlist") - Expect(repo.Delete(ctx, newPls.ID)).To(BeNil()) + Expect(repo.Delete(newPls.ID)).To(BeNil()) By("returns error if tries to retrieve the deleted playlist") - Expect(repo.Exists(ctx, newPls.ID)).To(BeFalse()) - }) - - It("enqueues a new empty playlist's artwork under its generated id, not an empty id", func() { - ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - newPls := model.Playlist{Name: "Empty PL", OwnerID: "userid"} // no tracks → refreshCounters path - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - Expect(newPls.ID).ToNot(BeEmpty()) - DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - - queued, err := NewArtworkQueueRepository(GetDBXBuilder()).DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).To(ContainElement(SatisfyAll(HaveField("ItemKind", "pl"), HaveField("ItemID", newPls.ID)))) - Expect(queued).ToNot(ContainElement(HaveField("ItemID", "")), "must not enqueue an empty playlist id") - }) - - // The grid samples albums at random, so re-resolving after a rename would change the cover. - It("does not enqueue artwork when only metadata changes", func() { - ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - newPls := model.Playlist{Name: "Rename Me", OwnerID: "userid"} - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - // Clear the row creation just enqueued, so anything present afterwards came from the update. - queueRepo := NewArtworkQueueRepository(GetDBXBuilder()) - queued, err := queueRepo.DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - for _, q := range queued { - if q.ItemID == newPls.ID { - Expect(queueRepo.DeleteIfUnchanged(ctx, q.ItemKind, q.ItemID, q.ImageType, q.RetryAt)).To(Succeed()) - } - } - - newPls.Name = "Renamed" - newPls.Comment = "edited" - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - - queued, err = queueRepo.DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).ToNot(ContainElement(HaveField("ItemID", newPls.ID))) - }) - - It("enqueues the playlist's artwork when its track set changes", func() { - ctx := request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - newPls := model.Playlist{Name: "Grid PL", OwnerID: "userid"} - newPls.AddMediaFilesByID([]string{"1001", "1002"}) - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - - queued, err := NewArtworkQueueRepository(GetDBXBuilder()).DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "pl"), - HaveField("ItemID", newPls.ID), - ))) + Expect(repo.Exists(newPls.ID)).To(BeFalse()) }) Describe("GetAll", func() { It("returns all playlists from DB", func() { - all, err := repo.GetAll(ctx) + all, err := repo.GetAll() Expect(err).To(BeNil()) Expect(all[0].ID).To(Equal(plsBest.ID)) Expect(all[1].ID).To(Equal(plsCool.ID)) }) }) - - Describe("GetPlaylists", func() { - It("returns playlists for a track", func() { - pls, err := repo.GetPlaylists(ctx, songRadioactivity.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(pls).To(HaveLen(1)) - Expect(pls[0].ID).To(Equal(plsBest.ID)) - }) - - It("returns empty when none", func() { - pls, err := repo.GetPlaylists(ctx, "9999") - Expect(err).ToNot(HaveOccurred()) - Expect(pls).To(HaveLen(0)) - }) - }) - - Describe("Track Deletion and Renumbering", func() { - var testPlaylistID string - - AfterEach(func() { - if testPlaylistID != "" { - Expect(repo.Delete(ctx, testPlaylistID)).To(BeNil()) - testPlaylistID = "" - } - }) - - // helper to get track positions and media file IDs - getTrackInfo := func(playlistID string) (ids []string, mediaFileIDs []string) { - pls, err := repo.GetWithTracks(ctx, playlistID, false, false) - Expect(err).ToNot(HaveOccurred()) - for _, t := range pls.Tracks { - ids = append(ids, t.ID) - mediaFileIDs = append(mediaFileIDs, t.MediaFileID) - } - return - } - - It("renumbers correctly after deleting a track from the middle", func() { - By("creating a playlist with 4 tracks") - newPls := model.Playlist{Name: "Renumber Test Middle", OwnerID: "userid"} - newPls.AddMediaFilesByID([]string{"1001", "1002", "1003", "1004"}) - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("deleting the second track (position 2)") - tracksRepo := repo.Tracks(ctx, newPls.ID, false) - Expect(tracksRepo.Delete(ctx, "2")).To(Succeed()) - - By("verifying remaining tracks are renumbered sequentially") - ids, mediaFileIDs := getTrackInfo(newPls.ID) - Expect(ids).To(Equal([]string{"1", "2", "3"})) - Expect(mediaFileIDs).To(Equal([]string{"1001", "1003", "1004"})) - }) - - It("renumbers correctly after deleting the first track", func() { - By("creating a playlist with 3 tracks") - newPls := model.Playlist{Name: "Renumber Test First", OwnerID: "userid"} - newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"}) - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("deleting the first track (position 1)") - tracksRepo := repo.Tracks(ctx, newPls.ID, false) - Expect(tracksRepo.Delete(ctx, "1")).To(Succeed()) - - By("verifying remaining tracks are renumbered sequentially") - ids, mediaFileIDs := getTrackInfo(newPls.ID) - Expect(ids).To(Equal([]string{"1", "2"})) - Expect(mediaFileIDs).To(Equal([]string{"1002", "1003"})) - }) - - It("renumbers correctly after deleting the last track", func() { - By("creating a playlist with 3 tracks") - newPls := model.Playlist{Name: "Renumber Test Last", OwnerID: "userid"} - newPls.AddMediaFilesByID([]string{"1001", "1002", "1003"}) - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("deleting the last track (position 3)") - tracksRepo := repo.Tracks(ctx, newPls.ID, false) - Expect(tracksRepo.Delete(ctx, "3")).To(Succeed()) - - By("verifying remaining tracks are renumbered sequentially") - ids, mediaFileIDs := getTrackInfo(newPls.ID) - Expect(ids).To(Equal([]string{"1", "2"})) - Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"})) - }) - }) - - // Exists is ctx-sensitive through userFilter, so callers that only want "does it still exist" - // -- the public image route serving a share -- must elevate, or a private playlist looks gone. - Describe("Exists visibility", func() { - It("hides a private playlist from an unauthenticated context", func() { - // "userid" is the fixture user; playlist.owner_id has a FK to user(id). - owner := model.User{ID: "userid", UserName: "userid"} - octx := request.WithUser(GinkgoT().Context(), owner) - ownerRepo := NewPlaylistRepository(GetDBXBuilder()) - pls := model.Playlist{Name: "Private One", OwnerID: owner.ID, Public: false} - Expect(ownerRepo.Put(octx, &pls)).To(Succeed()) - DeferCleanup(func() { _ = ownerRepo.Delete(octx, pls.ID) }) - - Expect(ownerRepo.Exists(octx, pls.ID)).To(BeTrue(), "the owner sees it") - - anon := NewPlaylistRepository(GetDBXBuilder()) - Expect(anon.Exists(GinkgoT().Context(), pls.ID)).To(BeFalse(), "no user: userFilter hides it") - - admin := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", IsAdmin: true}) - Expect(NewPlaylistRepository(GetDBXBuilder()).Exists(admin, pls.ID)).To(BeTrue(), - "elevating is what the public image route relies on") - }) - }) }) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 392446cef..24715a17a 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -1,10 +1,6 @@ package persistence import ( - "context" - "database/sql" - "slices" - . "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/log" @@ -14,232 +10,138 @@ import ( type playlistTrackRepository struct { sqlRepository + sqlRestful playlistId string playlist *model.Playlist playlistRepo *playlistRepository } -type dbPlaylistTrack struct { - dbMediaFile - *model.PlaylistTrack `structs:",flatten"` -} - -func (t *dbPlaylistTrack) PostScan() error { - if err := t.dbMediaFile.PostScan(); err != nil { - return err - } - t.PlaylistTrack.MediaFile = *t.dbMediaFile.MediaFile - t.PlaylistTrack.MediaFile.ID = t.MediaFileID - return nil -} - -type dbPlaylistTracks []dbPlaylistTrack - -func (t dbPlaylistTracks) toModels() model.PlaylistTracks { - return slice.Map(t, func(trk dbPlaylistTrack) model.PlaylistTrack { - return *trk.PlaylistTrack - }) -} - -func (r *playlistRepository) Tracks(ctx context.Context, playlistId string, refreshSmartPlaylist bool) model.PlaylistTrackRepository { +func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool) model.PlaylistTrackRepository { p := &playlistTrackRepository{} p.playlistRepo = r p.playlistId = playlistId - p.db = r.db + p.ctx = r.ctx + p.ormer = r.ormer p.tableName = "playlist_tracks" - p.registerModel(&model.PlaylistTrack{}, map[string]filterFunc{ - "missing": booleanFilter, - "library_id": libraryIdFilter, - }) - p.setSortMappings( - map[string]string{ - "id": "playlist_tracks.id", - "artist": "order_artist_name", - "album_artist": "order_album_artist_name", - "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, " + naturalSort("f.title"), - "title": "order_title", - "random": "random()", - // To make sure these fields will be whitelisted - "duration": "duration", - "year": "year", - "bpm": "bpm", - "channels": "channels", - }, - "f") // TODO I don't like this solution, but I won't change it now as it's not the focus of BFR. - - pls, err := r.Get(ctx, playlistId) + p.sortMappings = map[string]string{ + "id": "playlist_tracks.id", + } + pls, err := r.Get(playlistId) if err != nil { - log.Warn(ctx, "Error getting playlist's tracks", "playlistId", playlistId, err) return nil } if refreshSmartPlaylist { - r.refreshSmartPlaylist(ctx, pls) + r.refreshSmartPlaylist(pls) } p.playlist = pls return p } -func (r *playlistTrackRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - query := Select(). - Join("media_file f on f.id = media_file_id"). - Where(Eq{"playlist_id": r.playlistId}) - query = r.applyLibraryFilter(ctx, query, "f") - return r.count(ctx, query, options...) +func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.count(Select().Where(Eq{"playlist_id": r.playlistId}), r.parseRestOptions(options...)) } -func (r *playlistTrackRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - query := Select(). - LeftJoin("media_file f on f.id = media_file_id"). - Where(Eq{"playlist_id": r.playlistId}) - query = r.applyLibraryFilter(ctx, query, "f") - return r.count(ctx, query, r.parseRestOptions(ctx, options...)) -} - -func (r *playlistTrackRepository) Read(ctx context.Context, id string) (*model.PlaylistTrack, error) { - userID := loggedUser(ctx).ID - sel := r.newSelect(ctx). +func (r *playlistTrackRepository) Read(id string) (interface{}, error) { + sel := r.newSelect(). LeftJoin("annotation on ("+ "annotation.item_id = media_file_id"+ " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = '"+userID+"')"). - Columns( - "coalesce(starred, 0) as starred", - "coalesce(play_count, 0) as play_count", - "coalesce(rating, 0) as rating", - "starred_at", - "play_date", - "rated_at", - "f.*", - "playlist_tracks.*", - ). + " AND annotation.user_id = '"+userId(r.ctx)+"')"). + Columns("starred", "starred_at", "play_count", "play_date", "rating", "f.*", "playlist_tracks.*"). Join("media_file f on f.id = media_file_id"). - Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}}) - sel = r.applyLibraryFilter(ctx, sel, "f") - var trk dbPlaylistTrack - err := r.queryOne(ctx, sel, &trk) - return trk.PlaylistTrack, err + Where(And{Eq{"playlist_id": r.playlistId}, Eq{"id": id}}) + var trk model.PlaylistTrack + err := r.queryOne(sel, &trk) + return &trk, err } -func (r *playlistTrackRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.PlaylistTracks, error) { - tracks, err := r.playlistRepo.loadTracks(ctx, r.newSelect(ctx, options...), r.playlistId) +func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.PlaylistTracks, error) { + tracks, err := r.playlistRepo.loadTracks(r.newSelect(options...), r.playlistId) if err != nil { return nil, err } + mfs := tracks.MediaFiles() + err = r.loadMediaFileGenres(&mfs) + if err != nil { + log.Error(r.ctx, "Error loading genres for playlist", "playlist", r.playlist.Name, "id", r.playlist.ID, err) + return nil, err + } + for i, mf := range mfs { + tracks[i].MediaFile.Genres = mf.Genres + } return tracks, err } -func (r *playlistTrackRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { - sel := r.playlistRepo.tracksQuery(ctx, r.newSelect(ctx, options...), r.playlistId) - cursor, err := queryWithStableResults[dbPlaylistTrack](ctx, r.sqlRepository, sel) - if err != nil { - return nil, err - } - tracks := wrapCursor(cursor, func(t dbPlaylistTrack) *model.PlaylistTrack { - return t.PlaylistTrack - }) - return model.PlaylistTrackCursor(hydrateCursor(tracks, func(batch []model.PlaylistTrack) { - hydratePlaylistTrackArtwork(ctx, r.db, batch) - })), nil -} - -// GetMediaFileIDs returns the tracks' song ids, for callers that need every id but no track data. -func (r *playlistTrackRepository) GetMediaFileIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { - query := r.newSelect(ctx, options...).Columns("media_file_id"). - Join("media_file f on f.id = media_file_id"). - Where(Eq{"playlist_id": r.playlistId}) - query = r.applyLibraryFilter(ctx, query, "f") - var ids []string - if err := r.queryAllSlice(ctx, query, &ids); err != nil { - return nil, err - } - return ids, nil -} - -func (r *playlistTrackRepository) GetAlbumIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) { - query := r.newSelect(ctx, options...).Columns("distinct mf.album_id"). +func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]string, error) { + sql := r.newSelect(options...).Columns("distinct mf.album_id"). Join("media_file mf on mf.id = media_file_id"). Where(Eq{"playlist_id": r.playlistId}) - query = r.applyLibraryFilter(ctx, query, "mf") var ids []string - err := r.queryAllSlice(ctx, query, &ids) + err := r.queryAll(sql, &ids) if err != nil { return nil, err } return ids, nil } -func (r *playlistTrackRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.PlaylistTrack, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return r.GetAll(r.parseRestOptions(options...)) } -func (r *playlistTrackRepository) Add(ctx context.Context, mediaFileIds []string) (int, error) { +func (r *playlistTrackRepository) EntityName() string { + return "playlist_tracks" +} + +func (r *playlistTrackRepository) NewInstance() interface{} { + return &model.PlaylistTrack{} +} + +func (r *playlistTrackRepository) isTracksEditable() bool { + return r.playlistRepo.isWritable(r.playlistId) && !r.playlist.IsSmartPlaylist() +} + +func (r *playlistTrackRepository) Add(mediaFileIds []string) (int, error) { + if !r.isTracksEditable() { + return 0, rest.ErrPermissionDenied + } + if len(mediaFileIds) > 0 { - log.Debug(ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds) + log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds) } else { return 0, nil } // Get next pos (ID) in playlist - sq := r.newSelect(ctx).Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}) - var res struct{ Max sql.NullInt32 } - if err := r.queryOne(ctx, sq, &res); err != nil { - return 0, err - } - - return r.playlistRepo.addTracks(ctx, r.playlistId, int(res.Max.Int32+1), mediaFileIds) -} - -// Insert adds tracks before the 1-based position pos, shifting the following entries down; a -// position past the end appends. Callers must run it in a transaction. -func (r *playlistTrackRepository) Insert(ctx context.Context, mediaFileIds []string, pos int) (int, error) { - if len(mediaFileIds) == 0 { - return 0, nil - } - pos = max(pos, 1) - n := len(mediaFileIds) - // Negate while shifting, so no intermediate row hits the unique (playlist_id, id) index. - _, err := r.executeSQL(ctx, Expr(`UPDATE playlist_tracks SET id = -(id + ?) WHERE playlist_id = ? AND id >= ?`, n, r.playlistId, pos)) + sql := r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}) + var max int + err := r.queryOne(sql, &max) if err != nil { return 0, err } - res, err := r.executeSQL(ctx, Expr(`UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0`, r.playlistId)) - if err != nil { - return 0, err - } - if res == 0 { - return r.Add(ctx, mediaFileIds) - } - inserted, err := r.playlistRepo.addTracks(ctx, r.playlistId, pos, mediaFileIds) - if err != nil || inserted == n { - return inserted, err - } - // The shift above reserved a slot per requested id, so ids dropped by the library filter - // leave a hole. Close it. - return inserted, r.playlistRepo.renumber(ctx, r.playlistId) + + return len(mediaFileIds), r.playlistRepo.addTracks(r.playlistId, max+1, mediaFileIds) } -func (r *playlistTrackRepository) addMediaFileIds(ctx context.Context, cond Sqlizer) (int, error) { +func (r *playlistTrackRepository) addMediaFileIds(cond Sqlizer) (int, error) { sq := Select("id").From("media_file").Where(cond).OrderBy("album_artist, album, release_date, disc_number, track_number") var ids []string - err := r.queryAllSlice(ctx, sq, &ids) + err := r.queryAll(sq, &ids) if err != nil { - log.Error(ctx, "Error getting tracks to add to playlist", err) + log.Error(r.ctx, "Error getting tracks to add to playlist", err) return 0, err } - return r.Add(ctx, ids) + return r.Add(ids) } -func (r *playlistTrackRepository) AddAlbums(ctx context.Context, albumIds []string) (int, error) { - return r.addMediaFileIds(ctx, Eq{"album_id": albumIds}) +func (r *playlistTrackRepository) AddAlbums(albumIds []string) (int, error) { + return r.addMediaFileIds(Eq{"album_id": albumIds}) } -func (r *playlistTrackRepository) AddArtists(ctx context.Context, artistIds []string) (int, error) { - // Match by album-artist participation, not the deprecated album_artist_id - // column, which only holds the first album artist. - return r.addMediaFileIds(ctx, ParticipantIDFilter("media_file", artistIds, model.RoleAlbumArtist)) +func (r *playlistTrackRepository) AddArtists(artistIds []string) (int, error) { + return r.addMediaFileIds(Eq{"album_artist_id": artistIds}) } -func (r *playlistTrackRepository) AddDiscs(ctx context.Context, discs []model.DiscID) (int, error) { +func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) { if len(discs) == 0 { return 0, nil } @@ -247,80 +149,55 @@ func (r *playlistTrackRepository) AddDiscs(ctx context.Context, discs []model.Di for _, d := range discs { clauses = append(clauses, And{Eq{"album_id": d.AlbumID}, Eq{"release_date": d.ReleaseDate}, Eq{"disc_number": d.DiscNumber}}) } - return r.addMediaFileIds(ctx, clauses) + return r.addMediaFileIds(clauses) } -// deleteChunkSize keeps each DELETE under SQLITE_MAX_VARIABLE_NUMBER, matching addTracks. -const deleteChunkSize = 200 - -func (r *playlistTrackRepository) Delete(ctx context.Context, ids ...string) error { - for chunk := range slices.Chunk(ids, deleteChunkSize) { - if err := r.delete(ctx, And{Eq{"playlist_id": r.playlistId}, Eq{"id": chunk}}); err != nil { - return err - } +// Get ids from all current tracks +func (r *playlistTrackRepository) getTracks() ([]string, error) { + all := r.newSelect().Columns("media_file_id").Where(Eq{"playlist_id": r.playlistId}).OrderBy("id") + var ids []string + err := r.queryAll(all, &ids) + if err != nil { + log.Error(r.ctx, "Error querying current tracks from playlist", "playlistId", r.playlistId, err) + return nil, err } - - return r.playlistRepo.renumber(ctx, r.playlistId) + return ids, nil } -func (r *playlistTrackRepository) DeleteAll(ctx context.Context) error { - err := r.delete(ctx, Eq{"playlist_id": r.playlistId}) +func (r *playlistTrackRepository) Delete(ids ...string) error { + if !r.isTracksEditable() { + return rest.ErrPermissionDenied + } + err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}}) if err != nil { return err } - return r.playlistRepo.renumber(ctx, r.playlistId) + return r.playlistRepo.renumber(r.playlistId) } -// Reorder moves a track from pos to newPos, shifting other tracks accordingly. newPos is clamped -// to the playlist; a pos outside it is ErrNotFound, since shifting around it would leave a gap. -func (r *playlistTrackRepository) Reorder(ctx context.Context, pos int, newPos int) error { - var res struct{ Max sql.NullInt32 } - if err := r.queryOne(ctx, r.newSelect(ctx).Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}), &res); err != nil { - return err +func (r *playlistTrackRepository) DeleteAll() error { + if !r.isTracksEditable() { + return rest.ErrPermissionDenied } - last := int(res.Max.Int32) - if pos < 1 || pos > last { - return model.ErrNotFound - } - newPos = min(max(newPos, 1), last) - if pos == newPos { - return nil - } - pid := r.playlistId - - // Step 1: Move the source track out of the way (temporary sentinel value) - _, err := r.executeSQL(ctx, Expr( - `UPDATE playlist_tracks SET id = -999999 WHERE playlist_id = ? AND id = ?`, pid, pos)) + err := r.delete(Eq{"playlist_id": r.playlistId}) if err != nil { return err } - // Step 2: Shift the affected range using negative values to avoid unique constraint violations - if pos < newPos { - _, err = r.executeSQL(ctx, Expr( - `UPDATE playlist_tracks SET id = -(id - 1) WHERE playlist_id = ? AND id > ? AND id <= ?`, - pid, pos, newPos)) - } else { - _, err = r.executeSQL(ctx, Expr( - `UPDATE playlist_tracks SET id = -(id + 1) WHERE playlist_id = ? AND id >= ? AND id < ?`, - pid, newPos, pos)) + return r.playlistRepo.renumber(r.playlistId) +} + +func (r *playlistTrackRepository) Reorder(pos int, newPos int) error { + if !r.isTracksEditable() { + return rest.ErrPermissionDenied } + ids, err := r.getTracks() if err != nil { return err } - - // Step 3: Flip the shifted range back to positive - _, err = r.executeSQL(ctx, Expr( - `UPDATE playlist_tracks SET id = -id WHERE playlist_id = ? AND id < 0 AND id != -999999`, pid)) - if err != nil { - return err - } - - // Step 4: Place the source track at its new position - _, err = r.executeSQL(ctx, Expr( - `UPDATE playlist_tracks SET id = ? WHERE playlist_id = ? AND id = -999999`, newPos, pid)) - return err + newOrder := slice.Move(ids, pos-1, newPos-1) + return r.playlistRepo.updatePlaylist(r.playlistId, newOrder) } var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil) diff --git a/persistence/playlist_track_repository_test.go b/persistence/playlist_track_repository_test.go deleted file mode 100644 index 119c57116..000000000 --- a/persistence/playlist_track_repository_test.go +++ /dev/null @@ -1,407 +0,0 @@ -package persistence - -import ( - "context" - "strconv" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// sqliteMaxVariables is SQLITE_MAX_VARIABLE_NUMBER as compiled into the driver -const sqliteMaxVariables = 32766 - -var _ = Describe("PlaylistTrackRepository", func() { - var repo model.PlaylistTrackRepository - var ctx context.Context - - BeforeEach(func() { - ctx = log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPlaylistRepository(GetDBXBuilder()).Tracks(ctx, plsBest.ID, true) - }) - - Describe("GetCursor", func() { - It("yields the same tracks as GetAll", func() { - opts := model.QueryOptions{Sort: "id"} - want, err := repo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).To(HaveLen(2)) - - Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.PlaylistTrack(want))) - }) - - It("honors Max and Offset", func() { - opts := model.QueryOptions{Sort: "id", Max: 1, Offset: 1} - want, err := repo.GetAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - Expect(want).To(HaveLen(1)) - - Expect(collectCursor(repo.GetCursor(ctx, opts))).To(Equal([]model.PlaylistTrack(want))) - }) - }) - - Describe("GetAll", func() { - It("returns every row under a random sort, despite the integer id", func() { - // playlist_tracks.id is an INTEGER, so SEEDEDRAND drops every row unless it is cast to - // TEXT, and it fails silently: no error, just no rows. - all, err := repo.GetAll(ctx, model.QueryOptions{Sort: "random"}) - Expect(err).ToNot(HaveOccurred()) - Expect(all).To(HaveLen(2), "a random sort must not silently drop rows") - - got, err := repo.GetAll(ctx, model.QueryOptions{Sort: "random", Max: 1}) - Expect(err).ToNot(HaveOccurred()) - Expect(got).To(HaveLen(1)) - }) - }) - - Describe("CountAll", func() { - It("returns the number of tracks in the playlist", func() { - Expect(repo.CountAll(ctx)).To(Equal(int64(2))) - }) - - It("ignores Max and Offset", func() { - Expect(repo.CountAll(ctx, model.QueryOptions{Max: 1, Offset: 1})).To(Equal(int64(2))) - }) - }) - - Describe("GetMediaFileIDs", func() { - It("returns the song ids in playlist order", func() { - Expect(repo.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id"})). - To(Equal([]string{songDayInALife.ID, songRadioactivity.ID})) - }) - - It("honors Max and Offset", func() { - Expect(repo.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id", Max: 1, Offset: 1})). - To(Equal([]string{songRadioactivity.ID})) - }) - }) - - Describe("Insert", func() { - var tracks model.PlaylistTrackRepository - - BeforeEach(func() { - plsRepo := NewPlaylistRepository(GetDBXBuilder()) - pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"} - Expect(plsRepo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) }) - - tracks = plsRepo.Tracks(ctx, pls.ID, false) - Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2)) - }) - - order := func() []string { - ids, err := tracks.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id"}) - Expect(err).ToNot(HaveOccurred()) - return ids - } - - DescribeTable("inserts before a 1-based position, keeping the new tracks' order", - func(pos int, want func() []string) { - Expect(tracks.Insert(ctx, []string{songComeTogether.ID, songAntenna.ID}, pos)).To(Equal(2)) - Expect(order()).To(Equal(want())) - Expect(tracks.CountAll(ctx)).To(Equal(int64(4))) - }, - Entry("in the middle", 2, func() []string { - return []string{songDayInALife.ID, songComeTogether.ID, songAntenna.ID, songRadioactivity.ID} - }), - Entry("at the start, for zero or less", 0, func() []string { - return []string{songComeTogether.ID, songAntenna.ID, songDayInALife.ID, songRadioactivity.ID} - }), - Entry("at the end, past the last position", 9, func() []string { - return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID, songAntenna.ID} - }), - ) - - It("renumbers positions contiguously", func() { - Expect(tracks.Insert(ctx, []string{songComeTogether.ID}, 1)).To(Equal(1)) - all, err := tracks.GetAll(ctx, model.QueryOptions{Sort: "id"}) - Expect(err).ToNot(HaveOccurred()) - Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"})) - }) - }) - - Describe("Reorder", func() { - var tracks model.PlaylistTrackRepository - - BeforeEach(func() { - plsRepo := NewPlaylistRepository(GetDBXBuilder()) - pls := model.Playlist{Name: "Reorder", OwnerID: "userid", OwnerName: "userid"} - Expect(plsRepo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) }) - - tracks = plsRepo.Tracks(ctx, pls.ID, false) - Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})).To(Equal(3)) - }) - - rows := func() ([]string, []string) { - all, err := tracks.GetAll(ctx, model.QueryOptions{Sort: "id"}) - Expect(err).ToNot(HaveOccurred()) - var ids, songs []string - for _, t := range all { - ids = append(ids, t.ID) - songs = append(songs, t.MediaFileID) - } - return ids, songs - } - - DescribeTable("clamps the destination to the playlist", - func(newPos int, want func() []string) { - Expect(tracks.Reorder(ctx, 1, newPos)).To(Succeed()) - ids, songs := rows() - Expect(ids).To(Equal([]string{"1", "2", "3"})) - Expect(songs).To(Equal(want())) - }, - Entry("past the end moves to the end", 9, func() []string { - return []string{songRadioactivity.ID, songComeTogether.ID, songDayInALife.ID} - }), - Entry("below 1 stays first", -4, func() []string { - return []string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID} - }), - ) - - DescribeTable("rejects a source position outside the playlist, leaving rows untouched", - func(pos int) { - Expect(tracks.Reorder(ctx, pos, 1)).To(MatchError(model.ErrNotFound)) - ids, songs := rows() - Expect(ids).To(Equal([]string{"1", "2", "3"})) - Expect(songs).To(Equal([]string{songDayInALife.ID, songRadioactivity.ID, songComeTogether.ID})) - }, - Entry("past the end", 4), - Entry("zero", 0), - ) - }) - - Describe("Delete", func() { - var tracks model.PlaylistTrackRepository - const numTracks = deleteChunkSize*2 + 1 - - positionsUpTo := func(n int) []string { - positions := make([]string, 0, n) - for i := 1; i <= n; i++ { - positions = append(positions, strconv.Itoa(i)) - } - return positions - } - - BeforeEach(func() { - plsRepo := NewPlaylistRepository(GetDBXBuilder()) - - pls := model.Playlist{Name: "Chunked Delete", OwnerID: "userid", OwnerName: "userid"} - Expect(plsRepo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) }) - - tracks = plsRepo.Tracks(ctx, pls.ID, false) - songIds := make([]string, numTracks) - for i := range songIds { - songIds[i] = songDayInALife.ID - } - Expect(tracks.Add(ctx, songIds)).To(Equal(numTracks)) - }) - - It("removes positions spanning several chunks, and renumbers what is left", func() { - Expect(tracks.Delete(ctx, positionsUpTo(numTracks-1)...)).To(Succeed()) - - Expect(tracks.CountAll(ctx)).To(Equal(int64(1))) - remaining, err := tracks.GetAll(ctx, model.QueryOptions{Sort: "id"}) - Expect(err).ToNot(HaveOccurred()) - Expect(remaining[0].ID).To(Equal("1"), "the surviving track must be renumbered to position 1") - }) - - It("accepts more ids than SQLite allows as bind variables", func() { - Expect(tracks.Delete(ctx, positionsUpTo(sqliteMaxVariables+100)...)).To(Succeed()) - - Expect(tracks.CountAll(ctx)).To(BeZero()) - }) - }) - - Describe("AddArtists", func() { - var tracks model.PlaylistTrackRepository - var joint model.MediaFile - - BeforeEach(func() { - mfRepo := NewMediaFileRepository(GetDBXBuilder()) - joint = mf(model.MediaFile{ID: "pls-coartist-track", Title: "Joint Track", ArtistID: artistPunctuation.ID, - Artist: artistPunctuation.Name, AlbumID: "pls-coartist-album", Album: "Joint Album", - AlbumArtistID: artistKraftwerk.ID, AlbumArtist: artistKraftwerk.Name, Path: p("joint/track.mp3")}) - joint.Participants[model.RoleAlbumArtist] = model.ParticipantList{ - {Artist: artistKraftwerk}, - {Artist: artistBeatles}, - } - Expect(mfRepo.Put(ctx, &joint)).To(Succeed()) - DeferCleanup(func() { _ = mfRepo.Delete(ctx, joint.ID) }) - - plsRepo := NewPlaylistRepository(GetDBXBuilder()) - pls := model.Playlist{Name: "Co-album-artist", OwnerID: adminUser.ID, OwnerName: adminUser.UserName} - Expect(plsRepo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { _ = plsRepo.Delete(ctx, pls.ID) }) - tracks = plsRepo.Tracks(ctx, pls.ID, false) - }) - - It("adds tracks where the artist is the first album artist", func() { - Expect(tracks.AddArtists(ctx, []string{artistKraftwerk.ID})).To(Equal(1)) - Expect(tracks.GetMediaFileIDs(ctx)).To(ConsistOf(joint.ID)) - }) - - It("adds tracks where the artist is not the first album artist", func() { - Expect(tracks.AddArtists(ctx, []string{artistBeatles.ID})).To(Equal(1)) - Expect(tracks.GetMediaFileIDs(ctx)).To(ConsistOf(joint.ID)) - }) - - It("does not add tracks where the artist is only the track artist", func() { - Expect(tracks.AddArtists(ctx, []string{artistPunctuation.ID})).To(Equal(0)) - Expect(tracks.GetMediaFileIDs(ctx)).To(BeEmpty()) - }) - }) - - Describe("library access", func() { - var otherLib model.Library - var restrictedUser model.User - var adminCtx, userCtx context.Context - var userTracks model.PlaylistTrackRepository - var plsID string - - BeforeEach(func() { - adminCtx, otherLib, restrictedUser = restrictedFixture("pls") - userCtx = request.WithUser(log.NewContext(GinkgoT().Context()), restrictedUser) - db := GetDBXBuilder() - - adminMr := NewMediaFileRepository(db) - Expect(adminMr.Put(adminCtx, &model.MediaFile{ - ID: "pls-otherlib-track", LibraryID: otherLib.ID, AlbumID: "pls-hidden-album", - Path: "hidden/in-playlist.mp3", Title: "Hidden In Playlist", - })).To(Succeed()) - DeferCleanup(func() { _ = adminMr.Delete(adminCtx, "pls-otherlib-track") }) - - adminPls := NewPlaylistRepository(db) - pls := model.Playlist{Name: "Public Mixed", OwnerID: adminUser.ID, OwnerName: adminUser.UserName, Public: true} - Expect(adminPls.Put(adminCtx, &pls)).To(Succeed()) - plsID = pls.ID - DeferCleanup(func() { _ = adminPls.Delete(adminCtx, plsID) }) - Expect(adminPls.Tracks(adminCtx, plsID, false).Add(adminCtx, []string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(2)) - - userTracks = NewPlaylistRepository(db).Tracks(userCtx, plsID, false) - }) - - It("Read does not return a track outside the user's libraries", func() { - _, err := userTracks.Read(userCtx, "2") - Expect(err).To(MatchError(model.ErrNotFound), "position 2 holds a track the user cannot access") - }) - - It("Read still returns a track inside the user's libraries", func() { - trk, err := userTracks.Read(userCtx, "1") - Expect(err).ToNot(HaveOccurred()) - Expect(trk.MediaFile.ID).To(Equal(songDayInALife.ID)) - }) - - It("Count excludes tracks outside the user's libraries", func() { - Expect(userTracks.Count(userCtx)).To(Equal(int64(1)), "Count must agree with the filtered listing") - }) - - It("GetAlbumIDs excludes albums outside the user's libraries", func() { - Expect(userTracks.GetAlbumIDs(userCtx)).ToNot(ContainElement("pls-hidden-album")) - }) - - Describe("Add", func() { - var ownTracks model.PlaylistTrackRepository - - BeforeEach(func() { - userPls := NewPlaylistRepository(GetDBXBuilder()) - own := model.Playlist{Name: "Own Playlist", OwnerID: restrictedUser.ID, OwnerName: restrictedUser.UserName} - Expect(userPls.Put(userCtx, &own)).To(Succeed()) - DeferCleanup(func() { _ = NewPlaylistRepository(GetDBXBuilder()).Delete(adminCtx, own.ID) }) - ownTracks = userPls.Tracks(userCtx, own.ID, false) - }) - - It("drops ids outside the user's libraries", func() { - Expect(ownTracks.Add(userCtx, []string{songDayInALife.ID, "pls-otherlib-track"})).To(Equal(1)) - Expect(ownTracks.GetMediaFileIDs(userCtx)).To(ConsistOf(songDayInALife.ID)) - }) - - It("drops them when reached through AddAlbums", func() { - Expect(ownTracks.AddAlbums(userCtx, []string{"pls-hidden-album"})).To(BeZero()) - }) - - It("drops them when reached through Insert", func() { - Expect(ownTracks.Add(userCtx, []string{songDayInALife.ID})).To(Equal(1)) - - Expect(ownTracks.Insert(userCtx, []string{"pls-otherlib-track", songComeTogether.ID}, 1)).To(Equal(1)) - - Expect(ownTracks.GetMediaFileIDs(userCtx)).To(Equal([]string{songComeTogether.ID, songDayInALife.ID})) - trks, err := ownTracks.GetAll(userCtx, model.QueryOptions{Sort: "id"}) - Expect(err).ToNot(HaveOccurred()) - Expect(slice.Map(trks, func(t model.PlaylistTrack) string { return t.ID })).To(Equal([]string{"1", "2"}), - "positions must stay contiguous when an id is dropped") - }) - }) - - Describe("Put", func() { - storedIDs := func(id string) []string { - ids, err := NewPlaylistRepository(GetDBXBuilder()).Tracks(adminCtx, id, false).GetMediaFileIDs(adminCtx) - Expect(err).ToNot(HaveOccurred()) - return ids - } - put := func(ctx context.Context, owner model.User, pls *model.Playlist, ids ...string) string { - pls.OwnerID = owner.ID - pls.Tracks = nil - pls.AddMediaFilesByID(ids) - Expect(NewPlaylistRepository(GetDBXBuilder()).Put(ctx, pls)).To(Succeed()) - DeferCleanup(func() { _ = NewPlaylistRepository(GetDBXBuilder()).Delete(adminCtx, pls.ID) }) - return pls.ID - } - - It("drops ids outside the user's libraries when creating a playlist", func() { - id := put(userCtx, restrictedUser, &model.Playlist{Name: "Created"}, songDayInALife.ID, "pls-otherlib-track") - - Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID})) - }) - - It("drops them when replacing the tracks of an existing playlist", func() { - pls := &model.Playlist{Name: "Replaced"} - put(userCtx, restrictedUser, pls, songDayInALife.ID) - - put(userCtx, restrictedUser, pls, "pls-otherlib-track") - - Expect(storedIDs(pls.ID)).To(BeEmpty()) - }) - - It("does not count a dropped id, so it cannot be told apart from an unknown one", func() { - hidden := put(userCtx, restrictedUser, &model.Playlist{Name: "Hidden"}, songDayInALife.ID, "pls-otherlib-track") - unknown := put(userCtx, restrictedUser, &model.Playlist{Name: "Unknown"}, songDayInALife.ID, "no-such-track") - - userPls := NewPlaylistRepository(GetDBXBuilder()) - h, err := userPls.Get(userCtx, hidden) - Expect(err).ToNot(HaveOccurred()) - u, err := userPls.Get(userCtx, unknown) - Expect(err).ToNot(HaveOccurred()) - Expect(h.SongCount).To(Equal(u.SongCount)) - Expect(h.Duration).To(Equal(u.Duration)) - Expect(h.Size).To(Equal(u.Size)) - }) - - It("keeps order and duplicates of the accessible ids", func() { - id := put(userCtx, restrictedUser, &model.Playlist{Name: "Ordered"}, - songDayInALife.ID, "pls-otherlib-track", songComeTogether.ID, songDayInALife.ID) - - Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, songComeTogether.ID, songDayInALife.ID})) - }) - - It("keeps every id when run as an admin, as the scanner's playlist sync does", func() { - id := put(adminCtx, adminUser, &model.Playlist{Name: "Synced"}, songDayInALife.ID, "pls-otherlib-track") - - Expect(storedIDs(id)).To(Equal([]string{songDayInALife.ID, "pls-otherlib-track"})) - }) - }) - - It("still shows everything to an admin", func() { - adminTracks := NewPlaylistRepository(GetDBXBuilder()).Tracks(adminCtx, plsID, false) - Expect(adminTracks.Count(adminCtx)).To(Equal(int64(2))) - _, err := adminTracks.Read(adminCtx, "2") - Expect(err).ToNot(HaveOccurred()) - }) - }) -}) diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go index 9dc520c44..14bd1b9bd 100644 --- a/persistence/playqueue_repository.go +++ b/persistence/playqueue_repository.go @@ -2,32 +2,32 @@ package persistence import ( "context" - "errors" "strings" "time" . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "github.com/pocketbase/dbx" + "github.com/navidrome/navidrome/utils" ) type playQueueRepository struct { sqlRepository } -func NewPlayQueueRepository(db dbx.Builder) model.PlayQueueRepository { +func NewPlayQueueRepository(ctx context.Context, o orm.QueryExecutor) model.PlayQueueRepository { r := &playQueueRepository{} - r.db = db + r.ctx = ctx + r.ormer = o r.tableName = "playqueue" return r } type playQueue struct { - ID string `structs:"id"` - UserID string `structs:"user_id"` - Current int `structs:"current"` + ID string `structs:"id" orm:"column(id)"` + UserID string `structs:"user_id" orm:"column(user_id)"` + Current string `structs:"current"` Position int64 `structs:"position"` ChangedBy string `structs:"changed_by"` Items string `structs:"items"` @@ -35,60 +35,32 @@ type playQueue struct { UpdatedAt time.Time `structs:"updated_at"` } -func (r *playQueueRepository) Store(ctx context.Context, q *model.PlayQueue, colNames ...string) error { - u := loggedUser(ctx) - - // Always find existing playqueue for this user - existingQueue, err := r.Retrieve(ctx, q.UserID) - if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Error(ctx, "Error retrieving existing playqueue", "user", u.UserName, err) +func (r *playQueueRepository) Store(q *model.PlayQueue) error { + u := loggedUser(r.ctx) + err := r.clearPlayQueue(q.UserID) + if err != nil { + log.Error(r.ctx, "Error deleting previous playqueue", "user", u.UserName, err) return err } - - // Use existing ID if found, otherwise keep the provided ID (which may be empty for new records) - if !errors.Is(err, model.ErrNotFound) && existingQueue.ID != "" { - q.ID = existingQueue.ID - } - - // When no specific columns are provided, we replace the whole queue - if len(colNames) == 0 { - err := r.clearPlayQueue(ctx, q.UserID) - if err != nil { - log.Error(ctx, "Error deleting previous playqueue", "user", u.UserName, err) - return err - } - if len(q.Items) == 0 { - return nil - } - } - pq := r.fromModel(q) if pq.ID == "" { pq.CreatedAt = time.Now() } pq.UpdatedAt = time.Now() - _, err = r.put(ctx, pq.ID, pq, colNames...) + _, err = r.put(pq.ID, pq) if err != nil { - log.Error(ctx, "Error saving playqueue", "user", u.UserName, err) + log.Error(r.ctx, "Error saving playqueue", "user", u.UserName, err) return err } return nil } -func (r *playQueueRepository) RetrieveWithMediaFiles(ctx context.Context, userId string) (*model.PlayQueue, error) { - sel := r.newSelect(ctx).Columns("*").Where(Eq{"user_id": userId}) +func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error) { + sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId}) var res playQueue - err := r.queryOne(ctx, sel, &res) - q := r.toModel(&res) - q.Items = r.loadTracks(ctx, q.Items) - return &q, err -} - -func (r *playQueueRepository) Retrieve(ctx context.Context, userId string) (*model.PlayQueue, error) { - sel := r.newSelect(ctx).Columns("*").Where(Eq{"user_id": userId}) - var res playQueue - err := r.queryOne(ctx, sel, &res) - return new(r.toModel(&res)), err + err := r.queryOne(sel, &res) + pls := r.toModel(&res) + return &pls, err } func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue { @@ -120,34 +92,38 @@ func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue { UpdatedAt: pq.UpdatedAt, } if strings.TrimSpace(pq.Items) != "" { - tracks := strings.SplitSeq(pq.Items, ",") - for t := range tracks { + tracks := strings.Split(pq.Items, ",") + for _, t := range tracks { q.Items = append(q.Items, model.MediaFile{ID: t}) } } + q.Items = r.loadTracks(q.Items) return q } -// loadTracks loads the tracks from the database. It receives a list of track IDs and returns a list of MediaFiles -// in the same order as the input list. -func (r *playQueueRepository) loadTracks(ctx context.Context, tracks model.MediaFiles) model.MediaFiles { +func (r *playQueueRepository) loadTracks(tracks model.MediaFiles) model.MediaFiles { if len(tracks) == 0 { return nil } - mfRepo := NewMediaFileRepository(r.db) + // Collect all ids + ids := make([]string, len(tracks)) + for i, t := range tracks { + ids[i] = t.ID + } + + // Break the list in chunks, up to 50 items, to avoid hitting SQLITE_MAX_FUNCTION_ARG limit + chunks := utils.BreakUpStringSlice(ids, 50) + + // Query each chunk of media_file ids and store results in a map + mfRepo := NewMediaFileRepository(r.ctx, r.ormer) trackMap := map[string]model.MediaFile{} - - // Create an iterator to collect all track IDs - ids := slice.SeqFunc(tracks, func(t model.MediaFile) string { return t.ID }) - - // Break the list in chunks, up to 500 items, to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit - for chunk := range slice.CollectChunks(ids, 500) { - idsFilter := Eq{"media_file.id": chunk} - tracks, err := mfRepo.GetAll(ctx, model.QueryOptions{Filters: idsFilter}) + for i := range chunks { + idsFilter := Eq{"media_file.id": chunks[i]} + tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: idsFilter}) if err != nil { - u := loggedUser(ctx) - log.Error(ctx, "Could not load playqueue/bookmark's tracks", "user", u.UserName, err) + u := loggedUser(r.ctx) + log.Error(r.ctx, "Could not load playqueue/bookmark's tracks", "user", u.UserName, err) } for _, t := range tracks { trackMap[t.ID] = t @@ -155,22 +131,15 @@ func (r *playQueueRepository) loadTracks(ctx context.Context, tracks model.Media } // Create a new list of tracks with the same order as the original - // Exclude tracks that are not in the DB anymore - newTracks := make(model.MediaFiles, 0, len(tracks)) - for _, t := range tracks { - if track, ok := trackMap[t.ID]; ok { - newTracks = append(newTracks, track) - } + newTracks := make(model.MediaFiles, len(tracks)) + for i, t := range tracks { + newTracks[i] = trackMap[t.ID] } return newTracks } -func (r *playQueueRepository) clearPlayQueue(ctx context.Context, userId string) error { - return r.delete(ctx, Eq{"user_id": userId}) -} - -func (r *playQueueRepository) Clear(ctx context.Context, userId string) error { - return r.clearPlayQueue(ctx, userId) +func (r *playQueueRepository) clearPlayQueue(userId string) error { + return r.delete(Eq{"user_id": userId}) } var _ model.PlayQueueRepository = (*playQueueRepository)(nil) diff --git a/persistence/playqueue_repository_test.go b/persistence/playqueue_repository_test.go index 877faddfc..e1103dfae 100644 --- a/persistence/playqueue_repository_test.go +++ b/persistence/playqueue_repository_test.go @@ -5,10 +5,10 @@ import ( "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf/configtest" + "github.com/beego/beego/v2/client/orm" + "github.com/google/uuid" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -16,391 +16,47 @@ import ( var _ = Describe("PlayQueueRepository", func() { var repo model.PlayQueueRepository - var ctx context.Context BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPlayQueueRepository(GetDBXBuilder()) + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "user1", UserName: "user1", IsAdmin: true}) + repo = NewPlayQueueRepository(ctx, orm.NewOrm()) }) - Describe("Store", func() { - It("stores a complete playqueue", func() { - expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, expected)).To(Succeed()) - - actual, err := repo.RetrieveWithMediaFiles(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - AssertPlayQueue(expected, actual) - Expect(countPlayQueues(repo, "userid")).To(Equal(1)) - }) - - It("replaces existing playqueue when storing without column names", func() { - By("Storing initial playqueue") - initial := aPlayQueue("userid", 0, 100, songComeTogether) - Expect(repo.Store(ctx, initial)).To(Succeed()) - - By("Storing replacement playqueue") - replacement := aPlayQueue("userid", 1, 200, songDayInALife, songAntenna) - Expect(repo.Store(ctx, replacement)).To(Succeed()) - - actual, err := repo.RetrieveWithMediaFiles(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - AssertPlayQueue(replacement, actual) - Expect(countPlayQueues(repo, "userid")).To(Equal(1)) - }) - - It("clears playqueue when storing empty items", func() { - By("Storing initial playqueue") - initial := aPlayQueue("userid", 0, 100, songComeTogether) - Expect(repo.Store(ctx, initial)).To(Succeed()) - - By("Storing empty playqueue") - empty := aPlayQueue("userid", 0, 0) - Expect(repo.Store(ctx, empty)).To(Succeed()) - - By("Verifying playqueue is cleared") - _, err := repo.Retrieve(ctx, "userid") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("updates only current field when specified", func() { - By("Storing initial playqueue") - initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, initial)).To(Succeed()) - - By("Getting the existing playqueue to obtain its ID") - existing, err := repo.Retrieve(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - - By("Updating only current field") - update := &model.PlayQueue{ - ID: existing.ID, // Use existing ID for partial update - UserID: "userid", - Current: 1, - ChangedBy: "test-update", - } - Expect(repo.Store(ctx, update, "current")).To(Succeed()) - - By("Verifying only current was updated") - actual, err := repo.Retrieve(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - Expect(actual.Current).To(Equal(1)) - Expect(actual.Position).To(Equal(int64(100))) // Should remain unchanged - Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged - }) - - It("updates only position field when specified", func() { - By("Storing initial playqueue") - initial := aPlayQueue("userid", 1, 100, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, initial)).To(Succeed()) - - By("Getting the existing playqueue to obtain its ID") - existing, err := repo.Retrieve(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - - By("Updating only position field") - update := &model.PlayQueue{ - ID: existing.ID, // Use existing ID for partial update - UserID: "userid", - Position: 500, - ChangedBy: "test-update", - } - Expect(repo.Store(ctx, update, "position")).To(Succeed()) - - By("Verifying only position was updated") - actual, err := repo.Retrieve(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - Expect(actual.Position).To(Equal(int64(500))) - Expect(actual.Current).To(Equal(1)) // Should remain unchanged - Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged - }) - - It("updates multiple specified fields", func() { - By("Storing initial playqueue") - initial := aPlayQueue("userid", 0, 100, songComeTogether) - Expect(repo.Store(ctx, initial)).To(Succeed()) - - By("Getting the existing playqueue to obtain its ID") - existing, err := repo.Retrieve(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - - By("Updating current and position fields") - update := &model.PlayQueue{ - ID: existing.ID, // Use existing ID for partial update - UserID: "userid", - Current: 1, - Position: 300, - ChangedBy: "test-update", - } - Expect(repo.Store(ctx, update, "current", "position")).To(Succeed()) - - By("Verifying both fields were updated") - actual, err := repo.Retrieve(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - Expect(actual.Current).To(Equal(1)) - Expect(actual.Position).To(Equal(int64(300))) - Expect(actual.Items).To(HaveLen(1)) // Should remain unchanged - }) - - It("preserves existing data when updating with empty items list and column names", func() { - By("Storing initial playqueue") - initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, initial)).To(Succeed()) - - By("Getting the existing playqueue to obtain its ID") - existing, err := repo.Retrieve(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - - By("Updating only position with empty items") - update := &model.PlayQueue{ - ID: existing.ID, // Use existing ID for partial update - UserID: "userid", - Position: 200, - ChangedBy: "test-update", - Items: []model.MediaFile{}, // Empty items - } - Expect(repo.Store(ctx, update, "position")).To(Succeed()) - - By("Verifying items are preserved") - actual, err := repo.Retrieve(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - Expect(actual.Position).To(Equal(int64(200))) - Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged - }) - - It("ensures only one record per user by reusing existing record ID", func() { - By("Storing initial playqueue") - initial := aPlayQueue("userid", 0, 100, songComeTogether) - Expect(repo.Store(ctx, initial)).To(Succeed()) - initialCount := countPlayQueues(repo, "userid") - Expect(initialCount).To(Equal(1)) - - By("Storing another playqueue with different ID but same user") - different := aPlayQueue("userid", 1, 200, songDayInALife) - different.ID = "different-id" // Force a different ID - Expect(repo.Store(ctx, different)).To(Succeed()) - - By("Verifying only one record exists for the user") - finalCount := countPlayQueues(repo, "userid") - Expect(finalCount).To(Equal(1)) - - By("Verifying the record was updated, not duplicated") - actual, err := repo.Retrieve(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - Expect(actual.Current).To(Equal(1)) // Should be updated value - Expect(actual.Position).To(Equal(int64(200))) // Should be updated value - Expect(actual.Items).To(HaveLen(1)) // Should be new items - Expect(actual.Items[0].ID).To(Equal(songDayInALife.ID)) - }) - - It("ensures only one record per user even with partial updates", func() { - By("Storing initial playqueue") - initial := aPlayQueue("userid", 0, 100, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, initial)).To(Succeed()) - initialCount := countPlayQueues(repo, "userid") - Expect(initialCount).To(Equal(1)) - - By("Storing partial update with different ID but same user") - partialUpdate := &model.PlayQueue{ - ID: "completely-different-id", // Use a completely different ID - UserID: "userid", - Current: 1, - ChangedBy: "test-partial", - } - Expect(repo.Store(ctx, partialUpdate, "current")).To(Succeed()) - - By("Verifying only one record still exists for the user") - finalCount := countPlayQueues(repo, "userid") - Expect(finalCount).To(Equal(1)) - - By("Verifying the existing record was updated with new current value") - actual, err := repo.Retrieve(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - Expect(actual.Current).To(Equal(1)) // Should be updated value - Expect(actual.Position).To(Equal(int64(100))) // Should remain unchanged - Expect(actual.Items).To(HaveLen(2)) // Should remain unchanged - }) - }) - - Describe("Retrieve", func() { + Describe("PlayQueues", func() { It("returns notfound error if there's no playqueue for the user", func() { - _, err := repo.Retrieve(ctx, "user999") + _, err := repo.Retrieve("user999") Expect(err).To(MatchError(model.ErrNotFound)) }) - It("retrieves the playqueue with only track IDs (no full MediaFile data)", func() { + It("stores and retrieves the playqueue for the user", func() { By("Storing a playqueue for the user") - expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, expected)).To(Succeed()) + expected := aPlayQueue("user1", songDayInALife.ID, 123, songComeTogether, songDayInALife) + Expect(repo.Store(expected)).To(BeNil()) - actual, err := repo.Retrieve(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - - // Basic playqueue properties should match - Expect(actual.ID).To(Equal(expected.ID)) - Expect(actual.UserID).To(Equal(expected.UserID)) - Expect(actual.Current).To(Equal(expected.Current)) - Expect(actual.Position).To(Equal(expected.Position)) - Expect(actual.ChangedBy).To(Equal(expected.ChangedBy)) - Expect(actual.Items).To(HaveLen(len(expected.Items))) - - // Items should only contain IDs, not full MediaFile data - for i, item := range actual.Items { - Expect(item.ID).To(Equal(expected.Items[i].ID)) - // These fields should be empty since we're not loading full MediaFiles - Expect(item.Title).To(BeEmpty()) - Expect(item.Path).To(BeEmpty()) - Expect(item.Album).To(BeEmpty()) - Expect(item.Artist).To(BeEmpty()) - } - }) - - It("returns items with IDs even when some tracks don't exist in the DB", func() { - // Add a new song to the DB - newSong := songRadioactivity - newSong.ID = "temp-track" - newSong.Path = "/new-path" - mfRepo := NewMediaFileRepository(GetDBXBuilder()) - - Expect(mfRepo.Put(ctx, &newSong)).To(Succeed()) - - // Create a playqueue with the new song - pq := aPlayQueue("userid", 0, 0, newSong, songAntenna) - Expect(repo.Store(ctx, pq)).To(Succeed()) - - // Delete the new song from the database - Expect(mfRepo.Delete(ctx, "temp-track")).To(Succeed()) - - // Retrieve the playqueue with Retrieve method - actual, err := repo.Retrieve(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - - // The playqueue should still contain both track IDs (including the deleted one) - Expect(actual.Items).To(HaveLen(2)) - Expect(actual.Items[0].ID).To(Equal("temp-track")) - Expect(actual.Items[1].ID).To(Equal(songAntenna.ID)) - - // Items should only contain IDs, no other data - for _, item := range actual.Items { - Expect(item.Title).To(BeEmpty()) - Expect(item.Path).To(BeEmpty()) - Expect(item.Album).To(BeEmpty()) - Expect(item.Artist).To(BeEmpty()) - } - }) - }) - - Describe("RetrieveWithMediaFiles", func() { - It("returns notfound error if there's no playqueue for the user", func() { - _, err := repo.RetrieveWithMediaFiles(ctx, "user999") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("retrieves the playqueue with full MediaFile data", func() { - By("Storing a playqueue for the user") - - expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, expected)).To(Succeed()) - - actual, err := repo.RetrieveWithMediaFiles(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) + actual, err := repo.Retrieve("user1") + Expect(err).To(BeNil()) AssertPlayQueue(expected, actual) - }) - It("does not return tracks if they don't exist in the DB", func() { - // Add a new song to the DB - newSong := songRadioactivity - newSong.ID = "temp-track" - newSong.Path = "/new-path" - mfRepo := NewMediaFileRepository(GetDBXBuilder()) + By("Storing a new playqueue for the same user") - Expect(mfRepo.Put(ctx, &newSong)).To(Succeed()) + another := aPlayQueue("user1", songRadioactivity.ID, 321, songAntenna, songRadioactivity) + Expect(repo.Store(another)).To(BeNil()) - // Create a playqueue with the new song - pq := aPlayQueue("userid", 0, 0, newSong, songAntenna) - Expect(repo.Store(ctx, pq)).To(Succeed()) + actual, err = repo.Retrieve("user1") + Expect(err).To(BeNil()) - // Retrieve the playqueue - actual, err := repo.RetrieveWithMediaFiles(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - - // The playqueue should contain both tracks - AssertPlayQueue(pq, actual) - - // Delete the new song - Expect(mfRepo.Delete(ctx, "temp-track")).To(Succeed()) - - // Retrieve the playqueue - actual, err = repo.RetrieveWithMediaFiles(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - - // The playqueue should not contain the deleted track - Expect(actual.Items).To(HaveLen(1)) - Expect(actual.Items[0].ID).To(Equal(songAntenna.ID)) - }) - }) - - Describe("Clear", func() { - It("clears an existing playqueue", func() { - By("Storing a playqueue") - expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) - Expect(repo.Store(ctx, expected)).To(Succeed()) - - By("Verifying playqueue exists") - _, err := repo.Retrieve(ctx, "userid") - Expect(err).ToNot(HaveOccurred()) - - By("Clearing the playqueue") - Expect(repo.Clear(ctx, "userid")).To(Succeed()) - - By("Verifying playqueue is cleared") - _, err = repo.Retrieve(ctx, "userid") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("does not error when clearing non-existent playqueue", func() { - // Clear should not error even if no playqueue exists - Expect(repo.Clear(ctx, "nonexistent-user")).To(Succeed()) - }) - - It("only clears the specified user's playqueue", func() { - By("Creating users in the database to avoid foreign key constraints") - userRepo := NewUserRepository(GetDBXBuilder()) - user1 := &model.User{ID: "user1", UserName: "user1", Name: "User 1", Email: "user1@test.com"} - user2 := &model.User{ID: "user2", UserName: "user2", Name: "User 2", Email: "user2@test.com"} - Expect(userRepo.Put(ctx, user1)).To(Succeed()) - Expect(userRepo.Put(ctx, user2)).To(Succeed()) - - By("Storing playqueues for two users") - user1Queue := aPlayQueue("user1", 0, 100, songComeTogether) - user2Queue := aPlayQueue("user2", 1, 200, songDayInALife) - Expect(repo.Store(ctx, user1Queue)).To(Succeed()) - Expect(repo.Store(ctx, user2Queue)).To(Succeed()) - - By("Clearing only user1's playqueue") - Expect(repo.Clear(ctx, "user1")).To(Succeed()) - - By("Verifying user1's playqueue is cleared") - _, err := repo.Retrieve(ctx, "user1") - Expect(err).To(MatchError(model.ErrNotFound)) - - By("Verifying user2's playqueue still exists") - actual, err := repo.Retrieve(ctx, "user2") - Expect(err).ToNot(HaveOccurred()) - Expect(actual.UserID).To(Equal("user2")) - Expect(actual.Current).To(Equal(1)) - Expect(actual.Position).To(Equal(int64(200))) + AssertPlayQueue(another, actual) + Expect(countPlayQueues(repo, "user1")).To(Equal(1)) }) }) }) func countPlayQueues(repo model.PlayQueueRepository, userId string) int { r := repo.(*playQueueRepository) - c, err := r.count(GinkgoT().Context(), squirrel.Select().Where(squirrel.Eq{"user_id": userId})) + c, err := r.count(squirrel.Select().Where(squirrel.Eq{"user_id": userId})) if err != nil { panic(err) } @@ -419,11 +75,11 @@ func AssertPlayQueue(expected, actual *model.PlayQueue) { } } -func aPlayQueue(userId string, current int, position int64, items ...model.MediaFile) *model.PlayQueue { +func aPlayQueue(userId, current string, position int64, items ...model.MediaFile) *model.PlayQueue { createdAt := time.Now() updatedAt := createdAt.Add(time.Minute) return &model.PlayQueue{ - ID: id.NewRandom(), + ID: uuid.NewString(), UserID: userId, Current: current, Position: position, diff --git a/persistence/plugin_cleanup.go b/persistence/plugin_cleanup.go deleted file mode 100644 index 0202726e4..000000000 --- a/persistence/plugin_cleanup.go +++ /dev/null @@ -1,86 +0,0 @@ -package persistence - -import ( - "github.com/pocketbase/dbx" -) - -// cleanupPluginUserReferences removes a user ID from all plugins' users JSON arrays -// and auto-disables plugins that lose their only permitted user (when users permission is required). -// This is called from userRepository.Delete() to maintain referential integrity. -func cleanupPluginUserReferences(db dbx.Builder, userID string) error { - // SQLite JSON function: json_remove removes the element at the path where user matches. - // We use a subquery with json_each to find and remove the user ID from the array. - // This updates all plugins where the users array contains the given user ID. - _, err := db.NewQuery(` - UPDATE plugin - SET users = ( - SELECT json_group_array(value) - FROM json_each(plugin.users) - WHERE value != {:userID} - ), - updated_at = CURRENT_TIMESTAMP - WHERE users IS NOT NULL - AND users != '' - AND EXISTS (SELECT 1 FROM json_each(plugin.users) WHERE value = {:userID}) - `).Bind(dbx.Params{"userID": userID}).Execute() - if err != nil { - return err - } - - // Auto-disable plugins that: - // 1. Are currently enabled - // 2. Require users permission (manifest has permissions.users) - // 3. Don't have allUsers enabled - // 4. Now have an empty users array after cleanup - // - // The manifest check uses JSON path to see if permissions.users exists. - _, err = db.NewQuery(` - UPDATE plugin - SET enabled = false, - updated_at = CURRENT_TIMESTAMP - WHERE enabled = true - AND all_users = false - AND json_extract(manifest, '$.permissions.users') IS NOT NULL - AND (users IS NULL OR users = '' OR users = '[]' OR json_array_length(users) = 0) - `).Execute() - return err -} - -// cleanupPluginLibraryReferences removes a library ID from all plugins' libraries JSON arrays -// and auto-disables plugins that lose their only permitted library (when library permission is required). -// This is called from libraryRepository.Delete() to maintain referential integrity. -func cleanupPluginLibraryReferences(db dbx.Builder, libraryID int) error { - // SQLite JSON function: we filter out the library ID from the array. - // Libraries are stored as integers in the JSON array. - _, err := db.NewQuery(` - UPDATE plugin - SET libraries = ( - SELECT json_group_array(value) - FROM json_each(plugin.libraries) - WHERE CAST(value AS INTEGER) != {:libraryID} - ), - updated_at = CURRENT_TIMESTAMP - WHERE libraries IS NOT NULL - AND libraries != '' - AND EXISTS (SELECT 1 FROM json_each(plugin.libraries) WHERE CAST(value AS INTEGER) = {:libraryID}) - `).Bind(dbx.Params{"libraryID": libraryID}).Execute() - if err != nil { - return err - } - - // Auto-disable plugins that: - // 1. Are currently enabled - // 2. Require library permission (manifest has permissions.library) - // 3. Don't have allLibraries enabled - // 4. Now have an empty libraries array after cleanup - _, err = db.NewQuery(` - UPDATE plugin - SET enabled = false, - updated_at = CURRENT_TIMESTAMP - WHERE enabled = true - AND all_libraries = false - AND json_extract(manifest, '$.permissions.library') IS NOT NULL - AND (libraries IS NULL OR libraries = '' OR libraries = '[]' OR json_array_length(libraries) = 0) - `).Execute() - return err -} diff --git a/persistence/plugin_cleanup_test.go b/persistence/plugin_cleanup_test.go deleted file mode 100644 index 08959075d..000000000 --- a/persistence/plugin_cleanup_test.go +++ /dev/null @@ -1,282 +0,0 @@ -package persistence - -import ( - "context" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Plugin Cleanup", func() { - var pluginRepo model.PluginRepository - var userRepo model.UserRepository - var libraryRepo model.LibraryRepository - var ctx context.Context - - BeforeEach(func() { - ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin", UserName: "admin", IsAdmin: true}) - db := GetDBXBuilder() - pluginRepo = NewPluginRepository(db) - userRepo = NewUserRepository(db) - libraryRepo = NewLibraryRepository(db) - - // Clean up any existing plugins - all, _ := pluginRepo.GetAll(ctx) - for _, p := range all { - _ = pluginRepo.Delete(ctx, p.ID) - } - }) - - AfterEach(func() { - // Clean up after tests - all, _ := pluginRepo.GetAll(ctx) - for _, p := range all { - _ = pluginRepo.Delete(ctx, p.ID) - } - }) - - Describe("UserRepository.Delete", func() { - It("cleans up plugin references for users deleted before a later id fails", func() { - Expect(userRepo.Put(ctx, &model.User{ID: "bulk-1", UserName: "bulk-1", NewPassword: "x"})).To(Succeed()) - DeferCleanup(func() { _ = userRepo.Delete(ctx, "bulk-1") }) - Expect(pluginRepo.Put(ctx, &model.Plugin{ - ID: "bulk-plugin", Path: "/plugins/bulk.wasm", Manifest: `{"name":"bulk"}`, SHA256: "def456", - Users: `["bulk-1","other"]`, Enabled: true, - })).To(Succeed()) - - Expect(userRepo.Delete(ctx, "bulk-1", "does-not-exist")).To(MatchError(model.ErrNotFound)) - - updated, err := pluginRepo.Get(ctx, "bulk-plugin") - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Users).To(Equal(`["other"]`)) - }) - }) - - Describe("cleanupPluginUserReferences", func() { - It("removes user ID from plugin users array", func() { - // Create a plugin with multiple users - plugin := &model.Plugin{ - ID: "test-plugin", - Path: "/plugins/test.wasm", - Manifest: `{"name":"test"}`, - SHA256: "abc123", - Users: `["user1","user2","user3"]`, - Enabled: true, - } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) - - // Clean up user2 reference - db := GetDBXBuilder() - Expect(cleanupPluginUserReferences(db, "user2")).To(Succeed()) - - // Verify user2 was removed - updated, err := pluginRepo.Get(ctx, "test-plugin") - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Users).To(Equal(`["user1","user3"]`)) - Expect(updated.Enabled).To(BeTrue()) // Still has users, should remain enabled - }) - - It("auto-disables plugin when last permitted user is removed", func() { - // Create a plugin that requires users permission with only one user - plugin := &model.Plugin{ - ID: "user-plugin", - Path: "/plugins/user.wasm", - Manifest: `{"name":"user-plugin","permissions":{"users":{}}}`, - SHA256: "def456", - Users: `["only-user"]`, - AllUsers: false, - Enabled: true, - } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) - - // Remove the only user - db := GetDBXBuilder() - Expect(cleanupPluginUserReferences(db, "only-user")).To(Succeed()) - - // Verify plugin was auto-disabled - updated, err := pluginRepo.Get(ctx, "user-plugin") - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Users).To(Equal(`[]`)) - Expect(updated.Enabled).To(BeFalse()) - }) - - It("does not disable plugin when allUsers is true", func() { - plugin := &model.Plugin{ - ID: "all-users-plugin", - Path: "/plugins/all.wasm", - Manifest: `{"name":"all-users","permissions":{"users":{}}}`, - SHA256: "ghi789", - Users: `["user1"]`, - AllUsers: true, - Enabled: true, - } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) - - // Remove the user (but allUsers is true) - db := GetDBXBuilder() - Expect(cleanupPluginUserReferences(db, "user1")).To(Succeed()) - - // Plugin should still be enabled because allUsers is true - updated, err := pluginRepo.Get(ctx, "all-users-plugin") - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Enabled).To(BeTrue()) - }) - - It("does not affect plugins without users permission requirement", func() { - plugin := &model.Plugin{ - ID: "no-users-perm", - Path: "/plugins/noperm.wasm", - Manifest: `{"name":"no-perm"}`, // No permissions.users in manifest - SHA256: "jkl012", - Users: `["user1"]`, - Enabled: true, - } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) - - // Remove the user - db := GetDBXBuilder() - Expect(cleanupPluginUserReferences(db, "user1")).To(Succeed()) - - // Plugin should still be enabled (no users permission requirement) - updated, err := pluginRepo.Get(ctx, "no-users-perm") - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Users).To(Equal(`[]`)) - Expect(updated.Enabled).To(BeTrue()) - }) - }) - - Describe("cleanupPluginLibraryReferences", func() { - It("removes library ID from plugin libraries array", func() { - // Create a plugin with multiple libraries - plugin := &model.Plugin{ - ID: "lib-plugin", - Path: "/plugins/lib.wasm", - Manifest: `{"name":"lib-plugin"}`, - SHA256: "mno345", - Libraries: `[1,2,3]`, - Enabled: true, - } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) - - // Clean up library 2 reference - db := GetDBXBuilder() - Expect(cleanupPluginLibraryReferences(db, 2)).To(Succeed()) - - // Verify library 2 was removed - updated, err := pluginRepo.Get(ctx, "lib-plugin") - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Libraries).To(Equal(`[1,3]`)) - }) - - It("auto-disables plugin when last permitted library is removed", func() { - // Create a plugin that requires library permission with only one library - plugin := &model.Plugin{ - ID: "lib-only-plugin", - Path: "/plugins/libonly.wasm", - Manifest: `{"name":"lib-only","permissions":{"library":{}}}`, - SHA256: "pqr678", - Libraries: `[99]`, - AllLibraries: false, - Enabled: true, - } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) - - // Remove the only library - db := GetDBXBuilder() - Expect(cleanupPluginLibraryReferences(db, 99)).To(Succeed()) - - // Verify plugin was auto-disabled - updated, err := pluginRepo.Get(ctx, "lib-only-plugin") - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Libraries).To(Equal(`[]`)) - Expect(updated.Enabled).To(BeFalse()) - }) - - It("does not disable plugin when allLibraries is true", func() { - plugin := &model.Plugin{ - ID: "all-libs-plugin", - Path: "/plugins/alllibs.wasm", - Manifest: `{"name":"all-libs","permissions":{"library":{}}}`, - SHA256: "stu901", - Libraries: `[1]`, - AllLibraries: true, - Enabled: true, - } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) - - // Remove the library (but allLibraries is true) - db := GetDBXBuilder() - Expect(cleanupPluginLibraryReferences(db, 1)).To(Succeed()) - - // Plugin should still be enabled - updated, err := pluginRepo.Get(ctx, "all-libs-plugin") - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Enabled).To(BeTrue()) - }) - }) - - Describe("User Delete integration", func() { - It("cleans up plugin references when user is deleted", func() { - // Create a test user - user := &model.User{ - ID: "test-delete-user", - UserName: "plugin-cleanup-test-user", - IsAdmin: false, - } - user.NewPassword = "password123" - Expect(userRepo.Put(ctx, user)).To(Succeed()) - - // Create a plugin referencing this user - plugin := &model.Plugin{ - ID: "user-ref-plugin", - Path: "/plugins/userref.wasm", - Manifest: `{"name":"user-ref"}`, - SHA256: "xyz123", - Users: `["test-delete-user","other-user"]`, - Enabled: true, - } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) - - // Delete the user - Expect(userRepo.Delete(ctx, "test-delete-user")).To(Succeed()) - - // Verify user was removed from plugin - updated, err := pluginRepo.Get(ctx, "user-ref-plugin") - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Users).To(Equal(`["other-user"]`)) - }) - }) - - Describe("Library Delete integration", func() { - It("cleans up plugin references when library is deleted", func() { - // Create a test library (ID > 1 since ID 1 cannot be deleted) - library := &model.Library{ - ID: 99, - Name: "Test Library", - Path: "/tmp/test-lib", - } - Expect(libraryRepo.Put(ctx, library)).To(Succeed()) - - // Create a plugin referencing this library - plugin := &model.Plugin{ - ID: "lib-ref-plugin", - Path: "/plugins/libref.wasm", - Manifest: `{"name":"lib-ref"}`, - SHA256: "abc789", - Libraries: `[99,1]`, - Enabled: true, - } - Expect(pluginRepo.Put(ctx, plugin)).To(Succeed()) - - // Delete the library - Expect(libraryRepo.Delete(ctx, 99)).To(Succeed()) - - // Verify library was removed from plugin - updated, err := pluginRepo.Get(ctx, "lib-ref-plugin") - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Libraries).To(Equal(`[1]`)) - }) - }) -}) diff --git a/persistence/plugin_repository.go b/persistence/plugin_repository.go deleted file mode 100644 index 1545ce2ba..000000000 --- a/persistence/plugin_repository.go +++ /dev/null @@ -1,144 +0,0 @@ -package persistence - -import ( - "context" - "errors" - "time" - - . "github.com/Masterminds/squirrel" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/model" - "github.com/pocketbase/dbx" -) - -type pluginRepository struct { - sqlRepository -} - -func NewPluginRepository(db dbx.Builder) model.PluginRepository { - r := &pluginRepository{} - r.db = db - r.registerModel(&model.Plugin{}, map[string]filterFunc{ - "id": idFilter("plugin"), - "enabled": booleanFilter, - }) - return r -} - -func (r *pluginRepository) isPermitted(ctx context.Context) bool { - user := loggedUser(ctx) - return user.IsAdmin -} - -func (r *pluginRepository) ClearErrors(ctx context.Context) error { - if !r.isPermitted(ctx) { - return rest.ErrPermissionDenied - } - // An UPDATE takes the write lock even when nothing matches, so only run it when there is an error to clear - var hasErrors bool - if err := r.db.NewQuery("SELECT EXISTS (SELECT 1 FROM plugin WHERE last_error != '')").Row(&hasErrors); err != nil { - return err - } - if !hasErrors { - return nil - } - _, err := r.db.NewQuery("UPDATE plugin SET last_error = '' WHERE last_error != ''").Execute() - return err -} - -func (r *pluginRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - if !r.isPermitted(ctx) { - return 0, rest.ErrPermissionDenied - } - sql := r.newSelect(ctx) - return r.count(ctx, sql, options...) -} - -func (r *pluginRepository) Delete(ctx context.Context, id string) error { - if !r.isPermitted(ctx) { - return rest.ErrPermissionDenied - } - return r.delete(ctx, Eq{"id": id}) -} - -func (r *pluginRepository) Get(ctx context.Context, id string) (*model.Plugin, error) { - if !r.isPermitted(ctx) { - return nil, rest.ErrPermissionDenied - } - sel := r.newSelect(ctx).Where(Eq{"id": id}).Columns("*") - res := model.Plugin{} - err := r.queryOne(ctx, sel, &res) - return &res, err -} - -func (r *pluginRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Plugins, error) { - if !r.isPermitted(ctx) { - return nil, rest.ErrPermissionDenied - } - sel := r.newSelect(ctx, options...).Columns("*") - res := model.Plugins{} - err := r.queryAll(ctx, sel, &res) - return res, err -} - -func (r *pluginRepository) Put(ctx context.Context, plugin *model.Plugin) error { - if !r.isPermitted(ctx) { - return rest.ErrPermissionDenied - } - - plugin.UpdatedAt = time.Now() - - if plugin.ID == "" { - return errors.New("plugin ID cannot be empty") - } - - // Upsert using INSERT ... ON CONFLICT for atomic operation - _, err := r.db.NewQuery(` - INSERT INTO plugin (id, path, manifest, config, users, all_users, libraries, all_libraries, allow_write_access, enabled, last_error, sha256, created_at, updated_at) - VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:libraries}, {:all_libraries}, {:allow_write_access}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at}) - ON CONFLICT(id) DO UPDATE SET - path = excluded.path, - manifest = excluded.manifest, - config = excluded.config, - users = excluded.users, - all_users = excluded.all_users, - libraries = excluded.libraries, - all_libraries = excluded.all_libraries, - allow_write_access = excluded.allow_write_access, - enabled = excluded.enabled, - last_error = excluded.last_error, - sha256 = excluded.sha256, - updated_at = excluded.updated_at - `).Bind(dbx.Params{ - "id": plugin.ID, - "path": plugin.Path, - "manifest": plugin.Manifest, - "config": plugin.Config, - "users": plugin.Users, - "all_users": plugin.AllUsers, - "libraries": plugin.Libraries, - "all_libraries": plugin.AllLibraries, - "allow_write_access": plugin.AllowWriteAccess, - "enabled": plugin.Enabled, - "last_error": plugin.LastError, - "sha256": plugin.SHA256, - "created_at": time.Now(), - "updated_at": plugin.UpdatedAt, - }).Execute() - return err -} - -func (r *pluginRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) -} - -func (r *pluginRepository) Read(ctx context.Context, id string) (*model.Plugin, error) { - return r.Get(ctx, id) -} - -func (r *pluginRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Plugin, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) -} - -var _ model.PluginRepository = (*pluginRepository)(nil) -var _ rest.Repository[model.Plugin] = (*pluginRepository)(nil) diff --git a/persistence/plugin_repository_test.go b/persistence/plugin_repository_test.go deleted file mode 100644 index 44330250b..000000000 --- a/persistence/plugin_repository_test.go +++ /dev/null @@ -1,269 +0,0 @@ -package persistence - -import ( - "context" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("PluginRepository", func() { - var repo model.PluginRepository - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) - - Describe("Admin User", func() { - BeforeEach(func() { - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPluginRepository(GetDBXBuilder()) - - // Clean up any existing plugins - all, _ := repo.GetAll(ctx) - for _, p := range all { - _ = repo.Delete(ctx, p.ID) - } - }) - - AfterEach(func() { - // Clean up after tests - all, _ := repo.GetAll(ctx) - for _, p := range all { - _ = repo.Delete(ctx, p.ID) - } - }) - - Describe("CountAll", func() { - It("returns 0 when no plugins exist", func() { - Expect(repo.CountAll(ctx)).To(Equal(int64(0))) - }) - - It("returns the number of plugins in the DB", func() { - _ = repo.Put(ctx, &model.Plugin{ID: "test-plugin-1", Path: "/plugins/test1.wasm", Manifest: "{}", SHA256: "abc123"}) - _ = repo.Put(ctx, &model.Plugin{ID: "test-plugin-2", Path: "/plugins/test2.wasm", Manifest: "{}", SHA256: "def456"}) - - Expect(repo.CountAll(ctx)).To(Equal(int64(2))) - }) - }) - - Describe("Delete", func() { - It("deletes existing item", func() { - plugin := &model.Plugin{ID: "to-delete", Path: "/plugins/delete.wasm", Manifest: "{}", SHA256: "hash"} - _ = repo.Put(ctx, plugin) - - err := repo.Delete(ctx, plugin.ID) - Expect(err).To(BeNil()) - - _, err = repo.Get(ctx, plugin.ID) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("Get", func() { - It("returns an existing item", func() { - plugin := &model.Plugin{ID: "test-get", Path: "/plugins/test.wasm", Manifest: `{"name":"test"}`, SHA256: "hash123"} - _ = repo.Put(ctx, plugin) - - res, err := repo.Get(ctx, plugin.ID) - Expect(err).To(BeNil()) - Expect(res.ID).To(Equal(plugin.ID)) - Expect(res.Path).To(Equal(plugin.Path)) - Expect(res.Manifest).To(Equal(plugin.Manifest)) - }) - - It("errors when missing", func() { - _, err := repo.Get(ctx, "notanid") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("GetAll", func() { - It("returns all items from the DB", func() { - _ = repo.Put(ctx, &model.Plugin{ID: "plugin-a", Path: "/plugins/a.wasm", Manifest: "{}", SHA256: "hash1"}) - _ = repo.Put(ctx, &model.Plugin{ID: "plugin-b", Path: "/plugins/b.wasm", Manifest: "{}", SHA256: "hash2"}) - - all, err := repo.GetAll(ctx) - Expect(err).To(BeNil()) - Expect(all).To(HaveLen(2)) - }) - - It("supports pagination", func() { - _ = repo.Put(ctx, &model.Plugin{ID: "plugin-1", Path: "/plugins/1.wasm", Manifest: "{}", SHA256: "h1"}) - _ = repo.Put(ctx, &model.Plugin{ID: "plugin-2", Path: "/plugins/2.wasm", Manifest: "{}", SHA256: "h2"}) - _ = repo.Put(ctx, &model.Plugin{ID: "plugin-3", Path: "/plugins/3.wasm", Manifest: "{}", SHA256: "h3"}) - - page1, err := repo.GetAll(ctx, model.QueryOptions{Max: 2, Offset: 0, Sort: "id"}) - Expect(err).To(BeNil()) - Expect(page1).To(HaveLen(2)) - - page2, err := repo.GetAll(ctx, model.QueryOptions{Max: 2, Offset: 2, Sort: "id"}) - Expect(err).To(BeNil()) - Expect(page2).To(HaveLen(1)) - }) - }) - - Describe("Put", func() { - It("successfully creates a new plugin", func() { - plugin := &model.Plugin{ - ID: "new-plugin", - Path: "/plugins/new.wasm", - Manifest: `{"name":"new","version":"1.0"}`, - Config: `{"setting":"value"}`, - SHA256: "sha256hash", - Enabled: false, - } - - err := repo.Put(ctx, plugin) - Expect(err).To(BeNil()) - - saved, err := repo.Get(ctx, plugin.ID) - Expect(err).To(BeNil()) - Expect(saved.Path).To(Equal(plugin.Path)) - Expect(saved.Manifest).To(Equal(plugin.Manifest)) - Expect(saved.Config).To(Equal(plugin.Config)) - Expect(saved.Enabled).To(BeFalse()) - Expect(saved.CreatedAt).NotTo(BeZero()) - Expect(saved.UpdatedAt).NotTo(BeZero()) - }) - - It("successfully updates an existing plugin", func() { - plugin := &model.Plugin{ - ID: "update-plugin", - Path: "/plugins/update.wasm", - Manifest: `{"name":"test"}`, - SHA256: "original", - Enabled: false, - } - _ = repo.Put(ctx, plugin) - - plugin.Enabled = true - plugin.Config = `{"new":"config"}` - plugin.SHA256 = "updated" - err := repo.Put(ctx, plugin) - Expect(err).To(BeNil()) - - saved, err := repo.Get(ctx, plugin.ID) - Expect(err).To(BeNil()) - Expect(saved.Enabled).To(BeTrue()) - Expect(saved.Config).To(Equal(`{"new":"config"}`)) - Expect(saved.SHA256).To(Equal("updated")) - }) - - It("stores and retrieves last_error", func() { - plugin := &model.Plugin{ - ID: "error-plugin", - Path: "/plugins/error.wasm", - Manifest: "{}", - SHA256: "hash", - LastError: "failed to load: missing export", - } - err := repo.Put(ctx, plugin) - Expect(err).To(BeNil()) - - saved, err := repo.Get(ctx, plugin.ID) - Expect(err).To(BeNil()) - Expect(saved.LastError).To(Equal("failed to load: missing export")) - }) - - It("fails when ID is empty", func() { - plugin := &model.Plugin{ - Path: "/plugins/noid.wasm", - Manifest: "{}", - SHA256: "hash", - } - err := repo.Put(ctx, plugin) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("ID cannot be empty")) - }) - }) - - Describe("ClearErrors", func() { - It("clears last_error on all plugins with errors", func() { - _ = repo.Put(ctx, &model.Plugin{ID: "ok-plugin", Path: "/plugins/ok.wasm", Manifest: "{}", SHA256: "h1"}) - _ = repo.Put(ctx, &model.Plugin{ID: "err-plugin-1", Path: "/plugins/e1.wasm", Manifest: "{}", SHA256: "h2", LastError: "incompatible version"}) - _ = repo.Put(ctx, &model.Plugin{ID: "err-plugin-2", Path: "/plugins/e2.wasm", Manifest: "{}", SHA256: "h3", LastError: "missing export"}) - - err := repo.ClearErrors(ctx) - Expect(err).To(BeNil()) - - all, err := repo.GetAll(ctx) - Expect(err).To(BeNil()) - for _, p := range all { - Expect(p.LastError).To(BeEmpty(), "plugin %s should have no error", p.ID) - } - }) - - It("succeeds when no plugins have errors", func() { - _ = repo.Put(ctx, &model.Plugin{ID: "clean-plugin", Path: "/plugins/c.wasm", Manifest: "{}", SHA256: "h1"}) - - err := repo.ClearErrors(ctx) - Expect(err).To(BeNil()) - }) - - It("does not need the write lock when no plugins have errors", func() { - _ = repo.Put(ctx, &model.Plugin{ID: "clean-plugin", Path: "/plugins/c.wasm", Manifest: "{}", SHA256: "h1"}) - conn, err := db.Db().Conn(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(conn.Close) - _, err = conn.ExecContext(GinkgoT().Context(), "BEGIN IMMEDIATE") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { _, _ = conn.ExecContext(context.Background(), "ROLLBACK") }) - - Expect(repo.ClearErrors(ctx)).To(Succeed()) - }) - }) - }) - - Describe("Regular User", func() { - BeforeEach(func() { - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false}) - repo = NewPluginRepository(GetDBXBuilder()) - }) - - Describe("CountAll", func() { - It("fails to count items", func() { - _, err := repo.CountAll(ctx) - Expect(err).To(Equal(rest.ErrPermissionDenied)) - }) - }) - - Describe("Delete", func() { - It("fails to delete items", func() { - err := repo.Delete(ctx, "any-id") - Expect(err).To(Equal(rest.ErrPermissionDenied)) - }) - }) - - Describe("Get", func() { - It("fails to get items", func() { - _, err := repo.Get(ctx, "any-id") - Expect(err).To(Equal(rest.ErrPermissionDenied)) - }) - }) - - Describe("GetAll", func() { - It("fails to get all items", func() { - _, err := repo.GetAll(ctx) - Expect(err).To(Equal(rest.ErrPermissionDenied)) - }) - }) - - Describe("Put", func() { - It("fails to create/update item", func() { - err := repo.Put(ctx, &model.Plugin{ - ID: "user-create", - Path: "/plugins/create.wasm", - Manifest: "{}", - SHA256: "hash", - }) - Expect(err).To(Equal(rest.ErrPermissionDenied)) - }) - }) - }) -}) diff --git a/persistence/property_repository.go b/persistence/property_repository.go index 29bb2b564..d62cdec6e 100644 --- a/persistence/property_repository.go +++ b/persistence/property_repository.go @@ -5,24 +5,25 @@ import ( "errors" . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "github.com/navidrome/navidrome/model" - "github.com/pocketbase/dbx" ) type propertyRepository struct { sqlRepository } -func NewPropertyRepository(db dbx.Builder) model.PropertyRepository { +func NewPropertyRepository(ctx context.Context, o orm.QueryExecutor) model.PropertyRepository { r := &propertyRepository{} - r.db = db + r.ctx = ctx + r.ormer = o r.tableName = "property" return r } -func (r propertyRepository) Put(ctx context.Context, id string, value string) error { +func (r propertyRepository) Put(id string, value string) error { update := Update(r.tableName).Set("value", value).Where(Eq{"id": id}) - count, err := r.executeSQL(ctx, update) + count, err := r.executeSQL(update) if err != nil { return err } @@ -30,24 +31,24 @@ func (r propertyRepository) Put(ctx context.Context, id string, value string) er return nil } insert := Insert(r.tableName).Columns("id", "value").Values(id, value) - _, err = r.executeSQL(ctx, insert) + _, err = r.executeSQL(insert) return err } -func (r propertyRepository) Get(ctx context.Context, id string) (string, error) { +func (r propertyRepository) Get(id string) (string, error) { sel := Select("value").From(r.tableName).Where(Eq{"id": id}) resp := struct { Value string }{} - err := r.queryOne(ctx, sel, &resp) + err := r.queryOne(sel, &resp) if err != nil { return "", err } return resp.Value, nil } -func (r propertyRepository) DefaultGet(ctx context.Context, id string, defaultValue string) (string, error) { - value, err := r.Get(ctx, id) +func (r propertyRepository) DefaultGet(id string, defaultValue string) (string, error) { + value, err := r.Get(id) if errors.Is(err, model.ErrNotFound) { return defaultValue, nil } @@ -57,6 +58,6 @@ func (r propertyRepository) DefaultGet(ctx context.Context, id string, defaultVa return value, nil } -func (r propertyRepository) Delete(ctx context.Context, id string) error { - return r.delete(ctx, Eq{"id": id}) +func (r propertyRepository) Delete(id string) error { + return r.delete(Eq{"id": id}) } diff --git a/persistence/property_repository_test.go b/persistence/property_repository_test.go index 880b315ec..4c5df5eaf 100644 --- a/persistence/property_repository_test.go +++ b/persistence/property_repository_test.go @@ -3,6 +3,7 @@ package persistence import ( "context" + "github.com/beego/beego/v2/client/orm" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" @@ -10,27 +11,25 @@ import ( ) var _ = Describe("Property Repository", func() { - var ctx context.Context var pr model.PropertyRepository BeforeEach(func() { - ctx = log.NewContext(GinkgoT().Context()) - pr = NewPropertyRepository(GetDBXBuilder()) + pr = NewPropertyRepository(log.NewContext(context.TODO()), orm.NewOrm()) }) It("saves and restore a new property", func() { id := "1" value := "a_value" - Expect(pr.Put(ctx, id, value)).To(BeNil()) - Expect(pr.Get(ctx, id)).To(Equal("a_value")) + Expect(pr.Put(id, value)).To(BeNil()) + Expect(pr.Get(id)).To(Equal("a_value")) }) It("updates a property", func() { - Expect(pr.Put(ctx, "1", "another_value")).To(BeNil()) - Expect(pr.Get(ctx, "1")).To(Equal("another_value")) + Expect(pr.Put("1", "another_value")).To(BeNil()) + Expect(pr.Get("1")).To(Equal("another_value")) }) It("returns a default value if property does not exist", func() { - Expect(pr.DefaultGet(ctx, "2", "default")).To(Equal("default")) + Expect(pr.DefaultGet("2", "default")).To(Equal("default")) }) }) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index b5d4f3a07..17e4d2499 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -2,142 +2,141 @@ package persistence import ( "context" + "errors" + "strings" "time" . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "github.com/deluan/rest" - "github.com/navidrome/navidrome/log" + "github.com/google/uuid" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/pocketbase/dbx" ) type radioRepository struct { sqlRepository + sqlRestful } -func NewRadioRepository(db dbx.Builder) model.RadioRepository { +func NewRadioRepository(ctx context.Context, o orm.QueryExecutor) model.RadioRepository { r := &radioRepository{} - r.db = db - r.registerModel(&model.Radio{}, map[string]filterFunc{ - "name": containsFilter("name"), - }) + r.ctx = ctx + r.ormer = o + r.tableName = "radio" + r.filterMappings = map[string]filterFunc{ + "name": containsFilter, + } return r } -func (r *radioRepository) isPermitted(ctx context.Context) bool { - user := loggedUser(ctx) +func (r *radioRepository) isPermitted() bool { + user := loggedUser(r.ctx) return user.IsAdmin } -func (r *radioRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - sql := r.newSelect(ctx) - return r.count(ctx, sql, options...) +func (r *radioRepository) CountAll(options ...model.QueryOptions) (int64, error) { + sql := r.newSelect(options...) + return r.count(sql, options...) } -// Exists needs no library or ownership filter: radios are visible to every user. -func (r *radioRepository) Exists(ctx context.Context, id string) (bool, error) { - return r.exists(ctx, Eq{"id": id}) -} - -func (r *radioRepository) Delete(ctx context.Context, ids ...string) error { - if !r.isPermitted(ctx) { +func (r *radioRepository) Delete(id string) error { + if !r.isPermitted() { return rest.ErrPermissionDenied } - for _, id := range ids { - if err := r.deleteByID(ctx, id); err != nil { - return err - } - } - return nil + return r.delete(Eq{"id": id}) } -func (r *radioRepository) Get(ctx context.Context, id string) (*model.Radio, error) { - sel := r.newSelect(ctx).Where(Eq{"id": id}).Columns("*") +func (r *radioRepository) Get(id string) (*model.Radio, error) { + sel := r.newSelect().Where(Eq{"id": id}).Columns("*") res := model.Radio{} - err := r.queryOne(ctx, sel, &res) - if err != nil { - return &res, err - } - list := model.Radios{res} - r.hydrateArtwork(ctx, list) - return &list[0], nil + err := r.queryOne(sel, &res) + return &res, err } -func (r *radioRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Radios, error) { - sel := r.newSelect(ctx, options...).Columns("*") +func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, error) { + sel := r.newSelect(options...).Columns("*") res := model.Radios{} - err := r.queryAll(ctx, sel, &res) - if err != nil { - return res, err - } - r.hydrateArtwork(ctx, res) - return res, nil + err := r.queryAll(sel, &res) + return res, err } -// hydrateArtwork fills each radio's ImageHash/ImageAbsent from one batched item_artwork lookup. -func (r *radioRepository) hydrateArtwork(ctx context.Context, radios model.Radios) { - hydrateItems(ctx, r.db, model.KindRadioArtwork, radios, - func(rd *model.Radio) (string, *model.ItemImage) { return rd.ID, &rd.ItemImage }) -} - -func (r *radioRepository) Put(ctx context.Context, radio *model.Radio, colsToUpdate ...string) error { - if !r.isPermitted(ctx) { +func (r *radioRepository) Put(radio *model.Radio) error { + if !r.isPermitted() { return rest.ErrPermissionDenied } + var values map[string]interface{} + radio.UpdatedAt = time.Now() + if radio.ID == "" { radio.CreatedAt = time.Now() - radio.ID = id.NewRandom() + radio.ID = strings.ReplaceAll(uuid.NewString(), "-", "") + values, _ = toSqlArgs(*radio) + } else { + values, _ = toSqlArgs(*radio) + update := Update(r.tableName).Where(Eq{"id": radio.ID}).SetMap(values) + count, err := r.executeSQL(update) + + if err != nil { + return err + } else if count > 0 { + return nil + } } - if len(colsToUpdate) > 0 { - colsToUpdate = append(colsToUpdate, "UpdatedAt") - } - _, err := r.put(ctx, radio.ID, radio, colsToUpdate...) - if err != nil { - return err - } - // Enqueue artwork resolution for the created/updated radio at Bump priority so a new - // radio's cover resolves proactively. Never fails the save. - item := model.ArtworkQueueItem{ItemKind: model.KindRadioArtwork.Prefix(), ItemID: radio.ID, ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityBump} - if err := NewArtworkQueueRepository(r.db).Enqueue(ctx, item); err != nil { - log.Warn(ctx, "could not enqueue radio artwork", "id", radio.ID, err) - } - return nil + + values["created_at"] = time.Now() + insert := Insert(r.tableName).SetMap(values) + _, err := r.executeSQL(insert) + return err } -func (r *radioRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *radioRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(options...)) } -func (r *radioRepository) Read(ctx context.Context, id string) (*model.Radio, error) { - return r.Get(ctx, id) +func (r *radioRepository) EntityName() string { + return "radio" } -func (r *radioRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Radio, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *radioRepository) NewInstance() interface{} { + return &model.Radio{} } -func (r *radioRepository) Save(ctx context.Context, t *model.Radio) (string, error) { - if !r.isPermitted(ctx) { +func (r *radioRepository) Read(id string) (interface{}, error) { + return r.Get(id) +} + +func (r *radioRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return r.GetAll(r.parseRestOptions(options...)) +} + +func (r *radioRepository) Save(entity interface{}) (string, error) { + t := entity.(*model.Radio) + if !r.isPermitted() { return "", rest.ErrPermissionDenied } - err := r.Put(ctx, t) + err := r.Put(t) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound + } return t.ID, err } -func (r *radioRepository) Update(ctx context.Context, id string, entity model.Radio, cols ...string) error { - t := &entity +func (r *radioRepository) Update(id string, entity interface{}, cols ...string) error { + t := entity.(*model.Radio) t.ID = id - if !r.isPermitted(ctx) { + if !r.isPermitted() { return rest.ErrPermissionDenied } - return r.Put(ctx, t, cols...) + err := r.Put(t) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err } var _ model.RadioRepository = (*radioRepository)(nil) -var _ rest.Repository[model.Radio] = (*radioRepository)(nil) -var _ rest.Persistable[model.Radio] = (*radioRepository)(nil) +var _ rest.Repository = (*radioRepository)(nil) +var _ rest.Persistable = (*radioRepository)(nil) diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index aba776853..b87b1fc15 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -3,6 +3,7 @@ package persistence import ( "context" + "github.com/beego/beego/v2/client/orm" "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -11,30 +12,31 @@ import ( . "github.com/onsi/gomega" ) +var ( + NewId string = "123-456-789" +) + var _ = Describe("RadioRepository", func() { var repo model.RadioRepository - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) Describe("Admin User", func() { BeforeEach(func() { - ctx = request.WithUser(log.NewContext(ctx), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewRadioRepository(GetDBXBuilder()) - _ = repo.Put(ctx, &radioWithHomePage) + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewRadioRepository(ctx, orm.NewOrm()) + _ = repo.Put(&radioWithHomePage) }) AfterEach(func() { - all, _ := repo.GetAll(ctx) + all, _ := repo.GetAll() for _, radio := range all { - _ = repo.Delete(ctx, radio.ID) + _ = repo.Delete(radio.ID) } for i := range testRadios { - err := repo.Put(ctx, new(testRadios[i])) + r := testRadios[i] + err := repo.Put(&r) if err != nil { panic(err) } @@ -43,35 +45,31 @@ var _ = Describe("RadioRepository", func() { Describe("Count", func() { It("returns the number of radios in the DB", func() { - Expect(repo.CountAll(ctx)).To(Equal(int64(2))) + Expect(repo.CountAll()).To(Equal(int64(2))) }) }) Describe("Delete", func() { It("deletes existing item", func() { - err := repo.Delete(ctx, radioWithHomePage.ID) + err := repo.Delete(radioWithHomePage.ID) Expect(err).To(BeNil()) - _, err = repo.Get(ctx, radioWithHomePage.ID) + _, err = repo.Get(radioWithHomePage.ID) Expect(err).To(MatchError(model.ErrNotFound)) }) - - It("errors when missing", func() { - Expect(repo.Delete(ctx, "notanid")).To(MatchError(model.ErrNotFound)) - }) }) Describe("Get", func() { It("returns an existing item", func() { - res, err := repo.Get(ctx, radioWithHomePage.ID) + res, err := repo.Get(radioWithHomePage.ID) Expect(err).To(BeNil()) Expect(res.ID).To(Equal(radioWithHomePage.ID)) }) It("errors when missing", func() { - _, err := repo.Get(ctx, "notanid") + _, err := repo.Get("notanid") Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -79,7 +77,7 @@ var _ = Describe("RadioRepository", func() { Describe("GetAll", func() { It("returns all items from the DB", func() { - all, err := repo.GetAll(ctx) + all, err := repo.GetAll() Expect(err).To(BeNil()) Expect(all[0].ID).To(Equal(radioWithoutHomePage.ID)) Expect(all[1].ID).To(Equal(radioWithHomePage.ID)) @@ -88,7 +86,7 @@ var _ = Describe("RadioRepository", func() { Describe("Put", func() { It("successfully updates item", func() { - err := repo.Put(ctx, &model.Radio{ + err := repo.Put(&model.Radio{ ID: radioWithHomePage.ID, Name: "New Name", StreamUrl: "https://example.com:4533/app", @@ -96,81 +94,44 @@ var _ = Describe("RadioRepository", func() { Expect(err).To(BeNil()) - item, err := repo.Get(ctx, radioWithHomePage.ID) + item, err := repo.Get(radioWithHomePage.ID) Expect(err).To(BeNil()) Expect(item.HomePageUrl).To(Equal("")) }) It("successfully creates item", func() { - err := repo.Put(ctx, &model.Radio{ + err := repo.Put(&model.Radio{ Name: "New radio", StreamUrl: "https://example.com:4533/app", }) Expect(err).To(BeNil()) - Expect(repo.CountAll(ctx)).To(Equal(int64(3))) + Expect(repo.CountAll()).To(Equal(int64(3))) - all, err := repo.GetAll(ctx) + all, err := repo.GetAll() Expect(err).To(BeNil()) Expect(all[2].StreamUrl).To(Equal("https://example.com:4533/app")) }) - - It("enqueues artwork resolution for the saved radio", func() { - err := repo.Put(ctx, &model.Radio{ - Name: "Artwork radio", - StreamUrl: "https://example.com:4533/artwork", - }) - Expect(err).To(BeNil()) - - all, err := repo.GetAll(ctx) - Expect(err).To(BeNil()) - created := all[len(all)-1] - - queueRepo := NewArtworkQueueRepository(GetDBXBuilder()) - queued, err := queueRepo.DequeueBatch(ctx, 1000) - Expect(err).To(BeNil()) - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "ra"), - HaveField("ItemID", created.ID), - HaveField("Priority", model.ArtworkPriorityBump), - ))) - }) - }) - - Describe("Update", func() { - It("only writes the columns sent by the client", func() { - radio := radioWithHomePage - radio.UploadedImage = "cover.png" - Expect(repo.Put(ctx, &radio)).To(Succeed()) - - Expect(repo.Update(ctx, radio.ID, model.Radio{Name: "Renamed"}, "name")).To(Succeed()) - - item, err := repo.Get(ctx, radio.ID) - Expect(err).To(BeNil()) - Expect(item.Name).To(Equal("Renamed")) - Expect(item.UploadedImage).To(Equal("cover.png")) - Expect(item.StreamUrl).To(Equal(radio.StreamUrl)) - Expect(item.HomePageUrl).To(Equal(radio.HomePageUrl)) - }) }) }) Describe("Regular User", func() { BeforeEach(func() { - ctx = request.WithUser(log.NewContext(ctx), model.User{ID: "userid", UserName: "userid", IsAdmin: false}) - repo = NewRadioRepository(GetDBXBuilder()) + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false}) + repo = NewRadioRepository(ctx, orm.NewOrm()) }) Describe("Count", func() { It("returns the number of radios in the DB", func() { - Expect(repo.CountAll(ctx)).To(Equal(int64(2))) + Expect(repo.CountAll()).To(Equal(int64(2))) }) }) Describe("Delete", func() { It("fails to delete items", func() { - err := repo.Delete(ctx, radioWithHomePage.ID) + err := repo.Delete(radioWithHomePage.ID) Expect(err).To(Equal(rest.ErrPermissionDenied)) }) @@ -178,14 +139,14 @@ var _ = Describe("RadioRepository", func() { Describe("Get", func() { It("returns an existing item", func() { - res, err := repo.Get(ctx, radioWithHomePage.ID) + res, err := repo.Get(radioWithHomePage.ID) - Expect(err).To(BeNil()) + Expect(err).To((BeNil())) Expect(res.ID).To(Equal(radioWithHomePage.ID)) }) It("errors when missing", func() { - _, err := repo.Get(ctx, "notanid") + _, err := repo.Get("notanid") Expect(err).To(MatchError(model.ErrNotFound)) }) @@ -193,7 +154,7 @@ var _ = Describe("RadioRepository", func() { Describe("GetAll", func() { It("returns all items from the DB", func() { - all, err := repo.GetAll(ctx) + all, err := repo.GetAll() Expect(err).To(BeNil()) Expect(all[0].ID).To(Equal(radioWithoutHomePage.ID)) Expect(all[1].ID).To(Equal(radioWithHomePage.ID)) @@ -202,7 +163,7 @@ var _ = Describe("RadioRepository", func() { Describe("Put", func() { It("fails to update item", func() { - err := repo.Put(ctx, &model.Radio{ + err := repo.Put(&model.Radio{ ID: radioWithHomePage.ID, Name: "New Name", StreamUrl: "https://example.com:4533/app", diff --git a/persistence/scrobble_buffer_repository.go b/persistence/scrobble_buffer_repository.go index 0daf2b0e7..3912f7319 100644 --- a/persistence/scrobble_buffer_repository.go +++ b/persistence/scrobble_buffer_repository.go @@ -6,37 +6,23 @@ import ( "time" . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/pocketbase/dbx" ) type scrobbleBufferRepository struct { sqlRepository } -type dbScrobbleBuffer struct { - dbMediaFile - *model.ScrobbleEntry `structs:",flatten"` -} - -func (t *dbScrobbleBuffer) PostScan() error { - if err := t.dbMediaFile.PostScan(); err != nil { - return err - } - t.ScrobbleEntry.MediaFile = *t.dbMediaFile.MediaFile - t.ScrobbleEntry.MediaFile.ID = t.MediaFileID - return nil -} - -func NewScrobbleBufferRepository(db dbx.Builder) model.ScrobbleBufferRepository { +func NewScrobbleBufferRepository(ctx context.Context, o orm.QueryExecutor) model.ScrobbleBufferRepository { r := &scrobbleBufferRepository{} - r.db = db + r.ctx = ctx + r.ormer = o r.tableName = "scrobble_buffer" return r } -func (r *scrobbleBufferRepository) UserIDs(ctx context.Context, service string) ([]string, error) { +func (r *scrobbleBufferRepository) UserIDs(service string) ([]string, error) { sql := Select().Columns("user_id"). From(r.tableName). Where(And{ @@ -45,26 +31,24 @@ func (r *scrobbleBufferRepository) UserIDs(ctx context.Context, service string) GroupBy("user_id"). OrderBy("count(*)") var userIds []string - err := r.queryAllSlice(ctx, sql, &userIds) + err := r.queryAll(sql, &userIds) return userIds, err } -func (r *scrobbleBufferRepository) Enqueue(ctx context.Context, service, userId, mediaFileId string, playTime time.Time) error { - ins := Insert(r.tableName).SetMap(map[string]any{ - "id": id.NewRandom(), +func (r *scrobbleBufferRepository) Enqueue(service, userId, mediaFileId string, playTime time.Time) error { + ins := Insert(r.tableName).SetMap(map[string]interface{}{ "user_id": userId, "service": service, "media_file_id": mediaFileId, "play_time": playTime, "enqueue_time": time.Now(), }) - _, err := r.executeSQL(ctx, ins) + _, err := r.executeSQL(ins) return err } -func (r *scrobbleBufferRepository) Next(ctx context.Context, service string, userId string) (*model.ScrobbleEntry, error) { - // Put `s.*` last or else m.id overrides s.id - sql := Select().Columns("m.*, s.*"). +func (r *scrobbleBufferRepository) Next(service string, userId string) (*model.ScrobbleEntry, error) { + sql := Select().Columns("s.*, m.*"). From(r.tableName+" s"). LeftJoin("media_file m on m.id = s.media_file_id"). Where(And{ @@ -73,31 +57,28 @@ func (r *scrobbleBufferRepository) Next(ctx context.Context, service string, use }). OrderBy("play_time", "s.rowid").Limit(1) - var res dbScrobbleBuffer - err := r.queryOne(ctx, sql, &res) - if errors.Is(err, model.ErrNotFound) { + res := model.ScrobbleEntries{} + // TODO Rewrite queryOne to use QueryRows, to workaround the recursive embedded structs issue + err := r.queryAll(sql, &res) + if errors.Is(err, model.ErrNotFound) || len(res) == 0 { return nil, nil } if err != nil { return nil, err } - res.ScrobbleEntry.Participants, err = r.getParticipants(ctx, &res.ScrobbleEntry.MediaFile) - if err != nil { - return nil, err - } - return res.ScrobbleEntry, nil + return &res[0], nil } -func (r *scrobbleBufferRepository) Dequeue(ctx context.Context, entry *model.ScrobbleEntry) error { - return r.delete(ctx, Eq{"id": entry.ID}) +func (r *scrobbleBufferRepository) Dequeue(entry *model.ScrobbleEntry) error { + return r.delete(And{ + Eq{"service": entry.Service}, + Eq{"media_file_id": entry.MediaFile.ID}, + Eq{"play_time": entry.PlayTime}, + }) } -func (r *scrobbleBufferRepository) Discard(ctx context.Context, service string) error { - return r.delete(ctx, Eq{"service": service}) -} - -func (r *scrobbleBufferRepository) Length(ctx context.Context) (int64, error) { - return r.count(ctx, Select()) +func (r *scrobbleBufferRepository) Length() (int64, error) { + return r.count(Select()) } var _ model.ScrobbleBufferRepository = (*scrobbleBufferRepository)(nil) diff --git a/persistence/scrobble_buffer_repository_test.go b/persistence/scrobble_buffer_repository_test.go deleted file mode 100644 index 38b0e4e63..000000000 --- a/persistence/scrobble_buffer_repository_test.go +++ /dev/null @@ -1,230 +0,0 @@ -package persistence - -import ( - "context" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("ScrobbleBufferRepository", func() { - var scrobble model.ScrobbleBufferRepository - var rawRepo sqlRepository - var ctx context.Context - - enqueueTime := time.Date(2025, 01, 01, 00, 00, 00, 00, time.Local) - var ids []string - - var insertManually = func(service, userId, mediaFileId string, playTime time.Time) { - id := id.NewRandom() - ids = append(ids, id) - - ins := squirrel.Insert("scrobble_buffer").SetMap(map[string]any{ - "id": id, - "user_id": userId, - "service": service, - "media_file_id": mediaFileId, - "play_time": playTime, - "enqueue_time": enqueueTime, - }) - _, err := rawRepo.executeSQL(ctx, ins) - Expect(err).ToNot(HaveOccurred()) - } - - BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) - db := GetDBXBuilder() - scrobble = NewScrobbleBufferRepository(db) - - rawRepo = sqlRepository{ - tableName: "scrobble_buffer", - db: db, - } - ids = []string{} - }) - - AfterEach(func() { - del := squirrel.Delete(rawRepo.tableName) - _, err := rawRepo.executeSQL(ctx, del) - Expect(err).ToNot(HaveOccurred()) - }) - - Describe("Without data", func() { - Describe("Count", func() { - It("returns zero when empty", func() { - count, err := scrobble.Length(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeZero()) - }) - }) - - Describe("Dequeue", func() { - It("is a no-op when deleting a nonexistent item", func() { - err := scrobble.Dequeue(ctx, &model.ScrobbleEntry{ID: "fake"}) - Expect(err).ToNot(HaveOccurred()) - - count, err := scrobble.Length(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(0))) - }) - }) - - Describe("Next", func() { - It("should not fail with no item for the service", func() { - entry, err := scrobble.Next(ctx, "fake", "userid") - Expect(entry).To(BeNil()) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("UserIds", func() { - It("should return empty list with no data", func() { - ids, err := scrobble.UserIDs(ctx, "service") - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(BeEmpty()) - }) - }) - }) - - Describe("With data", func() { - timeA := enqueueTime.Add(24 * time.Hour) - timeB := enqueueTime.Add(48 * time.Hour) - timeC := enqueueTime.Add(72 * time.Hour) - timeD := enqueueTime.Add(96 * time.Hour) - - BeforeEach(func() { - insertManually("a", "userid", "1001", timeB) - insertManually("a", "userid", "1002", timeA) - insertManually("a", "2222", "1003", timeC) - insertManually("b", "2222", "1004", timeD) - }) - - Describe("Count", func() { - It("Returns count when populated", func() { - count, err := scrobble.Length(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(4))) - }) - }) - - Describe("Dequeue", func() { - It("is a no-op when deleting a nonexistent item", func() { - err := scrobble.Dequeue(ctx, &model.ScrobbleEntry{ID: "fake"}) - Expect(err).ToNot(HaveOccurred()) - - count, err := scrobble.Length(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(4))) - }) - - It("deletes an item when specified properly", func() { - err := scrobble.Dequeue(ctx, &model.ScrobbleEntry{ID: ids[3]}) - Expect(err).ToNot(HaveOccurred()) - - count, err := scrobble.Length(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(3))) - - entry, err := scrobble.Next(ctx, "b", "2222") - Expect(err).ToNot(HaveOccurred()) - Expect(entry).To(BeNil()) - }) - }) - - Describe("Enqueue", func() { - DescribeTable("enqueues an item properly", - func(service, userId, fileId string, playTime time.Time) { - now := time.Now() - err := scrobble.Enqueue(ctx, service, userId, fileId, playTime) - Expect(err).ToNot(HaveOccurred()) - - count, err := scrobble.Length(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(5))) - - entry, err := scrobble.Next(ctx, service, userId) - Expect(err).ToNot(HaveOccurred()) - Expect(entry).ToNot(BeNil()) - - Expect(entry.EnqueueTime).To(BeTemporally("~", now, 100*time.Millisecond)) - Expect(entry.MediaFileID).To(Equal(fileId)) - Expect(entry.PlayTime).To(BeTemporally("==", playTime)) - }, - Entry("to an existing service with multiple values", "a", "userid", "1004", enqueueTime), - Entry("to a new service", "c", "2222", "1001", timeD), - Entry("to an existing service as new user", "b", "userid", "1003", timeC), - ) - }) - - Describe("Next", func() { - DescribeTable("Returns the next item when populated", - func(service, id string, playTime time.Time, fileId, artistId string) { - entry, err := scrobble.Next(ctx, service, id) - Expect(err).ToNot(HaveOccurred()) - Expect(entry).ToNot(BeNil()) - - Expect(entry.Service).To(Equal(service)) - Expect(entry.UserID).To(Equal(id)) - Expect(entry.PlayTime).To(BeTemporally("==", playTime)) - Expect(entry.EnqueueTime).To(BeTemporally("==", enqueueTime)) - Expect(entry.MediaFileID).To(Equal(fileId)) - - Expect(entry.MediaFile.Participants).To(HaveLen(1)) - - artists, ok := entry.MediaFile.Participants[model.RoleArtist] - Expect(ok).To(BeTrue(), "no artist role in participants") - - Expect(artists).To(HaveLen(1)) - Expect(artists[0].ID).To(Equal(artistId)) - }, - - Entry("Service with multiple values for one user", "a", "userid", timeA, "1002", "3"), - Entry("Service with users", "a", "2222", timeC, "1003", "2"), - Entry("Service with one user", "b", "2222", timeD, "1004", "2"), - ) - - }) - - Describe("Discard", func() { - It("deletes all entries for a service, keeping other services intact", func() { - Expect(scrobble.Discard(ctx, "a")).To(Succeed()) - - count, err := scrobble.Length(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(1))) - - entry, err := scrobble.Next(ctx, "b", "2222") - Expect(err).ToNot(HaveOccurred()) - Expect(entry).ToNot(BeNil()) - }) - - It("is a no-op for a service without entries", func() { - Expect(scrobble.Discard(ctx, "nonexistent")).To(Succeed()) - - count, err := scrobble.Length(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(4))) - }) - }) - - Describe("UserIds", func() { - It("should return ordered list for services", func() { - ids, err := scrobble.UserIDs(ctx, "a") - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(Equal([]string{"2222", "userid"})) - }) - - It("should return for a different service", func() { - ids, err := scrobble.UserIDs(ctx, "b") - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(Equal([]string{"2222"})) - }) - }) - }) -}) diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go deleted file mode 100644 index 3d9882b99..000000000 --- a/persistence/scrobble_repository.go +++ /dev/null @@ -1,90 +0,0 @@ -package persistence - -import ( - "context" - "time" - - . "github.com/Masterminds/squirrel" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/model" - "github.com/pocketbase/dbx" -) - -type scrobbleRepository struct { - sqlRepository -} - -func fromTs(_ string, value any) Sqlizer { - return GtOrEq{"scrobbles.submission_time": value} -} - -func toTs(_ string, value any) Sqlizer { - return LtOrEq{"scrobbles.submission_time": value} -} - -func (r *scrobbleRepository) baseQuery(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - user := loggedUser(ctx) - - return r.newSelect(ctx, options...). - Columns("id", "media_file_id", "submission_time"). - Where(Eq{"scrobbles.user_id": user.ID}) -} - -func NewScrobbleRepository(db dbx.Builder) model.ScrobbleRepository { - r := &scrobbleRepository{} - r.db = db - r.tableName = "scrobbles" - r.registerModel(&model.Scrobble{}, map[string]filterFunc{ - "from": fromTs, - "to": toTs, - }) - r.setSortMappings(map[string]string{ - "submission_time": "submission_time", - }) - return r -} - -func (r *scrobbleRepository) RecordScrobble(ctx context.Context, mediaFileID string, submissionTime time.Time) error { - userID := loggedUser(ctx).ID - values := map[string]any{ - "media_file_id": mediaFileID, - "user_id": userID, - "submission_time": submissionTime.Unix(), - } - insert := Insert(r.tableName).SetMap(values) - _, err := r.executeSQL(ctx, insert) - return err -} - -func (r *scrobbleRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - return r.count(ctx, r.baseQuery(ctx), options...) -} - -func (r *scrobbleRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) -} - -func (r *scrobbleRepository) Get(ctx context.Context, id string) (*model.Scrobble, error) { - sel := r.baseQuery(ctx).Where(Eq{"id": id}) - var res model.Scrobble - err := r.queryOne(ctx, sel, &res) - return &res, err -} - -func (r *scrobbleRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Scrobbles, error) { - sel := r.baseQuery(ctx, options...) - var scrobbles model.Scrobbles - err := r.queryAll(ctx, sel, &scrobbles) - return scrobbles, err -} - -func (r *scrobbleRepository) Read(ctx context.Context, id string) (*model.Scrobble, error) { - return r.Get(ctx, id) -} - -func (r *scrobbleRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Scrobble, error) { - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) -} - -var _ model.ScrobbleRepository = (*scrobbleRepository)(nil) -var _ rest.Repository[model.Scrobble] = (*scrobbleRepository)(nil) diff --git a/persistence/scrobble_repository_test.go b/persistence/scrobble_repository_test.go deleted file mode 100644 index 860b1ad02..000000000 --- a/persistence/scrobble_repository_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package persistence - -import ( - "context" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" -) - -var _ = Describe("ScrobbleRepository", func() { - var repo model.ScrobbleRepository - var ctx context.Context - - Describe("RecordScrobble", func() { - var fileID string - var userID string - var rawRepo sqlRepository - - BeforeEach(func() { - fileID = id.NewRandom() - userID = id.NewRandom() - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true}) - db := GetDBXBuilder() - repo = NewScrobbleRepository(db) - - rawRepo = sqlRepository{ - tableName: "scrobbles", - db: db, - } - }) - - AfterEach(func() { - _, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute() - _, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute() - _, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute() - }) - - It("records a scrobble event", func() { - submissionTime := time.Now().UTC() - - // Insert User - _, err := rawRepo.db.Insert("user", dbx.Params{ - "id": userID, - "user_name": "user", - "password": "pw", - "created_at": time.Now(), - "updated_at": time.Now(), - }).Execute() - Expect(err).ToNot(HaveOccurred()) - - // Insert MediaFile - _, err = rawRepo.db.Insert("media_file", dbx.Params{ - "id": fileID, - "path": "path", - "created_at": time.Now(), - "updated_at": time.Now(), - }).Execute() - Expect(err).ToNot(HaveOccurred()) - - err = repo.RecordScrobble(ctx, fileID, submissionTime) - Expect(err).ToNot(HaveOccurred()) - - // Verify insertion - var scrobble struct { - MediaFileID string `db:"media_file_id"` - UserID string `db:"user_id"` - SubmissionTime int64 `db:"submission_time"` - } - err = rawRepo.db.Select("*").From("scrobbles"). - Where(dbx.HashExp{"media_file_id": fileID, "user_id": userID}). - One(&scrobble) - Expect(err).ToNot(HaveOccurred()) - Expect(scrobble.MediaFileID).To(Equal(fileID)) - Expect(scrobble.UserID).To(Equal(userID)) - Expect(scrobble.SubmissionTime).To(Equal(submissionTime.Unix())) - }) - }) - - Context("admin user (id userid)", func() { - BeforeEach(func() { - ctx = request.WithUser(log.NewContext(context.TODO()), adminUser) - repo = NewScrobbleRepository(GetDBXBuilder()) - }) - - Describe("Count", func() { - It("Returns the number of scrobbles in the DB for admin user", func() { - Expect(repo.CountAll(ctx)).To(Equal(int64(2))) - }) - - It("returns scrobbles in a range", func() { - Expect(repo.CountAll(ctx, model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(1))) - }) - }) - - Describe("Get", func() { - It("returns an existing scrobble for the user", func() { - scrobble, err := repo.Get(ctx, "1") - Expect(err).To(BeNil()) - Expect(scrobble.ID).To(Equal(int64(1))) - Expect(scrobble.MediaFileID).To(Equal("1001")) - Expect(scrobble.SubmissionTime).To(Equal(firstScrobble.SubmissionTime)) - - }) - - It("does not return a scrobble that exists for another user", func() { - _, err := repo.Get(ctx, "2") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("does not return a scrobble that does not exist", func() { - _, err := repo.Get(ctx, "444") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("GetAll", func() { - It("returns all scrobbles in reverse order", func() { - scrobbles, err := repo.GetAll(ctx, model.QueryOptions{ - Sort: "submission_time", - Order: "DESC", - }) - Expect(err).To(BeNil()) - Expect(scrobbles).To(HaveLen(2)) - - Expect(scrobbles[0].ID).To(Equal(int64(3))) - Expect(scrobbles[0].MediaFileID).To(Equal("1002")) - Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime)) - - Expect(scrobbles[1].ID).To(Equal(int64(1))) - Expect(scrobbles[1].MediaFileID).To(Equal("1001")) - Expect(scrobbles[1].SubmissionTime).To(Equal(firstScrobble.SubmissionTime)) - }) - - It("returns scrobbles in a range", func() { - scrobbles, err := repo.GetAll(ctx, model.QueryOptions{ - Filters: squirrel.GtOrEq{"submission_time": 1}}) - - Expect(err).To(BeNil()) - Expect(scrobbles).To(HaveLen(1)) - - Expect(scrobbles[0].ID).To(Equal(int64(3))) - Expect(scrobbles[0].MediaFileID).To(Equal("1002")) - Expect(scrobbles[0].SubmissionTime).To(Equal(thirdScrobble.SubmissionTime)) - }) - }) - }) - - Context("non-admin user", func() { - BeforeEach(func() { - ctx = request.WithUser(log.NewContext(context.TODO()), regularUser) - repo = NewScrobbleRepository(GetDBXBuilder()) - }) - - Describe("Count", func() { - It("Returns the number of scrobbles in the DB for admin user", func() { - Expect(repo.CountAll(ctx)).To(Equal(int64(1))) - }) - - It("returns scrobbles in a range", func() { - Expect(repo.CountAll(ctx, model.QueryOptions{Filters: squirrel.LtOrEq{"submission_time": 1}})).To(Equal(int64(0))) - }) - }) - - Describe("Get", func() { - It("returns an existing scrobble for the user", func() { - scrobble, err := repo.Get(ctx, "2") - Expect(err).To(BeNil()) - Expect(scrobble.ID).To(Equal(int64(2))) - Expect(scrobble.MediaFileID).To(Equal("1003")) - Expect(scrobble.SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) - }) - - It("does not return a scrobble that exists for another user", func() { - _, err := repo.Get(ctx, "1") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("does not return a scrobble that does not exist", func() { - _, err := repo.Get(ctx, "444") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("GetAll", func() { - It("returns all scrobbles in reverse order", func() { - scrobbles, err := repo.GetAll(ctx, model.QueryOptions{ - Sort: "submission_time", - Order: "DESC", - }) - Expect(err).To(BeNil()) - Expect(scrobbles).To(HaveLen(1)) - - Expect(scrobbles[0].ID).To(Equal(int64(2))) - Expect(scrobbles[0].MediaFileID).To(Equal("1003")) - Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) - }) - - It("returns scrobbles in a range", func() { - scrobbles, err := repo.GetAll(ctx, model.QueryOptions{ - Filters: squirrel.GtOrEq{"submission_time": 1}}) - - Expect(err).To(BeNil()) - Expect(scrobbles).To(HaveLen(1)) - - Expect(scrobbles[0].ID).To(Equal(int64(2))) - Expect(scrobbles[0].MediaFileID).To(Equal("1003")) - Expect(scrobbles[0].SubmissionTime).To(Equal(secondScrobble.SubmissionTime)) - }) - }) - }) -}) diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 6dd9c3d85..ac586e6be 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -2,66 +2,68 @@ package persistence import ( "context" + "errors" "fmt" "strings" "time" . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" - "github.com/pocketbase/dbx" ) type shareRepository struct { sqlRepository + sqlRestful } -func NewShareRepository(db dbx.Builder) model.ShareRepository { +func NewShareRepository(ctx context.Context, o orm.QueryExecutor) model.ShareRepository { r := &shareRepository{} - r.db = db - r.registerModel(&model.Share{}, nil) - r.setSortMappings(map[string]string{ - "username": "username", - }) + r.ctx = ctx + r.ormer = o + r.tableName = "share" return r } -func (r *shareRepository) Delete(ctx context.Context, ids ...string) error { - return r.deleteOwnedAll(ctx, ids...) +func (r *shareRepository) Delete(id string) error { + err := r.delete(Eq{"id": id}) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err } -func (r *shareRepository) selectShare(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - return r.newSelect(ctx, options...).Join("user u on u.id = share.user_id"). - Columns("share.*", "user_name as username"). - Where(r.addRestriction(ctx)) +func (r *shareRepository) selectShare(options ...model.QueryOptions) SelectBuilder { + return r.newSelect(options...).Join("user u on u.id = share.user_id"). + Columns("share.*", "user_name as username") } -func (r *shareRepository) Exists(ctx context.Context, id string) (bool, error) { - return r.exists(ctx, r.addRestriction(ctx, And{Eq{"id": id}})) +func (r *shareRepository) Exists(id string) (bool, error) { + return r.exists(Select().Where(Eq{"id": id})) } - -func (r *shareRepository) Get(ctx context.Context, id string) (*model.Share, error) { - sel := r.selectShare(ctx).Where(Eq{"share.id": id}) +func (r *shareRepository) Get(id string) (*model.Share, error) { + sel := r.selectShare().Where(Eq{"share.id": id}) var res model.Share - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) if err != nil { return nil, err } - err = r.loadMedia(ctx, &res) + err = r.loadMedia(&res) return &res, err } -func (r *shareRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Shares, error) { - sq := r.selectShare(ctx, options...) +func (r *shareRepository) GetAll(options ...model.QueryOptions) (model.Shares, error) { + sq := r.selectShare(options...) res := model.Shares{} - err := r.queryAll(ctx, sq, &res) + err := r.queryAll(sq, &res) if err != nil { return nil, err } for i := range res { - err = r.loadMedia(ctx, &res[i]) + err = r.loadMedia(&res[i]) if err != nil { return nil, fmt.Errorf("error loading media for share %s: %w", res[i].ID, err) } @@ -69,78 +71,53 @@ func (r *shareRepository) GetAll(ctx context.Context, options ...model.QueryOpti return res, err } -func (r *shareRepository) loadMedia(ctx context.Context, share *model.Share) error { +func (r *shareRepository) loadMedia(share *model.Share) error { + var err error ids := strings.Split(share.ResourceIDs, ",") if len(ids) == 0 { return nil } - noMissing := func(cond Sqlizer) Sqlizer { - return And{cond, Eq{"missing": false}} - } - // Load as the share owner so their library access is applied, whoever renders the share. - ownerCtx, err := r.ownerContext(ctx, share) - if err != nil { - return err - } switch share.ResourceType { case "artist": - // Match by album-artist participation, not the deprecated album_artist_id - // column (first album artist only), so co-album-artists are included too. - albumRepo := NewAlbumRepository(r.db) - share.Albums, err = albumRepo.GetAll(ownerCtx, model.QueryOptions{Filters: noMissing(ParticipantIDFilter("album", ids, model.RoleAlbumArtist)), Sort: "artist"}) + albumRepo := NewAlbumRepository(r.ctx, r.ormer) + share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: Eq{"album_artist_id": ids}, Sort: "artist"}) if err != nil { return err } - mfRepo := NewMediaFileRepository(r.db) - share.Tracks, err = mfRepo.GetAll(ownerCtx, model.QueryOptions{Filters: noMissing(ParticipantIDFilter("media_file", ids, model.RoleAlbumArtist)), Sort: "artist"}) + mfRepo := NewMediaFileRepository(r.ctx, r.ormer) + share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: Eq{"album_artist_id": ids}, Sort: "artist"}) return err case "album": - albumRepo := NewAlbumRepository(r.db) - share.Albums, err = albumRepo.GetAll(ownerCtx, model.QueryOptions{Filters: noMissing(Eq{"album.id": ids})}) + albumRepo := NewAlbumRepository(r.ctx, r.ormer) + share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: Eq{"id": ids}}) if err != nil { return err } - mfRepo := NewMediaFileRepository(r.db) - share.Tracks, err = mfRepo.GetAll(ownerCtx, model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"}) + mfRepo := NewMediaFileRepository(r.ctx, r.ormer) + share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: Eq{"album_id": ids}, Sort: "album"}) return err case "playlist": - plsRepo := NewPlaylistRepository(r.db) - // Tracks returns nil when the playlist is no longer visible to the owner - // (e.g. it was made private after the share was created); leave the share - // with no tracks rather than exposing it. - trackRepo := plsRepo.Tracks(ownerCtx, ids[0], true) - if trackRepo == nil { - return nil - } - tracks, err := trackRepo.GetAll(ownerCtx, model.QueryOptions{Sort: "id", Filters: noMissing(Eq{})}) + // Create a context with a fake admin user, to be able to access all playlists + ctx := request.WithUser(r.ctx, model.User{IsAdmin: true}) + plsRepo := NewPlaylistRepository(ctx, r.ormer) + tracks, err := plsRepo.Tracks(ids[0], true).GetAll(model.QueryOptions{Sort: "id"}) if err != nil { return err } - share.Tracks = tracks.MediaFiles() + if len(tracks) >= 0 { + share.Tracks = tracks.MediaFiles() + } return nil case "media_file": - mfRepo := NewMediaFileRepository(r.db) - tracks, err := mfRepo.GetAll(ownerCtx, model.QueryOptions{Filters: noMissing(Eq{"media_file.id": ids})}) + mfRepo := NewMediaFileRepository(r.ctx, r.ormer) + tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: Eq{"id": ids}}) share.Tracks = sortByIdPosition(tracks, ids) return err } - log.Warn(ctx, "Unsupported Share ResourceType", "share", share.ID, "resourceType", share.ResourceType) + log.Warn(r.ctx, "Unsupported Share ResourceType", "share", share.ID, "resourceType", share.ResourceType) return nil } -// ownerContext returns a context scoped to the share owner, so repository -// queries apply the owner's library access when a public share is rendered. -func (r *shareRepository) ownerContext(ctx context.Context, share *model.Share) (context.Context, error) { - owner, err := NewUserRepository(r.db).Get(ctx, share.UserID) - if err != nil { - return nil, fmt.Errorf("loading share owner %q: %w", share.UserID, err) - } - if owner == nil { - return nil, fmt.Errorf("share owner %q not found", share.UserID) - } - return request.WithUser(ctx, *owner), nil -} - func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { m := map[string]int{} for i, mf := range mfs { @@ -155,51 +132,65 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { return sorted } -func (r *shareRepository) Update(ctx context.Context, id string, entity model.Share, cols ...string) error { - s := &entity +func (r *shareRepository) Update(id string, entity interface{}, cols ...string) error { + s := entity.(*model.Share) + // TODO Validate record s.ID = id s.UpdatedAt = time.Now() - if len(cols) > 0 { - cols = append(cols, "updated_at") + cols = append(cols, "updated_at") + _, err := r.put(id, s, cols...) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound } - return r.updateOwned(ctx, id, s, cols...) + return err } -func (r *shareRepository) Save(ctx context.Context, s *model.Share) (string, error) { +func (r *shareRepository) Save(entity interface{}) (string, error) { + s := entity.(*model.Share) // TODO Validate record - // Owner is server-managed: for an authenticated request, never trust a - // client-supplied UserID, as it drives the share's library-access context. - u := loggedUser(ctx) - if u.ID != invalidUserId || s.UserID == "" { + u := loggedUser(r.ctx) + if s.UserID == "" { s.UserID = u.ID } s.CreatedAt = time.Now() s.UpdatedAt = time.Now() - return r.put(ctx, s.ID, s) + id, err := r.put(s.ID, s) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound + } + return id, err } -func (r *shareRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) { - return r.count(ctx, r.selectShare(ctx), options...) +func (r *shareRepository) CountAll(options ...model.QueryOptions) (int64, error) { + return r.count(r.selectShare(), options...) } -func (r *shareRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) +func (r *shareRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(options...)) } -func (r *shareRepository) Read(ctx context.Context, id string) (*model.Share, error) { - sel := r.selectShare(ctx).Where(Eq{"share.id": id}) +func (r *shareRepository) EntityName() string { + return "share" +} + +func (r *shareRepository) NewInstance() interface{} { + return &model.Share{} +} + +func (r *shareRepository) Read(id string) (interface{}, error) { + sel := r.selectShare().Where(Eq{"share.id": id}) var res model.Share - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) return &res, err } -func (r *shareRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Share, error) { - sq := r.selectShare(ctx, r.parseRestOptions(ctx, options...)) +func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + sq := r.selectShare(r.parseRestOptions(options...)) res := model.Shares{} - err := r.queryAll(ctx, sq, &res) + err := r.queryAll(sq, &res) return res, err } var _ model.ShareRepository = (*shareRepository)(nil) -var _ rest.Repository[model.Share] = (*shareRepository)(nil) -var _ rest.Persistable[model.Share] = (*shareRepository)(nil) +var _ rest.Repository = (*shareRepository)(nil) +var _ rest.Persistable = (*shareRepository)(nil) diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go deleted file mode 100644 index 33b5e2110..000000000 --- a/persistence/share_repository_test.go +++ /dev/null @@ -1,631 +0,0 @@ -package persistence - -import ( - "context" - "time" - - "github.com/Masterminds/squirrel" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("ShareRepository", func() { - var repo model.ShareRepository - var ctx context.Context - var adminUser = model.User{ID: "admin", UserName: "admin", IsAdmin: true} - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - repo = NewShareRepository(GetDBXBuilder()) - - // Insert the admin user into the database (required for foreign key constraint) - ur := NewUserRepository(GetDBXBuilder()) - err := ur.Put(ctx, &adminUser) - Expect(err).ToNot(HaveOccurred()) - - // Clean up shares - db := GetDBXBuilder() - _, err = db.NewQuery("DELETE FROM share").Execute() - Expect(err).ToNot(HaveOccurred()) - }) - - Describe("Headless Access", func() { - Context("Repository creation and basic operations", func() { - var headlessCtx context.Context - - BeforeEach(func() { - headlessCtx = GinkgoT().Context() - }) - - It("should create repository successfully with no user context", func() { - // Create repository with no user context (headless) - headlessRepo := NewShareRepository(GetDBXBuilder()) - Expect(headlessRepo).ToNot(BeNil()) - }) - - It("should handle GetAll for headless processes", func() { - // Create a simple share directly in database - shareID := "headless-test-share" - _, err := GetDBXBuilder().NewQuery(` - INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) - VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) - `).Bind(map[string]any{ - "id": shareID, - "user": adminUser.ID, - "desc": "Headless Test Share", - "type": "song", - "ids": "song-1", - "created": time.Now(), - "updated": time.Now(), - }).Execute() - Expect(err).ToNot(HaveOccurred()) - - // Headless process should see all shares - headlessRepo := NewShareRepository(GetDBXBuilder()) - shares, err := headlessRepo.GetAll(headlessCtx) - Expect(err).ToNot(HaveOccurred()) - - found := false - for _, s := range shares { - if s.ID == shareID { - found = true - break - } - } - Expect(found).To(BeTrue(), "Headless process should see all shares") - }) - - It("should handle individual share retrieval for headless processes", func() { - // Create a simple share - shareID := "headless-get-share" - _, err := GetDBXBuilder().NewQuery(` - INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) - VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) - `).Bind(map[string]any{ - "id": shareID, - "user": adminUser.ID, - "desc": "Headless Get Share", - "type": "song", - "ids": "song-2", - "created": time.Now(), - "updated": time.Now(), - }).Execute() - Expect(err).ToNot(HaveOccurred()) - - // Headless process should be able to get the share - headlessRepo := NewShareRepository(GetDBXBuilder()) - share, err := headlessRepo.Get(headlessCtx, shareID) - Expect(err).ToNot(HaveOccurred()) - Expect(share.ID).To(Equal(shareID)) - Expect(share.Description).To(Equal("Headless Get Share")) - }) - }) - }) - - Describe("SQL ambiguity fix verification", func() { - It("should handle share operations without SQL ambiguity errors", func() { - // This test verifies that the loadMedia function doesn't cause SQL ambiguity - // The key fix was using "album.id" instead of "id" in the album query filters - - // Create a share that would trigger the loadMedia function - shareID := "sql-test-share" - _, err := GetDBXBuilder().NewQuery(` - INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) - VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) - `).Bind(map[string]any{ - "id": shareID, - "user": adminUser.ID, - "desc": "SQL Test Share", - "type": "album", - "ids": "non-existent-album", // Won't find albums, but shouldn't cause SQL errors - "created": time.Now(), - "updated": time.Now(), - }).Execute() - Expect(err).ToNot(HaveOccurred()) - - // The Get operation should work without SQL ambiguity errors - // even if no albums are found - share, err := repo.Get(ctx, shareID) - Expect(err).ToNot(HaveOccurred()) - Expect(share.ID).To(Equal(shareID)) - // Albums array should be empty since we used non-existent album ID - Expect(share.Albums).To(BeEmpty()) - }) - }) - - Describe("Playlist share library scoping", func() { - var otherLib model.Library - var owner model.User - var plsID string - - BeforeEach(func() { - adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - - // A second library the owner has no access to, plus a track in it - lr := NewLibraryRepository(GetDBXBuilder()) - otherLib = model.Library{ID: 0, Name: "Share Other Library", Path: "/share/other/lib"} - Expect(lr.Put(adminCtx, &otherLib)).To(Succeed()) - mr := NewMediaFileRepository(GetDBXBuilder()) - Expect(mr.Put(adminCtx, &model.MediaFile{ID: "share-other", LibraryID: otherLib.ID, Path: "s/other.mp3", Title: "ShareOther"})).To(Succeed()) - Expect(mr.Put(adminCtx, &model.MediaFile{ID: "share-ok", LibraryID: 1, Path: "s/ok.mp3", Title: "ShareOK"})).To(Succeed()) - - // Non-admin owner with access to library 1 only - owner = createUserWithLibraries("share-owner", []int{1}) - ur := NewUserRepository(GetDBXBuilder()) - Expect(ur.Put(adminCtx, &owner)).To(Succeed()) - Expect(ur.SetUserLibraries(adminCtx, owner.ID, []int{1})).To(Succeed()) - - // Owner-owned playlist containing tracks from both libraries - plsID = "share-scope-pls" - ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), owner) - pr := NewPlaylistRepository(GetDBXBuilder()) - pls := &model.Playlist{ID: plsID, Name: "Scope Test", OwnerID: owner.ID} - pls.AddMediaFiles(model.MediaFiles{{ID: "share-ok"}, {ID: "share-other"}}) - Expect(pr.Put(ownerCtx, pls)).To(Succeed()) - - // Share row owned by the non-admin owner - _, err := GetDBXBuilder().NewQuery(` - INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) - VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) - `).Bind(map[string]any{ - "id": "share-scope", "user": owner.ID, "desc": "Scope test share", - "type": "playlist", "ids": plsID, "created": time.Now(), "updated": time.Now(), - }).Execute() - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - b := GetDBXBuilder() - _, _ = b.NewQuery(`DELETE FROM share WHERE id = 'share-scope'`).Execute() - pr := NewPlaylistRepository(b) - _ = pr.Delete(adminCtx, plsID) - mr := NewMediaFileRepository(b).(*mediaFileRepository) - _, _ = mr.executeSQL(adminCtx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"share-other", "share-ok"}})) - lr := NewLibraryRepository(b).(*libraryRepository) - _ = lr.delete(adminCtx, squirrel.Eq{"id": otherLib.ID}) - _ = NewUserRepository(b).Delete(adminCtx, owner.ID) - }) - - It("excludes tracks the owner cannot access from the shared playlist", func() { - // Read the share as admin (mimics the public-share render path, which uses - // the share repository's own context). loadMedia must scope to the owner. - adminCtx := request.WithUser(ctx, adminUser) - adminRepo := NewShareRepository(GetDBXBuilder()) - share, err := adminRepo.Get(adminCtx, "share-scope") - Expect(err).ToNot(HaveOccurred()) - - Expect(share.Tracks).To(ContainElement(HaveField("ID", "share-ok"))) - Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "share-other")), - "a track outside the owner's libraries must not appear in the share") - }) - - It("returns no tracks when the playlist is not visible to the owner", func() { - // A private playlist owned by someone else: the share owner can no longer - // see it, so Tracks() returns nil. The share must render with no tracks - // instead of panicking. - privatePlsID := "private-pls" - adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - pr := NewPlaylistRepository(GetDBXBuilder()) - privatePls := &model.Playlist{ID: privatePlsID, Name: "Private", OwnerID: adminUser.ID, Public: false} - privatePls.AddMediaFiles(model.MediaFiles{{ID: "share-ok"}}) - Expect(pr.Put(adminCtx, privatePls)).To(Succeed()) - DeferCleanup(func() { _ = pr.Delete(adminCtx, privatePlsID) }) - - _, err := GetDBXBuilder().NewQuery(` - INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) - VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) - `).Bind(map[string]any{ - "id": "share-private", "user": owner.ID, "desc": "Private share", - "type": "playlist", "ids": privatePlsID, "created": time.Now(), "updated": time.Now(), - }).Execute() - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { _, _ = GetDBXBuilder().NewQuery(`DELETE FROM share WHERE id = 'share-private'`).Execute() }) - - adminRepo := NewShareRepository(GetDBXBuilder()) - share, err := adminRepo.Get(adminCtx, "share-private") - Expect(err).ToNot(HaveOccurred()) - Expect(share.Tracks).To(BeEmpty()) - }) - }) - - Describe("Artist, album and media file share library scoping", func() { - var otherLib model.Library - var owner model.User - const primaryID = "share-aa-primary" - const secondaryID = "share-aa-secondary" - - BeforeEach(func() { - adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - b := GetDBXBuilder() - - // A second library the owner has no access to - lr := NewLibraryRepository(b) - otherLib = model.Library{ID: 0, Name: "Artist Share Other Library", Path: "/share/artist/other"} - Expect(lr.Put(adminCtx, &otherLib)).To(Succeed()) - - ar := NewArtistRepository(b) - Expect(createArtistWithLibrary(adminCtx, ar, &model.Artist{ID: primaryID, Name: "AA Primary", OrderArtistName: "aa primary"}, 1)).To(Succeed()) - Expect(createArtistWithLibrary(adminCtx, ar, &model.Artist{ID: secondaryID, Name: "AA Secondary", OrderArtistName: "aa secondary"}, 1)).To(Succeed()) - - // Secondary is a co-album-artist (not the first): album_artist_id points at - // primary, so the legacy-column filter would miss both tracks. - aaParticipants := model.Participants{model.RoleAlbumArtist: { - {Artist: model.Artist{ID: primaryID, Name: "AA Primary"}}, - {Artist: model.Artist{ID: secondaryID, Name: "AA Secondary"}}, - }} - alr := NewAlbumRepository(b) - Expect(alr.Put(ctx, &model.Album{ID: "art-album-ok", LibraryID: 1, Name: "Art Album OK", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed()) - Expect(alr.Put(ctx, &model.Album{ID: "art-album-other", LibraryID: otherLib.ID, Name: "Art Album Other", AlbumArtistID: primaryID, AlbumArtist: "AA Primary", Participants: aaParticipants})).To(Succeed()) - - mr := NewMediaFileRepository(b) - Expect(mr.Put(adminCtx, &model.MediaFile{ID: "art-ok", LibraryID: 1, AlbumID: "art-album-ok", Path: "a/ok.mp3", Title: "ArtOK", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed()) - Expect(mr.Put(adminCtx, &model.MediaFile{ID: "art-other", LibraryID: otherLib.ID, AlbumID: "art-album-other", Path: "a/other.mp3", Title: "ArtOther", AlbumArtistID: primaryID, Participants: aaParticipants})).To(Succeed()) - - // Non-admin owner with access to library 1 only - owner = createUserWithLibraries("artist-share-owner", []int{1}) - ur := NewUserRepository(b) - Expect(ur.Put(adminCtx, &owner)).To(Succeed()) - Expect(ur.SetUserLibraries(adminCtx, owner.ID, []int{1})).To(Succeed()) - - for _, s := range []struct{ id, typ, ids string }{ - {"art-share", "artist", secondaryID}, - {"art-album-share", "album", "art-album-ok,art-album-other"}, - {"art-mf-share", "media_file", "art-ok,art-other"}, - } { - _, err := b.NewQuery(` - INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) - VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) - `).Bind(map[string]any{ - "id": s.id, "user": owner.ID, "desc": "Scope share", - "type": s.typ, "ids": s.ids, "created": time.Now(), "updated": time.Now(), - }).Execute() - Expect(err).ToNot(HaveOccurred()) - } - }) - - AfterEach(func() { - adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - b := GetDBXBuilder() - _, _ = b.NewQuery(`DELETE FROM share WHERE id IN ('art-share', 'art-album-share', 'art-mf-share')`).Execute() - mr := NewMediaFileRepository(b).(*mediaFileRepository) - _, _ = mr.executeSQL(adminCtx, squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"art-ok", "art-other"}})) - alr := NewAlbumRepository(b).(*albumRepository) - _, _ = alr.executeSQL(adminCtx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{"art-album-ok", "art-album-other"}})) - ar := NewArtistRepository(b).(*artistRepository) - _, _ = ar.executeSQL(adminCtx, squirrel.Delete("artist").Where(squirrel.Eq{"id": []string{primaryID, secondaryID}})) - lr := NewLibraryRepository(b).(*libraryRepository) - _ = lr.delete(adminCtx, squirrel.Eq{"id": otherLib.ID}) - _ = NewUserRepository(b).Delete(adminCtx, owner.ID) - }) - - It("includes co-album-artist tracks the owner can access and excludes those they cannot", func() { - // Read as admin (mimics the public-share render path); loadMedia must still - // scope to the owner's libraries. - adminCtx := request.WithUser(ctx, adminUser) - adminRepo := NewShareRepository(GetDBXBuilder()) - share, err := adminRepo.Get(adminCtx, "art-share") - Expect(err).ToNot(HaveOccurred()) - - Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok")), - "a co-album-artist track (not matched by album_artist_id) must be included") - Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other")), - "a track outside the owner's libraries must not appear in the share") - - Expect(share.Albums).To(ContainElement(HaveField("ID", "art-album-ok")), - "a co-album-artist album must be included") - Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other")), - "an album outside the owner's libraries must not appear in the share") - }) - - It("excludes albums and their tracks outside the owner's libraries from an album share", func() { - // Public share rendering has no user in the context. - share, err := NewShareRepository(GetDBXBuilder()).Get(log.NewContext(GinkgoT().Context()), "art-album-share") - Expect(err).ToNot(HaveOccurred()) - Expect(share.Albums).To(ContainElement(HaveField("ID", "art-album-ok"))) - Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other"))) - Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok"))) - Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other"))) - }) - - It("excludes tracks outside the owner's libraries from a media file share", func() { - share, err := NewShareRepository(GetDBXBuilder()).Get(log.NewContext(GinkgoT().Context()), "art-mf-share") - Expect(err).ToNot(HaveOccurred()) - Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok"))) - Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other"))) - }) - }) - - Describe("Ownership Checks", func() { - var ownerUser = model.User{ID: "2222", UserName: "regular-user"} - var otherUser = model.User{ID: "3333", UserName: "third-user"} - - insertShare := func(shareID, userID string) { - _, err := GetDBXBuilder().NewQuery(` - INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at) - VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated}) - `).Bind(map[string]any{ - "id": shareID, - "user": userID, - "desc": "Test Share", - "type": "media_file", - "ids": "1001", - "created": time.Now(), - "updated": time.Now(), - }).Execute() - Expect(err).ToNot(HaveOccurred()) - } - - Describe("Delete", func() { - It("allows a non-admin user to delete their own share", func() { - insertShare("own-share-del", ownerUser.ID) - ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Delete(ctx, "own-share-del") - Expect(err).ToNot(HaveOccurred()) - }) - - It("denies a non-admin user from deleting another user's share", func() { - insertShare("other-share-del", ownerUser.ID) - ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Delete(ctx, "other-share-del") - Expect(err).To(Equal(rest.ErrPermissionDenied)) - - // The share was not deleted: the owner can still read it. - ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) - ownerRepo := NewShareRepository(GetDBXBuilder()) - _, err = ownerRepo.Read(ownerCtx, "other-share-del") - Expect(err).ToNot(HaveOccurred()) - }) - - It("allows an admin to delete any user's share", func() { - insertShare("admin-del-share", ownerUser.ID) - ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Delete(ctx, "admin-del-share") - Expect(err).ToNot(HaveOccurred()) - }) - - It("allows headless context (no user) to delete a share", func() { - insertShare("headless-del-share", ownerUser.ID) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Delete(GinkgoT().Context(), "headless-del-share") - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("Save", func() { - It("assigns the logged-in user as owner, ignoring a client-supplied UserID", func() { - ur := NewUserRepository(GetDBXBuilder()) - Expect(ur.Put(ctx, &ownerUser)).To(Succeed()) - Expect(ur.Put(ctx, &otherUser)).To(Succeed()) - - attackerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) - attackerRepo := NewShareRepository(GetDBXBuilder()) - - id, err := attackerRepo.Save(attackerCtx, &model.Share{ - ID: "spoof-save-share", UserID: otherUser.ID, - ResourceType: "media_file", ResourceIDs: "1001", - }) - Expect(err).ToNot(HaveOccurred()) - - adminCtx := request.WithUser(ctx, adminUser) - adminRepo := NewShareRepository(GetDBXBuilder()) - got, err := adminRepo.Get(adminCtx, id) - Expect(err).ToNot(HaveOccurred()) - Expect(got.UserID).To(Equal(ownerUser.ID)) - }) - }) - - Describe("Update", func() { - It("allows a non-admin user to update their own share", func() { - insertShare("own-share-upd", ownerUser.ID) - ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Update(ctx, "own-share-upd", model.Share{Description: "Updated"}, "description") - Expect(err).ToNot(HaveOccurred()) - }) - - It("denies a non-admin user from updating another user's share", func() { - insertShare("other-share-upd", ownerUser.ID) - ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Update(ctx, "other-share-upd", model.Share{Description: "Hacked"}, "description") - Expect(err).To(Equal(rest.ErrPermissionDenied)) - }) - - It("allows an admin to update any user's share", func() { - insertShare("admin-upd-share", ownerUser.ID) - ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Update(ctx, "admin-upd-share", model.Share{Description: "Admin Updated"}, "description") - Expect(err).ToNot(HaveOccurred()) - }) - - It("allows headless context (no user) to update a share", func() { - insertShare("headless-upd-share", ownerUser.ID) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Update(GinkgoT().Context(), "headless-upd-share", model.Share{Description: "Headless"}, "description") - Expect(err).ToNot(HaveOccurred()) - }) - - It("returns not found when updating a nonexistent share", func() { - ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Update(ctx, "does-not-exist", model.Share{Description: "Ghost"}, "description") - Expect(err).To(Equal(rest.ErrNotFound)) - }) - - It("updates all columns when no specific columns are given", func() { - insertShare("all-cols-share", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) - repo := NewShareRepository(GetDBXBuilder()) - // No cols: the update must write every column, not just updated_at. - err := repo.Update(ctx, "all-cols-share", - model.Share{Description: "All Updated", MaxBitRate: 192, ResourceType: "album", ResourceIDs: "2002"}) - Expect(err).ToNot(HaveOccurred()) - - share, err := repo.Read(ctx, "all-cols-share") - Expect(err).ToNot(HaveOccurred()) - Expect(share.Description).To(Equal("All Updated")) - Expect(share.MaxBitRate).To(Equal(192)) - Expect(share.ResourceType).To(Equal("album")) - }) - - It("does not let an owner reassign their share to another user", func() { - insertShare("reassign-share", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) - repo := NewShareRepository(GetDBXBuilder()) - err := repo.Update(ctx, "reassign-share", - model.Share{UserID: otherUser.ID, Description: "Given away"}, "user_id", "description") - Expect(err).ToNot(HaveOccurred()) - - // Ownership must not have moved, even though user_id was passed in the body and cols. - got, err := repo.Read(ctx, "reassign-share") - Expect(err).ToNot(HaveOccurred()) - Expect(got.UserID).To(Equal(ownerUser.ID)) - }) - }) - - Describe("Read scoping", func() { - BeforeEach(func() { - // Persist owner/other users so the JOIN in selectShare resolves. - ur := NewUserRepository(GetDBXBuilder()) - Expect(ur.Put(ctx, &ownerUser)).To(Succeed()) - Expect(ur.Put(ctx, &otherUser)).To(Succeed()) - - insertShare("share-owner-1", ownerUser.ID) - insertShare("share-owner-2", ownerUser.ID) - insertShare("share-other-1", otherUser.ID) - }) - - Context("non-admin user", func() { - var nonAdminRepo model.ShareRepository - var nonAdminCtx context.Context - - BeforeEach(func() { - nonAdminCtx = request.WithUser(ctx, ownerUser) - nonAdminRepo = NewShareRepository(GetDBXBuilder()) - }) - - It("GetAll returns only own shares", func() { - shares, err := nonAdminRepo.GetAll(nonAdminCtx) - Expect(err).ToNot(HaveOccurred()) - ids := make([]string, len(shares)) - for i, s := range shares { - ids[i] = s.ID - } - Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2")) - }) - - It("ReadAll returns only own shares", func() { - shares, err := nonAdminRepo.ReadAll(nonAdminCtx) - Expect(err).ToNot(HaveOccurred()) - ids := make([]string, len(shares)) - for i, s := range shares { - ids[i] = s.ID - } - Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2")) - }) - - It("Get returns own share", func() { - s, err := nonAdminRepo.Get(nonAdminCtx, "share-owner-1") - Expect(err).ToNot(HaveOccurred()) - Expect(s.ID).To(Equal("share-owner-1")) - }) - - It("Get returns ErrNotFound for another user's share", func() { - _, err := nonAdminRepo.Get(nonAdminCtx, "share-other-1") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("Read returns ErrNotFound for another user's share", func() { - _, err := nonAdminRepo.Read(nonAdminCtx, "share-other-1") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("Exists returns true for own share", func() { - exists, err := nonAdminRepo.Exists(nonAdminCtx, "share-owner-1") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - }) - - It("Exists returns false for another user's share", func() { - exists, err := nonAdminRepo.Exists(nonAdminCtx, "share-other-1") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - }) - - It("CountAll counts only own shares", func() { - count, err := nonAdminRepo.CountAll(nonAdminCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeNumerically("==", 2)) - }) - - It("Count (rest) counts only own shares", func() { - count, err := nonAdminRepo.Count(nonAdminCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeNumerically("==", 2)) - }) - }) - - Context("admin user", func() { - It("GetAll returns all shares", func() { - adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - adminRepo := NewShareRepository(GetDBXBuilder()) - shares, err := adminRepo.GetAll(adminCtx) - Expect(err).ToNot(HaveOccurred()) - ids := make([]string, len(shares)) - for i, s := range shares { - ids[i] = s.ID - } - Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2", "share-other-1")) - }) - - It("CountAll counts all shares", func() { - adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - adminRepo := NewShareRepository(GetDBXBuilder()) - count, err := adminRepo.CountAll(adminCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeNumerically("==", 3)) - }) - }) - - Context("headless context (public share route)", func() { - var headlessCtx context.Context - - BeforeEach(func() { - headlessCtx = GinkgoT().Context() - }) - - It("GetAll returns all shares", func() { - headlessRepo := NewShareRepository(GetDBXBuilder()) - shares, err := headlessRepo.GetAll(headlessCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(shares).To(HaveLen(3)) - }) - - It("Get returns another user's share", func() { - headlessRepo := NewShareRepository(GetDBXBuilder()) - s, err := headlessRepo.Get(headlessCtx, "share-other-1") - Expect(err).ToNot(HaveOccurred()) - Expect(s.ID).To(Equal("share-other-1")) - }) - - It("Exists returns true for any share", func() { - headlessRepo := NewShareRepository(GetDBXBuilder()) - exists, err := headlessRepo.Exists(headlessCtx, "share-other-1") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - }) - }) - }) - }) -}) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go deleted file mode 100644 index 871f53dd2..000000000 --- a/persistence/smart_playlist_repository.go +++ /dev/null @@ -1,205 +0,0 @@ -package persistence - -import ( - "context" - "slices" - "time" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" - "golang.org/x/text/unicode/norm" -) - -// PlaylistRepository methods to handle smart playlists, which are defined by criteria and automatically populated -// 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(ctx context.Context, pls *model.Playlist) bool { - return r.refreshSmartPlaylistTree(ctx, pls, map[string]struct{}{}) -} - -// The visited set stops playlists that reference each other from recursing forever. -func (r *playlistRepository) refreshSmartPlaylistTree(ctx context.Context, pls *model.Playlist, visited map[string]struct{}) bool { - if _, seen := visited[pls.ID]; seen { - log.Trace(ctx, "Skipping already visited smart playlist", "playlist", pls.Name, "id", pls.ID) - return false - } - visited[pls.ID] = struct{}{} - - usr := loggedUser(ctx) - if !r.shouldRefreshSmartPlaylist(ctx, pls, usr) { - return false - } - - log.Debug(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(ctx, del); err != nil { - log.Error(ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - rulesSQL := newSmartPlaylistCriteria(*pls.NormalizedRules(), withSmartPlaylistOwner(*usr)) - - if !r.refreshChildPlaylists(ctx, pls, rulesSQL, visited) { - return false - } - - if err := r.resolvePercentageLimit(ctx, pls, &rulesSQL, usr.ID); err != nil { - return false - } - - sq := r.buildSmartPlaylistQuery(ctx, pls, rulesSQL, usr.ID) - sq, err := r.addCriteria(sq, rulesSQL) - if err != nil { - log.Error(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(ctx, insSql); err != nil { - log.Error(ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - if err = r.refreshCounters(ctx, pls); err != nil { - log.Error(ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - // Reuse the stamp refreshCounters just wrote, so evaluated_at and updated_at agree - now := pls.UpdatedAt - updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) - if _, err = r.executeSQL(ctx, updSql); err != nil { - log.Error(ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) - return false - } - pls.EvaluatedAt = &now - - log.Debug(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(ctx context.Context, pls *model.Playlist, usr *model.User) bool { - if !pls.IsSmartPlaylist() { - return false - } - if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < pls.RefreshDelay() { - return false - } - if pls.OwnerID != usr.ID { - log.Trace(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(ctx context.Context, pls *model.Playlist, rulesSQL smartPlaylistCriteria, visited map[string]struct{}) bool { - childPlaylistIds := rulesSQL.ChildPlaylistIds() - childPlaylistPaths := rulesSQL.ChildPlaylistPaths() - if len(childPlaylistIds) == 0 && len(childPlaylistPaths) == 0 { - return true - } - - var conditions Or - if len(childPlaylistIds) > 0 { - conditions = append(conditions, Eq{"playlist.id": childPlaylistIds}) - } - if len(childPlaylistPaths) > 0 { - lookupPaths := slices.Concat(slice.Map(childPlaylistPaths, pathVariants)...) - conditions = append(conditions, Eq{"playlist.path": lookupPaths}) - } - - childPlaylists, err := r.GetAll(ctx, model.QueryOptions{Filters: conditions}) - if err != nil { - log.Error(ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, "childPaths", childPlaylistPaths, err) - return false - } - - found := make(map[string]struct{}, len(childPlaylists)*2) - for i := range childPlaylists { - found[childPlaylists[i].ID] = struct{}{} - if childPlaylists[i].Path != "" { - found[norm.NFC.String(childPlaylists[i].Path)] = struct{}{} - } - r.refreshSmartPlaylistTree(ctx, &childPlaylists[i], visited) - } - for _, id := range childPlaylistIds { - if _, ok := found[id]; !ok { - log.Warn(ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) - } - } - - for _, path := range childPlaylistPaths { - if _, ok := found[norm.NFC.String(path)]; !ok { - log.Warn(ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "path", path, "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(ctx context.Context, pls *model.Playlist, rulesSQL *smartPlaylistCriteria, userID string) error { - if !rulesSQL.IsPercentageLimit() { - return nil - } - - countSq := Select("count(*) as count").From("media_file") - countSq = rulesSQL.applyExpressionJoins(countSq, userID) - countSq = r.applyLibraryFilter(ctx, countSq, "media_file") - - cond, err := rulesSQL.where() - if err != nil { - log.Error(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(ctx, countSq, &res); err != nil { - log.Error(ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err) - return err - } - - rulesSQL.ResolveLimit(res.Count) - log.Debug(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 the joins its fields require and library filtering. -func (r *playlistRepository) buildSmartPlaylistQuery(ctx context.Context, 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 = rulesSQL.applyRequiredJoins(sq, userID) - sq = r.applyLibraryFilter(ctx, sq, "media_file") - 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 deleted file mode 100644 index 4d4c5edb0..000000000 --- a/persistence/smart_playlist_repository_test.go +++ /dev/null @@ -1,810 +0,0 @@ -package persistence - -import ( - "context" - "path/filepath" - "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/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" -) - -var _ = Describe("PlaylistRepository - Smart Playlists", func() { - var repo model.PlaylistRepository - var ctx context.Context - - BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - repo = NewPlaylistRepository(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(ctx, &newPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - - savedPls, err := repo.Get(ctx, newPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(savedPls.Rules).To(Equal(rules)) - }) - }) - - Context("after an evaluation", func() { - It("stamps updated_at and evaluated_at with the same instant", func() { - newPls := model.Playlist{Name: "Evaluated", OwnerID: "userid", Rules: rules} - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - - refreshed, err := repo.GetWithTracks(ctx, newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - stored, err := repo.Get(ctx, newPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(stored.EvaluatedAt).ToNot(BeNil()) - Expect(stored.UpdatedAt).To(BeTemporally("==", *stored.EvaluatedAt)) - Expect(refreshed.UpdatedAt).To(BeTemporally("==", stored.UpdatedAt)) - }) - }) - - Context("invalid rules", func() { - It("fails to Put it in the DB", func() { - rules = &criteria.Criteria{ - // 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(ctx, &newPls)).To(MatchError(ContainSubstring("invalid criteria expression"))) - }) - }) - - Context("re-imported from disk", func() { - // The scanner re-imports every playlist in a touched folder, and a freshly parsed - // .nsp carries no counters — saving it must not wipe the ones already evaluated. - It("keeps the stored counters when a freshly parsed playlist is saved over it", func() { - rules = &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"title": "Antenna"}, - }, - } - pls := model.Playlist{Name: "Smart", OwnerID: "userid", Rules: rules, Path: "/music/smart.nsp", Sync: true} - Expect(repo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, pls.ID) }) - - evaluated, err := repo.GetWithTracks(ctx, pls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - Expect(evaluated.SongCount).To(BeNumerically(">", 0)) - - stored, err := repo.Get(ctx, pls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(stored.SongCount).To(Equal(evaluated.SongCount)) - - reimported := model.Playlist{ - ID: pls.ID, Name: pls.Name, OwnerID: "userid", Rules: rules, - Path: pls.Path, Sync: true, - } - Expect(repo.Put(ctx, &reimported)).To(Succeed()) - - afterImport, err := repo.Get(ctx, pls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(afterImport.SongCount).To(Equal(stored.SongCount)) - Expect(afterImport.Duration).To(Equal(stored.Duration)) - Expect(afterImport.Size).To(Equal(stored.Size)) - }) - }) - - Context("child smart playlists", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - 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 [ID]", OwnerID: "userid", Public: true, Rules: childRules} - Expect(repo.Put(ctx, &nestedPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, nestedPls.ID) }) - - childRules = &criteria.Criteria{ - Expression: criteria.All{ - criteria.Eq{"artist": "シートベルツ"}, - }, - } - nestedPathPls := model.Playlist{Name: "Nested [Path]", OwnerID: "userid", Path: "test.nsp", Public: true, Rules: childRules} - Expect(repo.Put(ctx, &nestedPathPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, nestedPathPls.ID) }) - - parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ - Expression: criteria.Any{ - criteria.InPlaylist{"id": nestedPls.ID}, - criteria.InPlaylist{"path": nestedPathPls.Path}, - }, - }} - Expect(repo.Put(ctx, &parentPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, parentPls.ID) }) - - // Nested playlist has not been evaluated yet - nestedPlsRead, err := repo.Get(ctx, 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(ctx, 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(2)) - Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) - - // Nested playlists should now have been refreshed (EvaluatedAt set) - nestedPlsAfterParentGet, err := repo.Get(ctx, nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - - nestedPlsAfterParentGet, err = repo.Get(ctx, nestedPathPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - }) - }) - - It("does not recurse forever when two smart playlists reference each other", func() { - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - - plsA := model.Playlist{Name: "Cycle A", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.Contains{"title": "Day"}}, - }} - Expect(repo.Put(ctx, &plsA)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, plsA.ID) }) - - plsB := model.Playlist{Name: "Cycle B", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.InPlaylist{"id": plsA.ID}}, - }} - Expect(repo.Put(ctx, &plsB)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, plsB.ID) }) - - plsA.Rules = &criteria.Criteria{Expression: criteria.All{criteria.InPlaylist{"id": plsB.ID}}} - Expect(repo.Put(ctx, &plsA)).To(Succeed()) - - _, err := repo.GetWithTracks(ctx, plsA.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - }) - - It("does not treat an empty path as a reference to every playlist without a path", func() { - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - - bystander := model.Playlist{Name: "Bystander", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.Contains{"title": "Day"}}, - }} - Expect(repo.Put(ctx, &bystander)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, bystander.ID) }) - - parent := model.Playlist{Name: "Empty Path", OwnerID: "userid", Public: true, Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.InPlaylist{"path": ""}}, - }} - Expect(repo.Put(ctx, &parent)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, parent.ID) }) - - _, err := repo.GetWithTracks(ctx, parent.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - reloaded, err := repo.Get(ctx, bystander.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(reloaded.EvaluatedAt).To(BeNil()) - }) - - It("matches a child path stored in a different Unicode normalization form", func() { - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - - child := model.Playlist{Name: "NFD Child", OwnerID: "userid", Public: true, Path: filepath.FromSlash("/mu\u0301sica/child.nsp"), Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.Contains{"title": "Day"}}, - }} - Expect(repo.Put(ctx, &child)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, child.ID) }) - - parent := model.Playlist{Name: "NFC Parent", OwnerID: "userid", Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.InPlaylist{"path": "/m\u00fasica/child.nsp"}}, - }} - Expect(repo.Put(ctx, &parent)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, parent.ID) }) - - pls, err := repo.GetWithTracks(ctx, parent.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Tracks).To(HaveLen(1)) - Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) - }) - - When("refresh delay has not expired", func() { - It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { - conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour - 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(ctx, &nestedPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, 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(ctx, &parentPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, parentPls.ID) }) - - nestedPlsRead, err := repo.Get(ctx, nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - - // Getting parent with refresh should NOT recursively refresh the nested playlist - parent, err := repo.GetWithTracks(ctx, 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(ctx, nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second)) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) - }) - }) - - Context("per-playlist refreshDelay", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("does NOT refresh when the per-playlist delay has not elapsed, even if global has", func() { - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - evaluatedAt := time.Now().Add(-1 * time.Hour) - - rules := &criteria.Criteria{ - Expression: criteria.All{criteria.Contains{"title": "Day"}}, - RefreshDelay: 24 * time.Hour, - } - pls := model.Playlist{Name: "Frozen Daily", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt} - Expect(repo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, pls.ID) }) - - got, err := repo.GetWithTracks(ctx, pls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - // Not re-evaluated: EvaluatedAt unchanged, no tracks materialized - Expect(*got.EvaluatedAt).To(BeTemporally("~", evaluatedAt, time.Second)) - Expect(got.Tracks).To(BeEmpty()) - }) - - It("refreshes when the per-playlist delay has elapsed, even if global has not", func() { - conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour - evaluatedAt := time.Now().Add(-10 * time.Minute) - - rules := &criteria.Criteria{ - Expression: criteria.All{criteria.Contains{"title": "Day"}}, - RefreshDelay: 5 * time.Minute, - } - pls := model.Playlist{Name: "Fast Refresh", OwnerID: "userid", Rules: rules, EvaluatedAt: &evaluatedAt} - Expect(repo.Put(ctx, &pls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, pls.ID) }) - - got, err := repo.GetWithTracks(ctx, pls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - Expect(*got.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - Expect(got.Tracks).To(HaveLen(1)) - Expect(got.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) - }) - }) - }) - }) - - Describe("Playlist Track Sorting", func() { - var testPlaylistID string - - AfterEach(func() { - if testPlaylistID != "" { - Expect(repo.Delete(ctx, 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(ctx, &newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("retrieving tracks sorted by album") - tracksRepo := repo.Tracks(ctx, newPls.ID, false) - tracks, err := tracksRepo.GetAll(ctx, 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(ctx, 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(ctx, &newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(ctx, 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(ctx, &newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(ctx, 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(ctx, &newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(ctx, 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(ctx, &newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(ctx, 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(ctx, &newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(ctx, 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(ctx, &boolPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, 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(ctx, &stringPls)).To(Succeed()) - testPlaylistID = stringPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - boolResult, err := repo.GetWithTracks(ctx, boolPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - stringResult, err := repo.GetWithTracks(ctx, 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 Album Aggregate Criteria", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - }) - - trackIDsOf := func(rules *criteria.Criteria) []string { - newPls := model.Playlist{Name: "Album Aggregates", OwnerID: "userid", Rules: rules} - Expect(repo.Put(ctx, &newPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(ctx, newPls.ID) }) - - pls, err := repo.GetWithTracks(ctx, newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - return slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID }) - } - - It("filters on albumSongCount", func() { - // albumMultiDisc (ID "104") is the only fixture album with SongCount > 3 - rules := &criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumSongCount": 3}}} - - Expect(trackIDsOf(rules)).To(ConsistOf("2001", "2002", "2003", "2004")) - }) - - It("sorts by an album field not referenced in the expression (issue #5347)", func() { - // All four tracks share album 104, so the album date ties and disc/track number decide. - rules := &criteria.Criteria{ - Expression: criteria.All{criteria.Is{"album": "Multi Disc Album"}}, - Sort: "-albumDateAdded,discNumber,trackNumber", - } - - Expect(trackIDsOf(rules)).To(HaveExactElements("2002", "2004", "2003", "2001")) - }) - }) - - 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(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(ctx, &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(ctx, &songWithoutGrouping)).To(Succeed()) - }) - - AfterEach(func() { - if testPlaylistID != "" { - _ = repo.Delete(ctx, 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(ctx, &newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("refreshing the smart playlist") - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := repo.GetWithTracks(ctx, 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(ctx, &newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("refreshing the smart playlist") - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := repo.GetWithTracks(ctx, 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(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(ctx, &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(ctx, &songLib2)).To(Succeed()) - }) - - AfterEach(func() { - db := GetDBXBuilder() - if testPlaylistID != "" { - _ = repo.Delete(ctx, 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(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(ctx, &newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("refreshing the smart playlist") - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := restrictedRepo.GetWithTracks(ctx, 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/sort_index_coverage_test.go b/persistence/sort_index_coverage_test.go deleted file mode 100644 index 138cb32c8..000000000 --- a/persistence/sort_index_coverage_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package persistence - -import ( - "context" - "database/sql" - "fmt" - "maps" - "regexp" - "slices" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// These tests guard against sort options silently losing index support: adding or -// changing a sort mapping, or dropping/renaming an index in a migration, must not -// reintroduce full-table temp B-tree sorts on the large tables. Those are -// catastrophic on big libraries but invisible on dev-sized ones, which is how the -// unindexed album/artist song sorts went unnoticed for years. -// -// Every sort mapping is checked automatically: the real ORDER BY is built via -// buildSortOrder (both directions) and verified with EXPLAIN QUERY PLAN against -// the migrated test schema. The planner's choice is deterministic even on an -// empty table. A sort passes when the plan has no full "USE TEMP B-TREE FOR -// ORDER BY" step; an incremental sort of tie groups ("... FOR LAST TERM OF ORDER -// BY") is fine, as it only sorts rows with equal leading columns. -// -// A new sort mapping therefore fails this test until a matching index is created. -// The only escape hatch is exceptions, for sorts that genuinely cannot be -// served by a table index (random, annotation-join columns, JSON expressions): -// declaring one requires writing down the reason, making the trade-off visible in -// review. The checks run with the default config: PreferSortTags=true rewrites -// mappings to coalesce expressions with no matching indexes (used by ~0.1% of -// installations, per insights), and is out of scope here. -var _ = Describe("Sort index coverage", func() { - conn := db.Db() - - type repoCase struct { - table string - newRepo func(ctx context.Context) *sqlRepository - // sort mapping -> reason it cannot be served by an index - exceptions map[string]string - } - - cases := []repoCase{ - { - table: "media_file", - newRepo: func(ctx context.Context) *sqlRepository { - return &NewMediaFileRepository(GetDBXBuilder()).(*mediaFileRepository).sqlRepository - }, - exceptions: map[string]string{ - "random": "not a column sort", - "starred_at": "sorts on annotation join columns", - "rated_at": "sorts on annotation join columns", - "play_count": "sorts on annotation join columns", - "play_date": "sorts on annotation join columns", - "rating": "sorts on annotation join columns", - "comment": "UI-sortable but rarely used; not worth an index", - }, - }, - { - table: "album", - newRepo: func(ctx context.Context) *sqlRepository { - return &NewAlbumRepository(GetDBXBuilder()).(*albumRepository).sqlRepository - }, - exceptions: map[string]string{ - "random": "not a column sort", - "starred_at": "sorts on annotation join columns", - "rated_at": "sorts on annotation join columns", - "max_year": "coalesce expression over original_date/max_year, no expression index", - }, - }, - { - table: "artist", - newRepo: func(ctx context.Context) *sqlRepository { - return &NewArtistRepository(GetDBXBuilder()).(*artistRepository).sqlRepository - }, - exceptions: map[string]string{ //nolint:gosec // G101 false positive, same as the artist sortMappings - "starred_at": "sorts on annotation join columns", - "rated_at": "sorts on annotation join columns", - "song_count": "JSON expression over stats column", - "album_count": "JSON expression over stats column", - "size": "JSON expression over stats column", - "maincredit_song_count": "aggregate over JSON stats", - "maincredit_album_count": "aggregate over JSON stats", - "maincredit_size": "aggregate over JSON stats", - }, - }, - } - - newCtx := func() context.Context { - ctx := log.NewContext(GinkgoT().Context()) - return request.WithUser(ctx, model.User{ID: "userid"}) - } - - for _, c := range cases { - It(fmt.Sprintf("uses an index for every sort mapping on %s", c.table), func() { - r := c.newRepo(newCtx()) - for _, sort := range slices.Sorted(maps.Keys(r.sortMappings)) { - if _, ok := c.exceptions[sort]; ok { - continue - } - for _, dir := range []string{"asc", "desc"} { - orderBy := r.buildSortOrder(sort, dir) - Expect(checkSortUsesIndex(conn, c.table, orderBy)).To(Succeed(), - "sort %q (%s) on table %q needs an index. Create one matching its ORDER BY, or, if it cannot be served by an index, add it to exceptions with the reason", - sort, dir, c.table) - } - } - }) - - It(fmt.Sprintf("has no stale exceptions entries for %s", c.table), func() { - r := c.newRepo(newCtx()) - for _, sort := range slices.Sorted(maps.Keys(c.exceptions)) { - Expect(r.sortMappings).To(HaveKey(sort), - "exceptions entry %q on table %q does not match any sort mapping - remove it", sort, c.table) - } - }) - } - - It("uses an index for recently_added when RecentlyAddedByModTime is enabled", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.RecentlyAddedByModTime = true - for _, c := range cases[:2] { // media_file and album - r := c.newRepo(newCtx()) - for _, dir := range []string{"asc", "desc"} { - orderBy := r.buildSortOrder("recently_added", dir) - Expect(checkSortUsesIndex(conn, c.table, orderBy)).To(Succeed(), - "sort recently_added (%s) on table %q", dir, c.table) - } - } - }) -}) - -// Matches the full-sort step only: incremental tie-group sorts are reported as -// "USE TEMP B-TREE FOR LAST TERM OF ORDER BY" (or "LAST N TERMS") and are allowed. -var fullTempBTreeSort = regexp.MustCompile(`USE TEMP B-TREE FOR ORDER BY`) - -func checkSortUsesIndex(conn *sql.DB, table, orderBy string) error { - rows, err := conn.Query(fmt.Sprintf("explain query plan select * from %s order by %s limit 15", table, orderBy)) - if err != nil { - return fmt.Errorf("explain query plan failed for order by %q: %w", orderBy, err) - } - defer rows.Close() - - var details []string - for rows.Next() { - var id, parent, notUsed int - var detail string - if err := rows.Scan(&id, &parent, ¬Used, &detail); err != nil { - return err - } - details = append(details, detail) - } - if err := rows.Err(); err != nil { - return err - } - if slices.ContainsFunc(details, fullTempBTreeSort.MatchString) { - return fmt.Errorf("no index satisfies ORDER BY %s - plan: %v", orderBy, details) - } - return nil -} diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 51c7dc146..8e26fcf7f 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -1,131 +1,49 @@ package persistence import ( - "context" - "database/sql" "errors" - "fmt" - "regexp" - "sort" - "strings" - "sync" "time" . "github.com/Masterminds/squirrel" - "github.com/fatih/structs" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" + "github.com/beego/beego/v2/client/orm" + "github.com/google/uuid" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) const annotationTable = "annotation" -// annotationColumns are the columns withAnnotation's LEFT JOIN contributes, derived from -// model.Annotations so the set tracks schema changes. average_rating is excluded: it lives on the -// base table, not the annotation join. -var annotationColumns = sync.OnceValue(func() map[string]struct{} { - cols := map[string]struct{}{} - for name := range structs.Map(model.Annotations{}) { - if name == "average_rating" { - continue - } - cols[name] = struct{}{} - } - return cols -}) - -// annotationColumnRE matches any annotation column as a whole word. The word boundaries keep the -// base-table column average_rating from matching the annotation column rating (Go's \b treats '_' -// as a word char). It is case-insensitive because SQLite column names are, so a raw filter using -// e.g. "RATING" must still be detected. -var annotationColumnRE = sync.OnceValue(func() *regexp.Regexp { - cols := make([]string, 0, len(annotationColumns())) - for col := range annotationColumns() { - cols = append(cols, regexp.QuoteMeta(col)) - } - sort.Strings(cols) // map iteration is random; sort for a stable pattern - return regexp.MustCompile(`(?i)\b(?:` + strings.Join(cols, "|") + `)\b`) -}) - -// filtersNeedAnnotation reports whether the rendered query references an annotation column, i.e. -// whether the annotation LEFT JOIN must be kept. Scanning the rendered SQL catches every filter -// path. The placeholder column is needed because squirrel won't render a column-less SELECT; on a -// render error, keep the join to be safe. -func filtersNeedAnnotation(query SelectBuilder) bool { - sql, _, err := query.Columns("1").ToSql() - if err != nil { - return true - } - return annotationColumnRE().MatchString(sql) -} - -func (r sqlRepository) withAnnotation(ctx context.Context, query SelectBuilder, idField string) SelectBuilder { - userID := loggedUser(ctx).ID - if userID == invalidUserId { - return query.Columns(fmt.Sprintf("%s.average_rating", r.tableName)) - } - query = query. +func (r sqlRepository) newSelectWithAnnotation(idField string, options ...model.QueryOptions) SelectBuilder { + return r.newSelect(options...). LeftJoin("annotation on ("+ "annotation.item_id = "+idField+ - " AND annotation.item_type = ?"+ - " AND annotation.user_id = ?)", r.tableName, userID). - Columns( - "coalesce(starred, 0) as starred", - "coalesce(rating, 0) as rating", - "starred_at", - "play_date", - "rated_at", - ) - if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" { - query = query.Columns( - fmt.Sprintf("round(coalesce(round(cast(play_count as float) / coalesce(%[1]s.song_count, 1), 1), 0)) as play_count", r.tableName), - ) - } else { - query = query.Columns("coalesce(play_count, 0) as play_count") - } - - query = query.Columns(fmt.Sprintf("%s.average_rating", r.tableName)) - - return query + " AND annotation.item_type = '"+r.tableName+"'"+ + " AND annotation.user_id = '"+userId(r.ctx)+"')"). + Columns("starred", "starred_at", "play_count", "play_date", "rating") } -func annotationBoolFilter(field string) func(string, any) Sqlizer { - return func(_ string, value any) Sqlizer { - v, ok := value.(string) - if !ok { - return nil - } - if strings.ToLower(v) == "true" { - return Expr(fmt.Sprintf("COALESCE(%s, 0) > 0", field)) - } - return Expr(fmt.Sprintf("COALESCE(%s, 0) = 0", field)) - } -} - -func (r sqlRepository) annId(ctx context.Context, itemID ...string) And { - userID := loggedUser(ctx).ID +func (r sqlRepository) annId(itemID ...string) And { return And{ - Eq{annotationTable + ".user_id": userID}, + Eq{annotationTable + ".user_id": userId(r.ctx)}, Eq{annotationTable + ".item_type": r.tableName}, Eq{annotationTable + ".item_id": itemID}, } } -func (r sqlRepository) annUpsert(ctx context.Context, values map[string]any, itemIDs ...string) error { - upd := Update(annotationTable).Where(r.annId(ctx, itemIDs...)) +func (r sqlRepository) annUpsert(values map[string]interface{}, itemIDs ...string) error { + upd := Update(annotationTable).Where(r.annId(itemIDs...)) for f, v := range values { upd = upd.Set(f, v) } - c, err := r.executeSQL(ctx, upd) - if c == 0 || errors.Is(err, sql.ErrNoRows) { - userID := loggedUser(ctx).ID + c, err := r.executeSQL(upd) + if c == 0 || errors.Is(err, orm.ErrNoRows) { for _, itemID := range itemIDs { - values["user_id"] = userID + values["ann_id"] = uuid.NewString() + values["user_id"] = userId(r.ctx) values["item_type"] = r.tableName values["item_id"] = itemID ins := Insert(annotationTable).SetMap(values) - _, err = r.executeSQL(ctx, ins) + _, err = r.executeSQL(ins) if err != nil { return err } @@ -134,47 +52,31 @@ func (r sqlRepository) annUpsert(ctx context.Context, values map[string]any, ite return err } -func (r sqlRepository) SetStar(ctx context.Context, starred bool, ids ...string) error { +func (r sqlRepository) SetStar(starred bool, ids ...string) error { starredAt := time.Now() - return r.annUpsert(ctx, map[string]any{"starred": starred, "starred_at": starredAt}, ids...) + return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...) } -func (r sqlRepository) SetRating(ctx context.Context, rating int, itemID string) error { - ratedAt := time.Now() - err := r.annUpsert(ctx, map[string]any{"rating": rating, "rated_at": ratedAt}, itemID) - if err != nil { - return err - } - return r.updateAvgRating(ctx, itemID) +func (r sqlRepository) SetRating(rating int, itemID string) error { + return r.annUpsert(map[string]interface{}{"rating": rating}, itemID) } -func (r sqlRepository) updateAvgRating(ctx context.Context, itemID string) error { - upd := Update(r.tableName). - Where(Eq{"id": itemID}). - Set("average_rating", Expr( - "coalesce((select round(avg(rating), 2) from annotation where item_id = ? and item_type = ? and rating > 0), 0)", - itemID, r.tableName, - )) - _, err := r.executeSQL(ctx, upd) - return err -} - -func (r sqlRepository) IncPlayCount(ctx context.Context, itemID string, ts time.Time) error { - upd := Update(annotationTable).Where(r.annId(ctx, itemID)). +func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error { + upd := Update(annotationTable).Where(r.annId(itemID)). Set("play_count", Expr("play_count+1")). Set("play_date", Expr("max(ifnull(play_date,''),?)", ts)) - c, err := r.executeSQL(ctx, upd) + c, err := r.executeSQL(upd) - if c == 0 || errors.Is(err, sql.ErrNoRows) { - userID := loggedUser(ctx).ID - values := map[string]any{} - values["user_id"] = userID + if c == 0 || errors.Is(err, orm.ErrNoRows) { + values := map[string]interface{}{} + values["ann_id"] = uuid.NewString() + values["user_id"] = userId(r.ctx) values["item_type"] = r.tableName values["item_id"] = itemID values["play_count"] = 1 values["play_date"] = ts ins := Insert(annotationTable).SetMap(values) - _, err = r.executeSQL(ctx, ins) + _, err = r.executeSQL(ins) if err != nil { return err } @@ -182,28 +84,14 @@ func (r sqlRepository) IncPlayCount(ctx context.Context, itemID string, ts time. return err } -func (r sqlRepository) ReassignAnnotation(ctx context.Context, prevID string, newID string) error { - if prevID == newID || prevID == "" || newID == "" { - return nil - } - // OR IGNORE keeps newID's own row where a user annotated both, instead of aborting the whole statement - upd := Expr("update or ignore "+annotationTable+" set item_id = ? where item_type = ? and item_id = ?", - newID, r.tableName, prevID) - if _, err := r.executeSQL(ctx, upd); err != nil { +func (r sqlRepository) cleanAnnotations() error { + del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")") + c, err := r.executeSQL(del) + if err != nil { return err } - // The moved rows change newID's rating population, so its cached average no longer matches - return r.updateAvgRating(ctx, newID) -} - -func (r sqlRepository) cleanAnnotations(ctx context.Context) error { - del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")") - c, err := r.executeSQL(ctx, del) - if err != nil { - return fmt.Errorf("error cleaning up %s annotations: %w", r.tableName, err) - } if c > 0 { - log.Debug(ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c) + log.Debug(r.ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c) } return nil } diff --git a/persistence/sql_annotations_test.go b/persistence/sql_annotations_test.go deleted file mode 100644 index 6761331d9..000000000 --- a/persistence/sql_annotations_test.go +++ /dev/null @@ -1,297 +0,0 @@ -package persistence - -import ( - "context" - - "github.com/Masterminds/squirrel" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Annotation Filters", func() { - var ( - albumRepo *albumRepository - albumWithoutAnnotation model.Album - ctx context.Context - ) - - BeforeEach(func() { - ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) - albumRepo = NewAlbumRepository(GetDBXBuilder()).(*albumRepository) - - // Create album without any annotation (no star, no rating) - albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1} - Expect(albumRepo.Put(ctx, &albumWithoutAnnotation)).To(Succeed()) - }) - - AfterEach(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID})) - }) - - Describe("ReassignAnnotation", func() { - var prev, next model.Album - - BeforeEach(func() { - prev = model.Album{ID: "reassign-prev", Name: "Prev", LibraryID: 1} - next = model.Album{ID: "reassign-next", Name: "Next", LibraryID: 1} - Expect(albumRepo.Put(ctx, &prev)).To(Succeed()) - Expect(albumRepo.Put(ctx, &next)).To(Succeed()) - }) - - AfterEach(func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": []string{prev.ID, next.ID}})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": []string{prev.ID, next.ID}})) - }) - - It("moves the annotation when the new item has none", func() { - Expect(albumRepo.SetRating(ctx, 4, prev.ID)).To(Succeed()) - - Expect(albumRepo.ReassignAnnotation(ctx, prev.ID, next.ID)).To(Succeed()) - - got, err := albumRepo.Get(ctx, next.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Rating).To(Equal(4)) - }) - - It("recomputes the new item's cached average rating", func() { - Expect(albumRepo.SetRating(ctx, 4, prev.ID)).To(Succeed()) - - Expect(albumRepo.ReassignAnnotation(ctx, prev.ID, next.ID)).To(Succeed()) - - got, err := albumRepo.Get(ctx, next.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.AverageRating).To(Equal(4.0)) - }) - - It("keeps the new item's annotation when both exist", func() { - Expect(albumRepo.SetRating(ctx, 4, prev.ID)).To(Succeed()) - Expect(albumRepo.SetRating(ctx, 2, next.ID)).To(Succeed()) - - Expect(albumRepo.ReassignAnnotation(ctx, prev.ID, next.ID)).To(Succeed()) - - got, err := albumRepo.Get(ctx, next.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.Rating).To(Equal(2)) - }) - }) - - Describe("annotationBoolFilter", func() { - DescribeTable("creates correct SQL expressions", - func(field, value string, expectedSQL string, expectedArgs []any) { - sqlizer := annotationBoolFilter(field)(field, value) - sql, args, err := sqlizer.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal(expectedSQL)) - Expect(args).To(Equal(expectedArgs)) - }, - Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []any(nil)), - Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []any(nil)), - Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []any(nil)), - Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []any(nil)), - Entry("play_count=true", "play_count", "true", "COALESCE(play_count, 0) > 0", []any(nil)), - Entry("play_count=false", "play_count", "false", "COALESCE(play_count, 0) = 0", []any(nil)), - ) - - It("returns nil if value is not a string", func() { - sqlizer := annotationBoolFilter("starred")("starred", 123) - Expect(sqlizer).To(BeNil()) - }) - }) - - Describe("starredFilter", func() { - It("false includes items without annotations", func() { - albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ - Filters: annotationBoolFilter("starred")("starred", "false"), - }) - Expect(err).ToNot(HaveOccurred()) - - var found bool - for _, a := range albums { - if a.ID == albumWithoutAnnotation.ID { - found = true - break - } - } - Expect(found).To(BeTrue(), "Item without annotation should be included in starred=false filter") - }) - - It("true excludes items without annotations", func() { - albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ - Filters: annotationBoolFilter("starred")("starred", "true"), - }) - Expect(err).ToNot(HaveOccurred()) - - for _, a := range albums { - Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID)) - } - }) - }) - - Describe("hasRatingFilter", func() { - It("false includes items without annotations", func() { - albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ - Filters: annotationBoolFilter("rating")("rating", "false"), - }) - Expect(err).ToNot(HaveOccurred()) - - var found bool - for _, a := range albums { - if a.ID == albumWithoutAnnotation.ID { - found = true - break - } - } - Expect(found).To(BeTrue(), "Item without annotation should be included in has_rating=false filter") - }) - - It("true excludes items without annotations", func() { - albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ - Filters: annotationBoolFilter("rating")("rating", "true"), - }) - Expect(err).ToNot(HaveOccurred()) - - for _, a := range albums { - Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID)) - } - }) - - It("true includes items with rating > 0", func() { - // Create album with rating 1 - ratedAlbum := model.Album{ID: "rated-album", Name: "Rated Album", LibraryID: 1} - Expect(albumRepo.Put(ctx, &ratedAlbum)).To(Succeed()) - Expect(albumRepo.SetRating(ctx, 1, ratedAlbum.ID)).To(Succeed()) - defer func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": ratedAlbum.ID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": ratedAlbum.ID})) - }() - - albums, err := albumRepo.GetAll(ctx, model.QueryOptions{ - Filters: annotationBoolFilter("rating")("rating", "true"), - }) - Expect(err).ToNot(HaveOccurred()) - - var found bool - for _, a := range albums { - if a.ID == ratedAlbum.ID { - found = true - break - } - } - Expect(found).To(BeTrue(), "Album with rating 5 should be included in has_rating=true filter") - }) - }) - - It("ignores invalid filter values (not strings)", func() { - res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{ - Filters: map[string]any{"starred": 123}, - }) - Expect(err).ToNot(HaveOccurred()) - albums := res - - var found bool - for _, a := range albums { - if a.ID == albumWithoutAnnotation.ID { - found = true - break - } - } - Expect(found).To(BeTrue(), "Item without annotation should be included when filter is ignored") - }) - - Describe("annotationColumns", func() { - It("derives the annotation join columns from model.Annotations, excluding average_rating", func() { - cols := annotationColumns() - Expect(cols).To(HaveKey("starred")) - Expect(cols).To(HaveKey("starred_at")) - Expect(cols).To(HaveKey("rating")) - Expect(cols).To(HaveKey("rated_at")) - Expect(cols).To(HaveKey("play_count")) - Expect(cols).To(HaveKey("play_date")) - Expect(cols).To(HaveLen(6), "expected exactly the 6 annotation-join columns") - Expect(cols).ToNot(HaveKey("average_rating"), "average_rating lives on the base table, not the annotation join") - }) - }) - - Describe("filtersNeedAnnotation", func() { - It("is true when the query references an annotation column", func() { - q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Eq{"starred": true}) - Expect(filtersNeedAnnotation(q)).To(BeTrue()) - }) - - It("is true for a raw expression referencing an annotation column", func() { - q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("rating > 0")) - Expect(filtersNeedAnnotation(q)).To(BeTrue()) - }) - - It("is false for a query that references no annotation column", func() { - q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Eq{"missing": false}) - Expect(filtersNeedAnnotation(q)).To(BeFalse()) - }) - - It("is false for a filter on average_rating (base-table column, not the annotation rating)", func() { - // Regression: average_rating must not match the annotation column "rating". - q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Gt{"average_rating": 3}) - Expect(filtersNeedAnnotation(q)).To(BeFalse()) - }) - - It("is true when both average_rating and a real annotation column are referenced", func() { - q := squirrel.Select("count(1)").From("media_file"). - Where(squirrel.Gt{"average_rating": 3}). - Where(squirrel.Expr("COALESCE(rating, 0) > 0")) - Expect(filtersNeedAnnotation(q)).To(BeTrue()) - }) - - It("is true for uppercase/mixed-case annotation columns (SQLite is case-insensitive)", func() { - q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("RATING > 0")) - Expect(filtersNeedAnnotation(q)).To(BeTrue()) - }) - - It("is false for uppercase average_rating (still excluded case-insensitively)", func() { - q := squirrel.Select("count(1)").From("media_file").Where(squirrel.Expr("AVERAGE_RATING > 3")) - Expect(filtersNeedAnnotation(q)).To(BeFalse()) - }) - }) - - Describe("CountAll annotation-join gating", func() { - It("counts all items unfiltered (join dropped)", func() { - total, err := albumRepo.CountAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(total).To(BeNumerically(">=", int64(1))) - - filtered, err := albumRepo.CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"album.id": albumWithoutAnnotation.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(filtered).To(Equal(int64(1))) - }) - - It("counts starred items correctly (named annotation filter keeps the join)", func() { - starredAlbum := model.Album{ID: "counted-starred-album", Name: "Counted Starred", LibraryID: 1} - Expect(albumRepo.Put(ctx, &starredAlbum)).To(Succeed()) - Expect(albumRepo.SetStar(ctx, true, starredAlbum.ID)).To(Succeed()) - defer func() { - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": starredAlbum.ID})) - _, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": starredAlbum.ID})) - }() - - // Exactly two albums are starred for this user: the one created above and - // albumRadioactivity (id 103) from the seed data. - count, err := albumRepo.CountAll(ctx, model.QueryOptions{ - Filters: annotationBoolFilter("starred")("starred", "true"), - }) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(2))) - }) - - It("counts via a raw annotation filter without a 'no such column' error", func() { - count, err := albumRepo.CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Expr("COALESCE(rating, 0) > 0"), - }) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeNumerically(">=", int64(0))) - }) - }) -}) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 03cc6a01b..fa8938f45 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -2,147 +2,49 @@ package persistence import ( "context" - "database/sql" "errors" "fmt" - "iter" - "reflect" - "regexp" "strings" "time" . "github.com/Masterminds/squirrel" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/db" + "github.com/beego/beego/v2/client/orm" + "github.com/google/uuid" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - id2 "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/hasher" - "github.com/navidrome/navidrome/utils/slice" - "github.com/pocketbase/dbx" - "github.com/zeebo/xxh3" + "github.com/navidrome/navidrome/utils" ) -// sqlRepository is the base repository for all SQL repositories. It provides common functions to interact with the DB. -// When creating a new repository using this base, you must: -// -// - Embed this struct. -// - Set the db field. -// - Call registerModel with the model instance and any possible filters. -// - If the model has a different table name than the default (lowercase of the model name), it should be set manually -// using the tableName field. -// - Sort mappings must be set with setSortMappings method. If a sort field is not in the map, it will be used as the name of the column. -// -// All fields in filters and sortMappings must be in snake_case. Only sorts and filters based on real field names or -// defined in the mappings will be allowed. type sqlRepository struct { - tableName string - db dbx.Builder - - // Do not set these fields manually, they are set by the registerModel method - filterMappings map[string]filterFunc - isFieldWhiteListed fieldWhiteListedFunc - // Do not set this field manually, it is set by the setSortMappings method + ctx context.Context + tableName string + ormer orm.QueryExecutor sortMappings map[string]string } const invalidUserId = "-1" +func userId(ctx context.Context) string { + if user, ok := request.UserFrom(ctx); !ok { + return invalidUserId + } else { + return user.ID + } +} + func loggedUser(ctx context.Context) *model.User { if user, ok := request.UserFrom(ctx); !ok { - return &model.User{ID: invalidUserId} + return &model.User{} } else { return &user } } -// ownerFilter returns the predicate restricting access to rows owned by the logged-in user, for -// tables with a user_id column. It returns nil for admins and for headless/system contexts (invalid -// user), meaning "no ownership restriction". Callers should skip the WHERE clause when it is nil. -// -// The predicate uses an unqualified user_id, so it only works on queries where that column is -// unambiguous (no join introducing a second user_id). -func (r sqlRepository) ownerFilter(ctx context.Context) Sqlizer { - if usr := loggedUser(ctx); !usr.IsAdmin && usr.ID != invalidUserId { - return Eq{"user_id": usr.ID} - } - return nil -} - -// addRestriction combines an optional caller predicate with the ownership filter, producing the -// WHERE clause for owner-scoped reads. For admins and headless contexts ownerFilter() is nil and -// only the caller's predicate (if any) remains. -func (r sqlRepository) addRestriction(ctx context.Context, sql ...Sqlizer) Sqlizer { - s := And{} - if len(sql) > 0 { - s = append(s, sql[0]) - } - if owner := r.ownerFilter(ctx); owner != nil { - s = append(s, owner) - } - return s -} - -// writeAccess says who may change a row in a table with a user_id column. -type writeAccess int - -const ( - ownerOrAdmin writeAccess = iota // admins may write any row - ownerOnly // even admins may only write their own rows -) - -// ownedRow matches the row rowID only if the logged-in user may write it under access. -func (r sqlRepository) ownedRow(ctx context.Context, rowID string, access writeAccess) Sqlizer { - if access == ownerOnly { - return And{Eq{"id": rowID}, Eq{"user_id": loggedUser(ctx).ID}} - } - return r.addRestriction(ctx, Eq{"id": rowID}) -} - -func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) { - if r.tableName == "" { - r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.") - r.tableName = toSnakeCase(r.tableName) - } - r.tableName = strings.ToLower(r.tableName) - r.isFieldWhiteListed = registerModelWhiteList(instance) - r.filterMappings = filters -} - -// setSortMappings sets the mappings for the sort fields. If the sort field is not in the map, it will be used as is. -// This applies per comma-separated part, so a key added here also defines that bare name wherever a -// caller uses it inside a sort list. -// -// If PreferSortTags is enabled, it will map the order fields to the corresponding sort expression, -// which gives precedence to sort tags. -// Ex: order_title => (coalesce(nullif(sort_title,”),order_title) collate nocase) -// To avoid performance issues, indexes should be created for these sort expressions -// -// NOTE: if an individual item has spaces, it should be wrapped in parentheses. For example, -// you should write "(lyrics != '[]')". This prevents the item being split unexpectedly. -// Without parentheses, "lyrics != '[]'" would be mapped as simply "lyrics" -func (r *sqlRepository) setSortMappings(mappings map[string]string, tableName ...string) { - tn := r.tableName - if len(tableName) > 0 { - tn = tableName[0] - } - if conf.Server.PreferSortTags || conf.Server.EnableNaturalSorting { - for k, v := range mappings { - mappings[k] = mapSortOrder(tn, v) - } - } - r.sortMappings = mappings -} - -func (r sqlRepository) newSelect(ctx context.Context, options ...model.QueryOptions) SelectBuilder { +func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder { sq := Select().From(r.tableName) - if len(options) > 0 { - r.resetSeededRandom(ctx, options) - sq = r.applyOptions(sq, options...) - sq = r.applyFilters(sq, options...) - } + sq = r.applyOptions(sq, options...) + sq = r.applyFilters(sq, options...) return sq } @@ -161,43 +63,12 @@ func (r sqlRepository) applyOptions(sq SelectBuilder, options ...model.QueryOpti return sq } -// TODO Change all sortMappings to have a consistent case -func (r sqlRepository) sortMapping(sort string) string { - if mapping, _, ok := r.lookupSortMapping(sort); ok { - return mapping - } - // Each part of a comma list is resolved on its own, so a mix of mapped keys and plain columns - // keeps the mappings the recognized parts have. - parts := strings.FieldsFunc(sort, splitFunc(',')) - mapped := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if partMapping, _, ok := r.lookupSortMapping(part); ok { - part = partMapping - } else { - part = toSnakeCase(part) - } - mapped = append(mapped, part) - } - return strings.Join(mapped, ", ") -} - -// lookupSortMapping also returns the snake_case form when it had to derive one, so a caller's -// fallback doesn't recompute it: toSnakeCase runs two regexps. -func (r sqlRepository) lookupSortMapping(sort string) (mapping, snakeCased string, ok bool) { - if mapping, ok = r.sortMappings[sort]; ok { - return mapping, sort, true - } - if mapping, ok = r.sortMappings[toCamelCase(sort)]; ok { - return mapping, "", true - } - snakeCased = toSnakeCase(sort) - mapping, ok = r.sortMappings[snakeCased] - return mapping, snakeCased, ok -} - func (r sqlRepository) buildSortOrder(sort, order string) string { - sort = r.sortMapping(sort) + if mapping, ok := r.sortMappings[sort]; ok { + sort = mapping + } + + sort = toSnakeCase(sort) order = strings.ToLower(strings.TrimSpace(order)) var reverseOrder string if order == "desc" { @@ -207,12 +78,11 @@ func (r sqlRepository) buildSortOrder(sort, order string) string { reverseOrder = "desc" } - parts := strings.FieldsFunc(sort, splitFunc(',')) - newSort := make([]string, 0, len(parts)) + var newSort []string + parts := strings.FieldsFunc(sort, utils.SplitFunc(',')) for _, p := range parts { - f := strings.FieldsFunc(p, splitFunc(' ')) - newField := make([]string, 1, len(f)) - newField[0] = f[0] + f := strings.FieldsFunc(p, utils.SplitFunc(' ')) + newField := []string{f[0]} if len(f) == 1 { newField = append(newField, order) } else { @@ -227,23 +97,6 @@ func (r sqlRepository) buildSortOrder(sort, order string) string { return strings.Join(newSort, ", ") } -func splitFunc(delimiter rune) func(c rune) bool { - open := 0 - return func(c rune) bool { - if c == '(' { - open++ - return false - } - if open > 0 { - if c == ')' { - open-- - } - return false - } - return c == delimiter - } -} - func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOptions) SelectBuilder { if len(options) > 0 && options[0].Filters != nil { sq = sq.Where(options[0].Filters) @@ -251,379 +104,93 @@ func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOpti return sq } -// libraryIdFilter is a filter function to be added to resources that have a library_id column. -func libraryIdFilter(_ string, value any) Sqlizer { - return Eq{"library_id": value} -} - -// applyLibraryFilter adds library filtering to queries for tables that have a library_id column -// This ensures users only see content from libraries they have access to -func (r sqlRepository) applyLibraryFilter(ctx context.Context, sq SelectBuilder, tableName ...string) SelectBuilder { - user := loggedUser(ctx) - - // If the user is an admin, or the user ID is invalid (e.g., when no user is logged in), skip the library filter - if user.IsAdmin || user.ID == invalidUserId { - return sq - } - - // A non-admin granted every library sees everything the subquery would return, so applying it is - // pure overhead. Skip it in that case (same fast path admins get). - if visible, err := r.visibleLibraryIDs(ctx); err == nil && r.userSeesAllLibraries(ctx, visible) { - return sq - } - - table := r.tableName - if len(tableName) > 0 { - table = tableName[0] - } - - // Get user's accessible library IDs - // Use subquery to filter by user's library access - return sq.Where(Expr(table+".library_id IN ("+ - "SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)", user.ID)) -} - -// userSeesAllLibraries reports whether the visible set already covers every library, so a -// library filter would exclude nothing. -func (r sqlRepository) userSeesAllLibraries(ctx context.Context, visible []int) bool { - user := loggedUser(ctx) - if user.IsAdmin || user.ID == invalidUserId { - return true // visible is the whole library table - } - var res struct{ Count int64 } - err := r.queryOne(ctx, Select("count(*) as count").From("library"), &res) - if err != nil || res.Count == 0 { - return false - } - return int64(len(visible)) == res.Count -} - -// visibleLibraryIDs returns the libraries the current user can see: all libraries for admin and -// headless processes, otherwise the user's granted libraries. -func (r sqlRepository) visibleLibraryIDs(ctx context.Context) ([]int, error) { - user := loggedUser(ctx) - if user.IsAdmin || user.ID == invalidUserId { - var ids []int - err := r.queryAllSlice(ctx, Select("id").From("library"), &ids) - return ids, err - } - return slice.Map(user.Libraries, func(lib model.Library) int { return lib.ID }), nil -} - -func (r sqlRepository) seedKey(ctx context.Context) string { - // Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed - // used in the query. Hashing the user ID and converting it to a hex string will do the trick - userIDHash := xxh3.Hash([]byte(loggedUser(ctx).ID)) - return fmt.Sprintf("%s|%016x", r.tableName, userIDHash) -} - -func (r sqlRepository) resetSeededRandom(ctx context.Context, options []model.QueryOptions) { - if len(options) == 0 || options[0].Sort != "random" { - return - } - // CAST: playlist_tracks.id is an INTEGER (unlike other tables' TEXT ids); passing it to - // SEEDEDRAND's string param uncast silently drops every row (go-sqlite3 binding gotcha). - options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', CAST(%s.id AS TEXT))", r.seedKey(ctx), r.tableName) - if options[0].Seed != "" { - hasher.SetSeed(r.seedKey(ctx), options[0].Seed) - return - } - if options[0].Offset == 0 { - hasher.Reseed(r.seedKey(ctx)) - } -} - -func (r sqlRepository) executeSQL(ctx context.Context, sq Sqlizer) (int64, error) { - query, args, err := r.toSQL(sq) +func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) { + query, args, err := sq.ToSql() if err != nil { return 0, err } start := time.Now() var c int64 - res, err := r.db.NewQuery(query).Bind(args).WithContext(ctx).Execute() + res, err := r.ormer.Raw(query, args...).Exec() if res != nil { c, _ = res.RowsAffected() } - r.logSQL(ctx, query, args, err, c, start) + r.logSQL(query, args, err, c, start) if err != nil { if err.Error() != "LastInsertId is not supported by this driver" { return 0, err } } - return c, err + return res.RowsAffected() } -var placeholderRegex = regexp.MustCompile(`\?`) - -func (r sqlRepository) toSQL(sq Sqlizer) (string, dbx.Params, error) { +// Note: Due to a bug in the QueryRow method, this function does not map any embedded structs (ex: annotations) +// In this case, use the queryAll method and get the first item of the returned list +func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error { query, args, err := sq.ToSql() if err != nil { - return "", nil, err + return err } - // Replace query placeholders with named params - params := make(dbx.Params, len(args)) - counter := 0 - result := placeholderRegex.ReplaceAllStringFunc(query, func(_ string) string { - p := fmt.Sprintf("p%d", counter) - params[p] = args[counter] - counter++ - return "{:" + p + "}" - }) - return result, params, nil + start := time.Now() + err = r.ormer.Raw(query, args...).QueryRow(response) + if errors.Is(err, orm.ErrNoRows) { + r.logSQL(query, args, nil, 0, start) + return model.ErrNotFound + } + r.logSQL(query, args, err, 1, start) + return err } -func (r sqlRepository) queryOne(ctx context.Context, sq Sqlizer, response any) error { - query, args, err := r.toSQL(sq) +func (r sqlRepository) queryAll(sq Sqlizer, response interface{}) error { + query, args, err := sq.ToSql() if err != nil { return err } start := time.Now() - err = r.db.NewQuery(query).Bind(args).WithContext(ctx).One(response) - if errors.Is(err, sql.ErrNoRows) { - r.logSQL(ctx, query, args, nil, 0, start) + c, err := r.ormer.Raw(query, args...).QueryRows(response) + if errors.Is(err, orm.ErrNoRows) { + r.logSQL(query, args, nil, c, start) return model.ErrNotFound } - r.logSQL(ctx, query, args, err, 1, start) + r.logSQL(query, args, nil, c, start) return err } -// wrapCursor adapts a cursor over db rows into one over their models. toModel pulls out the row's -// embedded model, which a type parameter can't reach on its own. -func wrapCursor[D, T any](cursor iter.Seq2[D, error], toModel func(D) *T) iter.Seq2[T, error] { - return func(yield func(T, error) bool) { - for row, err := range cursor { - m := toModel(row) - if m == nil { - // Don't format row: its String() derefs the nil model (golang/go#81238). - var zero T - yield(zero, fmt.Errorf("unexpected nil %T: %w", zero, err)) - return - } - if !yield(*m, err) || err != nil { - return - } - } - } -} - -// queryWithStableResults is a helper function to execute a query and return an iterator that will yield its results -// from a cursor, guaranteeing that the results will be stable, even if the underlying data changes. -func queryWithStableResults[T any](ctx context.Context, r sqlRepository, sq SelectBuilder, options ...model.QueryOptions) (iter.Seq2[T, error], error) { - if len(options) > 0 && options[0].Offset > 0 { - sq = r.optimizePagination(sq, options[0]) - } - query, args, err := r.toSQL(sq) - if err != nil { - return nil, err - } - start := time.Now() - rows, err := r.db.NewQuery(query).Bind(args).WithContext(ctx).Rows() - r.logSQL(ctx, query, args, err, -1, start) - if err != nil { - return nil, err - } - return func(yield func(T, error) bool) { - defer rows.Close() - for rows.Next() { - var row T - err := rows.ScanStruct(&row) - if !yield(row, err) || err != nil { - return - } - } - if err := rows.Err(); err != nil { - var empty T - yield(empty, err) - } - }, nil -} - -func (r sqlRepository) queryAll(ctx context.Context, sq SelectBuilder, response any, options ...model.QueryOptions) error { - if len(options) > 0 && options[0].Offset > 0 { - sq = r.optimizePagination(sq, options[0]) - } - query, args, err := r.toSQL(sq) - if err != nil { - return err - } - start := time.Now() - err = r.db.NewQuery(query).Bind(args).WithContext(ctx).All(response) - if errors.Is(err, sql.ErrNoRows) { - r.logSQL(ctx, query, args, nil, -1, start) - return model.ErrNotFound - } - r.logSQL(ctx, query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start) - return err -} - -// queryAllSlice is a helper function to query a single column and return the result in a slice -func (r sqlRepository) queryAllSlice(ctx context.Context, sq SelectBuilder, response any) error { - query, args, err := r.toSQL(sq) - if err != nil { - return err - } - start := time.Now() - err = r.db.NewQuery(query).Bind(args).WithContext(ctx).Column(response) - if errors.Is(err, sql.ErrNoRows) { - r.logSQL(ctx, query, args, nil, -1, start) - return model.ErrNotFound - } - r.logSQL(ctx, query, args, err, int64(reflect.ValueOf(response).Elem().Len()), start) - return err -} - -// optimizePagination uses a less inefficient pagination, by not using OFFSET. -// See https://gist.github.com/ssokolow/262503 -func (r sqlRepository) optimizePagination(sq SelectBuilder, options model.QueryOptions) SelectBuilder { - if options.Offset > conf.Server.DevOffsetOptimize { - sq = sq.RemoveOffset() - rowidSq := sq.RemoveColumns().Columns(r.tableName + ".rowid") - rowidSq = rowidSq.Limit(uint64(options.Offset)) - rowidSql, args, _ := rowidSq.ToSql() - sq = sq.Where(r.tableName+".rowid not in ("+rowidSql+")", args...) - } - return sq -} - -func (r sqlRepository) exists(ctx context.Context, cond Sqlizer) (bool, error) { - existsQuery := Select("count(*) as exist").From(r.tableName).Where(cond) +func (r sqlRepository) exists(existsQuery SelectBuilder) (bool, error) { + existsQuery = existsQuery.Columns("count(*) as exist").From(r.tableName) var res struct{ Exist int64 } - err := r.queryOne(ctx, existsQuery, &res) + err := r.queryOne(existsQuery, &res) return res.Exist > 0, err } -// updateOwned performs an atomic, ownership-restricted update of the row identified by id, for -// repositories whose table has a user_id column. Non-admins can only update rows they own: the -// ownership predicate is part of the UPDATE's WHERE clause, so a row owned by another user simply -// does not match and no write happens. Ownership itself is immutable here: user_id is never written, -// so no caller (admin included) can reassign a row to a different owner via an update. Unlike put, -// it never falls through to an INSERT, so a non-matching id never creates a row. -// -// When the update matches no row it classifies the failure: if the row exists but is owned by -// another user it returns rest.ErrPermissionDenied, otherwise rest.ErrNotFound. The write itself is -// still atomic; the extra lookup happens only on the failure path (count == 0), where no write -// occurred, so there is no TOCTOU on the update. -func (r sqlRepository) updateOwned(ctx context.Context, id string, m any, colsToUpdate ...string) error { - values, err := toSQLArgs(m) - if err != nil { - return fmt.Errorf("error preparing values to write to DB: %w", err) - } - updateValues := filterUpdateValues(values, id, colsToUpdate...) - delete(updateValues, "user_id") // ownership is immutable on update - return r.updateOwnedRow(ctx, id, ownerOrAdmin, updateValues) -} - -// updateOwnedRow sets values on the row rowID if the logged-in user may write it under access. -func (r sqlRepository) updateOwnedRow(ctx context.Context, rowID string, access writeAccess, values map[string]any) error { - return r.runRowWrite(ctx, rowID, Update(r.tableName).SetMap(values).Where(r.ownedRow(ctx, rowID, access))) -} - -// deleteOwned performs an atomic, ownership-restricted delete of the row identified by id, for -// repositories whose table has a user_id column. Non-admins can only delete rows they own: the -// ownership predicate is part of the DELETE's WHERE clause, so a row owned by another user simply -// does not match and is left untouched. The failure path mirrors updateOwned (see -// classifyOwnedWriteMiss), so there is no TOCTOU on the delete. -func (r sqlRepository) deleteOwned(ctx context.Context, id string) error { - return r.runRowWrite(ctx, id, Delete(r.tableName).Where(r.ownedRow(ctx, id, ownerOrAdmin))) -} - -// runRowWrite executes q, a write already filtered by ownedRow(rowID, …), and classifies a miss. -func (r sqlRepository) runRowWrite(ctx context.Context, rowID string, q Sqlizer) error { - count, err := r.executeSQL(ctx, q) - if err != nil { - return err - } - if count == 0 { - return r.classifyOwnedWriteMiss(ctx, rowID) - } - return nil -} - -func (r sqlRepository) deleteOwnedAll(ctx context.Context, ids ...string) error { - for _, id := range ids { - if err := r.deleteOwned(ctx, id); err != nil { - return err - } - } - return nil -} - -// classifyOwnedWriteMiss explains why an ownership-filtered write (updateOwned/deleteOwned) matched -// no row: rest.ErrPermissionDenied if the row exists but is owned by another user, otherwise -// rest.ErrNotFound. It runs only on the failure path (count == 0), where no write occurred. -func (r sqlRepository) classifyOwnedWriteMiss(ctx context.Context, id string) error { - exists, err := r.exists(ctx, Eq{"id": id}) - if err != nil { - return err - } - if exists { - return rest.ErrPermissionDenied - } - return rest.ErrNotFound -} - -func (r sqlRepository) count(ctx context.Context, countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) { - countQuery = countQuery. - RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count"). - RemoveOffset().RemoveLimit(). - OrderBy(r.tableName + ".id"). // To remove any ORDER BY clause that could slow down the query - From(r.tableName) +func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) { + countQuery = countQuery.Columns("count(distinct " + r.tableName + ".id) as count").From(r.tableName) countQuery = r.applyFilters(countQuery, options...) var res struct{ Count int64 } - err := r.queryOne(ctx, countQuery, &res) + err := r.queryOne(countQuery, &res) return res.Count, err } -func (r sqlRepository) putByMatch(ctx context.Context, filter Sqlizer, id string, m any, colsToUpdate ...string) (string, error) { - if id != "" { - return r.put(ctx, id, m, colsToUpdate...) - } - existsQuery := r.newSelect(ctx).Columns("id").From(r.tableName).Where(filter) - - var res struct{ ID string } - err := r.queryOne(ctx, existsQuery, &res) - if err != nil && !errors.Is(err, model.ErrNotFound) { - return "", err - } - return r.put(ctx, res.ID, m, colsToUpdate...) -} - -// selectUpdateColumns keeps only the requested colsToUpdate (or all columns when none are -// specified), dropping columns that must never be overwritten on update (created_at, birth_time). -func selectUpdateColumns(values map[string]any, colsToUpdate ...string) map[string]any { - updateValues := map[string]any{} - - c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) { - return toSnakeCase(s), struct{}{} - }) - for k, v := range values { - if _, found := c2upd[k]; len(c2upd) == 0 || found { - updateValues[k] = v - } - } - - delete(updateValues, "created_at") - // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now - // TODO move to mediafile_repository when each repo has its own upsert method - delete(updateValues, "birth_time") - return updateValues -} - -func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any { - updateValues := selectUpdateColumns(values, colsToUpdate...) - updateValues["id"] = id - return updateValues -} - -func (r sqlRepository) put(ctx context.Context, id string, m any, colsToUpdate ...string) (newId string, err error) { - values, err := toSQLArgs(m) - if err != nil { - return "", fmt.Errorf("error preparing values to write to DB: %w", err) - } +func (r sqlRepository) put(id string, m interface{}, colsToUpdate ...string) (newId string, err error) { + values, _ := toSqlArgs(m) // If there's an ID, try to update first if id != "" { - update := Update(r.tableName).Where(Eq{"id": id}).SetMap(filterUpdateValues(values, id, colsToUpdate...)) - count, err := r.executeSQL(ctx, update) + updateValues := map[string]interface{}{} + + // This is a map of the columns that need to be updated, if specified + c2upd := map[string]struct{}{} + for _, c := range colsToUpdate { + c2upd[toSnakeCase(c)] = struct{}{} + } + for k, v := range values { + if _, found := c2upd[k]; len(c2upd) == 0 || found { + updateValues[k] = v + } + } + + delete(updateValues, "created_at") + update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues) + count, err := r.executeSQL(update) if err != nil { return "", err } @@ -633,46 +200,39 @@ func (r sqlRepository) put(ctx context.Context, id string, m any, colsToUpdate . } // If it does not have an ID OR the ID was not found (when it is a new record with predefined id) if id == "" { - id = id2.NewRandom() + id = uuid.NewString() values["id"] = id } insert := Insert(r.tableName).SetMap(values) - _, err = r.executeSQL(ctx, insert) + _, err = r.executeSQL(insert) return id, err } -func (r sqlRepository) delete(ctx context.Context, cond Sqlizer) error { - _, err := r.executeSQL(ctx, Delete(r.tableName).Where(cond)) +func (r sqlRepository) delete(cond Sqlizer) error { + del := Delete(r.tableName).Where(cond) + _, err := r.executeSQL(del) + if errors.Is(err, orm.ErrNoRows) { + return model.ErrNotFound + } return err } -// deleteByID is for single-item deletes that must report a missing row; delete succeeds silently. -func (r sqlRepository) deleteByID(ctx context.Context, id string) error { - count, err := r.executeSQL(ctx, Delete(r.tableName).Where(Eq{"id": id})) - if err != nil { - return err - } - if count == 0 { - return model.ErrNotFound - } - return nil -} - -func (r sqlRepository) logSQL(ctx context.Context, sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) { +func (r sqlRepository) logSQL(sql string, args []interface{}, err error, rowsAffected int64, start time.Time) { elapsed := time.Since(start) - fields := []any{ctx, "SQL: `" + sql + "`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed} - if err == nil || errors.Is(err, context.Canceled) { - log.Trace(append(fields, err)...) - return + var fmtArgs []string + for i := range args { + var f string + switch a := args[i].(type) { + case string: + f = `'` + a + `'` + default: + f = fmt.Sprintf("%v", a) + } + fmtArgs = append(fmtArgs, f) } - // The result codes separate errors that share a message, notably SQLITE_BUSY from - // SQLITE_BUSY_SNAPSHOT, which no busy_timeout can retry. - if code, extended, ok := db.ErrorCodes(err); ok { - fields = append(fields, "sqliteCode", code, "sqliteExtended", extended) + if err != nil { + log.Error(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err) + } else { + log.Trace(r.ctx, "SQL: `"+sql+"`", "args", `[`+strings.Join(fmtArgs, ",")+`]`, "rowsAffected", rowsAffected, "elapsedTime", elapsed) } - if db.IsBusy(err) && hasBusyRetry(ctx) { - log.Warn(append(fields, err)...) - return - } - log.Error(append(fields, err)...) } diff --git a/persistence/sql_base_repository_test.go b/persistence/sql_base_repository_test.go index 4b42e7f1d..8309a492f 100644 --- a/persistence/sql_base_repository_test.go +++ b/persistence/sql_base_repository_test.go @@ -1,51 +1,18 @@ package persistence import ( - "context" - "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/hasher" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("sqlRepository", func() { - var r sqlRepository - var ctx context.Context - BeforeEach(func() { - ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "user-id"}) - r.tableName = "table" - }) - - Describe("ownedRow", func() { - DescribeTable("matches the row, limited to what the logged-in user may write", - func(user model.User, access writeAccess, expectedSQL string, expectedArgs ...any) { - userCtx := request.WithUser(GinkgoT().Context(), user) - sql, args, err := r.ownedRow(userCtx, "row-1", access).ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal(expectedSQL)) - Expect(args).To(Equal(expectedArgs)) - }, - Entry("admin, ownerOrAdmin: any row", model.User{ID: "admin", IsAdmin: true}, ownerOrAdmin, - "(id = ?)", "row-1"), - Entry("regular, ownerOrAdmin: own rows", model.User{ID: "user"}, ownerOrAdmin, - "(id = ? AND user_id = ?)", "row-1", "user"), - Entry("admin, ownerOnly: own rows", model.User{ID: "admin", IsAdmin: true}, ownerOnly, - "(id = ? AND user_id = ?)", "row-1", "admin"), - Entry("regular, ownerOnly: own rows", model.User{ID: "user"}, ownerOnly, - "(id = ? AND user_id = ?)", "row-1", "user"), - ) - }) - + r := sqlRepository{} Describe("applyOptions", func() { var sq squirrel.SelectBuilder BeforeEach(func() { sq = squirrel.Select("*").From("test") - r.sortMappings = map[string]string{ - "name": "title", - } }) It("does not add any clauses when options is empty", func() { sq = r.applyOptions(sq, model.QueryOptions{}) @@ -60,160 +27,11 @@ var _ = Describe("sqlRepository", func() { Offset: 2, }) sql, _, _ := sq.ToSql() - Expect(sql).To(Equal("SELECT * FROM test ORDER BY title desc LIMIT 1 OFFSET 2")) - }) - }) - - Describe("toSQL", func() { - It("returns error for invalid SQL", func() { - sq := squirrel.Select("*").From("test").Where(1) - _, _, err := r.toSQL(sq) - Expect(err).To(HaveOccurred()) - }) - - It("returns the same query when there are no placeholders", func() { - sq := squirrel.Select("*").From("test") - query, params, err := r.toSQL(sq) - Expect(err).NotTo(HaveOccurred()) - Expect(query).To(Equal("SELECT * FROM test")) - Expect(params).To(BeEmpty()) - }) - - It("replaces one placeholder correctly", func() { - sq := squirrel.Select("*").From("test").Where(squirrel.Eq{"id": 1}) - query, params, err := r.toSQL(sq) - Expect(err).NotTo(HaveOccurred()) - Expect(query).To(Equal("SELECT * FROM test WHERE id = {:p0}")) - Expect(params).To(HaveKeyWithValue("p0", 1)) - }) - - It("replaces multiple placeholders correctly", func() { - sq := squirrel.Select("*").From("test").Where(squirrel.Eq{"id": 1, "name": "test"}) - query, params, err := r.toSQL(sq) - Expect(err).NotTo(HaveOccurred()) - Expect(query).To(Equal("SELECT * FROM test WHERE id = {:p0} AND name = {:p1}")) - Expect(params).To(HaveKeyWithValue("p0", 1)) - Expect(params).To(HaveKeyWithValue("p1", "test")) - }) - }) - - Describe("sanitizeSort", func() { - BeforeEach(func() { - r.registerModel(&struct { - Field string `structs:"field"` - }{}, nil) - r.sortMappings = map[string]string{ - "sort1": "mappedSort1", - } - }) - - When("sanitizing sort", func() { - It("returns empty if the sort key is not found in the model nor in the mappings", func() { - sort, _ := r.sanitizeSort(ctx, "unknown", "") - Expect(sort).To(BeEmpty()) - }) - - // Validation only: buildSortOrder resolves the mapping, so mapping here too would hand - // sortMapping its own output and re-map values whose parts are themselves keys. - It("accepts a known sort key without resolving it", func() { - sort, _ := r.sanitizeSort(ctx, "sort1", "") - Expect(sort).To(Equal("sort1")) - }) - - It("is case insensitive", func() { - sort, _ := r.sanitizeSort(ctx, "Sort1", "") - Expect(sort).To(Equal("sort1")) - }) - - It("still resolves the mapping by the time the SQL is built", func() { - Expect(r.buildSortOrder("sort1", "asc")).To(Equal("mappedSort1 asc")) - }) - - // A mapping whose parts are themselves keys (media_file rated_at = "rating, rated_at") - // must survive the round trip through sanitizeSort and buildSortOrder unduplicated. - It("does not re-map a value whose parts are also keys", func() { - r.sortMappings = map[string]string{"rating": "rating", "rated_at": "rating, rated_at"} - sort, _ := r.sanitizeSort(ctx, "rated_at", "") - Expect(r.buildSortOrder(sort, "asc")).To(Equal("rating asc, rated_at asc")) - }) - - It("returns the field if it is a valid field", func() { - sort, _ := r.sanitizeSort(ctx, "field", "") - Expect(sort).To(Equal("field")) - }) - - It("is case insensitive for fields", func() { - sort, _ := r.sanitizeSort(ctx, "FIELD", "") - Expect(sort).To(Equal("field")) - }) - }) - When("sanitizing order", func() { - It("returns 'asc' if order is empty", func() { - _, order := r.sanitizeSort(ctx, "", "") - Expect(order).To(Equal("")) - }) - - It("returns 'asc' if order is 'asc'", func() { - _, order := r.sanitizeSort(ctx, "", "ASC") - Expect(order).To(Equal("asc")) - }) - - It("returns 'desc' if order is 'desc'", func() { - _, order := r.sanitizeSort(ctx, "", "desc") - Expect(order).To(Equal("desc")) - }) - - It("returns 'asc' if order is unknown", func() { - _, order := r.sanitizeSort(ctx, "", "something") - Expect(order).To(Equal("asc")) - }) - }) - }) - - Describe("sortMapping", func() { - BeforeEach(func() { - r.sortMappings = map[string]string{ - "name": "order_album_name, order_album_artist_name", - "recently_added": "album.created_at, album.id", - } - }) - It("maps a single key", func() { - Expect(r.sortMapping("recently_added")).To(Equal("album.created_at, album.id")) - }) - It("maps every part of a comma list when all of them are known keys", func() { - Expect(r.sortMapping("recently_added, name")). - To(Equal("album.created_at, album.id, order_album_name, order_album_artist_name")) - }) - It("resolves the known parts of a mixed list and leaves the rest as columns", func() { - Expect(r.sortMapping("recently_added, play_count")). - To(Equal("album.created_at, album.id, play_count")) - }) - // Jellyfin's MusicAlbum SortBy=Runtime,SortName arrives as "duration, name"; duration is a - // plain album column while name is mapped, and the mapping must survive the mix. - It("keeps a mapping when an earlier part is a plain column", func() { - Expect(r.sortMapping("duration, name")). - To(Equal("duration, order_album_name, order_album_artist_name")) - }) - It("leaves a raw column list with directions untouched", func() { - Expect(r.sortMapping("starred desc, rating desc")).To(Equal("starred desc, rating desc")) - }) - It("does not split an expression on a comma inside its parentheses", func() { - Expect(r.sortMapping("coalesce(name, ''), title")).To(Equal("coalesce(name, ''), title")) - Expect(r.sortMapping("coalesce(nullif(a,''), b) desc, c")).To(Equal("coalesce(nullif(a,''), b) desc, c")) - }) - It("keeps a mapping whose value nests commas inside parentheses", func() { - r.sortMappings["max_year"] = "coalesce(nullif(original_date,''), cast(max_year as text)), release_date" - Expect(r.sortMapping("max_year, name")).To(Equal( - "coalesce(nullif(original_date,''), cast(max_year as text)), release_date, " + - "order_album_name, order_album_artist_name")) + Expect(sql).To(Equal("SELECT * FROM test ORDER BY name desc LIMIT 1 OFFSET 2")) }) }) Describe("buildSortOrder", func() { - BeforeEach(func() { - r.sortMappings = map[string]string{} - }) - Context("single field", func() { It("sorts by specified field", func() { sql := r.buildSortOrder("name", "desc") @@ -241,14 +59,6 @@ var _ = Describe("sqlRepository", func() { sql := r.buildSortOrder("name desc, age, status asc", "desc") Expect(sql).To(Equal("name asc, age desc, status desc")) }) - It("handles spaces in mapped field", func() { - r.sortMappings = map[string]string{ - "has_lyrics": "(lyrics != '[]'), updated_at", - } - sql := r.buildSortOrder("has_lyrics", "desc") - Expect(sql).To(Equal("(lyrics != '[]') desc, updated_at desc")) - }) - }) Context("function fields", func() { It("handles functions with multiple params", func() { @@ -265,159 +75,4 @@ var _ = Describe("sqlRepository", func() { }) }) }) - - Describe("resetSeededRandom", func() { - var id string - BeforeEach(func() { - id = r.seedKey(ctx) - hasher.SetSeed(id, "") - }) - It("does not reset seed if sort is not random", func() { - var options []model.QueryOptions - r.resetSeededRandom(ctx, options) - Expect(hasher.CurrentSeed(id)).To(BeEmpty()) - }) - It("resets seed if sort is random", func() { - options := []model.QueryOptions{{Sort: "random"}} - r.resetSeededRandom(ctx, options) - Expect(hasher.CurrentSeed(id)).NotTo(BeEmpty()) - }) - It("resets seed if sort is random and seed is provided", func() { - options := []model.QueryOptions{{Sort: "random", Seed: "seed"}} - r.resetSeededRandom(ctx, options) - Expect(hasher.CurrentSeed(id)).To(Equal("seed")) - }) - It("keeps seed when paginating", func() { - options := []model.QueryOptions{{Sort: "random", Seed: "seed", Offset: 0}} - r.resetSeededRandom(ctx, options) - Expect(hasher.CurrentSeed(id)).To(Equal("seed")) - - options = []model.QueryOptions{{Sort: "random", Offset: 1}} - r.resetSeededRandom(ctx, options) - Expect(hasher.CurrentSeed(id)).To(Equal("seed")) - }) - }) - - Describe("applyLibraryFilter", func() { - var sq squirrel.SelectBuilder - var savedDB = r.db - - BeforeEach(func() { - sq = squirrel.Select("*").From("test_table") - // Add library 2 so a user granted only library 1 is a genuine strict subset. - savedDB = r.db - r.db = GetDBXBuilder() - _, err := r.db.NewQuery("INSERT OR IGNORE INTO library (id, name, path) VALUES (2, 'Lib 2', '/lib2')").Execute() - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - _, err := r.db.NewQuery("DELETE FROM library WHERE id = 2").Execute() - Expect(err).ToNot(HaveOccurred()) - r.db = savedDB - }) - - Context("Admin User", func() { - BeforeEach(func() { - ctx = request.WithUser(ctx, model.User{ID: "admin", IsAdmin: true}) - }) - - It("should not apply library filter for admin users", func() { - result := r.applyLibraryFilter(ctx, sq) - sql, _, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal("SELECT * FROM test_table")) - }) - }) - - Context("Regular User with a subset of libraries", func() { - BeforeEach(func() { - // Strict subset: granted lib 1, DB has libs 1 and 2, so the filter must apply. - ctx = request.WithUser(ctx, model.User{ - ID: "user123", IsAdmin: false, Libraries: model.Libraries{{ID: 1}}, - }) - }) - - It("should apply library filter for regular users", func() { - result := r.applyLibraryFilter(ctx, sq) - sql, args, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("IN (SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)")) - Expect(args).To(ContainElement("user123")) - }) - - It("should use custom table name when provided", func() { - result := r.applyLibraryFilter(ctx, sq, "custom_table") - sql, args, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("custom_table.library_id IN")) - Expect(args).To(ContainElement("user123")) - }) - }) - - Context("Regular User with no libraries", func() { - BeforeEach(func() { - ctx = request.WithUser(ctx, model.User{ID: "empty", IsAdmin: false}) - }) - - It("should apply the library filter (never skip on empty)", func() { - result := r.applyLibraryFilter(ctx, sq) - sql, _, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("IN (SELECT ul.library_id FROM user_library ul WHERE ul.user_id = ?)")) - }) - }) - - Context("Regular User who can see all libraries", func() { - BeforeEach(func() { - // Grant every library that currently exists in the (shared) DB, so the filter - // would exclude nothing. Querying the real IDs keeps this correct even if other - // specs left extra libraries behind, which happens under Ginkgo's randomized order. - var ids []int - err := r.db.NewQuery("SELECT id FROM library ORDER BY id").Column(&ids) - Expect(err).ToNot(HaveOccurred()) - libs := make(model.Libraries, 0, len(ids)) - for _, id := range ids { - libs = append(libs, model.Library{ID: id}) - } - ctx = request.WithUser(ctx, model.User{ - ID: "alllibs", IsAdmin: false, Libraries: libs, - }) - }) - - It("should not apply the library filter (subquery would filter nothing)", func() { - result := r.applyLibraryFilter(ctx, sq) - sql, _, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal("SELECT * FROM test_table")) - }) - - It("should not apply the filter even with a custom table name", func() { - result := r.applyLibraryFilter(ctx, sq, "custom_table") - sql, _, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal("SELECT * FROM test_table")) - }) - }) - - Context("Headless Process (No User Context)", func() { - BeforeEach(func() { - ctx = GinkgoT().Context() // No user context - }) - - It("should not apply library filter for headless processes", func() { - result := r.applyLibraryFilter(ctx, sq) - sql, _, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal("SELECT * FROM test_table")) - }) - - It("should not apply library filter even with custom table name", func() { - result := r.applyLibraryFilter(ctx, sq, "custom_table") - sql, _, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(Equal("SELECT * FROM test_table")) - }) - }) - }) }) diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go index ddb63019d..0575deb03 100644 --- a/persistence/sql_bookmarks.go +++ b/persistence/sql_bookmarks.go @@ -1,13 +1,11 @@ package persistence import ( - "context" - "database/sql" "errors" - "fmt" "time" . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -15,80 +13,77 @@ import ( const bookmarkTable = "bookmark" -func (r sqlRepository) withBookmark(ctx context.Context, query SelectBuilder, idField string) SelectBuilder { - userID := loggedUser(ctx).ID - if userID == invalidUserId { - return query - } - return query. +func (r sqlRepository) withBookmark(sql SelectBuilder, idField string) SelectBuilder { + return sql. LeftJoin("bookmark on (" + "bookmark.item_id = " + idField + - " AND bookmark.user_id = '" + userID + "')"). - Columns("coalesce(position, 0) as bookmark_position") + " AND bookmark.item_type = '" + r.tableName + "'" + + " AND bookmark.user_id = '" + userId(r.ctx) + "')"). + Columns("position as bookmark_position") } -func (r sqlRepository) bmkID(ctx context.Context, itemID ...string) And { +func (r sqlRepository) bmkID(itemID ...string) And { return And{ - Eq{bookmarkTable + ".user_id": loggedUser(ctx).ID}, + Eq{bookmarkTable + ".user_id": userId(r.ctx)}, Eq{bookmarkTable + ".item_type": r.tableName}, Eq{bookmarkTable + ".item_id": itemID}, } } -func (r sqlRepository) bmkUpsert(ctx context.Context, itemID, comment string, position int64) error { - client, _ := request.ClientFrom(ctx) - user, _ := request.UserFrom(ctx) - values := map[string]any{ +func (r sqlRepository) bmkUpsert(itemID, comment string, position int64) error { + client, _ := request.ClientFrom(r.ctx) + user, _ := request.UserFrom(r.ctx) + values := map[string]interface{}{ "comment": comment, "position": position, "updated_at": time.Now(), "changed_by": client, } - upd := Update(bookmarkTable).Where(r.bmkID(ctx, itemID)).SetMap(values) - c, err := r.executeSQL(ctx, upd) + upd := Update(bookmarkTable).Where(r.bmkID(itemID)).SetMap(values) + c, err := r.executeSQL(upd) if err == nil { - log.Debug(ctx, "Updated bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment) + log.Debug(r.ctx, "Updated bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment) } - if c == 0 || errors.Is(err, sql.ErrNoRows) { + if c == 0 || errors.Is(err, orm.ErrNoRows) { values["user_id"] = user.ID values["item_type"] = r.tableName values["item_id"] = itemID values["created_at"] = time.Now() values["updated_at"] = time.Now() ins := Insert(bookmarkTable).SetMap(values) - _, err = r.executeSQL(ctx, ins) + _, err = r.executeSQL(ins) if err != nil { return err } - log.Debug(ctx, "Added bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment) + log.Debug(r.ctx, "Added bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment) } return err } -func (r sqlRepository) AddBookmark(ctx context.Context, id, comment string, position int64) error { - user, _ := request.UserFrom(ctx) - err := r.bmkUpsert(ctx, id, comment, position) +func (r sqlRepository) AddBookmark(id, comment string, position int64) error { + user, _ := request.UserFrom(r.ctx) + err := r.bmkUpsert(id, comment, position) if err != nil { - log.Error(ctx, "Error adding bookmark", "id", id, "user", user.UserName, "position", position, "comment", comment) + log.Error(r.ctx, "Error adding bookmark", "id", id, "user", user.UserName, "position", position, "comment", comment) } return err } -func (r sqlRepository) DeleteBookmark(ctx context.Context, id string) error { - user, _ := request.UserFrom(ctx) - del := Delete(bookmarkTable).Where(r.bmkID(ctx, id)) - _, err := r.executeSQL(ctx, del) +func (r sqlRepository) DeleteBookmark(id string) error { + user, _ := request.UserFrom(r.ctx) + del := Delete(bookmarkTable).Where(r.bmkID(id)) + _, err := r.executeSQL(del) if err != nil { - log.Error(ctx, "Error removing bookmark", "id", id, "user", user.UserName) + log.Error(r.ctx, "Error removing bookmark", "id", id, "user", user.UserName) } return err } type bookmark struct { - UserID string `json:"user_id"` - ItemID string `json:"item_id"` + UserID string `json:"user_id" orm:"column(user_id)"` + ItemID string `json:"item_id" orm:"column(item_id)"` ItemType string `json:"item_type"` Comment string `json:"comment"` Position int64 `json:"position"` @@ -97,18 +92,16 @@ type bookmark struct { UpdatedAt time.Time `json:"updatedAt"` } -func (r sqlRepository) GetBookmarks(ctx context.Context) (model.Bookmarks, error) { - user, _ := request.UserFrom(ctx) +func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) { + user, _ := request.UserFrom(r.ctx) idField := r.tableName + ".id" - sq := r.newSelect(ctx).Columns(r.tableName + ".*") - sq = r.withAnnotation(ctx, sq, idField) - sq = r.withBookmark(ctx, sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil}) - sq = r.applyLibraryFilter(ctx, sq) - var mfs dbMediaFiles // TODO Decouple from media_file - err := r.queryAll(ctx, sq, &mfs) + sql := r.newSelectWithAnnotation(idField).Columns("*") + sql = r.withBookmark(sql, idField).Where(NotEq{bookmarkTable + ".item_id": nil}) + var mfs model.MediaFiles + err := r.queryAll(sql, &mfs) if err != nil { - log.Error(ctx, "Error getting mediafiles with bookmarks", "user", user.UserName, err) + log.Error(r.ctx, "Error getting mediafiles with bookmarks", "user", user.UserName, err) return nil, err } @@ -119,18 +112,18 @@ func (r sqlRepository) GetBookmarks(ctx context.Context) (model.Bookmarks, error mfMap[mf.ID] = i } - sq = Select("*").From(bookmarkTable).Where(r.bmkID(ctx, ids...)) + sql = Select("*").From(bookmarkTable).Where(r.bmkID(ids...)) var bmks []bookmark - err = r.queryAll(ctx, sq, &bmks) + err = r.queryAll(sql, &bmks) if err != nil { - log.Error(ctx, "Error getting bookmarks", "user", user.UserName, "ids", ids, err) + log.Error(r.ctx, "Error getting bookmarks", "user", user.UserName, "ids", ids, err) return nil, err } resp := make(model.Bookmarks, len(bmks)) for i, bmk := range bmks { if itemIdx, ok := mfMap[bmk.ItemID]; !ok { - log.Debug(ctx, "Invalid bookmark", "id", bmk.ItemID, "user", user.UserName) + log.Debug(r.ctx, "Invalid bookmark", "id", bmk.ItemID, "user", user.UserName) continue } else { resp[i] = model.Bookmark{ @@ -139,28 +132,21 @@ func (r sqlRepository) GetBookmarks(ctx context.Context) (model.Bookmarks, error CreatedAt: bmk.CreatedAt, UpdatedAt: bmk.UpdatedAt, ChangedBy: bmk.ChangedBy, - Item: *mfs[itemIdx].MediaFile, + Item: mfs[itemIdx], } } } return resp, nil } -func (r sqlRepository) reassignBookmark(ctx context.Context, prevID, newID string) error { - upd := Expr("update or ignore "+bookmarkTable+" set item_id = ? where item_type = ? and item_id = ?", - newID, r.tableName, prevID) - _, err := r.executeSQL(ctx, upd) - return err -} - -func (r sqlRepository) cleanBookmarks(ctx context.Context) error { +func (r sqlRepository) cleanBookmarks() error { del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")") - c, err := r.executeSQL(ctx, del) + c, err := r.executeSQL(del) if err != nil { - return fmt.Errorf("error cleaning up %s bookmarks: %w", r.tableName, err) + return err } if c > 0 { - log.Debug(ctx, "Clean-up bookmarks", "totalDeleted", c, "itemType", r.tableName) + log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c) } return nil } diff --git a/persistence/sql_bookmarks_test.go b/persistence/sql_bookmarks_test.go index b50a35d5d..f80c123c6 100644 --- a/persistence/sql_bookmarks_test.go +++ b/persistence/sql_bookmarks_test.go @@ -3,6 +3,7 @@ package persistence import ( "context" + "github.com/beego/beego/v2/client/orm" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -12,24 +13,24 @@ import ( var _ = Describe("sqlBookmarks", func() { var mr model.MediaFileRepository - var ctx context.Context BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: "userid"}) - mr = NewMediaFileRepository(GetDBXBuilder()) + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "user1"}) + mr = NewMediaFileRepository(ctx, orm.NewOrm()) }) Describe("Bookmarks", func() { It("returns an empty collection if there are no bookmarks", func() { - Expect(mr.GetBookmarks(ctx)).To(BeEmpty()) + Expect(mr.GetBookmarks()).To(BeEmpty()) }) It("saves and overrides bookmarks", func() { By("Saving the bookmark") - Expect(mr.AddBookmark(ctx, songAntenna.ID, "this is a comment", 123)).To(BeNil()) + Expect(mr.AddBookmark(songAntenna.ID, "this is a comment", 123)).To(BeNil()) - bms, err := mr.GetBookmarks(ctx) - Expect(err).ToNot(HaveOccurred()) + bms, err := mr.GetBookmarks() + Expect(err).To(BeNil()) Expect(bms).To(HaveLen(1)) Expect(bms[0].Item.ID).To(Equal(songAntenna.ID)) @@ -42,10 +43,10 @@ var _ = Describe("sqlBookmarks", func() { Expect(updated).To(BeTemporally(">=", created)) By("Overriding the bookmark") - Expect(mr.AddBookmark(ctx, songAntenna.ID, "another comment", 333)).To(BeNil()) + Expect(mr.AddBookmark(songAntenna.ID, "another comment", 333)).To(BeNil()) - bms, err = mr.GetBookmarks(ctx) - Expect(err).ToNot(HaveOccurred()) + bms, err = mr.GetBookmarks() + Expect(err).To(BeNil()) Expect(bms[0].Item.ID).To(Equal(songAntenna.ID)) Expect(bms[0].Comment).To(Equal("another comment")) @@ -54,69 +55,18 @@ var _ = Describe("sqlBookmarks", func() { Expect(bms[0].UpdatedAt).To(BeTemporally(">=", updated)) By("Saving another bookmark") - Expect(mr.AddBookmark(ctx, songComeTogether.ID, "one more comment", 444)).To(BeNil()) - bms, err = mr.GetBookmarks(ctx) - Expect(err).ToNot(HaveOccurred()) + Expect(mr.AddBookmark(songComeTogether.ID, "one more comment", 444)).To(BeNil()) + bms, err = mr.GetBookmarks() + Expect(err).To(BeNil()) Expect(bms).To(HaveLen(2)) By("Delete bookmark") - Expect(mr.DeleteBookmark(ctx, songAntenna.ID)).To(Succeed()) - bms, err = mr.GetBookmarks(ctx) - Expect(err).ToNot(HaveOccurred()) + Expect(mr.DeleteBookmark(songAntenna.ID)) + bms, err = mr.GetBookmarks() + Expect(err).To(BeNil()) Expect(bms).To(HaveLen(1)) Expect(bms[0].Item.ID).To(Equal(songComeTogether.ID)) Expect(bms[0].Item.Title).To(Equal(songComeTogether.Title)) - - Expect(mr.DeleteBookmark(ctx, songComeTogether.ID)).To(Succeed()) - Expect(mr.GetBookmarks(ctx)).To(BeEmpty()) - }) - }) - - Describe("library access", func() { - var otherLib model.Library - var restrictedUser model.User - var adminCtx, userCtx context.Context - var userMr model.MediaFileRepository - - BeforeEach(func() { - adminCtx, otherLib, restrictedUser = restrictedFixture("bmk") - - adminMr := NewMediaFileRepository(GetDBXBuilder()) - Expect(adminMr.Put(adminCtx, &model.MediaFile{ - ID: "bmk-otherlib-track", LibraryID: otherLib.ID, - Path: "hidden/bookmarked.mp3", Title: "Hidden Bookmarked", - })).To(Succeed()) - DeferCleanup(func() { _ = adminMr.Delete(adminCtx, "bmk-otherlib-track") }) - - userCtx = request.WithUser(ctx, restrictedUser) - userMr = NewMediaFileRepository(GetDBXBuilder()) - }) - - It("does not return bookmarks for tracks outside the user's libraries", func() { - Expect(userMr.AddBookmark(userCtx, "bmk-otherlib-track", "sneaky", 1)).To(Succeed()) - - Expect(userMr.GetBookmarks(userCtx)).To(BeEmpty()) - }) - - It("still returns the bookmark for an admin", func() { - adminMr := NewMediaFileRepository(GetDBXBuilder()) - Expect(adminMr.AddBookmark(adminCtx, "bmk-otherlib-track", "mine", 1)).To(Succeed()) - DeferCleanup(func() { _ = adminMr.DeleteBookmark(adminCtx, "bmk-otherlib-track") }) - - bms, err := adminMr.GetBookmarks(adminCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(bms).To(HaveLen(1)) - Expect(bms[0].Item.ID).To(Equal("bmk-otherlib-track")) - }) - - It("keeps returning bookmarks for tracks inside the user's libraries", func() { - Expect(userMr.AddBookmark(userCtx, songAntenna.ID, "allowed", 5)).To(Succeed()) - DeferCleanup(func() { _ = userMr.DeleteBookmark(userCtx, songAntenna.ID) }) - - bms, err := userMr.GetBookmarks(userCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(bms).To(HaveLen(1)) - Expect(bms[0].Item.ID).To(Equal(songAntenna.ID)) }) }) }) diff --git a/persistence/sql_genres.go b/persistence/sql_genres.go new file mode 100644 index 000000000..397e43368 --- /dev/null +++ b/persistence/sql_genres.go @@ -0,0 +1,124 @@ +package persistence + +import ( + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" +) + +func (r sqlRepository) withGenres(sql SelectBuilder) SelectBuilder { + return sql.LeftJoin(r.tableName + "_genres ag on " + r.tableName + ".id = ag." + r.tableName + "_id"). + LeftJoin("genre on ag.genre_id = genre.id") +} + +func (r *sqlRepository) updateGenres(id string, tableName string, genres model.Genres) error { + del := Delete(tableName + "_genres").Where(Eq{tableName + "_id": id}) + _, err := r.executeSQL(del) + if err != nil { + return err + } + + if len(genres) == 0 { + return nil + } + var genreIds []string + for _, g := range genres { + genreIds = append(genreIds, g.ID) + } + err = utils.RangeByChunks(genreIds, 100, func(ids []string) error { + ins := Insert(tableName+"_genres").Columns("genre_id", tableName+"_id") + for _, gid := range ids { + ins = ins.Values(gid, id) + } + _, err = r.executeSQL(ins) + return err + }) + return err +} + +func (r *sqlRepository) loadMediaFileGenres(mfs *model.MediaFiles) error { + var ids []string + m := map[string]*model.MediaFile{} + for i := range *mfs { + mf := &(*mfs)[i] + ids = append(ids, mf.ID) + m[mf.ID] = mf + } + + return utils.RangeByChunks(ids, 900, func(ids []string) error { + sql := Select("g.*", "mg.media_file_id").From("genre g").Join("media_file_genres mg on mg.genre_id = g.id"). + Where(Eq{"mg.media_file_id": ids}).OrderBy("mg.media_file_id", "mg.rowid") + var genres []struct { + model.Genre + MediaFileId string + } + + err := r.queryAll(sql, &genres) + if err != nil { + return err + } + for _, g := range genres { + mf := m[g.MediaFileId] + mf.Genres = append(mf.Genres, g.Genre) + } + return nil + }) +} + +func (r *sqlRepository) loadAlbumGenres(mfs *model.Albums) error { + var ids []string + m := map[string]*model.Album{} + for i := range *mfs { + mf := &(*mfs)[i] + ids = append(ids, mf.ID) + m[mf.ID] = mf + } + + return utils.RangeByChunks(ids, 900, func(ids []string) error { + sql := Select("g.*", "ag.album_id").From("genre g").Join("album_genres ag on ag.genre_id = g.id"). + Where(Eq{"ag.album_id": ids}).OrderBy("ag.album_id", "ag.rowid") + var genres []struct { + model.Genre + AlbumId string + } + + err := r.queryAll(sql, &genres) + if err != nil { + return err + } + for _, g := range genres { + mf := m[g.AlbumId] + mf.Genres = append(mf.Genres, g.Genre) + } + return nil + }) +} + +func (r *sqlRepository) loadArtistGenres(mfs *model.Artists) error { + var ids []string + m := map[string]*model.Artist{} + for i := range *mfs { + mf := &(*mfs)[i] + ids = append(ids, mf.ID) + m[mf.ID] = mf + } + + return utils.RangeByChunks(ids, 900, func(ids []string) error { + sql := Select("g.*", "ag.artist_id").From("genre g").Join("artist_genres ag on ag.genre_id = g.id"). + Where(Eq{"ag.artist_id": ids}).OrderBy("ag.artist_id", "ag.rowid") + var genres []struct { + model.Genre + ArtistId string + } + + err := r.queryAll(sql, &genres) + if err != nil { + return err + } + for _, g := range genres { + mf := m[g.ArtistId] + mf.Genres = append(mf.Genres, g.Genre) + } + return nil + }) +} diff --git a/persistence/sql_participations.go b/persistence/sql_participations.go deleted file mode 100644 index bc2af39e2..000000000 --- a/persistence/sql_participations.go +++ /dev/null @@ -1,146 +0,0 @@ -package persistence - -import ( - "context" - "encoding/json" - "fmt" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" -) - -type participant struct { - ID string `json:"id"` - Name string `json:"name"` - SubRole string `json:"subRole,omitempty"` -} - -// flatParticipant represents a flattened participant structure for SQL processing -type flatParticipant struct { - ArtistID string `json:"artist_id"` - Role string `json:"role"` - SubRole string `json:"sub_role,omitempty"` -} - -// ParticipantIDFilter matches rows of table where the artist participates in any of the given roles -// (any role when empty). Semi-joins _artists; json_tree over the JSON is far slower at scale. -func ParticipantIDFilter(table string, artistID any, roles ...model.Role) Sqlizer { - return participantIDFilter(table, artistID, false, roles) -} - -// NotParticipantIDFilter is the negation of ParticipantIDFilter. -func NotParticipantIDFilter(table string, artistID any, roles ...model.Role) Sqlizer { - return participantIDFilter(table, artistID, true, roles) -} - -func participantIDFilter(table string, artistID any, negate bool, roles []model.Role) Sqlizer { - sel := Select(table + "_id").From(table + "_artists").Where(Eq{"artist_id": artistID}) - if len(roles) > 0 { - sel = sel.Where(Eq{"role": slice.Map(roles, func(r model.Role) string { return r.String() })}) - } - sql, args, _ := sel.ToSql() - op := " IN (" - if negate { - op = " NOT IN (" - } - return Expr(table+".id"+op+sql+")", args...) -} - -func marshalParticipants(participants model.Participants) string { - dbParticipants := make(map[model.Role][]participant) - for role, artists := range participants { - for _, artist := range artists { - dbParticipants[role] = append(dbParticipants[role], participant{ID: artist.ID, SubRole: artist.SubRole, Name: artist.Name}) - } - } - res, _ := json.Marshal(dbParticipants) - return string(res) -} - -func unmarshalParticipants(data string) (model.Participants, error) { - var dbParticipants map[model.Role][]participant - err := json.Unmarshal([]byte(data), &dbParticipants) - if err != nil { - return nil, fmt.Errorf("parsing participants: %w", err) - } - - participants := make(model.Participants, len(dbParticipants)) - for role, participantList := range dbParticipants { - artists := slice.Map(participantList, func(p participant) model.Participant { - return model.Participant{Artist: model.Artist{ID: p.ID, Name: p.Name}, SubRole: p.SubRole} - }) - participants[role] = artists - } - return participants, nil -} - -func (r sqlRepository) updateParticipants(ctx context.Context, itemID string, participants model.Participants) error { - // Delete all existing participant entries for this item. - // This ensures stale role associations are removed when an artist's role changes - // (e.g., an artist was both albumartist and composer, but is now only composer). - sqd := Delete(r.tableName + "_artists").Where(Eq{r.tableName + "_id": itemID}) - _, err := r.executeSQL(ctx, sqd) - if err != nil { - return err - } - if len(participants) == 0 { - return nil - } - - var flatParticipants []flatParticipant - for role, artists := range participants { - for _, artist := range artists { - flatParticipants = append(flatParticipants, flatParticipant{ - ArtistID: artist.ID, - Role: role.String(), - SubRole: artist.SubRole, - }) - } - } - - participantsJSON, err := json.Marshal(flatParticipants) - if err != nil { - return fmt.Errorf("marshaling participants: %w", err) - } - - // Build the INSERT query using json_each and INNER JOIN to artist table - // to automatically filter out non-existent artist IDs - query := fmt.Sprintf(` - INSERT INTO %[1]s_artists (%[1]s_id, artist_id, role, sub_role) - SELECT ?, - json_extract(value, '$.artist_id') as artist_id, - json_extract(value, '$.role') as role, - COALESCE(json_extract(value, '$.sub_role'), '') as sub_role - -- Parse the flat JSON array: [{"artist_id": "id", "role": "role", "sub_role": "subRole"}] - FROM json_each(?) -- Iterate through each array element - -- CRITICAL: Only insert records for artists that actually exist in the database - JOIN artist ON artist.id = json_extract(value, '$.artist_id') -- Filter out non-existent artist IDs via INNER JOIN - -- Handle duplicate insertions gracefully (e.g., if called multiple times) - ON CONFLICT (artist_id, %[1]s_id, role, sub_role) DO NOTHING -- Ignore duplicates - `, r.tableName) - - _, err = r.executeSQL(ctx, Expr(query, itemID, string(participantsJSON))) - return err -} - -func (r *sqlRepository) getParticipants(ctx context.Context, m *model.MediaFile) (model.Participants, error) { - ar := NewArtistRepository(r.db) - ids := m.Participants.AllIDs() - artists, err := ar.GetAll(ctx, model.QueryOptions{Filters: Eq{"artist.id": ids}}) - if err != nil { - return nil, fmt.Errorf("getting participants: %w", err) - } - artistMap := slice.ToMap(artists, func(a model.Artist) (string, model.Artist) { - return a.ID, a - }) - p := m.Participants - for role, artistList := range p { - for idx, artist := range artistList { - if a, ok := artistMap[artist.ID]; ok { - p[role][idx].Artist = a - } - } - } - return p, nil -} diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go index a758cb847..d04048312 100644 --- a/persistence/sql_restful.go +++ b/persistence/sql_restful.go @@ -1,123 +1,75 @@ package persistence import ( - "cmp" - "context" "fmt" - "reflect" "strings" - "sync" . "github.com/Masterminds/squirrel" "github.com/deluan/rest" - "github.com/fatih/structs" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) -type filterFunc = func(field string, value any) Sqlizer +type filterFunc = func(field string, value interface{}) Sqlizer -func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.QueryOptions) Sqlizer { +type sqlRestful struct { + filterMappings map[string]filterFunc +} + +func (r sqlRestful) parseRestFilters(options rest.QueryOptions) Sqlizer { if len(options.Filters) == 0 { return nil } filters := And{} for f, v := range options.Filters { - // Ignore filters with empty values if v == "" { continue } - // Look for a custom filter function - f = strings.ToLower(f) if ff, ok := r.filterMappings[f]; ok { - if filter := ff(f, v); filter != nil { - filters = append(filters, filter) - } - continue - } - // Ignore invalid filters (not based on a field or filter function) - if r.isFieldWhiteListed != nil && !r.isFieldWhiteListed(f) { - log.Warn(ctx, "Ignoring filter not whitelisted", "filter", f, "table", r.tableName) - continue - } - // For fields ending in "id", use an exact match - if strings.HasSuffix(f, "id") { + filters = append(filters, ff(f, v)) + } else if strings.HasSuffix(strings.ToLower(f), "id") { filters = append(filters, eqFilter(f, v)) - continue + } else { + filters = append(filters, startsWithFilter(f, v)) } - // Default to a "starts with" filter - filters = append(filters, Like{f: fmt.Sprintf("%s%%", v)}) } return filters } -func (r *sqlRepository) parseRestOptions(ctx context.Context, options ...rest.QueryOptions) model.QueryOptions { +func (r sqlRestful) parseRestOptions(options ...rest.QueryOptions) model.QueryOptions { qo := model.QueryOptions{} if len(options) > 0 { - qo.Sort, qo.Order = r.sanitizeSort(ctx, options[0].Sort, options[0].Order) + qo.Sort = options[0].Sort + qo.Order = strings.ToLower(options[0].Order) qo.Max = options[0].Max qo.Offset = options[0].Offset - if seed, ok := options[0].Filters["seed"].(string); ok { - qo.Seed = seed - delete(options[0].Filters, "seed") - } - qo.Filters = r.parseRestFilters(ctx, options[0]) + qo.Filters = r.parseRestFilters(options[0]) } return qo } -func (r sqlRepository) sanitizeSort(ctx context.Context, sort, order string) (string, string) { - if sort != "" { - sort = toSnakeCase(sort) - // Validate only: buildSortOrder resolves the mapping later, and mapping here as well would - // feed sortMapping its own output. - if _, _, known := r.lookupSortMapping(sort); !known && !r.isFieldWhiteListed(sort) { - log.Warn(ctx, "Ignoring sort not whitelisted", "sort", sort, "table", r.tableName) - sort = "" - } - } - if order != "" { - order = strings.ToLower(order) - if order != "desc" { - order = "asc" - } - } - return sort, order -} - -func eqFilter(field string, value any) Sqlizer { +func eqFilter(field string, value interface{}) Sqlizer { return Eq{field: value} } -func startsWithFilter(field string) func(string, any) Sqlizer { - return func(_ string, value any) Sqlizer { - return Like{field: fmt.Sprintf("%s%%", value)} - } +func startsWithFilter(field string, value interface{}) Sqlizer { + return Like{field: fmt.Sprintf("%s%%", value)} } -func containsFilter(field string) func(string, any) Sqlizer { - return func(_ string, value any) Sqlizer { - return Like{field: fmt.Sprintf("%%%s%%", value)} - } +func containsFilter(field string, value interface{}) Sqlizer { + return Like{field: fmt.Sprintf("%%%s%%", value)} } -func booleanFilter(field string, value any) Sqlizer { +func booleanFilter(field string, value interface{}) Sqlizer { v := strings.ToLower(value.(string)) - return Eq{field: v == "true"} + return Eq{field: strings.ToLower(v) == "true"} } -func fullTextFilter(tableName string, mbidFields ...string) func(string, any) Sqlizer { - return func(field string, value any) Sqlizer { - v := strings.ToLower(value.(string)) - return cmp.Or[Sqlizer]( - mbidExpr(tableName, v, mbidFields...), - getSearchStrategy(tableName, v), - ) - } +func fullTextFilter(field string, value interface{}) Sqlizer { + return fullTextExpr(value.(string)) } -func substringFilter(field string, value any) Sqlizer { - parts := strings.Fields(value.(string)) +func substringFilter(field string, value interface{}) Sqlizer { + parts := strings.Split(value.(string), " ") filters := And{} for _, part := range parts { filters = append(filters, Like{field: "%" + part + "%"}) @@ -125,53 +77,8 @@ func substringFilter(field string, value any) Sqlizer { return filters } -func idFilter(tableName string) func(string, any) Sqlizer { - return func(field string, value any) Sqlizer { return Eq{tableName + ".id": value} } -} - -func invalidFilter(field string, value any) Sqlizer { - log.Warn("Invalid filter", "fieldName", field, "value", value) - return Eq{"1": "0"} -} - -var ( - whiteList = map[string]map[string]struct{}{} - mutex sync.RWMutex -) - -func registerModelWhiteList(instance any) fieldWhiteListedFunc { - name := reflect.TypeOf(instance).String() - registerFieldWhiteList(name, instance) - return getFieldWhiteListedFunc(name) -} - -func registerFieldWhiteList(name string, instance any) { - mutex.Lock() - defer mutex.Unlock() - if whiteList[name] != nil { - return - } - m := structs.Map(instance) - whiteList[name] = map[string]struct{}{} - for k := range m { - whiteList[name][toSnakeCase(k)] = struct{}{} - } - ma := structs.Map(model.Annotations{}) - for k := range ma { - whiteList[name][toSnakeCase(k)] = struct{}{} - } -} - -type fieldWhiteListedFunc func(field string) bool - -func getFieldWhiteListedFunc(tableName string) fieldWhiteListedFunc { - return func(field string) bool { - mutex.RLock() - defer mutex.RUnlock() - if _, ok := whiteList[tableName]; !ok { - return false - } - _, ok := whiteList[tableName][field] - return ok +func idFilter(tableName string) func(string, interface{}) Sqlizer { + return func(field string, value interface{}) Sqlizer { + return Eq{tableName + ".id": value} } } diff --git a/persistence/sql_restful_test.go b/persistence/sql_restful_test.go index 32f418b8e..637ac3446 100644 --- a/persistence/sql_restful_test.go +++ b/persistence/sql_restful_test.go @@ -1,258 +1,49 @@ package persistence import ( - "context" - "strings" - "github.com/Masterminds/squirrel" "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("sqlRestful", func() { Describe("parseRestFilters", func() { - var r sqlRepository + var r sqlRestful var options rest.QueryOptions BeforeEach(func() { - r = sqlRepository{} + r = sqlRestful{} }) It("returns nil if filters is empty", func() { options.Filters = nil - Expect(r.parseRestFilters(context.Background(), options)).To(BeNil()) - }) - - It(`returns nil if tries a filter with legacySearchExpr("'")`, func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Search.Backend = "legacy" - r.filterMappings = map[string]filterFunc{ - "name": fullTextFilter("table"), - } - options.Filters = map[string]any{"name": "'"} - Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty()) - }) - - It("does not add nill filters", func() { - r.filterMappings = map[string]filterFunc{ - "name": func(string, any) squirrel.Sqlizer { - return nil - }, - } - options.Filters = map[string]any{"name": "joe"} - Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty()) + Expect(r.parseRestFilters(options)).To(BeNil()) }) It("returns a '=' condition for 'id' filter", func() { - options.Filters = map[string]any{"id": "123"} - Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}})) + options.Filters = map[string]interface{}{"id": "123"} + Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}})) }) It("returns a 'in' condition for multiples 'id' filters", func() { - options.Filters = map[string]any{"id": []string{"123", "456"}} - Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}})) + options.Filters = map[string]interface{}{"id": []string{"123", "456"}} + Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}})) }) It("returns a 'like' condition for other filters", func() { - options.Filters = map[string]any{"name": "joe"} - Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}})) + options.Filters = map[string]interface{}{"name": "joe"} + Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}})) }) It("uses the custom filter", func() { r.filterMappings = map[string]filterFunc{ - "test": func(field string, value any) squirrel.Sqlizer { + "test": func(field string, value interface{}) squirrel.Sqlizer { return squirrel.Gt{field: value} }, } - options.Filters = map[string]any{"test": 100} - Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}})) + options.Filters = map[string]interface{}{"test": 100} + Expect(r.parseRestFilters(options)).To(Equal(squirrel.And{squirrel.Gt{"test": 100}})) }) }) - - Describe("fullTextFilter function", func() { - var filter filterFunc - var tableName string - var mbidFields []string - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Search.Backend = "legacy" - tableName = "test_table" - mbidFields = []string{"mbid", "artist_mbid"} - filter = fullTextFilter(tableName, mbidFields...) - }) - - Context("when value is a valid UUID", func() { - It("returns only the mbid filter (precedence over full text)", func() { - uuid := "550e8400-e29b-41d4-a716-446655440000" - result := filter("search", uuid) - - expected := squirrel.Or{ - squirrel.Eq{"test_table.mbid": uuid}, - squirrel.Eq{"test_table.artist_mbid": uuid}, - } - Expect(result).To(Equal(expected)) - }) - - It("falls back to full text when no mbid fields are provided", func() { - noMbidFilter := fullTextFilter(tableName) - uuid := "550e8400-e29b-41d4-a716-446655440000" - result := noMbidFilter("search", uuid) - - // mbidExpr with no fields returns nil, so cmp.Or falls back to search strategy - sql, args, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) - Expect(args).To(ContainElement("% 550e8400-e29b-41d4-a716-446655440000%")) - }) - }) - - Context("when value is not a valid UUID", func() { - It("returns full text search condition only", func() { - result := filter("search", "beatles") - - // mbidExpr returns nil for non-UUIDs, so search strategy result is returned directly - sql, args, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) - Expect(args).To(ContainElement("% beatles%")) - }) - - It("handles multi-word search terms", func() { - result := filter("search", "the beatles abbey road") - - sql, args, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - // All words should be present as LIKE conditions - Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) - Expect(args).To(HaveLen(4)) - Expect(args).To(ContainElement("% the%")) - Expect(args).To(ContainElement("% beatles%")) - Expect(args).To(ContainElement("% abbey%")) - Expect(args).To(ContainElement("% road%")) - }) - }) - - Context("when SearchFullString config changes behavior", func() { - It("uses different separator with SearchFullString=false", func() { - conf.Server.Search.FullString = false - result := filter("search", "test query") - - sql, args, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) - Expect(args).To(HaveLen(2)) - Expect(args).To(ContainElement("% test%")) - Expect(args).To(ContainElement("% query%")) - }) - - It("uses no separator with SearchFullString=true", func() { - conf.Server.Search.FullString = true - result := filter("search", "test query") - - sql, args, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) - Expect(args).To(HaveLen(2)) - Expect(args).To(ContainElement("%test%")) - Expect(args).To(ContainElement("%query%")) - }) - }) - - Context("single-character queries (regression: must not be rejected)", func() { - It("returns valid filter for single-char query with legacy backend", func() { - conf.Server.Search.Backend = "legacy" - result := filter("search", "a") - Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped") - sql, args, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("LIKE")) - Expect(args).ToNot(BeEmpty()) - }) - - It("returns valid filter for single-char query with FTS backend", func() { - conf.Server.Search.Backend = "fts" - conf.Server.Search.FullString = false - ftsFilter := fullTextFilter(tableName, mbidFields...) - result := ftsFilter("search", "a") - Expect(result).ToNot(BeNil(), "single-char REST filter must not be dropped") - sql, args, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("MATCH")) - Expect(args).ToNot(BeEmpty()) - }) - }) - - Context("edge cases", func() { - It("returns nil for empty string", func() { - result := filter("search", "") - Expect(result).To(BeNil()) - }) - - It("returns nil for string with only whitespace", func() { - result := filter("search", " ") - Expect(result).To(BeNil()) - }) - - It("handles special characters that are sanitized", func() { - result := filter("search", "don't") - - sql, args, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) - Expect(args).To(ContainElement("% dont%")) - }) - - It("returns nil for single quote (SQL injection protection)", func() { - result := filter("search", "'") - Expect(result).To(BeNil()) - }) - - It("handles mixed case UUIDs", func() { - uuid := "550E8400-E29B-41D4-A716-446655440000" - result := filter("search", uuid) - - // Should return only mbid filter (uppercase UUID should work) - expected := squirrel.Or{ - squirrel.Eq{"test_table.mbid": strings.ToLower(uuid)}, - squirrel.Eq{"test_table.artist_mbid": strings.ToLower(uuid)}, - } - Expect(result).To(Equal(expected)) - }) - - It("handles invalid UUID format gracefully", func() { - result := filter("search", "550e8400-invalid-uuid") - - // Should return full text filter since UUID is invalid - sql, args, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) - Expect(args).To(ContainElement("% 550e8400-invalid-uuid%")) - }) - - It("handles empty mbid fields array", func() { - emptyMbidFilter := fullTextFilter(tableName, []string{}...) - result := emptyMbidFilter("search", "test") - - // mbidExpr with empty fields returns nil, so search strategy result is returned directly - sql, args, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) - Expect(args).To(ContainElement("% test%")) - }) - - It("converts value to lowercase before processing", func() { - result := filter("search", "TEST") - - sql, args, err := result.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("test_table.full_text LIKE")) - Expect(args).To(ContainElement("% test%")) - }) - }) - }) - }) diff --git a/persistence/sql_search.go b/persistence/sql_search.go index aa8707247..0ef0d623e 100644 --- a/persistence/sql_search.go +++ b/persistence/sql_search.go @@ -1,142 +1,45 @@ package persistence import ( - "context" - "fmt" "strings" . "github.com/Masterminds/squirrel" - "github.com/google/uuid" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/str" + "github.com/navidrome/navidrome/utils" ) -func formatFullText(text ...string) string { - fullText := str.SanitizeStrings(text...) +func getFullText(text ...string) string { + fullText := utils.SanitizeStrings(text...) return " " + fullText } -// searchConfig holds per-repository constants for doSearch. -type searchConfig struct { - NaturalOrder string // ORDER BY for empty-query results (e.g. "album.rowid") - OrderBy []string // ORDER BY for text search results (e.g. ["name"]) - MBIDFields []string // columns to match when query is a UUID - // LibraryFilter overrides the default applyLibraryFilter for the rowid Phase 1, for entities whose - // library access goes through a junction table (e.g. artist → library_artist). It MUST be join-free - // (Phase 1 has no DISTINCT, so a fan-out JOIN would corrupt offset pagination). See [artistLibraryFilter]. - LibraryFilter func(sq SelectBuilder) SelectBuilder -} - -// searchStrategy defines how to execute a text search against a repository table. -// options carries filters and pagination that must reach all query phases, -// including FTS Phase 1 which builds its own query outside sq. -type searchStrategy interface { - Sqlizer - execute(ctx context.Context, r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error -} - -// getSearchStrategy returns the appropriate search strategy based on config and query content. -// Returns nil when the query produces no searchable tokens. -func getSearchStrategy(tableName, query string) searchStrategy { - if conf.Server.Search.Backend == "legacy" || conf.Server.Search.FullString { - return newLegacySearch(tableName, query) - } - if containsCJK(query) { - return newLikeSearch(tableName, query) - } - return newFTSSearch(tableName, query) -} - -// doSearch dispatches a search query: empty → natural order, UUID → MBID match, -// otherwise delegates to getSearchStrategy. sq must already have LIMIT/OFFSET set -// via newSelect(options...). options is forwarded so FTS Phase 1 can apply the same -// filters and pagination independently. -func (r sqlRepository) doSearch(ctx context.Context, sq SelectBuilder, q string, results any, cfg searchConfig, options model.QueryOptions) error { +func (r sqlRepository) doSearch(q string, offset, size int, results interface{}, orderBys ...string) error { q = strings.TrimSpace(q) q = strings.TrimSuffix(q, "*") - - sq = sq.Where(Eq{r.tableName + ".missing": false}) - - // Empty query (OpenSubsonic `search3?query=""`) — return all in natural order. - if q == "" || q == `""` { - rowidCore := Select(r.tableName + ".rowid").From(r.tableName).OrderBy(cfg.NaturalOrder) - return r.executeTwoPhase(ctx, sq, results, rowidCore, cfg, options) - } - - // MBID search: if query is a valid UUID, search by MBID fields instead - if uuid.Validate(q) == nil && len(cfg.MBIDFields) > 0 { - sq = sq.Where(mbidExpr(r.tableName, q, cfg.MBIDFields...)) - return r.queryAll(ctx, sq, results) - } - - // Min-length guard: single-character queries are too broad for search3. - // This check lives here (not in the strategies) so that fullTextFilter - // (REST filter path) can still use single-character queries. if len(q) < 2 { return nil } - strategy := getSearchStrategy(r.tableName, q) - if strategy == nil { - return nil + sq := r.newSelectWithAnnotation(r.tableName + ".id").Columns("*") + sq = sq.Limit(uint64(size)).Offset(uint64(offset)) + if len(orderBys) > 0 { + sq = sq.OrderBy(orderBys...) } - - return strategy.execute(ctx, r, sq, results, cfg, options) + sq = sq.Where(fullTextExpr(q)) + err := r.queryAll(sq, results) + return err } -// executeTwoPhase runs a search in two phases: -// - Phase 1: rowidCore (strategy-specific FROM/JOINs and ORDER BY) plus the shared search -// contract applied here — non-missing rows only, library access, options.Filters, and -// pagination. Keeping Phase 1 free of the full SELECT's JOINs lets SQLite paginate via a -// covering index; with those JOINs, large offsets degrade to O(offset) join probes — -// multi-second responses on 100k+ libraries. -// - Phase 2: full SELECT with all JOINs, scoped to Phase 1's rowid page. -func (r sqlRepository) executeTwoPhase(ctx context.Context, sq SelectBuilder, results any, rowidCore SelectBuilder, cfg searchConfig, options model.QueryOptions) error { - rowidQuery := rowidCore. - Where(Eq{r.tableName + ".missing": false}) - if options.Max > 0 { - rowidQuery = rowidQuery.Limit(uint64(options.Max)) +func fullTextExpr(value string) Sqlizer { + var sep string + if !conf.Server.SearchFullString { + sep = " " } - if options.Offset > 0 { - rowidQuery = rowidQuery.Offset(uint64(options.Offset)) + q := utils.SanitizeStrings(value) + parts := strings.Split(q, " ") + filters := And{} + for _, part := range parts { + filters = append(filters, Like{"full_text": "%" + sep + part + "%"}) } - if cfg.LibraryFilter != nil { - rowidQuery = cfg.LibraryFilter(rowidQuery) - } else { - rowidQuery = r.applyLibraryFilter(ctx, rowidQuery) - } - if options.Filters != nil { - rowidQuery = rowidQuery.Where(options.Filters) - } - return r.hydrateRowidPage(ctx, sq, rowidQuery, results) -} - -// hydrateRowidPage joins sq to the ordered rowid set produced by rowidQuery, preserving its -// ordering. rowidQuery must handle pagination itself; sq's LIMIT/OFFSET are stripped. -func (r sqlRepository) hydrateRowidPage(ctx context.Context, sq SelectBuilder, rowidQuery SelectBuilder, results any) error { - rowidSQL, rowidArgs, err := rowidQuery.ToSql() - if err != nil { - return fmt.Errorf("building rowid query: %w", err) - } - sq = sq.RemoveLimit().RemoveOffset() - rankedSubquery := fmt.Sprintf( - "(SELECT rowid as _rid, row_number() OVER () AS _rn FROM (%s)) AS _ranked", - rowidSQL, - ) - sq = sq.Join(rankedSubquery+" ON "+r.tableName+".rowid = _ranked._rid", rowidArgs...) - sq = sq.OrderBy("_ranked._rn") - return r.queryAll(ctx, sq, results) -} - -func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer { - if uuid.Validate(mbid) != nil || len(mbidFields) == 0 { - return nil - } - mbid = strings.ToLower(mbid) - var cond []Sqlizer - for _, mbidField := range mbidFields { - cond = append(cond, Eq{tableName + "." + mbidField: mbid}) - } - return Or(cond) + return filters } diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go deleted file mode 100644 index ee43680e6..000000000 --- a/persistence/sql_search_fts.go +++ /dev/null @@ -1,389 +0,0 @@ -package persistence - -import ( - "context" - "fmt" - "regexp" - "strings" - "unicode" - "unicode/utf8" - - . "github.com/Masterminds/squirrel" - "github.com/deluan/sanitize" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/str" -) - -// containsCJK returns true if the string contains any CJK (Chinese/Japanese/Korean) characters. -// CJK text doesn't use spaces between words, so FTS5's unicode61 tokenizer treats entire -// CJK phrases as single tokens, making token-based search ineffective for CJK content. -func containsCJK(s string) bool { - for _, r := range s { - if unicode.Is(unicode.Han, r) || - unicode.Is(unicode.Hiragana, r) || - unicode.Is(unicode.Katakana, r) || - unicode.Is(unicode.Hangul, r) { - return true - } - } - return false -} - -// fts5SpecialChars matches characters that should be stripped from user input. -// We keep only Unicode letters, numbers, whitespace, * (prefix wildcard), " (phrase quotes), -// and \x00 (internal placeholder marker). All punctuation is removed because the unicode61 -// tokenizer treats it as token separators, and characters like ' can cause FTS5 parse errors -// as unbalanced string delimiters. -var fts5SpecialChars = regexp.MustCompile(`[^\p{L}\p{N}\s*"\x00]`) - -// fts5Operators matches FTS5 boolean operators as whole words (case-insensitive). -var fts5Operators = regexp.MustCompile(`(?i)\b(AND|OR|NOT|NEAR)\b`) - -// fts5LeadingStar matches a * at the start of a token. FTS5 only supports * at the end (prefix queries). -var fts5LeadingStar = regexp.MustCompile(`(^|[\s])\*+`) - -// isSingleUnicodeLetter returns true if token is exactly one Unicode letter. -func isSingleUnicodeLetter(token string) bool { - r, size := utf8.DecodeRuneInString(token) - return size == len(token) && size > 0 && unicode.IsLetter(r) -} - -// namePunctuation is the set of characters commonly used as separators in artist/album -// names (hyphens, slashes, dots, apostrophes). Only words containing these are candidates -// for punctuated-word processing; other special characters (^, :, &) are just stripped. -const namePunctuation = `-/.''` - -// processPunctuatedWords handles words with embedded name punctuation before the general -// special-character stripping. For each punctuated word it produces either: -// - A quoted phrase for dotted abbreviations: R.E.M. → "R E M" -// - A phrase+concat OR for other patterns: a-ha → ("a ha" OR aha*) -func processPunctuatedWords(input string, phrases []string) (string, []string) { - words := strings.Fields(input) - var result []string - for _, w := range words { - if strings.HasPrefix(w, "\x00") || strings.ContainsAny(w, `*"`) || !strings.ContainsAny(w, namePunctuation) { - result = append(result, w) - continue - } - concat := str.FTSPunctStrip.ReplaceAllString(w, "") - if concat == "" || concat == w { - result = append(result, w) - continue - } - subTokens := strings.Fields(fts5SpecialChars.ReplaceAllString(w, " ")) - if len(subTokens) < 2 { - // Single sub-token after splitting (e.g., N' → N): just use the stripped form - result = append(result, concat) - continue - } - // Dotted abbreviations (R.E.M., U.K.) — all single letters separated by dots only - if isDottedAbbreviation(w, subTokens) { - phrases = append(phrases, fmt.Sprintf(`"%s"`, strings.Join(subTokens, " "))) - } else { - // Punctuated names (a-ha, AC/DC, Jay-Z) — phrase for adjacency + concat for search_normalized - phrases = append(phrases, fmt.Sprintf(`("%s" OR %s*)`, strings.Join(subTokens, " "), concat)) - } - result = append(result, fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1)) - } - return strings.Join(result, " "), phrases -} - -// isDottedAbbreviation returns true if w uses only dots as punctuation and all sub-tokens -// are single letters (e.g., "R.E.M.", "U.K." but not "a-ha" or "AC/DC"). -func isDottedAbbreviation(w string, subTokens []string) bool { - for _, r := range w { - if !unicode.IsLetter(r) && !unicode.IsNumber(r) && r != '.' { - return false - } - } - for _, st := range subTokens { - if !isSingleUnicodeLetter(st) { - return false - } - } - return true -} - -// buildFTS5Query preprocesses user input into a safe FTS5 MATCH expression. -// Plain tokens are emitted as (token OR token*) so bm25 ranks exact-token hits above prefix-only matches. -// It preserves quoted phrases and * prefix wildcards, neutralizes FTS5 operators -// (by lowercasing them, since FTS5 operators are case-sensitive) and strips -// special characters to prevent query injection. -// The second return reports whether tokenization degraded the query (see ftsQueryDegraded). -func buildFTS5Query(userInput string) (string, bool) { - q := strings.TrimSpace(userInput) - if q == "" || q == `""` { - return "", false - } - - var phrases []string - result := q - for { - start := strings.Index(result, `"`) - if start == -1 { - break - } - end := strings.Index(result[start+1:], `"`) - if end == -1 { - // Unmatched quote — remove it - result = result[:start] + result[start+1:] - break - } - end += start + 1 - phrase := result[start : end+1] // includes quotes - phrases = append(phrases, phrase) - result = result[:start] + fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1) + result[end+1:] - } - - // 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) - - // Handle words with embedded punctuation (a-ha, AC/DC, R.E.M.) before stripping - result, phrases = processPunctuatedWords(result, phrases) - - result = fts5SpecialChars.ReplaceAllString(result, " ") - result = fts5LeadingStar.ReplaceAllString(result, "$1") - tokens := strings.Fields(result) - - // Two forms per token: a plain prefix form (love*) used only to evaluate query - // degradation, and the final (love OR love*) form. The OR adds no matches - // (exact ⊂ prefix) but gives bm25 a high-IDF exact-term hit, ranking rows that - // contain the literal word above prefix-only matches. Placeholders and - // user-supplied wildcards pass through untouched in both forms. - prefixTokens := make([]string, len(tokens)) - wrappedTokens := make([]string, len(tokens)) - for i, t := range tokens { - if strings.HasPrefix(t, "\x00") || strings.HasSuffix(t, "*") { - prefixTokens[i], wrappedTokens[i] = t, t - continue - } - prefixTokens[i] = t + "*" - wrappedTokens[i] = "(" + t + " OR " + t + "*)" - } - - // Use explicit AND between tokens — FTS5's implicit AND (space-separated) - // doesn't work correctly with parenthesized OR groups. The prefix form is - // space-joined instead: it only feeds ftsQueryDegraded, which would count a - // literal "AND" as a long token and never flag all-short-token queries. - prefixQuery := strings.Join(prefixTokens, " ") - result = strings.Join(wrappedTokens, " AND ") - - for i, phrase := range phrases { - placeholder := fmt.Sprintf("\x00PHRASE%d\x00", i) - prefixQuery = strings.ReplaceAll(prefixQuery, placeholder, phrase) - result = strings.ReplaceAll(result, placeholder, phrase) - } - - // Degradation is evaluated on the prefix form: ftsQueryDegraded treats - // leading-( tokens as punctuated-word groups and would never flag wrapped ones. - return result, ftsQueryDegraded(userInput, prefixQuery) -} - -// ftsColumn pairs an FTS5 column name with its BM25 relevance weight. -type ftsColumn struct { - Name string - Weight float64 -} - -// ftsColumnDefs defines FTS5 columns and their BM25 relevance weights. -// The order MUST match the column order in the FTS5 table definition (see migrations). -// All columns are both searched and ranked. When adding indexed-but-not-searched -// columns in the future, use Weight: 0 to exclude from the search column filter. -var ftsColumnDefs = map[string][]ftsColumn{ - "media_file": { - {"title", 10.0}, - {"album", 5.0}, - {"artist", 3.0}, - {"album_artist", 3.0}, - {"sort_title", 1.0}, - {"sort_album_name", 1.0}, - {"sort_artist_name", 1.0}, - {"sort_album_artist_name", 1.0}, - {"disc_subtitle", 1.0}, - {"search_participants", 2.0}, - {"search_normalized", 1.0}, - }, - "album": { - {"name", 10.0}, - {"sort_album_name", 1.0}, - {"album_artist", 3.0}, - {"search_participants", 2.0}, - {"discs", 1.0}, - {"catalog_num", 1.0}, - {"album_version", 1.0}, - {"search_normalized", 1.0}, - }, - "artist": { - {"name", 10.0}, - {"sort_artist_name", 1.0}, - // Same weight as name: for artists this column is purely the name in - // alternate spelling (unlike media_file/album, where it mixes - // title/album/artist variants and full weight would distort ranking). - {"search_normalized", 10.0}, - }, -} - -// ftsColumnFilters and ftsBM25Weights are precomputed from ftsColumnDefs at init time -// to avoid per-query allocations. -var ( - ftsColumnFilters = map[string]string{} - ftsBM25Weights = map[string]string{} -) - -func init() { - for table, cols := range ftsColumnDefs { - var names []string - weights := make([]string, len(cols)) - for i, c := range cols { - if c.Weight > 0 { - names = append(names, c.Name) - } - weights[i] = fmt.Sprintf("%.1f", c.Weight) - } - ftsColumnFilters[table] = "{" + strings.Join(names, " ") + "}" - ftsBM25Weights[table] = strings.Join(weights, ", ") - } -} - -// ftsSearch implements searchStrategy using FTS5 full-text search with BM25 ranking. -type ftsSearch struct { - tableName string - ftsTable string - matchExpr string - rankExpr string -} - -// ToSql returns a single-query fallback for the REST filter path (no two-phase split). -func (s *ftsSearch) ToSql() (string, []any, error) { - sql := s.tableName + ".rowid IN (SELECT rowid FROM " + s.ftsTable + " WHERE " + s.ftsTable + " MATCH ?)" - return sql, []any{s.matchExpr}, nil -} - -// execute runs a two-phase FTS5 search (see executeTwoPhase): Phase 1 here contributes the -// FTS MATCH join and BM25 rank ordering. Complex ORDER BY (function calls, aggregations) are -// dropped from Phase 1. -func (s *ftsSearch) execute(ctx context.Context, r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error { - qualifiedOrderBys := []string{s.rankExpr} - for _, ob := range cfg.OrderBy { - if qualified := qualifyOrderBy(s.tableName, ob); qualified != "" { - qualifiedOrderBys = append(qualifiedOrderBys, qualified) - } - } - - rowidCore := Select(s.tableName+".rowid"). - From(s.tableName). - Join(s.ftsTable+" ON "+s.ftsTable+".rowid = "+s.tableName+".rowid AND "+s.ftsTable+" MATCH ?", s.matchExpr). - OrderBy(qualifiedOrderBys...) - return r.executeTwoPhase(ctx, sq, dest, rowidCore, cfg, options) -} - -// qualifyOrderBy prepends tableName to a simple column name. Returns empty string for -// complex expressions (function calls, aggregations) that can't be used in Phase 1. -func qualifyOrderBy(tableName, orderBy string) string { - orderBy = strings.TrimSpace(orderBy) - if orderBy == "" || strings.ContainsAny(orderBy, "(,") { - return "" - } - parts := strings.Fields(orderBy) - if !strings.Contains(parts[0], ".") { - parts[0] = tableName + "." + parts[0] - } - return strings.Join(parts, " ") -} - -// ftsQueryDegraded returns true when the FTS query lost significant discriminating -// content compared to the original input. This happens when special characters that -// are part of the entity name (e.g., "1+", "C++", "!!!", "C#") get stripped by FTS -// tokenization, leaving only very short/broad tokens. Also detects quoted phrases -// that would be degraded by FTS5's unicode61 tokenizer (e.g., "1+" → token "1"). -func ftsQueryDegraded(original, ftsQuery string) bool { - original = strings.TrimSpace(original) - if original == "" || ftsQuery == "" { - return false - } - // Strip quotes from original for comparison — we want the raw content - stripped := strings.ReplaceAll(original, `"`, "") - // Extract the alphanumeric content from the original query - alphaNum := str.FTSPunctStrip.ReplaceAllString(stripped, "") - // If the original is entirely alphanumeric, nothing was stripped — not degraded - if len(alphaNum) == len(stripped) { - return false - } - // Check if all effective FTS tokens are very short (≤2 chars). - // Short tokens with prefix matching are too broad when special chars were stripped. - // For quoted phrases, extract the content and check the tokens inside. - tokens := strings.FieldsSeq(ftsQuery) - for t := range tokens { - t = strings.TrimSuffix(t, "*") - // Skip internal phrase placeholders - if strings.HasPrefix(t, "\x00") { - return false - } - // For OR groups from processPunctuatedWords (e.g., ("a ha" OR aha*)), - // the punctuated word was already handled meaningfully — not degraded. - if strings.HasPrefix(t, "(") { - return false - } - // For quoted phrases, check the tokens inside as FTS5 will tokenize them - if strings.HasPrefix(t, `"`) { - // Extract content between quotes - inner := strings.Trim(t, `"`) - innerAlpha := str.FTSPunctStrip.ReplaceAllString(inner, " ") - for it := range strings.FieldsSeq(innerAlpha) { - if len(it) > 2 { - return false - } - } - continue - } - if len(t) > 2 { - return false - } - } - return true -} - -// newFTSSearch creates an FTS5 search strategy. Falls back to LIKE search if the -// query produces no FTS tokens (e.g., punctuation-only like "!!!!!!!") or if FTS -// tokenization stripped significant content from the query (e.g., "1+" → "1*"). -// Returns nil when the query produces no searchable tokens at all. -func newFTSSearch(tableName, query string) searchStrategy { - q, degraded := buildFTS5Query(query) - if q == "" || degraded { - // Fallback: try LIKE search with the raw query - cleaned := strings.TrimSpace(strings.ReplaceAll(query, `"`, "")) - if cleaned != "" { - log.Trace("Search using LIKE fallback for non-tokenizable query", "table", tableName, "query", cleaned) - return newLikeSearch(tableName, cleaned) - } - return nil - } - ftsTable := tableName + "_fts" - matchExpr := q - if cols, ok := ftsColumnFilters[tableName]; ok { - matchExpr = cols + " : (" + q + ")" - } - - rankExpr := ftsTable + ".rank" - if weights, ok := ftsBM25Weights[tableName]; ok { - rankExpr = "bm25(" + ftsTable + ", " + weights + ")" - } - - s := &ftsSearch{ - tableName: tableName, - ftsTable: ftsTable, - matchExpr: matchExpr, - rankExpr: rankExpr, - } - log.Trace("Search using FTS5 backend", "table", tableName, "query", q, "filter", s) - return s -} diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go deleted file mode 100644 index 5d52cb1e8..000000000 --- a/persistence/sql_search_fts_test.go +++ /dev/null @@ -1,477 +0,0 @@ -package persistence - -import ( - "context" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = DescribeTable("buildFTS5Query", - func(input, expected string) { - q, _ := buildFTS5Query(input) - Expect(q).To(Equal(expected)) - }, - Entry("returns empty string for empty input", "", ""), - Entry("returns empty string for whitespace-only input", " ", ""), - Entry("wraps a single word as exact OR prefix", "beatles", "(beatles OR beatles*)"), - Entry("wraps each word as exact OR prefix", "abbey road", "(abbey OR abbey*) AND (road OR road*)"), - Entry("preserves quoted phrases without wrapping", `"the beatles"`, `"the beatles"`), - Entry("does not wrap user-supplied prefix wildcard", "beat*", "beat*"), - Entry("strips FTS5 operators and wraps lowercased words", "AND OR NOT NEAR", "(and OR and*) AND (or OR or*) AND (not OR not*) AND (near OR near*)"), - Entry("strips special FTS5 syntax characters and wraps", "test^col:val", "(test OR test*) AND (col OR col*) AND (val OR val*)"), - Entry("handles mixed phrases and words", `"the beatles" abbey`, `"the beatles" AND (abbey OR abbey*)`), - Entry("handles prefix with multiple words", "beat* abbey", "beat* AND (abbey OR abbey*)"), - Entry("collapses multiple spaces", "abbey road", "(abbey OR abbey*) AND (road OR road*)"), - Entry("strips leading * from tokens and wraps", "*livia", "(livia OR livia*)"), - Entry("strips leading * and preserves existing trailing *", "*livia oliv*", "(livia OR livia*) AND oliv*"), - Entry("strips standalone *", "*", ""), - Entry("strips apostrophe from input", "Guns N' Roses", "(Guns OR Guns*) AND (N OR N*) AND (Roses OR Roses*)"), - Entry("converts slashed word to phrase+concat OR", "AC/DC", `("AC DC" OR ACDC*)`), - Entry("converts hyphenated word to phrase+concat OR", "a-ha", `("a ha" OR aha*)`), - Entry("converts partial hyphenated word to phrase+concat OR", "a-h", `("a h" OR ah*)`), - Entry("converts hyphenated name to phrase+concat OR", "Jay-Z", `("Jay Z" OR JayZ*)`), - Entry("converts contraction to phrase+concat OR", "it's", `("it s" OR its*)`), - Entry("handles punctuated word mixed with plain words", "best of a-ha", `(best OR best*) AND (of OR of*) AND ("a ha" OR aha*)`), - Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND (got OR got*)`), - Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "(rock OR rock*) AND (roll OR roll*) AND (vol OR vol*) AND (2 OR 2*)"), - Entry("transliterates NFKD-decomposable diacritics", "Björk début", "(Bjork OR Bjork*) AND (debut OR debut*)"), - Entry("transliterates ø to o", "Øystein", "(Oystein OR Oystein*)"), - Entry("transliterates œ ligature to oe", "œuvre", "(oeuvre OR oeuvre*)"), - Entry("transliterates æ ligature to ae", "Brennæ", "(Brennae OR Brennae*)"), - Entry("transliterates mixed unicode words", "Mø Sigur Rós", "(Mo OR Mo*) AND (Sigur OR Sigur*) AND (Ros OR Ros*)"), - Entry("transliterates ß to ss", "Straße", "(Strasse OR 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 OR best*) AND (of OR of*) AND "R E M"`), - Entry("collapses two-letter abbreviation", "U.K.", `"U K"`), - Entry("does not collapse single letter surrounded by words", "I am fine", "(I OR I*) AND (am OR am*) AND (fine OR fine*)"), - Entry("does not collapse single standalone letter", "A test", "(A OR A*) AND (test OR test*)"), - Entry("preserves quoted phrase with punctuation verbatim", `"ac/dc"`, `"ac/dc"`), - Entry("preserves quoted abbreviation verbatim", `"R.E.M."`, `"R.E.M."`), - Entry("returns empty string for punctuation-only input", "!!!!!!!", ""), - Entry("returns empty string for mixed punctuation", "!@#$%^&", ""), - Entry("returns empty string for empty quoted phrase", `""`, ""), -) - -var _ = DescribeTable("buildFTS5Query degraded flag", - func(input string, expected bool) { - _, degraded := buildFTS5Query(input) - Expect(degraded).To(Equal(expected)) - }, - Entry("plain words are not degraded", "beatles", false), - Entry("special chars stripped leaving short token is degraded", "1+", true), - Entry("multiple short tokens are degraded", "1+ 2+", true), - Entry("short tokens mixed with a long word are not degraded", "1+ beatles", false), - Entry("quoted short-token phrase is degraded", `"1+"`, true), - Entry("punctuated-name group is not degraded", "AC/DC", false), - Entry("empty input is not degraded", "", false), -) - -var _ = DescribeTable("ftsQueryDegraded", - func(original, ftsQuery string, expected bool) { - Expect(ftsQueryDegraded(original, ftsQuery)).To(Equal(expected)) - }, - Entry("not degraded for empty original", "", "1*", false), - Entry("not degraded for empty ftsQuery", "1+", "", false), - Entry("not degraded for purely alphanumeric query", "beatles", "beatles*", false), - Entry("not degraded when long tokens remain", "test^val", "test* val*", false), - Entry("not degraded for quoted phrase with long tokens", `"the beatles"`, `"the beatles"`, false), - Entry("degraded for quoted phrase with only short tokens after tokenizer strips special chars", `"1+"`, `"1+"`, true), - Entry("not degraded for quoted phrase with meaningful content", `"C++ programming"`, `"C++ programming"`, false), - Entry("degraded when special chars stripped leaving short token", "1+", "1*", true), - Entry("degraded when special chars stripped leaving two short tokens", "C# 1", "C* 1*", true), - Entry("not degraded when at least one long token remains", "1+ beatles", "1* beatles*", false), - Entry("not degraded for OR groups from processPunctuatedWords", "AC/DC", `("AC DC" OR ACDC*)`, false), -) - -var _ = DescribeTable("containsCJK", - func(input string, expected bool) { - Expect(containsCJK(input)).To(Equal(expected)) - }, - Entry("returns false for empty string", "", false), - Entry("returns false for ASCII text", "hello world", false), - Entry("returns false for Latin with diacritics", "Björk début", false), - Entry("detects Chinese characters (Han)", "周杰伦", true), - Entry("detects Japanese Hiragana", "こんにちは", true), - Entry("detects Japanese Katakana", "カタカナ", true), - Entry("detects Korean Hangul", "한국어", true), - Entry("detects CJK mixed with Latin", "best of 周杰伦", true), - Entry("detects single CJK character", "a曲b", true), -) - -var _ = DescribeTable("qualifyOrderBy", - func(tableName, orderBy, expected string) { - Expect(qualifyOrderBy(tableName, orderBy)).To(Equal(expected)) - }, - Entry("returns empty string for empty input", "artist", "", ""), - Entry("qualifies simple column with table name", "artist", "name", "artist.name"), - Entry("qualifies column with direction", "artist", "name desc", "artist.name desc"), - Entry("preserves already-qualified column", "artist", "artist.name", "artist.name"), - Entry("preserves already-qualified column with direction", "artist", "artist.name desc", "artist.name desc"), - Entry("returns empty for function call expression", "artist", "sum(json_extract(stats, '$.total.m')) desc", ""), - Entry("returns empty for expression with comma", "artist", "a, b", ""), - Entry("qualifies media_file column", "media_file", "title", "media_file.title"), -) - -var _ = Describe("ftsColumnDefs helpers", func() { - Describe("ftsColumnFilters", func() { - It("returns column filter for media_file", func() { - Expect(ftsColumnFilters).To(HaveKeyWithValue("media_file", - "{title album artist album_artist sort_title sort_album_name sort_artist_name sort_album_artist_name disc_subtitle search_participants search_normalized}", - )) - }) - - It("returns column filter for album", func() { - Expect(ftsColumnFilters).To(HaveKeyWithValue("album", - "{name sort_album_name album_artist search_participants discs catalog_num album_version search_normalized}", - )) - }) - - It("returns column filter for artist", func() { - Expect(ftsColumnFilters).To(HaveKeyWithValue("artist", - "{name sort_artist_name search_normalized}", - )) - }) - - It("has no entry for unknown table", func() { - Expect(ftsColumnFilters).ToNot(HaveKey("unknown")) - }) - }) - - Describe("ftsBM25Weights", func() { - It("returns weight CSV for media_file", func() { - Expect(ftsBM25Weights).To(HaveKeyWithValue("media_file", - "10.0, 5.0, 3.0, 3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 1.0", - )) - }) - - It("returns weight CSV for album", func() { - Expect(ftsBM25Weights).To(HaveKeyWithValue("album", - "10.0, 1.0, 3.0, 2.0, 1.0, 1.0, 1.0, 1.0", - )) - }) - - It("returns weight CSV for artist", func() { - Expect(ftsBM25Weights).To(HaveKeyWithValue("artist", - "10.0, 1.0, 10.0", - )) - }) - - It("has no entry for unknown table", func() { - Expect(ftsBM25Weights).ToNot(HaveKey("unknown")) - }) - }) - - It("has definitions for all known tables", func() { - for _, table := range []string{"media_file", "album", "artist"} { - Expect(ftsColumnDefs).To(HaveKey(table)) - Expect(ftsColumnDefs[table]).ToNot(BeEmpty()) - } - }) - - It("has matching column count between filter and weights", func() { - for table, cols := range ftsColumnDefs { - // Column filter only includes Weight > 0 columns - filterCount := 0 - for _, c := range cols { - if c.Weight > 0 { - filterCount++ - } - } - // For now, all columns have Weight > 0, so filter count == total count - Expect(filterCount).To(Equal(len(cols)), "table %s: all columns should have positive weights", table) - } - }) -}) - -var _ = Describe("newFTSSearch", func() { - It("returns nil for empty query", func() { - Expect(newFTSSearch("media_file", "")).To(BeNil()) - }) - - It("returns non-nil for single-character query", func() { - strategy := newFTSSearch("media_file", "a") - Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here") - sql, _, err := strategy.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("MATCH")) - }) - - It("returns ftsSearch with correct table names and MATCH expression", func() { - strategy := newFTSSearch("media_file", "beatles") - fts, ok := strategy.(*ftsSearch) - Expect(ok).To(BeTrue()) - Expect(fts.tableName).To(Equal("media_file")) - Expect(fts.ftsTable).To(Equal("media_file_fts")) - Expect(fts.matchExpr).To(HavePrefix("{title album artist album_artist")) - Expect(fts.matchExpr).To(ContainSubstring("beatles*")) - }) - - It("ToSql generates rowid IN subquery with MATCH (fallback path)", func() { - strategy := newFTSSearch("media_file", "beatles") - sql, args, err := strategy.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("media_file.rowid IN")) - Expect(sql).To(ContainSubstring("media_file_fts")) - Expect(sql).To(ContainSubstring("MATCH")) - Expect(args).To(HaveLen(1)) - }) - - It("generates correct FTS table name per entity", func() { - for _, table := range []string{"media_file", "album", "artist"} { - strategy := newFTSSearch(table, "test") - fts, ok := strategy.(*ftsSearch) - Expect(ok).To(BeTrue()) - Expect(fts.tableName).To(Equal(table)) - Expect(fts.ftsTable).To(Equal(table + "_fts")) - } - }) - - It("builds bm25() rank expression with column weights", func() { - strategy := newFTSSearch("media_file", "beatles") - fts, ok := strategy.(*ftsSearch) - Expect(ok).To(BeTrue()) - Expect(fts.rankExpr).To(HavePrefix("bm25(media_file_fts,")) - Expect(fts.rankExpr).To(ContainSubstring("10.0")) - - strategy = newFTSSearch("artist", "beatles") - fts, ok = strategy.(*ftsSearch) - Expect(ok).To(BeTrue()) - Expect(fts.rankExpr).To(HavePrefix("bm25(artist_fts,")) - }) - - It("falls back to ftsTable.rank for unknown tables", func() { - strategy := newFTSSearch("unknown_table", "test") - fts, ok := strategy.(*ftsSearch) - Expect(ok).To(BeTrue()) - Expect(fts.rankExpr).To(Equal("unknown_table_fts.rank")) - }) - - It("wraps query with column filter", func() { - strategy := newFTSSearch("artist", "Beatles") - fts, ok := strategy.(*ftsSearch) - Expect(ok).To(BeTrue()) - Expect(fts.matchExpr).To(Equal("{name sort_artist_name search_normalized} : ((Beatles OR Beatles*))")) - }) - - It("passes query without column filter for unknown tables", func() { - strategy := newFTSSearch("unknown_table", "test") - fts, ok := strategy.(*ftsSearch) - Expect(ok).To(BeTrue()) - Expect(fts.matchExpr).To(Equal("(test OR test*)")) - }) - - It("preserves phrase queries inside column filter", func() { - strategy := newFTSSearch("media_file", `"the beatles"`) - fts, ok := strategy.(*ftsSearch) - Expect(ok).To(BeTrue()) - Expect(fts.matchExpr).To(ContainSubstring(`"the beatles"`)) - }) - - It("preserves prefix queries inside column filter", func() { - strategy := newFTSSearch("media_file", "beat*") - fts, ok := strategy.(*ftsSearch) - Expect(ok).To(BeTrue()) - Expect(fts.matchExpr).To(ContainSubstring("beat*")) - }) - - It("falls back to LIKE search for punctuation-only query", func() { - strategy := newFTSSearch("media_file", "!!!!!!!") - Expect(strategy).ToNot(BeNil()) - _, ok := strategy.(*ftsSearch) - Expect(ok).To(BeFalse(), "punctuation-only should fall back to LIKE, not FTS") - sql, args, err := strategy.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("LIKE")) - Expect(args).To(ContainElement("%!!!!!!!%")) - }) - - It("falls back to LIKE search for degraded query (special chars stripped leaving short tokens)", func() { - strategy := newFTSSearch("album", "1+") - Expect(strategy).ToNot(BeNil()) - _, ok := strategy.(*ftsSearch) - Expect(ok).To(BeFalse(), "degraded query should fall back to LIKE, not FTS") - sql, args, err := strategy.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("LIKE")) - Expect(args).To(ContainElement("%1+%")) - }) - - It("returns nil for empty string even with LIKE fallback", func() { - Expect(newFTSSearch("media_file", "")).To(BeNil()) - Expect(newFTSSearch("media_file", " ")).To(BeNil()) - }) - - It("returns nil for empty quoted phrase", func() { - Expect(newFTSSearch("media_file", `""`)).To(BeNil()) - }) -}) - -var _ = Describe("FTS5 Integration Search", func() { - var ( - mr model.MediaFileRepository - alr model.AlbumRepository - arr model.ArtistRepository - ctx context.Context - ) - - BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - conn := GetDBXBuilder() - mr = NewMediaFileRepository(conn) - alr = NewAlbumRepository(conn) - arr = NewArtistRepository(conn) - }) - - Describe("MediaFile search", func() { - It("finds media files by title", func() { - results, err := mr.Search(ctx, "Radioactivity", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].Title).To(Equal("Radioactivity")) - Expect(results[0].ID).To(Equal(songRadioactivity.ID)) - }) - - It("finds media files by artist name", func() { - results, err := mr.Search(ctx, "Beatles", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(3)) - for _, r := range results { - Expect(r.Artist).To(Equal("The Beatles")) - } - }) - }) - - Describe("Album search", func() { - It("finds albums by name", func() { - results, err := alr.Search(ctx, "Sgt Peppers", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].Name).To(Equal("Sgt Peppers")) - Expect(results[0].ID).To(Equal(albumSgtPeppers.ID)) - }) - - It("finds albums with multi-word search", func() { - results, err := alr.Search(ctx, "Abbey Road", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(2)) - }) - }) - - Describe("Artist search", func() { - It("finds artists by name", func() { - results, err := arr.Search(ctx, "Kraftwerk", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].Name).To(Equal("Kraftwerk")) - Expect(results[0].ID).To(Equal(artistKraftwerk.ID)) - }) - }) - - Describe("CJK search", func() { - It("finds media files by CJK title", func() { - results, err := mr.Search(ctx, "プラチナ", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].Title).To(Equal("プラチナ・ジェット")) - Expect(results[0].ID).To(Equal(songCJK.ID)) - }) - - It("finds media files by CJK artist name", func() { - results, err := mr.Search(ctx, "シートベルツ", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].Artist).To(Equal("シートベルツ")) - }) - - It("finds albums by CJK artist name", func() { - results, err := alr.Search(ctx, "シートベルツ", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].Name).To(Equal("COWBOY BEBOP")) - Expect(results[0].ID).To(Equal(albumCJK.ID)) - }) - - It("finds artists by CJK name", func() { - results, err := arr.Search(ctx, "シートベルツ", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].Name).To(Equal("シートベルツ")) - Expect(results[0].ID).To(Equal(artistCJK.ID)) - }) - }) - - Describe("Album version search", func() { - It("finds albums by version tag via FTS", func() { - results, err := alr.Search(ctx, "Deluxe", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal(albumWithVersion.ID)) - }) - }) - - Describe("Punctuation-only search", func() { - It("finds media files with punctuation-only title", func() { - results, err := mr.Search(ctx, "!!!!!!!", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].Title).To(Equal("!!!!!!!")) - Expect(results[0].ID).To(Equal(songPunctuation.ID)) - }) - }) - - Describe("Single-character search (doSearch min-length guard)", func() { - It("returns empty results for single-char query via Search", func() { - results, err := mr.Search(ctx, "a", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty(), "doSearch should reject single-char queries") - }) - }) - - Describe("Max=0 means no limit (regression: must not produce LIMIT 0)", func() { - It("returns results with Max=0", func() { - results, err := mr.Search(ctx, "Beatles", model.QueryOptions{Max: 0}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0") - }) - }) - - Describe("Exact-match ranking", func() { - BeforeEach(func() { - // Registered before the inserts so a mid-loop failure cannot leak corpus rows. - DeferCleanup(func() { - // library_artist rows are removed by the artist_id ON DELETE CASCADE FK. - _, err := GetDBXBuilder().NewQuery("DELETE FROM artist WHERE id LIKE 'fts-rank-%'").Execute() - Expect(err).ToNot(HaveOccurred()) - }) - // Corpus has no competing exact-word names ("Mo X"): exact-vs-exact order depends - // on corpus statistics; the guaranteed property is exact > prefix. - for _, a := range []model.Artist{ - {ID: "fts-rank-1", Name: "MØ", OrderArtistName: "mø"}, - {ID: "fts-rank-2", Name: "Modest Mouse", OrderArtistName: "modest mouse"}, - {ID: "fts-rank-3", Name: "Morrissey", OrderArtistName: "morrissey"}, - } { - Expect(createArtistWithLibrary(ctx, arr, &a, 1)).To(Succeed()) - } - }) - - It("ranks the exact transliterated match first for 'MO'", func() { - results, err := arr.Search(ctx, "MO", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(3)) - Expect(results[0].Name).To(Equal("MØ"), "exact match via search_normalized must outrank prefix matches") - }) - - It("ranks the exact match first for the accented query 'MØ'", func() { - results, err := arr.Search(ctx, "MØ", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).ToNot(BeEmpty()) - Expect(results[0].Name).To(Equal("MØ")) - }) - }) -}) diff --git a/persistence/sql_search_like.go b/persistence/sql_search_like.go deleted file mode 100644 index c7d94cc04..000000000 --- a/persistence/sql_search_like.go +++ /dev/null @@ -1,107 +0,0 @@ -package persistence - -import ( - "context" - "strings" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/str" -) - -// likeSearch implements searchStrategy using LIKE-based SQL filters. -// Used for legacy full_text searches, CJK fallback, and punctuation-only fallback. -type likeSearch struct { - filter Sqlizer -} - -func (s *likeSearch) ToSql() (string, []any, error) { - return s.filter.ToSql() -} - -func (s *likeSearch) execute(ctx context.Context, r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error { - sq = sq.Where(s.filter) - sq = sq.OrderBy(cfg.OrderBy...) - return r.queryAll(ctx, sq, dest, options) -} - -// newLegacySearch creates a LIKE search against the full_text column. -// Returns nil when the query produces no searchable tokens. -func newLegacySearch(tableName, query string) searchStrategy { - filter := legacySearchExpr(tableName, query) - if filter == nil { - return nil - } - return &likeSearch{filter: filter} -} - -// newLikeSearch creates a LIKE search against core entity columns (CJK, punctuation fallback). -// No minimum length is enforced, since single CJK characters are meaningful words. -// Returns nil when the query produces no searchable tokens. -func newLikeSearch(tableName, query string) searchStrategy { - filter := likeSearchExpr(tableName, query) - if filter == nil { - return nil - } - return &likeSearch{filter: filter} -} - -// legacySearchExpr generates LIKE-based search filters against the full_text column. -// This is the original search implementation, used when Search.Backend="legacy". -func legacySearchExpr(tableName string, s string) Sqlizer { - q := str.SanitizeStrings(s) - if q == "" { - log.Trace("Search using legacy backend, query is empty", "table", tableName) - return nil - } - var sep string - if !conf.Server.Search.FullString { - sep = " " - } - parts := strings.Split(q, " ") - filters := And{} - for _, part := range parts { - filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"}) - } - log.Trace("Search using legacy backend", "query", filters, "table", tableName) - return filters -} - -// likeSearchColumns defines the core columns to search with LIKE queries. -// These are the primary user-visible fields for each entity type. -// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input). -var likeSearchColumns = map[string][]string{ - "media_file": {"title", "album", "artist", "album_artist"}, - "album": {"name", "album_artist"}, - "artist": {"name"}, -} - -// likeSearchExpr generates LIKE-based search filters against core columns. -// Each word in the query must match at least one column (AND between words), -// and each word can match any column (OR within a word). -// Used as a fallback when FTS5 cannot handle the query (e.g., CJK text, punctuation-only input). -func likeSearchExpr(tableName string, s string) Sqlizer { - s = strings.TrimSpace(s) - if s == "" { - log.Trace("Search using LIKE backend, query is empty", "table", tableName) - return nil - } - columns, ok := likeSearchColumns[tableName] - if !ok { - log.Trace("Search using LIKE backend, couldn't find columns for this table", "table", tableName) - return nil - } - words := strings.Fields(s) - wordFilters := And{} - for _, word := range words { - colFilters := Or{} - for _, col := range columns { - colFilters = append(colFilters, Like{tableName + "." + col: "%" + word + "%"}) - } - wordFilters = append(wordFilters, colFilters) - } - log.Trace("Search using LIKE backend", "query", wordFilters, "table", tableName) - return wordFilters -} diff --git a/persistence/sql_search_like_test.go b/persistence/sql_search_like_test.go deleted file mode 100644 index 2bfeba29b..000000000 --- a/persistence/sql_search_like_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package persistence - -import ( - "context" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("newLegacySearch", func() { - It("returns non-nil for single-character query", func() { - strategy := newLegacySearch("media_file", "a") - Expect(strategy).ToNot(BeNil(), "single-char queries must not be rejected; min-length is enforced in doSearch, not here") - sql, _, err := strategy.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("LIKE")) - }) -}) - -var _ = Describe("legacySearchExpr", func() { - It("returns nil for empty query", func() { - Expect(legacySearchExpr("media_file", "")).To(BeNil()) - }) - - It("generates LIKE filter for single word", func() { - expr := legacySearchExpr("media_file", "beatles") - sql, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("media_file.full_text LIKE")) - Expect(args).To(ContainElement("% beatles%")) - }) - - It("generates AND of LIKE filters for multiple words", func() { - expr := legacySearchExpr("media_file", "abbey road") - sql, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("AND")) - Expect(args).To(HaveLen(2)) - }) -}) - -var _ = Describe("likeSearchExpr", func() { - It("returns nil for empty query", func() { - Expect(likeSearchExpr("media_file", "")).To(BeNil()) - }) - - It("returns nil for whitespace-only query", func() { - Expect(likeSearchExpr("media_file", " ")).To(BeNil()) - }) - - It("generates LIKE filters against core columns for single CJK word", func() { - expr := likeSearchExpr("media_file", "周杰伦") - sql, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - // Should have OR between columns for the single word - Expect(sql).To(ContainSubstring("OR")) - Expect(sql).To(ContainSubstring("media_file.title LIKE")) - Expect(sql).To(ContainSubstring("media_file.album LIKE")) - Expect(sql).To(ContainSubstring("media_file.artist LIKE")) - Expect(sql).To(ContainSubstring("media_file.album_artist LIKE")) - Expect(args).To(HaveLen(4)) - for _, arg := range args { - Expect(arg).To(Equal("%周杰伦%")) - } - }) - - It("generates AND of OR groups for multi-word query", func() { - expr := likeSearchExpr("media_file", "周杰伦 greatest") - sql, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - // Two groups AND'd together, each with 4 columns OR'd - Expect(sql).To(ContainSubstring("AND")) - Expect(args).To(HaveLen(8)) - }) - - It("uses correct columns for album table", func() { - expr := likeSearchExpr("album", "周杰伦") - sql, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("album.name LIKE")) - Expect(sql).To(ContainSubstring("album.album_artist LIKE")) - Expect(args).To(HaveLen(2)) - }) - - It("uses correct columns for artist table", func() { - expr := likeSearchExpr("artist", "周杰伦") - sql, args, err := expr.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("artist.name LIKE")) - Expect(args).To(HaveLen(1)) - }) - - It("returns nil for unknown table", func() { - Expect(likeSearchExpr("unknown_table", "周杰伦")).To(BeNil()) - }) -}) - -var _ = Describe("Legacy Integration Search", func() { - var mr model.MediaFileRepository - var ctx context.Context - - BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - DeferCleanup(configtest.SetupConfig()) - conf.Server.Search.Backend = "legacy" - - conn := GetDBXBuilder() - mr = NewMediaFileRepository(conn) - }) - - It("returns results using legacy LIKE-based search", func() { - results, err := mr.Search(ctx, "Radioactivity", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].Title).To(Equal("Radioactivity")) - }) - - It("returns empty results for single-char query (doSearch min-length guard)", func() { - results, err := mr.Search(ctx, "a", model.QueryOptions{Max: 10}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty(), "doSearch should reject single-char queries") - }) - - It("returns results with Max=0 (regression: must not produce LIMIT 0)", func() { - results, err := mr.Search(ctx, "Beatles", model.QueryOptions{Max: 0}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).ToNot(BeEmpty(), "Max=0 should mean no limit, not LIMIT 0") - }) -}) diff --git a/persistence/sql_search_test.go b/persistence/sql_search_test.go index 68ee205b4..b96c06f21 100644 --- a/persistence/sql_search_test.go +++ b/persistence/sql_search_test.go @@ -1,112 +1,14 @@ package persistence import ( - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("sqlRepository", func() { - Describe("formatFullText", func() { + Describe("getFullText", func() { It("prefixes with a space", func() { - Expect(formatFullText("legiao urbana")).To(Equal(" legiao urbana")) - }) - }) - - Describe("getSearchStrategy", func() { - It("returns FTS strategy by default", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Search.Backend = "fts" - conf.Server.Search.FullString = false - - strategy := getSearchStrategy("media_file", "test") - Expect(strategy).ToNot(BeNil()) - sql, _, err := strategy.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("MATCH")) - }) - - It("returns legacy LIKE strategy when SearchBackend is legacy", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Search.Backend = "legacy" - conf.Server.Search.FullString = false - - strategy := getSearchStrategy("media_file", "test") - Expect(strategy).ToNot(BeNil()) - sql, _, err := strategy.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("LIKE")) - }) - - It("falls back to legacy LIKE strategy when SearchFullString is enabled", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Search.Backend = "fts" - conf.Server.Search.FullString = true - - strategy := getSearchStrategy("media_file", "test") - Expect(strategy).ToNot(BeNil()) - sql, _, err := strategy.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("LIKE")) - }) - - It("routes CJK queries to LIKE strategy instead of FTS", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Search.Backend = "fts" - conf.Server.Search.FullString = false - - strategy := getSearchStrategy("media_file", "周杰伦") - Expect(strategy).ToNot(BeNil()) - sql, _, err := strategy.ToSql() - Expect(err).ToNot(HaveOccurred()) - // CJK should use LIKE, not MATCH - Expect(sql).To(ContainSubstring("LIKE")) - Expect(sql).NotTo(ContainSubstring("MATCH")) - }) - - It("routes non-CJK queries to FTS strategy", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Search.Backend = "fts" - conf.Server.Search.FullString = false - - strategy := getSearchStrategy("media_file", "beatles") - Expect(strategy).ToNot(BeNil()) - sql, _, err := strategy.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("MATCH")) - }) - - It("returns non-nil for single-character query (no min-length in strategy)", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Search.Backend = "fts" - conf.Server.Search.FullString = false - - strategy := getSearchStrategy("media_file", "a") - Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by strategies (min-length is enforced in doSearch)") - }) - - It("returns non-nil for single-character query with legacy backend", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Search.Backend = "legacy" - conf.Server.Search.FullString = false - - strategy := getSearchStrategy("media_file", "a") - Expect(strategy).ToNot(BeNil(), "single-char queries must be accepted by legacy strategy (min-length is enforced in doSearch)") - }) - - It("uses legacy for CJK when SearchBackend is legacy", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Search.Backend = "legacy" - conf.Server.Search.FullString = false - - strategy := getSearchStrategy("media_file", "周杰伦") - Expect(strategy).ToNot(BeNil()) - sql, _, err := strategy.ToSql() - Expect(err).ToNot(HaveOccurred()) - // Legacy should still use full_text column LIKE - Expect(sql).To(ContainSubstring("LIKE")) - Expect(sql).To(ContainSubstring("full_text")) + Expect(getFullText("legiao urbana")).To(Equal(" legiao urbana")) }) }) }) diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go deleted file mode 100644 index 7e1978453..000000000 --- a/persistence/sql_tags.go +++ /dev/null @@ -1,231 +0,0 @@ -package persistence - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - . "github.com/Masterminds/squirrel" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/model" - "github.com/pocketbase/dbx" -) - -// Format of a tag in the DB -type dbTag struct { - ID string `json:"id"` - Value string `json:"value"` -} -type dbTags map[model.TagName][]dbTag - -func unmarshalTags(data string) (model.Tags, error) { - var dbTags dbTags - err := json.Unmarshal([]byte(data), &dbTags) - if err != nil { - return nil, fmt.Errorf("parsing tags: %w", err) - } - - res := make(model.Tags, len(dbTags)) - for name, tags := range dbTags { - res[name] = make([]string, len(tags)) - for i, tag := range tags { - res[name][i] = tag.Value - } - } - return res, nil -} - -func marshalTags(tags model.Tags) string { - dbTags := dbTags{} - for name, values := range tags { - for _, value := range values { - t := model.NewTag(name, value) - dbTags[name] = append(dbTags[name], dbTag{ID: t.ID, Value: value}) - } - } - res, _ := json.Marshal(dbTags) - return string(res) -} - -// indexedTagNames are the tag types materialized into the
_tags join tables, so filtering by -// them is an index-backed semi-join instead of a per-row json_tree(tags) scan. Genre only for now. -var indexedTagNames = []model.TagName{model.TagGenre} - -// updateTags rewrites this item's
_tags rows from its in-memory tags, mirroring -// updateParticipants (delete-then-insert in the same Put; JOIN to tag skips not-yet-saved ids). -func (r sqlRepository) updateTags(ctx context.Context, itemID string, tags model.Tags) error { - del := Delete(r.tableName + "_tags").Where(Eq{r.tableName + "_id": itemID}) - if _, err := r.executeSQL(ctx, del); err != nil { - return err - } - var tagIDs []string - for _, name := range indexedTagNames { - for _, value := range tags.Values(name) { - tagIDs = append(tagIDs, model.NewTag(name, value).ID) - } - } - if len(tagIDs) == 0 { - return nil - } - idsJSON, err := json.Marshal(tagIDs) - if err != nil { - return fmt.Errorf("marshaling tag ids: %w", err) - } - query := fmt.Sprintf(` - INSERT INTO %[1]s_tags (%[1]s_id, tag_id) - SELECT ?, value FROM json_each(?) - JOIN tag ON tag.id = value - ON CONFLICT (%[1]s_id, tag_id) DO NOTHING`, r.tableName) - _, err = r.executeSQL(ctx, Expr(query, itemID, string(idsJSON))) - return err -} - -// genreFilterDef builds indexed genre filters for one item type. Callers use the exported SongGenres / AlbumGenres instances. -type genreFilterDef struct{ idCol, table, joinCol string } - -var ( - SongGenres = genreFilterDef{"media_file.id", "media_file_tags", "media_file_id"} - AlbumGenres = genreFilterDef{"album.id", "album_tags", "album_id"} -) - -// ByID matches items tagged with any of the given genre tag ids (scalar or slice). -func (g genreFilterDef) ByID(tagIDs any) Sqlizer { - sub, args, _ := Select(g.joinCol).From(g.table).Where(Eq{"tag_id": tagIDs}).ToSql() - return Expr(g.idCol+" IN ("+sub+")", args...) -} - -// ByName matches by genre name (Subsonic passes a name, not an id), resolved through the tag -// dictionary, which is uniquely indexed on (tag_name, tag_value). -func (g genreFilterDef) ByName(genre string) Sqlizer { - sub, args, _ := Select("jt." + g.joinCol).From(g.table + " jt"). - Join("tag on tag.id = jt.tag_id"). - Where(And{Eq{"tag.tag_name": "genre"}, Like{"tag.tag_value": genre}}).ToSql() - return Expr(g.idCol+" IN ("+sub+")", args...) -} - -// AlbumArtistsByGenreID matches album artists of albums tagged with any of the genre ids. It's a -// two-table join (album_artists ⨝ album_tags), so it doesn't fit the single-table genreFilterDef. -func AlbumArtistsByGenreID(tagIDs []string) Sqlizer { - sub, args, _ := Select("aa.artist_id").From("album_artists aa"). - Join("album_tags at on at.album_id = aa.album_id"). - Where(And{Eq{"aa.role": "albumartist"}, Eq{"at.tag_id": tagIDs}}).ToSql() - return Expr("artist.id IN ("+sub+")", args...) -} - -func genreFilter(filter genreFilterDef) func(_ string, v any) Sqlizer { - return func(_ string, v any) Sqlizer { - return filter.ByID(v) - } -} - -// tagIDFilter matches rows whose tags JSON contains the tag id(s); a "_id" key maps to "$.". -func tagIDFilter(name string, idValue any) Sqlizer { - name = strings.TrimSuffix(name, "_id") - return Exists( - fmt.Sprintf(`json_tree(tags, "$.%s")`, name), - And{ - NotEq{"json_tree.atom": nil}, - Eq{"value": idValue}, - }, - ) -} - -// tagLibraryIdFilter filters tags based on library access through the library_tag table -func tagLibraryIdFilter(_ string, value any) Sqlizer { - return Eq{"library_tag.library_id": value} -} - -// baseTagRepository provides common functionality for all tag-based repositories. -// It handles CRUD operations with optional filtering by tag name. -type baseTagRepository struct { - sqlRepository - tagFilter *model.TagName // nil = no filter (all tags), non-nil = filter by specific tag name -} - -// newBaseTagRepository creates a new base tag repository with optional tag filtering. -// If tagFilter is nil, the repository will work with all tags. -// If tagFilter is provided, the repository will only work with tags of that specific name. -func newBaseTagRepository(db dbx.Builder, tagFilter *model.TagName) *baseTagRepository { - r := &baseTagRepository{ - tagFilter: tagFilter, - } - r.db = db - r.tableName = "tag" - r.registerModel(&model.Tag{}, map[string]filterFunc{ - "name": containsFilter("tag_value"), - "library_id": tagLibraryIdFilter, - }) - r.setSortMappings(map[string]string{ - "name": "tag_value", - }) - return r -} - -// applyLibraryFiltering adds the appropriate library joins based on user context -func (r *baseTagRepository) applyLibraryFiltering(ctx context.Context, sq SelectBuilder) SelectBuilder { - // Add library_tag join - sq = sq.LeftJoin("library_tag on library_tag.tag_id = tag.id") - - // For authenticated users, also join with user_library to filter by accessible libraries - user := loggedUser(ctx) - if user.ID != invalidUserId { - sq = sq.Join("user_library on user_library.library_id = library_tag.library_id AND user_library.user_id = ?", user.ID) - } - - return sq -} - -// newSelect overrides the base implementation to apply tag name filtering and library filtering. -func (r *baseTagRepository) newSelect(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - sq := r.sqlRepository.newSelect(ctx, options...) - - // Apply tag name filtering if specified - if r.tagFilter != nil { - sq = sq.Where(Eq{"tag.tag_name": *r.tagFilter}) - } - - // Apply library filtering and set up aggregation columns - sq = r.applyLibraryFiltering(ctx, sq).Columns( - "tag.id", - "tag.tag_name", - "tag.tag_value", - "COALESCE(SUM(library_tag.album_count), 0) as album_count", - "COALESCE(SUM(library_tag.media_file_count), 0) as song_count", - ).GroupBy("tag.id", "tag.tag_name", "tag.tag_value") - - return sq -} - -// REST interface methods - -func (r *baseTagRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - sq := Select("COUNT(DISTINCT tag.id)").From("tag") - - // Apply tag name filtering if specified - if r.tagFilter != nil { - sq = sq.Where(Eq{"tag.tag_name": *r.tagFilter}) - } - - // Apply library filtering - sq = r.applyLibraryFiltering(ctx, sq) - - return r.count(ctx, sq, r.parseRestOptions(ctx, options...)) -} - -func (r *baseTagRepository) Read(ctx context.Context, id string) (*model.Tag, error) { - query := r.newSelect(ctx).Where(Eq{"id": id}) - var res model.Tag - err := r.queryOne(ctx, query, &res) - return &res, err -} - -func (r *baseTagRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Tag, error) { - query := r.newSelect(ctx, r.parseRestOptions(ctx, options...)) - var res model.TagList - err := r.queryAll(ctx, query, &res) - return res, err -} - -// Interface compliance check -var _ rest.Repository[model.Tag] = (*baseTagRepository)(nil) diff --git a/persistence/tag_library_filtering_test.go b/persistence/tag_library_filtering_test.go deleted file mode 100644 index a4382dccf..000000000 --- a/persistence/tag_library_filtering_test.go +++ /dev/null @@ -1,261 +0,0 @@ -package persistence - -import ( - "context" - "time" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" -) - -const ( - adminUserID = "userid" - regularUserID = "2222" - libraryID1 = 1 - libraryID2 = 2 - libraryID3 = 3 - - tagNameGenre = "genre" - tagValueRock = "rock" - tagValuePop = "pop" - tagValueJazz = "jazz" -) - -var _ = Describe("Tag Library Filtering", func() { - var ( - tagRockID = id.NewTagID(tagNameGenre, tagValueRock) - tagPopID = id.NewTagID(tagNameGenre, tagValuePop) - tagJazzID = id.NewTagID(tagNameGenre, tagValueJazz) - ) - - expectTagValues := func(tagList model.TagList, expected []string) { - tagValues := make([]string, len(tagList)) - for i, tag := range tagList { - tagValues[i] = tag.TagValue - } - Expect(tagValues).To(ContainElements(expected)) - } - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - - // Generate unique path suffix to avoid conflicts with other tests - uniqueSuffix := time.Now().Format("20060102150405.000") - - // Clean up database - db := GetDBXBuilder() - _, err := db.NewQuery("DELETE FROM library_tag").Execute() - Expect(err).ToNot(HaveOccurred()) - _, err = db.NewQuery("DELETE FROM tag").Execute() - Expect(err).ToNot(HaveOccurred()) - _, err = db.NewQuery("DELETE FROM user_library WHERE user_id != {:admin} AND user_id != {:regular}"). - Bind(dbx.Params{"admin": adminUserID, "regular": regularUserID}).Execute() - Expect(err).ToNot(HaveOccurred()) - _, err = db.NewQuery("DELETE FROM library WHERE id > 1").Execute() - Expect(err).ToNot(HaveOccurred()) - - // Create test libraries with unique names and paths to avoid conflicts with other tests - _, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})"). - Bind(dbx.Params{"id": libraryID2, "name": "Library 2-" + uniqueSuffix, "path": "/music/lib2-" + uniqueSuffix}).Execute() - Expect(err).ToNot(HaveOccurred()) - _, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})"). - Bind(dbx.Params{"id": libraryID3, "name": "Library 3-" + uniqueSuffix, "path": "/music/lib3-" + uniqueSuffix}).Execute() - Expect(err).ToNot(HaveOccurred()) - - // Give admin access to all libraries - for _, libID := range []int{libraryID1, libraryID2, libraryID3} { - _, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ({:user}, {:lib})"). - Bind(dbx.Params{"user": adminUserID, "lib": libID}).Execute() - Expect(err).ToNot(HaveOccurred()) - } - - // Create test tags - adminCtx := request.WithUser(log.NewContext(context.TODO()), adminUser) - tagRepo := NewTagRepository(GetDBXBuilder()) - - createTag := func(libraryID int, name, value string) { - tag := model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} - err := tagRepo.Add(adminCtx, libraryID, tag) - Expect(err).ToNot(HaveOccurred()) - } - - createTag(libraryID1, tagNameGenre, tagValueRock) - createTag(libraryID2, tagNameGenre, tagValuePop) - createTag(libraryID3, tagNameGenre, tagValueJazz) - createTag(libraryID2, tagNameGenre, tagValueRock) // Rock appears in both lib1 and lib2 - - // Set tag counts (manually for testing) - setCounts := func(tagID string, libID, albums, songs int) { - _, err := db.NewQuery("UPDATE library_tag SET album_count = {:albums}, media_file_count = {:songs} WHERE tag_id = {:tag} AND library_id = {:lib}"). - Bind(dbx.Params{"albums": albums, "songs": songs, "tag": tagID, "lib": libID}).Execute() - Expect(err).ToNot(HaveOccurred()) - } - - setCounts(tagRockID, libraryID1, 5, 20) - setCounts(tagPopID, libraryID2, 3, 10) - setCounts(tagJazzID, libraryID3, 2, 8) - setCounts(tagRockID, libraryID2, 1, 4) - - // Give regular user access to library 2 only - _, err = db.NewQuery("INSERT INTO user_library (user_id, library_id) VALUES ({:user}, {:lib})"). - Bind(dbx.Params{"user": regularUserID, "lib": libraryID2}).Execute() - Expect(err).ToNot(HaveOccurred()) - }) - - Describe("TagRepository Library Filtering", func() { - // Helper to create repository and read all tags - readAllTags := func(user *model.User, filters ...rest.QueryOptions) model.TagList { - var ctx context.Context - if user != nil { - ctx = request.WithUser(log.NewContext(context.TODO()), *user) - } else { - ctx = context.Background() // Headless context - } - - repo := NewTagRepository(GetDBXBuilder()) - - var opts rest.QueryOptions - if len(filters) > 0 { - opts = filters[0] - } - - tags, err := repo.ReadAll(ctx, opts) - Expect(err).ToNot(HaveOccurred()) - return tags - } - - // Helper to count tags - countTags := func(user *model.User) int64 { - var ctx context.Context - if user != nil { - ctx = request.WithUser(log.NewContext(context.TODO()), *user) - } else { - ctx = context.Background() - } - - repo := NewTagRepository(GetDBXBuilder()) - - count, err := repo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - return count - } - - Context("Admin User", func() { - It("should see all tags regardless of library", func() { - tags := readAllTags(&adminUser) - Expect(tags).To(HaveLen(3)) - }) - }) - - Context("Regular User with Limited Library Access", func() { - It("should only see tags from accessible libraries", func() { - tags := readAllTags(®ularUser) - // Should see rock (libraries 1,2) and pop (library 2), but not jazz (library 3) - Expect(tags).To(HaveLen(2)) - }) - - It("should respect explicit library_id filters within accessible libraries", func() { - tags := readAllTags(®ularUser, rest.QueryOptions{ - Filters: map[string]any{"library_id": libraryID2}, - }) - // Should see only tags from library 2: pop and rock(lib2) - Expect(tags).To(HaveLen(2)) - expectTagValues(tags, []string{tagValuePop, tagValueRock}) - }) - - It("should not return tags when filtering by inaccessible library", func() { - tags := readAllTags(®ularUser, rest.QueryOptions{ - Filters: map[string]any{"library_id": libraryID3}, - }) - // Should return no tags since user can't access library 3 - Expect(tags).To(HaveLen(0)) - }) - - It("should filter by library 1 correctly", func() { - tags := readAllTags(®ularUser, rest.QueryOptions{ - Filters: map[string]any{"library_id": libraryID1}, - }) - // Should see only rock from library 1 - Expect(tags).To(HaveLen(1)) - Expect(tags[0].TagValue).To(Equal(tagValueRock)) - }) - }) - - Context("Headless Processes (No User Context)", func() { - It("should see all tags from all libraries when no user is in context", func() { - tags := readAllTags(nil) // nil = headless context - // Should see all tags from all libraries (no filtering applied) - Expect(tags).To(HaveLen(3)) - expectTagValues(tags, []string{tagValueRock, tagValuePop, tagValueJazz}) - }) - - It("should count all tags from all libraries when no user is in context", func() { - count := countTags(nil) - // Should count all tags from all libraries - Expect(count).To(Equal(int64(3))) - }) - - It("should calculate proper statistics from all libraries for headless processes", func() { - tags := readAllTags(nil) - - // Find the rock tag (appears in libraries 1 and 2) - var rockTag *model.Tag - for _, tag := range tags { - if tag.TagValue == tagValueRock { - rockTag = &tag - break - } - } - Expect(rockTag).ToNot(BeNil()) - - // Should have stats from all libraries where rock appears - // Library 1: 5 albums, 20 songs - // Library 2: 1 album, 4 songs - // Total: 6 albums, 24 songs - Expect(rockTag.AlbumCount).To(Equal(6)) - Expect(rockTag.SongCount).To(Equal(24)) - }) - - It("should allow headless processes to apply explicit library_id filters", func() { - tags := readAllTags(nil, rest.QueryOptions{ - Filters: map[string]any{"library_id": libraryID3}, - }) - // Should see only jazz from library 3 - Expect(tags).To(HaveLen(1)) - Expect(tags[0].TagValue).To(Equal(tagValueJazz)) - }) - }) - - Context("Admin User with Explicit Library Filtering", func() { - It("should see all tags when no filter is applied", func() { - tags := readAllTags(&adminUser) - Expect(tags).To(HaveLen(3)) - }) - - It("should respect explicit library_id filters", func() { - tags := readAllTags(&adminUser, rest.QueryOptions{ - Filters: map[string]any{"library_id": libraryID3}, - }) - // Should see only jazz from library 3 - Expect(tags).To(HaveLen(1)) - Expect(tags[0].TagValue).To(Equal(tagValueJazz)) - }) - - It("should filter by library 2 correctly", func() { - tags := readAllTags(&adminUser, rest.QueryOptions{ - Filters: map[string]any{"library_id": libraryID2}, - }) - // Should see pop and rock from library 2 - Expect(tags).To(HaveLen(2)) - expectTagValues(tags, []string{tagValuePop, tagValueRock}) - }) - }) - }) -}) diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go deleted file mode 100644 index d13dece66..000000000 --- a/persistence/tag_repository.go +++ /dev/null @@ -1,107 +0,0 @@ -package persistence - -import ( - "context" - "fmt" - "slices" - "time" - - . "github.com/Masterminds/squirrel" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/pocketbase/dbx" -) - -type tagRepository struct { - *baseTagRepository -} - -func NewTagRepository(db dbx.Builder) model.TagRepository { - return &tagRepository{ - baseTagRepository: newBaseTagRepository(db, nil), // nil = no filter, works with all tags - } -} - -func (r *tagRepository) Add(ctx context.Context, libraryID int, tags ...model.Tag) error { - for chunk := range slices.Chunk(tags, 200) { - sq := Insert(r.tableName).Columns("id", "tag_name", "tag_value"). - Suffix("on conflict (id) do nothing") - for _, t := range chunk { - sq = sq.Values(t.ID, t.TagName, t.TagValue) - } - _, err := r.executeSQL(ctx, sq) - if err != nil { - return err - } - - // Create library_tag entries for library filtering - libSq := Insert("library_tag").Columns("tag_id", "library_id", "album_count", "media_file_count"). - Suffix("on conflict (tag_id, library_id) do nothing") - for _, t := range chunk { - libSq = libSq.Values(t.ID, libraryID, 0, 0) - } - _, err = r.executeSQL(ctx, libSq) - if err != nil { - return fmt.Errorf("adding library_tag entries: %w", err) - } - } - return nil -} - -// UpdateCounts updates the library_tag table with per-library statistics. -// Only genres are being updated for now. -func (r *tagRepository) UpdateCounts(ctx context.Context) error { - template := ` -INSERT INTO library_tag (tag_id, library_id, %[1]s_count) -SELECT jt.value as tag_id, %[1]s.library_id, count(distinct %[1]s.id) as %[1]s_count -FROM %[1]s -JOIN json_tree(%[1]s.tags, '$.genre') as jt ON jt.atom IS NOT NULL AND jt.key = 'id' -JOIN tag ON tag.id = jt.value -GROUP BY jt.value, %[1]s.library_id -ON CONFLICT (tag_id, library_id) -DO UPDATE SET %[1]s_count = excluded.%[1]s_count; -` - - for _, table := range []string{"album", "media_file"} { - start := time.Now() - query := Expr(fmt.Sprintf(template, table)) - c, err := r.executeSQL(ctx, query) - log.Debug(ctx, "Updated library tag counts", "table", table, "elapsed", time.Since(start), "updated", c) - if err != nil { - return fmt.Errorf("updating %s library tag counts: %w", table, err) - } - } - return nil -} - -func (r *tagRepository) GetAll(ctx context.Context, name model.TagName, options ...model.QueryOptions) (model.TagList, error) { - sq := r.newSelect(ctx, options...).Where(Eq{"tag.tag_name": name}) - res := model.TagList{} - err := r.queryAll(ctx, sq, &res) - return res, err -} - -func (r *tagRepository) purgeUnused(ctx context.Context) error { - del := Delete(r.tableName).Where(` - id not in (select jt.value - from album left join json_tree(album.tags, '$') as jt - where atom is not null - and key = 'id' - UNION - select jt.value - from media_file left join json_tree(media_file.tags, '$') as jt - where atom is not null - and key = 'id') -`) - c, err := r.executeSQL(ctx, del) - if err != nil { - return fmt.Errorf("error purging %s unused tags: %w", r.tableName, err) - } - if c > 0 { - log.Debug(ctx, "Purged unused tags", "totalDeleted", c, "table", r.tableName) - } - return err -} - -var _ rest.Repository[model.Tag] = &tagRepository{} diff --git a/persistence/tag_repository_test.go b/persistence/tag_repository_test.go deleted file mode 100644 index 730d26357..000000000 --- a/persistence/tag_repository_test.go +++ /dev/null @@ -1,290 +0,0 @@ -package persistence - -import ( - "context" - "slices" - "strings" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" -) - -var _ = Describe("TagRepository", func() { - var repo model.TagRepository - var restRepo rest.Repository[model.Tag] - var ctx context.Context - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) - tagRepo := NewTagRepository(GetDBXBuilder()) - repo = tagRepo - restRepo = tagRepo - - // Clean the database before each test to ensure isolation - db := GetDBXBuilder() - _, err := db.NewQuery("DELETE FROM tag").Execute() - Expect(err).ToNot(HaveOccurred()) - _, err = db.NewQuery("DELETE FROM library_tag").Execute() - Expect(err).ToNot(HaveOccurred()) - - // Ensure library 1 exists (if it doesn't already) - _, err = db.NewQuery("INSERT OR IGNORE INTO library (id, name, path, default_new_users) VALUES (1, 'Test Library', '/test', true)").Execute() - Expect(err).ToNot(HaveOccurred()) - - // Ensure the admin user has access to library 1 - _, err = db.NewQuery("INSERT OR IGNORE INTO user_library (user_id, library_id) VALUES ('userid', 1)").Execute() - Expect(err).ToNot(HaveOccurred()) - - // Add comprehensive test data that covers all test scenarios - newTag := func(name, value string) model.Tag { - return model.Tag{ID: id.NewTagID(name, value), TagName: model.TagName(name), TagValue: value} - } - - err = repo.Add(ctx, 1, - // Genre tags - newTag("genre", "rock"), - newTag("genre", "pop"), - newTag("genre", "jazz"), - newTag("genre", "electronic"), - newTag("genre", "classical"), - newTag("genre", "ambient"), - newTag("genre", "techno"), - newTag("genre", "house"), - newTag("genre", "trance"), - newTag("genre", "Alternative Rock"), - newTag("genre", "Blues"), - newTag("genre", "Country"), - // Mood tags - newTag("mood", "happy"), - newTag("mood", "sad"), - newTag("mood", "energetic"), - newTag("mood", "calm"), - // Other tag types - newTag("instrument", "guitar"), - newTag("instrument", "piano"), - newTag("decade", "1980s"), - newTag("decade", "1990s"), - ) - Expect(err).ToNot(HaveOccurred()) - }) - - Describe("Add", func() { - It("should handle adding new tags", func() { - newTag := model.Tag{ - ID: id.NewTagID("genre", "experimental"), - TagName: "genre", - TagValue: "experimental", - } - - err := repo.Add(ctx, 1, newTag) - Expect(err).ToNot(HaveOccurred()) - - // Verify tag was added - resultTag, err := restRepo.Read(ctx, newTag.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(resultTag.TagValue).To(Equal("experimental")) - - // Check count increased - count, err := restRepo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(21))) // 20 from dataset + 1 new - }) - - It("should handle duplicate tags gracefully", func() { - // Try to add a duplicate tag - duplicateTag := model.Tag{ - ID: id.NewTagID("genre", "rock"), // This already exists - TagName: "genre", - TagValue: "rock", - } - - count, err := restRepo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(20))) // Still 20 tags - - err = repo.Add(ctx, 1, duplicateTag) - Expect(err).ToNot(HaveOccurred()) // Should not error - - // Count should remain the same - count, err = restRepo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(20))) // Still 20 tags - }) - }) - - Describe("UpdateCounts", func() { - It("should update tag counts successfully", func() { - err := repo.UpdateCounts(ctx) - Expect(err).ToNot(HaveOccurred()) - }) - - It("should handle empty database gracefully", func() { - // Clear the database first - db := GetDBXBuilder() - _, err := db.NewQuery("DELETE FROM tag").Execute() - Expect(err).ToNot(HaveOccurred()) - - err = repo.UpdateCounts(ctx) - Expect(err).ToNot(HaveOccurred()) - }) - - It("should handle albums with non-existent tag IDs in JSON gracefully", func() { - // Regression test for foreign key constraint error - // Create an album with tag IDs in JSON that don't exist in tag table - db := GetDBXBuilder() - - // First, create a non-existent tag ID (this simulates tags in JSON that aren't in tag table) - nonExistentTagID := id.NewTagID("genre", "nonexistent-genre") - - // Create album with JSON containing the non-existent tag ID - albumWithBadTags := `{"genre":[{"id":"` + nonExistentTagID + `","value":"nonexistent-genre"}]}` - - // Insert album directly into database with the problematic JSON - _, err := db.NewQuery("INSERT INTO album (id, name, library_id, tags) VALUES ({:id}, {:name}, {:lib}, {:tags})"). - Bind(dbx.Params{ - "id": "test-album-bad-tags", - "name": "Album With Bad Tags", - "lib": 1, - "tags": albumWithBadTags, - }).Execute() - Expect(err).ToNot(HaveOccurred()) - - // This should not fail with foreign key constraint error - err = repo.UpdateCounts(ctx) - Expect(err).ToNot(HaveOccurred()) - - // Cleanup - _, err = db.NewQuery("DELETE FROM album WHERE id = {:id}"). - Bind(dbx.Params{"id": "test-album-bad-tags"}).Execute() - Expect(err).ToNot(HaveOccurred()) - }) - - It("should handle media files with non-existent tag IDs in JSON gracefully", func() { - // Regression test for foreign key constraint error with media files - db := GetDBXBuilder() - - // Create a non-existent tag ID - nonExistentTagID := id.NewTagID("genre", "another-nonexistent-genre") - - // Create media file with JSON containing the non-existent tag ID - mediaFileWithBadTags := `{"genre":[{"id":"` + nonExistentTagID + `","value":"another-nonexistent-genre"}]}` - - // Insert media file directly into database with the problematic JSON - _, err := db.NewQuery("INSERT INTO media_file (id, title, library_id, tags) VALUES ({:id}, {:title}, {:lib}, {:tags})"). - Bind(dbx.Params{ - "id": "test-media-bad-tags", - "title": "Media File With Bad Tags", - "lib": 1, - "tags": mediaFileWithBadTags, - }).Execute() - Expect(err).ToNot(HaveOccurred()) - - // This should not fail with foreign key constraint error - err = repo.UpdateCounts(ctx) - Expect(err).ToNot(HaveOccurred()) - - // Cleanup - _, err = db.NewQuery("DELETE FROM media_file WHERE id = {:id}"). - Bind(dbx.Params{"id": "test-media-bad-tags"}).Execute() - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("Count", func() { - It("should return correct count of tags", func() { - count, err := restRepo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(20))) // From the test dataset - }) - }) - - Describe("Read", func() { - It("should return existing tag", func() { - rockID := id.NewTagID("genre", "rock") - resultTag, err := restRepo.Read(ctx, rockID) - Expect(err).ToNot(HaveOccurred()) - Expect(resultTag.ID).To(Equal(rockID)) - Expect(resultTag.TagName).To(Equal(model.TagName("genre"))) - Expect(resultTag.TagValue).To(Equal("rock")) - }) - - It("should return error for non-existent tag", func() { - _, err := restRepo.Read(ctx, "non-existent-id") - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("ReadAll", func() { - It("should return all tags from dataset", func() { - tags, err := restRepo.ReadAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(tags).To(HaveLen(20)) - }) - - It("should filter tags by partial value correctly", func() { - options := rest.QueryOptions{ - Filters: map[string]any{"name": "%rock%"}, // Tags containing 'rock' - } - tags, err := restRepo.ReadAll(ctx, options) - Expect(err).ToNot(HaveOccurred()) - Expect(tags).To(HaveLen(2)) // "rock" and "Alternative Rock" - - // Verify all returned tags contain 'rock' in their value - for _, tag := range tags { - Expect(strings.ToLower(tag.TagValue)).To(ContainSubstring("rock")) - } - }) - - It("should filter tags by partial value using LIKE", func() { - options := rest.QueryOptions{ - Filters: map[string]any{"name": "%e%"}, // Tags containing 'e' - } - tags, err := restRepo.ReadAll(ctx, options) - Expect(err).ToNot(HaveOccurred()) - Expect(tags).To(HaveLen(8)) // electronic, house, trance, energetic, Blues, decade x2, Alternative Rock - - // Verify all returned tags contain 'e' in their value - for _, tag := range tags { - Expect(strings.ToLower(tag.TagValue)).To(ContainSubstring("e")) - } - }) - - It("should sort tags by value ascending", func() { - options := rest.QueryOptions{ - Filters: map[string]any{"name": "%r%"}, // Tags containing 'r' - Sort: "name", - Order: "asc", - } - tags, err := restRepo.ReadAll(ctx, options) - Expect(err).ToNot(HaveOccurred()) - Expect(tags).To(HaveLen(7)) - - Expect(slices.IsSortedFunc(tags, func(a, b model.Tag) int { - return strings.Compare(strings.ToLower(a.TagValue), strings.ToLower(b.TagValue)) - })) - }) - - It("should sort tags by value descending", func() { - options := rest.QueryOptions{ - Filters: map[string]any{"name": "%r%"}, // Tags containing 'r' - Sort: "name", - Order: "desc", - } - tags, err := restRepo.ReadAll(ctx, options) - Expect(err).ToNot(HaveOccurred()) - Expect(tags).To(HaveLen(7)) - - Expect(slices.IsSortedFunc(tags, func(a, b model.Tag) int { - return strings.Compare(strings.ToLower(b.TagValue), strings.ToLower(a.TagValue)) // Descending order - })) - }) - }) -}) diff --git a/persistence/transcoding_repository.go b/persistence/transcoding_repository.go index 9db56e143..fc364fce6 100644 --- a/persistence/transcoding_repository.go +++ b/persistence/transcoding_repository.go @@ -2,109 +2,100 @@ package persistence import ( "context" + "errors" . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" - "github.com/pocketbase/dbx" ) type transcodingRepository struct { sqlRepository + sqlRestful } -func NewTranscodingRepository(db dbx.Builder) model.TranscodingRepository { +func NewTranscodingRepository(ctx context.Context, o orm.QueryExecutor) model.TranscodingRepository { r := &transcodingRepository{} - r.db = db - r.registerModel(&model.Transcoding{}, nil) + r.ctx = ctx + r.ormer = o + r.tableName = "transcoding" return r } -func (r *transcodingRepository) Get(ctx context.Context, id string) (*model.Transcoding, error) { - sel := r.newSelect(ctx).Columns("*").Where(Eq{"id": id}) +func (r *transcodingRepository) Get(id string) (*model.Transcoding, error) { + sel := r.newSelect().Columns("*").Where(Eq{"id": id}) var res model.Transcoding - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) return &res, err } -func (r *transcodingRepository) CountAll(ctx context.Context, qo ...model.QueryOptions) (int64, error) { - return r.count(ctx, Select(), qo...) +func (r *transcodingRepository) CountAll(qo ...model.QueryOptions) (int64, error) { + return r.count(Select(), qo...) } -func (r *transcodingRepository) FindByFormat(ctx context.Context, format string) (*model.Transcoding, error) { - sel := r.newSelect(ctx).Columns("*").Where(Eq{"target_format": format}) +func (r *transcodingRepository) FindByFormat(format string) (*model.Transcoding, error) { + sel := r.newSelect().Columns("*").Where(Eq{"target_format": format}) var res model.Transcoding - err := r.queryOne(ctx, sel, &res) + err := r.queryOne(sel, &res) return &res, err } -func (r *transcodingRepository) Put(ctx context.Context, t *model.Transcoding) error { - if !loggedUser(ctx).IsAdmin { - return rest.ErrPermissionDenied - } - _, err := r.put(ctx, t.ID, t) +func (r *transcodingRepository) Put(t *model.Transcoding) error { + _, err := r.put(t.ID, t) return err } -func (r *transcodingRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.count(ctx, Select(), r.parseRestOptions(ctx, options...)) +func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.count(Select(), r.parseRestOptions(options...)) } -func (r *transcodingRepository) Read(ctx context.Context, id string) (*model.Transcoding, error) { - res, err := r.Get(ctx, id) - if err != nil { - return nil, err - } - if !loggedUser(ctx).IsAdmin { - res.Command = "" - } - return res, nil +func (r *transcodingRepository) Read(id string) (interface{}, error) { + return r.Get(id) } -func (r *transcodingRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Transcoding, error) { - sel := r.newSelect(ctx, r.parseRestOptions(ctx, options...)).Columns("*") +func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + sel := r.newSelect(r.parseRestOptions(options...)).Columns("*") res := model.Transcodings{} - err := r.queryAll(ctx, sel, &res) - if err != nil { - return nil, err - } - if !loggedUser(ctx).IsAdmin { - for i := range res { - res[i].Command = "" - } - } - return res, nil + err := r.queryAll(sel, &res) + return res, err } -func (r *transcodingRepository) Save(ctx context.Context, t *model.Transcoding) (string, error) { - if !loggedUser(ctx).IsAdmin { - return "", rest.ErrPermissionDenied - } - return r.put(ctx, t.ID, t) +func (r *transcodingRepository) EntityName() string { + return "transcoding" } -func (r *transcodingRepository) Update(ctx context.Context, id string, entity model.Transcoding, cols ...string) error { - if !loggedUser(ctx).IsAdmin { - return rest.ErrPermissionDenied +func (r *transcodingRepository) NewInstance() interface{} { + return &model.Transcoding{} +} + +func (r *transcodingRepository) Save(entity interface{}) (string, error) { + t := entity.(*model.Transcoding) + id, err := r.put(t.ID, t) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound } - t := &entity + return id, err +} + +func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error { + t := entity.(*model.Transcoding) t.ID = id - _, err := r.put(ctx, id, t) + _, err := r.put(id, t) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } return err } -func (r *transcodingRepository) Delete(ctx context.Context, ids ...string) error { - if !loggedUser(ctx).IsAdmin { - return rest.ErrPermissionDenied +func (r *transcodingRepository) Delete(id string) error { + err := r.delete(Eq{"id": id}) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound } - for _, id := range ids { - if err := r.deleteByID(ctx, id); err != nil { - return err - } - } - return nil + return err } var _ model.TranscodingRepository = (*transcodingRepository)(nil) -var _ rest.Repository[model.Transcoding] = (*transcodingRepository)(nil) -var _ rest.Persistable[model.Transcoding] = (*transcodingRepository)(nil) +var _ rest.Repository = (*transcodingRepository)(nil) +var _ rest.Persistable = (*transcodingRepository)(nil) diff --git a/persistence/transcoding_repository_test.go b/persistence/transcoding_repository_test.go deleted file mode 100644 index 372b5801f..000000000 --- a/persistence/transcoding_repository_test.go +++ /dev/null @@ -1,153 +0,0 @@ -package persistence - -import ( - "context" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("TranscodingRepository", func() { - var repo model.TranscodingRepository - var ctx, adminCtx context.Context - - BeforeEach(func() { - ctx = request.WithUser(log.NewContext(GinkgoT().Context()), regularUser) - adminCtx = request.WithUser(ctx, adminUser) - repo = NewTranscodingRepository(GetDBXBuilder()) - }) - - AfterEach(func() { - // Clean up any transcoding created during the tests - tc, err := repo.FindByFormat(adminCtx, "test_format") - if err == nil { - err = repo.Delete(adminCtx, tc.ID) - Expect(err).ToNot(HaveOccurred()) - } - }) - - Describe("Admin User", func() { - It("creates a new transcoding", func() { - base, err := repo.CountAll(adminCtx) - Expect(err).ToNot(HaveOccurred()) - - err = repo.Put(adminCtx, &model.Transcoding{ID: "new", Name: "new", TargetFormat: "test_format", DefaultBitRate: 320, Command: "ffmpeg"}) - Expect(err).ToNot(HaveOccurred()) - - count, err := repo.CountAll(adminCtx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(base + 1)) - }) - - It("updates an existing transcoding", func() { - tr := &model.Transcoding{ID: "upd", Name: "old", TargetFormat: "test_format", DefaultBitRate: 100, Command: "ffmpeg"} - Expect(repo.Put(adminCtx, tr)).To(Succeed()) - tr.Name = "updated" - err := repo.Put(adminCtx, tr) - Expect(err).ToNot(HaveOccurred()) - res, err := repo.FindByFormat(adminCtx, "test_format") - Expect(err).ToNot(HaveOccurred()) - Expect(res.Name).To(Equal("updated")) - }) - - It("deletes a transcoding", func() { - err := repo.Put(adminCtx, &model.Transcoding{ID: "to-delete", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 256, Command: "ffmpeg"}) - Expect(err).ToNot(HaveOccurred()) - err = repo.Delete(adminCtx, "to-delete") - Expect(err).ToNot(HaveOccurred()) - _, err = repo.Get(adminCtx, "to-delete") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("returns not found when deleting a missing transcoding", func() { - err := repo.(*transcodingRepository).Delete(adminCtx, "does-not-exist") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - - It("reads the Command field via the REST Read method", func() { - tr := &model.Transcoding{ID: "adminread", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} - Expect(repo.Put(adminCtx, tr)).To(Succeed()) - - res, err := repo.Read(adminCtx, "adminread") - Expect(err).ToNot(HaveOccurred()) - Expect(res.Command).To(Equal("ffmpeg -secret")) - }) - }) - - Describe("Regular User", func() { - It("reads a transcoding but with the Command field redacted", func() { - tr := &model.Transcoding{ID: "readreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} - Expect(repo.Put(adminCtx, tr)).To(Succeed()) - - t, err := repo.Read(ctx, "readreg") - Expect(err).ToNot(HaveOccurred()) - Expect(t.Name).To(Equal("temp")) - Expect(t.TargetFormat).To(Equal("test_format")) - Expect(t.Command).To(BeEmpty()) - }) - - It("lists transcodings but with the Command field redacted", func() { - tr := &model.Transcoding{ID: "listreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} - Expect(repo.Put(adminCtx, tr)).To(Succeed()) - - list, err := repo.ReadAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(list).ToNot(BeEmpty()) - for _, t := range list { - Expect(t.Command).To(BeEmpty()) - } - }) - - It("counts transcodings", func() { - count, err := repo.Count(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeNumerically(">=", 0)) - }) - - It("can still resolve a transcoding for streaming via Get (Command not redacted)", func() { - tr := &model.Transcoding{ID: "streamreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} - Expect(repo.Put(adminCtx, tr)).To(Succeed()) - - res, err := repo.Get(ctx, "streamreg") - Expect(err).ToNot(HaveOccurred()) - Expect(res.ID).To(Equal("streamreg")) - Expect(res.Command).To(Equal("ffmpeg -secret")) - }) - - It("can still resolve a transcoding for streaming via FindByFormat (Command not redacted)", func() { - tr := &model.Transcoding{ID: "fmtreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} - Expect(repo.Put(adminCtx, tr)).To(Succeed()) - - res, err := repo.FindByFormat(ctx, "test_format") - Expect(err).ToNot(HaveOccurred()) - Expect(res.ID).To(Equal("fmtreg")) - Expect(res.Command).To(Equal("ffmpeg -secret")) - }) - - It("fails to create", func() { - err := repo.Put(ctx, &model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}) - Expect(err).To(Equal(rest.ErrPermissionDenied)) - }) - - It("fails to update", func() { - tr := &model.Transcoding{ID: "updreg", Name: "old", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"} - Expect(repo.Put(adminCtx, tr)).To(Succeed()) - - tr.Name = "bad" - err := repo.Put(ctx, tr) - Expect(err).To(Equal(rest.ErrPermissionDenied)) - }) - - It("fails to delete", func() { - tr := &model.Transcoding{ID: "delreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"} - Expect(repo.Put(adminCtx, tr)).To(Succeed()) - - err := repo.Delete(ctx, "delreg") - Expect(err).To(Equal(rest.ErrPermissionDenied)) - }) - }) -}) diff --git a/persistence/user_props_repository.go b/persistence/user_props_repository.go index 59d7d332f..1fe9c7b92 100644 --- a/persistence/user_props_repository.go +++ b/persistence/user_props_repository.go @@ -5,24 +5,25 @@ import ( "errors" . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "github.com/navidrome/navidrome/model" - "github.com/pocketbase/dbx" ) type userPropsRepository struct { sqlRepository } -func NewUserPropsRepository(db dbx.Builder) model.UserPropsRepository { +func NewUserPropsRepository(ctx context.Context, o orm.QueryExecutor) model.UserPropsRepository { r := &userPropsRepository{} - r.db = db + r.ctx = ctx + r.ormer = o r.tableName = "user_props" return r } -func (r userPropsRepository) Put(ctx context.Context, userId, key string, value string) error { +func (r userPropsRepository) Put(userId, key string, value string) error { update := Update(r.tableName).Set("value", value).Where(And{Eq{"user_id": userId}, Eq{"key": key}}) - count, err := r.executeSQL(ctx, update) + count, err := r.executeSQL(update) if err != nil { return err } @@ -30,24 +31,24 @@ func (r userPropsRepository) Put(ctx context.Context, userId, key string, value return nil } insert := Insert(r.tableName).Columns("user_id", "key", "value").Values(userId, key, value) - _, err = r.executeSQL(ctx, insert) + _, err = r.executeSQL(insert) return err } -func (r userPropsRepository) Get(ctx context.Context, userId, key string) (string, error) { +func (r userPropsRepository) Get(userId, key string) (string, error) { sel := Select("value").From(r.tableName).Where(And{Eq{"user_id": userId}, Eq{"key": key}}) resp := struct { Value string }{} - err := r.queryOne(ctx, sel, &resp) + err := r.queryOne(sel, &resp) if err != nil { return "", err } return resp.Value, nil } -func (r userPropsRepository) DefaultGet(ctx context.Context, userId, key string, defaultValue string) (string, error) { - value, err := r.Get(ctx, userId, key) +func (r userPropsRepository) DefaultGet(userId, key string, defaultValue string) (string, error) { + value, err := r.Get(userId, key) if errors.Is(err, model.ErrNotFound) { return defaultValue, nil } @@ -57,6 +58,6 @@ func (r userPropsRepository) DefaultGet(ctx context.Context, userId, key string, return value, nil } -func (r userPropsRepository) Delete(ctx context.Context, userId, key string) error { - return r.delete(ctx, And{Eq{"user_id": userId}, Eq{"key": key}}) +func (r userPropsRepository) Delete(userId, key string) error { + return r.delete(And{Eq{"user_id": userId}, Eq{"key": key}}) } diff --git a/persistence/user_repository.go b/persistence/user_repository.go index 20b4e5125..0acbaae24 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -3,7 +3,6 @@ package persistence import ( "context" "crypto/sha256" - "encoding/json" "errors" "fmt" "strings" @@ -11,41 +10,19 @@ import ( "time" . "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "github.com/deluan/rest" + "github.com/google/uuid" "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/criteria" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/slice" - "github.com/pocketbase/dbx" ) type userRepository struct { sqlRepository -} - -type dbUser struct { - *model.User `structs:",flatten"` - LibrariesJSON string `structs:"-" json:"-"` -} - -func (u *dbUser) PostScan() error { - if u.LibrariesJSON != "" { - if err := json.Unmarshal([]byte(u.LibrariesJSON), &u.User.Libraries); err != nil { - return fmt.Errorf("parsing user libraries from db: %w", err) - } - } - return nil -} - -type dbUsers []dbUser - -func (us dbUsers) toModels() model.Users { - return slice.Map(us, func(u dbUser) model.User { return *u.User }) + sqlRestful } var ( @@ -53,229 +30,150 @@ var ( encKey []byte ) -func NewUserRepository(db dbx.Builder) model.UserRepository { +func NewUserRepository(ctx context.Context, o orm.QueryExecutor) model.UserRepository { r := &userRepository{} - r.db = db + r.ctx = ctx + r.ormer = o r.tableName = "user" - r.registerModel(&model.User{}, map[string]filterFunc{ - "id": idFilter(r.tableName), - "password": invalidFilter, - "name": startsWithFilter(r.tableName + ".name"), - }) once.Do(func() { - _ = r.initPasswordEncryptionKey(context.Background()) + _ = r.initPasswordEncryptionKey() }) return r } -// selectUserWithLibraries returns a SelectBuilder that includes library information -func (r *userRepository) selectUserWithLibraries(ctx context.Context, options ...model.QueryOptions) SelectBuilder { - return r.newSelect(ctx, options...). - Columns(`user.*`, - `COALESCE(json_group_array(json_object( - 'id', library.id, - 'name', library.name, - 'path', library.path, - 'remote_path', library.remote_path, - 'last_scan_at', library.last_scan_at, - 'last_scan_started_at', library.last_scan_started_at, - 'full_scan_in_progress', library.full_scan_in_progress, - 'updated_at', library.updated_at, - 'created_at', library.created_at - )) FILTER (WHERE library.id IS NOT NULL), '[]') AS libraries_json`). - LeftJoin("user_library ul ON user.id = ul.user_id"). - LeftJoin("library ON ul.library_id = library.id"). - GroupBy("user.id") +func (r *userRepository) CountAll(qo ...model.QueryOptions) (int64, error) { + return r.count(Select(), qo...) } -func (r *userRepository) CountAll(ctx context.Context, qo ...model.QueryOptions) (int64, error) { - return r.count(ctx, Select(), qo...) +func (r *userRepository) Get(id string) (*model.User, error) { + sel := r.newSelect().Columns("*").Where(Eq{"id": id}) + var res model.User + err := r.queryOne(sel, &res) + return &res, err } -func (r *userRepository) Get(ctx context.Context, id string) (*model.User, error) { - sel := r.selectUserWithLibraries(ctx).Where(Eq{"user.id": id}) - var res dbUser - err := r.queryOne(ctx, sel, &res) - if err != nil { - return nil, err - } - return res.User, nil +func (r *userRepository) GetAll(options ...model.QueryOptions) (model.Users, error) { + sel := r.newSelect(options...).Columns("*") + res := model.Users{} + err := r.queryAll(sel, &res) + return res, err } -func (r *userRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Users, error) { - sel := r.selectUserWithLibraries(ctx, options...) - var res dbUsers - err := r.queryAll(ctx, sel, &res) - if err != nil { - return nil, err - } - return res.toModels(), nil -} - -func (r *userRepository) Put(ctx context.Context, u *model.User) error { +func (r *userRepository) Put(u *model.User) error { if u.ID == "" { - u.ID = id.NewRandom() + u.ID = uuid.NewString() } u.UpdatedAt = time.Now() if u.NewPassword != "" { - _ = r.encryptPassword(ctx, u) - } - values, err := toSQLArgs(*u) - if err != nil { - return fmt.Errorf("error converting user to SQL args: %w", err) + _ = r.encryptPassword(u) } + values, _ := toSqlArgs(*u) delete(values, "current_password") - - // The epoch bump rides the password UPDATE: as two statements they can interleave with a - // concurrent change and leave a session valid that the other change should have revoked. update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values) - var isNewUser bool - var epoch int - if u.NewPassword != "" { - var res struct{ TokenEpoch int } - err = r.queryOne(ctx, update.Set("token_epoch", Expr("token_epoch + 1")). - Suffix("RETURNING token_epoch"), &res) - switch { - case errors.Is(err, model.ErrNotFound): - isNewUser = true - case err != nil: - return err - default: - epoch = res.TokenEpoch - } - } else { - count, err := r.executeSQL(ctx, update) - if err != nil { - return err - } - isNewUser = count == 0 - } - if isNewUser { - values["created_at"] = time.Now() - insert := Insert(r.tableName).SetMap(values) - _, err = r.executeSQL(ctx, insert) - if err != nil { - return err - } - } - - // Auto-assign all libraries to admin users in a single SQL operation - if u.IsAdmin { - sql := Expr( - "INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library", - u.ID, - ) - if _, err := r.executeSQL(ctx, sql); err != nil { - return fmt.Errorf("failed to assign all libraries to admin user: %w", err) - } - } else if isNewUser { // Only for new regular users - // Auto-assign default libraries to new regular users - sql := Expr( - "INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library WHERE default_new_users = true", - u.ID, - ) - if _, err := r.executeSQL(ctx, sql); err != nil { - return fmt.Errorf("failed to assign default libraries to new user: %w", err) - } - } - - // Only the caller's own token can be refreshed in-flight; an admin resetting another - // user must keep their own epoch. - if u.NewPassword != "" && !isNewUser && loggedUser(ctx).ID == u.ID { - request.SetTokenEpoch(ctx, epoch) - } - - return nil -} - -func (r *userRepository) FindFirstAdmin(ctx context.Context) (*model.User, error) { - sel := r.selectUserWithLibraries(ctx, model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true}) - var usr dbUser - err := r.queryOne(ctx, sel, &usr) + count, err := r.executeSQL(update) if err != nil { - return nil, err + return err } - return usr.User, nil -} - -func (r *userRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) { - sel := r.selectUserWithLibraries(ctx).Where(Expr("user.user_name = ? COLLATE NOCASE", username)) - var usr dbUser - err := r.queryOne(ctx, sel, &usr) - if err != nil { - return nil, err + if count > 0 { + return nil } - return usr.User, nil -} - -func (r *userRepository) FindByUsernameWithPassword(ctx context.Context, username string) (*model.User, error) { - usr, err := r.FindByUsername(ctx, username) - if err != nil { - return nil, err - } - _ = r.decryptPassword(ctx, usr) - return usr, nil -} - -func (r *userRepository) UpdateLastLoginAt(ctx context.Context, id string) error { - upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_login_at", time.Now()) - _, err := r.executeSQL(ctx, upd) + values["created_at"] = time.Now() + insert := Insert(r.tableName).SetMap(values) + _, err = r.executeSQL(insert) return err } -func (r *userRepository) UpdateLastAccessAt(ctx context.Context, id string) error { +func (r *userRepository) FindFirstAdmin() (*model.User, error) { + sel := r.newSelect(model.QueryOptions{Sort: "updated_at", Max: 1}).Columns("*").Where(Eq{"is_admin": true}) + var usr model.User + err := r.queryOne(sel, &usr) + return &usr, err +} + +func (r *userRepository) FindByUsername(username string) (*model.User, error) { + sel := r.newSelect().Columns("*").Where(Like{"user_name": username}) + var usr model.User + err := r.queryOne(sel, &usr) + return &usr, err +} + +func (r *userRepository) FindByUsernameWithPassword(username string) (*model.User, error) { + usr, err := r.FindByUsername(username) + if err == nil { + _ = r.decryptPassword(usr) + } + return usr, err +} + +func (r *userRepository) UpdateLastLoginAt(id string) error { + upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_login_at", time.Now()) + _, err := r.executeSQL(upd) + return err +} + +func (r *userRepository) UpdateLastAccessAt(id string) error { now := time.Now() upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_access_at", now) - _, err := r.executeSQL(ctx, upd) + _, err := r.executeSQL(upd) return err } -func (r *userRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - usr := loggedUser(ctx) +func (r *userRepository) Count(options ...rest.QueryOptions) (int64, error) { + usr := loggedUser(r.ctx) if !usr.IsAdmin { return 0, rest.ErrPermissionDenied } - return r.CountAll(ctx, r.parseRestOptions(ctx, options...)) + return r.CountAll(r.parseRestOptions(options...)) } -func (r *userRepository) Read(ctx context.Context, id string) (*model.User, error) { - usr := loggedUser(ctx) +func (r *userRepository) Read(id string) (interface{}, error) { + usr := loggedUser(r.ctx) if !usr.IsAdmin && usr.ID != id { return nil, rest.ErrPermissionDenied } - return r.Get(ctx, id) + usr, err := r.Get(id) + if errors.Is(err, model.ErrNotFound) { + return nil, rest.ErrNotFound + } + return usr, err } -func (r *userRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.User, error) { - usr := loggedUser(ctx) +func (r *userRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + usr := loggedUser(r.ctx) if !usr.IsAdmin { return nil, rest.ErrPermissionDenied } - return r.GetAll(ctx, r.parseRestOptions(ctx, options...)) + return r.GetAll(r.parseRestOptions(options...)) } -func (r *userRepository) Save(ctx context.Context, u *model.User) (string, error) { - usr := loggedUser(ctx) +func (r *userRepository) EntityName() string { + return "user" +} + +func (r *userRepository) NewInstance() interface{} { + return &model.User{} +} + +func (r *userRepository) Save(entity interface{}) (string, error) { + usr := loggedUser(r.ctx) if !usr.IsAdmin { return "", rest.ErrPermissionDenied } - if err := validateUsernameUnique(ctx, r, u); err != nil { + u := entity.(*model.User) + if err := validateUsernameUnique(r, u); err != nil { return "", err } - if err := validateScrobbleFilter(u); err != nil { - return "", err - } - err := r.Put(ctx, u) + err := r.Put(u) if err != nil { return "", err } return u.ID, err } -func (r *userRepository) Update(ctx context.Context, id string, entity model.User, _ ...string) error { - u := &entity +func (r *userRepository) Update(id string, entity interface{}, cols ...string) error { + u := entity.(*model.User) u.ID = id - usr := loggedUser(ctx) + usr := loggedUser(r.ctx) if !usr.IsAdmin && usr.ID != u.ID { return rest.ErrPermissionDenied } @@ -288,19 +186,20 @@ func (r *userRepository) Update(ctx context.Context, id string, entity model.Use } // Decrypt the user's existing password before validating. This is required otherwise the existing password entered by the user will never match. - if err := r.decryptPassword(ctx, usr); err != nil { + if err := r.decryptPassword(usr); err != nil { return err } if err := validatePasswordChange(u, usr); err != nil { return err } - if err := validateUsernameUnique(ctx, r, u); err != nil { + if err := validateUsernameUnique(r, u); err != nil { return err } - if err := validateScrobbleFilter(u); err != nil { - return err + err := r.Put(u) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound } - return r.Put(ctx, u) + return err } func validatePasswordChange(newUser *model.User, logged *model.User) error { @@ -329,8 +228,8 @@ func validatePasswordChange(newUser *model.User, logged *model.User) error { return nil } -func validateUsernameUnique(ctx context.Context, r model.UserRepository, u *model.User) error { - usr, err := r.FindByUsername(ctx, u.UserName) +func validateUsernameUnique(r model.UserRepository, u *model.User) error { + usr, err := r.FindByUsername(u.UserName) if errors.Is(err, model.ErrNotFound) { return nil } @@ -343,47 +242,16 @@ func validateUsernameUnique(ctx context.Context, r model.UserRepository, u *mode return nil } -func validateScrobbleFilter(u *model.User) error { - u.ScrobbleFilter = strings.TrimSpace(u.ScrobbleFilter) - if u.ScrobbleFilter == "" { - return nil - } - var c criteria.Criteria - if err := json.Unmarshal([]byte(u.ScrobbleFilter), &c); err != nil { - return invalidScrobbleFilter() - } - // A filter is a per-track test, so a result-set size means nothing here. Reject it - // rather than silently ignoring part of a rule copied from a smart playlist. - if c.Limit > 0 || c.LimitPercent > 0 || c.Offset > 0 || c.RefreshDelay > 0 { - return invalidScrobbleFilter() - } - // Building the WHERE clause is what validates field names and operators - if _, err := newSmartPlaylistCriteria(c).where(); err != nil { - return invalidScrobbleFilter() - } - return nil -} - -func invalidScrobbleFilter() error { - return &rest.ValidationError{Errors: map[string]string{ - "scrobbleFilter": "resources.user.validation.invalidScrobbleFilter", - }} -} - -func (r *userRepository) Delete(ctx context.Context, ids ...string) error { - usr := loggedUser(ctx) +func (r *userRepository) Delete(id string) error { + usr := loggedUser(r.ctx) if !usr.IsAdmin { return rest.ErrPermissionDenied } - for _, id := range ids { - if err := r.deleteByID(ctx, id); err != nil { - return err - } - if err := cleanupPluginUserReferences(r.db, id); err != nil { - log.Error(ctx, "Failed to cleanup plugin user references", "userID", id, err) - } + err := r.delete(Eq{"id": id}) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound } - return nil + return err } func keyTo32Bytes(input string) []byte { @@ -391,7 +259,7 @@ func keyTo32Bytes(input string) []byte { return data[0:] } -func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { +func (r *userRepository) initPasswordEncryptionKey() error { encKey = keyTo32Bytes(consts.DefaultEncryptionKey) if conf.Server.PasswordEncryptionKey == "" { return nil @@ -400,8 +268,8 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { key := keyTo32Bytes(conf.Server.PasswordEncryptionKey) keySum := fmt.Sprintf("%x", sha256.Sum256(key)) - props := NewPropertyRepository(r.db) - savedKeySum, err := props.Get(ctx, consts.PasswordsEncryptedKey) + props := NewPropertyRepository(r.ctx, r.ormer) + savedKeySum, err := props.Get(consts.PasswordsEncryptedKey) // If passwords are already encrypted if err == nil { @@ -415,24 +283,24 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { // if not, try to re-encrypt all current passwords with new encryption key, // assuming they were encrypted with the DefaultEncryptionKey - sql := r.newSelect(ctx).Columns("id", "user_name", "password") + sql := r.newSelect().Columns("id", "user_name", "password") users := model.Users{} - err = r.queryAll(ctx, sql, &users) + err = r.queryAll(sql, &users) if err != nil { log.Error("Could not encrypt all passwords", err) return err } log.Warn("New PasswordEncryptionKey set. Encrypting all passwords", "numUsers", len(users)) - if err = r.decryptAllPasswords(ctx, users); err != nil { + if err = r.decryptAllPasswords(users); err != nil { return err } encKey = key for i := range users { u := users[i] u.NewPassword = u.Password - if err := r.encryptPassword(ctx, &u); err == nil { + if err := r.encryptPassword(&u); err == nil { upd := Update(r.tableName).Set("password", u.NewPassword).Where(Eq{"id": u.ID}) - _, err = r.executeSQL(ctx, upd) + _, err = r.executeSQL(upd) if err != nil { log.Error("Password NOT encrypted! This may cause problems!", "user", u.UserName, "id", u.ID, err) } else { @@ -441,7 +309,7 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { } } - err = props.Put(ctx, consts.PasswordsEncryptedKey, keySum) + err = props.Put(consts.PasswordsEncryptedKey, keySum) if err != nil { log.Error("Could not flag passwords as encrypted. It will cause login errors", err) return err @@ -450,10 +318,10 @@ func (r *userRepository) initPasswordEncryptionKey(ctx context.Context) error { } // encrypts u.NewPassword -func (r *userRepository) encryptPassword(ctx context.Context, u *model.User) error { - encPassword, err := utils.Encrypt(ctx, encKey, u.NewPassword) +func (r *userRepository) encryptPassword(u *model.User) error { + encPassword, err := utils.Encrypt(r.ctx, encKey, u.NewPassword) if err != nil { - log.Error(ctx, "Error encrypting user's password", "user", u.UserName, err) + log.Error(r.ctx, "Error encrypting user's password", "user", u.UserName, err) return err } u.NewPassword = encPassword @@ -461,58 +329,25 @@ func (r *userRepository) encryptPassword(ctx context.Context, u *model.User) err } // decrypts u.Password -func (r *userRepository) decryptPassword(ctx context.Context, u *model.User) error { - plaintext, err := utils.Decrypt(ctx, encKey, u.Password) +func (r *userRepository) decryptPassword(u *model.User) error { + plaintext, err := utils.Decrypt(r.ctx, encKey, u.Password) if err != nil { - log.Error(ctx, "Error decrypting user's password", "user", u.UserName, err) + log.Error(r.ctx, "Error decrypting user's password", "user", u.UserName, err) return err } u.Password = plaintext return nil } -func (r *userRepository) decryptAllPasswords(ctx context.Context, users model.Users) error { +func (r *userRepository) decryptAllPasswords(users model.Users) error { for i := range users { - if err := r.decryptPassword(ctx, &users[i]); err != nil { + if err := r.decryptPassword(&users[i]); err != nil { return err } } return nil } -// Library association methods - -func (r *userRepository) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) { - sel := Select("l.*"). - From("library l"). - Join("user_library ul ON l.id = ul.library_id"). - Where(Eq{"ul.user_id": userID}). - OrderBy("l.name") - - var res model.Libraries - err := r.queryAll(ctx, sel, &res) - return res, err -} - -func (r *userRepository) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error { - // Remove existing associations - delSql := Delete("user_library").Where(Eq{"user_id": userID}) - if _, err := r.executeSQL(ctx, delSql); err != nil { - return err - } - - // Add new associations - if len(libraryIDs) > 0 { - insert := Insert("user_library").Columns("user_id", "library_id") - for _, libID := range libraryIDs { - insert = insert.Values(userID, libID) - } - _, err := r.executeSQL(ctx, insert) - return err - } - return nil -} - var _ model.UserRepository = (*userRepository)(nil) -var _ rest.Repository[model.User] = (*userRepository)(nil) -var _ rest.Persistable[model.User] = (*userRepository)(nil) +var _ rest.Repository = (*userRepository)(nil) +var _ rest.Persistable = (*userRepository)(nil) diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 0e776fc3a..d592b86d8 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -3,29 +3,23 @@ package persistence import ( "context" "errors" - "slices" - "sync" - "github.com/Masterminds/squirrel" + "github.com/beego/beego/v2/client/orm" "github.com/deluan/rest" + "github.com/google/uuid" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("UserRepository", func() { var repo model.UserRepository - var ctx context.Context BeforeEach(func() { - ctx = log.NewContext(GinkgoT().Context()) - repo = NewUserRepository(GetDBXBuilder()) + repo = NewUserRepository(log.NewContext(context.TODO()), orm.NewOrm()) }) Describe("Put/Get/FindByUsername", func() { @@ -38,20 +32,20 @@ var _ = Describe("UserRepository", func() { IsAdmin: true, } It("saves the user to the DB", func() { - Expect(repo.Put(ctx, &usr)).To(BeNil()) + Expect(repo.Put(&usr)).To(BeNil()) }) It("returns the newly created user", func() { - actual, err := repo.Get(ctx, "123") + actual, err := repo.Get("123") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Admin")) }) It("find the user by case-insensitive username", func() { - actual, err := repo.FindByUsername(ctx, "aDmIn") + actual, err := repo.FindByUsername("aDmIn") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Admin")) }) It("find the user by username and decrypts the password", func() { - actual, err := repo.FindByUsernameWithPassword(ctx, "aDmIn") + actual, err := repo.FindByUsernameWithPassword("aDmIn") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Admin")) Expect(actual.Password).To(Equal("wordpass")) @@ -59,41 +53,21 @@ var _ = Describe("UserRepository", func() { It("updates the name and keep the same password", func() { usr.Name = "Jane Doe" usr.NewPassword = "" - Expect(repo.Put(ctx, &usr)).To(BeNil()) + Expect(repo.Put(&usr)).To(BeNil()) - actual, err := repo.FindByUsernameWithPassword(ctx, "admin") + actual, err := repo.FindByUsernameWithPassword("admin") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Jane Doe")) Expect(actual.Password).To(Equal("wordpass")) }) It("updates password if specified", func() { usr.NewPassword = "newpass" - Expect(repo.Put(ctx, &usr)).To(BeNil()) + Expect(repo.Put(&usr)).To(BeNil()) - actual, err := repo.FindByUsernameWithPassword(ctx, "admin") + actual, err := repo.FindByUsernameWithPassword("admin") Expect(err).ToNot(HaveOccurred()) Expect(actual.Password).To(Equal("newpass")) }) - It("persists and reads back the scrobble filter", func() { - usr := model.User{ID: "u-filter", UserName: "u-filter", Name: "Filter User", - ScrobbleFilter: `{"all":[{"contains":{"title":"????"}}]}`} - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - saved, err := repo.Get(ctx, "u-filter") - Expect(err).ToNot(HaveOccurred()) - Expect(saved.ScrobbleFilter).To(Equal(`{"all":[{"contains":{"title":"????"}}]}`)) - }) - It("reads back a user row inserted without scrobble_filter", func() { - // Guards the column's NOT NULL DEFAULT '': rows predating the migration must stay scannable - _, err := GetDBXBuilder().NewQuery( - "insert into user (id, user_name, name, email, password, created_at, updated_at) " + - "values ('u-rawsql', 'u-rawsql', 'Raw', '', '', datetime('now'), datetime('now'))").Execute() - Expect(err).ToNot(HaveOccurred()) - - saved, err := repo.Get(ctx, "u-rawsql") - Expect(err).ToNot(HaveOccurred()) - Expect(saved.ScrobbleFilter).To(Equal("")) - }) }) Describe("validatePasswordChange", func() { @@ -106,19 +80,19 @@ var _ = Describe("UserRepository", func() { It("does nothing if passwords are not specified", func() { user := &model.User{ID: "2", UserName: "johndoe"} err := validatePasswordChange(user, loggedUser) - Expect(err).ToNot(HaveOccurred()) + Expect(err).To(BeNil()) }) Context("Autogenerated password (used with Reverse Proxy Authentication)", func() { var user model.User BeforeEach(func() { loggedUser.IsAdmin = false - loggedUser.Password = consts.PasswordAutogenPrefix + id.NewRandom() + loggedUser.Password = consts.PasswordAutogenPrefix + uuid.NewString() }) It("does nothing if passwords are not specified", func() { user = *loggedUser err := validatePasswordChange(&user, loggedUser) - Expect(err).ToNot(HaveOccurred()) + Expect(err).To(BeNil()) }) It("does not requires currentPassword for regular user", func() { user = *loggedUser @@ -145,7 +119,7 @@ var _ = Describe("UserRepository", func() { user := &model.User{ID: "2", UserName: "johndoe"} user.NewPassword = "new" err := validatePasswordChange(user, loggedUser) - Expect(err).ToNot(HaveOccurred()) + Expect(err).To(BeNil()) }) It("requires currentPassword to change its own", func() { user := *loggedUser @@ -183,7 +157,7 @@ var _ = Describe("UserRepository", func() { user.CurrentPassword = "abc123" user.NewPassword = "new" err := validatePasswordChange(&user, loggedUser) - Expect(err).ToNot(HaveOccurred()) + Expect(err).To(BeNil()) }) }) @@ -227,79 +201,27 @@ var _ = Describe("UserRepository", func() { user.CurrentPassword = "abc123" user.NewPassword = "new" err := validatePasswordChange(&user, loggedUser) - Expect(err).ToNot(HaveOccurred()) + Expect(err).To(BeNil()) }) }) }) - - Describe("Delete", func() { - It("returns not found for a missing user", func() { - adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) - adminRepo := NewUserRepository(GetDBXBuilder()).(*userRepository) - Expect(adminRepo.Delete(adminCtx, "does-not-exist")).To(MatchError(model.ErrNotFound)) - }) - }) - - Describe("ReadAll name filter", func() { - var adminRepo model.UserRepository - var adminCtx context.Context - - BeforeEach(func() { - adminCtx = request.WithUser(ctx, model.User{ID: "admin-id", UserName: "admin", IsAdmin: true}) - adminRepo = NewUserRepository(GetDBXBuilder()) - - for _, u := range []model.User{ - {ID: "filter-alice", UserName: "alice_filter", Name: "Alice Filter", NewPassword: "x"}, - {ID: "filter-bob", UserName: "bob_filter", Name: "Bob Filter", NewPassword: "x"}, - } { - Expect(adminRepo.Put(adminCtx, &u)).To(Succeed()) - } - }) - - AfterEach(func() { - _ = adminRepo.Delete(adminCtx, "filter-alice", "filter-bob") - }) - - It("matches users whose name starts with the given prefix", func() { - users, err := adminRepo.ReadAll(adminCtx, rest.QueryOptions{Filters: map[string]any{"name": "Alice"}}) - Expect(err).ToNot(HaveOccurred()) - - var names []string - for _, u := range users { - names = append(names, u.Name) - } - Expect(names).To(ContainElement("Alice Filter")) - Expect(names).ToNot(ContainElement("Bob Filter")) - }) - - It("does not match names by mid-string substring (startsWith, not contains)", func() { - users, err := adminRepo.ReadAll(adminCtx, rest.QueryOptions{Filters: map[string]any{"name": "Filter"}}) - Expect(err).ToNot(HaveOccurred()) - - for _, u := range users { - Expect(u.ID).ToNot(Or(Equal("filter-alice"), Equal("filter-bob")), - "a mid-string substring should not match a startsWith filter") - } - }) - }) - Describe("validateUsernameUnique", func() { var repo *tests.MockedUserRepo var existingUser *model.User BeforeEach(func() { existingUser = &model.User{ID: "1", UserName: "johndoe"} repo = tests.CreateMockUserRepo() - err := repo.Put(ctx, existingUser) + err := repo.Put(existingUser) Expect(err).ToNot(HaveOccurred()) }) It("allows unique usernames", func() { var newUser = &model.User{ID: "2", UserName: "unique_username"} - err := validateUsernameUnique(ctx, repo, newUser) + err := validateUsernameUnique(repo, newUser) Expect(err).ToNot(HaveOccurred()) }) It("returns ValidationError if username already exists", func() { var newUser = &model.User{ID: "2", UserName: "johndoe"} - err := validateUsernameUnique(ctx, repo, newUser) + err := validateUsernameUnique(repo, newUser) var verr *rest.ValidationError isValidationError := errors.As(err, &verr) @@ -310,540 +232,8 @@ var _ = Describe("UserRepository", func() { repo.Error = errors.New("fake error") var newUser = &model.User{ID: "2", UserName: "newuser"} - err := validateUsernameUnique(ctx, repo, newUser) + err := validateUsernameUnique(repo, newUser) Expect(err).To(MatchError("fake error")) }) }) - - Describe("Library Association Methods", func() { - var userID string - var library1, library2 model.Library - - BeforeEach(func() { - // Create a test user first to satisfy foreign key constraints - testUser := model.User{ - ID: "test-user-id", - UserName: "testuser", - Name: "Test User", - Email: "test@example.com", - NewPassword: "password", - IsAdmin: false, - } - Expect(repo.Put(ctx, &testUser)).To(BeNil()) - userID = testUser.ID - - library1 = model.Library{ID: 0, Name: "Library 500", Path: "/path/500"} - library2 = model.Library{ID: 0, Name: "Library 501", Path: "/path/501"} - - // Create test libraries - libRepo := NewLibraryRepository(GetDBXBuilder()) - Expect(libRepo.Put(ctx, &library1)).To(BeNil()) - Expect(libRepo.Put(ctx, &library2)).To(BeNil()) - }) - - AfterEach(func() { - // Clean up user-library associations to ensure test isolation - _ = repo.SetUserLibraries(ctx, userID, []int{}) - - // Clean up test libraries to ensure isolation between test groups - libRepo := NewLibraryRepository(GetDBXBuilder()) - _ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}}) - }) - - Describe("GetUserLibraries", func() { - It("returns empty list when user has no library associations", func() { - libraries, err := repo.GetUserLibraries(ctx, "non-existent-user") - Expect(err).ToNot(HaveOccurred()) - Expect(libraries).To(HaveLen(0)) - }) - - It("returns user's associated libraries", func() { - err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID}) - Expect(err).ToNot(HaveOccurred()) - - libraries, err := repo.GetUserLibraries(ctx, userID) - Expect(err).ToNot(HaveOccurred()) - Expect(libraries).To(HaveLen(2)) - - libIDs := []int{libraries[0].ID, libraries[1].ID} - Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) - }) - }) - - Describe("SetUserLibraries", func() { - It("sets user's library associations", func() { - libraryIDs := []int{library1.ID, library2.ID} - err := repo.SetUserLibraries(ctx, userID, libraryIDs) - Expect(err).ToNot(HaveOccurred()) - - libraries, err := repo.GetUserLibraries(ctx, userID) - Expect(err).ToNot(HaveOccurred()) - Expect(libraries).To(HaveLen(2)) - }) - - It("replaces existing associations", func() { - // Set initial associations - err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID}) - Expect(err).ToNot(HaveOccurred()) - - // Replace with just one library - err = repo.SetUserLibraries(ctx, userID, []int{library1.ID}) - Expect(err).ToNot(HaveOccurred()) - - libraries, err := repo.GetUserLibraries(ctx, userID) - Expect(err).ToNot(HaveOccurred()) - Expect(libraries).To(HaveLen(1)) - Expect(libraries[0].ID).To(Equal(library1.ID)) - }) - - It("removes all associations when passed empty slice", func() { - // Set initial associations - err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID}) - Expect(err).ToNot(HaveOccurred()) - - // Remove all - err = repo.SetUserLibraries(ctx, userID, []int{}) - Expect(err).ToNot(HaveOccurred()) - - libraries, err := repo.GetUserLibraries(ctx, userID) - Expect(err).ToNot(HaveOccurred()) - Expect(libraries).To(HaveLen(0)) - }) - }) - }) - - Describe("Admin User Auto-Assignment", func() { - var ( - libRepo model.LibraryRepository - library1 model.Library - library2 model.Library - initialLibCount int - ) - - BeforeEach(func() { - libRepo = NewLibraryRepository(GetDBXBuilder()) - - // Count initial libraries - existingLibs, err := libRepo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - initialLibCount = len(existingLibs) - - library1 = model.Library{ID: 0, Name: "Admin Test Library 1", Path: "/admin/test/path1"} - library2 = model.Library{ID: 0, Name: "Admin Test Library 2", Path: "/admin/test/path2"} - - // Create test libraries - Expect(libRepo.Put(ctx, &library1)).To(BeNil()) - Expect(libRepo.Put(ctx, &library2)).To(BeNil()) - }) - - AfterEach(func() { - // Clean up test libraries and their associations - _ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}}) - - // Clean up user-library associations for these test libraries - _, _ = repo.(*userRepository).executeSQL(ctx, squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}})) - }) - - It("automatically assigns all libraries to admin users when created", func() { - adminUser := model.User{ - ID: "admin-user-id-1", - UserName: "adminuser1", - Name: "Admin User", - Email: "admin1@example.com", - NewPassword: "password", - IsAdmin: true, - } - - err := repo.Put(ctx, &adminUser) - Expect(err).ToNot(HaveOccurred()) - - // Admin should automatically have access to all libraries (including existing ones) - libraries, err := repo.GetUserLibraries(ctx, adminUser.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries - - libIDs := make([]int, len(libraries)) - for i, lib := range libraries { - libIDs[i] = lib.ID - } - Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) - }) - - It("automatically assigns all libraries to admin users when updated", func() { - // Create regular user first - regularUser := model.User{ - ID: "regular-user-id-1", - UserName: "regularuser1", - Name: "Regular User", - Email: "regular1@example.com", - NewPassword: "password", - IsAdmin: false, - } - - err := repo.Put(ctx, ®ularUser) - Expect(err).ToNot(HaveOccurred()) - - // Give them access to just one library - err = repo.SetUserLibraries(ctx, regularUser.ID, []int{library1.ID}) - Expect(err).ToNot(HaveOccurred()) - - // Promote to admin - regularUser.IsAdmin = true - err = repo.Put(ctx, ®ularUser) - Expect(err).ToNot(HaveOccurred()) - - // Should now have access to all libraries (including existing ones) - libraries, err := repo.GetUserLibraries(ctx, regularUser.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries - - libIDs := make([]int, len(libraries)) - for i, lib := range libraries { - libIDs[i] = lib.ID - } - // Should include our test libraries plus all existing ones - Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) - }) - - It("assigns default libraries to regular users", func() { - regularUser := model.User{ - ID: "regular-user-id-2", - UserName: "regularuser2", - Name: "Regular User", - Email: "regular2@example.com", - NewPassword: "password", - IsAdmin: false, - } - - err := repo.Put(ctx, ®ularUser) - Expect(err).ToNot(HaveOccurred()) - - // Regular user should be assigned to default libraries (library ID 1 from migration) - libraries, err := repo.GetUserLibraries(ctx, regularUser.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(libraries).To(HaveLen(1)) - Expect(libraries[0].ID).To(Equal(1)) - Expect(libraries[0].DefaultNewUsers).To(BeTrue()) - }) - }) - - Describe("Libraries Field Population", func() { - var ( - libRepo model.LibraryRepository - library1 model.Library - library2 model.Library - testUser model.User - ) - - BeforeEach(func() { - libRepo = NewLibraryRepository(GetDBXBuilder()) - library1 = model.Library{ID: 0, Name: "Field Test Library 1", Path: "/field/test/path1"} - library2 = model.Library{ID: 0, Name: "Field Test Library 2", Path: "/field/test/path2"} - - // Create test libraries - Expect(libRepo.Put(ctx, &library1)).To(BeNil()) - Expect(libRepo.Put(ctx, &library2)).To(BeNil()) - - // Create test user - testUser = model.User{ - ID: "field-test-user", - UserName: "fieldtestuser", - Name: "Field Test User", - Email: "fieldtest@example.com", - NewPassword: "password", - IsAdmin: false, - } - Expect(repo.Put(ctx, &testUser)).To(BeNil()) - - // Assign libraries to user - Expect(repo.SetUserLibraries(ctx, testUser.ID, []int{library1.ID, library2.ID})).To(BeNil()) - }) - - AfterEach(func() { - // Clean up test libraries and their associations - _ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}}) - _ = repo.(*userRepository).delete(ctx, squirrel.Eq{"id": testUser.ID}) - - // Clean up user-library associations for these test libraries - _, _ = repo.(*userRepository).executeSQL(ctx, squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}})) - }) - - It("populates Libraries field when getting a single user", func() { - user, err := repo.Get(ctx, testUser.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(user.Libraries).To(HaveLen(2)) - - libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID} - Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) - - // Check that library details are properly populated - for _, lib := range user.Libraries { - switch lib.ID { - case library1.ID: - Expect(lib.Name).To(Equal("Field Test Library 1")) - Expect(lib.Path).To(Equal("/field/test/path1")) - case library2.ID: - Expect(lib.Name).To(Equal("Field Test Library 2")) - Expect(lib.Path).To(Equal("/field/test/path2")) - } - } - }) - - It("populates Libraries field when getting all users", func() { - users, err := repo.(*userRepository).GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - - // Find our test user in the results - found := slices.IndexFunc(users, func(u model.User) bool { return u.ID == testUser.ID }) - Expect(found).ToNot(Equal(-1)) - - foundUser := users[found] - Expect(foundUser).ToNot(BeNil()) - Expect(foundUser.Libraries).To(HaveLen(2)) - - libIDs := []int{foundUser.Libraries[0].ID, foundUser.Libraries[1].ID} - Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) - }) - - It("populates Libraries field when finding user by username", func() { - user, err := repo.FindByUsername(ctx, testUser.UserName) - Expect(err).ToNot(HaveOccurred()) - Expect(user.Libraries).To(HaveLen(2)) - - libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID} - Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) - }) - - It("returns default Libraries array for new regular users", func() { - // Create a user with no explicit library associations - should get default libraries - userWithoutLibs := model.User{ - ID: "no-libs-user", - UserName: "nolibsuser", - Name: "No Libs User", - Email: "nolibs@example.com", - NewPassword: "password", - IsAdmin: false, - } - Expect(repo.Put(ctx, &userWithoutLibs)).To(BeNil()) - defer func() { _ = repo.(*userRepository).delete(ctx, squirrel.Eq{"id": userWithoutLibs.ID}) }() - - user, err := repo.Get(ctx, userWithoutLibs.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(user.Libraries).ToNot(BeNil()) - // Regular users should be assigned to default libraries (library ID 1 from migration) - Expect(user.Libraries).To(HaveLen(1)) - Expect(user.Libraries[0].ID).To(Equal(1)) - }) - }) - - Describe("validateScrobbleFilter", func() { - It("accepts an empty filter", func() { - u := &model.User{} - Expect(validateScrobbleFilter(u)).To(Succeed()) - }) - It("trims a whitespace-only filter to empty", func() { - u := &model.User{ScrobbleFilter: " "} - Expect(validateScrobbleFilter(u)).To(Succeed()) - Expect(u.ScrobbleFilter).To(Equal("")) - }) - It("accepts valid criteria JSON", func() { - u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}]}`} - Expect(validateScrobbleFilter(u)).To(Succeed()) - }) - It("rejects malformed JSON", func() { - u := &model.User{ScrobbleFilter: `{not json`} - var vErr *rest.ValidationError - err := validateScrobbleFilter(u) - Expect(errors.As(err, &vErr)).To(BeTrue()) - Expect(vErr.Errors).To(HaveKey("scrobbleFilter")) - }) - It("rejects criteria without rules", func() { - u := &model.User{ScrobbleFilter: `{"sort":"title"}`} - Expect(validateScrobbleFilter(u)).ToNot(Succeed()) - }) - It("rejects selection options that mean nothing for a single track", func() { - for _, f := range []string{ - `{"all":[{"lt":{"rating":4}}],"limit":100}`, - `{"all":[{"lt":{"rating":4}}],"limitPercent":10}`, - `{"all":[{"lt":{"rating":4}}],"offset":5}`, - `{"all":[{"lt":{"rating":4}}],"refreshDelay":"1h"}`, - } { - u := &model.User{ScrobbleFilter: f} - Expect(validateScrobbleFilter(u)).ToNot(Succeed(), f) - } - }) - It("accepts a sort, which cannot change a single-track match", func() { - u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}],"sort":"title"}`} - Expect(validateScrobbleFilter(u)).To(Succeed()) - }) - It("rejects unknown fields", func() { - u := &model.User{ScrobbleFilter: `{"all":[{"is":{"bogusfield":1}}]}`} - Expect(validateScrobbleFilter(u)).ToNot(Succeed()) - }) - }) - - Describe("filters", func() { - It("qualifies id filter with table name", func() { - r := repo.(*userRepository) - qo := r.parseRestOptions(ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}}) - sel := r.selectUserWithLibraries(ctx, qo) - query, _, err := r.toSQL(sel) - Expect(err).NotTo(HaveOccurred()) - Expect(query).To(ContainSubstring("user.id = {:p0}")) - }) - }) - - Describe("token epoch", func() { - var repo model.UserRepository - var usr model.User - - newUser := func() model.User { - uid := id.NewRandom() - // user_name is unique; suffix it so each It gets its own row in the shared suite DB. - return model.User{ID: uid, UserName: "epoch-user-" + uid, Name: "Epoch", NewPassword: "hunter2"} - } - - BeforeEach(func() { - ctx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true}) - repo = NewUserRepository(GetDBXBuilder()) - usr = newUser() - Expect(repo.Put(ctx, &usr)).To(Succeed()) - }) - - It("starts at zero for a new user", func() { - got, err := repo.Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.TokenEpoch).To(Equal(0)) - }) - - It("increments once per password change", func() { - usr.NewPassword = "second" - Expect(repo.Put(ctx, &usr)).To(Succeed()) - got, err := repo.Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.TokenEpoch).To(Equal(1)) - - usr.NewPassword = "third" - Expect(repo.Put(ctx, &usr)).To(Succeed()) - got, err = repo.Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.TokenEpoch).To(Equal(2)) - }) - - It("leaves the epoch alone when the password is untouched", func() { - usr.NewPassword = "" - usr.Name = "Renamed" - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - got, err := repo.Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.TokenEpoch).To(Equal(0)) - Expect(got.Name).To(Equal("Renamed")) - }) - - It("never signals the same epoch to two concurrent password changes", func() { - // Each writer's epoch must be the one its own UPDATE produced. - const callers = 4 - var mu sync.Mutex - var signalled []int - var wg sync.WaitGroup - for range callers { - wg.Go(func() { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, model.User{ID: usr.ID}) - ctx = request.WithTokenEpochHolder(ctx) - own := NewUserRepository(GetDBXBuilder()) - - u := usr - u.NewPassword = "concurrent" - if err := own.Put(ctx, &u); err != nil { - return // the shared in-memory test DB can raise SQLITE_LOCKED - } - epoch, ok := request.TokenEpochFrom(ctx) - if !ok { - return - } - mu.Lock() - defer mu.Unlock() - signalled = append(signalled, epoch) - }) - } - wg.Wait() - - Expect(signalled).To(HaveLen(len(slice.Unique(signalled))), - "an epoch was signalled to more than one writer: %v", signalled) - }) - }) - - Describe("Put and the token epoch", func() { - newRepo := func(actingUserID string) (context.Context, model.UserRepository) { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, model.User{ID: actingUserID, IsAdmin: true}) - ctx = request.WithTokenEpochHolder(ctx) - return ctx, NewUserRepository(GetDBXBuilder()) - } - - It("does not bump when creating a user", func() { - ctx, repo := newRepo("admin") - usr := model.User{ID: id.NewRandom(), UserName: "fresh", NewPassword: "pw1"} - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - got, err := repo.Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.TokenEpoch).To(Equal(0)) - }) - - It("bumps when the password changes", func() { - ctx, repo := newRepo("admin") - usr := model.User{ID: id.NewRandom(), UserName: "changer", NewPassword: "pw1"} - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - usr.NewPassword = "pw2" - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - got, err := repo.Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.TokenEpoch).To(Equal(1)) - }) - - It("does not bump on an edit that leaves the password alone", func() { - ctx, repo := newRepo("admin") - usr := model.User{ID: id.NewRandom(), UserName: "renamer", NewPassword: "pw1"} - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - usr.NewPassword = "" - usr.Name = "New Display Name" - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - got, err := repo.Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(got.TokenEpoch).To(Equal(0)) - }) - - It("signals the new epoch when a user changes their own password", func() { - userID := id.NewRandom() - ctx, repo := newRepo(userID) - usr := model.User{ID: userID, UserName: "self", NewPassword: "pw1"} - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - usr.NewPassword = "pw2" - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - epoch, ok := request.TokenEpochFrom(ctx) - Expect(ok).To(BeTrue()) - Expect(epoch).To(Equal(1)) - }) - - It("does not signal when an admin changes someone else's password", func() { - ctx, repo := newRepo("some-admin") - usr := model.User{ID: id.NewRandom(), UserName: "other", NewPassword: "pw1"} - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - usr.NewPassword = "pw2" - Expect(repo.Put(ctx, &usr)).To(Succeed()) - - _, ok := request.TokenEpochFrom(ctx) - Expect(ok).To(BeFalse()) - }) - }) }) diff --git a/plugins/.gitignore b/plugins/.gitignore deleted file mode 100644 index 5026985e6..000000000 --- a/plugins/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -# Rust build artifacts -# Cargo.lock is not needed for library crates (this is a cdylib) -Cargo.lock -target \ No newline at end of file diff --git a/plugins/README.md b/plugins/README.md deleted file mode 100644 index 7dca3a5f2..000000000 --- a/plugins/README.md +++ /dev/null @@ -1,1321 +0,0 @@ -# Navidrome Plugin System - -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. 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 -- [Plugin Development Kits (PDKs)](https://extism.org/docs/concepts/pdk) – Language-specific libraries for writing plugins -- [Go PDK](https://github.com/extism/go-pdk) – Recommended for Go plugins with TinyGo -- [Rust PDK](https://github.com/extism/rust-pdk) – For Rust plugins -- [Python PDK](https://github.com/extism/python-pdk) – Experimental Python support -- [JavaScript PDK](https://github.com/extism/js-pdk) – For TypeScript/JavaScript plugins - -## Table of Contents - -- [Quick Start](#quick-start) -- [Plugin Basics](#plugin-basics) -- [Capabilities](#capabilities) - - [MetadataAgent](#metadataagent) - - [Scrobbler](#scrobbler) - - [Lyrics](#lyrics) - - [SonicSimilarity](#sonicsimilarity) - - [TaskWorker](#taskworker) - - [Lifecycle](#lifecycle) - - [SchedulerCallback](#schedulercallback) - - [WebSocketCallback](#websocketcallback) -- [Host Services](#host-services) - - [HTTP](#http) - - [Scheduler](#scheduler) - - [Cache](#cache) - - [KVStore](#kvstore) - - [Storage](#storage) - - [Task](#task) - - [WebSocket](#websocket) - - [Library](#library) - - [Matcher](#matcher) - - [Artwork](#artwork) - - [SubsonicAPI](#subsonicapi) - - [Config](#config) - - [Users](#users) - - [ScrobbleRetriever](#scrobbleretriever) -- [Configuration](#configuration) -- [Command Line Interface](#command-line-interface) -- [Building Plugins](#building-plugins) -- [Examples](#examples) -- [Security](#security) - ---- - -## Quick Start - -### 1. Create a minimal plugin - -Create `main.go`: - -```go -package main - -import "github.com/extism/go-pdk" - -func main() {} - -// Implement your capability functions here -``` - -Create `manifest.json`: - -```json -{ - "name": "My Plugin", - "author": "Your Name", - "version": "1.0.0" -} -``` - -### 2. Build with TinyGo and package as .ndp - -```bash -# Compile to WebAssembly -tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared . - -# Package as .ndp (zip archive) -zip -j my-plugin.ndp manifest.json plugin.wasm -``` - -### 3. Install - -Copy `my-plugin.ndp` to your Navidrome plugins folder and enable plugins in your config: - -```toml -[Plugins] -Enabled = true -Folder = "/path/to/plugins" -``` - ---- - -## Plugin Basics - -### What is a Plugin? - -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 Naming - -Plugins are identified by their **filename** (without `.ndp` extension), not the manifest `name` field: - -- `my-plugin.ndp` → plugin ID is `my-plugin` -- The manifest `name` is the display name shown in the UI - -This allows users to have multiple instances of the same plugin with different configs by renaming the files. - -### The Manifest - -Every plugin must include a `manifest.json` file. Example: - -```json -{ - "name": "My Plugin", - "author": "Author Name", - "version": "1.0.0", - "description": "What this plugin does", - "website": "https://example.com", - "config": { - "schema": { ... }, - "uiSchema": { ... } - }, - "permissions": { - "http": { - "reason": "Fetch metadata from external API", - "requiredHosts": ["api.example.com", "*.musicbrainz.org"] - } - } -} -``` - -**Required fields:** `name`, `author`, `version` - -**Optional fields:** `description`, `website`, `config`, `permissions` - -#### 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" } - } - } -} -``` - ---- - -## Capabilities - -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. All methods are **optional** — implement only the ones your data source supports. - -> **Returning "not found".** When you have no data for an item, return an empty response and no -> error. In the Go PDK that is `return nil, nil`. Navidrome reads it as a definitive "not found" -> and stops asking. -> -> Return an error only when the plugin itself failed, such as an unreachable API or a broken host -> call. Navidrome retries failed calls with backoff. A plugin that errors on "no data" makes -> Navidrome retry every item it has no data for. - -| Function | Input | Output | Description | -|-----------------------------------|----------------------------|----------------------------------|--------------------------| -| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID | -| `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: - -```toml -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. All four 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 | -| `nd_scrobbler_playback_report` | See below | (none) | Send playback state report | - -> **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:** - -```json -{ - "permissions": { - "users": { - "reason": "Receive scrobble events for users assigned to this plugin" - } - } -} -``` - -**NowPlaying/Scrobble Input:** - -```json -{ - "username": "john", - "track": { - "id": "track-id", - "title": "Song Title", - "album": "Album Name", - "artist": "Artist Name", - "albumArtist": "Album Artist", - "duration": 180.5, - "trackNumber": 1, - "discNumber": 1, - "mbzRecordingId": "...", - "mbzAlbumId": "...", - "mbzArtistId": "..." - }, - "timestamp": 1703270400 -} -``` - -**PlaybackReport Input:** - -Same `username` and `track` fields, plus playback state details: - -```json -{ - "username": "john", - "track": { ... }, - "state": "playing", - "positionMs": 45000, - "playbackRate": 1.0, - "playerId": "player-id", - "playerName": "My Client", - "timestamp": 1703270400 -} -``` - -`state` is one of `starting`, `playing`, `paused`, `stopped`, or `expired`. - -**Error Handling:** - -On success, return `0`. On failure, use `pdk.SetError()` with one of these error types: - -- `scrobbler(not_authorized)` – User needs to re-authorize -- `scrobbler(retry_later)` – Temporary failure, Navidrome will retry -- `scrobbler(unrecoverable)` – Permanent failure, scrobble discarded - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" - -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. **Required** if your plugin uses the [Task](#task) host service: declaring the `taskqueue` permission without exporting this function fails the plugin load. - -| 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. 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. 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: declaring the `scheduler` permission without exporting this function fails the plugin load. - -| 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 | - -Each callback invocation is subject to a 30-second timeout. - ---- - -## Host Services - -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. - -### Go PDK Setup - -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, with timeouts, redirect control, and protection against reaching private network addresses. This is the only supported way to make HTTP requests: Extism's built-in HTTP is disabled (`pdk.NewHTTPRequest` in Go, `http::request` in Rust, `extism.Http.request` in Python, `Http.request` in JS). - -**Manifest permission:** - -```json -{ - "permissions": { - "http": { - "reason": "Fetch metadata from external API", - "requiredHosts": ["api.example.com", "*.musicbrainz.org"] - } - } -} -``` - -**Private addresses:** the check runs on the resolved IP when connecting. A named host entry (`api.example.com`, `*.example.com`) never authorizes a loopback, private or link-local address on its own, even if its DNS points there. To reach a service on the local network, also list its IP or a CIDR (`192.168.1.10`, `10.0.0.0/8`), or use `"*"` when the user configures the address. Without `requiredHosts`, only public addresses are allowed. - -**Host functions:** - -| Function | Parameters | Returns | -|-------------|----------------------------------------------------------|----------------------------------| -| `http_send` | `method, url, headers, body, timeoutMs, noFollowRedirects` | `statusCode, headers, body` | - -**Limits:** Requests time out after 10 seconds by default (override per request with `timeoutMs`). Redirects are followed up to 5 times, re-checking the allowed hosts on every hop. Response bodies are capped at 10MB. - -**Usage:** - -```go -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 the [`nd_scheduler_callback`](#schedulercallback) function to receive events. - -**Manifest permission:** - -```json -{ - "permissions": { - "scheduler": { - "reason": "Schedule periodic metadata refresh" - } - } -} -``` - -**Host functions:** - -| Function | Parameters | Description | -|-------------------------------|------------------------------------------|-----------------------------| -| `scheduler_scheduleonetime` | `delaySeconds, payload, scheduleId?` | Schedule one-time callback | -| `scheduler_schedulerecurring` | `cronExpression, payload, scheduleId?` | Schedule recurring callback | -| `scheduler_cancelschedule` | `scheduleId` | Cancel a scheduled task | - -**Usage:** - -```go -// Schedule one-time task in 60 seconds -scheduleID, err := host.SchedulerScheduleOneTime(60, "my-payload", "") - -// Schedule recurring task with cron expression (every hour) -scheduleID, err := host.SchedulerScheduleRecurring("0 * * * *", "hourly-task", "") - -// Cancel a task -err := host.SchedulerCancelSchedule(scheduleID) -``` - -### Cache - -In-memory TTL-based cache. Each plugin has its own isolated namespace. Cleared on server restart. - -**Manifest permission:** - -```json -{ - "permissions": { - "cache": { - "reason": "Cache API responses to reduce external requests" - } - } -} -``` - -**Host functions:** - -| Function | Parameters | Description | -|-------------------|---------------------------|-----------------------| -| `cache_setstring` | `key, value, ttl_seconds` | Store a string | -| `cache_getstring` | `key` | Get a string | -| `cache_setint` | `key, value, ttl_seconds` | Store an integer | -| `cache_getint` | `key` | Get an integer | -| `cache_setfloat` | `key, value, ttl_seconds` | Store a float | -| `cache_getfloat` | `key` | Get a float | -| `cache_setbytes` | `key, value, ttl_seconds` | Store bytes | -| `cache_getbytes` | `key` | Get bytes | -| `cache_has` | `key` | Check if key exists | -| `cache_remove` | `key` | Delete a cached value | - -**TTL:** Pass `0` for the default (24 hours), or specify seconds. - -**Usage:** - -```go -// Cache a value for 1 hour -host.CacheSetString("api-response", responseData, 3600) - -// Retrieve (returns value, exists, error) -value, exists, err := host.CacheGetString("api-response") -if exists { - // Use value -} -``` - -### KVStore - -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:** - -```json -{ - "permissions": { - "kvstore": { - "reason": "Store OAuth tokens and plugin state", - "maxSize": "1MB" - } - } -} -``` - -- `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_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) | - -**Usage:** - -```go -// Store a value (as raw bytes) -token := []byte(`{"access_token": "xyz", "refresh_token": "abc"}`) -host.KVStoreSet("oauth:spotify", token) - -// Store with TTL (auto-expires after 1 hour) -host.KVStoreSetWithTTL("session:abc", sessionData, 3600) - -// Retrieve a value -value, exists, err := host.KVStoreGet("oauth:spotify") -if exists { - var tokenData map[string]string - json.Unmarshal(value, &tokenData) -} - -// 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 -usage, err := host.KVStoreGetStorageUsed() -fmt.Printf("Using %d bytes\n", usage) -``` - -### Storage - -A private read-write directory, mounted into the sandbox at `/storage` and backed by `${DataFolder}/plugins/${pluginID}/storage`. Survives server restarts. Use it for data that doesn't fit a key-value store: caches, downloaded files, generated indexes. - -**Manifest permission:** - -```json -{ - "permissions": { - "storage": { - "reason": "Cache generated playlists between restarts" - } - } -} -``` - -**Host functions:** - -| Function | Parameters | Description | -|--------------------------|------------|------------------------------------| -| `storage_getstoragepath` | – | Get the guest path of the mount | - -**Usage:** - -Normal WASI filesystem calls work inside the mount, so use the `os` package directly: - -```go -import ( - "os" - "path/filepath" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" -) - -// The path never changes, so read it once instead of per operation -var storageDir = host.StorageGetStoragePath() // "/storage" - -err := os.WriteFile(filepath.Join(storageDir, "cache.json"), data, 0600) -content, err := os.ReadFile(filepath.Join(storageDir, "cache.json")) -entries, err := os.ReadDir(storageDir) -``` - -> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks that already exist in the directory are still followed, so anything linked in from elsewhere remains reachable. - -> **Note:** There is no size limit, unlike [KVStore](#kvstore). The directory is not deleted when a plugin is uninstalled. - -### 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, delayMs, retentionMs` | 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| - -Tasks are persisted to SQLite, so pending tasks survive server restarts. Queue behavior: - -- `concurrency` – Parallel workers (default 1), capped by the manifest's `maxConcurrency` -- `maxRetries` – Retries for a failed task (default 0); `backoffMs` (default 1000) doubles on each retry -- `delayMs` – Minimum delay between consecutive task starts, useful for rate limiting (default 0) -- `retentionMs` – How long finished tasks are kept (default 1 hour, min 1 minute, max 1 week) -- Payloads are capped at 1MB - -**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. Your plugin must export [WebSocketCallback](#websocketcallback) functions to receive events. - -**Manifest permission:** - -```json -{ - "permissions": { - "websocket": { - "reason": "Real-time connection to service", - "requiredHosts": ["gateway.example.com", "*.discord.gg"] - } - } -} -``` - -`requiredHosts` is mandatory here: leave it out and every connection is blocked. Unlike HTTP, there is no fallback to public addresses. Entries follow the same [private address rules](#http). - -**Host functions:** - -| Function | Parameters | Description | -|----------------------------|---------------------------------|-------------------| -| `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 | - -**Usage:** - -```go -connID, err := host.WebSocketConnect("wss://gateway.example.com", nil, "") -host.WebSocketSendText(connID, `{"op": 1, "d": null}`) -host.WebSocketCloseConnection(connID, 1000, "done") -``` - -### Library - -Access music library metadata and optionally read files from library directories. - -**Manifest permission:** - -```json -{ - "permissions": { - "library": { - "reason": "Access library metadata for analysis", - "filesystem": false - } - } -} -``` - -- `filesystem` – Set to `true` to enable access to library directories, read-only unless an administrator grants write access (default: `false`) - -**Host functions:** - -| Function | Parameters | Returns | -|----------------------------|------------|---------------------------| -| `library_getlibrary` | `id` | Library metadata | -| `library_getalllibraries` | (none) | Array of library metadata | - -**Library metadata:** - -```json -{ - "id": 1, - "name": "My Music", - "path": "/music/collection", - "mountPoint": "/libraries/1", - "lastScanAt": 1703270400, - "totalSongs": 5000, - "totalAlbums": 500, - "totalArtists": 200, - "totalSize": 50000000000, - "totalDuration": 1500000.5 -} -``` - -> **Note:** The `path` and `mountPoint` fields are only included when `filesystem: true` is set in the permission. - -**Filesystem access:** - -When `filesystem: true`, your plugin can read files from library directories via WASI filesystem APIs. Each library is mounted at `/libraries/`: - -```go -import "os" - -content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3") -entries, err := os.ReadDir("/libraries/1/Artist") -``` - -> **Security:** Plugins cannot create symlinks inside the mount, and `..` or absolute paths are rejected. Symlinks already present in the library are still followed, so folders linked in from elsewhere work as expected. Access is read-only unless an administrator grants the plugin write access (`navidrome plugin edit --write-access`). - -**Usage:** - -```go -// Get a specific library -library, err := host.LibraryGetLibrary(1) -fmt.Printf("Library: %s (%d songs)\n", library.Name, library.TotalSongs) - -// Get all libraries -libraries, err := host.LibraryGetAllLibraries() -for _, lib := range libraries { - fmt.Printf("Library: %s (%d songs)\n", lib.Name, lib.TotalSongs) -} -``` - -### Matcher - -Match externally-obtained songs (e.g. results from a recommendation or similarity API) to tracks in the local library, reusing Navidrome's matching algorithm (ID > MBID > ISRC > fuzzy title). - -**Manifest permission:** - -```json -{ - "permissions": { - "matcher": { - "reason": "Resolve external recommendations to library tracks" - }, - "library": { - "reason": "Required by the matcher permission" - } - } -} -``` - -> **Important:** The `matcher` permission requires the `library` permission. - -**Host functions:** - -| Function | Parameters | Returns | -|----------------------|---------------|-------------------------| -| `matcher_matchsongs` | `songs, opts` | Array of matched tracks | - -The result has one entry per input song, in the same order; the entry for a song with no match is empty. Results are limited to the libraries the plugin (and the scoped user, if any) can access. Set `opts.username` to run the match as a specific user: their favorites and ratings inform tiebreaking, and the returned tracks carry their annotations. User scoping additionally requires the [`users`](#users) permission, with users assigned to the plugin. - -**Usage:** - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/types" - -matches, err := host.MatcherMatchSongs([]types.SongRef{ - {Name: "Song Title", Artists: []types.ArtistRef{{Name: "Artist Name"}}}, -}, host.MatchOptions{}) -``` - -### Artwork - -Generate public URLs for Navidrome artwork (albums, artists, tracks, playlists). - -**Manifest permission:** - -```json -{ - "permissions": { - "artwork": { - "reason": "Get artwork URLs for display" - } - } -} -``` - -**Host functions:** - -| Function | Parameters | Returns | -|--------------------------|------------|-------------| -| `artwork_getartisturl` | `id, size` | Artwork URL | -| `artwork_getalbumurl` | `id, size` | Artwork URL | -| `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). - -**Manifest permission:** - -```json -{ - "permissions": { - "subsonicapi": { - "reason": "Access library data" - }, - "users": { - "reason": "Access user information for SubsonicAPI authorization" - } - } -} -``` - -> **Important:** The `subsonicapi` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI. - -**Host functions:** - -| Function | Parameters | Returns | -|-----------------------|------------|--------------------------------| -| `subsonicapi_call` | `uri` | JSON response string | -| `subsonicapi_callraw` | `uri` | Content type + binary response | - -**Usage:** - -```go -// 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. 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. - -**Host functions:** - -| Function | Parameters | Returns | -|-----------------|------------|-----------------------------| -| `config_get` | `key` | `value, exists` | -| `config_getint` | `key` | `value, exists` | -| `config_keys` | `prefix` | Array of matching key names | - -**Usage:** - -```go -// Get a configuration value -value, exists := host.ConfigGet("api_key") - -// 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:") - -// List all configuration keys -allKeys := host.ConfigKeys("") -``` - -### Users - -Access user information for the users that the plugin has been granted access to. - -**Manifest permission:** - -```json -{ - "permissions": { - "users": { - "reason": "Display user information in status updates" - } - } -} -``` - -**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 - -If neither option is configured, the plugin cannot be enabled. - -**Host functions:** - -| Function | Parameters | Returns | -|------------------|------------|-----------------------| -| `users_getusers` | – | Array of User objects | -| `users_getadmins`| – | Array of admin Users | - -**User object fields:** - -| Field | Type | Description | -|------------|---------|--------------------------------| -| `userName` | string | The user's unique username | -| `name` | string | The user's display name | -| `isAdmin` | boolean | Whether the user is an admin | - -> **Security:** Sensitive fields like passwords, email addresses, and internal IDs are never exposed to plugins. - -**Usage:** - -```go -users, err := host.UsersGetUsers() -for _, user := range users { - pdk.Log(pdk.LogInfo, "User: " + user.UserName + " (" + user.Name + ")") -} - -admins, err := host.UsersGetAdmins() -``` - -### ScrobbleRetriever - -Retrieve the scrobble history of users the plugin has been granted access to. Each scrobble carries only the media file ID and the submission time; use the Matcher host service to resolve them to track metadata. - -**Manifest permission:** - -```json -{ - "permissions": { - "scrobbleRetriever": { - "reason": "Sync scrobble history to an external service" - }, - "users": { - "reason": "Access user information for scrobble retrieval" - } - } -} -``` - -> **Important:** The `scrobbleRetriever` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI. - -**Host functions:** - -| Function | Parameters | Returns | -|---------------------------------------|-----------------------|----------------------------------| -| `scrobbleretriever_getfirsttimestamp` | `username` | Unix timestamp of oldest scrobble, or null | -| `scrobbleretriever_getlasttimestamp` | `username` | Unix timestamp of newest scrobble, or null | -| `scrobbleretriever_getscrobbles` | `username`, `options` | One page of scrobbles + options for the next page | -| `scrobbleretriever_getscrobblecount` | `username`, `options` | Number of scrobbles in the range | - -**ScrobbleOptions fields** (all optional): - -| Field | Type | Description | -|-----------------|---------|----------------------------------------------------------| -| `fromTimestamp` | int64 | Start of the range (inclusive). Default: first scrobble | -| `toTimestamp` | int64 | End of the range (inclusive). Default: last scrobble | -| `descending` | boolean | Newest first. Default: oldest first | -| `maxItems` | int | Page size, capped at 5000 (the default) | -| `offset` | int | Managed by the host for pagination. Never set it manually | - -**ScrobbleRef fields:** - -| Field | Type | Description | -|------------------|--------|------------------------------------------------| -| `id` | int64 | Scrobble ID, unique even for duplicate submissions | -| `mediaFileId` | string | The media file that was scrobbled | -| `submissionTime` | int64 | Unix timestamp of the submission | - -**Usage:** - -`GetScrobbles` returns one page plus the options to fetch the following page. Pass them back unchanged and repeat until they are nil: - -```go -opts := host.ScrobbleOptions{MaxItems: 500} -var all []host.ScrobbleRef -for { - page, next, err := host.ScrobbleRetrieverGetScrobbles("username", opts) - if err != nil { - return err - } - all = append(all, page...) - if next == nil { - break // no more scrobbles - } - opts = *next -} - -// Range boundaries and counts -first, err := host.ScrobbleRetrieverGetFirstTimestamp("username") // nil if no scrobbles -count, err := host.ScrobbleRetrieverGetScrobbleCount("username", host.ScrobbleCountOptions{ - FromTimestamp: first, -}) -``` - -> **Note:** The returned `next` options carry an adjusted `fromTimestamp`/`toTimestamp`, so keep a copy of your original options if you still need the range. - ---- - -## Configuration - -### Server Configuration - -Enable plugins in `navidrome.toml`: - -```toml -[Plugins] -Enabled = true -Folder = "/path/to/plugins" # Default: DataFolder/plugins -AutoReload = true # Auto-reload on file changes (dev mode) -LogLevel = "debug" # Plugin-specific log level -CacheSize = "200MB" # Compilation cache size limit -``` - -### Plugin Configuration - -Plugin configuration is managed through the Navidrome web UI. Navigate to the Plugins page, select a plugin, and edit its configuration as key-value pairs. - -Access configuration values in your plugin: - -```go -apiKey, ok := pdk.GetConfig("api_key") -if !ok { - pdk.SetErrorString("api_key configuration is required") - return 1 -} -``` - -For more advanced access (listing keys, integer values), use the [Config](#config) host service. - ---- - -## Command Line Interface - -Manage plugins from the command line with `navidrome plugin`: - -| Command | Description | -|--------------------------------------------------------|------------------------------------------------------------| -| `navidrome plugin list [-f table\|csv\|json]` | List installed plugins | -| `navidrome plugin info [-f text\|json]` | Show details for an installed plugin or a `.ndp` package | -| `navidrome plugin validate ` | Validate an installed plugin or a `.ndp` package manifest | -| `navidrome plugin enable ` | Enable a plugin | -| `navidrome plugin disable ` | Disable a plugin | -| `navidrome plugin edit ` | Update a plugin's config and/or permissions | -| `navidrome plugin rescan` | Re-discover plugins in the plugins folder | - -**`plugin edit` flags:** - -- `--config ` / `--config-file ` – Set the plugin configuration (`-` reads from stdin) -- `--users ` / `--all-users` – Usernames the plugin may access (comma-separated or JSON array), or all users -- `--libraries ` / `--all-libraries` – Library IDs the plugin may access (comma-separated or JSON array), or all libraries -- `--write-access` / `--no-write-access` – Allow or deny the plugin write access to libraries - ---- - -## Building Plugins - -### Supported Languages - -Plugins can be written in any language that Extism supports. We recommend: - -- **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) - -```bash -# Install TinyGo: https://tinygo.org/getting-started/install/ - -# Build WebAssembly module -tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared . - -# Package as .ndp -zip -j my-plugin.ndp manifest.json plugin.wasm -``` - -#### Using Go PDK Packages - -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" - -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() {} -``` - -Add to your `go.mod`: - -``` -require github.com/navidrome/navidrome v0.0.0 -replace github.com/navidrome/navidrome => ../../.. -``` - -**Available capability packages:** - -| 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) | -| `types` | `plugins/pdk/go/types` | Shared data types (tracks, artists, song refs) | -| `pdk` | `plugins/pdk/go/pdk` | Low-level helpers (wraps extism/go-pdk: config, logging, memory) | - -See the example plugins in [examples/](examples/) for complete usage patterns. - -### Rust - -```bash -# Build WebAssembly module -cargo build --release --target wasm32-wasip1 - -# Package as .ndp -zip -j my-plugin.ndp manifest.json target/wasm32-wasip1/release/plugin.wasm -``` - -#### Using Rust PDK - -```toml -# Cargo.toml -[dependencies] -nd-pdk = { path = "../../pdk/rust/nd-pdk" } -extism-pdk = "1.2" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -``` - -**Implementing capabilities with traits and macros:** - -```rust -use nd_pdk::scrobbler::{Scrobbler, IsAuthorizedRequest, Error}; -use nd_pdk::register_scrobbler; - -#[derive(Default)] -struct MyPlugin; - -impl Scrobbler for MyPlugin { - fn is_authorized(&self, req: IsAuthorizedRequest) -> Result { - Ok(true) - } - fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error> { Ok(()) } - fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error> { Ok(()) } -} - -register_scrobbler!(MyPlugin); // Generates all WASM exports -``` - -**Using host services:** - -```rust -use nd_pdk::host::{cache, scheduler, library}; - -cache::set_string("my_key", "my_value", 3600)?; -scheduler::schedule_recurring("@every 5m", "payload", "task_id")?; -let libs = library::get_all_libraries()?; -``` - -See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation. - -### Python (with extism-py) - -```bash -# Build WebAssembly module (requires extism-py installed) -extism-py plugin.wasm -o plugin.wasm *.py - -# Package as .ndp -zip -j my-plugin.ndp manifest.json plugin.wasm -``` - -There is no Python PDK, so call host services directly: import them from the `extism:host/user` namespace (e.g. `http_send`) with `@extism.import_fn`, and exchange JSON through Extism memory. Each function takes a JSON request and returns a JSON response with an `error` field on failure. For HTTP, send `{"request": {"method": "GET", "url": "..."}}` and read `result.statusCode` and `result.body` (base64). See [coverartarchive-py](examples/coverartarchive-py/) and [nowplaying-py](examples/nowplaying-py/). - -### Using XTP CLI (Scaffolding) - -Bootstrap a new plugin from a schema: - -```bash -# Install XTP CLI: https://docs.xtp.dylibso.com/docs/cli - -# Create a metadata agent plugin -xtp plugin init \ - --schema-file plugins/capabilities/metadata_agent.yaml \ - --template go \ - --path ./my-agent \ - --name my-agent - -# Build and package -cd my-agent && xtp plugin build -zip -j my-agent.ndp manifest.json dist/plugin.wasm -``` - -See [capabilities/README.md](capabilities/README.md) for available schemas and scaffolding examples. - ---- - -## 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, SchedulerCallback | Scheduler, SubsonicAPI | Periodic now-playing logger | -| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle, SchedulerCallback | Library, Scheduler | Periodic library stats logging | -| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle, SchedulerCallback, WebSocketCallback | WebSocket, Scheduler | Real-time crypto prices demo | -| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler, SchedulerCallback, WebSocketCallback | HTTP, WebSocket, Cache, Scheduler, Artwork, Config | 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 cannot create symlinks inside a mount and `..` or absolute paths are rejected, though symlinks already present are followed. Library access requires the `library.filesystem` permission and is read-only unless an administrator grants write access; the `storage` permission grants a read-write directory private to the plugin -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`, `scrobbleRetriever`, 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 - -### Auto-Reload - -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 `AutoReload` is disabled, Navidrome needs to be restarted to pick up plugin changes. - -### Enabling/Disabling Plugins - -Plugins can be enabled/disabled via the Navidrome UI or the [`navidrome plugin` CLI](#command-line-interface). The plugin state is persisted in the database. - -### Important Notes - -- **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 \ No newline at end of file diff --git a/plugins/capabilities.go b/plugins/capabilities.go deleted file mode 100644 index b86475028..000000000 --- a/plugins/capabilities.go +++ /dev/null @@ -1,68 +0,0 @@ -package plugins - -import ( - "regexp" - "slices" - - "github.com/navidrome/navidrome/core/agents" -) - -// Capability represents a plugin capability type. -// Capabilities are detected by checking which functions a plugin exports. -type Capability string - -// capabilityFunctions maps each capability to its required/optional functions. -// A plugin has a capability if it exports at least one of these functions. -var capabilityFunctions = map[Capability][]string{} - -// registerCapability registers a capability with its associated functions. -func registerCapability(cap Capability, functions ...string) { - capabilityFunctions[cap] = functions -} - -// functionExistsChecker is an interface for checking if a function exists in a plugin. -// This allows for testing without a real plugin instance. -type functionExistsChecker interface { - FunctionExists(name string) bool -} - -// detectCapabilities detects which capabilities a plugin has by checking -// which functions it exports. -func detectCapabilities(plugin functionExistsChecker) []Capability { - var capabilities []Capability - - for cap, functions := range capabilityFunctions { - if slices.ContainsFunc(functions, plugin.FunctionExists) { - capabilities = append(capabilities, cap) // Found at least one function, plugin has this capability - } - } - - return capabilities -} - -// hasCapability checks if the given capabilities slice contains a specific capability. -func hasCapability(capabilities []Capability, cap Capability) bool { - return slices.Contains(capabilities, cap) -} - -// retryLaterRe matches one capability's `(retry_later[:seconds])` token, which is -// all a plugin fault carries back across the WASM boundary. The capability is part of the -// pattern, so another capability's token in the same message cannot mask this one. The leading -// \b keeps a superstring like `useragent(retry_later)` from matching `agent`. -var ( - agentRetryLaterRe = retryLaterRe("agent") - scrobblerRetryLaterRe = retryLaterRe("scrobbler") -) - -func retryLaterRe(capability string) *regexp.Regexp { - return regexp.MustCompile(`\b` + capability + `\(retry_later(?::(\d+))?\)`) -} - -// parseRetryLater reports whether msg carries the capability's retry_later token, with its delay. -func parseRetryLater(re *regexp.Regexp, msg string) (*agents.RetryLaterError, bool) { - m := re.FindStringSubmatch(msg) - if m == nil { - return nil, false - } - return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(m[1])}, true -} diff --git a/plugins/capabilities/README.md b/plugins/capabilities/README.md deleted file mode 100644 index 2ad4a82da..000000000 --- a/plugins/capabilities/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# Navidrome Plugin Capabilities - -This directory contains the Go interface definitions for Navidrome plugin capabilities. These interfaces are the **source of truth** for plugin development and are used to generate: - -1. **Go PDK packages** (`pdk/go/*/`) - Type-safe wrappers for Go plugin developers -2. **Rust PDK crates** (`pdk/rust/*/`) - Type-safe wrappers for Rust plugin developers -3. **XTP YAML schemas** (`*.yaml`) - Schema files for other [Extism plugin languages](https://extism.org/docs/concepts/pdk/) (TypeScript, Python, C#, Zig, C++, ...) - -## For Go Plugin Developers - -Go developers should use the generated PDK packages in `plugins/pdk/go/`. See the example Go plugins in `plugins/examples/` for usage patterns. - -## For Rust Plugin Developers - -Rust developers should use the generated PDK crate in `plugins/pdk/rust/nd-pdk`. See the example Rust plugins in `plugins/examples` for usage patterns. - -## For Non-Go Plugin Developers - -If you're developing plugins in other languages (TypeScript, Rust, Python, C#, Zig, C++), you can use the XTP CLI to generate type-safe bindings from the YAML schema files in this directory. - -### Prerequisites - -Install the XTP CLI: - -```bash -# macOS -brew install dylibso/tap/xtp - -# Other platforms - see https://docs.xtp.dylibso.com/docs/cli -curl https://static.dylibso.com/cli/install.sh | bash -``` - -### Generating Plugin Scaffolding - -Use the XTP CLI to generate plugin boilerplate from any capability schema: - -```bash -# TypeScript -xtp plugin init --schema-file plugins/capabilities/metadata_agent.yaml \ - --template typescript --path my-plugin - -# Rust -xtp plugin init --schema-file plugins/capabilities/scrobbler.yaml \ - --template rust --path my-plugin - -# Python -xtp plugin init --schema-file plugins/capabilities/lifecycle.yaml \ - --template python --path my-plugin - -# C# -xtp plugin init --schema-file plugins/capabilities/scheduler_callback.yaml \ - --template csharp --path my-plugin - -# Go (alternative to using the PDK packages) -xtp plugin init --schema-file plugins/capabilities/websocket_callback.yaml \ - --template go --path my-plugin -``` - -### Available Capabilities - -| Capability | Schema File | Description | -|--------------------|---------------------------|-------------------------------------------------------------| -| Metadata Agent | `metadata_agent.yaml` | Fetch artist biographies, album images, and similar artists | -| Scrobbler | `scrobbler.yaml` | Report listening activity to external services | -| Lifecycle | `lifecycle.yaml` | Plugin initialization callbacks | -| Scheduler Callback | `scheduler_callback.yaml` | Scheduled task execution | -| WebSocket Callback | `websocket_callback.yaml` | Real-time WebSocket message handling | - -### Building Your Plugin - -After generating the scaffolding, implement the required functions and build your plugin as a WebAssembly module. The exact build process depends on your chosen language - see the [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for language-specific guides. - -## XTP Schema Generation - -The YAML schemas in this package are automatically generated from the capability Go interfaces using `ndpgen`. -To regenerate the schemas after modifying the interfaces, run: - -```bash -cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types -``` - -## Resources - -- [XTP Documentation](https://docs.xtp.dylibso.com/) -- [XTP Bindgen Repository](https://github.com/dylibso/xtp-bindgen) -- [Extism Plugin Development Kit](https://extism.org/docs/concepts/pdk) -- [XTP Schema Definition](https://raw.githubusercontent.com/dylibso/xtp-bindgen/5090518dd86ba5e734dc225a33066ecc0ed2e12d/plugin/schema.json) diff --git a/plugins/capabilities/doc.go b/plugins/capabilities/doc.go deleted file mode 100644 index fa9b7eb5d..000000000 --- a/plugins/capabilities/doc.go +++ /dev/null @@ -1,56 +0,0 @@ -// Package capabilities defines Go interfaces for Navidrome plugin capabilities. -// -// These interfaces serve as the source of truth for capability definitions. -// The ndpgen tool generates: -// - Go export wrappers in plugins/pdk/go// for Go plugins -// - XTP YAML schemas for non-Go plugins (Rust, TypeScript, etc.) -// -// Each capability is defined as an annotated interface: -// -// //nd:capability name=metadata -// type MetadataAgent interface { -// //nd:export name=nd_get_artist_biography -// GetArtistBiography(ArtistRequest) (*ArtistBiographyResponse, error) -// } -// -// Annotation Reference: -// -// //nd:capability name= [required=true] -// - Marks an interface as a capability -// - name: Generated package name (e.g., name=metadata → pdk/go/metadata/) -// - required: If true, all methods must be implemented (default: false) -// -// //nd:export name= -// - Marks a method as an exported WASM function -// - name: The export name (e.g., nd_get_artist_biography) -// -// Generated Code Structure: -// -// For a capability like MetadataAgent with required=false: -// -// package metadata -// -// // Agent is the marker interface -// type Agent interface{} -// -// // Optional provider interfaces -// type ArtistBiographyProvider interface { -// GetArtistBiography(ArtistRequest) (*ArtistBiographyResponse, error) -// } -// -// // Registration function -// func Register(impl Agent) { ... } -// -// For a capability with required=true: -// -// package scrobbler -// -// // Scrobbler requires all methods -// type Scrobbler interface { -// IsAuthorized(IsAuthorizedRequest) (bool, error) -// NowPlaying(NowPlayingRequest) error -// Scrobble(ScrobbleRequest) error -// } -// -// func Register(impl Scrobbler) { ... } -package capabilities diff --git a/plugins/capabilities/lifecycle.go b/plugins/capabilities/lifecycle.go deleted file mode 100644 index b5f19ec5b..000000000 --- a/plugins/capabilities/lifecycle.go +++ /dev/null @@ -1,19 +0,0 @@ -package capabilities - -// Lifecycle provides plugin lifecycle hooks. -// This capability allows plugins to perform initialization when loaded, -// such as establishing connections, starting background processes, or -// validating configuration. -// -// The OnInit function is called once when the plugin is loaded, and is NOT -// called when the plugin is hot-reloaded. Plugins should not assume this -// function will be called on every startup. -// -//nd:capability name=lifecycle -type Lifecycle interface { - // OnInit is called after a plugin is fully loaded with all services registered. - // Plugins can use this function to perform one-time initialization tasks. - // Errors are logged but will not prevent the plugin from being loaded. - //nd:export name=nd_on_init - OnInit() error -} diff --git a/plugins/capabilities/lifecycle.yaml b/plugins/capabilities/lifecycle.yaml deleted file mode 100644 index 7c6af62b8..000000000 --- a/plugins/capabilities/lifecycle.yaml +++ /dev/null @@ -1,7 +0,0 @@ -version: v1-draft -exports: - nd_on_init: - description: |- - OnInit is called after a plugin is fully loaded with all services registered. - Plugins can use this function to perform one-time initialization tasks. - Errors are logged but will not prevent the plugin from being loaded. diff --git a/plugins/capabilities/lyrics.go b/plugins/capabilities/lyrics.go deleted file mode 100644 index 6f6d19177..000000000 --- a/plugins/capabilities/lyrics.go +++ /dev/null @@ -1,26 +0,0 @@ -package capabilities - -// Lyrics provides lyrics for a given track from external sources. -// -//nd:capability name=lyrics required=true -type Lyrics interface { - //nd:export name=nd_lyrics_get_lyrics - GetLyrics(GetLyricsRequest) (GetLyricsResponse, error) -} - -// GetLyricsRequest contains the track information for lyrics lookup. -type GetLyricsRequest struct { - Track TrackInfo `json:"track"` -} - -// GetLyricsResponse contains the lyrics returned by the plugin. -type GetLyricsResponse struct { - Lyrics []LyricsText `json:"lyrics"` -} - -// LyricsText represents a single set of lyrics in raw text format. -// Text can be plain text or LRC format — Navidrome will parse it. -type LyricsText struct { - Lang string `json:"lang,omitempty"` - Text string `json:"text"` -} diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml deleted file mode 100644 index 7336124fc..000000000 --- a/plugins/capabilities/lyrics.yaml +++ /dev/null @@ -1,140 +0,0 @@ -version: v1-draft -exports: - nd_lyrics_get_lyrics: - input: - $ref: '#/components/schemas/GetLyricsRequest' - contentType: application/json - output: - $ref: '#/components/schemas/GetLyricsResponse' - contentType: application/json -components: - schemas: - GetLyricsRequest: - description: GetLyricsRequest contains the track information for lyrics lookup. - properties: - track: - $ref: '#/components/schemas/TrackInfo' - required: - - track - GetLyricsResponse: - description: GetLyricsResponse contains the lyrics returned by the plugin. - properties: - lyrics: - type: array - items: - $ref: '#/components/schemas/LyricsText' - required: - - lyrics - LyricsText: - description: |- - LyricsText represents a single set of lyrics in raw text format. - Text can be plain text or LRC format — Navidrome will parse it. - properties: - lang: - type: string - text: - type: string - required: - - text - TrackInfo: - description: TrackInfo contains track metadata. - properties: - id: - type: string - description: ID is the internal Navidrome track ID. - title: - type: string - description: Title is the track title. - album: - type: string - description: Album is the album name. - artist: - type: string - description: Artist is the formatted artist name for display (e.g., "Artist1 • Artist2"). - albumArtist: - type: string - description: AlbumArtist is the formatted album artist name for display. - artists: - type: array - description: Artists is the list of track artists. - items: - $ref: '#/components/schemas/ArtistRef' - albumArtists: - type: array - description: AlbumArtists is the list of album artists. - items: - $ref: '#/components/schemas/ArtistRef' - duration: - type: number - format: float - description: Duration is the track duration in seconds. - trackNumber: - type: integer - format: int32 - description: TrackNumber is the track number on the album. - discNumber: - type: integer - format: int32 - description: DiscNumber is the disc number. - mbzRecordingId: - type: string - description: MBZRecordingID is the MusicBrainz recording ID. - mbzAlbumId: - type: string - description: MBZAlbumID is the MusicBrainz album/release ID. - mbzReleaseGroupId: - type: string - description: MBZReleaseGroupID is the MusicBrainz release group ID. - 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 - - album - - artist - - albumArtist - - artists - - albumArtists - - duration - - trackNumber - - discNumber - ArtistRef: - description: |- - ArtistRef is the minimal information a plugin returns for Navidrome to match an - artist against the library. It is a reference, not a full artist entity: it - carries only matching keys (name and optional internal/MusicBrainz IDs) plus a - few projection fields used when describing a track's participants, never - descriptive data such as biographies or images. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - sortName: - type: string - description: SortName is the artist name used for sorting (if known). - role: - type: string - description: Role is the participation category (e.g. "artist", "composer", "performer"). - subRole: - type: string - description: SubRole is a specialization within Role (e.g. the instrument for a performer). - required: - - name diff --git a/plugins/capabilities/metadata_agent.go b/plugins/capabilities/metadata_agent.go deleted file mode 100644 index 8fec7f5a8..000000000 --- a/plugins/capabilities/metadata_agent.go +++ /dev/null @@ -1,238 +0,0 @@ -package capabilities - -import "github.com/navidrome/navidrome/plugins/types" - -// MetadataAgent provides artist and album metadata retrieval. -// This capability allows plugins to provide external metadata for artists and albums, -// such as biographies, images, similar artists, and top songs. -// -// Plugins implementing this capability can choose which methods to implement. -// Each method is optional - plugins only need to provide the functionality they support. -// -// To say "no data for this item", return a nil response and a nil error. Return an error only when -// the plugin itself failed, because Navidrome retries failed calls with backoff. -// -//nd:capability name=metadata -type MetadataAgent interface { - // GetArtistMBID retrieves the MusicBrainz ID for an artist. - //nd:export name=nd_get_artist_mbid - GetArtistMBID(ArtistMBIDRequest) (*ArtistMBIDResponse, error) - - // GetArtistURL retrieves the external URL for an artist. - //nd:export name=nd_get_artist_url - GetArtistURL(ArtistRequest) (*ArtistURLResponse, error) - - // GetArtistBiography retrieves the biography for an artist. - //nd:export name=nd_get_artist_biography - GetArtistBiography(ArtistRequest) (*ArtistBiographyResponse, error) - - // GetSimilarArtists retrieves similar artists for a given artist. - //nd:export name=nd_get_similar_artists - GetSimilarArtists(SimilarArtistsRequest) (*SimilarArtistsResponse, error) - - // GetArtistImages retrieves images for an artist. - //nd:export name=nd_get_artist_images - GetArtistImages(ArtistRequest) (*ArtistImagesResponse, error) - - // GetArtistTopSongs retrieves top songs for an artist. - //nd:export name=nd_get_artist_top_songs - GetArtistTopSongs(TopSongsRequest) (*TopSongsResponse, error) - - // GetAlbumInfo retrieves album information. - //nd:export name=nd_get_album_info - GetAlbumInfo(AlbumRequest) (*AlbumInfoResponse, error) - - // GetAlbumImages retrieves images for an album. - //nd:export name=nd_get_album_images - GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error) - - // GetSimilarSongsByTrack retrieves songs similar to a specific track. - //nd:export name=nd_get_similar_songs_by_track - GetSimilarSongsByTrack(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error) - - // GetSimilarSongsByAlbum retrieves songs similar to tracks on an album. - //nd:export name=nd_get_similar_songs_by_album - GetSimilarSongsByAlbum(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error) - - // GetSimilarSongsByArtist retrieves songs similar to an artist's catalog. - //nd:export name=nd_get_similar_songs_by_artist - GetSimilarSongsByArtist(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error) -} - -// ArtistMBIDRequest is the request for GetArtistMBID. -type ArtistMBIDRequest struct { - // ID is the internal Navidrome artist ID. - ID string `json:"id"` - // Name is the artist name. - Name string `json:"name"` -} - -// ArtistMBIDResponse is the response for GetArtistMBID. -type ArtistMBIDResponse struct { - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid"` -} - -// ArtistRequest is the common request for artist-related functions. -type ArtistRequest struct { - // ID is the internal Navidrome artist ID. - ID string `json:"id"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist (if known). - MBID string `json:"mbid,omitempty"` -} - -// ArtistURLResponse is the response for GetArtistURL. -type ArtistURLResponse struct { - // URL is the external URL for the artist. - URL string `json:"url"` -} - -// ArtistBiographyResponse is the response for GetArtistBiography. -type ArtistBiographyResponse struct { - // Biography is the artist biography text. - Biography string `json:"biography"` -} - -// SimilarArtistsRequest is the request for GetSimilarArtists. -type SimilarArtistsRequest struct { - // ID is the internal Navidrome artist ID. - ID string `json:"id"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist (if known). - MBID string `json:"mbid,omitempty"` - // Limit is the maximum number of similar artists to return. - Limit int32 `json:"limit"` -} - -// Deprecated: use types.ArtistRef. -type ArtistRef = types.ArtistRef - -// SimilarArtistsResponse is the response for GetSimilarArtists. -type SimilarArtistsResponse struct { - // Artists is the list of similar artists. - Artists []types.ArtistRef `json:"artists"` -} - -// ImageInfo represents an image with URL and size. -type ImageInfo struct { - // URL is the URL of the image. - URL string `json:"url"` - // Size is the size of the image in pixels (width or height). - Size int32 `json:"size"` -} - -// ArtistImagesResponse is the response for GetArtistImages. -type ArtistImagesResponse struct { - // Images is the list of artist images. - Images []ImageInfo `json:"images"` -} - -// TopSongsRequest is the request for GetArtistTopSongs. -type TopSongsRequest struct { - // ID is the internal Navidrome artist ID. - ID string `json:"id"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist (if known). - MBID string `json:"mbid,omitempty"` - // Count is the maximum number of top songs to return. - Count int32 `json:"count"` -} - -// Deprecated: use types.SongRef. -type SongRef = types.SongRef - -// TopSongsResponse is the response for GetArtistTopSongs. -type TopSongsResponse struct { - // Songs is the list of top songs. - Songs []types.SongRef `json:"songs"` -} - -// AlbumRequest is the common request for album-related functions. -type AlbumRequest struct { - // Name is the album name. - Name string `json:"name"` - // Artist is the album artist name. - Artist string `json:"artist"` - // MBID is the MusicBrainz ID for the album (if known). - MBID string `json:"mbid,omitempty"` -} - -// AlbumInfoResponse is the response for GetAlbumInfo. -type AlbumInfoResponse struct { - // Name is the album name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the album. - MBID string `json:"mbid"` - // Description is the album description/notes. - Description string `json:"description"` - // URL is the external URL for the album. - URL string `json:"url"` -} - -// AlbumImagesResponse is the response for GetAlbumImages. -type AlbumImagesResponse struct { - // Images is the list of album images. - Images []ImageInfo `json:"images"` -} - -// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack. -type SimilarSongsByTrackRequest struct { - // ID is the internal Navidrome mediafile ID. - ID string `json:"id"` - // Name is the track title. - Name string `json:"name"` - // Artist is the artist name. - Artist string `json:"artist"` - // MBID is the MusicBrainz recording ID (if known). - MBID string `json:"mbid,omitempty"` - // Count is the maximum number of similar songs to return. - Count int32 `json:"count"` -} - -// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. -type SimilarSongsByAlbumRequest struct { - // ID is the internal Navidrome album ID. - ID string `json:"id"` - // Name is the album name. - Name string `json:"name"` - // Artist is the album artist name. - Artist string `json:"artist"` - // MBID is the MusicBrainz release ID (if known). - MBID string `json:"mbid,omitempty"` - // Count is the maximum number of similar songs to return. - Count int32 `json:"count"` -} - -// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist. -type SimilarSongsByArtistRequest struct { - // ID is the internal Navidrome artist ID. - ID string `json:"id"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz artist ID (if known). - MBID string `json:"mbid,omitempty"` - // Count is the maximum number of similar songs to return. - Count int32 `json:"count"` -} - -// SimilarSongsResponse is the response for GetSimilarSongsBy* functions. -type SimilarSongsResponse struct { - // Songs is the list of similar songs. - Songs []types.SongRef `json:"songs"` -} - -// MetadataAgentError represents an error type for metadata agent operations. -type MetadataAgentError string - -const ( - // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. - // Append ":" inside the parentheses to request a specific delay. - MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" -) - -// Error implements the error interface for MetadataAgentError. -func (e MetadataAgentError) Error() string { return string(e) } diff --git a/plugins/capabilities/metadata_agent.yaml b/plugins/capabilities/metadata_agent.yaml deleted file mode 100644 index 6e528c7e7..000000000 --- a/plugins/capabilities/metadata_agent.yaml +++ /dev/null @@ -1,437 +0,0 @@ -version: v1-draft -exports: - nd_get_artist_mbid: - description: GetArtistMBID retrieves the MusicBrainz ID for an artist. - input: - $ref: '#/components/schemas/ArtistMBIDRequest' - contentType: application/json - output: - $ref: '#/components/schemas/ArtistMBIDResponse' - contentType: application/json - nd_get_artist_url: - description: GetArtistURL retrieves the external URL for an artist. - input: - $ref: '#/components/schemas/ArtistRequest' - contentType: application/json - output: - $ref: '#/components/schemas/ArtistURLResponse' - contentType: application/json - nd_get_artist_biography: - description: GetArtistBiography retrieves the biography for an artist. - input: - $ref: '#/components/schemas/ArtistRequest' - contentType: application/json - output: - $ref: '#/components/schemas/ArtistBiographyResponse' - contentType: application/json - nd_get_similar_artists: - description: GetSimilarArtists retrieves similar artists for a given artist. - input: - $ref: '#/components/schemas/SimilarArtistsRequest' - contentType: application/json - output: - $ref: '#/components/schemas/SimilarArtistsResponse' - contentType: application/json - nd_get_artist_images: - description: GetArtistImages retrieves images for an artist. - input: - $ref: '#/components/schemas/ArtistRequest' - contentType: application/json - output: - $ref: '#/components/schemas/ArtistImagesResponse' - contentType: application/json - nd_get_artist_top_songs: - description: GetArtistTopSongs retrieves top songs for an artist. - input: - $ref: '#/components/schemas/TopSongsRequest' - contentType: application/json - output: - $ref: '#/components/schemas/TopSongsResponse' - contentType: application/json - nd_get_album_info: - description: GetAlbumInfo retrieves album information. - input: - $ref: '#/components/schemas/AlbumRequest' - contentType: application/json - output: - $ref: '#/components/schemas/AlbumInfoResponse' - contentType: application/json - nd_get_album_images: - description: GetAlbumImages retrieves images for an album. - input: - $ref: '#/components/schemas/AlbumRequest' - contentType: application/json - output: - $ref: '#/components/schemas/AlbumImagesResponse' - contentType: application/json - nd_get_similar_songs_by_track: - description: GetSimilarSongsByTrack retrieves songs similar to a specific track. - input: - $ref: '#/components/schemas/SimilarSongsByTrackRequest' - contentType: application/json - output: - $ref: '#/components/schemas/SimilarSongsResponse' - contentType: application/json - nd_get_similar_songs_by_album: - description: GetSimilarSongsByAlbum retrieves songs similar to tracks on an album. - input: - $ref: '#/components/schemas/SimilarSongsByAlbumRequest' - contentType: application/json - output: - $ref: '#/components/schemas/SimilarSongsResponse' - contentType: application/json - nd_get_similar_songs_by_artist: - description: GetSimilarSongsByArtist retrieves songs similar to an artist's catalog. - input: - $ref: '#/components/schemas/SimilarSongsByArtistRequest' - contentType: application/json - output: - $ref: '#/components/schemas/SimilarSongsResponse' - contentType: application/json -components: - schemas: - AlbumImagesResponse: - description: AlbumImagesResponse is the response for GetAlbumImages. - properties: - images: - type: array - description: Images is the list of album images. - items: - $ref: '#/components/schemas/ImageInfo' - required: - - images - AlbumInfoResponse: - description: AlbumInfoResponse is the response for GetAlbumInfo. - properties: - name: - type: string - description: Name is the album name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the album. - description: - type: string - description: Description is the album description/notes. - url: - type: string - description: URL is the external URL for the album. - required: - - name - - mbid - - description - - url - AlbumRequest: - description: AlbumRequest is the common request for album-related functions. - properties: - name: - type: string - description: Name is the album name. - artist: - type: string - description: Artist is the album artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the album (if known). - required: - - name - - artist - ArtistBiographyResponse: - description: ArtistBiographyResponse is the response for GetArtistBiography. - properties: - biography: - type: string - description: Biography is the artist biography text. - required: - - biography - ArtistImagesResponse: - description: ArtistImagesResponse is the response for GetArtistImages. - properties: - images: - type: array - description: Images is the list of artist images. - items: - $ref: '#/components/schemas/ImageInfo' - required: - - images - ArtistMBIDRequest: - description: ArtistMBIDRequest is the request for GetArtistMBID. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID. - name: - type: string - description: Name is the artist name. - required: - - id - - name - ArtistMBIDResponse: - description: ArtistMBIDResponse is the response for GetArtistMBID. - properties: - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - required: - - mbid - ArtistRequest: - description: ArtistRequest is the common request for artist-related functions. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID. - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist (if known). - required: - - id - - name - ArtistURLResponse: - description: ArtistURLResponse is the response for GetArtistURL. - properties: - url: - type: string - description: URL is the external URL for the artist. - required: - - url - ImageInfo: - description: ImageInfo represents an image with URL and size. - properties: - url: - type: string - description: URL is the URL of the image. - size: - type: integer - format: int32 - description: Size is the size of the image in pixels (width or height). - required: - - url - - size - SimilarArtistsRequest: - description: SimilarArtistsRequest is the request for GetSimilarArtists. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID. - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist (if known). - limit: - type: integer - format: int32 - description: Limit is the maximum number of similar artists to return. - required: - - id - - name - - limit - SimilarArtistsResponse: - description: SimilarArtistsResponse is the response for GetSimilarArtists. - properties: - artists: - type: array - description: Artists is the list of similar artists. - items: - $ref: '#/components/schemas/ArtistRef' - required: - - artists - SimilarSongsByAlbumRequest: - description: SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. - properties: - id: - type: string - description: ID is the internal Navidrome album ID. - name: - type: string - description: Name is the album name. - artist: - type: string - description: Artist is the album artist name. - mbid: - type: string - description: MBID is the MusicBrainz release ID (if known). - count: - type: integer - format: int32 - description: Count is the maximum number of similar songs to return. - required: - - id - - name - - artist - - count - SimilarSongsByArtistRequest: - description: SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID. - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz artist ID (if known). - count: - type: integer - format: int32 - description: Count is the maximum number of similar songs to return. - required: - - id - - name - - count - SimilarSongsByTrackRequest: - description: SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack. - properties: - id: - type: string - description: ID is the internal Navidrome mediafile ID. - name: - type: string - description: Name is the track title. - artist: - type: string - description: Artist is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz recording ID (if known). - count: - type: integer - format: int32 - description: Count is the maximum number of similar songs to return. - required: - - id - - name - - artist - - count - SimilarSongsResponse: - description: SimilarSongsResponse is the response for GetSimilarSongsBy* functions. - properties: - songs: - type: array - description: Songs is the list of similar songs. - items: - $ref: '#/components/schemas/SongRef' - required: - - songs - TopSongsRequest: - description: TopSongsRequest is the request for GetArtistTopSongs. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID. - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist (if known). - count: - type: integer - format: int32 - description: Count is the maximum number of top songs to return. - required: - - id - - name - - count - TopSongsResponse: - description: TopSongsResponse is the response for GetArtistTopSongs. - properties: - songs: - type: array - description: Songs is the list of top songs. - items: - $ref: '#/components/schemas/SongRef' - required: - - songs - ArtistRef: - description: |- - ArtistRef is the minimal information a plugin returns for Navidrome to match an - artist against the library. It is a reference, not a full artist entity: it - carries only matching keys (name and optional internal/MusicBrainz IDs) plus a - few projection fields used when describing a track's participants, never - descriptive data such as biographies or images. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - sortName: - type: string - description: SortName is the artist name used for sorting (if known). - role: - type: string - description: Role is the participation category (e.g. "artist", "composer", "performer"). - subRole: - type: string - description: SubRole is a specialization within Role (e.g. the instrument for a performer). - required: - - name - SongRef: - description: |- - SongRef is the minimal information exchanged between a plugin and Navidrome to - match a song. It is used both as input (a song Navidrome already has) and as - output (a song a plugin suggests, which may not be in the library yet). Unlike - Track, it is an abstract recording reference carrying only matching keys (IDs, - ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. - 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. - - Deprecated: use Artists. - artistMbid: - type: string - description: |- - ArtistMBID is the MusicBrainz artist ID. - - Deprecated: use Artists. - artists: - type: array - description: Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - items: - $ref: '#/components/schemas/ArtistRef' - 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. - - Deprecated: use DurationMs, which carries millisecond precision. When - DurationMs is non-zero it takes precedence; Duration is kept only for - backwards compatibility with plugins that still send seconds. - durationMs: - type: integer - format: int64 - description: |- - DurationMs is the song duration in milliseconds. It supersedes Duration - when non-zero. - required: - - name diff --git a/plugins/capabilities/scheduler_callback.go b/plugins/capabilities/scheduler_callback.go deleted file mode 100644 index 93f66f10d..000000000 --- a/plugins/capabilities/scheduler_callback.go +++ /dev/null @@ -1,27 +0,0 @@ -package capabilities - -// SchedulerCallback provides scheduled task handling. -// This capability allows plugins to receive callbacks when their scheduled tasks execute. -// Plugins that use the scheduler host service must implement this capability -// to handle task execution. -// -//nd:capability name=scheduler -type SchedulerCallback interface { - // OnCallback is called when a scheduled task fires. - // Errors are logged but do not affect the scheduling system. - //nd:export name=nd_scheduler_callback - OnCallback(SchedulerCallbackRequest) error -} - -// SchedulerCallbackRequest is the request provided when a scheduled task fires. -type SchedulerCallbackRequest struct { - // ScheduleID is the unique identifier for this scheduled task. - // This is either the ID provided when scheduling, or an auto-generated UUID if none was specified. - ScheduleID string `json:"scheduleId"` - // Payload is the payload data that was provided when the task was scheduled. - // Can be used to pass context or parameters to the callback handler. - Payload string `json:"payload"` - // IsRecurring is true if this is a recurring schedule (created via ScheduleRecurring), - // false if it's a one-time schedule (created via ScheduleOneTime). - IsRecurring bool `json:"isRecurring"` -} diff --git a/plugins/capabilities/scheduler_callback.yaml b/plugins/capabilities/scheduler_callback.yaml deleted file mode 100644 index 9a081cd08..000000000 --- a/plugins/capabilities/scheduler_callback.yaml +++ /dev/null @@ -1,33 +0,0 @@ -version: v1-draft -exports: - nd_scheduler_callback: - description: |- - OnCallback is called when a scheduled task fires. - Errors are logged but do not affect the scheduling system. - input: - $ref: '#/components/schemas/SchedulerCallbackRequest' - contentType: application/json -components: - schemas: - SchedulerCallbackRequest: - description: SchedulerCallbackRequest is the request provided when a scheduled task fires. - properties: - scheduleId: - type: string - description: |- - ScheduleID is the unique identifier for this scheduled task. - This is either the ID provided when scheduling, or an auto-generated UUID if none was specified. - payload: - type: string - description: |- - Payload is the payload data that was provided when the task was scheduled. - Can be used to pass context or parameters to the callback handler. - isRecurring: - type: boolean - description: |- - IsRecurring is true if this is a recurring schedule (created via ScheduleRecurring), - false if it's a one-time schedule (created via ScheduleOneTime). - required: - - scheduleId - - payload - - isRecurring diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go deleted file mode 100644 index c1c05839a..000000000 --- a/plugins/capabilities/scrobbler.go +++ /dev/null @@ -1,128 +0,0 @@ -package capabilities - -import "github.com/navidrome/navidrome/plugins/types" - -// Scrobbler provides scrobbling functionality to external services. -// This capability allows plugins to submit listening history to services like Last.fm, -// ListenBrainz, or custom scrobbling backends. -// -// All methods are required - plugins implementing this capability must provide -// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. -// -//nd:capability name=scrobbler required=true -type Scrobbler interface { - // IsAuthorized checks if a user is authorized to scrobble to this service. - //nd:export name=nd_scrobbler_is_authorized - IsAuthorized(IsAuthorizedRequest) (bool, error) - - // NowPlaying sends a now playing notification to the scrobbling service. - //nd:export name=nd_scrobbler_now_playing - NowPlaying(NowPlayingRequest) error - - // 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. -type IsAuthorizedRequest struct { - // Username is the username of the user. - Username string `json:"username"` -} - -// TrackInfo contains track metadata. -type TrackInfo struct { - // ID is the internal Navidrome track ID. - ID string `json:"id"` - // Title is the track title. - Title string `json:"title"` - // Album is the album name. - Album string `json:"album"` - // Artist is the formatted artist name for display (e.g., "Artist1 • Artist2"). - Artist string `json:"artist"` - // AlbumArtist is the formatted album artist name for display. - AlbumArtist string `json:"albumArtist"` - // Artists is the list of track artists. - Artists []types.ArtistRef `json:"artists"` - // AlbumArtists is the list of album artists. - AlbumArtists []types.ArtistRef `json:"albumArtists"` - // Duration is the track duration in seconds. - Duration float32 `json:"duration"` - // TrackNumber is the track number on the album. - TrackNumber int32 `json:"trackNumber"` - // DiscNumber is the disc number. - DiscNumber int32 `json:"discNumber"` - // MBZRecordingID is the MusicBrainz recording ID. - MBZRecordingID string `json:"mbzRecordingId,omitempty"` - // MBZAlbumID is the MusicBrainz album/release ID. - MBZAlbumID string `json:"mbzAlbumId,omitempty"` - // MBZReleaseGroupID is the MusicBrainz release group ID. - 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. -type NowPlayingRequest struct { - // Username is the username of the user. - Username string `json:"username"` - // Track is the track currently playing. - Track TrackInfo `json:"track"` - // Position is the current playback position in seconds. - Position int32 `json:"position"` -} - -// ScrobbleRequest is the request for submitting a scrobble. -type ScrobbleRequest struct { - // Username is the username of the user. - Username string `json:"username"` - // Track is the track that was played. - Track TrackInfo `json:"track"` - // Timestamp is the Unix timestamp when the track started playing. - 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 - -const ( - // ScrobblerErrorNotAuthorized indicates the user is not authorized. - ScrobblerErrorNotAuthorized ScrobblerError = "scrobbler(not_authorized)" - // ScrobblerErrorRetryLater indicates the operation should be retried later. - ScrobblerErrorRetryLater ScrobblerError = "scrobbler(retry_later)" - // ScrobblerErrorUnrecoverable indicates an unrecoverable error. - ScrobblerErrorUnrecoverable ScrobblerError = "scrobbler(unrecoverable)" -) - -// Error implements the error interface for ScrobblerError. -func (e ScrobblerError) Error() string { return string(e) } diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml deleted file mode 100644 index 2b862b964..000000000 --- a/plugins/capabilities/scrobbler.yaml +++ /dev/null @@ -1,210 +0,0 @@ -version: v1-draft -exports: - nd_scrobbler_is_authorized: - description: IsAuthorized checks if a user is authorized to scrobble to this service. - input: - $ref: '#/components/schemas/IsAuthorizedRequest' - contentType: application/json - output: - type: boolean - contentType: application/json - nd_scrobbler_now_playing: - description: NowPlaying sends a now playing notification to the scrobbling service. - input: - $ref: '#/components/schemas/NowPlayingRequest' - contentType: application/json - nd_scrobbler_scrobble: - description: Scrobble submits a completed scrobble to the scrobbling service. - 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: - IsAuthorizedRequest: - description: IsAuthorizedRequest is the request for authorization check. - properties: - username: - type: string - description: Username is the username of the user. - required: - - username - NowPlayingRequest: - description: NowPlayingRequest is the request for now playing notification. - properties: - username: - type: string - description: Username is the username of the user. - track: - $ref: '#/components/schemas/TrackInfo' - description: Track is the track currently playing. - position: - type: integer - format: int32 - description: Position is the current playback position in seconds. - required: - - 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: - username: - type: string - description: Username is the username of the user. - track: - $ref: '#/components/schemas/TrackInfo' - description: Track is the track that was played. - timestamp: - type: integer - format: int64 - description: Timestamp is the Unix timestamp when the track started playing. - required: - - username - - track - - timestamp - TrackInfo: - description: TrackInfo contains track metadata. - properties: - id: - type: string - description: ID is the internal Navidrome track ID. - title: - type: string - description: Title is the track title. - album: - type: string - description: Album is the album name. - artist: - type: string - description: Artist is the formatted artist name for display (e.g., "Artist1 • Artist2"). - albumArtist: - type: string - description: AlbumArtist is the formatted album artist name for display. - artists: - type: array - description: Artists is the list of track artists. - items: - $ref: '#/components/schemas/ArtistRef' - albumArtists: - type: array - description: AlbumArtists is the list of album artists. - items: - $ref: '#/components/schemas/ArtistRef' - duration: - type: number - format: float - description: Duration is the track duration in seconds. - trackNumber: - type: integer - format: int32 - description: TrackNumber is the track number on the album. - discNumber: - type: integer - format: int32 - description: DiscNumber is the disc number. - mbzRecordingId: - type: string - description: MBZRecordingID is the MusicBrainz recording ID. - mbzAlbumId: - type: string - description: MBZAlbumID is the MusicBrainz album/release ID. - mbzReleaseGroupId: - type: string - description: MBZReleaseGroupID is the MusicBrainz release group ID. - 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 - - album - - artist - - albumArtist - - artists - - albumArtists - - duration - - trackNumber - - discNumber - ArtistRef: - description: |- - ArtistRef is the minimal information a plugin returns for Navidrome to match an - artist against the library. It is a reference, not a full artist entity: it - carries only matching keys (name and optional internal/MusicBrainz IDs) plus a - few projection fields used when describing a track's participants, never - descriptive data such as biographies or images. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - sortName: - type: string - description: SortName is the artist name used for sorting (if known). - role: - type: string - description: Role is the participation category (e.g. "artist", "composer", "performer"). - subRole: - type: string - description: SubRole is a specialization within Role (e.g. the instrument for a performer). - required: - - name diff --git a/plugins/capabilities/sonic_similarity.go b/plugins/capabilities/sonic_similarity.go deleted file mode 100644 index a35d9d923..000000000 --- a/plugins/capabilities/sonic_similarity.go +++ /dev/null @@ -1,34 +0,0 @@ -package capabilities - -import "github.com/navidrome/navidrome/plugins/types" - -// 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 types.SongRef `json:"song"` - Count int32 `json:"count"` -} - -type FindSonicPathRequest struct { - StartSong types.SongRef `json:"startSong"` - EndSong types.SongRef `json:"endSong"` - Count int32 `json:"count"` -} - -type SonicSimilarityResponse struct { - Matches []SonicMatch `json:"matches"` -} - -type SonicMatch struct { - Song types.SongRef `json:"song"` - Similarity float64 `json:"similarity"` -} diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml deleted file mode 100644 index a336fbce8..000000000 --- a/plugins/capabilities/sonic_similarity.yaml +++ /dev/null @@ -1,147 +0,0 @@ -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 - 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 - ArtistRef: - description: |- - ArtistRef is the minimal information a plugin returns for Navidrome to match an - artist against the library. It is a reference, not a full artist entity: it - carries only matching keys (name and optional internal/MusicBrainz IDs) plus a - few projection fields used when describing a track's participants, never - descriptive data such as biographies or images. - properties: - id: - type: string - description: ID is the internal Navidrome artist ID (if known). - name: - type: string - description: Name is the artist name. - mbid: - type: string - description: MBID is the MusicBrainz ID for the artist. - sortName: - type: string - description: SortName is the artist name used for sorting (if known). - role: - type: string - description: Role is the participation category (e.g. "artist", "composer", "performer"). - subRole: - type: string - description: SubRole is a specialization within Role (e.g. the instrument for a performer). - required: - - name - SongRef: - description: |- - SongRef is the minimal information exchanged between a plugin and Navidrome to - match a song. It is used both as input (a song Navidrome already has) and as - output (a song a plugin suggests, which may not be in the library yet). Unlike - Track, it is an abstract recording reference carrying only matching keys (IDs, - ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. - 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. - - Deprecated: use Artists. - artistMbid: - type: string - description: |- - ArtistMBID is the MusicBrainz artist ID. - - Deprecated: use Artists. - artists: - type: array - description: Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - items: - $ref: '#/components/schemas/ArtistRef' - 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. - - Deprecated: use DurationMs, which carries millisecond precision. When - DurationMs is non-zero it takes precedence; Duration is kept only for - backwards compatibility with plugins that still send seconds. - durationMs: - type: integer - format: int64 - description: |- - DurationMs is the song duration in milliseconds. It supersedes Duration - when non-zero. - required: - - name diff --git a/plugins/capabilities/taskworker.go b/plugins/capabilities/taskworker.go deleted file mode 100644 index c53d50174..000000000 --- a/plugins/capabilities/taskworker.go +++ /dev/null @@ -1,27 +0,0 @@ -package capabilities - -// TaskWorker provides task execution handling. -// This capability allows plugins to receive callbacks when their queued tasks -// are ready to execute. Plugins that use the taskqueue host service must -// implement this capability. -// -//nd:capability name=taskworker -type TaskWorker interface { - // OnTaskExecute is called when a queued task is ready to run. - // The returned string is a status/result message stored in the tasks table. - // Return an error to trigger retry (if retries are configured). - //nd:export name=nd_task_execute - OnTaskExecute(TaskExecuteRequest) (string, error) -} - -// TaskExecuteRequest is the request provided when a task is ready to execute. -type TaskExecuteRequest struct { - // QueueName is the name of the queue this task belongs to. - QueueName string `json:"queueName"` - // TaskID is the unique identifier for this task. - TaskID string `json:"taskId"` - // Payload is the opaque data provided when the task was enqueued. - Payload []byte `json:"payload"` - // Attempt is the current attempt number (1-based: first attempt = 1). - Attempt int32 `json:"attempt"` -} diff --git a/plugins/capabilities/taskworker.yaml b/plugins/capabilities/taskworker.yaml deleted file mode 100644 index 7aa7126e0..000000000 --- a/plugins/capabilities/taskworker.yaml +++ /dev/null @@ -1,37 +0,0 @@ -version: v1-draft -exports: - nd_task_execute: - description: |- - OnTaskExecute is called when a queued task is ready to run. - The returned string is a status/result message stored in the tasks table. - Return an error to trigger retry (if retries are configured). - input: - $ref: '#/components/schemas/TaskExecuteRequest' - contentType: application/json - output: - type: string - contentType: application/json -components: - schemas: - TaskExecuteRequest: - description: TaskExecuteRequest is the request provided when a task is ready to execute. - properties: - queueName: - type: string - description: QueueName is the name of the queue this task belongs to. - taskId: - type: string - description: TaskID is the unique identifier for this task. - payload: - type: string - format: byte - description: Payload is the opaque data provided when the task was enqueued. - attempt: - type: integer - format: int32 - description: 'Attempt is the current attempt number (1-based: first attempt = 1).' - required: - - queueName - - taskId - - payload - - attempt diff --git a/plugins/capabilities/websocket_callback.go b/plugins/capabilities/websocket_callback.go deleted file mode 100644 index ddfc0fc95..000000000 --- a/plugins/capabilities/websocket_callback.go +++ /dev/null @@ -1,61 +0,0 @@ -package capabilities - -// WebSocketCallback provides WebSocket message handling. -// This capability allows plugins to receive callbacks for WebSocket events -// such as text messages, binary messages, errors, and connection closures. -// Plugins that use the WebSocket host service must implement this capability -// to handle incoming events. -// -//nd:capability name=websocket -type WebSocketCallback interface { - // OnTextMessage is called when a text message is received on a WebSocket connection. - //nd:export name=nd_websocket_on_text_message - OnTextMessage(OnTextMessageRequest) error - - // OnBinaryMessage is called when a binary message is received on a WebSocket connection. - //nd:export name=nd_websocket_on_binary_message - OnBinaryMessage(OnBinaryMessageRequest) error - - // OnError is called when an error occurs on a WebSocket connection. - //nd:export name=nd_websocket_on_error - OnError(OnErrorRequest) error - - // OnClose is called when a WebSocket connection is closed. - //nd:export name=nd_websocket_on_close - OnClose(OnCloseRequest) error -} - -// OnTextMessageRequest is the request provided when a text message is received. -type OnTextMessageRequest struct { - // ConnectionID is the unique identifier for the WebSocket connection that received the message. - ConnectionID string `json:"connectionId"` - // Message is the text message content received from the WebSocket. - Message string `json:"message"` -} - -// OnBinaryMessageRequest is the request provided when a binary message is received. -type OnBinaryMessageRequest struct { - // ConnectionID is the unique identifier for the WebSocket connection that received the message. - ConnectionID string `json:"connectionId"` - // Data is the binary data received from the WebSocket, encoded as base64. - Data []byte `json:"data"` -} - -// OnErrorRequest is the request provided when an error occurs on a WebSocket connection. -type OnErrorRequest struct { - // ConnectionID is the unique identifier for the WebSocket connection where the error occurred. - ConnectionID string `json:"connectionId"` - // Error is the error message describing what went wrong. - Error string `json:"error"` -} - -// OnCloseRequest is the request provided when a WebSocket connection is closed. -type OnCloseRequest struct { - // ConnectionID is the unique identifier for the WebSocket connection that was closed. - ConnectionID string `json:"connectionId"` - // Code is the WebSocket close status code (e.g., 1000 for normal closure, - // 1001 for going away, 1006 for abnormal closure). - Code int32 `json:"code"` - // Reason is the human-readable reason for the connection closure, if provided. - Reason string `json:"reason"` -} diff --git a/plugins/capabilities/websocket_callback.yaml b/plugins/capabilities/websocket_callback.yaml deleted file mode 100644 index 6cd0cff9f..000000000 --- a/plugins/capabilities/websocket_callback.yaml +++ /dev/null @@ -1,80 +0,0 @@ -version: v1-draft -exports: - nd_websocket_on_text_message: - description: OnTextMessage is called when a text message is received on a WebSocket connection. - input: - $ref: '#/components/schemas/OnTextMessageRequest' - contentType: application/json - nd_websocket_on_binary_message: - description: OnBinaryMessage is called when a binary message is received on a WebSocket connection. - input: - $ref: '#/components/schemas/OnBinaryMessageRequest' - contentType: application/json - nd_websocket_on_error: - description: OnError is called when an error occurs on a WebSocket connection. - input: - $ref: '#/components/schemas/OnErrorRequest' - contentType: application/json - nd_websocket_on_close: - description: OnClose is called when a WebSocket connection is closed. - input: - $ref: '#/components/schemas/OnCloseRequest' - contentType: application/json -components: - schemas: - OnBinaryMessageRequest: - description: OnBinaryMessageRequest is the request provided when a binary message is received. - properties: - connectionId: - type: string - description: ConnectionID is the unique identifier for the WebSocket connection that received the message. - data: - type: string - format: byte - description: Data is the binary data received from the WebSocket, encoded as base64. - required: - - connectionId - - data - OnCloseRequest: - description: OnCloseRequest is the request provided when a WebSocket connection is closed. - properties: - connectionId: - type: string - description: ConnectionID is the unique identifier for the WebSocket connection that was closed. - code: - type: integer - format: int32 - description: |- - Code is the WebSocket close status code (e.g., 1000 for normal closure, - 1001 for going away, 1006 for abnormal closure). - reason: - type: string - description: Reason is the human-readable reason for the connection closure, if provided. - required: - - connectionId - - code - - reason - OnErrorRequest: - description: OnErrorRequest is the request provided when an error occurs on a WebSocket connection. - properties: - connectionId: - type: string - description: ConnectionID is the unique identifier for the WebSocket connection where the error occurred. - error: - type: string - description: Error is the error message describing what went wrong. - required: - - connectionId - - error - OnTextMessageRequest: - description: OnTextMessageRequest is the request provided when a text message is received. - properties: - connectionId: - type: string - description: ConnectionID is the unique identifier for the WebSocket connection that received the message. - message: - type: string - description: Message is the text message content received from the WebSocket. - required: - - connectionId - - message diff --git a/plugins/capabilities_test.go b/plugins/capabilities_test.go deleted file mode 100644 index 35fc3910a..000000000 --- a/plugins/capabilities_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package plugins - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// mockFunctionChecker implements functionExistsChecker for testing -type mockFunctionChecker struct { - functions map[string]bool -} - -func (m *mockFunctionChecker) FunctionExists(name string) bool { - return m.functions[name] -} - -var _ = Describe("Capabilities", func() { - Describe("detectCapabilities", func() { - It("detects MetadataAgent capability when plugin exports artist biography function", func() { - checker := &mockFunctionChecker{ - functions: map[string]bool{ - FuncGetArtistBiography: true, - }, - } - - caps := detectCapabilities(checker) - Expect(caps).To(ContainElement(CapabilityMetadataAgent)) - }) - - It("detects MetadataAgent capability when plugin exports multiple functions", func() { - checker := &mockFunctionChecker{ - functions: map[string]bool{ - FuncGetArtistMBID: true, - FuncGetArtistURL: true, - FuncGetAlbumInfo: true, - FuncGetAlbumImages: true, - }, - } - - caps := detectCapabilities(checker) - Expect(caps).To(ContainElement(CapabilityMetadataAgent)) - Expect(caps).To(HaveLen(1)) // Should only have one MetadataAgent capability - }) - - It("returns empty slice when no capability functions are exported", func() { - checker := &mockFunctionChecker{ - functions: map[string]bool{ - "some_other_function": true, - }, - } - - caps := detectCapabilities(checker) - Expect(caps).To(BeEmpty()) - }) - - It("returns empty slice when plugin exports no functions", func() { - checker := &mockFunctionChecker{ - functions: map[string]bool{}, - } - - caps := detectCapabilities(checker) - Expect(caps).To(BeEmpty()) - }) - }) - - Describe("hasCapability", func() { - It("returns true when capability exists", func() { - caps := []Capability{CapabilityMetadataAgent} - Expect(hasCapability(caps, CapabilityMetadataAgent)).To(BeTrue()) - }) - - It("returns false when capability does not exist", func() { - var caps []Capability - Expect(hasCapability(caps, CapabilityMetadataAgent)).To(BeFalse()) - }) - - It("returns false when capabilities slice is nil", func() { - Expect(hasCapability(nil, CapabilityMetadataAgent)).To(BeFalse()) - }) - }) -}) diff --git a/plugins/capability_lifecycle.go b/plugins/capability_lifecycle.go deleted file mode 100644 index 499e3916d..000000000 --- a/plugins/capability_lifecycle.go +++ /dev/null @@ -1,38 +0,0 @@ -package plugins - -import ( - "context" - - "github.com/navidrome/navidrome/log" -) - -// CapabilityLifecycle indicates the plugin has lifecycle callback functions. -// Detected when the plugin exports the nd_on_init function. -const CapabilityLifecycle Capability = "Lifecycle" - -const FuncOnInit = "nd_on_init" - -func init() { - registerCapability( - CapabilityLifecycle, - FuncOnInit, - ) -} - -// callPluginInit calls the plugin's nd_on_init function if it has the Lifecycle capability. -// This is called after the plugin is fully loaded with all services registered. -func callPluginInit(ctx context.Context, instance *plugin) { - if !hasCapability(instance.capabilities, CapabilityLifecycle) { - return - } - - log.Debug(ctx, "Calling plugin init function", "plugin", instance.name) - - err := callPluginFunctionNoInput(ctx, instance, FuncOnInit) - if err != nil { - log.Error(ctx, "Plugin init function failed", "plugin", instance.name, err) - return - } - - log.Debug(ctx, "Plugin init function completed", "plugin", instance.name) -} diff --git a/plugins/cmd/ndpgen/.gitignore b/plugins/cmd/ndpgen/.gitignore deleted file mode 100644 index 315ccc05f..000000000 --- a/plugins/cmd/ndpgen/.gitignore +++ /dev/null @@ -1 +0,0 @@ -ndpgen \ No newline at end of file diff --git a/plugins/cmd/ndpgen/README.md b/plugins/cmd/ndpgen/README.md deleted file mode 100644 index 7487db892..000000000 --- a/plugins/cmd/ndpgen/README.md +++ /dev/null @@ -1,193 +0,0 @@ -# ndpgen - -Navidrome Plugin Development Kit (PDK) code generator. It reads Go interface definitions with special annotations and generates client wrappers for WASM plugins. - -This tool is the unified code generator that handle both host function wrappers and capability wrappers. - -## Usage - -```bash -ndpgen -input -output [-package ] [-v] [-dry-run] [-host-only] [-go] [-rust] -``` - -### Flags - -| Flag | Description | Default | -|--------------|----------------------------------------------------------------|----------------------| -| `-input` | Directory containing Go source files with annotated interfaces | Required | -| `-output` | Directory where generated files will be written | Same as input | -| `-package` | Package name for generated files | Inferred from output | -| `-v` | Verbose output | `false` | -| `-dry-run` | Parse and validate without writing files | `false` | -| `-host-only` | Generate only host function wrappers (capability support TBD) | `true` | -| `-go` | Generate Go client wrappers | `true`* | -| `-rust` | Generate Rust client wrappers | `false` | - -\* `-go` is enabled by default when `-rust` is not specified. Use `-go -rust` to generate both languages. - -### Example - -```bash -go run ./plugins/cmd/ndpgen \ - -input ./plugins/host \ - -output ./plugins/pdk -``` - -## Annotations - -### `//nd:hostservice` - -Marks an interface as a host service that will have wrappers generated. - -```go -//nd:hostservice name= permission= -type MyService interface { ... } -``` - -| Parameter | Description | Required | -|--------------|-----------------------------------------------------------------|----------| -| `name` | Service name used in generated type names and function prefixes | Yes | -| `permission` | Permission required by plugins to use this service | Yes | - -### `//nd:hostfunc` - -Marks a method within a host service interface for export to plugins. - -```go -//nd:hostfunc [name=] -MethodName(ctx context.Context, ...) (result Type, err error) -``` - -| Parameter | Description | Required | -|-----------|-------------------------------------------------------------------------|----------| -| `name` | Custom export name (default: `_` in lowercase) | No | - -## Input Format - -Host service interfaces must follow these conventions: - -1. **First parameter must be `context.Context`** - Required for all methods -2. **Last return value should be `error`** - For proper error handling -3. **Annotations must be on consecutive lines** - No blank comment lines between doc and annotation - -### Example Interface - -```go -package host - -import "context" - -// SubsonicAPIService provides access to Navidrome's Subsonic API. -// This documentation becomes part of the generated code. -//nd:hostservice name=SubsonicAPI permission=subsonicapi -type SubsonicAPIService interface { - // Call executes a Subsonic API request and returns the response. - //nd:hostfunc - Call(ctx context.Context, uri string) (response string, err error) -} -``` - -## Generated Output - -### Go Client Library (Go/TinyGo WASM) - -Generated files are named `nd_host_.go` (lowercase) and placed in `$output/go/host/`. The `$output/go/` directory becomes a complete Go module (`github.com/navidrome/navidrome/plugins/pdk/go`) with package name `host`, intended for import by Navidrome plugins built with TinyGo. - -The generator creates: -- `nd_host_.go` - Client wrapper code (WASM build) -- `nd_host__stub.go` - Mock implementations for non-WASM platforms (testing) -- `doc.go` - Package documentation listing all available services -- `go.mod` - Go module file with required dependencies - -Each service file includes: - -- `// Code generated by ndpgen. DO NOT EDIT.` header -- Required imports (`encoding/json`, `errors`, `github.com/extism/go-pdk`) -- `//go:wasmimport` declarations for each host function -- Response struct types and any struct definitions from the service -- Wrapper functions that handle memory allocation and JSON parsing - -### Testing Plugins with Mocks - -The stub files (`*_stub.go`) contain [testify/mock](https://github.com/stretchr/testify) implementations that allow plugin authors to unit test their code on non-WASM platforms. - -Each host service has: -- A private mock struct embedding `mock.Mock` -- An exported auto-instantiated mock instance (e.g., `host.CacheMock`, `host.ArtworkMock`) -- Wrapper functions that delegate to the mock - -**Example: Testing a plugin that uses the Cache service** - -```go -package myplugin - -import ( - "testing" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" -) - -func TestMyPluginFunction(t *testing.T) { - // Set expectations on the mock - host.CacheMock.On("GetString", "my-key").Return("cached-value", true, nil) - host.CacheMock.On("SetString", "new-key", "new-value", int64(3600)).Return(nil) - - // Call your plugin code that uses host.CacheGetString and host.CacheSetString - result := myPluginFunction() - - // Assert the result - if result != "expected" { - t.Errorf("unexpected result: %s", result) - } - - // Verify all expected calls were made - host.CacheMock.AssertExpectations(t) -} -``` - -**Resetting mocks between tests:** - -If you need to reset mock state between tests, testify's mock doesn't have a built-in reset. Either use separate test functions (testify automatically resets between test runs), or create a helper to set up fresh expectations. - -### Rust Client Library - -When using `-rust`, Rust client files are generated in a `rust/` subdirectory. - -## Supported Types - -ndpgen supports these Go types in method signatures: - -| Type | JSON Representation | -|-------------------------------|------------------------------------------| -| `string`, `int`, `bool`, etc. | Native JSON types | -| `[]T` (slices) | JSON arrays | -| `map[K]V` (maps) | JSON objects | -| `*T` (pointers) | Nullable fields | -| `interface{}` / `any` | Converts to `any` | -| Custom structs | JSON objects (must be JSON-serializable) | - -### Multiple Return Values - -Methods can return multiple values (plus error): - -```go -//nd:hostfunc -Search(ctx context.Context, query string) (results []string, total int, hasMore bool, err error) -``` - -Generates: - -```go -type ServiceSearchResponse struct { - Results []string `json:"results,omitempty"` - Total int `json:"total,omitempty"` - HasMore bool `json:"hasMore,omitempty"` - Error string `json:"error,omitempty"` -} -``` - -## Running Tests - -```bash -go test ./plugins/cmd/ndpgen/... -``` diff --git a/plugins/cmd/ndpgen/go.mod b/plugins/cmd/ndpgen/go.mod deleted file mode 100644 index 6b081a826..000000000 --- a/plugins/cmd/ndpgen/go.mod +++ /dev/null @@ -1,26 +0,0 @@ -module github.com/navidrome/navidrome/plugins/cmd/ndpgen - -go 1.25.0 - -require ( - github.com/extism/go-pdk v1.1.3 - github.com/onsi/ginkgo/v2 v2.27.5 - github.com/onsi/gomega v1.39.0 - github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 - golang.org/x/tools v0.44.0 - gopkg.in/yaml.v3 v3.0.1 -) - -require ( - github.com/Masterminds/semver/v3 v3.4.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect -) diff --git a/plugins/cmd/ndpgen/go.sum b/plugins/cmd/ndpgen/go.sum deleted file mode 100644 index 828273cab..000000000 --- a/plugins/cmd/ndpgen/go.sum +++ /dev/null @@ -1,75 +0,0 @@ -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= -github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= -github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= -github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= -github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= -github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= -github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= -github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= -github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= -github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= -github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= -github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= -github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= -github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE= -github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= -github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= -github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/cmd/ndpgen/integration_test.go b/plugins/cmd/ndpgen/integration_test.go deleted file mode 100644 index bbe886f8d..000000000 --- a/plugins/cmd/ndpgen/integration_test.go +++ /dev/null @@ -1,433 +0,0 @@ -package main - -import ( - "fmt" - "go/format" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// normalizeGeneratedCode normalizes generated code for comparison with expected output. -func normalizeGeneratedCode(code string) string { - // Replace package names (generated uses ndpdk, testdata may use ndhost) - code = strings.ReplaceAll(code, "package ndhost", "package ndpdk") - return code -} - -var _ = Describe("ndpgen CLI", Ordered, func() { - var ( - testDir string - outputDir string - ndpgenBin string - ) - - BeforeAll(func() { - // Set testdata directory (relative to ndpgen root) - testdataDir = filepath.Join(mustGetWd(GinkgoT()), "testdata") - - // Build the ndpgen binary - ndpgenBin = filepath.Join(os.TempDir(), "ndpgen-test") - if runtime.GOOS == "windows" { - ndpgenBin += ".exe" // Windows refuses to exec a file without it - } - cmd := exec.Command("go", "build", "-o", ndpgenBin, ".") - cmd.Dir = mustGetWd(GinkgoT()) - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Failed to build ndpgen: %s", output) - DeferCleanup(func() { - os.Remove(ndpgenBin) - }) - }) - - BeforeEach(func() { - var err error - testDir, err = os.MkdirTemp("", "ndpgen-test-input-*") - Expect(err).ToNot(HaveOccurred()) - outputDir, err = os.MkdirTemp("", "ndpgen-test-output-*") - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - os.RemoveAll(testDir) - os.RemoveAll(outputDir) - }) - - Describe("CLI flags and behavior", func() { - BeforeEach(func() { - serviceCode := `package testpkg - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - DoAction(ctx context.Context, input string) (output string, err error) -} -` - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - }) - - It("supports verbose mode", func() { - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-v") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - outputStr := string(output) - Expect(outputStr).To(ContainSubstring("Input directory:")) - Expect(outputStr).To(ContainSubstring("Base output directory:")) - Expect(outputStr).To(ContainSubstring("Go output directory:")) - Expect(outputStr).To(ContainSubstring("Found 1 host service(s)")) - Expect(outputStr).To(ContainSubstring("Generated")) - }) - - It("supports dry-run mode", func() { - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-dry-run") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - Expect(string(output)).To(ContainSubstring("func TestDoAction(")) - Expect(filepath.Join(outputDir, "nd_host_test.go")).ToNot(BeAnExistingFile()) - }) - - It("uses default package name 'host'", func() { - customOutput, err := os.MkdirTemp("", "mypkg") - Expect(err).ToNot(HaveOccurred()) - defer os.RemoveAll(customOutput) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", customOutput) - _, err = cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred()) - - // Go code goes to $output/go/host/ - content, err := os.ReadFile(filepath.Join(customOutput, "go", "host", "nd_host_test.go")) - Expect(err).ToNot(HaveOccurred()) - Expect(string(content)).To(ContainSubstring("package host")) - }) - - It("returns error for invalid input directory", func() { - cmd := exec.Command(ndpgenBin, "-input", "/nonexistent/path") - output, err := cmd.CombinedOutput() - Expect(err).To(HaveOccurred()) - Expect(string(output)).To(ContainSubstring("parsing source files")) - }) - - It("handles no annotated services gracefully", func() { - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte("package testpkg\n"), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-v") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - Expect(string(output)).To(ContainSubstring("No host services found")) - }) - - It("generates separate files for multiple services", func() { - // Remove service.go created by BeforeEach - Expect(os.Remove(filepath.Join(testDir, "service.go"))).To(Succeed()) - - service1 := `package testpkg -import "context" -//nd:hostservice name=ServiceA permission=a -type ServiceA interface { - //nd:hostfunc - MethodA(ctx context.Context) error -} -` - service2 := `package testpkg -import "context" -//nd:hostservice name=ServiceB permission=b -type ServiceB interface { - //nd:hostfunc - MethodB(ctx context.Context) error -} -` - Expect(os.WriteFile(filepath.Join(testDir, "a.go"), []byte(service1), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(testDir, "b.go"), []byte(service2), 0600)).To(Succeed()) - - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-v") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - Expect(string(output)).To(ContainSubstring("Found 2 host service(s)")) - - // Go code goes to $output/go/host/ - goHostDir := filepath.Join(outputDir, "go", "host") - Expect(filepath.Join(goHostDir, "nd_host_servicea.go")).To(BeAnExistingFile()) - Expect(filepath.Join(goHostDir, "nd_host_serviceb.go")).To(BeAnExistingFile()) - }) - - It("generates Go client code by default", func() { - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - // Go client code goes to $output/go/host/ - goHostDir := filepath.Join(outputDir, "go", "host") - Expect(filepath.Join(goHostDir, "nd_host_test.go")).To(BeAnExistingFile()) - // Stub file also generated - Expect(filepath.Join(goHostDir, "nd_host_test_stub.go")).To(BeAnExistingFile()) - // doc.go in host dir - Expect(filepath.Join(goHostDir, "doc.go")).To(BeAnExistingFile()) - // go.mod at parent $output/go/ for consolidated module - goDir := filepath.Join(outputDir, "go") - Expect(filepath.Join(goDir, "go.mod")).To(BeAnExistingFile()) - }) - }) - - Describe("code generation", func() { - DescribeTable("generates correct client output", - func(serviceFile, goClientExpectedFile, rsClientExpectedFile string) { - serviceCode := readTestdata(serviceFile) - goClientExpected := readTestdata(goClientExpectedFile) - rsClientExpected := readTestdata(rsClientExpectedFile) - - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - // Generate all client code (Go, Rust) - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk", "-go", "-rust") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", output) - - // Verify Go client code (now in $output/go/host/) - goHostDir := filepath.Join(outputDir, "go", "host") - entries, err := os.ReadDir(goHostDir) - Expect(err).ToNot(HaveOccurred()) - - var goClientFiles []string - for _, e := range entries { - if !e.IsDir() && - !strings.HasSuffix(e.Name(), "_stub.go") && - e.Name() != "doc.go" && e.Name() != "go.mod" { - goClientFiles = append(goClientFiles, e.Name()) - } - } - Expect(goClientFiles).To(HaveLen(1), "Expected exactly one Go client file, got: %v", goClientFiles) - - goClientActual, err := os.ReadFile(filepath.Join(goHostDir, goClientFiles[0])) - Expect(err).ToNot(HaveOccurred()) - - formattedGoClientActual, err := format.Source(goClientActual) - Expect(err).ToNot(HaveOccurred(), "Generated Go client code is not valid Go:\n%s", goClientActual) - - // Normalize expected code to match ndpgen output format - normalizedExpected := normalizeGeneratedCode(goClientExpected) - formattedGoClientExpected, err := format.Source([]byte(normalizedExpected)) - Expect(err).ToNot(HaveOccurred(), "Expected Go client code is not valid Go") - - Expect(string(formattedGoClientActual)).To(Equal(string(formattedGoClientExpected)), "Go client code mismatch") - - // Verify Rust client code (now in $output/rust/nd-pdk-host/src/) - rustSrcDir := filepath.Join(outputDir, "rust", "nd-pdk-host", "src") - rsClientEntries, err := os.ReadDir(rustSrcDir) - Expect(err).ToNot(HaveOccurred()) - Expect(rsClientEntries).To(HaveLen(2), "Expected Rust client file and lib.rs in src/") - - // Find the client file (not lib.rs) - var rsClientName string - for _, entry := range rsClientEntries { - if entry.Name() != "lib.rs" { - rsClientName = entry.Name() - break - } - } - Expect(rsClientName).ToNot(BeEmpty(), "Expected to find Rust client file") - - rsClientActual, err := os.ReadFile(filepath.Join(rustSrcDir, rsClientName)) - Expect(err).ToNot(HaveOccurred()) - - Expect(string(rsClientActual)).To(Equal(rsClientExpected), "Rust client code mismatch") - }, - - Entry("simple string params", - "echo_service.go.txt", "echo_client_expected.go.txt", "echo_client_expected.rs"), - - Entry("multiple simple params (int32)", - "math_service.go.txt", "math_client_expected.go.txt", "math_client_expected.rs"), - - Entry("struct param with request type", - "store_service.go.txt", "store_client_expected.go.txt", "store_client_expected.rs"), - - Entry("mixed simple and complex params", - "list_service.go.txt", "list_client_expected.go.txt", "list_client_expected.rs"), - - Entry("method without error", - "counter_service.go.txt", "counter_client_expected.go.txt", "counter_client_expected.rs"), - - Entry("no params, error only", - "ping_service.go.txt", "ping_client_expected.go.txt", "ping_client_expected.rs"), - - Entry("map and interface types", - "meta_service.go.txt", "meta_client_expected.go.txt", "meta_client_expected.rs"), - - Entry("pointer types", - "users_service.go.txt", "users_client_expected.go.txt", "users_client_expected.rs"), - - Entry("multiple returns", - "search_service.go.txt", "search_client_expected.go.txt", "search_client_expected.rs"), - - Entry("bytes", - "codec_service.go.txt", "codec_client_expected.go.txt", "codec_client_expected.rs"), - - Entry("option pattern (value, exists bool)", - "config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.rs"), - ) - - It("generates the shared Go types package with -shared-types", func() { - typesSrc := `package types - -// ArtistRef references an artist. -type ArtistRef struct { - ID string ` + "`json:\"id,omitempty\"`" + ` - Name string ` + "`json:\"name\"`" + ` -} -` - Expect(os.WriteFile(filepath.Join(testDir, "types.go"), []byte(typesSrc), 0600)).To(Succeed()) - cmd := exec.Command(ndpgenBin, "-shared-types", "-input", testDir, "-output", outputDir, "-go") - out, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Command failed: %s", out) - - content, err := os.ReadFile(filepath.Join(outputDir, "go", "types", "types.go")) - Expect(err).ToNot(HaveOccurred()) - Expect(string(content)).To(ContainSubstring("package types")) - Expect(string(content)).To(ContainSubstring("type ArtistRef struct {")) - }) - - It("generates compilable client code for comprehensive service", func() { - serviceCode := readTestdata("comprehensive_service.go.txt") - - Expect(os.WriteFile(filepath.Join(testDir, "service.go"), []byte(serviceCode), 0600)).To(Succeed()) - - // Generate client code - cmd := exec.Command(ndpgenBin, "-input", testDir, "-output", outputDir, "-package", "ndpdk") - output, err := cmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "Generation failed: %s", output) - - // Go code goes to $output/go/host/ - goHostDir := filepath.Join(outputDir, "go", "host") - - // Read generated client code - entries, err := os.ReadDir(goHostDir) - Expect(err).ToNot(HaveOccurred()) - - // Find the client file - var clientFileName string - for _, entry := range entries { - name := entry.Name() - if name != "doc.go" && name != "go.mod" && !strings.HasSuffix(name, "_stub.go") && strings.HasSuffix(name, ".go") { - clientFileName = name - break - } - } - Expect(clientFileName).ToNot(BeEmpty(), "Expected to find Go client file") - - content, err := os.ReadFile(filepath.Join(goHostDir, clientFileName)) - Expect(err).ToNot(HaveOccurred()) - - // Verify key expected content - contentStr := string(content) - // Should have wasmimport declarations for all methods - Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_simpleparams")) - Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_structparam")) - Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noerror")) - Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparams")) - Expect(contentStr).To(ContainSubstring("//go:wasmimport extism:host/user comprehensive_noparamsnoreturns")) - - // Should have response types for methods with complex returns (private types in client code) - Expect(contentStr).To(ContainSubstring("type comprehensiveSimpleParamsResponse struct")) - Expect(contentStr).To(ContainSubstring("type comprehensiveMultipleReturnsResponse struct")) - - // Should have wrapper functions - Expect(contentStr).To(ContainSubstring("func ComprehensiveSimpleParams(")) - Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParams()")) - Expect(contentStr).To(ContainSubstring("func ComprehensiveNoParamsNoReturns()")) - - // Create a plugin directory with proper import structure - pluginDir := filepath.Join(outputDir, "plugin") - Expect(os.MkdirAll(pluginDir, 0750)).To(Succeed()) - - // go.mod is at parent $output/go/ for consolidated module - goDir := filepath.Join(outputDir, "go") - - // Create go.mod for the plugin that imports the generated library - goMod := fmt.Sprintf(`module testplugin - -go 1.25 - -require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 - -replace github.com/navidrome/navidrome/plugins/pdk/go => %s -`, goDir) - Expect(os.WriteFile(filepath.Join(pluginDir, "go.mod"), []byte(goMod), 0600)).To(Succeed()) - - // Add a simple main function that imports and uses the ndpdk package - mainGo := `package main - -import ndpdk "github.com/navidrome/navidrome/plugins/pdk/go/host" - -func main() {} - -// Use some functions to ensure import is not unused -var _ = ndpdk.ComprehensiveNoParams -` - Expect(os.WriteFile(filepath.Join(pluginDir, "main.go"), []byte(mainGo), 0600)).To(Succeed()) - - // Tidy dependencies for the generated go library - goTidyLibCmd := exec.Command("go", "mod", "tidy") - goTidyLibCmd.Dir = goDir - goTidyLibOutput, err := goTidyLibCmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "go mod tidy (library) failed: %s", goTidyLibOutput) - - // Tidy dependencies for the plugin - goTidyCmd := exec.Command("go", "mod", "tidy") - goTidyCmd.Dir = pluginDir - goTidyOutput, err := goTidyCmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "go mod tidy (plugin) failed: %s", goTidyOutput) - - // Build as WASM plugin - this validates the client code compiles correctly - buildCmd := exec.Command("go", "build", "-buildmode=c-shared", "-o", "plugin.wasm", ".") - buildCmd.Dir = pluginDir - buildCmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm") - buildOutput, err := buildCmd.CombinedOutput() - Expect(err).ToNot(HaveOccurred(), "WASM build failed: %s", buildOutput) - - // Verify .wasm file was created - Expect(filepath.Join(pluginDir, "plugin.wasm")).To(BeAnExistingFile()) - }) - - }) -}) - -var testdataDir string - -func readTestdata(filename string) string { - content, err := os.ReadFile(filepath.Join(testdataDir, filename)) - Expect(err).ToNot(HaveOccurred(), "Failed to read testdata file: %s", filename) - return string(content) -} - -func mustGetWd(t FullGinkgoTInterface) string { - dir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - // Look for ndpgen's own go.mod (the subproject root) - for { - goModPath := filepath.Join(dir, "go.mod") - if _, err := os.Stat(goModPath); err == nil { - // Check if this is the ndpgen go.mod by reading it - content, err := os.ReadFile(goModPath) - if err == nil && strings.Contains(string(content), "plugins/cmd/ndpgen") { - return dir - } - } - parent := filepath.Dir(dir) - if parent == dir { - t.Fatal("could not find ndpgen project root") - } - dir = parent - } -} diff --git a/plugins/cmd/ndpgen/internal/generator.go b/plugins/cmd/ndpgen/internal/generator.go deleted file mode 100644 index bfc1b7c4e..000000000 --- a/plugins/cmd/ndpgen/internal/generator.go +++ /dev/null @@ -1,1024 +0,0 @@ -package internal - -import ( - "bytes" - "embed" - "fmt" - "slices" - "strings" - "text/template" -) - -//go:embed templates/*.tmpl -var templatesFS embed.FS - -// hostFuncMap returns the template functions for host code generation. -func hostFuncMap(svc Service) template.FuncMap { - return template.FuncMap{ - "lower": strings.ToLower, - "title": strings.Title, - "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, - "requestType": func(m Method) string { return m.RequestTypeName(svc.Name) }, - "responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) }, - } -} - -// clientFuncMap returns the template functions for client code generation. -// Uses private (lowercase) type names for request/response structs. -func clientFuncMap(svc Service) template.FuncMap { - return template.FuncMap{ - "lower": strings.ToLower, - "title": strings.Title, - "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, - "requestType": func(m Method) string { return m.ClientRequestTypeName(svc.Name) }, - "responseType": func(m Method) string { return m.ClientResponseTypeName(svc.Name) }, - "formatDoc": formatDoc, - "mockReturnBody": mockReturnBody, - } -} - -// mockReturnBody generates the testify mock body lines that extract return values. -// Nil-able returns are guarded so tests can use Return(nil, ...) without the -// type-assertion panic testify produces on an untyped nil. -func mockReturnBody(m Method) string { - var b strings.Builder - var parts []string - - for idx, r := range m.Returns { - if isNilableType(r.Type) { - name := fmt.Sprintf("r%d", idx) - fmt.Fprintf(&b, "\tvar %s %s\n\tif v := args.Get(%d); v != nil {\n\t\t%s = v.(%s)\n\t}\n", name, r.Type, idx, name, r.Type) - parts = append(parts, name) - } else { - parts = append(parts, mockAccessor(r.Type, idx)) - } - } - - if m.HasError { - parts = append(parts, fmt.Sprintf("args.Error(%d)", len(m.Returns))) - } - - b.WriteString("\treturn " + strings.Join(parts, ", ")) - return b.String() -} - -// isNilableType reports whether a nil mock return value is a valid intent for the -// type, rather than a malformed test expectation. -func isNilableType(typ string) bool { - return strings.HasPrefix(typ, "*") || strings.HasPrefix(typ, "[]") || - strings.HasPrefix(typ, "map[") || typ == "any" || typ == "interface{}" -} - -// mockAccessor returns the testify mock accessor call for a given type and index. -func mockAccessor(typ string, idx int) string { - switch { - case typ == "string": - return fmt.Sprintf("args.String(%d)", idx) - case typ == "bool": - return fmt.Sprintf("args.Bool(%d)", idx) - case typ == "int": - return fmt.Sprintf("args.Int(%d)", idx) - case typ == "int64": - return fmt.Sprintf("args.Get(%d).(int64)", idx) - case typ == "int32": - return fmt.Sprintf("args.Get(%d).(int32)", idx) - case typ == "float64": - return fmt.Sprintf("args.Get(%d).(float64)", idx) - case typ == "float32": - return fmt.Sprintf("args.Get(%d).(float32)", idx) - case typ == "[]byte": - return fmt.Sprintf("args.Get(%d).([]byte)", idx) - default: - // For slices, maps, pointers, and custom types, use Get with type assertion - return fmt.Sprintf("args.Get(%d).(%s)", idx, typ) - } -} - -// GenerateHost generates the host function wrapper code for a service. -func GenerateHost(svc Service, pkgName string) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/host.go.tmpl") - if err != nil { - return nil, fmt.Errorf("reading host template: %w", err) - } - - tmpl, err := template.New("host").Funcs(hostFuncMap(svc)).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - data := templateData{ - Package: pkgName, - Service: svc, - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -// GenerateClientGo generates client wrapper code for plugins to call host functions. -func GenerateClientGo(svc Service, pkgName string) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/client.go.tmpl") - if err != nil { - return nil, fmt.Errorf("reading client template: %w", err) - } - - tmpl, err := template.New("client").Funcs(clientFuncMap(svc)).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - data := templateData{ - Package: pkgName, - Service: svc, - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -// GenerateClientGoStub generates stub code for non-WASM platforms. -// These stubs provide type definitions and function signatures for IDE support, -// but panic at runtime since host functions are only available in WASM plugins. -func GenerateClientGoStub(svc Service, pkgName string) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/client_stub.go.tmpl") - if err != nil { - return nil, fmt.Errorf("reading client stub template: %w", err) - } - - tmpl, err := template.New("client_stub").Funcs(clientFuncMap(svc)).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - data := templateData{ - Package: pkgName, - Service: svc, - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -type templateData struct { - Package string - Service Service -} - -// formatDoc formats a documentation string for Go comments. -// It prefixes each line with "// " and trims trailing whitespace. -func formatDoc(doc string) string { - if doc == "" { - return "" - } - lines := strings.Split(strings.TrimSpace(doc), "\n") - var result []string - for _, line := range lines { - result = append(result, "// "+strings.TrimRight(line, " \t")) - } - return strings.Join(result, "\n") -} - -// rustFuncMap returns the template functions for Rust client code generation. -func rustFuncMap(svc Service) template.FuncMap { - knownStructs := svc.KnownStructs() - shared := make(map[string]string) - for _, a := range svc.SharedAliases { - shared[a.Name] = "nd_pdk_types::" + strings.TrimPrefix(a.Target, sharedTypesPrefix) - } - return template.FuncMap{ - "lower": strings.ToLower, - "exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) }, - "requestType": func(m Method) string { return m.RequestTypeName(svc.Name) }, - "responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) }, - "rustFunc": func(m Method) string { return m.RustFunctionName(svc.ExportPrefix()) }, - "rustDocComment": RustDocComment, - "rustType": func(p Param) string { return p.RustTypeWithShared(knownStructs, shared) }, - "rustParamType": func(p Param) string { return p.RustParamTypeWithShared(knownStructs, shared) }, - "fieldRustType": func(f FieldDef) string { return ToRustTypeWithShared(f.Type, knownStructs, shared) }, - } -} - -// GenerateClientRust generates Rust client wrapper code for plugins. -func GenerateClientRust(svc Service) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/client.rs.tmpl") - if err != nil { - return nil, fmt.Errorf("reading Rust client template: %w", err) - } - - tmpl, err := template.New("client_rs").Funcs(rustFuncMap(svc)).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl") - if err != nil { - return nil, fmt.Errorf("reading base64_bytes partial: %w", err) - } - tmpl, err = tmpl.Parse(string(partialContent)) - if err != nil { - return nil, fmt.Errorf("parsing base64_bytes partial: %w", err) - } - - data := templateData{ - Service: svc, - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -// firstLine returns the first line of a multi-line string, with the first word removed. -func firstLine(s string) string { - line := s - if idx := strings.Index(s, "\n"); idx >= 0 { - line = s[:idx] - } - // Remove the first word (service name like "ArtworkService") - if idx := strings.Index(line, " "); idx >= 0 { - line = line[idx+1:] - } - return line -} - -// GenerateRustLib generates the lib.rs file that exposes all service modules. -func GenerateRustLib(services []Service) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/lib.rs.tmpl") - if err != nil { - return nil, fmt.Errorf("reading Rust lib template: %w", err) - } - - tmpl, err := template.New("lib_rs").Funcs(template.FuncMap{ - "lower": strings.ToLower, - "firstLine": firstLine, - }).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - data := struct { - Services []Service - }{ - Services: services, - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -// GenerateGoDoc generates the doc.go file that provides package documentation. -func GenerateGoDoc(services []Service, pkgName string) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/doc.go.tmpl") - if err != nil { - return nil, fmt.Errorf("reading Go doc template: %w", err) - } - - tmpl, err := template.New("doc_go").Funcs(template.FuncMap{ - "firstLine": firstLine, - }).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - data := struct { - Package string - Services []Service - }{ - Package: pkgName, - Services: services, - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -// GenerateGoMod generates the go.mod file for the Go client library. -func GenerateGoMod() ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/go.mod.tmpl") - if err != nil { - return nil, fmt.Errorf("reading go.mod template: %w", err) - } - return tmplContent, nil -} - -// capabilityTemplateData holds data for capability template execution. -type capabilityTemplateData struct { - Package string - Capability Capability -} - -// capabilityFuncMap returns template functions for capability code generation. -func capabilityFuncMap(cap Capability) template.FuncMap { - return template.FuncMap{ - "formatDoc": formatDoc, - "indent": indentText, - "agentName": capabilityAgentName, - "providerInterface": func(e Export) string { return e.ProviderInterfaceName() }, - "implVar": func(e Export) string { return e.ImplVarName() }, - "exportFunc": func(e Export) string { return e.ExportFuncName() }, - } -} - -// indentText adds n tabs to each line of text. -func indentText(n int, s string) string { - indent := strings.Repeat("\t", n) - lines := strings.Split(s, "\n") - for i, line := range lines { - if line != "" { - lines[i] = indent + line - } - } - return strings.Join(lines, "\n") -} - -// indentSpaces adds n spaces to each non-empty line of text. -func indentSpaces(spaces int, s string) string { - ind := strings.Repeat(" ", spaces) - lines := strings.Split(s, "\n") - for i, line := range lines { - if line != "" { - lines[i] = ind + line - } - } - return strings.Join(lines, "\n") -} - -// capabilityAgentName returns the interface name for a capability. -// Uses the Go interface name stripped of common suffixes. -func capabilityAgentName(cap Capability) string { - name := cap.Interface - // Remove common suffixes to get a clean name - for _, suffix := range []string{"Agent", "Callback", "Service"} { - if strings.HasSuffix(name, suffix) { - name = name[:len(name)-len(suffix)] - break - } - } - // Use the shortened name or the original if no suffix found - if name == "" { - name = cap.Interface - } - return name -} - -// GenerateCapabilityGo generates Go export wrapper code for a capability. -func GenerateCapabilityGo(cap Capability, pkgName string) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/capability.go.tmpl") - if err != nil { - return nil, fmt.Errorf("reading capability template: %w", err) - } - - tmpl, err := template.New("capability").Funcs(capabilityFuncMap(cap)).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - data := capabilityTemplateData{ - Package: pkgName, - Capability: cap, - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -// GenerateCapabilityGoStub generates stub code for non-WASM platforms. -func GenerateCapabilityGoStub(cap Capability, pkgName string) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/capability_stub.go.tmpl") - if err != nil { - return nil, fmt.Errorf("reading capability stub template: %w", err) - } - - tmpl, err := template.New("capability_stub").Funcs(capabilityFuncMap(cap)).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - data := capabilityTemplateData{ - Package: pkgName, - Capability: cap, - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -// rustCapabilityFuncMap returns template functions for Rust capability code generation. -func rustCapabilityFuncMap(cap Capability) template.FuncMap { - knownStructs := cap.KnownStructs() - shared := make(map[string]string) - for _, a := range cap.SharedAliases { - shared[a.Name] = "nd_pdk_types::" + strings.TrimPrefix(a.Target, sharedTypesPrefix) - } - return template.FuncMap{ - "rustDocComment": RustDocComment, - "rustTypeAlias": rustTypeAlias, - "rustConstType": rustConstType, - "rustConstName": rustConstName, - "rustFieldName": func(name string) string { return ToSnakeCase(name) }, - "rustMethodName": func(name string) string { return ToSnakeCase(name) }, - "fieldRustType": func(f FieldDef) string { return ToRustTypeWithShared(f.Type, knownStructs, shared) }, - "rustOutputType": func(goType string) string { return rustTraitType(goType, shared) }, - "rustMethodType": func(goType string) string { return rustMethodType(goType, cap.Name, shared) }, - "skipSerializingFunc": skipSerializingFunc, - "hasHashMap": hasHashMap, - "agentName": capabilityAgentName, - "providerInterface": func(e Export) string { return e.ProviderInterfaceName() }, - "registerMacroName": func(name string) string { return registerMacroName(cap.Name, name) }, - "rustSharedTarget": func(target string) string { - return "nd_pdk_types::" + strings.TrimPrefix(target, sharedTypesPrefix) - }, - // rustSharedNote is the human-facing path for deprecation notes: plugin - // authors depend on the nd-pdk umbrella crate, which re-exports nd_pdk_types - // as `types`, so they reference these via nd_pdk::types::X. - "rustSharedNote": func(target string) string { - return "nd_pdk::types::" + strings.TrimPrefix(target, sharedTypesPrefix) - }, - "snakeCase": ToSnakeCase, - "indent": indentSpaces, - } -} - -// rustTypeAlias converts a Go type to its Rust equivalent for type aliases. -// For string types used as error sentinels/constants, we use &'static str -// since Rust consts can't be heap-allocated String values. -func rustTypeAlias(goType string) string { - switch goType { - case "string": - return "&'static str" - case "int", "int32": - return "i32" - case "int64": - return "i64" - default: - return goType - } -} - -// rustConstType converts a Go type to its Rust equivalent for const declarations. -// For String types, it returns &'static str since Rust consts can't be heap-allocated. -func rustConstType(goType string) string { - switch goType { - case "string", "String": - return "&'static str" - case "int", "int32": - return "i32" - case "int64": - return "i64" - default: - return goType - } -} - -// rustOutputType converts a Go type to Rust for capability method signatures. -// It handles pointer types specially - for capability outputs, pointers become the base type -// (not Option) because Rust's Result already provides optional semantics. -// -// TODO: Pointer to primitive types (e.g., *string, *int32) are not handled correctly. -// Currently "*string" returns "string" instead of "String". This would generate invalid -// Rust code. No current capability uses this pattern, but it should be fixed if needed. -// rustMethodType returns the fully-qualified Rust type for a capability method -// input/output as referenced inside the generated export macro. The macro expands -// in the downstream plugin crate, which depends on the umbrella nd-pdk crate and -// not on nd-pdk-types directly, so shared types must be reached through $crate -// (the defining nd-pdk-capabilities crate, which re-exports nd_pdk_types as -// `types`) rather than by naming the transitive crate. Primitives map to their -// Rust name; any other named type is a capability-local struct, qualified as -// $crate::::X. This is used instead of hand-assembling -// "$crate::::" + rustOutputType, which produced invalid paths like -// "$crate::demo::types.SongRef" for shared types used directly in a signature. -func rustMethodType(goType, pkg string, shared map[string]string) string { - goType = strings.TrimPrefix(goType, "*") - if isPrimitiveRustType(goType) { - return rustOutputType(goType) - } - if rest, ok := strings.CutPrefix(goType, sharedTypesPrefix); ok { - return "$crate::types::" + rest - } - if t, ok := shared[goType]; ok { - return "$crate::types::" + strings.TrimPrefix(t, "nd_pdk_types::") - } - return "$crate::" + ToSnakeCase(pkg) + "::" + goType -} - -// rustTraitType returns the Rust type for a capability trait method signature. -// The trait lives in the capability module alongside its local structs, so those -// stay bare; shared types must still resolve to their nd_pdk_types::X crate path -// (a shared type used directly in a signature would otherwise pass through as the -// invalid Go selector "types.SongRef"). -func rustTraitType(goType string, shared map[string]string) string { - stripped := strings.TrimPrefix(goType, "*") - if rest, ok := strings.CutPrefix(stripped, sharedTypesPrefix); ok { - return "nd_pdk_types::" + rest - } - if t, ok := shared[stripped]; ok { - return t - } - return rustOutputType(goType) -} - -func rustOutputType(goType string) string { - // Strip pointer prefix - capability outputs use Result for optionality - if strings.HasPrefix(goType, "*") { - return goType[1:] - } - // Convert Go primitives to Rust primitives - switch goType { - case "bool": - return "bool" - case "string": - return "String" - case "int", "int32": - return "i32" - case "int64": - return "i64" - case "float32": - return "f32" - case "float64": - return "f64" - } - return goType -} - -// isPrimitiveRustType returns true if the Go type maps to a Rust primitive type. -func isPrimitiveRustType(goType string) bool { - // Strip pointer prefix first - if strings.HasPrefix(goType, "*") { - goType = goType[1:] - } - switch goType { - case "bool", "string", "int", "int32", "int64", "float32", "float64": - return true - } - return false -} - -// rustConstName converts a Go const name to Rust convention (SCREAMING_SNAKE_CASE). -func rustConstName(name string) string { - return strings.ToUpper(ToSnakeCase(name)) -} - -// skipSerializingFunc returns the appropriate skip_serializing_if function name. -// The check must match the rendered Rust type: pointers become Option, slices Vec, -// and maps HashMap, each with a different emptiness predicate. -func skipSerializingFunc(goType string) string { - switch { - case strings.HasPrefix(goType, "*"): - return "Option::is_none" - case strings.HasPrefix(goType, "[]"): - return "Vec::is_empty" - case strings.HasPrefix(goType, "map["): - return "HashMap::is_empty" - } - switch goType { - case "string": - return "String::is_empty" - case "bool": - return "std::ops::Not::not" - case "int32": - return "is_zero_i32" - case "uint32": - return "is_zero_u32" - case "int64": - return "is_zero_i64" - case "uint64": - return "is_zero_u64" - case "float32": - return "is_zero_f32" - case "float64": - return "is_zero_f64" - default: - return "Option::is_none" - } -} - -// anyFieldUsesHashMap returns true if any field in the given structs uses a map type. -func anyFieldUsesHashMap(structs []StructDef) bool { - for _, st := range structs { - for _, f := range st.Fields { - if strings.HasPrefix(f.Type, "map[") { - return true - } - } - } - return false -} - -// anyFieldIsByteSlice reports whether any field across the given structs is a -// []byte, which Go's JSON encoder serializes as a base64 string. The Rust -// shared-types crate must match that with a base64_bytes serde override. -func anyFieldIsByteSlice(structs []StructDef) bool { - for _, st := range structs { - for _, f := range st.Fields { - if f.IsByteSlice() { - return true - } - } - } - return false -} - -// hasHashMap returns true if any struct in the capability uses HashMap. -func hasHashMap(cap Capability) bool { - return anyFieldUsesHashMap(cap.Structs) -} - -// sortedStructs returns a sorted copy of structs, ordered by name. -func sortedStructs(structs []StructDef) []StructDef { - sorted := append([]StructDef(nil), structs...) - slices.SortFunc(sorted, func(a, b StructDef) int { return strings.Compare(a.Name, b.Name) }) - return sorted -} - -// registerMacroName returns the macro name for registering an optional method. -// For package "websocket" and method "OnClose", returns "register_websocket_close". -func registerMacroName(pkg, name string) string { - // Remove common prefixes from method name - for _, prefix := range []string{"Get", "On"} { - if strings.HasPrefix(name, prefix) { - name = name[len(prefix):] - break - } - } - return "register_" + ToSnakeCase(pkg) + "_" + ToSnakeCase(name) -} - -// GenerateCapabilityRust generates Rust export wrapper code for a capability. -func GenerateCapabilityRust(cap Capability) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/capability.rs.tmpl") - if err != nil { - return nil, fmt.Errorf("reading Rust capability template: %w", err) - } - - tmpl, err := template.New("capability_rust").Funcs(rustCapabilityFuncMap(cap)).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl") - if err != nil { - return nil, fmt.Errorf("reading base64_bytes partial: %w", err) - } - tmpl, err = tmpl.Parse(string(partialContent)) - if err != nil { - return nil, fmt.Errorf("parsing base64_bytes partial: %w", err) - } - - data := capabilityTemplateData{ - Package: cap.Name, - Capability: cap, - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -// GenerateCapabilityRustLib generates the lib.rs file for the Rust capabilities crate. -func GenerateCapabilityRustLib(capabilities []Capability) ([]byte, error) { - var buf bytes.Buffer - buf.WriteString("// Code generated by ndpgen. DO NOT EDIT.\n\n") - buf.WriteString("//! Navidrome Plugin Development Kit - Capability Wrappers\n") - buf.WriteString("//!\n") - buf.WriteString("//! This crate provides type definitions, traits, and registration macros\n") - buf.WriteString("//! for implementing Navidrome plugin capabilities in Rust.\n\n") - - // Re-export the shared types so generated registration macros can reference them - // via $crate::types::X. The macro expands in the downstream plugin crate, which - // depends on the umbrella nd-pdk crate and not on nd-pdk-types directly. - buf.WriteString("pub use nd_pdk_types as types;\n\n") - - // Module declarations - for _, cap := range capabilities { - moduleName := ToSnakeCase(cap.Name) - buf.WriteString(fmt.Sprintf("pub mod %s;\n", moduleName)) - } - - return buf.Bytes(), nil -} - -// pdkFuncMap returns the template functions for PDK code generation. -func pdkFuncMap() template.FuncMap { - return template.FuncMap{ - "firstSentence": firstSentence, - "paramList": pdkParamList, - "returnList": pdkReturnList, - "argList": pdkArgList, - "argListWithReceiver": pdkArgListWithReceiver, - "mockReturnBody": pdkMockReturnBody, - "constValue": pdkConstValue, - "stubTypeUnderlying": stubTypeUnderlying, - "methodReceiver": pdkMethodReceiver, - } -} - -// stubTypeUnderlying returns the appropriate stub type for non-WASM builds. -// For types that reference internal packages (like memory.Memory), returns "struct{}". -func stubTypeUnderlying(t PDKType) string { - underlying := t.Underlying - // If the underlying type references a package (contains a dot), use a stub struct - if strings.Contains(underlying, ".") { - return "struct{}" - } - // For simple types like int, int32, return as-is - return underlying -} - -// firstSentence returns the first sentence of a doc string, normalized to a single line. -func firstSentence(doc string) string { - if doc == "" { - return "" - } - // Normalize whitespace (replace newlines with spaces, collapse multiple spaces) - doc = strings.Join(strings.Fields(doc), " ") - - // Find first period followed by space or end - for i, r := range doc { - if r == '.' && (i+1 >= len(doc) || doc[i+1] == ' ') { - return doc[:i+1] - } - } - return doc -} - -// pdkParamList generates a parameter list string for function signature. -func pdkParamList(params []PDKParam) string { - var parts []string - for _, p := range params { - if p.Name != "" { - parts = append(parts, p.Name+" "+p.Type) - } else { - parts = append(parts, p.Type) - } - } - return strings.Join(parts, ", ") -} - -// pdkReturnList generates a return list string for function signature. -func pdkReturnList(returns []PDKReturn) string { - if len(returns) == 0 { - return "" - } - if len(returns) == 1 && returns[0].Name == "" { - return " " + returns[0].Type - } - var parts []string - for _, r := range returns { - if r.Name != "" { - parts = append(parts, r.Name+" "+r.Type) - } else { - parts = append(parts, r.Type) - } - } - return " (" + strings.Join(parts, ", ") + ")" -} - -// pdkArgList generates an argument list string for function call. -func pdkArgList(params []PDKParam) string { - var parts []string - for _, p := range params { - if p.Name != "" { - parts = append(parts, p.Name) - } else { - parts = append(parts, "_") - } - } - return strings.Join(parts, ", ") -} - -// pdkArgListWithReceiver generates an argument list that includes the receiver variable -// as the first argument to PDKMock.Called(). This allows tests to verify which instance -// a method was called on. -func pdkArgListWithReceiver(params []PDKParam, typeName string) string { - // Use lowercase first letter of type name as receiver variable - receiverVar := strings.ToLower(typeName[:1]) - parts := []string{receiverVar} - for _, p := range params { - if p.Name != "" { - parts = append(parts, p.Name) - } else { - parts = append(parts, "_") - } - } - return strings.Join(parts, ", ") -} - -// pdkMethodReceiver generates the receiver declaration for a method. -// Example: "r *HTTPRequest" or "m Memory" -func pdkMethodReceiver(receiver, typeName string) string { - receiverVar := strings.ToLower(typeName[:1]) - if strings.HasPrefix(receiver, "*") { - return receiverVar + " *" + typeName - } - return receiverVar + " " + typeName -} - -// pdkMockReturns generates the mock return accessors for a function. -func pdkMockReturnBody(returns []PDKReturn) string { - var b strings.Builder - var parts []string - for i, r := range returns { - if isNilableType(r.Type) { - name := fmt.Sprintf("r%d", i) - fmt.Fprintf(&b, "\tvar %s %s\n\tif v := args.Get(%d); v != nil {\n\t\t%s = v.(%s)\n\t}\n", name, r.Type, i, name, r.Type) - parts = append(parts, name) - } else { - parts = append(parts, mockAccessorForType(r.Type, i)) - } - } - b.WriteString("\treturn " + strings.Join(parts, ", ")) - return b.String() -} - -// mockAccessorForType returns the testify mock accessor for a type. -func mockAccessorForType(typ string, idx int) string { - switch typ { - case "string": - return fmt.Sprintf("args.String(%d)", idx) - case "bool": - return fmt.Sprintf("args.Bool(%d)", idx) - case "int": - return fmt.Sprintf("args.Int(%d)", idx) - case "error": - return fmt.Sprintf("args.Error(%d)", idx) - case "[]byte": - return fmt.Sprintf("args.Get(%d).([]byte)", idx) - case "uint64": - return fmt.Sprintf("args.Get(%d).(uint64)", idx) - case "uint32": - return fmt.Sprintf("args.Get(%d).(uint32)", idx) - case "uint16": - return fmt.Sprintf("args.Get(%d).(uint16)", idx) - default: - return fmt.Sprintf("args.Get(%d).(%s)", idx, typ) - } -} - -// pdkConstValue returns the value expression for a constant. -func pdkConstValue(c PDKConst) string { - if c.Value == "" || c.Value == "iota" { - return "iota" - } - return c.Value -} - -// GeneratePDKGo generates the WASM implementation of the PDK wrapper package. -func GeneratePDKGo(symbols *PDKSymbols) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/pdk.go.tmpl") - if err != nil { - return nil, fmt.Errorf("reading pdk template: %w", err) - } - - tmpl, err := template.New("pdk").Funcs(pdkFuncMap()).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, symbols); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -// GeneratePDKGoStub generates the native stub implementation of the PDK wrapper package. -func GeneratePDKGoStub(symbols *PDKSymbols) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/pdk_stub.go.tmpl") - if err != nil { - return nil, fmt.Errorf("reading pdk stub template: %w", err) - } - - tmpl, err := template.New("pdk_stub").Funcs(pdkFuncMap()).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, symbols); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -// GeneratePDKTypesStub generates the native type definitions for the PDK wrapper package. -func GeneratePDKTypesStub(symbols *PDKSymbols) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/types_stub.go.tmpl") - if err != nil { - return nil, fmt.Errorf("reading types stub template: %w", err) - } - - tmpl, err := template.New("types_stub").Funcs(pdkFuncMap()).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, symbols); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - - return buf.Bytes(), nil -} - -// GenerateSharedTypesRust generates the nd-pdk-types crate root (lib.rs). -func GenerateSharedTypesRust(structs []StructDef) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/types.rs.tmpl") - if err != nil { - return nil, fmt.Errorf("reading types rust template: %w", err) - } - sorted := sortedStructs(structs) - known := map[string]bool{} - for _, s := range sorted { - known[s.Name] = true - } - tmpl, err := template.New("types_rs").Funcs(template.FuncMap{ - "rustDocComment": RustDocComment, - "rustFieldName": func(n string) string { return ToSnakeCase(n) }, - "fieldRustType": func(f FieldDef) string { return f.RustType(known) }, - "skipSerializingFunc": skipSerializingFunc, - "indent": indentSpaces, - }).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - - partialContent, err := templatesFS.ReadFile("templates/base64_bytes.rs.tmpl") - if err != nil { - return nil, fmt.Errorf("reading base64_bytes partial: %w", err) - } - tmpl, err = tmpl.Parse(string(partialContent)) - if err != nil { - return nil, fmt.Errorf("parsing base64_bytes partial: %w", err) - } - - data := struct { - Structs []StructDef - HasHashMap bool - HasByteFields bool - }{Structs: sorted, HasHashMap: anyFieldUsesHashMap(sorted), HasByteFields: anyFieldIsByteSlice(sorted)} - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - return buf.Bytes(), nil -} - -// GenerateSharedTypesGo generates the shared `types` package (plain data structs). -func GenerateSharedTypesGo(structs []StructDef, pkgName string) ([]byte, error) { - tmplContent, err := templatesFS.ReadFile("templates/types.go.tmpl") - if err != nil { - return nil, fmt.Errorf("reading types template: %w", err) - } - tmpl, err := template.New("types").Funcs(template.FuncMap{ - "formatDoc": formatDoc, - "indent": indentText, - }).Parse(string(tmplContent)) - if err != nil { - return nil, fmt.Errorf("parsing template: %w", err) - } - data := struct { - Package string - Structs []StructDef - }{Package: pkgName, Structs: sortedStructs(structs)} - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return nil, fmt.Errorf("executing template: %w", err) - } - return buf.Bytes(), nil -} diff --git a/plugins/cmd/ndpgen/internal/generator_test.go b/plugins/cmd/ndpgen/internal/generator_test.go deleted file mode 100644 index 555906489..000000000 --- a/plugins/cmd/ndpgen/internal/generator_test.go +++ /dev/null @@ -1,1825 +0,0 @@ -package internal - -import ( - "go/format" - "os" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Generator", func() { - Describe("GenerateHost", func() { - It("should generate valid Go code for a simple service with strings", func() { - // All methods use JSON request/response types - svc := Service{ - Name: "SubsonicAPI", - Permission: "subsonicapi", - Interface: "SubsonicAPIService", - Methods: []Method{ - { - Name: "Call", - HasError: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{NewParam("response", "string")}, - }, - }, - } - - code, err := GenerateHost(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - // Verify the code is valid Go - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for generated header - Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT.")) - - // Check for package declaration - Expect(codeStr).To(ContainSubstring("package host")) - - // All methods now use request type for JSON protocol - Expect(codeStr).To(ContainSubstring("type SubsonicAPICallRequest struct")) - Expect(codeStr).To(ContainSubstring(`Uri string `)) - - // Response type with error handling - Expect(codeStr).To(ContainSubstring("type SubsonicAPICallResponse struct")) - Expect(codeStr).To(ContainSubstring(`Response string `)) - Expect(codeStr).To(ContainSubstring(`Error string `)) - - // Check for registration function - Expect(codeStr).To(ContainSubstring("func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService)")) - - // Check for host function name - Expect(codeStr).To(ContainSubstring(`"subsonicapi_call"`)) - - // Check for JSON unmarshal (all methods use JSON now) - Expect(codeStr).To(ContainSubstring("json.Unmarshal")) - }) - - It("should generate code for methods without parameters", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "NoParams", - HasError: true, - Returns: []Param{NewParam("result", "string")}, - }, - }, - } - - code, err := GenerateHost(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - // Methods without params don't need a request type - no params to serialize - Expect(codeStr).NotTo(ContainSubstring("type TestNoParamsRequest struct")) - // But still uses PTR input/output for consistency - Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`)) - }) - - It("should generate code for methods without return values", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "NoReturn", - HasError: true, - Params: []Param{NewParam("input", "string")}, - }, - }, - } - - code, err := GenerateHost(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should generate code for multiple methods", func() { - svc := Service{ - Name: "Scheduler", - Permission: "scheduler", - Interface: "SchedulerService", - Methods: []Method{ - { - Name: "ScheduleRecurring", - HasError: true, - Params: []Param{NewParam("cronExpression", "string")}, - Returns: []Param{NewParam("scheduleID", "string")}, - }, - { - Name: "ScheduleOneTime", - HasError: true, - Params: []Param{NewParam("delaySeconds", "int32")}, - Returns: []Param{NewParam("scheduleID", "string")}, - }, - { - Name: "CancelSchedule", - HasError: true, - Params: []Param{NewParam("scheduleID", "string")}, - Returns: []Param{NewParam("canceled", "bool")}, - }, - }, - } - - code, err := GenerateHost(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - Expect(codeStr).To(ContainSubstring("scheduler_schedulerecurring")) - Expect(codeStr).To(ContainSubstring("scheduler_scheduleonetime")) - Expect(codeStr).To(ContainSubstring("scheduler_cancelschedule")) - }) - - It("should handle multiple simple parameters with JSON", func() { - // All params use JSON - single PTR input - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "MultiParam", - HasError: true, - Params: []Param{ - NewParam("name", "string"), - NewParam("count", "int32"), - NewParam("enabled", "bool"), - }, - Returns: []Param{NewParam("result", "string")}, - }, - }, - } - - code, err := GenerateHost(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - // All methods use request type with JSON protocol - Expect(codeStr).To(ContainSubstring("type TestMultiParamRequest struct")) - // Check for JSON unmarshal (all methods use JSON now) - Expect(codeStr).To(ContainSubstring("json.Unmarshal")) - // Check that input/output ValueType both use PTR (JSON) - Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`)) - }) - - It("should use single PTR for mixed simple and complex params", func() { - // When any param needs JSON, all are bundled into one request struct - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "MixedParam", - HasError: true, - Params: []Param{ - NewParam("id", "string"), // simple (PTR for string) - NewParam("tags", "[]string"), // complex - needs JSON - }, - Returns: []Param{NewParam("count", "int32")}, // simple - }, - }, - } - - code, err := GenerateHost(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - // Request type IS needed because of complex param - Expect(codeStr).To(ContainSubstring("type TestMixedParamRequest struct")) - // When using request type, only ONE PTR for input (the JSON request) - Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`)) - }) - - It("should generate proper JSON tags for complex types", func() { - // Complex types (structs, slices, maps) need JSON serialization - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "Method", - HasError: true, - Params: []Param{NewParam("inputValue", "[]string")}, // slice needs JSON - Returns: []Param{NewParam("outputValue", "map[string]string")}, // map needs JSON - }, - }, - } - - code, err := GenerateHost(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - // Complex params need request type with JSON tags - Expect(codeStr).To(ContainSubstring(`json:"inputValue"`)) - // Complex returns need response type with JSON tags - Expect(codeStr).To(ContainSubstring(`json:"outputValue,omitempty"`)) - }) - - It("should include required imports", func() { - // Service with complex types needs JSON import - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "Method", - HasError: true, - Params: []Param{NewParam("data", "MyStruct")}, // struct needs JSON - }, - }, - } - - code, err := GenerateHost(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - Expect(codeStr).To(ContainSubstring(`"context"`)) - Expect(codeStr).To(ContainSubstring(`"encoding/json"`)) - Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`)) - }) - - It("should always include json import for JSON protocol", func() { - // All services use JSON protocol, so json import is always needed - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "Method", - Params: []Param{NewParam("count", "int32")}, - Returns: []Param{NewParam("result", "int64")}, - }, - }, - } - - code, err := GenerateHost(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - Expect(codeStr).To(ContainSubstring(`"context"`)) - Expect(codeStr).To(ContainSubstring(`"encoding/json"`)) - Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`)) - }) - - It("imports the shared types package when a method references types directly", func() { - svc := Service{ - Name: "Matcher", - Interface: "MatcherService", - Methods: []Method{ - { - Name: "MatchSongs", - HasError: true, - Params: []Param{NewParam("songs", "[]types.SongRef")}, - Returns: []Param{NewParam("results", "[]*types.Track")}, - }, - }, - } - - code, err := GenerateHost(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - Expect(codeStr).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/types"`)) - Expect(codeStr).To(ContainSubstring("Songs []types.SongRef")) - }) - - It("does not import the shared types package when no method references types", func() { - svc := Service{ - Name: "Test", - Interface: "TestService", - Methods: []Method{ - {Name: "Method", Params: []Param{NewParam("count", "int32")}}, - }, - } - - code, err := GenerateHost(svc, "host") - Expect(err).NotTo(HaveOccurred()) - Expect(string(code)).NotTo(ContainSubstring(`"github.com/navidrome/navidrome/plugins/types"`)) - }) - }) - - Describe("toJSONName", func() { - It("should convert to camelCase matching Rust serde behavior", func() { - Expect(toJSONName("InputValue")).To(Equal("inputValue")) - Expect(toJSONName("URI")).To(Equal("uri")) - Expect(toJSONName("id")).To(Equal("id")) - Expect(toJSONName("ID")).To(Equal("id")) - Expect(toJSONName("ConnectionID")).To(Equal("connectionId")) - Expect(toJSONName("NewConnectionID")).To(Equal("newConnectionId")) - Expect(toJSONName("XMLHTTPRequest")).To(Equal("xmlhttpRequest")) - Expect(toJSONName("APIKey")).To(Equal("apiKey")) - }) - - It("should handle empty string", func() { - Expect(toJSONName("")).To(Equal("")) - }) - }) - - Describe("NewParam", func() { - It("should create param with auto-generated JSON name", func() { - p := NewParam("MyParam", "string") - Expect(p.Name).To(Equal("MyParam")) - Expect(p.Type).To(Equal("string")) - Expect(p.JSONName).To(Equal("myParam")) - }) - }) - - Describe("Method.IsOptionPattern", func() { - It("should return true for (value, exists bool) pattern", func() { - m := Method{ - Returns: []Param{ - {Name: "value", Type: "string"}, - {Name: "exists", Type: "bool"}, - }, - } - Expect(m.IsOptionPattern()).To(BeTrue()) - }) - - It("should return true for (value, ok bool) pattern", func() { - m := Method{ - Returns: []Param{ - {Name: "value", Type: "int64"}, - {Name: "ok", Type: "bool"}, - }, - } - Expect(m.IsOptionPattern()).To(BeTrue()) - }) - - It("should return true for (value, found bool) pattern", func() { - m := Method{ - Returns: []Param{ - {Name: "data", Type: "[]byte"}, - {Name: "found", Type: "bool"}, - }, - } - Expect(m.IsOptionPattern()).To(BeTrue()) - }) - - It("should be case insensitive for bool name", func() { - m := Method{ - Returns: []Param{ - {Name: "value", Type: "string"}, - {Name: "EXISTS", Type: "bool"}, - }, - } - Expect(m.IsOptionPattern()).To(BeTrue()) - }) - - It("should return false for single return", func() { - m := Method{ - Returns: []Param{ - {Name: "value", Type: "string"}, - }, - } - Expect(m.IsOptionPattern()).To(BeFalse()) - }) - - It("should return false for more than two returns", func() { - m := Method{ - Returns: []Param{ - {Name: "value", Type: "string"}, - {Name: "count", Type: "int"}, - {Name: "exists", Type: "bool"}, - }, - } - Expect(m.IsOptionPattern()).To(BeFalse()) - }) - - It("should return false when second return is not bool", func() { - m := Method{ - Returns: []Param{ - {Name: "value", Type: "string"}, - {Name: "count", Type: "int"}, - }, - } - Expect(m.IsOptionPattern()).To(BeFalse()) - }) - - It("should return false when bool is not named exists/ok/found", func() { - m := Method{ - Returns: []Param{ - {Name: "value", Type: "string"}, - {Name: "success", Type: "bool"}, - }, - } - Expect(m.IsOptionPattern()).To(BeFalse()) - }) - - It("should return false for Has() pattern where first return is bool", func() { - // Has(key) -> (exists bool) should NOT be treated as Option pattern - m := Method{ - Returns: []Param{ - {Name: "exists", Type: "bool"}, - }, - } - Expect(m.IsOptionPattern()).To(BeFalse()) - }) - - It("should return false when first return is bool (preserves Has-like methods)", func() { - // Even with two returns, if first is bool, don't convert to Option - m := Method{ - Returns: []Param{ - {Name: "result", Type: "bool"}, - {Name: "exists", Type: "bool"}, - }, - } - Expect(m.IsOptionPattern()).To(BeFalse()) - }) - }) - - Describe("ToSnakeCase", func() { - It("should convert PascalCase to snake_case", func() { - Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring")) - Expect(ToSnakeCase("GetString")).To(Equal("get_string")) - Expect(ToSnakeCase("simple")).To(Equal("simple")) - }) - - It("should handle acronyms correctly", func() { - Expect(ToSnakeCase("ID")).To(Equal("id")) - Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id")) - Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id")) - Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser")) - Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response")) - }) - }) - - Describe("GenerateGoDoc", func() { - It("should generate valid doc.go content for multiple services", func() { - services := []Service{ - { - Name: "Cache", - Permission: "cache", - Interface: "CacheService", - Doc: "CacheService provides temporary key-value storage with TTL.", - }, - { - Name: "Scheduler", - Permission: "scheduler", - Interface: "SchedulerService", - Doc: "SchedulerService manages scheduled tasks.", - }, - } - - code, err := GenerateGoDoc(services, "ndpdk") - Expect(err).NotTo(HaveOccurred()) - - // Verify it's valid Go code - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for generated header - Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT.")) - - // Check for package declaration - Expect(codeStr).To(ContainSubstring("package ndpdk")) - - // Check for package documentation - Expect(codeStr).To(ContainSubstring("Package ndpdk provides Navidrome Plugin Development Kit wrappers")) - - // Check that services are listed - Expect(codeStr).To(ContainSubstring("Cache:")) - Expect(codeStr).To(ContainSubstring("Scheduler:")) - }) - }) - - Describe("GenerateGoMod", func() { - It("should generate valid go.mod content", func() { - code, err := GenerateGoMod() - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for module declaration (consolidated PDK path at pdk/go level) - Expect(codeStr).To(ContainSubstring("module github.com/navidrome/navidrome/plugins/pdk/go")) - // Ensure it's not the old host-specific path - Expect(codeStr).NotTo(ContainSubstring("module github.com/navidrome/navidrome/plugins/pdk/go/host")) - - // Check for Go version - Expect(codeStr).To(ContainSubstring("go 1.25")) - - // Check for extism-go-pdk dependency - Expect(codeStr).To(ContainSubstring("github.com/extism/go-pdk")) - }) - }) - - Describe("GenerateClientGo", func() { - It("should include errors import when service has methods with errors", func() { - svc := Service{ - Name: "Cache", - Permission: "cache", - Interface: "CacheService", - Methods: []Method{ - { - Name: "Get", - HasError: true, - Params: []Param{NewParam("key", "string")}, - Returns: []Param{NewParam("value", "string")}, - }, - }, - } - - code, err := GenerateClientGo(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - // Verify the code is valid Go (can't actually compile without wasip1) - codeStr := string(code) - - // Check for errors import when methods have errors - Expect(codeStr).To(ContainSubstring(`"errors"`)) - Expect(codeStr).To(ContainSubstring("errors.New")) - }) - - It("should not include errors import when service has no methods with errors", func() { - svc := Service{ - Name: "Config", - Permission: "config", - Interface: "ConfigService", - Methods: []Method{ - { - Name: "Get", - HasError: false, - Params: []Param{NewParam("key", "string")}, - Returns: []Param{NewParam("value", "string"), NewParam("exists", "bool")}, - }, - { - Name: "List", - HasError: false, - Params: []Param{NewParam("prefix", "string")}, - Returns: []Param{NewParam("keys", "[]string")}, - }, - }, - } - - code, err := GenerateClientGo(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check that errors is NOT imported when no methods have errors - Expect(codeStr).NotTo(ContainSubstring(`"errors"`)) - Expect(codeStr).NotTo(ContainSubstring("errors.New")) - }) - - It("should generate valid Go code structure", func() { - svc := Service{ - Name: "SubsonicAPI", - Permission: "subsonicapi", - Interface: "SubsonicAPIService", - Methods: []Method{ - { - Name: "Call", - HasError: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{NewParam("response", "string")}, - }, - }, - } - - code, err := GenerateClientGo(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for generated header - Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT.")) - - // Check for build tag - Expect(codeStr).To(ContainSubstring("//go:build wasip1")) - - // Check for package declaration - Expect(codeStr).To(ContainSubstring("package host")) - - // Check for wasmimport directive - Expect(codeStr).To(ContainSubstring("//go:wasmimport extism:host/user")) - - // Check for PDK import - Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk")) - }) - - }) - - Describe("GenerateClientGoStub", func() { - It("should generate valid mock code with testify/mock", func() { - svc := Service{ - Name: "Cache", - Permission: "cache", - Interface: "CacheService", - Doc: "CacheService provides caching capabilities.", - Methods: []Method{ - { - Name: "Get", - Doc: "Get retrieves a value from the cache.", - Params: []Param{ - {Name: "key", Type: "string"}, - }, - Returns: []Param{ - {Name: "value", Type: "string"}, - {Name: "exists", Type: "bool"}, - }, - }, - }, - } - - code, err := GenerateClientGoStub(svc, "ndpdk") - Expect(err).NotTo(HaveOccurred()) - - // Verify it's valid Go code - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for build tag (non-WASM) - Expect(codeStr).To(ContainSubstring("//go:build !wasip1")) - - // Check for package declaration - Expect(codeStr).To(ContainSubstring("package ndpdk")) - - // Check for mock comment - Expect(codeStr).To(ContainSubstring("mock implementations for non-WASM builds")) - - // Check for testify/mock import - Expect(codeStr).To(ContainSubstring(`"github.com/stretchr/testify/mock"`)) - - // Check for private mock struct - Expect(codeStr).To(ContainSubstring("type mockCacheService struct")) - Expect(codeStr).To(ContainSubstring("mock.Mock")) - - // Check for exported mock instance - Expect(codeStr).To(ContainSubstring("var CacheMock = &mockCacheService{}")) - - // Check for mock method - Expect(codeStr).To(ContainSubstring("func (m *mockCacheService) Get(key string)")) - Expect(codeStr).To(ContainSubstring("m.Called(key)")) - - // Check for wrapper function delegating to mock - Expect(codeStr).To(ContainSubstring("func CacheGet(key string)")) - Expect(codeStr).To(ContainSubstring("return CacheMock.Get(key)")) - - // Stub files should NOT have request/response types (they're not needed) - Expect(codeStr).NotTo(ContainSubstring("Request struct")) - Expect(codeStr).NotTo(ContainSubstring("Response struct")) - }) - - It("should generate correct mock return values for different types", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "GetString", - Params: []Param{ - {Name: "key", Type: "string"}, - }, - Returns: []Param{ - {Name: "value", Type: "string"}, - }, - HasError: true, - }, - { - Name: "GetInt64", - Params: []Param{ - {Name: "key", Type: "string"}, - }, - Returns: []Param{ - {Name: "value", Type: "int64"}, - {Name: "exists", Type: "bool"}, - }, - HasError: true, - }, - { - Name: "GetBytes", - Params: []Param{ - {Name: "key", Type: "string"}, - }, - Returns: []Param{ - {Name: "value", Type: "[]byte"}, - }, - HasError: true, - }, - }, - } - - code, err := GenerateClientGoStub(svc, "ndpdk") - Expect(err).NotTo(HaveOccurred()) - - // Verify it's valid Go code - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check string return uses args.String(0) - Expect(codeStr).To(ContainSubstring("args.String(0)")) - - // Check int64 return uses args.Get(0).(int64) - Expect(codeStr).To(ContainSubstring("args.Get(0).(int64)")) - - // Check bool return uses args.Bool(1) - Expect(codeStr).To(ContainSubstring("args.Bool(1)")) - - // []byte is nil-able, so it gets a guarded extraction - Expect(codeStr).To(ContainSubstring("var r0 []byte")) - Expect(codeStr).To(ContainSubstring("r0 = v.([]byte)")) - - // Check error returns use args.Error(N) - Expect(codeStr).To(ContainSubstring("args.Error(")) - }) - }) - - Describe("Integration", func() { - It("should generate compilable code from parsed source", func() { - // This is an integration test that verifies the full pipeline - src := `package host - -import "context" - -// TestService is a test service. -//nd:hostservice name=Test permission=test -type TestService interface { - // DoSomething does something. - //nd:hostfunc - DoSomething(ctx context.Context, input string) (output string, err error) -} -` - // Create temporary directory - tmpDir := GinkgoT().TempDir() - path := tmpDir + "/test.go" - err := writeFile(path, src) - Expect(err).NotTo(HaveOccurred()) - - // Parse - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services).To(HaveLen(1)) - - // Generate - code, err := GenerateHost(services[0], "host") - Expect(err).NotTo(HaveOccurred()) - - // Format (validates syntax) - formatted, err := format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - // Verify key elements - codeStr := string(formatted) - Expect(codeStr).To(ContainSubstring("RegisterTestHostFunctions")) - Expect(codeStr).To(ContainSubstring(`"test_dosomething"`)) - }) - }) - - Describe("GenerateCapabilityGo", func() { - It("should generate valid Go code for a non-required capability", func() { - cap := Capability{ - Name: "metadata", - Interface: "MetadataAgent", - Required: false, - Doc: "MetadataAgent provides metadata retrieval.", - Methods: []Export{ - { - Name: "GetArtistBiography", - ExportName: "nd_get_artist_biography", - Input: Param{Type: "ArtistInput"}, - Output: Param{Type: "ArtistBiographyOutput"}, - Doc: "Returns artist biography", - }, - { - Name: "GetArtistImages", - ExportName: "nd_get_artist_images", - Input: Param{Type: "ArtistInput"}, - Output: Param{Type: "ArtistImagesOutput"}, - Doc: "Returns artist images", - }, - }, - Structs: []StructDef{ - { - Name: "ArtistInput", - Fields: []FieldDef{ - {Name: "ID", Type: "string", JSONTag: "id"}, - {Name: "Name", Type: "string", JSONTag: "name"}, - }, - }, - { - Name: "ArtistBiographyOutput", - Fields: []FieldDef{ - {Name: "Biography", Type: "string", JSONTag: "biography"}, - }, - }, - { - Name: "ArtistImagesOutput", - Fields: []FieldDef{ - {Name: "Images", Type: "[]ImageInfo", JSONTag: "images"}, - }, - }, - { - Name: "ImageInfo", - Fields: []FieldDef{ - {Name: "URL", Type: "string", JSONTag: "url"}, - {Name: "Size", Type: "int32", JSONTag: "size"}, - }, - }, - }, - } - - code, err := GenerateCapabilityGo(cap, "metadata") - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for build tag - Expect(codeStr).To(ContainSubstring("//go:build wasip1")) - - // Check for package declaration - Expect(codeStr).To(ContainSubstring("package metadata")) - - // Check for marker interface (non-required) - Expect(codeStr).To(ContainSubstring("type Metadata interface{}")) - - // Check for provider interfaces - Expect(codeStr).To(ContainSubstring("type ArtistBiographyProvider interface")) - Expect(codeStr).To(ContainSubstring("type ArtistImagesProvider interface")) - - // Check for Register function with type assertions - Expect(codeStr).To(ContainSubstring("func Register(impl Metadata)")) - Expect(codeStr).To(ContainSubstring("impl.(ArtistBiographyProvider)")) - - // Check for export wrappers - Expect(codeStr).To(ContainSubstring("//go:wasmexport nd_get_artist_biography")) - Expect(codeStr).To(ContainSubstring("func _NdGetArtistBiography()")) - - // Check for NotImplementedCode handling - Expect(codeStr).To(ContainSubstring("NotImplementedCode")) - Expect(codeStr).To(ContainSubstring("return NotImplementedCode")) - - // Check struct definitions - Expect(codeStr).To(ContainSubstring("type ArtistInput struct")) - Expect(codeStr).To(ContainSubstring("type ImageInfo struct")) - }) - - It("should generate valid Go code for a required capability", func() { - cap := Capability{ - Name: "scrobbler", - Interface: "Scrobbler", - Required: true, - Methods: []Export{ - { - Name: "IsAuthorized", - ExportName: "nd_scrobbler_is_authorized", - Input: Param{Type: "AuthInput"}, - Output: Param{Type: "AuthOutput"}, - }, - { - Name: "Scrobble", - ExportName: "nd_scrobbler_scrobble", - Input: Param{Type: "ScrobbleInput"}, - Output: Param{Type: "ScrobblerOutput"}, - }, - }, - Structs: []StructDef{ - {Name: "AuthInput", Fields: []FieldDef{{Name: "UserID", Type: "string", JSONTag: "userId"}}}, - {Name: "AuthOutput", Fields: []FieldDef{{Name: "Authorized", Type: "bool", JSONTag: "authorized"}}}, - {Name: "ScrobbleInput", Fields: []FieldDef{{Name: "UserID", Type: "string", JSONTag: "userId"}}}, - {Name: "ScrobblerOutput", Fields: []FieldDef{{Name: "Error", Type: "*string", JSONTag: "error", OmitEmpty: true}}}, - }, - } - - code, err := GenerateCapabilityGo(cap, "scrobbler") - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for full interface (required capability) - Expect(codeStr).To(ContainSubstring("type Scrobbler interface {")) - Expect(codeStr).To(ContainSubstring("IsAuthorized(AuthInput) (AuthOutput, error)")) - Expect(codeStr).To(ContainSubstring("Scrobble(ScrobbleInput) (ScrobblerOutput, error)")) - - // Should NOT have provider interfaces for required capability - Expect(codeStr).NotTo(ContainSubstring("AuthProvider interface")) - - // Register should directly assign methods - Expect(codeStr).To(ContainSubstring("func Register(impl Scrobbler)")) - Expect(codeStr).To(ContainSubstring("impl.IsAuthorized")) - }) - - It("should include type aliases and consts", func() { - cap := Capability{ - Name: "scrobbler", - Interface: "Scrobbler", - Required: true, - Methods: []Export{ - { - Name: "Scrobble", - ExportName: "nd_scrobble", - Input: Param{Type: "ScrobbleInput"}, - Output: Param{Type: "ScrobblerOutput"}, - }, - }, - Structs: []StructDef{ - {Name: "ScrobbleInput", Fields: []FieldDef{{Name: "UserID", Type: "string", JSONTag: "userId"}}}, - {Name: "ScrobblerOutput", Fields: []FieldDef{{Name: "ErrorType", Type: "*ScrobblerErrorType", JSONTag: "errorType", OmitEmpty: true}}}, - }, - TypeAliases: []TypeAlias{ - {Name: "ScrobblerErrorType", Type: "string", Doc: "ScrobblerErrorType indicates error handling."}, - }, - Consts: []ConstGroup{ - { - Type: "ScrobblerErrorType", - Values: []ConstDef{ - {Name: "ScrobblerErrorNone", Value: `"none"`, Doc: "No error"}, - {Name: "ScrobblerErrorRetry", Value: `"retry"`, Doc: "Retry later"}, - }, - }, - }, - } - - code, err := GenerateCapabilityGo(cap, "scrobbler") - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check type alias - Expect(codeStr).To(ContainSubstring("type ScrobblerErrorType string")) - - // Check consts - all consts should have type annotation - Expect(codeStr).To(ContainSubstring("ScrobblerErrorNone ScrobblerErrorType =")) - Expect(codeStr).To(ContainSubstring(`"none"`)) - Expect(codeStr).To(ContainSubstring("ScrobblerErrorRetry ScrobblerErrorType =")) - Expect(codeStr).To(ContainSubstring(`"retry"`)) - }) - - It("emits a deprecated alias and types import for a shared-aliased capability", func() { - cap := Capability{ - Name: "scrobbler", - Interface: "Scrobbler", - Required: true, - Methods: []Export{{ - Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", - Input: Param{Name: "input", Type: "NowPlayingRequest"}, - }}, - Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ - {Name: "Track", Type: "TrackInfo", JSONTag: "track"}, - }}}, - SharedAliases: []SharedAlias{{ - Name: "TrackInfo", Target: "types.TrackInfo", - Doc: "Deprecated: use types.TrackInfo.", - Def: StructDef{Name: "TrackInfo", Fields: []FieldDef{{Name: "Title", Type: "string", JSONTag: "title"}}}, - }}, - } - code, err := GenerateCapabilityGo(cap, "scrobbler") - Expect(err).NotTo(HaveOccurred()) - out := string(code) - Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`)) - Expect(out).To(ContainSubstring("// Deprecated: use types.TrackInfo.")) - Expect(out).To(ContainSubstring("type TrackInfo = types.TrackInfo")) - Expect(out).NotTo(ContainSubstring("type TrackInfo struct")) - }) - - It("emits the types import for a direct types.X field with no deprecated alias", func() { - cap := Capability{ - Name: "scrobbler", - Interface: "Scrobbler", - Required: true, - Methods: []Export{{ - Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", - Input: Param{Name: "input", Type: "NowPlayingRequest"}, - }}, - Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ - {Name: "Song", Type: "types.SongRef", JSONTag: "song"}, - }}}, - // No SharedAliases: the field references the canonical type directly. - } - code, err := GenerateCapabilityGo(cap, "scrobbler") - Expect(err).NotTo(HaveOccurred()) - out := string(code) - Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`)) - Expect(out).To(ContainSubstring("types.SongRef")) - }) - - It("emits the types import for a direct types.X method input/output", func() { - cap := Capability{ - Name: "demo", - Interface: "Demo", - Required: true, - Methods: []Export{{ - Name: "Lookup", ExportName: "nd_demo_lookup", - Input: Param{Name: "input", Type: "types.SongRef"}, - Output: Param{Name: "output", Type: "types.SongRef"}, - }}, - // No structs, no aliases: the method signature references the shared type directly. - } - code, err := GenerateCapabilityGo(cap, "demo") - Expect(err).NotTo(HaveOccurred()) - out := string(code) - Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`)) - Expect(out).To(ContainSubstring("types.SongRef")) - }) - }) - - Describe("GenerateCapabilityGoStub", func() { - It("should generate valid stub code for non-WASM builds", func() { - cap := Capability{ - Name: "metadata", - Interface: "MetadataAgent", - Required: false, - Methods: []Export{ - { - Name: "GetArtistBiography", - ExportName: "nd_get_artist_biography", - Input: Param{Type: "ArtistInput"}, - Output: Param{Type: "ArtistBiographyOutput"}, - }, - }, - Structs: []StructDef{ - {Name: "ArtistInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, - {Name: "ArtistBiographyOutput", Fields: []FieldDef{{Name: "Biography", Type: "string", JSONTag: "biography"}}}, - }, - } - - code, err := GenerateCapabilityGoStub(cap, "metadata") - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check for non-WASM build tag - Expect(codeStr).To(ContainSubstring("//go:build !wasip1")) - - // Check for package declaration - Expect(codeStr).To(ContainSubstring("package metadata")) - - // Check for no-op Register - Expect(codeStr).To(ContainSubstring("func Register(_ Metadata) {}")) - - // Check struct definitions are present - Expect(codeStr).To(ContainSubstring("type ArtistInput struct")) - - // Check there are no export wrappers - Expect(codeStr).NotTo(ContainSubstring("//go:wasmexport")) - Expect(codeStr).NotTo(ContainSubstring("pdk.InputJSON")) - }) - }) - - Describe("End-to-end capability generation", func() { - It("should parse and generate capability code from source", func() { - src := `package capabilities - -// Lifecycle provides plugin lifecycle hooks. -//nd:capability name=lifecycle -type Lifecycle interface { - // OnInit is called when the plugin is loaded. - //nd:export name=nd_on_init - OnInit(OnInitInput) (OnInitOutput, error) -} - -// OnInitInput is the input for OnInit. -type OnInitInput struct { -} - -// OnInitOutput is the output for OnInit. -type OnInitOutput struct { - // Error is the error message if initialization failed. - Error *string ` + "`json:\"error,omitempty\"`" + ` -} -` - // Create temporary directory - tmpDir := GinkgoT().TempDir() - path := tmpDir + "/lifecycle.go" - err := writeFile(path, src) - Expect(err).NotTo(HaveOccurred()) - - // Parse - capabilities, err := ParseCapabilities(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(capabilities).To(HaveLen(1)) - - cap := capabilities[0] - Expect(cap.Name).To(Equal("lifecycle")) - Expect(cap.Methods).To(HaveLen(1)) - - // Generate WASM code - code, err := GenerateCapabilityGo(cap, "lifecycle") - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - Expect(codeStr).To(ContainSubstring("//go:wasmexport nd_on_init")) - Expect(codeStr).To(ContainSubstring("type InitProvider interface")) - - // Generate stub code - stubCode, err := GenerateCapabilityGoStub(cap, "lifecycle") - Expect(err).NotTo(HaveOccurred()) - - stubStr := string(stubCode) - Expect(stubStr).To(ContainSubstring("//go:build !wasip1")) - Expect(stubStr).To(ContainSubstring("func Register(_ Lifecycle) {}")) - }) - }) -}) - -var _ = Describe("Shared Types Generation", func() { - It("emits a Rust types crate root with serde derives", func() { - structs := []StructDef{ - {Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{ - {Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true}, - {Name: "Name", Type: "string", JSONTag: "name"}, - }}, - } - code, err := GenerateSharedTypesRust(structs) - Expect(err).NotTo(HaveOccurred()) - out := string(code) - Expect(out).To(ContainSubstring("use serde::{Deserialize, Serialize};")) - Expect(out).To(ContainSubstring("pub struct ArtistRef {")) - Expect(out).To(ContainSubstring(`#[serde(rename_all = "camelCase")]`)) - Expect(out).To(ContainSubstring("pub name: String,")) - }) - - It("emits a flat Go types package with no imports", func() { - structs := []StructDef{ - {Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{ - {Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true}, - {Name: "Name", Type: "string", JSONTag: "name"}, - }}, - } - code, err := GenerateSharedTypesGo(structs, "types") - Expect(err).NotTo(HaveOccurred()) - out := string(code) - Expect(out).To(ContainSubstring("package types")) - Expect(out).To(ContainSubstring("type ArtistRef struct {")) - Expect(out).To(ContainSubstring("ID string `json:\"id,omitempty\"`")) - Expect(out).To(ContainSubstring("Name string `json:\"name\"`")) - Expect(out).NotTo(ContainSubstring("import")) - }) - - It("emits base64 serde for Vec fields in the Rust types crate", func() { - structs := []StructDef{ - {Name: "Payload", Doc: "Payload carries raw bytes.", Fields: []FieldDef{ - {Name: "Data", Type: "[]byte", JSONTag: "data"}, - }}, - } - code, err := GenerateSharedTypesRust(structs) - Expect(err).NotTo(HaveOccurred()) - out := string(code) - Expect(out).To(ContainSubstring("mod base64_bytes")) - Expect(out).To(ContainSubstring("use base64::Engine as _")) - Expect(out).To(ContainSubstring(`#[serde(with = "base64_bytes")]`)) - }) -}) - -var _ = Describe("Rust Generation", func() { - Describe("skipSerializingFunc", func() { - It("should return Option::is_none for pointer types", func() { - Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none")) - Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none")) - }) - - It("should return the matching emptiness predicate for slice and map types", func() { - // The predicate must match the rendered Rust type: []T -> Vec, map[K]V -> HashMap. - Expect(skipSerializingFunc("[]string")).To(Equal("Vec::is_empty")) - Expect(skipSerializingFunc("[]int32")).To(Equal("Vec::is_empty")) - Expect(skipSerializingFunc("map[string]int")).To(Equal("HashMap::is_empty")) - }) - - It("should return String::is_empty for string type", func() { - Expect(skipSerializingFunc("string")).To(Equal("String::is_empty")) - }) - - It("should return std::ops::Not::not for bool type", func() { - Expect(skipSerializingFunc("bool")).To(Equal("std::ops::Not::not")) - }) - - It("should return is_zero_* functions for numeric types", func() { - Expect(skipSerializingFunc("int32")).To(Equal("is_zero_i32")) - Expect(skipSerializingFunc("uint32")).To(Equal("is_zero_u32")) - Expect(skipSerializingFunc("int64")).To(Equal("is_zero_i64")) - Expect(skipSerializingFunc("uint64")).To(Equal("is_zero_u64")) - Expect(skipSerializingFunc("float32")).To(Equal("is_zero_f32")) - Expect(skipSerializingFunc("float64")).To(Equal("is_zero_f64")) - }) - - It("should return Option::is_none for unknown types", func() { - Expect(skipSerializingFunc("CustomType")).To(Equal("Option::is_none")) - }) - }) - - Describe("rustOutputType", func() { - It("should convert Go primitives to Rust primitives", func() { - Expect(rustOutputType("bool")).To(Equal("bool")) - Expect(rustOutputType("string")).To(Equal("String")) - Expect(rustOutputType("int")).To(Equal("i32")) - Expect(rustOutputType("int32")).To(Equal("i32")) - Expect(rustOutputType("int64")).To(Equal("i64")) - Expect(rustOutputType("float32")).To(Equal("f32")) - Expect(rustOutputType("float64")).To(Equal("f64")) - }) - - It("should strip pointer prefix", func() { - // NOTE: This behavior is incorrect for pointer to primitives. - // "*string" returns "string" instead of "String", which would generate - // invalid Rust code. No current capability uses this pattern. - // See TODO in rustOutputType function. - Expect(rustOutputType("*string")).To(Equal("string")) - Expect(rustOutputType("*MyStruct")).To(Equal("MyStruct")) - }) - - It("should pass through unknown types", func() { - Expect(rustOutputType("CustomType")).To(Equal("CustomType")) - Expect(rustOutputType("MyStruct")).To(Equal("MyStruct")) - }) - }) - - Describe("isPrimitiveRustType", func() { - It("should return true for primitive Go types", func() { - Expect(isPrimitiveRustType("bool")).To(BeTrue()) - Expect(isPrimitiveRustType("string")).To(BeTrue()) - Expect(isPrimitiveRustType("int")).To(BeTrue()) - Expect(isPrimitiveRustType("int32")).To(BeTrue()) - Expect(isPrimitiveRustType("int64")).To(BeTrue()) - Expect(isPrimitiveRustType("float32")).To(BeTrue()) - Expect(isPrimitiveRustType("float64")).To(BeTrue()) - }) - - It("should return false for non-primitive types", func() { - Expect(isPrimitiveRustType("MyStruct")).To(BeFalse()) - Expect(isPrimitiveRustType("CustomType")).To(BeFalse()) - Expect(isPrimitiveRustType("[]string")).To(BeFalse()) - Expect(isPrimitiveRustType("map[string]int")).To(BeFalse()) - }) - - It("should handle pointer types by stripping prefix", func() { - Expect(isPrimitiveRustType("*string")).To(BeTrue()) - Expect(isPrimitiveRustType("*int64")).To(BeTrue()) - Expect(isPrimitiveRustType("*MyStruct")).To(BeFalse()) - }) - }) - - Describe("GenerateCapabilityRust", func() { - It("should generate valid Rust code with primitive output types", func() { - cap := Capability{ - Name: "test", - Interface: "TestAgent", - Required: true, - SourceFile: "test", - Methods: []Export{ - { - Name: "GetBool", - ExportName: "nd_get_bool", - Input: Param{Type: "BoolInput"}, - Output: Param{Type: "bool"}, - }, - { - Name: "GetString", - ExportName: "nd_get_string", - Input: Param{Type: "StrInput"}, - Output: Param{Type: "string"}, - }, - { - Name: "GetInt", - ExportName: "nd_get_int", - Input: Param{Type: "IntInput"}, - Output: Param{Type: "int32"}, - }, - }, - Structs: []StructDef{ - {Name: "BoolInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, - {Name: "StrInput", Fields: []FieldDef{{Name: "Key", Type: "string", JSONTag: "key"}}}, - {Name: "IntInput", Fields: []FieldDef{{Name: "Index", Type: "int32", JSONTag: "index"}}}, - }, - } - - code, err := GenerateCapabilityRust(cap) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Check that primitive output types are not prefixed with $crate:: - // The template should use isPrimitiveRust to determine this - Expect(codeStr).To(ContainSubstring("FnResult>")) - Expect(codeStr).To(ContainSubstring("FnResult>")) - Expect(codeStr).To(ContainSubstring("FnResult>")) - - // Verify that primitive output types don't use $crate:: prefix in FnResult - // The pattern "$crate::test::bool>" would indicate incorrect generation - Expect(codeStr).NotTo(ContainSubstring("$crate::test::bool>")) - Expect(codeStr).NotTo(ContainSubstring("$crate::test::String>")) - Expect(codeStr).NotTo(ContainSubstring("$crate::test::i32>")) - }) - - It("should generate valid Rust code with struct output types", func() { - cap := Capability{ - Name: "metadata", - Interface: "MetadataAgent", - Required: true, - SourceFile: "metadata", - Methods: []Export{ - { - Name: "GetArtist", - ExportName: "nd_get_artist", - Input: Param{Type: "ArtistInput"}, - Output: Param{Type: "ArtistOutput"}, - }, - }, - Structs: []StructDef{ - {Name: "ArtistInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, - {Name: "ArtistOutput", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, - }, - } - - code, err := GenerateCapabilityRust(cap) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Non-primitive struct types should use $crate:: prefix - Expect(codeStr).To(ContainSubstring("$crate::metadata::ArtistOutput")) - }) - - It("should generate valid Rust code with pointer output types", func() { - cap := Capability{ - Name: "test", - Interface: "TestAgent", - Required: true, - SourceFile: "test", - Methods: []Export{ - { - Name: "GetOptionalStruct", - ExportName: "nd_get_optional_struct", - Input: Param{Type: "Input"}, - Output: Param{Type: "*Output"}, - }, - }, - Structs: []StructDef{ - {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, - {Name: "Output", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}}, - }, - } - - code, err := GenerateCapabilityRust(cap) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Pointer to struct should strip pointer and use struct type with $crate:: - Expect(codeStr).To(ContainSubstring("$crate::test::Output>")) - // Pointer output types should NOT have Option<> wrapping - Result handles optionality - Expect(codeStr).NotTo(ContainSubstring("Option<")) - }) - - It("translates a shared type used directly as a method input/output", func() { - cap := Capability{ - Name: "demo", Interface: "Demo", Required: true, - Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo", - Input: Param{Name: "input", Type: "types.SongRef"}, - Output: Param{Name: "output", Type: "types.SongRef"}}}, - // No structs, no aliases: the method signature references the shared type directly. - } - code, err := GenerateCapabilityRust(cap) - Expect(err).NotTo(HaveOccurred()) - out := string(code) - // The shared type must resolve to the canonical crate path, not pass through - // as the invalid Go selector `types.SongRef`. - Expect(out).To(ContainSubstring("nd_pdk_types::SongRef")) - Expect(out).NotTo(ContainSubstring("types.SongRef")) - }) - - It("routes shared macro types through $crate so umbrella-crate plugins resolve them", func() { - cap := Capability{ - Name: "demo", Interface: "Demo", Required: true, - Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo", - Input: Param{Name: "input", Type: "types.SongRef"}, - Output: Param{Name: "output", Type: "types.SongRef"}}}, - } - code, err := GenerateCapabilityRust(cap) - Expect(err).NotTo(HaveOccurred()) - out := string(code) - // Inside the export macro (expanded in the downstream plugin crate, which depends - // on the umbrella nd-pdk only), the shared type must be reachable via $crate, not - // by naming the transitive nd_pdk_types crate directly. - Expect(out).To(ContainSubstring("extism_pdk::Json<$crate::types::SongRef>")) - Expect(out).NotTo(ContainSubstring("extism_pdk::Json")) - }) - - It("emits a deprecated Rust type alias for shared types", func() { - cap := Capability{ - Name: "scrobbler", Interface: "Scrobbler", Required: true, - Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", - Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, - Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ - {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, - SharedAliases: []SharedAlias{{ - Name: "TrackInfo", Target: "types.TrackInfo", - Doc: "Deprecated: use types.TrackInfo.", - }}, - } - code, err := GenerateCapabilityRust(cap) - Expect(err).NotTo(HaveOccurred()) - out := string(code) - // Note points authors at the umbrella path (nd-pdk re-exports nd_pdk_types as `types`); - // the alias target stays the real crate path so it resolves inside nd-pdk-capabilities. - Expect(out).To(ContainSubstring(`#[deprecated(note = "use nd_pdk::types::TrackInfo")]`)) - Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::TrackInfo;")) - }) - - It("keeps struct-field type when the type name is a shared alias (regression: was serde_json::Value)", func() { - // Wrapper has a field whose type is only in SharedAliases, not Structs. - // The field must render as `pub track: nd_pdk_types::TrackInfo` (canonical - // path), not as the local deprecated alias and not as serde_json::Value. - cap := Capability{ - Name: "test", Interface: "TestAgent", Required: true, - Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit", - Input: Param{Name: "req", Type: "Wrapper"}}}, - Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{ - {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, - SharedAliases: []SharedAlias{{ - Name: "TrackInfo", Target: "types.TrackInfo", - Doc: "Deprecated: use types.TrackInfo.", - }}, - } - code, err := GenerateCapabilityRust(cap) - Expect(err).NotTo(HaveOccurred()) - out := string(code) - // Field must use the canonical nd_pdk_types:: path, not the local alias. - Expect(out).To(ContainSubstring("nd_pdk_types::TrackInfo")) - Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value")) - Expect(out).NotTo(ContainSubstring("pub track: TrackInfo,")) - }) - - It("renders a qualified types.X field as nd_pdk_types::X and keeps the renamed re-export", func() { - // The capability references the shared type by its canonical qualified - // name (types.Track) while the deprecated alias keeps the old name. - cap := Capability{ - Name: "test", Interface: "TestAgent", Required: true, - Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit", - Input: Param{Name: "req", Type: "Wrapper"}}}, - Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{ - {Name: "Track", Type: "types.Track", JSONTag: "track"}}}}, - SharedAliases: []SharedAlias{{ - Name: "TrackInfo", Target: "types.Track", - Doc: "Deprecated: use types.Track.", - }}, - } - code, err := GenerateCapabilityRust(cap) - Expect(err).NotTo(HaveOccurred()) - out := string(code) - // Field uses the canonical qualified path (resolved from the types. prefix). - Expect(out).To(ContainSubstring("pub track: nd_pdk_types::Track,")) - // The deprecated alias is still re-exported under its kept name. - Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::Track;")) - Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value")) - }) - - It("should include all float types correctly", func() { - cap := Capability{ - Name: "test", - Interface: "TestAgent", - Required: true, - SourceFile: "test", - Methods: []Export{ - { - Name: "GetFloat32", - ExportName: "nd_get_float32", - Input: Param{Type: "Input"}, - Output: Param{Type: "float32"}, - }, - { - Name: "GetFloat64", - ExportName: "nd_get_float64", - Input: Param{Type: "Input"}, - Output: Param{Type: "float64"}, - }, - }, - Structs: []StructDef{ - {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, - }, - } - - code, err := GenerateCapabilityRust(cap) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - Expect(codeStr).To(ContainSubstring("FnResult>")) - Expect(codeStr).To(ContainSubstring("FnResult>")) - }) - }) - - Describe("GenerateClientRust", func() { - It("should generate Option for (value, exists bool) pattern", func() { - svc := Service{ - Name: "Config", - Permission: "config", - Interface: "ConfigService", - Methods: []Method{ - { - Name: "Get", - Params: []Param{ - {Name: "key", Type: "string", JSONName: "key"}, - }, - Returns: []Param{ - {Name: "value", Type: "string", JSONName: "value"}, - {Name: "exists", Type: "bool", JSONName: "exists"}, - }, - }, - }, - } - - code, err := GenerateClientRust(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should generate Option return type, not (String, bool) - Expect(codeStr).To(ContainSubstring("Result, Error>")) - Expect(codeStr).NotTo(ContainSubstring("Result<(String, bool), Error>")) - - // Should generate Some/None logic - Expect(codeStr).To(ContainSubstring("Ok(Some(")) - Expect(codeStr).To(ContainSubstring("Ok(None)")) - }) - - It("should generate tuple for non-option multi-return", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "GetStats", - Returns: []Param{ - {Name: "count", Type: "int64", JSONName: "count"}, - {Name: "size", Type: "int64", JSONName: "size"}, - }, - }, - }, - } - - code, err := GenerateClientRust(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should generate tuple return type - Expect(codeStr).To(ContainSubstring("Result<(i64, i64), Error>")) - Expect(codeStr).NotTo(ContainSubstring("Option<")) - }) - - It("should NOT generate Option for Has() pattern where first return is bool", func() { - svc := Service{ - Name: "Cache", - Permission: "cache", - Interface: "CacheService", - Methods: []Method{ - { - Name: "Has", - Params: []Param{ - {Name: "key", Type: "string", JSONName: "key"}, - }, - Returns: []Param{ - {Name: "exists", Type: "bool", JSONName: "exists"}, - }, - }, - }, - } - - code, err := GenerateClientRust(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should generate simple bool return, not Option - Expect(codeStr).To(ContainSubstring("Result")) - Expect(codeStr).NotTo(ContainSubstring("Option")) - }) - - It("should generate base64 serde for Vec fields", func() { - svc := Service{ - Name: "Codec", - Permission: "codec", - Interface: "CodecService", - Methods: []Method{ - { - Name: "Encode", - HasError: true, - Params: []Param{NewParam("data", "[]byte")}, - Returns: []Param{NewParam("result", "[]byte")}, - }, - }, - } - - code, err := GenerateClientRust(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - // Should generate base64_bytes serde module - Expect(codeStr).To(ContainSubstring("mod base64_bytes")) - Expect(codeStr).To(ContainSubstring("use base64::Engine as _")) - - // Should add serde(with = "base64_bytes") on Vec fields - Expect(codeStr).To(ContainSubstring(`#[serde(with = "base64_bytes")]`)) - }) - - It("resolves a shared alias used in a method param/return to its canonical crate path", func() { - svc := Service{ - Name: "Matcher", - Permission: "matcher", - Interface: "MatcherService", - Methods: []Method{ - { - Name: "MatchSongs", - HasError: true, - Params: []Param{NewParam("query", "string")}, - // Return uses the deprecated alias name directly. - Returns: []Param{NewParam("matches", "[]Track")}, - }, - }, - SharedAliases: []SharedAlias{{ - Name: "Track", Target: "types.Track", - Def: StructDef{Name: "Track", Fields: []FieldDef{ - {Name: "Title", Type: "string", JSONTag: "title"}}}, - }}, - } - - code, err := GenerateClientRust(svc) - Expect(err).NotTo(HaveOccurred()) - out := string(code) - // The alias must resolve to the shared crate type; a bare `Track` is undefined - // in nd-pdk-host and would not compile. - Expect(out).To(ContainSubstring("nd_pdk_types::Track")) - Expect(out).NotTo(ContainSubstring("Vec")) - }) - - It("should not generate base64 module when no byte fields", func() { - svc := Service{ - Name: "Test", - Permission: "test", - Interface: "TestService", - Methods: []Method{ - { - Name: "Call", - HasError: true, - Params: []Param{NewParam("uri", "string")}, - Returns: []Param{NewParam("response", "string")}, - }, - }, - } - - code, err := GenerateClientRust(svc) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - - Expect(codeStr).NotTo(ContainSubstring("mod base64_bytes")) - Expect(codeStr).NotTo(ContainSubstring("use base64")) - }) - }) - - Describe("nil-safe mock accessors", func() { - Describe("GenerateClientGoStub", func() { - It("guards pointer, slice and map returns so Return(nil, ...) does not panic", func() { - svc := Service{ - Name: "Paged", - Interface: "PagedService", - Methods: []Method{ - { - Name: "GetPage", - HasError: true, - Params: []Param{NewParam("query", "string")}, - Returns: []Param{NewParam("items", "[]Item"), NewParam("next", "*PageOptions")}, - }, - { - Name: "GetLabels", - HasError: true, - Params: []Param{NewParam("id", "string")}, - Returns: []Param{NewParam("labels", "map[string]string")}, - }, - }, - } - - code, err := GenerateClientGoStub(svc, "host") - Expect(err).NotTo(HaveOccurred()) - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - Expect(codeStr).To(ContainSubstring("var r0 []Item")) - Expect(codeStr).To(ContainSubstring("var r1 *PageOptions")) - Expect(codeStr).To(ContainSubstring("if v := args.Get(1); v != nil {")) - Expect(codeStr).To(ContainSubstring("r1 = v.(*PageOptions)")) - Expect(codeStr).To(ContainSubstring("return r0, r1, args.Error(2)")) - Expect(codeStr).To(ContainSubstring("var r0 map[string]string")) - Expect(codeStr).NotTo(ContainSubstring("args.Get(0).([]Item)")) - Expect(codeStr).NotTo(ContainSubstring("args.Get(1).(*PageOptions)")) - }) - - It("keeps non-nilable returns as inline accessors", func() { - svc := Service{ - Name: "Counter", - Interface: "CounterService", - Methods: []Method{ - { - Name: "Count", - HasError: true, - Params: []Param{NewParam("id", "string")}, - Returns: []Param{NewParam("count", "int64"), NewParam("name", "string")}, - }, - }, - } - - code, err := GenerateClientGoStub(svc, "host") - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - Expect(codeStr).To(ContainSubstring("return args.Get(0).(int64), args.String(1), args.Error(2)")) - Expect(codeStr).NotTo(ContainSubstring("var r0")) - }) - }) - - Describe("GeneratePDKGoStub", func() { - It("guards pointer and slice returns", func() { - symbols := &PDKSymbols{ - Functions: []PDKFunc{ - { - Name: "NewHTTPRequest", - Params: []PDKParam{{Name: "method", Type: "HTTPMethod"}, {Name: "url", Type: "string"}}, - Returns: []PDKReturn{{Type: "*HTTPRequest"}}, - }, - { - Name: "Input", - Returns: []PDKReturn{{Type: "[]byte"}}, - }, - }, - } - - code, err := GeneratePDKGoStub(symbols) - Expect(err).NotTo(HaveOccurred()) - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - - codeStr := string(code) - Expect(codeStr).To(ContainSubstring("var r0 *HTTPRequest")) - Expect(codeStr).To(ContainSubstring("r0 = v.(*HTTPRequest)")) - Expect(codeStr).To(ContainSubstring("var r0 []byte")) - Expect(codeStr).NotTo(ContainSubstring("return args.Get(0).(*HTTPRequest)")) - }) - }) - - Describe("Deprecated PDK functions", func() { - symbols := &PDKSymbols{ - Functions: []PDKFunc{ - { - Name: "NewHTTPRequest", - Doc: "NewHTTPRequest returns a new `HTTPRequest`.", - Params: []PDKParam{{Name: "method", Type: "HTTPMethod"}, {Name: "url", Type: "string"}}, - Returns: []PDKReturn{{Type: "*HTTPRequest"}}, - Deprecated: "Use host.HTTPSend instead.", - }, - }, - } - - DescribeTable("emits a Deprecated paragraph after the doc line", - func(generate func(*PDKSymbols) ([]byte, error)) { - code, err := generate(symbols) - Expect(err).NotTo(HaveOccurred()) - _, err = format.Source(code) - Expect(err).NotTo(HaveOccurred()) - Expect(string(code)).To(ContainSubstring( - "// NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`.\n//\n// Deprecated: Use host.HTTPSend instead.\nfunc NewHTTPRequest(")) - }, - Entry("WASM wrapper", GeneratePDKGo), - Entry("native stub", GeneratePDKGoStub), - ) - }) - }) -}) - -func writeFile(path, content string) error { - return os.WriteFile(path, []byte(content), 0600) -} diff --git a/plugins/cmd/ndpgen/internal/internal_suite_test.go b/plugins/cmd/ndpgen/internal/internal_suite_test.go deleted file mode 100644 index 5c7d27088..000000000 --- a/plugins/cmd/ndpgen/internal/internal_suite_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package internal - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestInternal(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "NDPGen Internal Suite") -} diff --git a/plugins/cmd/ndpgen/internal/parser.go b/plugins/cmd/ndpgen/internal/parser.go deleted file mode 100644 index 9f305efec..000000000 --- a/plugins/cmd/ndpgen/internal/parser.go +++ /dev/null @@ -1,1046 +0,0 @@ -package internal - -import ( - "fmt" - "go/ast" - "go/parser" - "go/token" - "maps" - "os" - "path/filepath" - "regexp" - "slices" - "strings" -) - -// Annotation patterns -var ( - // //nd:hostservice name=ServiceName permission=key - hostServicePattern = regexp.MustCompile(`//nd:hostservice\s+(.*)`) - // //nd:hostfunc [name=CustomName] - hostFuncPattern = regexp.MustCompile(`//nd:hostfunc(?:\s+(.*))?`) - // //nd:capability name=PackageName [required=true] - capabilityPattern = regexp.MustCompile(`//nd:capability\s+(.*)`) - // //nd:export name=ExportName - exportPattern = regexp.MustCompile(`//nd:export\s+(.*)`) - // key=value pairs - keyValuePattern = regexp.MustCompile(`(\w+)=(\S+)`) -) - -// parsedGoFile pairs a source path with its already-parsed AST. -type parsedGoFile struct { - path string - file *ast.File -} - -// parseGoFiles returns the eligible Go source files in dir, each parsed once. -func parseGoFiles(dir string, fset *token.FileSet) ([]parsedGoFile, error) { - paths, err := goSourceFiles(dir) - if err != nil { - return nil, err - } - out := make([]parsedGoFile, 0, len(paths)) - for _, p := range paths { - f, err := parser.ParseFile(fset, p, nil, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("parsing %s: %w", filepath.Base(p), err) - } - out = append(out, parsedGoFile{path: p, file: f}) - } - return out, nil -} - -// goSourceFiles returns the Go source file paths in dir, excluding generated, -// test, and doc files. -func goSourceFiles(dir string) ([]string, error) { - entries, err := os.ReadDir(dir) - if err != nil { - return nil, fmt.Errorf("reading directory: %w", err) - } - var paths []string - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() || !strings.HasSuffix(name, ".go") { - continue - } - if strings.HasSuffix(name, "_gen.go") || strings.HasSuffix(name, "_test.go") || name == "doc.go" { - continue - } - paths = append(paths, filepath.Join(dir, name)) - } - return paths, nil -} - -// ParseDirectory parses all Go source files in a directory and extracts host services. -func ParseDirectory(dir string) ([]Service, error) { - return ParseDirectoryWithShared(dir, nil) -} - -// ParseDirectoryWithShared parses all Go source files in a directory, resolving any -// type aliases that reference the shared `types` package against the provided registry. -func ParseDirectoryWithShared(dir string, shared map[string]StructDef) ([]Service, error) { - fset := token.NewFileSet() - parsed, err := parseGoFiles(dir, fset) - if err != nil { - return nil, err - } - - // First pass: collect all struct definitions and type aliases from every file - // so that a struct or alias declared in one file is visible when resolving - // types in a sibling file. - pkgStructMap := make(map[string]StructDef) - pkgAliasMap := make(map[string]TypeAlias) - for _, pf := range parsed { - for _, s := range parseStructs(pf.file) { - pkgStructMap[s.Name] = s - } - for _, a := range parseTypeAliases(pf.file) { - pkgAliasMap[a.Name] = a - } - } - - // Second pass: parse services using the package-level maps. - var services []Service - for _, pf := range parsed { - svcList, err := parseServiceFile(pf.file, pkgStructMap, pkgAliasMap, shared) - if err != nil { - return nil, fmt.Errorf("parsing %s: %w", filepath.Base(pf.path), err) - } - services = append(services, svcList...) - } - - return services, nil -} - -// ParseCapabilities parses all Go source files in a directory and extracts capabilities. -func ParseCapabilities(dir string) ([]Capability, error) { - return ParseCapabilitiesWithShared(dir, nil) -} - -// ParseCapabilitiesWithShared parses all Go source files in a directory, resolving any -// type aliases that reference the shared `types` package against the provided registry. -func ParseCapabilitiesWithShared(dir string, shared map[string]StructDef) ([]Capability, error) { - fset := token.NewFileSet() - parsed, err := parseGoFiles(dir, fset) - if err != nil { - return nil, err - } - - // First pass: collect all structs, type aliases, and const groups. - pkgStructMap := make(map[string]StructDef) - pkgAliasMap := make(map[string]TypeAlias) - var allConstGroups []ConstGroup - - for _, pf := range parsed { - for _, s := range parseStructs(pf.file) { - pkgStructMap[s.Name] = s - } - for _, a := range parseTypeAliases(pf.file) { - pkgAliasMap[a.Name] = a - } - allConstGroups = append(allConstGroups, parseConstGroups(pf.file)...) - } - - // Second pass: parse capabilities using the package-level type maps. - var capabilities []Capability - for _, pf := range parsed { - capList, err := parseCapabilityFile(pf.path, pf.file, pkgStructMap, pkgAliasMap, allConstGroups, shared) - if err != nil { - return nil, fmt.Errorf("parsing %s: %w", filepath.Base(pf.path), err) - } - capabilities = append(capabilities, capList...) - } - - return capabilities, nil -} - -// LoadSharedTypes parses every struct defined in dir (the shared `types` source -// package) and returns them keyed by name. dir == "" yields an empty map. -func LoadSharedTypes(dir string) (map[string]StructDef, error) { - result := map[string]StructDef{} - if dir == "" { - return result, nil - } - paths, err := goSourceFiles(dir) - if err != nil { - return nil, fmt.Errorf("reading shared types directory: %w", err) - } - fset := token.NewFileSet() - for _, path := range paths { - f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) - if err != nil { - return nil, fmt.Errorf("parsing %s: %w", filepath.Base(path), err) - } - for _, s := range parseStructs(f) { - result[s.Name] = s - } - } - return result, nil -} - -// parseCapabilityFile parses a single Go source file and extracts capabilities. -func parseCapabilityFile(path string, f *ast.File, structMap map[string]StructDef, aliasMap map[string]TypeAlias, allConstGroups []ConstGroup, shared map[string]StructDef) ([]Capability, error) { - var capabilities []Capability - - for _, decl := range f.Decls { - genDecl, ok := decl.(*ast.GenDecl) - if !ok || genDecl.Tok != token.TYPE { - continue - } - - for _, spec := range genDecl.Specs { - typeSpec, ok := spec.(*ast.TypeSpec) - if !ok { - continue - } - - interfaceType, ok := typeSpec.Type.(*ast.InterfaceType) - if !ok { - continue - } - - // Check for //nd:capability annotation in doc comment - docText, rawDoc := getDocComment(genDecl, typeSpec) - capAnnotation := parseCapabilityAnnotation(rawDoc) - if capAnnotation == nil { - continue - } - - // Extract source file base name (e.g., "websocket_callback" from "websocket_callback.go") - baseName := filepath.Base(path) - sourceFile := strings.TrimSuffix(baseName, ".go") - - capability := Capability{ - Name: capAnnotation["name"], - Interface: typeSpec.Name.Name, - Required: capAnnotation["required"] == "true", - Doc: cleanDoc(docText), - SourceFile: sourceFile, - } - - // Parse methods and collect referenced types - referencedTypes := make(map[string]bool) - for _, method := range interfaceType.Methods.List { - if len(method.Names) == 0 { - continue // Embedded interface - } - - funcType, ok := method.Type.(*ast.FuncType) - if !ok { - continue - } - - // Check for //nd:export annotation - methodDocText, methodRawDoc := getMethodDocComment(method) - exportAnnotation := parseExportAnnotation(methodRawDoc) - if exportAnnotation == nil { - continue - } - - export, err := parseExport(method.Names[0].Name, funcType, exportAnnotation, cleanDoc(methodDocText)) - if err != nil { - return nil, fmt.Errorf("parsing export %s.%s: %w", typeSpec.Name.Name, method.Names[0].Name, err) - } - capability.Methods = append(capability.Methods, export) - - // Collect referenced types from input and output - collectReferencedTypes(export.Input.Type, referencedTypes) - collectReferencedTypes(export.Output.Type, referencedTypes) - } - - // Recursively collect all struct dependencies - collectAllStructDependencies(referencedTypes, structMap) - - // Resolve shared-type aliases against the registry - sharedAliases, sharedTypes, err := resolveSharedAliases(referencedTypes, aliasMap, shared) - if err != nil { - return nil, err - } - capability.SharedAliases = sharedAliases - capability.SharedTypes = sharedTypes - - // Build a set of names already covered by SharedAliases so we don't - // emit them again in TypeAliases (which would cause a redeclaration). - sharedAliasNames := make(map[string]bool, len(capability.SharedAliases)) - for _, sa := range capability.SharedAliases { - sharedAliasNames[sa.Name] = true - } - - // Sort type names for stable output order - sortedTypeNames := slices.Sorted(maps.Keys(referencedTypes)) - - // Attach referenced structs to the capability - for _, typeName := range sortedTypeNames { - if s, exists := structMap[typeName]; exists { - capability.Structs = append(capability.Structs, s) - } - } - - // Attach referenced type aliases (skip those already in SharedAliases) - for _, typeName := range sortedTypeNames { - if sharedAliasNames[typeName] { - continue - } - if a, exists := aliasMap[typeName]; exists { - capability.TypeAliases = append(capability.TypeAliases, a) - } - } - - // Also attach type aliases prefixed with interface name (e.g., ScrobblerError for Scrobbler interface) - // This supports error types that are not directly referenced in method signatures - interfaceName := typeSpec.Name.Name - for _, typeName := range slices.Sorted(maps.Keys(aliasMap)) { - if sharedAliasNames[typeName] { - continue - } - a := aliasMap[typeName] - if strings.HasPrefix(typeName, interfaceName) && !referencedTypes[typeName] { - capability.TypeAliases = append(capability.TypeAliases, a) - referencedTypes[typeName] = true // Mark as referenced for const lookup - } - } - - // Attach const groups that match referenced type aliases - for _, group := range allConstGroups { - if group.Type == "" { - continue - } - if referencedTypes[group.Type] { - capability.Consts = append(capability.Consts, group) - } - } - - if len(capability.Methods) > 0 { - capabilities = append(capabilities, capability) - } - } - } - - return capabilities, nil -} - -// resolveSharedAliases determines which shared `types` package structs a host -// service or capability uses and returns the deprecated re-export aliases to emit -// for them. -// -// A shared type counts as used when a field references it by qualified name -// (e.g. types.Track) or via a declared alias used by bare name (e.g. a field of -// type TrackInfo where `type TrackInfo = types.Track`). The shared struct's own -// fields are followed transitively so nested shared types are picked up too. For -// every used canonical type, each declared `type X = types.Canonical` alias is -// emitted as a SharedAlias so the generated PDK keeps re-exporting it for -// backwards compatibility. -// -// It returns the deprecated re-export aliases to emit and the resolved shapes of -// every used shared type (alias or not, for schema inlining). -// -// Returns an error if a referenced shared type cannot be found in the shared registry. -func resolveSharedAliases(referenced map[string]bool, aliasMap map[string]TypeAlias, shared map[string]StructDef) ([]SharedAlias, []StructDef, error) { - // Index declared shared aliases by the canonical type they target, e.g. - // "Track" -> [TrackInfo]. A canonical type may have more than one alias. - aliasesByCanonical := map[string][]TypeAlias{} - for _, a := range aliasMap { - if a.IsSharedAlias() { - canonical := strings.TrimPrefix(a.Type, sharedTypesPrefix) - aliasesByCanonical[canonical] = append(aliasesByCanonical[canonical], a) - } - } - - // Walk the referenced types, following nested shared references inside the - // shared structs, to find the set of canonical shared types used. - used := map[string]bool{} - var queue []string - for name := range referenced { - if c, ok := seedSharedCanonical(name, aliasMap); ok { - queue = append(queue, c) - } - } - fieldRefs := map[string]bool{} - for len(queue) > 0 { - canonical := queue[0] - queue = queue[1:] - if used[canonical] { - continue - } - def, ok := shared[canonical] - if !ok { - return nil, nil, fmt.Errorf( - "shared type %q could not be resolved: pass -shared= pointing at the shared types package, and ensure %s is defined there", - canonical, sharedTypesPrefix+canonical, - ) - } - used[canonical] = true - for _, f := range def.Fields { - clear(fieldRefs) - collectReferencedTypes(f.Type, fieldRefs) - for t := range fieldRefs { - if c, ok := nestedSharedCanonical(t, shared); ok { - queue = append(queue, c) - } - } - } - } - - var out []SharedAlias - var usedDefs []StructDef - for canonical := range used { - usedDefs = append(usedDefs, shared[canonical]) - for _, a := range aliasesByCanonical[canonical] { - out = append(out, SharedAlias{Name: a.Name, Target: a.Type, Doc: a.Doc, Def: shared[canonical]}) - } - } - slices.SortFunc(out, func(a, b SharedAlias) int { return strings.Compare(a.Name, b.Name) }) - slices.SortFunc(usedDefs, func(a, b StructDef) int { return strings.Compare(a.Name, b.Name) }) - return out, usedDefs, nil -} - -// seedSharedCanonical maps a type token referenced by a capability/service field -// to the canonical shared type it denotes. It recognizes qualified references -// (types.X -> X) and declared shared aliases used by bare name (X where -// `type X = types.Y` -> Y). A bare name that is not a declared shared alias is -// not treated as shared, so a local struct sharing a name with a shared type is -// never misclassified. -func seedSharedCanonical(name string, aliasMap map[string]TypeAlias) (string, bool) { - if rest, ok := strings.CutPrefix(name, sharedTypesPrefix); ok { - return rest, true - } - if a, ok := aliasMap[name]; ok && a.IsSharedAlias() { - return strings.TrimPrefix(a.Type, sharedTypesPrefix), true - } - return "", false -} - -// nestedSharedCanonical maps a type token found inside a shared struct's own -// fields to a canonical shared type. Within the shared package, types reference -// each other by bare name (e.g. Track.Artists is []ArtistRef), so any bare name -// present in the shared registry counts. -func nestedSharedCanonical(name string, shared map[string]StructDef) (string, bool) { - if rest, ok := strings.CutPrefix(name, sharedTypesPrefix); ok { - return rest, true - } - if _, ok := shared[name]; ok { - return name, true - } - return "", false -} - -// collectAllStructDependencies recursively collects all struct types referenced by other structs. -func collectAllStructDependencies(referencedTypes map[string]bool, structMap map[string]StructDef) { - // Keep iterating until no new types are added - for { - newTypes := make(map[string]bool) - for typeName := range referencedTypes { - if s, exists := structMap[typeName]; exists { - for _, field := range s.Fields { - collectReferencedTypes(field.Type, newTypes) - } - } - } - // Check if any new types were found - foundNew := false - for t := range newTypes { - if !referencedTypes[t] { - referencedTypes[t] = true - foundNew = true - } - } - if !foundNew { - break - } - } -} - -// parseExport parses an export method signature into an Export struct. -func parseExport(name string, funcType *ast.FuncType, annotation map[string]string, doc string) (Export, error) { - export := Export{ - Name: name, - ExportName: annotation["name"], - Doc: doc, - } - - // Capability exports have exactly one input parameter (the struct type) - if funcType.Params != nil && len(funcType.Params.List) == 1 { - field := funcType.Params.List[0] - typeName := typeToString(field.Type) - paramName := "input" - if len(field.Names) > 0 { - paramName = field.Names[0].Name - } - export.Input = NewParam(paramName, typeName) - } - - // Capability exports return (OutputType, error) - if funcType.Results != nil { - for _, field := range funcType.Results.List { - typeName := typeToString(field.Type) - if typeName == "error" { - continue // Skip error return - } - paramName := "output" - if len(field.Names) > 0 { - paramName = field.Names[0].Name - } - export.Output = NewParam(paramName, typeName) - break // Only take the first non-error return - } - } - - return export, nil -} - -// parseServiceFile parses a single Go source file and extracts host services. -// pkgStructMap and pkgAliasMap are the package-wide struct and alias maps built -// from all files in the package, so a host-service interface can reference types -// defined in a sibling file. -func parseServiceFile(f *ast.File, pkgStructMap map[string]StructDef, pkgAliasMap map[string]TypeAlias, shared map[string]StructDef) ([]Service, error) { - var services []Service - - for _, decl := range f.Decls { - genDecl, ok := decl.(*ast.GenDecl) - if !ok || genDecl.Tok != token.TYPE { - continue - } - - for _, spec := range genDecl.Specs { - typeSpec, ok := spec.(*ast.TypeSpec) - if !ok { - continue - } - - interfaceType, ok := typeSpec.Type.(*ast.InterfaceType) - if !ok { - continue - } - - // Check for //nd:hostservice annotation in doc comment - docText, rawDoc := getDocComment(genDecl, typeSpec) - svcAnnotation := parseHostServiceAnnotation(rawDoc) - if svcAnnotation == nil { - continue - } - - service := Service{ - Name: svcAnnotation["name"], - Permission: svcAnnotation["permission"], - Interface: typeSpec.Name.Name, - Doc: cleanDoc(docText), - } - - // Parse methods and collect referenced types - referencedTypes := make(map[string]bool) - for _, method := range interfaceType.Methods.List { - if len(method.Names) == 0 { - continue // Embedded interface - } - - funcType, ok := method.Type.(*ast.FuncType) - if !ok { - continue - } - - // Check for //nd:hostfunc annotation - methodDocText, methodRawDoc := getMethodDocComment(method) - methodAnnotation := parseHostFuncAnnotation(methodRawDoc) - if methodAnnotation == nil { - continue - } - - m, err := parseMethod(method.Names[0].Name, funcType, methodAnnotation, cleanDoc(methodDocText)) - if err != nil { - return nil, fmt.Errorf("parsing method %s.%s: %w", typeSpec.Name.Name, method.Names[0].Name, err) - } - service.Methods = append(service.Methods, m) - - // Collect referenced types from params and returns - for _, p := range m.Params { - collectReferencedTypes(p.Type, referencedTypes) - } - for _, r := range m.Returns { - collectReferencedTypes(r.Type, referencedTypes) - } - } - - // Resolve shared-type aliases against the registry. Host-service schemas - // are not generated (the -schemas pass is capability-only), so the resolved - // shared shapes are not needed here. - sharedAliases, _, err := resolveSharedAliases(referencedTypes, pkgAliasMap, shared) - if err != nil { - return nil, err - } - service.SharedAliases = sharedAliases - - // Recursively collect all struct dependencies so types referenced only - // transitively (e.g. a field type of a referenced struct) are attached. - collectAllStructDependencies(referencedTypes, pkgStructMap) - - // Attach referenced structs to the service (sorted for stable output) - for _, typeName := range slices.Sorted(maps.Keys(referencedTypes)) { - if s, exists := pkgStructMap[typeName]; exists { - service.Structs = append(service.Structs, s) - } - } - - if len(service.Methods) > 0 { - services = append(services, service) - } - } - } - - return services, nil -} - -// parseStructs extracts all struct type definitions from a parsed Go file. -func parseStructs(f *ast.File) []StructDef { - var structs []StructDef - - for _, decl := range f.Decls { - genDecl, ok := decl.(*ast.GenDecl) - if !ok || genDecl.Tok != token.TYPE { - continue - } - - for _, spec := range genDecl.Specs { - typeSpec, ok := spec.(*ast.TypeSpec) - if !ok { - continue - } - - structType, ok := typeSpec.Type.(*ast.StructType) - if !ok { - continue - } - - docText, _ := getDocComment(genDecl, typeSpec) - s := StructDef{ - Name: typeSpec.Name.Name, - Doc: cleanDoc(docText), - } - - // Parse struct fields - for _, field := range structType.Fields.List { - if len(field.Names) == 0 { - continue // Embedded field - } - - fieldDef := parseStructField(field) - s.Fields = append(s.Fields, fieldDef...) - } - - structs = append(structs, s) - } - } - - return structs -} - -// parseTypeAliases extracts all type alias definitions from a parsed Go file. -// Type aliases are non-struct type declarations like: type MyType string -func parseTypeAliases(f *ast.File) []TypeAlias { - var aliases []TypeAlias - - for _, decl := range f.Decls { - genDecl, ok := decl.(*ast.GenDecl) - if !ok || genDecl.Tok != token.TYPE { - continue - } - - for _, spec := range genDecl.Specs { - typeSpec, ok := spec.(*ast.TypeSpec) - if !ok { - continue - } - - // Skip struct and interface types - if _, isStruct := typeSpec.Type.(*ast.StructType); isStruct { - continue - } - if _, isInterface := typeSpec.Type.(*ast.InterfaceType); isInterface { - continue - } - - docText, _ := getDocComment(genDecl, typeSpec) - aliases = append(aliases, TypeAlias{ - Name: typeSpec.Name.Name, - Type: typeToString(typeSpec.Type), - Doc: cleanDoc(docText), - IsAlias: typeSpec.Assign.IsValid(), - }) - } - } - - return aliases -} - -// parseConstGroups extracts const groups from a parsed Go file. -func parseConstGroups(f *ast.File) []ConstGroup { - var groups []ConstGroup - - for _, decl := range f.Decls { - genDecl, ok := decl.(*ast.GenDecl) - if !ok || genDecl.Tok != token.CONST { - continue - } - - group := ConstGroup{} - for _, spec := range genDecl.Specs { - valueSpec, ok := spec.(*ast.ValueSpec) - if !ok { - continue - } - - // Get type if specified - if valueSpec.Type != nil && group.Type == "" { - group.Type = typeToString(valueSpec.Type) - } - - // Extract values - for i, name := range valueSpec.Names { - def := ConstDef{ - Name: name.Name, - } - // Get value if present - if i < len(valueSpec.Values) { - def.Value = exprToString(valueSpec.Values[i]) - } - // Get doc comment - if valueSpec.Doc != nil { - def.Doc = cleanDoc(valueSpec.Doc.Text()) - } else if valueSpec.Comment != nil { - def.Doc = cleanDoc(valueSpec.Comment.Text()) - } - group.Values = append(group.Values, def) - } - } - - if len(group.Values) > 0 { - groups = append(groups, group) - } - } - - return groups -} - -// exprToString converts an AST expression to a Go source string. -func exprToString(expr ast.Expr) string { - switch e := expr.(type) { - case *ast.BasicLit: - return e.Value - case *ast.Ident: - return e.Name - default: - return "" - } -} - -// parseStructField parses a struct field and returns FieldDef for each name. -func parseStructField(field *ast.Field) []FieldDef { - var fields []FieldDef - typeName := typeToString(field.Type) - - // Parse struct tag for JSON field name and omitempty - jsonTag := "" - omitEmpty := false - if field.Tag != nil { - tag := field.Tag.Value - // Remove backticks - tag = strings.Trim(tag, "`") - // Parse json tag - jsonTag, omitEmpty = parseJSONTag(tag) - } - - // Get doc comment - var doc string - if field.Doc != nil { - doc = cleanDoc(field.Doc.Text()) - } - - for _, name := range field.Names { - fieldJSONTag := jsonTag - if fieldJSONTag == "" { - // Default to field name with camelCase - fieldJSONTag = toJSONName(name.Name) - } - fields = append(fields, FieldDef{ - Name: name.Name, - Type: typeName, - JSONTag: fieldJSONTag, - OmitEmpty: omitEmpty, - Doc: doc, - }) - } - - return fields -} - -// parseJSONTag extracts the json field name and omitempty flag from a struct tag. -func parseJSONTag(tag string) (name string, omitEmpty bool) { - // Find json:"..." in the tag - for _, part := range strings.Split(tag, " ") { - if strings.HasPrefix(part, `json:"`) { - value := strings.TrimPrefix(part, `json:"`) - value = strings.TrimSuffix(value, `"`) - parts := strings.Split(value, ",") - if len(parts) > 0 && parts[0] != "-" { - name = parts[0] - } - for _, opt := range parts[1:] { - if opt == "omitempty" { - omitEmpty = true - } - } - return - } - } - return "", false -} - -// collectReferencedTypes extracts custom type names from a Go type string. -// It handles pointers, slices, and maps, collecting base type names. -func collectReferencedTypes(goType string, refs map[string]bool) { - // Strip pointer - if strings.HasPrefix(goType, "*") { - collectReferencedTypes(goType[1:], refs) - return - } - // Strip slice - if strings.HasPrefix(goType, "[]") { - if goType != "[]byte" { - collectReferencedTypes(goType[2:], refs) - } - return - } - // Handle map - if strings.HasPrefix(goType, "map[") { - rest := goType[4:] // Remove "map[" - depth := 1 - keyEnd := 0 - for i, r := range rest { - if r == '[' { - depth++ - } else if r == ']' { - depth-- - if depth == 0 { - keyEnd = i - break - } - } - } - keyType := rest[:keyEnd] - valueType := rest[keyEnd+1:] - collectReferencedTypes(keyType, refs) - collectReferencedTypes(valueType, refs) - return - } - - // Qualified reference to the shared `types` package (e.g. types.Track). - // These start with a lowercase package selector, so they must be collected - // before the uppercase check below would skip them. - if strings.HasPrefix(goType, sharedTypesPrefix) { - refs[goType] = true - return - } - - // Check if it's a custom type (starts with uppercase, not a builtin) - if len(goType) > 0 && goType[0] >= 'A' && goType[0] <= 'Z' { - switch goType { - case "String", "Bool", "Int", "Int32", "Int64", "Float32", "Float64": - // Not custom types (just capitalized for some reason) - default: - refs[goType] = true - } - } -} - -// toJSONName is imported from types.go via the same package - -// getDocComment extracts the doc comment for a type spec. -// Returns both the readable doc text and the raw comment text (which includes pragma-style comments). -func getDocComment(genDecl *ast.GenDecl, typeSpec *ast.TypeSpec) (docText, rawText string) { - var docGroup *ast.CommentGroup - // First check the TypeSpec's own doc (when multiple types in one block) - if typeSpec.Doc != nil { - docGroup = typeSpec.Doc - } else if genDecl.Doc != nil { - // Fall back to GenDecl doc (single type declaration) - docGroup = genDecl.Doc - } - if docGroup == nil { - return "", "" - } - return docGroup.Text(), commentGroupRaw(docGroup) -} - -// commentGroupRaw returns all comment text including pragma-style comments (//nd:...). -// Go's ast.CommentGroup.Text() strips comments without a space after //, so we need this. -func commentGroupRaw(cg *ast.CommentGroup) string { - if cg == nil { - return "" - } - var lines []string - for _, c := range cg.List { - lines = append(lines, c.Text) - } - return strings.Join(lines, "\n") -} - -// getMethodDocComment extracts the doc comment for a method. -func getMethodDocComment(field *ast.Field) (docText, rawText string) { - if field.Doc == nil { - return "", "" - } - return field.Doc.Text(), commentGroupRaw(field.Doc) -} - -// parseHostServiceAnnotation extracts //nd:hostservice annotation parameters. -func parseHostServiceAnnotation(doc string) map[string]string { - for _, line := range strings.Split(doc, "\n") { - line = strings.TrimSpace(line) - match := hostServicePattern.FindStringSubmatch(line) - if match != nil { - return parseKeyValuePairs(match[1]) - } - } - return nil -} - -// parseHostFuncAnnotation extracts //nd:hostfunc annotation parameters. -func parseHostFuncAnnotation(doc string) map[string]string { - for _, line := range strings.Split(doc, "\n") { - line = strings.TrimSpace(line) - match := hostFuncPattern.FindStringSubmatch(line) - if match != nil { - params := parseKeyValuePairs(match[1]) - if params == nil { - params = make(map[string]string) - } - return params - } - } - return nil -} - -// parseCapabilityAnnotation extracts //nd:capability annotation parameters. -func parseCapabilityAnnotation(doc string) map[string]string { - for _, line := range strings.Split(doc, "\n") { - line = strings.TrimSpace(line) - match := capabilityPattern.FindStringSubmatch(line) - if match != nil { - return parseKeyValuePairs(match[1]) - } - } - return nil -} - -// parseExportAnnotation extracts //nd:export annotation parameters. -func parseExportAnnotation(doc string) map[string]string { - for _, line := range strings.Split(doc, "\n") { - line = strings.TrimSpace(line) - match := exportPattern.FindStringSubmatch(line) - if match != nil { - return parseKeyValuePairs(match[1]) - } - } - return nil -} - -// parseKeyValuePairs extracts key=value pairs from annotation text. -func parseKeyValuePairs(text string) map[string]string { - matches := keyValuePattern.FindAllStringSubmatch(text, -1) - if len(matches) == 0 { - return nil - } - result := make(map[string]string) - for _, m := range matches { - result[m[1]] = m[2] - } - return result -} - -// parseMethod parses a method signature into a Method struct. -func parseMethod(name string, funcType *ast.FuncType, annotation map[string]string, doc string) (Method, error) { - m := Method{ - Name: name, - ExportName: annotation["name"], - Doc: doc, - } - - // Parse parameters (skip context.Context) - if funcType.Params != nil { - for _, field := range funcType.Params.List { - typeName := typeToString(field.Type) - if typeName == "context.Context" { - continue // Skip context parameter - } - - for _, name := range field.Names { - m.Params = append(m.Params, NewParam(name.Name, typeName)) - } - } - } - - // Parse return values - if funcType.Results != nil { - for _, field := range funcType.Results.List { - typeName := typeToString(field.Type) - if typeName == "error" { - m.HasError = true - continue // Track error but don't include in Returns - } - - // Handle anonymous returns - if len(field.Names) == 0 { - // Generate a name based on position - m.Returns = append(m.Returns, NewParam("result", typeName)) - } else { - for _, name := range field.Names { - m.Returns = append(m.Returns, NewParam(name.Name, typeName)) - } - } - } - } - - return m, nil -} - -// typeToString converts an AST type expression to a string. -func typeToString(expr ast.Expr) string { - switch t := expr.(type) { - case *ast.Ident: - return t.Name - case *ast.SelectorExpr: - return typeToString(t.X) + "." + t.Sel.Name - case *ast.StarExpr: - return "*" + typeToString(t.X) - case *ast.ArrayType: - if t.Len == nil { - return "[]" + typeToString(t.Elt) - } - return fmt.Sprintf("[%s]%s", typeToString(t.Len), typeToString(t.Elt)) - case *ast.MapType: - return fmt.Sprintf("map[%s]%s", typeToString(t.Key), typeToString(t.Value)) - case *ast.BasicLit: - return t.Value - case *ast.InterfaceType: - // Empty interface (interface{} or any) - if t.Methods == nil || len(t.Methods.List) == 0 { - return "any" - } - // Non-empty interfaces can't be easily represented - return "any" - default: - return fmt.Sprintf("%T", expr) - } -} - -// cleanDoc removes annotation lines from documentation. -func cleanDoc(doc string) string { - var lines []string - for _, line := range strings.Split(doc, "\n") { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "//nd:") { - continue - } - lines = append(lines, line) - } - return strings.TrimSpace(strings.Join(lines, "\n")) -} diff --git a/plugins/cmd/ndpgen/internal/parser_test.go b/plugins/cmd/ndpgen/internal/parser_test.go deleted file mode 100644 index bfdb93db5..000000000 --- a/plugins/cmd/ndpgen/internal/parser_test.go +++ /dev/null @@ -1,853 +0,0 @@ -package internal - -import ( - "os" - "path/filepath" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Parser", func() { - var tmpDir string - - BeforeEach(func() { - var err error - tmpDir, err = os.MkdirTemp("", "ndpgen-test-*") - Expect(err).NotTo(HaveOccurred()) - }) - - AfterEach(func() { - os.RemoveAll(tmpDir) - }) - - Describe("ParseDirectory", func() { - It("should parse a simple host service interface", func() { - src := `package host - -import "context" - -// SubsonicAPIService provides access to Navidrome's Subsonic API. -//nd:hostservice name=SubsonicAPI permission=subsonicapi -type SubsonicAPIService interface { - // Call executes a Subsonic API request. - //nd:hostfunc - Call(ctx context.Context, uri string) (response string, err error) -} -` - err := os.WriteFile(filepath.Join(tmpDir, "service.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services).To(HaveLen(1)) - - svc := services[0] - Expect(svc.Name).To(Equal("SubsonicAPI")) - Expect(svc.Permission).To(Equal("subsonicapi")) - Expect(svc.Interface).To(Equal("SubsonicAPIService")) - Expect(svc.Methods).To(HaveLen(1)) - - m := svc.Methods[0] - Expect(m.Name).To(Equal("Call")) - Expect(m.HasError).To(BeTrue()) - Expect(m.Params).To(HaveLen(1)) - Expect(m.Params[0].Name).To(Equal("uri")) - Expect(m.Params[0].Type).To(Equal("string")) - Expect(m.Returns).To(HaveLen(1)) - Expect(m.Returns[0].Name).To(Equal("response")) - Expect(m.Returns[0].Type).To(Equal("string")) - }) - - It("should parse multiple methods", func() { - src := `package host - -import "context" - -// SchedulerService provides scheduling capabilities. -//nd:hostservice name=Scheduler permission=scheduler -type SchedulerService interface { - //nd:hostfunc - ScheduleRecurring(ctx context.Context, cronExpression string) (scheduleID string, err error) - - //nd:hostfunc - ScheduleOneTime(ctx context.Context, delaySeconds int32) (scheduleID string, err error) - - //nd:hostfunc - CancelSchedule(ctx context.Context, scheduleID string) (canceled bool, err error) -} -` - err := os.WriteFile(filepath.Join(tmpDir, "scheduler.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services).To(HaveLen(1)) - - svc := services[0] - Expect(svc.Name).To(Equal("Scheduler")) - Expect(svc.Methods).To(HaveLen(3)) - - Expect(svc.Methods[0].Name).To(Equal("ScheduleRecurring")) - Expect(svc.Methods[0].Params[0].Type).To(Equal("string")) - - Expect(svc.Methods[1].Name).To(Equal("ScheduleOneTime")) - Expect(svc.Methods[1].Params[0].Type).To(Equal("int32")) - - Expect(svc.Methods[2].Name).To(Equal("CancelSchedule")) - Expect(svc.Methods[2].Returns[0].Type).To(Equal("bool")) - }) - - It("should skip methods without hostfunc annotation", func() { - src := `package host - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - Exported(ctx context.Context) error - - // This method is not exported - NotExported(ctx context.Context) error -} -` - err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services).To(HaveLen(1)) - Expect(services[0].Methods).To(HaveLen(1)) - Expect(services[0].Methods[0].Name).To(Equal("Exported")) - }) - - It("should handle custom export name", func() { - src := `package host - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc name=custom_export_name - MyMethod(ctx context.Context) error -} -` - err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services[0].Methods[0].ExportName).To(Equal("custom_export_name")) - Expect(services[0].Methods[0].FunctionName("test")).To(Equal("custom_export_name")) - }) - - It("should skip generated files", func() { - regularSrc := `package host - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - Method(ctx context.Context) error -} -` - genSrc := `// Code generated. DO NOT EDIT. -package host - -//nd:hostservice name=Generated permission=gen -type GeneratedService interface { - //nd:hostfunc - Method() error -} -` - err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(regularSrc), 0600) - Expect(err).NotTo(HaveOccurred()) - err = os.WriteFile(filepath.Join(tmpDir, "test_gen.go"), []byte(genSrc), 0600) - Expect(err).NotTo(HaveOccurred()) - - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services).To(HaveLen(1)) - Expect(services[0].Name).To(Equal("Test")) - }) - - It("should skip interfaces without hostservice annotation", func() { - src := `package host - -import "context" - -// Regular interface without annotation -type RegularInterface interface { - Method(ctx context.Context) error -} - -//nd:hostservice name=Annotated permission=annotated -type AnnotatedService interface { - //nd:hostfunc - Method(ctx context.Context) error -} -` - err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services).To(HaveLen(1)) - Expect(services[0].Name).To(Equal("Annotated")) - }) - - It("should return empty slice for directory with no host services", func() { - src := `package host - -type RegularInterface interface { - Method() error -} -` - err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services).To(BeEmpty()) - }) - - It("should resolve structs defined in a sibling file of the same package", func() { - // types.go defines Track and Artist — no host service here - typesSrc := `package host - -// Artist is a track participant. -type Artist struct { - // ID is the artist identifier. - ID string ` + "`json:\"id\"`" + ` - // Name is the artist name. - Name string ` + "`json:\"name\"`" + ` -} - -// Track is a media file projection. -type Track struct { - // ID is the track identifier. - ID string ` + "`json:\"id\"`" + ` - // Title is the track title. - Title string ` + "`json:\"title\"`" + ` - // Participants maps role to artists (transitive dependency test). - Participants map[string][]Artist ` + "`json:\"participants\"`" + ` -} -` - // service.go defines the host service that references Track from types.go - serviceSrc := `package host - -import "context" - -// MatcherService matches tracks. -//nd:hostservice name=Matcher permission=matcher -type MatcherService interface { - // MatchTrack finds a matching track. - //nd:hostfunc - MatchTrack(ctx context.Context, t Track) (matched bool, err error) -} -` - err := os.WriteFile(filepath.Join(tmpDir, "types.go"), []byte(typesSrc), 0600) - Expect(err).NotTo(HaveOccurred()) - err = os.WriteFile(filepath.Join(tmpDir, "service.go"), []byte(serviceSrc), 0600) - Expect(err).NotTo(HaveOccurred()) - - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services).To(HaveLen(1)) - - svc := services[0] - Expect(svc.Name).To(Equal("Matcher")) - Expect(svc.Methods).To(HaveLen(1)) - Expect(svc.Methods[0].Name).To(Equal("MatchTrack")) - - // Track must be resolved from the sibling file, not just the service file. - // Artist must also be transitively resolved (Track.Participants references it). - structNames := make([]string, len(svc.Structs)) - for i, s := range svc.Structs { - structNames[i] = s.Name - } - Expect(structNames).To(ConsistOf("Track", "Artist")) - }) - - It("returns an error when a shared-type alias cannot be resolved (no registry)", func() { - fileA := `package host - -import "github.com/navidrome/navidrome/plugins/types" - -type TrackInfo = types.TrackInfo -` - fileB := `package host - -import "context" - -//nd:hostservice name=Matcher permission=matcher -type MatcherService interface { - //nd:hostfunc - Match(ctx context.Context, t TrackInfo) (bool, error) -} -` - Expect(os.WriteFile(filepath.Join(tmpDir, "aliases.go"), []byte(fileA), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(tmpDir, "matcher.go"), []byte(fileB), 0600)).To(Succeed()) - - _, err := ParseDirectoryWithShared(tmpDir, nil) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("TrackInfo")) - Expect(err.Error()).To(ContainSubstring("-shared")) - }) - - It("resolves shared-type aliases declared in a sibling file (package-wide alias map)", func() { - // File A: declares the shared-type alias in the same package - fileA := `package host - -import "github.com/navidrome/navidrome/plugins/types" - -// Deprecated: use types.Track. -type Track = types.Track -` - // File B: declares the host service that references Track from file A - fileB := `package host - -import "context" - -//nd:hostservice name=Matcher permission=matcher -type MatcherService interface { - //nd:hostfunc - MatchSongs(ctx context.Context, query string) (results []Track, err error) -} -` - Expect(os.WriteFile(filepath.Join(tmpDir, "aliases.go"), []byte(fileA), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(tmpDir, "matcher.go"), []byte(fileB), 0600)).To(Succeed()) - - shared := map[string]StructDef{ - "Track": { - Name: "Track", - Fields: []FieldDef{ - {Name: "Title", Type: "string", JSONTag: "title"}, - {Name: "Artist", Type: "string", JSONTag: "artist"}, - }, - }, - } - - services, err := ParseDirectoryWithShared(tmpDir, shared) - Expect(err).NotTo(HaveOccurred()) - Expect(services).To(HaveLen(1)) - - byName := map[string]SharedAlias{} - for _, a := range services[0].SharedAliases { - byName[a.Name] = a - } - // Track alias is in a sibling file — must be resolved package-wide - Expect(byName).To(HaveKey("Track")) - Expect(byName["Track"].Target).To(Equal("types.Track")) - }) - }) - - Describe("parseKeyValuePairs", func() { - It("should parse key=value pairs", func() { - result := parseKeyValuePairs("name=Test permission=test") - Expect(result).To(HaveKeyWithValue("name", "Test")) - Expect(result).To(HaveKeyWithValue("permission", "test")) - }) - - It("should return nil for empty input", func() { - result := parseKeyValuePairs("") - Expect(result).To(BeNil()) - }) - }) - - Describe("typeToString", func() { - It("should handle basic types", func() { - src := `package test -type T interface { - Method(s string, i int, b bool) ([]byte, error) -} -` - err := os.WriteFile(filepath.Join(tmpDir, "types.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - // Parse and verify type conversion works - // This is implicitly tested through ParseDirectory - }) - - It("should convert interface{} to any", func() { - src := `package test - -import "context" - -//nd:hostservice name=Test permission=test -type TestService interface { - //nd:hostfunc - GetMetadata(ctx context.Context) (data map[string]interface{}, err error) -} -` - err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - services, err := ParseDirectory(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(services).To(HaveLen(1)) - Expect(services[0].Methods[0].Returns[0].Type).To(Equal("map[string]any")) - }) - }) - - Describe("Method helpers", func() { - It("should generate correct function names", func() { - m := Method{Name: "Call"} - Expect(m.FunctionName("subsonicapi")).To(Equal("subsonicapi_call")) - - m.ExportName = "custom_name" - Expect(m.FunctionName("subsonicapi")).To(Equal("custom_name")) - }) - - It("should generate correct type names", func() { - m := Method{Name: "Call"} - // Host-side types are public - Expect(m.RequestTypeName("SubsonicAPI")).To(Equal("SubsonicAPICallRequest")) - Expect(m.ResponseTypeName("SubsonicAPI")).To(Equal("SubsonicAPICallResponse")) - // Client/PDK types are private - Expect(m.ClientRequestTypeName("SubsonicAPI")).To(Equal("subsonicAPICallRequest")) - Expect(m.ClientResponseTypeName("SubsonicAPI")).To(Equal("subsonicAPICallResponse")) - }) - }) - - Describe("Service helpers", func() { - It("should generate correct output file name", func() { - s := Service{Name: "SubsonicAPI"} - Expect(s.OutputFileName()).To(Equal("subsonicapi_gen.go")) - }) - - It("should generate correct export prefix", func() { - s := Service{Name: "SubsonicAPI"} - Expect(s.ExportPrefix()).To(Equal("subsonicapi")) - }) - }) - - Describe("ParseCapabilities", func() { - It("should parse a simple capability interface", func() { - src := `package capabilities - -// MetadataAgent provides metadata retrieval. -//nd:capability name=metadata -type MetadataAgent interface { - // GetArtistBiography returns artist biography. - //nd:export name=nd_get_artist_biography - GetArtistBiography(ArtistInput) (ArtistBiographyOutput, error) -} - -// ArtistInput is the input for artist-related functions. -type ArtistInput struct { - // ID is the artist ID. - ID string ` + "`json:\"id\"`" + ` - // Name is the artist name. - Name string ` + "`json:\"name\"`" + ` -} - -// ArtistBiographyOutput is the output for GetArtistBiography. -type ArtistBiographyOutput struct { - // Biography is the biography text. - Biography string ` + "`json:\"biography\"`" + ` -} -` - err := os.WriteFile(filepath.Join(tmpDir, "metadata.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - capabilities, err := ParseCapabilities(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(capabilities).To(HaveLen(1)) - - cap := capabilities[0] - Expect(cap.Name).To(Equal("metadata")) - Expect(cap.Interface).To(Equal("MetadataAgent")) - Expect(cap.Required).To(BeFalse()) - Expect(cap.Doc).To(ContainSubstring("MetadataAgent provides metadata retrieval")) - Expect(cap.Methods).To(HaveLen(1)) - - m := cap.Methods[0] - Expect(m.Name).To(Equal("GetArtistBiography")) - Expect(m.ExportName).To(Equal("nd_get_artist_biography")) - Expect(m.Input.Type).To(Equal("ArtistInput")) - Expect(m.Output.Type).To(Equal("ArtistBiographyOutput")) - - // Check structs were collected - Expect(cap.Structs).To(HaveLen(2)) - }) - - It("should parse a required capability", func() { - src := `package capabilities - -// Scrobbler requires all methods to be implemented. -//nd:capability name=scrobbler required=true -type Scrobbler interface { - //nd:export name=nd_scrobbler_is_authorized - IsAuthorized(AuthInput) (AuthOutput, error) - - //nd:export name=nd_scrobbler_scrobble - Scrobble(ScrobbleInput) (ScrobblerOutput, error) -} - -type AuthInput struct { - UserID string ` + "`json:\"userId\"`" + ` -} - -type AuthOutput struct { - Authorized bool ` + "`json:\"authorized\"`" + ` -} - -type ScrobbleInput struct { - UserID string ` + "`json:\"userId\"`" + ` -} - -type ScrobblerOutput struct { - Error *string ` + "`json:\"error,omitempty\"`" + ` -} -` - err := os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - capabilities, err := ParseCapabilities(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(capabilities).To(HaveLen(1)) - - cap := capabilities[0] - Expect(cap.Name).To(Equal("scrobbler")) - Expect(cap.Required).To(BeTrue()) - Expect(cap.Methods).To(HaveLen(2)) - }) - - It("should parse type aliases and consts", func() { - src := `package capabilities - -//nd:capability name=scrobbler required=true -type Scrobbler interface { - //nd:export name=nd_scrobble - Scrobble(ScrobbleInput) (ScrobblerOutput, error) -} - -type ScrobbleInput struct { - UserID string ` + "`json:\"userId\"`" + ` -} - -// ScrobblerErrorType indicates error handling behavior. -type ScrobblerErrorType string - -const ( - // ScrobblerErrorNone indicates no error. - ScrobblerErrorNone ScrobblerErrorType = "none" - // ScrobblerErrorRetry indicates retry later. - ScrobblerErrorRetry ScrobblerErrorType = "retry" -) - -type ScrobblerOutput struct { - ErrorType *ScrobblerErrorType ` + "`json:\"errorType,omitempty\"`" + ` -} -` - err := os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - capabilities, err := ParseCapabilities(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(capabilities).To(HaveLen(1)) - - cap := capabilities[0] - // Type alias should be collected - Expect(cap.TypeAliases).To(HaveLen(1)) - Expect(cap.TypeAliases[0].Name).To(Equal("ScrobblerErrorType")) - Expect(cap.TypeAliases[0].Type).To(Equal("string")) - - // Consts should be collected - Expect(cap.Consts).To(HaveLen(1)) - Expect(cap.Consts[0].Type).To(Equal("ScrobblerErrorType")) - Expect(cap.Consts[0].Values).To(HaveLen(2)) - Expect(cap.Consts[0].Values[0].Name).To(Equal("ScrobblerErrorNone")) - Expect(cap.Consts[0].Values[0].Value).To(Equal(`"none"`)) - }) - - It("should collect nested struct dependencies", func() { - src := `package capabilities - -//nd:capability name=metadata -type MetadataAgent interface { - //nd:export name=nd_get_images - GetImages(ArtistInput) (ImagesOutput, error) -} - -type ArtistInput struct { - ID string ` + "`json:\"id\"`" + ` -} - -type ImagesOutput struct { - Images []ImageInfo ` + "`json:\"images\"`" + ` -} - -type ImageInfo struct { - URL string ` + "`json:\"url\"`" + ` - Size int32 ` + "`json:\"size\"`" + ` -} -` - err := os.WriteFile(filepath.Join(tmpDir, "metadata.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - capabilities, err := ParseCapabilities(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(capabilities).To(HaveLen(1)) - - cap := capabilities[0] - // Should collect all 3 structs: ArtistInput, ImagesOutput, and ImageInfo - Expect(cap.Structs).To(HaveLen(3)) - - structNames := make([]string, len(cap.Structs)) - for i, s := range cap.Structs { - structNames[i] = s.Name - } - Expect(structNames).To(ContainElements("ArtistInput", "ImagesOutput", "ImageInfo")) - }) - - It("should return empty slice for directory with no capabilities", func() { - src := `package capabilities - -type RegularInterface interface { - Method() error -} -` - err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - capabilities, err := ParseCapabilities(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(capabilities).To(BeEmpty()) - }) - - It("should ignore methods without export annotation", func() { - src := `package capabilities - -//nd:capability name=test -type TestCapability interface { - //nd:export name=nd_exported - ExportedMethod(Input) (Output, error) - - // This method has no export annotation - NotExportedMethod(Input) (Output, error) -} - -type Input struct { - Value string ` + "`json:\"value\"`" + ` -} - -type Output struct { - Result string ` + "`json:\"result\"`" + ` -} -` - err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600) - Expect(err).NotTo(HaveOccurred()) - - capabilities, err := ParseCapabilities(tmpDir) - Expect(err).NotTo(HaveOccurred()) - Expect(capabilities).To(HaveLen(1)) - - // Only the exported method should be captured - Expect(capabilities[0].Methods).To(HaveLen(1)) - Expect(capabilities[0].Methods[0].Name).To(Equal("ExportedMethod")) - }) - - It("distinguishes Go type aliases from defined types", func() { - src := `package capabilities - -import "github.com/navidrome/navidrome/plugins/types" - -// Deprecated: use types.ArtistRef. -type ArtistRef = types.ArtistRef - -// ScrobblerError is a sentinel error string. -type ScrobblerError string - -//nd:capability name=scrobbler required=true -type Scrobbler interface { - //nd:export name=nd_scrobbler_check - Check(ArtistRef) (bool, error) -} -` - Expect(os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)).To(Succeed()) - - shared := map[string]StructDef{ - "ArtistRef": {Name: "ArtistRef", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, - } - caps, err := ParseCapabilitiesWithShared(tmpDir, shared) - Expect(err).NotTo(HaveOccurred()) - Expect(caps).To(HaveLen(1)) - - // ArtistRef is a shared-type alias (types.*): it lands in SharedAliases, not TypeAliases. - sharedByName := map[string]SharedAlias{} - for _, a := range caps[0].SharedAliases { - sharedByName[a.Name] = a - } - Expect(sharedByName).To(HaveKey("ArtistRef")) - Expect(sharedByName["ArtistRef"].Target).To(Equal("types.ArtistRef")) - - // ScrobblerError is a plain defined type: it stays in TypeAliases. - typeByName := map[string]TypeAlias{} - for _, a := range caps[0].TypeAliases { - typeByName[a.Name] = a - } - Expect(typeByName).To(HaveKey("ScrobblerError")) - Expect(typeByName["ScrobblerError"].IsAlias).To(BeFalse()) - }) - }) - - Describe("ParseCapabilitiesWithShared", func() { - It("returns an error when a shared-type alias cannot be resolved (no registry)", func() { - src := `package capabilities - -import "github.com/navidrome/navidrome/plugins/types" - -// TrackInfo is an alias for the shared type. -type TrackInfo = types.TrackInfo - -//nd:capability name=nowplaying required=true -type NowPlaying interface { - //nd:export name=nd_now_playing - NowPlaying(TrackInfo) error -} -` - Expect(os.WriteFile(filepath.Join(tmpDir, "nowplaying.go"), []byte(src), 0600)).To(Succeed()) - - _, err := ParseCapabilitiesWithShared(tmpDir, nil) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("TrackInfo")) - Expect(err.Error()).To(ContainSubstring("-shared")) - }) - - It("resolves shared-type aliases against the registry", func() { - shared := map[string]StructDef{ - "ArtistRef": {Name: "ArtistRef", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, - "TrackInfo": {Name: "TrackInfo", Fields: []FieldDef{ - {Name: "Title", Type: "string", JSONTag: "title"}, - {Name: "Artists", Type: "[]ArtistRef", JSONTag: "artists"}, - }}, - } - src := `package capabilities - -import "github.com/navidrome/navidrome/plugins/types" - -// Deprecated: use types.TrackInfo. -type TrackInfo = types.TrackInfo - -// Deprecated: use types.ArtistRef. -type ArtistRef = types.ArtistRef - -// NowPlayingRequest carries a track. -type NowPlayingRequest struct { - Track TrackInfo ` + "`json:\"track\"`" + ` -} - -//nd:capability name=scrobbler required=true -type Scrobbler interface { - //nd:export name=nd_scrobbler_now_playing - NowPlaying(NowPlayingRequest) error -} -` - Expect(os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)).To(Succeed()) - - caps, err := ParseCapabilitiesWithShared(tmpDir, shared) - Expect(err).NotTo(HaveOccurred()) - Expect(caps).To(HaveLen(1)) - - names := []string{} - for _, a := range caps[0].SharedAliases { - names = append(names, a.Name) - } - // TrackInfo is referenced directly; ArtistRef is pulled in transitively via TrackInfo.Artists. - Expect(names).To(ContainElements("TrackInfo", "ArtistRef")) - - byName := map[string]SharedAlias{} - for _, a := range caps[0].SharedAliases { - byName[a.Name] = a - } - Expect(byName["TrackInfo"].Target).To(Equal("types.TrackInfo")) - Expect(byName["TrackInfo"].Def.Fields).To(HaveLen(2)) // for schema inlining - Expect(byName["ArtistRef"].Target).To(Equal("types.ArtistRef")) - }) - - It("resolves shared types from qualified types.X references with a renamed alias", func() { - shared := map[string]StructDef{ - "ArtistRef": {Name: "ArtistRef", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, - "Track": {Name: "Track", Fields: []FieldDef{ - {Name: "Title", Type: "string", JSONTag: "title"}, - {Name: "Artists", Type: "[]ArtistRef", JSONTag: "artists"}, - }}, - } - src := `package capabilities - -import "github.com/navidrome/navidrome/plugins/types" - -// Deprecated: use types.Track. -type TrackInfo = types.Track - -// Deprecated: use types.ArtistRef. -type ArtistRef = types.ArtistRef - -// NowPlayingRequest carries a track. -type NowPlayingRequest struct { - Track types.Track ` + "`json:\"track\"`" + ` -} - -//nd:capability name=scrobbler required=true -type Scrobbler interface { - //nd:export name=nd_scrobbler_now_playing - NowPlaying(NowPlayingRequest) error -} -` - Expect(os.WriteFile(filepath.Join(tmpDir, "scrobbler.go"), []byte(src), 0600)).To(Succeed()) - - caps, err := ParseCapabilitiesWithShared(tmpDir, shared) - Expect(err).NotTo(HaveOccurred()) - Expect(caps).To(HaveLen(1)) - - byName := map[string]SharedAlias{} - for _, a := range caps[0].SharedAliases { - byName[a.Name] = a - } - // The deprecated alias keeps its name (TrackInfo) but now targets types.Track. - // ArtistRef is pulled in transitively via Track.Artists. - Expect(byName).To(HaveKey("TrackInfo")) - Expect(byName).To(HaveKey("ArtistRef")) - Expect(byName["TrackInfo"].Target).To(Equal("types.Track")) - Expect(byName["TrackInfo"].Def.Fields).To(HaveLen(2)) // for schema inlining - Expect(byName["ArtistRef"].Target).To(Equal("types.ArtistRef")) - - // The capability struct field keeps the canonical qualified reference. - var nowPlaying StructDef - for _, st := range caps[0].Structs { - if st.Name == "NowPlayingRequest" { - nowPlaying = st - } - } - Expect(nowPlaying.Fields).To(HaveLen(1)) - Expect(nowPlaying.Fields[0].Type).To(Equal("types.Track")) - }) - }) - - Describe("Export helpers", func() { - It("should generate correct provider interface name", func() { - e := Export{Name: "GetArtistBiography"} - Expect(e.ProviderInterfaceName()).To(Equal("ArtistBiographyProvider")) - - e = Export{Name: "OnInit"} - Expect(e.ProviderInterfaceName()).To(Equal("InitProvider")) - }) - - It("should generate correct impl variable name", func() { - e := Export{Name: "GetArtistBiography"} - Expect(e.ImplVarName()).To(Equal("artistBiographyImpl")) - - e = Export{Name: "OnInit"} - Expect(e.ImplVarName()).To(Equal("initImpl")) - }) - - It("should generate correct export function name", func() { - e := Export{Name: "GetArtistBiography", ExportName: "nd_get_artist_biography"} - Expect(e.ExportFuncName()).To(Equal("_NdGetArtistBiography")) - }) - }) -}) diff --git a/plugins/cmd/ndpgen/internal/pdk_parser.go b/plugins/cmd/ndpgen/internal/pdk_parser.go deleted file mode 100644 index 7607fe881..000000000 --- a/plugins/cmd/ndpgen/internal/pdk_parser.go +++ /dev/null @@ -1,448 +0,0 @@ -package internal - -import ( - "fmt" - "go/ast" - "go/token" - "sort" - "strings" - - "golang.org/x/tools/go/packages" -) - -// PDKSymbols contains all exported symbols parsed from extism/go-pdk. -type PDKSymbols struct { - Types []PDKType - Consts []PDKConst - Functions []PDKFunc -} - -// PDKType represents an exported type from extism/go-pdk. -type PDKType struct { - Name string - Underlying string // The underlying type (e.g., "int" for LogLevel) - IsAlias bool // True if it's a type alias (type X = Y) - Doc string // Documentation comment - Methods []PDKFunc // Methods on this type - Fields []PDKField // Struct fields (if it's a struct type) -} - -// PDKField represents a struct field. -type PDKField struct { - Name string - Type string - Tag string // Struct tag (e.g., `json:"name"`) -} - -// PDKConst represents an exported constant from extism/go-pdk. -type PDKConst struct { - Name string - Type string // The type name (may be empty for untyped consts) - Value string // The value expression - Doc string -} - -// PDKFunc represents an exported function from extism/go-pdk. -type PDKFunc struct { - Name string - Doc string - Receiver string // Empty for package-level functions - Params []PDKParam - Returns []PDKReturn - IsVariadic bool - Deprecated string // Rendered as a "Deprecated:" paragraph when set -} - -// deprecatedPDKFuncs marks extism functions that do not work inside Navidrome, with what to use instead. -var deprecatedPDKFuncs = map[string]string{ - "NewHTTPRequest": "Navidrome does not enable extism's http_request host function, so every request sent this way fails. Use host.HTTPSend instead.", -} - -// PDKParam represents a function parameter. -type PDKParam struct { - Name string - Type string -} - -// PDKReturn represents a function return value. -type PDKReturn struct { - Name string // May be empty for unnamed returns - Type string -} - -// ParseExtismPDK parses the extism/go-pdk package and extracts all exported symbols. -func ParseExtismPDK() (*PDKSymbols, error) { - // Load both packages with syntax trees in one call - cfg := &packages.Config{ - Mode: packages.NeedName | packages.NeedSyntax | packages.NeedFiles, - } - pkgs, err := packages.Load(cfg, - "github.com/extism/go-pdk", - "github.com/extism/go-pdk/internal/memory", - ) - if err != nil { - return nil, fmt.Errorf("loading extism/go-pdk: %w", err) - } - - // Find both packages - var pdkPkg, memoryPkg *packages.Package - for _, pkg := range pkgs { - if len(pkg.Errors) > 0 { - return nil, fmt.Errorf("loading %s: %v", pkg.PkgPath, pkg.Errors[0]) - } - switch pkg.Name { - case "pdk": - pdkPkg = pkg - case "memory": - memoryPkg = pkg - } - } - if pdkPkg == nil { - return nil, fmt.Errorf("package github.com/extism/go-pdk not found") - } - if memoryPkg == nil { - return nil, fmt.Errorf("package github.com/extism/go-pdk/internal/memory not found") - } - - symbols := &PDKSymbols{} - seenTypes := make(map[string]bool) - - // Extract Memory type from internal/memory package first - extractMemorySymbols(memoryPkg.Syntax, symbols, seenTypes) - - // First pass: collect types from pdk package (skip if already found in internal packages) - for _, file := range pdkPkg.Syntax { - for _, decl := range file.Decls { - if genDecl, ok := decl.(*ast.GenDecl); ok { - for _, spec := range genDecl.Specs { - if typeSpec, ok := spec.(*ast.TypeSpec); ok { - if !typeSpec.Name.IsExported() { - continue - } - // Skip if we already have this type (from internal packages) - if seenTypes[typeSpec.Name.Name] { - continue - } - seenTypes[typeSpec.Name.Name] = true - pdkType := extractType(typeSpec, genDecl.Doc) - symbols.Types = append(symbols.Types, pdkType) - } - } - } - } - } - - // Build typeMap from the final slice (after all types are added) - typeMap := make(map[string]*PDKType) - for i := range symbols.Types { - typeMap[symbols.Types[i].Name] = &symbols.Types[i] - } - - // Second pass: collect functions and methods from pdk package - for _, file := range pdkPkg.Syntax { - for _, decl := range file.Decls { - switch d := decl.(type) { - case *ast.GenDecl: - if d.Tok == token.CONST { - consts := extractConsts(d) - symbols.Consts = append(symbols.Consts, consts...) - } - case *ast.FuncDecl: - if !d.Name.IsExported() { - continue - } - fn := extractFunc(d) - if fn.Receiver != "" { - // It's a method, associate with type - typeName := fn.Receiver - if strings.HasPrefix(typeName, "*") { - typeName = typeName[1:] - } - if t, ok := typeMap[typeName]; ok { - t.Methods = append(t.Methods, fn) - } - } else { - fn.Deprecated = deprecatedPDKFuncs[fn.Name] - symbols.Functions = append(symbols.Functions, fn) - } - } - } - } - - // Sort for consistent output - sort.Slice(symbols.Types, func(i, j int) bool { - return symbols.Types[i].Name < symbols.Types[j].Name - }) - sort.Slice(symbols.Consts, func(i, j int) bool { - return symbols.Consts[i].Name < symbols.Consts[j].Name - }) - sort.Slice(symbols.Functions, func(i, j int) bool { - return symbols.Functions[i].Name < symbols.Functions[j].Name - }) - - return symbols, nil -} - -func extractType(spec *ast.TypeSpec, doc *ast.CommentGroup) PDKType { - t := PDKType{ - Name: spec.Name.Name, - Doc: extractDoc(doc), - } - - // Check if it's an alias (type X = Y) - t.IsAlias = spec.Assign.IsValid() - - // Extract underlying type - t.Underlying = typeString(spec.Type) - - // Extract struct fields if it's a struct type - if structType, ok := spec.Type.(*ast.StructType); ok { - t.Fields = extractStructFields(structType) - } - - return t -} - -func extractStructFields(st *ast.StructType) []PDKField { - var fields []PDKField - if st.Fields == nil { - return fields - } - - for _, field := range st.Fields.List { - fieldType := typeString(field.Type) - tag := "" - if field.Tag != nil { - tag = field.Tag.Value - } - - if len(field.Names) == 0 { - // Embedded field - fields = append(fields, PDKField{ - Name: fieldType, // Use type name as field name for embedded - Type: fieldType, - Tag: tag, - }) - } else { - for _, name := range field.Names { - // Skip unexported fields - if !name.IsExported() { - continue - } - fields = append(fields, PDKField{ - Name: name.Name, - Type: fieldType, - Tag: tag, - }) - } - } - } - return fields -} - -func extractConsts(decl *ast.GenDecl) []PDKConst { - var consts []PDKConst - var currentType string // For iota-style const blocks - - for i, spec := range decl.Specs { - valSpec, ok := spec.(*ast.ValueSpec) - if !ok { - continue - } - - // Update type if specified - if valSpec.Type != nil { - currentType = typeString(valSpec.Type) - } - - for j, name := range valSpec.Names { - if !name.IsExported() { - continue - } - - c := PDKConst{ - Name: name.Name, - Type: currentType, - } - - // Extract value - if j < len(valSpec.Values) { - c.Value = exprString(valSpec.Values[j]) - } else if i == 0 && j == 0 { - // First const with no value - likely iota - c.Value = "iota" - } - - // Extract doc - if valSpec.Doc != nil { - c.Doc = extractDoc(valSpec.Doc) - } else if i == 0 && decl.Doc != nil { - c.Doc = extractDoc(decl.Doc) - } - - consts = append(consts, c) - } - } - - return consts -} - -func extractFunc(decl *ast.FuncDecl) PDKFunc { - fn := PDKFunc{ - Name: decl.Name.Name, - Doc: extractDoc(decl.Doc), - } - - // Extract receiver - if decl.Recv != nil && len(decl.Recv.List) > 0 { - fn.Receiver = typeString(decl.Recv.List[0].Type) - } - - // Extract parameters - if decl.Type.Params != nil { - for _, field := range decl.Type.Params.List { - paramType := typeString(field.Type) - - // Check for variadic - if _, ok := field.Type.(*ast.Ellipsis); ok { - fn.IsVariadic = true - } - - if len(field.Names) == 0 { - // Unnamed parameter - fn.Params = append(fn.Params, PDKParam{Type: paramType}) - } else { - for _, name := range field.Names { - fn.Params = append(fn.Params, PDKParam{ - Name: name.Name, - Type: paramType, - }) - } - } - } - } - - // Extract returns - if decl.Type.Results != nil { - for _, field := range decl.Type.Results.List { - retType := typeString(field.Type) - - if len(field.Names) == 0 { - // Unnamed return - fn.Returns = append(fn.Returns, PDKReturn{Type: retType}) - } else { - for _, name := range field.Names { - fn.Returns = append(fn.Returns, PDKReturn{ - Name: name.Name, - Type: retType, - }) - } - } - } - } - - return fn -} - -func extractDoc(doc *ast.CommentGroup) string { - if doc == nil { - return "" - } - return strings.TrimSpace(doc.Text()) -} - -func typeString(expr ast.Expr) string { - switch t := expr.(type) { - case *ast.Ident: - return t.Name - case *ast.StarExpr: - return "*" + typeString(t.X) - case *ast.SelectorExpr: - return typeString(t.X) + "." + t.Sel.Name - case *ast.ArrayType: - if t.Len == nil { - return "[]" + typeString(t.Elt) - } - return fmt.Sprintf("[%s]%s", exprString(t.Len), typeString(t.Elt)) - case *ast.MapType: - return fmt.Sprintf("map[%s]%s", typeString(t.Key), typeString(t.Value)) - case *ast.InterfaceType: - return "any" // Simplified - case *ast.Ellipsis: - return "..." + typeString(t.Elt) - case *ast.StructType: - return "struct{}" // Simplified for anonymous structs - case *ast.FuncType: - return "func()" // Simplified - default: - return fmt.Sprintf("%T", expr) - } -} - -func exprString(expr ast.Expr) string { - switch e := expr.(type) { - case *ast.Ident: - return e.Name - case *ast.BasicLit: - return e.Value - case *ast.BinaryExpr: - return exprString(e.X) + " " + e.Op.String() + " " + exprString(e.Y) - case *ast.UnaryExpr: - return e.Op.String() + exprString(e.X) - case *ast.CallExpr: - return typeString(e.Fun) + "(...)" - default: - return fmt.Sprintf("%T", expr) - } -} - -// extractMemorySymbols extracts the Memory type and its methods from already-parsed syntax trees. -// This is needed because Memory is defined in internal/memory but re-exported by the pdk package. -func extractMemorySymbols(files []*ast.File, symbols *PDKSymbols, seenTypes map[string]bool) { - // Collect the Memory type - for _, file := range files { - for _, decl := range file.Decls { - if genDecl, ok := decl.(*ast.GenDecl); ok { - for _, spec := range genDecl.Specs { - if typeSpec, ok := spec.(*ast.TypeSpec); ok { - // Only interested in Memory type - if typeSpec.Name.Name == "Memory" { - pdkType := extractType(typeSpec, genDecl.Doc) - symbols.Types = append(symbols.Types, pdkType) - seenTypes["Memory"] = true - } - } - } - } - } - } - - // Build local type map for method association - localTypeMap := make(map[string]*PDKType) - for i := range symbols.Types { - localTypeMap[symbols.Types[i].Name] = &symbols.Types[i] - } - - // Collect methods for Memory - for _, file := range files { - for _, decl := range file.Decls { - if funcDecl, ok := decl.(*ast.FuncDecl); ok { - if !funcDecl.Name.IsExported() { - continue - } - fn := extractFunc(funcDecl) - if fn.Receiver != "" { - typeName := fn.Receiver - if strings.HasPrefix(typeName, "*") { - typeName = typeName[1:] - } - if typeName == "Memory" { - if t, ok := localTypeMap["Memory"]; ok { - t.Methods = append(t.Methods, fn) - } - } - } - } - } - } -} diff --git a/plugins/cmd/ndpgen/internal/templates/base64_bytes.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/base64_bytes.rs.tmpl deleted file mode 100644 index 929aa8e3e..000000000 --- a/plugins/cmd/ndpgen/internal/templates/base64_bytes.rs.tmpl +++ /dev/null @@ -1,25 +0,0 @@ -{{define "base64_bytes_module"}} -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; - -mod base64_bytes { - use serde::{self, Deserialize, Deserializer, Serializer}; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; - - pub fn serialize(bytes: &Vec, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&BASE64.encode(bytes)) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - BASE64.decode(&s).map_err(serde::de::Error::custom) - } -} -{{- end}} \ No newline at end of file diff --git a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl deleted file mode 100644 index 8b88f367c..000000000 --- a/plugins/cmd/ndpgen/internal/templates/capability.go.tmpl +++ /dev/null @@ -1,235 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the {{.Capability.Interface}} capability. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package {{.Package}} - -import ( -{{- if .Capability.ImportsSharedTypes}} - "github.com/navidrome/navidrome/plugins/pdk/go/types" -{{- end}} - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -{{- /* Generate deprecated shared-type aliases */ -}} -{{- range .Capability.SharedAliases}} - -{{- if .Doc}} -{{formatDoc .Doc}} -{{- end}} -type {{.Name}} = {{.Target}} -{{- end}} - -{{- /* Generate type alias definitions */ -}} -{{- range .Capability.TypeAliases}} - -{{- if .Doc}} -{{formatDoc .Doc}} -{{- end}} -type {{.Name}} {{.Type}} -{{- end}} - -{{- /* Generate const definitions */ -}} -{{- range .Capability.Consts}} -{{- if .Values}} - -const ( -{{- $type := .Type}} -{{- range $i, $v := .Values}} -{{- if $v.Doc}} -{{formatDoc $v.Doc | indent 1}} -{{- end}} -{{- if $type}} - {{$v.Name}} {{$type}} = {{$v.Value}} -{{- else}} - {{$v.Name}} = {{$v.Value}} -{{- end}} -{{- end}} -) -{{- end}} -{{- end}} - -{{- /* Generate Error() methods for string type aliases with const values (implements error interface) */ -}} -{{- $consts := .Capability.Consts}} -{{- range .Capability.TypeAliases}} -{{- if eq .Type "string"}} -{{- $typeName := .Name}} -{{- range $consts}} -{{- if eq .Type $typeName}} - -// Error implements the error interface for {{$typeName}}. -func (e {{$typeName}}) Error() string { return string(e) } -{{- end}} -{{- end}} -{{- end}} -{{- end}} - -{{- /* Generate struct definitions */ -}} -{{- range .Capability.Structs}} - -{{- if .Doc}} -{{formatDoc .Doc}} -{{- else}} -// {{.Name}} represents the {{.Name}} data structure. -{{- end}} -type {{.Name}} struct { -{{- range .Fields}} -{{- if .Doc}} -{{formatDoc .Doc | indent 1}} -{{- end}} - {{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"` -{{- end}} -} -{{- end}} - -{{- /* Generate main interface based on required flag */ -}} -{{if .Capability.Required}} - -// {{agentName .Capability}} requires all methods to be implemented. -{{- if .Capability.Doc}} -{{formatDoc .Capability.Doc}} -{{- end}} -type {{agentName .Capability}} interface { -{{- range .Capability.Methods}} - // {{.Name}}{{if .Doc}} - {{.Doc}}{{end}} - {{- if and .HasInput .HasOutput}} - {{.Name}}({{.Input.Type}}) ({{.Output.Type}}, error) - {{- else if .HasInput}} - {{.Name}}({{.Input.Type}}) error - {{- else if .HasOutput}} - {{.Name}}() ({{.Output.Type}}, error) - {{- else}} - {{.Name}}() error - {{- end}} -{{- end}} -} -{{- else}} - -// {{agentName .Capability}} is the marker interface for {{.Package}} plugins. -// Implement one or more of the provider interfaces below. -{{- if .Capability.Doc}} -{{formatDoc .Capability.Doc}} -{{- end}} -type {{agentName .Capability}} interface{} -{{- end}} - -{{- /* Generate optional provider interfaces for non-required capabilities */ -}} -{{- if not .Capability.Required}} -{{- range .Capability.Methods}} - -// {{providerInterface .}} provides the {{.Name}} function. -type {{providerInterface .}} interface { - {{- if and .HasInput .HasOutput}} - {{.Name}}({{.Input.Type}}) ({{.Output.Type}}, error) - {{- else if .HasInput}} - {{.Name}}({{.Input.Type}}) error - {{- else if .HasOutput}} - {{.Name}}() ({{.Output.Type}}, error) - {{- else}} - {{.Name}}() error - {{- end}} -} -{{- end}} -{{- end}} - -{{- /* Generate implementation function holders */ -}} - -// Internal implementation holders -var ( -{{- range .Capability.Methods}} - {{- if and .HasInput .HasOutput}} - {{implVar .}} func({{.Input.Type}}) ({{.Output.Type}}, error) - {{- else if .HasInput}} - {{implVar .}} func({{.Input.Type}}) error - {{- else if .HasOutput}} - {{implVar .}} func() ({{.Output.Type}}, error) - {{- else}} - {{implVar .}} func() error - {{- end}} -{{- end}} -) - -// Register registers a {{.Package}} implementation. -{{- if .Capability.Required}} -// All methods are required. -func Register(impl {{agentName .Capability}}) { -{{- range .Capability.Methods}} - {{implVar .}} = impl.{{.Name}} -{{- end}} -} -{{- else}} -// The implementation is checked for optional provider interfaces. -func Register(impl {{agentName .Capability}}) { -{{- range .Capability.Methods}} - if p, ok := impl.({{providerInterface .}}); ok { - {{implVar .}} = p.{{.Name}} - } -{{- end}} -} -{{- end}} - -// NotImplementedCode is the standard return code for unimplemented functions. -// The host recognizes this and skips the plugin gracefully. -const NotImplementedCode int32 = -2 - -{{- /* Generate export wrappers */ -}} -{{range .Capability.Methods}} - -//go:wasmexport {{.ExportName}} -func {{exportFunc .}}() int32 { - if {{implVar .}} == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } -{{- if .HasInput}} - - var input {{.Input.Type}} - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } -{{- end}} -{{- if and .HasInput .HasOutput}} - - output, err := {{implVar .}}(input) - if err != nil { - pdk.SetError(err) - return -1 - } - - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } -{{- else if .HasInput}} - - if err := {{implVar .}}(input); err != nil { - pdk.SetError(err) - return -1 - } -{{- else if .HasOutput}} - - output, err := {{implVar .}}() - if err != nil { - pdk.SetError(err) - return -1 - } - - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } -{{- else}} - - if err := {{implVar .}}(); err != nil { - pdk.SetError(err) - return -1 - } -{{- end}} - - return 0 -} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl deleted file mode 100644 index 2ee7ece1d..000000000 --- a/plugins/cmd/ndpgen/internal/templates/capability.rs.tmpl +++ /dev/null @@ -1,209 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the {{.Capability.Interface}} capability. -// It is intended for use in Navidrome plugins built with extism-pdk. -{{if or .Capability.Structs .Capability.SharedAliases}} -use serde::{Deserialize, Serialize}; -{{- if hasHashMap .Capability}} -use std::collections::HashMap; -{{- end}} -{{- if .Capability.HasByteFields}}{{template "base64_bytes_module" .}}{{- end}} - -// Helper functions for skip_serializing_if with numeric types -#[allow(dead_code)] -fn is_zero_i32(value: &i32) -> bool { *value == 0 } -#[allow(dead_code)] -fn is_zero_u32(value: &u32) -> bool { *value == 0 } -#[allow(dead_code)] -fn is_zero_i64(value: &i64) -> bool { *value == 0 } -#[allow(dead_code)] -fn is_zero_u64(value: &u64) -> bool { *value == 0 } -#[allow(dead_code)] -fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } -#[allow(dead_code)] -fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } -{{- end}} - -{{- /* Generate deprecated aliases to the shared types crate */ -}} -{{- range .Capability.SharedAliases}} - -#[deprecated(note = "use {{rustSharedNote .Target}}")] -pub type {{.Name}} = {{rustSharedTarget .Target}}; -{{- end}} - -{{- /* Generate type alias definitions */ -}} -{{- range .Capability.TypeAliases}} - -{{- if .Doc}} -{{rustDocComment .Doc}} -{{- end}} -pub type {{.Name}} = {{rustTypeAlias .Type}}; -{{- end}} - -{{- /* Generate const definitions */ -}} -{{- range .Capability.Consts}} -{{- if .Values}} -{{- $type := .Type}} -{{- range $i, $v := .Values}} - -{{- if $v.Doc}} -{{rustDocComment $v.Doc}} -{{- end}} -{{- /* Use the type alias name if a named type is provided, otherwise use &'static str */ -}} -{{- if $type}} -pub const {{rustConstName $v.Name}}: {{$type}} = {{$v.Value}}; -{{- else}} -pub const {{rustConstName $v.Name}}: &'static str = {{$v.Value}}; -{{- end}} -{{- end}} -{{- end}} -{{- end}} - -{{- /* Generate struct definitions */ -}} -{{- range .Capability.Structs}} - -{{- if .Doc}} -{{rustDocComment .Doc}} -{{- else}} -/// {{.Name}} represents the {{.Name}} data structure. -{{- end}} -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct {{.Name}} { -{{- range .Fields}} -{{- if .Doc}} -{{rustDocComment .Doc | indent 4}} -{{- end}} -{{- if .OmitEmpty}} - #[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")] -{{- else}} - #[serde(default)] -{{- end}} -{{- if .IsByteSlice}} - #[serde(with = "base64_bytes")] -{{- end}} - pub {{rustFieldName .Name}}: {{fieldRustType .}}, -{{- end}} -} -{{- end}} - -/// 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() } - } -} - -{{- /* Generate main interface based on required flag */ -}} -{{if .Capability.Required}} - -/// {{agentName .Capability}} requires all methods to be implemented. -{{- if .Capability.Doc}} -{{rustDocComment .Capability.Doc}} -{{- end}} -pub trait {{agentName .Capability}} { -{{- range .Capability.Methods}} - /// {{.Name}}{{if .Doc}} - {{.Doc}}{{end}} - {{- if and .HasInput .HasOutput}} - fn {{rustMethodName .Name}}(&self, req: {{rustOutputType .Input.Type}}) -> Result<{{rustOutputType .Output.Type}}, Error>; - {{- else if .HasInput}} - fn {{rustMethodName .Name}}(&self, req: {{rustOutputType .Input.Type}}) -> Result<(), Error>; - {{- else if .HasOutput}} - fn {{rustMethodName .Name}}(&self) -> Result<{{rustOutputType .Output.Type}}, Error>; - {{- else}} - fn {{rustMethodName .Name}}(&self) -> Result<(), Error>; - {{- end}} -{{- end}} -} - -/// Register all exports for the {{agentName .Capability}} capability. -/// This macro generates the WASM export functions for all trait methods. -#[macro_export] -macro_rules! register_{{snakeCase .Package}} { - ($plugin_type:ty) => { - {{- range .Capability.Methods}} - #[extism_pdk::plugin_fn] - pub fn {{.ExportName}}( - {{- if .HasInput}} - req: extism_pdk::Json<{{rustMethodType .Input.Type}}> - {{- end}} - ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{rustMethodType .Output.Type}}>{{else}}(){{end}}> { - let plugin = <$plugin_type>::default(); - {{- if and .HasInput .HasOutput}} - let result = $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - {{- else if .HasInput}} - $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; - Ok(()) - {{- else if .HasOutput}} - let result = $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin)?; - Ok(extism_pdk::Json(result)) - {{- else}} - $crate::{{snakeCase $.Package}}::{{agentName $.Capability}}::{{rustMethodName .Name}}(&plugin)?; - Ok(()) - {{- end}} - } - {{- end}} - }; -} -{{- else}} - -{{- /* Generate optional provider interfaces for non-required capabilities */ -}} -{{- range .Capability.Methods}} - -/// {{providerInterface .}} provides the {{.Name}} function. -pub trait {{providerInterface .}} { - {{- if and .HasInput .HasOutput}} - fn {{rustMethodName .Name}}(&self, req: {{rustOutputType .Input.Type}}) -> Result<{{rustOutputType .Output.Type}}, Error>; - {{- else if .HasInput}} - fn {{rustMethodName .Name}}(&self, req: {{rustOutputType .Input.Type}}) -> Result<(), Error>; - {{- else if .HasOutput}} - fn {{rustMethodName .Name}}(&self) -> Result<{{rustOutputType .Output.Type}}, Error>; - {{- else}} - fn {{rustMethodName .Name}}(&self) -> Result<(), Error>; - {{- end}} -} - -/// Register the {{rustMethodName .Name}} export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! {{registerMacroName .Name}} { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn {{.ExportName}}( - {{- if .HasInput}} - req: extism_pdk::Json<{{rustMethodType .Input.Type}}> - {{- end}} - ) -> extism_pdk::FnResult<{{if .HasOutput}}extism_pdk::Json<{{rustMethodType .Output.Type}}>{{else}}(){{end}}> { - let plugin = <$plugin_type>::default(); - {{- if and .HasInput .HasOutput}} - let result = $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - {{- else if .HasInput}} - $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin, req.into_inner())?; - Ok(()) - {{- else if .HasOutput}} - let result = $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin)?; - Ok(extism_pdk::Json(result)) - {{- else}} - $crate::{{snakeCase $.Package}}::{{providerInterface .}}::{{rustMethodName .Name}}(&plugin)?; - Ok(()) - {{- end}} - } - }; -} -{{- end}} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl deleted file mode 100644 index 59a886f08..000000000 --- a/plugins/cmd/ndpgen/internal/templates/capability_stub.go.tmpl +++ /dev/null @@ -1,145 +0,0 @@ -// 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 {{.Package}} -{{- if .Capability.ImportsSharedTypes}} - -import "github.com/navidrome/navidrome/plugins/pdk/go/types" -{{- end}} - -{{- /* Generate deprecated shared-type aliases */ -}} -{{- range .Capability.SharedAliases}} - -{{- if .Doc}} -{{formatDoc .Doc}} -{{- end}} -type {{.Name}} = {{.Target}} -{{- end}} - -{{- /* Generate type alias definitions */ -}} -{{- range .Capability.TypeAliases}} - -{{- if .Doc}} -{{formatDoc .Doc}} -{{- end}} -type {{.Name}} {{.Type}} -{{- end}} - -{{- /* Generate const definitions */ -}} -{{- range .Capability.Consts}} -{{- if .Values}} - -const ( -{{- $type := .Type}} -{{- range $i, $v := .Values}} -{{- if $v.Doc}} -{{formatDoc $v.Doc | indent 1}} -{{- end}} -{{- if $type}} - {{$v.Name}} {{$type}} = {{$v.Value}} -{{- else}} - {{$v.Name}} = {{$v.Value}} -{{- end}} -{{- end}} -) -{{- end}} -{{- end}} - -{{- /* Generate Error() methods for string type aliases with const values (implements error interface) */ -}} -{{- $consts := .Capability.Consts}} -{{- range .Capability.TypeAliases}} -{{- if eq .Type "string"}} -{{- $typeName := .Name}} -{{- range $consts}} -{{- if eq .Type $typeName}} - -// Error implements the error interface for {{$typeName}}. -func (e {{$typeName}}) Error() string { return string(e) } -{{- end}} -{{- end}} -{{- end}} -{{- end}} - -{{- /* Generate struct definitions */ -}} -{{- range .Capability.Structs}} - -{{- if .Doc}} -{{formatDoc .Doc}} -{{- else}} -// {{.Name}} represents the {{.Name}} data structure. -{{- end}} -type {{.Name}} struct { -{{- range .Fields}} -{{- if .Doc}} -{{formatDoc .Doc | indent 1}} -{{- end}} - {{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"` -{{- end}} -} -{{- end}} - -{{- /* Generate main interface based on required flag */ -}} -{{if .Capability.Required}} - -// {{agentName .Capability}} requires all methods to be implemented. -{{- if .Capability.Doc}} -{{formatDoc .Capability.Doc}} -{{- end}} -type {{agentName .Capability}} interface { -{{- range .Capability.Methods}} - // {{.Name}}{{if .Doc}} - {{.Doc}}{{end}} - {{- if and .HasInput .HasOutput}} - {{.Name}}({{.Input.Type}}) ({{.Output.Type}}, error) - {{- else if .HasInput}} - {{.Name}}({{.Input.Type}}) error - {{- else if .HasOutput}} - {{.Name}}() ({{.Output.Type}}, error) - {{- else}} - {{.Name}}() error - {{- end}} -{{- end}} -} -{{- else}} - -// {{agentName .Capability}} is the marker interface for {{.Package}} plugins. -// Implement one or more of the provider interfaces below. -{{- if .Capability.Doc}} -{{formatDoc .Capability.Doc}} -{{- end}} -type {{agentName .Capability}} interface{} -{{- end}} - -{{- /* Generate optional provider interfaces for non-required capabilities */ -}} -{{- if not .Capability.Required}} -{{- range .Capability.Methods}} - -// {{providerInterface .}} provides the {{.Name}} function. -type {{providerInterface .}} interface { - {{- if and .HasInput .HasOutput}} - {{.Name}}({{.Input.Type}}) ({{.Output.Type}}, error) - {{- else if .HasInput}} - {{.Name}}({{.Input.Type}}) error - {{- else if .HasOutput}} - {{.Name}}() ({{.Output.Type}}, error) - {{- else}} - {{.Name}}() error - {{- end}} -} -{{- end}} -{{- end}} - -// 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. -{{- if .Capability.Required}} -func Register(_ {{agentName .Capability}}) {} -{{- else}} -func Register(_ {{agentName .Capability}}) {} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client.go.tmpl deleted file mode 100644 index b49680d1d..000000000 --- a/plugins/cmd/ndpgen/internal/templates/client.go.tmpl +++ /dev/null @@ -1,142 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the {{.Service.Name}} host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package {{.Package}} - -import ( - "encoding/json" -{{- if .Service.HasErrors}} - "errors" -{{- end}} -{{- if .Service.ImportsSharedTypes}} - - "github.com/navidrome/navidrome/plugins/pdk/go/types" -{{- end}} - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -{{- /* Generate deprecated shared-type aliases */ -}} -{{- range .Service.SharedAliases}} - -{{- if .Doc}} -{{formatDoc .Doc}} -{{- end}} -type {{.Name}} = {{.Target}} -{{- end}} - -{{- /* Generate struct definitions */ -}} -{{- range .Service.Structs}} - -// {{.Name}} represents the {{.Name}} data structure. -{{- if .Doc}} -{{formatDoc .Doc}} -{{- end}} -type {{.Name}} struct { -{{- range .Fields}} - {{.Name}} {{.Type}} `json:"{{.JSONTag}}"` -{{- end}} -} -{{- end}} - -{{- /* Generate wasmimport declarations for each method */ -}} -{{range .Service.Methods}} - -// {{exportName .}} is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user {{exportName .}} -func {{exportName .}}(uint64) uint64 -{{- end}} - -{{- /* Generate request/response types for all methods (private) */ -}} -{{range .Service.Methods}} -{{- if .HasParams}} - -type {{requestType .}} struct { -{{- range .Params}} - {{title .Name}} {{.Type}} `json:"{{.JSONName}}"` -{{- end}} -} -{{- end}} -{{- if not .IsErrorOnly}} - -type {{responseType .}} struct { -{{- range .Returns}} - {{title .Name}} {{.Type}} `json:"{{.JSONName}},omitempty"` -{{- end}} -{{- if .HasError}} - Error string `json:"error,omitempty"` -{{- end}} -} -{{- end}} -{{- end}} - -{{- /* Generate wrapper functions */ -}} -{{range .Service.Methods}} - -// {{$.Service.Name}}{{.Name}} calls the {{exportName .}} host function. -{{- if .Doc}} -{{formatDoc .Doc}} -{{- end}} -func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}} {{$p.Type}}{{end}}) {{.ReturnSignature}} { -{{- if .HasParams}} - // Marshal request to JSON - req := {{requestType .}}{ -{{- range .Params}} - {{title .Name}}: {{.Name}}, -{{- end}} - } - reqBytes, err := json.Marshal(req) - if err != nil { - return {{if .HasReturns}}{{.ZeroValues}}{{end}}{{if and .HasReturns .HasError}}, {{end}}{{if .HasError}}err{{end}} - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() -{{- else}} - // No parameters - allocate empty JSON object - reqMem := pdk.AllocateBytes([]byte("{}")) - defer reqMem.Free() -{{- end}} - - // Call the host function - responsePtr := {{exportName .}}(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() -{{- if .IsErrorOnly}} - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -{{- else}} - - // Parse the response - var response {{responseType .}} - if err := json.Unmarshal(responseBytes, &response); err != nil { - return {{if .HasReturns}}{{.ZeroValues}}{{end}}{{if and .HasReturns .HasError}}, {{end}}{{if .HasError}}err{{end}} - } -{{- if .HasError}} - - // Convert Error field to Go error - if response.Error != "" { - return {{if .HasReturns}}{{.ZeroValues}}, {{end}}errors.New(response.Error) - } -{{- end}} - - return {{range $i, $r := .Returns}}{{if $i}}, {{end}}response.{{title $r.Name}}{{end}}{{if and .HasReturns .HasError}}, {{end}}{{if .HasError}}nil{{end}} -{{- end}} -} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl deleted file mode 100644 index 786a50db5..000000000 --- a/plugins/cmd/ndpgen/internal/templates/client.rs.tmpl +++ /dev/null @@ -1,144 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the {{.Service.Name}} host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; -{{- if .Service.HasByteFields}}{{template "base64_bytes_module" .}}{{- end}} -{{- /* Generate struct definitions */ -}} -{{- range .Service.Structs}} -{{if .Doc}} -{{rustDocComment .Doc}} -{{else}} -{{end}}#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct {{.Name}} { -{{- range .Fields}} -{{- if .NeedsDefault}} - #[serde(default)] -{{- end}} -{{- if .IsByteSlice}} - #[serde(with = "base64_bytes")] -{{- end}} - pub {{.RustName}}: {{fieldRustType .}}, -{{- end}} -} -{{- end}} -{{- /* Generate request/response types */ -}} -{{- range .Service.Methods}} -{{- if .HasParams}} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct {{requestType .}} { -{{- range .Params}} -{{- if .IsByteSlice}} - #[serde(with = "base64_bytes")] -{{- end}} - {{.RustName}}: {{rustType .}}, -{{- end}} -} -{{- end}} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct {{responseType .}} { -{{- range .Returns}} - #[serde(default)] -{{- if .IsByteSlice}} - #[serde(with = "base64_bytes")] -{{- end}} - {{.RustName}}: {{rustType .}}, -{{- end}} -{{- if .HasError}} - #[serde(default)] - error: Option, -{{- end}} -} -{{- end}} - -#[host_fn] -extern "ExtismHost" { -{{- range .Service.Methods}} - fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>; -{{- end}} -} - -{{- /* Generate wrapper functions */ -}} -{{range .Service.Methods}} - -{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}} -{{- if .HasParams}} -/// -/// # Arguments -{{- range .Params}} -/// * `{{.RustName}}` - {{rustType .}} parameter. -{{- end}} -{{- end}} -{{- if .HasReturns}} -/// -/// # Returns -{{- if .IsOptionPattern}} -/// `Some({{(index .Returns 0).RustName}})` if found, `None` otherwise. -{{- else if eq (len .Returns) 1}} -/// The {{(index .Returns 0).RustName}} value. -{{- else}} -/// A tuple of ({{range $i, $r := .Returns}}{{if $i}}, {{end}}{{$r.RustName}}{{end}}). -{{- end}} -{{- end}} -/// -/// # Errors -/// Returns an error if the host function call fails. -{{- if .IsOptionPattern}} -pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result, Error> { - let response = unsafe { -{{- if .HasParams}} - {{exportName .}}(Json({{requestType .}} { -{{- range .Params}} - {{.RustName}}: {{.RustName}}{{if .NeedsToOwned}}.to_owned(){{end}}, -{{- end}} - }))? -{{- else}} - {{exportName .}}(Json(serde_json::json!({})))? -{{- end}} - }; -{{if .HasError}} - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } -{{end}} - if response.0.{{(index .Returns 1).RustName}} { - Ok(Some(response.0.{{(index .Returns 0).RustName}})) - } else { - Ok(None) - } -} -{{- else}} -pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result<{{if eq (len .Returns) 0}}(){{else if eq (len .Returns) 1}}{{rustType (index .Returns 0)}}{{else}}({{range $i, $r := .Returns}}{{if $i}}, {{end}}{{rustType $r}}{{end}}){{end}}, Error> { - let response = unsafe { -{{- if .HasParams}} - {{exportName .}}(Json({{requestType .}} { -{{- range .Params}} - {{.RustName}}: {{.RustName}}{{if .NeedsToOwned}}.to_owned(){{end}}, -{{- end}} - }))? -{{- else}} - {{exportName .}}(Json(serde_json::json!({})))? -{{- end}} - }; -{{if .HasError}} - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } -{{end}} -{{- if eq (len .Returns) 0}} - Ok(()) -{{- else if eq (len .Returns) 1}} - Ok(response.0.{{(index .Returns 0).RustName}}) -{{- else}} - Ok(({{range $i, $r := .Returns}}{{if $i}}, {{end}}response.0.{{$r.RustName}}{{end}})) -{{- end}} -} -{{- end}} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl deleted file mode 100644 index eb5e9f844..000000000 --- a/plugins/cmd/ndpgen/internal/templates/client_stub.go.tmpl +++ /dev/null @@ -1,64 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported mock instances to set expectations in tests. -// -//go:build !wasip1 - -package {{.Package}} - -import ( -{{- if .Service.ImportsSharedTypes}} - "github.com/navidrome/navidrome/plugins/pdk/go/types" -{{- end}} - "github.com/stretchr/testify/mock" -) - -{{- /* Generate deprecated shared-type aliases */ -}} -{{- range .Service.SharedAliases}} - -{{- if .Doc}} -{{formatDoc .Doc}} -{{- end}} -type {{.Name}} = {{.Target}} -{{- end}} - -{{- /* Generate struct definitions (same as main file, needed for type references in function signatures) */ -}} -{{- range .Service.Structs}} - -// {{.Name}} represents the {{.Name}} data structure. -{{- if .Doc}} -{{formatDoc .Doc}} -{{- end}} -type {{.Name}} struct { -{{- range .Fields}} - {{.Name}} {{.Type}} `json:"{{.JSONTag}}"` -{{- end}} -} -{{- end}} - -// mock{{.Service.Name}}Service is the mock implementation for testing. -type mock{{.Service.Name}}Service struct { - mock.Mock -} - -// {{.Service.Name}}Mock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.{{.Service.Name}}Mock.On("MethodName", args...).Return(values...) -var {{.Service.Name}}Mock = &mock{{.Service.Name}}Service{} -{{range .Service.Methods}} - -// {{.Name}} is the mock method for {{$.Service.Name}}{{.Name}}. -func (m *mock{{$.Service.Name}}Service) {{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}} {{$p.Type}}{{end}}) {{.ReturnSignature}} { - args := m.Called({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}}{{end}}) -{{mockReturnBody .}} -} - -// {{$.Service.Name}}{{.Name}} delegates to the mock instance. -{{- if .Doc}} -{{formatDoc .Doc}} -{{- end}} -func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}} {{$p.Type}}{{end}}) {{.ReturnSignature}} { - return {{$.Service.Name}}Mock.{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.Name}}{{end}}) -} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/doc.go.tmpl b/plugins/cmd/ndpgen/internal/templates/doc.go.tmpl deleted file mode 100644 index e48dc3363..000000000 --- a/plugins/cmd/ndpgen/internal/templates/doc.go.tmpl +++ /dev/null @@ -1,49 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -/* -Package {{.Package}} provides Navidrome Plugin Development Kit wrappers for Go/TinyGo plugins. - -This package is auto-generated by the ndpgen tool and should not be edited manually. - -# Usage - -Add this module as a dependency in your plugin's go.mod: - - require github.com/navidrome/navidrome/plugins/pdk/go/host v0.0.0 - -Then import the package in your plugin code: - - import {{.Package}} "github.com/navidrome/navidrome/plugins/pdk/go/host" - - func myPluginFunction() error { - // Use the cache service - _, err := {{.Package}}.CacheSetString("my_key", "my_value", 3600) - if err != nil { - return err - } - - // Schedule a recurring task - _, err = {{.Package}}.SchedulerScheduleRecurring("@every 5m", "payload", "task_id") - if err != nil { - return err - } - - return nil - } - -# Available Services - -The following host services are available: -{{range .Services}} - - {{.Name}}: {{if .Doc}}{{.Doc | firstLine}}{{else}}{{.Name}} service{{end}} -{{- end}} - -# Building Plugins - -Go plugins must be compiled to WebAssembly using TinyGo: - - tinygo build -o plugin.wasm -target=wasip1 -buildmode=c-shared . - -See the examples directory for complete plugin implementations. -*/ -package {{.Package}} diff --git a/plugins/cmd/ndpgen/internal/templates/go.mod.tmpl b/plugins/cmd/ndpgen/internal/templates/go.mod.tmpl deleted file mode 100644 index 3916cd749..000000000 --- a/plugins/cmd/ndpgen/internal/templates/go.mod.tmpl +++ /dev/null @@ -1,8 +0,0 @@ -module github.com/navidrome/navidrome/plugins/pdk/go - -go 1.25 - -require ( - github.com/extism/go-pdk v1.1.3 - github.com/stretchr/testify v1.11.1 -) diff --git a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl b/plugins/cmd/ndpgen/internal/templates/host.go.tmpl deleted file mode 100644 index b1ac4dd36..000000000 --- a/plugins/cmd/ndpgen/internal/templates/host.go.tmpl +++ /dev/null @@ -1,133 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package {{.Package}} - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -{{- if .Service.ImportsSharedTypes}} - - "github.com/navidrome/navidrome/plugins/types" -{{- end}} -) - -{{- /* Generate request/response types for all methods */ -}} -{{range .Service.Methods}} -{{- if .HasParams}} - -// {{requestType .}} is the request type for {{$.Service.Name}}.{{.Name}}. -type {{requestType .}} struct { -{{- range .Params}} - {{title .Name}} {{.Type}} `json:"{{.JSONName}}"` -{{- end}} -} -{{- end}} - -// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}. -type {{responseType .}} struct { -{{- range .Returns}} - {{title .Name}} {{.Type}} `json:"{{.JSONName}},omitempty"` -{{- end}} -{{- if .HasError}} - Error string `json:"error,omitempty"` -{{- end}} -} -{{end}} - -// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions. -// The returned host functions should be added to the plugin's configuration. -func Register{{.Service.Name}}HostFunctions(service {{.Service.Interface}}) []extism.HostFunction { - return []extism.HostFunction{ -{{- range .Service.Methods}} - new{{$.Service.Name}}{{.Name}}HostFunction(service), -{{- end}} - } -} -{{range .Service.Methods}} - -func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}}) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "{{exportName .}}", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { -{{- if .HasParams}} - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - {{$.Service.Name | lower}}WriteError(p, stack, err) - return - } - var req {{requestType .}} - if err := json.Unmarshal(reqBytes, &req); err != nil { - {{$.Service.Name | lower}}WriteError(p, stack, err) - return - } -{{- end}} - - // Call the service method -{{- if .HasReturns}} -{{- if .HasError}} - {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) - if svcErr != nil { - {{$.Service.Name | lower}}WriteError(p, stack, svcErr) - return - } -{{- else}} - {{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) -{{- end}} - - // Write JSON response to plugin memory - resp := {{responseType .}}{ -{{- range .Returns}} - {{title .Name}}: {{lower .Name}}, -{{- end}} - } - {{$.Service.Name | lower}}WriteResponse(p, stack, resp) -{{- else if .HasError}} - if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil { - {{$.Service.Name | lower}}WriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := {{responseType .}}{} - {{$.Service.Name | lower}}WriteResponse(p, stack, resp) -{{- else}} - service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}) - - // Write JSON response to plugin memory - resp := {{responseType .}}{} - {{$.Service.Name | lower}}WriteResponse(p, stack, resp) -{{- end}} - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} -{{end}} - -// {{.Service.Name | lower}}WriteResponse writes a JSON response to plugin memory. -func {{.Service.Name | lower}}WriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - {{.Service.Name | lower}}WriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// {{.Service.Name | lower}}WriteError writes an error response to plugin memory. -func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/cmd/ndpgen/internal/templates/lib.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/lib.rs.tmpl deleted file mode 100644 index 3b0434ee2..000000000 --- a/plugins/cmd/ndpgen/internal/templates/lib.rs.tmpl +++ /dev/null @@ -1,47 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -//! Navidrome Host Function Wrappers for Rust Plugins -//! -//! This crate provides idiomatic Rust wrappers for all Navidrome host services. -//! It is auto-generated by the ndpgen tool and should not be edited manually. -//! -//! # Usage -//! -//! Add this crate as a dependency in your plugin's Cargo.toml: -//! -//! ```toml -//! [dependencies] -//! nd-host = { path = "../../host/rust" } -//! ``` -//! -//! Then import the services you need: -//! -//! ```ignore -//! use nd_host::{cache, scheduler}; -//! -//! fn my_plugin_function() -> Result<(), extism_pdk::Error> { -//! // Use the cache service -//! cache::set_string("my_key", "my_value", 3600)?; -//! -//! // Schedule a recurring task -//! scheduler::schedule_recurring("@every 5m", "payload", "task_id")?; -//! -//! Ok(()) -//! } -//! ``` -//! -//! # Available Services -//! -{{- range .Services}} -//! - [`{{.Name | lower}}`] - {{if .Doc}}{{.Doc | firstLine}}{{else}}{{.Name}} service{{end}} -{{- end}} -{{range .Services}} -#[doc(hidden)] -mod nd_host_{{.Name | lower}}; -/// {{if .Doc}}{{.Doc | firstLine}}{{else}}{{.Name}} host service wrappers.{{end}} -pub mod {{.Name | lower}} { - pub use super::nd_host_{{.Name | lower}}::*; -} -{{end}} -// Re-export commonly used types from extism-pdk for convenience -pub use extism_pdk::Error; diff --git a/plugins/cmd/ndpgen/internal/templates/pdk.go.tmpl b/plugins/cmd/ndpgen/internal/templates/pdk.go.tmpl deleted file mode 100644 index 117bd9232..000000000 --- a/plugins/cmd/ndpgen/internal/templates/pdk.go.tmpl +++ /dev/null @@ -1,54 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains wrapper functions for the extism/go-pdk package. -// For WASM builds, it provides type aliases and function wrappers that delegate -// to the real extism/go-pdk package with zero overhead. -// -//go:build wasip1 - -package pdk - -import ( - extism "github.com/extism/go-pdk" -) - -// Type aliases - zero overhead, full compatibility -{{- range .Types}} -type {{.Name}} = extism.{{.Name}} -{{- end}} - -// Constants -{{- $prevType := ""}} -{{- range .Consts}} -{{- if ne .Type $prevType}} -{{- if ne $prevType ""}} -) -{{- end}} - -const ( -{{- end}} - {{.Name}} = extism.{{.Name}} -{{- $prevType = .Type}} -{{- end}} -{{- if ne $prevType ""}} -) -{{- end}} - -// Functions -{{- range .Functions}} - -{{- if .Doc}} -// {{.Name}} {{firstSentence .Doc}} -{{- end}} -{{- if .Deprecated}} -// -// Deprecated: {{.Deprecated}} -{{- end}} -func {{.Name}}({{paramList .Params}}){{returnList .Returns}} { -{{- if .Returns}} - return extism.{{.Name}}({{argList .Params}}) -{{- else}} - extism.{{.Name}}({{argList .Params}}) -{{- end}} -} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl deleted file mode 100644 index 27d06faa0..000000000 --- a/plugins/cmd/ndpgen/internal/templates/pdk_stub.go.tmpl +++ /dev/null @@ -1,46 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported PDKMock instance to set expectations in tests. -// -//go:build !wasip1 - -package pdk - -import "github.com/stretchr/testify/mock" - -// mockPDK is the mock implementation for testing PDK functions. -type mockPDK struct { - mock.Mock -} - -// PDKMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: pdk.PDKMock.On("GetConfig", "key").Return("value", true) -var PDKMock = &mockPDK{} - -// ResetMock resets the mock to its initial state. -// Call this in test setup/teardown to ensure clean state between tests. -func ResetMock() { - PDKMock = &mockPDK{} -} - -// Functions -{{- range .Functions}} - -{{- if .Doc}} -// {{.Name}} {{firstSentence .Doc}} -{{- end}} -{{- if .Deprecated}} -// -// Deprecated: {{.Deprecated}} -{{- end}} -func {{.Name}}({{paramList .Params}}){{returnList .Returns}} { -{{- if .Returns}} - args := PDKMock.Called({{argList .Params}}) -{{mockReturnBody .Returns}} -{{- else}} - PDKMock.Called({{argList .Params}}) -{{- end}} -} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/types.go.tmpl b/plugins/cmd/ndpgen/internal/templates/types.go.tmpl deleted file mode 100644 index 698429b96..000000000 --- a/plugins/cmd/ndpgen/internal/templates/types.go.tmpl +++ /dev/null @@ -1,23 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// Package {{.Package}} holds the stable, shared data types exchanged between -// Navidrome and its plugins. These types are referenced by host services and -// capability wrappers via the types package. - -package {{.Package}} -{{- range .Structs}} - -{{- if .Doc}} -{{formatDoc .Doc}} -{{- else}} -// {{.Name}} represents the {{.Name}} data structure. -{{- end}} -type {{.Name}} struct { -{{- range .Fields}} -{{- if .Doc}} -{{formatDoc .Doc | indent 1}} -{{- end}} - {{.Name}} {{.Type}} `json:"{{.JSONTag}}{{if .OmitEmpty}},omitempty{{end}}"` -{{- end}} -} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl b/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl deleted file mode 100644 index b7bc131ff..000000000 --- a/plugins/cmd/ndpgen/internal/templates/types.rs.tmpl +++ /dev/null @@ -1,49 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -//! Navidrome shared plugin data types. - -use serde::{Deserialize, Serialize}; -{{- if .HasHashMap}} -use std::collections::HashMap; -{{- end}} -{{- if .HasByteFields}}{{template "base64_bytes_module" .}}{{- end}} - -// Helper functions for skip_serializing_if with numeric types -#[allow(dead_code)] -fn is_zero_i32(value: &i32) -> bool { *value == 0 } -#[allow(dead_code)] -fn is_zero_u32(value: &u32) -> bool { *value == 0 } -#[allow(dead_code)] -fn is_zero_i64(value: &i64) -> bool { *value == 0 } -#[allow(dead_code)] -fn is_zero_u64(value: &u64) -> bool { *value == 0 } -#[allow(dead_code)] -fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } -#[allow(dead_code)] -fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } -{{- range .Structs}} - -{{- if .Doc}} -{{rustDocComment .Doc}} -{{- else}} -/// {{.Name}} represents the {{.Name}} data structure. -{{- end}} -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct {{.Name}} { -{{- range .Fields}} -{{- if .Doc}} -{{rustDocComment .Doc | indent 4}} -{{- end}} -{{- if .OmitEmpty}} - #[serde(default, skip_serializing_if = "{{skipSerializingFunc .Type}}")] -{{- else}} - #[serde(default)] -{{- end}} -{{- if .IsByteSlice}} - #[serde(with = "base64_bytes")] -{{- end}} - pub {{rustFieldName .Name}}: {{fieldRustType .}}, -{{- end}} -} -{{- end}} diff --git a/plugins/cmd/ndpgen/internal/templates/types_stub.go.tmpl b/plugins/cmd/ndpgen/internal/templates/types_stub.go.tmpl deleted file mode 100644 index 06cbb4f1f..000000000 --- a/plugins/cmd/ndpgen/internal/templates/types_stub.go.tmpl +++ /dev/null @@ -1,192 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains type definitions for non-WASM builds. -// These types match the extism/go-pdk signatures to allow compilation and testing -// on native platforms without importing the WASM-only extism package. -// -//go:build !wasip1 - -package pdk - -// LogLevel represents a logging level. -type LogLevel int - -// Log level constants -const ( - LogTrace LogLevel = iota - LogDebug - LogInfo - LogWarn - LogError -) - -// HTTPMethod represents an HTTP method. -type HTTPMethod int32 - -// HTTP method constants -const ( - MethodGet HTTPMethod = iota - MethodHead - MethodPost - MethodPut - MethodPatch - MethodDelete - MethodConnect - MethodOptions - MethodTrace -) - -// String returns the string representation of the HTTP method. -func (m HTTPMethod) String() string { - switch m { - case MethodGet: - return "GET" - case MethodHead: - return "HEAD" - case MethodPost: - return "POST" - case MethodPut: - return "PUT" - case MethodPatch: - return "PATCH" - case MethodDelete: - return "DELETE" - case MethodConnect: - return "CONNECT" - case MethodOptions: - return "OPTIONS" - case MethodTrace: - return "TRACE" - default: - return "UNKNOWN" - } -} - -// Memory represents memory allocated by (and shared with) the host. -// This is a stub implementation for non-WASM platforms. -type Memory struct { - offset uint64 - length uint64 - data []byte -} - -// Offset returns the offset of the memory block. -func (m Memory) Offset() uint64 { - return m.offset -} - -// Length returns the length of the memory block. -func (m Memory) Length() uint64 { - return m.length -} - -// ReadBytes reads all bytes from the memory block. -func (m Memory) ReadBytes() []byte { - return m.data -} - -// Load reads the memory block into the provided buffer. -func (m *Memory) Load(buffer []byte) { - copy(buffer, m.data) -} - -// Store writes data to the memory block. -func (m *Memory) Store(data []byte) { - m.data = make([]byte, len(data)) - copy(m.data, data) - m.length = uint64(len(data)) -} - -// Free frees the memory block. -func (m *Memory) Free() { - m.data = nil - m.length = 0 -} - -// NewStubMemory creates a new stub Memory for testing. -// This is a helper function not present in the real PDK. -func NewStubMemory(offset, length uint64, data []byte) Memory { - return Memory{ - offset: offset, - length: length, - data: data, - } -} - -// HTTPRequest represents an HTTP request sent by the host. -// This is a stub implementation for non-WASM platforms. -type HTTPRequest struct { - method HTTPMethod - url string - headers map[string]string - body []byte -} - -// SetHeader sets an HTTP header key to value. -func (r *HTTPRequest) SetHeader(key string, value string) *HTTPRequest { - if r.headers == nil { - r.headers = make(map[string]string) - } - r.headers[key] = value - return r -} - -// SetBody sets the HTTP request body. -func (r *HTTPRequest) SetBody(body []byte) *HTTPRequest { - r.body = body - return r -} - -// Send sends the HTTP request and returns the response. -// In the stub implementation, this delegates to the mock. -func (r *HTTPRequest) Send() HTTPResponse { - args := PDKMock.Called(r) - return args.Get(0).(HTTPResponse) -} - -// HTTPRequestMeta represents the metadata associated with an HTTP request. -type HTTPRequestMeta struct { - URL string `json:"url"` - Method string `json:"method"` - Headers map[string]string `json:"headers"` -} - -// HTTPResponse represents an HTTP response returned from the host. -// This is a stub implementation for non-WASM platforms. -type HTTPResponse struct { - status uint16 - headers map[string]string - body []byte - memory Memory -} - -// Status returns the status code from the response. -func (r HTTPResponse) Status() uint16 { - return r.status -} - -// Headers returns the HTTP response headers. -func (r *HTTPResponse) Headers() map[string]string { - return r.headers -} - -// Body returns the body byte slice from the response. -func (r HTTPResponse) Body() []byte { - return r.body -} - -// Memory returns the memory associated with the response. -func (r HTTPResponse) Memory() Memory { - return r.memory -} - -// NewStubHTTPResponse creates a new stub HTTPResponse for testing. -// This is a helper function not present in the real PDK. -func NewStubHTTPResponse(status uint16, headers map[string]string, body []byte) HTTPResponse { - return HTTPResponse{ - status: status, - headers: headers, - body: body, - memory: NewStubMemory(0, uint64(len(body)), body), - } -} diff --git a/plugins/cmd/ndpgen/internal/types.go b/plugins/cmd/ndpgen/internal/types.go deleted file mode 100644 index 09657d09e..000000000 --- a/plugins/cmd/ndpgen/internal/types.go +++ /dev/null @@ -1,769 +0,0 @@ -package internal - -import ( - "strings" - "unicode" -) - -// sharedTypesPrefix is the package selector a capability or host-service source -// uses to reference the shared types package (e.g. types.Track). -const sharedTypesPrefix = "types." - -// Service represents a parsed host service interface. -type Service struct { - Name string // Service name from annotation (e.g., "SubsonicAPI") - Permission string // Manifest permission key (e.g., "subsonicapi") - Interface string // Go interface name (e.g., "SubsonicAPIService") - Methods []Method // Methods marked with //nd:hostfunc - Doc string // Documentation comment for the service - Structs []StructDef // Structs used by this service - SharedAliases []SharedAlias // Aliases to types in the shared `types` package -} - -// Capability represents a parsed capability interface for plugin exports. -type Capability struct { - Name string // Package name from annotation (e.g., "metadata") - Interface string // Go interface name (e.g., "MetadataAgent") - Required bool // If true, all methods must be implemented - Methods []Export // Methods marked with //nd:export - Doc string // Documentation comment for the capability - Structs []StructDef // Structs used by this capability - TypeAliases []TypeAlias // Type aliases used by this capability - Consts []ConstGroup // Const groups used by this capability - SourceFile string // Base name of source file without extension (e.g., "websocket_callback") - SharedAliases []SharedAlias // Aliases to types in the shared `types` package - SharedTypes []StructDef // Resolved shapes of every used shared type, keyed by canonical name (for schema inlining, alias or not) -} - -// TypeAlias represents a type declaration (e.g. type ScrobblerErrorType string) -// or a Go type alias (e.g. type TrackInfo = types.Track). -type TypeAlias struct { - Name string // Type name - Type string // Underlying type (or alias target, e.g. "types.Track") - Doc string // Documentation comment - IsAlias bool // true for `type X = Y` (alias); false for `type X Y` (defined type) -} - -// IsDeprecated reports whether the alias carries a `Deprecated:` doc line. -func (t TypeAlias) IsDeprecated() bool { - for _, line := range strings.Split(t.Doc, "\n") { - if strings.HasPrefix(strings.TrimSpace(line), "Deprecated:") { - return true - } - } - return false -} - -// IsSharedAlias reports whether this alias targets the shared types package -// (e.g. `type TrackInfo = types.Track`). -func (t TypeAlias) IsSharedAlias() bool { - return t.IsAlias && strings.HasPrefix(t.Type, sharedTypesPrefix) -} - -// ConstGroup represents a group of const definitions. -type ConstGroup struct { - Type string // Type name for typed consts (empty for untyped) - Values []ConstDef // Const definitions -} - -// ConstDef represents a single const definition. -type ConstDef struct { - Name string // Const name - Value string // Const value - Doc string // Documentation comment -} - -// SharedAlias is a deprecated alias from a capability/host package to a type in -// the shared `types` package (e.g. type TrackInfo = types.Track). Def is the -// resolved shared struct, kept for XTP schema inlining. -type SharedAlias struct { - Name string // local name, e.g. "TrackInfo" - Target string // alias target, e.g. "types.Track" - Doc string // doc comment (carries the Deprecated: line) - Def StructDef // resolved shared struct shape -} - -// ImportsSharedTypes reports whether this capability references the shared types package. -// A reference can come from a deprecated re-export alias (e.g. type SongRef = types.SongRef) -// or directly from the canonical form (e.g. types.SongRef) in a struct field or a method -// signature, so the generated Go import must be emitted even when no alias is declared. -func (c Capability) ImportsSharedTypes() bool { - if len(c.SharedAliases) > 0 || structsReferenceSharedTypes(c.Structs) { - return true - } - for _, m := range c.Methods { - if typeReferencesSharedTypes(m.Input.Type) || typeReferencesSharedTypes(m.Output.Type) { - return true - } - } - return false -} - -// ImportsSharedTypes reports whether this service references the shared types package. -func (s Service) ImportsSharedTypes() bool { - if len(s.SharedAliases) > 0 || structsReferenceSharedTypes(s.Structs) { - return true - } - for _, m := range s.Methods { - for _, p := range m.Params { - if typeReferencesSharedTypes(p.Type) { - return true - } - } - for _, r := range m.Returns { - if typeReferencesSharedTypes(r.Type) { - return true - } - } - } - return false -} - -// structsReferenceSharedTypes reports whether any field across the given structs -// refers to the shared types package by its qualified name (e.g. types.SongRef, -// []types.SongRef, map[string]types.SongRef). -func structsReferenceSharedTypes(structs []StructDef) bool { - for _, st := range structs { - for _, f := range st.Fields { - if typeReferencesSharedTypes(f.Type) { - return true - } - } - } - return false -} - -// typeReferencesSharedTypes reports whether a Go type expression refers to the -// shared types package by its qualified name, accounting for pointer, slice, and -// map wrappers (e.g. types.SongRef, []types.SongRef, map[string]types.SongRef). -func typeReferencesSharedTypes(goType string) bool { - refs := map[string]bool{} - collectReferencedTypes(goType, refs) - for t := range refs { - if strings.HasPrefix(t, sharedTypesPrefix) { - return true - } - } - return false -} - -// KnownStructs returns a map of struct names defined in this capability, -// including shared-alias names so Rust field-type resolution finds them. -func (c Capability) KnownStructs() map[string]bool { - result := make(map[string]bool) - for _, st := range c.Structs { - result[st.Name] = true - } - for _, sa := range c.SharedAliases { - result[sa.Name] = true - } - return result -} - -// Export represents an exported WASM function within a capability. -type Export struct { - Name string // Go method name (e.g., "GetArtistBiography") - ExportName string // WASM export name (e.g., "nd_get_artist_biography") - Input Param // Single input parameter (the struct type) - Output Param // Single output return value (the struct type) - Doc string // Documentation comment for the method -} - -// ProviderInterfaceName returns the optional provider interface name. -// For a method "GetArtistBiography", returns "ArtistBiographyProvider". -func (e Export) ProviderInterfaceName() string { - // Remove "Get", "On", etc. prefixes and add "Provider" suffix - name := e.Name - for _, prefix := range []string{"Get", "On"} { - if strings.HasPrefix(name, prefix) { - name = name[len(prefix):] - break - } - } - return name + "Provider" -} - -// ImplVarName returns the internal implementation variable name. -// For "GetArtistBiography", returns "artistBiographyImpl". -func (e Export) ImplVarName() string { - name := e.Name - for _, prefix := range []string{"Get", "On"} { - if strings.HasPrefix(name, prefix) { - name = name[len(prefix):] - break - } - } - // Convert to camelCase - if len(name) > 0 { - name = strings.ToLower(string(name[0])) + name[1:] - } - return name + "Impl" -} - -// ExportFuncName returns the unexported WASM export function name. -// For "nd_get_artist_biography", returns "_ndGetArtistBiography". -func (e Export) ExportFuncName() string { - // Convert snake_case to PascalCase - parts := strings.Split(e.ExportName, "_") - var result strings.Builder - result.WriteString("_") - for _, part := range parts { - if len(part) > 0 { - result.WriteString(strings.ToUpper(string(part[0]))) - result.WriteString(part[1:]) - } - } - return result.String() -} - -// HasInput returns true if the method has an input parameter. -func (e Export) HasInput() bool { - return e.Input.Type != "" -} - -// HasOutput returns true if the method has a non-error return value. -func (e Export) HasOutput() bool { - return e.Output.Type != "" -} - -// IsPointerOutput returns true if the output type is a pointer. -func (e Export) IsPointerOutput() bool { - return strings.HasPrefix(e.Output.Type, "*") -} - -// StructDef represents a Go struct type definition. -type StructDef struct { - Name string // Go struct name (e.g., "Library") - Fields []FieldDef // Struct fields - Doc string // Documentation comment -} - -// FieldDef represents a field within a struct. -type FieldDef struct { - Name string // Go field name (e.g., "TotalSongs") - Type string // Go type (e.g., "int32", "*string", "[]User") - JSONTag string // JSON tag value (e.g., "totalSongs,omitempty") - OmitEmpty bool // Whether the field has omitempty tag - Doc string // Field documentation -} - -// OutputFileName returns the generated file name for this service. -func (s Service) OutputFileName() string { - return strings.ToLower(s.Name) + "_gen.go" -} - -// ExportPrefix returns the prefix for exported host function names. -func (s Service) ExportPrefix() string { - return strings.ToLower(s.Name) -} - -// KnownStructs returns a map of struct names defined in this service, -// including shared-alias names so Rust field-type resolution finds them. -func (s Service) KnownStructs() map[string]bool { - result := make(map[string]bool) - for _, st := range s.Structs { - result[st.Name] = true - } - for _, sa := range s.SharedAliases { - result[sa.Name] = true - } - return result -} - -// HasErrors returns true if any method in the service returns an error. -func (s Service) HasErrors() bool { - for _, m := range s.Methods { - if m.HasError { - return true - } - } - return false -} - -// Method represents a host function method within a service. -type Method struct { - Name string // Go method name (e.g., "Call") - ExportName string // Optional override for export name - Params []Param // Method parameters (excluding context.Context) - Returns []Param // Return values (excluding error) - HasError bool // Whether the method returns an error - Doc string // Documentation comment for the method -} - -// FunctionName returns the Extism host function export name. -func (m Method) FunctionName(servicePrefix string) string { - if m.ExportName != "" { - return m.ExportName - } - return servicePrefix + "_" + strings.ToLower(m.Name) -} - -// RequestTypeName returns the generated request type name (public, for host-side code). -func (m Method) RequestTypeName(serviceName string) string { - return serviceName + m.Name + "Request" -} - -// ResponseTypeName returns the generated response type name (public, for host-side code). -func (m Method) ResponseTypeName(serviceName string) string { - return serviceName + m.Name + "Response" -} - -// ClientRequestTypeName returns the generated request type name (private, for client/PDK code). -func (m Method) ClientRequestTypeName(serviceName string) string { - return lowerFirst(serviceName) + m.Name + "Request" -} - -// ClientResponseTypeName returns the generated response type name (private, for client/PDK code). -func (m Method) ClientResponseTypeName(serviceName string) string { - return lowerFirst(serviceName) + m.Name + "Response" -} - -// lowerFirst returns the string with the first letter lowercased. -func lowerFirst(s string) string { - if s == "" { - return s - } - r := []rune(s) - r[0] = unicode.ToLower(r[0]) - return string(r) -} - -// HasParams returns true if the method has input parameters. -func (m Method) HasParams() bool { - return len(m.Params) > 0 -} - -// HasReturns returns true if the method has return values (excluding error). -func (m Method) HasReturns() bool { - return len(m.Returns) > 0 -} - -// IsErrorOnly returns true if the method only returns an error (no data fields). -func (m Method) IsErrorOnly() bool { - return m.HasError && !m.HasReturns() -} - -// IsSingleReturn returns true if the method has exactly one return value (excluding error). -func (m Method) IsSingleReturn() bool { - return len(m.Returns) == 1 -} - -// IsMultiReturn returns true if the method has multiple return values (excluding error). -func (m Method) IsMultiReturn() bool { - return len(m.Returns) > 1 -} - -// IsOptionPattern returns true if the method returns (value, bool) where the bool -// indicates existence (named "exists", "ok", or "found"). This pattern is used to -// generate Option in Rust instead of a tuple. -func (m Method) IsOptionPattern() bool { - if len(m.Returns) != 2 { - return false - } - if m.Returns[1].Type != "bool" { - return false - } - // Only treat as option pattern if the first return has a meaningful value type - // (not just a bool check like Has()) - if m.Returns[0].Type == "bool" { - return false - } - name := strings.ToLower(m.Returns[1].Name) - return name == "exists" || name == "ok" || name == "found" -} - -// ReturnSignature returns the Go return type signature for the wrapper function. -// For error-only: "error" -// For single return with error: "(Type, error)" -// For single return no error: "Type" -// For multi return: "(Type1, Type2, ..., error)" -func (m Method) ReturnSignature() string { - if m.IsErrorOnly() { - return "error" - } - var parts []string - for _, r := range m.Returns { - parts = append(parts, r.Type) - } - if m.HasError { - parts = append(parts, "error") - } - // Single return without error doesn't need parentheses - if len(parts) == 1 { - return parts[0] - } - return "(" + strings.Join(parts, ", ") + ")" -} - -// ZeroValues returns the zero value expressions for all return types (excluding error). -// Used for error return statements like "return "", false, err". -func (m Method) ZeroValues() string { - var zeros []string - for _, r := range m.Returns { - zeros = append(zeros, zeroValue(r.Type)) - } - return strings.Join(zeros, ", ") -} - -// zeroValue returns the zero value for a Go type. -func zeroValue(typ string) string { - switch { - case typ == "string": - return `""` - case typ == "bool": - return "false" - case typ == "int", typ == "int8", typ == "int16", typ == "int32", typ == "int64", - typ == "uint", typ == "uint8", typ == "uint16", typ == "uint32", typ == "uint64", - typ == "float32", typ == "float64": - return "0" - case typ == "[]byte": - return "nil" - case strings.HasPrefix(typ, "[]"): - return "nil" - case strings.HasPrefix(typ, "map["): - return "nil" - case strings.HasPrefix(typ, "*"): - return "nil" - case typ == "any", typ == "interface{}": - return "nil" - default: - // For custom struct types, return empty struct - return typ + "{}" - } -} - -// Param represents a method parameter or return value. -type Param struct { - Name string // Parameter name - Type string // Go type (e.g., "string", "int32", "[]byte") - JSONName string // JSON field name (camelCase) -} - -// IsByteSlice returns true if the parameter type is []byte. -func (p Param) IsByteSlice() bool { - return p.Type == "[]byte" -} - -// IsByteSlice returns true if the field type is []byte. -func (f FieldDef) IsByteSlice() bool { - return f.Type == "[]byte" -} - -// HasByteFields returns true if any method params, returns, or struct fields use []byte. -func (s Service) HasByteFields() bool { - for _, m := range s.Methods { - for _, p := range m.Params { - if p.IsByteSlice() { - return true - } - } - for _, r := range m.Returns { - if r.IsByteSlice() { - return true - } - } - } - for _, st := range s.Structs { - for _, f := range st.Fields { - if f.IsByteSlice() { - return true - } - } - } - return false -} - -// HasByteFields returns true if any capability struct fields use []byte. -func (c Capability) HasByteFields() bool { - for _, st := range c.Structs { - for _, f := range st.Fields { - if f.IsByteSlice() { - return true - } - } - } - return false -} - -// NewParam creates a Param with auto-generated JSON name. -func NewParam(name, typ string) Param { - return Param{ - Name: name, - Type: typ, - JSONName: toJSONName(name), - } -} - -// toJSONName converts a Go identifier to camelCase JSON field name. -// This matches Rust serde's rename_all = "camelCase" behavior. -// Examples: "ConnectionID" -> "connectionId", "NewConnectionID" -> "newConnectionId" -func toJSONName(name string) string { - if name == "" { - return "" - } - - runes := []rune(name) - result := make([]rune, 0, len(runes)) - - for i, r := range runes { - if i == 0 { - // First character is always lowercase - result = append(result, unicode.ToLower(r)) - } else if unicode.IsUpper(r) { - // Check if this is part of an acronym (consecutive uppercase) - // or a word boundary - prevIsUpper := unicode.IsUpper(runes[i-1]) - nextIsLower := i+1 < len(runes) && unicode.IsLower(runes[i+1]) - - if prevIsUpper && !nextIsLower { - // Middle of an acronym - lowercase it - result = append(result, unicode.ToLower(r)) - } else if prevIsUpper && nextIsLower { - // End of acronym followed by lowercase - this starts a new word - // Keep uppercase - result = append(result, r) - } else { - // Regular word boundary - keep uppercase - result = append(result, r) - } - } else { - result = append(result, r) - } - } - - return string(result) -} - -// ToSnakeCase converts a PascalCase or camelCase string to snake_case. -// It handles consecutive uppercase letters correctly (e.g., "ScheduleID" -> "schedule_id"). -func ToSnakeCase(s string) string { - var result strings.Builder - runes := []rune(s) - for i, r := range runes { - if i > 0 && r >= 'A' && r <= 'Z' { - // Add underscore before uppercase, but not if: - // - Previous char was uppercase AND next char is uppercase or end of string - // (this handles acronyms like "ID" in "NewScheduleID") - prevUpper := runes[i-1] >= 'A' && runes[i-1] <= 'Z' - nextUpper := i+1 < len(runes) && runes[i+1] >= 'A' && runes[i+1] <= 'Z' - atEnd := i+1 == len(runes) - - // Only skip underscore if we're in the middle of an acronym - if !prevUpper || (!nextUpper && !atEnd) { - result.WriteByte('_') - } - } - result.WriteRune(r) - } - return strings.ToLower(result.String()) -} - -// ToRustType converts a Go type to its Rust equivalent. -func ToRustType(goType string) string { - return ToRustTypeWithStructs(goType, nil) -} - -// RustParamType returns the Rust type for a function parameter (uses &str for strings). -func RustParamType(goType string) string { - if goType == "string" { - return "&str" - } - return ToRustType(goType) -} - -// RustDefaultValue returns the default value for a Rust type. -func RustDefaultValue(goType string) string { - switch goType { - case "string": - return `String::new()` - case "int", "int32", "int64", "uint", "uint32", "uint64": - return "0" - case "float32", "float64": - return "0.0" - case "bool": - return "false" - default: - if strings.HasPrefix(goType, "[]") { - return "Vec::new()" - } - if strings.HasPrefix(goType, "map[") { - return "std::collections::HashMap::new()" - } - if strings.HasPrefix(goType, "*") { - return "None" - } - return "serde_json::Value::Null" - } -} - -// RustFunctionName returns the Rust function name for a method (snake_case). -// Uses just the method name without service prefix since the module provides namespacing. -func (m Method) RustFunctionName(_ string) string { - return ToSnakeCase(m.Name) -} - -// RustDocComment returns a properly formatted Rust doc comment. -// Each line of the input doc string is prefixed with "/// ". -func RustDocComment(doc string) string { - if doc == "" { - return "" - } - lines := strings.Split(doc, "\n") - var result []string - for _, line := range lines { - result = append(result, "/// "+line) - } - return strings.Join(result, "\n") -} - -// RustType returns the Rust type for this parameter. -func (p Param) RustType() string { - return ToRustType(p.Type) -} - -// RustTypeWithStructs returns the Rust type using known struct names. -func (p Param) RustTypeWithStructs(knownStructs map[string]bool) string { - return ToRustTypeWithStructs(p.Type, knownStructs) -} - -// RustTypeWithShared returns the Rust type, resolving shared-alias names to their -// canonical nd_pdk_types::X crate path (e.g. a return of []Track where -// type Track = types.Track renders as Vec). -func (p Param) RustTypeWithShared(knownStructs map[string]bool, shared map[string]string) string { - return ToRustTypeWithShared(p.Type, knownStructs, shared) -} - -// RustParamType returns the Rust type for this parameter when used as a function argument. -func (p Param) RustParamType() string { - return RustParamType(p.Type) -} - -// RustParamTypeWithStructs returns the Rust param type using known struct names. -func (p Param) RustParamTypeWithStructs(knownStructs map[string]bool) string { - if p.Type == "string" { - return "&str" - } - return ToRustTypeWithStructs(p.Type, knownStructs) -} - -// RustParamTypeWithShared returns the Rust param type, resolving shared-alias -// names to their canonical nd_pdk_types::X crate path. -func (p Param) RustParamTypeWithShared(knownStructs map[string]bool, shared map[string]string) string { - if p.Type == "string" { - return "&str" - } - return ToRustTypeWithShared(p.Type, knownStructs, shared) -} - -// RustName returns the snake_case Rust name for this parameter. -func (p Param) RustName() string { - return ToSnakeCase(p.Name) -} - -// NeedsToOwned returns true if the parameter needs .to_owned() when used. -func (p Param) NeedsToOwned() bool { - return p.Type == "string" -} - -// RustType returns the Rust type for this field, using known struct names. -func (f FieldDef) RustType(knownStructs map[string]bool) string { - return ToRustTypeWithStructs(f.Type, knownStructs) -} - -// RustName returns the snake_case Rust name for this field. -func (f FieldDef) RustName() string { - return ToSnakeCase(f.Name) -} - -// NeedsDefault returns true if the field needs #[serde(default)] attribute. -// This is true for fields with omitempty tag. -func (f FieldDef) NeedsDefault() bool { - return f.OmitEmpty -} - -// ToRustTypeWithStructs converts a Go type to its Rust equivalent, -// using known struct names instead of serde_json::Value. -func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string { - return toRustType(goType, knownStructs, nil) -} - -// ToRustTypeWithShared resolves shared-alias names to their canonical nd_pdk_types::X path. -func ToRustTypeWithShared(goType string, knownStructs map[string]bool, shared map[string]string) string { - return toRustType(goType, knownStructs, shared) -} - -func toRustType(goType string, knownStructs map[string]bool, shared map[string]string) string { - // Handle pointer types - if strings.HasPrefix(goType, "*") { - inner := toRustType(goType[1:], knownStructs, shared) - return "Option<" + inner + ">" - } - // Handle slice types - if strings.HasPrefix(goType, "[]") { - if goType == "[]byte" { - return "Vec" - } - inner := toRustType(goType[2:], knownStructs, shared) - return "Vec<" + inner + ">" - } - // Handle map types - if strings.HasPrefix(goType, "map[") { - // Extract key and value types from map[K]V - rest := goType[4:] // Remove "map[" - depth := 1 - keyEnd := 0 - for i, r := range rest { - if r == '[' { - depth++ - } else if r == ']' { - depth-- - if depth == 0 { - keyEnd = i - break - } - } - } - keyType := rest[:keyEnd] - valueType := rest[keyEnd+1:] - return "std::collections::HashMap<" + toRustType(keyType, knownStructs, shared) + ", " + toRustType(valueType, knownStructs, shared) + ">" - } - - switch goType { - case "string": - return "String" - case "int", "int32": - return "i32" - case "int64": - return "i64" - case "uint", "uint32": - return "u32" - case "uint64": - return "u64" - case "float32": - return "f32" - case "float64": - return "f64" - case "bool": - return "bool" - case "interface{}", "any": - return "serde_json::Value" - default: - // Qualified reference to the shared types crate (e.g. types.Track -> - // nd_pdk_types::Track). - if rest, ok := strings.CutPrefix(goType, sharedTypesPrefix); ok { - return "nd_pdk_types::" + rest - } - // Resolve shared-alias names to their canonical nd_pdk_types:: path. - if shared != nil { - if t, ok := shared[goType]; ok { - return t - } - } - // Check if this is a known struct type - if knownStructs != nil && knownStructs[goType] { - return goType - } - // For unknown custom types, fall back to Value - return "serde_json::Value" - } -} diff --git a/plugins/cmd/ndpgen/internal/xtp_schema.go b/plugins/cmd/ndpgen/internal/xtp_schema.go deleted file mode 100644 index 20c067ffa..000000000 --- a/plugins/cmd/ndpgen/internal/xtp_schema.go +++ /dev/null @@ -1,415 +0,0 @@ -package internal - -import ( - "sort" - "strings" - - "gopkg.in/yaml.v3" -) - -// XTP Schema types for YAML marshalling -type ( - xtpSchema struct { - Version string `yaml:"version"` - Exports yaml.Node `yaml:"exports,omitempty"` - Components *xtpComponents `yaml:"components,omitempty"` - } - - xtpComponents struct { - Schemas yaml.Node `yaml:"schemas"` - } - - xtpExport struct { - Description string `yaml:"description,omitempty"` - Input *xtpIOParam `yaml:"input,omitempty"` - Output *xtpIOParam `yaml:"output,omitempty"` - } - - xtpIOParam struct { - Ref string `yaml:"$ref,omitempty"` - Type string `yaml:"type,omitempty"` - ContentType string `yaml:"contentType"` - } - - // xtpObjectSchema represents an object schema in XTP. - // Per the XTP JSON Schema, ObjectSchema has properties, required, and description - // but NOT a type field. - xtpObjectSchema struct { - Description string `yaml:"description,omitempty"` - Properties yaml.Node `yaml:"properties"` - Required []string `yaml:"required,omitempty"` - } - - xtpEnumSchema struct { - Description string `yaml:"description,omitempty"` - Type string `yaml:"type"` - Enum []string `yaml:"enum"` - } - - xtpProperty struct { - Ref string `yaml:"$ref,omitempty"` - Type string `yaml:"type,omitempty"` - Format string `yaml:"format,omitempty"` - Description string `yaml:"description,omitempty"` - Nullable bool `yaml:"nullable,omitempty"` - Items *xtpProperty `yaml:"items,omitempty"` - } -) - -// GenerateSchema generates an XTP YAML schema from a capability. -func GenerateSchema(cap Capability) ([]byte, error) { - schema := xtpSchema{Version: "v1-draft"} - - aliasToCanonical := buildAliasToCanonical(cap) - - // Build exports as ordered map - if len(cap.Methods) > 0 { - schema.Exports = yaml.Node{Kind: yaml.MappingNode} - for _, export := range cap.Methods { - addToMap(&schema.Exports, export.ExportName, buildExport(export, aliasToCanonical)) - } - } - - // Build components/schemas - schemas := buildSchemas(cap, aliasToCanonical) - if len(schemas.Content) > 0 { - schema.Components = &xtpComponents{Schemas: schemas} - } - - return yaml.Marshal(schema) -} - -// buildAliasToCanonical maps each deprecated shared-alias name to the canonical -// shared type it targets (e.g. TrackInfo -> Track). Schema components are emitted -// under the canonical name, so every $ref site must resolve through this map. -func buildAliasToCanonical(cap Capability) map[string]string { - m := map[string]string{} - for _, a := range cap.SharedAliases { - m[a.Name] = strings.TrimPrefix(a.Target, sharedTypesPrefix) - } - return m -} - -func buildExport(export Export, aliasToCanonical map[string]string) xtpExport { - e := xtpExport{Description: cleanDocForYAML(export.Doc)} - if export.Input.Type != "" { - e.Input = &xtpIOParam{ - Ref: "#/components/schemas/" + canonicalRefName(fieldBaseType(export.Input.Type), aliasToCanonical), - ContentType: "application/json", - } - } - if export.Output.Type != "" { - outputType := strings.TrimPrefix(export.Output.Type, "*") - // Check if output is a primitive type - if isPrimitiveGoType(outputType) { - e.Output = &xtpIOParam{ - Type: goTypeToXTPType(outputType), - ContentType: "application/json", - } - } else { - e.Output = &xtpIOParam{ - Ref: "#/components/schemas/" + canonicalRefName(fieldBaseType(outputType), aliasToCanonical), - ContentType: "application/json", - } - } - } - return e -} - -// isPrimitiveGoType returns true if the Go type is a primitive type. -func isPrimitiveGoType(goType string) bool { - switch goType { - case "bool", "string", "int", "int32", "int64", "uint", "uint32", "uint64", "float32", "float64", "[]byte": - return true - } - return false -} - -func buildSchemas(cap Capability, aliasToCanonical map[string]string) yaml.Node { - schemas := yaml.Node{Kind: yaml.MappingNode} - knownTypes := cap.KnownStructs() - for _, alias := range cap.TypeAliases { - knownTypes[alias.Name] = true - } - - // Register shared types under their canonical name (e.g. types.Track -> Track) - // and stash their struct shapes for inlining. SharedTypes covers every used - // shared type, including ones referenced directly as types.X with no declared - // deprecated alias; SharedAliases is folded in for completeness. - sharedDefs := map[string]StructDef{} - for _, def := range cap.SharedTypes { - knownTypes[def.Name] = true - sharedDefs[def.Name] = def - } - for _, a := range cap.SharedAliases { - canonical := strings.TrimPrefix(a.Target, sharedTypesPrefix) - knownTypes[canonical] = true - sharedDefs[canonical] = a.Def - } - - // Collect types that are actually used by exports - usedTypes := collectUsedTypes(cap, knownTypes, sharedDefs) - - // A used alias name (e.g. TrackInfo) implies its canonical component (Track) is - // used, since the alias-typed field's $ref resolves to the canonical name. - for alias, canonical := range aliasToCanonical { - if usedTypes[alias] { - usedTypes[canonical] = true - } - } - - // Sort structs by name for consistent output - structNames := make([]string, 0, len(cap.Structs)) - structMap := make(map[string]StructDef) - for _, st := range cap.Structs { - if usedTypes[st.Name] { - structNames = append(structNames, st.Name) - structMap[st.Name] = st - } - } - sort.Strings(structNames) - - for _, name := range structNames { - st := structMap[name] - addToMap(&schemas, name, buildObjectSchema(st, knownTypes, aliasToCanonical)) - } - - // Emit components for used shared aliases (sorted for deterministic output). - sharedNames := make([]string, 0, len(sharedDefs)) - for name, def := range sharedDefs { - if usedTypes[name] && len(def.Fields) > 0 { - sharedNames = append(sharedNames, name) - } - } - sort.Strings(sharedNames) - for _, name := range sharedNames { - addToMap(&schemas, name, buildObjectSchema(sharedDefs[name], knownTypes, aliasToCanonical)) - } - - // Build enum types from type aliases (only if used by exports) - for _, alias := range cap.TypeAliases { - if !usedTypes[alias.Name] { - continue - } - if alias.Type == "string" { - for _, cg := range cap.Consts { - if cg.Type == alias.Name { - addToMap(&schemas, alias.Name, buildEnumSchema(alias, cg)) - break - } - } - } - } - - return schemas -} - -// collectUsedTypes returns a set of type names that are reachable from exports. -func collectUsedTypes(cap Capability, knownTypes map[string]bool, sharedDefs map[string]StructDef) map[string]bool { - used := make(map[string]bool) - - // Start with types directly referenced by exports - for _, export := range cap.Methods { - if export.Input.Type != "" { - addTypeAndDeps(strings.TrimPrefix(export.Input.Type, "*"), cap, knownTypes, sharedDefs, used) - } - if export.Output.Type != "" { - outputType := strings.TrimPrefix(export.Output.Type, "*") - if !isPrimitiveGoType(outputType) { - addTypeAndDeps(outputType, cap, knownTypes, sharedDefs, used) - } - } - } - - return used -} - -// addTypeAndDeps adds a type and all its dependencies to the used set. -func addTypeAndDeps(typeName string, cap Capability, knownTypes map[string]bool, sharedDefs map[string]StructDef, used map[string]bool) { - typeName = strings.TrimPrefix(typeName, sharedTypesPrefix) - if used[typeName] || !knownTypes[typeName] { - return - } - used[typeName] = true - - // Walk fields of capability-local structs. - for _, st := range cap.Structs { - if st.Name == typeName { - for _, field := range st.Fields { - if base := fieldBaseType(field.Type); knownTypes[base] { - addTypeAndDeps(base, cap, knownTypes, sharedDefs, used) - } - } - return - } - } - - // Walk fields of shared structs so their nested refs are also marked used. - if def, ok := sharedDefs[typeName]; ok { - for _, field := range def.Fields { - if base := fieldBaseType(field.Type); knownTypes[base] { - addTypeAndDeps(base, cap, knownTypes, sharedDefs, used) - } - } - } -} - -// fieldBaseType reduces a field type to the base named type used for schema -// lookups: it strips a leading pointer/slice and any shared `types.` selector. -func fieldBaseType(goType string) string { - goType = strings.TrimPrefix(goType, "*") - goType = strings.TrimPrefix(goType, "[]") - return strings.TrimPrefix(goType, sharedTypesPrefix) -} - -// canonicalRefName resolves a deprecated shared-alias name to the canonical type -// the schema component is emitted under (e.g. TrackInfo -> Track). Non-alias -// names pass through unchanged, so $ref targets always point at an emitted -// component instead of a dangling alias name. -func canonicalRefName(name string, aliasToCanonical map[string]string) string { - if canonical, ok := aliasToCanonical[name]; ok { - return canonical - } - return name -} - -func buildObjectSchema(st StructDef, knownTypes map[string]bool, aliasToCanonical map[string]string) xtpObjectSchema { - schema := xtpObjectSchema{ - Description: cleanDocForYAML(st.Doc), - Properties: yaml.Node{Kind: yaml.MappingNode}, - } - - for _, field := range st.Fields { - propName := getJSONFieldName(field) - addToMap(&schema.Properties, propName, buildProperty(field, knownTypes, aliasToCanonical)) - - if !strings.HasPrefix(field.Type, "*") && !field.OmitEmpty { - schema.Required = append(schema.Required, propName) - } - } - - return schema -} - -func buildEnumSchema(alias TypeAlias, cg ConstGroup) xtpEnumSchema { - values := make([]string, 0, len(cg.Values)) - for _, cv := range cg.Values { - values = append(values, strings.Trim(cv.Value, `"`)) - } - return xtpEnumSchema{ - Description: cleanDocForYAML(alias.Doc), - Type: "string", - Enum: values, - } -} - -func buildProperty(field FieldDef, knownTypes map[string]bool, aliasToCanonical map[string]string) xtpProperty { - goType := field.Type - isPointer := strings.HasPrefix(goType, "*") - if isPointer { - goType = goType[1:] - } - - prop := xtpProperty{ - Description: cleanDocForYAML(field.Doc), - Nullable: isPointer, - } - - // Handle reference types (use $ref instead of type). Qualified shared - // references (types.X) are referenced by their canonical name. - if refType := strings.TrimPrefix(goType, sharedTypesPrefix); isKnownType(refType, knownTypes) && !strings.HasPrefix(goType, "[]") { - prop.Ref = "#/components/schemas/" + canonicalRefName(refType, aliasToCanonical) - return prop - } - - // Handle primitive types (including []byte which maps to string/byte, not array) - if isPrimitiveGoType(goType) { - prop.Type, prop.Format = goTypeToXTPTypeAndFormat(goType) - return prop - } - - // Handle slice types - if strings.HasPrefix(goType, "[]") { - elemType := strings.TrimPrefix(goType[2:], sharedTypesPrefix) - prop.Type = "array" - prop.Items = &xtpProperty{} - if isKnownType(elemType, knownTypes) { - prop.Items.Ref = "#/components/schemas/" + canonicalRefName(elemType, aliasToCanonical) - } else { - prop.Items.Type = goTypeToXTPType(elemType) - } - return prop - } - - // Handle remaining types - prop.Type, prop.Format = goTypeToXTPTypeAndFormat(goType) - return prop -} - -// addToMap adds a key-value pair to a yaml.Node map, preserving insertion order. -func addToMap[T any](node *yaml.Node, key string, value T) { - var valNode yaml.Node - _ = valNode.Encode(value) - node.Content = append(node.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: key}, &valNode) -} - -func getJSONFieldName(field FieldDef) string { - propName := field.JSONTag - if idx := strings.Index(propName, ","); idx >= 0 { - propName = propName[:idx] - } - if propName == "" { - propName = field.Name - } - return propName -} - -// isKnownType checks if a type is a known struct or type alias. -func isKnownType(typeName string, knownTypes map[string]bool) bool { - return knownTypes[typeName] -} - -// goTypeToXTPType converts a Go type to an XTP schema type. -func goTypeToXTPType(goType string) string { - typ, _ := goTypeToXTPTypeAndFormat(goType) - return typ -} - -// goTypeToXTPTypeAndFormat converts a Go type to XTP type and format. -func goTypeToXTPTypeAndFormat(goType string) (typ, format string) { - switch goType { - case "string": - return "string", "" - case "int", "int32": - return "integer", "int32" - case "int64": - return "integer", "int64" - case "uint", "uint32": - // XTP schema doesn't support unsigned formats; use int64 to hold full uint32 range - return "integer", "int64" - case "uint64": - // XTP schema doesn't support unsigned formats; use int64 (may lose precision for large values) - return "integer", "int64" - case "float32": - return "number", "float" - case "float64": - return "number", "float" - case "bool": - return "boolean", "" - case "[]byte": - return "string", "byte" - default: - return "object", "" - } -} - -// cleanDocForYAML cleans documentation for YAML output. -func cleanDocForYAML(doc string) string { - doc = strings.TrimSpace(doc) - // Remove leading "// " from each line if present - lines := strings.Split(doc, "\n") - for i, line := range lines { - lines[i] = strings.TrimPrefix(strings.TrimSpace(line), "// ") - } - return strings.TrimSpace(strings.Join(lines, "\n")) -} diff --git a/plugins/cmd/ndpgen/internal/xtp_schema.json b/plugins/cmd/ndpgen/internal/xtp_schema.json deleted file mode 100644 index 8b3fbe0b9..000000000 --- a/plugins/cmd/ndpgen/internal/xtp_schema.json +++ /dev/null @@ -1,549 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "type": "object", - "properties": { - "version": { - "$ref": "#/$defs/XtpVersion" - } - }, - "required": [ - "version" - ], - "allOf": [ - { - "if": { - "properties": { - "version": { - "const": "v0" - } - } - }, - "then": { - "properties": { - "exports": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[a-zA-Z_$][a-zA-Z0-9_$]*$" - } - }, - "version": { - "const": "v0" - } - }, - "required": [ - "exports" - ], - "additionalProperties": false - } - }, - { - "if": { - "properties": { - "version": { - "const": "v1-draft" - } - } - }, - "then": { - "properties": { - "version": { - "$ref": "#/$defs/XtpVersion" - }, - "exports": { - "type": "object", - "patternProperties": { - "^[a-zA-Z_$][a-zA-Z0-9_$]*$": { - "$ref": "#/$defs/Export" - } - }, - "additionalProperties": false - }, - "imports": { - "type": "object", - "patternProperties": { - "^[a-zA-Z_$][a-zA-Z0-9_$]*$": { - "$ref": "#/$defs/Import" - } - }, - "additionalProperties": false - }, - "components": { - "type": "object", - "properties": { - "schemas": { - "type": "object", - "patternProperties": { - "^[a-zA-Z_$][a-zA-Z0-9_$]*$": { - "$ref": "#/$defs/Schema" - } - }, - "additionalProperties": false - } - }, - "required": [ - "schemas" - ], - "additionalProperties": false - } - }, - "required": [ - "exports" - ], - "additionalProperties": false - } - } - ], - "$defs": { - "XtpVersion": { - "type": "string", - "enum": [ - "v0", - "v1-draft" - ] - }, - "Export": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "codeSamples": { - "type": "array", - "items": { - "$ref": "#/$defs/CodeSample" - } - }, - "input": { - "$ref": "#/$defs/Parameter" - }, - "output": { - "$ref": "#/$defs/Parameter" - } - }, - "additionalProperties": false - }, - "CodeSample": { - "type": "object", - "properties": { - "lang": { - "anyOf": [ - { - "type": "string", - "enum": [ - "typescript", - "csharp", - "zig", - "rust", - "go", - "python", - "c++" - ] - }, - { - "type": "string" - } - ] - }, - "source": { - "type": "string" - }, - "label": { - "type": "string" - } - }, - "required": [ - "lang", - "source" - ], - "additionalProperties": false - }, - "Import": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "input": { - "$ref": "#/$defs/Parameter" - }, - "output": { - "$ref": "#/$defs/Parameter" - } - }, - "additionalProperties": false - }, - "Schema": { - "oneOf": [ - { - "$ref": "#/$defs/ObjectSchema" - }, - { - "$ref": "#/$defs/EnumSchema" - } - ] - }, - "ObjectSchema": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "properties": { - "type": "object", - "patternProperties": { - "^[a-zA-Z_$][a-zA-Z0-9_$]*$": { - "$ref": "#/$defs/Property" - } - }, - "additionalProperties": false - }, - "required": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "properties" - ], - "additionalProperties": false - }, - "EnumSchema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "string" - ] - }, - "description": { - "type": "string" - }, - "enum": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[a-zA-Z_$][a-zA-Z0-9_$]*$" - } - } - }, - "required": [ - "enum" - ], - "additionalProperties": false - }, - "Parameter": { - "oneOf": [ - { - "$ref": "#/$defs/ValueParameter" - }, - { - "$ref": "#/$defs/RefParameter" - }, - { - "$ref": "#/$defs/MapParameter" - } - ] - }, - "RefParameter": { - "type": "object", - "properties": { - "$ref": { - "$ref": "#/$defs/SchemaReference" - }, - "description": { - "type": "string" - }, - "nullable": { - "type": "boolean", - "default": false - }, - "contentType": { - "$ref": "#/$defs/ContentType" - } - }, - "required": [ - "$ref", - "contentType" - ], - "additionalProperties": false - }, - "ValueParameter": { - "type": "object", - "properties": { - "contentType": { - "$ref": "#/$defs/ContentType" - }, - "type": { - "$ref": "#/$defs/XtpType" - }, - "format": { - "$ref": "#/$defs/XtpFormat" - }, - "nullable": { - "type": "boolean", - "default": false - }, - "description": { - "type": "string" - }, - "items": { - "type": "object", - "$ref": "#/$defs/ArrayItem" - } - }, - "required": [ - "type", - "contentType" - ], - "additionalProperties": false - }, - "MapParameter": { - "type": "object", - "properties": { - "type": { - "const": "object" - }, - "description": { - "type": "string" - }, - "additionalProperties": { - "allOf": [ - { - "$ref": "#/$defs/NonMapProperty" - }, - { - "type": "object", - "properties": { - "description": false - }, - "additionalProperties": false - } - ] - }, - "nullable": { - "type": "boolean", - "default": false - }, - "contentType": { - "$ref": "#/$defs/ContentType" - } - }, - "required": [ - "additionalProperties", - "contentType" - ] - }, - "NonMapProperty": { - "oneOf": [ - { - "$ref": "#/$defs/ValueProperty" - }, - { - "$ref": "#/$defs/RefProperty" - } - ] - }, - "Property": { - "oneOf": [ - { - "$ref": "#/$defs/ValueProperty" - }, - { - "$ref": "#/$defs/RefProperty" - }, - { - "$ref": "#/$defs/MapProperty" - } - ] - }, - "ValueProperty": { - "type": "object", - "properties": { - "type": { - "$ref": "#/$defs/XtpType" - }, - "format": { - "$ref": "#/$defs/XtpFormat" - }, - "nullable": { - "type": "boolean", - "default": false - }, - "description": { - "type": "string" - }, - "items": { - "type": "object", - "$ref": "#/$defs/ArrayItem" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "MapProperty": { - "type": "object", - "properties": { - "type": { - "const": "object" - }, - "description": { - "type": "string" - }, - "additionalProperties": { - "allOf": [ - { - "$ref": "#/$defs/NonMapProperty" - }, - { - "not": { - "type": "object", - "required": ["description"] - } - } - ] - }, - "nullable": { - "type": "boolean", - "default": false - } - }, - "required": [ - "additionalProperties" - ], - "additionalProperties": false - }, - "RefProperty": { - "type": "object", - "properties": { - "$ref": { - "$ref": "#/$defs/SchemaReference" - }, - "description": { - "type": "string" - }, - "nullable": { - "type": "boolean", - "default": false - } - }, - "required": [ - "$ref" - ], - "additionalProperties": false - }, - "ContentType": { - "type": "string", - "enum": [ - "application/json", - "application/x-binary", - "text/plain; charset=utf-8" - ] - }, - "SchemaReference": { - "type": "string", - "pattern": "^#/components/schemas/[^/]+$" - }, - "XtpType": { - "type": "string", - "enum": [ - "integer", - "string", - "number", - "boolean", - "object", - "array", - "buffer" - ] - }, - "XtpFormat": { - "type": "string", - "enum": [ - "int32", - "int64", - "float", - "double", - "date-time", - "byte" - ] - }, - "ArrayItem": { - "type": "object", - "oneOf": [ - { - "$ref": "#/$defs/ValueArrayItem" - }, - { - "$ref": "#/$defs/RefArrayItem" - }, - { - "$ref": "#/$defs/MapArrayItem" - } - ] - }, - "ValueArrayItem": { - "type": "object", - "properties": { - "type": { - "$ref": "#/$defs/XtpType" - }, - "format": { - "$ref": "#/$defs/XtpFormat" - }, - "nullable": { - "type": "boolean" - } - }, - "required": [ - "type" - ], - "additionalProperties": false - }, - "RefArrayItem": { - "type": "object", - "properties": { - "$ref": { - "$ref": "#/$defs/SchemaReference" - }, - "nullable": { - "type": "boolean", - "default": false - } - }, - "required": [ - "$ref" - ], - "additionalProperties": false - }, - "MapArrayItem": { - "type": "object", - "properties": { - "type": { - "const": "object" - }, - "additionalProperties": { - "allOf": [ - { - "$ref": "#/$defs/NonMapProperty" - }, - { - "not": { - "type": "object", - "required": ["description"] - } - } - ] - } - }, - "required": [ - "additionalProperties" - ], - "additionalProperties": false - } - } -} diff --git a/plugins/cmd/ndpgen/internal/xtp_schema_test.go b/plugins/cmd/ndpgen/internal/xtp_schema_test.go deleted file mode 100644 index f8702aa4d..000000000 --- a/plugins/cmd/ndpgen/internal/xtp_schema_test.go +++ /dev/null @@ -1,870 +0,0 @@ -package internal - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "gopkg.in/yaml.v3" -) - -var _ = Describe("XTP Schema Generation", func() { - parseSchema := func(schema []byte) map[string]any { - var doc map[string]any - Expect(yaml.Unmarshal(schema, &doc)).To(Succeed()) - return doc - } - - Describe("GenerateSchema", func() { - Context("basic capability with one export", func() { - var schema []byte - - BeforeEach(func() { - capability := Capability{ - Name: "test", - Doc: "Test capability", - SourceFile: "test", - Methods: []Export{ - { - ExportName: "test_method", - Doc: "Test method does something", - Input: NewParam("input", "TestInput"), - Output: NewParam("output", "TestOutput"), - }, - }, - Structs: []StructDef{ - { - Name: "TestInput", - Doc: "Input for test", - Fields: []FieldDef{ - {Name: "Name", Type: "string", JSONTag: "name", Doc: "The name"}, - {Name: "Count", Type: "int", JSONTag: "count", Doc: "The count"}, - }, - }, - { - Name: "TestOutput", - Doc: "Output for test", - Fields: []FieldDef{ - {Name: "Result", Type: "string", JSONTag: "result", Doc: "The result"}, - }, - }, - }, - } - var err error - schema, err = GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - Expect(schema).NotTo(BeEmpty()) - }) - - It("should validate against XTP JSONSchema", func() { - Expect(ValidateXTPSchema(schema)).To(Succeed()) - }) - - It("should have correct version", func() { - doc := parseSchema(schema) - Expect(doc["version"]).To(Equal("v1-draft")) - }) - - It("should include exports with description", func() { - doc := parseSchema(schema) - exports := doc["exports"].(map[string]any) - Expect(exports).To(HaveKey("test_method")) - method := exports["test_method"].(map[string]any) - Expect(method["description"]).To(Equal("Test method does something")) - }) - - It("should include schemas for input and output types", func() { - doc := parseSchema(schema) - components := doc["components"].(map[string]any) - schemas := components["schemas"].(map[string]any) - Expect(schemas).To(HaveKey("TestInput")) - Expect(schemas).To(HaveKey("TestOutput")) - }) - - It("should define input schema with correct properties", func() { - doc := parseSchema(schema) - components := doc["components"].(map[string]any) - schemas := components["schemas"].(map[string]any) - input := schemas["TestInput"].(map[string]any) - // Per XTP spec, ObjectSchema does NOT have a type field - only properties, required, description - Expect(input).NotTo(HaveKey("type")) - props := input["properties"].(map[string]any) - Expect(props).To(HaveKey("name")) - Expect(props).To(HaveKey("count")) - }) - - It("should mark non-pointer, non-omitempty fields as required", func() { - doc := parseSchema(schema) - components := doc["components"].(map[string]any) - schemas := components["schemas"].(map[string]any) - input := schemas["TestInput"].(map[string]any) - required := input["required"].([]any) - Expect(required).To(ContainElement("name")) - Expect(required).To(ContainElement("count")) - }) - }) - - Context("capability with pointer fields (nullable)", func() { - var schema []byte - - BeforeEach(func() { - capability := Capability{ - Name: "nullable_test", - SourceFile: "nullable_test", - Methods: []Export{ - {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, - }, - Structs: []StructDef{ - { - Name: "Input", - Fields: []FieldDef{ - {Name: "Required", Type: "string", JSONTag: "required"}, - {Name: "Optional", Type: "*string", JSONTag: "optional,omitempty", OmitEmpty: true}, - }, - }, - { - Name: "Output", - Fields: []FieldDef{ - {Name: "Value", Type: "string", JSONTag: "value"}, - }, - }, - }, - } - var err error - schema, err = GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should validate against XTP JSONSchema", func() { - Expect(ValidateXTPSchema(schema)).To(Succeed()) - }) - - It("should not mark required field as nullable", func() { - doc := parseSchema(schema) - components := doc["components"].(map[string]any) - schemas := components["schemas"].(map[string]any) - input := schemas["Input"].(map[string]any) - props := input["properties"].(map[string]any) - requiredField := props["required"].(map[string]any) - Expect(requiredField).NotTo(HaveKey("nullable")) - }) - - It("should mark optional pointer field as nullable", func() { - doc := parseSchema(schema) - components := doc["components"].(map[string]any) - schemas := components["schemas"].(map[string]any) - input := schemas["Input"].(map[string]any) - props := input["properties"].(map[string]any) - optionalField := props["optional"].(map[string]any) - Expect(optionalField["nullable"]).To(BeTrue()) - }) - - It("should only include non-pointer fields in required array", func() { - doc := parseSchema(schema) - components := doc["components"].(map[string]any) - schemas := components["schemas"].(map[string]any) - input := schemas["Input"].(map[string]any) - required := input["required"].([]any) - Expect(required).To(ContainElement("required")) - Expect(required).NotTo(ContainElement("optional")) - }) - }) - - Context("capability with enum", func() { - var schema []byte - - BeforeEach(func() { - capability := Capability{ - Name: "enum_test", - SourceFile: "enum_test", - Methods: []Export{ - {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, - }, - Structs: []StructDef{ - { - Name: "Input", - Fields: []FieldDef{ - {Name: "Status", Type: "Status", JSONTag: "status"}, - }, - }, - { - Name: "Output", - Fields: []FieldDef{ - {Name: "Value", Type: "string", JSONTag: "value"}, - }, - }, - }, - TypeAliases: []TypeAlias{ - {Name: "Status", Type: "string", Doc: "Status type"}, - }, - Consts: []ConstGroup{ - { - Type: "Status", - Values: []ConstDef{ - {Name: "StatusPending", Value: `"pending"`}, - {Name: "StatusActive", Value: `"active"`}, - {Name: "StatusDone", Value: `"done"`}, - }, - }, - }, - } - var err error - schema, err = GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should validate against XTP JSONSchema", func() { - Expect(ValidateXTPSchema(schema)).To(Succeed()) - }) - - It("should define enum type with correct values", func() { - doc := parseSchema(schema) - components := doc["components"].(map[string]any) - schemas := components["schemas"].(map[string]any) - Expect(schemas).To(HaveKey("Status")) - status := schemas["Status"].(map[string]any) - Expect(status["type"]).To(Equal("string")) - enum := status["enum"].([]any) - Expect(enum).To(ConsistOf("pending", "active", "done")) - }) - - It("should use $ref for enum field in struct", func() { - doc := parseSchema(schema) - components := doc["components"].(map[string]any) - schemas := components["schemas"].(map[string]any) - input := schemas["Input"].(map[string]any) - props := input["properties"].(map[string]any) - statusRef := props["status"].(map[string]any) - Expect(statusRef["$ref"]).To(Equal("#/components/schemas/Status")) - }) - }) - - Context("capability with array types", func() { - var schema []byte - - BeforeEach(func() { - capability := Capability{ - Name: "array_test", - SourceFile: "array_test", - Methods: []Export{ - {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, - }, - Structs: []StructDef{ - { - Name: "Input", - Fields: []FieldDef{ - {Name: "Tags", Type: "[]string", JSONTag: "tags"}, - {Name: "Items", Type: "[]Item", JSONTag: "items"}, - }, - }, - { - Name: "Output", - Fields: []FieldDef{ - {Name: "Value", Type: "string", JSONTag: "value"}, - }, - }, - { - Name: "Item", - Fields: []FieldDef{ - {Name: "ID", Type: "string", JSONTag: "id"}, - }, - }, - }, - } - var err error - schema, err = GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - }) - - It("should validate against XTP JSONSchema", func() { - Expect(ValidateXTPSchema(schema)).To(Succeed()) - }) - - It("should define string array with primitive type", func() { - doc := parseSchema(schema) - components := doc["components"].(map[string]any) - schemas := components["schemas"].(map[string]any) - input := schemas["Input"].(map[string]any) - props := input["properties"].(map[string]any) - tags := props["tags"].(map[string]any) - Expect(tags["type"]).To(Equal("array")) - tagItems := tags["items"].(map[string]any) - Expect(tagItems["type"]).To(Equal("string")) - }) - - It("should define struct array with $ref", func() { - doc := parseSchema(schema) - components := doc["components"].(map[string]any) - schemas := components["schemas"].(map[string]any) - input := schemas["Input"].(map[string]any) - props := input["properties"].(map[string]any) - items := props["items"].(map[string]any) - Expect(items["type"]).To(Equal("array")) - itemItems := items["items"].(map[string]any) - Expect(itemItems["$ref"]).To(Equal("#/components/schemas/Item")) - }) - }) - - Context("capability with []byte field", func() { - It("should map []byte to string with byte format, not array", func() { - capability := Capability{ - Name: "byte_test", - SourceFile: "byte_test", - Methods: []Export{ - {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, - }, - Structs: []StructDef{ - { - Name: "Input", - Fields: []FieldDef{ - {Name: "Data", Type: "[]byte", JSONTag: "data"}, - }, - }, - { - Name: "Output", - Fields: []FieldDef{ - {Name: "Value", Type: "string", JSONTag: "value"}, - }, - }, - }, - } - schema, err := GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - Expect(ValidateXTPSchema(schema)).To(Succeed()) - - doc := parseSchema(schema) - components := doc["components"].(map[string]any) - schemas := components["schemas"].(map[string]any) - input := schemas["Input"].(map[string]any) - props := input["properties"].(map[string]any) - data := props["data"].(map[string]any) - Expect(data["type"]).To(Equal("string")) - Expect(data["format"]).To(Equal("byte")) - Expect(data).NotTo(HaveKey("items")) - }) - }) - - Context("capability with nullable ref", func() { - It("should mark pointer to enum as nullable with $ref", func() { - capability := Capability{ - Name: "nullable_ref_test", - SourceFile: "nullable_ref_test", - Methods: []Export{ - {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, - }, - Structs: []StructDef{ - { - Name: "Input", - Fields: []FieldDef{ - {Name: "Value", Type: "string", JSONTag: "value"}, - }, - }, - { - Name: "Output", - Fields: []FieldDef{ - {Name: "Status", Type: "*ErrorType", JSONTag: "status,omitempty", OmitEmpty: true}, - }, - }, - }, - TypeAliases: []TypeAlias{ - {Name: "ErrorType", Type: "string"}, - }, - Consts: []ConstGroup{ - { - Type: "ErrorType", - Values: []ConstDef{ - {Name: "ErrorNone", Value: `"none"`}, - {Name: "ErrorFatal", Value: `"fatal"`}, - }, - }, - }, - } - schema, err := GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - - // Validate against XTP JSONSchema - Expect(ValidateXTPSchema(schema)).To(Succeed()) - - doc := parseSchema(schema) - components := doc["components"].(map[string]any) - schemas := components["schemas"].(map[string]any) - output := schemas["Output"].(map[string]any) - props := output["properties"].(map[string]any) - status := props["status"].(map[string]any) - Expect(status["$ref"]).To(Equal("#/components/schemas/ErrorType")) - Expect(status["nullable"]).To(BeTrue()) - }) - }) - }) - - Describe("goTypeToXTPTypeAndFormat", func() { - DescribeTable("should convert Go types to XTP types", - func(goType, wantType, wantFormat string) { - gotType, gotFormat := goTypeToXTPTypeAndFormat(goType) - Expect(gotType).To(Equal(wantType)) - Expect(gotFormat).To(Equal(wantFormat)) - }, - Entry("string", "string", "string", ""), - Entry("int", "int", "integer", "int32"), - Entry("int32", "int32", "integer", "int32"), - Entry("int64", "int64", "integer", "int64"), - Entry("float32", "float32", "number", "float"), - Entry("float64", "float64", "number", "float"), - Entry("bool", "bool", "boolean", ""), - Entry("[]byte", "[]byte", "string", "byte"), - Entry("unknown types default to object", "CustomType", "object", ""), - ) - }) - - Describe("cleanDocForYAML", func() { - DescribeTable("should clean documentation strings", - func(doc, want string) { - Expect(cleanDocForYAML(doc)).To(Equal(want)) - }, - Entry("empty", "", ""), - Entry("single line", "Simple description", "Simple description"), - Entry("multiline", "First line\nSecond line", "First line\nSecond line"), - Entry("trailing newline", "Description\n", "Description"), - Entry("whitespace", " Description ", "Description"), - ) - }) - - Describe("isPrimitiveGoType", func() { - DescribeTable("should identify primitive Go types", - func(goType string, want bool) { - Expect(isPrimitiveGoType(goType)).To(Equal(want)) - }, - Entry("bool", "bool", true), - Entry("string", "string", true), - Entry("int", "int", true), - Entry("int32", "int32", true), - Entry("int64", "int64", true), - Entry("float32", "float32", true), - Entry("float64", "float64", true), - Entry("[]byte", "[]byte", true), - Entry("custom type", "CustomType", false), - Entry("struct type", "MyStruct", false), - Entry("slice of string", "[]string", false), - Entry("map type", "map[string]int", false), - ) - }) - - Describe("GenerateSchema with primitive output types", func() { - inputStruct := StructDef{ - Name: "Input", - Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}, - } - - Context("export with primitive string output", func() { - It("should use type instead of $ref and validate against XTP JSONSchema", func() { - capability := Capability{ - Name: "test", - SourceFile: "test", - Methods: []Export{ - {ExportName: "get_name", Input: NewParam("input", "Input"), Output: NewParam("output", "string")}, - }, - Structs: []StructDef{inputStruct}, - } - schema, err := GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - Expect(schema).NotTo(BeEmpty()) - Expect(ValidateXTPSchema(schema)).To(Succeed()) - - doc := parseSchema(schema) - exports := doc["exports"].(map[string]any) - method := exports["get_name"].(map[string]any) - output := method["output"].(map[string]any) - Expect(output["type"]).To(Equal("string")) - Expect(output).NotTo(HaveKey("$ref")) - Expect(output["contentType"]).To(Equal("application/json")) - }) - }) - - Context("export with primitive bool output", func() { - It("should use boolean type and validate against XTP JSONSchema", func() { - capability := Capability{ - Name: "test", - SourceFile: "test", - Methods: []Export{ - {ExportName: "is_valid", Input: NewParam("input", "Input"), Output: NewParam("output", "bool")}, - }, - Structs: []StructDef{inputStruct}, - } - schema, err := GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - Expect(ValidateXTPSchema(schema)).To(Succeed()) - - doc := parseSchema(schema) - exports := doc["exports"].(map[string]any) - method := exports["is_valid"].(map[string]any) - output := method["output"].(map[string]any) - Expect(output["type"]).To(Equal("boolean")) - Expect(output).NotTo(HaveKey("$ref")) - }) - }) - - Context("export with primitive int output", func() { - It("should use integer type and validate against XTP JSONSchema", func() { - capability := Capability{ - Name: "test", - SourceFile: "test", - Methods: []Export{ - {ExportName: "get_count", Input: NewParam("input", "Input"), Output: NewParam("output", "int32")}, - }, - Structs: []StructDef{inputStruct}, - } - schema, err := GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - Expect(ValidateXTPSchema(schema)).To(Succeed()) - - doc := parseSchema(schema) - exports := doc["exports"].(map[string]any) - method := exports["get_count"].(map[string]any) - output := method["output"].(map[string]any) - Expect(output["type"]).To(Equal("integer")) - Expect(output).NotTo(HaveKey("$ref")) - }) - }) - - Context("export with pointer to primitive output", func() { - It("should strip pointer and use primitive type and validate against XTP JSONSchema", func() { - capability := Capability{ - Name: "test", - SourceFile: "test", - Methods: []Export{ - {ExportName: "get_optional_string", Input: NewParam("input", "Input"), Output: NewParam("output", "*string")}, - }, - Structs: []StructDef{inputStruct}, - } - schema, err := GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - Expect(ValidateXTPSchema(schema)).To(Succeed()) - - doc := parseSchema(schema) - exports := doc["exports"].(map[string]any) - method := exports["get_optional_string"].(map[string]any) - output := method["output"].(map[string]any) - Expect(output["type"]).To(Equal("string")) - Expect(output).NotTo(HaveKey("$ref")) - }) - }) - - Context("export with struct output", func() { - It("should still use $ref and validate against XTP JSONSchema", func() { - capability := Capability{ - Name: "test", - SourceFile: "test", - Methods: []Export{ - {ExportName: "get_result", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, - }, - Structs: []StructDef{ - inputStruct, - {Name: "Output", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}}, - }, - } - schema, err := GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - Expect(ValidateXTPSchema(schema)).To(Succeed()) - - doc := parseSchema(schema) - exports := doc["exports"].(map[string]any) - method := exports["get_result"].(map[string]any) - output := method["output"].(map[string]any) - Expect(output["$ref"]).To(Equal("#/components/schemas/Output")) - Expect(output).NotTo(HaveKey("type")) - }) - }) - }) - - Describe("collectUsedTypes", func() { - getSchemas := func(schema []byte) map[string]any { - doc := parseSchema(schema) - components, hasComponents := doc["components"].(map[string]any) - if !hasComponents { - return make(map[string]any) - } - schemas, ok := components["schemas"].(map[string]any) - if !ok { - return make(map[string]any) - } - return schemas - } - - It("should only include types referenced by exports", func() { - capability := Capability{ - Name: "test", - SourceFile: "test", - Methods: []Export{ - {ExportName: "test", Input: NewParam("input", "UsedInput"), Output: NewParam("output", "UsedOutput")}, - }, - Structs: []StructDef{ - {Name: "UsedInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, - {Name: "UsedOutput", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}}, - {Name: "UnusedStruct", Fields: []FieldDef{{Name: "Foo", Type: "string", JSONTag: "foo"}}}, - }, - } - schema, err := GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - Expect(ValidateXTPSchema(schema)).To(Succeed()) - - schemas := getSchemas(schema) - Expect(schemas).To(HaveKey("UsedInput")) - Expect(schemas).To(HaveKey("UsedOutput")) - Expect(schemas).NotTo(HaveKey("UnusedStruct")) - }) - - It("should include transitively referenced types", func() { - capability := Capability{ - Name: "test", - SourceFile: "test", - Methods: []Export{ - {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, - }, - Structs: []StructDef{ - {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, - {Name: "Output", Fields: []FieldDef{{Name: "Nested", Type: "NestedType", JSONTag: "nested"}}}, - {Name: "NestedType", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}}, - }, - } - schema, err := GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - Expect(ValidateXTPSchema(schema)).To(Succeed()) - - schemas := getSchemas(schema) - Expect(schemas).To(HaveKey("Input")) - Expect(schemas).To(HaveKey("Output")) - Expect(schemas).To(HaveKey("NestedType")) - }) - - It("should include array element types", func() { - capability := Capability{ - Name: "test", - SourceFile: "test", - Methods: []Export{ - {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, - }, - Structs: []StructDef{ - {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, - {Name: "Output", Fields: []FieldDef{{Name: "Items", Type: "[]Item", JSONTag: "items"}}}, - {Name: "Item", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}}, - }, - } - schema, err := GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - Expect(ValidateXTPSchema(schema)).To(Succeed()) - - schemas := getSchemas(schema) - Expect(schemas).To(HaveKey("Input")) - Expect(schemas).To(HaveKey("Output")) - Expect(schemas).To(HaveKey("Item")) - }) - - It("should include pointer types", func() { - capability := Capability{ - Name: "test", - SourceFile: "test", - Methods: []Export{ - {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, - }, - Structs: []StructDef{ - {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, - {Name: "Output", Fields: []FieldDef{{Name: "Optional", Type: "*OptionalType", JSONTag: "optional"}}}, - {Name: "OptionalType", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}}, - }, - } - schema, err := GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - Expect(ValidateXTPSchema(schema)).To(Succeed()) - - schemas := getSchemas(schema) - Expect(schemas).To(HaveKey("Input")) - Expect(schemas).To(HaveKey("Output")) - Expect(schemas).To(HaveKey("OptionalType")) - }) - - It("should exclude primitive output types from schema", func() { - capability := Capability{ - Name: "test", - SourceFile: "test", - Methods: []Export{ - {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "string")}, - }, - Structs: []StructDef{ - {Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}}, - }, - } - schema, err := GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - Expect(ValidateXTPSchema(schema)).To(Succeed()) - - schemas := getSchemas(schema) - Expect(schemas).To(HaveKey("Input")) - }) - }) - - Describe("GenerateSchema with shared aliases", func() { - It("inlines shared-alias shapes as schema components", func() { - cap := Capability{ - Name: "scrobbler", Interface: "Scrobbler", Required: true, - Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", - Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, - Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ - {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, - SharedAliases: []SharedAlias{{ - Name: "TrackInfo", Target: "types.TrackInfo", - Def: StructDef{Name: "TrackInfo", Fields: []FieldDef{ - {Name: "Title", Type: "string", JSONTag: "title"}}}, - }}, - } - out, err := GenerateSchema(cap) - Expect(err).NotTo(HaveOccurred()) - Expect(string(out)).To(ContainSubstring("TrackInfo:")) - Expect(string(out)).To(ContainSubstring("title:")) - }) - - It("names the shared component by its canonical type for qualified types.X fields", func() { - cap := Capability{ - Name: "scrobbler", Interface: "Scrobbler", Required: true, - Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", - Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, - Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ - {Name: "Track", Type: "types.Track", JSONTag: "track"}}}}, - SharedAliases: []SharedAlias{{ - Name: "TrackInfo", Target: "types.Track", - Def: StructDef{Name: "Track", Fields: []FieldDef{ - {Name: "Title", Type: "string", JSONTag: "title"}}}, - }}, - } - out, err := GenerateSchema(cap) - Expect(err).NotTo(HaveOccurred()) - s := string(out) - // Component is named by the canonical type (Track), not the deprecated alias. - Expect(s).To(ContainSubstring("Track:")) - Expect(s).NotTo(ContainSubstring("TrackInfo:")) - // The field $ref points at the canonical component. - Expect(s).To(ContainSubstring("$ref: '#/components/schemas/Track'")) - Expect(s).To(ContainSubstring("title:")) - }) - - It("points an alias-named field at the canonical component for a renamed alias", func() { - cap := Capability{ - Name: "demo", Interface: "Demo", Required: true, - Methods: []Export{{Name: "Play", ExportName: "nd_demo_play", - Input: Param{Name: "input", Type: "PlayRequest"}}}, - Structs: []StructDef{{Name: "PlayRequest", Fields: []FieldDef{ - // Field is typed with the deprecated alias name, not the canonical types.Track. - {Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}}, - SharedAliases: []SharedAlias{{ - Name: "TrackInfo", Target: "types.Track", - Def: StructDef{Name: "Track", Fields: []FieldDef{ - {Name: "Title", Type: "string", JSONTag: "title"}}}, - }}, - } - out, err := GenerateSchema(cap) - Expect(err).NotTo(HaveOccurred()) - s := string(out) - // The component is emitted under the canonical name, and the field $ref must - // point at it — not at a non-existent TrackInfo component (dangling reference). - Expect(s).To(ContainSubstring("Track:")) - Expect(s).To(ContainSubstring("$ref: '#/components/schemas/Track'")) - Expect(s).NotTo(ContainSubstring("$ref: '#/components/schemas/TrackInfo'")) - }) - - It("points an alias-named export input/output at the canonical component", func() { - cap := Capability{ - Name: "demo", Interface: "Demo", Required: true, - // The method takes/returns the deprecated alias name directly. - Methods: []Export{{Name: "Play", ExportName: "nd_demo_play", - Input: Param{Name: "input", Type: "TrackInfo"}, - Output: Param{Name: "output", Type: "TrackInfo"}}}, - SharedAliases: []SharedAlias{{ - Name: "TrackInfo", Target: "types.Track", - Def: StructDef{Name: "Track", Fields: []FieldDef{ - {Name: "Title", Type: "string", JSONTag: "title"}}}, - }}, - } - out, err := GenerateSchema(cap) - Expect(err).NotTo(HaveOccurred()) - s := string(out) - // Export $ref must resolve to the canonical component, not a missing TrackInfo. - Expect(s).To(ContainSubstring("$ref: '#/components/schemas/Track'")) - Expect(s).NotTo(ContainSubstring("$ref: '#/components/schemas/TrackInfo'")) - }) - - It("inlines a directly-referenced shared type that has no deprecated alias", func() { - cap := Capability{ - Name: "scrobbler", Interface: "Scrobbler", Required: true, - Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing", - Input: Param{Name: "input", Type: "NowPlayingRequest"}}}, - Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{ - {Name: "Song", Type: "types.SongRef", JSONTag: "song"}}}}, - // No SharedAliases: the field references the canonical type directly. - SharedTypes: []StructDef{{Name: "SongRef", Fields: []FieldDef{ - {Name: "Name", Type: "string", JSONTag: "name"}}}}, - } - out, err := GenerateSchema(cap) - Expect(err).NotTo(HaveOccurred()) - s := string(out) - Expect(s).To(ContainSubstring("SongRef:")) - Expect(s).To(ContainSubstring("$ref: '#/components/schemas/SongRef'")) - Expect(s).To(ContainSubstring("name:")) - }) - }) - - Describe("GenerateSchema enum filtering", func() { - It("should only include enums that are actually used by exports", func() { - capability := Capability{ - Name: "test", - SourceFile: "test", - Methods: []Export{ - {ExportName: "test", Input: NewParam("input", "Input"), Output: NewParam("output", "Output")}, - }, - Structs: []StructDef{ - { - Name: "Input", - Fields: []FieldDef{{Name: "Status", Type: "UsedStatus", JSONTag: "status"}}, - }, - { - Name: "Output", - Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}, - }, - }, - TypeAliases: []TypeAlias{ - {Name: "UsedStatus", Type: "string"}, - {Name: "UnusedStatus", Type: "string"}, - }, - Consts: []ConstGroup{ - { - Type: "UsedStatus", - Values: []ConstDef{ - {Name: "StatusActive", Value: `"active"`}, - {Name: "StatusInactive", Value: `"inactive"`}, - }, - }, - { - Type: "UnusedStatus", - Values: []ConstDef{ - {Name: "UnusedPending", Value: `"pending"`}, - }, - }, - }, - } - - schema, err := GenerateSchema(capability) - Expect(err).NotTo(HaveOccurred()) - Expect(ValidateXTPSchema(schema)).To(Succeed()) - - doc := parseSchema(schema) - components := doc["components"].(map[string]any) - schemas := components["schemas"].(map[string]any) - - // UsedStatus should be included because it's referenced by Input - Expect(schemas).To(HaveKey("UsedStatus")) - usedStatus := schemas["UsedStatus"].(map[string]any) - Expect(usedStatus["type"]).To(Equal("string")) - enum := usedStatus["enum"].([]any) - Expect(enum).To(ConsistOf("active", "inactive")) - - // UnusedStatus should NOT be included - Expect(schemas).NotTo(HaveKey("UnusedStatus")) - }) - }) -}) diff --git a/plugins/cmd/ndpgen/internal/xtp_schema_validate.go b/plugins/cmd/ndpgen/internal/xtp_schema_validate.go deleted file mode 100644 index 94404fb79..000000000 --- a/plugins/cmd/ndpgen/internal/xtp_schema_validate.go +++ /dev/null @@ -1,86 +0,0 @@ -package internal - -import ( - _ "embed" - "encoding/json" - "errors" - "fmt" - "strings" - - "github.com/santhosh-tekuri/jsonschema/v6" - "gopkg.in/yaml.v3" -) - -// XTP JSONSchema specification, from -// https://raw.githubusercontent.com/dylibso/xtp-bindgen/5090518dd86ba5e734dc225a33066ecc0ed2e12d/plugin/schema.json -// -//go:embed xtp_schema.json -var xtpSchemaJSON string - -// ValidateXTPSchema validates that the generated schema conforms to the XTP JSONSchema specification. -// Returns nil if valid, or an error with validation details if invalid. -func ValidateXTPSchema(generatedSchema []byte) error { - // Parse the YAML schema to JSON for validation - var schemaDoc map[string]any - if err := yaml.Unmarshal(generatedSchema, &schemaDoc); err != nil { - return fmt.Errorf("failed to parse generated schema as YAML: %w", err) - } - - // Parse the XTP schema JSON - var xtpSchema any - if err := json.Unmarshal([]byte(xtpSchemaJSON), &xtpSchema); err != nil { - return fmt.Errorf("failed to parse XTP schema: %w", err) - } - - // Compile the XTP schema - compiler := jsonschema.NewCompiler() - if err := compiler.AddResource("xtp-schema.json", xtpSchema); err != nil { - return fmt.Errorf("failed to add XTP schema resource: %w", err) - } - - schema, err := compiler.Compile("xtp-schema.json") - if err != nil { - return fmt.Errorf("failed to compile XTP schema: %w", err) - } - - // Validate the generated schema against XTP schema - if err := schema.Validate(schemaDoc); err != nil { - return fmt.Errorf("schema validation errors:\n%s", formatValidationErrors(err)) - } - - return nil -} - -// formatValidationErrors formats jsonschema validation errors into readable strings. -func formatValidationErrors(err error) string { - var validationErr *jsonschema.ValidationError - if !errors.As(err, &validationErr) { - return fmt.Sprintf("- %s", err.Error()) - } - - var errs []string - collectValidationErrors(validationErr, &errs) - - if len(errs) == 0 { - return fmt.Sprintf("- %s", validationErr.Error()) - } - return strings.Join(errs, "\n") -} - -// collectValidationErrors recursively collects leaf validation errors. -func collectValidationErrors(err *jsonschema.ValidationError, errs *[]string) { - if len(err.Causes) > 0 { - for _, cause := range err.Causes { - collectValidationErrors(cause, errs) - } - return - } - - // Leaf error - format with location if available - msg := err.Error() - if len(err.InstanceLocation) > 0 { - location := strings.Join(err.InstanceLocation, "/") - msg = fmt.Sprintf("%s: %s", location, msg) - } - *errs = append(*errs, fmt.Sprintf("- %s", msg)) -} diff --git a/plugins/cmd/ndpgen/main.go b/plugins/cmd/ndpgen/main.go deleted file mode 100644 index 6520a3ecd..000000000 --- a/plugins/cmd/ndpgen/main.go +++ /dev/null @@ -1,1010 +0,0 @@ -// ndpgen generates Navidrome Plugin Development Kit (PDK) code from annotated Go interfaces. -// -// This is the unified code generator that handles both host function wrappers -// and capability export wrappers. -// -// Usage: -// -// # Generate host wrappers for Navidrome server (output to input directory) -// ndpgen -host-wrappers -input=./plugins/host -package=host -// -// # Generate PDK client wrappers (from plugins/host to plugins/pdk) -// ndpgen -host-only -input=./plugins/host -output=./plugins/pdk -// -// # Generate capability wrappers (from plugins/capabilities to plugins/pdk) -// ndpgen -capability-only -input=./plugins/capabilities -output=./plugins/pdk -// -// # Generate XTP schemas from capabilities (output to input directory) -// ndpgen -schemas -input=./plugins/capabilities -// -// Output directories: -// - Host wrappers: $input/_gen.go (server-side, used by Navidrome) -// - Host functions: $output/go/host/, $output/rust/host/ -// - Capabilities: $output/go// (e.g., $output/go/metadata/) -// - Schemas: $input/.yaml (co-located with Go sources) -// -// Flags: -// -// -input Input directory containing Go source files with annotated interfaces -// -output Output directory base for generated files (default: same as input) -// -package Output package name for Go (default: host for host-only, auto for capabilities) -// -host-wrappers Generate server-side host wrappers (used by Navidrome, output to input directory) -// -host-only Generate PDK client wrappers for calling host functions -// -capability-only Generate only capability export wrappers -// -schemas Generate XTP YAML schemas from capabilities -// -go Generate Go client wrappers (default: true when not using -rust) -// -rust Generate Rust client wrappers (default: false) -// -v Verbose output -// -dry-run Preview generated code without writing files -package main - -import ( - "flag" - "fmt" - "go/format" - "maps" - "os" - "path/filepath" - "slices" - "strings" - - "github.com/navidrome/navidrome/plugins/cmd/ndpgen/internal" -) - -// config holds the parsed command-line configuration. -type config struct { - inputDir string - outputDir string // Base output directory (e.g., plugins/pdk) - goOutputDir string // Go output: $outputDir/go/host (for host-only) - rustOutputDir string // Rust output: $outputDir/rust/host - pkgName string - hostOnly bool - hostWrappers bool // Generate host wrappers (used by Navidrome server) - capabilityOnly bool - schemasOnly bool // Generate XTP schemas from capabilities (output goes to inputDir) - pdkOnly bool // Generate PDK abstraction layer wrapper - sharedTypes bool // Generate the shared types package - sharedDir string // Directory of shared types to load as a registry - generateGoClient bool - generateRsClient bool - verbose bool - dryRun bool -} - -func main() { - cfg, err := parseConfig() - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - - if cfg.schemasOnly { - if err := runSchemaGeneration(cfg); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - return - } - - if cfg.sharedTypes { - if err := runSharedTypesGeneration(cfg); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - return - } - - if cfg.pdkOnly { - if err := runPDKGeneration(cfg); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - return - } - - if cfg.capabilityOnly { - if err := runCapabilityGeneration(cfg); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - return - } - - if cfg.hostWrappers { - if err := runHostWrapperGeneration(cfg); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - return - } - - // Default: host-only mode - services, err := parseServices(cfg) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - if len(services) == 0 { - return - } - - if err := generateAllCode(cfg, services); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} - -// runCapabilityGeneration handles capability-only code generation. -func runCapabilityGeneration(cfg *config) error { - capabilities, err := parseCapabilities(cfg) - if err != nil { - return err - } - if len(capabilities) == 0 { - if cfg.verbose { - fmt.Println("No capabilities found") - } - return nil - } - - return generateCapabilityCode(cfg, capabilities) -} - -// runSchemaGeneration handles XTP schema generation from capabilities. -func runSchemaGeneration(cfg *config) error { - capabilities, err := parseCapabilities(cfg) - if err != nil { - return err - } - if len(capabilities) == 0 { - if cfg.verbose { - fmt.Println("No capabilities found") - } - return nil - } - - return generateSchemas(cfg, capabilities) -} - -// writeGenerated creates dir (if needed) and writes content to name inside it. -// In dry-run mode it prints the content instead. -func writeGenerated(dir, name string, content []byte, dryRun, verbose bool) error { - path := filepath.Join(dir, name) - if dryRun { - fmt.Printf("=== %s ===\n%s\n", path, content) - return nil - } - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("creating output directory %s: %w", dir, err) - } - if err := os.WriteFile(path, content, 0600); err != nil { - return fmt.Errorf("writing %s: %w", path, err) - } - if verbose { - fmt.Printf("Generated: %s\n", path) - } - return nil -} - -// runSharedTypesGeneration handles shared types package generation. -func runSharedTypesGeneration(cfg *config) error { - structs, err := internal.LoadSharedTypes(cfg.inputDir) - if err != nil { - return err - } - if len(structs) == 0 { - return nil - } - list := slices.Collect(maps.Values(structs)) - if cfg.generateGoClient { - code, err := internal.GenerateSharedTypesGo(list, "types") - if err != nil { - return fmt.Errorf("generating Go types: %w", err) - } - formatted, err := format.Source(code) - if err != nil { - return fmt.Errorf("formatting Go types: %w\n%s", err, code) - } - dir := filepath.Join(cfg.outputDir, "go", "types") - if err := writeGenerated(dir, "types.go", formatted, cfg.dryRun, cfg.verbose); err != nil { - return err - } - } - if cfg.generateRsClient { - if err := generateSharedTypesRust(list, cfg); err != nil { - return err - } - } - return nil -} - -// generateSharedTypesRust writes the nd-pdk-types crate root to -// /rust/nd-pdk-types/src/lib.rs. -func generateSharedTypesRust(structs []internal.StructDef, cfg *config) error { - code, err := internal.GenerateSharedTypesRust(structs) - if err != nil { - return fmt.Errorf("generating Rust types: %w", err) - } - dir := filepath.Join(cfg.outputDir, "rust", "nd-pdk-types", "src") - return writeGenerated(dir, "lib.rs", code, cfg.dryRun, cfg.verbose) -} - -// runPDKGeneration handles PDK abstraction layer code generation. -// This generates the pdk wrapper package that wraps extism/go-pdk -// with mockable implementations for unit testing on native platforms. -func runPDKGeneration(cfg *config) error { - // Output directory is $output/go/pdk/ - outputDir := filepath.Join(cfg.outputDir, "go", "pdk") - return generatePDKPackageWithParsing(outputDir, cfg.dryRun, cfg.verbose) -} - -// generatePDKPackageWithParsing generates the PDK abstraction layer using AST parsing. -// It extracts all exported symbols from extism/go-pdk and generates wrappers for them. -func generatePDKPackageWithParsing(outputDir string, dryRun, verbose bool) error { - if verbose { - fmt.Println("Parsing extism/go-pdk to extract exported symbols...") - } - - // Parse extism/go-pdk to get all exported symbols - symbols, err := internal.ParseExtismPDK() - if err != nil { - return fmt.Errorf("parsing extism/go-pdk: %w", err) - } - - if verbose { - fmt.Printf("Found %d types, %d constants, %d functions\n", - len(symbols.Types), len(symbols.Consts), len(symbols.Functions)) - for _, t := range symbols.Types { - fmt.Printf(" Type %s: %d methods, %d fields\n", t.Name, len(t.Methods), len(t.Fields)) - for _, m := range t.Methods { - fmt.Printf(" Method: %s (receiver: %s)\n", m.Name, m.Receiver) - } - } - fmt.Printf("Generating PDK abstraction layer to: %s\n", outputDir) - } - - // Generate the WASM implementation (pdk.go) - pdkCode, err := internal.GeneratePDKGo(symbols) - if err != nil { - return fmt.Errorf("generating pdk.go: %w", err) - } - - formatted, err := format.Source(pdkCode) - if err != nil { - return fmt.Errorf("formatting pdk.go: %w\nRaw code:\n%s", err, pdkCode) - } - - pdkFile := filepath.Join(outputDir, "pdk.go") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", pdkFile, formatted) - } else { - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("creating output directory: %w", err) - } - - if err := os.WriteFile(pdkFile, formatted, 0600); err != nil { - return fmt.Errorf("writing pdk.go: %w", err) - } - - if verbose { - fmt.Printf("Generated: %s\n", pdkFile) - } - } - - // Generate the types stub (types_stub.go) - typesStubCode, err := internal.GeneratePDKTypesStub(symbols) - if err != nil { - return fmt.Errorf("generating types_stub.go: %w", err) - } - - formattedTypesStub, err := format.Source(typesStubCode) - if err != nil { - return fmt.Errorf("formatting types_stub.go: %w\nRaw code:\n%s", err, typesStubCode) - } - - typesStubFile := filepath.Join(outputDir, "types_stub.go") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", typesStubFile, formattedTypesStub) - } else { - if err := os.WriteFile(typesStubFile, formattedTypesStub, 0600); err != nil { - return fmt.Errorf("writing types_stub.go: %w", err) - } - - if verbose { - fmt.Printf("Generated: %s\n", typesStubFile) - } - } - - // Generate the stub implementation (pdk_stub.go) - stubCode, err := internal.GeneratePDKGoStub(symbols) - if err != nil { - return fmt.Errorf("generating pdk_stub.go: %w", err) - } - - formattedStub, err := format.Source(stubCode) - if err != nil { - return fmt.Errorf("formatting pdk_stub.go: %w\nRaw code:\n%s", err, stubCode) - } - - stubFile := filepath.Join(outputDir, "pdk_stub.go") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", stubFile, formattedStub) - } else { - if err := os.WriteFile(stubFile, formattedStub, 0600); err != nil { - return fmt.Errorf("writing pdk_stub.go: %w", err) - } - - if verbose { - fmt.Printf("Generated: %s\n", stubFile) - } - } - - return nil -} - -// generatePDKPackage generates the PDK abstraction layer to a specific directory. -// This is called by generateAllCode to include the PDK package alongside host client code. -func generatePDKPackage(outputDir string, dryRun, verbose bool) error { - return generatePDKPackageWithParsing(outputDir, dryRun, verbose) -} - -// runHostWrapperGeneration handles host wrapper code generation. -// This generates the *_gen.go files in the input directory that are used -// by Navidrome server to expose host functions to plugins. -func runHostWrapperGeneration(cfg *config) error { - services, err := parseServices(cfg) - if err != nil { - return err - } - if len(services) == 0 { - if cfg.verbose { - fmt.Println("No host services found") - } - return nil - } - - // Generate host wrappers for each service - for _, svc := range services { - if err := generateHostWrapperCode(svc, cfg.inputDir, cfg.pkgName, cfg.dryRun, cfg.verbose); err != nil { - return fmt.Errorf("generating host wrapper for %s: %w", svc.Name, err) - } - } - - return nil -} - -// parseConfig parses command-line flags and returns the configuration. -func parseConfig() (*config, error) { - var ( - inputDir = flag.String("input", ".", "Input directory containing Go source files") - outputDir = flag.String("output", "", "Base output directory for generated files (default: same as input)") - pkgName = flag.String("package", "", "Output package name for Go (default: host for host-only, auto for capabilities)") - hostOnly = flag.Bool("host-only", false, "Generate only host function wrappers") - hostWrappers = flag.Bool("host-wrappers", false, "Generate host wrappers (used by Navidrome server, output to input directory)") - capabilityOnly = flag.Bool("capability-only", false, "Generate only capability export wrappers") - schemasOnly = flag.Bool("schemas", false, "Generate XTP YAML schemas from capabilities (output to input directory)") - pdkOnly = flag.Bool("extism-pdk", false, "Generate PDK abstraction layer by parsing extism/go-pdk") - sharedTypes = flag.Bool("shared-types", false, "Generate the shared types package") - shared = flag.String("shared", "", "Directory of shared types to load as a registry") - goClient = flag.Bool("go", false, "Generate Go client wrappers") - rsClient = flag.Bool("rust", false, "Generate Rust client wrappers") - verbose = flag.Bool("v", false, "Verbose output") - dryRun = flag.Bool("dry-run", false, "Preview generated code without writing files") - ) - flag.Parse() - - // Count how many mode flags are specified - modeCount := 0 - if *hostOnly { - modeCount++ - } - if *hostWrappers { - modeCount++ - } - if *capabilityOnly { - modeCount++ - } - if *schemasOnly { - modeCount++ - } - if *pdkOnly { - modeCount++ - } - if *sharedTypes { - modeCount++ - } - - // Default to host-only if no mode is specified - if modeCount == 0 { - *hostOnly = true - } - - // Cannot specify multiple modes - if modeCount > 1 { - return nil, fmt.Errorf("cannot specify multiple modes (-host-only, -host-wrappers, -capability-only, -schemas, -extism-pdk, -shared-types)") - } - - if *outputDir == "" { - *outputDir = *inputDir - } - - // Default package name based on mode - if *pkgName == "" { - if *hostOnly { - *pkgName = "host" - } - // For capability-only, package name is derived from capability annotation - } - - absInput, err := filepath.Abs(*inputDir) - if err != nil { - return nil, fmt.Errorf("resolving input path: %w", err) - } - absOutput, err := filepath.Abs(*outputDir) - if err != nil { - return nil, fmt.Errorf("resolving output path: %w", err) - } - - absShared := "" - if *shared != "" { - absShared, err = filepath.Abs(*shared) - if err != nil { - return nil, fmt.Errorf("resolving shared path: %w", err) - } - } - - // Set output directories for each language - // Go host wrappers: $output/go/host/ - // Rust host wrappers: $output/rust/nd-pdk-host/ (renamed crate) - absGoOutput := filepath.Join(absOutput, "go", "host") - absRustOutput := filepath.Join(absOutput, "rust", "nd-pdk-host") - - // Determine what to generate - // Default: generate Go clients if no language flag is specified - anyLangFlag := *goClient || *rsClient - - return &config{ - inputDir: absInput, - outputDir: absOutput, - goOutputDir: absGoOutput, - rustOutputDir: absRustOutput, - pkgName: *pkgName, - hostOnly: *hostOnly, - hostWrappers: *hostWrappers, - capabilityOnly: *capabilityOnly, - schemasOnly: *schemasOnly, - pdkOnly: *pdkOnly, - sharedTypes: *sharedTypes, - sharedDir: absShared, - generateGoClient: *goClient || !anyLangFlag, - generateRsClient: *rsClient, - verbose: *verbose, - dryRun: *dryRun, - }, nil -} - -// parseServices parses source files and returns discovered services. -func parseServices(cfg *config) ([]internal.Service, error) { - if cfg.verbose { - fmt.Printf("Input directory: %s\n", cfg.inputDir) - fmt.Printf("Base output directory: %s\n", cfg.outputDir) - if cfg.generateGoClient { - fmt.Printf("Go output directory: %s\n", cfg.goOutputDir) - } - if cfg.generateRsClient { - fmt.Printf("Rust output directory: %s\n", cfg.rustOutputDir) - } - fmt.Printf("Package name: %s\n", cfg.pkgName) - fmt.Printf("Host-only mode: %v\n", cfg.hostOnly) - fmt.Printf("Generate Go client code: %v\n", cfg.generateGoClient) - fmt.Printf("Generate Rust client code: %v\n", cfg.generateRsClient) - } - - shared, err := internal.LoadSharedTypes(cfg.sharedDir) - if err != nil { - return nil, fmt.Errorf("loading shared types: %w", err) - } - - services, err := internal.ParseDirectoryWithShared(cfg.inputDir, shared) - if err != nil { - return nil, fmt.Errorf("parsing source files: %w", err) - } - - if len(services) == 0 { - if cfg.verbose { - fmt.Println("No host services found") - } - return nil, nil - } - - if cfg.verbose { - fmt.Printf("Found %d host service(s)\n", len(services)) - for _, svc := range services { - fmt.Printf(" - %s (%d methods)\n", svc.Name, len(svc.Methods)) - } - } - - return services, nil -} - -// parseCapabilities parses source files and returns discovered capabilities. -func parseCapabilities(cfg *config) ([]internal.Capability, error) { - if cfg.verbose { - fmt.Printf("Input directory: %s\n", cfg.inputDir) - fmt.Printf("Base output directory: %s\n", cfg.outputDir) - fmt.Printf("Capability-only mode: %v\n", cfg.capabilityOnly) - } - - shared, err := internal.LoadSharedTypes(cfg.sharedDir) - if err != nil { - return nil, fmt.Errorf("loading shared types: %w", err) - } - - capabilities, err := internal.ParseCapabilitiesWithShared(cfg.inputDir, shared) - if err != nil { - return nil, fmt.Errorf("parsing capability files: %w", err) - } - - if len(capabilities) == 0 { - return nil, nil - } - - if cfg.verbose { - fmt.Printf("Found %d capability(ies)\n", len(capabilities)) - for _, cap := range capabilities { - fmt.Printf(" - %s (%d exports, required=%v)\n", cap.Name, len(cap.Methods), cap.Required) - } - } - - return capabilities, nil -} - -// generateCapabilityCode generates export wrappers for all capabilities. -func generateCapabilityCode(cfg *config, capabilities []internal.Capability) error { - // Generate Go capability wrappers (always, for now) - for _, cap := range capabilities { - // Output directory is $output/go// - outputDir := filepath.Join(cfg.outputDir, "go", cap.Name) - - if err := generateCapabilityGoCode(cap, outputDir, cfg.dryRun, cfg.verbose); err != nil { - return fmt.Errorf("generating Go capability code for %s: %w", cap.Name, err) - } - } - - // Generate Rust capability wrappers if -rust flag is set - if cfg.generateRsClient { - rustOutputDir := filepath.Join(cfg.outputDir, "rust", "nd-pdk-capabilities", "src") - if err := generateCapabilityRustCode(capabilities, rustOutputDir, cfg.dryRun, cfg.verbose); err != nil { - return fmt.Errorf("generating Rust capability code: %w", err) - } - } - - return nil -} - -// generateCapabilityGoCode generates Go export wrapper code for a capability. -func generateCapabilityGoCode(cap internal.Capability, outputDir string, dryRun, verbose bool) error { - // Use the capability name as the package name - pkgName := cap.Name - - // Generate the main WASM code - code, err := internal.GenerateCapabilityGo(cap, pkgName) - if err != nil { - return fmt.Errorf("generating code: %w", err) - } - - formatted, err := format.Source(code) - if err != nil { - return fmt.Errorf("formatting code: %w\nRaw code:\n%s", err, code) - } - - mainFile := filepath.Join(outputDir, cap.Name+".go") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", mainFile, formatted) - } else { - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("creating output directory: %w", err) - } - - if err := os.WriteFile(mainFile, formatted, 0600); err != nil { - return fmt.Errorf("writing file: %w", err) - } - - if verbose { - fmt.Printf("Generated capability code: %s\n", mainFile) - } - } - - // Generate the stub code for non-WASM platforms - stubCode, err := internal.GenerateCapabilityGoStub(cap, pkgName) - if err != nil { - return fmt.Errorf("generating stub code: %w", err) - } - - formattedStub, err := format.Source(stubCode) - if err != nil { - return fmt.Errorf("formatting stub code: %w\nRaw code:\n%s", err, stubCode) - } - - stubFile := filepath.Join(outputDir, cap.Name+"_stub.go") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", stubFile, formattedStub) - } else { - if err := os.WriteFile(stubFile, formattedStub, 0600); err != nil { - return fmt.Errorf("writing stub file: %w", err) - } - - if verbose { - fmt.Printf("Generated capability stub: %s\n", stubFile) - } - } - - return nil -} - -// generateCapabilityRustCode generates Rust export wrapper code for all capabilities. -func generateCapabilityRustCode(capabilities []internal.Capability, outputDir string, dryRun, verbose bool) error { - // Generate individual capability modules - for _, cap := range capabilities { - code, err := internal.GenerateCapabilityRust(cap) - if err != nil { - return fmt.Errorf("generating Rust code for %s: %w", cap.Name, err) - } - - fileName := internal.ToSnakeCase(cap.Name) + ".rs" - filePath := filepath.Join(outputDir, fileName) - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", filePath, code) - } else { - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("creating output directory: %w", err) - } - - if err := os.WriteFile(filePath, code, 0600); err != nil { - return fmt.Errorf("writing file %s: %w", filePath, err) - } - - if verbose { - fmt.Printf("Generated Rust capability code: %s\n", filePath) - } - } - } - - // Generate lib.rs - libCode, err := internal.GenerateCapabilityRustLib(capabilities) - if err != nil { - return fmt.Errorf("generating lib.rs: %w", err) - } - - libPath := filepath.Join(outputDir, "lib.rs") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", libPath, libCode) - } else { - if err := os.WriteFile(libPath, libCode, 0600); err != nil { - return fmt.Errorf("writing lib.rs: %w", err) - } - - if verbose { - fmt.Printf("Generated Rust lib.rs: %s\n", libPath) - } - } - - return nil -} - -// generateAllCode generates all requested code for the services. -func generateAllCode(cfg *config, services []internal.Service) error { - for _, svc := range services { - if cfg.generateGoClient { - if err := generateGoClientCode(svc, cfg.goOutputDir, cfg.pkgName, cfg.dryRun, cfg.verbose); err != nil { - return fmt.Errorf("generating Go client code for %s: %w", svc.Name, err) - } - } - if cfg.generateRsClient { - if err := generateRustClientCode(svc, cfg.rustOutputDir, cfg.dryRun, cfg.verbose); err != nil { - return fmt.Errorf("generating Rust client code for %s: %w", svc.Name, err) - } - } - } - - if cfg.generateRsClient && len(services) > 0 { - if err := generateRustLibFile(services, cfg.rustOutputDir, cfg.dryRun, cfg.verbose); err != nil { - return fmt.Errorf("generating Rust lib.rs: %w", err) - } - } - - if cfg.generateGoClient && len(services) > 0 { - if err := generateGoDocFile(services, cfg.goOutputDir, cfg.pkgName, cfg.dryRun, cfg.verbose); err != nil { - return fmt.Errorf("generating Go doc.go: %w", err) - } - if err := generateGoModFile(cfg.goOutputDir, cfg.dryRun, cfg.verbose); err != nil { - return fmt.Errorf("generating Go go.mod: %w", err) - } - // Generate PDK abstraction layer alongside host client code - pdkDir := filepath.Join(filepath.Dir(cfg.goOutputDir), "pdk") - if err := generatePDKPackage(pdkDir, cfg.dryRun, cfg.verbose); err != nil { - return fmt.Errorf("generating PDK package: %w", err) - } - } - - return nil -} - -// generateHostWrapperCode generates host wrapper code for a service. -// This generates the *_gen.go files that are used by Navidrome server -// to expose host functions to plugins via Extism. -func generateHostWrapperCode(svc internal.Service, outputDir, pkgName string, dryRun, verbose bool) error { - code, err := internal.GenerateHost(svc, pkgName) - if err != nil { - return fmt.Errorf("generating code: %w", err) - } - - formatted, err := format.Source(code) - if err != nil { - return fmt.Errorf("formatting code: %w\nRaw code:\n%s", err, code) - } - - // Host wrapper file follows the pattern _gen.go - hostFile := filepath.Join(outputDir, strings.ToLower(svc.Name)+"_gen.go") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", hostFile, formatted) - } else { - if err := os.WriteFile(hostFile, formatted, 0600); err != nil { - return fmt.Errorf("writing file: %w", err) - } - - if verbose { - fmt.Printf("Generated host wrapper: %s\n", hostFile) - } - } - - return nil -} - -// generateGoClientCode generates Go client-side code for a service. -func generateGoClientCode(svc internal.Service, outputDir, pkgName string, dryRun, verbose bool) error { - code, err := internal.GenerateClientGo(svc, pkgName) - if err != nil { - return fmt.Errorf("generating code: %w", err) - } - - formatted, err := format.Source(code) - if err != nil { - return fmt.Errorf("formatting code: %w\nRaw code:\n%s", err, code) - } - - // Client code goes directly in the output directory - clientFile := filepath.Join(outputDir, "nd_host_"+strings.ToLower(svc.Name)+".go") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", clientFile, formatted) - } else { - // Create output directory if needed - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("creating output directory: %w", err) - } - - if err := os.WriteFile(clientFile, formatted, 0600); err != nil { - return fmt.Errorf("writing file: %w", err) - } - - if verbose { - fmt.Printf("Generated Go client code: %s\n", clientFile) - } - } - - // Also generate stub file for non-WASM platforms - return generateGoClientStubCode(svc, outputDir, pkgName, dryRun, verbose) -} - -// generateGoClientStubCode generates stub code for non-WASM platforms. -func generateGoClientStubCode(svc internal.Service, outputDir, pkgName string, dryRun, verbose bool) error { - code, err := internal.GenerateClientGoStub(svc, pkgName) - if err != nil { - return fmt.Errorf("generating stub code: %w", err) - } - - formatted, err := format.Source(code) - if err != nil { - return fmt.Errorf("formatting stub code: %w\nRaw code:\n%s", err, code) - } - - // Stub code goes directly in output directory with _stub suffix - stubFile := filepath.Join(outputDir, "nd_host_"+strings.ToLower(svc.Name)+"_stub.go") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", stubFile, formatted) - return nil - } - - // Create output directory if needed - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("creating output directory: %w", err) - } - - if err := os.WriteFile(stubFile, formatted, 0600); err != nil { - return fmt.Errorf("writing stub file: %w", err) - } - - if verbose { - fmt.Printf("Generated Go client stub: %s\n", stubFile) - } - return nil -} - -// generateRustClientCode generates Rust client-side code for a service. -func generateRustClientCode(svc internal.Service, outputDir string, dryRun, verbose bool) error { - code, err := internal.GenerateClientRust(svc) - if err != nil { - return fmt.Errorf("generating code: %w", err) - } - - // Rust code goes in src/ subdirectory (standard Rust convention) - srcDir := filepath.Join(outputDir, "src") - clientFile := filepath.Join(srcDir, "nd_host_"+strings.ToLower(svc.Name)+".rs") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", clientFile, code) - return nil - } - - // Create src directory if needed - if err := os.MkdirAll(srcDir, 0755); err != nil { - return fmt.Errorf("creating rust src directory: %w", err) - } - - if err := os.WriteFile(clientFile, code, 0600); err != nil { - return fmt.Errorf("writing file: %w", err) - } - - if verbose { - fmt.Printf("Generated Rust client code: %s\n", clientFile) - } - return nil -} - -// generateRustLibFile generates the lib.rs file that exposes all Rust modules. -func generateRustLibFile(services []internal.Service, outputDir string, dryRun, verbose bool) error { - code, err := internal.GenerateRustLib(services) - if err != nil { - return fmt.Errorf("generating lib.rs: %w", err) - } - - // lib.rs goes in src/ subdirectory (standard Rust convention) - srcDir := filepath.Join(outputDir, "src") - libFile := filepath.Join(srcDir, "lib.rs") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", libFile, code) - return nil - } - - // Create src directory if needed - if err := os.MkdirAll(srcDir, 0755); err != nil { - return fmt.Errorf("creating rust src directory: %w", err) - } - - if err := os.WriteFile(libFile, code, 0600); err != nil { - return fmt.Errorf("writing file: %w", err) - } - - if verbose { - fmt.Printf("Generated Rust lib.rs: %s\n", libFile) - } - return nil -} - -// generateGoDocFile generates the doc.go file for the Go library. -func generateGoDocFile(services []internal.Service, outputDir, pkgName string, dryRun, verbose bool) error { - code, err := internal.GenerateGoDoc(services, pkgName) - if err != nil { - return fmt.Errorf("generating doc.go: %w", err) - } - - formatted, err := format.Source(code) - if err != nil { - return fmt.Errorf("formatting doc.go: %w\nRaw code:\n%s", err, code) - } - - docFile := filepath.Join(outputDir, "doc.go") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", docFile, formatted) - return nil - } - - // Create output directory if needed - if err := os.MkdirAll(outputDir, 0755); err != nil { - return fmt.Errorf("creating output directory: %w", err) - } - - if err := os.WriteFile(docFile, formatted, 0600); err != nil { - return fmt.Errorf("writing file: %w", err) - } - - if verbose { - fmt.Printf("Generated Go doc.go: %s\n", docFile) - } - return nil -} - -// generateGoModFile generates the go.mod file for the Go library. -// The go.mod is placed at the parent directory ($output/go/) to create a unified -// module that includes both host wrappers and capabilities. -func generateGoModFile(outputDir string, dryRun, verbose bool) error { - code, err := internal.GenerateGoMod() - if err != nil { - return fmt.Errorf("generating go.mod: %w", err) - } - - // Output to parent directory ($output/go/) instead of host directory - parentDir := filepath.Dir(outputDir) - modFile := filepath.Join(parentDir, "go.mod") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", modFile, code) - return nil - } - - // Create parent directory if needed - if err := os.MkdirAll(parentDir, 0755); err != nil { - return fmt.Errorf("creating output directory: %w", err) - } - - if err := os.WriteFile(modFile, code, 0600); err != nil { - return fmt.Errorf("writing file: %w", err) - } - - if verbose { - fmt.Printf("Generated Go go.mod: %s\n", modFile) - } - return nil -} - -// generateSchemas generates XTP YAML schemas from capabilities. -func generateSchemas(cfg *config, capabilities []internal.Capability) error { - for _, cap := range capabilities { - if err := generateSchemaFile(cap, cfg.inputDir, cfg.dryRun, cfg.verbose); err != nil { - return fmt.Errorf("generating schema for %s: %w", cap.Name, err) - } - } - return nil -} - -// generateSchemaFile generates an XTP YAML schema file for a capability. -func generateSchemaFile(cap internal.Capability, outputDir string, dryRun, verbose bool) error { - schema, err := internal.GenerateSchema(cap) - if err != nil { - return fmt.Errorf("generating schema: %w", err) - } - - // Validate the generated schema against XTP JSONSchema spec - if err := internal.ValidateXTPSchema(schema); err != nil { - fmt.Fprintf(os.Stderr, "Warning: Schema validation for %s:\n%s\n", cap.Name, err) - } - - // Use the source file name: websocket_callback.go -> websocket_callback.yaml - schemaFile := filepath.Join(outputDir, cap.SourceFile+".yaml") - - if dryRun { - fmt.Printf("=== %s ===\n%s\n", schemaFile, schema) - return nil - } - - if err := os.WriteFile(schemaFile, schema, 0600); err != nil { - return fmt.Errorf("writing file: %w", err) - } - - if verbose { - fmt.Printf("Generated XTP schema: %s\n", schemaFile) - } - return nil -} diff --git a/plugins/cmd/ndpgen/ndpgen_suite_test.go b/plugins/cmd/ndpgen/ndpgen_suite_test.go deleted file mode 100644 index 543fe8876..000000000 --- a/plugins/cmd/ndpgen/ndpgen_suite_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package main - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestNdpgen(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "NDPGen CLI Suite") -} diff --git a/plugins/cmd/ndpgen/testdata/codec_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/codec_client_expected.go.txt deleted file mode 100644 index 93d5cb27c..000000000 --- a/plugins/cmd/ndpgen/testdata/codec_client_expected.go.txt +++ /dev/null @@ -1,63 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Codec host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package ndhost - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// codec_encode is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user codec_encode -func codec_encode(uint64) uint64 - -type codecEncodeRequest struct { - Data []byte `json:"data"` -} - -type codecEncodeResponse struct { - Result []byte `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// CodecEncode calls the codec_encode host function. -func CodecEncode(data []byte) ([]byte, error) { - // Marshal request to JSON - req := codecEncodeRequest{ - Data: data, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := codec_encode(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response codecEncodeResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Result, nil -} diff --git a/plugins/cmd/ndpgen/testdata/codec_client_expected.rs b/plugins/cmd/ndpgen/testdata/codec_client_expected.rs deleted file mode 100644 index 3e229ea8a..000000000 --- a/plugins/cmd/ndpgen/testdata/codec_client_expected.rs +++ /dev/null @@ -1,76 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Codec host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; - -mod base64_bytes { - use serde::{self, Deserialize, Deserializer, Serializer}; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; - - pub fn serialize(bytes: &Vec, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&BASE64.encode(bytes)) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - BASE64.decode(&s).map_err(serde::de::Error::custom) - } -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CodecEncodeRequest { - #[serde(with = "base64_bytes")] - data: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CodecEncodeResponse { - #[serde(default)] - #[serde(with = "base64_bytes")] - result: Vec, - #[serde(default)] - error: Option, -} - -#[host_fn] -extern "ExtismHost" { - fn codec_encode(input: Json) -> Json; -} - -/// Calls the codec_encode host function. -/// -/// # Arguments -/// * `data` - Vec parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn encode(data: Vec) -> Result, Error> { - let response = unsafe { - codec_encode(Json(CodecEncodeRequest { - data: data, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} diff --git a/plugins/cmd/ndpgen/testdata/codec_expected.go.txt b/plugins/cmd/ndpgen/testdata/codec_expected.go.txt deleted file mode 100644 index 8655e49c8..000000000 --- a/plugins/cmd/ndpgen/testdata/codec_expected.go.txt +++ /dev/null @@ -1,88 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package testpkg - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// CodecEncodeRequest is the request type for Codec.Encode. -type CodecEncodeRequest struct { - Data []byte `json:"data"` -} - -// CodecEncodeResponse is the response type for Codec.Encode. -type CodecEncodeResponse struct { - Result []byte `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterCodecHostFunctions registers Codec service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterCodecHostFunctions(service CodecService) []extism.HostFunction { - return []extism.HostFunction{ - newCodecEncodeHostFunction(service), - } -} - -func newCodecEncodeHostFunction(service CodecService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "codec_encode", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - codecWriteError(p, stack, err) - return - } - var req CodecEncodeRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - codecWriteError(p, stack, err) - return - } - - // Call the service method - result, svcErr := service.Encode(ctx, req.Data) - if svcErr != nil { - codecWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := CodecEncodeResponse{ - Result: result, - } - codecWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// codecWriteResponse writes a JSON response to plugin memory. -func codecWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - codecWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// codecWriteError writes an error response to plugin memory. -func codecWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/cmd/ndpgen/testdata/codec_service.go.txt b/plugins/cmd/ndpgen/testdata/codec_service.go.txt deleted file mode 100644 index 94a1b71db..000000000 --- a/plugins/cmd/ndpgen/testdata/codec_service.go.txt +++ /dev/null @@ -1,9 +0,0 @@ -package testpkg - -import "context" - -//nd:hostservice name=Codec permission=codec -type CodecService interface { - //nd:hostfunc - Encode(ctx context.Context, data []byte) ([]byte, error) -} diff --git a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs b/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs deleted file mode 100644 index 1bd953640..000000000 --- a/plugins/cmd/ndpgen/testdata/comprehensive_client_expected.rs +++ /dev/null @@ -1,423 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Comprehensive host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; - -mod base64_bytes { - use serde::{self, Deserialize, Deserializer, Serializer}; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; - - pub fn serialize(bytes: &Vec, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&BASE64.encode(bytes)) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - BASE64.decode(&s).map_err(serde::de::Error::custom) - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct User2 { - pub id: String, - pub name: String, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Filter2 { - pub active: bool, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveSimpleParamsRequest { - name: String, - count: i32, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveSimpleParamsResponse { - #[serde(default)] - result: String, - #[serde(default)] - error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveStructParamRequest { - user: User2, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveStructParamResponse { - #[serde(default)] - error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveMixedParamsRequest { - id: String, - filter: Filter2, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveMixedParamsResponse { - #[serde(default)] - result: i32, - #[serde(default)] - error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveNoErrorRequest { - name: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveNoErrorResponse { - #[serde(default)] - result: String, - #[serde(default)] - error: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveNoParamsResponse { - #[serde(default)] - error: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveNoParamsNoReturnsResponse { - #[serde(default)] - error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensivePointerParamsRequest { - id: Option, - user: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensivePointerParamsResponse { - #[serde(default)] - result: Option, - #[serde(default)] - error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveMapParamsRequest { - data: std::collections::HashMap, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveMapParamsResponse { - #[serde(default)] - result: serde_json::Value, - #[serde(default)] - error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveMultipleReturnsRequest { - query: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveMultipleReturnsResponse { - #[serde(default)] - results: Vec, - #[serde(default)] - total: i32, - #[serde(default)] - error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveByteSliceRequest { - #[serde(with = "base64_bytes")] - data: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ComprehensiveByteSliceResponse { - #[serde(default)] - #[serde(with = "base64_bytes")] - result: Vec, - #[serde(default)] - error: Option, -} - -#[host_fn] -extern "ExtismHost" { - fn comprehensive_simpleparams(input: Json) -> Json; - fn comprehensive_structparam(input: Json) -> Json; - fn comprehensive_mixedparams(input: Json) -> Json; - fn comprehensive_noerror(input: Json) -> Json; - fn comprehensive_noparams(input: Json) -> Json; - fn comprehensive_noparamsnoreturns(input: Json) -> Json; - fn comprehensive_pointerparams(input: Json) -> Json; - fn comprehensive_mapparams(input: Json) -> Json; - fn comprehensive_multiplereturns(input: Json) -> Json; - fn comprehensive_byteslice(input: Json) -> Json; -} - -/// Calls the comprehensive_simpleparams host function. -/// -/// # Arguments -/// * `name` - String parameter. -/// * `count` - i32 parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn simple_params(name: &str, count: i32) -> Result { - let response = unsafe { - comprehensive_simpleparams(Json(ComprehensiveSimpleParamsRequest { - name: name.to_owned(), - count: count, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} - -/// Calls the comprehensive_structparam host function. -/// -/// # Arguments -/// * `user` - User2 parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn struct_param(user: User2) -> Result<(), Error> { - let response = unsafe { - comprehensive_structparam(Json(ComprehensiveStructParamRequest { - user: user, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} - -/// Calls the comprehensive_mixedparams host function. -/// -/// # Arguments -/// * `id` - String parameter. -/// * `filter` - Filter2 parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn mixed_params(id: &str, filter: Filter2) -> Result { - let response = unsafe { - comprehensive_mixedparams(Json(ComprehensiveMixedParamsRequest { - id: id.to_owned(), - filter: filter, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} - -/// Calls the comprehensive_noerror host function. -/// -/// # Arguments -/// * `name` - String parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn no_error(name: &str) -> Result { - let response = unsafe { - comprehensive_noerror(Json(ComprehensiveNoErrorRequest { - name: name.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} - -/// Calls the comprehensive_noparams host function. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn no_params() -> Result<(), Error> { - let response = unsafe { - comprehensive_noparams(Json(serde_json::json!({})))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} - -/// Calls the comprehensive_noparamsnoreturns host function. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn no_params_no_returns() -> Result<(), Error> { - let response = unsafe { - comprehensive_noparamsnoreturns(Json(serde_json::json!({})))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} - -/// Calls the comprehensive_pointerparams host function. -/// -/// # Arguments -/// * `id` - Option parameter. -/// * `user` - Option parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn pointer_params(id: Option, user: Option) -> Result, Error> { - let response = unsafe { - comprehensive_pointerparams(Json(ComprehensivePointerParamsRequest { - id: id, - user: user, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} - -/// Calls the comprehensive_mapparams host function. -/// -/// # Arguments -/// * `data` - std::collections::HashMap parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn map_params(data: std::collections::HashMap) -> Result { - let response = unsafe { - comprehensive_mapparams(Json(ComprehensiveMapParamsRequest { - data: data, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} - -/// Calls the comprehensive_multiplereturns host function. -/// -/// # Arguments -/// * `query` - String parameter. -/// -/// # Returns -/// A tuple of (results, total). -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn multiple_returns(query: &str) -> Result<(Vec, i32), Error> { - let response = unsafe { - comprehensive_multiplereturns(Json(ComprehensiveMultipleReturnsRequest { - query: query.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok((response.0.results, response.0.total)) -} - -/// Calls the comprehensive_byteslice host function. -/// -/// # Arguments -/// * `data` - Vec parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn byte_slice(data: Vec) -> Result, Error> { - let response = unsafe { - comprehensive_byteslice(Json(ComprehensiveByteSliceRequest { - data: data, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} diff --git a/plugins/cmd/ndpgen/testdata/comprehensive_service.go.txt b/plugins/cmd/ndpgen/testdata/comprehensive_service.go.txt deleted file mode 100644 index 3a9a1bfc4..000000000 --- a/plugins/cmd/ndpgen/testdata/comprehensive_service.go.txt +++ /dev/null @@ -1,36 +0,0 @@ -package testpkg - -import "context" - -type User2 struct { - ID string - Name string -} - -type Filter2 struct { - Active bool -} - -//nd:hostservice name=Comprehensive permission=comprehensive -type ComprehensiveService interface { - //nd:hostfunc - SimpleParams(ctx context.Context, name string, count int32) (string, error) - //nd:hostfunc - StructParam(ctx context.Context, user User2) error - //nd:hostfunc - MixedParams(ctx context.Context, id string, filter Filter2) (int32, error) - //nd:hostfunc - NoError(ctx context.Context, name string) string - //nd:hostfunc - NoParams(ctx context.Context) error - //nd:hostfunc - NoParamsNoReturns(ctx context.Context) - //nd:hostfunc - PointerParams(ctx context.Context, id *string, user *User2) (*User2, error) - //nd:hostfunc - MapParams(ctx context.Context, data map[string]any) (interface{}, error) - //nd:hostfunc - MultipleReturns(ctx context.Context, query string) (results []User2, total int32, err error) - //nd:hostfunc - ByteSlice(ctx context.Context, data []byte) ([]byte, error) -} diff --git a/plugins/cmd/ndpgen/testdata/config_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/config_client_expected.go.txt deleted file mode 100644 index c88fb930b..000000000 --- a/plugins/cmd/ndpgen/testdata/config_client_expected.go.txt +++ /dev/null @@ -1,156 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Config host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package ndhost - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// config_get is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user config_get -func config_get(uint64) uint64 - -// config_set is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user config_set -func config_set(uint64) uint64 - -// config_has is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user config_has -func config_has(uint64) uint64 - -type configGetRequest struct { - Key string `json:"key"` -} - -type configGetResponse struct { - Value string `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -type configSetRequest struct { - Key string `json:"key"` - Value string `json:"value"` -} - -type configHasRequest struct { - Key string `json:"key"` -} - -type configHasResponse struct { - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -// ConfigGet calls the config_get host function. -func ConfigGet(key string) (string, bool, error) { - // Marshal request to JSON - req := configGetRequest{ - Key: key, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", false, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := config_get(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response configGetResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "", false, err - } - - // Convert Error field to Go error - if response.Error != "" { - return "", false, errors.New(response.Error) - } - - return response.Value, response.Exists, nil -} - -// ConfigSet calls the config_set host function. -func ConfigSet(key string, value string) error { - // Marshal request to JSON - req := configSetRequest{ - Key: key, - Value: value, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := config_set(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} - -// ConfigHas calls the config_has host function. -func ConfigHas(key string) (bool, error) { - // Marshal request to JSON - req := configHasRequest{ - Key: key, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return false, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := config_has(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response configHasResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return false, err - } - - // Convert Error field to Go error - if response.Error != "" { - return false, errors.New(response.Error) - } - - return response.Exists, nil -} diff --git a/plugins/cmd/ndpgen/testdata/config_client_expected.rs b/plugins/cmd/ndpgen/testdata/config_client_expected.rs deleted file mode 100644 index 154d01b0c..000000000 --- a/plugins/cmd/ndpgen/testdata/config_client_expected.rs +++ /dev/null @@ -1,135 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Config host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ConfigGetRequest { - key: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ConfigGetResponse { - #[serde(default)] - value: String, - #[serde(default)] - exists: bool, - #[serde(default)] - error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ConfigSetRequest { - key: String, - value: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ConfigSetResponse { - #[serde(default)] - error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ConfigHasRequest { - key: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ConfigHasResponse { - #[serde(default)] - exists: bool, - #[serde(default)] - error: Option, -} - -#[host_fn] -extern "ExtismHost" { - fn config_get(input: Json) -> Json; - fn config_set(input: Json) -> Json; - fn config_has(input: Json) -> Json; -} - -/// Calls the config_get host function. -/// -/// # Arguments -/// * `key` - String parameter. -/// -/// # Returns -/// `Some(value)` if found, `None` otherwise. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get(key: &str) -> Result, Error> { - let response = unsafe { - config_get(Json(ConfigGetRequest { - key: key.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - if response.0.exists { - Ok(Some(response.0.value)) - } else { - Ok(None) - } -} - -/// Calls the config_set host function. -/// -/// # Arguments -/// * `key` - String parameter. -/// * `value` - String parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn set(key: &str, value: &str) -> Result<(), Error> { - let response = unsafe { - config_set(Json(ConfigSetRequest { - key: key.to_owned(), - value: value.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} - -/// Calls the config_has host function. -/// -/// # Arguments -/// * `key` - String parameter. -/// -/// # Returns -/// The exists value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn has(key: &str) -> Result { - let response = unsafe { - config_has(Json(ConfigHasRequest { - key: key.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.exists) -} diff --git a/plugins/cmd/ndpgen/testdata/config_service.go.txt b/plugins/cmd/ndpgen/testdata/config_service.go.txt deleted file mode 100644 index 5def79302..000000000 --- a/plugins/cmd/ndpgen/testdata/config_service.go.txt +++ /dev/null @@ -1,15 +0,0 @@ -package testpkg - -import "context" - -//nd:hostservice name=Config permission=config -type ConfigService interface { - //nd:hostfunc - Get(ctx context.Context, key string) (value string, exists bool, err error) - - //nd:hostfunc - Set(ctx context.Context, key string, value string) error - - //nd:hostfunc - Has(ctx context.Context, key string) (exists bool, err error) -} diff --git a/plugins/cmd/ndpgen/testdata/counter_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/counter_client_expected.go.txt deleted file mode 100644 index 3fbb53727..000000000 --- a/plugins/cmd/ndpgen/testdata/counter_client_expected.go.txt +++ /dev/null @@ -1,56 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Counter host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package ndhost - -import ( - "encoding/json" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// counter_count is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user counter_count -func counter_count(uint64) uint64 - -type counterCountRequest struct { - Name string `json:"name"` -} - -type counterCountResponse struct { - Value int32 `json:"value,omitempty"` -} - -// CounterCount calls the counter_count host function. -func CounterCount(name string) int32 { - // Marshal request to JSON - req := counterCountRequest{ - Name: name, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return 0 - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := counter_count(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response counterCountResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return 0 - } - - return response.Value -} diff --git a/plugins/cmd/ndpgen/testdata/counter_client_expected.rs b/plugins/cmd/ndpgen/testdata/counter_client_expected.rs deleted file mode 100644 index a58dd8e1e..000000000 --- a/plugins/cmd/ndpgen/testdata/counter_client_expected.rs +++ /dev/null @@ -1,45 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Counter host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CounterCountRequest { - name: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CounterCountResponse { - #[serde(default)] - value: i32, -} - -#[host_fn] -extern "ExtismHost" { - fn counter_count(input: Json) -> Json; -} - -/// Calls the counter_count host function. -/// -/// # Arguments -/// * `name` - String parameter. -/// -/// # Returns -/// The value value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn count(name: &str) -> Result { - let response = unsafe { - counter_count(Json(CounterCountRequest { - name: name.to_owned(), - }))? - }; - - Ok(response.0.value) -} diff --git a/plugins/cmd/ndpgen/testdata/counter_expected.go.txt b/plugins/cmd/ndpgen/testdata/counter_expected.go.txt deleted file mode 100644 index 7fd1cbb84..000000000 --- a/plugins/cmd/ndpgen/testdata/counter_expected.go.txt +++ /dev/null @@ -1,83 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package testpkg - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// CounterCountRequest is the request type for Counter.Count. -type CounterCountRequest struct { - Name string `json:"name"` -} - -// CounterCountResponse is the response type for Counter.Count. -type CounterCountResponse struct { - Value int32 `json:"value,omitempty"` -} - -// RegisterCounterHostFunctions registers Counter service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterCounterHostFunctions(service CounterService) []extism.HostFunction { - return []extism.HostFunction{ - newCounterCountHostFunction(service), - } -} - -func newCounterCountHostFunction(service CounterService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "counter_count", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - counterWriteError(p, stack, err) - return - } - var req CounterCountRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - counterWriteError(p, stack, err) - return - } - - // Call the service method - value := service.Count(ctx, req.Name) - - // Write JSON response to plugin memory - resp := CounterCountResponse{ - Value: value, - } - counterWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// counterWriteResponse writes a JSON response to plugin memory. -func counterWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - counterWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// counterWriteError writes an error response to plugin memory. -func counterWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/cmd/ndpgen/testdata/counter_service.go.txt b/plugins/cmd/ndpgen/testdata/counter_service.go.txt deleted file mode 100644 index 456599031..000000000 --- a/plugins/cmd/ndpgen/testdata/counter_service.go.txt +++ /dev/null @@ -1,9 +0,0 @@ -package testpkg - -import "context" - -//nd:hostservice name=Counter permission=counter -type CounterService interface { - //nd:hostfunc - Count(ctx context.Context, name string) (value int32) -} diff --git a/plugins/cmd/ndpgen/testdata/echo_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/echo_client_expected.go.txt deleted file mode 100644 index 7a495acf4..000000000 --- a/plugins/cmd/ndpgen/testdata/echo_client_expected.go.txt +++ /dev/null @@ -1,63 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Echo host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package ndhost - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// echo_echo is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user echo_echo -func echo_echo(uint64) uint64 - -type echoEchoRequest struct { - Message string `json:"message"` -} - -type echoEchoResponse struct { - Reply string `json:"reply,omitempty"` - Error string `json:"error,omitempty"` -} - -// EchoEcho calls the echo_echo host function. -func EchoEcho(message string) (string, error) { - // Marshal request to JSON - req := echoEchoRequest{ - Message: message, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := echo_echo(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response echoEchoResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "", err - } - - // Convert Error field to Go error - if response.Error != "" { - return "", errors.New(response.Error) - } - - return response.Reply, nil -} diff --git a/plugins/cmd/ndpgen/testdata/echo_client_expected.rs b/plugins/cmd/ndpgen/testdata/echo_client_expected.rs deleted file mode 100644 index 0b97ca1cd..000000000 --- a/plugins/cmd/ndpgen/testdata/echo_client_expected.rs +++ /dev/null @@ -1,51 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Echo host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct EchoEchoRequest { - message: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct EchoEchoResponse { - #[serde(default)] - reply: String, - #[serde(default)] - error: Option, -} - -#[host_fn] -extern "ExtismHost" { - fn echo_echo(input: Json) -> Json; -} - -/// Calls the echo_echo host function. -/// -/// # Arguments -/// * `message` - String parameter. -/// -/// # Returns -/// The reply value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn echo(message: &str) -> Result { - let response = unsafe { - echo_echo(Json(EchoEchoRequest { - message: message.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.reply) -} diff --git a/plugins/cmd/ndpgen/testdata/echo_expected.go.txt b/plugins/cmd/ndpgen/testdata/echo_expected.go.txt deleted file mode 100644 index d67854abf..000000000 --- a/plugins/cmd/ndpgen/testdata/echo_expected.go.txt +++ /dev/null @@ -1,88 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package testpkg - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// EchoEchoRequest is the request type for Echo.Echo. -type EchoEchoRequest struct { - Message string `json:"message"` -} - -// EchoEchoResponse is the response type for Echo.Echo. -type EchoEchoResponse struct { - Reply string `json:"reply,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterEchoHostFunctions registers Echo service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterEchoHostFunctions(service EchoService) []extism.HostFunction { - return []extism.HostFunction{ - newEchoEchoHostFunction(service), - } -} - -func newEchoEchoHostFunction(service EchoService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "echo_echo", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - echoWriteError(p, stack, err) - return - } - var req EchoEchoRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - echoWriteError(p, stack, err) - return - } - - // Call the service method - reply, svcErr := service.Echo(ctx, req.Message) - if svcErr != nil { - echoWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := EchoEchoResponse{ - Reply: reply, - } - echoWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// echoWriteResponse writes a JSON response to plugin memory. -func echoWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - echoWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// echoWriteError writes an error response to plugin memory. -func echoWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/cmd/ndpgen/testdata/echo_service.go.txt b/plugins/cmd/ndpgen/testdata/echo_service.go.txt deleted file mode 100644 index 42a1e9572..000000000 --- a/plugins/cmd/ndpgen/testdata/echo_service.go.txt +++ /dev/null @@ -1,9 +0,0 @@ -package testpkg - -import "context" - -//nd:hostservice name=Echo permission=echo -type EchoService interface { - //nd:hostfunc - Echo(ctx context.Context, message string) (reply string, err error) -} diff --git a/plugins/cmd/ndpgen/testdata/list_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/list_client_expected.go.txt deleted file mode 100644 index ea825ee43..000000000 --- a/plugins/cmd/ndpgen/testdata/list_client_expected.go.txt +++ /dev/null @@ -1,70 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the List host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package ndhost - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// Filter represents the Filter data structure. -type Filter struct { - Active bool `json:"active"` -} - -// list_items is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user list_items -func list_items(uint64) uint64 - -type listItemsRequest struct { - Name string `json:"name"` - Filter Filter `json:"filter"` -} - -type listItemsResponse struct { - Count int32 `json:"count,omitempty"` - Error string `json:"error,omitempty"` -} - -// ListItems calls the list_items host function. -func ListItems(name string, filter Filter) (int32, error) { - // Marshal request to JSON - req := listItemsRequest{ - Name: name, - Filter: filter, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return 0, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := list_items(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response listItemsResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return 0, err - } - - // Convert Error field to Go error - if response.Error != "" { - return 0, errors.New(response.Error) - } - - return response.Count, nil -} diff --git a/plugins/cmd/ndpgen/testdata/list_client_expected.rs b/plugins/cmd/ndpgen/testdata/list_client_expected.rs deleted file mode 100644 index 5227cadf4..000000000 --- a/plugins/cmd/ndpgen/testdata/list_client_expected.rs +++ /dev/null @@ -1,60 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the List host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Filter { - pub active: bool, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ListItemsRequest { - name: String, - filter: Filter, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ListItemsResponse { - #[serde(default)] - count: i32, - #[serde(default)] - error: Option, -} - -#[host_fn] -extern "ExtismHost" { - fn list_items(input: Json) -> Json; -} - -/// Calls the list_items host function. -/// -/// # Arguments -/// * `name` - String parameter. -/// * `filter` - Filter parameter. -/// -/// # Returns -/// The count value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn items(name: &str, filter: Filter) -> Result { - let response = unsafe { - list_items(Json(ListItemsRequest { - name: name.to_owned(), - filter: filter, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.count) -} diff --git a/plugins/cmd/ndpgen/testdata/list_expected.go.txt b/plugins/cmd/ndpgen/testdata/list_expected.go.txt deleted file mode 100644 index 778f3a409..000000000 --- a/plugins/cmd/ndpgen/testdata/list_expected.go.txt +++ /dev/null @@ -1,89 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package testpkg - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// ListItemsRequest is the request type for List.Items. -type ListItemsRequest struct { - Name string `json:"name"` - Filter Filter `json:"filter"` -} - -// ListItemsResponse is the response type for List.Items. -type ListItemsResponse struct { - Count int32 `json:"count,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterListHostFunctions registers List service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterListHostFunctions(service ListService) []extism.HostFunction { - return []extism.HostFunction{ - newListItemsHostFunction(service), - } -} - -func newListItemsHostFunction(service ListService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "list_items", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - listWriteError(p, stack, err) - return - } - var req ListItemsRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - listWriteError(p, stack, err) - return - } - - // Call the service method - count, svcErr := service.Items(ctx, req.Name, req.Filter) - if svcErr != nil { - listWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := ListItemsResponse{ - Count: count, - } - listWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// listWriteResponse writes a JSON response to plugin memory. -func listWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - listWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// listWriteError writes an error response to plugin memory. -func listWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/cmd/ndpgen/testdata/list_service.go.txt b/plugins/cmd/ndpgen/testdata/list_service.go.txt deleted file mode 100644 index ff3a42e01..000000000 --- a/plugins/cmd/ndpgen/testdata/list_service.go.txt +++ /dev/null @@ -1,13 +0,0 @@ -package testpkg - -import "context" - -type Filter struct { - Active bool -} - -//nd:hostservice name=List permission=list -type ListService interface { - //nd:hostfunc - Items(ctx context.Context, name string, filter Filter) (count int32, err error) -} diff --git a/plugins/cmd/ndpgen/testdata/math_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/math_client_expected.go.txt deleted file mode 100644 index 9b95b50e9..000000000 --- a/plugins/cmd/ndpgen/testdata/math_client_expected.go.txt +++ /dev/null @@ -1,65 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Math host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package ndhost - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// math_add is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user math_add -func math_add(uint64) uint64 - -type mathAddRequest struct { - A int32 `json:"a"` - B int32 `json:"b"` -} - -type mathAddResponse struct { - Result int32 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// MathAdd calls the math_add host function. -func MathAdd(a int32, b int32) (int32, error) { - // Marshal request to JSON - req := mathAddRequest{ - A: a, - B: b, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return 0, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := math_add(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response mathAddResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return 0, err - } - - // Convert Error field to Go error - if response.Error != "" { - return 0, errors.New(response.Error) - } - - return response.Result, nil -} diff --git a/plugins/cmd/ndpgen/testdata/math_client_expected.rs b/plugins/cmd/ndpgen/testdata/math_client_expected.rs deleted file mode 100644 index fde6a7cb9..000000000 --- a/plugins/cmd/ndpgen/testdata/math_client_expected.rs +++ /dev/null @@ -1,54 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Math host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct MathAddRequest { - a: i32, - b: i32, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct MathAddResponse { - #[serde(default)] - result: i32, - #[serde(default)] - error: Option, -} - -#[host_fn] -extern "ExtismHost" { - fn math_add(input: Json) -> Json; -} - -/// Calls the math_add host function. -/// -/// # Arguments -/// * `a` - i32 parameter. -/// * `b` - i32 parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn add(a: i32, b: i32) -> Result { - let response = unsafe { - math_add(Json(MathAddRequest { - a: a, - b: b, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} diff --git a/plugins/cmd/ndpgen/testdata/math_expected.go.txt b/plugins/cmd/ndpgen/testdata/math_expected.go.txt deleted file mode 100644 index 48a2bf875..000000000 --- a/plugins/cmd/ndpgen/testdata/math_expected.go.txt +++ /dev/null @@ -1,89 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package testpkg - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// MathAddRequest is the request type for Math.Add. -type MathAddRequest struct { - A int32 `json:"a"` - B int32 `json:"b"` -} - -// MathAddResponse is the response type for Math.Add. -type MathAddResponse struct { - Result int32 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterMathHostFunctions registers Math service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterMathHostFunctions(service MathService) []extism.HostFunction { - return []extism.HostFunction{ - newMathAddHostFunction(service), - } -} - -func newMathAddHostFunction(service MathService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "math_add", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - mathWriteError(p, stack, err) - return - } - var req MathAddRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - mathWriteError(p, stack, err) - return - } - - // Call the service method - result, svcErr := service.Add(ctx, req.A, req.B) - if svcErr != nil { - mathWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := MathAddResponse{ - Result: result, - } - mathWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// mathWriteResponse writes a JSON response to plugin memory. -func mathWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - mathWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// mathWriteError writes an error response to plugin memory. -func mathWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/cmd/ndpgen/testdata/math_service.go.txt b/plugins/cmd/ndpgen/testdata/math_service.go.txt deleted file mode 100644 index 66776b1da..000000000 --- a/plugins/cmd/ndpgen/testdata/math_service.go.txt +++ /dev/null @@ -1,9 +0,0 @@ -package testpkg - -import "context" - -//nd:hostservice name=Math permission=math -type MathService interface { - //nd:hostfunc - Add(ctx context.Context, a int32, b int32) (result int32, err error) -} diff --git a/plugins/cmd/ndpgen/testdata/meta_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/meta_client_expected.go.txt deleted file mode 100644 index 4147f35f1..000000000 --- a/plugins/cmd/ndpgen/testdata/meta_client_expected.go.txt +++ /dev/null @@ -1,105 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Meta host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package ndhost - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// meta_get is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user meta_get -func meta_get(uint64) uint64 - -// meta_set is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user meta_set -func meta_set(uint64) uint64 - -type metaGetRequest struct { - Key string `json:"key"` -} - -type metaGetResponse struct { - Value any `json:"value,omitempty"` - Error string `json:"error,omitempty"` -} - -type metaSetRequest struct { - Data map[string]any `json:"data"` -} - -// MetaGet calls the meta_get host function. -func MetaGet(key string) (any, error) { - // Marshal request to JSON - req := metaGetRequest{ - Key: key, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := meta_get(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response metaGetResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Value, nil -} - -// MetaSet calls the meta_set host function. -func MetaSet(data map[string]any) error { - // Marshal request to JSON - req := metaSetRequest{ - Data: data, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := meta_set(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} diff --git a/plugins/cmd/ndpgen/testdata/meta_client_expected.rs b/plugins/cmd/ndpgen/testdata/meta_client_expected.rs deleted file mode 100644 index 79b95ffb3..000000000 --- a/plugins/cmd/ndpgen/testdata/meta_client_expected.rs +++ /dev/null @@ -1,86 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Meta host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct MetaGetRequest { - key: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct MetaGetResponse { - #[serde(default)] - value: serde_json::Value, - #[serde(default)] - error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct MetaSetRequest { - data: std::collections::HashMap, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct MetaSetResponse { - #[serde(default)] - error: Option, -} - -#[host_fn] -extern "ExtismHost" { - fn meta_get(input: Json) -> Json; - fn meta_set(input: Json) -> Json; -} - -/// Calls the meta_get host function. -/// -/// # Arguments -/// * `key` - String parameter. -/// -/// # Returns -/// The value value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get(key: &str) -> Result { - let response = unsafe { - meta_get(Json(MetaGetRequest { - key: key.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.value) -} - -/// Calls the meta_set host function. -/// -/// # Arguments -/// * `data` - std::collections::HashMap parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn set(data: std::collections::HashMap) -> Result<(), Error> { - let response = unsafe { - meta_set(Json(MetaSetRequest { - data: data, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} diff --git a/plugins/cmd/ndpgen/testdata/meta_expected.go.txt b/plugins/cmd/ndpgen/testdata/meta_expected.go.txt deleted file mode 100644 index 6f660fd65..000000000 --- a/plugins/cmd/ndpgen/testdata/meta_expected.go.txt +++ /dev/null @@ -1,130 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package testpkg - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// MetaGetRequest is the request type for Meta.Get. -type MetaGetRequest struct { - Key string `json:"key"` -} - -// MetaGetResponse is the response type for Meta.Get. -type MetaGetResponse struct { - Value any `json:"value,omitempty"` - Error string `json:"error,omitempty"` -} - -// MetaSetRequest is the request type for Meta.Set. -type MetaSetRequest struct { - Data map[string]any `json:"data"` -} - -// MetaSetResponse is the response type for Meta.Set. -type MetaSetResponse struct { - Error string `json:"error,omitempty"` -} - -// RegisterMetaHostFunctions registers Meta service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterMetaHostFunctions(service MetaService) []extism.HostFunction { - return []extism.HostFunction{ - newMetaGetHostFunction(service), - newMetaSetHostFunction(service), - } -} - -func newMetaGetHostFunction(service MetaService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "meta_get", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - metaWriteError(p, stack, err) - return - } - var req MetaGetRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - metaWriteError(p, stack, err) - return - } - - // Call the service method - value, svcErr := service.Get(ctx, req.Key) - if svcErr != nil { - metaWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := MetaGetResponse{ - Value: value, - } - metaWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newMetaSetHostFunction(service MetaService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "meta_set", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - metaWriteError(p, stack, err) - return - } - var req MetaSetRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - metaWriteError(p, stack, err) - return - } - - // Call the service method - if svcErr := service.Set(ctx, req.Data); svcErr != nil { - metaWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := MetaSetResponse{} - metaWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// metaWriteResponse writes a JSON response to plugin memory. -func metaWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - metaWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// metaWriteError writes an error response to plugin memory. -func metaWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/cmd/ndpgen/testdata/meta_service.go.txt b/plugins/cmd/ndpgen/testdata/meta_service.go.txt deleted file mode 100644 index a7b23ecea..000000000 --- a/plugins/cmd/ndpgen/testdata/meta_service.go.txt +++ /dev/null @@ -1,11 +0,0 @@ -package testpkg - -import "context" - -//nd:hostservice name=Meta permission=meta -type MetaService interface { - //nd:hostfunc - Get(ctx context.Context, key string) (value interface{}, err error) - //nd:hostfunc - Set(ctx context.Context, data map[string]any) error -} diff --git a/plugins/cmd/ndpgen/testdata/ping_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/ping_client_expected.go.txt deleted file mode 100644 index be9668314..000000000 --- a/plugins/cmd/ndpgen/testdata/ping_client_expected.go.txt +++ /dev/null @@ -1,46 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Ping host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package ndhost - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// ping_ping is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user ping_ping -func ping_ping(uint64) uint64 - -// PingPing calls the ping_ping host function. -func PingPing() error { - // No parameters - allocate empty JSON object - reqMem := pdk.AllocateBytes([]byte("{}")) - defer reqMem.Free() - - // Call the host function - responsePtr := ping_ping(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} diff --git a/plugins/cmd/ndpgen/testdata/ping_client_expected.rs b/plugins/cmd/ndpgen/testdata/ping_client_expected.rs deleted file mode 100644 index a40f10843..000000000 --- a/plugins/cmd/ndpgen/testdata/ping_client_expected.rs +++ /dev/null @@ -1,35 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Ping host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct PingPingResponse { - #[serde(default)] - error: Option, -} - -#[host_fn] -extern "ExtismHost" { - fn ping_ping(input: Json) -> Json; -} - -/// Calls the ping_ping host function. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn ping() -> Result<(), Error> { - let response = unsafe { - ping_ping(Json(serde_json::json!({})))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} diff --git a/plugins/cmd/ndpgen/testdata/ping_expected.go.txt b/plugins/cmd/ndpgen/testdata/ping_expected.go.txt deleted file mode 100644 index 0b0253817..000000000 --- a/plugins/cmd/ndpgen/testdata/ping_expected.go.txt +++ /dev/null @@ -1,68 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package testpkg - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// PingPingResponse is the response type for Ping.Ping. -type PingPingResponse struct { - Error string `json:"error,omitempty"` -} - -// RegisterPingHostFunctions registers Ping service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterPingHostFunctions(service PingService) []extism.HostFunction { - return []extism.HostFunction{ - newPingPingHostFunction(service), - } -} - -func newPingPingHostFunction(service PingService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "ping_ping", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - - // Call the service method - if svcErr := service.Ping(ctx); svcErr != nil { - pingWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := PingPingResponse{} - pingWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// pingWriteResponse writes a JSON response to plugin memory. -func pingWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - pingWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// pingWriteError writes an error response to plugin memory. -func pingWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/cmd/ndpgen/testdata/ping_service.go.txt b/plugins/cmd/ndpgen/testdata/ping_service.go.txt deleted file mode 100644 index c6bd1f489..000000000 --- a/plugins/cmd/ndpgen/testdata/ping_service.go.txt +++ /dev/null @@ -1,9 +0,0 @@ -package testpkg - -import "context" - -//nd:hostservice name=Ping permission=ping -type PingService interface { - //nd:hostfunc - Ping(ctx context.Context) error -} diff --git a/plugins/cmd/ndpgen/testdata/search_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/search_client_expected.go.txt deleted file mode 100644 index 6ea002cf3..000000000 --- a/plugins/cmd/ndpgen/testdata/search_client_expected.go.txt +++ /dev/null @@ -1,69 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Search host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package ndhost - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// Result represents the Result data structure. -type Result struct { - ID string `json:"id"` -} - -// search_find is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user search_find -func search_find(uint64) uint64 - -type searchFindRequest struct { - Query string `json:"query"` -} - -type searchFindResponse struct { - Results []Result `json:"results,omitempty"` - Total int32 `json:"total,omitempty"` - Error string `json:"error,omitempty"` -} - -// SearchFind calls the search_find host function. -func SearchFind(query string) ([]Result, int32, error) { - // Marshal request to JSON - req := searchFindRequest{ - Query: query, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, 0, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := search_find(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response searchFindResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, 0, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, 0, errors.New(response.Error) - } - - return response.Results, response.Total, nil -} diff --git a/plugins/cmd/ndpgen/testdata/search_client_expected.rs b/plugins/cmd/ndpgen/testdata/search_client_expected.rs deleted file mode 100644 index 396a22b4e..000000000 --- a/plugins/cmd/ndpgen/testdata/search_client_expected.rs +++ /dev/null @@ -1,59 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Search host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Result { - pub id: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SearchFindRequest { - query: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SearchFindResponse { - #[serde(default)] - results: Vec, - #[serde(default)] - total: i32, - #[serde(default)] - error: Option, -} - -#[host_fn] -extern "ExtismHost" { - fn search_find(input: Json) -> Json; -} - -/// Calls the search_find host function. -/// -/// # Arguments -/// * `query` - String parameter. -/// -/// # Returns -/// A tuple of (results, total). -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn find(query: &str) -> Result<(Vec, i32), Error> { - let response = unsafe { - search_find(Json(SearchFindRequest { - query: query.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok((response.0.results, response.0.total)) -} diff --git a/plugins/cmd/ndpgen/testdata/search_expected.go.txt b/plugins/cmd/ndpgen/testdata/search_expected.go.txt deleted file mode 100644 index 6c316266f..000000000 --- a/plugins/cmd/ndpgen/testdata/search_expected.go.txt +++ /dev/null @@ -1,90 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package testpkg - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// SearchFindRequest is the request type for Search.Find. -type SearchFindRequest struct { - Query string `json:"query"` -} - -// SearchFindResponse is the response type for Search.Find. -type SearchFindResponse struct { - Results []Result `json:"results,omitempty"` - Total int32 `json:"total,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterSearchHostFunctions registers Search service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterSearchHostFunctions(service SearchService) []extism.HostFunction { - return []extism.HostFunction{ - newSearchFindHostFunction(service), - } -} - -func newSearchFindHostFunction(service SearchService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "search_find", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - searchWriteError(p, stack, err) - return - } - var req SearchFindRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - searchWriteError(p, stack, err) - return - } - - // Call the service method - results, total, svcErr := service.Find(ctx, req.Query) - if svcErr != nil { - searchWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := SearchFindResponse{ - Results: results, - Total: total, - } - searchWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// searchWriteResponse writes a JSON response to plugin memory. -func searchWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - searchWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// searchWriteError writes an error response to plugin memory. -func searchWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/cmd/ndpgen/testdata/search_service.go.txt b/plugins/cmd/ndpgen/testdata/search_service.go.txt deleted file mode 100644 index 03a081966..000000000 --- a/plugins/cmd/ndpgen/testdata/search_service.go.txt +++ /dev/null @@ -1,13 +0,0 @@ -package testpkg - -import "context" - -type Result struct { - ID string -} - -//nd:hostservice name=Search permission=search -type SearchService interface { - //nd:hostfunc - Find(ctx context.Context, query string) (results []Result, total int32, err error) -} diff --git a/plugins/cmd/ndpgen/testdata/store_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/store_client_expected.go.txt deleted file mode 100644 index 89618026c..000000000 --- a/plugins/cmd/ndpgen/testdata/store_client_expected.go.txt +++ /dev/null @@ -1,69 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Store host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package ndhost - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// Item represents the Item data structure. -type Item struct { - ID string `json:"id"` - Name string `json:"name"` -} - -// store_save is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user store_save -func store_save(uint64) uint64 - -type storeSaveRequest struct { - Item Item `json:"item"` -} - -type storeSaveResponse struct { - Id string `json:"id,omitempty"` - Error string `json:"error,omitempty"` -} - -// StoreSave calls the store_save host function. -func StoreSave(item Item) (string, error) { - // Marshal request to JSON - req := storeSaveRequest{ - Item: item, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := store_save(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response storeSaveResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "", err - } - - // Convert Error field to Go error - if response.Error != "" { - return "", errors.New(response.Error) - } - - return response.Id, nil -} diff --git a/plugins/cmd/ndpgen/testdata/store_client_expected.rs b/plugins/cmd/ndpgen/testdata/store_client_expected.rs deleted file mode 100644 index 3b9d5ac17..000000000 --- a/plugins/cmd/ndpgen/testdata/store_client_expected.rs +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Store host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Item { - pub id: String, - pub name: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct StoreSaveRequest { - item: Item, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct StoreSaveResponse { - #[serde(default)] - id: String, - #[serde(default)] - error: Option, -} - -#[host_fn] -extern "ExtismHost" { - fn store_save(input: Json) -> Json; -} - -/// Calls the store_save host function. -/// -/// # Arguments -/// * `item` - Item parameter. -/// -/// # Returns -/// The id value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn save(item: Item) -> Result { - let response = unsafe { - store_save(Json(StoreSaveRequest { - item: item, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.id) -} diff --git a/plugins/cmd/ndpgen/testdata/store_expected.go.txt b/plugins/cmd/ndpgen/testdata/store_expected.go.txt deleted file mode 100644 index 07537ca41..000000000 --- a/plugins/cmd/ndpgen/testdata/store_expected.go.txt +++ /dev/null @@ -1,88 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package testpkg - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// StoreSaveRequest is the request type for Store.Save. -type StoreSaveRequest struct { - Item Item `json:"item"` -} - -// StoreSaveResponse is the response type for Store.Save. -type StoreSaveResponse struct { - Id string `json:"id,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterStoreHostFunctions registers Store service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterStoreHostFunctions(service StoreService) []extism.HostFunction { - return []extism.HostFunction{ - newStoreSaveHostFunction(service), - } -} - -func newStoreSaveHostFunction(service StoreService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "store_save", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - storeWriteError(p, stack, err) - return - } - var req StoreSaveRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - storeWriteError(p, stack, err) - return - } - - // Call the service method - id, svcErr := service.Save(ctx, req.Item) - if svcErr != nil { - storeWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := StoreSaveResponse{ - Id: id, - } - storeWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// storeWriteResponse writes a JSON response to plugin memory. -func storeWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - storeWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// storeWriteError writes an error response to plugin memory. -func storeWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/cmd/ndpgen/testdata/store_service.go.txt b/plugins/cmd/ndpgen/testdata/store_service.go.txt deleted file mode 100644 index c2ff69740..000000000 --- a/plugins/cmd/ndpgen/testdata/store_service.go.txt +++ /dev/null @@ -1,14 +0,0 @@ -package testpkg - -import "context" - -type Item struct { - ID string - Name string -} - -//nd:hostservice name=Store permission=store -type StoreService interface { - //nd:hostfunc - Save(ctx context.Context, item Item) (id string, err error) -} diff --git a/plugins/cmd/ndpgen/testdata/users_client_expected.go.txt b/plugins/cmd/ndpgen/testdata/users_client_expected.go.txt deleted file mode 100644 index ddd71f3dc..000000000 --- a/plugins/cmd/ndpgen/testdata/users_client_expected.go.txt +++ /dev/null @@ -1,71 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Users host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package ndhost - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// User represents the User data structure. -type User struct { - ID string `json:"id"` - Name string `json:"name"` -} - -// users_get is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user users_get -func users_get(uint64) uint64 - -type usersGetRequest struct { - Id *string `json:"id"` - Filter *User `json:"filter"` -} - -type usersGetResponse struct { - Result *User `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// UsersGet calls the users_get host function. -func UsersGet(id *string, filter *User) (*User, error) { - // Marshal request to JSON - req := usersGetRequest{ - Id: id, - Filter: filter, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := users_get(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response usersGetResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Result, nil -} diff --git a/plugins/cmd/ndpgen/testdata/users_client_expected.rs b/plugins/cmd/ndpgen/testdata/users_client_expected.rs deleted file mode 100644 index 4bac43ce8..000000000 --- a/plugins/cmd/ndpgen/testdata/users_client_expected.rs +++ /dev/null @@ -1,61 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Users host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct User { - pub id: String, - pub name: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct UsersGetRequest { - id: Option, - filter: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct UsersGetResponse { - #[serde(default)] - result: Option, - #[serde(default)] - error: Option, -} - -#[host_fn] -extern "ExtismHost" { - fn users_get(input: Json) -> Json; -} - -/// Calls the users_get host function. -/// -/// # Arguments -/// * `id` - Option parameter. -/// * `filter` - Option parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get(id: Option, filter: Option) -> Result, Error> { - let response = unsafe { - users_get(Json(UsersGetRequest { - id: id, - filter: filter, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} diff --git a/plugins/cmd/ndpgen/testdata/users_expected.go.txt b/plugins/cmd/ndpgen/testdata/users_expected.go.txt deleted file mode 100644 index 1b0fbfa93..000000000 --- a/plugins/cmd/ndpgen/testdata/users_expected.go.txt +++ /dev/null @@ -1,89 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package testpkg - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// UsersGetRequest is the request type for Users.Get. -type UsersGetRequest struct { - Id *string `json:"id"` - Filter *User `json:"filter"` -} - -// UsersGetResponse is the response type for Users.Get. -type UsersGetResponse struct { - Result *User `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterUsersHostFunctions registers Users service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterUsersHostFunctions(service UsersService) []extism.HostFunction { - return []extism.HostFunction{ - newUsersGetHostFunction(service), - } -} - -func newUsersGetHostFunction(service UsersService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "users_get", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - usersWriteError(p, stack, err) - return - } - var req UsersGetRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - usersWriteError(p, stack, err) - return - } - - // Call the service method - result, svcErr := service.Get(ctx, req.Id, req.Filter) - if svcErr != nil { - usersWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := UsersGetResponse{ - Result: result, - } - usersWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// usersWriteResponse writes a JSON response to plugin memory. -func usersWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - usersWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// usersWriteError writes an error response to plugin memory. -func usersWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/cmd/ndpgen/testdata/users_service.go.txt b/plugins/cmd/ndpgen/testdata/users_service.go.txt deleted file mode 100644 index cb8db8c12..000000000 --- a/plugins/cmd/ndpgen/testdata/users_service.go.txt +++ /dev/null @@ -1,14 +0,0 @@ -package testpkg - -import "context" - -type User struct { - ID string - Name string -} - -//nd:hostservice name=Users permission=users -type UsersService interface { - //nd:hostfunc - Get(ctx context.Context, id *string, filter *User) (*User, error) -} diff --git a/plugins/cmd/ndpgen/tools.go b/plugins/cmd/ndpgen/tools.go deleted file mode 100644 index 961d4e805..000000000 --- a/plugins/cmd/ndpgen/tools.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build tools - -// This file ensures the extism/go-pdk dependency stays in go.mod. -// The PDK parser loads this package at runtime using go/packages. -// Without this import, `go mod tidy` would remove it since it's not directly imported elsewhere. -package main - -import _ "github.com/extism/go-pdk" diff --git a/plugins/config_validation.go b/plugins/config_validation.go deleted file mode 100644 index d75f9ee7c..000000000 --- a/plugins/config_validation.go +++ /dev/null @@ -1,129 +0,0 @@ -package plugins - -import ( - "encoding/json" - "errors" - "fmt" - "strings" - - "github.com/santhosh-tekuri/jsonschema/v6" -) - -// ConfigValidationError represents a validation error with field path and message. -type ConfigValidationError struct { - Field string `json:"field"` - Message string `json:"message"` -} - -// ConfigValidationErrors is a collection of validation errors. -type ConfigValidationErrors struct { - Errors []ConfigValidationError `json:"errors"` -} - -func (e *ConfigValidationErrors) Error() string { - if len(e.Errors) == 0 { - return "validation failed" - } - var msgs []string - for _, err := range e.Errors { - if err.Field != "" { - msgs = append(msgs, fmt.Sprintf("%s: %s", err.Field, err.Message)) - } else { - msgs = append(msgs, err.Message) - } - } - return strings.Join(msgs, "; ") -} - -// ValidateConfig validates a config JSON string against a plugin's config schema. -// If the manifest has no config schema, it returns an error indicating the plugin -// has no configurable options. -// Returns nil if validation passes, ConfigValidationErrors if validation fails. -func ValidateConfig(manifest *Manifest, configJSON string) error { - // If no config schema defined, plugin has no configurable options - if !manifest.HasConfigSchema() { - return fmt.Errorf("plugin has no configurable options") - } - - // Parse the config JSON (empty string treated as empty object) - var configData any - if configJSON == "" { - configData = map[string]any{} - } else { - if err := json.Unmarshal([]byte(configJSON), &configData); err != nil { - return &ConfigValidationErrors{ - Errors: []ConfigValidationError{{ - Message: fmt.Sprintf("invalid JSON: %v", err), - }}, - } - } - } - - // Compile the schema - compiler := jsonschema.NewCompiler() - if err := compiler.AddResource("schema.json", manifest.Config.Schema); err != nil { - return fmt.Errorf("adding schema resource: %w", err) - } - - schema, err := compiler.Compile("schema.json") - if err != nil { - return fmt.Errorf("compiling schema: %w", err) - } - - // Validate config against schema - if err := schema.Validate(configData); err != nil { - return convertValidationError(err) - } - - return nil -} - -// convertValidationError converts jsonschema validation errors to our format. -func convertValidationError(err error) *ConfigValidationErrors { - var validationErr *jsonschema.ValidationError - if !errors.As(err, &validationErr) { - return &ConfigValidationErrors{ - Errors: []ConfigValidationError{{ - Message: err.Error(), - }}, - } - } - - var configErrors []ConfigValidationError - collectErrors(validationErr, &configErrors) - - if len(configErrors) == 0 { - configErrors = append(configErrors, ConfigValidationError{ - Message: validationErr.Error(), - }) - } - - return &ConfigValidationErrors{Errors: configErrors} -} - -// collectErrors recursively collects validation errors from the error tree. -func collectErrors(err *jsonschema.ValidationError, errors *[]ConfigValidationError) { - // If there are child errors, collect from them - if len(err.Causes) > 0 { - for _, cause := range err.Causes { - collectErrors(cause, errors) - } - return - } - - // Leaf error - add it - field := "" - if len(err.InstanceLocation) > 0 { - field = strings.Join(err.InstanceLocation, "/") - } - - *errors = append(*errors, ConfigValidationError{ - Field: field, - Message: err.Error(), - }) -} - -// HasConfigSchema returns true if the manifest defines a config schema. -func (m *Manifest) HasConfigSchema() bool { - return m.Config != nil && m.Config.Schema != nil -} diff --git a/plugins/config_validation_test.go b/plugins/config_validation_test.go deleted file mode 100644 index b430c0b31..000000000 --- a/plugins/config_validation_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package plugins - -import ( - "errors" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Config Validation", func() { - Describe("ValidateConfig", func() { - Context("when manifest has no config schema", func() { - It("returns an error", func() { - manifest := &Manifest{ - Name: "test", - Author: "test", - Version: "1.0.0", - } - err := ValidateConfig(manifest, `{"key": "value"}`) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("no configurable options")) - }) - }) - - Context("when manifest has config schema", func() { - var manifest *Manifest - - BeforeEach(func() { - manifest = &Manifest{ - Name: "test", - Author: "test", - Version: "1.0.0", - Config: &ConfigDefinition{ - Schema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "apiKey": map[string]any{ - "type": "string", - "description": "API key for the service", - "minLength": float64(1), - }, - "timeout": map[string]any{ - "type": "integer", - "minimum": float64(1), - "maximum": float64(300), - }, - "enabled": map[string]any{ - "type": "boolean", - }, - }, - "required": []any{"apiKey"}, - }, - }, - } - }) - - It("accepts valid config", func() { - err := ValidateConfig(manifest, `{"apiKey": "secret123", "timeout": 30}`) - Expect(err).ToNot(HaveOccurred()) - }) - - It("rejects empty config when required fields are missing", func() { - err := ValidateConfig(manifest, "") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("apiKey")) - - err = ValidateConfig(manifest, "{}") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("apiKey")) - }) - - It("rejects config missing required field", func() { - err := ValidateConfig(manifest, `{"timeout": 30}`) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("apiKey")) - }) - - It("rejects config with wrong type", func() { - err := ValidateConfig(manifest, `{"apiKey": "secret", "timeout": "not a number"}`) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("timeout")) - }) - - It("rejects config with value out of range", func() { - err := ValidateConfig(manifest, `{"apiKey": "secret", "timeout": 500}`) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("timeout")) - }) - - It("rejects config with empty required string", func() { - err := ValidateConfig(manifest, `{"apiKey": ""}`) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("apiKey")) - }) - - It("rejects invalid JSON", func() { - err := ValidateConfig(manifest, `{invalid json}`) - Expect(err).To(HaveOccurred()) - var validationErr *ConfigValidationErrors - Expect(errors.As(err, &validationErr)).To(BeTrue()) - Expect(validationErr.Errors[0].Message).To(ContainSubstring("invalid JSON")) - }) - }) - - Context("with enum values", func() { - It("accepts valid enum value", func() { - manifest := &Manifest{ - Name: "test", - Author: "test", - Version: "1.0.0", - Config: &ConfigDefinition{ - Schema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "logLevel": map[string]any{ - "type": "string", - "enum": []any{"debug", "info", "warn", "error"}, - }, - }, - }, - }, - } - err := ValidateConfig(manifest, `{"logLevel": "info"}`) - Expect(err).ToNot(HaveOccurred()) - }) - - It("rejects invalid enum value", func() { - manifest := &Manifest{ - Name: "test", - Author: "test", - Version: "1.0.0", - Config: &ConfigDefinition{ - Schema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "logLevel": map[string]any{ - "type": "string", - "enum": []any{"debug", "info", "warn", "error"}, - }, - }, - }, - }, - } - err := ValidateConfig(manifest, `{"logLevel": "verbose"}`) - Expect(err).To(HaveOccurred()) - }) - }) - }) - - Describe("HasConfigSchema", func() { - It("returns false when config is nil", func() { - manifest := &Manifest{ - Name: "test", - Author: "test", - Version: "1.0.0", - } - Expect(manifest.HasConfigSchema()).To(BeFalse()) - }) - - It("returns false when schema is nil", func() { - manifest := &Manifest{ - Name: "test", - Author: "test", - Version: "1.0.0", - Config: &ConfigDefinition{}, - } - Expect(manifest.HasConfigSchema()).To(BeFalse()) - }) - - It("returns true when schema is present", func() { - manifest := &Manifest{ - Name: "test", - Author: "test", - Version: "1.0.0", - Config: &ConfigDefinition{ - Schema: map[string]any{ - "type": "object", - }, - }, - } - Expect(manifest.HasConfigSchema()).To(BeTrue()) - }) - }) -}) diff --git a/plugins/examples/Makefile b/plugins/examples/Makefile deleted file mode 100644 index c8a62ab0c..000000000 --- a/plugins/examples/Makefile +++ /dev/null @@ -1,98 +0,0 @@ -# Build example plugins for Navidrome -# Auto-discover all plugin folders (folders containing go.mod) -PLUGINS := $(patsubst %/go.mod,%,$(wildcard */go.mod)) - -# Auto-discover Python plugins (folders containing plugin/__init__.py) -PYTHON_PLUGINS := $(patsubst %/plugin/__init__.py,%,$(wildcard */plugin/__init__.py)) - -# Auto-discover Rust plugins (folders containing Cargo.toml) -RUST_PLUGINS := $(patsubst %/Cargo.toml,%,$(wildcard */Cargo.toml)) - -# Prefer tinygo if available, it produces smaller wasm binaries. -TINYGO := $(shell command -v tinygo 2> /dev/null) -EXTISM_PY := $(shell command -v extism-py 2> /dev/null) - -# PDK source files that trigger rebuild when changed (recursive) -PDK_GO_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null) -PDK_PY_SOURCES := $(shell find ../pdk/python -name '*.py' 2>/dev/null) -PDK_RS_SOURCES := $(shell find ../pdk/rust -name '*.rs' 2>/dev/null) - -# Allow building plugins without .ndp extension (e.g., make minimal instead of make minimal.ndp) -.PHONY: $(PLUGINS) $(PYTHON_PLUGINS) $(RUST_PLUGINS) -$(PLUGINS): %: %.ndp -$(PYTHON_PLUGINS): %: %.ndp -$(RUST_PLUGINS): %: %.ndp - -# Default target: show available plugins -.DEFAULT_GOAL := help - -help: - @echo "Available Go plugins:" - @$(foreach p,$(PLUGINS),echo " $(p)";) - @echo "" - @echo "Available Python plugins:" - @$(foreach p,$(PYTHON_PLUGINS),echo " $(p)";) - @echo "" - @echo "Available Rust plugins:" - @$(foreach p,$(RUST_PLUGINS),echo " $(p)";) - @echo "" - @echo "Usage:" - @echo " make Build a specific plugin (e.g., make $(firstword $(PLUGINS)))" - @echo " make all Build all plugins" - @echo " make all-go Build all Go plugins" - @echo " make all-python Build all Python plugins (requires extism-py)" - @echo " make all-rust Build all Rust plugins (requires cargo)" - @echo " make clean Remove all built plugins (.ndp and .wasm files)" - -all: all-go all-python all-rust - -all-go: $(PLUGINS:%=%.ndp) - -all-python: $(PYTHON_PLUGINS:%=%.ndp) - -all-rust: $(RUST_PLUGINS:%=%.ndp) - -clean: - rm -f $(PLUGINS:%=%.ndp) $(PYTHON_PLUGINS:%=%.ndp) $(RUST_PLUGINS:%=%.ndp) - rm -f $(PLUGINS:%=%.wasm) $(PYTHON_PLUGINS:%=%.wasm) $(RUST_PLUGINS:%=%.wasm) - @$(foreach p,$(RUST_PLUGINS),(cd $(p) && cargo clean 2>/dev/null) || true;) - -# Mark .wasm files as intermediate so Make deletes them after building .ndp -.INTERMEDIATE: $(PLUGINS:%=%.wasm) $(PYTHON_PLUGINS:%=%.wasm) $(RUST_PLUGINS:%=%.wasm) - -# Build .ndp package from .wasm and manifest.json -# Go plugins -%.ndp: %.wasm %/manifest.json - @rm -f $@ - @cp $< plugin.wasm - zip -j $@ $*/manifest.json plugin.wasm - @rm -f plugin.wasm - @mv $< $<.tmp && mv $<.tmp $< # Touch wasm to ensure it's older than ndp - -# Use secondary expansion to properly track all Go source files -.SECONDEXPANSION: -$(PLUGINS:%=%.wasm): %.wasm: $$(shell find % -name '*.go' 2>/dev/null) %/go.mod $(PDK_GO_SOURCES) -ifdef TINYGO - cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ . -else - cd $* && GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o ../$@ . -endif - -# Python plugin builds (generic rule for any folder with plugin/__init__.py) -# Use secondary expansion to get all .py files in the plugin directory as dependencies -.SECONDEXPANSION: -$(PYTHON_PLUGINS:%=%.wasm): %.wasm: $$(wildcard %/plugin/*.py) $(PDK_PY_SOURCES) -ifndef EXTISM_PY - $(error extism-py is not installed. Install from https://github.com/extism/python-pdk) -endif - cd $* && PYTHONPATH=plugin extism-py plugin/__init__.py -o ../$@ - -# Rust plugin builds (generic rule for any folder with Cargo.toml) -# Note: Rust crate names use underscores, but plugin names use hyphens -# All Rust plugins use wasm32-wasip1 for WASI support (filesystem, etc.) -RUST_TARGET := wasm32-wasip1 -RUSTUP_CARGO := $(shell rustup which cargo 2>/dev/null || echo cargo) -RUSTUP_RUSTC := $(shell rustup which rustc 2>/dev/null) -$(RUST_PLUGINS:%=%.wasm): %.wasm: %/Cargo.toml $$(wildcard %/src/*.rs) $(PDK_RS_SOURCES) - cd $* && CARGO_BUILD_RUSTC=$(RUSTUP_RUSTC) $(RUSTUP_CARGO) build --release --target $(RUST_TARGET) - cp $*/target/$(RUST_TARGET)/release/$(subst -,_,$*).wasm $@ diff --git a/plugins/examples/README.md b/plugins/examples/README.md deleted file mode 100644 index 8e2dc5a85..000000000 --- a/plugins/examples/README.md +++ /dev/null @@ -1,180 +0,0 @@ -# Navidrome Plugin Examples - -This folder contains example plugins demonstrating various capabilities and languages supported by Navidrome's plugin system. - -## Available Examples - -| Plugin | Language | Capabilities | Description | -|-------------------------------------------------------|----------|-------------------------------------------------|--------------------------------| -| [minimal](minimal/) | Go | MetadataAgent | Basic plugin structure | -| [wikimedia](wikimedia/) | Go | MetadataAgent | Wikidata/Wikipedia metadata | -| [crypto-ticker](crypto-ticker/) | Go | Scheduler, WebSocket, Cache | Real-time crypto prices (demo) | -| [coverartarchive-py](coverartarchive-py/) | Python | MetadataAgent | Cover Art Archive | -| [nowplaying-py](nowplaying-py/) | Python | Scheduler, SubsonicAPI | Now playing logger | -| [webhook-rs](webhook-rs/) | Rust | Scrobbler | HTTP webhook on scrobble | -| [library-inspector-rs](library-inspector-rs/) | Rust | Library, Scheduler | Periodic library stats logging | -| [discord-rich-presence-rs](discord-rich-presence-rs/) | Rust | Scrobbler, Scheduler, WebSocket, Cache, Artwork | Discord integration (Rust) | - -## Building - -### Prerequisites - -- **Go plugins:** [TinyGo](https://tinygo.org/getting-started/install/) 0.30+ -- **Python plugins:** [extism-py](https://github.com/extism/python-pdk) -- **Rust plugins:** [Rust](https://rustup.rs/) with `wasm32-unknown-unknown` target - -### Build All Plugins - -```bash -make all -``` - -This creates `.ndp` package files for each plugin. - -### Build Individual Plugin - -```bash -make minimal.ndp -make wikimedia.ndp -make discord-rich-presence-rs.ndp -``` - -### Clean - -```bash -make clean -``` - -## Testing Plugins - -### With Extism CLI - -Test any plugin without running Navidrome. First extract the `.wasm` file from the `.ndp` package: - -```bash -# Install: https://extism.org/docs/install - -# Extract the wasm file from the package -unzip -p minimal.ndp plugin.wasm > minimal.wasm - -# Test a capability function -extism call minimal.wasm nd_get_artist_biography --wasi \ - --input '{"id":"1","name":"The Beatles"}' -``` - -For plugins that make HTTP requests, allow the hosts: - -```bash -unzip -p wikimedia.ndp plugin.wasm > wikimedia.wasm -extism call wikimedia.wasm nd_get_artist_biography --wasi \ - --input '{"id":"1","name":"Yussef Dayes"}' \ - --allow-host "query.wikidata.org" \ - --allow-host "en.wikipedia.org" -``` - -### With Navidrome - -1. Copy the `.ndp` file to your plugins folder -2. Enable plugins in `navidrome.toml`: - ```toml - [Plugins] - Enabled = true - Folder = "/path/to/plugins" - ``` -3. For metadata agents, add to your agents list: - ```toml - Agents = "lastfm,spotify,wikimedia" - ``` - -## Creating Your Own Plugin - -### Option 1: Start from Minimal - -Copy the [minimal](minimal/) example and modify: - -```bash -cp -r minimal my-plugin -cd my-plugin -# Edit main.go and manifest.json -tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared . -zip -j my-plugin.ndp manifest.json plugin.wasm -``` - -### Option 2: Bootstrap with XTP CLI - -Generate boilerplate from a schema: - -```bash -# Install XTP: https://docs.xtp.dylibso.com/docs/cli - -xtp plugin init \ - --schema-file ../capabilities/metadata_agent.yaml \ - --template go \ - --path ./my-plugin \ - --name my-plugin - -# Then create manifest.json and package -cd my-plugin -xtp plugin build -zip -j my-plugin.ndp manifest.json dist/plugin.wasm -``` - -Available schemas in [../capabilities/](../capabilities/): -- `metadata_agent.yaml` – Artist/album metadata -- `scrobbler.yaml` – Scrobbling integration -- `lifecycle.yaml` – Init callbacks -- `scheduler_callback.yaml` – Scheduled tasks -- `websocket_callback.yaml` – WebSocket events - -### Option 3: Different Language - -See language-specific examples: -- **Python:** [coverartarchive-py](coverartarchive-py/) -- **Rust:** [webhook-rs](webhook-rs/) - -## Example Breakdown - -### Minimal (Go) - -The simplest possible plugin. Shows: -- Manifest export -- Single capability function -- Basic input/output handling - -### Wikimedia (Go) - -Real-world metadata agent. Shows: -- HTTP requests to external APIs -- SPARQL queries (Wikidata) -- Error handling -- Host allowlisting - -### Discord Rich Presence (Go) - -Complex multi-capability plugin. Shows: -- **Scrobbler** – Receives play events -- **WebSocket** – Maintains Discord gateway connection -- **Scheduler** – Heartbeat and timeout management -- **Cache** – Connection state storage -- **Artwork** – Getting album art URLs - -### Cover Art Archive (Python) - -Python metadata agent. Shows: -- extism-py plugin structure -- HTTP requests -- JSON handling - -### Webhook (Rust) - -Rust scrobbler. Shows: -- extism-rs plugin structure -- HTTP POST requests -- Minimal dependencies - -## Resources - -- [Plugin System Documentation](../README.md) -- [Extism PDK Docs](https://extism.org/docs/concepts/pdk) -- [TinyGo WebAssembly](https://tinygo.org/docs/guides/webassembly/) -- [XTP CLI](https://docs.xtp.dylibso.com/docs/cli) diff --git a/plugins/examples/coverartarchive-py/Makefile b/plugins/examples/coverartarchive-py/Makefile deleted file mode 100644 index c47c242d7..000000000 --- a/plugins/examples/coverartarchive-py/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -# Build the Cover Art Archive Python plugin -.PHONY: build clean - -WASM_FILE = coverartarchive-py.wasm - -build: $(WASM_FILE) - -$(WASM_FILE): plugin/__init__.py - extism-py plugin/__init__.py -o $(WASM_FILE) - -clean: - rm -f $(WASM_FILE) diff --git a/plugins/examples/coverartarchive-py/README.md b/plugins/examples/coverartarchive-py/README.md deleted file mode 100644 index c779f4a43..000000000 --- a/plugins/examples/coverartarchive-py/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# Cover Art Archive Plugin (Python) - -A Python example plugin that fetches album cover images from the [Cover Art Archive](https://coverartarchive.org/) API using the MusicBrainz Release MBID. - -## Features - -- Implements the `nd_get_album_images` method of the MetadataAgent plugin interface -- Returns front cover images for a given release MBID -- Returns `not found` if no MBID is provided or no images are found -- Demonstrates Python plugin development for Navidrome - -## Prerequisites - -- [extism-py](https://github.com/extism/python-pdk) - Python PDK compiler - ```bash - curl -Ls https://raw.githubusercontent.com/extism/python-pdk/main/install.sh | bash - ``` - -> **Note:** `extism-py` requires [Binaryen](https://github.com/WebAssembly/binaryen/) (`wasm-merge`, `wasm-opt`) to be installed. - -## Building - -From the `plugins/examples` directory: - -```bash -make coverartarchive-py.ndp -``` - -Or directly: - -```bash -extism-py plugin/__init__.py -o plugin.wasm -zip -j coverartarchive-py.ndp manifest.json plugin.wasm -``` - -## Installation - -1. Copy `coverartarchive-py.ndp` to your Navidrome plugins folder - -2. Enable plugins in `navidrome.toml`: - ```toml - [Plugins] - Enabled = true - Folder = "/path/to/plugins" - ``` - -3. Add to your agents list: - ```toml - Agents = "coverartarchive-py,spotify,lastfm" - ``` - -## Testing - -The plugin makes HTTP requests through Navidrome's `http_send` host function, so it only runs inside Navidrome (the `extism` CLI can't provide that function). Install the `.ndp` as described above, then open an album that has a MusicBrainz ID and check the Navidrome logs. - -## How It Works - -1. **Album Image Request (`nd_get_album_images`)**: Receives album metadata including the MusicBrainz Release MBID. - -2. **API Query**: Fetches cover art metadata from `https://coverartarchive.org/release/{mbid}`. - -3. **Response**: Returns the front cover image URL if found. - -## API Reference - -- [Cover Art Archive API](https://musicbrainz.org/doc/Cover_Art_Archive/API) diff --git a/plugins/examples/coverartarchive-py/manifest.json b/plugins/examples/coverartarchive-py/manifest.json deleted file mode 100644 index c9a52ba07..000000000 --- a/plugins/examples/coverartarchive-py/manifest.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "Cover Art Archive (Python)", - "author": "Navidrome", - "version": "1.0.0", - "description": "Album cover art from the Cover Art Archive - Python example", - "website": "https://coverartarchive.org", - "permissions": { - "http": { - "reason": "Fetch album cover art from Cover Art Archive API", - "requiredHosts": [ - "coverartarchive.org", - "*.archive.org" - ] - } - } -} diff --git a/plugins/examples/coverartarchive-py/plugin/__init__.py b/plugins/examples/coverartarchive-py/plugin/__init__.py deleted file mode 100644 index c4a0480b5..000000000 --- a/plugins/examples/coverartarchive-py/plugin/__init__.py +++ /dev/null @@ -1,116 +0,0 @@ -# Cover Art Archive Plugin for Navidrome -# -# This plugin fetches album cover art from the Cover Art Archive (https://coverartarchive.org/) -# using the MusicBrainz album MBID. -# -# Build with: -# extism-py plugin/__init__.py -o coverartarchive-py.wasm - -import base64 -import extism -import json - - -@extism.import_fn("extism:host/user", "http_send") -def _http_send(offset: int) -> int: ... - - -def http_get(url): - """GET url via Navidrome's HTTP host service. Returns (status_code, body_bytes).""" - request = json.dumps({"request": {"method": "GET", "url": url}}).encode("utf-8") - response_offset = _http_send(extism.memory.alloc(request).offset) - resp = json.loads(extism.memory.string(extism.memory.find(response_offset))) - if resp.get("error"): - raise Exception(f"HTTP request failed: {resp['error']}") - result = resp["result"] - return result["statusCode"], base64.b64decode(result.get("body", "")) - - -@extism.plugin_fn -def nd_get_album_images(): - """Retrieve album cover images from Cover Art Archive.""" - input_data = extism.input_json() - mbid = input_data.get("mbid", "") - - if not mbid: - raise Exception("not found: MBID required") - - # Query Cover Art Archive API - url = f"https://coverartarchive.org/release/{mbid}" - status, body = http_get(url) - - if status != 200: - raise Exception(f"not found: CAA returned status {status}") - - try: - data = json.loads(body) - except json.JSONDecodeError: - raise Exception("not found: invalid JSON response") - - caa_images = data.get("images", []) - if not caa_images: - raise Exception("not found: no images in response") - - # Find the front cover image - front_image = find_front_image(caa_images) - if not front_image: - raise Exception("not found: no front cover image") - - # Build the response with available image sizes - images = build_image_list(front_image) - if not images: - raise Exception("not found: no usable image URLs") - - extism.output_str(json.dumps({"images": images})) - - -def find_front_image(images): - """Find the front cover image from CAA response.""" - # First, look for an image explicitly marked as front - for img in images: - if img.get("front", False): - return img - - # Second, look for an image with "Front" in types - for img in images: - types = img.get("types", []) - if "Front" in types: - return img - - # Fallback to first image - if images: - return images[0] - - return None - - -def build_image_list(img): - """Build list of images with URLs and sizes from CAA image data.""" - images = [] - thumbnails = img.get("thumbnails", {}) - - # First, try numeric sizes (250, 500, 1200, etc.) - for size_str, url in thumbnails.items(): - if not url: - continue - try: - size = int(size_str) - images.append({"url": url, "size": size}) - except ValueError: - pass # Not a numeric size - - # If no numeric sizes, fallback to named sizes - if not images: - size_map = {"large": 500, "small": 250} - for size_name, size in size_map.items(): - url = thumbnails.get(size_name) - if url: - images.append({"url": url, "size": size}) - - # If still no images, use the main image URL - if not images: - main_url = img.get("image") - if main_url: - images.append({"url": main_url, "size": 0}) - - return images diff --git a/plugins/examples/crypto-ticker/README.md b/plugins/examples/crypto-ticker/README.md deleted file mode 100644 index 7f23433f5..000000000 --- a/plugins/examples/crypto-ticker/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# Crypto Ticker Plugin - -This is a WebSocket-based WASM plugin for Navidrome that displays real-time cryptocurrency prices from Coinbase. - -## Features - -- Connects to Coinbase WebSocket API to receive real-time ticker updates -- Configurable to track multiple cryptocurrency pairs -- Implements WebSocket callback handlers for message processing -- Automatically reconnects on connection loss using the scheduler service -- Displays price, best bid, best ask, and 24-hour percentage change - -## Configuration - -Configure in the Navidrome UI (Settings → Plugins → crypto-ticker): - -| Key | Description | Default | -|-----------|----------------------------------------------------------------------|-----------| -| `tickers` | Comma-separated list of cryptocurrency symbols (e.g., `BTC,ETH,SOL`) | `BTC,ETH` | - -The plugin will append `-USD` to any symbol without a trading pair specified. - -## How it Works - -1. On plugin initialization, connects to Coinbase's WebSocket API -2. Subscribes to ticker updates for the configured cryptocurrencies -3. Incoming ticker data is processed via `nd_websocket_on_text_message` callback -4. On connection loss, schedules a reconnection attempt via the scheduler service -5. Reconnection is attempted until successful - -## Building - -To build the plugin and package as `.ndp`: - -```bash -# Using TinyGo (recommended - smaller binary) -tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared . -zip -j crypto-ticker.ndp manifest.json plugin.wasm -``` - -Or from the `plugins/examples/` directory: - -```bash -make crypto-ticker.ndp -``` - -## Installation - -Copy the resulting `crypto-ticker.ndp` to your Navidrome plugins folder. - -## Example Output - -``` -[Crypto] Crypto Ticker Plugin initializing... -[Crypto] Configured tickers: [BTC-USD ETH-USD] -[Crypto] Connected to Coinbase WebSocket API (connection: crypto-ticker-conn) -[Crypto] Subscription message sent to Coinbase WebSocket API -[Crypto] Received subscriptions message -[Crypto] 💰 BTC-USD: $98765.43 (24h: +2.35%) Bid: $98764.00 Ask: $98766.00 -[Crypto] 💰 ETH-USD: $3456.78 (24h: -0.54%) Bid: $3455.90 Ask: $3457.80 -``` - -## Permissions Required - -- **config**: Read ticker symbols configuration -- **websocket**: Connect to `ws-feed.exchange.coinbase.com` -- **scheduler**: Schedule reconnection attempts - -## Files - -- `main.go` - Main plugin implementation -- `go.mod` - Go module file - -## PDK - -This plugin imports the Navidrome PDK subpackages directly: - -```go -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/lifecycle" - "github.com/navidrome/navidrome/plugins/pdk/go/scheduler" - "github.com/navidrome/navidrome/plugins/pdk/go/websocket" -) -``` - -The `go.mod` file uses `replace` directives to point to the local packages for development. - ---- - -For more details, see the source code in `main.go`. diff --git a/plugins/examples/crypto-ticker/go.mod b/plugins/examples/crypto-ticker/go.mod deleted file mode 100755 index f2884679a..000000000 --- a/plugins/examples/crypto-ticker/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module crypto-ticker - -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/examples/crypto-ticker/go.sum b/plugins/examples/crypto-ticker/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/examples/crypto-ticker/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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/examples/crypto-ticker/main.go b/plugins/examples/crypto-ticker/main.go deleted file mode 100755 index 8fc189128..000000000 --- a/plugins/examples/crypto-ticker/main.go +++ /dev/null @@ -1,302 +0,0 @@ -// Crypto Ticker Plugin - Demonstrates WebSocket host service capabilities. -// -// This plugin connects to Coinbase's WebSocket API to receive real-time -// cryptocurrency price updates and logs them to the Navidrome console. -package main - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/lifecycle" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" - "github.com/navidrome/navidrome/plugins/pdk/go/scheduler" - "github.com/navidrome/navidrome/plugins/pdk/go/websocket" -) - -const ( - // Coinbase WebSocket API endpoint - coinbaseWSEndpoint = "wss://ws-feed.exchange.coinbase.com" - - // Connection ID for our WebSocket connection - connectionID = "crypto-ticker-conn" - - // ID for the reconnection schedule - reconnectScheduleID = "crypto-ticker-reconnect" - - // Config keys (must match manifest.json schema property names) - symbolsKey = "symbols" - reconnectDelayKey = "reconnectDelay" - logPricesKey = "logPrices" - - // Default values - defaultReconnectDelay = 5 -) - -// CoinbaseSubscription message structure -type CoinbaseSubscription struct { - Type string `json:"type"` - ProductIDs []string `json:"product_ids"` - Channels []string `json:"channels"` -} - -// CoinbaseTicker message structure -type CoinbaseTicker struct { - Type string `json:"type"` - Sequence int64 `json:"sequence"` - ProductID string `json:"product_id"` - Price string `json:"price"` - Open24h string `json:"open_24h"` - Volume24h string `json:"volume_24h"` - Low24h string `json:"low_24h"` - High24h string `json:"high_24h"` - BestBid string `json:"best_bid"` - BestAsk string `json:"best_ask"` - Time string `json:"time"` -} - -// cryptoTickerPlugin implements the lifecycle, websocket and scheduler interfaces. -type cryptoTickerPlugin struct{} - -// init registers the plugin capabilities -func init() { - lifecycle.Register(&cryptoTickerPlugin{}) - websocket.Register(&cryptoTickerPlugin{}) - scheduler.Register(&cryptoTickerPlugin{}) -} - -// Ensure cryptoTickerPlugin implements the required provider interfaces -var ( - _ lifecycle.InitProvider = (*cryptoTickerPlugin)(nil) - _ websocket.TextMessageProvider = (*cryptoTickerPlugin)(nil) - _ websocket.BinaryMessageProvider = (*cryptoTickerPlugin)(nil) - _ websocket.ErrorProvider = (*cryptoTickerPlugin)(nil) - _ websocket.CloseProvider = (*cryptoTickerPlugin)(nil) - _ scheduler.CallbackProvider = (*cryptoTickerPlugin)(nil) -) - -// OnInit is called when the plugin is loaded. -// We use this to establish the initial WebSocket connection. -func (p *cryptoTickerPlugin) OnInit() error { - pdk.Log(pdk.LogInfo, "Crypto Ticker Plugin initializing...") - - // Get ticker configuration from JSON schema config - symbols := getSymbols() - pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured symbols: %v", symbols)) - - // Connect to WebSocket - // Errors won't fail init - reconnect logic will handle it - return connectAndSubscribe(symbols) -} - -// getSymbols reads the symbols array from config -func getSymbols() []string { - defaultSymbols := []string{"BTC-USD"} - symbolsJSON, ok := pdk.GetConfig(symbolsKey) - if !ok || symbolsJSON == "" { - return defaultSymbols - } - - var symbols []string - if err := json.Unmarshal([]byte(symbolsJSON), &symbols); err != nil { - pdk.Log(pdk.LogWarn, fmt.Sprintf("failed to parse symbols config: %v, using defaults", err)) - return defaultSymbols - } - - if len(symbols) == 0 { - return defaultSymbols - } - - // Normalize symbols - add -USD suffix if not present - for i, s := range symbols { - s = strings.TrimSpace(s) - if !strings.Contains(s, "-") { - symbols[i] = s + "-USD" - } else { - symbols[i] = s - } - } - - return symbols -} - -// getReconnectDelay reads the reconnect delay from config -func getReconnectDelay() int32 { - delayStr, ok := pdk.GetConfig(reconnectDelayKey) - if !ok || delayStr == "" { - return defaultReconnectDelay - } - - var delay int - if _, err := fmt.Sscanf(delayStr, "%d", &delay); err != nil || delay < 1 { - return defaultReconnectDelay - } - return int32(delay) -} - -// shouldLogPrices reads the logPrices setting from config -func shouldLogPrices() bool { - logStr, ok := pdk.GetConfig(logPricesKey) - if !ok || logStr == "" { - return false - } - return logStr == "true" -} - -// connectAndSubscribe connects to Coinbase WebSocket and subscribes to tickers -func connectAndSubscribe(tickers []string) error { - // Connect to WebSocket using host function - newConnID, err := host.WebSocketConnect(coinbaseWSEndpoint, nil, connectionID) - if err != nil { - return fmt.Errorf("WebSocket connection error: %w", err) - } - pdk.Log(pdk.LogInfo, fmt.Sprintf("Connected to Coinbase WebSocket API (connection: %s)", newConnID)) - - // Subscribe to ticker channel - subscription := CoinbaseSubscription{ - Type: "subscribe", - ProductIDs: tickers, - Channels: []string{"ticker"}, - } - - subscriptionJSON, err := json.Marshal(subscription) - if err != nil { - return fmt.Errorf("JSON marshal error: %v", err) - } - - // Send subscription message - err = host.WebSocketSendText(connectionID, string(subscriptionJSON)) - if err != nil { - return fmt.Errorf("WebSocket send error: %w", err) - } - - pdk.Log(pdk.LogInfo, "Subscription message sent to Coinbase WebSocket API") - return nil -} - -// OnTextMessage is called when a text message is received -func (p *cryptoTickerPlugin) OnTextMessage(input websocket.OnTextMessageRequest) error { - // Only process messages from our connection - if input.ConnectionID != connectionID { - return nil - } - - // Try to parse as a ticker message - var ticker CoinbaseTicker - err := json.Unmarshal([]byte(input.Message), &ticker) - if err != nil { - // Not a valid JSON message, ignore - return nil - } - - // Only process ticker messages - if ticker.Type != "ticker" { - // Could be subscription confirmation or heartbeat - if ticker.Type != "" { - pdk.Log(pdk.LogDebug, fmt.Sprintf("Received %s message", ticker.Type)) - } - return nil - } - - // Calculate 24h change percentage - change := calculatePercentChange(ticker.Open24h, ticker.Price) - - // Log ticker information (only if enabled in config) - if shouldLogPrices() { - pdk.Log(pdk.LogInfo, fmt.Sprintf("💰 %s: $%s (24h: %s%%) Bid: $%s Ask: $%s", - ticker.ProductID, - ticker.Price, - change, - ticker.BestBid, - ticker.BestAsk, - )) - } - - return nil -} - -// OnBinaryMessage is called when a binary message is received -func (p *cryptoTickerPlugin) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error { - // Coinbase doesn't send binary messages, but we implement the handler anyway - pdk.Log(pdk.LogWarn, fmt.Sprintf("Received unexpected binary message on connection %s", input.ConnectionID)) - return nil -} - -// OnError is called when an error occurs on the WebSocket connection -func (p *cryptoTickerPlugin) OnError(input websocket.OnErrorRequest) error { - pdk.Log(pdk.LogError, fmt.Sprintf("WebSocket error on connection %s: %s", input.ConnectionID, input.Error)) - return nil -} - -// OnClose is called when the WebSocket connection is closed -func (p *cryptoTickerPlugin) OnClose(input websocket.OnCloseRequest) error { - pdk.Log(pdk.LogInfo, fmt.Sprintf("WebSocket connection %s closed (code: %d, reason: %s)", - input.ConnectionID, input.Code, input.Reason)) - - // Only attempt reconnect for our connection - if input.ConnectionID == connectionID { - delay := getReconnectDelay() - pdk.Log(pdk.LogInfo, fmt.Sprintf("Scheduling reconnection attempt in %d seconds...", delay)) - - // Schedule a one-time reconnection attempt - _, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID) - if err != nil { - pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule reconnection: %v", err)) - } - } - - return nil -} - -// OnCallback is called when a scheduled task fires -func (p *cryptoTickerPlugin) OnCallback(input scheduler.SchedulerCallbackRequest) error { - // Only handle our reconnection schedule - if input.ScheduleID != reconnectScheduleID { - return nil - } - - pdk.Log(pdk.LogInfo, "Attempting to reconnect to Coinbase WebSocket API...") - - // Get ticker configuration - symbols := getSymbols() - - // Try to connect and subscribe - err := connectAndSubscribe(symbols) - if err != nil { - delay := getReconnectDelay() * 2 // Double delay on failure - pdk.Log(pdk.LogError, fmt.Sprintf("Reconnection failed: %v - will retry in %d seconds", err, delay)) - - // Schedule another attempt - _, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID) - if err != nil { - pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule retry: %v", err)) - } - } else { - pdk.Log(pdk.LogInfo, "Successfully reconnected!") - } - - return nil -} - -// calculatePercentChange calculates the percentage change between open and current price -func calculatePercentChange(open, current string) string { - var openFloat, currentFloat float64 - _, err := fmt.Sscanf(open, "%f", &openFloat) - if err != nil || openFloat == 0 { - return "N/A" - } - _, err = fmt.Sscanf(current, "%f", ¤tFloat) - if err != nil { - return "N/A" - } - - change := ((currentFloat - openFloat) / openFloat) * 100 - if change >= 0 { - return fmt.Sprintf("+%.2f", change) - } - return fmt.Sprintf("%.2f", change) -} - -func main() {} diff --git a/plugins/examples/crypto-ticker/manifest.json b/plugins/examples/crypto-ticker/manifest.json deleted file mode 100644 index 362fcd0e9..000000000 --- a/plugins/examples/crypto-ticker/manifest.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "name": "Crypto Ticker", - "author": "Navidrome", - "version": "1.0.0", - "description": "Real-time cryptocurrency price ticker using Coinbase WebSocket API", - "website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker", - "config": { - "schema": { - "type": "object", - "properties": { - "symbols": { - "type": "array", - "title": "Trading Pairs", - "description": "Cryptocurrency trading pairs to track (default: BTC-USD)", - "items": { - "type": "string", - "title": "Trading Pair", - "pattern": "^[A-Z]{3,5}-[A-Z]{3,5}$", - "description": "Trading pair in the format BASE-QUOTE (e.g., BTC-USD, ETH-USD)" - }, - "default": ["BTC-USD"] - }, - "reconnectDelay": { - "type": "integer", - "title": "Reconnect Delay", - "description": "Delay in seconds before attempting to reconnect after connection loss", - "default": 5, - "minimum": 1, - "maximum": 60 - }, - "logPrices": { - "type": "boolean", - "title": "Log Prices", - "description": "Whether to log price updates to the server log", - "default": false - } - } - }, - "uiSchema": { - "type": "VerticalLayout", - "elements": [ - { - "type": "Control", - "scope": "#/properties/symbols" - }, - { - "type": "HorizontalLayout", - "elements": [ - { - "type": "Control", - "scope": "#/properties/reconnectDelay" - }, - { - "type": "Control", - "scope": "#/properties/logPrices" - } - ] - } - ] - } - }, - "permissions": { - "scheduler": { - "reason": "To schedule reconnection attempts on connection loss" - }, - "websocket": { - "reason": "To connect to Coinbase WebSocket API for real-time prices", - "requiredHosts": ["ws-feed.exchange.coinbase.com"] - } - } -} diff --git a/plugins/examples/discord-rich-presence-rs/.cargo/config.toml b/plugins/examples/discord-rich-presence-rs/.cargo/config.toml deleted file mode 100644 index 6b509f5b7..000000000 --- a/plugins/examples/discord-rich-presence-rs/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -target = "wasm32-wasip1" diff --git a/plugins/examples/discord-rich-presence-rs/Cargo.toml b/plugins/examples/discord-rich-presence-rs/Cargo.toml deleted file mode 100644 index bc473147d..000000000 --- a/plugins/examples/discord-rich-presence-rs/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "discord-rich-presence-rs" -version = "1.0.0" -edition = "2021" -description = "Discord Rich Presence plugin for Navidrome - Rust implementation" -authors = ["Navidrome Team"] -license = "GPL-3.0" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nd-pdk = { path = "../../pdk/rust/nd-pdk" } -extism-pdk = "1.2" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" diff --git a/plugins/examples/discord-rich-presence-rs/README.md b/plugins/examples/discord-rich-presence-rs/README.md deleted file mode 100644 index 5cd973af0..000000000 --- a/plugins/examples/discord-rich-presence-rs/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# Discord Rich Presence Plugin (Rust) - -A Navidrome plugin that displays your currently playing track on Discord using Rich Presence. This is the Rust implementation demonstrating how to use the `nd-pdk` library. - -## ⚠️ Warning - -This plugin is for **demonstration purposes only**. It requires storing your Discord token in the Navidrome configuration file, which: - -1. Is not secure (tokens should never be stored in plain text) -2. May violate Discord's Terms of Service - -**Use at your own risk.** - -## Features - -- Shows currently playing track on Discord Rich Presence -- Displays album artwork -- Shows track progress with start/end timestamps -- Automatically clears presence when track finishes -- Supports multiple users - -## Capabilities - -This plugin implements multiple capabilities to demonstrate the nd-pdk library: - -- **Scrobbler**: Receives now-playing events from Navidrome -- **SchedulerCallback**: Handles heartbeat and activity clearing timers -- **WebSocketCallback**: Communicates with Discord gateway (text, binary, error, and close handlers) - -## Configuration - -Configure in the Navidrome UI (Settings → Plugins → discord-rich-presence-rs): - -| Key | Description | Example | -|---------------|--------------------------------------|---------------------------| -| `clientid` | Your Discord application ID | `123456789012345678` | -| `user.` | Discord token for the specified user | `user.alice` = `token123` | - -Each user is configured as a separate key with the `user.` prefix. - - -### Getting Configuration Values - -1. **Client ID**: Create a Discord Application at https://discord.com/developers/applications and copy the Application ID - -2. **Discord Token**: This requires extracting your user token from Discord (not recommended for security reasons) - -3. **Multiple Users**: Add multiple user keys: - ```properties - user.user1 = "token1" - user.user2 = "token2" - ``` - -## Building - -```bash -# From the plugins/examples directory -make discord-rich-presence-rs.ndp - -# This creates discord-rich-presence-rs.ndp containing: -# - manifest.json -# - plugin.wasm -``` - -## Installation - -1. Build the plugin using the command above -2. Copy the `.ndp` file to your Navidrome plugins directory -3. Enable and configure the plugin in the Navidrome UI (Settings → Plugins) -4. Restart Navidrome if needed - -## Using nd-pdk Library - -This plugin demonstrates how to use the Rust plugin development kit: - -```rust -use nd_pdk::host::{artwork, cache, scheduler, websocket}; -use std::collections::HashMap; - -// Get artwork URL -let url = artwork::get_track_url(track_id, 300)?; - -// Cache operations -cache::set_string("key", "value", 3600)?; -if let Some(value) = cache::get_string("key")? { - // Use the cached value -} - -// Schedule tasks -scheduler::schedule_one_time(60, "payload", "task-id")?; -scheduler::schedule_recurring("@every 30s", "heartbeat", "heartbeat-task")?; - -// WebSocket operations -let conn_id = websocket::connect("wss://example.com/socket", HashMap::new(), "my-conn")?; -websocket::send_text(&conn_id, "Hello")?; -``` - -## License - -GPL-3.0 diff --git a/plugins/examples/discord-rich-presence-rs/manifest.json b/plugins/examples/discord-rich-presence-rs/manifest.json deleted file mode 100644 index 4cf64b557..000000000 --- a/plugins/examples/discord-rich-presence-rs/manifest.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "name": "Discord Rich Presence (Rust)", - "author": "Navidrome Team", - "version": "1.0.0", - "description": "Discord Rich Presence integration for Navidrome - Rust implementation", - "website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/discord-rich-presence-rs", - "permissions": { - "users": { - "reason": "To process scrobbles on behalf of users" - }, - "http": { - "reason": "To communicate with Discord API for gateway discovery and image uploads", - "requiredHosts": ["discord.com"] - }, - "websocket": { - "reason": "To maintain real-time connection with Discord gateway", - "requiredHosts": ["gateway.discord.gg"] - }, - "cache": { - "reason": "To store connection state and sequence numbers" - }, - "scheduler": { - "reason": "To schedule heartbeat messages and activity clearing" - }, - "artwork": { - "reason": "To get track artwork URLs for rich presence display" - } - }, - "config": { - "schema": { - "type": "object", - "properties": { - "clientid": { - "type": "string", - "title": "Discord Application Client ID", - "description": "The Client ID from your Discord Developer Application. Create one at https://discord.com/developers/applications", - "minLength": 17, - "maxLength": 20, - "pattern": "^[0-9]+$" - }, - "users": { - "type": "array", - "title": "User Tokens", - "description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!", - "minItems": 1, - "items": { - "type": "object", - "properties": { - "username": { - "type": "string", - "title": "Navidrome Username", - "description": "The Navidrome username to associate with this Discord token", - "minLength": 1 - }, - "token": { - "type": "string", - "title": "Discord Token", - "description": "The user's Discord token (keep this secret!)", - "minLength": 1 - } - }, - "required": ["username", "token"] - } - } - }, - "required": ["clientid", "users"] - }, - "uiSchema": { - "type": "VerticalLayout", - "elements": [ - { - "type": "Control", - "scope": "#/properties/clientid" - }, - { - "type": "Control", - "scope": "#/properties/users", - "options": { - "elementLabelProp": "username", - "detail": { - "type": "HorizontalLayout", - "elements": [ - { - "type": "Control", - "scope": "#/properties/username" - }, - { - "type": "Control", - "scope": "#/properties/token" - } - ] - } - } - } - ] - } - } -} diff --git a/plugins/examples/discord-rich-presence-rs/src/lib.rs b/plugins/examples/discord-rich-presence-rs/src/lib.rs deleted file mode 100644 index 10c8f8c66..000000000 --- a/plugins/examples/discord-rich-presence-rs/src/lib.rs +++ /dev/null @@ -1,294 +0,0 @@ -//! Discord Rich Presence Plugin for Navidrome - Rust Implementation -//! -//! This plugin integrates Navidrome with Discord Rich Presence. It demonstrates how to: -//! - Use the nd-pdk crate for host service calls -//! - Implement the Scrobbler capability for now-playing updates -//! - Implement SchedulerCallback for heartbeat and activity clearing -//! - Implement WebSocketCallback for Discord gateway communication -//! -//! ## Configuration -//! -//! Configure this plugin through the Navidrome UI with: -//! - Discord Application Client ID -//! - User tokens array mapping Navidrome usernames to Discord tokens -//! -//! **WARNING**: This plugin is for demonstration purposes only. Storing Discord tokens -//! in configuration files is not secure and may violate Discord's terms of service. - -use extism_pdk::*; -use nd_pdk::host::{artwork, config, scheduler}; -use nd_pdk::scrobbler::{ - Error as ScrobblerError, IsAuthorizedRequest, NowPlayingRequest, PlaybackReportRequest, - ScrobbleRequest, Scrobbler, SCROBBLER_ERROR_NOT_AUTHORIZED, SCROBBLER_ERROR_RETRY_LATER, -}; -use nd_pdk::scheduler::{ - CallbackProvider, Error as SchedulerError, SchedulerCallbackRequest, -}; -use nd_pdk::websocket::{ - BinaryMessageProvider, CloseProvider, Error as WebSocketError, ErrorProvider, - OnBinaryMessageRequest, OnCloseRequest, OnErrorRequest, OnTextMessageRequest, - TextMessageProvider, -}; -use serde::Deserialize; - -mod rpc; - -// Register capabilities using PDK macros -nd_pdk::register_scrobbler!(DiscordPlugin); -nd_pdk::register_scheduler_callback!(DiscordPlugin); -nd_pdk::register_websocket_text_message!(DiscordPlugin); -nd_pdk::register_websocket_binary_message!(DiscordPlugin); -nd_pdk::register_websocket_error!(DiscordPlugin); -nd_pdk::register_websocket_close!(DiscordPlugin); - -// ============================================================================ -// Constants -// ============================================================================ - -const CLIENT_ID_KEY: &str = "clientid"; -const USERS_KEY: &str = "users"; -const PAYLOAD_HEARTBEAT: &str = "heartbeat"; -const PAYLOAD_CLEAR_ACTIVITY: &str = "clear-activity"; - -// ============================================================================ -// Plugin Implementation -// ============================================================================ - -/// The Discord Rich Presence plugin type. -#[derive(Default)] -struct DiscordPlugin; - -// ============================================================================ -// Configuration -// ============================================================================ - -/// User token entry from the config schema -#[derive(Debug, Deserialize)] -struct UserToken { - username: String, - token: String, -} - -fn get_config() -> Result<(String, std::collections::HashMap), Error> { - let client_id = config::get(CLIENT_ID_KEY)? - .filter(|s| !s.is_empty()) - .ok_or_else(|| Error::msg("missing clientid in configuration"))?; - - // Get users array from config (JSON format) - let users_json = config::get(USERS_KEY)?.unwrap_or_default(); - - let mut users = std::collections::HashMap::new(); - if !users_json.is_empty() { - // Parse JSON array of user tokens - let user_tokens: Vec = serde_json::from_str(&users_json) - .map_err(|e| Error::msg(format!("failed to parse users config: {}", e)))?; - - for user_token in user_tokens { - if !user_token.username.is_empty() && !user_token.token.is_empty() { - users.insert(user_token.username, user_token.token); - } - } - } - - Ok((client_id, users)) -} - -fn get_image_url(track_id: &str) -> String { - match artwork::get_track_url(track_id, 300) { - Ok(url) => { - if url.starts_with("http://localhost") { - String::new() - } else { - url - } - } - Err(e) => { - warn!("Failed to get artwork URL: {:?}", e); - String::new() - } - } -} - -// ============================================================================ -// Scrobbler Implementation -// ============================================================================ - -impl Scrobbler for DiscordPlugin { - fn is_authorized(&self, req: IsAuthorizedRequest) -> Result { - let (_, users) = match get_config() { - Ok(config) => config, - Err(e) => { - error!("Failed to get config: {:?}", e); - return Ok(false); - } - }; - - let authorized = users.contains_key(&req.username); - info!("IsAuthorized for user {}: {}", req.username, authorized); - Ok(authorized) - } - - fn now_playing(&self, req: NowPlayingRequest) -> Result<(), ScrobblerError> { - info!( - "Setting presence for user {}, track: {}", - req.username, req.track.title - ); - - // Load configuration - let (client_id, users) = get_config() - .map_err(|e| ScrobblerError::new(format!("{}: failed to get config: {:?}", SCROBBLER_ERROR_RETRY_LATER, e)))?; - - // Check authorization - let user_token = users.get(&req.username).cloned().ok_or_else(|| { - ScrobblerError::new(format!( - "{}: user '{}' not authorized", - SCROBBLER_ERROR_NOT_AUTHORIZED, req.username - )) - })?; - - // Connect to Discord - rpc::connect(&req.username, &user_token) - .map_err(|e| ScrobblerError::new(format!( - "{}: failed to connect to Discord: {:?}", - SCROBBLER_ERROR_RETRY_LATER, e - )))?; - - // Cancel any existing completion schedule - let _ = scheduler::cancel_schedule(&format!("{}-clear", req.username)); - - // Calculate timestamps - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0); - let start_time = (now - req.position as i64) * 1000; - let end_time = start_time + (req.track.duration as i64) * 1000; - - // Send activity update - rpc::send_activity( - &client_id, - &req.username, - &user_token, - rpc::Activity { - application: client_id.clone(), - name: "Navidrome".to_string(), - activity_type: 2, // Listening - details: req.track.title.clone(), - state: req.track.artist.clone(), - timestamps: rpc::ActivityTimestamps { - start: start_time, - end: end_time, - }, - assets: rpc::ActivityAssets { - large_image: get_image_url(&req.track.id), - large_text: req.track.album.clone(), - }, - }, - ) - .map_err(|e| ScrobblerError::new(format!( - "{}: failed to send activity: {:?}", - SCROBBLER_ERROR_RETRY_LATER, e - )))?; - - // Schedule a timer to clear the activity after the track completes - let remaining_seconds = (req.track.duration as i32) - req.position + 5; - if let Err(e) = scheduler::schedule_one_time( - remaining_seconds, - PAYLOAD_CLEAR_ACTIVITY, - &format!("{}-clear", req.username), - ) { - warn!("Failed to schedule completion timer: {:?}", e); - } - - Ok(()) - } - - fn scrobble(&self, _req: ScrobbleRequest) -> Result<(), ScrobblerError> { - // Discord Rich Presence doesn't need scrobble events - success - Ok(()) - } - - fn playback_report(&self, _req: PlaybackReportRequest) -> Result<(), ScrobblerError> { - // Discord Rich Presence doesn't need playback reports - success - Ok(()) - } -} - -// ============================================================================ -// Scheduler Callback Implementation -// ============================================================================ - -impl CallbackProvider for DiscordPlugin { - fn on_callback(&self, req: SchedulerCallbackRequest) -> Result<(), SchedulerError> { - match req.payload.as_str() { - PAYLOAD_HEARTBEAT => { - // Heartbeat callback - schedule_id is the username - if let Err(e) = rpc::handle_heartbeat_callback(&req.schedule_id) { - // On heartbeat failure, clean up the connection (like the original Go plugin) - // The next NowPlaying call will reconnect if needed - warn!("Heartbeat failed for user {}, cleaning up connection: {:?}", req.schedule_id, e); - rpc::cleanup_connection(&req.schedule_id); - return Err(SchedulerError::new(format!("heartbeat failed, connection cleaned up: {}", e))); - } - } - PAYLOAD_CLEAR_ACTIVITY => { - // Clear activity callback - schedule_id is "username-clear" - let username = req.schedule_id.trim_end_matches("-clear"); - info!("Removing presence for user {}", username); - rpc::handle_clear_activity_callback(username) - .map_err(|e| SchedulerError::new(e.to_string()))?; - info!("Disconnecting user {}", username); - rpc::disconnect(username) - .map_err(|e| SchedulerError::new(e.to_string()))?; - } - _ => { - warn!("Unknown scheduler callback payload: {}", req.payload); - } - } - - Ok(()) - } -} - -// ============================================================================ -// WebSocket Callback Implementations -// ============================================================================ - -impl TextMessageProvider for DiscordPlugin { - fn on_text_message(&self, req: OnTextMessageRequest) -> Result<(), WebSocketError> { - rpc::handle_websocket_message(&req.connection_id, &req.message) - .map_err(|e| WebSocketError::new(e.to_string()))?; - Ok(()) - } -} - -impl BinaryMessageProvider for DiscordPlugin { - fn on_binary_message(&self, _req: OnBinaryMessageRequest) -> Result<(), WebSocketError> { - // Binary messages are not expected from Discord - Ok(()) - } -} - -impl ErrorProvider for DiscordPlugin { - fn on_error(&self, req: OnErrorRequest) -> Result<(), WebSocketError> { - warn!( - "WebSocket error for connection '{}': {}", - req.connection_id, req.error - ); - // Clean up all state associated with this connection since it's likely broken - rpc::handle_connection_close(&req.connection_id); - Ok(()) - } -} - -impl CloseProvider for DiscordPlugin { - fn on_close(&self, req: OnCloseRequest) -> Result<(), WebSocketError> { - info!( - "WebSocket connection '{}' closed with code {}: {}", - req.connection_id, req.code, req.reason - ); - // Clean up all state associated with this connection - rpc::handle_connection_close(&req.connection_id); - Ok(()) - } -} diff --git a/plugins/examples/discord-rich-presence-rs/src/rpc.rs b/plugins/examples/discord-rich-presence-rs/src/rpc.rs deleted file mode 100644 index fa72f7466..000000000 --- a/plugins/examples/discord-rich-presence-rs/src/rpc.rs +++ /dev/null @@ -1,559 +0,0 @@ -//! Discord Rich Presence Plugin - RPC Communication -//! -//! This module handles all Discord gateway communication including WebSocket connections, -//! presence updates, and heartbeat management. - -use extism_pdk::*; -use nd_pdk::host::{cache, http, scheduler, websocket}; -use serde::{Deserialize, Serialize}; - -// ============================================================================ -// Constants -// ============================================================================ - -const HEARTBEAT_OP_CODE: i32 = 1; -const GATE_OP_CODE: i32 = 2; -const PRESENCE_OP_CODE: i32 = 3; -const HEARTBEAT_INTERVAL: i32 = 41; -const DEFAULT_IMAGE: &str = "https://i.imgur.com/hb3XPzA.png"; - -const PAYLOAD_HEARTBEAT: &str = "heartbeat"; - -// ============================================================================ -// Discord Types -// ============================================================================ - -#[derive(Serialize)] -pub struct Activity { - pub name: String, - #[serde(rename = "type")] - pub activity_type: i32, - pub details: String, - pub state: String, - #[serde(rename = "application_id")] - pub application: String, - pub timestamps: ActivityTimestamps, - pub assets: ActivityAssets, -} - -#[derive(Serialize)] -pub struct ActivityTimestamps { - pub start: i64, - pub end: i64, -} - -#[derive(Serialize)] -pub struct ActivityAssets { - pub large_image: String, - pub large_text: String, -} - -#[derive(Serialize)] -struct PresencePayload { - activities: Vec, - since: i64, - status: String, - afk: bool, -} - -#[derive(Serialize)] -struct IdentifyPayload { - token: String, - intents: i32, - properties: IdentifyProperties, -} - -#[derive(Serialize)] -struct IdentifyProperties { - os: String, - browser: String, - device: String, -} - -#[derive(Serialize)] -struct GatewayMessage { - op: i32, - d: T, -} - -#[derive(Deserialize)] -struct GatewayResponse { - op: i32, - #[serde(default)] - #[allow(dead_code)] - d: Option, - #[serde(default)] - s: Option, -} - -// ============================================================================ -// Cache Keys -// ============================================================================ - -fn connection_key(username: &str) -> String { - format!("discord.connection.{}", username) -} - -fn token_key(username: &str) -> String { - format!("discord.token.{}", username) -} - -fn sequence_key(username: &str) -> String { - format!("discord.sequence.{}", username) -} - -// ============================================================================ -// Connection Management -// ============================================================================ - -/// Tests if the connection is still valid by trying to send a heartbeat. -fn is_connected(username: &str) -> bool { - match send_heartbeat(username) { - Ok(_) => true, - Err(e) => { - trace!("Connection test failed for user {}: {:?}", username, e); - false - } - } -} - -/// Cleans up a connection for a user. -/// Called when heartbeat fails or connection is lost. -pub fn cleanup_connection(username: &str) { - info!("Cleaning up failed connection for user {}", username); - - // Cancel the heartbeat schedule - if let Err(e) = scheduler::cancel_schedule(username) { - warn!("Failed to cancel heartbeat schedule for user {}: {:?}", username, e); - } - - // Try to close the WebSocket connection - let conn_key = connection_key(username); - if let Ok(Some(conn_id)) = cache::get_string(&conn_key) { - if !conn_id.is_empty() { - if let Err(e) = websocket::close_connection(&conn_id, 1000, "Reconnecting") { - trace!("Failed to close WebSocket for user {}: {:?}", username, e); - } - // Clean up reverse mapping - let reverse_key = format!("discord.reverse.{}", conn_id); - let _ = cache::remove(&reverse_key); - } - } - - // Clean up cache entries - let _ = cache::remove(&conn_key); - let _ = cache::remove(&sequence_key(username)); - - info!("Cleaned up connection for user {}", username); -} - -/// Handles connection close by connection ID (called from WebSocket close callback). -/// This cleans up all state associated with the connection. -pub fn handle_connection_close(connection_id: &str) { - // Find the username for this connection using the reverse mapping - if let Ok(Some(username)) = find_username_for_connection(connection_id) { - info!("Connection closed for user {}, cleaning up", username); - - // Cancel the heartbeat schedule - if let Err(e) = scheduler::cancel_schedule(&username) { - // Not an error if schedule doesn't exist - trace!("Failed to cancel heartbeat schedule for user {}: {:?}", username, e); - } - - // Cancel any pending clear-activity schedule - let _ = scheduler::cancel_schedule(&format!("{}-clear", username)); - - // Clean up cache entries - let conn_key = connection_key(&username); - let _ = cache::remove(&conn_key); - let _ = cache::remove(&sequence_key(&username)); - - // Clean up reverse mapping - let reverse_key = format!("discord.reverse.{}", connection_id); - let _ = cache::remove(&reverse_key); - - info!("Cleaned up connection state for user {}", username); - } else { - // Just clean up the reverse mapping if we can't find the username - let reverse_key = format!("discord.reverse.{}", connection_id); - let _ = cache::remove(&reverse_key); - } -} - -/// Connects to the Discord gateway for a user. -pub fn connect(username: &str, token: &str) -> Result<(), Error> { - // Check if already connected and connection is valid - if is_connected(username) { - info!("Reusing existing connection for user {}", username); - return Ok(()); - } - - // Clean up any stale connection state - cleanup_connection(username); - - info!("Connecting to Discord gateway for user {}", username); - - // Store token for later use - cache::set_string(&token_key(username), token, 86400)?; - - // Get Discord Gateway URL - let gateway = get_discord_gateway()?; - info!("Using gateway: {}", gateway); - - // Connect to Discord gateway - let headers = std::collections::HashMap::new(); - let conn_id = websocket::connect( - &gateway, - headers, - username, // Use username as connection ID for easy lookup - )?; - info!("WebSocket connection established: {}", conn_id); - - // Store connection ID - let conn_key = connection_key(username); - cache::set_string(&conn_key, &conn_id, 86400)?; - - // Send identify immediately (don't wait for Hello) - identify(username)?; - - info!("Successfully connected and identified user {}", username); - Ok(()) -} - -/// Handles a WebSocket message from Discord. -pub fn handle_websocket_message(connection_id: &str, message: &str) -> Result<(), Error> { - let response: GatewayResponse = serde_json::from_str(message) - .map_err(|e| Error::msg(format!("Failed to parse gateway message: {}", e)))?; - - // Update sequence number if present - if let Some(seq) = response.s { - // Find username for this connection - if let Some(username) = find_username_for_connection(connection_id)? { - cache::set_string(&sequence_key(&username), &seq.to_string(), 86400)?; - } - } - - match response.op { - 10 => { - // Hello - we already identified in connect(), nothing to do - } - 11 => { - // Heartbeat ACK - no action needed - } - 1 => { - // Heartbeat request - send heartbeat - if let Some(username) = find_username_for_connection(connection_id)? { - send_heartbeat(&username)?; - } - } - _ => { - trace!("Received Discord gateway op: {}", response.op); - } - } - - Ok(()) -} - -/// Handles heartbeat callback from scheduler. -pub fn handle_heartbeat_callback(username: &str) -> Result<(), Error> { - send_heartbeat(username) -} - -/// Handles clear activity callback from scheduler. -pub fn handle_clear_activity_callback(username: &str) -> Result<(), Error> { - info!("Clearing activity for user {}", username); - - let conn_key = connection_key(username); - if let Some(conn_id) = cache::get_string(&conn_key)?.filter(|s| !s.is_empty()) { - // Send empty presence to clear activity - let msg = GatewayMessage { - op: PRESENCE_OP_CODE, - d: PresencePayload { - activities: vec![], - since: 0, - status: "dnd".to_string(), - afk: false, - }, - }; - - let json = serde_json::to_string(&msg) - .map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?; - - websocket::send_text(&conn_id, &json)?; - } - - Ok(()) -} - -/// Disconnects from Discord for a user. -pub fn disconnect(username: &str) -> Result<(), Error> { - info!("Disconnecting from Discord for user {}", username); - - // Cancel the heartbeat schedule - if let Err(e) = scheduler::cancel_schedule(username) { - warn!("Failed to cancel heartbeat schedule: {:?}", e); - } - - // Close the WebSocket connection - let conn_key = connection_key(username); - if let Some(conn_id) = cache::get_string(&conn_key)?.filter(|s| !s.is_empty()) { - if let Err(e) = websocket::close_connection(&conn_id, 1000, "Navidrome disconnect") { - warn!("Failed to close WebSocket connection: {:?}", e); - } - // Clean up reverse mapping - let reverse_key = format!("discord.reverse.{}", conn_id); - let _ = cache::remove(&reverse_key); - } - - // Clean up cache entries - let _ = cache::remove(&conn_key); - let _ = cache::remove(&sequence_key(username)); - - Ok(()) -} - -/// Sends an activity update to Discord. -pub fn send_activity( - client_id: &str, - username: &str, - token: &str, - mut activity: Activity, -) -> Result<(), Error> { - let conn_key = connection_key(username); - let conn_id = cache::get_string(&conn_key)? - .filter(|s| !s.is_empty()) - .ok_or_else(|| Error::msg("Not connected to Discord"))?; - - // Process image URL - activity.assets.large_image = process_image(&activity.assets.large_image, client_id, token)?; - - // Send presence update - let msg = GatewayMessage { - op: PRESENCE_OP_CODE, - d: PresencePayload { - activities: vec![activity], - since: 0, - status: "dnd".to_string(), - afk: false, - }, - }; - - let json = serde_json::to_string(&msg) - .map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?; - - websocket::send_text(&conn_id, &json)?; - - Ok(()) -} - -// ============================================================================ -// Internal Functions -// ============================================================================ - -fn find_username_for_connection(connection_id: &str) -> Result, Error> { - // This is a simple approach - in production you might want to maintain a proper mapping - // For now, we'll use a known pattern to find the username - // The connection ID is stored as cache value, so we need to scan for it - // Since we can't iterate cache, we'll use a workaround with a reverse mapping - let reverse_key = format!("discord.reverse.{}", connection_id); - Ok(cache::get_string(&reverse_key)?.filter(|s| !s.is_empty())) -} - -fn send_http( - method: &str, - url: &str, - headers: std::collections::HashMap, - body: Vec, -) -> Result { - http::send(http::HTTPRequest { - method: method.into(), - url: url.into(), - headers, - body, - ..Default::default() - })? - .ok_or_else(|| Error::msg("empty HTTP response")) -} - -fn get_discord_gateway() -> Result { - let resp = send_http("GET", "https://discord.com/api/gateway", Default::default(), Vec::new())?; - if resp.status_code >= 400 { - return Err(Error::msg(format!( - "Failed to get Discord gateway: HTTP {}", - resp.status_code - ))); - } - - let body = resp.body; - let data: std::collections::HashMap = serde_json::from_slice(&body) - .map_err(|e| Error::msg(format!("Failed to parse gateway response: {}", e)))?; - - data.get("url") - .map(|url| url.to_string()) - .ok_or_else(|| Error::msg("No URL in gateway response")) -} - -fn identify(username: &str) -> Result<(), Error> { - info!("Identifying with Discord for user {}", username); - - let conn_key = connection_key(username); - let conn_id = cache::get_string(&conn_key)? - .filter(|s| !s.is_empty()) - .ok_or_else(|| Error::msg("No connection found"))?; - - let token_k = token_key(username); - let token = cache::get_string(&token_k)? - .filter(|s| !s.is_empty()) - .ok_or_else(|| Error::msg("No token found"))?; - - // Store reverse mapping for connection -> username - let reverse_key = format!("discord.reverse.{}", conn_id); - cache::set_string(&reverse_key, username, 86400)?; - - // Send identify - let msg = GatewayMessage { - op: GATE_OP_CODE, - d: IdentifyPayload { - token, - intents: 0, - properties: IdentifyProperties { - os: "Windows 10".to_string(), - browser: "Discord Client".to_string(), - device: "Discord Client".to_string(), - }, - }, - }; - - let json = serde_json::to_string(&msg) - .map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?; - - websocket::send_text(&conn_id, &json)?; - - // Schedule heartbeat - scheduler::schedule_recurring( - &format!("@every {}s", HEARTBEAT_INTERVAL), - PAYLOAD_HEARTBEAT, - username, - )?; - - Ok(()) -} - -fn send_heartbeat(username: &str) -> Result<(), Error> { - let conn_key = connection_key(username); - let conn_id = cache::get_string(&conn_key)? - .filter(|s| !s.is_empty()) - .ok_or_else(|| Error::msg("No connection found"))?; - - // Get sequence number - let seq_key = sequence_key(username); - let seq: Option = cache::get_string(&seq_key)? - .and_then(|s| s.parse().ok()); - - // Send heartbeat - let msg = GatewayMessage { - op: HEARTBEAT_OP_CODE, - d: seq, - }; - - let json = serde_json::to_string(&msg) - .map_err(|e| Error::msg(format!("Failed to serialize message: {}", e)))?; - - websocket::send_text(&conn_id, &json)?; - Ok(()) -} - -fn process_image(image_url: &str, client_id: &str, token: &str) -> Result { - process_image_inner(image_url, client_id, token, false) -} - -fn process_image_inner( - image_url: &str, - client_id: &str, - token: &str, - is_default: bool, -) -> Result { - let url = if image_url.is_empty() { - if is_default { - return Err(Error::msg("default image URL is empty")); - } - return process_image_inner(DEFAULT_IMAGE, client_id, token, true); - } else { - image_url - }; - - // Already processed - if url.starts_with("mp:") { - return Ok(url.to_string()); - } - - // Check cache - let cache_key = format!("discord.image.{:x}", md5_hash(url)); - if let Some(cached) = cache::get_string(&cache_key)?.filter(|s| !s.is_empty()) { - return Ok(cached); - } - - // Process via Discord API - let body = format!(r#"{{"urls":["{}"]}}"#, url); - let api_url = format!( - "https://discord.com/api/v9/applications/{}/external-assets", - client_id - ); - - let headers = std::collections::HashMap::from([ - ("Authorization".to_string(), token.to_string()), - ("Content-Type".to_string(), "application/json".to_string()), - ]); - let resp = send_http("POST", &api_url, headers, body.into_bytes())?; - if resp.status_code >= 400 { - if is_default { - return Err(Error::msg(format!( - "failed to process default image: HTTP {}", - resp.status_code - ))); - } - return process_image_inner(DEFAULT_IMAGE, client_id, token, true); - } - - let body = resp.body; - let data: Vec> = serde_json::from_slice(&body) - .map_err(|e| Error::msg(format!("Failed to parse image response: {}", e)))?; - - if data.is_empty() { - if is_default { - return Err(Error::msg("no data returned for default image")); - } - return process_image_inner(DEFAULT_IMAGE, client_id, token, true); - } - - let asset_path = data[0] - .get("external_asset_path") - .map(|s| s.as_str()) - .unwrap_or(""); - - if asset_path.is_empty() { - if is_default { - return Err(Error::msg("empty external_asset_path for default image")); - } - return process_image_inner(DEFAULT_IMAGE, client_id, token, true); - } - - let processed = format!("mp:{}", asset_path); - - // Cache the result - let ttl = if is_default { 48 * 60 * 60 } else { 4 * 60 * 60 }; - let _ = cache::set_string(&cache_key, &processed, ttl); - - Ok(processed) -} - -/// Simple hash function for cache keys. -fn md5_hash(input: &str) -> u64 { - // A simple hash - not actual MD5, but sufficient for cache keys - let mut hash: u64 = 0; - for (i, byte) in input.bytes().enumerate() { - hash = hash.wrapping_add((byte as u64).wrapping_mul((i as u64).wrapping_add(1))); - hash = hash.wrapping_mul(31); - } - hash -} diff --git a/plugins/examples/library-inspector-rs/.cargo/config.toml b/plugins/examples/library-inspector-rs/.cargo/config.toml deleted file mode 100644 index 6b509f5b7..000000000 --- a/plugins/examples/library-inspector-rs/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -target = "wasm32-wasip1" diff --git a/plugins/examples/library-inspector-rs/.gitignore b/plugins/examples/library-inspector-rs/.gitignore deleted file mode 100644 index 8f0c20473..000000000 --- a/plugins/examples/library-inspector-rs/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Rust build artifacts -/target/ - -# Cargo.lock is not needed for library crates (this is a cdylib) -Cargo.lock \ No newline at end of file diff --git a/plugins/examples/library-inspector-rs/Cargo.toml b/plugins/examples/library-inspector-rs/Cargo.toml deleted file mode 100644 index a0bc9700c..000000000 --- a/plugins/examples/library-inspector-rs/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "library-inspector-rs" -version = "1.0.0" -edition = "2021" -description = "Navidrome plugin that periodically logs library details and finds largest files" -authors = ["Navidrome Team"] -license = "GPL-3.0" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nd-pdk = { path = "../../pdk/rust/nd-pdk" } -extism-pdk = "1.2" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" diff --git a/plugins/examples/library-inspector-rs/README.md b/plugins/examples/library-inspector-rs/README.md deleted file mode 100644 index 37c672679..000000000 --- a/plugins/examples/library-inspector-rs/README.md +++ /dev/null @@ -1,93 +0,0 @@ -# Library Inspector Plugin - -A Navidrome plugin written in Rust that demonstrates the Library host service. It periodically logs details about all configured music libraries and finds the largest file in the root of each library directory. - -## Features - -- Logs comprehensive library statistics (songs, albums, artists, size, duration) -- Lists the largest file found in each library's root directory -- Configurable inspection interval via cron expression -- Runs an initial inspection on plugin load - -## Requirements - -- Rust toolchain with `wasm32-wasip1` target -- Navidrome with plugins enabled - -## Building - -```bash -# Install the WASM target if you haven't already -rustup target add wasm32-wasip1 - -# Build the plugin -cargo build --target wasm32-wasip1 --release - -# Package as .ndp -zip -j library-inspector.ndp manifest.json target/wasm32-wasip1/release/library_inspector.wasm -``` - -Or use the provided Makefile from the examples directory: - -```bash -cd plugins/examples -make library-inspector.ndp -``` - -## Installation - -1. Copy the `.ndp` file to your Navidrome plugins folder -2. Enable plugins in your Navidrome configuration: - -```toml -[Plugins] -Enabled = true -Folder = "/path/to/plugins" -``` - -3. Restart Navidrome and enable the plugin in the UI - -## Configuration - -Configure the inspection interval in the Navidrome UI (Settings → Plugins → library-inspector): - -| Key | Description | Default | -|--------|------------------------------------------|--------------| -| `cron` | Cron expression for inspection interval | `@every 1m` | - -## Permissions - -This plugin requires: - -- **Library** (with filesystem): To read library metadata and scan directories -- **Scheduler**: To schedule periodic inspections - -## Example Output - -``` -=== Library Inspection Started === -Found 2 libraries ----------------------------------------- -Library: My Music (ID: 1) - Songs: 5432 tracks - Albums: 456 - Artists: 234 - Size: 45.67 GB - Duration: 312h 45m - Mount: /libraries/1 - Largest file in root: cover.jpg (2.34 MB) ----------------------------------------- -Library: Podcasts (ID: 2) - Songs: 128 tracks - Albums: 12 - Artists: 8 - Size: 3.21 GB - Duration: 48h 15m - Mount: /libraries/2 - Largest file in root: episode-001.mp3 (156.78 MB) -=== Library Inspection Complete === -``` - -## License - -GPL-3.0 - Same as Navidrome diff --git a/plugins/examples/library-inspector-rs/manifest.json b/plugins/examples/library-inspector-rs/manifest.json deleted file mode 100644 index 6288dc948..000000000 --- a/plugins/examples/library-inspector-rs/manifest.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "Library Inspector", - "author": "Navidrome Team", - "version": "1.0.0", - "description": "Periodically logs library details and finds largest files", - "website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/library-inspector", - "permissions": { - "library": { - "reason": "To read library metadata and scan directories for file sizes", - "filesystem": true - }, - "scheduler": { - "reason": "To schedule periodic library inspections" - } - } -} diff --git a/plugins/examples/library-inspector-rs/src/lib.rs b/plugins/examples/library-inspector-rs/src/lib.rs deleted file mode 100644 index 88ff1b787..000000000 --- a/plugins/examples/library-inspector-rs/src/lib.rs +++ /dev/null @@ -1,207 +0,0 @@ -//! Library Inspector Plugin for Navidrome -//! -//! This plugin demonstrates how to use the nd-pdk crate for accessing Navidrome -//! host services and implementing capabilities in Rust. It periodically logs details -//! about all music libraries and finds the largest file in the root of each library. -//! -//! ## Configuration -//! -//! Set the `cron` config key to customize the schedule (default: "@every 1m"): -//! ```toml -//! [PluginConfig.library-inspector] -//! cron = "@every 5m" -//! ``` - -use extism_pdk::*; -use nd_pdk::host::{library, scheduler}; -use nd_pdk::lifecycle::{Error as LifecycleError, InitProvider}; -use nd_pdk::scheduler::{CallbackProvider, Error as SchedulerError, SchedulerCallbackRequest}; -use std::fs; - -// Register capabilities using PDK macros -nd_pdk::register_lifecycle_init!(LibraryInspector); -nd_pdk::register_scheduler_callback!(LibraryInspector); - -// ============================================================================ -// Plugin Implementation -// ============================================================================ - -/// The library inspector plugin type. -#[derive(Default)] -struct LibraryInspector; - -impl InitProvider for LibraryInspector { - fn on_init(&self) -> Result<(), LifecycleError> { - info!("Library Inspector plugin initializing..."); - - // Get cron expression from config, default to every minute - let cron = config::get("cron") - .ok() - .flatten() - .unwrap_or_else(|| "@every 1m".to_string()); - - info!("Scheduling library inspection with cron: {}", cron); - - // Schedule the recurring task using nd-pdk host scheduler - match scheduler::schedule_recurring(&cron, "inspect", "library-inspect") { - Ok(schedule_id) => { - info!("Scheduled inspection task with ID: {}", schedule_id); - } - Err(e) => { - let error_msg = format!("Failed to schedule inspection: {}", e); - error!("{}", error_msg); - return Err(LifecycleError::new(error_msg)); - } - } - - // Run an initial inspection - inspect_libraries(); - - info!("Library Inspector plugin initialized successfully"); - Ok(()) - } -} - -impl CallbackProvider for LibraryInspector { - fn on_callback(&self, req: SchedulerCallbackRequest) -> Result<(), SchedulerError> { - info!( - "Scheduler callback fired: schedule_id={}, payload={}, recurring={}", - req.schedule_id, req.payload, req.is_recurring - ); - - if req.payload == "inspect" { - inspect_libraries(); - } - - Ok(()) - } -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -/// Format bytes into human-readable size -fn format_size(bytes: i64) -> String { - const KB: i64 = 1024; - const MB: i64 = KB * 1024; - const GB: i64 = MB * 1024; - const TB: i64 = GB * 1024; - - if bytes >= TB { - format!("{:.2} TB", bytes as f64 / TB as f64) - } else if bytes >= GB { - format!("{:.2} GB", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.2} MB", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.2} KB", bytes as f64 / KB as f64) - } else { - format!("{} bytes", bytes) - } -} - -/// Format duration in seconds to human-readable format -fn format_duration(seconds: f64) -> String { - let total_seconds = seconds as i64; - let hours = total_seconds / 3600; - let minutes = (total_seconds % 3600) / 60; - - if hours > 0 { - format!("{}h {}m", hours, minutes) - } else { - format!("{}m", minutes) - } -} - -/// Find the largest file in a directory (non-recursive) -fn find_largest_file(mount_point: &str) -> Option<(String, u64)> { - let entries = match fs::read_dir(mount_point) { - Ok(entries) => entries, - Err(e) => { - warn!("Failed to read directory {}: {}", mount_point, e); - return None; - } - }; - - let mut largest: Option<(String, u64)> = None; - - for entry in entries.flatten() { - let path = entry.path(); - - // Only consider files, not directories - if !path.is_file() { - continue; - } - - let metadata = match entry.metadata() { - Ok(m) => m, - Err(_) => continue, - }; - - let size = metadata.len(); - let name = entry.file_name().to_string_lossy().to_string(); - - match &largest { - None => largest = Some((name, size)), - Some((_, current_size)) if size > *current_size => { - largest = Some((name, size)); - } - _ => {} - } - } - - largest -} - -/// Inspect and log all library details -fn inspect_libraries() { - info!("=== Library Inspection Started ==="); - - let libraries = match library::get_all_libraries() { - Ok(libs) => libs, - Err(e) => { - error!("Failed to get libraries: {}", e); - return; - } - }; - - if libraries.is_empty() { - info!("No libraries configured"); - return; - } - - info!("Found {} libraries", libraries.len()); - - for lib in &libraries { - info!("----------------------------------------"); - info!("Library: {} (ID: {})", lib.name, lib.id); - info!(" Songs: {} tracks", lib.total_songs); - info!(" Albums: {}", lib.total_albums); - info!(" Artists: {}", lib.total_artists); - info!(" Size: {}", format_size(lib.total_size)); - info!(" Duration: {}", format_duration(lib.total_duration)); - - // If we have filesystem access, find the largest file - if !lib.mount_point.is_empty() { - info!(" Mount: {}", lib.mount_point); - - match find_largest_file(&lib.mount_point) { - Some((name, size)) => { - info!( - " Largest file in root: {} ({})", - name, - format_size(size as i64) - ); - } - None => { - info!(" Largest file in root: (no files found)"); - } - } - } else { - info!(" (Filesystem access not enabled)"); - } - } - - info!("=== Library Inspection Complete ==="); -} diff --git a/plugins/examples/minimal/README.md b/plugins/examples/minimal/README.md deleted file mode 100644 index 549a98a8f..000000000 --- a/plugins/examples/minimal/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# Minimal Navidrome Plugin Example - -This is a minimal example demonstrating how to create a Navidrome plugin using Go and the Navidrome PDK. - -## Building - -1. Install [TinyGo](https://tinygo.org/getting-started/install/) -2. Build the plugin: - ```bash - go mod tidy - tinygo build -o plugin.wasm -target wasip1 -buildmode=c-shared . - zip -j minimal.ndp manifest.json plugin.wasm - ``` - -Or using the examples Makefile: - ```bash - cd plugins/examples - make minimal.ndp - ``` - -## Installing - -Copy `minimal.ndp` to your Navidrome plugins folder (default: `/plugins/`). - -## Configuration - -Enable plugins in your `navidrome.toml`: - -```toml -[Plugins] -Enabled = true - -# Add the plugin to your agents list -Agents = "lastfm,spotify,minimal" -``` - -## What This Example Demonstrates - -- Plugin package structure (`.ndp` = zip with `manifest.json` + `plugin.wasm`) -- Using the Navidrome PDK `metadata` subpackage -- Implementing the `ArtistBiographyProvider` interface -- Registration pattern with `metadata.Register()` - -## PDK Usage - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/metadata" - -type myPlugin struct{} - -func init() { - metadata.Register(&myPlugin{}) -} - -func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (metadata.ArtistBiographyResponse, error) { - return metadata.ArtistBiographyResponse{Biography: "..."}, nil -} -``` - -## Extending the Example - -To add more capabilities, implement additional provider interfaces from the `metadata` package: - -- `ArtistMBIDProvider` - Get MusicBrainz ID for an artist -- `ArtistURLProvider` - Get external URL for an artist -- `SimilarArtistsProvider` - Get similar artists -- `ArtistImagesProvider` - Get artist images -- `ArtistTopSongsProvider` - Get top songs for an artist -- `AlbumInfoProvider` - Get album information -- `AlbumImagesProvider` - Get album images - -See the full documentation in `/plugins/README.md` for input/output formats. diff --git a/plugins/examples/minimal/go.mod b/plugins/examples/minimal/go.mod deleted file mode 100644 index b8f6c5fc0..000000000 --- a/plugins/examples/minimal/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module minimal-plugin - -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/examples/minimal/go.sum b/plugins/examples/minimal/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/examples/minimal/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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/examples/minimal/main.go b/plugins/examples/minimal/main.go deleted file mode 100644 index 18303891c..000000000 --- a/plugins/examples/minimal/main.go +++ /dev/null @@ -1,31 +0,0 @@ -// Minimal example Navidrome plugin demonstrating the MetadataAgent capability. -// -// Build with: -// -// tinygo build -o minimal.wasm -target wasip1 -buildmode=c-shared . -// -// Install by copying minimal.ndp to your Navidrome plugins folder. -package main - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/metadata" -) - -// minimalPlugin implements the metadata provider interfaces. -type minimalPlugin struct{} - -// init registers the plugin implementation -func init() { - metadata.Register(&minimalPlugin{}) -} - -var _ metadata.ArtistBiographyProvider = (*minimalPlugin)(nil) - -// GetArtistBiography returns a placeholder biography for the artist. -func (p *minimalPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) { - return &metadata.ArtistBiographyResponse{ - Biography: "This is a placeholder biography for " + input.Name + ".", - }, nil -} - -func main() {} diff --git a/plugins/examples/minimal/manifest.json b/plugins/examples/minimal/manifest.json deleted file mode 100644 index de0c4d75d..000000000 --- a/plugins/examples/minimal/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Minimal Example", - "author": "Navidrome", - "version": "1.0.0", - "description": "A minimal example plugin" -} diff --git a/plugins/examples/nowplaying-py/Makefile b/plugins/examples/nowplaying-py/Makefile deleted file mode 100644 index 2bf6ea971..000000000 --- a/plugins/examples/nowplaying-py/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -# Build the Now Playing Logger Python plugin -.PHONY: build test clean - -WASM_FILE = nowplaying-py.wasm - -build: $(WASM_FILE) - -$(WASM_FILE): plugin/__init__.py - extism-py plugin/__init__.py -o $(WASM_FILE) - -clean: - rm -f $(WASM_FILE) diff --git a/plugins/examples/nowplaying-py/README.md b/plugins/examples/nowplaying-py/README.md deleted file mode 100644 index ac4ba26f7..000000000 --- a/plugins/examples/nowplaying-py/README.md +++ /dev/null @@ -1,112 +0,0 @@ -# Now Playing Logger Plugin (Python) - -A Python example plugin that demonstrates the **Scheduler** and **SubsonicAPI** host services by periodically logging what is currently playing in Navidrome. - -## Features - -- Uses `scheduler_schedulerecurring` host function to set up a recurring task -- Uses `subsonicapi_call` host function to query the `getNowPlaying` API -- Configurable cron expression and user via plugin config -- Demonstrates Python host function imports using `@extism.import_fn` - -## Prerequisites - -- [extism-py](https://github.com/extism/python-pdk) - Python PDK compiler - ```bash - curl -Ls https://raw.githubusercontent.com/extism/python-pdk/main/install.sh | bash - ``` - -> **Note:** `extism-py` requires [Binaryen](https://github.com/WebAssembly/binaryen/) (`wasm-merge`, `wasm-opt`) to be installed. - -## Building - -From the `plugins/examples` directory: - -```bash -make nowplaying-py.ndp -``` - -Or directly: - -```bash -extism-py plugin/__init__.py -o plugin.wasm -zip -j nowplaying-py.ndp manifest.json plugin.wasm -``` - -## Installation - -1. Copy `nowplaying-py.ndp` to your Navidrome plugins folder - -2. Enable plugins in `navidrome.toml`: - ```toml - [Plugins] - Enabled = true - Folder = "/path/to/plugins" - ``` - -3. Configure the plugin in the UI (Settings → Plugins → nowplaying-py) - -## Configuration - -| Key | Description | Default | -|--------|-------------------------------------|---------------| -| `cron` | Cron expression for check frequency | `*/1 * * * *` | -| `user` | Navidrome user for SubsonicAPI | `admin` | - -## Testing - -Test the manifest: - -```bash -extism call nowplaying-py.wasm nd_manifest --wasi -``` - -## Output - -When running, the plugin logs messages like: - -``` -🎵 john is playing: Pink Floyd - Comfortably Numb (The Wall) -🎵 jane is playing: Radiohead - Paranoid Android (OK Computer) -``` - -Or when no one is playing: - -``` -🎵 No users currently playing music -``` - -## How It Works - -1. **Initialization (`nd_on_init`)**: Reads the cron expression from config and schedules a recurring task using the Scheduler host service. - -2. **Callback (`nd_scheduler_callback`)**: When the scheduled task fires, calls the SubsonicAPI `getNowPlaying` endpoint and logs the results. - -## Host Function Usage (Python) - -This plugin demonstrates how to call Navidrome host functions from Python: - -```python -import extism -import json - -# Import the host function -@extism.import_fn("extism:host/user", "subsonicapi_call") -def _subsonicapi_call(offset: int) -> int: - """Raw host function - returns memory offset.""" - ... - -# Wrapper for JSON marshalling -def subsonicapi_call(uri: str) -> dict: - request = {"uri": uri} - request_bytes = json.dumps(request).encode('utf-8') - request_mem = extism.memory.alloc(request_bytes) - response_offset = _subsonicapi_call(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise Exception(response["error"]) - - return json.loads(response.get("responseJSON", "{}")) -``` \ No newline at end of file diff --git a/plugins/examples/nowplaying-py/manifest.json b/plugins/examples/nowplaying-py/manifest.json deleted file mode 100644 index 6284c4617..000000000 --- a/plugins/examples/nowplaying-py/manifest.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "Now Playing Logger (Python)", - "author": "Navidrome", - "version": "1.0.0", - "description": "Periodically logs currently playing tracks - Python example demonstrating Scheduler and SubsonicAPI host services", - "website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/nowplaying-py", - "permissions": { - "scheduler": { - "reason": "Schedule periodic checks for now playing status" - }, - "subsonicapi": { - "reason": "Query the getNowPlaying API endpoint" - }, - "users": { - "reason": "Access user information for SubsonicAPI authorization" - } - } -} diff --git a/plugins/examples/nowplaying-py/plugin/__init__.py b/plugins/examples/nowplaying-py/plugin/__init__.py deleted file mode 100644 index f7453fdb7..000000000 --- a/plugins/examples/nowplaying-py/plugin/__init__.py +++ /dev/null @@ -1,168 +0,0 @@ -# Now Playing Logger Plugin for Navidrome -# -# This plugin demonstrates the Scheduler and SubsonicAPI host services by -# periodically logging what is currently playing in Navidrome. -# -# Build with: -# extism-py plugin/__init__.py -o nowplaying-py.wasm -# -# Configuration: -# [PluginConfig.nowplaying-py] -# cron = "*/1 * * * *" # Every minute (default) -# user = "admin" # User to query getNowPlaying (default) - -import extism -import json - -# Schedule ID for our recurring task -SCHEDULE_ID = "nowplaying-check" - - -# ============================================================================= -# Host Function Imports -# ============================================================================= -# These are custom host functions provided by Navidrome. -# We import them using the extism:host/user namespace. - - -@extism.import_fn("extism:host/user", "scheduler_schedulerecurring") -def _scheduler_schedulerecurring(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -@extism.import_fn("extism:host/user", "subsonicapi_call") -def _subsonicapi_call(offset: int) -> int: - """Raw host function - do not call directly.""" - ... - - -# ============================================================================= -# Host Function Wrappers -# ============================================================================= -# These wrappers handle JSON marshalling/unmarshalling and memory management. -# They were copied from plugins/host/python due to extism-py limitations. - - -def scheduler_schedule_recurring(cron_expression: str, payload: str, schedule_id: str) -> str: - """Schedule a recurring task using a cron expression. - - Args: - cron_expression: Cron format (e.g., "*/1 * * * *" for every minute) - payload: Data to pass to the callback - schedule_id: Unique identifier for the schedule - - Returns: - The schedule ID (same as input or auto-generated) - """ - request = { - "cronExpression": cron_expression, - "payload": payload, - "scheduleId": schedule_id - } - request_bytes = json.dumps(request).encode('utf-8') - request_mem = extism.memory.alloc(request_bytes) - response_offset = _scheduler_schedulerecurring(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise Exception(response["error"]) - - return response.get("newScheduleId", schedule_id) - - -def subsonicapi_call(uri: str) -> dict: - """Call a Subsonic API endpoint. - - Args: - uri: API path (e.g., "getNowPlaying") - - Returns: - Parsed JSON response from the API - """ - request = {"uri": uri} - request_bytes = json.dumps(request).encode('utf-8') - request_mem = extism.memory.alloc(request_bytes) - response_offset = _subsonicapi_call(request_mem.offset) - response_mem = extism.memory.find(response_offset) - response = json.loads(extism.memory.string(response_mem)) - - if response.get("error"): - raise Exception(response["error"]) - - # Parse the nested JSON response - response_json = response.get("responseJson", "{}") - return json.loads(response_json) - - -# ============================================================================= -# Plugin Exports -# ============================================================================= - - -@extism.plugin_fn -def nd_on_init(): - """Initialize the plugin by scheduling the recurring task.""" - # Read cron expression from config, default to every minute - cron = extism.Config.get_str("cron") - if not cron: - cron = "*/1 * * * *" - - extism.log(extism.LogLevel.Info, f"Now Playing Logger initializing with cron: {cron}") - - try: - schedule_id = scheduler_schedule_recurring(cron, "check", SCHEDULE_ID) - extism.log(extism.LogLevel.Info, f"Scheduled recurring task with ID: {schedule_id}") - except Exception as e: - extism.log(extism.LogLevel.Error, f"Failed to schedule task: {e}") - raise - # No output - lifecycle callbacks don't return responses - - -@extism.plugin_fn -def nd_scheduler_callback(): - """Handle scheduler callback - check and log now playing tracks.""" - input_data = extism.input_json() - schedule_id = input_data.get("scheduleId", "") - - # Only handle our schedule - if schedule_id != SCHEDULE_ID: - return - - try: - # Read user from config, default to admin - user = extism.Config.get_str("user") - if not user: - user = "admin" - - # Call the getNowPlaying API - response = subsonicapi_call(f"getNowPlaying?u={user}") - - # Extract the subsonic-response - subsonic_response = response.get("subsonic-response", {}) - now_playing = subsonic_response.get("nowPlaying", {}) - entries = now_playing.get("entry", []) - - if not entries: - extism.log(extism.LogLevel.Info, "🎵 No users currently playing music") - else: - # Handle both single entry and list of entries - if isinstance(entries, dict): - entries = [entries] - - for entry in entries: - artist = entry.get("artist", "Unknown Artist") - title = entry.get("title", "Unknown Title") - album = entry.get("album", "Unknown Album") - username = entry.get("username", "Unknown User") - - extism.log( - extism.LogLevel.Info, - f"🎵 {username} is playing: {artist} - {title} ({album})" - ) - # No output - scheduler callbacks don't return responses - - except Exception as e: - extism.log(extism.LogLevel.Error, f"Failed to get now playing: {e}") - # Errors are logged but scheduler callbacks don't return responses diff --git a/plugins/examples/webhook-rs/.cargo/config.toml b/plugins/examples/webhook-rs/.cargo/config.toml deleted file mode 100644 index 6b509f5b7..000000000 --- a/plugins/examples/webhook-rs/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -target = "wasm32-wasip1" diff --git a/plugins/examples/webhook-rs/Cargo.toml b/plugins/examples/webhook-rs/Cargo.toml deleted file mode 100644 index d74e180fd..000000000 --- a/plugins/examples/webhook-rs/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "webhook-rs" -version = "1.0.0" -edition = "2021" -description = "Navidrome webhook plugin that sends HTTP requests on scrobble events" -authors = ["Navidrome Team"] -license = "GPL-3.0" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nd-pdk = { path = "../../pdk/rust/nd-pdk" } -extism-pdk = "1.2" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" diff --git a/plugins/examples/webhook-rs/README.md b/plugins/examples/webhook-rs/README.md deleted file mode 100644 index 86afad9bb..000000000 --- a/plugins/examples/webhook-rs/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# Webhook Scrobbler Plugin (Rust) - -A Navidrome plugin written in Rust that sends HTTP webhook notifications when tracks are scrobbled. This is useful for integrating with external services like home automation systems, Discord bots, monitoring tools, or any service that can receive HTTP requests. - -## Features - -- Sends HTTP GET requests to configured URLs on every scrobble event -- Includes track metadata (title, artist, album, username, timestamp) as query parameters -- Supports multiple webhook URLs (comma-separated) -- All users are automatically authorized (no external service authentication required) -- Now playing events are ignored (webhooks fire only on completed scrobbles) - -## Prerequisites - -- [Rust](https://rustup.rs/) toolchain -- WebAssembly target: `rustup target add wasm32-unknown-unknown` - -## Building - -From the `plugins/examples` directory: - -```bash -make webhook-rs.ndp -``` - -Or build directly with cargo: - -```bash -cd webhook-rs -cargo build --release -zip -j webhook-rs.ndp manifest.json target/wasm32-unknown-unknown/release/webhook_rs.wasm -``` - -## Installation - -Copy `webhook-rs.ndp` to your Navidrome plugins folder (configured via `Plugins.Folder` in your config). - -## Configuration - -Configure in the Navidrome UI (Settings → Plugins → webhook-rs): - -| Key | Description | Example | -|--------|--------------------------------------|-----------------------------------------------------------| -| `urls` | Comma-separated list of webhook URLs | `https://example.com/hook1,https://example.com/hook2` | - -## Webhook Request Format - -When a scrobble occurs, the plugin sends an HTTP GET request to each configured URL with the following query parameters: - -| Parameter | Description | -|-------------|-----------------------------------------------| -| `title` | Track title | -| `artist` | Track artist | -| `album` | Album name | -| `user` | Username who scrobbled | -| `timestamp` | Unix timestamp when the track started playing | - -Example request: -``` -GET https://example.com/webhook?title=Song%20Name&artist=Artist%20Name&album=Album%20Name&user=john×tamp=1703270400 -``` - -## Use Cases - -- **Home Automation**: Trigger lights or displays when music starts playing -- **Discord/Slack Notifications**: Post currently playing tracks to a channel -- **Logging/Analytics**: Track listening history in an external system -- **IFTTT/Zapier Integration**: Connect to thousands of services via webhook triggers - -## Development - -The plugin is built using the [Extism Rust PDK](https://github.com/extism/rust-pdk). Key exports: - -- `nd_manifest` - Returns plugin metadata and permissions -- `nd_scrobbler_is_authorized` - Always returns `true` (all users authorized) -- `nd_scrobbler_now_playing` - No-op (returns success without action) -- `nd_scrobbler_scrobble` - Sends webhooks to configured URLs diff --git a/plugins/examples/webhook-rs/manifest.json b/plugins/examples/webhook-rs/manifest.json deleted file mode 100644 index 88048a747..000000000 --- a/plugins/examples/webhook-rs/manifest.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "Webhook Scrobbler", - "author": "Navidrome Team", - "version": "1.0.0", - "description": "Sends HTTP webhooks on scrobble events", - "website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/webhook-rs", - "permissions": { - "http": { - "reason": "To send webhook notifications to configured URLs", - "requiredHosts": ["*"] - }, - "users": { - "reason": "Receive scrobble events for users assigned to this plugin" - } - } -} diff --git a/plugins/examples/webhook-rs/src/lib.rs b/plugins/examples/webhook-rs/src/lib.rs deleted file mode 100644 index 5f5d972a0..000000000 --- a/plugins/examples/webhook-rs/src/lib.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Webhook Scrobbler Plugin for Navidrome -//! -//! This plugin demonstrates how to build a Navidrome plugin in Rust using the nd-pdk crate. -//! It implements the Scrobbler capability and sends HTTP GET requests to configured URLs -//! whenever a track is scrobbled. -//! -//! ## Configuration -//! -//! Set the `urls` config key to a comma-separated list of webhook URLs: -//! ```toml -//! [PluginConfig.webhook-rs] -//! urls = "https://example.com/webhook1,https://example.com/webhook2" -//! ``` - -use extism_pdk::{config, error, info, warn}; -use nd_pdk::host::http::{self, HTTPRequest}; -use nd_pdk::scrobbler::{ - Error, IsAuthorizedRequest, NowPlayingRequest, PlaybackReportRequest, ScrobbleRequest, - Scrobbler, -}; - -// Register the WASM exports for the Scrobbler capability -nd_pdk::register_scrobbler!(WebhookPlugin); - -// ============================================================================ -// Plugin Implementation -// ============================================================================ - -/// The webhook plugin type. Implements the Scrobbler trait. -#[derive(Default)] -struct WebhookPlugin; - -impl Scrobbler for WebhookPlugin { - /// Checks if a user is authorized. This plugin authorizes all users. - fn is_authorized(&self, req: IsAuthorizedRequest) -> Result { - info!("Authorization check for user: {}", req.username); - Ok(true) - } - - /// Handles now playing notifications. This plugin ignores them (webhooks only on scrobble). - fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error> { - info!( - "Now playing (ignored): {} - {} for user {}", - req.track.artist, req.track.title, req.username - ); - Ok(()) - } - - /// Handles playback state reports. This plugin ignores them (webhooks only on scrobble). - fn playback_report(&self, req: PlaybackReportRequest) -> Result<(), Error> { - info!( - "Playback report (ignored): {} - {} for user {} (state: {})", - req.track.artist, req.track.title, req.username, req.state - ); - Ok(()) - } - - /// Handles scrobble events by sending HTTP GET requests to configured URLs. - fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error> { - // Get configured URLs - let urls_config = match config::get("urls") { - Ok(Some(urls)) if !urls.is_empty() => urls, - _ => { - warn!("No webhook URLs configured. Set 'urls' in plugin config."); - return Ok(()); - } - }; - - info!( - "Scrobble: {} - {} by user {}", - req.track.artist, req.track.title, req.username - ); - - // Build query parameters - let query = format!( - "?title={}&artist={}&album={}&user={}×tamp={}", - urlencode(&req.track.title), - urlencode(&req.track.artist), - urlencode(&req.track.album), - urlencode(&req.username), - req.timestamp - ); - - // Send requests to each configured URL - for url in urls_config.split(',') { - let url = url.trim(); - if url.is_empty() { - continue; - } - - let full_url = format!("{}{}", url, query); - info!("Sending webhook to: {}", full_url); - - let http_req = HTTPRequest { - method: "GET".into(), - url: full_url, - ..Default::default() - }; - match http::send(http_req) { - Ok(res) => { - let status = res.map_or(0, |r| r.status_code); - if (200..300).contains(&status) { - info!("Webhook succeeded: {} (status {})", url, status); - } else { - warn!("Webhook returned non-2xx status: {} (status {})", url, status); - } - } - Err(e) => { - error!("Webhook failed for {}: {:?}", url, e); - } - } - } - - Ok(()) - } -} - -/// Simple URL encoding for query parameters. -fn urlencode(s: &str) -> String { - let mut result = String::with_capacity(s.len() * 3); - for c in s.chars() { - match c { - 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c), - ' ' => result.push_str("%20"), - _ => { - for b in c.to_string().as_bytes() { - result.push_str(&format!("%{:02X}", b)); - } - } - } - } - result -} diff --git a/plugins/examples/wikimedia/README.md b/plugins/examples/wikimedia/README.md deleted file mode 100644 index 181055c69..000000000 --- a/plugins/examples/wikimedia/README.md +++ /dev/null @@ -1,144 +0,0 @@ -# Wikimedia Plugin for Navidrome - -A Navidrome plugin that fetches artist metadata from Wikidata, DBpedia, and Wikipedia. - -## Generating the Plugin - -This plugin was generated using the XTP CLI: - -```bash -xtp plugin init \ - --schema-file plugins/schemas/metadata_agent.yaml \ - --template go \ - --path ./wikimedia \ - --name wikimedia-plugin -``` - -## Features - -- **Artist URL**: Fetches Wikipedia URL for an artist using Wikidata (by MBID or name), DBpedia, or falls back to a Wikipedia search URL -- **Artist Biography**: Fetches the introductory text from the artist's Wikipedia page -- **Artist Images**: Fetches artist images from Wikidata - -## Building - -### Using TinyGo - -```bash -tinygo build -target wasip1 -buildmode=c-shared -o plugin.wasm . -zip -j wikimedia.ndp manifest.json plugin.wasm -``` - -### Using the Makefile - -From the `plugins/examples` directory: - -```bash -make wikimedia.ndp -``` - -### Using XTP CLI - -```bash -xtp plugin build -zip -j wikimedia.ndp manifest.json dist/plugin.wasm -``` - -## Installation - -Copy the `.ndp` file to your Navidrome plugins folder: - -```bash -cp wikimedia.ndp /path/to/navidrome/plugins/ -``` - -Then enable plugins in your `navidrome.toml`: - -```toml -[Plugins] -Enabled = true -Folder = "/path/to/navidrome/plugins" -``` - -Add the plugin to your agents list: - -```toml -Agents = "lastfm,wikimedia" -``` - -## Testing with Extism CLI - -Install the [Extism CLI](https://extism.org/docs/install): - -```bash -brew install extism/tap/extism # macOS -# or see https://extism.org/docs/install for other platforms -``` - -Extract the wasm file from the package and test: - -```bash -# Extract wasm from package -unzip -p wikimedia.ndp plugin.wasm > wikimedia.wasm - -# Test artist URL lookup with MBID (The Beatles) -extism call wikimedia.wasm nd_get_artist_url --wasi \ - --input '{"id":"1","name":"The Beatles","mbid":"b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d"}' \ - --allow-host "query.wikidata.org" -``` - -Expected output: -```json -{"url":"https://en.wikipedia.org/wiki/The_Beatles"} -``` - -### Test artist biography - -```bash -extism call wikimedia.wasm nd_get_artist_biography --wasi \ - --input '{"id":"1","name":"The Beatles","mbid":"b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d"}' \ - --allow-host "query.wikidata.org" \ - --allow-host "en.wikipedia.org" -``` - -### Test artist images - -```bash -extism call wikimedia.wasm nd_get_artist_images --wasi \ - --input '{"id":"1","name":"The Beatles","mbid":"b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d"}' \ - --allow-host "query.wikidata.org" -``` - -Expected output: -```json -{"images":[{"url":"http://commons.wikimedia.org/wiki/Special:FilePath/Beatles%20ad%201965%20just%20the%20beatles%20crop.jpg","size":0}]} -``` - -## Project Structure - -``` -wikimedia/ -├── main.go # Plugin implementation with Wikimedia API logic -├── pdk.gen.go # Generated types and export wrappers (DO NOT EDIT) -├── go.mod # Go module file -├── go.sum # Go module checksums -├── prepare.sh # Build preparation script -└── xtp.toml # XTP plugin configuration -``` - -## API Endpoints Used - -| Service | Endpoint | Purpose | -|-----------|--------------------------------------|-----------------------------------------------------------| -| Wikidata | `https://query.wikidata.org/sparql` | SPARQL queries for Wikipedia URLs and images | -| DBpedia | `https://dbpedia.org/sparql` | Fallback SPARQL queries for Wikipedia URLs and short bios | -| Wikipedia | `https://en.wikipedia.org/w/api.php` | MediaWiki API for article extracts | - -## Implemented Functions - -| Function | Description | -|---------------------------|-----------------------------------------------| -| `nd_manifest` | Returns plugin manifest with HTTP permissions | -| `nd_get_artist_url` | Returns Wikipedia URL for an artist | -| `nd_get_artist_biography` | Returns artist biography from Wikipedia | -| `nd_get_artist_images` | Returns artist image URLs from Wikidata | diff --git a/plugins/examples/wikimedia/go.mod b/plugins/examples/wikimedia/go.mod deleted file mode 100644 index 17f14b065..000000000 --- a/plugins/examples/wikimedia/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module wikimedia-plugin - -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/examples/wikimedia/go.sum b/plugins/examples/wikimedia/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/examples/wikimedia/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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/examples/wikimedia/main.go b/plugins/examples/wikimedia/main.go deleted file mode 100644 index 8508354cf..000000000 --- a/plugins/examples/wikimedia/main.go +++ /dev/null @@ -1,366 +0,0 @@ -// Wikimedia plugin for Navidrome - fetches artist metadata from Wikidata, DBpedia and Wikipedia. -// -// Build with: -// -// tinygo build -o wikimedia.wasm -target wasip1 -buildmode=c-shared . -// -// Install by copying the .ndp file to your Navidrome plugins folder. -package main - -import ( - "encoding/json" - "errors" - "fmt" - "net/url" - "strings" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/metadata" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// wikimediaPlugin implements the metadata provider interfaces for the methods we support. -type wikimediaPlugin struct{} - -// init registers the plugin implementation -func init() { - metadata.Register(&wikimediaPlugin{}) -} - -// Ensure wikimediaPlugin implements the provider interfaces -var ( - _ metadata.ArtistURLProvider = (*wikimediaPlugin)(nil) - _ metadata.ArtistBiographyProvider = (*wikimediaPlugin)(nil) - _ metadata.ArtistImagesProvider = (*wikimediaPlugin)(nil) -) - -const ( - wikidataEndpoint = "https://query.wikidata.org/sparql" - dbpediaEndpoint = "https://dbpedia.org/sparql" - mediawikiAPIEndpoint = "https://en.wikipedia.org/w/api.php" -) - -// SPARQL response types -type SPARQLResult struct { - Results struct { - Bindings []SPARQLBinding `json:"bindings"` - } `json:"results"` -} - -type SPARQLBinding struct { - Sitelink *SPARQLValue `json:"sitelink,omitempty"` - Wiki *SPARQLValue `json:"wiki,omitempty"` - Comment *SPARQLValue `json:"comment,omitempty"` - Img *SPARQLValue `json:"img,omitempty"` -} - -type SPARQLValue struct { - Value string `json:"value"` -} - -// MediaWiki API response types -type MediaWikiExtractResult struct { - Query struct { - Pages map[string]MediaWikiPage `json:"pages"` - } `json:"query"` -} - -type MediaWikiPage struct { - PageID int `json:"pageid"` - Ns int `json:"ns"` - Title string `json:"title"` - Extract string `json:"extract"` - Missing bool `json:"missing"` -} - -// sparqlQuery executes a SPARQL query and returns the result -func sparqlQuery(endpoint, query string) (*SPARQLResult, error) { - form := url.Values{} - form.Set("query", query) - - pdk.Log(pdk.LogDebug, fmt.Sprintf("SPARQL query to %s: %s", endpoint, query)) - - resp, err := host.HTTPSend(host.HTTPRequest{ - Method: "POST", - URL: endpoint, - Headers: map[string]string{ - "Accept": "application/sparql-results+json", - "Content-Type": "application/x-www-form-urlencoded", - "User-Agent": "NavidromeWikimediaPlugin/1.0", - }, - Body: []byte(form.Encode()), - TimeoutMs: 10000, - }) - if err != nil { - return nil, fmt.Errorf("SPARQL HTTP error: %w", err) - } - if resp.StatusCode != 200 { - return nil, fmt.Errorf("SPARQL HTTP error: status %d", resp.StatusCode) - } - - var result SPARQLResult - if err := json.Unmarshal(resp.Body, &result); err != nil { - return nil, fmt.Errorf("failed to parse SPARQL response: %w", err) - } - if len(result.Results.Bindings) == 0 { - return nil, errors.New("not found") - } - return &result, nil -} - -// mediawikiQuery executes a MediaWiki API query -func mediawikiQuery(params url.Values) ([]byte, error) { - apiURL := fmt.Sprintf("%s?%s", mediawikiAPIEndpoint, params.Encode()) - - resp, err := host.HTTPSend(host.HTTPRequest{ - Method: "GET", - URL: apiURL, - Headers: map[string]string{ - "Accept": "application/json", - "User-Agent": "NavidromeWikimediaPlugin/1.0", - }, - TimeoutMs: 10000, - }) - if err != nil { - return nil, fmt.Errorf("MediaWiki HTTP error: %w", err) - } - if resp.StatusCode != 200 { - return nil, fmt.Errorf("MediaWiki HTTP error: status %d", resp.StatusCode) - } - return resp.Body, nil -} - -// getWikidataWikipediaURL fetches the Wikipedia URL from Wikidata using MBID or name -func getWikidataWikipediaURL(mbid, name string) (string, error) { - var q string - if mbid != "" { - q = fmt.Sprintf(`SELECT ?sitelink WHERE { ?artist wdt:P434 "%s". ?sitelink schema:about ?artist; schema:isPartOf . } LIMIT 1`, mbid) - } else if name != "" { - escapedName := strings.ReplaceAll(name, "\"", "\\\"") - q = fmt.Sprintf(`SELECT ?sitelink WHERE { ?artist rdfs:label "%s"@en. ?sitelink schema:about ?artist; schema:isPartOf . } LIMIT 1`, escapedName) - } else { - return "", errors.New("MBID or Name required for Wikidata URL lookup") - } - - result, err := sparqlQuery(wikidataEndpoint, q) - if err != nil { - return "", err - } - if result.Results.Bindings[0].Sitelink != nil { - return result.Results.Bindings[0].Sitelink.Value, nil - } - return "", errors.New("not found") -} - -// getDBpediaWikipediaURL fetches the Wikipedia URL from DBpedia using name -func getDBpediaWikipediaURL(name string) (string, error) { - if name == "" { - return "", errors.New("not found") - } - escapedName := strings.ReplaceAll(name, "\"", "\\\"") - q := fmt.Sprintf(`SELECT ?wiki WHERE { ?artist foaf:name "%s"@en; foaf:isPrimaryTopicOf ?wiki. FILTER regex(str(?wiki), "^https://en.wikipedia.org/") } LIMIT 1`, escapedName) - - result, err := sparqlQuery(dbpediaEndpoint, q) - if err != nil { - return "", err - } - if result.Results.Bindings[0].Wiki != nil { - return result.Results.Bindings[0].Wiki.Value, nil - } - return "", errors.New("not found") -} - -// getDBpediaComment fetches the DBpedia comment (short bio) for an artist -func getDBpediaComment(name string) (string, error) { - if name == "" { - return "", errors.New("not found") - } - escapedName := strings.ReplaceAll(name, "\"", "\\\"") - q := fmt.Sprintf(`SELECT ?comment WHERE { ?artist foaf:name "%s"@en; rdfs:comment ?comment. FILTER (lang(?comment) = 'en') } LIMIT 1`, escapedName) - - result, err := sparqlQuery(dbpediaEndpoint, q) - if err != nil { - return "", err - } - if result.Results.Bindings[0].Comment != nil { - return result.Results.Bindings[0].Comment.Value, nil - } - return "", errors.New("not found") -} - -// getWikipediaExtract fetches the intro text from Wikipedia -func getWikipediaExtract(pageTitle string) (string, error) { - if pageTitle == "" { - return "", errors.New("page title required") - } - params := url.Values{} - params.Set("action", "query") - params.Set("format", "json") - params.Set("prop", "extracts") - params.Set("exintro", "true") - params.Set("explaintext", "true") - params.Set("titles", pageTitle) - params.Set("redirects", "1") - - body, err := mediawikiQuery(params) - if err != nil { - return "", err - } - - var result MediaWikiExtractResult - if err := json.Unmarshal(body, &result); err != nil { - return "", fmt.Errorf("failed to parse MediaWiki response: %w", err) - } - - for _, page := range result.Query.Pages { - if page.Missing { - continue - } - if page.Extract != "" { - return strings.TrimSpace(page.Extract), nil - } - } - return "", errors.New("not found") -} - -// extractPageTitleFromURL extracts the page title from a Wikipedia URL -func extractPageTitleFromURL(wikiURL string) (string, error) { - parsedURL, err := url.Parse(wikiURL) - if err != nil { - return "", err - } - if parsedURL.Host != "en.wikipedia.org" { - return "", fmt.Errorf("URL host is not en.wikipedia.org: %s", parsedURL.Host) - } - pathParts := strings.Split(strings.TrimPrefix(parsedURL.Path, "/"), "/") - if len(pathParts) < 2 || pathParts[0] != "wiki" { - return "", fmt.Errorf("URL path does not match /wiki/ format: %s", parsedURL.Path) - } - title := pathParts[1] - if title == "" { - return "", errors.New("extracted title is empty") - } - decodedTitle, err := url.PathUnescape(title) - if err != nil { - return "", fmt.Errorf("failed to decode title '%s': %w", title, err) - } - return decodedTitle, nil -} - -// GetArtistURL returns the Wikipedia URL for an artist -func (*wikimediaPlugin) GetArtistURL(input metadata.ArtistRequest) (*metadata.ArtistURLResponse, error) { - pdk.Log(pdk.LogDebug, fmt.Sprintf("GetArtistURL: name=%s, mbid=%s", input.Name, input.MBID)) - - // 1. Try Wikidata (MBID first, then name) - wikiURL, err := getWikidataWikipediaURL(input.MBID, input.Name) - if err == nil && wikiURL != "" { - return &metadata.ArtistURLResponse{URL: wikiURL}, nil - } - if err != nil { - pdk.Log(pdk.LogDebug, fmt.Sprintf("Wikidata URL failed: %v", err)) - } - - // 2. Try DBpedia (Name only) - if input.Name != "" { - wikiURL, err = getDBpediaWikipediaURL(input.Name) - if err == nil && wikiURL != "" { - return &metadata.ArtistURLResponse{URL: wikiURL}, nil - } - if err != nil { - pdk.Log(pdk.LogDebug, fmt.Sprintf("DBpedia URL failed: %v", err)) - } - } - - // 3. Fallback to search URL - if input.Name != "" { - searchURL := fmt.Sprintf("https://en.wikipedia.org/w/index.php?search=%s", url.QueryEscape(input.Name)) - pdk.Log(pdk.LogInfo, fmt.Sprintf("URL not found, falling back to search URL: %s", searchURL)) - return &metadata.ArtistURLResponse{URL: searchURL}, nil - } - - return nil, errors.New("could not determine Wikipedia URL") -} - -// GetArtistBiography returns the biography for an artist from Wikipedia -func (*wikimediaPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) { - pdk.Log(pdk.LogDebug, fmt.Sprintf("GetArtistBiography: name=%s, mbid=%s", input.Name, input.MBID)) - - // 1. Get Wikipedia URL (using the logic from GetArtistURL) - wikiURL := "" - tempURL, wdErr := getWikidataWikipediaURL(input.MBID, input.Name) - if wdErr == nil && tempURL != "" { - pdk.Log(pdk.LogDebug, fmt.Sprintf("Found Wikidata URL: %s", tempURL)) - wikiURL = tempURL - } else if input.Name != "" { - pdk.Log(pdk.LogDebug, fmt.Sprintf("Wikidata URL failed (%v), trying DBpedia", wdErr)) - tempURL, dbErr := getDBpediaWikipediaURL(input.Name) - if dbErr == nil && tempURL != "" { - pdk.Log(pdk.LogDebug, fmt.Sprintf("Found DBpedia URL: %s", tempURL)) - wikiURL = tempURL - } else { - pdk.Log(pdk.LogDebug, fmt.Sprintf("DBpedia URL failed: %v", dbErr)) - } - } - - // 2. If Wikipedia URL found, try MediaWiki API - if wikiURL != "" { - pageTitle, err := extractPageTitleFromURL(wikiURL) - if err == nil { - pdk.Log(pdk.LogDebug, fmt.Sprintf("Extracted page title: %s", pageTitle)) - bio, err := getWikipediaExtract(pageTitle) - if err == nil && bio != "" { - pdk.Log(pdk.LogDebug, "Found Wikipedia extract") - return &metadata.ArtistBiographyResponse{Biography: bio}, nil - } - pdk.Log(pdk.LogDebug, fmt.Sprintf("Wikipedia extract failed: %v", err)) - } else { - pdk.Log(pdk.LogDebug, fmt.Sprintf("Error extracting page title from URL '%s': %v", wikiURL, err)) - } - } - - // 3. Fallback to DBpedia Comment (Name only) - if input.Name != "" { - pdk.Log(pdk.LogDebug, fmt.Sprintf("Falling back to DBpedia comment for name: %s", input.Name)) - bio, err := getDBpediaComment(input.Name) - if err == nil && bio != "" { - pdk.Log(pdk.LogDebug, "Found DBpedia comment") - return &metadata.ArtistBiographyResponse{Biography: bio}, nil - } - pdk.Log(pdk.LogDebug, fmt.Sprintf("DBpedia comment failed: %v", err)) - } - - pdk.Log(pdk.LogInfo, fmt.Sprintf("Biography not found for: %s (%s)", input.Name, input.MBID)) - return nil, errors.New("biography not found") -} - -// GetArtistImages returns artist images from Wikidata -func (*wikimediaPlugin) GetArtistImages(input metadata.ArtistRequest) (*metadata.ArtistImagesResponse, error) { - pdk.Log(pdk.LogDebug, fmt.Sprintf("GetArtistImages: name=%s, mbid=%s", input.Name, input.MBID)) - - var q string - if input.MBID != "" { - q = fmt.Sprintf(`SELECT ?img WHERE { ?artist wdt:P434 "%s"; wdt:P18 ?img } LIMIT 1`, input.MBID) - } else if input.Name != "" { - escapedName := strings.ReplaceAll(input.Name, "\"", "\\\"") - q = fmt.Sprintf(`SELECT ?img WHERE { ?artist rdfs:label "%s"@en; wdt:P18 ?img } LIMIT 1`, escapedName) - } else { - return nil, errors.New("MBID or Name required for Wikidata Image lookup") - } - - result, err := sparqlQuery(wikidataEndpoint, q) - if err != nil { - pdk.Log(pdk.LogInfo, fmt.Sprintf("Image not found for: %s (%s)", input.Name, input.MBID)) - return nil, errors.New("image not found") - } - if result.Results.Bindings[0].Img != nil { - return &metadata.ArtistImagesResponse{ - Images: []metadata.ImageInfo{{URL: result.Results.Bindings[0].Img.Value, Size: 0}}, - }, nil - } - - pdk.Log(pdk.LogInfo, fmt.Sprintf("Image not found for: %s (%s)", input.Name, input.MBID)) - return nil, errors.New("image not found") -} - -// Required main function - init() handles registration -func main() {} diff --git a/plugins/examples/wikimedia/manifest.json b/plugins/examples/wikimedia/manifest.json deleted file mode 100644 index 8590d51a8..000000000 --- a/plugins/examples/wikimedia/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "Wikimedia", - "author": "Navidrome", - "version": "1.0.0", - "description": "Fetches artist metadata from Wikidata, DBpedia and Wikipedia", - "website": "https://navidrome.org", - "permissions": { - "http": { - "reason": "Fetch metadata from Wikimedia APIs", - "requiredHosts": [ - "query.wikidata.org", - "dbpedia.org", - "en.wikipedia.org" - ] - } - } -} diff --git a/plugins/examples/wikimedia/prepare.sh b/plugins/examples/wikimedia/prepare.sh deleted file mode 100644 index 9fbb93cfb..000000000 --- a/plugins/examples/wikimedia/prepare.sh +++ /dev/null @@ -1,92 +0,0 @@ -#!/bin/bash -set -eou pipefail - -# Function to check if a command exists -command_exists () { - command -v "$1" >/dev/null 2>&1 -} - -# Function to compare version numbers for "less than" -version_lt() { - test "$(echo "$@" | tr " " "\n" | sort -V | head -n 1)" = "$1" && test "$1" != "$2" -} - -missing_deps=0 - -# Check for Go -if ! (command_exists go); then - missing_deps=1 - echo "❌ Go (supported version between 1.20 - 1.24) is not installed." - echo "" - echo "To install Go, visit the official download page:" - echo "👉 https://go.dev/dl/" - echo "" - echo "Or install it using a package manager:" - echo "" - echo "🔹 macOS (Homebrew):" - echo " brew install go" - echo "" - echo "🔹 Ubuntu/Debian:" - echo " sudo apt-get -y install golang-go" - echo "" - echo "🔹 Arch Linux:" - echo " sudo pacman -S go" - echo "" - echo "🔹 Windows:" - echo " scoop install go" - echo "" -fi - -# Check for the right version of Go, needed by TinyGo (supports go 1.20 - 1.24) -if (command_exists go); then - compat=0 - for v in `seq 20 24`; do - if (go version | grep -q "go1.$v"); then - compat=1 - fi - done - - if [ $compat -eq 0 ]; then - echo "❌ Supported Go version is not installed. Must be Go 1.20 - 1.24." - echo "" - fi -fi - -ARCH=$(arch) - -# Check for TinyGo and its version -if ! (command_exists tinygo); then - missing_deps=1 - echo "❌ TinyGo is not installed." - echo "" - echo "To install TinyGo, visit the official download page:" - echo "👉 https://tinygo.org/getting-started/install/" - echo "" - echo "Or install it using a package manager:" - echo "" - echo "🔹 macOS (Homebrew):" - echo " brew tap tinygo-org/tools" - echo " brew install tinygo" - echo "" - echo "🔹 Ubuntu/Debian:" - echo " wget https://github.com/tinygo-org/tinygo/releases/download/v0.34.0/tinygo_0.34.0_$ARCH.deb" - echo " sudo dpkg -i tinygo_0.34.0_$ARCH.deb" - echo "" - echo "🔹 Arch Linux:" - echo " pacman -S extra/tinygo" - echo "" - echo "🔹 Windows:" - echo " scoop install tinygo" - echo "" -else - # Check TinyGo version - tinygo_version=$(tinygo version | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+' | head -n1) - if version_lt "$tinygo_version" "0.34.0"; then - missing_deps=1 - echo "❌ TinyGo version must be >= 0.34.0 (current version: $tinygo_version)" - echo "Please update TinyGo to a newer version." - echo "" - fi -fi - -go install golang.org/x/tools/cmd/goimports@latest diff --git a/plugins/examples/wikimedia/xtp.toml b/plugins/examples/wikimedia/xtp.toml deleted file mode 100755 index 73000ebcd..000000000 --- a/plugins/examples/wikimedia/xtp.toml +++ /dev/null @@ -1,17 +0,0 @@ -app_id = "" - -# This is where 'xtp plugin push' expects to find the wasm file after the build script has run. -bin = "dist/plugin.wasm" -extension_point_id = "" -name = "wikimedia-plugin" - -[scripts] - - # xtp plugin build runs this script to generate the wasm file - build = "mkdir -p dist && tinygo build -buildmode c-shared -target wasip1 -o dist/plugin.wasm ." - - # xtp plugin init runs this script to format the plugin code - format = "go fmt && go mod tidy && goimports -w main.go" - - # xtp plugin init runs this script before running the format script - prepare = "bash prepare.sh && go get ./..." diff --git a/plugins/host/artwork.go b/plugins/host/artwork.go deleted file mode 100644 index 9b9d3e98e..000000000 --- a/plugins/host/artwork.go +++ /dev/null @@ -1,53 +0,0 @@ -package host - -import "context" - -// ArtworkService provides artwork public URL generation capabilities for plugins. -// -// This service allows plugins to generate public URLs for artwork images of -// various entity types (artists, albums, tracks, playlists). The generated URLs -// include authentication tokens and can be used to display artwork in external -// services or custom UIs. -// -//nd:hostservice name=Artwork permission=artwork -type ArtworkService interface { - // GetArtistUrl generates a public URL for an artist's artwork. - // - // Parameters: - // - id: The artist's unique identifier - // - size: Desired image size in pixels (0 for original size) - // - // Returns the public URL for the artwork, or an error if generation fails. - //nd:hostfunc - GetArtistUrl(ctx context.Context, id string, size int32) (url string, err error) - - // GetAlbumUrl generates a public URL for an album's artwork. - // - // Parameters: - // - id: The album's unique identifier - // - size: Desired image size in pixels (0 for original size) - // - // Returns the public URL for the artwork, or an error if generation fails. - //nd:hostfunc - GetAlbumUrl(ctx context.Context, id string, size int32) (url string, err error) - - // GetTrackUrl generates a public URL for a track's artwork. - // - // Parameters: - // - id: The track's (media file) unique identifier - // - size: Desired image size in pixels (0 for original size) - // - // Returns the public URL for the artwork, or an error if generation fails. - //nd:hostfunc - GetTrackUrl(ctx context.Context, id string, size int32) (url string, err error) - - // GetPlaylistUrl generates a public URL for a playlist's artwork. - // - // Parameters: - // - id: The playlist's unique identifier - // - size: Desired image size in pixels (0 for original size) - // - // Returns the public URL for the artwork, or an error if generation fails. - //nd:hostfunc - GetPlaylistUrl(ctx context.Context, id string, size int32) (url string, err error) -} diff --git a/plugins/host/artwork_gen.go b/plugins/host/artwork_gen.go deleted file mode 100644 index fbf807351..000000000 --- a/plugins/host/artwork_gen.go +++ /dev/null @@ -1,230 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// ArtworkGetArtistUrlRequest is the request type for Artwork.GetArtistUrl. -type ArtworkGetArtistUrlRequest struct { - Id string `json:"id"` - Size int32 `json:"size"` -} - -// ArtworkGetArtistUrlResponse is the response type for Artwork.GetArtistUrl. -type ArtworkGetArtistUrlResponse struct { - Url string `json:"url,omitempty"` - Error string `json:"error,omitempty"` -} - -// ArtworkGetAlbumUrlRequest is the request type for Artwork.GetAlbumUrl. -type ArtworkGetAlbumUrlRequest struct { - Id string `json:"id"` - Size int32 `json:"size"` -} - -// ArtworkGetAlbumUrlResponse is the response type for Artwork.GetAlbumUrl. -type ArtworkGetAlbumUrlResponse struct { - Url string `json:"url,omitempty"` - Error string `json:"error,omitempty"` -} - -// ArtworkGetTrackUrlRequest is the request type for Artwork.GetTrackUrl. -type ArtworkGetTrackUrlRequest struct { - Id string `json:"id"` - Size int32 `json:"size"` -} - -// ArtworkGetTrackUrlResponse is the response type for Artwork.GetTrackUrl. -type ArtworkGetTrackUrlResponse struct { - Url string `json:"url,omitempty"` - Error string `json:"error,omitempty"` -} - -// ArtworkGetPlaylistUrlRequest is the request type for Artwork.GetPlaylistUrl. -type ArtworkGetPlaylistUrlRequest struct { - Id string `json:"id"` - Size int32 `json:"size"` -} - -// ArtworkGetPlaylistUrlResponse is the response type for Artwork.GetPlaylistUrl. -type ArtworkGetPlaylistUrlResponse struct { - Url string `json:"url,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterArtworkHostFunctions registers Artwork service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterArtworkHostFunctions(service ArtworkService) []extism.HostFunction { - return []extism.HostFunction{ - newArtworkGetArtistUrlHostFunction(service), - newArtworkGetAlbumUrlHostFunction(service), - newArtworkGetTrackUrlHostFunction(service), - newArtworkGetPlaylistUrlHostFunction(service), - } -} - -func newArtworkGetArtistUrlHostFunction(service ArtworkService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "artwork_getartisturl", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - artworkWriteError(p, stack, err) - return - } - var req ArtworkGetArtistUrlRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - artworkWriteError(p, stack, err) - return - } - - // Call the service method - url, svcErr := service.GetArtistUrl(ctx, req.Id, req.Size) - if svcErr != nil { - artworkWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := ArtworkGetArtistUrlResponse{ - Url: url, - } - artworkWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newArtworkGetAlbumUrlHostFunction(service ArtworkService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "artwork_getalbumurl", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - artworkWriteError(p, stack, err) - return - } - var req ArtworkGetAlbumUrlRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - artworkWriteError(p, stack, err) - return - } - - // Call the service method - url, svcErr := service.GetAlbumUrl(ctx, req.Id, req.Size) - if svcErr != nil { - artworkWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := ArtworkGetAlbumUrlResponse{ - Url: url, - } - artworkWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newArtworkGetTrackUrlHostFunction(service ArtworkService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "artwork_gettrackurl", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - artworkWriteError(p, stack, err) - return - } - var req ArtworkGetTrackUrlRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - artworkWriteError(p, stack, err) - return - } - - // Call the service method - url, svcErr := service.GetTrackUrl(ctx, req.Id, req.Size) - if svcErr != nil { - artworkWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := ArtworkGetTrackUrlResponse{ - Url: url, - } - artworkWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newArtworkGetPlaylistUrlHostFunction(service ArtworkService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "artwork_getplaylisturl", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - artworkWriteError(p, stack, err) - return - } - var req ArtworkGetPlaylistUrlRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - artworkWriteError(p, stack, err) - return - } - - // Call the service method - url, svcErr := service.GetPlaylistUrl(ctx, req.Id, req.Size) - if svcErr != nil { - artworkWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := ArtworkGetPlaylistUrlResponse{ - Url: url, - } - artworkWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// artworkWriteResponse writes a JSON response to plugin memory. -func artworkWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - artworkWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// artworkWriteError writes an error response to plugin memory. -func artworkWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/host/cache.go b/plugins/host/cache.go deleted file mode 100644 index 37cb4da74..000000000 --- a/plugins/host/cache.go +++ /dev/null @@ -1,117 +0,0 @@ -package host - -import "context" - -// CacheService provides in-memory TTL-based caching capabilities for plugins. -// -// This service allows plugins to store and retrieve typed values (strings, integers, -// floats, and byte slices) with configurable time-to-live expiration. Each plugin's -// cache keys are automatically namespaced to prevent collisions between plugins. -// -// The cache is in-memory only and will be lost on server restart. Plugins should -// handle cache misses gracefully. -// -//nd:hostservice name=Cache permission=cache -type CacheService interface { - // SetString stores a string value in the cache. - // - // Parameters: - // - key: The cache key (will be namespaced with plugin ID) - // - value: The string value to store - // - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - // - // Returns an error if the operation fails. - //nd:hostfunc - SetString(ctx context.Context, key string, value string, ttlSeconds int64) error - - // GetString retrieves a string value from the cache. - // - // Parameters: - // - key: The cache key (will be namespaced with plugin ID) - // - // Returns the value and whether the key exists. If the key doesn't exist - // or the stored value is not a string, exists will be false. - //nd:hostfunc - GetString(ctx context.Context, key string) (value string, exists bool, err error) - - // SetInt stores an integer value in the cache. - // - // Parameters: - // - key: The cache key (will be namespaced with plugin ID) - // - value: The integer value to store - // - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - // - // Returns an error if the operation fails. - //nd:hostfunc - SetInt(ctx context.Context, key string, value int64, ttlSeconds int64) error - - // GetInt retrieves an integer value from the cache. - // - // Parameters: - // - key: The cache key (will be namespaced with plugin ID) - // - // Returns the value and whether the key exists. If the key doesn't exist - // or the stored value is not an integer, exists will be false. - //nd:hostfunc - GetInt(ctx context.Context, key string) (value int64, exists bool, err error) - - // SetFloat stores a float value in the cache. - // - // Parameters: - // - key: The cache key (will be namespaced with plugin ID) - // - value: The float value to store - // - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - // - // Returns an error if the operation fails. - //nd:hostfunc - SetFloat(ctx context.Context, key string, value float64, ttlSeconds int64) error - - // GetFloat retrieves a float value from the cache. - // - // Parameters: - // - key: The cache key (will be namespaced with plugin ID) - // - // Returns the value and whether the key exists. If the key doesn't exist - // or the stored value is not a float, exists will be false. - //nd:hostfunc - GetFloat(ctx context.Context, key string) (value float64, exists bool, err error) - - // SetBytes stores a byte slice in the cache. - // - // Parameters: - // - key: The cache key (will be namespaced with plugin ID) - // - value: The byte slice to store - // - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) - // - // Returns an error if the operation fails. - //nd:hostfunc - SetBytes(ctx context.Context, key string, value []byte, ttlSeconds int64) error - - // GetBytes retrieves a byte slice from the cache. - // - // Parameters: - // - key: The cache key (will be namespaced with plugin ID) - // - // Returns the value and whether the key exists. If the key doesn't exist - // or the stored value is not a byte slice, exists will be false. - //nd:hostfunc - GetBytes(ctx context.Context, key string) (value []byte, exists bool, err error) - - // Has checks if a key exists in the cache. - // - // Parameters: - // - key: The cache key (will be namespaced with plugin ID) - // - // Returns true if the key exists and has not expired. - //nd:hostfunc - Has(ctx context.Context, key string) (exists bool, err error) - - // Remove deletes a value from the cache. - // - // Parameters: - // - key: The cache key (will be namespaced with plugin ID) - // - // Returns an error if the operation fails. Does not return an error if the key doesn't exist. - //nd:hostfunc - Remove(ctx context.Context, key string) error -} diff --git a/plugins/host/cache_gen.go b/plugins/host/cache_gen.go deleted file mode 100644 index 5645c5495..000000000 --- a/plugins/host/cache_gen.go +++ /dev/null @@ -1,498 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// CacheSetStringRequest is the request type for Cache.SetString. -type CacheSetStringRequest struct { - Key string `json:"key"` - Value string `json:"value"` - TtlSeconds int64 `json:"ttlSeconds"` -} - -// CacheSetStringResponse is the response type for Cache.SetString. -type CacheSetStringResponse struct { - Error string `json:"error,omitempty"` -} - -// CacheGetStringRequest is the request type for Cache.GetString. -type CacheGetStringRequest struct { - Key string `json:"key"` -} - -// CacheGetStringResponse is the response type for Cache.GetString. -type CacheGetStringResponse struct { - Value string `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -// CacheSetIntRequest is the request type for Cache.SetInt. -type CacheSetIntRequest struct { - Key string `json:"key"` - Value int64 `json:"value"` - TtlSeconds int64 `json:"ttlSeconds"` -} - -// CacheSetIntResponse is the response type for Cache.SetInt. -type CacheSetIntResponse struct { - Error string `json:"error,omitempty"` -} - -// CacheGetIntRequest is the request type for Cache.GetInt. -type CacheGetIntRequest struct { - Key string `json:"key"` -} - -// CacheGetIntResponse is the response type for Cache.GetInt. -type CacheGetIntResponse struct { - Value int64 `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -// CacheSetFloatRequest is the request type for Cache.SetFloat. -type CacheSetFloatRequest struct { - Key string `json:"key"` - Value float64 `json:"value"` - TtlSeconds int64 `json:"ttlSeconds"` -} - -// CacheSetFloatResponse is the response type for Cache.SetFloat. -type CacheSetFloatResponse struct { - Error string `json:"error,omitempty"` -} - -// CacheGetFloatRequest is the request type for Cache.GetFloat. -type CacheGetFloatRequest struct { - Key string `json:"key"` -} - -// CacheGetFloatResponse is the response type for Cache.GetFloat. -type CacheGetFloatResponse struct { - Value float64 `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -// CacheSetBytesRequest is the request type for Cache.SetBytes. -type CacheSetBytesRequest struct { - Key string `json:"key"` - Value []byte `json:"value"` - TtlSeconds int64 `json:"ttlSeconds"` -} - -// CacheSetBytesResponse is the response type for Cache.SetBytes. -type CacheSetBytesResponse struct { - Error string `json:"error,omitempty"` -} - -// CacheGetBytesRequest is the request type for Cache.GetBytes. -type CacheGetBytesRequest struct { - Key string `json:"key"` -} - -// CacheGetBytesResponse is the response type for Cache.GetBytes. -type CacheGetBytesResponse struct { - Value []byte `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -// CacheHasRequest is the request type for Cache.Has. -type CacheHasRequest struct { - Key string `json:"key"` -} - -// CacheHasResponse is the response type for Cache.Has. -type CacheHasResponse struct { - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -// CacheRemoveRequest is the request type for Cache.Remove. -type CacheRemoveRequest struct { - Key string `json:"key"` -} - -// CacheRemoveResponse is the response type for Cache.Remove. -type CacheRemoveResponse struct { - Error string `json:"error,omitempty"` -} - -// RegisterCacheHostFunctions registers Cache service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterCacheHostFunctions(service CacheService) []extism.HostFunction { - return []extism.HostFunction{ - newCacheSetStringHostFunction(service), - newCacheGetStringHostFunction(service), - newCacheSetIntHostFunction(service), - newCacheGetIntHostFunction(service), - newCacheSetFloatHostFunction(service), - newCacheGetFloatHostFunction(service), - newCacheSetBytesHostFunction(service), - newCacheGetBytesHostFunction(service), - newCacheHasHostFunction(service), - newCacheRemoveHostFunction(service), - } -} - -func newCacheSetStringHostFunction(service CacheService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "cache_setstring", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - cacheWriteError(p, stack, err) - return - } - var req CacheSetStringRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - cacheWriteError(p, stack, err) - return - } - - // Call the service method - if svcErr := service.SetString(ctx, req.Key, req.Value, req.TtlSeconds); svcErr != nil { - cacheWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := CacheSetStringResponse{} - cacheWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newCacheGetStringHostFunction(service CacheService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "cache_getstring", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - cacheWriteError(p, stack, err) - return - } - var req CacheGetStringRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - cacheWriteError(p, stack, err) - return - } - - // Call the service method - value, exists, svcErr := service.GetString(ctx, req.Key) - if svcErr != nil { - cacheWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := CacheGetStringResponse{ - Value: value, - Exists: exists, - } - cacheWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newCacheSetIntHostFunction(service CacheService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "cache_setint", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - cacheWriteError(p, stack, err) - return - } - var req CacheSetIntRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - cacheWriteError(p, stack, err) - return - } - - // Call the service method - if svcErr := service.SetInt(ctx, req.Key, req.Value, req.TtlSeconds); svcErr != nil { - cacheWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := CacheSetIntResponse{} - cacheWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newCacheGetIntHostFunction(service CacheService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "cache_getint", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - cacheWriteError(p, stack, err) - return - } - var req CacheGetIntRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - cacheWriteError(p, stack, err) - return - } - - // Call the service method - value, exists, svcErr := service.GetInt(ctx, req.Key) - if svcErr != nil { - cacheWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := CacheGetIntResponse{ - Value: value, - Exists: exists, - } - cacheWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newCacheSetFloatHostFunction(service CacheService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "cache_setfloat", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - cacheWriteError(p, stack, err) - return - } - var req CacheSetFloatRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - cacheWriteError(p, stack, err) - return - } - - // Call the service method - if svcErr := service.SetFloat(ctx, req.Key, req.Value, req.TtlSeconds); svcErr != nil { - cacheWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := CacheSetFloatResponse{} - cacheWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newCacheGetFloatHostFunction(service CacheService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "cache_getfloat", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - cacheWriteError(p, stack, err) - return - } - var req CacheGetFloatRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - cacheWriteError(p, stack, err) - return - } - - // Call the service method - value, exists, svcErr := service.GetFloat(ctx, req.Key) - if svcErr != nil { - cacheWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := CacheGetFloatResponse{ - Value: value, - Exists: exists, - } - cacheWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newCacheSetBytesHostFunction(service CacheService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "cache_setbytes", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - cacheWriteError(p, stack, err) - return - } - var req CacheSetBytesRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - cacheWriteError(p, stack, err) - return - } - - // Call the service method - if svcErr := service.SetBytes(ctx, req.Key, req.Value, req.TtlSeconds); svcErr != nil { - cacheWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := CacheSetBytesResponse{} - cacheWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newCacheGetBytesHostFunction(service CacheService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "cache_getbytes", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - cacheWriteError(p, stack, err) - return - } - var req CacheGetBytesRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - cacheWriteError(p, stack, err) - return - } - - // Call the service method - value, exists, svcErr := service.GetBytes(ctx, req.Key) - if svcErr != nil { - cacheWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := CacheGetBytesResponse{ - Value: value, - Exists: exists, - } - cacheWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newCacheHasHostFunction(service CacheService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "cache_has", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - cacheWriteError(p, stack, err) - return - } - var req CacheHasRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - cacheWriteError(p, stack, err) - return - } - - // Call the service method - exists, svcErr := service.Has(ctx, req.Key) - if svcErr != nil { - cacheWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := CacheHasResponse{ - Exists: exists, - } - cacheWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newCacheRemoveHostFunction(service CacheService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "cache_remove", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - cacheWriteError(p, stack, err) - return - } - var req CacheRemoveRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - cacheWriteError(p, stack, err) - return - } - - // Call the service method - if svcErr := service.Remove(ctx, req.Key); svcErr != nil { - cacheWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := CacheRemoveResponse{} - cacheWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// cacheWriteResponse writes a JSON response to plugin memory. -func cacheWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - cacheWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// cacheWriteError writes an error response to plugin memory. -func cacheWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/host/config.go b/plugins/host/config.go deleted file mode 100644 index 0a0a62ca7..000000000 --- a/plugins/host/config.go +++ /dev/null @@ -1,44 +0,0 @@ -package host - -import "context" - -// ConfigService provides access to plugin configuration values. -// -// This service allows plugins to retrieve configuration values and enumerate -// available configuration keys. Unlike the built-in pdk.GetConfig(key) which -// only retrieves individual values, this service provides methods to list all -// available keys, making it useful for plugins that need to discover dynamic -// configuration (e.g., user-to-token mappings). -// -// This service is always available and does not require a permission in the manifest. -// -//nd:hostservice name=Config -type ConfigService interface { - // Get retrieves a configuration value as a string. - // - // Parameters: - // - key: The configuration key - // - // Returns the value and whether the key exists. - //nd:hostfunc - Get(ctx context.Context, key string) (value string, exists bool) - - // GetInt retrieves a configuration value as an integer. - // - // Parameters: - // - key: The configuration key - // - // Returns the value and whether the key exists. If the key exists but the - // value cannot be parsed as an integer, exists will be false. - //nd:hostfunc - GetInt(ctx context.Context, key string) (value int64, exists bool) - - // Keys returns configuration keys matching the given prefix. - // - // Parameters: - // - prefix: Key prefix to filter by. If empty, returns all keys. - // - // Returns a sorted slice of matching configuration keys. - //nd:hostfunc - Keys(ctx context.Context, prefix string) (keys []string) -} diff --git a/plugins/host/config_gen.go b/plugins/host/config_gen.go deleted file mode 100644 index 0fd1b6ef4..000000000 --- a/plugins/host/config_gen.go +++ /dev/null @@ -1,169 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// ConfigGetRequest is the request type for Config.Get. -type ConfigGetRequest struct { - Key string `json:"key"` -} - -// ConfigGetResponse is the response type for Config.Get. -type ConfigGetResponse struct { - Value string `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` -} - -// ConfigGetIntRequest is the request type for Config.GetInt. -type ConfigGetIntRequest struct { - Key string `json:"key"` -} - -// ConfigGetIntResponse is the response type for Config.GetInt. -type ConfigGetIntResponse struct { - Value int64 `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` -} - -// ConfigKeysRequest is the request type for Config.Keys. -type ConfigKeysRequest struct { - Prefix string `json:"prefix"` -} - -// ConfigKeysResponse is the response type for Config.Keys. -type ConfigKeysResponse struct { - Keys []string `json:"keys,omitempty"` -} - -// RegisterConfigHostFunctions registers Config service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterConfigHostFunctions(service ConfigService) []extism.HostFunction { - return []extism.HostFunction{ - newConfigGetHostFunction(service), - newConfigGetIntHostFunction(service), - newConfigKeysHostFunction(service), - } -} - -func newConfigGetHostFunction(service ConfigService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "config_get", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - configWriteError(p, stack, err) - return - } - var req ConfigGetRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - configWriteError(p, stack, err) - return - } - - // Call the service method - value, exists := service.Get(ctx, req.Key) - - // Write JSON response to plugin memory - resp := ConfigGetResponse{ - Value: value, - Exists: exists, - } - configWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newConfigGetIntHostFunction(service ConfigService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "config_getint", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - configWriteError(p, stack, err) - return - } - var req ConfigGetIntRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - configWriteError(p, stack, err) - return - } - - // Call the service method - value, exists := service.GetInt(ctx, req.Key) - - // Write JSON response to plugin memory - resp := ConfigGetIntResponse{ - Value: value, - Exists: exists, - } - configWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newConfigKeysHostFunction(service ConfigService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "config_keys", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - configWriteError(p, stack, err) - return - } - var req ConfigKeysRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - configWriteError(p, stack, err) - return - } - - // Call the service method - keys := service.Keys(ctx, req.Prefix) - - // Write JSON response to plugin memory - resp := ConfigKeysResponse{ - Keys: keys, - } - configWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// configWriteResponse writes a JSON response to plugin memory. -func configWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - configWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// configWriteError writes an error response to plugin memory. -func configWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/host/doc.go b/plugins/host/doc.go deleted file mode 100644 index 10e2b846b..000000000 --- a/plugins/host/doc.go +++ /dev/null @@ -1,39 +0,0 @@ -// Package host provides host services that can be called by plugins via Extism host functions. -// -// Host services allow plugins to access Navidrome functionality like the Subsonic API, -// scheduler, and other internal services. Services are defined as Go interfaces with -// special annotations that enable automatic code generation of Extism host function wrappers. -// -// # Annotation Format -// -// Host services use Go doc comment annotations to mark interfaces and methods for code generation: -// -// // MyService provides some functionality. -// //nd:hostservice name=MyService permission=myservice -// type MyService interface { -// // DoSomething performs an action. -// //nd:hostfunc -// DoSomething(ctx context.Context, input string) (output string, err error) -// } -// -// Service-level annotations: -// - //nd:hostservice - Marks an interface as a host service -// - name=<ServiceName> - Service identifier used in generated code -// - permission=<key> - Manifest permission key (e.g., "subsonicapi", "scheduler") -// -// Method-level annotations: -// - //nd:hostfunc - Marks a method for host function wrapper generation -// - name=<CustomName> - Optional: override the export name -// -// # Generated Code -// -// The ndpgen tool reads annotated interfaces and generates Extism host function wrappers -// that handle: -// - JSON serialization/deserialization of request/response types -// - Memory operations (ReadBytes, WriteBytes, Alloc) -// - Error handling and propagation -// - Service registration functions -// -// Generated files follow the pattern <servicename>_gen.go and include a header comment -// indicating they should not be edited manually. -package host diff --git a/plugins/host/http.go b/plugins/host/http.go deleted file mode 100644 index 96e832a0e..000000000 --- a/plugins/host/http.go +++ /dev/null @@ -1,41 +0,0 @@ -package host - -import "context" - -// HTTPRequest represents an outbound HTTP request from a plugin. -type HTTPRequest struct { - Method string `json:"method"` - URL string `json:"url"` - Headers map[string]string `json:"headers,omitempty"` - NoFollowRedirects bool `json:"noFollowRedirects,omitempty"` - Body []byte `json:"body,omitempty"` - TimeoutMs int32 `json:"timeoutMs,omitempty"` -} - -// HTTPResponse represents the response from an outbound HTTP request. -type HTTPResponse struct { - StatusCode int32 `json:"statusCode"` - Headers map[string]string `json:"headers,omitempty"` - Body []byte `json:"body,omitempty"` -} - -// HTTPService provides outbound HTTP request capabilities for plugins. -// -// This service allows plugins to make HTTP requests to external services. -// Requests are validated against the plugin's declared requiredHosts patterns -// from the http permission in the manifest. Redirects are followed but each -// redirect destination is also validated against the allowed hosts. -// -//nd:hostservice name=HTTP permission=http -type HTTPService interface { - // Send executes an HTTP request and returns the response. - // - // Parameters: - // - request: The HTTP request to execute, including method, URL, headers, body, and timeout - // - // Returns the HTTP response with status code, headers, and body. - // Network errors, timeouts, and permission failures are returned as Go errors. - // Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. - //nd:hostfunc - Send(ctx context.Context, request HTTPRequest) (*HTTPResponse, error) -} diff --git a/plugins/host/http_gen.go b/plugins/host/http_gen.go deleted file mode 100644 index c14a533d0..000000000 --- a/plugins/host/http_gen.go +++ /dev/null @@ -1,88 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// HTTPSendRequest is the request type for HTTP.Send. -type HTTPSendRequest struct { - Request HTTPRequest `json:"request"` -} - -// HTTPSendResponse is the response type for HTTP.Send. -type HTTPSendResponse struct { - Result *HTTPResponse `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterHTTPHostFunctions registers HTTP service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterHTTPHostFunctions(service HTTPService) []extism.HostFunction { - return []extism.HostFunction{ - newHTTPSendHostFunction(service), - } -} - -func newHTTPSendHostFunction(service HTTPService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "http_send", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - httpWriteError(p, stack, err) - return - } - var req HTTPSendRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - httpWriteError(p, stack, err) - return - } - - // Call the service method - result, svcErr := service.Send(ctx, req.Request) - if svcErr != nil { - httpWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := HTTPSendResponse{ - Result: result, - } - httpWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// httpWriteResponse writes a JSON response to plugin memory. -func httpWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - httpWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// httpWriteError writes an error response to plugin memory. -func httpWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/host/kvstore.go b/plugins/host/kvstore.go deleted file mode 100644 index aa2597f27..000000000 --- a/plugins/host/kvstore.go +++ /dev/null @@ -1,98 +0,0 @@ -package host - -import "context" - -// KVStoreService provides persistent key-value storage for plugins. -// -// Unlike CacheService which is in-memory only, KVStoreService persists data -// to disk and survives server restarts. Each plugin has its own isolated -// storage with configurable size limits. -// -// Values are stored as raw bytes, giving plugins full control over -// serialization (JSON, protobuf, etc.). -// -//nd:hostservice name=KVStore permission=kvstore -type KVStoreService interface { - // Set stores a byte value with the given key. - // - // Parameters: - // - key: The storage key (max 256 bytes, UTF-8) - // - value: The byte slice to store - // - // Returns an error if the storage limit would be exceeded or the operation fails. - //nd:hostfunc - Set(ctx context.Context, key string, value []byte) error - - // SetWithTTL stores a byte value with the given key and a time-to-live. - // - // After ttlSeconds, the key is treated as non-existent and will be - // cleaned up lazily. ttlSeconds must be greater than 0. - // - // Parameters: - // - key: The storage key (max 256 bytes, UTF-8) - // - value: The byte slice to store - // - ttlSeconds: Time-to-live in seconds (must be > 0) - // - // Returns an error if the storage limit would be exceeded or the operation fails. - //nd:hostfunc - SetWithTTL(ctx context.Context, key string, value []byte, ttlSeconds int64) error - - // Get retrieves a byte value from storage. - // - // Parameters: - // - key: The storage key - // - // Returns the value and whether the key exists. - //nd:hostfunc - Get(ctx context.Context, key string) (value []byte, exists bool, err error) - - // GetMany retrieves multiple values in a single call. - // - // Parameters: - // - keys: The storage keys to retrieve - // - // Returns a map of key to value for keys that exist and have not expired. - // Missing or expired keys are omitted from the result. - //nd:hostfunc - GetMany(ctx context.Context, keys []string) (values map[string][]byte, err error) - - // Has checks if a key exists in storage. - // - // Parameters: - // - key: The storage key - // - // Returns true if the key exists. - //nd:hostfunc - Has(ctx context.Context, key string) (exists bool, err error) - - // List returns all keys matching the given prefix. - // - // Parameters: - // - prefix: Key prefix to filter by (empty string returns all keys) - // - // Returns a slice of matching keys. - //nd:hostfunc - List(ctx context.Context, prefix string) (keys []string, err error) - - // Delete removes a value from storage. - // - // Parameters: - // - key: The storage key - // - // Returns an error if the operation fails. Does not return an error if the key doesn't exist. - //nd:hostfunc - Delete(ctx context.Context, key string) error - - // DeleteByPrefix removes all keys matching the given prefix. - // - // Parameters: - // - prefix: Key prefix to match (must not be empty) - // - // Returns the number of keys deleted. Includes expired keys. - //nd:hostfunc - DeleteByPrefix(ctx context.Context, prefix string) (deletedCount int64, err error) - - // GetStorageUsed returns the total storage used by this plugin in bytes. - //nd:hostfunc - GetStorageUsed(ctx context.Context) (bytes int64, err error) -} diff --git a/plugins/host/kvstore_gen.go b/plugins/host/kvstore_gen.go deleted file mode 100644 index 44ee3b131..000000000 --- a/plugins/host/kvstore_gen.go +++ /dev/null @@ -1,433 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// KVStoreSetRequest is the request type for KVStore.Set. -type KVStoreSetRequest struct { - Key string `json:"key"` - Value []byte `json:"value"` -} - -// KVStoreSetResponse is the response type for KVStore.Set. -type KVStoreSetResponse struct { - Error string `json:"error,omitempty"` -} - -// KVStoreSetWithTTLRequest is the request type for KVStore.SetWithTTL. -type KVStoreSetWithTTLRequest struct { - Key string `json:"key"` - Value []byte `json:"value"` - TtlSeconds int64 `json:"ttlSeconds"` -} - -// KVStoreSetWithTTLResponse is the response type for KVStore.SetWithTTL. -type KVStoreSetWithTTLResponse struct { - Error string `json:"error,omitempty"` -} - -// KVStoreGetRequest is the request type for KVStore.Get. -type KVStoreGetRequest struct { - Key string `json:"key"` -} - -// KVStoreGetResponse is the response type for KVStore.Get. -type KVStoreGetResponse struct { - Value []byte `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -// KVStoreGetManyRequest is the request type for KVStore.GetMany. -type KVStoreGetManyRequest struct { - Keys []string `json:"keys"` -} - -// KVStoreGetManyResponse is the response type for KVStore.GetMany. -type KVStoreGetManyResponse struct { - Values map[string][]byte `json:"values,omitempty"` - Error string `json:"error,omitempty"` -} - -// KVStoreHasRequest is the request type for KVStore.Has. -type KVStoreHasRequest struct { - Key string `json:"key"` -} - -// KVStoreHasResponse is the response type for KVStore.Has. -type KVStoreHasResponse struct { - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -// KVStoreListRequest is the request type for KVStore.List. -type KVStoreListRequest struct { - Prefix string `json:"prefix"` -} - -// KVStoreListResponse is the response type for KVStore.List. -type KVStoreListResponse struct { - Keys []string `json:"keys,omitempty"` - Error string `json:"error,omitempty"` -} - -// KVStoreDeleteRequest is the request type for KVStore.Delete. -type KVStoreDeleteRequest struct { - Key string `json:"key"` -} - -// KVStoreDeleteResponse is the response type for KVStore.Delete. -type KVStoreDeleteResponse struct { - Error string `json:"error,omitempty"` -} - -// KVStoreDeleteByPrefixRequest is the request type for KVStore.DeleteByPrefix. -type KVStoreDeleteByPrefixRequest struct { - Prefix string `json:"prefix"` -} - -// KVStoreDeleteByPrefixResponse is the response type for KVStore.DeleteByPrefix. -type KVStoreDeleteByPrefixResponse struct { - DeletedCount int64 `json:"deletedCount,omitempty"` - Error string `json:"error,omitempty"` -} - -// KVStoreGetStorageUsedResponse is the response type for KVStore.GetStorageUsed. -type KVStoreGetStorageUsedResponse struct { - Bytes int64 `json:"bytes,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterKVStoreHostFunctions registers KVStore service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterKVStoreHostFunctions(service KVStoreService) []extism.HostFunction { - return []extism.HostFunction{ - newKVStoreSetHostFunction(service), - newKVStoreSetWithTTLHostFunction(service), - newKVStoreGetHostFunction(service), - newKVStoreGetManyHostFunction(service), - newKVStoreHasHostFunction(service), - newKVStoreListHostFunction(service), - newKVStoreDeleteHostFunction(service), - newKVStoreDeleteByPrefixHostFunction(service), - newKVStoreGetStorageUsedHostFunction(service), - } -} - -func newKVStoreSetHostFunction(service KVStoreService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "kvstore_set", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - kvstoreWriteError(p, stack, err) - return - } - var req KVStoreSetRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - kvstoreWriteError(p, stack, err) - return - } - - // Call the service method - if svcErr := service.Set(ctx, req.Key, req.Value); svcErr != nil { - kvstoreWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := KVStoreSetResponse{} - kvstoreWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newKVStoreSetWithTTLHostFunction(service KVStoreService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "kvstore_setwithttl", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - kvstoreWriteError(p, stack, err) - return - } - var req KVStoreSetWithTTLRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - kvstoreWriteError(p, stack, err) - return - } - - // Call the service method - if svcErr := service.SetWithTTL(ctx, req.Key, req.Value, req.TtlSeconds); svcErr != nil { - kvstoreWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := KVStoreSetWithTTLResponse{} - kvstoreWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newKVStoreGetHostFunction(service KVStoreService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "kvstore_get", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - kvstoreWriteError(p, stack, err) - return - } - var req KVStoreGetRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - kvstoreWriteError(p, stack, err) - return - } - - // Call the service method - value, exists, svcErr := service.Get(ctx, req.Key) - if svcErr != nil { - kvstoreWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := KVStoreGetResponse{ - Value: value, - Exists: exists, - } - kvstoreWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newKVStoreGetManyHostFunction(service KVStoreService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "kvstore_getmany", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - kvstoreWriteError(p, stack, err) - return - } - var req KVStoreGetManyRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - kvstoreWriteError(p, stack, err) - return - } - - // Call the service method - values, svcErr := service.GetMany(ctx, req.Keys) - if svcErr != nil { - kvstoreWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := KVStoreGetManyResponse{ - Values: values, - } - kvstoreWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newKVStoreHasHostFunction(service KVStoreService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "kvstore_has", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - kvstoreWriteError(p, stack, err) - return - } - var req KVStoreHasRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - kvstoreWriteError(p, stack, err) - return - } - - // Call the service method - exists, svcErr := service.Has(ctx, req.Key) - if svcErr != nil { - kvstoreWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := KVStoreHasResponse{ - Exists: exists, - } - kvstoreWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newKVStoreListHostFunction(service KVStoreService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "kvstore_list", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - kvstoreWriteError(p, stack, err) - return - } - var req KVStoreListRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - kvstoreWriteError(p, stack, err) - return - } - - // Call the service method - keys, svcErr := service.List(ctx, req.Prefix) - if svcErr != nil { - kvstoreWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := KVStoreListResponse{ - Keys: keys, - } - kvstoreWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newKVStoreDeleteHostFunction(service KVStoreService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "kvstore_delete", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - kvstoreWriteError(p, stack, err) - return - } - var req KVStoreDeleteRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - kvstoreWriteError(p, stack, err) - return - } - - // Call the service method - if svcErr := service.Delete(ctx, req.Key); svcErr != nil { - kvstoreWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := KVStoreDeleteResponse{} - kvstoreWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newKVStoreDeleteByPrefixHostFunction(service KVStoreService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "kvstore_deletebyprefix", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - kvstoreWriteError(p, stack, err) - return - } - var req KVStoreDeleteByPrefixRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - kvstoreWriteError(p, stack, err) - return - } - - // Call the service method - deletedcount, svcErr := service.DeleteByPrefix(ctx, req.Prefix) - if svcErr != nil { - kvstoreWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := KVStoreDeleteByPrefixResponse{ - DeletedCount: deletedcount, - } - kvstoreWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newKVStoreGetStorageUsedHostFunction(service KVStoreService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "kvstore_getstorageused", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - - // Call the service method - bytes, svcErr := service.GetStorageUsed(ctx) - if svcErr != nil { - kvstoreWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := KVStoreGetStorageUsedResponse{ - Bytes: bytes, - } - kvstoreWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// kvstoreWriteResponse writes a JSON response to plugin memory. -func kvstoreWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - kvstoreWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// kvstoreWriteError writes an error response to plugin memory. -func kvstoreWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/host/library.go b/plugins/host/library.go deleted file mode 100644 index ed86b8b3d..000000000 --- a/plugins/host/library.go +++ /dev/null @@ -1,41 +0,0 @@ -package host - -import "context" - -// Library represents a music library with metadata. -type Library struct { - ID int32 `json:"id"` - Name string `json:"name"` - Path string `json:"path,omitempty"` - MountPoint string `json:"mountPoint,omitempty"` - LastScanAt int64 `json:"lastScanAt"` - TotalSongs int32 `json:"totalSongs"` - TotalAlbums int32 `json:"totalAlbums"` - TotalArtists int32 `json:"totalArtists"` - TotalSize int64 `json:"totalSize"` - TotalDuration float64 `json:"totalDuration"` -} - -// LibraryService provides access to music library metadata for plugins. -// -// This service allows plugins to query information about configured music libraries, -// including statistics and optionally filesystem access to library directories. -// Filesystem access is controlled via the `filesystem` permission flag. -// -//nd:hostservice name=Library permission=library -type LibraryService interface { - // GetLibrary retrieves metadata for a specific library by ID. - // - // Parameters: - // - id: The library's unique identifier - // - // Returns the library metadata, or an error if the library is not found. - //nd:hostfunc - GetLibrary(ctx context.Context, id int32) (*Library, error) - - // GetAllLibraries retrieves metadata for all configured libraries. - // - // Returns a slice of all libraries with their metadata. - //nd:hostfunc - GetAllLibraries(ctx context.Context) ([]Library, error) -} diff --git a/plugins/host/library_gen.go b/plugins/host/library_gen.go deleted file mode 100644 index 27195e85c..000000000 --- a/plugins/host/library_gen.go +++ /dev/null @@ -1,118 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// LibraryGetLibraryRequest is the request type for Library.GetLibrary. -type LibraryGetLibraryRequest struct { - Id int32 `json:"id"` -} - -// LibraryGetLibraryResponse is the response type for Library.GetLibrary. -type LibraryGetLibraryResponse struct { - Result *Library `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// LibraryGetAllLibrariesResponse is the response type for Library.GetAllLibraries. -type LibraryGetAllLibrariesResponse struct { - Result []Library `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterLibraryHostFunctions registers Library service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterLibraryHostFunctions(service LibraryService) []extism.HostFunction { - return []extism.HostFunction{ - newLibraryGetLibraryHostFunction(service), - newLibraryGetAllLibrariesHostFunction(service), - } -} - -func newLibraryGetLibraryHostFunction(service LibraryService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "library_getlibrary", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - libraryWriteError(p, stack, err) - return - } - var req LibraryGetLibraryRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - libraryWriteError(p, stack, err) - return - } - - // Call the service method - result, svcErr := service.GetLibrary(ctx, req.Id) - if svcErr != nil { - libraryWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := LibraryGetLibraryResponse{ - Result: result, - } - libraryWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newLibraryGetAllLibrariesHostFunction(service LibraryService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "library_getalllibraries", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - - // Call the service method - result, svcErr := service.GetAllLibraries(ctx) - if svcErr != nil { - libraryWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := LibraryGetAllLibrariesResponse{ - Result: result, - } - libraryWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// libraryWriteResponse writes a JSON response to plugin memory. -func libraryWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - libraryWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// libraryWriteError writes an error response to plugin memory. -func libraryWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/host/matcher.go b/plugins/host/matcher.go deleted file mode 100644 index 678d3bfaa..000000000 --- a/plugins/host/matcher.go +++ /dev/null @@ -1,27 +0,0 @@ -package host - -import ( - "context" - - "github.com/navidrome/navidrome/plugins/types" -) - -// MatchOptions carries optional parameters for a match request. -type MatchOptions struct { - // Username runs the match as that user (case-insensitive): their favourites and - // ratings inform tiebreaking, and the returned tracks carry their annotations. - Username string `json:"username,omitempty"` -} - -// MatcherService resolves externally-obtained songs to local library tracks, -// reusing Navidrome's matching algorithm (ID > MBID > ISRC > fuzzy title). -// -//nd:hostservice name=Matcher permission=matcher -type MatcherService interface { - // MatchSongs resolves each input song to its best-matching library track. - // It returns one entry per input song, in the same order as the input; the - // entry for an input song that had no match is empty (absent). Results are - // limited to the libraries the plugin (and the scoped user, if any) can access. - //nd:hostfunc - MatchSongs(ctx context.Context, songs []types.SongRef, opts MatchOptions) (results []*types.Track, err error) -} diff --git a/plugins/host/matcher_gen.go b/plugins/host/matcher_gen.go deleted file mode 100644 index 1bcd0dc3b..000000000 --- a/plugins/host/matcher_gen.go +++ /dev/null @@ -1,91 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" - - "github.com/navidrome/navidrome/plugins/types" -) - -// MatcherMatchSongsRequest is the request type for Matcher.MatchSongs. -type MatcherMatchSongsRequest struct { - Songs []types.SongRef `json:"songs"` - Opts MatchOptions `json:"opts"` -} - -// MatcherMatchSongsResponse is the response type for Matcher.MatchSongs. -type MatcherMatchSongsResponse struct { - Results []*types.Track `json:"results,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterMatcherHostFunctions registers Matcher service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterMatcherHostFunctions(service MatcherService) []extism.HostFunction { - return []extism.HostFunction{ - newMatcherMatchSongsHostFunction(service), - } -} - -func newMatcherMatchSongsHostFunction(service MatcherService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "matcher_matchsongs", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - matcherWriteError(p, stack, err) - return - } - var req MatcherMatchSongsRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - matcherWriteError(p, stack, err) - return - } - - // Call the service method - results, svcErr := service.MatchSongs(ctx, req.Songs, req.Opts) - if svcErr != nil { - matcherWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := MatcherMatchSongsResponse{ - Results: results, - } - matcherWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// matcherWriteResponse writes a JSON response to plugin memory. -func matcherWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - matcherWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// matcherWriteError writes an error response to plugin memory. -func matcherWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/host/scheduler.go b/plugins/host/scheduler.go deleted file mode 100644 index d640bc97b..000000000 --- a/plugins/host/scheduler.go +++ /dev/null @@ -1,44 +0,0 @@ -package host - -import "context" - -// SchedulerService provides task scheduling capabilities for plugins. -// -// This service allows plugins to schedule both one-time and recurring tasks using -// cron expressions. All scheduled tasks can be cancelled using their schedule ID. -// -//nd:hostservice name=Scheduler permission=scheduler -type SchedulerService interface { - // ScheduleOneTime schedules a one-time event to be triggered after the specified delay. - // Plugins that use this function must also implement the SchedulerCallback capability - // - // Parameters: - // - delaySeconds: Number of seconds to wait before triggering the event - // - payload: Data to be passed to the scheduled event handler - // - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated - // - // Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. - //nd:hostfunc - ScheduleOneTime(ctx context.Context, delaySeconds int32, payload string, scheduleID string) (newScheduleID string, err error) - - // ScheduleRecurring schedules a recurring event using a cron expression. - // Plugins that use this function must also implement the SchedulerCallback capability - // - // Parameters: - // - cronExpression: Standard cron format expression (e.g., "0 0 * * *" for daily at midnight) - // - payload: Data to be passed to each scheduled event handler invocation - // - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated - // - // Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. - //nd:hostfunc - ScheduleRecurring(ctx context.Context, cronExpression string, payload string, scheduleID string) (newScheduleID string, err error) - - // CancelSchedule cancels a scheduled job identified by its schedule ID. - // - // This works for both one-time and recurring schedules. Once cancelled, the job will not trigger - // any future events. - // - // Returns an error if the schedule ID is not found or if cancellation fails. - //nd:hostfunc - CancelSchedule(ctx context.Context, scheduleID string) error -} diff --git a/plugins/host/scheduler_gen.go b/plugins/host/scheduler_gen.go deleted file mode 100644 index d3845419c..000000000 --- a/plugins/host/scheduler_gen.go +++ /dev/null @@ -1,180 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// SchedulerScheduleOneTimeRequest is the request type for Scheduler.ScheduleOneTime. -type SchedulerScheduleOneTimeRequest struct { - DelaySeconds int32 `json:"delaySeconds"` - Payload string `json:"payload"` - ScheduleID string `json:"scheduleId"` -} - -// SchedulerScheduleOneTimeResponse is the response type for Scheduler.ScheduleOneTime. -type SchedulerScheduleOneTimeResponse struct { - NewScheduleID string `json:"newScheduleId,omitempty"` - Error string `json:"error,omitempty"` -} - -// SchedulerScheduleRecurringRequest is the request type for Scheduler.ScheduleRecurring. -type SchedulerScheduleRecurringRequest struct { - CronExpression string `json:"cronExpression"` - Payload string `json:"payload"` - ScheduleID string `json:"scheduleId"` -} - -// SchedulerScheduleRecurringResponse is the response type for Scheduler.ScheduleRecurring. -type SchedulerScheduleRecurringResponse struct { - NewScheduleID string `json:"newScheduleId,omitempty"` - Error string `json:"error,omitempty"` -} - -// SchedulerCancelScheduleRequest is the request type for Scheduler.CancelSchedule. -type SchedulerCancelScheduleRequest struct { - ScheduleID string `json:"scheduleId"` -} - -// SchedulerCancelScheduleResponse is the response type for Scheduler.CancelSchedule. -type SchedulerCancelScheduleResponse struct { - Error string `json:"error,omitempty"` -} - -// RegisterSchedulerHostFunctions registers Scheduler service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterSchedulerHostFunctions(service SchedulerService) []extism.HostFunction { - return []extism.HostFunction{ - newSchedulerScheduleOneTimeHostFunction(service), - newSchedulerScheduleRecurringHostFunction(service), - newSchedulerCancelScheduleHostFunction(service), - } -} - -func newSchedulerScheduleOneTimeHostFunction(service SchedulerService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "scheduler_scheduleonetime", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - schedulerWriteError(p, stack, err) - return - } - var req SchedulerScheduleOneTimeRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - schedulerWriteError(p, stack, err) - return - } - - // Call the service method - newscheduleid, svcErr := service.ScheduleOneTime(ctx, req.DelaySeconds, req.Payload, req.ScheduleID) - if svcErr != nil { - schedulerWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := SchedulerScheduleOneTimeResponse{ - NewScheduleID: newscheduleid, - } - schedulerWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newSchedulerScheduleRecurringHostFunction(service SchedulerService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "scheduler_schedulerecurring", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - schedulerWriteError(p, stack, err) - return - } - var req SchedulerScheduleRecurringRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - schedulerWriteError(p, stack, err) - return - } - - // Call the service method - newscheduleid, svcErr := service.ScheduleRecurring(ctx, req.CronExpression, req.Payload, req.ScheduleID) - if svcErr != nil { - schedulerWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := SchedulerScheduleRecurringResponse{ - NewScheduleID: newscheduleid, - } - schedulerWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newSchedulerCancelScheduleHostFunction(service SchedulerService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "scheduler_cancelschedule", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - schedulerWriteError(p, stack, err) - return - } - var req SchedulerCancelScheduleRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - schedulerWriteError(p, stack, err) - return - } - - // Call the service method - if svcErr := service.CancelSchedule(ctx, req.ScheduleID); svcErr != nil { - schedulerWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := SchedulerCancelScheduleResponse{} - schedulerWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// schedulerWriteResponse writes a JSON response to plugin memory. -func schedulerWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - schedulerWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// schedulerWriteError writes an error response to plugin memory. -func schedulerWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/host/scrobble_retriever.go b/plugins/host/scrobble_retriever.go deleted file mode 100644 index 2582dd650..000000000 --- a/plugins/host/scrobble_retriever.go +++ /dev/null @@ -1,89 +0,0 @@ -package host - -import "context" - -// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) -type ScrobbleRef struct { - // The ID of the scrobble. Useful if duplicate scrobbles happen for the same time - ID int64 `json:"id"` - // The ID of the MediaFile submitted at this time - MediaFileID string `json:"mediaFileId"` - // The UNIX timestamp this scrobble was submitted - SubmissionTime int64 `json:"submissionTime"` -} - -// ScrobbleOptions carries optional parameters for retrieving user scrobbles -type ScrobbleOptions struct { - // The starting unix timestamp to query for scrobbles (inclusive). - // If not specified, start from the first scrobble - FromTimestamp *int64 `json:"fromTimestamp,omitempty"` - // The ending unix timestamp to query for scrobbles (inclusive). - // If not specified, go up to the last scrobble - ToTimestamp *int64 `json:"toTimestamp,omitempty"` - // If true, return scrobbles from newest to oldest. Defaults to oldest first - Descending bool `json:"descending"` - // The maximum number of items to retrieve. This is capped at 5000, the - // default if not specified - MaxItems int `json:"maxItems"` - // Pagination state managed by GetScrobbles; only meaningful on the options it returns. - // Never set it or combine it with your own timestamps — scrobbles may be silently skipped - Offset int `json:"offset,omitempty"` -} - -// ScrobbleCountOptions carries optional parameters for counting user scrobbles -type ScrobbleCountOptions struct { - // The starting unix timestamp to query for scrobbles (inclusive). - // If not specified, start from the first scrobble - FromTimestamp *int64 `json:"fromTimestamp,omitempty"` - // The ending unix timestamp to query for scrobbles (inclusive). - // If not specified, go up to the last scrobble - ToTimestamp *int64 `json:"toTimestamp,omitempty"` -} - -// ScrobbleRetrieverService allows a plugin to retrieve scrobbles for one or more authorized users. -// It will only provide the media_file ID and submission time, which can be combined with the MatcherService -// to fetch deduped tracks -// -//nd:hostservice name=ScrobbleRetriever permission=scrobbleRetriever -type ScrobbleRetrieverService interface { - // GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. - // If the user has no scrobbles, returns nil - //nd:hostfunc - GetFirstTimestamp(ctx context.Context, username string) (*int64, error) - - // GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user - // If the user has no scrobbles, return nil - //nd:hostfunc - GetLastTimestamp(ctx context.Context, username string) (*int64, error) - - // GetScrobbles returns one page of scrobbles for a user. - // - // Parameters: - // - username: the user to query for scrobbles - // - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble - // - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble - // - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest - // - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 - // - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually - // - // Returns: - // - scrobbles: The scrobbles in the requested range, ordered by submission time - // (ties broken by scrobble ID) in the direction given by options.Descending - // - next: The options for the following page, or nil once no scrobbles remain. - // Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an - // adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range - //nd:hostfunc - GetScrobbles(ctx context.Context, username string, options ScrobbleOptions) (scrobbles []ScrobbleRef, next *ScrobbleOptions, err error) - - // GetScrobbleCount returns the number of scrobbles for a user in a given range - // - // Parameters: - // - username: the user to query for scrobbles - // - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble - // - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble - // - // Returns: - // - the number of scrobbles in the given range, or 0 - //nd:hostfunc - GetScrobbleCount(ctx context.Context, username string, options ScrobbleCountOptions) (int64, error) -} diff --git a/plugins/host/scrobbleretriever_gen.go b/plugins/host/scrobbleretriever_gen.go deleted file mode 100644 index 7a00c9c1f..000000000 --- a/plugins/host/scrobbleretriever_gen.go +++ /dev/null @@ -1,230 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// ScrobbleRetrieverGetFirstTimestampRequest is the request type for ScrobbleRetriever.GetFirstTimestamp. -type ScrobbleRetrieverGetFirstTimestampRequest struct { - Username string `json:"username"` -} - -// ScrobbleRetrieverGetFirstTimestampResponse is the response type for ScrobbleRetriever.GetFirstTimestamp. -type ScrobbleRetrieverGetFirstTimestampResponse struct { - Result *int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// ScrobbleRetrieverGetLastTimestampRequest is the request type for ScrobbleRetriever.GetLastTimestamp. -type ScrobbleRetrieverGetLastTimestampRequest struct { - Username string `json:"username"` -} - -// ScrobbleRetrieverGetLastTimestampResponse is the response type for ScrobbleRetriever.GetLastTimestamp. -type ScrobbleRetrieverGetLastTimestampResponse struct { - Result *int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// ScrobbleRetrieverGetScrobblesRequest is the request type for ScrobbleRetriever.GetScrobbles. -type ScrobbleRetrieverGetScrobblesRequest struct { - Username string `json:"username"` - Options ScrobbleOptions `json:"options"` -} - -// ScrobbleRetrieverGetScrobblesResponse is the response type for ScrobbleRetriever.GetScrobbles. -type ScrobbleRetrieverGetScrobblesResponse struct { - Scrobbles []ScrobbleRef `json:"scrobbles,omitempty"` - Next *ScrobbleOptions `json:"next,omitempty"` - Error string `json:"error,omitempty"` -} - -// ScrobbleRetrieverGetScrobbleCountRequest is the request type for ScrobbleRetriever.GetScrobbleCount. -type ScrobbleRetrieverGetScrobbleCountRequest struct { - Username string `json:"username"` - Options ScrobbleCountOptions `json:"options"` -} - -// ScrobbleRetrieverGetScrobbleCountResponse is the response type for ScrobbleRetriever.GetScrobbleCount. -type ScrobbleRetrieverGetScrobbleCountResponse struct { - Result int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterScrobbleRetrieverHostFunctions registers ScrobbleRetriever service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterScrobbleRetrieverHostFunctions(service ScrobbleRetrieverService) []extism.HostFunction { - return []extism.HostFunction{ - newScrobbleRetrieverGetFirstTimestampHostFunction(service), - newScrobbleRetrieverGetLastTimestampHostFunction(service), - newScrobbleRetrieverGetScrobblesHostFunction(service), - newScrobbleRetrieverGetScrobbleCountHostFunction(service), - } -} - -func newScrobbleRetrieverGetFirstTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "scrobbleretriever_getfirsttimestamp", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - var req ScrobbleRetrieverGetFirstTimestampRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - - // Call the service method - result, svcErr := service.GetFirstTimestamp(ctx, req.Username) - if svcErr != nil { - scrobbleretrieverWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := ScrobbleRetrieverGetFirstTimestampResponse{ - Result: result, - } - scrobbleretrieverWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newScrobbleRetrieverGetLastTimestampHostFunction(service ScrobbleRetrieverService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "scrobbleretriever_getlasttimestamp", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - var req ScrobbleRetrieverGetLastTimestampRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - - // Call the service method - result, svcErr := service.GetLastTimestamp(ctx, req.Username) - if svcErr != nil { - scrobbleretrieverWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := ScrobbleRetrieverGetLastTimestampResponse{ - Result: result, - } - scrobbleretrieverWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newScrobbleRetrieverGetScrobblesHostFunction(service ScrobbleRetrieverService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "scrobbleretriever_getscrobbles", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - var req ScrobbleRetrieverGetScrobblesRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - - // Call the service method - scrobbles, next, svcErr := service.GetScrobbles(ctx, req.Username, req.Options) - if svcErr != nil { - scrobbleretrieverWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := ScrobbleRetrieverGetScrobblesResponse{ - Scrobbles: scrobbles, - Next: next, - } - scrobbleretrieverWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newScrobbleRetrieverGetScrobbleCountHostFunction(service ScrobbleRetrieverService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "scrobbleretriever_getscrobblecount", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - var req ScrobbleRetrieverGetScrobbleCountRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - - // Call the service method - result, svcErr := service.GetScrobbleCount(ctx, req.Username, req.Options) - if svcErr != nil { - scrobbleretrieverWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := ScrobbleRetrieverGetScrobbleCountResponse{ - Result: result, - } - scrobbleretrieverWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// scrobbleretrieverWriteResponse writes a JSON response to plugin memory. -func scrobbleretrieverWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - scrobbleretrieverWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// scrobbleretrieverWriteError writes an error response to plugin memory. -func scrobbleretrieverWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/host/storage.go b/plugins/host/storage.go deleted file mode 100644 index ed24d7508..000000000 --- a/plugins/host/storage.go +++ /dev/null @@ -1,13 +0,0 @@ -package host - -import "context" - -// StorageService provides access to a plugin-specific directory with read/write permissions -// -//nd:hostservice name=Storage permission=storage -type StorageService interface { - // GetStoragePath retrieves the persistent storage path, if allowed - // - //nd:hostfunc - GetStoragePath(ctx context.Context) string -} diff --git a/plugins/host/storage_gen.go b/plugins/host/storage_gen.go deleted file mode 100644 index d705f0831..000000000 --- a/plugins/host/storage_gen.go +++ /dev/null @@ -1,67 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// StorageGetStoragePathResponse is the response type for Storage.GetStoragePath. -type StorageGetStoragePathResponse struct { - Result string `json:"result,omitempty"` -} - -// RegisterStorageHostFunctions registers Storage service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterStorageHostFunctions(service StorageService) []extism.HostFunction { - return []extism.HostFunction{ - newStorageGetStoragePathHostFunction(service), - } -} - -func newStorageGetStoragePathHostFunction(service StorageService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "storage_getstoragepath", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - - // Call the service method - result := service.GetStoragePath(ctx) - - // Write JSON response to plugin memory - resp := StorageGetStoragePathResponse{ - Result: result, - } - storageWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// storageWriteResponse writes a JSON response to plugin memory. -func storageWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - storageWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// storageWriteError writes an error response to plugin memory. -func storageWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/host/subsonicapi.go b/plugins/host/subsonicapi.go deleted file mode 100644 index 32de75e77..000000000 --- a/plugins/host/subsonicapi.go +++ /dev/null @@ -1,24 +0,0 @@ -package host - -import "context" - -// SubsonicAPIService provides access to Navidrome's Subsonic API from plugins. -// -// This service allows plugins to make Subsonic API requests on behalf of the plugin's user, -// enabling access to library data, user preferences, and other Subsonic-compatible operations. -// -//nd:hostservice name=SubsonicAPI permission=subsonicapi -type SubsonicAPIService interface { - // Call executes a Subsonic API request and returns the JSON response. - // - // The uri parameter should be the Subsonic API path without the server prefix, - // e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. - //nd:hostfunc - Call(ctx context.Context, uri string) (responseJSON string, err error) - - // CallRaw executes a Subsonic API request and returns the raw binary response. - // Designed for binary endpoints like getCoverArt and stream that return - // non-JSON data. The data is base64-encoded over JSON on the wire. - //nd:hostfunc - CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error) -} diff --git a/plugins/host/subsonicapi_gen.go b/plugins/host/subsonicapi_gen.go deleted file mode 100644 index 52474030e..000000000 --- a/plugins/host/subsonicapi_gen.go +++ /dev/null @@ -1,136 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// SubsonicAPICallRequest is the request type for SubsonicAPI.Call. -type SubsonicAPICallRequest struct { - Uri string `json:"uri"` -} - -// SubsonicAPICallResponse is the response type for SubsonicAPI.Call. -type SubsonicAPICallResponse struct { - ResponseJSON string `json:"responseJson,omitempty"` - Error string `json:"error,omitempty"` -} - -// SubsonicAPICallRawRequest is the request type for SubsonicAPI.CallRaw. -type SubsonicAPICallRawRequest struct { - Uri string `json:"uri"` -} - -// SubsonicAPICallRawResponse is the response type for SubsonicAPI.CallRaw. -type SubsonicAPICallRawResponse struct { - ContentType string `json:"contentType,omitempty"` - Data []byte `json:"data,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction { - return []extism.HostFunction{ - newSubsonicAPICallHostFunction(service), - newSubsonicAPICallRawHostFunction(service), - } -} - -func newSubsonicAPICallHostFunction(service SubsonicAPIService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "subsonicapi_call", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - subsonicapiWriteError(p, stack, err) - return - } - var req SubsonicAPICallRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - subsonicapiWriteError(p, stack, err) - return - } - - // Call the service method - responsejson, svcErr := service.Call(ctx, req.Uri) - if svcErr != nil { - subsonicapiWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := SubsonicAPICallResponse{ - ResponseJSON: responsejson, - } - subsonicapiWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newSubsonicAPICallRawHostFunction(service SubsonicAPIService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "subsonicapi_callraw", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - subsonicapiWriteError(p, stack, err) - return - } - var req SubsonicAPICallRawRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - subsonicapiWriteError(p, stack, err) - return - } - - // Call the service method - contenttype, data, svcErr := service.CallRaw(ctx, req.Uri) - if svcErr != nil { - subsonicapiWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := SubsonicAPICallRawResponse{ - ContentType: contenttype, - Data: data, - } - subsonicapiWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// subsonicapiWriteResponse writes a JSON response to plugin memory. -func subsonicapiWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - subsonicapiWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// subsonicapiWriteError writes an error response to plugin memory. -func subsonicapiWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/host/task.go b/plugins/host/task.go deleted file mode 100644 index dcaf7197e..000000000 --- a/plugins/host/task.go +++ /dev/null @@ -1,73 +0,0 @@ -package host - -import "context" - -// TaskInfo holds the current state of a task. -type TaskInfo struct { - // Status is the current task status: "pending", "running", - // "completed", "failed", or "cancelled". - Status string `json:"status"` - // Message is the status/result message returned by the plugin callback. - Message string `json:"message"` - // Attempt is the current or last attempt number (1-based). - Attempt int32 `json:"attempt"` -} - -// QueueConfig holds configuration for a task queue. -type QueueConfig struct { - // Concurrency is the max number of parallel workers. Default: 1. - // Capped by the plugin's manifest maxConcurrency. - Concurrency int32 `json:"concurrency"` - - // MaxRetries is the number of times to retry a failed task. Default: 0. - MaxRetries int32 `json:"maxRetries"` - - // BackoffMs is the initial backoff between retries in milliseconds. - // Doubles each retry (exponential: backoffMs * 2^(attempt-1)). Default: 1000. - BackoffMs int64 `json:"backoffMs"` - - // DelayMs is the minimum delay between starting consecutive tasks - // in milliseconds. Useful for rate limiting. Default: 0. - DelayMs int64 `json:"delayMs"` - - // RetentionMs is how long completed/failed/cancelled tasks are kept - // in milliseconds. Default: 3600000 (1h). Min: 60000 (1m). Max: 604800000 (1w). - RetentionMs int64 `json:"retentionMs"` -} - -// TaskService provides persistent task queues for plugins. -// -// This service allows plugins to create named queues with configurable concurrency, -// retry policies, and rate limiting. Tasks are persisted to SQLite and survive -// server restarts. When a task is ready to execute, the host calls the plugin's -// nd_task_execute callback function. -// -//nd:hostservice name=Task permission=taskqueue -type TaskService interface { - // CreateQueue creates a named task queue with the given configuration. - // Zero-value fields in config use sensible defaults. - // If a queue with the same name already exists, returns an error. - // On startup, this also recovers any stale "running" tasks from a previous crash. - //nd:hostfunc - CreateQueue(ctx context.Context, name string, config QueueConfig) error - - // Enqueue adds a task to the named queue. Returns the task ID. - // payload is opaque bytes passed back to the plugin on execution. - //nd:hostfunc - Enqueue(ctx context.Context, queueName string, payload []byte) (string, error) - - // Get returns the current state of a task including its status, - // message, and attempt count. - //nd:hostfunc - Get(ctx context.Context, taskID string) (*TaskInfo, error) - - // Cancel cancels a pending task. Returns error if already - // running, completed, or failed. - //nd:hostfunc - Cancel(ctx context.Context, taskID string) error - - // ClearQueue removes all pending tasks from the named queue. - // Running tasks are not affected. Returns the number of tasks removed. - //nd:hostfunc - ClearQueue(ctx context.Context, queueName string) (int64, error) -} diff --git a/plugins/host/task_gen.go b/plugins/host/task_gen.go deleted file mode 100644 index e4864bb5b..000000000 --- a/plugins/host/task_gen.go +++ /dev/null @@ -1,266 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// TaskCreateQueueRequest is the request type for Task.CreateQueue. -type TaskCreateQueueRequest struct { - Name string `json:"name"` - Config QueueConfig `json:"config"` -} - -// TaskCreateQueueResponse is the response type for Task.CreateQueue. -type TaskCreateQueueResponse struct { - Error string `json:"error,omitempty"` -} - -// TaskEnqueueRequest is the request type for Task.Enqueue. -type TaskEnqueueRequest struct { - QueueName string `json:"queueName"` - Payload []byte `json:"payload"` -} - -// TaskEnqueueResponse is the response type for Task.Enqueue. -type TaskEnqueueResponse struct { - Result string `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// TaskGetRequest is the request type for Task.Get. -type TaskGetRequest struct { - TaskID string `json:"taskId"` -} - -// TaskGetResponse is the response type for Task.Get. -type TaskGetResponse struct { - Result *TaskInfo `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// TaskCancelRequest is the request type for Task.Cancel. -type TaskCancelRequest struct { - TaskID string `json:"taskId"` -} - -// TaskCancelResponse is the response type for Task.Cancel. -type TaskCancelResponse struct { - Error string `json:"error,omitempty"` -} - -// TaskClearQueueRequest is the request type for Task.ClearQueue. -type TaskClearQueueRequest struct { - QueueName string `json:"queueName"` -} - -// TaskClearQueueResponse is the response type for Task.ClearQueue. -type TaskClearQueueResponse struct { - Result int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterTaskHostFunctions registers Task service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterTaskHostFunctions(service TaskService) []extism.HostFunction { - return []extism.HostFunction{ - newTaskCreateQueueHostFunction(service), - newTaskEnqueueHostFunction(service), - newTaskGetHostFunction(service), - newTaskCancelHostFunction(service), - newTaskClearQueueHostFunction(service), - } -} - -func newTaskCreateQueueHostFunction(service TaskService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "task_createqueue", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - taskWriteError(p, stack, err) - return - } - var req TaskCreateQueueRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - taskWriteError(p, stack, err) - return - } - - // Call the service method - if svcErr := service.CreateQueue(ctx, req.Name, req.Config); svcErr != nil { - taskWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := TaskCreateQueueResponse{} - taskWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newTaskEnqueueHostFunction(service TaskService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "task_enqueue", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - taskWriteError(p, stack, err) - return - } - var req TaskEnqueueRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - taskWriteError(p, stack, err) - return - } - - // Call the service method - result, svcErr := service.Enqueue(ctx, req.QueueName, req.Payload) - if svcErr != nil { - taskWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := TaskEnqueueResponse{ - Result: result, - } - taskWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newTaskGetHostFunction(service TaskService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "task_get", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - taskWriteError(p, stack, err) - return - } - var req TaskGetRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - taskWriteError(p, stack, err) - return - } - - // Call the service method - result, svcErr := service.Get(ctx, req.TaskID) - if svcErr != nil { - taskWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := TaskGetResponse{ - Result: result, - } - taskWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newTaskCancelHostFunction(service TaskService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "task_cancel", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - taskWriteError(p, stack, err) - return - } - var req TaskCancelRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - taskWriteError(p, stack, err) - return - } - - // Call the service method - if svcErr := service.Cancel(ctx, req.TaskID); svcErr != nil { - taskWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := TaskCancelResponse{} - taskWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newTaskClearQueueHostFunction(service TaskService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "task_clearqueue", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - taskWriteError(p, stack, err) - return - } - var req TaskClearQueueRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - taskWriteError(p, stack, err) - return - } - - // Call the service method - result, svcErr := service.ClearQueue(ctx, req.QueueName) - if svcErr != nil { - taskWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := TaskClearQueueResponse{ - Result: result, - } - taskWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// taskWriteResponse writes a JSON response to plugin memory. -func taskWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - taskWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// taskWriteError writes an error response to plugin memory. -func taskWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/host/users.go b/plugins/host/users.go deleted file mode 100644 index c05a0c797..000000000 --- a/plugins/host/users.go +++ /dev/null @@ -1,35 +0,0 @@ -package host - -import "context" - -// User represents a Navidrome user with minimal information exposed to plugins. -// Sensitive fields like password, email, and internal IDs are intentionally excluded. -type User struct { - UserName string `json:"userName"` - Name string `json:"name"` - IsAdmin bool `json:"isAdmin"` -} - -// UsersService provides access to user information for plugins. -// -// This service allows plugins to query information about users that the plugin -// has been granted access to. Access is controlled by the administrator who -// configures which users each plugin can see. -// -//nd:hostservice name=Users permission=users -type UsersService interface { - // GetUsers returns all users the plugin has been granted access to. - // Only minimal user information (userName, name, isAdmin) is returned. - // Sensitive fields like password and email are never exposed. - // - // Returns a slice of users the plugin can access, or an empty slice if none configured. - //nd:hostfunc - GetUsers(ctx context.Context) ([]User, error) - - // GetAdmins returns only admin users the plugin has been granted access to. - // This is a convenience method that filters GetUsers results to include only admins. - // - // Returns a slice of admin users the plugin can access, or an empty slice if none. - //nd:hostfunc - GetAdmins(ctx context.Context) ([]User, error) -} diff --git a/plugins/host/users_gen.go b/plugins/host/users_gen.go deleted file mode 100644 index 4e7210991..000000000 --- a/plugins/host/users_gen.go +++ /dev/null @@ -1,102 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// UsersGetUsersResponse is the response type for Users.GetUsers. -type UsersGetUsersResponse struct { - Result []User `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// UsersGetAdminsResponse is the response type for Users.GetAdmins. -type UsersGetAdminsResponse struct { - Result []User `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// RegisterUsersHostFunctions registers Users service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterUsersHostFunctions(service UsersService) []extism.HostFunction { - return []extism.HostFunction{ - newUsersGetUsersHostFunction(service), - newUsersGetAdminsHostFunction(service), - } -} - -func newUsersGetUsersHostFunction(service UsersService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "users_getusers", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - - // Call the service method - result, svcErr := service.GetUsers(ctx) - if svcErr != nil { - usersWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := UsersGetUsersResponse{ - Result: result, - } - usersWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newUsersGetAdminsHostFunction(service UsersService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "users_getadmins", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - - // Call the service method - result, svcErr := service.GetAdmins(ctx) - if svcErr != nil { - usersWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := UsersGetAdminsResponse{ - Result: result, - } - usersWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// usersWriteResponse writes a JSON response to plugin memory. -func usersWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - usersWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// usersWriteError writes an error response to plugin memory. -func usersWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/host/websocket.go b/plugins/host/websocket.go deleted file mode 100644 index 434a28a8a..000000000 --- a/plugins/host/websocket.go +++ /dev/null @@ -1,59 +0,0 @@ -package host - -import "context" - -// WebSocketService provides WebSocket communication capabilities for plugins. -// -// This service allows plugins to establish WebSocket connections to external services, -// send and receive messages, and manage connection lifecycle. Plugins using this service -// must implement the WebSocketCallback capability to receive incoming messages and -// connection state changes. -// -//nd:hostservice name=WebSocket permission=websocket -type WebSocketService interface { - // Connect establishes a WebSocket connection to the specified URL. - // - // Plugins that use this function must also implement the WebSocketCallback capability - // to receive incoming messages and connection events. - // - // Parameters: - // - url: The WebSocket URL to connect to (ws:// or wss://) - // - headers: Optional HTTP headers to include in the handshake request - // - connectionID: Optional unique identifier for the connection. If empty, one will be generated - // - // Returns the connection ID that can be used to send messages or close the connection, - // or an error if the connection fails. - //nd:hostfunc - Connect(ctx context.Context, url string, headers map[string]string, connectionID string) (newConnectionID string, err error) - - // SendText sends a text message over an established WebSocket connection. - // - // Parameters: - // - connectionID: The connection identifier returned by Connect - // - message: The text message to send - // - // Returns an error if the connection is not found or if sending fails. - //nd:hostfunc - SendText(ctx context.Context, connectionID, message string) error - - // SendBinary sends binary data over an established WebSocket connection. - // - // Parameters: - // - connectionID: The connection identifier returned by Connect - // - data: The binary data to send - // - // Returns an error if the connection is not found or if sending fails. - //nd:hostfunc - SendBinary(ctx context.Context, connectionID string, data []byte) error - - // CloseConnection gracefully closes a WebSocket connection. - // - // Parameters: - // - connectionID: The connection identifier returned by Connect - // - code: WebSocket close status code (e.g., 1000 for normal closure) - // - reason: Optional human-readable reason for closing - // - // Returns an error if the connection is not found or if closing fails. - //nd:hostfunc - CloseConnection(ctx context.Context, connectionID string, code int32, reason string) error -} diff --git a/plugins/host/websocket_gen.go b/plugins/host/websocket_gen.go deleted file mode 100644 index b7b630674..000000000 --- a/plugins/host/websocket_gen.go +++ /dev/null @@ -1,220 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -package host - -import ( - "context" - "encoding/json" - - extism "github.com/extism/go-sdk" -) - -// WebSocketConnectRequest is the request type for WebSocket.Connect. -type WebSocketConnectRequest struct { - Url string `json:"url"` - Headers map[string]string `json:"headers"` - ConnectionID string `json:"connectionId"` -} - -// WebSocketConnectResponse is the response type for WebSocket.Connect. -type WebSocketConnectResponse struct { - NewConnectionID string `json:"newConnectionId,omitempty"` - Error string `json:"error,omitempty"` -} - -// WebSocketSendTextRequest is the request type for WebSocket.SendText. -type WebSocketSendTextRequest struct { - ConnectionID string `json:"connectionId"` - Message string `json:"message"` -} - -// WebSocketSendTextResponse is the response type for WebSocket.SendText. -type WebSocketSendTextResponse struct { - Error string `json:"error,omitempty"` -} - -// WebSocketSendBinaryRequest is the request type for WebSocket.SendBinary. -type WebSocketSendBinaryRequest struct { - ConnectionID string `json:"connectionId"` - Data []byte `json:"data"` -} - -// WebSocketSendBinaryResponse is the response type for WebSocket.SendBinary. -type WebSocketSendBinaryResponse struct { - Error string `json:"error,omitempty"` -} - -// WebSocketCloseConnectionRequest is the request type for WebSocket.CloseConnection. -type WebSocketCloseConnectionRequest struct { - ConnectionID string `json:"connectionId"` - Code int32 `json:"code"` - Reason string `json:"reason"` -} - -// WebSocketCloseConnectionResponse is the response type for WebSocket.CloseConnection. -type WebSocketCloseConnectionResponse struct { - Error string `json:"error,omitempty"` -} - -// RegisterWebSocketHostFunctions registers WebSocket service host functions. -// The returned host functions should be added to the plugin's configuration. -func RegisterWebSocketHostFunctions(service WebSocketService) []extism.HostFunction { - return []extism.HostFunction{ - newWebSocketConnectHostFunction(service), - newWebSocketSendTextHostFunction(service), - newWebSocketSendBinaryHostFunction(service), - newWebSocketCloseConnectionHostFunction(service), - } -} - -func newWebSocketConnectHostFunction(service WebSocketService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "websocket_connect", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - websocketWriteError(p, stack, err) - return - } - var req WebSocketConnectRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - websocketWriteError(p, stack, err) - return - } - - // Call the service method - newconnectionid, svcErr := service.Connect(ctx, req.Url, req.Headers, req.ConnectionID) - if svcErr != nil { - websocketWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := WebSocketConnectResponse{ - NewConnectionID: newconnectionid, - } - websocketWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newWebSocketSendTextHostFunction(service WebSocketService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "websocket_sendtext", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - websocketWriteError(p, stack, err) - return - } - var req WebSocketSendTextRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - websocketWriteError(p, stack, err) - return - } - - // Call the service method - if svcErr := service.SendText(ctx, req.ConnectionID, req.Message); svcErr != nil { - websocketWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := WebSocketSendTextResponse{} - websocketWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newWebSocketSendBinaryHostFunction(service WebSocketService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "websocket_sendbinary", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - websocketWriteError(p, stack, err) - return - } - var req WebSocketSendBinaryRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - websocketWriteError(p, stack, err) - return - } - - // Call the service method - if svcErr := service.SendBinary(ctx, req.ConnectionID, req.Data); svcErr != nil { - websocketWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := WebSocketSendBinaryResponse{} - websocketWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -func newWebSocketCloseConnectionHostFunction(service WebSocketService) extism.HostFunction { - return extism.NewHostFunctionWithStack( - "websocket_closeconnection", - func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { - // Read JSON request from plugin memory - reqBytes, err := p.ReadBytes(stack[0]) - if err != nil { - websocketWriteError(p, stack, err) - return - } - var req WebSocketCloseConnectionRequest - if err := json.Unmarshal(reqBytes, &req); err != nil { - websocketWriteError(p, stack, err) - return - } - - // Call the service method - if svcErr := service.CloseConnection(ctx, req.ConnectionID, req.Code, req.Reason); svcErr != nil { - websocketWriteError(p, stack, svcErr) - return - } - - // Write JSON response to plugin memory - resp := WebSocketCloseConnectionResponse{} - websocketWriteResponse(p, stack, resp) - }, - []extism.ValueType{extism.ValueTypePTR}, - []extism.ValueType{extism.ValueTypePTR}, - ) -} - -// websocketWriteResponse writes a JSON response to plugin memory. -func websocketWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { - respBytes, err := json.Marshal(resp) - if err != nil { - websocketWriteError(p, stack, err) - return - } - respPtr, err := p.WriteBytes(respBytes) - if err != nil { - stack[0] = 0 - return - } - stack[0] = respPtr -} - -// websocketWriteError writes an error response to plugin memory. -func websocketWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { - errResp := struct { - Error string `json:"error"` - }{Error: err.Error()} - respBytes, _ := json.Marshal(errResp) - respPtr, _ := p.WriteBytes(respBytes) - stack[0] = respPtr -} diff --git a/plugins/host_artwork.go b/plugins/host_artwork.go deleted file mode 100644 index 7a19eddb9..000000000 --- a/plugins/host_artwork.go +++ /dev/null @@ -1,37 +0,0 @@ -package plugins - -import ( - "context" - - "github.com/navidrome/navidrome/core/publicurl" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/plugins/host" -) - -type artworkServiceImpl struct{} - -func newArtworkService() host.ArtworkService { - return &artworkServiceImpl{} -} - -func (a *artworkServiceImpl) GetArtistUrl(ctx context.Context, id string, size int32) (string, error) { - artID := model.ArtworkID{Kind: model.KindArtistArtwork, ID: id} - return publicurl.ImageURL(ctx, artID, int(size)), nil -} - -func (a *artworkServiceImpl) GetAlbumUrl(ctx context.Context, id string, size int32) (string, error) { - artID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: id} - return publicurl.ImageURL(ctx, artID, int(size)), nil -} - -func (a *artworkServiceImpl) GetTrackUrl(ctx context.Context, id string, size int32) (string, error) { - artID := model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: id} - return publicurl.ImageURL(ctx, artID, int(size)), nil -} - -func (a *artworkServiceImpl) GetPlaylistUrl(ctx context.Context, id string, size int32) (string, error) { - artID := model.ArtworkID{Kind: model.KindPlaylistArtwork, ID: id} - return publicurl.ImageURL(ctx, artID, int(size)), nil -} - -var _ host.ArtworkService = (*artworkServiceImpl)(nil) diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go deleted file mode 100644 index 311bf21d5..000000000 --- a/plugins/host_artwork_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package plugins - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "net/http" - "os" - "path/filepath" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("ArtworkService", Ordered, func() { - var ( - manager *Manager - tmpDir string - ) - - BeforeAll(func() { - var err error - tmpDir, err = os.MkdirTemp("", "artwork-test-*") - Expect(err).ToNot(HaveOccurred()) - - // Copy the test-artwork plugin - srcPath := filepath.Join(testdataDir, "test-artwork"+PackageExtension) - destPath := filepath.Join(tmpDir, "test-artwork"+PackageExtension) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Compute SHA256 for the plugin - hash := sha256.Sum256(data) - hashHex := hex.EncodeToString(hash[:]) - - // Setup config - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - - // Initialize auth (required for token generation) - ds := &tests.MockDataStore{MockedProperty: &tests.MockedPropertyRepo{}} - auth.Init(ds) - - // Setup mock DataStore with pre-enabled plugin - mockPluginRepo := tests.CreateMockPluginRepo() - mockPluginRepo.Permitted = true - mockPluginRepo.SetData(model.Plugins{{ - ID: "test-artwork", - Path: destPath, - SHA256: hashHex, - Enabled: true, - }}) - dataStore := &tests.MockDataStore{ - MockedProperty: &tests.MockedPropertyRepo{}, - MockedPlugin: mockPluginRepo, - } - - // Create and start manager - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - subsonicRouter: http.NotFoundHandler(), - } - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) - }) - - Describe("Plugin Loading", func() { - It("should load plugin with artwork permission", func() { - manager.mu.RLock() - p, ok := manager.plugins["test-artwork"] - manager.mu.RUnlock() - Expect(ok).To(BeTrue()) - Expect(p.manifest.Permissions).ToNot(BeNil()) - Expect(p.manifest.Permissions.Artwork).ToNot(BeNil()) - }) - }) - - Describe("Artwork URL Generation", func() { - type testArtworkInput struct { - ArtworkType string `json:"artwork_type"` - ID string `json:"id"` - Size int32 `json:"size"` - } - type testArtworkOutput struct { - URL string `json:"url,omitempty"` - Error *string `json:"error,omitempty"` - } - - callTestArtwork := func(ctx context.Context, artworkType, id string, size int32) (string, error) { - manager.mu.RLock() - p := manager.plugins["test-artwork"] - manager.mu.RUnlock() - - instance, err := p.instance(ctx) - if err != nil { - return "", err - } - defer instance.Close(ctx) - - input := testArtworkInput{ - ArtworkType: artworkType, - ID: id, - Size: size, - } - inputBytes, _ := json.Marshal(input) - _, outputBytes, err := instance.CallWithContext(ctx, "nd_test_artwork", inputBytes) - if err != nil { - return "", err - } - - var output testArtworkOutput - if err := json.Unmarshal(outputBytes, &output); err != nil { - return "", err - } - if output.Error != nil { - return "", Errorf(*output.Error) - } - return output.URL, nil - } - - It("should generate artist artwork URL", func() { - url, err := callTestArtwork(GinkgoT().Context(), "artist", "ar-123", 0) - Expect(err).ToNot(HaveOccurred()) - Expect(url).To(ContainSubstring("/img/")) - Expect(url).ToNot(ContainSubstring("size=")) - - // Decode JWT and verify artwork ID - artID := decodeArtworkURL(url) - Expect(artID.Kind).To(Equal(model.KindArtistArtwork)) - Expect(artID.ID).To(Equal("ar-123")) - }) - - It("should generate album artwork URL", func() { - url, err := callTestArtwork(GinkgoT().Context(), "album", "al-456", 0) - Expect(err).ToNot(HaveOccurred()) - Expect(url).To(ContainSubstring("/img/")) - - artID := decodeArtworkURL(url) - Expect(artID.Kind).To(Equal(model.KindAlbumArtwork)) - Expect(artID.ID).To(Equal("al-456")) - }) - - It("should generate track artwork URL", func() { - url, err := callTestArtwork(GinkgoT().Context(), "track", "mf-789", 0) - Expect(err).ToNot(HaveOccurred()) - Expect(url).To(ContainSubstring("/img/")) - - artID := decodeArtworkURL(url) - Expect(artID.Kind).To(Equal(model.KindMediaFileArtwork)) - Expect(artID.ID).To(Equal("mf-789")) - }) - - It("should generate playlist artwork URL", func() { - url, err := callTestArtwork(GinkgoT().Context(), "playlist", "pl-abc", 0) - Expect(err).ToNot(HaveOccurred()) - Expect(url).To(ContainSubstring("/img/")) - - artID := decodeArtworkURL(url) - Expect(artID.Kind).To(Equal(model.KindPlaylistArtwork)) - Expect(artID.ID).To(Equal("pl-abc")) - }) - - It("should include size parameter when specified", func() { - url, err := callTestArtwork(GinkgoT().Context(), "album", "al-456", 300) - Expect(err).ToNot(HaveOccurred()) - Expect(url).To(ContainSubstring("size=300")) - - artID := decodeArtworkURL(url) - Expect(artID.Kind).To(Equal(model.KindAlbumArtwork)) - Expect(artID.ID).To(Equal("al-456")) - }) - - It("uses the address of the request that triggered the plugin", func() { - ctx := request.WithServerAddress(GinkgoT().Context(), "https", "music.example.com") - - url, err := callTestArtwork(ctx, "track", "mf-789", 300) - Expect(err).ToNot(HaveOccurred()) - Expect(url).To(HavePrefix("https://music.example.com/share/img/")) - }) - - It("should handle unknown artwork type", func() { - _, err := callTestArtwork(GinkgoT().Context(), "unknown", "id-123", 0) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("unknown artwork type")) - }) - }) -}) - -// Errorf creates an error from a format string (helper for tests) -func Errorf(format string, args ...any) error { - return &errorString{s: format} -} - -type errorString struct { - s string -} - -func (e *errorString) Error() string { - return e.s -} - -// decodeArtworkURL extracts and decodes the JWT token from an artwork URL, -// returning the parsed ArtworkID. Panics on error (test helper). -func decodeArtworkURL(artworkURL string) model.ArtworkID { - // URL format: http://localhost/img/<token>?size=... - // Extract token from path after /img/ - idx := strings.Index(artworkURL, "/img/") - Expect(idx).To(BeNumerically(">=", 0), "URL should contain /img/") - - tokenPart := artworkURL[idx+5:] // skip "/img/" - // Remove query string if present - if qIdx := strings.Index(tokenPart, "?"); qIdx >= 0 { - tokenPart = tokenPart[:qIdx] - } - - // Decode JWT token - token, err := auth.PublicTokenAuth.Decode(tokenPart) - Expect(err).ToNot(HaveOccurred(), "Failed to decode JWT token") - - c := auth.ClaimsFromToken(token) - - id := c.ID - Expect(id).ToNot(BeEmpty(), "Token should contain 'id' claim") - - artID, err := model.ParseArtworkID(id) - Expect(err).ToNot(HaveOccurred(), "Failed to parse artwork ID from token") - - return artID -} diff --git a/plugins/host_cache.go b/plugins/host_cache.go deleted file mode 100644 index f410dfd3d..000000000 --- a/plugins/host_cache.go +++ /dev/null @@ -1,161 +0,0 @@ -package plugins - -import ( - "context" - "runtime" - "time" - - "github.com/jellydator/ttlcache/v3" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/plugins/host" -) - -const ( - defaultCacheTTL = 24 * time.Hour -) - -// cacheServiceImpl implements the host.CacheService interface. -// Each plugin gets its own cache instance for isolation. -type cacheServiceImpl struct { - pluginName string - cache *ttlcache.Cache[string, any] - defaultTTL time.Duration -} - -// newCacheService creates a new cacheServiceImpl instance with its own cache. -func newCacheService(pluginName string) *cacheServiceImpl { - cache := ttlcache.New[string, any]( - ttlcache.WithTTL[string, any](defaultCacheTTL), - ) - // Start the janitor goroutine to clean up expired entries - go cache.Start() - - svc := &cacheServiceImpl{ - pluginName: pluginName, - cache: cache, - defaultTTL: defaultCacheTTL, - } - - // Automatic cleanup to prevent goroutine leak when the service is garbage collected - runtime.AddCleanup(svc, func(ttlCache *ttlcache.Cache[string, any]) { - ttlCache.Stop() - }, cache) - - return svc -} - -// getTTL converts seconds to a duration, using default if 0 or negative -func (s *cacheServiceImpl) getTTL(seconds int64) time.Duration { - if seconds <= 0 { - return s.defaultTTL - } - return time.Duration(seconds) * time.Second -} - -// SetString stores a string value in the cache. -func (s *cacheServiceImpl) SetString(ctx context.Context, key string, value string, ttlSeconds int64) error { - s.cache.Set(key, value, s.getTTL(ttlSeconds)) - return nil -} - -// GetString retrieves a string value from the cache. -func (s *cacheServiceImpl) GetString(ctx context.Context, key string) (string, bool, error) { - item := s.cache.Get(key) - if item == nil { - return "", false, nil - } - - value, ok := item.Value().(string) - if !ok { - log.Debug(ctx, "Cache type mismatch", "plugin", s.pluginName, "key", key, "expected", "string") - return "", false, nil - } - return value, true, nil -} - -// SetInt stores an integer value in the cache. -func (s *cacheServiceImpl) SetInt(ctx context.Context, key string, value int64, ttlSeconds int64) error { - s.cache.Set(key, value, s.getTTL(ttlSeconds)) - return nil -} - -// GetInt retrieves an integer value from the cache. -func (s *cacheServiceImpl) GetInt(ctx context.Context, key string) (int64, bool, error) { - item := s.cache.Get(key) - if item == nil { - return 0, false, nil - } - - value, ok := item.Value().(int64) - if !ok { - log.Debug(ctx, "Cache type mismatch", "plugin", s.pluginName, "key", key, "expected", "int64") - return 0, false, nil - } - return value, true, nil -} - -// SetFloat stores a float value in the cache. -func (s *cacheServiceImpl) SetFloat(ctx context.Context, key string, value float64, ttlSeconds int64) error { - s.cache.Set(key, value, s.getTTL(ttlSeconds)) - return nil -} - -// GetFloat retrieves a float value from the cache. -func (s *cacheServiceImpl) GetFloat(ctx context.Context, key string) (float64, bool, error) { - item := s.cache.Get(key) - if item == nil { - return 0, false, nil - } - - value, ok := item.Value().(float64) - if !ok { - log.Debug(ctx, "Cache type mismatch", "plugin", s.pluginName, "key", key, "expected", "float64") - return 0, false, nil - } - return value, true, nil -} - -// SetBytes stores a byte slice in the cache. -func (s *cacheServiceImpl) SetBytes(ctx context.Context, key string, value []byte, ttlSeconds int64) error { - s.cache.Set(key, value, s.getTTL(ttlSeconds)) - return nil -} - -// GetBytes retrieves a byte slice from the cache. -func (s *cacheServiceImpl) GetBytes(ctx context.Context, key string) ([]byte, bool, error) { - item := s.cache.Get(key) - if item == nil { - return nil, false, nil - } - - value, ok := item.Value().([]byte) - if !ok { - log.Debug(ctx, "Cache type mismatch", "plugin", s.pluginName, "key", key, "expected", "[]byte") - return nil, false, nil - } - return value, true, nil -} - -// Has checks if a key exists in the cache. -func (s *cacheServiceImpl) Has(ctx context.Context, key string) (bool, error) { - item := s.cache.Get(key) - return item != nil, nil -} - -// Remove deletes a value from the cache. -func (s *cacheServiceImpl) Remove(ctx context.Context, key string) error { - s.cache.Delete(key) - return nil -} - -// Close stops the cache's janitor goroutine and clears all entries. -// This is called when the plugin is unloaded. -func (s *cacheServiceImpl) Close() error { - s.cache.Stop() - s.cache.DeleteAll() - log.Debug("Closed plugin cache", "plugin", s.pluginName) - return nil -} - -// Ensure cacheServiceImpl implements host.CacheService -var _ host.CacheService = (*cacheServiceImpl)(nil) diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go deleted file mode 100644 index 925856701..000000000 --- a/plugins/host_cache_test.go +++ /dev/null @@ -1,633 +0,0 @@ -package plugins - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "net/http" - "os" - "path/filepath" - "runtime" - "time" - - "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("CacheService", func() { - var service *cacheServiceImpl - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - service = newCacheService("test_plugin") - }) - - AfterEach(func() { - if service != nil { - service.Close() - } - }) - - Describe("getTTL", func() { - It("returns default TTL when seconds is 0", func() { - ttl := service.getTTL(0) - Expect(ttl).To(Equal(defaultCacheTTL)) - }) - - It("returns default TTL when seconds is negative", func() { - ttl := service.getTTL(-10) - Expect(ttl).To(Equal(defaultCacheTTL)) - }) - - It("returns correct duration when seconds is positive", func() { - ttl := service.getTTL(60) - Expect(ttl).To(Equal(time.Minute)) - }) - }) - - Describe("Plugin Isolation", func() { - It("isolates keys between plugins", func() { - service1 := newCacheService("plugin1") - defer service1.Close() - service2 := newCacheService("plugin2") - defer service2.Close() - - // Both plugins set same key - err := service1.SetString(ctx, "shared", "value1", 0) - Expect(err).ToNot(HaveOccurred()) - err = service2.SetString(ctx, "shared", "value2", 0) - Expect(err).ToNot(HaveOccurred()) - - // Each plugin should get their own value - val1, exists1, err := service1.GetString(ctx, "shared") - Expect(err).ToNot(HaveOccurred()) - Expect(exists1).To(BeTrue()) - Expect(val1).To(Equal("value1")) - - val2, exists2, err := service2.GetString(ctx, "shared") - Expect(err).ToNot(HaveOccurred()) - Expect(exists2).To(BeTrue()) - Expect(val2).To(Equal("value2")) - }) - }) - - Describe("String Operations", func() { - It("sets and gets a string value", func() { - err := service.SetString(ctx, "string_key", "test_value", 300) - Expect(err).ToNot(HaveOccurred()) - - value, exists, err := service.GetString(ctx, "string_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - Expect(value).To(Equal("test_value")) - }) - - It("returns not exists for missing key", func() { - value, exists, err := service.GetString(ctx, "missing_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - Expect(value).To(Equal("")) - }) - }) - - Describe("Integer Operations", func() { - It("sets and gets an integer value", func() { - err := service.SetInt(ctx, "int_key", 42, 300) - Expect(err).ToNot(HaveOccurred()) - - value, exists, err := service.GetInt(ctx, "int_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - Expect(value).To(Equal(int64(42))) - }) - - It("returns not exists for missing key", func() { - value, exists, err := service.GetInt(ctx, "missing_int_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - Expect(value).To(Equal(int64(0))) - }) - }) - - Describe("Float Operations", func() { - It("sets and gets a float value", func() { - err := service.SetFloat(ctx, "float_key", 3.14, 300) - Expect(err).ToNot(HaveOccurred()) - - value, exists, err := service.GetFloat(ctx, "float_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - Expect(value).To(Equal(3.14)) - }) - - It("returns not exists for missing key", func() { - value, exists, err := service.GetFloat(ctx, "missing_float_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - Expect(value).To(Equal(float64(0))) - }) - }) - - Describe("Bytes Operations", func() { - It("sets and gets a bytes value", func() { - byteData := []byte("hello world") - err := service.SetBytes(ctx, "bytes_key", byteData, 300) - Expect(err).ToNot(HaveOccurred()) - - value, exists, err := service.GetBytes(ctx, "bytes_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - Expect(value).To(Equal(byteData)) - }) - - It("returns not exists for missing key", func() { - value, exists, err := service.GetBytes(ctx, "missing_bytes_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - Expect(value).To(BeNil()) - }) - }) - - Describe("Type mismatch handling", func() { - It("returns not exists when type doesn't match the getter", func() { - // Set string - err := service.SetString(ctx, "mixed_key", "string value", 0) - Expect(err).ToNot(HaveOccurred()) - - // Try to get as int - value, exists, err := service.GetInt(ctx, "mixed_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - Expect(value).To(Equal(int64(0))) - }) - - It("returns not exists when getting string as float", func() { - err := service.SetString(ctx, "str_as_float", "not a float", 0) - Expect(err).ToNot(HaveOccurred()) - - value, exists, err := service.GetFloat(ctx, "str_as_float") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - Expect(value).To(Equal(float64(0))) - }) - - It("returns not exists when getting int as bytes", func() { - err := service.SetInt(ctx, "int_as_bytes", 123, 0) - Expect(err).ToNot(HaveOccurred()) - - value, exists, err := service.GetBytes(ctx, "int_as_bytes") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - Expect(value).To(BeNil()) - }) - }) - - Describe("Has Operation", func() { - It("returns true for existing key", func() { - err := service.SetString(ctx, "existing_key", "exists", 0) - Expect(err).ToNot(HaveOccurred()) - - exists, err := service.Has(ctx, "existing_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - }) - - It("returns false for non-existing key", func() { - exists, err := service.Has(ctx, "non_existing_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - }) - }) - - Describe("Remove Operation", func() { - It("removes a value from the cache", func() { - // Set a value - err := service.SetString(ctx, "remove_key", "to be removed", 0) - Expect(err).ToNot(HaveOccurred()) - - // Verify it exists - exists, err := service.Has(ctx, "remove_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - - // Remove it - err = service.Remove(ctx, "remove_key") - Expect(err).ToNot(HaveOccurred()) - - // Verify it's gone - exists, err = service.Has(ctx, "remove_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - }) - - It("does not error when removing non-existing key", func() { - err := service.Remove(ctx, "never_existed") - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("TTL Behavior", func() { - It("uses default TTL when 0 is provided", func() { - err := service.SetString(ctx, "default_ttl", "value", 0) - Expect(err).ToNot(HaveOccurred()) - - // Value should exist immediately - exists, err := service.Has(ctx, "default_ttl") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - }) - - It("uses custom TTL when provided", func() { - err := service.SetString(ctx, "custom_ttl", "value", 300) - Expect(err).ToNot(HaveOccurred()) - - // Value should exist immediately - exists, err := service.Has(ctx, "custom_ttl") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - }) - }) - - Describe("Close", func() { - It("removes all cache entries for the plugin", func() { - // Use a dedicated service for this test - closeService := newCacheService("close_test_plugin") - - // Set multiple values - err := closeService.SetString(ctx, "key1", "value1", 0) - Expect(err).ToNot(HaveOccurred()) - err = closeService.SetInt(ctx, "key2", 42, 0) - Expect(err).ToNot(HaveOccurred()) - err = closeService.SetFloat(ctx, "key3", 3.14, 0) - Expect(err).ToNot(HaveOccurred()) - - // Verify they exist - exists, _ := closeService.Has(ctx, "key1") - Expect(exists).To(BeTrue()) - exists, _ = closeService.Has(ctx, "key2") - Expect(exists).To(BeTrue()) - exists, _ = closeService.Has(ctx, "key3") - Expect(exists).To(BeTrue()) - - // Close the service - err = closeService.Close() - Expect(err).ToNot(HaveOccurred()) - - // All entries should be gone - exists, _ = closeService.Has(ctx, "key1") - Expect(exists).To(BeFalse()) - exists, _ = closeService.Has(ctx, "key2") - Expect(exists).To(BeFalse()) - exists, _ = closeService.Has(ctx, "key3") - Expect(exists).To(BeFalse()) - }) - - It("does not affect other plugins' cache entries", func() { - // Create two services for different plugins - service1 := newCacheService("plugin_close_test1") - service2 := newCacheService("plugin_close_test2") - defer service2.Close() - - // Set values for both plugins - err := service1.SetString(ctx, "key", "value1", 0) - Expect(err).ToNot(HaveOccurred()) - err = service2.SetString(ctx, "key", "value2", 0) - Expect(err).ToNot(HaveOccurred()) - - // Close only service1 - err = service1.Close() - Expect(err).ToNot(HaveOccurred()) - - // service1's key should be gone - exists, _ := service1.Has(ctx, "key") - Expect(exists).To(BeFalse()) - - // service2's key should still exist - exists, _ = service2.Has(ctx, "key") - Expect(exists).To(BeTrue()) - }) - }) -}) - -var _ = Describe("CacheService Integration", Ordered, func() { - var ( - manager *Manager - tmpDir string - ) - - BeforeAll(func() { - var err error - tmpDir, err = os.MkdirTemp("", "cache-test-*") - Expect(err).ToNot(HaveOccurred()) - - // Copy the test-cache-plugin - srcPath := filepath.Join(testdataDir, "test-cache-plugin"+PackageExtension) - destPath := filepath.Join(tmpDir, "test-cache-plugin"+PackageExtension) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Compute SHA256 for the plugin - hash := sha256.Sum256(data) - hashHex := hex.EncodeToString(hash[:]) - - // Setup config - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - - // Setup mock DataStore with pre-enabled plugin - mockPluginRepo := tests.CreateMockPluginRepo() - mockPluginRepo.Permitted = true - mockPluginRepo.SetData(model.Plugins{{ - ID: "test-cache-plugin", - Path: destPath, - SHA256: hashHex, - Enabled: true, - }}) - dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo} - - // Create and start manager - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - subsonicRouter: http.NotFoundHandler(), - } - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) - }) - - Describe("Plugin Loading", func() { - It("should load plugin with cache permission", func() { - manager.mu.RLock() - p, ok := manager.plugins["test-cache-plugin"] - manager.mu.RUnlock() - Expect(ok).To(BeTrue()) - Expect(p.manifest.Permissions).ToNot(BeNil()) - Expect(p.manifest.Permissions.Cache).ToNot(BeNil()) - }) - }) - - Describe("Cache Operations via Plugin", func() { - type testCacheInput struct { - Operation string `json:"operation"` - Key string `json:"key"` - StringVal string `json:"string_val,omitempty"` - IntVal int64 `json:"int_val,omitempty"` - FloatVal float64 `json:"float_val,omitempty"` - BytesVal []byte `json:"bytes_val,omitempty"` - TTLSeconds int64 `json:"ttl_seconds,omitempty"` - } - type testCacheOutput struct { - StringVal string `json:"string_val,omitempty"` - IntVal int64 `json:"int_val,omitempty"` - FloatVal float64 `json:"float_val,omitempty"` - BytesVal []byte `json:"bytes_val,omitempty"` - Exists bool `json:"exists,omitempty"` - Error *string `json:"error,omitempty"` - } - - callTestCache := func(ctx context.Context, input testCacheInput) (*testCacheOutput, error) { - manager.mu.RLock() - p := manager.plugins["test-cache-plugin"] - manager.mu.RUnlock() - - instance, err := p.instance(ctx) - if err != nil { - return nil, err - } - defer instance.Close(ctx) - - inputBytes, _ := json.Marshal(input) - _, outputBytes, err := instance.Call("nd_test_cache", inputBytes) - if err != nil { - return nil, err - } - - var output testCacheOutput - if err := json.Unmarshal(outputBytes, &output); err != nil { - return nil, err - } - if output.Error != nil { - return nil, errors.New(*output.Error) - } - return &output, nil - } - - It("should set and get string value", func() { - ctx := GinkgoT().Context() - - // Set string - _, err := callTestCache(ctx, testCacheInput{ - Operation: "set_string", - Key: "test_string", - StringVal: "hello world", - TTLSeconds: 300, - }) - Expect(err).ToNot(HaveOccurred()) - - // Get string - output, err := callTestCache(ctx, testCacheInput{ - Operation: "get_string", - Key: "test_string", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeTrue()) - Expect(output.StringVal).To(Equal("hello world")) - }) - - It("should set and get integer value", func() { - ctx := GinkgoT().Context() - - // Set int - _, err := callTestCache(ctx, testCacheInput{ - Operation: "set_int", - Key: "test_int", - IntVal: 42, - TTLSeconds: 300, - }) - Expect(err).ToNot(HaveOccurred()) - - // Get int - output, err := callTestCache(ctx, testCacheInput{ - Operation: "get_int", - Key: "test_int", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeTrue()) - Expect(output.IntVal).To(Equal(int64(42))) - }) - - It("should set and get float value", func() { - ctx := GinkgoT().Context() - - // Set float - _, err := callTestCache(ctx, testCacheInput{ - Operation: "set_float", - Key: "test_float", - FloatVal: 3.14159, - TTLSeconds: 300, - }) - Expect(err).ToNot(HaveOccurred()) - - // Get float - output, err := callTestCache(ctx, testCacheInput{ - Operation: "get_float", - Key: "test_float", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeTrue()) - Expect(output.FloatVal).To(Equal(3.14159)) - }) - - It("should set and get bytes value", func() { - ctx := GinkgoT().Context() - testBytes := []byte{0x01, 0x02, 0x03, 0x04} - - // Set bytes - _, err := callTestCache(ctx, testCacheInput{ - Operation: "set_bytes", - Key: "test_bytes", - BytesVal: testBytes, - TTLSeconds: 300, - }) - Expect(err).ToNot(HaveOccurred()) - - // Get bytes - output, err := callTestCache(ctx, testCacheInput{ - Operation: "get_bytes", - Key: "test_bytes", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeTrue()) - Expect(output.BytesVal).To(Equal(testBytes)) - }) - - It("should handle binary data with null bytes through WASM", func() { - ctx := GinkgoT().Context() - - // Binary data with null bytes, high bytes, and other edge cases - binaryData := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0x00, 0x80, 0x7F} - - // Set binary bytes - _, err := callTestCache(ctx, testCacheInput{ - Operation: "set_bytes", - Key: "binary_test", - BytesVal: binaryData, - TTLSeconds: 300, - }) - Expect(err).ToNot(HaveOccurred()) - - // Get binary bytes and verify exact match - output, err := callTestCache(ctx, testCacheInput{ - Operation: "get_bytes", - Key: "binary_test", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeTrue()) - Expect(output.BytesVal).To(Equal(binaryData)) - }) - - It("should check if key exists", func() { - ctx := GinkgoT().Context() - - // Set a value - _, err := callTestCache(ctx, testCacheInput{ - Operation: "set_string", - Key: "exists_test", - StringVal: "value", - }) - Expect(err).ToNot(HaveOccurred()) - - // Check has - output, err := callTestCache(ctx, testCacheInput{ - Operation: "has", - Key: "exists_test", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeTrue()) - - // Check non-existent - output, err = callTestCache(ctx, testCacheInput{ - Operation: "has", - Key: "nonexistent", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeFalse()) - }) - - It("should remove a key", func() { - ctx := GinkgoT().Context() - - // Set a value - _, err := callTestCache(ctx, testCacheInput{ - Operation: "set_string", - Key: "remove_test", - StringVal: "value", - }) - Expect(err).ToNot(HaveOccurred()) - - // Remove it - _, err = callTestCache(ctx, testCacheInput{ - Operation: "remove", - Key: "remove_test", - }) - Expect(err).ToNot(HaveOccurred()) - - // Verify it's gone - output, err := callTestCache(ctx, testCacheInput{ - Operation: "has", - Key: "remove_test", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeFalse()) - }) - }) -}) - -var _ = Describe("newCacheService", func() { - // The suite above leaves goroutines winding down, so settle before sampling. - settledBaseline := func() int { - var n int - Eventually(func() int { - runtime.GC() - prev := n - n = runtime.NumGoroutine() - return n - prev - }).WithTimeout(10 * time.Second).WithPolling(20 * time.Millisecond).Should(BeZero()) - return n - } - - It("stops the janitor goroutine once the service is unreachable", func() { - const numServices = 5 - baseline := settledBaseline() - - func() { - services := make([]*cacheServiceImpl, 0, numServices) - for i := range numServices { - services = append(services, newCacheService(fmt.Sprintf("plugin_%d", i))) - } - Expect(runtime.NumGoroutine()).To(BeNumerically(">=", baseline+numServices), - "expected one janitor goroutine per cache service") - }() - - Eventually(func() int { runtime.GC(); return runtime.NumGoroutine() }). - WithTimeout(10*time.Second).WithPolling(20*time.Millisecond). - Should(BeNumerically("<=", baseline), "janitor goroutines leaked") - }) -}) diff --git a/plugins/host_config.go b/plugins/host_config.go deleted file mode 100644 index 9e71db72f..000000000 --- a/plugins/host_config.go +++ /dev/null @@ -1,69 +0,0 @@ -package plugins - -import ( - "context" - "sort" - "strconv" - "strings" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/plugins/host" -) - -// configServiceImpl implements the host.ConfigService interface. -// It provides access to plugin configuration values set in the Navidrome config file. -type configServiceImpl struct { - pluginName string - config map[string]string -} - -// newConfigService creates a new configServiceImpl instance. -func newConfigService(pluginName string, config map[string]string) *configServiceImpl { - if config == nil { - config = make(map[string]string) - } - return &configServiceImpl{ - pluginName: pluginName, - config: config, - } -} - -// Get retrieves a configuration value as a string. -func (s *configServiceImpl) Get(ctx context.Context, key string) (string, bool) { - value, exists := s.config[key] - log.Trace(ctx, "Config.Get", "plugin", s.pluginName, "key", key, "exists", exists) - return value, exists -} - -// GetInt retrieves a configuration value as an integer. -func (s *configServiceImpl) GetInt(ctx context.Context, key string) (int64, bool) { - value, exists := s.config[key] - if !exists { - log.Trace(ctx, "Config.GetInt", "plugin", s.pluginName, "key", key, "exists", false) - return 0, false - } - - intValue, err := strconv.ParseInt(value, 10, 64) - if err != nil { - log.Trace(ctx, "Config.GetInt parse error", "plugin", s.pluginName, "key", key, "value", value, "error", err) - return 0, false - } - - log.Trace(ctx, "Config.GetInt", "plugin", s.pluginName, "key", key, "value", intValue) - return intValue, true -} - -// Keys returns configuration keys matching the given prefix. -func (s *configServiceImpl) Keys(ctx context.Context, prefix string) []string { - keys := make([]string, 0, len(s.config)) - for k := range s.config { - if prefix == "" || strings.HasPrefix(k, prefix) { - keys = append(keys, k) - } - } - sort.Strings(keys) - log.Trace(ctx, "Config.Keys", "plugin", s.pluginName, "prefix", prefix, "keyCount", len(keys)) - return keys -} - -var _ host.ConfigService = (*configServiceImpl)(nil) diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go deleted file mode 100644 index 80b06efe1..000000000 --- a/plugins/host_config_test.go +++ /dev/null @@ -1,379 +0,0 @@ -package plugins - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "net/http" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// testConfigInput is the input for nd_test_config callback. -type testConfigInput struct { - Operation string `json:"operation"` - Key string `json:"key,omitempty"` - Prefix string `json:"prefix,omitempty"` -} - -// testConfigOutput is the output from nd_test_config callback. -type testConfigOutput struct { - StringVal string `json:"string_val,omitempty"` - IntVal int64 `json:"int_val,omitempty"` - Keys []string `json:"keys,omitempty"` - Exists bool `json:"exists,omitempty"` - Error *string `json:"error,omitempty"` -} - -// setupTestConfigPlugin sets up a test environment with the test-config plugin loaded. -// Returns a cleanup function and a helper to call the plugin's nd_test_config function. -func setupTestConfigPlugin(configJSON string) (*Manager, func(context.Context, testConfigInput) (*testConfigOutput, error)) { - tmpDir, err := os.MkdirTemp("", "config-test-*") - Expect(err).ToNot(HaveOccurred()) - - // Copy the test-config plugin - srcPath := filepath.Join(testdataDir, "test-config"+PackageExtension) - destPath := filepath.Join(tmpDir, "test-config"+PackageExtension) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Compute SHA256 for the plugin - hash := sha256.Sum256(data) - hashHex := hex.EncodeToString(hash[:]) - - // Setup config - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - - // Setup mock DataStore - mockPluginRepo := tests.CreateMockPluginRepo() - mockPluginRepo.Permitted = true - mockPluginRepo.SetData(model.Plugins{{ - ID: "test-config", - Path: destPath, - SHA256: hashHex, - Enabled: true, - AllUsers: true, - Config: configJSON, - }}) - dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo} - - // Create and start manager - manager := &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - subsonicRouter: http.NotFoundHandler(), - } - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) - - // Helper to call test plugin's exported function - callTestConfig := func(ctx context.Context, input testConfigInput) (*testConfigOutput, error) { - manager.mu.RLock() - p := manager.plugins["test-config"] - manager.mu.RUnlock() - - instance, err := p.instance(ctx) - if err != nil { - return nil, err - } - defer instance.Close(ctx) - - inputBytes, _ := json.Marshal(input) - _, outputBytes, err := instance.Call("nd_test_config", inputBytes) - if err != nil { - return nil, err - } - - var output testConfigOutput - if err := json.Unmarshal(outputBytes, &output); err != nil { - return nil, err - } - if output.Error != nil { - return nil, errors.New(*output.Error) - } - return &output, nil - } - - return manager, callTestConfig -} - -var _ = Describe("ConfigService", func() { - var service *configServiceImpl - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - }) - - Describe("newConfigService", func() { - It("creates service with provided config", func() { - config := map[string]string{"key1": "value1", "key2": "value2"} - service = newConfigService("test_plugin", config) - Expect(service.pluginName).To(Equal("test_plugin")) - Expect(service.config).To(Equal(config)) - }) - - It("creates service with empty config when nil", func() { - service = newConfigService("test_plugin", nil) - Expect(service.config).ToNot(BeNil()) - Expect(service.config).To(BeEmpty()) - }) - }) - - Describe("Get", func() { - BeforeEach(func() { - service = newConfigService("test_plugin", map[string]string{ - "api_key": "secret123", - "debug_mode": "true", - "max_items": "100", - }) - }) - - It("returns value for existing key", func() { - value, exists := service.Get(ctx, "api_key") - Expect(exists).To(BeTrue()) - Expect(value).To(Equal("secret123")) - }) - - It("returns not exists for missing key", func() { - value, exists := service.Get(ctx, "missing_key") - Expect(exists).To(BeFalse()) - Expect(value).To(Equal("")) - }) - }) - - Describe("GetInt", func() { - BeforeEach(func() { - service = newConfigService("test_plugin", map[string]string{ - "max_items": "100", - "timeout": "30", - "negative": "-50", - "not_a_number": "abc", - "float": "3.14", - }) - }) - - It("returns integer for valid numeric value", func() { - value, exists := service.GetInt(ctx, "max_items") - Expect(exists).To(BeTrue()) - Expect(value).To(Equal(int64(100))) - }) - - It("returns negative integer", func() { - value, exists := service.GetInt(ctx, "negative") - Expect(exists).To(BeTrue()) - Expect(value).To(Equal(int64(-50))) - }) - - It("returns not exists for non-numeric value", func() { - value, exists := service.GetInt(ctx, "not_a_number") - Expect(exists).To(BeFalse()) - Expect(value).To(Equal(int64(0))) - }) - - It("returns not exists for float value", func() { - value, exists := service.GetInt(ctx, "float") - Expect(exists).To(BeFalse()) - Expect(value).To(Equal(int64(0))) - }) - - It("returns not exists for missing key", func() { - value, exists := service.GetInt(ctx, "missing_key") - Expect(exists).To(BeFalse()) - Expect(value).To(Equal(int64(0))) - }) - }) - - Describe("Keys", func() { - BeforeEach(func() { - service = newConfigService("test_plugin", map[string]string{ - "zebra": "z", - "apple": "a", - "banana": "b", - "user_alice": "token1", - "user_bob": "token2", - "user_charlie": "token3", - }) - }) - - It("returns all keys in sorted order when prefix is empty", func() { - keys := service.Keys(ctx, "") - Expect(keys).To(Equal([]string{"apple", "banana", "user_alice", "user_bob", "user_charlie", "zebra"})) - }) - - It("returns only keys matching prefix", func() { - keys := service.Keys(ctx, "user_") - Expect(keys).To(Equal([]string{"user_alice", "user_bob", "user_charlie"})) - }) - - It("returns empty slice when no keys match prefix", func() { - keys := service.Keys(ctx, "nonexistent_") - Expect(keys).To(BeEmpty()) - }) - - It("returns empty slice for empty config", func() { - service = newConfigService("test_plugin", nil) - keys := service.Keys(ctx, "") - Expect(keys).To(BeEmpty()) - }) - }) -}) - -var _ = Describe("ConfigService Integration", Ordered, func() { - var ( - manager *Manager - callTestConfig func(context.Context, testConfigInput) (*testConfigOutput, error) - ) - - BeforeAll(func() { - manager, callTestConfig = setupTestConfigPlugin(`{"api_key":"test_secret","max_retries":"5","timeout":"30"}`) - }) - - Describe("Plugin Loading", func() { - It("should load plugin without config permission", func() { - manager.mu.RLock() - p, ok := manager.plugins["test-config"] - manager.mu.RUnlock() - Expect(ok).To(BeTrue()) - Expect(p.manifest.Name).To(Equal("Test Config Plugin")) - }) - }) - - Describe("Config Operations via Plugin", func() { - It("should get string value", func() { - output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ - Operation: "get", - Key: "api_key", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.StringVal).To(Equal("test_secret")) - Expect(output.Exists).To(BeTrue()) - }) - - It("should return not exists for missing key", func() { - output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ - Operation: "get", - Key: "nonexistent", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeFalse()) - }) - - It("should get integer value", func() { - output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ - Operation: "get_int", - Key: "max_retries", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.IntVal).To(Equal(int64(5))) - Expect(output.Exists).To(BeTrue()) - }) - - It("should return not exists for non-integer value", func() { - output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ - Operation: "get_int", - Key: "api_key", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeFalse()) - }) - - It("should list all config keys with empty prefix", func() { - output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ - Operation: "list", - Prefix: "", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Keys).To(ConsistOf("api_key", "max_retries", "timeout")) - }) - - It("should list config keys with prefix filter", func() { - output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ - Operation: "list", - Prefix: "max", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Keys).To(ConsistOf("max_retries")) - }) - }) -}) - -var _ = Describe("Complex Config Values Integration", Ordered, func() { - var callTestConfig func(context.Context, testConfigInput) (*testConfigOutput, error) - - BeforeAll(func() { - // Config with arrays and objects - these should be properly serialized as JSON strings - _, callTestConfig = setupTestConfigPlugin(`{"api_key":"secret123","users":[{"username":"admin","token":"tok1"},{"username":"user2","token":"tok2"}],"settings":{"enabled":true,"count":5}}`) - }) - - Describe("Config Serialization", func() { - It("should make simple string config values accessible to plugin", func() { - output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ - Operation: "get", - Key: "api_key", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeTrue()) - Expect(output.StringVal).To(Equal("secret123")) - }) - - It("should serialize array config values as JSON strings", func() { - output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ - Operation: "get", - Key: "users", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeTrue()) - // Array values are serialized as JSON strings - parse to verify structure - var users []map[string]string - Expect(json.Unmarshal([]byte(output.StringVal), &users)).To(Succeed()) - Expect(users).To(HaveLen(2)) - Expect(users[0]).To(HaveKeyWithValue("username", "admin")) - Expect(users[0]).To(HaveKeyWithValue("token", "tok1")) - Expect(users[1]).To(HaveKeyWithValue("username", "user2")) - Expect(users[1]).To(HaveKeyWithValue("token", "tok2")) - }) - - It("should serialize object config values as JSON strings", func() { - output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ - Operation: "get", - Key: "settings", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeTrue()) - // Object values are serialized as JSON strings - parse to verify structure - var settings map[string]any - Expect(json.Unmarshal([]byte(output.StringVal), &settings)).To(Succeed()) - Expect(settings).To(HaveKeyWithValue("enabled", true)) - Expect(settings).To(HaveKeyWithValue("count", float64(5))) - }) - - It("should list all config keys including complex values", func() { - output, err := callTestConfig(GinkgoT().Context(), testConfigInput{ - Operation: "list", - Prefix: "", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Keys).To(ConsistOf("api_key", "users", "settings")) - }) - }) -}) diff --git a/plugins/host_httpclient.go b/plugins/host_httpclient.go deleted file mode 100644 index 6a9a4d2d6..000000000 --- a/plugins/host_httpclient.go +++ /dev/null @@ -1,210 +0,0 @@ -package plugins - -import ( - "bytes" - "cmp" - "context" - "fmt" - "io" - "net" - "net/http" - "net/url" - "strings" - "syscall" - "time" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/plugins/host" - "github.com/navidrome/navidrome/utils/httpclient" - "github.com/navidrome/navidrome/utils/netguard" -) - -const ( - httpClientDefaultTimeout = 10 * time.Second - httpClientMaxRedirects = 5 - httpClientMaxResponseBodyLen = 10 * 1024 * 1024 // 10 MB -) - -// contextKey is used for per-request redirect control via context. -type contextKey struct{} - -// noFollowRedirectsKey signals the CheckRedirect callback to stop following redirects. -var noFollowRedirectsKey = contextKey{} - -// httpServiceImpl implements host.HTTPService. -type httpServiceImpl struct { - pluginName string - requiredHosts []string - client *http.Client - transport *http.Transport -} - -// newHTTPService creates a new HTTPService for a plugin. -func newHTTPService(pluginName string, permission *HTTPPermission) *httpServiceImpl { - var requiredHosts []string - if permission != nil { - requiredHosts = permission.RequiredHosts - } - svc := &httpServiceImpl{ - pluginName: pluginName, - requiredHosts: requiredHosts, - } - svc.transport = http.DefaultTransport.(*http.Transport).Clone() - svc.transport.DialContext = (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - Control: svc.dialControl, - Resolver: dialResolver, - }).DialContext - // No client timeout: it is set per-request via context deadline. - svc.client = &http.Client{Transport: httpclient.NewTransport(svc.transport)} - svc.client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - if req.Context().Value(noFollowRedirectsKey) != nil { - return http.ErrUseLastResponse - } - if len(via) >= httpClientMaxRedirects { - log.Warn(req.Context(), "HTTP redirect limit exceeded", "plugin", svc.pluginName, "url", req.URL.String(), "redirectCount", len(via)) - return http.ErrUseLastResponse - } - if err := svc.validateHost(req.Context(), req.URL.Host); err != nil { - log.Warn(req.Context(), "HTTP redirect blocked", "plugin", svc.pluginName, "url", req.URL.String(), "err", err) - return err - } - return nil - } - return svc -} - -// Close releases the plugin's pooled connections when the plugin is unloaded. -func (s *httpServiceImpl) Close() error { - s.transport.CloseIdleConnections() - return nil -} - -func (s *httpServiceImpl) Send(ctx context.Context, request host.HTTPRequest) (*host.HTTPResponse, error) { - // Parse and validate URL - parsedURL, err := url.Parse(request.URL) - if err != nil { - return nil, fmt.Errorf("invalid URL: %w", err) - } - - // Validate URL scheme - if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { - return nil, fmt.Errorf("invalid URL scheme %q: must be http or https", parsedURL.Scheme) - } - - // Validate host against allowed hosts and private IP restrictions - if err := s.validateHost(ctx, parsedURL.Host); err != nil { - return nil, err - } - - // Apply per-request timeout via context deadline - timeout := cmp.Or(time.Duration(request.TimeoutMs)*time.Millisecond, httpClientDefaultTimeout) - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - // Signal CheckRedirect to not follow redirects for this request - if request.NoFollowRedirects { - ctx = context.WithValue(ctx, noFollowRedirectsKey, true) - } - - // Build request body - method := strings.ToUpper(request.Method) - var body io.Reader - if len(request.Body) > 0 { - body = bytes.NewReader(request.Body) - } - - // Create HTTP request - httpReq, err := http.NewRequestWithContext(ctx, method, request.URL, body) - if err != nil { - return nil, fmt.Errorf("creating request: %w", err) - } - for k, v := range request.Headers { - httpReq.Header.Set(k, v) - } - - // Execute request - resp, err := s.client.Do(httpReq) //nolint:gosec // URL is validated against requiredHosts - if err != nil { - return nil, err - } - defer resp.Body.Close() - - log.Trace(ctx, "HTTP request", "plugin", s.pluginName, "method", method, "url", request.URL, "status", resp.StatusCode) - - // Read response body (with size limit to prevent memory exhaustion) - respBody, err := io.ReadAll(io.LimitReader(resp.Body, httpClientMaxResponseBodyLen)) - if err != nil { - return nil, fmt.Errorf("reading response body: %w", err) - } - - // Flatten response headers (first value only) - headers := make(map[string]string, len(resp.Header)) - for k, v := range resp.Header { - if len(v) > 0 { - headers[k] = v[0] - } - } - - return &host.HTTPResponse{ - StatusCode: int32(resp.StatusCode), - Headers: headers, - Body: respBody, - }, nil -} - -// validateHost checks whether a request to the given host is permitted. -// When requiredHosts is set, it checks against the allowlist. -// When requiredHosts is empty, it blocks private/loopback IPs to prevent SSRF. -func (s *httpServiceImpl) validateHost(ctx context.Context, hostStr string) error { - hostname := extractHostname(hostStr) - - if len(s.requiredHosts) > 0 { - if !isHostInAllowlist(s.requiredHosts, hostname) { - return fmt.Errorf("host %q is not allowed", hostStr) - } - return nil - } - - // No explicit allowlist: block private/loopback IPs - if isPrivateOrLoopback(hostname) { - log.Warn(ctx, "HTTP request to private/loopback address blocked", "plugin", s.pluginName, "host", hostStr) - return fmt.Errorf("host %q is not allowed: private/loopback addresses require explicit requiredHosts in manifest", hostStr) - } - return nil -} - -func (s *httpServiceImpl) dialControl(_, address string, _ syscall.RawConn) error { - return checkPrivateDial(s.requiredHosts, address) -} - -// extractHostname returns the hostname portion of a host string, stripping -// any port number and IPv6 brackets. It handles IPv6 addresses correctly -// (e.g. "[::1]:8080" → "::1", "[::1]" → "::1"). -func extractHostname(hostStr string) string { - if h, _, err := net.SplitHostPort(hostStr); err == nil { - return h - } - // Strip IPv6 brackets when no port is present (e.g. "[::1]" → "::1") - if strings.HasPrefix(hostStr, "[") && strings.HasSuffix(hostStr, "]") { - return hostStr[1 : len(hostStr)-1] - } - return hostStr -} - -// isPrivateOrLoopback is a pre-flight check on the literal host (IP or "localhost"); it does not -// resolve names, so dialControl remains the real guard. -func isPrivateOrLoopback(hostname string) bool { - if strings.EqualFold(hostname, "localhost") { - return true - } - ip := net.ParseIP(hostname) - if ip == nil { - return false - } - return netguard.IsPrivateIP(ip) -} - -// Verify interface implementation -var _ host.HTTPService = (*httpServiceImpl)(nil) diff --git a/plugins/host_httpclient_test.go b/plugins/host_httpclient_test.go deleted file mode 100644 index 81e3192fd..000000000 --- a/plugins/host_httpclient_test.go +++ /dev/null @@ -1,648 +0,0 @@ -package plugins - -import ( - "context" - "io" - "net" - "net/http" - "net/http/httptest" - "strings" - "time" - - "github.com/navidrome/navidrome/plugins/host" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("httpServiceImpl", func() { - var ( - svc *httpServiceImpl - ts *httptest.Server - ) - - BeforeEach(func() { - stubLocalhostDNS() - }) - - AfterEach(func() { - if ts != nil { - ts.Close() - } - }) - - Context("without host restrictions (default SSRF protection)", func() { - BeforeEach(func() { - svc = newHTTPService("test-plugin", nil) - }) - - It("should block requests to loopback IPs", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - })) - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: ts.URL, - TimeoutMs: 1000, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("private/loopback")) - }) - - It("should block a symbolic hostname that resolves to loopback (SSRF)", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - })) - // The trailing dot passes the pre-flight string check; only the dial-time guard catches it. - _, port, _ := net.SplitHostPort(strings.TrimPrefix(ts.URL, "http://")) - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: "http://localhost.:" + port + "/test", - TimeoutMs: 1000, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("private/loopback")) - }) - - It("should block requests to localhost by name", func() { - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: "http://localhost:12345/test", - TimeoutMs: 1000, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("private/loopback")) - }) - - It("should block requests to private IPs (10.x)", func() { - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: "http://10.0.0.1/test", - TimeoutMs: 1000, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("private/loopback")) - }) - - It("should block requests to private IPs (192.168.x)", func() { - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: "http://192.168.1.1/test", - TimeoutMs: 1000, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("private/loopback")) - }) - - It("should block requests to private IPs (172.16.x)", func() { - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: "http://172.16.0.1/test", - TimeoutMs: 1000, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("private/loopback")) - }) - - It("should block requests to link-local IPs (169.254.x)", func() { - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: "http://169.254.169.254/latest/meta-data/", - TimeoutMs: 1000, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("private/loopback")) - }) - - It("should block requests to IPv6 loopback with port", func() { - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: "http://[::1]:8080/test", - TimeoutMs: 1000, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("private/loopback")) - }) - - It("should block requests to IPv6 loopback without port", func() { - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: "http://[::1]/test", - TimeoutMs: 1000, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("private/loopback")) - }) - - It("should allow requests to public hostnames", func() { - // This will fail at the network level (connection refused or DNS), - // but it should NOT fail with a "private/loopback" error - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: "http://203.0.113.1:1/test", // TEST-NET-3, non-routable but not private - TimeoutMs: 100, - }) - // Should get a network error, not a permission error - if err != nil { - Expect(err.Error()).ToNot(ContainSubstring("private/loopback")) - } - }) - - It("should return error for invalid URL", func() { - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: "://bad-url", - }) - Expect(err).To(HaveOccurred()) - }) - - It("should reject non-http/https URL schemes", func() { - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: "ftp://example.com/file", - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("must be http or https")) - }) - }) - - Context("with explicit requiredHosts allowing loopback", func() { - BeforeEach(func() { - svc = newHTTPService("test-plugin", &HTTPPermission{ - RequiredHosts: []string{"127.0.0.1"}, - }) - }) - - It("should handle GET requests", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Expect(r.Method).To(Equal("GET")) - w.Header().Set("X-Test", "ok") - w.WriteHeader(201) - _, _ = w.Write([]byte("hello")) - })) - resp, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: ts.URL, - Headers: map[string]string{"Accept": "text/plain"}, - TimeoutMs: 1000, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.StatusCode).To(Equal(int32(201))) - Expect(string(resp.Body)).To(Equal("hello")) - Expect(resp.Headers["X-Test"]).To(Equal("ok")) - }) - - It("should handle POST requests with body", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Expect(r.Method).To(Equal("POST")) - b, _ := io.ReadAll(r.Body) - _, _ = w.Write([]byte("got:" + string(b))) - })) - resp, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "POST", - URL: ts.URL, - Body: []byte("abc"), - TimeoutMs: 1000, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(string(resp.Body)).To(Equal("got:abc")) - }) - - It("should handle PUT requests with body", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Expect(r.Method).To(Equal("PUT")) - b, _ := io.ReadAll(r.Body) - _, _ = w.Write([]byte("put:" + string(b))) - })) - resp, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "PUT", - URL: ts.URL, - Body: []byte("xyz"), - TimeoutMs: 1000, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(string(resp.Body)).To(Equal("put:xyz")) - }) - - It("should handle DELETE requests", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Expect(r.Method).To(Equal("DELETE")) - w.WriteHeader(204) - })) - resp, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "DELETE", - URL: ts.URL, - TimeoutMs: 1000, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.StatusCode).To(Equal(int32(204))) - }) - - It("should handle DELETE requests with body", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Expect(r.Method).To(Equal("DELETE")) - b, _ := io.ReadAll(r.Body) - _, _ = w.Write([]byte("del:" + string(b))) - })) - resp, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "DELETE", - URL: ts.URL, - Body: []byte(`{"id":"123"}`), - TimeoutMs: 1000, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(string(resp.Body)).To(Equal(`del:{"id":"123"}`)) - }) - - It("should handle PATCH requests with body", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Expect(r.Method).To(Equal("PATCH")) - b, _ := io.ReadAll(r.Body) - _, _ = w.Write([]byte("patch:" + string(b))) - })) - resp, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "PATCH", - URL: ts.URL, - Body: []byte("data"), - TimeoutMs: 1000, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(string(resp.Body)).To(Equal("patch:data")) - }) - - It("should handle HEAD requests", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Expect(r.Method).To(Equal("HEAD")) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - })) - resp, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "HEAD", - URL: ts.URL, - TimeoutMs: 1000, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.StatusCode).To(Equal(int32(200))) - Expect(resp.Headers["Content-Type"]).To(Equal("application/json")) - Expect(resp.Body).To(BeEmpty()) - }) - - It("should use default timeout when TimeoutMs is 0", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - })) - resp, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: ts.URL, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.StatusCode).To(Equal(int32(200))) - }) - - It("should return error on timeout", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(50 * time.Millisecond) - })) - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: ts.URL, - TimeoutMs: 1, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("deadline exceeded")) - }) - - It("should return error on context cancellation", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - time.Sleep(50 * time.Millisecond) - })) - ctx, cancel := context.WithCancel(context.Background()) - go func() { - time.Sleep(1 * time.Millisecond) - cancel() - }() - _, err := svc.Send(ctx, host.HTTPRequest{ - Method: "GET", - URL: ts.URL, - TimeoutMs: 5000, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("context canceled")) - }) - - It("should not follow redirects when NoFollowRedirects is true", func() { - dest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("final")) - })) - defer dest.Close() - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, dest.URL, http.StatusFound) - })) - resp, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: ts.URL, - TimeoutMs: 1000, - NoFollowRedirects: true, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.StatusCode).To(Equal(int32(302))) - Expect(resp.Headers["Location"]).To(Equal(dest.URL)) - Expect(string(resp.Body)).ToNot(Equal("final")) - }) - - It("should send request headers", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(r.Header.Get("X-Custom"))) - })) - resp, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: ts.URL, - Headers: map[string]string{"X-Custom": "myvalue"}, - TimeoutMs: 1000, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(string(resp.Body)).To(Equal("myvalue")) - }) - }) - - Context("with host restrictions", func() { - BeforeEach(func() { - svc = newHTTPService("test-plugin", &HTTPPermission{ - RequiredHosts: []string{"allowed.example.com", "*.allowed.org"}, - }) - }) - - It("should block requests to non-allowed hosts", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - })) - // httptest server is on 127.0.0.1 which is not in requiredHosts - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: ts.URL, - TimeoutMs: 1000, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not allowed")) - }) - - It("should follow redirects to allowed hosts", func() { - // Create a destination server - dest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("final")) - })) - defer dest.Close() - // Create a redirect server - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, dest.URL, http.StatusFound) - })) - // Allow both servers (both on 127.0.0.1) - svc.requiredHosts = []string{"127.0.0.1"} - resp, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: ts.URL, - TimeoutMs: 1000, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.StatusCode).To(Equal(int32(200))) - Expect(string(resp.Body)).To(Equal("final")) - }) - - It("should block redirects to non-allowed hosts", func() { - // Server that redirects to a disallowed host - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "http://evil.example.com/steal", http.StatusFound) - })) - // Override requiredHosts to allow the test server - svc.requiredHosts = []string{"127.0.0.1"} - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: ts.URL, - TimeoutMs: 1000, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not allowed")) - }) - - It("should block redirects to private IPs when allowlist is set", func() { - // Server that redirects to a private IP - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "http://10.0.0.1/internal", http.StatusFound) - })) - // Allow the test server; redirect to 10.0.0.1 is blocked by allowlist - svc.requiredHosts = []string{"127.0.0.1"} - resp, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: ts.URL, - TimeoutMs: 1000, - }) - Expect(err).To(HaveOccurred()) - Expect(resp).To(BeNil()) - }) - - It("blocks a private IP reached via a hostname allowlist entry (rebinding protection)", func() { - // Allowlisting a name authorizes the external service, not whatever private - // IP it may resolve or rebind to. Only literal IP/CIDR entries do that. - svc.requiredHosts = []string{"api.example.com"} - err := svc.dialControl("tcp", "10.0.0.1:80", nil) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("private/loopback")) - }) - - It("allows a private IP that an explicit CIDR allowlist entry authorizes", func() { - svc.requiredHosts = []string{"10.0.0.0/8"} - Expect(svc.dialControl("tcp", "10.0.0.1:80", nil)).To(Succeed()) - }) - - It("allows private IPs when the allowlist is the bare '*' wildcard", func() { - svc.requiredHosts = []string{"*"} - Expect(svc.dialControl("tcp", "192.168.1.10:8000", nil)).To(Succeed()) - Expect(svc.dialControl("tcp", "127.0.0.1:8000", nil)).To(Succeed()) - }) - - It("still blocks private IPs for a subdomain wildcard entry", func() { - svc.requiredHosts = []string{"*.example.com"} - Expect(svc.dialControl("tcp", "10.0.0.1:80", nil)).To(MatchError(ContainSubstring("private/loopback"))) - }) - - It("closes idle pooled connections on Close", func() { - closed := make(chan struct{}) - ts = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) - ts.Config.ConnState = func(_ net.Conn, state http.ConnState) { - if state == http.StateClosed { - close(closed) - } - } - ts.Start() - svc.requiredHosts = []string{"127.0.0.1"} - _, err := svc.Send(context.Background(), host.HTTPRequest{Method: "GET", URL: ts.URL, TimeoutMs: 1000}) - Expect(err).ToNot(HaveOccurred()) - Expect(svc.Close()).To(Succeed()) - Eventually(closed).Should(BeClosed()) - }) - - It("should allow wildcard host patterns", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("wildcard")) - })) - // The literal IP is what authorizes the loopback dial under the private-IP guard. - svc.requiredHosts = []string{"*.0.0.1", "127.0.0.1"} - resp, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: ts.URL, - TimeoutMs: 1000, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(string(resp.Body)).To(Equal("wildcard")) - }) - - It("should reject hosts not matching wildcard patterns", func() { - svc.requiredHosts = []string{"*.example.com"} - _, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: "http://evil.other.com/test", - TimeoutMs: 1000, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not allowed")) - }) - }) - - Context("response body size limit", func() { - BeforeEach(func() { - svc = newHTTPService("test-plugin", &HTTPPermission{ - RequiredHosts: []string{"127.0.0.1"}, - }) - }) - - It("should truncate response body at the size limit", func() { - // Serve a body larger than the limit - oversizedBody := strings.Repeat("x", httpClientMaxResponseBodyLen+1024) - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(oversizedBody)) - })) - resp, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "GET", - URL: ts.URL, - TimeoutMs: 5000, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(len(resp.Body)).To(Equal(httpClientMaxResponseBodyLen)) - }) - }) - - Context("edge cases", func() { - BeforeEach(func() { - svc = newHTTPService("test-plugin", &HTTPPermission{ - RequiredHosts: []string{"127.0.0.1"}, - }) - }) - - It("should default empty method to GET", func() { - ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("method:" + r.Method)) - })) - // Empty method — Go's http.NewRequestWithContext normalizes "" to "GET" - resp, err := svc.Send(context.Background(), host.HTTPRequest{ - Method: "", - URL: ts.URL, - TimeoutMs: 1000, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(string(resp.Body)).To(Equal("method:GET")) - }) - }) -}) - -var _ = Describe("extractHostname", func() { - It("should extract hostname from host:port", func() { - Expect(extractHostname("example.com:8080")).To(Equal("example.com")) - }) - - It("should return hostname when no port", func() { - Expect(extractHostname("example.com")).To(Equal("example.com")) - }) - - It("should handle IPv6 with port", func() { - Expect(extractHostname("[::1]:8080")).To(Equal("::1")) - }) - - It("should handle IPv6 without port", func() { - Expect(extractHostname("::1")).To(Equal("::1")) - }) - - It("should strip brackets from IPv6 without port", func() { - Expect(extractHostname("[::1]")).To(Equal("::1")) - }) - - It("should handle IPv4 with port", func() { - Expect(extractHostname("127.0.0.1:9090")).To(Equal("127.0.0.1")) - }) - - It("should handle IPv4 without port", func() { - Expect(extractHostname("127.0.0.1")).To(Equal("127.0.0.1")) - }) -}) - -var _ = Describe("isPrivateOrLoopback", func() { - It("should detect IPv4 loopback", func() { - Expect(isPrivateOrLoopback("127.0.0.1")).To(BeTrue()) - Expect(isPrivateOrLoopback("127.0.0.2")).To(BeTrue()) - }) - - It("should detect IPv6 loopback", func() { - Expect(isPrivateOrLoopback("::1")).To(BeTrue()) - }) - - It("should detect localhost by name", func() { - Expect(isPrivateOrLoopback("localhost")).To(BeTrue()) - Expect(isPrivateOrLoopback("LOCALHOST")).To(BeTrue()) - }) - - It("should detect 10.x.x.x private range", func() { - Expect(isPrivateOrLoopback("10.0.0.1")).To(BeTrue()) - Expect(isPrivateOrLoopback("10.255.255.255")).To(BeTrue()) - }) - - It("should detect 172.16.x.x private range", func() { - Expect(isPrivateOrLoopback("172.16.0.1")).To(BeTrue()) - Expect(isPrivateOrLoopback("172.31.255.255")).To(BeTrue()) - }) - - It("should detect 192.168.x.x private range", func() { - Expect(isPrivateOrLoopback("192.168.0.1")).To(BeTrue()) - Expect(isPrivateOrLoopback("192.168.255.255")).To(BeTrue()) - }) - - It("should detect link-local addresses", func() { - Expect(isPrivateOrLoopback("169.254.169.254")).To(BeTrue()) - Expect(isPrivateOrLoopback("169.254.0.1")).To(BeTrue()) - }) - - It("should detect IPv6 private (fc00::/7)", func() { - Expect(isPrivateOrLoopback("fd00::1")).To(BeTrue()) - }) - - It("should detect IPv6 link-local (fe80::/10)", func() { - Expect(isPrivateOrLoopback("fe80::1")).To(BeTrue()) - }) - - It("should detect unspecified addresses, which dial the local host", func() { - Expect(isPrivateOrLoopback("0.0.0.0")).To(BeTrue()) - Expect(isPrivateOrLoopback("::")).To(BeTrue()) - }) - - It("should allow public IPs", func() { - Expect(isPrivateOrLoopback("8.8.8.8")).To(BeFalse()) - Expect(isPrivateOrLoopback("203.0.113.1")).To(BeFalse()) - Expect(isPrivateOrLoopback("2001:db8::1")).To(BeFalse()) - }) - - It("should allow non-IP hostnames (DNS names)", func() { - Expect(isPrivateOrLoopback("example.com")).To(BeFalse()) - Expect(isPrivateOrLoopback("api.example.com")).To(BeFalse()) - }) - - It("should not treat 172.32.x.x as private", func() { - Expect(isPrivateOrLoopback("172.32.0.1")).To(BeFalse()) - }) -}) diff --git a/plugins/host_kvstore.go b/plugins/host_kvstore.go deleted file mode 100644 index 2224b7485..000000000 --- a/plugins/host_kvstore.go +++ /dev/null @@ -1,378 +0,0 @@ -package plugins - -import ( - "context" - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "slices" - "strings" - "sync" - "time" - - "github.com/dustin/go-humanize" - _ "github.com/mattn/go-sqlite3" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/plugins/host" - "github.com/navidrome/navidrome/utils/slice" -) - -const ( - defaultMaxKVStoreSize = 1 * 1024 * 1024 // 1MB default - maxKeyLength = 256 // Max key length in bytes -) - -// notExpiredFilter is the SQL condition to exclude expired keys. -const notExpiredFilter = "(expires_at IS NULL OR expires_at >= datetime('now'))" - -const cleanupInterval = 1 * time.Hour - -// kvstoreServiceImpl implements the host.KVStoreService interface. -// Each plugin gets its own SQLite database for isolation. -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. -// The provided context controls the lifetime of the background cleanup goroutine. -func newKVStoreService(ctx context.Context, pluginName string, perm *KVStorePermission) (*kvstoreServiceImpl, error) { - // Parse max size from permission, default to 1MB - maxSize := int64(defaultMaxKVStoreSize) - if perm != nil && perm.MaxSize != nil && *perm.MaxSize != "" { - parsed, err := humanize.ParseBytes(*perm.MaxSize) - if err != nil { - return nil, fmt.Errorf("invalid maxSize %q: %w", *perm.MaxSize, err) - } - maxSize = int64(parsed) - } - - // Create plugin data directory - dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName) - if err := os.MkdirAll(dataDir, 0700); err != nil { - return nil, fmt.Errorf("creating plugin data directory: %w", err) - } - - // Open SQLite database - dbPath := filepath.Join(dataDir, "kvstore.db") - db, err := sql.Open("sqlite3", dbPath+"?_busy_timeout=5000&_journal_mode=WAL&_foreign_keys=off") - if err != nil { - return nil, fmt.Errorf("opening kvstore database: %w", err) - } - - db.SetMaxOpenConns(3) - db.SetMaxIdleConns(1) - - // Apply schema migrations - if err := createKVStoreSchema(db); err != nil { - db.Close() - return nil, fmt.Errorf("migrating kvstore schema: %w", err) - } - - 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, - } - svc.wg.Add(1) - go svc.cleanupLoop(cleanupCtx) - return svc, nil -} - -// createKVStoreSchema applies schema migrations to the kvstore database. -// New migrations must be appended at the end of the slice. -func createKVStoreSchema(db *sql.DB) error { - return migrateDB(db, []string{ - `CREATE TABLE IF NOT EXISTS kvstore ( - key TEXT PRIMARY KEY NOT NULL, - value BLOB NOT NULL, - size INTEGER NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP - )`, - `ALTER TABLE kvstore ADD COLUMN expires_at DATETIME DEFAULT NULL`, - `CREATE INDEX idx_kvstore_expires_at ON kvstore(expires_at)`, - }) -} - -// storageUsed returns the current total storage used by non-expired keys. -func (s *kvstoreServiceImpl) storageUsed(ctx context.Context) (int64, error) { - var used int64 - err := s.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(size), 0) FROM kvstore WHERE `+notExpiredFilter).Scan(&used) - if err != nil { - return 0, fmt.Errorf("calculating storage used: %w", err) - } - return used, nil -} - -// checkStorageLimit verifies that adding delta bytes would not exceed the storage limit. -func (s *kvstoreServiceImpl) checkStorageLimit(ctx context.Context, delta int64) error { - if delta <= 0 { - return nil - } - used, err := s.storageUsed(ctx) - if err != nil { - return err - } - newTotal := used + delta - if newTotal > s.maxSize { - return fmt.Errorf("storage limit exceeded: would use %s of %s allowed", - humanize.Bytes(uint64(newTotal)), humanize.Bytes(uint64(s.maxSize))) - } - return nil -} - -// setValue is the shared implementation for Set and SetWithTTL. -// A ttlSeconds of 0 means no expiration. -func (s *kvstoreServiceImpl) setValue(ctx context.Context, key string, value []byte, ttlSeconds int64) error { - if len(key) == 0 { - return fmt.Errorf("key cannot be empty") - } - if len(key) > maxKeyLength { - return fmt.Errorf("key exceeds maximum length of %d bytes", maxKeyLength) - } - - newValueSize := int64(len(value)) - - // Get current size of this key (if it exists and not expired) to calculate delta - var oldSize int64 - err := s.db.QueryRowContext(ctx, `SELECT COALESCE(size, 0) FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&oldSize) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("checking existing key: %w", err) - } - - if err := s.checkStorageLimit(ctx, newValueSize-oldSize); err != nil { - return err - } - - // Compute expires_at: sql.NullString{Valid:false} sends NULL (no expiration), - // otherwise we send a concrete timestamp. - var expiresAt sql.NullString - if ttlSeconds > 0 { - expiresAt = sql.NullString{String: fmt.Sprintf("+%d seconds", ttlSeconds), Valid: true} - } - - _, err = s.db.ExecContext(ctx, ` - INSERT INTO kvstore (key, value, size, created_at, updated_at, expires_at) - VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, datetime('now', ?)) - ON CONFLICT(key) DO UPDATE SET - value = excluded.value, - size = excluded.size, - updated_at = CURRENT_TIMESTAMP, - expires_at = excluded.expires_at - `, key, value, newValueSize, expiresAt) - if err != nil { - return fmt.Errorf("storing value: %w", err) - } - - log.Trace(ctx, "KVStore.Set", "plugin", s.pluginName, "key", key, "size", newValueSize, "ttlSeconds", ttlSeconds) - return nil -} - -// Set stores a byte value with the given key. -func (s *kvstoreServiceImpl) Set(ctx context.Context, key string, value []byte) error { - return s.setValue(ctx, key, value, 0) -} - -// SetWithTTL stores a byte value with the given key and a time-to-live. -func (s *kvstoreServiceImpl) SetWithTTL(ctx context.Context, key string, value []byte, ttlSeconds int64) error { - if ttlSeconds <= 0 { - return fmt.Errorf("ttlSeconds must be greater than 0") - } - return s.setValue(ctx, key, value, ttlSeconds) -} - -// Get retrieves a byte value from storage. -func (s *kvstoreServiceImpl) Get(ctx context.Context, key string) ([]byte, bool, error) { - var value []byte - err := s.db.QueryRowContext(ctx, `SELECT value FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&value) - if errors.Is(err, sql.ErrNoRows) { - return nil, false, nil - } - if err != nil { - return nil, false, fmt.Errorf("reading value: %w", err) - } - - log.Trace(ctx, "KVStore.Get", "plugin", s.pluginName, "key", key, "found", true) - return value, true, nil -} - -// Delete removes a value from storage. -func (s *kvstoreServiceImpl) Delete(ctx context.Context, key string) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key = ?`, key) - if err != nil { - return fmt.Errorf("deleting value: %w", err) - } - - log.Trace(ctx, "KVStore.Delete", "plugin", s.pluginName, "key", key) - return nil -} - -// Has checks if a key exists in storage. -func (s *kvstoreServiceImpl) Has(ctx context.Context, key string) (bool, error) { - var count int - err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM kvstore WHERE key = ? AND `+notExpiredFilter, key).Scan(&count) - if err != nil { - return false, fmt.Errorf("checking key: %w", err) - } - - return count > 0, nil -} - -// List returns all keys matching the given prefix. -func (s *kvstoreServiceImpl) List(ctx context.Context, prefix string) ([]string, error) { - var rows *sql.Rows - var err error - - if prefix == "" { - rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE `+notExpiredFilter+` ORDER BY key`) - } else { - // Escape special LIKE characters in prefix - escapedPrefix := strings.ReplaceAll(prefix, "%", "\\%") - escapedPrefix = strings.ReplaceAll(escapedPrefix, "_", "\\_") - rows, err = s.db.QueryContext(ctx, `SELECT key FROM kvstore WHERE key LIKE ? ESCAPE '\' AND `+notExpiredFilter+` ORDER BY key`, escapedPrefix+"%") - } - if err != nil { - return nil, fmt.Errorf("listing keys: %w", err) - } - defer rows.Close() - - var keys []string - for rows.Next() { - var key string - if err := rows.Scan(&key); err != nil { - return nil, fmt.Errorf("scanning key: %w", err) - } - keys = append(keys, key) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterating keys: %w", err) - } - - log.Trace(ctx, "KVStore.List", "plugin", s.pluginName, "prefix", prefix, "count", len(keys)) - return keys, nil -} - -// GetStorageUsed returns the total storage used by this plugin in bytes. -func (s *kvstoreServiceImpl) GetStorageUsed(ctx context.Context) (int64, error) { - used, err := s.storageUsed(ctx) - if err != nil { - return 0, err - } - log.Trace(ctx, "KVStore.GetStorageUsed", "plugin", s.pluginName, "bytes", used) - return used, nil -} - -// DeleteByPrefix removes all keys matching the given prefix. -func (s *kvstoreServiceImpl) DeleteByPrefix(ctx context.Context, prefix string) (int64, error) { - if prefix == "" { - return 0, fmt.Errorf("prefix cannot be empty") - } - - escapedPrefix := strings.ReplaceAll(prefix, "%", "\\%") - escapedPrefix = strings.ReplaceAll(escapedPrefix, "_", "\\_") - result, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE key LIKE ? ESCAPE '\'`, escapedPrefix+"%") - if err != nil { - return 0, fmt.Errorf("deleting keys: %w", err) - } - - count, err := result.RowsAffected() - if err != nil { - return 0, fmt.Errorf("getting deleted count: %w", err) - } - - log.Trace(ctx, "KVStore.DeleteByPrefix", "plugin", s.pluginName, "prefix", prefix, "deletedCount", count) - return count, nil -} - -// GetMany retrieves multiple values in a single call, processing keys in batches. -func (s *kvstoreServiceImpl) GetMany(ctx context.Context, keys []string) (map[string][]byte, error) { - if len(keys) == 0 { - return map[string][]byte{}, nil - } - - const batchSize = 200 - result := make(map[string][]byte) - for chunk := range slice.CollectChunks(slices.Values(keys), batchSize) { - placeholders := make([]string, len(chunk)) - args := make([]any, len(chunk)) - for i, key := range chunk { - placeholders[i] = "?" - args[i] = key - } - - query := `SELECT key, value FROM kvstore WHERE key IN (` + strings.Join(placeholders, ",") + `) AND ` + notExpiredFilter //nolint:gosec // placeholders are always "?" - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, fmt.Errorf("querying values: %w", err) - } - - for rows.Next() { - var key string - var value []byte - if err := rows.Scan(&key, &value); err != nil { - rows.Close() - return nil, fmt.Errorf("scanning value: %w", err) - } - result[key] = value - } - if err := rows.Err(); err != nil { - rows.Close() - return nil, fmt.Errorf("iterating values: %w", err) - } - rows.Close() - } - - log.Trace(ctx, "KVStore.GetMany", "plugin", s.pluginName, "requested", len(keys), "found", len(result)) - return result, nil -} - -// 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 { - select { - case <-ctx.Done(): - return - case <-ticker.C: - s.cleanupExpired(ctx) - } - } -} - -// cleanupExpired removes all expired keys from the database to reclaim disk space. -func (s *kvstoreServiceImpl) cleanupExpired(ctx context.Context) { - result, err := s.db.ExecContext(ctx, `DELETE FROM kvstore WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`) - if err != nil { - log.Error(ctx, "KVStore cleanup: failed to delete expired keys", "plugin", s.pluginName, err) - return - } - if count, err := result.RowsAffected(); err == nil && count > 0 { - log.Debug("KVStore cleanup completed", "plugin", s.pluginName, "deletedKeys", count) - } -} - -// Close stops the cleanup goroutine and closes the SQLite database connection. -func (s *kvstoreServiceImpl) Close() error { - log.Debug("Closing plugin kvstore", "plugin", s.pluginName) - s.cancel() - s.wg.Wait() - return s.db.Close() -} - -// Compile-time verification -var _ host.KVStoreService = (*kvstoreServiceImpl)(nil) diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go deleted file mode 100644 index 9af3fcbcd..000000000 --- a/plugins/host_kvstore_test.go +++ /dev/null @@ -1,1038 +0,0 @@ -package plugins - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "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("KVStoreService", func() { - var tmpDir string - var service *kvstoreServiceImpl - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - var err error - tmpDir, err = os.MkdirTemp("", "kvstore-test-*") - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir(tmpDir) - - // Create service with 1KB limit for testing - service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")}) - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - if service != nil { - service.Close() - } - os.RemoveAll(tmpDir) - }) - - Describe("Basic Operations", func() { - It("sets and gets a value", func() { - err := service.Set(ctx, "key1", []byte("value1")) - Expect(err).ToNot(HaveOccurred()) - - value, exists, err := service.Get(ctx, "key1") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - Expect(value).To(Equal([]byte("value1"))) - }) - - It("returns not exists for missing key", func() { - value, exists, err := service.Get(ctx, "missing_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - Expect(value).To(BeNil()) - }) - - It("overwrites existing key", func() { - err := service.Set(ctx, "key1", []byte("value1")) - Expect(err).ToNot(HaveOccurred()) - - err = service.Set(ctx, "key1", []byte("value2")) - Expect(err).ToNot(HaveOccurred()) - - value, exists, err := service.Get(ctx, "key1") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - Expect(value).To(Equal([]byte("value2"))) - }) - - It("handles binary data", func() { - binaryData := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD} - err := service.Set(ctx, "binary", binaryData) - Expect(err).ToNot(HaveOccurred()) - - value, exists, err := service.Get(ctx, "binary") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - Expect(value).To(Equal(binaryData)) - }) - }) - - Describe("Delete Operation", func() { - It("deletes a value", func() { - err := service.Set(ctx, "delete_me", []byte("value")) - Expect(err).ToNot(HaveOccurred()) - - err = service.Delete(ctx, "delete_me") - Expect(err).ToNot(HaveOccurred()) - - _, exists, err := service.Get(ctx, "delete_me") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - }) - - It("does not error when deleting non-existing key", func() { - err := service.Delete(ctx, "never_existed") - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("Has Operation", func() { - It("returns true for existing key", func() { - err := service.Set(ctx, "exists_key", []byte("value")) - Expect(err).ToNot(HaveOccurred()) - - exists, err := service.Has(ctx, "exists_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - }) - - It("returns false for non-existing key", func() { - exists, err := service.Has(ctx, "non_existing_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - }) - }) - - Describe("List Operation", func() { - BeforeEach(func() { - Expect(service.Set(ctx, "user:1:name", []byte("Alice"))).To(Succeed()) - Expect(service.Set(ctx, "user:1:email", []byte("alice@test.com"))).To(Succeed()) - Expect(service.Set(ctx, "user:2:name", []byte("Bob"))).To(Succeed()) - Expect(service.Set(ctx, "config:theme", []byte("dark"))).To(Succeed()) - }) - - It("lists all keys with empty prefix", func() { - keys, err := service.List(ctx, "") - Expect(err).ToNot(HaveOccurred()) - Expect(keys).To(HaveLen(4)) - Expect(keys).To(ContainElements("config:theme", "user:1:email", "user:1:name", "user:2:name")) - }) - - It("lists keys matching prefix", func() { - keys, err := service.List(ctx, "user:1:") - Expect(err).ToNot(HaveOccurred()) - Expect(keys).To(HaveLen(2)) - Expect(keys).To(ContainElements("user:1:name", "user:1:email")) - }) - - It("lists keys matching partial prefix", func() { - keys, err := service.List(ctx, "user:") - Expect(err).ToNot(HaveOccurred()) - Expect(keys).To(HaveLen(3)) - }) - - It("returns empty list for non-matching prefix", func() { - keys, err := service.List(ctx, "notfound:") - Expect(err).ToNot(HaveOccurred()) - Expect(keys).To(BeEmpty()) - }) - - It("handles special LIKE characters in prefix", func() { - // Add keys with special characters - Expect(service.Set(ctx, "test%key", []byte("value1"))).To(Succeed()) - Expect(service.Set(ctx, "test_key", []byte("value2"))).To(Succeed()) - Expect(service.Set(ctx, "testXkey", []byte("value3"))).To(Succeed()) - - // Search for "test%" - keys, err := service.List(ctx, "test%") - Expect(err).ToNot(HaveOccurred()) - Expect(keys).To(HaveLen(1)) - Expect(keys).To(ContainElement("test%key")) - }) - }) - - Describe("Storage Usage", func() { - It("reports correct storage used", func() { - used, err := service.GetStorageUsed(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(used).To(Equal(int64(0))) - - err = service.Set(ctx, "key1", []byte("12345")) - Expect(err).ToNot(HaveOccurred()) - - used, err = service.GetStorageUsed(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(used).To(Equal(int64(5))) - - err = service.Set(ctx, "key2", []byte("67890")) - Expect(err).ToNot(HaveOccurred()) - - used, err = service.GetStorageUsed(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(used).To(Equal(int64(10))) - }) - - It("updates storage when value is overwritten", func() { - err := service.Set(ctx, "key1", []byte("12345")) - Expect(err).ToNot(HaveOccurred()) - - used, _ := service.GetStorageUsed(ctx) - Expect(used).To(Equal(int64(5))) - - // Overwrite with smaller value - err = service.Set(ctx, "key1", []byte("ab")) - Expect(err).ToNot(HaveOccurred()) - - used, _ = service.GetStorageUsed(ctx) - Expect(used).To(Equal(int64(2))) - }) - - It("decreases storage when key is deleted", func() { - Expect(service.Set(ctx, "key1", []byte("12345"))).To(Succeed()) - Expect(service.Set(ctx, "key2", []byte("67890"))).To(Succeed()) - - used, err := service.GetStorageUsed(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(used).To(Equal(int64(10))) - - Expect(service.Delete(ctx, "key1")).To(Succeed()) - - used, err = service.GetStorageUsed(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(used).To(Equal(int64(5))) - }) - - It("updates storage when value is overwritten with larger value", func() { - err := service.Set(ctx, "key1", []byte("ab")) - Expect(err).ToNot(HaveOccurred()) - - used, _ := service.GetStorageUsed(ctx) - Expect(used).To(Equal(int64(2))) - - // Overwrite with larger value - err = service.Set(ctx, "key1", []byte("12345")) - Expect(err).ToNot(HaveOccurred()) - - used, _ = service.GetStorageUsed(ctx) - Expect(used).To(Equal(int64(5))) - }) - - It("restores correct size after service restart", func() { - // Add some data - Expect(service.Set(ctx, "key1", []byte("12345"))).To(Succeed()) - Expect(service.Set(ctx, "key2", []byte("67890"))).To(Succeed()) - - used, _ := service.GetStorageUsed(ctx) - Expect(used).To(Equal(int64(10))) - - // Close and reopen the service (simulating restart) - Expect(service.Close()).To(Succeed()) - - service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")}) - Expect(err).ToNot(HaveOccurred()) - defer service2.Close() - - // Size should be restored from database - used, err = service2.GetStorageUsed(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(used).To(Equal(int64(10))) - }) - }) - - Describe("Size Limits", func() { - It("rejects value when storage limit would be exceeded", func() { - // Service has 1KB limit - bigValue := make([]byte, 2048) - err := service.Set(ctx, "big", bigValue) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("storage limit exceeded")) - }) - - It("allows updating existing key even if total would exceed limit", func() { - // Fill up most of the storage - almostFull := make([]byte, 900) - err := service.Set(ctx, "big", almostFull) - Expect(err).ToNot(HaveOccurred()) - - // Overwrite with same size should work - err = service.Set(ctx, "big", almostFull) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("Key Validation", func() { - It("rejects empty key", func() { - err := service.Set(ctx, "", []byte("value")) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("key cannot be empty")) - }) - - It("rejects key exceeding max length", func() { - longKey := strings.Repeat("a", 300) - err := service.Set(ctx, longKey, []byte("value")) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("key exceeds maximum length")) - }) - }) - - Describe("Plugin Isolation", func() { - It("isolates data between plugins", func() { - service2, err := newKVStoreService(ctx, "other_plugin", &KVStorePermission{}) - Expect(err).ToNot(HaveOccurred()) - defer service2.Close() - - // Set same key in both plugins - err = service.Set(ctx, "shared", []byte("value1")) - Expect(err).ToNot(HaveOccurred()) - err = service2.Set(ctx, "shared", []byte("value2")) - Expect(err).ToNot(HaveOccurred()) - - // Each plugin should get their own value - val1, _, _ := service.Get(ctx, "shared") - Expect(val1).To(Equal([]byte("value1"))) - - val2, _, _ := service2.Get(ctx, "shared") - Expect(val2).To(Equal([]byte("value2"))) - }) - - It("creates separate database files per plugin", func() { - service2, err := newKVStoreService(ctx, "other_plugin", &KVStorePermission{}) - Expect(err).ToNot(HaveOccurred()) - defer service2.Close() - - // Check that separate directories exist - _, err = os.Stat(filepath.Join(tmpDir, "plugins", "test_plugin", "kvstore.db")) - Expect(err).ToNot(HaveOccurred()) - _, err = os.Stat(filepath.Join(tmpDir, "plugins", "other_plugin", "kvstore.db")) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("Close", func() { - It("closes database connection", func() { - err := service.Close() - Expect(err).ToNot(HaveOccurred()) - - // After close, operations should fail - _, _, err = service.Get(ctx, "any") - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("TTL Expiration", func() { - It("Get returns not-exists for expired keys", func() { - _, err := service.db.Exec(` - INSERT INTO kvstore (key, value, size, expires_at) - VALUES ('expired_key', 'old', 3, datetime('now', '-1 seconds')) - `) - Expect(err).ToNot(HaveOccurred()) - value, exists, err := service.Get(ctx, "expired_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - Expect(value).To(BeNil()) - }) - It("Has returns false for expired keys", func() { - _, err := service.db.Exec(` - INSERT INTO kvstore (key, value, size, expires_at) - VALUES ('expired_has', 'old', 3, datetime('now', '-1 seconds')) - `) - Expect(err).ToNot(HaveOccurred()) - exists, err := service.Has(ctx, "expired_has") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - }) - It("List excludes expired keys", func() { - Expect(service.Set(ctx, "live:1", []byte("alive"))).To(Succeed()) - _, err := service.db.Exec(` - INSERT INTO kvstore (key, value, size, expires_at) - VALUES ('live:expired', 'dead', 4, datetime('now', '-1 seconds')) - `) - Expect(err).ToNot(HaveOccurred()) - keys, err := service.List(ctx, "live:") - Expect(err).ToNot(HaveOccurred()) - Expect(keys).To(HaveLen(1)) - Expect(keys).To(ContainElement("live:1")) - }) - It("Get returns value for non-expired keys with TTL", func() { - _, err := service.db.Exec(` - INSERT INTO kvstore (key, value, size, expires_at) - VALUES ('future_key', 'still alive', 11, datetime('now', '+3600 seconds')) - `) - Expect(err).ToNot(HaveOccurred()) - value, exists, err := service.Get(ctx, "future_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - Expect(value).To(Equal([]byte("still alive"))) - }) - It("Set clears expires_at from a key previously set with TTL", func() { - // Insert a key with a TTL that has already expired - _, err := service.db.Exec(` - INSERT INTO kvstore (key, value, size, expires_at) - VALUES ('ttl_then_set', 'temp', 4, datetime('now', '-1 seconds')) - `) - Expect(err).ToNot(HaveOccurred()) - - // Overwrite with Set (no TTL) — should become permanent - err = service.Set(ctx, "ttl_then_set", []byte("permanent")) - Expect(err).ToNot(HaveOccurred()) - - // Should exist because Set cleared expires_at - value, exists, err := service.Get(ctx, "ttl_then_set") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - Expect(value).To(Equal([]byte("permanent"))) - - // Verify expires_at is actually NULL - var expiresAt *string - Expect(service.db.QueryRow(`SELECT expires_at FROM kvstore WHERE key = 'ttl_then_set'`).Scan(&expiresAt)).To(Succeed()) - Expect(expiresAt).To(BeNil()) - }) - It("expired keys are not counted in storage used", func() { - _, err := service.db.Exec(` - INSERT INTO kvstore (key, value, size, expires_at) - VALUES ('expired_key', '12345', 5, datetime('now', '-1 seconds')) - `) - Expect(err).ToNot(HaveOccurred()) - - // Expired keys should not be counted - used, err := service.GetStorageUsed(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(used).To(Equal(int64(0))) - }) - It("cleanup removes expired rows from disk", func() { - _, err := service.db.Exec(` - INSERT INTO kvstore (key, value, size, expires_at) - VALUES ('cleanup_me', '12345', 5, datetime('now', '-1 seconds')) - `) - Expect(err).ToNot(HaveOccurred()) - - // Row exists in DB but is logically expired - var count int - Expect(service.db.QueryRow(`SELECT COUNT(*) FROM kvstore`).Scan(&count)).To(Succeed()) - Expect(count).To(Equal(1)) - - service.cleanupExpired(ctx) - - // Row should be physically deleted - Expect(service.db.QueryRow(`SELECT COUNT(*) FROM kvstore`).Scan(&count)).To(Succeed()) - Expect(count).To(Equal(0)) - }) - }) - - 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() - - svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: new("1KB")}) - 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) - Expect(err).ToNot(HaveOccurred()) - - value, exists, err := service.Get(ctx, "ttl_key") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - Expect(value).To(Equal([]byte("ttl_value"))) - }) - - It("value is not retrievable after expiry", func() { - // Insert a key with an already-expired TTL - _, err := service.db.Exec(` - INSERT INTO kvstore (key, value, size, expires_at) - VALUES ('short_ttl', 'gone_soon', 9, datetime('now', '-1 seconds')) - `) - Expect(err).ToNot(HaveOccurred()) - - _, exists, err := service.Get(ctx, "short_ttl") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeFalse()) - }) - - It("rejects ttlSeconds <= 0", func() { - err := service.SetWithTTL(ctx, "bad_ttl", []byte("value"), 0) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("ttlSeconds must be greater than 0")) - - err = service.SetWithTTL(ctx, "bad_ttl", []byte("value"), -5) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("ttlSeconds must be greater than 0")) - }) - - It("validates key same as Set", func() { - err := service.SetWithTTL(ctx, "", []byte("value"), 60) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("key cannot be empty")) - }) - - It("enforces size limits same as Set", func() { - bigValue := make([]byte, 2048) - err := service.SetWithTTL(ctx, "big_ttl", bigValue, 60) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("storage limit exceeded")) - }) - - It("overwrites existing key and updates TTL", func() { - // Insert a key with an already-expired TTL - _, err := service.db.Exec(` - INSERT INTO kvstore (key, value, size, expires_at) - VALUES ('overwrite_ttl', 'first', 5, datetime('now', '-1 seconds')) - `) - Expect(err).ToNot(HaveOccurred()) - - // Overwrite with a long TTL — should be retrievable - err = service.SetWithTTL(ctx, "overwrite_ttl", []byte("second"), 3600) - Expect(err).ToNot(HaveOccurred()) - - value, exists, err := service.Get(ctx, "overwrite_ttl") - Expect(err).ToNot(HaveOccurred()) - Expect(exists).To(BeTrue()) - Expect(value).To(Equal([]byte("second"))) - }) - - It("tracks storage correctly", func() { - err := service.SetWithTTL(ctx, "sized_ttl", []byte("12345"), 3600) - Expect(err).ToNot(HaveOccurred()) - - used, err := service.GetStorageUsed(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(used).To(Equal(int64(5))) - }) - }) - - Describe("DeleteByPrefix", func() { - BeforeEach(func() { - Expect(service.Set(ctx, "cache:user:1", []byte("Alice"))).To(Succeed()) - Expect(service.Set(ctx, "cache:user:2", []byte("Bob"))).To(Succeed()) - Expect(service.Set(ctx, "cache:item:1", []byte("Widget"))).To(Succeed()) - Expect(service.Set(ctx, "data:important", []byte("keep"))).To(Succeed()) - }) - - It("deletes all keys with the given prefix", func() { - deleted, err := service.DeleteByPrefix(ctx, "cache:user:") - Expect(err).ToNot(HaveOccurred()) - Expect(deleted).To(Equal(int64(2))) - - keys, err := service.List(ctx, "") - Expect(err).ToNot(HaveOccurred()) - Expect(keys).To(HaveLen(2)) - Expect(keys).To(ContainElements("cache:item:1", "data:important")) - }) - - It("rejects empty prefix", func() { - _, err := service.DeleteByPrefix(ctx, "") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("prefix cannot be empty")) - }) - - It("returns 0 when no keys match", func() { - deleted, err := service.DeleteByPrefix(ctx, "nonexistent:") - Expect(err).ToNot(HaveOccurred()) - Expect(deleted).To(Equal(int64(0))) - }) - - It("updates storage size correctly", func() { - usedBefore, _ := service.GetStorageUsed(ctx) - Expect(usedBefore).To(BeNumerically(">", 0)) - - _, err := service.DeleteByPrefix(ctx, "cache:") - Expect(err).ToNot(HaveOccurred()) - - usedAfter, _ := service.GetStorageUsed(ctx) - Expect(usedAfter).To(Equal(int64(4))) - }) - - It("handles special LIKE characters in prefix", func() { - Expect(service.Set(ctx, "test%special", []byte("v1"))).To(Succeed()) - Expect(service.Set(ctx, "test_special", []byte("v2"))).To(Succeed()) - Expect(service.Set(ctx, "testXspecial", []byte("v3"))).To(Succeed()) - - deleted, err := service.DeleteByPrefix(ctx, "test%") - Expect(err).ToNot(HaveOccurred()) - Expect(deleted).To(Equal(int64(1))) - - exists, _ := service.Has(ctx, "test_special") - Expect(exists).To(BeTrue()) - exists, _ = service.Has(ctx, "testXspecial") - Expect(exists).To(BeTrue()) - }) - - It("also deletes expired keys matching prefix", func() { - _, err := service.db.Exec(` - INSERT INTO kvstore (key, value, size, expires_at) - VALUES ('cache:expired', 'old', 3, datetime('now', '-1 seconds')) - `) - Expect(err).ToNot(HaveOccurred()) - - deleted, err := service.DeleteByPrefix(ctx, "cache:") - Expect(err).ToNot(HaveOccurred()) - Expect(deleted).To(Equal(int64(4))) - }) - }) - - Describe("GetMany", func() { - BeforeEach(func() { - Expect(service.Set(ctx, "key1", []byte("value1"))).To(Succeed()) - Expect(service.Set(ctx, "key2", []byte("value2"))).To(Succeed()) - Expect(service.Set(ctx, "key3", []byte("value3"))).To(Succeed()) - }) - - It("retrieves multiple values at once", func() { - values, err := service.GetMany(ctx, []string{"key1", "key2", "key3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(values).To(HaveLen(3)) - Expect(values["key1"]).To(Equal([]byte("value1"))) - Expect(values["key2"]).To(Equal([]byte("value2"))) - Expect(values["key3"]).To(Equal([]byte("value3"))) - }) - - It("omits missing keys from result", func() { - values, err := service.GetMany(ctx, []string{"key1", "missing", "key3"}) - Expect(err).ToNot(HaveOccurred()) - Expect(values).To(HaveLen(2)) - Expect(values["key1"]).To(Equal([]byte("value1"))) - Expect(values["key3"]).To(Equal([]byte("value3"))) - _, hasMissing := values["missing"] - Expect(hasMissing).To(BeFalse()) - }) - - It("returns empty map for empty keys slice", func() { - values, err := service.GetMany(ctx, []string{}) - Expect(err).ToNot(HaveOccurred()) - Expect(values).To(BeEmpty()) - }) - - It("returns empty map for nil keys slice", func() { - values, err := service.GetMany(ctx, nil) - Expect(err).ToNot(HaveOccurred()) - Expect(values).To(BeEmpty()) - }) - - It("excludes expired keys", func() { - _, err := service.db.Exec(` - INSERT INTO kvstore (key, value, size, expires_at) - VALUES ('expired_many', 'old', 3, datetime('now', '-1 seconds')) - `) - Expect(err).ToNot(HaveOccurred()) - - values, err := service.GetMany(ctx, []string{"key1", "expired_many"}) - Expect(err).ToNot(HaveOccurred()) - Expect(values).To(HaveLen(1)) - Expect(values["key1"]).To(Equal([]byte("value1"))) - }) - - It("handles all keys missing", func() { - values, err := service.GetMany(ctx, []string{"nope1", "nope2"}) - Expect(err).ToNot(HaveOccurred()) - Expect(values).To(BeEmpty()) - }) - }) -}) - -var _ = Describe("KVStoreService Integration", Ordered, func() { - var ( - manager *Manager - tmpDir string - ) - - BeforeAll(func() { - var err error - tmpDir, err = os.MkdirTemp("", "kvstore-integration-test-*") - Expect(err).ToNot(HaveOccurred()) - - // Copy the test-kvstore plugin - srcPath := filepath.Join(testdataDir, "test-kvstore"+PackageExtension) - destPath := filepath.Join(tmpDir, "test-kvstore"+PackageExtension) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Compute SHA256 for the plugin - hash := sha256.Sum256(data) - hashHex := hex.EncodeToString(hash[:]) - - // Setup config - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - conf.Server.DataFolder = conf.NewDir(tmpDir) - - // Setup mock DataStore with pre-enabled plugin - mockPluginRepo := tests.CreateMockPluginRepo() - mockPluginRepo.Permitted = true - mockPluginRepo.SetData(model.Plugins{{ - ID: "test-kvstore", - Path: destPath, - SHA256: hashHex, - Enabled: true, - }}) - dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo} - - // Create and start manager - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - subsonicRouter: http.NotFoundHandler(), - } - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) - }) - - Describe("Plugin Loading", func() { - It("should load plugin with kvstore permission", func() { - manager.mu.RLock() - p, ok := manager.plugins["test-kvstore"] - manager.mu.RUnlock() - Expect(ok).To(BeTrue()) - Expect(p.manifest.Permissions).ToNot(BeNil()) - Expect(p.manifest.Permissions.Kvstore).ToNot(BeNil()) - Expect(*p.manifest.Permissions.Kvstore.MaxSize).To(Equal("10KB")) - }) - }) - - Describe("KVStore Operations via Plugin", func() { - type testKVStoreInput struct { - Operation string `json:"operation"` - Key string `json:"key"` - Value []byte `json:"value,omitempty"` - Prefix string `json:"prefix,omitempty"` - TTLSeconds int64 `json:"ttl_seconds,omitempty"` - Keys []string `json:"keys,omitempty"` - } - type testKVStoreOutput struct { - Value []byte `json:"value,omitempty"` - Values map[string][]byte `json:"values,omitempty"` - Exists bool `json:"exists,omitempty"` - Keys []string `json:"keys,omitempty"` - StorageUsed int64 `json:"storage_used,omitempty"` - DeletedCount int64 `json:"deleted_count,omitempty"` - Error *string `json:"error,omitempty"` - } - - callTestKVStore := func(ctx context.Context, input testKVStoreInput) (*testKVStoreOutput, error) { - manager.mu.RLock() - p := manager.plugins["test-kvstore"] - manager.mu.RUnlock() - - instance, err := p.instance(ctx) - if err != nil { - return nil, err - } - defer instance.Close(ctx) - - inputBytes, _ := json.Marshal(input) - _, outputBytes, err := instance.Call("nd_test_kvstore", inputBytes) - if err != nil { - return nil, err - } - - var output testKVStoreOutput - if err := json.Unmarshal(outputBytes, &output); err != nil { - return nil, err - } - if output.Error != nil { - return nil, errors.New(*output.Error) - } - return &output, nil - } - - It("should set and get value", func() { - ctx := GinkgoT().Context() - - // Set value - _, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "set", - Key: "test_key", - Value: []byte("hello kvstore"), - }) - Expect(err).ToNot(HaveOccurred()) - - // Get value - output, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "get", - Key: "test_key", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeTrue()) - Expect(output.Value).To(Equal([]byte("hello kvstore"))) - }) - - It("should check key existence with has", func() { - ctx := GinkgoT().Context() - - // Check existing key - output, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "has", - Key: "test_key", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeTrue()) - - // Check non-existing key - output, err = callTestKVStore(ctx, testKVStoreInput{ - Operation: "has", - Key: "non_existing", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeFalse()) - }) - - It("should delete value", func() { - ctx := GinkgoT().Context() - - // Set another key - _, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "set", - Key: "to_delete", - Value: []byte("delete me"), - }) - Expect(err).ToNot(HaveOccurred()) - - // Delete it - _, err = callTestKVStore(ctx, testKVStoreInput{ - Operation: "delete", - Key: "to_delete", - }) - Expect(err).ToNot(HaveOccurred()) - - // Verify it's gone - output, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "has", - Key: "to_delete", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeFalse()) - }) - - It("should list keys with prefix", func() { - ctx := GinkgoT().Context() - - // Set some keys - for _, key := range []string{"prefix:1", "prefix:2", "other:1"} { - _, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "set", - Key: key, - Value: []byte("value"), - }) - Expect(err).ToNot(HaveOccurred()) - } - - // List with prefix - output, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "list", - Prefix: "prefix:", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Keys).To(HaveLen(2)) - Expect(output.Keys).To(ContainElements("prefix:1", "prefix:2")) - }) - - It("should report storage used", func() { - ctx := GinkgoT().Context() - - output, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "get_storage_used", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.StorageUsed).To(BeNumerically(">", 0)) - }) - - It("should enforce size limits", func() { - ctx := GinkgoT().Context() - - // Plugin has 10KB limit, try to exceed it - bigValue := make([]byte, 15*1024) - _, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "set", - Key: "too_big", - Value: bigValue, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("storage limit exceeded")) - }) - - It("should handle binary data with null bytes through WASM", func() { - ctx := GinkgoT().Context() - - // Binary data with null bytes, high bytes, and other edge cases - binaryData := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0x00, 0x80, 0x7F} - - // Set binary value - _, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "set", - Key: "binary_test", - Value: binaryData, - }) - Expect(err).ToNot(HaveOccurred()) - - // Get binary value and verify exact match - output, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "get", - Key: "binary_test", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeTrue()) - Expect(output.Value).To(Equal(binaryData)) - }) - - It("should set value with TTL and expire it", func() { - ctx := GinkgoT().Context() - - // Set value with 1 second TTL - _, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "set_with_ttl", - Key: "ttl_key", - Value: []byte("temporary"), - TTLSeconds: 1, - }) - Expect(err).ToNot(HaveOccurred()) - - // Immediately should exist - output, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "get", - Key: "ttl_key", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Exists).To(BeTrue()) - Expect(output.Value).To(Equal([]byte("temporary"))) - - // Poll until the key expires (1s TTL) - Eventually(func(g Gomega) { - output, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "get", - Key: "ttl_key", - }) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(output.Exists).To(BeFalse()) - }).WithTimeout(3 * time.Second).WithPolling(200 * time.Millisecond).Should(Succeed()) - }) - - It("should delete keys by prefix", func() { - ctx := GinkgoT().Context() - - // Set multiple keys with shared prefix - for _, key := range []string{"del_prefix:a", "del_prefix:b", "keep:c"} { - _, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "set", - Key: key, - Value: []byte("value"), - }) - Expect(err).ToNot(HaveOccurred()) - } - - // Delete by prefix - output, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "delete_by_prefix", - Prefix: "del_prefix:", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.DeletedCount).To(Equal(int64(2))) - - // Verify remaining key - getOutput, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "has", - Key: "keep:c", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(getOutput.Exists).To(BeTrue()) - - // Verify deleted keys are gone - getOutput, err = callTestKVStore(ctx, testKVStoreInput{ - Operation: "has", - Key: "del_prefix:a", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(getOutput.Exists).To(BeFalse()) - }) - - It("should get many values at once", func() { - ctx := GinkgoT().Context() - - // Set multiple keys - for _, kv := range []struct{ k, v string }{ - {"many:1", "val1"}, - {"many:2", "val2"}, - {"many:3", "val3"}, - } { - _, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "set", - Key: kv.k, - Value: []byte(kv.v), - }) - Expect(err).ToNot(HaveOccurred()) - } - - // Get many, including a missing key - output, err := callTestKVStore(ctx, testKVStoreInput{ - Operation: "get_many", - Keys: []string{"many:1", "many:3", "many:missing"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Values).To(HaveLen(2)) - Expect(output.Values["many:1"]).To(Equal([]byte("val1"))) - Expect(output.Values["many:3"]).To(Equal([]byte("val3"))) - _, hasMissing := output.Values["many:missing"] - Expect(hasMissing).To(BeFalse()) - }) - }) - - Describe("Database Isolation", func() { - It("should create separate database file for plugin", func() { - dbPath := filepath.Join(tmpDir, "plugins", "test-kvstore", "kvstore.db") - _, err := os.Stat(dbPath) - Expect(err).ToNot(HaveOccurred()) - }) - }) -}) diff --git a/plugins/host_library.go b/plugins/host_library.go deleted file mode 100644 index 25c14a813..000000000 --- a/plugins/host_library.go +++ /dev/null @@ -1,99 +0,0 @@ -package plugins - -import ( - "context" - "fmt" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/plugins/host" -) - -type libraryServiceImpl struct { - ds model.DataStore - hasFilesystemPerm bool - allowedLibraryIDs []int - allLibraries bool - libraryIDMap map[int]struct{} -} - -func newLibraryService(ds model.DataStore, perm *LibraryPermission, allowedLibraryIDs []int, allLibraries bool) host.LibraryService { - hasFS := perm != nil && perm.Filesystem - libraryIDMap := make(map[int]struct{}) - for _, id := range allowedLibraryIDs { - libraryIDMap[id] = struct{}{} - } - return &libraryServiceImpl{ - ds: ds, - hasFilesystemPerm: hasFS, - allowedLibraryIDs: allowedLibraryIDs, - allLibraries: allLibraries, - libraryIDMap: libraryIDMap, - } -} - -func (s *libraryServiceImpl) GetLibrary(ctx context.Context, id int32) (*host.Library, error) { - // Check if the library is accessible - if !s.isLibraryAccessible(int(id)) { - return nil, fmt.Errorf("library not accessible: library ID %d is not in the allowed list", id) - } - - lib, err := s.ds.Library().Get(ctx, int(id)) - if err != nil { - return nil, fmt.Errorf("library not found: %w", err) - } - - return s.convertLibrary(lib), nil -} - -// isLibraryAccessible checks if a library ID is accessible to this plugin. -func (s *libraryServiceImpl) isLibraryAccessible(id int) bool { - if s.allLibraries { - return true - } - _, ok := s.libraryIDMap[id] - return ok -} - -func (s *libraryServiceImpl) GetAllLibraries(ctx context.Context) ([]host.Library, error) { - libs, err := s.ds.Library().GetAll(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get libraries: %w", err) - } - - // Filter libraries based on allowed list - var result []host.Library - for _, lib := range libs { - if s.isLibraryAccessible(lib.ID) { - result = append(result, *s.convertLibrary(&lib)) - } - } - - return result, nil -} - -func (s *libraryServiceImpl) convertLibrary(lib *model.Library) *host.Library { - hostLib := &host.Library{ - ID: int32(lib.ID), - Name: lib.Name, - LastScanAt: lib.LastScanAt.Unix(), - TotalSongs: int32(lib.TotalSongs), - TotalAlbums: int32(lib.TotalAlbums), - TotalArtists: int32(lib.TotalArtists), - TotalSize: lib.TotalSize, - TotalDuration: lib.TotalDuration, - } - - // Only include path and mount point if filesystem permission is granted - if s.hasFilesystemPerm { - hostLib.Path = lib.Path - hostLib.MountPoint = toPluginMountPoint(int32(lib.ID)) - } - - return hostLib -} - -func toPluginMountPoint(libID int32) string { - return fmt.Sprintf("/libraries/%d", libID) -} - -var _ host.LibraryService = (*libraryServiceImpl)(nil) diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go deleted file mode 100644 index edd4b546f..000000000 --- a/plugins/host_library_test.go +++ /dev/null @@ -1,565 +0,0 @@ -package plugins - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "net/http" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("LibraryService", Ordered, func() { - var ( - ctx context.Context - ds model.DataStore - service *libraryServiceImpl - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = context.Background() - ds = &tests.MockDataStore{} - }) - - Describe("GetLibrary", func() { - It("should return library metadata without filesystem permission", func() { - service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl) - - lib := &model.Library{ - ID: 1, - Name: "Test Library", - Path: "/music/test", - TotalSongs: 100, - TotalAlbums: 10, - TotalArtists: 5, - TotalSize: 1024000, - TotalDuration: 3600.5, - } - lib.LastScanAt = lib.LastScanAt.Add(0) // Ensure time is set - - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) - mockLibRepo.SetData(model.Libraries{*lib}) - - result, err := service.GetLibrary(ctx, 1) - Expect(err).ToNot(HaveOccurred()) - Expect(result.ID).To(Equal(int32(1))) - Expect(result.Name).To(Equal("Test Library")) - Expect(result.TotalSongs).To(Equal(int32(100))) - Expect(result.TotalAlbums).To(Equal(int32(10))) - Expect(result.TotalArtists).To(Equal(int32(5))) - Expect(result.TotalSize).To(Equal(int64(1024000))) - Expect(result.TotalDuration).To(Equal(3600.5)) - Expect(result.Path).To(BeEmpty(), "Path should not be included without filesystem permission") - Expect(result.MountPoint).To(BeEmpty(), "MountPoint should not be included without filesystem permission") - }) - - It("should return library metadata with filesystem permission", func() { - service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl) - - lib := &model.Library{ - ID: 2, - Name: "FS Library", - Path: "/music/fs", - TotalSongs: 50, - TotalAlbums: 5, - TotalArtists: 3, - TotalSize: 512000, - TotalDuration: 1800.0, - } - - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) - mockLibRepo.SetData(model.Libraries{*lib}) - - result, err := service.GetLibrary(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(result.ID).To(Equal(int32(2))) - Expect(result.Name).To(Equal("FS Library")) - Expect(result.Path).To(Equal("/music/fs"), "Path should be included with filesystem permission") - Expect(result.MountPoint).To(Equal("/libraries/2"), "MountPoint should be included with filesystem permission") - }) - - It("should return error for non-existent library", func() { - service = newLibraryService(ds, &LibraryPermission{Reason: new("test")}, nil, true).(*libraryServiceImpl) - - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) - mockLibRepo.SetData(model.Libraries{}) - - _, err := service.GetLibrary(ctx, 999) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("library not found")) - }) - }) - - Describe("GetAllLibraries", func() { - It("should return all libraries without filesystem permission", func() { - service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl) - - libs := model.Libraries{ - {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, - {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, - } - - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) - mockLibRepo.SetData(libs) - - results, err := service.GetAllLibraries(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(2)) - Expect(results[0].Name).To(Equal("Rock")) - Expect(results[0].Path).To(BeEmpty()) - Expect(results[0].MountPoint).To(BeEmpty()) - Expect(results[1].Name).To(Equal("Jazz")) - Expect(results[1].Path).To(BeEmpty()) - Expect(results[1].MountPoint).To(BeEmpty()) - }) - - It("should return all libraries with filesystem permission", func() { - service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl) - - libs := model.Libraries{ - {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, - {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, - } - - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) - mockLibRepo.SetData(libs) - - results, err := service.GetAllLibraries(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(2)) - Expect(results[0].Path).To(Equal("/music/rock")) - Expect(results[0].MountPoint).To(Equal("/libraries/1")) - Expect(results[1].Path).To(Equal("/music/jazz")) - Expect(results[1].MountPoint).To(Equal("/libraries/2")) - }) - }) - - Describe("Library Access Filtering", func() { - It("should only return libraries in the allowed list", func() { // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) - - libs := model.Libraries{ - {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, - {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, - {ID: 3, Name: "Classical", Path: "/music/classical", TotalSongs: 75}, - } - - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) - mockLibRepo.SetData(libs) - - results, err := service.GetAllLibraries(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(1)) - Expect(results[0].ID).To(Equal(int32(2))) - Expect(results[0].Name).To(Equal("Jazz")) - }) - - It("should return error when getting a library not in the allowed list", func() { // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) - - libs := model.Libraries{ - {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, - {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, - } - - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) - mockLibRepo.SetData(libs) - - // Requesting library 1 which is not in the allowed list - _, err := service.GetLibrary(ctx, 1) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not accessible")) - }) - - It("should allow access to a library in the allowed list", func() { // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) - - libs := model.Libraries{ - {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, - {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, - } - - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) - mockLibRepo.SetData(libs) - - result, err := service.GetLibrary(ctx, 2) - Expect(err).ToNot(HaveOccurred()) - Expect(result.ID).To(Equal(int32(2))) - Expect(result.Name).To(Equal("Jazz")) - }) - - It("should return empty list when no libraries are allowed and allLibraries is false", func() { // No libraries allowed - service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{}, false).(*libraryServiceImpl) - - libs := model.Libraries{ - {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, - {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, - } - - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) - mockLibRepo.SetData(libs) - - results, err := service.GetAllLibraries(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(0)) - }) - - It("should return all libraries when allLibraries is true regardless of allowed list", func() { // allLibraries=true should ignore the allowed list - service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{1}, true).(*libraryServiceImpl) - - libs := model.Libraries{ - {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, - {ID: 2, Name: "Jazz", Path: "/music/jazz", TotalSongs: 50}, - } - - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) - mockLibRepo.SetData(libs) - - results, err := service.GetAllLibraries(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(2)) - }) - }) - - Describe("Plugin Integration", func() { - var ( - manager *Manager - tmpDir string - ) - - BeforeEach(func() { - var err error - tmpDir, err = os.MkdirTemp("", "library-test-*") - Expect(err).ToNot(HaveOccurred()) - - // Note: Since we don't have WASM test plugins yet, we can test - // the service registration and configuration without full plugin execution - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - - // Create mock &tests.MockLibraryRepo{} - mockLibRepo := &tests.MockLibraryRepo{} - mockLibRepo.SetData(model.Libraries{ - {ID: 1, Name: "Test", Path: "/tmp/test-music", TotalSongs: 10}, - }) - - ds := &tests.MockDataStore{ - MockedProperty: &tests.MockedPropertyRepo{}, - MockedPlugin: tests.CreateMockPluginRepo(), - MockedLibrary: mockLibRepo, - } - - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: ds, - } - - DeferCleanup(func() { - if manager != nil { - _ = manager.Stop() - } - _ = os.RemoveAll(tmpDir) - }) - }) - - It("should register library service in hostServices table", func() { - // Verify the library service is in the hostServices table - found := false - for _, entry := range hostServices { - if entry.name == "Library" { - found = true - break - } - } - Expect(found).To(BeTrue(), "Library service should be registered in hostServices") - }) - - It("should configure AllowedPaths when filesystem permission is granted", func() { - // This test verifies the AllowedPaths configuration logic - // We can't fully test without a real WASM plugin, but we can verify the setup - Expect(manager.ds).ToNot(BeNil()) - - ctx := context.Background() - libs, err := manager.ds.Library().GetAll(adminContext(ctx)) - Expect(err).ToNot(HaveOccurred()) - Expect(libs).To(HaveLen(1)) - Expect(libs[0].Path).To(Equal("/tmp/test-music")) - - // Verify mount point format - mountPoint := "/libraries/1" - Expect(mountPoint).To(MatchRegexp(`^/libraries/\d+$`)) - }) - }) -}) - -var _ = Describe("LibraryService Integration", Ordered, func() { - var ( - manager *Manager - tmpDir string - libraryDir string - ) - - BeforeAll(func() { - var err error - tmpDir, err = os.MkdirTemp("", "library-integration-test-*") - Expect(err).ToNot(HaveOccurred()) - - // Create a library directory with a test file - libraryDir = filepath.Join(tmpDir, "music-library") - err = os.MkdirAll(libraryDir, 0755) - Expect(err).ToNot(HaveOccurred()) - - // Create a test file in the library - testFile := filepath.Join(libraryDir, "test-track.txt") - err = os.WriteFile(testFile, []byte("test audio file content"), 0600) - Expect(err).ToNot(HaveOccurred()) - - // Copy the test-library plugin - srcPath := filepath.Join(testdataDir, "test-library"+PackageExtension) - destPath := filepath.Join(tmpDir, "test-library"+PackageExtension) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Compute SHA256 for the plugin - hash := sha256.Sum256(data) - hashHex := hex.EncodeToString(hash[:]) - - // Setup config - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - - // Setup mock DataStore with pre-enabled plugin and library - mockPluginRepo := tests.CreateMockPluginRepo() - mockPluginRepo.Permitted = true - mockPluginRepo.SetData(model.Plugins{{ - ID: "test-library", - Path: destPath, - SHA256: hashHex, - Enabled: true, - AllLibraries: true, // Grant access to all libraries for testing - }}) - - mockLibraryRepo := &tests.MockLibraryRepo{} - mockLibraryRepo.SetData(model.Libraries{ - { - ID: 1, - Name: "Test Library", - Path: libraryDir, - TotalSongs: 100, - TotalAlbums: 10, - TotalArtists: 5, - TotalSize: 1024000, - TotalDuration: 3600.5, - }, - { - ID: 2, - Name: "Jazz Collection", - Path: "/nonexistent/jazz", - TotalSongs: 50, - TotalAlbums: 5, - TotalArtists: 3, - TotalSize: 512000, - TotalDuration: 1800.0, - }, - }) - - dataStore := &tests.MockDataStore{ - MockedPlugin: mockPluginRepo, - MockedLibrary: mockLibraryRepo, - } - - // Create and start manager - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - subsonicRouter: http.NotFoundHandler(), - } - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) - }) - - Describe("Plugin Loading", func() { - It("should load plugin with library permission", func() { - manager.mu.RLock() - p, ok := manager.plugins["test-library"] - manager.mu.RUnlock() - Expect(ok).To(BeTrue()) - Expect(p.manifest.Permissions).ToNot(BeNil()) - Expect(p.manifest.Permissions.Library).ToNot(BeNil()) - Expect(p.manifest.Permissions.Library.Filesystem).To(BeTrue()) - }) - }) - - Describe("Library Operations via Plugin", func() { - type testLibraryInput struct { - Operation string `json:"operation"` - LibraryID int32 `json:"library_id,omitempty"` - MountPoint string `json:"mount_point,omitempty"` - FilePath string `json:"file_path,omitempty"` - } - type library struct { - ID int32 `json:"id"` - Name string `json:"name"` - Path string `json:"path,omitempty"` - MountPoint string `json:"mountPoint,omitempty"` - LastScanAt int64 `json:"lastScanAt"` - TotalSongs int32 `json:"totalSongs"` - TotalAlbums int32 `json:"totalAlbums"` - TotalArtists int32 `json:"totalArtists"` - TotalSize int64 `json:"totalSize"` - TotalDuration float64 `json:"totalDuration"` - } - type testLibraryOutput struct { - Library *library `json:"library,omitempty"` - Libraries []library `json:"libraries,omitempty"` - FileContent string `json:"file_content,omitempty"` - DirEntries []string `json:"dir_entries,omitempty"` - Error *string `json:"error,omitempty"` - } - - callTestLibrary := func(ctx context.Context, input testLibraryInput) (*testLibraryOutput, error) { - manager.mu.RLock() - p := manager.plugins["test-library"] - manager.mu.RUnlock() - - instance, err := p.instance(ctx) - if err != nil { - return nil, err - } - defer instance.Close(ctx) - - inputBytes, _ := json.Marshal(input) - _, outputBytes, err := instance.Call("nd_test_library", inputBytes) - if err != nil { - return nil, err - } - - var output testLibraryOutput - if err := json.Unmarshal(outputBytes, &output); err != nil { - return nil, err - } - if output.Error != nil { - return nil, errors.New(*output.Error) - } - return &output, nil - } - - It("should get library by ID with metadata", func() { - ctx := GinkgoT().Context() - - output, err := callTestLibrary(ctx, testLibraryInput{ - Operation: "get_library", - LibraryID: 1, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Library).ToNot(BeNil()) - Expect(output.Library.ID).To(Equal(int32(1))) - Expect(output.Library.Name).To(Equal("Test Library")) - Expect(output.Library.TotalSongs).To(Equal(int32(100))) - Expect(output.Library.TotalAlbums).To(Equal(int32(10))) - Expect(output.Library.TotalArtists).To(Equal(int32(5))) - }) - - It("should include path and mount point with filesystem permission", func() { - ctx := GinkgoT().Context() - - output, err := callTestLibrary(ctx, testLibraryInput{ - Operation: "get_library", - LibraryID: 1, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Library).ToNot(BeNil()) - Expect(output.Library.Path).To(Equal(libraryDir)) - Expect(output.Library.MountPoint).To(Equal("/libraries/1")) - }) - - It("should get all libraries", func() { - ctx := GinkgoT().Context() - - output, err := callTestLibrary(ctx, testLibraryInput{ - Operation: "get_all_libraries", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Libraries).To(HaveLen(2)) - - // First library - Expect(output.Libraries[0].ID).To(Equal(int32(1))) - Expect(output.Libraries[0].Name).To(Equal("Test Library")) - Expect(output.Libraries[0].MountPoint).To(Equal("/libraries/1")) - - // Second library - Expect(output.Libraries[1].ID).To(Equal(int32(2))) - Expect(output.Libraries[1].Name).To(Equal("Jazz Collection")) - Expect(output.Libraries[1].MountPoint).To(Equal("/libraries/2")) - }) - - It("should return error for non-existent library", func() { - ctx := GinkgoT().Context() - - _, err := callTestLibrary(ctx, testLibraryInput{ - Operation: "get_library", - LibraryID: 999, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("library not found")) - }) - - // Note: This test is slightly flaky due to a potential race condition in wazero's - // WASI filesystem mounting. The test passes ~85% of the time. Using FlakeAttempts - // to automatically retry on failure. - It("should read file from mounted library directory", FlakeAttempts(5), func() { - ctx := GinkgoT().Context() - - output, err := callTestLibrary(ctx, testLibraryInput{ - Operation: "read_file", - MountPoint: "/libraries/1", - FilePath: "test-track.txt", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.FileContent).To(Equal("test audio file content")) - }) - - // Note: Uses FlakeAttempts for the same reason as the read_file test above - It("should list files in mounted library directory", FlakeAttempts(5), func() { - ctx := GinkgoT().Context() - - output, err := callTestLibrary(ctx, testLibraryInput{ - Operation: "list_dir", - MountPoint: "/libraries/1", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.DirEntries).To(ContainElement("test-track.txt")) - }) - - It("should fail to access unmapped library directory", func() { - ctx := GinkgoT().Context() - - // Try to access a path outside the mapped libraries - _, err := callTestLibrary(ctx, testLibraryInput{ - Operation: "list_dir", - MountPoint: "/etc", - }) - Expect(err).To(HaveOccurred()) - }) - }) -}) diff --git a/plugins/host_matcher.go b/plugins/host_matcher.go deleted file mode 100644 index 125d16649..000000000 --- a/plugins/host_matcher.go +++ /dev/null @@ -1,198 +0,0 @@ -package plugins - -import ( - "cmp" - "context" - "fmt" - "maps" - "slices" - "time" - - "github.com/navidrome/navidrome/core/matcher" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/plugins/host" - "github.com/navidrome/navidrome/plugins/types" - "github.com/navidrome/navidrome/utils/slice" -) - -type matcherServiceImpl struct { - ds model.DataStore - hasFilesystemPerm bool - users userAccess - libs libraryAccess -} - -func newMatcherService(ds model.DataStore, hasFilesystemPerm bool, users userAccess, libs libraryAccess) host.MatcherService { - return &matcherServiceImpl{ - ds: ds, - hasFilesystemPerm: hasFilesystemPerm, - users: users, - libs: libs, - } -} - -func (s *matcherServiceImpl) MatchSongs(ctx context.Context, songs []types.SongRef, opts host.MatchOptions) ([]*types.Track, error) { - results := make([]*types.Track, len(songs)) - if len(songs) == 0 { - return results, nil - } - - // Fail closed when the plugin has no library scope, rather than matching nothing. - if !s.libs.configured() { - return nil, fmt.Errorf("matcher: no libraries configured for this plugin") - } - - // Set the user context explicitly so the match never inherits the request user - // of whatever invoked the plugin: a username scopes to that user (loading their - // annotations and library access), and an unscoped match runs as admin so only - // the plugin's own library scope constrains the results. - scoped := opts.Username != "" - if scoped { - usr, err := s.users.resolve(ctx, s.ds, opts.Username) - if err != nil { - return nil, fmt.Errorf("matcher: %w", err) - } - ctx = request.WithUser(ctx, *usr) - } else { - ctx = adminContext(ctx) - } - - agentSongs := slice.Map(songs, songRefToAgentSong) - - matched, err := matcher.New(s.ds).MatchSongsIndexed(ctx, agentSongs) - if err != nil { - return nil, err - } - // The plugin's library scope is a second, independent authorization on top of the context - // user's own access, so it's applied here rather than in-query: the unscoped path runs as - // admin (which applyLibraryFilter skips), and folding s.libs into the context user would - // conflate the two scopes instead of intersecting them. - for i, mf := range matched { - // Drop tracks outside the plugin's library scope, leaving that index unmatched. - if !s.libs.contains(mf.LibraryID) { - continue - } - results[i] = s.toTrack(&mf, scoped) - } - return results, nil -} - -// toTrack projects a MediaFile into the public Track DTO. Path needs filesystem -// permission; per-user annotations are only set for a scoped match. -func (s *matcherServiceImpl) toTrack(mf *model.MediaFile, scoped bool) *types.Track { - t := &types.Track{ - ID: mf.ID, - LibraryID: int32(mf.LibraryID), - LibraryName: mf.LibraryName, - Missing: mf.Missing, - Title: mf.Title, - Album: mf.Album, - Artist: mf.Artist, - AlbumArtist: mf.AlbumArtist, - AlbumID: mf.AlbumID, - SortTitle: mf.SortTitle, - SortAlbumName: mf.SortAlbumName, - SortArtistName: mf.SortArtistName, - TrackNumber: int32(mf.TrackNumber), - DiscNumber: int32(mf.DiscNumber), - DiscSubtitle: mf.DiscSubtitle, - Year: int32(mf.Year), - Date: mf.Date, - OriginalYear: int32(mf.OriginalYear), - OriginalDate: mf.OriginalDate, - ReleaseYear: int32(mf.ReleaseYear), - ReleaseDate: mf.ReleaseDate, - Size: mf.Size, - Suffix: mf.Suffix, - Duration: float64(mf.Duration), - BitRate: int32(mf.BitRate), - SampleRate: int32(mf.SampleRate), - BitDepth: ptrInt32(mf.BitDepth), - Channels: int32(mf.Channels), - Codec: mf.Codec, - Comment: mf.Comment, - BPM: ptrInt32(mf.BPM), - ExplicitStatus: mf.ExplicitStatus, - CatalogNum: mf.CatalogNum, - Compilation: mf.Compilation, - HasCoverArt: mf.HasCoverArt, - MbzRecordingID: mf.MbzRecordingID, - MbzReleaseTrackID: mf.MbzReleaseTrackID, - MbzAlbumID: mf.MbzAlbumID, - MbzReleaseGroupID: mf.MbzReleaseGroupID, - MbzAlbumType: mf.MbzAlbumType, - MbzAlbumComment: mf.MbzAlbumComment, - RGAlbumGain: mf.RGAlbumGain, - RGAlbumPeak: mf.RGAlbumPeak, - RGTrackGain: mf.RGTrackGain, - RGTrackPeak: mf.RGTrackPeak, - AverageRating: mf.AverageRating, // aggregate, not user-scoped - BirthTime: unixOrZero(mf.BirthTime), - CreatedAt: unixOrZero(mf.CreatedAt), - UpdatedAt: unixOrZero(mf.UpdatedAt), - } - if s.hasFilesystemPerm { - t.Path = mf.Path - } - if len(mf.Genres) > 0 { - t.Genres = slice.Map(mf.Genres, func(g model.Genre) string { return g.Name }) - } - if len(mf.Tags) > 0 { - t.Tags = make(map[string][]string, len(mf.Tags)) - for name, values := range mf.Tags { - t.Tags[string(name)] = values - } - } - if len(mf.Participants) > 0 { - // Flatten the role→artists map into a role-tagged list, in stable role order. - roles := slices.SortedFunc(maps.Keys(mf.Participants), func(a, b model.Role) int { - return cmp.Compare(a.String(), b.String()) - }) - for _, role := range roles { - for _, p := range mf.Participants[role] { - t.Participants = append(t.Participants, types.ArtistRef{ - ID: p.ID, - Name: p.Name, - MBID: p.MbzArtistID, - SortName: p.SortArtistName, - Role: role.String(), - SubRole: p.SubRole, - }) - } - } - } - if scoped { - t.Starred = mf.Starred - t.StarredAt = unixPtr(mf.StarredAt) - t.Rating = int32(mf.Rating) - t.PlayCount = mf.PlayCount - t.PlayDate = unixPtr(mf.PlayDate) - } - return t -} - -func unixOrZero(t time.Time) int64 { - if t.IsZero() { - return 0 - } - return t.Unix() -} - -// unixPtr maps a nullable time to Unix seconds, keeping nil distinct from the epoch. -func unixPtr(t *time.Time) *int64 { - if t == nil || t.IsZero() { - return nil - } - return new(t.Unix()) -} - -// ptrInt32 narrows a nullable *int to *int32, keeping nil distinct from a real 0. -func ptrInt32(p *int) *int32 { - if p == nil { - return nil - } - return new(int32(*p)) -} - -var _ host.MatcherService = (*matcherServiceImpl)(nil) diff --git a/plugins/host_matcher_test.go b/plugins/host_matcher_test.go deleted file mode 100644 index 7d3e47fae..000000000 --- a/plugins/host_matcher_test.go +++ /dev/null @@ -1,512 +0,0 @@ -package plugins - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "net/http" - "os" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/plugins/host" - "github.com/navidrome/navidrome/plugins/types" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("MatcherService", Ordered, func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - // newConverter returns the service as its concrete type so converter unit - // tests can call toTrack directly with a chosen filesystem-permission flag. - newConverter := func(hasFilesystemPerm bool) *matcherServiceImpl { - return newMatcherService(nil, hasFilesystemPerm, newUserAccess(nil, true), newLibraryAccess(nil, true)).(*matcherServiceImpl) - } - - Describe("toTrack", func() { - It("projects a MediaFile into a public Track", func() { - bitDepth := 24 - bpm := 128 - rgGain := -7.5 - created := time.Unix(1700000000, 0) - updated := time.Unix(1700000500, 0) - birth := time.Unix(1699999000, 0) - - mf := &model.MediaFile{ - ID: "mf-1", - LibraryID: 3, - LibraryName: "Main", - Path: "/music/song.flac", - Title: "My Song", - Album: "My Album", - Artist: "My Artist", - AlbumArtist: "My Artist", - AlbumID: "al-1", - SortTitle: "my song", - TrackNumber: 4, - DiscNumber: 1, - Year: 2020, - Size: 1234, - Suffix: "flac", - Duration: 210.5, - BitRate: 1000, - SampleRate: 44100, - BitDepth: &bitDepth, - Channels: 2, - Codec: "flac", - Genre: "Rock", - BPM: &bpm, - ExplicitStatus: "c", - Compilation: true, - HasCoverArt: true, - MbzRecordingID: "rec-1", - RGTrackGain: &rgGain, - CreatedAt: created, - UpdatedAt: updated, - BirthTime: birth, - Genres: model.Genres{{Name: "Rock"}, {Name: "Pop"}}, - Tags: model.Tags{model.TagName("isrc"): []string{"US-XXX-00"}}, - } - mf.AverageRating = 4.2 - mf.Participants = model.Participants{} - mf.Participants.Add(model.RoleArtist, model.Artist{ - ID: "ar-1", Name: "My Artist", SortArtistName: "artist, my", MbzArtistID: "mbz-ar-1", - }) - mf.Participants.AddWithSubRole(model.RolePerformer, "violin", model.Artist{ - ID: "ar-2", Name: "A Fiddler", - }) - - track := newConverter(true).toTrack(mf, false) - - Expect(track.ID).To(Equal("mf-1")) - Expect(track.LibraryID).To(Equal(int32(3))) - Expect(track.LibraryName).To(Equal("Main")) - Expect(track.Path).To(Equal("/music/song.flac")) - Expect(track.Title).To(Equal("My Song")) - Expect(track.Duration).To(Equal(210.5)) - Expect(track.BitDepth).To(HaveValue(Equal(int32(24)))) - Expect(track.BPM).To(HaveValue(Equal(int32(128)))) - Expect(track.RGTrackGain).To(HaveValue(Equal(-7.5))) - Expect(track.Compilation).To(BeTrue()) - Expect(track.MbzRecordingID).To(Equal("rec-1")) - Expect(track.Genres).To(Equal([]string{"Rock", "Pop"})) - Expect(track.CreatedAt).To(Equal(int64(1700000000))) - Expect(track.UpdatedAt).To(Equal(int64(1700000500))) - Expect(track.BirthTime).To(Equal(int64(1699999000))) - Expect(track.Tags).To(HaveKeyWithValue("isrc", []string{"US-XXX-00"})) - // Flat, role-tagged, role-sorted. - Expect(track.Participants).To(HaveLen(2)) - Expect(track.Participants[0]).To(Equal(types.ArtistRef{ - ID: "ar-1", Name: "My Artist", SortName: "artist, my", MBID: "mbz-ar-1", Role: "artist", - })) - Expect(track.Participants[1]).To(Equal(types.ArtistRef{ - ID: "ar-2", Name: "A Fiddler", Role: "performer", SubRole: "violin", - })) - // AverageRating is an aggregate, exposed even though the match is unscoped. - Expect(track.AverageRating).To(Equal(4.2)) - }) - - It("leaves nil-able numeric fields nil when absent", func() { - mf := &model.MediaFile{ID: "mf-2", Title: "No Optionals"} - track := newConverter(true).toTrack(mf, false) - Expect(track.BitDepth).To(BeNil()) - Expect(track.BPM).To(BeNil()) - Expect(track.RGAlbumGain).To(BeNil()) - Expect(track.RGAlbumPeak).To(BeNil()) - Expect(track.RGTrackGain).To(BeNil()) - Expect(track.RGTrackPeak).To(BeNil()) - }) - - It("preserves a real 0 ReplayGain value as non-nil", func() { - zero := 0.0 - mf := &model.MediaFile{ID: "mf-3", Title: "Zero RG", RGTrackGain: &zero} - track := newConverter(true).toTrack(mf, false) - Expect(track.RGTrackGain).To(HaveValue(Equal(0.0))) - Expect(track.RGAlbumGain).To(BeNil()) - }) - - It("exposes Path only when the plugin has filesystem permission", func() { - mf := &model.MediaFile{ID: "mf-4", Title: "With Path", Path: "/music/x.flac"} - Expect(newConverter(true).toTrack(mf, false).Path).To(Equal("/music/x.flac")) - Expect(newConverter(false).toTrack(mf, false).Path).To(BeEmpty()) - }) - }) - - Describe("MatchSongs", func() { - // The mock MediaFileRepo returns stored files (with annotations) verbatim, - // ignoring QueryOptions and the context user. These tests therefore cover the - // adapter's gating/access logic, not the SQL per-user join (a persistence-layer - // concern). - - // allowAll returns a service permitted to match as any user across all - // libraries. - allowAll := func(ds model.DataStore) host.MatcherService { - return newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) - } - - It("returns one entry per input song in order, with nil for no-match", func() { - mediaFileRepo := tests.CreateMockMediaFileRepo() - // First (ID) phase returns the match for input song 0 only. - mediaFileRepo.SetData(model.MediaFiles{ - {ID: "mf-100", Title: "Hit", Artist: "Band"}, - }) - ds := &tests.MockDataStore{MockedMediaFile: mediaFileRepo} - - results, err := allowAll(ds).MatchSongs(GinkgoT().Context(), []types.SongRef{ - {ID: "mf-100", Name: "Hit", Artist: "Band"}, - {ID: "missing-id", Name: "Ghost", Artist: "Nobody"}, - }, host.MatchOptions{}) - - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(HaveLen(2)) - Expect(results[0]).ToNot(BeNil()) - Expect(results[0].ID).To(Equal("mf-100")) - Expect(results[1]).To(BeNil()) - }) - - It("returns an empty slice for empty input", func() { - ds := &tests.MockDataStore{MockedMediaFile: tests.CreateMockMediaFileRepo()} - results, err := allowAll(ds).MatchSongs(GinkgoT().Context(), nil, host.MatchOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(results).To(BeEmpty()) - }) - - Context("with a scoped user", func() { - var ds *tests.MockDataStore - var userRepo *tests.MockedUserRepo - - BeforeEach(func() { - mediaFileRepo := tests.CreateMockMediaFileRepo() - mf := model.MediaFile{ID: "mf-1", Title: "Hit", Artist: "Band", LibraryID: 1} - mf.Starred = true - mf.Rating = 5 - mediaFileRepo.SetData(model.MediaFiles{mf}) - - userRepo = tests.CreateMockUserRepo() - Expect(userRepo.Put(GinkgoT().Context(), &model.User{ID: "u-alice", UserName: "alice"})).To(Succeed()) - - ds = &tests.MockDataStore{MockedMediaFile: mediaFileRepo, MockedUser: userRepo} - }) - - input := []types.SongRef{{ID: "mf-1", Name: "Hit", Artist: "Band"}} - - It("does not expose annotations when no username is given", func() { - svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) - results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(results[0]).ToNot(BeNil()) - Expect(results[0].Starred).To(BeFalse()) - Expect(results[0].Rating).To(BeZero()) - }) - - It("exposes the user's annotations when an allowed username is given", func() { - svc := newMatcherService(ds, false, newUserAccess([]string{"u-alice"}, false), newLibraryAccess(nil, true)) - results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) - Expect(err).ToNot(HaveOccurred()) - Expect(results[0]).ToNot(BeNil()) - Expect(results[0].Starred).To(BeTrue()) - Expect(results[0].Rating).To(Equal(int32(5))) - }) - - It("allows any username when allUsers is set", func() { - svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) - results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) - Expect(err).ToNot(HaveOccurred()) - Expect(results[0].Starred).To(BeTrue()) - }) - - It("returns an error for an unknown username", func() { - svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) - _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "ghost"}) - Expect(err).To(MatchError(ContainSubstring("not found"))) - }) - - It("surfaces a backend error rather than masking it as not-found", func() { - userRepo.Error = errors.New("db is locked") - svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) - _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) - Expect(err).To(MatchError(ContainSubstring("db is locked"))) - Expect(err.Error()).ToNot(ContainSubstring("not found")) - }) - - It("returns an error for a username the plugin is not allowed to use", func() { - svc := newMatcherService(ds, false, newUserAccess([]string{"u-bob"}, false), newLibraryAccess(nil, true)) - _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) - Expect(err).To(MatchError(ContainSubstring("not allowed"))) - }) - - It("rejects a username with the same error whether it exists, when the plugin has no user scope", func() { - // A plugin with no user scope (the only state a matcher-only plugin can - // be in) must not leak whether a username exists via the error text. - svc := newMatcherService(ds, false, newUserAccess(nil, false), newLibraryAccess(nil, true)) - - _, errExisting := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "alice"}) - _, errMissing := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{Username: "ghost"}) - - Expect(errExisting).To(HaveOccurred()) - Expect(errExisting.Error()).To(Equal(errMissing.Error())) - Expect(errExisting.Error()).ToNot(ContainSubstring("not found")) - Expect(errExisting.Error()).To(ContainSubstring("not authorized to scope by user")) - }) - - It("does not inherit the caller's request user for an unscoped match", func() { - // The plugin may be invoked while handling another user's request; an - // unscoped match must run as admin, not as that inherited user. - capturing := &ctxCapturingDataStore{MockDataStore: ds} - svc := newMatcherService(capturing, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) - - callerCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "u-caller", UserName: "caller"}) - _, err := svc.MatchSongs(callerCtx, input, host.MatchOptions{}) - Expect(err).ToNot(HaveOccurred()) - - usr, ok := request.UserFrom(capturing.lastMediaFileCtx()) - Expect(ok).To(BeTrue()) - Expect(usr.IsAdmin).To(BeTrue()) - Expect(usr.ID).ToNot(Equal("u-caller")) - }) - - It("uses the requested user, overriding an inherited caller user", func() { - capturing := &ctxCapturingDataStore{MockDataStore: ds} - svc := newMatcherService(capturing, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) - - callerCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "u-caller", UserName: "caller"}) - _, err := svc.MatchSongs(callerCtx, input, host.MatchOptions{Username: "alice"}) - Expect(err).ToNot(HaveOccurred()) - - usr, ok := request.UserFrom(capturing.lastMediaFileCtx()) - Expect(ok).To(BeTrue()) - Expect(usr.ID).To(Equal("u-alice")) - }) - }) - - Context("with plugin library access", func() { - var ds *tests.MockDataStore - - BeforeEach(func() { - mediaFileRepo := tests.CreateMockMediaFileRepo() - mediaFileRepo.SetData(model.MediaFiles{ - {ID: "mf-lib1", Title: "A", Artist: "Band", LibraryID: 1}, - {ID: "mf-lib2", Title: "B", Artist: "Band", LibraryID: 2}, - }) - ds = &tests.MockDataStore{MockedMediaFile: mediaFileRepo} - }) - - input := []types.SongRef{ - {ID: "mf-lib1", Name: "A", Artist: "Band"}, - {ID: "mf-lib2", Name: "B", Artist: "Band"}, - } - - It("drops matches from libraries the plugin cannot access", func() { - svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess([]int{1}, false)) - results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(results[0]).ToNot(BeNil()) - Expect(results[0].ID).To(Equal("mf-lib1")) - Expect(results[1]).To(BeNil()) // library 2 not permitted - }) - - It("keeps all matches when allLibraries is set", func() { - svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, true)) - results, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(results[0]).ToNot(BeNil()) - Expect(results[1]).ToNot(BeNil()) - }) - - It("errors when the plugin has no library scope configured", func() { - svc := newMatcherService(ds, false, newUserAccess(nil, true), newLibraryAccess(nil, false)) - _, err := svc.MatchSongs(GinkgoT().Context(), input, host.MatchOptions{}) - Expect(err).To(MatchError(ContainSubstring("no libraries configured"))) - }) - }) - }) - - // Artist precedence for songRefToAgentSong is covered in metadata_agent_test.go; - // here we cover the duration normalization the matcher path relies on. - Describe("songRefToAgentSong duration", func() { - It("prefers DurationMs over the deprecated seconds field", func() { - song := songRefToAgentSong(types.SongRef{DurationMs: 247333, Duration: 99}) - Expect(song.Duration).To(Equal(uint32(247333))) - }) - - It("falls back to the seconds field when DurationMs is zero", func() { - song := songRefToAgentSong(types.SongRef{Duration: 210.5}) - Expect(song.Duration).To(Equal(uint32(210500))) - }) - - It("clamps a negative seconds duration to zero instead of overflowing", func() { - song := songRefToAgentSong(types.SongRef{Duration: -1}) - Expect(song.Duration).To(BeZero()) - }) - }) -}) - -var _ = Describe("MatcherService Integration", Ordered, func() { - var ( - manager *Manager - tmpDir string - ) - - BeforeAll(func() { - var err error - tmpDir, err = os.MkdirTemp("", "matcher-integration-test-*") - Expect(err).ToNot(HaveOccurred()) - - srcPath := filepath.Join(testdataDir, "test-matcher"+PackageExtension) - destPath := filepath.Join(tmpDir, "test-matcher"+PackageExtension) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - hash := sha256.Sum256(data) - hashHex := hex.EncodeToString(hash[:]) - - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - - mockPluginRepo := tests.CreateMockPluginRepo() - mockPluginRepo.Permitted = true - // AllLibraries: the matcher requires a library scope. - mockPluginRepo.SetData(model.Plugins{{ - ID: "test-matcher", - Path: destPath, - SHA256: hashHex, - Enabled: true, - AllUsers: true, - AllLibraries: true, - }}) - - mediaFileRepo := tests.CreateMockMediaFileRepo() - hit := model.MediaFile{ID: "mf-hit", Title: "Hit", Artist: "Band"} - hit.Starred = true - mediaFileRepo.SetData(model.MediaFiles{hit}) - - userRepo := tests.CreateMockUserRepo() - Expect(userRepo.Put(GinkgoT().Context(), &model.User{ID: "u-alice", UserName: "alice"})).To(Succeed()) - - dataStore := &tests.MockDataStore{ - MockedPlugin: mockPluginRepo, - MockedMediaFile: mediaFileRepo, - MockedUser: userRepo, - } - - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - subsonicRouter: http.NotFoundHandler(), - } - Expect(manager.Start(GinkgoT().Context())).To(Succeed()) - - DeferCleanup(func() { - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) - }) - - It("loads the plugin with the matcher permission", func() { - manager.mu.RLock() - p, ok := manager.plugins["test-matcher"] - manager.mu.RUnlock() - Expect(ok).To(BeTrue()) - Expect(p.manifest.Permissions).ToNot(BeNil()) - Expect(p.manifest.Permissions.Matcher).ToNot(BeNil()) - }) - - It("matches songs through the host boundary, preserving order and nils", func() { - ctx := GinkgoT().Context() - manager.mu.RLock() - p := manager.plugins["test-matcher"] - manager.mu.RUnlock() - - instance, err := p.instance(ctx) - Expect(err).ToNot(HaveOccurred()) - defer instance.Close(ctx) - - type tIn struct { - Songs []types.SongRef `json:"songs"` - Username string `json:"username,omitempty"` - } - type tOut struct { - MatchedIDs []string `json:"matched_ids"` - Starred []bool `json:"starred"` - Error *string `json:"error,omitempty"` - } - - call := func(in tIn) tOut { - inputBytes, err := json.Marshal(in) - Expect(err).ToNot(HaveOccurred()) - _, outputBytes, err := instance.Call("nd_test_matcher", inputBytes) - Expect(err).ToNot(HaveOccurred()) - var out tOut - Expect(json.Unmarshal(outputBytes, &out)).To(Succeed()) - Expect(out.Error).To(BeNil()) - return out - } - - songs := []types.SongRef{ - {ID: "mf-hit", Name: "Hit", Artist: "Band"}, - {ID: "nope", Name: "Ghost", Artist: "Nobody"}, - } - - By("matching without a user, preserving order and nils") - out := call(tIn{Songs: songs}) - Expect(out.MatchedIDs).To(HaveLen(2)) - Expect(out.MatchedIDs[0]).To(Equal("mf-hit")) - Expect(out.MatchedIDs[1]).To(BeEmpty()) - Expect(out.Starred[0]).To(BeFalse()) // no user scope → no annotations - - By("matching as a user, exposing that user's annotations across the boundary") - scoped := call(tIn{Songs: songs, Username: "alice"}) - Expect(scoped.MatchedIDs[0]).To(Equal("mf-hit")) - Expect(scoped.Starred[0]).To(BeTrue()) - }) -}) - -// ctxCapturingDataStore records the context the media file queries run with, so tests -// can assert which user the matcher resolved before querying the library. -type ctxCapturingDataStore struct { - *tests.MockDataStore - repo *ctxCapturingMediaFileRepo -} - -func (d *ctxCapturingDataStore) MediaFile() model.MediaFileRepository { - if d.repo == nil { - d.repo = &ctxCapturingMediaFileRepo{MediaFileRepository: d.MockDataStore.MediaFile()} - } - return d.repo -} - -func (d *ctxCapturingDataStore) lastMediaFileCtx() context.Context { - if d.repo == nil { - return nil - } - return d.repo.lastCtx -} - -type ctxCapturingMediaFileRepo struct { - model.MediaFileRepository - lastCtx context.Context -} - -func (r *ctxCapturingMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) { - r.lastCtx = ctx - return r.MediaFileRepository.GetAll(ctx, options...) -} - -func (r *ctxCapturingMediaFileRepo) GetAllByTags(ctx context.Context, tag model.TagName, values []string, options ...model.QueryOptions) (model.MediaFiles, error) { - r.lastCtx = ctx - return r.MediaFileRepository.GetAllByTags(ctx, tag, values, options...) -} diff --git a/plugins/host_netguard.go b/plugins/host_netguard.go deleted file mode 100644 index ab092009a..000000000 --- a/plugins/host_netguard.go +++ /dev/null @@ -1,58 +0,0 @@ -package plugins - -import ( - "fmt" - "net" - "slices" - - "github.com/navidrome/navidrome/utils/netguard" -) - -// dialResolver is nil in production (the system resolver); tests swap in a stub to avoid real DNS. -var dialResolver *net.Resolver - -// checkPrivateDial runs at dial time on the resolved IP, so hostnames can't reach private addresses unless a -// literal IP/CIDR entry or a bare "*" (plugins targeting user-configured LAN services) allows it. -func checkPrivateDial(requiredHosts []string, address string) error { - if slices.Contains(requiredHosts, "*") { - return nil - } - host, _, err := net.SplitHostPort(address) - if err != nil { - return err - } - ip := net.ParseIP(host) - if ip == nil || !netguard.IsPrivateIP(ip) { - return nil - } - for _, entry := range requiredHosts { - if ipMatchesEntry(entry, ip) { - return nil - } - } - return fmt.Errorf("dial to private/loopback address %q blocked: requires an explicit IP or CIDR in requiredHosts", address) -} - -func isHostInAllowlist(requiredHosts []string, hostname string) bool { - ip := net.ParseIP(hostname) - for _, pattern := range requiredHosts { - if matchHostPattern(pattern, hostname) { - return true - } - if ip != nil && ipMatchesEntry(pattern, ip) { - return true - } - } - return false -} - -// ipMatchesEntry reports whether a requiredHosts entry is a literal IP or CIDR that covers ip. -func ipMatchesEntry(entry string, ip net.IP) bool { - if _, cidr, err := net.ParseCIDR(entry); err == nil { - return cidr.Contains(ip) - } - if entryIP := net.ParseIP(entry); entryIP != nil { - return entryIP.Equal(ip) - } - return false -} diff --git a/plugins/host_scheduler.go b/plugins/host_scheduler.go deleted file mode 100644 index 131f56521..000000000 --- a/plugins/host_scheduler.go +++ /dev/null @@ -1,209 +0,0 @@ -package plugins - -import ( - "context" - "fmt" - "maps" - "sync" - "time" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/plugins/capabilities" - "github.com/navidrome/navidrome/plugins/host" - "github.com/navidrome/navidrome/scheduler" -) - -// CapabilityScheduler indicates the plugin can receive scheduled event callbacks. -// Detected when the plugin exports the scheduler callback function. -const CapabilityScheduler Capability = "Scheduler" - -const FuncSchedulerCallback = "nd_scheduler_callback" - -func init() { - registerCapability( - CapabilityScheduler, - FuncSchedulerCallback, - ) -} - -// timeAfterFunc is a variable for time.AfterFunc, allowing tests to override it. -var timeAfterFunc = time.AfterFunc - -// scheduleEntry stores metadata about a scheduled task. -type scheduleEntry struct { - pluginName string - payload string - isRecurring bool - entryID int // Internal scheduler entry ID (for recurring tasks) - timer *time.Timer // Timer for one-time tasks (nil for recurring) -} - -// schedulerServiceImpl implements host.SchedulerService. -// It provides plugins with scheduling capabilities and invokes callbacks when schedules fire. -type schedulerServiceImpl struct { - pluginName string - manager *Manager - scheduler scheduler.Scheduler - - mu sync.Mutex - schedules map[string]*scheduleEntry -} - -// newSchedulerService creates a new SchedulerService for a plugin. -func newSchedulerService(pluginName string, manager *Manager, sched scheduler.Scheduler) *schedulerServiceImpl { - return &schedulerServiceImpl{ - pluginName: pluginName, - manager: manager, - scheduler: sched, - schedules: make(map[string]*scheduleEntry), - } -} - -func (s *schedulerServiceImpl) ScheduleOneTime(ctx context.Context, delaySeconds int32, payload string, scheduleID string) (string, error) { - if scheduleID == "" { - scheduleID = id.NewRandom() - } - - s.mu.Lock() - defer s.mu.Unlock() - - if _, exists := s.schedules[scheduleID]; exists { - return "", fmt.Errorf("schedule ID %q already exists", scheduleID) - } - - capturedID := scheduleID - timer := timeAfterFunc(time.Duration(delaySeconds)*time.Second, func() { - s.invokeCallback(context.Background(), capturedID) - // Clean up the entry after firing - s.mu.Lock() - delete(s.schedules, capturedID) - s.mu.Unlock() - }) - - s.schedules[scheduleID] = &scheduleEntry{ - pluginName: s.pluginName, - payload: payload, - isRecurring: false, - timer: timer, - } - - log.Debug(ctx, "Scheduled one-time task", "plugin", s.pluginName, "scheduleID", scheduleID, "delaySeconds", delaySeconds) - return scheduleID, nil -} - -func (s *schedulerServiceImpl) ScheduleRecurring(ctx context.Context, cronExpression string, payload string, scheduleID string) (string, error) { - if scheduleID == "" { - scheduleID = id.NewRandom() - } - - capturedID := scheduleID - callback := func() { - s.invokeCallback(context.Background(), capturedID) - } - - s.mu.Lock() - defer s.mu.Unlock() - - if _, exists := s.schedules[scheduleID]; exists { - return "", fmt.Errorf("schedule ID %q already exists", scheduleID) - } - - entryID, err := s.scheduler.Add(cronExpression, callback) - if err != nil { - return "", fmt.Errorf("failed to schedule task: %w", err) - } - - s.schedules[scheduleID] = &scheduleEntry{ - pluginName: s.pluginName, - payload: payload, - isRecurring: true, - entryID: entryID, - } - - log.Debug(ctx, "Scheduled recurring task", "plugin", s.pluginName, "scheduleID", scheduleID, "cron", cronExpression) - return scheduleID, nil -} - -func (s *schedulerServiceImpl) CancelSchedule(ctx context.Context, scheduleID string) error { - s.mu.Lock() - entry, exists := s.schedules[scheduleID] - if !exists { - s.mu.Unlock() - return fmt.Errorf("schedule ID %q not found", scheduleID) - } - delete(s.schedules, scheduleID) - s.mu.Unlock() - - if entry.timer != nil { - entry.timer.Stop() - } else { - s.scheduler.Remove(entry.entryID) - } - log.Debug(ctx, "Cancelled schedule", "plugin", s.pluginName, "scheduleID", scheduleID) - return nil -} - -// Close cancels all schedules for this plugin. -// This is called when the plugin is unloaded. -func (s *schedulerServiceImpl) Close() error { - s.mu.Lock() - schedules := maps.Clone(s.schedules) - s.schedules = make(map[string]*scheduleEntry) - s.mu.Unlock() - - for scheduleID, entry := range schedules { - if entry.timer != nil { - entry.timer.Stop() - } else { - s.scheduler.Remove(entry.entryID) - } - log.Debug("Cancelled schedule on plugin unload", "plugin", s.pluginName, "scheduleID", scheduleID) - } - return nil -} - -// invokeCallback calls the plugin's nd_scheduler_callback function. -func (s *schedulerServiceImpl) invokeCallback(ctx context.Context, scheduleID string) { - log.Debug(ctx, "Scheduler callback invoked", "plugin", s.pluginName, "scheduleID", scheduleID) - - s.mu.Lock() - entry, exists := s.schedules[scheduleID] - if !exists { - s.mu.Unlock() - log.Warn(ctx, "Schedule entry not found during callback", "plugin", s.pluginName, "scheduleID", scheduleID) - return - } - payload := entry.payload - isRecurring := entry.isRecurring - s.mu.Unlock() - - // Get the plugin instance from the manager - s.manager.mu.RLock() - instance, ok := s.manager.plugins[s.pluginName] - s.manager.mu.RUnlock() - - if !ok { - log.Warn(ctx, "Plugin not loaded when scheduler callback fired", "plugin", s.pluginName, "scheduleID", scheduleID) - return - } - - // Prepare callback input - input := capabilities.SchedulerCallbackRequest{ - ScheduleID: scheduleID, - Payload: payload, - IsRecurring: isRecurring, - } - - start := time.Now() - err := callPluginFunctionNoOutput(ctx, instance, FuncSchedulerCallback, input) - if err != nil { - log.Error(ctx, "Scheduler callback failed", "plugin", s.pluginName, "scheduleID", scheduleID, "duration", time.Since(start), err) - return - } - - log.Debug(ctx, "Scheduler callback completed", "plugin", s.pluginName, "scheduleID", scheduleID, "duration", time.Since(start)) -} - -// Verify interface implementation -var _ host.SchedulerService = (*schedulerServiceImpl)(nil) diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go deleted file mode 100644 index 6940d8820..000000000 --- a/plugins/host_scheduler_test.go +++ /dev/null @@ -1,460 +0,0 @@ -package plugins - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "net/http" - "os" - "path/filepath" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/scheduler" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("SchedulerService", Ordered, func() { - var ( - manager *Manager - tmpDir string - mockSched *mockScheduler - mockTimers *mockTimerRegistry - testService *testableSchedulerService - origAfterFn func(time.Duration, func()) *time.Timer - ) - - BeforeAll(func() { - var err error - tmpDir, err = os.MkdirTemp("", "scheduler-test-*") - Expect(err).ToNot(HaveOccurred()) - - // Copy the test-scheduler plugin - srcPath := filepath.Join(testdataDir, "test-scheduler"+PackageExtension) - destPath := filepath.Join(tmpDir, "test-scheduler"+PackageExtension) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Compute SHA256 for the plugin - hash := sha256.Sum256(data) - hashHex := hex.EncodeToString(hash[:]) - - // Setup config - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - - // Create mock scheduler and timer registry - mockSched = newMockScheduler() - mockTimers = newMockTimerRegistry() - - // Replace timeAfterFunc with mock - origAfterFn = timeAfterFunc - timeAfterFunc = mockTimers.AfterFunc - - // Setup mock DataStore with pre-enabled plugin - mockPluginRepo := tests.CreateMockPluginRepo() - mockPluginRepo.Permitted = true - mockPluginRepo.SetData(model.Plugins{{ - ID: "test-scheduler", - Path: destPath, - SHA256: hashHex, - Enabled: true, - }}) - dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo} - - // Create and start manager - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - subsonicRouter: http.NotFoundHandler(), - metrics: noopMetricsRecorder{}, - } - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - // Get scheduler service from plugin's closers and wrap it for testing - service := findSchedulerService(manager, "test-scheduler") - Expect(service).ToNot(BeNil()) - testService = &testableSchedulerService{schedulerServiceImpl: service} - testService.scheduler = mockSched - - DeferCleanup(func() { - timeAfterFunc = origAfterFn - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) - }) - - BeforeEach(func() { - mockSched.Reset() - mockTimers.Reset() - testService.ClearSchedules() - }) - - Describe("Plugin Loading", func() { - It("should detect scheduler capability", func() { - names := manager.PluginNames(string(CapabilityScheduler)) - Expect(names).To(ContainElement("test-scheduler")) - }) - - It("should register scheduler service for plugin", func() { - service := findSchedulerService(manager, "test-scheduler") - Expect(service).ToNot(BeNil()) - }) - }) - - Describe("ScheduleOneTime", func() { - It("should schedule a one-time task", func() { - scheduleID, err := testService.ScheduleOneTime(GinkgoT().Context(), 1, "test-payload", "test-id") - Expect(err).ToNot(HaveOccurred()) - Expect(scheduleID).To(Equal("test-id")) - - // Verify schedule was registered - Expect(testService.GetScheduleCount()).To(Equal(1)) - Expect(mockTimers.GetTimerCount()).To(Equal(1)) - }) - - It("should invoke plugin callback and auto-cleanup after firing", func() { - _, err := testService.ScheduleOneTime(GinkgoT().Context(), 1, "data", "cleanup-id") - Expect(err).ToNot(HaveOccurred()) - Expect(testService.GetScheduleCount()).To(Equal(1)) - - // Trigger fires the callback which calls the plugin's nd_scheduler_callback - // One-time schedules clean up after the callback completes - mockTimers.TriggerAll() - - // One-time schedules should self-cleanup - Expect(testService.GetScheduleCount()).To(Equal(0)) - }) - - It("should reject duplicate schedule ID", func() { - _, err := testService.ScheduleOneTime(GinkgoT().Context(), 60, "data", "dup-id") - Expect(err).ToNot(HaveOccurred()) - - _, err = testService.ScheduleOneTime(GinkgoT().Context(), 60, "data2", "dup-id") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("already exists")) - }) - - It("should auto-generate schedule ID when empty", func() { - scheduleID, err := testService.ScheduleOneTime(GinkgoT().Context(), 1, "data", "") - Expect(err).ToNot(HaveOccurred()) - Expect(scheduleID).ToNot(BeEmpty()) - }) - }) - - Describe("ScheduleRecurring", func() { - It("should schedule recurring tasks", func() { - scheduleID, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "recurring-data", "recurring-id") - Expect(err).ToNot(HaveOccurred()) - Expect(scheduleID).To(Equal("recurring-id")) - - // Verify schedule was registered - Expect(testService.GetScheduleCount()).To(Equal(1)) - entry := testService.GetSchedule("recurring-id") - Expect(entry).ToNot(BeNil()) - Expect(entry.isRecurring).To(BeTrue()) - }) - - It("should invoke plugin callback multiple times without self-canceling", func() { - _, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "data", "persist-id") - Expect(err).ToNot(HaveOccurred()) - - // Trigger multiple times - recurring schedules should persist - mockSched.TriggerAll() - mockSched.TriggerAll() - - // Recurring schedules should persist - Expect(testService.GetScheduleCount()).To(Equal(1)) - }) - }) - - Describe("Plugin Calling Host Functions", func() { - It("should allow plugin to schedule a one-time task from callback", func() { - // Schedule with magic payload that triggers plugin to call SchedulerScheduleOneTime - _, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "schedule-followup", "trigger-id") - Expect(err).ToNot(HaveOccurred()) - Expect(testService.GetScheduleCount()).To(Equal(1)) - - // Trigger - plugin callback will schedule a follow-up task - mockSched.TriggerAll() - - // Verify the plugin created a new schedule via host function - Expect(testService.GetScheduleCount()).To(Equal(2)) // original + followup - - // Verify the follow-up schedule was created with correct ID and properties - followup := testService.GetSchedule("followup-id") - Expect(followup).ToNot(BeNil()) - Expect(followup.payload).To(Equal("followup-created")) - Expect(followup.isRecurring).To(BeFalse()) - Expect(followup.timer).ToNot(BeNil()) // One-time tasks use timers - }) - - It("should allow plugin to schedule a recurring task from callback", func() { - _, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "schedule-recurring", "trigger-id") - Expect(err).ToNot(HaveOccurred()) - - mockSched.TriggerAll() - - // Verify the plugin created a recurring schedule - entry := testService.GetSchedule("recurring-from-plugin") - Expect(entry).ToNot(BeNil()) - Expect(entry.isRecurring).To(BeTrue()) - Expect(entry.payload).To(Equal("recurring-created")) - }) - }) - - Describe("CancelSchedule", func() { - It("should cancel a recurring task", func() { - _, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "data", "cancel-id") - Expect(err).ToNot(HaveOccurred()) - Expect(testService.GetScheduleCount()).To(Equal(1)) - - err = testService.CancelSchedule(GinkgoT().Context(), "cancel-id") - Expect(err).ToNot(HaveOccurred()) - Expect(testService.GetScheduleCount()).To(Equal(0)) - }) - - It("should cancel a one-time task", func() { - _, err := testService.ScheduleOneTime(GinkgoT().Context(), 60, "data", "cancel-onetime-id") - Expect(err).ToNot(HaveOccurred()) - Expect(testService.GetScheduleCount()).To(Equal(1)) - Expect(mockTimers.GetTimerCount()).To(Equal(1)) - - err = testService.CancelSchedule(GinkgoT().Context(), "cancel-onetime-id") - Expect(err).ToNot(HaveOccurred()) - Expect(testService.GetScheduleCount()).To(Equal(0)) - }) - - It("should remove callback from scheduler for recurring tasks", func() { - _, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 1s", "data", "cancel-id") - Expect(err).ToNot(HaveOccurred()) - Expect(mockSched.GetCallbackCount()).To(Equal(1)) - - err = testService.CancelSchedule(GinkgoT().Context(), "cancel-id") - Expect(err).ToNot(HaveOccurred()) - Expect(mockSched.GetCallbackCount()).To(Equal(0)) - }) - - It("should return error for non-existent schedule", func() { - err := testService.CancelSchedule(GinkgoT().Context(), "non-existent") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not found")) - }) - }) - - Describe("Scheduler Service Isolation", func() { - It("should share the same scheduler service across multiple plugin instances", func() { - // This test verifies that when we call plugin.instance() multiple times - // (creating multiple instances from the same compiled plugin), they all - // share the same scheduler service. This is the expected behavior since - // the scheduler service is registered once per plugin at compile time. - - // Get the plugin - manager.mu.RLock() - plugin, ok := manager.plugins["test-scheduler"] - manager.mu.RUnlock() - Expect(ok).To(BeTrue()) - - // Schedule a task using the service directly - _, err := testService.ScheduleOneTime(GinkgoT().Context(), 60, "shared-data", "shared-id") - Expect(err).ToNot(HaveOccurred()) - Expect(testService.GetScheduleCount()).To(Equal(1)) - - // Create a plugin instance - instance, err := plugin.instance(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - defer instance.Close(GinkgoT().Context()) - - // The scheduler service is shared, so the schedule ID should clash - // if another instance tries to use the same ID - _, err = testService.ScheduleOneTime(GinkgoT().Context(), 60, "other-data", "shared-id") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("already exists")) - - // But different IDs should work fine - _, err = testService.ScheduleOneTime(GinkgoT().Context(), 60, "instance2-data", "otherx-id") - Expect(err).ToNot(HaveOccurred()) - Expect(testService.GetScheduleCount()).To(Equal(2)) - }) - }) - - Describe("Plugin Unload", func() { - It("should cancel all schedules when plugin is unloaded", func() { - _, err := testService.ScheduleRecurring(GinkgoT().Context(), "@every 10s", "data1", "unload-1") - Expect(err).ToNot(HaveOccurred()) - _, err = testService.ScheduleOneTime(GinkgoT().Context(), 60, "data2", "unload-2") - Expect(err).ToNot(HaveOccurred()) - Expect(testService.GetScheduleCount()).To(Equal(2)) - Expect(mockSched.GetCallbackCount()).To(Equal(1)) // Only recurring task uses scheduler - Expect(mockTimers.GetTimerCount()).To(Equal(1)) // Only one-time task uses timer - - err = manager.unloadPlugin("test-scheduler") - Expect(err).ToNot(HaveOccurred()) - - Expect(findSchedulerService(manager, "test-scheduler")).To(BeNil()) - Expect(mockSched.GetCallbackCount()).To(Equal(0)) // Recurring task removed - }) - }) -}) - -// testableSchedulerService wraps schedulerServiceImpl with test helpers. -type testableSchedulerService struct { - *schedulerServiceImpl -} - -func (t *testableSchedulerService) GetScheduleCount() int { - t.mu.Lock() - defer t.mu.Unlock() - return len(t.schedules) -} - -func (t *testableSchedulerService) GetSchedule(id string) *scheduleEntry { - t.mu.Lock() - defer t.mu.Unlock() - return t.schedules[id] -} - -func (t *testableSchedulerService) ClearSchedules() { - t.mu.Lock() - defer t.mu.Unlock() - t.schedules = make(map[string]*scheduleEntry) -} - -// mockScheduler implements scheduler.Scheduler for testing without timing dependencies. -type mockScheduler struct { - mu sync.Mutex - callbacks map[int]func() - nextID int -} - -func newMockScheduler() *mockScheduler { - return &mockScheduler{ - callbacks: make(map[int]func()), - nextID: 1, - } -} - -func (s *mockScheduler) Run(_ context.Context) {} - -func (s *mockScheduler) Add(_ string, cmd func()) (int, error) { - s.mu.Lock() - defer s.mu.Unlock() - id := s.nextID - s.nextID++ - s.callbacks[id] = cmd - return id, nil -} - -func (s *mockScheduler) Remove(id int) { - s.mu.Lock() - defer s.mu.Unlock() - delete(s.callbacks, id) -} - -func (s *mockScheduler) TriggerAll() { - s.mu.Lock() - callbacks := make([]func(), 0, len(s.callbacks)) - for _, cb := range s.callbacks { - callbacks = append(callbacks, cb) - } - s.mu.Unlock() - for _, cb := range callbacks { - cb() - } -} - -func (s *mockScheduler) GetCallbackCount() int { - s.mu.Lock() - defer s.mu.Unlock() - return len(s.callbacks) -} - -func (s *mockScheduler) Reset() { - s.mu.Lock() - defer s.mu.Unlock() - s.callbacks = make(map[int]func()) - s.nextID = 1 -} - -var _ scheduler.Scheduler = (*mockScheduler)(nil) - -// mockTimerRegistry tracks mock timers created during tests. -type mockTimerRegistry struct { - mu sync.Mutex - callbacks []func() - timers []*time.Timer -} - -func newMockTimerRegistry() *mockTimerRegistry { - return &mockTimerRegistry{ - callbacks: make([]func(), 0), - timers: make([]*time.Timer, 0), - } -} - -// AfterFunc creates a timer that we control for testing. -func (r *mockTimerRegistry) AfterFunc(_ time.Duration, f func()) *time.Timer { - r.mu.Lock() - defer r.mu.Unlock() - - // Store callback for TriggerAll - r.callbacks = append(r.callbacks, f) - - // Create a real timer that won't fire (very long duration, immediately stopped) - t := time.NewTimer(time.Hour * 24 * 365) - t.Stop() - r.timers = append(r.timers, t) - - return t -} - -// TriggerAll fires all pending timer callbacks. -func (r *mockTimerRegistry) TriggerAll() { - r.mu.Lock() - callbacks := make([]func(), len(r.callbacks)) - copy(callbacks, r.callbacks) - r.mu.Unlock() - - for _, cb := range callbacks { - cb() - } -} - -func (r *mockTimerRegistry) GetTimerCount() int { - r.mu.Lock() - defer r.mu.Unlock() - return len(r.callbacks) -} - -func (r *mockTimerRegistry) Reset() { - r.mu.Lock() - defer r.mu.Unlock() - r.callbacks = make([]func(), 0) - r.timers = make([]*time.Timer, 0) -} - -// findSchedulerService finds the scheduler service from a plugin's closers. -func findSchedulerService(m *Manager, pluginName string) *schedulerServiceImpl { - m.mu.RLock() - instance, ok := m.plugins[pluginName] - m.mu.RUnlock() - if !ok { - return nil - } - for _, closer := range instance.closers { - if svc, ok := closer.(*schedulerServiceImpl); ok { - return svc - } - } - return nil -} diff --git a/plugins/host_scrobbleretriever.go b/plugins/host_scrobbleretriever.go deleted file mode 100644 index 780b0274d..000000000 --- a/plugins/host_scrobbleretriever.go +++ /dev/null @@ -1,161 +0,0 @@ -package plugins - -import ( - "context" - "fmt" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/plugins/host" -) - -// maxScrobbleItems caps how many scrobbles a single GetScrobbles call can return. -const maxScrobbleItems = 5000 - -type scrobbleRetrieverServiceImpl struct { - ds model.DataStore - users userAccess -} - -func newScrobbleRetrieverService(ds model.DataStore, users userAccess) host.ScrobbleRetrieverService { - return &scrobbleRetrieverServiceImpl{ - ds: ds, - users: users, - } -} - -func (s *scrobbleRetrieverServiceImpl) getUserContext(ctx context.Context, username string) (context.Context, error) { - usr, err := s.users.resolve(ctx, s.ds, username) - if err != nil { - return nil, fmt.Errorf("scrobbleRetriever: %w", err) - } - - ctx = request.WithUser(ctx, *usr) - return ctx, nil -} - -func (s *scrobbleRetrieverServiceImpl) getFirstLastScrobble(ctx context.Context, username string, order string) (*int64, error) { - ctx, err := s.getUserContext(ctx, username) - if err != nil { - return nil, err - } - - scrobbles, err := s.ds.Scrobble().GetAll(ctx, model.QueryOptions{Sort: "submission_time", Order: order, Max: 1}) - if err != nil { - return nil, err - } - - if len(scrobbles) == 0 { - return nil, nil - } - - return &scrobbles[0].SubmissionTime, nil -} - -func (s *scrobbleRetrieverServiceImpl) GetFirstTimestamp(ctx context.Context, username string) (*int64, error) { - return s.getFirstLastScrobble(ctx, username, "ASC") -} - -func (s *scrobbleRetrieverServiceImpl) GetLastTimestamp(ctx context.Context, username string) (*int64, error) { - return s.getFirstLastScrobble(ctx, username, "DESC") -} - -func (s *scrobbleRetrieverServiceImpl) GetScrobbles(ctx context.Context, username string, options host.ScrobbleOptions) ([]host.ScrobbleRef, *host.ScrobbleOptions, error) { - ctx, err := s.getUserContext(ctx, username) - if err != nil { - return nil, nil, err - } - - if options.MaxItems < 1 || options.MaxItems > maxScrobbleItems { - options.MaxItems = maxScrobbleItems - } - options.Offset = max(options.Offset, 0) - - order := "ASC" - if options.Descending { - order = "DESC" - } - - // Fetch one more item than requested. The last item is the next timestamp to fetch - lookahead := options.MaxItems + 1 - - scrobbles, err := s.ds.Scrobble().GetAll(ctx, model.QueryOptions{ - Max: lookahead, - Filters: scrobbleRangeFilters(options.FromTimestamp, options.ToTimestamp), - // The id tiebreak makes the order of equal timestamps stable, which is what - // lets Offset skip exactly the ties already returned - Sort: "scrobbles.submission_time, scrobbles.id", - Order: order, - Offset: options.Offset, - }) - if err != nil { - return nil, nil, err - } - - var next *host.ScrobbleOptions - targetLen := len(scrobbles) - - if len(scrobbles) == lookahead { - nextTimestamp := scrobbles[lookahead-1].SubmissionTime - targetLen = lookahead - 1 - - ties := 0 - for i := targetLen - 1; i >= 0; i-- { - if scrobbles[i].SubmissionTime != nextTimestamp { - break - } - ties++ - } - - // Every scrobble in this page shares the timestamp, so the ties skipped by the - // incoming offset are still ahead of us and must be carried over - if ties == targetLen { - ties += options.Offset - } - - advanced := options - advanced.Offset = ties - if options.Descending { - advanced.ToTimestamp = &nextTimestamp - } else { - advanced.FromTimestamp = &nextTimestamp - } - next = &advanced - } - - scrobbleRefs := make([]host.ScrobbleRef, targetLen) - for idx, scrobble := range scrobbles[:targetLen] { - scrobbleRefs[idx] = host.ScrobbleRef{ - ID: scrobble.ID, - MediaFileID: scrobble.MediaFileID, - SubmissionTime: scrobble.SubmissionTime, - } - } - - return scrobbleRefs, next, nil -} - -func (s *scrobbleRetrieverServiceImpl) GetScrobbleCount(ctx context.Context, username string, options host.ScrobbleCountOptions) (int64, error) { - ctx, err := s.getUserContext(ctx, username) - if err != nil { - return 0, err - } - - return s.ds.Scrobble().CountAll(ctx, model.QueryOptions{ - Filters: scrobbleRangeFilters(options.FromTimestamp, options.ToTimestamp), - }) -} - -func scrobbleRangeFilters(from, to *int64) squirrel.And { - var filters squirrel.And - if from != nil { - filters = append(filters, squirrel.GtOrEq{"scrobbles.submission_time": *from}) - } - if to != nil { - filters = append(filters, squirrel.LtOrEq{"scrobbles.submission_time": *to}) - } - return filters -} - -var _ host.ScrobbleRetrieverService = (*scrobbleRetrieverServiceImpl)(nil) diff --git a/plugins/host_scrobbleretriever_test.go b/plugins/host_scrobbleretriever_test.go deleted file mode 100644 index 6721c1487..000000000 --- a/plugins/host_scrobbleretriever_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package plugins - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "os" - "path/filepath" - "strconv" - "time" - - extism "github.com/extism/go-sdk" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/plugins/host" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// scrobblePage mirrors what the test plugin emits for the multi-return GetScrobbles -type scrobblePage struct { - Scrobbles []host.ScrobbleRef `json:"scrobbles"` - Next *host.ScrobbleOptions `json:"next"` -} - -var _ = Describe("Scrobble Retriever Host Function", Ordered, func() { - var ( - manager *Manager - tmpDir string - dataStore *tests.MockDataStore - ) - - p := func(val int64) *int64 { - return &val - } - - opts := func(from, to *int64, descending bool, offset, maxItems int) *host.ScrobbleOptions { - return &host.ScrobbleOptions{ - FromTimestamp: from, ToTimestamp: to, - Descending: descending, Offset: offset, MaxItems: maxItems, - } - } - - BeforeAll(func() { - ctx := GinkgoT().Context() - - var err error - tmpDir, err = os.MkdirTemp("", "scrobble-retriever-test-*") - Expect(err).ToNot(HaveOccurred()) - - conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") - - db.Init(ctx) - DeferCleanup(func() { - Expect(tests.ClearDB()).To(Succeed()) - }) - dataStore = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - - // Copy test plugin to temp dir - srcPath := filepath.Join(testdataDir, "test-scrobble-retriever"+PackageExtension) - destPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Setup config - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - - userRepo := dataStore.User() - // Add test users - _ = userRepo.Put(ctx, &model.User{ - ID: "user1", - UserName: "testuser", - IsAdmin: false, - }) - _ = userRepo.Put(ctx, &model.User{ - ID: "admin1", - UserName: "adminuser", - IsAdmin: true, - }) - - err = dataStore.MediaFile().Put(ctx, &model.MediaFile{ID: "1", LibraryID: 1}) - Expect(err).To(BeNil()) - err = dataStore.MediaFile().Put(ctx, &model.MediaFile{ID: "2", LibraryID: 1}) - Expect(err).To(BeNil()) - err = dataStore.MediaFile().Put(ctx, &model.MediaFile{ID: "3", LibraryID: 1}) - Expect(err).To(BeNil()) - - scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"}) - - scrobbleRepo := dataStore.Scrobble() - err = scrobbleRepo.RecordScrobble(scrobbleCtx, "1", time.Unix(0, 0)) - Expect(err).To(BeNil()) - err = scrobbleRepo.RecordScrobble(scrobbleCtx, "2", time.Unix(1, 0)) - Expect(err).To(BeNil()) - err = scrobbleRepo.RecordScrobble(scrobbleCtx, "3", time.Unix(2, 0)) - Expect(err).To(BeNil()) - err = scrobbleRepo.RecordScrobble(scrobbleCtx, "1", time.Unix(2, 0)) - Expect(err).To(BeNil()) - - // Create and configure manager - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - } - router := &fakeSubsonicRouter{} - manager.SetSubsonicRouter(router) - - // Pre-enable the plugin in the mock repo so it loads on startup - // Compute SHA256 of the plugin file to match what syncPlugins will compute - pluginPath := filepath.Join(tmpDir, "test-scrobble-retriever"+PackageExtension) - wasmData, err := os.ReadFile(pluginPath) - Expect(err).ToNot(HaveOccurred()) - hash := sha256.Sum256(wasmData) - hashHex := hex.EncodeToString(hash[:]) - - dataStore.MockedPlugin = tests.CreateMockPluginRepo() - - mockPluginRepo := dataStore.Plugin().(*tests.MockPluginRepo) - mockPluginRepo.Permitted = true - enabledPlugin := model.Plugin{ - ID: "test-scrobble-retriever", - Path: pluginPath, - SHA256: hashHex, - Enabled: true, - Users: `["user1","admin1"]`, - } - mockPluginRepo.SetData(model.Plugins{enabledPlugin}) - - // Start the manager - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) - }) - - var instance *extism.Plugin - - BeforeEach(func() { - manager.mu.RLock() - plugin := manager.plugins["test-scrobble-retriever"] - manager.mu.RUnlock() - Expect(plugin).ToNot(BeNil()) - - var err error - ctx := GinkgoT().Context() - instance, err = plugin.instance(ctx) - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - instance.Close(ctx) - }) - }) - - Describe("not authorized", func() { - It("rejects first timestamp", func() { - exit, _, err := instance.Call("call_get_first_timestamp", []byte("baduser")) - Expect(err).To(HaveOccurred()) - Expect(exit).To(Equal(uint32(1))) - }) - - It("rejects last timestamp", func() { - exit, _, err := instance.Call("call_get_last_timestamp", []byte("baduser")) - Expect(err).To(HaveOccurred()) - Expect(exit).To(Equal(uint32(1))) - }) - - It("rejects scrobbles", func() { - exit, _, err := instance.Call("call_get_scrobbles", []byte(`{"username":"baduser"}`)) - Expect(err).To(HaveOccurred()) - Expect(exit).To(Equal(uint32(1))) - }) - - It("rejects scrobbles", func() { - exit, _, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"baduser"}`)) - Expect(err).To(HaveOccurred()) - Expect(exit).To(Equal(uint32(1))) - }) - }) - - Describe("no items", func() { - It("calls get first timestamp", func() { - exit, output, err := instance.Call("call_get_first_timestamp", []byte("testuser")) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - Expect(output).To(Equal([]byte("{\"timestamp\":null}"))) - }) - - It("calls get last timestamp", func() { - exit, output, err := instance.Call("call_get_last_timestamp", []byte("testuser")) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - Expect(output).To(Equal([]byte("{\"timestamp\":null}"))) - }) - - It("calls scrobbles", func() { - exit, output, err := instance.Call("call_get_scrobbles", []byte(`{"username":"testuser"}`)) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - Expect(output).To(Equal([]byte(`{"scrobbles":[],"next":null}`))) - }) - - It("calls get scrobble count", func() { - exit, output, err := instance.Call("call_get_scrobbles_count", []byte(`{"username":"testuser"}`)) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - Expect(output).To(Equal([]byte("0"))) - }) - }) - - Describe("with items", func() { - scrobbles := []host.ScrobbleRef{ - {ID: 1, MediaFileID: "1", SubmissionTime: 0}, - {ID: 2, MediaFileID: "2", SubmissionTime: 1}, - {ID: 3, MediaFileID: "3", SubmissionTime: 2}, - {ID: 4, MediaFileID: "1", SubmissionTime: 2}, - } - - scrobblesReversed := make([]host.ScrobbleRef, 4) - - BeforeAll(func() { - for idx := range scrobbles { - scrobblesReversed[3-idx] = scrobbles[idx] - } - }) - - It("calls get first timestamp", func() { - exit, output, err := instance.Call("call_get_first_timestamp", []byte("adminuser")) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - Expect(output).To(Equal([]byte("{\"timestamp\":0}"))) - }) - - It("calls get last timestamp", func() { - exit, output, err := instance.Call("call_get_last_timestamp", []byte("adminuser")) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - Expect(output).To(Equal([]byte("{\"timestamp\":2}"))) - }) - - DescribeTable("getScrobbles", func(params string, scrobbles []host.ScrobbleRef, next *host.ScrobbleOptions) { - exit, output, err := instance.Call("call_get_scrobbles", []byte(params)) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - var page scrobblePage - Expect(json.Unmarshal(output, &page)).To(Succeed()) - Expect(page).To(Equal(scrobblePage{Scrobbles: scrobbles, Next: next})) - }, - Entry("calls scrobbles in ascending order", `{"username":"adminuser"}`, scrobbles, nil), - Entry("calls scrobbles in descending order by request", `{"username":"adminuser","descending":true}`, scrobblesReversed, nil), - Entry("calls scrobbles in ascending order, beyond range", `{"username":"adminuser","fromTimestamp":-1, "toTimestamp": 1000}`, scrobbles, nil), - Entry("defaults to ascending even when only toTimestamp is given", `{"username":"adminuser","toTimestamp":2}`, scrobbles, nil), - Entry("calls subset of scrobbles in ascending order, next page", `{"username":"adminuser","maxItems":2}`, scrobbles[:2], opts(p(2), nil, false, 0, 2)), - Entry("calls subset of scrobbles in ascending order, next page with offset", `{"username":"adminuser","maxItems":2,"fromTimestamp":1}`, scrobbles[1:3], opts(p(2), nil, false, 1, 2)), - Entry("calls subset of scrobbles in ascending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1}`, scrobbles[1:], nil), - Entry("calls subset of scrobbles in descending order, from and to timestamp", `{"username":"adminuser","toTimestamp":2,"fromTimestamp":1,"descending":true}`, scrobblesReversed[:3], nil), - Entry("calls in reverse order, full", `{"username":"adminuser","toTimestamp":2,"descending":true}`, scrobblesReversed, nil), - Entry("calls in reverse order, with count", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 3}`, scrobblesReversed[:3], opts(nil, p(0), true, 0, 3)), - Entry("calls in reverse order, with count of 1", `{"username":"adminuser","toTimestamp":2,"descending":true, "maxItems": 1}`, scrobblesReversed[:1], opts(nil, p(2), true, 1, 1)), - ) - - DescribeTable("GetScrobblesCount", func(params string, count int) { - exit, output, err := instance.Call("call_get_scrobbles_count", []byte(params)) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - value, err := strconv.ParseInt(string(output), 10, 64) - Expect(err).ToNot(HaveOccurred()) - Expect(value).To(Equal(int64(count))) - }, - Entry("gets all scrobbles", `{"username":"adminuser"}`, 4), - Entry("gets two scrobbles ascending", `{"username":"adminuser", "fromTimestamp": 2}`, 2), - Entry("gets one scrobble descending", `{"username":"adminuser", "toTimestamp": 0}`, 1), - Entry("filters upper and bottom", `{"username":"adminuser", "fromTimestamp": 1, "toTimestamp": 1}`, 1), - Entry("accepts filter out of range", `{"username":"adminuser", "fromTimestamp": -1, "toTimestamp": 1000}`, 4), - ) - }) - - Context("Complex edge cases - multiple scrobbles at the same timestamp", func() { - duplicates := make([]host.ScrobbleRef, 5) - duplicatesReversed := make([]host.ScrobbleRef, 5) - - BeforeAll(func() { - scrobbleCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin1", UserName: "adminuser"}) - - scrobbleRepo := dataStore.Scrobble() - - for i := range 5 { - err := scrobbleRepo.RecordScrobble(scrobbleCtx, "3", time.Unix(100, 0)) - Expect(err).To(BeNil()) - - scrobble := host.ScrobbleRef{ID: 5 + int64(i), MediaFileID: "3", SubmissionTime: 100} - duplicates[i] = scrobble - duplicatesReversed[4-i] = scrobble - } - }) - - getPage := func(o host.ScrobbleOptions) scrobblePage { - GinkgoHelper() - payload, err := json.Marshal(struct { - Username string `json:"username"` - FromTimestamp *int64 `json:"fromTimestamp,omitempty"` - ToTimestamp *int64 `json:"toTimestamp,omitempty"` - Descending bool `json:"descending"` - Offset int `json:"offset,omitempty"` - MaxItems int `json:"maxItems"` - }{ - Username: "adminuser", - FromTimestamp: o.FromTimestamp, - ToTimestamp: o.ToTimestamp, - Descending: o.Descending, - Offset: o.Offset, - MaxItems: o.MaxItems, - }) - Expect(err).ToNot(HaveOccurred()) - - exit, output, err := instance.Call("call_get_scrobbles", payload) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - var page scrobblePage - Expect(json.Unmarshal(output, &page)).To(Succeed()) - return page - } - - // every scrobble here shares timestamp 100, so only Offset advances between pages - tied := func(offset, maxItems int, descending bool) host.ScrobbleOptions { - return *opts(p(100), p(100), descending, offset, maxItems) - } - - DescribeTable("Edge cases; duplicate scrobbles", func(offset, count int, descending bool, scrobbles []host.ScrobbleRef, nextOffset *int) { - var next *host.ScrobbleOptions - if nextOffset != nil { - next = opts(p(100), p(100), descending, *nextOffset, count) - } - Expect(getPage(tied(offset, count, descending))).To(Equal(scrobblePage{Scrobbles: scrobbles, Next: next})) - }, - Entry("All tracks, in order", 0, 100, false, duplicates, nil), - Entry("All tracks, in reverse order", 0, 100, true, duplicatesReversed, nil), - Entry("Ascending order, from the start", 0, 1, false, duplicates[:1], new(1)), - Entry("Ascending order, middle page", 1, 2, false, duplicates[1:3], new(3)), - Entry("Ascending order, to the end", 3, 100, false, duplicates[3:], nil), - Entry("Ascending order, from the start, continuing offset", 3, 1, false, duplicates[3:4], new(4)), - Entry("start descending", 0, 2, true, duplicatesReversed[:2], new(2)), - Entry("start descending, next step", 2, 2, true, duplicatesReversed[2:4], new(4)), - Entry("start descending, end", 4, 1, true, duplicatesReversed[4:], nil), - ) - - It("walks every page exactly once when all timestamps collide", func() { - for _, descending := range []bool{false, true} { - var seen []host.ScrobbleRef - o := tied(0, 2, descending) - var page scrobblePage - for range 10 { - page = getPage(o) - seen = append(seen, page.Scrobbles...) - if page.Next == nil { - break - } - o = *page.Next - } - Expect(page.Next).To(BeNil(), "pagination did not terminate") - if descending { - Expect(seen).To(Equal(duplicatesReversed)) - } else { - Expect(seen).To(Equal(duplicates)) - } - } - }) - }) -}) diff --git a/plugins/host_storage.go b/plugins/host_storage.go deleted file mode 100644 index e2ead7aae..000000000 --- a/plugins/host_storage.go +++ /dev/null @@ -1,34 +0,0 @@ -package plugins - -import ( - "context" - "fmt" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/plugins/host" -) - -const storageMount = "/storage" - -type storageServiceImpl struct{} - -func getHostStoragePath(pluginName string) string { - return filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName, "storage") -} - -func newStorageService(pluginName string) (host.StorageService, error) { - dataDir := getHostStoragePath(pluginName) - if err := os.MkdirAll(dataDir, 0700); err != nil { - return nil, fmt.Errorf("creating plugin data directory: %w", err) - } - - return &storageServiceImpl{}, nil -} - -func (s *storageServiceImpl) GetStoragePath(ctx context.Context) string { - return storageMount -} - -var _ host.StorageService = (*storageServiceImpl)(nil) diff --git a/plugins/host_storage_test.go b/plugins/host_storage_test.go deleted file mode 100644 index 9d8df23a0..000000000 --- a/plugins/host_storage_test.go +++ /dev/null @@ -1,268 +0,0 @@ -package plugins - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "os" - "path/filepath" - - extism "github.com/extism/go-sdk" - "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("utility functions", Ordered, func() { - var tmpDir string - - BeforeAll(func() { - var err error - tmpDir, err = os.MkdirTemp("", "storage-test-*") - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir(tmpDir) - - DeferCleanup(func() { - _ = os.RemoveAll(tmpDir) - }) - }) - - Describe("GetHostStoragePath", func() { - It("should join data folder, plugins, plugin name, and storage", func() { - actual := getHostStoragePath("plugin-name") - expected := filepath.Join(tmpDir, "plugins", "plugin-name", "storage") - Expect(actual).To(Equal(expected)) - }) - }) - - Describe("GetStoragePath", func() { - It("should return the fixed path", func() { - impl := storageServiceImpl{} - Expect(impl.GetStoragePath(context.TODO())).To(Equal("/storage")) - }) - }) - - Describe("netStorageService", func() { - It("should create the directory on init", func() { - svc, err := newStorageService("plugin-name") - Expect(err).ToNot(HaveOccurred()) - Expect(svc).ToNot(BeNil()) - - dataDir := filepath.Join(tmpDir, "plugins", "plugin-name", "storage") - Expect(dataDir).To(BeADirectory()) - }) - }) -}) - -var _ = Describe("Storage Host Function", Ordered, func() { - const ID = "test-storage-plugin" - - var ( - manager *Manager - tmpDir string - ) - - BeforeAll(func() { - var err error - tmpDir, err = os.MkdirTemp("", "storage-test-*") - Expect(err).ToNot(HaveOccurred()) - - // Setup mock router and data store - router := &fakeSubsonicRouter{} - userRepo := tests.CreateMockUserRepo() - dataStore := &tests.MockDataStore{MockedUser: userRepo} - - // Create and configure manager - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - } - manager.SetSubsonicRouter(router) - - mockPluginRepo := dataStore.Plugin().(*tests.MockPluginRepo) - mockPluginRepo.Permitted = true - - // Setup config - DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir(tmpDir) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - - pluginPaths := []string{ID, ID + "-2"} - plugins := []model.Plugin{} - - for idx := range pluginPaths { - path := pluginPaths[idx] + PackageExtension - // Copy test plugin to temp dir - srcPath := filepath.Join(testdataDir, path) - destPath := filepath.Join(tmpDir, path) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Pre-enable the plugin in the mock repo so it loads on startup - // Compute SHA256 of the plugin file to match what syncPlugins will compute - pluginPath := filepath.Join(tmpDir, path) - wasmData, err := os.ReadFile(pluginPath) - Expect(err).ToNot(HaveOccurred()) - hash := sha256.Sum256(wasmData) - hashHex := hex.EncodeToString(hash[:]) - - plugins = append(plugins, model.Plugin{ - ID: pluginPaths[idx], - Path: pluginPath, - SHA256: hashHex, - Enabled: true, - AllUsers: true, // Allow all users for test plugin - }) - } - - mockPluginRepo.SetData(plugins) - - // Start the manager - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) - }) - - var instance *extism.Plugin - BeforeEach(func() { - var err error - manager.mu.RLock() - plugin := manager.plugins[ID] - manager.mu.RUnlock() - Expect(plugin).ToNot(BeNil()) - - ctx := GinkgoT().Context() - instance, err = plugin.instance(ctx) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - instance.Close(ctx) - }) - }) - - Describe("Read", func() { - BeforeAll(func() { - path := filepath.Join(getHostStoragePath(ID), "real") - err := os.WriteFile(path, []byte("1234"), 0600) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = os.Remove(path) - }) - }) - - It("should fail to read missing file", func() { - exit, _, err := instance.Call("call_read", []byte("missing")) - Expect(exit).To(Equal(uint32(1))) - Expect(err).To(HaveOccurred()) - }) - - It("should read an existing file", func() { - exit, output, err := instance.Call("call_read", []byte("real")) - Expect(exit).To(Equal(uint32(0))) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal([]byte("1234"))) - }) - - It("should not escape read", func() { - path := filepath.Join(getHostStoragePath(ID), "..", "outside") - err := os.WriteFile(path, []byte("outside"), 0600) - Expect(err).ToNot(HaveOccurred()) - - exit, _, err := instance.Call("call_read", []byte("../outside")) - Expect(exit).To(Equal(uint32(1))) - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("Write", func() { - BeforeAll(func() { - path := filepath.Join(getHostStoragePath(ID), "real") - err := os.WriteFile(path, []byte("1234"), 0600) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = os.Remove(path) - }) - }) - - It("should fail to write to nested file", func() { - exit, _, err := instance.Call("call_write", []byte(`{"path":"nested/file","contents":"1234"}`)) - Expect(exit).To(Equal(uint32(1))) - Expect(err).To(HaveOccurred()) - }) - - It("should write to a file", func() { - exit, _, err := instance.Call("call_write", []byte(`{"path":"new","contents":"contents"}`)) - Expect(exit).To(Equal(uint32(0))) - Expect(err).ToNot(HaveOccurred()) - - data, err := os.ReadFile(filepath.Join(getHostStoragePath(ID), "new")) - Expect(err).ToNot(HaveOccurred()) - Expect(data).To(Equal([]byte("contents"))) - - exit, output, err := instance.Call("call_read", []byte("new")) - Expect(exit).To(Equal(uint32(0))) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal([]byte("contents"))) - }) - - It("should not escape writing a file", func() { - path := filepath.Join(getHostStoragePath(ID), "..", "outside") - err := os.WriteFile(path, []byte("outside"), 0600) - Expect(err).ToNot(HaveOccurred()) - - exit, _, err := instance.Call("call_write", []byte(`{"path":"../new","contents":"contents"}`)) - Expect(exit).To(Equal(uint32(1))) - Expect(err).To(HaveOccurred()) - }) - - It("should have independent storage for multiple plugins", func() { - manager.mu.RLock() - plugin2 := manager.plugins[ID+"-2"] - manager.mu.RUnlock() - - Expect(plugin2).ToNot(BeNil()) - - secondInstance, err := plugin2.instance(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - defer secondInstance.Close(GinkgoT().Context()) - - instances := []*extism.Plugin{instance, secondInstance} - names := []string{ID, ID + "-2"} - - for idx := range instances { - exit, _, err := instances[idx].Call("call_write", fmt.Appendf(nil, `{"path":"new","contents":"%s"}`, names[idx])) - Expect(exit).To(Equal(uint32(0))) - Expect(err).ToNot(HaveOccurred()) - } - - for idx := range names { - data, err := os.ReadFile(filepath.Join(getHostStoragePath(names[idx]), "new")) - Expect(err).ToNot(HaveOccurred()) - Expect(data).To(Equal([]byte(names[idx]))) - } - - for idx := range instances { - exit, output, err := instances[idx].Call("call_read", []byte("new")) - Expect(exit).To(Equal(uint32(0))) - Expect(err).ToNot(HaveOccurred()) - Expect(output).To(Equal([]byte(names[idx]))) - } - }) - }) -}) diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go deleted file mode 100644 index a8ff12140..000000000 --- a/plugins/host_subsonicapi.go +++ /dev/null @@ -1,155 +0,0 @@ -package plugins - -import ( - "context" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "path" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/plugins/host" -) - -// subsonicAPIVersion is the Subsonic API version used for plugin calls. -// This is defined locally to avoid import cycle with server/subsonic. -const subsonicAPIVersion = "1.16.1" - -// subsonicAPIServiceImpl implements host.SubsonicAPIService. -// It provides plugins with access to Navidrome's Subsonic API. -// -// Authentication: The plugin must provide a valid 'u' (username) parameter in the URL. -// URL Format: Only the path and query parameters are used - host/protocol are ignored. -// Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format). -type subsonicAPIServiceImpl struct { - pluginID string - router SubsonicRouter - ds model.DataStore - users userAccess // users this plugin may act as (from DB configuration) -} - -// newSubsonicAPIService creates a new SubsonicAPIService for a plugin. -func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.DataStore, users userAccess) host.SubsonicAPIService { - return &subsonicAPIServiceImpl{ - pluginID: pluginID, - router: router, - ds: ds, - users: users, - } -} - -// executeRequest handles URL parsing, validation, permission checks, HTTP request creation, -// and router invocation. Shared between Call and CallRaw. -// If setJSON is true, the 'f=json' query parameter is added. -func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string, setJSON bool) (*httptest.ResponseRecorder, error) { - if s.router == nil { - return nil, fmt.Errorf("SubsonicAPI router not available") - } - - // Parse the input URL - parsedURL, err := url.Parse(uri) - if err != nil { - return nil, fmt.Errorf("invalid URL format: %w", err) - } - - // Extract query parameters - query := parsedURL.Query() - - // Validate that 'u' (username) parameter is present - username := query.Get("u") - if username == "" { - return nil, fmt.Errorf("missing required parameter 'u' (username)") - } - - if err := s.checkPermissions(ctx, username); err != nil { - log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err) - return nil, err - } - - // Add required Subsonic API parameters - query.Set("c", s.pluginID) // Client name (plugin ID) - query.Set("v", subsonicAPIVersion) // API version - if setJSON { - query.Set("f", "json") // Response format - } - - // Extract the endpoint from the path - endpoint := path.Base(parsedURL.Path) - - // Build the final URL with processed path and modified query parameters - finalURL := &url.URL{ - Path: "/" + endpoint, - RawQuery: query.Encode(), - } - - // Create HTTP request with a fresh context to avoid Chi RouteContext pollution. - // Using http.NewRequest (instead of http.NewRequestWithContext) ensures the internal - // SubsonicAPI call doesn't inherit routing information from the parent handler, - // which would cause Chi to invoke the wrong handler. Authentication context is - // explicitly added in the next step via request.WithInternalAuth. - httpReq, err := http.NewRequest("GET", finalURL.String(), nil) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP request: %w", err) - } - - // Set internal authentication context using the username from the 'u' parameter - authCtx := request.WithInternalAuth(httpReq.Context(), username) - httpReq = httpReq.WithContext(authCtx) - - // Use ResponseRecorder to capture the response - recorder := httptest.NewRecorder() - - // Call the subsonic router - s.router.ServeHTTP(recorder, httpReq) - - return recorder, nil -} - -func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) { - recorder, err := s.executeRequest(ctx, uri, true) - if err != nil { - return "", err - } - return recorder.Body.String(), nil -} - -func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (string, []byte, error) { - recorder, err := s.executeRequest(ctx, uri, false) - if err != nil { - return "", nil, err - } - contentType := recorder.Header().Get("Content-Type") - return contentType, recorder.Body.Bytes(), nil -} - -func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error { - // If allUsers is true, allow any user - if s.users.allUsers { - return nil - } - - // Must have at least one allowed user ID configured - if len(s.users.userIDSet) == 0 { - return fmt.Errorf("no users configured for plugin %s", s.pluginID) - } - - // Look up the user by username to get their ID - usr, err := s.ds.User().FindByUsername(ctx, username) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - return fmt.Errorf("username %s not found", username) - } - return err - } - - // Check if the user's ID is in the allowed list - if !s.users.allows(usr.ID) { - return fmt.Errorf("user %s is not authorized for this plugin", username) - } - - return nil -} diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go deleted file mode 100644 index 0d9c75ee7..000000000 --- a/plugins/host_subsonicapi_test.go +++ /dev/null @@ -1,463 +0,0 @@ -package plugins - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "net/http" - "os" - "path" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("SubsonicAPI Host Function", Ordered, func() { - var ( - manager *Manager - tmpDir string - router *fakeSubsonicRouter - userRepo *tests.MockedUserRepo - dataStore *tests.MockDataStore - ) - - BeforeAll(func() { - var err error - tmpDir, err = os.MkdirTemp("", "subsonicapi-test-*") - Expect(err).ToNot(HaveOccurred()) - - // Copy test plugin to temp dir - srcPath := filepath.Join(testdataDir, "test-subsonicapi-plugin"+PackageExtension) - destPath := filepath.Join(tmpDir, "test-subsonicapi-plugin"+PackageExtension) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Setup config - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - - // Setup mock router and data store - router = &fakeSubsonicRouter{} - userRepo = tests.CreateMockUserRepo() - dataStore = &tests.MockDataStore{MockedUser: userRepo} - - // Add test users - _ = userRepo.Put(GinkgoT().Context(), &model.User{ - ID: "user1", - UserName: "testuser", - IsAdmin: false, - }) - _ = userRepo.Put(GinkgoT().Context(), &model.User{ - ID: "admin1", - UserName: "adminuser", - IsAdmin: true, - }) - - // Create and configure manager - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - } - manager.SetSubsonicRouter(router) - - // Pre-enable the plugin in the mock repo so it loads on startup - // Compute SHA256 of the plugin file to match what syncPlugins will compute - pluginPath := filepath.Join(tmpDir, "test-subsonicapi-plugin"+PackageExtension) - wasmData, err := os.ReadFile(pluginPath) - Expect(err).ToNot(HaveOccurred()) - hash := sha256.Sum256(wasmData) - hashHex := hex.EncodeToString(hash[:]) - - mockPluginRepo := dataStore.Plugin().(*tests.MockPluginRepo) - mockPluginRepo.Permitted = true - enabledPlugin := model.Plugin{ - ID: "test-subsonicapi-plugin", - Path: pluginPath, - SHA256: hashHex, - Enabled: true, - AllUsers: true, // Allow all users for test plugin - } - mockPluginRepo.SetData(model.Plugins{enabledPlugin}) - - // Start the manager - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) - }) - - Describe("Plugin Loading", func() { - It("loads the plugin with SubsonicAPI permission", func() { - manager.mu.RLock() - plugin := manager.plugins["test-subsonicapi-plugin"] - manager.mu.RUnlock() - - Expect(plugin).ToNot(BeNil()) - }) - - It("has the correct manifest", func() { - manager.mu.RLock() - plugin := manager.plugins["test-subsonicapi-plugin"] - manager.mu.RUnlock() - - Expect(plugin).ToNot(BeNil()) - Expect(plugin.manifest.Name).To(Equal("Test SubsonicAPI Plugin")) - Expect(plugin.manifest.Permissions.Subsonicapi).ToNot(BeNil()) - }) - }) - - Describe("SubsonicAPI Call", func() { - var plugin *plugin - - BeforeEach(func() { - manager.mu.RLock() - plugin = manager.plugins["test-subsonicapi-plugin"] - manager.mu.RUnlock() - Expect(plugin).ToNot(BeNil()) - }) - - It("successfully calls the ping endpoint", func() { - instance, err := plugin.instance(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - defer instance.Close(GinkgoT().Context()) - - exit, output, err := instance.Call("call_subsonic_api", []byte("/ping?u=testuser")) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - // Verify the response contains the expected structure - var response map[string]any - err = json.Unmarshal(output, &response) - Expect(err).ToNot(HaveOccurred()) - - subsonicResponse, ok := response["subsonic-response"].(map[string]any) - Expect(ok).To(BeTrue()) - Expect(subsonicResponse["status"]).To(Equal("ok")) - }) - - It("adds required parameters (c, f, v) to the request", func() { - instance, err := plugin.instance(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - defer instance.Close(GinkgoT().Context()) - - _, _, err = instance.Call("call_subsonic_api", []byte("/getAlbumList?u=testuser&type=newest")) - Expect(err).ToNot(HaveOccurred()) - - // Verify the parameters were added - Expect(router.lastRequest).ToNot(BeNil()) - query := router.lastRequest.URL.Query() - Expect(query.Get("c")).To(Equal("test-subsonicapi-plugin")) - Expect(query.Get("f")).To(Equal("json")) - Expect(query.Get("v")).To(Equal("1.16.1")) - Expect(query.Get("type")).To(Equal("newest")) - }) - - It("returns error when username is missing", func() { - instance, err := plugin.instance(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - defer instance.Close(GinkgoT().Context()) - - exit, _, err := instance.Call("call_subsonic_api", []byte("/ping")) - Expect(err).To(HaveOccurred()) - Expect(exit).To(Equal(uint32(1))) - Expect(err.Error()).To(ContainSubstring("missing required parameter")) - }) - }) - - Describe("SubsonicAPI CallRaw", func() { - var plugin *plugin - - BeforeEach(func() { - manager.mu.RLock() - plugin = manager.plugins["test-subsonicapi-plugin"] - manager.mu.RUnlock() - Expect(plugin).ToNot(BeNil()) - }) - - It("successfully calls getCoverArt and returns binary data", func() { - instance, err := plugin.instance(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - defer instance.Close(GinkgoT().Context()) - - exit, output, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1")) - Expect(err).ToNot(HaveOccurred()) - Expect(exit).To(Equal(uint32(0))) - - // Parse the metadata response from the test plugin - var result map[string]any - err = json.Unmarshal(output, &result) - Expect(err).ToNot(HaveOccurred()) - Expect(result["contentType"]).To(Equal("image/png")) - Expect(result["size"]).To(BeNumerically("==", len(fakePNGHeader))) - Expect(result["firstByte"]).To(BeNumerically("==", 0x89)) // PNG magic byte - }) - - It("does NOT set f=json parameter for raw calls", func() { - instance, err := plugin.instance(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - defer instance.Close(GinkgoT().Context()) - - _, _, err = instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1")) - Expect(err).ToNot(HaveOccurred()) - - Expect(router.lastRequest).ToNot(BeNil()) - query := router.lastRequest.URL.Query() - Expect(query.Get("f")).To(BeEmpty()) - Expect(query.Get("c")).To(Equal("test-subsonicapi-plugin")) - Expect(query.Get("v")).To(Equal("1.16.1")) - }) - - It("returns error when username is missing", func() { - instance, err := plugin.instance(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - defer instance.Close(GinkgoT().Context()) - - exit, _, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt")) - Expect(err).To(HaveOccurred()) - Expect(exit).To(Equal(uint32(1))) - Expect(err.Error()).To(ContainSubstring("missing required parameter")) - }) - }) -}) - -var _ = Describe("SubsonicAPIService", func() { - var ( - ctx context.Context - router *fakeSubsonicRouter - userRepo *tests.MockedUserRepo - dataStore *tests.MockDataStore - ) - - BeforeEach(func() { - ctx = GinkgoT().Context() - router = &fakeSubsonicRouter{} - userRepo = tests.CreateMockUserRepo() - dataStore = &tests.MockDataStore{MockedUser: userRepo} - - _ = userRepo.Put(ctx, &model.User{ - ID: "user1", - UserName: "testuser", - IsAdmin: false, - }) - _ = userRepo.Put(ctx, &model.User{ - ID: "admin1", - UserName: "adminuser", - IsAdmin: true, - }) - _ = userRepo.Put(ctx, &model.User{ - ID: "user2", - UserName: "alloweduser", - IsAdmin: false, - }) - }) - - Describe("Permission Enforcement", func() { - Context("with specific user IDs allowed", func() { - It("blocks users not in the allowed list", func() { - // allowedUserIDs contains "user2", but testuser is "user1" - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) - - _, err := service.Call(ctx, "/ping?u=testuser") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not authorized")) - }) - - It("allows users in the allowed list", func() { - // allowedUserIDs contains "user2" which is "alloweduser" - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) - - response, err := service.Call(ctx, "/ping?u=alloweduser") - Expect(err).ToNot(HaveOccurred()) - Expect(response).To(ContainSubstring("ok")) - }) - - It("blocks admin users when not in allowed list", func() { - // allowedUserIDs only contains "user1" (testuser), not "admin1" - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user1"}, false)) - - _, err := service.Call(ctx, "/ping?u=adminuser") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not authorized")) - }) - - It("allows admin users when in allowed list", func() { - // allowedUserIDs contains "admin1" - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"admin1"}, false)) - - response, err := service.Call(ctx, "/ping?u=adminuser") - Expect(err).ToNot(HaveOccurred()) - Expect(response).To(ContainSubstring("ok")) - }) - }) - - Context("with allUsers=true", func() { - It("allows all users regardless of allowed list", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - - response, err := service.Call(ctx, "/ping?u=testuser") - Expect(err).ToNot(HaveOccurred()) - Expect(response).To(ContainSubstring("ok")) - }) - - It("allows admin users when allUsers is true", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - - response, err := service.Call(ctx, "/ping?u=adminuser") - Expect(err).ToNot(HaveOccurred()) - Expect(response).To(ContainSubstring("ok")) - }) - }) - - Context("with no users configured", func() { - It("returns error when no users are configured", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, false)) - - _, err := service.Call(ctx, "/ping?u=testuser") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("no users configured")) - }) - - It("returns error for empty user list", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{}, false)) - - _, err := service.Call(ctx, "/ping?u=testuser") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("no users configured")) - }) - }) - }) - - Describe("URL Handling", func() { - It("returns error for missing username parameter", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - - _, err := service.Call(ctx, "/ping") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("missing required parameter")) - }) - - It("returns error for invalid URL", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - - _, err := service.Call(ctx, "://invalid") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid URL")) - }) - - It("extracts endpoint from path correctly", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user1"}, false)) - - _, err := service.Call(ctx, "/rest/ping.view?u=testuser") - Expect(err).ToNot(HaveOccurred()) - - // The endpoint should be extracted as "ping.view" - Expect(router.lastRequest.URL.Path).To(Equal("/ping.view")) - }) - }) - - Describe("CallRaw", func() { - It("returns binary data and content-type", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - - contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") - Expect(err).ToNot(HaveOccurred()) - Expect(contentType).To(Equal("image/png")) - Expect(data).To(Equal(fakePNGHeader)) - }) - - It("does not set f=json parameter", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - - _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") - Expect(err).ToNot(HaveOccurred()) - - Expect(router.lastRequest).ToNot(BeNil()) - query := router.lastRequest.URL.Query() - Expect(query.Get("f")).To(BeEmpty()) - }) - - It("enforces permission checks", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess([]string{"user2"}, false)) - - _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not authorized")) - }) - - It("returns error when username is missing", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - - _, _, err := service.CallRaw(ctx, "/getCoverArt") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("missing required parameter")) - }) - - It("returns error when router is nil", func() { - service := newSubsonicAPIService("test-plugin", nil, dataStore, newUserAccess(nil, true)) - - _, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("router not available")) - }) - - It("returns error for invalid URL", func() { - service := newSubsonicAPIService("test-plugin", router, dataStore, newUserAccess(nil, true)) - - _, _, err := service.CallRaw(ctx, "://invalid") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid URL")) - }) - }) - - Describe("Router Availability", func() { - It("returns error when router is nil", func() { - service := newSubsonicAPIService("test-plugin", nil, dataStore, newUserAccess(nil, true)) - - _, err := service.Call(ctx, "/ping?u=testuser") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("router not available")) - }) - }) -}) - -// fakePNGHeader is a minimal PNG file header used in tests. -var fakePNGHeader = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} - -// fakeSubsonicRouter is a mock Subsonic router that returns predictable responses. -type fakeSubsonicRouter struct { - lastRequest *http.Request -} - -func (r *fakeSubsonicRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { - r.lastRequest = req - - endpoint := path.Base(req.URL.Path) - switch endpoint { - case "getCoverArt": - w.Header().Set("Content-Type", "image/png") - _, _ = w.Write(fakePNGHeader) - default: - // Return a successful ping response - response := map[string]any{ - "subsonic-response": map[string]any{ - "status": "ok", - "version": "1.16.1", - }, - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(response) - } -} diff --git a/plugins/host_taskqueue.go b/plugins/host_taskqueue.go deleted file mode 100644 index b15cfaf56..000000000 --- a/plugins/host_taskqueue.go +++ /dev/null @@ -1,595 +0,0 @@ -package plugins - -import ( - "context" - "database/sql" - "errors" - "fmt" - "io" - "maps" - "os" - "path/filepath" - "sync" - "time" - - _ "github.com/mattn/go-sqlite3" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/plugins/capabilities" - "github.com/navidrome/navidrome/plugins/host" - "golang.org/x/time/rate" -) - -const ( - defaultConcurrency int32 = 1 - defaultBackoffMs int64 = 1000 - defaultRetentionMs int64 = 3_600_000 // 1 hour - minRetentionMs int64 = 60_000 // 1 minute - maxRetentionMs int64 = 604_800_000 // 1 week - maxQueueNameLength = 128 - maxPayloadSize = 1 * 1024 * 1024 // 1MB - maxBackoffMs int64 = 3_600_000 // 1 hour - taskCleanupInterval = 5 * time.Minute - pollInterval = 5 * time.Second - shutdownTimeout = 10 * time.Second - - taskStatusPending = "pending" - taskStatusRunning = "running" - taskStatusCompleted = "completed" - taskStatusFailed = "failed" - taskStatusCancelled = "cancelled" -) - -// CapabilityTaskWorker indicates the plugin can receive task execution callbacks. -const CapabilityTaskWorker Capability = "TaskWorker" - -const FuncTaskWorkerCallback = "nd_task_execute" - -func init() { - registerCapability(CapabilityTaskWorker, FuncTaskWorkerCallback) -} - -type queueState struct { - config host.QueueConfig - signal chan struct{} - limiter *rate.Limiter -} - -// notifyWorkers sends a non-blocking signal to wake up queue workers. -func (qs *queueState) notifyWorkers() { - select { - case qs.signal <- struct{}{}: - default: - } -} - -// taskQueueServiceImpl implements host.TaskQueueService with SQLite persistence -// and background worker goroutines for task execution. -type taskQueueServiceImpl struct { - pluginName string - manager *Manager - maxConcurrency int32 - db *sql.DB - ctx context.Context //nolint:containedctx // service lifecycle ctx for the worker goroutines - cancel context.CancelFunc - wg sync.WaitGroup - mu sync.Mutex - queues map[string]*queueState - - // For testing: override how callbacks are invoked - invokeCallbackFn func(ctx context.Context, queueName, taskID string, payload []byte, attempt int32) (string, error) -} - -// newTaskQueueService creates a new taskQueueServiceImpl with its own SQLite database. -// The given ctx bounds the service's background work (queue workers, cleanup loop). -func newTaskQueueService(ctx context.Context, pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) { - dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName) - if err := os.MkdirAll(dataDir, 0700); err != nil { - return nil, fmt.Errorf("creating plugin data directory: %w", err) - } - - dbPath := filepath.Join(dataDir, "taskqueue.db") - db, err := sql.Open("sqlite3", dbPath+"?_busy_timeout=5000&_journal_mode=WAL&_foreign_keys=off") - if err != nil { - return nil, fmt.Errorf("opening taskqueue database: %w", err) - } - - db.SetMaxOpenConns(3) - db.SetMaxIdleConns(1) - - if err := createTaskQueueSchema(db); err != nil { - db.Close() - return nil, fmt.Errorf("creating taskqueue schema: %w", err) - } - - ctx, cancel := context.WithCancel(ctx) //nolint:gosec // cancel is stored in struct and called in Close() - - s := &taskQueueServiceImpl{ - pluginName: pluginName, - manager: manager, - maxConcurrency: maxConcurrency, - db: db, - ctx: ctx, - cancel: cancel, - queues: make(map[string]*queueState), - } - s.invokeCallbackFn = s.defaultInvokeCallback - - s.wg.Go(s.cleanupLoop) - - log.Debug("Initialized plugin taskqueue", "plugin", pluginName, "path", dbPath, "maxConcurrency", maxConcurrency) - return s, nil -} - -// createTaskQueueSchema applies schema migrations to the taskqueue database. -// New migrations must be appended at the end of the slice. -func createTaskQueueSchema(db *sql.DB) error { - return migrateDB(db, []string{ - `CREATE TABLE IF NOT EXISTS queues ( - name TEXT PRIMARY KEY, - concurrency INTEGER NOT NULL DEFAULT 1, - max_retries INTEGER NOT NULL DEFAULT 0, - backoff_ms INTEGER NOT NULL DEFAULT 1000, - delay_ms INTEGER NOT NULL DEFAULT 0, - retention_ms INTEGER NOT NULL DEFAULT 3600000 - )`, - `CREATE TABLE IF NOT EXISTS tasks ( - id TEXT PRIMARY KEY, - queue_name TEXT NOT NULL REFERENCES queues(name), - payload BLOB NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - attempt INTEGER NOT NULL DEFAULT 0, - max_retries INTEGER NOT NULL, - next_run_at INTEGER NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - message TEXT NOT NULL DEFAULT '' - )`, - `CREATE INDEX IF NOT EXISTS idx_tasks_dequeue ON tasks(queue_name, status, next_run_at)`, - }) -} - -// applyConfigDefaults fills zero-value config fields with sensible defaults -// and clamps values to valid ranges, logging warnings for clamped values. -func (s *taskQueueServiceImpl) applyConfigDefaults(ctx context.Context, name string, config *host.QueueConfig) { - if config.Concurrency <= 0 { - config.Concurrency = defaultConcurrency - } - if config.BackoffMs <= 0 { - config.BackoffMs = defaultBackoffMs - } - if config.RetentionMs <= 0 { - config.RetentionMs = defaultRetentionMs - } - - if config.RetentionMs < minRetentionMs { - log.Warn(ctx, "TaskQueue retention clamped to minimum", "plugin", s.pluginName, "queue", name, - "requested", config.RetentionMs, "min", minRetentionMs) - config.RetentionMs = minRetentionMs - } - if config.RetentionMs > maxRetentionMs { - log.Warn(ctx, "TaskQueue retention clamped to maximum", "plugin", s.pluginName, "queue", name, - "requested", config.RetentionMs, "max", maxRetentionMs) - config.RetentionMs = maxRetentionMs - } -} - -// clampConcurrency reduces config.Concurrency if it exceeds the remaining budget. -// Returns an error when the concurrency budget is fully exhausted. -// Must be called with s.mu held. -func (s *taskQueueServiceImpl) clampConcurrency(ctx context.Context, name string, config *host.QueueConfig) error { - var allocated int32 - for _, qs := range s.queues { - allocated += qs.config.Concurrency - } - available := s.maxConcurrency - allocated - if available <= 0 { - log.Warn(ctx, "TaskQueue concurrency budget exhausted", "plugin", s.pluginName, "queue", name, - "allocated", allocated, "maxConcurrency", s.maxConcurrency) - return fmt.Errorf("concurrency budget exhausted (%d/%d allocated)", allocated, s.maxConcurrency) - } - if config.Concurrency > available { - log.Warn(ctx, "TaskQueue concurrency clamped", "plugin", s.pluginName, "queue", name, - "requested", config.Concurrency, "available", available, "maxConcurrency", s.maxConcurrency) - config.Concurrency = available - } - return nil -} - -func (s *taskQueueServiceImpl) CreateQueue(ctx context.Context, name string, config host.QueueConfig) error { - if len(name) == 0 { - return fmt.Errorf("queue name cannot be empty") - } - if len(name) > maxQueueNameLength { - return fmt.Errorf("queue name exceeds maximum length of %d bytes", maxQueueNameLength) - } - - s.applyConfigDefaults(ctx, name, &config) - - s.mu.Lock() - defer s.mu.Unlock() - - if err := s.clampConcurrency(ctx, name, &config); err != nil { - return err - } - - if _, exists := s.queues[name]; exists { - return fmt.Errorf("queue %q already exists", name) - } - - // Upsert into queues table (idempotent across restarts) - _, err := s.db.ExecContext(ctx, ` - INSERT INTO queues (name, concurrency, max_retries, backoff_ms, delay_ms, retention_ms) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(name) DO UPDATE SET - concurrency = excluded.concurrency, - max_retries = excluded.max_retries, - backoff_ms = excluded.backoff_ms, - delay_ms = excluded.delay_ms, - retention_ms = excluded.retention_ms - `, name, config.Concurrency, config.MaxRetries, config.BackoffMs, config.DelayMs, config.RetentionMs) - if err != nil { - return fmt.Errorf("creating queue: %w", err) - } - - // Reset stale running tasks from previous crash - now := time.Now().UnixMilli() - _, err = s.db.ExecContext(ctx, ` - UPDATE tasks SET status = ?, updated_at = ? WHERE queue_name = ? AND status = ? - `, taskStatusPending, now, name, taskStatusRunning) - if err != nil { - return fmt.Errorf("resetting stale tasks: %w", err) - } - - qs := &queueState{ - config: config, - signal: make(chan struct{}, 1), - } - if config.DelayMs > 0 { - // Rate limit dispatches to enforce delay between tasks. - // Burst of 1 allows one immediate dispatch, then enforces the delay interval. - qs.limiter = rate.NewLimiter(rate.Every(time.Duration(config.DelayMs)*time.Millisecond), 1) - } - s.queues[name] = qs - - for i := int32(0); i < config.Concurrency; i++ { - s.wg.Go(func() { s.worker(name, qs) }) - } - - log.Debug(ctx, "Created task queue", "plugin", s.pluginName, "queue", name, - "concurrency", config.Concurrency, "maxRetries", config.MaxRetries, - "backoffMs", config.BackoffMs, "delayMs", config.DelayMs, "retentionMs", config.RetentionMs) - return nil -} - -func (s *taskQueueServiceImpl) Enqueue(ctx context.Context, queueName string, payload []byte) (string, error) { - s.mu.Lock() - qs, exists := s.queues[queueName] - s.mu.Unlock() - - if !exists { - return "", fmt.Errorf("queue %q does not exist", queueName) - } - if len(payload) > maxPayloadSize { - return "", fmt.Errorf("payload size %d exceeds maximum of %d bytes", len(payload), maxPayloadSize) - } - - taskID := id.NewRandom() - now := time.Now().UnixMilli() - - _, err := s.db.ExecContext(ctx, ` - INSERT INTO tasks (id, queue_name, payload, status, attempt, max_retries, next_run_at, created_at, updated_at) - VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?) - `, taskID, queueName, payload, taskStatusPending, qs.config.MaxRetries, now, now, now) - if err != nil { - return "", fmt.Errorf("enqueuing task: %w", err) - } - - qs.notifyWorkers() - log.Trace(ctx, "Enqueued task", "plugin", s.pluginName, "queue", queueName, "taskID", taskID) - return taskID, nil -} - -// Get returns the current state of a task. -func (s *taskQueueServiceImpl) Get(ctx context.Context, taskID string) (*host.TaskInfo, error) { - var info host.TaskInfo - err := s.db.QueryRowContext(ctx, `SELECT status, message, attempt FROM tasks WHERE id = ?`, taskID). - Scan(&info.Status, &info.Message, &info.Attempt) - if errors.Is(err, sql.ErrNoRows) { - return nil, fmt.Errorf("task %q not found", taskID) - } - if err != nil { - return nil, fmt.Errorf("getting task info: %w", err) - } - return &info, nil -} - -// Cancel cancels a pending task. -func (s *taskQueueServiceImpl) Cancel(ctx context.Context, taskID string) error { - now := time.Now().UnixMilli() - result, err := s.db.ExecContext(ctx, ` - UPDATE tasks SET status = ?, updated_at = ? WHERE id = ? AND status = ? - `, taskStatusCancelled, now, taskID, taskStatusPending) - if err != nil { - return fmt.Errorf("cancelling task: %w", err) - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("checking cancel result: %w", err) - } - - if rowsAffected == 0 { - // Check if task exists at all - var status string - err := s.db.QueryRowContext(ctx, `SELECT status FROM tasks WHERE id = ?`, taskID).Scan(&status) - if errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("task %q not found", taskID) - } - if err != nil { - return fmt.Errorf("checking task existence: %w", err) - } - return fmt.Errorf("task %q cannot be cancelled (status: %s)", taskID, status) - } - - log.Trace(ctx, "Cancelled task", "plugin", s.pluginName, "taskID", taskID) - return nil -} - -// ClearQueue removes all pending tasks from the named queue. -// Running tasks are not affected. Returns the number of tasks removed. -func (s *taskQueueServiceImpl) ClearQueue(ctx context.Context, queueName string) (int64, error) { - s.mu.Lock() - _, exists := s.queues[queueName] - s.mu.Unlock() - - if !exists { - return 0, fmt.Errorf("queue %q does not exist", queueName) - } - - now := time.Now().UnixMilli() - result, err := s.db.ExecContext(ctx, ` - UPDATE tasks SET status = ?, updated_at = ? WHERE queue_name = ? AND status = ? - `, taskStatusCancelled, now, queueName, taskStatusPending) - if err != nil { - return 0, fmt.Errorf("clearing queue: %w", err) - } - - cleared, err := result.RowsAffected() - if err != nil { - return 0, fmt.Errorf("checking clear result: %w", err) - } - - if cleared > 0 { - log.Debug(ctx, "Cleared pending tasks from queue", "plugin", s.pluginName, "queue", queueName, "cleared", cleared) - } - return cleared, nil -} - -// worker is the main loop for a single worker goroutine. -func (s *taskQueueServiceImpl) worker(queueName string, qs *queueState) { - // Process any existing pending tasks immediately on startup - s.drainQueue(queueName, qs) - - ticker := time.NewTicker(pollInterval) - defer ticker.Stop() - - for { - select { - case <-s.ctx.Done(): - return - case <-qs.signal: - s.drainQueue(queueName, qs) - case <-ticker.C: - s.drainQueue(queueName, qs) - } - } -} - -func (s *taskQueueServiceImpl) drainQueue(queueName string, qs *queueState) { - for s.ctx.Err() == nil && s.processTask(queueName, qs) { - } -} - -// processTask dequeues and processes a single task. Returns true if a task was processed. -func (s *taskQueueServiceImpl) processTask(queueName string, qs *queueState) bool { - now := time.Now().UnixMilli() - - // Atomically dequeue a task - var taskID string - var payload []byte - var attempt, maxRetries int32 - err := s.db.QueryRowContext(s.ctx, ` - UPDATE tasks SET status = ?, attempt = attempt + 1, updated_at = ? - WHERE id = ( - SELECT id FROM tasks - WHERE queue_name = ? AND status = ? AND next_run_at <= ? - ORDER BY next_run_at, created_at LIMIT 1 - ) - RETURNING id, payload, attempt, max_retries - `, taskStatusRunning, now, queueName, taskStatusPending, now).Scan(&taskID, &payload, &attempt, &maxRetries) - if errors.Is(err, sql.ErrNoRows) { - return false - } - if err != nil { - log.Error(s.ctx, "Failed to dequeue task", "plugin", s.pluginName, "queue", queueName, err) - return false - } - - // Enforce delay between task dispatches using a rate limiter. - // This is done after dequeue so that empty polls don't consume rate tokens. - if qs.limiter != nil { - if err := qs.limiter.Wait(s.ctx); err != nil { - // Context cancelled during wait — revert task to pending for recovery - s.revertTaskToPending(taskID) - return false - } - } - - // Invoke callback - log.Debug(s.ctx, "Executing task", "plugin", s.pluginName, "queue", queueName, "taskID", taskID, "attempt", attempt) - message, callbackErr := s.invokeCallbackFn(s.ctx, queueName, taskID, payload, attempt) - - // If context was cancelled (shutdown), revert task to pending for recovery - if s.ctx.Err() != nil { - s.revertTaskToPending(taskID) - return false - } - - if callbackErr == nil { - s.completeTask(queueName, taskID, message) - } else { - s.handleTaskFailure(queueName, taskID, attempt, maxRetries, qs, callbackErr, message) - } - return true -} - -func (s *taskQueueServiceImpl) completeTask(queueName, taskID, message string) { - now := time.Now().UnixMilli() - if _, err := s.db.ExecContext(s.ctx, `UPDATE tasks SET status = ?, message = ?, updated_at = ? WHERE id = ?`, taskStatusCompleted, message, now, taskID); err != nil { - log.Error(s.ctx, "Failed to mark task as completed", "plugin", s.pluginName, "taskID", taskID, err) - } - log.Debug(s.ctx, "Task completed", "plugin", s.pluginName, "queue", queueName, "taskID", taskID) -} - -func (s *taskQueueServiceImpl) handleTaskFailure(queueName, taskID string, attempt, maxRetries int32, qs *queueState, callbackErr error, message string) { - log.Warn(s.ctx, "Task execution failed", "plugin", s.pluginName, "queue", queueName, - "taskID", taskID, "attempt", attempt, "maxRetries", maxRetries, "err", callbackErr) - - // Use error message as fallback if no message was provided - if message == "" { - message = callbackErr.Error() - } - - now := time.Now().UnixMilli() - if attempt > maxRetries { - if _, err := s.db.ExecContext(s.ctx, `UPDATE tasks SET status = ?, message = ?, updated_at = ? WHERE id = ?`, taskStatusFailed, message, now, taskID); err != nil { - log.Error(s.ctx, "Failed to mark task as failed", "plugin", s.pluginName, "taskID", taskID, err) - } - log.Warn(s.ctx, "Task failed after all retries", "plugin", s.pluginName, "queue", queueName, "taskID", taskID) - return - } - - // Exponential backoff: backoffMs * 2^(attempt-1) - backoff := qs.config.BackoffMs << (attempt - 1) - if backoff <= 0 || backoff > maxBackoffMs { - backoff = maxBackoffMs - } - nextRunAt := now + backoff - if _, err := s.db.ExecContext(s.ctx, ` - UPDATE tasks SET status = ?, next_run_at = ?, updated_at = ? WHERE id = ? - `, taskStatusPending, nextRunAt, now, taskID); err != nil { - log.Error(s.ctx, "Failed to reschedule task for retry", "plugin", s.pluginName, "taskID", taskID, err) - } - - // Wake worker after backoff expires - time.AfterFunc(time.Duration(backoff)*time.Millisecond, func() { - qs.notifyWorkers() - }) -} - -// revertTaskToPending puts a running task back to pending status and decrements the attempt -// counter (used during shutdown to ensure the interrupted attempt doesn't count). -func (s *taskQueueServiceImpl) revertTaskToPending(taskID string) { - now := time.Now().UnixMilli() - _, err := s.db.Exec(`UPDATE tasks SET status = ?, attempt = MAX(attempt - 1, 0), updated_at = ? WHERE id = ? AND status = ?`, taskStatusPending, now, taskID, taskStatusRunning) - if err != nil { - log.Error("Failed to revert task to pending", "plugin", s.pluginName, "taskID", taskID, err) - } -} - -// defaultInvokeCallback calls the plugin's nd_task_execute function. -func (s *taskQueueServiceImpl) defaultInvokeCallback(ctx context.Context, queueName, taskID string, payload []byte, attempt int32) (string, error) { - s.manager.mu.RLock() - p, ok := s.manager.plugins[s.pluginName] - s.manager.mu.RUnlock() - - if !ok { - return "", fmt.Errorf("plugin %s not loaded", s.pluginName) - } - - input := capabilities.TaskExecuteRequest{ - QueueName: queueName, - TaskID: taskID, - Payload: payload, - Attempt: attempt, - } - - message, err := callPluginFunction[capabilities.TaskExecuteRequest, string](ctx, p, FuncTaskWorkerCallback, input) - if err != nil { - return "", err - } - return message, nil -} - -// cleanupLoop periodically removes terminal tasks past their retention period. -func (s *taskQueueServiceImpl) cleanupLoop() { - ticker := time.NewTicker(taskCleanupInterval) - defer ticker.Stop() - - for { - select { - case <-s.ctx.Done(): - return - case <-ticker.C: - s.runCleanup() - } - } -} - -// runCleanup deletes terminal tasks past their retention period. -func (s *taskQueueServiceImpl) runCleanup() { - s.mu.Lock() - queues := make(map[string]*queueState, len(s.queues)) - maps.Copy(queues, s.queues) - s.mu.Unlock() - - now := time.Now().UnixMilli() - for name, qs := range queues { - result, err := s.db.ExecContext(s.ctx, ` - DELETE FROM tasks WHERE queue_name = ? AND status IN (?, ?, ?) AND updated_at + ? < ? - `, name, taskStatusCompleted, taskStatusFailed, taskStatusCancelled, qs.config.RetentionMs, now) - if err != nil { - log.Error(s.ctx, "Failed to cleanup tasks", "plugin", s.pluginName, "queue", name, err) - continue - } - if deleted, _ := result.RowsAffected(); deleted > 0 { - log.Debug(s.ctx, "Cleaned up terminal tasks", "plugin", s.pluginName, "queue", name, "deleted", deleted) - } - } -} - -// Close shuts down the task queue service, stopping all workers and closing the database. -func (s *taskQueueServiceImpl) Close() error { - // Cancel context to signal all goroutines - s.cancel() - - // Wait for goroutines with timeout - done := make(chan struct{}) - go func() { - s.wg.Wait() - close(done) - }() - - select { - case <-done: - case <-time.After(shutdownTimeout): - log.Warn("TaskQueue shutdown timed out", "plugin", s.pluginName) - } - - // Mark running tasks as pending for recovery on next startup - if s.db != nil { - now := time.Now().UnixMilli() - if _, err := s.db.Exec(`UPDATE tasks SET status = ?, updated_at = ? WHERE status = ?`, taskStatusPending, now, taskStatusRunning); err != nil { - log.Error("Failed to reset running tasks on shutdown", "plugin", s.pluginName, err) - } - log.Debug("Closing plugin taskqueue", "plugin", s.pluginName) - return s.db.Close() - } - return nil -} - -// Compile-time verification -var _ host.TaskService = (*taskQueueServiceImpl)(nil) -var _ io.Closer = (*taskQueueServiceImpl)(nil) diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go deleted file mode 100644 index 993ddc7f6..000000000 --- a/plugins/host_taskqueue_test.go +++ /dev/null @@ -1,1210 +0,0 @@ -package plugins - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "net/http" - "os" - "path/filepath" - "sort" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/plugins/host" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("TaskQueueService", func() { - var tmpDir string - var service *taskQueueServiceImpl - var ctx context.Context - var manager *Manager - - BeforeEach(func() { - ctx = GinkgoT().Context() - var err error - tmpDir, err = os.MkdirTemp("", "taskqueue-test-*") - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = conf.NewDir(tmpDir) - - manager = &Manager{ - plugins: make(map[string]*plugin), - } - - service, err = newTaskQueueService(ctx, "test_plugin", manager, 5) - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - if service != nil { - service.Close() - } - os.RemoveAll(tmpDir) - }) - - Describe("CreateQueue", func() { - It("creates a queue successfully", func() { - err := service.CreateQueue(ctx, "my-queue", host.QueueConfig{ - Concurrency: 2, - MaxRetries: 3, - BackoffMs: 2000, - RetentionMs: 7200000, - }) - Expect(err).ToNot(HaveOccurred()) - - service.mu.Lock() - qs, exists := service.queues["my-queue"] - service.mu.Unlock() - Expect(exists).To(BeTrue()) - Expect(qs.config.Concurrency).To(Equal(int32(2))) - Expect(qs.config.MaxRetries).To(Equal(int32(3))) - Expect(qs.config.BackoffMs).To(Equal(int64(2000))) - Expect(qs.config.RetentionMs).To(Equal(int64(7200000))) - }) - - It("returns error for duplicate queue name", func() { - err := service.CreateQueue(ctx, "dup-queue", host.QueueConfig{}) - Expect(err).ToNot(HaveOccurred()) - - err = service.CreateQueue(ctx, "dup-queue", host.QueueConfig{}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("already exists")) - }) - }) - - Describe("CreateQueue name validation", func() { - It("rejects empty queue name", func() { - err := service.CreateQueue(ctx, "", host.QueueConfig{}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("queue name cannot be empty")) - }) - - It("rejects over-length queue name", func() { - longName := strings.Repeat("a", maxQueueNameLength+1) - err := service.CreateQueue(ctx, longName, host.QueueConfig{}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("exceeds maximum length")) - }) - - It("accepts queue name at maximum length", func() { - service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { - return "", nil - } - exactName := strings.Repeat("a", maxQueueNameLength) - err := service.CreateQueue(ctx, exactName, host.QueueConfig{}) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("CreateQueue defaults", func() { - It("applies defaults for zero-value config", func() { - err := service.CreateQueue(ctx, "defaults-queue", host.QueueConfig{}) - Expect(err).ToNot(HaveOccurred()) - - service.mu.Lock() - qs := service.queues["defaults-queue"] - service.mu.Unlock() - Expect(qs.config.Concurrency).To(Equal(defaultConcurrency)) - Expect(qs.config.BackoffMs).To(Equal(defaultBackoffMs)) - Expect(qs.config.RetentionMs).To(Equal(defaultRetentionMs)) - }) - }) - - Describe("CreateQueue defaults with negative values", func() { - It("applies default RetentionMs for negative value", func() { - service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { - return "", nil - } - err := service.CreateQueue(ctx, "neg-retention", host.QueueConfig{ - RetentionMs: -500, - }) - Expect(err).ToNot(HaveOccurred()) - - service.mu.Lock() - qs := service.queues["neg-retention"] - service.mu.Unlock() - Expect(qs.config.RetentionMs).To(Equal(defaultRetentionMs)) - }) - }) - - Describe("CreateQueue clamping", func() { - It("clamps concurrency exceeding maxConcurrency", func() { - // maxConcurrency is 5; request 10 - err := service.CreateQueue(ctx, "clamped-queue", host.QueueConfig{ - Concurrency: 10, - }) - Expect(err).ToNot(HaveOccurred()) - - service.mu.Lock() - qs := service.queues["clamped-queue"] - service.mu.Unlock() - Expect(qs.config.Concurrency).To(Equal(int32(5))) - }) - - It("returns error when concurrency budget is exhausted", func() { - // maxConcurrency is 5; create a queue that uses all 5 - err := service.CreateQueue(ctx, "full-budget", host.QueueConfig{ - Concurrency: 5, - }) - Expect(err).ToNot(HaveOccurred()) - - // Next queue should fail — no budget remaining - err = service.CreateQueue(ctx, "over-budget", host.QueueConfig{ - Concurrency: 1, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("concurrency budget exhausted")) - }) - - It("clamps retention below minimum", func() { - err := service.CreateQueue(ctx, "low-retention", host.QueueConfig{ - RetentionMs: 100, // below minRetentionMs - }) - Expect(err).ToNot(HaveOccurred()) - - service.mu.Lock() - qs := service.queues["low-retention"] - service.mu.Unlock() - Expect(qs.config.RetentionMs).To(Equal(minRetentionMs)) - }) - - It("clamps retention above maximum", func() { - err := service.CreateQueue(ctx, "high-retention", host.QueueConfig{ - RetentionMs: 999_999_999_999, // above maxRetentionMs - }) - Expect(err).ToNot(HaveOccurred()) - - service.mu.Lock() - qs := service.queues["high-retention"] - service.mu.Unlock() - Expect(qs.config.RetentionMs).To(Equal(maxRetentionMs)) - }) - }) - - Describe("Enqueue", func() { - BeforeEach(func() { - // Use a no-op callback to prevent actual execution attempts - service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { - return "", nil - } - err := service.CreateQueue(ctx, "enqueue-test", host.QueueConfig{}) - Expect(err).ToNot(HaveOccurred()) - }) - - It("enqueues a task and returns task ID", func() { - taskID, err := service.Enqueue(ctx, "enqueue-test", []byte("payload")) - Expect(err).ToNot(HaveOccurred()) - Expect(taskID).ToNot(BeEmpty()) - }) - - It("returns error for non-existent queue", func() { - _, err := service.Enqueue(ctx, "no-such-queue", []byte("payload")) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("does not exist")) - }) - - It("rejects payload exceeding maximum size", func() { - bigPayload := make([]byte, maxPayloadSize+1) - _, err := service.Enqueue(ctx, "enqueue-test", bigPayload) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("exceeds maximum")) - }) - - It("accepts payload at maximum size", func() { - exactPayload := make([]byte, maxPayloadSize) - taskID, err := service.Enqueue(ctx, "enqueue-test", exactPayload) - Expect(err).ToNot(HaveOccurred()) - Expect(taskID).ToNot(BeEmpty()) - }) - }) - - Describe("GetTaskStatus", func() { - BeforeEach(func() { - // Use a callback that blocks until context is cancelled so tasks stay pending - service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) (string, error) { - <-ctx.Done() - return "", ctx.Err() - } - }) - - It("returns pending for a new task", func() { - err := service.CreateQueue(ctx, "status-test", host.QueueConfig{}) - Expect(err).ToNot(HaveOccurred()) - - taskID, err := service.Enqueue(ctx, "status-test", []byte("data")) - Expect(err).ToNot(HaveOccurred()) - - // The task may get picked up quickly; check initial status - // Since the callback blocks, it should be either pending or running - info, err := service.Get(ctx, taskID) - Expect(err).ToNot(HaveOccurred()) - Expect(info).ToNot(BeNil()) - Expect(info.Status).To(BeElementOf("pending", "running")) - }) - - It("returns error for unknown task ID", func() { - _, err := service.Get(ctx, "nonexistent-id") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not found")) - }) - }) - - Describe("CancelTask", func() { - BeforeEach(func() { - // Block callback so tasks stay in pending/running - service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) (string, error) { - <-ctx.Done() - return "", ctx.Err() - } - }) - - It("cancels a pending task", func() { - // Block the callback so the first task occupies the worker - started := make(chan struct{}) - service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) (string, error) { - close(started) - <-ctx.Done() - return "", ctx.Err() - } - - err := service.CreateQueue(ctx, "cancel-test", host.QueueConfig{ - Concurrency: 1, - }) - Expect(err).ToNot(HaveOccurred()) - - // Enqueue a blocker task to occupy the single worker - _, err = service.Enqueue(ctx, "cancel-test", []byte("blocker")) - Expect(err).ToNot(HaveOccurred()) - - // Wait for the blocker task to start running - Eventually(started).WithTimeout(5 * time.Second).Should(BeClosed()) - - // Enqueue a second task — it stays pending since the worker is busy - taskID, err := service.Enqueue(ctx, "cancel-test", []byte("cancel-me")) - Expect(err).ToNot(HaveOccurred()) - - err = service.Cancel(ctx, taskID) - Expect(err).ToNot(HaveOccurred()) - - info, err := service.Get(ctx, taskID) - Expect(err).ToNot(HaveOccurred()) - Expect(info.Status).To(Equal("cancelled")) - }) - - It("returns error for unknown task ID", func() { - err := service.Cancel(ctx, "nonexistent-id") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not found")) - }) - - It("returns error for non-pending task", func() { - // Create a queue where tasks complete immediately - service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { - return "", nil - } - err := service.CreateQueue(ctx, "completed-test", host.QueueConfig{}) - Expect(err).ToNot(HaveOccurred()) - - taskID, err := service.Enqueue(ctx, "completed-test", []byte("data")) - Expect(err).ToNot(HaveOccurred()) - - // Wait for task to complete - Eventually(func() string { - info, err := service.Get(ctx, taskID) - if err != nil || info == nil { - return "" - } - return info.Status - }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) - - // Try to cancel completed task - err = service.Cancel(ctx, taskID) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("cannot be cancelled")) - }) - }) - - Describe("ClearQueue", func() { - It("clears all pending tasks from a queue", func() { - // Block the callback so the first task occupies the worker - started := make(chan struct{}) - service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) (string, error) { - close(started) - <-ctx.Done() - return "", ctx.Err() - } - - err := service.CreateQueue(ctx, "clear-test", host.QueueConfig{ - Concurrency: 1, - }) - Expect(err).ToNot(HaveOccurred()) - - // Enqueue a blocker task to occupy the single worker - _, err = service.Enqueue(ctx, "clear-test", []byte("blocker")) - Expect(err).ToNot(HaveOccurred()) - - // Wait for the blocker task to start running - Eventually(started).WithTimeout(5 * time.Second).Should(BeClosed()) - - // Enqueue several more tasks — they stay pending since the worker is busy - var pendingIDs []string - for i := range 3 { - taskID, err := service.Enqueue(ctx, "clear-test", fmt.Appendf(nil, "task-%d", i)) - Expect(err).ToNot(HaveOccurred()) - pendingIDs = append(pendingIDs, taskID) - } - - // Clear the queue - cleared, err := service.ClearQueue(ctx, "clear-test") - Expect(err).ToNot(HaveOccurred()) - Expect(cleared).To(Equal(int64(3))) - - // Verify all pending tasks are now cancelled - for _, id := range pendingIDs { - info, err := service.Get(ctx, id) - Expect(err).ToNot(HaveOccurred()) - Expect(info.Status).To(Equal("cancelled")) - } - }) - - It("returns zero when queue has no pending tasks", func() { - service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { - return "", nil - } - err := service.CreateQueue(ctx, "empty-clear", host.QueueConfig{}) - Expect(err).ToNot(HaveOccurred()) - - cleared, err := service.ClearQueue(ctx, "empty-clear") - Expect(err).ToNot(HaveOccurred()) - Expect(cleared).To(Equal(int64(0))) - }) - - It("returns error for non-existent queue", func() { - _, err := service.ClearQueue(ctx, "no-such-queue") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("does not exist")) - }) - - It("does not affect running tasks", func() { - // Block the callback so tasks stay running - started := make(chan struct{}, 1) - service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) (string, error) { - select { - case started <- struct{}{}: - default: - } - <-ctx.Done() - return "", ctx.Err() - } - - err := service.CreateQueue(ctx, "clear-running", host.QueueConfig{ - Concurrency: 1, - }) - Expect(err).ToNot(HaveOccurred()) - - // Enqueue a task that will start running - runningID, err := service.Enqueue(ctx, "clear-running", []byte("running-task")) - Expect(err).ToNot(HaveOccurred()) - - // Wait for it to start running - Eventually(started).WithTimeout(5 * time.Second).Should(Receive()) - - // Clear the queue — should not affect the running task - cleared, err := service.ClearQueue(ctx, "clear-running") - Expect(err).ToNot(HaveOccurred()) - Expect(cleared).To(Equal(int64(0))) - - // Verify the running task is still running - info, err := service.Get(ctx, runningID) - Expect(err).ToNot(HaveOccurred()) - Expect(info.Status).To(Equal("running")) - }) - }) - - Describe("Worker execution", func() { - It("invokes callback and completes task", func() { - var callCount atomic.Int32 - var receivedQueueName, receivedTaskID string - var receivedPayload []byte - var receivedAttempt int32 - - service.invokeCallbackFn = func(_ context.Context, queueName, taskID string, payload []byte, attempt int32) (string, error) { - callCount.Add(1) - receivedQueueName = queueName - receivedTaskID = taskID - receivedPayload = payload - receivedAttempt = attempt - return "", nil - } - - err := service.CreateQueue(ctx, "worker-test", host.QueueConfig{}) - Expect(err).ToNot(HaveOccurred()) - - taskID, err := service.Enqueue(ctx, "worker-test", []byte("test-payload")) - Expect(err).ToNot(HaveOccurred()) - - Eventually(func() string { - info, err := service.Get(ctx, taskID) - if err != nil || info == nil { - return "" - } - return info.Status - }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) - - Expect(callCount.Load()).To(Equal(int32(1))) - Expect(receivedQueueName).To(Equal("worker-test")) - Expect(receivedTaskID).To(Equal(taskID)) - Expect(receivedPayload).To(Equal([]byte("test-payload"))) - Expect(receivedAttempt).To(Equal(int32(1))) - }) - }) - - Describe("Message storage", func() { - It("stores message on successful completion", func() { - service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { - return "task completed successfully", nil - } - - err := service.CreateQueue(ctx, "msg-success", host.QueueConfig{}) - Expect(err).ToNot(HaveOccurred()) - - taskID, err := service.Enqueue(ctx, "msg-success", []byte("data")) - Expect(err).ToNot(HaveOccurred()) - - Eventually(func() string { - info, err := service.Get(ctx, taskID) - if err != nil || info == nil { - return "" - } - return info.Status - }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) - - info, err := service.Get(ctx, taskID) - Expect(err).ToNot(HaveOccurred()) - Expect(info.Message).To(Equal("task completed successfully")) - Expect(info.Attempt).To(Equal(int32(1))) - }) - - It("stores error message on failure", func() { - service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { - return "", fmt.Errorf("something went wrong") - } - - err := service.CreateQueue(ctx, "msg-fail", host.QueueConfig{ - MaxRetries: 0, - }) - Expect(err).ToNot(HaveOccurred()) - - taskID, err := service.Enqueue(ctx, "msg-fail", []byte("data")) - Expect(err).ToNot(HaveOccurred()) - - Eventually(func() string { - info, err := service.Get(ctx, taskID) - if err != nil || info == nil { - return "" - } - return info.Status - }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("failed")) - - info, err := service.Get(ctx, taskID) - Expect(err).ToNot(HaveOccurred()) - Expect(info.Message).To(Equal("something went wrong")) - }) - - It("uses explicit message over error message on failure", func() { - service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { - return "partial progress made", fmt.Errorf("timeout exceeded") - } - - err := service.CreateQueue(ctx, "msg-fail-with-msg", host.QueueConfig{ - MaxRetries: 0, - }) - Expect(err).ToNot(HaveOccurred()) - - taskID, err := service.Enqueue(ctx, "msg-fail-with-msg", []byte("data")) - Expect(err).ToNot(HaveOccurred()) - - Eventually(func() string { - info, err := service.Get(ctx, taskID) - if err != nil || info == nil { - return "" - } - return info.Status - }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("failed")) - - info, err := service.Get(ctx, taskID) - Expect(err).ToNot(HaveOccurred()) - Expect(info.Message).To(Equal("partial progress made")) - }) - }) - - Describe("Retry on failure", func() { - It("retries and eventually fails after exhausting retries", func() { - var callCount atomic.Int32 - - service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { - callCount.Add(1) - return "", fmt.Errorf("task failed") - } - - err := service.CreateQueue(ctx, "retry-test", host.QueueConfig{ - MaxRetries: 2, - BackoffMs: 10, // Very short for testing - }) - Expect(err).ToNot(HaveOccurred()) - - taskID, err := service.Enqueue(ctx, "retry-test", []byte("retry-payload")) - Expect(err).ToNot(HaveOccurred()) - - Eventually(func() string { - info, err := service.Get(ctx, taskID) - if err != nil || info == nil { - return "" - } - return info.Status - }).WithTimeout(10 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("failed")) - - // 1 initial attempt + 2 retries = 3 total calls - Expect(callCount.Load()).To(Equal(int32(3))) - }) - }) - - Describe("Retry then succeed", func() { - It("retries and succeeds on second attempt", func() { - var callCount atomic.Int32 - - service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, attempt int32) (string, error) { - callCount.Add(1) - if attempt == 1 { - return "", fmt.Errorf("temporary error") - } - return "success", nil - } - - err := service.CreateQueue(ctx, "retry-succeed", host.QueueConfig{ - MaxRetries: 1, - BackoffMs: 10, // Very short for testing - }) - Expect(err).ToNot(HaveOccurred()) - - taskID, err := service.Enqueue(ctx, "retry-succeed", []byte("data")) - Expect(err).ToNot(HaveOccurred()) - - Eventually(func() string { - info, err := service.Get(ctx, taskID) - if err != nil || info == nil { - return "" - } - return info.Status - }).WithTimeout(10 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) - - Expect(callCount.Load()).To(Equal(int32(2))) - }) - }) - - Describe("Backoff overflow cap", func() { - It("caps backoff at maxRetentionMs to prevent overflow", func() { - var callCount atomic.Int32 - - service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { - callCount.Add(1) - return "", fmt.Errorf("always fail") - } - - err := service.CreateQueue(ctx, "backoff-overflow", host.QueueConfig{ - MaxRetries: 3, - BackoffMs: 1_000_000_000, // Very large backoff to trigger overflow on exponentiation - }) - Expect(err).ToNot(HaveOccurred()) - - taskID, err := service.Enqueue(ctx, "backoff-overflow", []byte("overflow-test")) - Expect(err).ToNot(HaveOccurred()) - - // Wait for first attempt to fail - Eventually(func() int32 { - return callCount.Load() - }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(BeNumerically(">=", int32(1))) - - // Check next_run_at is positive and reasonable (capped at maxRetentionMs from now) - var nextRunAt int64 - err = service.db.QueryRow(`SELECT next_run_at FROM tasks WHERE id = ?`, taskID).Scan(&nextRunAt) - Expect(err).ToNot(HaveOccurred()) - - now := time.Now().UnixMilli() - Expect(nextRunAt).To(BeNumerically(">", int64(0)), "next_run_at should be positive") - Expect(nextRunAt).To(BeNumerically("<=", now+maxBackoffMs+1000), "next_run_at should be at most maxBackoffMs from now") - }) - }) - - Describe("Delay enforcement with concurrent workers", func() { - It("enforces delay between dispatches even with multiple workers", func() { - var mu sync.Mutex - var dispatchTimes []time.Time - - service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { - mu.Lock() - dispatchTimes = append(dispatchTimes, time.Now()) - mu.Unlock() - return "", nil - } - - err := service.CreateQueue(ctx, "delay-concurrent", host.QueueConfig{ - Concurrency: 3, - DelayMs: 200, - }) - Expect(err).ToNot(HaveOccurred()) - - // Enqueue 5 tasks - for i := range 5 { - _, err := service.Enqueue(ctx, "delay-concurrent", fmt.Appendf(nil, "task-%d", i)) - Expect(err).ToNot(HaveOccurred()) - } - - // Wait for all tasks to complete - Eventually(func() int { - mu.Lock() - defer mu.Unlock() - return len(dispatchTimes) - }).WithTimeout(10 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal(5)) - - // Sort dispatch times and verify gaps - mu.Lock() - sort.Slice(dispatchTimes, func(i, j int) bool { - return dispatchTimes[i].Before(dispatchTimes[j]) - }) - times := make([]time.Time, len(dispatchTimes)) - copy(times, dispatchTimes) - mu.Unlock() - - // Wake-up latency varies per worker, so check offsets from the first dispatch, not gaps. - for i := 1; i < len(times); i++ { - offset := times[i].Sub(times[0]) - minOffset := time.Duration(i)*200*time.Millisecond - 50*time.Millisecond - Expect(offset).To(BeNumerically(">=", minOffset), - fmt.Sprintf("dispatch %d ran %v after the first, expected >= %v", i, offset, minOffset)) - } - }) - }) - - Describe("Shutdown recovery", func() { - It("resets stale running tasks on CreateQueue", func() { - // Create a first service and queue, enqueue a task - service.invokeCallbackFn = func(ctx context.Context, _, _ string, _ []byte, _ int32) (string, error) { - <-ctx.Done() - return "", ctx.Err() - } - err := service.CreateQueue(ctx, "recovery-queue", host.QueueConfig{}) - Expect(err).ToNot(HaveOccurred()) - - taskID, err := service.Enqueue(ctx, "recovery-queue", []byte("stale-task")) - Expect(err).ToNot(HaveOccurred()) - - // Wait for the task to start running - Eventually(func() string { - info, err := service.Get(ctx, taskID) - if err != nil || info == nil { - return "" - } - return info.Status - }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("running")) - - // Close the service (simulates crash - tasks left in running state) - service.Close() - - // Create a new service pointing to the same DB - manager2 := &Manager{ - plugins: make(map[string]*plugin), - } - - service, err = newTaskQueueService(ctx, "test_plugin", manager2, 5) - Expect(err).ToNot(HaveOccurred()) - - // Override callback to succeed - service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { - return "", nil - } - - // Re-create the queue - the upsert handles the existing row from the old service - err = service.CreateQueue(ctx, "recovery-queue", host.QueueConfig{}) - Expect(err).ToNot(HaveOccurred()) - - // The stale running task should now be reset to pending and eventually completed - Eventually(func() string { - info, err := service.Get(ctx, taskID) - if err != nil || info == nil { - return "" - } - return info.Status - }).WithTimeout(10 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) - }) - }) - - Describe("Close", func() { - It("prevents subsequent operations after close", func() { - err := service.CreateQueue(ctx, "close-test", host.QueueConfig{}) - Expect(err).ToNot(HaveOccurred()) - - service.Close() - - // After close, operations should fail - _, err = service.Enqueue(ctx, "close-test", []byte("data")) - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("Plugin isolation", func() { - It("uses separate databases for different plugins", func() { - manager2 := &Manager{ - plugins: make(map[string]*plugin), - } - - service2, err := newTaskQueueService(ctx, "other_plugin", manager2, 5) - Expect(err).ToNot(HaveOccurred()) - defer service2.Close() - - // Check that separate database files exist - _, err = os.Stat(filepath.Join(tmpDir, "plugins", "test_plugin", "taskqueue.db")) - Expect(err).ToNot(HaveOccurred()) - _, err = os.Stat(filepath.Join(tmpDir, "plugins", "other_plugin", "taskqueue.db")) - Expect(err).ToNot(HaveOccurred()) - - // Both services should be able to create queues with the same name independently - service.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { return "", nil } - service2.invokeCallbackFn = func(_ context.Context, _, _ string, _ []byte, _ int32) (string, error) { return "", nil } - - err = service.CreateQueue(ctx, "shared-name", host.QueueConfig{}) - Expect(err).ToNot(HaveOccurred()) - err = service2.CreateQueue(ctx, "shared-name", host.QueueConfig{}) - Expect(err).ToNot(HaveOccurred()) - - // Enqueue to each and verify they work independently - taskID1, err := service.Enqueue(ctx, "shared-name", []byte("plugin1")) - Expect(err).ToNot(HaveOccurred()) - taskID2, err := service2.Enqueue(ctx, "shared-name", []byte("plugin2")) - Expect(err).ToNot(HaveOccurred()) - - Expect(taskID1).ToNot(Equal(taskID2)) - - // Both should complete - Eventually(func() string { - info, err := service.Get(ctx, taskID1) - if err != nil || info == nil { - return "" - } - return info.Status - }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) - - Eventually(func() string { - info, err := service2.Get(ctx, taskID2) - if err != nil || info == nil { - return "" - } - return info.Status - }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) - }) - }) -}) - -var _ = Describe("TaskQueueService Integration", Ordered, func() { - var manager *Manager - var tmpDir string - - BeforeAll(func() { - var err error - tmpDir, err = os.MkdirTemp("", "taskqueue-integration-test-*") - Expect(err).ToNot(HaveOccurred()) - - // Copy the test-taskqueue plugin - srcPath := filepath.Join(testdataDir, "test-taskqueue"+PackageExtension) - destPath := filepath.Join(tmpDir, "test-taskqueue"+PackageExtension) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Compute SHA256 for the plugin - hash := sha256.Sum256(data) - hashHex := hex.EncodeToString(hash[:]) - - // Setup config - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = conf.NewDir(filepath.Join(tmpDir, "cache")) - conf.Server.DataFolder = conf.NewDir(tmpDir) - - // Setup mock DataStore with pre-enabled plugin - mockPluginRepo := tests.CreateMockPluginRepo() - mockPluginRepo.Permitted = true - mockPluginRepo.SetData(model.Plugins{{ - ID: "test-taskqueue", - Path: destPath, - SHA256: hashHex, - Enabled: true, - }}) - dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo} - - // Create and start manager - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - metrics: noopMetricsRecorder{}, - subsonicRouter: http.NotFoundHandler(), - } - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) - }) - - // Helper types for calling the test plugin - type testQueueConfig struct { - Concurrency int32 `json:"concurrency,omitempty"` - MaxRetries int32 `json:"maxRetries,omitempty"` - BackoffMs int64 `json:"backoffMs,omitempty"` - DelayMs int64 `json:"delayMs,omitempty"` - RetentionMs int64 `json:"retentionMs,omitempty"` - } - - type testTaskQueueInput struct { - Operation string `json:"operation"` - QueueName string `json:"queueName,omitempty"` - Config *testQueueConfig `json:"config,omitempty"` - Payload []byte `json:"payload,omitempty"` - TaskID string `json:"taskId,omitempty"` - } - - type testTaskQueueOutput struct { - TaskID string `json:"taskId,omitempty"` - Status string `json:"status,omitempty"` - Message string `json:"message,omitempty"` - Attempt int32 `json:"attempt,omitempty"` - Cleared int64 `json:"cleared,omitempty"` - Error *string `json:"error,omitempty"` - } - - callTestTaskQueue := func(ctx context.Context, input testTaskQueueInput) (*testTaskQueueOutput, error) { - manager.mu.RLock() - p := manager.plugins["test-taskqueue"] - manager.mu.RUnlock() - - instance, err := p.instance(ctx) - if err != nil { - return nil, err - } - defer instance.Close(ctx) - - inputBytes, _ := json.Marshal(input) - _, outputBytes, err := instance.Call("nd_test_taskqueue", inputBytes) - if err != nil { - return nil, err - } - - var output testTaskQueueOutput - if err := json.Unmarshal(outputBytes, &output); err != nil { - return nil, err - } - if output.Error != nil { - return nil, errors.New(*output.Error) - } - return &output, nil - } - - Describe("Plugin Loading", func() { - It("should load plugin with taskqueue permission and TaskWorker capability", func() { - manager.mu.RLock() - p, ok := manager.plugins["test-taskqueue"] - manager.mu.RUnlock() - Expect(ok).To(BeTrue()) - Expect(p.manifest.Permissions).ToNot(BeNil()) - Expect(p.manifest.Permissions.Taskqueue).ToNot(BeNil()) - Expect(p.manifest.Permissions.Taskqueue.MaxConcurrency).To(Equal(10)) - Expect(p.capabilities).To(ContainElement(CapabilityTaskWorker)) - }) - }) - - Describe("Create Queue", func() { - It("should create a queue without error", func() { - ctx := GinkgoT().Context() - _, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "create_queue", - QueueName: "test-create", - }) - Expect(err).ToNot(HaveOccurred()) - }) - - It("should return error for duplicate queue name", func() { - ctx := GinkgoT().Context() - _, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "create_queue", - QueueName: "test-dup", - }) - Expect(err).ToNot(HaveOccurred()) - - _, err = callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "create_queue", - QueueName: "test-dup", - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("already exists")) - }) - }) - - Describe("Enqueue and Task Completion", func() { - It("should enqueue a task and complete successfully", func() { - ctx := GinkgoT().Context() - - // Create queue - _, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "create_queue", - QueueName: "test-complete", - }) - Expect(err).ToNot(HaveOccurred()) - - // Enqueue task with payload "hello" - output, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "enqueue", - QueueName: "test-complete", - Payload: []byte("hello"), - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.TaskID).ToNot(BeEmpty()) - - taskID := output.TaskID - - // Poll until completed - Eventually(func() string { - out, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "get_task_status", - TaskID: taskID, - }) - if err != nil { - return "error" - } - return out.Status - }).WithTimeout(5 * time.Second).WithPolling(100 * time.Millisecond).Should(Equal("completed")) - }) - }) - - Describe("Enqueue with Failure, No Retries", func() { - It("should fail when payload is 'fail' and maxRetries is 0", func() { - ctx := GinkgoT().Context() - - // Create queue with no retries - _, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "create_queue", - QueueName: "test-fail-no-retry", - Config: &testQueueConfig{ - MaxRetries: 0, - }, - }) - Expect(err).ToNot(HaveOccurred()) - - // Enqueue task that will fail - output, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "enqueue", - QueueName: "test-fail-no-retry", - Payload: []byte("fail"), - }) - Expect(err).ToNot(HaveOccurred()) - - taskID := output.TaskID - - // Poll until failed - Eventually(func() string { - out, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "get_task_status", - TaskID: taskID, - }) - if err != nil { - return "error" - } - return out.Status - }).WithTimeout(5 * time.Second).WithPolling(100 * time.Millisecond).Should(Equal("failed")) - }) - }) - - Describe("Enqueue with Retry Then Success", func() { - It("should retry and eventually succeed with 'fail-then-succeed' payload", func() { - ctx := GinkgoT().Context() - - // Create queue with retries and short backoff - _, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "create_queue", - QueueName: "test-retry-succeed", - Config: &testQueueConfig{ - MaxRetries: 2, - BackoffMs: 100, - }, - }) - Expect(err).ToNot(HaveOccurred()) - - // Enqueue task that fails on attempt < 2, then succeeds - output, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "enqueue", - QueueName: "test-retry-succeed", - Payload: []byte("fail-then-succeed"), - }) - Expect(err).ToNot(HaveOccurred()) - - taskID := output.TaskID - - // Poll until completed - Eventually(func() string { - out, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "get_task_status", - TaskID: taskID, - }) - if err != nil { - return "error" - } - return out.Status - }).WithTimeout(5 * time.Second).WithPolling(100 * time.Millisecond).Should(Equal("completed")) - }) - }) - - Describe("Cancel Pending Task", func() { - It("should cancel a pending task", func() { - ctx := GinkgoT().Context() - - // Create queue with concurrency=1 and a large delay between dispatches. - // The first task completes immediately (burst token), the second is dequeued - // but blocks on the rate limiter. Tasks 3+ remain in 'pending' and can be cancelled. - _, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "create_queue", - QueueName: "test-cancel", - Config: &testQueueConfig{ - Concurrency: 1, - DelayMs: 60000, - }, - }) - Expect(err).ToNot(HaveOccurred()) - - // Enqueue several tasks - the first will complete immediately, - // the second will be dequeued but block on the rate limiter (status=running), - // the rest will stay pending. - var taskIDs []string - for range 5 { - output, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "enqueue", - QueueName: "test-cancel", - Payload: []byte("hello"), - }) - Expect(err).ToNot(HaveOccurred()) - taskIDs = append(taskIDs, output.TaskID) - } - - // Wait for the first task to complete (it has no delay) - Eventually(func() string { - out, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "get_task_status", - TaskID: taskIDs[0], - }) - if err != nil { - return "error" - } - return out.Status - }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Equal("completed")) - - // Give the worker a moment to dequeue the second task (which will - // block on the delay) so tasks 3+ stay in 'pending' - time.Sleep(100 * time.Millisecond) - - // Cancel the last task - it should still be pending - lastTaskID := taskIDs[len(taskIDs)-1] - _, err = callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "cancel_task", - TaskID: lastTaskID, - }) - Expect(err).ToNot(HaveOccurred()) - - // Verify status is cancelled - statusOut, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "get_task_status", - TaskID: lastTaskID, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(statusOut.Status).To(Equal("cancelled")) - }) - }) - - Describe("Enqueue to Non-Existent Queue", func() { - It("should return error when enqueueing to a queue that does not exist", func() { - ctx := GinkgoT().Context() - - _, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "enqueue", - QueueName: "nonexistent-queue", - Payload: []byte("payload"), - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("does not exist")) - }) - }) - - Describe("Clear Queue", func() { - It("should clear pending tasks and return the count", func() { - ctx := GinkgoT().Context() - - // Create queue with large delay so tasks stay pending after the first completes - _, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "create_queue", - QueueName: "test-clear", - Config: &testQueueConfig{ - Concurrency: 1, - DelayMs: 60000, - }, - }) - Expect(err).ToNot(HaveOccurred()) - - // Enqueue several tasks - for i := range 4 { - _, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "enqueue", - QueueName: "test-clear", - Payload: fmt.Appendf(nil, "task-%d", i), - }) - Expect(err).ToNot(HaveOccurred()) - } - - // Wait for the first task to complete (burst token) - time.Sleep(200 * time.Millisecond) - - // Clear the queue — should cancel remaining pending tasks - output, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "clear_queue", - QueueName: "test-clear", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Cleared).To(BeNumerically(">=", int64(1))) - }) - - It("should return error for non-existent queue", func() { - ctx := GinkgoT().Context() - - _, err := callTestTaskQueue(ctx, testTaskQueueInput{ - Operation: "clear_queue", - QueueName: "nonexistent-clear", - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("does not exist")) - }) - }) -}) diff --git a/plugins/host_users.go b/plugins/host_users.go deleted file mode 100644 index 28f4dc9b0..000000000 --- a/plugins/host_users.go +++ /dev/null @@ -1,64 +0,0 @@ -package plugins - -import ( - "context" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/plugins/host" - "github.com/navidrome/navidrome/utils/slice" -) - -type usersServiceImpl struct { - ds model.DataStore - allowedUsers []string // User IDs this plugin can access - allUsers bool // If true, plugin can access all users -} - -func newUsersService(ds model.DataStore, allowedUsers []string, allUsers bool) host.UsersService { - return &usersServiceImpl{ - ds: ds, - allowedUsers: allowedUsers, - allUsers: allUsers, - } -} - -func (s *usersServiceImpl) GetUsers(ctx context.Context) ([]host.User, error) { - users, err := s.ds.User().GetAll(ctx) - if err != nil { - return nil, err - } - - // Build allowed users map for efficient lookup - allowedMap := make(map[string]bool, len(s.allowedUsers)) - for _, id := range s.allowedUsers { - allowedMap[id] = true - } - - var result []host.User - for _, u := range users { - // If allUsers is true, include all users - // Otherwise, only include users in the allowed list - if s.allUsers || allowedMap[u.ID] { - result = append(result, host.User{ - UserName: u.UserName, - Name: u.Name, - IsAdmin: u.IsAdmin, - }) - } - } - - return result, nil -} - -func (s *usersServiceImpl) GetAdmins(ctx context.Context) ([]host.User, error) { - users, err := s.GetUsers(ctx) - if err != nil { - return nil, err - } - - return slice.Filter(users, func(u host.User) bool { - return u.IsAdmin - }), nil -} - -var _ host.UsersService = (*usersServiceImpl)(nil) diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go deleted file mode 100644 index 56edd9f67..000000000 --- a/plugins/host_users_test.go +++ /dev/null @@ -1,586 +0,0 @@ -package plugins - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "net/http" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/plugins/host" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("UsersService", Ordered, func() { - var ( - ctx context.Context - ds model.DataStore - service host.UsersService - ) - - BeforeEach(func() { - ctx = GinkgoT().Context() - ds = &tests.MockDataStore{} - }) - - Describe("GetUsers", func() { - var mockUserRepo *tests.MockedUserRepo - - BeforeEach(func() { - mockUserRepo = ds.User().(*tests.MockedUserRepo) - // Add test users - _ = mockUserRepo.Put(ctx, &model.User{ - ID: "user1", - UserName: "alice", - Name: "Alice Admin", - IsAdmin: true, - }) - _ = mockUserRepo.Put(ctx, &model.User{ - ID: "user2", - UserName: "bob", - Name: "Bob User", - IsAdmin: false, - }) - _ = mockUserRepo.Put(ctx, &model.User{ - ID: "user3", - UserName: "charlie", - Name: "Charlie User", - IsAdmin: false, - }) - }) - - Context("with allUsers=true", func() { - BeforeEach(func() { - service = newUsersService(ds, nil, true) - }) - - It("should return all users", func() { - users, err := service.GetUsers(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(users).To(HaveLen(3)) - - // Verify that the correct fields are returned - userNames := make([]string, len(users)) - for i, u := range users { - userNames[i] = u.UserName - } - Expect(userNames).To(ContainElements("alice", "bob", "charlie")) - }) - - It("should return correct user properties", func() { - users, err := service.GetUsers(ctx) - Expect(err).ToNot(HaveOccurred()) - - // Find alice - var alice *host.User - for i := range users { - if users[i].UserName == "alice" { - alice = &users[i] - break - } - } - - Expect(alice).ToNot(BeNil()) - Expect(alice.UserName).To(Equal("alice")) - Expect(alice.Name).To(Equal("Alice Admin")) - Expect(alice.IsAdmin).To(BeTrue()) - }) - }) - - Context("with specific allowed users", func() { - BeforeEach(func() { - // Only allow access to user1 and user3 - service = newUsersService(ds, []string{"user1", "user3"}, false) - }) - - It("should return only allowed users", func() { - users, err := service.GetUsers(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(users).To(HaveLen(2)) - - userNames := make([]string, len(users)) - for i, u := range users { - userNames[i] = u.UserName - } - Expect(userNames).To(ContainElements("alice", "charlie")) - Expect(userNames).ToNot(ContainElement("bob")) - }) - }) - - Context("with empty allowed users and allUsers=false", func() { - BeforeEach(func() { - service = newUsersService(ds, []string{}, false) - }) - - It("should return no users", func() { - users, err := service.GetUsers(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(users).To(BeEmpty()) - }) - }) - - Context("when datastore returns error", func() { - BeforeEach(func() { - mockUserRepo.Error = model.ErrNotFound - service = newUsersService(ds, nil, true) - }) - - It("should propagate the error", func() { - _, err := service.GetUsers(ctx) - Expect(err).To(HaveOccurred()) - }) - }) - }) - - Describe("GetAdmins", func() { - var mockUserRepo *tests.MockedUserRepo - - BeforeEach(func() { - mockUserRepo = ds.User().(*tests.MockedUserRepo) - // Add test users - alice is admin, bob and charlie are not - _ = mockUserRepo.Put(ctx, &model.User{ - ID: "user1", - UserName: "alice", - Name: "Alice Admin", - IsAdmin: true, - }) - _ = mockUserRepo.Put(ctx, &model.User{ - ID: "user2", - UserName: "bob", - Name: "Bob User", - IsAdmin: false, - }) - _ = mockUserRepo.Put(ctx, &model.User{ - ID: "user3", - UserName: "charlie", - Name: "Charlie User", - IsAdmin: false, - }) - }) - - Context("with allUsers=true", func() { - BeforeEach(func() { - service = newUsersService(ds, nil, true) - }) - - It("should return only admin users", func() { - admins, err := service.GetAdmins(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(admins).To(HaveLen(1)) - Expect(admins[0].UserName).To(Equal("alice")) - Expect(admins[0].IsAdmin).To(BeTrue()) - }) - }) - - Context("with specific allowed users including admin", func() { - BeforeEach(func() { - // Allow access to user1 (admin) and user2 (non-admin) - service = newUsersService(ds, []string{"user1", "user2"}, false) - }) - - It("should return only admin users from allowed list", func() { - admins, err := service.GetAdmins(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(admins).To(HaveLen(1)) - Expect(admins[0].UserName).To(Equal("alice")) - }) - }) - - Context("with specific allowed users excluding admin", func() { - BeforeEach(func() { - // Only allow access to non-admin users - service = newUsersService(ds, []string{"user2", "user3"}, false) - }) - - It("should return empty when no admins in allowed list", func() { - admins, err := service.GetAdmins(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(admins).To(BeEmpty()) - }) - }) - - Context("when datastore returns error", func() { - BeforeEach(func() { - mockUserRepo.Error = model.ErrNotFound - service = newUsersService(ds, nil, true) - }) - - It("should propagate the error", func() { - _, err := service.GetAdmins(ctx) - Expect(err).To(HaveOccurred()) - }) - }) - }) -}) - -var _ = Describe("UsersService Integration", Ordered, func() { - var manager *Manager - - BeforeAll(func() { - var cleanup func() - manager, cleanup = setupUsersIntegrationManager(true, "") - DeferCleanup(cleanup) - }) - - Describe("Plugin Loading", func() { - It("should load plugin with users permission", func() { - manager.mu.RLock() - p, ok := manager.plugins["test-users"] - manager.mu.RUnlock() - Expect(ok).To(BeTrue()) - Expect(p.manifest.Permissions).ToNot(BeNil()) - Expect(p.manifest.Permissions.Users).ToNot(BeNil()) - }) - }) - - Describe("Users Operations via Plugin", func() { - It("should get all users when allUsers is true", func() { - output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_users"}) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Users).To(HaveLen(3)) - - // Verify user names - userNames := make([]string, len(output.Users)) - for i, u := range output.Users { - userNames[i] = u.UserName - } - Expect(userNames).To(ContainElements("alice", "bob", "charlie")) - }) - - It("should return correct user properties", func() { - output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_users"}) - Expect(err).ToNot(HaveOccurred()) - - // Find alice - var alice *testUser - for i := range output.Users { - if output.Users[i].UserName == "alice" { - alice = &output.Users[i] - break - } - } - - Expect(alice).ToNot(BeNil()) - Expect(alice.UserName).To(Equal("alice")) - Expect(alice.Name).To(Equal("Alice Admin")) - Expect(alice.IsAdmin).To(BeTrue()) - }) - - It("should return non-admin user correctly", func() { - output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_users"}) - Expect(err).ToNot(HaveOccurred()) - - // Find bob - var bob *testUser - for i := range output.Users { - if output.Users[i].UserName == "bob" { - bob = &output.Users[i] - break - } - } - - Expect(bob).ToNot(BeNil()) - Expect(bob.UserName).To(Equal("bob")) - Expect(bob.Name).To(Equal("Bob User")) - Expect(bob.IsAdmin).To(BeFalse()) - }) - }) - - Describe("GetAdmins Operations via Plugin", func() { - It("should get only admin users when allUsers is true", func() { - output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_admins"}) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Users).To(HaveLen(1)) - Expect(output.Users[0].UserName).To(Equal("alice")) - Expect(output.Users[0].IsAdmin).To(BeTrue()) - }) - }) -}) - -var _ = Describe("UsersService Integration with Specific Users", Ordered, func() { - var manager *Manager - - BeforeAll(func() { - var cleanup func() - manager, cleanup = setupUsersIntegrationManager(false, `["user1", "user3"]`) - DeferCleanup(cleanup) - }) - - Describe("Users Operations with Specific Allowed Users", func() { - It("should only return allowed users", func() { - output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_users"}) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Users).To(HaveLen(2)) - - // Verify only alice and charlie are returned, not bob - userNames := make([]string, len(output.Users)) - for i, u := range output.Users { - userNames[i] = u.UserName - } - Expect(userNames).To(ContainElements("alice", "charlie")) - Expect(userNames).ToNot(ContainElement("bob")) - }) - - It("should only return admin users from allowed list via GetAdmins", func() { - output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_admins"}) - Expect(err).ToNot(HaveOccurred()) - // Only alice (user1) is admin, charlie (user3) is not - Expect(output.Users).To(HaveLen(1)) - Expect(output.Users[0].UserName).To(Equal("alice")) - Expect(output.Users[0].IsAdmin).To(BeTrue()) - }) - }) -}) - -var _ = Describe("UsersService Integration GetAdmins with No Admins", Ordered, func() { - var manager *Manager - - BeforeAll(func() { - var cleanup func() - // Only allow user2 (bob) and user3 (charlie), both non-admins - manager, cleanup = setupUsersIntegrationManager(false, `["user2", "user3"]`) - DeferCleanup(cleanup) - }) - - Describe("GetAdmins with no admin users in allowed list", func() { - It("should return empty when no admins in allowed list", func() { - output, err := callTestUsersPlugin(GinkgoT().Context(), manager, testUsersInput{Operation: "get_admins"}) - Expect(err).ToNot(HaveOccurred()) - Expect(output.Users).To(BeEmpty()) - }) - }) -}) - -var _ = Describe("UsersService Enable Gate", Ordered, func() { - var manager *Manager - - BeforeAll(func() { - var cleanup func() - // Start with disabled plugin, no users configured - manager, cleanup = setupUsersIntegrationManagerWithEnabled(false, false, "") - DeferCleanup(cleanup) - }) - - Describe("Enable Gate Behavior", func() { - It("should block enabling when no users configured and allUsers is false", func() { - ctx := GinkgoT().Context() - err := manager.EnablePlugin(ctx, "test-users") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("users permission requires configuration")) - }) - - It("should allow enabling when allUsers is true", func() { - ctx := GinkgoT().Context() - - // Update the plugin to have allUsers=true - err := manager.UpdatePluginUsers(ctx, "test-users", "", true) - Expect(err).ToNot(HaveOccurred()) - - // Now enabling should succeed - err = manager.EnablePlugin(ctx, "test-users") - Expect(err).ToNot(HaveOccurred()) - - // Verify plugin is loaded - manager.mu.RLock() - _, ok := manager.plugins["test-users"] - manager.mu.RUnlock() - Expect(ok).To(BeTrue()) - }) - - It("should allow enabling when specific users are configured", func() { - ctx := GinkgoT().Context() - - // First disable the plugin - err := manager.DisablePlugin(ctx, "test-users") - Expect(err).ToNot(HaveOccurred()) - - // Update to have specific users (and allUsers=false) - err = manager.UpdatePluginUsers(ctx, "test-users", `["user1"]`, false) - Expect(err).ToNot(HaveOccurred()) - - // Now enabling should succeed - err = manager.EnablePlugin(ctx, "test-users") - Expect(err).ToNot(HaveOccurred()) - - // Verify plugin is loaded - manager.mu.RLock() - _, ok := manager.plugins["test-users"] - manager.mu.RUnlock() - Expect(ok).To(BeTrue()) - }) - }) -}) - -// testUsersSetup contains common setup data for users integration tests -type testUsersSetup struct { - tmpDir string - destPath string - hashHex string -} - -// setupTestUsersPlugin creates a temporary directory with the test-users plugin and returns setup info -func setupTestUsersPlugin() (*testUsersSetup, error) { - tmpDir, err := os.MkdirTemp("", "users-integration-test-*") - if err != nil { - return nil, err - } - - // Copy the test-users plugin - srcPath := filepath.Join(testdataDir, "test-users"+PackageExtension) - destPath := filepath.Join(tmpDir, "test-users"+PackageExtension) - data, err := os.ReadFile(srcPath) - if err != nil { - _ = os.RemoveAll(tmpDir) - return nil, err - } - if err := os.WriteFile(destPath, data, 0600); err != nil { - _ = os.RemoveAll(tmpDir) - return nil, err - } - - // Compute SHA256 for the plugin - hash := sha256.Sum256(data) - hashHex := hex.EncodeToString(hash[:]) - - return &testUsersSetup{ - tmpDir: tmpDir, - destPath: destPath, - hashHex: hashHex, - }, nil -} - -// createTestUsers creates standard test users in the mock repo -func createTestUsers(ctx context.Context, mockUserRepo *tests.MockedUserRepo) { - _ = mockUserRepo.Put(ctx, &model.User{ - ID: "user1", - UserName: "alice", - Name: "Alice Admin", - IsAdmin: true, - }) - _ = mockUserRepo.Put(ctx, &model.User{ - ID: "user2", - UserName: "bob", - Name: "Bob User", - IsAdmin: false, - }) - _ = mockUserRepo.Put(ctx, &model.User{ - ID: "user3", - UserName: "charlie", - Name: "Charlie User", - IsAdmin: false, - }) -} - -// setupTestUsersConfig sets up common plugin configuration -func setupTestUsersConfig(tmpDir string) { - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false -} - -// testUsersInput represents input for test-users plugin calls -type testUsersInput struct { - Operation string `json:"operation"` -} - -// testUser represents a user returned from test-users plugin -type testUser struct { - UserName string `json:"userName"` - Name string `json:"name"` - IsAdmin bool `json:"isAdmin"` -} - -// testUsersOutput represents output from test-users plugin -type testUsersOutput struct { - Users []testUser `json:"users,omitempty"` - Error *string `json:"error,omitempty"` -} - -// callTestUsersPlugin calls the test-users plugin with given input -func callTestUsersPlugin(ctx context.Context, manager *Manager, input testUsersInput) (*testUsersOutput, error) { - manager.mu.RLock() - p := manager.plugins["test-users"] - manager.mu.RUnlock() - - instance, err := p.instance(ctx) - if err != nil { - return nil, err - } - defer instance.Close(ctx) - - inputBytes, _ := json.Marshal(input) - _, outputBytes, err := instance.Call("nd_test_users", inputBytes) - if err != nil { - return nil, err - } - - var output testUsersOutput - if err := json.Unmarshal(outputBytes, &output); err != nil { - return nil, err - } - if output.Error != nil { - return nil, errors.New(*output.Error) - } - return &output, nil -} - -// setupUsersIntegrationManager creates a Manager for users integration tests with the given plugin settings. -// The plugin is enabled by default. -func setupUsersIntegrationManager(allUsers bool, allowedUsers string) (*Manager, func()) { - return setupUsersIntegrationManagerWithEnabled(true, allUsers, allowedUsers) -} - -// setupUsersIntegrationManagerWithEnabled creates a Manager for users integration tests with full control over plugin state -func setupUsersIntegrationManagerWithEnabled(enabled, allUsers bool, allowedUsers string) (*Manager, func()) { - setup, err := setupTestUsersPlugin() - Expect(err).ToNot(HaveOccurred()) - - // Setup config - cleanupConfig := configtest.SetupConfig() - setupTestUsersConfig(setup.tmpDir) - - // Setup mock DataStore with plugin and users - mockPluginRepo := tests.CreateMockPluginRepo() - mockPluginRepo.Permitted = true - mockPluginRepo.SetData(model.Plugins{{ - ID: "test-users", - Path: setup.destPath, - SHA256: setup.hashHex, - Enabled: enabled, - AllUsers: allUsers, - Users: allowedUsers, - }}) - - mockUserRepo := tests.CreateMockUserRepo() - createTestUsers(GinkgoT().Context(), mockUserRepo) - - dataStore := &tests.MockDataStore{ - MockedPlugin: mockPluginRepo, - MockedUser: mockUserRepo, - } - - // Create and start manager - manager := &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - subsonicRouter: http.NotFoundHandler(), - } - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - cleanup := func() { - _ = manager.Stop() - _ = os.RemoveAll(setup.tmpDir) - cleanupConfig() - } - - return manager, cleanup -} diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go deleted file mode 100644 index a58deb129..000000000 --- a/plugins/host_websocket.go +++ /dev/null @@ -1,388 +0,0 @@ -package plugins - -import ( - "context" - "errors" - "fmt" - "maps" - "net" - "net/http" - "net/url" - "strings" - "sync" - "syscall" - "time" - - "github.com/gorilla/websocket" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/plugins/capabilities" - "github.com/navidrome/navidrome/plugins/host" -) - -// CapabilityWebSocket indicates the plugin can receive WebSocket callbacks. -// Detected when the plugin exports any of the WebSocket callback functions. -const CapabilityWebSocket Capability = "WebSocket" - -// webSocketCallbackTimeout is the maximum duration allowed for a WebSocket callback. -const webSocketCallbackTimeout = 30 * time.Second - -// WebSocket callback function names -const ( - FuncWebSocketOnTextMessage = "nd_websocket_on_text_message" - FuncWebSocketOnBinaryMessage = "nd_websocket_on_binary_message" - FuncWebSocketOnError = "nd_websocket_on_error" - FuncWebSocketOnClose = "nd_websocket_on_close" -) - -func init() { - registerCapability( - CapabilityWebSocket, - FuncWebSocketOnTextMessage, - FuncWebSocketOnBinaryMessage, - FuncWebSocketOnError, - FuncWebSocketOnClose, - ) -} - -// wsConnection represents an active WebSocket connection. -type wsConnection struct { - conn *websocket.Conn - done chan struct{} - closeMu sync.Mutex - isClosed bool -} - -// webSocketServiceImpl implements host.WebSocketService. -// It provides plugins with WebSocket communication capabilities. -type webSocketServiceImpl struct { - baseCtx context.Context //nolint:containedctx // bounds the read loops, which outlive the Connect() call - pluginName string - manager *Manager - requiredHosts []string - - mu sync.RWMutex - connections map[string]*wsConnection -} - -// newWebSocketService creates a new WebSocketService for a plugin. -func newWebSocketService(ctx context.Context, pluginName string, manager *Manager, permission *WebSocketPermission) *webSocketServiceImpl { - return &webSocketServiceImpl{ - baseCtx: ctx, - pluginName: pluginName, - manager: manager, - requiredHosts: permission.RequiredHosts, - connections: make(map[string]*wsConnection), - } -} - -func (s *webSocketServiceImpl) Connect(ctx context.Context, urlStr string, headers map[string]string, connectionID string) (string, error) { - // Parse and validate URL - parsedURL, err := url.Parse(urlStr) - if err != nil { - return "", fmt.Errorf("invalid URL: %w", err) - } - - // Validate scheme - if parsedURL.Scheme != "ws" && parsedURL.Scheme != "wss" { - return "", fmt.Errorf("invalid URL scheme: must be ws:// or wss://") - } - - // Validate host against allowed hosts - if !s.isHostAllowed(parsedURL.Host) { - return "", fmt.Errorf("host %q is not allowed", parsedURL.Host) - } - - // Generate connection ID if not provided - if connectionID == "" { - connectionID = id.NewRandom() - } - - s.mu.Lock() - if _, exists := s.connections[connectionID]; exists { - s.mu.Unlock() - return "", fmt.Errorf("connection ID %q already exists", connectionID) - } - s.mu.Unlock() - - // Create HTTP headers for handshake - httpHeaders := http.Header{} - for k, v := range headers { - httpHeaders.Set(k, v) - } - - // Establish WebSocket connection - dialer := websocket.Dialer{ - HandshakeTimeout: 30 * time.Second, - NetDialContext: (&net.Dialer{Control: s.dialControl, Resolver: dialResolver}).DialContext, - } - - conn, resp, err := dialer.DialContext(ctx, urlStr, httpHeaders) - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - if err != nil { - return "", fmt.Errorf("failed to connect: %w", err) - } - - wsConn := &wsConnection{ - conn: conn, - done: make(chan struct{}), - } - - s.mu.Lock() - s.connections[connectionID] = wsConn - s.mu.Unlock() - - // Start read goroutine with the service's base context instead of the - // caller's ctx, because the readLoop must outlive the Connect() call. - // Connections are closed by Close() when the plugin is unloaded, which ends - // the readLoop; the base context is a backstop that also ends it on server - // shutdown (it is never cancelled in one-shot CLI runs). - go s.readLoop(s.baseCtx, connectionID, wsConn) - - log.Debug(ctx, "WebSocket connected", "plugin", s.pluginName, "connectionID", connectionID, "url", urlStr) - return connectionID, nil -} - -func (s *webSocketServiceImpl) SendText(ctx context.Context, connectionID, message string) error { - wsConn, err := s.getConnection(connectionID) - if err != nil { - return err - } - - if err := wsConn.conn.WriteMessage(websocket.TextMessage, []byte(message)); err != nil { - return fmt.Errorf("failed to send text message: %w", err) - } - - return nil -} - -func (s *webSocketServiceImpl) SendBinary(ctx context.Context, connectionID string, data []byte) error { - wsConn, err := s.getConnection(connectionID) - if err != nil { - return err - } - - if err := wsConn.conn.WriteMessage(websocket.BinaryMessage, data); err != nil { - return fmt.Errorf("failed to send binary message: %w", err) - } - - return nil -} - -func (s *webSocketServiceImpl) CloseConnection(ctx context.Context, connectionID string, code int32, reason string) error { - s.mu.Lock() - wsConn, exists := s.connections[connectionID] - if !exists { - s.mu.Unlock() - return fmt.Errorf("connection ID %q not found", connectionID) - } - delete(s.connections, connectionID) - s.mu.Unlock() - - // Mark as closed to prevent callback - wsConn.closeMu.Lock() - wsConn.isClosed = true - wsConn.closeMu.Unlock() - - // Send close message - closeMsg := websocket.FormatCloseMessage(int(code), reason) - _ = wsConn.conn.WriteControl(websocket.CloseMessage, closeMsg, time.Now().Add(5*time.Second)) - _ = wsConn.conn.Close() - - // Signal read goroutine to stop - close(wsConn.done) - - // Invoke close callback - s.invokeOnClose(ctx, connectionID, code, reason) - - log.Debug(ctx, "WebSocket connection closed", "plugin", s.pluginName, "connectionID", connectionID, "code", code) - return nil -} - -// Close closes all connections for this plugin. -// This is called when the plugin is unloaded. -func (s *webSocketServiceImpl) Close() error { - s.mu.Lock() - connections := make(map[string]*wsConnection, len(s.connections)) - maps.Copy(connections, s.connections) - s.connections = make(map[string]*wsConnection) - s.mu.Unlock() - - ctx := context.Background() - for connID, wsConn := range connections { - wsConn.closeMu.Lock() - wsConn.isClosed = true - wsConn.closeMu.Unlock() - - closeMsg := websocket.FormatCloseMessage(websocket.CloseGoingAway, "plugin unloaded") - err := wsConn.conn.WriteControl(websocket.CloseMessage, closeMsg, time.Now().Add(2*time.Second)) - if err != nil { - log.Warn("Failed to send WebSocket close message on plugin unload", "plugin", s.pluginName, "connectionID", connID, "error", err) - } - err = wsConn.conn.Close() - if err != nil { - log.Warn("Failed to close WebSocket connection on plugin unload", "plugin", s.pluginName, "connectionID", connID, "error", err) - } - close(wsConn.done) - - s.invokeOnClose(ctx, connID, websocket.CloseGoingAway, "plugin unloaded") - log.Debug("WebSocket connection closed on plugin unload", "plugin", s.pluginName, "connectionID", connID) - } - - return nil -} - -func (s *webSocketServiceImpl) getConnection(connectionID string) (*wsConnection, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - wsConn, exists := s.connections[connectionID] - if !exists { - return nil, fmt.Errorf("connection ID %q not found", connectionID) - } - return wsConn, nil -} - -func (s *webSocketServiceImpl) isHostAllowed(host string) bool { - return isHostInAllowlist(s.requiredHosts, extractHostname(host)) -} - -func (s *webSocketServiceImpl) dialControl(_, address string, _ syscall.RawConn) error { - return checkPrivateDial(s.requiredHosts, address) -} - -// matchHostPattern matches a host against a pattern. -// Supports "*" (allow all) and wildcards like "*.example.com". -func matchHostPattern(pattern, host string) bool { - if pattern == "*" { - return true - } - if pattern == host { - return true - } - - // Handle wildcard patterns like *.example.com - if strings.HasPrefix(pattern, "*.") { - suffix := pattern[1:] // Get .example.com - return strings.HasSuffix(host, suffix) - } - - return false -} - -func (s *webSocketServiceImpl) readLoop(ctx context.Context, connectionID string, wsConn *wsConnection) { - defer func() { - // Remove connection if still present - s.mu.Lock() - delete(s.connections, connectionID) - s.mu.Unlock() - }() - - for { - select { - case <-wsConn.done: - return - default: - } - - messageType, data, err := wsConn.conn.ReadMessage() - if err != nil { - wsConn.closeMu.Lock() - isClosed := wsConn.isClosed - wsConn.closeMu.Unlock() - - if isClosed { - return - } - - // Check if it's a close error - if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { - closeCode := websocket.CloseNoStatusReceived - closeReason := "" - if ce, ok := errors.AsType[*websocket.CloseError](err); ok { - closeCode = ce.Code - closeReason = ce.Text - } - s.invokeOnClose(ctx, connectionID, int32(closeCode), closeReason) - return - } - - // Other read error - s.invokeOnError(ctx, connectionID, err.Error()) - return - } - - switch messageType { - case websocket.TextMessage: - s.invokeOnTextMessage(ctx, connectionID, string(data)) - case websocket.BinaryMessage: - s.invokeOnBinaryMessage(ctx, connectionID, data) - } - } -} - -// invokeWebSocketCallback is a generic helper that handles the common callback invocation pattern. -func invokeWebSocketCallback[I any](ctx context.Context, s *webSocketServiceImpl, funcName string, input I, callbackName string, connectionID string) { - instance := s.getPluginInstance() - if instance == nil { - return - } - - callbackCtx, cancel := context.WithTimeout(ctx, webSocketCallbackTimeout) - defer cancel() - - start := time.Now() - err := callPluginFunctionNoOutput(callbackCtx, instance, funcName, input) - if err != nil { - if !errors.Is(errFunctionNotFound, err) { - log.Error(ctx, "WebSocket "+callbackName+" callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err) - } - } -} - -func (s *webSocketServiceImpl) invokeOnTextMessage(ctx context.Context, connectionID, message string) { - invokeWebSocketCallback(ctx, s, FuncWebSocketOnTextMessage, capabilities.OnTextMessageRequest{ - ConnectionID: connectionID, - Message: message, - }, "text message", connectionID) -} - -func (s *webSocketServiceImpl) invokeOnBinaryMessage(ctx context.Context, connectionID string, data []byte) { - invokeWebSocketCallback(ctx, s, FuncWebSocketOnBinaryMessage, capabilities.OnBinaryMessageRequest{ - ConnectionID: connectionID, - Data: data, - }, "binary message", connectionID) -} - -func (s *webSocketServiceImpl) invokeOnError(ctx context.Context, connectionID, errorMsg string) { - invokeWebSocketCallback(ctx, s, FuncWebSocketOnError, capabilities.OnErrorRequest{ - ConnectionID: connectionID, - Error: errorMsg, - }, "error", connectionID) -} - -func (s *webSocketServiceImpl) invokeOnClose(ctx context.Context, connectionID string, code int32, reason string) { - invokeWebSocketCallback(ctx, s, FuncWebSocketOnClose, capabilities.OnCloseRequest{ - ConnectionID: connectionID, - Code: code, - Reason: reason, - }, "close", connectionID) -} - -func (s *webSocketServiceImpl) getPluginInstance() *plugin { - s.manager.mu.RLock() - instance, ok := s.manager.plugins[s.pluginName] - s.manager.mu.RUnlock() - - if !ok { - log.Warn("Plugin not loaded for WebSocket callback", "plugin", s.pluginName) - return nil - } - - return instance -} - -// Verify interface implementation -var _ host.WebSocketService = (*webSocketServiceImpl)(nil) diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go deleted file mode 100644 index 9b94e4b70..000000000 --- a/plugins/host_websocket_test.go +++ /dev/null @@ -1,658 +0,0 @@ -package plugins - -import ( - "context" - "crypto/sha256" - - "encoding/hex" - "maps" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strings" - "sync" - - "github.com/gorilla/websocket" - "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("WebSocketService", Ordered, func() { - var ( - manager *Manager - tmpDir string - testService *testableWebSocketService - ) - - BeforeAll(func() { - var err error - tmpDir, err = os.MkdirTemp("", "websocket-test-*") - Expect(err).ToNot(HaveOccurred()) - - // Copy the test-websocket plugin - srcPath := filepath.Join(testdataDir, "test-websocket"+PackageExtension) - destPath := filepath.Join(tmpDir, "test-websocket"+PackageExtension) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - - // Compute SHA256 for the plugin - hash := sha256.Sum256(data) - hashHex := hex.EncodeToString(hash[:]) - - // Setup config - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - - // Setup mock DataStore with pre-enabled plugin - mockPluginRepo := tests.CreateMockPluginRepo() - mockPluginRepo.Permitted = true - mockPluginRepo.SetData(model.Plugins{{ - ID: "test-websocket", - Path: destPath, - SHA256: hashHex, - Enabled: true, - }}) - dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo} - - // Create and start manager - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - subsonicRouter: http.NotFoundHandler(), - metrics: noopMetricsRecorder{}, - } - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - // Get WebSocket service from plugin's closers and wrap it for testing - service := findWebSocketService(manager, "test-websocket") - Expect(service).ToNot(BeNil()) - testService = &testableWebSocketService{webSocketServiceImpl: service} - - DeferCleanup(func() { - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) - }) - - BeforeEach(func() { - // Clean up any connections from previous tests - testService.closeAllConnections() - }) - - Describe("Plugin Loading", func() { - It("should detect WebSocket capability", func() { - names := manager.PluginNames(string(CapabilityWebSocket)) - Expect(names).To(ContainElement("test-websocket")) - }) - - It("should register WebSocket service for plugin", func() { - service := findWebSocketService(manager, "test-websocket") - Expect(service).ToNot(BeNil()) - }) - }) - - Describe("URL Validation", func() { - It("should reject invalid URL schemes", func() { - ctx := GinkgoT().Context() - _, err := testService.Connect(ctx, "http://example.com", nil, "test-conn") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid URL scheme")) - }) - - It("should reject disallowed hosts", func() { - ctx := GinkgoT().Context() - _, err := testService.Connect(ctx, "wss://evil.com/socket", nil, "test-conn") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not allowed")) - }) - - It("should allow hosts matching wildcard patterns", func() { - // test-websocket manifest allows *.example.com - // The pattern *.example.com matches any host ending with .example.com - ctx := context.Background() - allowed := testService.isHostAllowed("api.example.com") - Expect(allowed).To(BeTrue()) - - // Deep subdomains also match (ends with .example.com) - allowed = testService.isHostAllowed("sub.api.example.com") - Expect(allowed).To(BeTrue()) - - // But exact match without subdomain doesn't match *.example.com - allowed = testService.isHostAllowed("example.com") - Expect(allowed).To(BeFalse()) - _ = ctx - }) - - It("should allow exact host matches", func() { - // test-websocket manifest allows echo.websocket.org - allowed := testService.isHostAllowed("echo.websocket.org") - Expect(allowed).To(BeTrue()) - - allowed = testService.isHostAllowed("other.org") - Expect(allowed).To(BeFalse()) - }) - - DescribeTable("should match against the host with its port stripped", - func(allowed []string, host string, expected bool) { - svc := &webSocketServiceImpl{requiredHosts: allowed} - Expect(svc.isHostAllowed(host)).To(Equal(expected)) - }, - Entry("hostname with port", []string{"example.com"}, "example.com:8080", true), - Entry("IPv6 with port", []string{"::1"}, "[::1]:8080", true), - Entry("IPv6 without port", []string{"::1"}, "[::1]", true), - Entry("host not in the list", []string{"::2"}, "[::1]:8080", false), - // "localhost:*" is matched against the stripped "localhost", so it never hits - Entry("port wildcards are not supported", []string{"localhost:*"}, "localhost:8080", false), - ) - }) - - Describe("Connection Management", func() { - var wsServer *httptest.Server - var serverMessages []string - var serverMu sync.Mutex - - BeforeEach(func() { - serverMessages = nil - - upgrader := websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, - } - wsServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - - // Read messages until connection closes - for { - _, msg, err := conn.ReadMessage() - if err != nil { - break - } - serverMu.Lock() - serverMessages = append(serverMessages, string(msg)) - serverMu.Unlock() - } - })) - - // Add the server's host to allowed hosts for testing - // Since the implementation strips port before matching, we need to add - // the host without port - serverURL := strings.TrimPrefix(wsServer.URL, "http://") - hostOnly := serverURL - if idx := strings.LastIndex(serverURL, ":"); idx != -1 { - hostOnly = serverURL[:idx] - } - testService.requiredHosts = append(testService.requiredHosts, hostOnly) - }) - - AfterEach(func() { - testService.closeAllConnections() - if wsServer != nil { - wsServer.Close() - } - }) - - It("should connect to WebSocket server", func() { - ctx := GinkgoT().Context() - wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://") - connID, err := testService.Connect(ctx, wsURL, nil, "test-conn") - Expect(err).ToNot(HaveOccurred()) - Expect(connID).To(Equal("test-conn")) - Expect(testService.getConnectionCount()).To(Equal(1)) - }) - - It("should generate connection ID when not provided", func() { - ctx := GinkgoT().Context() - wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://") - connID, err := testService.Connect(ctx, wsURL, nil, "") - Expect(err).ToNot(HaveOccurred()) - Expect(connID).ToNot(BeEmpty()) - }) - - It("should reject duplicate connection IDs", func() { - ctx := GinkgoT().Context() - wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://") - _, err := testService.Connect(ctx, wsURL, nil, "dup-conn") - Expect(err).ToNot(HaveOccurred()) - - _, err = testService.Connect(ctx, wsURL, nil, "dup-conn") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("already exists")) - }) - - It("should send text messages", func() { - ctx := GinkgoT().Context() - wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://") - connID, err := testService.Connect(ctx, wsURL, nil, "send-text-conn") - Expect(err).ToNot(HaveOccurred()) - - err = testService.SendText(ctx, connID, "hello world") - Expect(err).ToNot(HaveOccurred()) - - // Give server time to receive the message - Eventually(func() []string { - serverMu.Lock() - defer serverMu.Unlock() - return serverMessages - }).Should(ContainElement("hello world")) - }) - - It("should send binary messages", func() { - ctx := GinkgoT().Context() - wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://") - connID, err := testService.Connect(ctx, wsURL, nil, "send-binary-conn") - Expect(err).ToNot(HaveOccurred()) - - binaryData := []byte{0x00, 0x01, 0x02, 0x03} - err = testService.SendBinary(ctx, connID, binaryData) - Expect(err).ToNot(HaveOccurred()) - - // Give server time to receive the message - Eventually(func() []string { - serverMu.Lock() - defer serverMu.Unlock() - return serverMessages - }).Should(ContainElement(string(binaryData))) - }) - - It("should close connections", func() { - ctx := GinkgoT().Context() - wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://") - connID, err := testService.Connect(ctx, wsURL, nil, "close-conn") - Expect(err).ToNot(HaveOccurred()) - Expect(testService.getConnectionCount()).To(Equal(1)) - - err = testService.CloseConnection(ctx, connID, 1000, "normal close") - Expect(err).ToNot(HaveOccurred()) - Expect(testService.getConnectionCount()).To(Equal(0)) - }) - - It("should return error for non-existent connection", func() { - ctx := GinkgoT().Context() - err := testService.SendText(ctx, "non-existent", "message") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not found")) - }) - }) - - Describe("Plugin Callbacks", func() { - var wsServer *httptest.Server - var serverConn *websocket.Conn - var serverMessages []string - var serverBinaryMessages [][]byte - var serverMu sync.Mutex - - BeforeEach(func() { - serverConn = nil - serverMessages = nil - serverBinaryMessages = nil - - upgrader := websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, - } - wsServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - serverMu.Lock() - serverConn = conn - serverMu.Unlock() - - // Read and store messages - for { - msgType, msg, err := conn.ReadMessage() - if err != nil { - break - } - serverMu.Lock() - if msgType == websocket.BinaryMessage { - serverBinaryMessages = append(serverBinaryMessages, msg) - } else { - serverMessages = append(serverMessages, string(msg)) - } - serverMu.Unlock() - } - })) - - serverURL := strings.TrimPrefix(wsServer.URL, "http://") - hostOnly := serverURL - if idx := strings.LastIndex(serverURL, ":"); idx != -1 { - hostOnly = serverURL[:idx] - } - testService.requiredHosts = append(testService.requiredHosts, hostOnly) - }) - - AfterEach(func() { - testService.closeAllConnections() - if wsServer != nil { - wsServer.Close() - } - }) - - It("should invoke OnBinaryMessage callback when receiving binary", func() { - ctx := GinkgoT().Context() - wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://") - _, err := testService.Connect(ctx, wsURL, nil, "binary-cb-conn") - Expect(err).ToNot(HaveOccurred()) - - // Wait for server to have the connection - Eventually(func() *websocket.Conn { - serverMu.Lock() - defer serverMu.Unlock() - return serverConn - }).ShouldNot(BeNil()) - - // Send binary message from server to plugin - binaryData := []byte{0xDE, 0xAD, 0xBE, 0xEF} - serverMu.Lock() - err = serverConn.WriteMessage(websocket.BinaryMessage, binaryData) - serverMu.Unlock() - Expect(err).ToNot(HaveOccurred()) - - // Plugin echoes binary data back as a binary message - Eventually(func() [][]byte { - serverMu.Lock() - defer serverMu.Unlock() - return serverBinaryMessages - }).Should(ContainElement(binaryData)) - }) - - It("should invoke OnClose callback when server closes connection", func() { - ctx := GinkgoT().Context() - wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://") - _, err := testService.Connect(ctx, wsURL, nil, "close-cb-conn") - Expect(err).ToNot(HaveOccurred()) - - // Wait for server to have the connection - Eventually(func() *websocket.Conn { - serverMu.Lock() - defer serverMu.Unlock() - return serverConn - }).ShouldNot(BeNil()) - - // Close from server side - serverMu.Lock() - _ = serverConn.WriteMessage(websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, "goodbye")) - serverConn.Close() - serverMu.Unlock() - - // Connection should be removed after close callback - Eventually(func() int { - return testService.getConnectionCount() - }).Should(Equal(0)) - }) - }) - - Describe("Plugin Host Function Calls", func() { - var wsServer *httptest.Server - var serverConn *websocket.Conn - var serverMessages []string - var serverMu sync.Mutex - - BeforeEach(func() { - serverMessages = nil - serverConn = nil - - upgrader := websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, - } - wsServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - serverMu.Lock() - serverConn = conn - serverMu.Unlock() - - // Read and store messages - for { - _, msg, err := conn.ReadMessage() - if err != nil { - break - } - serverMu.Lock() - serverMessages = append(serverMessages, string(msg)) - serverMu.Unlock() - } - })) - - serverURL := strings.TrimPrefix(wsServer.URL, "http://") - hostOnly := serverURL - if idx := strings.LastIndex(serverURL, ":"); idx != -1 { - hostOnly = serverURL[:idx] - } - testService.requiredHosts = append(testService.requiredHosts, hostOnly) - }) - - AfterEach(func() { - testService.closeAllConnections() - if wsServer != nil { - wsServer.Close() - } - }) - - It("should allow plugin to send messages via host function", func() { - ctx := GinkgoT().Context() - wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://") - _, err := testService.Connect(ctx, wsURL, nil, "host-send-conn") - Expect(err).ToNot(HaveOccurred()) - - // Wait for server to have the connection - Eventually(func() *websocket.Conn { - serverMu.Lock() - defer serverMu.Unlock() - return serverConn - }).ShouldNot(BeNil()) - - // Server sends "echo" message to trigger plugin to echo back - serverMu.Lock() - err = serverConn.WriteMessage(websocket.TextMessage, []byte("echo")) - serverMu.Unlock() - Expect(err).ToNot(HaveOccurred()) - - // Plugin should have echoed back via host function - Eventually(func() []string { - serverMu.Lock() - defer serverMu.Unlock() - return serverMessages - }).Should(ContainElement("echo:echo")) - }) - - It("should allow plugin to close connection via host function", func() { - ctx := GinkgoT().Context() - wsURL := "ws://" + strings.TrimPrefix(wsServer.URL, "http://") - _, err := testService.Connect(ctx, wsURL, nil, "host-close-conn") - Expect(err).ToNot(HaveOccurred()) - Expect(testService.getConnectionCount()).To(Equal(1)) - - // Wait for server to have the connection - Eventually(func() *websocket.Conn { - serverMu.Lock() - defer serverMu.Unlock() - return serverConn - }).ShouldNot(BeNil()) - - // Server sends "close" message to trigger plugin to close connection - serverMu.Lock() - err = serverConn.WriteMessage(websocket.TextMessage, []byte("close")) - serverMu.Unlock() - Expect(err).ToNot(HaveOccurred()) - - // Connection should be closed by plugin - Eventually(func() int { - return testService.getConnectionCount() - }).Should(Equal(0)) - }) - }) - - Describe("Private address protection", func() { - var wsServer *httptest.Server - var savedHosts []string - - BeforeEach(func() { - stubLocalhostDNS() - savedHosts = testService.requiredHosts - upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} - wsServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if conn, err := upgrader.Upgrade(w, r, nil); err == nil { - _, _, _ = conn.ReadMessage() - } - })) - }) - - AfterEach(func() { - testService.closeAllConnections() - testService.requiredHosts = savedHosts - wsServer.Close() - }) - - serverPort := func() string { - u, _ := url.Parse(wsServer.URL) - return u.Port() - } - - It("blocks an allowlisted hostname that resolves to loopback", func() { - testService.requiredHosts = []string{"localhost."} - _, err := testService.Connect(GinkgoT().Context(), "ws://localhost.:"+serverPort(), nil, "") - Expect(err).To(MatchError(ContainSubstring("private/loopback"))) - }) - - It("allows loopback when a CIDR entry covers it", func() { - testService.requiredHosts = []string{"127.0.0.0/8"} - _, err := testService.Connect(GinkgoT().Context(), "ws://127.0.0.1:"+serverPort(), nil, "") - Expect(err).ToNot(HaveOccurred()) - }) - - It("allows loopback when the allowlist is the bare '*' wildcard", func() { - testService.requiredHosts = []string{"*"} - _, err := testService.Connect(GinkgoT().Context(), "ws://localhost.:"+serverPort(), nil, "") - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("Plugin Unload", func() { - It("should close all connections when plugin is unloaded", func() { - // Create a fresh server for this test - upgrader := websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, - } - wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - // Keep alive - for { - _, _, err := conn.ReadMessage() - if err != nil { - break - } - } - })) - defer wsServer.Close() - - serverURL := strings.TrimPrefix(wsServer.URL, "http://") - hostOnly := serverURL - if idx := strings.LastIndex(serverURL, ":"); idx != -1 { - hostOnly = serverURL[:idx] - } - testService.requiredHosts = append(testService.requiredHosts, hostOnly) - - ctx := GinkgoT().Context() - wsURL := "ws://" + serverURL - - // Create multiple connections - _, err := testService.Connect(ctx, wsURL, nil, "unload-conn-1") - Expect(err).ToNot(HaveOccurred()) - _, err = testService.Connect(ctx, wsURL, nil, "unload-conn-2") - Expect(err).ToNot(HaveOccurred()) - Expect(testService.getConnectionCount()).To(Equal(2)) - - // Close the service (simulates plugin unload) - err = testService.Close() - Expect(err).ToNot(HaveOccurred()) - Expect(testService.getConnectionCount()).To(Equal(0)) - }) - }) - - Describe("matchHostPattern", func() { - It("should match exact hosts", func() { - Expect(matchHostPattern("example.com", "example.com")).To(BeTrue()) - Expect(matchHostPattern("example.com", "other.com")).To(BeFalse()) - }) - - It("should match wildcard patterns", func() { - Expect(matchHostPattern("*.example.com", "api.example.com")).To(BeTrue()) - Expect(matchHostPattern("*.example.com", "example.com")).To(BeFalse()) - Expect(matchHostPattern("*.example.com", "deep.api.example.com")).To(BeTrue()) - }) - - It("should match bare '*' as allow-all", func() { - Expect(matchHostPattern("*", "anything.example.com")).To(BeTrue()) - Expect(matchHostPattern("*", "127.0.0.1")).To(BeTrue()) - Expect(matchHostPattern("*", "::1")).To(BeTrue()) - }) - - It("should not match partial patterns", func() { - Expect(matchHostPattern("*.example.com", "example.com.evil.org")).To(BeFalse()) - }) - }) -}) - -// testableWebSocketService wraps webSocketServiceImpl with test helpers. -type testableWebSocketService struct { - *webSocketServiceImpl -} - -func (t *testableWebSocketService) getConnectionCount() int { - t.mu.RLock() - defer t.mu.RUnlock() - return len(t.connections) -} - -func (t *testableWebSocketService) closeAllConnections() { - t.mu.Lock() - conns := make(map[string]*wsConnection, len(t.connections)) - maps.Copy(conns, t.connections) - t.connections = make(map[string]*wsConnection) - t.mu.Unlock() - - for _, conn := range conns { - conn.closeMu.Lock() - conn.isClosed = true - conn.closeMu.Unlock() - _ = conn.conn.Close() - close(conn.done) - } -} - -// findWebSocketService finds the WebSocket service from a plugin's closers. -func findWebSocketService(m *Manager, pluginName string) *webSocketServiceImpl { - m.mu.RLock() - instance, ok := m.plugins[pluginName] - m.mu.RUnlock() - if !ok { - return nil - } - for _, closer := range instance.closers { - if svc, ok := closer.(*webSocketServiceImpl); ok { - return svc - } - } - return nil -} diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go deleted file mode 100644 index 9e02115e7..000000000 --- a/plugins/lyrics_adapter.go +++ /dev/null @@ -1,79 +0,0 @@ -package plugins - -import ( - "context" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/plugins/capabilities" -) - -const CapabilityLyrics Capability = "Lyrics" - -const ( - FuncLyricsGetLyrics = "nd_lyrics_get_lyrics" -) - -// maxConcurrentLyricsCalls caps in-flight lyrics calls per plugin: clients prefetch -// lyrics for whole queues, and the resulting burst can rate-limit upstream providers. -const maxConcurrentLyricsCalls = 2 - -func init() { - registerCapability( - CapabilityLyrics, - FuncLyricsGetLyrics, - ) -} - -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 - plugin *plugin -} - -// GetLyrics calls the plugin to fetch lyrics, then content-sniffs each response -// via model.ParseLyrics (TTML/SRT/YAML/LRC/plain). -func (l *LyricsPlugin) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { - select { - case l.plugin.lyricsSem <- struct{}{}: - defer func() { <-l.plugin.lyricsSem }() - case <-ctx.Done(): - return nil, ctx.Err() - } - req := capabilities.GetLyricsRequest{ - Track: mediaFileToTrackInfo(l.plugin, mf), - } - resp, err := callPluginFunction[capabilities.GetLyricsRequest, capabilities.GetLyricsResponse]( - ctx, l.plugin, FuncLyricsGetLyrics, req, - ) - if err != nil { - return nil, err - } - - // The lyric text comes from the plugin, not the media file's own tags, so - // attribute logs to both the plugin and the track it was fetched for. - ctx = log.NewContext(ctx, "plugin", l.name, "file", mf.Path) - - var result model.LyricList - for _, lt := range resp.Lyrics { - lang := lt.Lang - if lang == "" { - lang = "xxx" - } - parsed, err := model.ParseLyrics(ctx, "", lang, []byte(lt.Text)) - if err != nil { - log.Warn(ctx, "Error parsing plugin lyrics", err) - continue - } - for _, lyric := range parsed { - if !lyric.IsEmpty() { - result = append(result, lyric) - } - } - } - return result, nil -} diff --git a/plugins/lyrics_adapter_test.go b/plugins/lyrics_adapter_test.go deleted file mode 100644 index fe15e40b2..000000000 --- a/plugins/lyrics_adapter_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package plugins - -import ( - "context" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("LyricsPlugin", Ordered, func() { - var ( - lyricsManager *Manager - provider *LyricsPlugin - ) - - BeforeAll(func() { - lyricsManager, _ = createTestManagerWithPlugins(nil, - "test-lyrics"+PackageExtension, - "test-metadata-agent"+PackageExtension, - ) - - p, ok := lyricsManager.LoadLyricsProvider("test-lyrics") - Expect(ok).To(BeTrue()) - provider = p.(*LyricsPlugin) - }) - - Describe("LoadLyricsProvider", func() { - It("returns a lyrics provider for a plugin with Lyrics capability", func() { - Expect(provider).ToNot(BeNil()) - }) - - It("returns false for a plugin without Lyrics capability", func() { - _, ok := lyricsManager.LoadLyricsProvider("test-metadata-agent") - Expect(ok).To(BeFalse()) - }) - - It("returns false for non-existent plugin", func() { - _, ok := lyricsManager.LoadLyricsProvider("non-existent") - Expect(ok).To(BeFalse()) - }) - }) - - Describe("GetLyrics", func() { - It("successfully returns lyrics from the plugin", func() { - track := &model.MediaFile{ - ID: "track-1", - Title: "Test Song", - Artist: "Test Artist", - } - - result, err := provider.GetLyrics(GinkgoT().Context(), track) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result[0].Line).ToNot(BeEmpty()) - Expect(result[0].Line[0].Value).To(ContainSubstring("Test Song")) - }) - - It("defaults language to 'xxx' when plugin does not provide one", func() { - manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ - "test-lyrics": {"no_lang": "true"}, - }, "test-lyrics"+PackageExtension) - - p, ok := manager.LoadLyricsProvider("test-lyrics") - Expect(ok).To(BeTrue()) - - track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"} - result, err := p.GetLyrics(GinkgoT().Context(), track) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result[0].Lang).To(Equal("xxx")) - }) - - It("blocks new calls while the per-plugin concurrency cap is saturated", func() { - sem := provider.plugin.lyricsSem - for range cap(sem) { - sem <- struct{}{} - } - - ctx := GinkgoT().Context() - track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"} - done := make(chan error, 1) - go func() { - _, err := provider.GetLyrics(ctx, track) - done <- err - }() - - Consistently(done, "500ms").ShouldNot(Receive()) - <-sem // free one slot; the pending call should now proceed - Eventually(done).Should(Receive(BeNil())) - for range cap(sem) - 1 { - <-sem - } - }) - - It("gives up waiting for a slot when the context is cancelled", func() { - sem := provider.plugin.lyricsSem - for range cap(sem) { - sem <- struct{}{} - } - defer func() { - for range cap(sem) { - <-sem - } - }() - - ctx, cancel := context.WithCancel(GinkgoT().Context()) - cancel() - _, err := provider.GetLyrics(ctx, &model.MediaFile{ID: "track-1"}) - Expect(err).To(MatchError(context.Canceled)) - }) - - It("returns error when plugin returns error", func() { - manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ - "test-lyrics": {"error": "service unavailable"}, - }, "test-lyrics"+PackageExtension) - - p, ok := manager.LoadLyricsProvider("test-lyrics") - Expect(ok).To(BeTrue()) - - track := &model.MediaFile{ID: "track-1", Title: "Test Song"} - _, err := p.GetLyrics(GinkgoT().Context(), track) - Expect(err).To(HaveOccurred()) - }) - - // Each DescribeTable entry proves that the adapter's content-sniffing routes - // the plugin's rich payload to the right parser rather than mangling it as plain text. - DescribeTable("content-sniffs plugin responses across all supported formats", - func(format string, wantSynced bool, wantLine string) { - manager, _ := createTestManagerWithPlugins(map[string]map[string]string{ - "test-lyrics": {"format": format}, - }, "test-lyrics"+PackageExtension) - - p, ok := manager.LoadLyricsProvider("test-lyrics") - Expect(ok).To(BeTrue()) - - track := &model.MediaFile{ID: "track-1", Title: "Test Song", Artist: "Test Artist"} - result, err := p.GetLyrics(GinkgoT().Context(), track) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result[0].Synced).To(Equal(wantSynced), "unexpected Synced value for format %s", format) - Expect(result[0].Line).To(HaveLen(1)) - Expect(result[0].Line[0].Value).To(Equal(wantLine)) - }, - Entry("ttml", "ttml", true, "plugin ttml line"), - Entry("srt", "srt", true, "plugin srt line"), - Entry("yaml", "yaml", true, "plugin yaml line"), - Entry("lrc", "lrc", true, "plugin lrc line"), - Entry("plain", "plain", false, "plugin plain line"), - ) - }) - - Describe("PluginNames", func() { - It("returns plugin names with Lyrics capability", func() { - names := lyricsManager.PluginNames("Lyrics") - Expect(names).To(ContainElement("test-lyrics")) - }) - - It("does not return metadata agent plugins for Lyrics capability", func() { - names := lyricsManager.PluginNames("Lyrics") - Expect(names).ToNot(ContainElement("test-metadata-agent")) - }) - }) -}) diff --git a/plugins/manager.go b/plugins/manager.go deleted file mode 100644 index 90247a75c..000000000 --- a/plugins/manager.go +++ /dev/null @@ -1,679 +0,0 @@ -package plugins - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "path/filepath" - "runtime" - "sync" - "sync/atomic" - "time" - - "github.com/Masterminds/squirrel" - extism "github.com/extism/go-sdk" - "github.com/navidrome/navidrome/conf" - "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" - "github.com/navidrome/navidrome/utils/singleton" - "github.com/rjeczalik/notify" - "github.com/tetratelabs/wazero" -) - -const ( - // defaultTimeout is the default timeout for plugin function calls - defaultTimeout = 30 * time.Second - - // maxPluginLoadConcurrency is the maximum number of plugins that can be - // compiled/loaded in parallel during startup - maxPluginLoadConcurrency = 3 -) - -// SubsonicRouter is an http.Handler that serves Subsonic API requests. -type SubsonicRouter = http.Handler - -// PluginMetricsRecorder is an interface for recording plugin metrics. -// This is satisfied by core/metrics.Metrics but defined here to avoid import cycles. -type PluginMetricsRecorder interface { - RecordPluginRequest(ctx context.Context, plugin, method string, ok bool, elapsed int64) -} - -// Manager manages loading and lifecycle of WebAssembly plugins. -// It implements both agents.PluginLoader and scrobbler.PluginLoader interfaces. -type Manager struct { - mu sync.RWMutex - plugins map[string]*plugin - ctx context.Context //nolint:containedctx // manager lifecycle ctx, cancelled by Stop - cancel context.CancelFunc - cache wazero.CompilationCache - stopped atomic.Bool // Set to true when Stop() is called - loadWg sync.WaitGroup // Tracks in-flight plugin load operations - - // File watcher fields (used when AutoReload is enabled) - watcherEvents chan notify.EventInfo - watcherDone chan struct{} - debounceTimers map[string]*time.Timer - debounceMu sync.Mutex - - // transient is set by LoadPlugins, and nil for a server Start. - transient *transientLoad - - // SubsonicAPI host function dependencies (set once before Start, not modified after) - subsonicRouter SubsonicRouter - ds model.DataStore - broker events.Broker - metrics PluginMetricsRecorder -} - -// GetManager returns a singleton instance of the plugin manager. -// The manager is not started automatically; call Start() to begin loading plugins. -func GetManager(ds model.DataStore, broker events.Broker, m PluginMetricsRecorder) *Manager { - return singleton.GetInstance(func() *Manager { - return &Manager{ - ds: ds, - broker: broker, - metrics: m, - plugins: make(map[string]*plugin), - } - }) -} - -// sendPluginRefreshEvent broadcasts a refresh event for the plugin resource. -// This notifies connected UI clients that plugin data has changed. -func (m *Manager) sendPluginRefreshEvent(ctx context.Context, pluginIDs ...string) { - if m.broker == nil { - return - } - event := (&events.RefreshResource{}).With("plugin", pluginIDs...) - m.broker.SendBroadcastMessage(ctx, event) -} - -// SetSubsonicRouter sets the Subsonic router for SubsonicAPI host functions. -// This should be called after the subsonic router is created but before plugins -// that require SubsonicAPI access are loaded. -func (m *Manager) SetSubsonicRouter(router SubsonicRouter) { - m.subsonicRouter = router -} - -// Start initializes the plugin manager and loads plugins from the configured folder. -// It should be called once during application startup when plugins are enabled. -// The startup flow is: -// 1. Sync plugins folder with DB (discover new, update changed, remove deleted) -// 2. Load only enabled plugins from DB -func (m *Manager) Start(ctx context.Context) error { - if !conf.Server.Plugins.Enabled { - log.Debug(ctx, "Plugin system is disabled") - return nil - } - - if m.subsonicRouter == nil { - log.Fatal(ctx, "Plugin manager requires the SubsonicAPI router to be configured") - } - - cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins") - purgeCacheBySize(ctx, cacheDir, conf.Server.Plugins.CacheSize) - - if err := m.initRuntime(ctx, cacheDir); err != nil { - return err - } - - if conf.Server.Plugins.Folder.String() == "" { - log.Debug(ctx, "No plugins folder configured") - return nil - } - - folder := conf.Server.Plugins.Folder.MustPath() - - log.Info(ctx, "Starting plugin manager", "folder", folder) - - // Clear previous error states so plugins can be retried on restart - adminCtx := adminContext(ctx) - if err := m.ds.Plugin().ClearErrors(adminCtx); err != nil { - log.Error(ctx, "Error clearing plugin errors", err) - } - - // Sync plugins folder with DB - if err := m.syncPlugins(ctx, folder); err != nil { - log.Error(ctx, "Error syncing plugins with DB", err) - // Continue - we can still try to load plugins - } - - // Load enabled plugins from DB - if err := m.loadEnabledPlugins(ctx); err != nil { - log.Error(ctx, "Error loading enabled plugins", err) - return fmt.Errorf("loading enabled plugins: %w", err) - } - - // Start file watcher if auto-reload is enabled - if conf.Server.Plugins.AutoReload { - if err := m.startWatcher(); err != nil { - log.Error(ctx, "Failed to start plugin file watcher", err) - // Non-fatal - plugins are still loaded, just no auto-reload - } - } - - return nil -} - -// initRuntime prepares the extism/wazero runtime that instantiating a plugin needs. -func (m *Manager) initRuntime(ctx context.Context, cacheDir string) error { - pluginLogLevel := conf.Server.Plugins.LogLevel - if pluginLogLevel == "" { - pluginLogLevel = conf.Server.LogLevel - } - extism.SetLogLevel(toExtismLogLevel(log.ParseLogLevel(pluginLogLevel))) - - m.ctx, m.cancel = context.WithCancel(ctx) - - var err error - m.cache, err = wazero.NewCompilationCacheWithDir(cacheDir) - if err != nil { - log.Error(ctx, "Failed to create wazero compilation cache", err) - return fmt.Errorf("creating wazero compilation cache: %w", err) - } - return nil -} - -// transientLoad scopes a load that will not outlive the command asking for it; see LoadPlugins. -type transientLoad struct { - only []string - runInit bool -} - -// LoadPlugins loads the plugins named in only, so a CLI sees the agents a server would. Each is -// instantiated, creating any KVStore, TaskQueue or Storage it declares; call Stop when done. -func (m *Manager) LoadPlugins(ctx context.Context, only []string, runInit bool) error { - if !conf.Server.Plugins.Enabled || conf.Server.Plugins.Folder.String() == "" || len(only) == 0 { - return nil - } - m.transient = &transientLoad{only: only, runInit: runInit} - - cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins") - if err := m.initRuntime(ctx, cacheDir); err != nil { - return err - } - if err := m.loadEnabledPlugins(ctx); err != nil { - return fmt.Errorf("loading enabled plugins: %w", err) - } - return nil -} - -// Stop shuts down the plugin manager and releases all resources. -func (m *Manager) Stop() error { - // Mark as stopped first to prevent new operations - m.stopped.Store(true) - - // Cancel context to signal all goroutines to stop - if m.cancel != nil { - m.cancel() - } - - // Stop file watcher - m.stopWatcher() - - // Wait for all in-flight plugin load operations to complete - // This is critical to avoid races with cache.Close() - m.loadWg.Wait() - - m.mu.Lock() - defer m.mu.Unlock() - - // Close all plugins - for name, plugin := range m.plugins { - err := plugin.Close() - if err != nil { - log.Error("Error during plugin cleanup", "plugin", name, err) - } - if plugin.compiled != nil { - if err := plugin.compiled.Close(context.Background()); err != nil { - log.Error("Error closing plugin", "plugin", name, err) - } - } - } - m.plugins = make(map[string]*plugin) - - // Close compilation cache - if m.cache != nil { - if err := m.cache.Close(context.Background()); err != nil { - log.Error("Error closing wazero cache", err) - } - m.cache = nil - } - - return nil -} - -// PluginNames returns the names of all plugins that implement a particular capability. -// This is used by both agents and scrobbler systems to discover available plugins. -// Capabilities are auto-detected from the plugin's exported functions. -func (m *Manager) PluginNames(capability string) []string { - m.mu.RLock() - defer m.mu.RUnlock() - - var names []string - cap := Capability(capability) - for name, plugin := range m.plugins { - if hasCapability(plugin.capabilities, cap) { - names = append(names, name) - } - } - return names -} - -func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) { - return loadPlugin(m, name, CapabilityMetadataAgent, newMetadataAgent) -} - -func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { - return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin) -} - -func (m *Manager) LoadLyricsProvider(name string) (lyrics.Provider, 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() - p, ok := m.plugins[name] - m.mu.RUnlock() - - var zero T - if !ok || !hasCapability(p.capabilities, cap) { - return zero, false - } - return newAdapter(p), true -} - -// PluginInfo contains basic information about a plugin for metrics/insights. -type PluginInfo struct { - Name string - Version string -} - -// GetPluginInfo returns information about all loaded plugins. -func (m *Manager) GetPluginInfo() map[string]PluginInfo { - m.mu.RLock() - defer m.mu.RUnlock() - - info := make(map[string]PluginInfo, len(m.plugins)) - for name, plugin := range m.plugins { - info[name] = PluginInfo{ - Name: plugin.manifest.Name, - Version: plugin.manifest.Version, - } - } - return info -} - -// EnablePlugin enables a plugin by loading it and updating the DB. -// Returns an error if the plugin is not found in DB or fails to load. -func (m *Manager) EnablePlugin(ctx context.Context, id string) error { - if m.ds == nil { - return fmt.Errorf("datastore not configured") - } - - adminCtx := adminContext(ctx) - repo := m.ds.Plugin() - - plugin, err := repo.Get(adminCtx, id) - if err != nil { - return fmt.Errorf("getting plugin from DB: %w", err) - } - - if plugin.Enabled { - return nil // Already enabled - } - - // Check permission gates before enabling - if err := m.checkPermissionGates(plugin); err != nil { - return err - } - - // Try to load the plugin - if err := m.loadPluginWithConfig(plugin); err != nil { - // Store error and return - plugin.LastError = err.Error() - plugin.UpdatedAt = time.Now() - _ = repo.Put(adminCtx, plugin) - return fmt.Errorf("loading plugin: %w", err) - } - - // Update DB - plugin.Enabled = true - plugin.LastError = "" - plugin.UpdatedAt = time.Now() - if err := repo.Put(adminCtx, plugin); err != nil { - // Unload since we couldn't update DB - _ = m.unloadPlugin(id) - return fmt.Errorf("updating plugin in DB: %w", err) - } - - log.Info(ctx, "Enabled plugin", "plugin", id) - m.sendPluginRefreshEvent(ctx, id) - return nil -} - -// DisablePlugin disables a plugin by unloading it and updating the DB. -// Returns an error if the plugin is not found in DB. -func (m *Manager) DisablePlugin(ctx context.Context, id string) error { - if m.ds == nil { - return fmt.Errorf("datastore not configured") - } - - adminCtx := adminContext(ctx) - repo := m.ds.Plugin() - - plugin, err := repo.Get(adminCtx, id) - if err != nil { - return fmt.Errorf("getting plugin from DB: %w", err) - } - - if !plugin.Enabled { - return nil // Already disabled - } - - // Unload the plugin - if err := m.unloadPlugin(id); err != nil { - log.Debug(ctx, "Plugin was not loaded", "plugin", id) - } - - // Update DB - plugin.Enabled = false - plugin.UpdatedAt = time.Now() - if err := repo.Put(adminCtx, plugin); err != nil { - return fmt.Errorf("updating plugin in DB: %w", err) - } - - log.Info(ctx, "Disabled plugin", "plugin", id) - m.sendPluginRefreshEvent(ctx, id) - return nil -} - -// ValidatePluginConfig validates a config JSON string against the plugin's config schema. -// If the plugin has no config schema defined, it returns an error. -// Returns nil if validation passes, or an error describing the validation failure. -func (m *Manager) ValidatePluginConfig(ctx context.Context, id, configJSON string) error { - if m.ds == nil { - return fmt.Errorf("datastore not configured") - } - - adminCtx := adminContext(ctx) - repo := m.ds.Plugin() - - plugin, err := repo.Get(adminCtx, id) - if err != nil { - return fmt.Errorf("getting plugin from DB: %w", err) - } - - manifest, err := ReadManifest(plugin.Path) - if err != nil { - return fmt.Errorf("reading manifest: %w", err) - } - - return ValidateConfig(manifest, configJSON) -} - -// UpdatePluginConfig updates the configuration for a plugin. -// If the plugin is enabled, it will be reloaded with the new config. -func (m *Manager) UpdatePluginConfig(ctx context.Context, id, configJSON string) error { - return m.updatePluginSettings(ctx, id, func(p *model.Plugin) { - p.Config = configJSON - }) -} - -// UpdatePluginUsers updates the users permission settings for a plugin. -// If the plugin is enabled, it will be reloaded with the new settings. -// If the plugin requires users permission and no users are configured (and allUsers is false), -// the plugin will be automatically disabled. -func (m *Manager) UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error { - return m.updatePluginSettings(ctx, id, func(p *model.Plugin) { - p.Users = usersJSON - p.AllUsers = allUsers - }) -} - -// UpdatePluginLibraries updates the libraries permission settings for a plugin. -// If the plugin is enabled, it will be reloaded with the new settings. -// If the plugin requires library permission and no libraries are configured (and allLibraries is false), -// the plugin will be automatically disabled. -func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error { - return m.updatePluginSettings(ctx, id, func(p *model.Plugin) { - p.Libraries = librariesJSON - p.AllLibraries = allLibraries - p.AllowWriteAccess = allowWriteAccess - }) -} - -// RescanPlugins triggers a manual rescan of the plugins folder. -// This synchronizes the database with the filesystem, discovering new plugins, -// updating changed ones, and removing deleted ones. -func (m *Manager) RescanPlugins(ctx context.Context) error { - folder := conf.Server.Plugins.Folder.String() - if folder == "" { - return fmt.Errorf("plugins folder not configured") - } - log.Info(ctx, "Manual plugin rescan requested", "folder", folder) - return m.syncPlugins(ctx, folder) -} - -// updatePluginSettings is a common implementation for updating plugin settings. -// The updateFn is called to apply the specific field updates to the plugin. -// If the plugin is enabled, it will be reloaded. If users permission is required -// but no longer satisfied, the plugin will be disabled. -func (m *Manager) updatePluginSettings(ctx context.Context, id string, updateFn func(*model.Plugin)) error { - if m.ds == nil { - return fmt.Errorf("datastore not configured") - } - - adminCtx := adminContext(ctx) - repo := m.ds.Plugin() - - plugin, err := repo.Get(adminCtx, id) - if err != nil { - return fmt.Errorf("getting plugin from DB: %w", err) - } - - wasEnabled := plugin.Enabled - - // Apply the specific updates - updateFn(plugin) - plugin.UpdatedAt = time.Now() - - // Check if plugin requires permission and if it's still satisfied - shouldDisable := false - disableReason := "" - if wasEnabled { - manifest, err := ReadManifest(plugin.Path) - if err == nil && manifest.Permissions != nil { - if manifest.Permissions.Users != nil && !hasValidUsersConfig(plugin.Users, plugin.AllUsers) { - shouldDisable = true - disableReason = "users permission removal" - } - if manifest.Permissions.Library != nil && !hasValidLibrariesConfig(plugin.Libraries, plugin.AllLibraries) { - shouldDisable = true - disableReason = "library permission removal" - } - } - } - - if shouldDisable { - // Disable the plugin since permission is no longer satisfied - if err := m.unloadPlugin(id); err != nil { - log.Debug(ctx, "Plugin was not loaded", "plugin", id) - } - plugin.Enabled = false - if err := repo.Put(adminCtx, plugin); err != nil { - return fmt.Errorf("updating plugin in DB: %w", err) - } - log.Info(ctx, "Disabled plugin due to "+disableReason, "plugin", id) - m.sendPluginRefreshEvent(ctx, id) - return nil - } - - if err := repo.Put(adminCtx, plugin); err != nil { - return fmt.Errorf("updating plugin in DB: %w", err) - } - - // Reload if enabled - if wasEnabled { - if err := m.unloadPlugin(id); err != nil { - log.Debug(ctx, "Plugin was not loaded", "plugin", id) - } - if err := m.loadPluginWithConfig(plugin); err != nil { - plugin.LastError = err.Error() - plugin.Enabled = false - _ = repo.Put(adminCtx, plugin) - return fmt.Errorf("reloading plugin: %w", err) - } - } - - log.Info(ctx, "Updated plugin settings", "plugin", id) - m.sendPluginRefreshEvent(ctx, id) - return nil -} - -// unloadPlugin removes a plugin from the manager and closes its resources. -// Returns an error if the plugin is not found. -func (m *Manager) unloadPlugin(name string) error { - m.mu.Lock() - plugin, ok := m.plugins[name] - if !ok { - m.mu.Unlock() - return fmt.Errorf("plugin %q not found", name) - } - delete(m.plugins, name) - m.mu.Unlock() - - // Run cleanup functions - err := plugin.Close() - if err != nil { - log.Error("Error during plugin cleanup", "plugin", name, err) - } - - // Close the compiled plugin outside the lock with a grace period - // to allow in-flight requests to complete - if plugin.compiled != nil { - // Use a brief timeout for cleanup - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := plugin.compiled.Close(ctx); err != nil { - log.Error("Error closing plugin during unload", "plugin", name, err) - } - } - - runtime.GC() - log.Info(m.ctx, "Unloaded plugin", "plugin", name) - return nil -} - -// UnloadDisabledPlugins checks for plugins that are disabled in the database -// but still loaded in memory, and unloads them. This is called after user or -// library deletion to clean up plugins that were auto-disabled due to -// permission loss. -func (m *Manager) UnloadDisabledPlugins(ctx context.Context) { - if m.ds == nil { - return - } - - adminCtx := adminContext(ctx) - repo := m.ds.Plugin() - - // Get all disabled plugins from the database - plugins, err := repo.GetAll(adminCtx, model.QueryOptions{ - Filters: squirrel.Eq{"enabled": false}, - }) - if err != nil { - log.Error(ctx, "Failed to get disabled plugins", err) - return - } - - // Check each disabled plugin and unload if still in memory - var unloaded []string - for _, p := range plugins { - m.mu.RLock() - _, loaded := m.plugins[p.ID] - m.mu.RUnlock() - - if loaded { - if err := m.unloadPlugin(p.ID); err != nil { - log.Warn(ctx, "Failed to unload disabled plugin", "plugin", p.ID, err) - } else { - unloaded = append(unloaded, p.ID) - log.Info(ctx, "Unloaded disabled plugin", "plugin", p.ID) - } - } - } - - // Send refresh events for unloaded plugins - if len(unloaded) > 0 { - m.sendPluginRefreshEvent(ctx, unloaded...) - } -} - -// checkPermissionGates validates that all permission-based requirements are met -// before a plugin can be enabled. Returns an error if any gate condition fails. -func (m *Manager) checkPermissionGates(p *model.Plugin) error { - // Parse manifest to check permissions - manifest, err := ReadManifest(p.Path) - if err != nil { - return fmt.Errorf("reading manifest: %w", err) - } - - // Check users permission gate - if manifest.Permissions != nil && manifest.Permissions.Users != nil { - if !hasValidUsersConfig(p.Users, p.AllUsers) { - return fmt.Errorf("users permission requires configuration: select users or enable 'all users' access") - } - } - - // Check library permission gate - if manifest.Permissions != nil && manifest.Permissions.Library != nil { - if !hasValidLibrariesConfig(p.Libraries, p.AllLibraries) { - return fmt.Errorf("library permission requires configuration: select libraries or enable 'all libraries' access") - } - } - - return nil -} - -// hasValidUsersConfig checks if a plugin has valid users configuration. -// Returns true if allUsers is true, or if usersJSON contains at least one user. -func hasValidUsersConfig(usersJSON string, allUsers bool) bool { - if allUsers { - return true - } - if usersJSON == "" { - return false - } - var users []string - if err := json.Unmarshal([]byte(usersJSON), &users); err != nil { - return false - } - return len(users) > 0 -} - -// hasValidLibrariesConfig checks if a plugin has valid libraries configuration. -// Returns true if allLibraries is true, or if librariesJSON contains at least one library. -func hasValidLibrariesConfig(librariesJSON string, allLibraries bool) bool { - if allLibraries { - return true - } - if librariesJSON == "" { - return false - } - var libraries []int - if err := json.Unmarshal([]byte(librariesJSON), &libraries); err != nil { - return false - } - return len(libraries) > 0 -} diff --git a/plugins/manager_cache.go b/plugins/manager_cache.go deleted file mode 100644 index 74b27171f..000000000 --- a/plugins/manager_cache.go +++ /dev/null @@ -1,92 +0,0 @@ -package plugins - -import ( - "cmp" - "context" - "io/fs" - "os" - "path/filepath" - "slices" - "time" - - "github.com/dustin/go-humanize" - "github.com/navidrome/navidrome/log" -) - -// purgeCacheBySize removes the oldest files in dir until its total size is -// lower than or equal to maxSize. maxSize should be a human-readable string -// like "10MB" or "200K". If parsing fails or maxSize is "0", the function is -// a no-op. -func purgeCacheBySize(ctx context.Context, dir, maxSize string) { - sizeLimit, err := humanize.ParseBytes(maxSize) - if err != nil || sizeLimit == 0 { - return - } - - type fileInfo struct { - path string - size uint64 - mod int64 - } - - var files []fileInfo - var total uint64 - - walk := func(path string, d fs.DirEntry, err error) error { - if err != nil { - log.Trace(ctx, "Failed to access plugin cache entry", "path", path, err) - return nil //nolint:nilerr - } - if d.IsDir() { - return nil - } - info, err := d.Info() - if err != nil { - log.Trace(ctx, "Failed to get file info for plugin cache entry", "path", path, err) - return nil //nolint:nilerr - } - files = append(files, fileInfo{ - path: path, - size: uint64(info.Size()), - mod: info.ModTime().UnixMilli(), - }) - total += uint64(info.Size()) - return nil - } - - if err := filepath.WalkDir(dir, walk); err != nil { - if !os.IsNotExist(err) { - log.Warn(ctx, "Failed to traverse plugin cache directory", "path", dir, err) - } - return - } - - log.Trace(ctx, "Current plugin cache size", "path", dir, "size", humanize.Bytes(total), "sizeLimit", humanize.Bytes(sizeLimit)) - if total <= sizeLimit { - return - } - - log.Debug(ctx, "Purging plugin cache", "path", dir, "sizeLimit", humanize.Bytes(sizeLimit), "currentSize", humanize.Bytes(total)) - slices.SortFunc(files, func(i, j fileInfo) int { return cmp.Compare(i.mod, j.mod) }) - - for _, f := range files { - if total <= sizeLimit { - break - } - if err := os.Remove(f.path); err != nil { - log.Warn(ctx, "Failed to remove plugin cache entry", "path", f.path, "size", humanize.Bytes(f.size), err) - continue - } - total -= f.size - log.Debug(ctx, "Removed plugin cache entry", "path", f.path, "size", humanize.Bytes(f.size), "time", time.UnixMilli(f.mod), "remainingSize", humanize.Bytes(total)) - - // Remove empty parent directories - dirPath := filepath.Dir(f.path) - for dirPath != dir { - if err := os.Remove(dirPath); err != nil { - break - } - dirPath = filepath.Dir(dirPath) - } - } -} diff --git a/plugins/manager_cache_test.go b/plugins/manager_cache_test.go deleted file mode 100644 index 3dbfa45ee..000000000 --- a/plugins/manager_cache_test.go +++ /dev/null @@ -1,188 +0,0 @@ -package plugins - -import ( - "context" - "os" - "path/filepath" - "strconv" - "time" - - "github.com/dustin/go-humanize" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("purgeCacheBySize", func() { - var ( - tmpDir string - ctx context.Context - ) - - BeforeEach(func() { - var err error - ctx = GinkgoT().Context() - tmpDir, err = os.MkdirTemp("", "cache-purge-test-*") - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - os.RemoveAll(tmpDir) - }) - - createFileWithSize := func(path string, sizeBytes int64, modTime time.Time) { - dir := filepath.Dir(path) - err := os.MkdirAll(dir, 0755) - Expect(err).ToNot(HaveOccurred()) - - f, err := os.Create(path) - Expect(err).ToNot(HaveOccurred()) - defer f.Close() - - // Write random data to reach desired size - if sizeBytes > 0 { - err = f.Truncate(sizeBytes) - Expect(err).ToNot(HaveOccurred()) - } - - // Set modification time - err = os.Chtimes(path, modTime, modTime) - Expect(err).ToNot(HaveOccurred()) - } - - getDirSize := func(dir string) uint64 { - var total uint64 - err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { - if err != nil || d.IsDir() { - return nil - } - info, err := d.Info() - if err != nil { - return nil - } - total += uint64(info.Size()) - return nil - }) - Expect(err).ToNot(HaveOccurred()) - return total - } - - Context("when maxSize is invalid or zero", func() { - It("should not remove any files with invalid size", func() { - cacheDir := filepath.Join(tmpDir, "cache") - createFileWithSize(filepath.Join(cacheDir, "file1.bin"), 1000, time.Now()) - createFileWithSize(filepath.Join(cacheDir, "file2.bin"), 1000, time.Now()) - - purgeCacheBySize(ctx, cacheDir, "invalid") - - Expect(getDirSize(cacheDir)).To(Equal(uint64(2000))) - }) - - It("should not remove any files when maxSize is 0", func() { - cacheDir := filepath.Join(tmpDir, "cache") - createFileWithSize(filepath.Join(cacheDir, "file1.bin"), 1000, time.Now()) - createFileWithSize(filepath.Join(cacheDir, "file2.bin"), 1000, time.Now()) - - purgeCacheBySize(ctx, cacheDir, "0") - - Expect(getDirSize(cacheDir)).To(Equal(uint64(2000))) - }) - }) - - Context("when cache directory doesn't exist", func() { - It("should not error", func() { - nonExistentDir := filepath.Join(tmpDir, "nonexistent") - Expect(func() { - purgeCacheBySize(ctx, nonExistentDir, "100MB") - }).ToNot(Panic()) - }) - }) - - Context("when total size is under limit", func() { - It("should not remove any files", func() { - cacheDir := filepath.Join(tmpDir, "cache") - createFileWithSize(filepath.Join(cacheDir, "file1.bin"), 1000, time.Now()) - createFileWithSize(filepath.Join(cacheDir, "file2.bin"), 1000, time.Now()) - - purgeCacheBySize(ctx, cacheDir, "10KB") - - Expect(getDirSize(cacheDir)).To(Equal(uint64(2000))) - }) - }) - - Context("when total size exceeds limit", func() { - It("should remove oldest files first", func() { - cacheDir := filepath.Join(tmpDir, "cache") - now := time.Now() - - // Create files with different ages (1MB each) - oldestFile := filepath.Join(cacheDir, "old.bin") - middleFile := filepath.Join(cacheDir, "middle.bin") - newestFile := filepath.Join(cacheDir, "new.bin") - - createFileWithSize(oldestFile, 1*1024*1024, now.Add(-3*time.Hour)) - createFileWithSize(middleFile, 1*1024*1024, now.Add(-2*time.Hour)) - createFileWithSize(newestFile, 1*1024*1024, now.Add(-1*time.Hour)) - - // Set limit to 2MiB - should remove oldest file - purgeCacheBySize(ctx, cacheDir, "2MiB") - - // Oldest should be removed - _, err := os.Stat(oldestFile) - Expect(os.IsNotExist(err)).To(BeTrue(), "oldest file should be removed") - - // Others should remain - _, err = os.Stat(middleFile) - Expect(err).ToNot(HaveOccurred(), "middle file should remain") - - _, err = os.Stat(newestFile) - Expect(err).ToNot(HaveOccurred(), "newest file should remain") - }) - - It("should remove multiple files to get under limit", func() { - cacheDir := filepath.Join(tmpDir, "cache") - now := time.Now() - - // Create 5 files, 1MiB each (total 5MiB) - for i := range 5 { - path := filepath.Join(cacheDir, filepath.Join("dir", "file"+strconv.Itoa(i)+".bin")) - createFileWithSize(path, 1*1024*1024, now.Add(-time.Duration(5-i)*time.Hour)) - } - - // Set limit to 2.5MiB - should remove oldest 3 files (leaving 2MiB) - purgeCacheBySize(ctx, cacheDir, "2.5MiB") - - finalSize := getDirSize(cacheDir) - limit, _ := humanize.ParseBytes("2.5MiB") - Expect(finalSize).To(BeNumerically("<=", limit)) - }) - - It("should remove empty parent directories after removing files", func() { - cacheDir := filepath.Join(tmpDir, "cache") - now := time.Now() - - // Create files in subdirectories - oldFile := filepath.Join(cacheDir, "subdir1", "old.bin") - newFile := filepath.Join(cacheDir, "subdir2", "new.bin") - - createFileWithSize(oldFile, 2*1024*1024, now.Add(-2*time.Hour)) - createFileWithSize(newFile, 2*1024*1024, now.Add(-1*time.Hour)) - - // Set limit to 2MiB - should remove old file and its parent dir - purgeCacheBySize(ctx, cacheDir, "2MiB") - - // Old file and its parent dir should be removed - _, err := os.Stat(oldFile) - Expect(os.IsNotExist(err)).To(BeTrue()) - - _, err = os.Stat(filepath.Join(cacheDir, "subdir1")) - Expect(os.IsNotExist(err)).To(BeTrue(), "empty parent directory should be removed") - - // New file and its parent dir should remain - _, err = os.Stat(newFile) - Expect(err).ToNot(HaveOccurred()) - - _, err = os.Stat(filepath.Join(cacheDir, "subdir2")) - Expect(err).ToNot(HaveOccurred()) - }) - }) -}) diff --git a/plugins/manager_call.go b/plugins/manager_call.go deleted file mode 100644 index b5c7536b6..000000000 --- a/plugins/manager_call.go +++ /dev/null @@ -1,124 +0,0 @@ -package plugins - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - - extism "github.com/extism/go-sdk" - "github.com/navidrome/navidrome/log" -) - -var errFunctionNotFound = errors.New("function not found") -var errNotImplemented = errors.New("function not implemented") - -// notImplementedCode is the standard return code from plugin PDKs -// indicating a function exists but is not implemented by this plugin. -// The plugin returns -2 as int32, which becomes 0xFFFFFFFE as uint32. -const notImplementedCode uint32 = 0xFFFFFFFE - -// callPluginFunctionNoInput is a helper to call a plugin function with no input and output. -func callPluginFunctionNoInput(ctx context.Context, plugin *plugin, funcName string) error { - _, err := callPluginFunction[struct{}, struct{}](ctx, plugin, funcName, struct{}{}) - return err -} - -// callPluginFunctionNoOutput is a helper to call a plugin function with input and no output. -func callPluginFunctionNoOutput[I any](ctx context.Context, plugin *plugin, funcName string, input I) error { - _, err := callPluginFunction[I, struct{}](ctx, plugin, funcName, input) - return err -} - -// callPluginFunction is a helper to call a plugin function with input and output types. -// It handles JSON marshalling/unmarshalling and error checking. -// The context is used for cancellation - if cancelled during the call, the plugin -// instance will be terminated and context.Canceled or context.DeadlineExceeded will be returned. -func callPluginFunction[I any, O any](ctx context.Context, plugin *plugin, funcName string, input I) (O, error) { - start := time.Now() - - var result O - - // Create plugin instance with context for cancellation support - p, err := plugin.instance(ctx) - if err != nil { - return result, fmt.Errorf("failed to create plugin: %w", err) - } - defer p.Close(ctx) - - if !p.FunctionExists(funcName) { - log.Trace(ctx, "Plugin function not found", "plugin", plugin.name, "function", funcName) - return result, fmt.Errorf("%w: %s", errFunctionNotFound, funcName) - } - - inputBytes, err := json.Marshal(input) - if err != nil { - return result, fmt.Errorf("failed to marshal input: %w", err) - } - - startCall := time.Now() - exit, output, err := p.CallWithContext(ctx, funcName, inputBytes) - elapsed := time.Since(startCall) - if err != nil { - // If context was cancelled, return that error instead of the plugin error - if ctx.Err() != nil { - log.Debug(ctx, "Plugin call cancelled", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed) - return result, ctx.Err() - } - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) - log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err) - return result, fmt.Errorf("plugin call failed: %w", err) - } - if exit != 0 { - if exit == notImplementedCode { - log.Trace(ctx, "Plugin function not implemented", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start)) - // TODO Should we record metrics for not implemented calls? - //plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, true, elapsed.Milliseconds()) - return result, fmt.Errorf("%w: %s", errNotImplemented, funcName) - } - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, false, elapsed.Milliseconds()) - return result, fmt.Errorf("plugin call exited with code %d", exit) - } - - if len(output) > 0 { - err = json.Unmarshal(output, &result) - if err != nil { - log.Trace(ctx, "Plugin call failed", "plugin", plugin.name, "function", funcName, "pluginDuration", elapsed, "navidromeDuration", startCall.Sub(start), err) - } - } - - // Record metrics for successful calls (or JSON unmarshal failures) - plugin.metrics.RecordPluginRequest(ctx, plugin.name, funcName, err == nil, elapsed.Milliseconds()) - - log.Trace(ctx, "Plugin call succeeded", "plugin", plugin.name, "function", funcName, "pluginDuration", time.Since(startCall), "navidromeDuration", startCall.Sub(start)) - return result, err -} - -// extismLogger is a helper to log messages from Extism plugins -func extismLogger(pluginName string) func(level extism.LogLevel, msg string) { - return func(level extism.LogLevel, msg string) { - if level == extism.LogLevelOff { - return - } - log.Log(log.ParseLogLevel(level.String()), msg, "plugin", pluginName) - } -} - -// toExtismLogLevel converts a Navidrome log level to an extism LogLevel -func toExtismLogLevel(level log.Level) extism.LogLevel { - switch level { - case log.LevelTrace: - return extism.LogLevelTrace - case log.LevelDebug: - return extism.LogLevelDebug - case log.LevelInfo: - return extism.LogLevelInfo - case log.LevelWarn: - return extism.LogLevelWarn - case log.LevelError, log.LevelFatal: - return extism.LogLevelError - default: - return extism.LogLevelInfo - } -} diff --git a/plugins/manager_call_test.go b/plugins/manager_call_test.go deleted file mode 100644 index 5e2c6385e..000000000 --- a/plugins/manager_call_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package plugins - -import ( - "context" - "sync" - - "github.com/navidrome/navidrome/core/agents" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// mockMetricsRecorder tracks calls to RecordPluginRequest for testing -type mockMetricsRecorder struct { - mu sync.Mutex - calls []metricsCall -} - -type metricsCall struct { - plugin string - method string - ok bool - elapsed int64 -} - -func (m *mockMetricsRecorder) RecordPluginRequest(_ context.Context, plugin, method string, ok bool, elapsed int64) { - m.mu.Lock() - defer m.mu.Unlock() - m.calls = append(m.calls, metricsCall{plugin: plugin, method: method, ok: ok, elapsed: elapsed}) -} - -func (m *mockMetricsRecorder) getCalls() []metricsCall { - m.mu.Lock() - defer m.mu.Unlock() - return append([]metricsCall{}, m.calls...) -} - -func (m *mockMetricsRecorder) reset() { - m.mu.Lock() - defer m.mu.Unlock() - m.calls = nil -} - -var _ = Describe("callPluginFunction metrics", Ordered, func() { - var ( - metricsManager *Manager - metricsRecorder *mockMetricsRecorder - agent agents.Interface - ) - - BeforeAll(func() { - metricsRecorder = &mockMetricsRecorder{} - - // Create a manager with the metrics recorder - metricsManager, _ = createTestManagerWithPluginsAndMetrics( - nil, - metricsRecorder, - "test-metadata-agent"+PackageExtension, - ) - - var ok bool - agent, ok = metricsManager.LoadMediaAgent("test-metadata-agent") - Expect(ok).To(BeTrue()) - }) - - BeforeEach(func() { - metricsRecorder.reset() - }) - - It("records metrics for successful plugin calls", func() { - retriever := agent.(agents.ArtistBiographyRetriever) - _, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") - Expect(err).ToNot(HaveOccurred()) - - calls := metricsRecorder.getCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].plugin).To(Equal("test-metadata-agent")) - Expect(calls[0].method).To(Equal(FuncGetArtistBiography)) - Expect(calls[0].ok).To(BeTrue()) - Expect(calls[0].elapsed).To(BeNumerically(">=", 0)) - }) - - Context("with error config", Ordered, func() { - var ( - errorRecorder *mockMetricsRecorder - errorAgent agents.Interface - ) - - BeforeAll(func() { - errorRecorder = &mockMetricsRecorder{} - errorManager, _ := createTestManagerWithPluginsAndMetrics( - map[string]map[string]string{ - "test-metadata-agent": {"error": "simulated error"}, - }, - errorRecorder, - "test-metadata-agent"+PackageExtension, - ) - - var ok bool - errorAgent, ok = errorManager.LoadMediaAgent("test-metadata-agent") - Expect(ok).To(BeTrue()) - }) - - It("records metrics for failed plugin calls (error returned)", func() { - retriever := errorAgent.(agents.ArtistBiographyRetriever) - _, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") - Expect(err).To(HaveOccurred()) - - calls := errorRecorder.getCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].plugin).To(Equal("test-metadata-agent")) - Expect(calls[0].method).To(Equal(FuncGetArtistBiography)) - Expect(calls[0].ok).To(BeFalse()) - }) - }) - - Context("with partial metadata agent", Ordered, func() { - var ( - partialRecorder *mockMetricsRecorder - partialAgent agents.Interface - ) - - BeforeAll(func() { - partialRecorder = &mockMetricsRecorder{} - partialManager, _ := createTestManagerWithPluginsAndMetrics( - nil, - partialRecorder, - "partial-metadata-agent"+PackageExtension, - ) - - var ok bool - partialAgent, ok = partialManager.LoadMediaAgent("partial-metadata-agent") - Expect(ok).To(BeTrue()) - }) - - It("does not record metrics for not-implemented functions", func() { - retriever := partialAgent.(agents.ArtistMBIDRetriever) - _, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist") - Expect(err).To(MatchError(errNotImplemented)) - - calls := partialRecorder.getCalls() - Expect(calls).To(HaveLen(0)) - }) - }) -}) diff --git a/plugins/manager_loader.go b/plugins/manager_loader.go deleted file mode 100644 index cca87e5b0..000000000 --- a/plugins/manager_loader.go +++ /dev/null @@ -1,509 +0,0 @@ -package plugins - -import ( - "context" - "encoding/json" - "fmt" - "io" - "slices" - "time" - - extism "github.com/extism/go-sdk" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/plugins/host" - "github.com/navidrome/navidrome/scheduler" - "github.com/tetratelabs/wazero" - "golang.org/x/sync/errgroup" -) - -// serviceContext provides dependencies needed by host service factories. -type serviceContext struct { - pluginName string - manager *Manager - permissions *Permissions - config map[string]string - allowedUsers []string // User IDs this plugin can access - allUsers bool // If true, plugin can access all users - allowedLibraries []int // Library IDs this plugin can access - allLibraries bool // If true, plugin can access all libraries -} - -// baseCtx returns the manager's lifecycle context, for host services that -// outlive the plugin call that created them. It falls back to -// context.Background() when the manager was never started, which is the case -// for CLI commands (e.g. `navidrome plugin enable`) that load plugins without -// calling Start. -func (c *serviceContext) baseCtx() context.Context { - if c.manager.ctx == nil { - return context.Background() - } - return c.manager.ctx -} - -// hostServiceEntry defines a host service for table-driven registration. -type hostServiceEntry struct { - name string - hasPermission func(*Permissions) bool - create func(*serviceContext) ([]extism.HostFunction, io.Closer, error) -} - -// hostServices defines all available host services. -// Adding a new host service only requires adding an entry here. -var hostServices = []hostServiceEntry{ - { - name: "Config", - hasPermission: func(p *Permissions) bool { return true }, // Always available, no permission required - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - service := newConfigService(ctx.pluginName, ctx.config) - return host.RegisterConfigHostFunctions(service), nil, nil - }, - }, - { - name: "SubsonicAPI", - hasPermission: func(p *Permissions) bool { return p != nil && p.Subsonicapi != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - service := newSubsonicAPIService(ctx.pluginName, ctx.manager.subsonicRouter, ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers)) - return host.RegisterSubsonicAPIHostFunctions(service), nil, nil - }, - }, - { - name: "Scheduler", - hasPermission: func(p *Permissions) bool { return p != nil && p.Scheduler != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - service := newSchedulerService(ctx.pluginName, ctx.manager, scheduler.GetInstance()) - return host.RegisterSchedulerHostFunctions(service), service, nil - }, - }, - { - name: "WebSocket", - hasPermission: func(p *Permissions) bool { return p != nil && p.Websocket != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - perm := ctx.permissions.Websocket - service := newWebSocketService(ctx.baseCtx(), ctx.pluginName, ctx.manager, perm) - return host.RegisterWebSocketHostFunctions(service), service, nil - }, - }, - { - name: "Artwork", - hasPermission: func(p *Permissions) bool { return p != nil && p.Artwork != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - service := newArtworkService() - return host.RegisterArtworkHostFunctions(service), nil, nil - }, - }, - { - name: "Cache", - hasPermission: func(p *Permissions) bool { return p != nil && p.Cache != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - service := newCacheService(ctx.pluginName) - return host.RegisterCacheHostFunctions(service), service, nil - }, - }, - { - name: "Library", - hasPermission: func(p *Permissions) bool { return p != nil && p.Library != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - perm := ctx.permissions.Library - service := newLibraryService(ctx.manager.ds, perm, ctx.allowedLibraries, ctx.allLibraries) - return host.RegisterLibraryHostFunctions(service), nil, nil - }, - }, - { - name: "KVStore", - hasPermission: func(p *Permissions) bool { return p != nil && p.Kvstore != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - perm := ctx.permissions.Kvstore - service, err := newKVStoreService(ctx.baseCtx(), ctx.pluginName, perm) - if err != nil { - return nil, nil, err - } - return host.RegisterKVStoreHostFunctions(service), service, nil - }, - }, - { - name: "Users", - hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers) - return host.RegisterUsersHostFunctions(service), nil, nil - }, - }, - { - name: "Matcher", - hasPermission: func(p *Permissions) bool { return p != nil && p.Matcher != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - hasFilesystemPerm := ctx.permissions.Library != nil && ctx.permissions.Library.Filesystem - service := newMatcherService( - ctx.manager.ds, hasFilesystemPerm, - newUserAccess(ctx.allowedUsers, ctx.allUsers), - newLibraryAccess(ctx.allowedLibraries, ctx.allLibraries), - ) - return host.RegisterMatcherHostFunctions(service), nil, nil - }, - }, - { - name: "HTTP", - hasPermission: func(p *Permissions) bool { return p != nil && p.Http != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - perm := ctx.permissions.Http - service := newHTTPService(ctx.pluginName, perm) - return host.RegisterHTTPHostFunctions(service), service, nil - }, - }, - { - name: "Task", - hasPermission: func(p *Permissions) bool { return p != nil && p.Taskqueue != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - perm := ctx.permissions.Taskqueue - maxConcurrency := int32(1) - if perm.MaxConcurrency > 0 { - maxConcurrency = int32(perm.MaxConcurrency) - } - service, err := newTaskQueueService(ctx.baseCtx(), ctx.pluginName, ctx.manager, maxConcurrency) - if err != nil { - return nil, nil, err - } - return host.RegisterTaskHostFunctions(service), service, nil - }, - }, - { - name: "Storage", - hasPermission: func(p *Permissions) bool { return p != nil && p.Storage != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - service, err := newStorageService(ctx.pluginName) - if err != nil { - return nil, nil, err - } - return host.RegisterStorageHostFunctions(service), nil, nil - }, - }, - { - name: "ScrobbleRetriever", - hasPermission: func(p *Permissions) bool { return p != nil && p.ScrobbleRetriever != nil }, - create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer, error) { - service := newScrobbleRetrieverService(ctx.manager.ds, newUserAccess(ctx.allowedUsers, ctx.allUsers)) - return host.RegisterScrobbleRetrieverHostFunctions(service), nil, nil - }, - }, -} - -// extractManifest reads manifest from an .ndp package and computes its SHA-256 hash. -// This is a lightweight operation used for plugin discovery and change detection. -// Unlike the old implementation, this does NOT compile the wasm - just reads the manifest JSON. -func (m *Manager) extractManifest(ndpPath string) (*PluginMetadata, error) { - if m.stopped.Load() { - return nil, fmt.Errorf("manager is stopped") - } - - manifest, err := ReadManifest(ndpPath) - if err != nil { - return nil, err - } - - sha256Hash, err := ComputeFileSHA256(ndpPath) - if err != nil { - return nil, fmt.Errorf("computing hash: %w", err) - } - - return &PluginMetadata{ - Manifest: manifest, - SHA256: sha256Hash, - }, nil -} - -// loadEnabledPlugins loads all enabled plugins from the database. -func (m *Manager) loadEnabledPlugins(ctx context.Context) error { - if m.ds == nil { - return fmt.Errorf("datastore not configured") - } - - adminCtx := adminContext(ctx) - repo := m.ds.Plugin() - - plugins, err := repo.GetAll(adminCtx) - if err != nil { - return fmt.Errorf("reading plugins from DB: %w", err) - } - - g := errgroup.Group{} - g.SetLimit(maxPluginLoadConcurrency) - - for _, p := range plugins { - if !p.Enabled { - continue - } - // Instantiating a plugin creates its host services, so a transient load takes only the - // ones it may actually consult. - if m.transient != nil && !slices.Contains(m.transient.only, p.ID) { - continue - } - - plugin := p // Capture for goroutine - g.Go(func() error { - start := time.Now() - log.Debug(ctx, "Loading enabled plugin", "plugin", plugin.ID, "path", plugin.Path) - - // Panic recovery - defer func() { - if r := recover(); r != nil { - log.Error(ctx, "Panic while loading plugin", "plugin", plugin.ID, "panic", r) - } - }() - - if err := m.loadPluginWithConfig(&plugin); err != nil { - // A transient load must not disable the user's plugin just for looking at it. - if m.transient == nil { - plugin.LastError = err.Error() - plugin.Enabled = false - plugin.UpdatedAt = time.Now() - if putErr := repo.Put(adminCtx, &plugin); putErr != nil { - log.Error(ctx, "Failed to update plugin error in DB", "plugin", plugin.ID, putErr) - } - } - log.Error(ctx, "Failed to load plugin", "plugin", plugin.ID, err) - return nil - } - - // Clear any previous error - if plugin.LastError != "" && m.transient == nil { - plugin.LastError = "" - plugin.UpdatedAt = time.Now() - if putErr := repo.Put(adminCtx, &plugin); putErr != nil { - log.Error(ctx, "Failed to clear plugin error in DB", "plugin", plugin.ID, putErr) - } - } - - m.mu.RLock() - loadedPlugin := m.plugins[plugin.ID] - m.mu.RUnlock() - if loadedPlugin != nil { - log.Info(ctx, "Loaded plugin", "plugin", plugin.ID, "manifest", loadedPlugin.manifest.Name, - "capabilities", loadedPlugin.capabilities, "duration", time.Since(start)) - } - return nil - }) - } - - return g.Wait() -} - -// loadPluginWithConfig loads a plugin with configuration from DB. -// The p.Path should point to an .ndp package file. -func (m *Manager) loadPluginWithConfig(p *model.Plugin) error { - // NewContext falls back to context.Background() when m.ctx is nil (unstarted manager) - ctx := log.NewContext(m.ctx, "plugin", p.ID) - - if m.stopped.Load() { - return fmt.Errorf("manager is stopped") - } - - if !validPluginID(p.ID) { - return fmt.Errorf("invalid plugin ID %q", p.ID) - } - - // Track this operation - m.loadWg.Add(1) - defer m.loadWg.Done() - - if m.stopped.Load() { - return fmt.Errorf("manager is stopped") - } - - // Parse config from JSON - pluginConfig, err := parsePluginConfig(p.Config) - if err != nil { - return err - } - - // Parse users from JSON - var allowedUsers []string - if p.Users != "" { - if err := json.Unmarshal([]byte(p.Users), &allowedUsers); err != nil { - return fmt.Errorf("parsing plugin users: %w", err) - } - } - - // Parse libraries from JSON - var allowedLibraries []int - if p.Libraries != "" { - if err := json.Unmarshal([]byte(p.Libraries), &allowedLibraries); err != nil { - return fmt.Errorf("parsing plugin libraries: %w", err) - } - } - - // Open the .ndp package to get manifest and wasm bytes - pkg, err := openPackage(p.Path) - if err != nil { - return fmt.Errorf("opening package: %w", err) - } - - pluginManifest := buildExtismManifest(pkg, pluginConfig) - - // Configure filesystem access for library permission, applied per instance - var fsConfig wazero.FSConfig - if pkg.Manifest.HasLibraryFilesystemPermission() || pkg.Manifest.HasStoragePermission() { - mounts := []mount{} - - if pkg.Manifest.HasLibraryFilesystemPermission() { - adminCtx := adminContext(ctx) - libraries, err := m.ds.Library().GetAll(adminCtx) - if err != nil { - return fmt.Errorf("failed to get libraries for filesystem access: %w", err) - } - mounts = buildMounts(ctx, libraries, allowedLibraries, p.AllLibraries, p.AllowWriteAccess) - } - - if pkg.Manifest.HasStoragePermission() { - pluginStore := getHostStoragePath(p.ID) - log.Info(ctx, "Granting read-write filesystem access to plugin storage", "path", pluginStore, "id", p.ID) - - mounts = append(mounts, mount{ - hostPath: pluginStore, - guestPath: storageMount, - }) - } - - fsConfig = buildFSConfig(mounts) - } - - // Build host functions based on permissions from manifest - var hostFunctions []extism.HostFunction - var closers []io.Closer - loaded := false - // On success the closers are owned by the registered plugin; on any - // failure past this point, close them so partially-created services - // don't leak goroutines or file handles. - defer func() { - if !loaded { - closeAll(closers) - } - }() - - svcCtx := &serviceContext{ - pluginName: p.ID, - manager: m, - permissions: pkg.Manifest.Permissions, - config: pluginConfig, - allowedUsers: allowedUsers, - allUsers: p.AllUsers, - allowedLibraries: allowedLibraries, - allLibraries: p.AllLibraries, - } - for _, entry := range hostServices { - if entry.hasPermission(pkg.Manifest.Permissions) { - funcs, closer, err := entry.create(svcCtx) - if err != nil { - return fmt.Errorf("creating %s service: %w", entry.name, err) - } - hostFunctions = append(hostFunctions, funcs...) - if closer != nil { - closers = append(closers, closer) - } - } - } - - // Compile the plugin with all host functions - runtimeConfig := wazero.NewRuntimeConfig(). - WithCompilationCache(m.cache). - WithCloseOnContextDone(true) - - extismConfig := extism.PluginConfig{ - EnableWasi: true, - RuntimeConfig: runtimeConfig, - EnableHttpResponseHeaders: true, - } - compiled, err := extism.NewCompiledPlugin(ctx, pluginManifest, extismConfig, hostFunctions) - if err != nil { - return fmt.Errorf("compiling plugin: %w", err) - } - - // Create instance to detect capabilities - instance, err := compiled.Instance(ctx, instanceConfig(fsConfig)) - if err != nil { - compiled.Close(ctx) - return fmt.Errorf("creating instance: %w", err) - } - instance.SetLogger(extismLogger(p.ID)) - capabilities := detectCapabilities(instance) - instance.Close(ctx) - - // Validate manifest against detected capabilities - if err := ValidateWithCapabilities(pkg.Manifest, capabilities); err != nil { - compiled.Close(ctx) - return fmt.Errorf("manifest validation: %w", err) - } - - loadedPlugin := &plugin{ - name: p.ID, - path: p.Path, - manifest: pkg.Manifest, - compiled: compiled, - capabilities: capabilities, - closers: closers, - metrics: m.metrics, - allowedUserIDs: allowedUsers, - allUsers: p.AllUsers, - libraries: newLibraryAccess(allowedLibraries, p.AllLibraries), - fsConfig: fsConfig, - lyricsSem: make(chan struct{}, maxConcurrentLyricsCalls), - } - m.mu.Lock() - m.plugins[p.ID] = loadedPlugin - m.mu.Unlock() - loaded = true - - // Init is the plugin's first chance to run arbitrary code: open sockets, create task queues, - // schedule work. Only a caller that already intends to reach the network asks for it. - // Use the local: loads run concurrently, so reading the map back here would race the writes. - if m.transient == nil || m.transient.runInit { - callPluginInit(ctx, loadedPlugin) - } - - return nil -} - -// closeAll closes host service closers accumulated before a load failure, -// so partially-created services don't leak goroutines or file handles. -func closeAll(closers []io.Closer) { - for _, c := range closers { - _ = c.Close() - } -} - -// parsePluginConfig parses a JSON config string into a map of string values. -// For Extism, all config values must be strings, so non-string values are serialized as JSON. -func parsePluginConfig(configJSON string) (map[string]string, error) { - if configJSON == "" { - return nil, nil - } - var rawConfig map[string]any - if err := json.Unmarshal([]byte(configJSON), &rawConfig); err != nil { - return nil, fmt.Errorf("parsing plugin config: %w", err) - } - pluginConfig := make(map[string]string) - for key, value := range rawConfig { - switch v := value.(type) { - case string: - pluginConfig[key] = v - default: - // Serialize non-string values as JSON - jsonBytes, err := json.Marshal(v) - if err != nil { - return nil, fmt.Errorf("serializing config value %q: %w", key, err) - } - pluginConfig[key] = string(jsonBytes) - } - } - return pluginConfig, nil -} - -// buildExtismManifest describes the plugin to extism. It must never set AllowedPaths (extism would replace our -// jailed FSConfig) nor AllowedHosts (extism's http_request has no SSRF guard; plugins must use host.HTTPSend). -func buildExtismManifest(pkg *ndpPackage, pluginConfig map[string]string) extism.Manifest { - return extism.Manifest{ - Wasm: []extism.Wasm{extism.WasmData{Data: pkg.WasmBytes, Name: "main"}}, - Config: pluginConfig, - Timeout: uint64(defaultTimeout.Milliseconds()), - } -} diff --git a/plugins/manager_loader_load_test.go b/plugins/manager_loader_load_test.go deleted file mode 100644 index eb004276e..000000000 --- a/plugins/manager_loader_load_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package plugins - -import ( - "crypto/sha256" - "encoding/hex" - "net/http" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("loadPluginWithConfig", func() { - var manager *Manager - var dataDir string - - BeforeEach(func() { - pluginsDir := GinkgoT().TempDir() - dataDir = GinkgoT().TempDir() - - src := filepath.Join(testdataDir, "test-taskqueue"+PackageExtension) - data, err := os.ReadFile(src) - Expect(err).ToNot(HaveOccurred()) - dest := filepath.Join(pluginsDir, "test-taskqueue"+PackageExtension) - Expect(os.WriteFile(dest, data, 0600)).To(Succeed()) - hash := sha256.Sum256(data) - - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(pluginsDir) - conf.Server.Plugins.AutoReload = false - conf.Server.DataFolder = conf.NewDir(dataDir) - - repo := tests.CreateMockPluginRepo() - repo.Permitted = true - repo.SetData(model.Plugins{{ - ID: "test-taskqueue", - Path: dest, - SHA256: hex.EncodeToString(hash[:]), - Enabled: false, - }}) - manager = &Manager{ - plugins: make(map[string]*plugin), - ds: &tests.MockDataStore{MockedPlugin: repo}, - metrics: noopMetricsRecorder{}, - subsonicRouter: http.NotFoundHandler(), - } - }) - - Describe("host service creation failures", func() { - It("reports the Task service creation error instead of a missing host function", func() { - Expect(manager.Start(GinkgoT().Context())).To(Succeed()) - DeferCleanup(func() { _ = manager.Stop() }) - - // Block the taskqueue data dir by creating a file where the directory should be - Expect(os.WriteFile(filepath.Join(dataDir, "plugins"), nil, 0600)).To(Succeed()) - - err := manager.EnablePlugin(GinkgoT().Context(), "test-taskqueue") - Expect(err).To(MatchError(ContainSubstring("creating Task service"))) - Expect(err).ToNot(MatchError(ContainSubstring("not exported"))) - }) - }) - - Describe("unstarted manager", func() { - It("enables a taskqueue plugin on a manager that was never started", func() { - // CLI commands (navidrome plugin enable) use the manager without calling Start - Expect(manager.EnablePlugin(GinkgoT().Context(), "test-taskqueue")).To(Succeed()) - DeferCleanup(func() { _ = manager.unloadPlugin("test-taskqueue") }) - }) - }) -}) diff --git a/plugins/manager_loader_test.go b/plugins/manager_loader_test.go deleted file mode 100644 index 85a636202..000000000 --- a/plugins/manager_loader_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package plugins - -import ( - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("buildExtismManifest", func() { - var pkg *ndpPackage - - BeforeEach(func() { - pkg = &ndpPackage{ - WasmBytes: []byte("wasm"), - Manifest: &Manifest{Permissions: &Permissions{ - Library: &LibraryPermission{Reason: new("test"), Filesystem: true}, - Http: &HTTPPermission{Reason: new("test"), RequiredHosts: []string{"example.com"}}, - }}, - } - }) - - It("never sets AllowedPaths, even with filesystem permission", func() { - Expect(buildExtismManifest(pkg, nil).AllowedPaths).To(BeEmpty()) - }) - - It("never sets AllowedHosts, so plugin HTTP can't bypass the host service's SSRF guard", func() { - Expect(buildExtismManifest(pkg, nil).AllowedHosts).To(BeEmpty()) - }) -}) - -var _ = Describe("loadPluginWithConfig", func() { - // Discovery already rejects these, but a row predating that check, or one - // left behind by a failed sync, must not reach the mount setup - It("refuses a plugin whose ID is not usable as a directory name", func() { - m := &Manager{plugins: make(map[string]*plugin)} - - err := m.loadPluginWithConfig(&model.Plugin{ID: "..", Path: "/does/not/matter.ndp"}) - - Expect(err).To(MatchError(ContainSubstring("invalid plugin ID"))) - }) -}) - -var _ = Describe("parsePluginConfig", func() { - It("returns nil for empty string", func() { - result, err := parsePluginConfig("") - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(BeNil()) - }) - - It("serializes object values as JSON strings", func() { - result, err := parsePluginConfig(`{"settings": {"enabled": true, "count": 5}}`) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result["settings"]).To(Equal(`{"count":5,"enabled":true}`)) - }) - - It("handles mixed value types", func() { - result, err := parsePluginConfig(`{"api_key": "secret", "timeout": 30, "rate": 1.5, "enabled": true, "tags": ["a", "b"]}`) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(5)) - Expect(result["api_key"]).To(Equal("secret")) - Expect(result["timeout"]).To(Equal("30")) - Expect(result["rate"]).To(Equal("1.5")) - Expect(result["enabled"]).To(Equal("true")) - Expect(result["tags"]).To(Equal(`["a","b"]`)) - }) - - It("returns error for invalid JSON", func() { - _, err := parsePluginConfig(`{invalid json}`) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("parsing plugin config")) - }) - - It("returns error for non-object JSON", func() { - _, err := parsePluginConfig(`["array", "not", "object"]`) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("parsing plugin config")) - }) - - It("handles null values", func() { - result, err := parsePluginConfig(`{"key": null}`) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(1)) - Expect(result["key"]).To(Equal("null")) - }) - - It("handles empty object", func() { - result, err := parsePluginConfig(`{}`) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(HaveLen(0)) - Expect(result).ToNot(BeNil()) - }) -}) diff --git a/plugins/manager_plugin.go b/plugins/manager_plugin.go deleted file mode 100644 index 015cd7f4c..000000000 --- a/plugins/manager_plugin.go +++ /dev/null @@ -1,143 +0,0 @@ -package plugins - -import ( - "context" - "crypto/rand" - "errors" - "fmt" - "io" - - extism "github.com/extism/go-sdk" - "github.com/navidrome/navidrome/model" - "github.com/tetratelabs/wazero" -) - -// plugin represents a loaded plugin -type plugin struct { - name string // Plugin name (from filename) - path string // Path to the wasm file - manifest *Manifest - compiled *extism.CompiledPlugin - capabilities []Capability // Auto-detected capabilities based on exported functions - closers []io.Closer // Cleanup functions to call on unload - metrics PluginMetricsRecorder - allowedUserIDs []string // User IDs this plugin can access (from DB configuration) - allUsers bool // If true, plugin can access all users - libraries libraryAccess - lyricsSem chan struct{} // Caps concurrent lyrics calls (see LyricsPlugin.GetLyrics) - fsConfig wazero.FSConfig // Sandboxed library mounts, nil if no filesystem permission -} - -// instanceConfig is used by every call site, so all instances get the sandboxed mounts. -func instanceConfig(fsConfig wazero.FSConfig) extism.PluginInstanceConfig { - moduleConfig := wazero.NewModuleConfig().WithSysWalltime().WithRandSource(rand.Reader) - if fsConfig != nil { - moduleConfig = moduleConfig.WithFSConfig(fsConfig) - } - return extism.PluginInstanceConfig{ModuleConfig: moduleConfig} -} - -// instance creates a new plugin instance for the given context. -// The context is used for cancellation - if cancelled during a call, -// the module will be terminated and the instance becomes unusable. -func (p *plugin) instance(ctx context.Context) (*extism.Plugin, error) { - instance, err := p.compiled.Instance(ctx, instanceConfig(p.fsConfig)) - if err != nil { - return nil, err - } - instance.SetLogger(extismLogger(p.name)) - return instance, nil -} - -func (p *plugin) Close() error { - var errs []error - for _, f := range p.closers { - err := f.Close() - if err != nil { - errs = append(errs, err) - } - } - 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 -} - -// configured reports whether the plugin has any library scope (all, or specific). -func (a libraryAccess) configured() bool { - return a.allLibraries || len(a.libraryIDSet) > 0 -} - -// userAccess captures the set of users a plugin is permitted to act as, -// precomputed at load time for O(1) lookup. -type userAccess struct { - allUsers bool - userIDSet map[string]struct{} -} - -func newUserAccess(allowedUserIDs []string, allUsers bool) userAccess { - set := make(map[string]struct{}, len(allowedUserIDs)) - for _, id := range allowedUserIDs { - set[id] = struct{}{} - } - return userAccess{allUsers: allUsers, userIDSet: set} -} - -// allows reports whether the plugin may act as the given user ID. -func (a userAccess) allows(userID string) bool { - if a.allUsers { - return true - } - _, ok := a.userIDSet[userID] - return ok -} - -// resolve looks up a user by username and authorizes it against this access set, -// distinguishing an absent user from a backend failure. -// -// When the plugin has no user scope at all, it rejects before the lookup with a -// single fixed error, so a caller cannot tell a real account from a missing one by -// the error text (username enumeration). -func (a userAccess) resolve(ctx context.Context, ds model.DataStore, username string) (*model.User, error) { - if !a.allUsers && len(a.userIDSet) == 0 { - return nil, fmt.Errorf("plugin is not authorized to scope by user") - } - usr, err := ds.User().FindByUsername(ctx, username) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - return nil, fmt.Errorf("user %q not found", username) - } - return nil, fmt.Errorf("looking up user %q: %w", username, err) - } - if usr == nil { // defensive: a conforming repo returns ErrNotFound, not (nil, nil) - return nil, fmt.Errorf("user %q not found", username) - } - if !a.allows(usr.ID) { - return nil, fmt.Errorf("plugin is not allowed to act as user %q", username) - } - return usr, nil -} diff --git a/plugins/manager_plugin_test.go b/plugins/manager_plugin_test.go deleted file mode 100644 index 513b8cb8e..000000000 --- a/plugins/manager_plugin_test.go +++ /dev/null @@ -1,34 +0,0 @@ -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_readonly_test.go b/plugins/manager_readonly_test.go deleted file mode 100644 index 0c88459c0..000000000 --- a/plugins/manager_readonly_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package plugins - -import ( - "context" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Manager.LoadPlugins", func() { - var ( - ctx context.Context - mgr *Manager - repo *tests.MockPluginRepo - tmpDir string - ) - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) - - // newManager builds a manager over rows the caller can corrupt, with no Subsonic router: a CLI - // has none, and Start would log.Fatal on that. - newManager := func(rows model.Plugins) *Manager { - DeferCleanup(configtest.SetupConfig()) - var err error - tmpDir, err = os.MkdirTemp("", "plugins-readonly-*") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { _ = os.RemoveAll(tmpDir) }) - - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = conf.NewDir(tmpDir) - - if rows == nil { - rows = installTestPlugins(tmpDir, "test-metadata-agent"+PackageExtension) - for i := range rows { - rows[i].AllUsers = true - } - } - repo = tests.CreateMockPluginRepo() - repo.Permitted = true - repo.SetData(rows) - m := &Manager{ - plugins: make(map[string]*plugin), - ds: &tests.MockDataStore{MockedPlugin: repo}, - metrics: noopMetricsRecorder{}, - } - DeferCleanup(func() { _ = m.Stop() }) - return m - } - - It("detects capabilities without a Subsonic router configured", func() { - mgr = newManager(nil) - - Expect(mgr.LoadPlugins(ctx, []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) - - Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(ContainElement("test-metadata-agent")) - }) - - Context("when a plugin cannot be loaded", func() { - brokenRows := func() model.Plugins { - return model.Plugins{{ - ID: "broken", Path: filepath.Join(GinkgoT().TempDir(), "does-not-exist.ndp"), - Enabled: true, AllUsers: true, - }} - } - - It("leaves the stored row untouched", func() { - mgr = newManager(brokenRows()) - - Expect(mgr.LoadPlugins(ctx, []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) - - stored, err := repo.Get(ctx, "broken") - Expect(err).ToNot(HaveOccurred()) - Expect(stored.Enabled).To(BeTrue(), "inspecting a plugin must never disable it") - Expect(stored.LastError).To(BeEmpty()) - }) - - // Without this the test above would pass for the wrong reason. Start cannot be used: it - // syncs the folder first, dropping a row whose file is missing before any load. - It("still disables it when not read-only", func() { - mgr = newManager(brokenRows()) - - Expect(mgr.loadEnabledPlugins(ctx)).To(Succeed()) - - stored, err := repo.Get(ctx, "broken") - Expect(err).ToNot(HaveOccurred()) - Expect(stored.Enabled).To(BeFalse()) - Expect(stored.LastError).ToNot(BeEmpty()) - }) - }) - - // Loading a plugin creates its host services — a KVStore or task queue database on disk — so a - // plugin that could never supply an image must not be instantiated just to be ignored. - It("does not load a plugin that is not in the agent list", func() { - mgr = newManager(nil) - - Expect(mgr.LoadPlugins(ctx, []string{"some-other-agent"}, false)).To(Succeed()) - - Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty()) - }) - - It("does nothing when no agents are configured", func() { - mgr = newManager(nil) - - Expect(mgr.LoadPlugins(ctx, nil, false)).To(Succeed()) - - Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty()) - // Not even the wazero cache: with nothing to load there is nothing to compile. - Expect(filepath.Join(tmpDir, "plugins")).ToNot(BeADirectory()) - }) - - It("does nothing when the plugin system is disabled", func() { - mgr = newManager(nil) - conf.Server.Plugins.Enabled = false - - Expect(mgr.LoadPlugins(ctx, []string{"test-metadata-agent", "broken"}, false)).To(Succeed()) - - Expect(mgr.PluginNames(string(CapabilityMetadataAgent))).To(BeEmpty()) - }) -}) diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go deleted file mode 100644 index 33b4f81d4..000000000 --- a/plugins/manager_sync.go +++ /dev/null @@ -1,249 +0,0 @@ -package plugins - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "time" - - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/events" -) - -// PluginMetadata holds the extracted information from a plugin file -// without fully initializing the plugin. -type PluginMetadata struct { - Manifest *Manifest - SHA256 string -} - -// adminContext returns a context with admin privileges for DB operations. -func adminContext(ctx context.Context) context.Context { - return request.WithUser(ctx, model.User{IsAdmin: true}) -} - -// marshalManifest marshals a manifest to JSON string, returning empty string on error. -func marshalManifest(m *Manifest) string { - b, _ := json.Marshal(m) - return string(b) -} - -// ComputeFileSHA256 computes the SHA-256 hash of a file without loading it into memory. -// This is used for quick change detection before full plugin compilation. -func ComputeFileSHA256(path string) (string, error) { - f, err := os.Open(path) - if err != nil { - return "", err - } - defer f.Close() - - h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return "", err - } - return hex.EncodeToString(h.Sum(nil)), nil -} - -// addPluginToDB adds a new plugin to the database as disabled. -func (m *Manager) addPluginToDB(ctx context.Context, repo model.PluginRepository, name, path string, metadata *PluginMetadata) error { - now := time.Now() - newPlugin := &model.Plugin{ - ID: name, - Path: path, - Manifest: marshalManifest(metadata.Manifest), - SHA256: metadata.SHA256, - Enabled: false, - CreatedAt: now, - UpdatedAt: now, - } - if err := repo.Put(ctx, newPlugin); err != nil { - return fmt.Errorf("adding plugin to DB: %w", err) - } - log.Info(ctx, "Discovered new plugin", "plugin", name) - m.sendPluginRefreshEvent(ctx, events.Any) - return nil -} - -// updatePluginInDB updates an existing plugin in the database after a file change. -// If the plugin was enabled, it will be unloaded and disabled. -func (m *Manager) updatePluginInDB(ctx context.Context, repo model.PluginRepository, dbPlugin *model.Plugin, path string, metadata *PluginMetadata) error { - wasEnabled := dbPlugin.Enabled - if wasEnabled { - if err := m.unloadPlugin(dbPlugin.ID); err != nil { - log.Debug(ctx, "Plugin not loaded during change", "plugin", dbPlugin.ID, err) - } - } - dbPlugin.Path = path - dbPlugin.Manifest = marshalManifest(metadata.Manifest) - dbPlugin.SHA256 = metadata.SHA256 - dbPlugin.Enabled = false - dbPlugin.LastError = "" - dbPlugin.UpdatedAt = time.Now() - if err := repo.Put(ctx, dbPlugin); err != nil { - return fmt.Errorf("updating plugin in DB: %w", err) - } - log.Info(ctx, "Plugin file changed", "plugin", dbPlugin.ID, "wasEnabled", wasEnabled) - m.sendPluginRefreshEvent(ctx, dbPlugin.ID) - return nil -} - -// removePluginFromDB removes a plugin from the database. -// If the plugin was enabled, it will be unloaded first. -func (m *Manager) removePluginFromDB(ctx context.Context, repo model.PluginRepository, dbPlugin *model.Plugin) error { - pluginID := dbPlugin.ID - if dbPlugin.Enabled { - if err := m.unloadPlugin(pluginID); err != nil { - log.Debug(ctx, "Plugin not loaded during removal", "plugin", pluginID, err) - } - } - if err := repo.Delete(ctx, pluginID); err != nil { - return fmt.Errorf("deleting plugin from DB: %w", err) - } - // Discard any scrobbles still buffered for the removed plugin, so they are - // not delivered to an unrelated plugin that reuses the same name later. - // Skip names owned by builtin scrobblers: buffer entries are keyed by - // service name, so removing a plugin file named e.g. "lastfm.ndp" must not - // wipe the builtin Last.fm retry queue. - if scrobbler.IsBuiltinScrobbler(pluginID) { - log.Debug(ctx, "Keeping buffered scrobbles: name is owned by a builtin scrobbler", "plugin", pluginID) - } else if err := m.ds.ScrobbleBuffer().Discard(ctx, pluginID); err != nil { - log.Error(ctx, "Error discarding buffered scrobbles for removed plugin", "plugin", pluginID, err) - } - log.Info(ctx, "Plugin removed", "plugin", pluginID) - m.sendPluginRefreshEvent(ctx, events.Any) - return nil -} - -// syncPlugins scans the plugins folder and synchronizes with the database. -// It handles new, changed, and removed plugins by comparing SHA-256 hashes. -// - New plugins are added to DB as disabled -// - Changed plugins are updated in DB and disabled if they were enabled -// - Removed plugins are deleted from DB (after unloading if enabled) -func (m *Manager) syncPlugins(ctx context.Context, folder string) error { - if m.ds == nil { - return fmt.Errorf("datastore not configured") - } - - adminCtx := adminContext(ctx) - - // Read current plugins from folder - entries, err := os.ReadDir(folder) - if err != nil { - if os.IsNotExist(err) { - log.Debug(ctx, "Plugins folder does not exist", "folder", folder) - return nil - } - return fmt.Errorf("reading plugins folder: %w", err) - } - - // Build map of files in folder - filesOnDisk := make(map[string]string) // name -> path - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), PackageExtension) { - log.Trace(ctx, "Skipping non-plugin entry", "name", entry.Name(), "isDir", entry.IsDir()) - continue - } - name, ok := pluginIDFromPath(entry.Name()) - if !ok { - log.Warn(ctx, "Skipping plugin with unusable name", "name", entry.Name()) - continue - } - filesOnDisk[name] = filepath.Join(folder, entry.Name()) - } - log.Debug(ctx, "Plugin sync: scanned folder", "folder", folder, "entriesTotal", len(entries), "pluginsFound", len(filesOnDisk)) - - // Get all plugins from DB - repo := m.ds.Plugin() - dbPlugins, err := repo.GetAll(adminCtx) - if err != nil { - return fmt.Errorf("reading plugins from DB: %w", err) - } - pluginsInDB := make(map[string]*model.Plugin) - for i := range dbPlugins { - pluginsInDB[dbPlugins[i].ID] = &dbPlugins[i] - } - log.Debug(ctx, "Plugin sync: current DB state", "pluginsInDB", len(pluginsInDB)) - - now := time.Now() - - // Process files on disk - for name, path := range filesOnDisk { - dbPlugin, exists := pluginsInDB[name] - - // Compute SHA256 first (lightweight operation) to check if plugin changed - sha256Hash, err := ComputeFileSHA256(path) - if err != nil { - log.Error(ctx, "Failed to compute SHA256 for plugin", "plugin", name, "path", path, err) - continue - } - - // If plugin exists in DB with same hash, skip full manifest extraction - if exists && dbPlugin.SHA256 == sha256Hash { - // Plugin unchanged - just update path in case folder moved - if dbPlugin.Path != path { - dbPlugin.Path = path - dbPlugin.UpdatedAt = now - if err := repo.Put(adminCtx, dbPlugin); err != nil { - log.Error(ctx, "Failed to update plugin path in DB", "plugin", name, err) - } - } - delete(pluginsInDB, name) - continue - } - - // Plugin is new or changed - need full manifest extraction - metadata, err := m.extractManifest(path) - if err != nil { - log.Error(ctx, "Failed to extract manifest from plugin", "plugin", name, "path", path, err) - // Store error in DB if plugin exists - if exists { - dbPlugin.LastError = err.Error() - dbPlugin.UpdatedAt = now - if dbPlugin.Enabled { - // Unload broken plugin - if unloadErr := m.unloadPlugin(name); unloadErr != nil { - log.Debug(ctx, "Plugin not loaded", "plugin", name) - } - dbPlugin.Enabled = false - } - if putErr := repo.Put(adminCtx, dbPlugin); putErr != nil { - log.Error(ctx, "Failed to update plugin in DB", "plugin", name, err) - } - } - delete(pluginsInDB, name) - continue - } - - if !exists { - // New plugin - add to DB as disabled - if err := m.addPluginToDB(adminCtx, repo, name, path, metadata); err != nil { - log.Error(ctx, "Failed to add plugin to DB", "plugin", name, err) - } - } else { - // Plugin changed - update DB - if err := m.updatePluginInDB(adminCtx, repo, dbPlugin, path, metadata); err != nil { - log.Error(ctx, "Failed to update plugin in DB", "plugin", name, err) - } - } - // Mark as processed - delete(pluginsInDB, name) - } - - // Remove plugins no longer on disk - for _, dbPlugin := range pluginsInDB { - if err := m.removePluginFromDB(adminCtx, repo, dbPlugin); err != nil { - log.Error(ctx, "Failed to delete plugin from DB", "plugin", dbPlugin.ID, err) - } - } - - return nil -} diff --git a/plugins/manager_sync_test.go b/plugins/manager_sync_test.go deleted file mode 100644 index e190abd34..000000000 --- a/plugins/manager_sync_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package plugins - -import ( - "context" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("syncPlugins", func() { - var ctx context.Context - var m *Manager - var repo *tests.MockPluginRepo - var folder string - - BeforeEach(func() { - ctx = GinkgoT().Context() - folder = GinkgoT().TempDir() - repo = tests.CreateMockPluginRepo() - repo.SetData(model.Plugins{}) - m = &Manager{ds: &tests.MockDataStore{MockedPlugin: repo}} - }) - - writePackage := func(name string) { - GinkgoHelper() - manifest := &Manifest{Name: "Test Plugin", Author: "Test Author", Version: "1.0.0"} - wasm := []byte{0x00, 0x61, 0x73, 0x6d} // Minimal wasm header - Expect(createTestPackage(filepath.Join(folder, name), manifest, wasm)).To(Succeed()) - } - - It("registers a plugin with a usable ID", func() { - writePackage("my-plugin" + PackageExtension) - - Expect(m.syncPlugins(context.Background(), folder)).To(Succeed()) - - _, err := repo.Get(ctx, "my-plugin") - Expect(err).ToNot(HaveOccurred()) - }) - - It("skips packages whose name yields a path-like ID", func() { - writePackage("." + PackageExtension) - writePackage(".." + PackageExtension) - - Expect(m.syncPlugins(context.Background(), folder)).To(Succeed()) - - all, err := repo.GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(all).To(BeEmpty()) - }) -}) - -var _ = Describe("removePluginFromDB", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) - - It("discards buffered scrobbles for the removed plugin", func() { - buffer := tests.CreateMockedScrobbleBufferRepo() - Expect(buffer.Enqueue(ctx, "my-plugin", "user1", "track1", time.Now())).To(Succeed()) - Expect(buffer.Enqueue(ctx, "other-plugin", "user1", "track2", time.Now())).To(Succeed()) - - repo := tests.CreateMockPluginRepo() - plugin := model.Plugin{ID: "my-plugin", Enabled: false} - repo.SetData(model.Plugins{plugin}) - - // No broker: sendPluginRefreshEvent is nil-safe, and testBroker is - // defined in manager_test.go, which is excluded on Windows. - m := &Manager{ - ds: &tests.MockDataStore{MockedScrobbleBuffer: buffer}, - } - Expect(m.removePluginFromDB(ctx, repo, &plugin)).To(Succeed()) - - _, err := repo.Get(ctx, "my-plugin") - Expect(err).To(MatchError(model.ErrNotFound)) - - remaining, err := buffer.Length(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(remaining).To(Equal(int64(1))) - entry, err := buffer.Next(ctx, "other-plugin", "user1") - Expect(err).ToNot(HaveOccurred()) - Expect(entry).ToNot(BeNil(), "entries of other services must be kept") - }) - - It("keeps buffered scrobbles of a builtin scrobbler sharing the removed plugin's name", func() { - scrobbler.Register("builtin-svc", func(model.DataStore) scrobbler.Scrobbler { return nil }) - buffer := tests.CreateMockedScrobbleBufferRepo() - Expect(buffer.Enqueue(ctx, "builtin-svc", "user1", "track1", time.Now())).To(Succeed()) - - repo := tests.CreateMockPluginRepo() - plugin := model.Plugin{ID: "builtin-svc", Enabled: false} - repo.SetData(model.Plugins{plugin}) - - m := &Manager{ - ds: &tests.MockDataStore{MockedScrobbleBuffer: buffer}, - } - Expect(m.removePluginFromDB(ctx, repo, &plugin)).To(Succeed()) - - remaining, err := buffer.Length(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(remaining).To(Equal(int64(1)), "builtin scrobbler queue must not be wiped") - }) -}) - -var _ = Describe("ComputeFileSHA256", func() { - It("returns a consistent 64-char lowercase hex hash for the same file", func() { - dir := GinkgoT().TempDir() - ndpPath := filepath.Join(dir, "test.ndp") - err := createTestPackage(ndpPath, &Manifest{Name: "S", Author: "a", Version: "1.0.0"}, []byte{0x00, 0x61, 0x73, 0x6d}) - Expect(err).ToNot(HaveOccurred()) - - hash1, err := ComputeFileSHA256(ndpPath) - Expect(err).ToNot(HaveOccurred()) - hash2, err := ComputeFileSHA256(ndpPath) - Expect(err).ToNot(HaveOccurred()) - - Expect(hash1).To(Equal(hash2)) - Expect(hash1).To(MatchRegexp(`^[0-9a-f]{64}$`)) - }) - - It("returns an error for a non-existent path", func() { - _, err := ComputeFileSHA256(filepath.Join(GinkgoT().TempDir(), "does-not-exist.ndp")) - Expect(err).To(HaveOccurred()) - }) -}) diff --git a/plugins/manager_test.go b/plugins/manager_test.go deleted file mode 100644 index 6cf90994a..000000000 --- a/plugins/manager_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package plugins - -import ( - "context" - "fmt" - "net/http" - "sync" - - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/server/events" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Manager", Ordered, func() { - var ctx context.Context - - BeforeAll(func() { - ctx = GinkgoT().Context() - }) - - Describe("Plugin Loading", func() { - It("loads enabled plugins from DB on Start", func() { - // Plugin is already loaded by testManager.Start() via loadEnabledPlugins - names := testManager.PluginNames(string(CapabilityMetadataAgent)) - Expect(names).To(ContainElement("test-metadata-agent")) - }) - }) - - Describe("unloadPlugin", func() { - It("removes a loaded plugin", func() { - // Plugin is already loaded from Start - err := testManager.unloadPlugin("test-metadata-agent") - Expect(err).ToNot(HaveOccurred()) - - names := testManager.PluginNames(string(CapabilityMetadataAgent)) - Expect(names).ToNot(ContainElement("test-metadata-agent")) - }) - - It("returns error when plugin not found", func() { - err := testManager.unloadPlugin("nonexistent") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not found")) - }) - }) - - Describe("EnablePlugin", func() { - It("enables and loads a disabled plugin", func() { - // First disable the plugin (which also unloads it) - err := testManager.DisablePlugin(ctx, "test-metadata-agent") - Expect(err).ToNot(HaveOccurred()) - Expect(testManager.PluginNames(string(CapabilityMetadataAgent))).ToNot(ContainElement("test-metadata-agent")) - - // Enable it - err = testManager.EnablePlugin(ctx, "test-metadata-agent") - Expect(err).ToNot(HaveOccurred()) - - names := testManager.PluginNames(string(CapabilityMetadataAgent)) - Expect(names).To(ContainElement("test-metadata-agent")) - }) - }) - - Describe("DisablePlugin", func() { - It("disables and unloads an enabled plugin", func() { - // Ensure the plugin is loaded first - _ = testManager.EnablePlugin(ctx, "test-metadata-agent") - - err := testManager.DisablePlugin(ctx, "test-metadata-agent") - Expect(err).ToNot(HaveOccurred()) - - names := testManager.PluginNames(string(CapabilityMetadataAgent)) - Expect(names).ToNot(ContainElement("test-metadata-agent")) - }) - }) - - Describe("GetPluginInfo", func() { - BeforeEach(func() { - // Ensure plugin is loaded for this test - _ = testManager.EnablePlugin(ctx, "test-metadata-agent") - }) - - It("returns information about all loaded plugins", func() { - info := testManager.GetPluginInfo() - Expect(info).To(HaveKey("test-metadata-agent")) - Expect(info["test-metadata-agent"].Name).To(Equal("Test Plugin")) - Expect(info["test-metadata-agent"].Version).To(Equal("1.0.0")) - }) - }) - - It("can call the plugin concurrently", func() { - // Ensure plugin is loaded - _ = testManager.EnablePlugin(ctx, "test-metadata-agent") - - const concurrency = 30 - errs := make(chan error, concurrency) - bios := make(chan string, concurrency) - - g := sync.WaitGroup{} - g.Add(concurrency) - for i := range concurrency { - go func(i int) { - defer g.Done() - a, ok := testManager.LoadMediaAgent("test-metadata-agent") - Expect(ok).To(BeTrue()) - agent := a.(agents.ArtistBiographyRetriever) - bio, err := agent.GetArtistBiography(ctx, fmt.Sprintf("artist-%d", i), fmt.Sprintf("Artist %d", i), "") - if err != nil { - errs <- err - return - } - bios <- bio - }(i) - } - g.Wait() - - // Collect results - for range concurrency { - select { - case err := <-errs: - Expect(err).ToNot(HaveOccurred()) - case bio := <-bios: - Expect(bio).To(ContainSubstring("Biography for Artist")) - } - } - }) - - Describe("sendPluginRefreshEvent", func() { - var broker *testBroker - var manager *Manager - - BeforeEach(func() { - broker = &testBroker{} - manager = &Manager{ - broker: broker, - } - }) - - It("sends refresh event with single plugin ID", func() { - manager.sendPluginRefreshEvent(ctx, "test-plugin") - - Expect(broker.broadcastCalled).To(BeTrue()) - Expect(broker.lastEvent).ToNot(BeNil()) - Expect(broker.lastEventCtx).To(Equal(ctx)) - - refreshEvent, ok := broker.lastEvent.(*events.RefreshResource) - Expect(ok).To(BeTrue(), "event should be a RefreshResource") - Expect(refreshEvent.Data(refreshEvent)).To(Equal(`{"plugin":["test-plugin"]}`)) - }) - - It("sends refresh event with multiple plugin IDs", func() { - manager.sendPluginRefreshEvent(ctx, "plugin-1", "plugin-2", "plugin-3") - - Expect(broker.broadcastCalled).To(BeTrue()) - refreshEvent, ok := broker.lastEvent.(*events.RefreshResource) - Expect(ok).To(BeTrue()) - Expect(refreshEvent.Data(refreshEvent)).To(Equal(`{"plugin":["plugin-1","plugin-2","plugin-3"]}`)) - }) - - It("sends refresh event with wildcard when using events.Any", func() { - manager.sendPluginRefreshEvent(ctx, events.Any) - - Expect(broker.broadcastCalled).To(BeTrue()) - refreshEvent, ok := broker.lastEvent.(*events.RefreshResource) - Expect(ok).To(BeTrue()) - Expect(refreshEvent.Data(refreshEvent)).To(Equal(`{"plugin":["*"]}`)) - }) - - It("does not panic when broker is nil", func() { - manager.broker = nil - Expect(func() { - manager.sendPluginRefreshEvent(ctx, "test-plugin") - }).ToNot(Panic()) - }) - }) -}) - -// testBroker is a simple mock implementation of events.Broker for testing -type testBroker struct { - lastEvent events.Event - lastEventCtx context.Context - broadcastCalled bool -} - -func (m *testBroker) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Not used in tests -} - -func (m *testBroker) SendMessage(ctx context.Context, event events.Event) { - // Not used in tests -} - -func (m *testBroker) SendBroadcastMessage(ctx context.Context, event events.Event) { - m.lastEvent = event - m.lastEventCtx = ctx - m.broadcastCalled = true -} diff --git a/plugins/manager_watcher.go b/plugins/manager_watcher.go deleted file mode 100644 index ce9e25a9d..000000000 --- a/plugins/manager_watcher.go +++ /dev/null @@ -1,221 +0,0 @@ -package plugins - -import ( - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/rjeczalik/notify" -) - -// debounceDuration is the time to wait before acting on file events -// to handle multiple rapid events for the same file. -const debounceDuration = 2 * time.Second - -// startWatcher starts the file watcher for the plugins folder. -// It watches for CREATE, WRITE, and REMOVE events on .wasm files. -func (m *Manager) startWatcher() error { - folder := conf.Server.Plugins.Folder.String() - if folder == "" { - return nil - } - - m.watcherEvents = make(chan notify.EventInfo, 10) - m.watcherDone = make(chan struct{}) - m.debounceTimers = make(map[string]*time.Timer) - m.debounceMu = sync.Mutex{} - - // Watch the plugins folder (not recursive) - // We filter for .wasm files in the event handler - if err := notify.Watch(folder, m.watcherEvents, notify.Create, notify.Write, notify.Remove, notify.Rename); err != nil { - close(m.watcherEvents) - return err - } - - log.Info(m.ctx, "Started plugin file watcher", "folder", folder) - - go m.watcherLoop() - - return nil -} - -// stopWatcher stops the file watcher -func (m *Manager) stopWatcher() { - if m.watcherEvents == nil { - return - } - - notify.Stop(m.watcherEvents) - close(m.watcherDone) - - // Cancel any pending debounce timers - m.debounceMu.Lock() - for _, timer := range m.debounceTimers { - timer.Stop() - } - m.debounceTimers = nil - m.debounceMu.Unlock() - - log.Debug(m.ctx, "Stopped plugin file watcher") -} - -// watcherLoop processes file watcher events -func (m *Manager) watcherLoop() { - for { - select { - case event, ok := <-m.watcherEvents: - if !ok { - return - } - m.handleWatcherEvent(event) - case <-m.ctx.Done(): - return - case <-m.watcherDone: - return - } - } -} - -// handleWatcherEvent processes a single file watcher event with debouncing -func (m *Manager) handleWatcherEvent(event notify.EventInfo) { - path := event.Path() - - // Only process .ndp package files - if !strings.HasSuffix(path, PackageExtension) { - return - } - - pluginName, ok := pluginIDFromPath(path) - if !ok { - log.Warn(m.ctx, "Ignoring plugin file with unusable name", "path", path) - return - } - - log.Trace(m.ctx, "Plugin file event", "plugin", pluginName, "event", event.Event(), "path", path) - - // Debounce: cancel any pending timer for this plugin and start a new one - m.debounceMu.Lock() - if timer, exists := m.debounceTimers[pluginName]; exists { - timer.Stop() - } - - // Note: We don't capture the event type here. Instead, processPluginEvent - // checks if the file exists when the timer fires. This handles sequences like - // Remove+Create+Rename correctly by checking actual file state after debounce. - m.debounceTimers[pluginName] = time.AfterFunc(debounceDuration, func() { - m.processPluginEvent(pluginName) - }) - m.debounceMu.Unlock() -} - -// pluginAction represents the action to take on a plugin based on file state -type pluginAction int - -const ( - actionNone pluginAction = iota // No action needed - actionUpdate // File exists: add new or update existing plugin in DB - actionRemove // File gone: remove plugin from DB (unload if enabled) -) - -// determinePluginAction decides what action to take based on file existence. -// We check file existence rather than relying on event type because: -// 1. Events can be coalesced on some systems (macOS FSEvents) -// 2. Rename events can mean either "renamed away" (remove) or "renamed to" (add) -// 3. Build tools often do atomic writes (write temp file, rename to target) -// By checking existence, we handle all these cases correctly. -func determinePluginAction(path string) pluginAction { - if _, err := os.Stat(path); err == nil { - // File exists - treat as add/update - return actionUpdate - } - // File doesn't exist - it was removed - return actionRemove -} - -// processPluginEvent handles the actual plugin load/unload/reload after debouncing. -// - If file exists: extract manifest, add or update plugin in DB -// - If file gone: unload if enabled, delete from DB -func (m *Manager) processPluginEvent(pluginName string) { - // Don't process if manager is stopping/stopped (atomic check to avoid race with Stop()) - if m.stopped.Load() { - return - } - - // Clean up debounce timer entry - m.debounceMu.Lock() - delete(m.debounceTimers, pluginName) - m.debounceMu.Unlock() - - folder := conf.Server.Plugins.Folder.String() - ndpPath := filepath.Join(folder, pluginName+PackageExtension) - - action := determinePluginAction(ndpPath) - log.Debug(m.ctx, "Plugin event action", "plugin", pluginName, "action", action, "path", ndpPath) - - ctx := adminContext(m.ctx) - repo := m.ds.Plugin() - - switch action { - case actionUpdate: - // File changed - check SHA256 first, then extract manifest if needed - sha256Hash, err := ComputeFileSHA256(ndpPath) - if err != nil { - log.Error(m.ctx, "Failed to compute SHA256 for changed plugin", "plugin", pluginName, err) - return - } - - dbPlugin, err := repo.Get(ctx, pluginName) - if err != nil { - // Plugin not in DB yet, need full manifest extraction to add it - metadata, extractErr := m.extractManifest(ndpPath) - if extractErr != nil { - log.Error(m.ctx, "Failed to extract manifest from new plugin", "plugin", pluginName, extractErr) - return - } - if addErr := m.addPluginToDB(ctx, repo, pluginName, ndpPath, metadata); addErr != nil { - log.Error(m.ctx, "Failed to add plugin to DB", "plugin", pluginName, addErr) - } - return - } - - // Check if actually changed using lightweight SHA256 comparison - if dbPlugin.SHA256 == sha256Hash { - return // No actual change - } - - // Plugin changed - now extract full manifest - metadata, err := m.extractManifest(ndpPath) - if err != nil { - log.Error(m.ctx, "Failed to extract manifest from changed plugin", "plugin", pluginName, err) - // Update error in DB - dbPlugin.LastError = err.Error() - dbPlugin.UpdatedAt = time.Now() - if dbPlugin.Enabled { - _ = m.unloadPlugin(pluginName) - dbPlugin.Enabled = false - } - _ = repo.Put(ctx, dbPlugin) - return - } - - if err := m.updatePluginInDB(ctx, repo, dbPlugin, ndpPath, metadata); err != nil { - log.Error(m.ctx, "Failed to update plugin in DB", "plugin", pluginName, err) - } - - case actionRemove: - // File removed - unload if enabled, delete from DB - dbPlugin, err := repo.Get(ctx, pluginName) - if err != nil { - log.Debug(m.ctx, "Removed plugin not in DB", "plugin", pluginName) - return - } - - if err := m.removePluginFromDB(ctx, repo, dbPlugin); err != nil { - log.Error(m.ctx, "Failed to delete plugin from DB", "plugin", pluginName, err) - } - } -} diff --git a/plugins/manager_watcher_test.go b/plugins/manager_watcher_test.go deleted file mode 100644 index 17d9489dc..000000000 --- a/plugins/manager_watcher_test.go +++ /dev/null @@ -1,181 +0,0 @@ -package plugins - -import ( - "context" - "net/http" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Plugin Watcher", func() { - Describe("Integration Tests", Ordered, func() { - // Uses testdataDir and createTestManager from BeforeSuite - var ( - manager *Manager - tmpDir string - ctx context.Context - ) - - BeforeAll(func() { - ctx = GinkgoT().Context() - - // Create manager for watcher lifecycle tests (no plugin preloaded - tests copy plugin as needed) - manager, tmpDir = createTestManager(nil) - - // Remove the auto-loaded plugin so tests can control loading - _ = manager.unloadPlugin("test-metadata-agent") - _ = os.Remove(filepath.Join(tmpDir, "test-metadata-agent"+PackageExtension)) - // Also remove from DB so tests start with a clean slate - _ = manager.ds.Plugin().Delete(ctx, "test-metadata-agent") - }) - - // Helper to copy test plugin into the temp folder - copyTestPlugin := func() { - srcPath := filepath.Join(testdataDir, "test-metadata-agent"+PackageExtension) - destPath := filepath.Join(tmpDir, "test-metadata-agent"+PackageExtension) - data, err := os.ReadFile(srcPath) - Expect(err).ToNot(HaveOccurred()) - err = os.WriteFile(destPath, data, 0600) - Expect(err).ToNot(HaveOccurred()) - } - - Describe("Plugin event processing (integration)", func() { - // These tests verify the DB-driven flow with actual WASM plugin loading. - - AfterEach(func() { - // Clean up: unload plugin if loaded, remove copied file, delete from DB - _ = manager.unloadPlugin("test-metadata-agent") - _ = os.Remove(filepath.Join(tmpDir, "test-metadata-agent"+PackageExtension)) - _ = manager.ds.Plugin().Delete(ctx, "test-metadata-agent") - }) - - It("adds plugin to DB when file exists", func() { - copyTestPlugin() - manager.processPluginEvent("test-metadata-agent") - - // Plugin should be in DB but not loaded (starts disabled) - Expect(manager.PluginNames(string(CapabilityMetadataAgent))).ToNot(ContainElement("test-metadata-agent")) - - // Verify it was added to DB - repo := manager.ds.Plugin() - plugin, err := repo.Get(ctx, "test-metadata-agent") - Expect(err).ToNot(HaveOccurred()) - Expect(plugin.ID).To(Equal("test-metadata-agent")) - Expect(plugin.Enabled).To(BeFalse()) - }) - - It("updates DB and disables plugin when file changes", func() { - copyTestPlugin() - - // First add and enable the plugin - manager.processPluginEvent("test-metadata-agent") - err := manager.EnablePlugin(ctx, "test-metadata-agent") - Expect(err).ToNot(HaveOccurred()) - Expect(manager.PluginNames(string(CapabilityMetadataAgent))).To(ContainElement("test-metadata-agent")) - - // Modify the stored SHA256 in DB to simulate a file change - // (In reality, the file would have different content) - repo := manager.ds.Plugin() - plugin, err := repo.Get(ctx, "test-metadata-agent") - Expect(err).ToNot(HaveOccurred()) - plugin.SHA256 = "different-hash-to-simulate-change" - err = repo.Put(ctx, plugin) - Expect(err).ToNot(HaveOccurred()) - - // Simulate modification - the plugin should be disabled and unloaded - manager.processPluginEvent("test-metadata-agent") - - // Should be unloaded - Expect(manager.PluginNames(string(CapabilityMetadataAgent))).ToNot(ContainElement("test-metadata-agent")) - - // But still in DB (just disabled) - plugin, err = repo.Get(ctx, "test-metadata-agent") - Expect(err).ToNot(HaveOccurred()) - Expect(plugin.Enabled).To(BeFalse()) - }) - - It("removes plugin from DB when file is removed", func() { - copyTestPlugin() - - // First add and enable the plugin - manager.processPluginEvent("test-metadata-agent") - err := manager.EnablePlugin(ctx, "test-metadata-agent") - Expect(err).ToNot(HaveOccurred()) - - // Remove the file - plugin should be unloaded and removed from DB - _ = os.Remove(filepath.Join(tmpDir, "test-metadata-agent"+PackageExtension)) - manager.processPluginEvent("test-metadata-agent") - - // Should be unloaded - Expect(manager.PluginNames(string(CapabilityMetadataAgent))).ToNot(ContainElement("test-metadata-agent")) - - // And removed from DB - repo := manager.ds.Plugin() - _, err = repo.Get(ctx, "test-metadata-agent") - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("Watcher lifecycle", func() { - It("does not start file watcher when AutoReload is disabled", func() { - Expect(manager.watcherEvents).To(BeNil()) - Expect(manager.watcherDone).To(BeNil()) - }) - - It("starts file watcher when AutoReload is enabled", func() { - _ = manager.Stop() - - conf.Server.Plugins.AutoReload = true - - // Set up a mock DataStore for the auto-reload manager - mockPluginRepo := tests.CreateMockPluginRepo() - mockPluginRepo.Permitted = true - dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo} - - autoReloadManager := &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - subsonicRouter: http.NotFoundHandler(), - } - err := autoReloadManager.Start(ctx) - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(autoReloadManager.Stop) - - Expect(autoReloadManager.watcherEvents).ToNot(BeNil()) - Expect(autoReloadManager.watcherDone).ToNot(BeNil()) - }) - }) - }) - - Describe("determinePluginAction", func() { - var tmpDir string - - BeforeEach(func() { - var err error - tmpDir, err = os.MkdirTemp("", "plugin-action-test-*") - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - os.RemoveAll(tmpDir) - }) - - It("returns actionUpdate when file exists", func() { - filePath := filepath.Join(tmpDir, "test.ndp") - err := os.WriteFile(filePath, []byte("test"), 0600) - Expect(err).ToNot(HaveOccurred()) - - Expect(determinePluginAction(filePath)).To(Equal(actionUpdate)) - }) - - It("returns actionRemove when file does not exist", func() { - filePath := filepath.Join(tmpDir, "nonexistent.ndp") - Expect(determinePluginAction(filePath)).To(Equal(actionRemove)) - }) - }) -}) diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json deleted file mode 100644 index 056a1caed..000000000 --- a/plugins/manifest-schema.json +++ /dev/null @@ -1,277 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://navidrome.org/schemas/Manifest.json", - "title": "Manifest", - "description": "Plugin manifest for Navidrome plugins", - "type": "object", - "additionalProperties": false, - "required": ["name", "author", "version"], - "properties": { - "name": { - "type": "string", - "description": "The display name of the plugin", - "minLength": 1 - }, - "author": { - "type": "string", - "description": "The author of the plugin", - "minLength": 1 - }, - "version": { - "type": "string", - "description": "The version of the plugin (semver recommended)", - "minLength": 1 - }, - "description": { - "type": "string", - "description": "A brief description of what the plugin does" - }, - "website": { - "type": "string", - "description": "URL to the plugin's website or repository", - "format": "uri" - }, - "permissions": { - "$ref": "#/$defs/Permissions" - }, - "config": { - "$ref": "#/$defs/ConfigDefinition" - } - }, - "$defs": { - "ConfigDefinition": { - "type": "object", - "description": "Configuration schema for the plugin using JSON Schema (draft-07) and optional JSONForms UI Schema", - "additionalProperties": false, - "required": ["schema"], - "properties": { - "schema": { - "type": "object", - "description": "JSON Schema (draft-07) defining the plugin's configuration options" - }, - "uiSchema": { - "type": "object", - "description": "Optional JSONForms UI Schema for customizing form layout" - } - } - }, - "Permissions": { - "type": "object", - "description": "Permissions required by the plugin", - "additionalProperties": false, - "properties": { - "http": { - "$ref": "#/$defs/HTTPPermission" - }, - "subsonicapi": { - "$ref": "#/$defs/SubsonicAPIPermission" - }, - "scheduler": { - "$ref": "#/$defs/SchedulerPermission" - }, - "websocket": { - "$ref": "#/$defs/WebSocketPermission" - }, - "artwork": { - "$ref": "#/$defs/ArtworkPermission" - }, - "cache": { - "$ref": "#/$defs/CachePermission" - }, - "library": { - "$ref": "#/$defs/LibraryPermission" - }, - "kvstore": { - "$ref": "#/$defs/KVStorePermission" - }, - "users": { - "$ref": "#/$defs/UsersPermission" - }, - "taskqueue": { - "$ref": "#/$defs/TaskQueuePermission" - }, - "matcher": { - "$ref": "#/$defs/MatcherPermission" - }, - "storage": { - "$ref": "#/$defs/StoragePermission" - }, - "scrobbleRetriever": { - "$ref": "#/$defs/ScrobbleRetrieverPermission" - } - } - }, - "ArtworkPermission": { - "type": "object", - "description": "Artwork service permissions for generating artwork URLs", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why artwork access is needed" - } - } - }, - "CachePermission": { - "type": "object", - "description": "Cache service permissions for storing and retrieving data", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why cache access is needed" - } - } - }, - "HTTPPermission": { - "type": "object", - "description": "HTTP access permissions for a plugin", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why HTTP access is needed" - }, - "requiredHosts": { - "type": "array", - "description": "List of required host patterns for HTTP requests (e.g., 'api.example.com', '*.musicbrainz.org'). A named host alone can't reach a private address; also list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that", - "items": { - "type": "string" - } - } - } - }, - "SubsonicAPIPermission": { - "type": "object", - "description": "SubsonicAPI service permissions. Requires 'users' permission to be declared.", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why SubsonicAPI access is needed" - } - } - }, - "SchedulerPermission": { - "type": "object", - "description": "Scheduler service permissions for scheduling tasks", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why scheduler access is needed" - } - } - }, - "WebSocketPermission": { - "type": "object", - "description": "WebSocket service permissions for establishing WebSocket connections", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why WebSocket access is needed" - }, - "requiredHosts": { - "type": "array", - "description": "List of required host patterns for WebSocket connections (e.g., 'api.example.com', '*.musicbrainz.org'). Required: with no entries every connection is blocked. A named host alone can't reach a private address; also list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that", - "items": { - "type": "string" - } - } - } - }, - "LibraryPermission": { - "type": "object", - "description": "Library service permissions for accessing library metadata and optionally filesystem", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why library access is needed" - }, - "filesystem": { - "type": "boolean", - "description": "Whether the plugin requires read-only filesystem access to library directories", - "default": false - } - } - }, - "KVStorePermission": { - "type": "object", - "description": "Key-value store permissions for persistent plugin storage", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why key-value store access is needed" - }, - "maxSize": { - "type": "string", - "description": "Maximum storage size (e.g., '1MB', '500KB'). Default: 1MB" - } - } - }, - "TaskQueuePermission": { - "type": "object", - "description": "Task queue permissions for background task processing", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why task queue access is needed" - }, - "maxConcurrency": { - "type": "integer", - "description": "Maximum total concurrent workers across all queues. Default: 1", - "minimum": 1, - "default": 1 - } - } - }, - "UsersPermission": { - "type": "object", - "description": "Users service permissions for accessing user information", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why users access is needed" - } - } - }, - "MatcherPermission": { - "type": "object", - "description": "Matcher service permissions for resolving external songs to local library tracks", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why matcher access is needed" - } - } - }, - "StoragePermission": { - "type": "object", - "description": "Storage permissions for enabling persistent read-write storage exclusively for the plugin", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why storage access is needed" - } - } - }, - "ScrobbleRetrieverPermission": { - "type": "object", - "description": "Scrobble retriever permissions for retrieving scrobbles from users", - "additionalProperties": false, - "properties": { - "reason": { - "type": "string", - "description": "Explanation for why scrobble retriever access is needed" - } - } - } - } -} diff --git a/plugins/manifest.go b/plugins/manifest.go deleted file mode 100644 index 5c0d91f51..000000000 --- a/plugins/manifest.go +++ /dev/null @@ -1,133 +0,0 @@ -package plugins - -import ( - "encoding/json" - "fmt" - "reflect" - "sort" - "strings" - - "github.com/santhosh-tekuri/jsonschema/v6" -) - -// DeclaredNames returns the sorted names of the non-nil permission fields. It -// reflects over the generated json tags so new permission types are picked up -// automatically rather than via a hand-maintained list. -func (p *Permissions) DeclaredNames() []string { - if p == nil { - return nil - } - var names []string - v := reflect.ValueOf(*p) - t := v.Type() - for i := 0; i < t.NumField(); i++ { - f := v.Field(i) - if f.Kind() != reflect.Pointer || f.IsNil() { - continue - } - tag := t.Field(i).Tag.Get("json") - if name, _, _ := strings.Cut(tag, ","); name != "" && name != "-" { - names = append(names, name) - } - } - sort.Strings(names) - return names -} - -//go:generate go tool go-jsonschema -p plugins --struct-name-from-title -o manifest_gen.go manifest-schema.json - -// ParseManifest unmarshals manifest JSON and performs cross-field validation. -// This is the single entry point for manifest parsing after reading from a file. -func ParseManifest(data []byte) (*Manifest, error) { - var m Manifest - if err := json.Unmarshal(data, &m); err != nil { - return nil, fmt.Errorf("parsing manifest JSON: %w", err) - } - if err := m.Validate(); err != nil { - return nil, fmt.Errorf("validating manifest: %w", err) - } - return &m, nil -} - -// Validate performs cross-field validation that cannot be expressed in JSON Schema. -// This validates rules like "SubsonicAPI permission requires users permission". -func (m *Manifest) Validate() error { - // SubsonicAPI permission requires users permission - if m.Permissions != nil && m.Permissions.Users == nil { - if m.Permissions.Subsonicapi != nil { - return fmt.Errorf("'subsonicapi' permission requires 'users' permission to be declared") - } - - if m.Permissions.ScrobbleRetriever != nil { - return fmt.Errorf("'scrobbleRetriever' permission requires 'users' permission to be declared") - } - } - - // Matcher returns library content, so it requires the library permission (which - // is what exposes a library scope for configuration). - if m.Permissions != nil && m.Permissions.Matcher != nil { - if m.Permissions.Library == nil { - return fmt.Errorf("'matcher' permission requires 'library' permission to be declared") - } - } - - // Validate config schema if present - if m.Config != nil && m.Config.Schema != nil { - if err := validateConfigSchema(m.Config.Schema); err != nil { - return fmt.Errorf("invalid config schema: %w", err) - } - } - - return nil -} - -// validateConfigSchema validates that the schema is a valid JSON Schema that can be compiled. -func validateConfigSchema(schema map[string]any) error { - compiler := jsonschema.NewCompiler() - if err := compiler.AddResource("schema.json", schema); err != nil { - return fmt.Errorf("invalid schema structure: %w", err) - } - if _, err := compiler.Compile("schema.json"); err != nil { - return err - } - return nil -} - -// ValidateWithCapabilities validates the manifest against detected capabilities. -// This must be called after WASM capability detection since Scrobbler capability -// is detected from exported functions, not manifest declarations. -func ValidateWithCapabilities(m *Manifest, capabilities []Capability) error { - // Scrobbler capability requires users permission - if hasCapability(capabilities, CapabilityScrobbler) { - if m.Permissions == nil || m.Permissions.Users == nil { - return fmt.Errorf("scrobbler capability requires 'users' permission to be declared in manifest") - } - } - - // Scheduler permission requires SchedulerCallback capability - if m.Permissions != nil && m.Permissions.Scheduler != nil { - if !hasCapability(capabilities, CapabilityScheduler) { - return fmt.Errorf("'scheduler' permission requires plugin to export '%s' function", FuncSchedulerCallback) - } - } - - // Task (taskqueue) permission requires TaskWorker capability - if m.Permissions != nil && m.Permissions.Taskqueue != nil { - if !hasCapability(capabilities, CapabilityTaskWorker) { - return fmt.Errorf("'taskqueue' permission requires plugin to export '%s' function", FuncTaskWorkerCallback) - } - } - - return 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 -} - -func (m *Manifest) HasStoragePermission() bool { - return m.Permissions != nil && m.Permissions.Storage != nil -} diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go deleted file mode 100644 index b5e9afd96..000000000 --- a/plugins/manifest_gen.go +++ /dev/null @@ -1,272 +0,0 @@ -// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. - -package plugins - -import "encoding/json" -import "fmt" - -// Artwork service permissions for generating artwork URLs -type ArtworkPermission struct { - // Explanation for why artwork access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - -// Cache service permissions for storing and retrieving data -type CachePermission struct { - // Explanation for why cache access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - -// Configuration schema for the plugin using JSON Schema (draft-07) and optional -// JSONForms UI Schema -type ConfigDefinition struct { - // JSON Schema (draft-07) defining the plugin's configuration options - Schema map[string]interface{} `json:"schema" yaml:"schema" mapstructure:"schema"` - - // Optional JSONForms UI Schema for customizing form layout - UiSchema map[string]interface{} `json:"uiSchema,omitempty" yaml:"uiSchema,omitempty" mapstructure:"uiSchema,omitempty"` -} - -// UnmarshalJSON implements json.Unmarshaler. -func (j *ConfigDefinition) UnmarshalJSON(value []byte) error { - var raw map[string]interface{} - if err := json.Unmarshal(value, &raw); err != nil { - return err - } - if _, ok := raw["schema"]; raw != nil && !ok { - return fmt.Errorf("field schema in ConfigDefinition: required") - } - type Plain ConfigDefinition - var plain Plain - if err := json.Unmarshal(value, &plain); err != nil { - return err - } - *j = ConfigDefinition(plain) - return nil -} - -// HTTP access permissions for a plugin -type HTTPPermission struct { - // Explanation for why HTTP access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` - - // List of required host patterns for HTTP requests (e.g., 'api.example.com', - // '*.musicbrainz.org'). A named host alone can't reach a private address; also - // list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that - RequiredHosts []string `json:"requiredHosts,omitempty" yaml:"requiredHosts,omitempty" mapstructure:"requiredHosts,omitempty"` -} - -// Key-value store permissions for persistent plugin storage -type KVStorePermission struct { - // Maximum storage size (e.g., '1MB', '500KB'). Default: 1MB - MaxSize *string `json:"maxSize,omitempty" yaml:"maxSize,omitempty" mapstructure:"maxSize,omitempty"` - - // Explanation for why key-value store access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - -// Library service permissions for accessing library metadata and optionally -// filesystem -type LibraryPermission struct { - // Whether the plugin requires read-only filesystem access to library directories - Filesystem bool `json:"filesystem,omitempty" yaml:"filesystem,omitempty" mapstructure:"filesystem,omitempty"` - - // Explanation for why library access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - -// UnmarshalJSON implements json.Unmarshaler. -func (j *LibraryPermission) UnmarshalJSON(value []byte) error { - var raw map[string]interface{} - if err := json.Unmarshal(value, &raw); err != nil { - return err - } - type Plain LibraryPermission - var plain Plain - if err := json.Unmarshal(value, &plain); err != nil { - return err - } - if v, ok := raw["filesystem"]; !ok || v == nil { - plain.Filesystem = false - } - *j = LibraryPermission(plain) - return nil -} - -// Plugin manifest for Navidrome plugins -type Manifest struct { - // The author of the plugin - Author string `json:"author" yaml:"author" mapstructure:"author"` - - // Config corresponds to the JSON schema field "config". - Config *ConfigDefinition `json:"config,omitempty" yaml:"config,omitempty" mapstructure:"config,omitempty"` - - // A brief description of what the plugin does - Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"` - - // The display name of the plugin - Name string `json:"name" yaml:"name" mapstructure:"name"` - - // Permissions corresponds to the JSON schema field "permissions". - Permissions *Permissions `json:"permissions,omitempty" yaml:"permissions,omitempty" mapstructure:"permissions,omitempty"` - - // The version of the plugin (semver recommended) - Version string `json:"version" yaml:"version" mapstructure:"version"` - - // URL to the plugin's website or repository - Website *string `json:"website,omitempty" yaml:"website,omitempty" mapstructure:"website,omitempty"` -} - -// UnmarshalJSON implements json.Unmarshaler. -func (j *Manifest) UnmarshalJSON(value []byte) error { - var raw map[string]interface{} - if err := json.Unmarshal(value, &raw); err != nil { - return err - } - if _, ok := raw["author"]; raw != nil && !ok { - return fmt.Errorf("field author in Manifest: required") - } - if _, ok := raw["name"]; raw != nil && !ok { - return fmt.Errorf("field name in Manifest: required") - } - if _, ok := raw["version"]; raw != nil && !ok { - return fmt.Errorf("field version in Manifest: required") - } - type Plain Manifest - var plain Plain - if err := json.Unmarshal(value, &plain); err != nil { - return err - } - if len(plain.Author) < 1 { - return fmt.Errorf("field %s length: must be >= %d", "author", 1) - } - if len(plain.Name) < 1 { - return fmt.Errorf("field %s length: must be >= %d", "name", 1) - } - if len(plain.Version) < 1 { - return fmt.Errorf("field %s length: must be >= %d", "version", 1) - } - *j = Manifest(plain) - return nil -} - -// Matcher service permissions for resolving external songs to local library tracks -type MatcherPermission struct { - // Explanation for why matcher access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - -// Permissions required by the plugin -type Permissions struct { - // Artwork corresponds to the JSON schema field "artwork". - Artwork *ArtworkPermission `json:"artwork,omitempty" yaml:"artwork,omitempty" mapstructure:"artwork,omitempty"` - - // Cache corresponds to the JSON schema field "cache". - Cache *CachePermission `json:"cache,omitempty" yaml:"cache,omitempty" mapstructure:"cache,omitempty"` - - // Http corresponds to the JSON schema field "http". - Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"` - - // Kvstore corresponds to the JSON schema field "kvstore". - Kvstore *KVStorePermission `json:"kvstore,omitempty" yaml:"kvstore,omitempty" mapstructure:"kvstore,omitempty"` - - // Library corresponds to the JSON schema field "library". - Library *LibraryPermission `json:"library,omitempty" yaml:"library,omitempty" mapstructure:"library,omitempty"` - - // Matcher corresponds to the JSON schema field "matcher". - Matcher *MatcherPermission `json:"matcher,omitempty" yaml:"matcher,omitempty" mapstructure:"matcher,omitempty"` - - // Scheduler corresponds to the JSON schema field "scheduler". - Scheduler *SchedulerPermission `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"` - - // ScrobbleRetriever corresponds to the JSON schema field "scrobbleRetriever". - ScrobbleRetriever *ScrobbleRetrieverPermission `json:"scrobbleRetriever,omitempty" yaml:"scrobbleRetriever,omitempty" mapstructure:"scrobbleRetriever,omitempty"` - - // Storage corresponds to the JSON schema field "storage". - Storage *StoragePermission `json:"storage,omitempty" yaml:"storage,omitempty" mapstructure:"storage,omitempty"` - - // Subsonicapi corresponds to the JSON schema field "subsonicapi". - Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"` - - // Taskqueue corresponds to the JSON schema field "taskqueue". - Taskqueue *TaskQueuePermission `json:"taskqueue,omitempty" yaml:"taskqueue,omitempty" mapstructure:"taskqueue,omitempty"` - - // Users corresponds to the JSON schema field "users". - Users *UsersPermission `json:"users,omitempty" yaml:"users,omitempty" mapstructure:"users,omitempty"` - - // Websocket corresponds to the JSON schema field "websocket". - Websocket *WebSocketPermission `json:"websocket,omitempty" yaml:"websocket,omitempty" mapstructure:"websocket,omitempty"` -} - -// Scheduler service permissions for scheduling tasks -type SchedulerPermission struct { - // Explanation for why scheduler access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - -// Scrobble retriever permissions for retrieving scrobbles from users -type ScrobbleRetrieverPermission struct { - // Explanation for why scrobble retriever access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - -// Storage permissions for enabling persistent read-write storage exclusively for -// the plugin -type StoragePermission struct { - // Explanation for why storage access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - -// SubsonicAPI service permissions. Requires 'users' permission to be declared. -type SubsonicAPIPermission struct { - // Explanation for why SubsonicAPI access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - -// Task queue permissions for background task processing -type TaskQueuePermission struct { - // Maximum total concurrent workers across all queues. Default: 1 - MaxConcurrency int `json:"maxConcurrency,omitempty" yaml:"maxConcurrency,omitempty" mapstructure:"maxConcurrency,omitempty"` - - // Explanation for why task queue access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - -// UnmarshalJSON implements json.Unmarshaler. -func (j *TaskQueuePermission) UnmarshalJSON(value []byte) error { - var raw map[string]interface{} - if err := json.Unmarshal(value, &raw); err != nil { - return err - } - type Plain TaskQueuePermission - var plain Plain - if err := json.Unmarshal(value, &plain); err != nil { - return err - } - if v, ok := raw["maxConcurrency"]; !ok || v == nil { - plain.MaxConcurrency = 1.0 - } - if 1 > plain.MaxConcurrency { - return fmt.Errorf("field %s: must be >= %v", "maxConcurrency", 1) - } - *j = TaskQueuePermission(plain) - return nil -} - -// Users service permissions for accessing user information -type UsersPermission struct { - // Explanation for why users access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` -} - -// WebSocket service permissions for establishing WebSocket connections -type WebSocketPermission struct { - // Explanation for why WebSocket access is needed - Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` - - // List of required host patterns for WebSocket connections (e.g., - // 'api.example.com', '*.musicbrainz.org'). Required: with no entries every - // connection is blocked. A named host alone can't reach a private address; also - // list an IP, a CIDR (e.g., '10.0.0.0/8') or '*' for that - RequiredHosts []string `json:"requiredHosts,omitempty" yaml:"requiredHosts,omitempty" mapstructure:"requiredHosts,omitempty"` -} diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go deleted file mode 100644 index bfd8bcac1..000000000 --- a/plugins/manifest_test.go +++ /dev/null @@ -1,481 +0,0 @@ -package plugins - -import ( - "encoding/json" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Manifest", func() { - Describe("UnmarshalJSON", func() { - It("parses a valid manifest", func() { - data := []byte(`{ - "name": "Test Plugin", - "author": "Test Author", - "version": "1.0.0", - "description": "A test plugin", - "website": "https://example.com", - "permissions": { - "http": { - "reason": "Fetch metadata", - "requiredHosts": ["api.example.com", "*.musicbrainz.org"] - } - } - }`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).ToNot(HaveOccurred()) - Expect(m.Name).To(Equal("Test Plugin")) - Expect(m.Author).To(Equal("Test Author")) - Expect(m.Version).To(Equal("1.0.0")) - Expect(*m.Description).To(Equal("A test plugin")) - Expect(*m.Website).To(Equal("https://example.com")) - Expect(m.Permissions.Http).ToNot(BeNil()) - Expect(*m.Permissions.Http.Reason).To(Equal("Fetch metadata")) - Expect(m.Permissions.Http.RequiredHosts).To(ContainElements("api.example.com", "*.musicbrainz.org")) - }) - - It("parses a minimal manifest", func() { - data := []byte(`{ - "name": "Minimal Plugin", - "author": "Author", - "version": "1.0.0" - }`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).ToNot(HaveOccurred()) - Expect(m.Name).To(Equal("Minimal Plugin")) - Expect(m.Author).To(Equal("Author")) - Expect(m.Version).To(Equal("1.0.0")) - Expect(m.Description).To(BeNil()) - Expect(m.Permissions).To(BeNil()) - }) - - It("returns an error for invalid JSON", func() { - data := []byte(`{invalid json}`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).To(HaveOccurred()) - }) - - It("returns an error when name is missing", func() { - data := []byte(`{"author": "Test Author", "version": "1.0.0"}`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("name")) - }) - - It("returns an error when author is missing", func() { - data := []byte(`{"name": "Test Plugin", "version": "1.0.0"}`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("author")) - }) - - It("returns an error when version is missing", func() { - data := []byte(`{"name": "Test Plugin", "author": "Test Author"}`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("version")) - }) - - It("returns an error when name is empty", func() { - data := []byte(`{"name": "", "author": "Test Author", "version": "1.0.0"}`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("name")) - }) - - It("returns an error when author is empty", func() { - data := []byte(`{"name": "Test Plugin", "author": "", "version": "1.0.0"}`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("author")) - }) - - It("returns an error when version is empty", func() { - data := []byte(`{"name": "Test Plugin", "author": "Test Author", "version": ""}`) - - var m Manifest - err := json.Unmarshal(data, &m) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("version")) - }) - }) - - Describe("ParseManifest", func() { - It("parses a valid manifest with users permission", func() { - data := []byte(`{ - "name": "Test Plugin", - "author": "Test Author", - "version": "1.0.0", - "permissions": { - "subsonicapi": {}, - "users": {} - } - }`) - - m, err := ParseManifest(data) - Expect(err).ToNot(HaveOccurred()) - Expect(m.Name).To(Equal("Test Plugin")) - Expect(m.Permissions.Subsonicapi).ToNot(BeNil()) - Expect(m.Permissions.Users).ToNot(BeNil()) - }) - - It("returns error for invalid JSON", func() { - data := []byte(`{invalid}`) - - _, err := ParseManifest(data) - Expect(err).To(HaveOccurred()) - }) - - It("returns error when subsonicapi is requested without users permission", func() { - data := []byte(`{ - "name": "Test Plugin", - "author": "Test Author", - "version": "1.0.0", - "permissions": { - "subsonicapi": {} - } - }`) - - _, err := ParseManifest(data) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("subsonicapi")) - Expect(err.Error()).To(ContainSubstring("users")) - }) - }) - - Describe("Validate", func() { - It("validates manifest with subsonicapi and users permissions", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - Permissions: &Permissions{ - Subsonicapi: &SubsonicAPIPermission{}, - Users: &UsersPermission{}, - }, - } - - err := m.Validate() - Expect(err).ToNot(HaveOccurred()) - }) - - It("returns error when subsonicapi without users permission", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - Permissions: &Permissions{ - Subsonicapi: &SubsonicAPIPermission{}, - }, - } - - err := m.Validate() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("subsonicapi")) - }) - - It("validates manifest with matcher and library permissions", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - Permissions: &Permissions{ - Matcher: &MatcherPermission{}, - Library: &LibraryPermission{}, - }, - } - - err := m.Validate() - Expect(err).ToNot(HaveOccurred()) - }) - - It("returns error when matcher without library permission", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - Permissions: &Permissions{ - Matcher: &MatcherPermission{}, - }, - } - - err := m.Validate() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("matcher")) - Expect(err.Error()).To(ContainSubstring("library")) - }) - - It("validates manifest with scrobbleRetriever and users permissions", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - Permissions: &Permissions{ - ScrobbleRetriever: &ScrobbleRetrieverPermission{}, - Users: &UsersPermission{}, - }, - } - - err := m.Validate() - Expect(err).ToNot(HaveOccurred()) - }) - - It("returns error when scrobbleRetriever without users permission", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - Permissions: &Permissions{ - ScrobbleRetriever: &ScrobbleRetrieverPermission{}, - }, - } - - err := m.Validate() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("scrobbleRetriever")) - }) - - It("validates manifest without subsonicapi", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - Permissions: &Permissions{ - Http: &HTTPPermission{}, - }, - } - - err := m.Validate() - Expect(err).ToNot(HaveOccurred()) - }) - - It("validates manifest without any permissions", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - } - - err := m.Validate() - Expect(err).ToNot(HaveOccurred()) - }) - - It("validates manifest with valid config schema", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - Config: &ConfigDefinition{ - Schema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "api_key": map[string]any{ - "type": "string", - }, - }, - }, - }, - } - - err := m.Validate() - Expect(err).ToNot(HaveOccurred()) - }) - - It("validates manifest with complex config schema", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - Config: &ConfigDefinition{ - Schema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "users": map[string]any{ - "type": "array", - "items": map[string]any{ - "type": "object", - "properties": map[string]any{ - "username": map[string]any{"type": "string"}, - "token": map[string]any{"type": "string"}, - }, - "required": []any{"username", "token"}, - }, - }, - }, - }, - }, - } - - err := m.Validate() - Expect(err).ToNot(HaveOccurred()) - }) - - It("returns error for invalid config schema - bad type", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - Config: &ConfigDefinition{ - Schema: map[string]any{ - "type": "invalid_type", - }, - }, - } - - err := m.Validate() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("config schema")) - }) - - It("returns error for invalid config schema - bad minLength", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - Config: &ConfigDefinition{ - Schema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "name": map[string]any{ - "type": "string", - "minLength": "not_a_number", - }, - }, - }, - }, - } - - err := m.Validate() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("config schema")) - }) - - It("validates manifest without config", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - } - - err := m.Validate() - Expect(err).ToNot(HaveOccurred()) - }) - }) - - Describe("ValidateWithCapabilities", func() { - It("validates scrobbler capability with users permission", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - Permissions: &Permissions{ - Users: &UsersPermission{}, - }, - } - - err := ValidateWithCapabilities(m, []Capability{CapabilityScrobbler}) - Expect(err).ToNot(HaveOccurred()) - }) - - It("returns error when scrobbler capability without users permission", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - } - - err := ValidateWithCapabilities(m, []Capability{CapabilityScrobbler}) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("scrobbler")) - Expect(err.Error()).To(ContainSubstring("users")) - }) - - It("validates non-scrobbler capability without users permission", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - } - - err := ValidateWithCapabilities(m, []Capability{CapabilityMetadataAgent}) - Expect(err).ToNot(HaveOccurred()) - }) - - It("validates multiple capabilities including scrobbler", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - Permissions: &Permissions{ - Users: &UsersPermission{}, - }, - } - - err := ValidateWithCapabilities(m, []Capability{CapabilityMetadataAgent, CapabilityScrobbler}) - Expect(err).ToNot(HaveOccurred()) - }) - - It("validates with nil capabilities", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - } - - err := ValidateWithCapabilities(m, nil) - Expect(err).ToNot(HaveOccurred()) - }) - - It("validates with empty capabilities", func() { - m := &Manifest{ - Name: "Test", - Author: "Author", - Version: "1.0.0", - } - - err := ValidateWithCapabilities(m, []Capability{}) - Expect(err).ToNot(HaveOccurred()) - }) - }) -}) - -var _ = Describe("Permissions.DeclaredNames", func() { - It("returns nil for a nil receiver", func() { - var p *Permissions - Expect(p.DeclaredNames()).To(BeEmpty()) - }) - - It("returns declared names sorted", func() { - p := &Permissions{ - Subsonicapi: &SubsonicAPIPermission{}, - Users: &UsersPermission{}, - } - Expect(p.DeclaredNames()).To(Equal([]string{"subsonicapi", "users"})) - }) - - It("returns all declared names sorted regardless of field order", func() { - p := &Permissions{ - Http: &HTTPPermission{}, - Artwork: &ArtworkPermission{}, - Cache: &CachePermission{}, - } - Expect(p.DeclaredNames()).To(Equal([]string{"artwork", "cache", "http"})) - }) -}) diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go deleted file mode 100644 index 17062ba6b..000000000 --- a/plugins/metadata_agent.go +++ /dev/null @@ -1,288 +0,0 @@ -package plugins - -import ( - "context" - "errors" - - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/plugins/capabilities" - "github.com/navidrome/navidrome/plugins/types" - "github.com/navidrome/navidrome/utils/slice" -) - -// CapabilityMetadataAgent indicates the plugin can provide artist/album metadata. -// Detected when the plugin exports at least one of the metadata agent functions. -const CapabilityMetadataAgent Capability = "MetadataAgent" - -// Export function names (snake_case as per design) -const ( - FuncGetArtistMBID = "nd_get_artist_mbid" - FuncGetArtistURL = "nd_get_artist_url" - FuncGetArtistBiography = "nd_get_artist_biography" - FuncGetSimilarArtists = "nd_get_similar_artists" - FuncGetArtistImages = "nd_get_artist_images" - FuncGetArtistTopSongs = "nd_get_artist_top_songs" - FuncGetAlbumInfo = "nd_get_album_info" - FuncGetAlbumImages = "nd_get_album_images" - FuncGetSimilarSongsByTrack = "nd_get_similar_songs_by_track" - FuncGetSimilarSongsByAlbum = "nd_get_similar_songs_by_album" - FuncGetSimilarSongsByArtist = "nd_get_similar_songs_by_artist" -) - -func init() { - registerCapability( - CapabilityMetadataAgent, - FuncGetArtistMBID, - FuncGetArtistURL, - FuncGetArtistBiography, - FuncGetSimilarArtists, - FuncGetArtistImages, - FuncGetArtistTopSongs, - FuncGetAlbumInfo, - FuncGetAlbumImages, - FuncGetSimilarSongsByTrack, - FuncGetSimilarSongsByAlbum, - FuncGetSimilarSongsByArtist, - ) -} - -func newMetadataAgent(p *plugin) *MetadataAgent { - return &MetadataAgent{name: p.name, plugin: p} -} - -// agentErr keeps a plugin fault distinguishable from a definitive miss: a method the plugin -// simply does not implement has answered, so it must not count against a caller's back-off. -func agentErr(err error) error { - if errors.Is(err, errNotImplemented) || errors.Is(err, errFunctionNotFound) { - return errors.Join(agents.ErrNotFound, err) - } - if retryLater, ok := parseRetryLater(agentRetryLaterRe, err.Error()); ok { - return errors.Join(retryLater, err) - } - return err -} - -// MetadataAgent is an adapter that wraps an Extism plugin and implements -// the agents interfaces for metadata retrieval. -type MetadataAgent struct { - name string - plugin *plugin -} - -// AgentName returns the plugin name -func (a *MetadataAgent) AgentName() string { - return a.name -} - -// --- Interface implementations --- - -// GetArtistMBID retrieves the MusicBrainz ID for an artist -func (a *MetadataAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { - input := capabilities.ArtistMBIDRequest{ID: id, Name: name} - result, err := callPluginFunction[capabilities.ArtistMBIDRequest, *capabilities.ArtistMBIDResponse](ctx, a.plugin, FuncGetArtistMBID, input) - if err != nil { - return "", agentErr(err) - } - - if result == nil || result.MBID == "" { - return "", agents.ErrNotFound - } - - return result.MBID, nil -} - -// GetArtistURL retrieves the external URL for an artist -func (a *MetadataAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) { - input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid} - result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistURLResponse](ctx, a.plugin, FuncGetArtistURL, input) - if err != nil { - return "", agentErr(err) - } - if result == nil || result.URL == "" { - return "", agents.ErrNotFound - } - return result.URL, nil -} - -// GetArtistBiography retrieves the biography for an artist -func (a *MetadataAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) { - input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid} - result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistBiographyResponse](ctx, a.plugin, FuncGetArtistBiography, input) - if err != nil { - return "", agentErr(err) - } - - if result == nil || result.Biography == "" { - return "", agents.ErrNotFound - } - - return result.Biography, nil -} - -// GetSimilarArtists retrieves similar artists -func (a *MetadataAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) { - input := capabilities.SimilarArtistsRequest{ID: id, Name: name, MBID: mbid, Limit: int32(limit)} - result, err := callPluginFunction[capabilities.SimilarArtistsRequest, *capabilities.SimilarArtistsResponse](ctx, a.plugin, FuncGetSimilarArtists, input) - if err != nil { - return nil, agentErr(err) - } - - if result == nil || len(result.Artists) == 0 { - return nil, agents.ErrNotFound - } - - artists := make([]agents.Artist, len(result.Artists)) - for i, ar := range result.Artists { - artists[i] = agents.Artist{ID: ar.ID, Name: ar.Name, MBID: ar.MBID} - } - - return artists, nil -} - -// GetArtistImages retrieves images for an artist -func (a *MetadataAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) { - input := capabilities.ArtistRequest{ID: id, Name: name, MBID: mbid} - result, err := callPluginFunction[capabilities.ArtistRequest, *capabilities.ArtistImagesResponse](ctx, a.plugin, FuncGetArtistImages, input) - if err != nil { - return nil, agentErr(err) - } - - if result == nil || len(result.Images) == 0 { - return nil, agents.ErrNotFound - } - - images := make([]agents.ExternalImage, len(result.Images)) - for i, img := range result.Images { - images[i] = agents.ExternalImage{URL: img.URL, Size: int(img.Size)} - } - - return images, nil -} - -// GetArtistTopSongs retrieves top songs for an artist -func (a *MetadataAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) { - input := capabilities.TopSongsRequest{ID: id, Name: artistName, MBID: mbid, Count: int32(count)} - result, err := callPluginFunction[capabilities.TopSongsRequest, *capabilities.TopSongsResponse](ctx, a.plugin, FuncGetArtistTopSongs, input) - if err != nil { - return nil, agentErr(err) - } - - if result == nil || len(result.Songs) == 0 { - return nil, agents.ErrNotFound - } - - return songRefsToAgentSongs(result.Songs), nil -} - -// GetAlbumInfo retrieves album information -func (a *MetadataAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) { - input := capabilities.AlbumRequest{Name: name, Artist: artist, MBID: mbid} - result, err := callPluginFunction[capabilities.AlbumRequest, *capabilities.AlbumInfoResponse](ctx, a.plugin, FuncGetAlbumInfo, input) - if err != nil { - return nil, agentErr(err) - } - - if result == nil { - return nil, agents.ErrNotFound - } - - return &agents.AlbumInfo{ - Name: result.Name, - MBID: result.MBID, - Description: result.Description, - URL: result.URL, - }, nil -} - -// GetAlbumImages retrieves images for an album -func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { - input := capabilities.AlbumRequest{Name: name, Artist: artist, MBID: mbid} - result, err := callPluginFunction[capabilities.AlbumRequest, *capabilities.AlbumImagesResponse](ctx, a.plugin, FuncGetAlbumImages, input) - if err != nil { - return nil, agentErr(err) - } - - if result == nil || len(result.Images) == 0 { - return nil, agents.ErrNotFound - } - - images := make([]agents.ExternalImage, len(result.Images)) - for i, img := range result.Images { - images[i] = agents.ExternalImage{URL: img.URL, Size: int(img.Size)} - } - - return images, nil -} - -func callSimilarSongsPluginFunction[T any](ctx context.Context, plugin *plugin, funcName string, input T) ([]agents.Song, error) { - result, err := callPluginFunction[T, *capabilities.SimilarSongsResponse](ctx, plugin, funcName, input) - if err != nil { - return nil, agentErr(err) - } - if result == nil || len(result.Songs) == 0 { - return nil, agents.ErrNotFound - } - return songRefsToAgentSongs(result.Songs), nil -} - -// GetSimilarSongsByTrack retrieves songs similar to a specific track -func (a *MetadataAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) { - return callSimilarSongsPluginFunction[capabilities.SimilarSongsByTrackRequest](ctx, a.plugin, FuncGetSimilarSongsByTrack, capabilities.SimilarSongsByTrackRequest{ID: id, Name: name, Artist: artist, MBID: mbid, Count: int32(count)}) -} - -// GetSimilarSongsByAlbum retrieves songs similar to tracks on an album -func (a *MetadataAgent) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) { - return callSimilarSongsPluginFunction[capabilities.SimilarSongsByAlbumRequest](ctx, a.plugin, FuncGetSimilarSongsByAlbum, capabilities.SimilarSongsByAlbumRequest{ID: id, Name: name, Artist: artist, MBID: mbid, Count: int32(count)}) -} - -// GetSimilarSongsByArtist retrieves songs similar to an artist's catalog -func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]agents.Song, error) { - return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)}) -} - -// songRefToAgentSong converts a single SongRef to agents.Song. SongRef keeps the single -// Artist/ArtistMBID fields as part of the plugin wire contract; when a plugin sends those instead -// of the artists array, they are folded into a one-element Artists list here. -func songRefToAgentSong(s types.SongRef) agents.Song { - var artists []agents.Artist - switch { - case len(s.Artists) > 0: - artists = make([]agents.Artist, len(s.Artists)) - for i, a := range s.Artists { - artists[i] = agents.Artist{ID: a.ID, Name: a.Name, MBID: a.MBID} - } - case s.Artist != "" || s.ArtistMBID != "": - artists = []agents.Artist{{Name: s.Artist, MBID: s.ArtistMBID}} - } - return agents.Song{ - ID: s.ID, - Name: s.Name, - MBID: s.MBID, - ISRC: s.ISRC, - Artists: artists, - Album: s.Album, - AlbumMBID: s.AlbumMBID, - Duration: s.DurationInMs(), - } -} - -// songRefsToAgentSongs converts a slice of SongRef to agents.Song -func songRefsToAgentSongs(refs []types.SongRef) []agents.Song { - return slice.Map(refs, songRefToAgentSong) -} - -// Verify interface implementations at compile time -var ( - _ agents.Interface = (*MetadataAgent)(nil) - _ agents.ArtistMBIDRetriever = (*MetadataAgent)(nil) - _ agents.ArtistURLRetriever = (*MetadataAgent)(nil) - _ agents.ArtistBiographyRetriever = (*MetadataAgent)(nil) - _ agents.ArtistSimilarRetriever = (*MetadataAgent)(nil) - _ agents.ArtistImageRetriever = (*MetadataAgent)(nil) - _ agents.ArtistTopSongsRetriever = (*MetadataAgent)(nil) - _ agents.AlbumInfoRetriever = (*MetadataAgent)(nil) - _ agents.AlbumImageRetriever = (*MetadataAgent)(nil) - _ agents.SimilarSongsByTrackRetriever = (*MetadataAgent)(nil) - _ agents.SimilarSongsByAlbumRetriever = (*MetadataAgent)(nil) - _ agents.SimilarSongsByArtistRetriever = (*MetadataAgent)(nil) -) diff --git a/plugins/metadata_agent_test.go b/plugins/metadata_agent_test.go deleted file mode 100644 index 0a7b6c1e2..000000000 --- a/plugins/metadata_agent_test.go +++ /dev/null @@ -1,371 +0,0 @@ -package plugins - -import ( - "errors" - "fmt" - "time" - - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/plugins/capabilities" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// The partial-metadata-agent fixture registers through the Go PDK, which exports every method -// and answers -2, so only errNotImplemented reaches agentErr through a real plugin. A plugin -// that omits the export entirely yields errFunctionNotFound, covered here directly. -var _ = Describe("agentErr", func() { - DescribeTable("classifies a plugin error as a miss or a fault", - func(err error, wantMiss bool) { - got := agentErr(err) - Expect(errors.Is(got, agents.ErrNotFound)).To(Equal(wantMiss)) - Expect(got).To(MatchError(err), "the underlying reason must survive for diagnostics") - }, - Entry("an unimplemented method is a miss", - fmt.Errorf("%w: %s", errNotImplemented, FuncGetArtistImages), true), - Entry("a missing export is a miss", - fmt.Errorf("%w: %s", errFunctionNotFound, FuncGetArtistImages), true), - Entry("a call failure is a fault", - fmt.Errorf("plugin call failed: %w", errors.New("returned status 429")), false), - Entry("a non-zero exit is a fault", - errors.New("plugin call exited with code 1"), false), - ) - - DescribeTable("agentErr retry-later", - func(msg string, wantDelay time.Duration) { - err := agentErr(errors.New(msg)) - Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue()) - retry, _ := errors.AsType[*agents.RetryLaterError](err) - d := retry.RetryIn - Expect(d).To(Equal(wantDelay)) - }, - Entry("bare token", "agent(retry_later)", time.Duration(0)), - Entry("with seconds", "agent(retry_later:120)", 120*time.Second), - Entry("capped at 1h", "agent(retry_later:999999)", time.Hour), - // Scaling to nanoseconds before capping wraps past 2^64, landing on ~0.29s. - Entry("capped before it can overflow", "agent(retry_later:18446744074)", time.Hour), - ) - - It("leaves other plugin errors untouched", func() { - orig := errors.New("some plugin failure") - Expect(agentErr(orig)).To(Equal(orig)) - }) - - It("does not treat a superstring token as a throttle", func() { - orig := errors.New("useragent(retry_later)") - Expect(agentErr(orig)).To(Equal(orig)) - }) -}) - -var _ = Describe("MetadataAgent", Ordered, func() { - var agent agents.Interface - - BeforeAll(func() { - // Load the agent via shared manager - var ok bool - agent, ok = testManager.LoadMediaAgent("test-metadata-agent") - Expect(ok).To(BeTrue()) - }) - - Describe("AgentName", func() { - It("returns the plugin name", func() { - Expect(agent.AgentName()).To(Equal("test-metadata-agent")) - }) - }) - - Describe("GetArtistMBID", func() { - It("returns the MBID from the plugin", func() { - retriever := agent.(agents.ArtistMBIDRetriever) - mbid, err := retriever.GetArtistMBID(GinkgoT().Context(), "artist-1", "The Beatles") - Expect(err).ToNot(HaveOccurred()) - Expect(mbid).To(Equal("test-mbid-The Beatles")) - }) - }) - - Describe("GetArtistURL", func() { - It("returns the URL from the plugin", func() { - retriever := agent.(agents.ArtistURLRetriever) - url, err := retriever.GetArtistURL(GinkgoT().Context(), "artist-1", "The Beatles", "some-mbid") - Expect(err).ToNot(HaveOccurred()) - Expect(url).To(Equal("https://test.example.com/artist/The Beatles")) - }) - }) - - Describe("GetArtistBiography", func() { - It("returns the biography from the plugin", func() { - retriever := agent.(agents.ArtistBiographyRetriever) - bio, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "The Beatles", "some-mbid") - Expect(err).ToNot(HaveOccurred()) - Expect(bio).To(Equal("Biography for The Beatles")) - }) - }) - - Describe("GetArtistImages", func() { - It("returns images from the plugin", func() { - retriever := agent.(agents.ArtistImageRetriever) - images, err := retriever.GetArtistImages(GinkgoT().Context(), "artist-1", "The Beatles", "some-mbid") - Expect(err).ToNot(HaveOccurred()) - Expect(images).To(HaveLen(2)) - Expect(images[0].URL).To(Equal("https://test.example.com/images/The Beatles/large.jpg")) - Expect(images[0].Size).To(Equal(500)) - Expect(images[1].URL).To(Equal("https://test.example.com/images/The Beatles/small.jpg")) - Expect(images[1].Size).To(Equal(100)) - }) - }) - - Describe("GetSimilarArtists", func() { - It("returns similar artists from the plugin", func() { - retriever := agent.(agents.ArtistSimilarRetriever) - artists, err := retriever.GetSimilarArtists(GinkgoT().Context(), "artist-1", "The Beatles", "some-mbid", 3) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(HaveLen(3)) - Expect(artists[0].Name).To(Equal("The Beatles Similar A")) - Expect(artists[1].Name).To(Equal("The Beatles Similar B")) - Expect(artists[2].Name).To(Equal("The Beatles Similar C")) - }) - }) - - Describe("GetArtistTopSongs", func() { - It("returns top songs from the plugin", func() { - retriever := agent.(agents.ArtistTopSongsRetriever) - songs, err := retriever.GetArtistTopSongs(GinkgoT().Context(), "artist-1", "The Beatles", "some-mbid", 3) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(3)) - Expect(songs[0].Name).To(Equal("The Beatles Song 1")) - Expect(songs[1].Name).To(Equal("The Beatles Song 2")) - Expect(songs[2].Name).To(Equal("The Beatles Song 3")) - }) - }) - - Describe("GetAlbumInfo", func() { - It("returns album info from the plugin", func() { - retriever := agent.(agents.AlbumInfoRetriever) - info, err := retriever.GetAlbumInfo(GinkgoT().Context(), "Abbey Road", "The Beatles", "album-mbid") - Expect(err).ToNot(HaveOccurred()) - Expect(info.Name).To(Equal("Abbey Road")) - Expect(info.MBID).To(Equal("test-album-mbid-Abbey Road")) - Expect(info.Description).To(Equal("Description for Abbey Road by The Beatles")) - Expect(info.URL).To(Equal("https://test.example.com/album/Abbey Road")) - }) - }) - - Describe("GetAlbumImages", func() { - It("returns album images from the plugin", func() { - retriever := agent.(agents.AlbumImageRetriever) - images, err := retriever.GetAlbumImages(GinkgoT().Context(), "Abbey Road", "The Beatles", "album-mbid") - Expect(err).ToNot(HaveOccurred()) - Expect(images).To(HaveLen(1)) - Expect(images[0].URL).To(Equal("https://test.example.com/albums/Abbey Road/cover.jpg")) - Expect(images[0].Size).To(Equal(500)) - }) - }) - - Describe("GetSimilarSongsByTrack", func() { - It("returns similar songs from the plugin", func() { - retriever := agent.(agents.SimilarSongsByTrackRetriever) - songs, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Yesterday", "The Beatles", "some-mbid", 3) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(3)) - Expect(songs[0].Name).To(Equal("Similar to Yesterday #1")) - Expect(songs[0].Artists).To(HaveLen(1)) - Expect(songs[0].Artists[0].Name).To(Equal("The Beatles")) - }) - }) - - Describe("GetSimilarSongsByAlbum", func() { - It("returns similar songs from the plugin", func() { - retriever := agent.(agents.SimilarSongsByAlbumRetriever) - songs, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Abbey Road", "The Beatles", "album-mbid", 3) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(3)) - Expect(songs[0].Album).To(Equal("Abbey Road")) - }) - }) - - Describe("GetSimilarSongsByArtist", func() { - It("returns similar songs from the plugin", func() { - retriever := agent.(agents.SimilarSongsByArtistRetriever) - songs, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "The Beatles", "some-mbid", 3) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).To(HaveLen(3)) - Expect(songs[0].Name).To(ContainSubstring("The Beatles Style Song")) - }) - }) -}) - -var _ = Describe("MetadataAgent error handling", Ordered, func() { - // Tests error paths when plugin is configured to return errors - var ( - errorManager *Manager - errorAgent agents.Interface - ) - - BeforeAll(func() { - // Create manager with error injection config - errorManager, _ = createTestManager(map[string]map[string]string{ - "test-metadata-agent": { - "error": "simulated plugin error", - }, - }) - - // Load the agent - var ok bool - errorAgent, ok = errorManager.LoadMediaAgent("test-metadata-agent") - Expect(ok).To(BeTrue()) - }) - - DescribeTable("surfaces the plugin failure, not a definitive not-found", - func(call func() error) { - err := call() - Expect(err).To(MatchError(ContainSubstring("simulated plugin error"))) - Expect(err).ToNot(MatchError(agents.ErrNotFound)) - }, - Entry("GetArtistMBID", func() error { - _, err := errorAgent.(agents.ArtistMBIDRetriever).GetArtistMBID(GinkgoT().Context(), "artist-1", "Test") - return err - }), - Entry("GetArtistURL", func() error { - _, err := errorAgent.(agents.ArtistURLRetriever).GetArtistURL(GinkgoT().Context(), "artist-1", "Test", "mbid") - return err - }), - Entry("GetArtistBiography", func() error { - _, err := errorAgent.(agents.ArtistBiographyRetriever).GetArtistBiography(GinkgoT().Context(), "artist-1", "Test", "mbid") - return err - }), - Entry("GetSimilarArtists", func() error { - _, err := errorAgent.(agents.ArtistSimilarRetriever).GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test", "mbid", 5) - return err - }), - Entry("GetArtistImages", func() error { - _, err := errorAgent.(agents.ArtistImageRetriever).GetArtistImages(GinkgoT().Context(), "artist-1", "Test", "mbid") - return err - }), - Entry("GetArtistTopSongs", func() error { - _, err := errorAgent.(agents.ArtistTopSongsRetriever).GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test", "mbid", 5) - return err - }), - Entry("GetAlbumInfo", func() error { - _, err := errorAgent.(agents.AlbumInfoRetriever).GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid") - return err - }), - Entry("GetAlbumImages", func() error { - _, err := errorAgent.(agents.AlbumImageRetriever).GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid") - return err - }), - Entry("GetSimilarSongsByTrack", func() error { - _, err := errorAgent.(agents.SimilarSongsByTrackRetriever).GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) - return err - }), - Entry("GetSimilarSongsByAlbum", func() error { - _, err := errorAgent.(agents.SimilarSongsByAlbumRetriever).GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) - return err - }), - Entry("GetSimilarSongsByArtist", func() error { - _, err := errorAgent.(agents.SimilarSongsByArtistRetriever).GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) - return err - }), - ) -}) - -var _ = Describe("MetadataAgent partial implementation", Ordered, func() { - // Tests the "not implemented" code path when a plugin only implements some methods - var ( - partialManager *Manager - partialAgent agents.Interface - ) - - BeforeAll(func() { - // Create manager with the partial metadata agent plugin - partialManager, _ = createTestManagerWithPlugins(nil, "partial-metadata-agent"+PackageExtension) - - // Load the agent - var ok bool - partialAgent, ok = partialManager.LoadMediaAgent("partial-metadata-agent") - Expect(ok).To(BeTrue()) - }) - - It("returns data from implemented method (GetArtistBiography)", func() { - retriever := partialAgent.(agents.ArtistBiographyRetriever) - bio, err := retriever.GetArtistBiography(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") - Expect(err).ToNot(HaveOccurred()) - Expect(bio).To(Equal("Partial agent biography for Test Artist")) - }) - - // An unimplemented optional method is a definitive miss. Reported as a fault it would - // count against the artwork circuit breaker and keep the item in the retry queue. - DescribeTable("reports an unimplemented method as a definitive not-found", - func(call func() error) { - err := call() - Expect(err).To(MatchError(errNotImplemented)) - Expect(err).To(MatchError(agents.ErrNotFound)) - }, - Entry("GetArtistMBID", func() error { - _, err := partialAgent.(agents.ArtistMBIDRetriever).GetArtistMBID(GinkgoT().Context(), "artist-1", "Test Artist") - return err - }), - Entry("GetArtistURL", func() error { - _, err := partialAgent.(agents.ArtistURLRetriever).GetArtistURL(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") - return err - }), - Entry("GetArtistImages", func() error { - _, err := partialAgent.(agents.ArtistImageRetriever).GetArtistImages(GinkgoT().Context(), "artist-1", "Test Artist", "mbid") - return err - }), - Entry("GetSimilarArtists", func() error { - _, err := partialAgent.(agents.ArtistSimilarRetriever).GetSimilarArtists(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5) - return err - }), - Entry("GetArtistTopSongs", func() error { - _, err := partialAgent.(agents.ArtistTopSongsRetriever).GetArtistTopSongs(GinkgoT().Context(), "artist-1", "Test Artist", "mbid", 5) - return err - }), - Entry("GetAlbumInfo", func() error { - _, err := partialAgent.(agents.AlbumInfoRetriever).GetAlbumInfo(GinkgoT().Context(), "Album", "Artist", "mbid") - return err - }), - Entry("GetAlbumImages", func() error { - _, err := partialAgent.(agents.AlbumImageRetriever).GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid") - return err - }), - Entry("GetSimilarSongsByTrack", func() error { - _, err := partialAgent.(agents.SimilarSongsByTrackRetriever).GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5) - return err - }), - Entry("GetSimilarSongsByAlbum", func() error { - _, err := partialAgent.(agents.SimilarSongsByAlbumRetriever).GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5) - return err - }), - Entry("GetSimilarSongsByArtist", func() error { - _, err := partialAgent.(agents.SimilarSongsByArtistRetriever).GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5) - return err - }), - ) -}) - -var _ = Describe("songRefToAgentSong multi-artist", func() { - It("maps ArtistRef to agents.Artist", func() { - ref := capabilities.SongRef{Name: "Collab", Artist: "Drake", Artists: []capabilities.ArtistRef{ - {ID: "id-drake", Name: "Drake", MBID: "m-drake"}, - {Name: "Future", MBID: "m-future"}, - }} - got := songRefToAgentSong(ref) - Expect(got.Artists).To(Equal([]agents.Artist{ - {ID: "id-drake", Name: "Drake", MBID: "m-drake"}, - {Name: "Future", MBID: "m-future"}, - })) - }) - It("folds the single Artist/ArtistMBID into a one-element Artists when no Artists provided", func() { - ref := capabilities.SongRef{Name: "Solo", Artist: "Drake", ArtistMBID: "m-drake"} - got := songRefToAgentSong(ref) - Expect(got.Artists).To(Equal([]agents.Artist{{Name: "Drake", MBID: "m-drake"}})) - }) - It("folds an MBID-only single artist (empty name) so the MBID is not dropped", func() { - ref := capabilities.SongRef{Name: "Solo", ArtistMBID: "m-drake"} - got := songRefToAgentSong(ref) - Expect(got.Artists).To(Equal([]agents.Artist{{MBID: "m-drake"}})) - }) - It("leaves Artists nil when neither Artists nor the single Artist/ArtistMBID are provided", func() { - got := songRefToAgentSong(capabilities.SongRef{Name: "Anon"}) - Expect(got.Artists).To(BeNil()) - }) -}) diff --git a/plugins/migrate.go b/plugins/migrate.go deleted file mode 100644 index 332e34838..000000000 --- a/plugins/migrate.go +++ /dev/null @@ -1,47 +0,0 @@ -package plugins - -import ( - "database/sql" - "fmt" -) - -// migrateDB applies schema migrations to a SQLite database. -// -// Each entry in migrations is a single SQL statement. The current schema version -// is tracked using SQLite's built-in PRAGMA user_version. Only statements after -// the current version are executed, within a single transaction. -func migrateDB(db *sql.DB, migrations []string) error { - var version int - if err := db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil { - return fmt.Errorf("reading schema version: %w", err) - } - - if version >= len(migrations) { - return nil - } - - tx, err := db.Begin() - if err != nil { - return fmt.Errorf("starting migration transaction: %w", err) - } - defer func() { _ = tx.Rollback() }() - - for i := version; i < len(migrations); i++ { - if _, err := tx.Exec(migrations[i]); err != nil { - return fmt.Errorf("migration %d failed: %w", i+1, err) - } - } - - // PRAGMA statements cannot be executed inside a transaction in some SQLite - // drivers, but with mattn/go-sqlite3 this works. We set it inside the tx - // so that a failed commit leaves the version unchanged. - if _, err := tx.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, len(migrations))); err != nil { - return fmt.Errorf("updating schema version: %w", err) - } - - if err := tx.Commit(); err != nil { - return fmt.Errorf("committing migrations: %w", err) - } - - return nil -} diff --git a/plugins/migrate_test.go b/plugins/migrate_test.go deleted file mode 100644 index 568ad34cb..000000000 --- a/plugins/migrate_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package plugins - -import ( - "database/sql" - - _ "github.com/mattn/go-sqlite3" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("migrateDB", func() { - var db *sql.DB - - BeforeEach(func() { - var err error - db, err = sql.Open("sqlite3", ":memory:") - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - if db != nil { - db.Close() - } - }) - - getUserVersion := func() int { - var version int - Expect(db.QueryRow(`PRAGMA user_version`).Scan(&version)).To(Succeed()) - return version - } - - It("applies all migrations on a fresh database", func() { - migrations := []string{ - `CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`, - `ALTER TABLE test ADD COLUMN email TEXT`, - } - - Expect(migrateDB(db, migrations)).To(Succeed()) - Expect(getUserVersion()).To(Equal(2)) - - // Verify schema - _, err := db.Exec(`INSERT INTO test (id, name, email) VALUES (1, 'Alice', 'alice@test.com')`) - Expect(err).ToNot(HaveOccurred()) - }) - - It("skips already applied migrations", func() { - migrations1 := []string{ - `CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`, - } - Expect(migrateDB(db, migrations1)).To(Succeed()) - Expect(getUserVersion()).To(Equal(1)) - - // Add a new migration - migrations2 := []string{ - `CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`, - `ALTER TABLE test ADD COLUMN email TEXT`, - } - Expect(migrateDB(db, migrations2)).To(Succeed()) - Expect(getUserVersion()).To(Equal(2)) - - // Verify the new column exists - _, err := db.Exec(`INSERT INTO test (id, name, email) VALUES (1, 'Alice', 'alice@test.com')`) - Expect(err).ToNot(HaveOccurred()) - }) - - It("is a no-op when all migrations are applied", func() { - migrations := []string{ - `CREATE TABLE test (id INTEGER PRIMARY KEY)`, - } - Expect(migrateDB(db, migrations)).To(Succeed()) - Expect(migrateDB(db, migrations)).To(Succeed()) - Expect(getUserVersion()).To(Equal(1)) - }) - - It("is a no-op with empty migrations slice", func() { - Expect(migrateDB(db, nil)).To(Succeed()) - Expect(getUserVersion()).To(Equal(0)) - }) - - It("rolls back on failure", func() { - migrations := []string{ - `CREATE TABLE test (id INTEGER PRIMARY KEY)`, - `INVALID SQL STATEMENT`, - } - - err := migrateDB(db, migrations) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("migration 2 failed")) - - // Version should remain 0 (rolled back) - Expect(getUserVersion()).To(Equal(0)) - - // Table should not exist (rolled back) - _, err = db.Exec(`INSERT INTO test (id) VALUES (1)`) - Expect(err).To(HaveOccurred()) - }) -}) diff --git a/plugins/package.go b/plugins/package.go deleted file mode 100644 index 77cb6e5ac..000000000 --- a/plugins/package.go +++ /dev/null @@ -1,135 +0,0 @@ -package plugins - -import ( - "archive/zip" - "errors" - "fmt" - "io" - "path/filepath" - "strings" -) - -const ( - // PackageExtension is the file extension for Navidrome plugin packages. - PackageExtension = ".ndp" - - // manifestFileName is the name of the manifest file inside the package. - manifestFileName = "manifest.json" - - // wasmFileName is the name of the WebAssembly module inside the package. - wasmFileName = "plugin.wasm" -) - -// validPluginID reports whether id is usable. It names a directory under -// DataFolder/plugins, so a path-like one would point at another plugin's data. -func validPluginID(id string) bool { - if id == "." || id == ".." || strings.ContainsAny(id, `/\`) || !filepath.IsLocal(id) { - return false - } - // Windows drops trailing dots and spaces, so "foo." and "foo" would end up - // sharing a directory - return strings.TrimRight(id, ". ") == id -} - -// pluginIDFromPath derives the plugin ID from a package path. -func pluginIDFromPath(path string) (string, bool) { - id := strings.TrimSuffix(filepath.Base(path), PackageExtension) - if !validPluginID(id) { - return "", false - } - return id, true -} - -// ndpPackage represents a loaded .ndp plugin package. -// It contains the manifest and wasm bytes read from the archive. -type ndpPackage struct { - Manifest *Manifest - WasmBytes []byte -} - -// openPackage opens an .ndp file and extracts the manifest and wasm bytes. -// The caller does not need to call Close() - all resources are read into memory. -func openPackage(ndpPath string) (*ndpPackage, error) { - // Open the zip archive - zr, err := zip.OpenReader(ndpPath) - if err != nil { - return nil, fmt.Errorf("opening package: %w", err) - } - defer zr.Close() - - var manifestBytes []byte - var wasmBytes []byte - - for _, f := range zr.File { - switch f.Name { - case manifestFileName: - manifestBytes, err = readZipFile(f) - if err != nil { - return nil, fmt.Errorf("reading manifest: %w", err) - } - case wasmFileName: - wasmBytes, err = readZipFile(f) - if err != nil { - return nil, fmt.Errorf("reading wasm: %w", err) - } - } - } - - if manifestBytes == nil { - return nil, errors.New("package missing manifest.json") - } - if wasmBytes == nil { - return nil, errors.New("package missing plugin.wasm") - } - - // Parse and validate manifest - manifest, err := ParseManifest(manifestBytes) - if err != nil { - return nil, fmt.Errorf("parsing manifest: %w", err) - } - - return &ndpPackage{ - Manifest: manifest, - WasmBytes: wasmBytes, - }, nil -} - -// ReadManifest reads and validates the manifest from a .ndp file without loading -// the wasm bytes (it runs ParseManifest, so JSON-schema and cross-field -// validation are applied). Useful for quick plugin discovery and validation. -func ReadManifest(ndpPath string) (*Manifest, error) { - // Open the zip archive - zr, err := zip.OpenReader(ndpPath) - if err != nil { - return nil, fmt.Errorf("opening package: %w", err) - } - defer zr.Close() - - for _, f := range zr.File { - if f.Name == manifestFileName { - manifestBytes, err := readZipFile(f) - if err != nil { - return nil, fmt.Errorf("reading manifest: %w", err) - } - - manifest, err := ParseManifest(manifestBytes) - if err != nil { - return nil, fmt.Errorf("parsing manifest: %w", err) - } - - return manifest, nil - } - } - - return nil, errors.New("package missing manifest.json") -} - -// readZipFile reads the contents of a file from a zip archive. -func readZipFile(f *zip.File) ([]byte, error) { - rc, err := f.Open() - if err != nil { - return nil, err - } - defer rc.Close() - return io.ReadAll(rc) -} diff --git a/plugins/package_test.go b/plugins/package_test.go deleted file mode 100644 index 1539d8df7..000000000 --- a/plugins/package_test.go +++ /dev/null @@ -1,306 +0,0 @@ -package plugins - -import ( - "archive/zip" - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("pluginIDFromPath", func() { - DescribeTable("derives the ID from the package filename", - func(path, expected string) { - id, ok := pluginIDFromPath(path) - Expect(ok).To(BeTrue()) - Expect(id).To(Equal(expected)) - }, - Entry("plain name", "/plugins/discord-rich-presence.ndp", "discord-rich-presence"), - Entry("name with spaces", "/plugins/My Plugin.ndp", "My Plugin"), - Entry("leading dot", "/plugins/.hidden.ndp", ".hidden"), - Entry("dots inside", "/plugins/v1.2.3.ndp", "v1.2.3"), - ) - - // The ID becomes a directory name under DataFolder/plugins, so a path-like - // one would let a plugin reach another plugin's data - DescribeTable("rejects IDs that are unsafe as a directory name", - func(path string) { - _, ok := pluginIDFromPath(path) - Expect(ok).To(BeFalse()) - }, - Entry("empty", "/plugins/.ndp"), - Entry("current directory", "/plugins/..ndp"), - Entry("parent directory", "/plugins/...ndp"), - // Windows drops trailing dots and spaces, so these would share a - // directory with "foo" - Entry("trailing dot", "/plugins/foo..ndp"), - Entry("trailing space", "/plugins/foo .ndp"), - ) -}) - -var _ = Describe("ndpPackage", func() { - var tmpDir string - - BeforeEach(func() { - var err error - tmpDir, err = os.MkdirTemp("", "plugin-package-test-*") - Expect(err).ToNot(HaveOccurred()) - }) - - AfterEach(func() { - os.RemoveAll(tmpDir) - }) - - Describe("openPackage", func() { - It("should load a valid .ndp package", func() { - ndpPath := filepath.Join(tmpDir, "test.ndp") - manifest := &Manifest{ - Name: "Test Plugin", - Author: "Test Author", - Version: "1.0.0", - } - wasmBytes := []byte{0x00, 0x61, 0x73, 0x6d} // Minimal wasm header - - err := createTestPackage(ndpPath, manifest, wasmBytes) - Expect(err).ToNot(HaveOccurred()) - - pkg, err := openPackage(ndpPath) - Expect(err).ToNot(HaveOccurred()) - Expect(pkg.Manifest.Name).To(Equal("Test Plugin")) - Expect(pkg.Manifest.Author).To(Equal("Test Author")) - Expect(pkg.Manifest.Version).To(Equal("1.0.0")) - Expect(pkg.WasmBytes).To(Equal(wasmBytes)) - }) - - It("should return error for missing manifest.json", func() { - ndpPath := filepath.Join(tmpDir, "no-manifest.ndp") - - // Create a zip with only plugin.wasm - f, err := os.Create(ndpPath) - Expect(err).ToNot(HaveOccurred()) - defer f.Close() - - zw := newTestZipWriter(f) - err = zw.addFile("plugin.wasm", []byte{0x00}) - Expect(err).ToNot(HaveOccurred()) - err = zw.close() - Expect(err).ToNot(HaveOccurred()) - - _, err = openPackage(ndpPath) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("missing manifest.json")) - }) - - It("should return error for missing plugin.wasm", func() { - ndpPath := filepath.Join(tmpDir, "no-wasm.ndp") - - // Create a zip with only manifest.json - f, err := os.Create(ndpPath) - Expect(err).ToNot(HaveOccurred()) - defer f.Close() - - zw := newTestZipWriter(f) - err = zw.addFile("manifest.json", []byte(`{"name":"Test","author":"Test","version":"1.0.0"}`)) - Expect(err).ToNot(HaveOccurred()) - err = zw.close() - Expect(err).ToNot(HaveOccurred()) - - _, err = openPackage(ndpPath) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("missing plugin.wasm")) - }) - - It("should return error for invalid manifest JSON", func() { - ndpPath := filepath.Join(tmpDir, "invalid-json.ndp") - - f, err := os.Create(ndpPath) - Expect(err).ToNot(HaveOccurred()) - defer f.Close() - - zw := newTestZipWriter(f) - err = zw.addFile("manifest.json", []byte(`{invalid json}`)) - Expect(err).ToNot(HaveOccurred()) - err = zw.addFile("plugin.wasm", []byte{0x00}) - Expect(err).ToNot(HaveOccurred()) - err = zw.close() - Expect(err).ToNot(HaveOccurred()) - - _, err = openPackage(ndpPath) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("parsing manifest")) - }) - - It("should return error for manifest missing required fields", func() { - ndpPath := filepath.Join(tmpDir, "invalid-manifest.ndp") - - f, err := os.Create(ndpPath) - Expect(err).ToNot(HaveOccurred()) - defer f.Close() - - zw := newTestZipWriter(f) - err = zw.addFile("manifest.json", []byte(`{"name":"Test"}`)) // Missing author and version - Expect(err).ToNot(HaveOccurred()) - err = zw.addFile("plugin.wasm", []byte{0x00}) - Expect(err).ToNot(HaveOccurred()) - err = zw.close() - Expect(err).ToNot(HaveOccurred()) - - _, err = openPackage(ndpPath) - Expect(err).To(HaveOccurred()) - // JSON schema validation happens during unmarshaling - Expect(err.Error()).To(ContainSubstring("parsing manifest")) - Expect(err.Error()).To(ContainSubstring("author")) - }) - - It("should return error for non-existent file", func() { - _, err := openPackage(filepath.Join(tmpDir, "nonexistent.ndp")) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("opening package")) - }) - }) - - Describe("ReadManifest", func() { - It("parses the manifest from a package that also contains wasm", func() { - ndpPath := filepath.Join(tmpDir, "test.ndp") - manifest := &Manifest{ - Name: "Test Plugin", - Author: "Test Author", - Version: "1.0.0", - Description: new("A test plugin"), - } - - err := createTestPackage(ndpPath, manifest, nil) - Expect(err).ToNot(HaveOccurred()) - - m, err := ReadManifest(ndpPath) - Expect(err).ToNot(HaveOccurred()) - Expect(m.Name).To(Equal("Test Plugin")) - Expect(*m.Description).To(Equal("A test plugin")) - }) - - It("returns an error for a non-existent file", func() { - _, err := ReadManifest(filepath.Join(tmpDir, "does-not-exist.ndp")) - Expect(err).To(HaveOccurred()) - }) - - It("returns a specific error for a package missing manifest.json", func() { - ndpPath := filepath.Join(tmpDir, "no-manifest.ndp") - f, err := os.Create(ndpPath) - Expect(err).ToNot(HaveOccurred()) - defer f.Close() - zw := newTestZipWriter(f) - Expect(zw.addFile("plugin.wasm", []byte{0x00})).To(Succeed()) - Expect(zw.close()).To(Succeed()) - - _, err = ReadManifest(ndpPath) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("missing manifest.json")) - }) - - It("fails for a package with a schema-invalid manifest", func() { - ndp := filepath.Join(tmpDir, "bad.ndp") - // empty required fields violate the manifest JSON schema - err := createTestPackage(ndp, &Manifest{}, nil) - Expect(err).ToNot(HaveOccurred()) - _, err = ReadManifest(ndp) - Expect(err).To(HaveOccurred()) - }) - - It("enforces cross-field validation", func() { - ndp := filepath.Join(tmpDir, "crossfield.ndp") - // subsonicapi permission without users: violates cross-field rule - manifest := &Manifest{ - Name: "X", - Author: "me", - Version: "1.0.0", - Permissions: &Permissions{Subsonicapi: &SubsonicAPIPermission{}}, - } - err := createTestPackage(ndp, manifest, nil) - Expect(err).ToNot(HaveOccurred()) - - _, err = ReadManifest(ndp) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("subsonicapi")) - Expect(err.Error()).To(ContainSubstring("users")) - }) - }) -}) - -// testZipHelper is a helper for creating test zip files with specific contents -type testZipHelper struct { - f *os.File - entries []zipEntry -} - -type zipEntry struct { - name string - data []byte -} - -func newTestZipWriter(f *os.File) *testZipHelper { - return &testZipHelper{f: f} -} - -func (h *testZipHelper) addFile(name string, data []byte) error { - h.entries = append(h.entries, zipEntry{name: name, data: data}) - return nil -} - -func (h *testZipHelper) close() error { - zw := zip.NewWriter(h.f) - for _, e := range h.entries { - w, err := zw.Create(e.name) - if err != nil { - return err - } - if _, err := w.Write(e.data); err != nil { - return err - } - } - return zw.Close() -} - -// createTestPackage creates an .ndp package file from a manifest and wasm bytes. -// This is primarily used for testing. -func createTestPackage(ndpPath string, manifest *Manifest, wasmBytes []byte) error { - manifestBytes, err := json.Marshal(manifest) - if err != nil { - return fmt.Errorf("marshaling manifest: %w", err) - } - return writeNdp(ndpPath, bytes.NewReader(manifestBytes), bytes.NewReader(wasmBytes)) -} - -func writeNdp(ndpPath string, manifest, wasm io.Reader) error { - f, err := os.Create(ndpPath) - if err != nil { - return fmt.Errorf("creating package file: %w", err) - } - defer f.Close() - - zw := zip.NewWriter(f) - add := func(name string, r io.Reader) error { - w, err := zw.Create(name) - if err != nil { - return fmt.Errorf("creating %s in package: %w", name, err) - } - if _, err := io.Copy(w, r); err != nil { - return fmt.Errorf("writing %s: %w", name, err) - } - return nil - } - // Entry order is fixed: the loader hashes the package bytes, so they must - // be reproducible across rebuilds. - if err := add(manifestFileName, manifest); err != nil { - return err - } - if err := add(wasmFileName, wasm); err != nil { - return err - } - return errors.Join(zw.Close(), f.Close()) -} diff --git a/plugins/pdk/go/README.md b/plugins/pdk/go/README.md deleted file mode 100644 index 70c680b1f..000000000 --- a/plugins/pdk/go/README.md +++ /dev/null @@ -1,379 +0,0 @@ -# Navidrome Plugin Development Kit for Go - -This directory contains the auto-generated Go PDK (Plugin Development Kit) for building Navidrome plugins. -The PDK provides both **host function wrappers** for interacting with Navidrome and -**capability interfaces** for implementing plugin functionality. - -## ⚠️ Auto-Generated Code - -**Do not edit files in this directory manually.** They are generated by the `ndpgen` tool. - -To regenerate: - -```bash -make gen -``` - -## Module Structure - -This is a consolidated Go module that includes: - -- `host/` - Host function wrappers for calling Navidrome services from plugins -- `lifecycle/` - Plugin lifecycle hooks (initialization) -- `metadata/` - Metadata agent capability for artist/album info -- `scheduler/` - Scheduler callback capability for scheduled tasks -- `scrobbler/` - Scrobbler capability for play tracking -- `websocket/` - WebSocket callback capability for real-time messages - -## Usage - -Add this module as a dependency in your plugin's `go.mod`: - -```go -require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 - -replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go -``` - -Then import the packages you need: - -```go -package main - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/lifecycle" - "github.com/navidrome/navidrome/plugins/pdk/go/scheduler" -) - -func init() { - lifecycle.Register(&myPlugin{}) - scheduler.Register(&myPlugin{}) -} - -type myPlugin struct{} - -func (p *myPlugin) OnInit() error { - // Initialize your plugin - return nil -} - -func (p *myPlugin) OnCallback(req scheduler.SchedulerCallbackRequest) error { - // Handle scheduled task - return host.WebSocketBroadcast("task-complete", req.ScheduleID) -} - -func main() {} -``` - -## Host Services - -The `host` package provides wrappers for calling Navidrome's host services: - -| Service | Description | -|---------------|----------------------------------------------------| -| `Artwork` | Access album and artist artwork | -| `Cache` | Temporary key-value storage with TTL | -| `KVStore` | Persistent key-value storage | -| `Library` | Access the music library (albums, artists, tracks) | -| `Scheduler` | Schedule one-time and recurring tasks | -| `SubsonicAPI` | Make Subsonic API calls | -| `WebSocket` | Send real-time messages to clients | - -### Example: Using Host Services - -```go -package main - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/host" -) - -func myPluginFunction() error { - // Use the cache service - _, err := host.CacheSetString("my_key", "my_value", 3600) - if err != nil { - return err - } - - // Schedule a recurring task - _, err = host.SchedulerScheduleRecurring("@every 5m", "payload", "task_id") - if err != nil { - return err - } - - // Access library data with typed structs - resp, err := host.LibraryGetAllLibraries() - if err != nil { - return err - } - for _, lib := range resp.Result { - // Library: %s with %d songs", lib.Name, lib.TotalSongs - } - - return nil -} -``` - -## Capabilities - -Capabilities define what functionality your plugin implements. Register your implementations -in the `init()` function. - -### Lifecycle - -Provides plugin initialization hooks. - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/lifecycle" - -func init() { - lifecycle.Register(&myPlugin{}) -} - -type myPlugin struct{} - -func (p *myPlugin) OnInit() error { - // Called once when plugin is loaded - return nil -} -``` - -### MetadataAgent - -Provides artist and album metadata from external sources. - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/metadata" - -func init() { - metadata.Register(&myAgent{}) -} - -type myAgent struct{} - -func (a *myAgent) GetArtistBiography(req metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) { - return &metadata.ArtistBiographyResponse{ - Biography: "Artist biography text...", - }, nil -} - -func (a *myAgent) GetArtistImages(req metadata.ArtistRequest) (*metadata.ArtistImagesResponse, error) { - return &metadata.ArtistImagesResponse{ - Images: []metadata.ImageInfo{ - {URL: "https://example.com/image.jpg", Size: 1000}, - }, - }, nil -} -``` - -### Scheduler - -Handles callbacks from scheduled tasks. - -```go -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/scheduler" -) - -func init() { - scheduler.Register(&myScheduler{}) -} - -type myScheduler struct{} - -func (s *myScheduler) OnCallback(req scheduler.SchedulerCallbackRequest) error { - // Handle the scheduled task - if req.Payload == "update-data" { - // Do work... - return host.WebSocketBroadcast("data-updated", "") - } - return nil -} -``` - -### Scrobbler - -Tracks play activity. - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" - -func init() { - scrobbler.Register(&myScrobbler{}) -} - -type myScrobbler struct{} - -func (s *myScrobbler) Scrobble(req scrobbler.ScrobbleRequest) error { - // Track the play - return nil -} - -func (s *myScrobbler) NowPlaying(req scrobbler.NowPlayingRequest) error { - // Update now playing status - return nil -} -``` - -### WebSocket - -Handles incoming WebSocket messages. - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/websocket" - -func init() { - websocket.Register(&myHandler{}) -} - -type myHandler struct{} - -func (h *myHandler) OnWebSocketMessage(req websocket.WebSocketMessageRequest) error { - // Handle incoming message - return nil -} -``` - -## Building Plugins - -Go plugins must be compiled to WebAssembly using TinyGo: - -```bash -tinygo build -o plugin.wasm -target=wasip1 -buildmode=c-shared . -``` - -Or use the provided Makefile targets in plugin examples: - -```bash -make plugin.wasm -``` - -## Testing Plugins - -The PDK includes [testify/mock](https://github.com/stretchr/testify) implementations for all host services, -allowing you to unit test your plugin code on non-WASM platforms (your development machine). - -### PDK Abstraction Layer - -The `pdk` subpackage provides a testable wrapper around the Extism PDK functions. Instead of importing -`github.com/extism/go-pdk` directly, import the abstraction layer: - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/pdk" - -func myFunction() { - // Use pdk functions - same API as extism/go-pdk - config, ok := pdk.GetConfig("my_setting") - if ok { - pdk.Log(pdk.LogInfo, "Setting: " + config) - } - - var input MyInput - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return - } - - output := processInput(input) - pdk.OutputJSON(output) -} -``` - -For WASM builds, these functions delegate directly to `extism/go-pdk` with zero overhead. -For native builds (tests), they use mocks that you can configure: - -```go -package myplugin - -import ( - "testing" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -func TestMyFunction(t *testing.T) { - // Reset mock state before each test - pdk.ResetMock() - - // Set up expectations - pdk.PDKMock.On("GetConfig", "my_setting").Return("test_value", true) - pdk.PDKMock.On("Log", pdk.LogInfo, "Setting: test_value").Return() - pdk.PDKMock.On("InputJSON", mock.Anything).Return(nil).Run(func(args mock.Arguments) { - // Populate the input struct - input := args.Get(0).(*MyInput) - input.Name = "test" - }) - pdk.PDKMock.On("OutputJSON", mock.Anything).Return(nil) - - // Call your function - myFunction() - - // Verify expectations - pdk.PDKMock.AssertExpectations(t) -} -``` - -### Mock Instances - -Each host service has an auto-instantiated mock instance: - -| Service | Mock Instance | -|---------------|--------------------------| -| `Artwork` | `host.ArtworkMock` | -| `Cache` | `host.CacheMock` | -| `Config` | `host.ConfigMock` | -| `KVStore` | `host.KVStoreMock` | -| `Library` | `host.LibraryMock` | -| `Scheduler` | `host.SchedulerMock` | -| `SubsonicAPI` | `host.SubsonicAPIMock` | -| `WebSocket` | `host.WebSocketMock` | - -### Example Test - -```go -package myplugin - -import ( - "testing" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" -) - -func TestMyPluginFunction(t *testing.T) { - // Set expectations on the mock - host.CacheMock.On("GetString", "my-key").Return("cached-value", true, nil) - host.CacheMock.On("SetString", "new-key", "new-value", int64(3600)).Return(nil) - - // Call your plugin code that uses host.CacheGetString / host.CacheSetString - result, err := myPluginFunction() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Assert the result - if result != "expected" { - t.Errorf("unexpected result: %s", result) - } - - // Verify all expected calls were made - host.CacheMock.AssertExpectations(t) -} -``` - -### Running Tests - -Since tests run on your development machine (not WASM), use standard Go testing: - -```bash -go test ./... -``` - -The stub files with mocks are only compiled for non-WASM builds (`//go:build !wasip1`), -so they won't affect your production WASM binary. - -### Complete Examples - -For more comprehensive examples including HTTP requests, Memory handling, and various testing patterns, -see [pdk/example_test.go](pdk/example_test.go). diff --git a/plugins/pdk/go/go.mod b/plugins/pdk/go/go.mod deleted file mode 100644 index 4d5fcddfc..000000000 --- a/plugins/pdk/go/go.mod +++ /dev/null @@ -1,15 +0,0 @@ -module github.com/navidrome/navidrome/plugins/pdk/go - -go 1.25 - -require ( - github.com/extism/go-pdk v1.1.3 - github.com/stretchr/testify v1.11.1 -) - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/objx v0.5.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/plugins/pdk/go/go.sum b/plugins/pdk/go/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/pdk/go/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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/pdk/go/host/doc.go b/plugins/pdk/go/host/doc.go deleted file mode 100644 index 9063f2bd0..000000000 --- a/plugins/pdk/go/host/doc.go +++ /dev/null @@ -1,61 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -/* -Package host provides Navidrome Plugin Development Kit wrappers for Go/TinyGo plugins. - -This package is auto-generated by the ndpgen tool and should not be edited manually. - -# Usage - -Add this module as a dependency in your plugin's go.mod: - - require github.com/navidrome/navidrome/plugins/pdk/go/host v0.0.0 - -Then import the package in your plugin code: - - import host "github.com/navidrome/navidrome/plugins/pdk/go/host" - - func myPluginFunction() error { - // Use the cache service - _, err := host.CacheSetString("my_key", "my_value", 3600) - if err != nil { - return err - } - - // Schedule a recurring task - _, err = host.SchedulerScheduleRecurring("@every 5m", "payload", "task_id") - if err != nil { - return err - } - - return nil - } - -# Available Services - -The following host services are available: - - - Artwork: provides artwork public URL generation capabilities for plugins. - - Cache: provides in-memory TTL-based caching capabilities for plugins. - - Config: provides access to plugin configuration values. - - HTTP: provides outbound HTTP request capabilities for plugins. - - KVStore: provides persistent key-value storage for plugins. - - Library: provides access to music library metadata for plugins. - - Matcher: resolves externally-obtained songs to local library tracks, - - Scheduler: provides task scheduling capabilities for plugins. - - ScrobbleRetriever: allows a plugin to retrieve scrobbles for one or more authorized users. - - Storage: provides access to a plugin-specific directory with read/write permissions - - SubsonicAPI: provides access to Navidrome's Subsonic API from plugins. - - Task: provides persistent task queues for plugins. - - Users: provides access to user information for plugins. - - WebSocket: provides WebSocket communication capabilities for plugins. - -# Building Plugins - -Go plugins must be compiled to WebAssembly using TinyGo: - - tinygo build -o plugin.wasm -target=wasip1 -buildmode=c-shared . - -See the examples directory for complete plugin implementations. -*/ -package host diff --git a/plugins/pdk/go/host/mock_nil_returns_test.go b/plugins/pdk/go/host/mock_nil_returns_test.go deleted file mode 100644 index ea5f334f1..000000000 --- a/plugins/pdk/go/host/mock_nil_returns_test.go +++ /dev/null @@ -1,27 +0,0 @@ -//go:build !wasip1 - -package host - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -// Return(nil, ...) models "no result"; the generated accessors must yield the -// zero value instead of panicking on the type assertion. -func TestMockNilPointerReturn(t *testing.T) { - HTTPMock.On("Send", HTTPRequest{URL: "http://nil.example"}).Return(nil, nil) - - resp, err := HTTPSend(HTTPRequest{URL: "http://nil.example"}) - require.NoError(t, err) - require.Nil(t, resp) -} - -func TestMockNilSliceReturn(t *testing.T) { - LibraryMock.On("GetAllLibraries").Return(nil, nil) - - libs, err := LibraryGetAllLibraries() - require.NoError(t, err) - require.Nil(t, libs) -} diff --git a/plugins/pdk/go/host/nd_host_artwork.go b/plugins/pdk/go/host/nd_host_artwork.go deleted file mode 100644 index 05fcdebe2..000000000 --- a/plugins/pdk/go/host/nd_host_artwork.go +++ /dev/null @@ -1,243 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Artwork host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package host - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// artwork_getartisturl is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user artwork_getartisturl -func artwork_getartisturl(uint64) uint64 - -// artwork_getalbumurl is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user artwork_getalbumurl -func artwork_getalbumurl(uint64) uint64 - -// artwork_gettrackurl is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user artwork_gettrackurl -func artwork_gettrackurl(uint64) uint64 - -// artwork_getplaylisturl is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user artwork_getplaylisturl -func artwork_getplaylisturl(uint64) uint64 - -type artworkGetArtistUrlRequest struct { - Id string `json:"id"` - Size int32 `json:"size"` -} - -type artworkGetArtistUrlResponse struct { - Url string `json:"url,omitempty"` - Error string `json:"error,omitempty"` -} - -type artworkGetAlbumUrlRequest struct { - Id string `json:"id"` - Size int32 `json:"size"` -} - -type artworkGetAlbumUrlResponse struct { - Url string `json:"url,omitempty"` - Error string `json:"error,omitempty"` -} - -type artworkGetTrackUrlRequest struct { - Id string `json:"id"` - Size int32 `json:"size"` -} - -type artworkGetTrackUrlResponse struct { - Url string `json:"url,omitempty"` - Error string `json:"error,omitempty"` -} - -type artworkGetPlaylistUrlRequest struct { - Id string `json:"id"` - Size int32 `json:"size"` -} - -type artworkGetPlaylistUrlResponse struct { - Url string `json:"url,omitempty"` - Error string `json:"error,omitempty"` -} - -// ArtworkGetArtistUrl calls the artwork_getartisturl host function. -// GetArtistUrl generates a public URL for an artist's artwork. -// -// Parameters: -// - id: The artist's unique identifier -// - size: Desired image size in pixels (0 for original size) -// -// Returns the public URL for the artwork, or an error if generation fails. -func ArtworkGetArtistUrl(id string, size int32) (string, error) { - // Marshal request to JSON - req := artworkGetArtistUrlRequest{ - Id: id, - Size: size, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := artwork_getartisturl(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response artworkGetArtistUrlResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "", err - } - - // Convert Error field to Go error - if response.Error != "" { - return "", errors.New(response.Error) - } - - return response.Url, nil -} - -// ArtworkGetAlbumUrl calls the artwork_getalbumurl host function. -// GetAlbumUrl generates a public URL for an album's artwork. -// -// Parameters: -// - id: The album's unique identifier -// - size: Desired image size in pixels (0 for original size) -// -// Returns the public URL for the artwork, or an error if generation fails. -func ArtworkGetAlbumUrl(id string, size int32) (string, error) { - // Marshal request to JSON - req := artworkGetAlbumUrlRequest{ - Id: id, - Size: size, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := artwork_getalbumurl(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response artworkGetAlbumUrlResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "", err - } - - // Convert Error field to Go error - if response.Error != "" { - return "", errors.New(response.Error) - } - - return response.Url, nil -} - -// ArtworkGetTrackUrl calls the artwork_gettrackurl host function. -// GetTrackUrl generates a public URL for a track's artwork. -// -// Parameters: -// - id: The track's (media file) unique identifier -// - size: Desired image size in pixels (0 for original size) -// -// Returns the public URL for the artwork, or an error if generation fails. -func ArtworkGetTrackUrl(id string, size int32) (string, error) { - // Marshal request to JSON - req := artworkGetTrackUrlRequest{ - Id: id, - Size: size, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := artwork_gettrackurl(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response artworkGetTrackUrlResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "", err - } - - // Convert Error field to Go error - if response.Error != "" { - return "", errors.New(response.Error) - } - - return response.Url, nil -} - -// ArtworkGetPlaylistUrl calls the artwork_getplaylisturl host function. -// GetPlaylistUrl generates a public URL for a playlist's artwork. -// -// Parameters: -// - id: The playlist's unique identifier -// - size: Desired image size in pixels (0 for original size) -// -// Returns the public URL for the artwork, or an error if generation fails. -func ArtworkGetPlaylistUrl(id string, size int32) (string, error) { - // Marshal request to JSON - req := artworkGetPlaylistUrlRequest{ - Id: id, - Size: size, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := artwork_getplaylisturl(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response artworkGetPlaylistUrlResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "", err - } - - // Convert Error field to Go error - if response.Error != "" { - return "", errors.New(response.Error) - } - - return response.Url, nil -} diff --git a/plugins/pdk/go/host/nd_host_artwork_stub.go b/plugins/pdk/go/host/nd_host_artwork_stub.go deleted file mode 100644 index 3b81e0d6b..000000000 --- a/plugins/pdk/go/host/nd_host_artwork_stub.go +++ /dev/null @@ -1,94 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported mock instances to set expectations in tests. -// -//go:build !wasip1 - -package host - -import ( - "github.com/stretchr/testify/mock" -) - -// mockArtworkService is the mock implementation for testing. -type mockArtworkService struct { - mock.Mock -} - -// ArtworkMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.ArtworkMock.On("MethodName", args...).Return(values...) -var ArtworkMock = &mockArtworkService{} - -// GetArtistUrl is the mock method for ArtworkGetArtistUrl. -func (m *mockArtworkService) GetArtistUrl(id string, size int32) (string, error) { - args := m.Called(id, size) - return args.String(0), args.Error(1) -} - -// ArtworkGetArtistUrl delegates to the mock instance. -// GetArtistUrl generates a public URL for an artist's artwork. -// -// Parameters: -// - id: The artist's unique identifier -// - size: Desired image size in pixels (0 for original size) -// -// Returns the public URL for the artwork, or an error if generation fails. -func ArtworkGetArtistUrl(id string, size int32) (string, error) { - return ArtworkMock.GetArtistUrl(id, size) -} - -// GetAlbumUrl is the mock method for ArtworkGetAlbumUrl. -func (m *mockArtworkService) GetAlbumUrl(id string, size int32) (string, error) { - args := m.Called(id, size) - return args.String(0), args.Error(1) -} - -// ArtworkGetAlbumUrl delegates to the mock instance. -// GetAlbumUrl generates a public URL for an album's artwork. -// -// Parameters: -// - id: The album's unique identifier -// - size: Desired image size in pixels (0 for original size) -// -// Returns the public URL for the artwork, or an error if generation fails. -func ArtworkGetAlbumUrl(id string, size int32) (string, error) { - return ArtworkMock.GetAlbumUrl(id, size) -} - -// GetTrackUrl is the mock method for ArtworkGetTrackUrl. -func (m *mockArtworkService) GetTrackUrl(id string, size int32) (string, error) { - args := m.Called(id, size) - return args.String(0), args.Error(1) -} - -// ArtworkGetTrackUrl delegates to the mock instance. -// GetTrackUrl generates a public URL for a track's artwork. -// -// Parameters: -// - id: The track's (media file) unique identifier -// - size: Desired image size in pixels (0 for original size) -// -// Returns the public URL for the artwork, or an error if generation fails. -func ArtworkGetTrackUrl(id string, size int32) (string, error) { - return ArtworkMock.GetTrackUrl(id, size) -} - -// GetPlaylistUrl is the mock method for ArtworkGetPlaylistUrl. -func (m *mockArtworkService) GetPlaylistUrl(id string, size int32) (string, error) { - args := m.Called(id, size) - return args.String(0), args.Error(1) -} - -// ArtworkGetPlaylistUrl delegates to the mock instance. -// GetPlaylistUrl generates a public URL for a playlist's artwork. -// -// Parameters: -// - id: The playlist's unique identifier -// - size: Desired image size in pixels (0 for original size) -// -// Returns the public URL for the artwork, or an error if generation fails. -func ArtworkGetPlaylistUrl(id string, size int32) (string, error) { - return ArtworkMock.GetPlaylistUrl(id, size) -} diff --git a/plugins/pdk/go/host/nd_host_cache.go b/plugins/pdk/go/host/nd_host_cache.go deleted file mode 100644 index 7fd9d10fa..000000000 --- a/plugins/pdk/go/host/nd_host_cache.go +++ /dev/null @@ -1,557 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Cache host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package host - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// cache_setstring is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user cache_setstring -func cache_setstring(uint64) uint64 - -// cache_getstring is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user cache_getstring -func cache_getstring(uint64) uint64 - -// cache_setint is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user cache_setint -func cache_setint(uint64) uint64 - -// cache_getint is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user cache_getint -func cache_getint(uint64) uint64 - -// cache_setfloat is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user cache_setfloat -func cache_setfloat(uint64) uint64 - -// cache_getfloat is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user cache_getfloat -func cache_getfloat(uint64) uint64 - -// cache_setbytes is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user cache_setbytes -func cache_setbytes(uint64) uint64 - -// cache_getbytes is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user cache_getbytes -func cache_getbytes(uint64) uint64 - -// cache_has is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user cache_has -func cache_has(uint64) uint64 - -// cache_remove is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user cache_remove -func cache_remove(uint64) uint64 - -type cacheSetStringRequest struct { - Key string `json:"key"` - Value string `json:"value"` - TtlSeconds int64 `json:"ttlSeconds"` -} - -type cacheGetStringRequest struct { - Key string `json:"key"` -} - -type cacheGetStringResponse struct { - Value string `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -type cacheSetIntRequest struct { - Key string `json:"key"` - Value int64 `json:"value"` - TtlSeconds int64 `json:"ttlSeconds"` -} - -type cacheGetIntRequest struct { - Key string `json:"key"` -} - -type cacheGetIntResponse struct { - Value int64 `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -type cacheSetFloatRequest struct { - Key string `json:"key"` - Value float64 `json:"value"` - TtlSeconds int64 `json:"ttlSeconds"` -} - -type cacheGetFloatRequest struct { - Key string `json:"key"` -} - -type cacheGetFloatResponse struct { - Value float64 `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -type cacheSetBytesRequest struct { - Key string `json:"key"` - Value []byte `json:"value"` - TtlSeconds int64 `json:"ttlSeconds"` -} - -type cacheGetBytesRequest struct { - Key string `json:"key"` -} - -type cacheGetBytesResponse struct { - Value []byte `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -type cacheHasRequest struct { - Key string `json:"key"` -} - -type cacheHasResponse struct { - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -type cacheRemoveRequest struct { - Key string `json:"key"` -} - -// CacheSetString calls the cache_setstring host function. -// SetString stores a string value in the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// - value: The string value to store -// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) -// -// Returns an error if the operation fails. -func CacheSetString(key string, value string, ttlSeconds int64) error { - // Marshal request to JSON - req := cacheSetStringRequest{ - Key: key, - Value: value, - TtlSeconds: ttlSeconds, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := cache_setstring(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} - -// CacheGetString calls the cache_getstring host function. -// GetString retrieves a string value from the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// -// Returns the value and whether the key exists. If the key doesn't exist -// or the stored value is not a string, exists will be false. -func CacheGetString(key string) (string, bool, error) { - // Marshal request to JSON - req := cacheGetStringRequest{ - Key: key, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", false, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := cache_getstring(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response cacheGetStringResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "", false, err - } - - // Convert Error field to Go error - if response.Error != "" { - return "", false, errors.New(response.Error) - } - - return response.Value, response.Exists, nil -} - -// CacheSetInt calls the cache_setint host function. -// SetInt stores an integer value in the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// - value: The integer value to store -// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) -// -// Returns an error if the operation fails. -func CacheSetInt(key string, value int64, ttlSeconds int64) error { - // Marshal request to JSON - req := cacheSetIntRequest{ - Key: key, - Value: value, - TtlSeconds: ttlSeconds, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := cache_setint(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} - -// CacheGetInt calls the cache_getint host function. -// GetInt retrieves an integer value from the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// -// Returns the value and whether the key exists. If the key doesn't exist -// or the stored value is not an integer, exists will be false. -func CacheGetInt(key string) (int64, bool, error) { - // Marshal request to JSON - req := cacheGetIntRequest{ - Key: key, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return 0, false, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := cache_getint(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response cacheGetIntResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return 0, false, err - } - - // Convert Error field to Go error - if response.Error != "" { - return 0, false, errors.New(response.Error) - } - - return response.Value, response.Exists, nil -} - -// CacheSetFloat calls the cache_setfloat host function. -// SetFloat stores a float value in the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// - value: The float value to store -// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) -// -// Returns an error if the operation fails. -func CacheSetFloat(key string, value float64, ttlSeconds int64) error { - // Marshal request to JSON - req := cacheSetFloatRequest{ - Key: key, - Value: value, - TtlSeconds: ttlSeconds, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := cache_setfloat(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} - -// CacheGetFloat calls the cache_getfloat host function. -// GetFloat retrieves a float value from the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// -// Returns the value and whether the key exists. If the key doesn't exist -// or the stored value is not a float, exists will be false. -func CacheGetFloat(key string) (float64, bool, error) { - // Marshal request to JSON - req := cacheGetFloatRequest{ - Key: key, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return 0, false, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := cache_getfloat(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response cacheGetFloatResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return 0, false, err - } - - // Convert Error field to Go error - if response.Error != "" { - return 0, false, errors.New(response.Error) - } - - return response.Value, response.Exists, nil -} - -// CacheSetBytes calls the cache_setbytes host function. -// SetBytes stores a byte slice in the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// - value: The byte slice to store -// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) -// -// Returns an error if the operation fails. -func CacheSetBytes(key string, value []byte, ttlSeconds int64) error { - // Marshal request to JSON - req := cacheSetBytesRequest{ - Key: key, - Value: value, - TtlSeconds: ttlSeconds, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := cache_setbytes(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} - -// CacheGetBytes calls the cache_getbytes host function. -// GetBytes retrieves a byte slice from the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// -// Returns the value and whether the key exists. If the key doesn't exist -// or the stored value is not a byte slice, exists will be false. -func CacheGetBytes(key string) ([]byte, bool, error) { - // Marshal request to JSON - req := cacheGetBytesRequest{ - Key: key, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, false, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := cache_getbytes(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response cacheGetBytesResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, false, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, false, errors.New(response.Error) - } - - return response.Value, response.Exists, nil -} - -// CacheHas calls the cache_has host function. -// Has checks if a key exists in the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// -// Returns true if the key exists and has not expired. -func CacheHas(key string) (bool, error) { - // Marshal request to JSON - req := cacheHasRequest{ - Key: key, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return false, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := cache_has(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response cacheHasResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return false, err - } - - // Convert Error field to Go error - if response.Error != "" { - return false, errors.New(response.Error) - } - - return response.Exists, nil -} - -// CacheRemove calls the cache_remove host function. -// Remove deletes a value from the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// -// Returns an error if the operation fails. Does not return an error if the key doesn't exist. -func CacheRemove(key string) error { - // Marshal request to JSON - req := cacheRemoveRequest{ - Key: key, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := cache_remove(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} diff --git a/plugins/pdk/go/host/nd_host_cache_stub.go b/plugins/pdk/go/host/nd_host_cache_stub.go deleted file mode 100644 index faf4e65d2..000000000 --- a/plugins/pdk/go/host/nd_host_cache_stub.go +++ /dev/null @@ -1,208 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported mock instances to set expectations in tests. -// -//go:build !wasip1 - -package host - -import ( - "github.com/stretchr/testify/mock" -) - -// mockCacheService is the mock implementation for testing. -type mockCacheService struct { - mock.Mock -} - -// CacheMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.CacheMock.On("MethodName", args...).Return(values...) -var CacheMock = &mockCacheService{} - -// SetString is the mock method for CacheSetString. -func (m *mockCacheService) SetString(key string, value string, ttlSeconds int64) error { - args := m.Called(key, value, ttlSeconds) - return args.Error(0) -} - -// CacheSetString delegates to the mock instance. -// SetString stores a string value in the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// - value: The string value to store -// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) -// -// Returns an error if the operation fails. -func CacheSetString(key string, value string, ttlSeconds int64) error { - return CacheMock.SetString(key, value, ttlSeconds) -} - -// GetString is the mock method for CacheGetString. -func (m *mockCacheService) GetString(key string) (string, bool, error) { - args := m.Called(key) - return args.String(0), args.Bool(1), args.Error(2) -} - -// CacheGetString delegates to the mock instance. -// GetString retrieves a string value from the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// -// Returns the value and whether the key exists. If the key doesn't exist -// or the stored value is not a string, exists will be false. -func CacheGetString(key string) (string, bool, error) { - return CacheMock.GetString(key) -} - -// SetInt is the mock method for CacheSetInt. -func (m *mockCacheService) SetInt(key string, value int64, ttlSeconds int64) error { - args := m.Called(key, value, ttlSeconds) - return args.Error(0) -} - -// CacheSetInt delegates to the mock instance. -// SetInt stores an integer value in the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// - value: The integer value to store -// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) -// -// Returns an error if the operation fails. -func CacheSetInt(key string, value int64, ttlSeconds int64) error { - return CacheMock.SetInt(key, value, ttlSeconds) -} - -// GetInt is the mock method for CacheGetInt. -func (m *mockCacheService) GetInt(key string) (int64, bool, error) { - args := m.Called(key) - return args.Get(0).(int64), args.Bool(1), args.Error(2) -} - -// CacheGetInt delegates to the mock instance. -// GetInt retrieves an integer value from the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// -// Returns the value and whether the key exists. If the key doesn't exist -// or the stored value is not an integer, exists will be false. -func CacheGetInt(key string) (int64, bool, error) { - return CacheMock.GetInt(key) -} - -// SetFloat is the mock method for CacheSetFloat. -func (m *mockCacheService) SetFloat(key string, value float64, ttlSeconds int64) error { - args := m.Called(key, value, ttlSeconds) - return args.Error(0) -} - -// CacheSetFloat delegates to the mock instance. -// SetFloat stores a float value in the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// - value: The float value to store -// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) -// -// Returns an error if the operation fails. -func CacheSetFloat(key string, value float64, ttlSeconds int64) error { - return CacheMock.SetFloat(key, value, ttlSeconds) -} - -// GetFloat is the mock method for CacheGetFloat. -func (m *mockCacheService) GetFloat(key string) (float64, bool, error) { - args := m.Called(key) - return args.Get(0).(float64), args.Bool(1), args.Error(2) -} - -// CacheGetFloat delegates to the mock instance. -// GetFloat retrieves a float value from the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// -// Returns the value and whether the key exists. If the key doesn't exist -// or the stored value is not a float, exists will be false. -func CacheGetFloat(key string) (float64, bool, error) { - return CacheMock.GetFloat(key) -} - -// SetBytes is the mock method for CacheSetBytes. -func (m *mockCacheService) SetBytes(key string, value []byte, ttlSeconds int64) error { - args := m.Called(key, value, ttlSeconds) - return args.Error(0) -} - -// CacheSetBytes delegates to the mock instance. -// SetBytes stores a byte slice in the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// - value: The byte slice to store -// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) -// -// Returns an error if the operation fails. -func CacheSetBytes(key string, value []byte, ttlSeconds int64) error { - return CacheMock.SetBytes(key, value, ttlSeconds) -} - -// GetBytes is the mock method for CacheGetBytes. -func (m *mockCacheService) GetBytes(key string) ([]byte, bool, error) { - args := m.Called(key) - var r0 []byte - if v := args.Get(0); v != nil { - r0 = v.([]byte) - } - return r0, args.Bool(1), args.Error(2) -} - -// CacheGetBytes delegates to the mock instance. -// GetBytes retrieves a byte slice from the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// -// Returns the value and whether the key exists. If the key doesn't exist -// or the stored value is not a byte slice, exists will be false. -func CacheGetBytes(key string) ([]byte, bool, error) { - return CacheMock.GetBytes(key) -} - -// Has is the mock method for CacheHas. -func (m *mockCacheService) Has(key string) (bool, error) { - args := m.Called(key) - return args.Bool(0), args.Error(1) -} - -// CacheHas delegates to the mock instance. -// Has checks if a key exists in the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// -// Returns true if the key exists and has not expired. -func CacheHas(key string) (bool, error) { - return CacheMock.Has(key) -} - -// Remove is the mock method for CacheRemove. -func (m *mockCacheService) Remove(key string) error { - args := m.Called(key) - return args.Error(0) -} - -// CacheRemove delegates to the mock instance. -// Remove deletes a value from the cache. -// -// Parameters: -// - key: The cache key (will be namespaced with plugin ID) -// -// Returns an error if the operation fails. Does not return an error if the key doesn't exist. -func CacheRemove(key string) error { - return CacheMock.Remove(key) -} diff --git a/plugins/pdk/go/host/nd_host_config.go b/plugins/pdk/go/host/nd_host_config.go deleted file mode 100644 index 1d913e626..000000000 --- a/plugins/pdk/go/host/nd_host_config.go +++ /dev/null @@ -1,161 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Config host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package host - -import ( - "encoding/json" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// config_get is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user config_get -func config_get(uint64) uint64 - -// config_getint is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user config_getint -func config_getint(uint64) uint64 - -// config_keys is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user config_keys -func config_keys(uint64) uint64 - -type configGetRequest struct { - Key string `json:"key"` -} - -type configGetResponse struct { - Value string `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` -} - -type configGetIntRequest struct { - Key string `json:"key"` -} - -type configGetIntResponse struct { - Value int64 `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` -} - -type configKeysRequest struct { - Prefix string `json:"prefix"` -} - -type configKeysResponse struct { - Keys []string `json:"keys,omitempty"` -} - -// ConfigGet calls the config_get host function. -// Get retrieves a configuration value as a string. -// -// Parameters: -// - key: The configuration key -// -// Returns the value and whether the key exists. -func ConfigGet(key string) (string, bool) { - // Marshal request to JSON - req := configGetRequest{ - Key: key, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", false - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := config_get(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response configGetResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "", false - } - - return response.Value, response.Exists -} - -// ConfigGetInt calls the config_getint host function. -// GetInt retrieves a configuration value as an integer. -// -// Parameters: -// - key: The configuration key -// -// Returns the value and whether the key exists. If the key exists but the -// value cannot be parsed as an integer, exists will be false. -func ConfigGetInt(key string) (int64, bool) { - // Marshal request to JSON - req := configGetIntRequest{ - Key: key, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return 0, false - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := config_getint(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response configGetIntResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return 0, false - } - - return response.Value, response.Exists -} - -// ConfigKeys calls the config_keys host function. -// Keys returns configuration keys matching the given prefix. -// -// Parameters: -// - prefix: Key prefix to filter by. If empty, returns all keys. -// -// Returns a sorted slice of matching configuration keys. -func ConfigKeys(prefix string) []string { - // Marshal request to JSON - req := configKeysRequest{ - Prefix: prefix, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := config_keys(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response configKeysResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil - } - - return response.Keys -} diff --git a/plugins/pdk/go/host/nd_host_config_stub.go b/plugins/pdk/go/host/nd_host_config_stub.go deleted file mode 100644 index 0327346a3..000000000 --- a/plugins/pdk/go/host/nd_host_config_stub.go +++ /dev/null @@ -1,78 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported mock instances to set expectations in tests. -// -//go:build !wasip1 - -package host - -import ( - "github.com/stretchr/testify/mock" -) - -// mockConfigService is the mock implementation for testing. -type mockConfigService struct { - mock.Mock -} - -// ConfigMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.ConfigMock.On("MethodName", args...).Return(values...) -var ConfigMock = &mockConfigService{} - -// Get is the mock method for ConfigGet. -func (m *mockConfigService) Get(key string) (string, bool) { - args := m.Called(key) - return args.String(0), args.Bool(1) -} - -// ConfigGet delegates to the mock instance. -// Get retrieves a configuration value as a string. -// -// Parameters: -// - key: The configuration key -// -// Returns the value and whether the key exists. -func ConfigGet(key string) (string, bool) { - return ConfigMock.Get(key) -} - -// GetInt is the mock method for ConfigGetInt. -func (m *mockConfigService) GetInt(key string) (int64, bool) { - args := m.Called(key) - return args.Get(0).(int64), args.Bool(1) -} - -// ConfigGetInt delegates to the mock instance. -// GetInt retrieves a configuration value as an integer. -// -// Parameters: -// - key: The configuration key -// -// Returns the value and whether the key exists. If the key exists but the -// value cannot be parsed as an integer, exists will be false. -func ConfigGetInt(key string) (int64, bool) { - return ConfigMock.GetInt(key) -} - -// Keys is the mock method for ConfigKeys. -func (m *mockConfigService) Keys(prefix string) []string { - args := m.Called(prefix) - var r0 []string - if v := args.Get(0); v != nil { - r0 = v.([]string) - } - return r0 -} - -// ConfigKeys delegates to the mock instance. -// Keys returns configuration keys matching the given prefix. -// -// Parameters: -// - prefix: Key prefix to filter by. If empty, returns all keys. -// -// Returns a sorted slice of matching configuration keys. -func ConfigKeys(prefix string) []string { - return ConfigMock.Keys(prefix) -} diff --git a/plugins/pdk/go/host/nd_host_http.go b/plugins/pdk/go/host/nd_host_http.go deleted file mode 100644 index d999d3718..000000000 --- a/plugins/pdk/go/host/nd_host_http.go +++ /dev/null @@ -1,90 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the HTTP host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package host - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// HTTPRequest represents the HTTPRequest data structure. -// HTTPRequest represents an outbound HTTP request from a plugin. -type HTTPRequest struct { - Method string `json:"method"` - URL string `json:"url"` - Headers map[string]string `json:"headers"` - NoFollowRedirects bool `json:"noFollowRedirects"` - Body []byte `json:"body"` - TimeoutMs int32 `json:"timeoutMs"` -} - -// HTTPResponse represents the HTTPResponse data structure. -// HTTPResponse represents the response from an outbound HTTP request. -type HTTPResponse struct { - StatusCode int32 `json:"statusCode"` - Headers map[string]string `json:"headers"` - Body []byte `json:"body"` -} - -// http_send is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user http_send -func http_send(uint64) uint64 - -type hTTPSendRequest struct { - Request HTTPRequest `json:"request"` -} - -type hTTPSendResponse struct { - Result *HTTPResponse `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// HTTPSend calls the http_send host function. -// Send executes an HTTP request and returns the response. -// -// Parameters: -// - request: The HTTP request to execute, including method, URL, headers, body, and timeout -// -// Returns the HTTP response with status code, headers, and body. -// Network errors, timeouts, and permission failures are returned as Go errors. -// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. -func HTTPSend(request HTTPRequest) (*HTTPResponse, error) { - // Marshal request to JSON - req := hTTPSendRequest{ - Request: request, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := http_send(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response hTTPSendResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Result, nil -} diff --git a/plugins/pdk/go/host/nd_host_http_stub.go b/plugins/pdk/go/host/nd_host_http_stub.go deleted file mode 100644 index 8706ac9e5..000000000 --- a/plugins/pdk/go/host/nd_host_http_stub.go +++ /dev/null @@ -1,64 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported mock instances to set expectations in tests. -// -//go:build !wasip1 - -package host - -import ( - "github.com/stretchr/testify/mock" -) - -// HTTPRequest represents the HTTPRequest data structure. -// HTTPRequest represents an outbound HTTP request from a plugin. -type HTTPRequest struct { - Method string `json:"method"` - URL string `json:"url"` - Headers map[string]string `json:"headers"` - NoFollowRedirects bool `json:"noFollowRedirects"` - Body []byte `json:"body"` - TimeoutMs int32 `json:"timeoutMs"` -} - -// HTTPResponse represents the HTTPResponse data structure. -// HTTPResponse represents the response from an outbound HTTP request. -type HTTPResponse struct { - StatusCode int32 `json:"statusCode"` - Headers map[string]string `json:"headers"` - Body []byte `json:"body"` -} - -// mockHTTPService is the mock implementation for testing. -type mockHTTPService struct { - mock.Mock -} - -// HTTPMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.HTTPMock.On("MethodName", args...).Return(values...) -var HTTPMock = &mockHTTPService{} - -// Send is the mock method for HTTPSend. -func (m *mockHTTPService) Send(request HTTPRequest) (*HTTPResponse, error) { - args := m.Called(request) - var r0 *HTTPResponse - if v := args.Get(0); v != nil { - r0 = v.(*HTTPResponse) - } - return r0, args.Error(1) -} - -// HTTPSend delegates to the mock instance. -// Send executes an HTTP request and returns the response. -// -// Parameters: -// - request: The HTTP request to execute, including method, URL, headers, body, and timeout -// -// Returns the HTTP response with status code, headers, and body. -// Network errors, timeouts, and permission failures are returned as Go errors. -// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. -func HTTPSend(request HTTPRequest) (*HTTPResponse, error) { - return HTTPMock.Send(request) -} diff --git a/plugins/pdk/go/host/nd_host_kvstore.go b/plugins/pdk/go/host/nd_host_kvstore.go deleted file mode 100644 index 15e1e366a..000000000 --- a/plugins/pdk/go/host/nd_host_kvstore.go +++ /dev/null @@ -1,481 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the KVStore host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package host - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// kvstore_set is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user kvstore_set -func kvstore_set(uint64) uint64 - -// kvstore_setwithttl is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user kvstore_setwithttl -func kvstore_setwithttl(uint64) uint64 - -// kvstore_get is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user kvstore_get -func kvstore_get(uint64) uint64 - -// kvstore_getmany is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user kvstore_getmany -func kvstore_getmany(uint64) uint64 - -// kvstore_has is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user kvstore_has -func kvstore_has(uint64) uint64 - -// kvstore_list is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user kvstore_list -func kvstore_list(uint64) uint64 - -// kvstore_delete is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user kvstore_delete -func kvstore_delete(uint64) uint64 - -// kvstore_deletebyprefix is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user kvstore_deletebyprefix -func kvstore_deletebyprefix(uint64) uint64 - -// kvstore_getstorageused is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user kvstore_getstorageused -func kvstore_getstorageused(uint64) uint64 - -type kVStoreSetRequest struct { - Key string `json:"key"` - Value []byte `json:"value"` -} - -type kVStoreSetWithTTLRequest struct { - Key string `json:"key"` - Value []byte `json:"value"` - TtlSeconds int64 `json:"ttlSeconds"` -} - -type kVStoreGetRequest struct { - Key string `json:"key"` -} - -type kVStoreGetResponse struct { - Value []byte `json:"value,omitempty"` - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -type kVStoreGetManyRequest struct { - Keys []string `json:"keys"` -} - -type kVStoreGetManyResponse struct { - Values map[string][]byte `json:"values,omitempty"` - Error string `json:"error,omitempty"` -} - -type kVStoreHasRequest struct { - Key string `json:"key"` -} - -type kVStoreHasResponse struct { - Exists bool `json:"exists,omitempty"` - Error string `json:"error,omitempty"` -} - -type kVStoreListRequest struct { - Prefix string `json:"prefix"` -} - -type kVStoreListResponse struct { - Keys []string `json:"keys,omitempty"` - Error string `json:"error,omitempty"` -} - -type kVStoreDeleteRequest struct { - Key string `json:"key"` -} - -type kVStoreDeleteByPrefixRequest struct { - Prefix string `json:"prefix"` -} - -type kVStoreDeleteByPrefixResponse struct { - DeletedCount int64 `json:"deletedCount,omitempty"` - Error string `json:"error,omitempty"` -} - -type kVStoreGetStorageUsedResponse struct { - Bytes int64 `json:"bytes,omitempty"` - Error string `json:"error,omitempty"` -} - -// KVStoreSet calls the kvstore_set host function. -// Set stores a byte value with the given key. -// -// Parameters: -// - key: The storage key (max 256 bytes, UTF-8) -// - value: The byte slice to store -// -// Returns an error if the storage limit would be exceeded or the operation fails. -func KVStoreSet(key string, value []byte) error { - // Marshal request to JSON - req := kVStoreSetRequest{ - Key: key, - Value: value, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := kvstore_set(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} - -// KVStoreSetWithTTL calls the kvstore_setwithttl host function. -// SetWithTTL stores a byte value with the given key and a time-to-live. -// -// After ttlSeconds, the key is treated as non-existent and will be -// cleaned up lazily. ttlSeconds must be greater than 0. -// -// Parameters: -// - key: The storage key (max 256 bytes, UTF-8) -// - value: The byte slice to store -// - ttlSeconds: Time-to-live in seconds (must be > 0) -// -// Returns an error if the storage limit would be exceeded or the operation fails. -func KVStoreSetWithTTL(key string, value []byte, ttlSeconds int64) error { - // Marshal request to JSON - req := kVStoreSetWithTTLRequest{ - Key: key, - Value: value, - TtlSeconds: ttlSeconds, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := kvstore_setwithttl(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} - -// KVStoreGet calls the kvstore_get host function. -// Get retrieves a byte value from storage. -// -// Parameters: -// - key: The storage key -// -// Returns the value and whether the key exists. -func KVStoreGet(key string) ([]byte, bool, error) { - // Marshal request to JSON - req := kVStoreGetRequest{ - Key: key, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, false, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := kvstore_get(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response kVStoreGetResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, false, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, false, errors.New(response.Error) - } - - return response.Value, response.Exists, nil -} - -// KVStoreGetMany calls the kvstore_getmany host function. -// GetMany retrieves multiple values in a single call. -// -// Parameters: -// - keys: The storage keys to retrieve -// -// Returns a map of key to value for keys that exist and have not expired. -// Missing or expired keys are omitted from the result. -func KVStoreGetMany(keys []string) (map[string][]byte, error) { - // Marshal request to JSON - req := kVStoreGetManyRequest{ - Keys: keys, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := kvstore_getmany(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response kVStoreGetManyResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Values, nil -} - -// KVStoreHas calls the kvstore_has host function. -// Has checks if a key exists in storage. -// -// Parameters: -// - key: The storage key -// -// Returns true if the key exists. -func KVStoreHas(key string) (bool, error) { - // Marshal request to JSON - req := kVStoreHasRequest{ - Key: key, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return false, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := kvstore_has(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response kVStoreHasResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return false, err - } - - // Convert Error field to Go error - if response.Error != "" { - return false, errors.New(response.Error) - } - - return response.Exists, nil -} - -// KVStoreList calls the kvstore_list host function. -// List returns all keys matching the given prefix. -// -// Parameters: -// - prefix: Key prefix to filter by (empty string returns all keys) -// -// Returns a slice of matching keys. -func KVStoreList(prefix string) ([]string, error) { - // Marshal request to JSON - req := kVStoreListRequest{ - Prefix: prefix, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := kvstore_list(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response kVStoreListResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Keys, nil -} - -// KVStoreDelete calls the kvstore_delete host function. -// Delete removes a value from storage. -// -// Parameters: -// - key: The storage key -// -// Returns an error if the operation fails. Does not return an error if the key doesn't exist. -func KVStoreDelete(key string) error { - // Marshal request to JSON - req := kVStoreDeleteRequest{ - Key: key, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := kvstore_delete(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} - -// KVStoreDeleteByPrefix calls the kvstore_deletebyprefix host function. -// DeleteByPrefix removes all keys matching the given prefix. -// -// Parameters: -// - prefix: Key prefix to match (must not be empty) -// -// Returns the number of keys deleted. Includes expired keys. -func KVStoreDeleteByPrefix(prefix string) (int64, error) { - // Marshal request to JSON - req := kVStoreDeleteByPrefixRequest{ - Prefix: prefix, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return 0, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := kvstore_deletebyprefix(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response kVStoreDeleteByPrefixResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return 0, err - } - - // Convert Error field to Go error - if response.Error != "" { - return 0, errors.New(response.Error) - } - - return response.DeletedCount, nil -} - -// KVStoreGetStorageUsed calls the kvstore_getstorageused host function. -// GetStorageUsed returns the total storage used by this plugin in bytes. -func KVStoreGetStorageUsed() (int64, error) { - // No parameters - allocate empty JSON object - reqMem := pdk.AllocateBytes([]byte("{}")) - defer reqMem.Free() - - // Call the host function - responsePtr := kvstore_getstorageused(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response kVStoreGetStorageUsedResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return 0, err - } - - // Convert Error field to Go error - if response.Error != "" { - return 0, errors.New(response.Error) - } - - return response.Bytes, nil -} diff --git a/plugins/pdk/go/host/nd_host_kvstore_stub.go b/plugins/pdk/go/host/nd_host_kvstore_stub.go deleted file mode 100644 index e5243860a..000000000 --- a/plugins/pdk/go/host/nd_host_kvstore_stub.go +++ /dev/null @@ -1,189 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported mock instances to set expectations in tests. -// -//go:build !wasip1 - -package host - -import ( - "github.com/stretchr/testify/mock" -) - -// mockKVStoreService is the mock implementation for testing. -type mockKVStoreService struct { - mock.Mock -} - -// KVStoreMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.KVStoreMock.On("MethodName", args...).Return(values...) -var KVStoreMock = &mockKVStoreService{} - -// Set is the mock method for KVStoreSet. -func (m *mockKVStoreService) Set(key string, value []byte) error { - args := m.Called(key, value) - return args.Error(0) -} - -// KVStoreSet delegates to the mock instance. -// Set stores a byte value with the given key. -// -// Parameters: -// - key: The storage key (max 256 bytes, UTF-8) -// - value: The byte slice to store -// -// Returns an error if the storage limit would be exceeded or the operation fails. -func KVStoreSet(key string, value []byte) error { - return KVStoreMock.Set(key, value) -} - -// SetWithTTL is the mock method for KVStoreSetWithTTL. -func (m *mockKVStoreService) SetWithTTL(key string, value []byte, ttlSeconds int64) error { - args := m.Called(key, value, ttlSeconds) - return args.Error(0) -} - -// KVStoreSetWithTTL delegates to the mock instance. -// SetWithTTL stores a byte value with the given key and a time-to-live. -// -// After ttlSeconds, the key is treated as non-existent and will be -// cleaned up lazily. ttlSeconds must be greater than 0. -// -// Parameters: -// - key: The storage key (max 256 bytes, UTF-8) -// - value: The byte slice to store -// - ttlSeconds: Time-to-live in seconds (must be > 0) -// -// Returns an error if the storage limit would be exceeded or the operation fails. -func KVStoreSetWithTTL(key string, value []byte, ttlSeconds int64) error { - return KVStoreMock.SetWithTTL(key, value, ttlSeconds) -} - -// Get is the mock method for KVStoreGet. -func (m *mockKVStoreService) Get(key string) ([]byte, bool, error) { - args := m.Called(key) - var r0 []byte - if v := args.Get(0); v != nil { - r0 = v.([]byte) - } - return r0, args.Bool(1), args.Error(2) -} - -// KVStoreGet delegates to the mock instance. -// Get retrieves a byte value from storage. -// -// Parameters: -// - key: The storage key -// -// Returns the value and whether the key exists. -func KVStoreGet(key string) ([]byte, bool, error) { - return KVStoreMock.Get(key) -} - -// GetMany is the mock method for KVStoreGetMany. -func (m *mockKVStoreService) GetMany(keys []string) (map[string][]byte, error) { - args := m.Called(keys) - var r0 map[string][]byte - if v := args.Get(0); v != nil { - r0 = v.(map[string][]byte) - } - return r0, args.Error(1) -} - -// KVStoreGetMany delegates to the mock instance. -// GetMany retrieves multiple values in a single call. -// -// Parameters: -// - keys: The storage keys to retrieve -// -// Returns a map of key to value for keys that exist and have not expired. -// Missing or expired keys are omitted from the result. -func KVStoreGetMany(keys []string) (map[string][]byte, error) { - return KVStoreMock.GetMany(keys) -} - -// Has is the mock method for KVStoreHas. -func (m *mockKVStoreService) Has(key string) (bool, error) { - args := m.Called(key) - return args.Bool(0), args.Error(1) -} - -// KVStoreHas delegates to the mock instance. -// Has checks if a key exists in storage. -// -// Parameters: -// - key: The storage key -// -// Returns true if the key exists. -func KVStoreHas(key string) (bool, error) { - return KVStoreMock.Has(key) -} - -// List is the mock method for KVStoreList. -func (m *mockKVStoreService) List(prefix string) ([]string, error) { - args := m.Called(prefix) - var r0 []string - if v := args.Get(0); v != nil { - r0 = v.([]string) - } - return r0, args.Error(1) -} - -// KVStoreList delegates to the mock instance. -// List returns all keys matching the given prefix. -// -// Parameters: -// - prefix: Key prefix to filter by (empty string returns all keys) -// -// Returns a slice of matching keys. -func KVStoreList(prefix string) ([]string, error) { - return KVStoreMock.List(prefix) -} - -// Delete is the mock method for KVStoreDelete. -func (m *mockKVStoreService) Delete(key string) error { - args := m.Called(key) - return args.Error(0) -} - -// KVStoreDelete delegates to the mock instance. -// Delete removes a value from storage. -// -// Parameters: -// - key: The storage key -// -// Returns an error if the operation fails. Does not return an error if the key doesn't exist. -func KVStoreDelete(key string) error { - return KVStoreMock.Delete(key) -} - -// DeleteByPrefix is the mock method for KVStoreDeleteByPrefix. -func (m *mockKVStoreService) DeleteByPrefix(prefix string) (int64, error) { - args := m.Called(prefix) - return args.Get(0).(int64), args.Error(1) -} - -// KVStoreDeleteByPrefix delegates to the mock instance. -// DeleteByPrefix removes all keys matching the given prefix. -// -// Parameters: -// - prefix: Key prefix to match (must not be empty) -// -// Returns the number of keys deleted. Includes expired keys. -func KVStoreDeleteByPrefix(prefix string) (int64, error) { - return KVStoreMock.DeleteByPrefix(prefix) -} - -// GetStorageUsed is the mock method for KVStoreGetStorageUsed. -func (m *mockKVStoreService) GetStorageUsed() (int64, error) { - args := m.Called() - return args.Get(0).(int64), args.Error(1) -} - -// KVStoreGetStorageUsed delegates to the mock instance. -// GetStorageUsed returns the total storage used by this plugin in bytes. -func KVStoreGetStorageUsed() (int64, error) { - return KVStoreMock.GetStorageUsed() -} diff --git a/plugins/pdk/go/host/nd_host_library.go b/plugins/pdk/go/host/nd_host_library.go deleted file mode 100644 index 0107d1afe..000000000 --- a/plugins/pdk/go/host/nd_host_library.go +++ /dev/null @@ -1,124 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Library host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package host - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// Library represents the Library data structure. -// Library represents a music library with metadata. -type Library struct { - ID int32 `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - MountPoint string `json:"mountPoint"` - LastScanAt int64 `json:"lastScanAt"` - TotalSongs int32 `json:"totalSongs"` - TotalAlbums int32 `json:"totalAlbums"` - TotalArtists int32 `json:"totalArtists"` - TotalSize int64 `json:"totalSize"` - TotalDuration float64 `json:"totalDuration"` -} - -// library_getlibrary is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user library_getlibrary -func library_getlibrary(uint64) uint64 - -// library_getalllibraries is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user library_getalllibraries -func library_getalllibraries(uint64) uint64 - -type libraryGetLibraryRequest struct { - Id int32 `json:"id"` -} - -type libraryGetLibraryResponse struct { - Result *Library `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -type libraryGetAllLibrariesResponse struct { - Result []Library `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// LibraryGetLibrary calls the library_getlibrary host function. -// GetLibrary retrieves metadata for a specific library by ID. -// -// Parameters: -// - id: The library's unique identifier -// -// Returns the library metadata, or an error if the library is not found. -func LibraryGetLibrary(id int32) (*Library, error) { - // Marshal request to JSON - req := libraryGetLibraryRequest{ - Id: id, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := library_getlibrary(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response libraryGetLibraryResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Result, nil -} - -// LibraryGetAllLibraries calls the library_getalllibraries host function. -// GetAllLibraries retrieves metadata for all configured libraries. -// -// Returns a slice of all libraries with their metadata. -func LibraryGetAllLibraries() ([]Library, error) { - // No parameters - allocate empty JSON object - reqMem := pdk.AllocateBytes([]byte("{}")) - defer reqMem.Free() - - // Call the host function - responsePtr := library_getalllibraries(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response libraryGetAllLibrariesResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Result, nil -} diff --git a/plugins/pdk/go/host/nd_host_library_stub.go b/plugins/pdk/go/host/nd_host_library_stub.go deleted file mode 100644 index 0232c49d9..000000000 --- a/plugins/pdk/go/host/nd_host_library_stub.go +++ /dev/null @@ -1,76 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported mock instances to set expectations in tests. -// -//go:build !wasip1 - -package host - -import ( - "github.com/stretchr/testify/mock" -) - -// Library represents the Library data structure. -// Library represents a music library with metadata. -type Library struct { - ID int32 `json:"id"` - Name string `json:"name"` - Path string `json:"path"` - MountPoint string `json:"mountPoint"` - LastScanAt int64 `json:"lastScanAt"` - TotalSongs int32 `json:"totalSongs"` - TotalAlbums int32 `json:"totalAlbums"` - TotalArtists int32 `json:"totalArtists"` - TotalSize int64 `json:"totalSize"` - TotalDuration float64 `json:"totalDuration"` -} - -// mockLibraryService is the mock implementation for testing. -type mockLibraryService struct { - mock.Mock -} - -// LibraryMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.LibraryMock.On("MethodName", args...).Return(values...) -var LibraryMock = &mockLibraryService{} - -// GetLibrary is the mock method for LibraryGetLibrary. -func (m *mockLibraryService) GetLibrary(id int32) (*Library, error) { - args := m.Called(id) - var r0 *Library - if v := args.Get(0); v != nil { - r0 = v.(*Library) - } - return r0, args.Error(1) -} - -// LibraryGetLibrary delegates to the mock instance. -// GetLibrary retrieves metadata for a specific library by ID. -// -// Parameters: -// - id: The library's unique identifier -// -// Returns the library metadata, or an error if the library is not found. -func LibraryGetLibrary(id int32) (*Library, error) { - return LibraryMock.GetLibrary(id) -} - -// GetAllLibraries is the mock method for LibraryGetAllLibraries. -func (m *mockLibraryService) GetAllLibraries() ([]Library, error) { - args := m.Called() - var r0 []Library - if v := args.Get(0); v != nil { - r0 = v.([]Library) - } - return r0, args.Error(1) -} - -// LibraryGetAllLibraries delegates to the mock instance. -// GetAllLibraries retrieves metadata for all configured libraries. -// -// Returns a slice of all libraries with their metadata. -func LibraryGetAllLibraries() ([]Library, error) { - return LibraryMock.GetAllLibraries() -} diff --git a/plugins/pdk/go/host/nd_host_matcher.go b/plugins/pdk/go/host/nd_host_matcher.go deleted file mode 100644 index b32ac6f9d..000000000 --- a/plugins/pdk/go/host/nd_host_matcher.go +++ /dev/null @@ -1,77 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Matcher host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package host - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/types" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// MatchOptions represents the MatchOptions data structure. -// MatchOptions carries optional parameters for a match request. -type MatchOptions struct { - Username string `json:"username"` -} - -// matcher_matchsongs is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user matcher_matchsongs -func matcher_matchsongs(uint64) uint64 - -type matcherMatchSongsRequest struct { - Songs []types.SongRef `json:"songs"` - Opts MatchOptions `json:"opts"` -} - -type matcherMatchSongsResponse struct { - Results []*types.Track `json:"results,omitempty"` - Error string `json:"error,omitempty"` -} - -// MatcherMatchSongs calls the matcher_matchsongs host function. -// MatchSongs resolves each input song to its best-matching library track. -// It returns one entry per input song, in the same order as the input; the -// entry for an input song that had no match is empty (absent). Results are -// limited to the libraries the plugin (and the scoped user, if any) can access. -func MatcherMatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { - // Marshal request to JSON - req := matcherMatchSongsRequest{ - Songs: songs, - Opts: opts, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := matcher_matchsongs(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response matcherMatchSongsResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Results, nil -} diff --git a/plugins/pdk/go/host/nd_host_matcher_stub.go b/plugins/pdk/go/host/nd_host_matcher_stub.go deleted file mode 100644 index ade368c06..000000000 --- a/plugins/pdk/go/host/nd_host_matcher_stub.go +++ /dev/null @@ -1,48 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported mock instances to set expectations in tests. -// -//go:build !wasip1 - -package host - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/types" - "github.com/stretchr/testify/mock" -) - -// MatchOptions represents the MatchOptions data structure. -// MatchOptions carries optional parameters for a match request. -type MatchOptions struct { - Username string `json:"username"` -} - -// mockMatcherService is the mock implementation for testing. -type mockMatcherService struct { - mock.Mock -} - -// MatcherMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.MatcherMock.On("MethodName", args...).Return(values...) -var MatcherMock = &mockMatcherService{} - -// MatchSongs is the mock method for MatcherMatchSongs. -func (m *mockMatcherService) MatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { - args := m.Called(songs, opts) - var r0 []*types.Track - if v := args.Get(0); v != nil { - r0 = v.([]*types.Track) - } - return r0, args.Error(1) -} - -// MatcherMatchSongs delegates to the mock instance. -// MatchSongs resolves each input song to its best-matching library track. -// It returns one entry per input song, in the same order as the input; the -// entry for an input song that had no match is empty (absent). Results are -// limited to the libraries the plugin (and the scoped user, if any) can access. -func MatcherMatchSongs(songs []types.SongRef, opts MatchOptions) ([]*types.Track, error) { - return MatcherMock.MatchSongs(songs, opts) -} diff --git a/plugins/pdk/go/host/nd_host_scheduler.go b/plugins/pdk/go/host/nd_host_scheduler.go deleted file mode 100644 index 0159533ce..000000000 --- a/plugins/pdk/go/host/nd_host_scheduler.go +++ /dev/null @@ -1,185 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Scheduler host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package host - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// scheduler_scheduleonetime is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user scheduler_scheduleonetime -func scheduler_scheduleonetime(uint64) uint64 - -// scheduler_schedulerecurring is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user scheduler_schedulerecurring -func scheduler_schedulerecurring(uint64) uint64 - -// scheduler_cancelschedule is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user scheduler_cancelschedule -func scheduler_cancelschedule(uint64) uint64 - -type schedulerScheduleOneTimeRequest struct { - DelaySeconds int32 `json:"delaySeconds"` - Payload string `json:"payload"` - ScheduleID string `json:"scheduleId"` -} - -type schedulerScheduleOneTimeResponse struct { - NewScheduleID string `json:"newScheduleId,omitempty"` - Error string `json:"error,omitempty"` -} - -type schedulerScheduleRecurringRequest struct { - CronExpression string `json:"cronExpression"` - Payload string `json:"payload"` - ScheduleID string `json:"scheduleId"` -} - -type schedulerScheduleRecurringResponse struct { - NewScheduleID string `json:"newScheduleId,omitempty"` - Error string `json:"error,omitempty"` -} - -type schedulerCancelScheduleRequest struct { - ScheduleID string `json:"scheduleId"` -} - -// SchedulerScheduleOneTime calls the scheduler_scheduleonetime host function. -// ScheduleOneTime schedules a one-time event to be triggered after the specified delay. -// Plugins that use this function must also implement the SchedulerCallback capability -// -// Parameters: -// - delaySeconds: Number of seconds to wait before triggering the event -// - payload: Data to be passed to the scheduled event handler -// - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated -// -// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. -func SchedulerScheduleOneTime(delaySeconds int32, payload string, scheduleID string) (string, error) { - // Marshal request to JSON - req := schedulerScheduleOneTimeRequest{ - DelaySeconds: delaySeconds, - Payload: payload, - ScheduleID: scheduleID, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := scheduler_scheduleonetime(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response schedulerScheduleOneTimeResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "", err - } - - // Convert Error field to Go error - if response.Error != "" { - return "", errors.New(response.Error) - } - - return response.NewScheduleID, nil -} - -// SchedulerScheduleRecurring calls the scheduler_schedulerecurring host function. -// ScheduleRecurring schedules a recurring event using a cron expression. -// Plugins that use this function must also implement the SchedulerCallback capability -// -// Parameters: -// - cronExpression: Standard cron format expression (e.g., "0 0 * * *" for daily at midnight) -// - payload: Data to be passed to each scheduled event handler invocation -// - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated -// -// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. -func SchedulerScheduleRecurring(cronExpression string, payload string, scheduleID string) (string, error) { - // Marshal request to JSON - req := schedulerScheduleRecurringRequest{ - CronExpression: cronExpression, - Payload: payload, - ScheduleID: scheduleID, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := scheduler_schedulerecurring(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response schedulerScheduleRecurringResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "", err - } - - // Convert Error field to Go error - if response.Error != "" { - return "", errors.New(response.Error) - } - - return response.NewScheduleID, nil -} - -// SchedulerCancelSchedule calls the scheduler_cancelschedule host function. -// CancelSchedule cancels a scheduled job identified by its schedule ID. -// -// This works for both one-time and recurring schedules. Once cancelled, the job will not trigger -// any future events. -// -// Returns an error if the schedule ID is not found or if cancellation fails. -func SchedulerCancelSchedule(scheduleID string) error { - // Marshal request to JSON - req := schedulerCancelScheduleRequest{ - ScheduleID: scheduleID, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := scheduler_cancelschedule(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} diff --git a/plugins/pdk/go/host/nd_host_scheduler_stub.go b/plugins/pdk/go/host/nd_host_scheduler_stub.go deleted file mode 100644 index ac2a8821f..000000000 --- a/plugins/pdk/go/host/nd_host_scheduler_stub.go +++ /dev/null @@ -1,79 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported mock instances to set expectations in tests. -// -//go:build !wasip1 - -package host - -import ( - "github.com/stretchr/testify/mock" -) - -// mockSchedulerService is the mock implementation for testing. -type mockSchedulerService struct { - mock.Mock -} - -// SchedulerMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.SchedulerMock.On("MethodName", args...).Return(values...) -var SchedulerMock = &mockSchedulerService{} - -// ScheduleOneTime is the mock method for SchedulerScheduleOneTime. -func (m *mockSchedulerService) ScheduleOneTime(delaySeconds int32, payload string, scheduleID string) (string, error) { - args := m.Called(delaySeconds, payload, scheduleID) - return args.String(0), args.Error(1) -} - -// SchedulerScheduleOneTime delegates to the mock instance. -// ScheduleOneTime schedules a one-time event to be triggered after the specified delay. -// Plugins that use this function must also implement the SchedulerCallback capability -// -// Parameters: -// - delaySeconds: Number of seconds to wait before triggering the event -// - payload: Data to be passed to the scheduled event handler -// - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated -// -// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. -func SchedulerScheduleOneTime(delaySeconds int32, payload string, scheduleID string) (string, error) { - return SchedulerMock.ScheduleOneTime(delaySeconds, payload, scheduleID) -} - -// ScheduleRecurring is the mock method for SchedulerScheduleRecurring. -func (m *mockSchedulerService) ScheduleRecurring(cronExpression string, payload string, scheduleID string) (string, error) { - args := m.Called(cronExpression, payload, scheduleID) - return args.String(0), args.Error(1) -} - -// SchedulerScheduleRecurring delegates to the mock instance. -// ScheduleRecurring schedules a recurring event using a cron expression. -// Plugins that use this function must also implement the SchedulerCallback capability -// -// Parameters: -// - cronExpression: Standard cron format expression (e.g., "0 0 * * *" for daily at midnight) -// - payload: Data to be passed to each scheduled event handler invocation -// - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated -// -// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. -func SchedulerScheduleRecurring(cronExpression string, payload string, scheduleID string) (string, error) { - return SchedulerMock.ScheduleRecurring(cronExpression, payload, scheduleID) -} - -// CancelSchedule is the mock method for SchedulerCancelSchedule. -func (m *mockSchedulerService) CancelSchedule(scheduleID string) error { - args := m.Called(scheduleID) - return args.Error(0) -} - -// SchedulerCancelSchedule delegates to the mock instance. -// CancelSchedule cancels a scheduled job identified by its schedule ID. -// -// This works for both one-time and recurring schedules. Once cancelled, the job will not trigger -// any future events. -// -// Returns an error if the schedule ID is not found or if cancellation fails. -func SchedulerCancelSchedule(scheduleID string) error { - return SchedulerMock.CancelSchedule(scheduleID) -} diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever.go b/plugins/pdk/go/host/nd_host_scrobbleretriever.go deleted file mode 100644 index 596d7ec1d..000000000 --- a/plugins/pdk/go/host/nd_host_scrobbleretriever.go +++ /dev/null @@ -1,266 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the ScrobbleRetriever host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package host - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// ScrobbleCountOptions represents the ScrobbleCountOptions data structure. -// ScrobbleCountOptions carries optional parameters for counting user scrobbles -type ScrobbleCountOptions struct { - FromTimestamp *int64 `json:"fromTimestamp"` - ToTimestamp *int64 `json:"toTimestamp"` -} - -// ScrobbleOptions represents the ScrobbleOptions data structure. -// ScrobbleOptions carries optional parameters for retrieving user scrobbles -type ScrobbleOptions struct { - FromTimestamp *int64 `json:"fromTimestamp"` - ToTimestamp *int64 `json:"toTimestamp"` - Descending bool `json:"descending"` - MaxItems int `json:"maxItems"` - Offset int `json:"offset"` -} - -// ScrobbleRef represents the ScrobbleRef data structure. -// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) -type ScrobbleRef struct { - ID int64 `json:"id"` - MediaFileID string `json:"mediaFileId"` - SubmissionTime int64 `json:"submissionTime"` -} - -// scrobbleretriever_getfirsttimestamp is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user scrobbleretriever_getfirsttimestamp -func scrobbleretriever_getfirsttimestamp(uint64) uint64 - -// scrobbleretriever_getlasttimestamp is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user scrobbleretriever_getlasttimestamp -func scrobbleretriever_getlasttimestamp(uint64) uint64 - -// scrobbleretriever_getscrobbles is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user scrobbleretriever_getscrobbles -func scrobbleretriever_getscrobbles(uint64) uint64 - -// scrobbleretriever_getscrobblecount is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user scrobbleretriever_getscrobblecount -func scrobbleretriever_getscrobblecount(uint64) uint64 - -type scrobbleRetrieverGetFirstTimestampRequest struct { - Username string `json:"username"` -} - -type scrobbleRetrieverGetFirstTimestampResponse struct { - Result *int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -type scrobbleRetrieverGetLastTimestampRequest struct { - Username string `json:"username"` -} - -type scrobbleRetrieverGetLastTimestampResponse struct { - Result *int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -type scrobbleRetrieverGetScrobblesRequest struct { - Username string `json:"username"` - Options ScrobbleOptions `json:"options"` -} - -type scrobbleRetrieverGetScrobblesResponse struct { - Scrobbles []ScrobbleRef `json:"scrobbles,omitempty"` - Next *ScrobbleOptions `json:"next,omitempty"` - Error string `json:"error,omitempty"` -} - -type scrobbleRetrieverGetScrobbleCountRequest struct { - Username string `json:"username"` - Options ScrobbleCountOptions `json:"options"` -} - -type scrobbleRetrieverGetScrobbleCountResponse struct { - Result int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// ScrobbleRetrieverGetFirstTimestamp calls the scrobbleretriever_getfirsttimestamp host function. -// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. -// If the user has no scrobbles, returns nil -func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { - // Marshal request to JSON - req := scrobbleRetrieverGetFirstTimestampRequest{ - Username: username, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := scrobbleretriever_getfirsttimestamp(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response scrobbleRetrieverGetFirstTimestampResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Result, nil -} - -// ScrobbleRetrieverGetLastTimestamp calls the scrobbleretriever_getlasttimestamp host function. -// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user -// If the user has no scrobbles, return nil -func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { - // Marshal request to JSON - req := scrobbleRetrieverGetLastTimestampRequest{ - Username: username, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := scrobbleretriever_getlasttimestamp(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response scrobbleRetrieverGetLastTimestampResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Result, nil -} - -// ScrobbleRetrieverGetScrobbles calls the scrobbleretriever_getscrobbles host function. -// GetScrobbles returns one page of scrobbles for a user. -// -// Parameters: -// - username: the user to query for scrobbles -// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble -// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble -// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest -// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 -// - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually -// -// Returns: -// - scrobbles: The scrobbles in the requested range, ordered by submission time -// (ties broken by scrobble ID) in the direction given by options.Descending -// - next: The options for the following page, or nil once no scrobbles remain. -// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an -// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range -func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) { - // Marshal request to JSON - req := scrobbleRetrieverGetScrobblesRequest{ - Username: username, - Options: options, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := scrobbleretriever_getscrobbles(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response scrobbleRetrieverGetScrobblesResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, nil, errors.New(response.Error) - } - - return response.Scrobbles, response.Next, nil -} - -// ScrobbleRetrieverGetScrobbleCount calls the scrobbleretriever_getscrobblecount host function. -// GetScrobbleCount returns the number of scrobbles for a user in a given range -// -// Parameters: -// - username: the user to query for scrobbles -// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble -// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble -// -// Returns: -// - the number of scrobbles in the given range, or 0 -func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { - // Marshal request to JSON - req := scrobbleRetrieverGetScrobbleCountRequest{ - Username: username, - Options: options, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return 0, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := scrobbleretriever_getscrobblecount(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response scrobbleRetrieverGetScrobbleCountResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return 0, err - } - - // Convert Error field to Go error - if response.Error != "" { - return 0, errors.New(response.Error) - } - - return response.Result, nil -} diff --git a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go b/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go deleted file mode 100644 index a75fcebb5..000000000 --- a/plugins/pdk/go/host/nd_host_scrobbleretriever_stub.go +++ /dev/null @@ -1,136 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported mock instances to set expectations in tests. -// -//go:build !wasip1 - -package host - -import ( - "github.com/stretchr/testify/mock" -) - -// ScrobbleCountOptions represents the ScrobbleCountOptions data structure. -// ScrobbleCountOptions carries optional parameters for counting user scrobbles -type ScrobbleCountOptions struct { - FromTimestamp *int64 `json:"fromTimestamp"` - ToTimestamp *int64 `json:"toTimestamp"` -} - -// ScrobbleOptions represents the ScrobbleOptions data structure. -// ScrobbleOptions carries optional parameters for retrieving user scrobbles -type ScrobbleOptions struct { - FromTimestamp *int64 `json:"fromTimestamp"` - ToTimestamp *int64 `json:"toTimestamp"` - Descending bool `json:"descending"` - MaxItems int `json:"maxItems"` - Offset int `json:"offset"` -} - -// ScrobbleRef represents the ScrobbleRef data structure. -// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) -type ScrobbleRef struct { - ID int64 `json:"id"` - MediaFileID string `json:"mediaFileId"` - SubmissionTime int64 `json:"submissionTime"` -} - -// mockScrobbleRetrieverService is the mock implementation for testing. -type mockScrobbleRetrieverService struct { - mock.Mock -} - -// ScrobbleRetrieverMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.ScrobbleRetrieverMock.On("MethodName", args...).Return(values...) -var ScrobbleRetrieverMock = &mockScrobbleRetrieverService{} - -// GetFirstTimestamp is the mock method for ScrobbleRetrieverGetFirstTimestamp. -func (m *mockScrobbleRetrieverService) GetFirstTimestamp(username string) (*int64, error) { - args := m.Called(username) - var r0 *int64 - if v := args.Get(0); v != nil { - r0 = v.(*int64) - } - return r0, args.Error(1) -} - -// ScrobbleRetrieverGetFirstTimestamp delegates to the mock instance. -// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. -// If the user has no scrobbles, returns nil -func ScrobbleRetrieverGetFirstTimestamp(username string) (*int64, error) { - return ScrobbleRetrieverMock.GetFirstTimestamp(username) -} - -// GetLastTimestamp is the mock method for ScrobbleRetrieverGetLastTimestamp. -func (m *mockScrobbleRetrieverService) GetLastTimestamp(username string) (*int64, error) { - args := m.Called(username) - var r0 *int64 - if v := args.Get(0); v != nil { - r0 = v.(*int64) - } - return r0, args.Error(1) -} - -// ScrobbleRetrieverGetLastTimestamp delegates to the mock instance. -// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user -// If the user has no scrobbles, return nil -func ScrobbleRetrieverGetLastTimestamp(username string) (*int64, error) { - return ScrobbleRetrieverMock.GetLastTimestamp(username) -} - -// GetScrobbles is the mock method for ScrobbleRetrieverGetScrobbles. -func (m *mockScrobbleRetrieverService) GetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) { - args := m.Called(username, options) - var r0 []ScrobbleRef - if v := args.Get(0); v != nil { - r0 = v.([]ScrobbleRef) - } - var r1 *ScrobbleOptions - if v := args.Get(1); v != nil { - r1 = v.(*ScrobbleOptions) - } - return r0, r1, args.Error(2) -} - -// ScrobbleRetrieverGetScrobbles delegates to the mock instance. -// GetScrobbles returns one page of scrobbles for a user. -// -// Parameters: -// - username: the user to query for scrobbles -// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble -// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble -// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest -// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 -// - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually -// -// Returns: -// - scrobbles: The scrobbles in the requested range, ordered by submission time -// (ties broken by scrobble ID) in the direction given by options.Descending -// - next: The options for the following page, or nil once no scrobbles remain. -// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an -// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range -func ScrobbleRetrieverGetScrobbles(username string, options ScrobbleOptions) ([]ScrobbleRef, *ScrobbleOptions, error) { - return ScrobbleRetrieverMock.GetScrobbles(username, options) -} - -// GetScrobbleCount is the mock method for ScrobbleRetrieverGetScrobbleCount. -func (m *mockScrobbleRetrieverService) GetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { - args := m.Called(username, options) - return args.Get(0).(int64), args.Error(1) -} - -// ScrobbleRetrieverGetScrobbleCount delegates to the mock instance. -// GetScrobbleCount returns the number of scrobbles for a user in a given range -// -// Parameters: -// - username: the user to query for scrobbles -// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble -// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble -// -// Returns: -// - the number of scrobbles in the given range, or 0 -func ScrobbleRetrieverGetScrobbleCount(username string, options ScrobbleCountOptions) (int64, error) { - return ScrobbleRetrieverMock.GetScrobbleCount(username, options) -} diff --git a/plugins/pdk/go/host/nd_host_storage.go b/plugins/pdk/go/host/nd_host_storage.go deleted file mode 100644 index 4f37bc8e2..000000000 --- a/plugins/pdk/go/host/nd_host_storage.go +++ /dev/null @@ -1,46 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Storage host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package host - -import ( - "encoding/json" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// storage_getstoragepath is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user storage_getstoragepath -func storage_getstoragepath(uint64) uint64 - -type storageGetStoragePathResponse struct { - Result string `json:"result,omitempty"` -} - -// StorageGetStoragePath calls the storage_getstoragepath host function. -// GetStoragePath retrieves the persistent storage path, if allowed -func StorageGetStoragePath() string { - // No parameters - allocate empty JSON object - reqMem := pdk.AllocateBytes([]byte("{}")) - defer reqMem.Free() - - // Call the host function - responsePtr := storage_getstoragepath(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response storageGetStoragePathResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "" - } - - return response.Result -} diff --git a/plugins/pdk/go/host/nd_host_storage_stub.go b/plugins/pdk/go/host/nd_host_storage_stub.go deleted file mode 100644 index c2213ece9..000000000 --- a/plugins/pdk/go/host/nd_host_storage_stub.go +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported mock instances to set expectations in tests. -// -//go:build !wasip1 - -package host - -import ( - "github.com/stretchr/testify/mock" -) - -// mockStorageService is the mock implementation for testing. -type mockStorageService struct { - mock.Mock -} - -// StorageMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.StorageMock.On("MethodName", args...).Return(values...) -var StorageMock = &mockStorageService{} - -// GetStoragePath is the mock method for StorageGetStoragePath. -func (m *mockStorageService) GetStoragePath() string { - args := m.Called() - return args.String(0) -} - -// StorageGetStoragePath delegates to the mock instance. -// GetStoragePath retrieves the persistent storage path, if allowed -func StorageGetStoragePath() string { - return StorageMock.GetStoragePath() -} diff --git a/plugins/pdk/go/host/nd_host_subsonicapi.go b/plugins/pdk/go/host/nd_host_subsonicapi.go deleted file mode 100644 index e6e56ce6b..000000000 --- a/plugins/pdk/go/host/nd_host_subsonicapi.go +++ /dev/null @@ -1,119 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the SubsonicAPI host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package host - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// subsonicapi_call is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user subsonicapi_call -func subsonicapi_call(uint64) uint64 - -// subsonicapi_callraw is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user subsonicapi_callraw -func subsonicapi_callraw(uint64) uint64 - -type subsonicAPICallRequest struct { - Uri string `json:"uri"` -} - -type subsonicAPICallResponse struct { - ResponseJSON string `json:"responseJson,omitempty"` - Error string `json:"error,omitempty"` -} - -type subsonicAPICallRawRequest struct { - Uri string `json:"uri"` -} - -type subsonicAPICallRawResponse struct { - ContentType string `json:"contentType,omitempty"` - Data []byte `json:"data,omitempty"` - Error string `json:"error,omitempty"` -} - -// SubsonicAPICall calls the subsonicapi_call host function. -// Call executes a Subsonic API request and returns the JSON response. -// -// The uri parameter should be the Subsonic API path without the server prefix, -// e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. -func SubsonicAPICall(uri string) (string, error) { - // Marshal request to JSON - req := subsonicAPICallRequest{ - Uri: uri, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := subsonicapi_call(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response subsonicAPICallResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "", err - } - - // Convert Error field to Go error - if response.Error != "" { - return "", errors.New(response.Error) - } - - return response.ResponseJSON, nil -} - -// SubsonicAPICallRaw calls the subsonicapi_callraw host function. -// CallRaw executes a Subsonic API request and returns the raw binary response. -// Designed for binary endpoints like getCoverArt and stream that return -// non-JSON data. The data is base64-encoded over JSON on the wire. -func SubsonicAPICallRaw(uri string) (string, []byte, error) { - // Marshal request to JSON - req := subsonicAPICallRawRequest{ - Uri: uri, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := subsonicapi_callraw(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response subsonicAPICallRawResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "", nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return "", nil, errors.New(response.Error) - } - - return response.ContentType, response.Data, nil -} diff --git a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go b/plugins/pdk/go/host/nd_host_subsonicapi_stub.go deleted file mode 100644 index ef3d1b735..000000000 --- a/plugins/pdk/go/host/nd_host_subsonicapi_stub.go +++ /dev/null @@ -1,55 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported mock instances to set expectations in tests. -// -//go:build !wasip1 - -package host - -import ( - "github.com/stretchr/testify/mock" -) - -// mockSubsonicAPIService is the mock implementation for testing. -type mockSubsonicAPIService struct { - mock.Mock -} - -// SubsonicAPIMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.SubsonicAPIMock.On("MethodName", args...).Return(values...) -var SubsonicAPIMock = &mockSubsonicAPIService{} - -// Call is the mock method for SubsonicAPICall. -func (m *mockSubsonicAPIService) Call(uri string) (string, error) { - args := m.Called(uri) - return args.String(0), args.Error(1) -} - -// SubsonicAPICall delegates to the mock instance. -// Call executes a Subsonic API request and returns the JSON response. -// -// The uri parameter should be the Subsonic API path without the server prefix, -// e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. -func SubsonicAPICall(uri string) (string, error) { - return SubsonicAPIMock.Call(uri) -} - -// CallRaw is the mock method for SubsonicAPICallRaw. -func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) { - args := m.Called(uri) - var r1 []byte - if v := args.Get(1); v != nil { - r1 = v.([]byte) - } - return args.String(0), r1, args.Error(2) -} - -// SubsonicAPICallRaw delegates to the mock instance. -// CallRaw executes a Subsonic API request and returns the raw binary response. -// Designed for binary endpoints like getCoverArt and stream that return -// non-JSON data. The data is base64-encoded over JSON on the wire. -func SubsonicAPICallRaw(uri string) (string, []byte, error) { - return SubsonicAPIMock.CallRaw(uri) -} diff --git a/plugins/pdk/go/host/nd_host_task.go b/plugins/pdk/go/host/nd_host_task.go deleted file mode 100644 index 92a41c5bc..000000000 --- a/plugins/pdk/go/host/nd_host_task.go +++ /dev/null @@ -1,277 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Task host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package host - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// QueueConfig represents the QueueConfig data structure. -// QueueConfig holds configuration for a task queue. -type QueueConfig struct { - Concurrency int32 `json:"concurrency"` - MaxRetries int32 `json:"maxRetries"` - BackoffMs int64 `json:"backoffMs"` - DelayMs int64 `json:"delayMs"` - RetentionMs int64 `json:"retentionMs"` -} - -// TaskInfo represents the TaskInfo data structure. -// TaskInfo holds the current state of a task. -type TaskInfo struct { - Status string `json:"status"` - Message string `json:"message"` - Attempt int32 `json:"attempt"` -} - -// task_createqueue is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user task_createqueue -func task_createqueue(uint64) uint64 - -// task_enqueue is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user task_enqueue -func task_enqueue(uint64) uint64 - -// task_get is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user task_get -func task_get(uint64) uint64 - -// task_cancel is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user task_cancel -func task_cancel(uint64) uint64 - -// task_clearqueue is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user task_clearqueue -func task_clearqueue(uint64) uint64 - -type taskCreateQueueRequest struct { - Name string `json:"name"` - Config QueueConfig `json:"config"` -} - -type taskEnqueueRequest struct { - QueueName string `json:"queueName"` - Payload []byte `json:"payload"` -} - -type taskEnqueueResponse struct { - Result string `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -type taskGetRequest struct { - TaskID string `json:"taskId"` -} - -type taskGetResponse struct { - Result *TaskInfo `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -type taskCancelRequest struct { - TaskID string `json:"taskId"` -} - -type taskClearQueueRequest struct { - QueueName string `json:"queueName"` -} - -type taskClearQueueResponse struct { - Result int64 `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// TaskCreateQueue calls the task_createqueue host function. -// CreateQueue creates a named task queue with the given configuration. -// Zero-value fields in config use sensible defaults. -// If a queue with the same name already exists, returns an error. -// On startup, this also recovers any stale "running" tasks from a previous crash. -func TaskCreateQueue(name string, config QueueConfig) error { - // Marshal request to JSON - req := taskCreateQueueRequest{ - Name: name, - Config: config, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := task_createqueue(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} - -// TaskEnqueue calls the task_enqueue host function. -// Enqueue adds a task to the named queue. Returns the task ID. -// payload is opaque bytes passed back to the plugin on execution. -func TaskEnqueue(queueName string, payload []byte) (string, error) { - // Marshal request to JSON - req := taskEnqueueRequest{ - QueueName: queueName, - Payload: payload, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := task_enqueue(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response taskEnqueueResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "", err - } - - // Convert Error field to Go error - if response.Error != "" { - return "", errors.New(response.Error) - } - - return response.Result, nil -} - -// TaskGet calls the task_get host function. -// Get returns the current state of a task including its status, -// message, and attempt count. -func TaskGet(taskID string) (*TaskInfo, error) { - // Marshal request to JSON - req := taskGetRequest{ - TaskID: taskID, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return nil, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := task_get(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response taskGetResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Result, nil -} - -// TaskCancel calls the task_cancel host function. -// Cancel cancels a pending task. Returns error if already -// running, completed, or failed. -func TaskCancel(taskID string) error { - // Marshal request to JSON - req := taskCancelRequest{ - TaskID: taskID, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := task_cancel(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} - -// TaskClearQueue calls the task_clearqueue host function. -// ClearQueue removes all pending tasks from the named queue. -// Running tasks are not affected. Returns the number of tasks removed. -func TaskClearQueue(queueName string) (int64, error) { - // Marshal request to JSON - req := taskClearQueueRequest{ - QueueName: queueName, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return 0, err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := task_clearqueue(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response taskClearQueueResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return 0, err - } - - // Convert Error field to Go error - if response.Error != "" { - return 0, errors.New(response.Error) - } - - return response.Result, nil -} diff --git a/plugins/pdk/go/host/nd_host_task_stub.go b/plugins/pdk/go/host/nd_host_task_stub.go deleted file mode 100644 index f2c45a2b5..000000000 --- a/plugins/pdk/go/host/nd_host_task_stub.go +++ /dev/null @@ -1,111 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported mock instances to set expectations in tests. -// -//go:build !wasip1 - -package host - -import ( - "github.com/stretchr/testify/mock" -) - -// QueueConfig represents the QueueConfig data structure. -// QueueConfig holds configuration for a task queue. -type QueueConfig struct { - Concurrency int32 `json:"concurrency"` - MaxRetries int32 `json:"maxRetries"` - BackoffMs int64 `json:"backoffMs"` - DelayMs int64 `json:"delayMs"` - RetentionMs int64 `json:"retentionMs"` -} - -// TaskInfo represents the TaskInfo data structure. -// TaskInfo holds the current state of a task. -type TaskInfo struct { - Status string `json:"status"` - Message string `json:"message"` - Attempt int32 `json:"attempt"` -} - -// mockTaskService is the mock implementation for testing. -type mockTaskService struct { - mock.Mock -} - -// TaskMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.TaskMock.On("MethodName", args...).Return(values...) -var TaskMock = &mockTaskService{} - -// CreateQueue is the mock method for TaskCreateQueue. -func (m *mockTaskService) CreateQueue(name string, config QueueConfig) error { - args := m.Called(name, config) - return args.Error(0) -} - -// TaskCreateQueue delegates to the mock instance. -// CreateQueue creates a named task queue with the given configuration. -// Zero-value fields in config use sensible defaults. -// If a queue with the same name already exists, returns an error. -// On startup, this also recovers any stale "running" tasks from a previous crash. -func TaskCreateQueue(name string, config QueueConfig) error { - return TaskMock.CreateQueue(name, config) -} - -// Enqueue is the mock method for TaskEnqueue. -func (m *mockTaskService) Enqueue(queueName string, payload []byte) (string, error) { - args := m.Called(queueName, payload) - return args.String(0), args.Error(1) -} - -// TaskEnqueue delegates to the mock instance. -// Enqueue adds a task to the named queue. Returns the task ID. -// payload is opaque bytes passed back to the plugin on execution. -func TaskEnqueue(queueName string, payload []byte) (string, error) { - return TaskMock.Enqueue(queueName, payload) -} - -// Get is the mock method for TaskGet. -func (m *mockTaskService) Get(taskID string) (*TaskInfo, error) { - args := m.Called(taskID) - var r0 *TaskInfo - if v := args.Get(0); v != nil { - r0 = v.(*TaskInfo) - } - return r0, args.Error(1) -} - -// TaskGet delegates to the mock instance. -// Get returns the current state of a task including its status, -// message, and attempt count. -func TaskGet(taskID string) (*TaskInfo, error) { - return TaskMock.Get(taskID) -} - -// Cancel is the mock method for TaskCancel. -func (m *mockTaskService) Cancel(taskID string) error { - args := m.Called(taskID) - return args.Error(0) -} - -// TaskCancel delegates to the mock instance. -// Cancel cancels a pending task. Returns error if already -// running, completed, or failed. -func TaskCancel(taskID string) error { - return TaskMock.Cancel(taskID) -} - -// ClearQueue is the mock method for TaskClearQueue. -func (m *mockTaskService) ClearQueue(queueName string) (int64, error) { - args := m.Called(queueName) - return args.Get(0).(int64), args.Error(1) -} - -// TaskClearQueue delegates to the mock instance. -// ClearQueue removes all pending tasks from the named queue. -// Running tasks are not affected. Returns the number of tasks removed. -func TaskClearQueue(queueName string) (int64, error) { - return TaskMock.ClearQueue(queueName) -} diff --git a/plugins/pdk/go/host/nd_host_users.go b/plugins/pdk/go/host/nd_host_users.go deleted file mode 100644 index 21b6ad0ed..000000000 --- a/plugins/pdk/go/host/nd_host_users.go +++ /dev/null @@ -1,107 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Users host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package host - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// User represents the User data structure. -// User represents a Navidrome user with minimal information exposed to plugins. -// Sensitive fields like password, email, and internal IDs are intentionally excluded. -type User struct { - UserName string `json:"userName"` - Name string `json:"name"` - IsAdmin bool `json:"isAdmin"` -} - -// users_getusers is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user users_getusers -func users_getusers(uint64) uint64 - -// users_getadmins is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user users_getadmins -func users_getadmins(uint64) uint64 - -type usersGetUsersResponse struct { - Result []User `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -type usersGetAdminsResponse struct { - Result []User `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -// UsersGetUsers calls the users_getusers host function. -// GetUsers returns all users the plugin has been granted access to. -// Only minimal user information (userName, name, isAdmin) is returned. -// Sensitive fields like password and email are never exposed. -// -// Returns a slice of users the plugin can access, or an empty slice if none configured. -func UsersGetUsers() ([]User, error) { - // No parameters - allocate empty JSON object - reqMem := pdk.AllocateBytes([]byte("{}")) - defer reqMem.Free() - - // Call the host function - responsePtr := users_getusers(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response usersGetUsersResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Result, nil -} - -// UsersGetAdmins calls the users_getadmins host function. -// GetAdmins returns only admin users the plugin has been granted access to. -// This is a convenience method that filters GetUsers results to include only admins. -// -// Returns a slice of admin users the plugin can access, or an empty slice if none. -func UsersGetAdmins() ([]User, error) { - // No parameters - allocate empty JSON object - reqMem := pdk.AllocateBytes([]byte("{}")) - defer reqMem.Free() - - // Call the host function - responsePtr := users_getadmins(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response usersGetAdminsResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, err - } - - // Convert Error field to Go error - if response.Error != "" { - return nil, errors.New(response.Error) - } - - return response.Result, nil -} diff --git a/plugins/pdk/go/host/nd_host_users_stub.go b/plugins/pdk/go/host/nd_host_users_stub.go deleted file mode 100644 index e0f8c0939..000000000 --- a/plugins/pdk/go/host/nd_host_users_stub.go +++ /dev/null @@ -1,70 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported mock instances to set expectations in tests. -// -//go:build !wasip1 - -package host - -import ( - "github.com/stretchr/testify/mock" -) - -// User represents the User data structure. -// User represents a Navidrome user with minimal information exposed to plugins. -// Sensitive fields like password, email, and internal IDs are intentionally excluded. -type User struct { - UserName string `json:"userName"` - Name string `json:"name"` - IsAdmin bool `json:"isAdmin"` -} - -// mockUsersService is the mock implementation for testing. -type mockUsersService struct { - mock.Mock -} - -// UsersMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.UsersMock.On("MethodName", args...).Return(values...) -var UsersMock = &mockUsersService{} - -// GetUsers is the mock method for UsersGetUsers. -func (m *mockUsersService) GetUsers() ([]User, error) { - args := m.Called() - var r0 []User - if v := args.Get(0); v != nil { - r0 = v.([]User) - } - return r0, args.Error(1) -} - -// UsersGetUsers delegates to the mock instance. -// GetUsers returns all users the plugin has been granted access to. -// Only minimal user information (userName, name, isAdmin) is returned. -// Sensitive fields like password and email are never exposed. -// -// Returns a slice of users the plugin can access, or an empty slice if none configured. -func UsersGetUsers() ([]User, error) { - return UsersMock.GetUsers() -} - -// GetAdmins is the mock method for UsersGetAdmins. -func (m *mockUsersService) GetAdmins() ([]User, error) { - args := m.Called() - var r0 []User - if v := args.Get(0); v != nil { - r0 = v.([]User) - } - return r0, args.Error(1) -} - -// UsersGetAdmins delegates to the mock instance. -// GetAdmins returns only admin users the plugin has been granted access to. -// This is a convenience method that filters GetUsers results to include only admins. -// -// Returns a slice of admin users the plugin can access, or an empty slice if none. -func UsersGetAdmins() ([]User, error) { - return UsersMock.GetAdmins() -} diff --git a/plugins/pdk/go/host/nd_host_websocket.go b/plugins/pdk/go/host/nd_host_websocket.go deleted file mode 100644 index 956f63c21..000000000 --- a/plugins/pdk/go/host/nd_host_websocket.go +++ /dev/null @@ -1,235 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the WebSocket host service. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package host - -import ( - "encoding/json" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// websocket_connect is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user websocket_connect -func websocket_connect(uint64) uint64 - -// websocket_sendtext is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user websocket_sendtext -func websocket_sendtext(uint64) uint64 - -// websocket_sendbinary is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user websocket_sendbinary -func websocket_sendbinary(uint64) uint64 - -// websocket_closeconnection is the host function provided by Navidrome. -// -//go:wasmimport extism:host/user websocket_closeconnection -func websocket_closeconnection(uint64) uint64 - -type webSocketConnectRequest struct { - Url string `json:"url"` - Headers map[string]string `json:"headers"` - ConnectionID string `json:"connectionId"` -} - -type webSocketConnectResponse struct { - NewConnectionID string `json:"newConnectionId,omitempty"` - Error string `json:"error,omitempty"` -} - -type webSocketSendTextRequest struct { - ConnectionID string `json:"connectionId"` - Message string `json:"message"` -} - -type webSocketSendBinaryRequest struct { - ConnectionID string `json:"connectionId"` - Data []byte `json:"data"` -} - -type webSocketCloseConnectionRequest struct { - ConnectionID string `json:"connectionId"` - Code int32 `json:"code"` - Reason string `json:"reason"` -} - -// WebSocketConnect calls the websocket_connect host function. -// Connect establishes a WebSocket connection to the specified URL. -// -// Plugins that use this function must also implement the WebSocketCallback capability -// to receive incoming messages and connection events. -// -// Parameters: -// - url: The WebSocket URL to connect to (ws:// or wss://) -// - headers: Optional HTTP headers to include in the handshake request -// - connectionID: Optional unique identifier for the connection. If empty, one will be generated -// -// Returns the connection ID that can be used to send messages or close the connection, -// or an error if the connection fails. -func WebSocketConnect(url string, headers map[string]string, connectionID string) (string, error) { - // Marshal request to JSON - req := webSocketConnectRequest{ - Url: url, - Headers: headers, - ConnectionID: connectionID, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return "", err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := websocket_connect(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse the response - var response webSocketConnectResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return "", err - } - - // Convert Error field to Go error - if response.Error != "" { - return "", errors.New(response.Error) - } - - return response.NewConnectionID, nil -} - -// WebSocketSendText calls the websocket_sendtext host function. -// SendText sends a text message over an established WebSocket connection. -// -// Parameters: -// - connectionID: The connection identifier returned by Connect -// - message: The text message to send -// -// Returns an error if the connection is not found or if sending fails. -func WebSocketSendText(connectionID string, message string) error { - // Marshal request to JSON - req := webSocketSendTextRequest{ - ConnectionID: connectionID, - Message: message, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := websocket_sendtext(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} - -// WebSocketSendBinary calls the websocket_sendbinary host function. -// SendBinary sends binary data over an established WebSocket connection. -// -// Parameters: -// - connectionID: The connection identifier returned by Connect -// - data: The binary data to send -// -// Returns an error if the connection is not found or if sending fails. -func WebSocketSendBinary(connectionID string, data []byte) error { - // Marshal request to JSON - req := webSocketSendBinaryRequest{ - ConnectionID: connectionID, - Data: data, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := websocket_sendbinary(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} - -// WebSocketCloseConnection calls the websocket_closeconnection host function. -// CloseConnection gracefully closes a WebSocket connection. -// -// Parameters: -// - connectionID: The connection identifier returned by Connect -// - code: WebSocket close status code (e.g., 1000 for normal closure) -// - reason: Optional human-readable reason for closing -// -// Returns an error if the connection is not found or if closing fails. -func WebSocketCloseConnection(connectionID string, code int32, reason string) error { - // Marshal request to JSON - req := webSocketCloseConnectionRequest{ - ConnectionID: connectionID, - Code: code, - Reason: reason, - } - reqBytes, err := json.Marshal(req) - if err != nil { - return err - } - reqMem := pdk.AllocateBytes(reqBytes) - defer reqMem.Free() - - // Call the host function - responsePtr := websocket_closeconnection(reqMem.Offset()) - - // Read the response from memory - responseMem := pdk.FindMemory(responsePtr) - responseBytes := responseMem.ReadBytes() - - // Parse error-only response - var response struct { - Error string `json:"error,omitempty"` - } - if err := json.Unmarshal(responseBytes, &response); err != nil { - return err - } - if response.Error != "" { - return errors.New(response.Error) - } - return nil -} diff --git a/plugins/pdk/go/host/nd_host_websocket_stub.go b/plugins/pdk/go/host/nd_host_websocket_stub.go deleted file mode 100644 index ca6e39c44..000000000 --- a/plugins/pdk/go/host/nd_host_websocket_stub.go +++ /dev/null @@ -1,100 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported mock instances to set expectations in tests. -// -//go:build !wasip1 - -package host - -import ( - "github.com/stretchr/testify/mock" -) - -// mockWebSocketService is the mock implementation for testing. -type mockWebSocketService struct { - mock.Mock -} - -// WebSocketMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: host.WebSocketMock.On("MethodName", args...).Return(values...) -var WebSocketMock = &mockWebSocketService{} - -// Connect is the mock method for WebSocketConnect. -func (m *mockWebSocketService) Connect(url string, headers map[string]string, connectionID string) (string, error) { - args := m.Called(url, headers, connectionID) - return args.String(0), args.Error(1) -} - -// WebSocketConnect delegates to the mock instance. -// Connect establishes a WebSocket connection to the specified URL. -// -// Plugins that use this function must also implement the WebSocketCallback capability -// to receive incoming messages and connection events. -// -// Parameters: -// - url: The WebSocket URL to connect to (ws:// or wss://) -// - headers: Optional HTTP headers to include in the handshake request -// - connectionID: Optional unique identifier for the connection. If empty, one will be generated -// -// Returns the connection ID that can be used to send messages or close the connection, -// or an error if the connection fails. -func WebSocketConnect(url string, headers map[string]string, connectionID string) (string, error) { - return WebSocketMock.Connect(url, headers, connectionID) -} - -// SendText is the mock method for WebSocketSendText. -func (m *mockWebSocketService) SendText(connectionID string, message string) error { - args := m.Called(connectionID, message) - return args.Error(0) -} - -// WebSocketSendText delegates to the mock instance. -// SendText sends a text message over an established WebSocket connection. -// -// Parameters: -// - connectionID: The connection identifier returned by Connect -// - message: The text message to send -// -// Returns an error if the connection is not found or if sending fails. -func WebSocketSendText(connectionID string, message string) error { - return WebSocketMock.SendText(connectionID, message) -} - -// SendBinary is the mock method for WebSocketSendBinary. -func (m *mockWebSocketService) SendBinary(connectionID string, data []byte) error { - args := m.Called(connectionID, data) - return args.Error(0) -} - -// WebSocketSendBinary delegates to the mock instance. -// SendBinary sends binary data over an established WebSocket connection. -// -// Parameters: -// - connectionID: The connection identifier returned by Connect -// - data: The binary data to send -// -// Returns an error if the connection is not found or if sending fails. -func WebSocketSendBinary(connectionID string, data []byte) error { - return WebSocketMock.SendBinary(connectionID, data) -} - -// CloseConnection is the mock method for WebSocketCloseConnection. -func (m *mockWebSocketService) CloseConnection(connectionID string, code int32, reason string) error { - args := m.Called(connectionID, code, reason) - return args.Error(0) -} - -// WebSocketCloseConnection delegates to the mock instance. -// CloseConnection gracefully closes a WebSocket connection. -// -// Parameters: -// - connectionID: The connection identifier returned by Connect -// - code: WebSocket close status code (e.g., 1000 for normal closure) -// - reason: Optional human-readable reason for closing -// -// Returns an error if the connection is not found or if closing fails. -func WebSocketCloseConnection(connectionID string, code int32, reason string) error { - return WebSocketMock.CloseConnection(connectionID, code, reason) -} diff --git a/plugins/pdk/go/lifecycle/lifecycle.go b/plugins/pdk/go/lifecycle/lifecycle.go deleted file mode 100644 index 93b5cf37b..000000000 --- a/plugins/pdk/go/lifecycle/lifecycle.go +++ /dev/null @@ -1,59 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the Lifecycle capability. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package lifecycle - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// Lifecycle is the marker interface for lifecycle plugins. -// Implement one or more of the provider interfaces below. -// Lifecycle provides plugin lifecycle hooks. -// This capability allows plugins to perform initialization when loaded, -// such as establishing connections, starting background processes, or -// validating configuration. -// -// The OnInit function is called once when the plugin is loaded, and is NOT -// called when the plugin is hot-reloaded. Plugins should not assume this -// function will be called on every startup. -type Lifecycle interface{} - -// InitProvider provides the OnInit function. -type InitProvider interface { - OnInit() error -} // Internal implementation holders -var ( - initImpl func() error -) - -// Register registers a lifecycle implementation. -// The implementation is checked for optional provider interfaces. -func Register(impl Lifecycle) { - if p, ok := impl.(InitProvider); ok { - initImpl = p.OnInit - } -} - -// 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_on_init -func _NdOnInit() int32 { - if initImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - if err := initImpl(); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} diff --git a/plugins/pdk/go/lifecycle/lifecycle_stub.go b/plugins/pdk/go/lifecycle/lifecycle_stub.go deleted file mode 100644 index 8d392f6c6..000000000 --- a/plugins/pdk/go/lifecycle/lifecycle_stub.go +++ /dev/null @@ -1,33 +0,0 @@ -// 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 lifecycle - -// Lifecycle is the marker interface for lifecycle plugins. -// Implement one or more of the provider interfaces below. -// Lifecycle provides plugin lifecycle hooks. -// This capability allows plugins to perform initialization when loaded, -// such as establishing connections, starting background processes, or -// validating configuration. -// -// The OnInit function is called once when the plugin is loaded, and is NOT -// called when the plugin is hot-reloaded. Plugins should not assume this -// function will be called on every startup. -type Lifecycle interface{} - -// InitProvider provides the OnInit function. -type InitProvider interface { - OnInit() 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(_ Lifecycle) {} diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go deleted file mode 100644 index b3c7f29ac..000000000 --- a/plugins/pdk/go/lyrics/lyrics.go +++ /dev/null @@ -1,118 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the Lyrics capability. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package lyrics - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" - "github.com/navidrome/navidrome/plugins/pdk/go/types" -) - -// Deprecated: use types.ArtistRef. -type ArtistRef = types.ArtistRef - -// GetLyricsRequest contains the track information for lyrics lookup. -type GetLyricsRequest struct { - Track TrackInfo `json:"track"` -} - -// GetLyricsResponse contains the lyrics returned by the plugin. -type GetLyricsResponse struct { - Lyrics []LyricsText `json:"lyrics"` -} - -// LyricsText represents a single set of lyrics in raw text format. -// Text can be plain text or LRC format — Navidrome will parse it. -type LyricsText struct { - Lang string `json:"lang,omitempty"` - Text string `json:"text"` -} - -// TrackInfo contains track metadata. -type TrackInfo struct { - // ID is the internal Navidrome track ID. - ID string `json:"id"` - // Title is the track title. - Title string `json:"title"` - // Album is the album name. - Album string `json:"album"` - // Artist is the formatted artist name for display (e.g., "Artist1 • Artist2"). - Artist string `json:"artist"` - // AlbumArtist is the formatted album artist name for display. - AlbumArtist string `json:"albumArtist"` - // Artists is the list of track artists. - Artists []types.ArtistRef `json:"artists"` - // AlbumArtists is the list of album artists. - AlbumArtists []types.ArtistRef `json:"albumArtists"` - // Duration is the track duration in seconds. - Duration float32 `json:"duration"` - // TrackNumber is the track number on the album. - TrackNumber int32 `json:"trackNumber"` - // DiscNumber is the disc number. - DiscNumber int32 `json:"discNumber"` - // MBZRecordingID is the MusicBrainz recording ID. - MBZRecordingID string `json:"mbzRecordingId,omitempty"` - // MBZAlbumID is the MusicBrainz album/release ID. - MBZAlbumID string `json:"mbzAlbumId,omitempty"` - // MBZReleaseGroupID is the MusicBrainz release group ID. - 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. -// Lyrics provides lyrics for a given track from external sources. -type Lyrics interface { - // GetLyrics - GetLyrics(GetLyricsRequest) (GetLyricsResponse, error) -} // Internal implementation holders -var ( - lyricsImpl func(GetLyricsRequest) (GetLyricsResponse, error) -) - -// Register registers a lyrics implementation. -// All methods are required. -func Register(impl Lyrics) { - lyricsImpl = impl.GetLyrics -} - -// 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_lyrics_get_lyrics -func _NdLyricsGetLyrics() int32 { - if lyricsImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input GetLyricsRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - output, err := lyricsImpl(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/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go deleted file mode 100644 index 76c54d2e5..000000000 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ /dev/null @@ -1,83 +0,0 @@ -// 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 lyrics - -import "github.com/navidrome/navidrome/plugins/pdk/go/types" - -// Deprecated: use types.ArtistRef. -type ArtistRef = types.ArtistRef - -// GetLyricsRequest contains the track information for lyrics lookup. -type GetLyricsRequest struct { - Track TrackInfo `json:"track"` -} - -// GetLyricsResponse contains the lyrics returned by the plugin. -type GetLyricsResponse struct { - Lyrics []LyricsText `json:"lyrics"` -} - -// LyricsText represents a single set of lyrics in raw text format. -// Text can be plain text or LRC format — Navidrome will parse it. -type LyricsText struct { - Lang string `json:"lang,omitempty"` - Text string `json:"text"` -} - -// TrackInfo contains track metadata. -type TrackInfo struct { - // ID is the internal Navidrome track ID. - ID string `json:"id"` - // Title is the track title. - Title string `json:"title"` - // Album is the album name. - Album string `json:"album"` - // Artist is the formatted artist name for display (e.g., "Artist1 • Artist2"). - Artist string `json:"artist"` - // AlbumArtist is the formatted album artist name for display. - AlbumArtist string `json:"albumArtist"` - // Artists is the list of track artists. - Artists []types.ArtistRef `json:"artists"` - // AlbumArtists is the list of album artists. - AlbumArtists []types.ArtistRef `json:"albumArtists"` - // Duration is the track duration in seconds. - Duration float32 `json:"duration"` - // TrackNumber is the track number on the album. - TrackNumber int32 `json:"trackNumber"` - // DiscNumber is the disc number. - DiscNumber int32 `json:"discNumber"` - // MBZRecordingID is the MusicBrainz recording ID. - MBZRecordingID string `json:"mbzRecordingId,omitempty"` - // MBZAlbumID is the MusicBrainz album/release ID. - MBZAlbumID string `json:"mbzAlbumId,omitempty"` - // MBZReleaseGroupID is the MusicBrainz release group ID. - 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. -// Lyrics provides lyrics for a given track from external sources. -type Lyrics interface { - // GetLyrics - GetLyrics(GetLyricsRequest) (GetLyricsResponse, 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(_ Lyrics) {} diff --git a/plugins/pdk/go/metadata/metadata.go b/plugins/pdk/go/metadata/metadata.go deleted file mode 100644 index 57546352e..000000000 --- a/plugins/pdk/go/metadata/metadata.go +++ /dev/null @@ -1,611 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the MetadataAgent capability. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package metadata - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" - "github.com/navidrome/navidrome/plugins/pdk/go/types" -) - -// Deprecated: use types.ArtistRef. -type ArtistRef = types.ArtistRef - -// Deprecated: use types.SongRef. -type SongRef = types.SongRef - -// MetadataAgentError represents an error type for metadata agent operations. -type MetadataAgentError string - -const ( - // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. - // Append ":<seconds>" inside the parentheses to request a specific delay. - MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" -) - -// Error implements the error interface for MetadataAgentError. -func (e MetadataAgentError) Error() string { return string(e) } - -// AlbumImagesResponse is the response for GetAlbumImages. -type AlbumImagesResponse struct { - // Images is the list of album images. - Images []ImageInfo `json:"images"` -} - -// AlbumInfoResponse is the response for GetAlbumInfo. -type AlbumInfoResponse struct { - // Name is the album name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the album. - MBID string `json:"mbid"` - // Description is the album description/notes. - Description string `json:"description"` - // URL is the external URL for the album. - URL string `json:"url"` -} - -// AlbumRequest is the common request for album-related functions. -type AlbumRequest struct { - // Name is the album name. - Name string `json:"name"` - // Artist is the album artist name. - Artist string `json:"artist"` - // MBID is the MusicBrainz ID for the album (if known). - MBID string `json:"mbid,omitempty"` -} - -// ArtistBiographyResponse is the response for GetArtistBiography. -type ArtistBiographyResponse struct { - // Biography is the artist biography text. - Biography string `json:"biography"` -} - -// ArtistImagesResponse is the response for GetArtistImages. -type ArtistImagesResponse struct { - // Images is the list of artist images. - Images []ImageInfo `json:"images"` -} - -// ArtistMBIDRequest is the request for GetArtistMBID. -type ArtistMBIDRequest struct { - // ID is the internal Navidrome artist ID. - ID string `json:"id"` - // Name is the artist name. - Name string `json:"name"` -} - -// ArtistMBIDResponse is the response for GetArtistMBID. -type ArtistMBIDResponse struct { - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid"` -} - -// ArtistRequest is the common request for artist-related functions. -type ArtistRequest struct { - // ID is the internal Navidrome artist ID. - ID string `json:"id"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist (if known). - MBID string `json:"mbid,omitempty"` -} - -// ArtistURLResponse is the response for GetArtistURL. -type ArtistURLResponse struct { - // URL is the external URL for the artist. - URL string `json:"url"` -} - -// ImageInfo represents an image with URL and size. -type ImageInfo struct { - // URL is the URL of the image. - URL string `json:"url"` - // Size is the size of the image in pixels (width or height). - Size int32 `json:"size"` -} - -// SimilarArtistsRequest is the request for GetSimilarArtists. -type SimilarArtistsRequest struct { - // ID is the internal Navidrome artist ID. - ID string `json:"id"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist (if known). - MBID string `json:"mbid,omitempty"` - // Limit is the maximum number of similar artists to return. - Limit int32 `json:"limit"` -} - -// SimilarArtistsResponse is the response for GetSimilarArtists. -type SimilarArtistsResponse struct { - // Artists is the list of similar artists. - Artists []types.ArtistRef `json:"artists"` -} - -// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. -type SimilarSongsByAlbumRequest struct { - // ID is the internal Navidrome album ID. - ID string `json:"id"` - // Name is the album name. - Name string `json:"name"` - // Artist is the album artist name. - Artist string `json:"artist"` - // MBID is the MusicBrainz release ID (if known). - MBID string `json:"mbid,omitempty"` - // Count is the maximum number of similar songs to return. - Count int32 `json:"count"` -} - -// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist. -type SimilarSongsByArtistRequest struct { - // ID is the internal Navidrome artist ID. - ID string `json:"id"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz artist ID (if known). - MBID string `json:"mbid,omitempty"` - // Count is the maximum number of similar songs to return. - Count int32 `json:"count"` -} - -// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack. -type SimilarSongsByTrackRequest struct { - // ID is the internal Navidrome mediafile ID. - ID string `json:"id"` - // Name is the track title. - Name string `json:"name"` - // Artist is the artist name. - Artist string `json:"artist"` - // MBID is the MusicBrainz recording ID (if known). - MBID string `json:"mbid,omitempty"` - // Count is the maximum number of similar songs to return. - Count int32 `json:"count"` -} - -// SimilarSongsResponse is the response for GetSimilarSongsBy* functions. -type SimilarSongsResponse struct { - // Songs is the list of similar songs. - Songs []types.SongRef `json:"songs"` -} - -// TopSongsRequest is the request for GetArtistTopSongs. -type TopSongsRequest struct { - // ID is the internal Navidrome artist ID. - ID string `json:"id"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist (if known). - MBID string `json:"mbid,omitempty"` - // Count is the maximum number of top songs to return. - Count int32 `json:"count"` -} - -// TopSongsResponse is the response for GetArtistTopSongs. -type TopSongsResponse struct { - // Songs is the list of top songs. - Songs []types.SongRef `json:"songs"` -} - -// Metadata is the marker interface for metadata plugins. -// Implement one or more of the provider interfaces below. -// MetadataAgent provides artist and album metadata retrieval. -// This capability allows plugins to provide external metadata for artists and albums, -// such as biographies, images, similar artists, and top songs. -// -// Plugins implementing this capability can choose which methods to implement. -// Each method is optional - plugins only need to provide the functionality they support. -// -// To say "no data for this item", return a nil response and a nil error. Return an error only when -// the plugin itself failed, because Navidrome retries failed calls with backoff. -type Metadata interface{} - -// ArtistMBIDProvider provides the GetArtistMBID function. -type ArtistMBIDProvider interface { - GetArtistMBID(ArtistMBIDRequest) (*ArtistMBIDResponse, error) -} - -// ArtistURLProvider provides the GetArtistURL function. -type ArtistURLProvider interface { - GetArtistURL(ArtistRequest) (*ArtistURLResponse, error) -} - -// ArtistBiographyProvider provides the GetArtistBiography function. -type ArtistBiographyProvider interface { - GetArtistBiography(ArtistRequest) (*ArtistBiographyResponse, error) -} - -// SimilarArtistsProvider provides the GetSimilarArtists function. -type SimilarArtistsProvider interface { - GetSimilarArtists(SimilarArtistsRequest) (*SimilarArtistsResponse, error) -} - -// ArtistImagesProvider provides the GetArtistImages function. -type ArtistImagesProvider interface { - GetArtistImages(ArtistRequest) (*ArtistImagesResponse, error) -} - -// ArtistTopSongsProvider provides the GetArtistTopSongs function. -type ArtistTopSongsProvider interface { - GetArtistTopSongs(TopSongsRequest) (*TopSongsResponse, error) -} - -// AlbumInfoProvider provides the GetAlbumInfo function. -type AlbumInfoProvider interface { - GetAlbumInfo(AlbumRequest) (*AlbumInfoResponse, error) -} - -// AlbumImagesProvider provides the GetAlbumImages function. -type AlbumImagesProvider interface { - GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error) -} - -// SimilarSongsByTrackProvider provides the GetSimilarSongsByTrack function. -type SimilarSongsByTrackProvider interface { - GetSimilarSongsByTrack(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error) -} - -// SimilarSongsByAlbumProvider provides the GetSimilarSongsByAlbum function. -type SimilarSongsByAlbumProvider interface { - GetSimilarSongsByAlbum(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error) -} - -// SimilarSongsByArtistProvider provides the GetSimilarSongsByArtist function. -type SimilarSongsByArtistProvider interface { - GetSimilarSongsByArtist(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error) -} // Internal implementation holders -var ( - artistMBIDImpl func(ArtistMBIDRequest) (*ArtistMBIDResponse, error) - artistURLImpl func(ArtistRequest) (*ArtistURLResponse, error) - artistBiographyImpl func(ArtistRequest) (*ArtistBiographyResponse, error) - similarArtistsImpl func(SimilarArtistsRequest) (*SimilarArtistsResponse, error) - artistImagesImpl func(ArtistRequest) (*ArtistImagesResponse, error) - artistTopSongsImpl func(TopSongsRequest) (*TopSongsResponse, error) - albumInfoImpl func(AlbumRequest) (*AlbumInfoResponse, error) - albumImagesImpl func(AlbumRequest) (*AlbumImagesResponse, error) - similarSongsByTrackImpl func(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error) - similarSongsByAlbumImpl func(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error) - similarSongsByArtistImpl func(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error) -) - -// Register registers a metadata implementation. -// The implementation is checked for optional provider interfaces. -func Register(impl Metadata) { - if p, ok := impl.(ArtistMBIDProvider); ok { - artistMBIDImpl = p.GetArtistMBID - } - if p, ok := impl.(ArtistURLProvider); ok { - artistURLImpl = p.GetArtistURL - } - if p, ok := impl.(ArtistBiographyProvider); ok { - artistBiographyImpl = p.GetArtistBiography - } - if p, ok := impl.(SimilarArtistsProvider); ok { - similarArtistsImpl = p.GetSimilarArtists - } - if p, ok := impl.(ArtistImagesProvider); ok { - artistImagesImpl = p.GetArtistImages - } - if p, ok := impl.(ArtistTopSongsProvider); ok { - artistTopSongsImpl = p.GetArtistTopSongs - } - if p, ok := impl.(AlbumInfoProvider); ok { - albumInfoImpl = p.GetAlbumInfo - } - if p, ok := impl.(AlbumImagesProvider); ok { - albumImagesImpl = p.GetAlbumImages - } - if p, ok := impl.(SimilarSongsByTrackProvider); ok { - similarSongsByTrackImpl = p.GetSimilarSongsByTrack - } - if p, ok := impl.(SimilarSongsByAlbumProvider); ok { - similarSongsByAlbumImpl = p.GetSimilarSongsByAlbum - } - if p, ok := impl.(SimilarSongsByArtistProvider); ok { - similarSongsByArtistImpl = p.GetSimilarSongsByArtist - } -} - -// NotImplementedCode is the standard return code for unimplemented functions. -// The host recognizes this and skips the plugin gracefully. -const NotImplementedCode int32 = -2 - -//go:wasmexport nd_get_artist_mbid -func _NdGetArtistMbid() int32 { - if artistMBIDImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input ArtistMBIDRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - output, err := artistMBIDImpl(input) - if err != nil { - pdk.SetError(err) - return -1 - } - - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} - -//go:wasmexport nd_get_artist_url -func _NdGetArtistUrl() int32 { - if artistURLImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input ArtistRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - output, err := artistURLImpl(input) - if err != nil { - pdk.SetError(err) - return -1 - } - - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} - -//go:wasmexport nd_get_artist_biography -func _NdGetArtistBiography() int32 { - if artistBiographyImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input ArtistRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - output, err := artistBiographyImpl(input) - if err != nil { - pdk.SetError(err) - return -1 - } - - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} - -//go:wasmexport nd_get_similar_artists -func _NdGetSimilarArtists() int32 { - if similarArtistsImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input SimilarArtistsRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - output, err := similarArtistsImpl(input) - if err != nil { - pdk.SetError(err) - return -1 - } - - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} - -//go:wasmexport nd_get_artist_images -func _NdGetArtistImages() int32 { - if artistImagesImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input ArtistRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - output, err := artistImagesImpl(input) - if err != nil { - pdk.SetError(err) - return -1 - } - - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} - -//go:wasmexport nd_get_artist_top_songs -func _NdGetArtistTopSongs() int32 { - if artistTopSongsImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input TopSongsRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - output, err := artistTopSongsImpl(input) - if err != nil { - pdk.SetError(err) - return -1 - } - - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} - -//go:wasmexport nd_get_album_info -func _NdGetAlbumInfo() int32 { - if albumInfoImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input AlbumRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - output, err := albumInfoImpl(input) - if err != nil { - pdk.SetError(err) - return -1 - } - - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} - -//go:wasmexport nd_get_album_images -func _NdGetAlbumImages() int32 { - if albumImagesImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input AlbumRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - output, err := albumImagesImpl(input) - if err != nil { - pdk.SetError(err) - return -1 - } - - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} - -//go:wasmexport nd_get_similar_songs_by_track -func _NdGetSimilarSongsByTrack() int32 { - if similarSongsByTrackImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input SimilarSongsByTrackRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - output, err := similarSongsByTrackImpl(input) - if err != nil { - pdk.SetError(err) - return -1 - } - - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} - -//go:wasmexport nd_get_similar_songs_by_album -func _NdGetSimilarSongsByAlbum() int32 { - if similarSongsByAlbumImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input SimilarSongsByAlbumRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - output, err := similarSongsByAlbumImpl(input) - if err != nil { - pdk.SetError(err) - return -1 - } - - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} - -//go:wasmexport nd_get_similar_songs_by_artist -func _NdGetSimilarSongsByArtist() int32 { - if similarSongsByArtistImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input SimilarSongsByArtistRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - output, err := similarSongsByArtistImpl(input) - if err != nil { - pdk.SetError(err) - return -1 - } - - if err := pdk.OutputJSON(output); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} diff --git a/plugins/pdk/go/metadata/metadata_stub.go b/plugins/pdk/go/metadata/metadata_stub.go deleted file mode 100644 index f979419a9..000000000 --- a/plugins/pdk/go/metadata/metadata_stub.go +++ /dev/null @@ -1,264 +0,0 @@ -// 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 metadata - -import "github.com/navidrome/navidrome/plugins/pdk/go/types" - -// Deprecated: use types.ArtistRef. -type ArtistRef = types.ArtistRef - -// Deprecated: use types.SongRef. -type SongRef = types.SongRef - -// MetadataAgentError represents an error type for metadata agent operations. -type MetadataAgentError string - -const ( - // MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. - // Append ":<seconds>" inside the parentheses to request a specific delay. - MetadataAgentErrorRetryLater MetadataAgentError = "agent(retry_later)" -) - -// Error implements the error interface for MetadataAgentError. -func (e MetadataAgentError) Error() string { return string(e) } - -// AlbumImagesResponse is the response for GetAlbumImages. -type AlbumImagesResponse struct { - // Images is the list of album images. - Images []ImageInfo `json:"images"` -} - -// AlbumInfoResponse is the response for GetAlbumInfo. -type AlbumInfoResponse struct { - // Name is the album name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the album. - MBID string `json:"mbid"` - // Description is the album description/notes. - Description string `json:"description"` - // URL is the external URL for the album. - URL string `json:"url"` -} - -// AlbumRequest is the common request for album-related functions. -type AlbumRequest struct { - // Name is the album name. - Name string `json:"name"` - // Artist is the album artist name. - Artist string `json:"artist"` - // MBID is the MusicBrainz ID for the album (if known). - MBID string `json:"mbid,omitempty"` -} - -// ArtistBiographyResponse is the response for GetArtistBiography. -type ArtistBiographyResponse struct { - // Biography is the artist biography text. - Biography string `json:"biography"` -} - -// ArtistImagesResponse is the response for GetArtistImages. -type ArtistImagesResponse struct { - // Images is the list of artist images. - Images []ImageInfo `json:"images"` -} - -// ArtistMBIDRequest is the request for GetArtistMBID. -type ArtistMBIDRequest struct { - // ID is the internal Navidrome artist ID. - ID string `json:"id"` - // Name is the artist name. - Name string `json:"name"` -} - -// ArtistMBIDResponse is the response for GetArtistMBID. -type ArtistMBIDResponse struct { - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid"` -} - -// ArtistRequest is the common request for artist-related functions. -type ArtistRequest struct { - // ID is the internal Navidrome artist ID. - ID string `json:"id"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist (if known). - MBID string `json:"mbid,omitempty"` -} - -// ArtistURLResponse is the response for GetArtistURL. -type ArtistURLResponse struct { - // URL is the external URL for the artist. - URL string `json:"url"` -} - -// ImageInfo represents an image with URL and size. -type ImageInfo struct { - // URL is the URL of the image. - URL string `json:"url"` - // Size is the size of the image in pixels (width or height). - Size int32 `json:"size"` -} - -// SimilarArtistsRequest is the request for GetSimilarArtists. -type SimilarArtistsRequest struct { - // ID is the internal Navidrome artist ID. - ID string `json:"id"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist (if known). - MBID string `json:"mbid,omitempty"` - // Limit is the maximum number of similar artists to return. - Limit int32 `json:"limit"` -} - -// SimilarArtistsResponse is the response for GetSimilarArtists. -type SimilarArtistsResponse struct { - // Artists is the list of similar artists. - Artists []types.ArtistRef `json:"artists"` -} - -// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. -type SimilarSongsByAlbumRequest struct { - // ID is the internal Navidrome album ID. - ID string `json:"id"` - // Name is the album name. - Name string `json:"name"` - // Artist is the album artist name. - Artist string `json:"artist"` - // MBID is the MusicBrainz release ID (if known). - MBID string `json:"mbid,omitempty"` - // Count is the maximum number of similar songs to return. - Count int32 `json:"count"` -} - -// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist. -type SimilarSongsByArtistRequest struct { - // ID is the internal Navidrome artist ID. - ID string `json:"id"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz artist ID (if known). - MBID string `json:"mbid,omitempty"` - // Count is the maximum number of similar songs to return. - Count int32 `json:"count"` -} - -// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack. -type SimilarSongsByTrackRequest struct { - // ID is the internal Navidrome mediafile ID. - ID string `json:"id"` - // Name is the track title. - Name string `json:"name"` - // Artist is the artist name. - Artist string `json:"artist"` - // MBID is the MusicBrainz recording ID (if known). - MBID string `json:"mbid,omitempty"` - // Count is the maximum number of similar songs to return. - Count int32 `json:"count"` -} - -// SimilarSongsResponse is the response for GetSimilarSongsBy* functions. -type SimilarSongsResponse struct { - // Songs is the list of similar songs. - Songs []types.SongRef `json:"songs"` -} - -// TopSongsRequest is the request for GetArtistTopSongs. -type TopSongsRequest struct { - // ID is the internal Navidrome artist ID. - ID string `json:"id"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist (if known). - MBID string `json:"mbid,omitempty"` - // Count is the maximum number of top songs to return. - Count int32 `json:"count"` -} - -// TopSongsResponse is the response for GetArtistTopSongs. -type TopSongsResponse struct { - // Songs is the list of top songs. - Songs []types.SongRef `json:"songs"` -} - -// Metadata is the marker interface for metadata plugins. -// Implement one or more of the provider interfaces below. -// MetadataAgent provides artist and album metadata retrieval. -// This capability allows plugins to provide external metadata for artists and albums, -// such as biographies, images, similar artists, and top songs. -// -// Plugins implementing this capability can choose which methods to implement. -// Each method is optional - plugins only need to provide the functionality they support. -// -// To say "no data for this item", return a nil response and a nil error. Return an error only when -// the plugin itself failed, because Navidrome retries failed calls with backoff. -type Metadata interface{} - -// ArtistMBIDProvider provides the GetArtistMBID function. -type ArtistMBIDProvider interface { - GetArtistMBID(ArtistMBIDRequest) (*ArtistMBIDResponse, error) -} - -// ArtistURLProvider provides the GetArtistURL function. -type ArtistURLProvider interface { - GetArtistURL(ArtistRequest) (*ArtistURLResponse, error) -} - -// ArtistBiographyProvider provides the GetArtistBiography function. -type ArtistBiographyProvider interface { - GetArtistBiography(ArtistRequest) (*ArtistBiographyResponse, error) -} - -// SimilarArtistsProvider provides the GetSimilarArtists function. -type SimilarArtistsProvider interface { - GetSimilarArtists(SimilarArtistsRequest) (*SimilarArtistsResponse, error) -} - -// ArtistImagesProvider provides the GetArtistImages function. -type ArtistImagesProvider interface { - GetArtistImages(ArtistRequest) (*ArtistImagesResponse, error) -} - -// ArtistTopSongsProvider provides the GetArtistTopSongs function. -type ArtistTopSongsProvider interface { - GetArtistTopSongs(TopSongsRequest) (*TopSongsResponse, error) -} - -// AlbumInfoProvider provides the GetAlbumInfo function. -type AlbumInfoProvider interface { - GetAlbumInfo(AlbumRequest) (*AlbumInfoResponse, error) -} - -// AlbumImagesProvider provides the GetAlbumImages function. -type AlbumImagesProvider interface { - GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error) -} - -// SimilarSongsByTrackProvider provides the GetSimilarSongsByTrack function. -type SimilarSongsByTrackProvider interface { - GetSimilarSongsByTrack(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error) -} - -// SimilarSongsByAlbumProvider provides the GetSimilarSongsByAlbum function. -type SimilarSongsByAlbumProvider interface { - GetSimilarSongsByAlbum(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error) -} - -// SimilarSongsByArtistProvider provides the GetSimilarSongsByArtist function. -type SimilarSongsByArtistProvider interface { - GetSimilarSongsByArtist(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error) -} - -// 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(_ Metadata) {} diff --git a/plugins/pdk/go/pdk/example_test.go b/plugins/pdk/go/pdk/example_test.go deleted file mode 100644 index bf98eba2f..000000000 --- a/plugins/pdk/go/pdk/example_test.go +++ /dev/null @@ -1,314 +0,0 @@ -// Example test demonstrating how to use the PDK mock for unit testing. -// This file is only compiled for non-WASM builds. -// -//go:build !wasip1 - -package pdk_test - -import ( - "testing" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" - "github.com/stretchr/testify/mock" -) - -// ExamplePlugin demonstrates a simple plugin that uses PDK functions. -type ExamplePlugin struct{} - -// ProcessMessage reads input, logs it, and outputs a response. -func (p *ExamplePlugin) ProcessMessage() error { - // Get configuration - prefix, ok := pdk.GetConfig("message_prefix") - if !ok { - prefix = "Hello" - } - - // Read input - message := pdk.InputString() - - // Log the message - pdk.Log(pdk.LogInfo, "Processing: "+message) - - // Output the response - pdk.OutputString(prefix + ", " + message + "!") - - return nil -} - -func TestExamplePlugin_ProcessMessage(t *testing.T) { - // Reset mock state before the test - pdk.ResetMock() - - // Set up expectations - pdk.PDKMock.On("GetConfig", "message_prefix").Return("Hi", true) - pdk.PDKMock.On("InputString").Return("World") - pdk.PDKMock.On("Log", pdk.LogInfo, "Processing: World").Return() - pdk.PDKMock.On("OutputString", "Hi, World!").Return() - - // Call the plugin function - plugin := &ExamplePlugin{} - err := plugin.ProcessMessage() - - // Verify no error - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Verify all expected calls were made - pdk.PDKMock.AssertExpectations(t) -} - -func TestExamplePlugin_ProcessMessage_DefaultPrefix(t *testing.T) { - // Reset mock state before the test - pdk.ResetMock() - - // Set up expectations - config key not found - pdk.PDKMock.On("GetConfig", "message_prefix").Return("", false) - pdk.PDKMock.On("InputString").Return("Test") - pdk.PDKMock.On("Log", pdk.LogInfo, "Processing: Test").Return() - pdk.PDKMock.On("OutputString", "Hello, Test!").Return() - - // Call the plugin function - plugin := &ExamplePlugin{} - err := plugin.ProcessMessage() - - // Verify no error - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Verify all expected calls were made - pdk.PDKMock.AssertExpectations(t) -} - -// Example of testing JSON input/output -type Request struct { - Name string `json:"name"` - Count int `json:"count"` -} - -type Response struct { - Message string `json:"message"` - Total int `json:"total"` -} - -func ProcessJSONRequest() error { - var req Request - if err := pdk.InputJSON(&req); err != nil { - pdk.SetError(err) - return err - } - - resp := Response{ - Message: "Hello, " + req.Name, - Total: req.Count * 2, - } - - return pdk.OutputJSON(resp) -} - -func TestProcessJSONRequest(t *testing.T) { - pdk.ResetMock() - - // Mock InputJSON to populate the request struct - pdk.PDKMock.On("InputJSON", mock.AnythingOfType("*pdk_test.Request")). - Return(nil). - Run(func(args mock.Arguments) { - req := args.Get(0).(*Request) - req.Name = "Alice" - req.Count = 5 - }) - - // Expect OutputJSON with the correct response - pdk.PDKMock.On("OutputJSON", Response{ - Message: "Hello, Alice", - Total: 10, - }).Return(nil) - - // Call the function - err := ProcessJSONRequest() - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - pdk.PDKMock.AssertExpectations(t) -} - -// ============================================================================= -// HTTP requests go through the host HTTP service (host.HTTPSend), not -// pdk.NewHTTPRequest, which Navidrome does not enable. -// ============================================================================= - -// FetchData demonstrates a plugin function that makes an HTTP request. -func FetchData(url string) ([]byte, error) { - resp, err := host.HTTPSend(host.HTTPRequest{ - Method: "GET", - URL: url, - Headers: map[string]string{ - "Accept": "application/json", - "User-Agent": "MyPlugin/1.0", - }, - }) - if err != nil { - return nil, err - } - - if resp.StatusCode != 200 { - return nil, nil - } - return resp.Body, nil -} - -func TestFetchData(t *testing.T) { - host.HTTPMock.ExpectedCalls = nil - - expectedBody := []byte(`{"result": "success"}`) - host.HTTPMock.On("Send", mock.MatchedBy(func(req host.HTTPRequest) bool { - return req.Method == "GET" && req.URL == "https://api.example.com/data" && - req.Headers["Accept"] == "application/json" - })).Return(&host.HTTPResponse{StatusCode: 200, Body: expectedBody}, nil) - - body, err := FetchData("https://api.example.com/data") - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if string(body) != string(expectedBody) { - t.Errorf("expected body %q, got %q", expectedBody, body) - } - - host.HTTPMock.AssertExpectations(t) -} - -func TestFetchData_NonOKStatus(t *testing.T) { - host.HTTPMock.ExpectedCalls = nil - - host.HTTPMock.On("Send", mock.Anything). - Return(&host.HTTPResponse{StatusCode: 404, Body: []byte("Not Found")}, nil) - - body, err := FetchData("https://api.example.com/missing") - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Should return nil for non-200 status - if body != nil { - t.Errorf("expected nil body for 404, got %q", body) - } - - host.HTTPMock.AssertExpectations(t) -} - -// ProcessMemoryData demonstrates working with Memory type. -func ProcessMemoryData(mem pdk.Memory) string { - // Memory methods work directly on the stub - no mocking needed! - data := mem.ReadBytes() - return "Processed " + string(data) + " (length: " + formatUint64(mem.Length()) + ")" -} - -func formatUint64(n uint64) string { - return string(rune('0' + n%10)) // Simplified for demo -} - -func TestProcessMemoryData(t *testing.T) { - // Create stub memory with test data - no mocking needed! - mem := pdk.NewStubMemory(0, 5, []byte("hello")) - - result := ProcessMemoryData(mem) - - expected := "Processed hello (length: 5)" - if result != expected { - t.Errorf("expected %q, got %q", expected, result) - } -} - -// StoreAndRetrieve demonstrates Memory Store/Load methods. -func TestMemoryStoreAndLoad(t *testing.T) { - // Create empty memory - mem := pdk.NewStubMemory(100, 0, nil) - - // Store data - works directly, no mock needed - mem.Store([]byte("test data")) - - // Verify the data was stored - if mem.Length() != 9 { - t.Errorf("expected length 9, got %d", mem.Length()) - } - - // Load into buffer - buffer := make([]byte, 9) - mem.Load(buffer) - - if string(buffer) != "test data" { - t.Errorf("expected 'test data', got %q", buffer) - } - - // Free the memory - mem.Free() - - if mem.Length() != 0 { - t.Errorf("expected length 0 after free, got %d", mem.Length()) - } -} - -// HTTPMethodString demonstrates that HTTPMethod.String() works without mocking. -func TestHTTPMethodString(t *testing.T) { - // These work directly - no mocking needed! - tests := []struct { - method pdk.HTTPMethod - expected string - }{ - {pdk.MethodGet, "GET"}, - {pdk.MethodPost, "POST"}, - {pdk.MethodPut, "PUT"}, - {pdk.MethodDelete, "DELETE"}, - } - - for _, tc := range tests { - result := tc.method.String() - if result != tc.expected { - t.Errorf("expected %q for method %d, got %q", tc.expected, tc.method, result) - } - } -} - -// PostJSON demonstrates a more complex HTTP request with body. -func PostJSON(url string, data []byte) (int, error) { - resp, err := host.HTTPSend(host.HTTPRequest{ - Method: "POST", - URL: url, - Headers: map[string]string{"Content-Type": "application/json"}, - Body: data, - }) - if err != nil { - return 0, err - } - return int(resp.StatusCode), nil -} - -func TestPostJSON(t *testing.T) { - host.HTTPMock.ExpectedCalls = nil - - host.HTTPMock.On("Send", host.HTTPRequest{ - Method: "POST", - URL: "https://api.example.com/items", - Headers: map[string]string{"Content-Type": "application/json"}, - Body: []byte(`{"name":"test"}`), - }).Return(&host.HTTPResponse{StatusCode: 201}, nil) - - status, err := PostJSON("https://api.example.com/items", []byte(`{"name":"test"}`)) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if status != 201 { - t.Errorf("expected status 201, got %d", status) - } - - host.HTTPMock.AssertExpectations(t) -} diff --git a/plugins/pdk/go/pdk/pdk.go b/plugins/pdk/go/pdk/pdk.go deleted file mode 100644 index 456a238f5..000000000 --- a/plugins/pdk/go/pdk/pdk.go +++ /dev/null @@ -1,206 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains wrapper functions for the extism/go-pdk package. -// For WASM builds, it provides type aliases and function wrappers that delegate -// to the real extism/go-pdk package with zero overhead. -// -//go:build wasip1 - -package pdk - -import ( - extism "github.com/extism/go-pdk" -) - -// Type aliases - zero overhead, full compatibility -type HTTPMethod = extism.HTTPMethod -type HTTPRequest = extism.HTTPRequest -type HTTPRequestMeta = extism.HTTPRequestMeta -type HTTPResponse = extism.HTTPResponse -type LogLevel = extism.LogLevel -type Memory = extism.Memory - -// Constants - -const ( - LogDebug = extism.LogDebug - LogError = extism.LogError - LogInfo = extism.LogInfo - LogTrace = extism.LogTrace - LogWarn = extism.LogWarn -) - -const ( - MethodConnect = extism.MethodConnect - MethodDelete = extism.MethodDelete - MethodGet = extism.MethodGet - MethodHead = extism.MethodHead - MethodOptions = extism.MethodOptions - MethodPatch = extism.MethodPatch - MethodPost = extism.MethodPost - MethodPut = extism.MethodPut - MethodTrace = extism.MethodTrace -) - -// Functions -func Allocate(length int) Memory { - return extism.Allocate(length) -} -func AllocateBytes(data []byte) Memory { - return extism.AllocateBytes(data) -} - -// AllocateJSON AllocateJSON allocates and saves the type `any` into Memory on the host. -func AllocateJSON(v any) (Memory, error) { - return extism.AllocateJSON(v) -} - -// AllocateString AllocateString allocates and saves the UTF-8 string `data` into Memory on the host. -func AllocateString(data string) Memory { - return extism.AllocateString(data) -} - -// FindMemory FindMemory finds the host memory block at the given `offset`. -func FindMemory(offset uint64) Memory { - return extism.FindMemory(offset) -} - -// GetConfig GetConfig returns the config string associated with `key` (if any). -func GetConfig(key string) (string, bool) { - return extism.GetConfig(key) -} - -// GetVar GetVar returns the byte slice (if any) associated with `key`. -func GetVar(key string) []byte { - return extism.GetVar(key) -} - -// GetVarInt GetVarInt returns the int associated with `key` (or 0 if none). -func GetVarInt(key string) int { - return extism.GetVarInt(key) -} - -// Input Input returns a slice of bytes from the host. -func Input() []byte { - return extism.Input() -} - -// InputJSON InputJSON returns unmartialed JSON data from the host "input". -func InputJSON(v any) error { - return extism.InputJSON(v) -} - -// InputString InputString returns the input data from the host as a UTF-8 string. -func InputString() string { - return extism.InputString() -} - -// JSONFrom JSONFrom unmarshals a `Memory` block located at `offset` from the host into the provided data `v`. -func JSONFrom(offset uint64, v any) error { - return extism.JSONFrom(offset, v) -} - -// Log Log logs the provided UTF-8 string `s` on the host using the provided log `level`. -func Log(level LogLevel, s string) { - extism.Log(level, s) -} - -// LogMemory LogMemory logs the `memory` block on the host using the provided log `level`. -func LogMemory(level LogLevel, m Memory) { - extism.LogMemory(level, m) -} - -// NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`. -// -// Deprecated: Navidrome does not enable extism's http_request host function, so every request sent this way fails. Use host.HTTPSend instead. -func NewHTTPRequest(method HTTPMethod, url string) *HTTPRequest { - return extism.NewHTTPRequest(method, url) -} -func NewMemory(offset uint64, length uint64) Memory { - return extism.NewMemory(offset, length) -} - -// Output Output sends the `data` slice of bytes to the host output. -func Output(data []byte) { - extism.Output(data) -} - -// OutputJSON OutputJSON marshals the provided data `v` as output to the host. -func OutputJSON(v any) error { - return extism.OutputJSON(v) -} - -// OutputMemory OutputMemory sends the `mem` Memory to the host output. -func OutputMemory(mem Memory) { - extism.OutputMemory(mem) -} - -// OutputString OutputString sends the UTF-8 string `s` to the host output. -func OutputString(s string) { - extism.OutputString(s) -} - -// ParamBytes ParamBytes returns bytes from Extism host memory given an offset. -func ParamBytes(offset uint64) []byte { - return extism.ParamBytes(offset) -} - -// ParamString ParamString returns UTF-8 string data from Extism host memory given an offset. -func ParamString(offset uint64) string { - return extism.ParamString(offset) -} - -// ParamU32 ParamU32 returns a uint32 from Extism host memory given an offset. -func ParamU32(offset uint64) uint32 { - return extism.ParamU32(offset) -} - -// ParamU64 ParamU64 returns a uint64 from Extism host memory given an offset. -func ParamU64(offset uint64) uint64 { - return extism.ParamU64(offset) -} - -// RemoveVar RemoveVar removes (and frees) the host variable associated with `key`. -func RemoveVar(key string) { - extism.RemoveVar(key) -} - -// ResultBytes ResultBytes allocates bytes and returns the offset in Extism host memory. -func ResultBytes(d []byte) uint64 { - return extism.ResultBytes(d) -} - -// ResultString ResultString allocates a UTF-8 string and returns the offset in Extism host memory. -func ResultString(s string) uint64 { - return extism.ResultString(s) -} - -// ResultU32 ResultU32 allocates a uint32 and returns the offset in Extism host memory. -func ResultU32(d uint32) uint64 { - return extism.ResultU32(d) -} - -// ResultU64 ResultU64 allocates a uint64 and returns the offset in Extism host memory. -func ResultU64(d uint64) uint64 { - return extism.ResultU64(d) -} - -// SetError SetError sets the host error string from `err`. -func SetError(err error) { - extism.SetError(err) -} - -// SetErrorString SetErrorString sets the host error string from `err`. -func SetErrorString(err string) { - extism.SetErrorString(err) -} - -// SetVar SetVar sets the host variable associated with `key` to the `value` byte slice. -func SetVar(key string, value []byte) { - extism.SetVar(key, value) -} - -// SetVarInt SetVarInt sets the host variable associated with `key` to the `value` int. -func SetVarInt(key string, value int) { - extism.SetVarInt(key, value) -} diff --git a/plugins/pdk/go/pdk/pdk_stub.go b/plugins/pdk/go/pdk/pdk_stub.go deleted file mode 100644 index 1a685eecc..000000000 --- a/plugins/pdk/go/pdk/pdk_stub.go +++ /dev/null @@ -1,228 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains mock implementations for non-WASM builds. -// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms. -// Plugin authors can use the exported PDKMock instance to set expectations in tests. -// -//go:build !wasip1 - -package pdk - -import "github.com/stretchr/testify/mock" - -// mockPDK is the mock implementation for testing PDK functions. -type mockPDK struct { - mock.Mock -} - -// PDKMock is the auto-instantiated mock instance for testing. -// Use this to set expectations: pdk.PDKMock.On("GetConfig", "key").Return("value", true) -var PDKMock = &mockPDK{} - -// ResetMock resets the mock to its initial state. -// Call this in test setup/teardown to ensure clean state between tests. -func ResetMock() { - PDKMock = &mockPDK{} -} - -// Functions -func Allocate(length int) Memory { - args := PDKMock.Called(length) - return args.Get(0).(Memory) -} -func AllocateBytes(data []byte) Memory { - args := PDKMock.Called(data) - return args.Get(0).(Memory) -} - -// AllocateJSON AllocateJSON allocates and saves the type `any` into Memory on the host. -func AllocateJSON(v any) (Memory, error) { - args := PDKMock.Called(v) - return args.Get(0).(Memory), args.Error(1) -} - -// AllocateString AllocateString allocates and saves the UTF-8 string `data` into Memory on the host. -func AllocateString(data string) Memory { - args := PDKMock.Called(data) - return args.Get(0).(Memory) -} - -// FindMemory FindMemory finds the host memory block at the given `offset`. -func FindMemory(offset uint64) Memory { - args := PDKMock.Called(offset) - return args.Get(0).(Memory) -} - -// GetConfig GetConfig returns the config string associated with `key` (if any). -func GetConfig(key string) (string, bool) { - args := PDKMock.Called(key) - return args.String(0), args.Bool(1) -} - -// GetVar GetVar returns the byte slice (if any) associated with `key`. -func GetVar(key string) []byte { - args := PDKMock.Called(key) - var r0 []byte - if v := args.Get(0); v != nil { - r0 = v.([]byte) - } - return r0 -} - -// GetVarInt GetVarInt returns the int associated with `key` (or 0 if none). -func GetVarInt(key string) int { - args := PDKMock.Called(key) - return args.Int(0) -} - -// Input Input returns a slice of bytes from the host. -func Input() []byte { - args := PDKMock.Called() - var r0 []byte - if v := args.Get(0); v != nil { - r0 = v.([]byte) - } - return r0 -} - -// InputJSON InputJSON returns unmartialed JSON data from the host "input". -func InputJSON(v any) error { - args := PDKMock.Called(v) - return args.Error(0) -} - -// InputString InputString returns the input data from the host as a UTF-8 string. -func InputString() string { - args := PDKMock.Called() - return args.String(0) -} - -// JSONFrom JSONFrom unmarshals a `Memory` block located at `offset` from the host into the provided data `v`. -func JSONFrom(offset uint64, v any) error { - args := PDKMock.Called(offset, v) - return args.Error(0) -} - -// Log Log logs the provided UTF-8 string `s` on the host using the provided log `level`. -func Log(level LogLevel, s string) { - PDKMock.Called(level, s) -} - -// LogMemory LogMemory logs the `memory` block on the host using the provided log `level`. -func LogMemory(level LogLevel, m Memory) { - PDKMock.Called(level, m) -} - -// NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`. -// -// Deprecated: Navidrome does not enable extism's http_request host function, so every request sent this way fails. Use host.HTTPSend instead. -func NewHTTPRequest(method HTTPMethod, url string) *HTTPRequest { - args := PDKMock.Called(method, url) - var r0 *HTTPRequest - if v := args.Get(0); v != nil { - r0 = v.(*HTTPRequest) - } - return r0 -} -func NewMemory(offset uint64, length uint64) Memory { - args := PDKMock.Called(offset, length) - return args.Get(0).(Memory) -} - -// Output Output sends the `data` slice of bytes to the host output. -func Output(data []byte) { - PDKMock.Called(data) -} - -// OutputJSON OutputJSON marshals the provided data `v` as output to the host. -func OutputJSON(v any) error { - args := PDKMock.Called(v) - return args.Error(0) -} - -// OutputMemory OutputMemory sends the `mem` Memory to the host output. -func OutputMemory(mem Memory) { - PDKMock.Called(mem) -} - -// OutputString OutputString sends the UTF-8 string `s` to the host output. -func OutputString(s string) { - PDKMock.Called(s) -} - -// ParamBytes ParamBytes returns bytes from Extism host memory given an offset. -func ParamBytes(offset uint64) []byte { - args := PDKMock.Called(offset) - var r0 []byte - if v := args.Get(0); v != nil { - r0 = v.([]byte) - } - return r0 -} - -// ParamString ParamString returns UTF-8 string data from Extism host memory given an offset. -func ParamString(offset uint64) string { - args := PDKMock.Called(offset) - return args.String(0) -} - -// ParamU32 ParamU32 returns a uint32 from Extism host memory given an offset. -func ParamU32(offset uint64) uint32 { - args := PDKMock.Called(offset) - return args.Get(0).(uint32) -} - -// ParamU64 ParamU64 returns a uint64 from Extism host memory given an offset. -func ParamU64(offset uint64) uint64 { - args := PDKMock.Called(offset) - return args.Get(0).(uint64) -} - -// RemoveVar RemoveVar removes (and frees) the host variable associated with `key`. -func RemoveVar(key string) { - PDKMock.Called(key) -} - -// ResultBytes ResultBytes allocates bytes and returns the offset in Extism host memory. -func ResultBytes(d []byte) uint64 { - args := PDKMock.Called(d) - return args.Get(0).(uint64) -} - -// ResultString ResultString allocates a UTF-8 string and returns the offset in Extism host memory. -func ResultString(s string) uint64 { - args := PDKMock.Called(s) - return args.Get(0).(uint64) -} - -// ResultU32 ResultU32 allocates a uint32 and returns the offset in Extism host memory. -func ResultU32(d uint32) uint64 { - args := PDKMock.Called(d) - return args.Get(0).(uint64) -} - -// ResultU64 ResultU64 allocates a uint64 and returns the offset in Extism host memory. -func ResultU64(d uint64) uint64 { - args := PDKMock.Called(d) - return args.Get(0).(uint64) -} - -// SetError SetError sets the host error string from `err`. -func SetError(err error) { - PDKMock.Called(err) -} - -// SetErrorString SetErrorString sets the host error string from `err`. -func SetErrorString(err string) { - PDKMock.Called(err) -} - -// SetVar SetVar sets the host variable associated with `key` to the `value` byte slice. -func SetVar(key string, value []byte) { - PDKMock.Called(key, value) -} - -// SetVarInt SetVarInt sets the host variable associated with `key` to the `value` int. -func SetVarInt(key string, value int) { - PDKMock.Called(key, value) -} diff --git a/plugins/pdk/go/pdk/types_stub.go b/plugins/pdk/go/pdk/types_stub.go deleted file mode 100644 index 06cbb4f1f..000000000 --- a/plugins/pdk/go/pdk/types_stub.go +++ /dev/null @@ -1,192 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains type definitions for non-WASM builds. -// These types match the extism/go-pdk signatures to allow compilation and testing -// on native platforms without importing the WASM-only extism package. -// -//go:build !wasip1 - -package pdk - -// LogLevel represents a logging level. -type LogLevel int - -// Log level constants -const ( - LogTrace LogLevel = iota - LogDebug - LogInfo - LogWarn - LogError -) - -// HTTPMethod represents an HTTP method. -type HTTPMethod int32 - -// HTTP method constants -const ( - MethodGet HTTPMethod = iota - MethodHead - MethodPost - MethodPut - MethodPatch - MethodDelete - MethodConnect - MethodOptions - MethodTrace -) - -// String returns the string representation of the HTTP method. -func (m HTTPMethod) String() string { - switch m { - case MethodGet: - return "GET" - case MethodHead: - return "HEAD" - case MethodPost: - return "POST" - case MethodPut: - return "PUT" - case MethodPatch: - return "PATCH" - case MethodDelete: - return "DELETE" - case MethodConnect: - return "CONNECT" - case MethodOptions: - return "OPTIONS" - case MethodTrace: - return "TRACE" - default: - return "UNKNOWN" - } -} - -// Memory represents memory allocated by (and shared with) the host. -// This is a stub implementation for non-WASM platforms. -type Memory struct { - offset uint64 - length uint64 - data []byte -} - -// Offset returns the offset of the memory block. -func (m Memory) Offset() uint64 { - return m.offset -} - -// Length returns the length of the memory block. -func (m Memory) Length() uint64 { - return m.length -} - -// ReadBytes reads all bytes from the memory block. -func (m Memory) ReadBytes() []byte { - return m.data -} - -// Load reads the memory block into the provided buffer. -func (m *Memory) Load(buffer []byte) { - copy(buffer, m.data) -} - -// Store writes data to the memory block. -func (m *Memory) Store(data []byte) { - m.data = make([]byte, len(data)) - copy(m.data, data) - m.length = uint64(len(data)) -} - -// Free frees the memory block. -func (m *Memory) Free() { - m.data = nil - m.length = 0 -} - -// NewStubMemory creates a new stub Memory for testing. -// This is a helper function not present in the real PDK. -func NewStubMemory(offset, length uint64, data []byte) Memory { - return Memory{ - offset: offset, - length: length, - data: data, - } -} - -// HTTPRequest represents an HTTP request sent by the host. -// This is a stub implementation for non-WASM platforms. -type HTTPRequest struct { - method HTTPMethod - url string - headers map[string]string - body []byte -} - -// SetHeader sets an HTTP header key to value. -func (r *HTTPRequest) SetHeader(key string, value string) *HTTPRequest { - if r.headers == nil { - r.headers = make(map[string]string) - } - r.headers[key] = value - return r -} - -// SetBody sets the HTTP request body. -func (r *HTTPRequest) SetBody(body []byte) *HTTPRequest { - r.body = body - return r -} - -// Send sends the HTTP request and returns the response. -// In the stub implementation, this delegates to the mock. -func (r *HTTPRequest) Send() HTTPResponse { - args := PDKMock.Called(r) - return args.Get(0).(HTTPResponse) -} - -// HTTPRequestMeta represents the metadata associated with an HTTP request. -type HTTPRequestMeta struct { - URL string `json:"url"` - Method string `json:"method"` - Headers map[string]string `json:"headers"` -} - -// HTTPResponse represents an HTTP response returned from the host. -// This is a stub implementation for non-WASM platforms. -type HTTPResponse struct { - status uint16 - headers map[string]string - body []byte - memory Memory -} - -// Status returns the status code from the response. -func (r HTTPResponse) Status() uint16 { - return r.status -} - -// Headers returns the HTTP response headers. -func (r *HTTPResponse) Headers() map[string]string { - return r.headers -} - -// Body returns the body byte slice from the response. -func (r HTTPResponse) Body() []byte { - return r.body -} - -// Memory returns the memory associated with the response. -func (r HTTPResponse) Memory() Memory { - return r.memory -} - -// NewStubHTTPResponse creates a new stub HTTPResponse for testing. -// This is a helper function not present in the real PDK. -func NewStubHTTPResponse(status uint16, headers map[string]string, body []byte) HTTPResponse { - return HTTPResponse{ - status: status, - headers: headers, - body: body, - memory: NewStubMemory(0, uint64(len(body)), body), - } -} diff --git a/plugins/pdk/go/scheduler/scheduler.go b/plugins/pdk/go/scheduler/scheduler.go deleted file mode 100644 index b3dd67bd2..000000000 --- a/plugins/pdk/go/scheduler/scheduler.go +++ /dev/null @@ -1,74 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the SchedulerCallback capability. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package scheduler - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// SchedulerCallbackRequest is the request provided when a scheduled task fires. -type SchedulerCallbackRequest struct { - // ScheduleID is the unique identifier for this scheduled task. - // This is either the ID provided when scheduling, or an auto-generated UUID if none was specified. - ScheduleID string `json:"scheduleId"` - // Payload is the payload data that was provided when the task was scheduled. - // Can be used to pass context or parameters to the callback handler. - Payload string `json:"payload"` - // IsRecurring is true if this is a recurring schedule (created via ScheduleRecurring), - // false if it's a one-time schedule (created via ScheduleOneTime). - IsRecurring bool `json:"isRecurring"` -} - -// Scheduler is the marker interface for scheduler plugins. -// Implement one or more of the provider interfaces below. -// SchedulerCallback provides scheduled task handling. -// This capability allows plugins to receive callbacks when their scheduled tasks execute. -// Plugins that use the scheduler host service must implement this capability -// to handle task execution. -type Scheduler interface{} - -// CallbackProvider provides the OnCallback function. -type CallbackProvider interface { - OnCallback(SchedulerCallbackRequest) error -} // Internal implementation holders -var ( - callbackImpl func(SchedulerCallbackRequest) error -) - -// Register registers a scheduler implementation. -// The implementation is checked for optional provider interfaces. -func Register(impl Scheduler) { - if p, ok := impl.(CallbackProvider); ok { - callbackImpl = p.OnCallback - } -} - -// 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_scheduler_callback -func _NdSchedulerCallback() int32 { - if callbackImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input SchedulerCallbackRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - if err := callbackImpl(input); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} diff --git a/plugins/pdk/go/scheduler/scheduler_stub.go b/plugins/pdk/go/scheduler/scheduler_stub.go deleted file mode 100644 index 44b79c800..000000000 --- a/plugins/pdk/go/scheduler/scheduler_stub.go +++ /dev/null @@ -1,42 +0,0 @@ -// 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 scheduler - -// SchedulerCallbackRequest is the request provided when a scheduled task fires. -type SchedulerCallbackRequest struct { - // ScheduleID is the unique identifier for this scheduled task. - // This is either the ID provided when scheduling, or an auto-generated UUID if none was specified. - ScheduleID string `json:"scheduleId"` - // Payload is the payload data that was provided when the task was scheduled. - // Can be used to pass context or parameters to the callback handler. - Payload string `json:"payload"` - // IsRecurring is true if this is a recurring schedule (created via ScheduleRecurring), - // false if it's a one-time schedule (created via ScheduleOneTime). - IsRecurring bool `json:"isRecurring"` -} - -// Scheduler is the marker interface for scheduler plugins. -// Implement one or more of the provider interfaces below. -// SchedulerCallback provides scheduled task handling. -// This capability allows plugins to receive callbacks when their scheduled tasks execute. -// Plugins that use the scheduler host service must implement this capability -// to handle task execution. -type Scheduler interface{} - -// CallbackProvider provides the OnCallback function. -type CallbackProvider interface { - OnCallback(SchedulerCallbackRequest) 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(_ Scheduler) {} diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go deleted file mode 100644 index 22ae4c3bf..000000000 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ /dev/null @@ -1,242 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the Scrobbler capability. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package scrobbler - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" - "github.com/navidrome/navidrome/plugins/pdk/go/types" -) - -// Deprecated: use types.ArtistRef. -type ArtistRef = types.ArtistRef - -// ScrobblerError represents an error type for scrobbling operations. -type ScrobblerError string - -const ( - // ScrobblerErrorNotAuthorized indicates the user is not authorized. - ScrobblerErrorNotAuthorized ScrobblerError = "scrobbler(not_authorized)" - // ScrobblerErrorRetryLater indicates the operation should be retried later. - ScrobblerErrorRetryLater ScrobblerError = "scrobbler(retry_later)" - // ScrobblerErrorUnrecoverable indicates an unrecoverable error. - ScrobblerErrorUnrecoverable ScrobblerError = "scrobbler(unrecoverable)" -) - -// Error implements the error interface for ScrobblerError. -func (e ScrobblerError) Error() string { return string(e) } - -// IsAuthorizedRequest is the request for authorization check. -type IsAuthorizedRequest struct { - // Username is the username of the user. - Username string `json:"username"` -} - -// NowPlayingRequest is the request for now playing notification. -type NowPlayingRequest struct { - // Username is the username of the user. - Username string `json:"username"` - // Track is the track currently playing. - Track TrackInfo `json:"track"` - // Position is the current playback position in seconds. - 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. - Username string `json:"username"` - // Track is the track that was played. - Track TrackInfo `json:"track"` - // Timestamp is the Unix timestamp when the track started playing. - Timestamp int64 `json:"timestamp"` -} - -// TrackInfo contains track metadata. -type TrackInfo struct { - // ID is the internal Navidrome track ID. - ID string `json:"id"` - // Title is the track title. - Title string `json:"title"` - // Album is the album name. - Album string `json:"album"` - // Artist is the formatted artist name for display (e.g., "Artist1 • Artist2"). - Artist string `json:"artist"` - // AlbumArtist is the formatted album artist name for display. - AlbumArtist string `json:"albumArtist"` - // Artists is the list of track artists. - Artists []types.ArtistRef `json:"artists"` - // AlbumArtists is the list of album artists. - AlbumArtists []types.ArtistRef `json:"albumArtists"` - // Duration is the track duration in seconds. - Duration float32 `json:"duration"` - // TrackNumber is the track number on the album. - TrackNumber int32 `json:"trackNumber"` - // DiscNumber is the disc number. - DiscNumber int32 `json:"discNumber"` - // MBZRecordingID is the MusicBrainz recording ID. - MBZRecordingID string `json:"mbzRecordingId,omitempty"` - // MBZAlbumID is the MusicBrainz album/release ID. - MBZAlbumID string `json:"mbzAlbumId,omitempty"` - // MBZReleaseGroupID is the MusicBrainz release group ID. - 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. -// Scrobbler provides scrobbling functionality to external services. -// This capability allows plugins to submit listening history to services like Last.fm, -// ListenBrainz, or custom scrobbling backends. -// -// All methods are required - plugins implementing this capability must provide -// 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) - // NowPlaying - NowPlaying sends a now playing notification to the scrobbling service. - 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 - playbackReportImpl func(PlaybackReportRequest) error -) - -// Register registers a scrobbler implementation. -// All methods are required. -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. -// The host recognizes this and skips the plugin gracefully. -const NotImplementedCode int32 = -2 - -//go:wasmexport nd_scrobbler_is_authorized -func _NdScrobblerIsAuthorized() int32 { - if isAuthorizedImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input IsAuthorizedRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - output, err := isAuthorizedImpl(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_scrobbler_now_playing -func _NdScrobblerNowPlaying() int32 { - if nowPlayingImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input NowPlayingRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - if err := nowPlayingImpl(input); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} - -//go:wasmexport nd_scrobbler_scrobble -func _NdScrobblerScrobble() int32 { - if scrobbleImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input ScrobbleRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - if err := scrobbleImpl(input); err != nil { - pdk.SetError(err) - return -1 - } - - 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 deleted file mode 100644 index 722ec5462..000000000 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ /dev/null @@ -1,138 +0,0 @@ -// 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 scrobbler - -import "github.com/navidrome/navidrome/plugins/pdk/go/types" - -// Deprecated: use types.ArtistRef. -type ArtistRef = types.ArtistRef - -// ScrobblerError represents an error type for scrobbling operations. -type ScrobblerError string - -const ( - // ScrobblerErrorNotAuthorized indicates the user is not authorized. - ScrobblerErrorNotAuthorized ScrobblerError = "scrobbler(not_authorized)" - // ScrobblerErrorRetryLater indicates the operation should be retried later. - ScrobblerErrorRetryLater ScrobblerError = "scrobbler(retry_later)" - // ScrobblerErrorUnrecoverable indicates an unrecoverable error. - ScrobblerErrorUnrecoverable ScrobblerError = "scrobbler(unrecoverable)" -) - -// Error implements the error interface for ScrobblerError. -func (e ScrobblerError) Error() string { return string(e) } - -// IsAuthorizedRequest is the request for authorization check. -type IsAuthorizedRequest struct { - // Username is the username of the user. - Username string `json:"username"` -} - -// NowPlayingRequest is the request for now playing notification. -type NowPlayingRequest struct { - // Username is the username of the user. - Username string `json:"username"` - // Track is the track currently playing. - Track TrackInfo `json:"track"` - // Position is the current playback position in seconds. - 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. - Username string `json:"username"` - // Track is the track that was played. - Track TrackInfo `json:"track"` - // Timestamp is the Unix timestamp when the track started playing. - Timestamp int64 `json:"timestamp"` -} - -// TrackInfo contains track metadata. -type TrackInfo struct { - // ID is the internal Navidrome track ID. - ID string `json:"id"` - // Title is the track title. - Title string `json:"title"` - // Album is the album name. - Album string `json:"album"` - // Artist is the formatted artist name for display (e.g., "Artist1 • Artist2"). - Artist string `json:"artist"` - // AlbumArtist is the formatted album artist name for display. - AlbumArtist string `json:"albumArtist"` - // Artists is the list of track artists. - Artists []types.ArtistRef `json:"artists"` - // AlbumArtists is the list of album artists. - AlbumArtists []types.ArtistRef `json:"albumArtists"` - // Duration is the track duration in seconds. - Duration float32 `json:"duration"` - // TrackNumber is the track number on the album. - TrackNumber int32 `json:"trackNumber"` - // DiscNumber is the disc number. - DiscNumber int32 `json:"discNumber"` - // MBZRecordingID is the MusicBrainz recording ID. - MBZRecordingID string `json:"mbzRecordingId,omitempty"` - // MBZAlbumID is the MusicBrainz album/release ID. - MBZAlbumID string `json:"mbzAlbumId,omitempty"` - // MBZReleaseGroupID is the MusicBrainz release group ID. - 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. -// Scrobbler provides scrobbling functionality to external services. -// This capability allows plugins to submit listening history to services like Last.fm, -// ListenBrainz, or custom scrobbling backends. -// -// All methods are required - plugins implementing this capability must provide -// 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) - // NowPlaying - NowPlaying sends a now playing notification to the scrobbling service. - 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. -const NotImplementedCode int32 = -2 - -// Register is a no-op on non-WASM platforms. -// This stub allows code to compile outside of WASM. -func Register(_ Scrobbler) {} diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go deleted file mode 100644 index fca8bd2c1..000000000 --- a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go +++ /dev/null @@ -1,121 +0,0 @@ -// 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" - "github.com/navidrome/navidrome/plugins/pdk/go/types" -) - -// Deprecated: use types.ArtistRef. -type ArtistRef = types.ArtistRef - -// Deprecated: use types.SongRef. -type SongRef = types.SongRef - -// FindSonicPathRequest represents the FindSonicPathRequest data structure. -type FindSonicPathRequest struct { - StartSong types.SongRef `json:"startSong"` - EndSong types.SongRef `json:"endSong"` - Count int32 `json:"count"` -} - -// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. -type GetSonicSimilarTracksRequest struct { - Song types.SongRef `json:"song"` - Count int32 `json:"count"` -} - -// SonicMatch represents the SonicMatch data structure. -type SonicMatch struct { - Song types.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 deleted file mode 100644 index 518a166e5..000000000 --- a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go +++ /dev/null @@ -1,57 +0,0 @@ -// 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 - -import "github.com/navidrome/navidrome/plugins/pdk/go/types" - -// Deprecated: use types.ArtistRef. -type ArtistRef = types.ArtistRef - -// Deprecated: use types.SongRef. -type SongRef = types.SongRef - -// FindSonicPathRequest represents the FindSonicPathRequest data structure. -type FindSonicPathRequest struct { - StartSong types.SongRef `json:"startSong"` - EndSong types.SongRef `json:"endSong"` - Count int32 `json:"count"` -} - -// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. -type GetSonicSimilarTracksRequest struct { - Song types.SongRef `json:"song"` - Count int32 `json:"count"` -} - -// SonicMatch represents the SonicMatch data structure. -type SonicMatch struct { - Song types.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/go/taskworker/taskworker.go b/plugins/pdk/go/taskworker/taskworker.go deleted file mode 100644 index 5d09a3209..000000000 --- a/plugins/pdk/go/taskworker/taskworker.go +++ /dev/null @@ -1,79 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the TaskWorker capability. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package taskworker - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// TaskExecuteRequest is the request provided when a task is ready to execute. -type TaskExecuteRequest struct { - // QueueName is the name of the queue this task belongs to. - QueueName string `json:"queueName"` - // TaskID is the unique identifier for this task. - TaskID string `json:"taskId"` - // Payload is the opaque data provided when the task was enqueued. - Payload []byte `json:"payload"` - // Attempt is the current attempt number (1-based: first attempt = 1). - Attempt int32 `json:"attempt"` -} - -// TaskWorker is the marker interface for taskworker plugins. -// Implement one or more of the provider interfaces below. -// TaskWorker provides task execution handling. -// This capability allows plugins to receive callbacks when their queued tasks -// are ready to execute. Plugins that use the taskqueue host service must -// implement this capability. -type TaskWorker interface{} - -// TaskExecuteProvider provides the OnTaskExecute function. -type TaskExecuteProvider interface { - OnTaskExecute(TaskExecuteRequest) (string, error) -} // Internal implementation holders -var ( - taskExecuteImpl func(TaskExecuteRequest) (string, error) -) - -// Register registers a taskworker implementation. -// The implementation is checked for optional provider interfaces. -func Register(impl TaskWorker) { - if p, ok := impl.(TaskExecuteProvider); ok { - taskExecuteImpl = p.OnTaskExecute - } -} - -// 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_task_execute -func _NdTaskExecute() int32 { - if taskExecuteImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input TaskExecuteRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - output, err := taskExecuteImpl(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/taskworker/taskworker_stub.go b/plugins/pdk/go/taskworker/taskworker_stub.go deleted file mode 100644 index e45054e8e..000000000 --- a/plugins/pdk/go/taskworker/taskworker_stub.go +++ /dev/null @@ -1,41 +0,0 @@ -// 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 taskworker - -// TaskExecuteRequest is the request provided when a task is ready to execute. -type TaskExecuteRequest struct { - // QueueName is the name of the queue this task belongs to. - QueueName string `json:"queueName"` - // TaskID is the unique identifier for this task. - TaskID string `json:"taskId"` - // Payload is the opaque data provided when the task was enqueued. - Payload []byte `json:"payload"` - // Attempt is the current attempt number (1-based: first attempt = 1). - Attempt int32 `json:"attempt"` -} - -// TaskWorker is the marker interface for taskworker plugins. -// Implement one or more of the provider interfaces below. -// TaskWorker provides task execution handling. -// This capability allows plugins to receive callbacks when their queued tasks -// are ready to execute. Plugins that use the taskqueue host service must -// implement this capability. -type TaskWorker interface{} - -// TaskExecuteProvider provides the OnTaskExecute function. -type TaskExecuteProvider interface { - OnTaskExecute(TaskExecuteRequest) (string, 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(_ TaskWorker) {} diff --git a/plugins/pdk/go/types/types.go b/plugins/pdk/go/types/types.go deleted file mode 100644 index 4d4cdedd2..000000000 --- a/plugins/pdk/go/types/types.go +++ /dev/null @@ -1,148 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// Package types holds the stable, shared data types exchanged between -// Navidrome and its plugins. These types are referenced by host services and -// capability wrappers via the types package. - -package types - -// ArtistRef is the minimal information a plugin returns for Navidrome to match an -// artist against the library. It is a reference, not a full artist entity: it -// carries only matching keys (name and optional internal/MusicBrainz IDs) plus a -// few projection fields used when describing a track's participants, never -// descriptive data such as biographies or images. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` - // SortName is the artist name used for sorting (if known). - SortName string `json:"sortName,omitempty"` - // Role is the participation category (e.g. "artist", "composer", "performer"). - Role string `json:"role,omitempty"` - // SubRole is a specialization within Role (e.g. the instrument for a performer). - SubRole string `json:"subRole,omitempty"` -} - -// SongRef is the minimal information exchanged between a plugin and Navidrome to -// match a song. It is used both as input (a song Navidrome already has) and as -// output (a song a plugin suggests, which may not be in the library yet). Unlike -// Track, it is an abstract recording reference carrying only matching keys (IDs, -// ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. -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. - // - // Deprecated: use Artists. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - // - // Deprecated: use Artists. - ArtistMBID string `json:"artistMbid,omitempty"` - // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - Artists []ArtistRef `json:"artists,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. - // - // Deprecated: use DurationMs, which carries millisecond precision. When - // DurationMs is non-zero it takes precedence; Duration is kept only for - // backwards compatibility with plugins that still send seconds. - Duration float32 `json:"duration,omitempty"` - // DurationMs is the song duration in milliseconds. It supersedes Duration - // when non-zero. - DurationMs uint32 `json:"durationMs,omitempty"` -} - -// Track is a stable, public projection of a library media file for plugin consumption. -// It is a sane subset of the internal model.MediaFile, intended for reuse across host -// services and capabilities. Timestamps are Unix epoch seconds. -// -// Unlike SongRef, which is an abstract recording reference carrying only matching keys, -// Track is a concrete library entity: it identifies a specific media file that exists -// (or once existed) in the library and exposes its full descriptive metadata. -type Track struct { - // Identity & location - ID string `json:"id"` - LibraryID int32 `json:"libraryId"` - LibraryName string `json:"libraryName,omitempty"` - Path string `json:"path,omitempty"` - Missing bool `json:"missing"` - // Core metadata - Title string `json:"title"` - Album string `json:"album"` - Artist string `json:"artist"` - AlbumArtist string `json:"albumArtist,omitempty"` - AlbumID string `json:"albumId,omitempty"` - SortTitle string `json:"sortTitle,omitempty"` - SortAlbumName string `json:"sortAlbumName,omitempty"` - SortArtistName string `json:"sortArtistName,omitempty"` - // Track / disc / dates - TrackNumber int32 `json:"trackNumber"` - DiscNumber int32 `json:"discNumber"` - DiscSubtitle string `json:"discSubtitle,omitempty"` - Year int32 `json:"year"` - Date string `json:"date,omitempty"` - OriginalYear int32 `json:"originalYear"` - OriginalDate string `json:"originalDate,omitempty"` - ReleaseYear int32 `json:"releaseYear"` - ReleaseDate string `json:"releaseDate,omitempty"` - // Audio / file - Size int64 `json:"size"` - Suffix string `json:"suffix,omitempty"` - Duration float64 `json:"duration"` - BitRate int32 `json:"bitRate"` - SampleRate int32 `json:"sampleRate"` - BitDepth *int32 `json:"bitDepth,omitempty"` - Channels int32 `json:"channels"` - Codec string `json:"codec,omitempty"` - // Descriptive - Genres []string `json:"genres,omitempty"` - Comment string `json:"comment,omitempty"` - BPM *int32 `json:"bpm,omitempty"` - ExplicitStatus string `json:"explicitStatus,omitempty"` - CatalogNum string `json:"catalogNum,omitempty"` - Compilation bool `json:"compilation"` - HasCoverArt bool `json:"hasCoverArt"` - // MusicBrainz - MbzRecordingID string `json:"mbzRecordingId,omitempty"` - MbzReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` - MbzAlbumID string `json:"mbzAlbumId,omitempty"` - MbzReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` - MbzAlbumType string `json:"mbzAlbumType,omitempty"` - MbzAlbumComment string `json:"mbzAlbumComment,omitempty"` - // ReplayGain — nil means no data; 0 is a valid measured value, so these - // must stay pointers to distinguish "absent" from "0". - RGAlbumGain *float64 `json:"rgAlbumGain,omitempty"` - RGAlbumPeak *float64 `json:"rgAlbumPeak,omitempty"` - RGTrackGain *float64 `json:"rgTrackGain,omitempty"` - RGTrackPeak *float64 `json:"rgTrackPeak,omitempty"` - // Timestamps (Unix epoch seconds) - BirthTime int64 `json:"birthTime"` - CreatedAt int64 `json:"createdAt"` - UpdatedAt int64 `json:"updatedAt"` - // AverageRating is the track's mean rating across all users (always set; 0 when unrated). - AverageRating float64 `json:"averageRating"` - // Per-user annotations, set only for a user-scoped match. Timestamps are Unix - // seconds; a nil pointer means "no value". - Starred bool `json:"starred,omitempty"` - StarredAt *int64 `json:"starredAt,omitempty"` - Rating int32 `json:"rating,omitempty"` - PlayCount int64 `json:"playCount,omitempty"` - PlayDate *int64 `json:"playDate,omitempty"` - // Composite - Tags map[string][]string `json:"tags,omitempty"` - // Participants lists the track's artists across all roles, each tagged with its Role. - Participants []ArtistRef `json:"participants,omitempty"` -} diff --git a/plugins/pdk/go/websocket/websocket.go b/plugins/pdk/go/websocket/websocket.go deleted file mode 100644 index 47a53b7b3..000000000 --- a/plugins/pdk/go/websocket/websocket.go +++ /dev/null @@ -1,187 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the WebSocketCallback capability. -// It is intended for use in Navidrome plugins built with TinyGo. -// -//go:build wasip1 - -package websocket - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// OnBinaryMessageRequest is the request provided when a binary message is received. -type OnBinaryMessageRequest struct { - // ConnectionID is the unique identifier for the WebSocket connection that received the message. - ConnectionID string `json:"connectionId"` - // Data is the binary data received from the WebSocket, encoded as base64. - Data []byte `json:"data"` -} - -// OnCloseRequest is the request provided when a WebSocket connection is closed. -type OnCloseRequest struct { - // ConnectionID is the unique identifier for the WebSocket connection that was closed. - ConnectionID string `json:"connectionId"` - // Code is the WebSocket close status code (e.g., 1000 for normal closure, - // 1001 for going away, 1006 for abnormal closure). - Code int32 `json:"code"` - // Reason is the human-readable reason for the connection closure, if provided. - Reason string `json:"reason"` -} - -// OnErrorRequest is the request provided when an error occurs on a WebSocket connection. -type OnErrorRequest struct { - // ConnectionID is the unique identifier for the WebSocket connection where the error occurred. - ConnectionID string `json:"connectionId"` - // Error is the error message describing what went wrong. - Error string `json:"error"` -} - -// OnTextMessageRequest is the request provided when a text message is received. -type OnTextMessageRequest struct { - // ConnectionID is the unique identifier for the WebSocket connection that received the message. - ConnectionID string `json:"connectionId"` - // Message is the text message content received from the WebSocket. - Message string `json:"message"` -} - -// WebSocket is the marker interface for websocket plugins. -// Implement one or more of the provider interfaces below. -// WebSocketCallback provides WebSocket message handling. -// This capability allows plugins to receive callbacks for WebSocket events -// such as text messages, binary messages, errors, and connection closures. -// Plugins that use the WebSocket host service must implement this capability -// to handle incoming events. -type WebSocket interface{} - -// TextMessageProvider provides the OnTextMessage function. -type TextMessageProvider interface { - OnTextMessage(OnTextMessageRequest) error -} - -// BinaryMessageProvider provides the OnBinaryMessage function. -type BinaryMessageProvider interface { - OnBinaryMessage(OnBinaryMessageRequest) error -} - -// ErrorProvider provides the OnError function. -type ErrorProvider interface { - OnError(OnErrorRequest) error -} - -// CloseProvider provides the OnClose function. -type CloseProvider interface { - OnClose(OnCloseRequest) error -} // Internal implementation holders -var ( - textMessageImpl func(OnTextMessageRequest) error - binaryMessageImpl func(OnBinaryMessageRequest) error - errorImpl func(OnErrorRequest) error - closeImpl func(OnCloseRequest) error -) - -// Register registers a websocket implementation. -// The implementation is checked for optional provider interfaces. -func Register(impl WebSocket) { - if p, ok := impl.(TextMessageProvider); ok { - textMessageImpl = p.OnTextMessage - } - if p, ok := impl.(BinaryMessageProvider); ok { - binaryMessageImpl = p.OnBinaryMessage - } - if p, ok := impl.(ErrorProvider); ok { - errorImpl = p.OnError - } - if p, ok := impl.(CloseProvider); ok { - closeImpl = p.OnClose - } -} - -// 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_websocket_on_text_message -func _NdWebsocketOnTextMessage() int32 { - if textMessageImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input OnTextMessageRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - if err := textMessageImpl(input); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} - -//go:wasmexport nd_websocket_on_binary_message -func _NdWebsocketOnBinaryMessage() int32 { - if binaryMessageImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input OnBinaryMessageRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - if err := binaryMessageImpl(input); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} - -//go:wasmexport nd_websocket_on_error -func _NdWebsocketOnError() int32 { - if errorImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input OnErrorRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - if err := errorImpl(input); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} - -//go:wasmexport nd_websocket_on_close -func _NdWebsocketOnClose() int32 { - if closeImpl == nil { - // Return standard code - host will skip this plugin gracefully - return NotImplementedCode - } - - var input OnCloseRequest - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return -1 - } - - if err := closeImpl(input); err != nil { - pdk.SetError(err) - return -1 - } - - return 0 -} diff --git a/plugins/pdk/go/websocket/websocket_stub.go b/plugins/pdk/go/websocket/websocket_stub.go deleted file mode 100644 index 214118a89..000000000 --- a/plugins/pdk/go/websocket/websocket_stub.go +++ /dev/null @@ -1,80 +0,0 @@ -// 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 websocket - -// OnBinaryMessageRequest is the request provided when a binary message is received. -type OnBinaryMessageRequest struct { - // ConnectionID is the unique identifier for the WebSocket connection that received the message. - ConnectionID string `json:"connectionId"` - // Data is the binary data received from the WebSocket, encoded as base64. - Data []byte `json:"data"` -} - -// OnCloseRequest is the request provided when a WebSocket connection is closed. -type OnCloseRequest struct { - // ConnectionID is the unique identifier for the WebSocket connection that was closed. - ConnectionID string `json:"connectionId"` - // Code is the WebSocket close status code (e.g., 1000 for normal closure, - // 1001 for going away, 1006 for abnormal closure). - Code int32 `json:"code"` - // Reason is the human-readable reason for the connection closure, if provided. - Reason string `json:"reason"` -} - -// OnErrorRequest is the request provided when an error occurs on a WebSocket connection. -type OnErrorRequest struct { - // ConnectionID is the unique identifier for the WebSocket connection where the error occurred. - ConnectionID string `json:"connectionId"` - // Error is the error message describing what went wrong. - Error string `json:"error"` -} - -// OnTextMessageRequest is the request provided when a text message is received. -type OnTextMessageRequest struct { - // ConnectionID is the unique identifier for the WebSocket connection that received the message. - ConnectionID string `json:"connectionId"` - // Message is the text message content received from the WebSocket. - Message string `json:"message"` -} - -// WebSocket is the marker interface for websocket plugins. -// Implement one or more of the provider interfaces below. -// WebSocketCallback provides WebSocket message handling. -// This capability allows plugins to receive callbacks for WebSocket events -// such as text messages, binary messages, errors, and connection closures. -// Plugins that use the WebSocket host service must implement this capability -// to handle incoming events. -type WebSocket interface{} - -// TextMessageProvider provides the OnTextMessage function. -type TextMessageProvider interface { - OnTextMessage(OnTextMessageRequest) error -} - -// BinaryMessageProvider provides the OnBinaryMessage function. -type BinaryMessageProvider interface { - OnBinaryMessage(OnBinaryMessageRequest) error -} - -// ErrorProvider provides the OnError function. -type ErrorProvider interface { - OnError(OnErrorRequest) error -} - -// CloseProvider provides the OnClose function. -type CloseProvider interface { - OnClose(OnCloseRequest) 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(_ WebSocket) {} diff --git a/plugins/pdk/rust/README.md b/plugins/pdk/rust/README.md deleted file mode 100644 index 891465cc5..000000000 --- a/plugins/pdk/rust/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# Navidrome Plugin Development Kit for Rust - -This directory contains the Rust PDK crates for building Navidrome plugins. - -## Crate Structure - -``` -plugins/pdk/rust/ -├── nd-pdk/ # Umbrella crate - use this as your dependency -├── nd-pdk-host/ # Host function wrappers (call Navidrome services) -└── nd-pdk-capabilities/ # Capability traits and types (generated) -``` - -## Usage - -Add the `nd-pdk` crate as a dependency in your plugin's `Cargo.toml`: - -```toml -[package] -name = "my-plugin" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nd-pdk = { path = "../../pdk/rust/nd-pdk" } -extism-pdk = "1.2" -``` - -### Implementing a Scrobbler (Required-All Pattern) - -The Scrobbler capability requires all methods to be implemented: - -```rust -use nd_pdk::scrobbler::{ - Error, IsAuthorizedRequest, - NowPlayingRequest, ScrobbleRequest, Scrobbler, -}; - -// Register WASM exports for all Scrobbler methods -nd_pdk::register_scrobbler!(MyPlugin); - -#[derive(Default)] -struct MyPlugin; - -impl Scrobbler for MyPlugin { - fn is_authorized(&self, req: IsAuthorizedRequest) -> Result<bool, Error> { - Ok(true) - } - - fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error> { - // Handle now playing notification - Ok(()) - } - - fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error> { - // Submit scrobble - Ok(()) - } -} -``` - -### Implementing Metadata Agent (Optional Pattern) - -The MetadataAgent capability allows implementing individual methods: - -```rust -use nd_pdk::metadata::{ - ArtistBiographyProvider, GetArtistBiographyRequest, ArtistBiography, Error, -}; - -// Register only the methods you implement -nd_pdk::register_artist_biography!(MyPlugin); - -#[derive(Default)] -struct MyPlugin; - -impl ArtistBiographyProvider for MyPlugin { - fn get_artist_biography(&self, req: GetArtistBiographyRequest) - -> Result<ArtistBiography, Error> - { - // Return artist biography - Ok(ArtistBiography { - biography: "Artist bio text...".into(), - ..Default::default() - }) - } -} -``` - -### Using Host Services - -Access Navidrome services via the host module: - -```rust -use nd_pdk::host::{artwork, scheduler, library}; - -// Get artwork URL for a track -let url = artwork::get_track_url("track-id", 300)?; - -// Schedule a one-time callback -scheduler::schedule_one_time(60, "my-payload", "schedule-id")?; - -// Get library information -let libs = library::get_all()?; -``` - -## Available Capabilities - -| Capability | Pattern | Description | -|-------------|--------------|-----------------------------------------------------| -| `scrobbler` | Required-all | Submit listening history to external services | -| `metadata` | Optional | Provide artist/album metadata from external sources | -| `lifecycle` | Optional | Handle plugin initialization | -| `scheduler` | Optional | Receive scheduled callbacks | -| `websocket` | Optional | Handle WebSocket messages | - -## Building - -Rust plugins must be compiled to WASM using the `wasm32-wasip1` target: - -```bash -cargo build --release --target wasm32-wasip1 -``` - -The resulting `.wasm` file can be packaged into an `.ndp` plugin package. - -## Examples - -See the example plugins for complete implementations: - -- [webhook-rs](../../examples/webhook-rs/) - Simple scrobbler using the PDK -- [discord-rich-presence-rs](../../examples/discord-rich-presence-rs/) - Complex plugin with multiple capabilities -- [library-inspector-rs](../../examples/library-inspector-rs/) - Host service demonstration - -## Code Generation - -The capability modules in `nd-pdk-capabilities` are auto-generated from the Go capability definitions. To regenerate after capability changes: - -```bash -make gen -``` - -This generates both Go and Rust PDK code. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml b/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml deleted file mode 100644 index e9fe367da..000000000 --- a/plugins/pdk/rust/nd-pdk-capabilities/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "nd-pdk-capabilities" -version = "0.1.0" -edition = "2021" -description = "Navidrome capability wrappers for Rust plugins" -authors = ["Navidrome Team"] -license = "GPL-3.0" - -[lib] -path = "src/lib.rs" -crate-type = ["rlib"] - -[dependencies] -nd-pdk-types = { path = "../nd-pdk-types" } -base64 = "0.22" -extism-pdk = "1.2" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs deleted file mode 100644 index d3d2b19aa..000000000 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ /dev/null @@ -1,17 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. - -//! Navidrome Plugin Development Kit - Capability Wrappers -//! -//! This crate provides type definitions, traits, and registration macros -//! for implementing Navidrome plugin capabilities in Rust. - -pub use nd_pdk_types as types; - -pub mod lifecycle; -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/lifecycle.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lifecycle.rs deleted file mode 100644 index 87b5485ba..000000000 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lifecycle.rs +++ /dev/null @@ -1,45 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the Lifecycle capability. -// It is intended for use in Navidrome plugins built with extism-pdk. - - -/// 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<String>) -> Self { - Self { message: message.into() } - } -} - -/// InitProvider provides the OnInit function. -pub trait InitProvider { - fn on_init(&self) -> Result<(), Error>; -} - -/// Register the on_init export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_lifecycle_init { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_on_init( - ) -> extism_pdk::FnResult<()> { - let plugin = <$plugin_type>::default(); - $crate::lifecycle::InitProvider::on_init(&plugin)?; - Ok(()) - } - }; -} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs deleted file mode 100644 index a8449a5ec..000000000 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ /dev/null @@ -1,145 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the Lyrics 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 } - -#[deprecated(note = "use nd_pdk::types::ArtistRef")] -pub type ArtistRef = nd_pdk_types::ArtistRef; -/// GetLyricsRequest contains the track information for lyrics lookup. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetLyricsRequest { - #[serde(default)] - pub track: TrackInfo, -} -/// GetLyricsResponse contains the lyrics returned by the plugin. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetLyricsResponse { - #[serde(default)] - pub lyrics: Vec<LyricsText>, -} -/// LyricsText represents a single set of lyrics in raw text format. -/// Text can be plain text or LRC format — Navidrome will parse it. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LyricsText { - #[serde(default, skip_serializing_if = "String::is_empty")] - pub lang: String, - #[serde(default)] - pub text: String, -} -/// TrackInfo contains track metadata. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TrackInfo { - /// ID is the internal Navidrome track ID. - #[serde(default)] - pub id: String, - /// Title is the track title. - #[serde(default)] - pub title: String, - /// Album is the album name. - #[serde(default)] - pub album: String, - /// Artist is the formatted artist name for display (e.g., "Artist1 • Artist2"). - #[serde(default)] - pub artist: String, - /// AlbumArtist is the formatted album artist name for display. - #[serde(default)] - pub album_artist: String, - /// Artists is the list of track artists. - #[serde(default)] - pub artists: Vec<nd_pdk_types::ArtistRef>, - /// AlbumArtists is the list of album artists. - #[serde(default)] - pub album_artists: Vec<nd_pdk_types::ArtistRef>, - /// Duration is the track duration in seconds. - #[serde(default)] - pub duration: f32, - /// TrackNumber is the track number on the album. - #[serde(default)] - pub track_number: i32, - /// DiscNumber is the disc number. - #[serde(default)] - pub disc_number: i32, - /// MBZRecordingID is the MusicBrainz recording ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbz_recording_id: String, - /// MBZAlbumID is the MusicBrainz album/release ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbz_album_id: String, - /// MBZReleaseGroupID is the MusicBrainz release group ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbz_release_group_id: String, - /// 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. -#[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<String>) -> Self { - Self { message: message.into() } - } -} - -/// Lyrics requires all methods to be implemented. -/// Lyrics provides lyrics for a given track from external sources. -pub trait Lyrics { - /// GetLyrics - fn get_lyrics(&self, req: GetLyricsRequest) -> Result<GetLyricsResponse, Error>; -} - -/// Register all exports for the Lyrics capability. -/// This macro generates the WASM export functions for all trait methods. -#[macro_export] -macro_rules! register_lyrics { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_lyrics_get_lyrics( - req: extism_pdk::Json<$crate::lyrics::GetLyricsRequest> - ) -> extism_pdk::FnResult<extism_pdk::Json<$crate::lyrics::GetLyricsResponse>> { - let plugin = <$plugin_type>::default(); - let result = $crate::lyrics::Lyrics::get_lyrics(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - } - }; -} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs deleted file mode 100644 index 890e16954..000000000 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/metadata.rs +++ /dev/null @@ -1,504 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the MetadataAgent 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 } - -#[deprecated(note = "use nd_pdk::types::ArtistRef")] -pub type ArtistRef = nd_pdk_types::ArtistRef; - -#[deprecated(note = "use nd_pdk::types::SongRef")] -pub type SongRef = nd_pdk_types::SongRef; -/// MetadataAgentError represents an error type for metadata agent operations. -pub type MetadataAgentError = &'static str; -/// MetadataAgentErrorRetryLater indicates the provider is throttling; retry later. -/// Append ":<seconds>" inside the parentheses to request a specific delay. -pub const METADATA_AGENT_ERROR_RETRY_LATER: MetadataAgentError = "agent(retry_later)"; -/// AlbumImagesResponse is the response for GetAlbumImages. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AlbumImagesResponse { - /// Images is the list of album images. - #[serde(default)] - pub images: Vec<ImageInfo>, -} -/// AlbumInfoResponse is the response for GetAlbumInfo. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AlbumInfoResponse { - /// Name is the album name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the album. - #[serde(default)] - pub mbid: String, - /// Description is the album description/notes. - #[serde(default)] - pub description: String, - /// URL is the external URL for the album. - #[serde(default)] - pub url: String, -} -/// AlbumRequest is the common request for album-related functions. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AlbumRequest { - /// Name is the album name. - #[serde(default)] - pub name: String, - /// Artist is the album artist name. - #[serde(default)] - pub artist: String, - /// MBID is the MusicBrainz ID for the album (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} -/// ArtistBiographyResponse is the response for GetArtistBiography. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistBiographyResponse { - /// Biography is the artist biography text. - #[serde(default)] - pub biography: String, -} -/// ArtistImagesResponse is the response for GetArtistImages. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistImagesResponse { - /// Images is the list of artist images. - #[serde(default)] - pub images: Vec<ImageInfo>, -} -/// ArtistMBIDRequest is the request for GetArtistMBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistMBIDRequest { - /// ID is the internal Navidrome artist ID. - #[serde(default)] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, -} -/// ArtistMBIDResponse is the response for GetArtistMBID. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistMBIDResponse { - /// MBID is the MusicBrainz ID for the artist. - #[serde(default)] - pub mbid: String, -} -/// ArtistRequest is the common request for artist-related functions. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRequest { - /// ID is the internal Navidrome artist ID. - #[serde(default)] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, -} -/// ArtistURLResponse is the response for GetArtistURL. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistURLResponse { - /// URL is the external URL for the artist. - #[serde(default)] - pub url: String, -} -/// ImageInfo represents an image with URL and size. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ImageInfo { - /// URL is the URL of the image. - #[serde(default)] - pub url: String, - /// Size is the size of the image in pixels (width or height). - #[serde(default)] - pub size: i32, -} -/// SimilarArtistsRequest is the request for GetSimilarArtists. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SimilarArtistsRequest { - /// ID is the internal Navidrome artist ID. - #[serde(default)] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, - /// Limit is the maximum number of similar artists to return. - #[serde(default)] - pub limit: i32, -} -/// SimilarArtistsResponse is the response for GetSimilarArtists. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SimilarArtistsResponse { - /// Artists is the list of similar artists. - #[serde(default)] - pub artists: Vec<nd_pdk_types::ArtistRef>, -} -/// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SimilarSongsByAlbumRequest { - /// ID is the internal Navidrome album ID. - #[serde(default)] - pub id: String, - /// Name is the album name. - #[serde(default)] - pub name: String, - /// Artist is the album artist name. - #[serde(default)] - pub artist: String, - /// MBID is the MusicBrainz release ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, - /// Count is the maximum number of similar songs to return. - #[serde(default)] - pub count: i32, -} -/// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SimilarSongsByArtistRequest { - /// ID is the internal Navidrome artist ID. - #[serde(default)] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, - /// Count is the maximum number of similar songs to return. - #[serde(default)] - pub count: i32, -} -/// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SimilarSongsByTrackRequest { - /// ID is the internal Navidrome mediafile ID. - #[serde(default)] - pub id: String, - /// Name is the track title. - #[serde(default)] - pub name: String, - /// Artist is the artist name. - #[serde(default)] - pub artist: String, - /// MBID is the MusicBrainz recording ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, - /// Count is the maximum number of similar songs to return. - #[serde(default)] - pub count: i32, -} -/// SimilarSongsResponse is the response for GetSimilarSongsBy* functions. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SimilarSongsResponse { - /// Songs is the list of similar songs. - #[serde(default)] - pub songs: Vec<nd_pdk_types::SongRef>, -} -/// TopSongsRequest is the request for GetArtistTopSongs. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TopSongsRequest { - /// ID is the internal Navidrome artist ID. - #[serde(default)] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, - /// Count is the maximum number of top songs to return. - #[serde(default)] - pub count: i32, -} -/// TopSongsResponse is the response for GetArtistTopSongs. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TopSongsResponse { - /// Songs is the list of top songs. - #[serde(default)] - pub songs: Vec<nd_pdk_types::SongRef>, -} - -/// 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<String>) -> Self { - Self { message: message.into() } - } -} - -/// ArtistMBIDProvider provides the GetArtistMBID function. -pub trait ArtistMBIDProvider { - fn get_artist_mbid(&self, req: ArtistMBIDRequest) -> Result<ArtistMBIDResponse, Error>; -} - -/// Register the get_artist_mbid export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_metadata_artist_mbid { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_get_artist_mbid( - req: extism_pdk::Json<$crate::metadata::ArtistMBIDRequest> - ) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::ArtistMBIDResponse>> { - let plugin = <$plugin_type>::default(); - let result = $crate::metadata::ArtistMBIDProvider::get_artist_mbid(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - } - }; -} - -/// ArtistURLProvider provides the GetArtistURL function. -pub trait ArtistURLProvider { - fn get_artist_url(&self, req: ArtistRequest) -> Result<ArtistURLResponse, Error>; -} - -/// Register the get_artist_url export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_metadata_artist_url { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_get_artist_url( - req: extism_pdk::Json<$crate::metadata::ArtistRequest> - ) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::ArtistURLResponse>> { - let plugin = <$plugin_type>::default(); - let result = $crate::metadata::ArtistURLProvider::get_artist_url(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - } - }; -} - -/// ArtistBiographyProvider provides the GetArtistBiography function. -pub trait ArtistBiographyProvider { - fn get_artist_biography(&self, req: ArtistRequest) -> Result<ArtistBiographyResponse, Error>; -} - -/// Register the get_artist_biography export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_metadata_artist_biography { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_get_artist_biography( - req: extism_pdk::Json<$crate::metadata::ArtistRequest> - ) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::ArtistBiographyResponse>> { - let plugin = <$plugin_type>::default(); - let result = $crate::metadata::ArtistBiographyProvider::get_artist_biography(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - } - }; -} - -/// SimilarArtistsProvider provides the GetSimilarArtists function. -pub trait SimilarArtistsProvider { - fn get_similar_artists(&self, req: SimilarArtistsRequest) -> Result<SimilarArtistsResponse, Error>; -} - -/// Register the get_similar_artists export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_metadata_similar_artists { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_get_similar_artists( - req: extism_pdk::Json<$crate::metadata::SimilarArtistsRequest> - ) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::SimilarArtistsResponse>> { - let plugin = <$plugin_type>::default(); - let result = $crate::metadata::SimilarArtistsProvider::get_similar_artists(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - } - }; -} - -/// ArtistImagesProvider provides the GetArtistImages function. -pub trait ArtistImagesProvider { - fn get_artist_images(&self, req: ArtistRequest) -> Result<ArtistImagesResponse, Error>; -} - -/// Register the get_artist_images export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_metadata_artist_images { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_get_artist_images( - req: extism_pdk::Json<$crate::metadata::ArtistRequest> - ) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::ArtistImagesResponse>> { - let plugin = <$plugin_type>::default(); - let result = $crate::metadata::ArtistImagesProvider::get_artist_images(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - } - }; -} - -/// ArtistTopSongsProvider provides the GetArtistTopSongs function. -pub trait ArtistTopSongsProvider { - fn get_artist_top_songs(&self, req: TopSongsRequest) -> Result<TopSongsResponse, Error>; -} - -/// Register the get_artist_top_songs export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_metadata_artist_top_songs { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_get_artist_top_songs( - req: extism_pdk::Json<$crate::metadata::TopSongsRequest> - ) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::TopSongsResponse>> { - let plugin = <$plugin_type>::default(); - let result = $crate::metadata::ArtistTopSongsProvider::get_artist_top_songs(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - } - }; -} - -/// AlbumInfoProvider provides the GetAlbumInfo function. -pub trait AlbumInfoProvider { - fn get_album_info(&self, req: AlbumRequest) -> Result<AlbumInfoResponse, Error>; -} - -/// Register the get_album_info export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_metadata_album_info { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_get_album_info( - req: extism_pdk::Json<$crate::metadata::AlbumRequest> - ) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::AlbumInfoResponse>> { - let plugin = <$plugin_type>::default(); - let result = $crate::metadata::AlbumInfoProvider::get_album_info(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - } - }; -} - -/// AlbumImagesProvider provides the GetAlbumImages function. -pub trait AlbumImagesProvider { - fn get_album_images(&self, req: AlbumRequest) -> Result<AlbumImagesResponse, Error>; -} - -/// Register the get_album_images export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_metadata_album_images { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_get_album_images( - req: extism_pdk::Json<$crate::metadata::AlbumRequest> - ) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::AlbumImagesResponse>> { - let plugin = <$plugin_type>::default(); - let result = $crate::metadata::AlbumImagesProvider::get_album_images(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - } - }; -} - -/// SimilarSongsByTrackProvider provides the GetSimilarSongsByTrack function. -pub trait SimilarSongsByTrackProvider { - fn get_similar_songs_by_track(&self, req: SimilarSongsByTrackRequest) -> Result<SimilarSongsResponse, Error>; -} - -/// Register the get_similar_songs_by_track export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_metadata_similar_songs_by_track { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_get_similar_songs_by_track( - req: extism_pdk::Json<$crate::metadata::SimilarSongsByTrackRequest> - ) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::SimilarSongsResponse>> { - let plugin = <$plugin_type>::default(); - let result = $crate::metadata::SimilarSongsByTrackProvider::get_similar_songs_by_track(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - } - }; -} - -/// SimilarSongsByAlbumProvider provides the GetSimilarSongsByAlbum function. -pub trait SimilarSongsByAlbumProvider { - fn get_similar_songs_by_album(&self, req: SimilarSongsByAlbumRequest) -> Result<SimilarSongsResponse, Error>; -} - -/// Register the get_similar_songs_by_album export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_metadata_similar_songs_by_album { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_get_similar_songs_by_album( - req: extism_pdk::Json<$crate::metadata::SimilarSongsByAlbumRequest> - ) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::SimilarSongsResponse>> { - let plugin = <$plugin_type>::default(); - let result = $crate::metadata::SimilarSongsByAlbumProvider::get_similar_songs_by_album(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - } - }; -} - -/// SimilarSongsByArtistProvider provides the GetSimilarSongsByArtist function. -pub trait SimilarSongsByArtistProvider { - fn get_similar_songs_by_artist(&self, req: SimilarSongsByArtistRequest) -> Result<SimilarSongsResponse, Error>; -} - -/// Register the get_similar_songs_by_artist export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_metadata_similar_songs_by_artist { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_get_similar_songs_by_artist( - req: extism_pdk::Json<$crate::metadata::SimilarSongsByArtistRequest> - ) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::SimilarSongsResponse>> { - let plugin = <$plugin_type>::default(); - let result = $crate::metadata::SimilarSongsByArtistProvider::get_similar_songs_by_artist(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - } - }; -} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs deleted file mode 100644 index 53b8564ee..000000000 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scheduler.rs +++ /dev/null @@ -1,78 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the SchedulerCallback 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 } -/// SchedulerCallbackRequest is the request provided when a scheduled task fires. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SchedulerCallbackRequest { - /// ScheduleID is the unique identifier for this scheduled task. - /// This is either the ID provided when scheduling, or an auto-generated UUID if none was specified. - #[serde(default)] - pub schedule_id: String, - /// Payload is the payload data that was provided when the task was scheduled. - /// Can be used to pass context or parameters to the callback handler. - #[serde(default)] - pub payload: String, - /// IsRecurring is true if this is a recurring schedule (created via ScheduleRecurring), - /// false if it's a one-time schedule (created via ScheduleOneTime). - #[serde(default)] - pub is_recurring: bool, -} - -/// 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<String>) -> Self { - Self { message: message.into() } - } -} - -/// CallbackProvider provides the OnCallback function. -pub trait CallbackProvider { - fn on_callback(&self, req: SchedulerCallbackRequest) -> Result<(), Error>; -} - -/// Register the on_callback export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_scheduler_callback { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_scheduler_callback( - req: extism_pdk::Json<$crate::scheduler::SchedulerCallbackRequest> - ) -> extism_pdk::FnResult<()> { - let plugin = <$plugin_type>::default(); - $crate::scheduler::CallbackProvider::on_callback(&plugin, req.into_inner())?; - Ok(()) - } - }; -} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs deleted file mode 100644 index b0b843e6c..000000000 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ /dev/null @@ -1,229 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the Scrobbler 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 } - -#[deprecated(note = "use nd_pdk::types::ArtistRef")] -pub type ArtistRef = nd_pdk_types::ArtistRef; -/// ScrobblerError represents an error type for scrobbling operations. -pub type ScrobblerError = &'static str; -/// ScrobblerErrorNotAuthorized indicates the user is not authorized. -pub const SCROBBLER_ERROR_NOT_AUTHORIZED: ScrobblerError = "scrobbler(not_authorized)"; -/// ScrobblerErrorRetryLater indicates the operation should be retried later. -pub const SCROBBLER_ERROR_RETRY_LATER: ScrobblerError = "scrobbler(retry_later)"; -/// ScrobblerErrorUnrecoverable indicates an unrecoverable error. -pub const SCROBBLER_ERROR_UNRECOVERABLE: ScrobblerError = "scrobbler(unrecoverable)"; -/// IsAuthorizedRequest is the request for authorization check. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct IsAuthorizedRequest { - /// Username is the username of the user. - #[serde(default)] - pub username: String, -} -/// NowPlayingRequest is the request for now playing notification. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NowPlayingRequest { - /// Username is the username of the user. - #[serde(default)] - pub username: String, - /// Track is the track currently playing. - #[serde(default)] - pub track: TrackInfo, - /// Position is the current playback position in seconds. - #[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")] -pub struct ScrobbleRequest { - /// Username is the username of the user. - #[serde(default)] - pub username: String, - /// Track is the track that was played. - #[serde(default)] - pub track: TrackInfo, - /// Timestamp is the Unix timestamp when the track started playing. - #[serde(default)] - pub timestamp: i64, -} -/// TrackInfo contains track metadata. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TrackInfo { - /// ID is the internal Navidrome track ID. - #[serde(default)] - pub id: String, - /// Title is the track title. - #[serde(default)] - pub title: String, - /// Album is the album name. - #[serde(default)] - pub album: String, - /// Artist is the formatted artist name for display (e.g., "Artist1 • Artist2"). - #[serde(default)] - pub artist: String, - /// AlbumArtist is the formatted album artist name for display. - #[serde(default)] - pub album_artist: String, - /// Artists is the list of track artists. - #[serde(default)] - pub artists: Vec<nd_pdk_types::ArtistRef>, - /// AlbumArtists is the list of album artists. - #[serde(default)] - pub album_artists: Vec<nd_pdk_types::ArtistRef>, - /// Duration is the track duration in seconds. - #[serde(default)] - pub duration: f32, - /// TrackNumber is the track number on the album. - #[serde(default)] - pub track_number: i32, - /// DiscNumber is the disc number. - #[serde(default)] - pub disc_number: i32, - /// MBZRecordingID is the MusicBrainz recording ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbz_recording_id: String, - /// MBZAlbumID is the MusicBrainz album/release ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbz_album_id: String, - /// MBZReleaseGroupID is the MusicBrainz release group ID. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbz_release_group_id: String, - /// 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. -#[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<String>) -> Self { - Self { message: message.into() } - } -} - -/// Scrobbler requires all methods to be implemented. -/// Scrobbler provides scrobbling functionality to external services. -/// This capability allows plugins to submit listening history to services like Last.fm, -/// ListenBrainz, or custom scrobbling backends. -/// -/// All methods are required - plugins implementing this capability must provide -/// 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<bool, Error>; - /// NowPlaying - NowPlaying sends a now playing notification to the scrobbling service. - 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. -/// This macro generates the WASM export functions for all trait methods. -#[macro_export] -macro_rules! register_scrobbler { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_scrobbler_is_authorized( - req: extism_pdk::Json<$crate::scrobbler::IsAuthorizedRequest> - ) -> extism_pdk::FnResult<extism_pdk::Json<bool>> { - let plugin = <$plugin_type>::default(); - let result = $crate::scrobbler::Scrobbler::is_authorized(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - } - #[extism_pdk::plugin_fn] - pub fn nd_scrobbler_now_playing( - req: extism_pdk::Json<$crate::scrobbler::NowPlayingRequest> - ) -> extism_pdk::FnResult<()> { - let plugin = <$plugin_type>::default(); - $crate::scrobbler::Scrobbler::now_playing(&plugin, req.into_inner())?; - Ok(()) - } - #[extism_pdk::plugin_fn] - pub fn nd_scrobbler_scrobble( - req: extism_pdk::Json<$crate::scrobbler::ScrobbleRequest> - ) -> extism_pdk::FnResult<()> { - let plugin = <$plugin_type>::default(); - $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 deleted file mode 100644 index a28df9c0a..000000000 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs +++ /dev/null @@ -1,115 +0,0 @@ -// 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 } - -#[deprecated(note = "use nd_pdk::types::ArtistRef")] -pub type ArtistRef = nd_pdk_types::ArtistRef; - -#[deprecated(note = "use nd_pdk::types::SongRef")] -pub type SongRef = nd_pdk_types::SongRef; -/// FindSonicPathRequest represents the FindSonicPathRequest data structure. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FindSonicPathRequest { - #[serde(default)] - pub start_song: nd_pdk_types::SongRef, - #[serde(default)] - pub end_song: nd_pdk_types::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: nd_pdk_types::SongRef, - #[serde(default)] - pub count: i32, -} -/// SonicMatch represents the SonicMatch data structure. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SonicMatch { - #[serde(default)] - pub song: nd_pdk_types::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<SonicMatch>, -} - -/// 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<String>) -> 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<SonicSimilarityResponse, Error>; - /// FindSonicPath - fn find_sonic_path(&self, req: FindSonicPathRequest) -> Result<SonicSimilarityResponse, Error>; -} - -/// 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<extism_pdk::Json<$crate::sonicsimilarity::SonicSimilarityResponse>> { - 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<extism_pdk::Json<$crate::sonicsimilarity::SonicSimilarityResponse>> { - 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/pdk/rust/nd-pdk-capabilities/src/taskworker.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/taskworker.rs deleted file mode 100644 index e8aa106a2..000000000 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/taskworker.rs +++ /dev/null @@ -1,102 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the TaskWorker capability. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use serde::{Deserialize, Serialize}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; - -mod base64_bytes { - use serde::{self, Deserialize, Deserializer, Serializer}; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; - - pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error> - where - S: Serializer, - { - serializer.serialize_str(&BASE64.encode(bytes)) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error> - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - BASE64.decode(&s).map_err(serde::de::Error::custom) - } -} - -// 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 } -/// TaskExecuteRequest is the request provided when a task is ready to execute. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TaskExecuteRequest { - /// QueueName is the name of the queue this task belongs to. - #[serde(default)] - pub queue_name: String, - /// TaskID is the unique identifier for this task. - #[serde(default)] - pub task_id: String, - /// Payload is the opaque data provided when the task was enqueued. - #[serde(default)] - #[serde(with = "base64_bytes")] - pub payload: Vec<u8>, - /// Attempt is the current attempt number (1-based: first attempt = 1). - #[serde(default)] - pub attempt: i32, -} - -/// 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<String>) -> Self { - Self { message: message.into() } - } -} - -/// TaskExecuteProvider provides the OnTaskExecute function. -pub trait TaskExecuteProvider { - fn on_task_execute(&self, req: TaskExecuteRequest) -> Result<String, Error>; -} - -/// Register the on_task_execute export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_taskworker_task_execute { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_task_execute( - req: extism_pdk::Json<$crate::taskworker::TaskExecuteRequest> - ) -> extism_pdk::FnResult<extism_pdk::Json<String>> { - let plugin = <$plugin_type>::default(); - let result = $crate::taskworker::TaskExecuteProvider::on_task_execute(&plugin, req.into_inner())?; - Ok(extism_pdk::Json(result)) - } - }; -} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs deleted file mode 100644 index 672233e4b..000000000 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/websocket.rs +++ /dev/null @@ -1,196 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains export wrappers for the WebSocketCallback capability. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use serde::{Deserialize, Serialize}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; - -mod base64_bytes { - use serde::{self, Deserialize, Deserializer, Serializer}; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; - - pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error> - where - S: Serializer, - { - serializer.serialize_str(&BASE64.encode(bytes)) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error> - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - BASE64.decode(&s).map_err(serde::de::Error::custom) - } -} - -// Helper functions for skip_serializing_if with numeric types -#[allow(dead_code)] -fn is_zero_i32(value: &i32) -> bool { *value == 0 } -#[allow(dead_code)] -fn is_zero_u32(value: &u32) -> bool { *value == 0 } -#[allow(dead_code)] -fn is_zero_i64(value: &i64) -> bool { *value == 0 } -#[allow(dead_code)] -fn is_zero_u64(value: &u64) -> bool { *value == 0 } -#[allow(dead_code)] -fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } -#[allow(dead_code)] -fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } -/// OnBinaryMessageRequest is the request provided when a binary message is received. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OnBinaryMessageRequest { - /// ConnectionID is the unique identifier for the WebSocket connection that received the message. - #[serde(default)] - pub connection_id: String, - /// Data is the binary data received from the WebSocket, encoded as base64. - #[serde(default)] - #[serde(with = "base64_bytes")] - pub data: Vec<u8>, -} -/// OnCloseRequest is the request provided when a WebSocket connection is closed. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OnCloseRequest { - /// ConnectionID is the unique identifier for the WebSocket connection that was closed. - #[serde(default)] - pub connection_id: String, - /// Code is the WebSocket close status code (e.g., 1000 for normal closure, - /// 1001 for going away, 1006 for abnormal closure). - #[serde(default)] - pub code: i32, - /// Reason is the human-readable reason for the connection closure, if provided. - #[serde(default)] - pub reason: String, -} -/// OnErrorRequest is the request provided when an error occurs on a WebSocket connection. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OnErrorRequest { - /// ConnectionID is the unique identifier for the WebSocket connection where the error occurred. - #[serde(default)] - pub connection_id: String, - /// Error is the error message describing what went wrong. - #[serde(default)] - pub error: String, -} -/// OnTextMessageRequest is the request provided when a text message is received. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OnTextMessageRequest { - /// ConnectionID is the unique identifier for the WebSocket connection that received the message. - #[serde(default)] - pub connection_id: String, - /// Message is the text message content received from the WebSocket. - #[serde(default)] - pub message: String, -} - -/// 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<String>) -> Self { - Self { message: message.into() } - } -} - -/// TextMessageProvider provides the OnTextMessage function. -pub trait TextMessageProvider { - fn on_text_message(&self, req: OnTextMessageRequest) -> Result<(), Error>; -} - -/// Register the on_text_message export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_websocket_text_message { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_websocket_on_text_message( - req: extism_pdk::Json<$crate::websocket::OnTextMessageRequest> - ) -> extism_pdk::FnResult<()> { - let plugin = <$plugin_type>::default(); - $crate::websocket::TextMessageProvider::on_text_message(&plugin, req.into_inner())?; - Ok(()) - } - }; -} - -/// BinaryMessageProvider provides the OnBinaryMessage function. -pub trait BinaryMessageProvider { - fn on_binary_message(&self, req: OnBinaryMessageRequest) -> Result<(), Error>; -} - -/// Register the on_binary_message export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_websocket_binary_message { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_websocket_on_binary_message( - req: extism_pdk::Json<$crate::websocket::OnBinaryMessageRequest> - ) -> extism_pdk::FnResult<()> { - let plugin = <$plugin_type>::default(); - $crate::websocket::BinaryMessageProvider::on_binary_message(&plugin, req.into_inner())?; - Ok(()) - } - }; -} - -/// ErrorProvider provides the OnError function. -pub trait ErrorProvider { - fn on_error(&self, req: OnErrorRequest) -> Result<(), Error>; -} - -/// Register the on_error export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_websocket_error { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_websocket_on_error( - req: extism_pdk::Json<$crate::websocket::OnErrorRequest> - ) -> extism_pdk::FnResult<()> { - let plugin = <$plugin_type>::default(); - $crate::websocket::ErrorProvider::on_error(&plugin, req.into_inner())?; - Ok(()) - } - }; -} - -/// CloseProvider provides the OnClose function. -pub trait CloseProvider { - fn on_close(&self, req: OnCloseRequest) -> Result<(), Error>; -} - -/// Register the on_close export. -/// This macro generates the WASM export function for this method. -#[macro_export] -macro_rules! register_websocket_close { - ($plugin_type:ty) => { - #[extism_pdk::plugin_fn] - pub fn nd_websocket_on_close( - req: extism_pdk::Json<$crate::websocket::OnCloseRequest> - ) -> extism_pdk::FnResult<()> { - let plugin = <$plugin_type>::default(); - $crate::websocket::CloseProvider::on_close(&plugin, req.into_inner())?; - Ok(()) - } - }; -} diff --git a/plugins/pdk/rust/nd-pdk-host/.gitignore b/plugins/pdk/rust/nd-pdk-host/.gitignore deleted file mode 100644 index 9da4a887b..000000000 --- a/plugins/pdk/rust/nd-pdk-host/.gitignore +++ /dev/null @@ -1 +0,0 @@ -!Cargo.lock diff --git a/plugins/pdk/rust/nd-pdk-host/Cargo.lock b/plugins/pdk/rust/nd-pdk-host/Cargo.lock deleted file mode 100644 index b4d9042d0..000000000 --- a/plugins/pdk/rust/nd-pdk-host/Cargo.lock +++ /dev/null @@ -1,380 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bytemuck" -version = "1.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "extism-convert" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f6612b4e92559eeb4c2dac88a53ee8b4729bea64025befcdeb2b3677e62fc1d" -dependencies = [ - "anyhow", - "base64", - "bytemuck", - "extism-convert-macros", - "prost", - "rmp-serde", - "serde", - "serde_json", -] - -[[package]] -name = "extism-convert-macros" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525831f1f15079a7c43514905579aac10f90fee46bc6353b683ed632029dd945" -dependencies = [ - "manyhow", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "extism-manifest" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e60e36345a96ad0d74adfca64dc22d93eb4979ab15a6c130cded5e0585f31b10" -dependencies = [ - "base64", - "serde", - "serde_json", -] - -[[package]] -name = "extism-pdk" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352fcb5a66eb74145a1c4a01f2bd15d59c62c85be73aac8471880c65b26b798f" -dependencies = [ - "anyhow", - "base64", - "extism-convert", - "extism-manifest", - "extism-pdk-derive", - "serde", - "serde_json", -] - -[[package]] -name = "extism-pdk-derive" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d086daea5fd844e3c5ac69ddfe36df4a9a43e7218cf7d1f888182b089b09806c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "indexmap" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "manyhow" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" -dependencies = [ - "manyhow-macros", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "manyhow-macros" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" -dependencies = [ - "proc-macro-utils", - "proc-macro2", - "quote", -] - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "nd-pdk-host" -version = "0.1.0" -dependencies = [ - "extism-pdk", - "serde", - "serde_json", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "proc-macro-crate" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro-utils" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" -dependencies = [ - "proc-macro2", - "quote", - "smallvec", -] - -[[package]] -name = "proc-macro2" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "prost" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "quote" -version = "1.0.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "rmp" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "rmp-serde" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" -dependencies = [ - "rmp", - "serde", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.148" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "syn" -version = "2.0.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.23.10+spec-1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.0.6+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" -dependencies = [ - "winnow", -] - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "winnow" -version = "0.7.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" -dependencies = [ - "memchr", -] - -[[package]] -name = "zmij" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f4a4e8e9dc5c62d159f04fcdbe07f4c3fb710415aab4754bf11505501e3251d" diff --git a/plugins/pdk/rust/nd-pdk-host/Cargo.toml b/plugins/pdk/rust/nd-pdk-host/Cargo.toml deleted file mode 100644 index 100ce6a35..000000000 --- a/plugins/pdk/rust/nd-pdk-host/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "nd-pdk-host" -version = "0.1.0" -edition = "2021" -description = "Navidrome host function wrappers for Rust plugins" -authors = ["Navidrome Team"] -license = "GPL-3.0" -readme = "README.md" - -[lib] -crate-type = ["rlib"] - -[dependencies] -base64 = "0.22" -extism-pdk = "1.2" -nd-pdk-types = { path = "../nd-pdk-types" } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk-host/README.md b/plugins/pdk/rust/nd-pdk-host/README.md deleted file mode 100644 index 00bcdfc2f..000000000 --- a/plugins/pdk/rust/nd-pdk-host/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# Navidrome Host Function Wrappers for Rust - -This directory contains auto-generated Rust wrappers for Navidrome's host services. -These wrappers provide idiomatic Rust APIs for interacting with Navidrome from WASM plugins. - -## ⚠️ Auto-Generated Code - -**Do not edit these files manually.** They are generated by the `ndpgen` tool. - -To regenerate: - -```bash -make gen -``` - -## Usage - -Add this crate as a dependency in your plugin's `Cargo.toml`: - -```toml -[dependencies] -nd-host = { path = "../../pdk/rust/host" } -``` - -Then import the services you need: - -```rust -use nd_host::{cache, scheduler, library}; -use nd_host::library::Library; // Import the typed struct - -#[plugin_fn] -pub fn my_callback(input: String) -> FnResult<String> { - // Use the cache service - cache::set("my_key", b"my_value", 3600)?; - - // Schedule a recurring task - scheduler::schedule_recurring("@every 5m", "payload", "task_id")?; - - // Access library data with typed structs - let libraries: Vec<Library> = library::get_all_libraries()?; - for lib in &libraries { - info!("Library: {} with {} songs", lib.name, lib.total_songs); - } - - Ok("done".to_string()) -} -``` - -## Typed Structs - -Services that work with domain objects provide typed Rust structs instead of -`serde_json::Value`. This enables compile-time type checking and IDE -autocompletion. - -For example, the `library` module provides a `Library` struct: - -```rust -use nd_host::library::Library; - -let libs: Vec<Library> = library::get_all_libraries()?; -println!("First library: {} ({} songs)", libs[0].name, libs[0].total_songs); -``` - -All structs derive `Debug`, `Clone`, `Serialize`, and `Deserialize` for -convenient use with logging and serialization. - -## Available Services - -| Module | Description | -|---------------|----------------------------------------------------| -| `artwork` | Access album and artist artwork | -| `cache` | Temporary key-value storage with TTL | -| `kvstore` | Persistent key-value storage | -| `library` | Access the music library (albums, artists, tracks) | -| `scheduler` | Schedule one-time and recurring tasks | -| `subsonicapi` | Make Subsonic API calls | -| `websocket` | Send real-time messages to clients | - -## Building Plugins - -Rust plugins must be compiled to WebAssembly: - -```bash -cargo build --target wasm32-wasip1 --release -``` - -See the [webhook-rs](../../../examples/webhook-rs/) example for a complete plugin implementation. diff --git a/plugins/pdk/rust/nd-pdk-host/src/lib.rs b/plugins/pdk/rust/nd-pdk-host/src/lib.rs deleted file mode 100644 index 33cbe7725..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/lib.rs +++ /dev/null @@ -1,149 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -//! Navidrome Host Function Wrappers for Rust Plugins -//! -//! This crate provides idiomatic Rust wrappers for all Navidrome host services. -//! It is auto-generated by the ndpgen tool and should not be edited manually. -//! -//! # Usage -//! -//! Add this crate as a dependency in your plugin's Cargo.toml: -//! -//! ```toml -//! [dependencies] -//! nd-host = { path = "../../host/rust" } -//! ``` -//! -//! Then import the services you need: -//! -//! ```ignore -//! use nd_host::{cache, scheduler}; -//! -//! fn my_plugin_function() -> Result<(), extism_pdk::Error> { -//! // Use the cache service -//! cache::set_string("my_key", "my_value", 3600)?; -//! -//! // Schedule a recurring task -//! scheduler::schedule_recurring("@every 5m", "payload", "task_id")?; -//! -//! Ok(()) -//! } -//! ``` -//! -//! # Available Services -//! -//! - [`artwork`] - provides artwork public URL generation capabilities for plugins. -//! - [`cache`] - provides in-memory TTL-based caching capabilities for plugins. -//! - [`config`] - provides access to plugin configuration values. -//! - [`http`] - provides outbound HTTP request capabilities for plugins. -//! - [`kvstore`] - provides persistent key-value storage for plugins. -//! - [`library`] - provides access to music library metadata for plugins. -//! - [`matcher`] - resolves externally-obtained songs to local library tracks, -//! - [`scheduler`] - provides task scheduling capabilities for plugins. -//! - [`scrobbleretriever`] - allows a plugin to retrieve scrobbles for one or more authorized users. -//! - [`storage`] - provides access to a plugin-specific directory with read/write permissions -//! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins. -//! - [`task`] - provides persistent task queues for plugins. -//! - [`users`] - provides access to user information for plugins. -//! - [`websocket`] - provides WebSocket communication capabilities for plugins. - -#[doc(hidden)] -mod nd_host_artwork; -/// provides artwork public URL generation capabilities for plugins. -pub mod artwork { - pub use super::nd_host_artwork::*; -} - -#[doc(hidden)] -mod nd_host_cache; -/// provides in-memory TTL-based caching capabilities for plugins. -pub mod cache { - pub use super::nd_host_cache::*; -} - -#[doc(hidden)] -mod nd_host_config; -/// provides access to plugin configuration values. -pub mod config { - pub use super::nd_host_config::*; -} - -#[doc(hidden)] -mod nd_host_http; -/// provides outbound HTTP request capabilities for plugins. -pub mod http { - pub use super::nd_host_http::*; -} - -#[doc(hidden)] -mod nd_host_kvstore; -/// provides persistent key-value storage for plugins. -pub mod kvstore { - pub use super::nd_host_kvstore::*; -} - -#[doc(hidden)] -mod nd_host_library; -/// provides access to music library metadata for plugins. -pub mod library { - pub use super::nd_host_library::*; -} - -#[doc(hidden)] -mod nd_host_matcher; -/// resolves externally-obtained songs to local library tracks, -pub mod matcher { - pub use super::nd_host_matcher::*; -} - -#[doc(hidden)] -mod nd_host_scheduler; -/// provides task scheduling capabilities for plugins. -pub mod scheduler { - pub use super::nd_host_scheduler::*; -} - -#[doc(hidden)] -mod nd_host_scrobbleretriever; -/// allows a plugin to retrieve scrobbles for one or more authorized users. -pub mod scrobbleretriever { - pub use super::nd_host_scrobbleretriever::*; -} - -#[doc(hidden)] -mod nd_host_storage; -/// provides access to a plugin-specific directory with read/write permissions -pub mod storage { - pub use super::nd_host_storage::*; -} - -#[doc(hidden)] -mod nd_host_subsonicapi; -/// provides access to Navidrome's Subsonic API from plugins. -pub mod subsonicapi { - pub use super::nd_host_subsonicapi::*; -} - -#[doc(hidden)] -mod nd_host_task; -/// provides persistent task queues for plugins. -pub mod task { - pub use super::nd_host_task::*; -} - -#[doc(hidden)] -mod nd_host_users; -/// provides access to user information for plugins. -pub mod users { - pub use super::nd_host_users::*; -} - -#[doc(hidden)] -mod nd_host_websocket; -/// provides WebSocket communication capabilities for plugins. -pub mod websocket { - pub use super::nd_host_websocket::*; -} - -// Re-export commonly used types from extism-pdk for convenience -pub use extism_pdk::Error; diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_artwork.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_artwork.rs deleted file mode 100644 index e565e0956..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_artwork.rs +++ /dev/null @@ -1,207 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Artwork host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ArtworkGetArtistUrlRequest { - id: String, - size: i32, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ArtworkGetArtistUrlResponse { - #[serde(default)] - url: String, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ArtworkGetAlbumUrlRequest { - id: String, - size: i32, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ArtworkGetAlbumUrlResponse { - #[serde(default)] - url: String, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ArtworkGetTrackUrlRequest { - id: String, - size: i32, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ArtworkGetTrackUrlResponse { - #[serde(default)] - url: String, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ArtworkGetPlaylistUrlRequest { - id: String, - size: i32, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ArtworkGetPlaylistUrlResponse { - #[serde(default)] - url: String, - #[serde(default)] - error: Option<String>, -} - -#[host_fn] -extern "ExtismHost" { - fn artwork_getartisturl(input: Json<ArtworkGetArtistUrlRequest>) -> Json<ArtworkGetArtistUrlResponse>; - fn artwork_getalbumurl(input: Json<ArtworkGetAlbumUrlRequest>) -> Json<ArtworkGetAlbumUrlResponse>; - fn artwork_gettrackurl(input: Json<ArtworkGetTrackUrlRequest>) -> Json<ArtworkGetTrackUrlResponse>; - fn artwork_getplaylisturl(input: Json<ArtworkGetPlaylistUrlRequest>) -> Json<ArtworkGetPlaylistUrlResponse>; -} - -/// GetArtistUrl generates a public URL for an artist's artwork. -/// -/// Parameters: -/// - id: The artist's unique identifier -/// - size: Desired image size in pixels (0 for original size) -/// -/// Returns the public URL for the artwork, or an error if generation fails. -/// -/// # Arguments -/// * `id` - String parameter. -/// * `size` - i32 parameter. -/// -/// # Returns -/// The url value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_artist_url(id: &str, size: i32) -> Result<String, Error> { - let response = unsafe { - artwork_getartisturl(Json(ArtworkGetArtistUrlRequest { - id: id.to_owned(), - size: size, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.url) -} - -/// GetAlbumUrl generates a public URL for an album's artwork. -/// -/// Parameters: -/// - id: The album's unique identifier -/// - size: Desired image size in pixels (0 for original size) -/// -/// Returns the public URL for the artwork, or an error if generation fails. -/// -/// # Arguments -/// * `id` - String parameter. -/// * `size` - i32 parameter. -/// -/// # Returns -/// The url value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_album_url(id: &str, size: i32) -> Result<String, Error> { - let response = unsafe { - artwork_getalbumurl(Json(ArtworkGetAlbumUrlRequest { - id: id.to_owned(), - size: size, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.url) -} - -/// GetTrackUrl generates a public URL for a track's artwork. -/// -/// Parameters: -/// - id: The track's (media file) unique identifier -/// - size: Desired image size in pixels (0 for original size) -/// -/// Returns the public URL for the artwork, or an error if generation fails. -/// -/// # Arguments -/// * `id` - String parameter. -/// * `size` - i32 parameter. -/// -/// # Returns -/// The url value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_track_url(id: &str, size: i32) -> Result<String, Error> { - let response = unsafe { - artwork_gettrackurl(Json(ArtworkGetTrackUrlRequest { - id: id.to_owned(), - size: size, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.url) -} - -/// GetPlaylistUrl generates a public URL for a playlist's artwork. -/// -/// Parameters: -/// - id: The playlist's unique identifier -/// - size: Desired image size in pixels (0 for original size) -/// -/// Returns the public URL for the artwork, or an error if generation fails. -/// -/// # Arguments -/// * `id` - String parameter. -/// * `size` - i32 parameter. -/// -/// # Returns -/// The url value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_playlist_url(id: &str, size: i32) -> Result<String, Error> { - let response = unsafe { - artwork_getplaylisturl(Json(ArtworkGetPlaylistUrlRequest { - id: id.to_owned(), - size: size, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.url) -} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_cache.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_cache.rs deleted file mode 100644 index 267654136..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_cache.rs +++ /dev/null @@ -1,521 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Cache host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; - -mod base64_bytes { - use serde::{self, Deserialize, Deserializer, Serializer}; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; - - pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error> - where - S: Serializer, - { - serializer.serialize_str(&BASE64.encode(bytes)) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error> - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - BASE64.decode(&s).map_err(serde::de::Error::custom) - } -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CacheSetStringRequest { - key: String, - value: String, - ttl_seconds: i64, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CacheSetStringResponse { - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CacheGetStringRequest { - key: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CacheGetStringResponse { - #[serde(default)] - value: String, - #[serde(default)] - exists: bool, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CacheSetIntRequest { - key: String, - value: i64, - ttl_seconds: i64, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CacheSetIntResponse { - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CacheGetIntRequest { - key: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CacheGetIntResponse { - #[serde(default)] - value: i64, - #[serde(default)] - exists: bool, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CacheSetFloatRequest { - key: String, - value: f64, - ttl_seconds: i64, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CacheSetFloatResponse { - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CacheGetFloatRequest { - key: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CacheGetFloatResponse { - #[serde(default)] - value: f64, - #[serde(default)] - exists: bool, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CacheSetBytesRequest { - key: String, - #[serde(with = "base64_bytes")] - value: Vec<u8>, - ttl_seconds: i64, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CacheSetBytesResponse { - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CacheGetBytesRequest { - key: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CacheGetBytesResponse { - #[serde(default)] - #[serde(with = "base64_bytes")] - value: Vec<u8>, - #[serde(default)] - exists: bool, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CacheHasRequest { - key: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CacheHasResponse { - #[serde(default)] - exists: bool, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CacheRemoveRequest { - key: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CacheRemoveResponse { - #[serde(default)] - error: Option<String>, -} - -#[host_fn] -extern "ExtismHost" { - fn cache_setstring(input: Json<CacheSetStringRequest>) -> Json<CacheSetStringResponse>; - fn cache_getstring(input: Json<CacheGetStringRequest>) -> Json<CacheGetStringResponse>; - fn cache_setint(input: Json<CacheSetIntRequest>) -> Json<CacheSetIntResponse>; - fn cache_getint(input: Json<CacheGetIntRequest>) -> Json<CacheGetIntResponse>; - fn cache_setfloat(input: Json<CacheSetFloatRequest>) -> Json<CacheSetFloatResponse>; - fn cache_getfloat(input: Json<CacheGetFloatRequest>) -> Json<CacheGetFloatResponse>; - fn cache_setbytes(input: Json<CacheSetBytesRequest>) -> Json<CacheSetBytesResponse>; - fn cache_getbytes(input: Json<CacheGetBytesRequest>) -> Json<CacheGetBytesResponse>; - fn cache_has(input: Json<CacheHasRequest>) -> Json<CacheHasResponse>; - fn cache_remove(input: Json<CacheRemoveRequest>) -> Json<CacheRemoveResponse>; -} - -/// SetString stores a string value in the cache. -/// -/// Parameters: -/// - key: The cache key (will be namespaced with plugin ID) -/// - value: The string value to store -/// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) -/// -/// Returns an error if the operation fails. -/// -/// # Arguments -/// * `key` - String parameter. -/// * `value` - String parameter. -/// * `ttl_seconds` - i64 parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn set_string(key: &str, value: &str, ttl_seconds: i64) -> Result<(), Error> { - let response = unsafe { - cache_setstring(Json(CacheSetStringRequest { - key: key.to_owned(), - value: value.to_owned(), - ttl_seconds: ttl_seconds, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} - -/// GetString retrieves a string value from the cache. -/// -/// Parameters: -/// - key: The cache key (will be namespaced with plugin ID) -/// -/// Returns the value and whether the key exists. If the key doesn't exist -/// or the stored value is not a string, exists will be false. -/// -/// # Arguments -/// * `key` - String parameter. -/// -/// # Returns -/// `Some(value)` if found, `None` otherwise. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_string(key: &str) -> Result<Option<String>, Error> { - let response = unsafe { - cache_getstring(Json(CacheGetStringRequest { - key: key.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - if response.0.exists { - Ok(Some(response.0.value)) - } else { - Ok(None) - } -} - -/// SetInt stores an integer value in the cache. -/// -/// Parameters: -/// - key: The cache key (will be namespaced with plugin ID) -/// - value: The integer value to store -/// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) -/// -/// Returns an error if the operation fails. -/// -/// # Arguments -/// * `key` - String parameter. -/// * `value` - i64 parameter. -/// * `ttl_seconds` - i64 parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn set_int(key: &str, value: i64, ttl_seconds: i64) -> Result<(), Error> { - let response = unsafe { - cache_setint(Json(CacheSetIntRequest { - key: key.to_owned(), - value: value, - ttl_seconds: ttl_seconds, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} - -/// GetInt retrieves an integer value from the cache. -/// -/// Parameters: -/// - key: The cache key (will be namespaced with plugin ID) -/// -/// Returns the value and whether the key exists. If the key doesn't exist -/// or the stored value is not an integer, exists will be false. -/// -/// # Arguments -/// * `key` - String parameter. -/// -/// # Returns -/// `Some(value)` if found, `None` otherwise. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_int(key: &str) -> Result<Option<i64>, Error> { - let response = unsafe { - cache_getint(Json(CacheGetIntRequest { - key: key.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - if response.0.exists { - Ok(Some(response.0.value)) - } else { - Ok(None) - } -} - -/// SetFloat stores a float value in the cache. -/// -/// Parameters: -/// - key: The cache key (will be namespaced with plugin ID) -/// - value: The float value to store -/// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) -/// -/// Returns an error if the operation fails. -/// -/// # Arguments -/// * `key` - String parameter. -/// * `value` - f64 parameter. -/// * `ttl_seconds` - i64 parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn set_float(key: &str, value: f64, ttl_seconds: i64) -> Result<(), Error> { - let response = unsafe { - cache_setfloat(Json(CacheSetFloatRequest { - key: key.to_owned(), - value: value, - ttl_seconds: ttl_seconds, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} - -/// GetFloat retrieves a float value from the cache. -/// -/// Parameters: -/// - key: The cache key (will be namespaced with plugin ID) -/// -/// Returns the value and whether the key exists. If the key doesn't exist -/// or the stored value is not a float, exists will be false. -/// -/// # Arguments -/// * `key` - String parameter. -/// -/// # Returns -/// `Some(value)` if found, `None` otherwise. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_float(key: &str) -> Result<Option<f64>, Error> { - let response = unsafe { - cache_getfloat(Json(CacheGetFloatRequest { - key: key.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - if response.0.exists { - Ok(Some(response.0.value)) - } else { - Ok(None) - } -} - -/// SetBytes stores a byte slice in the cache. -/// -/// Parameters: -/// - key: The cache key (will be namespaced with plugin ID) -/// - value: The byte slice to store -/// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) -/// -/// Returns an error if the operation fails. -/// -/// # Arguments -/// * `key` - String parameter. -/// * `value` - Vec<u8> parameter. -/// * `ttl_seconds` - i64 parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn set_bytes(key: &str, value: Vec<u8>, ttl_seconds: i64) -> Result<(), Error> { - let response = unsafe { - cache_setbytes(Json(CacheSetBytesRequest { - key: key.to_owned(), - value: value, - ttl_seconds: ttl_seconds, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} - -/// GetBytes retrieves a byte slice from the cache. -/// -/// Parameters: -/// - key: The cache key (will be namespaced with plugin ID) -/// -/// Returns the value and whether the key exists. If the key doesn't exist -/// or the stored value is not a byte slice, exists will be false. -/// -/// # Arguments -/// * `key` - String parameter. -/// -/// # Returns -/// `Some(value)` if found, `None` otherwise. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_bytes(key: &str) -> Result<Option<Vec<u8>>, Error> { - let response = unsafe { - cache_getbytes(Json(CacheGetBytesRequest { - key: key.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - if response.0.exists { - Ok(Some(response.0.value)) - } else { - Ok(None) - } -} - -/// Has checks if a key exists in the cache. -/// -/// Parameters: -/// - key: The cache key (will be namespaced with plugin ID) -/// -/// Returns true if the key exists and has not expired. -/// -/// # Arguments -/// * `key` - String parameter. -/// -/// # Returns -/// The exists value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn has(key: &str) -> Result<bool, Error> { - let response = unsafe { - cache_has(Json(CacheHasRequest { - key: key.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.exists) -} - -/// Remove deletes a value from the cache. -/// -/// Parameters: -/// - key: The cache key (will be namespaced with plugin ID) -/// -/// Returns an error if the operation fails. Does not return an error if the key doesn't exist. -/// -/// # Arguments -/// * `key` - String parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn remove(key: &str) -> Result<(), Error> { - let response = unsafe { - cache_remove(Json(CacheRemoveRequest { - key: key.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_config.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_config.rs deleted file mode 100644 index effd5923e..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_config.rs +++ /dev/null @@ -1,141 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Config host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ConfigGetRequest { - key: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ConfigGetResponse { - #[serde(default)] - value: String, - #[serde(default)] - exists: bool, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ConfigGetIntRequest { - key: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ConfigGetIntResponse { - #[serde(default)] - value: i64, - #[serde(default)] - exists: bool, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ConfigKeysRequest { - prefix: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ConfigKeysResponse { - #[serde(default)] - keys: Vec<String>, -} - -#[host_fn] -extern "ExtismHost" { - fn config_get(input: Json<ConfigGetRequest>) -> Json<ConfigGetResponse>; - fn config_getint(input: Json<ConfigGetIntRequest>) -> Json<ConfigGetIntResponse>; - fn config_keys(input: Json<ConfigKeysRequest>) -> Json<ConfigKeysResponse>; -} - -/// Get retrieves a configuration value as a string. -/// -/// Parameters: -/// - key: The configuration key -/// -/// Returns the value and whether the key exists. -/// -/// # Arguments -/// * `key` - String parameter. -/// -/// # Returns -/// `Some(value)` if found, `None` otherwise. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get(key: &str) -> Result<Option<String>, Error> { - let response = unsafe { - config_get(Json(ConfigGetRequest { - key: key.to_owned(), - }))? - }; - - if response.0.exists { - Ok(Some(response.0.value)) - } else { - Ok(None) - } -} - -/// GetInt retrieves a configuration value as an integer. -/// -/// Parameters: -/// - key: The configuration key -/// -/// Returns the value and whether the key exists. If the key exists but the -/// value cannot be parsed as an integer, exists will be false. -/// -/// # Arguments -/// * `key` - String parameter. -/// -/// # Returns -/// `Some(value)` if found, `None` otherwise. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_int(key: &str) -> Result<Option<i64>, Error> { - let response = unsafe { - config_getint(Json(ConfigGetIntRequest { - key: key.to_owned(), - }))? - }; - - if response.0.exists { - Ok(Some(response.0.value)) - } else { - Ok(None) - } -} - -/// Keys returns configuration keys matching the given prefix. -/// -/// Parameters: -/// - prefix: Key prefix to filter by. If empty, returns all keys. -/// -/// Returns a sorted slice of matching configuration keys. -/// -/// # Arguments -/// * `prefix` - String parameter. -/// -/// # Returns -/// The keys value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn keys(prefix: &str) -> Result<Vec<String>, Error> { - let response = unsafe { - config_keys(Json(ConfigKeysRequest { - prefix: prefix.to_owned(), - }))? - }; - - Ok(response.0.keys) -} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs deleted file mode 100644 index f1607112c..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_http.rs +++ /dev/null @@ -1,110 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the HTTP host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; - -mod base64_bytes { - use serde::{self, Deserialize, Deserializer, Serializer}; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; - - pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error> - where - S: Serializer, - { - serializer.serialize_str(&BASE64.encode(bytes)) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error> - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - BASE64.decode(&s).map_err(serde::de::Error::custom) - } -} - -/// HTTPRequest represents an outbound HTTP request from a plugin. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HTTPRequest { - pub method: String, - pub url: String, - #[serde(default)] - pub headers: std::collections::HashMap<String, String>, - #[serde(default)] - pub no_follow_redirects: bool, - #[serde(default)] - #[serde(with = "base64_bytes")] - pub body: Vec<u8>, - #[serde(default)] - pub timeout_ms: i32, -} - -/// HTTPResponse represents the response from an outbound HTTP request. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HTTPResponse { - pub status_code: i32, - #[serde(default)] - pub headers: std::collections::HashMap<String, String>, - #[serde(default)] - #[serde(with = "base64_bytes")] - pub body: Vec<u8>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct HTTPSendRequest { - request: HTTPRequest, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct HTTPSendResponse { - #[serde(default)] - result: Option<HTTPResponse>, - #[serde(default)] - error: Option<String>, -} - -#[host_fn] -extern "ExtismHost" { - fn http_send(input: Json<HTTPSendRequest>) -> Json<HTTPSendResponse>; -} - -/// Send executes an HTTP request and returns the response. -/// -/// Parameters: -/// - request: The HTTP request to execute, including method, URL, headers, body, and timeout -/// -/// Returns the HTTP response with status code, headers, and body. -/// Network errors, timeouts, and permission failures are returned as Go errors. -/// Successful HTTP calls (including 4xx/5xx status codes) return a non-nil response with nil error. -/// -/// # Arguments -/// * `request` - HTTPRequest parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn send(request: HTTPRequest) -> Result<Option<HTTPResponse>, Error> { - let response = unsafe { - http_send(Json(HTTPSendRequest { - request: request, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_kvstore.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_kvstore.rs deleted file mode 100644 index a85e72895..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_kvstore.rs +++ /dev/null @@ -1,433 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the KVStore host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; - -mod base64_bytes { - use serde::{self, Deserialize, Deserializer, Serializer}; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; - - pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error> - where - S: Serializer, - { - serializer.serialize_str(&BASE64.encode(bytes)) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error> - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - BASE64.decode(&s).map_err(serde::de::Error::custom) - } -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreSetRequest { - key: String, - #[serde(with = "base64_bytes")] - value: Vec<u8>, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreSetResponse { - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreSetWithTTLRequest { - key: String, - #[serde(with = "base64_bytes")] - value: Vec<u8>, - ttl_seconds: i64, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreSetWithTTLResponse { - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreGetRequest { - key: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreGetResponse { - #[serde(default)] - #[serde(with = "base64_bytes")] - value: Vec<u8>, - #[serde(default)] - exists: bool, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreGetManyRequest { - keys: Vec<String>, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreGetManyResponse { - #[serde(default)] - values: std::collections::HashMap<String, Vec<u8>>, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreHasRequest { - key: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreHasResponse { - #[serde(default)] - exists: bool, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreListRequest { - prefix: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreListResponse { - #[serde(default)] - keys: Vec<String>, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreDeleteRequest { - key: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreDeleteResponse { - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreDeleteByPrefixRequest { - prefix: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreDeleteByPrefixResponse { - #[serde(default)] - deleted_count: i64, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct KVStoreGetStorageUsedResponse { - #[serde(default)] - bytes: i64, - #[serde(default)] - error: Option<String>, -} - -#[host_fn] -extern "ExtismHost" { - fn kvstore_set(input: Json<KVStoreSetRequest>) -> Json<KVStoreSetResponse>; - fn kvstore_setwithttl(input: Json<KVStoreSetWithTTLRequest>) -> Json<KVStoreSetWithTTLResponse>; - fn kvstore_get(input: Json<KVStoreGetRequest>) -> Json<KVStoreGetResponse>; - fn kvstore_getmany(input: Json<KVStoreGetManyRequest>) -> Json<KVStoreGetManyResponse>; - fn kvstore_has(input: Json<KVStoreHasRequest>) -> Json<KVStoreHasResponse>; - fn kvstore_list(input: Json<KVStoreListRequest>) -> Json<KVStoreListResponse>; - fn kvstore_delete(input: Json<KVStoreDeleteRequest>) -> Json<KVStoreDeleteResponse>; - fn kvstore_deletebyprefix(input: Json<KVStoreDeleteByPrefixRequest>) -> Json<KVStoreDeleteByPrefixResponse>; - fn kvstore_getstorageused(input: Json<serde_json::Value>) -> Json<KVStoreGetStorageUsedResponse>; -} - -/// Set stores a byte value with the given key. -/// -/// Parameters: -/// - key: The storage key (max 256 bytes, UTF-8) -/// - value: The byte slice to store -/// -/// Returns an error if the storage limit would be exceeded or the operation fails. -/// -/// # Arguments -/// * `key` - String parameter. -/// * `value` - Vec<u8> parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn set(key: &str, value: Vec<u8>) -> Result<(), Error> { - let response = unsafe { - kvstore_set(Json(KVStoreSetRequest { - key: key.to_owned(), - value: value, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} - -/// SetWithTTL stores a byte value with the given key and a time-to-live. -/// -/// After ttlSeconds, the key is treated as non-existent and will be -/// cleaned up lazily. ttlSeconds must be greater than 0. -/// -/// Parameters: -/// - key: The storage key (max 256 bytes, UTF-8) -/// - value: The byte slice to store -/// - ttlSeconds: Time-to-live in seconds (must be > 0) -/// -/// Returns an error if the storage limit would be exceeded or the operation fails. -/// -/// # Arguments -/// * `key` - String parameter. -/// * `value` - Vec<u8> parameter. -/// * `ttl_seconds` - i64 parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn set_with_ttl(key: &str, value: Vec<u8>, ttl_seconds: i64) -> Result<(), Error> { - let response = unsafe { - kvstore_setwithttl(Json(KVStoreSetWithTTLRequest { - key: key.to_owned(), - value: value, - ttl_seconds: ttl_seconds, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} - -/// Get retrieves a byte value from storage. -/// -/// Parameters: -/// - key: The storage key -/// -/// Returns the value and whether the key exists. -/// -/// # Arguments -/// * `key` - String parameter. -/// -/// # Returns -/// `Some(value)` if found, `None` otherwise. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get(key: &str) -> Result<Option<Vec<u8>>, Error> { - let response = unsafe { - kvstore_get(Json(KVStoreGetRequest { - key: key.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - if response.0.exists { - Ok(Some(response.0.value)) - } else { - Ok(None) - } -} - -/// GetMany retrieves multiple values in a single call. -/// -/// Parameters: -/// - keys: The storage keys to retrieve -/// -/// Returns a map of key to value for keys that exist and have not expired. -/// Missing or expired keys are omitted from the result. -/// -/// # Arguments -/// * `keys` - Vec<String> parameter. -/// -/// # Returns -/// The values value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_many(keys: Vec<String>) -> Result<std::collections::HashMap<String, Vec<u8>>, Error> { - let response = unsafe { - kvstore_getmany(Json(KVStoreGetManyRequest { - keys: keys, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.values) -} - -/// Has checks if a key exists in storage. -/// -/// Parameters: -/// - key: The storage key -/// -/// Returns true if the key exists. -/// -/// # Arguments -/// * `key` - String parameter. -/// -/// # Returns -/// The exists value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn has(key: &str) -> Result<bool, Error> { - let response = unsafe { - kvstore_has(Json(KVStoreHasRequest { - key: key.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.exists) -} - -/// List returns all keys matching the given prefix. -/// -/// Parameters: -/// - prefix: Key prefix to filter by (empty string returns all keys) -/// -/// Returns a slice of matching keys. -/// -/// # Arguments -/// * `prefix` - String parameter. -/// -/// # Returns -/// The keys value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn list(prefix: &str) -> Result<Vec<String>, Error> { - let response = unsafe { - kvstore_list(Json(KVStoreListRequest { - prefix: prefix.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.keys) -} - -/// Delete removes a value from storage. -/// -/// Parameters: -/// - key: The storage key -/// -/// Returns an error if the operation fails. Does not return an error if the key doesn't exist. -/// -/// # Arguments -/// * `key` - String parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn delete(key: &str) -> Result<(), Error> { - let response = unsafe { - kvstore_delete(Json(KVStoreDeleteRequest { - key: key.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} - -/// DeleteByPrefix removes all keys matching the given prefix. -/// -/// Parameters: -/// - prefix: Key prefix to match (must not be empty) -/// -/// Returns the number of keys deleted. Includes expired keys. -/// -/// # Arguments -/// * `prefix` - String parameter. -/// -/// # Returns -/// The deleted_count value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn delete_by_prefix(prefix: &str) -> Result<i64, Error> { - let response = unsafe { - kvstore_deletebyprefix(Json(KVStoreDeleteByPrefixRequest { - prefix: prefix.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.deleted_count) -} - -/// GetStorageUsed returns the total storage used by this plugin in bytes. -/// -/// # Returns -/// The bytes value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_storage_used() -> Result<i64, Error> { - let response = unsafe { - kvstore_getstorageused(Json(serde_json::json!({})))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.bytes) -} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_library.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_library.rs deleted file mode 100644 index 966c1607f..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_library.rs +++ /dev/null @@ -1,105 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Library host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -/// Library represents a music library with metadata. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Library { - pub id: i32, - pub name: String, - #[serde(default)] - pub path: String, - #[serde(default)] - pub mount_point: String, - pub last_scan_at: i64, - pub total_songs: i32, - pub total_albums: i32, - pub total_artists: i32, - pub total_size: i64, - pub total_duration: f64, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct LibraryGetLibraryRequest { - id: i32, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct LibraryGetLibraryResponse { - #[serde(default)] - result: Option<Library>, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct LibraryGetAllLibrariesResponse { - #[serde(default)] - result: Vec<Library>, - #[serde(default)] - error: Option<String>, -} - -#[host_fn] -extern "ExtismHost" { - fn library_getlibrary(input: Json<LibraryGetLibraryRequest>) -> Json<LibraryGetLibraryResponse>; - fn library_getalllibraries(input: Json<serde_json::Value>) -> Json<LibraryGetAllLibrariesResponse>; -} - -/// GetLibrary retrieves metadata for a specific library by ID. -/// -/// Parameters: -/// - id: The library's unique identifier -/// -/// Returns the library metadata, or an error if the library is not found. -/// -/// # Arguments -/// * `id` - i32 parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_library(id: i32) -> Result<Option<Library>, Error> { - let response = unsafe { - library_getlibrary(Json(LibraryGetLibraryRequest { - id: id, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} - -/// GetAllLibraries retrieves metadata for all configured libraries. -/// -/// Returns a slice of all libraries with their metadata. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_all_libraries() -> Result<Vec<Library>, Error> { - let response = unsafe { - library_getalllibraries(Json(serde_json::json!({})))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs deleted file mode 100644 index 7fdf88b7f..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_matcher.rs +++ /dev/null @@ -1,65 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Matcher host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -/// MatchOptions carries optional parameters for a match request. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MatchOptions { - #[serde(default)] - pub username: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct MatcherMatchSongsRequest { - songs: Vec<nd_pdk_types::SongRef>, - opts: MatchOptions, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct MatcherMatchSongsResponse { - #[serde(default)] - results: Vec<Option<nd_pdk_types::Track>>, - #[serde(default)] - error: Option<String>, -} - -#[host_fn] -extern "ExtismHost" { - fn matcher_matchsongs(input: Json<MatcherMatchSongsRequest>) -> Json<MatcherMatchSongsResponse>; -} - -/// MatchSongs resolves each input song to its best-matching library track. -/// It returns one entry per input song, in the same order as the input; the -/// entry for an input song that had no match is empty (absent). Results are -/// limited to the libraries the plugin (and the scoped user, if any) can access. -/// -/// # Arguments -/// * `songs` - Vec<nd_pdk_types::SongRef> parameter. -/// * `opts` - MatchOptions parameter. -/// -/// # Returns -/// The results value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn match_songs(songs: Vec<nd_pdk_types::SongRef>, opts: MatchOptions) -> Result<Vec<Option<nd_pdk_types::Track>>, Error> { - let response = unsafe { - matcher_matchsongs(Json(MatcherMatchSongsRequest { - songs: songs, - opts: opts, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.results) -} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scheduler.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scheduler.rs deleted file mode 100644 index 042f97410..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scheduler.rs +++ /dev/null @@ -1,159 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Scheduler host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SchedulerScheduleOneTimeRequest { - delay_seconds: i32, - payload: String, - schedule_id: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SchedulerScheduleOneTimeResponse { - #[serde(default)] - new_schedule_id: String, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SchedulerScheduleRecurringRequest { - cron_expression: String, - payload: String, - schedule_id: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SchedulerScheduleRecurringResponse { - #[serde(default)] - new_schedule_id: String, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SchedulerCancelScheduleRequest { - schedule_id: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SchedulerCancelScheduleResponse { - #[serde(default)] - error: Option<String>, -} - -#[host_fn] -extern "ExtismHost" { - fn scheduler_scheduleonetime(input: Json<SchedulerScheduleOneTimeRequest>) -> Json<SchedulerScheduleOneTimeResponse>; - fn scheduler_schedulerecurring(input: Json<SchedulerScheduleRecurringRequest>) -> Json<SchedulerScheduleRecurringResponse>; - fn scheduler_cancelschedule(input: Json<SchedulerCancelScheduleRequest>) -> Json<SchedulerCancelScheduleResponse>; -} - -/// ScheduleOneTime schedules a one-time event to be triggered after the specified delay. -/// Plugins that use this function must also implement the SchedulerCallback capability -/// -/// Parameters: -/// - delaySeconds: Number of seconds to wait before triggering the event -/// - payload: Data to be passed to the scheduled event handler -/// - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated -/// -/// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. -/// -/// # Arguments -/// * `delay_seconds` - i32 parameter. -/// * `payload` - String parameter. -/// * `schedule_id` - String parameter. -/// -/// # Returns -/// The new_schedule_id value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn schedule_one_time(delay_seconds: i32, payload: &str, schedule_id: &str) -> Result<String, Error> { - let response = unsafe { - scheduler_scheduleonetime(Json(SchedulerScheduleOneTimeRequest { - delay_seconds: delay_seconds, - payload: payload.to_owned(), - schedule_id: schedule_id.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.new_schedule_id) -} - -/// ScheduleRecurring schedules a recurring event using a cron expression. -/// Plugins that use this function must also implement the SchedulerCallback capability -/// -/// Parameters: -/// - cronExpression: Standard cron format expression (e.g., "0 0 * * *" for daily at midnight) -/// - payload: Data to be passed to each scheduled event handler invocation -/// - scheduleID: Optional unique identifier for the scheduled job. If empty, one will be generated -/// -/// Returns the schedule ID that can be used to cancel the job, or an error if scheduling fails. -/// -/// # Arguments -/// * `cron_expression` - String parameter. -/// * `payload` - String parameter. -/// * `schedule_id` - String parameter. -/// -/// # Returns -/// The new_schedule_id value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn schedule_recurring(cron_expression: &str, payload: &str, schedule_id: &str) -> Result<String, Error> { - let response = unsafe { - scheduler_schedulerecurring(Json(SchedulerScheduleRecurringRequest { - cron_expression: cron_expression.to_owned(), - payload: payload.to_owned(), - schedule_id: schedule_id.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.new_schedule_id) -} - -/// CancelSchedule cancels a scheduled job identified by its schedule ID. -/// -/// This works for both one-time and recurring schedules. Once cancelled, the job will not trigger -/// any future events. -/// -/// Returns an error if the schedule ID is not found or if cancellation fails. -/// -/// # Arguments -/// * `schedule_id` - String parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn cancel_schedule(schedule_id: &str) -> Result<(), Error> { - let response = unsafe { - scheduler_cancelschedule(Json(SchedulerCancelScheduleRequest { - schedule_id: schedule_id.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs deleted file mode 100644 index 19010924a..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_scrobbleretriever.rs +++ /dev/null @@ -1,237 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the ScrobbleRetriever host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -/// ScrobbleCountOptions carries optional parameters for counting user scrobbles -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ScrobbleCountOptions { - #[serde(default)] - pub from_timestamp: Option<i64>, - #[serde(default)] - pub to_timestamp: Option<i64>, -} - -/// ScrobbleOptions carries optional parameters for retrieving user scrobbles -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ScrobbleOptions { - #[serde(default)] - pub from_timestamp: Option<i64>, - #[serde(default)] - pub to_timestamp: Option<i64>, - pub descending: bool, - pub max_items: i32, - #[serde(default)] - pub offset: i32, -} - -/// ScrobbleRef represents one instance of a scrobble (instance id, file id, submission time) -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ScrobbleRef { - pub id: i64, - pub media_file_id: String, - pub submission_time: i64, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetFirstTimestampRequest { - username: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetFirstTimestampResponse { - #[serde(default)] - result: Option<i64>, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetLastTimestampRequest { - username: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetLastTimestampResponse { - #[serde(default)] - result: Option<i64>, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetScrobblesRequest { - username: String, - options: ScrobbleOptions, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetScrobblesResponse { - #[serde(default)] - scrobbles: Vec<ScrobbleRef>, - #[serde(default)] - next: Option<ScrobbleOptions>, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetScrobbleCountRequest { - username: String, - options: ScrobbleCountOptions, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ScrobbleRetrieverGetScrobbleCountResponse { - #[serde(default)] - result: i64, - #[serde(default)] - error: Option<String>, -} - -#[host_fn] -extern "ExtismHost" { - fn scrobbleretriever_getfirsttimestamp(input: Json<ScrobbleRetrieverGetFirstTimestampRequest>) -> Json<ScrobbleRetrieverGetFirstTimestampResponse>; - fn scrobbleretriever_getlasttimestamp(input: Json<ScrobbleRetrieverGetLastTimestampRequest>) -> Json<ScrobbleRetrieverGetLastTimestampResponse>; - fn scrobbleretriever_getscrobbles(input: Json<ScrobbleRetrieverGetScrobblesRequest>) -> Json<ScrobbleRetrieverGetScrobblesResponse>; - fn scrobbleretriever_getscrobblecount(input: Json<ScrobbleRetrieverGetScrobbleCountRequest>) -> Json<ScrobbleRetrieverGetScrobbleCountResponse>; -} - -/// GetFirstTimestamp returns the unix timestamp of the oldest scrobble for the user. -/// If the user has no scrobbles, returns nil -/// -/// # Arguments -/// * `username` - String parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_first_timestamp(username: &str) -> Result<Option<i64>, Error> { - let response = unsafe { - scrobbleretriever_getfirsttimestamp(Json(ScrobbleRetrieverGetFirstTimestampRequest { - username: username.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} - -/// GetLastTimestamp returns the unix timestamp of the most recent scrobble for the user -/// If the user has no scrobbles, return nil -/// -/// # Arguments -/// * `username` - String parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_last_timestamp(username: &str) -> Result<Option<i64>, Error> { - let response = unsafe { - scrobbleretriever_getlasttimestamp(Json(ScrobbleRetrieverGetLastTimestampRequest { - username: username.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} - -/// GetScrobbles returns one page of scrobbles for a user. -/// -/// Parameters: -/// - username: the user to query for scrobbles -/// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble -/// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble -/// - options.Descending: If true, order from newest to oldest. Otherwise, oldest to newest -/// - options.MaxItems: The maximum number of items to retrieve. The maximum value (and default) if not specified is 5000 -/// - options.Offset: Pagination state; only valid as received on the options returned by a previous call. Never set it manually -/// -/// Returns: -/// - scrobbles: The scrobbles in the requested range, ordered by submission time -/// (ties broken by scrobble ID) in the direction given by options.Descending -/// - next: The options for the following page, or nil once no scrobbles remain. -/// Pass it back to GetScrobbles unchanged and repeat until it is nil. It carries an -/// adjusted FromTimestamp/ToTimestamp, so keep a copy if you still need the original range -/// -/// # Arguments -/// * `username` - String parameter. -/// * `options` - ScrobbleOptions parameter. -/// -/// # Returns -/// A tuple of (scrobbles, next). -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_scrobbles(username: &str, options: ScrobbleOptions) -> Result<(Vec<ScrobbleRef>, Option<ScrobbleOptions>), Error> { - let response = unsafe { - scrobbleretriever_getscrobbles(Json(ScrobbleRetrieverGetScrobblesRequest { - username: username.to_owned(), - options: options, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok((response.0.scrobbles, response.0.next)) -} - -/// GetScrobbleCount returns the number of scrobbles for a user in a given range -/// -/// Parameters: -/// - username: the user to query for scrobbles -/// - options.FromTimestamp: If specified, the first UNIX timestamp to start fetching scrobbles (inclusive). Otherwise, start from the first scrobble -/// - options.ToTimestamp: If specified, the last UNIX timestamp to fetch (inclusive). Otherwise, end at the last scrobble -/// -/// Returns: -/// - the number of scrobbles in the given range, or 0 -/// -/// # Arguments -/// * `username` - String parameter. -/// * `options` - ScrobbleCountOptions parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_scrobble_count(username: &str, options: ScrobbleCountOptions) -> Result<i64, Error> { - let response = unsafe { - scrobbleretriever_getscrobblecount(Json(ScrobbleRetrieverGetScrobbleCountRequest { - username: username.to_owned(), - options: options, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs deleted file mode 100644 index 336ae565f..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_storage.rs +++ /dev/null @@ -1,34 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Storage host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct StorageGetStoragePathResponse { - #[serde(default)] - result: String, -} - -#[host_fn] -extern "ExtismHost" { - fn storage_getstoragepath(input: Json<serde_json::Value>) -> Json<StorageGetStoragePathResponse>; -} - -/// GetStoragePath retrieves the persistent storage path, if allowed -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_storage_path() -> Result<String, Error> { - let response = unsafe { - storage_getstoragepath(Json(serde_json::json!({})))? - }; - - Ok(response.0.result) -} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs deleted file mode 100644 index 56ba1066e..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_subsonicapi.rs +++ /dev/null @@ -1,122 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the SubsonicAPI host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; - -mod base64_bytes { - use serde::{self, Deserialize, Deserializer, Serializer}; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; - - pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error> - where - S: Serializer, - { - serializer.serialize_str(&BASE64.encode(bytes)) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error> - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - BASE64.decode(&s).map_err(serde::de::Error::custom) - } -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SubsonicAPICallRequest { - uri: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SubsonicAPICallResponse { - #[serde(default)] - response_json: String, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SubsonicAPICallRawRequest { - uri: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SubsonicAPICallRawResponse { - #[serde(default)] - content_type: String, - #[serde(default)] - #[serde(with = "base64_bytes")] - data: Vec<u8>, - #[serde(default)] - error: Option<String>, -} - -#[host_fn] -extern "ExtismHost" { - fn subsonicapi_call(input: Json<SubsonicAPICallRequest>) -> Json<SubsonicAPICallResponse>; - fn subsonicapi_callraw(input: Json<SubsonicAPICallRawRequest>) -> Json<SubsonicAPICallRawResponse>; -} - -/// Call executes a Subsonic API request and returns the JSON response. -/// -/// The uri parameter should be the Subsonic API path without the server prefix, -/// e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON. -/// -/// # Arguments -/// * `uri` - String parameter. -/// -/// # Returns -/// The response_json value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn call(uri: &str) -> Result<String, Error> { - let response = unsafe { - subsonicapi_call(Json(SubsonicAPICallRequest { - uri: uri.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.response_json) -} - -/// CallRaw executes a Subsonic API request and returns the raw binary response. -/// Designed for binary endpoints like getCoverArt and stream that return -/// non-JSON data. The data is base64-encoded over JSON on the wire. -/// -/// # Arguments -/// * `uri` - String parameter. -/// -/// # Returns -/// A tuple of (content_type, data). -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn call_raw(uri: &str) -> Result<(String, Vec<u8>), Error> { - let response = unsafe { - subsonicapi_callraw(Json(SubsonicAPICallRawRequest { - uri: uri.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok((response.0.content_type, response.0.data)) -} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs deleted file mode 100644 index ac82fdd08..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_task.rs +++ /dev/null @@ -1,258 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Task host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; - -mod base64_bytes { - use serde::{self, Deserialize, Deserializer, Serializer}; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; - - pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error> - where - S: Serializer, - { - serializer.serialize_str(&BASE64.encode(bytes)) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error> - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - BASE64.decode(&s).map_err(serde::de::Error::custom) - } -} - -/// QueueConfig holds configuration for a task queue. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct QueueConfig { - pub concurrency: i32, - pub max_retries: i32, - pub backoff_ms: i64, - pub delay_ms: i64, - pub retention_ms: i64, -} - -/// TaskInfo holds the current state of a task. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TaskInfo { - pub status: String, - pub message: String, - pub attempt: i32, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct TaskCreateQueueRequest { - name: String, - config: QueueConfig, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct TaskCreateQueueResponse { - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct TaskEnqueueRequest { - queue_name: String, - #[serde(with = "base64_bytes")] - payload: Vec<u8>, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct TaskEnqueueResponse { - #[serde(default)] - result: String, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct TaskGetRequest { - task_id: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct TaskGetResponse { - #[serde(default)] - result: Option<TaskInfo>, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct TaskCancelRequest { - task_id: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct TaskCancelResponse { - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct TaskClearQueueRequest { - queue_name: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct TaskClearQueueResponse { - #[serde(default)] - result: i64, - #[serde(default)] - error: Option<String>, -} - -#[host_fn] -extern "ExtismHost" { - fn task_createqueue(input: Json<TaskCreateQueueRequest>) -> Json<TaskCreateQueueResponse>; - fn task_enqueue(input: Json<TaskEnqueueRequest>) -> Json<TaskEnqueueResponse>; - fn task_get(input: Json<TaskGetRequest>) -> Json<TaskGetResponse>; - fn task_cancel(input: Json<TaskCancelRequest>) -> Json<TaskCancelResponse>; - fn task_clearqueue(input: Json<TaskClearQueueRequest>) -> Json<TaskClearQueueResponse>; -} - -/// CreateQueue creates a named task queue with the given configuration. -/// Zero-value fields in config use sensible defaults. -/// If a queue with the same name already exists, returns an error. -/// On startup, this also recovers any stale "running" tasks from a previous crash. -/// -/// # Arguments -/// * `name` - String parameter. -/// * `config` - QueueConfig parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn create_queue(name: &str, config: QueueConfig) -> Result<(), Error> { - let response = unsafe { - task_createqueue(Json(TaskCreateQueueRequest { - name: name.to_owned(), - config: config, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} - -/// Enqueue adds a task to the named queue. Returns the task ID. -/// payload is opaque bytes passed back to the plugin on execution. -/// -/// # Arguments -/// * `queue_name` - String parameter. -/// * `payload` - Vec<u8> parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn enqueue(queue_name: &str, payload: Vec<u8>) -> Result<String, Error> { - let response = unsafe { - task_enqueue(Json(TaskEnqueueRequest { - queue_name: queue_name.to_owned(), - payload: payload, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} - -/// Get returns the current state of a task including its status, -/// message, and attempt count. -/// -/// # Arguments -/// * `task_id` - String parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get(task_id: &str) -> Result<Option<TaskInfo>, Error> { - let response = unsafe { - task_get(Json(TaskGetRequest { - task_id: task_id.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} - -/// Cancel cancels a pending task. Returns error if already -/// running, completed, or failed. -/// -/// # Arguments -/// * `task_id` - String parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn cancel(task_id: &str) -> Result<(), Error> { - let response = unsafe { - task_cancel(Json(TaskCancelRequest { - task_id: task_id.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} - -/// ClearQueue removes all pending tasks from the named queue. -/// Running tasks are not affected. Returns the number of tasks removed. -/// -/// # Arguments -/// * `queue_name` - String parameter. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn clear_queue(queue_name: &str) -> Result<i64, Error> { - let response = unsafe { - task_clearqueue(Json(TaskClearQueueRequest { - queue_name: queue_name.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs deleted file mode 100644 index b31b39fb4..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs +++ /dev/null @@ -1,86 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the Users host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; - -/// User represents a Navidrome user with minimal information exposed to plugins. -/// Sensitive fields like password, email, and internal IDs are intentionally excluded. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct User { - pub user_name: String, - pub name: String, - pub is_admin: bool, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct UsersGetUsersResponse { - #[serde(default)] - result: Vec<User>, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct UsersGetAdminsResponse { - #[serde(default)] - result: Vec<User>, - #[serde(default)] - error: Option<String>, -} - -#[host_fn] -extern "ExtismHost" { - fn users_getusers(input: Json<serde_json::Value>) -> Json<UsersGetUsersResponse>; - fn users_getadmins(input: Json<serde_json::Value>) -> Json<UsersGetAdminsResponse>; -} - -/// GetUsers returns all users the plugin has been granted access to. -/// Only minimal user information (userName, name, isAdmin) is returned. -/// Sensitive fields like password and email are never exposed. -/// -/// Returns a slice of users the plugin can access, or an empty slice if none configured. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_users() -> Result<Vec<User>, Error> { - let response = unsafe { - users_getusers(Json(serde_json::json!({})))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} - -/// GetAdmins returns only admin users the plugin has been granted access to. -/// This is a convenience method that filters GetUsers results to include only admins. -/// -/// Returns a slice of admin users the plugin can access, or an empty slice if none. -/// -/// # Returns -/// The result value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn get_admins() -> Result<Vec<User>, Error> { - let response = unsafe { - users_getadmins(Json(serde_json::json!({})))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.result) -} diff --git a/plugins/pdk/rust/nd-pdk-host/src/nd_host_websocket.rs b/plugins/pdk/rust/nd-pdk-host/src/nd_host_websocket.rs deleted file mode 100644 index 05ceb5407..000000000 --- a/plugins/pdk/rust/nd-pdk-host/src/nd_host_websocket.rs +++ /dev/null @@ -1,228 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -// This file contains client wrappers for the WebSocket host service. -// It is intended for use in Navidrome plugins built with extism-pdk. - -use extism_pdk::*; -use serde::{Deserialize, Serialize}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; - -mod base64_bytes { - use serde::{self, Deserialize, Deserializer, Serializer}; - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD as BASE64; - - pub fn serialize<S>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error> - where - S: Serializer, - { - serializer.serialize_str(&BASE64.encode(bytes)) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error> - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - BASE64.decode(&s).map_err(serde::de::Error::custom) - } -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct WebSocketConnectRequest { - url: String, - headers: std::collections::HashMap<String, String>, - connection_id: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WebSocketConnectResponse { - #[serde(default)] - new_connection_id: String, - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct WebSocketSendTextRequest { - connection_id: String, - message: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WebSocketSendTextResponse { - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct WebSocketSendBinaryRequest { - connection_id: String, - #[serde(with = "base64_bytes")] - data: Vec<u8>, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WebSocketSendBinaryResponse { - #[serde(default)] - error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct WebSocketCloseConnectionRequest { - connection_id: String, - code: i32, - reason: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WebSocketCloseConnectionResponse { - #[serde(default)] - error: Option<String>, -} - -#[host_fn] -extern "ExtismHost" { - fn websocket_connect(input: Json<WebSocketConnectRequest>) -> Json<WebSocketConnectResponse>; - fn websocket_sendtext(input: Json<WebSocketSendTextRequest>) -> Json<WebSocketSendTextResponse>; - fn websocket_sendbinary(input: Json<WebSocketSendBinaryRequest>) -> Json<WebSocketSendBinaryResponse>; - fn websocket_closeconnection(input: Json<WebSocketCloseConnectionRequest>) -> Json<WebSocketCloseConnectionResponse>; -} - -/// Connect establishes a WebSocket connection to the specified URL. -/// -/// Plugins that use this function must also implement the WebSocketCallback capability -/// to receive incoming messages and connection events. -/// -/// Parameters: -/// - url: The WebSocket URL to connect to (ws:// or wss://) -/// - headers: Optional HTTP headers to include in the handshake request -/// - connectionID: Optional unique identifier for the connection. If empty, one will be generated -/// -/// Returns the connection ID that can be used to send messages or close the connection, -/// or an error if the connection fails. -/// -/// # Arguments -/// * `url` - String parameter. -/// * `headers` - std::collections::HashMap<String, String> parameter. -/// * `connection_id` - String parameter. -/// -/// # Returns -/// The new_connection_id value. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn connect(url: &str, headers: std::collections::HashMap<String, String>, connection_id: &str) -> Result<String, Error> { - let response = unsafe { - websocket_connect(Json(WebSocketConnectRequest { - url: url.to_owned(), - headers: headers, - connection_id: connection_id.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(response.0.new_connection_id) -} - -/// SendText sends a text message over an established WebSocket connection. -/// -/// Parameters: -/// - connectionID: The connection identifier returned by Connect -/// - message: The text message to send -/// -/// Returns an error if the connection is not found or if sending fails. -/// -/// # Arguments -/// * `connection_id` - String parameter. -/// * `message` - String parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn send_text(connection_id: &str, message: &str) -> Result<(), Error> { - let response = unsafe { - websocket_sendtext(Json(WebSocketSendTextRequest { - connection_id: connection_id.to_owned(), - message: message.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} - -/// SendBinary sends binary data over an established WebSocket connection. -/// -/// Parameters: -/// - connectionID: The connection identifier returned by Connect -/// - data: The binary data to send -/// -/// Returns an error if the connection is not found or if sending fails. -/// -/// # Arguments -/// * `connection_id` - String parameter. -/// * `data` - Vec<u8> parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn send_binary(connection_id: &str, data: Vec<u8>) -> Result<(), Error> { - let response = unsafe { - websocket_sendbinary(Json(WebSocketSendBinaryRequest { - connection_id: connection_id.to_owned(), - data: data, - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} - -/// CloseConnection gracefully closes a WebSocket connection. -/// -/// Parameters: -/// - connectionID: The connection identifier returned by Connect -/// - code: WebSocket close status code (e.g., 1000 for normal closure) -/// - reason: Optional human-readable reason for closing -/// -/// Returns an error if the connection is not found or if closing fails. -/// -/// # Arguments -/// * `connection_id` - String parameter. -/// * `code` - i32 parameter. -/// * `reason` - String parameter. -/// -/// # Errors -/// Returns an error if the host function call fails. -pub fn close_connection(connection_id: &str, code: i32, reason: &str) -> Result<(), Error> { - let response = unsafe { - websocket_closeconnection(Json(WebSocketCloseConnectionRequest { - connection_id: connection_id.to_owned(), - code: code, - reason: reason.to_owned(), - }))? - }; - - if let Some(err) = response.0.error { - return Err(Error::msg(err)); - } - - Ok(()) -} diff --git a/plugins/pdk/rust/nd-pdk-types/Cargo.toml b/plugins/pdk/rust/nd-pdk-types/Cargo.toml deleted file mode 100644 index e20e07021..000000000 --- a/plugins/pdk/rust/nd-pdk-types/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "nd-pdk-types" -version = "0.1.0" -edition = "2021" -description = "Navidrome shared plugin data types for Rust" -authors = ["Navidrome Team"] -license = "GPL-3.0" - -[lib] -path = "src/lib.rs" -crate-type = ["rlib"] - -[dependencies] -base64 = "0.22" -serde = { version = "1.0", features = ["derive"] } diff --git a/plugins/pdk/rust/nd-pdk-types/src/lib.rs b/plugins/pdk/rust/nd-pdk-types/src/lib.rs deleted file mode 100644 index fa1a87048..000000000 --- a/plugins/pdk/rust/nd-pdk-types/src/lib.rs +++ /dev/null @@ -1,239 +0,0 @@ -// Code generated by ndpgen. DO NOT EDIT. -// -//! Navidrome shared plugin data types. - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -// 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 } -/// ArtistRef is the minimal information a plugin returns for Navidrome to match an -/// artist against the library. It is a reference, not a full artist entity: it -/// carries only matching keys (name and optional internal/MusicBrainz IDs) plus a -/// few projection fields used when describing a track's participants, never -/// descriptive data such as biographies or images. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ArtistRef { - /// ID is the internal Navidrome artist ID (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub id: String, - /// Name is the artist name. - #[serde(default)] - pub name: String, - /// MBID is the MusicBrainz ID for the artist. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbid: String, - /// SortName is the artist name used for sorting (if known). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub sort_name: String, - /// Role is the participation category (e.g. "artist", "composer", "performer"). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub role: String, - /// SubRole is a specialization within Role (e.g. the instrument for a performer). - #[serde(default, skip_serializing_if = "String::is_empty")] - pub sub_role: String, -} -/// SongRef is the minimal information exchanged between a plugin and Navidrome to -/// match a song. It is used both as input (a song Navidrome already has) and as -/// output (a song a plugin suggests, which may not be in the library yet). Unlike -/// Track, it is an abstract recording reference carrying only matching keys (IDs, -/// ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. -#[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. - /// - /// Deprecated: use Artists. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist: String, - /// ArtistMBID is the MusicBrainz artist ID. - /// - /// Deprecated: use Artists. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub artist_mbid: String, - /// Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub artists: Vec<ArtistRef>, - /// 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. - /// - /// Deprecated: use DurationMs, which carries millisecond precision. When - /// DurationMs is non-zero it takes precedence; Duration is kept only for - /// backwards compatibility with plugins that still send seconds. - #[serde(default, skip_serializing_if = "is_zero_f32")] - pub duration: f32, - /// DurationMs is the song duration in milliseconds. It supersedes Duration - /// when non-zero. - #[serde(default, skip_serializing_if = "is_zero_u32")] - pub duration_ms: u32, -} -/// Track is a stable, public projection of a library media file for plugin consumption. -/// It is a sane subset of the internal model.MediaFile, intended for reuse across host -/// services and capabilities. Timestamps are Unix epoch seconds. -/// -/// Unlike SongRef, which is an abstract recording reference carrying only matching keys, -/// Track is a concrete library entity: it identifies a specific media file that exists -/// (or once existed) in the library and exposes its full descriptive metadata. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Track { - /// Identity & location - #[serde(default)] - pub id: String, - #[serde(default)] - pub library_id: i32, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub library_name: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub path: String, - #[serde(default)] - pub missing: bool, - /// Core metadata - #[serde(default)] - pub title: String, - #[serde(default)] - pub album: String, - #[serde(default)] - pub artist: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album_artist: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub album_id: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub sort_title: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub sort_album_name: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub sort_artist_name: String, - /// Track / disc / dates - #[serde(default)] - pub track_number: i32, - #[serde(default)] - pub disc_number: i32, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub disc_subtitle: String, - #[serde(default)] - pub year: i32, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub date: String, - #[serde(default)] - pub original_year: i32, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub original_date: String, - #[serde(default)] - pub release_year: i32, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub release_date: String, - /// Audio / file - #[serde(default)] - pub size: i64, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub suffix: String, - #[serde(default)] - pub duration: f64, - #[serde(default)] - pub bit_rate: i32, - #[serde(default)] - pub sample_rate: i32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bit_depth: Option<i32>, - #[serde(default)] - pub channels: i32, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub codec: String, - /// Descriptive - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub genres: Vec<String>, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub comment: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bpm: Option<i32>, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub explicit_status: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub catalog_num: String, - #[serde(default)] - pub compilation: bool, - #[serde(default)] - pub has_cover_art: bool, - /// MusicBrainz - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbz_recording_id: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbz_release_track_id: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbz_album_id: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbz_release_group_id: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbz_album_type: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - pub mbz_album_comment: String, - /// ReplayGain — nil means no data; 0 is a valid measured value, so these - /// must stay pointers to distinguish "absent" from "0". - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rg_album_gain: Option<f64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rg_album_peak: Option<f64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rg_track_gain: Option<f64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rg_track_peak: Option<f64>, - /// Timestamps (Unix epoch seconds) - #[serde(default)] - pub birth_time: i64, - #[serde(default)] - pub created_at: i64, - #[serde(default)] - pub updated_at: i64, - /// AverageRating is the track's mean rating across all users (always set; 0 when unrated). - #[serde(default)] - pub average_rating: f64, - /// Per-user annotations, set only for a user-scoped match. Timestamps are Unix - /// seconds; a nil pointer means "no value". - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub starred: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub starred_at: Option<i64>, - #[serde(default, skip_serializing_if = "is_zero_i32")] - pub rating: i32, - #[serde(default, skip_serializing_if = "is_zero_i64")] - pub play_count: i64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub play_date: Option<i64>, - /// Composite - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub tags: std::collections::HashMap<String, Vec<String>>, - /// Participants lists the track's artists across all roles, each tagged with its Role. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub participants: Vec<ArtistRef>, -} diff --git a/plugins/pdk/rust/nd-pdk/Cargo.toml b/plugins/pdk/rust/nd-pdk/Cargo.toml deleted file mode 100644 index 8ca457914..000000000 --- a/plugins/pdk/rust/nd-pdk/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "nd-pdk" -version = "0.1.0" -edition = "2021" -description = "Navidrome Plugin Development Kit for Rust" -authors = ["Navidrome Team"] -license = "GPL-3.0" -readme = "../README.md" - -[lib] -crate-type = ["rlib"] - -[dependencies] -nd-pdk-host = { path = "../nd-pdk-host" } -nd-pdk-capabilities = { path = "../nd-pdk-capabilities" } -nd-pdk-types = { path = "../nd-pdk-types" } -extism-pdk = "1.2" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" diff --git a/plugins/pdk/rust/nd-pdk/src/lib.rs b/plugins/pdk/rust/nd-pdk/src/lib.rs deleted file mode 100644 index 36b2ec316..000000000 --- a/plugins/pdk/rust/nd-pdk/src/lib.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Navidrome Plugin Development Kit for Rust -//! -//! This crate provides a unified API for building Navidrome plugins in Rust. -//! It re-exports all functionality from the host and capabilities sub-crates. -//! -//! # Example -//! -//! ```rust,no_run -//! use nd_pdk::scrobbler::{Scrobbler, IsAuthorizedRequest, Error}; -//! use nd_pdk::register_scrobbler; -//! -//! struct MyPlugin; -//! -//! impl Default for MyPlugin { -//! fn default() -> Self { MyPlugin } -//! } -//! -//! impl Scrobbler for MyPlugin { -//! fn is_authorized(&self, req: IsAuthorizedRequest) -> Result<bool, Error> { -//! Ok(true) -//! } -//! // ... implement other required methods -//! } -//! -//! register_scrobbler!(MyPlugin); -//! ``` - -/// Host function wrappers for calling Navidrome services from plugins. -pub use nd_pdk_host as host; - -/// Capability wrappers for implementing plugin exports. -pub use nd_pdk_capabilities::*; - -/// Shared plugin data types. -pub use nd_pdk_types as types; - -/// Re-export extism-pdk for convenience. -pub use extism_pdk; diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go deleted file mode 100644 index aa07eae32..000000000 --- a/plugins/plugins_suite_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package plugins - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "net/http" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - "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/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -const ( - testDataDir = "plugins/testdata" - wazeroCacheDir = ".wazero-cache" -) - -// Shared test state initialized in BeforeSuite -var ( - testdataDir string // Path to testdata folder with test plugin .ndp packages - tmpPluginsDir string // Temp directory for plugin tests that modify files - testManager *Manager -) - -func TestPlugins(t *testing.T) { - tests.Init(t, false) - - // Set globally so tests using configtest.SetupConfig inherit it. The cache - // persists between runs; entries are content-addressed, so a stale one only misses. - conf.Server.CacheFolder = conf.NewDir(filepath.Join(testDataDir, wazeroCacheDir)) - conf.Server.Plugins.CacheSize = "1GB" // the default evicts the cache mid-run - - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Plugins Suite") -} - -// stubLocalhostDNS resolves "localhost." without real DNS: the trailing dot never matches /etc/hosts. -func stubLocalhostDNS() { - dialResolver = tests.StubResolver(map[string]string{"localhost.": "127.0.0.1"}) - DeferCleanup(func() { dialResolver = nil }) -} - -// createTestManager creates a new plugin Manager with the given plugin config. -// It creates a temp directory, copies the test-metadata-agent plugin, and starts the manager. -// Returns the manager, temp directory path, and a cleanup function. -func createTestManager(pluginConfig map[string]map[string]string) (*Manager, string) { - return createTestManagerWithPlugins(pluginConfig, "test-metadata-agent"+PackageExtension) -} - -// createTestManagerWithPlugins creates a new plugin Manager with the given plugin config -// and specified plugins. It creates a temp directory, copies the specified plugins, and starts the manager. -// Returns the manager and temp directory path. -func createTestManagerWithPlugins(pluginConfig map[string]map[string]string, plugins ...string) (*Manager, string) { - return createTestManagerWithPluginsAndMetrics(pluginConfig, noopMetricsRecorder{}, plugins...) -} - -// installTestPlugins copies the given .ndp packages into dir and returns their -// enabled DB rows, so callers can grant whatever access the test needs. -func installTestPlugins(dir string, plugins ...string) model.Plugins { - var rows model.Plugins - for _, plugin := range plugins { - data, err := os.ReadFile(filepath.Join(testdataDir, plugin)) - Expect(err).ToNot(HaveOccurred()) - destPath := filepath.Join(dir, plugin) - Expect(os.WriteFile(destPath, data, 0600)).To(Succeed()) - - hash := sha256.Sum256(data) - rows = append(rows, model.Plugin{ - ID: strings.TrimSuffix(plugin, PackageExtension), - Path: destPath, - SHA256: hex.EncodeToString(hash[:]), - Enabled: true, - }) - } - return rows -} - -// createTestManagerWithPluginsAndMetrics creates a new plugin Manager with the given plugin config, -// metrics recorder, and specified plugins. It creates a temp directory, copies the specified plugins, -// and starts the manager. Returns the manager and temp directory path. -func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]string, metrics PluginMetricsRecorder, plugins ...string) (*Manager, string) { - // Create temp directory - tmpDir, err := os.MkdirTemp("", "plugins-test-*") - Expect(err).ToNot(HaveOccurred()) - - enabledPlugins := installTestPlugins(tmpDir, plugins...) - for i, p := range enabledPlugins { - enabledPlugins[i].AllUsers = true // Allow all users by default in tests - if pluginConfig[p.ID] != nil { - configBytes, err := json.Marshal(pluginConfig[p.ID]) - Expect(err).ToNot(HaveOccurred()) - enabledPlugins[i].Config = string(configBytes) - } - } - - // Setup config - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - - // Setup mock DataStore with pre-enabled plugins - mockPluginRepo := tests.CreateMockPluginRepo() - mockPluginRepo.Permitted = true - mockPluginRepo.SetData(enabledPlugins) - dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo} - - // Create and start manager - manager := &Manager{ - plugins: make(map[string]*plugin), - ds: dataStore, - metrics: metrics, - subsonicRouter: http.NotFoundHandler(), // Stub router for tests - } - err = manager.Start(GinkgoT().Context()) - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(func() { - _ = manager.Stop() - _ = os.RemoveAll(tmpDir) - }) - - return manager, tmpDir -} - -var _ = SynchronizedBeforeSuite(func() { - // Build once: the testdata Makefile is not safe to run concurrently. - buildTestPlugins(testDataDir) -}, func() { - // Get testdata directory (where test plugin .ndp packages live) - _, currentFile, _, ok := runtime.Caller(0) - Expect(ok).To(BeTrue()) - testdataDir = filepath.Join(filepath.Dir(currentFile), "testdata") - - // Create shared manager for most tests - testManager, tmpPluginsDir = createTestManager(nil) -}) - -var _ = AfterSuite(func() { - if testManager != nil { - _ = testManager.Stop() - } - if tmpPluginsDir != "" { - _ = os.RemoveAll(tmpPluginsDir) - } -}) - -// noopMetricsRecorder is a no-op implementation of PluginMetricsRecorder for tests -type noopMetricsRecorder struct{} - -func (noopMetricsRecorder) RecordPluginRequest(context.Context, string, string, bool, int64) {} diff --git a/plugins/sandbox_fs.go b/plugins/sandbox_fs.go deleted file mode 100644 index 57bba85af..000000000 --- a/plugins/sandbox_fs.go +++ /dev/null @@ -1,157 +0,0 @@ -package plugins - -import ( - "context" - "io/fs" - "path/filepath" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/tetratelabs/wazero" - experimentalsys "github.com/tetratelabs/wazero/experimental/sys" - "github.com/tetratelabs/wazero/experimental/sysfs" - "github.com/tetratelabs/wazero/sys" -) - -// jailedFS confines a mount to its root: wazero resolves guest paths by bare -// concatenation. Named field, not embedding, so a new sys.FS method can't slip through. -type jailedFS struct { - fs experimentalsys.FS -} - -// escapes reports whether a guest path can resolve outside the mount root. -// WASI only validates "/"-separated paths, missing `..\`, `C:x` and device names. -func escapes(path string) bool { - switch path { - case "", ".", "/": // how wazero opens the mount root itself - return false - } - return !filepath.IsLocal(path) -} - -func (jailedFS) Symlink(string, string) experimentalsys.Errno { - return experimentalsys.EPERM -} - -func (j jailedFS) OpenFile(path string, flag experimentalsys.Oflag, perm fs.FileMode) (experimentalsys.File, experimentalsys.Errno) { - if escapes(path) { - return nil, experimentalsys.EPERM - } - return j.fs.OpenFile(path, flag, perm) -} - -func (j jailedFS) Lstat(path string) (sys.Stat_t, experimentalsys.Errno) { - if escapes(path) { - return sys.Stat_t{}, experimentalsys.EPERM - } - return j.fs.Lstat(path) -} - -func (j jailedFS) Stat(path string) (sys.Stat_t, experimentalsys.Errno) { - if escapes(path) { - return sys.Stat_t{}, experimentalsys.EPERM - } - return j.fs.Stat(path) -} - -func (j jailedFS) Mkdir(path string, perm fs.FileMode) experimentalsys.Errno { - if escapes(path) { - return experimentalsys.EPERM - } - return j.fs.Mkdir(path, perm) -} - -func (j jailedFS) Chmod(path string, perm fs.FileMode) experimentalsys.Errno { - if escapes(path) { - return experimentalsys.EPERM - } - return j.fs.Chmod(path, perm) -} - -func (j jailedFS) Rename(from, to string) experimentalsys.Errno { - if escapes(from) || escapes(to) { - return experimentalsys.EPERM - } - return j.fs.Rename(from, to) -} - -func (j jailedFS) Rmdir(path string) experimentalsys.Errno { - if escapes(path) { - return experimentalsys.EPERM - } - return j.fs.Rmdir(path) -} - -func (j jailedFS) Unlink(path string) experimentalsys.Errno { - if escapes(path) { - return experimentalsys.EPERM - } - return j.fs.Unlink(path) -} - -func (j jailedFS) Link(oldPath, newPath string) experimentalsys.Errno { - if escapes(oldPath) || escapes(newPath) { - return experimentalsys.EPERM - } - return j.fs.Link(oldPath, newPath) -} - -func (j jailedFS) Readlink(path string) (string, experimentalsys.Errno) { - if escapes(path) { - return "", experimentalsys.EPERM - } - return j.fs.Readlink(path) -} - -func (j jailedFS) Utimens(path string, atim, mtim int64) experimentalsys.Errno { - if escapes(path) { - return experimentalsys.EPERM - } - return j.fs.Utimens(path, atim, mtim) -} - -// mount is a host directory exposed to a plugin at guestPath. -type mount struct { - hostPath string - guestPath string - readOnly bool -} - -// buildMounts lists the libraries the plugin may reach through the filesystem. -func buildMounts(ctx context.Context, libraries model.Libraries, allowedLibraryIDs []int, allLibraries, allowWriteAccess bool) []mount { - allowedLibrarySet := make(map[int]struct{}, len(allowedLibraryIDs)) - for _, id := range allowedLibraryIDs { - allowedLibrarySet[id] = struct{}{} - } - var mounts []mount - for _, lib := range libraries { - _, allowed := allowedLibrarySet[lib.ID] - if allLibraries || allowed { - m := mount{hostPath: lib.Path, guestPath: toPluginMountPoint(int32(lib.ID)), readOnly: !allowWriteAccess} - mounts = append(mounts, m) - log.Trace(ctx, "Added library to plugin mounts", "libraryID", lib.ID, "mountPoint", m.guestPath, "readOnly", m.readOnly, "hostPath", m.hostPath) - } - } - if allowWriteAccess { - log.Info(ctx, "Granting read-write filesystem access to libraries", "libraryCount", len(mounts), "allLibraries", allLibraries) - } else { - log.Debug(ctx, "Granting read-only filesystem access to libraries", "libraryCount", len(mounts), "allLibraries", allLibraries) - } - return mounts -} - -// buildFSConfig mounts each host directory jailed to its root. -func buildFSConfig(mounts []mount) wazero.FSConfig { - if len(mounts) == 0 { - return nil - } - cfg := wazero.NewFSConfig() - for _, m := range mounts { - var mounted experimentalsys.FS = jailedFS{fs: sysfs.DirFS(m.hostPath)} - if m.readOnly { - mounted = &sysfs.ReadFS{FS: mounted} - } - cfg = cfg.(sysfs.FSConfig).WithSysFSMount(mounted, m.guestPath) - } - return cfg -} diff --git a/plugins/sandbox_fs_internal_test.go b/plugins/sandbox_fs_internal_test.go deleted file mode 100644 index 73011225d..000000000 --- a/plugins/sandbox_fs_internal_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package plugins - -import ( - "os" - "path/filepath" - "runtime" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - experimentalsys "github.com/tetratelabs/wazero/experimental/sys" - "github.com/tetratelabs/wazero/experimental/sysfs" -) - -var _ = Describe("buildMounts", func() { - var libraries model.Libraries - - BeforeEach(func() { - libraries = model.Libraries{ - {ID: 1, Path: "/music/library1"}, - {ID: 2, Path: "/music/library2"}, - {ID: 3, Path: "/music/library3"}, - } - }) - - It("mounts all libraries read-only by default", func() { - Expect(buildMounts(nil, libraries, nil, true, false)).To(Equal([]mount{ - {hostPath: "/music/library1", guestPath: "/libraries/1", readOnly: true}, - {hostPath: "/music/library2", guestPath: "/libraries/2", readOnly: true}, - {hostPath: "/music/library3", guestPath: "/libraries/3", readOnly: true}, - })) - }) - - It("mounts only the selected libraries", func() { - Expect(buildMounts(nil, libraries, []int{1, 3}, false, false)).To(Equal([]mount{ - {hostPath: "/music/library1", guestPath: "/libraries/1", readOnly: true}, - {hostPath: "/music/library3", guestPath: "/libraries/3", readOnly: true}, - })) - }) - - It("mounts writable when write access is granted", func() { - Expect(buildMounts(nil, libraries, []int{2}, false, true)).To(Equal([]mount{ - {hostPath: "/music/library2", guestPath: "/libraries/2", readOnly: false}, - })) - }) - - DescribeTable("mounts nothing", - func(libs model.Libraries, allowedIDs []int, allLibraries bool) { - Expect(buildMounts(nil, libs, allowedIDs, allLibraries, false)).To(BeEmpty()) - }, - Entry("when no library matches", libraries, []int{99}, false), - Entry("when there are no libraries", model.Libraries(nil), []int{1}, false), - Entry("when no library is selected", libraries, nil, false), - ) -}) - -var _ = Describe("jailedFS", func() { - var ( - fs jailedFS - root string - outsideDir string - ) - - BeforeEach(func() { - tmpDir := GinkgoT().TempDir() - root = filepath.Join(tmpDir, "root") - outsideDir = filepath.Join(tmpDir, "outside") - Expect(os.MkdirAll(root, 0755)).To(Succeed()) - Expect(os.MkdirAll(outsideDir, 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(outsideDir, "secret.txt"), []byte("secret"), 0600)).To(Succeed()) - - fs = jailedFS{sysfs.DirFS(root)} - }) - - It("denies creating symlinks", func() { - Expect(fs.Symlink("../outside", "link")).To(Equal(experimentalsys.EPERM)) - }) - - It("denies reading through a path that escapes the root", func() { - _, errno := fs.OpenFile("../outside/secret.txt", experimentalsys.O_RDONLY, 0) - Expect(errno).To(Equal(experimentalsys.EPERM)) - }) - - It("denies traversal with the host separator", func() { - _, errno := fs.OpenFile(`..\outside\secret.txt`, experimentalsys.O_RDONLY, 0) - - if runtime.GOOS == "windows" { - Expect(errno).To(Equal(experimentalsys.EPERM)) - } else { - // A backslash is a legal filename character on POSIX, not a separator - Expect(errno).To(Equal(experimentalsys.ENOENT)) - } - }) - - It("denies absolute paths", func() { - _, errno := fs.OpenFile("/etc/passwd", experimentalsys.O_RDONLY, 0) - Expect(errno).To(Equal(experimentalsys.EPERM)) - }) - - It("denies creating a directory outside the root", func() { - Expect(fs.Mkdir("../outside/new-dir", 0755)).To(Equal(experimentalsys.EPERM)) - Expect(filepath.Join(outsideDir, "new-dir")).ToNot(BeADirectory()) - }) - - It("denies escaping through either side of a rename", func() { - Expect(fs.Rename("../outside/secret.txt", "stolen.txt")).To(Equal(experimentalsys.EPERM)) - Expect(fs.Rename("inside.txt", "../outside/leaked.txt")).To(Equal(experimentalsys.EPERM)) - }) - - // Music libraries symlink in folders from elsewhere; following them is intended - It("follows symlinks that already exist in the mount", func() { - if err := os.Symlink(outsideDir, filepath.Join(root, "linked")); err != nil { - Skip("cannot create symlinks here: " + err.Error()) // Windows without privileges - } - - _, errno := fs.OpenFile("linked/secret.txt", experimentalsys.O_RDONLY, 0) - Expect(errno).To(BeZero()) - }) - - It("allows access within the root", func() { - Expect(fs.Mkdir("sub", 0755)).To(BeZero()) - f, errno := fs.OpenFile("sub/file.txt", experimentalsys.O_CREAT|experimentalsys.O_WRONLY, 0600) - Expect(errno).To(BeZero()) - Expect(f.Close()).To(BeZero()) - Expect(filepath.Join(root, "sub", "file.txt")).To(BeAnExistingFile()) - }) -}) diff --git a/plugins/sandbox_fs_test.go b/plugins/sandbox_fs_test.go deleted file mode 100644 index 0dd9d15ab..000000000 --- a/plugins/sandbox_fs_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package plugins - -import ( - "context" - "encoding/json" - "net/http" - "os" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type sandboxInput struct { - Operation string `json:"operation"` - MountPoint string `json:"mount_point,omitempty"` - FilePath string `json:"file_path,omitempty"` - Content string `json:"content,omitempty"` - Target string `json:"target,omitempty"` -} - -type sandboxOutput struct { - FileContent string `json:"file_content,omitempty"` - Error *string `json:"error,omitempty"` -} - -// startSandboxManager loads test-library against libraryDir with the given grant. -func startSandboxManager(tmpDir, libraryDir string, grant func(*model.Plugin)) *Manager { - GinkgoHelper() - installed := installTestPlugins(tmpDir, "test-library"+PackageExtension) - grant(&installed[0]) - - DeferCleanup(configtest.SetupConfig()) - conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = conf.NewDir(tmpDir) - conf.Server.Plugins.AutoReload = false - - mockPluginRepo := tests.CreateMockPluginRepo() - mockPluginRepo.Permitted = true - mockPluginRepo.SetData(installed) - - mockLibraryRepo := &tests.MockLibraryRepo{} - mockLibraryRepo.SetData(model.Libraries{{ID: 1, Name: "Test Library", Path: libraryDir}}) - - manager := &Manager{ - plugins: make(map[string]*plugin), - ds: &tests.MockDataStore{MockedPlugin: mockPluginRepo, MockedLibrary: mockLibraryRepo}, - subsonicRouter: http.NotFoundHandler(), - } - Expect(manager.Start(GinkgoT().Context())).To(Succeed()) - DeferCleanup(func() { _ = manager.Stop() }) - return manager -} - -// callSandbox runs one filesystem operation inside the plugin sandbox. -func callSandbox(manager *Manager, input sandboxInput) sandboxOutput { - GinkgoHelper() - manager.mu.RLock() - p := manager.plugins["test-library"] - manager.mu.RUnlock() - Expect(p).ToNot(BeNil()) - - instance, err := p.instance(context.Background()) - Expect(err).ToNot(HaveOccurred()) - defer instance.Close(context.Background()) - - inputBytes, err := json.Marshal(input) - Expect(err).ToNot(HaveOccurred()) - _, outputBytes, err := instance.Call("nd_test_library", inputBytes) - Expect(err).ToNot(HaveOccurred()) - - var output sandboxOutput - Expect(json.Unmarshal(outputBytes, &output)).To(Succeed()) - return output -} - -var _ = Describe("Plugin filesystem sandbox", Ordered, ContinueOnFailure, func() { - var ( - manager *Manager - libraryDir string - outsideDir string - secretFile string - mountPoint string - ) - - call := func(input sandboxInput) sandboxOutput { - GinkgoHelper() - input.MountPoint = mountPoint - return callSandbox(manager, input) - } - - BeforeAll(func() { - tmpDir := GinkgoT().TempDir() - libraryDir = filepath.Join(tmpDir, "music-library") - Expect(os.MkdirAll(libraryDir, 0755)).To(Succeed()) - - // Sibling of the mount root: anything reached here escaped the sandbox - outsideDir = filepath.Join(tmpDir, "outside") - Expect(os.MkdirAll(outsideDir, 0755)).To(Succeed()) - secretFile = filepath.Join(outsideDir, "secret.txt") - Expect(os.WriteFile(secretFile, []byte("secret"), 0600)).To(Succeed()) - - mountPoint = toPluginMountPoint(1) - manager = startSandboxManager(tmpDir, libraryDir, func(p *model.Plugin) { - p.AllLibraries = true - p.AllowWriteAccess = true - }) - }) - - It("allows writing inside the mount", func() { - out := call(sandboxInput{Operation: "write_file", FilePath: "inside.txt", Content: "hello"}) - - Expect(out.Error).To(BeNil()) - Expect(os.ReadFile(filepath.Join(libraryDir, "inside.txt"))).To(BeEquivalentTo("hello")) - }) - - It("cannot escape the mount by creating a symlink", func() { - linked := call(sandboxInput{Operation: "symlink", FilePath: "escape", Target: "../outside"}) - Expect(linked.Error).ToNot(BeNil()) - _, err := os.Lstat(filepath.Join(libraryDir, "escape")) - Expect(err).To(MatchError(os.ErrNotExist), "the plugin created a symlink out of the mount") - - wrote := call(sandboxInput{Operation: "write_file", FilePath: "escape/via-created-symlink.txt", Content: "escaped"}) - - Expect(wrote.Error).ToNot(BeNil()) - Expect(filepath.Join(outsideDir, "via-created-symlink.txt")).ToNot(BeAnExistingFile()) - }) - - // Accepted residual, pinned so a future tightening can't happen silently - It("still follows a symlink planted in the mount by something else", func() { - if err := os.Symlink(outsideDir, filepath.Join(libraryDir, "planted")); err != nil { - Skip("cannot create symlinks here: " + err.Error()) // Windows without privileges - } - - out := call(sandboxInput{Operation: "write_file", FilePath: "planted/via-symlink.txt", Content: "escaped"}) - - Expect(out.Error).To(BeNil()) - Expect(filepath.Join(outsideDir, "via-symlink.txt")).To(BeAnExistingFile()) - }) - - It("cannot read outside the mount with ..", func() { - out := call(sandboxInput{Operation: "read_file", FilePath: "../outside/secret.txt"}) - - Expect(out.Error).ToNot(BeNil()) - Expect(out.FileContent).ToNot(Equal("secret")) - }) - - It("cannot write outside the mount with ..", func() { - out := call(sandboxInput{Operation: "write_file", FilePath: "../outside/via-dotdot.txt", Content: "escaped"}) - - Expect(out.Error).ToNot(BeNil()) - Expect(filepath.Join(outsideDir, "via-dotdot.txt")).ToNot(BeAnExistingFile()) - }) -}) - -var _ = Describe("Plugin filesystem sandbox without library access", Ordered, func() { - var manager *Manager - var libraryDir string - - BeforeAll(func() { - tmpDir := GinkgoT().TempDir() - libraryDir = filepath.Join(tmpDir, "music-library") - Expect(os.MkdirAll(libraryDir, 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(libraryDir, "track.txt"), []byte("audio"), 0600)).To(Succeed()) - - manager = startSandboxManager(tmpDir, libraryDir, func(*model.Plugin) {}) - }) - - It("mounts nothing when no library is granted", func() { - manager.mu.RLock() - p := manager.plugins["test-library"] - manager.mu.RUnlock() - Expect(p.fsConfig).To(BeNil()) - - out := callSandbox(manager, sandboxInput{Operation: "read_file", MountPoint: toPluginMountPoint(1), FilePath: "track.txt"}) - Expect(out.Error).ToNot(BeNil()) - }) -}) diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go deleted file mode 100644 index 721f0d3fa..000000000 --- a/plugins/scrobbler_adapter.go +++ /dev/null @@ -1,211 +0,0 @@ -package plugins - -import ( - "context" - "errors" - "strings" - - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/plugins/capabilities" - "github.com/navidrome/navidrome/plugins/types" -) - -// CapabilityScrobbler indicates the plugin can receive scrobble events. -// Detected when the plugin exports at least one of the scrobbler functions. -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" - FuncScrobblerPlaybackReport = "nd_scrobbler_playback_report" -) - -func init() { - registerCapability( - CapabilityScrobbler, - 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 { - name string - plugin *plugin - allowedUserIDs []string // User IDs this plugin can access (from DB configuration) - allUsers bool // If true, plugin can access all users - userIDMap map[string]struct{} // Cached map for fast lookups -} - -// IsAuthorized checks if the user is authorized with this scrobbler. -// First checks if the user is allowed to use this plugin (server-side), -// then delegates to the plugin for service-specific authorization. -func (s *ScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool { - // First check server-side authorization based on plugin configuration - if !s.isUserAllowed(userId) { - return false - } - - // Then delegate to the plugin for service-specific authorization - username := getUsernameFromContext(ctx) - input := capabilities.IsAuthorizedRequest{ - Username: username, - } - - result, err := callPluginFunction[capabilities.IsAuthorizedRequest, bool](ctx, s.plugin, FuncScrobblerIsAuthorized, input) - if err != nil { - return false - } - - return result -} - -// isUserAllowed checks if the given user ID is allowed to use this plugin. -func (s *ScrobblerPlugin) isUserAllowed(userId string) bool { - if s.allUsers { - return true - } - if len(s.allowedUserIDs) == 0 { - return false - } - _, ok := s.userIDMap[userId] - return ok -} - -// NowPlaying sends a now playing notification to the scrobbler -func (s *ScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { - username := getUsernameFromContext(ctx) - input := capabilities.NowPlayingRequest{ - Username: username, - Track: mediaFileToTrackInfo(s.plugin, track), - Position: int32(position), - } - - err := callPluginFunctionNoOutput(ctx, s.plugin, FuncScrobblerNowPlaying, input) - return mapScrobblerError(err) -} - -// Scrobble submits a scrobble to the scrobbler -func (s *ScrobblerPlugin) Scrobble(ctx context.Context, userId string, sc scrobbler.Scrobble) error { - username := getUsernameFromContext(ctx) - input := capabilities.ScrobbleRequest{ - Username: username, - Track: mediaFileToTrackInfo(s.plugin, &sc.MediaFile), - Timestamp: sc.TimeStamp.Unix(), - } - - err := callPluginFunctionNoOutput(ctx, s.plugin, FuncScrobblerScrobble, input) - return mapScrobblerError(err) -} - -// getUsernameFromContext extracts the username from the request context -func getUsernameFromContext(ctx context.Context) string { - if user, ok := request.UserFrom(ctx); ok { - return user.UserName - } - return "" -} - -// 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, - Artist: mf.Artist, - AlbumArtist: mf.AlbumArtist, - Artists: participantsToArtistRefs(mf.Participants[model.RoleArtist]), - AlbumArtists: participantsToArtistRefs(mf.Participants[model.RoleAlbumArtist]), - Duration: mf.Duration, - TrackNumber: int32(mf.TrackNumber), - DiscNumber: int32(mf.DiscNumber), - MBZRecordingID: mf.MbzRecordingID, - MBZAlbumID: mf.MbzAlbumID, - 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 -func participantsToArtistRefs(participants model.ParticipantList) []types.ArtistRef { - refs := make([]types.ArtistRef, len(participants)) - for i, p := range participants { - refs[i] = types.ArtistRef{ - ID: p.ID, - Name: p.Name, - MBID: p.MbzArtistID, - } - } - return refs -} - -// mapScrobblerError converts plugin errors to scrobbler errors based on error message, as errors are returned as -// strings from plugins. -func mapScrobblerError(err error) error { - if err == nil { - return nil - } - errMsg := err.Error() - retryLater, isRetryLater := parseRetryLater(scrobblerRetryLaterRe, errMsg) - switch { - case strings.Contains(errMsg, capabilities.ScrobblerErrorNotAuthorized.Error()): - return scrobbler.ErrNotAuthorized - case isRetryLater: - return retryLater - case strings.Contains(errMsg, capabilities.ScrobblerErrorUnrecoverable.Error()): - return scrobbler.ErrUnrecoverable - default: - return scrobbler.ErrUnrecoverable - } -} - -// 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 deleted file mode 100644 index 00c9045a4..000000000 --- a/plugins/scrobbler_adapter_test.go +++ /dev/null @@ -1,385 +0,0 @@ -package plugins - -import ( - "context" - "errors" - "time" - - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// ctxWithUser returns a fresh context with the test user. -// Must be called within each test, not in BeforeAll, because the context -// from BeforeAll gets cancelled before tests run. -func ctxWithUser() context.Context { - return request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", UserName: "testuser"}) -} - -var _ = Describe("ScrobblerPlugin", Ordered, func() { - var ( - scrobblerManager *Manager - s scrobbler.Scrobbler - ) - - BeforeAll(func() { - // Load the scrobbler via a new manager with the test-scrobbler plugin - scrobblerManager, _ = createTestManagerWithPlugins(nil, "test-scrobbler"+PackageExtension) - - var ok bool - s, ok = scrobblerManager.LoadScrobbler("test-scrobbler") - Expect(ok).To(BeTrue()) - }) - - Describe("LoadScrobbler", func() { - It("returns a scrobbler for a plugin with Scrobbler capability", func() { - Expect(s).ToNot(BeNil()) - }) - - It("returns false for a plugin without Scrobbler capability", func() { - _, ok := testManager.LoadScrobbler("test-metadata-agent") - Expect(ok).To(BeFalse()) - }) - - It("returns false for non-existent plugin", func() { - _, ok := scrobblerManager.LoadScrobbler("non-existent") - Expect(ok).To(BeFalse()) - }) - }) - - Describe("IsAuthorized", func() { - It("returns true when plugin is configured to authorize", func() { - result := s.IsAuthorized(ctxWithUser(), "user-1") - Expect(result).To(BeTrue()) - }) - - Context("when plugin is configured to not authorize", Ordered, func() { - var notAuthScrobbler scrobbler.Scrobbler - - BeforeAll(func() { - mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{ - "test-scrobbler": {"authorized": "false"}, - }, "test-scrobbler"+PackageExtension) - - var ok bool - notAuthScrobbler, ok = mgr.LoadScrobbler("test-scrobbler") - Expect(ok).To(BeTrue()) - }) - - It("returns false", func() { - result := notAuthScrobbler.IsAuthorized(ctxWithUser(), "user-1") - Expect(result).To(BeFalse()) - }) - }) - }) - - Describe("isUserAllowed", func() { - It("returns true when allUsers is true", func() { - sp := &ScrobblerPlugin{allUsers: true} - Expect(sp.isUserAllowed("any-user")).To(BeTrue()) - }) - - It("returns false when allowedUserIDs is empty and allUsers is false", func() { - sp := &ScrobblerPlugin{allUsers: false, allowedUserIDs: []string{}} - Expect(sp.isUserAllowed("user-1")).To(BeFalse()) - }) - - It("returns false when allowedUserIDs is nil and allUsers is false", func() { - sp := &ScrobblerPlugin{allUsers: false} - Expect(sp.isUserAllowed("user-1")).To(BeFalse()) - }) - - It("returns true when user is in allowedUserIDs", func() { - sp := &ScrobblerPlugin{ - allUsers: false, - allowedUserIDs: []string{"user-1", "user-2"}, - userIDMap: map[string]struct{}{"user-1": {}, "user-2": {}}, - } - Expect(sp.isUserAllowed("user-1")).To(BeTrue()) - }) - - It("returns false when user is not in allowedUserIDs", func() { - sp := &ScrobblerPlugin{ - allUsers: false, - allowedUserIDs: []string{"user-1", "user-2"}, - userIDMap: map[string]struct{}{"user-1": {}, "user-2": {}}, - } - Expect(sp.isUserAllowed("user-3")).To(BeFalse()) - }) - }) - - Describe("NowPlaying", func() { - It("successfully calls the plugin", func() { - track := &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"}}}, - }, - } - - err := s.NowPlaying(ctxWithUser(), "user-1", track, 30) - 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() { - track := &model.MediaFile{ID: "track-1", Title: "Test Song"} - err := retryScrobbler.NowPlaying(ctxWithUser(), "user-1", track, 30) - Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(scrobbler.ErrRetryLater)) - }) - }) - }) - - Describe("Scrobble", func() { - It("successfully calls the plugin", func() { - sc := scrobbler.Scrobble{ - 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"}}}, - }, - }, - TimeStamp: time.Now(), - } - - err := s.Scrobble(ctxWithUser(), "user-1", sc) - Expect(err).ToNot(HaveOccurred()) - }) - - Context("when plugin returns not_authorized error", Ordered, func() { - var notAuthScrobbler scrobbler.Scrobbler - - BeforeAll(func() { - mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{ - "test-scrobbler": {"error": "user not linked", "error_type": "scrobbler(not_authorized)"}, - }, "test-scrobbler"+PackageExtension) - - var ok bool - notAuthScrobbler, ok = mgr.LoadScrobbler("test-scrobbler") - Expect(ok).To(BeTrue()) - }) - - It("returns ErrNotAuthorized", func() { - scrobble := scrobbler.Scrobble{ - MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"}, - TimeStamp: time.Now(), - } - err := notAuthScrobbler.Scrobble(ctxWithUser(), "user-1", scrobble) - Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) - }) - }) - - Context("when plugin returns unrecoverable error", Ordered, func() { - var unrecoverableScrobbler scrobbler.Scrobbler - - BeforeAll(func() { - mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{ - "test-scrobbler": {"error": "track rejected", "error_type": "scrobbler(unrecoverable)"}, - }, "test-scrobbler"+PackageExtension) - - var ok bool - unrecoverableScrobbler, ok = mgr.LoadScrobbler("test-scrobbler") - Expect(ok).To(BeTrue()) - }) - - It("returns ErrUnrecoverable", func() { - scrobble := scrobbler.Scrobble{ - MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"}, - TimeStamp: time.Now(), - } - err := unrecoverableScrobbler.Scrobble(ctxWithUser(), "user-1", scrobble) - Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) - }) - }) - }) - - 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") - Expect(names).To(ContainElement("test-scrobbler")) - }) - - It("does not return metadata agent plugins for Scrobbler capability", func() { - names := testManager.PluginNames("Scrobbler") - 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() { - It("returns nil for nil error", func() { - Expect(mapScrobblerError(nil)).ToNot(HaveOccurred()) - }) - - It("returns ErrNotAuthorized for error containing 'not_authorized'", func() { - err := mapScrobblerError(errors.New("plugin error: scrobbler(not_authorized)")) - Expect(err).To(MatchError(scrobbler.ErrNotAuthorized)) - }) - - It("returns ErrRetryLater for error containing 'retry_later'", func() { - err := mapScrobblerError(errors.New("temporary failure: scrobbler(retry_later)")) - Expect(err).To(MatchError(scrobbler.ErrRetryLater)) - }) - - It("returns ErrUnrecoverable for error containing 'unrecoverable'", func() { - err := mapScrobblerError(errors.New("fatal error: scrobbler(unrecoverable)")) - Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) - }) - - It("returns ErrUnrecoverable for unknown error", func() { - err := mapScrobblerError(errors.New("some unknown error")) - Expect(err).To(MatchError(scrobbler.ErrUnrecoverable)) - }) - - DescribeTable("mapScrobblerError retry-later", - func(msg string, wantDelay time.Duration) { - err := mapScrobblerError(errors.New(msg)) - Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue()) - retry, _ := errors.AsType[*agents.RetryLaterError](err) - d := retry.RetryIn - Expect(d).To(Equal(wantDelay)) - }, - Entry("bare token", "scrobbler(retry_later)", time.Duration(0)), - Entry("with seconds", "scrobbler(retry_later:30)", 30*time.Second), - Entry("capped at 1h", "scrobbler(retry_later:999999)", time.Hour), - // Scaling to nanoseconds before capping wraps past 2^64, landing on ~0.29s. - Entry("capped before it can overflow", "scrobbler(retry_later:18446744074)", time.Hour), - Entry("wrapped in context", "plugin xyz: scrobbler(retry_later:5)", 5*time.Second), - ) - - It("still maps unknown errors to unrecoverable", func() { - err := mapScrobblerError(errors.New("scrobbler(retry_later_garbage")) - Expect(errors.Is(err, scrobbler.ErrUnrecoverable)).To(BeTrue()) - }) -}) diff --git a/plugins/sonic_similarity_adapter.go b/plugins/sonic_similarity_adapter.go deleted file mode 100644 index a4f7d73f8..000000000 --- a/plugins/sonic_similarity_adapter.go +++ /dev/null @@ -1,96 +0,0 @@ -package plugins - -import ( - "context" - - "github.com/navidrome/navidrome/core/sonic" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/plugins/capabilities" - "github.com/navidrome/navidrome/plugins/types" -) - -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) types.SongRef { - ref := types.SongRef{ - ID: mf.ID, - Name: mf.Title, - MBID: mf.MbzRecordingID, - Artist: mf.Artist, - ArtistMBID: mf.MbzArtistID, - Album: mf.Album, - AlbumMBID: mf.MbzAlbumID, - } - ref.SetDuration(mf.Duration) - for _, p := range mf.Participants[model.RoleArtist] { - ref.Artists = append(ref.Artists, types.ArtistRef{ID: p.ID, Name: p.Name, MBID: p.MbzArtistID, SortName: p.SortArtistName, Role: model.RoleArtist.String()}) - } - 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 deleted file mode 100644 index e08fc124f..000000000 --- a/plugins/sonic_similarity_adapter_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package plugins - -import ( - "github.com/navidrome/navidrome/core/sonic" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/plugins/capabilities" - . "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.Artists).To(HaveLen(1)) - Expect(results[0].Song.Artists[0].Name).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.Artists).To(HaveLen(1)) - Expect(results[0].Song.Artists[0].Name).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")) - }) -}) - -var _ = Describe("mediaFileToSongRef multi-artist", func() { - It("fills Artists (with IDs) from role=artist participants", func() { - mf := &model.MediaFile{ID: "x", Title: "Collab", Participants: model.Participants{ - model.RoleArtist: model.ParticipantList{ - {Artist: model.Artist{ID: "ar-drake", Name: "Drake", MbzArtistID: "m-drake"}}, - {Artist: model.Artist{ID: "ar-future", Name: "Future", MbzArtistID: "m-future"}}, - }, - }} - ref := mediaFileToSongRef(mf) - Expect(ref.Artists).To(Equal([]capabilities.ArtistRef{ - {ID: "ar-drake", Name: "Drake", MBID: "m-drake", Role: "artist"}, - {ID: "ar-future", Name: "Future", MBID: "m-future", Role: "artist"}, - })) - }) - It("leaves Artists nil when the track has no role=artist participants", func() { - mf := &model.MediaFile{ID: "x", Title: "Solo", Artist: "Drake"} - ref := mediaFileToSongRef(mf) - Expect(ref.Artists).To(BeNil()) - }) -}) diff --git a/plugins/testdata/Makefile b/plugins/testdata/Makefile deleted file mode 100644 index de948a2bb..000000000 --- a/plugins/testdata/Makefile +++ /dev/null @@ -1,35 +0,0 @@ -# Build test plugins used for integration testing -# Auto-discover all plugin folders (folders containing go.mod) -PLUGINS := $(patsubst %/go.mod,%,$(wildcard */go.mod)) - -# Prefer tinygo if available, it produces smaller wasm binaries and -# makes the tests faster. -TINYGO := $(shell command -v tinygo 2> /dev/null) - -all: $(PLUGINS:%=%.ndp) - -clean: - rm -f $(PLUGINS:%=%.ndp) $(PLUGINS:%=%.wasm) - rm -rf .wazero-cache $(PLUGINS:%=%.stage) - -# PDK source files that trigger rebuild when changed (recursive) -PDK_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null) - -# Build the .ndp package (zip containing manifest.json + plugin.wasm) -# Stage under a per-target name: a shared plugin.wasm breaks concurrent builds. -%.ndp: %.wasm %/manifest.json - @rm -f $@ - @rm -rf $*.stage && mkdir -p $*.stage - @cp $< $*.stage/plugin.wasm - zip -j $@ $*/manifest.json $*.stage/plugin.wasm - @rm -rf $*.stage - @mv $< $<.tmp && mv $<.tmp $< # Touch wasm to ensure it's older than ndp - -# Build the wasm binary. -buildvcs=false keeps the bytes stable across commits, so -# the test suite's wazero compilation cache still hits after a rebuild. -%.wasm: %/*.go %/go.mod $(PDK_SOURCES) -ifdef TINYGO - cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ . -else - cd $* && GOOS=wasip1 GOARCH=wasm go build -buildvcs=false -buildmode=c-shared -o ../$@ . -endif \ No newline at end of file diff --git a/plugins/testdata/partial-metadata-agent/go.mod b/plugins/testdata/partial-metadata-agent/go.mod deleted file mode 100644 index b37144d9a..000000000 --- a/plugins/testdata/partial-metadata-agent/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module partial-metadata-agent - -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/partial-metadata-agent/go.sum b/plugins/testdata/partial-metadata-agent/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/partial-metadata-agent/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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/partial-metadata-agent/main.go b/plugins/testdata/partial-metadata-agent/main.go deleted file mode 100644 index c11febf00..000000000 --- a/plugins/testdata/partial-metadata-agent/main.go +++ /dev/null @@ -1,23 +0,0 @@ -// Test plugin that only implements some metadata methods. -// Used to test the "not implemented" code path (-2 return code). -// Build with: tinygo build -o ../partial-metadata-agent.wasm -target wasip1 -buildmode=c-shared . -package main - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/metadata" -) - -func init() { - metadata.Register(&partialMetadataAgent{}) -} - -// partialMetadataAgent only implements GetArtistBiography. -// All other methods will return NotImplementedCode (-2). -type partialMetadataAgent struct{} - -// GetArtistBiography is the only method we implement. -func (t *partialMetadataAgent) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) { - return &metadata.ArtistBiographyResponse{Biography: "Partial agent biography for " + input.Name}, nil -} - -func main() {} diff --git a/plugins/testdata/partial-metadata-agent/manifest.json b/plugins/testdata/partial-metadata-agent/manifest.json deleted file mode 100644 index a600985a8..000000000 --- a/plugins/testdata/partial-metadata-agent/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Partial Metadata Agent", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test plugin that only implements some metadata methods" -} diff --git a/plugins/testdata/test-artwork/go.mod b/plugins/testdata/test-artwork/go.mod deleted file mode 100644 index 553f5e575..000000000 --- a/plugins/testdata/test-artwork/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-artwork - -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-artwork/go.sum b/plugins/testdata/test-artwork/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-artwork/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-artwork/main.go b/plugins/testdata/test-artwork/main.go deleted file mode 100644 index de857dd97..000000000 --- a/plugins/testdata/test-artwork/main.go +++ /dev/null @@ -1,64 +0,0 @@ -// Test Artwork plugin for Navidrome plugin system integration tests. -// Build with: tinygo build -o ../test-artwork.wasm -target wasip1 -buildmode=c-shared . -package main - -import ( - "strings" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// TestInput is the input for nd_test_artwork callback. -type TestInput struct { - ArtworkType string `json:"artwork_type"` // "artist", "album", "track", "playlist" - ID string `json:"id"` - Size int32 `json:"size"` -} - -// TestOutput is the output from nd_test_artwork callback. -type TestOutput struct { - URL string `json:"url,omitempty"` - Error *string `json:"error,omitempty"` -} - -// nd_test_artwork is the test callback that tests the artwork host functions. -// -//go:wasmexport nd_test_artwork -func ndTestArtwork() int32 { - var input TestInput - if err := pdk.InputJSON(&input); err != nil { - errStr := err.Error() - pdk.OutputJSON(TestOutput{Error: &errStr}) - return 0 - } - - var url string - var err error - - switch strings.ToLower(input.ArtworkType) { - case "artist": - url, err = host.ArtworkGetArtistUrl(input.ID, input.Size) - case "album": - url, err = host.ArtworkGetAlbumUrl(input.ID, input.Size) - case "track": - url, err = host.ArtworkGetTrackUrl(input.ID, input.Size) - case "playlist": - url, err = host.ArtworkGetPlaylistUrl(input.ID, input.Size) - default: - errStr := "unknown artwork type: " + input.ArtworkType - pdk.OutputJSON(TestOutput{Error: &errStr}) - return 0 - } - - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestOutput{Error: &errStr}) - return 0 - } - - pdk.OutputJSON(TestOutput{URL: url}) - return 0 -} - -func main() {} diff --git a/plugins/testdata/test-artwork/manifest.json b/plugins/testdata/test-artwork/manifest.json deleted file mode 100644 index c6ddbcb05..000000000 --- a/plugins/testdata/test-artwork/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "Test Artwork", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test artwork plugin for integration testing", - "permissions": { - "artwork": { - "reason": "For testing artwork URL generation" - } - } -} diff --git a/plugins/testdata/test-cache-plugin/go.mod b/plugins/testdata/test-cache-plugin/go.mod deleted file mode 100644 index c41110b70..000000000 --- a/plugins/testdata/test-cache-plugin/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-cache-plugin - -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-cache-plugin/go.sum b/plugins/testdata/test-cache-plugin/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-cache-plugin/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-cache-plugin/main.go b/plugins/testdata/test-cache-plugin/main.go deleted file mode 100644 index 1d193ac39..000000000 --- a/plugins/testdata/test-cache-plugin/main.go +++ /dev/null @@ -1,150 +0,0 @@ -// Test Cache plugin for Navidrome plugin system integration tests. -// Build with: tinygo build -o ../test-cache-plugin.wasm -target wasip1 -buildmode=c-shared . -package main - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// TestCacheInput is the input for nd_test_cache callback. -type TestCacheInput struct { - Operation string `json:"operation"` // "set_string", "get_string", "set_int", "get_int", "set_float", "get_float", "set_bytes", "get_bytes", "has", "remove" - Key string `json:"key"` // Cache key - StringVal string `json:"string_val"` // For string operations - IntVal int64 `json:"int_val"` // For int operations - FloatVal float64 `json:"float_val"` // For float operations - BytesVal []byte `json:"bytes_val"` // For bytes operations - TTLSeconds int64 `json:"ttl_seconds"` // TTL in seconds -} - -// TestCacheOutput is the output from nd_test_cache callback. -type TestCacheOutput struct { - StringVal string `json:"string_val,omitempty"` - IntVal int64 `json:"int_val,omitempty"` - FloatVal float64 `json:"float_val,omitempty"` - BytesVal []byte `json:"bytes_val,omitempty"` - Exists bool `json:"exists,omitempty"` - Error *string `json:"error,omitempty"` -} - -// nd_test_cache is the test callback that tests the cache host functions. -// -//go:wasmexport nd_test_cache -func ndTestCache() int32 { - var input TestCacheInput - if err := pdk.InputJSON(&input); err != nil { - errStr := err.Error() - pdk.OutputJSON(TestCacheOutput{Error: &errStr}) - return 0 - } - - switch input.Operation { - case "set_string": - err := host.CacheSetString(input.Key, input.StringVal, input.TTLSeconds) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestCacheOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestCacheOutput{}) - return 0 - - case "get_string": - value, exists, err := host.CacheGetString(input.Key) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestCacheOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestCacheOutput{StringVal: value, Exists: exists}) - return 0 - - case "set_int": - err := host.CacheSetInt(input.Key, input.IntVal, input.TTLSeconds) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestCacheOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestCacheOutput{}) - return 0 - - case "get_int": - value, exists, err := host.CacheGetInt(input.Key) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestCacheOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestCacheOutput{IntVal: value, Exists: exists}) - return 0 - - case "set_float": - err := host.CacheSetFloat(input.Key, input.FloatVal, input.TTLSeconds) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestCacheOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestCacheOutput{}) - return 0 - - case "get_float": - value, exists, err := host.CacheGetFloat(input.Key) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestCacheOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestCacheOutput{FloatVal: value, Exists: exists}) - return 0 - - case "set_bytes": - err := host.CacheSetBytes(input.Key, input.BytesVal, input.TTLSeconds) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestCacheOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestCacheOutput{}) - return 0 - - case "get_bytes": - value, exists, err := host.CacheGetBytes(input.Key) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestCacheOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestCacheOutput{BytesVal: value, Exists: exists}) - return 0 - - case "has": - exists, err := host.CacheHas(input.Key) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestCacheOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestCacheOutput{Exists: exists}) - return 0 - - case "remove": - err := host.CacheRemove(input.Key) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestCacheOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestCacheOutput{}) - return 0 - - default: - errStr := "unknown operation: " + input.Operation - pdk.OutputJSON(TestCacheOutput{Error: &errStr}) - return 0 - } -} - -func main() {} diff --git a/plugins/testdata/test-cache-plugin/manifest.json b/plugins/testdata/test-cache-plugin/manifest.json deleted file mode 100644 index 8c0d16a8b..000000000 --- a/plugins/testdata/test-cache-plugin/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "Test Cache Plugin", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test cache plugin for integration testing", - "permissions": { - "cache": { - "reason": "For testing cache operations" - } - } -} diff --git a/plugins/testdata/test-config/go.mod b/plugins/testdata/test-config/go.mod deleted file mode 100644 index 7fa19b41e..000000000 --- a/plugins/testdata/test-config/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-config - -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-config/go.sum b/plugins/testdata/test-config/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-config/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-config/main.go b/plugins/testdata/test-config/main.go deleted file mode 100644 index 1d058db11..000000000 --- a/plugins/testdata/test-config/main.go +++ /dev/null @@ -1,60 +0,0 @@ -// Test Config plugin for Navidrome plugin system integration tests. -// Build with: tinygo build -o ../test-config.wasm -target wasip1 -buildmode=c-shared . -package main - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// TestConfigInput is the input for nd_test_config callback. -type TestConfigInput struct { - Operation string `json:"operation"` // "get", "get_int", "list" - Key string `json:"key"` // For get/get_int operations - Prefix string `json:"prefix"` // For list operation -} - -// TestConfigOutput is the output from nd_test_config callback. -type TestConfigOutput struct { - StringVal string `json:"string_val,omitempty"` - IntVal int64 `json:"int_val,omitempty"` - Keys []string `json:"keys,omitempty"` - Exists bool `json:"exists,omitempty"` - Error *string `json:"error,omitempty"` -} - -// nd_test_config is the test callback that tests the config host functions. -// -//go:wasmexport nd_test_config -func ndTestConfig() int32 { - var input TestConfigInput - if err := pdk.InputJSON(&input); err != nil { - errStr := err.Error() - pdk.OutputJSON(TestConfigOutput{Error: &errStr}) - return 0 - } - - switch input.Operation { - case "get": - value, exists := host.ConfigGet(input.Key) - pdk.OutputJSON(TestConfigOutput{StringVal: value, Exists: exists}) - return 0 - - case "get_int": - value, exists := host.ConfigGetInt(input.Key) - pdk.OutputJSON(TestConfigOutput{IntVal: value, Exists: exists}) - return 0 - - case "list": - keys := host.ConfigKeys(input.Prefix) - pdk.OutputJSON(TestConfigOutput{Keys: keys}) - return 0 - - default: - errStr := "unknown operation: " + input.Operation - pdk.OutputJSON(TestConfigOutput{Error: &errStr}) - return 0 - } -} - -func main() {} diff --git a/plugins/testdata/test-config/manifest.json b/plugins/testdata/test-config/manifest.json deleted file mode 100644 index b5ed5dd86..000000000 --- a/plugins/testdata/test-config/manifest.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "name": "Test Config Plugin", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test plugin for config service integration testing", - "config": { - "schema": { - "type": "object", - "properties": { - "api_key": { - "type": "string", - "title": "API Key", - "minLength": 1 - }, - "max_retries": { - "type": "string", - "title": "Max Retries" - }, - "timeout": { - "type": "string", - "title": "Timeout" - }, - "users": { - "type": "array", - "title": "Users", - "items": { - "type": "object", - "properties": { - "username": { - "type": "string", - "title": "Username", - "minLength": 1 - }, - "token": { - "type": "string", - "title": "Token", - "minLength": 1 - } - }, - "required": ["username", "token"] - } - }, - "settings": { - "type": "object", - "title": "Settings", - "properties": { - "enabled": { - "type": "boolean", - "title": "Enabled" - }, - "count": { - "type": "integer", - "title": "Count" - } - } - } - }, - "required": ["api_key"] - } - } -} diff --git a/plugins/testdata/test-kvstore/go.mod b/plugins/testdata/test-kvstore/go.mod deleted file mode 100644 index 19fa0a47c..000000000 --- a/plugins/testdata/test-kvstore/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-kvstore - -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-kvstore/go.sum b/plugins/testdata/test-kvstore/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-kvstore/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-kvstore/main.go b/plugins/testdata/test-kvstore/main.go deleted file mode 100644 index 7fba14abe..000000000 --- a/plugins/testdata/test-kvstore/main.go +++ /dev/null @@ -1,140 +0,0 @@ -// Test KVStore plugin for Navidrome plugin system integration tests. -// Build with: tinygo build -o ../test-kvstore.wasm -target wasip1 -buildmode=c-shared . -package main - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// TestKVStoreInput is the input for nd_test_kvstore callback. -type TestKVStoreInput struct { - Operation string `json:"operation"` // "set", "get", "delete", "has", "list", "get_storage_used", "set_with_ttl", "delete_by_prefix", "get_many" - Key string `json:"key"` // Storage key - Value []byte `json:"value"` // For set operations - Prefix string `json:"prefix"` // For list/delete_by_prefix operations - TTLSeconds int64 `json:"ttl_seconds,omitempty"` // For set_with_ttl - Keys []string `json:"keys,omitempty"` // For get_many -} - -// TestKVStoreOutput is the output from nd_test_kvstore callback. -type TestKVStoreOutput struct { - Value []byte `json:"value,omitempty"` - Values map[string][]byte `json:"values,omitempty"` - Exists bool `json:"exists,omitempty"` - Keys []string `json:"keys,omitempty"` - StorageUsed int64 `json:"storage_used,omitempty"` - DeletedCount int64 `json:"deleted_count,omitempty"` - Error *string `json:"error,omitempty"` -} - -// nd_test_kvstore is the test callback that tests the kvstore host functions. -// -//go:wasmexport nd_test_kvstore -func ndTestKVStore() int32 { - var input TestKVStoreInput - if err := pdk.InputJSON(&input); err != nil { - errStr := err.Error() - pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) - return 0 - } - - switch input.Operation { - case "set": - err := host.KVStoreSet(input.Key, input.Value) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestKVStoreOutput{}) - return 0 - - case "get": - value, exists, err := host.KVStoreGet(input.Key) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestKVStoreOutput{Value: value, Exists: exists}) - return 0 - - case "delete": - err := host.KVStoreDelete(input.Key) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestKVStoreOutput{}) - return 0 - - case "has": - exists, err := host.KVStoreHas(input.Key) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestKVStoreOutput{Exists: exists}) - return 0 - - case "list": - keys, err := host.KVStoreList(input.Prefix) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestKVStoreOutput{Keys: keys}) - return 0 - - case "get_storage_used": - bytesUsed, err := host.KVStoreGetStorageUsed() - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestKVStoreOutput{StorageUsed: bytesUsed}) - return 0 - - case "set_with_ttl": - err := host.KVStoreSetWithTTL(input.Key, input.Value, input.TTLSeconds) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestKVStoreOutput{}) - return 0 - - case "delete_by_prefix": - deletedCount, err := host.KVStoreDeleteByPrefix(input.Prefix) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestKVStoreOutput{DeletedCount: deletedCount}) - return 0 - - case "get_many": - values, err := host.KVStoreGetMany(input.Keys) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestKVStoreOutput{Values: values}) - return 0 - - default: - errStr := "unknown operation: " + input.Operation - pdk.OutputJSON(TestKVStoreOutput{Error: &errStr}) - return 0 - } -} - -func main() {} diff --git a/plugins/testdata/test-kvstore/manifest.json b/plugins/testdata/test-kvstore/manifest.json deleted file mode 100644 index d2a411d93..000000000 --- a/plugins/testdata/test-kvstore/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "Test KVStore Plugin", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test kvstore plugin for integration testing", - "permissions": { - "kvstore": { - "reason": "For testing kvstore operations", - "maxSize": "10KB" - } - } -} diff --git a/plugins/testdata/test-library/go.mod b/plugins/testdata/test-library/go.mod deleted file mode 100644 index d3efcf2fd..000000000 --- a/plugins/testdata/test-library/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-library - -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-library/go.sum b/plugins/testdata/test-library/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-library/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-library/main.go b/plugins/testdata/test-library/main.go deleted file mode 100644 index e83c9aec6..000000000 --- a/plugins/testdata/test-library/main.go +++ /dev/null @@ -1,122 +0,0 @@ -// Test Library plugin for Navidrome plugin system integration tests. -// This plugin tests library metadata access WITH filesystem permission, -// allowing tests for both metadata and filesystem access. -// Build with: tinygo build -o ../test-library.wasm -target wasip1 -buildmode=c-shared . -package main - -import ( - "os" - "path/filepath" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// TestLibraryInput is the input for nd_test_library callback. -type TestLibraryInput struct { - Operation string `json:"operation"` // "get_library", "get_all_libraries", "read_file", "list_dir", "write_file", "symlink" - LibraryID int32 `json:"library_id,omitempty"` - MountPoint string `json:"mount_point,omitempty"` // For filesystem operations - FilePath string `json:"file_path,omitempty"` // For read_file operation (relative to mount point) - Content string `json:"content,omitempty"` // For write_file operation - Target string `json:"target,omitempty"` // For symlink operation -} - -// TestLibraryOutput is the output from nd_test_library callback. -type TestLibraryOutput struct { - Library *host.Library `json:"library,omitempty"` - Libraries []host.Library `json:"libraries,omitempty"` - FileContent string `json:"file_content,omitempty"` - DirEntries []string `json:"dir_entries,omitempty"` - Error *string `json:"error,omitempty"` -} - -// nd_test_library is the test callback that tests the library host functions. -// -//go:wasmexport nd_test_library -func ndTestLibrary() int32 { - var input TestLibraryInput - if err := pdk.InputJSON(&input); err != nil { - errStr := err.Error() - pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) - return 0 - } - - switch input.Operation { - case "get_library": - library, err := host.LibraryGetLibrary(input.LibraryID) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestLibraryOutput{Library: library}) - return 0 - - case "get_all_libraries": - libraries, err := host.LibraryGetAllLibraries() - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestLibraryOutput{Libraries: libraries}) - return 0 - - case "read_file": - // Read a file from the mounted library directory - fullPath := filepath.Join(input.MountPoint, input.FilePath) - content, err := os.ReadFile(fullPath) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestLibraryOutput{FileContent: string(content)}) - return 0 - - case "list_dir": - // List files in the mounted library directory - entries, err := os.ReadDir(input.MountPoint) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) - return 0 - } - var names []string - for _, entry := range entries { - names = append(names, entry.Name()) - } - pdk.OutputJSON(TestLibraryOutput{DirEntries: names}) - return 0 - - case "write_file": - // Write a file to the mounted library directory - fullPath := filepath.Join(input.MountPoint, input.FilePath) - if err := os.WriteFile(fullPath, []byte(input.Content), 0600); err != nil { - errStr := err.Error() - pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestLibraryOutput{}) - return 0 - - case "symlink": - // Create a symlink inside the mounted library directory - fullPath := filepath.Join(input.MountPoint, input.FilePath) - if err := os.Symlink(input.Target, fullPath); err != nil { - errStr := err.Error() - pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestLibraryOutput{}) - return 0 - - default: - errStr := "unknown operation: " + input.Operation - pdk.OutputJSON(TestLibraryOutput{Error: &errStr}) - return 0 - } -} - -func main() {} diff --git a/plugins/testdata/test-library/manifest.json b/plugins/testdata/test-library/manifest.json deleted file mode 100644 index b06b3bbec..000000000 --- a/plugins/testdata/test-library/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "Test Library Plugin", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test library plugin for integration testing", - "permissions": { - "library": { - "reason": "For testing library metadata and filesystem access", - "filesystem": true - } - } -} diff --git a/plugins/testdata/test-lyrics/go.mod b/plugins/testdata/test-lyrics/go.mod deleted file mode 100644 index fbbb23fc0..000000000 --- a/plugins/testdata/test-lyrics/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-lyrics - -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-lyrics/go.sum b/plugins/testdata/test-lyrics/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-lyrics/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-lyrics/main.go b/plugins/testdata/test-lyrics/main.go deleted file mode 100644 index 2ee2dabbf..000000000 --- a/plugins/testdata/test-lyrics/main.go +++ /dev/null @@ -1,77 +0,0 @@ -// Test lyrics plugin for Navidrome plugin system integration tests. -package main - -import ( - "fmt" - - "github.com/navidrome/navidrome/plugins/pdk/go/lyrics" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -func init() { - lyrics.Register(&testLyrics{}) -} - -type testLyrics struct{} - -func (t *testLyrics) GetLyrics(input lyrics.GetLyricsRequest) (lyrics.GetLyricsResponse, error) { - errMsg, hasErr := pdk.GetConfig("error") - if hasErr && errMsg != "" { - return lyrics.GetLyricsResponse{}, fmt.Errorf("%s", errMsg) - } - - // Config-selected format lets tests exercise the adapter's content-sniffing per format. - format, hasFormat := pdk.GetConfig("format") - if hasFormat { - var text string - var lang string - switch format { - case "ttml": - lang = "eng" - text = `<?xml version="1.0" encoding="UTF-8"?> -<tt xmlns="http://www.w3.org/ns/ttml"> - <body xml:lang="eng"> - <div> - <p begin="00:00.000" end="00:01.000">plugin ttml line</p> - </div> - </body> -</tt>` - case "srt": - lang = "eng" - text = "1\n00:00:01,000 --> 00:00:02,000\nplugin srt line\n" - case "yaml": - lang = "eng" - text = "version: \"1.0\"\nmetadata:\n language: eng\nlines:\n - text: \"plugin yaml line\"\n start_ms: 1000\n" - case "lrc": - lang = "eng" - text = "[00:01.00]plugin lrc line" - case "plain": - lang = "eng" - text = "plugin plain line" - } - if text != "" { - return lyrics.GetLyricsResponse{ - Lyrics: []lyrics.LyricsText{{Lang: lang, Text: text}}, - }, nil - } - } - - // Check if we should omit language (to test default language handling) - noLang, hasNoLang := pdk.GetConfig("no_lang") - lang := "eng" - if hasNoLang && noLang == "true" { - lang = "" - } - - // Return test lyrics based on track info - return lyrics.GetLyricsResponse{ - Lyrics: []lyrics.LyricsText{ - { - Lang: lang, - Text: "Test lyrics for " + input.Track.Title + "\nBy " + input.Track.Artist, - }, - }, - }, nil -} - -func main() {} diff --git a/plugins/testdata/test-lyrics/manifest.json b/plugins/testdata/test-lyrics/manifest.json deleted file mode 100644 index a61299e92..000000000 --- a/plugins/testdata/test-lyrics/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Test Lyrics", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test lyrics plugin for integration testing" -} diff --git a/plugins/testdata/test-matcher/go.mod b/plugins/testdata/test-matcher/go.mod deleted file mode 100644 index 7abbc93c6..000000000 --- a/plugins/testdata/test-matcher/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-matcher - -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-matcher/go.sum b/plugins/testdata/test-matcher/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-matcher/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-matcher/main.go b/plugins/testdata/test-matcher/main.go deleted file mode 100644 index 80d650b7b..000000000 --- a/plugins/testdata/test-matcher/main.go +++ /dev/null @@ -1,56 +0,0 @@ -// Test Matcher plugin for Navidrome plugin system integration tests. -// Build with: tinygo build -o ../test-matcher.wasm -target wasip1 -buildmode=c-shared . -package main - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" - "github.com/navidrome/navidrome/plugins/pdk/go/types" -) - -// TestMatcherInput is the input for the nd_test_matcher callback. -type TestMatcherInput struct { - Songs []types.SongRef `json:"songs"` - Username string `json:"username,omitempty"` -} - -// TestMatcherOutput is the output from the nd_test_matcher callback. -// MatchedIDs and Starred are aligned to the input: an empty string at index i -// means no match; Starred[i] reflects the matched track's starred flag. -type TestMatcherOutput struct { - MatchedIDs []string `json:"matched_ids"` - Starred []bool `json:"starred"` - Error *string `json:"error,omitempty"` -} - -// nd_test_matcher forwards the input song list to the host matcher and returns matched track IDs. -// -//go:wasmexport nd_test_matcher -func ndTestMatcher() int32 { - var input TestMatcherInput - if err := pdk.InputJSON(&input); err != nil { - errStr := err.Error() - pdk.OutputJSON(TestMatcherOutput{Error: &errStr}) - return 0 - } - - results, err := host.MatcherMatchSongs(input.Songs, host.MatchOptions{Username: input.Username}) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestMatcherOutput{Error: &errStr}) - return 0 - } - - ids := make([]string, len(results)) - starred := make([]bool, len(results)) - for i, t := range results { - if t != nil { - ids[i] = t.ID - starred[i] = t.Starred - } - } - pdk.OutputJSON(TestMatcherOutput{MatchedIDs: ids, Starred: starred}) - return 0 -} - -func main() {} diff --git a/plugins/testdata/test-matcher/manifest.json b/plugins/testdata/test-matcher/manifest.json deleted file mode 100644 index 26be5c577..000000000 --- a/plugins/testdata/test-matcher/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "Test Matcher Plugin", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test plugin for Matcher integration testing", - "permissions": { - "matcher": { - "reason": "For testing matcher operations" - }, - "library": { - "reason": "Matching returns library tracks" - } - } -} diff --git a/plugins/testdata/test-metadata-agent/go.mod b/plugins/testdata/test-metadata-agent/go.mod deleted file mode 100644 index 02aff736c..000000000 --- a/plugins/testdata/test-metadata-agent/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-metadata-agent - -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-metadata-agent/go.sum b/plugins/testdata/test-metadata-agent/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-metadata-agent/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-metadata-agent/main.go b/plugins/testdata/test-metadata-agent/main.go deleted file mode 100644 index bb8a092c6..000000000 --- a/plugins/testdata/test-metadata-agent/main.go +++ /dev/null @@ -1,185 +0,0 @@ -// Test plugin for Navidrome plugin system integration tests. -// Build with: tinygo build -o ../test-metadata-agent.wasm -target wasip1 -buildmode=c-shared . -package main - -import ( - "errors" - "strconv" - - "github.com/navidrome/navidrome/plugins/pdk/go/metadata" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" - "github.com/navidrome/navidrome/plugins/pdk/go/types" -) - -func init() { - metadata.Register(&testMetadataAgent{}) -} - -type testMetadataAgent struct{} - -// checkConfigError checks if the plugin is configured to return an error. -// If "error" config is set, it returns an error with that message. -func checkConfigError() error { - errMsg, hasErr := pdk.GetConfig("error") - if !hasErr || errMsg == "" { - return nil - } - return errors.New(errMsg) -} - -func (t *testMetadataAgent) GetArtistMBID(input metadata.ArtistMBIDRequest) (*metadata.ArtistMBIDResponse, error) { - if err := checkConfigError(); err != nil { - return nil, err - } - return &metadata.ArtistMBIDResponse{MBID: "test-mbid-" + input.Name}, nil -} - -func (t *testMetadataAgent) GetArtistURL(input metadata.ArtistRequest) (*metadata.ArtistURLResponse, error) { - if err := checkConfigError(); err != nil { - return nil, err - } - return &metadata.ArtistURLResponse{URL: "https://test.example.com/artist/" + input.Name}, nil -} - -func (t *testMetadataAgent) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) { - if err := checkConfigError(); err != nil { - return nil, err - } - return &metadata.ArtistBiographyResponse{Biography: "Biography for " + input.Name}, nil -} - -func (t *testMetadataAgent) GetArtistImages(input metadata.ArtistRequest) (*metadata.ArtistImagesResponse, error) { - if err := checkConfigError(); err != nil { - return nil, err - } - return &metadata.ArtistImagesResponse{ - Images: []metadata.ImageInfo{ - {URL: "https://test.example.com/images/" + input.Name + "/large.jpg", Size: 500}, - {URL: "https://test.example.com/images/" + input.Name + "/small.jpg", Size: 100}, - }, - }, nil -} - -func (t *testMetadataAgent) GetSimilarArtists(input metadata.SimilarArtistsRequest) (*metadata.SimilarArtistsResponse, error) { - if err := checkConfigError(); err != nil { - return nil, err - } - limit := int(input.Limit) - if limit == 0 { - limit = 5 - } - artists := make([]types.ArtistRef, 0, limit) - for i := range limit { - artists = append(artists, types.ArtistRef{ - ID: "similar-artist-id-" + strconv.Itoa(i+1), - Name: input.Name + " Similar " + string(rune('A'+i)), - MBID: "similar-mbid-" + strconv.Itoa(i+1), - }) - } - return &metadata.SimilarArtistsResponse{Artists: artists}, nil -} - -func (t *testMetadataAgent) GetArtistTopSongs(input metadata.TopSongsRequest) (*metadata.TopSongsResponse, error) { - if err := checkConfigError(); err != nil { - return nil, err - } - count := int(input.Count) - if count == 0 { - count = 5 - } - songs := make([]types.SongRef, 0, count) - for i := range count { - songs = append(songs, types.SongRef{ - ID: "song-id-" + strconv.Itoa(i+1), - Name: input.Name + " Song " + strconv.Itoa(i+1), - MBID: "song-mbid-" + strconv.Itoa(i+1), - }) - } - return &metadata.TopSongsResponse{Songs: songs}, nil -} - -func (t *testMetadataAgent) GetAlbumInfo(input metadata.AlbumRequest) (*metadata.AlbumInfoResponse, error) { - if err := checkConfigError(); err != nil { - return nil, err - } - return &metadata.AlbumInfoResponse{ - Name: input.Name, - MBID: "test-album-mbid-" + input.Name, - Description: "Description for " + input.Name + " by " + input.Artist, - URL: "https://test.example.com/album/" + input.Name, - }, nil -} - -func (t *testMetadataAgent) GetAlbumImages(input metadata.AlbumRequest) (*metadata.AlbumImagesResponse, error) { - if err := checkConfigError(); err != nil { - return nil, err - } - return &metadata.AlbumImagesResponse{ - Images: []metadata.ImageInfo{ - {URL: "https://test.example.com/albums/" + input.Name + "/cover.jpg", Size: 500}, - }, - }, nil -} - -func (t *testMetadataAgent) GetSimilarSongsByTrack(input metadata.SimilarSongsByTrackRequest) (*metadata.SimilarSongsResponse, error) { - if err := checkConfigError(); err != nil { - return nil, err - } - count := int(input.Count) - if count == 0 { - count = 5 - } - songs := make([]types.SongRef, 0, count) - for i := range count { - songs = append(songs, types.SongRef{ - ID: "similar-track-id-" + strconv.Itoa(i+1), - Name: "Similar to " + input.Name + " #" + strconv.Itoa(i+1), - MBID: "similar-mbid-" + strconv.Itoa(i+1), - ISRC: "similar-isrc-" + strconv.Itoa(i+1), - Artist: input.Artist, - ArtistMBID: "artist-mbid-" + strconv.Itoa(i+1), - }) - } - return &metadata.SimilarSongsResponse{Songs: songs}, nil -} - -func (t *testMetadataAgent) GetSimilarSongsByAlbum(input metadata.SimilarSongsByAlbumRequest) (*metadata.SimilarSongsResponse, error) { - if err := checkConfigError(); err != nil { - return nil, err - } - count := int(input.Count) - if count == 0 { - count = 5 - } - songs := make([]types.SongRef, 0, count) - for i := range count { - songs = append(songs, types.SongRef{ - ID: "album-similar-id-" + strconv.Itoa(i+1), - Name: "Album Similar #" + strconv.Itoa(i+1), - Artist: input.Artist, - Album: input.Name, - }) - } - return &metadata.SimilarSongsResponse{Songs: songs}, nil -} - -func (t *testMetadataAgent) GetSimilarSongsByArtist(input metadata.SimilarSongsByArtistRequest) (*metadata.SimilarSongsResponse, error) { - if err := checkConfigError(); err != nil { - return nil, err - } - count := int(input.Count) - if count == 0 { - count = 5 - } - songs := make([]types.SongRef, 0, count) - for i := range count { - songs = append(songs, types.SongRef{ - ID: "artist-similar-id-" + strconv.Itoa(i+1), - Name: input.Name + " Style Song #" + strconv.Itoa(i+1), - Artist: input.Name + " Similar Artist", - }) - } - return &metadata.SimilarSongsResponse{Songs: songs}, nil -} - -func main() {} diff --git a/plugins/testdata/test-metadata-agent/manifest.json b/plugins/testdata/test-metadata-agent/manifest.json deleted file mode 100644 index 3a1838730..000000000 --- a/plugins/testdata/test-metadata-agent/manifest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "Test Plugin", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test plugin for integration testing", - "capabilities": ["MetadataAgent"], - "permissions": { - "http": { - "reason": "Test HTTP access", - "allowedURLs": { - "https://test.example.com/*": ["GET"] - } - } - } -} diff --git a/plugins/testdata/test-scheduler/go.mod b/plugins/testdata/test-scheduler/go.mod deleted file mode 100644 index 337cc9650..000000000 --- a/plugins/testdata/test-scheduler/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-scheduler - -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-scheduler/go.sum b/plugins/testdata/test-scheduler/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-scheduler/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-scheduler/main.go b/plugins/testdata/test-scheduler/main.go deleted file mode 100644 index 5276f9215..000000000 --- a/plugins/testdata/test-scheduler/main.go +++ /dev/null @@ -1,40 +0,0 @@ -// Test scheduler plugin for Navidrome plugin system integration tests. -// Build with: tinygo build -o ../test-scheduler.wasm -target wasip1 -buildmode=c-shared . -package main - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/scheduler" -) - -func init() { - scheduler.Register(&testScheduler{}) -} - -type testScheduler struct{} - -// OnCallback is called when a scheduled task fires. -// Magic payloads trigger specific behaviors to test host functions: -// - "schedule-followup": schedules a one-time task via host function -// - "schedule-recurring": schedules a recurring task via host function -// - "schedule-duplicate:<id>": attempts to schedule with the given ID (for testing duplicate detection) -func (t *testScheduler) OnCallback(input scheduler.SchedulerCallbackRequest) error { - switch { - case input.Payload == "schedule-followup": - if _, err := host.SchedulerScheduleOneTime(1, "followup-created", "followup-id"); err != nil { - return err - } - case input.Payload == "schedule-recurring": - if _, err := host.SchedulerScheduleRecurring("@every 1s", "recurring-created", "recurring-from-plugin"); err != nil { - return err - } - case len(input.Payload) > 19 && input.Payload[:19] == "schedule-duplicate:": - duplicateID := input.Payload[19:] - if _, err := host.SchedulerScheduleOneTime(60, "duplicate-attempt", duplicateID); err != nil { - return err - } - } - return nil -} - -func main() {} diff --git a/plugins/testdata/test-scheduler/manifest.json b/plugins/testdata/test-scheduler/manifest.json deleted file mode 100644 index b001ff2ad..000000000 --- a/plugins/testdata/test-scheduler/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "Test Scheduler", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test scheduler plugin for integration testing", - "permissions": { - "scheduler": { - "reason": "For testing scheduler callbacks" - } - } -} diff --git a/plugins/testdata/test-scrobble-retriever/go.mod b/plugins/testdata/test-scrobble-retriever/go.mod deleted file mode 100644 index 59486d796..000000000 --- a/plugins/testdata/test-scrobble-retriever/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-scrobble-retriever - -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-scrobble-retriever/go.sum b/plugins/testdata/test-scrobble-retriever/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-scrobble-retriever/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-scrobble-retriever/main.go b/plugins/testdata/test-scrobble-retriever/main.go deleted file mode 100644 index cc824acb9..000000000 --- a/plugins/testdata/test-scrobble-retriever/main.go +++ /dev/null @@ -1,120 +0,0 @@ -package main - -import ( - "strconv" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -func main() { - -} - -type TestScrobbleTimestampOutput struct { - Timestamp *int64 `json:"timestamp"` -} - -//go:wasmexport call_get_first_timestamp -func callGetFirstTimestamp() int32 { - username := pdk.InputString() - - time, err := host.ScrobbleRetrieverGetFirstTimestamp(username) - if err != nil { - pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) - return 1 - } - - pdk.OutputJSON(TestScrobbleTimestampOutput{Timestamp: time}) - return 0 -} - -//go:wasmexport call_get_last_timestamp -func callGetLastTimestamp() int32 { - username := pdk.InputString() - - time, err := host.ScrobbleRetrieverGetLastTimestamp(username) - if err != nil { - pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) - return 1 - } - - pdk.OutputJSON(TestScrobbleTimestampOutput{Timestamp: time}) - return 0 -} - -type TestScrobbleOptions struct { - Username string `json:"username"` - FromTimestamp *int64 `json:"fromTimestamp,omitempty"` - ToTimestamp *int64 `json:"toTimestamp,omitempty"` - Descending bool `json:"descending"` - Offset int `json:"offset,omitempty"` - MaxItems int `json:"maxItems"` -} - -// TestScrobblePage mirrors the multi-return shape so tests can assert on both values -type TestScrobblePage struct { - Scrobbles []host.ScrobbleRef `json:"scrobbles"` - Next *host.ScrobbleOptions `json:"next"` -} - -//go:wasmexport call_get_scrobbles -func callGetScrobbles() int32 { - var options TestScrobbleOptions - err := pdk.InputJSON(&options) - - if err != nil { - pdk.SetErrorString("failed to deserialize input " + err.Error()) - return 1 - } - - scrobbles, next, err := host.ScrobbleRetrieverGetScrobbles(options.Username, host.ScrobbleOptions{ - FromTimestamp: options.FromTimestamp, - ToTimestamp: options.ToTimestamp, - MaxItems: options.MaxItems, - Descending: options.Descending, - Offset: options.Offset, - }) - - if err != nil { - pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) - return 1 - } - - if scrobbles == nil { - scrobbles = []host.ScrobbleRef{} - } - - pdk.OutputJSON(TestScrobblePage{Scrobbles: scrobbles, Next: next}) - return 0 -} - -type TestScrobbleCountOptions struct { - Username string `json:"username"` - FromTimestamp *int64 `json:"fromTimestamp,omitempty"` - ToTimestamp *int64 `json:"toTimestamp,omitempty"` -} - -//go:wasmexport call_get_scrobbles_count -func callGetScrobblesCount() int32 { - var options TestScrobbleOptions - err := pdk.InputJSON(&options) - - if err != nil { - pdk.SetErrorString("failed to deserialize input " + err.Error()) - return 1 - } - - count, err := host.ScrobbleRetrieverGetScrobbleCount(options.Username, host.ScrobbleCountOptions{ - FromTimestamp: options.FromTimestamp, - ToTimestamp: options.ToTimestamp, - }) - - if err != nil { - pdk.SetErrorString("failed to call scrobble retriever api " + err.Error()) - return 1 - } - - pdk.OutputString(strconv.FormatInt(count, 10)) - return 0 -} diff --git a/plugins/testdata/test-scrobble-retriever/manifest.json b/plugins/testdata/test-scrobble-retriever/manifest.json deleted file mode 100644 index a60203704..000000000 --- a/plugins/testdata/test-scrobble-retriever/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "Test Scrobble Retriever", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test plugin for scrobble retriever integration settings", - "permissions": { - "scrobbleRetriever": { - "reason": "For testing scrobble retriever operations" - }, - "users": { - "reason": "Access user information for scrobble retrieval" - } - } -} diff --git a/plugins/testdata/test-scrobbler/go.mod b/plugins/testdata/test-scrobbler/go.mod deleted file mode 100644 index b9e2c3887..000000000 --- a/plugins/testdata/test-scrobbler/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-scrobbler - -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-scrobbler/go.sum b/plugins/testdata/test-scrobbler/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-scrobbler/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-scrobbler/main.go b/plugins/testdata/test-scrobbler/main.go deleted file mode 100644 index a8cee4a4e..000000000 --- a/plugins/testdata/test-scrobbler/main.go +++ /dev/null @@ -1,104 +0,0 @@ -// Test scrobbler plugin for Navidrome plugin system integration tests. -// Build with: tinygo build -o ../test-scrobbler.wasm -target wasip1 -buildmode=c-shared ./main.go -package main - -import ( - "fmt" - "strconv" - - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" - "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" -) - -func init() { - scrobbler.Register(&testScrobbler{}) -} - -type testScrobbler struct{} - -// IsAuthorized checks if a user is authorized. -func (t *testScrobbler) IsAuthorized(scrobbler.IsAuthorizedRequest) (bool, error) { - return checkAuthConfig(), nil -} - -// NowPlaying sends a now playing notification. -func (t *testScrobbler) NowPlaying(input scrobbler.NowPlayingRequest) error { - // Check for configured error - if err := checkConfigError(); err != nil { - return err - } - - // Log the now playing (for potential debugging) - artistName := "" - if len(input.Track.Artists) > 0 { - artistName = input.Track.Artists[0].Name - } - pdk.Log(pdk.LogInfo, "NowPlaying: "+input.Track.Title+" by "+artistName) - return nil -} - -// Scrobble submits a scrobble. -func (t *testScrobbler) Scrobble(input scrobbler.ScrobbleRequest) error { - // Check for configured error - if err := checkConfigError(); err != nil { - return err - } - - // Log the scrobble (for potential debugging) - artistName := "" - if len(input.Track.Artists) > 0 { - artistName = input.Track.Artists[0].Name - } - pdk.Log(pdk.LogInfo, "Scrobble: "+input.Track.Title+" by "+artistName) - 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" -func checkConfigError() error { - errMsg, hasErr := pdk.GetConfig("error") - if !hasErr || errMsg == "" { - return nil - } - errType, _ := pdk.GetConfig("error_type") - switch errType { - case scrobbler.ScrobblerErrorNotAuthorized.Error(): - return fmt.Errorf("%w: %s", scrobbler.ScrobblerErrorNotAuthorized, errMsg) - case scrobbler.ScrobblerErrorRetryLater.Error(): - return fmt.Errorf("%w: %s", scrobbler.ScrobblerErrorRetryLater, errMsg) - default: - return fmt.Errorf("%w: %s", scrobbler.ScrobblerErrorUnrecoverable, errMsg) - } -} - -// checkAuthConfig returns whether the plugin is configured to authorize users. -// If "authorized" config is set to "false", users are not authorized. -// Default is true (authorized). -func checkAuthConfig() bool { - authStr, hasAuth := pdk.GetConfig("authorized") - if !hasAuth { - return true // Default: authorized - } - auth, err := strconv.ParseBool(authStr) - if err != nil { - return true // Default on parse error - } - return auth -} - -func main() {} diff --git a/plugins/testdata/test-scrobbler/manifest.json b/plugins/testdata/test-scrobbler/manifest.json deleted file mode 100644 index 6a6ec48ed..000000000 --- a/plugins/testdata/test-scrobbler/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "Test Scrobbler", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test scrobbler plugin for integration testing", - "permissions": { - "users": { - "reason": "Receive scrobble events for users assigned to this plugin" - } - } -} diff --git a/plugins/testdata/test-sonic-similarity/go.mod b/plugins/testdata/test-sonic-similarity/go.mod deleted file mode 100644 index 6265288f9..000000000 --- a/plugins/testdata/test-sonic-similarity/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-sonic-similarity/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 538e9c8f3..000000000 --- a/plugins/testdata/test-sonic-similarity/main.go +++ /dev/null @@ -1,72 +0,0 @@ -// 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" - "github.com/navidrome/navidrome/plugins/pdk/go/types" -) - -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: types.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: types.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 deleted file mode 100644 index a55c4021f..000000000 --- a/plugins/testdata/test-sonic-similarity/manifest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "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/plugins/testdata/test-storage-plugin-2 b/plugins/testdata/test-storage-plugin-2 deleted file mode 120000 index 444bafd87..000000000 --- a/plugins/testdata/test-storage-plugin-2 +++ /dev/null @@ -1 +0,0 @@ -test-storage-plugin \ No newline at end of file diff --git a/plugins/testdata/test-storage-plugin/go.mod b/plugins/testdata/test-storage-plugin/go.mod deleted file mode 100644 index 7b9a2b567..000000000 --- a/plugins/testdata/test-storage-plugin/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-storage-plugin - -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-storage-plugin/go.sum b/plugins/testdata/test-storage-plugin/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-storage-plugin/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-storage-plugin/main.go b/plugins/testdata/test-storage-plugin/main.go deleted file mode 100644 index 17ffc8e6b..000000000 --- a/plugins/testdata/test-storage-plugin/main.go +++ /dev/null @@ -1,50 +0,0 @@ -// Test plugin for Storage host function integration tests. -// Build with: tinygo build -o ../test-storage-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go -package main - -import ( - "os" - "path/filepath" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -//go:wasmexport call_read -func callRead() int32 { - path := filepath.Join(host.StorageGetStoragePath(), pdk.InputString()) - content, err := os.ReadFile(path) - if err != nil { - pdk.SetErrorString("failed to read file: " + err.Error()) - return 1 - } - pdk.Output(content) - return 0 -} - -type WriteInput struct { - Path string `json:"path"` - Contents string `json:"contents"` -} - -//go:wasmexport call_write -func callWrite() int32 { - var config WriteInput - err := pdk.InputJSON(&config) - - if err != nil { - pdk.SetErrorString("failed to parse json: " + err.Error()) - return 1 - } - - path := filepath.Join(host.StorageGetStoragePath(), config.Path) - err = os.WriteFile(path, []byte(config.Contents), 0600) - if err != nil { - pdk.SetErrorString("failed to write file: " + err.Error()) - return 1 - } - - return 0 -} - -func main() {} diff --git a/plugins/testdata/test-storage-plugin/manifest.json b/plugins/testdata/test-storage-plugin/manifest.json deleted file mode 100644 index 98ac24a47..000000000 --- a/plugins/testdata/test-storage-plugin/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "Test Storage Plugin", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "Test plugin for storage host function", - "permissions": { - "storage": { - "reason": "persistent storage for testing" - } - } -} diff --git a/plugins/testdata/test-subsonicapi-plugin/go.mod b/plugins/testdata/test-subsonicapi-plugin/go.mod deleted file mode 100644 index 1fb943418..000000000 --- a/plugins/testdata/test-subsonicapi-plugin/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-subsonicapi-plugin - -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-subsonicapi-plugin/go.sum b/plugins/testdata/test-subsonicapi-plugin/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-subsonicapi-plugin/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-subsonicapi-plugin/main.go b/plugins/testdata/test-subsonicapi-plugin/main.go deleted file mode 100644 index 573036b95..000000000 --- a/plugins/testdata/test-subsonicapi-plugin/main.go +++ /dev/null @@ -1,57 +0,0 @@ -// Test plugin for SubsonicAPI host function integration tests. -// Build with: tinygo build -o ../test-subsonicapi-plugin.wasm -target wasip1 -buildmode=c-shared ./main.go -package main - -import ( - "fmt" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// call_subsonic_api is the exported function that tests the SubsonicAPI host function. -// Input: URI string (e.g., "/ping?u=testuser") -// Output: The raw JSON response from the Subsonic API -// -//go:wasmexport call_subsonic_api -func callSubsonicAPIExport() int32 { - // Get the URI from input - uri := pdk.InputString() - - // Call the Subsonic API via host function - responseJSON, err := host.SubsonicAPICall(uri) - if err != nil { - pdk.SetErrorString("failed to call SubsonicAPI: " + err.Error()) - return 1 - } - - // Return the response - pdk.OutputString(responseJSON) - return 0 -} - -// call_subsonic_api_raw is the exported function that tests the SubsonicAPI CallRaw host function. -// Input: URI string (e.g., "/getCoverArt?u=testuser&id=al-1") -// Output: JSON with contentType, size, and first bytes of the raw response -// -//go:wasmexport call_subsonic_api_raw -func callSubsonicAPIRawExport() int32 { - uri := pdk.InputString() - - contentType, data, err := host.SubsonicAPICallRaw(uri) - if err != nil { - pdk.SetErrorString("failed to call SubsonicAPI raw: " + err.Error()) - return 1 - } - - // Return metadata about the raw response as JSON - firstByte := 0 - if len(data) > 0 { - firstByte = int(data[0]) - } - result := fmt.Sprintf(`{"contentType":%q,"size":%d,"firstByte":%d}`, contentType, len(data), firstByte) - pdk.OutputString(result) - return 0 -} - -func main() {} diff --git a/plugins/testdata/test-subsonicapi-plugin/manifest.json b/plugins/testdata/test-subsonicapi-plugin/manifest.json deleted file mode 100644 index 027e17761..000000000 --- a/plugins/testdata/test-subsonicapi-plugin/manifest.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "Test SubsonicAPI Plugin", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "Test plugin for SubsonicAPI host function", - "permissions": { - "subsonicapi": { - "reason": "Testing SubsonicAPI access" - }, - "users": { - "reason": "Access user information for SubsonicAPI authorization" - } - } -} diff --git a/plugins/testdata/test-taskqueue/go.mod b/plugins/testdata/test-taskqueue/go.mod deleted file mode 100644 index 37f857e5a..000000000 --- a/plugins/testdata/test-taskqueue/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-taskqueue - -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-taskqueue/go.sum b/plugins/testdata/test-taskqueue/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-taskqueue/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-taskqueue/main.go b/plugins/testdata/test-taskqueue/main.go deleted file mode 100644 index 9b734ed67..000000000 --- a/plugins/testdata/test-taskqueue/main.go +++ /dev/null @@ -1,114 +0,0 @@ -// Test TaskQueue plugin for Navidrome plugin system integration tests. -// Build with: tinygo build -o ../test-taskqueue.wasm -target wasip1 -buildmode=c-shared . -package main - -import ( - "fmt" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" - "github.com/navidrome/navidrome/plugins/pdk/go/taskworker" -) - -func init() { - taskworker.Register(&handler{}) -} - -type handler struct{} - -func (h *handler) OnTaskExecute(req taskworker.TaskExecuteRequest) (string, error) { - payload := string(req.Payload) - if payload == "fail" { - return "", fmt.Errorf("task failed as instructed") - } - if payload == "fail-then-succeed" && req.Attempt < 2 { - return "", fmt.Errorf("transient failure") - } - return "completed successfully", nil -} - -// Test helper types -type TestInput struct { - Operation string `json:"operation"` - QueueName string `json:"queueName,omitempty"` - Config *host.QueueConfig `json:"config,omitempty"` - Payload []byte `json:"payload,omitempty"` - TaskID string `json:"taskId,omitempty"` -} - -type TestOutput struct { - TaskID string `json:"taskId,omitempty"` - Status string `json:"status,omitempty"` - Message string `json:"message,omitempty"` - Attempt int32 `json:"attempt,omitempty"` - Cleared int64 `json:"cleared,omitempty"` - Error *string `json:"error,omitempty"` -} - -//go:wasmexport nd_test_taskqueue -func ndTestTaskQueue() int32 { - var input TestInput - if err := pdk.InputJSON(&input); err != nil { - errStr := err.Error() - pdk.OutputJSON(TestOutput{Error: &errStr}) - return 0 - } - - switch input.Operation { - case "create_queue": - config := host.QueueConfig{} - if input.Config != nil { - config = *input.Config - } - err := host.TaskCreateQueue(input.QueueName, config) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestOutput{}) - - case "enqueue": - taskID, err := host.TaskEnqueue(input.QueueName, input.Payload) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestOutput{TaskID: taskID}) - - case "get_task_status": - info, err := host.TaskGet(input.TaskID) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestOutput{Status: info.Status, Message: info.Message, Attempt: info.Attempt}) - - case "cancel_task": - err := host.TaskCancel(input.TaskID) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestOutput{}) - - case "clear_queue": - cleared, err := host.TaskClearQueue(input.QueueName) - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestOutput{Cleared: cleared}) - - default: - errStr := "unknown operation: " + input.Operation - pdk.OutputJSON(TestOutput{Error: &errStr}) - } - return 0 -} - -func main() {} diff --git a/plugins/testdata/test-taskqueue/manifest.json b/plugins/testdata/test-taskqueue/manifest.json deleted file mode 100644 index 3cd3b0f0b..000000000 --- a/plugins/testdata/test-taskqueue/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "Test TaskQueue Plugin", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test plugin for TaskQueue integration testing", - "permissions": { - "taskqueue": { - "reason": "For testing task queue operations", - "maxConcurrency": 10 - } - } -} diff --git a/plugins/testdata/test-users/go.mod b/plugins/testdata/test-users/go.mod deleted file mode 100644 index 973f29c9c..000000000 --- a/plugins/testdata/test-users/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-users - -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-users/go.sum b/plugins/testdata/test-users/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-users/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-users/main.go b/plugins/testdata/test-users/main.go deleted file mode 100644 index 08d07b360..000000000 --- a/plugins/testdata/test-users/main.go +++ /dev/null @@ -1,61 +0,0 @@ -// Test Users plugin for Navidrome plugin system integration tests. -// This plugin tests user metadata access via the Users host service. -// Build with: tinygo build -o ../test-users.wasm -target wasip1 -buildmode=c-shared . -package main - -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" -) - -// TestUsersInput is the input for nd_test_users callback. -type TestUsersInput struct { - Operation string `json:"operation"` // "get_users", "get_admins" -} - -// TestUsersOutput is the output from nd_test_users callback. -type TestUsersOutput struct { - Users []host.User `json:"users,omitempty"` - Error *string `json:"error,omitempty"` -} - -// nd_test_users is the test callback that tests the users host functions. -// -//go:wasmexport nd_test_users -func ndTestUsers() int32 { - var input TestUsersInput - if err := pdk.InputJSON(&input); err != nil { - errStr := err.Error() - pdk.OutputJSON(TestUsersOutput{Error: &errStr}) - return 0 - } - - switch input.Operation { - case "get_users": - users, err := host.UsersGetUsers() - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestUsersOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestUsersOutput{Users: users}) - return 0 - - case "get_admins": - admins, err := host.UsersGetAdmins() - if err != nil { - errStr := err.Error() - pdk.OutputJSON(TestUsersOutput{Error: &errStr}) - return 0 - } - pdk.OutputJSON(TestUsersOutput{Users: admins}) - return 0 - - default: - errStr := "unknown operation: " + input.Operation - pdk.OutputJSON(TestUsersOutput{Error: &errStr}) - return 0 - } -} - -func main() {} diff --git a/plugins/testdata/test-users/manifest.json b/plugins/testdata/test-users/manifest.json deleted file mode 100644 index 787260785..000000000 --- a/plugins/testdata/test-users/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "Test Users Plugin", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test users plugin for integration testing", - "permissions": { - "users": { - "reason": "For testing user metadata access" - } - } -} diff --git a/plugins/testdata/test-websocket/go.mod b/plugins/testdata/test-websocket/go.mod deleted file mode 100644 index f786b8658..000000000 --- a/plugins/testdata/test-websocket/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module test-websocket - -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-websocket/go.sum b/plugins/testdata/test-websocket/go.sum deleted file mode 100644 index af880eb51..000000000 --- a/plugins/testdata/test-websocket/go.sum +++ /dev/null @@ -1,14 +0,0 @@ -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-websocket/main.go b/plugins/testdata/test-websocket/main.go deleted file mode 100644 index 270f36a11..000000000 --- a/plugins/testdata/test-websocket/main.go +++ /dev/null @@ -1,80 +0,0 @@ -// Test WebSocket plugin for Navidrome plugin system integration tests. -// Build with: tinygo build -o ../test-websocket.wasm -target wasip1 -buildmode=c-shared . -package main - -import ( - "encoding/base64" - "errors" - - "github.com/navidrome/navidrome/plugins/pdk/go/host" - "github.com/navidrome/navidrome/plugins/pdk/go/pdk" - "github.com/navidrome/navidrome/plugins/pdk/go/websocket" -) - -func init() { - websocket.Register(&testWebSocket{}) -} - -type testWebSocket struct{} - -// OnTextMessage is called when a text message is received. -// Magic messages trigger specific behaviors to test host functions: -// - "echo": sends back the same message using SendText host function -// - "close": closes the connection using CloseConnection host function -// - "store:MESSAGE": stores MESSAGE in plugin config for later retrieval -// - "fail": returns an error to test error handling -func (t *testWebSocket) OnTextMessage(input websocket.OnTextMessageRequest) error { - // Store all received messages for test verification - storeReceivedMessage("text:" + input.Message) - - switch input.Message { - case "echo": - if err := host.WebSocketSendText(input.ConnectionID, "echo:"+input.Message); err != nil { - return err - } - - case "close": - if err := host.WebSocketCloseConnection(input.ConnectionID, 1000, "closed by plugin"); err != nil { - return err - } - - case "fail": - return errors.New("intentional test failure") - } - - return nil -} - -// OnBinaryMessage is called when a binary message is received. -// Echoes the data back as a binary message so tests can observe the callback fired. -func (t *testWebSocket) OnBinaryMessage(input websocket.OnBinaryMessageRequest) error { - encoded := base64.StdEncoding.EncodeToString(input.Data) - storeReceivedMessage("binary:" + encoded) - return host.WebSocketSendBinary(input.ConnectionID, input.Data) -} - -// OnError is called when an error occurs on a WebSocket connection. -func (t *testWebSocket) OnError(input websocket.OnErrorRequest) error { - // Store error for test verification - storeReceivedMessage("error:" + input.Error) - return nil -} - -// OnClose is called when a WebSocket connection is closed. -func (t *testWebSocket) OnClose(input websocket.OnCloseRequest) error { - // Store close event for test verification - storeReceivedMessage("close:" + input.Reason) - return nil -} - -// storeReceivedMessage stores messages in plugin variable storage for test verification. -// Messages are appended to an existing list. -func storeReceivedMessage(msg string) { - // Use Extism var storage for plugin state - if existingVar := pdk.GetVar("_received_messages"); existingVar != nil { - msg = string(existingVar) + "\n" + msg - } - pdk.SetVar("_received_messages", []byte(msg)) -} - -func main() {} diff --git a/plugins/testdata/test-websocket/manifest.json b/plugins/testdata/test-websocket/manifest.json deleted file mode 100644 index 0ac756470..000000000 --- a/plugins/testdata/test-websocket/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "Test WebSocket", - "author": "Navidrome Test", - "version": "1.0.0", - "description": "A test WebSocket plugin for integration testing", - "permissions": { - "websocket": { - "reason": "For testing WebSocket callbacks", - "requiredHosts": ["*.example.com", "localhost:*", "echo.websocket.org"] - } - } -} diff --git a/plugins/testdata_builder_test.go b/plugins/testdata_builder_test.go deleted file mode 100644 index 746a8af22..000000000 --- a/plugins/testdata_builder_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package plugins - -import ( - "errors" - "fmt" - "io/fs" - "os" - "os/exec" - "path/filepath" - "slices" - "sync" - "time" - - "github.com/navidrome/navidrome/utils" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// buildTestPlugins packages every test plugin under dir, replicating -// `make -C plugins/testdata` without needing make or zip on the PATH. -func buildTestPlugins(dir string) { - start := time.Now() - built, err := buildPackages(dir) - fmt.Fprintf(GinkgoWriter, "[BeforeSuite] built test plugins in %s: %v\n", time.Since(start), built) - Expect(err).ToNot(HaveOccurred(), "failed to build test plugins") -} - -func buildPackages(dir string) ([]string, error) { - mods, err := filepath.Glob(filepath.Join(dir, "*", "go.mod")) - if err != nil || len(mods) == 0 { - return nil, err - } - pdkTime, err := newestModTime(filepath.Join(dir, "..", "pdk", "go")) - if err != nil { - return nil, err - } - - built := make([]string, len(mods)) - errs := make([]error, len(mods)) - build := func(i int) { - pluginDir := filepath.Dir(mods[i]) - var rebuilt bool - if rebuilt, errs[i] = buildPackage(pluginDir, pdkTime); rebuilt { - built[i] = filepath.Base(pluginDir) - } - } - - // The first build populates the wasip1 stdlib and PDK objects every plugin - // shares; fanning out before it lands makes each one compile them again. - build(0) - var wg sync.WaitGroup - for i := range mods[1:] { - wg.Go(func() { build(i + 1) }) - } - wg.Wait() - return slices.DeleteFunc(built, func(name string) bool { return name == "" }), errors.Join(errs...) -} - -func buildPackage(dir string, pdkTime time.Time) (bool, error) { - sourceTime, err := newestModTime(dir) - if err != nil { - return false, err - } - pkg := dir + PackageExtension - if info, err := os.Stat(pkg); err == nil && info.ModTime().After(utils.TimeNewest(sourceTime, pdkTime)) { - return false, nil - } - - wasm, err := filepath.Abs(pkg + ".build.wasm") - if err != nil { - return false, err - } - defer os.Remove(wasm) - // -buildvcs=false keeps the bytes stable across commits, so the suite's - // wazero compilation cache still hits after a rebuild. - cmd := exec.Command("go", "build", "-buildvcs=false", "-buildmode=c-shared", "-o", wasm, ".") - cmd.Dir = dir - cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm") - if out, err := cmd.CombinedOutput(); err != nil { - return false, fmt.Errorf("building %s: %w\n%s", dir, err, out) - } - - tmp := pkg + ".build.ndp" - defer os.Remove(tmp) - if err := packageFiles(tmp, filepath.Join(dir, manifestFileName), wasm); err != nil { - return false, fmt.Errorf("packaging %s: %w", dir, err) - } - return true, os.Rename(tmp, pkg) -} - -func packageFiles(pkg, manifest, wasm string) error { - m, err := os.Open(manifest) - if err != nil { - return err - } - defer m.Close() - w, err := os.Open(wasm) - if err != nil { - return err - } - defer w.Close() - return writeNdp(pkg, m, w) -} - -func newestModTime(root string) (time.Time, error) { - var newest time.Time - err := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err - } - info, err := d.Info() - if err != nil { - return err - } - newest = utils.TimeNewest(newest, info.ModTime()) - return nil - }) - return newest, err -} diff --git a/plugins/types/track.go b/plugins/types/track.go deleted file mode 100644 index 9b63c0f26..000000000 --- a/plugins/types/track.go +++ /dev/null @@ -1,93 +0,0 @@ -package types - -// Track is a stable, public projection of a library media file for plugin consumption. -// It is a sane subset of the internal model.MediaFile, intended for reuse across host -// services and capabilities. Timestamps are Unix epoch seconds. -// -// Unlike SongRef, which is an abstract recording reference carrying only matching keys, -// Track is a concrete library entity: it identifies a specific media file that exists -// (or once existed) in the library and exposes its full descriptive metadata. -type Track struct { - // Identity & location - ID string `json:"id"` - LibraryID int32 `json:"libraryId"` - LibraryName string `json:"libraryName,omitempty"` - Path string `json:"path,omitempty"` - Missing bool `json:"missing"` - - // Core metadata - Title string `json:"title"` - Album string `json:"album"` - Artist string `json:"artist"` - AlbumArtist string `json:"albumArtist,omitempty"` - AlbumID string `json:"albumId,omitempty"` - SortTitle string `json:"sortTitle,omitempty"` - SortAlbumName string `json:"sortAlbumName,omitempty"` - SortArtistName string `json:"sortArtistName,omitempty"` - - // Track / disc / dates - TrackNumber int32 `json:"trackNumber"` - DiscNumber int32 `json:"discNumber"` - DiscSubtitle string `json:"discSubtitle,omitempty"` - Year int32 `json:"year"` - Date string `json:"date,omitempty"` - OriginalYear int32 `json:"originalYear"` - OriginalDate string `json:"originalDate,omitempty"` - ReleaseYear int32 `json:"releaseYear"` - ReleaseDate string `json:"releaseDate,omitempty"` - - // Audio / file - Size int64 `json:"size"` - Suffix string `json:"suffix,omitempty"` - Duration float64 `json:"duration"` - BitRate int32 `json:"bitRate"` - SampleRate int32 `json:"sampleRate"` - BitDepth *int32 `json:"bitDepth,omitempty"` - Channels int32 `json:"channels"` - Codec string `json:"codec,omitempty"` - - // Descriptive - Genres []string `json:"genres,omitempty"` - Comment string `json:"comment,omitempty"` - BPM *int32 `json:"bpm,omitempty"` - ExplicitStatus string `json:"explicitStatus,omitempty"` - CatalogNum string `json:"catalogNum,omitempty"` - Compilation bool `json:"compilation"` - HasCoverArt bool `json:"hasCoverArt"` - - // MusicBrainz - MbzRecordingID string `json:"mbzRecordingId,omitempty"` - MbzReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` - MbzAlbumID string `json:"mbzAlbumId,omitempty"` - MbzReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` - MbzAlbumType string `json:"mbzAlbumType,omitempty"` - MbzAlbumComment string `json:"mbzAlbumComment,omitempty"` - - // ReplayGain — nil means no data; 0 is a valid measured value, so these - // must stay pointers to distinguish "absent" from "0". - RGAlbumGain *float64 `json:"rgAlbumGain,omitempty"` - RGAlbumPeak *float64 `json:"rgAlbumPeak,omitempty"` - RGTrackGain *float64 `json:"rgTrackGain,omitempty"` - RGTrackPeak *float64 `json:"rgTrackPeak,omitempty"` - - // Timestamps (Unix epoch seconds) - BirthTime int64 `json:"birthTime"` - CreatedAt int64 `json:"createdAt"` - UpdatedAt int64 `json:"updatedAt"` - - // AverageRating is the track's mean rating across all users (always set; 0 when unrated). - AverageRating float64 `json:"averageRating"` - - // Per-user annotations, set only for a user-scoped match. Timestamps are Unix - // seconds; a nil pointer means "no value". - Starred bool `json:"starred,omitempty"` - StarredAt *int64 `json:"starredAt,omitempty"` - Rating int32 `json:"rating,omitempty"` - PlayCount int64 `json:"playCount,omitempty"` - PlayDate *int64 `json:"playDate,omitempty"` - - // Composite - Tags map[string][]string `json:"tags,omitempty"` - // Participants lists the track's artists across all roles, each tagged with its Role. - Participants []ArtistRef `json:"participants,omitempty"` -} diff --git a/plugins/types/types.go b/plugins/types/types.go deleted file mode 100644 index 71928a924..000000000 --- a/plugins/types/types.go +++ /dev/null @@ -1,87 +0,0 @@ -package types - -// ArtistRef is the minimal information a plugin returns for Navidrome to match an -// artist against the library. It is a reference, not a full artist entity: it -// carries only matching keys (name and optional internal/MusicBrainz IDs) plus a -// few projection fields used when describing a track's participants, never -// descriptive data such as biographies or images. -type ArtistRef struct { - // ID is the internal Navidrome artist ID (if known). - ID string `json:"id,omitempty"` - // Name is the artist name. - Name string `json:"name"` - // MBID is the MusicBrainz ID for the artist. - MBID string `json:"mbid,omitempty"` - // SortName is the artist name used for sorting (if known). - SortName string `json:"sortName,omitempty"` - // Role is the participation category (e.g. "artist", "composer", "performer"). - Role string `json:"role,omitempty"` - // SubRole is a specialization within Role (e.g. the instrument for a performer). - SubRole string `json:"subRole,omitempty"` -} - -// SongRef is the minimal information exchanged between a plugin and Navidrome to -// match a song. It is used both as input (a song Navidrome already has) and as -// output (a song a plugin suggests, which may not be in the library yet). Unlike -// Track, it is an abstract recording reference carrying only matching keys (IDs, -// ISRC, and title/artist/album/duration) that Navidrome resolves to a library track. -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"` // TODO: rename to Title to align with Track.Title and model.MediaFile.Title; kept as Name for now for compatibility. - // 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. - // - // Deprecated: use Artists. - Artist string `json:"artist,omitempty"` - // ArtistMBID is the MusicBrainz artist ID. - // - // Deprecated: use Artists. - ArtistMBID string `json:"artistMbid,omitempty"` - // Artists is the full artist list; when set, takes precedence over Artist/ArtistMBID for matching. - Artists []ArtistRef `json:"artists,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. - // - // Deprecated: use DurationMs, which carries millisecond precision. When - // DurationMs is non-zero it takes precedence; Duration is kept only for - // backwards compatibility with plugins that still send seconds. - Duration float32 `json:"duration,omitempty"` - // DurationMs is the song duration in milliseconds. It supersedes Duration - // when non-zero. - DurationMs uint32 `json:"durationMs,omitempty"` -} - -// DurationInMs returns the song duration in milliseconds, preferring the -// millisecond-precision DurationMs and falling back to the deprecated -// seconds-based Duration. It returns 0 when neither is set, and clamps a -// negative seconds value to 0 to avoid an unsigned-conversion wraparound. -func (s SongRef) DurationInMs() uint32 { - if s.DurationMs != 0 { - return s.DurationMs - } - if s.Duration < 0 { - return 0 - } - return uint32(s.Duration * 1000) -} - -// SetDuration sets the song duration from a value in seconds, populating both the -// millisecond-precision DurationMs and the deprecated seconds-based Duration so -// that plugins reading either field see a consistent value. Use this when -// building a SongRef to send to a plugin. -func (s *SongRef) SetDuration(seconds float32) { - s.Duration = seconds - if seconds < 0 { - s.DurationMs = 0 - return - } - s.DurationMs = uint32(seconds * 1000) -} diff --git a/plugins/types/types_suite_test.go b/plugins/types/types_suite_test.go deleted file mode 100644 index 9c058ec4f..000000000 --- a/plugins/types/types_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package types_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestTypes(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Plugins Types Suite") -} diff --git a/plugins/types/types_test.go b/plugins/types/types_test.go deleted file mode 100644 index ae6d40a25..000000000 --- a/plugins/types/types_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package types_test - -import ( - "github.com/navidrome/navidrome/plugins/types" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("SongRef", func() { - Describe("DurationInMs", func() { - It("returns DurationMs when set", func() { - s := types.SongRef{DurationMs: 247333, Duration: 247.5} - Expect(s.DurationInMs()).To(Equal(uint32(247333))) - }) - - It("falls back to Duration (seconds) when DurationMs is zero", func() { - s := types.SongRef{Duration: 247.5} - Expect(s.DurationInMs()).To(Equal(uint32(247500))) - }) - - It("returns 0 when neither is set", func() { - Expect(types.SongRef{}.DurationInMs()).To(BeZero()) - }) - - It("clamps a negative seconds value to 0 instead of overflowing", func() { - Expect(types.SongRef{Duration: -1}.DurationInMs()).To(BeZero()) - }) - }) - - Describe("SetDuration", func() { - It("populates both DurationMs and the deprecated Duration from seconds", func() { - var s types.SongRef - s.SetDuration(247.333) - Expect(s.Duration).To(BeNumerically("~", 247.333, 0.001)) - Expect(s.DurationMs).To(Equal(uint32(247333))) - }) - - It("keeps DurationInMs consistent with what was set", func() { - var s types.SongRef - s.SetDuration(60) - Expect(s.DurationInMs()).To(Equal(uint32(60000))) - }) - - It("clamps a negative duration to a zero DurationMs", func() { - var s types.SongRef - s.SetDuration(-1) - Expect(s.DurationMs).To(BeZero()) - }) - }) -}) diff --git a/reflex.conf b/reflex.conf index 1cbaa7bb7..c25b1662a 100644 --- a/reflex.conf +++ b/reflex.conf @@ -1 +1 @@ --s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -R "_test\.go$$" -R "^\.worktrees" -- go run -race -tags netgo,sqlite_fts5 . +-s -r "(\.go$$|\.cpp$$|\.h$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migration)" -- go run -race -tags netgo . diff --git a/release/build-tags.sh b/release/build-tags.sh deleted file mode 100755 index f719117ff..000000000 --- a/release/build-tags.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/sh -# Print the Go build tags for the xx-cc target platform (used by the Dockerfile). -# -# gen2brain/webp's native libwebp backend links ebitengine/purego, whose reverse -# callbacks are unsupported on 32-bit ARM and x86 and SIGSEGV at package-init time, -# taking the whole process down at startup (issues #5597 / #5606 / #5738). Force the -# WASM-only path there with the "nodynamic" tag; 64-bit arches keep native libwebp. -# -# This is the single source of truth for the tag decision: both Dockerfile build -# stages (Docker-image and standalone downloads) call it so they cannot drift apart. -set -e - -# Prefer xx-info (the cross-build target arch); fall back to `go env GOARCH` so the -# script is still correct when run outside the xx environment. Both report the -# cross-compilation target, unlike `uname -m`, which would report the build host. -arch=$(xx-info arch 2>/dev/null || go env GOARCH) - -tags="netgo,sqlite_fts5" -case "${arch}" in - arm | 386) tags="${tags},nodynamic" ;; -esac -printf '%s' "${tags}" diff --git a/release/goreleaser.yml b/release/goreleaser.yml deleted file mode 100644 index e7610f25e..000000000 --- a/release/goreleaser.yml +++ /dev/null @@ -1,155 +0,0 @@ -# GoReleaser config -project_name: navidrome -version: 2 - -builds: - - id: navidrome - # Instead of compiling the binary with goreleaser, we just copy it from `binaries` folder - # This is because we need to compile the binaries with our Dockerfile, and to avoid having to - # compile it twice, we just copy the docker build output. The xxgo script handles this for us - tool: "./release/xxgo" - - # All available targets compiled by the Dockerfile - targets: - - darwin_amd64 - - darwin_arm64 - - linux_386 - - linux_amd64 - - linux_arm_v5 - - linux_arm_v6 - - linux_arm_v7 - - linux_arm64 - - linux_riscv64 - - windows_386 - - windows_amd64 - -archives: - - format_overrides: - - goos: windows - formats: - - zip - name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' - -checksum: - name_template: "{{ .ProjectName }}_checksums.txt" - -snapshot: - version_template: "{{ .Version }}-SNAPSHOT" - -nfpms: - - id: navidrome - package_name: navidrome - file_name_template: '{{ .PackageName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}{{ . }}{{ end }}{{ with .Mips }}_{{ . }}{{ end }}{{ if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}' - - homepage: https://navidrome.org - description: |- - 🎧☁ Your Personal Streaming Service - - maintainer: Deluan Quintão <deluan at navidrome.org> - - license: GPL-3.0 - formats: - - deb - - rpm - - dependencies: - - ffmpeg - - suggests: - - mpv - - overrides: - rpm: - dependencies: - - "(ffmpeg or ffmpeg-free)" - - contents: - - src: release/linux/navidrome.toml - dst: /etc/navidrome/navidrome.toml - type: "config|noreplace" - file_info: - mode: 0644 - owner: navidrome - group: navidrome - - - dst: /var/lib/navidrome - type: dir - file_info: - owner: navidrome - group: navidrome - - - dst: /opt/navidrome/music - type: dir - file_info: - owner: navidrome - group: navidrome - - - src: release/linux/.package.rpm # contents: "rpm" - dst: /var/lib/navidrome/.package - type: "config|noreplace" - packager: rpm - - src: release/linux/.package.deb # contents: "deb" - dst: /var/lib/navidrome/.package - type: "config|noreplace" - packager: deb - - scripts: - preinstall: "release/linux/preinstall.sh" - postinstall: "release/linux/postinstall.sh" - preremove: "release/linux/preremove.sh" - -release: - draft: true - mode: append - footer: | - **Full Changelog**: https://github.com/navidrome/navidrome/compare/{{ .PreviousTag }}...{{ .Tag }} - - ## Helping out - - This release is only possible thanks to the support of some **awesome people**! - - Want to be one of them? - You can [sponsor](https://github.com/sponsors/deluan), pay me a [Ko-fi](https://ko-fi.com/deluan), or [contribute with code](https://www.navidrome.org/docs/developers/). - - ## Where to go next? - - * Read installation instructions on our [website](https://www.navidrome.org/docs/installation/). - * Host Navidrome on [PikaPods](https://www.pikapods.com/pods?run=navidrome) or [Zenith](https://zenith.hosting/host/navidrome?ref=navidrome) for a simple cloud solution. - * Reach out on [Discord](https://discord.gg/xh7j7yF), [Reddit](https://www.reddit.com/r/navidrome/) and [Twitter](https://twitter.com/navidrome)! - - # Add the MSI installers to the release - extra_files: - - glob: binaries/navidrome_386.msi - name_template: navidrome_{{.Version}}_windows_386_installer.msi - - glob: binaries/navidrome_amd64.msi - name_template: navidrome_{{.Version}}_windows_amd64_installer.msi - -changelog: - sort: asc - use: github - filters: - exclude: - - "^test:" - - "^refactor:" - - Merge pull request - - Merge remote-tracking branch - - Merge branch - - go mod tidy - groups: - - title: "New Features" - regexp: '^.*?feat(\(.+\))??!?:.+$' - order: 100 - - title: "Security updates" - regexp: '^.*?sec(\(.+\))??!?:.+$' - order: 150 - - title: "Bug fixes" - regexp: '^.*?(fix|refactor)(\(.+\))??!?:.+$' - order: 200 - - title: "Documentation updates" - regexp: ^.*?docs?(\(.+\))??!?:.+$ - order: 400 - - title: "Build process updates" - regexp: ^.*?(build|ci)(\(.+\))??!?:.+$ - order: 400 - - title: Other work - order: 9999 diff --git a/release/linux/.package.deb b/release/linux/.package.deb deleted file mode 100644 index 811c85f42..000000000 --- a/release/linux/.package.deb +++ /dev/null @@ -1 +0,0 @@ -deb \ No newline at end of file diff --git a/release/linux/.package.rpm b/release/linux/.package.rpm deleted file mode 100644 index 7c88ef3c0..000000000 --- a/release/linux/.package.rpm +++ /dev/null @@ -1 +0,0 @@ -rpm \ No newline at end of file diff --git a/release/linux/navidrome.toml b/release/linux/navidrome.toml deleted file mode 100644 index e626c8caa..000000000 --- a/release/linux/navidrome.toml +++ /dev/null @@ -1,2 +0,0 @@ -DataFolder = "/var/lib/navidrome" -MusicFolder = "/opt/navidrome/music" diff --git a/release/linux/postinstall.sh b/release/linux/postinstall.sh deleted file mode 100644 index 007114ef5..000000000 --- a/release/linux/postinstall.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/sh - -# It is possible for a user to delete the configuration file in such a way that -# the package manager (in particular, deb) thinks that the file exists, while it is -# no longer on disk. Specifically, doing a `rm /etc/navidrome/navidrome.toml` -# without something like `apt purge navidrome` will result in the system believing that -# the file still exists. In this case, during install it will NOT extract the configuration -# file (as to not override it). Since `navidrome service install` depends on this file existing, -# we will create it with the defaults anyway. -if [ ! -f /etc/navidrome/navidrome.toml ]; then - printf "No navidrome.toml detected, creating in postinstall\n" - printf "DataFolder = \"/var/lib/navidrome\"\n" > /etc/navidrome/navidrome.toml - printf "MusicFolder = \"/opt/navidrome/music\"\n" >> /etc/navidrome/navidrome.toml -fi - -# Older versions created these folders as root when this script ran `navidrome`. They were created empty, -# so no -R. Real dirs only, never following links: navidrome owns /var/lib/navidrome and could plant symlinks. -find /var/lib/navidrome/cache /var/lib/navidrome/artwork /var/lib/navidrome/plugins -maxdepth 0 -type d -user root -exec chown -h navidrome:navidrome {} + 2>/dev/null - -postinstall_flag="/var/lib/navidrome/.installed" - -if [ ! -f "$postinstall_flag" ]; then - # The primary reason why this would fail is if the service was already installed AND - # someone manually removed the .installed flag. In this case, ignore the error - navidrome service install --user navidrome --working-directory /var/lib/navidrome --configfile /etc/navidrome/navidrome.toml || : - touch "$postinstall_flag" -else - navidrome service stop --configfile /etc/navidrome/navidrome.toml && navidrome service start --configfile /etc/navidrome/navidrome.toml -fi - - diff --git a/release/linux/preinstall.sh b/release/linux/preinstall.sh deleted file mode 100755 index aa5850e6e..000000000 --- a/release/linux/preinstall.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh - -if ! getent passwd navidrome > /dev/null 2>&1; then - printf "Creating default Navidrome user\n" - useradd --home-dir /var/lib/navidrome --create-home --system --user-group navidrome -fi diff --git a/release/linux/preremove.sh b/release/linux/preremove.sh deleted file mode 100644 index 0dfcafe60..000000000 --- a/release/linux/preremove.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/bin/sh - -action=$1 - -remove() { - postinstall_flag="/var/lib/navidrome/.installed" - - if [ -f "$postinstall_flag" ]; then - # If this fails, ignore it - navidrome service uninstall || : - rm "$postinstall_flag" - - printf "The following may still be present (especially if you have not done a purge):\n" - printf "1. /etc/navidrome/navidrome.toml (configuration file)\n" - printf "2. /var/lib/navidrome (database/cache)\n" - printf "3. /opt/navidrome (default location for music)\n" - printf "4. The Navidrome user (user name navidrome)\n" - fi -} - -case "$action" in - "1" | "upgrade") - # For an upgrade, do nothing - # Leave the service file untouched - # This is relevant for RPM/DEB-based installs - ;; - *) - remove - ;; -esac diff --git a/release/verify-binary.sh b/release/verify-binary.sh deleted file mode 100755 index cde775992..000000000 --- a/release/verify-binary.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh -# Fail the build if a 32-bit ARM/x86 binary links ebitengine/purego, which would -# SIGSEGV at startup on those arches (issue #5738). -# -# Independent safety net for build-tags.sh: it inspects the actual build metadata -# recorded in the binary (survives stripping) instead of trusting the requested -# tags, so it still fires if the tag decision is wrong or gen2brain/webp changes -# its build-tag semantics. Runs in the Dockerfile, where xx-info and go are present. -# -# Usage: verify-binary.sh <binary> [<binary>...] -set -e - -# Prefer xx-info (the cross-build target arch); fall back to `go env GOARCH` so the -# check is still correct when run outside the xx environment. -arch=$(xx-info arch 2>/dev/null || go env GOARCH) - -case "${arch}" in - arm | 386) ;; - *) exit 0 ;; # 64-bit arches legitimately link purego for native libwebp -esac - -for bin in "$@"; do - # Fail loudly if the expected binary is missing (e.g. an unmatched glob), rather - # than letting `go version -m` fail inside the pipeline and silently pass. - if [ ! -f "${bin}" ]; then - echo "ERROR: expected binary '${bin}' not found; purego verification did not run." - exit 1 - fi - if go version -m "${bin}" | grep -q "ebitengine/purego"; then - echo "ERROR: 32-bit binary '${bin}' links ebitengine/purego; it will SIGSEGV at startup (issue #5738)." - echo " Ensure the 'nodynamic' build tag is applied (see release/build-tags.sh)." - exit 1 - fi -done diff --git a/release/wix/Navidrome_UI_Flow.wxs b/release/wix/Navidrome_UI_Flow.wxs deleted file mode 100644 index 59c2f5184..000000000 --- a/release/wix/Navidrome_UI_Flow.wxs +++ /dev/null @@ -1,41 +0,0 @@ -<Fragment> - <UI Id="Navidrome_UI_Flow"> - <TextStyle Id="WixUI_Font_Normal" FaceName="Tahoma" Size="8" /> - <TextStyle Id="WixUI_Font_Bigger" FaceName="Tahoma" Size="12" /> - <TextStyle Id="WixUI_Font_Title" FaceName="Tahoma" Size="9" Bold="yes" /> - - <Property Id="DefaultUIFont" Value="WixUI_Font_Normal" /> - <Property Id="WixUI_Mode" Value="Minimal" /> - - <DialogRef Id="ErrorDlg" /> - <DialogRef Id="FatalError" /> - <DialogRef Id="FilesInUse" /> - <DialogRef Id="MsiRMFilesInUse" /> - <DialogRef Id="PrepareDlg" /> - <DialogRef Id="ProgressDlg" /> - <DialogRef Id="ResumeDlg" /> - <DialogRef Id="UserExit" /> - <DialogRef Id="WelcomeDlg" /> - - <Publish Dialog="ExitDialog" Control="Finish" Event="EndDialog" Value="Return" Order="999" /> - - <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MyCustomPropertiesDlg" /> - - <Publish Dialog="MaintenanceWelcomeDlg" Control="Next" Event="NewDialog" Value="MaintenanceTypeDlg" /> - - <Publish Dialog="MaintenanceTypeDlg" Control="RepairButton" Event="NewDialog" Value="VerifyReadyDlg" /> - <Publish Dialog="MaintenanceTypeDlg" Control="RemoveButton" Event="NewDialog" Value="VerifyReadyDlg" /> - <Publish Dialog="MaintenanceTypeDlg" Control="Back" Event="NewDialog" Value="MaintenanceWelcomeDlg" /> - - <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Condition="Installed AND PATCH" /> - <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2" Condition="Installed AND PATCH" /> - - <InstallUISequence> - <Show Dialog="WelcomeDlg" Before="ProgressDlg" Condition="Installed AND PATCH" /> - </InstallUISequence> - - <Property Id="ARPNOMODIFY" Value="1" /> - </UI> - - <UIRef Id="WixUI_Common" /> -</Fragment> diff --git a/release/wix/SettingsDlg.wxs b/release/wix/SettingsDlg.wxs deleted file mode 100644 index cdc625434..000000000 --- a/release/wix/SettingsDlg.wxs +++ /dev/null @@ -1,44 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<Fragment> - <UI> - <Dialog Id="MyCustomPropertiesDlg" Width="370" Height="270" Title="[ProductName] Setup" NoMinimize="yes"> - <!--Header--> - <Control Id="BannerBitmap" Type="Bitmap" X="0" Y="0" Width="370" Height="44" TabSkip="no" Text="WixUI_Bmp_Banner" /> - <Control Id="Title" Type="Text" X="15" Y="6" Width="200" Height="15" Transparent="yes" NoPrefix="yes"> - <Text>{\WixUI_Font_Title}Configuration</Text> - </Control> - <Control Id="Description" Type="Text" X="25" Y="23" Width="280" Height="15" Transparent="yes" NoPrefix="yes"> - <Text>Please enter configuration settings</Text> - </Control> - <Control Id="BannerLine" Type="Line" X="0" Y="44" Width="370" Height="0" /> - - <!--Properties--> - - <!-- Install directory --> - <Control Id="NameLabel1" Type="Text" X="30" Y="60" Width="220" Height="15" TabSkip="no" Text="&Installation Folder (executable and configuration file):" /> - <Control Id="NameEdit1" Type="Edit" X="30" Y="75" Width="220" Height="18" Property="MSI_INSTALLATIONDIRECTORY" Text="[MSI_INSTALLATIONDIRECTORY]" /> - - <!-- NAVIDROME_PORT --> - <Control Id="NameLabel2" Type="Text" X="30" Y="93" Width="100" Height="15" TabSkip="no" Text="&Port:" /> - <Control Id="NameEdit2" Type="Edit" X="30" Y="108" Width="220" Height="18" Property="ND_PORT" Text="[ND_PORT]" /> - - <!-- NAVIDROME_MUSICDIR --> - <Control Id="NameLabel3" Type="Text" X="30" Y="126" Width="100" Height="15" TabSkip="no" Text="&Music Folder:" /> - <Control Id="NameEdit3" Type="Edit" X="30" Y="141" Width="220" Height="18" Property="ND_MUSICFOLDER" Text="[ND_MUSICFOLDER]" /> - - <!-- NAVIDROME_DATADIR --> - <Control Id="NameLabel4" Type="Text" X="30" Y="159" Width="100" Height="15" TabSkip="no" Text="&Data Folder:" /> - <Control Id="NameEdit5" Type="Edit" X="30" Y="174" Width="220" Height="18" Property="ND_DATAFOLDER" Text="[ND_DATAFOLDER]" /> - - <!--Footer--> - <Control Id="BottomLine" Type="Line" X="0" Y="234" Width="370" Height="0" /> - <Control Id="Next" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="&Next"> - <Publish Event="SpawnWaitDialog" Value="WaitForCostingDlg">CostingComplete = 1</Publish> - <Publish Event="NewDialog" Value="VerifyReadyDlg"></Publish> - </Control> - <Control Id="Cancel" Type="PushButton" X="304" Y="243" Width="56" Height="17" Cancel="yes" Text="Cancel"> - <Publish Event="SpawnDialog" Value="CancelDlg">1</Publish> - </Control> - </Dialog> - </UI> - </Fragment> diff --git a/release/wix/bmp/banner.bmp b/release/wix/bmp/banner.bmp deleted file mode 100644 index bbaa0fc2b..000000000 Binary files a/release/wix/bmp/banner.bmp and /dev/null differ diff --git a/release/wix/bmp/dialogue.bmp b/release/wix/bmp/dialogue.bmp deleted file mode 100644 index 56f3db55b..000000000 Binary files a/release/wix/bmp/dialogue.bmp and /dev/null differ diff --git a/release/wix/build_msi.sh b/release/wix/build_msi.sh deleted file mode 100755 index a8781a965..000000000 --- a/release/wix/build_msi.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/sh - -FFMPEG_VERSION="7.1" -FFMPEG_REPOSITORY=navidrome/ffmpeg-windows-builds -DOWNLOAD_FOLDER=/tmp - -#Exit if GIT_TAG is not set -if [ -z "$GIT_TAG" ]; then - echo "GIT_TAG is not set, exiting..." - exit 1 -fi - -set -e - -WORKSPACE=$1 -ARCH=$2 -NAVIDROME_BUILD_VERSION=$(echo "$GIT_TAG" | sed -e 's/^v//' -e 's/-SNAPSHOT/.1/') - -echo "Building MSI package for $ARCH, version $NAVIDROME_BUILD_VERSION" - -MSI_OUTPUT_DIR=$WORKSPACE/binaries/msi -mkdir -p "$MSI_OUTPUT_DIR" -BINARY_DIR=$WORKSPACE/binaries/windows_${ARCH} - -if [ "$ARCH" = "386" ]; then - PLATFORM="x86" - WIN_ARCH="win32" -else - PLATFORM="x64" - WIN_ARCH="win64" -fi - -BINARY=$BINARY_DIR/navidrome.exe -if [ ! -f "$BINARY" ]; then - echo - echo "$BINARY not found!" - echo "Build it with 'make single GOOS=windows GOARCH=${ARCH}'" - exit 1 -fi - -# Download static compiled ffmpeg for Windows -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" "*/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" - -# package type indicator file -echo "msi" > "$MSI_OUTPUT_DIR/.package" - -# workaround for wixl WixVariable not working to override bmp locations -cp "$WORKSPACE"/release/wix/bmp/banner.bmp /usr/share/wixl-*/ext/ui/bitmaps/bannrbmp.bmp -cp "$WORKSPACE"/release/wix/bmp/dialogue.bmp /usr/share/wixl-*/ext/ui/bitmaps/dlgbmp.bmp - -cd "$MSI_OUTPUT_DIR" -rm -f "$MSI_OUTPUT_DIR"/navidrome_"${ARCH}".msi -wixl "$WORKSPACE"/release/wix/navidrome.wxs -D Version="$NAVIDROME_BUILD_VERSION" -D Platform=$PLATFORM --arch $PLATFORM \ - --ext ui --output "$MSI_OUTPUT_DIR"/navidrome_"${ARCH}".msi - diff --git a/release/wix/msitools.dockerfile b/release/wix/msitools.dockerfile deleted file mode 100644 index 90249c1ce..000000000 --- a/release/wix/msitools.dockerfile +++ /dev/null @@ -1,3 +0,0 @@ -FROM alpine -RUN apk update && apk add jq msitools -WORKDIR /workspace \ No newline at end of file diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs deleted file mode 100644 index 71a6de6b5..000000000 --- a/release/wix/navidrome.wxs +++ /dev/null @@ -1,114 +0,0 @@ -<?xml version='1.0' encoding='windows-1252'?> -<?if $(var.Platform) = x64 ?> - <?define ProductName = "Navidrome" ?> - <?define UpgradeCode = "2f154974-1443-41b6-b808-b8be530291b3" ?> - <?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?> - <?define Win64 = 'yes' ?> -<?else ?> - <?define ProductName = "Navidrome (x86)" ?> - <?define UpgradeCode = "2f0572e4-7e8c-42e7-a186-77f70ec0911a" ?> - <?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?> - <?define Win64 = "no" ?> -<?endif ?> -<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi'> - <?include SettingsDlg.wxs?> - <?include Navidrome_UI_Flow.wxs?> - <Product Name="$(var.ProductName)" Id="*" UpgradeCode="$(var.UpgradeCode)" Language='1033' Codepage='1252' Version='$(var.Version)' Manufacturer='Deluan'> - <UIRef Id="Navidrome_UI_Flow"/> - - <Package Id='*' Keywords='Installer' Description="$(var.ProductName)" Comments='' Manufacturer='Deluan' InstallerVersion='200' Languages='1033' Compressed='yes' SummaryCodepage='1252' InstallScope='perMachine' /> - - <MajorUpgrade AllowDowngrades="no" DowngradeErrorMessage="A newer version of $(var.ProductName) is already installed." /> - - <Media Id='1' Cabinet='main.cab' EmbedCab='yes' DiskPrompt="CD-ROM #1" /> - <Property Id='DiskPrompt' Value="Navidrome Install [1]" /> - <Property Id="REBOOT" Value="ReallySuppress" /> - - <Property Id="ND_PORT" Value="4533"> - <RegistrySearch Id="ND_PORT" Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='ND_PORT' Type='raw' /> - </Property> - <Property Id="ND_MUSICFOLDER" Value="C:\Music"> - <RegistrySearch Id="ND_MUSICFOLDER" Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='ND_MUSICFOLDER' Type='raw' /> - </Property> - <Property Id="ND_DATAFOLDER" Value="C:\ProgramData\Navidrome"> - <RegistrySearch Id="ND_DATAFOLDER" Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='ND_DATAFOLDER' Type='raw' /> - </Property> - <Property Id="MSI_INSTALLATIONDIRECTORY"> - <RegistrySearch Id="MSI_INSTALLATIONDIRECTORY" Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='MSI_INSTALLATIONDIRECTORY' Type='raw' /> - </Property> - - <CustomAction Id="SetInstallDirProperty" Property="MSI_INSTALLATIONDIRECTORY" Value="[$(var.PlatformProgramFilesFolder)]Navidrome\" /> - - <Directory Id='TARGETDIR' Name='SourceDir'> - <Directory Id="MSI_INSTALLATIONDIRECTORY"> - <Component Id='LICENSEFile' Guid='eb5610a4-e3f3-4f36-ae2c-e96914e460c2' Win64="$(var.Win64)"> - <File Id='LICENSE' Name='LICENSE' DiskId='1' Source='LICENSE' KeyPath='yes' /> - </Component> - - <Component Id='README.mdFile' Guid='d1ee412b-2ebc-4b0b-9fa7-0228ab707686' Win64="$(var.Win64)"> - <File Id='README.md' Name='README.md' DiskId='1' Source='README.md' KeyPath='yes' /> - </Component> - - <Component Id="Configuration" Guid="9e17ed4b-ef13-44bf-a605-ed4132cff7f6" Win64="$(var.Win64)"> - <IniFile Id="ConfigurationPort" Name="navidrome.ini" Action="createLine" Directory="MSI_INSTALLATIONDIRECTORY" Key="Port" Section="default" Value="'[ND_PORT]'" /> - <IniFile Id="ConfigurationMusicDir" Name="navidrome.ini" Action="createLine" Directory="MSI_INSTALLATIONDIRECTORY" Key="MusicFolder" Section="default" Value="'[ND_MUSICFOLDER]'" /> - <IniFile Id="ConfigurationDataDir" Name="navidrome.ini" Action="createLine" Directory="MSI_INSTALLATIONDIRECTORY" Key="DataFolder" Section="default" Value="'[ND_DATAFOLDER]'" /> - <IniFile Id="FFmpegPath" Name="navidrome.ini" Action="createLine" Directory="MSI_INSTALLATIONDIRECTORY" Key="FFmpegPath" Section="default" Value="'[MSI_INSTALLATIONDIRECTORY]ffmpeg.exe'" /> - - <RegistryValue Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='MSI_INSTALLATIONDIRECTORY' Value='[MSI_INSTALLATIONDIRECTORY]' Type='string' /> - <RegistryValue Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='ND_PORT' Value='[ND_PORT]' Type='string' /> - <RegistryValue Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='ND_MUSICFOLDER' Value='[ND_MUSICFOLDER]' Type='string' /> - <RegistryValue Root='HKLM' Key='SOFTWARE\Deluan\$(var.ProductName)\Installer' Name='ND_DATAFOLDER' Value='[ND_DATAFOLDER]' Type='string' /> - </Component> - - <Component Id='MainExecutable' Guid='e645aa06-8bbc-40d6-8d3c-73b4f5b76fd7' Win64="$(var.Win64)"> - <File Id='NavidromeExe' Name='Navidrome.exe' DiskId='1' Source='navidrome.exe' KeyPath='yes' /> - <ServiceInstall - Description='Navidrome is a self-hosted music server and streamer' - ErrorControl='ignore' - Name = '$(var.ProductName)' - Id='NavidromeService' - Start='auto' - Type='ownProcess' - Vital='yes' - Arguments='service execute --configfile "[MSI_INSTALLATIONDIRECTORY]navidrome.ini" --logfile "[ND_DATAFOLDER]\navidrome.log"' - /> - <ServiceControl Id='StartNavidromeService' Start='install' Stop='both' Remove='uninstall' Name='$(var.ProductName)' Wait='yes' /> - </Component> - - <Component Id='FFMpegExecutable' Guid='d17358f7-abdc-4080-acd3-6427903a7dd8' Win64="$(var.Win64)"> - <File Id='ffmpeg.exe' Name='ffmpeg.exe' DiskId='1' Source='ffmpeg.exe' KeyPath='yes' /> - </Component> - - <Component Id='FFProbeExecutable' Guid='f8a3b2c1-5d4e-4f6a-9b8c-7e2d1a0f3c5b' Win64="$(var.Win64)"> - <File Id='ffprobe.exe' Name='ffprobe.exe' DiskId='1' Source='ffprobe.exe' KeyPath='yes' /> - </Component> - </Directory> - - <Directory Id="ND_DATAFOLDER" name="[ND_DATAFOLDER]"> - <Component Id='PackageFile' Guid='9eec0697-803c-4629-858f-20dc376c960b' Win64="$(var.Win64)"> - <File Id='package' Name='.package' DiskId='1' Source='.package' KeyPath='no' /> - </Component> - </Directory> - </Directory> - - <InstallExecuteSequence> - <Custom Action="SetInstallDirProperty" Before="CostFinalize">NOT MSI_INSTALLATIONDIRECTORY</Custom> - </InstallExecuteSequence> - - <InstallUISequence> - <Custom Action="SetInstallDirProperty" Before="CostFinalize">NOT MSI_INSTALLATIONDIRECTORY</Custom> - <Show Dialog="MyCustomPropertiesDlg" After="WelcomeDlg">Not Installed AND NOT WIX_UPGRADE_DETECTED</Show> - </InstallUISequence> - - <Feature Id='Complete' Level='1'> - <ComponentRef Id='LICENSEFile' /> - <ComponentRef Id='README.mdFile' /> - <ComponentRef Id='Configuration'/> - <ComponentRef Id='MainExecutable' /> - <ComponentRef Id='FFMpegExecutable' /> - <ComponentRef Id='FFProbeExecutable' /> - <ComponentRef Id='PackageFile' /> - </Feature> - </Product> -</Wix> diff --git a/release/xxgo b/release/xxgo deleted file mode 100755 index 3cdacd833..000000000 --- a/release/xxgo +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -# Use sed to extract the value of the -o parameter -output=$(echo "$@" | sed -n 's/.*-o \([^ ]*\).*/\1/p') - -# Ensure the directory part of the output exists -mkdir -p "$(dirname "$output")" - -# Build the source folder name based on GOOS, GOARCH and GOARM. -source="${GOOS}_${GOARCH}" -if [ "$GOARCH" = "arm" ]; then - source="${source}_${GOARM}" -fi - -# Copy the output to the desired location -chmod +x binaries/"${source}"/navidrome* -cp binaries/"${source}"/navidrome* "$output" \ No newline at end of file diff --git a/resources/album-placeholder.webp b/resources/album-placeholder.webp deleted file mode 100644 index ced0ade23..000000000 Binary files a/resources/album-placeholder.webp and /dev/null differ diff --git a/resources/banner.go b/resources/banner.go index 0f7f1a549..52f3fcd18 100644 --- a/resources/banner.go +++ b/resources/banner.go @@ -2,7 +2,6 @@ package resources import ( "fmt" - "io" "strings" "unicode" @@ -10,11 +9,7 @@ import ( ) func loadBanner() string { - f, err := embedFS.Open("banner.txt") - if err != nil { - return "" - } - data, _ := io.ReadAll(f) + data, _ := Asset("banner.txt") return strings.TrimRightFunc(string(data), unicode.IsSpace) } diff --git a/resources/embed.go b/resources/embed.go index 040bb5d84..860e3130f 100644 --- a/resources/embed.go +++ b/resources/embed.go @@ -2,20 +2,31 @@ package resources import ( "embed" + "io" "io/fs" "os" "path" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/utils/merge" + "github.com/navidrome/navidrome/utils" ) -//go:embed * -var embedFS embed.FS +var ( + //go:embed * + fsys embed.FS +) func FS() fs.FS { - return merge.FS{ - Base: embedFS, - Overlay: os.DirFS(path.Join(conf.Server.DataFolder.String(), "resources")), + return utils.MergeFS{ + Base: fsys, + Overlay: os.DirFS(path.Join(conf.Server.DataFolder, "resources")), } } + +func Asset(path string) ([]byte, error) { + f, err := FS().Open(path) + if err != nil { + return nil, err + } + return io.ReadAll(f) +} diff --git a/resources/i18n/ar.json b/resources/i18n/ar.json index d7902c14b..681924a29 100644 --- a/resources/i18n/ar.json +++ b/resources/i18n/ar.json @@ -54,11 +54,7 @@ "comment": "التعليق", "rating": "التقييم", "createdAt": "تاريخ الإضافة", - "size": "الحجم", - "originalDate": "", - "releaseDate": "", - "releases": "", - "released": "" + "size": "الحجم" }, "actions": { "playAll": "شغّل", diff --git a/resources/i18n/bg.json b/resources/i18n/bg.json index 7a0281f33..bf836969a 100644 --- a/resources/i18n/bg.json +++ b/resources/i18n/bg.json @@ -1,715 +1,456 @@ { - "languageName": "Български", - "resources": { - "song": { - "name": "Песен |||| Песни", - "fields": { - "albumArtist": "Изпълнител албум", - "duration": "Време", - "trackNumber": "#", - "playCount": "Пускания", - "title": "Заглавие", - "artist": "Изпълнител", - "album": "Албум", - "path": "Път до файл", - "genre": "Жанр", - "compilation": "Компилация", - "year": "Година", - "size": "Размер на файла", - "updatedAt": "Актуализирана", - "bitRate": "Битрейт", - "discSubtitle": "Субтитри на диска", - "starred": "Любима", - "comment": "Коментар", - "rating": "Рейтинг", - "quality": "Качество", - "bpm": "BPM", - "playDate": "Последно слушана", - "channels": "Канала", - "createdAt": "Добавено на", - "grouping": "Групиране", - "mood": "Настроение", - "participants": "Допълнителни участници", - "tags": "Допълнителни етикети", - "mappedTags": "Картирани тагове", - "rawTags": "Сурови тагове", - "bitDepth": "Битова дълбочина", - "sampleRate": "Честота на семплиране", - "missing": "Липсва", - "libraryName": "Библиотека", - "composer": "Композитор", - "disc": "" - }, - "actions": { - "addToQueue": "Пусни по-късно", - "playNow": "Пусни сега", - "addToPlaylist": "Добави към плейлист", - "shuffleAll": "Разбъркай всички", - "download": "Свали", - "playNext": "Следваща", - "info": "Информация", - "showInPlaylist": "Показване в плейлиста", - "instantMix": "Незабавен микс" - } + "languageName": "Български", + "resources": { + "song": { + "name": "Песен |||| Песни", + "fields": { + "albumArtist": "Изпълнител албум", + "duration": "Време", + "trackNumber": "#", + "playCount": "Пускания", + "title": "Заглавие", + "artist": "Изпълнител", + "album": "Албум", + "path": "Път до файл", + "genre": "Жанр", + "compilation": "Компилация", + "year": "Година", + "size": "Размер на файла", + "updatedAt": "Актуализирана", + "bitRate": "Битрейт", + "discSubtitle": "Субтитри на диска", + "starred": "Любима", + "comment": "Коментар", + "rating": "Рейтинг", + "quality": "Качество", + "bpm": "BPM", + "playDate": "Последно слушана", + "channels": "Канала", + "createdAt": "Добавено на" + }, + "actions": { + "addToQueue": "Пусни по-късно", + "playNow": "Пусни сега", + "addToPlaylist": "Добави към плейлист", + "shuffleAll": "Разбъркай всички", + "download": "Свали", + "playNext": "Следваща", + "info": "Информация" + } + }, + "album": { + "name": "Албум |||| Албуми", + "fields": { + "albumArtist": "Изпълнител албум", + "artist": "Изпълнител", + "duration": "Време", + "songCount": "Песни", + "playCount": "Пускания", + "name": "Име", + "genre": "Жанр", + "compilation": "Компилация", + "year": "Година", + "updatedAt": "Актуализиран", + "comment": "Коментар", + "rating": "Рейтинг", + "createdAt": "Добавено на", + "size": "Размер" + }, + "actions": { + "playAll": "Пусни", + "playNext": "Пусни следваща", + "addToQueue": "Пусни по-късно", + "shuffle": "Разбъркай", + "addToPlaylist": "Добави към плейлист", + "download": "Свали", + "info": "Информация", + "share": "Сподели" + }, + "lists": { + "all": "Всички", + "random": "Случайни", + "recentlyAdded": "Последно добавени", + "recentlyPlayed": "Последно слушани", + "mostPlayed": "Най-слушани", + "starred": "Любими", + "topRated": "Най-висок рейтинг" + } + }, + "artist": { + "name": "Изпълнител |||| Изпълнители", + "fields": { + "name": "Име", + "albumCount": "Брой албуми", + "songCount": "Брой песни", + "playCount": "Пускания", + "rating": "Рейтинг", + "genre": "Жанр", + "size": "Размер" + } + }, + "user": { + "name": "Потребител |||| Потребители", + "fields": { + "userName": "Потребителско име", + "isAdmin": "Администратор", + "lastLoginAt": "Последен вход", + "updatedAt": "Актуализиран", + "name": "Име", + "password": "Парола", + "createdAt": "Създаден на", + "changePassword": "Промяна на паролата?", + "currentPassword": "Текуща парола", + "newPassword": "Нова парола", + "token": "Токен" + }, + "helperTexts": { + "name": "Промените в името ще бъдат отразени при следващото влизане" + }, + "notifications": { + "created": "Потребителят е създаден", + "updated": "Потребителят е актуализиран", + "deleted": "Потребителят е изтрит" + }, + "message": { + "listenBrainzToken": "Въведете Вашия токен за ListenBrainz.", + "clickHereForToken": "Кликнете тук, за да получите Вашия токен" + } + }, + "player": { + "name": "Плейър |||| Плейъри", + "fields": { + "name": "Име", + "transcodingId": "Транскодиране", + "maxBitRate": "Макс. битрейт", + "client": "Клиент", + "userName": "Потребителско име", + "lastSeen": "Последно видян", + "reportRealPath": "Докладвай реален път", + "scrobbleEnabled": "Изпрати Scrobbles към външни услуги" + } + }, + "transcoding": { + "name": "Транскодиране |||| Транскодинг", + "fields": { + "name": "Име", + "targetFormat": "Целеви формат", + "defaultBitRate": "Битрейт по подразбиране", + "command": "Команда" + } + }, + "playlist": { + "name": "Плейлист |||| Плейлисти", + "fields": { + "name": "Име", + "duration": "Продължителност", + "ownerName": "Собственик", + "public": "Публичен", + "updatedAt": "Актуализиран", + "createdAt": "Създаден на", + "songCount": "Песни", + "comment": "Коментар", + "sync": "Автоматично импортиране", + "path": "Импортиране от" + }, + "actions": { + "selectPlaylist": "Изберете плейлист:", + "addNewPlaylist": "Създай \"%{name}\"", + "export": "Експорт", + "makePublic": "Направи публичен", + "makePrivate": "Направи личен" + }, + "message": { + "duplicate_song": "Добави дублирани песни", + "song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?" + } + }, + "radio": { + "name": "Радиостанция |||| Радиостанции", + "fields": { + "name": "Име", + "streamUrl": "Стрийм адрес", + "homePageUrl": "Начална страница адрес", + "updatedAt": "Актуализиранa на", + "createdAt": "Създаденa на" + }, + "actions": { + "playNow": "Възпроизвеждане сега" + } + }, + "share": { + "name": "Сподели |||| Споделени", + "fields": { + "username": "Споделено от", + "url": "Адрес", + "description": "Описание", + "contents": "Съдържание", + "expiresAt": "Изтича", + "lastVisitedAt": "Последно посетен", + "visitCount": "Посещения", + "format": "Формат", + "maxBitRate": "Макс. Bit Rate", + "updatedAt": "Актуализирана на", + "createdAt": "Създадена на", + "downloadable": "" + } + } }, - "album": { - "name": "Албум |||| Албуми", - "fields": { - "albumArtist": "Изпълнител албум", - "artist": "Изпълнител", - "duration": "Време", - "songCount": "Песни", - "playCount": "Пускания", - "name": "Име", - "genre": "Жанр", - "compilation": "Компилация", - "year": "Година", - "updatedAt": "Актуализиран", - "comment": "Коментар", - "rating": "Рейтинг", - "createdAt": "Добавено на", - "size": "Размер", - "originalDate": "Оригинал", - "releaseDate": "Издаден", - "releases": "Издание |||| Издания", - "released": "Издаден", - "recordLabel": "Лейбъл", - "catalogNum": "Каталожен номер", - "releaseType": "Тип", - "grouping": "Групиране", - "media": "Медия", - "mood": "Настроение", - "date": "Дата на запис", - "missing": "Липсва", - "libraryName": "Библиотека" - }, - "actions": { - "playAll": "Пусни", - "playNext": "Пусни следваща", - "addToQueue": "Пусни по-късно", - "shuffle": "Разбъркай", - "addToPlaylist": "Добави към плейлист", - "download": "Свали", - "info": "Информация", - "share": "Сподели" - }, - "lists": { - "all": "Всички", - "random": "Случайни", - "recentlyAdded": "Последно добавени", - "recentlyPlayed": "Последно слушани", - "mostPlayed": "Най-слушани", - "starred": "Любими", - "topRated": "Най-висок рейтинг" - } - }, - "artist": { - "name": "Изпълнител |||| Изпълнители", - "fields": { - "name": "Име", - "albumCount": "Брой албуми", - "songCount": "Брой песни", - "playCount": "Пускания", - "rating": "Рейтинг", - "genre": "Жанр", - "size": "Размер", - "role": "Роля", - "missing": "Липсва" - }, - "roles": { - "albumartist": "Изпълнител на албума |||| Изпълнители на албума", - "artist": "Изпълнител |||| Изпълнители", - "composer": "Композитор |||| Композитори", - "conductor": "Диригент |||| Диригенти", - "lyricist": "Текстописец |||| Текстописци", - "arranger": "Аранжор |||| Аранжори", - "producer": "Продуцент |||| Продуценти", - "director": "Директор |||| Директори", - "engineer": "Инженер |||| Инженери", - "mixer": "Миксер |||| Миксери", - "remixer": "Ремиксер |||| Ремиксери", - "djmixer": "DJ миксер |||| DJ миксери", - "performer": "Изпълнител |||| Изпълнители", - "maincredit": "Изпълнител на албума или изпълнител |||| Изпълнители на албума или изпълнители" - }, - "actions": { - "shuffle": "Разбъркване", - "radio": "Радио", - "topSongs": "Топ песни" - } - }, - "user": { - "name": "Потребител |||| Потребители", - "fields": { - "userName": "Потребителско име", - "isAdmin": "Администратор", - "lastLoginAt": "Последен вход", - "updatedAt": "Актуализиран", - "name": "Име", - "password": "Парола", - "createdAt": "Създаден на", - "changePassword": "Промяна на паролата?", - "currentPassword": "Текуща парола", - "newPassword": "Нова парола", - "token": "Токен", - "lastAccessAt": "Последен достъп", - "libraries": "Библиотеки" - }, - "helperTexts": { - "name": "Промените в името ще бъдат отразени при следващото влизане", - "libraries": "Изберете конкретни библиотеки за този потребител или оставете празно, за да използвате библиотеки по подразбиране" - }, - "notifications": { - "created": "Потребителят е създаден", - "updated": "Потребителят е актуализиран", - "deleted": "Потребителят е изтрит" - }, - "message": { - "listenBrainzToken": "Въведете Вашия токен за ListenBrainz.", - "clickHereForToken": "Кликнете тук, за да получите Вашия токен", - "selectAllLibraries": "Изберете всички библиотеки", - "adminAutoLibraries": "Администраторите автоматично получават достъп до всички библиотеки" - }, - "validation": { - "librariesRequired": "Трябва да бъде избрана поне една библиотека за потребители без администраторски права" - } - }, - "player": { - "name": "Плейър |||| Плейъри", - "fields": { - "name": "Име", - "transcodingId": "Транскодиране", - "maxBitRate": "Макс. битрейт", - "client": "Клиент", - "userName": "Потребителско име", - "lastSeen": "Последно видян", - "reportRealPath": "Докладвай реален път", - "scrobbleEnabled": "Изпрати Scrobbles към външни услуги" - } - }, - "transcoding": { - "name": "Транскодиране |||| Транскодинг", - "fields": { - "name": "Име", - "targetFormat": "Целеви формат", - "defaultBitRate": "Битрейт по подразбиране", - "command": "Команда" - } - }, - "playlist": { - "name": "Плейлист |||| Плейлисти", - "fields": { - "name": "Име", - "duration": "Продължителност", - "ownerName": "Собственик", - "public": "Публичен", - "updatedAt": "Актуализиран", - "createdAt": "Създаден на", - "songCount": "Песни", - "comment": "Коментар", - "sync": "Автоматично импортиране", - "path": "Импортиране от" - }, - "actions": { - "selectPlaylist": "Изберете плейлист:", - "addNewPlaylist": "Създай \"%{name}\"", - "export": "Експорт", - "makePublic": "Направи публичен", - "makePrivate": "Направи личен", - "saveQueue": "Запазване на опашката в плейлист", - "searchOrCreate": "Търсете в плейлисти или пишете, за да създадете нови...", - "pressEnterToCreate": "Натиснете Enter, за да създадете нов плейлист", - "removeFromSelection": "Премахване от селекцията" - }, - "message": { - "duplicate_song": "Добави дублирани песни", - "song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?", - "noPlaylistsFound": "Няма намерени плейлисти", - "noPlaylists": "Няма налични плейлисти" - } - }, - "radio": { - "name": "Радиостанция |||| Радиостанции", - "fields": { - "name": "Име", - "streamUrl": "Стрийм адрес", - "homePageUrl": "Начална страница адрес", - "updatedAt": "Актуализиранa на", - "createdAt": "Създаденa на" - }, - "actions": { - "playNow": "Възпроизвеждане сега" - } - }, - "share": { - "name": "Сподели |||| Споделени", - "fields": { - "username": "Споделено от", - "url": "Адрес", - "description": "Описание", - "contents": "Съдържание", - "expiresAt": "Изтича", - "lastVisitedAt": "Последно посетен", - "visitCount": "Посещения", - "format": "Формат", - "maxBitRate": "Макс. Bit Rate", - "updatedAt": "Актуализирана на", - "createdAt": "Създадена на", - "downloadable": "Разреши изтегляния?" - } - }, - "missing": { - "name": "Липсващ файл |||| Липсващи файлове", - "fields": { - "path": "Път", - "size": "Размер", - "updatedAt": "Изчезнал на", - "libraryName": "Библиотека" - }, - "actions": { - "remove": "Премахни", - "remove_all": "Премахни всички" - }, - "notifications": { - "removed": "Липсващите файлове са премахнати" - }, - "empty": "Няма липсващи файлове" - }, - "library": { - "name": "Библиотека |||| Библиотеки", - "fields": { - "name": "Име", - "path": "Път", - "remotePath": "Отдалечен път", - "lastScanAt": "Последно сканиране", - "songCount": "Песни", - "albumCount": "Албуми", - "artistCount": "Изпълнители", - "totalSongs": "Песни", - "totalAlbums": "Албуми", - "totalArtists": "Изпълнители", - "totalFolders": "Папки", - "totalFiles": "Файлове", - "totalMissingFiles": "Липсващи файлове", - "totalSize": "Общ размер", - "totalDuration": "Продължителност", - "defaultNewUsers": "По подразбиране за нови потребители", - "createdAt": "Създаден", - "updatedAt": "Актуализиран" - }, - "sections": { - "basic": "Основна информация", - "statistics": "Статистика" - }, - "actions": { - "scan": "Сканирай библиотеката", - "manageUsers": "Управление на потребителския достъп", - "viewDetails": "Преглед на подробности", - "quickScan": "Quick Scan", - "fullScan": "Пълно сканиране" - }, - "notifications": { - "created": "Библиотеката е създадена успешно", - "updated": "Библиотеката е актуализирана успешно", - "deleted": "Библиотеката е изтрита успешно", - "scanStarted": "Сканирането на библиотеката започна", - "scanCompleted": "Сканирането на библиотеката е завършено", - "quickScanStarted": "Бързото сканиране започна", - "fullScanStarted": "Пълното сканиране започна", - "scanError": "Грешка при стартиране на сканирането. Проверете лог файловете" - }, - "validation": { - "nameRequired": "Името на библиотеката е задължително", - "pathRequired": "Пътят към библиотеката е задължителен", - "pathNotDirectory": "Пътят до библиотеката трябва да е директория", - "pathNotFound": "Пътят към библиотеката не е намерен", - "pathNotAccessible": "Пътят до библиотеката не е достъпен", - "pathInvalid": "Невалиден път към библиотеката" - }, - "messages": { - "deleteConfirm": "Сигурни ли сте, че желаете да изтриете тази библиотека? Това ще премахне всички свързани данни и потребителски достъп.", - "scanInProgress": "Сканирането е в ход...", - "noLibrariesAssigned": "Няма библиотеки, присвоени на този потребител" - } - }, - "plugin": { - "name": "Плъгин |||| Плъгини", - "fields": { - "id": "ID номер", - "name": "Име", - "description": "Описание", - "version": "Версия", - "author": "Автор", - "website": "Уебсайт", - "permissions": "Разрешения", - "enabled": "Активирано", - "status": "Статус", - "path": "Път", - "lastError": "Грешка", - "hasError": "Грешка", - "updatedAt": "Актуализирано", - "createdAt": "Инсталирано", - "configKey": "Ключ", - "configValue": "Стойност", - "allUsers": "Разрешаване на всички потребители", - "selectedUsers": "Избрани потребители", - "allLibraries": "Разрешаване на всички библиотеки", - "selectedLibraries": "Избрани библиотеки", - "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": { - "auth": { - "welcome1": "Благодаря, че инсталирахте Navidrome!", - "welcome2": "За да започнете, създайте администраторски профил", - "confirmPassword": "Потвърдете паролата", - "buttonCreateAdmin": "Създaй администратор", - "auth_check_error": "Моля, влезте за да продължите", - "user_menu": "Профил", - "username": "Потребителско име", - "password": "Парола", - "sign_in": "Вход", - "sign_in_error": "Грешка при удостоверяването. Моля, опитайте отново", - "logout": "Изход", - "insightsCollectionNote": "Navidrome събира анонимни данни, за да помогне\nподобряването на проекта. Кликнете [тук], за да\nнаучите повече и да се откажете, ако желаете" - }, - "validation": { - "invalidChars": "Моля, използвайте само букви и цифри", - "passwordDoesNotMatch": "Паролата не съвпада", - "required": "Задължително", - "minLength": "Трябва да съдържа поне %{min} знака", - "maxLength": "Трябва да съдържа %{max} знака или по-малко", - "minValue": "Трябва да е поне %{min}", - "maxValue": "Трябва да бъде %{max} или по-малко", - "number": "Трябва да е число", - "email": "Трябва да е валиден имейл", - "oneOf": "Трябва да е едно от: %{options}", - "regex": "Трябва да съответства на конкретен формат (regexp): %{pattern}", - "unique": "Трябва да е уникално", - "url": "Трябва да бъде валиден адрес" - }, - "action": { - "add_filter": "Добави филтър", - "add": "Добави", - "back": "Назад", - "bulk_actions": "Избран е 1 елемент |||| Избрани са %{smart_count} елемента", - "cancel": "Отмени", - "clear_input_value": "Изчисти въведеното", - "clone": "Клонирай", - "confirm": "Потвърди", - "create": "Създай", - "delete": "Изтрий", - "edit": "Редактирай", - "export": "Експорт", - "list": "Списък", - "refresh": "Обнови", - "remove_filter": "Премахни този филтър", - "remove": "Премахни", - "save": "Запази", - "search": "Търси", - "show": "Покажи", - "sort": "Сортирай", - "undo": "Отмени", - "expand": "Разгърни", - "close": "Затвори", - "open_menu": "Отвори меню", - "close_menu": "Затвори меню", - "unselect": "Премахни избора", - "skip": "Пропусни", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Споделяне", - "download": "Сваляне" - }, - "boolean": { - "true": "Да", - "false": "Не" - }, - "page": { - "create": "Създаване на %{name}", - "dashboard": "Табло", - "edit": "%{name} #%{id}", - "error": "Нещо се обърка", - "list": "%{name}", - "loading": "Зареждане", - "not_found": "Не е намерен", - "show": "%{name} #%{id}", - "empty": "Все още няма %{name}.", - "invite": "Желаете ли да добавите?" - }, - "input": { - "file": { - "upload_several": "Пуснете файл за да качите, или кликнете за да изберете.", - "upload_single": "Пуснете файл за да качите, или кликнете за да изберете." - }, - "image": { - "upload_several": "Пуснете снимки за качване, или кликнете, за да изберете.", - "upload_single": "Пуснете снимка за качване, или кликнете за да изберете." - }, - "references": { - "all_missing": "Не намирам свързаните данни.", - "many_missing": "Изглежда, че поне една от свързаните препратки, вече не е налична.", - "single_missing": "Изглежда, че връзката вече не е налична." - }, - "password": { - "toggle_visible": "Скрий паролата", - "toggle_hidden": "Покажи паролата" - } + "ra": { + "auth": { + "welcome1": "Благодаря, че инсталирахте Navidrome!", + "welcome2": "За да започнете, създайте администраторски профил", + "confirmPassword": "Потвърдете паролата", + "buttonCreateAdmin": "Създaй администратор", + "auth_check_error": "Моля, влезте за да продължите", + "user_menu": "Профил", + "username": "Потребителско име", + "password": "Парола", + "sign_in": "Вход", + "sign_in_error": "Грешка при удостоверяването. Моля, опитайте отново", + "logout": "Изход" + }, + "validation": { + "invalidChars": "Моля, използвайте само букви и цифри", + "passwordDoesNotMatch": "Паролата не съвпада", + "required": "Задължително", + "minLength": "Трябва да съдържа поне %{min} знака", + "maxLength": "Трябва да съдържа %{max} знака или по-малко", + "minValue": "Трябва да е поне %{min}", + "maxValue": "Трябва да бъде %{max} или по-малко", + "number": "Трябва да е число", + "email": "Трябва да е валиден имейл", + "oneOf": "Трябва да е едно от: %{options}", + "regex": "Трябва да съответства на конкретен формат (regexp): %{pattern}", + "unique": "Трябва да е уникално", + "url": "Трябва да бъде валиден адрес" + }, + "action": { + "add_filter": "Добави филтър", + "add": "Добави", + "back": "Назад", + "bulk_actions": "Избран е 1 елемент |||| Избрани са %{smart_count} елемента", + "cancel": "Отмени", + "clear_input_value": "Изчисти въведеното", + "clone": "Клонирай", + "confirm": "Потвърди", + "create": "Създай", + "delete": "Изтрий", + "edit": "Редактирай", + "export": "Експорт", + "list": "Списък", + "refresh": "Обнови", + "remove_filter": "Премахни този филтър", + "remove": "Премахни", + "save": "Запази", + "search": "Търси", + "show": "Покажи", + "sort": "Сортирай", + "undo": "Отмени", + "expand": "Разгърни", + "close": "Затвори", + "open_menu": "Отвори меню", + "close_menu": "Затвори меню", + "unselect": "Премахни избора", + "skip": "Пропусни", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Споделяне", + "download": "Сваляне" + }, + "boolean": { + "true": "Да", + "false": "Не" + }, + "page": { + "create": "Създаване на %{name}", + "dashboard": "Табло", + "edit": "%{name} #%{id}", + "error": "Нещо се обърка", + "list": "%{name}", + "loading": "Зареждане", + "not_found": "Не е намерен", + "show": "%{name} #%{id}", + "empty": "Все още няма %{name}.", + "invite": "Желаете ли да добавите?" + }, + "input": { + "file": { + "upload_several": "Пуснете файл за да качите, или кликнете за да изберете.", + "upload_single": "Пуснете файл за да качите, или кликнете за да изберете." + }, + "image": { + "upload_several": "Пуснете снимки за качване, или кликнете, за да изберете.", + "upload_single": "Пуснете снимка за качване, или кликнете за да изберете." + }, + "references": { + "all_missing": "Не намирам свързаните данни.", + "many_missing": "Изглежда, че поне една от свързаните препратки, вече не е налична.", + "single_missing": "Изглежда, че връзката вече не е налична." + }, + "password": { + "toggle_visible": "Скрий паролата", + "toggle_hidden": "Покажи паролата" + } + }, + "message": { + "about": "Относно", + "are_you_sure": "Сигурни ли сте?", + "bulk_delete_content": "Наистина ли желаете да изтриете това %{name}? |||| Наистина ли желаете да изтриете тези %{smart_count} елементи?", + "bulk_delete_title": "Изтрий %{name} |||| Изтрий %{smart_count} %{name}", + "delete_content": "Наистина ли желаете да изтриете този елемент?", + "delete_title": "Изтрий %{name} #%{id}", + "details": "Описание", + "error": "Възникна грешка с клиента и заявката Ви не може да бъде изпълнена.", + "invalid_form": "Формата не е валидна. Моля, проверете за грешки", + "loading": "Страницата се зарежда, моля изчакайте", + "no": "Не", + "not_found": "Или сте въвели грешен URL адрес, или сте следвали грешна връзка.", + "yes": "Да", + "unsaved_changes": "Някои от промените не бяха запазени. Сигурни ли сте, че желаете да ги игнорирате?" + }, + "navigation": { + "no_results": "Няма намерени резултати", + "no_more_results": "Страница %{page} е извън границите. Опитайте предишната страница.", + "page_out_of_boundaries": "Страница %{page} е извън границите", + "page_out_from_end": "Не може да отидете след последната страница", + "page_out_from_begin": "Не може да се премине преди страница 1", + "page_range_info": "%{offsetBegin}-%{offsetEnd} от %{total}", + "page_rows_per_page": "Елемента на страница:", + "next": "Следваща", + "prev": "Предишна", + "skip_nav": "Премини към съдържанието" + }, + "notification": { + "updated": "Елементът е актуализиран |||| %{smart_count} елемента са актуализирани", + "created": "Елементът е създаден", + "deleted": "Елементът е изтрит |||| %{smart_count} елемента са изтрити", + "bad_item": "Неправилен елемент", + "item_doesnt_exist": "Елементът не съществува", + "http_error": "Грешка в комуникацията със сървъра", + "data_provider_error": "Грешка в доставчика на данни. Проверете конзолата за подробности.", + "i18n_error": "Не мога да заредя преводите за посочения език", + "canceled": "Действието е отменено", + "logged_out": "Вашата сесия приключи. Моля, влезте отново.", + "new_version": "Налична е нова версия! Моля, опреснете този прозорец." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Колони за показване", + "layout": "Оформление", + "grid": "Решетка", + "table": "Таблица" + } }, "message": { - "about": "Относно", - "are_you_sure": "Сигурни ли сте?", - "bulk_delete_content": "Наистина ли желаете да изтриете това %{name}? |||| Наистина ли желаете да изтриете тези %{smart_count} елементи?", - "bulk_delete_title": "Изтрий %{name} |||| Изтрий %{smart_count} %{name}", - "delete_content": "Наистина ли желаете да изтриете този елемент?", - "delete_title": "Изтрий %{name} #%{id}", - "details": "Описание", - "error": "Възникна грешка с клиента и заявката Ви не може да бъде изпълнена.", - "invalid_form": "Формата не е валидна. Моля, проверете за грешки", - "loading": "Страницата се зарежда, моля изчакайте", - "no": "Не", - "not_found": "Или сте въвели грешен URL адрес, или сте следвали грешна връзка.", - "yes": "Да", - "unsaved_changes": "Някои от промените не бяха запазени. Сигурни ли сте, че желаете да ги игнорирате?" - }, - "navigation": { - "no_results": "Няма намерени резултати", - "no_more_results": "Страница %{page} е извън границите. Опитайте предишната страница.", - "page_out_of_boundaries": "Страница %{page} е извън границите", - "page_out_from_end": "Не може да отидете след последната страница", - "page_out_from_begin": "Не може да се премине преди страница 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} от %{total}", - "page_rows_per_page": "Елемента на страница:", - "next": "Следваща", - "prev": "Предишна", - "skip_nav": "Премини към съдържанието" - }, - "notification": { - "updated": "Елементът е актуализиран |||| %{smart_count} елемента са актуализирани", - "created": "Елементът е създаден", - "deleted": "Елементът е изтрит |||| %{smart_count} елемента са изтрити", - "bad_item": "Неправилен елемент", - "item_doesnt_exist": "Елементът не съществува", - "http_error": "Грешка в комуникацията със сървъра", - "data_provider_error": "Грешка в доставчика на данни. Проверете конзолата за подробности.", - "i18n_error": "Не мога да заредя преводите за посочения език", - "canceled": "Действието е отменено", - "logged_out": "Вашата сесия приключи. Моля, влезте отново.", - "new_version": "Налична е нова версия! Моля, опреснете този прозорец." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Колони за показване", - "layout": "Оформление", - "grid": "Решетка", - "table": "Таблица" - } - }, - "message": { - "note": "ЗАБЕЛЕЖКА", - "transcodingDisabled": "Промяната на конфигурацията за транскодиране през уеб интерфейса е забранена от съображения за сигурност. Ако желаете да промените (редактирате или добавите) опциите за транскодиране, рестартирайте сървъра с конфигурационната опция %{config}.", - "transcodingEnabled": "Navidrome в момента работи с %{config}, което прави възможно стартирането на системни команди от настройките за транскодиране с помощта на уеб интерфейса. Препоръчваме да го деактивирате от съображения за сигурност и да го активирате само при конфигуриране на опциите за транскодиране.", - "songsAddedToPlaylist": "Добавена 1 песен към плейлиста |||| Добавени %{smart_count} песни към плейлиста", - "noPlaylistsAvailable": "Няма налични", - "delete_user_title": "Изтрий потребителя '%{name}'", - "delete_user_content": "Наистина ли желаете да изтриете този потребител и всичките му данни (включително плейлисти и предпочитания)?", - "notifications_blocked": "В настройките на браузъра сте блокирали известията за този сайт", - "notifications_not_available": "Този браузър не поддържа известия на работния плот или нямате достъп до Navidrome през https", - "lastfmLinkSuccess": "Връзката с Last.fm е успешна! Scrobbling е активиран", - "lastfmLinkFailure": "Last.fm не можа да бъде свързан", - "lastfmUnlinkSuccess": "Връзката с Last.fm е прекъсната! Scrobbling е деактивиран", - "lastfmUnlinkFailure": "Last.fm връзката не можа да бъде премахната", - "openIn": { - "lastfm": "Отвори в Last.fm", - "musicbrainz": "Отвори в MusicBrainz" - }, - "lastfmLink": "Прочетете още...", - "listenBrainzLinkSuccess": "Връзката с ListenBrainz е успешна! Scrobbling е активиран от името на потребителя: %{user}", - "listenBrainzLinkFailure": "ListenBrainz не можа да бъде свързан: %{error}", - "listenBrainzUnlinkSuccess": "Връзката с ListenBrainz е прекъсната! Scrobbling е деактивиран", - "listenBrainzUnlinkFailure": "Връзката с ListenBrainz не можа да бъде прекратена", - "downloadOriginalFormat": "Свали в оригиналния формат", - "shareOriginalFormat": "Сподели в оригинален формат", - "shareDialogTitle": "Сподели %{resource} '%{name}'", - "shareBatchDialogTitle": "Сподели 1 %{resource} |||| Сподели %{smart_count} %{resource}", - "shareSuccess": "Адресът е копиран в клипборда: %{url}", - "shareFailure": "Грешка при копиране на адрес %{url} в клипборда", - "downloadDialogTitle": "Сваляне %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Копиране в клипборда: Ctrl+C, Enter", - "remove_missing_title": "Премахни липсващите файлове", - "remove_missing_content": "Сигурни ли сте, че желаете да премахнете избраните липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.", - "remove_all_missing_title": "Премахни всички липсващи файлове", - "remove_all_missing_content": "Сигурни ли сте, че желаете да премахнете всички липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.", - "noSimilarSongsFound": "Не са намерени подобни песни", - "noTopSongsFound": "Няма намерени топ песни", - "startingInstantMix": "Зареждане на незабавен микс..." - }, - "menu": { - "library": "Библиотека", - "settings": "Настройки", - "version": "Версия", - "theme": "Тема", - "personal": { - "name": "Лични", - "options": { - "theme": "Тема", - "language": "Език", - "defaultView": "Изглед по подразбиране", - "desktop_notifications": "Известия на работния плот", - "lastfmScrobbling": "Scrobble към Last.fm", - "listenBrainzScrobbling": "Scrobble към ListenBrainz", - "replaygain": "Режим ReplayGain", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "Изключен", - "album": "Използвай Album Gain", - "track": "Използвай Track Gain" + "note": "ЗАБЕЛЕЖКА", + "transcodingDisabled": "Промяната на конфигурацията за транскодиране през уеб интерфейса е забранена от съображения за сигурност. Ако желаете да промените (редактирате или добавите) опциите за транскодиране, рестартирайте сървъра с конфигурационната опция %{config}.", + "transcodingEnabled": "Navidrome в момента работи с %{config}, което прави възможно стартирането на системни команди от настройките за транскодиране с помощта на уеб интерфейса. Препоръчваме да го деактивирате от съображения за сигурност и да го активирате само при конфигуриране на опциите за транскодиране.", + "songsAddedToPlaylist": "Добавена 1 песен към плейлиста |||| Добавени %{smart_count} песни към плейлиста", + "noPlaylistsAvailable": "Няма налични", + "delete_user_title": "Изтрий потребителя '%{name}'", + "delete_user_content": "Наистина ли желаете да изтриете този потребител и всичките му данни (включително плейлисти и предпочитания)?", + "notifications_blocked": "В настройките на браузъра сте блокирали известията за този сайт", + "notifications_not_available": "Този браузър не поддържа известия на работния плот или нямате достъп до Navidrome през https", + "lastfmLinkSuccess": "Връзката с Last.fm е успешна! Scrobbling е активиран", + "lastfmLinkFailure": "Last.fm не можа да бъде свързан", + "lastfmUnlinkSuccess": "Връзката с Last.fm е прекъсната! Scrobbling е деактивиран", + "lastfmUnlinkFailure": "Last.fm връзката не можа да бъде премахната", + "openIn": { + "lastfm": "Отвори в Last.fm", + "musicbrainz": "Отвори в MusicBrainz" }, - "lastfmNotConfigured": "API ключът на Last.fm не е конфигуриран" - } + "lastfmLink": "Прочетете още...", + "listenBrainzLinkSuccess": "Връзката с ListenBrainz е успешна! Scrobbling е активиран от името на потребителя: %{user}", + "listenBrainzLinkFailure": "ListenBrainz не можа да бъде свързан: %{error}", + "listenBrainzUnlinkSuccess": "Връзката с ListenBrainz е прекъсната! Scrobbling е деактивиран", + "listenBrainzUnlinkFailure": "Връзката с ListenBrainz не можа да бъде прекратена", + "downloadOriginalFormat": "Свали в оригиналния формат", + "shareOriginalFormat": "Сподели в оригинален формат", + "shareDialogTitle": "Сподели %{resource} '%{name}'", + "shareBatchDialogTitle": "Сподели 1 %{resource} |||| Сподели %{smart_count} %{resource}", + "shareSuccess": "Адресът е копиран в клипборда: %{url}", + "shareFailure": "Грешка при копиране на адрес %{url} в клипборда", + "downloadDialogTitle": "Сваляне %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Копиране в клипборда: Ctrl+C, Enter" }, - "albumList": "Албуми", - "about": "Относно", - "playlists": "Плейлисти", - "sharedPlaylists": "Споделени плейлисти", - "librarySelector": { - "allLibraries": "Всички библиотеки (%{count})", - "multipleLibraries": "%{selected} от %{total} библиотеки", - "selectLibraries": "Изберете библиотеки", - "none": "Няма" - } - }, - "player": { - "playListsText": "Списък с песни", - "openText": "Отвори", - "closeText": "Затвори", - "notContentText": "Няма песни", - "clickToPlayText": "Пускане", - "clickToPauseText": "Пауза", - "nextTrackText": "Следваща песен", - "previousTrackText": "Предишна песен", - "reloadText": "Презареди", - "volumeText": "Сила на звука", - "toggleLyricText": "Текст на песен", - "toggleMiniModeText": "Минимизирай", - "destroyText": "Унищожи", - "downloadText": "Свали", - "removeAudioListsText": "Изтриване на плейлисти", - "clickToDeleteText": "Кликнете, за да изтриете %{name}", - "emptyLyricText": "Няма текст", - "playModeText": { - "order": "По ред", - "orderLoop": "Повтаряй всички", - "singleLoop": "Повтаряй същата", - "shufflePlay": "Разбъркай" - } - }, - "about": { - "links": { - "homepage": "Начална страница", - "source": "Програмен код", - "featureRequests": "Заявете функционалност", - "lastInsightsCollection": "Последна колекция от анализи", - "insights": { - "disabled": "Деактивиран", - "waiting": "Изчакване" - } + "menu": { + "library": "Библиотека", + "settings": "Настройки", + "version": "Версия", + "theme": "Тема", + "personal": { + "name": "Лични", + "options": { + "theme": "Тема", + "language": "Език", + "defaultView": "Изглед по подразбиране", + "desktop_notifications": "Известия на работния плот", + "lastfmScrobbling": "Scrobble към Last.fm", + "listenBrainzScrobbling": "Scrobble към ListenBrainz", + "replaygain": "Режим ReplayGain", + "preAmp": "ReplayGain PreAmp (dB)", + "gain": { + "none": "Изключен", + "album": "Използвай Album Gain", + "track": "Използвай Track Gain" + } + } + }, + "albumList": "Албуми", + "about": "Относно", + "playlists": "Плейлисти", + "sharedPlaylists": "Споделени плейлисти" }, - "tabs": { - "about": "Относно", - "config": "Конфигурация" + "player": { + "playListsText": "Списък с песни", + "openText": "Отвори", + "closeText": "Затвори", + "notContentText": "Няма песни", + "clickToPlayText": "Пускане", + "clickToPauseText": "Пауза", + "nextTrackText": "Следваща песен", + "previousTrackText": "Предишна песен", + "reloadText": "Презареди", + "volumeText": "Сила на звука", + "toggleLyricText": "Текст на песен", + "toggleMiniModeText": "Минимизирай", + "destroyText": "Унищожи", + "downloadText": "Свали", + "removeAudioListsText": "Изтриване на плейлисти", + "clickToDeleteText": "Кликнете, за да изтриете %{name}", + "emptyLyricText": "Няма текст", + "playModeText": { + "order": "По ред", + "orderLoop": "Повтаряй всички", + "singleLoop": "Повтаряй същата", + "shufflePlay": "Разбъркай" + } }, - "config": { - "configName": "Име на конфигурация", - "environmentVariable": "Променлива на средата", - "currentValue": "Текуща стойност", - "configurationFile": "Конфигурационен файл", - "exportToml": "Експортиране на конфигурация (TOML)", - "exportSuccess": "Конфигурация, експортирана в клипборда във формат TOML", - "exportFailed": "Неуспешно копиране на конфигурация", - "devFlagsHeader": "Флагове за разработка (подлежащи на промяна/премахване)", - "devFlagsComment": "Това са експериментални настройки и е възможно да бъдат премахнати в бъдещи версии.", - "downloadToml": "Изтегляне на конфигурация (TOML)" + "about": { + "links": { + "homepage": "Начална страница", + "source": "Програмен код", + "featureRequests": "Заявете функционалност" + } + }, + "activity": { + "title": "Действия", + "totalScanned": "Сканирани папки", + "quickScan": "Бързо сканиране", + "fullScan": "Пълно сканиране", + "serverUptime": "Сървърът работи", + "serverDown": "ОФЛАЙН" + }, + "help": { + "title": "Бързи клавиши на Navidrome", + "hotkeys": { + "show_help": "Показва този помощен текст", + "toggle_menu": "Превключване на страничната меню лента", + "toggle_play": "Пусни / Пауза", + "prev_song": "Предишна песен", + "next_song": "Следваща песен", + "vol_up": "Увеличи звука", + "vol_down": "Намали звука", + "toggle_love": "Добави песента към любими", + "current_song": "Премини към текущата песен" + } } - }, - "activity": { - "title": "Действия", - "totalScanned": "Сканирани папки", - "quickScan": "Бързо сканиране", - "fullScan": "Пълно сканиране", - "serverUptime": "Сървърът работи", - "serverDown": "ОФЛАЙН", - "scanType": "Последно сканиране", - "status": "Грешка при сканиране", - "elapsedTime": "Изминало време", - "selectiveScan": "Селективен" - }, - "help": { - "title": "Бързи клавиши на Navidrome", - "hotkeys": { - "show_help": "Показва този помощен текст", - "toggle_menu": "Превключване на страничната меню лента", - "toggle_play": "Пусни / Пауза", - "prev_song": "Предишна песен", - "next_song": "Следваща песен", - "vol_up": "Увеличи звука", - "vol_down": "Намали звука", - "toggle_love": "Добави песента към любими", - "current_song": "Премини към текущата песен" - } - }, - "nowPlaying": { - "title": "Сега свири", - "empty": "Нищо не се възпроизвежда", - "minutesAgo": "преди %{smart_count} минута |||| преди %{smart_count} минути" - } -} +} \ No newline at end of file diff --git a/resources/i18n/bs.json b/resources/i18n/bs.json deleted file mode 100644 index 9d5c552e7..000000000 --- a/resources/i18n/bs.json +++ /dev/null @@ -1,628 +0,0 @@ -{ - "languageName": "Bosanski", - "resources": { - "song": { - "name": "Pjesma |||| Pjesme", - "fields": { - "albumArtist": "Izvođač albuma", - "duration": "Trajanje", - "trackNumber": "Pjesma #", - "playCount": "Reprodukcija", - "title": "Naslov", - "artist": "Izvođač", - "album": "Album", - "path": "Putanja datoteke", - "genre": "Žanr", - "compilation": "Kompilacija", - "year": "Godina", - "size": "Veličina datoteke", - "updatedAt": "Dodano", - "bitRate": "Brzina prijenosa", - "discSubtitle": "Podnaslov CD-a", - "starred": "Favorit", - "comment": "Komentar", - "rating": "Ocjena", - "quality": "Kvaliteta", - "bpm": "BPM", - "playDate": "Posljednja reprodukcija", - "channels": "Kanali", - "createdAt": "Dodano", - "grouping": "Grupisanje", - "mood": "Raspoloženje", - "participants": "Dodatni učesnici", - "tags": "Dodatne oznake", - "mappedTags": "Mapirane oznake", - "rawTags": "Sirovi podaci oznaka", - "bitDepth": "Dubina bita", - "sampleRate": "Uzorkovanje", - "missing": "Nedostaje", - "libraryName": "Biblioteka" - }, - "actions": { - "addToQueue": "Reprodukcija kasnije", - "playNow": "Reprodukcija sada", - "addToPlaylist": "Dodaj u playlistu", - "shuffleAll": "Nasumična reprodukcija", - "download": "Preuzmi", - "playNext": "Reprodukcija sljedeće", - "info": "Više informacija", - "showInPlaylist": "Prikaži u playlisti" - } - }, - "album": { - "name": "Album |||| Albumi", - "fields": { - "albumArtist": "Izvođač albuma", - "artist": "Izvođač", - "duration": "Trajanje", - "songCount": "Broj pjesama", - "playCount": "Reprodukcija", - "name": "Naziv", - "genre": "Žanr", - "compilation": "Kompilacija", - "year": "Godina", - "updatedAt": "Ažurirano", - "comment": "Komentar", - "rating": "Ocjena", - "createdAt": "Dodano", - "size": "Veličina", - "originalDate": "Originalni datum", - "releaseDate": "Datum izdanja", - "releases": "Izdanje |||| Izdanja", - "released": "Objavljeno", - "recordLabel": "Izdavač", - "catalogNum": "Kataloški broj", - "releaseType": "Tip", - "grouping": "Grupisanje", - "media": "Medij", - "mood": "Raspoloženje", - "date": "Datum snimanja", - "missing": "Nedostaje", - "libraryName": "Biblioteka" - }, - "actions": { - "playAll": "Reprodukcija", - "playNext": "Reprodukcija sljedeće", - "addToQueue": "Dodaj u red", - "shuffle": "Nasumična reprodukcija", - "addToPlaylist": "Dodaj u playlistu", - "download": "Preuzmi", - "info": "Više informacija", - "share": "Podijeli" - }, - "lists": { - "all": "Sve", - "random": "Nasumično", - "recentlyAdded": "Nedavno dodano", - "recentlyPlayed": "Nedavno reproducirano", - "mostPlayed": "Najviše reproducirano", - "starred": "Favoriti", - "topRated": "Najbolje ocijenjeno" - } - }, - "artist": { - "name": "Izvođač |||| Izvođači", - "fields": { - "name": "Naziv", - "albumCount": "Broj albuma", - "songCount": "Broj pjesama", - "playCount": "Reprodukcija", - "rating": "Ocjena", - "genre": "Žanr", - "size": "Veličina", - "role": "Uloga", - "missing": "Nedostaje" - }, - "roles": { - "albumartist": "Izvođač albuma |||| Izvođači albuma", - "artist": "Izvođač |||| Izvođači", - "composer": "Kompozitor |||| Kompozitori", - "conductor": "Dirigent |||| Dirigenti", - "lyricist": "Tekstopisac |||| Tekstopisci", - "arranger": "Aranžer |||| Aranžeri", - "producer": "Producent |||| Producenti", - "director": "Direktor |||| Direktori", - "engineer": "Inženjer |||| Inženjeri", - "mixer": "Mikser |||| Mikseri", - "remixer": "Remikser |||| Remikseri", - "djmixer": "DJ Mikser |||| DJ Mikseri", - "performer": "Izvođač |||| Izvođači", - "maincredit": "Izvođač albuma ili izvođač |||| Izvođači albuma ili izvođači" - }, - "actions": { - "shuffle": "Nasumična reprodukcija", - "radio": "Radio", - "topSongs": "Najpopularnije pjesme" - } - }, - "user": { - "name": "Korisnik |||| Korisnici", - "fields": { - "userName": "Korisničko ime", - "isAdmin": "Je admin", - "lastLoginAt": "Posljednja prijava", - "updatedAt": "Ažurirano", - "name": "Ime", - "password": "Lozinka", - "createdAt": "Kreirano", - "changePassword": "Promijeni lozinku?", - "currentPassword": "Trenutna lozinka", - "newPassword": "Nova lozinka", - "token": "Token", - "lastAccessAt": "Posljednji pristup", - "libraries": "Biblioteke" - }, - "helperTexts": { - "name": "Promjena će biti aktivna nakon sljedeće prijave", - "libraries": "Odaberi specifične biblioteke za ovog korisnika ili ostavi prazno za standardne biblioteke" - }, - "notifications": { - "created": "Korisnik kreiran", - "updated": "Korisnik ažuriran", - "deleted": "Korisnik obrisan" - }, - "message": { - "listenBrainzToken": "Unesite svoj ListenBrainz korisnički token", - "clickHereForToken": "Kliknite ovdje za dobijanje tokena", - "selectAllLibraries": "Odaberi sve biblioteke", - "adminAutoLibraries": "Administratori automatski imaju pristup svim bibliotekama" - }, - "validation": { - "librariesRequired": "Ne-administratori moraju imati barem jednu odabranu biblioteku" - } - }, - "player": { - "name": "Player |||| Playeri", - "fields": { - "name": "Naziv", - "transcodingId": "ID transkodiranja", - "maxBitRate": "Maks. brzina prijenosa", - "client": "Klijent", - "userName": "Korisničko ime", - "lastSeen": "Posljednji put viđen", - "reportRealPath": "Prikaži stvarnu putanju", - "scrobbleEnabled": "Slanje podataka o reprodukciji (scrobbling)" - } - }, - "transcoding": { - "name": "Transkodiranje |||| Transkodiranja", - "fields": { - "name": "Naziv", - "targetFormat": "Ciljani format", - "defaultBitRate": "Zadana brzina prijenosa", - "command": "Komanda" - } - }, - "playlist": { - "name": "Playlista |||| Playliste", - "fields": { - "name": "Naziv", - "duration": "Trajanje", - "ownerName": "Vlasnik", - "public": "Javna", - "updatedAt": "Ažurirano", - "createdAt": "Kreirano", - "songCount": "Broj pjesama", - "comment": "Komentar", - "sync": "Auto-uvoz", - "path": "Uvezi iz" - }, - "actions": { - "selectPlaylist": "Odaberi playlistu:", - "addNewPlaylist": "Kreiraj \"%{name}\"", - "export": "Izvezi", - "makePublic": "Učini javnom", - "makePrivate": "Učini privatnom", - "saveQueue": "Sačuvaj red čekanja u playlistu", - "searchOrCreate": "Pretraži playlistu ili kreiraj novu...", - "pressEnterToCreate": "Pritisni Enter za kreiranje nove playliste", - "removeFromSelection": "Ukloni iz odabira" - }, - "message": { - "duplicate_song": "Dodaj duplikate", - "song_exist": "Neke pjesme su već u playlisti. Želiš li ih ipak dodati ili preskočiti?", - "noPlaylistsFound": "Nije pronađena nijedna playlista", - "noPlaylists": "Nema playlisti" - } - }, - "radio": { - "name": "Radio |||| Radiji", - "fields": { - "name": "Naziv", - "streamUrl": "Stream URL", - "homePageUrl": "URL početne stranice", - "updatedAt": "Ažurirano", - "createdAt": "Dodano" - }, - "actions": { - "playNow": "Reprodukcija sada" - } - }, - "share": { - "name": "Dijeljenje |||| Dijeljenja", - "fields": { - "username": "Podijeljeno od strane", - "url": "URL", - "description": "Opis", - "contents": "Sadržaj", - "expiresAt": "Vrijedi do", - "lastVisitedAt": "Posljednja posjeta", - "visitCount": "Posjete", - "format": "Format", - "maxBitRate": "Maks. brzina prijenosa", - "updatedAt": "Ažurirano", - "createdAt": "Kreirano", - "downloadable": "Dozvoli preuzimanje?" - } - }, - "missing": { - "name": "Nedostajuća datoteka |||| Nedostajuće datoteke", - "fields": { - "path": "Putanja", - "size": "Veličina", - "updatedAt": "Nedostaje od", - "libraryName": "Biblioteka" - }, - "actions": { - "remove": "Ukloni", - "remove_all": "ukloni sve" - }, - "notifications": { - "removed": "Nedostajuća(e) datoteka(e) uklonjena(e)" - }, - "empty": "nema nedostajućih datoteka" - }, - "library": { - "name": "Biblioteka |||| Biblioteke", - "fields": { - "name": "Naziv", - "path": "Putanja", - "remotePath": "Udaljena putanja", - "lastScanAt": "Posljednje skeniranje", - "songCount": "Pjesme", - "albumCount": "Albumi", - "artistCount": "Izvođači", - "totalSongs": "Pjesme", - "totalAlbums": "Albumi", - "totalArtists": "Izvođači", - "totalFolders": "Folderi", - "totalFiles": "Datoteke", - "totalMissingFiles": "Nedostajuće datoteke", - "totalSize": "Veličina", - "totalDuration": "Trajanje", - "defaultNewUsers": "Standardno za nove korisnike", - "createdAt": "Kreirano", - "updatedAt": "Ažurirano" - }, - "sections": { - "basic": "Osnovne informacije", - "statistics": "Statistika" - }, - "actions": { - "scan": "Skeniraj biblioteku", - "manageUsers": "Upravljaj pristupima", - "viewDetails": "Pogledaj detalje" - }, - "notifications": { - "created": "Biblioteka uspješno kreirana", - "updated": "Biblioteka uspješno ažurirana", - "deleted": "Biblioteka uspješno obrisana", - "scanStarted": "Skeniranje biblioteke započeto", - "scanCompleted": "Skeniranje biblioteke završeno" - }, - "validation": { - "nameRequired": "Naziv biblioteke je obavezan", - "pathRequired": "Putanja biblioteke je obavezna", - "pathNotDirectory": "Putanja biblioteke mora biti folder", - "pathNotFound": "Putanja biblioteke nije pronađena", - "pathNotAccessible": "Putanja biblioteke nije dostupna", - "pathInvalid": "Putanja biblioteke nije validna" - }, - "messages": { - "deleteConfirm": "Da li zaista želiš obrisati ovu biblioteku? Pristup i podaci će biti uklonjeni.", - "scanInProgress": "Skeniranje biblioteke u toku...", - "noLibrariesAssigned": "Nema dodijeljenih biblioteka" - } - } - }, - "ra": { - "auth": { - "welcome1": "Hvala što ste instalirali Navidrome!", - "welcome2": "Prvo kreirajte admin korisnika", - "confirmPassword": "Potvrdi lozinku", - "buttonCreateAdmin": "Kreiraj admina", - "auth_check_error": "Prijavite se da biste nastavili", - "user_menu": "Profil", - "username": "Korisničko ime", - "password": "Lozinka", - "sign_in": "Prijava", - "sign_in_error": "Greška pri prijavi", - "logout": "Odjava", - "insightsCollectionNote": "Navidrome prikuplja anonimne statistike \nda podrži razvoj projekta. \nKliknite [ovdje] za više informacija ili da isključite \"Insights\"" - }, - "validation": { - "invalidChars": "Koristite samo slova i brojeve", - "passwordDoesNotMatch": "Lozinke se ne podudaraju", - "required": "Obavezno", - "minLength": "Mora imati najmanje %{min} znakova", - "maxLength": "Mora imati najviše %{max} znakova", - "minValue": "Mora biti najmanje %{min}", - "maxValue": "Mora biti %{max} ili manje", - "number": "Mora biti broj", - "email": "Mora biti validna e-mail adresa", - "oneOf": "Mora biti jedan od: %{options}", - "regex": "Mora odgovarati regularnom izrazu: %{pattern}", - "unique": "Mora biti jedinstveno", - "url": "Mora biti validan URL" - }, - "action": { - "add_filter": "Dodaj filter", - "add": "Dodaj", - "back": "Nazad", - "bulk_actions": "1 odabrana stavka |||| %{smart_count} odabrane stavke", - "cancel": "Otkaži", - "clear_input_value": "Obriši unos", - "clone": "Kloniraj", - "confirm": "Potvrdi", - "create": "Kreiraj", - "delete": "Obriši", - "edit": "Uredi", - "export": "Izvezi", - "list": "Lista", - "refresh": "Osvježi", - "remove_filter": "Ukloni filter", - "remove": "Ukloni", - "save": "Sačuvaj", - "search": "Pretraži", - "show": "Prikaži", - "sort": "Sortiraj", - "undo": "Poništi", - "expand": "Proširi", - "close": "Zatvori", - "open_menu": "Otvori meni", - "close_menu": "Zatvori meni", - "unselect": "Poništi odabir", - "skip": "Preskoči", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Podijeli", - "download": "Preuzmi" - }, - "boolean": { - "true": "Da", - "false": "Ne" - }, - "page": { - "create": "Kreiraj %{name}", - "dashboard": "Kontrolna tabla", - "edit": "%{name} #%{id}", - "error": "Nešto je pošlo po zlu", - "list": "%{name}", - "loading": "Učitavanje", - "not_found": "Nije pronađeno", - "show": "%{name} #%{id}", - "empty": "Još nema %{name}.", - "invite": "Želiš li dodati jednu?" - }, - "input": { - "file": { - "upload_several": "Povuci datoteke ovdje za prijenos ili klikni za odabir.", - "upload_single": "Povuci datoteku ovdje za prijenos ili klikni za odabir." - }, - "image": { - "upload_several": "Povuci slike ovdje za prijenos ili klikni za odabir.", - "upload_single": "Povuci sliku ovdje za prijenos ili klikni za odabir." - }, - "references": { - "all_missing": "Povezane reference nisu pronađene.", - "many_missing": "Neke povezane reference više nisu dostupne.", - "single_missing": "Povezana referenca više nije dostupna." - }, - "password": { - "toggle_visible": "Sakrij lozinku", - "toggle_hidden": "Prikaži lozinku" - } - }, - "message": { - "about": "O aplikaciji", - "are_you_sure": "Jesi li siguran?", - "bulk_delete_content": "Da li zaista želiš obrisati \"%{name}\"? |||| Da li zaista želiš obrisati %{smart_count} stavki?", - "bulk_delete_title": "Obriši %{name} |||| Obriši %{smart_count} %{name} stavki", - "delete_content": "Da li zaista želiš obrisati ovaj sadržaj?", - "delete_title": "Obriši %{name} #%{id}", - "details": "Detalji", - "error": "Došlo je do greške i zahtjev nije mogao biti završen.", - "invalid_form": "Formular nije validan. Provjeri unose.", - "loading": "Stranica se učitava", - "no": "Ne", - "not_found": "Stranica nije pronađena.", - "yes": "Da", - "unsaved_changes": "Neke promjene nisu sačuvane. Želiš li ih ignorisati?" - }, - "navigation": { - "no_results": "Nema rezultata", - "no_more_results": "Stranica %{page} nema sadržaja.", - "page_out_of_boundaries": "Stranica %{page} je izvan opsega", - "page_out_from_end": "Posljednja stranica", - "page_out_from_begin": "Prva stranica", - "page_range_info": "%{offsetBegin}-%{offsetEnd} od %{total}", - "page_rows_per_page": "Redova po stranici:", - "next": "Sljedeća", - "prev": "Prethodna", - "skip_nav": "Preskoči na sadržaj" - }, - "notification": { - "updated": "Stavka ažurirana |||| %{smart_count} stavki ažurirano", - "created": "Stavka kreirana", - "deleted": "Stavka obrisana |||| %{smart_count} stavki obrisano", - "bad_item": "Neispravna stavka", - "item_doesnt_exist": "Stavka ne postoji", - "http_error": "Greška u komunikaciji sa serverom", - "data_provider_error": "Greška u dataProvider-u. Provjeri konzolu za detalje.", - "i18n_error": "Prijevod za odabrani jezik nije dostupan", - "canceled": "Akcija otkazana", - "logged_out": "Sesija je istekla. Ponovo se prijavi.", - "new_version": "Nova verzija dostupna! Osveži stranicu." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Odaberi kolone", - "layout": "Izgled", - "grid": "Mreža", - "table": "Tabela" - } - }, - "message": { - "note": "NAPOMENA", - "transcodingDisabled": "Izmjena postavki transkodiranja preko web sučelja je onemogućena iz sigurnosnih razloga. Ako želiš promijeniti opcije transkodiranja (urediti ili dodati), ponovo pokreni server sa konfiguracijskom opcijom %{config}.", - "transcodingEnabled": "Navidrome trenutno radi sa %{config}, što omogućava izvršavanje sistemskih komandi kroz postavke transkodiranja preko web sučelja. Preporučujemo da ovo onemogućiš iz sigurnosnih razloga i koristiš samo prilikom konfiguracije transkodiranja.", - "songsAddedToPlaylist": "1 pjesma dodana u playlistu |||| %{smart_count} pjesme dodane u playlistu", - "noPlaylistsAvailable": "Nema dostupnih playlisti", - "delete_user_title": "Obriši korisnika '%{name}'", - "delete_user_content": "Da li zaista želiš obrisati ovog korisnika i sve njegove podatke (uključujući playliste i postavke)?", - "notifications_blocked": "Blokirali ste obavijesti za ovu stranicu u postavkama preglednika", - "notifications_not_available": "Ovaj preglednik ne podržava desktop obavijesti", - "lastfmLinkSuccess": "Last.fm veza uspostavljena i scrobbling omogućen", - "lastfmLinkFailure": "Last.fm veza nije uspjela", - "lastfmUnlinkSuccess": "Last.fm veza uklonjena i scrobbling onemogućen", - "lastfmUnlinkFailure": "Last.fm veza nije uklonjena", - "openIn": { - "lastfm": "Prikaži na Last.fm", - "musicbrainz": "Prikaži na MusicBrainz" - }, - "lastfmLink": "Pročitaj više", - "listenBrainzLinkSuccess": "ListenBrainz veza uspostavljena i scrobbling omogućen kao korisnik: %{user}", - "listenBrainzLinkFailure": "ListenBrainz veza nije uspjela: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz veza uklonjena i scrobbling onemogućen", - "listenBrainzUnlinkFailure": "ListenBrainz veza nije uklonjena", - "downloadOriginalFormat": "Preuzmi u originalnom formatu", - "shareOriginalFormat": "Podijeli u originalnom formatu", - "shareDialogTitle": "Podijeli %{resource} '%{name}'", - "shareBatchDialogTitle": "Podijeli 1 %{resource} |||| Podijeli %{smart_count} %{resource}", - "shareSuccess": "URL kopiran u međuspremnik: %{url}", - "shareFailure": "Greška pri kopiranju URL-a %{url} u međuspremnik", - "downloadDialogTitle": "Preuzmi %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Kopiraj u međuspremnik: Ctrl+C, Enter", - "remove_missing_title": "Ukloni nedostajuće datoteke", - "remove_missing_content": "Da li zaista želiš ukloniti odabrane nedostajuće datoteke iz baze podataka? Sve reference na datoteke (broj reprodukcija, ocjene) bit će trajno obrisane.", - "remove_all_missing_title": "Ukloni sve nedostajuće datoteke", - "remove_all_missing_content": "Da li zaista želiš ukloniti sve nedostajuće datoteke iz baze podataka? Sve reference na datoteke (broj reprodukcija, ocjene) bit će trajno obrisane.", - "noSimilarSongsFound": "Nema sličnih pjesama", - "noTopSongsFound": "Nema popularnih pjesama" - }, - "menu": { - "library": "Biblioteka", - "settings": "Postavke", - "version": "Verzija", - "theme": "Tema", - "personal": { - "name": "Lično", - "options": { - "theme": "Tema", - "language": "Jezik", - "defaultView": "Zadani pregled", - "desktop_notifications": "Desktop obavijesti", - "lastfmScrobbling": "Last.fm scrobbling", - "listenBrainzScrobbling": "ListenBrainz scrobbling", - "replaygain": "ReplayGain mod", - "preAmp": "ReplayGain pojačanje (dB)", - "gain": { - "none": "Isključeno", - "album": "Koristi album gain", - "track": "Koristi pjesmu gain" - }, - "lastfmNotConfigured": "Last.fm API ključ nije konfiguriran" - } - }, - "albumList": "Albumi", - "about": "O aplikaciji", - "playlists": "Playliste", - "sharedPlaylists": "Dijeljene playliste", - "librarySelector": { - "allLibraries": "Sve biblioteke (%{count})", - "multipleLibraries": "%{selected} od %{total} biblioteka", - "selectLibraries": "Odaberi biblioteke", - "none": "Nijedna" - } - }, - "player": { - "playListsText": "Reprodukcija reda čekanja", - "openText": "Otvori", - "closeText": "Zatvori", - "notContentText": "Nema muzike", - "clickToPlayText": "Klikni za reprodukciju", - "clickToPauseText": "Klikni za pauzu", - "nextTrackText": "Sljedeća pjesma", - "previousTrackText": "Prethodna pjesma", - "reloadText": "Ponovo učitaj", - "volumeText": "Glasnoća", - "toggleLyricText": "Prikaži/sakrij tekst", - "toggleMiniModeText": "Minimiziraj", - "destroyText": "Uništi", - "downloadText": "Preuzmi", - "removeAudioListsText": "Ukloni audio liste", - "clickToDeleteText": "Klikni za brisanje %{name}", - "emptyLyricText": "Nema teksta", - "playModeText": { - "order": "Redom", - "orderLoop": "Ponavljaj", - "singleLoop": "Ponavljaj jednu", - "shufflePlay": "Nasumična reprodukcija" - } - }, - "about": { - "links": { - "homepage": "Početna stranica", - "source": "Izvorni kod", - "featureRequests": "Zahtjevi za funkcijama", - "lastInsightsCollection": "Posljednje prikupljanje \"Insights\"", - "insights": { - "disabled": "Isključeno", - "waiting": "Čekanje" - } - }, - "tabs": { - "about": "O aplikaciji", - "config": "Konfiguracija" - }, - "config": { - "configName": "Postavka", - "environmentVariable": "Varijabla okruženja", - "currentValue": "Vrijednost", - "configurationFile": "Konfiguracijska datoteka", - "exportToml": "Izvezi konfiguraciju (TOML)", - "exportSuccess": "Konfiguracija kopirana u međuspremnik u TOML formatu", - "exportFailed": "Greška pri kopiranju konfiguracije", - "devFlagsHeader": "Dev postavke (mogu se promijeniti)", - "devFlagsComment": "Eksperimentalne postavke koje mogu biti uklonjene ili promijenjene u budućnosti" - } - }, - "activity": { - "title": "Aktivnost", - "totalScanned": "Ukupno skeniranih foldera", - "quickScan": "Brzo skeniranje", - "fullScan": "Potpuno skeniranje", - "serverUptime": "Vrijeme rada servera", - "serverDown": "ISKLJUČEN", - "scanType": "Tip", - "status": "Greška pri skeniranju", - "elapsedTime": "Proteklo vrijeme" - }, - "help": { - "title": "Navidrome prečice", - "hotkeys": { - "show_help": "Prikaži ovu pomoć", - "toggle_menu": "Uključi/isključi bočnu traku", - "toggle_play": "Reprodukcija / Pauza", - "prev_song": "Prethodna pjesma", - "next_song": "Sljedeća pjesma", - "vol_up": "Glasnije", - "vol_down": "Tiše", - "toggle_love": "Dodaj u favorite", - "current_song": "Prikaži trenutnu pjesmu" - } - }, - "nowPlaying": { - "title": "Trenutna reprodukcija", - "empty": "Nema reprodukcije", - "minutesAgo": "Prije %{smart_count} minute |||| Prije %{smart_count} minuta" - } -} diff --git a/resources/i18n/ca.json b/resources/i18n/ca.json index 1ef2ce016..eca26c0f9 100644 --- a/resources/i18n/ca.json +++ b/resources/i18n/ca.json @@ -1,715 +1,456 @@ { - "languageName": "Català", - "resources": { - "song": { - "name": "Cançó |||| Cançons", - "fields": { - "albumArtist": "Artista de l'àlbum", - "duration": "Durada", - "trackNumber": "#", - "playCount": "Reproduccions", - "title": "Títol", - "artist": "Artista", - "album": "Àlbum", - "path": "Ruta del fitxer", - "genre": "Gènere", - "compilation": "Compilació", - "year": "Any", - "size": "Mida del fitxer", - "updatedAt": "Actualitzat", - "bitRate": "Taxa de bits", - "discSubtitle": "Subtítol del disc", - "starred": "Preferit", - "comment": "Comentari", - "rating": "Valoració", - "quality": "Qualitat", - "bpm": "tempo", - "playDate": "Darrer resproduït", - "channels": "Canals", - "createdAt": "Data d'addició", - "grouping": "Agrupació", - "mood": "Sentiment", - "participants": "Participants", - "tags": "Etiquetes", - "mappedTags": "Etiquetes assignades", - "rawTags": "Etiquetes sense processar", - "bitDepth": "Bits", - "sampleRate": "Freqüencia de mostreig", - "missing": "Desaparegut", - "libraryName": "Biblioteca", - "composer": "Compositor", - "disc": "" - }, - "actions": { - "addToQueue": "Reprodueix després", - "playNow": "Reprodueix ara", - "addToPlaylist": "Afegeix a la llista", - "shuffleAll": "Aleatori", - "download": "Descarrega", - "playNext": "Reprodueix següent", - "info": "Obtén informació", - "showInPlaylist": "Mostra a la llista", - "instantMix": "Mescla immediata" - } + "languageName": "Català", + "resources": { + "song": { + "name": "Cançó |||| Cançons", + "fields": { + "albumArtist": "Artista de l'àlbum", + "duration": "Durada", + "trackNumber": "#", + "playCount": "Reproduccions", + "title": "Títol", + "artist": "Artista", + "album": "Àlbum", + "path": "Ruta del fitxer", + "genre": "Gènere", + "compilation": "Compilació", + "year": "Any", + "size": "Mida del fitxer", + "updatedAt": "Actualitzat", + "bitRate": "Taxa de bits", + "discSubtitle": "Subtítol del disc", + "starred": "Preferit", + "comment": "Comentari", + "rating": "Valoració", + "quality": "Qualitat", + "bpm": "tempo", + "playDate": "Darrer resproduït", + "channels": "Canals", + "createdAt": "" + }, + "actions": { + "addToQueue": "Reprodueix després", + "playNow": "Reprodueix ara", + "addToPlaylist": "Afegeix a la llista", + "shuffleAll": "Aleatori", + "download": "Descarrega", + "playNext": "Reprodueix següent", + "info": "Obtén informació" + } + }, + "album": { + "name": "Àlbum |||| Àlbums", + "fields": { + "albumArtist": "Artista de l'àlbum", + "artist": "Artista", + "duration": "Durada", + "songCount": "Cançons", + "playCount": "Reproduccions", + "name": "Nom", + "genre": "Gènere", + "compilation": "Compilació", + "year": "Any", + "updatedAt": "Actualitzat ", + "comment": "Comentari", + "rating": "Valoració", + "createdAt": "", + "size": "" + }, + "actions": { + "playAll": "Reprodueix", + "playNext": "Reprodueix la següent", + "addToQueue": "Reprodueix després", + "shuffle": "Aleatori", + "addToPlaylist": "Afegeix a la llista", + "download": "Descarrega", + "info": "Obtén informació", + "share": "" + }, + "lists": { + "all": "Tot", + "random": "Aleatori", + "recentlyAdded": "Afegit fa poc", + "recentlyPlayed": "Reproduït fa poc", + "mostPlayed": "Més reproduït", + "starred": "Preferits", + "topRated": "Més ben valorades" + } + }, + "artist": { + "name": "Artista |||| Artistes", + "fields": { + "name": "Nom", + "albumCount": "Nombre d'àlbums", + "songCount": "Compte de cançons", + "playCount": "Reproduccions", + "rating": "Valoració", + "genre": "Gènere", + "size": "" + } + }, + "user": { + "name": "Usuari |||| Usuaris", + "fields": { + "userName": "Nom d'usuari", + "isAdmin": "És admin", + "lastLoginAt": "Última connexió", + "updatedAt": "Actualitzat", + "name": "Nom", + "password": "Contrasenya", + "createdAt": "Creat", + "changePassword": "Canviar la contrasenya?", + "currentPassword": "Contrasenya actual", + "newPassword": "Contrasenya nova", + "token": "Token" + }, + "helperTexts": { + "name": "Els canvis en el nom s'hi aplicaran en la següent connexió" + }, + "notifications": { + "created": "Usuari creat", + "updated": "Usuari actualitzat", + "deleted": "Usuari eliminat" + }, + "message": { + "listenBrainzToken": "Introduïu el vostre token d'usuari de ListenBrainz", + "clickHereForToken": "Feu clic ací per a obtenir el vostre token" + } + }, + "player": { + "name": "Reproductor |||| Reproductors", + "fields": { + "name": "Nom", + "transcodingId": "Transcodificador", + "maxBitRate": "Taxa de bits màx.", + "client": "Client", + "userName": "Nom d'usuari", + "lastSeen": "Vist", + "reportRealPath": "Informa de la ruta real", + "scrobbleEnabled": "Activa el seguiment des de serveis externs" + } + }, + "transcoding": { + "name": "Transcodificador |||| Transcodificadors", + "fields": { + "name": "Nom", + "targetFormat": "Format desitjat", + "defaultBitRate": "Taxa de bits per defecte", + "command": "Ordre" + } + }, + "playlist": { + "name": "Llista |||| Llistes", + "fields": { + "name": "Nom", + "duration": "Durada", + "ownerName": "Propietari", + "public": "Públic", + "updatedAt": "Actualitzat ", + "createdAt": "Creat", + "songCount": "Cançons", + "comment": "Comentari", + "sync": "Auto-importació", + "path": "Importa de" + }, + "actions": { + "selectPlaylist": "Selecciona una llista:", + "addNewPlaylist": "Crea \"%{nom}", + "export": "Exporta", + "makePublic": "Fes públic", + "makePrivate": "Fes privat" + }, + "message": { + "duplicate_song": "Afegeix cançons duplicades", + "song_exist": "Heu afegit duplicats a la llista. Voleu afegir-los o ignorar-los?" + } + }, + "radio": { + "name": "", + "fields": { + "name": "", + "streamUrl": "", + "homePageUrl": "", + "updatedAt": "", + "createdAt": "" + }, + "actions": { + "playNow": "" + } + }, + "share": { + "name": "", + "fields": { + "username": "", + "url": "", + "description": "", + "contents": "", + "expiresAt": "", + "lastVisitedAt": "", + "visitCount": "", + "format": "", + "maxBitRate": "", + "updatedAt": "", + "createdAt": "", + "downloadable": "" + } + } }, - "album": { - "name": "Àlbum |||| Àlbums", - "fields": { - "albumArtist": "Artista de l'àlbum", - "artist": "Artista", - "duration": "Durada", - "songCount": "Cançons", - "playCount": "Reproduccions", - "name": "Nom", - "genre": "Gènere", - "compilation": "Compilació", - "year": "Any", - "updatedAt": "Actualitzat ", - "comment": "Comentari", - "rating": "Valoració", - "createdAt": "Data d'addició", - "size": "Mida", - "originalDate": "Original", - "releaseDate": "Publicat", - "releases": "LLançament |||| Llançaments", - "released": "Publicat", - "recordLabel": "Discogràfica", - "catalogNum": "Número de catàleg", - "releaseType": "Tipus de publicació", - "grouping": "Agrupació", - "media": "Mitjà", - "mood": "Sentiment", - "date": "Data d'enregistrament", - "missing": "Desaparegut", - "libraryName": "Biblioteca" - }, - "actions": { - "playAll": "Reprodueix", - "playNext": "Reprodueix la següent", - "addToQueue": "Reprodueix després", - "shuffle": "Aleatori", - "addToPlaylist": "Afegeix a la llista", - "download": "Descarrega", - "info": "Obtén informació", - "share": "Compartir" - }, - "lists": { - "all": "Tot", - "random": "Aleatori", - "recentlyAdded": "Afegits recentment", - "recentlyPlayed": "Reproduïts recentment", - "mostPlayed": "Més reproduïts", - "starred": "Preferits", - "topRated": "Més ben valorats" - } - }, - "artist": { - "name": "Artista |||| Artistes", - "fields": { - "name": "Nom", - "albumCount": "Nombre d'àlbums", - "songCount": "Nombre de cançons", - "playCount": "Reproduccions", - "rating": "Valoració", - "genre": "Gènere", - "size": "Mida", - "role": "Rol", - "missing": "Desaparegut" - }, - "roles": { - "albumartist": "Artista de l'Àlbum |||| Artistes de l'Àlbum", - "artist": "Artista |||| Artistes", - "composer": "Compositor |||| Compositors", - "conductor": "Director |||| Directors", - "lyricist": "Lletrista |||| Lletristes", - "arranger": "Arranjador |||| Arranjadors", - "producer": "Productor |||| Productors", - "director": "Director |||| Directors", - "engineer": "Enginyer |||| Enginyers", - "mixer": "Mesclador |||| Mescladors", - "remixer": "Remesclador |||| Remescladors", - "djmixer": "Mesclador DJ |||| Mescladors DJ", - "performer": "Intèrpret |||| Intèrprets", - "maincredit": "Artista de l'àlbum or Artista |||| Artistes de l'àlbum or Artistes" - }, - "actions": { - "shuffle": "Barreja", - "radio": "Ràdio", - "topSongs": "Cançons populars" - } - }, - "user": { - "name": "Usuari |||| Usuaris", - "fields": { - "userName": "Nom d'usuari", - "isAdmin": "És admin", - "lastLoginAt": "Última connexió", - "updatedAt": "Actualitzat", - "name": "Nom", - "password": "Contrasenya", - "createdAt": "Creat", - "changePassword": "Canviar la contrasenya?", - "currentPassword": "Contrasenya actual", - "newPassword": "Contrasenya nova", - "token": "Token", - "lastAccessAt": "Últim accés", - "libraries": "Biblioteques" - }, - "helperTexts": { - "name": "Els canvis en el nom s'hi aplicaran en la següent connexió", - "libraries": "Seleccioneu biblioteques específiques per a aquest usuari o deixeu-ho buit per utilitzar les biblioteques predeterminades" - }, - "notifications": { - "created": "Usuari creat", - "updated": "Usuari actualitzat", - "deleted": "Usuari eliminat" - }, - "message": { - "listenBrainzToken": "Introduïu el vostre token d'usuari de ListenBrainz", - "clickHereForToken": "Feu clic ací per a obtenir el vostre token", - "selectAllLibraries": "Selecciona totes les biblioteques", - "adminAutoLibraries": "Els administradors tenen accés a totes les biblioteques automàticament" - }, - "validation": { - "librariesRequired": "Cal que trieu almenys una biblioteca per als usuaris que no siguin administradors" - } - }, - "player": { - "name": "Reproductor |||| Reproductors", - "fields": { - "name": "Nom", - "transcodingId": "Transcodificador", - "maxBitRate": "Taxa de bits màx.", - "client": "Client", - "userName": "Nom d'usuari", - "lastSeen": "Vist", - "reportRealPath": "Informa de la ruta real", - "scrobbleEnabled": "Activa el seguiment des de serveis externs" - } - }, - "transcoding": { - "name": "Transcodificador |||| Transcodificadors", - "fields": { - "name": "Nom", - "targetFormat": "Format desitjat", - "defaultBitRate": "Taxa de bits per defecte", - "command": "Ordre" - } - }, - "playlist": { - "name": "Llista |||| Llistes", - "fields": { - "name": "Nom", - "duration": "Durada", - "ownerName": "Propietari", - "public": "Públic", - "updatedAt": "Actualitzat ", - "createdAt": "Creat", - "songCount": "Cançons", - "comment": "Comentari", - "sync": "Auto-importació", - "path": "Importa de" - }, - "actions": { - "selectPlaylist": "Selecciona una llista:", - "addNewPlaylist": "Crea \"%{nom}", - "export": "Exporta", - "makePublic": "Fes públic", - "makePrivate": "Fes privat", - "saveQueue": "Desar la cua a una llista", - "searchOrCreate": "Cerca llistes o escriu per crear-ne de noves...", - "pressEnterToCreate": "Prem Retorn per crear una nova llista", - "removeFromSelection": "Elimina de la selecció" - }, - "message": { - "duplicate_song": "Afegeix cançons duplicades", - "song_exist": "Heu afegit duplicats a la llista. Voleu afegir-los o ignorar-los?", - "noPlaylistsFound": "No s'ha trobat cap llista", - "noPlaylists": "No hi ha cap llista disponible" - } - }, - "radio": { - "name": "Ràdio |||| Ràdios", - "fields": { - "name": "Nom", - "streamUrl": "URL del flux", - "homePageUrl": "URL principal", - "updatedAt": "Actualitzat", - "createdAt": "Creat" - }, - "actions": { - "playNow": "Reprodueix" - } - }, - "share": { - "name": "Compartir |||| Compartits", - "fields": { - "username": "Compartit per", - "url": "URL", - "description": "Descripció", - "contents": "Continguts", - "expiresAt": "Caduca", - "lastVisitedAt": "Última Visita", - "visitCount": "Visites", - "format": "Format", - "maxBitRate": "Taxa de bits màx.", - "updatedAt": "Actualitzat", - "createdAt": "Creat", - "downloadable": "Permet descarregar?" - } - }, - "missing": { - "name": "Fitxer faltant |||| Fitxers Faltants", - "fields": { - "path": "Directori", - "size": "Mida", - "updatedAt": "Desaparegut", - "libraryName": "Biblioteca" - }, - "actions": { - "remove": "Eliminar", - "remove_all": "Suprimeix-ho tot" - }, - "notifications": { - "removed": "Fitxers faltants eliminats" - }, - "empty": "No falten fitxers" - }, - "library": { - "name": "Biblioteca |||| Biblioteques\n", - "fields": { - "name": "Nom", - "path": "Camí", - "remotePath": "Camí remot", - "lastScanAt": "Últim escaneig", - "songCount": "Cançons", - "albumCount": "Àlbums", - "artistCount": "Artistes", - "totalSongs": "Cançons", - "totalAlbums": "Àlbums", - "totalArtists": "Artistes", - "totalFolders": "Carpetes", - "totalFiles": "Fitxers", - "totalMissingFiles": "Fitxers desapareguts", - "totalSize": "Mida total", - "totalDuration": "Durada", - "defaultNewUsers": "Predeterminat per a usuaris nous", - "createdAt": "Creat", - "updatedAt": "Actualitzat" - }, - "sections": { - "basic": "Informació bàsica", - "statistics": "Estadístiques" - }, - "actions": { - "scan": "Escaneja la biblioteca", - "manageUsers": "Gestiona l'accés d'usuari", - "viewDetails": "Mostra els detalls", - "quickScan": "Escaneig ràpid", - "fullScan": "Escaneig complet" - }, - "notifications": { - "created": "La biblioteca s'ha creat correctament", - "updated": "La biblioteca s'ha actualitzat correctament", - "deleted": "La biblioteca s'ha suprimit correctament", - "scanStarted": "Començant l'escaneig de la biblioteca", - "scanCompleted": "S'ha completat l'escaneig de la biblioteca", - "quickScanStarted": "Començant escaneig ràpid", - "fullScanStarted": "Començant escaneig complet", - "scanError": "S'ha produït un error en començar l'escaneig. Comproveu els registres" - }, - "validation": { - "nameRequired": "Es requereix un nom per la biblioteca", - "pathRequired": "Es requereix un camí a la biblioteca", - "pathNotDirectory": "El camí a la biblioteca ha de ser un directori", - "pathNotFound": "No s'ha trobat el camí a la biblioteca", - "pathNotAccessible": "No es pot accedir al camí de la biblioteca ", - "pathInvalid": "Camí a la llibreria no vàlid" - }, - "messages": { - "deleteConfirm": "Esteu segur que voleu suprimir aquesta biblioteca? Se'n suprimiran totes les dades associades i els accessos d'usuari.", - "scanInProgress": "Escaneig en curs...", - "noLibrariesAssigned": "Aquest usuari no té cap biblioteca assignada" - } - }, - "plugin": { - "name": "\nConnector |||| Connectors", - "fields": { - "id": "ID", - "name": "Nom", - "description": "Descripció", - "version": "Versió", - "author": "Autor", - "website": "Lloc web", - "permissions": "Permissos", - "enabled": "Activat", - "status": "Estat", - "path": "Camí", - "lastError": "Error", - "hasError": "Error", - "updatedAt": "Actualitzat", - "createdAt": "Instal·lat", - "configKey": "Clau", - "configValue": "Valor", - "allUsers": "Permet tots els usuaris", - "selectedUsers": "Usuaris seleccionats", - "allLibraries": "Permet totes les llibreries", - "selectedLibraries": "Biblioteques seleccionades", - "allowWriteAccess": "" - }, - "sections": { - "status": "Estat", - "info": "Informació del controlador", - "configuration": "Configuració", - "manifest": "Manifest", - "usersPermission": "Permís dels usuaris", - "libraryPermission": "Permís de la llibreria" - }, - "status": { - "enabled": "Activat", - "disabled": "Desactivat" - }, - "actions": { - "enable": "Activa", - "disable": "Desactiva", - "disabledDueToError": "Arregleu l'error abans de l'activació", - "disabledUsersRequired": "Seleccioneu els usuaris abans de l'activació", - "disabledLibrariesRequired": "Seleccioneu les biblioteques abans de l'activació", - "addConfig": "Afegeix una configuració", - "rescan": "Torna a escanejar" - }, - "notifications": { - "enabled": "Controlador activat", - "disabled": "Controlador desactivat", - "updated": "Controlador activat", - "error": "S'ha produït un error en actualitzar el controlador" - }, - "validation": { - "invalidJson": "El fitxer Configuració ha de ser un JSON vàlid" - }, - "messages": { - "configHelp": "Configureu el controlador utilitzant parelles clau-valor. Deixeu-ho buit si el controlador no requereix cap configuració.", - "clickPermissions": "Feu clic en un permís per veure’n els detalls", - "noConfig": "No s'ha establert cap configuració", - "allUsersHelp": "Quan està activat, el controlador té accés a tots els usuaris, inclosos els creats a posteriori.", - "noUsers": "No s'ha seleccionat cap usuari", - "permissionReason": "Motiu", - "usersRequired": "Aquest controlador necessita accedir a la informació de la biblioteca. Selecciona a quines biblioteques pot accedir o activa «Permet tots els usuaris».", - "allLibrariesHelp": "Quan està activat, el controlador té accés a totes les llibreries, incloses les creades a posteriori.", - "noLibraries": "No s'ha seleccionat cap biblioteca", - "librariesRequired": "Aquest controlador necessita accedir a la informació de la biblioteca. Selecciona a quines biblioteques pot accedir o activa «Permet totes les biblioteques».", - "requiredHosts": "Hosts requerits", - "configValidationError": "Ha fallat la validació de la configuració:", - "schemaRenderError": "No s'ha pogut renderitzar el formulari de configuració. És possible que l'esquema del controlador sigui invàlid.", - "allowWriteAccessHelp": "" - }, - "placeholders": { - "configKey": "clau", - "configValue": "valor" - } - } - }, - "ra": { - "auth": { - "welcome1": "Gràcies d'haver instal·lat Navidrome!", - "welcome2": "Per a començar, creeu un usuari administrador", - "confirmPassword": "Confirmeu la contrasenya", - "buttonCreateAdmin": "Crea un administrador", - "auth_check_error": "Si us plau, inicieu sessió per a continuar", - "user_menu": "Perfil", - "username": "Nom d'usuari", - "password": "Contrasenya", - "sign_in": "Inicia sessió", - "sign_in_error": "L'autenticació ha fallat, torneu-ho a intentar", - "logout": "Sortida", - "insightsCollectionNote": "Navidrome recull dades d'us anonimitzades per\najudar a millorar el projecte. Clica [aquí] per a saber-ne\nmés i no participar-hi si no vols" - }, - "validation": { - "invalidChars": "Si us plau, utilitzeu només lletres i nombres", - "passwordDoesNotMatch": "Les contrasenyes no coincideixen", - "required": "Obligatori", - "minLength": "Ha de tenir, si més no, %{min} caràcters", - "maxLength": "Ha de tenir %{max} caràcters o menys", - "minValue": "Ha de ser com a mínim %{min}", - "maxValue": "Ha de ser %{max} o menys", - "number": "Ha de ser un nombre", - "email": "Ha de ser un correu vàlid", - "oneOf": "Ha de ser un de: %{options}", - "regex": "Ha de tenir el format (regexp): %{pattern}", - "unique": "Ha de ser únic", - "url": "Ha de ser una URL vàlida" - }, - "action": { - "add_filter": "Afegeix un filtre", - "add": "Afegeix", - "back": "Enrere", - "bulk_actions": "1 element seleccionat |||| %{smart_count} elements seleccionats", - "cancel": "Cancel·la", - "clear_input_value": "Neteja el valor", - "clone": "Clona", - "confirm": "Confirma", - "create": "Crea", - "delete": "Suprimeix", - "edit": "Edita", - "export": "Exporta", - "list": "Llista", - "refresh": "Refresca", - "remove_filter": "Suprimeix aquest filtre", - "remove": "Elimina", - "save": "Desa", - "search": "Cerca", - "show": "Mostra", - "sort": "Ordena", - "undo": "Desfés", - "expand": "Expandeix", - "close": "Tanca", - "open_menu": "Obre el menú", - "close_menu": "Tanca el menú", - "unselect": "Anul·la la selecció", - "skip": "Omet", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Compartir", - "download": "Descarregar" - }, - "boolean": { - "true": "Sí", - "false": "No" - }, - "page": { - "create": "Crea %{nom}", - "dashboard": "Tauler", - "edit": "%{name} #%{id}", - "error": "Alguna cosa ha fallat", - "list": "%{name}", - "loading": "Ara es carrega", - "not_found": "No s'ha trobat", - "show": "%{name} #%{id}", - "empty": "No hi ha %{name} encara.", - "invite": "Voleu afegir-ne una?" - }, - "input": { - "file": { - "upload_several": "Deixeu caure-hi fitxers per a carregar-los o feu clic per a seleccionar-ne un.", - "upload_single": "Deixeu caure-hi un fitxer per a carregar o feu clic per a seleccionar-lo." - }, - "image": { - "upload_several": "Deixeu caure-hi imatges per a carregar-les o feu clic per a seleccionar-ne una.", - "upload_single": "Deixeu caure-hi una imatge per a carregar-la o feu clic per a seleccionar-la." - }, - "references": { - "all_missing": "No ha estat possible trobar les dades de referència.", - "many_missing": "Sembla que almenys una de les referències associades ja no està disponible.", - "single_missing": "Sembla que la referència associada ja no està disponible." - }, - "password": { - "toggle_visible": "Amaga la contrasenya", - "toggle_hidden": "Mostra la contrasenya" - } + "ra": { + "auth": { + "welcome1": "Gràcies d'haver instal·lat Navidrome!", + "welcome2": "Per a començar, creeu un usuari administrador", + "confirmPassword": "Confirmeu la contrasenya", + "buttonCreateAdmin": "Crea un administrador", + "auth_check_error": "Si us plau, inicieu sessió per a continuar", + "user_menu": "Perfil", + "username": "Nom d'usuari", + "password": "Contrasenya", + "sign_in": "Inicia sessió", + "sign_in_error": "L'autenticació ha fallat, torneu-ho a intentar", + "logout": "Sortida" + }, + "validation": { + "invalidChars": "Si us plau, useu solament lletres i nombres", + "passwordDoesNotMatch": "Les contrasenyes no coincideixen", + "required": "Obligatori", + "minLength": "Ha de tenir, si més no, %{min} caràcters", + "maxLength": "Ha de tenir %{max} caràcter o menys", + "minValue": "Ha de ser si més no %{min}", + "maxValue": "Ha de ser %{max} o menys", + "number": "Ha de ser un nombre", + "email": "Ha de ser un correu vàlid", + "oneOf": "Ha de ser un de: %{options}", + "regex": "Ha de tenir el format (regexp): %{pattern}", + "unique": "Ha de ser únic", + "url": "" + }, + "action": { + "add_filter": "Afegeix un filtre", + "add": "Afegeix", + "back": "Enrere", + "bulk_actions": "1 element seleccionat |||| %{smart_count} elements seleccionats", + "cancel": "Cancel·la", + "clear_input_value": "Neteja el valor", + "clone": "Clona", + "confirm": "Confirma", + "create": "Crea", + "delete": "Suprimeix", + "edit": "Edita", + "export": "Exporta", + "list": "Llista", + "refresh": "Refresca", + "remove_filter": "Suprimeix aquest filtre", + "remove": "Elimina", + "save": "Desa", + "search": "Cerca", + "show": "Mostra", + "sort": "Ordena", + "undo": "Desfés", + "expand": "Expandeix", + "close": "Tanca", + "open_menu": "Obre el menú", + "close_menu": "Tanca el menú", + "unselect": "Anul·la la selecció", + "skip": "Omet", + "bulk_actions_mobile": "", + "share": "", + "download": "" + }, + "boolean": { + "true": "Sí", + "false": "No" + }, + "page": { + "create": "Crea %{nom}", + "dashboard": "Tauler", + "edit": "%{name} #%{id}", + "error": "Alguna cosa ha fallat", + "list": "%{name}", + "loading": "Ara es carrega", + "not_found": "No s'ha trobat", + "show": "%{name} #%{id}", + "empty": "No hi ha %{name} encara.", + "invite": "Voleu afegir-ne una?" + }, + "input": { + "file": { + "upload_several": "Deixeu caure-hi fitxers per a carregar-los o feu clic per a seleccionar-ne un.", + "upload_single": "Deixeu caure-hi un fitxer per a carregar o feu clic per a seleccionar-lo." + }, + "image": { + "upload_several": "Deixeu caure-hi imatges per a carregar-les o feu clic per a seleccionar-ne una.", + "upload_single": "Deixeu caure-hi una imatge per a carregar-la o feu clic per a seleccionar-la." + }, + "references": { + "all_missing": "No ha estat possible trobar les dades de referència.", + "many_missing": "Sembla que almenys una de les referències associades ja no està disponible.", + "single_missing": "Sembla que la referència associada ja no està disponible." + }, + "password": { + "toggle_visible": "Amaga la contrasenya", + "toggle_hidden": "Mostra la contrasenya" + } + }, + "message": { + "about": "Quant a...", + "are_you_sure": "N'esteu segur?", + "bulk_delete_content": "Voleu eliminar aquest %{name}? |||| Voleu eliminar aquests %{smart_count} element?\n", + "bulk_delete_title": "Esborra %{name} |||| Esborra %{smart_count} %{name}", + "delete_content": "Segur que voleu eliminar aquest element?", + "delete_title": "Elimina %{name} #%{id}", + "details": "Detalls", + "error": "S'ha produït un error en un client i la vostra sol·licitud no ha pogut ser completada.", + "invalid_form": "El formulari no és vàlid.", + "loading": "La pàgina es carrega, un moment si us plau.", + "no": "No", + "not_found": "La URL és incorrecta o heu seguit un enllaç erroni.", + "yes": "Sí", + "unsaved_changes": "Alguns canvis no s'hi han desat. Segur que voleu ignorar-los?" + }, + "navigation": { + "no_results": "No s'ha trobat", + "no_more_results": "La pàgina número %{page} no existeix. Proveu l'anterior.", + "page_out_of_boundaries": "La pàgina número %{page} no existeix", + "page_out_from_end": "No podeu anar més enllà de la darrera pàgina", + "page_out_from_begin": "No podeu anar més enllà de la primera pàgina", + "page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}", + "page_rows_per_page": "Elements per pàgina:", + "next": "Següent", + "prev": "Anterior", + "skip_nav": "Salta al contingut" + }, + "notification": { + "updated": "Element actualitzat |||| %{smart_count} elements actualitzats", + "created": "Element creat", + "deleted": "Element actualitzat |||| %{smart_count} elements actualitzats", + "bad_item": "Element incorrecte", + "item_doesnt_exist": "L'element no existeix", + "http_error": "Error de comunicació del servidor", + "data_provider_error": "dataProvider error. Vegeu la consola si en voleu més detalls.", + "i18n_error": "No ha estat possible carregar les traduccions per a l'idioma indicat", + "canceled": "Acció cancel·lada", + "logged_out": "La sessió ha acabat, si us plau reconnecteu", + "new_version": "Hi ha una versió nova disponible! Si us plau refresqueu aquesta finestra." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Columnes a mostrar", + "layout": "Disposició", + "grid": "Quadrícula", + "table": "Taula" + } }, "message": { - "about": "Quant a...", - "are_you_sure": "N'esteu segur?", - "bulk_delete_content": "Voleu eliminar aquest %{name}? |||| Voleu eliminar aquests %{smart_count} element?\n", - "bulk_delete_title": "Esborra %{name} |||| Esborra %{smart_count} %{name}", - "delete_content": "Segur que voleu eliminar aquest element?", - "delete_title": "Elimina %{name} #%{id}", - "details": "Detalls", - "error": "S'ha produït un error en un client i la vostra sol·licitud no ha pogut ser completada.", - "invalid_form": "El formulari no és vàlid.", - "loading": "La pàgina es carrega, un moment si us plau.", - "no": "No", - "not_found": "La URL és incorrecta o heu seguit un enllaç erroni.", - "yes": "Sí", - "unsaved_changes": "Alguns canvis no s'hi han desat. Segur que voleu ignorar-los?" - }, - "navigation": { - "no_results": "No s'ha trobat", - "no_more_results": "La pàgina número %{page} no existeix. Proveu l'anterior.", - "page_out_of_boundaries": "La pàgina número %{page} no existeix", - "page_out_from_end": "No podeu anar més enllà de la darrera pàgina", - "page_out_from_begin": "No podeu anar més enllà de la primera pàgina", - "page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}", - "page_rows_per_page": "Elements per pàgina:", - "next": "Següent", - "prev": "Anterior", - "skip_nav": "Salta al contingut" - }, - "notification": { - "updated": "Element actualitzat |||| %{smart_count} elements actualitzats", - "created": "Element creat", - "deleted": "Element actualitzat |||| %{smart_count} elements actualitzats", - "bad_item": "Element incorrecte", - "item_doesnt_exist": "L'element no existeix", - "http_error": "Error de comunicació del servidor", - "data_provider_error": "dataProvider error. Vegeu la consola si en voleu més detalls.", - "i18n_error": "No ha estat possible carregar les traduccions per a l'idioma indicat", - "canceled": "Acció cancel·lada", - "logged_out": "La sessió ha acabat, si us plau reconnecteu", - "new_version": "Hi ha una versió nova disponible! Si us plau actualitzeu aquesta finestra." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Columnes a mostrar", - "layout": "Disposició", - "grid": "Quadrícula", - "table": "Taula" - } - }, - "message": { - "note": "NOTA", - "transcodingDisabled": "Per motius de seguretat, el canvi de configuració del transcodificador amb la interfície web està desactivat. Si voleu canviar les opcions de transcodificació (sia editar-les sia afegir-ne), reinicieu el servidor amb l'opció %{config}.", - "transcodingEnabled": "Navidrome s'executa amb %{config}, cosa que fa possible executar ordres del sistema des de les opcions de transcodificació usant la interfície web. Per motius de seguretat us recomanem que només l'activeu quan necessiteu configurar les opcions de transcodificació.", - "songsAddedToPlaylist": "S'ha afegit 1 cançó a la llista |||| S'han afegit %{smart_count} a la llista", - "noPlaylistsAvailable": "No n'hi ha cap disponible", - "delete_user_title": "Esborra usuari '%{nom}'", - "delete_user_content": "Segur que voleu eliminar aquest usuari i les seues dades\n(incloent-hi llistes i preferències)", - "notifications_blocked": "Heu blocat les notificacions d'escriptori en les preferències del navegador", - "notifications_not_available": "El navegador no és compatible amb les notificacions o no us heu connectat a Navidrome per https", - "lastfmLinkSuccess": "Ha reexit la vinculació amb Last.fm i se n'ha activat el seguiment", - "lastfmLinkFailure": "No ha estat possible la vinculació amb Last.fm", - "lastfmUnlinkSuccess": "Desvinculat de Last.fm i desactivat el seguiment", - "lastfmUnlinkFailure": "No s'ha pogut desvincular de Last.fm", - "openIn": { - "lastfm": "Obri en Last.fm", - "musicbrainz": "Obri en MusicBrainz" - }, - "lastfmLink": "Llegeix més...", - "listenBrainzLinkSuccess": "Connectat correctament a ListenBrainz i seguiment activat com a: %{user}", - "listenBrainzLinkFailure": "No s'ha pogut connectar a ListenBrainz: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz desconnectat i seguiment desactivat", - "listenBrainzUnlinkFailure": "No s'ha pogut desconnectar de ListenBrainz", - "downloadOriginalFormat": "Descarregar en el format original", - "shareOriginalFormat": "Compartir en format original", - "shareDialogTitle": "Compartir %{resource} '%{name}'", - "shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}", - "shareSuccess": "URL copiada al porta-retalls: %{url}", - "shareFailure": "Error copiant URL %{url} al porta-retalls", - "downloadDialogTitle": "Deascarregar %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Copiar al porta-retalls: Ctrl+C, Enter", - "remove_missing_title": "Eliminar fitxers faltants", - "remove_missing_content": "Segur que vols eliminar els fitxers faltants seleccionats de la base de dades? Això eliminarà permanentment les referències a ells, incloent-hi el nombre de reproduccions i les valoracions.", - "remove_all_missing_title": "Suprimir tots els fitxers perduts", - "remove_all_missing_content": "Esteu segur que voleu eliminar tots els fitxers desapareguts de la base de dades? Se n'eliminarà permanentment qualsevol referència, inclosos el nombre de reproduccions i les puntuacions.", - "noSimilarSongsFound": "No s'ha trobat cap cançó similar", - "noTopSongsFound": "No s'ha trobat cap cançó popular", - "startingInstantMix": "S'està carregant la mescla immediata..." - }, - "menu": { - "library": "Biblioteca", - "settings": "Configuració", - "version": "Versió", - "theme": "Tema", - "personal": { - "name": "Personal", - "options": { - "theme": "Tema", - "language": "Llengua", - "defaultView": "Vista per defecte", - "desktop_notifications": "Notificacions d'escriptori", - "lastfmScrobbling": "Activa el seguiment de Last.fm", - "listenBrainzScrobbling": "Activa el seguiment de ListenBrainz", - "replaygain": "Mode ReplayGain", - "preAmp": "PreAmp de ReplayGain (dB)", - "gain": { - "none": "Cap", - "album": "Guany de l'àlbum", - "track": "Guany de la pista" + "note": "NOTA", + "transcodingDisabled": "Per motius de seguretat, el canvi de configuració del trasnscodificador amb la interfície web no està habilitat. Si voleu canviar les opcions de transcodificació (sia editar-les sia afegir-ne), reinicieu el servidor amb l'opció %{config}.", + "transcodingEnabled": "Ara Navidrome s'executa amb %{config}, cosa que fa possible executar ordres del sistema des de les opcions de transcodificació usant la interfície web. Per motius de seguretat us recomanem que només l'activeu quan necessiteu configurar les opcions de transcodificació.", + "songsAddedToPlaylist": "S'ha afegit 1 cançó a la llista |||| S'han afegit %{smart_count} a la llista", + "noPlaylistsAvailable": "No n'hi ha cap disponible", + "delete_user_title": "Esborra usuari '%{nom}'", + "delete_user_content": "Segur que voleu eliminar aquest usuari i les seues dades\n(incloent-hi llistes i preferències)", + "notifications_blocked": "Heu blocat les notificacions d'escriptori en les preferències del navegador", + "notifications_not_available": "El navegador no suporta les notificacions o no heu connectat a Navidrome per https", + "lastfmLinkSuccess": "Ha reexit la vinculació amb Last.fm i se n'ha activat el seguiment", + "lastfmLinkFailure": "No ha estat possible la vinculació amb Last.fm", + "lastfmUnlinkSuccess": "Desvinculat de Last.fm i desactivat el seguiment", + "lastfmUnlinkFailure": "No s'ha pogut desvincular de Last.fm", + "openIn": { + "lastfm": "Obri en Last.fm", + "musicbrainz": "Obri en MusicBrainz" }, - "lastfmNotConfigured": "No s'ha configurat l'API de Last.fm" - } + "lastfmLink": "Llegeix més...", + "listenBrainzLinkSuccess": "Ha reexit la vinculació amb ListenBrainz i se n'ha activat el seguiment com a usuari: %{user}", + "listenBrainzLinkFailure": "No ha estat possible vincular-se a ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "Desvinculat de ListenBrainz i desactivat el seguiment", + "listenBrainzUnlinkFailure": "No s'ha pogut desvincular de ListenBrainz", + "downloadOriginalFormat": "", + "shareOriginalFormat": "", + "shareDialogTitle": "", + "shareBatchDialogTitle": "", + "shareSuccess": "", + "shareFailure": "", + "downloadDialogTitle": "", + "shareCopyToClipboard": "" }, - "albumList": "Àlbums", - "about": "Quant a...", - "playlists": "Llistes", - "sharedPlaylists": "Llistes compartides", - "librarySelector": { - "allLibraries": "Totes les llibreries (%{count})", - "multipleLibraries": "%{selected} de %{total} Biblioteques", - "selectLibraries": "Selecciona les biblioteques", - "none": "Cap" - } - }, - "player": { - "playListsText": "Reprodueix la cua", - "openText": "Obre", - "closeText": "Tanca", - "notContentText": "No hi ha música", - "clickToPlayText": "Feu clic per a reproduir", - "clickToPauseText": "Feu clic per a posar en pausa", - "nextTrackText": "Pista següent", - "previousTrackText": "Pista anterior", - "reloadText": "Recarrega", - "volumeText": "Volum", - "toggleLyricText": "Activa / desactiva lletra", - "toggleMiniModeText": "Minimitza", - "destroyText": "Destrueix", - "downloadText": "Descarrega", - "removeAudioListsText": "Elimina llistes d'àudio", - "clickToDeleteText": "Feu clic per a eliminar %{name}", - "emptyLyricText": "Sense lletra", - "playModeText": { - "order": "En ordre", - "orderLoop": "Repeteix", - "singleLoop": "Repeteix una vegada", - "shufflePlay": "Aleatori" - } - }, - "about": { - "links": { - "homepage": "Inici", - "source": "Codi font", - "featureRequests": "Sol·licita funcionalitats", - "lastInsightsCollection": "Última recolecció d'informació", - "insights": { - "disabled": "Desactivada", - "waiting": "Esperant" - } + "menu": { + "library": "Discoteca", + "settings": "Configuració", + "version": "Versió", + "theme": "Tema", + "personal": { + "name": "Personal", + "options": { + "theme": "Tema", + "language": "Llengua", + "defaultView": "Vista per defecte", + "desktop_notifications": "Notificacions d'escriptori", + "lastfmScrobbling": "Activa el seguiment de Last.fm", + "listenBrainzScrobbling": "Activa el seguiment de ListenBrainz", + "replaygain": "", + "preAmp": "", + "gain": { + "none": "", + "album": "", + "track": "" + } + } + }, + "albumList": "Àlbums", + "about": "Quant a...", + "playlists": "Llistes", + "sharedPlaylists": "Llistes compartides" }, - "tabs": { - "about": "Quant a", - "config": "Configuració" + "player": { + "playListsText": "Reprodueix la cua", + "openText": "Obre", + "closeText": "Tanca", + "notContentText": "No hi ha música", + "clickToPlayText": "Feu clic per a reproduir", + "clickToPauseText": "Feu clic per a posar en pausa", + "nextTrackText": "Pista següent", + "previousTrackText": "Pista anterior", + "reloadText": "Recarrega", + "volumeText": "Volum", + "toggleLyricText": "Activa / desactiva lletra", + "toggleMiniModeText": "Minimitza", + "destroyText": "Destrueix", + "downloadText": "Descarrega", + "removeAudioListsText": "Elimina llistes d'àudio", + "clickToDeleteText": "Feu clic per a eliminar %{name}", + "emptyLyricText": "Sense lletra", + "playModeText": { + "order": "En ordre", + "orderLoop": "Repeteix", + "singleLoop": "Repeteix una vegada", + "shufflePlay": "Aleatori" + } }, - "config": { - "configName": "Nom de Config", - "environmentVariable": "Variable d'entorn", - "currentValue": "Valor actual", - "configurationFile": "Fitxer de configuració", - "exportToml": "Exporta la configuració (TOML)", - "exportSuccess": "Configuració exportada al porta-retalls en format TOML", - "exportFailed": "La còpia de la configuració ha fallat", - "devFlagsHeader": "Indicadors de desenvolupament (subjecte a canvis o eliminació)", - "devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures", - "downloadToml": "Descarrega la configuració (TOML)" + "about": { + "links": { + "homepage": "Inici", + "source": "Codi font", + "featureRequests": "Sol·licitud de funcionalitats" + } + }, + "activity": { + "title": "Activitat", + "totalScanned": "Carpetes escanejades en total", + "quickScan": "Escaneig ràpid", + "fullScan": "Escaneig complet", + "serverUptime": "Temps de funcionament del servidor", + "serverDown": "Sense connexió" + }, + "help": { + "title": "Dreceres de teclat de Navidrome", + "hotkeys": { + "show_help": "Mostra aquesta ajuda", + "toggle_menu": "Commuta la barra lateral", + "toggle_play": "Reprodueix / Pausa", + "prev_song": "Cançó anterior", + "next_song": "Cançó següent", + "vol_up": "Apuja el volum", + "vol_down": "Abaixa el volum", + "toggle_love": "Afegeix la pista a favorits", + "current_song": "" + } } - }, - "activity": { - "title": "Activitat", - "totalScanned": "Carpetes escanejades en total", - "quickScan": "Escaneig ràpid", - "fullScan": "Escaneig complet", - "serverUptime": "Temps de funcionament del servidor", - "serverDown": "Sense connexió", - "scanType": "Últim escaneig", - "status": "Error d'escaneig", - "elapsedTime": "Temps transcorregut", - "selectiveScan": "Selectiu" - }, - "help": { - "title": "Dreceres de teclat de Navidrome", - "hotkeys": { - "show_help": "Mostra aquesta ajuda", - "toggle_menu": "Commuta la barra lateral", - "toggle_play": "Reprodueix / Pausa", - "prev_song": "Cançó anterior", - "next_song": "Cançó següent", - "vol_up": "Apuja el volum", - "vol_down": "Abaixa el volum", - "toggle_love": "Afegeix la pista a favorits", - "current_song": "Anar a la cançó actual" - } - }, - "nowPlaying": { - "title": "Està sonant", - "empty": "No s'està reproduint res", - "minutesAgo": "Fa %{smart_count} minut |||| Fa %{smart_count} minuts" - } -} +} \ No newline at end of file diff --git a/resources/i18n/cs.json b/resources/i18n/cs.json index fc2cc959d..5c0a8e78a 100644 --- a/resources/i18n/cs.json +++ b/resources/i18n/cs.json @@ -26,7 +26,7 @@ "bpm": "BPM", "playDate": "Poslední přehravaná skladba", "channels": "Kanály", - "createdAt": "Přidáno" + "createdAt": "" }, "actions": { "addToQueue": "Přehrát později", @@ -53,12 +53,8 @@ "updatedAt": "Aktualizováno", "comment": "Komentář", "rating": "Hodnocení", - "createdAt": "Přidáno", - "size": "Velikost", - "originalDate": "Původní", - "releaseDate": "Vydáno", - "releases": "Vydání |||| Vydání", - "released": "Vydáno" + "createdAt": "", + "size": "" }, "actions": { "playAll": "Přehrát", @@ -68,7 +64,7 @@ "addToPlaylist": "Přidat do seznamu skladeb", "download": "Stáhnout", "info": "Získat informace", - "share": "Sdílet" + "share": "" }, "lists": { "all": "Všechno", @@ -89,7 +85,7 @@ "playCount": "Přehrání", "rating": "Hodnocení", "genre": "Žánr", - "size": "Velikost" + "size": "" } }, "user": { @@ -169,33 +165,33 @@ } }, "radio": { - "name": "Rádio |||| Rádia", + "name": "", "fields": { - "name": "Název", - "streamUrl": "URL streamu", - "homePageUrl": "URL stránky", - "updatedAt": "Nahráno", - "createdAt": "Vytvořeno" + "name": "", + "streamUrl": "", + "homePageUrl": "", + "updatedAt": "", + "createdAt": "" }, "actions": { - "playNow": "Spustit" + "playNow": "" } }, "share": { - "name": "Sdílení |||| Sdílení", + "name": "", "fields": { - "username": "Sdíleno", - "url": "URL", - "description": "Popis", - "contents": "Obsah", - "expiresAt": "Vyprší", - "lastVisitedAt": "Naposledy navštíveno", - "visitCount": "Počet návšev", - "format": "Formát", - "maxBitRate": "Max. Bit Rate", - "updatedAt": "Nahráno", - "createdAt": "Vytvořeno", - "downloadable": "Povolit stahování?" + "username": "", + "url": "", + "description": "", + "contents": "", + "expiresAt": "", + "lastVisitedAt": "", + "visitCount": "", + "format": "", + "maxBitRate": "", + "updatedAt": "", + "createdAt": "", + "downloadable": "" } } }, @@ -226,7 +222,7 @@ "oneOf": "Musí splňovat jedno z: %{options}", "regex": "Musí být ve specifickém formátu (regexp): %{pattern}", "unique": "Musí být jedinečný", - "url": "Musí být platná URL" + "url": "" }, "action": { "add_filter": "Přidat filtr", @@ -256,9 +252,9 @@ "close_menu": "Zavřít nabídku", "unselect": "Zrušit výběr", "skip": "Přeskočit", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Sdílet", - "download": "Stáhnout" + "bulk_actions_mobile": "", + "share": "", + "download": "" }, "boolean": { "true": "Ano", @@ -366,14 +362,14 @@ "listenBrainzLinkFailure": "ListenBrainz nemohlo být připojeno: %{error}", "listenBrainzUnlinkSuccess": "ListenBrainz odpojeno a scrobblování vypnuto", "listenBrainzUnlinkFailure": "ListenBrainz nemohlo být odpojeno", - "downloadOriginalFormat": "Stáhnout v původním formátu", - "shareOriginalFormat": "Sdílet v původním formátu", - "shareDialogTitle": "Sdílet %{resource} '%{name}'", - "shareBatchDialogTitle": "Sdílet 1 %{resource} |||| Sdílet %{smart_count} %{resource}", - "shareSuccess": "URL zkopírována do schránky: %{url}", - "shareFailure": "Chyba při kopírování URL %{url} do schránky", - "downloadDialogTitle": "Stáhnout %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Zkopírovat do schránky: Ctrl+C, Enter" + "downloadOriginalFormat": "", + "shareOriginalFormat": "", + "shareDialogTitle": "", + "shareBatchDialogTitle": "", + "shareSuccess": "", + "shareFailure": "", + "downloadDialogTitle": "", + "shareCopyToClipboard": "" }, "menu": { "library": "Knihovna", @@ -389,12 +385,12 @@ "desktop_notifications": "Oznámení na ploše", "lastfmScrobbling": "Scrobblovat na Last.fm", "listenBrainzScrobbling": "Scrobblovat na ListenBrainz", - "replaygain": "Mód ReplayGain", - "preAmp": "ReplayGain PreAmp (dB)", + "replaygain": "", + "preAmp": "", "gain": { - "none": "Vypnuto", - "album": "Použít Album Gain", - "track": "Použít Track Gain" + "none": "", + "album": "", + "track": "" } } }, diff --git a/resources/i18n/da.json b/resources/i18n/da.json index edb1df183..e4aaf5c92 100644 --- a/resources/i18n/da.json +++ b/resources/i18n/da.json @@ -1,721 +1,456 @@ { - "languageName": "Dansk", - "resources": { - "song": { - "name": "Sang |||| Sange", - "fields": { - "albumArtist": "Album kunstner", - "duration": "Varighed", - "trackNumber": "#", - "playCount": "Afspilninger", - "title": "Titel", - "artist": "Kunstner", - "album": "Album navn", - "path": "Filsti", - "genre": "Genre", - "compilation": "Opsamling", - "year": "År", - "size": "Fil størrelse", - "updatedAt": "Opdateret den", - "bitRate": "Bitrate", - "discSubtitle": "Plade undertitel", - "starred": "Stjernemarkeret", - "comment": "Kommentar", - "rating": "Bedømmelse", - "quality": "Kvalitet", - "bpm": "BPM", - "playDate": "Senest afspillet", - "channels": "Kanaler", - "createdAt": "Tilføjet d.", - "grouping": "Gruppering", - "mood": "Humør", - "participants": "Yderligere deltagere", - "tags": "Yderligere tags", - "mappedTags": "Mappede tags", - "rawTags": "Rå tags", - "bitDepth": "Bitdybde", - "sampleRate": "Samplingfrekvens", - "missing": "Manglende", - "libraryName": "Bibliotek", - "composer": "Komponist", - "disc": "Disk %{discNumber}" - }, - "actions": { - "addToQueue": "Afspil senere", - "playNow": "Afspil nu", - "addToPlaylist": "Føj til afspilningsliste", - "shuffleAll": "Bland alle", - "download": "Download", - "playNext": "Afspil næste", - "info": "Hent info", - "showInPlaylist": "Vis i afspilningsliste", - "instantMix": "Instant Mix" - } + "languageName": "Dansk", + "resources": { + "song": { + "name": "Sang |||| Sange", + "fields": { + "albumArtist": "Album kunstner", + "duration": "Varighed", + "trackNumber": "#", + "playCount": "Afspilninger", + "title": "Titel", + "artist": "Kunstner", + "album": "Album navn", + "path": "Fil placering", + "genre": "Genre", + "compilation": "Samling", + "year": "År", + "size": "Fil størrelse", + "updatedAt": "Opdateret den", + "bitRate": "Bitrate", + "discSubtitle": "Plade undernavn", + "starred": "Stjernemarkeret", + "comment": "Kommentar", + "rating": "", + "quality": "", + "bpm": "", + "playDate": "", + "channels": "", + "createdAt": "" + }, + "actions": { + "addToQueue": "Afspil senere", + "playNow": "Afspil nu", + "addToPlaylist": "Tilføj til afspilningsliste", + "shuffleAll": "Bland alle", + "download": "Hent", + "playNext": "Spil næste", + "info": "" + } + }, + "album": { + "name": "Album |||| Albums", + "fields": { + "albumArtist": "Album kunstner", + "artist": "Kunstner", + "duration": "Varighed", + "songCount": "Sange", + "playCount": "Afspilninger", + "name": "Navn", + "genre": "Genre", + "compilation": "Samling", + "year": "År", + "updatedAt": "Opdateret den", + "comment": "Kommentar", + "rating": "", + "createdAt": "", + "size": "" + }, + "actions": { + "playAll": "Afspil", + "playNext": "Afspil næste", + "addToQueue": "Afspil senere", + "shuffle": "Bland", + "addToPlaylist": "Tilføj til afspilningsliste", + "download": "Hent", + "info": "", + "share": "" + }, + "lists": { + "all": "Alle", + "random": "Tilfældig", + "recentlyAdded": "Nyligt tilføjet", + "recentlyPlayed": "Nyligt Afspillet", + "mostPlayed": "Mest Afspillet", + "starred": "Stjernemarkeret", + "topRated": "" + } + }, + "artist": { + "name": "Kunstner |||| Kunstnere", + "fields": { + "name": "Navn", + "albumCount": "Antal album", + "songCount": "Antal sange", + "playCount": "Afspilninger", + "rating": "", + "genre": "", + "size": "" + } + }, + "user": { + "name": "Bruger |||| Brugere", + "fields": { + "userName": "Brugernavn", + "isAdmin": "Er administrator", + "lastLoginAt": "Sidste login", + "updatedAt": "Opdateret den", + "name": "Navn", + "password": "Kodeord", + "createdAt": "Oprettet den", + "changePassword": "", + "currentPassword": "", + "newPassword": "", + "token": "" + }, + "helperTexts": { + "name": "" + }, + "notifications": { + "created": "", + "updated": "", + "deleted": "" + }, + "message": { + "listenBrainzToken": "", + "clickHereForToken": "" + } + }, + "player": { + "name": "Afspiller |||| Afspillere", + "fields": { + "name": "Navn", + "transcodingId": "Omkodning", + "maxBitRate": "Maks. bitrate", + "client": "Klient", + "userName": "Brugernavn", + "lastSeen": "Sidst set", + "reportRealPath": "", + "scrobbleEnabled": "" + } + }, + "transcoding": { + "name": "Omkodning |||| Omkodninger", + "fields": { + "name": "Navn", + "targetFormat": "Målformat", + "defaultBitRate": "Standard bitrate", + "command": "Kommando" + } + }, + "playlist": { + "name": "Afspilningsliste |||| Afspilningslister", + "fields": { + "name": "Navn", + "duration": "Varighed", + "ownerName": "Ejer", + "public": "Offentlig", + "updatedAt": "Opdateret den", + "createdAt": "Oprettet den", + "songCount": "Sange", + "comment": "Kommentar", + "sync": "Auto-importér", + "path": "Importér fra" + }, + "actions": { + "selectPlaylist": "Vælg en afspilningsliste:", + "addNewPlaylist": "Opret \"%{name}\"", + "export": "Eksporter", + "makePublic": "", + "makePrivate": "" + }, + "message": { + "duplicate_song": "", + "song_exist": "" + } + }, + "radio": { + "name": "", + "fields": { + "name": "", + "streamUrl": "", + "homePageUrl": "", + "updatedAt": "", + "createdAt": "" + }, + "actions": { + "playNow": "" + } + }, + "share": { + "name": "", + "fields": { + "username": "", + "url": "", + "description": "", + "contents": "", + "expiresAt": "", + "lastVisitedAt": "", + "visitCount": "", + "format": "", + "maxBitRate": "", + "updatedAt": "", + "createdAt": "", + "downloadable": "" + } + } }, - "album": { - "name": "Album |||| Albums", - "fields": { - "albumArtist": "Album kunstner", - "artist": "Kunstner", - "duration": "Varighed", - "songCount": "Sange", - "playCount": "Afspilninger", - "name": "Navn", - "genre": "Genre", - "compilation": "Opsamling", - "year": "År", - "updatedAt": "Opdateret d.", - "comment": "Kommentar", - "rating": "Bedømmelse", - "createdAt": "Tilføjet d.", - "size": "Størrelse", - "originalDate": "Original", - "releaseDate": "Udgivet", - "releases": "Udgivelse |||| Udgivelser", - "released": "Udgivet", - "recordLabel": "Plademærke", - "catalogNum": "Katalognummer", - "releaseType": "Type", - "grouping": "Gruppering", - "media": "Medier", - "mood": "Humør", - "date": "Optagelsesdato", - "missing": "Manglende", - "libraryName": "Bibliotek" - }, - "actions": { - "playAll": "Afspil", - "playNext": "Afspil næste", - "addToQueue": "Føj til kø", - "shuffle": "Bland", - "addToPlaylist": "Føj til afspilningsliste", - "download": "Download", - "info": "Hent info", - "share": "Del" - }, - "lists": { - "all": "Alle", - "random": "Tilfældig", - "recentlyAdded": "Nyligt tilføjet", - "recentlyPlayed": "Nyligt Afspillet", - "mostPlayed": "Mest Afspillet", - "starred": "Stjernemarkerede", - "topRated": "Top bedømmelse" - } - }, - "artist": { - "name": "Kunstner |||| Kunstnere", - "fields": { - "name": "Navn", - "albumCount": "Antal albums", - "songCount": "Antal sange", - "playCount": "Afspilninger", - "rating": "Bedømmelse", - "genre": "Genre", - "size": "Størrelse", - "role": "Rolle", - "missing": "Manglende" - }, - "roles": { - "albumartist": "Albumkunstner |||| Albumkunstnere", - "artist": "Kunstner |||| Kunstnere", - "composer": "Komponist |||| Komponister", - "conductor": "Dirigent |||| Dirigenter", - "lyricist": "Tekstforfatter |||| Tekstforfattere", - "arranger": "Arrangør |||| Arrangører", - "producer": "Producent |||| Producenter", - "director": "Instruktør |||| Instruktører", - "engineer": "Tekniker||||Teknikere", - "mixer": "Mixer |||| Mixere", - "remixer": "Remixer |||| Remixere", - "djmixer": "DJ-mixer |||| DJ-mixere", - "performer": "Udførende kunstner |||| Udførende kunstnere", - "maincredit": "Albumkunstner eller kunstner |||| Albumkunstnere eller kunstnere" - }, - "actions": { - "shuffle": "Bland", - "radio": "Radio", - "topSongs": "Topsange" - } - }, - "user": { - "name": "Bruger |||| Brugere", - "fields": { - "userName": "Brugernavn", - "isAdmin": "Er administrator", - "lastLoginAt": "Seneste login", - "updatedAt": "Opdateret d.", - "name": "Navn", - "password": "Kodeord", - "createdAt": "Oprettet d.", - "changePassword": "Skifte kodeord?", - "currentPassword": "Nuværende kodeord", - "newPassword": "Nyt kodeord", - "token": "Token", - "lastAccessAt": "Senest tilgået", - "libraries": "Biblioteker" - }, - "helperTexts": { - "name": "Ændringer i dit navn vises først ved næste login", - "libraries": "Vælg specifikke biblioteker til denne bruger, eller lad det stå tomt for at bruge standardbiblioteker" - }, - "notifications": { - "created": "Bruger oprettet", - "updated": "Bruger opdateret", - "deleted": "Bruger slettet" - }, - "message": { - "listenBrainzToken": "Skriv dit ListenBrainz token", - "clickHereForToken": "Tryk her for at få dit token", - "selectAllLibraries": "Vælg alle biblioteker", - "adminAutoLibraries": "Administratorbrugere har automatisk adgang til alle biblioteker" - }, - "validation": { - "librariesRequired": "Der skal være valgt mindst ét bibliotek til ikke-administrative brugere" - } - }, - "player": { - "name": "Afspiller |||| Afspillere", - "fields": { - "name": "Navn", - "transcodingId": "Transkodning", - "maxBitRate": "Maks. bitrate", - "client": "Klient", - "userName": "Brugernavn", - "lastSeen": "Sidst set", - "reportRealPath": "Vis den virkelige sti", - "scrobbleEnabled": "Send scrobbles til eksterne tjenester" - } - }, - "transcoding": { - "name": "Transkodning |||| Transkodninger", - "fields": { - "name": "Navn", - "targetFormat": "Målformat", - "defaultBitRate": "Standard bitrate", - "command": "Kommando" - } - }, - "playlist": { - "name": "Afspilningsliste |||| Afspilningslister", - "fields": { - "name": "Navn", - "duration": "Varighed", - "ownerName": "Ejer", - "public": "Offentlig", - "updatedAt": "Opdateret d.", - "createdAt": "Oprettet d.", - "songCount": "Sange", - "comment": "Kommentar", - "sync": "Auto-importér", - "path": "Importér fra" - }, - "actions": { - "selectPlaylist": "Vælg en afspilningsliste:", - "addNewPlaylist": "Opret \"%{name}\"", - "export": "Eksportér", - "makePublic": "Offentliggør", - "makePrivate": "Gør privat", - "saveQueue": "Gem kø på afspilningsliste", - "searchOrCreate": "Søg i afspilningslister eller skriv for at oprette nye...", - "pressEnterToCreate": "Tryk Enter for at oprette en ny afspilningsliste", - "removeFromSelection": "Fjern fra valg" - }, - "message": { - "duplicate_song": "Tilføj dubletter af sange", - "song_exist": "Der føjes dubletter til playlisten", - "noPlaylistsFound": "Ingen playlister fundet", - "noPlaylists": "Ingen tilgængelige playlister" - } - }, - "radio": { - "name": "Radio |||| Radioer", - "fields": { - "name": "Navn", - "streamUrl": "Stream-URL", - "homePageUrl": "Hjemmeside-URL", - "updatedAt": "Opdateret d.", - "createdAt": "Oprettet d." - }, - "actions": { - "playNow": "Afspil nu" - } - }, - "share": { - "name": "Del |||| Delinger", - "fields": { - "username": "Delt af", - "url": "URL", - "description": "Beskrivelse", - "contents": "Indhold", - "expiresAt": "Udløber", - "lastVisitedAt": "Senest besøgt", - "visitCount": "Besøg", - "format": "Format", - "maxBitRate": "Maks. bitrate", - "updatedAt": "Opdateret d.", - "createdAt": "Oprettet d.", - "downloadable": "Tillad downloads?" - } - }, - "missing": { - "name": "Manglende fil |||| Manglende filer", - "fields": { - "path": "Sti", - "size": "Størrelse", - "updatedAt": "Forsvandt d.", - "libraryName": "Bibliotek" - }, - "actions": { - "remove": "Fjern", - "remove_all": "Fjern alle" - }, - "notifications": { - "removed": "Manglende fil(er) fjernet" - }, - "empty": "Ingen manglende filer" - }, - "library": { - "name": "Bibliotek |||| Biblioteker", - "fields": { - "name": "Navn", - "path": "Sti", - "remotePath": "Fjernsti", - "lastScanAt": "Sidste scanning", - "songCount": "Sange", - "albumCount": "Albummer", - "artistCount": "Kunstnere", - "totalSongs": "Sange", - "totalAlbums": "Albummer", - "totalArtists": "Kunstnere", - "totalFolders": "Mapper", - "totalFiles": "Filer", - "totalMissingFiles": "Manglende filer", - "totalSize": "Samlet størrelse", - "totalDuration": "Varighed", - "defaultNewUsers": "Standard for nye brugere", - "createdAt": "Oprettet d.", - "updatedAt": "Opdateret d." - }, - "sections": { - "basic": "Grundlæggende oplysninger", - "statistics": "Statistik" - }, - "actions": { - "scan": "Scanningsbibliotek", - "manageUsers": "Administrer brugeradgang", - "viewDetails": "Se detaljer", - "quickScan": "hurtig skanning", - "fullScan": "Fuld skanning" - }, - "notifications": { - "created": "Bibliotek oprettet", - "updated": "Biblioteket er blevet opdateret", - "deleted": "Biblioteket er blevet slettet", - "scanStarted": "Biblioteksscanning startet", - "scanCompleted": "Biblioteksscanning fuldført", - "quickScanStarted": "hurtig skanning startet", - "fullScanStarted": "Fuld skanning startet", - "scanError": "Kan ikke starte skanning. Tjek loggen" - }, - "validation": { - "nameRequired": "Biblioteksnavn er påkrævet", - "pathRequired": "Bibliotekssti er påkrævet", - "pathNotDirectory": "Biblioteksstien skal være en mappe", - "pathNotFound": "Biblioteksstien blev ikke fundet", - "pathNotAccessible": "Biblioteksstien er ikke tilgængelig", - "pathInvalid": "Ugyldig bibliotekssti" - }, - "messages": { - "deleteConfirm": "Er du sikker på, at du vil slette dette bibliotek? Dét vil fjerne alle tilknyttede data og brugeradgange", - "scanInProgress": "Scanning i gang...", - "noLibrariesAssigned": "Ingen biblioteker tildelt denne bruger" - } - }, - "plugin": { - "name": "Plugin |||| Plugins", - "fields": { - "id": "ID", - "name": "Navn", - "description": "Beskrivelse", - "version": "Version", - "author": "Forfatter", - "website": "Hjemmeside", - "permissions": "Tilladelser", - "enabled": "Aktiveret", - "status": "Status", - "path": "Sti", - "lastError": "Fejl", - "hasError": "Fejl", - "updatedAt": "Opdateret", - "createdAt": "Installeret", - "configKey": "Nøgle", - "configValue": "Værdi", - "allUsers": "Tillad alle brugere", - "selectedUsers": "Valgte brugere", - "allLibraries": "Tillad alle biblioteker", - "selectedLibraries": "Valgte biblioteker", - "allowWriteAccess": "Tillad skriveadgang" - }, - "sections": { - "status": "Status", - "info": "Pluginoplysninger", - "configuration": "Konfiguration", - "manifest": "Manifest", - "usersPermission": "Brugertilladelse", - "libraryPermission": "Bibliotekstilladelse" - }, - "status": { - "enabled": "Aktiveret", - "disabled": "Deaktiveret" - }, - "actions": { - "enable": "Aktivér", - "disable": "Deaktivér", - "disabledDueToError": "Ret fejlen før aktivering", - "disabledUsersRequired": "Vælg brugere før aktivering", - "disabledLibrariesRequired": "Vælg biblioteker før aktivering", - "addConfig": "Tilføj konfiguration", - "rescan": "Genskan" - }, - "notifications": { - "enabled": "Plugin aktiveret", - "disabled": "Plugin deaktiveret", - "updated": "Plugin opdateret", - "error": "Fejl ved opdatering af plugin" - }, - "validation": { - "invalidJson": "Konfigurationen skal være gyldig JSON" - }, - "messages": { - "configHelp": "Konfigurér pluginet med nøgle-værdi-par. Lad stå tomt, hvis pluginet ikke kræver konfiguration.", - "clickPermissions": "Klik på en tilladelse for detaljer", - "noConfig": "Ingen konfiguration angivet", - "allUsersHelp": "Når aktiveret, vil pluginet have adgang til alle brugere, inklusiv dem der oprettes i fremtiden.", - "noUsers": "Ingen brugere valgt", - "permissionReason": "Årsag", - "usersRequired": "Dette plugin kræver adgang til brugeroplysninger. Vælg hvilke brugere pluginet kan tilgå, eller aktivér 'Tillad alle brugere'.", - "allLibrariesHelp": "Når aktiveret, vil pluginet have adgang til alle biblioteker, inklusiv dem der oprettes i fremtiden.", - "noLibraries": "Ingen biblioteker valgt", - "librariesRequired": "Dette plugin kræver adgang til biblioteksoplysninger. Vælg hvilke biblioteker pluginet kan tilgå, eller aktivér 'Tillad alle biblioteker'.", - "requiredHosts": "Påkrævede hosts", - "configValidationError": "Konfigurationsvalidering mislykkedes:", - "schemaRenderError": "Kan ikke vise konfigurationsformularen. Pluginets skema er muligvis ugyldigt.", - "allowWriteAccessHelp": "Når aktiveret, kan denne plugin rette filer i biblioteksmapper. På forhånd har plugins kun læseadgang." - }, - "placeholders": { - "configKey": "nøgle", - "configValue": "værdi" - } - } - }, - "ra": { - "auth": { - "welcome1": "Tak fordi du installerede Navidrome!", - "welcome2": "Først, opret en administrator", - "confirmPassword": "Bekræft kodeord", - "buttonCreateAdmin": "Opret administrator", - "auth_check_error": "Venligst login for at fortsætte", - "user_menu": "Profil", - "username": "Brugernavn", - "password": "Kodeord", - "sign_in": "Log ind", - "sign_in_error": "Dit log ind slog fejl, prøv igen", - "logout": "Log ud", - "insightsCollectionNote": "Navidrome indsamler anonyme brugsdata for at forbedre projektet. Klik [her] for at få mere at vide og fravælge, hvis du ønsker det." - }, - "validation": { - "invalidChars": "Venligst, benyt kun bogstaver og tal", - "passwordDoesNotMatch": "Kodeord er ikke ens", - "required": "Nødvendig", - "minLength": "Skal være mindst %{min} tegn", - "maxLength": "Skal være op til %{max} tegn", - "minValue": "Skal være mindst %{min}", - "maxValue": "Skal være op til %{max}", - "number": "Skal være et tal", - "email": "Skal være en gyldig e-mail-adresse", - "oneOf": "Skal være én af: %{options}", - "regex": "Skal matche et specifikt format (regexp): %{pattern}", - "unique": "Skal være unik", - "url": "Skal være en gyldig URL" - }, - "action": { - "add_filter": "Tilføj filter", - "add": "Tilføj", - "back": "Tilbage", - "bulk_actions": "1 emne valgt |||| %{smart_count} emner valgt", - "cancel": "Annuller", - "clear_input_value": "Ryd", - "clone": "Klon", - "confirm": "Bekræft", - "create": "Opret", - "delete": "Slet", - "edit": "Rediger", - "export": "Eksportér", - "list": "Liste", - "refresh": "Opdater", - "remove_filter": "Slet filter", - "remove": "Fjern", - "save": "Gem", - "search": "Søg", - "show": "Vis", - "sort": "Sortér", - "undo": "Fortryd", - "expand": "Udvid", - "close": "Luk", - "open_menu": "Åbn menu", - "close_menu": "Luk menu", - "unselect": "Fravælg", - "skip": "Spring over", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Del", - "download": "Download" - }, - "boolean": { - "true": "Ja", - "false": "Nej" - }, - "page": { - "create": "Opret %{name}", - "dashboard": "Instrumentbræt", - "edit": "%{name} #%{id}", - "error": "Noget gik galt", - "list": "%{name} liste", - "loading": "Henter", - "not_found": "Ikke fundet", - "show": "%{name} #%{id}", - "empty": "Ingen %{name} endnu.", - "invite": "Vil du tilføje en?" - }, - "input": { - "file": { - "upload_several": "Træk nogle filer herind for at uploade, eller klik for at vælge en.", - "upload_single": "Træk en fil herind for at uploade, eller klik for at vælge den." - }, - "image": { - "upload_several": "Træk billedfiler herind for at uploade, eller klik for at vælge en.", - "upload_single": "Træk en billedfil herind for at uploade, eller klik for at vælge den." - }, - "references": { - "all_missing": "Kan ikke finde nogen referencedata.", - "many_missing": "Mindst en af de tilknyttede referencer synes ikke længere at være tilgængelig.", - "single_missing": "Tilknyttede referencer synes ikke længere at være tilgængelige." - }, - "password": { - "toggle_visible": "Skjul kodeord", - "toggle_hidden": "Vis kodeord" - } + "ra": { + "auth": { + "welcome1": "Tak fordi du installerede Navidrome!", + "welcome2": "Opret administrator for at begynde", + "confirmPassword": "Bekræft kodeord", + "buttonCreateAdmin": "Opret administrator", + "auth_check_error": "Venligst login for at fortsætte", + "user_menu": "Profil", + "username": "Brugernavn", + "password": "Password", + "sign_in": "Log ind", + "sign_in_error": "Dit log ind fejlede, prøv igen", + "logout": "Log ud" + }, + "validation": { + "invalidChars": "Vær venlig kun at benytte bogstaver og tal", + "passwordDoesNotMatch": "Kodeord er ikke ens", + "required": "Obligatorisk", + "minLength": "Skal være mindst %{min} tegn", + "maxLength": "Skal være max %{max} tegn", + "minValue": "Skal være mindst %{min}", + "maxValue": "Skal være max %{max}", + "number": "Skal være et nummer", + "email": "Skal være en gyldig e-mail-adresse", + "oneOf": "Skal være en af: %{options}", + "regex": "Skal matche et bestemt format (regexp): %{pattern}", + "unique": "", + "url": "" + }, + "action": { + "add_filter": "Tilføj filter", + "add": "Tilføj", + "back": "Tilbage", + "bulk_actions": "%{smart_count} valgt", + "cancel": "Annuller", + "clear_input_value": "Ryd", + "clone": "Klon", + "confirm": "Bekræft", + "create": "Opret", + "delete": "Slet", + "edit": "Rediger", + "export": "Eksporter", + "list": "Liste", + "refresh": "Opdater", + "remove_filter": "Slet filter", + "remove": "Fjern", + "save": "Gem", + "search": "Søg", + "show": "Vis", + "sort": "Sortér", + "undo": "Fortryd", + "expand": "Udvid", + "close": "Luk", + "open_menu": "Åben menu", + "close_menu": "Luk menu", + "unselect": "Fravælg", + "skip": "", + "bulk_actions_mobile": "", + "share": "", + "download": "" + }, + "boolean": { + "true": "Ja", + "false": "Nej" + }, + "page": { + "create": "Opret %{name}", + "dashboard": "Dashboard", + "edit": "%{name} #%{id}", + "error": "Noget gik galt", + "list": "%{name} liste", + "loading": "Henter", + "not_found": "Ikke fundet", + "show": "%{name} #%{id}", + "empty": "Ingen %{name} endnu", + "invite": "Vil du tilføje en?" + }, + "input": { + "file": { + "upload_several": "Træk og slip filer for at uploade, eller klik for at vælge filer.", + "upload_single": "Træk og slip en fil for at uploade, eller klik for at vælge en fil." + }, + "image": { + "upload_several": "Træk og slip filer for at uploade, eller klik for at vælge filer.", + "upload_single": "Træk og slip et billede for at uploade, eller klik for at vælge en fil." + }, + "references": { + "all_missing": "Kan ikke finde nogle referencedata.", + "many_missing": "Mindst en af de tilknyttede referencer synes ikke længere at være tilgængelig.", + "single_missing": "Tilknyttede referencer synes ikke længere at være tilgængelige." + }, + "password": { + "toggle_visible": "Skjul kodeord", + "toggle_hidden": "Vis kodeord" + } + }, + "message": { + "about": "Om", + "are_you_sure": "Er du sikker?", + "bulk_delete_content": "Er du sikker på du vil slette %{name}? |||| Er du sikker på du ville slette %{smart_count} poster?", + "bulk_delete_title": "Slet %{name} |||| Sletter %{smart_count} %{name} poster", + "delete_content": "Er du sikker på du ville slette denne post?", + "delete_title": "Slet %{name} #%{id}", + "details": "Detaljer", + "error": "Der opstod en klientfejl, og din forespørgsel kunne ikke udføres.", + "invalid_form": "Formularen er ikke gyldig. Kontroller for fejl", + "loading": "Siden indlæses, Vent et øjeblik", + "no": "Nej", + "not_found": "Enten har du skrevet en forkert URL eller du har fulgt et invalidt link.", + "yes": "Ja", + "unsaved_changes": "Du har lavet ændringer der ikke er gemt. Er du sikker på at du vil ignorere dem?" + }, + "navigation": { + "no_results": "Ingen resultater fundet", + "no_more_results": "Sidenummeret %{page} eksistere ikke. Gå tilbage til forrige side.", + "page_out_of_boundaries": "Sidenummeret %{page} eksistere ikke", + "page_out_from_end": "Der findes ikke flere sider", + "page_out_from_begin": "Der er ingen side før end side 1", + "page_range_info": "%{offsetBegin}-%{offsetEnd} af %{total}", + "page_rows_per_page": "Rækker pr. side:", + "next": "Næste", + "prev": "Forrige", + "skip_nav": "" + }, + "notification": { + "updated": "Objekt opdateret |||| %{smart_count} objekter opdateret", + "created": "Objekt oprettet", + "deleted": "Objekt slettet |||| %{smart_count} objekter slettet", + "bad_item": "Incorrect element", + "item_doesnt_exist": "Objektet findes ikke", + "http_error": "Kommunikationsfejl med serveren", + "data_provider_error": "dataProvider fejl. Check din console for detaljer.", + "i18n_error": "Kan ikke indlæse oversættelse af det ønskede sprog", + "canceled": "Handling blev annulleret", + "logged_out": "Din session er udløbet, venligst tilslut igen", + "new_version": "" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "", + "layout": "", + "grid": "", + "table": "" + } }, "message": { - "about": "Om", - "are_you_sure": "Er du sikker?", - "bulk_delete_content": "Er du sikker på, at du vil slette %{name}? |||| Er du sikker på, at du vil slette disse %{smart_count} poster?", - "bulk_delete_title": "Slet %{name} |||| Sletter %{smart_count} %{name} poster", - "delete_content": "Er du sikker på, at du vil slette denne post?", - "delete_title": "Slet %{name} #%{id}", - "details": "Detaljer", - "error": "Der opstod en klientfejl, og din forespørgsel kunne ikke udføres.", - "invalid_form": "Formularen er ikke gyldig. Tjek for fejl", - "loading": "Siden indlæses, vent et øjeblik", - "no": "Nej", - "not_found": "Enten har du skrevet en forkert URL eller du har fulgt et ugyldigt link.", - "yes": "Ja", - "unsaved_changes": "Du har lavet ændringer der ikke er gemt. Er du sikker på at du vil ignorere dem?" - }, - "navigation": { - "no_results": "Ingen resultater fundet", - "no_more_results": "Sidenummeret %{page} eksisterer ikke. Gå tilbage til forrige side.", - "page_out_of_boundaries": "Sidenummeret %{page} ligger uden for grænserne", - "page_out_from_end": "Dette er sidste side", - "page_out_from_begin": "Dette er side 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} af %{total}", - "page_rows_per_page": "Rækker pr. side:", - "next": "Næste", - "prev": "Forrige", - "skip_nav": "Hop til indhold" - }, - "notification": { - "updated": "Element opdateret |||| %{smart_count} elementer opdateret", - "created": "Element oprettet", - "deleted": "Element slettet |||| %{smart_count} elementer slettet", - "bad_item": "Forkert element", - "item_doesnt_exist": "Elementet findes ikke", - "http_error": "Kommunikationsfejl med serveren", - "data_provider_error": "dataProvider fejl. Tjek konsollen for detaljer.", - "i18n_error": "Kan ikke indlæse oversættelsen af det ønskede sprog", - "canceled": "Handling blev annulleret", - "logged_out": "Din session er udløbet, venligst tilslut igen", - "new_version": "Ny version tilgængelig! – genopfrisk venligst vinduet" - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Antal synlige kolonner", - "layout": "Layout", - "grid": "Gitter", - "table": "Tabel" - } - }, - "message": { - "note": "NOTE", - "transcodingDisabled": "Ændring af indstillinger til transkodning via webgrænsefladen er deaktiveret af sikkerhedshensyn.\nFor at ændre eller tilføje indstillinger skal du genstarte serveren med %{config} konfigurations option.", - "transcodingEnabled": "Navidrome kører i øjeblikket med %{config}. Dét gør det muligt at køre systemkommandoer fra transkodningsindstillingerne, via webgrænsefladen.\nVi anbefaler at deaktivere dette af sikkerhedshensyn og kun have det aktiveret, når du konfigurerer indstillinger til transkodning.", - "songsAddedToPlaylist": "Føjede 1 sang til afspilningsliste |||| Føjede %{smart_count} sange til afspilningsliste", - "noPlaylistsAvailable": "Ingen tilgængelige", - "delete_user_title": "Slet bruger '%{name}'", - "delete_user_content": "Er du sikker på at du vil slette denne bruger og tilhørende data (inklusive afspilningslister og valgte indstillinger)?", - "notifications_blocked": "Du blokerer for notifikationer fra dette site i dine browserindstillinger", - "notifications_not_available": "Denne browser understøtter ikke skrivebordsnotifikationer, eller: Du tilgår ikke Navidrome over https", - "lastfmLinkSuccess": "Du er koblet til Last.fm, og scrobbling er slået til", - "lastfmLinkFailure": "Du kan ikke kobles til Last.fm", - "lastfmUnlinkSuccess": "Last.fm frakoblet, og scrobbling deaktiveret", - "lastfmUnlinkFailure": "Last.fm kunne ikke frakobles", - "openIn": { - "lastfm": "Åbn i Last.fm", - "musicbrainz": "Åbn i MusicBrainz" - }, - "lastfmLink": "Læs mere...", - "listenBrainzLinkSuccess": "Du er koblet til ListenBrainz og scrobbling er aktiveret som bruger: %{user}", - "listenBrainzLinkFailure": "Du kunne ikke kobles til ListenBrainz: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz er frakoblet, og scrobbling deaktiveret", - "listenBrainzUnlinkFailure": "ListenBrainz kunne ikke frakobles", - "downloadOriginalFormat": "Download i originalformat", - "shareOriginalFormat": "Del i originalformat", - "shareDialogTitle": "Del %{resource} '%{name}'", - "shareBatchDialogTitle": "Del 1 %{resource} |||| Del %{smart_count} %{resource}", - "shareSuccess": "URL kopieret til udklipsholder: %{url}", - "shareFailure": "Fejl ved kopiering af URL %{url} til udklipsholder", - "downloadDialogTitle": "Download %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Kopiér til udklipsholder: Ctrl+C, Enter", - "remove_missing_title": "Fjern manglende filer", - "remove_missing_content": "Er du sikker på, at du vil fjerne de valgte manglende filer fra databasen? Dét vil permanent fjerne alle referencer til dem, inklusive deres afspilningstællere og vurderinger.", - "remove_all_missing_title": "Fjern alle manglende filer", - "remove_all_missing_content": "Er du sikker på, at du vil fjerne alle manglende filer fra databasen? Dét vil permanent fjerne alle referencer til dem, inklusive deres afspilningstællere og vurderinger.", - "noSimilarSongsFound": "Ingen lignende sange fundet", - "noTopSongsFound": "Ingen topsange fundet", - "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", - "settings": "Indstillinger", - "version": "Version", - "theme": "Tema", - "personal": { - "name": "Personligt", - "options": { - "theme": "Tema", - "language": "Sprog", - "defaultView": "Standardopsætning", - "desktop_notifications": "Skrivebordsnotifikationer", - "lastfmScrobbling": "Scrobble til Last.fm", - "listenBrainzScrobbling": "Scrobble til ListenBrainz", - "replaygain": "ReplayGain-tilstand", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "Slået fra", - "album": "Brug Album Gain", - "track": "Brug Gain for spor" + "note": "NOTE", + "transcodingDisabled": "Skift af indstillinger for omkodning gennem web platformen er frakoblet af sikkerhedsgrunde. Genstart serveren med %{config} indstilling tilvalgt.", + "transcodingEnabled": "Navidrome kører i øjeblikket med %{config}, hvilket gør det muligt at køre system kommandoer fra web platformen. Vi anbefaler at slå det fra af sikkerhedsgrunde og kun slå det til ved indstilling af omkodning.", + "songsAddedToPlaylist": "Tilføjede 1 sang til afspilningsliste |||| Tilføjede %{smart_count} sange til afspilningsliste", + "noPlaylistsAvailable": "Ingen tilgængelige", + "delete_user_title": "Slet bruger '%{name}'", + "delete_user_content": "Er du sikker på at du vil slette denne bruger og tilhørende data (inklusive afspilningslister og indstillinger)?", + "notifications_blocked": "", + "notifications_not_available": "", + "lastfmLinkSuccess": "", + "lastfmLinkFailure": "", + "lastfmUnlinkSuccess": "", + "lastfmUnlinkFailure": "", + "openIn": { + "lastfm": "", + "musicbrainz": "" }, - "lastfmNotConfigured": "Last.fm API-nøglen er ikke konfigureret" - } + "lastfmLink": "", + "listenBrainzLinkSuccess": "", + "listenBrainzLinkFailure": "", + "listenBrainzUnlinkSuccess": "", + "listenBrainzUnlinkFailure": "", + "downloadOriginalFormat": "", + "shareOriginalFormat": "", + "shareDialogTitle": "", + "shareBatchDialogTitle": "", + "shareSuccess": "", + "shareFailure": "", + "downloadDialogTitle": "", + "shareCopyToClipboard": "" }, - "albumList": "Albums", - "about": "Om", - "playlists": "Afspilningslister", - "sharedPlaylists": "Delte afspilningslister", - "librarySelector": { - "allLibraries": "Alle biblioteker (%{count})", - "multipleLibraries": "%{selected} af %{total} biblioteker", - "selectLibraries": "Vælg biblioteker", - "none": "Ingen" - } - }, - "player": { - "playListsText": "Afspilningskø", - "openText": "Åbn", - "closeText": "Luk", - "notContentText": "Ingen musik", - "clickToPlayText": "Tryk for at afspille", - "clickToPauseText": "Tryk for at sætte på pause", - "nextTrackText": "Næste nummer", - "previousTrackText": "Forrige nummer", - "reloadText": "Genindlæs", - "volumeText": "Lydstyrke", - "toggleLyricText": "Skift sangtekst til/fra", - "toggleMiniModeText": "Minimer", - "destroyText": "Fjern", - "downloadText": "Hent", - "removeAudioListsText": "Slet afspilningslister", - "clickToDeleteText": "Tryk for at slette %{name}", - "emptyLyricText": "Ingen sangtekst", - "playModeText": { - "order": "I rækkefølge", - "orderLoop": "Gentag", - "singleLoop": "Gentag enkelt", - "shufflePlay": "Bland" - } - }, - "about": { - "links": { - "homepage": "Hjemmeside", - "source": "Kildekode", - "featureRequests": "Funktionsønsker", - "lastInsightsCollection": "Seneste indsamling af indsigter", - "insights": { - "disabled": "Slået fra", - "waiting": "Venter" - } + "menu": { + "library": "Bibliotek", + "settings": "Indstillinger", + "version": "Version", + "theme": "Tema", + "personal": { + "name": "Personligt", + "options": { + "theme": "Tema", + "language": "Sprog", + "defaultView": "Standardopsætning", + "desktop_notifications": "", + "lastfmScrobbling": "", + "listenBrainzScrobbling": "", + "replaygain": "", + "preAmp": "", + "gain": { + "none": "", + "album": "", + "track": "" + } + } + }, + "albumList": "Albums", + "about": "Om", + "playlists": "", + "sharedPlaylists": "" }, - "tabs": { - "about": "Om", - "config": "Konfiguration" + "player": { + "playListsText": "Afspilnings kø", + "openText": "Åben", + "closeText": "Luk", + "notContentText": "Ingen musik", + "clickToPlayText": "Tryk for at afspille", + "clickToPauseText": "Tryk for at pause", + "nextTrackText": "Næste nummer", + "previousTrackText": "Forrige nummer", + "reloadText": "Genindlæs", + "volumeText": "Lydstyrke", + "toggleLyricText": "Skift sangtekst", + "toggleMiniModeText": "Minimer", + "destroyText": "Fjern", + "downloadText": "Hent", + "removeAudioListsText": "Slet afspillingsliste", + "clickToDeleteText": "Tryk for at slette %{name}", + "emptyLyricText": "Ingen sangtekst", + "playModeText": { + "order": "I rækkefølge", + "orderLoop": "Gentag", + "singleLoop": "Gentag enkelt", + "shufflePlay": "Bland" + } }, - "config": { - "configName": "Navn på konfiguration", - "environmentVariable": "Miljøvariabel", - "currentValue": "Nuværende værdi", - "configurationFile": "Konfigurationsfil", - "exportToml": "Eksportér konfigurationen (TOML)", - "exportSuccess": "Konfigurationen eksporteret til udklipsholder i TOML-format", - "exportFailed": "Kunne ikke kopiere konfigurationen", - "devFlagsHeader": "Udviklingsflagget (med forbehold for ændring/fjernelse)", - "devFlagsComment": "Disse er eksperimental-indstillinger og kan blive fjernet i fremtidige udgaver", - "downloadToml": "Download konfigurationen (TOML)" + "about": { + "links": { + "homepage": "Hjme", + "source": "Kildekode", + "featureRequests": "Ønskede funktioner" + } + }, + "activity": { + "title": "Aktivitet", + "totalScanned": "Antal sange fundet", + "quickScan": "Hurtig søgning", + "fullScan": "Fuld søgning\n", + "serverUptime": "Server uptime", + "serverDown": "OFFLINE" + }, + "help": { + "title": "", + "hotkeys": { + "show_help": "", + "toggle_menu": "", + "toggle_play": "", + "prev_song": "", + "next_song": "", + "vol_up": "", + "vol_down": "", + "toggle_love": "", + "current_song": "" + } } - }, - "activity": { - "title": "Aktivitet", - "totalScanned": "Antal mapper gennemsøgt", - "quickScan": "Hurtig søgning", - "fullScan": "Fuld søgning", - "serverUptime": "Server oppetid", - "serverDown": "OFFLINE", - "scanType": "Type", - "status": "Scanningsfejl", - "elapsedTime": "Medgået tid", - "selectiveScan": "Selektiv" - }, - "help": { - "title": "Navidrome genvejstaster", - "hotkeys": { - "show_help": "Vis denne hjælp", - "toggle_menu": "Skift menu sidepanel", - "toggle_play": "Play / Pause", - "prev_song": "Forrige sang", - "next_song": "Næste sang", - "vol_up": "Volumen op", - "vol_down": "Volumen ned", - "toggle_love": "Føj dette nummer til dine favoritter", - "current_song": "Gå til den aktuelle sang" - } - }, - "nowPlaying": { - "title": "Afspilles nu", - "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 400a2364d..7ef43dedf 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -1,734 +1,456 @@ { - "languageName": "Deutsch", - "resources": { - "song": { - "name": "Titel |||| Titel", - "fields": { - "albumArtist": "Albuminterpret", - "duration": "Dauer", - "trackNumber": "Titel #", - "playCount": "Wiedergaben", - "title": "Titel", - "artist": "Interpret", - "album": "Album", - "path": "Dateipfad", - "genre": "Genre", - "compilation": "Kompilation", - "year": "Jahr", - "size": "Dateigröße", - "updatedAt": "Hochgeladen am", - "bitRate": "Bitrate", - "discSubtitle": "Disc Untertitel", - "starred": "Favorit", - "comment": "Kommentar", - "rating": "Bewertung", - "quality": "Qualität", - "bpm": "BPM", - "playDate": "Letzte Wiedergabe", - "channels": "Spuren", - "createdAt": "Hinzugefügt", - "grouping": "Gruppierung", - "mood": "Stimmung", - "participants": "Weitere Beteiligte", - "tags": "Weitere Tags", - "mappedTags": "Gemappte Tags", - "rawTags": "Tag Rohdaten", - "bitDepth": "Bittiefe", - "sampleRate": "Samplerate", - "missing": "Fehlend", - "libraryName": "Bibliothek", - "composer": "Komponist", - "disc": "Disc %{discNumber}", - "albumGain": "Album Gain", - "trackGain": "Titel Gain" - }, - "actions": { - "addToQueue": "Später abspielen", - "playNow": "Jetzt abspielen", - "addToPlaylist": "Zu einer Wiedergabeliste hinzufügen", - "shuffleAll": "Zufallswiedergabe", - "download": "Herunterladen", - "playNext": "Als nächstes abspielen", - "info": "Mehr Informationen", - "showInPlaylist": "In Wiedergabeliste anzeigen", - "instantMix": "Sofort-Mix" - } + "languageName": "Deutsch", + "resources": { + "song": { + "name": "Song |||| Songs", + "fields": { + "albumArtist": "Albuminterpret", + "duration": "Dauer", + "trackNumber": "Titel #", + "playCount": "Wiedergaben", + "title": "Titel", + "artist": "Künstler", + "album": "Album", + "path": "Dateipfad", + "genre": "Genre", + "compilation": "Kompilation", + "year": "Jahr", + "size": "Dateigröße", + "updatedAt": "Hochgeladen um", + "bitRate": "Bitrate", + "discSubtitle": "CD Untertitel", + "starred": "Favorit", + "comment": "Kommentar", + "rating": "Bewertung", + "quality": "Qualität", + "bpm": "BPM", + "playDate": "Letzte Wiedergabe", + "channels": "Spuren", + "createdAt": "Hinzugefügt" + }, + "actions": { + "addToQueue": "Später abspielen", + "playNow": "Jetzt abspielen", + "addToPlaylist": "Zur Playlist hinzufügen", + "shuffleAll": "Zufallswiedergabe", + "download": "Herunterladen", + "playNext": "Als nächstes abspielen", + "info": "Mehr Informationen" + } + }, + "album": { + "name": "Album |||| Alben", + "fields": { + "albumArtist": "Albuminterpret", + "artist": "Interpret", + "duration": "Dauer", + "songCount": "Titelanzahl", + "playCount": "Wiedergaben", + "name": "Name", + "genre": "Genre", + "compilation": "Kompilation", + "year": "Jahr", + "updatedAt": "Aktualisiert um", + "comment": "Kommentar", + "rating": "Bewertung", + "createdAt": "Hinzugefügt", + "size": "Größe" + }, + "actions": { + "playAll": "Abspielen", + "playNext": "Als nächstes abspielen", + "addToQueue": "Später abspielen", + "shuffle": "Zufallswiedergabe", + "addToPlaylist": "Zur Playlist hinzufügen", + "download": "Herunterladen", + "info": "Mehr Informationen", + "share": "Freigabe erstellen" + }, + "lists": { + "all": "Alle", + "random": "Zufällig", + "recentlyAdded": "Kürzlich hinzugefügt", + "recentlyPlayed": "Kürzlich gespielt", + "mostPlayed": "Meist gespielt", + "starred": "Favorit", + "topRated": "Beste Bewertung" + } + }, + "artist": { + "name": "Interpret |||| Interpreten", + "fields": { + "name": "Name", + "albumCount": "Albumanzahl", + "songCount": "Titel Anzahl", + "playCount": "Wiedergaben", + "rating": "Bewertung", + "genre": "Genre", + "size": "Größe" + } + }, + "user": { + "name": "Nutzer |||| Nutzer", + "fields": { + "userName": "Nutzername", + "isAdmin": "Ist Admin", + "lastLoginAt": "Letzer Login um", + "updatedAt": "Aktualisiert um", + "name": "Name", + "password": "Passwort", + "createdAt": "Erstellt um", + "changePassword": "Passwort ändern?", + "currentPassword": "Aktuelles Passwort", + "newPassword": "Neues Passwort", + "token": "Token" + }, + "helperTexts": { + "name": "Die Änderung wird erst nach dem nächsten Login gültig" + }, + "notifications": { + "created": "Benutzer erstellt", + "updated": "Benutzer aktualisiert", + "deleted": "Benutzer gelöscht" + }, + "message": { + "listenBrainzToken": "Gib deinen ListenBrainz Benutzer Token ein", + "clickHereForToken": "Hier klicken um deinen Token abzurufen" + } + }, + "player": { + "name": "Player |||| Players", + "fields": { + "name": "Name", + "transcodingId": "Transkodierungs-ID", + "maxBitRate": "Max. Bitrate", + "client": "Client", + "userName": "Nutzername", + "lastSeen": "Zuletzt gesehen um", + "reportRealPath": "Echten Pfad anzeigen", + "scrobbleEnabled": "An externe Dienstleister scrobblen" + } + }, + "transcoding": { + "name": "Transcodierung |||| Transcodierungen", + "fields": { + "name": "Name", + "targetFormat": "Zielformat", + "defaultBitRate": "Standardbitrate", + "command": "Befehl" + } + }, + "playlist": { + "name": "Playlist |||| Playlists", + "fields": { + "name": "Name", + "duration": "Dauer", + "ownerName": "Inhaber", + "public": "Öffentlich", + "updatedAt": "Aktualisiert um", + "createdAt": "Erstellt um", + "songCount": "Titel Anzahl", + "comment": "Kommentar", + "sync": "Auto-Import", + "path": "Importieren aus" + }, + "actions": { + "selectPlaylist": "Titel zur Playlist hinzufügen", + "addNewPlaylist": "\"%{name}\" erstellen", + "export": "Exportieren", + "makePublic": "Öffentlich machen", + "makePrivate": "Privat stellen" + }, + "message": { + "duplicate_song": "Duplikate hinzufügen", + "song_exist": "Manche Titel sind bereits in der Playlist. Möchtest du sie trotzdem hinzufügen oder überspringen?" + } + }, + "radio": { + "name": "Radio |||| Radios", + "fields": { + "name": "Name", + "streamUrl": "Stream URL", + "homePageUrl": "Homepage URL", + "updatedAt": "Geändert", + "createdAt": "Hinzugefügt" + }, + "actions": { + "playNow": "Jetzt abspielen" + } + }, + "share": { + "name": "Freigabe |||| Freigaben", + "fields": { + "username": "Freigegeben von", + "url": "URL", + "description": "Beschreibung", + "contents": "Inhalt", + "expiresAt": "Gültig bis", + "lastVisitedAt": "Zuletzt besucht", + "visitCount": "Aufrufe", + "format": "Format", + "maxBitRate": "Max. Bit Rate", + "updatedAt": "Geändert am", + "createdAt": "Erstellt am", + "downloadable": "Downloads erlauben?" + } + } }, - "album": { - "name": "Album |||| Alben", - "fields": { - "albumArtist": "Albuminterpret", - "artist": "Interpret", - "duration": "Dauer", - "songCount": "Titelanzahl", - "playCount": "Wiedergaben", - "name": "Name", - "genre": "Genre", - "compilation": "Kompilation", - "year": "Jahr", - "updatedAt": "Aktualisiert am", - "comment": "Kommentar", - "rating": "Bewertung", - "createdAt": "Hinzugefügt", - "size": "Größe", - "originalDate": "Ursprünglich", - "releaseDate": "Erschienen", - "releases": "Veröffentlichung |||| Veröffentlichungen", - "released": "Erschienen", - "recordLabel": "Label", - "catalogNum": "Katalognummer", - "releaseType": "Typ", - "grouping": "Gruppierung", - "media": "Medium", - "mood": "Stimmung", - "date": "Aufnahmedatum", - "missing": "Fehlend", - "libraryName": "Bibliothek" - }, - "actions": { - "playAll": "Abspielen", - "playNext": "Als nächstes abspielen", - "addToQueue": "Später abspielen", - "shuffle": "Zufallswiedergabe", - "addToPlaylist": "Zu einer Wiedergabeliste hinzufügen", - "download": "Herunterladen", - "info": "Mehr Informationen", - "share": "Freigabe erstellen", - "refresh": "Metadaten aktualisieren" - }, - "lists": { - "all": "Alle", - "random": "Zufällig", - "recentlyAdded": "Kürzlich hinzugefügt", - "recentlyPlayed": "Kürzlich gespielt", - "mostPlayed": "Meist gespielt", - "starred": "Favorit", - "topRated": "Beste Bewertung" - } - }, - "artist": { - "name": "Interpret |||| Interpreten", - "fields": { - "name": "Name", - "albumCount": "Albumanzahl", - "songCount": "Titelanzahl", - "playCount": "Wiedergaben", - "rating": "Bewertung", - "genre": "Genre", - "size": "Größe", - "role": "Rolle", - "missing": "Fehlend" - }, - "roles": { - "albumartist": "Albuminterpret |||| Albuminterpreten", - "artist": "Interpret |||| Interpreten", - "composer": "Komponist |||| Komponisten", - "conductor": "Dirigent |||| Dirigenten", - "lyricist": "Texter |||| Texter", - "arranger": "Arrangeur |||| Arrangeure", - "producer": "Produzent |||| Produzenten", - "director": "Direktor |||| Direktoren", - "engineer": "Ingenieur |||| Ingenieure", - "mixer": "Mixer |||| Mixer", - "remixer": "Remixer |||| Remixer", - "djmixer": "DJ Mixer |||| DJ Mixer", - "performer": "ausübender Künstler |||| ausübende Künstler", - "maincredit": "Albuminterpret oder Interpret |||| Albuminterpreten oder Interpreten" - }, - "actions": { - "shuffle": "Zufallswiedergabe", - "radio": "Radio", - "topSongs": "Beliebteste Titel" - } - }, - "user": { - "name": "Nutzer |||| Nutzer", - "fields": { - "userName": "Nutzername", - "isAdmin": "Ist Admin", - "lastLoginAt": "Letzer Login am", - "updatedAt": "Aktualisiert am", - "name": "Name", - "password": "Passwort", - "createdAt": "Erstellt am", - "changePassword": "Passwort ändern?", - "currentPassword": "Aktuelles Passwort", - "newPassword": "Neues Passwort", - "token": "Token", - "lastAccessAt": "Letzter Zugriff am", - "libraries": "Bibliotheken", - "scrobbleFilter": "Scrobble-Filter" - }, - "helperTexts": { - "name": "Die Änderung wird erst nach dem nächsten Login gültig", - "libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken", - "scrobbleFilter": "Titel, die diesen Regeln für intelligente Wiedergabelisten entsprechen, werden nicht an Last.fm, ListenBrainz oder Scrobbler-Plugins übermittelt. Es werden dieselbe JSON-Syntax und dasselbe Verhalten wie bei intelligenten Wiedergabelisten verwendet. Beispiel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Ist das Feld leer, wird alles gescrobbelt. Lokale Wiedergabezahlen bleiben davon unberührt." - }, - "notifications": { - "created": "Benutzer erstellt", - "updated": "Benutzer aktualisiert", - "deleted": "Benutzer gelöscht" - }, - "message": { - "listenBrainzToken": "Gib deinen ListenBrainz Benutzer Token ein", - "clickHereForToken": "Hier klicken um deinen Token abzurufen", - "selectAllLibraries": "Wähle alle Bibliotheken", - "adminAutoLibraries": "Administrator-Benutzer haben automatisch Zugriff auf alle Bibliotheken" - }, - "validation": { - "librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein", - "invalidScrobbleFilter": "Es müssen gültige Regeln für intelligente Wiedergabelisten sein. Limit, Offset und Aktualisierungsverzögerung werden nicht unterstützt." - } - }, - "player": { - "name": "Player |||| Players", - "fields": { - "name": "Name", - "transcodingId": "Transkodierungs-ID", - "maxBitRate": "Max. Bitrate", - "client": "Client", - "userName": "Nutzername", - "lastSeen": "Zuletzt gesehen am", - "reportRealPath": "Echten Pfad anzeigen", - "scrobbleEnabled": "An externe Dienstleister scrobblen" - } - }, - "transcoding": { - "name": "Transcodierung |||| Transcodierungen", - "fields": { - "name": "Name", - "targetFormat": "Zielformat", - "defaultBitRate": "Standardbitrate", - "command": "Befehl" - }, - "choices": { - "noDefaultBitRate": "Keine" - } - }, - "playlist": { - "name": "Wiedergabeliste |||| Wiedergabelisten", - "fields": { - "name": "Name", - "duration": "Dauer", - "ownerName": "Inhaber", - "public": "Öffentlich", - "updatedAt": "Aktualisiert am", - "createdAt": "Erstellt am", - "songCount": "Titelanzahl", - "comment": "Kommentar", - "sync": "Auto-Import", - "path": "Importieren aus", - "starred": "Favorit" - }, - "actions": { - "selectPlaylist": "Wiedergabeliste auswählen:", - "addNewPlaylist": "\"%{name}\" erstellen", - "export": "Exportieren", - "makePublic": "Öffentlich machen", - "makePrivate": "Privat stellen", - "saveQueue": "Warteschlange in Wiedergabeliste speichern", - "searchOrCreate": "Wiedergabeliste suchen oder neue erstellen...", - "pressEnterToCreate": "Enter drücken um neue Wiedergabeliste zu erstellen", - "removeFromSelection": "Von Auswahl entfernen" - }, - "message": { - "duplicate_song": "Duplikate hinzufügen", - "song_exist": "Manche Titel sind bereits in der Playlist. Möchtest du sie trotzdem hinzufügen oder überspringen?", - "noPlaylistsFound": "Keine Wiedergabeliste gefunden", - "noPlaylists": "Keine Wiedergabelisten vorhanden" - } - }, - "radio": { - "name": "Radio |||| Radios", - "fields": { - "name": "Name", - "streamUrl": "Stream URL", - "homePageUrl": "Homepage URL", - "updatedAt": "Geändert", - "createdAt": "Hinzugefügt" - }, - "actions": { - "playNow": "Jetzt abspielen" - } - }, - "share": { - "name": "Freigabe |||| Freigaben", - "fields": { - "username": "Freigegeben von", - "url": "URL", - "description": "Beschreibung", - "contents": "Inhalt", - "expiresAt": "Gültig bis", - "lastVisitedAt": "Zuletzt besucht", - "visitCount": "Aufrufe", - "format": "Format", - "maxBitRate": "Max. Bit Rate", - "updatedAt": "Geändert am", - "createdAt": "Erstellt am", - "downloadable": "Downloads erlauben?" - } - }, - "missing": { - "name": "Fehlende Datei |||| Fehlende Dateien", - "fields": { - "path": "Pfad", - "size": "Größe", - "updatedAt": "Fehlt seit", - "libraryName": "Bibliothek" - }, - "actions": { - "remove": "Entfernen", - "remove_all": "alle entfernen" - }, - "notifications": { - "removed": "Fehlende Datei(en) entfernt" - }, - "empty": "keine fehlenden Dateien" - }, - "library": { - "name": "Bibliothek |||| Bibliotheken", - "fields": { - "name": "Name", - "path": "Pfad", - "remotePath": "Remote Pfad", - "lastScanAt": "Letzter Scan", - "songCount": "Lieder", - "albumCount": "Alben", - "artistCount": "Interpreten", - "totalSongs": "Lieder", - "totalAlbums": "Alben", - "totalArtists": "Interpreten", - "totalFolders": "Ordner", - "totalFiles": "Dateien", - "totalMissingFiles": "Fehlende Dateien", - "totalSize": "Größe", - "totalDuration": "Dauer", - "defaultNewUsers": "Standard für neue Benutzer", - "createdAt": "Erstellt", - "updatedAt": "Geändert" - }, - "sections": { - "basic": "Basis Informationen", - "statistics": "Statistik" - }, - "actions": { - "scan": "Bibliothek scannen", - "manageUsers": "Zugriff verwalten", - "viewDetails": "Details ansehen", - "quickScan": "Schneller Scan", - "fullScan": "Kompletter Scan" - }, - "notifications": { - "created": "Bibliothek erfolgreich erstellt", - "updated": "Bibliothek erfolgreich geändert", - "deleted": "Bibliothek erfolgreich gelöscht", - "scanStarted": "Bibliothek Scan gestartet", - "scanCompleted": "Bibliothek Scan vollständig", - "quickScanStarted": "Schneller Scan gestartet", - "fullScanStarted": "Kompletter Scan gestartet", - "scanError": "Fehler beim Starten des Scans. Logs prüfen" - }, - "validation": { - "nameRequired": "Bibliotheksname ist Pflichtfeld", - "pathRequired": "Bibliothekspfad ist Pflichtfeld", - "pathNotDirectory": "Bibliothekspfad muss ein Ordner sein", - "pathNotFound": "Bibliothekspfad nicht gefunden", - "pathNotAccessible": "Bibliothekspfad nicht zugänglich", - "pathInvalid": "Bibliothekspfad ungültig" - }, - "messages": { - "deleteConfirm": "Möchtest du diese Bibliothek wirklich löschen? Zugriffsrechte und Daten werden entfernt. ", - "scanInProgress": "Bibliothek Scan läuft...", - "noLibrariesAssigned": "Keine Bibliotheken zugeordnet" - } - }, - "plugin": { - "name": "Plugin |||| Plugins", - "fields": { - "id": "ID", - "name": "Name", - "description": "Beschreibung", - "version": "Version", - "author": "Autor", - "website": "Website", - "permissions": "Berechtigungen", - "enabled": "Aktiv", - "status": "Status", - "path": "Pfad", - "lastError": "Fehler", - "hasError": "Fehler", - "updatedAt": "Aktualisiert am", - "createdAt": "Installiert", - "configKey": "Schlüssel", - "configValue": "Wert", - "allUsers": "Alle Benutzer", - "selectedUsers": "Ausgewählte Benutzer", - "allLibraries": "Alle Bibliotheken", - "selectedLibraries": "Ausgewählte Bibliotheken", - "allowWriteAccess": "Schreibzugriff erlauben" - }, - "sections": { - "status": "Status", - "info": "Plugin Information", - "configuration": "Konfiguration", - "manifest": "Manifest", - "usersPermission": "Benutzer Zugriff", - "libraryPermission": "Bibliotheken Zugriff" - }, - "status": { - "enabled": "Aktiv", - "disabled": "Inaktiv" - }, - "actions": { - "enable": "Aktivieren", - "disable": "Deaktivieren", - "disabledDueToError": "Fehler beheben um Plugin zu aktivieren", - "disabledUsersRequired": "Wähle Benutzer Zugriff um Plugin zu aktivieren", - "disabledLibrariesRequired": "Wähle Bibliotheken Zugriff um Plugin zu aktivieren", - "addConfig": "Konfiguration hinzufügen", - "rescan": "Scan" - }, - "notifications": { - "enabled": "Plugin aktiv", - "disabled": "Plugin inaktiv", - "updated": "Plugin aktualisiert", - "error": "Fehler beim aktualisieren des Plugins" - }, - "validation": { - "invalidJson": "Konfiguration muss valides JSON sein" - }, - "messages": { - "idHelp": "Die Plugin-ID, abgeleitet vom Dateinamen. Verwende diese, um in Konfigurationen (z. B. bei Agents) auf dieses Plugin zu verweisen.", - "configHelp": "Plugin mit Schlüssel-Werte Paaren konfigurieren. Leer lassen wenn das Plugin keine Konfiguration benötigt.", - "clickPermissions": "Berechtigung anklicken für mehr Details", - "noConfig": "Keine Konfiguration gesetzt", - "allUsersHelp": "Wenn aktiviert, erhält das Plugin Zugriff auf alle Benutzer, inklusive solcher, die in Zukunft erstellt werden.", - "noUsers": "Keine Benutzer ausgewählt", - "permissionReason": "Begründung", - "usersRequired": "Dieses Plugin benötigt Zugriff auf Benutzerinformationen. Wähle aus, auf welche Nutzer das Plugin zugreifen darf oder wähle 'Alle Benutzer'.", - "allLibrariesHelp": "Wenn aktiviert, erhält das Plugin Zugriff auf alle Bibliotheken, inklusive solcher, die in Zukunft erstellt werden.", - "noLibraries": "Keine Bibliotheken ausgewählt", - "librariesRequired": "Dieses Plugin benötigt Zugriff auf Bibliotheken. Wähle aus, auf welche Bibliotheken das Plugin zugreifen darf oder wähle 'Alle Bibliotheken'.", - "requiredHosts": "Benötigte Hosts", - "configValidationError": "Validierung der Konfiguration fehlgeschlagen:", - "schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt.", - "allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff." - }, - "placeholders": { - "configKey": "Schlüssel", - "configValue": "Wert" - } - } - }, - "ra": { - "auth": { - "welcome1": "Vielen Dank für die Installation von Navidrome!", - "welcome2": "Als erstes erstelle einen Admin-Benutzer", - "confirmPassword": "Passwort bestätigen", - "buttonCreateAdmin": "Admin erstellen", - "auth_check_error": "Bitte einloggen um fortzufahren", - "user_menu": "Profil", - "username": "Nutzername", - "password": "Passwort", - "sign_in": "Anmelden", - "sign_in_error": "Fehler bei der Anmeldung", - "logout": "Abmelden", - "insightsCollectionNote": "Navidrome sammelt anonyme Statistiken \num die Entwicklung des Projekts zu unterstützen. \n[here] klicken für mehr Informationen oder um \"Insights\" abzuschalten" - }, - "validation": { - "invalidChars": "Bitte nur Buchstaben und Zahlen verwenden", - "passwordDoesNotMatch": "Passwort stimmt nicht überein", - "required": "Benötigt", - "minLength": "Muss mindestens %{min} Zeichen lang sein", - "maxLength": "Darf maximal %{max} Zeichen lang sein", - "minValue": "Muss mindestens %{min} sein", - "maxValue": "Muss %{max} oder weniger sein", - "number": "Muss eine Nummer sein", - "email": "Muss eine gültige E-Mail sein", - "oneOf": "Es muss einer sein von: %{options}", - "regex": "Es muss folgendem regulären Ausdruck entsprechen: %{pattern}", - "unique": "Muss eindeutig sein", - "url": "Muss eine gültige URL sein" - }, - "action": { - "add_filter": "Filter hinzufügen", - "add": "Hinzufügen", - "back": "Zurück", - "bulk_actions": "Ein Element ausgewählt |||| %{smart_count} Elemente ausgewählt", - "cancel": "Abbrechen", - "clear_input_value": "Eingabe löschen", - "clone": "Klonen", - "confirm": "Bestätigen", - "create": "Erstellen", - "delete": "Löschen", - "edit": "Bearbeiten", - "export": "Exportieren", - "list": "Liste", - "refresh": "Aktualisieren", - "remove_filter": "Filter entfernen", - "remove": "Entfernen", - "save": "Speichern", - "search": "Suchen", - "show": "Anzeigen", - "sort": "Sortieren", - "undo": "Zurücksetzen", - "expand": "Expandieren", - "close": "Schließen", - "open_menu": "Menü öffnen", - "close_menu": "Menü schließen", - "unselect": "Abwählen", - "skip": "Überspringen", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Freigabe erstellen", - "download": "Herunterladen" - }, - "boolean": { - "true": "Ja", - "false": "Nein" - }, - "page": { - "create": "%{name} erstellen", - "dashboard": "Dashboard", - "edit": "%{name} #%{id}", - "error": "Etwas ist schief gelaufen", - "list": "%{name}", - "loading": "Laden", - "not_found": "Nicht gefunden", - "show": "%{name} #%{id}", - "empty": "Noch kein %{name}.", - "invite": "Möchtest du eine hinzufügen?" - }, - "input": { - "file": { - "upload_several": "Zum Hochladen Dateien hineinziehen oder hier klicken, um Dateien auszuwählen.", - "upload_single": "Zum Hochladen Datei hineinziehen oder hier klicken, um eine Datei auszuwählen." - }, - "image": { - "upload_several": "Zum Hochladen Bilder hineinziehen oder hier klicken, um Bilder auszuwählen.", - "upload_single": "Zum Hochladen Bild hineinziehen oder hier klicken, um ein Bild auszuwählen." - }, - "references": { - "all_missing": "Die zugehörigen Referenzen konnten nicht gefunden werden.", - "many_missing": "Mindestens eine der zugehörigen Referenzen scheint nicht mehr verfügbar zu sein.", - "single_missing": "Eine zugehörige Referenz scheint nicht mehr verfügbar zu sein." - }, - "password": { - "toggle_visible": "Passwort verbergen", - "toggle_hidden": "Passwort anzeigen" - } + "ra": { + "auth": { + "welcome1": "Vielen Dank für die Installation von Navidrome!", + "welcome2": "Als erstes erstelle einen Admin-Benutzer", + "confirmPassword": "Passwort bestätigen", + "buttonCreateAdmin": "Admin erstellen", + "auth_check_error": "Bitte einloggen um fortzufahren", + "user_menu": "Profil", + "username": "Nutzername", + "password": "Passwort", + "sign_in": "Anmelden", + "sign_in_error": "Fehler bei der Anmeldung", + "logout": "Abmelden" + }, + "validation": { + "invalidChars": "Bitte nur Buchstaben und Zahlen verwenden", + "passwordDoesNotMatch": "Passwort stimmt nicht überein", + "required": "Benötigt", + "minLength": "Muss mindestens %{min} Zeichen lang sein", + "maxLength": "Darf maximal %{max} Zeichen lang sein", + "minValue": "Muss mindestens %{min} sein", + "maxValue": "Muss %{max} oder weniger sein", + "number": "Muss eine Nummer sein", + "email": "Muss eine gültige E-Mail sein", + "oneOf": "Es muss einer sein von: %{options}", + "regex": "Es muss folgendem regulären Ausdruck entsprechen: %{pattern}", + "unique": "Muss eindeutig sein", + "url": "Muss eine gültige URL sein" + }, + "action": { + "add_filter": "Filter hinzufügen", + "add": "Hinzufügen", + "back": "Zurück", + "bulk_actions": "Ein Element ausgewählt |||| %{smart_count} Elemente ausgewählt", + "cancel": "Abbrechen", + "clear_input_value": "Eingabe löschen", + "clone": "Klonen", + "confirm": "Bestätigen", + "create": "Erstellen", + "delete": "Löschen", + "edit": "Bearbeiten", + "export": "Exportieren", + "list": "Liste", + "refresh": "Aktualisieren", + "remove_filter": "Filter entfernen", + "remove": "Entfernen", + "save": "Speichern", + "search": "Suchen", + "show": "Anzeigen", + "sort": "Sortieren", + "undo": "Zurücksetzen", + "expand": "Expandieren", + "close": "Schließen", + "open_menu": "Menü öffnen", + "close_menu": "Menü schließen", + "unselect": "Abwählen", + "skip": "Überspringen", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Freigabe erstellen", + "download": "Herunterladen" + }, + "boolean": { + "true": "Ja", + "false": "Nein" + }, + "page": { + "create": "%{name} erstellen", + "dashboard": "Dashboard", + "edit": "%{name} #%{id}", + "error": "Etwas ist schief gelaufen", + "list": "%{name}", + "loading": "Laden", + "not_found": "Nicht gefunden", + "show": "%{name} #%{id}", + "empty": "Noch kein %{name}.", + "invite": "Möchtest du eine hinzufügen?" + }, + "input": { + "file": { + "upload_several": "Zum Hochladen Dateien hineinziehen oder hier klicken, um Dateien auszuwählen.", + "upload_single": "Zum Hochladen Datei hineinziehen oder hier klicken, um eine Datei auszuwählen." + }, + "image": { + "upload_several": "Zum Hochladen Bilder hineinziehen oder hier klicken, um Bilder auszuwählen.", + "upload_single": "Zum Hochladen Bild hineinziehen oder hier klicken, um ein Bild auszuwählen." + }, + "references": { + "all_missing": "Die zugehörigen Referenzen konnten nicht gefunden werden.", + "many_missing": "Mindestens eine der zugehörigen Referenzen scheint nicht mehr verfügbar zu sein.", + "single_missing": "Eine zugehörige Referenz scheint nicht mehr verfügbar zu sein." + }, + "password": { + "toggle_visible": "Passwort verbergen", + "toggle_hidden": "Passwort anzeigen" + } + }, + "message": { + "about": "Über", + "are_you_sure": "Bist du sicher?", + "bulk_delete_content": "Möchtest du \"%{name}\" wirklich löschen? |||| Möchtest du diese %{smart_count} Elemente wirklich löschen?", + "bulk_delete_title": "Lösche %{name} |||| Lösche %{smart_count} %{name} Elemente", + "delete_content": "Möchtest du diesen Inhalt wirklich löschen?", + "delete_title": "Lösche %{name} #%{id}", + "details": "Details", + "error": "Ein Fehler ist aufgetreten und deine Anfrage konnte nicht abgeschlossen werden.", + "invalid_form": "Das Formular ist ungültig. Bitte überprüfe deine Eingaben.", + "loading": "Die Seite wird geladen", + "no": "Nein", + "not_found": "Die Seite konnte nicht gefunden werden.", + "yes": "Ja", + "unsaved_changes": "Einige deiner Änderungen wurden nicht gespeichert. Bist du sicher, dass du sie ignorieren möchtest?" + }, + "navigation": { + "no_results": "Keine Resultate gefunden", + "no_more_results": "Die Seite %{page} enthält keine Inhalte.", + "page_out_of_boundaries": "Die Seite %{page} liegt ausserhalb des gültigen Bereichs", + "page_out_from_end": "Letzte Seite", + "page_out_from_begin": "Erste Seite", + "page_range_info": "%{offsetBegin}-%{offsetEnd} von %{total}", + "page_rows_per_page": "Zeilen pro Seite:", + "next": "Weiter", + "prev": "Zurück", + "skip_nav": "Zum Inhalt springen" + }, + "notification": { + "updated": "Element wurde aktualisiert |||| %{smart_count} Elemente wurden aktualisiert", + "created": "Element wurde erstellt", + "deleted": "Element wurde gelöscht |||| %{smart_count} Elemente wurden gelöscht", + "bad_item": "Fehlerhaftes Element", + "item_doesnt_exist": "Das Element existiert nicht", + "http_error": "Fehler beim Kommunizieren mit dem Server", + "data_provider_error": "dataProvider Fehler. Prüfe die Konsole für Details.", + "i18n_error": "Die Übersetzungen für die angegebene Sprache können nicht geladen werden", + "canceled": "Aktion abgebrochen", + "logged_out": "Deine Session wurde beendet. Bitte erneut verbinden.", + "new_version": "Neue Version verfügbar! Bitte aktualisiere dieses Fenster." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Spalten auswählen", + "layout": "Anzeige", + "grid": "Raster", + "table": "Tabelle" + } }, "message": { - "about": "Über", - "are_you_sure": "Bist du sicher?", - "bulk_delete_content": "Möchtest du \"%{name}\" wirklich löschen? |||| Möchtest du diese %{smart_count} Elemente wirklich löschen?", - "bulk_delete_title": "Lösche %{name} |||| Lösche %{smart_count} %{name} Elemente", - "delete_content": "Möchtest du diesen Inhalt wirklich löschen?", - "delete_title": "Lösche %{name} #%{id}", - "details": "Details", - "error": "Ein Fehler ist aufgetreten und deine Anfrage konnte nicht abgeschlossen werden.", - "invalid_form": "Das Formular ist ungültig. Bitte überprüfe deine Eingaben.", - "loading": "Die Seite wird geladen", - "no": "Nein", - "not_found": "Die Seite konnte nicht gefunden werden.", - "yes": "Ja", - "unsaved_changes": "Einige deiner Änderungen wurden nicht gespeichert. Bist du sicher, dass du sie ignorieren möchtest?" - }, - "navigation": { - "no_results": "Keine Resultate gefunden", - "no_more_results": "Die Seite %{page} enthält keine Inhalte.", - "page_out_of_boundaries": "Die Seite %{page} liegt ausserhalb des gültigen Bereichs", - "page_out_from_end": "Letzte Seite", - "page_out_from_begin": "Erste Seite", - "page_range_info": "%{offsetBegin}-%{offsetEnd} von %{total}", - "page_rows_per_page": "Zeilen pro Seite:", - "next": "Weiter", - "prev": "Zurück", - "skip_nav": "Zum Inhalt springen" - }, - "notification": { - "updated": "Element wurde aktualisiert |||| %{smart_count} Elemente wurden aktualisiert", - "created": "Element wurde erstellt", - "deleted": "Element wurde gelöscht |||| %{smart_count} Elemente wurden gelöscht", - "bad_item": "Fehlerhaftes Element", - "item_doesnt_exist": "Das Element existiert nicht", - "http_error": "Fehler beim Kommunizieren mit dem Server", - "data_provider_error": "dataProvider Fehler. Prüfe die Konsole für Details.", - "i18n_error": "Die Übersetzungen für die angegebene Sprache können nicht geladen werden", - "canceled": "Aktion abgebrochen", - "logged_out": "Deine Session wurde beendet. Bitte erneut verbinden.", - "new_version": "Neue Version verfügbar! Bitte aktualisiere dieses Fenster." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Spalten auswählen", - "layout": "Anzeige", - "grid": "Raster", - "table": "Tabelle" - } - }, - "message": { - "note": "HINWEIS", - "transcodingDisabled": "Die Änderung der Transcodierungskonfiguration über die Web-UI ist aus Sicherheitsgründen deaktiviert. Wenn du die Transcodierungsoptionen ändern (bearbeiten oder hinzufügen) möchtest, starte den Server mit der Konfigurationsoption %{config} neu.", - "transcodingEnabled": "Navidrome läuft derzeit mit %{config}, wodurch es möglich ist, Systembefehle aus den Transkodierungseinstellungen über die Webschnittstelle auszuführen. Wir empfehlen, es aus Sicherheitsgründen zu deaktivieren und nur bei der Konfiguration von Transkodierungsoptionen zu aktivieren.", - "songsAddedToPlaylist": "Einen Titel zur Wiedergabeliste hinzugefügt |||| %{smart_count} Titel zur Wiedergabeliste hinzugefügt", - "noPlaylistsAvailable": "Keine Wiedergabeliste verfügbar", - "delete_user_title": "Benutzer '%{name}' löschen", - "delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Wiedergabelisten und Einstellungen) wirklich löschen?", - "notifications_blocked": "Benachrichtigungen für diese Seite sind in den Browsereinstellungen blockiert", - "notifications_not_available": "Dieser Browser unterstützt keine Desktop-Benachrichtigungen", - "lastfmLinkSuccess": "Last.fm Verbindung hergestellt und scrobbling aktiviert", - "lastfmLinkFailure": "Last.fm konnte nicht verbunden werden", - "lastfmUnlinkSuccess": "Last.fm Verbindung entfernt und scrobbling deaktiviert", - "lastfmUnlinkFailure": "Last.fm Verbindung konnte nicht entfernt werden", - "openIn": { - "lastfm": "Auf Last.fm anzeigen", - "musicbrainz": "Auf MusicBrainz anzeigen" - }, - "lastfmLink": "Mehr lesen", - "listenBrainzLinkSuccess": "Last.fm Verbindung hergestellt und und scrobbling aktiviert als user: %{user}", - "listenBrainzLinkFailure": "ListenBrainz konnte nicht verbunden werden: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz Verbindung entfernt und scrobbling deaktiviert", - "listenBrainzUnlinkFailure": "ListenBrainz Verbindung konnte nicht entfernt werden", - "downloadOriginalFormat": "Download im Original Format", - "shareOriginalFormat": "Freigeben im Original Format", - "shareDialogTitle": "%{resource} '%{name}' freigeben", - "shareBatchDialogTitle": "1 %{resource} freigeben |||| %{smart_count} %{resource} freigeben", - "shareSuccess": "URL in Zwischenablage kopiert: %{url}", - "shareFailure": "Fehler URL %{url} konnte nicht in Zwischenablage kopiert werden", - "downloadDialogTitle": "Download %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "In Zwischenablage kopieren: Ctrl+C, Enter", - "remove_missing_title": "Fehlende Dateien entfernen", - "remove_missing_content": "Möchtest du die ausgewählten Fehlenden Dateien wirklich aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.", - "remove_all_missing_title": "Alle fehlenden Dateien entfernen", - "remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.", - "noSimilarSongsFound": "Keine ähnlichen Titel gefunden", - "noTopSongsFound": "Keine beliebten Titel gefunden", - "startingInstantMix": "Lade Sofort-Mix...", - "uploadCover": "Cover hochladen", - "removeCover": "Cover entfernen", - "coverUploaded": "Cover aktualisiert", - "coverRemoved": "Cover entfernt", - "coverUploadError": "Fehler beim Hochladen des Covers", - "coverRemoveError": "Fehler beim Entfernen des Covers", - "metadataRefreshStarted": "Aktualisiert Metadaten im Hintergrund" - }, - "menu": { - "library": "Bibliothek", - "settings": "Einstellungen", - "version": "Version", - "theme": "Design", - "personal": { - "name": "Persönlich", - "options": { - "theme": "Design", - "language": "Sprache", - "defaultView": "Standard-Ansicht", - "desktop_notifications": "Desktop-Benachrichtigungen", - "lastfmScrobbling": "Last.fm Scrobbling", - "listenBrainzScrobbling": "ListenBrainz Scrobbling", - "replaygain": "ReplayGain Modus", - "preAmp": "ReplayGain Vorverstärkung (dB)", - "gain": { - "none": "Deaktiviert", - "album": "Album Gain verwenden", - "track": "Titel Gain verwenden" + "note": "HINWEIS", + "transcodingDisabled": "Die Änderung der Transcodierungskonfiguration über die Web-UI ist aus Sicherheitsgründen deaktiviert. Wenn du die Transcodierungsoptionen ändern (bearbeiten oder hinzufügen) möchtest, starte den Server mit der Konfigurationsoption %{config} neu.", + "transcodingEnabled": "Navidrome läuft derzeit mit %{config}, wodurch es möglich ist, Systembefehle aus den Transkodierungseinstellungen über die Webschnittstelle auszuführen. Wir empfehlen, es aus Sicherheitsgründen zu deaktivieren und nur bei der Konfiguration von Transkodierungsoptionen zu aktivieren.", + "songsAddedToPlaylist": "Einen Titel zur Playlist hinzugefügt |||| %{smart_count} Titel zur Playlist hinzugefügt", + "noPlaylistsAvailable": "Keine Playlist verfügbar", + "delete_user_title": "Benutzer \"% {name}\" löschen", + "delete_user_content": "Möchtest du diesen Benutzer und alle seine Daten (einschließlich Playlisten und Einstellungen) wirklich löschen?", + "notifications_blocked": "Sie haben Benachrichtigungen für diese Seite in den Einstellungen Ihres Browsers blockiert", + "notifications_not_available": "Dieser Browser unterstützt keine Desktop-Benachrichtigungen", + "lastfmLinkSuccess": "Last.fm Verbindung hergestellt und scrobbling aktiviert", + "lastfmLinkFailure": "Last.fm konnte nicht verbunden werden", + "lastfmUnlinkSuccess": "Last.fm Verbindung entfernt und scrobbling deaktiviert", + "lastfmUnlinkFailure": "Last.fm Verbindung konnte nicht entfernt werden", + "openIn": { + "lastfm": "Auf Last.fm anzeigen", + "musicbrainz": "Auf MusicBrainz anzeigen" }, - "lastfmNotConfigured": "Last.fm API-Key ist nicht konfiguriert" - } + "lastfmLink": "Mehr lesen", + "listenBrainzLinkSuccess": "Last.fm Verbindung hergestellt und und scrobbling aktiviert als user: %{user}", + "listenBrainzLinkFailure": "ListenBrainz konnte nicht verbunden werden: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz Verbindung entfernt und scrobbling deaktiviert", + "listenBrainzUnlinkFailure": "ListenBrainz Verbindung konnte nicht entfernt werden", + "downloadOriginalFormat": "Download im Original Format", + "shareOriginalFormat": "Freigeben im Original Format", + "shareDialogTitle": "%{resource} '%{name}' freigeben", + "shareBatchDialogTitle": "1 %{resource} freigeben |||| %{smart_count} %{resource} freigeben", + "shareSuccess": "URL in Zwischenablage kopiert: %{url}", + "shareFailure": "Fehler URL %{url} konnte nicht in Zwischenablage kopiert werden", + "downloadDialogTitle": "Download %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "In Zwischenablage kopieren: Ctrl+C, Enter" }, - "albumList": "Alben", - "about": "Über", - "playlists": "Wiedergabelisten", - "sharedPlaylists": "Geteilte Wiedergabelisten", - "librarySelector": { - "allLibraries": "Alle Bibliotheken (%{count})", - "multipleLibraries": "%{selected} von %{total} Bibliotheken", - "selectLibraries": "Bibliotheken auswählen", - "none": "Keine" + "menu": { + "library": "Bibliothek", + "settings": "Einstellungen", + "version": "Version", + "theme": "Design", + "personal": { + "name": "Persönlich", + "options": { + "theme": "Design", + "language": "Sprache", + "defaultView": "Standard-Ansicht", + "desktop_notifications": "Desktop-Benachrichtigungen", + "lastfmScrobbling": "Last.fm Scrobbling", + "listenBrainzScrobbling": "ListenBrainz Scrobbling", + "replaygain": "ReplayGain Modus", + "preAmp": "ReplayGain Vorverstärkung (dB)", + "gain": { + "none": "Deaktiviert", + "album": "Album Gain verwenden", + "track": "Titel Gain verwenden" + } + } + }, + "albumList": "Alben", + "about": "Über", + "playlists": "Playlisten", + "sharedPlaylists": "Geteilte Playlisten" }, - "onlyFavourites": "Nur Favoriten anzeigen" - }, - "player": { - "playListsText": "Warteschlange abspielen", - "openText": "Öffnen", - "closeText": "Schließen", - "notContentText": "Keine Musik", - "clickToPlayText": "Anklicken zum Abzuspielen", - "clickToPauseText": "Anklicken zum Pausieren", - "nextTrackText": "Nächster Titel", - "previousTrackText": "Vorheriger Titel", - "reloadText": "Neu laden", - "volumeText": "Lautstärke", - "toggleLyricText": "Liedtext umschalten", - "toggleMiniModeText": "Minimieren", - "destroyText": "Zerstören", - "downloadText": "Herunterladen", - "removeAudioListsText": "Audiolisten entfernen", - "clickToDeleteText": "Klicken um %{name} zu Löschen", - "emptyLyricText": "Kein Liedtext", - "playModeText": { - "order": "Der Reihe nach", - "orderLoop": "Wiederholen", - "singleLoop": "Eins wiederholen", - "shufflePlay": "Zufallswiedergabe" + "player": { + "playListsText": "Wiedergabeliste abspielen", + "openText": "Öffnen", + "closeText": "Schließen", + "notContentText": "Keine Musik", + "clickToPlayText": "Anklicken zum Abzuspielen", + "clickToPauseText": "Anklicken zum Pausieren", + "nextTrackText": "Nächster Titel", + "previousTrackText": "Vorheriger Titel", + "reloadText": "Neu laden", + "volumeText": "Lautstärke", + "toggleLyricText": "Liedtext umschalten", + "toggleMiniModeText": "Minimieren", + "destroyText": "Zerstören", + "downloadText": "Herunterladen", + "removeAudioListsText": "Audiolisten löschen", + "clickToDeleteText": "Klicken um %{name} zu Löschen", + "emptyLyricText": "Kein Liedtext", + "playModeText": { + "order": "Der Reihe nach", + "orderLoop": "Wiederholen", + "singleLoop": "Eins wiederholen", + "shufflePlay": "Zufallswiedergabe" + } + }, + "about": { + "links": { + "homepage": "Startseite", + "source": "Quellcode", + "featureRequests": "Feature-Request" + } + }, + "activity": { + "title": "Aktivität", + "totalScanned": "Insgesamt gescannte Ordner", + "quickScan": "Schneller Scan", + "fullScan": "Kompletter Scan", + "serverUptime": "Server Uptime", + "serverDown": "OFFLINE" + }, + "help": { + "title": "Navidrome Hotkeys", + "hotkeys": { + "show_help": "Diese Hilfe anzeigen", + "toggle_menu": "Seitenleiste umschalten", + "toggle_play": "Play / Pause", + "prev_song": "vorheriger Titel", + "next_song": "Nächster Titel", + "vol_up": "Lauter", + "vol_down": "Leiser", + "toggle_love": "Titel zu Favoriten hinzufügen", + "current_song": "Aktuellen Titel Anzeigen" + } } - }, - "about": { - "links": { - "homepage": "Startseite", - "source": "Quellcode", - "featureRequests": "Feature-Request", - "lastInsightsCollection": "Letzte \"Insights\" Erfassung", - "insights": { - "disabled": "Deaktiviert", - "waiting": "Warten" - } - }, - "tabs": { - "about": "Über", - "config": "Konfiguration" - }, - "config": { - "configName": "Einstellung", - "environmentVariable": "Umbegungsvariable", - "currentValue": "Wert", - "configurationFile": "Konfigurationsdatei", - "exportToml": "Konfiguration exportieren (TOML)", - "exportSuccess": "Konfiguration im TOML Format in die Zwischenablage kopiert", - "exportFailed": "Fehler beim Kopieren der Konfiguration", - "devFlagsHeader": "Entwicklungseinstellungen (können sich ändern)", - "devFlagsComment": "Experimentelle Einstellungen, die eventuell in Zukunft entfernt oder geändert werden", - "downloadToml": "Konfiguration Herunterladen (TOML)" - } - }, - "activity": { - "title": "Aktivität", - "totalScanned": "Insgesamt gescannte Ordner", - "quickScan": "Schneller Scan", - "fullScan": "Kompletter Scan", - "serverUptime": "Server-Betriebszeit", - "serverDown": "OFFLINE", - "scanType": "Typ", - "status": "Scan Fehler", - "elapsedTime": "Laufzeit", - "selectiveScan": "Selektiver Scan" - }, - "help": { - "title": "Navidrome Hotkeys", - "hotkeys": { - "show_help": "Diese Hilfe anzeigen", - "toggle_menu": "Seitenleiste umschalten", - "toggle_play": "Play / Pause", - "prev_song": "vorheriger Titel", - "next_song": "Nächster Titel", - "vol_up": "Lauter", - "vol_down": "Leiser", - "toggle_love": "Titel zu Favoriten hinzufügen", - "current_song": "Aktuellen Titel Anzeigen" - } - }, - "nowPlaying": { - "title": "Aktuelle Wiedergabe", - "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 deleted file mode 100644 index 37882ea99..000000000 --- a/resources/i18n/el.json +++ /dev/null @@ -1,734 +0,0 @@ -{ - "languageName": "Ελληνικά", - "resources": { - "song": { - "name": "Τραγούδι |||| Τραγούδια", - "fields": { - "albumArtist": "Καλλιτεχνης Αλμπουμ", - "duration": "Διαρκεια", - "trackNumber": "#", - "playCount": "Αναπαραγωγες", - "title": "Τιτλος", - "artist": "Καλλιτεχνης", - "album": "Αλμπουμ", - "path": "Διαδρομη αρχειου", - "genre": "Ειδος", - "compilation": "Συλλογή", - "year": "Ετος", - "size": "Μεγεθος αρχειου", - "updatedAt": "Ενημερωθηκε", - "bitRate": "Ρυθμός Bit", - "discSubtitle": "Υπότιτλοι Δίσκου", - "starred": "Αγαπημένο", - "comment": "Σχόλιο", - "rating": "Βαθμολογια", - "quality": "Ποιοτητα", - "bpm": "BPM", - "playDate": "Παίχτηκε Τελευταία", - "channels": "Κανάλια", - "createdAt": "Ημερομηνία προσθήκης", - "grouping": "Ομαδοποίηση", - "mood": "Διάθεση", - "participants": "Πρόσθετοι συμμετέχοντες", - "tags": "Πρόσθετες Ετικέτες", - "mappedTags": "Χαρτογραφημένες ετικέτες", - "rawTags": "Ακατέργαστες ετικέτες", - "bitDepth": "Λίγο βάθος", - "sampleRate": "Ποσοστό δειγματοληψίας", - "missing": "Απών", - "libraryName": "Βιβλιοθήκη", - "composer": "Συνθέτης", - "disc": "Δίσκος %{discNumber}", - "albumGain": "Κέρδος άλμπουμ", - "trackGain": "Κέρδος παρακολούθησης" - }, - "actions": { - "addToQueue": "Αναπαραγωγη Μετα", - "playNow": "Αναπαραγωγή Τώρα", - "addToPlaylist": "Προσθήκη στη λίστα αναπαραγωγής", - "shuffleAll": "Ανακατεμα ολων", - "download": "Ληψη", - "playNext": "Επόμενη Αναπαραγωγή", - "info": "Εμφάνιση Πληροφοριών", - "showInPlaylist": "Εμφάνιση στη λίστα αναπαραγωγής", - "instantMix": "Άμεση Μίξη" - } - }, - "album": { - "name": "Άλμπουμ |||| Άλμπουμ", - "fields": { - "albumArtist": "Καλλιτεχνης Αλμπουμ", - "artist": "Καλλιτεχνης", - "duration": "Διαρκεια", - "songCount": "Τραγουδια", - "playCount": "Αναπαραγωγες", - "name": "Ονομα", - "genre": "Ειδος", - "compilation": "Συλλογη", - "year": "Ετος", - "updatedAt": "Ενημερωθηκε", - "comment": "Σχόλιο", - "rating": "Βαθμολογια", - "createdAt": "Ημερομηνία προσθήκης", - "size": "Μέγεθος", - "originalDate": "Πρωτότυπο", - "releaseDate": "Κυκλοφόρησε", - "releases": "Έκδοση |||| Εκδόσεις", - "released": "Κυκλοφόρησε", - "recordLabel": "Επιγραφή", - "catalogNum": "Αριθμός καταλόγου", - "releaseType": "Τύπος", - "grouping": "Ομαδοποίηση", - "media": "Μέσα", - "mood": "Διάθεση", - "date": "Ημερομηνία Ηχογράφησης", - "missing": "Απών", - "libraryName": "Βιβλιοθήκη" - }, - "actions": { - "playAll": "Αναπαραγωγή", - "playNext": "Αναπαραγωγη Μετα", - "addToQueue": "Αναπαραγωγη Αργοτερα", - "shuffle": "Ανακατεμα", - "addToPlaylist": "Προσθηκη στη λιστα αναπαραγωγης", - "download": "Ληψη", - "info": "Εμφάνιση Πληροφοριών", - "share": "Μερίδιο", - "refresh": "Ανανέωση μεταδεδομένων" - }, - "lists": { - "all": "Όλα", - "random": "Τυχαία", - "recentlyAdded": "Νέες Προσθήκες", - "recentlyPlayed": "Παίχτηκαν Πρόσφατα", - "mostPlayed": "Παίζονται Συχνά", - "starred": "Αγαπημένα", - "topRated": "Κορυφαία" - } - }, - "artist": { - "name": "Καλλιτέχνης |||| Καλλιτέχνες", - "fields": { - "name": "Ονομα", - "albumCount": "Αναπαραγωγές Αλμπουμ", - "songCount": "Αναπαραγωγες Τραγουδιου", - "playCount": "Αναπαραγωγες", - "rating": "Βαθμολογια", - "genre": "Είδος", - "size": "Μέγεθος", - "role": "Ρόλος", - "missing": "Απών" - }, - "roles": { - "albumartist": "Καλλιτέχνης Άλμπουμ |||| Καλλιτέχνες άλμπουμ", - "artist": "Καλλιτέχνης |||| Καλλιτέχνες", - "composer": "Συνθέτης |||| Συνθέτες", - "conductor": "Μαέστρος |||| Μαέστροι", - "lyricist": "Στιχουργός |||| Στιχουργοί", - "arranger": "Τακτοποιητής |||| Τακτοποιητές", - "producer": "Παραγωγός |||| Παραγωγοί", - "director": "Διευθυντής |||| Διευθυντές", - "engineer": "Μηχανικός |||| Μηχανικοί", - "mixer": "Μίξερ |||| Μίξερ", - "remixer": "Ρεμίξερ |||| Ρεμίξερ", - "djmixer": "Dj Μίξερ |||| Dj Μίξερ", - "performer": "Εκτελεστής |||| Ερμηνευτές", - "maincredit": "Καλλιτέχνης Άλμπουμ ή Καλλιτέχνης |||| Καλλιτέχνες Άλμπουμ ή Καλλιτέχνες" - }, - "actions": { - "shuffle": "Ανάμιξη", - "radio": "Ραδιόφωνο", - "topSongs": "Κορυφαία τραγούδια" - } - }, - "user": { - "name": "Χρήστης |||| Χρήστες", - "fields": { - "userName": "Ονομα Χρηστη", - "isAdmin": "Ειναι Διαχειριστης", - "lastLoginAt": "Τελευταια συνδεση στις", - "updatedAt": "Ενημερωθηκε", - "name": "Όνομα", - "password": "Κωδικός Πρόσβασης", - "createdAt": "Δημιουργήθηκε στις", - "changePassword": "Αλλαγή Κωδικού Πρόσβασης?", - "currentPassword": "Υπάρχων Κωδικός Πρόσβασης", - "newPassword": "Νέος Κωδικός Πρόσβασης", - "token": "Token", - "lastAccessAt": "Τελευταία Πρόσβαση", - "libraries": "Βιβλιοθήκες", - "scrobbleFilter": "Φίλτρο Scrobble" - }, - "helperTexts": { - "name": "Αλλαγές στο όνομα σας θα εφαρμοστούν στην επόμενη σύνδεση", - "libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη", - "scrobbleFilter": "Τα τραγούδια που αντιστοιχούν σε αυτούς τους κανόνες έξυπνης λίστας αναπαραγωγής δεν αποστέλλονται στα πρόσθετα Last.fm, ListenBrainz ή scrobbler. Χρησιμοποιεί την ίδια σύνταξη και συμπεριφορά JSON με τις έξυπνες λίστες αναπαραγωγής. Παράδειγμα: {\"all\":[{\"lt\":{\"rating\":4}}]}." - }, - "notifications": { - "created": "Ο χρήστης δημιουργήθηκε", - "updated": "Ο χρήστης ενημερώθηκε", - "deleted": "Ο χρήστης διαγράφηκε" - }, - "message": { - "listenBrainzToken": "Εισάγετε το token του χρήστη σας στο ListenBrainz.", - "clickHereForToken": "Κάντε κλικ εδώ για να αποκτήσετε το token σας", - "selectAllLibraries": "Επιλογή όλων των βιβλιοθηκών", - "adminAutoLibraries": "Οι χρήστες διαχειριστές έχουν αυτόματα πρόσβαση σε όλες τις βιβλιοθήκες" - }, - "validation": { - "librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές", - "invalidScrobbleFilter": "Πρέπει να υπάρχουν έγκυροι κανόνες έξυπνης λίστας αναπαραγωγής. Δεν υποστηρίζονται το όριο, η μετατόπιση και η καθυστέρηση ανανέωσης." - } - }, - "player": { - "name": "Συσκευή Αναπαραγωγής |||| Συσκευές Αναπαραγωγής", - "fields": { - "name": "Όνομα", - "transcodingId": "Διακωδικοποίηση", - "maxBitRate": "Μεγ. Ρυθμός Bit", - "client": "Πελάτης", - "userName": "Ονομα Χρηστη", - "lastSeen": "Τελευταια προβολη στις", - "reportRealPath": "Αναφορά Πραγματικής Διαδρομής", - "scrobbleEnabled": "Αποστολή Scrobbles σε εξωτερικές συσκευές" - } - }, - "transcoding": { - "name": "Διακωδικοποίηση |||| Διακωδικοποιήσεις", - "fields": { - "name": "Όνομα", - "targetFormat": "Μορφη Προορισμου", - "defaultBitRate": "Προκαθορισμένος Ρυθμός Bit", - "command": "Εντολή" - }, - "choices": { - "noDefaultBitRate": "" - } - }, - "playlist": { - "name": "Λίστα αναπαραγωγής |||| Λίστες αναπαραγωγής", - "fields": { - "name": "Όνομα", - "duration": "Διάρκεια", - "ownerName": "Ιδιοκτήτης", - "public": "Δημόσιο", - "updatedAt": "Ενημερωθηκε", - "createdAt": "Δημιουργήθηκε στις", - "songCount": "Τραγούδια", - "comment": "Σχόλιο", - "sync": "Αυτόματη εισαγωγή", - "path": "Εισαγωγή από", - "starred": "Ευνοούμενος" - }, - "actions": { - "selectPlaylist": "Επιλέξτε μια λίστα αναπαραγωγής:", - "addNewPlaylist": "Δημιουργία \"%{name}\"", - "export": "Εξαγωγη", - "makePublic": "Να γίνει δημόσιο", - "makePrivate": "Να γίνει ιδιωτικό", - "saveQueue": "Αποθήκευση ουράς στη λίστα αναπαραγωγής", - "searchOrCreate": "Αναζητήστε λίστες αναπαραγωγής ή πληκτρολογήστε για να δημιουργήσετε νέες...", - "pressEnterToCreate": "Πατήστε Enter για να δημιουργήσετε νέα λίστα αναπαραγωγής", - "removeFromSelection": "Αφαίρεση από την επιλογή" - }, - "message": { - "duplicate_song": "Προσθήκη διπλοεγγραφών τραγουδιών", - "song_exist": "Υπάρχουν διπλοεγγραφές στην λίστα αναπαραγωγής. Θέλετε να προστεθούν οι διπλοεγγραφές ή να τις παραβλέψετε?", - "noPlaylistsFound": "Δεν βρέθηκαν λίστες αναπαραγωγής", - "noPlaylists": "Δεν υπάρχουν διαθέσιμες λίστες αναπαραγωγής" - } - }, - "radio": { - "name": "Ραδιόφωνο |||| Ραδιόφωνα", - "fields": { - "name": "Όνομα", - "streamUrl": "Ρεύμα URL", - "homePageUrl": "Αρχική σελίδα URL", - "updatedAt": "Ενημερώθηκε στις", - "createdAt": "Δημιουργήθηκε στις" - }, - "actions": { - "playNow": "Αναπαραγωγή" - } - }, - "share": { - "name": "Μοιραστείτε |||| Μερίδια", - "fields": { - "username": "Κοινή χρήση από", - "url": "URL", - "description": "Περιγραφή", - "contents": "Περιεχόμενα", - "expiresAt": "Λήγει", - "lastVisitedAt": "Τελευταία Επίσκεψη", - "visitCount": "Επισκέψεις", - "format": "Μορφή", - "maxBitRate": "Μέγ. Ρυθμός Bit", - "updatedAt": "Ενημερώθηκε στις", - "createdAt": "Δημιουργήθηκε στις", - "downloadable": "Επιτρέπονται οι λήψεις?" - } - }, - "missing": { - "name": "Λείπει αρχείο |||| Λείπουν αρχεία", - "fields": { - "path": "Διαδρομή", - "size": "Μέγεθος", - "updatedAt": "Εξαφανίστηκε", - "libraryName": "Βιβλιοθήκη" - }, - "actions": { - "remove": "Αφαίρεση", - "remove_all": "Αφαίρεση όλων" - }, - "notifications": { - "removed": "Λείπει αρχείο(α) αφαιρέθηκε" - }, - "empty": "Δεν λείπουν αρχεία" - }, - "library": { - "name": "Βιβλιοθήκη |||| Βιβλιοθήκες", - "fields": { - "name": "Ονομα", - "path": "διαδρομή", - "remotePath": "Απομακρυσμένη διαδρομή", - "lastScanAt": "Τελευταία σάρωση", - "songCount": "Τραγούδια", - "albumCount": "Άλμπουμ", - "artistCount": "Καλλιτέχνες", - "totalSongs": "Τραγούδια", - "totalAlbums": "Άλμπουμ", - "totalArtists": "Καλλιτέχνες", - "totalFolders": "Φάκελοι", - "totalFiles": "Αρχεία", - "totalMissingFiles": "Λείπει αρχείο", - "totalSize": "Συνολικό μέγεθος", - "totalDuration": "Διάρκεια", - "defaultNewUsers": "Προεπιλογή για νέους χρήστες", - "createdAt": "Δημιουργήθηκε", - "updatedAt": "Ενημερώθηκε" - }, - "sections": { - "basic": "Βασικές πληροφορίες", - "statistics": "Στατιστική" - }, - "actions": { - "scan": "Σάρωση βιβλιοθήκης", - "manageUsers": "Διαχείριση πρόσβασης χρήστη", - "viewDetails": "Προβολή λεπτομερειών", - "quickScan": "Γρήγορη σάρωση", - "fullScan": "Πλήρης σάρωση" - }, - "notifications": { - "created": "Η βιβλιοθήκη δημιουργήθηκε με επιτυχία", - "updated": "Η βιβλιοθήκη ενημερώθηκε με επιτυχία", - "deleted": "Η βιβλιοθήκη διαγράφηκε με επιτυχία", - "scanStarted": "Ξεκίνησε η σάρωση της βιβλιοθήκης", - "scanCompleted": "Η σάρωση της βιβλιοθήκης ολοκληρώθηκε", - "quickScanStarted": "Η Γρήγορη Σάρωση ξεκίνησε", - "fullScanStarted": "Η πλήρης σάρωση ξεκίνησε", - "scanError": "Σφάλμα κατά την έναρξη της σάρωσης. Ελέγξτε τα αρχεία καταγραφής." - }, - "validation": { - "nameRequired": "Απαιτείται όνομα βιβλιοθήκης", - "pathRequired": "Απαιτείται διαδρομή βιβλιοθήκης", - "pathNotDirectory": "Η διαδρομή της βιβλιοθήκης πρέπει να είναι ένας κατάλογος", - "pathNotFound": "Η διαδρομή της βιβλιοθήκης δεν βρέθηκε", - "pathNotAccessible": "Η διαδρομή της βιβλιοθήκης δεν είναι προσβάσιμη", - "pathInvalid": "Μη έγκυρη διαδρομή βιβλιοθήκης" - }, - "messages": { - "deleteConfirm": "Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτήν τη βιβλιοθήκη? Αυτή η ενέργεια θα καταργήσει όλα τα σχετικά δεδομένα και την πρόσβαση των χρηστών.", - "scanInProgress": "Σάρωση σε εξέλιξη...", - "noLibrariesAssigned": "Δεν έχουν αντιστοιχιστεί βιβλιοθήκες σε αυτόν τον χρήστη" - } - }, - "plugin": { - "name": "Πρόσθετο |||| Πρόσθετα", - "fields": { - "id": "ID", - "name": "Όνομα", - "description": "Περιγραφή", - "version": "Έκδοση", - "author": "Καλλιτέχνης", - "website": "Ιστοσελίδα", - "permissions": "Άδειες", - "enabled": "Ενεργό", - "status": "Κατάσταση", - "path": "Διαδρομή", - "lastError": "Σφάλμα", - "hasError": "Σφάλμα", - "updatedAt": "Ενημερώθηκε", - "createdAt": "Εγκατασταθηκε", - "configKey": "Κλειδί", - "configValue": "Τιμή", - "allUsers": "Επιτρέψτε όλους τους χρήστες", - "selectedUsers": "Επιλογή χρηστών", - "allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες", - "selectedLibraries": "Επιλεγμένες βιβλιοθήκες", - "allowWriteAccess": "Επιτρέψτε την πρόσβαση εγγραφής" - }, - "sections": { - "status": "Κατάσταση", - "info": "Πληροφορίες Πρόσθετου", - "configuration": "Παραμετροποίηση", - "manifest": "Manifest", - "usersPermission": "Άδειες Χρηστών", - "libraryPermission": "Άδειες Βιβλιοθηκών" - }, - "status": { - "enabled": "Ενεργό", - "disabled": "Ανενεργό" - }, - "actions": { - "enable": "Ενεργοποίηση", - "disable": "Απενεργοποίηση", - "disabledDueToError": "Διορθώστε το σφάλμα πριν την ενεργοποίηση", - "disabledUsersRequired": "Επιλέξτε χρήστες πριν την ενεργοποίηση", - "disabledLibrariesRequired": "Επιλέξτε βιβλιοθήκες πριν την ενεργοποίηση", - "addConfig": "Προσθήκη παραμετροποίησης", - "rescan": "Σάρωση ξανά" - }, - "notifications": { - "enabled": "Πρόσθετο ενεργοποιημένο", - "disabled": "Πρόσθετο απενεργοποιημένο", - "updated": "Πρόσθετο ενημερωμένο", - "error": "Σφάλμα κατά την ενημέρωση του πρόσθετου" - }, - "validation": { - "invalidJson": "Η παραμετροποίηση πρέπει να είναι συμβατό JSON" - }, - "messages": { - "configHelp": "Παραμετροποιήστε το πρόσθετο με χρήση ζεύγων κλειδιών-τιμών. Αφήστε κενό αν το πρόσθετο δεν απαιτεί παραμετροποίηση", - "clickPermissions": "Κάνετε κλικ για λεπτομέρειες αδειών", - "noConfig": "Δεν ορίστηκε παραμετροποίηση", - "allUsersHelp": "Όταν είναι ενεργό, το πρόσθετο θα έχει πρόσβαση σε όλους τους χρήστες, συμπεριλαμβανομένων και όσων δημιουργηθούν στο μέλλον.", - "noUsers": "Δεν επιλέχθηκαν χρήστες", - "permissionReason": "Αιτία", - "usersRequired": "Το πρόσθετο απαιτεί πρόσβαση στις πληροφορίες χρηστών. Ορίστε τους χρήστες που θα έχει πρόσβαση το πρόσθετο, ή ενεργοποιήστε το 'Επιτρέψτε όλους τους χρήστες'", - "allLibrariesHelp": "Όταν είναι ενεργό, το πρόσθετο θα έχει πρόσβαση σε όλες τις βιβλιοθήκες, συμπεριλαμβανομένων και όσων δημιουργηθούν στο μέλλον.", - "noLibraries": "Δεν επιλέχθηκαν βιβλιοθήκες", - "librariesRequired": "Αυτό το πρόσθετο απαιτεί πρόσβαση στις πληροφορίες βιβλιοθήκης. Επιλέξτε σε ποιές βιβλιοθήκες μπορεί να έχει πρόσβαση το πρόσθετο, ή ενεργοποιήστε το 'Επιτρέψτε όλες τις βιβλιοθήκες'", - "requiredHosts": "Απαιτούμενοι hosts", - "configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:", - "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.", - "allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση.", - "idHelp": "" - }, - "placeholders": { - "configKey": "κλειδί", - "configValue": "τιμή" - } - } - }, - "ra": { - "auth": { - "welcome1": "Σας ευχαριστούμε που εγκαταστήσατε το Navidrome!", - "welcome2": "Για να ξεκινήσετε, δημιουργήστε έναν χρήστη ως διαχειριστή", - "confirmPassword": "Επιβεβαίωση κωδικού πρόσβασης", - "buttonCreateAdmin": "Δημιουργία Διαχειριστή", - "auth_check_error": "Παρακαλούμε συνδεθείτε για να συννεχίσετε", - "user_menu": "Προφίλ", - "username": "Ονομα Χρηστη", - "password": "Κωδικός Πρόσβασης", - "sign_in": "Σύνδεση", - "sign_in_error": "Η αυθεντικοποίηση απέτυχε, παρακαλούμε προσπαθήστε ξανά", - "logout": "Αποσύνδεση", - "insightsCollectionNote": "Το Navidrome συλλέγει ανώνυμα δεδομένα χρήσης σε\nβοηθήσουν στη βελτίωση του έργου. Κάντε κλικ [εδώ] για να μάθετε\nπερισσότερα και να εξαιρεθείτε αν θέλετε" - }, - "validation": { - "invalidChars": "Παρακαλούμε χρησημοποιήστε μόνο γράμματα και αριθμούς", - "passwordDoesNotMatch": "Ο κωδικός πρόσβασης δεν ταιριάζει", - "required": "Υποχρεωτικό", - "minLength": "Πρέπει να είναι %{min} χαρακτήρες τουλάχιστον", - "maxLength": "Πρέπει να είναι %{max} χαρακτήρες ή λιγότερο", - "minValue": "Πρέπει να είναι τουλάχιστον %{min}", - "maxValue": "Πρέπει να είναι %{max} ή λιγότερο", - "number": "Πρέπει να είναι αριθμός", - "email": "Πρέπει να είναι ένα έγκυρο email", - "oneOf": "Πρέπει να είναι ένα από τα ακόλουθα: %{options}", - "regex": "Πρέπει να ταιριάζει με ένα συγκεκριμένο τύπο (κανονική έκφραση): %{pattern}", - "unique": "Πρέπει να είναι μοναδικό", - "url": "Πρέπει να είναι έγκυρη διεύθυνση URL" - }, - "action": { - "add_filter": "Προσθηκη φιλτρου", - "add": "Προσθήκη", - "back": "Πίσω", - "bulk_actions": "1 αντικείμενο επιλέχθηκε |||| %{smart_count} αντικείμενα επιλέχθηκαν", - "cancel": "Ακύρωση", - "clear_input_value": "Καθαρισμός τιμής", - "clone": "Κλωνοποίηση", - "confirm": "Επιβεβαίωση", - "create": "Δημιουργία", - "delete": "Διαγραφή", - "edit": "Επεξεργασία", - "export": "Εξαγωγη", - "list": "Λίστα", - "refresh": "Ανανέωση", - "remove_filter": "Αφαίρεση αυτού του φίλτρου", - "remove": "Αφαίρεση", - "save": "Αποθηκευση", - "search": "Αναζήτηση", - "show": "Προβολή", - "sort": "Ταξινόμιση", - "undo": "Αναίρεση", - "expand": "Επέκταση", - "close": "Κλείσιμο", - "open_menu": "Άνοιγμα μενού", - "close_menu": "Κλείσιμο μενού", - "unselect": "Αποεπιλογή", - "skip": "Παράβλεψη", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Κοινοποίηση", - "download": "Λήψη" - }, - "boolean": { - "true": "Ναι", - "false": "Όχι" - }, - "page": { - "create": "Δημιουργία %{name}", - "dashboard": "Πίνακας Ελέγχου", - "edit": "%{name} #%{id}", - "error": "Κάτι πήγε στραβά", - "list": "%{name}", - "loading": "Φόρτωση", - "not_found": "Δεν βρέθηκε", - "show": "%{name} #%{id}", - "empty": "Δεν υπάρχει %{name} ακόμη.", - "invite": "Θέλετε να προσθέσετε ένα?" - }, - "input": { - "file": { - "upload_several": "Ρίξτε μερικά αρχεία για να τα ανεβάσετε, ή κάντε κλικ για να επιλέξετε ένα.", - "upload_single": "Ρίξτε ένα αρχείο για να τα ανεβάσετε, ή κάντε κλικ για να το επιλέξετε." - }, - "image": { - "upload_several": "Ρίξτε μερικές φωτογραφίες για να τις ανεβάσετε, ή κάντε κλικ για να επιλέξετε μια.", - "upload_single": "Ρίξτε μια φωτογραφία για να την ανεβάσετε, ή κάντε κλικ για να την επιλέξετε." - }, - "references": { - "all_missing": "Αδυναμία εύρεσης δεδομένων αναφοράς.", - "many_missing": "Τουλάχιστον μια από τις συσχετιζόμενες αναφορές φαίνεται δεν είναι διαθέσιμη.", - "single_missing": "Η συσχετιζόμενη αναφορά φαίνεται δεν είναι διαθέσιμη." - }, - "password": { - "toggle_visible": "Απόκρυψη κωδικού πρόσβασης", - "toggle_hidden": "Εμφάνιση κωδικού πρόσβασης" - } - }, - "message": { - "about": "Σχετικά", - "are_you_sure": "Είστε σίγουροι?", - "bulk_delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε το %{name}? |||| Είστε σίγουροι πως θέλετε να διαγράψετε τα %{smart_count}?", - "bulk_delete_title": "Διαγραφή του %{name} |||| Διαγραφή του %{smart_count} %{name}", - "delete_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το αντικείμενο?", - "delete_title": "Διαγραφή του %{name} #%{id}", - "details": "Λεπτομέρειες", - "error": "Παρουσιάστηκε ένα πρόβλημα από τη μεριά του πελάτη και το αίτημα σας δεν μπορεί να ολοκληρωθεί.", - "invalid_form": "Η φόρμα δεν είναι έγκυρη. Ελέγξτε για σφάλματα", - "loading": "Η σελίδα φορτώνει, περιμένετε λίγο", - "no": "Όχι", - "not_found": "Είτε έχετε εισάγει λανθασμένο URL, είτε ακολουθήσατε έναν υπερσύνδεσμο που δεν ισχύει.", - "yes": "Ναι", - "unsaved_changes": "Μερικές από τις αλλαγές σας δεν έχουν αποθηκευτεί. Είστε σίγουροι πως θέλετε να τις αγνοήσετε?" - }, - "navigation": { - "no_results": "Δεν βρέθηκαν αποτελέσματα", - "no_more_results": "Η σελίδα %{page} είναι εκτός ορίων. Δοκιμάστε την προηγούμενη σελίδα.", - "page_out_of_boundaries": "Η σελίδα %{page} είναι εκτός ορίων", - "page_out_from_end": "Δεν είναι δυνατή η πλοήγηση πέραν της τελευταίας σελίδας", - "page_out_from_begin": "Δεν είναι δυνατή η πλοήγηση πριν τη σελίδα 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} από %{total}", - "page_rows_per_page": "Αντικείμενα ανά σελίδα:", - "next": "Επόμενο", - "prev": "Προηγούμενο", - "skip_nav": "Παράβλεψη στο περιεχόμενο" - }, - "notification": { - "updated": "Το στοιχείο ενημερώθηκε |||| %{smart_count} στοιχεία ενημερώθηκαν", - "created": "Το στοιχείο δημιουργήθηκε", - "deleted": "Το στοιχείο διαγράφηκε |||| %{smart_count} στοιχεία διαγράφηκαν", - "bad_item": "Λανθασμένο στοιχείο", - "item_doesnt_exist": "Το παρόν στοιχείο δεν υπάρχει", - "http_error": "Σφάλμα κατά την επικοινωνία με το διακομιστή", - "data_provider_error": "Σφάλμα παρόχου δεδομένων. Παρακαλούμε συμβουλευτείτε την κονσόλα για περισσότερες πληροφορίες.", - "i18n_error": "Αδυναμία ανάκτησης των μεταφράσεων για την συγκεκριμένη γλώσσα", - "canceled": "Η συγκεκριμένη δράση ακυρώθηκε", - "logged_out": "Η συνεδρία σας έχει λήξει, παρακαλούμε ξανασυνδεθείτε.", - "new_version": "Υπάρχει νέα έκδοση διαθέσιμη! Παρακαλούμε ανανεώστε το παράθυρο." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Στήλες προς εμφάνιση", - "layout": "Διάταξη", - "grid": "Πλεγμα", - "table": "Πινακας" - } - }, - "message": { - "note": "ΣΗΜΕΙΩΣΗ", - "transcodingDisabled": "Η αλλαγή της διαμόρφωσης της διακωδικοποίησης μέσω της διεπαφής του περιηγητή ιστού είναι απενεργοποιημένη για λόγους ασφαλείας. Εαν επιθυμείτε να αλλάξετε (τροποποίηση ή δημιουργία) των επιλογών διακωδικοποίησης, επανεκκινήστε το διακομιστή με την επιλογή %{config}.", - "transcodingEnabled": "Το Navidrome λειτουργεί με %{config}, καθιστόντας δυνατή την εκτέλεση εντολών συστήματος μέσω των ρυθμίσεων διακωδικοποίησης χρησιμοποιώντας την διεπαφή ιστού. Προτείνουμε να το απενεργοποιήσετε για λόγους ασφαλείας και να το ενεργοποιήσετε μόνο όταν παραμετροποιείτε τις επιλογές διακωδικοποίησης.", - "songsAddedToPlaylist": "Προστέθηκε 1 τραγούδι στη λίστα αναπαραγωγής |||| Προστέθηκαν %{smart_count} τραγούδια στη λίστα αναπαραγωγής", - "noPlaylistsAvailable": "Κανένα διαθέσιμο", - "delete_user_title": "Διαγραφή του χρήστη '%{name}'", - "delete_user_content": "Είστε σίγουροι πως θέλετε να διαγράψετε αυτό το χρήστη και όλα τα δεδομένα του (συμπεριλαμβανομένων των λιστών αναπαραγωγής και προτιμήσεων)?", - "notifications_blocked": "Έχετε μπλοκάρει τις Ειδοποιήσεις από τη σελίδα, μέσω των ρυθμίσεων του περιηγητή ιστού σας", - "notifications_not_available": "Αυτός ο περιηγητής ιστού δεν υποστηρίζει ειδοποιήσεις στην επιφάνεια εργασίας ή δεν έχετε πρόσβαση στο Navidrome μέσω https", - "lastfmLinkSuccess": "Το Last.fm έχει διασυνδεθεί επιτυχώς και η λειτουργία scrobbling ενεργοποιήθηκε", - "lastfmLinkFailure": "Δεν μπορεί να πραγματοποιηθεί διασύνδεση με το Last.fm", - "lastfmUnlinkSuccess": "Το Last.fm αποσυνδέθηκε και η λειτουργία scrobbling έχει απενεργοποιηθεί", - "lastfmUnlinkFailure": "Το Last.fm δεν μπορεί να αποσυνδεθεί", - "openIn": { - "lastfm": "Άνοιγμα στο Last.fm", - "musicbrainz": "Άνοιγμα στο MusicBrainz" - }, - "lastfmLink": "Διαβάστε περισσότερα...", - "listenBrainzLinkSuccess": "Το ListenBrainz έχει διασυνδεθεί επιτυχώς και η λειτουργία scrobbling έχει ενεργοποιηθεί για το χρήστη: %{user}", - "listenBrainzLinkFailure": "Το ListenBrainz δεν μπορεί να διασυνδεθεί: %{error}", - "listenBrainzUnlinkSuccess": "Το ListenBrainz έχει αποσυνδεθεί και το scrobbling έχει απενεργοποιηθεί", - "listenBrainzUnlinkFailure": "Το ListenBrainz δεν μπορεί να διασυνδεθεί", - "downloadOriginalFormat": "Λήψη σε αρχική μορφή", - "shareOriginalFormat": "Κοινή χρήση σε αρχική μορφή", - "shareDialogTitle": "Κοινή χρήση %{resource} '%{name}'", - "shareBatchDialogTitle": "Κοινή χρήση 1 %{resource} |||| Κοινή χρήση %{smart_count} %{resource}", - "shareSuccess": "Το 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": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους.", - "noSimilarSongsFound": "Δεν βρέθηκαν παρόμοια τραγούδια", - "noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια", - "startingInstantMix": "Φόρτωση Άμεσης Μίξης...", - "uploadCover": "Μεταφόρτωση εξωφύλλου", - "removeCover": "Αφαίρεση καλύμματος", - "coverUploaded": "Το εξώφυλλο ενημερώθηκε", - "coverRemoved": "Το εξώφυλλο αφαιρέθηκε", - "coverUploadError": "Σφάλμα κατά τη μεταφόρτωση του εξωφύλλου", - "coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου", - "metadataRefreshStarted": "Ανανέωση μεταδεδομένων στο παρασκήνιο" - }, - "menu": { - "library": "Βιβλιοθήκη", - "settings": "Ρυθμίσεις", - "version": "Έκδοση", - "theme": "Θέμα", - "personal": { - "name": "Προσωπικές", - "options": { - "theme": "Θέμα", - "language": "Γλώσσα", - "defaultView": "Προκαθορισμένη προβολή", - "desktop_notifications": "Ειδοποιήσεις στην Επιφάνεια Εργασίας", - "lastfmScrobbling": "Λειτουργία Scrobble στο Last.fm", - "listenBrainzScrobbling": "Λειτουργία Scrobble στο ListenBrainz", - "replaygain": "Λειτουργία ReplayGain", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "Ανενεργό", - "album": "Χρησιμοποιήστε το Album Gain", - "track": "Χρησιμοποιήστε το Track Gain" - }, - "lastfmNotConfigured": "Το Last.fm API-Key δεν έχει ρυθμιστεί" - } - }, - "albumList": "Άλμπουμ", - "about": "Σχετικά", - "playlists": "Λίστες Αναπαραγωγής", - "sharedPlaylists": "Κοινοποιημένες Λίστες Αναπαραγωγής", - "librarySelector": { - "allLibraries": "Όλες οι βιβλιοθήκες (%{count})", - "multipleLibraries": "%{selected} από %{total} Βιβλιοθήκες", - "selectLibraries": "Επιλέξτε βιβλιοθήκες", - "none": "Κανένα" - }, - "onlyFavourites": "Εμφάνιση μόνο αγαπημένων" - }, - "player": { - "playListsText": "Ουρά Αναπαραγωγής", - "openText": "Άνοιγμα", - "closeText": "Κλείσιμο", - "notContentText": "Δεν υπάρχει μουσική", - "clickToPlayText": "Κλίκ για αναπαραγωγή", - "clickToPauseText": "Κλίκ για παύση", - "nextTrackText": "Επόμενο κομμάτι", - "previousTrackText": "Προηγούμενο κομμάτι", - "reloadText": "Επαναφόρτωση", - "volumeText": "Ένταση", - "toggleLyricText": "Εναλλαγή στίχων", - "toggleMiniModeText": "Ελαχιστοποίηση", - "destroyText": "Κλέισιμο", - "downloadText": "Ληψη", - "removeAudioListsText": "Διαγραφή λιστών ήχου", - "clickToDeleteText": "Κάντε κλικ για να διαγράψετε %{name}", - "emptyLyricText": "Δεν υπάρχουν στίχοι", - "playModeText": { - "order": "Στη σειρά", - "orderLoop": "Επανάληψη", - "singleLoop": "Επανάληψη μια φορά", - "shufflePlay": "Ανακατεμα" - } - }, - "about": { - "links": { - "homepage": "Αρχική σελίδα", - "source": "Πηγαίος κώδικας", - "featureRequests": "Αιτήματα χαρακτηριστικών", - "lastInsightsCollection": "Τελευταία συλλογή πληροφοριών", - "insights": { - "disabled": "Απενεργοποιημένο", - "waiting": "Αναμονή" - } - }, - "tabs": { - "about": "Σχετικά", - "config": "Διαμόρφωση" - }, - "config": { - "configName": "Όνομα διαμόρφωσης", - "environmentVariable": "Μεταβλητή περιβάλλοντος", - "currentValue": "Τρέχουσα Αξία", - "configurationFile": "Αρχείο διαμόρφωσης", - "exportToml": "Ρύθμιση παραμέτρων εξαγωγής (TOML)", - "exportSuccess": "Η διαμόρφωση εξήχθη στο πρόχειρο σε μορφή TOML", - "exportFailed": "Η αντιγραφή της διαμόρφωσης απέτυχε", - "devFlagsHeader": "Σημαίες Ανάπτυξης (υπόκειται σε αλλαγές / αφαίρεση)", - "devFlagsComment": "Αυτές είναι πειραματικές ρυθμίσεις και ενδέχεται να καταργηθούν σε μελλοντικές εκδόσεις", - "downloadToml": "Λήψη διαμόρφωσης (TOML)" - } - }, - "activity": { - "title": "Δραστηριότητα", - "totalScanned": "Σαρώμένοι Φάκελοι", - "quickScan": "Γρήγορη Σάρωση", - "fullScan": "Πλήρης Σάρωση", - "serverUptime": "Λειτουργία Διακομιστή", - "serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ", - "scanType": "Τύπος", - "status": "Σφάλμα σάρωσης", - "elapsedTime": "Χρόνος που πέρασε", - "selectiveScan": "Εκλεκτικός" - }, - "help": { - "title": "Συντομεύσεις του Navidrome", - "hotkeys": { - "show_help": "Προβολή αυτής της Βοήθειας", - "toggle_menu": "Εναλλαγή Μπάρας Μενού", - "toggle_play": "Αναπαραγωγή / Παύση", - "prev_song": "Προηγούμενο Τραγούδι", - "next_song": "Επόμενο Τραγούδι", - "vol_up": "Αύξηση Έντασης", - "vol_down": "Μείωση Έντασης", - "toggle_love": "Προσθήκη αυτού του κομματιού στα αγαπημένα", - "current_song": "Μεταβείτε στο Τρέχον τραγούδι" - } - }, - "nowPlaying": { - "title": "Αναπαραγωγή τώρα", - "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 60eaa6d7c..1b166378a 100644 --- a/resources/i18n/eo.json +++ b/resources/i18n/eo.json @@ -1,721 +1,456 @@ { - "languageName": "Esperanto", - "resources": { - "song": { - "name": "Kanto |||| Kantoj", - "fields": { - "albumArtist": "Artisto de Albumo", - "duration": "Daŭro", - "trackNumber": "#", - "playCount": "Ludoj", - "title": "Titolo", - "artist": "Artisto", - "album": "Albumo", - "path": "Dosiera vojo", - "genre": "Ĝenro", - "compilation": "Kompilaĵo", - "year": "Jaro", - "size": "Dosiera grandeco", - "updatedAt": "Ĝisdatigita je", - "bitRate": "Bitrapido", - "discSubtitle": "Diska Subteksto", - "starred": "Stela", - "comment": "Komento", - "rating": "Takso", - "quality": "Kvalito", - "bpm": "Pulsrapideco", - "playDate": "Laste Ludita", - "channels": "Kanaloj", - "createdAt": "Dato de aligo", - "grouping": "Grupo", - "mood": "Humoro", - "participants": "Aldonaj partoprenantoj", - "tags": "Aldonaj Etikedoj", - "mappedTags": "Mapigitaj etikedoj", - "rawTags": "Krudaj etikedoj", - "bitDepth": "Bitprofundo", - "sampleRate": "Elprena rapido", - "missing": "Mankaj", - "libraryName": "Biblioteko", - "composer": "", - "disc": "" - }, - "actions": { - "addToQueue": "Ludi Poste", - "playNow": "Ludi nun", - "addToPlaylist": "Aldoni al Ludlisto", - "shuffleAll": "Miksu Ĉiujn", - "download": "Elŝuti", - "playNext": "Ludu Poste", - "info": "Akiri Informon", - "showInPlaylist": "Montri en Ludlisto", - "instantMix": "" - } + "languageName": "Esperanto", + "resources": { + "song": { + "name": "kanto |||| kantoj", + "fields": { + "albumArtist": "Albumo artista", + "duration": "Tempo", + "trackNumber": "#", + "playCount": "Nombro de ŝkotoj", + "title": "Titolo", + "artist": "Artisto", + "album": "Albumo", + "path": "Dosiera vojo", + "genre": "Ĝenro", + "compilation": "Kompilaĵo", + "year": "Jaro", + "size": "Dosiera grandeco", + "updatedAt": "Ĝisdatigita je", + "bitRate": "Bitrapido", + "discSubtitle": "Diska Subteksto", + "starred": "Stela", + "comment": "Komento", + "rating": "", + "quality": "", + "bpm": "", + "playDate": "", + "channels": "", + "createdAt": "" + }, + "actions": { + "addToQueue": "Ludi Poste", + "playNow": "Ludi nun", + "addToPlaylist": "Aldoni al Ludlisto", + "shuffleAll": "Miksu Ĉiujn", + "download": "Elŝuti", + "playNext": "Ludu Poste", + "info": "" + } + }, + "album": { + "name": "Albumo |||| Albumoj", + "fields": { + "albumArtist": "Albumo artista", + "artist": "Artisto", + "duration": "Tempo", + "songCount": "Kantoj", + "playCount": "Nombro de ŝkotoj", + "name": "Nomo", + "genre": "Genro", + "compilation": "Kompilaĵo", + "year": "Jaro", + "updatedAt": "Ĝisdatigita je :", + "comment": "Komento", + "rating": "", + "createdAt": "", + "size": "" + }, + "actions": { + "playAll": "Ludi", + "playNext": "Ludi poste", + "addToQueue": "Aldoni la dosieron de atento", + "shuffle": "Miksi", + "addToPlaylist": "Aldoni al la Ludlisto", + "download": "Elŝuti", + "info": "", + "share": "" + }, + "lists": { + "all": "Ĉiuj", + "random": "Hazarda", + "recentlyAdded": "Lastatempe Aldonita", + "recentlyPlayed": "Lastatempe Ludita", + "mostPlayed": "Plej Luditaj", + "starred": "Stelplena", + "topRated": "" + } + }, + "artist": { + "name": "Artisto |||| Artistoj", + "fields": { + "name": "Nomo", + "albumCount": "Nombro da albumoj", + "songCount": "Kanto kalkula", + "playCount": "Teatraĵoj", + "rating": "", + "genre": "", + "size": "" + } + }, + "user": { + "name": "Uzanto |||| Uzantoj", + "fields": { + "userName": "Uzantonomo", + "isAdmin": "Estas Administranto", + "lastLoginAt": "Lasta Ensaluto Je", + "updatedAt": "Ĝisdatigita je", + "name": "Nomo", + "password": "Pasvorto", + "createdAt": "Kreita je :", + "changePassword": "", + "currentPassword": "", + "newPassword": "", + "token": "" + }, + "helperTexts": { + "name": "" + }, + "notifications": { + "created": "", + "updated": "", + "deleted": "" + }, + "message": { + "listenBrainzToken": "", + "clickHereForToken": "" + } + }, + "player": { + "name": "Legilo |||| Legilj", + "fields": { + "name": "Nomo", + "transcodingId": "Transkodigo", + "maxBitRate": "Maksimuma Bitrapido", + "client": "Kliento", + "userName": "Uzantonomo", + "lastSeen": "Laste Vidita Je", + "reportRealPath": "Raporti vera pado", + "scrobbleEnabled": "" + } + }, + "transcoding": { + "name": "Transkodigo |||| Transkodigoj", + "fields": { + "name": "Nomo", + "targetFormat": "Celformato", + "defaultBitRate": "Defaŭlta Bitrapido", + "command": "Komando" + } + }, + "playlist": { + "name": "Ludlisto |||| Ludlistoj", + "fields": { + "name": "Nomo", + "duration": "Daŭro", + "ownerName": "Posedanto", + "public": "Publika", + "updatedAt": "Ĝisdatigita je", + "createdAt": "Kreita je", + "songCount": "Kantoj", + "comment": "Komento", + "sync": "Aŭtomata importado", + "path": "Importi de" + }, + "actions": { + "selectPlaylist": "Elektu ludliston :", + "addNewPlaylist": "Krei \"%{name}\"", + "export": "Eksporti", + "makePublic": "", + "makePrivate": "" + }, + "message": { + "duplicate_song": "", + "song_exist": "" + } + }, + "radio": { + "name": "", + "fields": { + "name": "", + "streamUrl": "", + "homePageUrl": "", + "updatedAt": "", + "createdAt": "" + }, + "actions": { + "playNow": "" + } + }, + "share": { + "name": "", + "fields": { + "username": "", + "url": "", + "description": "", + "contents": "", + "expiresAt": "", + "lastVisitedAt": "", + "visitCount": "", + "format": "", + "maxBitRate": "", + "updatedAt": "", + "createdAt": "", + "downloadable": "" + } + } }, - "album": { - "name": "Albumo |||| Albumoj", - "fields": { - "albumArtist": "Artisto de Albumo", - "artist": "Artisto", - "duration": "Tempo", - "songCount": "Kantoj", - "playCount": "Ludoj", - "name": "Nomo", - "genre": "Ĝenro", - "compilation": "Kompilaĵo", - "year": "Jaro", - "updatedAt": "Ĝisdatigita je :", - "comment": "Komento", - "rating": "Takso", - "createdAt": "Dato aldonita", - "size": "Grando", - "originalDate": "Originala", - "releaseDate": "Publikiĝis", - "releases": "Publikiĝo |||| Publikiĝoj", - "released": "Publikiĝis", - "recordLabel": "Eldonejo", - "catalogNum": "Kataloga Numero", - "releaseType": "Tipo", - "grouping": "Grupo", - "media": "Aŭdvidaĵo", - "mood": "Humoro", - "date": "Registraĵa Dato", - "missing": "Mankaj", - "libraryName": "Biblioteko" - }, - "actions": { - "playAll": "Ludi", - "playNext": "Ludi Sekvante", - "addToQueue": "Aldoni la dosieron de atento", - "shuffle": "Miksi", - "addToPlaylist": "Aldoni al la Ludlisto", - "download": "Elŝuti", - "info": "Akiri Informon", - "share": "Diskonigi" - }, - "lists": { - "all": "Ĉiuj", - "random": "Hazardaj", - "recentlyAdded": "Lastatempe Aldonitaj", - "recentlyPlayed": "Lastatempe Luditaj", - "mostPlayed": "Plej Luditaj", - "starred": "Stelplenaj", - "topRated": "Plej Alte Taksitaj" - } - }, - "artist": { - "name": "Artisto |||| Artistoj", - "fields": { - "name": "Nomo", - "albumCount": "Kvanto da Albumoj", - "songCount": "Kanta Kalkulo", - "playCount": "Ludoj", - "rating": "Takso", - "genre": "Ĝenro", - "size": "Grando", - "role": "Rolo", - "missing": "Mankaj" - }, - "roles": { - "albumartist": "Albuma Artisto |||| Albumaj Artistoj", - "artist": "Artisto |||| Artistoj", - "composer": "Komponisto |||| Komponistoj", - "conductor": "Dirigento |||| Dirigentoj", - "lyricist": "Kantoteksisto |||| Kantotekstistoj", - "arranger": "Aranĝisto |||| Aranĝistoj", - "producer": "Produktisto |||| Produktistoj", - "director": "Direktoro |||| Direktoroj", - "engineer": "Inĝeniero |||| Inĝenieroj", - "mixer": "Miksisto |||| Miksistoj", - "remixer": "Remiksisto |||| Remiksistoj", - "djmixer": "Dĵ-a Miksisto |||| Dĵ-a Miksistoj", - "performer": "Plenumisto |||| Plenumistoj", - "maincredit": "Albuma Artisto aŭ Artisto |||| Albumaj Artistoj aŭ Artistoj" - }, - "actions": { - "shuffle": "Miksi", - "radio": "Radio", - "topSongs": "Plej Luditaj Kantoj" - } - }, - "user": { - "name": "Uzanto |||| Uzantoj", - "fields": { - "userName": "Uzantnomo", - "isAdmin": "Estas Administranto", - "lastLoginAt": "Antaŭa Ensaluto Je", - "updatedAt": "Ĝisdatigita je", - "name": "Nomo", - "password": "Pasvorto", - "createdAt": "Kreita je :", - "changePassword": "Ĉu Ŝanĝi Pasvorton?", - "currentPassword": "Nuna Pasvorto", - "newPassword": "Nova Pasvorto", - "token": "Ĵetono", - "lastAccessAt": "Lasta Atingo", - "libraries": "Bibliotekoj" - }, - "helperTexts": { - "name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto", - "libraries": "Elekti specifajn bibliotekojn por ĉi tiu uzanto, aŭ lasi malplena por uzi defaŭltajn bibliotekojn" - }, - "notifications": { - "created": "Uzanto farita", - "updated": "Uzanto ĝistadigita", - "deleted": "Uzanto forigita" - }, - "message": { - "listenBrainzToken": "Enigi vian uzantan ĵetonon de ListenBrainz.", - "clickHereForToken": "Alkakli ĉi tie por akiri vian ĵetonon", - "selectAllLibraries": "Elekti ĉiujn bibliotekojn", - "adminAutoLibraries": "Administrantoj aŭtomate havas aliron al ĉiuj bibliotekoj" - }, - "validation": { - "librariesRequired": "Almenaŭ unu biblioteko devas esti elektita por neadministrantoj" - } - }, - "player": { - "name": "Ludanto |||| Ludantoj", - "fields": { - "name": "Nomo", - "transcodingId": "Transkodigo", - "maxBitRate": "Maksimuma Bitrapido", - "client": "Kliento", - "userName": "Uzantnomo", - "lastSeen": "Laste Vidita Je", - "reportRealPath": "Raporti vera pado", - "scrobbleEnabled": "Sendi Scrobbles al eksteraj servoj" - } - }, - "transcoding": { - "name": "Transkodigo |||| Transkodigoj", - "fields": { - "name": "Nomo", - "targetFormat": "Cela Formato", - "defaultBitRate": "Defaŭlta Bitrapido", - "command": "Komando" - } - }, - "playlist": { - "name": "Ludlisto |||| Ludlistoj", - "fields": { - "name": "Nomo", - "duration": "Daŭro", - "ownerName": "Posedanto", - "public": "Publika", - "updatedAt": "Ĝisdatigita je", - "createdAt": "Kreita je", - "songCount": "Kantoj", - "comment": "Komento", - "sync": "Aŭtomata importado", - "path": "Importi de" - }, - "actions": { - "selectPlaylist": "Elektu ludliston :", - "addNewPlaylist": "Krei \"%{name}\"", - "export": "Eksporti", - "makePublic": "Publikigi", - "makePrivate": "Malpublikigi", - "saveQueue": "Konservi Ludvicon al Ludlisto", - "searchOrCreate": "Serĉi ludlistojn aŭ tajpi por krei novan...", - "pressEnterToCreate": "Premu je Enter por krei novan ludliston", - "removeFromSelection": "Forigi de elekto" - }, - "message": { - "duplicate_song": "Aldoni duobligitajn kantojn", - "song_exist": "Estas duoblaĵoj kiuj aldoniĝas al la kantolisto. Ĉu vi ŝatus aldoni la duoblaĵojn aŭ pasigi ilin?", - "noPlaylistsFound": "Neniuj ludlistoj trovitaj", - "noPlaylists": "Neniuj ludlistoj haveblaj" - } - }, - "radio": { - "name": "Radio |||| Radioj", - "fields": { - "name": "Nomo", - "streamUrl": "Flua Ligilo", - "homePageUrl": "Hejmpaĝa Ligilo", - "updatedAt": "Ĝisdatiĝis je", - "createdAt": "Kreiĝis je" - }, - "actions": { - "playNow": "Ludi Nun" - } - }, - "share": { - "name": "Diskonigo |||| Diskonigoj", - "fields": { - "username": "Diskonigite De", - "url": "Ligilo", - "description": "Priskribo", - "contents": "Enhavo", - "expiresAt": "Senvalidiĝas", - "lastVisitedAt": "Laste Vizitita", - "visitCount": "Vizitoj", - "format": "Formato", - "maxBitRate": "Maks. Bitrapido", - "updatedAt": "Ĝisdatiĝis je", - "createdAt": "Fariĝis je", - "downloadable": "Ĉu Ebligi Elŝutojn?" - } - }, - "missing": { - "name": "Manka Dosiero |||| Mankaj Dosieroj", - "fields": { - "path": "Vojo", - "size": "Grando", - "updatedAt": "Malaperis je", - "libraryName": "Biblioteko" - }, - "actions": { - "remove": "Forigi", - "remove_all": "Forigi Ĉiujn" - }, - "notifications": { - "removed": "Manka(j) dosiero(j) forigite" - }, - "empty": "Neniuj Mankaj Dosieroj" - }, - "library": { - "name": "Biblioteko |||| Bibliotekoj", - "fields": { - "name": "Nomo", - "path": "Vojo", - "remotePath": "Fora Vojo", - "lastScanAt": "Plej Lasta Skano", - "songCount": "Kantoj", - "albumCount": "Albumoj", - "artistCount": "Artistoj", - "totalSongs": "Kantoj", - "totalAlbums": "Albumoj", - "totalArtists": "Artistoj", - "totalFolders": "Dosierujoj", - "totalFiles": "Dosieroj", - "totalMissingFiles": "Mankaj Dosieroj", - "totalSize": "Totala Grando", - "totalDuration": "Daŭro", - "defaultNewUsers": "Defaŭlto por Novaj Uzantoj", - "createdAt": "Farite je", - "updatedAt": "Ĝisdatiĝis je" - }, - "sections": { - "basic": "Bazaj Informoj", - "statistics": "Statistikaĵoj" - }, - "actions": { - "scan": "Skani Bibliotekon", - "manageUsers": "Agordi Uzantan Aliron", - "viewDetails": "Montri Informojn", - "quickScan": "Rapida Skano", - "fullScan": "Plena Skano" - }, - "notifications": { - "created": "Biblioteko kreiĝis sukcese", - "updated": "Biblioteko ĝisdatiĝis sukcese", - "deleted": "Biblioteko foriĝis sukcese", - "scanStarted": "Biblioteka skano komenciĝis", - "scanCompleted": "Biblioteka skano finiĝis", - "quickScanStarted": "Rapida skano komenciĝis", - "fullScanStarted": "Plena skano komenciĝis", - "scanError": "Eraro de skana komenco. Kontrolu la protokolojn" - }, - "validation": { - "nameRequired": "Biblioteka nomo estas necesa", - "pathRequired": "Biblioteka vojo estas necesa", - "pathNotDirectory": "Biblioteka vojo devas esti dosierujo", - "pathNotFound": "Biblioteka vojo ne trovite", - "pathNotAccessible": "Biblioteka vojo ne estas alirebla", - "pathInvalid": "Nevalida biblioteka vojo" - }, - "messages": { - "deleteConfirm": "Ĉu vi certas, ke vi volas forigi ĉi tiun bibliotekon? Ĉi tio forigos ĉiujn rilatajn datumojn kaj uzantan aliron.", - "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": { - "auth": { - "welcome1": "Dankon pro instalado de Navidrome !", - "welcome2": "Por komenci, kreu administrantan uzanton", - "confirmPassword": "Konfirmu Pasvorton", - "buttonCreateAdmin": "Krei Administranto", - "auth_check_error": "Bonvolu ensaluti por daŭrigi", - "user_menu": "Profilo", - "username": "Uzantnomo", - "password": "Pasvorto", - "sign_in": "Ensaluti", - "sign_in_error": "Aŭtentikigo malsukcesis, bonvolu reprovi", - "logout": "Elsaluti", - "insightsCollectionNote": "Navidrome kolektas anoniman uzdatumon por helpi\nplibonigi la projekton. Alklaku [ĉi tie] por lerni pli kaj\nsupozi permeson se vi volas" - }, - "validation": { - "invalidChars": "Bonvolu uzi nur literojn kaj ciferojn", - "passwordDoesNotMatch": "Pasvorto ne kongruas", - "required": "Necesa", - "minLength": "Devas esti almenaŭ %{min} signoj", - "maxLength": "Devas esti %{max} signoj aŭ malpli", - "minValue": "Devas esti almenaŭ %{min}", - "maxValue": "Devas esti %{max} aŭ malpli", - "number": "Devas esti nombro", - "email": "Devas esti valida retpoŝto", - "oneOf": "Devas esti unu el: %{options}", - "regex": "Devas kongrui kun specifa formato (regexp): %{pattern}", - "unique": "Devas esti unika", - "url": "Devas esti valida ligilo" - }, - "action": { - "add_filter": "Aldoni filtrilon", - "add": "Aldoni", - "back": "Reiri", - "bulk_actions": "1 ero elektita |||| ${smart_count} eroj elektitaj", - "cancel": "Nuligi", - "clear_input_value": "Viŝi valoron", - "clone": "Kloni", - "confirm": "Konfirmi", - "create": "Krei", - "delete": "Forigi", - "edit": "Redakti", - "export": "Eksporti", - "list": "Listigi", - "refresh": "Aktualigi", - "remove_filter": "Forigu ĉi tiun filtrilon", - "remove": "Forigi", - "save": "Konservi", - "search": "Serĉi", - "show": "Montri", - "sort": "Ordigi", - "undo": "Malfari", - "expand": "Etendi", - "close": "Fermi", - "open_menu": "Malfermi menuon", - "close_menu": "Fermu menuon", - "unselect": "Malelekti", - "skip": "Pasigi", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Diskonigi", - "download": "Elŝuti" - }, - "boolean": { - "true": "Jes", - "false": "Ne" - }, - "page": { - "create": "Krei %{name}", - "dashboard": "Panelo", - "edit": "%{name} #%{id}", - "error": "Io fuŝiĝis", - "list": "${name}", - "loading": "Ŝarĝante", - "not_found": "Ne Trovita", - "show": "%{name} #%{id}", - "empty": "Ankoraŭ ne %{name}", - "invite": "Ĉu vi volas aldoni unu?" - }, - "input": { - "file": { - "upload_several": "Demetu iom da dosieroj por alŝuti, aŭ alklaku por elekti unu.", - "upload_single": "Demetu iom da dosieroj por alŝuti, aŭ alklaku por elekti ĝin." - }, - "image": { - "upload_several": "Demetu iom da bildoj por alŝuti, aŭ alklaku por elekti unu.", - "upload_single": "Demetu bildon por alŝuti, aŭ alklaku por elekti ĝin." - }, - "references": { - "all_missing": "Ne eblas trovi referencajn datumojn.", - "many_missing": "Almenaŭ unu el la rilataj referencoj ne plu ŝajnas esti disponebla.", - "single_missing": "Rilata referenco ne plu ŝajnas esti disponebla." - }, - "password": { - "toggle_visible": "Kaŝi pasvorton", - "toggle_hidden": "Montri pasvorton" - } + "ra": { + "auth": { + "welcome1": "Dankon pro instalado de Navidrome !", + "welcome2": "Por komenci, kreu administrantan uzanton", + "confirmPassword": "Konfirmu pasvorton", + "buttonCreateAdmin": "Krei administranto", + "auth_check_error": "Bonvolu ensaluti por daŭrigi", + "user_menu": "Profilo", + "username": "Uzantnomo", + "password": "Pasvorto", + "sign_in": "Ensaluti", + "sign_in_error": "Aŭtentikigo malsukcesis, bonvolu reprovi", + "logout": "Elsaluti" + }, + "validation": { + "invalidChars": "Bonvolu uzi nur literon kaj ciferojn", + "passwordDoesNotMatch": "Pasvorto ne kongruas", + "required": "Necesa", + "minLength": "Devas esti almenaŭ %{min} signoj", + "maxLength": "Devas esti %{max} signoj aŭ malpli", + "minValue": "Devas esti almenaŭ %{min}", + "maxValue": "Devas esti %{max} aŭ malpli", + "number": "Devas esti nombro", + "email": "Devas esti valida retpoŝto", + "oneOf": "Devas esti unu el: %{options}", + "regex": "Devas kongrui kun specifa formato (regexp): %{pattern}", + "unique": "", + "url": "" + }, + "action": { + "add_filter": "Aldoni filtrilon", + "add": "Aldoni", + "back": "Reiri", + "bulk_actions": "1 ero elektita |||| ${smart_count} eroj elektitaj", + "cancel": "Nuligi", + "clear_input_value": "Viŝi valoro", + "clone": "Kloni", + "confirm": "Konfirmi", + "create": "Krei", + "delete": "Forstrekis", + "edit": "Modifi", + "export": "Eksporti", + "list": "Listigi", + "refresh": "Aktualigi", + "remove_filter": "Forigu ĉi tiun filtrilon", + "remove": "Forigi", + "save": "Konservi", + "search": "Serĉi", + "show": "Montri", + "sort": "Ordigi", + "undo": "Malfari", + "expand": "Etendi", + "close": "Fermi", + "open_menu": "Malfermu menuon", + "close_menu": "Fermu menuon", + "unselect": "Malelekti", + "skip": "", + "bulk_actions_mobile": "", + "share": "", + "download": "" + }, + "boolean": { + "true": "Jes", + "false": "Ne" + }, + "page": { + "create": "Krei %{name}", + "dashboard": "Panelo", + "edit": "%{name} #%{id}", + "error": "Io fuŝiĝis", + "list": "${name}", + "loading": "Ŝarĝante", + "not_found": "Ne trovita", + "show": "%{name} #%{id}", + "empty": "Ankoraŭ ne %{name}", + "invite": "Ĉu vi volas aldoni unu?" + }, + "input": { + "file": { + "upload_several": "Forĵetu iujn dosierojn por alŝuti, aŭ alklaku por elekti unu.", + "upload_single": "Forĵetu iujn dosierojn por alŝuti, aŭ alklaku por elekti ĝin." + }, + "image": { + "upload_several": "Faligu iujn bildojn por alŝuti, aŭ alklaku por elekti unu.", + "upload_single": "Faligu bildon por alŝuti, aŭ alklaku por elekti ĝin." + }, + "references": { + "all_missing": "Ne eblas trovi referencajn datumojn.", + "many_missing": "Almenaŭ unu el la rilataj referencoj ne plu ŝajnas esti disponebla.", + "single_missing": "Rilata referenco ne plu ŝajnas esti disponebla." + }, + "password": { + "toggle_visible": "kaŝi pasvorto", + "toggle_hidden": "montri pasvorto" + } + }, + "message": { + "about": "Pri", + "are_you_sure": "Ĉu vi certas ?", + "bulk_delete_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun %{name} ? |||| Ĉu vi certas, ke vi volas forigi ĉi tiujn %{smart_count} erojn ?", + "bulk_delete_title": "Forigi %{name} |||| Forigi %{smart_count} %{name}", + "delete_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun %{smart_count} eron ?", + "delete_title": "Forigi %{name} #%{id}", + "details": "Detaleto", + "error": "Klienta eraro okazis kaj via peto ne povis esti plenumita.", + "invalid_form": "La formo ne estas valida. Bonvolu kontroli pri eraroj.", + "loading": "La paĝo ŝarĝas, nur momenton bonvolu.", + "no": "Ne", + "not_found": "Aŭ vi tajpis malbonan URL, aŭ vi sekvis malbonan ligon.", + "yes": "Jes", + "unsaved_changes": "Luj el viaj ŝanĝoj ne estis konservitaj. Ĉu vi estas certa, ke vi volas ignori ilin?" + }, + "navigation": { + "no_results": "Neniu rezulto troviĝis", + "no_more_results": "La paĝa numero %{page} estas ekster limoj. Provu la antaŭan paĝon.", + "page_out_of_boundaries": "Paĝa numero %{page} ekster limoj", + "page_out_from_end": "Ne povas iri post la lasta paĝo", + "page_out_from_begin": "Ne povas iri antaŭ paĝo 1", + "page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}", + "page_rows_per_page": "Eroj por paĝo:", + "next": "Poste", + "prev": "Antaŭ", + "skip_nav": "Preterlasu al enhavo" + }, + "notification": { + "updated": "Elemento ĝisdatigita |||| %{smart_count} elementoj ĝisdatigitaj", + "created": "\nElemento kretia", + "deleted": "Elemento foriga |||| %{smart_count} elementoj forigaj", + "bad_item": "Elemento malkorekta", + "item_doesnt_exist": "Elemento ne ekzistas", + "http_error": "Servila komunikada eraro", + "data_provider_error": "datumaProvizora eraro. Kontrolu la konzolon por detaloj.", + "i18n_error": "Ne eblas ŝargi la tradukojn por la specifa lingvo", + "canceled": "Ago nuligita", + "logged_out": "Via seanco finiĝis, bonvolu rekonekti.", + "new_version": "" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "", + "layout": "", + "grid": "", + "table": "" + } }, "message": { - "about": "Pri", - "are_you_sure": "Ĉu vi certas?", - "bulk_delete_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun %{name}? |||| Ĉu vi certas, ke vi volas forigi ĉi tiujn %{smart_count} erojn?", - "bulk_delete_title": "Forigi %{name} |||| Forigi %{smart_count} %{name}", - "delete_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun eron?", - "delete_title": "Forigi %{name} #%{id}", - "details": "Detaloj", - "error": "Klienta eraro okazis kaj via peto ne povis esti plenumita.", - "invalid_form": "La formo ne estas valida. Bonvolu kontroli pri eraroj.", - "loading": "La paĝo ŝargiĝas, atendu nur momenton bonvole", - "no": "Ne", - "not_found": "Aŭ vi tajpis malĝustan ligilon, aŭ vi sekvis malbonan ligilon.", - "yes": "Jes", - "unsaved_changes": "Iuj el viaj ŝanĝoj ne estis konservitaj. Ĉu vi certas, ke vi volas ignori ilin?" - }, - "navigation": { - "no_results": "Neniu rezulto troviĝis", - "no_more_results": "La paĝa numero %{page} estas ekster limoj. Provu la antaŭan paĝon.", - "page_out_of_boundaries": "Paĝa numero %{page} estas ekster limoj", - "page_out_from_end": "Ne povas iri post la lasta paĝo", - "page_out_from_begin": "Ne povas iri antaŭ paĝo 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}", - "page_rows_per_page": "Eroj en paĝo:", - "next": "Sekvanta", - "prev": "Antaŭa", - "skip_nav": "Preterlasu al enhavo" - }, - "notification": { - "updated": "Elemento ĝisdatigita |||| %{smart_count} elementoj ĝisdatigitaj", - "created": "\nElemento kretia", - "deleted": "Elemento foriga |||| %{smart_count} elementoj forigaj", - "bad_item": "Malĝusta elemento", - "item_doesnt_exist": "Elemento ne ekzistas", - "http_error": "Servila komunikada eraro", - "data_provider_error": "datumaProvizora eraro. Kontrolu la konzolon por detaloj.", - "i18n_error": "Ne eblas ŝargi la tradukojn por la specifa lingvo", - "canceled": "Ago nuligita", - "logged_out": "Via seanco finiĝis, bonvolu rekonekti.", - "new_version": "Nova versio haveblas! Bonvolu reŝargi la fenestron." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Kolumnoj Por Montri", - "layout": "Aranĝo", - "grid": "Krado", - "table": "Tabelo" - } - }, - "message": { - "note": "Noto", - "transcodingDisabled": "Ŝanĝi la transkodigan agordon per la interreta interfaco estas malebligita pro sekurecaj kialoj. Se vi ŝatus ŝanĝi (redakti aŭ aldoni) transkodigajn opciojn, relanĉu la servilon per la agordo %{config}.", - "transcodingEnabled": "Navidrome nuntempe funkcias kun %{config}, ebligante lanĉi sistemajn komandojn de la transkodigaj agordoj per la interreta interfaco. Ni rekomendas malŝalti ĝin pro sekurecaj kialoj kaj ebligi ĝin nur dum agordo de Transkodigaj opcioj.", - "songsAddedToPlaylist": "Aldonis 1 kanton al ludlisto |||| Aldonis %{smart_count} kantojn al ludlisto", - "noPlaylistsAvailable": "Neniu disponebla", - "delete_user_title": "Forigi uzanto '%{name}'", - "delete_user_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun uzanton kaj ĉiujn iliajn datumojn (inkluzive ludlistojn kaj preferojn) ?", - "notifications_blocked": "Vi blokis sciigojn por ĉi tiu retejo en la agordoj de via retumilo", - "notifications_not_available": "Ĉi tiu retumilo ne subtenas labortablajn sciigojn aŭ vi ne aliras Navidrome per https", - "lastfmLinkSuccess": "Last.fm sukcese ligiĝis kaj scrobbling ebliĝis", - "lastfmLinkFailure": "Last.fm ne povis ligiĝi", - "lastfmUnlinkSuccess": "Last.fm malligiĝis kaj scrobbling malebliĝis", - "lastfmUnlinkFailure": "Last.fm ne povis malligiĝi", - "openIn": { - "lastfm": "Malfermi en Last.fm", - "musicbrainz": "Malfermi en MusicBrainz" - }, - "lastfmLink": "Legi Pli...", - "listenBrainzLinkSuccess": "ListenBrainz sukcese ligiĝis kaj scrobbling ebliĝis kiel uzanto: %{user}", - "listenBrainzLinkFailure": "ListenBrainz ne povis ligiĝi: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz malligiĝis kaj scrobbling malebliĝis", - "listenBrainzUnlinkFailure": "ListenBrainz ne povis malligiĝi", - "downloadOriginalFormat": "Elŝuti en originala formato", - "shareOriginalFormat": "Diskonigi en originala formato", - "shareDialogTitle": "Diskonigi %{resource} '%{name}'", - "shareBatchDialogTitle": "Diskonigi 1 %{resource} |||| Diskonigi %{smart_count} %{resource}", - "shareSuccess": "Ligilo kopiiĝis al la tondujo: %{url}", - "shareFailure": "Eraro de kopio de ligilo %{url} al la tondujo", - "downloadDialogTitle": "Elŝuti %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Kopii al la tondujo: Ctrl+C, Enter", - "remove_missing_title": "Forigi mankajn dosierojn", - "remove_missing_content": "Ĉu vi certas, ke vi volas forigi la elektitajn mankajn dosierojn de la datumbazo? Ĉi tio forigos eterne ĉiujn referencojn de ili, inkluzive iliajn ludkvantojn kaj taksojn.", - "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", - "startingInstantMix": "", - "uploadCover": "", - "removeCover": "", - "coverUploaded": "", - "coverRemoved": "", - "coverUploadError": "", - "coverRemoveError": "" - }, - "menu": { - "library": "Biblioteko", - "settings": "Agordoj", - "version": "Versio", - "theme": "Etoso", - "personal": { - "name": "Persona", - "options": { - "theme": "Etoso", - "language": "Lingvo", - "defaultView": "Defaŭlta Vido", - "desktop_notifications": "Labortablaj sciigoj", - "lastfmScrobbling": "Scrobble al Last.fm", - "listenBrainzScrobbling": "Scrobble al ListenBrainz", - "replaygain": "ReplayGain-Reĝimo", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "Malebligita", - "album": "Uzi Albuman Songajnon", - "track": "Uzi Kantan Songajnon" + "note": "Noto", + "transcodingDisabled": "Ŝanĝi la transkodigan agordon per la interreta interfaco estas malebligita pro sekurecaj kialoj. Se vi ŝatus ŝanĝi (redakti aŭ aldoni) transkodigajn opciojn, relanĉu la servilon per la agordo %{config}.", + "transcodingEnabled": "Navidrome nuntempe funkcias kun %{config}, ebligante lanĉi sistemajn komandojn de la transkodigaj agordoj per la interreta interfaco. Ni rekomendas malŝalti ĝin pro sekurecaj kialoj kaj ebligi ĝin nur dum agordo de Transkodigaj opcioj.", + "songsAddedToPlaylist": "Aldonis 1 kanton al ludlisto |||| Aldonis %{smart_count} kantojn al ludlisto", + "noPlaylistsAvailable": "Neniu disponebla", + "delete_user_title": "Forigi uzanto '%{name}'", + "delete_user_content": "Ĉu vi certas, ke vi volas forigi ĉi tiun uzanton kaj ĉiujn iliajn datumojn (inkluzive ludlistojn kaj preferojn) ?", + "notifications_blocked": "Vi blokis sciigojn por ĉi tiu retejo en la agordoj de via retumilo", + "notifications_not_available": "Ĉi tiu retumilo ne subtenas labortablajn sciigojn aŭ vi ne aliras Navidrome per https", + "lastfmLinkSuccess": "", + "lastfmLinkFailure": "", + "lastfmUnlinkSuccess": "", + "lastfmUnlinkFailure": "", + "openIn": { + "lastfm": "", + "musicbrainz": "" }, - "lastfmNotConfigured": "API-ŝlosilo de Last.fm ne agordita" - } + "lastfmLink": "", + "listenBrainzLinkSuccess": "", + "listenBrainzLinkFailure": "", + "listenBrainzUnlinkSuccess": "", + "listenBrainzUnlinkFailure": "", + "downloadOriginalFormat": "", + "shareOriginalFormat": "", + "shareDialogTitle": "", + "shareBatchDialogTitle": "", + "shareSuccess": "", + "shareFailure": "", + "downloadDialogTitle": "", + "shareCopyToClipboard": "" }, - "albumList": "Albumoj", - "about": "Pri", - "playlists": "Ludlistoj", - "sharedPlaylists": "Diskonigitaj Ludistoj", - "librarySelector": { - "allLibraries": "Ĉiuj Bibliotekoj (%{count})", - "multipleLibraries": "%{selected} el %{total} Bibliotekoj", - "selectLibraries": "Elekti Bibliotekojn", - "none": "Neniu" - } - }, - "player": { - "playListsText": "Atendovico", - "openText": "Malfermi", - "closeText": "Fermi", - "notContentText": "Neniu muziko", - "clickToPlayText": "Alklaku por ludi", - "clickToPauseText": "Alklaku por paŭzi", - "nextTrackText": "Sekvanta kanto", - "previousTrackText": "Antaŭa kanto", - "reloadText": "Reŝargi", - "volumeText": "Laŭteco", - "toggleLyricText": "Baskuligi kantotekston", - "toggleMiniModeText": "Minimumigi", - "destroyText": "Detrui", - "downloadText": "Elŝuti", - "removeAudioListsText": "Forigi sonlistojn", - "clickToDeleteText": "Alklaku por forigi %{name}", - "emptyLyricText": "Neniu kantoteksto", - "playModeText": { - "order": "Laŭorde", - "orderLoop": "Ripeti", - "singleLoop": "Ripeti Unufoje", - "shufflePlay": "Miksi" - } - }, - "about": { - "links": { - "homepage": "Hejmpaĝo", - "source": "Fontkodo", - "featureRequests": "Trajta peto", - "lastInsightsCollection": "Plej lasta kolekto de datumoj", - "insights": { - "disabled": "Malebligita", - "waiting": "Atendante" - } + "menu": { + "library": "Biblioteko", + "settings": "Agordoj", + "version": "Versio", + "theme": "Temo", + "personal": { + "name": "Persona", + "options": { + "theme": "Temo", + "language": "Lingvo", + "defaultView": "Defaŭlta Vido", + "desktop_notifications": "Labortablaj sciigoj", + "lastfmScrobbling": "", + "listenBrainzScrobbling": "", + "replaygain": "", + "preAmp": "", + "gain": { + "none": "", + "album": "", + "track": "" + } + } + }, + "albumList": "Albumoj", + "about": "Pri", + "playlists": "", + "sharedPlaylists": "" }, - "tabs": { - "about": "Pri", - "config": "Agordo" + "player": { + "playListsText": "Ludu Atendon", + "openText": "Malfermi", + "closeText": "Fermi", + "notContentText": "Neniu Muziko", + "clickToPlayText": "Alklaku por ludi", + "clickToPauseText": "Alklaku por paŭzi", + "nextTrackText": "Sekva muziko", + "previousTrackText": "Antaŭa muziko", + "reloadText": "Reŝargi", + "volumeText": "Laŭteco", + "toggleLyricText": "Baskuligi paroloj", + "toggleMiniModeText": "Minimumigi", + "destroyText": "Detrui", + "downloadText": "Elŝuti", + "removeAudioListsText": "Forigi sonlistojn", + "clickToDeleteText": "Alklaku por forigi %{name}", + "emptyLyricText": "Neniaj paroloj", + "playModeText": { + "order": "En ordo", + "orderLoop": "Ripeti", + "singleLoop": "Ripeti Unu", + "shufflePlay": "Miksi" + } }, - "config": { - "configName": "Agorda Nomo", - "environmentVariable": "Medivariablo", - "currentValue": "Nuna Valoro", - "configurationFile": "Agorda Dosiero", - "exportToml": "Eksporti Agordojn (TOML)", - "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", - "downloadToml": "" + "about": { + "links": { + "homepage": "Hejmpaĝo", + "source": "Fontkodo", + "featureRequests": "Trajta peto" + } + }, + "activity": { + "title": "Aktiveco", + "totalScanned": "Entute dosierujoj skanitaj", + "quickScan": "Rapida Skanado", + "fullScan": "Plena Skanado", + "serverUptime": "Servila daŭro de funkciado", + "serverDown": "SENKONEKTA" + }, + "help": { + "title": "Navidrome klavkomando", + "hotkeys": { + "show_help": "Montru ĉi tiun helpon", + "toggle_menu": "Baskuli menuan flankobreton", + "toggle_play": "Ludi / Paŭzi", + "prev_song": "Antaŭa kanto", + "next_song": "Sekva kanto", + "vol_up": "Pli volumo", + "vol_down": "Malpli volumo", + "toggle_love": "Baskuli la stelon de nuna kanto", + "current_song": "" + } } - }, - "activity": { - "title": "Aktiveco", - "totalScanned": "Entute dosierujoj skanitaj", - "quickScan": "Rapida Skanado", - "fullScan": "Plena Skanado", - "serverUptime": "Servila daŭro de funkciado", - "serverDown": "SENKONEKTA", - "scanType": "Plej Lasta Skano", - "status": "Skana Eraro", - "elapsedTime": "Pasinta Tempo", - "selectiveScan": "Selektema" - }, - "help": { - "title": "Navidrome klavkomando", - "hotkeys": { - "show_help": "Montru ĉi tiun helpon", - "toggle_menu": "Baskuli menuan flankobreton", - "toggle_play": "Ludi / Paŭzi", - "prev_song": "Antaŭa kanto", - "next_song": "Sekva kanto", - "vol_up": "Pli volumo", - "vol_down": "Malpli volumo", - "toggle_love": "Aldoni ĉi tiun kanton al plej ŝatataj", - "current_song": "Iri al Nuna Kanto" - } - }, - "nowPlaying": { - "title": "Nun Ludanta", - "empty": "Nenio ludas", - "minutesAgo": "Antaŭ %{smart_count} minuto |||| Antaŭ %{smart_count} minutoj" - } } \ No newline at end of file diff --git a/resources/i18n/es.json b/resources/i18n/es.json index 555c165d6..3b6c1516e 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -1,723 +1,456 @@ { - "languageName": "Español", - "resources": { - "song": { - "name": "Canción |||| Canciones", - "fields": { - "albumArtist": "Artista del álbum", - "duration": "Duración", - "trackNumber": "#", - "playCount": "Reproducciones", - "title": "Título", - "artist": "Artista", - "album": "Álbum", - "path": "Ruta del archivo", - "genre": "Género", - "compilation": "Compilación", - "year": "Año", - "size": "Tamaño del archivo", - "updatedAt": "Actualizado el", - "bitRate": "Tasa de bits", - "discSubtitle": "Subtítulo del disco", - "starred": "Favorito", - "comment": "Comentario", - "rating": "Calificación", - "quality": "Calidad", - "bpm": "BPM", - "playDate": "Últimas reproducciones", - "channels": "Canales", - "createdAt": "Creado el", - "grouping": "Agrupación", - "mood": "Estado de ánimo", - "participants": "Participantes", - "tags": "Etiquetas", - "mappedTags": "Etiquetas asignadas", - "rawTags": "Etiquetas sin procesar", - "bitDepth": "Profundidad de bits", - "sampleRate": "Frecuencia de muestreo", - "albumGain": "Ganancia del álbum", - "trackGain": "Ganancia de pista", - "missing": "Faltante", - "libraryName": "Biblioteca", - "composer": "Compositor", - "disc": "Disco %{discNumber}" - }, - "actions": { - "addToQueue": "Reproducir después", - "playNow": "Reproducir ahora", - "addToPlaylist": "Agregar a la playlist", - "shuffleAll": "Todas aleatorias", - "download": "Descarga", - "playNext": "Siguiente", - "info": "Obtener información", - "showInPlaylist": "Mostrar en la lista de reproducción", - "instantMix": "Mezcla instantánea" - } + "languageName": "Español", + "resources": { + "song": { + "name": "Canción |||| Canciones", + "fields": { + "albumArtist": "Artista del álbum", + "duration": "Duración", + "trackNumber": "#", + "playCount": "Reproducciones", + "title": "Título", + "artist": "Artista", + "album": "Álbum", + "path": "Ruta del archivo", + "genre": "Género", + "compilation": "Compilación", + "year": "Año", + "size": "Tamaño del archivo", + "updatedAt": "Actualizado el", + "bitRate": "Tasa de bits", + "discSubtitle": "Subtítulo del disco", + "starred": "Favorito", + "comment": "Comentario", + "rating": "Calificación", + "quality": "Calidad", + "bpm": "BPM", + "playDate": "Últimas reproducciones", + "channels": "Canales", + "createdAt": "Creado el" + }, + "actions": { + "addToQueue": "Reproducir después", + "playNow": "Reproducir ahora", + "addToPlaylist": "Agregar a la lista de reproducción", + "shuffleAll": "Todas aleatorias", + "download": "Descarga", + "playNext": "Siguiente", + "info": "Obtener información" + } + }, + "album": { + "name": "Álbum |||| Álbumes", + "fields": { + "albumArtist": "Artista del álbum", + "artist": "Artista", + "duration": "Duración", + "songCount": "Canciones", + "playCount": "Reproducciones", + "name": "Nombre", + "genre": "Género", + "compilation": "Compilación", + "year": "Año", + "updatedAt": "Actualizado el", + "comment": "Comentario", + "rating": "Calificación", + "createdAt": "Creado el", + "size": "Tamaño del archivo" + }, + "actions": { + "playAll": "Reproducir", + "playNext": "Reproducir siguiente", + "addToQueue": "Reproducir después", + "shuffle": "Aletorio", + "addToPlaylist": "Agregar a la lista", + "download": "Descargar", + "info": "Obtener información", + "share": "Compartir" + }, + "lists": { + "all": "Todos", + "random": "Aleatorio", + "recentlyAdded": "Recientes", + "recentlyPlayed": "Recientes", + "mostPlayed": "Más reproducidos", + "starred": "Favoritos", + "topRated": "Los mejores calificados" + } + }, + "artist": { + "name": "Artista |||| Artistas", + "fields": { + "name": "Nombre", + "albumCount": "Número de álbumes", + "songCount": "Número de canciones", + "playCount": "Reproducciones", + "rating": "Calificación", + "genre": "Género", + "size": "Tamaño" + } + }, + "user": { + "name": "Usuario |||| Usuarios", + "fields": { + "userName": "Nombre de usuario", + "isAdmin": "Es administrador", + "lastLoginAt": "Último acceso el", + "updatedAt": "Actualizado el", + "name": "Nombre", + "password": "Contraseña", + "createdAt": "Creado el", + "changePassword": "¿Cambiar contraseña?", + "currentPassword": "Contraseña actual", + "newPassword": "Nueva contraseña", + "token": "Token" + }, + "helperTexts": { + "name": "Los cambios a tu nombre se verán en el próximo inicio de sesión" + }, + "notifications": { + "created": "Usuario creado", + "updated": "Usuario actulalizado", + "deleted": "Usuario eliminado" + }, + "message": { + "listenBrainzToken": "Escribe tu token de usuario de ListenBrainz", + "clickHereForToken": "Click aquí para obtener tu token" + } + }, + "player": { + "name": "Reproductor |||| Reproductores", + "fields": { + "name": "Nombre", + "transcodingId": "Transcodificación", + "maxBitRate": "Tasa de bits Máx.", + "client": "Cliente", + "userName": "Usuario", + "lastSeen": "Última conexión", + "reportRealPath": "Reporta la ruta absoluta", + "scrobbleEnabled": "Envía los scrobbles a servicios externos" + } + }, + "transcoding": { + "name": "Transcodificación |||| Transcodificaciones", + "fields": { + "name": "Nombre", + "targetFormat": "Formato de destino", + "defaultBitRate": "Tasa de bits default", + "command": "Comando" + } + }, + "playlist": { + "name": "Lista |||| Listas", + "fields": { + "name": "Nombre", + "duration": "Duración", + "ownerName": "Dueño", + "public": "Público", + "updatedAt": "Actualizado el", + "createdAt": "Creado el", + "songCount": "Canciones", + "comment": "Comentario", + "sync": "Auto-importados", + "path": "Importados de" + }, + "actions": { + "selectPlaylist": "Seleccione una lista:", + "addNewPlaylist": "Creada \"%{name}\"", + "export": "Exportar", + "makePublic": "Hazla pública", + "makePrivate": "Hazla privada" + }, + "message": { + "duplicate_song": "Algunas de las canciones seleccionadas están presentes en la lista de reproducción", + "song_exist": "Se están agregando duplicados a la lista de reproducción. ¿Quieres agregar los duplicados o omitirlos?" + } + }, + "radio": { + "name": "Radio |||| Radios", + "fields": { + "name": "Nombre", + "streamUrl": "URL del stream", + "homePageUrl": "URL de la página web", + "updatedAt": "Actualizado el", + "createdAt": "Creado el" + }, + "actions": { + "playNow": "Reproducir ahora" + } + }, + "share": { + "name": "Compartir", + "fields": { + "username": "Nombre de usuario", + "url": "", + "description": "Descripción", + "contents": "Contenido", + "expiresAt": "Caduca el", + "lastVisitedAt": "Visitado por última vez el", + "visitCount": "Número de visitas", + "format": "Formato", + "maxBitRate": "Tasa de bits Máx.", + "updatedAt": "Actualizado el", + "createdAt": "Creado el", + "downloadable": "" + } + } }, - "album": { - "name": "Álbum |||| Álbumes", - "fields": { - "albumArtist": "Artista del álbum", - "artist": "Artista", - "duration": "Duración", - "songCount": "Canciones", - "playCount": "Reproducciones", - "name": "Nombre", - "genre": "Género", - "compilation": "Compilación", - "year": "Año", - "updatedAt": "Actualizado el", - "comment": "Comentario", - "rating": "Calificación", - "createdAt": "Creado el", - "size": "Tamaño del archivo", - "originalDate": "Original", - "releaseDate": "Publicado", - "releases": "Lanzamiento |||| Lanzamientos", - "released": "Publicado", - "recordLabel": "Discográfica", - "catalogNum": "Número de catálogo", - "releaseType": "Tipo de lanzamiento", - "grouping": "Agrupación", - "media": "Medios", - "mood": "Estado de ánimo", - "date": "Fecha de grabación", - "missing": "Faltante", - "libraryName": "Biblioteca" - }, - "actions": { - "playAll": "Reproducir", - "playNext": "Reproducir siguiente", - "addToQueue": "Reproducir después", - "shuffle": "Aleatorio", - "addToPlaylist": "Agregar a la lista", - "download": "Descargar", - "info": "Obtener información", - "share": "Compartir" - }, - "lists": { - "all": "Todos", - "random": "Aleatorio", - "recentlyAdded": "Recientes", - "recentlyPlayed": "Recientes", - "mostPlayed": "Más reproducidos", - "starred": "Favoritos", - "topRated": "Mejor calificados" - } - }, - "artist": { - "name": "Artista |||| Artistas", - "fields": { - "name": "Nombre", - "albumCount": "Número de álbumes", - "songCount": "Número de canciones", - "playCount": "Reproducciones", - "rating": "Calificación", - "genre": "Género", - "size": "Tamaño", - "role": "Rol", - "missing": "Faltante" - }, - "roles": { - "albumartist": "Artista del álbum |||| Artistas del álbum", - "artist": "Artista |||| Artistas", - "composer": "Compositor |||| Compositores", - "conductor": "Director de orquesta |||| Directores de orquesta", - "lyricist": "Letrista |||| Letristas", - "arranger": "Arreglista |||| Arreglistas", - "producer": "Productor |||| Productores", - "director": "Director |||| Directores", - "engineer": "Ingeniero de sonido |||| Ingenieros de sonido", - "mixer": "Mezclador |||| Mezcladores", - "remixer": "Remezclador |||| Remezcladores", - "djmixer": "DJ Mezclador |||| DJ Mezcladores", - "performer": "Intérprete |||| Intérpretes", - "maincredit": "Artista del álbum o Artista |||| Artistas del álbum o Artistas" - }, - "actions": { - "shuffle": "Aleatorio", - "radio": "Radio", - "topSongs": "Más destacadas" - } - }, - "user": { - "name": "Usuario |||| Usuarios", - "fields": { - "userName": "Nombre de usuario", - "isAdmin": "Es administrador", - "lastLoginAt": "Último inicio de sesión", - "updatedAt": "Actualizado el", - "name": "Nombre", - "password": "Contraseña", - "createdAt": "Creado el", - "changePassword": "¿Cambiar contraseña?", - "currentPassword": "Contraseña actual", - "newPassword": "Nueva contraseña", - "token": "Token", - "lastAccessAt": "Último acceso", - "libraries": "Bibliotecas" - }, - "helperTexts": { - "name": "Los cambios a tu nombre se verán en el próximo inicio de sesión", - "libraries": "Selecciona bibliotecas específicas para este usuario o déjalo vacío para usar las bibliotecas por defecto" - }, - "notifications": { - "created": "Usuario creado", - "updated": "Usuario actualizado", - "deleted": "Usuario eliminado" - }, - "message": { - "listenBrainzToken": "Escribe tu token de usuario de ListenBrainz", - "clickHereForToken": "Click aquí para obtener tu token", - "selectAllLibraries": "Seleccionar todas las bibliotecas", - "adminAutoLibraries": "Los usuarios administradores tienen acceso a todas las bibliotecas automáticamente" - }, - "validation": { - "librariesRequired": "Se debe seleccionar al menos una biblioteca para los usuarios que no sean administradores" - } - }, - "player": { - "name": "Reproductor |||| Reproductores", - "fields": { - "name": "Nombre", - "transcodingId": "Transcodificación", - "maxBitRate": "Tasa de bits Máx.", - "client": "Cliente", - "userName": "Usuario", - "lastSeen": "Última conexión", - "reportRealPath": "Reporta la ruta absoluta", - "scrobbleEnabled": "Envía los scrobbles a servicios externos" - } - }, - "transcoding": { - "name": "Transcodificación |||| Transcodificaciones", - "fields": { - "name": "Nombre", - "targetFormat": "Formato de destino", - "defaultBitRate": "Tasa de bits por defecto", - "command": "Comando" - } - }, - "playlist": { - "name": "Lista |||| Listas", - "fields": { - "name": "Nombre", - "duration": "Duración", - "ownerName": "Dueño", - "public": "Público", - "updatedAt": "Actualizado el", - "createdAt": "Creado el", - "songCount": "Canciones", - "comment": "Comentario", - "sync": "Auto-importados", - "path": "Importados de" - }, - "actions": { - "selectPlaylist": "Seleccione una lista:", - "addNewPlaylist": "Creada \"%{name}\"", - "export": "Exportar", - "makePublic": "Hazla pública", - "makePrivate": "Hazla privada", - "saveQueue": "Guardar la fila de reproducción en una playlist", - "searchOrCreate": "Buscar listas de reproducción o escribe para crear una nueva…", - "pressEnterToCreate": "Pulsa Enter para crear una nueva lista de reproducción", - "removeFromSelection": "Quitar de la selección" - }, - "message": { - "duplicate_song": "Algunas de las canciones seleccionadas están presentes en la playlist", - "song_exist": "Se están agregando duplicados a la playlist. ¿Quieres agregar los duplicados o omitirlos?", - "noPlaylistsFound": "No se encontraron listas de reproducción", - "noPlaylists": "No hay listas de reproducción disponibles" - } - }, - "radio": { - "name": "Radio |||| Radios", - "fields": { - "name": "Nombre", - "streamUrl": "URL del stream", - "homePageUrl": "URL de la página web", - "updatedAt": "Actualizado el", - "createdAt": "Creado el" - }, - "actions": { - "playNow": "Reproducir ahora" - } - }, - "share": { - "name": "Compartir |||| Compartidos", - "fields": { - "username": "Compartido por", - "url": "URL", - "description": "Descripción", - "contents": "Contenido", - "expiresAt": "Caduca el", - "lastVisitedAt": "Visitado por última vez el", - "visitCount": "Número de visitas", - "format": "Formato", - "maxBitRate": "Tasa de bits Máx.", - "updatedAt": "Actualizado el", - "createdAt": "Creado el", - "downloadable": "¿Permitir descargas?" - } - }, - "missing": { - "name": "Fichero faltante |||| Ficheros faltantes", - "fields": { - "path": "Ruta", - "size": "Tamaño", - "updatedAt": "Actualizado el", - "libraryName": "Biblioteca" - }, - "actions": { - "remove": "Eliminar", - "remove_all": "Eliminar todo" - }, - "notifications": { - "removed": "Eliminado" - }, - "empty": "No faltan archivos" - }, - "library": { - "name": "Biblioteca |||| Bibliotecas", - "fields": { - "name": "Nombre", - "path": "Ruta", - "remotePath": "Ruta remota", - "lastScanAt": "Último escaneo", - "songCount": "Canciones", - "albumCount": "Álbumes", - "artistCount": "Artistas", - "totalSongs": "Canciones", - "totalAlbums": "Álbumes", - "totalArtists": "Artistas", - "totalFolders": "Carpetas", - "totalFiles": "Archivos", - "totalMissingFiles": "Archivos faltantes", - "totalSize": "Tamaño total", - "totalDuration": "Duración", - "defaultNewUsers": "Por defecto para nuevos usuarios", - "createdAt": "Creado", - "updatedAt": "Actualizado" - }, - "sections": { - "basic": "Información básica", - "statistics": "Estadísticas" - }, - "actions": { - "scan": "Escanear biblioteca", - "manageUsers": "Gestionar el acceso de usarios", - "viewDetails": "Ver detalles", - "quickScan": "Escaneo rápido", - "fullScan": "Escaneo completo" - }, - "notifications": { - "created": "La biblioteca se creó correctamente", - "updated": "La biblioteca se actualizó correctamente", - "deleted": "La biblioteca se eliminó correctamente", - "scanStarted": "El escaneo de la biblioteca ha comenzado", - "scanCompleted": "El escaneo de la biblioteca se completó", - "quickScanStarted": "Escaneo rápido ha comenzado", - "fullScanStarted": "Escaneo completo ha comenzado", - "scanError": "Error al iniciar el escaneo. Revisa los registros" - }, - "validation": { - "nameRequired": "El nombre de la biblioteca es obligatorio", - "pathRequired": "La ruta de la biblioteca es obligatoria", - "pathNotDirectory": "La ruta de la biblioteca debe ser un directorio", - "pathNotFound": "Ruta de la biblioteca no encontrada", - "pathNotAccessible": "La ruta de la biblioteca no es accesible", - "pathInvalid": "Ruta de la biblioteca no válida" - }, - "messages": { - "deleteConfirm": "¿Estás seguro/a de que quieres eliminar esta biblioteca? Esto eliminará todos los datos asociados y el acceso de les usuaries.", - "scanInProgress": "Escaneo en curso...", - "noLibrariesAssigned": "No hay bibliotecas asignadas a este usuario" - } - }, - "plugin": { - "name": "Plugin |||| Plugins", - "fields": { - "id": "ID", - "name": "Nombre", - "description": "Descripción", - "version": "Versión", - "author": "Autor", - "website": "Web", - "permissions": "Permisos", - "enabled": "Activado", - "status": "Estado", - "path": "Ruta", - "lastError": "Error", - "hasError": "Error", - "updatedAt": "Actualizado", - "createdAt": "Instalado", - "configKey": "Clave", - "configValue": "Valor", - "allUsers": "Permitir todos los usuarios", - "selectedUsers": "Usuarios seleccionados", - "allLibraries": "Permitir todas las bibliotecas", - "selectedLibraries": "Bibliotecas seleccionadas", - "allowWriteAccess": "Permitir acceso de escritura" - }, - "sections": { - "status": "Estado", - "info": "Información del Plugin", - "configuration": "Configuración", - "manifest": "Manifiesto", - "usersPermission": "Permiso del usuario", - "libraryPermission": "Permiso de la biblioteca" - }, - "status": { - "enabled": "Activado", - "disabled": "Deshabilitado" - }, - "actions": { - "enable": "Activar", - "disable": "Desactivar", - "disabledDueToError": "Corrige el error antes de activar", - "disabledUsersRequired": "Selecciona usuarios antes de activar", - "disabledLibrariesRequired": "Selecciona bibliotecas antes de activar", - "addConfig": "Añadir configuración", - "rescan": "Reescanear" - }, - "notifications": { - "enabled": "Plugin activado", - "disabled": "Plugin deshabilitado", - "updated": "Plugin actualizado", - "error": "Error al actualizar el plugin" - }, - "validation": { - "invalidJson": "La configuración debe ser un JSON válido" - }, - "messages": { - "configHelp": "Configura el plugin utilizando pares de clave-valor. Déjalo en blanco si el plugin no requiere configuración.", - "clickPermissions": "Haz clic en un permiso para ver los detalles", - "noConfig": "No hay configuración establecida", - "allUsersHelp": "Cuando se active, el plugin tendrá acceso a todos los usuarios, incluidos los que se creen en el futuro.", - "noUsers": "Ningún usuario seleccionado", - "permissionReason": "Razón", - "usersRequired": "Este plugin requiere acceso a la información de los usuarios. Selecciona a qué usuarios puede acceder el plugin, o activa 'Permitir todos los usuarios'.", - "allLibrariesHelp": "Cuando se active, el plugin tendrá acceso a todas las bibliotecas, incluidas las que se creen en el futuro.", - "noLibraries": "Ninguna biblioteca seleccionada", - "librariesRequired": "Este plugin requiere acceso a la información de las bibliotecas. Selecciona a qué bibliotecas puede acceder el plugin, o activa 'Permitir todas las bibliotecas'.", - "requiredHosts": "Hosts requeridos", - "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": "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", - "configValue": "valor" - } - } - }, - "ra": { - "auth": { - "welcome1": "¡Gracias por instalar Navidrome!", - "welcome2": "Para empezar, crea un usuario administrador", - "confirmPassword": "Confirme la contraseña", - "buttonCreateAdmin": "Crear Admin", - "auth_check_error": "Por favor inicie sesión para continuar", - "user_menu": "Perfil", - "username": "Usuario", - "password": "Contraseña", - "sign_in": "Acceder", - "sign_in_error": "La autenticación falló, por favor, vuelva a intentarlo", - "logout": "Cerrar sesión", - "insightsCollectionNote": "Navidrome recopila datos de uso anónimos para\nayudar a mejorar el proyecto. Haz clic [aquí] para \nobtener más información y optar por no\nparticipar si lo deseas" - }, - "validation": { - "invalidChars": "Por favor use solo letras y números", - "passwordDoesNotMatch": "La contraseña no coincide", - "required": "Requerido", - "minLength": "Debe contener %{min} caracteres al menos", - "maxLength": "Debe contener %{max} caracteres o menos", - "minValue": "Debe ser al menos %{min}", - "maxValue": "Debe ser %{max} o menos", - "number": "Debe ser un número", - "email": "Debe ser un correo electrónico válido", - "oneOf": "Debe ser uno de: %{options}", - "regex": "Debe coincidir con un formato específico (regexp): %{pattern}", - "unique": "Tiene que ser único", - "url": "Debe ser una URL válida" - }, - "action": { - "add_filter": "Añadir filtro", - "add": "Añadir", - "back": "Ir atrás", - "bulk_actions": "1 elemento seleccionado |||| %{smart_count} elementos seleccionados", - "cancel": "Cancelar", - "clear_input_value": "Limpiar valor", - "clone": "Duplicar", - "confirm": "Confirmar", - "create": "Crear", - "delete": "Eliminar", - "edit": "Editar", - "export": "Exportar", - "list": "Lista", - "refresh": "Refrescar", - "remove_filter": "Eliminar este filtro", - "remove": "Eliminar", - "save": "Guardar", - "search": "Buscar", - "show": "Mostrar", - "sort": "Ordenar", - "undo": "Deshacer", - "expand": "Expandir", - "close": "Cerrar", - "open_menu": "Abrir menú", - "close_menu": "Cerrar menú", - "unselect": "Deseleccionado", - "skip": "Omitir", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Compartir", - "download": "Descargar" - }, - "boolean": { - "true": "Sí", - "false": "No" - }, - "page": { - "create": "Crear %{name}", - "dashboard": "Tablero", - "edit": "%{name} #%{id}", - "error": "Algo salió mal", - "list": "%{name}", - "loading": "Cargando", - "not_found": "No encontrado", - "show": "%{name} #%{id}", - "empty": "Sin %{name} todavía.", - "invite": "¿Quiere agregar una?" - }, - "input": { - "file": { - "upload_several": "Arrastre algunos archivos para subir o haga clic para seleccionar uno.", - "upload_single": "Arrastre un archivo para subir o haga clic para seleccionarlo." - }, - "image": { - "upload_several": "Arrastre algunas imagénes para subir o haga clic para seleccionar una.", - "upload_single": "Arrastre alguna imagen para subir o haga clic para seleccionarla." - }, - "references": { - "all_missing": "No se pueden encontrar datos de referencias.", - "many_missing": "Al menos una de las referencias asociadas parece no estar disponible.", - "single_missing": "La referencia asociada no parece estar disponible." - }, - "password": { - "toggle_visible": "Ocultar contraseña", - "toggle_hidden": "Mostrar contraseña" - } + "ra": { + "auth": { + "welcome1": "¡Gracias por instalar Navidrome!", + "welcome2": "Para empezar, crea un usuario administrador", + "confirmPassword": "Confirme la contraseña", + "buttonCreateAdmin": "Crear Admin", + "auth_check_error": "Por favor inicie sesión para continuar", + "user_menu": "Perfil", + "username": "Usuario", + "password": "Contraseña", + "sign_in": "Acceder", + "sign_in_error": "La autenticación falló, por favor, vuelva a intentarlo", + "logout": "Cerrar sesión" + }, + "validation": { + "invalidChars": "Por favor use solo letras y números", + "passwordDoesNotMatch": "La contraseña no coincide", + "required": "Requerido", + "minLength": "Debe contener %{min} caracteres al menos", + "maxLength": "Debe contener %{max} caracteres o menos", + "minValue": "Debe ser al menos %{min}", + "maxValue": "Debe ser %{max} o menos", + "number": "Debe ser un número", + "email": "Debe ser un correo electrónico válido", + "oneOf": "Debe ser uno de: %{options}", + "regex": "Debe coincidir con un formato específico (regexp): %{pattern}", + "unique": "Tiene que ser único", + "url": "" + }, + "action": { + "add_filter": "Añadir filtro", + "add": "Añadir", + "back": "Ir atrás", + "bulk_actions": "1 elemento seleccionado |||| %{smart_count} elementos seleccionados", + "cancel": "Cancelar", + "clear_input_value": "Limpiar valor", + "clone": "Duplicar", + "confirm": "Confirmar", + "create": "Crear", + "delete": "Eliminar", + "edit": "Editar", + "export": "Exportar", + "list": "Lista", + "refresh": "Refrescar", + "remove_filter": "Eliminar este filtro", + "remove": "Eliminar", + "save": "Guardar", + "search": "Buscar", + "show": "Mostrar", + "sort": "Ordenar", + "undo": "Deshacer", + "expand": "Expandir", + "close": "Cerrar", + "open_menu": "Abrir menú", + "close_menu": "Cerrar menú", + "unselect": "Deseleccionado", + "skip": "Omitir", + "bulk_actions_mobile": "", + "share": "Compartir", + "download": "Descargar" + }, + "boolean": { + "true": "Sí", + "false": "No" + }, + "page": { + "create": "Crear %{name}", + "dashboard": "Tablero", + "edit": "%{name} #%{id}", + "error": "Algo salió mal", + "list": "%{name}", + "loading": "Cargando", + "not_found": "No encontrado", + "show": "%{name} #%{id}", + "empty": "Sin %{name} todavía.", + "invite": "¿Quiere agregar una?" + }, + "input": { + "file": { + "upload_several": "Arrastre algunos archivos para subir o haga clic para seleccionar uno.", + "upload_single": "Arrastre un archivo para subir o haga clic para seleccionarlo." + }, + "image": { + "upload_several": "Arrastre algunas imagénes para subir o haga clic para seleccionar una.", + "upload_single": "Arrastre alguna imagen para subir o haga clic para seleccionarla." + }, + "references": { + "all_missing": "No se pueden encontrar datos de referencias.", + "many_missing": "Al menos una de las referencias asociadas parece no estar disponible.", + "single_missing": "La referencia asociada no parece estar disponible." + }, + "password": { + "toggle_visible": "Ocultar contraseña", + "toggle_hidden": "Mostrar contraseña" + } + }, + "message": { + "about": "Acerca de", + "are_you_sure": "¿Está seguro?", + "bulk_delete_content": "¿Seguro que quiere eliminar este %{name}? |||| ¿Seguro que quiere eliminar estos %{smart_count} elementos?", + "bulk_delete_title": "Eliminar %{name} |||| Eliminar %{smart_count} %{name}", + "delete_content": "¿Seguro que quiere eliminar este elemento?", + "delete_title": "Eliminar %{name} #%{id}", + "details": "Detalles", + "error": "Se produjo un error en el cliente y su solicitud no se pudo completar", + "invalid_form": "El formulario no es válido. Por favor verifique si hay errores", + "loading": "La página se está cargando, espere un momento por favor", + "no": "No", + "not_found": "O bien escribió una URL incorrecta o siguió un enlace incorrecto.", + "yes": "Sí", + "unsaved_changes": "Algunos de sus cambios no se guardaron. ¿Está seguro que quiere ignorarlos?" + }, + "navigation": { + "no_results": "No se han encontrado resultados", + "no_more_results": "El número de página %{page} está fuera de los límites. Pruebe la página anterior.", + "page_out_of_boundaries": "Número de página %{page} fuera de los límites", + "page_out_from_end": "No puede avanzar después de la última página", + "page_out_from_begin": "No puede ir antes de la página 1", + "page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}", + "page_rows_per_page": "Filas por página:", + "next": "Siguiente", + "prev": "Anterior", + "skip_nav": "Pasa al contenido" + }, + "notification": { + "updated": "Elemento actualizado |||| %{smart_count} elementos actualizados", + "created": "Elemento creado", + "deleted": "Elemento eliminado |||| %{smart_count} elementos eliminados.", + "bad_item": "Elemento incorrecto", + "item_doesnt_exist": "El elemento no existe", + "http_error": "Error de comunicación con el servidor", + "data_provider_error": "Error del proveedor de datos. Consulte la consola para más detalles.", + "i18n_error": "No se pudieron cargar las traducciones para el idioma especificado", + "canceled": "Acción cancelada", + "logged_out": "Su sesión ha finalizado, vuelva a conectarse.", + "new_version": "¡Nueva versión disponible! Por favor refresca esta ventana." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Columnas para mostrar", + "layout": "Diseño", + "grid": "Cuadrícula", + "table": "Tabla" + } }, "message": { - "about": "Acerca de", - "are_you_sure": "¿Está seguro?", - "bulk_delete_content": "¿Seguro que quiere eliminar este %{name}? |||| ¿Seguro que quiere eliminar estos %{smart_count} elementos?", - "bulk_delete_title": "Eliminar %{name} |||| Eliminar %{smart_count} %{name}", - "delete_content": "¿Seguro que quiere eliminar este elemento?", - "delete_title": "Eliminar %{name} #%{id}", - "details": "Detalles", - "error": "Se produjo un error en el cliente y su solicitud no se pudo completar", - "invalid_form": "El formulario no es válido. Por favor verifique si hay errores", - "loading": "La página se está cargando, espere un momento por favor", - "no": "No", - "not_found": "O bien escribió una URL incorrecta o siguió un enlace incorrecto.", - "yes": "Sí", - "unsaved_changes": "Algunos de sus cambios no se guardaron. ¿Está seguro que quiere ignorarlos?" - }, - "navigation": { - "no_results": "No se han encontrado resultados", - "no_more_results": "El número de página %{page} está fuera de los límites. Pruebe la página anterior.", - "page_out_of_boundaries": "Número de página %{page} fuera de los límites", - "page_out_from_end": "No puede avanzar después de la última página", - "page_out_from_begin": "No puede ir antes de la página 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}", - "page_rows_per_page": "Filas por página:", - "next": "Siguiente", - "prev": "Anterior", - "skip_nav": "Pasa al contenido" - }, - "notification": { - "updated": "Elemento actualizado |||| %{smart_count} elementos actualizados", - "created": "Elemento creado", - "deleted": "Elemento eliminado |||| %{smart_count} elementos eliminados.", - "bad_item": "Elemento incorrecto", - "item_doesnt_exist": "El elemento no existe", - "http_error": "Error de comunicación con el servidor", - "data_provider_error": "Error del proveedor de datos. Consulte la consola para más detalles.", - "i18n_error": "No se pudieron cargar las traducciones para el idioma especificado", - "canceled": "Acción cancelada", - "logged_out": "Su sesión ha finalizado, vuelva a conectarse.", - "new_version": "¡Nueva versión disponible! Por favor refresca esta ventana." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Columnas para mostrar", - "layout": "Diseño", - "grid": "Cuadrícula", - "table": "Tabla" - } - }, - "message": { - "note": "NOTA", - "transcodingDisabled": "Cambiar la configuración de la transcodificación a través de la interfaz web esta deshabilitado por motivos de seguridad. Si quieres cambiar (editar o agregar) opciones de transcodificación, reinicia el servidor con la %{config} opción de configuración.", - "transcodingEnabled": "Navidrom se esta ejecutando con %{config}, lo que hace posible ejecutar comandos de sistema desde el apartado de transcodificación en la interfaz web. Recomendamos deshabilitarlo por motivos de seguridad y solo habilitarlo cuando se este configurando opciones de transcodificación.", - "songsAddedToPlaylist": "1 canción agregada a la lista |||| %{smart_count} canciones agregadas a la lista", - "noPlaylistsAvailable": "Ninguna lista disponible", - "delete_user_title": "Eliminar usuario '%{name}'", - "delete_user_content": "¿Esta seguro de eliminar a este usuario y todos sus datos (incluyendo listas y preferencias)?", - "notifications_blocked": "Las notificaciones de este sitio están bloqueadas en tu navegador", - "notifications_not_available": "Este navegador no soporta notificaciones o no ingresaste a Navidrome usando https", - "lastfmLinkSuccess": "Last.fm esta conectado y el scrobbling esta activado", - "lastfmLinkFailure": "No se pudo conectar con Last.fm", - "lastfmUnlinkSuccess": "Last.fm se ha desconectado y el scrobbling se desactivo", - "lastfmUnlinkFailure": "No se pudo desconectar Last.fm", - "openIn": { - "lastfm": "Ver en Last.fm", - "musicbrainz": "Ver en MusicBrainz" - }, - "lastfmLink": "Leer más...", - "listenBrainzLinkSuccess": "Se ha conectado correctamente a ListenBrainz y se activó el scrobbling como el usuario: %{user}", - "listenBrainzLinkFailure": "No se pudo conectar con ListenBrainz: %{error}", - "listenBrainzUnlinkSuccess": "Se desconectó ListenBrainz y se desactivó el scrobbling", - "listenBrainzUnlinkFailure": "No se pudo desconectar ListenBrainz", - "downloadOriginalFormat": "Descargar formato original", - "shareOriginalFormat": "Compartir formato original", - "shareDialogTitle": "Compartir %{resource} '%{name}'", - "shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}", - "shareSuccess": "URL copiada al portapapeles: %{url}", - "shareFailure": "Error al copiar la URL %{url} al portapapeles", - "downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro", - "remove_missing_title": "Eliminar archivos faltantes", - "remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.", - "remove_all_missing_title": "Eliminar todos los archivos faltantes", - "remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.", - "noSimilarSongsFound": "No se encontraron canciones similares", - "noTopSongsFound": "No se encontraron canciones destacadas", - "startingInstantMix": "Cargando la mezcla instantánea...", - "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", - "settings": "Ajustes", - "version": "Versión", - "theme": "Tema", - "personal": { - "name": "Personal", - "options": { - "theme": "Tema", - "language": "Idioma", - "defaultView": "Vista por defecto", - "desktop_notifications": "Notificaciones de escritorio", - "lastfmScrobbling": "Scrobble a Last.fm", - "listenBrainzScrobbling": "Scrobble a ListenBrainz", - "replaygain": "Modo de ReplayGain", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "Desactivado", - "album": "Ganancia del álbum", - "track": "Ganancia de pista" + "note": "NOTA", + "transcodingDisabled": "Cambiar la configuración de la transcodificación a través de la interfaz web esta deshabilitado por motivos de seguridad. Si quieres cambiar (editar o agregar) opciones de transcodificación, reinicia el servidor con la %{config} opción de configuración.", + "transcodingEnabled": "Navidrom se esta ejecutando con %{config}, lo que hace posible ejecutar comandos de sistema desde el apartado de transcodificación en la interfaz web. Recomendamos deshabilitarlo por motivos de seguridad y solo habilitarlo cuando se este configurando opciones de transcodificación.", + "songsAddedToPlaylist": "1 canción agregada a la lista |||| %{smart_count} canciones agregadas a la lista", + "noPlaylistsAvailable": "Ninguna lista disponible", + "delete_user_title": "Eliminar usuario '%{name}'", + "delete_user_content": "¿Esta seguro de eliminar a este usuario y todos sus datos (incluyendo listas y preferencias)?", + "notifications_blocked": "Las notificaciones de este sitio están bloqueadas en tu navegador", + "notifications_not_available": "Este navegador no soporta notificaciones o no ingresaste a Navidrome usando https", + "lastfmLinkSuccess": "Last.fm esta conectado y el scrobbling esta activado", + "lastfmLinkFailure": "No se pudo conectar con Last.fm", + "lastfmUnlinkSuccess": "Last.fm se ha desconectado y el scrobbling se desactivo", + "lastfmUnlinkFailure": "No se pudo desconectar Last.fm", + "openIn": { + "lastfm": "Ver en Last.fm", + "musicbrainz": "Ver en MusicBrainz" }, - "lastfmNotConfigured": "La clave API de Last.fm no está configurada" - } + "lastfmLink": "Leer más...", + "listenBrainzLinkSuccess": "Se ha conectado correctamente a ListenBrainz y se activo el scrobbling como el usuario: %{user}", + "listenBrainzLinkFailure": "No se pudo conectar con ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "Se desconecto ListenBrainz y se desactivo el scrobbling", + "listenBrainzUnlinkFailure": "No se pudo desconectar ListenBrainz", + "downloadOriginalFormat": "Descargar formato original", + "shareOriginalFormat": "Compartir formato original", + "shareDialogTitle": "", + "shareBatchDialogTitle": "", + "shareSuccess": "", + "shareFailure": "", + "downloadDialogTitle": "", + "shareCopyToClipboard": "" }, - "albumList": "Álbumes", - "about": "Acerca de", - "playlists": "Playlists", - "sharedPlaylists": "Playlists Compartidas", - "librarySelector": { - "allLibraries": "Todas las bibliotecas (%{count})", - "multipleLibraries": "%{selected} de %{total} bibliotecas", - "selectLibraries": "Seleccionar bibliotecas", - "none": "Ninguno" - } - }, - "player": { - "playListsText": "Fila de reproducción", - "openText": "Abrir", - "closeText": "Cerrar", - "notContentText": "Sin música", - "clickToPlayText": "Clic para reproducir", - "clickToPauseText": "Clic para pausar", - "nextTrackText": "Pista siguiente", - "previousTrackText": "Pista anterior", - "reloadText": "Refrescar", - "volumeText": "Volumen", - "toggleLyricText": "Mostrar letras", - "toggleMiniModeText": "Minimizar", - "destroyText": "Destruir", - "downloadText": "Descargar", - "removeAudioListsText": "Eliminar listas de audio", - "clickToDeleteText": "Clic para eliminar %{name}", - "emptyLyricText": "Sin letras", - "playModeText": { - "order": "En orden", - "orderLoop": "Repetir", - "singleLoop": "Repetir una", - "shufflePlay": "Aleatorio" - } - }, - "about": { - "links": { - "homepage": "Página de inicio", - "source": "Código fuente", - "featureRequests": "Pedir funcionalidad", - "lastInsightsCollection": "Última recopilación de datos", - "insights": { - "disabled": "Deshabilitado", - "waiting": "Esperando" - } + "menu": { + "library": "Biblioteca", + "settings": "Ajustes", + "version": "Versión", + "theme": "Tema", + "personal": { + "name": "Personal", + "options": { + "theme": "Tema", + "language": "Idioma", + "defaultView": "Vista por defecto", + "desktop_notifications": "Notificaciones de escritorio", + "lastfmScrobbling": "Scrobble a Last.fm", + "listenBrainzScrobbling": "Scrobble a ListenBrainz", + "replaygain": "", + "preAmp": "", + "gain": { + "none": "Ninguno", + "album": "Álbum", + "track": "Pista" + } + } + }, + "albumList": "Álbumes", + "about": "Acerca de", + "playlists": "Playlists", + "sharedPlaylists": "Playlists Compartidas" }, - "tabs": { - "about": "Acerca de", - "config": "Configuración" + "player": { + "playListsText": "Lista de reproducción", + "openText": "Abrir", + "closeText": "Cerrar", + "notContentText": "Sin música", + "clickToPlayText": "Clic para reproducir", + "clickToPauseText": "Clic para pausar", + "nextTrackText": "Pista siguiente", + "previousTrackText": "Pista anterior", + "reloadText": "Refrescar", + "volumeText": "Volumen", + "toggleLyricText": "Mostrar letras", + "toggleMiniModeText": "Minimizar", + "destroyText": "Destruir", + "downloadText": "Descargar", + "removeAudioListsText": "Eliminar listas de audio", + "clickToDeleteText": "Clic para eliminar %{name}", + "emptyLyricText": "Sin letras", + "playModeText": { + "order": "En orden", + "orderLoop": "Repetir", + "singleLoop": "Repetir una", + "shufflePlay": "Aleatorio" + } }, - "config": { - "configName": "Nombre de la configuración", - "environmentVariable": "Variables de entorno", - "currentValue": "Valor actual", - "configurationFile": "Archivo de configuración", - "exportToml": "Exportar configuración (TOML)", - "exportSuccess": "Configuración exportada al portapapeles en formato TOML", - "exportFailed": "Error al copiar la configuración", - "devFlagsHeader": "Indicadores de desarrollo (sujetos a cambios o eliminación)", - "devFlagsComment": "Estas son configuraciones experimentales y pueden eliminarse en versiones futuras", - "downloadToml": "Descargar la configuración (TOML)" + "about": { + "links": { + "homepage": "Página de inicio", + "source": "Código fuente", + "featureRequests": "Pedir funcionalidad" + } + }, + "activity": { + "title": "Actividad", + "totalScanned": "Total de carpetas escaneadas", + "quickScan": "Escaneo rápido", + "fullScan": "Escaneo completo", + "serverUptime": "Uptime del servidor", + "serverDown": "OFFLINE" + }, + "help": { + "title": "Atajos de teclado de Navidrome", + "hotkeys": { + "show_help": "Muestra esta ayuda", + "toggle_menu": "Activa/desactiva la barra lateral", + "toggle_play": "Reproducir / Pausar", + "prev_song": "Canción anterior", + "next_song": "Siguiente canción", + "vol_up": "Subir volumen", + "vol_down": "Bajar volumen", + "toggle_love": "Marca esta canción como favorita", + "current_song": "Canción actual" + } } - }, - "activity": { - "title": "Actividad", - "totalScanned": "Total de carpetas escaneadas", - "quickScan": "Escaneo rápido", - "fullScan": "Escaneo completo", - "serverUptime": "Uptime del servidor", - "serverDown": "DESCONECTADO", - "scanType": "Tipo", - "status": "Error de escaneo", - "elapsedTime": "Tiempo transcurrido", - "selectiveScan": "Selectivo" - }, - "help": { - "title": "Atajos de teclado de Navidrome", - "hotkeys": { - "show_help": "Muestra esta ayuda", - "toggle_menu": "Activa/desactiva la barra lateral", - "toggle_play": "Reproducir / Pausar", - "prev_song": "Canción anterior", - "next_song": "Siguiente canción", - "vol_up": "Subir volumen", - "vol_down": "Bajar volumen", - "toggle_love": "Marca esta canción como favorita", - "current_song": "Canción actual" - } - }, - "nowPlaying": { - "title": "En reproducción", - "empty": "Nada en reproducción", - "minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos" - } } \ No newline at end of file diff --git a/resources/i18n/et.json b/resources/i18n/et.json deleted file mode 100644 index b0131f4ea..000000000 --- a/resources/i18n/et.json +++ /dev/null @@ -1,723 +0,0 @@ -{ - "languageName": "eesti keel", - "resources": { - "song": { - "name": "Laul |||| Laulud", - "fields": { - "albumArtist": "Albumi esitaja", - "duration": "Kestus", - "trackNumber": "Nr", - "playCount": "Esituskordi", - "title": "Pealkiri", - "artist": "Esitaja", - "album": "Album", - "path": "Faili asukoht", - "genre": "Žanr", - "compilation": "Kogumik", - "year": "Aasta", - "size": "Faili suurus", - "updatedAt": "Uuendatud", - "bitRate": "Bitikiirus", - "discSubtitle": "Plaadi alapealkiri", - "starred": "Märgi lemmikuks", - "comment": "Kommentaar", - "rating": "Hinnang", - "quality": "Kvaliteet", - "bpm": "BPM", - "playDate": "Viimati esitatud", - "channels": "Kanaleid", - "createdAt": "Lisamise kuupäev", - "grouping": "Rühmitamine", - "mood": "Meeleolu", - "participants": "Täiendavad osalejad", - "tags": "Täiendavad sildid", - "mappedTags": "Tuvastatud sildid", - "rawTags": "Sildid töötlemata vaates", - "bitDepth": "Bitisügavus", - "sampleRate": "Diskreetmisagedus", - "missing": "Puudub", - "libraryName": "Kogumik", - "composer": "Helilooja", - "disc": "%{discNumber}. plaat", - "albumGain": "Albumikohane esitusvaljuse tundlikkus", - "trackGain": "Rajakohane esitusvaljuse tundlikkus" - }, - "actions": { - "addToQueue": "Esita hiljem", - "playNow": "Esita kohe", - "addToPlaylist": "Lisa esitusloendisse", - "shuffleAll": "Sega kõik", - "download": "Laadi alla", - "playNext": "Esita järgmisena", - "info": "Loo teave", - "showInPlaylist": "Näita esitusloendis", - "instantMix": "Kohene miks" - } - }, - "album": { - "name": "Album |||| Albumid", - "fields": { - "albumArtist": "Albumi esitaja", - "artist": "Esitaja", - "duration": "Kestus", - "songCount": "laulu", - "playCount": "Esituskordi", - "name": "Nimi", - "genre": "Žanr", - "compilation": "Kogumik", - "year": "Aasta", - "updatedAt": "Uuendatud", - "comment": "Kommentaar", - "rating": "Hinnangud", - "createdAt": "Lisamise kuupäev", - "size": "Suurus", - "originalDate": "Originaal", - "releaseDate": "Avaldatud", - "releases": "Väljalase ||| Väljalasked", - "released": "Avaldatud", - "recordLabel": "Plaadifirma", - "catalogNum": "Tunnus kataloogides", - "releaseType": "Tüüp", - "grouping": "Grupeerimine", - "media": "Meedium", - "mood": "Meeleolu", - "date": "Salvestuskuupäev", - "missing": "Puudu", - "libraryName": "Kogumik" - }, - "actions": { - "playAll": "Esita", - "playNext": "Esita järgmisena", - "addToQueue": "Esita hiljem", - "shuffle": "Sega lood", - "addToPlaylist": "Lisa esitusloendisse", - "download": "Laadi alla", - "info": "Albumi teave", - "share": "Jaga" - }, - "lists": { - "all": "Kõik", - "random": "Juhuslik", - "recentlyAdded": "Hiljuti lisatud", - "recentlyPlayed": "Hiljuti esitatud", - "mostPlayed": "Enimesitatud", - "starred": "Lemmikud", - "topRated": "Kõrgeima hinnanguga" - } - }, - "artist": { - "name": "Esitaja |||| Esitajad", - "fields": { - "name": "Nimi", - "albumCount": "Albumeid", - "songCount": "Lugusid", - "playCount": "Esituskordi", - "rating": "Hinnang", - "genre": "Žanr", - "size": "Suurus", - "role": "Roll", - "missing": "Puudub" - }, - "roles": { - "albumartist": "Albumi esitaja ||| Albumi esitajad", - "artist": "Esitaja ||| Esitajad", - "composer": "Helilooja ||| Heliloojad", - "conductor": "Dirigent ||| Dirigendid", - "lyricist": "Laulusõnade autor ||| Laulusõnade autorid", - "arranger": "Seade autor ||| Seade autorid", - "producer": "Produtsent ||| Produtsendid", - "director": "Lavastaja ||| Lavastajad", - "engineer": "Helirežissöör ||| Helirežissöörid", - "mixer": "Miksija ||| Miksijad", - "remixer": "Remiksija ||| Remiksijad", - "djmixer": "DJ-versiooni remiksija ||| DJ-versiooni remiksijad", - "performer": "Esineja ||| Esinejad", - "maincredit": "Albumi esitaja või Esitaja ||| Albumi esitajad või Esitajad" - }, - "actions": { - "shuffle": "Sega", - "radio": "Raadio", - "topSongs": "Populaarsed lood" - } - }, - "user": { - "name": "Kasutaja |||| Kasutajad", - "fields": { - "userName": "Kasutajanimi", - "isAdmin": "On peakasutaja", - "lastLoginAt": "Viimane sisselogimine", - "updatedAt": "Uuendatud", - "name": "Nimi", - "password": "Salasõna", - "createdAt": "Loodud", - "changePassword": "Kas soovid salasõna muuta?", - "currentPassword": "Senine salasõna", - "newPassword": "Uus salasõna", - "token": "Tunnusluba", - "lastAccessAt": "Viimati avatud", - "libraries": "Kogumikud" - }, - "helperTexts": { - "name": "Sinu nime muudatused on näha järgmisel sisselogimisel", - "libraries": "Vali selle kasutaja jaoks konkreetsed kogumikud või jäta vaikimisi väärtuse kasutamiseks tühjaks" - }, - "notifications": { - "created": "Kasutaja on lisatud", - "updated": "Kasutaja andmed on uuendatud", - "deleted": "Kasutaja on kustutatud" - }, - "message": { - "listenBrainzToken": "Sisesta oma ListenBrainzi tunnusluba.", - "clickHereForToken": "Tunnusloa saamiseks klõpsi siin", - "selectAllLibraries": "Vali kõik kogumikud", - "adminAutoLibraries": "Peakasutajatel on automaatselt ligipääs kõikidele kogumikele" - }, - "validation": { - "librariesRequired": "Vähemalt üks kogumik peab olema valitud muude, kui peakasutajate jaoks" - } - }, - "player": { - "name": "Meediaesitaja |||| Meediaesitajad", - "fields": { - "name": "Nimi", - "transcodingId": "Teisendamine", - "maxBitRate": "Maksimaalne bitikiirus", - "client": "Klient", - "userName": "Kasutajanimi", - "lastSeen": "Viimati nähtud", - "reportRealPath": "Teata tegelikust asukohast", - "scrobbleEnabled": "Saada kraasimisandmed välistesse teenustesse" - } - }, - "transcoding": { - "name": "Teisendamine |||| Teisendamised", - "fields": { - "name": "Nimi", - "targetFormat": "Sihtvorming", - "defaultBitRate": "Vaikimisi bitikiirus", - "command": "Käsk" - } - }, - "playlist": { - "name": "Esitusloend ||| Esitusloendid", - "fields": { - "name": "Nimi", - "duration": "Kestus", - "ownerName": "Omanik", - "public": "Avalik", - "updatedAt": "Muudetud", - "createdAt": "Loodud", - "songCount": "Lood", - "comment": "Kommentaar", - "sync": "Automaatne import", - "path": "Impordi siit" - }, - "actions": { - "selectPlaylist": "Valo esitusloend:", - "addNewPlaylist": "Loo „%{name}\"“", - "export": "Ekspordi", - "makePublic": "Muuda avalikuks", - "makePrivate": "Muuda privaatseks", - "saveQueue": "Salvesta esitusjärjekord esitusloendina", - "searchOrCreate": "Otsi esitusloendeid või uue loomiseks sisesta nimi...", - "pressEnterToCreate": "Uue esitusloendi lisamiseks vajuta sisestusklahvi", - "removeFromSelection": "Eemalda valikust" - }, - "message": { - "duplicate_song": "Lisa topeltlood", - "song_exist": "Tundub, et oled esitusloendisse lisamas topeltkirjeid. Kas tahad nii jätkata või soovid topeltkirjed vahele jätta?", - "noPlaylistsFound": "Esitusloendeid ei leidu", - "noPlaylists": "Esitusloendeid pole saadaval" - } - }, - "radio": { - "name": "Raadio ||| Raadiod", - "fields": { - "name": "Nimi", - "streamUrl": "Voogedastuse võrguaadress", - "homePageUrl": "Avalehe võrguaadress", - "updatedAt": "Uuendatud", - "createdAt": "Lisatud" - }, - "actions": { - "playNow": "Esita kohe" - } - }, - "share": { - "name": "Jagamine ||| Jagamised", - "fields": { - "username": "Seda jagas", - "url": "Võrguaadress", - "description": "Kirjeldus", - "contents": "Sisu", - "expiresAt": "Aegub", - "lastVisitedAt": "Viimati vaadatud", - "visitCount": "Külastusi", - "format": "Vorming", - "maxBitRate": "Maksimaalne bitikiirus", - "updatedAt": "Muudetud", - "createdAt": "Lisatud", - "downloadable": "Kas lubad allalaadimised?" - } - }, - "missing": { - "name": "Puuduv fail ||| Puuduvad failid", - "fields": { - "path": "Asukoht", - "size": "Suurus", - "updatedAt": "Kadumise aeg", - "libraryName": "Kogumik" - }, - "actions": { - "remove": "Eemalda", - "remove_all": "Eemalda kõik" - }, - "notifications": { - "removed": "Puuduv(ad) fail(id) on eemaldatud" - }, - "empty": "Puuduvaid faile pole" - }, - "library": { - "name": "Kogumik ||| Kogumikud", - "fields": { - "name": "Nimi", - "path": "Asukoht", - "remotePath": "Asukoht kaugseadmes", - "lastScanAt": "Viimane skaneerimine", - "songCount": "Lood", - "albumCount": "Albumid", - "artistCount": "Esitajad", - "totalSongs": "Lood", - "totalAlbums": "Albumid", - "totalArtists": "Esitajad", - "totalFolders": "Kaustad", - "totalFiles": "Failid", - "totalMissingFiles": "Puuduvad failid", - "totalSize": "Kogumaht", - "totalDuration": "Kestus", - "defaultNewUsers": "Vaikimisi väärtus uutele kasutajatele", - "createdAt": "Lisatud", - "updatedAt": "Muudetud" - }, - "sections": { - "basic": "Põhiteave", - "statistics": "Statistika" - }, - "actions": { - "scan": "Skaneeri kogumikku", - "manageUsers": "Halda kasutajate õigusi", - "viewDetails": "Vaata üksikasju", - "quickScan": "Kiirskaneerimine", - "fullScan": "Täismahuline skaneerimine" - }, - "notifications": { - "created": "Kogumiku loomine õnnestus", - "updated": "Kogumiku uuendamine õnnestus", - "deleted": "Kogumiku kustutamine õnnestus", - "scanStarted": "Kogumiku skaneerimine algas", - "scanCompleted": "Kogumiku skaneerimine lõppes", - "quickScanStarted": "Kiirskaneerimine algas", - "fullScanStarted": "Täismahuline skaneerimine algas", - "scanError": "Viga skaneerimise käivitamisel. Lisateavet leiad logidest" - }, - "validation": { - "nameRequired": "Pead sisestama kogumiku nime", - "pathRequired": "Pead sisestama kogumiku asukoha", - "pathNotDirectory": "Kogumiku asukoht peab olema kaust", - "pathNotFound": "Kogumiku asukoha kausta ei leidu", - "pathNotAccessible": "Kogumiku asukoha kaust pole ligipääsetav", - "pathInvalid": "Vigane kogumiku asukoha kaust" - }, - "messages": { - "deleteConfirm": "Kas oled kindel, et soovid selle kogumiku kustutada? Samaga eemaldad ka kõik seotud andmed ja kasutajate ligipääsu.", - "scanInProgress": "Skaneerimine on pooleli...", - "noLibrariesAssigned": "Selle kasutajaga pole veel ühtegi kogumikku seotud" - } - }, - "plugin": { - "name": "Lisamoodul |||| Lisamoodulid", - "fields": { - "id": "Tunnus", - "name": "Nimi", - "description": "Kirjeldus", - "version": "Versioon", - "author": "Autor", - "website": "Veebisait", - "permissions": "Õigused", - "enabled": "Kasutusel", - "status": "Olek", - "path": "Asukoht", - "lastError": "Viga", - "hasError": "Viga", - "updatedAt": "Uuendatud", - "createdAt": "Paigaldatud", - "configKey": "Võti", - "configValue": "Väärtus", - "allUsers": "Luba kõiki kasutajaid", - "selectedUsers": "Valitud kasutajad", - "allLibraries": "Luba kõik kogumikud", - "selectedLibraries": "Valitud kogumikud", - "allowWriteAccess": "Luba kirjutusõigused" - }, - "sections": { - "status": "Olek", - "info": "Lisamooduli teave", - "configuration": "Seadistus", - "manifest": "Manifest", - "usersPermission": "Kasutajate õigused", - "libraryPermission": "Kogumike õigused" - }, - "status": { - "enabled": "Kasutusel", - "disabled": "Pole kasutusel" - }, - "actions": { - "enable": "Võta kasutusele", - "disable": "Eemalda kasutuselt", - "disabledDueToError": "Enne kasutuselevõtmist paranda viga", - "disabledUsersRequired": "Enne kasutuselevõtmist vali kasutajad", - "disabledLibrariesRequired": "Enne kasutuselevõtmist vali kogumikud", - "addConfig": "Lisa seadistus", - "rescan": "Skaneeri uuesti" - }, - "notifications": { - "enabled": "Lisamoodul on kasutusel", - "disabled": "Lisamoodul pole kasutusel", - "updated": "Lisamoodul on uuendatud", - "error": "Viga lisamooduli uuendamisel" - }, - "validation": { - "invalidJson": "Seadistus peab olema koostatud korrektses JSON-vormingus" - }, - "messages": { - "configHelp": "Seadista lisamoodulit võti-väärtus paaride abil. Kui lisamoodul seadistamist ei vaja, siis jäta tühjaks.", - "clickPermissions": "Üksikasjade vaatamiseks klõpsa õigust", - "noConfig": "Ühtegi seadistust pole määratud", - "allUsersHelp": "Kasutuselevõetuna on sellel lisamoodulil ligipääs kõikidele kasutajatele, sealhulgas tulevikus loodavatele.", - "noUsers": "Ühtegi kasutajat pole valitud", - "permissionReason": "Põhjus", - "usersRequired": "See lisamoodul vajab ligipääsu kasutajate teabele. Vali kasutajad, millele ta ligi peaks saama või vali „Kõik kasutajad“.", - "allLibrariesHelp": "Kasutuselevõetuna on sellel lisamoodulil ligipääs kõikidele kogumikele, sealhulgas tulevikus loodavatele.", - "noLibraries": "Ühtegi kogumikku pole valitud", - "librariesRequired": "See lisamoodul vajab ligipääsu kogumiku teabele. Vali kogumikud, millele ta ligi peaks saama või vali „Kõik kogumikud“.", - "requiredHosts": "Nõutavad hostid", - "configValidationError": "Seadistuse õigsuse kontrollimine ei õnnestunud:", - "schemaRenderError": "Seadistuste vormi lugemine ja töötlemine ei õnnestunud. Lisamooduli ülesehitus/skeem võib olla vigane.", - "allowWriteAccessHelp": "Kui valik on kasutusel, siis lisamoodul võib muuta vaid faile kogumike kaustades. Vaikimisi on lisamoodulitel vaid lugemisõigus." - }, - "placeholders": { - "configKey": "võti", - "configValue": "väärtus" - } - } - }, - "ra": { - "auth": { - "welcome1": "Aitäh, et paigaldasid Navidrome'i!", - "welcome2": "Alustamiseks lisa peakasutaja", - "confirmPassword": "Korda salasõna", - "buttonCreateAdmin": "Lisa peakasutaja", - "auth_check_error": "Jätkamiseks palun logi sisse", - "user_menu": "Profiil", - "username": "Kasutajanimi", - "password": "Salasõna", - "sign_in": "Logi sisse", - "sign_in_error": "Tuvastamine ei toiminud, palun proovi uuesti", - "logout": "Logi välja", - "insightsCollectionNote": "Navidrome kogub anonüümset kasutustusstatistikat, mille alusel on võimalik projekti paremaks muuta. Klõpsides [siin], saad lugeda lisateavet ning soovi korral sellest kogumisest loobuda" - }, - "validation": { - "invalidChars": "Palun kasuta ainult tähti ja numbreid", - "passwordDoesNotMatch": "Salasõnad ei kattu", - "required": "Nõutav", - "minLength": "Pikkus peab olema vähemalt %{min} tähemärki", - "maxLength": "Pikkus ei tohi olla üle %{max} tähemärgi", - "minValue": "Väärtus peab olema vähemalt %{min}", - "maxValue": "Väärtus ei tohi olla enam, kui %{max}", - "number": "Sisend peab olema number", - "email": "Sisend peab korrektne e-posti aadress", - "oneOf": "Väärtus peab olema üks järgnevaist: %{options}", - "regex": "Väärtus peab vastama kindlale vormingule (regulaaravaldis): %{pattern}", - "unique": "Sisend peab olema unikaalne", - "url": "Sisend peab olema korrektne võrguaadress" - }, - "action": { - "add_filter": "Lisa filter", - "add": "Lisa", - "back": "Mine tagasi", - "bulk_actions": "1 objekt on valitud |||| %{smart_count} objekti on valitud", - "cancel": "Katkesta", - "clear_input_value": "Eemalda väärtus", - "clone": "Klooni", - "confirm": "Kinnita", - "create": "Loo", - "delete": "Kustuta", - "edit": "Muuda", - "export": "Ekspordi", - "list": "Loend", - "refresh": "Uuenda andmed", - "remove_filter": "Eemalda see filter", - "remove": "Eemalda", - "save": "Salvesta", - "search": "Otsi", - "show": "Näita", - "sort": "Järjesta", - "undo": "Võta tegevus tagasi", - "expand": "Laienda", - "close": "Sulge", - "open_menu": "Ava menüü", - "close_menu": "Sulge menüü", - "unselect": "Eemalda valik", - "skip": "Jäta vahele", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Jaga", - "download": "Laadi alla" - }, - "boolean": { - "true": "Jah", - "false": "Ei" - }, - "page": { - "create": "Loo %{name}", - "dashboard": "Töölaud", - "edit": "%{name} #%{id}", - "error": "Midagi läks valesti", - "list": "%{name}", - "loading": "Laadin", - "not_found": "Ei leidu", - "show": "%{name} #%{id}", - "empty": "Nimi on veel puudu - %{name}.", - "invite": "Kas sa sooviksid ühe sellise lisada?" - }, - "input": { - "file": { - "upload_several": "Lohista üleslaadimiseks mõned failid või vali üks failivalijast.", - "upload_single": "Lohista üleslaadimiseks fail või vali ta failivalijast." - }, - "image": { - "upload_several": "Lohista üleslaadimiseks mõned pildid või vali üks failivalijast.", - "upload_single": "Lohista üleslaadimiseks pilt või vali ta failivalijast." - }, - "references": { - "all_missing": "Viitenumbrite andmeid ei leidu.", - "many_missing": "Vähemalt üks seotud viide ei tundu enam olema saadaval.", - "single_missing": "Seotud viide ei tundu enam olema saadaval." - }, - "password": { - "toggle_visible": "Peida salasõna", - "toggle_hidden": "Näita salasõna" - } - }, - "message": { - "about": "Teave", - "are_you_sure": "Kas oled kindel?", - "bulk_delete_content": "Kas sa oled kindel, et soovid kustutada selle objekti - %{name}? |||| Kas sa oled kindel, et soovid kustutada need %{smart_count} objekti?", - "bulk_delete_title": "Kustuta %{name} |||| Kustuta %{name} - %{smart_count} kirjet", - "delete_content": "Kas oled kindel, et soovid selle objekti kustutada?", - "delete_title": "Kustuta %{name} #%{id}", - "details": "Üksikasjad", - "error": "Tekkis klientrakenduse viga ja päringut polnud võimalik lõpetada.", - "invalid_form": "Vormi andmed pole õiged. Palun kontrolli sisestusi", - "loading": "Leht on just laadimisel, palun oota hetke", - "no": "Ei", - "not_found": "Sa kas sisestasid vigase võrguaadressi või klõpsisid vigast linki.", - "yes": "Jah", - "unsaved_changes": "Mõned sinu muudatused pole salvestatud. Kas sa soovid neist loobuda?" - }, - "navigation": { - "no_results": "Tulemusi ei leidu", - "no_more_results": "Lehe number %{page} on väljaspool etteantud piire. Proovi eelmist lehte.", - "page_out_of_boundaries": "Lehe number %{page} on väljaspool etteantud piire", - "page_out_from_end": "Viimasest lehest ei saa edasi minna", - "page_out_from_begin": "Esimese lehe ette ei saa minna", - "page_range_info": "%{offsetBegin}-%{offsetEnd} - kokku %{total}", - "page_rows_per_page": "Kirjeid lehel:", - "next": "Edasi", - "prev": "Tagasi", - "skip_nav": "Mine sisu juurde" - }, - "notification": { - "updated": "Objekt on uuendatud |||| %{smart_count} objekti on uuendatud", - "created": "Objekt on loodud", - "deleted": "Objekt on kustutatud |||| %{smart_count} objekti on kustutatud", - "bad_item": "Vigane objekt", - "item_doesnt_exist": "Objekti pole olemas", - "http_error": "Viga suhtlemisel serveriga", - "data_provider_error": "Andmeteenusepakkuja viga. Lisateavet leiad brauseri konsoolist.", - "i18n_error": "Vastava keele tõlget ei saa laadida", - "canceled": "Tegevus on tühistatud", - "logged_out": "Sinu sessioon on lõppenud, palun ühenda uuesti.", - "new_version": "Uus versioon on saadaval! Palun laadi see vaade uuesti." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Kuvatavad veerud", - "layout": "Paigutus", - "grid": "Ruudustik", - "table": "Tabel" - } - }, - "message": { - "note": "MÄRGE", - "transcodingDisabled": "Teisendusseadistuste muutmine läbi veebiliidese ei ole turvariskide tõttu saadaval. Kui soovid muuta või lisada teisendamisega seotud seadistusi, taaskäivita server %{config} valikuga.", - "transcodingEnabled": "Navidrome käivitati %{config} valikuga, mis lubab läbi veebiliidese teisendusseadistuste käivitada süsteemikäsklusi. Turvakaalutlustel on soovitatav kasutada seda valikut ainult teisendusvalikute muutmiseks.", - "songsAddedToPlaylist": "Lisasin ühe loo esitusloendisse |||| Lisasin %{smart_count} lugu esitusloendisse", - "noPlaylistsAvailable": "Pole saadaval", - "delete_user_title": "Kustuta kasutaja „%{name}“", - "delete_user_content": "Kas oled kindel, et soovid selle kasutaja ja kõik tema andmed (sh esitusloendid ja eelistused) kustutada?", - "notifications_blocked": "Sa oled selle saidi teavitused veebibrauseri seadistusest keelanud", - "notifications_not_available": "See veebibrauser kas ei toeta töölauateavitusi või sa ei kasuta Navidrome'i üle https-protokolli", - "lastfmLinkSuccess": "Last.fm-i seos on lisatud ja kraasimine on lülitatud sisse", - "lastfmLinkFailure": "Last.fm-i seose lisamine ei õnnestunud", - "lastfmUnlinkSuccess": "Last.fm-i seos on eemaldatud ja kraasimine on lülitatud välja", - "lastfmUnlinkFailure": "Last.fm-i seose eemaldamine ei õnnestunud", - "openIn": { - "lastfm": "Ava Last.fm-is", - "musicbrainz": "Ava MusicBrainzis" - }, - "lastfmLink": "Lisateave...", - "listenBrainzLinkSuccess": "ListenBrainzi seos on lisatud ja kraasimine on lülitatud sisse kasutajana: %{user}", - "listenBrainzLinkFailure": "ListenBrainzi seose lisamine ei õnnestunud: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainzi seos on eemaldatud ja kraasimine on lülitatud välja", - "listenBrainzUnlinkFailure": "ListenBrainzi seose eemaldamine ei õnnestunud", - "downloadOriginalFormat": "Laadi alla algses vormingus", - "shareOriginalFormat": "Jaga algses vormingus", - "shareDialogTitle": "Jaga - %{resource} „%{name}“", - "shareBatchDialogTitle": "Jaga - %{resource} |||| Jaga %{smart_count} kirjet - %{resource}", - "shareSuccess": "Võrguaadress on kopeeritud lõikelauale: %{url}", - "shareFailure": "Viga %{url} võrguaadressi kopeerimisel lõikelauale", - "downloadDialogTitle": "Laadi alla - %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Kopeeri lõikelauale: Ctrl+C, sisestusklahv", - "remove_missing_title": "Eemalda puuduvad failid", - "remove_missing_content": "Kas sa oled kindel, et soovid valitud puuduvate failide andmed andmebaasist eemaldada? Sellega kaovad kõik viited neile, sealhulgas esituskordade ja hinnangute märked.", - "remove_all_missing_title": "Eemalda kõik puuduvad failid", - "remove_all_missing_content": "Kas sa oled kindel, et soovid kõik puuduvate failide andmed andmebaasist eemaldada? Sellega kaovad kõik viited neile, sealhulgas esituskordade ja hinnangute märked.", - "noSimilarSongsFound": "Sarnaseid lugusid ei leidu", - "noTopSongsFound": "Populaarsemaid lugusid ei leidu", - "startingInstantMix": "Laadin kohest miksi...", - "uploadCover": "Laadi kaanepilt üles", - "removeCover": "Eemalda kaanepilt", - "coverUploaded": "Kaanepilt on uuendatud", - "coverRemoved": "Kaanepilt on eemaldatud", - "coverUploadError": "Viga kaanepildi üleslaadimisel", - "coverRemoveError": "Viga kaanepildi eemaldamisel" - }, - "menu": { - "library": "Kogumik", - "settings": "Seadistused", - "version": "Versioon", - "theme": "Kujundus", - "personal": { - "name": "Isiklik", - "options": { - "theme": "Kujundus", - "language": "Keel", - "defaultView": "Vaikimisi vaade", - "desktop_notifications": "Teavitused töölaual", - "lastfmScrobbling": "Kraasi Last.fm-i teenusesse", - "listenBrainzScrobbling": "Kraasi ListenBrainzi teenusesse", - "replaygain": "Esitusvaljuse tundlikkuse režiim", - "preAmp": "Esitusvaljuse tundlikkuse eelvõimendus (dB)", - "gain": { - "none": "Pole kasutusel", - "album": "Kasuta albumikohast esitusvaljuse tundlikkust", - "track": "Kasuta lookohast esitusvaljuse tundlikkust" - }, - "lastfmNotConfigured": "Last.fm-i API-võti pole seadistatud" - } - }, - "albumList": "Albumid", - "about": "Rakenduse teave", - "playlists": "Esitusloendid", - "sharedPlaylists": "Jagatud esitusloendid", - "librarySelector": { - "allLibraries": "Kõik kogumikud (%{count})", - "multipleLibraries": "%{selected} / %{total} kogumikest", - "selectLibraries": "Vali kogumikud", - "none": "Puudub" - } - }, - "player": { - "playListsText": "Esitusjärjekord", - "openText": "Ava", - "closeText": "Sulge", - "notContentText": "Muusikat pole", - "clickToPlayText": "Klõpsa esitamiseks", - "clickToPauseText": "Klõpsa peatamiseks", - "nextTrackText": "Järgmine lugu", - "previousTrackText": "Eelmine lugu", - "reloadText": "Laadi uuesti", - "volumeText": "Helivaljus", - "toggleLyricText": "Näita/peida laulusõnad", - "toggleMiniModeText": "Minimeeri", - "destroyText": "Hävita", - "downloadText": "Laadi alla", - "removeAudioListsText": "Kustuta heliloendid", - "clickToDeleteText": "„%{name}“ kustutamiseks klõpsa", - "emptyLyricText": "Laulusõnu pole", - "playModeText": { - "order": "Oma järjekorras", - "orderLoop": "Korda", - "singleLoop": "Korda üks kord", - "shufflePlay": "Sega lood" - } - }, - "about": { - "links": { - "homepage": "Avaleht", - "source": "Lähtekood", - "featureRequests": "Arendusettepanekud", - "lastInsightsCollection": "Viimati kogutud statistika", - "insights": { - "disabled": "Pole kasutusel", - "waiting": "Ootel" - } - }, - "tabs": { - "about": "Teave", - "config": "Seadistus" - }, - "config": { - "configName": "Seadistuse nimi", - "environmentVariable": "Keskkonnamuutuja", - "currentValue": "Praegune väärtus", - "configurationFile": "Seadistusfail", - "exportToml": "Ekspordi seadistused (TOML-failina)", - "exportSuccess": "Seadistused on eksporditud lõikelauale TOML-failina", - "exportFailed": "Seadistuse kopeerimine ei õnnestunud", - "devFlagsHeader": "Arendusparameetrid (võivad muutuda või sootuks kaduda)", - "devFlagsComment": "Need on katselised seadistused, mis võivad tulevastest versioonidest kaduda", - "downloadToml": "Laadi seadistused alla (TOML-failina)" - } - }, - "activity": { - "title": "Tegevus", - "totalScanned": "Kokku skaneeritud kaustu", - "quickScan": "Kiirskaneerimine", - "fullScan": "Täisskaneerimine", - "serverUptime": "Serveri katkematu tööaeg", - "serverDown": "POLE VÕRGUS", - "scanType": "Tüüp", - "status": "Skaneerimisviga", - "elapsedTime": "Möödunud aeg", - "selectiveScan": "Valikuline" - }, - "help": { - "title": "Navidrome'i kiirklahvid", - "hotkeys": { - "show_help": "Näita seda abiteavet", - "toggle_menu": "Lülita menüü külgriba sisse/välja", - "toggle_play": "Esita / Peata esitus", - "prev_song": "Eelmine lugu", - "next_song": "Järgmine lugu", - "vol_up": "Heli valjemaks", - "vol_down": "Heli vaiksemaks", - "toggle_love": "Lisa see lugu lemmikute hulka", - "current_song": "Mine esitamisel loo juurde" - } - }, - "nowPlaying": { - "title": "Hetkel esitamisel", - "empty": "Mitte midagi pole hetkel esitamisel", - "minutesAgo": "%{smart_count} minut tagasi |||| %{smart_count} minutit tagasi" - } -} \ No newline at end of file diff --git a/resources/i18n/eu.json b/resources/i18n/eu.json deleted file mode 100644 index 30db91cde..000000000 --- a/resources/i18n/eu.json +++ /dev/null @@ -1,725 +0,0 @@ -{ - "languageName": "Euskara", - "resources": { - "song": { - "name": "Abestia |||| Abestiak", - "fields": { - "albumArtist": "Albumaren artista", - "duration": "Iraupena", - "trackNumber": "#", - "playCount": "Erreprodukzioak", - "title": "Titulua", - "artist": "Artista", - "composer": "Konpositorea", - "album": "Albuma", - "path": "Fitxategiaren bidea", - "libraryName": "Liburutegia", - "genre": "Generoa", - "compilation": "Konpilazioa", - "year": "Urtea", - "size": "Fitxategiaren tamaina", - "updatedAt": "Eguneratze-data:", - "bitRate": "Bit-tasa", - "bitDepth": "Bit-sakonera", - "sampleRate": "Lagin-tasa", - "albumGain": "Album-irabazia", - "trackGain": "Pista-irabazia", - "channels": "Kanalak", - "disc": "%{discNumber}. diskoa", - "discSubtitle": "Diskoaren azpititulua", - "starred": "Gogokoa", - "comment": "Iruzkina", - "rating": "Balorazioa", - "quality": "Kalitatea", - "bpm": "BPM", - "playDate": "Azken erreprodukzioa:", - "createdAt": "Gehitu zen data:", - "grouping": "Multzokatzea", - "mood": "Aldartea", - "participants": "Partaide gehiago", - "tags": "Etiketa gehiago", - "mappedTags": "Esleitutako etiketak", - "rawTags": "Etiketa gordinak", - "missing": "Ez da aurkitu" - }, - "actions": { - "addToQueue": "Erreproduzitu ondoren", - "playNow": "Erreproduzitu orain", - "addToPlaylist": "Gehitu erreprodukzio-zerrendara", - "showInPlaylist": "Erakutsi erreprodukzio-zerrendan", - "shuffleAll": "Erreprodukzio aleatorioa", - "download": "Deskargatu", - "playNext": "Hurrengoa", - "info": "Erakutsi informazioa", - "instantMix": "Berehalako nahastea" - } - }, - "album": { - "name": "Albuma |||| Albumak", - "fields": { - "albumArtist": "Albumaren artista", - "artist": "Artista", - "duration": "Iraupena", - "songCount": "abesti", - "playCount": "Erreprodukzioak", - "size": "Fitxategiaren tamaina", - "name": "Izena", - "libraryName": "Liburutegia", - "genre": "Generoa", - "compilation": "Konpilazioa", - "year": "Urtea", - "date": "Recording Date", - "originalDate": "Jatorrizkoa", - "releaseDate": "Argitaratze-data", - "releases": "Argitaratzea |||| Argitaratze", - "released": "Argitaratua", - "updatedAt": "Aktualizatze-data:", - "comment": "Iruzkina", - "rating": "Balorazioa", - "createdAt": "Gehitu zen data:", - "recordLabel": "Disketxea", - "catalogNum": "Katalogo-zenbakia", - "releaseType": "Mota", - "grouping": "Multzokatzea", - "media": "Multimedia", - "mood": "Aldartea", - "missing": "Ez da aurkitu" - }, - "actions": { - "playAll": "Erreproduzitu", - "playNext": "Erreproduzitu orain", - "addToQueue": "Erreproduzitu amaieran", - "shuffle": "Aletorioa", - "addToPlaylist": "Gehitu zerrendara", - "download": "Deskargatu", - "info": "Erakutsi informazioa", - "share": "Partekatu" - }, - "lists": { - "all": "Guztiak", - "random": "Aleatorioa", - "recentlyAdded": "Berriki gehitutakoak", - "recentlyPlayed": "Berriki entzundakoak", - "mostPlayed": "Gehien entzundakoak", - "starred": "Gogokoak", - "topRated": "Hobekien baloratutakoak" - } - }, - "artist": { - "name": "Artista |||| Artistak", - "fields": { - "name": "Izena", - "albumCount": "Album kopurua", - "songCount": "Abesti kopurua", - "size": "Tamaina", - "playCount": "Erreprodukzio kopurua", - "rating": "Balorazioa", - "genre": "Generoa", - "role": "Rola", - "missing": "Ez da aurkitu" - }, - "roles": { - "albumartist": "Albumeko artista |||| Albumeko artistak", - "artist": "Artista |||| Artistak", - "composer": "Konpositorea |||| Konpositoreak", - "conductor": "Orkestra zuzendaria |||| Orkestra zuzendariak", - "lyricist": "Hitzen egilea |||| Hitzen egileak", - "arranger": "Moldatzailea |||| Moldatzaileak", - "producer": "Produktorea |||| Produktoreak", - "director": "Zuzendaria |||| Zuzendaria", - "engineer": "Teknikaria |||| Teknikariak", - "mixer": "Nahaslea |||| Nahasleak", - "remixer": "Remixerra |||| Remixerrak", - "djmixer": "DJ nahaslea |||| DJ nahasleak", - "performer": "Interpretatzailea |||| Interpretatzaileak", - "maincredit": "Albumeko egilea edo egilea |||| Albumeko egileak edo egileak" - }, - "actions": { - "topSongs": "Abesti apartak", - "shuffle": "Aleatorioki", - "radio": "Irratia" - } - }, - "user": { - "name": "Erabiltzailea |||| Erabiltzaileak", - "fields": { - "userName": "Erabiltzailearen izena", - "isAdmin": "Administratzailea da", - "lastLoginAt": "Azken saio hasiera:", - "updatedAt": "Eguneratze-data:", - "name": "Izena", - "password": "Pasahitza", - "createdAt": "Sortze-data:", - "changePassword": "Pasahitza aldatu?", - "currentPassword": "Uneko pasahitza", - "newPassword": "Pasahitz berria", - "token": "Tokena", - "lastAccessAt": "Azken sarbidea", - "libraries": "Liburutegiak" - }, - "helperTexts": { - "name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira", - "libraries": "Hautatu erabiltzaile honentzat liburutegi jakinak, edo utzi hutsik defektuzko liburutegiak erabiltzeko" - }, - "notifications": { - "created": "Erabiltzailea sortu da", - "updated": "Erabiltzailea eguneratu da", - "deleted": "Erabiltzailea ezabatu da" - }, - "validation": { - "librariesRequired": "Gutxienez liburutegi bat hautatu behar da administratzaile ez diren erabiltzaileentzat" - }, - "message": { - "listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena", - "clickHereForToken": "Egin klik hemen tokena lortzeko", - "selectAllLibraries": "Hautatu liburutegi guztiak", - "adminAutoLibraries": "Administratzaileek automatikoki dute liburutegi guztietara sarbidea" - } - }, - "player": { - "name": "Erreproduktorea |||| Erreproduktoreak", - "fields": { - "name": "Izena", - "transcodingId": "Transkodifikazioa", - "maxBitRate": "Gehienezko bit tasa", - "client": "Bezeroa", - "userName": "Erabiltzailea", - "lastSeen": "Azken konexioa", - "reportRealPath": "Erakutsi bide absolutua", - "scrobbleEnabled": "Bidali erabiltzailearen ohiturak hirugarrenen zerbitzuetara" - } - }, - "transcoding": { - "name": "Transkodeketa |||| Transkodeketak", - "fields": { - "name": "Izena", - "targetFormat": "Helburuko formatua", - "defaultBitRate": "Bit tasa, defektuz", - "command": "Komandoa" - } - }, - "playlist": { - "name": "Zerrenda |||| Zerrendak", - "fields": { - "name": "Izena", - "duration": "Iraupena", - "ownerName": "Jabea", - "public": "Publikoa", - "updatedAt": "Eguneratze-data:", - "createdAt": "Sortze-data:", - "songCount": "abesti", - "comment": "Iruzkina", - "sync": "Automatikoki inportatuak", - "path": "Inportatze-data:" - }, - "actions": { - "selectPlaylist": "Hautatu zerrenda:", - "addNewPlaylist": "Sortu \"%{name}\"", - "export": "Esportatu", - "saveQueue": "Gorde ilaran daudek erreprodukzio-zerrendan", - "makePublic": "Egin publikoa", - "makePrivate": "Egin pribatua", - "searchOrCreate": "Bilatu erreprodukzio-zerrenda edo idatzi berria sortzeko…", - "pressEnterToCreate": "Sakatu Enter erreprodukzio-zerrenda berria sortzeko", - "removeFromSelection": "Kendu hautaketatik" - }, - "message": { - "duplicate_song": "Hautatutako abesti batzuk lehendik ere daude zerrendan", - "song_exist": "Bikoiztutakoak gehitzen ari dira erreprodukzio-zerrendara. Ziur gehitu nahi dituzula?", - "noPlaylistsFound": "Ez da erreprodukzio-zerrenda aurkitu", - "noPlaylists": "Ez dago erreprodukzio-zerrendarik eskuragarri" - } - }, - "radio": { - "name": "Irratia |||| Irratiak", - "fields": { - "name": "Izena", - "streamUrl": "Jarioaren URLa", - "homePageUrl": "Web orriaren URLa", - "updatedAt": "Eguneratze-data:", - "createdAt": "Sortze-data:" - }, - "actions": { - "playNow": "Erreproduzitu orain" - } - }, - "share": { - "name": "Partekatu", - "fields": { - "username": "Partekatzailea:", - "url": "URLa", - "description": "Deskribapena", - "downloadable": "Deskargatzea ahalbidetu?", - "contents": "Edukia", - "expiresAt": "Iraungitze-data:", - "lastVisitedAt": "Azkenekoz bisitatu zen:", - "visitCount": "Bisita kopurua", - "format": "Formatua", - "maxBitRate": "Gehienezko bit tasa", - "updatedAt": "Eguneratze-data:", - "createdAt": "Sortze-data:" - }, - "notifications": {}, - "actions": {} - }, - "missing": { - "name": "Aurkitu ez den fitxategia |||| Aurkitu ez diren fitxategiak", - "empty": "Ez da fitxategirik falta", - "fields": { - "path": "Bidea", - "size": "Tamaina", - "libraryName": "Liburutegia", - "updatedAt": "Desagertze-data:" - }, - "actions": { - "remove": "Kendu", - "remove_all": "Kendu guztiak" - }, - "notifications": { - "removed": "Aurkitzen ez ziren fitxategiak kendu dira" - } - }, - "library": { - "name": "Liburutegia |||| Liburutegiak", - "fields": { - "name": "Izena", - "path": "Fitxategiaren bidea", - "remotePath": "Urruneko bidea", - "lastScanAt": "Azken araketa", - "songCount": "Abestiak", - "albumCount": "Albumak", - "artistCount": "Artistak", - "totalSongs": "Abestiak", - "totalAlbums": "Albumak", - "totalArtists": "Artistak", - "totalFolders": "Karpetak", - "totalFiles": "Fitxategiak", - "totalMissingFiles": "Fitxategiak faltan", - "totalSize": "Tamaina guztira", - "totalDuration": "Iraupena", - "defaultNewUsers": "Defektuz erabiltzaile berrientzat", - "createdAt": "Sortze-data", - "updatedAt": "Eguneratze-data" - }, - "sections": { - "basic": "Oinarrizko informazioa", - "statistics": "Estatistikak" - }, - "actions": { - "scan": "Arakatu liburutegia", - "quickScan": "Araketa bizkorra", - "fullScan": "Araketa sakona", - "manageUsers": "Kudeatu erabiltzaileen sarbidea", - "viewDetails": "Ikusi xehetasunak" - }, - "notifications": { - "created": "Liburutegia ondo sortu da", - "updated": "Liburutegia ondo eguneratu da", - "deleted": "Liburutegia ondo ezabatu da", - "scanStarted": "Liburutegiaren araketa hasi da", - "quickScanStarted": "Araketa bizkorra hasi da", - "fullScanStarted": "Araketa sakona hasi da", - "scanError": "Errorea araketa abiaraztean. Aztertu erregistroak", - "scanCompleted": "Liburutegiaren araketa amaitu da" - }, - "validation": { - "nameRequired": "Liburutegiaren izena beharrezkoa da", - "pathRequired": "Liburutegiaren bidea beharrezkoa da", - "pathNotDirectory": "Liburutegiaren bidea direktorio bat izan behar da", - "pathNotFound": "Ez da liburutegiaren bidea aurkitu", - "pathNotAccessible": "Liburutegiaren bidea ez dago eskuragai", - "pathInvalid": "Liburutegiaren bidea ez da baliozkoa" - }, - "messages": { - "deleteConfirm": "Ziur liburutegia ezabatu nahi duzula? Erlazionatutako datu guztiak eta erabiltzaileen sarbidea kenduko ditu.", - "scanInProgress": "Araketa abian da…", - "noLibrariesAssigned": "Ez da liburutegirik egokitu erabiltzaile honentzat" - } - }, - "plugin": { - "name": "Plugina |||| Pluginak", - "fields": { - "id": "IDa", - "name": "Izena", - "description": "Deskribapena", - "version": "Bertsioa", - "author": "Autorea", - "website": "Webgunea", - "permissions": "Baimenak", - "enabled": "Gaituta", - "status": "Egoera", - "path": "Bidea", - "lastError": "Errorea", - "hasError": "Errorea", - "updatedAt": "Eguneratuta", - "createdAt": "Instalatuta", - "configKey": "Gakoa", - "configValue": "Balioa", - "allUsers": "Baimendu erabiltzaile guztiak", - "selectedUsers": "Hautatutako erabiltzaileak", - "allLibraries": "Baimendu liburutegi guztiak", - "selectedLibraries": "Hautatutako liburutegiak", - "allowWriteAccess": "Eman idazteko baimena" - }, - "sections": { - "status": "Egoera", - "info": "Pluginaren informazioa", - "configuration": "Konfigurazioa", - "manifest": "Manifestua", - "usersPermission": "Erabiltzaileen baimenak", - "libraryPermission": "Liburutegien baimenak" - }, - "status": { - "enabled": "Gaituta", - "disabled": "Ezgaituta" - }, - "actions": { - "enable": "Gaitu", - "disable": "Ezgaitu", - "disabledDueToError": "Konpondu errorea gaitu baino lehen", - "disabledUsersRequired": "Hautatu erabiltzaileak gaitu baino lehen", - "disabledLibrariesRequired": "Hautatu liburutegiak gaitu baino lehen", - "addConfig": "Gehitu konfigurazioa", - "rescan": "Arakatu berriro" - }, - "notifications": { - "enabled": "Plugina gaituta", - "disabled": "Plugina ezgaituta", - "updated": "Plugina eguneratuta", - "error": "Errorea plugina eguneratzean" - }, - "validation": { - "invalidJson": "Konfigurazioa baliozko JSON-a izan behar da" - }, - "messages": { - "configHelp": "Konfiguratu plugina gako-balio bikoteak erabiliz. Utzi hutsik pluginak konfiguraziorik behar ez badu.", - "configValidationError": "Huts egin du konfigurazioaren balidazioak:", - "schemaRenderError": "Ezin izan da konfigurazioaren formularioa bihurtu. Litekeena da pluginaren eskema baliozkoa ez izatea.", - "clickPermissions": "Sakatu baimen batean xehetasunetarako", - "noConfig": "Ez da konfiguraziorik ezarri", - "allUsersHelp": "Gaituta dagoenean, pluginak erabiltzaile guztiak atzitu ditzazke, baita etorkizunean sortuko direnak ere.", - "noUsers": "Ez da erabiltzailerik hautatu", - "permissionReason": "Arrazoia", - "usersRequired": "Plugin honek erabiltzaileen informaziora sarbidea behar du. Hautatu zein erabiltzaile atzitu dezakeen pluginak, edo gaitu 'Baimendu erabiltzaile guztiak'.", - "allLibrariesHelp": "Gaituta dagoenean, pluginak liburutegi guztietara izango du sarbidea, baita etorkizunean sortuko direnetara ere.", - "noLibraries": "Ez da liburutegirik hautatu", - "librariesRequired": "Plugin honek liburutegien informaziora sarbidea behar du. Hautatu zein liburutegi atzitu dezakeen pluginak, edo gaitu 'Baimendu liburutegi guztiak'.", - "allowWriteAccessHelp": "Gaituta dagoenean, pluginak liburutegien direktorioko fitxategiak moldatu ditzake. Defektuz, pluginek bakarrik irakurtzeko baimena dute.", - "requiredHosts": "Beharrezko ostatatzaileak" - }, - "placeholders": { - "configKey": "gakoa", - "configValue": "balioa" - } - } - }, - "ra": { - "auth": { - "welcome1": "Eskerrik asko Navidrome instalatzeagatik!", - "welcome2": "Lehenik eta behin, sortu administratzaile kontua", - "confirmPassword": "Baieztatu pasahitza", - "buttonCreateAdmin": "Sortu administratzailea", - "auth_check_error": "Hasi saioa aurrera egiteko", - "user_menu": "Profila", - "username": "Erabiltzailea", - "password": "Pasahitza", - "sign_in": "Sartu", - "sign_in_error": "Autentifikazioak huts egin du, saiatu berriro", - "logout": "Amaitu saioa", - "insightsCollectionNote": "Navidromek erabilera-datu anonimoak biltzen ditu\nproiektua hobetzen laguntzeko. Egin klik [hemen]\ngehiago ikasteko, eta datuak ez biltzeko eskatzeko,\nhala nahi izanez gero." - }, - "validation": { - "invalidChars": "Erabili hizkiak eta zenbakiak bakarrik", - "passwordDoesNotMatch": "Pasahitzak ez datoz bat", - "required": "Beharrezkoa", - "minLength": "Gutxienez %{min} karaktere izan behar ditu", - "maxLength": "Gehienez %{max} karaktere izan ditzake", - "minValue": "Gutxienez %{min} izan behar da", - "maxValue": "Gehienez %{max} izan daiteke", - "number": "Zenbakia izan behar da", - "email": "Baliozko ePosta helbidea izan behar da", - "oneOf": "Hauetako bat izan behar da: %{options}", - "regex": "Formatu zehatzarekin bat etorri behar da (regexp): %{pattern}", - "unique": "Bakarra izan behar da", - "url": "Baliozko URLa izan behar da" - }, - "action": { - "add_filter": "Gehitu iragazkia", - "add": "Gehitu", - "back": "Itzuli", - "bulk_actions": "elementu 1 hautatuta |||| %{smart_count} elementu hautatuta", - "cancel": "Utzi", - "clear_input_value": "Garbitu balioa", - "clone": "Bikoiztu", - "confirm": "Baieztatu", - "create": "Sortu", - "delete": "Ezabatu", - "edit": "Editatu", - "export": "Esportatu", - "list": "Zerrenda", - "refresh": "Freskatu", - "remove_filter": "Ezabatu iragazkia", - "remove": "Ezabatu", - "save": "Gorde", - "search": "Bilatu", - "show": "Erakutsi", - "sort": "Ordenatu", - "undo": "Desegin", - "expand": "Hedatu", - "close": "Itxi", - "open_menu": "Ireki menua", - "close_menu": "Itxi menua", - "unselect": "Utzi hautatzeari", - "skip": "Utzi alde batera", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Partekatu", - "download": "Deskargatu" - }, - "boolean": { - "true": "Bai", - "false": "Ez" - }, - "page": { - "create": "Sortu %{name}", - "dashboard": "Mahaigaina", - "edit": "%{name} #%{id}", - "error": "Zerbaitek huts egin du", - "list": "%{name}", - "loading": "Kargatzen", - "not_found": "Ez da aurkitu", - "show": "%{name} #%{id}", - "empty": "Oraindik ez dago %{name}(r)ik.", - "invite": "Sortu nahi al duzu?" - }, - "input": { - "file": { - "upload_several": "Jaregin edo hautatu igo nahi dituzun fitxategiak.", - "upload_single": "Jaregin edo hautatu igo nahi duzun fitxategia." - }, - "image": { - "upload_several": "Jaregin edo hautatu igo nahi dituzun irudiak.", - "upload_single": "Jaregin edo hautatu igo nahi duzun irudia." - }, - "references": { - "all_missing": "Ezin dira erreferentziazko datuak aurkitu.", - "many_missing": "Erreferentzietako bat gutxieenez ez dago eskuragai.", - "single_missing": "Ez dirudi erreferentzia eskuragai dagoenik." - }, - "password": { - "toggle_visible": "Ezkutatu pasahitza", - "toggle_hidden": "Erakutsi pasahitza" - } - }, - "message": { - "about": "Honi buruz", - "are_you_sure": "Ziur zaude?", - "bulk_delete_content": "Ziur %{name} ezabatu nahi duzula? |||| Ziur %{smart_count} hauek ezabatu nahi dituzula?", - "bulk_delete_title": "Ezabatu %{name} |||| Ezabatu %{smart_count} %{name}", - "delete_content": "Ziur elementu hau ezabatu nahi duzula?", - "delete_title": "Ezabatu %{name} #%{id}", - "details": "Xehetasunak", - "error": "Bezeroan errorea gertatu da eta eskaera ezin izan da gauzatu", - "invalid_form": "Formularioa ez da baliozkoa. Egiaztatu errorerik ez dagoela", - "loading": "Orria kargatzen ari da, itxaron", - "no": "Ez", - "not_found": "URLa ez da zuzena edo jarraitutako esteka akastuna da.", - "yes": "Bai", - "unsaved_changes": "Ez dira aldaketa batzuk gorde. Ziur muzin egin nahi diezula?" - }, - "navigation": { - "no_results": "Ez da emaitzarik aurkitu", - "no_more_results": "%{page} orrialde-zenbakia mugetatik kanpo dago. Saiatu aurreko orrialdearekin.", - "page_out_of_boundaries": "%{page} orrialde-zenbakia mugetatik kanpo dago", - "page_out_from_end": "Ezin zara azken orrialdea baino haratago joan", - "page_out_from_begin": "Ezin zara lehenengo orrialdea baino aurrerago joan", - "page_range_info": "%{offsetBegin}-%{offsetEnd}, %{total} guztira", - "page_rows_per_page": "Errenkadak orrialdeko:", - "next": "Hurrengoa", - "prev": "Aurrekoa", - "skip_nav": "Joan edukira" - }, - "notification": { - "updated": "Elementua eguneratu da |||| %{smart_count} elementu eguneratu dira", - "created": "Elementua sortu da", - "deleted": "Elementua ezabatu da |||| %{smart_count} elementu ezabatu dira.", - "bad_item": "Elementu okerra", - "item_doesnt_exist": "Elementua ez dago", - "http_error": "Errorea zerbitzariarekin komunikatzerakoan", - "data_provider_error": "Errorea datuen hornitzailean. Aztertu kontsola xehetasun gehiagorako.", - "i18n_error": "Ezin izan dira zehaztutako hizkuntzaren itzulpenak kargatu", - "canceled": "Ekintza bertan behera utzi da", - "logged_out": "Saioa amaitu da, konektatu berriro.", - "new_version": "Bertsio berria eskuragai! Freskatu leihoa." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Erakusteko zutabeak", - "layout": "Antolaketa", - "grid": "Sareta", - "table": "Taula" - } - }, - "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.", - "songsAddedToPlaylist": "Abesti bat zerrendara gehitu da |||| %{smart_count} abesti zerrendara gehitu dira", - "noSimilarSongsFound": "Ez da antzeko abestirik aurkitu", - "startingInstantMix": "Berehalako nahastea kargatzen…", - "noTopSongsFound": "Ez da aparteko abestirik aurkitu", - "noPlaylistsAvailable": "Ez dago zerrendarik erabilgarri", - "delete_user_title": "Ezabatu '%{name}' erabiltzailea", - "delete_user_content": "Ziur zaide erabiltzaile hau eta bere datu guztiak (zerrendak eta hobespenak barne) ezabatu nahi dituzula?", - "remove_missing_title": "Kendu faltan dauden fitxategiak", - "remove_missing_content": "Ziur hautatutako fitxategiak datu-basetik kendu nahi dituzula? Betiko kenduko dira haiei buruzko erreferentziak, erreprodukzio-zenbaketak eta sailkapenak barne.", - "remove_all_missing_title": "Kendu faltan dauden fitxategi guztiak", - "remove_all_missing_content": "Ziur aurkitu ez diren fitxategi guztiak datu-basetik kendu nahi dituzula? Betiko kenduko dira haiei buruzko erreferentziak, erreprodukzio-zenbaketak eta sailkapenak barne.", - "notifications_blocked": "Nabigatzaileak jakinarazpenak blokeatzen ditu", - "notifications_not_available": "Nabigatzaile hau ez da jakinarazpenekin bateragarria edo Navidrome ez da HTTPS erabiltzen ari", - "lastfmLinkSuccess": "Last.fm konektatuta dago eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea gaituta dago", - "lastfmLinkFailure": "Ezin izan da Last.fm-rekin konektatu", - "lastfmUnlinkSuccess": "Last.fm deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea ezgaitu da", - "lastfmUnlinkFailure": "Ezin izan da Last.fm deskonektatu", - "listenBrainzLinkSuccess": "Ondo konektatu da ListenBrainz-ekin eta %{user} erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea aktibatu da", - "listenBrainzLinkFailure": "Ezin izan da ListenBrainz-ekin konektatu: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da", - "listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu", - "openIn": { - "lastfm": "Ikusi Last.fm-en", - "musicbrainz": "Ikusi MusicBrainz-en" - }, - "lastfmLink": "Irakurri gehiago…", - "shareOriginalFormat": "Partekatu jatorrizko formatua", - "shareDialogTitle": "Partekatu '%{name}' %{resource}", - "shareBatchDialogTitle": "Partekatu %{resource} bat |||| Partekatu %{smart_count} %{resource}", - "shareCopyToClipboard": "Kopiatu arbelera: Ktrl + C, Sartu tekla", - "shareSuccess": "URLa arbelera kopiatu da: %{url}", - "shareFailure": "Errorea %{url} URLa arbelera kopiatzean", - "downloadDialogTitle": "Deskargatu '%{name}' %{resource}, (%{size})", - "downloadOriginalFormat": "Deskargatu jatorrizko formatua" - }, - "menu": { - "library": "Liburutegia", - "librarySelector": { - "allLibraries": "Liburutegi guztiak (%{count})", - "multipleLibraries": "%{total} liburutegitik %{selected} hautatuta", - "selectLibraries": "Hautatu liburutegiak", - "none": "Bat ere ez" - }, - "settings": "Ezarpenak", - "version": "Bertsioa", - "theme": "Itxura", - "personal": { - "name": "Pertsonala", - "options": { - "theme": "Itxura", - "language": "Hizkuntza", - "defaultView": "Bista, defektuz", - "desktop_notifications": "Mahaigaineko jakinarazpenak", - "lastfmNotConfigured": "Last.fm-ren API-gakoa ez dago konfiguratuta", - "lastfmScrobbling": "Bidali Last.fm-ra erabiltzailearen ohiturak", - "listenBrainzScrobbling": "Bidali ListenBrainz-era erabiltzailearen ohiturak", - "replaygain": "ReplayGain modua", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "Bat ere ez", - "album": "Albuma", - "track": "Pista" - } - } - }, - "albumList": "Albumak", - "playlists": "Zerrendak", - "sharedPlaylists": "Partekatutako erreprodukzio-zerrendak", - "about": "Honi buruz" - }, - "player": { - "playListsText": "Erreprodukzio-zerrenda", - "openText": "Ireki", - "closeText": "Itxi", - "notContentText": "Ez dago musikarik", - "clickToPlayText": "Egin klik erreproduzitzeko", - "clickToPauseText": "Egin klik eteteko", - "nextTrackText": "Hurrengo pista", - "previousTrackText": "Aurreko pista", - "reloadText": "Freskatu", - "volumeText": "Bolumena", - "toggleLyricText": "Erakutsi letrak", - "toggleMiniModeText": "Ikonotu", - "destroyText": "Suntsitu", - "downloadText": "Deskargatu", - "removeAudioListsText": "Ezabatu audio-zerrendak", - "clickToDeleteText": "Egin klik %{name} ezabatzeko", - "emptyLyricText": "Ez dago letrarik", - "playModeText": { - "order": "Ordenean", - "orderLoop": "Errepikatu", - "singleLoop": "Errepikatu abesti hau", - "shufflePlay": "Aleatorioki" - } - }, - "about": { - "links": { - "homepage": "Hasierako orria", - "source": "Iturburu kodea", - "featureRequests": "Eskatu ezaugarria", - "lastInsightsCollection": "Bildutako azken datuak", - "insights": { - "disabled": "Ezgaituta", - "waiting": "Zain" - } - }, - "tabs": { - "about": "Honi buruz", - "config": "Konfigurazioa" - }, - "config": { - "configName": "Konfigurazioaren izena", - "environmentVariable": "Ingurune-aldagaia", - "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)", - "devFlagsComment": "Ezarpen esperimentalak dira eta litekeena da etorkizunean desagertzea" - } - }, - "activity": { - "title": "Ekintzak", - "totalScanned": "Arakatutako karpeta guztiak", - "quickScan": "Arakatze bizkorra", - "fullScan": "Arakatze sakona", - "selectiveScan": "Arakatze selektiboa", - "serverUptime": "Zerbitzariak piztuta daraman denbora", - "serverDown": "LINEAZ KANPO", - "scanType": "Mota", - "status": "Errorea arakatzean", - "elapsedTime": "Igarotako denbora" - }, - "nowPlaying": { - "title": "Une honetan erreproduzitzen", - "empty": "Ez dago erreproduzitzeko ezer", - "minutesAgo": "Duela minutu %{smart_count} |||| Duela %{smart_count} minutu" - }, - "help": { - "title": "Navidromeren laster-teklak", - "hotkeys": { - "show_help": "Erakutsi laguntza", - "toggle_menu": "Alboko barra bai / ez", - "toggle_play": "Erreproduzitu / Eten", - "prev_song": "Aurreko abestia", - "next_song": "Hurrengo abestia", - "vol_up": "Igo bolumena", - "vol_down": "Jaitsi bolumena", - "toggle_love": "Abestia gogoko bai / ez", - "current_song": "Uneko abestia" - } - } -} diff --git a/resources/i18n/fa.json b/resources/i18n/fa.json index 86504afef..6393de0e9 100644 --- a/resources/i18n/fa.json +++ b/resources/i18n/fa.json @@ -1,5 +1,5 @@ { - "languageName": "فارسی", + "languageName": "انگلیسی", "resources": { "song": { "name": "ترانه |||| ترانه ها", @@ -54,11 +54,7 @@ "comment": "اظهارنظر", "rating": "رتبه بندی", "createdAt": "", - "size": "", - "originalDate": "", - "releaseDate": "", - "releases": "", - "released": "" + "size": "" }, "actions": { "playAll": "پخش", @@ -457,4 +453,4 @@ "current_song": "" } } -} +} \ No newline at end of file diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index f79d71f94..57a37f045 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -1,745 +1,456 @@ { - "languageName": "Suomi", - "resources": { - "song": { - "name": "Kappale |||| Kappaleet", - "fields": { - "albumArtist": "Albumin artisti", - "duration": "Kesto", - "trackNumber": "#", - "playCount": "Soittokerrat", - "title": "Kappale", - "artist": "Artisti", - "album": "Albumi", - "path": "Tiedostopolku", - "genre": "Tyylilaji", - "compilation": "Kokoelma", - "year": "Vuosi", - "size": "Tiedostokoko", - "updatedAt": "Päivitetty", - "bitRate": "Bittinopeus", - "discSubtitle": "Levyn tekstitys", - "starred": "Suosikki", - "comment": "Kommentti", - "rating": "Arvostelu", - "quality": "Äänenlaatu", - "bpm": "BPM", - "playDate": "Viimeksi kuunneltu", - "channels": "Kanavat", - "createdAt": "Lisätty", - "grouping": "Ryhmittely", - "mood": "Tunnelma", - "participants": "Lisäosallistujat", - "tags": "Lisätunnisteet", - "mappedTags": "Mäpätyt tunnisteet", - "rawTags": "Raakatunnisteet", - "bitDepth": "Bittisyvyys", - "sampleRate": "Näytteenottotaajuus", - "missing": "Puuttuva", - "libraryName": "Kirjasto", - "composer": "Säveltäjä", - "disc": "Levy %{discNumber}", - "albumGain": "Albumin äänenvoimakkuus", - "trackGain": "Kappaleen äänenvoimakkuus" - }, - "actions": { - "addToQueue": "Lisää jonoon", - "playNow": "Soita nyt", - "addToPlaylist": "Lisää soittolistaan", - "shuffleAll": "Sekoita kaikki", - "download": "Lataa", - "playNext": "Soita seuraavaksi", - "info": "Info", - "showInPlaylist": "Näytä soittolistassa", - "instantMix": "Pikasekoitus" - } + "languageName": "Suomi", + "resources": { + "song": { + "name": "Kappale |||| Kappaleet", + "fields": { + "albumArtist": "Albumin artisti", + "duration": "Kesto", + "trackNumber": "#", + "playCount": "Soittokerrat", + "title": "Kappale", + "artist": "Artisti", + "album": "Albumi", + "path": "Tiedostopolku", + "genre": "Tyylilaji", + "compilation": "Kokoelma", + "year": "Vuosi", + "size": "Tiedostokoko", + "updatedAt": "Päivitetty", + "bitRate": "Bittinopeus", + "discSubtitle": "Levyn tekstitys", + "starred": "Suosikki", + "comment": "Kommentti", + "rating": "Arvostelu", + "quality": "Äänenlaatu", + "bpm": "BPM", + "playDate": "Viimeksi kuunneltu", + "channels": "Kanavat", + "createdAt": "Lisätty" + }, + "actions": { + "addToQueue": "Lisää jonoon", + "playNow": "Soita nyt", + "addToPlaylist": "Lisää soittolistaan", + "shuffleAll": "Sekoita kaikki", + "download": "Lataa", + "playNext": "Soita seuraavaksi", + "info": "Info" + } + }, + "album": { + "name": "Albumi |||| Albumit", + "fields": { + "albumArtist": "Albumin artisti", + "artist": "Artisti", + "duration": "Kesto", + "songCount": "Kappaleita", + "playCount": "Kuuntelukertoja", + "name": "Albumi", + "genre": "Tyylilaji", + "compilation": "Kokoelma", + "year": "Vuosi", + "updatedAt": "Päivitetty", + "comment": "Kommentti", + "rating": "Arvostelu", + "createdAt": "Lisätty", + "size": "Koko" + }, + "actions": { + "playAll": "Soita", + "playNext": "Soita seuraavaksi", + "addToQueue": "Lisää jonoon", + "shuffle": "Sekoita", + "addToPlaylist": "Lisää soittolistaan", + "download": "Lataa", + "info": "Info", + "share": "Jaa" + }, + "lists": { + "all": "Kaikki", + "random": "Satunnainen", + "recentlyAdded": "Viimeksi lisätyt", + "recentlyPlayed": "Viimeksi soitetut", + "mostPlayed": "Useimmiten soitetut", + "starred": "Suosikit", + "topRated": "Tykätyimmät" + } + }, + "artist": { + "name": "Artisti |||| Artistit", + "fields": { + "name": "Artisti", + "albumCount": "Levyjen määrä", + "songCount": "Kappaleiden määrä", + "playCount": "Kuuntelukertoja", + "rating": "Arvostelu", + "genre": "Tyylilaji", + "size": "Koko" + } + }, + "user": { + "name": "Käyttäjä |||| Käyttäjät", + "fields": { + "userName": "Käyttäjätunnus", + "isAdmin": "Pääkäyttäjä", + "lastLoginAt": "Viimeksi kirjautunut", + "updatedAt": "Päivitetty", + "name": "Nimi", + "password": "Salasana", + "createdAt": "Luotu", + "changePassword": "Vaihda salasana?", + "currentPassword": "Nykyinen salasana", + "newPassword": "Uusi salasana", + "token": "Avain" + }, + "helperTexts": { + "name": "Nimen muutos tulee voimaan kun seuraavan kerran kirjaudut sisään" + }, + "notifications": { + "created": "Käyttäjä luotu", + "updated": "Käyttäjä päivitetty", + "deleted": "Käyttäjä poistettu" + }, + "message": { + "listenBrainzToken": "Syötä ListenBrainz avain.", + "clickHereForToken": "Paina tästä saadaksesi avaimen" + } + }, + "player": { + "name": "Soitin |||| Soittimet", + "fields": { + "name": "Nimi", + "transcodingId": "Muunnos", + "maxBitRate": "Suurin bittinopeus", + "client": "Sovellus", + "userName": "Käyttäjänimi", + "lastSeen": "Viimeksi käytetty", + "reportRealPath": "Ilmoita todellinen polku", + "scrobbleEnabled": "Lähetä kuuntelutottumukset ulkoiseen palveluun" + } + }, + "transcoding": { + "name": "Muunnos |||| Muunnokset", + "fields": { + "name": "Nimi", + "targetFormat": "Kohde formaatti", + "defaultBitRate": "Oletus bittinopeus", + "command": "Komento" + } + }, + "playlist": { + "name": "Soittolista |||| Soittolistat", + "fields": { + "name": "Nimi", + "duration": "Kesto", + "ownerName": "Omistaja", + "public": "Julkinen", + "updatedAt": "Päivitetty", + "createdAt": "Luotu", + "songCount": "Kappaleita", + "comment": "Kommentti", + "sync": "Automaattinen tuonti", + "path": "Tuo" + }, + "actions": { + "selectPlaylist": "Valitse soittolista:", + "addNewPlaylist": "Luo \"%{name}\"", + "export": "Vie", + "makePublic": "Tee julkinen", + "makePrivate": "Tee yksityinen" + }, + "message": { + "duplicate_song": "Lisää olemassa oleva kappale", + "song_exist": "Olet lisäämässä soittolistalla jo olevaa kappaletta. Haluatko lisätä saman kappaleen vai ohittaa sen?" + } + }, + "radio": { + "name": "Radio |||| Radiot", + "fields": { + "name": "Nimi", + "streamUrl": "Streamin URL", + "homePageUrl": "Kotisivu", + "updatedAt": "Päivitetty", + "createdAt": "Luotu" + }, + "actions": { + "playNow": "Kuuntele nyt" + } + }, + "share": { + "name": "Jako |||| Jaot", + "fields": { + "username": "Jakanut", + "url": "URL", + "description": "Kuvaus", + "contents": "Sisältö", + "expiresAt": "Vanhenee", + "lastVisitedAt": "Viimeksi vierailtu", + "visitCount": "Vierailuja", + "format": "Formaatti", + "maxBitRate": "Max. bittinopeus", + "updatedAt": "Päivitetty", + "createdAt": "Luotu", + "downloadable": "Salli lataus?" + } + } }, - "album": { - "name": "Albumi |||| Albumit", - "fields": { - "albumArtist": "Albumin artisti", - "artist": "Artisti", - "duration": "Kesto", - "songCount": "Kappaleita", - "playCount": "Kuuntelukertoja", - "name": "Albumi", - "genre": "Tyylilaji", - "compilation": "Kokoelma", - "year": "Vuosi", - "updatedAt": "Päivitetty", - "comment": "Kommentti", - "rating": "Arvostelu", - "createdAt": "Lisätty", - "size": "Koko", - "originalDate": "Alkuperäinen", - "releaseDate": "Julkaistu", - "releases": "Julkaisu |||| Julkaisut", - "released": "Julkaistu", - "recordLabel": "Levy-yhtiö", - "catalogNum": "Luettelonumero", - "releaseType": "Tyyppi", - "grouping": "Ryhmittely", - "media": "Media", - "mood": "Tunnelma", - "date": "Tallennuspäivä", - "missing": "Puuttuva", - "libraryName": "Kirjasto" - }, - "actions": { - "playAll": "Soita", - "playNext": "Soita seuraavaksi", - "addToQueue": "Lisää jonoon", - "shuffle": "Sekoita", - "addToPlaylist": "Lisää soittolistaan", - "download": "Lataa", - "info": "Info", - "share": "Jaa", - "refresh": "Päivitä metatiedot" - }, - "lists": { - "all": "Kaikki", - "random": "Satunnainen", - "recentlyAdded": "Viimeksi lisätyt", - "recentlyPlayed": "Viimeksi soitetut", - "mostPlayed": "Useimmiten soitetut", - "starred": "Suosikit", - "topRated": "Tykätyimmät" - } - }, - "artist": { - "name": "Artisti |||| Artistit", - "fields": { - "name": "Artisti", - "albumCount": "Levyjen määrä", - "songCount": "Kappaleiden määrä", - "playCount": "Kuuntelukertoja", - "rating": "Arvostelu", - "genre": "Tyylilaji", - "size": "Koko", - "role": "Rooli", - "missing": "Puuttuva" - }, - "roles": { - "albumartist": "Albumitaiteilija |||| Albumitaiteilijat", - "artist": "Artisti |||| Artistit", - "composer": "Säveltäjä |||| Säveltäjät", - "conductor": "Kapellimestari |||| Kapellimestarit", - "lyricist": "Sanoittaja |||| Sanoittajat", - "arranger": "Musiikkisovittaja |||| Musiikkisovittajat", - "producer": "Musiikkituottaja |||| Musiikkituottajat", - "director": "Musiikkiohjaaja |||| Musiikkiohjaajat", - "engineer": "Ääniteknikko |||| Ääniteknikot", - "mixer": "Miksaaja |||| Miksaajat", - "remixer": "Remiksaaja |||| Remiksaajat", - "djmixer": "DJ-miksaaja |||| DJ-miksaajat", - "performer": "Esiintyjä |||| Esiintyjät", - "maincredit": "Albumin artisti tai artisti |||| Albumin artistit tai artistit" - }, - "actions": { - "shuffle": "Sekoita", - "radio": "Radio", - "topSongs": "Suosituimmat kappaleet" - } - }, - "user": { - "name": "Käyttäjä |||| Käyttäjät", - "fields": { - "userName": "Käyttäjätunnus", - "isAdmin": "Pääkäyttäjä", - "lastLoginAt": "Viimeksi kirjautunut", - "updatedAt": "Päivitetty", - "name": "Nimi", - "password": "Salasana", - "createdAt": "Luotu", - "changePassword": "Vaihda salasana?", - "currentPassword": "Nykyinen salasana", - "newPassword": "Uusi salasana", - "token": "Avain", - "lastAccessAt": "Viimeisin käyttö", - "libraries": "Kirjastot", - "scrobbleFilter": "Scrobble-suodatin" - }, - "helperTexts": { - "name": "Nimen muutos tulee voimaan kun seuraavan kerran kirjaudut sisään", - "libraries": "Valitse tietyt kirjastot tälle käyttäjälle tai jätä tyhjäksi käyttääksesi oletuskirjastoja", - "scrobbleFilter": "Älykkään soittolistan sääntöihin osumat kappaleet ohitetaan Last.fm-, ListenBrainz- ja skrobbausliitännäisissä. Käyttää samaa JSON-syntaksia ja toimintalogiikkaa kuin älykkäät soittolistat. Esimerkki: {\"all\":[{\"lt\":{\"rating\":4}}]}. Jätä tyhjäksi, jos haluat skrobata kaiken. Paikallisiin toistokertoihin tämä ei vaikuta." - }, - "notifications": { - "created": "Käyttäjä luotu", - "updated": "Käyttäjä päivitetty", - "deleted": "Käyttäjä poistettu" - }, - "message": { - "listenBrainzToken": "Syötä ListenBrainz avain.", - "clickHereForToken": "Paina tästä saadaksesi avaimen", - "selectAllLibraries": "Valitse kaikki kirjastot", - "adminAutoLibraries": "Admin-käyttäjillä on automaattisesti pääsy kaikkiin kirjastoihin" - }, - "validation": { - "librariesRequired": "Vähintään yksi kirjasto on valittava ei-admin käyttäjille", - "invalidScrobbleFilter": "Sääntöjen pitää olla kelvollisia älykkään soittolistan sääntöjä. Rajaus (limit), siirtymä (offset) ja päivitysviive eivät toimi tässä." - } - }, - "player": { - "name": "Soitin |||| Soittimet", - "fields": { - "name": "Nimi", - "transcodingId": "Muunnos", - "maxBitRate": "Suurin bittinopeus", - "client": "Sovellus", - "userName": "Käyttäjänimi", - "lastSeen": "Viimeksi käytetty", - "reportRealPath": "Ilmoita todellinen polku", - "scrobbleEnabled": "Lähetä kuuntelutottumukset ulkoiseen palveluun" - } - }, - "transcoding": { - "name": "Muunnos |||| Muunnokset", - "fields": { - "name": "Nimi", - "targetFormat": "Kohde formaatti", - "defaultBitRate": "Oletus bittinopeus", - "command": "Komento" - }, - "choices": { - "noDefaultBitRate": "Ei mikään" - } - }, - "playlist": { - "name": "Soittolista |||| Soittolistat", - "fields": { - "name": "Nimi", - "duration": "Kesto", - "ownerName": "Omistaja", - "public": "Julkinen", - "updatedAt": "Päivitetty", - "createdAt": "Luotu", - "songCount": "Kappaleita", - "comment": "Kommentti", - "sync": "Automaattinen tuonti", - "path": "Tuo", - "starred": "Suosikki" - }, - "actions": { - "selectPlaylist": "Valitse soittolista:", - "addNewPlaylist": "Luo \"%{name}\"", - "export": "Vie", - "makePublic": "Tee julkinen", - "makePrivate": "Tee yksityinen", - "saveQueue": "Tallenna jono soittolistaan", - "searchOrCreate": "Etsi soittolistoja tai kirjoita luodaksesi uuden...", - "pressEnterToCreate": "Paina Enter luodaksesi uuden soittolistan", - "removeFromSelection": "Poista valinnasta" - }, - "message": { - "duplicate_song": "Lisää olemassa oleva kappale", - "song_exist": "Olet lisäämässä soittolistalla jo olevaa kappaletta. Haluatko lisätä saman kappaleen vai ohittaa sen?", - "noPlaylistsFound": "Soittolistoja ei löytynyt", - "noPlaylists": "Soittolistoja ei ole saatavilla" - } - }, - "radio": { - "name": "Radio |||| Radiot", - "fields": { - "name": "Nimi", - "streamUrl": "Streamin URL", - "homePageUrl": "Kotisivu", - "updatedAt": "Päivitetty", - "createdAt": "Luotu" - }, - "actions": { - "playNow": "Kuuntele nyt" - } - }, - "share": { - "name": "Jako |||| Jaot", - "fields": { - "username": "Jakanut", - "url": "URL", - "description": "Kuvaus", - "contents": "Sisältö", - "expiresAt": "Vanhenee", - "lastVisitedAt": "Viimeksi vierailtu", - "visitCount": "Vierailuja", - "format": "Formaatti", - "maxBitRate": "Max. bittinopeus", - "updatedAt": "Päivitetty", - "createdAt": "Luotu", - "downloadable": "Salli lataus?" - } - }, - "missing": { - "name": "Puuttuva tiedosto|||| Puuttuvat tiedostot", - "fields": { - "path": "Polku", - "size": "Koko", - "updatedAt": "Katosi", - "libraryName": "Kirjasto" - }, - "actions": { - "remove": "Poista", - "remove_all": "Poista kaikki" - }, - "notifications": { - "removed": "Puuttuvat tiedostot poistettu" - }, - "empty": "Ei puuttuvia tiedostoja" - }, - "library": { - "name": "Kirjasto |||| Kirjastot", - "fields": { - "name": "Nimi", - "path": "Polku", - "remotePath": "Etäpolku", - "lastScanAt": "Viimeisin skannaus", - "songCount": "Kappaleet", - "albumCount": "Albumit", - "artistCount": "Artistit", - "totalSongs": "Kappaleet", - "totalAlbums": "Albumit", - "totalArtists": "Artistit", - "totalFolders": "Kansiot", - "totalFiles": "Tiedostot", - "totalMissingFiles": "Puuttuvat tiedostot", - "totalSize": "Kokonaiskoko", - "totalDuration": "Kesto", - "defaultNewUsers": "Oletus uusille käyttäjille", - "createdAt": "Luotu", - "updatedAt": "Päivitetty" - }, - "sections": { - "basic": "Perustiedot", - "statistics": "Tilastot" - }, - "actions": { - "scan": "Skannaa kirjasto", - "manageUsers": "Hallitse käyttäjien pääsyä", - "viewDetails": "Näytä tiedot", - "quickScan": "Nopea skannaus", - "fullScan": "Täysi skannaus" - }, - "notifications": { - "created": "Kirjasto luotu onnistuneesti", - "updated": "Kirjasto päivitetty onnistuneesti", - "deleted": "Kirjasto poistettu onnistuneesti", - "scanStarted": "Kirjaston skannaus aloitettu", - "scanCompleted": "Kirjaston skannaus valmistunut", - "quickScanStarted": "Nopea skannaus aloitettu", - "fullScanStarted": "Täysi skannaus aloitettu", - "scanError": "Virhe skannauksen käynnistyksessä. Tarkista lokit" - }, - "validation": { - "nameRequired": "Kirjaston nimi vaaditaan", - "pathRequired": "Kirjaston polku vaaditaan", - "pathNotDirectory": "Kirjaston polun tulee olla hakemisto", - "pathNotFound": "Kirjaston polkua ei löytynyt", - "pathNotAccessible": "Kirjaston polku ei ole käytettävissä", - "pathInvalid": "Virheellinen kirjaston polku" - }, - "messages": { - "deleteConfirm": "Haluatko varmasti poistaa tämän kirjaston? Kaikki siihen liittyvät tiedot ja käyttäjien pääsy poistetaan.", - "scanInProgress": "Skannaus käynnissä...", - "noLibrariesAssigned": "Tälle käyttäjälle ei ole määritetty kirjastoja" - } - }, - "plugin": { - "name": "Liitännäinen |||| Liitännäiset", - "fields": { - "id": "ID", - "name": "Nimi", - "description": "Kuvaus", - "version": "Versio", - "author": "Tekijä", - "website": "Verkkosivusto", - "permissions": "Oikeudet", - "enabled": "Käytössä", - "status": "Tila", - "path": "Polku", - "lastError": "Virhe", - "hasError": "Virhe", - "updatedAt": "Päivitetty", - "createdAt": "Asennettu", - "configKey": "Avain", - "configValue": "Arvo", - "allUsers": "Salli kaikki käyttäjät", - "selectedUsers": "Valitut käyttäjät", - "allLibraries": "Salli kaikki kirjastot", - "selectedLibraries": "Valitut kirjastot", - "allowWriteAccess": "Salli kirjoitusoikeus" - }, - "sections": { - "status": "Tila", - "info": "Lisäosan tiedot", - "configuration": "Määritykset", - "manifest": "Luettelo", - "usersPermission": "Käyttäjäoikeudet", - "libraryPermission": "Kirjaston oikeudet" - }, - "status": { - "enabled": "Käytössä", - "disabled": "Ei käytössä" - }, - "actions": { - "enable": "Ota käyttöön", - "disable": "Poista käytöstä", - "disabledDueToError": "Korjaa virhe ennen käyttöönottoa", - "disabledUsersRequired": "Valitse käyttäjät ennen käyttöönottoa", - "disabledLibrariesRequired": "Valitse kirjastot ennen käyttöönottoa", - "addConfig": "Lisää määritykset", - "rescan": "Skannaa uudelleen" - }, - "notifications": { - "enabled": "Lisäosa käytössä", - "disabled": "Lisäosa ei käytössä", - "updated": "Lisäosa päivitetty", - "error": "Virhe lisäosaa päivitettäessä" - }, - "validation": { - "invalidJson": "Määrityksen on oltava kelvollinen JSON" - }, - "messages": { - "configHelp": "Määritä lisäosa avain-arvo-parien avulla. Jätä tyhjäksi, jos lisäosa ei vaadi määrityksiä.", - "clickPermissions": "Napsauta käyttöoikeutta saadaksesi lisätietoja", - "noConfig": "Ei määritettyjä asetuksia", - "allUsersHelp": "Kun tämä on käytössä, laajennuksella on pääsy kaikkiin käyttäjiin, myös tulevaisuudessa luotaviin.", - "noUsers": "Ei valittuja käyttäjiä", - "permissionReason": "Syy", - "usersRequired": "Tämä laajennus vaatii pääsyn käyttäjätietoihin. Valitse käyttäjät, joihin laajennus voi päästä, tai ota käyttöön 'Salli kaikki käyttäjät'.", - "allLibrariesHelp": "Kun tämä on käytössä, laajennuksella on pääsy kaikkiin kirjastoihin, myös tulevaisuudessa luotaviin.", - "noLibraries": "Ei valittuja kirjastoja", - "librariesRequired": "Tämä laajennus vaatii pääsyn kirjastotietoihin. Valitse, mihin kirjastoihin laajennus voi käyttää, tai ota käyttöön 'Salli kaikki kirjastot'.", - "requiredHosts": "Vaaditut palvelimet", - "configValidationError": "Määrityksen validointi epäonnistui:", - "schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen.", - "allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus.", - "idHelp": "Lisäosan ID, joka johdetaan sen tiedostonimestä. Käytä sitä viitatessasi tähän lisäosaan määritelmäasetuksissa, kuten agenteissa." - }, - "placeholders": { - "configKey": "avain", - "configValue": "arvo" - } - } - }, - "ra": { - "auth": { - "welcome1": "Kiitos, että asensit Navidromen!", - "welcome2": "Aloittaaksesi luo admin-käyttäjä", - "confirmPassword": "Vahvista salasana", - "buttonCreateAdmin": "Luo Admin", - "auth_check_error": "Kirjaudu sisään jatkaaksesi", - "user_menu": "Profiili", - "username": "Käyttäjänimi", - "password": "Salasana", - "sign_in": "Kirjaudu", - "sign_in_error": "Kirjautuminen epäonnistui. Yritä uudelleen", - "logout": "Kirjaudu ulos", - "insightsCollectionNote": "Navidrome kerää anonyymejä käyttötietoja auttaakseen parantamaan\nprojektia. Paina [tästä] saadaksesi lisätietoa\nja halutessasi kieltäytyä" - }, - "validation": { - "invalidChars": "Käytä vain kirjaimia ja numeroita", - "passwordDoesNotMatch": "Salasanat eivät täsmää", - "required": "Pakollinen", - "minLength": "Pitää vähintään olla %{min} merkkiä", - "maxLength": "Saa olla enintään %{max} merkkiä", - "minValue": "Pitää olla vähintään %{min}", - "maxValue": "Saa olla enentään %{max}", - "number": "Pitää olla numero", - "email": "Pitää olla oikea sähköpostiosoite", - "oneOf": "Pitää olla joku näistä: %{options}", - "regex": "Pitää olla määrätyssä muodossa (regexp): %{pattern}", - "unique": "Pitää olla yksilöllinen", - "url": "Virheellinen URL" - }, - "action": { - "add_filter": "Lisää suodatin", - "add": "Lisää", - "back": "Palaa", - "bulk_actions": "1 kohde valittu |||| %{smart_count} kohdetta valittu", - "cancel": "Peru", - "clear_input_value": "Tyhjennä", - "clone": "Kopio", - "confirm": "Vahvista", - "create": "Luo", - "delete": "Poista", - "edit": "Muokkaa", - "export": "Vie", - "list": "Lista", - "refresh": "Päivitä", - "remove_filter": "Poista suodatin", - "remove": "Poista", - "save": "Tallenna", - "search": "Etsi", - "show": "Näytä", - "sort": "Järjestä", - "undo": "Peru", - "expand": "Laajenna", - "close": "Sulje", - "open_menu": "Avaa valikko", - "close_menu": "Sulje valikko", - "unselect": "Poista valinta", - "skip": "Ohita", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Jako", - "download": "Lataa" - }, - "boolean": { - "true": "Kyllä", - "false": "Ei" - }, - "page": { - "create": "Luo %{name}", - "dashboard": "Dashboard", - "edit": "%{name} #%{id}", - "error": "Jotainen meni pieleen", - "list": "%{name}", - "loading": "Ladataan", - "not_found": "Ei löytynyt", - "show": "%{name} #%{id}", - "empty": "%{name} on tyhjä", - "invite": "Haluatko luoda uuden?" - }, - "input": { - "file": { - "upload_several": "Pudota tiedostoja tai valitse useampi ladataksesi ne.", - "upload_single": "Pudota tiedosto tai valitse tiedosto ladataksesi se." - }, - "image": { - "upload_several": "Pudota kuvia tai valitse useampi ladataksesi ne.", - "upload_single": "Pudota kuva tai valitse kuva ladataksesi se." - }, - "references": { - "all_missing": "Viitetietoja ei löytynyt.", - "many_missing": "Ainakin yksi viitetieto näyttäisi puuttuvan.", - "single_missing": "Viitetietoa ei ole enää saatavilla." - }, - "password": { - "toggle_visible": "Piilota salasana", - "toggle_hidden": "Näytä salasana" - } + "ra": { + "auth": { + "welcome1": "Kiitos, että asensit Navidromen!", + "welcome2": "Aloittaaksesi luo admin-käyttäjä", + "confirmPassword": "Vahvista salasana", + "buttonCreateAdmin": "Luo Admin", + "auth_check_error": "Kirjaudu sisään jatkaaksesi", + "user_menu": "Profiili", + "username": "Käyttäjänimi", + "password": "Salasana", + "sign_in": "Kirjaudu", + "sign_in_error": "Autentikointi epäonnistui. Yritä uudelleen", + "logout": "Kirjaudu ulos" + }, + "validation": { + "invalidChars": "Käytä vain kirjaimia ja numeroita", + "passwordDoesNotMatch": "Salasanat eivät täsmää", + "required": "Pakollinen", + "minLength": "Pitää vähintään olla %{min} merkkiä", + "maxLength": "Saa olla enintään %{max} merkkiä", + "minValue": "pitää olla vähintään %{min}", + "maxValue": "Saa olla enentään %{max}", + "number": "Pitää olla numero", + "email": "Pitää olla oikea sähköpostiosoite", + "oneOf": "Pitää olla joku näistä: %{options}", + "regex": "Pitää olla määrätyssä muodossa (regexp): %{pattern}", + "unique": "Pitää olla yksilöllinen", + "url": "Virheellinen URL" + }, + "action": { + "add_filter": "Lisää suodatin", + "add": "Lisää", + "back": "Palaa", + "bulk_actions": "1 kohde valittu |||| %{smart_count} kohdetta valittu", + "cancel": "Peru", + "clear_input_value": "Tyhjennä", + "clone": "Kopio", + "confirm": "Vahvista", + "create": "Luo", + "delete": "Poista", + "edit": "Muokkaa", + "export": "Vie", + "list": "Lista", + "refresh": "Päivitä", + "remove_filter": "Poista suodatin", + "remove": "Poista", + "save": "Tallenna", + "search": "Etsi", + "show": "Näytä", + "sort": "Järjestä", + "undo": "Peru", + "expand": "Laajenna", + "close": "Sulje", + "open_menu": "Avaa valikko", + "close_menu": "Sulje valikko", + "unselect": "Poista valinta", + "skip": "Ohita", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Jako", + "download": "Lataa" + }, + "boolean": { + "true": "Kyllä", + "false": "Ei" + }, + "page": { + "create": "Luo %{name}", + "dashboard": "Dashboard", + "edit": "%{name} #%{id}", + "error": "Jotainen meni pieleen", + "list": "%{name}", + "loading": "Ladataan", + "not_found": "Ei löytynyt", + "show": "%{name} #%{id}", + "empty": "%{name} on tyhjä", + "invite": "Haluatko luoda uuden?" + }, + "input": { + "file": { + "upload_several": "Pudota tiedostoja tai valitse useampi ladataksesi ne.", + "upload_single": "Pudota tiedosto tai valitse tiedosto ladataksesi se." + }, + "image": { + "upload_several": "Pudota kuvia tai valitse useampi ladataksesi ne.", + "upload_single": "Pudota kuva tai valitse kuva ladataksesi se." + }, + "references": { + "all_missing": "Viitetietoja ei löytynyt.", + "many_missing": "Ainakin yksi viitetieto näyttäisi puuttuvan.", + "single_missing": "Viitetietoa ei ole enää saatavilla." + }, + "password": { + "toggle_visible": "Piilota salasana", + "toggle_hidden": "Näytä salasana" + } + }, + "message": { + "about": "Tietoa", + "are_you_sure": "Oletko varma?", + "bulk_delete_content": "Oletko varma, että haluat poistaa %{name}? |||| Oletko varma, että haluat poistaa %{smart_count}?", + "bulk_delete_title": "Poista %{name} |||| Poista %{smart_count} %{name}", + "delete_content": "Haluatko varmasti poistaa tämän?", + "delete_title": "Poista %{name} #%{id}", + "details": "Yksityiskohdat", + "error": "Tapahtui virhe, eikä pyyntöäsi voitu suorittaa.", + "invalid_form": "Lomakkeessa on virhe. Tarkista virheet", + "loading": "Sivu latautuu, odota hetki", + "no": "Ei", + "not_found": "Joko kirjoitit väärän osoitteen tai seuraamasi linkki on rikki.", + "yes": "Kyllä", + "unsaved_changes": "Joitakin muutoksiasi ei tallennettu. Haluatko varmasti jättää ne huomiotta?" + }, + "navigation": { + "no_results": "Ei tuloksia", + "no_more_results": "Sivunumero %{page} on rajojen ulkopuolella. Kokeile edellinen sivu.", + "page_out_of_boundaries": "Sivunumero %{page} on rajojen ulkopuolella", + "page_out_from_end": "Viimeinen sivu, ei voi edetä", + "page_out_from_begin": "Ensimmäinen sivu, ei voi palata", + "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", + "page_rows_per_page": "Kohteita sivulla:", + "next": "Seuraava", + "prev": "Edellinen", + "skip_nav": "Siirry sisältöön" + }, + "notification": { + "updated": "Elementti päivitetty |||| %{smart_count} elementtiä päivitetty", + "created": "Elementti luotu", + "deleted": "Elementti poistettu |||| %{smart_count} elementtiä poistettu", + "bad_item": "Virheellinen elementti", + "item_doesnt_exist": "Elementtiä ei ole", + "http_error": "Palvelimen yhteysvirhe", + "data_provider_error": "Tapahtui virhe. Katso lisätietoja konsolista.", + "i18n_error": "Käännöksiä valitulle kielelle ei voitu ladata.", + "canceled": "Toiminto peruttu", + "logged_out": "Istuntosi on päättynyt, yhdistä uudelleen.", + "new_version": "Päivitys saatavilla! Päivitä tämä ikkuna." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Näytettävät sarakkeet", + "layout": "Asettelu", + "grid": "Ruudukko", + "table": "Taulukko" + } }, "message": { - "about": "Tietoa", - "are_you_sure": "Oletko varma?", - "bulk_delete_content": "Oletko varma, että haluat poistaa %{name}? |||| Oletko varma, että haluat poistaa %{smart_count}?", - "bulk_delete_title": "Poista %{name} |||| Poista %{smart_count} %{name}", - "delete_content": "Haluatko varmasti poistaa tämän?", - "delete_title": "Poista %{name} #%{id}", - "details": "Yksityiskohdat", - "error": "Tapahtui virhe, eikä pyyntöäsi voitu suorittaa.", - "invalid_form": "Lomakkeessa on virhe. Tarkista virheet", - "loading": "Sivu latautuu, odota hetki", - "no": "Ei", - "not_found": "Joko kirjoitit väärän osoitteen tai seuraamasi linkki on rikki.", - "yes": "Kyllä", - "unsaved_changes": "Joitakin muutoksiasi ei tallennettu. Haluatko varmasti jättää ne huomiotta?" - }, - "navigation": { - "no_results": "Ei tuloksia", - "no_more_results": "Sivunumeroa %{page} ei löydy. Yritä edellistä sivua.", - "page_out_of_boundaries": "Sivunumero %{page} on rajojen ulkopuolella", - "page_out_from_end": "Viimeinen sivu, ei voi edetä", - "page_out_from_begin": "Ensimmäinen sivu, ei voi palata", - "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", - "page_rows_per_page": "Kohteita sivulla:", - "next": "Seuraava", - "prev": "Edellinen", - "skip_nav": "Siirry sisältöön" - }, - "notification": { - "updated": "Elementti päivitetty |||| %{smart_count} elementtiä päivitetty", - "created": "Elementti luotu", - "deleted": "Elementti poistettu |||| %{smart_count} elementtiä poistettu", - "bad_item": "Virheellinen elementti", - "item_doesnt_exist": "Elementtiä ei ole", - "http_error": "Palvelimen yhteysvirhe", - "data_provider_error": "Tapahtui virhe. Katso lisätietoja konsolista.", - "i18n_error": "Käännöksiä valitulle kielelle ei voitu ladata.", - "canceled": "Toiminto peruttu", - "logged_out": "Istuntosi on päättynyt, yhdistä uudelleen.", - "new_version": "Päivitys saatavilla! Päivitä tämä ikkuna." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Näytettävät sarakkeet", - "layout": "Asettelu", - "grid": "Ruudukko", - "table": "Taulukko" - } - }, - "message": { - "note": "HUOM", - "transcodingDisabled": "Muunnoksen muokkaaminen verkkoliittymän kautta on poistettu tietoturvasyistä käytöstä. Mikäli haluat muuttaa muunnoksen asetuksia käynnistä palvelu uudelleen %{config} asetuksella.", - "transcodingEnabled": "Navidrome käyttää parhaillaan %{config} asetuksia. Tämä mahdollistaa järjestelmän komentojen suorittamisen muunnoksen asetuksista verkkokäyttöliittymän kautta. Tietoturvasyistä suosittelemme sen käyttöä vain kun teet muutoksia muunnoksen asetuksiin.", - "songsAddedToPlaylist": "Lisättiin 1 kappale soittolistalle |||| Lisättiin %{smart_count} kappaletta soittolistalle", - "noPlaylistsAvailable": "Soittolistaa ei saatavilla", - "delete_user_title": "Poista käyttäjä '%{name}'", - "delete_user_content": "Haluatko varmasti poistaa tämän käyttäjän ja kaikki hänen tiedot (mukaan lukien soittolistat ja asetukset)?", - "notifications_blocked": "Olet estänyt tämän sivuston ilmoitukset selaimesi asetuksissa", - "notifications_not_available": "Tämä selain ei tue työpöytäilmoituksia tai et käytä Navidromea https-yhteyden kautta", - "lastfmLinkSuccess": "Last.fm onnistuneesti linkitetty ja kuuntelutottumus otettu käyttöön", - "lastfmLinkFailure": "Last.fm linkitys epäonnistui", - "lastfmUnlinkSuccess": "Last.fm linkitys poistettu ja kuuntelutottumus poistettu käytöstä", - "lastfmUnlinkFailure": "Last.fm linkityksen poisto epäonnistui", - "openIn": { - "lastfm": "Avaa Last.fm:ssä", - "musicbrainz": "Avaa MusicBrainz:ssä" - }, - "lastfmLink": "Lue lisää...", - "listenBrainzLinkSuccess": "ListenBrainz linkitetty onnistuneesti ja käyttäjän %{user} kuuntelutottumus otettu käyttöön", - "listenBrainzLinkFailure": "Ei voitu linkittää ListenBrainz palveluun: %{error}", - "listenBrainzUnlinkSuccess": "Linkitys ListenBrainz poistettu ja kuuntelutottumus poistettu käytöstä", - "listenBrainzUnlinkFailure": "ListenBrainz linkitystä ei voitu poistaa", - "downloadOriginalFormat": "Lataa alkuperäisessä formaatissa", - "shareOriginalFormat": "Jaa alkuperäisessä formaatissa", - "shareDialogTitle": "Jaa %{resource} '%{name}'", - "shareBatchDialogTitle": "Jako 1 %{resource} |||| Jako %{smart_count} %{resource}", - "shareSuccess": "Osoite kopioitu leikepöydälle: %{url}", - "shareFailure": "Virhe kopioitaessa %{url} leikepöydälle", - "downloadDialogTitle": "Lataa %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Kopio leikepöydälle: Ctrl+C, Enter", - "remove_missing_title": "Poista puuttuvat tiedostot", - "remove_missing_content": "Oletko varma, että haluat poistaa valitut puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien niiden soittojen määrät ja arvostelut.", - "remove_all_missing_title": "Poista kaikki puuttuvat tiedostot", - "remove_all_missing_content": "Haluatko varmasti poistaa kaikki puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien toistomäärät ja arvostelut.", - "noSimilarSongsFound": "Samankaltaisia kappaleita ei löytynyt", - "noTopSongsFound": "Suosituimpia kappaleita ei löytynyt", - "startingInstantMix": "Ladataan Pikasekoitus...", - "uploadCover": "Lataa kansikuva", - "removeCover": "Poista kansikuva", - "coverUploaded": "Kansikuva päivitetty", - "coverRemoved": "Kansikuva poistettu", - "coverUploadError": "Virhe ladattaessa kansikuvaa", - "coverRemoveError": "Virhe poistettaessa kansikuvaa", - "metadataRefreshStarted": "Metatietoja päivitetään taustalla", - "quickConnectApproved": "", - "quickConnectInvalidCode": "", - "quickConnectError": "" - }, - "menu": { - "library": "Kirjasto", - "settings": "Asetukset", - "version": "Versio", - "theme": "Teema", - "personal": { - "name": "Omat asetukset", - "options": { - "theme": "Teema", - "language": "Kieli", - "defaultView": "Oletusnäkymä", - "desktop_notifications": "Työpöytäilmoitukset", - "lastfmScrobbling": "Kuuntelutottumuksen lähetys Last.fm-palveluun", - "listenBrainzScrobbling": "Kuuntelutottumuksen lähetys ListenBrainz-palveluun", - "replaygain": "ReplayGain -tila", - "preAmp": "ReplayGain esivahvistus (dB)", - "gain": { - "none": "Pois käytöstä", - "album": "Käytä albumin äänen normalisointia", - "track": "Käytä kappaleen äänen normalisointia" + "note": "HUOM", + "transcodingDisabled": "Muunnoksen muokkaaminen verkkoliittymän kautta on poistettu tietoturvasyistä käytöstä. Mikäli haluat muuttaa muunnoksen asetuksia käynnistä palvelu uudelleen %{config} asetuksella.", + "transcodingEnabled": "Navidrome käyttää parhaillaan %{config} asetuksia. Tämä mahdollistaa järjestelmän komentojen suorittamisen muunnoksen asetuksista verkkokäyttöliittymän kautta. Tietoturvasyistä suosittelemme sen käyttöä vain kun teet muutoksia muunnoksen asetuksiin.", + "songsAddedToPlaylist": "Lisättiin 1 kappale soittolistalle |||| Lisättiin %{smart_count} kappaletta soittolistalle", + "noPlaylistsAvailable": "Soittolistaa ei saatavilla", + "delete_user_title": "Poista käyttäjä '%{name}'", + "delete_user_content": "Haluatko varmasti poistaa tämän käyttäjän ja kaikki hänen tiedot (mukaan lukien soittolistat ja asetukset)?", + "notifications_blocked": "Olet estänyt tämän sivuston ilmoitukset selaimesi asetuksissa", + "notifications_not_available": "Tämä selain ei tue työpöytäilmoituksia tai et käytä Navidromea https-yhteyden kautta", + "lastfmLinkSuccess": "Last.fm onnistuneesti linkitetty ja kuuntelutottumus otettu käyttöön", + "lastfmLinkFailure": "Last.fm linkitys epäonnistui", + "lastfmUnlinkSuccess": "Last.fm linkitys poistettu ja kuuntelutottumus poistettu käytöstä", + "lastfmUnlinkFailure": "Last.fm linkityksen poisto epäonnistui", + "openIn": { + "lastfm": "Avaa Last.fm:ssä", + "musicbrainz": "Avaa MusicBrainz:ssä" }, - "lastfmNotConfigured": "Last.fm API-avainta ei ole määritetty" - } + "lastfmLink": "Lue lisää...", + "listenBrainzLinkSuccess": "ListenBrainz linkitetty onnistuneesti ja käyttäjän %{user} kuuntelutottumus otettu käyttöön", + "listenBrainzLinkFailure": "Ei voitu linkittää ListenBrainz palveluun: %{error}", + "listenBrainzUnlinkSuccess": "Linkitys ListenBrainz poistettu ja kuuntelutottumus poistettu käytöstä", + "listenBrainzUnlinkFailure": "ListenBrainz linkitystä ei voitu poistaa", + "downloadOriginalFormat": "Lataa alkuperäisessä formaatissa", + "shareOriginalFormat": "Jaa alkuperäisessä formaatissa", + "shareDialogTitle": "Jaa %{resource} '%{name}'", + "shareBatchDialogTitle": "Jako 1 %{resource} |||| Jako %{smart_count} %{resource}", + "shareSuccess": "Osoite kopioitu leikepöydälle: %{url}", + "shareFailure": "Virhe kopioitaessa %{url} leikepöydälle", + "downloadDialogTitle": "Lataa %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Kopio leikepöydälle: Ctrl+C, Enter" }, - "albumList": "Albumit", - "about": "Tietoa", - "playlists": "Soittolista", - "sharedPlaylists": "Jaettu soittolista", - "librarySelector": { - "allLibraries": "Kaikki kirjastot (%{count})", - "multipleLibraries": "%{selected} / %{total} kirjastoa", - "selectLibraries": "Valitse kirjastot", - "none": "Ei mitään" + "menu": { + "library": "Kirjasto", + "settings": "Asetukset", + "version": "Versio", + "theme": "Teema", + "personal": { + "name": "Omat asetukset", + "options": { + "theme": "Teema", + "language": "Kieli", + "defaultView": "Oletusnäkymä", + "desktop_notifications": "Työpöytäilmoitukset", + "lastfmScrobbling": "Kuuntelutottumuksen lähetys Last.fm-palveluun", + "listenBrainzScrobbling": "Kuuntelutottumuksen lähetys ListenBrainz-palveluun", + "replaygain": "RepleyGain -tila", + "preAmp": "ReplayGain esivahvistus (dB)", + "gain": { + "none": "Pois käytöstä", + "album": "Käytä albumin äänen normalisointia", + "track": "Käytä kappaleen äänen normalisointia" + } + } + }, + "albumList": "Albumit", + "about": "Tietoa", + "playlists": "Soittolista", + "sharedPlaylists": "Jaettu soittolista" }, - "onlyFavourites": "Näytä vain suosikit", - "quickConnect": { - "name": "", - "code": "", - "help": "", - "confirm": "", - "continue": "", - "approve": "" - } - }, - "player": { - "playListsText": "Jono", - "openText": "Avaa", - "closeText": "Sulje", - "notContentText": "Ei musiikkia", - "clickToPlayText": "Soita", - "clickToPauseText": "Tauko", - "nextTrackText": "Seuraava kappale", - "previousTrackText": "Edellinen kappale", - "reloadText": "Päivitä", - "volumeText": "Äänenvoimakkuus", - "toggleLyricText": "Näytä/piilota sanat", - "toggleMiniModeText": "Minimoi", - "destroyText": "Poista", - "downloadText": "Lataa", - "removeAudioListsText": "Tyhjennä jono", - "clickToDeleteText": "Paina poistaaksesi %{name}", - "emptyLyricText": "Ei sanoja", - "playModeText": { - "order": "Järjestyksessä", - "orderLoop": "Toista", - "singleLoop": "Toista yksi", - "shufflePlay": "Sekoita" - } - }, - "about": { - "links": { - "homepage": "Kotisivu", - "source": "Lähdekoodi", - "featureRequests": "Ominaisuuspyynnöt", - "lastInsightsCollection": "Viimeisin tietojenkeruu", - "insights": { - "disabled": "Ei käytössä", - "waiting": "Odottaa" - } + "player": { + "playListsText": "Jono", + "openText": "Avaa", + "closeText": "Sulje", + "notContentText": "Ei musiikkia", + "clickToPlayText": "Soita", + "clickToPauseText": "Tauko", + "nextTrackText": "Seuraava kappale", + "previousTrackText": "Edellinen kappale", + "reloadText": "Päivitä", + "volumeText": "Äänenvoimakkuus", + "toggleLyricText": "Toggle lyric", + "toggleMiniModeText": "Minimoi", + "destroyText": "Poista", + "downloadText": "Lataa", + "removeAudioListsText": "Tyhjennä jono", + "clickToDeleteText": "Paina poistaaksesi %{name}", + "emptyLyricText": "Ei sanoja", + "playModeText": { + "order": "Järjestyksessä", + "orderLoop": "Toista", + "singleLoop": "Toista yksi", + "shufflePlay": "Sekoita" + } }, - "tabs": { - "about": "Tietoja", - "config": "Määritykset" + "about": { + "links": { + "homepage": "Kotisivu", + "source": "Lähdekoodi", + "featureRequests": "Ominaisuuspyynnöt" + } }, - "config": { - "configName": "Konfiguraation nimi", - "environmentVariable": "Ympäristömuuttuja", - "currentValue": "Nykyinen arvo", - "configurationFile": "Määritystiedosto", - "exportToml": "Vie määritys (TOML)", - "exportSuccess": "Määritykset viety leikepöydälle TOML-muodossa", - "exportFailed": "Määritysten kopiointi epäonnistui", - "devFlagsHeader": "Kehitysliput (voivat muuttua/poistua)", - "devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa", - "downloadToml": "Lataa määritykset (TOML)" + "activity": { + "title": "Palvelun tila", + "totalScanned": "Tarkistettuja hakemistoja", + "quickScan": "Nopea tarkistus", + "fullScan": "Täysi tarkistus", + "serverUptime": "Palvelun käyttöaika", + "serverDown": "SAMMUTETTU" + }, + "help": { + "title": "Navidrome pikapainikkeet", + "hotkeys": { + "show_help": "Näytä tämä apuvalikko", + "toggle_menu": "Menuvalikko päälle ja pois", + "toggle_play": "Toista / Tauko", + "prev_song": "Esellinen kappale", + "next_song": "Seuraava kappale", + "vol_up": "Kovemmalle", + "vol_down": "Hiljemmalle", + "toggle_love": "Lisää kappale suosikkeihin", + "current_song": "Siirry nykyiseen kappaleeseen" + } } - }, - "activity": { - "title": "Palvelun tila", - "totalScanned": "Tarkistettuja hakemistoja", - "quickScan": "Nopea tarkistus", - "fullScan": "Täysi tarkistus", - "serverUptime": "Palvelun käyttöaika", - "serverDown": "SAMMUTETTU", - "scanType": "Tyyppi", - "status": "Skannausvirhe", - "elapsedTime": "Kulunut aika", - "selectiveScan": "Valikoiva" - }, - "help": { - "title": "Navidrome pikapainikkeet", - "hotkeys": { - "show_help": "Näytä tämä apuvalikko", - "toggle_menu": "Menuvalikko päälle ja pois", - "toggle_play": "Toista / Tauko", - "prev_song": "Edellinen kappale", - "next_song": "Seuraava kappale", - "vol_up": "Kovemmalle", - "vol_down": "Hiljemmalle", - "toggle_love": "Lisää kappale suosikkeihin", - "current_song": "Siirry nykyiseen kappaleeseen" - } - }, - "nowPlaying": { - "title": "Nyt soi", - "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 dae9f47e7..6508248c9 100644 --- a/resources/i18n/fr.json +++ b/resources/i18n/fr.json @@ -1,721 +1,456 @@ { - "languageName": "Français", - "resources": { - "song": { - "name": "Titre |||| Titres", - "fields": { - "albumArtist": "Artiste", - "duration": "Durée", - "trackNumber": "#", - "playCount": "Nombre d'écoutes", - "title": "Titre", - "artist": "Artiste", - "album": "Album", - "path": "Chemin d'accès", - "genre": "Genre", - "compilation": "Compilation", - "year": "Année", - "size": "Taille", - "updatedAt": "Mise à jour", - "bitRate": "Bitrate", - "discSubtitle": "Sous-titre du disque", - "starred": "Favoris", - "comment": "Commentaire", - "rating": "Classement", - "quality": "Qualité", - "bpm": "BPM", - "playDate": "Derniers joués", - "channels": "Canaux", - "createdAt": "Date d'ajout", - "grouping": "Regroupement", - "mood": "Humeur", - "participants": "Participants supplémentaires", - "tags": "Étiquettes supplémentaires", - "mappedTags": "Étiquettes correspondantes", - "rawTags": "Étiquettes brutes", - "bitDepth": "Profondeur de bits", - "sampleRate": "Fréquence d'échantillonnage", - "missing": "Manquant", - "libraryName": "Bibliothèque", - "composer": "Compositeur·e", - "disc": "Disque %{discNumber}" - }, - "actions": { - "addToQueue": "Ajouter à la file", - "playNow": "Lire", - "addToPlaylist": "Ajouter à la playlist", - "shuffleAll": "Tout mélanger", - "download": "Télécharger", - "playNext": "Jouer ensuite", - "info": "Plus d'informations", - "showInPlaylist": "Montrer dans la playlist", - "instantMix": "Mix instantanné" - } + "languageName": "Français", + "resources": { + "song": { + "name": "Piste |||| Pistes", + "fields": { + "albumArtist": "Artiste", + "duration": "Durée", + "trackNumber": "#", + "playCount": "Nombre d'écoutes", + "title": "Titre", + "artist": "Artiste", + "album": "Album", + "path": "Chemin", + "genre": "Genre", + "compilation": "Compilation", + "year": "Année", + "size": "Taille", + "updatedAt": "Mise à jour", + "bitRate": "Bitrate", + "discSubtitle": "Sous-titre du disque", + "starred": "Favoris", + "comment": "Commentaire", + "rating": "Classement", + "quality": "Qualité", + "bpm": "BPM", + "playDate": "Derniers joués", + "channels": "Canaux", + "createdAt": "" + }, + "actions": { + "addToQueue": "Ajouter à la file", + "playNow": "Lire", + "addToPlaylist": "Ajouter à la playlist", + "shuffleAll": "Tout mélanger", + "download": "Télécharger", + "playNext": "Jouer ensuite", + "info": "Plus d'informations" + } + }, + "album": { + "name": "Album |||| Albums", + "fields": { + "albumArtist": "Artiste", + "artist": "Artiste", + "duration": "Durée", + "songCount": "Nombre de pistes", + "playCount": "Nombre d'écoutes", + "name": "Nom", + "genre": "Genre", + "compilation": "Compilation", + "year": "Année", + "updatedAt": "Mis à jour le", + "comment": "Commentaire", + "rating": "Classement", + "createdAt": "", + "size": "" + }, + "actions": { + "playAll": "Lire", + "playNext": "Lire ensuite", + "addToQueue": "Ajouter à la file", + "shuffle": "Mélanger", + "addToPlaylist": "Ajouter à la playlist", + "download": "Télécharger", + "info": "Plus d'informations", + "share": "" + }, + "lists": { + "all": "Tous", + "random": "Aléatoire", + "recentlyAdded": "Récemment ajoutés", + "recentlyPlayed": "Récemment joués", + "mostPlayed": "Plus joués", + "starred": "Favoris", + "topRated": "Les mieux classés" + } + }, + "artist": { + "name": "Artiste |||| Artistes", + "fields": { + "name": "Nom", + "albumCount": "Nombre d'albums", + "songCount": "Nombre de pistes", + "playCount": "Lectures", + "rating": "Classement", + "genre": "Genre", + "size": "" + } + }, + "user": { + "name": "Utilisateur |||| Utilisateurs", + "fields": { + "userName": "Nom d'utilisateur", + "isAdmin": "Administrateur", + "lastLoginAt": "Dernière connexion", + "updatedAt": "Dernière mise à jour", + "name": "Nom", + "password": "Mot de passe", + "createdAt": "Créé le", + "changePassword": "Changer le mot de passe ?", + "currentPassword": "Mot de passe actuel", + "newPassword": "Nouveau mot de passe", + "token": "Token" + }, + "helperTexts": { + "name": "Les changements liés à votre nom ne seront reflétés qu'à la prochaine connexion" + }, + "notifications": { + "created": "Utilisateur créé", + "updated": "Utilisateur mis à jour", + "deleted": "Utilisateur supprimé" + }, + "message": { + "listenBrainzToken": "Entrez votre token ListenBrainz.", + "clickHereForToken": "Cliquez ici pour recevoir votre token" + } + }, + "player": { + "name": "Lecteur |||| Lecteurs", + "fields": { + "name": "Nom", + "transcodingId": "Transcodage", + "maxBitRate": "Bitrate maximum", + "client": "Client", + "userName": "Nom d'utilisateur", + "lastSeen": "Vu pour la dernière fois", + "reportRealPath": "Rapporter le chemin absolu", + "scrobbleEnabled": "Scrobbler vers des services externes" + } + }, + "transcoding": { + "name": "Conversion |||| Conversions", + "fields": { + "name": "Nom", + "targetFormat": "Format", + "defaultBitRate": "Bitrate par défaut", + "command": "Commande" + } + }, + "playlist": { + "name": "Playlist |||| Playlists", + "fields": { + "name": "Nom", + "duration": "Durée", + "ownerName": "Propriétaire", + "public": "Public", + "updatedAt": "Mise à jour le", + "createdAt": "Créé le", + "songCount": "Titres", + "comment": "Commentaire", + "sync": "Import automatique", + "path": "Importer depuis" + }, + "actions": { + "selectPlaylist": "Ajouter les pistes à la playlist", + "addNewPlaylist": "Créer \"%{name}\"", + "export": "Exporter", + "makePublic": "Rendre publique", + "makePrivate": "Rendre privée" + }, + "message": { + "duplicate_song": "Pistes déjà présentes dans la playlist", + "song_exist": "Certaines des pistes sélectionnées font déjà partie de la playlist. Voulez-vous les ajouter ou les ignorer ?" + } + }, + "radio": { + "name": "", + "fields": { + "name": "", + "streamUrl": "", + "homePageUrl": "", + "updatedAt": "", + "createdAt": "" + }, + "actions": { + "playNow": "" + } + }, + "share": { + "name": "", + "fields": { + "username": "", + "url": "", + "description": "", + "contents": "", + "expiresAt": "", + "lastVisitedAt": "", + "visitCount": "", + "format": "", + "maxBitRate": "", + "updatedAt": "", + "createdAt": "", + "downloadable": "" + } + } }, - "album": { - "name": "Album |||| Albums", - "fields": { - "albumArtist": "Artiste", - "artist": "Artiste", - "duration": "Durée", - "songCount": "Titres", - "playCount": "Nombre d'écoutes", - "name": "Nom", - "genre": "Genre", - "compilation": "Compilation", - "year": "Année", - "updatedAt": "Mis à jour le", - "comment": "Commentaire", - "rating": "Classement", - "createdAt": "Date d'ajout", - "size": "Taille", - "originalDate": "Original", - "releaseDate": "Sortie", - "releases": "Sortie |||| Sorties", - "released": "Sortie", - "recordLabel": "Label", - "catalogNum": "Numéro de catalogue", - "releaseType": "Type", - "grouping": "Regroupement", - "media": "Média", - "mood": "Humeur", - "date": "Date d'enregistrement", - "missing": "Manquant", - "libraryName": "Bibliothèque" - }, - "actions": { - "playAll": "Lire", - "playNext": "Lire ensuite", - "addToQueue": "Ajouter à la file", - "shuffle": "Mélanger", - "addToPlaylist": "Ajouter à la playlist", - "download": "Télécharger", - "info": "Plus d'informations", - "share": "Partager" - }, - "lists": { - "all": "Tous", - "random": "Aléatoire", - "recentlyAdded": "Récemment ajoutés", - "recentlyPlayed": "Récemment joués", - "mostPlayed": "Plus joués", - "starred": "Favoris", - "topRated": "Les mieux classés" - } - }, - "artist": { - "name": "Artiste |||| Artistes", - "fields": { - "name": "Nom", - "albumCount": "Nombre d'albums", - "songCount": "Nombre de titres", - "playCount": "Lectures", - "rating": "Classement", - "genre": "Genre", - "size": "Taille", - "role": "Rôle", - "missing": "Manquant" - }, - "roles": { - "albumartist": "Artiste de l'album |||| Artistes de l'album", - "artist": "Artiste |||| Artistes", - "composer": "Compositeur |||| Compositeurs", - "conductor": "Chef d'orchestre |||| Chefs d'orchestre", - "lyricist": "Parolier |||| Paroliers", - "arranger": "Arrangeur |||| Arrangeurs", - "producer": "Producteur |||| Producteurs", - "director": "Réalisateur |||| Réalisateurs", - "engineer": "Ingénieur |||| Ingénieurs", - "mixer": "Mixeur |||| Mixeurs", - "remixer": "Remixeur |||| Remixeurs", - "djmixer": "Mixeur DJ |||| Mixeurs DJ", - "performer": "Interprète |||| Interprètes", - "maincredit": "Artiste de l'album ou Artiste |||| Artistes de l'album ou Artistes" - }, - "actions": { - "shuffle": "Lecture aléatoire", - "radio": "Radio", - "topSongs": "Meilleurs titres" - } - }, - "user": { - "name": "Utilisateur |||| Utilisateurs", - "fields": { - "userName": "Nom d'utilisateur", - "isAdmin": "Administrateur", - "lastLoginAt": "Dernière connexion", - "updatedAt": "Dernière mise à jour", - "name": "Nom", - "password": "Mot de passe", - "createdAt": "Créé le", - "changePassword": "Changer le mot de passe ?", - "currentPassword": "Mot de passe actuel", - "newPassword": "Nouveau mot de passe", - "token": "Token", - "lastAccessAt": "Dernier accès", - "libraries": "Bibliothèques" - }, - "helperTexts": { - "name": "Les changements liés à votre nom ne seront reflétés qu'à la prochaine connexion", - "libraries": "Sélectionner une bibliothèque pour cet utilisateur ou laisser vide pour utiliser la bibliothèque par défaut" - }, - "notifications": { - "created": "Utilisateur créé", - "updated": "Utilisateur mis à jour", - "deleted": "Utilisateur supprimé" - }, - "message": { - "listenBrainzToken": "Entrez votre token ListenBrainz.", - "clickHereForToken": "Cliquez ici pour recevoir votre token", - "selectAllLibraries": "Sélectionner toutes les bibliothèques", - "adminAutoLibraries": "Les utilisateurs admin ont automatiquement accès à l'ensemble des bibliothèques" - }, - "validation": { - "librariesRequired": "Au moins une bibliothèque doit être sélectionnée pour les utilisateurs non administrateurs" - } - }, - "player": { - "name": "Lecteur |||| Lecteurs", - "fields": { - "name": "Nom", - "transcodingId": "Transcodage", - "maxBitRate": "Bitrate maximum", - "client": "Client", - "userName": "Nom d'utilisateur", - "lastSeen": "Vu pour la dernière fois", - "reportRealPath": "Rapporter le chemin d'accès absolu", - "scrobbleEnabled": "Scrobbler vers des services externes" - } - }, - "transcoding": { - "name": "Conversion |||| Conversions", - "fields": { - "name": "Nom", - "targetFormat": "Format", - "defaultBitRate": "Bitrate par défaut", - "command": "Commande" - } - }, - "playlist": { - "name": "Playlist |||| Playlists", - "fields": { - "name": "Nom", - "duration": "Durée", - "ownerName": "Propriétaire", - "public": "Publique", - "updatedAt": "Mise à jour le", - "createdAt": "Créée le", - "songCount": "Morceaux", - "comment": "Commentaire", - "sync": "Import automatique", - "path": "Importer depuis" - }, - "actions": { - "selectPlaylist": "Sélectionner une playlist :", - "addNewPlaylist": "Créer \"%{name}\"", - "export": "Exporter", - "makePublic": "Rendre publique", - "makePrivate": "Rendre privée", - "saveQueue": "Sauvegarder la file de lecture dans la playlist", - "searchOrCreate": "Chercher ou créer une nouvelle playlist...", - "pressEnterToCreate": "Appuyer sur entrée pour créer une nouvelle playlist", - "removeFromSelection": "Supprimer de la sélection" - }, - "message": { - "duplicate_song": "Ajouter les titres déjà présents dans la playlist", - "song_exist": "Certains des titres sélectionnés font déjà partie de la playlist. Voulez-vous les ajouter ou les ignorer ?", - "noPlaylistsFound": "Aucune playlist trouvée", - "noPlaylists": "Aucune playlist disponible" - } - }, - "radio": { - "name": "Radio |||| Radios", - "fields": { - "name": "Nom", - "streamUrl": "Lien du stream", - "homePageUrl": "Lien de la page d'accueil", - "updatedAt": "Mise à jour le", - "createdAt": "Créée le" - }, - "actions": { - "playNow": "Jouer" - } - }, - "share": { - "name": "Partage |||| Partages", - "fields": { - "username": "Partagé(e) par", - "url": "Lien URL", - "description": "Description", - "contents": "Contenu", - "expiresAt": "Expire le", - "lastVisitedAt": "Visité pour la dernière fois", - "visitCount": "Nombre de visites", - "format": "Format", - "maxBitRate": "Bitrate maximum", - "updatedAt": "Mis à jour le", - "createdAt": "Créé le", - "downloadable": "Autoriser les téléchargements ?" - } - }, - "missing": { - "name": "Fichier manquant|||| Fichiers manquants", - "fields": { - "path": "Chemin d'accès", - "size": "Taille", - "updatedAt": "A disparu le", - "libraryName": "Bibliothèque" - }, - "actions": { - "remove": "Supprimer", - "remove_all": "Tout supprimer" - }, - "notifications": { - "removed": "Fichier(s) manquant(s) supprimé(s)" - }, - "empty": "Aucun fichier manquant" - }, - "library": { - "name": "Bibliothèque |||| Bibliothèques", - "fields": { - "name": "Nom", - "path": "Chemin d'accès", - "remotePath": "Chemin d'accès distant", - "lastScanAt": "Dernier scan", - "songCount": "Titres", - "albumCount": "Albums", - "artistCount": "Artistes", - "totalSongs": "Titres", - "totalAlbums": "Albums", - "totalArtists": "Artistes", - "totalFolders": "Dossiers", - "totalFiles": "Fichiers", - "totalMissingFiles": "Fichiers manquants", - "totalSize": "Taille totale", - "totalDuration": "Durée", - "defaultNewUsers": "Défaut pour les nouveaux utilisateurs", - "createdAt": "Crée", - "updatedAt": "Mise à jour" - }, - "sections": { - "basic": "Informations", - "statistics": "Statistiques" - }, - "actions": { - "scan": "Scanner la bibliothèque", - "manageUsers": "Gérer les accès utilisateurs", - "viewDetails": "Voir les détails", - "quickScan": "Scan Rapide", - "fullScan": "Scan Complet" - }, - "notifications": { - "created": "Bibliothèque créée avec succès", - "updated": "Bibliothèque mise à jour avec succès", - "deleted": "Bibliothèque supprimée avec succès", - "scanStarted": "Le scan de la bibliothèque a commencé", - "scanCompleted": "Le scan de la bibliothèque est terminé", - "quickScanStarted": "Scan rapide démarré", - "fullScanStarted": "Scan complet démarré", - "scanError": "Une erreur est survenue en démarrant le scan. Veuillez regarder les logs" - }, - "validation": { - "nameRequired": "La bibliothèque doit obligatoirement avoir un nom", - "pathRequired": "La bibliothèque doit obligatoirement avoir un chemin d'accès", - "pathNotDirectory": "Le chemin d'accès de la bibliothèque doit pointer sur un dossier", - "pathNotFound": "Impossible de trouver ce chemin d'accès", - "pathNotAccessible": "Impossible d'accéder à ce chemin d'accès", - "pathInvalid": "Ce chemin d'accès n'est pas valide" - }, - "messages": { - "deleteConfirm": "Êtes-vous sûr(e) de vouloir supprimer cette bibliothèque ? Cela supprimera toutes les données associées ainsi que les accès utilisateurs.", - "scanInProgress": "Scan en cours...", - "noLibrariesAssigned": "Aucune bibliothèque pour cet utilisateur" - } - }, - "plugin": { - "name": "Extension |||| Extensions", - "fields": { - "id": "ID", - "name": "Nom", - "description": "Description", - "version": "Version", - "author": "Auteur.e", - "website": "Site web", - "permissions": "Permissions", - "enabled": "Activée", - "status": "Statut", - "path": "Chemin", - "lastError": "Erreur", - "hasError": "Erreur", - "updatedAt": "Mise à jour", - "createdAt": "Installée", - "configKey": "Clef", - "configValue": "Valeur", - "allUsers": "Autoriser tous les utilisateur·rices", - "selectedUsers": "Utilisateur·rices sélectionné.e.s", - "allLibraries": "Autoriser toutes les bibliothèques", - "selectedLibraries": "Bibliothèques sélectionnées", - "allowWriteAccess": "Autoriser l'accès en écriture" - }, - "sections": { - "status": "Statut", - "info": "Informations de l'extension", - "configuration": "Configuration", - "manifest": "Manifeste", - "usersPermission": "Permissions utilisateur·ices", - "libraryPermission": "Permissions des bibliothèques" - }, - "status": { - "enabled": "Activées", - "disabled": "Désactivées" - }, - "actions": { - "enable": "Activer", - "disable": "Désactiver", - "disabledDueToError": "L'erreur doit être réglée avant de pouvoir activer la bibliothèque", - "disabledUsersRequired": "Sélectionner des utilisateur·ices avant d'activer la bibliothèque", - "disabledLibrariesRequired": "Sélectionner au moins une bibliothèque", - "addConfig": "Ajouter une configuration", - "rescan": "Rescanner" - }, - "notifications": { - "enabled": "Extension activée", - "disabled": "Extension désactivée", - "updated": "Extension mise à jour", - "error": "Erreur pendant la mise à jour de l'extension" - }, - "validation": { - "invalidJson": "La configuration doit être un JSON valide" - }, - "messages": { - "configHelp": "Configurer l'extension en utilisant des paires clef/valeurs. Laisser vide si l'extension ne requiert aucune configuration.", - "clickPermissions": "Cliquer sur une permission pour plus de détails", - "noConfig": "Aucune configuration", - "allUsersHelp": "Quand sélectionnée, l'extension aura accès à l'ensemble des utilisateur·rices, y compris ceux créé.e.s dans le future.", - "noUsers": "Aucun.e utilisateur·rice sélectionné.e", - "permissionReason": "Raison", - "usersRequired": "Cette extension nécessite un accès aux informations utilisateurs. Sélectionnez les utilisateur·rices autorisé.e.s ou sélectionnez 'Tout autoriser'.", - "allLibrariesHelp": "Quand sélectionnée, cette extension aura accès à l'ensemble des bibliothèques, y compris celles créées dans le futur.", - "noLibraries": "Aucune bibliothèque sélectionnée", - "librariesRequired": "Cette extension nécessite l'accès aux information de la bibliothèque. Sélectionnez à quelles bibliothèque cette extension a accès, ou sélectionnez 'Autoriser toutes les bibliothèques'.", - "requiredHosts": "Hôtes requis", - "configValidationError": "Erreur lors de la validation de la configuration", - "schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide.", - "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", - "configValue": "valeur" - } - } - }, - "ra": { - "auth": { - "welcome1": "Merci d'avoir installé Navidrome !", - "welcome2": "Pour commencer, créez un compte administrateur", - "confirmPassword": "Confirmez votre mot de passe", - "buttonCreateAdmin": "Créer un compte administrateur", - "auth_check_error": "Merci de vous connecter pour continuer", - "user_menu": "Profil", - "username": "Identifiant", - "password": "Mot de passe", - "sign_in": "Connexion", - "sign_in_error": "Échec de l'authentification, merci de réessayer", - "logout": "Déconnexion", - "insightsCollectionNote": "Navidrome collecte des données de façon anonyme\nafin d'améliorer le projet. Cliquez [ici] pour en savoir\nplus ou pour désactiver la télémétrie" - }, - "validation": { - "invalidChars": "Merci de n'utiliser que des chiffres et des lettres", - "passwordDoesNotMatch": "Les mots de passe ne correspondent pas", - "required": "Ce champ est requis", - "minLength": "Minimum %{min} caractères", - "maxLength": "Maximum %{max} caractères", - "minValue": "Minimum %{min}", - "maxValue": "Maximum %{max}", - "number": "Doit être un nombre", - "email": "Doit être un e-mail", - "oneOf": "Doit être au choix : %{options}", - "regex": "Doit respecter un format spécifique (regexp) : %{pattern}", - "unique": "Doit être unique", - "url": "Doit être un lien URL correct" - }, - "action": { - "add_filter": "Ajouter un filtre", - "add": "Ajouter", - "back": "Retour", - "bulk_actions": "%{smart_count} sélectionné |||| %{smart_count} sélectionnés", - "cancel": "Annuler", - "clear_input_value": "Vider le champ", - "clone": "Dupliquer", - "confirm": "Confirmer", - "create": "Créer", - "delete": "Supprimer", - "edit": "Éditer", - "export": "Exporter", - "list": "Liste", - "refresh": "Actualiser", - "remove_filter": "Supprimer ce filtre", - "remove": "Supprimer", - "save": "Enregistrer", - "search": "Rechercher", - "show": "Afficher", - "sort": "Trier", - "undo": "Annuler", - "expand": "Étendre", - "close": "Fermer", - "open_menu": "Ouvrir le menu", - "close_menu": "Fermer le menu", - "unselect": "Désélectionner", - "skip": "Ignorer", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Partager", - "download": "Télécharger" - }, - "boolean": { - "true": "Oui", - "false": "Non" - }, - "page": { - "create": "Créer %{name}", - "dashboard": "Tableau de bord", - "edit": "%{name} #%{id}", - "error": "Un problème est survenu", - "list": "%{name}", - "loading": "Chargement", - "not_found": "Introuvable", - "show": "%{name} #%{id}", - "empty": "Pas encore de %{name}.", - "invite": "Voulez-vous en créer un ?" - }, - "input": { - "file": { - "upload_several": "Déposez les fichiers à téléverser, ou cliquez pour en sélectionner.", - "upload_single": "Déposez le fichier à téléverser, ou cliquez pour le sélectionner." - }, - "image": { - "upload_several": "Déposez les images à téléverser, ou cliquez pour en sélectionner.", - "upload_single": "Déposez l'image à téléverser, ou cliquez pour la sélectionner." - }, - "references": { - "all_missing": "Impossible de trouver des données de références.", - "many_missing": "Au moins une des références associées semble ne plus être disponible.", - "single_missing": "La référence associée ne semble plus disponible." - }, - "password": { - "toggle_visible": "Cacher le mot de passe", - "toggle_hidden": "Montrer le mot de passe" - } + "ra": { + "auth": { + "welcome1": "Merci d'avoir installé Navidrome !", + "welcome2": "Pour commencer, créez un compte administrateur", + "confirmPassword": "Confirmez votre mot de passe", + "buttonCreateAdmin": "Créer un compte administrateur", + "auth_check_error": "Merci de vous connecter pour continuer", + "user_menu": "Profil", + "username": "Identifiant", + "password": "Mot de passe", + "sign_in": "Connexion", + "sign_in_error": "Échec de l'authentification, merci de réessayer", + "logout": "Déconnexion" + }, + "validation": { + "invalidChars": "Merci de n'utiliser que des chiffres et des lettres", + "passwordDoesNotMatch": "Les mots de passe ne correspondent pas", + "required": "Ce champ est requis", + "minLength": "Minimum %{min} caractères", + "maxLength": "Maximum %{max} caractères", + "minValue": "Minimum %{min}", + "maxValue": "Maximum %{max}", + "number": "Doit être un nombre", + "email": "Doit être un e-mail", + "oneOf": "Doit être au choix : %{options}", + "regex": "Doit respecter un format spécifique (regexp) : %{pattern}", + "unique": "Doit être unique", + "url": "" + }, + "action": { + "add_filter": "Ajouter un filtre", + "add": "Ajouter", + "back": "Retour", + "bulk_actions": "%{smart_count} sélectionné |||| %{smart_count} sélectionnés", + "cancel": "Annuler", + "clear_input_value": "Vider le champ", + "clone": "Dupliquer", + "confirm": "Confirmer", + "create": "Créer", + "delete": "Supprimer", + "edit": "Éditer", + "export": "Exporter", + "list": "Liste", + "refresh": "Actualiser", + "remove_filter": "Supprimer ce filtre", + "remove": "Supprimer", + "save": "Enregistrer", + "search": "Rechercher", + "show": "Afficher", + "sort": "Trier", + "undo": "Annuler", + "expand": "Étendre", + "close": "Fermer", + "open_menu": "Ouvrir le menu", + "close_menu": "Fermer le menu", + "unselect": "Désélectionner", + "skip": "Ignorer", + "bulk_actions_mobile": "", + "share": "", + "download": "" + }, + "boolean": { + "true": "Oui", + "false": "Non" + }, + "page": { + "create": "Créer %{name}", + "dashboard": "Tableau de bord", + "edit": "%{name} #%{id}", + "error": "Un problème est survenu", + "list": "%{name}", + "loading": "Chargement", + "not_found": "Page manquante", + "show": "%{name} #%{id}", + "empty": "Pas encore de %{name}.", + "invite": "Voulez-vous en créer un ?" + }, + "input": { + "file": { + "upload_several": "Déposez les fichiers à téléverser, ou cliquez pour en sélectionner.", + "upload_single": "Déposez le fichier à téléverser, ou cliquez pour le sélectionner." + }, + "image": { + "upload_several": "Déposez les images à téléverser, ou cliquez pour en sélectionner.", + "upload_single": "Déposez l'image à téléverser, ou cliquez pour la sélectionner." + }, + "references": { + "all_missing": "Impossible de trouver des données de références.", + "many_missing": "Au moins une des références associées semble ne plus être disponible.", + "single_missing": "La référence associée ne semble plus disponible." + }, + "password": { + "toggle_visible": "Cacher le mot de passe", + "toggle_hidden": "Montrer le mot de passe" + } + }, + "message": { + "about": "À propos de", + "are_you_sure": "Êtes-vous sûr(e) ?", + "bulk_delete_content": "Êtes-vous sûr(e) de vouloir supprimer cet élément ? |||| Êtes-vous sûr(e) de vouloir supprimer ces %{smart_count} éléments ?", + "bulk_delete_title": "Supprimer %{name} |||| Supprimer %{smart_count} %{name}", + "delete_content": "Êtes-vous sûr(e) de vouloir supprimer cet élément ?", + "delete_title": "Supprimer %{name} #%{id}", + "details": "Détails", + "error": "En raison d'une erreur côté navigateur, votre requête n'a pas pu aboutir.", + "invalid_form": "Le formulaire n'est pas valide.", + "loading": "La page est en cours de chargement, merci de bien vouloir patienter.", + "no": "Non", + "not_found": "L'URL saisie est incorrecte, ou vous avez suivi un mauvais lien.", + "yes": "Oui", + "unsaved_changes": "Certains changements n'ont pas été enregistrés. Êtes-vous sûr(e) de vouloir quitter cette page ?" + }, + "navigation": { + "no_results": "Aucun résultat", + "no_more_results": "La page numéro %{page} est en dehors des limites. Essayez la page précédente.", + "page_out_of_boundaries": "La page %{page} est en dehors des limites", + "page_out_from_end": "Fin de la pagination", + "page_out_from_begin": "La page doit être supérieure à 1", + "page_range_info": "%{offsetBegin}-%{offsetEnd} sur %{total}", + "page_rows_per_page": "Lignes par page :", + "next": "Suivant", + "prev": "Précédent", + "skip_nav": "Avancer au contenu" + }, + "notification": { + "updated": "Élément mis à jour |||| %{smart_count} éléments mis à jour", + "created": "Élément créé", + "deleted": "Élément supprimé |||| %{smart_count} éléments supprimés", + "bad_item": "Élément inconnu", + "item_doesnt_exist": "L'élément n'existe pas", + "http_error": "Erreur de communication avec le serveur", + "data_provider_error": "Erreur dans le fournisseur de données. Plus de détails dans la console.", + "i18n_error": "Erreur de chargement des traductions pour la langue sélectionnée", + "canceled": "Action annulée", + "logged_out": "Votre session a pris fin, veuillez vous reconnecter.", + "new_version": "Nouvelle version disponible ! Veuillez rafraîchir la page." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Colonnes à afficher", + "layout": "Mise en page", + "grid": "Grille", + "table": "Table" + } }, "message": { - "about": "À propos de", - "are_you_sure": "Êtes-vous sûr(e) ?", - "bulk_delete_content": "Êtes-vous sûr(e) de vouloir supprimer cet élément ? |||| Êtes-vous sûr(e) de vouloir supprimer ces %{smart_count} éléments ?", - "bulk_delete_title": "Supprimer %{name} |||| Supprimer %{smart_count} %{name}", - "delete_content": "Êtes-vous sûr(e) de vouloir supprimer cet élément ?", - "delete_title": "Supprimer %{name} #%{id}", - "details": "Détails", - "error": "En raison d'une erreur côté navigateur, votre requête n'a pas pu aboutir.", - "invalid_form": "Le formulaire n'est pas valide.", - "loading": "La page est en cours de chargement, merci de bien vouloir patienter.", - "no": "Non", - "not_found": "L'URL saisie est incorrecte, ou vous avez suivi un mauvais lien.", - "yes": "Oui", - "unsaved_changes": "Certains changements n'ont pas été enregistrés. Êtes-vous sûr(e) de vouloir quitter cette page ?" - }, - "navigation": { - "no_results": "Aucun résultat", - "no_more_results": "La page numéro %{page} est en dehors des limites. Essayez la page précédente.", - "page_out_of_boundaries": "La page %{page} est en dehors des limites", - "page_out_from_end": "Fin de la pagination", - "page_out_from_begin": "La page doit être supérieure à 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} sur %{total}", - "page_rows_per_page": "Lignes par page :", - "next": "Suivant", - "prev": "Précédent", - "skip_nav": "Avancer au contenu" - }, - "notification": { - "updated": "Élément mis à jour |||| %{smart_count} éléments mis à jour", - "created": "Élément créé", - "deleted": "Élément supprimé |||| %{smart_count} éléments supprimés", - "bad_item": "Élément inconnu", - "item_doesnt_exist": "L'élément n'existe pas", - "http_error": "Erreur de communication avec le serveur", - "data_provider_error": "Erreur dans le fournisseur de données. Plus de détails dans la console.", - "i18n_error": "Erreur de chargement des traductions pour la langue sélectionnée", - "canceled": "Action annulée", - "logged_out": "Votre session a pris fin, veuillez vous reconnecter.", - "new_version": "Nouvelle version disponible ! Veuillez rafraîchir la page." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Colonnes à afficher", - "layout": "Mise en page", - "grid": "Grille", - "table": "Table" - } - }, - "message": { - "note": "NOTE", - "transcodingDisabled": "Le changement de paramètres depuis l'interface web est désactivé pour des raisons de sécurité. Pour changer (éditer ou supprimer) les options de transcodage, relancer le serveur avec l'option %{config} activée.", - "transcodingEnabled": "Navidrome fonctionne actuellement avec %{config}, rendant possible l’exécution de commandes arbitraires depuis l'interface web. Il est recommandé d'activer cette fonctionnalité uniquement lors de la configuration du transcodage.", - "songsAddedToPlaylist": "1 titre a été ajouté à la playlist |||| %{smart_count} titres ont été ajoutés à la playlist", - "noPlaylistsAvailable": "Aucune playlist", - "delete_user_title": "Supprimer l'utilisateur '%{name}'", - "delete_user_content": "Êtes-vous sûr(e) de vouloir supprimer cet utilisateur et ses données associées (y compris ses playlists et préférences) ?", - "notifications_blocked": "Votre navigateur bloque les notifications de ce site", - "notifications_not_available": "Votre navigateur ne permet pas d'afficher les notifications sur le bureau ou vous n'accédez pas à Navidrome via HTTPS", - "lastfmLinkSuccess": "Last.fm a été correctement relié et le scrobble a été activé", - "lastfmLinkFailure": "Last.fm n'a pas pu être correctement relié", - "lastfmUnlinkSuccess": "Last.fm n'est plus relié et le scrobble a été désactivé", - "lastfmUnlinkFailure": "Erreur pendant la suppression du lien avec Last.fm", - "openIn": { - "lastfm": "Ouvrir dans Last.fm", - "musicbrainz": "Ouvrir dans MusicBrainz" - }, - "lastfmLink": "Lire plus...", - "listenBrainzLinkSuccess": "La liaison et le scrobble avec ListenBrainz sont maintenant activés pour l'utilisateur : %{user}", - "listenBrainzLinkFailure": "Échec lors de la liaison avec ListenBrainz : %{error}", - "listenBrainzUnlinkSuccess": "La liaison et le scrobble avec ListenBrainz sont maintenant désactivés", - "listenBrainzUnlinkFailure": "Échec lors de la désactivation de la liaison avec ListenBrainz", - "downloadOriginalFormat": "Télécharger au format original", - "shareOriginalFormat": "Partager avec le format original", - "shareDialogTitle": "Partager %{resource} '%{name}'", - "shareBatchDialogTitle": "Partager 1 %{resource} |||| Partager %{smart_count} %{resource}", - "shareSuccess": "Lien copié vers le presse-papier : %{url}", - "shareFailure": "Erreur en copiant le lien %{url} vers le presse-papier", - "downloadDialogTitle": "Télécharger %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Copier vers le presse-papier : Ctrl+C, Enter", - "remove_missing_title": "Supprimer les fichiers manquants", - "remove_missing_content": "Êtes-vous sûr(e) de vouloir supprimer les fichiers manquants sélectionnés de la base de données ? Ceci supprimera définitivement toute référence à ceux-ci, y compris leurs nombres d'écoutes et leurs notations", - "remove_all_missing_title": "Supprimer tous les fichiers manquants", - "remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence.", - "noSimilarSongsFound": "Aucun titre similaire n'a été trouvé", - "noTopSongsFound": "Aucun meilleur titre n'a été trouvé", - "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", - "settings": "Paramètres", - "version": "Version", - "theme": "Thème", - "personal": { - "name": "Paramètres personnels", - "options": { - "theme": "Thème", - "language": "Langue", - "defaultView": "Vue par défaut", - "desktop_notifications": "Notifications de bureau", - "lastfmScrobbling": "Scrobbler vers Last.fm", - "listenBrainzScrobbling": "Scrobbler vers ListenBrainz", - "replaygain": "Mode ReplayGain", - "preAmp": "Pré-amplification ReplayGain (dB)", - "gain": { - "none": "Désactivé", - "album": "Utiliser le gain de l'album", - "track": "Utiliser le gain des titres" + "note": "NOTE", + "transcodingDisabled": "Le changement de paramètres depuis l'interface web est désactivé pour des raisons de sécurité. Pour changer (éditer ou supprimer) les options de transcodage, relancer le serveur avec l'option %{config} activée.", + "transcodingEnabled": "Navidrome fonctionne actuellement avec %{config}, rendant possible l’exécution de commandes arbitraires depuis l'interface web. Il est recommandé d'activer cette fonctionnalité uniquement lors de la configuration du transcodage.", + "songsAddedToPlaylist": "Une piste a été ajoutée à la playlist |||| %{smart_count} pistes ont été ajoutées à la playlist", + "noPlaylistsAvailable": "Aucune playlist", + "delete_user_title": "Supprimer l'utilisateur '%{name}'", + "delete_user_content": "Êtes-vous sûr(e) de vouloir supprimer cet utilisateur et ses données associées (y compris ses playlists et préférences) ?", + "notifications_blocked": "Votre navigateur bloque les notifications de ce site", + "notifications_not_available": "Votre navigateur ne permet pas d'afficher les notifications sur le bureau ou vous n'accédez pas à Navidrome via HTTPS", + "lastfmLinkSuccess": "Last.fm a été correctement relié et le scrobble a été activé", + "lastfmLinkFailure": "Last.fm n'a pas pu être correctement relié", + "lastfmUnlinkSuccess": "Last.fm n'est plus relié et le scrobble a été désactivé", + "lastfmUnlinkFailure": "Erreur pendant la suppression du lien avec Last.fm", + "openIn": { + "lastfm": "Ouvrir dans Last.fm", + "musicbrainz": "Ouvrir dans MusicBrainz" }, - "lastfmNotConfigured": "La clef API de Last.fm n'est pas configurée" - } + "lastfmLink": "Lire plus...", + "listenBrainzLinkSuccess": "La liaison et le scrobble avec ListenBrainz sont maintenant activés pour l'utilisateur: %{user}", + "listenBrainzLinkFailure": "Échec lors de la liaison avec ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "La liaison et le scrobble avec ListenBrainz sont maintenant désactivés", + "listenBrainzUnlinkFailure": "Échec lors de la désactivation de la liaison avec ListenBrainz", + "downloadOriginalFormat": "", + "shareOriginalFormat": "", + "shareDialogTitle": "", + "shareBatchDialogTitle": "", + "shareSuccess": "", + "shareFailure": "", + "downloadDialogTitle": "", + "shareCopyToClipboard": "" }, - "albumList": "Albums", - "about": "À propos", - "playlists": "Playlists", - "sharedPlaylists": "Playlists partagées", - "librarySelector": { - "allLibraries": "Toutes les bibliothèques (%{count})", - "multipleLibraries": "%{selected} bibliothèque(s) sélectionnée(s) sur %{total}", - "selectLibraries": "Sélectionner les bibliothèques", - "none": "Aucune" - } - }, - "player": { - "playListsText": "File de lecture", - "openText": "Ouvrir", - "closeText": "Fermer", - "notContentText": "Absence de musique", - "clickToPlayText": "Cliquer pour lire", - "clickToPauseText": "Cliquer pour mettre en pause", - "nextTrackText": "Morceau suivant", - "previousTrackText": "Morceau précédent", - "reloadText": "Recharger", - "volumeText": "Volume", - "toggleLyricText": "Afficher/masquer les paroles", - "toggleMiniModeText": "Minimiser", - "destroyText": "Détruire", - "downloadText": "Télécharger", - "removeAudioListsText": "Vider la liste de lecture", - "clickToDeleteText": "Cliquer pour supprimer %{name}", - "emptyLyricText": "Absence de paroles", - "playModeText": { - "order": "Ordonner", - "orderLoop": "Tout répéter", - "singleLoop": "Répéter", - "shufflePlay": "Aléatoire" - } - }, - "about": { - "links": { - "homepage": "Page d'accueil", - "source": "Code source", - "featureRequests": "Demande de fonctionnalités", - "lastInsightsCollection": "Dernière collection des données", - "insights": { - "disabled": "Désactivée", - "waiting": "En attente" - } + "menu": { + "library": "Bibliothèque", + "settings": "Paramètres", + "version": "Version", + "theme": "Thème", + "personal": { + "name": "Paramètres personnels", + "options": { + "theme": "Thème", + "language": "Langue", + "defaultView": "Vue par défaut", + "desktop_notifications": "Notifications de bureau", + "lastfmScrobbling": "Scrobbler vers Last.fm", + "listenBrainzScrobbling": "Scrobbler vers ListenBrainz", + "replaygain": "", + "preAmp": "", + "gain": { + "none": "", + "album": "", + "track": "" + } + } + }, + "albumList": "Albums", + "about": "À propos", + "playlists": "Playlists", + "sharedPlaylists": "Playlists partagées" }, - "tabs": { - "about": "À propos", - "config": "Paramètres" + "player": { + "playListsText": "File de lecture", + "openText": "Ouvrir", + "closeText": "Fermer", + "notContentText": "Absence de musique", + "clickToPlayText": "Cliquer pour lire", + "clickToPauseText": "Cliquer pour mettre en pause", + "nextTrackText": "Morceau suivant", + "previousTrackText": "Morceau précédent", + "reloadText": "Recharger", + "volumeText": "Volume", + "toggleLyricText": "Afficher/masquer les paroles", + "toggleMiniModeText": "Minimiser", + "destroyText": "Détruire", + "downloadText": "Télécharger", + "removeAudioListsText": "Vider la liste de lecture", + "clickToDeleteText": "Cliquer pour supprimer %{name}", + "emptyLyricText": "Absence de paroles", + "playModeText": { + "order": "Ordonner", + "orderLoop": "Tout répéter", + "singleLoop": "Répéter", + "shufflePlay": "Aléatoire" + } }, - "config": { - "configName": "Nom de la configuration", - "environmentVariable": "Variable d'environnement", - "currentValue": "Valeur actuelle", - "configurationFile": "Fichier de configuration", - "exportToml": "Exporter la configuration (TOML)", - "exportSuccess": "La configuration a été copiée vers le presse-papier au format TOML", - "exportFailed": "Une erreur est survenue en copiant la configuration", - "devFlagsHeader": "Options de développement (peuvent être amenés à changer / être supprimés)", - "devFlagsComment": "Ces paramètres sont expérimentaux et peuvent être amenés à changer dans le futur", - "downloadToml": "Télécharger la configuration (TOML)" + "about": { + "links": { + "homepage": "Page d'accueil", + "source": "Code source", + "featureRequests": "Demande de fonctionnalités" + } + }, + "activity": { + "title": "Activité", + "totalScanned": "Nombre total de dossiers scannés", + "quickScan": "Scan rapide", + "fullScan": "Scan complet", + "serverUptime": "Disponibilité du serveur", + "serverDown": "HORS LIGNE" + }, + "help": { + "title": "Raccourcis Navidrome", + "hotkeys": { + "show_help": "Montrer cette fenêtre d'aide", + "toggle_menu": "Afficher/Cacher le menu latéral", + "toggle_play": "Lecture/Pause", + "prev_song": "Morceau précédent", + "next_song": "Morceau suivant", + "vol_up": "Augmenter le volume", + "vol_down": "Baisser le volume", + "toggle_love": "Ajouter/Enlever le morceau des favoris", + "current_song": "" + } } - }, - "activity": { - "title": "Activité", - "totalScanned": "Nombre total de dossiers scannés", - "quickScan": "Scan rapide", - "fullScan": "Scan complet", - "serverUptime": "Disponibilité du serveur", - "serverDown": "HORS LIGNE", - "scanType": "Type", - "status": "Erreur de scan", - "elapsedTime": "Temps écoulé", - "selectiveScan": "Sélectif" - }, - "help": { - "title": "Raccourcis Navidrome", - "hotkeys": { - "show_help": "Montrer cette fenêtre d'aide", - "toggle_menu": "Afficher/Cacher le menu latéral", - "toggle_play": "Lecture/Pause", - "prev_song": "Morceau précédent", - "next_song": "Morceau suivant", - "vol_up": "Augmenter le volume", - "vol_down": "Baisser le volume", - "toggle_love": "Ajouter/Enlever le morceau des favoris", - "current_song": "Aller au titre en cours" - } - }, - "nowPlaying": { - "title": "En cours de lecture", - "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 47f2f4272..940839104 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -1,734 +1,456 @@ { - "languageName": "Galego", - "resources": { - "song": { - "name": "Canción |||| Cancións", - "fields": { - "albumArtist": "Artista do Álbum", - "duration": "Tempo", - "trackNumber": "#", - "playCount": "Reproducións", - "title": "Título", - "artist": "Artista", - "album": "Álbum", - "path": "Ruta do ficheiro", - "genre": "Xénero", - "compilation": "Compilación", - "year": "Ano", - "size": "Tamaño do ficheiro", - "updatedAt": "Actualizado a", - "bitRate": "Taxa de bits", - "discSubtitle": "Subtítulo do disco", - "starred": "Favorito", - "comment": "Comentario", - "rating": "Valoración", - "quality": "Calidade", - "bpm": "BPM", - "playDate": "Último reproducido", - "channels": "Canles", - "createdAt": "Engadido", - "grouping": "Grupos", - "mood": "Estado", - "participants": "Participantes adicionais", - "tags": "Etiquetas adicionais", - "mappedTags": "Etiquetas mapeadas", - "rawTags": "Etiquetas en cru", - "bitDepth": "Calidade de Bit", - "sampleRate": "Taxa de mostra", - "missing": "Falta", - "libraryName": "Biblioteca", - "composer": "Composición", - "disc": "Disco %{discNumber}", - "albumGain": "Gañancia de Album", - "trackGain": "Gañancia de Canción" - }, - "actions": { - "addToQueue": "Ao final da cola", - "playNow": "Reproducir agora", - "addToPlaylist": "Engadir á lista", - "shuffleAll": "Remexer todo", - "download": "Descargar", - "playNext": "A continuación", - "info": "Obter info", - "showInPlaylist": "Mostrar en Lista de reprodución", - "instantMix": "Mestura Súbita" - } + "languageName": "Galego", + "resources": { + "song": { + "name": "Canción |||| Cancións", + "fields": { + "albumArtist": "Artista do Álbum", + "duration": "Tempo", + "trackNumber": "#", + "playCount": "Reproducións", + "title": "Título", + "artist": "Artista", + "album": "Álbum", + "path": "Ruta do ficheiro", + "genre": "Xénero", + "compilation": "Compilación", + "year": "Ano", + "size": "Tamaño do ficheiro", + "updatedAt": "Actualizado a", + "bitRate": "Taxa de bits", + "discSubtitle": "Subtítulo do disco", + "starred": "Favorito", + "comment": "Comentario", + "rating": "Valoración", + "quality": "Calidade", + "bpm": "BPM", + "playDate": "Último reproducido", + "channels": "Canles", + "createdAt": "Engadido" + }, + "actions": { + "addToQueue": "Reproducir reprodución", + "playNow": "Reproducir agora", + "addToPlaylist": "Engadir á lista", + "shuffleAll": "Remexer todo", + "download": "Descargar", + "playNext": "Reproducir seguinte", + "info": "Obter info" + } + }, + "album": { + "name": "Álbum |||| Álbums", + "fields": { + "albumArtist": "Artista do álbum", + "artist": "Artista", + "duration": "Tempo", + "songCount": "Cancións", + "playCount": "Reproducións", + "name": "Nome", + "genre": "Xénero", + "compilation": "Compilación", + "year": "Ano", + "updatedAt": "Actualizado a", + "comment": "Comentario", + "rating": "Valoración", + "createdAt": "Engadido o", + "size": "Tamaño" + }, + "actions": { + "playAll": "Reproducir", + "playNext": "Reproducir a seguir", + "addToQueue": "Reproducir máis tarde", + "shuffle": "Barallar", + "addToPlaylist": "Engadir a Lista", + "download": "Descargar", + "info": "Obter info", + "share": "Compartir" + }, + "lists": { + "all": "Todo", + "random": "Ao chou", + "recentlyAdded": "Engadida recentemente", + "recentlyPlayed": "Reproducida recentemente", + "mostPlayed": "Reproducida máis veces", + "starred": "Favoritas", + "topRated": "Máis valoradas" + } + }, + "artist": { + "name": "Artista |||| Artistas", + "fields": { + "name": "Nome", + "albumCount": "Número de álbums", + "songCount": "Número de cancións", + "playCount": "Reproducións", + "rating": "Valoración", + "genre": "Xénero", + "size": "Tamaño" + } + }, + "user": { + "name": "Usuaria |||| Usuarias", + "fields": { + "userName": "Identificador", + "isAdmin": "É Admin", + "lastLoginAt": "Último acceso o", + "updatedAt": "Actualizado", + "name": "Nome", + "password": "Contrasinal", + "createdAt": "Data creación", + "changePassword": "Cambiar contrasinal?", + "currentPassword": "Contrasinal actual", + "newPassword": "Novo contrasinal", + "token": "Token" + }, + "helperTexts": { + "name": "Os cambios no nome aplicaranse a próxima vez que accedas" + }, + "notifications": { + "created": "Creouse a usuaria", + "updated": "Actualizouse a usuaria", + "deleted": "Eliminouse a usuaria" + }, + "message": { + "listenBrainzToken": "Escribe o token de usuaria de ListenBrainz", + "clickHereForToken": "Preme aquí para obter o token" + } + }, + "player": { + "name": "Reprodutor |||| Reprodutores", + "fields": { + "name": "Nome", + "transcodingId": "Transcodificación", + "maxBitRate": "Taxa de bit máx.", + "client": "Cliente", + "userName": "Identificador", + "lastSeen": "Último acceso", + "reportRealPath": "Informar de Ruta Real", + "scrobbleEnabled": "Enviar Scrobbles a servizos externos" + } + }, + "transcoding": { + "name": "Transcodificación |||| Transcodificacións", + "fields": { + "name": "Nome", + "targetFormat": "Formato de destino", + "defaultBitRate": "Taxa de bit por defecto", + "command": "Orde" + } + }, + "playlist": { + "name": "Lista de reprodución |||| Listas de reprodución", + "fields": { + "name": "Nome", + "duration": "Duración", + "ownerName": "Propiedade", + "public": "Pública", + "updatedAt": "Actualizada o", + "createdAt": "Creada o", + "songCount": "Cancións", + "comment": "Comentario", + "sync": "Autoimportación", + "path": "Importar desde" + }, + "actions": { + "selectPlaylist": "Elixe unha lista:", + "addNewPlaylist": "Crear \"%{name}\"", + "export": "Exportar", + "makePublic": "Facela Pública", + "makePrivate": "Facela Privada" + }, + "message": { + "duplicate_song": "Engadir cancións duplicadas", + "song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?" + } + }, + "radio": { + "name": "Radio |||| Radios", + "fields": { + "name": "Nome", + "streamUrl": "URL do fluxo", + "homePageUrl": "URL da web", + "updatedAt": "Actualizada", + "createdAt": "Creada" + }, + "actions": { + "playNow": "En reprodución" + } + }, + "share": { + "name": "´", + "fields": { + "username": "Compartida por", + "url": "URL", + "description": "Descrición", + "contents": "Contidos", + "expiresAt": "Caducidade", + "lastVisitedAt": "Última visitada", + "visitCount": "Visitas", + "format": "Formato", + "maxBitRate": "Taxa de Bit Máx.", + "updatedAt": "Actualizada o", + "createdAt": "Creada o", + "downloadable": "Permitir descargas?" + } + } }, - "album": { - "name": "Álbum |||| Álbums", - "fields": { - "albumArtist": "Artista do álbum", - "artist": "Artista", - "duration": "Tempo", - "songCount": "Cancións", - "playCount": "Reproducións", - "name": "Nome", - "genre": "Xénero", - "compilation": "Compilación", - "year": "Ano", - "updatedAt": "Actualizado a", - "comment": "Comentario", - "rating": "Valoración", - "createdAt": "Engadido o", - "size": "Tamaño", - "originalDate": "Orixinal", - "releaseDate": "Publicado", - "releases": "Publicación |||| Publicacións", - "released": "Publicado", - "recordLabel": "Editorial", - "catalogNum": "Número de catálogo", - "releaseType": "Tipo", - "grouping": "Grupos", - "media": "Multimedia", - "mood": "Estado", - "date": "Data de gravación", - "missing": "Falta", - "libraryName": "Biblioteca" - }, - "actions": { - "playAll": "Reproducir", - "playNext": "Reproducir a seguir", - "addToQueue": "Reproducir máis tarde", - "shuffle": "Barallar", - "addToPlaylist": "Engadir a Lista", - "download": "Descargar", - "info": "Obter info", - "share": "Compartir", - "refresh": "Actualizar metadatos" - }, - "lists": { - "all": "Todo", - "random": "Ao chou", - "recentlyAdded": "Engadida recentemente", - "recentlyPlayed": "Reproducida recentemente", - "mostPlayed": "Reproducida máis veces", - "starred": "Favoritas", - "topRated": "Máis valoradas" - } - }, - "artist": { - "name": "Artista |||| Artistas", - "fields": { - "name": "Nome", - "albumCount": "Número de álbums", - "songCount": "Número de cancións", - "playCount": "Reproducións", - "rating": "Valoración", - "genre": "Xénero", - "size": "Tamaño", - "role": "Rol", - "missing": "Falta" - }, - "roles": { - "albumartist": "Artista do álbum |||| Artistas do álbum", - "artist": "Artista |||| Artistas", - "composer": "Composición |||| Composición", - "conductor": "Condutor |||| Condutoras", - "lyricist": "Letrista |||| Letristas", - "arranger": "Arranxos |||| Arranxos", - "producer": "Produtora |||| Produtoras", - "director": "Dirección |||| Dirección", - "engineer": "Enxeñería |||| Enxeñería", - "mixer": "Mistura |||| Mistura", - "remixer": "Remezcla |||| Remezcla", - "djmixer": "Mezcla DJs |||| Mezcla DJs", - "performer": "Intérprete |||| Intérpretes", - "maincredit": "Artista do álbum ou Artista |||| Artistas do álbum ou Artistas" - }, - "actions": { - "shuffle": "Barallar", - "radio": "Radio", - "topSongs": "Cancións destacadas" - } - }, - "user": { - "name": "Usuaria |||| Usuarias", - "fields": { - "userName": "Identificador", - "isAdmin": "É Admin", - "lastLoginAt": "Último acceso o", - "updatedAt": "Actualizado", - "name": "Nome", - "password": "Contrasinal", - "createdAt": "Data creación", - "changePassword": "Cambiar contrasinal?", - "currentPassword": "Contrasinal actual", - "newPassword": "Novo contrasinal", - "token": "Token", - "lastAccessAt": "Último acceso", - "libraries": "Bibliotecas", - "scrobbleFilter": "Filtro para scrobble" - }, - "helperTexts": { - "name": "Os cambios no nome aplicaranse a próxima vez que accedas", - "libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto", - "scrobbleFilter": "As cancións que concorden coas regras desta lista intelixente non se envían a Last.fm, ListenBrainz ou complementos similares. O filtro usa a mesma sintaxe JSON e comportamento que as listas de reprodución intelixentes. Exemplo: {\"all\":[{\"lt\":{\"rating\":4}}]}. Deixar baleiro para enviar todo. Non lle afecta ao número de reproducións locais." - }, - "notifications": { - "created": "Creouse a usuaria", - "updated": "Actualizouse a usuaria", - "deleted": "Eliminouse a usuaria" - }, - "message": { - "listenBrainzToken": "Escribe o token de usuaria de ListenBrainz", - "clickHereForToken": "Preme aquí para obter o token", - "selectAllLibraries": "Seleccionar todas as bibliotecas", - "adminAutoLibraries": "As usuarias Admin teñen acceso por defecto a todas as bibliotecas" - }, - "validation": { - "librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins", - "invalidScrobbleFilter": "" - } - }, - "player": { - "name": "Reprodutor |||| Reprodutores", - "fields": { - "name": "Nome", - "transcodingId": "Transcodificación", - "maxBitRate": "Taxa de bit máx.", - "client": "Cliente", - "userName": "Identificador", - "lastSeen": "Último acceso", - "reportRealPath": "Informar de Ruta Real", - "scrobbleEnabled": "Enviar Scrobbles a servizos externos" - } - }, - "transcoding": { - "name": "Transcodificación |||| Transcodificacións", - "fields": { - "name": "Nome", - "targetFormat": "Formato de destino", - "defaultBitRate": "Taxa de bit por defecto", - "command": "Orde" - }, - "choices": { - "noDefaultBitRate": "" - } - }, - "playlist": { - "name": "Lista de reprodución |||| Listas de reprodución", - "fields": { - "name": "Nome", - "duration": "Duración", - "ownerName": "Propiedade", - "public": "Pública", - "updatedAt": "Actualizada o", - "createdAt": "Creada o", - "songCount": "Cancións", - "comment": "Comentario", - "sync": "Autoimportación", - "path": "Importar desde", - "starred": "Favorita" - }, - "actions": { - "selectPlaylist": "Elixe unha lista:", - "addNewPlaylist": "Crear \"%{name}\"", - "export": "Exportar", - "makePublic": "Facela Pública", - "makePrivate": "Facela Privada", - "saveQueue": "Salvar a Cola como Lista de reprodución", - "searchOrCreate": "Buscar listas ou escribe para crear nova…", - "pressEnterToCreate": "Preme Enter para crear nova lista", - "removeFromSelection": "Retirar da selección" - }, - "message": { - "duplicate_song": "Engadir cancións duplicadas", - "song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?", - "noPlaylistsFound": "Sen listas de reprodución", - "noPlaylists": "Sen listas dispoñibles" - } - }, - "radio": { - "name": "Radio |||| Radios", - "fields": { - "name": "Nome", - "streamUrl": "URL do fluxo", - "homePageUrl": "URL da web", - "updatedAt": "Actualizada", - "createdAt": "Creada" - }, - "actions": { - "playNow": "En reprodución" - } - }, - "share": { - "name": "Compartido |||| Compartidos", - "fields": { - "username": "Compartida por", - "url": "URL", - "description": "Descrición", - "contents": "Contidos", - "expiresAt": "Caducidade", - "lastVisitedAt": "Última visitada", - "visitCount": "Visitas", - "format": "Formato", - "maxBitRate": "Taxa de Bit Máx.", - "updatedAt": "Actualizada o", - "createdAt": "Creada o", - "downloadable": "Permitir descargas?" - } - }, - "missing": { - "name": "Falta o ficheiro |||| Faltan os ficheiros", - "fields": { - "path": "Ruta", - "size": "Tamaño", - "updatedAt": "Desapareceu o", - "libraryName": "Biblioteca" - }, - "actions": { - "remove": "Retirar", - "remove_all": "Retirar todo" - }, - "notifications": { - "removed": "Ficheiro(s) faltantes retirados" - }, - "empty": "Sen ficheiros faltantes" - }, - "library": { - "name": "Biblioteca |||| Bibliotecas", - "fields": { - "name": "Nome", - "path": "Ruta", - "remotePath": "Ruta remota", - "lastScanAt": "Último escaneado", - "songCount": "Cancións", - "albumCount": "Álbums", - "artistCount": "Artistas", - "totalSongs": "Cancións", - "totalAlbums": "Álbums", - "totalArtists": "Artistas", - "totalFolders": "Cartafoles", - "totalFiles": "Ficheiros", - "totalMissingFiles": "Ficheiros que faltan", - "totalSize": "Tamaño total", - "totalDuration": "Duración", - "defaultNewUsers": "Por defecto para novas usuarias", - "createdAt": "Creada", - "updatedAt": "Actualizada" - }, - "sections": { - "basic": "Información básica", - "statistics": "Estatísticas" - }, - "actions": { - "scan": "Escanear Biblioteca", - "manageUsers": "Xestionar acceso das usuarias", - "viewDetails": "Ver detalles", - "quickScan": "Escaneado rápido", - "fullScan": "Escaneado completo" - }, - "notifications": { - "created": "Biblioteca creada correctamente", - "updated": "Biblioteca actualizada correctamente", - "deleted": "Biblioteca eliminada correctamente", - "scanStarted": "Comezou o escaneo da biblioteca", - "scanCompleted": "Completouse o escaneado da biblioteca", - "quickScanStarted": "Iniciado o escaneado rápido", - "fullScanStarted": "Iniciado o escaneado completo", - "scanError": "Erro ao escanear. Comproba o rexistro" - }, - "validation": { - "nameRequired": "Requírese un nome para a biblioteca", - "pathRequired": "Requírese unha ruta para a biblioteca", - "pathNotDirectory": "A ruta á biblioteca ten que ser un directorio", - "pathNotFound": "Non se atopa a ruta á biblioteca", - "pathNotAccessible": "A ruta á biblioteca non é accesible", - "pathInvalid": "Ruta non válida á biblioteca" - }, - "messages": { - "deleteConfirm": "Tes certeza de querer eliminar esta biblioteca? Isto eliminará todos os datos asociados e accesos de usuarias.", - "scanInProgress": "Escaneo en progreso…", - "noLibrariesAssigned": "Sen bibliotecas asignadas a esta usuaria" - } - }, - "plugin": { - "name": "Complemento |||| Complementos", - "fields": { - "id": "ID", - "name": "Nome", - "description": "Descrición", - "version": "Versión", - "author": "Autoría", - "website": "Sitio web", - "permissions": "Permisos", - "enabled": "Activado", - "status": "Estado", - "path": "Ruta", - "lastError": "Erro", - "hasError": "Erro", - "updatedAt": "Actualizado", - "createdAt": "Instalado", - "configKey": "Clave", - "configValue": "Valor", - "allUsers": "Para todas as usuarias", - "selectedUsers": "Usuarias seleccionadas", - "allLibraries": "Permitir todas as bibliotecas", - "selectedLibraries": "Selecciona bibliotecas", - "allowWriteAccess": "Conceder acceso de escritura" - }, - "sections": { - "status": "Estado", - "info": "Info do complemento", - "configuration": "Configuración", - "manifest": "Manifesto", - "usersPermission": "Permiso sobre usuarias", - "libraryPermission": "Permiso sobre bibliotecas" - }, - "status": { - "enabled": "Activado", - "disabled": "Desactivado" - }, - "actions": { - "enable": "Activar", - "disable": "Desactivar", - "disabledDueToError": "Arranxar erro antes de activar", - "disabledUsersRequired": "Selección de usuarias antes de activar", - "disabledLibrariesRequired": "Selección de bibliotecas antes de activar", - "addConfig": "Engadir configuración", - "rescan": "Volver a escanear" - }, - "notifications": { - "enabled": "Complemento activado", - "disabled": "Complemento desactivado", - "updated": "Complemento actualizado", - "error": "Erro ao actualizar o complemento" - }, - "validation": { - "invalidJson": "A configuración debe ser un JSON válido" - }, - "messages": { - "configHelp": "Configura o complemento usando pares clave-valor. Deixa baleiro se o complemento non require configuración.", - "clickPermissions": "Preme nun permiso para ver detalles", - "noConfig": "Sen configuración establecida", - "allUsersHelp": "Ao activalo, o complemento terá acceso a todas as usuarias, incluíndo aquelas que se creen no futuro.", - "noUsers": "Sen usuarias seleccionadas", - "permissionReason": "Motivo", - "usersRequired": "O complemento precisa acceso á información sobre a usuaria. Selecciona as usuarias ás que pode acceder, ou activa 'Todas as usuarias'.", - "allLibrariesHelp": "Ao activalo, o complemento terá acceso a todas as bibliotecas, incluíndo aquelas que se creen no futuro.", - "noLibraries": "Sen bibliotecas seleccionadas", - "librariesRequired": "O complemento precisa acceso á información sobre a biblioteca. Selecciona as bibliotecas ás que pode acceder, ou activa 'Todas as bibliotecas'.", - "requiredHosts": "Servidores requeridos", - "configValidationError": "Fallou a comprobación da configuración:", - "schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido.", - "allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura.", - "idHelp": "O ID do complemento, derivado do seu nome de ficheiro. Utilízao cando te refiras ao complemento nas opcións de configuración, como en Agents." - }, - "placeholders": { - "configKey": "clave", - "configValue": "valor" - } - } - }, - "ra": { - "auth": { - "welcome1": "Grazas por instalar Navidrome!", - "welcome2": "Para comezar, crea a conta para administración", - "confirmPassword": "Confirmar contrasinal", - "buttonCreateAdmin": "Crear Admin", - "auth_check_error": "Accede para continuar", - "user_menu": "Perfil", - "username": "Identificador", - "password": "Contrasinal", - "sign_in": "Accede", - "sign_in_error": "Fallou a autenticación, volve intentalo", - "logout": "Pechar sesión", - "insightsCollectionNote": "Navidrome recolle datos anónimos de uso para mellorar o proxecto. Peme [aquí] para saber máis e desactivar se queres" - }, - "validation": { - "invalidChars": "Utiliza só letras e números", - "passwordDoesNotMatch": "Os contrasinais non concordan", - "required": "Requerido", - "minLength": "Ten que ter %{min} caracteres como mínimo", - "maxLength": "Ten que ter %{max} caracteres ou menos", - "minValue": "Ten que ter polo menos %{min}", - "maxValue": "Ten que ter %{max} ou menos", - "number": "Ten que ser un número", - "email": "Ten que ser un correo válido", - "oneOf": "Ten que ser un de: %{options}", - "regex": "Ten que ter un formato específico (regexp): %{pattern}", - "unique": "Ten que ser único", - "url": "Ten que ser un URL válido" - }, - "action": { - "add_filter": "Engadir filtro", - "add": "Engadir", - "back": "Atrás", - "bulk_actions": "1 elemento seleccionado |||| %{smart_count} elementos seleccionados", - "cancel": "Cancelar", - "clear_input_value": "Limpar valor", - "clone": "Clonar", - "confirm": "Confirmar", - "create": "Crear", - "delete": "Eliminar", - "edit": "Editar", - "export": "Exportar", - "list": "Lista", - "refresh": "Actualizar", - "remove_filter": "Eliminar este filtro", - "remove": "Eliminar", - "save": "Gardar", - "search": "Buscar", - "show": "Mostrar", - "sort": "Orde", - "undo": "Desfacer", - "expand": "Despregar", - "close": "Pechar", - "open_menu": "Abrir menú", - "close_menu": "Pechar menú", - "unselect": "Deseleccionar", - "skip": "Omitir", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Compartir", - "download": "Descargar" - }, - "boolean": { - "true": "Si", - "false": "Non" - }, - "page": { - "create": "Crear %{name}", - "dashboard": "Taboleiro", - "edit": "%{name} #%{id}", - "error": "Algo fallou", - "list": "%{name}", - "loading": "Cargando", - "not_found": "Non atopado", - "show": "%{name} #%{id}", - "empty": "Aínda non hai %{name}.", - "invite": "Queres engadir unha?" - }, - "input": { - "file": { - "upload_several": "Solta aquí algún ficheiro para subilo, ou preme para selección.", - "upload_single": "Solta aquí un ficheiro para subilo, ou preme para seleccionalo." - }, - "image": { - "upload_several": "Solta aquí algunhas imaxes para subir, ou preme para seleccionar.", - "upload_single": "Solta unha imaxe para subila, ou preme para seleccionala." - }, - "references": { - "all_missing": "Non se atopan datos de referencia.", - "many_missing": "Semella que unha das referencias asociadas xa non está dispoñible.", - "single_missing": "A referencia asociada semella que xa non está dispoñible." - }, - "password": { - "toggle_visible": "Agochar contrasinal", - "toggle_hidden": "Mostrar contrasinal" - } + "ra": { + "auth": { + "welcome1": "Grazas por instalar Navidrome!", + "welcome2": "Para comezar, crea a conta para administración", + "confirmPassword": "Confirmar contrasinal", + "buttonCreateAdmin": "Crear Admin", + "auth_check_error": "Accede para continuar", + "user_menu": "Perfil", + "username": "Identificador", + "password": "Contrasinal", + "sign_in": "Accede", + "sign_in_error": "Fallou a autenticación, volve intentalo", + "logout": "Pechar sesión" + }, + "validation": { + "invalidChars": "Utiliza só letras e números", + "passwordDoesNotMatch": "Os contrasinais non concordan", + "required": "Requerido", + "minLength": "Ten que ter %{min} caracteres como mínimo", + "maxLength": "Ten que ter %{max} caracteres ou menos", + "minValue": "Ten que ter polo menos %{min}", + "maxValue": "Ten que ter %{max} ou menos", + "number": "Ten que ser un número", + "email": "Ten que ser un email válido", + "oneOf": "Ten que ser un de: %{options}", + "regex": "Ten que ter un formato específico (regexp): %{pattern}", + "unique": "Ten que ser único", + "url": "Ten que ser un URL válido" + }, + "action": { + "add_filter": "Engadir filtro", + "add": "Engadir", + "back": "Atrás", + "bulk_actions": "1 elemento seleccionado |||| %{smart_count} elementos seleccionados", + "cancel": "Cancelar", + "clear_input_value": "Limpar valor", + "clone": "Clonar", + "confirm": "Confirmar", + "create": "Crear", + "delete": "Eliminar", + "edit": "Editar", + "export": "Exportar", + "list": "Lista", + "refresh": "Actualizar", + "remove_filter": "Eliminar este filtro", + "remove": "Eliminar", + "save": "Gardar", + "search": "Buscar", + "show": "Mostrar", + "sort": "Orde", + "undo": "Desfacer", + "expand": "Despregar", + "close": "Pechar", + "open_menu": "Abrir menú", + "close_menu": "Pechar menú", + "unselect": "Deseleccionar", + "skip": "Omitir", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Compartir", + "download": "Descargar" + }, + "boolean": { + "true": "Si", + "false": "Non" + }, + "page": { + "create": "Crear %{name}", + "dashboard": "Taboleiro", + "edit": "%{name} #%{id}", + "error": "Algo fallou", + "list": "%{name}", + "loading": "Cargando", + "not_found": "Non atopado", + "show": "%{name} #%{id}", + "empty": "Aínda non hai %{name}.", + "invite": "Queres engadir unha?" + }, + "input": { + "file": { + "upload_several": "Solta aquí algún ficheiro para subilo, ou preme para selección.", + "upload_single": "Solta aquí un ficheiro para subilo, ou preme para seleccionalo." + }, + "image": { + "upload_several": "Solta aquí algunhas imaxes para subir, ou preme para seleccionar.", + "upload_single": "Solta unha imaxe para subila, ou preme para seleccionala." + }, + "references": { + "all_missing": "Non se atopan datos de referencia.", + "many_missing": "Semella que unha das referencias asociadas xa non está dispoñible.", + "single_missing": "A referencia asociada semella que xa non está dispoñible." + }, + "password": { + "toggle_visible": "Agochar contrasinal", + "toggle_hidden": "Mostrar contrasinal" + } + }, + "message": { + "about": "Acerca de", + "are_you_sure": "Tes certeza?", + "bulk_delete_content": "Tes a certeza de querer borrar a %{name} |||| Tes a certeza de querer eleminar estes %{smart_count} elementos?", + "bulk_delete_title": "Eliminar %{name} |||| Eliminar %{smart_count} %{name}", + "delete_content": "Tes a certeza de querer eliminar este elemento?", + "delete_title": "Eliminar %{name} #%{id}", + "details": "Detalles", + "error": "Houbo un erro no cliente e a solicitude non se puido completar.", + "invalid_form": "O formulario non é válido. Comproba os erros", + "loading": "A páxina está cargando, agarda un momento", + "no": "Non", + "not_found": "Ou ben escribiches un URL incorrecto ou ben seguiches unha ligazón non válida.", + "yes": "Si", + "unsaved_changes": "Algún dos cambios non foi gardado. Tes a certeza de querer ignoralos?" + }, + "navigation": { + "no_results": "No hai resultados", + "no_more_results": "O número de páxina %{page} supera os límites. Inténtao coa páxina anterior.", + "page_out_of_boundaries": "O número de páxina %{page} supera o límite", + "page_out_from_end": "Non se pode ir máis alá la última páxina", + "page_out_from_begin": "Non se pode ir a antes da páxina 1", + "page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}", + "page_rows_per_page": "Elementos por páxina:", + "next": "Seguinte", + "prev": "Anterior", + "skip_nav": "Omitir contido" + }, + "notification": { + "updated": "Elemento actualizado |||| %{smart_count} elementos actualizados", + "created": "Creouse o elemento", + "deleted": "Elemento eliminado |||| %{smart_count} elementos eliminados", + "bad_item": "O elemento non é correcto", + "item_doesnt_exist": "O elemento non existe", + "http_error": "Erro de comunicación co servidor", + "data_provider_error": "Erro dataProvider. Mira na consola para ver detalles.", + "i18n_error": "Non se puido cargar a tradución do idioma indicado", + "canceled": "Acción cancelada", + "logged_out": "Rematou a túa sesión, volve a acceder.", + "new_version": "Nova versión dispoñible! Actualiza esta ventá." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Columnas a mostrar", + "layout": "Disposición", + "grid": "Grella", + "table": "Táboa" + } }, "message": { - "about": "Sobre", - "are_you_sure": "Tes certeza?", - "bulk_delete_content": "Tes a certeza de querer borrar a %{name} |||| Tes a certeza de querer eleminar estes %{smart_count} elementos?", - "bulk_delete_title": "Eliminar %{name} |||| Eliminar %{smart_count} %{name}", - "delete_content": "Tes a certeza de querer eliminar este elemento?", - "delete_title": "Eliminar %{name} #%{id}", - "details": "Detalles", - "error": "Houbo un erro no cliente e a solicitude non se puido completar.", - "invalid_form": "O formulario non é válido. Comproba os erros", - "loading": "A páxina está cargando, agarda un momento", - "no": "Non", - "not_found": "Ou ben escribiches un URL incorrecto ou ben seguiches unha ligazón non válida.", - "yes": "Si", - "unsaved_changes": "Algún dos cambios non foi gardado. Tes a certeza de querer ignoralos?" - }, - "navigation": { - "no_results": "No hai resultados", - "no_more_results": "O número de páxina %{page} supera os límites. Inténtao coa páxina anterior.", - "page_out_of_boundaries": "O número de páxina %{page} supera o límite", - "page_out_from_end": "Non se pode ir máis alá la última páxina", - "page_out_from_begin": "Non se pode ir a antes da páxina 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}", - "page_rows_per_page": "Elementos por páxina:", - "next": "Seguinte", - "prev": "Anterior", - "skip_nav": "Omitir contido" - }, - "notification": { - "updated": "Elemento actualizado |||| %{smart_count} elementos actualizados", - "created": "Creouse o elemento", - "deleted": "Elemento eliminado |||| %{smart_count} elementos eliminados", - "bad_item": "O elemento non é correcto", - "item_doesnt_exist": "O elemento non existe", - "http_error": "Erro de comunicación co servidor", - "data_provider_error": "Erro dataProvider. Mira na consola para ver detalles.", - "i18n_error": "Non se puido cargar a tradución do idioma indicado", - "canceled": "Acción cancelada", - "logged_out": "Rematou a túa sesión, volve a acceder.", - "new_version": "Nova versión dispoñible! Actualiza esta ventá." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Columnas a mostrar", - "layout": "Disposición", - "grid": "Grella", - "table": "Táboa" - } - }, - "message": { - "note": "NOTA", - "transcodingDisabled": "Desactivouse o cambio da configuración da transcodificación usando a interface web por razóns de seguridade. Se queres cambiar (editar ou engadir) opcións de transcodificación, reinicia o servidor coa opción de configuración %{config}", - "transcodingEnabled": "Navidrome está a funcionar con %{config}, polo que é posible executar ordes do sistema desde os axustes de transcodificación usando a interface web. Por razóns de seguridade, recomendamos desactivalo e só activalo cando se configuran as opcións de Transcodificación.", - "songsAddedToPlaylist": "Engadida 1 canción á lista de reprodución |||| Engadidas %{smart_count} cancións á lista de reprodución", - "noPlaylistsAvailable": "Nada dispoñible", - "delete_user_title": "Eliminar usuaria '%{name}'", - "delete_user_content": "Tes a certeza de querer eliminar esta usuaria e todos os seus datos (incluíndo listas e preferencias)?", - "notifications_blocked": "Tes bloqueadas as Notificacións desta páxina web nos axustes do navegador", - "notifications_not_available": "Este navegador non ten soporte para as notificacións de escritorio ou ben non estás accedendo Navidrome con https", - "lastfmLinkSuccess": "Ligouse Last.fm correctamente e o scrobbling está activado", - "lastfmLinkFailure": "Non se puido ligar Last.fm", - "lastfmUnlinkSuccess": "Desligouse Last.fm e desactivouse o scrobbling", - "lastfmUnlinkFailure": "Non se puido desligar Last.fm", - "openIn": { - "lastfm": "Abrir en Last.fm", - "musicbrainz": "Abrir en MusicBrainz" - }, - "lastfmLink": "Saber máis...", - "listenBrainzLinkSuccess": "Conectouse correctamente con ListenBrainz e activouse o scrobbling para: %{user}", - "listenBrainzLinkFailure": "Non se conectou con ListenBrainz: %{error}", - "listenBrainzUnlinkSuccess": "Desconectouse ListenBrainz e desactivouse o scrobbling", - "listenBrainzUnlinkFailure": "Non se puido desconectar de ListenBrainz", - "downloadOriginalFormat": "Descargar formato orixinal", - "shareOriginalFormat": "Compartir no formato orixinal", - "shareDialogTitle": "Compartir %{resource} '%{name}'", - "shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}", - "shareSuccess": "URL copiado ao portapapeis: %{url}", - "shareFailure": "Erro ao copiar o URL %{url} ao portapapeis", - "downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Copiar ao portapapeis: Ctrl+C, Enter", - "remove_missing_title": "Retirar ficheiros que faltan", - "remove_missing_content": "Tes certeza de querer retirar da base de datos os ficheiros que faltan? Isto retirará de xeito permanente todas a referencias a eles, incluíndo a conta de reproducións e valoracións.", - "remove_all_missing_title": "Retirar todos os ficheiros que faltan", - "remove_all_missing_content": "Tes certeza de querer retirar da base de datos todos os ficheiros que faltan? Isto eliminará todas as referencias a eles, incluíndo o número de reproducións e valoracións.", - "noSimilarSongsFound": "Sen cancións parecidas", - "noTopSongsFound": "Sen cancións destacadas", - "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", - "metadataRefreshStarted": "Actualizar en segundo plano os metadatos" - }, - "menu": { - "library": "Biblioteca", - "settings": "Axustes", - "version": "Versión", - "theme": "Decorado", - "personal": { - "name": "Persoal", - "options": { - "theme": "Decorado", - "language": "Idioma", - "defaultView": "Vista por defecto", - "desktop_notifications": "Notificacións de escritorio", - "lastfmScrobbling": "Scrobble con Last.fm", - "listenBrainzScrobbling": "Scrobble con ListenBrainz", - "replaygain": "Modo ReplayGain", - "preAmp": "PreAmp (dB) de ReplayGain", - "gain": { - "none": "Desactivada", - "album": "Usar ganancia do Álbum", - "track": "Usar ganancia da Canción" + "note": "NOTA", + "transcodingDisabled": "Desactivouse o cambio da configuración da transcodificación usando a interface web por razóns de seguridade. Se queres cambiar (editar ou engadir) opcións de transcodificación, reinicia o servidor coa opción de configuración %{config}", + "transcodingEnabled": "Navidrome está a funcionar con %{config}, polo que é posible executar ordes do sistema desde os axustes de transcodificación usando a interface web. Por razóns de seguridade, recomendamos desactivalo e só activalo cando se configuran as opcións de Transcodificación.", + "songsAddedToPlaylist": "Engadida 1 canción á lista de reprodución |||| Engadidas %{smart_count} cancións á lista de reprodución", + "noPlaylistsAvailable": "Nada dispoñible", + "delete_user_title": "Eliminar usuaria '%{name}'", + "delete_user_content": "Tes a certeza de querer eliminar esta usuaria e todos os seus datos (incluíndo listas e preferencias)?", + "notifications_blocked": "Tes bloqueadas as Notificacións desta páxina web nos axustes do navegador", + "notifications_not_available": "Este navegador non ten soporte para as notificacións de escritorio ou ben non estás accedendo Navidrome con https", + "lastfmLinkSuccess": "Ligouse Last.fm correctamente e o scrobbling está activado", + "lastfmLinkFailure": "Non se puido ligar Last.fm", + "lastfmUnlinkSuccess": "Desligouse Last.fm e desactivouse o scrobbling", + "lastfmUnlinkFailure": "Non se puido desligar Last.fm", + "openIn": { + "lastfm": "Abrir en Last.fm", + "musicbrainz": "Abrir en MusicBrainz" }, - "lastfmNotConfigured": "Clave da API Last.fm non configurada" - } + "lastfmLink": "Saber máis...", + "listenBrainzLinkSuccess": "Conectouse correctamente con ListenBrainz e activouse o scrobbling para: %{user}", + "listenBrainzLinkFailure": "Non se conectou con ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "Desconectouse ListenBrainz e desactivouse o scrobbling", + "listenBrainzUnlinkFailure": "Non se puido desconectar de ListenBrainz", + "downloadOriginalFormat": "Descargar formato orixinal", + "shareOriginalFormat": "Compartir no formato orixinal", + "shareDialogTitle": "Compartir %{resource} '%{name}'", + "shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}", + "shareSuccess": "URL copiado ao portapapeis: %{url}", + "shareFailure": "Erro ao copiar o URL %{url} ao portapapeis", + "downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Copiar ao portapapeis: Ctrl+C, Enter" }, - "albumList": "Álbums", - "about": "Sobre", - "playlists": "Listas de reprodución", - "sharedPlaylists": "Listas compartidas", - "librarySelector": { - "allLibraries": "Todas as bibliotecas (%{count})", - "multipleLibraries": "%{selected} de %{total} Bibliotecas", - "selectLibraries": "Seleccionar Bibliotecas", - "none": "Ningunha" + "menu": { + "library": "Biblioteca", + "settings": "Axustes", + "version": "Versión", + "theme": "Decorado", + "personal": { + "name": "Persoal", + "options": { + "theme": "Decorado", + "language": "Idioma", + "defaultView": "Vista por defecto", + "desktop_notifications": "Notificacións de escritorio", + "lastfmScrobbling": "Scrobble con Last.fm", + "listenBrainzScrobbling": "Scrobble con ListenBrainz", + "replaygain": "Modo ReplayGain", + "preAmp": "PreAmp (dB) de ReplayGain", + "gain": { + "none": "Desactivada", + "album": "Usar ganancia do Álbum", + "track": "Usar ganancia da Canción" + } + } + }, + "albumList": "Álbums", + "about": "Acerca de", + "playlists": "Listas de reprodución", + "sharedPlaylists": "Listas compartidas" }, - "onlyFavourites": "Mostrar só as favoritas" - }, - "player": { - "playListsText": "Reproducir cola", - "openText": "Abrir", - "closeText": "Pechar", - "notContentText": "Sen música", - "clickToPlayText": "Preme para reproducir", - "clickToPauseText": "Preme para deter", - "nextTrackText": "Canción seguinte", - "previousTrackText": "Canción anterior", - "reloadText": "Recargar", - "volumeText": "Volume", - "toggleLyricText": "Activar letras", - "toggleMiniModeText": "Minimizar", - "destroyText": "Destruír", - "downloadText": "Descargar", - "removeAudioListsText": "Eliminar listas de audio", - "clickToDeleteText": "Preme para eliminar %{name}", - "emptyLyricText": "Sen letra", - "playModeText": { - "order": "Na orde", - "orderLoop": "Repetir", - "singleLoop": "Repetir unha", - "shufflePlay": "Barallar" + "player": { + "playListsText": "Reproducir cola", + "openText": "Abrir", + "closeText": "Pechar", + "notContentText": "Sen música", + "clickToPlayText": "Preme para reproducir", + "clickToPauseText": "Preme para deter", + "nextTrackText": "Canción seguinte", + "previousTrackText": "Canción anterior", + "reloadText": "Recargar", + "volumeText": "Volume", + "toggleLyricText": "Activar letras", + "toggleMiniModeText": "Minimizar", + "destroyText": "Destruír", + "downloadText": "Descargar", + "removeAudioListsText": "Eliminar listas de audio", + "clickToDeleteText": "Preme para eliminar %{name}", + "emptyLyricText": "Sen letra", + "playModeText": { + "order": "Na orde", + "orderLoop": "Repetir", + "singleLoop": "Repetir unha", + "shufflePlay": "Barallar" + } + }, + "about": { + "links": { + "homepage": "Inicio", + "source": "Código fonte", + "featureRequests": "Solicitar funcións" + } + }, + "activity": { + "title": "Actividade", + "totalScanned": "Número de cartafoles examinados", + "quickScan": "Escaneo rápido", + "fullScan": "Escaneo completo", + "serverUptime": "Servidor a funcionar", + "serverDown": "SEN CONEXIÓN" + }, + "help": { + "title": "Atallos de Navidrome", + "hotkeys": { + "show_help": "Mostrar esta axuda", + "toggle_menu": "Activar Menú Barra lateral", + "toggle_play": "Reproducir / Deter", + "prev_song": "Canción anterior", + "next_song": "Canción seguinte", + "vol_up": "Máis volume", + "vol_down": "Menos volume", + "toggle_love": "Engadir canción a favoritas", + "current_song": "Ir á Canción actual " + } } - }, - "about": { - "links": { - "homepage": "Inicio", - "source": "Código fonte", - "featureRequests": "Solicitar funcións", - "lastInsightsCollection": "Última colección insights", - "insights": { - "disabled": "Desactivado", - "waiting": "Agardando" - } - }, - "tabs": { - "about": "Sobre", - "config": "Configuración" - }, - "config": { - "configName": "Nome", - "environmentVariable": "Variable de entorno", - "currentValue": "Valor actual", - "configurationFile": "Ficheiro de configuración", - "exportToml": "Exportar configuración (TOML)", - "exportSuccess": "Configuración exportada ao portapapeis no formato TOML", - "exportFailed": "Fallou a copia da configuración", - "devFlagsHeader": "Configuracións de Desenvolvemento (suxeitas a cambio/retirada)", - "devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións", - "downloadToml": "Descargar configuración (TOML)" - } - }, - "activity": { - "title": "Actividade", - "totalScanned": "Número de cartafoles examinados", - "quickScan": "Escaneo rápido", - "fullScan": "Escaneo completo", - "serverUptime": "Servidor a funcionar", - "serverDown": "SEN CONEXIÓN", - "scanType": "Tipo", - "status": "Erro de escaneado", - "elapsedTime": "Tempo transcurrido", - "selectiveScan": "Selectivo" - }, - "help": { - "title": "Atallos de Navidrome", - "hotkeys": { - "show_help": "Mostrar esta axuda", - "toggle_menu": "Activar Menú Barra lateral", - "toggle_play": "Reproducir / Deter", - "prev_song": "Canción anterior", - "next_song": "Canción seguinte", - "vol_up": "Máis volume", - "vol_down": "Menos volume", - "toggle_love": "Engadir canción a favoritas", - "current_song": "Ir á Canción actual " - } - }, - "nowPlaying": { - "title": "En reprodución", - "empty": "Sen reprodución", - "minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos" - } } \ No newline at end of file diff --git a/resources/i18n/hi.json b/resources/i18n/hi.json deleted file mode 100644 index 5b9ece530..000000000 --- a/resources/i18n/hi.json +++ /dev/null @@ -1,630 +0,0 @@ -{ - "languageName": "हिंदी", - "resources": { - "song": { - "name": "गाना |||| गाने", - "fields": { - "albumArtist": "एल्बम कलाकार", - "duration": "समय", - "trackNumber": "#", - "playCount": "प्ले संख्या", - "title": "शीर्षक", - "artist": "कलाकार", - "album": "एल्बम", - "path": "फ़ाइल पथ", - "libraryName": "लाइब्रेरी", - "genre": "शैली", - "compilation": "संकलन", - "year": "वर्ष", - "size": "फ़ाइल का आकार", - "updatedAt": "अपडेट किया गया", - "bitRate": "बिट रेट", - "bitDepth": "बिट गहराई", - "sampleRate": "सैंपल रेट", - "channels": "चैनल", - "discSubtitle": "डिस्क उपशीर्षक", - "starred": "पसंदीदा", - "comment": "टिप्पणी", - "rating": "रेटिंग", - "quality": "गुणवत्ता", - "bpm": "BPM", - "playDate": "अंतिम बार चलाया गया", - "createdAt": "जोड़ने की तारीख", - "grouping": "समूहीकरण", - "mood": "मूड", - "participants": "अतिरिक्त प्रतिभागी", - "tags": "अतिरिक्त टैग", - "mappedTags": "मैप किए गए टैग", - "rawTags": "रॉ टैग", - "missing": "गुम" - }, - "actions": { - "addToQueue": "बाद में चलाएं", - "playNow": "अभी चलाएं", - "addToPlaylist": "प्लेलिस्ट में जोड़ें", - "showInPlaylist": "प्लेलिस्ट में दिखाएं", - "shuffleAll": "सभी को शफल करें", - "download": "डाउनलोड", - "playNext": "अगला चलाएं", - "info": "जानकारी प्राप्त करें" - } - }, - "album": { - "name": "एल्बम |||| एल्बम", - "fields": { - "albumArtist": "एल्बम कलाकार", - "artist": "कलाकार", - "duration": "समय", - "songCount": "गाने", - "playCount": "प्ले संख्या", - "size": "आकार", - "name": "नाम", - "libraryName": "लाइब्रेरी", - "genre": "शैली", - "compilation": "संकलन", - "year": "वर्ष", - "date": "रिकॉर्डिंग की तारीख", - "originalDate": "मूल", - "releaseDate": "रिलीज़", - "releases": "रिलीज़ |||| रिलीज़", - "released": "रिलीज़ किया गया", - "updatedAt": "अपडेट किया गया", - "comment": "टिप्पणी", - "rating": "रेटिंग", - "createdAt": "जोड़ने की तारीख", - "recordLabel": "लेबल", - "catalogNum": "कैटलॉग नंबर", - "releaseType": "प्रकार", - "grouping": "समूहीकरण", - "media": "मीडिया", - "mood": "मूड", - "missing": "गुम" - }, - "actions": { - "playAll": "चलाएं", - "playNext": "अगला चलाएं", - "addToQueue": "बाद में चलाएं", - "share": "साझा करें", - "shuffle": "शफल", - "addToPlaylist": "प्लेलिस्ट में जोड़ें", - "download": "डाउनलोड", - "info": "जानकारी प्राप्त करें" - }, - "lists": { - "all": "सभी", - "random": "रैंडम", - "recentlyAdded": "हाल ही में जोड़े गए", - "recentlyPlayed": "हाल ही में चलाए गए", - "mostPlayed": "सबसे ज्यादा चलाए गए", - "starred": "पसंदीदा", - "topRated": "टॉप रेटेड" - } - }, - "artist": { - "name": "कलाकार |||| कलाकार", - "fields": { - "name": "नाम", - "albumCount": "एल्बम की संख्या", - "songCount": "गानों की संख्या", - "size": "आकार", - "playCount": "प्ले संख्या", - "rating": "रेटिंग", - "genre": "शैली", - "role": "भूमिका", - "missing": "गुम" - }, - "roles": { - "albumartist": "एल्बम कलाकार |||| एल्बम कलाकार", - "artist": "कलाकार |||| कलाकार", - "composer": "संगीतकार |||| संगीतकार", - "conductor": "संचालक |||| संचालक", - "lyricist": "गीतकार |||| गीतकार", - "arranger": "संयोजक |||| संयोजक", - "producer": "निर्माता |||| निर्माता", - "director": "निदेशक |||| निदेशक", - "engineer": "इंजीनियर |||| इंजीनियर", - "mixer": "मिक्सर |||| मिक्सर", - "remixer": "रीमिक्सर |||| रीमिक्सर", - "djmixer": "डीजे मिक्सर |||| डीजे मिक्सर", - "performer": "कलाकार |||| कलाकार", - "maincredit": "एल्बम कलाकार या कलाकार |||| एल्बम कलाकार या कलाकार" - }, - "actions": { - "topSongs": "टॉप गाने", - "shuffle": "शफल", - "radio": "रेडियो" - } - }, - "user": { - "name": "उपयोगकर्ता |||| उपयोगकर्ता", - "fields": { - "userName": "उपयोगकर्ता नाम", - "isAdmin": "एडमिन है", - "lastLoginAt": "अंतिम लॉगिन", - "lastAccessAt": "अंतिम पहुंच", - "updatedAt": "अपडेट किया गया", - "name": "नाम", - "password": "पासवर्ड", - "createdAt": "बनाया गया", - "changePassword": "पासवर्ड बदलें?", - "currentPassword": "वर्तमान पासवर्ड", - "newPassword": "नया पासवर्ड", - "token": "टोकन", - "libraries": "लाइब्रेरी" - }, - "helperTexts": { - "name": "आपके नाम में परिवर्तन केवल अगली लॉगिन पर प्रभावी होगा", - "libraries": "इस उपयोगकर्ता के लिए विशिष्ट लाइब्रेरी चुनें, या डिफ़ॉल्ट लाइब्रेरी का उपयोग करने के लिए खाली छोड़ें" - }, - "notifications": { - "created": "उपयोगकर्ता बनाया गया", - "updated": "उपयोगकर्ता अपडेट किया गया", - "deleted": "उपयोगकर्ता हटाया गया" - }, - "validation": { - "librariesRequired": "गैर-एडमिन उपयोगकर्ताओं के लिए कम से कम एक लाइब्रेरी चुननी होगी" - }, - "message": { - "listenBrainzToken": "अपना ListenBrainz उपयोगकर्ता टोकन दर्ज करें।", - "clickHereForToken": "अपना टोकन प्राप्त करने के लिए यहां क्लिक करें", - "selectAllLibraries": "सभी लाइब्रेरी चुनें", - "adminAutoLibraries": "एडमिन उपयोगकर्ताओं की सभी लाइब्रेरी तक स्वचालित पहुंच है" - } - }, - "player": { - "name": "प्लेयर |||| प्लेयर", - "fields": { - "name": "नाम", - "transcodingId": "ट्रांसकोडिंग", - "maxBitRate": "अधिकतम बिट रेट", - "client": "क्लाइंट", - "userName": "उपयोगकर्ता नाम", - "lastSeen": "अंतिम बार देखा गया", - "reportRealPath": "वास्तविक पथ रिपोर्ट करें", - "scrobbleEnabled": "बाहरी सेवाओं को स्क्रॉबल भेजें" - } - }, - "transcoding": { - "name": "ट्रांसकोडिंग |||| ट्रांसकोडिंग", - "fields": { - "name": "नाम", - "targetFormat": "लक्ष्य प्रारूप", - "defaultBitRate": "डिफ़ॉल्ट बिट रेट", - "command": "कमांड" - } - }, - "playlist": { - "name": "प्लेलिस्ट |||| प्लेलिस्ट", - "fields": { - "name": "नाम", - "duration": "अवधि", - "ownerName": "मालिक", - "public": "सार्वजनिक", - "updatedAt": "अपडेट किया गया", - "createdAt": "बनाया गया", - "songCount": "गाने", - "comment": "टिप्पणी", - "sync": "ऑटो-इंपोर्ट", - "path": "से इंपोर्ट करें" - }, - "actions": { - "selectPlaylist": "एक प्लेलिस्ट चुनें:", - "addNewPlaylist": "\"%{name}\" बनाएं", - "export": "निर्यात", - "saveQueue": "क्यू को प्लेलिस्ट में सेव करें", - "makePublic": "सार्वजनिक बनाएं", - "makePrivate": "निजी बनाएं", - "searchOrCreate": "प्लेलिस्ट खोजें या नई बनाने के लिए टाइप करें...", - "pressEnterToCreate": "नई प्लेलिस्ट बनाने के लिए Enter दबाएं", - "removeFromSelection": "चयन से हटाएं" - }, - "message": { - "duplicate_song": "डुप्लिकेट गाने जोड़ें", - "song_exist": "प्लेलिस्ट में डुप्लिकेट जोड़े जा रहे हैं। क्या आप डुप्लिकेट जोड़ना चाहते हैं या उन्हें छोड़ना चाहते हैं?", - "noPlaylistsFound": "कोई प्लेलिस्ट नहीं मिली", - "noPlaylists": "कोई प्लेलिस्ट उपलब्ध नहीं" - } - }, - "radio": { - "name": "रेडियो |||| रेडियो", - "fields": { - "name": "नाम", - "streamUrl": "स्ट्रीम URL", - "homePageUrl": "होम पेज URL", - "updatedAt": "अपडेट किया गया", - "createdAt": "बनाया गया" - }, - "actions": { - "playNow": "अभी चलाएं" - } - }, - "share": { - "name": "साझा |||| साझा", - "fields": { - "username": "द्वारा साझा किया गया", - "url": "URL", - "description": "विवरण", - "downloadable": "डाउनलोड की अनुमति दें?", - "contents": "सामग्री", - "expiresAt": "समाप्त होता है", - "lastVisitedAt": "अंतिम बार देखा गया", - "visitCount": "विज़िट", - "format": "प्रारूप", - "maxBitRate": "अधिकतम बिट रेट", - "updatedAt": "अपडेट किया गया", - "createdAt": "बनाया गया" - }, - "notifications": {}, - "actions": {} - }, - "missing": { - "name": "गुम फ़ाइल |||| गुम फ़ाइलें", - "empty": "कोई गुम फ़ाइल नहीं", - "fields": { - "path": "पथ", - "size": "आकार", - "libraryName": "लाइब्रेरी", - "updatedAt": "गायब हुई" - }, - "actions": { - "remove": "हटाएं", - "remove_all": "सभी हटाएं" - }, - "notifications": { - "removed": "गुम फ़ाइल(एं) हटा दी गईं" - } - }, - "library": { - "name": "लाइब्रेरी |||| लाइब्रेरी", - "fields": { - "name": "नाम", - "path": "पथ", - "remotePath": "रिमोट पथ", - "lastScanAt": "अंतिम स्कैन", - "songCount": "गाने", - "albumCount": "एल्बम", - "artistCount": "कलाकार", - "totalSongs": "गाने", - "totalAlbums": "एल्बम", - "totalArtists": "कलाकार", - "totalFolders": "फ़ोल्डर", - "totalFiles": "फ़ाइलें", - "totalMissingFiles": "गुम फ़ाइलें", - "totalSize": "कुल आकार", - "totalDuration": "अवधि", - "defaultNewUsers": "नए उपयोगकर्ताओं के लिए डिफ़ॉल्ट", - "createdAt": "बनाया गया", - "updatedAt": "अपडेट किया गया" - }, - "sections": { - "basic": "बुनियादी जानकारी", - "statistics": "आंकड़े" - }, - "actions": { - "scan": "लाइब्रेरी स्कैन करें", - "manageUsers": "उपयोगकर्ता पहुंच प्रबंधित करें", - "viewDetails": "विवरण देखें" - }, - "notifications": { - "created": "लाइब्रेरी सफलतापूर्वक बनाई गई", - "updated": "लाइब्रेरी सफलतापूर्वक अपडेट की गई", - "deleted": "लाइब्रेरी सफलतापूर्वक हटाई गई", - "scanStarted": "लाइब्रेरी स्कैन शुरू किया गया", - "scanCompleted": "लाइब्रेरी स्कैन पूरा हुआ" - }, - "validation": { - "nameRequired": "लाइब्रेरी का नाम आवश्यक है", - "pathRequired": "लाइब्रेरी पथ आवश्यक है", - "pathNotDirectory": "लाइब्रेरी पथ एक डायरेक्टरी होना चाहिए", - "pathNotFound": "लाइब्रेरी पथ नहीं मिला", - "pathNotAccessible": "लाइब्रेरी पथ पहुंच योग्य नहीं है", - "pathInvalid": "अमान्य लाइब्रेरी पथ" - }, - "messages": { - "deleteConfirm": "क्या आप वाकई इस लाइब्रेरी को हटाना चाहते हैं? इससे सभी संबंधित डेटा और उपयोगकर्ता पहुंच हट जाएगी।", - "scanInProgress": "स्कैन चल रहा है...", - "noLibrariesAssigned": "इस उपयोगकर्ता को कोई लाइब्रेरी असाइन नहीं की गई" - } - } - }, - "ra": { - "auth": { - "welcome1": "Navidrome इंस्टॉल करने के लिए धन्यवाद!", - "welcome2": "शुरू करने के लिए, एक एडमिन उपयोगकर्ता बनाएं", - "confirmPassword": "पासवर्ड की पुष्टि करें", - "buttonCreateAdmin": "एडमिन बनाएं", - "auth_check_error": "जारी रखने के लिए कृपया लॉगिन करें", - "user_menu": "प्रोफ़ाइल", - "username": "उपयोगकर्ता नाम", - "password": "पासवर्ड", - "sign_in": "साइन इन", - "sign_in_error": "प्रमाणीकरण विफल, कृपया पुनः प्रयास करें", - "logout": "लॉगआउट", - "insightsCollectionNote": "Navidrome परियोजना को बेहतर बनाने में मदद के लिए\nअज्ञात उपयोग डेटा एकत्र करता है। अधिक जानने\nऔर चाहें तो ऑप्ट-आउट करने के लिए [यहां] क्लिक करें" - }, - "validation": { - "invalidChars": "कृपया केवल अक्षर और संख्याओं का उपयोग करें।", - "passwordDoesNotMatch": "पासवर्ड मेल नहीं खाता।", - "required": "आवश्यक", - "minLength": "कम से कम %{min} अक्षर होने चाहिए", - "maxLength": "%{max} अक्षर या उससे कम होने चाहिए", - "minValue": "कम से कम %{min} होना चाहिए", - "maxValue": "%{max} या उससे कम होना चाहिए", - "number": "एक संख्या होनी चाहिए", - "email": "एक वैध ईमेल होना चाहिए", - "oneOf": "इनमें से एक होना चाहिए: %{options}", - "regex": "एक विशिष्ट प्रारूप से मेल खाना चाहिए (regexp): %{pattern}", - "unique": "अद्वितीय होना चाहिए", - "url": "एक वैध URL होना चाहिए" - }, - "action": { - "add_filter": "फ़िल्टर जोड़ें", - "add": "जोड़ें", - "back": "वापस जाएं", - "bulk_actions": "1 आइटम चुना गया |||| %{smart_count} आइटम चुने गए", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "cancel": "रद्द करें", - "clear_input_value": "मान साफ़ करें", - "clone": "क्लोन", - "confirm": "पुष्टि करें", - "create": "बनाएं", - "delete": "हटाएं", - "edit": "संपादित करें", - "export": "निर्यात", - "list": "सूची", - "refresh": "रीफ्रेश", - "remove_filter": "इस फ़िल्टर को हटाएं", - "remove": "हटाएं", - "save": "सेव करें", - "search": "खोजें", - "show": "दिखाएं", - "sort": "क्रमबद्ध करें", - "undo": "पूर्ववत करें", - "expand": "विस्तार करें", - "close": "बंद करें", - "open_menu": "मेनू खोलें", - "close_menu": "मेनू बंद करें", - "unselect": "चयन हटाएं", - "skip": "छोड़ें", - "share": "साझा करें", - "download": "डाउनलोड" - }, - "boolean": { - "true": "हां", - "false": "नहीं" - }, - "page": { - "create": "%{name} बनाएं", - "dashboard": "डैशबोर्ड", - "edit": "%{name} #%{id}", - "error": "कुछ गलत हुआ", - "list": "%{name}", - "loading": "लोड हो रहा है", - "not_found": "नहीं मिला", - "show": "%{name} #%{id}", - "empty": "अभी तक कोई %{name} नहीं।", - "invite": "क्या आप एक जोड़ना चाहते हैं?" - }, - "input": { - "file": { - "upload_several": "अपलोड करने के लिए कुछ फ़ाइलें छोड़ें, या चुनने के लिए क्लिक करें।", - "upload_single": "अपलोड करने के लिए एक फ़ाइल छोड़ें, या इसे चुनने के लिए क्लिक करें।" - }, - "image": { - "upload_several": "अपलोड करने के लिए कुछ तस्वीरें छोड़ें, या चुनने के लिए क्लिक करें।", - "upload_single": "अपलोड करने के लिए एक तस्वीर छोड़ें, या इसे चुनने के लिए क्लिक करें।" - }, - "references": { - "all_missing": "संदर्भ डेटा खोजने में असमर्थ।", - "many_missing": "संबंधित संदर्भों में से कम से कम एक अब उपलब्ध नहीं लगता।", - "single_missing": "संबंधित संदर्भ अब उपलब्ध नहीं लगता।" - }, - "password": { - "toggle_visible": "पासवर्ड छुपाएं", - "toggle_hidden": "पासवर्ड दिखाएं" - } - }, - "message": { - "about": "के बारे में", - "are_you_sure": "क्या आप सुनिश्चित हैं?", - "bulk_delete_content": "क्या आप वाकई इस %{name} को हटाना चाहते हैं? |||| क्या आप वाकई इन %{smart_count} आइटमों को हटाना चाहते हैं?", - "bulk_delete_title": "%{name} हटाएं |||| %{smart_count} %{name} हटाएं", - "delete_content": "क्या आप वाकई इस आइटम को हटाना चाहते हैं?", - "delete_title": "%{name} #%{id} हटाएं", - "details": "विवरण", - "error": "एक क्लाइंट त्रुटि हुई और आपका अनुरोध पूरा नहीं हो सका।", - "invalid_form": "फॉर्म मान्य नहीं है। कृपया त्रुटियों की जांच करें।", - "loading": "पेज लोड हो रहा है, कृपया एक क्षण प्रतीक्षा करें", - "no": "नहीं", - "not_found": "या तो आपने गलत URL टाइप किया है, या आपने गलत लिंक फॉलो किया है।", - "yes": "हां", - "unsaved_changes": "आपके कुछ बदलाव सेव नहीं हुए। क्या आप वाकई उन्हें नज़रअंदाज़ करना चाहते हैं?" - }, - "navigation": { - "no_results": "कोई परिणाम नहीं मिला", - "no_more_results": "पेज नंबर %{page} सीमा से बाहर है। पिछले पेज को आज़माएं।", - "page_out_of_boundaries": "पेज नंबर %{page} सीमा से बाहर", - "page_out_from_end": "अंतिम पेज के बाद नहीं जा सकते", - "page_out_from_begin": "पेज 1 से पहले नहीं जा सकते", - "page_range_info": "%{total} में से %{offsetBegin}-%{offsetEnd}", - "page_rows_per_page": "प्रति पेज आइटम:", - "next": "अगला", - "prev": "पिछला", - "skip_nav": "सामग्री पर जाएं" - }, - "notification": { - "updated": "एलिमेंट अपडेट किया गया |||| %{smart_count} एलिमेंट अपडेट किए गए", - "created": "एलिमेंट बनाया गया", - "deleted": "एलिमेंट हटाया गया |||| %{smart_count} एलिमेंट हटाए गए", - "bad_item": "गलत एलिमेंट", - "item_doesnt_exist": "एलिमेंट मौजूद नहीं है", - "http_error": "सर्वर संचार त्रुटि", - "data_provider_error": "dataProvider त्रुटि। विवरण के लिए कंसोल जांचें।", - "i18n_error": "निर्दिष्ट भाषा के लिए अनुवाद लोड नहीं हो सकते", - "canceled": "कार्रवाई रद्द की गई", - "logged_out": "आपका सत्र समाप्त हो गया है, कृपया फिर से कनेक्ट करें।", - "new_version": "नया संस्करण उपलब्ध! कृपया इस विंडो को रीफ्रेश करें।" - }, - "toggleFieldsMenu": { - "columnsToDisplay": "प्रदर्शित करने वाले कॉलम", - "layout": "लेआउट", - "grid": "ग्रिड", - "table": "टेबल" - } - }, - "message": { - "note": "नोट", - "transcodingDisabled": "सुरक्षा कारणों से वेब इंटरफेस के माध्यम से ट्रांसकोडिंग कॉन्फ़िगरेशन बदलना अक्षम है। यदि आप ट्रांसकोडिंग विकल्प बदलना (संपादित या जोड़ना) चाहते हैं, तो %{config} कॉन्फ़िगरेशन विकल्प के साथ सर्वर को पुनः आरंभ करें।", - "transcodingEnabled": "Navidrome वर्तमान में %{config} के साथ चल रहा है, जो वेब इंटरफेस का उपयोग करके ट्रांसकोडिंग सेटिंग्स से सिस्टम कमांड चलाना संभव बनाता है। हम सुरक्षा कारणों से इसे अक्षम करने और केवल ट्रांसकोडिंग विकल्प कॉन्फ़िगर करते समय इसे सक्षम करने की सलाह देते हैं।", - "songsAddedToPlaylist": "प्लेलिस्ट में 1 गाना जोड़ा गया |||| प्लेलिस्ट में %{smart_count} गाने जोड़े गए", - "noSimilarSongsFound": "कोई समान गाने नहीं मिले", - "noTopSongsFound": "कोई टॉप गाने नहीं मिले", - "noPlaylistsAvailable": "कोई उपलब्ध नहीं", - "delete_user_title": "उपयोगकर्ता '%{name}' को हटाएं", - "delete_user_content": "क्या आप वाकई इस उपयोगकर्ता और उनके सभी डेटा (प्लेलिस्ट और प्राथमिकताओं सहित) को हटाना चाहते हैं?", - "remove_missing_title": "गुम फ़ाइलें हटाएं", - "remove_missing_content": "क्या आप वाकई चयनित गुम फ़ाइलों को डेटाबेस से हटाना चाहते हैं? इससे उनके सभी संदर्भ स्थायी रूप से हट जाएंगे, जिसमें उनकी प्ले काउंट और रेटिंग शामिल है।", - "remove_all_missing_title": "सभी गुम फ़ाइलें हटाएं", - "remove_all_missing_content": "क्या आप वाकई सभी गुम फ़ाइलों को डेटाबेस से हटाना चाहते हैं? इससे उनके सभी संदर्भ स्थायी रूप से हट जाएंगे, जिसमें उनकी प्ले काउंट और रेटिंग शामिल है।", - "notifications_blocked": "आपने अपने ब्राउज़र की सेटिंग्स में इस साइट के लिए सूचनाएं ब्लॉक की हैं।", - "notifications_not_available": "यह ब्राउज़र डेस्कटॉप सूचनाओं का समर्थन नहीं करता या आप https पर Navidrome का उपयोग नहीं कर रहे।", - "lastfmLinkSuccess": "Last.fm सफलतापूर्वक लिंक किया गया और स्क्रॉबलिंग सक्षम की गई", - "lastfmLinkFailure": "Last.fm लिंक नहीं हो सका", - "lastfmUnlinkSuccess": "Last.fm अनलिंक किया गया और स्क्रॉबलिंग अक्षम की गई", - "lastfmUnlinkFailure": "Last.fm अनलिंक नहीं हो सका", - "listenBrainzLinkSuccess": "ListenBrainz सफलतापूर्वक लिंक किया गया और उपयोगकर्ता के रूप में स्क्रॉबलिंग सक्षम की गई: %{user}", - "listenBrainzLinkFailure": "ListenBrainz लिंक नहीं हो सका: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz अनलिंक किया गया और स्क्रॉबलिंग अक्षम की गई", - "listenBrainzUnlinkFailure": "ListenBrainz अनलिंक नहीं हो सका", - "openIn": { - "lastfm": "Last.fm में खोलें", - "musicbrainz": "MusicBrainz में खोलें" - }, - "lastfmLink": "और पढ़ें...", - "shareOriginalFormat": "मूल प्रारूप में साझा करें", - "shareDialogTitle": "%{resource} '%{name}' साझा करें", - "shareBatchDialogTitle": "1 %{resource} साझा करें |||| %{smart_count} %{resource} साझा करें", - "shareCopyToClipboard": "क्लिपबोर्ड में कॉपी करें: Ctrl+C, Enter", - "shareSuccess": "URL क्लिपबोर्ड में कॉपी किया गया: %{url}", - "shareFailure": "URL %{url} को क्लिपबोर्ड में कॉपी करने में त्रुटि", - "downloadDialogTitle": "%{resource} '%{name}' (%{size}) डाउनलोड करें", - "downloadOriginalFormat": "मूल प्रारूप में डाउनलोड करें" - }, - "menu": { - "library": "लाइब्रेरी", - "librarySelector": { - "allLibraries": "सभी लाइब्रेरी (%{count})", - "multipleLibraries": "%{total} में से %{selected} लाइब्रेरी", - "selectLibraries": "लाइब्रेरी चुनें", - "none": "कोई नहीं" - }, - "settings": "सेटिंग्स", - "version": "संस्करण", - "theme": "थीम", - "personal": { - "name": "व्यक्तिगत", - "options": { - "theme": "थीम", - "language": "भाषा", - "defaultView": "डिफ़ॉल्ट दृश्य", - "desktop_notifications": "डेस्कटॉप सूचनाएं", - "lastfmNotConfigured": "Last.fm API-Key कॉन्फ़िगर नहीं है", - "lastfmScrobbling": "Last.fm में स्क्रॉबल करें", - "listenBrainzScrobbling": "ListenBrainz में स्क्रॉबल करें", - "replaygain": "ReplayGain मोड", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "अक्षम", - "album": "एल्बम गेन का उपयोग करें", - "track": "ट्रैक गेन का उपयोग करें" - } - } - }, - "albumList": "एल्बम", - "playlists": "प्लेलिस्ट", - "sharedPlaylists": "साझा की गई प्लेलिस्ट", - "about": "के बारे में" - }, - "player": { - "playListsText": "प्ले क्यू", - "openText": "खोलें", - "closeText": "बंद करें", - "notContentText": "कोई संगीत नहीं", - "clickToPlayText": "चलाने के लिए क्लिक करें", - "clickToPauseText": "रोकने के लिए क्लिक करें", - "nextTrackText": "अगला ट्रैक", - "previousTrackText": "पिछला ट्रैक", - "reloadText": "रीलोड", - "volumeText": "वॉल्यूम", - "toggleLyricText": "गीत टॉगल करें", - "toggleMiniModeText": "मिनिमाइज़ करें", - "destroyText": "नष्ट करें", - "downloadText": "डाउनलोड", - "removeAudioListsText": "ऑडियो सूची हटाएं", - "clickToDeleteText": "%{name} को हटाने के लिए क्लिक करें", - "emptyLyricText": "कोई गीत नहीं", - "playModeText": { - "order": "क्रम में", - "orderLoop": "दोहराएं", - "singleLoop": "एक दोहराएं", - "shufflePlay": "शफल" - } - }, - "about": { - "links": { - "homepage": "होम पेज", - "source": "सोर्स कोड", - "featureRequests": "फीचर अनुरोध", - "lastInsightsCollection": "अंतिम अंतर्दृष्टि संग्रह", - "insights": { - "disabled": "अक्षम", - "waiting": "प्रतीक्षा में" - } - }, - "tabs": { - "about": "के बारे में", - "config": "कॉन्फ़िगरेशन" - }, - "config": { - "configName": "कॉन्फ़िग नाम", - "environmentVariable": "पर्यावरण चर", - "currentValue": "वर्तमान मान", - "configurationFile": "कॉन्फ़िगरेशन फ़ाइल", - "exportToml": "कॉन्फ़िगरेशन निर्यात करें (TOML)", - "exportSuccess": "कॉन्फ़िगरेशन TOML प्रारूप में क्लिपबोर्ड में निर्यात किया गया", - "exportFailed": "कॉन्फ़िगरेशन कॉपी करने में विफल", - "devFlagsHeader": "विकास फ्लैग (परिवर्तन/हटाने के अधीन)", - "devFlagsComment": "ये प्रयोगात्मक सेटिंग्स हैं और भविष्य के संस्करणों में हटाई जा सकती हैं" - } - }, - "activity": { - "title": "गतिविधि", - "totalScanned": "कुल स्कैन किए गए फ़ोल्डर", - "quickScan": "त्वरित स्कैन", - "fullScan": "पूर्ण स्कैन", - "serverUptime": "सर्वर अपटाइम", - "serverDown": "ऑफलाइन", - "scanType": "प्रकार", - "status": "स्कैन त्रुटि", - "elapsedTime": "बीता समय" - }, - "nowPlaying": { - "title": "अभी चल रहा है", - "empty": "कुछ नहीं चल रहा", - "minutesAgo": "%{smart_count} मिनट पहले |||| %{smart_count} मिनट पहले" - }, - "help": { - "title": "Navidrome हॉटकीज़", - "hotkeys": { - "show_help": "यह सहायता दिखाएं", - "toggle_menu": "मेनू साइड बार टॉगल करें", - "toggle_play": "चलाएं / रोकें", - "prev_song": "पिछला गाना", - "next_song": "अगला गाना", - "current_song": "वर्तमान गाने पर जाएं", - "vol_up": "वॉल्यूम बढ़ाएं", - "vol_down": "वॉल्यूम कम करें", - "toggle_love": "इस ट्रैक को पसंदीदा में जोड़ें" - } - } -} diff --git a/resources/i18n/hu.json b/resources/i18n/hu.json deleted file mode 100644 index ce3d5ae87..000000000 --- a/resources/i18n/hu.json +++ /dev/null @@ -1,718 +0,0 @@ -{ - "languageName": "Magyar", - "resources": { - "song": { - "name": "Szám |||| Számok", - "fields": { - "albumArtist": "Album előadó", - "duration": "Hossz", - "trackNumber": "#", - "playCount": "Lejátszások", - "title": "Cím", - "artist": "Előadó", - "composer": "Zeneszerző", - "album": "Album", - "path": "Elérési út", - "libraryName": "Könyvtár", - "genre": "Műfaj", - "compilation": "Válogatásalbum", - "year": "Év", - "size": "Fájlméret", - "updatedAt": "Legutóbb frissítve", - "bitRate": "Bitráta", - "bitDepth": "Bitmélység", - "sampleRate": "Mintavételezési frekvencia", - "disc": "Lemez %{discNumber}", - "discSubtitle": "Lemezfelirat", - "starred": "Kedvenc", - "comment": "Megjegyzés", - "rating": "Értékelés", - "quality": "Minőség", - "bpm": "BPM", - "playDate": "Utoljára lejátszva", - "channels": "Csatornák", - "createdAt": "Hozzáadva", - "grouping": "Csoportosítás", - "mood": "Hangulat", - "participants": "További résztvevők", - "tags": "További címkék", - "mappedTags": "Feldolgozott címkék", - "rawTags": "Nyers címkék", - "missing": "Hiányzó" - }, - "actions": { - "addToQueue": "Lejátszás útolsóként", - "playNow": "Lejátszás", - "addToPlaylist": "Lejátszási listához adás", - "showInPlaylist": "Megjelenítés a lejátszási listában", - "shuffleAll": "Keverés", - "download": "Letöltés", - "playNext": "Lejátszás következőként", - "info": "Részletek", - "instantMix": "Instant keverés" - } - }, - "album": { - "name": "Album |||| Albumok", - "fields": { - "albumArtist": "Album előadó", - "artist": "Előadó", - "duration": "Hossz", - "songCount": "Számok", - "playCount": "Lejátszások", - "name": "Név", - "libraryName": "Könyvtár", - "genre": "Stílus", - "compilation": "Válogatásalbum", - "year": "Év", - "date": "Felvétel dátuma", - "updatedAt": "Legutóbb frissítve", - "comment": "Megjegyzés", - "rating": "Értékelés", - "createdAt": "Létrehozva", - "size": "Méret", - "originalDate": "Eredeti", - "releaseDate": "Kiadva", - "releases": "Kiadó |||| Kiadók", - "released": "Kiadta", - "recordLabel": "Lemezkiadó", - "catalogNum": "Katalógusszám", - "releaseType": "Típus", - "grouping": "Csoportosítás", - "media": "Média", - "mood": "Hangulat", - "missing": "Hiányzó" - }, - "actions": { - "playAll": "Lejátszás", - "playNext": "Lejátszás következőként", - "addToQueue": "Lejátszás útolsóként", - "shuffle": "Keverés", - "addToPlaylist": "Lejátszási listához adás", - "download": "Letöltés", - "info": "Részletek", - "share": "Megosztás" - }, - "lists": { - "all": "Mind", - "random": "Véletlenszerű", - "recentlyAdded": "Nemrég hozzáadott", - "recentlyPlayed": "Nemrég lejátszott", - "mostPlayed": "Legtöbbször lejátszott", - "starred": "Kedvencek", - "topRated": "Legjobbra értékelt" - } - }, - "artist": { - "name": "Előadó |||| Előadók", - "fields": { - "name": "Név", - "albumCount": "Albumok száma", - "songCount": "Számok száma", - "playCount": "Lejátszások", - "rating": "Értékelés", - "genre": "Stílus", - "size": "Méret", - "role": "Szerep", - "missing": "Hiányzó" - }, - "roles": { - "albumartist": "Album előadó |||| Album előadók", - "artist": "Előadó |||| Előadók", - "composer": "Zeneszerző |||| Zeneszerzők", - "conductor": "Karmester |||| Karmesterek", - "lyricist": "Szövegíró |||| Szövegírók", - "arranger": "Hangszerelő |||| Hangszerelők", - "producer": "Producer |||| Producerek", - "director": "Rendező |||| Rendezők", - "engineer": "Mérnök |||| Mérnökök", - "mixer": "Keverő |||| Keverők", - "remixer": "Átdolgozó |||| Átdolgozók", - "djmixer": "DJ keverő |||| DJ keverők", - "performer": "Előadóművész |||| Előadóművészek", - "maincredit": "Album előadó vagy előadó |||| Album előadók vagy előadók" - }, - "actions": { - "topSongs": "Top számok", - "shuffle": "Keverés", - "radio": "Rádió" - } - }, - "user": { - "name": "Felhasználó |||| Felhasználók", - "fields": { - "userName": "Felhasználónév", - "isAdmin": "Admin", - "lastLoginAt": "Utolsó belépés", - "updatedAt": "Legutóbb frissítve", - "name": "Név", - "password": "Jelszó", - "createdAt": "Létrehozva", - "changePassword": "Jelszó módosítása?", - "currentPassword": "Jelenlegi jelszó", - "newPassword": "Új jelszó", - "token": "Token", - "lastAccessAt": "Utolsó elérés", - "libraries": "Könyvtárak" - }, - "helperTexts": { - "name": "A névváltoztatások csak a következő bejelentkezéskor jelennek meg", - "libraries": "Válassz könyvtárakat ehhez a felhasználóhoz vagy ne jelölj be egyet sem, az alapértelmezett könyvtárak használatához" - }, - "notifications": { - "created": "Felhasználó létrehozva", - "updated": "Felhasználó frissítve", - "deleted": "Felhasználó törölve" - }, - "validation": { - "librariesRequired": "Legalább egy könyvtárat ki kell választani nem admin felhasználókhoz" - }, - "message": { - "listenBrainzToken": "Add meg a ListenBrainz felhasználó tokened.", - "clickHereForToken": "Kattints ide, hogy megszerezd a tokened", - "selectAllLibraries": "Minden könyvtár kiválasztása", - "adminAutoLibraries": "Minden admin felhasználó hozzáfér bármely könyvtárhoz" - } - }, - "player": { - "name": "Lejátszó |||| Lejátszók", - "fields": { - "name": "Név", - "transcodingId": "Átkódolás", - "maxBitRate": "Max. bitráta", - "client": "Kliens", - "userName": "Felhasználó név", - "lastSeen": "Utoljára bejelentkezett", - "reportRealPath": "Valódi fájlútvonal küldése", - "scrobbleEnabled": "Statisztika küldése külső szolgáltatásoknak" - } - }, - "transcoding": { - "name": "Átkódolás |||| Átkódolások", - "fields": { - "name": "Név", - "targetFormat": "Cél formátum", - "defaultBitRate": "Alapértelmezett bitráta", - "command": "Parancs" - } - }, - "playlist": { - "name": "Lejátszási lista |||| Lejátszási listák", - "fields": { - "name": "Név", - "duration": "Hossz", - "ownerName": "Tulajdonos", - "public": "Publikus", - "updatedAt": "Frissítve", - "createdAt": "Létrehozva", - "songCount": "Számok", - "comment": "Megjegyzés", - "sync": "Auto-importálás", - "path": "Importálás" - }, - "actions": { - "selectPlaylist": "Válassz egy lejátszási listát:", - "addNewPlaylist": "\"%{name}\" létrehozása", - "export": "Exportálás", - "saveQueue": "Műsorlista elmentése lejátszási listaként", - "makePublic": "Publikussá tétel", - "makePrivate": "Priváttá tétel", - "searchOrCreate": "Keress lejátszási listák között vagy hozz létre egyet...", - "pressEnterToCreate": "Nyomj Entert, hogy létrehozz egy lejátszási listát", - "removeFromSelection": "Eltávolítás a kiválasztásból" - }, - "message": { - "duplicate_song": "Duplikált számok hozzáadása", - "song_exist": "Egyes számok már hozzá vannak adva a listához. Még egyszer hozzá akarod adni?", - "noPlaylistsFound": "Nem található lejátszási lista", - "noPlaylists": "Nincsenek lejátszási listák" - } - }, - "radio": { - "name": "Radió |||| Radiók", - "fields": { - "name": "Név", - "streamUrl": "Stream URL", - "homePageUrl": "Honlap URL", - "updatedAt": "Frissítve", - "createdAt": "Létrehozva" - }, - "actions": { - "playNow": "Lejátszás" - } - }, - "share": { - "name": "Megosztás |||| Megosztások", - "fields": { - "username": "Megosztotta", - "url": "URL", - "description": "Leírás", - "contents": "Tartalom", - "expiresAt": "Lejárat", - "lastVisitedAt": "Utoljára látogatva", - "visitCount": "Látogatók", - "format": "Formátum", - "maxBitRate": "Max. bitráta", - "updatedAt": "Frissítve", - "createdAt": "Létrehozva", - "downloadable": "Engedélyezed a letöltéseket?" - } - }, - "missing": { - "name": "Hiányzó fájl|||| Hiányzó fájlok", - "empty": "Nincsenek hiányzó fájlok", - "fields": { - "path": "Útvonal", - "size": "Méret", - "libraryName": "Könyvtár", - "updatedAt": "Eltűnt ekkor:" - }, - "actions": { - "remove": "Eltávolítás", - "remove_all": "Összes eltávolítása" - }, - "notifications": { - "removed": "Hiányzó fájl(ok) eltávolítva" - } - }, - "library": { - "name": "Könyvtár |||| Könyvtárak", - "fields": { - "name": "Név", - "path": "Elérési út", - "remotePath": "Távoli elérési út", - "lastScanAt": "Legutóbbi szkennelés", - "songCount": "Számok", - "albumCount": "Albumok", - "artistCount": "Előadók", - "totalSongs": "Számok", - "totalAlbums": "Albumok", - "totalArtists": "Előadók", - "totalFolders": "Mappák", - "totalFiles": "Fájlok", - "totalMissingFiles": "Hiányzó fájlok", - "totalSize": "Teljes méret", - "totalDuration": "Hossz", - "defaultNewUsers": "Alapértelmezett könyvtár új felhasználóknak", - "createdAt": "Létrehozva", - "updatedAt": "Frissítve" - }, - "sections": { - "basic": "Alapinformációk", - "statistics": "Statisztikák" - }, - "actions": { - "scan": "Könyvtár szkennelése", - "quickScan": "Gyors szkennelés", - "fullScan": "Teljes szkennelés", - "manageUsers": "Hozzáférés kezelése", - "viewDetails": "Részletek" - }, - "notifications": { - "created": "Könyvtár létrehozva", - "updated": "Könyvtár frissítve", - "deleted": "Könyvtár törölve", - "scanStarted": "Szkennelés folyamatban", - "scanCompleted": "Könyvtár szkennelés befelyezve" - }, - "validation": { - "nameRequired": "Adj meg egy könyvtárnevet", - "pathRequired": "Adj meg egy útvonalat", - "pathNotDirectory": "A könyvtárútvonalnak egy mappának kell lennie", - "pathNotFound": "A könyvtár útvonala nem található", - "pathNotAccessible": "A könyvtár útvonala nem elérhető", - "pathInvalid": "Helytelen könyvtár útvonal" - }, - "messages": { - "deleteConfirm": "Biztosan törlöd ezt a könyvtárt? Minden adata törlődni fog és elérhetetlenné válik.", - "scanInProgress": "Szkennelés folyamatban...", - "noLibrariesAssigned": "Ehhez a felhasználóhoz nincsenek könyvtárak adva" - } - }, - "plugin": { - "name": "Kiegészítő |||| Kiegészítők", - "fields": { - "id": "ID", - "name": "Név", - "description": "Leírás", - "version": "Verzió", - "author": "Fejlesztő", - "website": "Weboldal", - "permissions": "Engedélyek", - "enabled": "Engedélyezve", - "status": "Státusz", - "path": "Útvonal", - "lastError": "Hiba", - "hasError": "Hiba", - "updatedAt": "Frissítve", - "createdAt": "Telepítve", - "configKey": "Kulcs", - "configValue": "Érték", - "allUsers": "Összes felhasználó engedélyezése", - "selectedUsers": "Kiválasztott felhasználók engedélyezése", - "allLibraries": "Összes könyvtár engedélyezése", - "selectedLibraries": "Kiválasztott könyvtárak engedélyezése", -"allowWriteAccess": "Írási hozzáférés engedélyezése" - }, - "sections": { - "status": "Státusz", - "info": "Kiegészítő információi", - "configuration": "Konfiguráció", - "manifest": "Manifest", - "usersPermission": "Felhasználói engedélyek", - "libraryPermission": "Könyvtári engedélyek" - }, - "status": { - "enabled": "Engedélyezve", - "disabled": "Letiltva" - }, - "actions": { - "enable": "Engedélyezés", - "disable": "Letiltás", - "disabledDueToError": "Javítsd ki a kiegészítő hibáját", - "disabledUsersRequired": "Válassz felhasználókat", - "disabledLibrariesRequired": "Válassz könyvtárakat", - "addConfig": "Konfiguráció hozzáadása", - "rescan": "Újraszkennelés" - }, - "notifications": { - "enabled": "Kiegészítő engedélyezve", - "disabled": "Kiegészítő letiltva", - "updated": "Kiegészítő frissítve", - "error": "Hiba történt a kiegészítő frissítése közben" - }, - "validation": { - "invalidJson": "A konfigurációs JSON érvénytelen" - }, - "messages": { - "configHelp": "Konfiguráld a kiegészítőt kulcs-érték párokkal. Hagyd a mezőt üresen, ha nincs szükség konfigurációra.", - "configValidationError": "Helytelen konfiguráció:", - "schemaRenderError": "Nem sikerült megjeleníteni a konfigurációs űrlapot. A bővítmény sémája érvénytelen lehet.", - "clickPermissions": "Kattints egy engedélyre a részletekért", - "noConfig": "Nincs konfiguráció beállítva", - "allUsersHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott felhasználóhoz.", - "noUsers": "Nincsenek kiválasztott felhasználók", - "permissionReason": "Indok", - "usersRequired": "Ez a kiegészítő hozzáférést kér felhasználói információkhoz. Válaszd ki, melyik felhasználókat érheti el, vagy az 'Összes felhasználó engedélyezése' opciót.", - "allLibrariesHelp": "Engedélyezés esetén ez a kiegészítő hozzá fog férni minden jelenlegi és jövőben létrehozott könyvtárhoz.", - "noLibraries": "Nincs kiválasztott könyvtár", - "librariesRequired": "Ez a kiegészítő hozzáférést kér könyvtárinformációkhoz. Válaszd ki, melyik könyvtárakat érheti el, vagy az 'Összes könyvtár engedélyezése' opciót.", -"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": { - "configKey": "kulcs", - "configValue": "érték" - } - } - }, - "ra": { - "auth": { - "welcome1": "Köszönjük, hogy a Navidrome-ot telepítetted!", - "welcome2": "A kezdéshez hozz létre egy admin felhasználót!", - "confirmPassword": "Jelszó megerősítése", - "buttonCreateAdmin": "Admin hozzáadása", - "auth_check_error": "Jelentkezz be a folytatáshoz!", - "user_menu": "Profil", - "username": "Felhasználó név", - "password": "Jelszó", - "sign_in": "Bejelentkezés", - "sign_in_error": "A hitelesítés sikertelen. Kérjük, próbáld újra!", - "logout": "Kijelentkezés", - "insightsCollectionNote": "A Navidrome anonim metrikákat gyűjt \na projekt fejlesztéséhez. Kattints [ide],\n információkért és az adatgyűjtésből kilépésért." - }, - "validation": { - "invalidChars": "Kérlek, csak betűket és számokat használj!", - "passwordDoesNotMatch": "A jelszó nem egyezik.", - "required": "Szükséges", - "minLength": "Legalább %{min} karakternek kell lennie", - "maxLength": "Legfeljebb %{max} karakternek kell lennie", - "minValue": "Legalább %{min}", - "maxValue": "Legfeljebb %{max} vagy kevesebb", - "number": "Számnak kell lennie", - "email": "Érvényes email címnek kell lennie", - "oneOf": "Az egyiknek kell lennie: %{options}", - "regex": "Meg kell felelnie egy adott formátumnak (regexp): %{pattern}", - "unique": "Egyedinek kell lennie", - "url": "Érvényes URL-nek kell lennie" - }, - "action": { - "add_filter": "Szűrő hozzáadása", - "add": "Hozzáadás", - "back": "Vissza", - "bulk_actions": "1 kiválasztott elem |||| %{smart_count} kiválasztott elem", - "cancel": "Mégse", - "clear_input_value": "Üres érték", - "clone": "Klónozás", - "confirm": "Megerősítés", - "create": "Létrehozás", - "delete": "Törlés", - "edit": "Szerkesztés", - "export": "Exportálás", - "list": "Lista", - "refresh": "Frissítés", - "remove_filter": "Szűrő eltávolítása", - "remove": "Eltávolítás", - "save": "Mentés", - "search": "Keresés", - "show": "Megjelenítés", - "sort": "Rendezés", - "undo": "Vísszavonás", - "expand": "Kiterjesztés", - "close": "Bezárás", - "open_menu": "Menü megnyitása", - "close_menu": "Menü bezárása", - "unselect": "Kijelölés megszüntetése", - "skip": "Átugrás", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Megosztás", - "download": "Letöltés" - }, - "boolean": { - "true": "Igen", - "false": "Nem" - }, - "page": { - "create": "%{name} létrehozása", - "dashboard": "Műszerfal", - "edit": "%{name} #%{id}", - "error": "Valami probléma történt", - "list": "%{name}", - "loading": "Betöltés", - "not_found": "Nem található", - "show": "%{name} #%{id}", - "empty": "Nincsenek %{name}.", - "invite": "Szeretnél egyet hozzáadni?" - }, - "input": { - "file": { - "upload_several": "Húzz ide néhány feltöltendő fájlt vagy válassz egyet.", - "upload_single": "Húzz ide egy feltöltendő fájlt vagy válassz egyet." - }, - "image": { - "upload_several": "Húzz ide néhány feltöltendő képet vagy válassz egyet.", - "upload_single": "Húzz ide egy feltöltendő képet vagy válassz egyet." - }, - "references": { - "all_missing": "Hivatkozási adatok nem találhatóak.", - "many_missing": "Legalább az egyik kapcsolódó hivatkozás már nem elérhető.", - "single_missing": "A kapcsolódó hivatkozás már nem elérhető." - }, - "password": { - "toggle_visible": "Jelszó elrejtése", - "toggle_hidden": "Jelszó megjelenítése" - } - }, - "message": { - "about": "Rólunk", - "are_you_sure": "Biztos vagy benne?", - "bulk_delete_content": "Biztos, hogy törölni akarod %{name}? |||| Biztos, hogy törölni akarod ezeket az %{smart_count} elemeket?", - "bulk_delete_title": "%{name} törlése |||| %{smart_count} %{name} elem törlése", - "delete_content": "Biztos, hogy törlöd ezt az elemet?", - "delete_title": "%{name} #%{id} törlése", - "details": "Részletek", - "error": "Kliens hiba lépett fel, és a kérést nem lehetett teljesíteni.", - "invalid_form": "Az űrlap érvénytelen. Kérlek, ellenőrizzd a hibákat.", - "loading": "Az oldal betöltődik. Egy pillanat.", - "no": "Nem", - "not_found": "Rossz hivatkozást írtál be, vagy egy rossz linket adtál meg.", - "yes": "Igen", - "unsaved_changes": "Néhány módosítás nem lett elmentve. Biztos, hogy figyelmen kívül akarod hagyni?" - }, - "navigation": { - "no_results": "Nincs találat.", - "no_more_results": "Az oldalszám %{page} kívül esik a határokon. Próbáld meg az előző oldalt.", - "page_out_of_boundaries": "Az oldalszám %{page} kívül esik a határokon.", - "page_out_from_end": "Nem lehet az utolsó oldal után menni", - "page_out_from_begin": "Nem lehet az első oldal elé menni", - "page_range_info": "%{offsetBegin}-%{offsetEnd} of %{total}", - "page_rows_per_page": "Elemek oldalanként:", - "next": "Következő", - "prev": "Előző", - "skip_nav": "Ugrás a tartalomra" - }, - "notification": { - "updated": "Elem frissítve |||| %{smart_count} elemek frissíteve", - "created": "Elem létrehozva", - "deleted": "Elem törölve |||| %{smart_count} elemek frissítve", - "bad_item": "Hibás elem", - "item_doesnt_exist": "Elem nem létezik", - "http_error": "Szerver kommunikációs hiba", - "data_provider_error": "Adatszolgáltatói hiba. Ellenőrizzd a konzolt a részletekért.", - "i18n_error": "Nem lehet betölteni a fordítást a kért nyelven", - "canceled": "A művelet visszavonva", - "logged_out": "A munkamenet lejárt. Kérlek, csatlakozz újra.", - "new_version": "Új verzió elérhető! Kérlek, frissítsd ezt az ablakot!" - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Megjelenítendő oszlopok", - "layout": "Elrendezés", - "grid": "Rács", - "table": "Tábla" - } - }, - "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.", - "songsAddedToPlaylist": "1 szám hozzáadva a lejátszási listához |||| %{smart_count} szám hozzáadva a lejátszási listához", - "noSimilarSongsFound": "Nem találhatóak hasonló számok", - "startingInstantMix": "Instant keverés töltődik...", - "noTopSongsFound": "Nincsenek top számok", - "noPlaylistsAvailable": "Nem áll rendelkezésre", - "delete_user_title": "Felhasználó törlése '%{name}'", - "delete_user_content": "Biztos, hogy törölni akarod ezt a felhasználót az adataival (beállítások és lejátszási listák) együtt?", - "remove_all_missing_title": "Összes hiányzó fájl eltávolítása", - "remove_all_missing_content": "Biztos, hogy minden hiányzó fájlt törölni akarsz az adatbázisból? Ez minden hozzájuk fűződő referenciát törölni fog, beleértve a lejátszásaikat és értékeléseiket.", - "notifications_blocked": "A böngésződ beállításaiban letiltottad az értesítéseket erre az oldalra.", - "notifications_not_available": "Ez a böngésző nem támogatja az asztali értesítéseket, vagy a Navidrome-ot nem https-en keresztül használod.", - "lastfmLinkSuccess": "Sikeresen összekapcsolva Last.fm-el és halgatott számok küldése engedélyezve.", - "lastfmLinkFailure": "Nem lehet kapcsolódni a Last.fm-hez.", - "lastfmUnlinkSuccess": "Last.fm leválasztva és a halgatott számok küldése kikapcsolva.", - "lastfmUnlinkFailure": "Nem sikerült leválasztani a Last.fm-et.", - "openIn": { - "lastfm": "Megnyitás Last.fm-ben", - "musicbrainz": "Megnyitás MusicBrainz-ben" - }, - "lastfmLink": "Bővebben...", - "listenBrainzLinkSuccess": "Sikeresen összekapcsolva ListenBrainz-el. Halgatott számok küldése %{user} felhasználónak engedélyezve.", - "listenBrainzLinkFailure": "Nem lehet kapcsolódni a Listenbrainz-hez: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz Last.fm leválasztva és a halgatott számok küldése kikapcsolva.", - "listenBrainzUnlinkFailure": "Nem sikerült leválasztani a ListenBrainz-et.", - "downloadOriginalFormat": "Letöltés eredeti formátumban", - "shareOriginalFormat": "Megosztás eredeti formátumban", - "shareDialogTitle": "Megosztás %{resource} '%{name}'", - "shareBatchDialogTitle": "1 %{resource} megosztása |||| %{smart_count} %{resource} megosztása", - "shareSuccess": "Hivatkozás másolva a vágólapra: %{url}", - "shareFailure": "Hiba történt a hivatkozás %{url} vágólapra másolása közben.", - "downloadDialogTitle": "Letöltés %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Másolás vágólapra: Ctrl+C, Enter", - "remove_missing_title": "Hiányzó fájlok eltávolítása", - "remove_missing_content": "Biztos, hogy el akarod távolítani a kiválasztott, hiányó fájlokat az adatbázisból? Ez a művelet véglegesen törölni fog minden hozzájuk kapcsolódó referenciát, beleértve a lejátszások számát és értékeléseket." - }, - "menu": { - "library": "Könyvtár", - "librarySelector": { - "allLibraries": "Minden %{count} könyvtár", - "multipleLibraries": "%{selected} kiválasztva %{total} könyvtárból", - "selectLibraries": "Kiválasztott kőnyvtárak", - "none": "Semmi" - }, - "settings": "Beállítások", - "version": "Verzió", - "theme": "Téma", - "personal": { - "name": "Személyes", - "options": { - "theme": "Téma", - "language": "Nyelv", - "defaultView": "Alapértelmezett nézet", - "desktop_notifications": "Asztali értesítések", - "lastfmScrobbling": "Halgatott számok küldése a Last.fm-nek", - "listenBrainzScrobbling": "Halgatott számok küldése a ListenBrainz-nek", - "replaygain": "ReplayGain mód", - "preAmp": "ReplayGain előerősítő (dB)", - "gain": { - "none": "Kikapcsolva", - "album": "Album", - "track": "Sáv" - }, - "lastfmNotConfigured": "Last.fm API kulcs nincs beállítva" - } - }, - "albumList": "Albumok", - "about": "Rólunk", - "playlists": "Lejátszási listák", - "sharedPlaylists": "Megosztott lej. listák" - }, - "player": { - "playListsText": "Műsorlista", - "openText": "Megnyitás", - "closeText": "Bezárás", - "notContentText": "Nincs zene", - "clickToPlayText": "Lejátszás", - "clickToPauseText": "Szünet", - "nextTrackText": "Következő szám", - "previousTrackText": "Előző szám", - "reloadText": "Újratöltés", - "volumeText": "Hangerő", - "toggleLyricText": "Zeneszöveg", - "toggleMiniModeText": "Minimalizálás", - "destroyText": "Bezárás", - "downloadText": "Letöltés", - "removeAudioListsText": "Audio listák törlése", - "clickToDeleteText": "Kattints a törléshez %{name}", - "emptyLyricText": "Nincs szöveg", - "playModeText": { - "order": "Sorrendben", - "orderLoop": "Ismétlés", - "singleLoop": "Egy szám ismétlése", - "shufflePlay": "Véletlenszerű" - } - }, - "about": { - "links": { - "homepage": "Honlap", - "source": "Forráskód", - "featureRequests": "Funkciókérések", - "lastInsightsCollection": "Legutóbb gyűjtött metrikák", - "insights": { - "disabled": "Kikapcsolva", - "waiting": "Várakozás" - } - }, - "tabs": { - "about": "Rólunk", - "config": "Konfiguráció" - }, - "config": { - "configName": "Beállítás neve", - "environmentVariable": "Környezeti változó", - "currentValue": "Jelenlegi érték", - "configurationFile": "Konfigurációs fájl", - "exportToml": "Konfiguráció exportálása (TOML)", - "downloadToml": "Konfiguráció letöltése (TOML)", - "exportSuccess": "Konfiguráció kiexportálva a vágólapra, TOML formában", - "exportFailed": "Nem sikerült kimásolni a konfigurációt", - "devFlagsHeader": "Fejlesztői beállítások (változások/eltávolítás jogát fenntartjuk)", - "devFlagsComment": "Ezek kísérleti beállítások, és a jövőbeli verziókban eltávolíthatók" - } - }, - "activity": { - "title": "Aktivitás", - "totalScanned": "Összes beolvasott mappa:", - "quickScan": "Gyors", - "fullScan": "Teljes", - "selectiveScan": "Szelektív", - "serverUptime": "Szerver üzemidő", - "serverDown": "OFFLINE", - "scanType": "Legutóbbi szkennelés", - "status": "Szkennelési hiba", - "elapsedTime": "Eltelt idő" - }, - "nowPlaying": { - "title": "Most megy", - "empty": "Nem hallgatsz semmit", - "minutesAgo": "%{smart_count} perce |||| %{smart_count} perce" - }, - "help": { - "title": "Navidrome Gyorsbillentyűk", - "hotkeys": { - "show_help": "Mutasd ezt a súgót", - "toggle_menu": "Menu oldalsáv be", - "toggle_play": "Lejátszás / Szünet", - "prev_song": "Előző Szám", - "next_song": "Következő Szám", - "vol_up": "Hangerő fel", - "vol_down": "Hangerő le", - "toggle_love": "Ad hozzá ezt a számot a kedvencekhez", - "current_song": "Aktuális számhoz ugrás" - } - } -} \ No newline at end of file diff --git a/resources/i18n/id.json b/resources/i18n/id.json deleted file mode 100644 index 762ce4ebb..000000000 --- a/resources/i18n/id.json +++ /dev/null @@ -1,723 +0,0 @@ -{ - "languageName": "Bahasa Indonesia", - "resources": { - "song": { - "name": "Lagu |||| Lagu", - "fields": { - "albumArtist": "Artis Album", - "duration": "Durasi", - "trackNumber": "#", - "playCount": "Diputar", - "title": "Judul", - "artist": "Artis", - "album": "Album", - "path": "Lokasi file", - "genre": "Genre", - "compilation": "Kompilasi", - "year": "Tahun", - "size": "Ukuran file", - "updatedAt": "Diperbarui pada", - "bitRate": "Bit rate", - "discSubtitle": "Subtitle Disk", - "starred": "Favorit", - "comment": "Komentar", - "rating": "Peringkat", - "quality": "Kualitas", - "bpm": "BPM", - "playDate": "Terakhir Diputar", - "channels": "Saluran", - "createdAt": "Tgl. Ditambahkan", - "grouping": "Mengelompokkan", - "mood": "Mood", - "participants": "Partisipan tambahan", - "tags": "Tag tambahan", - "mappedTags": "Tag yang dipetakan", - "rawTags": "Tag raw", - "bitDepth": "Bit depth", - "sampleRate": "Sample rate", - "missing": "Hilang", - "libraryName": "Pustaka", - "composer": "Komposer", - "disc": "Disk %{discNumber}", - "albumGain": "Album gain", - "trackGain": "Trek gain" - }, - "actions": { - "addToQueue": "Tambah ke antrean", - "playNow": "Putar sekarang", - "addToPlaylist": "Tambahkan ke Playlist", - "shuffleAll": "Acak Semua", - "download": "Unduh", - "playNext": "Putar Berikutnya", - "info": "Lihat Info", - "showInPlaylist": "Tampilkan di Playlist", - "instantMix": "Mix Instan" - } - }, - "album": { - "name": "Album |||| Album", - "fields": { - "albumArtist": "Artis Album", - "artist": "Artis", - "duration": "Durasi", - "songCount": "Lagu", - "playCount": "Diputar", - "name": "Nama", - "genre": "Genre", - "compilation": "Kompilasi", - "year": "Tahun", - "updatedAt": "Diperbarui pada", - "comment": "Komentar", - "rating": "Peringkat", - "createdAt": "Tgl. Ditambahkan", - "size": "Ukuran", - "originalDate": "Tanggal", - "releaseDate": "Dirilis", - "releases": "Rilis |||| Rilis", - "released": "Dirilis", - "recordLabel": "Label", - "catalogNum": "Nomer Katalog", - "releaseType": "Tipe", - "grouping": "Pengelompokkan", - "media": "Media", - "mood": "Mood", - "date": "Tanggal Perekaman", - "missing": "Hilang", - "libraryName": "Pustaka" - }, - "actions": { - "playAll": "Putar", - "playNext": "Putar Selanjutnya", - "addToQueue": "Putar Nanti", - "shuffle": "Acak", - "addToPlaylist": "Tambahkan ke Playlist", - "download": "Unduh", - "info": "Lihat Info", - "share": "Bagikan" - }, - "lists": { - "all": "Semua", - "random": "Acak", - "recentlyAdded": "Terakhir Ditambahkan", - "recentlyPlayed": "Terakhir Diputar", - "mostPlayed": "Sering Diputar", - "starred": "Favorit", - "topRated": "Peringkat Teratas" - } - }, - "artist": { - "name": "Artis |||| Artis", - "fields": { - "name": "Nama", - "albumCount": "Jumlah Album", - "songCount": "Jumlah Lagu", - "playCount": "Diputar", - "rating": "Peringkat", - "genre": "Genre", - "size": "Ukuran", - "role": "Peran", - "missing": "Hilang" - }, - "roles": { - "albumartist": "Artis Album |||| Artis Album", - "artist": "Artis |||| Artis", - "composer": "Komposer |||| Komposer", - "conductor": "Konduktor |||| Konduktor", - "lyricist": "Penulis Lirik |||| Penulis Lirik", - "arranger": "Arranger |||| Arranger", - "producer": "Produser |||| Produser", - "director": "Director |||| Director", - "engineer": "Engineer |||| Engineer", - "mixer": "Mixer |||| Mixer", - "remixer": "Remixer |||| Remixer", - "djmixer": "DJ Mixer |||| Dj Mixer", - "performer": "Performer |||| Performer", - "maincredit": "Artis Album atau Artis |||| Artis Album or Artis" - }, - "actions": { - "shuffle": "Acak", - "radio": "Radio", - "topSongs": "Lagu Teratas" - } - }, - "user": { - "name": "Pengguna |||| Pengguna", - "fields": { - "userName": "Nama Pengguna", - "isAdmin": "Admin", - "lastLoginAt": "Terakhir Login", - "updatedAt": "Diperbarui pada", - "name": "Nama", - "password": "Kata Sandi", - "createdAt": "Dibuat pada", - "changePassword": "Ganti Kata Sandi?", - "currentPassword": "Kata Sandi Sebelumnya", - "newPassword": "Kata Sandi Baru", - "token": "Token", - "lastAccessAt": "Terakhir Diakses", - "libraries": "Perpustakaan" - }, - "helperTexts": { - "name": "Perubahan pada nama Kamu akan terlihat pada login berikutnya", - "libraries": "Pilih pustaka yang ditentukan untuk pengguna ini, atau biarkan kosong untuk menggunakan pustaka default" - }, - "notifications": { - "created": "Pengguna dibuat", - "updated": "Pengguna diperbarui", - "deleted": "Pengguna dihapus" - }, - "message": { - "listenBrainzToken": "Masukkan token pengguna ListenBrainz Kamu.", - "clickHereForToken": "Klik di sini untuk mendapatkan token baru anda", - "selectAllLibraries": "Pilih semua pustaka", - "adminAutoLibraries": "Pengguna admin otomatis langsung memiliki akses ke semua perpustakaan" - }, - "validation": { - "librariesRequired": "Setidaknya satu pustaka harus dipilih untuk pengguna non-admin" - } - }, - "player": { - "name": "Pemutar |||| Pemutar", - "fields": { - "name": "Nama", - "transcodingId": "Transkode", - "maxBitRate": "Maks. Bit Rate", - "client": "Klien", - "userName": "Nama Pengguna", - "lastSeen": "Terakhir Terlihat Pada", - "reportRealPath": "Laporkan Jalur Sebenarnya", - "scrobbleEnabled": "Kirim Scrobbles ke layanan eksternal" - } - }, - "transcoding": { - "name": "Transkoding |||| Transkoding", - "fields": { - "name": "Nama", - "targetFormat": "Target Format", - "defaultBitRate": "Bit Rate Bawaan", - "command": "Perintah" - } - }, - "playlist": { - "name": "Playlist |||| Playlist", - "fields": { - "name": "Nama", - "duration": "Durasi", - "ownerName": "Pemilik", - "public": "Publik", - "updatedAt": "Diperbarui pada", - "createdAt": "Dibuat pada", - "songCount": "Lagu", - "comment": "Komentar", - "sync": "Impor Otomatis", - "path": "Impor Dari" - }, - "actions": { - "selectPlaylist": "Pilih playlist:", - "addNewPlaylist": "Buat \"%{name}\"", - "export": "Ekspor", - "makePublic": "Jadikan Publik", - "makePrivate": "Jadikan Pribadi", - "saveQueue": "Simpan Antrean ke Playlist", - "searchOrCreate": "Cari playlist atau ketik untuk buat baru..", - "pressEnterToCreate": "Tekan Enter untuk membuat playlist baru", - "removeFromSelection": "Hapus yang dipilih" - }, - "message": { - "duplicate_song": "Tambahkan lagu duplikat", - "song_exist": "Ada lagu duplikat yang ditambahkan ke daftar putar. Apakah Kamu ingin menambahkan lagu duplikat atau melewatkannya?", - "noPlaylistsFound": "Playlist tidak ditemukan", - "noPlaylists": "Playlist tidak tersedia" - } - }, - "radio": { - "name": "Radio |||| Radio", - "fields": { - "name": "Nama", - "streamUrl": "URL Stream", - "homePageUrl": "Halaman Beranda URL", - "updatedAt": "Diperbarui pada", - "createdAt": "Dibuat pada" - }, - "actions": { - "playNow": "Putar Sekarang" - } - }, - "share": { - "name": "Bagikan |||| Bagikan", - "fields": { - "username": "Dibagikan Oleh", - "url": "URL", - "description": "Deskripsi", - "contents": "Konten", - "expiresAt": "Berakhir", - "lastVisitedAt": "Terakhir Dikunjungi", - "visitCount": "Pengunjung", - "format": "Format", - "maxBitRate": "Maks. Laju Bit", - "updatedAt": "Diperbarui pada", - "createdAt": "Dibuat pada", - "downloadable": "Izinkan Pengunduhan?" - } - }, - "missing": { - "name": "File yang Hilang |||| File yang Hilang", - "fields": { - "path": "Jalur", - "size": "Ukuran", - "updatedAt": "Tidak muncul di", - "libraryName": "Pustaka" - }, - "actions": { - "remove": "Hapus", - "remove_all": "Hapus Semua" - }, - "notifications": { - "removed": "File yang hilang dihapus" - }, - "empty": "Tidak ada File yang Hilang" - }, - "library": { - "name": "Pustaka |||| Perpustakaan", - "fields": { - "name": "Nama", - "path": "Jalur", - "remotePath": "Jalur Remote", - "lastScanAt": "Terakhir Dipindai", - "songCount": "Lagu", - "albumCount": "Album", - "artistCount": "Artis", - "totalSongs": "Lagu", - "totalAlbums": "Album", - "totalArtists": "Artis", - "totalFolders": "Folder", - "totalFiles": "File", - "totalMissingFiles": "File hilang", - "totalSize": "Ukuran Total", - "totalDuration": "Durasi", - "defaultNewUsers": "Default untuk Pengguna Baru", - "createdAt": "Dibuat", - "updatedAt": "Diperbarui" - }, - "sections": { - "basic": "Informasi Dasar", - "statistics": "Statistik" - }, - "actions": { - "scan": "Pindai Pustaka", - "manageUsers": "Kelola Akses Pengguna", - "viewDetails": "Lihat Detail", - "quickScan": "Pindai Cepat", - "fullScan": "Pindai Keseluruhan" - }, - "notifications": { - "created": "Pustaka berhasil dibuat", - "updated": "Pustaka berhasil dibuat", - "deleted": "Berhasil menghapus pustaka", - "scanStarted": "Memindai pustaka dimulai", - "scanCompleted": "Memindai pustaka selesai", - "quickScanStarted": "Pemindaian cepat dimulai", - "fullScanStarted": "Pemindaian keseluruhan dimulai", - "scanError": "Kesalahan saat memulai pemindaian. Periksa log" - }, - "validation": { - "nameRequired": "Nama pustaka diperlukan", - "pathRequired": "Lokasi pustaka diperlukan", - "pathNotDirectory": "Lokasi pustaka harus ada di direktori", - "pathNotFound": "Lokasi pustaka tidak ditemukan", - "pathNotAccessible": "Lokasi pustaka tidak dapat diakses", - "pathInvalid": "Lokasi pustaka tidak valid" - }, - "messages": { - "deleteConfirm": "Kamu yakin ingin menghapus pustaka ini? Ini akan menghapus semua data yang terkait dan akses pengguna.", - "scanInProgress": "Pemindaian sedang berlangsung...", - "noLibrariesAssigned": "Tidak ada pustaka yang ditugaskan ke pengguna ini" - } - }, - "plugin": { - "name": "Plugin |||| Plugin", - "fields": { - "id": "ID", - "name": "Nama", - "description": "Deskripsi", - "version": "Versi", - "author": "Pembuat", - "website": "Situs Web", - "permissions": "Perizinan", - "enabled": "Diaktifkan", - "status": "Status", - "path": "Jalur", - "lastError": "Kesalahan", - "hasError": "Kesalahan", - "updatedAt": "Diperbarui", - "createdAt": "Terinstal", - "configKey": "Key", - "configValue": "Value", - "allUsers": "Izinkan semua pengguna", - "selectedUsers": "Pengguna yang dipilih", - "allLibraries": "Izinkan semua pustaka", - "selectedLibraries": "Pustaka dipilih", - "allowWriteAccess": "Izinkan akses tulis" - }, - "sections": { - "status": "Status", - "info": "Informasi plugin", - "configuration": "Konfigurasi", - "manifest": "Manifes", - "usersPermission": "Pengguna yang Diizinkan", - "libraryPermission": "Pustaka yang Diizinkan" - }, - "status": { - "enabled": "Diaktifkan", - "disabled": "Dinonaktifkan" - }, - "actions": { - "enable": "Aktifkan", - "disable": "Nonaktifkan", - "disabledDueToError": "Perbaiki kesalahan sebelum diaktifkan", - "disabledUsersRequired": "Pilih pengguna sebelum diaktifkan", - "disabledLibrariesRequired": "Pilih pustaka sebelum diaktifkan", - "addConfig": "Tambahkan Konfigurasi", - "rescan": "Pindai ulang" - }, - "notifications": { - "enabled": "Plugin diaktifkan", - "disabled": "Plugin dinonaktifkan", - "updated": "Plugin diperbarui", - "error": "Kesalahan saat memperbarui plugin" - }, - "validation": { - "invalidJson": "Konfigurasi harus berupa JSON yang valid" - }, - "messages": { - "configHelp": "Konfigurasikan plugin menggunakan key-value pairs. Biarkan kosong jika plugin tidak membutuhkan konfigurasi.", - "clickPermissions": "Klik perizinan untuk detail", - "noConfig": "Konfigurasi tidak diatur", - "allUsersHelp": "Ketika diaktifkan, plugin akan mengakses untuk semua pengguna, termasuk yang akan dibuat di masa depan.", - "noUsers": "Tidak ada pengguna yang dipilih", - "permissionReason": "Alasan", - "usersRequired": "Plugin ini membutuhkan akses ke informasi pengguna. Pilih pengguna yang bisa mengakses plugin, atau aktifkan 'Izinkan semua pengguna'.", - "allLibrariesHelp": "Ketika diaktifkan, plugin akan memiliki akses ke semua pustaka, termasuk yang dibuat di masa depan.", - "noLibraries": "Tidak ada pustaka yang dipilih", - "librariesRequired": "Plugin ini membutuhkan akses ke informasi pustaka. Pilih beberapa pustaka yang bisa diakses, atau aktifkan 'Izinkan semua pustaka'.", - "requiredHosts": "Hosts diperlukan", - "configValidationError": "Validasi konfigurasi gagal:", - "schemaRenderError": "Tidak dapat menampilkan form konfigurasi. Skema plugin mungkin tidak valid.", - "allowWriteAccessHelp": "Ketika diaktifkan, plugin dapat mengubah file di direktori pustaka. Bawaannya, plugin hanya memiliki akses read-only" - }, - "placeholders": { - "configKey": "key", - "configValue": "value" - } - } - }, - "ra": { - "auth": { - "welcome1": "Terima kasih telah menginstal Navidrome!", - "welcome2": "Untuk memulai, buat dulu akun admin", - "confirmPassword": "Konfirmasi Kata Sandi", - "buttonCreateAdmin": "Buat Akun Admin", - "auth_check_error": "Silahkan masuk untuk melanjutkan", - "user_menu": "Profil", - "username": "Nama Pengguna", - "password": "Kata Sandi", - "sign_in": "Masuk", - "sign_in_error": "Otentikasi gagal, silakan coba lagi", - "logout": "Keluar", - "insightsCollectionNote": "Navidrome mengumpulkan penggunaan data anonim untuk membantu menyempurnakan project ini. Klik [disini] untuk mempelajari lebih lanjut dan untuk opt-out jika anda mau" - }, - "validation": { - "invalidChars": "Harap menggunakan huruf dan angka saja", - "passwordDoesNotMatch": "Kata sandi tidak cocok", - "required": "Wajib", - "minLength": "Setidaknya harus %{min} karakter", - "maxLength": "Harus berisi %{max} karakter atau kurang", - "minValue": "Minimal harus %{min}", - "maxValue": "Harus %{max} atau kurang", - "number": "Harus berupa angka", - "email": "Harus berupa email yang valid", - "oneOf": "Harus salah satu dari: %{options}", - "regex": "Harus cocok dengan format spesifik (regexp): %{pattern}", - "unique": "Harus unik", - "url": "Harus berupa URL yang valid" - }, - "action": { - "add_filter": "Tambah filter", - "add": "Tambah", - "back": "Kembali", - "bulk_actions": "1 item dipilih |||| %{smart_count} item dipilih", - "cancel": "Batal", - "clear_input_value": "Hapus", - "clone": "Klon", - "confirm": "Konfirmasi", - "create": "Buat", - "delete": "Hapus", - "edit": "Sunting", - "export": "Ekspor", - "list": "Daftar", - "refresh": "Segarkan", - "remove_filter": "Hapus filter ini", - "remove": "Hapus", - "save": "Simpan", - "search": "Cari", - "show": "Tampilkan", - "sort": "Urutkan", - "undo": "Batalkan", - "expand": "Luaskan", - "close": "Tutup", - "open_menu": "Buka menu", - "close_menu": "Tutup menu", - "unselect": "Batalkan pilihan", - "skip": "Lewati", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Bagikan", - "download": "Unduh" - }, - "boolean": { - "true": "Ya", - "false": "Tidak" - }, - "page": { - "create": "Buat %{name}", - "dashboard": "Dasbor", - "edit": "%{name} #%{id}", - "error": "Terjadi kesalahan", - "list": "%{name}", - "loading": "Memuat", - "not_found": "Tidak ditemukan", - "show": "%{name} #%{id}", - "empty": "Belum ada %{name}.", - "invite": "Apakah kamu ingin menambahkan satu?" - }, - "input": { - "file": { - "upload_several": "Letakkan beberapa file untuk diunggah, atau klik untuk memilih salah satu.", - "upload_single": "Letakkan file untuk diunggah, atau klik untuk memilihnya." - }, - "image": { - "upload_several": "Letakkan beberapa gambar untuk diunggah, atau klik untuk memilih salah satu.", - "upload_single": "Letakkan gambar untuk diunggah, atau klik untuk memilihnya." - }, - "references": { - "all_missing": "Tidak dapat menemukan data referensi.", - "many_missing": "Tampaknya beberapa referensi tidak tersedia.", - "single_missing": "Referensi yang ter asosiasi tidak tersedia untuk ditampilkan." - }, - "password": { - "toggle_visible": "Sembunyikan kata sandi", - "toggle_hidden": "Tampilkan kata sandi" - } - }, - "message": { - "about": "Tentang", - "are_you_sure": "Kamu Yakin?", - "bulk_delete_content": "Kamu yakin ingin menghapus %{name} ini? |||| Kamu yakin ingin menghapus %{smart_count} item ini?", - "bulk_delete_title": "Hapus %{name} |||| Hapus %{smart_count} %{name}", - "delete_content": "Kamu ingin menghapus item ini?", - "delete_title": "Hapus %{name} #%{id}", - "details": "Detail", - "error": "Terjadi kesalahan klien dan permintaan Kamu tidak dapat diselesaikan.", - "invalid_form": "Form tidak valid. Silakan periksa kesalahannya", - "loading": "Halaman sedang dimuat, mohon tunggu sebentar", - "no": "Tidak", - "not_found": "Mungkin Kamu mengetik URL yang salah, atau Kamu mengikuti tautan yang buruk.", - "yes": "Ya", - "unsaved_changes": "Beberapa perubahan tidak disimpan. Apakah Kamu yakin ingin mengabaikannya?" - }, - "navigation": { - "no_results": "Hasil tidak ditemukan", - "no_more_results": "Nomor halaman %{page} melampaui batas. Coba halaman sebelumnya.", - "page_out_of_boundaries": "Nomor halaman %{page} melampaui batas", - "page_out_from_end": "Tidak dapat menelusuri sebelum halaman terakhir", - "page_out_from_begin": "Tidak dapat menelusuri sebelum halaman 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} dari %{total}", - "page_rows_per_page": "Item per halaman:", - "next": "Selanjutnya", - "prev": "Sebelumnya", - "skip_nav": "Lewati ke konten" - }, - "notification": { - "updated": "Elemen diperbarui |||| %{smart_count} elemen diperbarui", - "created": "Elemen dibuat", - "deleted": "Elemen dihapus |||| %{smart_count} elemen dihapus", - "bad_item": "Kesalahan elemen", - "item_doesnt_exist": "Elemen tidak ditemukan", - "http_error": "Kesalahan komunikasi peladen", - "data_provider_error": "dataProvider galat. Periksa konsol untuk detailnya.", - "i18n_error": "Tidak dapat memuat terjemahan untuk bahasa yang diatur", - "canceled": "Tindakan dibatalkan", - "logged_out": "Sesi Kamu telah berakhir, harap sambungkan kembali.", - "new_version": "Tersedia versi baru! Silakan menyegarkan jendela ini." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Kolom Untuk Ditampilkan", - "layout": "Tata Letak", - "grid": "Ubin", - "table": "Tabel" - } - }, - "message": { - "note": "CATATAN", - "transcodingDisabled": "Mengubah konfigurasi transkode melalui antarmuka web dinonaktifkan karena alasan keamanan. Jika Kamu ingin mengubah (mengedit atau menambahkan) opsi transkode, restart server dengan opsi konfigurasi %{config}.", - "transcodingEnabled": "Navidrome saat ini berjalan dengan %{config}, sehingga memungkinkan untuk menjalankan perintah sistem dari pengaturan Transkode menggunakan antarmuka web. Kami sarankan untuk menonaktifkannya demi alasan keamanan dan hanya mengaktifkannya saat mengonfigurasi opsi Transcoding.", - "songsAddedToPlaylist": "Menambahkan 1 lagu ke playlist |||| Menambahkan %{smart_count} lagu ke playlist", - "noPlaylistsAvailable": "Tidak tersedia", - "delete_user_title": "Hapus pengguna '%{name}'", - "delete_user_content": "Apakah Kamu yakin ingin menghapus pengguna ini dan semua datanya (termasuk daftar putar dan preferensi)?", - "notifications_blocked": "Kamu telah memblokir Notifikasi untuk situs ini di pengaturan browser Anda", - "notifications_not_available": "Browser ini tidak mendukung notifikasi desktop atau Kamu tidak mengakses Navidrome melalui https", - "lastfmLinkSuccess": "Last.fm berhasil ditautkan dan scrobbling diaktifkan", - "lastfmLinkFailure": "Last.fm tidak dapat ditautkan", - "lastfmUnlinkSuccess": "Tautan Last.fm dibatalkan dan scrobbling dinonaktifkan", - "lastfmUnlinkFailure": "Tautan Last.fm tidak dapat dibatalkan", - "openIn": { - "lastfm": "Lihat di Last.fm", - "musicbrainz": "Lihat di MusicBrainz" - }, - "lastfmLink": "Baca selengkapnya...", - "listenBrainzLinkSuccess": "ListenBrainz berhasil ditautkan dan scrobbling diaktifkan sebagai pengguna: %{user}", - "listenBrainzLinkFailure": "ListenBrainz tidak dapat ditautkan: %{error}", - "listenBrainzUnlinkSuccess": "Tautan ListenBrainz dibatalkan dan scrobbling dinonaktifkan", - "listenBrainzUnlinkFailure": "Tautan ListenBrainz tidak dapat dibatalkan", - "downloadOriginalFormat": "Unduh dalam format asli", - "shareOriginalFormat": "Bagikan dalam format asli", - "shareDialogTitle": "Bagikan %{resource} '%{name}'", - "shareBatchDialogTitle": "Bagikan 1 %{resource} |||| Bagikan %{smart_count} %{resource}", - "shareSuccess": "URL disalin ke papan klip: %{url}", - "shareFailure": "Terjadi kesalahan saat menyalin URL %{url} ke papan klip", - "downloadDialogTitle": "Unduh %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Salin ke papan klip: Ctrl+C, Enter", - "remove_missing_title": "Hapus file yang hilang", - "remove_missing_content": "Apakah Anda yakin ingin menghapus file-file yang hilang dari basis data? Tindakan ini akan menghapus secara permanen semua referensi ke file-file tersebut, termasuk jumlah pemutaran dan peringkatnya.", - "remove_all_missing_title": "Hapus semua file yang hilang", - "remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka.", - "noSimilarSongsFound": "Tidak ada lagu yang serupa ditemukan", - "noTopSongsFound": "Tidak ada lagu teratas ditemukan", - "startingInstantMix": "Memuat Mix Instan...", - "uploadCover": "Unggah Sampul", - "removeCover": "Hapus Sampul", - "coverUploaded": "Sampul diperbarui", - "coverRemoved": "Sampul dihapus", - "coverUploadError": "Kesalahan mengunggah sampul", - "coverRemoveError": "Kesalahan menghapus sampul" - }, - "menu": { - "library": "Pustaka", - "settings": "Pengaturan", - "version": "Versi", - "theme": "Tema", - "personal": { - "name": "Personal", - "options": { - "theme": "Tema", - "language": "Bahasa", - "defaultView": "Tampilan Bawaan", - "desktop_notifications": "Pemberitahuan Desktop", - "lastfmScrobbling": "Scrobble ke Last.fm", - "listenBrainzScrobbling": "Scrobble ke ListenBrainz", - "replaygain": "Mode ReplayGain", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "Nonaktif", - "album": "Gunakan Gain Album", - "track": "Gunakan Gain Lagu" - }, - "lastfmNotConfigured": "API-Key Last.fm belum dikonfigurasi" - } - }, - "albumList": "Album", - "about": "Tentang", - "playlists": "Playlist", - "sharedPlaylists": "Playlist yang Dibagikan", - "librarySelector": { - "allLibraries": "Semua Pustaka (%{count})", - "multipleLibraries": "Pustaka %{selected} dari %{total}", - "selectLibraries": "Pilih Perpustakaan", - "none": "Tidak ada" - } - }, - "player": { - "playListsText": "Putar Antrean", - "openText": "Buka", - "closeText": "Tutup", - "notContentText": "Tidak ada musik", - "clickToPlayText": "Klik untuk memutar", - "clickToPauseText": "Klik untuk menjeda", - "nextTrackText": "Lagu Selanjutnya", - "previousTrackText": "Lagu Sebelumnya", - "reloadText": "Muat ulang", - "volumeText": "Volume", - "toggleLyricText": "Lirik", - "toggleMiniModeText": "Minimalkan", - "destroyText": "Tutup", - "downloadText": "Unduh", - "removeAudioListsText": "Hapus daftar audio", - "clickToDeleteText": "Klik untuk menghapus %{name}", - "emptyLyricText": "Tidak ada lirik", - "playModeText": { - "order": "Berurutan", - "orderLoop": "Ulang", - "singleLoop": "Ulangi Sekali", - "shufflePlay": "Acak" - } - }, - "about": { - "links": { - "homepage": "Halaman beranda", - "source": "Kode sumber", - "featureRequests": "Permintaan fitur", - "lastInsightsCollection": "Koleksi insight terakhir", - "insights": { - "disabled": "Nonaktifkan", - "waiting": "Menunggu" - } - }, - "tabs": { - "about": "Tentang", - "config": "Konfigurasi" - }, - "config": { - "configName": "Nama Konfigurasi", - "environmentVariable": "Variabel Environment", - "currentValue": "Value Saat Ini", - "configurationFile": "File Konfigurasi", - "exportToml": "Ekspor Konfigurasi (TOML)", - "exportSuccess": "Konfigurasi sudah diekspor ke papan klip dalam bentuk format TOML", - "exportFailed": "Gagal menyalin konfigurasi", - "devFlagsHeader": "Flag Pengembangan (subyek untuk perubahan/pemindahan)", - "devFlagsComment": "Ini adalan pengaturan eksperimen dan mungkin akan dihapus di versi mendatang", - "downloadToml": "Unduh Konfigurasi (TOML)" - } - }, - "activity": { - "title": "Aktivitas", - "totalScanned": "Total Folder yang Dipindai", - "quickScan": "Pemindaian Cepat", - "fullScan": "Pemindaian Penuh", - "serverUptime": "Waktu Aktif Peladen", - "serverDown": "LURING", - "scanType": "Tipe", - "status": "Kesalahan Memindai", - "elapsedTime": "Waktu Berakhir", - "selectiveScan": "Selektif" - }, - "help": { - "title": "Tombol Pintasan Navidrome", - "hotkeys": { - "show_help": "Tampilkan Bantuan Ini", - "toggle_menu": "Menu Samping", - "toggle_play": "Putar / Jeda", - "prev_song": "Lagu Sebelumnya", - "next_song": "Lagu Selanjutnya", - "vol_up": "Volume Naik", - "vol_down": "Volume Turun", - "toggle_love": "Tambahkan lagu ini ke favorit", - "current_song": "Buka Lagu Saat Ini" - } - }, - "nowPlaying": { - "title": "Sedang Diputar", - "empty": "Tidak ada yang diputar", - "minutesAgo": "%{smart_count} menit yang lalu |||| %{smart_count} menit yang lalu" - } -} \ No newline at end of file diff --git a/resources/i18n/it.json b/resources/i18n/it.json index 656043589..550ce2af0 100644 --- a/resources/i18n/it.json +++ b/resources/i18n/it.json @@ -10,48 +10,32 @@ "playCount": "Riproduzioni", "title": "Titolo", "artist": "Artista", - "composer": "Compositore", "album": "Album", "path": "Percorso", - "libraryName": "Libreria", "genre": "Genere", "compilation": "Compilation", "year": "Anno", - "size": "Dimensione file", + "size": "Dimensioni", "updatedAt": "Ultimo aggiornamento", "bitRate": "Bitrate", - "bitDepth": "Profondità di bit", - "sampleRate": "Frequenza di campionamento", - "albumGain": "Guadagno album", - "trackGain": "Guadagno traccia", - "channels": "Canali", - "disc": "Disco %{discNumber}", - "discSubtitle": "Sottotitolo disco", + "discSubtitle": "Sottotitoli disco", "starred": "Preferita", "comment": "Commento", "rating": "Valutazione", "quality": "Qualità", "bpm": "BPM", "playDate": "Ultima riproduzione", - "createdAt": "Data di aggiunta", - "grouping": "Raggruppamento", - "mood": "Umore", - "participants": "Partecipanti aggiuntivi", - "tags": "Tag aggiuntivi", - "mappedTags": "Tag mappati", - "rawTags": "Tag grezzi", - "missing": "Mancante" + "channels": "Canali", + "createdAt": "" }, "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", - "instantMix": "Mix istantaneo" + "info": "Informazioni" } }, "album": { @@ -62,45 +46,32 @@ "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 aggiunta", - "recordLabel": "Etichetta", - "catalogNum": "Numero di catalogo", - "releaseType": "Tipo", - "grouping": "Raggruppamento", - "media": "Media", - "mood": "Umore", - "missing": "Mancante" + "createdAt": "", + "size": "" }, "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" + "info": "Informazioni", + "share": "" }, "lists": { "all": "Tutti", "random": "Casuali", - "recentlyAdded": "Aggiunti di recente", - "recentlyPlayed": "Riprodotti di recente", - "mostPlayed": "I più riprodotti", + "recentlyAdded": "Aggiunti di Recente", + "recentlyPlayed": "Riprodotti di Recente", + "mostPlayed": "I Più Riprodotti", "starred": "Preferiti", "topRated": "Più votati" } @@ -111,73 +82,42 @@ "name": "Nome", "albumCount": "Album", "songCount": "Numero tracce", - "size": "Dimensione", "playCount": "Riproduzioni", "rating": "Valutazione", "genre": "Genere", - "role": "Ruolo", - "missing": "Mancante" - }, - "roles": { - "albumartist": "Artista Album |||| Artisti Album", - "artist": "Artista |||| Artisti", - "composer": "Composizione |||| Composizione", - "conductor": "Direzione d'orchestra |||| Direzione d'orchestra", - "lyricist": "Testi |||| Testi", - "arranger": "Arrangiamento |||| Arrangiamento", - "producer": "Produzione |||| Produzione", - "director": "Direzione |||| Direzione", - "engineer": "Ingegneria del suono |||| Ingegneria del suono", - "mixer": "Mixer |||| Mixer", - "remixer": "Remixer |||| Remixer", - "djmixer": "DJ Mixer |||| DJ Mixer", - "performer": "Esecuzione |||| Esecuzione", - "maincredit": "Artista Album o Artista |||| Artisti Album o Artisti" - }, - "actions": { - "topSongs": "Brani più ascoltati", - "shuffle": "Riproduci casualmente", - "radio": "Radio" + "size": "" } }, "user": { "name": "Utente |||| Utenti", "fields": { "userName": "Nome utente", - "isAdmin": "Admin", - "lastLoginAt": "Ultimo login", - "lastAccessAt": "Ultimo accesso", + "isAdmin": "Amministratore", + "lastLoginAt": "Ultimo accesso", "updatedAt": "Ultimo aggiornamento", "name": "Nome", "password": "Password", - "createdAt": "Creato il", + "createdAt": "Creato a", "changePassword": "Cambiare la password?", - "currentPassword": "Password attuale", - "newPassword": "Nuova password", - "token": "Token", - "libraries": "Librerie" + "currentPassword": "Password Attuale", + "newPassword": "Nuova Password", + "token": "Token" }, "helperTexts": { - "name": "Le modifiche al tuo nome verranno mostrate solo al prossimo accesso", - "libraries": "Seleziona librerie specifiche per questo utente, o lascia vuoto per usare le librerie predefinite" + "name": "Le modifiche effettuate al tuo nome verrano mostrate al prossimo accesso" }, "notifications": { "created": "Utente creato", "updated": "Utente aggiornato", "deleted": "Utente eliminato" }, - "validation": { - "librariesRequired": "Almeno una libreria deve essere selezionata per gli utenti non admin" - }, "message": { "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz.", - "clickHereForToken": "Clicca qui per ottenere il tuo token", - "selectAllLibraries": "Seleziona tutte le librerie", - "adminAutoLibraries": "Gli utenti admin hanno automaticamente accesso a tutte le librerie" + "clickHereForToken": "Clicca qui per ottenere il tuo token" } }, "player": { - "name": "Lettore |||| Lettori", + "name": "Client |||| Client", "fields": { "name": "Nome", "transcodingId": "Transcodifica", @@ -186,7 +126,7 @@ "userName": "Nome utente", "lastSeen": "Ultimo accesso", "reportRealPath": "Mostra percorso reale", - "scrobbleEnabled": "Invia scrobble ai servizi esterni" + "scrobbleEnabled": "" } }, "transcoding": { @@ -203,231 +143,71 @@ "fields": { "name": "Nome", "duration": "Durata", - "ownerName": "Di", + "ownerName": "Creatore", "public": "Pubblica", "updatedAt": "Ultimo aggiornamento", "createdAt": "Data creazione", "songCount": "Tracce", "comment": "Commento", "sync": "Importazione automatica", - "path": "Importa da", - "starred": "Preferita" + "path": "Importa da" }, "actions": { - "selectPlaylist": "Seleziona una playlist:", - "addNewPlaylist": "Crea \"%{name}\"", + "selectPlaylist": "Aggiungi tracce alla playlist:", + "addNewPlaylist": "Aggiungi \"%{name}\"", "export": "Esporta", - "saveQueue": "Salva la coda nella playlist", - "makePublic": "Rendi pubblica", - "makePrivate": "Rendi privata", - "searchOrCreate": "Cerca playlist o digita per crearne una nuova...", - "pressEnterToCreate": "Premi Invio per creare una nuova playlist", - "removeFromSelection": "Rimuovi dalla selezione" + "makePublic": "", + "makePrivate": "" }, "message": { - "duplicate_song": "Aggiungi tracce duplicate", - "song_exist": "Si stanno aggiungendo dei duplicati nella playlist. Vuoi aggiungerli o saltarli?", - "noPlaylistsFound": "Nessuna playlist trovata", - "noPlaylists": "Nessuna playlist disponibile" + "duplicate_song": "Aggiungere i duplicati", + "song_exist": "Stanno essendo aggiunti dei duplicati nella playlist. Vuoi aggiungerli o saltarli?" } }, "radio": { - "name": "Radio |||| Radio", + "name": "", "fields": { - "name": "Nome", - "streamUrl": "URL dello stream", - "homePageUrl": "URL della pagina web", - "updatedAt": "Ultimo aggiornamento", - "createdAt": "Data di creazione" + "name": "", + "streamUrl": "", + "homePageUrl": "", + "updatedAt": "", + "createdAt": "" }, "actions": { - "playNow": "Riproduci adesso" + "playNow": "" } }, "share": { - "name": "Condivisione |||| Condivisioni", + "name": "", "fields": { - "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": "Vuoi 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": "Permessi librerie" - }, - "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 a 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 a 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" + "username": "", + "url": "", + "description": "", + "contents": "", + "expiresAt": "", + "lastVisitedAt": "", + "visitCount": "", + "format": "", + "maxBitRate": "", + "updatedAt": "", + "createdAt": "", + "downloadable": "" } } }, "ra": { "auth": { "welcome1": "Grazie per aver installato Navidrome!", - "welcome2": "Per iniziare, crea un account amministratore", + "welcome2": "Per iniziare, crea un amministratore", "confirmPassword": "Conferma la password", - "buttonCreateAdmin": "Crea admin", + "buttonCreateAdmin": "Crea amministratore", "auth_check_error": "Per favore accedi per continuare", - "user_menu": "Profilo", + "user_menu": "Profile", "username": "Nome utente", "password": "Password", "sign_in": "Accedi", "sign_in_error": "Autenticazione fallita, per favore riprova", - "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" + "logout": "Disconnetti" }, "validation": { "invalidChars": "Per favore usa solo lettere e numeri", @@ -442,14 +222,13 @@ "oneOf": "Deve essere uno di: %{options}", "regex": "Deve rispettare il formato (espressione regolare): %{pattern}", "unique": "Deve essere unico", - "url": "Deve essere un URL valido" + "url": "" }, "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}", + "bulk_actions": "Un elemento selezionato ||| %{smart_count} elementi selezionati", "cancel": "Annulla", "clear_input_value": "Cancella", "clone": "Duplica", @@ -461,7 +240,7 @@ "list": "Elenco", "refresh": "Aggiorna", "remove_filter": "Rimuovi questo filtro", - "remove": "Rimuovi", + "remove": "Remove", "save": "Salva", "search": "Cerca", "show": "Mostra", @@ -472,16 +251,17 @@ "open_menu": "Apri menù", "close_menu": "Chiudi menù", "unselect": "Deseleziona", - "skip": "Salta", - "share": "Condividi", - "download": "Scarica" + "skip": "Saltare i duplicati", + "bulk_actions_mobile": "", + "share": "", + "download": "" }, "boolean": { - "true": "Sì", + "true": "Si", "false": "No" }, "page": { - "create": "Crea %{name}", + "create": "Aggiungi %{name}", "dashboard": "Pannello di controllo", "edit": "%{name} #%{id}", "error": "Qualcosa è andato storto", @@ -489,8 +269,8 @@ "loading": "Caricamento in corso", "not_found": "Non trovato", "show": "%{name} #%{id}", - "empty": "Ancora niente %{name}.", - "invite": "Vuoi aggiungerne?" + "empty": "Nessun %{name} per adesso.", + "invite": "Vuoi invitare un amico?" }, "input": { "file": { @@ -513,10 +293,10 @@ }, "message": { "about": "Informazioni", - "are_you_sure": "Vuoi procedere?", - "bulk_delete_content": "Vuoi rimuovere questo %{name}? |||| Vuoi rimuovere questi %{smart_count} elementi?", + "are_you_sure": "Sei sicuro?", + "bulk_delete_content": "Sei sicuro di voler rimuovere questo %{name}? |||| Sei sicuro di voler rimuovere questi %{smart_count} elementi?", "bulk_delete_title": "Rimuovi %{name} |||| Rimuovi %{smart_count} %{name}", - "delete_content": "Vuoi eliminare questo elemento?", + "delete_content": "Sei sicuro di voler eliminare questo elemento?", "delete_title": "Rimuovi %{name} #%{id}", "details": "Dettagli", "error": "Un errore dal lato client ha impedito il completamento della tua richiesta.", @@ -524,17 +304,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": "Sì", - "unsaved_changes": "Alcune modifiche non sono state salvate. Vuoi ignorarle?" + "yes": "Si", + "unsaved_changes": "Alcune modifiche non sono state salvate. Vuoi ripristinarle?" }, "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": "Elementi per pagina:", + "page_rows_per_page": "Righe per pagina:", "next": "Successivo", "prev": "Precedente", "skip_nav": "Passa al contenuto" @@ -550,7 +330,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", @@ -560,58 +340,39 @@ } }, "message": { - "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.", + "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.", "songsAddedToPlaylist": "Aggiunta una traccia alla playlist |||| Aggiunte %{smart_count} tracce alla playlist", - "noSimilarSongsFound": "Nessuna traccia simile trovata", - "startingInstantMix": "Caricamento del Mix istantaneo...", - "noTopSongsFound": "Nessun brano più ascoltato trovato", - "noPlaylistsAvailable": "Nessuna disponibile", + "noPlaylistsAvailable": "Nessuna playlist", "delete_user_title": "Rimuovi utente '%{name}'", - "delete_user_content": "Vuoi rimuovere questo utente e tutti i suoi dati (incluse playlist e impostazioni)?", - "remove_missing_title": "Rimuovi i file mancanti", - "remove_missing_content": "Vuoi 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": "Vuoi rimuovere tutti i file mancanti dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", + "delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati, incluse playlist e impostazioni?", "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 possibile collegare Last.fm", + "lastfmLinkFailure": "Non è stato possible 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ù...", - "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" + "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": "" }, "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", @@ -620,38 +381,36 @@ "options": { "theme": "Tema", "language": "Lingua", - "defaultView": "Vista predefinita", + "defaultView": "Vista Predefinita", "desktop_notifications": "Notifiche desktop", - "lastfmNotConfigured": "La chiave API di Last.fm non è configurata", "lastfmScrobbling": "Esegui lo scrobbling tramite Last.fm", - "listenBrainzScrobbling": "Esegui lo scrobbling tramite ListenBrainz", - "replaygain": "Modalità ReplayGain", - "preAmp": "ReplayGain PreAmp (dB)", + "listenBrainzScrobbling": "", + "replaygain": "", + "preAmp": "", "gain": { - "none": "Disabilitato", - "album": "Usa guadagno album", - "track": "Usa guadagno traccia" + "none": "", + "album": "", + "track": "" } } }, "albumList": "Album", - "playlists": "Playlist", - "sharedPlaylists": "Playlist condivise", "about": "Info", - "onlyFavourites": "Mostra solo i preferiti" + "playlists": "Scalette", + "sharedPlaylists": "Scalette Condivise" }, "player": { "playListsText": "Coda", "openText": "Apri", "closeText": "Chiudi", - "notContentText": "Niente musica", + "notContentText": "Nessuna traccia", "clickToPlayText": "Clicca per riprodurre", "clickToPauseText": "Clicca per mettere in pausa", "nextTrackText": "Traccia successiva", "previousTrackText": "Traccia precedente", "reloadText": "Ricarica", "volumeText": "Volume", - "toggleLyricText": "Mostra/nascondi testo", + "toggleLyricText": "Mostra testo", "toggleMiniModeText": "Minimizza", "destroyText": "Distruggi", "downloadText": "Scarica", @@ -661,7 +420,7 @@ "playModeText": { "order": "In ordine", "orderLoop": "Ripeti", - "singleLoop": "Ripeti traccia", + "singleLoop": "Ripeti una volta", "shufflePlay": "Casuale" } }, @@ -669,59 +428,29 @@ "links": { "homepage": "Sito web", "source": "Codice sorgente", - "featureRequests": "Proponi idee", - "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" + "featureRequests": "Richieste" } }, "activity": { "title": "Attività", - "totalScanned": "Totale cartelle scansionate", - "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" + "totalScanned": "Cartelle scansionate", + "quickScan": "Scansione veloce", + "fullScan": "Scansione completa", + "serverUptime": "Periodo di attività", + "serverDown": "OFFLINE" }, "help": { - "title": "Scorciatoie da tastiera di Navidrome", + "title": "Scorciatoie da Tastiera", "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" + "prev_song": "Traccia Precedente", + "next_song": "Traccia Successiva", + "vol_up": "Alza il Volume", + "vol_down": "Abbassa il Volume", + "toggle_love": "Aggiungi questa traccia ai preferiti", + "current_song": "" } } -} +} \ No newline at end of file diff --git a/resources/i18n/ja.json b/resources/i18n/ja.json index 83cd7822b..0e20124b9 100644 --- a/resources/i18n/ja.json +++ b/resources/i18n/ja.json @@ -1,733 +1,456 @@ { - "languageName": "日本語", - "resources": { - "song": { - "name": "曲", - "fields": { - "albumArtist": "アルバムアーティスト", - "duration": "再生時間", - "trackNumber": "#", - "playCount": "再生回数", - "title": "タイトル", - "artist": "アーティスト", - "composer": "作曲", - "album": "アルバム", - "path": "ファイルパス", - "libraryName": "ライブラリ", - "genre": "ジャンル", - "compilation": "コンピレーション", - "year": "年", - "size": "ファイルサイズ", - "updatedAt": "更新日時", - "bitRate": "ビットレート", - "bitDepth": "ビット深度", - "sampleRate": "サンプリングレート", - "albumGain": "アルバムゲイン", - "trackGain": "トラックゲイン", - "channels": "チャンネル", - "disc": "ディスク %{discNumber}", - "discSubtitle": "ディスクサブタイトル", - "starred": "お気に入り", - "comment": "コメント", - "rating": "評価", - "quality": "形式", - "bpm": "BPM", - "playDate": "最終再生日時", - "createdAt": "追加日時", - "grouping": "グループ化", - "mood": "ムード", - "participants": "参加者", - "tags": "追加タグ", - "mappedTags": "マッピングされたタグ", - "rawTags": "未加工タグ", - "missing": "不明" - }, - "actions": { - "addToQueue": "最後に再生", - "playNow": "今すぐ再生", - "addToPlaylist": "プレイリストに追加", - "showInPlaylist": "プレイリストで表示", - "shuffleAll": "すべてシャッフル", - "download": "ダウンロード", - "playNext": "次に再生", - "info": "詳細", - "instantMix": "インスタントミックス" - } + "languageName": "日本語", + "resources": { + "song": { + "name": "曲", + "fields": { + "albumArtist": "アルバムアーティスト", + "duration": "長さ", + "trackNumber": "#", + "playCount": "再生数", + "title": "タイトル", + "artist": "アーティスト", + "album": "アルバム", + "path": "ファイルパス", + "genre": "ジャンル", + "compilation": "Compilation", + "year": "年", + "size": "ファイルサイズ", + "updatedAt": "更新日", + "bitRate": "ビットレート", + "discSubtitle": "ディスクサブタイトル", + "starred": "お気に入り", + "comment": "コメント", + "rating": "レート", + "quality": "品質", + "bpm": "BPM", + "playDate": "最後の再生", + "channels": "チャンネル", + "createdAt": "追加日" + }, + "actions": { + "addToQueue": "最後に再生", + "playNow": "すぐに再生", + "addToPlaylist": "プレイリストに追加", + "shuffleAll": "全曲シャッフル", + "download": "ダウンロード", + "playNext": "次に再生", + "info": "詳細" + } + }, + "album": { + "name": "アルバム", + "fields": { + "albumArtist": "アルバムアーティスト", + "artist": "アーティスト", + "duration": "長さ", + "songCount": "曲", + "playCount": "再生数", + "name": "名前", + "genre": "ジャンル", + "compilation": "Compilation", + "year": "年", + "updatedAt": "更新日", + "comment": "コメント", + "rating": "レート", + "createdAt": "追加日", + "size": "サイズ" + }, + "actions": { + "playAll": "再生", + "playNext": "次に再生", + "addToQueue": "最後に再生", + "shuffle": "シャッフル", + "addToPlaylist": "プレイリストへ追加", + "download": "ダウンロード", + "info": "詳細", + "share": "共有" + }, + "lists": { + "all": "全て", + "random": "ランダム", + "recentlyAdded": "最近の追加", + "recentlyPlayed": "最近の再生", + "mostPlayed": "最も再生", + "starred": "お気に入り", + "topRated": "高評価" + } + }, + "artist": { + "name": "アーティスト", + "fields": { + "name": "名前", + "albumCount": "アルバム枚数", + "songCount": "曲数", + "playCount": "再生数", + "rating": "レート", + "genre": "ジャンル", + "size": "サイズ" + } + }, + "user": { + "name": "ユーザー", + "fields": { + "userName": "ユーザー名", + "isAdmin": "管理者", + "lastLoginAt": "最終ログイン", + "updatedAt": "更新日", + "name": "名前", + "password": "パスワード", + "createdAt": "作成日", + "changePassword": "パスワードを変更しますか?", + "currentPassword": "現在のパスワード", + "newPassword": "新しいパスワード", + "token": "トークン" + }, + "helperTexts": { + "name": "名前の変更は次回ログイン以降反映されます" + }, + "notifications": { + "created": "ユーザーが作成されました", + "updated": "ユーザーが更新されました", + "deleted": "ユーザーが削除されました" + }, + "message": { + "listenBrainzToken": "ListenBrainzユーザートークンを入力", + "clickHereForToken": "ここをクリックしトークンを入手" + } + }, + "player": { + "name": "プレイヤー", + "fields": { + "name": "名前", + "transcodingId": "トランスコード", + "maxBitRate": "最大ビットレート", + "client": "クライアント", + "userName": "ユーザ名", + "lastSeen": "最後の利用", + "reportRealPath": "実際のファイルパスを返す", + "scrobbleEnabled": "他のサービスへscrobbleする" + } + }, + "transcoding": { + "name": "トランスコード", + "fields": { + "name": "名前", + "targetFormat": "対象フォーマット", + "defaultBitRate": "デフォルトビットレート", + "command": "コマンド" + } + }, + "playlist": { + "name": "プレイリスト", + "fields": { + "name": "名前", + "duration": "時間", + "ownerName": "所有者", + "public": "公開", + "updatedAt": "更新日", + "createdAt": "作成日", + "songCount": "曲", + "comment": "コメント", + "sync": "自動インポート", + "path": "インポート元" + }, + "actions": { + "selectPlaylist": "プレイリストを選択", + "addNewPlaylist": "'%{name}' を作成", + "export": "エクスポート", + "makePublic": "公開する", + "makePrivate": "非公開にする" + }, + "message": { + "duplicate_song": "重複する曲を追加", + "song_exist": "既にプレイリストに存在する曲です。追加しますか?" + } + }, + "radio": { + "name": "ラジオ", + "fields": { + "name": "名前", + "streamUrl": "配信URL", + "homePageUrl": "ホームページURL", + "updatedAt": "更新日", + "createdAt": "作成日" + }, + "actions": { + "playNow": "すぐに再生" + } + }, + "share": { + "name": "共有", + "fields": { + "username": "共有者", + "url": "URL", + "description": "説明", + "contents": "コンテンツ", + "expiresAt": "期限切れ", + "lastVisitedAt": "最後の訪問", + "visitCount": "訪問回数", + "format": "フォーマット", + "maxBitRate": "最大ビットレート", + "updatedAt": "更新日", + "createdAt": "作成日", + "downloadable": "" + } + } }, - "album": { - "name": "アルバム", - "fields": { - "albumArtist": "アルバムアーティスト", - "artist": "アーティスト", - "duration": "再生時間", - "songCount": "曲数", - "playCount": "再生回数", - "size": "ファイルサイズ", - "name": "アルバム名", - "libraryName": "ライブラリ", - "genre": "ジャンル", - "compilation": "コンピレーション", - "year": "年", - "date": "録音日", - "originalDate": "オリジナル発売日", - "releaseDate": "発売日", - "releases": "リリース", - "released": "リリース済み", - "updatedAt": "更新日時", - "comment": "コメント", - "rating": "評価", - "createdAt": "追加日時", - "recordLabel": "レーベル", - "catalogNum": "カタログ番号", - "releaseType": "リリースタイプ", - "grouping": "グループ化", - "media": "メディア", - "mood": "ムード", - "missing": "不明" - }, - "actions": { - "playAll": "すべて再生", - "playNext": "次に再生", - "addToQueue": "最後に再生", - "share": "共有", - "shuffle": "シャッフル", - "addToPlaylist": "プレイリストに追加", - "download": "ダウンロード", - "info": "詳細", - "refresh": "メタデータを更新" - }, - "lists": { - "all": "すべて", - "random": "ランダム", - "recentlyAdded": "最近の追加", - "recentlyPlayed": "最近の再生", - "mostPlayed": "最多再生", - "starred": "お気に入り", - "topRated": "高評価" - } - }, - "artist": { - "name": "アーティスト", - "fields": { - "name": "名前", - "albumCount": "アルバム枚数", - "songCount": "曲数", - "size": "サイズ", - "playCount": "再生回数", - "rating": "評価", - "genre": "ジャンル", - "role": "役割", - "missing": "不明" - }, - "roles": { - "albumartist": "アルバムアーティスト", - "artist": "アーティスト", - "composer": "作曲", - "conductor": "指揮", - "lyricist": "作詞", - "arranger": "編曲", - "producer": "プロデューサー", - "director": "監督", - "engineer": "エンジニア", - "mixer": "ミキサー", - "remixer": "リミキサー", - "djmixer": "DJ ミキサー", - "performer": "演奏", - "maincredit": "アルバムアーティストまたはアーティスト" - }, - "actions": { - "topSongs": "人気曲", - "shuffle": "シャッフル", - "radio": "ラジオ" - } - }, - "user": { - "name": "ユーザー", - "fields": { - "userName": "ユーザー名", - "isAdmin": "管理者", - "lastLoginAt": "最終ログイン日時", - "lastAccessAt": "最終アクセス日時", - "updatedAt": "更新日時", - "name": "名前", - "password": "パスワード", - "createdAt": "作成日時", - "changePassword": "パスワードを変更しますか?", - "currentPassword": "現在のパスワード", - "newPassword": "新しいパスワード", - "token": "トークン", - "libraries": "ライブラリ", - "scrobbleFilter": "Scrobble フィルター" - }, - "helperTexts": { - "name": "ユーザー名の変更は、次回ログイン時に反映されます", - "libraries": "このユーザー用に特定のライブラリを選択するか、デフォルトのライブラリを使用する場合は空のままにしてください", - "scrobbleFilter": "これらのスマートプレイリストのルールに一致する曲は、Last.fm、ListenBrainz、または Scrobble プラグインに送信されません。スマートプレイリストと同じ JSON 構文と動作を使用します。例: {\"all\":[{\"lt\":{\"rating\":4}}]}。すべてを Scrobble する場合は空欄のままにしてください。ローカルでの再生回数には影響しません。" - }, - "notifications": { - "created": "ユーザーが作成されました", - "updated": "ユーザーが更新されました", - "deleted": "ユーザーが削除されました" - }, - "validation": { - "librariesRequired": "管理者以外のユーザーについては、少なくとも 1 つのライブラリを選択する必要があります", - "invalidScrobbleFilter": "有効なスマートプレイリストのルールである必要があります。上限、オフセット、更新遅延はサポートされていません。" - }, - "message": { - "listenBrainzToken": "ListenBrainz のユーザートークンを入力してください。", - "clickHereForToken": "トークンを取得するには、ここをクリックしてください", - "selectAllLibraries": "すべてのライブラリを選択", - "adminAutoLibraries": "管理者ユーザーは、すべてのライブラリに自動的にアクセスできます" - } - }, - "player": { - "name": "プレーヤー", - "fields": { - "name": "名前", - "transcodingId": "トランスコード", - "maxBitRate": "最大ビットレート", - "client": "クライアント", - "userName": "ユーザー名", - "lastSeen": "最終アクセス日時", - "reportRealPath": "実際のファイルパスを返す", - "scrobbleEnabled": "外部サービスに Scrobble する" - } - }, - "transcoding": { - "name": "トランスコード", - "fields": { - "name": "名前", - "targetFormat": "ターゲット形式", - "defaultBitRate": "デフォルトビットレート", - "command": "コマンド" - } - }, - "playlist": { - "name": "プレイリスト", - "fields": { - "name": "名前", - "duration": "再生時間", - "ownerName": "所有者", - "public": "公開", - "updatedAt": "更新日時", - "createdAt": "作成日時", - "songCount": "曲数", - "comment": "コメント", - "sync": "同期", - "path": "インポートパス", - "starred": "お気に入り" - }, - "actions": { - "selectPlaylist": "プレイリストを選択:", - "addNewPlaylist": "\"%{name}\" を作成", - "export": "エクスポート", - "saveQueue": "キューをプレイリストに保存", - "makePublic": "公開にする", - "makePrivate": "非公開にする", - "searchOrCreate": "プレイリストを検索するか、入力して新規作成します...", - "pressEnterToCreate": "Enter キーを押して新しいプレイリストを作成します", - "removeFromSelection": "選択から除外" - }, - "message": { - "duplicate_song": "重複している曲を追加", - "song_exist": "プレイリストに重複する曲が追加されようとしています。重複する曲を追加しますか、それともスキップしますか?", - "noPlaylistsFound": "プレイリストが見つかりませんでした", - "noPlaylists": "利用可能なプレイリストがありません" - } - }, - "radio": { - "name": "ラジオ", - "fields": { - "name": "名前", - "streamUrl": "ストリーム URL", - "homePageUrl": "ホームページ URL", - "updatedAt": "更新日時", - "createdAt": "作成日時" - }, - "actions": { - "playNow": "今すぐ再生" - } - }, - "share": { - "name": "共有", - "fields": { - "username": "共有者", - "url": "URL", - "description": "説明", - "downloadable": "ダウンロードを許可しますか?", - "contents": "コンテンツ", - "expiresAt": "有効期限", - "lastVisitedAt": "最終閲覧日時", - "visitCount": "閲覧回数", - "format": "形式", - "maxBitRate": "最大ビットレート", - "updatedAt": "更新日時", - "createdAt": "作成日時" - }, - "notifications": {}, - "actions": {} - }, - "missing": { - "name": "欠落ファイル", - "empty": "欠落しているファイルはありません", - "fields": { - "path": "ファイルパス", - "size": "ファイルサイズ", - "libraryName": "ライブラリ名", - "updatedAt": "更新日時" - }, - "actions": { - "remove": "削除", - "remove_all": "すべて削除" - }, - "notifications": { - "removed": "欠落ファイルが削除されました" - } - }, - "library": { - "name": "ライブラリ", - "fields": { - "name": "名前", - "path": "パス", - "remotePath": "リモートスキャン", - "lastScanAt": "最終スキャン日時", - "songCount": "曲数", - "albumCount": "アルバム枚数", - "artistCount": "アーティスト数", - "totalSongs": "総曲数", - "totalAlbums": "総アルバム枚数", - "totalArtists": "総アーティスト数", - "totalFolders": "フォルダー総数", - "totalFiles": "ファイル総数", - "totalMissingFiles": "欠落ファイル数", - "totalSize": "合計サイズ", - "totalDuration": "総再生時間", - "defaultNewUsers": "新規ユーザーのデフォルト設定", - "createdAt": "作成日時", - "updatedAt": "更新日時" - }, - "sections": { - "basic": "基本情報", - "statistics": "統計" - }, - "actions": { - "scan": "ライブラリをスキャン", - "quickScan": "クイックスキャン", - "fullScan": "フルスキャン", - "manageUsers": "ユーザーを管理", - "viewDetails": "詳細を表示" - }, - "notifications": { - "created": "ライブラリが正常に作成されました", - "updated": "ライブラリが正常に更新されました", - "deleted": "ライブラリが正常に削除されました", - "scanStarted": "ライブラリのスキャンが開始されました", - "quickScanStarted": "クイックスキャンが開始されました", - "fullScanStarted": "フルスキャンが開始されました", - "scanError": "スキャンを開始できませんでした。ログを確認してください", - "scanCompleted": "ライブラリのスキャンが完了しました" - }, - "validation": { - "nameRequired": "ライブラリ名は必須です", - "pathRequired": "ライブラリパスは必須です", - "pathNotDirectory": "ライブラリパスはディレクトリである必要があります", - "pathNotFound": "ライブラリパスが見つかりません", - "pathNotAccessible": "ライブラリパスにアクセスできません", - "pathInvalid": "無効なライブラリパス" - }, - "messages": { - "deleteConfirm": "このライブラリを削除してもよろしいですか?これにより、関連するすべてのデータおよびユーザーのアクセス権が削除されます。", - "scanInProgress": "スキャン中...", - "noLibrariesAssigned": "このユーザーに割り当てられたライブラリはありません" - } - }, - "plugin": { - "name": "プラグイン", - "fields": { - "id": "ID", - "name": "名前", - "description": "説明", - "version": "バージョン", - "author": "作者", - "website": "ウェブサイト", - "permissions": "権限", - "enabled": "有効", - "status": "ステータス", - "path": "パス", - "lastError": "エラー", - "hasError": "エラー", - "updatedAt": "更新日時", - "createdAt": "インストール日時", - "configKey": "キー", - "configValue": "値", - "allUsers": "すべてのユーザーを許可", - "selectedUsers": "選択されたユーザー", - "allLibraries": "すべてのライブラリを許可", - "selectedLibraries": "選択されたライブラリ", - "allowWriteAccess": "書き込み権限を許可" - }, - "sections": { - "status": "ステータス", - "info": "プラグイン情報", - "configuration": "設定", - "manifest": "マニフェスト", - "usersPermission": "ユーザー権限", - "libraryPermission": "ライブラリ権限" - }, - "status": { - "enabled": "有効", - "disabled": "無効" - }, - "actions": { - "enable": "有効化", - "disable": "無効化", - "disabledDueToError": "有効化する前にエラーを修正してください", - "disabledUsersRequired": "有効化する前にユーザーを選択してください", - "disabledLibrariesRequired": "有効化する前にライブラリを選択してください", - "addConfig": "設定を追加", - "rescan": "再スキャン" - }, - "notifications": { - "enabled": "プラグインが有効化されました", - "disabled": "プラグインが無効化されました", - "updated": "プラグインが更新されました", - "error": "プラグインの更新中にエラーが発生しました" - }, - "validation": { - "invalidJson": "設定は有効な JSON である必要があります" - }, - "messages": { - "idHelp": "ファイル名から生成されるプラグイン ID です。エージェントなどの設定オプションでこのプラグインを参照する際に使用します。", - "configHelp": "キーと値のペアを使用してプラグインを設定します。設定が不要な場合は、空のままにしてください。", - "configValidationError": "設定の検証に失敗しました:", - "schemaRenderError": "設定フォームを表示できません。プラグインのスキーマが無効である可能性があります。", - "clickPermissions": "詳細を表示するには、「権限」をクリックしてください", - "noConfig": "設定がありません", - "allUsersHelp": "有効にすると、このプラグインは、今後作成されるユーザーも含め、すべてのユーザーにアクセスできるようになります。", - "noUsers": "ユーザーが選択されていません", - "permissionReason": "理由", - "usersRequired": "このプラグインは、ユーザー情報へのアクセスを必要とします。プラグインがアクセスできるユーザーを選択するか、「すべてのユーザーを許可」を有効にしてください。", - "allLibrariesHelp": "有効にすると、このプラグインは、今後作成されるライブラリも含め、すべてのライブラリにアクセスできるようになります。", - "noLibraries": "ライブラリが選択されていません", - "librariesRequired": "このプラグインは、ライブラリ情報へのアクセスを必要とします。プラグインがアクセスできるライブラリを選択するか、「すべてのライブラリを許可」を有効にしてください。", - "allowWriteAccessHelp": "有効にすると、プラグインはライブラリディレクトリ内のファイルを変更できるようになります。デフォルトでは、プラグインのアクセス権限は読み取り専用に設定されています。", - "requiredHosts": "必須ホスト" - }, - "placeholders": { - "configKey": "キー", - "configValue": "値" - } - } - }, - "ra": { - "auth": { - "welcome1": "Navidrome をインストールしていただき、ありがとうございます!", - "welcome2": "まずは、管理者ユーザーを作成してください", - "confirmPassword": "パスワードの確認", - "buttonCreateAdmin": "管理者を作成", - "auth_check_error": "続行するにはログインしてください", - "user_menu": "プロファイル", - "username": "ユーザー名", - "password": "パスワード", - "sign_in": "ログイン", - "sign_in_error": "認証に失敗しました。もう一度お試しください", - "logout": "ログアウト", - "insightsCollectionNote": "Navidrome では、プロジェクトの改善に役立てるため、匿名化された利用データを収集しています。詳細の確認やオプトアウト(収集の停止)を行うには、[こちら]をクリックしてください" - }, - "validation": { - "invalidChars": "英数字のみを使用してください", - "passwordDoesNotMatch": "パスワードが一致しません", - "required": "必須", - "minLength": "%{min} 文字以上である必要があります", - "maxLength": "%{max} 文字以下である必要があります", - "minValue": "%{min} 桁以上である必要があります", - "maxValue": "%{max} 桁以下である必要があります", - "number": "数字である必要があります", - "email": "有効なメールアドレスである必要があります", - "oneOf": "次のいずれかである必要があります: %{options}", - "regex": "特定の形式(正規表現)に一致する必要があります: %{pattern}", - "unique": "一意である必要があります", - "url": "有効な URL である必要があります" - }, - "action": { - "add_filter": "フィルターを追加", - "add": "追加", - "back": "戻る", - "bulk_actions": "1 件選択中 |||| %{smart_count} 件選択中", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "cancel": "キャンセル", - "clear_input_value": "値を消去", - "clone": "複製", - "confirm": "確認", - "create": "作成", - "delete": "削除", - "edit": "編集", - "export": "エクスポート", - "list": "リスト", - "refresh": "更新", - "remove_filter": "このフィルターを削除", - "remove": "削除", - "save": "保存", - "search": "検索", - "show": "表示", - "sort": "並べ替え", - "undo": "元に戻す", - "expand": "展開", - "close": "閉じる", - "open_menu": "メニューを開く", - "close_menu": "メニューを閉じる", - "unselect": "選択解除", - "skip": "スキップ", - "share": "共有", - "download": "ダウンロード" - }, - "boolean": { - "true": "はい", - "false": "いいえ" - }, - "page": { - "create": "%{name} を作成", - "dashboard": "ダッシュボード", - "edit": "%{name} #%{id}", - "error": "問題が発生しました", - "list": "%{name}", - "loading": "読み込み中", - "not_found": "見つかりませんでした", - "show": "%{name} #%{id}", - "empty": "%{name} はまだありません。", - "invite": "追加してもよろしいですか?" - }, - "input": { - "file": { - "upload_several": "ファイルをドロップしてアップロードするか、クリックしてファイルを選択してください。", - "upload_single": "ファイルをドロップしてアップロードするか、クリックして選択してください。" - }, - "image": { - "upload_several": "画像をドロップしてアップロードするか、クリックして画像を選択してください。", - "upload_single": "画像をドロップしてアップロードするか、クリックして画像を選択してください。" - }, - "references": { - "all_missing": "参照データが見つかりませんでした。", - "many_missing": "参照データのうち、少なくとも 1 件が利用できなくなっています。", - "single_missing": "参照データは現在利用できません。" - }, - "password": { - "toggle_visible": "パスワードを非表示", - "toggle_hidden": "パスワードを表示" - } + "ra": { + "auth": { + "welcome1": "Navidromeをインストールいただきありがとうございます!", + "welcome2": "管理ユーザーを作成して始めましょう", + "confirmPassword": "パスワードの確認", + "buttonCreateAdmin": "管理者の作成", + "auth_check_error": "認証に失敗しました。再度ログインしてください", + "user_menu": "プロフィール", + "username": "ユーザー名", + "password": "パスワード", + "sign_in": "ログイン", + "sign_in_error": "認証に失敗しました。入力を確認してください", + "logout": "ログアウト" + }, + "validation": { + "invalidChars": "文字と数字のみを使用してください", + "passwordDoesNotMatch": "パスワードが一致しません", + "required": "必須", + "minLength": "%{min}文字以上である必要があります", + "maxLength": "%{max}文字以下である必要があります", + "minValue": "%{min}以上である必要があります", + "maxValue": "%{max}以下である必要があります", + "number": "数字である必要があります", + "email": "メールアドレスである必要があります", + "oneOf": "次のいずれかである必要があります: %{options}", + "regex": "次の正規表現形式にする必要があります: %{pattern}", + "unique": "一意である必要があります", + "url": "有効なURLを入力してください" + }, + "action": { + "add_filter": "検索条件", + "add": "追加", + "back": "戻る", + "bulk_actions": "%{smart_count}件選択", + "cancel": "キャンセル", + "clear_input_value": "空にする", + "clone": "複製", + "confirm": "確認", + "create": "作成", + "delete": "削除", + "edit": "編集", + "export": "エクスポート", + "list": "一覧", + "refresh": "更新", + "remove_filter": "検索条件を削除", + "remove": "削除", + "save": "保存", + "search": "検索", + "show": "詳細", + "sort": "並び替え", + "undo": "元に戻す", + "expand": "開く", + "close": "閉じる", + "open_menu": "開く", + "close_menu": "閉じる", + "unselect": "選択解除", + "skip": "スキップ", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "共有", + "download": "ダウンロード" + }, + "boolean": { + "true": "はい", + "false": "いいえ" + }, + "page": { + "create": "%{name} を作成", + "dashboard": "ダッシュボード", + "edit": "%{name} #%{id}", + "error": "問題が発生しました", + "list": "%{name}", + "loading": "読込中", + "not_found": "見つかりませんでした", + "show": "%{name} #%{id}", + "empty": "%{name}はありません", + "invite": "作成しますか?" + }, + "input": { + "file": { + "upload_several": "アップロードするファイルをドロップ、または選択してください", + "upload_single": "アップロードするファイルをドロップ、または選択してください" + }, + "image": { + "upload_several": "アップロードする画像をドロップ、または選択してください", + "upload_single": "アップロードする画像をドロップ、または選択してください" + }, + "references": { + "all_missing": "データが利用できなくなりました", + "many_missing": "選択したデータが利用できなくなりました", + "single_missing": "選択したデータが利用できなくなりました" + }, + "password": { + "toggle_visible": "非表示", + "toggle_hidden": "表示" + } + }, + "message": { + "about": "詳細", + "are_you_sure": "本当によろしいですか?", + "bulk_delete_content": "%{name} を削除してよろしいですか? |||| 選択した %{smart_count}件のアイテムを削除してよろしいですか?", + "bulk_delete_title": "%{name} を削除 |||| %{name} %{smart_count}件を削除", + "delete_content": "削除してよろしいですか?", + "delete_title": "%{name} #%{id} を削除", + "details": "詳細", + "error": "クライアントエラーが発生し、処理を完了できませんでした", + "invalid_form": "入力値に誤りがあります。エラーメッセージを確認してください", + "loading": "読み込み中です。しばらくお待ちください", + "no": "いいえ", + "not_found": "間違ったURLを入力したか、間違ったリンクを辿りました", + "yes": "はい", + "unsaved_changes": "行った変更が保存されていません。このページから移動してよろしいですか?" + }, + "navigation": { + "no_results": "結果が見つかりませんでした", + "no_more_results": "ページ番号 %{page} は最大のページ数を超えています。前のページに戻ってください", + "page_out_of_boundaries": "ページ番号 %{page} は最大のページ数を超えています", + "page_out_from_end": "最大のページ数より後に移動できません", + "page_out_from_begin": "1 ページより前に移動できません", + "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", + "page_rows_per_page": "表示件数:", + "next": "次", + "prev": "前", + "skip_nav": "スキップ" + }, + "notification": { + "updated": "更新しました |||| %{smart_count} 件更新しました", + "created": "作成しました", + "deleted": "削除しました |||| %{smart_count} 件削除しました", + "bad_item": "データが不正です", + "item_doesnt_exist": "データが存在しませんでした", + "http_error": "通信エラーが発生しました", + "data_provider_error": "dataProviderエラー。詳細はコンソールを確認してください", + "i18n_error": "翻訳ファイルが読み込めませんでした", + "canceled": "元に戻しました", + "logged_out": "認証に失敗しました。再度ログインしてください", + "new_version": "新しいバージョンが利用可能です!ページを更新してください。" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "表示列", + "layout": "レイアウト", + "grid": "グリッド", + "table": "テーブル" + } }, "message": { - "about": "概要", - "are_you_sure": "よろしいですか?", - "bulk_delete_content": "%{name} を削除してもよろしいですか? |||| %{smart_count} 件の項目を削除してもよろしいですか?", - "bulk_delete_title": "%{name} を削除 |||| %{smart_count} 件の %{name} を削除", - "delete_content": "この項目を削除してもよろしいですか?", - "delete_title": "%{name} #%{id} を削除", - "details": "詳細", - "error": "クライアント側でエラーが発生したため、リクエストを完了できませんでした。", - "invalid_form": "フォームが無効です。エラーを確認してください", - "loading": "ページを読み込んでいます。しばらくお待ちください", - "no": "いいえ", - "not_found": "URL を間違えて入力したか、無効なリンクをクリックした可能性があります。", - "yes": "はい", - "unsaved_changes": "変更の一部が保存されませんでした。変更を破棄してもよろしいですか?" + "note": "注意", + "transcodingDisabled": "セキュリティ上の理由から、Web インターフェイスからのトランスコード設定は無効になっています。\nこれを設定したい場合、環境変数 %{config} を設定しサーバーを再起動してください。", + "transcodingEnabled": "Navidromeは現在 %{config} の設定で実行されており、WebUIのトランスコード設定からコマンドを実行できます。\nセキュリティ上の問題から、この設定はトランスコード設定を変更する時のみ有効にすることを推奨します。", + "songsAddedToPlaylist": "プレイリストへ1曲追加しました |||| プレイリストへ%{smart_count}曲追加しました", + "noPlaylistsAvailable": "利用不可", + "delete_user_title": "'%{name}' を削除", + "delete_user_content": "このユーザーとその全てのデータ(プレイリストや設定)を削除してもよろしいですか?", + "notifications_blocked": "ブラウザの設定でこのサイトの通知がブロックされています", + "notifications_not_available": "このブラウザはデスクトップ通知をサポートしていません", + "lastfmLinkSuccess": "Last.fmとリンクしscrobbleが有効になりました", + "lastfmLinkFailure": "Last.fmとリンクできませんでした", + "lastfmUnlinkSuccess": "設定が解除され、Last.fmへのscrobbleは無効になっています", + "lastfmUnlinkFailure": "Last.fmとリンクできませんでした", + "openIn": { + "lastfm": "Last.fmで開く", + "musicbrainz": "MusicBrainzで開く" + }, + "lastfmLink": "続きを読む", + "listenBrainzLinkSuccess": "%{user} へのscrobbling設定に成功しました", + "listenBrainzLinkFailure": "ListenBrainzとのリンクに失敗しました: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainzとのリンクとscrobblingを無効化しました。", + "listenBrainzUnlinkFailure": "ListenBrainzとのリンクを解除できませんでした", + "downloadOriginalFormat": "元のフォーマットでダウンロード", + "shareOriginalFormat": "元のフォーマットで共有", + "shareDialogTitle": "%{resource} '%{name}' を共有", + "shareBatchDialogTitle": "1 %{resource} を共有 |||| %{smart_count} %{resource} を共有", + "shareSuccess": "コピーしました: %{url}", + "shareFailure": "コピーに失敗しました %{url}", + "downloadDialogTitle": "ダウンロード %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "" }, - "navigation": { - "no_results": "検索結果はありません", - "no_more_results": "ページ番号 %{page} は範囲外です。前のページに戻ってください。", - "page_out_of_boundaries": "ページ番号 %{page} は範囲外です", - "page_out_from_end": "最後のページより先には進めません", - "page_out_from_begin": "ページ 1 より前には戻れません", - "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", - "page_rows_per_page": "1 ページあたりの表示件数:", - "next": "次", - "prev": "前", - "skip_nav": "コンテンツへスキップ" - }, - "notification": { - "updated": "更新されました |||| %{smart_count} 件の項目が更新されました", - "created": "作成されました", - "deleted": "削除されました |||| %{smart_count} 件の項目が削除されました", - "bad_item": "項目が無効です", - "item_doesnt_exist": "項目が存在しません", - "http_error": "サーバー通信エラーが発生しました", - "data_provider_error": "dataProvider のエラーが発生しました。詳細についてはコンソールを確認してください。", - "i18n_error": "指定された言語の翻訳を読み込めません", - "canceled": "操作がキャンセルされました", - "logged_out": "セッションが終了しました。再接続してください。", - "new_version": "新しいバージョンが利用可能になりました!このウィンドウを更新してください。" - }, - "toggleFieldsMenu": { - "columnsToDisplay": "表示列", - "layout": "レイアウト", - "grid": "グリッド", - "table": "テーブル" - } - }, - "message": { - "uploadCover": "カバーをアップロード", - "removeCover": "カバーを削除", - "coverUploaded": "カバーアートが更新されました", - "coverRemoved": "カバーアートが削除されました", - "coverUploadError": "カバーアートのアップロード中にエラーが発生しました", - "coverRemoveError": "カバーアートの削除中にエラーが発生しました", - "metadataRefreshStarted": "メタデータの更新が開始されました", - "note": "注記", - "transcodingDisabled": "セキュリティ上の理由により、ウェブインターフェース経由でのトランスコード設定の変更は無効になっています。トランスコードのオプションを変更(編集または追加)する場合は、%{config} 設定オプションを指定してサーバーを再起動してください。", - "transcodingEnabled": "Navidrome は現在 %{config} で動作しており、ウェブインターフェースのトランスコード設定からシステムコマンドを実行できる状態になっています。セキュリティ上の理由から、この機能を無効にしておき、トランスコードのオプションを設定する際のみ有効にすることをお勧めします。", - "songsAddedToPlaylist": "1 曲をプレイリストに追加しました |||| %{smart_count} 曲をプレイリストに追加しました", - "noSimilarSongsFound": "類似の曲は見つかりませんでした", - "startingInstantMix": "インスタントミックスを読み込み中...", - "noTopSongsFound": "人気曲は見つかりませんでした", - "noPlaylistsAvailable": "利用可能なプレイリストがありません", - "delete_user_title": "ユーザー「%{name}」を削除", - "delete_user_content": "このユーザーと、そのすべてのデータ(プレイリストや設定を含む)を削除してもよろしいですか?", - "remove_missing_title": "欠落ファイルを削除", - "remove_missing_content": "選択した欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生回数や評価を含む、これらのファイルに関するすべての情報が完全に削除されます。", - "remove_all_missing_title": "すべての欠落ファイルを削除", - "remove_all_missing_content": "すべての欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生回数や評価を含む、これらのファイルに関するすべての情報が完全に削除されます。", - "notifications_blocked": "お使いのブラウザーの設定で、このサイトからの通知をブロックしています", - "notifications_not_available": "このブラウザーはデスクトップ通知に対応していないか、または Navidrome に HTTPS 経由でアクセスしていない可能性があります", - "lastfmLinkSuccess": "Last.fm との連携に成功し、Scrobbling 機能が有効になりました", - "lastfmLinkFailure": "Last.fm との連携に失敗しました", - "lastfmUnlinkSuccess": "Last.fm との連携が解除され、Scrobbling 機能が無効になりました", - "lastfmUnlinkFailure": "Last.fm との連携解除に失敗しました", - "listenBrainzLinkSuccess": "ListenBrainz との連携に成功し、ユーザー %{user} として Scrobbling 機能が有効になりました", - "listenBrainzLinkFailure": "ListenBrainz との連携に失敗しました: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz との連携が解除され、Scrobbling 機能が無効になりました", - "listenBrainzUnlinkFailure": "ListenBrainz との連携解除に失敗しました", - "openIn": { - "lastfm": "Last.fm で開く", - "musicbrainz": "MusicBrainz で開く" - }, - "lastfmLink": "続きを読む", - "shareOriginalFormat": "オリジナルの形式で共有", - "shareDialogTitle": "%{resource}「%{name}」を共有", - "shareBatchDialogTitle": "1 %{resource} を共有 |||| %{smart_count} %{resource} を共有", - "shareCopyToClipboard": "クリップボードにコピー: Ctrl+C、Enter", - "shareSuccess": "URL をクリップボードにコピーしました: %{url}", - "shareFailure": "URL %{url} をクリップボードにコピーできませんでした", - "downloadDialogTitle": "%{resource}「%{name}」(%{size}) をダウンロード", - "downloadOriginalFormat": "オリジナルの形式でダウンロード" - }, - "menu": { - "library": "ライブラリ", - "librarySelector": { - "allLibraries": "すべてのライブラリ (%{count})", - "multipleLibraries": "%{total} 件のライブラリのうち %{selected} 件", - "selectLibraries": "ライブラリを選択", - "none": "無し" - }, - "settings": "設定", - "version": "バージョン", - "theme": "テーマ", - "personal": { - "name": "個人設定", - "options": { + "menu": { + "library": "ライブラリ", + "settings": "設定", + "version": "バージョン", "theme": "テーマ", - "language": "言語", - "defaultView": "デフォルトの画面", - "desktop_notifications": "デスクトップ通知", - "lastfmNotConfigured": "Last.fm API キーが設定されていません。", - "lastfmScrobbling": "Last.fm に Scrobble", - "listenBrainzScrobbling": "ListenBrainz に Scrobble", - "replaygain": "リプレイゲインモード", - "preAmp": "リプレイゲインプリアンプ (dB)", - "gain": { - "none": "無効", - "album": "アルバムゲインを使用", - "track": "トラックゲインを使用" + "personal": { + "name": "個人設定", + "options": { + "theme": "テーマ", + "language": "言語", + "defaultView": "デフォルト画面", + "desktop_notifications": "デスクトップ通知", + "lastfmScrobbling": "Last.fmへscrobbleする", + "listenBrainzScrobbling": "ListenBrainzへscrobble", + "replaygain": "ReplayGainモード", + "preAmp": "プリアンプ", + "gain": { + "none": "無効", + "album": "アルバムゲインを使う", + "track": "トラックゲインを使う" + } + } + }, + "albumList": "アルバム", + "about": "詳細", + "playlists": "プレイリスト", + "sharedPlaylists": "共有プレイリスト" + }, + "player": { + "playListsText": "再生リスト", + "openText": "開く", + "closeText": "閉じる", + "notContentText": "音楽がありません", + "clickToPlayText": "クリックして再生", + "clickToPauseText": "一時停止", + "nextTrackText": "次の曲", + "previousTrackText": "前の曲", + "reloadText": "更新", + "volumeText": "音量", + "toggleLyricText": "歌詞を切り替え", + "toggleMiniModeText": "最小化", + "destroyText": "削除", + "downloadText": "ダウンロード", + "removeAudioListsText": "リストを空にする", + "clickToDeleteText": "クリックして%{name}を削除", + "emptyLyricText": "歌詞がありません", + "playModeText": { + "order": "順番に", + "orderLoop": "リピート", + "singleLoop": "一曲リピート", + "shufflePlay": "シャッフル" } - } }, - "albumList": "アルバム", - "playlists": "プレイリスト", - "onlyFavourites": "お気に入りのみ表示", - "sharedPlaylists": "共有プレイリスト", - "about": "概要" - }, - "player": { - "playListsText": "再生キュー", - "openText": "開く", - "closeText": "閉じる", - "notContentText": "音楽がありません", - "clickToPlayText": "クリックして再生", - "clickToPauseText": "クリックして一時停止", - "nextTrackText": "次の曲", - "previousTrackText": "前の曲", - "reloadText": "再読み込み", - "volumeText": "音量", - "toggleLyricText": "歌詞の表示 / 非表示", - "toggleMiniModeText": "最小化", - "destroyText": "破棄", - "downloadText": "ダウンロード", - "removeAudioListsText": "オーディオリストを削除", - "clickToDeleteText": "クリックして %{name} を削除", - "emptyLyricText": "歌詞がありません", - "playModeText": { - "order": "順番に再生", - "orderLoop": "繰り返し再生", - "singleLoop": "1 曲のみ繰り返し再生", - "shufflePlay": "シャッフル" - } - }, - "about": { - "links": { - "homepage": "ホームページ", - "source": "ソースコード", - "featureRequests": "機能リクエスト", - "lastInsightsCollection": "最終データ収集日時", - "insights": { - "disabled": "無効", - "waiting": "待機中" - } + "about": { + "links": { + "homepage": "ホームページ", + "source": "ソースコード", + "featureRequests": "機能リクエスト" + } }, - "tabs": { - "about": "概要", - "config": "設定" + "activity": { + "title": "活動", + "totalScanned": "スキャン済みフォルダー", + "quickScan": "クイックスキャン", + "fullScan": "フルスキャン", + "serverUptime": "サーバー稼働時間", + "serverDown": "サーバーオフライン" }, - "config": { - "configName": "設定名", - "environmentVariable": "環境変数", - "currentValue": "現在値", - "configurationFile": "設定ファイル", - "exportToml": "設定をエクスポート (TOML)", - "downloadToml": "設定をダウンロード (TOML)", - "exportSuccess": "設定が TOML 形式でクリップボードにエクスポートされました", - "exportFailed": "設定のコピーに失敗しました", - "devFlagsHeader": "開発用フラグ(変更または削除される可能性があります)", - "devFlagsComment": "これらは実験的な設定であり、将来のバージョンで削除される可能性があります" + "help": { + "title": "ホットキー", + "hotkeys": { + "show_help": "このヘルプを表示", + "toggle_menu": "サイドバーの表示/非表示", + "toggle_play": "再生/停止", + "prev_song": "前の曲", + "next_song": "次の曲", + "vol_up": "音量を上げる", + "vol_down": "音量を下げる", + "toggle_love": "星の付け外し", + "current_song": "現在の曲へ移動" + } } - }, - "activity": { - "title": "アクティビティ", - "totalScanned": "スキャンされたフォルダーの総数", - "quickScan": "クイック", - "fullScan": "フル", - "selectiveScan": "選択的", - "serverUptime": "サーバー稼働時間", - "serverDown": "オフライン", - "scanType": "最終スキャン", - "status": "スキャンエラー", - "elapsedTime": "経過時間" - }, - "nowPlaying": { - "title": "再生中", - "empty": "再生中の曲はありません", - "minutesAgo": "%{smart_count} 分前" - }, - "help": { - "title": "Navidrome ホットキー", - "hotkeys": { - "show_help": "ヘルプを表示", - "toggle_menu": "サイドバーの表示 / 非表示", - "toggle_play": "再生 / 一時停止", - "prev_song": "前の曲", - "next_song": "次の曲", - "current_song": "再生中の曲へ移動", - "vol_up": "音量を上げる", - "vol_down": "音量を下げる", - "toggle_love": "この曲をお気に入り登録 / 登録解除" - } - } -} +} \ No newline at end of file diff --git a/resources/i18n/ko.json b/resources/i18n/ko.json deleted file mode 100644 index 6b81e02d8..000000000 --- a/resources/i18n/ko.json +++ /dev/null @@ -1,629 +0,0 @@ -{ - "languageName": "한국어", - "resources": { - "song": { - "name": "노래 |||| 노래들", - "fields": { - "albumArtist": "앨범 아티스트", - "duration": "시간", - "trackNumber": "#", - "playCount": "재생 횟수", - "title": "제목", - "artist": "아티스트", - "album": "앨범", - "path": "파일 경로", - "libraryName": "라이브러리", - "genre": "장르", - "compilation": "컴필레이션", - "year": "년", - "size": "파일 크기", - "updatedAt": "업데이트됨", - "bitRate": "비트레이트", - "bitDepth": "비트 심도", - "sampleRate": "샘플레이트", - "channels": "채널", - "discSubtitle": "디스크 서브타이틀", - "starred": "즐겨찾기", - "comment": "댓글", - "rating": "평가", - "quality": "품질", - "bpm": "BPM", - "playDate": "마지막 재생", - "createdAt": "추가된 날짜", - "grouping": "그룹", - "mood": "분위기", - "participants": "추가 참가자", - "tags": "추가 태그", - "mappedTags": "매핑된 태그", - "rawTags": "원시 태그", - "missing": "누락" - }, - "actions": { - "addToQueue": "나중에 재생", - "playNow": "지금 재생", - "addToPlaylist": "재생목록에 추가", - "shuffleAll": "모든 노래 셔플", - "download": "다운로드", - "playNext": "다음 재생", - "info": "정보 얻기" - } - }, - "album": { - "name": "앨범 |||| 앨범들", - "fields": { - "albumArtist": "앨범 아티스트", - "artist": "아티스트", - "duration": "시간", - "songCount": "노래", - "playCount": "재생 횟수", - "size": "크기", - "name": "이름", - "libraryName": "라이브러리", - "genre": "장르", - "compilation": "컴필레이션", - "year": "년", - "date": "녹음 날짜", - "originalDate": "오리지널", - "releaseDate": "발매일", - "releases": "발매 음반 |||| 발매 음반들", - "released": "발매됨", - "updatedAt": "업데이트됨", - "comment": "댓글", - "rating": "평가", - "createdAt": "추가된 날짜", - "recordLabel": "레이블", - "catalogNum": "카탈로그 번호", - "releaseType": "유형", - "grouping": "그룹", - "media": "미디어", - "mood": "분위기", - "missing": "누락" - }, - "actions": { - "playAll": "재생", - "playNext": "다음 재생", - "addToQueue": "나중에 재생", - "share": "공유", - "shuffle": "셔플", - "addToPlaylist": "재생목록 추가", - "download": "다운로드", - "info": "정보 얻기" - }, - "lists": { - "all": "모두", - "random": "랜덤", - "recentlyAdded": "최근 추가됨", - "recentlyPlayed": "최근 재생됨", - "mostPlayed": "가장 많이 재생됨", - "starred": "즐겨찾기", - "topRated": "높은 평가" - } - }, - "artist": { - "name": "아티스트 |||| 아티스트들", - "fields": { - "name": "이름", - "albumCount": "앨범 수", - "songCount": "노래 수", - "size": "크기", - "playCount": "재생 횟수", - "rating": "평가", - "genre": "장르", - "role": "역할", - "missing": "누락" - }, - "roles": { - "albumartist": "앨범 아티스트 |||| 앨범 아티스트들", - "artist": "아티스트 |||| 아티스트들", - "composer": "작곡가 |||| 작곡가들", - "conductor": "지휘자 |||| 지휘자들", - "lyricist": "작사가 |||| 작사가들", - "arranger": "편곡가 |||| 편곡가들", - "producer": "제작자 |||| 제작자들", - "director": "감독 |||| 감독들", - "engineer": "엔지니어 |||| 엔지니어들", - "mixer": "믹서 |||| 믹서들", - "remixer": "리믹서 |||| 리믹서들", - "djmixer": "DJ 믹서 |||| DJ 믹서들", - "performer": "공연자 |||| 공연자들", - "maincredit": "앨범 아티스트 또는 아티스트 |||| 앨범 아티스트들 또는 아티스트들" - }, - "actions": { - "topSongs": "인기곡", - "shuffle": "셔플", - "radio": "라디오" - } - }, - "user": { - "name": "사용자 |||| 사용자들", - "fields": { - "userName": "사용자이름", - "isAdmin": "관리자", - "lastLoginAt": "마지막 로그인", - "lastAccessAt": "마지막 접속", - "updatedAt": "업데이트됨", - "name": "이름", - "password": "비밀번호", - "createdAt": "생성됨", - "changePassword": "비밀번호를 변경할까요?", - "currentPassword": "현재 비밀번호", - "newPassword": "새 비밀번호", - "token": "토큰", - "libraries": "라이브러리" - }, - "helperTexts": { - "name": "이름 변경 사항은 다음 로그인 시에만 반영됨", - "libraries": "이 사용자에 대한 특정 라이브러리를 선택하거나 기본 라이브러리를 사용하려면 비움" - }, - "notifications": { - "created": "사용자 생성됨", - "updated": "사용자 업데이트됨", - "deleted": "사용자 삭제됨" - }, - "validation": { - "librariesRequired": "관리자가 아닌 사용자의 경우 최소한 하나의 라이브러리를 선택해야 함" - }, - "message": { - "listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.", - "clickHereForToken": "여기를 클릭하여 토큰을 얻으세요", - "selectAllLibraries": "모든 라이브러리 선택", - "adminAutoLibraries": "관리자 사용자는 자동으로 모든 라이브러리에 접속할 수 있음" - } - }, - "player": { - "name": "플레이어 |||| 플레이어들", - "fields": { - "name": "이름", - "transcodingId": "트랜스코딩", - "maxBitRate": "최대 비트레이트", - "client": "클라이언트", - "userName": "사용자이름", - "lastSeen": "마지막으로 봤음", - "reportRealPath": "실제 경로 보고서", - "scrobbleEnabled": "외부 서비스에 스크로블 보내기" - } - }, -"transcoding": { - "name": "트랜스코딩 |||| 트랜스코딩", - "fields": { - "name": "이름", - "targetFormat": "대상 형식", - "defaultBitRate": "기본 비트레이트", - "command": "명령" - } - }, - "playlist": { - "name": "재생목록 |||| 재생목록들", - "fields": { - "name": "이름", - "duration": "지속", - "ownerName": "소유자", - "public": "공개", - "updatedAt": "업데이트됨", - "createdAt": "생성됨", - "songCount": "노래", - "comment": "댓글", - "sync": "자동 가져오기", - "path": "다음에서 가져오기" - }, - "actions": { - "selectPlaylist": "재생목록 선택:", - "addNewPlaylist": "\"%{name}\" 만들기", - "export": "내보내기", - "saveQueue": "재생목록에 대기열 저장", - "makePublic": "공개 만들기", - "makePrivate": "비공개 만들기", - "searchOrCreate": "재생목록을 검색하거나 입력하여 새 재생목록을 만드세요...", - "pressEnterToCreate": "새 재생목록을 만드려면 Enter 키를 누름", - "removeFromSelection": "선택에서 제거" - }, - "message": { - "duplicate_song": "중복된 노래 추가", - "song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?", - "noPlaylistsFound": "재생목록을 찾을 수 없음", - "noPlaylists": "사용 가능한 재생 목록이 없음" - } - }, - "radio": { - "name": "라디오 |||| 라디오들", - "fields": { - "name": "이름", - "streamUrl": "스트리밍 URL", - "homePageUrl": "홈페이지 URL", - "updatedAt": "업데이트됨", - "createdAt": "생성됨" - }, - "actions": { - "playNow": "지금 재생" - } - }, - "share": { - "name": "공유 |||| 공유되는 것들", - "fields": { - "username": "공유됨", - "url": "URL", - "description": "설명", - "downloadable": "다운로드를 허용할까요?", - "contents": "컨텐츠", - "expiresAt": "만료", - "lastVisitedAt": "마지막 방문", - "visitCount": "방문 수", - "format": "형식", - "maxBitRate": "최대 비트레이트", - "updatedAt": "업데이트됨", - "createdAt": "생성됨" - }, - "notifications": {}, - "actions": {} - }, - "missing": { - "name": "누락 파일 |||| 누락 파일들", - "empty": "누락 파일 없음", - "fields": { - "path": "경로", - "size": "크기", - "libraryName": "라이브러리", - "updatedAt": "사라짐" - }, - "actions": { - "remove": "제거", - "remove_all": "모두 제거" - }, - "notifications": { - "removed": "누락된 파일이 제거되었음" - } - }, - "library": { - "name": "라이브러리 |||| 라이브러리들", - "fields": { - "name": "이름", - "path": "경로", - "remotePath": "원격 경로", - "lastScanAt": "최근 스캔", - "songCount": "노래", - "albumCount": "앨범", - "artistCount": "아티스트", - "totalSongs": "노래", - "totalAlbums": "앨범", - "totalArtists": "아티스트", - "totalFolders": "폴더", - "totalFiles": "파일", - "totalMissingFiles": "누락된 파일", - "totalSize": "총 크기", - "totalDuration": "기간", - "defaultNewUsers": "신규 사용자 기본값", - "createdAt": "생성됨", - "updatedAt": "업데이트됨" - }, - "sections": { - "basic": "기본 정보", - "statistics": "통계" - }, - "actions": { - "scan": "라이브러리 스캔", - "manageUsers": "자용자 접속 관리", - "viewDetails": "상세 보기" - }, - "notifications": { - "created": "라이브러리가 성공적으로 생성됨", - "updated": "라이브러리가 성공적으로 업데이트됨", - "deleted": "라이브러리가 성공적으로 삭제됨", - "scanStarted": "라이브러리 스캔 스작됨", - "scanCompleted": "라이브러리 스캔 완료됨" - }, - "validation": { - "nameRequired": "라이브러리 이름이 필요함", - "pathRequired": "라이브러리 경로가 필요함", - "pathNotDirectory": "라이브러리 경로는 디렉터리여야 함", - "pathNotFound": "라이브러리 경로를 찾을 수 없음", - "pathNotAccessible": "라이브러리 경로에 접근할 수 없음", - "pathInvalid": "잘못된 라이브러리 경로" - }, - "messages": { - "deleteConfirm": "이 라이브러리를 삭제할까요? 삭제하면 연결된 모든 데이터와 사용자 접속 권한이 제거됩니다.", - "scanInProgress": "스캔 진행 중...", - "noLibrariesAssigned": "이 사용자에게 할당된 라이브러리가 없음" - } - } - }, - "ra": { - "auth": { - "welcome1": "Navidrome을 설치해 주셔서 감사합니다!", - "welcome2": "관리자를 만들고 시작해 보세요", - "confirmPassword": "비밀번호 확인", - "buttonCreateAdmin": "관리자 만들기", - "auth_check_error": "계속하려면 로그인하세요", - "user_menu": "프로파일", - "username": "사용자이름", - "password": "비밀번호", - "sign_in": "가입", - "sign_in_error": "인증에 실패했습니다. 다시 시도하세요", - "logout": "로그아웃", - "insightsCollectionNote": "Navidrome은 프로젝트 개선을 위해 익명의 사용 데이터를\n수집합니다. 자세한 내용을 알아보고 원치 않으시면 [여기]를\n클릭하여 수신 거부하세요" - }, - "validation": { - "invalidChars": "문자와 숫자만 사용하세요", - "passwordDoesNotMatch": "비밀번호가 일치하지 않음", - "required": "필수 항목임", - "minLength": "%{min}자 이하여야 함", - "maxLength": "%{max}자 이하여야 함", - "minValue": "%{min}자 이상이어야 함", - "maxValue": "%{max}자 이하여야 함", - "number": "숫자여야 함", - "email": "유효한 이메일이어야 함", - "oneOf": "다음 중 하나여야 함: %{options}", - "regex": "특정 형식(정규식)과 일치해야 함: %{pattern}", - "unique": "고유해야 함", - "url": "유효한 URL이어야 함" - }, - "action": { - "add_filter": "필터 추가", - "add": "추가", - "back": "뒤로 가기", - "bulk_actions": "1 item selected |||| %{smart_count} 개 항목이 선택되었음", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "cancel": "취소", - "clear_input_value": "값 지우기", - "clone": "복제", - "confirm": "확인", - "create": "만들기", - "delete": "삭제", - "edit": "편집", - "export": "내보내기", - "list": "목록", - "refresh": "새로 고침", - "remove_filter": "이 필터 제거", - "remove": "제거", - "save": "저장", - "search": "검색", - "show": "표시", - "sort": "정렬", - "undo": "실행 취소", - "expand": "확장", - "close": "닫기", - "open_menu": "메뉴 열기", - "close_menu": "메뉴 닫기", - "unselect": "선택 해제", - "skip": "건너뛰기", - "share": "공유", - "download": "다운로드" - }, - "boolean": { - "true": "예", - "false": "아니오" - }, - "page": { - "create": "%{name} 만들기", - "dashboard": "대시보드", - "edit": "%{name} #%{id}", - "error": "문제가 발생하였음", - "list": "%{name}", - "loading": "불러오기 중", - "not_found": "찾을 수 없음", - "show": "%{name} #%{id}", - "empty": "아직 %{name}이(가) 없습니다.", - "invite": "추가할까요?" - }, - "input": { - "file": { - "upload_several": "업로드할 파일을 몇 개 놓거나 클릭하여 하나를 선택하세요.", - "upload_single": "업로드할 파일을 몇 개 놓거나 클릭하여 선택하세요." - }, - "image": { - "upload_several": "업로드할 사진을 몇 개 놓거나 클릭하여 하나를 선택하세요.", - "upload_single": "업로드할 사진을 몇 개 놓거나 클릭하여 선택하세요." - }, - "references": { - "all_missing": "참조 데이터를 찾을 수 없습니다.", - "many_missing": "연관된 참조 중 적어도 하나는 더 이상 사용할 수 없는 것 같습니다.", - "single_missing": "연관된 참조는 더 이상 사용할 수 없는 것 같습니다." - }, - "password": { - "toggle_visible": "비밀번호 숨기기", - "toggle_hidden": "비밀번호 표시" - } - }, - "message": { - "about": "정보", - "are_you_sure": "확실한가요?", - "bulk_delete_content": "이 %{name}을(를) 삭제할까요? |||| 이 %{smart_count} 개의 항목을 삭제할까요?", - "bulk_delete_title": "%{name} 삭제 |||| %{smart_count} %{name} 삭제", - "delete_content": "이 항목을 삭제할까요?", - "delete_title": "%{name} #%{id} 삭제", - "details": "상세정보", - "error": "클라이언트 오류가 발생하여 요청을 완료할 수 없습니다.", - "invalid_form": "양식이 유효하지 않습니다. 오류를 확인하세요", - "loading": "페이지가 로드 중입니다. 잠시만 기다려 주세요", - "no": "아니오", - "not_found": "잘못된 URL을 입력했거나 잘못된 링크를 클릭했습니다.", - "yes": "예", - "unsaved_changes": "일부 변경 사항이 저장되지 않았습니다. 무시할까요?" - }, - "navigation": { - "no_results": "결과를 찾을 수 없음", - "no_more_results": "페이지 번호 %{page}이(가) 경계를 벗어났습니다. 이전 페이지를 시도해 보세요.", - "page_out_of_boundaries": "페이지 번호 %{page}이(가) 경계를 벗어남", - "page_out_from_end": "마지막 페이지 뒤로 갈 수 없음", - "page_out_from_begin": "1 페이지 앞으로 갈 수 없음", - "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", - "page_rows_per_page": "페이지당 항목:", - "next": "다음", - "prev": "이전", - "skip_nav": "콘텐츠 건너뛰기" - }, - "notification": { - "updated": "요소 업데이트됨 |||| %{smart_count} 개 요소 업데이트됨", - "created": "요소 생성됨", - "deleted": "요소 삭제됨 |||| %{smart_count} 개 요소 삭제됨", - "bad_item": "잘못된 요소", - "item_doesnt_exist": "요소가 존재하지 않음", - "http_error": "서버 통신 오류", - "data_provider_error": "dataProvider 오류입니다. 자세한 내용은 콘솔을 확인하세요.", - "i18n_error": "지정된 언어에 대한 번역을 로드할 수 없음", - "canceled": "작업이 취소됨", - "logged_out": "세션이 종료되었습니다. 다시 연결하세요.", - "new_version": "새로운 버전이 출시되었습니다! 이 창을 새로 고침하세요." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "표시할 열", - "layout": "레이아웃", - "grid": "격자", - "table": "표" - } - }, - "message": { - "note": "참고", - "transcodingDisabled": "웹 인터페이스를 통한 트랜스코딩 구성 변경은 보안상의 이유로 비활성화되어 있습니다. 트랜스코딩 옵션을 변경(편집 또는 추가)하려면, %{config} 구성 옵션으로 서버를 다시 시작하세요.", - "transcodingEnabled": "Navidrome은 현재 %{config}로 실행 중이므로 웹 인터페이스를 사용하여 트랜스코딩 설정에서 시스템 명령을 실행할 수 있습니다. 보안상의 이유로 비활성화하고 트랜스코딩 옵션을 구성할 때만 활성화하는 것이 좋습니다.", - "songsAddedToPlaylist": "1 개의 노래를 재생목록에 추가하였음 |||| %{smart_count} 개의 노래를 재생 목록에 추가하였음", - "noSimilarSongsFound": "비슷한 노래를 찾을 수 없음", - "noTopSongsFound": "인기곡을 찾을 수 없음", - "noPlaylistsAvailable": "사용 가능한 노래 없음", - "delete_user_title": "사용자 '%{name}' 삭제", - "delete_user_content": "이 사용자와 해당 사용자의 모든 데이터(재생 목록 및 환경 설정 포함)를 삭제할까요?", - "remove_missing_title": "누락된 파일들 제거", - "remove_missing_content": "선택한 누락된 파일을 데이터베이스에서 삭제할까요? 삭제하면 재생 횟수 및 평점을 포함하여 해당 파일에 대한 모든 참조가 영구적으로 삭제됩니다.", - "remove_all_missing_title": "누락된 모든 파일 제거", - "remove_all_missing_content": "데이터베이스에서 누락된 모든 파일을 제거할까요? 이렇게 하면 해당 게임의 플레이 횟수와 평점을 포함한 모든 참조 내용이 영구적으로 삭제됩니다.", - "notifications_blocked": "브라우저 설정에서 이 사이트의 알림을 차단하였음", - "notifications_not_available": "이 브라우저는 데스크톱 알림을 지원하지 않거나 https를 통해 Navidrome에 접속하고 있지 않음", - "lastfmLinkSuccess": "Last.fm이 성공적으로 연결되었고 스크로블링이 활성화되었음", - "lastfmLinkFailure": "Last.fm을 연결 해제할 수 없음", - "lastfmUnlinkSuccess": "Last.fm 연결 해제 및 스크로블링 비활성화", - "lastfmUnlinkFailure": "Last.fm을 연결 해제할 수 없음", - "listenBrainzLinkSuccess": "ListenBrainz가 성공적으로 연결되었고, 다음 사용자로 스크로블링이 활성화되었음: %{user}", - "listenBrainzLinkFailure": "ListenBrainz를 연결할 수 없음: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz가 연결 해제되었고 스크로블링이 비활성화되었음", - "listenBrainzUnlinkFailure": "ListenBrainz를 연결 해제할 수 없음", - "openIn": { - "lastfm": "Last.fm에서 열기", - "musicbrainz": "MusicBrainz에서 열기" - }, - "lastfmLink": "더 읽기...", - "shareOriginalFormat": "오리지널 형식으로 공유", - "shareDialogTitle": "%{resource} '%{name}' 공유", - "shareBatchDialogTitle": "1 %{resource} 공유 |||| %{smart_count} %{resource} 공유", - "shareCopyToClipboard": "클립보드에 복사: Ctrl+C, Enter", - "shareSuccess": "URL이 클립보드에 복사되었음: %{url}", - "shareFailure": "URL %{url}을 클립보드에 복사하는 중 오류가 발생하였음", - "downloadDialogTitle": "%{resource} '%{name}' (%{size}) 다운로드", - "downloadOriginalFormat": "오리지널 형식으로 다운로드" - }, - "menu": { - "library": "라이브러리", - "librarySelector": { - "allLibraries": "모든 라이브러리 (%{count})", - "multipleLibraries": "%{selected} / %{total} 라이브러리", - "selectLibraries": "라이브러리 선택", - "none": "없음" - }, - "settings": "설정", - "version": "버전", - "theme": "테마", - "personal": { - "name": "개인 설정", - "options": { - "theme": "테마", - "language": "언어", - "defaultView": "기본 보기", - "desktop_notifications": "데스크톱 알림", - "lastfmNotConfigured": "Last.fm API 키가 구성되지 않았음", - "lastfmScrobbling": "Last.fm으로 스크로블", - "listenBrainzScrobbling": "ListenBrainz로 스크로블", - "replaygain": "리플레이게인 모드", - "preAmp": "리플레이게인 프리앰프 (dB)", - "gain": { - "none": "비활성화", - "album": "앨범 게인 사용", - "track": "트랙 게인 사용" - } - } - }, - "albumList": "앨범", - "playlists": "재생목록", - "sharedPlaylists": "공유된 재생목록", - "about": "정보" - }, - "player": { - "playListsText": "대기열 재생", - "openText": "열기", - "closeText": "닫기", - "notContentText": "음악 없음", - "clickToPlayText": "재생하려면 클릭", - "clickToPauseText": "일시 중지하려면 클릭", - "nextTrackText": "다음 트랙", - "previousTrackText": "이전 트랙", - "reloadText": "다시 로드하기", - "volumeText": "볼륨", - "toggleLyricText": "가사 전환", - "toggleMiniModeText": "최소화", - "destroyText": "제거", - "downloadText": "다운로드", - "removeAudioListsText": "오디오 목록 삭제", - "clickToDeleteText": "%{name}을(를) 삭제하려면 클릭", - "emptyLyricText": "가사 없음", - "playModeText": { - "order": "순서대로", - "orderLoop": "반복", - "singleLoop": "노래 하나 반복", - "shufflePlay": "셔플" - } - }, - "about": { - "links": { - "homepage": "홈페이지", - "source": "소스 코드", - "featureRequests": "기능 요청", - "lastInsightsCollection": "마지막 인사이트 컬렉션", - "insights": { - "disabled": "비활성화", - "waiting": "대기중" - } - }, - "tabs": { - "about": "정보", - "config": "구성" - }, - "config": { - "configName": "구성 이름", - "environmentVariable": "환경 변수", - "currentValue": "현재 값", - "configurationFile": "구성 파일", - "exportToml": "구성 내보내기 (TOML)", - "exportSuccess": "TOML 형식으로 클립보드로 내보낸 구성", - "exportFailed": "구성 복사 실패", - "devFlagsHeader": "개발 플래그 (변경/삭제 가능)", - "devFlagsComment": "이는 실험적 설정이므로 향후 버전에서 제거될 수 있음" - } - }, - "activity": { - "title": "활동", - "totalScanned": "스캔된 전체 폴더", - "quickScan": "빠른 스캔", - "fullScan": "전체 스캔", - "serverUptime": "서버 가동 시간", - "serverDown": "오프라인", - "scanType": "유형", - "status": "스캔 오류", - "elapsedTime": "경과 시간" - }, - "nowPlaying": { - "title": "현재 재생 중", - "empty": "재생 중인 콘텐츠 없음", - "minutesAgo": "%{smart_count} 분 전" - }, - "help": { - "title": "Navidrome 단축키", - "hotkeys": { - "show_help": "이 도움말 표시", - "toggle_menu": "메뉴 사이드바 전환", - "toggle_play": "재생 / 일시 중지", - "prev_song": "이전 노래", - "next_song": "다음 노래", - "current_song": "현재 노래로 이동", - "vol_up": "볼륨 높이기", - "vol_down": "볼륨 낮추기", - "toggle_love": "이 트랙을 즐겨찾기에 추가" - } - } -} diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index cb276b8f4..e9f7f43be 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -1,745 +1,456 @@ { - "languageName": "Nederlands", - "resources": { - "song": { - "name": "Nummer |||| Nummers", - "fields": { - "albumArtist": "Album Artiest", - "duration": "Afspeelduur", - "trackNumber": "Nummer #", - "playCount": "Aantal keren afgespeeld", - "title": "Titel", - "artist": "Artiest", - "album": "Album", - "path": "Bestandspad", - "genre": "Genre", - "compilation": "Compilatie", - "year": "Jaar", - "size": "Bestandsgrootte", - "updatedAt": "Laatst bijgewerkt op", - "bitRate": "Bitrate", - "discSubtitle": "Schijfondertitel", - "starred": "Favoriet", - "comment": "Commentaar", - "rating": "Beoordeling", - "quality": "Kwaliteit", - "bpm": "BPM", - "playDate": "Laatst afgespeeld", - "channels": "Kanalen", - "createdAt": "Datum toegevoegd", - "grouping": "Groep", - "mood": "Sfeer", - "participants": "Extra deelnemers", - "tags": "Extra tags", - "mappedTags": "Gemapte tags", - "rawTags": "Onbewerkte tags", - "bitDepth": "Bit diepte", - "sampleRate": "Sample waarde", - "missing": "Ontbrekend", - "libraryName": "Bibliotheek", - "composer": "Componist", - "disc": "Schijf %{discNumber}", - "albumGain": "Album gain", - "trackGain": "Nummer gain" - }, - "actions": { - "addToQueue": "Voeg toe aan wachtrij", - "playNow": "Nu afspelen", - "addToPlaylist": "Voeg toe aan afspeellijst", - "shuffleAll": "Shuffle alles", - "download": "Downloaden", - "playNext": "Volgende", - "info": "Meer info", - "showInPlaylist": "Toon in afspeellijst", - "instantMix": "Instant mix" - } + "languageName": "Nederlands", + "resources": { + "song": { + "name": "Nummer |||| Nummers", + "fields": { + "albumArtist": "Album Artiest", + "duration": "Lengte", + "trackNumber": "Nummer #", + "playCount": "Aantal keren afgespeeld", + "title": "Titel", + "artist": "Artiest", + "album": "Album", + "path": "Bestandspad", + "genre": "Genre", + "compilation": "Compilatie", + "year": "Jaar", + "size": "Bestandsgrootte", + "updatedAt": "Laatst bijgewerkt op", + "bitRate": "Bitrate", + "discSubtitle": "Schijfondertitel", + "starred": "Favoriet", + "comment": "Commentaar", + "rating": "Beoordeling", + "quality": "Kwaliteit", + "bpm": "BPM", + "playDate": "Laatst afgespeeld", + "channels": "Kanalen", + "createdAt": "Datum toegevoegd" + }, + "actions": { + "addToQueue": "Voeg toe aan wachtrij", + "playNow": "Nu afspelen", + "addToPlaylist": "Voeg toe aan afspeellijst", + "shuffleAll": "Shuffle alles", + "download": "Downloaden", + "playNext": "Volgende", + "info": "Meer info" + } + }, + "album": { + "name": "Album |||| Albums", + "fields": { + "albumArtist": "Album Artiest", + "artist": "Artiest", + "duration": "Afspeelduur", + "songCount": "Nummers", + "playCount": "Aantal keren afgespeeld", + "name": "Naam", + "genre": "Genre", + "compilation": "Compilatie", + "year": "Jaar", + "updatedAt": "Bijgewerkt op", + "comment": "Commentaar", + "rating": "Beoordeling", + "createdAt": "Datum toegevoegd", + "size": "Grootte" + }, + "actions": { + "playAll": "Afspelen", + "playNext": "Hierna afspelen", + "addToQueue": "Voeg toe aan wachtrij", + "shuffle": "Shuffle", + "addToPlaylist": "Toevoegen aan afspeellijst", + "download": "Downloaden", + "info": "Meer info", + "share": "Delen" + }, + "lists": { + "all": "Alle", + "random": "Willekeurig", + "recentlyAdded": "Onlangs toegevoegd", + "recentlyPlayed": "Onlangs afgespeeld", + "mostPlayed": "Meest afgespeeld", + "starred": "Favoriet", + "topRated": "Best beoordeeld" + } + }, + "artist": { + "name": "Artiest |||| Artiesten", + "fields": { + "name": "Naam", + "albumCount": "Aantal albums", + "songCount": "Aantal nummers", + "playCount": "Afgespeeld", + "rating": "Beoordeling", + "genre": "Genre", + "size": "Grootte" + } + }, + "user": { + "name": "Gebruiker |||| Gebruikers", + "fields": { + "userName": "Gebruikersnaam", + "isAdmin": "Is beheerder", + "lastLoginAt": "Laatst ingelogd op", + "updatedAt": "Laatst gewijzigd op", + "name": "Naam", + "password": "Wachtwoord", + "createdAt": "Aangemaakt op", + "changePassword": "Wijzig wachtwoord?", + "currentPassword": "Huidig wachtwoord", + "newPassword": "Nieuw wachtwoord", + "token": "Token" + }, + "helperTexts": { + "name": "Naamswijziging wordt pas zichtbaar bij de volgende login" + }, + "notifications": { + "created": "Aangemaakt door gebruiker", + "updated": "Gewijzigd door gebruiker", + "deleted": "Gewist door gebruiker" + }, + "message": { + "listenBrainzToken": "Vul je ListenBrainz gebruikers-token in.", + "clickHereForToken": "Klik hier voor je token" + } + }, + "player": { + "name": "Speler |||| Spelers", + "fields": { + "name": "Naam", + "transcodingId": "Transcoden", + "maxBitRate": "Max. bitrate", + "client": "Client", + "userName": "Gebruikersnaam", + "lastSeen": "Laatst gezien op", + "reportRealPath": "Toon het echte bestandspad", + "scrobbleEnabled": "Scrobbles naar externe dienst sturen" + } + }, + "transcoding": { + "name": "Transcoding |||| Transcoderingen", + "fields": { + "name": "Naam", + "targetFormat": "Doelformaat", + "defaultBitRate": "Standaard bitrate", + "command": "Commando" + } + }, + "playlist": { + "name": "Afspeellijst |||| Afspeellijsten", + "fields": { + "name": "Titel", + "duration": "Lengte", + "ownerName": "Eigenaar", + "public": "Publiek", + "updatedAt": "Laatst gewijzigd op", + "createdAt": "Aangemaakt op", + "songCount": "Nummers", + "comment": "Commentaar", + "sync": "Auto-importeren", + "path": "Importeer vanuit" + }, + "actions": { + "selectPlaylist": "Selecteer een afspeellijst:", + "addNewPlaylist": "Creëer \"%{name}\"", + "export": "Exporteer", + "makePublic": "Openbaar maken", + "makePrivate": "Privé maken" + }, + "message": { + "duplicate_song": "Dubbele nummers toevoegen", + "song_exist": "Er komen nummers dubbel in de afspeellijst. Wil je de dubbele nummers toevoegen of overslaan?" + } + }, + "radio": { + "name": "Radio |||| Radio's", + "fields": { + "name": "Naam", + "streamUrl": "Stream URL", + "homePageUrl": "Hoofdpagina URL", + "updatedAt": "Geüpdate op", + "createdAt": "Gecreëerd op" + }, + "actions": { + "playNow": "Speel nu" + } + }, + "share": { + "name": "Gedeeld", + "fields": { + "username": "Gedeeld door", + "url": "URL", + "description": "Beschrijving", + "contents": "Inhoud", + "expiresAt": "Verloopt", + "lastVisitedAt": "Laatst bezocht", + "visitCount": "Bezocht", + "format": "Formaat", + "maxBitRate": "Max. bitrate", + "updatedAt": "Geüpdatet op", + "createdAt": "Gecreëerd op", + "downloadable": "Downloads toestaan?" + } + } }, - "album": { - "name": "Album |||| Albums", - "fields": { - "albumArtist": "Album Artiest", - "artist": "Artiest", - "duration": "Afspeelduur", - "songCount": "Nummers", - "playCount": "Aantal keren afgespeeld", - "name": "Titel", - "genre": "Genre", - "compilation": "Compilatie", - "year": "Jaar", - "updatedAt": "Bijgewerkt op", - "comment": "Commentaar", - "rating": "Beoordeling", - "createdAt": "Datum toegevoegd", - "size": "Grootte", - "originalDate": "Origineel", - "releaseDate": "Uitgave", - "releases": "Uitgave |||| Uitgaven", - "released": "Uitgave", - "recordLabel": "Label", - "catalogNum": "Catalogus nummer", - "releaseType": "Type", - "grouping": "Groep", - "media": "Media", - "mood": "Sfeer", - "date": "Opnamedatum", - "missing": "Ontbrekend", - "libraryName": "Bibliotheek" - }, - "actions": { - "playAll": "Afspelen", - "playNext": "Hierna afspelen", - "addToQueue": "Voeg toe aan wachtrij", - "shuffle": "Shuffle", - "addToPlaylist": "Toevoegen aan afspeellijst", - "download": "Downloaden", - "info": "Meer info", - "share": "Delen", - "refresh": "Metadata verversen" - }, - "lists": { - "all": "Alle", - "random": "Willekeurig", - "recentlyAdded": "Onlangs toegevoegd", - "recentlyPlayed": "Onlangs afgespeeld", - "mostPlayed": "Meest afgespeeld", - "starred": "Favoriet", - "topRated": "Best beoordeeld" - } - }, - "artist": { - "name": "Artiest |||| Artiesten", - "fields": { - "name": "Naam", - "albumCount": "Aantal albums", - "songCount": "Aantal nummers", - "playCount": "Afgespeeld", - "rating": "Beoordeling", - "genre": "Genre", - "size": "Grootte", - "role": "Rol", - "missing": "Ontbrekend" - }, - "roles": { - "albumartist": "Album artiest |||| Album artiesten", - "artist": "Artiest |||| Artiesten", - "composer": "Componist |||| Componisten", - "conductor": "Dirigent |||| Dirigenten", - "lyricist": "Tekstschrijver |||| Tekstschrijvers", - "arranger": "Arrangeur |||| Arrangeurs", - "producer": "Producent |||| Producenten", - "director": "Regisseur |||| Regisseurs", - "engineer": "Opnametechnicus |||| Opnametechnici", - "mixer": "Mixer |||| Mixers", - "remixer": "Remixer |||| Remixers", - "djmixer": "DJ Mixer |||| DJ Mixers", - "performer": "Performer |||| Performers", - "maincredit": "Album Artiest of Artiest |||| Album Artiesten or Artiesten" - }, - "actions": { - "shuffle": "Shuffle", - "radio": "Radio", - "topSongs": "Beste nummers" - } - }, - "user": { - "name": "Gebruiker |||| Gebruikers", - "fields": { - "userName": "Gebruikersnaam", - "isAdmin": "Is beheerder", - "lastLoginAt": "Laatst ingelogd op", - "updatedAt": "Laatst bijgewerkt op", - "name": "Naam", - "password": "Wachtwoord", - "createdAt": "Aangemaakt op", - "changePassword": "Wijzig wachtwoord?", - "currentPassword": "Huidig wachtwoord", - "newPassword": "Nieuw wachtwoord", - "token": "Token", - "lastAccessAt": "Meest recente toegang", - "libraries": "Bibliotheken", - "scrobbleFilter": "Scrobble filter" - }, - "helperTexts": { - "name": "Naamswijziging wordt pas zichtbaar bij de volgende login", - "libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken", - "scrobbleFilter": "Nummers binnen deze slimme afspeellijst regels worden niet gestuurd naar Last.fm, ListenBrainz of scrobbler plugins. Gebruikt dezelfde JSON syntaxis en gedrag als slimme afspeellijsten. Voorbeeld: {\"all\":[{\"lt\":{\"rating\":4}}]}. Leeglaten om alles te scrobblen. Lokale afspeelaantallen vallen hierbuiten." - }, - "notifications": { - "created": "Aangemaakt door gebruiker", - "updated": "Bijgewerkt door gebruiker", - "deleted": "Gebruiker verwijderd" - }, - "message": { - "listenBrainzToken": "Vul je ListenBrainz gebruikers-token in.", - "clickHereForToken": "Klik hier voor je token", - "selectAllLibraries": "Selecteer alle bibliotheken", - "adminAutoLibraries": "Admin gebruikers hebben automatisch toegang tot alle bibliotheken" - }, - "validation": { - "librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers", - "invalidScrobbleFilter": "Moeten geldige slimme afspeellijst regels zijn. Geen ondersteuning voor Limit, Offset en Refresh Delay" - } - }, - "player": { - "name": "Speler |||| Spelers", - "fields": { - "name": "Naam", - "transcodingId": "Transcoden", - "maxBitRate": "Max. bitrate", - "client": "Client", - "userName": "Gebruikersnaam", - "lastSeen": "Laatst gezien op", - "reportRealPath": "Toon het echte bestandspad", - "scrobbleEnabled": "Scrobbles naar externe dienst sturen" - } - }, - "transcoding": { - "name": "Transcoding |||| Transcoderingen", - "fields": { - "name": "Naam", - "targetFormat": "Doelformaat", - "defaultBitRate": "Standaard bitrate", - "command": "Commando" - }, - "choices": { - "noDefaultBitRate": "Geen" - } - }, - "playlist": { - "name": "Afspeellijst |||| Afspeellijsten", - "fields": { - "name": "Titel", - "duration": "Afspeelduur", - "ownerName": "Eigenaar", - "public": "Publiek", - "updatedAt": "Laatst bijgewerkt op", - "createdAt": "Aangemaakt op", - "songCount": "Nummers", - "comment": "Commentaar", - "sync": "Auto-importeren", - "path": "Importeer vanuit", - "starred": "Favoriet" - }, - "actions": { - "selectPlaylist": "Selecteer een afspeellijst:", - "addNewPlaylist": "Creëer \"%{name}\"", - "export": "Exporteer", - "makePublic": "Openbaar maken", - "makePrivate": "Privé maken", - "saveQueue": "Bewaar wachtrij als playlist", - "searchOrCreate": "Zoek afspeellijsten of typ om een nieuwe te starten...", - "pressEnterToCreate": "Druk Enter om nieuwe afspeellijst te maken", - "removeFromSelection": "Verwijder van selectie" - }, - "message": { - "duplicate_song": "Dubbele nummers toevoegen", - "song_exist": "Er komen nummers dubbel in de afspeellijst. Wil je de dubbele nummers toevoegen of overslaan?", - "noPlaylistsFound": "Geen playlists gevonden", - "noPlaylists": "Geen playlists beschikbaar" - } - }, - "radio": { - "name": "Radio |||| Radio's", - "fields": { - "name": "Naam", - "streamUrl": "Stream URL", - "homePageUrl": "Hoofdpagina URL", - "updatedAt": "Bijgewerkt op", - "createdAt": "Aangemaakt op" - }, - "actions": { - "playNow": "Speel nu" - } - }, - "share": { - "name": "Gedeeld", - "fields": { - "username": "Gedeeld door", - "url": "URL", - "description": "Beschrijving", - "contents": "Inhoud", - "expiresAt": "Verloopt", - "lastVisitedAt": "Laatst bezocht", - "visitCount": "Bezocht", - "format": "Formaat", - "maxBitRate": "Max. bitrate", - "updatedAt": "Bijgewerkt op", - "createdAt": "Aangemaakt op", - "downloadable": "Downloads toestaan?" - } - }, - "missing": { - "name": "Ontbrekend bestand |||| Ontbrekende bestanden", - "fields": { - "path": "Pad", - "size": "Grootte", - "updatedAt": "Verdwenen op", - "libraryName": "Bibliotheek" - }, - "actions": { - "remove": "Verwijder", - "remove_all": "Alles verwijderen" - }, - "notifications": { - "removed": "Ontbrekende bestanden verwijderd" - }, - "empty": "Geen ontbrekende bestanden" - }, - "library": { - "name": "Bibliotheek |||| Bibliotheken", - "fields": { - "name": "Naam", - "path": "Pad", - "remotePath": "Extern pad", - "lastScanAt": "Laatste scan", - "songCount": "Nummers", - "albumCount": "Albums", - "artistCount": "Artiesten", - "totalSongs": "Nummers", - "totalAlbums": "Albums", - "totalArtists": "Artiesten", - "totalFolders": "Mappen", - "totalFiles": "Bestanden", - "totalMissingFiles": "Ontbrekende bestanden", - "totalSize": "Totale bestandsgrootte", - "totalDuration": "Afspeelduur", - "defaultNewUsers": "Standaard voor nieuwe gebruikers", - "createdAt": "Aangemaakt", - "updatedAt": "Bijgewerkt" - }, - "sections": { - "basic": "Basisinformatie", - "statistics": "Statistieken" - }, - "actions": { - "scan": "Scan bibliotheek", - "manageUsers": "Beheer gebruikerstoegang", - "viewDetails": "Bekijk details", - "quickScan": "Snelle scan", - "fullScan": "Volledige scan" - }, - "notifications": { - "created": "Bibliotheek succesvol aangemaakt", - "updated": "Bibliotheek succesvol bijgewerkt", - "deleted": "Bibliotheek succesvol verwijderd", - "scanStarted": "Bibliotheekscan is gestart", - "scanCompleted": "Bibliotheekscan is voltooid", - "quickScanStarted": "Snelle scan gestart", - "fullScanStarted": "Volledige scan gestart", - "scanError": "Fout bij start van scan. Check de logs" - }, - "validation": { - "nameRequired": "Bibliotheek naam is vereist", - "pathRequired": "Pad naar bibliotheek is vereist", - "pathNotDirectory": "Pad naar bibliotheek moet een map zijn", - "pathNotFound": "Pad naar bibliotheek niet gevonden", - "pathNotAccessible": "Pad naar bibliotheek is niet toegankelijk", - "pathInvalid": "Ongeldig pad naar bibliotheek" - }, - "messages": { - "deleteConfirm": "Weet je zeker dat je deze bibliotheek wil verwijderen? Dit verwijdert ook alle gerelateerde data en gebruikerstoegang.", - "scanInProgress": "Scan is bezig...", - "noLibrariesAssigned": "Geen bibliotheken aan deze gebruiker toegewezen" - } - }, - "plugin": { - "name": "Plugin |||| Plugins", - "fields": { - "id": "ID", - "name": "Naam", - "description": "Omschrijving", - "version": "Versie", - "author": "Auteur", - "website": "Website", - "permissions": "Permissies", - "enabled": "Aangezet", - "status": "Status", - "path": "Pad", - "lastError": "Fout", - "hasError": "Fout", - "updatedAt": "Geupdate", - "createdAt": "Geinstalleerd", - "configKey": "Sleutel", - "configValue": "Waarde", - "allUsers": "Sta toe voor alle gebruikers", - "selectedUsers": "Geselecteerde gebruikers", - "allLibraries": "Sta toe voor alle bibliotheken", - "selectedLibraries": "Geselecteerde bibliotheken", - "allowWriteAccess": "Sta schrijftoegang toe" - }, - "sections": { - "status": "Status", - "info": "Plugin informatie", - "configuration": "Configuratie", - "manifest": "Manifest", - "usersPermission": "Gebruikers permissie", - "libraryPermission": "Bibliotheekpermissie" - }, - "status": { - "enabled": "Aangezet", - "disabled": "Uitgezet" - }, - "actions": { - "enable": "Aanzetten", - "disable": "Uitzetten", - "disabledDueToError": "Herstel de fout voor aanzetten", - "disabledUsersRequired": "Selecteer gebruikers voor aanzetten", - "disabledLibrariesRequired": "Selecteer bibliotheek voor aanzetten", - "addConfig": "Configuratie toevoegen", - "rescan": "Opnieuw scannen" - }, - "notifications": { - "enabled": "Plugin actief", - "disabled": "Plugin niet actief", - "updated": "Plugin bijgewerkt", - "error": "Fout bij updaten plugin" - }, - "validation": { - "invalidJson": "Configuratie moet geldige JSON zijn" - }, - "messages": { - "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": "Als dit aanstaat heeft de plug-in toegang tot alle gebruikers, inclusief toekomstige.", - "noUsers": "Geen gebruikers geselecteerd", - "permissionReason": "Reden", - "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": "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": "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.", - "idHelp": "De plugin ID, afgeleid van zijn bestandsnaam. Gebruik dit als gerefereerd wordt aan deze plugin in de configuratie opties, zoals Agenten." - }, - "placeholders": { - "configKey": "Sleutel", - "configValue": "Waarde" - } - } - }, - "ra": { - "auth": { - "welcome1": "Bedankt voor het installeren van Navidrome!", - "welcome2": "Maak om te beginnen een beheerdersaccount", - "confirmPassword": "Bevestig wachtwoord", - "buttonCreateAdmin": "Beheerder aanmaken", - "auth_check_error": "Log in om door te gaan", - "user_menu": "Profiel", - "username": "Gebruikersnaam", - "password": "Wachtwoord", - "sign_in": "Inloggen", - "sign_in_error": "Authenticatie mislukt, probeer opnieuw a.u.b.", - "logout": "Uitloggen", - "insightsCollectionNote": "Navidrome verzamelt anonieme gebruiksdata om het project te verbeteren. Klik [hier] voor meer info en de mogelijkheid om te weigeren" - }, - "validation": { - "invalidChars": "Gebruik alleen letters en cijfers", - "passwordDoesNotMatch": "Wachtwoord komt niet overeen", - "required": "Verplicht", - "minLength": "Moet minimaal %{min} karakters bevatten", - "maxLength": "Mag maximaal %{max} karakters bevatten", - "minValue": "Moet minstens %{min} zijn", - "maxValue": "Mag maximaal %{max} zijn", - "number": "Moet een getal zijn", - "email": "Moet een geldig e-mailadres zijn", - "oneOf": "Moet een zijn van: %{options}", - "regex": "Moet overeenkomen met een specifiek format (regexp): %{pattern}", - "unique": "Moet uniek zijn", - "url": "Moet een geldige URL" - }, - "action": { - "add_filter": "Voeg filter toe", - "add": "Voeg toe", - "back": "Ga terug", - "bulk_actions": "1 geselecteerd |||| %{smart_count} geselecteerd", - "cancel": "Annuleer", - "clear_input_value": "Veld wissen", - "clone": "Kloon", - "confirm": "Bevestig", - "create": "Toevoegen", - "delete": "Verwijderen", - "edit": "Bewerk", - "export": "Exporteer", - "list": "Lijst", - "refresh": "Ververs", - "remove_filter": "Verwijder dit filter", - "remove": "Verwijder", - "save": "Opslaan", - "search": "Zoek", - "show": "Toon", - "sort": "Sorteer", - "undo": "Ongedaan maken", - "expand": "Uitklappen", - "close": "Sluiten", - "open_menu": "Open menu", - "close_menu": "Sluit menu", - "unselect": "Deselecteer", - "skip": "Overslaan", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Delen", - "download": "Downloaden" - }, - "boolean": { - "true": "Ja", - "false": "Nee" - }, - "page": { - "create": "%{name} toevoegen", - "dashboard": "Dashboard", - "edit": "%{name} #%{id}", - "error": "Er is iets misgegaan", - "list": "%{name}", - "loading": "Aan het laden", - "not_found": "Niet gevonden", - "show": "%{name} #%{id}", - "empty": "Nog geen %{name}.", - "invite": "Wil je er één toevoegen?" - }, - "input": { - "file": { - "upload_several": "Sleep bestanden hier om te uploaden, of klik om te selecteren.", - "upload_single": "Sleep een bestand hier om te uploaden, of klik om te selecteren." - }, - "image": { - "upload_several": "Sleep afbeeldingen hier om te uploaden, of klik om te selecteren.", - "upload_single": "Sleep een afbeelding hier om te uploaden, of klik om te selecteren." - }, - "references": { - "all_missing": "De gerefereerde elementen konden niet gevonden worden.", - "many_missing": "Een of meer van de gerefereerde elementen is niet meer beschikbaar.", - "single_missing": "Een van de bijbehorende elementen is niet meer beschikbaar" - }, - "password": { - "toggle_visible": "Verberg wachtwoord", - "toggle_hidden": "Toon wachtwoord" - } + "ra": { + "auth": { + "welcome1": "Bedankt voor het installeren van Navidrome!", + "welcome2": "Maak om te beginnen een beheerdersaccount", + "confirmPassword": "Bevestig wachtwoord", + "buttonCreateAdmin": "Beheerder aanmaken", + "auth_check_error": "Log in om door te gaan", + "user_menu": "Profiel", + "username": "Gebruikersnaam", + "password": "Wachtwoord", + "sign_in": "Inloggen", + "sign_in_error": "Authenticatie mislukt, probeer opnieuw a.u.b.", + "logout": "Uitloggen" + }, + "validation": { + "invalidChars": "Gebruik alleen letters en cijfers", + "passwordDoesNotMatch": "Wachtwoord komt niet overeen", + "required": "Verplicht", + "minLength": "Moet minimaal %{min} karakters bevatten", + "maxLength": "Mag maximaal %{max} karakters bevatten", + "minValue": "Moet minstens %{min} zijn", + "maxValue": "Mag maximaal %{max} zijn", + "number": "Moet een getal zijn", + "email": "Moet een geldig e-mailadres zijn", + "oneOf": "Moet een zijn van: %{options}", + "regex": "Moet overeenkomen met een specifiek format (regexp): %{pattern}", + "unique": "Moet uniek zijn", + "url": "Moet een geldige URL" + }, + "action": { + "add_filter": "Voeg filter toe", + "add": "Voeg toe", + "back": "Ga terug", + "bulk_actions": "1 geselecteerd |||| %{smart_count} geselecteerd", + "cancel": "Annuleer", + "clear_input_value": "Veld wissen", + "clone": "Kloon", + "confirm": "Bevestig", + "create": "Toevoegen", + "delete": "Verwijderen", + "edit": "Bewerk", + "export": "Exporteer", + "list": "Lijst", + "refresh": "Ververs", + "remove_filter": "Verwijder dit filter", + "remove": "Verwijder", + "save": "Opslaan", + "search": "Zoek", + "show": "Toon", + "sort": "Sorteer", + "undo": "Ongedaan maken", + "expand": "Uitklappen", + "close": "Sluiten", + "open_menu": "Open menu", + "close_menu": "Sluit menu", + "unselect": "Deselecteer", + "skip": "Overslaan", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Delen", + "download": "Downloaden" + }, + "boolean": { + "true": "Ja", + "false": "Nee" + }, + "page": { + "create": "%{name} toevoegen", + "dashboard": "Dashboard", + "edit": "%{name} #%{id}", + "error": "Er is iets misgegaan", + "list": "%{name}", + "loading": "Aan het laden", + "not_found": "Niet gevonden", + "show": "%{name} #%{id}", + "empty": "Nog geen %{name}.", + "invite": "Wil je er één toevoegen?" + }, + "input": { + "file": { + "upload_several": "Sleep bestanden hier om te uploaden, of klik om te selecteren.", + "upload_single": "Sleep een bestand hier om te uploaden, of klik om te selecteren." + }, + "image": { + "upload_several": "Sleep afbeeldingen hier om te uploaden, of klik om te selecteren.", + "upload_single": "Sleep een afbeelding hier om te uploaden, of klik om te selecteren." + }, + "references": { + "all_missing": "De gerefereerde elementen konden niet gevonden worden.", + "many_missing": "Een of meer van de gerefereerde elementen is niet meer beschikbaar.", + "single_missing": "Een van de bijbehorende elementen is niet meer beschikbaar" + }, + "password": { + "toggle_visible": "Verberg wachtwoord", + "toggle_hidden": "Toon wachtwoord" + } + }, + "message": { + "about": "Over", + "are_you_sure": "Weet je het zeker?", + "bulk_delete_content": "Weet je zeker dat je dit %{name} item wilt verwijderen? |||| Weet je zeker dat je deze %{smart_count} items wilt verwijderen?", + "bulk_delete_title": "Verwijder %{name} |||| Verwijder %{smart_count} %{name}", + "delete_content": "Weet je zeker dat je dit item wilt verwijderen?", + "delete_title": "Verwijder %{name} #%{id}", + "details": "Details", + "error": "Er is een clientfout opgetreden en je aanvraag kon niet worden voltooid.", + "invalid_form": "Het formulier is ongeldig. Controleer a.u.b. de foutmeldingen", + "loading": "De pagina is aan het laden, een moment a.u.b.", + "no": "Nee", + "not_found": "Je hebt een verkeerde URL ingevoerd of een defecte link aangeklikt.", + "yes": "Ja", + "unsaved_changes": "Sommige van uw wijzigingen zijn niet opgeslagen. Weet je zeker dat je ze wilt negeren?" + }, + "navigation": { + "no_results": "Geen resultaten gevonden", + "no_more_results": "Pagina %{page} ligt buiten het bereik. Probeer de vorige pagina.", + "page_out_of_boundaries": "Paginanummer %{page} buiten bereik", + "page_out_from_end": "Laatste pagina", + "page_out_from_begin": "Eerste pagina", + "page_range_info": "%{offsetBegin}-%{offsetEnd} van %{total}", + "page_rows_per_page": "Rijen per pagina:", + "next": "Volgende", + "prev": "Vorige", + "skip_nav": "Doorgaan" + }, + "notification": { + "updated": "Element bijgewerkt |||| %{smart_count} elementen bijgewerkt", + "created": "Element toegevoegd", + "deleted": "Element verwijderd |||| %{smart_count} elementen verwijderd", + "bad_item": "Incorrect element", + "item_doesnt_exist": "Element bestaat niet", + "http_error": "Server communicatie fout", + "data_provider_error": "dataProvider fout. Open de console voor meer details.", + "i18n_error": "Kan de vertalingen voor de opgegeven taal niet laden", + "canceled": "Actie geannuleerd", + "logged_out": "Uw sessie is beëindigd, maak opnieuw verbinding.", + "new_version": "Een nieuwe versie is beschikbaar! Ververs dit venster aub." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Kolommen keuze", + "layout": "Layout", + "grid": "Raster", + "table": "Tabel" + } }, "message": { - "about": "Over", - "are_you_sure": "Weet je het zeker?", - "bulk_delete_content": "Weet je zeker dat je dit %{name} item wilt verwijderen? |||| Weet je zeker dat je deze %{smart_count} items wilt verwijderen?", - "bulk_delete_title": "Verwijder %{name} |||| Verwijder %{smart_count} %{name}", - "delete_content": "Weet je zeker dat je dit item wilt verwijderen?", - "delete_title": "Verwijder %{name} #%{id}", - "details": "Details", - "error": "Er is een clientfout opgetreden en je aanvraag kon niet worden voltooid.", - "invalid_form": "Het formulier is ongeldig. Controleer a.u.b. de foutmeldingen", - "loading": "De pagina is aan het laden, een moment a.u.b.", - "no": "Nee", - "not_found": "Je hebt een verkeerde URL ingevoerd of een defecte link aangeklikt.", - "yes": "Ja", - "unsaved_changes": "Sommige van uw wijzigingen zijn niet opgeslagen. Weet je zeker dat je ze wilt negeren?" - }, - "navigation": { - "no_results": "Geen resultaten gevonden", - "no_more_results": "Pagina %{page} ligt buiten het bereik. Probeer de vorige pagina.", - "page_out_of_boundaries": "Paginanummer %{page} buiten bereik", - "page_out_from_end": "Laatste pagina", - "page_out_from_begin": "Eerste pagina", - "page_range_info": "%{offsetBegin}-%{offsetEnd} van %{total}", - "page_rows_per_page": "Rijen per pagina:", - "next": "Volgende", - "prev": "Vorige", - "skip_nav": "Doorgaan" - }, - "notification": { - "updated": "Element bijgewerkt |||| %{smart_count} elementen bijgewerkt", - "created": "Element toegevoegd", - "deleted": "Element verwijderd |||| %{smart_count} elementen verwijderd", - "bad_item": "Incorrect element", - "item_doesnt_exist": "Element bestaat niet", - "http_error": "Server communicatie fout", - "data_provider_error": "dataProvider fout. Open de console voor meer details.", - "i18n_error": "Kan de vertalingen voor de opgegeven taal niet laden", - "canceled": "Actie geannuleerd", - "logged_out": "Uw sessie is beëindigd, maak opnieuw verbinding.", - "new_version": "Een nieuwe versie is beschikbaar! Ververs dit venster aub." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Kolommen keuze", - "layout": "Layout", - "grid": "Raster", - "table": "Tabel" - } - }, - "message": { - "note": "Notitie", - "transcodingDisabled": "Het wijzigen van de transcoderconfiguratie via de web interface is om veiligheidsredenen uitgeschakeld. Als je transcoderopties wilt wijzigen (bewerken of toevoegen), start de server opnieuw op met de %{config} configuratie-optie.", - "transcodingEnabled": "Navidrome werkt momenteel met %{config}, waardoor het mogelijk is om systeemopdrachten uit te voeren vanuit de transcoderinstellingen via de web interface. We raden aan dit om veiligheidsredenen uit te schakelen en alleen in te schakelen bij het configureren van transcoderopties.", - "songsAddedToPlaylist": "1 nummer toegevoegd aan afspeellijst |||| %{smart_count} nummers toegevoegd aan afspeellijst", - "noPlaylistsAvailable": "Geen beschikbaar", - "delete_user_title": "Verwijder gebruiker '%{name}'", - "delete_user_content": "Weet je zeker dat je deze gebruiker en al zijn gegevens wilt verwijderen (inclusief afspeellijsten en voorkeuren)?", - "notifications_blocked": "Je hebt bureaubladmeldingen geblokkeerd voor deze site in je browserinstellingen", - "notifications_not_available": "Deze browser ondersteunt bureaubladmeldingen niet, of je verbindt niet over https", - "lastfmLinkSuccess": "Last.fm succesvol gekoppeld en scrobbling actief", - "lastfmLinkFailure": "Last.fm kon niet worden gekoppeld", - "lastfmUnlinkSuccess": "Last.fm ontkoppeld en scrobbling uitgezet", - "lastfmUnlinkFailure": "Last.fm kon niet worden ontkoppeld", - "openIn": { - "lastfm": "Open in Last.fm", - "musicbrainz": "Open in MusicBrainz" - }, - "lastfmLink": "Lees meer...", - "listenBrainzLinkSuccess": "ListenBrainz is succesvol gelinkt en scrobbling staat aan, als gebruiker: %{user}", - "listenBrainzLinkFailure": "ListenBrainz kon niet worden gelinkt: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz ontkoppeld", - "listenBrainzUnlinkFailure": "ListenBrainz kon niet ontkoppeld worden", - "downloadOriginalFormat": "Download in origineel formaat", - "shareOriginalFormat": "Deel in origineel formaat", - "shareDialogTitle": "Deel %{resource} '%{name}'", - "shareBatchDialogTitle": "Deel 1 %{resource} |||| Deel %{smart_count} %{resource}", - "shareSuccess": "URL gekopieeerd naar klembord: %{url}", - "shareFailure": "Fout bij kopieren URL %{url} naar klembord", - "downloadDialogTitle": "Download %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Kopieeer naar klembord: Ctrl+C, Enter", - "remove_missing_title": "Verwijder ontbrekende bestanden", - "remove_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.", - "remove_all_missing_title": "Verwijder alle ontbrekende bestanden", - "remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.", - "noSimilarSongsFound": "Geen vergelijkbare nummers gevonden", - "noTopSongsFound": "Geen beste nummers gevonden", - "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", - "metadataRefreshStarted": "Metadata verversen op de achtergrond", - "quickConnectApproved": "", - "quickConnectInvalidCode": "", - "quickConnectError": "" - }, - "menu": { - "library": "Bibliotheek", - "settings": "Instellingen", - "version": "Versie", - "theme": "Thema", - "personal": { - "name": "Persoonlijk", - "options": { - "theme": "Thema", - "language": "Taal", - "defaultView": "Standaard weergave", - "desktop_notifications": "Bureaubladmeldingen", - "lastfmScrobbling": "Scrobble naar Last.fm", - "listenBrainzScrobbling": "Scrobble naar ListenBrainz", - "replaygain": "ReplayGain modus", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "Uitgeschakeld", - "album": "Gebruik Album Gain", - "track": "Gebruik Track Gain" + "note": "Notitie", + "transcodingDisabled": "Het wijzigen van de transcoderconfiguratie via de web interface is om veiligheidsredenen uitgeschakeld. Als je transcoderopties wilt wijzigen (bewerken of toevoegen), start de server opnieuw op met de %{config} configuratie-optie.", + "transcodingEnabled": "Navidrome werkt momenteel met %{config}, waardoor het mogelijk is om systeemopdrachten uit te voeren vanuit de transcoderinstellingen via de web interface. We raden aan dit om veiligheidsredenen uit te schakelen en alleen in te schakelen bij het configureren van transcoderopties.", + "songsAddedToPlaylist": "1 nummer toegevoegd aan afspeellijst |||| %{smart_count} nummers toegevoegd aan afspeellijst", + "noPlaylistsAvailable": "Geen beschikbaar", + "delete_user_title": "Verwijder gebruiker '%{name}'", + "delete_user_content": "Weet je zeker dat je deze gebruiker en al zijn gegevens wilt verwijderen (inclusief afspeellijsten en voorkeuren)?", + "notifications_blocked": "Je hebt bureaubladmeldingen geblokkeerd voor deze site in je browserinstellingen", + "notifications_not_available": "Deze browser ondersteunt bureaubladmeldingen niet", + "lastfmLinkSuccess": "Last.fm succesvol gekoppeld en scrobbling actief", + "lastfmLinkFailure": "Last.fm kon niet worden gekoppeld", + "lastfmUnlinkSuccess": "Last.fm ontkoppeld en scrobbling uitgezet", + "lastfmUnlinkFailure": "Last.fm kon niet worden ontkoppeld", + "openIn": { + "lastfm": "Open in Last.fm", + "musicbrainz": "Open in MusicBrainz" }, - "lastfmNotConfigured": "Last.fm API-sleutel is niet geconfigureerd" - } + "lastfmLink": "Lees meer...", + "listenBrainzLinkSuccess": "ListenBrainz is succesvol gelinkt en scrobbling staat aan, als gebruiker: %{user}", + "listenBrainzLinkFailure": "ListenBrainz kon niet worden gelinkt: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz ontkoppeld", + "listenBrainzUnlinkFailure": "ListenBrainz kon niet ontkoppeld worden", + "downloadOriginalFormat": "Download in origineel formaat", + "shareOriginalFormat": "Deel in origineel formaat", + "shareDialogTitle": "Deel %{resource} '%{name}'", + "shareBatchDialogTitle": "Deel 1 %{resource} |||| Deel %{smart_count} %{resource}", + "shareSuccess": "URL gekopieeerd naar klembord: %{url}", + "shareFailure": "Fout bij kopieren URL %{url} naar klembord", + "downloadDialogTitle": "Download %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Kopieeer naar klembord: Ctrl+C, Enter" }, - "albumList": "Albums", - "about": "Over", - "playlists": "Afspeellijsten", - "sharedPlaylists": "Gedeelde afspeellijsten", - "librarySelector": { - "allLibraries": "Alle bibliotheken (%{count})", - "multipleLibraries": "%{selected} van %{total} bibliotheken", - "selectLibraries": "Selecteer bibliotheken", - "none": "Geen" + "menu": { + "library": "Bibliotheek", + "settings": "Instellingen", + "version": "Versie", + "theme": "Thema", + "personal": { + "name": "Persoonlijk", + "options": { + "theme": "Thema", + "language": "Taal", + "defaultView": "Standaard weergave", + "desktop_notifications": "Bureaubladmeldingen", + "lastfmScrobbling": "Scrobble naar Last.fm", + "listenBrainzScrobbling": "Scrobble naar ListenBrainz", + "replaygain": "ReplayGain modus", + "preAmp": "ReplayGain PreAmp (dB)", + "gain": { + "none": "Uitgeschakeld", + "album": "Gebruik Album Gain", + "track": "Gebruik Track Gain" + } + } + }, + "albumList": "Albums", + "about": "Over", + "playlists": "Playlists", + "sharedPlaylists": "Gedeelde playlists" }, - "onlyFavourites": "Toon alleen favorieten", - "quickConnect": { - "name": "", - "code": "", - "help": "", - "confirm": "", - "continue": "", - "approve": "" - } - }, - "player": { - "playListsText": "Wachtrij", - "openText": "Openen", - "closeText": "Sluiten", - "notContentText": "Geen muziek", - "clickToPlayText": "Klik om af te spelen", - "clickToPauseText": "Klik om te pauzeren", - "nextTrackText": "Volgend nummer", - "previousTrackText": "Vorige", - "reloadText": "Herladen", - "volumeText": "Volume", - "toggleLyricText": "Songtekst aan/uit", - "toggleMiniModeText": "Minimaliseren", - "destroyText": "Vernietigen", - "downloadText": "Downloaden", - "removeAudioListsText": "Audiolijsten verwijderen", - "clickToDeleteText": "Klik om %{name} te verwijderen", - "emptyLyricText": "Geen songtekst", - "playModeText": { - "order": "In volgorde", - "orderLoop": "Herhalen", - "singleLoop": "Herhaal eenmalig", - "shufflePlay": "Shuffle" - } - }, - "about": { - "links": { - "homepage": "Thuispagina", - "source": "Broncode", - "featureRequests": "Functie verzoeken", - "lastInsightsCollection": "Laatste inzichten", - "insights": { - "disabled": "Uitgeschakeld", - "waiting": "Wachten" - } + "player": { + "playListsText": "Afspeellijst afspelen", + "openText": "Openen", + "closeText": "Sluiten", + "notContentText": "Geen muziek", + "clickToPlayText": "Klik om af te spelen", + "clickToPauseText": "Klik om te pauzeren", + "nextTrackText": "Volgende", + "previousTrackText": "Vorige", + "reloadText": "Herladen", + "volumeText": "Volume", + "toggleLyricText": "Songtekst aan/uit", + "toggleMiniModeText": "Minimaliseren", + "destroyText": "Vernietigen", + "downloadText": "Downloaden", + "removeAudioListsText": "Audiolijsten verwijderen", + "clickToDeleteText": "Klik om %{name} te verwijderen", + "emptyLyricText": "Geen songtekst", + "playModeText": { + "order": "In volgorde", + "orderLoop": "Herhalen", + "singleLoop": "Herhaal eenmalig", + "shufflePlay": "Shuffle" + } }, - "tabs": { - "about": "Over", - "config": "Configuratie" + "about": { + "links": { + "homepage": "Thuispagina", + "source": "Broncode", + "featureRequests": "Functie verzoeken" + } }, - "config": { - "configName": "Config Naam", - "environmentVariable": "Omgevingsvariabele", - "currentValue": "Huidige waarde", - "configurationFile": "Configuratiebestand", - "exportToml": "Exporteer configuratie (TOML)", - "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", - "downloadToml": "Download configuratie (TOML)" + "activity": { + "title": "Activiteit", + "totalScanned": "Totaal gescande folders", + "quickScan": "Snelle scan", + "fullScan": "Volledige scan", + "serverUptime": "Server uptime", + "serverDown": "Offline" + }, + "help": { + "title": "Navidrome sneltoetsen", + "hotkeys": { + "show_help": "Help weergeven", + "toggle_menu": "Menu zijbalk Aan/Uit\n", + "toggle_play": "Afspelen / Pause", + "prev_song": "Vorig nummer", + "next_song": "Volgend nummer", + "vol_up": "Volume harder", + "vol_down": "Volume zachter", + "toggle_love": "Voeg toe aan favorieten", + "current_song": "Ga naar huidig nummer" + } } - }, - "activity": { - "title": "Activiteit", - "totalScanned": "Totaal gescande mappen", - "quickScan": "Snelle scan", - "fullScan": "Volledige scan", - "serverUptime": "Server uptime", - "serverDown": "Offline", - "scanType": "Type", - "status": "Scan fout", - "elapsedTime": "Verlopen tijd", - "selectiveScan": "Selectief" - }, - "help": { - "title": "Navidrome sneltoetsen", - "hotkeys": { - "show_help": "Help weergeven", - "toggle_menu": "Menu zijbalk Aan/Uit\n", - "toggle_play": "Afspelen / Pause", - "prev_song": "Vorig nummer", - "next_song": "Volgend nummer", - "vol_up": "Volume harder", - "vol_down": "Volume zachter", - "toggle_love": "Voeg toe aan favorieten", - "current_song": "Ga naar huidig nummer" - } - }, - "nowPlaying": { - "title": "Speelt nu", - "empty": "Er wordt niets afgespeed", - "minutesAgo": "%{smart_count} minuut geleden |||| %{smart_count} minuten geleden" - } } \ No newline at end of file diff --git a/resources/i18n/no.json b/resources/i18n/no.json deleted file mode 100644 index 3b75bab25..000000000 --- a/resources/i18n/no.json +++ /dev/null @@ -1,634 +0,0 @@ -{ - "languageName": "Norsk", - "resources": { - "song": { - "name": "Sang |||| Sanger", - "fields": { - "albumArtist": "Album Artist", - "duration": "Tid", - "trackNumber": "#", - "playCount": "Avspillinger", - "title": "Tittel", - "artist": "Artist", - "album": "Album", - "path": "Filsti", - "genre": "Sjanger", - "compilation": "Samlingg", - "year": "År", - "size": "Filstørrelse", - "updatedAt": "Oppdatert", - "bitRate": "Bit rate", - "discSubtitle": "Disk Undertittel", - "starred": "Favoritt", - "comment": "Kommentar", - "rating": "Rangering", - "quality": "Kvalitet", - "bpm": "BPM", - "playDate": "Sist Avspilt", - "channels": "Kanaler", - "createdAt": "Lagt til", - "grouping": "Gruppering", - "mood": "Stemning", - "participants": "Ytterlige deltakere", - "tags": "Ytterlige Tags", - "mappedTags": "Kartlagte tags", - "rawTags": "Rå tags", - "bitDepth": "Bit depth", - "sampleRate": "", - "missing": "", - "libraryName": "" - }, - "actions": { - "addToQueue": "Avspill senere", - "playNow": "Avspill nå", - "addToPlaylist": "Legg til i spilleliste", - "shuffleAll": "Shuffle Alle", - "download": "Last ned", - "playNext": "Avspill neste", - "info": "Få Info", - "showInPlaylist": "" - } - }, - "album": { - "name": "Album |||| Album", - "fields": { - "albumArtist": "Album Artist", - "artist": "Artist", - "duration": "Tid", - "songCount": "Sanger", - "playCount": "Avspillinger", - "name": "Navn", - "genre": "Sjanger", - "compilation": "Samling", - "year": "År", - "updatedAt": "Oppdatert", - "comment": "Kommentar", - "rating": "Rangering", - "createdAt": "Lagt Til", - "size": "Størrelse", - "originalDate": "Original", - "releaseDate": "Utgitt", - "releases": "Utgivelse |||| Utgivelser", - "released": "Utgitt", - "recordLabel": "Plateselskap", - "catalogNum": "Katalognummer", - "releaseType": "Type", - "grouping": "Gruppering", - "media": "Media", - "mood": "Stemning", - "date": "Inspillingsdato", - "missing": "", - "libraryName": "" - }, - "actions": { - "playAll": "Avspill", - "playNext": "Avspill Neste", - "addToQueue": "Avspill Senere", - "shuffle": "Shuffle", - "addToPlaylist": "Legg til i spilleliste", - "download": "Last ned", - "info": "Få Info", - "share": "Del" - }, - "lists": { - "all": "Alle", - "random": "Tilfeldig", - "recentlyAdded": "Nylig lagt til", - "recentlyPlayed": "Nylig Avspilt", - "mostPlayed": "Mest Avspilt", - "starred": "Favoritter", - "topRated": "Top Rangert" - } - }, - "artist": { - "name": "Artist |||| Artister", - "fields": { - "name": "Navn", - "albumCount": "Album Antall", - "songCount": "Song Antall", - "playCount": "Avspillinger", - "rating": "Rangering", - "genre": "Sjanger", - "size": "Størrelse", - "role": "Rolle", - "missing": "" - }, - "roles": { - "albumartist": "Album Artist |||| Album Artister", - "artist": "Artist |||| Artister", - "composer": "Composer |||| Composers", - "conductor": "Conductor |||| Conductors", - "lyricist": "Lyriker |||| Lyriker", - "arranger": "Arranger |||| Arrangers", - "producer": "Produsent |||| Produsenter", - "director": "Director |||| Directors", - "engineer": "Engineer |||| Engineers", - "mixer": "Mixer |||| Mixers", - "remixer": "Remixer |||| Remixers", - "djmixer": "DJ Mixer |||| DJ Mixers", - "performer": "Performer |||| Performers", - "maincredit": "" - }, - "actions": { - "shuffle": "", - "radio": "", - "topSongs": "" - } - }, - "user": { - "name": "Bruker |||| Brukere", - "fields": { - "userName": "Brukernavn", - "isAdmin": "Admin", - "lastLoginAt": "Sist Pålogging", - "updatedAt": "Oppdatert", - "name": "Navn", - "password": "Passord", - "createdAt": "Opprettet", - "changePassword": "Bytt Passord?", - "currentPassword": "Nåværende Passord", - "newPassword": "Nytt Passord", - "token": "Token", - "lastAccessAt": "Sist Tilgang", - "libraries": "" - }, - "helperTexts": { - "name": "Navnendringer vil ikke være synlig før neste pålogging", - "libraries": "" - }, - "notifications": { - "created": "Bruker opprettet", - "updated": "Bruker oppdatert", - "deleted": "Bruker slettet" - }, - "message": { - "listenBrainzToken": "Fyll inn din ListenBrainz bruker token.", - "clickHereForToken": "Klikk her for å hente din token", - "selectAllLibraries": "", - "adminAutoLibraries": "" - }, - "validation": { - "librariesRequired": "" - } - }, - "player": { - "name": "Musikkavspiller |||| Musikkavspillere", - "fields": { - "name": "Navn", - "transcodingId": "Transkoding", - "maxBitRate": "Maks. Bit Rate", - "client": "Klient", - "userName": "Brukernavn", - "lastSeen": "Sist sett", - "reportRealPath": "Rapporter ekte filsti", - "scrobbleEnabled": "Send Scrobbles til eksterne tjenester" - } - }, - "transcoding": { - "name": "Transkoding |||| Transkodinger", - "fields": { - "name": "Navn", - "targetFormat": "Mål Format", - "defaultBitRate": "Default Bit Rate", - "command": "Kommando" - } - }, - "playlist": { - "name": "Spilleliste |||| Spillelister", - "fields": { - "name": "Navn", - "duration": "Lengde", - "ownerName": "Eier", - "public": "Offentlig", - "updatedAt": "Oppdatert", - "createdAt": "Opprettet", - "songCount": "Sanger", - "comment": "Kommentar", - "sync": "Auto-importer", - "path": "Importer fra" - }, - "actions": { - "selectPlaylist": "Velg en spilleliste:", - "addNewPlaylist": "Opprett \"%{name}\"", - "export": "Eksporter", - "makePublic": "Gjør Offentlig", - "makePrivate": "Gjør Privat", - "saveQueue": "", - "searchOrCreate": "", - "pressEnterToCreate": "", - "removeFromSelection": "" - }, - "message": { - "duplicate_song": "Legg til Duplikater", - "song_exist": "Duplikater har blitt lagt til i spillelisten. Ønsker du å legge til duplikater eller hoppe over de?", - "noPlaylistsFound": "", - "noPlaylists": "" - } - }, - "radio": { - "name": "Radio |||| Radio", - "fields": { - "name": "Navn", - "streamUrl": "Stream URL", - "homePageUrl": "Hjemmeside URL", - "updatedAt": "Oppdatert", - "createdAt": "Opprettet" - }, - "actions": { - "playNow": "Avspill" - } - }, - "share": { - "name": "Del |||| Delinger", - "fields": { - "username": "Delt Av", - "url": "URL", - "description": "Beskrivelse", - "contents": "Innhold", - "expiresAt": "Utløper", - "lastVisitedAt": "Sist Besøkt", - "visitCount": "Visninger", - "format": "Format", - "maxBitRate": "Maks. Bit Rate", - "updatedAt": "Oppdatert", - "createdAt": "Opprettet", - "downloadable": "Tillat Nedlastinger?" - } - }, - "missing": { - "name": "Manglende Fil|||| Manglende Filer", - "fields": { - "path": "Filsti", - "size": "Størrelse", - "updatedAt": "Ble borte", - "libraryName": "" - }, - "actions": { - "remove": "Fjern", - "remove_all": "" - }, - "notifications": { - "removed": "Manglende fil(er) fjernet" - }, - "empty": "Ingen Manglende Filer" - }, - "library": { - "name": "", - "fields": { - "name": "", - "path": "", - "remotePath": "", - "lastScanAt": "", - "songCount": "", - "albumCount": "", - "artistCount": "", - "totalSongs": "", - "totalAlbums": "", - "totalArtists": "", - "totalFolders": "", - "totalFiles": "", - "totalMissingFiles": "", - "totalSize": "", - "totalDuration": "", - "defaultNewUsers": "", - "createdAt": "", - "updatedAt": "" - }, - "sections": { - "basic": "", - "statistics": "" - }, - "actions": { - "scan": "", - "manageUsers": "", - "viewDetails": "", - "quickScan": "", - "fullScan": "" - }, - "notifications": { - "created": "", - "updated": "", - "deleted": "Biblioteket slettet", - "scanStarted": "Skanning startet", - "scanCompleted": "", - "quickScanStarted": "", - "fullScanStarted": "", - "scanError": "Error starte skanning. Sjekk loggene" - }, - "validation": { - "nameRequired": "", - "pathRequired": "", - "pathNotDirectory": "", - "pathNotFound": "", - "pathNotAccessible": "", - "pathInvalid": "" - }, - "messages": { - "deleteConfirm": "", - "scanInProgress": "", - "noLibrariesAssigned": "" - } - } - }, - "ra": { - "auth": { - "welcome1": "Takk for at du installerte Navidrome!", - "welcome2": "La oss begynne med å lage en admin bruker.", - "confirmPassword": "Bekreft Passord", - "buttonCreateAdmin": "Opprett Admin", - "auth_check_error": "Logg inn for å fortsette", - "user_menu": "Profil", - "username": "Brukernavn", - "password": "Passord", - "sign_in": "Logg inn", - "sign_in_error": "Autentiseringsfeil, vennligst prøv igjen", - "logout": "Logg ut", - "insightsCollectionNote": "Navidrome innhenter anonymisert forbruksdata\nfor å hjelpe og forbedre prosjektet.\nTrykk [her] for å lære mer og for å melde deg av hvis ønskelig." - }, - "validation": { - "invalidChars": "Det er kun bokstaver og tall som støttes", - "passwordDoesNotMatch": "Passord samstemmer ikke", - "required": "Kreves", - "minLength": "Må være minst %{min} karakterer.", - "maxLength": "Må være %{max} karakterer eller mindre", - "minValue": "Må være minst %{min}", - "maxValue": "Må være %{max} eller mindre", - "number": "Må være et tall", - "email": "Må være en gyldig epost", - "oneOf": "Må være en av: %{options}", - "regex": "Må samstemme med et spesifikt format (regexp): %{pattern}", - "unique": "Må være unikt", - "url": "Må være en gyldig URL" - }, - "action": { - "add_filter": "Legg til filter", - "add": "Legg Til", - "back": "Tilbake", - "bulk_actions": "1 element valgt |||| %{smart_count} elementer valgt", - "cancel": "Avbryt", - "clear_input_value": "Nullstill verdi", - "clone": "Klone", - "confirm": "Bekreft", - "create": "Opprett", - "delete": "Slett", - "edit": "Rediger", - "export": "Eksporter", - "list": "Liste", - "refresh": "Oppdater", - "remove_filter": "Fjern dette filteret", - "remove": "Fjern", - "save": "Lagre", - "search": "Søk", - "show": "Vis", - "sort": "Sorter", - "undo": "Angre", - "expand": "Utvid", - "close": "Lukk", - "open_menu": "Åpne meny", - "close_menu": "Lukk meny", - "unselect": "Avvelg", - "skip": "Hopp over", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Del", - "download": "Last Ned" - }, - "boolean": { - "true": "Ja", - "false": "Nei" - }, - "page": { - "create": "Opprett %{name}", - "dashboard": "Dashboard", - "edit": "%{name} #%{id}", - "error": "Noe gikk galt", - "list": "%{name}", - "loading": "Laster", - "not_found": "Ikke Funnet", - "show": "%{name} #%{id}", - "empty": "Ingen %{name} enda.", - "invite": "Ønsker du å legge til en?" - }, - "input": { - "file": { - "upload_several": "Dra filer hit for å laste opp, eller klikk for å velge en.", - "upload_single": "Dra en fil hit for å laste opp, eller klikk for å velge den." - }, - "image": { - "upload_several": "Dra bilder hit for å laste opp, eller klikk for å velge en.", - "upload_single": "Dra et bilde hit for å laste opp, eller klikk for å velge den." - }, - "references": { - "all_missing": "Finner ikke referansedata.", - "many_missing": "Minst en av de tilhørende referansene ser ikke lenger ut til å være tilgjengelig.", - "single_missing": "Tilhørende referanse ser ikke lenger ut til å være tilgjengelig." - }, - "password": { - "toggle_visible": "Skjul passord", - "toggle_hidden": "Vis passord" - } - }, - "message": { - "about": "Om", - "are_you_sure": "Er du sikker?", - "bulk_delete_content": "Er du sikker på at du vil slette denne %{name}? |||| Er du sikker på at du vil slette disse %{smart_count} elementene?", - "bulk_delete_title": "Slett %{name} |||| Slett %{smart_count} %{name}", - "delete_content": "Er du sikker på at du ønsker å slette dette elementet?", - "delete_title": "Slett %{name} #%{id}", - "details": "Detaljer", - "error": "En klient feil har oppstått og din forespørsel lot seg ikke gjennomføre.", - "invalid_form": "Skjemaet er ikke gyldig. Vennligst se etter feil.", - "loading": "Siden laster, vennligst vent.", - "no": "Nei", - "not_found": "Enten skrev du feil URL, eller så har du fulgt en dårlig link.", - "yes": "Ja", - "unsaved_changes": "Noen av dine endringer ble ikke lagret. Er du sikker på at du ønsker å ignorere de?" - }, - "navigation": { - "no_results": "Ingen resultater", - "no_more_results": "Sidenummeret %{page} er utenfor grensene. Prøv forrige side.", - "page_out_of_boundaries": "Sidenummer %{page} er utenfor grensene", - "page_out_from_end": "Kan ikke være etter siste side", - "page_out_from_begin": "Kan ikke være før side 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}", - "page_rows_per_page": "Elementer per side:", - "next": "Neste", - "prev": "Forrige", - "skip_nav": "Hopp til innhold" - }, - "notification": { - "updated": "Element oppdatert |||| %{smart_count} elementer oppdatert", - "created": "Element opprettet", - "deleted": "Element slettet |||| %{smart_count} elementer slettet", - "bad_item": "Feil element", - "item_doesnt_exist": "Element eksisterer ikke", - "http_error": "Kommunikasjonsfeil mot server", - "data_provider_error": "dataProvider feil. Sjekk konsollet for feil.", - "i18n_error": "Klarte ikke laste oversettelser for valgt språk.", - "canceled": "Handling avbrutt", - "logged_out": "Din sesjon er avsluttet, vennligst koble til på nytt.", - "new_version": "Ny versjon tilgjengelig! Vennligst last siden på nytt." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Vis følgende kolonner", - "layout": "Layout", - "grid": "Rutenett", - "table": "Tabell" - } - }, - "message": { - "note": "NOTAT", - "transcodingDisabled": "Endringer på transkodingkonfigurasjon fra web grensesnittet er deaktivert grunnet sikkerhet. Hvis du ønsker å endre eller legge til transkodingsmuligheter, restart serveren med %{config} konfigurasjonsalternativ.", - "transcodingEnabled": "Navidrome kjører for øyeblikket med %{config}, som gjør det mulig å kjøre systemkommandoer fra transkodingsinstillinger i web grensesnittet. Vi anbefaler å deaktivere denne muligheten av sikkerhetsårsaker og heller kun ha det aktivert under konfigurasjon av transkodingsmuligheter.", - "songsAddedToPlaylist": "Lagt til 1 sang i spillelisten |||| Lagt til %{smart_count} sanger i spillelisten", - "noPlaylistsAvailable": "Ingen tilgjengelig", - "delete_user_title": "Slett bruker '%{name}'", - "delete_user_content": "Er du sikker på at du vil slette denne brukeren og all tilhørlig data (inkludert spillelister og preferanser)?", - "notifications_blocked": "Du har blokkert notifikasjoner for denne nettsiden i din nettleser.", - "notifications_not_available": "Denne nettleseren støtter ikke skrivebordsnotifikasjoner, eller så er du ikke tilkoblet Navidrome via https.", - "lastfmLinkSuccess": "Last.fm er tilkoblet og scrobbling er aktivert", - "lastfmLinkFailure": "Last.fm kunne ikke koble til", - "lastfmUnlinkSuccess": "Last.fm er avkoblet og scrobbling er deaktivert", - "lastfmUnlinkFailure": "Last.fm kunne ikke avkobles", - "openIn": { - "lastfm": "Åpne i Last.fm", - "musicbrainz": "Åpne i MusicBrainz" - }, - "lastfmLink": "Les Mer...", - "listenBrainzLinkSuccess": "ListenBrainz er koblet til og scrobbling er aktivert som bruker: %{user}", - "listenBrainzLinkFailure": "ListenBrainz kunne ikke koble til: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz er avkoblet og scrobbling er deaktivert", - "listenBrainzUnlinkFailure": "ListenBrainz kunne ikke avkobles", - "downloadOriginalFormat": "Last ned i originalformat", - "shareOriginalFormat": "Del i originalformat", - "shareDialogTitle": "Del %{resource} '%{name}'", - "shareBatchDialogTitle": "Del 1 %{resource} |||| Del %{smart_count} %{resource}", - "shareSuccess": "URL kopiert til utklippstavle: %{url}", - "shareFailure": "Error ved kopiering av URL %{url} til utklippstavle", - "downloadDialogTitle": "Last ned %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Kopier til utklippstavle: Ctrl+C, Enter", - "remove_missing_title": "Fjern manglende filer", - "remove_missing_content": "Er du sikker på at du ønsker å fjerne de valgte manglende filene fra databasen? Dette vil permanent fjerne alle referanser til de, inkludert antall avspillinger og rangeringer.", - "remove_all_missing_title": "", - "remove_all_missing_content": "", - "noSimilarSongsFound": "", - "noTopSongsFound": "" - }, - "menu": { - "library": "Bibliotek", - "settings": "Instillinger", - "version": "Versjon", - "theme": "Tema", - "personal": { - "name": "Personlig", - "options": { - "theme": "Tema", - "language": "Språk", - "defaultView": "Standardvisning", - "desktop_notifications": "Skrivebordsnotifikasjoner", - "lastfmScrobbling": "Scrobble til Last.fm", - "listenBrainzScrobbling": "Scrobble til ListenBrainz", - "replaygain": "ReplayGain Mode", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "Deaktivert", - "album": "Bruk Album Gain", - "track": "Bruk Track Gain" - }, - "lastfmNotConfigured": "Last.fm API-Key er ikke konfigurert" - } - }, - "albumList": "Album", - "about": "Om", - "playlists": "Spillelister", - "sharedPlaylists": "Delte Spillelister", - "librarySelector": { - "allLibraries": "", - "multipleLibraries": "", - "selectLibraries": "", - "none": "" - } - }, - "player": { - "playListsText": "Spill Av Kø", - "openText": "Åpne", - "closeText": "Lukk", - "notContentText": "Ingen musikk", - "clickToPlayText": "Klikk for å avspille", - "clickToPauseText": "Klikk for å pause", - "nextTrackText": "Neste spor", - "previousTrackText": "Forrige spor", - "reloadText": "Last på nytt", - "volumeText": "Volum", - "toggleLyricText": "Slå på/av sangtekster", - "toggleMiniModeText": "Minimer", - "destroyText": "Ødelegg", - "downloadText": "Last Ned", - "removeAudioListsText": "Slett lydlister", - "clickToDeleteText": "Klikk for å slette %{name}", - "emptyLyricText": "Ingen sangtekster", - "playModeText": { - "order": "I rekkefølge", - "orderLoop": "Repeat", - "singleLoop": "Repeat En", - "shufflePlay": "Shuffle" - } - }, - "about": { - "links": { - "homepage": "Hjemmeside", - "source": "Kildekode", - "featureRequests": "Funksjonsforespørseler", - "lastInsightsCollection": "Siste Innsamling av anonymisert forbruksdata", - "insights": { - "disabled": "Deaktivert", - "waiting": "Venter" - } - }, - "tabs": { - "about": "", - "config": "" - }, - "config": { - "configName": "", - "environmentVariable": "", - "currentValue": "", - "configurationFile": "", - "exportToml": "", - "exportSuccess": "", - "exportFailed": "", - "devFlagsHeader": "", - "devFlagsComment": "" - } - }, - "activity": { - "title": "Aktivitet", - "totalScanned": "Antall mapper skannet", - "quickScan": "Hurtigskann", - "fullScan": "Full Skann", - "serverUptime": "Server Oppetid", - "serverDown": "OFFLINE", - "scanType": "", - "status": "", - "elapsedTime": "", - "selectiveScan": "Utvalgt" - }, - "help": { - "title": "Navidrome Hurtigtaster", - "hotkeys": { - "show_help": "Vis Hjelp", - "toggle_menu": "Åpne/Lukke Sidepanel", - "toggle_play": "Avspill / Pause", - "prev_song": "Forrige Sang", - "next_song": "Neste Sang", - "vol_up": "Volum Opp", - "vol_down": "Volum Ned", - "toggle_love": "Legg til spor i favoritter", - "current_song": "Gå til Nåværende Sang" - } - }, - "nowPlaying": { - "title": "", - "empty": "", - "minutesAgo": "" - } -} \ No newline at end of file diff --git a/resources/i18n/pl.json b/resources/i18n/pl.json index ff697d64c..d478aa050 100644 --- a/resources/i18n/pl.json +++ b/resources/i18n/pl.json @@ -1,734 +1,456 @@ { - "languageName": "Polski", - "resources": { - "song": { - "name": "Utwór |||| Utwory", - "fields": { - "albumArtist": "Album Wykonawcy", - "duration": "Czas trwania", - "trackNumber": "#", - "playCount": "Liczba odtworzeń", - "title": "Tytuł", - "artist": "Wykonawca", - "album": "Album", - "path": "Ścieżka pliku", - "genre": "Gatunek", - "compilation": "Kompilacja", - "year": "Rok", - "size": "Rozmiar pliku", - "updatedAt": "Zaktualizowano", - "bitRate": "Szybkość transmisji danych", - "discSubtitle": "Podtytuł Płyty", - "starred": "Ulubione", - "comment": "Komentarz", - "rating": "Ocena", - "quality": "Jakość", - "bpm": "BPM", - "playDate": "Ostatnio Odtwarzane", - "channels": "Kanały", - "createdAt": "Data dodania", - "grouping": "Grupowanie", - "mood": "Nastrój", - "participants": "Dodatkowi uczestnicy", - "tags": "Dodatkowe Tagi", - "mappedTags": "Zmapowane tagi", - "rawTags": "Surowe tagi", - "bitDepth": "Głębokość próbkowania", - "sampleRate": "Częstotliwość próbkowania", - "missing": "Brak", - "libraryName": "Biblioteka", - "composer": "Kompozytor", - "disc": "Dysk %{discNumber}", - "albumGain": "Wzmocnienie albumu", - "trackGain": "Wzmocnienie utworu" - }, - "actions": { - "addToQueue": "Odtwarzaj Później", - "playNow": "Odtwarzaj Teraz", - "addToPlaylist": "Dodaj do Playlisty", - "shuffleAll": "Losuj Wszystkie", - "download": "Pobierz", - "playNext": "Odtwarzaj Następny", - "info": "Zdobądź Informacje", - "showInPlaylist": "Pokaż w Liście Odtwarzania", - "instantMix": "Natychmiastowy Miks" - } + "languageName": "Polski", + "resources": { + "song": { + "name": "Utwór |||| Utwory", + "fields": { + "albumArtist": "Album Wykonawcy", + "duration": "Czas trwania", + "trackNumber": "#", + "playCount": "Liczba odtworzeń", + "title": "Tytuł", + "artist": "Wykonawca", + "album": "Album", + "path": "Ścieżka pliku", + "genre": "Gatunek", + "compilation": "Kompilacja", + "year": "Rok", + "size": "Rozmiar pliku", + "updatedAt": "Zaktualizowano", + "bitRate": "Szybkość transmisji danych", + "discSubtitle": "Podtytuł Płyty", + "starred": "Ulubione", + "comment": "Komentarz", + "rating": "Ocena", + "quality": "Jakość", + "bpm": "BPM", + "playDate": "Ostatnio Odtwarzane", + "channels": "Kanały", + "createdAt": "Data dodania" + }, + "actions": { + "addToQueue": "Odtwarzaj Później", + "playNow": "Odtwarzaj Teraz", + "addToPlaylist": "Dodaj do Playlisty", + "shuffleAll": "Losuj Wszystkie", + "download": "Pobierz", + "playNext": "Odtwarzaj Następny", + "info": "Zdobądź Informacje" + } + }, + "album": { + "name": "Album |||| Albumy", + "fields": { + "albumArtist": "Album Artysty", + "artist": "Wykonawca", + "duration": "Czas trwania", + "songCount": "Liczba utworów", + "playCount": "Liczba odtworzeń", + "name": "Tytuł", + "genre": "Gatunek", + "compilation": "Kompilacja", + "year": "Rok", + "updatedAt": "Zaktualizowany", + "comment": "Komentarz", + "rating": "Ocena", + "createdAt": "Data dodania", + "size": "Rozmiar" + }, + "actions": { + "playAll": "Odtwarzaj", + "playNext": "Odtwarzaj Następny", + "addToQueue": "Odtwarzaj Później", + "shuffle": "Losowo", + "addToPlaylist": "Dodaj do Playlisty", + "download": "Pobierz", + "info": "Zdobądź Informacje", + "share": "Udostępnij" + }, + "lists": { + "all": "Wszystkie", + "random": "Losowo", + "recentlyAdded": "Ostatnio Dodane", + "recentlyPlayed": "Ostatnio Odtwarzane", + "mostPlayed": "Najczęściej Odtwarzane", + "starred": "Ulubione", + "topRated": "Najwyżej Oceniane" + } + }, + "artist": { + "name": "Wykonawca |||| Wykonawcy", + "fields": { + "name": "Tytuł", + "albumCount": "Liczba Albumów", + "songCount": "Liczba Utworów", + "playCount": "Liczba Odtworzeń", + "rating": "Ocena", + "genre": "Gatunek", + "size": "Rozmiar" + } + }, + "user": { + "name": "Użytkownik |||| Użytkownicy", + "fields": { + "userName": "Nazwa użytkownika", + "isAdmin": "Administrator", + "lastLoginAt": "Ostatnio zalogowany", + "updatedAt": "Zaktualizowano", + "name": "Imię", + "password": "Hasło", + "createdAt": "Dodany", + "changePassword": "Zmienić hasło?", + "currentPassword": "Obecne hasło", + "newPassword": "Nowe hasło", + "token": "Token" + }, + "helperTexts": { + "name": "Zmiana nazwy będzie widoczna przy następnym logowaniu" + }, + "notifications": { + "created": "Dodano użytkownika", + "updated": "Zaktualizowano użytkownika", + "deleted": "Usunięto użytkownika" + }, + "message": { + "listenBrainzToken": "Wprowadź swój token ListenBrainz.", + "clickHereForToken": "Kliknij tutaj, aby uzyskać token" + } + }, + "player": { + "name": "Odtwarzacz |||| Odtwarzacze", + "fields": { + "name": "Nazwa", + "transcodingId": "Transkodowanie", + "maxBitRate": "Maks. Bit Rate", + "client": "Klient", + "userName": "Nazwa użytkownika", + "lastSeen": "Ostatnio Widziany", + "reportRealPath": "Zgłoś Rzeczywistą Ścieżkę", + "scrobbleEnabled": "Scrobbluj do zewnętrznych serwisów" + } + }, + "transcoding": { + "name": "Transkodowanie |||| Transkodowanie", + "fields": { + "name": "Nazwa", + "targetFormat": "Format Docelowy", + "defaultBitRate": "Domyślny Bit Rate", + "command": "Komenda" + } + }, + "playlist": { + "name": "Playlista |||| Playlisty", + "fields": { + "name": "Nazwa", + "duration": "Czas trwania", + "ownerName": "Właściciel", + "public": "Publiczna", + "updatedAt": "Zaktualizowana", + "createdAt": "Stworzona", + "songCount": "Liczba utworów", + "comment": "Komentarz", + "sync": "Import automatyczny", + "path": "Zaimportuj z" + }, + "actions": { + "selectPlaylist": "Wybierz playlistę:", + "addNewPlaylist": "Stwórz \"%{name}\"", + "export": "Wyeksportuj", + "makePublic": "Zmień na Publiczną", + "makePrivate": "Zmień na Prywatną" + }, + "message": { + "duplicate_song": "Dodaj zduplikowane utwory", + "song_exist": "Do playlisty dodawane są duplikaty. Czy chcesz je dodać czy pominąć?" + } + }, + "radio": { + "name": "Radio |||| Radia", + "fields": { + "name": "Nazwa", + "streamUrl": "URL Strumienia", + "homePageUrl": "URL Strony Głównej", + "updatedAt": "Zaktualizowano", + "createdAt": "Stworzono" + }, + "actions": { + "playNow": "Odtwarzaj" + } + }, + "share": { + "name": "Udostępnienie |||| Udostępnienia", + "fields": { + "username": "Udostępnione Przez", + "url": "URL", + "description": "Opis", + "contents": "Zawartość", + "expiresAt": "Wygasa", + "lastVisitedAt": "Ostatnio Wyświetlone", + "visitCount": "Wyświetlenia", + "format": "Format", + "maxBitRate": "Maks. Bit Rate", + "updatedAt": "Zaktualizowano", + "createdAt": "Stworzono", + "downloadable": "Zezwolić Na Pobieranie?" + } + } }, - "album": { - "name": "Album |||| Albumy", - "fields": { - "albumArtist": "Album Artysty", - "artist": "Wykonawca", - "duration": "Czas trwania", - "songCount": "Liczba utworów", - "playCount": "Liczba odtworzeń", - "name": "Tytuł", - "genre": "Gatunek", - "compilation": "Kompilacja", - "year": "Rok", - "updatedAt": "Zaktualizowany", - "comment": "Komentarz", - "rating": "Ocena", - "createdAt": "Data dodania", - "size": "Rozmiar", - "originalDate": "Pierwotna Data", - "releaseDate": "Data Wydania", - "releases": "Wydanie |||| Wydania", - "released": "Wydany", - "recordLabel": "Wytwórnia", - "catalogNum": "Numer Katalogowy", - "releaseType": "Typ", - "grouping": "Grupowanie", - "media": "Media", - "mood": "Nastrój", - "date": "Data Nagrania", - "missing": "Brak", - "libraryName": "Biblioteka" - }, - "actions": { - "playAll": "Odtwarzaj", - "playNext": "Odtwarzaj Następny", - "addToQueue": "Odtwarzaj Później", - "shuffle": "Losowo", - "addToPlaylist": "Dodaj do Playlisty", - "download": "Pobierz", - "info": "Zdobądź Informacje", - "share": "Udostępnij", - "refresh": "Odśwież Metadane" - }, - "lists": { - "all": "Wszystkie", - "random": "Losowo", - "recentlyAdded": "Ostatnio Dodane", - "recentlyPlayed": "Ostatnio Odtwarzane", - "mostPlayed": "Najczęściej Odtwarzane", - "starred": "Ulubione", - "topRated": "Najwyżej Oceniane" - } - }, - "artist": { - "name": "Wykonawca |||| Wykonawcy", - "fields": { - "name": "Tytuł", - "albumCount": "Liczba Albumów", - "songCount": "Liczba Utworów", - "playCount": "Liczba Odtworzeń", - "rating": "Ocena", - "genre": "Gatunek", - "size": "Rozmiar", - "role": "Rola", - "missing": "Brak" - }, - "roles": { - "albumartist": "Wykonawca Albumu |||| Wykonawcy Albumu", - "artist": "Wykonawca |||| Wykonawcy", - "composer": "Kompozytor |||| Kompozytorzy", - "conductor": "Dyrygent |||| Dyrygenci", - "lyricist": "Autor tekstów |||| Autorzy tekstów", - "arranger": "Aranżer |||| Aranżerzy", - "producer": "Producent |||| Producenci", - "director": "Reżyser |||| Reżyserzy", - "engineer": "Inżynier |||| Inżynierowie", - "mixer": "Mikser |||| Mikserzy", - "remixer": "Remixer |||| Remixerzy", - "djmixer": "Didżej |||| Didżerzy", - "performer": "Wykonawca |||| Wykonawcy", - "maincredit": "Artysta albumu lub Artysta |||| Artyści albumu lub Artyści" - }, - "actions": { - "shuffle": "Losuj", - "radio": "Radio", - "topSongs": "Najlepsze Utwory" - } - }, - "user": { - "name": "Użytkownik |||| Użytkownicy", - "fields": { - "userName": "Nazwa użytkownika", - "isAdmin": "Administrator", - "lastLoginAt": "Ostatnio zalogowany", - "updatedAt": "Zaktualizowano", - "name": "Imię", - "password": "Hasło", - "createdAt": "Dodany", - "changePassword": "Zmienić hasło?", - "currentPassword": "Obecne hasło", - "newPassword": "Nowe hasło", - "token": "Token", - "lastAccessAt": "Ostatnia Aktywność", - "libraries": "Biblioteki", - "scrobbleFilter": "Filtr scrobblowania" - }, - "helperTexts": { - "name": "Zmiana nazwy będzie widoczna przy następnym logowaniu", - "libraries": "Wybierz biblioteki dla użytkownika lub pozostaw pustę, aby użyć domyślnej biblioteki", - "scrobbleFilter": "Utwory spełniające kryteria tych inteligentnych list odtwarzania nie są wysyłane do serwisów Last.fm, ListenBrainz ani do wtyczek typu scrobbler. Wykorzystywana jest tu ta sama składnia JSON i zasada działania, co w przypadku inteligentnych list odtwarzania. Przykład: {\"all\":[{\"lt\":{\"rating\":4}}]}. Pozostawienie pola pustego spowoduje scrobblowanie wszystkich utworów. Nie wpływa to na lokalne liczniki odtworzeń." - }, - "notifications": { - "created": "Dodano użytkownika", - "updated": "Zaktualizowano użytkownika", - "deleted": "Usunięto użytkownika" - }, - "message": { - "listenBrainzToken": "Wprowadź swój token ListenBrainz.", - "clickHereForToken": "Kliknij tutaj, aby uzyskać token", - "selectAllLibraries": "Wybierz wszystkie biblioteki", - "adminAutoLibraries": "Administratorzy automatycznie mają dostęp do wszystkich bibliotek" - }, - "validation": { - "librariesRequired": "Przynajmniej jedna biblioteka musi być wybrana dla zwykłego użytkownika", - "invalidScrobbleFilter": "Reguły inteligentnej listy odtwarzania muszą być poprawne. Parametry limitu, przesunięcia oraz opóźnienia odświeżania nie są obsługiwane." - } - }, - "player": { - "name": "Odtwarzacz |||| Odtwarzacze", - "fields": { - "name": "Nazwa", - "transcodingId": "Transkodowanie", - "maxBitRate": "Maks. Bit Rate", - "client": "Klient", - "userName": "Nazwa użytkownika", - "lastSeen": "Ostatnio Widziany", - "reportRealPath": "Zgłoś Rzeczywistą Ścieżkę", - "scrobbleEnabled": "Scrobbluj do zewnętrznych serwisów" - } - }, - "transcoding": { - "name": "Transkodowanie |||| Transkodowanie", - "fields": { - "name": "Nazwa", - "targetFormat": "Format Docelowy", - "defaultBitRate": "Domyślny Bit Rate", - "command": "Komenda" - }, - "choices": { - "noDefaultBitRate": "" - } - }, - "playlist": { - "name": "Playlista |||| Playlisty", - "fields": { - "name": "Nazwa", - "duration": "Czas trwania", - "ownerName": "Właściciel", - "public": "Publiczna", - "updatedAt": "Zaktualizowana", - "createdAt": "Stworzona", - "songCount": "Liczba utworów", - "comment": "Komentarz", - "sync": "Import automatyczny", - "path": "Zaimportuj z", - "starred": "Ulubione" - }, - "actions": { - "selectPlaylist": "Wybierz playlistę:", - "addNewPlaylist": "Stwórz \"%{name}\"", - "export": "Wyeksportuj", - "makePublic": "Zmień na Publiczną", - "makePrivate": "Zmień na Prywatną", - "saveQueue": "Zapisz Kolejkę do Playlisty", - "searchOrCreate": "Szukaj list odtwarzania lub zacznij pisać, aby stworzyć nową...", - "pressEnterToCreate": "Wciśnij Enter, aby stworzyć nową listę odtwarzania", - "removeFromSelection": "Usuń z zaznaczenia" - }, - "message": { - "duplicate_song": "Dodaj zduplikowane utwory", - "song_exist": "Do playlisty dodawane są duplikaty. Czy chcesz je dodać czy pominąć?", - "noPlaylistsFound": "Brak list odtwarzania", - "noPlaylists": "Brak dostępnych list odtwarzania" - } - }, - "radio": { - "name": "Radio |||| Radia", - "fields": { - "name": "Nazwa", - "streamUrl": "URL Strumienia", - "homePageUrl": "URL Strony Głównej", - "updatedAt": "Zaktualizowano", - "createdAt": "Stworzono" - }, - "actions": { - "playNow": "Odtwarzaj" - } - }, - "share": { - "name": "Udostępnienie |||| Udostępnienia", - "fields": { - "username": "Udostępnione Przez", - "url": "URL", - "description": "Opis", - "contents": "Zawartość", - "expiresAt": "Wygasa", - "lastVisitedAt": "Ostatnio Wyświetlone", - "visitCount": "Wyświetlenia", - "format": "Format", - "maxBitRate": "Maks. Bit Rate", - "updatedAt": "Zaktualizowano", - "createdAt": "Stworzono", - "downloadable": "Zezwolić Na Pobieranie?" - } - }, - "missing": { - "name": "Brakujący Plik|||| Brakujące Pliki", - "fields": { - "path": "Ścieżka", - "size": "Rozmiar", - "updatedAt": "Zniknął na", - "libraryName": "Biblioteka" - }, - "actions": { - "remove": "Usuń", - "remove_all": "Usuń Wszystko" - }, - "notifications": { - "removed": "Usunięto brakujące pliki" - }, - "empty": "Brak Brakujących Plików" - }, - "library": { - "name": "Biblioteka |||| Biblioteki", - "fields": { - "name": "Nazwa", - "path": "Ścieżka", - "remotePath": "Zdalna Ścieżka", - "lastScanAt": "Ostatni Skan", - "songCount": "Utwory", - "albumCount": "Albumy", - "artistCount": "Artyści", - "totalSongs": "Utwory", - "totalAlbums": "Albumy", - "totalArtists": "Artyści", - "totalFolders": "Foldery", - "totalFiles": "Pliki", - "totalMissingFiles": "Brakujące Pliki", - "totalSize": "Całkowity Rozmiar", - "totalDuration": "Czas Trwania", - "defaultNewUsers": "Domyślne dla Nowych Użytkowników", - "createdAt": "Stworzona", - "updatedAt": "Zaktualizowana" - }, - "sections": { - "basic": "Podstawowe Informacje", - "statistics": "Statystyki" - }, - "actions": { - "scan": "Skanuj Bibliotekę", - "manageUsers": "Zarządzaj Dostępami Użytkownika", - "viewDetails": "Zobacz Szczegóły", - "quickScan": "Szybkie Skanowanie", - "fullScan": "Pełne Skanowanie" - }, - "notifications": { - "created": "Biblioteka utworzona prawidłowo", - "updated": "Biblioteka zaktualizowana prawidłowo", - "deleted": "Biblioteka usunięta prawidłowo", - "scanStarted": "Rozpoczęto skan biblioteki", - "scanCompleted": "Zakończono skan biblioteki", - "quickScanStarted": "Szybkie skanowanie rozpoczęte", - "fullScanStarted": "Pełne skanowanie rozpoczęte", - "scanError": "Błąd podczas startu skanowania. Sprawdź logi" - }, - "validation": { - "nameRequired": "Nazwa biblioteki jest wymagana", - "pathRequired": "Ścieżka biblioteki jest wymagana", - "pathNotDirectory": "Ścieżka biblioteki musi być katalogiem", - "pathNotFound": "Brak ścieżki biblioteki", - "pathNotAccessible": "Ścieżka biblioteki niedostępna", - "pathInvalid": "Niepoprawna ścieżka biblioteki" - }, - "messages": { - "deleteConfirm": "Czy chcesz usunąć tę bibliotekę? Spowoduje to usunięcie wszystkich powiązanych danych i dostępów użytkowników.", - "scanInProgress": "Skanowanie w trakcie...", - "noLibrariesAssigned": "Brak bibliotek przypisanych do tego użytkownika" - } - }, - "plugin": { - "name": "\nWtyczka |||| Wtyczki", - "fields": { - "id": "ID", - "name": "Nazwa", - "description": "Opis", - "version": "Wersja", - "author": "Autor", - "website": "Witryna", - "permissions": "Uprawnienia", - "enabled": "Aktywny", - "status": "Status", - "path": "Ścieżka", - "lastError": "Błąd", - "hasError": "Błąd", - "updatedAt": "Zaktualizowana", - "createdAt": "Zainstalowana", - "configKey": "Klucz", - "configValue": "Wartość", - "allUsers": "Zezwalaj wszystkim użytkownikom", - "selectedUsers": "Wybrani użytkownicy", - "allLibraries": "Zezwalaj dla wszystkich bibliotek", - "selectedLibraries": "Wybrane biblioteki", - "allowWriteAccess": "Zezwól na zapis" - }, - "sections": { - "status": "Status", - "info": "Informacje O Wtyczce", - "configuration": "Konfiguracja", - "manifest": "Manifest", - "usersPermission": "Uprawnienia Użytkowników", - "libraryPermission": "Uprawnienia Biblioteki" - }, - "status": { - "enabled": "Włączona", - "disabled": "Wyłączona" - }, - "actions": { - "enable": "Włącz", - "disable": "Wyłącz", - "disabledDueToError": "Napraw błąd przed włączeniem", - "disabledUsersRequired": "Wybierz użytkowników przed włączeniem", - "disabledLibrariesRequired": "Wybierz biblioteki przed włączaniem", - "addConfig": "Dodaj Konfigurację", - "rescan": "Przeskanuj Ponownie" - }, - "notifications": { - "enabled": "Wtyczka włączona", - "disabled": "Wtyczka wyłączona", - "updated": "Wtyczka zaktualizowana", - "error": "Błąd aktualizacji wtyczki" - }, - "validation": { - "invalidJson": "Konfiguracja musić być w poprawnym formacie JSON" - }, - "messages": { - "configHelp": "Użyj par klucz-wartość, aby skonfigurować wtyczkę. Pozostaw puste, jeśli wtyczka nie wymaga konfiguracji.", - "clickPermissions": "Kliknij uprawnienie, aby uzyskać szczegółowe informacje", - "noConfig": "Nie wybrano konfiguracji", - "allUsersHelp": "Po włączeniu wtyczka będzie miała dostęp do wszystkich użytkowników, także tych utworzonych w przyszłości.", - "noUsers": "Nie wybrano użytkowników", - "permissionReason": "Powód", - "usersRequired": "Ta wtyczka wymaga dostępu do informacji o użytkowniku. Wybierz użytkowników, do których wtyczka ma mieć dostęp, lub włącz opcję „Zezwól wszystkim użytkownikom”.", - "allLibrariesHelp": "Po włączeniu wtyczka będzie miała dostęp do wszystkich bibliotek, także tych utworzonych w przyszłości.", - "noLibraries": "Nie wybrano biblioteki", - "librariesRequired": "Wtyczka wymaga dostępu do informacji o bibliotece. Wybierz, dla której biblioteki zezwolić dostęp, lub włącz 'Zezwalaj dla wszystkich bibliotek'.", - "requiredHosts": "Wymagane hosty", - "configValidationError": "Weryfikacja konfiguracji nie powiodła się:", - "schemaRenderError": "Nie można wyrenderować formularza konfiguracji. Schemat wtyczki może być nieprawidłowy.", - "allowWriteAccessHelp": "Po włączeniu wtyczka może modyfikować pliki w katalogach bibliotek. Domyślnie wtyczki mają dostęp tylko do odczytu.", - "idHelp": "" - }, - "placeholders": { - "configKey": "klucz", - "configValue": "wartość" - } - } - }, - "ra": { - "auth": { - "welcome1": "Dziękujemy za zainstalowanie Navidrome!", - "welcome2": "Stwórz konto administratora, aby rozpocząć", - "confirmPassword": "Potwierdź Hasło", - "buttonCreateAdmin": "Stwórz Administratora", - "auth_check_error": "Proszę się zalogować, aby kontynuować", - "user_menu": "Profil", - "username": "Nazwa użytkownika", - "password": "Hasło", - "sign_in": "Zaloguj się", - "sign_in_error": "Uwierzytelnianie nie powiodło się, spróbuj ponownie", - "logout": "Wyloguj", - "insightsCollectionNote": "Navidrome zbiera anonimowe dane dotyczące użytkowania, aby\npomóc ulepszyć projekt. Kliknij [tutaj], jeśli chcesz dowiedzieć się więcej lub zrezygnować" - }, - "validation": { - "invalidChars": "Proszę, używaj wyłącznie liter i cyfr", - "passwordDoesNotMatch": "Hasło nie pasuje", - "required": "Wymagane", - "minLength": "Powinno być minimalnie %{min} znaków", - "maxLength": "Powinno być %{max} lub mniej znaków", - "minValue": "Minimalna wartość to %{min}", - "maxValue": "Powinno być %{max} lub mniej", - "number": "Musi być liczbą", - "email": "Adres e-mail musi być poprawny", - "oneOf": "Musi być jedną z: %{options}", - "regex": "Musi pasować do określonego formatu (regexp): %{pattern}", - "unique": "Musi być unikalne", - "url": "Adres URL musi być poprawny" - }, - "action": { - "add_filter": "Dodaj filtr", - "add": "Dodaj", - "back": "Wstecz", - "bulk_actions": "1 element wybrany |||| %{smart_count} wybranych elementów", - "cancel": "Anuluj", - "clear_input_value": "Wyczyść wartość", - "clone": "Sklonuj", - "confirm": "Potwierdź", - "create": "Stwórz", - "delete": "Usuń", - "edit": "Edytuj", - "export": "Wyeksportuj", - "list": "Lista", - "refresh": "Odśwież", - "remove_filter": "Usuń ten filtr", - "remove": "Usuń", - "save": "Zapisz", - "search": "Szukaj", - "show": "Pokaż", - "sort": "Sortuj", - "undo": "Cofnij", - "expand": "Rozwiń", - "close": "Zamknij", - "open_menu": "Otwórz menu", - "close_menu": "Zamknij menu", - "unselect": "Odznacz", - "skip": "Pomiń", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Udostępnij", - "download": "Pobierz" - }, - "boolean": { - "true": "Tak", - "false": "Nie" - }, - "page": { - "create": "Stwórz %{name}", - "dashboard": "Panel Główny", - "edit": "%{name} #%{id}", - "error": "Coś poszło nie tak", - "list": "%{name}", - "loading": "Ładowanie", - "not_found": "Nie Znaleziono", - "show": "%{name} #%{id}\n", - "empty": "Brakuje elementu typu %{name}.", - "invite": "Czy chcesz dodać nowy?" - }, - "input": { - "file": { - "upload_several": "Upuść kilka plików do przesłania lub kliknij, aby wybrać jeden.", - "upload_single": "Upuść plik do przesłania, lub kliknij, aby go wybrać." - }, - "image": { - "upload_several": "Upuść kilka zdjęć do przesłania, lub kliknij, aby wybrać jedno.", - "upload_single": "Upuść zdjęcie do przesłania, lub kliknij, aby je wybrać." - }, - "references": { - "all_missing": "Nie można znaleźć danych referencyjnych.", - "many_missing": "Co najmniej jedno z powiązanych odniesień nie jest już dostępne.", - "single_missing": "Wygląda na to, że powiązane odniesienie nie jest już dostępne." - }, - "password": { - "toggle_visible": "Ukryj hasło", - "toggle_hidden": "Pokaż hasło" - } + "ra": { + "auth": { + "welcome1": "Dziękujemy za zainstalowanie Navidrome!", + "welcome2": "Stwórz konto administratora, aby rozpocząć", + "confirmPassword": "Potwierdź Hasło", + "buttonCreateAdmin": "Stwórz Administratora", + "auth_check_error": "Proszę się zalogować, aby kontynuować", + "user_menu": "Profil", + "username": "Nazwa użytkownika", + "password": "Hasło", + "sign_in": "Zaloguj się", + "sign_in_error": "Uwierzytelnianie nie powiodło się, spróbuj ponownie", + "logout": "Wyloguj" + }, + "validation": { + "invalidChars": "Proszę, używaj wyłącznie liter i cyfr", + "passwordDoesNotMatch": "Hasło nie pasuje", + "required": "Wymagane", + "minLength": "Powinno być minimalnie %{min} znaków", + "maxLength": "Powinno być %{max} lub mniej znaków", + "minValue": "Minimalna wartość to %{min}", + "maxValue": "Powinno być %{max} lub mniej", + "number": "Musi być liczbą", + "email": "Adres e-mail musi być poprawny", + "oneOf": "Musi być jedną z: %{options}", + "regex": "Musi pasować do określonego formatu (regexp): %{pattern}", + "unique": "Musi być unikalne", + "url": "Adres URL musi być poprawny" + }, + "action": { + "add_filter": "Dodaj filtr", + "add": "Dodaj", + "back": "Wstecz", + "bulk_actions": "1 element wybrany |||| %{smart_count} wybranych elementów", + "cancel": "Anuluj", + "clear_input_value": "Wyczyść wartość", + "clone": "Sklonuj", + "confirm": "Potwierdź", + "create": "Stwórz", + "delete": "Usuń", + "edit": "Edytuj", + "export": "Wyeksportuj", + "list": "Lista", + "refresh": "Odśwież", + "remove_filter": "Usuń ten filtr", + "remove": "Usuń", + "save": "Zapisz", + "search": "Szukaj", + "show": "Pokaż", + "sort": "Sortuj", + "undo": "Cofnij", + "expand": "Rozwiń", + "close": "Zamknij", + "open_menu": "Otwórz menu", + "close_menu": "Zamknij menu", + "unselect": "Odznacz", + "skip": "Pomiń", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Udostępnij", + "download": "Pobierz" + }, + "boolean": { + "true": "Tak", + "false": "Nie" + }, + "page": { + "create": "Stwórz %{name}", + "dashboard": "Panel Główny", + "edit": "%{name} #%{id}", + "error": "Coś poszło nie tak", + "list": "%{name}", + "loading": "Ładowanie", + "not_found": "Nie Znaleziono", + "show": "%{name} #%{id}\n", + "empty": "Brakuje elementu typu %{name}.", + "invite": "Czy chcesz dodać nowy?" + }, + "input": { + "file": { + "upload_several": "Upuść kilka plików do przesłania lub kliknij, aby wybrać jeden.", + "upload_single": "Upuść plik do przesłania, lub kliknij, aby go wybrać." + }, + "image": { + "upload_several": "Upuść kilka zdjęć do przesłania, lub kliknij, aby wybrać jedno.", + "upload_single": "Upuść zdjęcie do przesłania, lub kliknij, aby je wybrać." + }, + "references": { + "all_missing": "Nie można znaleźć danych referencyjnych.", + "many_missing": "Co najmniej jedno z powiązanych odniesień nie jest już dostępne.", + "single_missing": "Wygląda na to, że powiązane odniesienie nie jest już dostępne." + }, + "password": { + "toggle_visible": "Ukryj hasło", + "toggle_hidden": "Pokaż hasło" + } + }, + "message": { + "about": "O aplikacji", + "are_you_sure": "Czy jesteś pewny?", + "bulk_delete_content": "Czy jesteś pewny, że chcesz usunąć %{name}? |||| Czy jesteś pewny, że chcesz usunąć %{smart_count} elementów?", + "bulk_delete_title": "Usuń %{name} |||| Usuń %{smart_count} %{name}", + "delete_content": "Czy jesteś pewny, że chcesz usunąć ten element?", + "delete_title": "Usuń %{name} #%{id}", + "details": "Szczegóły", + "error": "Wystąpił błąd po stronie klienta i Twoje żądanie nie może być zrealizowane.", + "invalid_form": "Formularz jest nieprawidłowy. Proszę sprawdź błędy", + "loading": "Ładowanie zawartości, proszę czekać", + "no": "Nie", + "not_found": "Wpisałeś zły adres URL, albo skorzystałeś ze złego linku.", + "yes": "Tak", + "unsaved_changes": "Niektóre z Twoich zmian nie zostały zapisane. Czy jesteś pewny, że chcesz je zignorować?" + }, + "navigation": { + "no_results": "Brak wyników", + "no_more_results": "Strona o numerze %{page} jest poza granicami. Proszę spróbować poprzednią stronę.", + "page_out_of_boundaries": "Strona o numerze %{page} jest poza granicami", + "page_out_from_end": "Nie można przejść za ostatnią stronę", + "page_out_from_begin": "Nie można przejść przed 1 stronę", + "page_range_info": "%{offsetBegin}-%{offsetEnd} z %{total}", + "page_rows_per_page": "Wierszy na stronie:", + "next": "Następna", + "prev": "Poprzednia", + "skip_nav": "Przejdź do treści" + }, + "notification": { + "updated": "Element zaktualizowany |||| %{smart_count} zaktualizowanych elementów", + "created": "Element stworzony", + "deleted": "Element usunięty |||| %{smart_count} usuniętych elementów", + "bad_item": "Niepoprawny element", + "item_doesnt_exist": "Element nie istnieje", + "http_error": "Błąd komunikacji z serwerem", + "data_provider_error": "Błąd dostawcy danych. Sprawdź konsolę, aby uzyskać szczegółowe informacje.", + "i18n_error": "Nie można załadować tłumaczenia dla tego języka", + "canceled": "Akcja anulowana", + "logged_out": "Twoja sesja została zakończona, proszę o ponowne połączenie.", + "new_version": "Dostępna nowa wersja! Proszę odświeżyć okno przeglądarki." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Kolumny Do Wyświetlenia", + "layout": "Układ", + "grid": "Siatka", + "table": "Tabela" + } }, "message": { - "about": "O aplikacji", - "are_you_sure": "Czy jesteś pewny?", - "bulk_delete_content": "Czy jesteś pewny, że chcesz usunąć %{name}? |||| Czy jesteś pewny, że chcesz usunąć %{smart_count} elementów?", - "bulk_delete_title": "Usuń %{name} |||| Usuń %{smart_count} %{name}", - "delete_content": "Czy jesteś pewny, że chcesz usunąć ten element?", - "delete_title": "Usuń %{name} #%{id}", - "details": "Szczegóły", - "error": "Wystąpił błąd po stronie klienta i Twoje żądanie nie może być zrealizowane.", - "invalid_form": "Formularz jest nieprawidłowy. Proszę sprawdź błędy", - "loading": "Ładowanie zawartości, proszę czekać", - "no": "Nie", - "not_found": "Wpisałeś zły adres URL, albo skorzystałeś ze złego linku.", - "yes": "Tak", - "unsaved_changes": "Niektóre z Twoich zmian nie zostały zapisane. Czy jesteś pewny, że chcesz je zignorować?" - }, - "navigation": { - "no_results": "Brak wyników", - "no_more_results": "Strona o numerze %{page} jest poza granicami. Proszę spróbować poprzednią stronę.", - "page_out_of_boundaries": "Strona o numerze %{page} jest poza granicami", - "page_out_from_end": "Nie można przejść za ostatnią stronę", - "page_out_from_begin": "Nie można przejść przed 1 stronę", - "page_range_info": "%{offsetBegin}-%{offsetEnd} z %{total}", - "page_rows_per_page": "Wierszy na stronie:", - "next": "Następna", - "prev": "Poprzednia", - "skip_nav": "Przejdź do treści" - }, - "notification": { - "updated": "Element zaktualizowany |||| %{smart_count} zaktualizowanych elementów", - "created": "Element stworzony", - "deleted": "Element usunięty |||| %{smart_count} usuniętych elementów", - "bad_item": "Niepoprawny element", - "item_doesnt_exist": "Element nie istnieje", - "http_error": "Błąd komunikacji z serwerem", - "data_provider_error": "Błąd dostawcy danych. Sprawdź konsolę, aby uzyskać szczegółowe informacje.", - "i18n_error": "Nie można załadować tłumaczenia dla tego języka", - "canceled": "Akcja anulowana", - "logged_out": "Twoja sesja została zakończona, proszę o ponowne połączenie.", - "new_version": "Dostępna nowa wersja! Proszę odświeżyć okno przeglądarki." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Kolumny Do Wyświetlenia", - "layout": "Układ", - "grid": "Siatka", - "table": "Tabela" - } - }, - "message": { - "note": "UWAGA", - "transcodingDisabled": "Zmiana ustawień transkodowania przez interfejs sieciowy jest zablokowana z powodów bezpieczeństwa. Jeśli chcesz zmienić (edytować lub dodawać) opcje transkodowania, uruchom ponownie serwer z %{config} opcją konfiguracji.", - "transcodingEnabled": "Navidrome aktualnie działa z %{config}, co umożliwia korzystanie z komend systemowych ustawień transkodowania poprzez interfejs sieciowy. Rekomendujemy wyłączenie tego ustawienia w celu zwiększenia bezpieczeństwa i aktywowanie go wyłącznie podczas konfiguracji transkodowania.", - "songsAddedToPlaylist": "Dodano 1 utwór do playlisty |||| Dodano %{smart_count} utworów do playlisty", - "noPlaylistsAvailable": "Niedostępne", - "delete_user_title": "Usuń użytkownika '%{name}'", - "delete_user_content": "Czy jesteś pewien, że chcesz usunąć tego użytkownika oraz wszystkie jego dane (wliczając w to playlisty oraz ustawienia)?", - "notifications_blocked": "Zablokowałeś Powiadomienia w ustawieniach swojej przeglądarki", - "notifications_not_available": "Ta przeglądarka nie obsługuje powiadomień", - "lastfmLinkSuccess": "Połączono Last.fm i włączono scrobblowanie", - "lastfmLinkFailure": "Nie można połączyć Last.fm", - "lastfmUnlinkSuccess": "Odłączono Last.fm i wyłączono scrobblowanie", - "lastfmUnlinkFailure": "Nie można odłączyć Last.fm", - "openIn": { - "lastfm": "Otwórz w Last.fm", - "musicbrainz": "Otwórz w MusicBrainz" - }, - "lastfmLink": "Czytaj więcej...", - "listenBrainzLinkSuccess": "Połączono ListenBrainz i włączono scrobblowanie dla użytkownika: %{user}", - "listenBrainzLinkFailure": "ListenBrainz nie mógł zostać połączony: %{error}", - "listenBrainzUnlinkSuccess": "Odłączono ListenBrainz i wyłączono scrobblowanie", - "listenBrainzUnlinkFailure": "ListenBrainz nie może być odłączony", - "downloadOriginalFormat": "Pobierz w oryginalnym formacie", - "shareOriginalFormat": "Udostępnij w oryginalnym formacie", - "shareDialogTitle": "Udostępnij %{resource} '%{name}'", - "shareBatchDialogTitle": "Udostępnij 1 %{resource} |||| Udostępnij %{smart_count} %{resource}", - "shareSuccess": "Adres URL skopiowany do schowka: %{url}", - "shareFailure": "Błąd podczas kopiowania URL %{url} do schowka", - "downloadDialogTitle": "Pobierz %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Skopiuj do schowka: Ctrl+C, Enter", - "remove_missing_title": "Usuń brakujące dane", - "remove_missing_content": "Czy na pewno chcesz usunąć wybrane brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszystkich powiązań, takich jak liczba odtworzeń i oceny.", - "remove_all_missing_title": "Usuń wszystkie brakujące pliki", - "remove_all_missing_content": "Czy chcesz usunąć wszystkie brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszelkich odniesień do tych plików, takich jak liczba odtworzeń, czy oceny.", - "noSimilarSongsFound": "Brak podobnych utworów", - "noTopSongsFound": "Brak najlepszych utworów", - "startingInstantMix": "Ładowanie Natychmiastowego Miksu...", - "uploadCover": "Prześlij Okładkę", - "removeCover": "Usuń Okładkę", - "coverUploaded": "Okładka zaktualizowana", - "coverRemoved": "Okładka usunięta", - "coverUploadError": "Błąd przesyłania okładki", - "coverRemoveError": "Błąd usuwania okładki", - "metadataRefreshStarted": "Odświeżanie metadanych w tle" - }, - "menu": { - "library": "Biblioteka", - "settings": "Ustawienia", - "version": "Wersja", - "theme": "Wygląd", - "personal": { - "name": "Personalizacja", - "options": { - "theme": "Wygląd", - "language": "Język", - "defaultView": "Widok Podstawowy", - "desktop_notifications": "Powiadomienia", - "lastfmScrobbling": "Scrobbluj do Last.fm", - "listenBrainzScrobbling": "Scrobbluj do ListenBrainz", - "replaygain": "Tryb ReplayGain", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "Wyłączony", - "album": "Użyj Wzmocnienia Albumu", - "track": "Użyj Wzmocnienia Utworu" + "note": "UWAGA", + "transcodingDisabled": "Zmiana ustawień transkodowania przez interfejs sieciowy jest zablokowana z powodów bezpieczeństwa. Jeśli chcesz zmienić (edytować lub dodawać) opcje transkodowania, uruchom ponownie serwer z %{config} opcją konfiguracji.", + "transcodingEnabled": "Navidrome aktualnie działa z %{config}, co umożliwia korzystanie z komend systemowych ustawień transkodowania poprzez interfejs sieciowy. Rekomendujemy wyłączenie tego ustawienia w celu zwiększenia bezpieczeństwa i aktywowanie go wyłącznie podczas konfiguracji transkodowania.", + "songsAddedToPlaylist": "Dodano 1 utwór do playlisty |||| Dodano %{smart_count} utworów do playlisty", + "noPlaylistsAvailable": "Niedostępne", + "delete_user_title": "Usuń użytkownika '%{name}'", + "delete_user_content": "Czy jesteś pewien, że chcesz usunąć tego użytkownika oraz wszystkie jego dane (wliczając w to playlisty oraz ustawienia)?", + "notifications_blocked": "Zablokowałeś Powiadomienia w ustawieniach swojej przeglądarki", + "notifications_not_available": "Ta przeglądarka nie obsługuje powiadomień", + "lastfmLinkSuccess": "Połączono Last.fm i włączono scrobblowanie", + "lastfmLinkFailure": "Nie można połączyć Last.fm", + "lastfmUnlinkSuccess": "Odłączono Last.fm i wyłączono scrobblowanie", + "lastfmUnlinkFailure": "Nie można odłączyć Last.fm", + "openIn": { + "lastfm": "Otwórz w Last.fm", + "musicbrainz": "Otwórz w MusicBrainz" }, - "lastfmNotConfigured": "Klucz-API Last.fm jest nieskonfigurowany" - } + "lastfmLink": "Czytaj więcej...", + "listenBrainzLinkSuccess": "Połączono ListenBrainz i włączono scrobblowanie dla użytkownika: %{user}", + "listenBrainzLinkFailure": "ListenBrainz nie mógł zostać połączony: %{error}", + "listenBrainzUnlinkSuccess": "Odłączono ListenBrainz i wyłączono scrobblowanie", + "listenBrainzUnlinkFailure": "ListenBrainz nie może być odłączony", + "downloadOriginalFormat": "Pobierz w oryginalnym formacie", + "shareOriginalFormat": "Udostępnij w oryginalnym formacie", + "shareDialogTitle": "Udostępnij %{resource} '%{name}'", + "shareBatchDialogTitle": "Udostępnij 1 %{resource} |||| Udostępnij %{smart_count} %{resource}", + "shareSuccess": "Adres URL skopiowany do schowka: %{url}", + "shareFailure": "Błąd podczas kopiowania URL %{url} do schowka", + "downloadDialogTitle": "Pobierz %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Skopiuj do schowka: Ctrl+C, Enter" }, - "albumList": "Albumy", - "about": "O aplikacji", - "playlists": "Playlisty", - "sharedPlaylists": "Udostępnione Playlisty", - "librarySelector": { - "allLibraries": "Wszystkie Biblioteki (%{count})", - "multipleLibraries": "%{selected} z %{total} Bibliotek", - "selectLibraries": "Wybierz Biblioteki", - "none": "Żadna" + "menu": { + "library": "Biblioteka", + "settings": "Ustawienia", + "version": "Wersja", + "theme": "Wygląd", + "personal": { + "name": "Personalizacja", + "options": { + "theme": "Wygląd", + "language": "Język", + "defaultView": "Widok Podstawowy", + "desktop_notifications": "Powiadomienia", + "lastfmScrobbling": "Scrobbluj do Last.fm", + "listenBrainzScrobbling": "Scrobbluj do ListenBrainz", + "replaygain": "Tryb ReplayGain", + "preAmp": "ReplayGain PreAmp (dB)", + "gain": { + "none": "Wyłączony", + "album": "Użyj Wzmocnienia Albumu", + "track": "Użyj Wzmocnienia Utworu" + } + } + }, + "albumList": "Albumy", + "about": "O aplikacji", + "playlists": "Playlisty", + "sharedPlaylists": "Udostępnione Playlisty" }, - "onlyFavourites": "Pokaż tylko ulubione" - }, - "player": { - "playListsText": "Kolejka Odtwarzania", - "openText": "Otwórz", - "closeText": "Zamknij", - "notContentText": "Brak muzyki", - "clickToPlayText": "Kliknij, aby odtworzyć", - "clickToPauseText": "Kliknij, aby zapauzować", - "nextTrackText": "Następny utwór", - "previousTrackText": "Poprzedni utwór", - "reloadText": "Przeładuj", - "volumeText": "Głośność", - "toggleLyricText": "Pokaż tekst utworu", - "toggleMiniModeText": "Zminimalizuj", - "destroyText": "Zniszcz", - "downloadText": "Pobierz", - "removeAudioListsText": "Usuń listy audio", - "clickToDeleteText": "Kliknij, aby usunąć %{name}", - "emptyLyricText": "Brak tekstu", - "playModeText": { - "order": "W kolejności", - "orderLoop": "Powtarzaj", - "singleLoop": "Powtórz Raz", - "shufflePlay": "Odtwarzaj losowo" + "player": { + "playListsText": "Kolejka Odtwarzania", + "openText": "Otwórz", + "closeText": "Zamknij", + "notContentText": "Brak muzyki", + "clickToPlayText": "Kliknij, aby odtworzyć", + "clickToPauseText": "Kliknij, aby zapauzować", + "nextTrackText": "Następny utwór", + "previousTrackText": "Poprzedni utwór", + "reloadText": "Przeładuj", + "volumeText": "Głośność", + "toggleLyricText": "Pokaż tekst utworu", + "toggleMiniModeText": "Zminimalizuj", + "destroyText": "Zniszcz", + "downloadText": "Pobierz", + "removeAudioListsText": "Usuń listy audio", + "clickToDeleteText": "Kliknij, aby usunąć %{name}", + "emptyLyricText": "Brak tekstu", + "playModeText": { + "order": "W kolejności", + "orderLoop": "Powtarzaj", + "singleLoop": "Powtórz Raz", + "shufflePlay": "Odtwarzaj losowo" + } + }, + "about": { + "links": { + "homepage": "Strona główna", + "source": "Kod źródłowy", + "featureRequests": "Prośby o nowe funkcjonalności" + } + }, + "activity": { + "title": "Aktywność", + "totalScanned": "Liczba Przeskanowanych Folderów", + "quickScan": "Szybkie Skanowanie", + "fullScan": "Pełne Skanowanie", + "serverUptime": "Czas Działania Serwera", + "serverDown": "NIEDOSTĘPNY" + }, + "help": { + "title": "Skróty Klawiszowe Navidrome", + "hotkeys": { + "show_help": "Wyświetl Pomoc", + "toggle_menu": "Pokaż Pasek Boczny", + "toggle_play": "Odtwórz / Wstrzymaj", + "prev_song": "Poprzedni Utwór", + "next_song": "Następny Utwór", + "vol_up": "Głośniej", + "vol_down": "Ciszej", + "toggle_love": "Dodaj ten utwór do ulubionych", + "current_song": "Przejdź do Bieżącego Utworu" + } } - }, - "about": { - "links": { - "homepage": "Strona główna", - "source": "Kod źródłowy", - "featureRequests": "Prośby o nowe funkcjonalności", - "lastInsightsCollection": "Ostatnie zebranie statystyk", - "insights": { - "disabled": "Wyłączone", - "waiting": "Oczekujące" - } - }, - "tabs": { - "about": "O", - "config": "Konfiguracja" - }, - "config": { - "configName": "Nazwa Konfiguracji", - "environmentVariable": "Zmienna Środowiskowa", - "currentValue": "Obecna Wartość", - "configurationFile": "Plik Konfiguracyjny", - "exportToml": "Eksportuj Konfigurację (TOML)", - "exportSuccess": "Konfiguracja wyeksportowana do schowka w formacie TOML", - "exportFailed": "Błąd kopiowania konfiguracji", - "devFlagsHeader": "Flagi Rozwojowe (mogą ulec zmianie/usunięciu)", - "devFlagsComment": "To są ustawienia eksperymentalne i mogą zostać usunięte w przyszłych wydaniach", - "downloadToml": "Konfiguracja Pobierania (TOML)" - } - }, - "activity": { - "title": "Aktywność", - "totalScanned": "Liczba Przeskanowanych Folderów", - "quickScan": "Szybkie Skanowanie", - "fullScan": "Pełne Skanowanie", - "serverUptime": "Czas Działania Serwera", - "serverDown": "NIEDOSTĘPNY", - "scanType": "Typ", - "status": "Błąd Skanowania", - "elapsedTime": "Upłynięty Czas", - "selectiveScan": "Selektywne" - }, - "help": { - "title": "Skróty Klawiszowe Navidrome", - "hotkeys": { - "show_help": "Wyświetl Pomoc", - "toggle_menu": "Pokaż Pasek Boczny", - "toggle_play": "Odtwórz / Wstrzymaj", - "prev_song": "Poprzedni Utwór", - "next_song": "Następny Utwór", - "vol_up": "Głośniej", - "vol_down": "Ciszej", - "toggle_love": "Dodaj ten utwór do ulubionych", - "current_song": "Przejdź do Bieżącego Utworu" - } - }, - "nowPlaying": { - "title": "Teraz Odtwarzane", - "empty": "Nic nie jest odtwarzane", - "minutesAgo": "%{smart_count} minutę temu |||| %{smart_count} minut temu" - } } \ No newline at end of file diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json deleted file mode 100644 index 238919259..000000000 --- a/resources/i18n/pt-br.json +++ /dev/null @@ -1,781 +0,0 @@ -{ - "languageName": "Português (Brasil)", - "resources": { - "song": { - "name": "Música |||| Músicas", - "fields": { - "albumArtist": "Artista do Álbum", - "duration": "Duração", - "trackNumber": "#", - "playCount": "Execuções", - "title": "Título", - "artist": "Artista", - "album": "Álbum", - "path": "Arquivo", - "genre": "Gênero", - "compilation": "Coletânea", - "year": "Ano", - "size": "Tamanho", - "updatedAt": "Últ. Atualização", - "bitRate": "Bitrate", - "discSubtitle": "Sub-título do disco", - "starred": "Favorita", - "comment": "Comentário", - "rating": "Classificação", - "quality": "Qualidade", - "bpm": "BPM", - "playDate": "Últ. Execução", - "channels": "Canais", - "createdAt": "Adiconado em", - "grouping": "Agrupamento", - "mood": "Mood", - "participants": "Outros Participantes", - "tags": "Outras Tags", - "mappedTags": "Tags mapeadas", - "rawTags": "Tags originais", - "bitDepth": "Profundidade de bits", - "sampleRate": "Taxa de amostragem", - "missing": "Ausente", - "libraryName": "Biblioteca", - "composer": "Compositor", - "disc": "Disco %{discNumber}", - "albumGain": "Ganho do álbum", - "trackGain": "Ganho da faixa" - }, - "actions": { - "addToQueue": "Adicionar à fila", - "playNow": "Tocar agora", - "addToPlaylist": "Adicionar à playlist", - "shuffleAll": "Aleatório", - "download": "Baixar", - "playNext": "Toca a seguir", - "info": "Detalhes", - "showInPlaylist": "Ir para playlist", - "instantMix": "Mix Instantâneo" - } - }, - "album": { - "name": "Álbum |||| Álbuns", - "fields": { - "albumArtist": "Artista do Álbum", - "artist": "Artista", - "duration": "Duração", - "songCount": "Músicas", - "playCount": "Execuções", - "name": "Nome", - "genre": "Gênero", - "compilation": "Coletânea", - "year": "Ano", - "updatedAt": "Últ. Atualização", - "comment": "Comentário", - "rating": "Classificação", - "createdAt": "Adicionado em", - "size": "Tamanho", - "originalDate": "Original", - "releaseDate": "Data de Lançamento", - "releases": "Versão||||Versões", - "released": "Lançado", - "recordLabel": "Selo", - "catalogNum": "Nr. Catálogo", - "releaseType": "Tipo", - "grouping": "Agrupamento", - "media": "Mídia", - "mood": "Mood", - "date": "Data de Lançamento", - "missing": "Ausente", - "libraryName": "Biblioteca" - }, - "actions": { - "playAll": "Tocar", - "playNext": "Tocar em seguida", - "addToQueue": "Adicionar à fila", - "shuffle": "Aleatório", - "addToPlaylist": "Adicionar à playlist", - "download": "Baixar", - "info": "Detalhes", - "share": "Compartilhar", - "refresh": "Atualizar Metadados" - }, - "lists": { - "all": "Todos", - "random": "Aleatório", - "recentlyAdded": "Recém-adicionados", - "recentlyPlayed": "Recém-tocados", - "mostPlayed": "Mais tocados", - "starred": "Favoritos", - "topRated": "Melhor classificados" - } - }, - "artist": { - "name": "Artista |||| Artistas", - "fields": { - "name": "Nome", - "albumCount": "Total de Álbuns", - "songCount": "Total de Músicas", - "playCount": "Execuções", - "rating": "Classificação", - "genre": "Gênero", - "size": "Tamanho", - "role": "Role", - "missing": "Ausente" - }, - "roles": { - "albumartist": "Artista do Álbum |||| Artistas do Álbum", - "artist": "Artista |||| Artistas", - "composer": "Compositor |||| Compositores", - "conductor": "Maestro |||| Maestros", - "lyricist": "Letrista |||| Letristas", - "arranger": "Arranjador |||| Arranjadores", - "producer": "Produtor |||| Produtores", - "director": "Diretor |||| Diretores", - "engineer": "Engenheiro |||| Engenheiros", - "mixer": "Mixador |||| Mixadores", - "remixer": "Remixador |||| Remixadores", - "djmixer": "DJ Mixer |||| DJ Mixers", - "performer": "Músico |||| Músicos", - "maincredit": "Artista do Álbum ou Artista |||| Artistas do Álbum ou Artistas" - }, - "actions": { - "shuffle": "Aleatório", - "radio": "Rádio", - "topSongs": "Mais tocadas" - } - }, - "user": { - "name": "Usuário |||| Usuários", - "fields": { - "userName": "Usuário", - "isAdmin": "Admin?", - "lastLoginAt": "Últ. Login", - "updatedAt": "Últ. Atualização", - "name": "Nome", - "password": "Senha", - "createdAt": "Data de Criação", - "changePassword": "Trocar Senha?", - "currentPassword": "Senha Atual", - "newPassword": "Nova Senha", - "token": "Token", - "lastAccessAt": "Últ. Acesso", - "libraries": "Bibliotecas", - "scrobbleFilter": "Filtro de scrobble" - }, - "helperTexts": { - "name": "Alterações no seu nome só serão refletidas no próximo login", - "libraries": "Selecione bibliotecas específicas para este usuário, ou deixe vazio para usar bibliotecas padrão", - "scrobbleFilter": "Músicas que correspondem a estas regras de playlist inteligente não são enviadas ao Last.fm, ListenBrainz ou plugins de scrobble. Usa a mesma sintaxe JSON e o mesmo comportamento das playlists inteligentes. Exemplo: {\"all\":[{\"lt\":{\"rating\":4}}]}. Deixe vazio para enviar scrobbles de todas as músicas. As contagens de reprodução locais não são afetadas." - }, - "notifications": { - "created": "Novo usuário criado", - "updated": "Usuário atualizado com sucesso", - "deleted": "Usuário deletado com sucesso" - }, - "message": { - "listenBrainzToken": "Entre seu token do ListenBrainz", - "clickHereForToken": "Clique aqui para obter seu token", - "selectAllLibraries": "Selecionar todas as bibliotecas", - "adminAutoLibraries": "Usuários administradores têm acesso automático a todas as bibliotecas" - }, - "validation": { - "librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores", - "invalidScrobbleFilter": "Devem ser regras válidas de playlist inteligente. Limite, deslocamento e intervalo de atualização não são suportados." - } - }, - "player": { - "name": "Tocador |||| Tocadores", - "menuName": "Tocadores e chaves de API", - "fields": { - "name": "Nome", - "transcodingId": "Conversão", - "maxBitRate": "Bitrate máx", - "client": "Cliente", - "userName": "Usuário", - "lastSeen": "Últ. acesso", - "reportRealPath": "Use paths reais", - "scrobbleEnabled": "Enviar scrobbles para serviços externos", - "hasApiKey": "Chave de API" - }, - "actions": { - "generateApiKey": "Gerar chave de API", - "regenerateApiKey": "Gerar nova", - "revokeApiKey": "Revogar", - "copyApiKey": "Copiar" - }, - "message": { - "apiKeyActive": "Este tocador tem uma chave de API. Use-a no seu app como chave de API, ou como senha se o app não usar autenticação por token.", - "apiKeyNone": "Sem chave de API. Gere uma para conectar um app a este tocador.", - "apiKeyNoneOther": "Sem chave de API.", - "apiKeyPending": "Copie esta chave agora. Ela será salva quando você clicar em Salvar e não será exibida novamente.", - "apiKeyRevokePending": "A chave de API será removida quando você salvar.", - "deleteWithKeyTitle": "Excluir tocador", - "deleteWithKeyContent": "Este tocador tem uma chave de API. Os apps que a usam vão parar de funcionar." - }, - "notifications": { - "apiKeyCopied": "Chave de API copiada para o clipboard" - }, - "validation": { - "apiKeyFormat": "Formato de chave de API inválido" - } - }, - "transcoding": { - "name": "Conversão |||| Conversões", - "fields": { - "name": "Nome", - "targetFormat": "Formato", - "defaultBitRate": "Bitrate padrão", - "command": "Comando" - }, - "choices": { - "noDefaultBitRate": "Nenhum" - } - }, - "playlist": { - "name": "Playlist |||| Playlists", - "fields": { - "name": "Nome", - "duration": "Duração", - "ownerName": "Dono", - "public": "Pública", - "updatedAt": "Últ. Atualização", - "createdAt": "Data de Criação", - "songCount": "Músicas", - "comment": "Comentário", - "sync": "Auto-importar", - "path": "Importar de", - "starred": "Favorita" - }, - "actions": { - "selectPlaylist": "Selecione a playlist:", - "addNewPlaylist": "Criar \"%{name}\"", - "export": "Exportar", - "makePublic": "Pública", - "makePrivate": "Pessoal", - "saveQueue": "Salvar fila em nova Playlist", - "searchOrCreate": "Buscar playlists ou criar nova...", - "pressEnterToCreate": "Pressione Enter para criar nova playlist", - "removeFromSelection": "Remover da seleção" - }, - "message": { - "duplicate_song": "Adicionar músicas duplicadas", - "song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?", - "noPlaylistsFound": "Nenhuma playlist encontrada", - "noPlaylists": "Nenhuma playlist disponível" - } - }, - "radio": { - "name": "Rádio |||| Rádios", - "fields": { - "name": "Nome", - "streamUrl": "Endereço de stream", - "homePageUrl": "Home Page", - "updatedAt": "Últ. Atualização", - "createdAt": "Data de Criação" - }, - "actions": { - "playNow": "Tocar agora" - } - }, - "share": { - "name": "Compartilhamento |||| Compartilhamentos", - "fields": { - "username": "Compartilhado por", - "url": "Link", - "description": "Descrição", - "contents": "Conteúdo", - "expiresAt": "Dt. Expiração", - "lastVisitedAt": "Última visita", - "visitCount": "Visitas", - "format": "Formato", - "maxBitRate": "Bitrate máx", - "updatedAt": "Últ. Atualização", - "createdAt": "Data de Criação", - "downloadable": "Permitir Baixar?" - } - }, - "missing": { - "name": "Arquivo ausente |||| Arquivos ausentes", - "fields": { - "path": "Caminho", - "size": "Tamanho", - "updatedAt": "Desaparecido em", - "libraryName": "Biblioteca" - }, - "actions": { - "remove": "Remover", - "remove_all": "Remover todos" - }, - "notifications": { - "removed": "Arquivo(s) ausente(s) removido(s)" - }, - "empty": "Nenhum arquivo ausente" - }, - "library": { - "name": "Biblioteca |||| Bibliotecas", - "fields": { - "name": "Nome", - "path": "Caminho", - "remotePath": "Caminho Remoto", - "lastScanAt": "Último Scan", - "songCount": "Músicas", - "albumCount": "Álbuns", - "artistCount": "Artistas", - "totalSongs": "Músicas", - "totalAlbums": "Álbuns", - "totalArtists": "Artistas", - "totalFolders": "Pastas", - "totalFiles": "Arquivos", - "totalMissingFiles": "Arquivos Ausentes", - "totalSize": "Tamanho Total", - "totalDuration": "Duração", - "defaultNewUsers": "Padrão para Novos Usuários", - "createdAt": "Data de Criação", - "updatedAt": "Últ. Atualização", - "pidAlbum": "Agrupamento de álbuns", - "pidTrack": "Identificação das faixas" - }, - "sections": { - "basic": "Informações Básicas", - "statistics": "Estatísticas", - "pid": "IDs Persistentes" - }, - "pid": { - "global": "Usar configuração global (%{value})", - "folder": "Pasta (um álbum por pasta)", - "custom": "Personalizado", - "spec": "Especificação do PID", - "help": "Tags e atributos que identificam um item. Consulte a sintaxe na documentação:", - "docs": "IDs Persistentes" - }, - "actions": { - "scan": "Scanear Biblioteca", - "manageUsers": "Gerenciar Acesso do Usuário", - "viewDetails": "Ver Detalhes", - "quickScan": "Scan Rápido", - "fullScan": "Scan Completo" - }, - "notifications": { - "created": "Biblioteca criada com sucesso", - "updated": "Biblioteca atualizada com sucesso", - "deleted": "Biblioteca excluída com sucesso", - "scanStarted": "Scan da biblioteca iniciada", - "scanCompleted": "Scan da biblioteca concluída", - "quickScanStarted": "Scan rápido iniciado", - "fullScanStarted": "Scan completo iniciado", - "scanError": "Erro ao iniciar o scan. Verifique os logs" - }, - "validation": { - "nameRequired": "Nome da biblioteca é obrigatório", - "pathRequired": "Caminho da biblioteca é obrigatório", - "pathNotDirectory": "Caminho da biblioteca deve ser um diretório", - "pathNotFound": "Caminho da biblioteca não encontrado", - "pathNotAccessible": "Caminho da biblioteca não está acessível", - "pathInvalid": "Caminho da biblioteca inválido" - }, - "messages": { - "deleteConfirm": "Tem certeza que deseja excluir esta biblioteca? Isso removerá todos os dados associados.", - "scanInProgress": "Scan em progresso...", - "noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário", - "pidChangeTitle": "Alterar os IDs persistentes?", - "pidChangeConfirm": "Ao salvar, os álbuns desta biblioteca serão reagrupados e as faixas serão identificadas novamente. Um scan completo da biblioteca começará imediatamente. As marcações como favoritas, as classificações e as contagens de reprodução das faixas serão mantidas. Os favoritos e as classificações dos álbuns serão transferidos para os novos álbuns quando um álbum antigo corresponder a um novo." - } - }, - "plugin": { - "name": "Plugin |||| Plugins", - "fields": { - "id": "ID", - "name": "Nome", - "description": "Descrição", - "version": "Versão", - "author": "Autor", - "website": "Website", - "permissions": "Permissões", - "enabled": "Habilitado", - "status": "Status", - "path": "Caminho", - "lastError": "Erro", - "hasError": "Erro", - "updatedAt": "Atualizado", - "createdAt": "Instalado", - "configKey": "Chave", - "configValue": "Valor", - "allUsers": "Permitir todos os usuários", - "selectedUsers": "Usuários selecionados", - "allLibraries": "Permitir todas as bibliotecas", - "selectedLibraries": "Bibliotecas selecionadas", - "allowWriteAccess": "Permitir acesso de escrita" - }, - "sections": { - "status": "Status", - "info": "Informações do Plugin", - "configuration": "Configuração", - "manifest": "Manifesto", - "usersPermission": "Permissão de Usuários", - "libraryPermission": "Permissão de Bibliotecas" - }, - "status": { - "enabled": "Habilitado", - "disabled": "Desabilitado" - }, - "actions": { - "enable": "Habilitar", - "disable": "Desabilitar", - "disabledDueToError": "Corrija o erro antes de habilitar", - "disabledUsersRequired": "Selecione usuários antes de habilitar", - "disabledLibrariesRequired": "Selecione bibliotecas antes de habilitar", - "addConfig": "Adicionar configuração", - "rescan": "Rescanear" - }, - "notifications": { - "enabled": "Plugin habilitado", - "disabled": "Plugin desabilitado", - "updated": "Plugin atualizado", - "error": "Erro ao atualizar plugin" - }, - "validation": { - "invalidJson": "A configuração deve ser um JSON válido" - }, - "messages": { - "configHelp": "Configure o plugin usando pares chave-valor. Deixe vazio se o plugin não precisa de configuração.", - "clickPermissions": "Clique em uma permissão para ver detalhes", - "noConfig": "Nenhuma configuração definida", - "allUsersHelp": "Quando habilitado, o plugin terá acesso a todos os usuários, incluindo os criados no futuro.", - "noUsers": "Nenhum usuário selecionado", - "permissionReason": "Motivo", - "usersRequired": "Este plugin requer acesso a informações de usuário. Selecione quais usuários o plugin pode acessar, ou habilite 'Permitir todos os usuários'.", - "allLibrariesHelp": "Quando habilitado, o plugin terá acesso a todas as bibliotecas, incluindo as criadas no futuro.", - "noLibraries": "Nenhuma biblioteca selecionada", - "librariesRequired": "Este plugin requer acesso a informações de bibliotecas. Selecione quais bibliotecas o plugin pode acessar, ou habilite 'Permitir todas as bibliotecas'.", - "requiredHosts": "Hosts necessários", - "configValidationError": "Falha na validação da configuração:", - "schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido.", - "allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura.", - "idHelp": "O ID do plugin, derivado do nome do arquivo. Use-o ao referenciar este plugin em opções de configuração, como Agents." - }, - "placeholders": { - "configKey": "chave", - "configValue": "valor" - } - } - }, - "ra": { - "auth": { - "welcome1": "Obrigado por instalar Navidrome!", - "welcome2": "Para iniciar, crie um usuário admin", - "confirmPassword": "Confirme a senha", - "buttonCreateAdmin": "Criar Admin", - "auth_check_error": "Por favor, faça login para continuar", - "user_menu": "Perfil", - "username": "Usuário", - "password": "Senha", - "sign_in": "Entrar", - "sign_in_error": "Erro na autenticação, tente novamente.", - "logout": "Sair", - "insightsCollectionNote": "Navidrome coleta dados de uso anônimos para\najudar a melhorar o projeto. Clique [aqui] para\nsaber mais e para desativar se desejar" - }, - "validation": { - "invalidChars": "Somente use letras e numeros", - "passwordDoesNotMatch": "Senha não confere", - "required": "Obrigatório", - "minLength": "Deve ser ter no mínimo %{min} caracteres", - "maxLength": "Deve ter no máximo %{max} caracteres", - "minValue": "Deve ser %{min} ou maior", - "maxValue": "Deve ser %{max} ou menor", - "number": "Deve ser um número", - "email": "Deve ser um email válido", - "oneOf": "Deve ser uma das seguintes opções: %{options}", - "regex": "Deve ter o formato específico (regexp): %{pattern}", - "unique": "Deve ser único", - "url": "URL inválida" - }, - "action": { - "add_filter": "Adicionar Filtro", - "add": "Adicionar", - "back": "Voltar", - "bulk_actions": "1 item selecionado |||| %{smart_count} itens selecionados", - "cancel": "Cancelar", - "clear_input_value": "Limpar campo", - "clone": "Duplicar", - "confirm": "Confirmar", - "create": "Novo", - "delete": "Deletar", - "edit": "Editar", - "export": "Exportar", - "list": "Listar", - "refresh": "Atualizar", - "remove_filter": "Cancelar filtro", - "remove": "Remover", - "save": "Salvar", - "search": "Buscar", - "show": "Exibir", - "sort": "Ordenar", - "undo": "Desfazer", - "expand": "Expandir", - "close": "Fechar", - "open_menu": "Abrir menu", - "close_menu": "Fechar menu", - "unselect": "Deselecionar", - "skip": "Ignorar", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Compartilhar", - "download": "Baixar" - }, - "boolean": { - "true": "Sim", - "false": "Não" - }, - "page": { - "create": "Criar %{name}", - "dashboard": "Painel de Controle", - "edit": "%{name} #%{id}", - "error": "Um erro ocorreu", - "list": "Listar %{name}", - "loading": "Carregando", - "not_found": "Não encontrado", - "show": "%{name} #%{id}", - "empty": "Ainda não há nenhum registro em %{name}", - "invite": "Gostaria de criar um novo?" - }, - "input": { - "file": { - "upload_several": "Arraste alguns arquivos para fazer o upload, ou clique para selecioná-los.", - "upload_single": "Arraste o arquivo para fazer o upload, ou clique para selecioná-lo." - }, - "image": { - "upload_several": "Arraste algumas imagens para fazer o upload ou clique para selecioná-las", - "upload_single": "Arraste um arquivo para upload ou clique em selecionar arquivo." - }, - "references": { - "all_missing": "Não foi possível encontrar os dados das referencias.", - "many_missing": "Pelo menos uma das referências passadas não está mais disponível.", - "single_missing": "A referência passada aparenta não estar mais disponível." - }, - "password": { - "toggle_visible": "Esconder senha", - "toggle_hidden": "Mostrar senha" - } - }, - "message": { - "about": "Sobre", - "are_you_sure": "Tem certeza?", - "bulk_delete_content": "Você tem certeza que deseja excluir %{name}? |||| Você tem certeza que deseja excluir estes %{smart_count} itens?", - "bulk_delete_title": "Excluir %{name} |||| Excluir %{smart_count} %{name} itens", - "delete_content": "Você tem certeza que deseja excluir?", - "delete_title": "Excluir %{name} #%{id}", - "details": "Detalhes", - "error": "Um erro ocorreu e a sua requisição não pôde ser completada.", - "invalid_form": "Este formulário não está valido. Certifique-se de corrigir os erros", - "loading": "A página está carregando. Um momento, por favor", - "no": "Não", - "not_found": "Foi digitada uma URL inválida, ou o link pode estar quebrado.", - "yes": "Sim", - "unsaved_changes": "Algumas das suas mudanças não foram salvas, deseja realmente ignorá-las?" - }, - "navigation": { - "no_results": "Nenhum resultado encontrado", - "no_more_results": "A página numero %{page} está fora dos limites. Tente a página anterior.", - "page_out_of_boundaries": "Página %{page} fora do limite", - "page_out_from_end": "Não é possível ir após a última página", - "page_out_from_begin": "Não é possível ir antes da primeira página", - "page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}", - "page_rows_per_page": "Resultados por página:", - "next": "Próximo", - "prev": "Anterior", - "skip_nav": "Pular para o conteúdo" - }, - "notification": { - "updated": "Item atualizado com sucesso |||| %{smart_count} itens foram atualizados com sucesso", - "created": "Item criado com sucesso", - "deleted": "Item removido com sucesso! |||| %{smart_count} itens foram removidos com sucesso", - "bad_item": "Item incorreto", - "item_doesnt_exist": "Esse item não existe mais", - "http_error": "Erro na comunicação com servidor", - "data_provider_error": "Erro interno do servidor. Entre em contato", - "i18n_error": "Não foi possível carregar as traduções para o idioma especificado", - "canceled": "Ação cancelada", - "logged_out": "Sua sessão foi encerrada. Por favor, reconecte", - "new_version": "Nova versão disponível! Por favor recarregue esta janela." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Colunas visíveis", - "layout": "Layout", - "grid": "Grade", - "table": "Tabela" - } - }, - "message": { - "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", - "songsAddedToPlaylist": "Música adicionada à playlist |||| %{smart_count} músicas adicionadas à playlist", - "noPlaylistsAvailable": "Nenhuma playlist", - "delete_user_title": "Excluir usuário '%{name}'", - "delete_user_content": "Você tem certeza que deseja excluir o usuário e todos os seus dados (incluindo suas playlists e preferências)?", - "notifications_blocked": "Você bloqueou notificações para este site nas configurações do seu browser", - "notifications_not_available": "Este navegador não suporta notificações", - "lastfmLinkSuccess": "Sua conta no Last.fm foi conectada com sucesso", - "lastfmLinkFailure": "Sua conta no Last.fm não pode ser conectada", - "lastfmUnlinkSuccess": "Sua conta no Last.fm foi desconectada", - "lastfmUnlinkFailure": "Sua conta no Last.fm não pode ser desconectada", - "openIn": { - "lastfm": "Abrir em Last.fm", - "musicbrainz": "Abrir em MusicBrainz" - }, - "lastfmLink": "Leia mais", - "listenBrainzLinkSuccess": "Sua conta no ListenBrainz foi conectada com sucesso", - "listenBrainzLinkFailure": "Sua conta no ListenBrainz não pode ser conectada", - "listenBrainzUnlinkSuccess": "Sua conta no ListenBrainz foi desconectada", - "listenBrainzUnlinkFailure": "Sua conta no ListenBrainz não pode ser desconectada", - "downloadOriginalFormat": "Baixar no formato original", - "shareOriginalFormat": "Compartilhar no formato original", - "shareDialogTitle": "Compartilhar %{resource} '%{name}'", - "shareBatchDialogTitle": "Compartilhar 1 %{resource} |||| Compartilhar %{smart_count} %{resource}", - "shareSuccess": "Link copiado para o clipboard : %{url}", - "shareFailure": "Erro ao copiar o link %{url} para o clipboard", - "downloadDialogTitle": "Baixar %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Copie para o clipboard: Ctrl+C, Enter", - "remove_missing_title": "Remover arquivos ausentes", - "remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.", - "remove_all_missing_title": "Remover todos os arquivos ausentes", - "remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.", - "noSimilarSongsFound": "Nenhuma música semelhante encontrada", - "noTopSongsFound": "Nenhuma música mais tocada encontrada", - "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", - "metadataRefreshStarted": "Atualizando metadados em segundo plano", - "quickConnectApproved": "%{app} em %{device} está conectado agora", - "quickConnectInvalidCode": "Código inválido ou expirado", - "quickConnectError": "Não foi possível aprovar o código" - }, - "menu": { - "library": "Biblioteca", - "settings": "Configurações", - "version": "Versão", - "theme": "Tema", - "personal": { - "name": "Pessoal", - "options": { - "theme": "Tema", - "language": "Língua", - "defaultView": "Tela inicial", - "desktop_notifications": "Notificações", - "lastfmScrobbling": "Enviar scrobbles para Last.fm", - "listenBrainzScrobbling": "Enviar scrobbles para ListenBrainz", - "replaygain": "Modo ReplayGain", - "preAmp": "PreAmp ReplayGain (dB)", - "gain": { - "none": "Desligado", - "album": "Usar ganho do álbum", - "track": "Usar ganho do faixa" - }, - "lastfmNotConfigured": "A API-Key do Last.fm não está configurada" - } - }, - "albumList": "Álbuns", - "about": "Info", - "playlists": "Playlists", - "sharedPlaylists": "Compartilhadas", - "librarySelector": { - "allLibraries": "Todas as Bibliotecas (%{count})", - "multipleLibraries": "%{selected} de %{total} Bibliotecas", - "selectLibraries": "Selecionar Bibliotecas", - "none": "Nenhuma" - }, - "onlyFavourites": "Somente favoritas", - "quickConnect": { - "name": "Conexão Rápida", - "code": "Código", - "help": "Digite o código exibido por um aplicativo Jellyfin para conectá-lo à sua conta", - "confirm": "Conectar %{app} %{version} em %{device} à sua conta?", - "continue": "Continuar", - "approve": "Aprovar" - } - }, - "player": { - "playListsText": "Fila de Execução", - "openText": "Abrir", - "closeText": "Fechar", - "notContentText": "Nenhum música", - "clickToPlayText": "Clique para tocar", - "clickToPauseText": "Clique para pausar", - "nextTrackText": "Próxima faixa", - "previousTrackText": "Faixa anterior", - "reloadText": "Recarregar", - "volumeText": "Volume", - "toggleLyricText": "Letra", - "toggleMiniModeText": "Minimizar", - "destroyText": "Destruir", - "downloadText": "Baixar", - "removeAudioListsText": "Limpar fila de execução", - "clickToDeleteText": "Clique para remover %{name}", - "emptyLyricText": "Letra não disponível", - "playModeText": { - "order": "Em ordem", - "orderLoop": "Repetir tudo", - "singleLoop": "Repetir", - "shufflePlay": "Aleatório" - } - }, - "about": { - "links": { - "homepage": "Website", - "source": "Código fonte", - "featureRequests": "Solicitar funcionalidade", - "lastInsightsCollection": "Última coleta de dados", - "insights": { - "disabled": "Desligado", - "waiting": "Aguardando" - } - }, - "tabs": { - "about": "Sobre", - "config": "Configuração" - }, - "config": { - "configName": "Nome da Configuração", - "environmentVariable": "Variável de Ambiente", - "currentValue": "Valor Atual", - "configurationFile": "Arquivo de Configuração", - "exportToml": "Exportar Configuração (TOML)", - "exportSuccess": "Configuração exportada para o clipboard em formato TOML", - "exportFailed": "Falha ao copiar configuração", - "devFlagsHeader": "Flags de Desenvolvimento (sujeitas a mudança/remoção)", - "devFlagsComment": "Estas são configurações experimentais e podem ser removidas em versões futuras", - "downloadToml": "Baixar configuração (TOML)" - } - }, - "activity": { - "title": "Atividade", - "totalScanned": "Total de pastas scaneadas", - "quickScan": "Rápido", - "fullScan": "Completo", - "serverUptime": "Uptime do servidor", - "serverDown": "DESCONECTADO", - "scanType": "Último Scan", - "status": "Erro", - "elapsedTime": "Duração", - "selectiveScan": "Seletivo" - }, - "help": { - "title": "Teclas de atalho", - "hotkeys": { - "show_help": "Mostra esta janela", - "toggle_menu": "Mostra o menu lateral", - "toggle_play": "Tocar / pausar", - "prev_song": "Música anterior", - "next_song": "Próxima música", - "vol_up": "Aumenta volume", - "vol_down": "Diminui volume", - "toggle_love": "Marcar/desmarcar favorita", - "current_song": "Vai para música atual" - } - }, - "nowPlaying": { - "title": "Tocando agora", - "empty": "Nada tocando", - "minutesAgo": "%{smart_count} minuto atrás |||| %{smart_count} minutos atrás" - } -} \ No newline at end of file diff --git a/resources/i18n/pt.json b/resources/i18n/pt.json new file mode 100644 index 000000000..9767a62f8 --- /dev/null +++ b/resources/i18n/pt.json @@ -0,0 +1,456 @@ +{ + "languageName": "Português", + "resources": { + "song": { + "name": "Música |||| Músicas", + "fields": { + "albumArtist": "Artista", + "duration": "Duração", + "trackNumber": "#", + "playCount": "Execuções", + "title": "Título", + "artist": "Artista", + "album": "Álbum", + "path": "Arquivo", + "genre": "Gênero", + "compilation": "Coletânea", + "year": "Ano", + "size": "Tamanho", + "updatedAt": "Últ. Atualização", + "bitRate": "Bitrate", + "discSubtitle": "Sub-título do disco", + "starred": "Favorita", + "comment": "Comentário", + "rating": "Classificação", + "quality": "Qualidade", + "bpm": "BPM", + "playDate": "Últ. Execução", + "channels": "Canais", + "createdAt": "Adiconado em" + }, + "actions": { + "addToQueue": "Adicionar à fila", + "playNow": "Tocar agora", + "addToPlaylist": "Adicionar à playlist", + "shuffleAll": "Aleatório", + "download": "Baixar", + "playNext": "Toca a seguir", + "info": "Detalhes" + } + }, + "album": { + "name": "Álbum |||| Álbuns", + "fields": { + "albumArtist": "Artista", + "artist": "Artista", + "duration": "Duração", + "songCount": "Músicas", + "playCount": "Execuções", + "name": "Nome", + "genre": "Gênero", + "compilation": "Coletânea", + "year": "Ano", + "updatedAt": "Últ. Atualização", + "comment": "Comentário", + "rating": "Classificação", + "createdAt": "Adicionado em", + "size": "Tamanho" + }, + "actions": { + "playAll": "Tocar", + "playNext": "Tocar em seguida", + "addToQueue": "Adicionar à fila", + "shuffle": "Aleatório", + "addToPlaylist": "Adicionar à playlist", + "download": "Baixar", + "info": "Detalhes", + "share": "Compartilhar" + }, + "lists": { + "all": "Todos", + "random": "Aleatório", + "recentlyAdded": "Recém-adicionados", + "recentlyPlayed": "Recém-tocados", + "mostPlayed": "Mais tocados", + "starred": "Favoritos", + "topRated": "Melhor classificados" + } + }, + "artist": { + "name": "Artista |||| Artistas", + "fields": { + "name": "Nome", + "albumCount": "Total de Álbuns", + "songCount": "Total de Músicas", + "playCount": "Execuções", + "rating": "Classificação", + "genre": "Gênero", + "size": "Tamanho" + } + }, + "user": { + "name": "Usuário |||| Usuários", + "fields": { + "userName": "Usuário", + "isAdmin": "Admin?", + "lastLoginAt": "Últ. Login", + "updatedAt": "Últ. Atualização", + "name": "Nome", + "password": "Senha", + "createdAt": "Data de Criação", + "changePassword": "Trocar Senha?", + "currentPassword": "Senha Atual", + "newPassword": "Nova Senha", + "token": "Token" + }, + "helperTexts": { + "name": "Alterações no seu nome só serão refletidas no próximo login" + }, + "notifications": { + "created": "Novo usuário criado", + "updated": "Usuário atualizado com sucesso", + "deleted": "Usuário deletado com sucesso" + }, + "message": { + "listenBrainzToken": "Entre seu token do ListenBrainz", + "clickHereForToken": "Clique aqui para obter seu token" + } + }, + "player": { + "name": "Tocador |||| Tocadores", + "fields": { + "name": "Nome", + "transcodingId": "Conversão", + "maxBitRate": "Bitrate máx", + "client": "Cliente", + "userName": "Usuário", + "lastSeen": "Últ. acesso", + "reportRealPath": "Use paths reais", + "scrobbleEnabled": "Enviar scrobbles para serviços externos" + } + }, + "transcoding": { + "name": "Conversão |||| Conversões", + "fields": { + "name": "Nome", + "targetFormat": "Formato", + "defaultBitRate": "Bitrate padrão", + "command": "Comando" + } + }, + "playlist": { + "name": "Playlist |||| Playlists", + "fields": { + "name": "Nome", + "duration": "Duração", + "ownerName": "Dono", + "public": "Pública", + "updatedAt": "Últ. Atualização", + "createdAt": "Data de Criação", + "songCount": "Músicas", + "comment": "Comentário", + "sync": "Auto-importar", + "path": "Importar de" + }, + "actions": { + "selectPlaylist": "Selecione a playlist:", + "addNewPlaylist": "Criar \"%{name}\"", + "export": "Exportar", + "makePublic": "Pública", + "makePrivate": "Pessoal" + }, + "message": { + "duplicate_song": "Adicionar músicas duplicadas", + "song_exist": "Algumas destas músicas já existem na playlist. Você quer adicionar as duplicadas ou ignorá-las?" + } + }, + "radio": { + "name": "Rádio |||| Rádios", + "fields": { + "name": "Nome", + "streamUrl": "Endereço de stream", + "homePageUrl": "Home Page", + "updatedAt": "Últ. Atualização", + "createdAt": "Data de Criação" + }, + "actions": { + "playNow": "Tocar agora" + } + }, + "share": { + "name": "Compartilhamento |||| Compartilhamentos", + "fields": { + "username": "Compartilhado por", + "url": "Link", + "description": "Descrição", + "contents": "Conteúdo", + "expiresAt": "Dt. Expiração", + "lastVisitedAt": "Última visita", + "visitCount": "Visitas", + "format": "Formato", + "maxBitRate": "Bitrate máx", + "updatedAt": "Últ. Atualização", + "createdAt": "Data de Criação", + "downloadable": "Permitir Baixar?" + } + } + }, + "ra": { + "auth": { + "welcome1": "Obrigado por instalar Navidrome!", + "welcome2": "Para iniciar, crie um usuário admin", + "confirmPassword": "Confirme a senha", + "buttonCreateAdmin": "Criar Admin", + "auth_check_error": "Por favor, faça login para continuar", + "user_menu": "Perfil", + "username": "Usuário", + "password": "Senha", + "sign_in": "Entrar", + "sign_in_error": "Erro na autenticação, tente novamente.", + "logout": "Sair" + }, + "validation": { + "invalidChars": "Somente use letras e numeros", + "passwordDoesNotMatch": "Senha não confere", + "required": "Obrigatório", + "minLength": "Deve ser ter no mínimo %{min} caracteres", + "maxLength": "Deve ter no máximo %{max} caracteres", + "minValue": "Deve ser %{min} ou maior", + "maxValue": "Deve ser %{max} ou menor", + "number": "Deve ser um número", + "email": "Deve ser um email válido", + "oneOf": "Deve ser uma das seguintes opções: %{options}", + "regex": "Deve ter o formato específico (regexp): %{pattern}", + "unique": "Deve ser único", + "url": "URL inválida" + }, + "action": { + "add_filter": "Adicionar Filtro", + "add": "Adicionar", + "back": "Voltar", + "bulk_actions": "1 item selecionado |||| %{smart_count} itens selecionados", + "cancel": "Cancelar", + "clear_input_value": "Limpar campo", + "clone": "Duplicar", + "confirm": "Confirmar", + "create": "Novo", + "delete": "Deletar", + "edit": "Editar", + "export": "Exportar", + "list": "Listar", + "refresh": "Atualizar", + "remove_filter": "Cancelar filtro", + "remove": "Excluir", + "save": "Salvar", + "search": "Buscar", + "show": "Exibir", + "sort": "Ordenar", + "undo": "Desfazer", + "expand": "Expandir", + "close": "Fechar", + "open_menu": "Abrir menu", + "close_menu": "Fechar menu", + "unselect": "Deselecionar", + "skip": "Ignorar", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Compartilhar", + "download": "Baixar" + }, + "boolean": { + "true": "Sim", + "false": "Não" + }, + "page": { + "create": "Criar %{name}", + "dashboard": "Painel de Controle", + "edit": "%{name} #%{id}", + "error": "Um erro ocorreu", + "list": "Listar %{name}", + "loading": "Carregando", + "not_found": "Não encontrado", + "show": "%{name} #%{id}", + "empty": "Ainda não há nenhum registro em %{name}", + "invite": "Gostaria de criar um novo?" + }, + "input": { + "file": { + "upload_several": "Arraste alguns arquivos para fazer o upload, ou clique para selecioná-los.", + "upload_single": "Arraste o arquivo para fazer o upload, ou clique para selecioná-lo." + }, + "image": { + "upload_several": "Arraste algumas imagens para fazer o upload ou clique para selecioná-las", + "upload_single": "Arraste um arquivo para upload ou clique em selecionar arquivo." + }, + "references": { + "all_missing": "Não foi possível encontrar os dados das referencias.", + "many_missing": "Pelo menos uma das referências passadas não está mais disponível.", + "single_missing": "A referência passada aparenta não estar mais disponível." + }, + "password": { + "toggle_visible": "Esconder senha", + "toggle_hidden": "Mostrar senha" + } + }, + "message": { + "about": "Sobre", + "are_you_sure": "Tem certeza?", + "bulk_delete_content": "Você tem certeza que deseja excluir %{name}? |||| Você tem certeza que deseja excluir estes %{smart_count} itens?", + "bulk_delete_title": "Excluir %{name} |||| Excluir %{smart_count} %{name} itens", + "delete_content": "Você tem certeza que deseja excluir?", + "delete_title": "Excluir %{name} #%{id}", + "details": "Detalhes", + "error": "Um erro ocorreu e a sua requisição não pôde ser completada.", + "invalid_form": "Este formulário não está valido. Certifique-se de corrigir os erros", + "loading": "A página está carregando. Um momento, por favor", + "no": "Não", + "not_found": "Foi digitada uma URL inválida, ou o link pode estar quebrado.", + "yes": "Sim", + "unsaved_changes": "Algumas das suas mudanças não foram salvas, deseja realmente ignorá-las?" + }, + "navigation": { + "no_results": "Nenhum resultado encontrado", + "no_more_results": "A página numero %{page} está fora dos limites. Tente a página anterior.", + "page_out_of_boundaries": "Página %{page} fora do limite", + "page_out_from_end": "Não é possível ir após a última página", + "page_out_from_begin": "Não é possível ir antes da primeira página", + "page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}", + "page_rows_per_page": "Resultados por página:", + "next": "Próximo", + "prev": "Anterior", + "skip_nav": "Pular para o conteúdo" + }, + "notification": { + "updated": "Item atualizado com sucesso |||| %{smart_count} itens foram atualizados com sucesso", + "created": "Item criado com sucesso", + "deleted": "Item removido com sucesso! |||| %{smart_count} itens foram removidos com sucesso", + "bad_item": "Item incorreto", + "item_doesnt_exist": "Esse item não existe mais", + "http_error": "Erro na comunicação com servidor", + "data_provider_error": "Erro interno do servidor. Entre em contato", + "i18n_error": "Não foi possível carregar as traduções para o idioma especificado", + "canceled": "Ação cancelada", + "logged_out": "Sua sessão foi encerrada. Por favor, reconecte", + "new_version": "Nova versão disponível! Por favor recarregue esta janela." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Colunas visíveis", + "layout": "Layout", + "grid": "Grade", + "table": "Tabela" + } + }, + "message": { + "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", + "songsAddedToPlaylist": "Música adicionada à playlist |||| %{smart_count} músicas adicionadas à playlist", + "noPlaylistsAvailable": "Nenhuma playlist", + "delete_user_title": "Excluir usuário '%{name}'", + "delete_user_content": "Você tem certeza que deseja excluir o usuário e todos os seus dados (incluindo suas playlists e preferências)?", + "notifications_blocked": "Você bloqueou notificações para este site nas configurações do seu browser", + "notifications_not_available": "Este navegador não suporta notificações", + "lastfmLinkSuccess": "Sua conta no Last.fm foi conectada com sucesso", + "lastfmLinkFailure": "Sua conta no Last.fm não pode ser conectada", + "lastfmUnlinkSuccess": "Sua conta no Last.fm foi desconectada", + "lastfmUnlinkFailure": "Sua conta no Last.fm não pode ser desconectada", + "openIn": { + "lastfm": "Abrir em Last.fm", + "musicbrainz": "Abrir em MusicBrainz" + }, + "lastfmLink": "Leia mais", + "listenBrainzLinkSuccess": "Sua conta no ListenBrainz foi conectada com sucesso", + "listenBrainzLinkFailure": "Sua conta no ListenBrainz não pode ser conectada", + "listenBrainzUnlinkSuccess": "Sua conta no ListenBrainz foi desconectada", + "listenBrainzUnlinkFailure": "Sua conta no ListenBrainz não pode ser desconectada", + "downloadOriginalFormat": "Baixar no formato original", + "shareOriginalFormat": "Compartilhar no formato original", + "shareDialogTitle": "Compartilhar %{resource} '%{name}'", + "shareBatchDialogTitle": "Compartilhar 1 %{resource} |||| Compartilhar %{smart_count} %{resource}", + "shareSuccess": "Link copiado para o clipboard : %{url}", + "shareFailure": "Erro ao copiar o link %{url} para o clipboard", + "downloadDialogTitle": "Baixar %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Copie para o clipboard: Ctrl+C, Enter" + }, + "menu": { + "library": "Biblioteca", + "settings": "Configurações", + "version": "Versão", + "theme": "Tema", + "personal": { + "name": "Pessoal", + "options": { + "theme": "Tema", + "language": "Língua", + "defaultView": "Tela inicial", + "desktop_notifications": "Notificações", + "lastfmScrobbling": "Enviar scrobbles para Last.fm", + "listenBrainzScrobbling": "Enviar scrobbles para ListenBrainz", + "replaygain": "Modo ReplayGain", + "preAmp": "PreAmp ReplayGain (dB)", + "gain": { + "none": "Desligado", + "album": "Usar ganho do álbum", + "track": "Usar ganho do faixa" + } + } + }, + "albumList": "Álbuns", + "about": "Info", + "playlists": "Playlists", + "sharedPlaylists": "Compartilhadas" + }, + "player": { + "playListsText": "Fila de Execução", + "openText": "Abrir", + "closeText": "Fechar", + "notContentText": "Nenhum música", + "clickToPlayText": "Clique para tocar", + "clickToPauseText": "Clique para pausar", + "nextTrackText": "Próxima faixa", + "previousTrackText": "Faixa anterior", + "reloadText": "Recarregar", + "volumeText": "Volume", + "toggleLyricText": "Letra", + "toggleMiniModeText": "Minimizar", + "destroyText": "Destruir", + "downloadText": "Baixar", + "removeAudioListsText": "Limpar fila de execução", + "clickToDeleteText": "Clique para remover %{name}", + "emptyLyricText": "Letra não disponível", + "playModeText": { + "order": "Em ordem", + "orderLoop": "Repetir tudo", + "singleLoop": "Repetir", + "shufflePlay": "Aleatório" + } + }, + "about": { + "links": { + "homepage": "Website", + "source": "Código fonte", + "featureRequests": "Solicitar funcionalidade" + } + }, + "activity": { + "title": "Atividade", + "totalScanned": "Total de pastas analisadas", + "quickScan": "Scan rápido", + "fullScan": "Scan completo", + "serverUptime": "Uptime do servidor", + "serverDown": "DESCONECTADO" + }, + "help": { + "title": "Teclas de atalho", + "hotkeys": { + "show_help": "Mostra esta janela", + "toggle_menu": "Mostra o menu lateral", + "toggle_play": "Tocar / pausar", + "prev_song": "Música anterior", + "next_song": "Próxima música", + "vol_up": "Aumenta volume", + "vol_down": "Diminui volume", + "toggle_love": "Marcar/desmarcar favorita", + "current_song": "Vai para música atual" + } + } +} \ No newline at end of file diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json index 1a7adcc4a..3c387d5f8 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -1,717 +1,456 @@ { - "languageName": "Русский", - "resources": { - "song": { - "name": "Трек |||| Треки |||| Треков", - "fields": { - "albumArtist": "Исполнитель альбома", - "duration": "Длительность", - "trackNumber": "#", - "playCount": "Прослушивания", - "title": "Название трека", - "artist": "Артист", - "album": "Альбом", - "path": "Путь", - "genre": "Жанр", - "compilation": "Сборник", - "year": "Год", - "size": "Размер", - "updatedAt": "Обновлено", - "bitRate": "Битрейт", - "discSubtitle": "Название диска", - "starred": "Избранное", - "comment": "Комментарий", - "rating": "Рейтинг", - "quality": "Качество", - "bpm": "BPM", - "playDate": "Последнее воспроизведение", - "channels": "Каналы", - "createdAt": "Дата добавления", - "grouping": "Группирование", - "mood": "Настроение", - "participants": "Дополнительные участники", - "tags": "Дополнительные теги", - "mappedTags": "Сопоставленные теги", - "rawTags": "Исходные теги", - "bitDepth": "Битовая глубина (Bit)", - "sampleRate": "Частота дискретизации (Hz)", - "albumGain": "Усиление альбома", - "trackGain": "Усиление трека", - "missing": "Поле отсутствует", - "libraryName": "Библиотека", - "composer": "Композитор", - "disc": "Диск %{discNumber}" - }, - "actions": { - "addToQueue": "В очередь", - "playNow": "Играть", - "addToPlaylist": "Добавить в плейлист", - "shuffleAll": "Перемешать", - "download": "Скачать", - "playNext": "Следующий", - "info": "Информация", - "showInPlaylist": "Показать в плейлисте", - "instantMix": "Быстрый микс" - } + "languageName": "Pусский", + "resources": { + "song": { + "name": "Трек |||| Треки", + "fields": { + "albumArtist": "Исполнитель альбома", + "duration": "Длительность", + "trackNumber": "#", + "playCount": "Проигран", + "title": "Название", + "artist": "Исполнитель", + "album": "Альбом", + "path": "Путь", + "genre": "Жанр", + "compilation": "Сборник", + "year": "Год", + "size": "Размер", + "updatedAt": "Обновлен", + "bitRate": "Битрейт", + "discSubtitle": "Название диска", + "starred": "Избранные", + "comment": "Комментарий", + "rating": "Рейтинг", + "quality": "Качество", + "bpm": "BPM", + "playDate": "Последнее воспроизведение", + "channels": "Каналы", + "createdAt": "Дата добавления" + }, + "actions": { + "addToQueue": "В очередь", + "playNow": "Играть", + "addToPlaylist": "Добавить в плейлист", + "shuffleAll": "Перемешать", + "download": "Скачать", + "playNext": "Следующий", + "info": "Информация" + } + }, + "album": { + "name": "Альбом |||| Альбомы", + "fields": { + "albumArtist": "Исполнитель альбома", + "artist": "Исполнитель", + "duration": "Длительность", + "songCount": "Треков", + "playCount": "Проигран", + "name": "Название", + "genre": "Жанр", + "compilation": "Сборник", + "year": "Год", + "updatedAt": "Обновлен", + "comment": "Комментарий", + "rating": "Рейтинг", + "createdAt": "Дата добавления", + "size": "Размер" + }, + "actions": { + "playAll": "Играть", + "playNext": "Следующий", + "addToQueue": "В очередь", + "shuffle": "Перемешать", + "addToPlaylist": "Добавить в плейлист", + "download": "Скачать", + "info": "Информация", + "share": "Поделиться" + }, + "lists": { + "all": "Все", + "random": "Случайные", + "recentlyAdded": "Свежие", + "recentlyPlayed": "Проигранные", + "mostPlayed": "Популярные", + "starred": "Избранные", + "topRated": "Лучшие" + } + }, + "artist": { + "name": "Исполнитель |||| Исполнители", + "fields": { + "name": "Название", + "albumCount": "Количество альбомов", + "songCount": "Количество треков", + "playCount": "Проигран", + "rating": "Рейтинг", + "genre": "Жанр", + "size": "Размер" + } + }, + "user": { + "name": "Пользователь |||| Пользователи", + "fields": { + "userName": "Логин", + "isAdmin": "Администратор", + "lastLoginAt": "Последний вход", + "updatedAt": "Обновлено", + "name": "Имя", + "password": "Пароль", + "createdAt": "Создан", + "changePassword": "Сменить пароль?", + "currentPassword": "Текущий пароль", + "newPassword": "Новый пароль", + "token": "Токен" + }, + "helperTexts": { + "name": "Изменение вступит в силу после следующего входа в систему" + }, + "notifications": { + "created": "Пользователь создан", + "updated": "Пользователь обновлен", + "deleted": "Пользователь удален" + }, + "message": { + "listenBrainzToken": "Введите свой токен пользователя ListenBrainz.", + "clickHereForToken": "Нажмите здесь, чтобы получить токен" + } + }, + "player": { + "name": "Плеер |||| Плееры", + "fields": { + "name": "Имя", + "transcodingId": "Транскодирование", + "maxBitRate": "Макс. Битрейт", + "client": "Клиент", + "userName": "Пользователь", + "lastSeen": "Был на сайте", + "reportRealPath": "Показать реальный путь", + "scrobbleEnabled": "Отправлять скробблы во внешние службы" + } + }, + "transcoding": { + "name": "Транскодирование |||| Транскодирование", + "fields": { + "name": "Название", + "targetFormat": "Целевой формат", + "defaultBitRate": "Битрейт по умолчанию", + "command": "Команда" + } + }, + "playlist": { + "name": "Плейлистов |||| Плейлисты", + "fields": { + "name": "Название", + "duration": "Длительность", + "ownerName": "Владелец", + "public": "Публичный", + "updatedAt": "Обновлен", + "createdAt": "Создан", + "songCount": "Треков", + "comment": "Комментарий", + "sync": "Автоимпорт", + "path": "Импортировать из" + }, + "actions": { + "selectPlaylist": "Выберите плейлист:", + "addNewPlaylist": "Создать \"%{name}\"", + "export": "Экспорт", + "makePublic": "Опубликовать", + "makePrivate": "Сделать личным" + }, + "message": { + "duplicate_song": "Повторяющиеся треки", + "song_exist": "Некоторые треки уже есть в плейлисте. Вы хотите добавить их или пропустить?" + } + }, + "radio": { + "name": "Радио |||| Радио", + "fields": { + "name": "Имя", + "streamUrl": "Ссылка на поток", + "homePageUrl": "Домашняя страница", + "updatedAt": "Обновлено", + "createdAt": "Создано" + }, + "actions": { + "playNow": "Играть сейчас" + } + }, + "share": { + "name": "Общий доступ |||| Общий доступ", + "fields": { + "username": "Поделился", + "url": "Ссылка", + "description": "Описание", + "contents": "Содержание", + "expiresAt": "Ссылка истекает", + "lastVisitedAt": "Последнее посещение", + "visitCount": "Посещения", + "format": "Формат", + "maxBitRate": "Макс. Битрейт", + "updatedAt": "Обновлено в", + "createdAt": "Создано", + "downloadable": "Разрешить загрузку?" + } + } }, - "album": { - "name": "Альбом |||| Альбомы |||| Альбомов", - "fields": { - "albumArtist": "Исполнитель альбома", - "artist": "Артист", - "duration": "Длительность", - "songCount": "Трек |||| Треки |||| Треков", - "playCount": "Прослушивания", - "name": "Название альбома", - "genre": "Жанр", - "compilation": "Сборник", - "year": "Год", - "updatedAt": "Обновлено", - "comment": "Комментарий", - "rating": "Рейтинг", - "createdAt": "Дата добавления", - "size": "Размер", - "originalDate": "Оригинал", - "releaseDate": "Релиз", - "releases": "Релиз |||| Релиза |||| Релизов", - "released": "Релиз", - "recordLabel": "Лейбл", - "catalogNum": "Номер каталога", - "releaseType": "Тип", - "grouping": "Группирование", - "media": "Медиа", - "mood": "Настроение", - "date": "Дата записи", - "missing": "Поле отсутствует", - "libraryName": "Библиотека" - }, - "actions": { - "playAll": "Играть", - "playNext": "Следующий", - "addToQueue": "В очередь", - "shuffle": "Перемешать", - "addToPlaylist": "Добавить в плейлист", - "download": "Скачать", - "info": "Информация", - "share": "Поделиться" - }, - "lists": { - "all": "Все", - "random": "Случайные", - "recentlyAdded": "Новые", - "recentlyPlayed": "Проигранные", - "mostPlayed": "Популярные", - "starred": "Избранное", - "topRated": "Лучшие" - } - }, - "artist": { - "name": "Артист |||| Артисты |||| Артистов", - "fields": { - "name": "Название исполнителя", - "albumCount": "Количество альбомов", - "songCount": "Количество треков", - "playCount": "Прослушивания", - "rating": "Рейтинг", - "genre": "Жанр", - "size": "Размер", - "role": "Роль", - "missing": "Поле отсутствует" - }, - "roles": { - "albumartist": "Исполнитель альбома |||| Исполнители альбома |||| Исполнителей альбома", - "artist": "Артист |||| Артисты |||| Артистов", - "composer": "Композитор |||| Композиторы |||| Композиторов", - "conductor": "Дирижёр |||| Дирижёры |||| Дирижёров", - "lyricist": "Автор текста |||| Авторы текста |||| Авторов текста", - "arranger": "Аранжировщик |||| Аранжировщики |||| Аранжировщиков", - "producer": "Продюсер |||| Продюсеры |||| Продюсеров", - "director": "Режиссёр |||| Режиссёры |||| Режиссёров", - "engineer": "Инженер |||| Инженеры |||| Инженеров", - "mixer": "Звукоинженер |||| Звукоинженеры |||| Звукоинженеров", - "remixer": "Ремиксер |||| Ремиксеры |||| Ремиксеров", - "djmixer": "DJ-миксер |||| DJ-миксеры |||| DJ-миксеров", - "performer": "Исполнитель |||| Исполнители |||| Исполнителей", - "maincredit": "Исполнитель альбома или артист |||| Исполнители альбома или артисты |||| Исполнителей альбома или артистов" - }, - "actions": { - "shuffle": "Перемешать", - "radio": "Радио", - "topSongs": "Топовые треки" - } - }, - "user": { - "name": "Пользователь |||| Пользователи |||| Пользователей", - "fields": { - "userName": "Имя пользователя", - "isAdmin": "Администратор", - "lastLoginAt": "Последний вход", - "updatedAt": "Обновлено", - "name": "Имя", - "password": "Пароль", - "createdAt": "Аккаунт создан", - "changePassword": "Сменить пароль?", - "currentPassword": "Текущий пароль", - "newPassword": "Новый пароль", - "token": "Токен", - "lastAccessAt": "Последний доступ", - "libraries": "Библиотеки" - }, - "helperTexts": { - "name": "Изменение вступит в силу после следующего входа в систему", - "libraries": "Выберите конкретные библиотеки для этого пользователя или оставьте поле пустым, чтобы использовать библиотеки по умолчанию" - }, - "notifications": { - "created": "Пользователь создан", - "updated": "Пользователь обновлен", - "deleted": "Пользователь удален" - }, - "message": { - "listenBrainzToken": "Введите свой токен пользователя ListenBrainz.", - "clickHereForToken": "Нажмите здесь, чтобы получить токен", - "selectAllLibraries": "Выбрать все библиотеки", - "adminAutoLibraries": "Пользователи-администраторы автоматически получают доступ ко всем библиотекам" - }, - "validation": { - "librariesRequired": "Для пользователей, не являющихся администраторами, должна быть выбрана хотя бы одна библиотека" - } - }, - "player": { - "name": "Плеер |||| Плееры |||| Плееров", - "fields": { - "name": "Название", - "transcodingId": "Транскодирование", - "maxBitRate": "Макс. битрейт", - "client": "Клиент", - "userName": "Пользователь", - "lastSeen": "Был на сайте", - "reportRealPath": "Показать реальный путь", - "scrobbleEnabled": "Отправлять скробблы во внешние службы" - } - }, - "transcoding": { - "name": "Транскодирование |||| Транскодирование |||| Транскодирований", - "fields": { - "name": "Название", - "targetFormat": "Целевой формат", - "defaultBitRate": "Битрейт по умолчанию", - "command": "Команда" - } - }, - "playlist": { - "name": "Плейлист |||| Плейлисты |||| Плейлистов", - "fields": { - "name": "Название", - "duration": "Длительность", - "ownerName": "Владелец", - "public": "Публичный", - "updatedAt": "Обновлено", - "createdAt": "Создан", - "songCount": "Трек |||| Трека |||| Треков", - "comment": "Комментарий", - "sync": "Автоимпорт", - "path": "Импортировать из" - }, - "actions": { - "selectPlaylist": "Выберите плейлист:", - "addNewPlaylist": "Создать \"%{name}\"", - "export": "Экспорт", - "makePublic": "Опубликовать", - "makePrivate": "Сделать личным", - "saveQueue": "Сохранить очередь в плейлист", - "searchOrCreate": "Поиск плейлистов или введите текст для создания новых...", - "pressEnterToCreate": "Нажмите Enter, чтобы создать новый плейлист", - "removeFromSelection": "Удалить из списка выделенных" - }, - "message": { - "duplicate_song": "Повторяющиеся треки", - "song_exist": "Некоторые треки уже есть в плейлисте. Вы хотите добавить их или пропустить?", - "noPlaylistsFound": "Плейлисты не найдены", - "noPlaylists": "Нет доступных плейлистов" - } - }, - "radio": { - "name": "Радио |||| Радио |||| Радио", - "fields": { - "name": "Название", - "streamUrl": "Ссылка на поток", - "homePageUrl": "Домашняя страница", - "updatedAt": "Обновлено", - "createdAt": "Создано" - }, - "actions": { - "playNow": "Играть сейчас" - } - }, - "share": { - "name": "Общий доступ |||| Общий доступ |||| Общий доступ", - "fields": { - "username": "Поделился", - "url": "Ссылка", - "description": "Описание", - "contents": "Содержание", - "expiresAt": "Ссылка истекает", - "lastVisitedAt": "Последнее посещение", - "visitCount": "Количество посещений", - "format": "Формат", - "maxBitRate": "Макс. битрейт", - "updatedAt": "Обновлено", - "createdAt": "Создано", - "downloadable": "Разрешить загрузку?" - } - }, - "missing": { - "name": "Отсутствующий файл |||| Отсутствующие файлы |||| Отсутствующих файлов", - "fields": { - "path": "Путь", - "size": "Размер", - "updatedAt": "Исчез", - "libraryName": "Библиотека" - }, - "actions": { - "remove": "Удалить", - "remove_all": "Убрать все" - }, - "notifications": { - "removed": "Отсутствующие файлы удалены" - }, - "empty": "Нет отсутствующих файлов" - }, - "library": { - "name": "Библиотека |||| Библиотеки |||| Библиотек", - "fields": { - "name": "Название", - "path": "Путь", - "remotePath": "Удаленный путь", - "lastScanAt": "Последнее сканирование", - "songCount": "Треки", - "albumCount": "Альбомы", - "artistCount": "Артисты", - "totalSongs": "Треки", - "totalAlbums": "Альбомы", - "totalArtists": "Артисты", - "totalFolders": "Папки", - "totalFiles": "Файлы", - "totalMissingFiles": "Отсутствующие файлы", - "totalSize": "Общий размер", - "totalDuration": "Длительность", - "defaultNewUsers": "По умолчанию для новых пользователей", - "createdAt": "Создано", - "updatedAt": "Обновлено" - }, - "sections": { - "basic": "Основная информация", - "statistics": "Статистика" - }, - "actions": { - "scan": "Сканировать библиотеку", - "manageUsers": "Управление доступом пользователей", - "viewDetails": "Просмотреть подробности", - "quickScan": "Быстрое сканирование", - "fullScan": "Полное сканирование" - }, - "notifications": { - "created": "Библиотека успешно создана", - "updated": "Библиотека успешно обновлена", - "deleted": "Библиотека успешно удалена", - "scanStarted": "Сканирование библиотеки начато", - "scanCompleted": "Сканирование библиотеки закончено", - "quickScanStarted": "Быстрое сканирование началось", - "fullScanStarted": "Началось полное сканирование", - "scanError": "Ошибка при запуске сканирования. Проверьте логи" - }, - "validation": { - "nameRequired": "Название библиотеки обязательно", - "pathRequired": "Путь к библиотеке обязателен", - "pathNotDirectory": "Путь к библиотеке должен быть директорией", - "pathNotFound": "Путь к библиотеке не найден", - "pathNotAccessible": "Путь к библиотеке недоступен", - "pathInvalid": "Неверный путь к библиотеке" - }, - "messages": { - "deleteConfirm": "Вы уверены, что хотите удалить эту библиотеку? Это приведет к удалению всех связанных с ней данных и доступа пользователей.", - "scanInProgress": "Сканирование продолжается...", - "noLibrariesAssigned": "Нет библиотек, назначенных этому пользователю" - } - }, - "plugin": { - "name": "Плагин |||| Плагины |||| Плагинов", - "fields": { - "id": "ID", - "name": "Название", - "description": "Описание", - "version": "Версия", - "author": "Автор", - "website": "Веб-сайт", - "permissions": "Разрешения", - "enabled": "Включено", - "status": "Статус", - "path": "Путь", - "lastError": "Ошибка", - "hasError": "Ошибка", - "updatedAt": "Обновлено", - "createdAt": "Дата установки", - "configKey": "Ключ", - "configValue": "Значение", - "allUsers": "Разрешить всем пользователям", - "selectedUsers": "Выбранные пользователи", - "allLibraries": "Разрешить доступ ко всем библиотекам", - "selectedLibraries": "Выбранные библиотеки", - "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": { - "auth": { - "welcome1": "Спасибо за установку Navidrome!", - "welcome2": "Для начала создайте аккаунт администратора", - "confirmPassword": "Подтвердите пароль", - "buttonCreateAdmin": "Создать аккаунт администратора", - "auth_check_error": "Пожалуйста, авторизуйтесь для продолжения работы", - "user_menu": "Профиль", - "username": "Имя пользователя", - "password": "Пароль", - "sign_in": "Войти", - "sign_in_error": "Ошибка аутентификации, попробуйте снова", - "logout": "Выйти", - "insightsCollectionNote": "Navidrome анонимно собирает данные об использовании, \nчтобы сделать проект лучше. \nУзнать больше и отключить сбор данных можно [здесь]" - }, - "validation": { - "invalidChars": "Пожалуйста, используйте только буквы и цифры", - "passwordDoesNotMatch": "Пароли не совпадают", - "required": "Обязательно для заполнения", - "minLength": "Минимальное количество символов: %{min}", - "maxLength": "Максимальное количество символов: %{max}", - "minValue": "Минимальное значение: %{min}", - "maxValue": "Максимальное значение: %{max}", - "number": "Должно быть числом", - "email": "Некорректный Email", - "oneOf": "Должно быть одним из: %{options}", - "regex": "Должно соответствовать формату: %{pattern}", - "unique": "Должно быть уникальным", - "url": "Должен быть действительный URL" - }, - "action": { - "add_filter": "Фильтр", - "add": "Добавить", - "back": "Назад", - "bulk_actions": "1 выбран |||| %{smart_count} выбраны |||| %{smart_count} выбрано", - "cancel": "Отмена", - "clear_input_value": "Очистить", - "clone": "Дублировать", - "confirm": "Подтвердить", - "create": "Создать", - "delete": "Удалить", - "edit": "Редактировать", - "export": "Экспорт", - "list": "Список", - "refresh": "Обновить", - "remove_filter": "Убрать этот фильтр", - "remove": "Удалить", - "save": "Сохранить", - "search": "Поиск", - "show": "Просмотр", - "sort": "Сортировать", - "undo": "Отменить", - "expand": "Развернуть", - "close": "Закрыть", - "open_menu": "Открыть меню", - "close_menu": "Закрыть меню", - "unselect": "Снять выделение", - "skip": "Пропустить", - "bulk_actions_mobile": "1 |||| %{smart_count} |||| %{smart_count}", - "share": "Поделиться", - "download": "Скачать" - }, - "boolean": { - "true": "Да", - "false": "Нет" - }, - "page": { - "create": "Создать %{name}", - "dashboard": "Главная", - "edit": "%{name} #%{id}", - "error": "Что-то пошло не так", - "list": "%{name}", - "loading": "Загрузка...", - "not_found": "Не найдено", - "show": "%{name} #%{id}", - "empty": "Нет %{name}.", - "invite": "Хотите создать?" - }, - "input": { - "file": { - "upload_several": "Перетащите файлы для загрузки или щёлкните для выбора.", - "upload_single": "Перетащите файл для загрузки или щёлкните для выбора." - }, - "image": { - "upload_several": "Перетащите изображения для загрузки или щёлкните для выбора.", - "upload_single": "Перетащите изображение для загрузки или щёлкните для выбора." - }, - "references": { - "all_missing": "Связанных данных не найдено.", - "many_missing": "Некоторые из связанных данных недоступны", - "single_missing": "Связанный объект недоступен" - }, - "password": { - "toggle_visible": "Скрыть пароль", - "toggle_hidden": "Показать пароль" - } + "ra": { + "auth": { + "welcome1": "Спасибо за установку Navidrome!", + "welcome2": "Для начала создайте Администратора", + "confirmPassword": "Подтвердить Пароль", + "buttonCreateAdmin": "Создать Администратора", + "auth_check_error": "Пожалуйста, авторизуйтесь для продолжения работы", + "user_menu": "Профиль", + "username": "Имя пользователя", + "password": "Пароль", + "sign_in": "Войти", + "sign_in_error": "Ошибка аутентификации, попробуйте снова", + "logout": "Выйти" + }, + "validation": { + "invalidChars": "Пожалуйста, используйте только буквы и цифры", + "passwordDoesNotMatch": "Пароли не совпадают", + "required": "Обязательно для заполнения", + "minLength": "Минимальное кол-во символов %{min}", + "maxLength": "Максимальное кол-во символов %{max}", + "minValue": "Минимальное значение %{min}", + "maxValue": "Значение может быть %{max} или меньше", + "number": "Должно быть цифрой", + "email": "Некорректный email", + "oneOf": "Должно быть одним из: %{options}", + "regex": "Должно быть в формате (regexp): %{pattern}", + "unique": "Должно быть уникальным", + "url": "Должен быть действительным URL адрес" + }, + "action": { + "add_filter": "Фильтр", + "add": "Добавить", + "back": "Назад", + "bulk_actions": "1 выбран |||| %{smart_count} выбрано |||| %{smart_count} выбрано", + "cancel": "Отмена", + "clear_input_value": "Очистить", + "clone": "Дублировать", + "confirm": "Подтвердить", + "create": "Создать", + "delete": "Удалить", + "edit": "Редактировать", + "export": "Экспорт", + "list": "Список", + "refresh": "Обновить", + "remove_filter": "Убрать фильтр", + "remove": "Удалить", + "save": "Сохранить", + "search": "Поиск", + "show": "Просмотр", + "sort": "Сортировать", + "undo": "Отменить", + "expand": "Раскрыть", + "close": "Закрыть", + "open_menu": "Открыть меню", + "close_menu": "Закрыть меню", + "unselect": "Отменить выделение", + "skip": "Пропустить", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Поделиться", + "download": "Скачать" + }, + "boolean": { + "true": "Да", + "false": "Нет" + }, + "page": { + "create": "Создать %{name}", + "dashboard": "Главная", + "edit": "%{name} #%{id}", + "error": "Что-то пошло не так", + "list": "%{name}", + "loading": "Загрузка", + "not_found": "Не найдено", + "show": "%{name} #%{id}", + "empty": "Нет %{name}.", + "invite": "Хотите создать?" + }, + "input": { + "file": { + "upload_several": "Перетащите файлы сюда или нажмите для выбора.", + "upload_single": "Перетащите файл сюда или нажмите для выбора." + }, + "image": { + "upload_several": "Перетащите изображения сюда или нажмите для выбора.", + "upload_single": "Перетащите изображение сюда или нажмите для выбора." + }, + "references": { + "all_missing": "Связанных данных не найдено", + "many_missing": "Некоторые из связанных данных не доступны", + "single_missing": "Связанный объект не доступен" + }, + "password": { + "toggle_visible": "Скрыть пароль", + "toggle_hidden": "Показать пароль" + } + }, + "message": { + "about": "Справка", + "are_you_sure": "Вы уверены?", + "bulk_delete_content": "Вы уверены, что хотите удалить %{name}? |||| Вы уверены, что хотите удалить объекты, кол-вом %{smart_count} ? |||| Вы уверены, что хотите удалить объекты, кол-вом %{smart_count} ?", + "bulk_delete_title": "Удалить %{name} |||| Удалить %{smart_count} %{name} |||| Удалить %{smart_count} %{name}", + "delete_content": "Вы уверены что хотите удалить этот объект", + "delete_title": "Удалить %{name} #%{id}", + "details": "Описание", + "error": "При выполнении запроса возникла ошибка, и он не может быть завершен", + "invalid_form": "Форма заполнена неверно, проверьте, пожалуйста, ошибки", + "loading": "Идет загрузка, пожалуйста, подождите...", + "no": "Нет", + "not_found": "Ошибка URL или вы следуете по неверной ссылке", + "yes": "Да", + "unsaved_changes": "Некоторые из ваших изменений не сохранены. Продолжить без сохранения?" + }, + "navigation": { + "no_results": "Результатов не найдено", + "no_more_results": "Страница %{page} выходит за пределы нумерации, попробуйте предыдущую", + "page_out_of_boundaries": "Страница %{page} вне границ", + "page_out_from_end": "Невозможно переместиться дальше последней страницы", + "page_out_from_begin": "Номер страницы не может быть меньше 1", + "page_range_info": "%{offsetBegin}-%{offsetEnd} из %{total}", + "page_rows_per_page": "Строк на странице:", + "next": "Следующая", + "prev": "Предыдущая", + "skip_nav": "Перейти к содержанию" + }, + "notification": { + "updated": "Элемент обновлен |||| %{smart_count} обновлено |||| %{smart_count} обновлено", + "created": "Элемент создан", + "deleted": "Элемент удален |||| %{smart_count} удалено |||| %{smart_count} удалено", + "bad_item": "Элемент неправильный", + "item_doesnt_exist": "Элемент не существует", + "http_error": "Ошибка сервера", + "data_provider_error": "Ошибка dataProvider, проверьте консоль", + "i18n_error": "Не удалось загрузить перевод для указанного языка", + "canceled": "Операция отменена", + "logged_out": "Ваша сессия завершена, попробуйте переподключиться/войти снова", + "new_version": "Доступна новая версия! Пожалуйста, обновите это окно" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Отображение столбцов", + "layout": "Макет", + "grid": "Сетка", + "table": "Таблица" + } }, "message": { - "about": "О программе", - "are_you_sure": "Вы уверены?", - "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": "Загрузка, пожалуйста, подождите...", - "no": "Нет", - "not_found": "Страница не найдена. Возможно, вы ввели неправильный URL.", - "yes": "Да", - "unsaved_changes": "Есть несохраненные изменения. Продолжить без сохранения?" - }, - "navigation": { - "no_results": "Результатов не найдено", - "no_more_results": "Страница %{page} выходит за пределы, попробуйте предыдущую", - "page_out_of_boundaries": "Страница %{page} выходит за пределы", - "page_out_from_end": "Невозможно переместиться дальше последней страницы", - "page_out_from_begin": "Номер страницы не может быть меньше 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} из %{total}", - "page_rows_per_page": "Строк на странице:", - "next": "Вперед", - "prev": "Назад", - "skip_nav": "Перейти к основному контенту" - }, - "notification": { - "updated": "Элемент обновлен |||| %{smart_count} элемента обновлены |||| %{smart_count} элементов обновлено", - "created": "Элемент создан", - "deleted": "Элемент удален |||| %{smart_count} элемента удалены |||| %{smart_count} элементов удалено", - "bad_item": "Некорректный элемент", - "item_doesnt_exist": "Элемент не существует", - "http_error": "Ошибка сервера", - "data_provider_error": "Ошибка поставщика данных, проверьте консоль", - "i18n_error": "Не удалось загрузить перевод", - "canceled": "Операция отменена", - "logged_out": "Сессия завершена, пожалуйста, войдите снова", - "new_version": "Доступна новая версия! Пожалуйста, обновите страницу." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Отображение столбцов", - "layout": "Макет", - "grid": "Сетка", - "table": "Таблица" - } - }, - "message": { - "note": "ПРИМЕЧАНИЕ", - "transcodingDisabled": "Изменение настроек транскодирования через веб-интерфейс отключено по соображениям безопасности. Если вы хотите изменить или добавить опции транскодирования, перезапустите сервер с опцией конфигурации %{config}.", - "transcodingEnabled": "Navidrome работает с настройками %{config}, позволяющими запускать команды транскодирования через веб-интерфейс. В целях безопасности мы рекомендуем отключить эту возможность.", - "songsAddedToPlaylist": "Добавлен 1 трек |||| Добавлены %{smart_count} трека |||| Добавлено %{smart_count} треков", - "noPlaylistsAvailable": "Недоступно", - "delete_user_title": "Удалить пользователя '%{name}'", - "delete_user_content": "Вы уверены, что хотите удалить пользователя и все его данные (включая плейлисты и настройки)?", - "notifications_blocked": "Вы заблокировали уведомления для этой страницы в настройках вашего браузера", - "notifications_not_available": "Ваш браузер не поддерживает всплывающие уведомления", - "lastfmLinkSuccess": "Соединение с Last.fm установлено, скробблинг включен", - "lastfmLinkFailure": "Не удалось подключиться к Last.fm", - "lastfmUnlinkSuccess": "Соединение с Last.fm разорвано, скробблинг отключен", - "lastfmUnlinkFailure": "Не удалось разорвать соединение с Last.fm", - "openIn": { - "lastfm": "Показать на Last.fm", - "musicbrainz": "Показать на MusicBrainz" - }, - "lastfmLink": "Подробнее...", - "listenBrainzLinkSuccess": "ListenBrainz скробблинг успешно подключен для пользователя: %{user}", - "listenBrainzLinkFailure": "Не удалось подключить ListenBrainz:", - "listenBrainzUnlinkSuccess": "ListenBrainz скробблинг отключен", - "listenBrainzUnlinkFailure": "Не удалось отключить ListenBrainz", - "downloadOriginalFormat": "Скачать в оригинальном формате", - "shareOriginalFormat": "Поделиться в оригинальном формате", - "shareDialogTitle": "Поделиться %{resource} '%{name}'", - "shareBatchDialogTitle": "Поделиться 1 %{resource} |||| Поделиться %{smart_count} %{resource} |||| Поделиться %{smart_count} %{resource}", - "shareSuccess": "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": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество прослушиваний и рейтинг.", - "noSimilarSongsFound": "Похожих треков не найдено", - "noTopSongsFound": "Лучших треков не найдено", - "startingInstantMix": "Загрузка быстрого микса..." - }, - "menu": { - "library": "Библиотека", - "settings": "Настройки", - "version": "Версия", - "theme": "Тема", - "personal": { - "name": "Личное", - "options": { - "theme": "Тема", - "language": "Язык", - "defaultView": "Вид по умолчанию", - "desktop_notifications": "Уведомления на рабочем столе", - "lastfmScrobbling": "Скробблинг Last.fm", - "listenBrainzScrobbling": "Скробблинг ListenBrainz", - "replaygain": "Режим ReplayGain", - "preAmp": "Предусилитель ReplayGain (дБ)", - "gain": { - "none": "Отключено", - "album": "Использовать усиление альбома", - "track": "Использовать усиление трека" + "note": "ПРИМЕЧАНИЕ", + "transcodingDisabled": "Изменение настроек транскодирования через веб интерфейс, отключено по соображениям безопасности. Если вы хотите изменить или добавить опции транскодирования, перезапустите сервер с %{config} опцией конфигурации.", + "transcodingEnabled": "Navidrome работает с настройками %{config}, позволяющими запускать команды с настройками транскодирования через веб интерфейс. В целях безопасности, мы рекомендуем отключить эту возможность.", + "songsAddedToPlaylist": "Один трек добавлен в плейлист |||| %{smart_count} треков добавлено в плейлист", + "noPlaylistsAvailable": "Недоступно", + "delete_user_title": "Удалить пользователя '%{name}'", + "delete_user_content": "Вы уверены, что вы хотите удалить пользователя и все его данные (включая плейлисты и настройки)?", + "notifications_blocked": "Вы заблокировали уведомления для этой страницы в настройках вашего браузера", + "notifications_not_available": "Ваш браузер не поддерживает всплывающие уведомления", + "lastfmLinkSuccess": "Соединение с Last.fm установлено, скробблинг включен", + "lastfmLinkFailure": "Last.fm не может быть подключен", + "lastfmUnlinkSuccess": "Соединение с Last.fm удалено, скробблинг отключен", + "lastfmUnlinkFailure": "Соединение с Last.fm не может быть удалено", + "openIn": { + "lastfm": "Показать на Last.fm", + "musicbrainz": "Показать на MusicBrainz" }, - "lastfmNotConfigured": "API-ключ Last.fm не настроен" - } + "lastfmLink": "Подробнее...", + "listenBrainzLinkSuccess": "ListenBrainz скробблинг успешно подключен для пользователя: %{user}", + "listenBrainzLinkFailure": "ListenBrainz не может быть связан:", + "listenBrainzUnlinkSuccess": "ListenBrainz скробблинг отключен", + "listenBrainzUnlinkFailure": "ListenBrainz не удалось отключить", + "downloadOriginalFormat": "Скачать в оригинальном формате", + "shareOriginalFormat": "Поделиться в оригинальном формате", + "shareDialogTitle": "Поделиться %{resource} '%{name}'", + "shareBatchDialogTitle": "Поделиться 1 %{resource} |||| Поделиться %{smart_count} %{resource}", + "shareSuccess": "URL скопирован в буфер обмена: %{url}", + "shareFailure": "Ошибка копирования URL-адреса %{url} в буфер обмена", + "downloadDialogTitle": "Скачать %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Копировать в буфер обмена: Ctrl+C, Enter" }, - "albumList": "Альбомы", - "about": "О программе", - "playlists": "Плейлисты", - "sharedPlaylists": "Общие плейлисты", - "librarySelector": { - "allLibraries": "Все библиотеки (%{count})", - "multipleLibraries": "%{selected} из %{total} библиотек |||| %{selected} из %{total} библиотек |||| %{selected} из %{total} библиотек", - "selectLibraries": "Выбор библиотек", - "none": "Отсутствует" - } - }, - "player": { - "playListsText": "Очередь воспроизведения", - "openText": "Открыть", - "closeText": "Закрыть", - "notContentText": "Нет музыки", - "clickToPlayText": "Играть", - "clickToPauseText": "Пауза", - "nextTrackText": "Следующий трек", - "previousTrackText": "Предыдущий трек", - "reloadText": "Перезагрузить", - "volumeText": "Громкость", - "toggleLyricText": "Посмотреть текст", - "toggleMiniModeText": "Свернуть", - "destroyText": "Выключить", - "downloadText": "Скачать", - "removeAudioListsText": "Очистить очередь", - "clickToDeleteText": "Нажмите для удаления %{name}", - "emptyLyricText": "Текст песни отсутствует", - "playModeText": { - "order": "По порядку", - "orderLoop": "Повторять", - "singleLoop": "Повторять один трек", - "shufflePlay": "Перемешать" - } - }, - "about": { - "links": { - "homepage": "Сайт проекта", - "source": "Исходный код", - "featureRequests": "Предложения", - "lastInsightsCollection": "Последний сбор данных", - "insights": { - "disabled": "Выключено", - "waiting": "Ожидание" - } + "menu": { + "library": "Библиотека", + "settings": "Настройки", + "version": "Версия", + "theme": "Тема", + "personal": { + "name": "Личные", + "options": { + "theme": "Тема", + "language": "Язык", + "defaultView": "Вид по умолчанию", + "desktop_notifications": "Уведомления на рабочем столе", + "lastfmScrobbling": "Скробблинг Last.fm", + "listenBrainzScrobbling": "Скробблинг ListenBrainz", + "replaygain": "ReplayGain режим", + "preAmp": "ReplayGain предусилитель (dB)", + "gain": { + "none": "Отключить", + "album": "Использовать усиление альбома", + "track": "Использовать усиление трека" + } + } + }, + "albumList": "Альбомы", + "about": "О нас", + "playlists": "Плейлисты", + "sharedPlaylists": "Поделиться плейлистом" }, - "tabs": { - "about": "О программе", - "config": "Конфигурация" + "player": { + "playListsText": "Очередь воспроизведения", + "openText": "Открыть", + "closeText": "Закрыть", + "notContentText": "Нет музыки", + "clickToPlayText": "Играть", + "clickToPauseText": "Пауза", + "nextTrackText": "Следующий трек", + "previousTrackText": "Предыдущий трек", + "reloadText": "Перезагрузить", + "volumeText": "Громкость", + "toggleLyricText": "Посмотреть текст", + "toggleMiniModeText": "Минимизировать", + "destroyText": "Выключить", + "downloadText": "Скачать", + "removeAudioListsText": "Удалить список воспроизведения", + "clickToDeleteText": "Нажмите для удаления %{name}", + "emptyLyricText": "Без текста", + "playModeText": { + "order": "По порядку", + "orderLoop": "Повторять", + "singleLoop": "Повторить один раз", + "shufflePlay": "Перемешать" + } }, - "config": { - "configName": "Параметр", - "environmentVariable": "Переменная окружения", - "currentValue": "Текущее значение", - "configurationFile": "Файл конфигурации", - "exportToml": "Экспорт в TOML", - "exportSuccess": "Конфигурация скопирована в буфер обмена в формате TOML", - "exportFailed": "Не удалось скопировать конфигурацию", - "devFlagsHeader": "Флаги разработки (могут быть изменены/удалены)", - "devFlagsComment": "Это экспериментальные настройки, которые могут быть удалены в будущих версиях.", - "downloadToml": "Скачать TOML" + "about": { + "links": { + "homepage": "Главная", + "source": "Код", + "featureRequests": "Предложения" + } + }, + "activity": { + "title": "Действия", + "totalScanned": "Всего просканировано папок", + "quickScan": "Быстрое сканирование", + "fullScan": "Полное сканирование", + "serverUptime": "Время работы", + "serverDown": "Оффлайн" + }, + "help": { + "title": "Горячие клавиши Navidrome", + "hotkeys": { + "show_help": "Показать справку", + "toggle_menu": "Показать / скрыть боковое меню", + "toggle_play": "Играть / Пауза", + "prev_song": "Предыдущий трек", + "next_song": "Следующий трек", + "vol_up": "Увеличить громкость", + "vol_down": "Уменьшить громкость", + "toggle_love": "Добавить / удалить песню из избранного", + "current_song": "Перейти к текущей песне" + } } - }, - "activity": { - "title": "Активность", - "totalScanned": "Всего просканировано папок", - "quickScan": "Быстрое сканирование", - "fullScan": "Полное сканирование", - "serverUptime": "Время работы сервера", - "serverDown": "Офлайн", - "scanType": "Тип", - "status": "Статус", - "elapsedTime": "Прошедшее время", - "selectiveScan": "Избирательное" - }, - "help": { - "title": "Горячие клавиши Navidrome", - "hotkeys": { - "show_help": "Показать справку", - "toggle_menu": "Показать/скрыть боковое меню", - "toggle_play": "Играть/Пауза", - "prev_song": "Предыдущий трек", - "next_song": "Следующий трек", - "vol_up": "Увеличить громкость", - "vol_down": "Уменьшить громкость", - "toggle_love": "Добавить/удалить из избранного", - "current_song": "Перейти к текущему треку" - } - }, - "nowPlaying": { - "title": "Сейчас играет", - "empty": "Ничего не играет", - "minutesAgo": "%{smart_count} минуту назад |||| %{smart_count} минуты назад |||| %{smart_count} минут назад" - } -} +} \ No newline at end of file diff --git a/resources/i18n/sk.json b/resources/i18n/sk.json deleted file mode 100644 index f294d1602..000000000 --- a/resources/i18n/sk.json +++ /dev/null @@ -1,723 +0,0 @@ -{ - "languageName": "Slovenčina", - "resources": { - "song": { - "name": "Skladba |||| Skladby", - "fields": { - "albumArtist": "Interpret albumu", - "duration": "Dĺžka", - "trackNumber": "#", - "playCount": "Počet prehratí", - "title": "Názov", - "artist": "Interpret", - "album": "Album", - "path": "Cesta k súboru", - "genre": "Žáner", - "compilation": "Kompilácia", - "year": "Rok", - "size": "Veľkosť súboru", - "updatedAt": "Nahrané", - "bitRate": "Prenosová rýchlosť", - "discSubtitle": "Podtitul disku", - "starred": "Obľúbené", - "comment": "Komentár", - "rating": "Hodnotenie", - "quality": "Kvalita", - "bpm": "BPM", - "playDate": "Naposledy prehraná skladba", - "channels": "Kanály", - "createdAt": "Pridané", - "grouping": "Zoskupovanie", - "mood": "Nálada", - "participants": "Ďalší účastníci", - "tags": "Ďalšie značky", - "mappedTags": "Mapované značky", - "rawTags": "Nespracované značky", - "bitDepth": "Bitová hĺbka", - "sampleRate": "Vzorkovacia frekvencia", - "missing": "Chýbajúce", - "libraryName": "Knižnica", - "composer": "Skladateľ", - "disc": "Disk %{discNumber}", - "albumGain": "Zosilnenie albumu", - "trackGain": "Zosilnenie stopy" - }, - "actions": { - "addToQueue": "Prehrať neskôr", - "playNow": "Prehrať teraz", - "addToPlaylist": "Pridať do zoznamu skladieb", - "shuffleAll": "Zamiešať všetko", - "download": "Stiahnuť", - "playNext": "Prehrať ako ďalšie", - "info": "Získať informácie", - "showInPlaylist": "Zobraziť v zozname skladieb", - "instantMix": "Okamžitý mix" - } - }, - "album": { - "name": "Album |||| Albumy", - "fields": { - "albumArtist": "Interpret albumu", - "artist": "Interpret", - "duration": "Dĺžka", - "songCount": "Skladby", - "playCount": "Počet prehratí", - "name": "Názov", - "genre": "Žáner", - "compilation": "Kompilácia", - "year": "Rok", - "updatedAt": "Aktualizované", - "comment": "Komentár", - "rating": "Hodnotenie", - "createdAt": "Pridané", - "size": "Veľkosť", - "originalDate": "Pôvodné", - "releaseDate": "Vydané", - "releases": "Vydanie |||| Vydania", - "released": "Vydané", - "recordLabel": "Štítok", - "catalogNum": "Katalógové číslo", - "releaseType": "Typ vydania", - "grouping": "Zoskupovanie", - "media": "Médiá", - "mood": "Nálada", - "date": "Dátum záznamu", - "missing": "Chýbajúce", - "libraryName": "Knižnica" - }, - "actions": { - "playAll": "Prehrať", - "playNext": "Prehrať ako ďalšie", - "addToQueue": "Prehrať neskôr", - "shuffle": "Zamiešať", - "addToPlaylist": "Pridať do zoznamu skladieb", - "download": "Stiahnuť", - "info": "Získať informácie", - "share": "Zdieľať" - }, - "lists": { - "all": "Všetko", - "random": "Náhodné", - "recentlyAdded": "Nedávno pridané", - "recentlyPlayed": "Nedávno prehrané", - "mostPlayed": "Najviac prehrávané", - "starred": "Obľúbené", - "topRated": "Najlepšie hodnotené" - } - }, - "artist": { - "name": "Interpret |||| Interpreti", - "fields": { - "name": "Názov", - "albumCount": "Počet albumov", - "songCount": "Počet skladieb", - "playCount": "Prehrania", - "rating": "Hodnotenie", - "genre": "Žáner", - "size": "Veľkosť", - "role": "Rola", - "missing": "Chýbajúci" - }, - "roles": { - "albumartist": "Interpret albumu |||| Interpreti albumov", - "artist": "Interpret |||| Interpreti", - "composer": "Skladateľ |||| Skladatelia", - "conductor": "Dirigent |||| Dirigenti", - "lyricist": "Textár |||| Textári", - "arranger": "Aranžér |||| Aranžéri", - "producer": "Producent |||| Producenti", - "director": "Režisér |||| Režiséri", - "engineer": "Zvukový technik |||| Zvukoví technici", - "mixer": "Mixér |||| Mixéri", - "remixer": "Remixér |||| Remixéri", - "djmixer": "DJ Mixér |||| DJ Mixéri", - "performer": "Účinkujúci |||| Účinkujúci", - "maincredit": "Interpret albumu alebo interpret |||| Interpreti albumov alebo interpreti" - }, - "actions": { - "shuffle": "Zamiešať", - "radio": "Rádio", - "topSongs": "Najpopulárnejšie skladby" - } - }, - "user": { - "name": "Používateľ |||| Používatelia", - "fields": { - "userName": "Používateľské meno", - "isAdmin": "Správca", - "lastLoginAt": "Naposledy prihlásený", - "updatedAt": "Upravený", - "name": "Meno", - "password": "Heslo", - "createdAt": "Vytvorený", - "changePassword": "Zmeniť heslo?", - "currentPassword": "Súčastné heslo", - "newPassword": "Nové heslo", - "token": "Token", - "lastAccessAt": "Posledný Prístup", - "libraries": "Knižnice" - }, - "helperTexts": { - "name": "Zmena mena sa zobrazí až po ďalšom prihlásení", - "libraries": "Vyberte konkrétne knižnice pre tohto používateľa alebo nechajte pole prázdne, ak chcete použiť predvolené knižnice" - }, - "notifications": { - "created": "Používateľ vytvorený", - "updated": "Používateľ upravený", - "deleted": "Používateľ odstránený" - }, - "message": { - "listenBrainzToken": "Vložte svoj používateľský ListenBrainz token.", - "clickHereForToken": "Kliknite sem pre získanie svojho tokenu", - "selectAllLibraries": "Vybrať všetky knižnice", - "adminAutoLibraries": "Administrátori majú automaticky prístup ku všetkým knižniciam" - }, - "validation": { - "librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica" - } - }, - "player": { - "name": "Prehrávač |||| Prehrávače", - "fields": { - "name": "Názov", - "transcodingId": "ID transkódovania", - "maxBitRate": "Max. prenosová rýchlosť", - "client": "Klient", - "userName": "Používateľské meno", - "lastSeen": "Naposledy videný", - "reportRealPath": "Skutočná cesta hlásenia", - "scrobbleEnabled": "Odosielať scrobbling na externé služby" - } - }, - "transcoding": { - "name": "Transkódovanie |||| Transkódovania", - "fields": { - "name": "Názov", - "targetFormat": "Cieľový formát", - "defaultBitRate": "Predvolená prenosová rýchlosť", - "command": "Príkaz" - } - }, - "playlist": { - "name": "Zoznam skladieb |||| Zoznamy skladieb", - "fields": { - "name": "Názov", - "duration": "Dĺžka", - "ownerName": "Autor", - "public": "Verejný", - "updatedAt": "Nahraný", - "createdAt": "Vytvorený", - "songCount": "Skladby", - "comment": "Komentár", - "sync": "Auto-import", - "path": "Importovať z" - }, - "actions": { - "selectPlaylist": "Vybrať zoznam skladieb:", - "addNewPlaylist": "Vytvoriť \"%{name}\"", - "export": "Export", - "makePublic": "Zverejniť", - "makePrivate": "Nastaviť ako súkromné", - "saveQueue": "Uložiť rad do zoznamu skladieb", - "searchOrCreate": "Vyhľadajte zoznamy skladieb alebo napíšte pre vytvorenie nového...", - "pressEnterToCreate": "Stlačte Enter pre vytvorenie nového zoznamu skladieb", - "removeFromSelection": "Odstrániť z výberu" - }, - "message": { - "duplicate_song": "Pridať duplicitné položky", - "song_exist": "Pridávate duplikát už existujúcej položky v zozname skladieb. Chcete pridať duplikát alebo ho preskočiť?", - "noPlaylistsFound": "Žiadne zoznamy skladieb sa nenašli", - "noPlaylists": "Žiadne zoznamy skladieb nie sú dostupné" - } - }, - "radio": { - "name": "Rádio |||| Rádiá", - "fields": { - "name": "Názov", - "streamUrl": "URL streamu", - "homePageUrl": "URL stránky", - "updatedAt": "Nahrané", - "createdAt": "Vytvorené" - }, - "actions": { - "playNow": "Spustiť" - } - }, - "share": { - "name": "Zdieľanie |||| Zdieľania", - "fields": { - "username": "Zdieľané", - "url": "URL", - "description": "Popis", - "contents": "Obsah", - "expiresAt": "Vyprší", - "lastVisitedAt": "Naposledy navštívené", - "visitCount": "Počet návštev", - "format": "Formát", - "maxBitRate": "Max. Bit Rate", - "updatedAt": "Nahrané", - "createdAt": "Vytvorené", - "downloadable": "Povoliť sťahovanie?" - } - }, - "missing": { - "name": "Chýbajúci súbor |||| Chýbajúce súbory", - "fields": { - "path": "Cesta", - "size": "Veľkosť", - "updatedAt": "Zmizol dňa", - "libraryName": "Knižnica" - }, - "actions": { - "remove": "Odstrániť", - "remove_all": "Odstrániť všetky" - }, - "notifications": { - "removed": "Chýbajúce súbory odstránené" - }, - "empty": "Žiadne chýbajúce súbory" - }, - "library": { - "name": "Knižnica |||| Knižnice", - "fields": { - "name": "Názov", - "path": "Cesta", - "remotePath": "Vzdialená cesta", - "lastScanAt": "Posledný sken", - "songCount": "Skladby", - "albumCount": "Albumy", - "artistCount": "Interpreti", - "totalSongs": "Skladby", - "totalAlbums": "Albumy", - "totalArtists": "Interpreti", - "totalFolders": "Priečinky", - "totalFiles": "Súbory", - "totalMissingFiles": "Chýbajúce súbory", - "totalSize": "Celková veľkosť", - "totalDuration": "Dĺžka", - "defaultNewUsers": "Predvolené pre nových používateľov", - "createdAt": "Vytvorené", - "updatedAt": "Aktualizované" - }, - "sections": { - "basic": "Základné informácie", - "statistics": "Štatistiky" - }, - "actions": { - "scan": "Skenovať knižnicu", - "manageUsers": "Spravovať prístup používateľov", - "viewDetails": "Zobraziť detaily", - "quickScan": "Rýchly sken", - "fullScan": "Úplný sken" - }, - "notifications": { - "created": "Knižnica úspešne vytvorená", - "updated": "Knižnica úspešne aktualizovaná", - "deleted": "Knižnica úspešne odstránená", - "scanStarted": "Skenovanie knižnice spustené", - "scanCompleted": "Skenovanie knižnice dokončené", - "quickScanStarted": "Rýchly sken spustený", - "fullScanStarted": "Úplný sken spustený", - "scanError": "Chyba pri spustení skenu. Skontrolujte logy" - }, - "validation": { - "nameRequired": "Názov knižnice je povinný", - "pathRequired": "Cesta ku knižnici je povinná", - "pathNotDirectory": "Cesta ku knižnici musí byť priečinok", - "pathNotFound": "Cesta ku knižnici sa nenašla", - "pathNotAccessible": "Cesta ku knižnici nie je dostupná", - "pathInvalid": "Neplatná cesta ku knižnici" - }, - "messages": { - "deleteConfirm": "Ste si istý, že chcete odstrániť túto knižnicu? Tým sa odstránia všetky súvisiace dáta a prístupy používateľov.", - "scanInProgress": "Skenovanie prebieha...", - "noLibrariesAssigned": "Tomuto používateľovi nie sú priradené žiadne knižnice" - } - }, - "plugin": { - "name": "Plugin |||| Pluginy", - "fields": { - "id": "ID", - "name": "Názov", - "description": "Popis", - "version": "Verzia", - "author": "Autor", - "website": "Webová stránka", - "permissions": "Oprávnenia", - "enabled": "Povolený", - "status": "Stav", - "path": "Cesta", - "lastError": "Chyba", - "hasError": "Chyba", - "updatedAt": "Aktualizovaný", - "createdAt": "Nainštalovaný", - "configKey": "Kľúč", - "configValue": "Hodnota", - "allUsers": "Povoliť všetkých používateľov", - "selectedUsers": "Vybraní používatelia", - "allLibraries": "Povoliť všetky knižnice", - "selectedLibraries": "Vybrané knižnice", - "allowWriteAccess": "Povoliť prístup na zápis" - }, - "sections": { - "status": "Stav", - "info": "Informácie o plugine", - "configuration": "Konfigurácia", - "manifest": "Manifest", - "usersPermission": "Oprávnenia používateľov", - "libraryPermission": "Oprávnenia knižnice" - }, - "status": { - "enabled": "Povolený", - "disabled": "Zakázaný" - }, - "actions": { - "enable": "Povoliť", - "disable": "Zakázať", - "disabledDueToError": "Opravte chybu pred povolením", - "disabledUsersRequired": "Vyberte používateľov pred povolením", - "disabledLibrariesRequired": "Vyberte knižnice pred povolením", - "addConfig": "Pridať konfiguráciu", - "rescan": "Znovu skenovať" - }, - "notifications": { - "enabled": "Plugin povolený", - "disabled": "Plugin zakázaný", - "updated": "Plugin aktualizovaný", - "error": "Chyba pri aktualizácii pluginu" - }, - "validation": { - "invalidJson": "Konfigurácia musí byť platný JSON" - }, - "messages": { - "configHelp": "Nakonfigurujte plugin pomocou párov kľúč-hodnota. Nechajte prázdne, ak plugin nevyžaduje žiadnu konfiguráciu.", - "clickPermissions": "Kliknite na oprávnenie pre detaily", - "noConfig": "Žiadna konfigurácia nastavená", - "allUsersHelp": "Keď je povolené, plugin bude mať prístup ku všetkým používateľom, vrátane tých vytvorených v budúcnosti.", - "noUsers": "Žiadni používatelia nevybraní", - "permissionReason": "Dôvod", - "usersRequired": "Tento plugin vyžaduje prístup k informáciám o používateľoch. Vyberte, ku ktorým používateľom má plugin prístup, alebo povolte 'Povoliť všetkých používateľov'.", - "allLibrariesHelp": "Keď je povolené, plugin bude mať prístup ku všetkým knižniciam, vrátane tých vytvorených v budúcnosti.", - "noLibraries": "Žiadne knižnice nevybrané", - "librariesRequired": "Tento plugin vyžaduje prístup k informáciám o knižniciach. Vyberte, ku ktorým knižniciam má plugin prístup, alebo povolte 'Povoliť všetky knižnice'.", - "requiredHosts": "Požadovaní hostitelia", - "configValidationError": "Overenie konfigurácie zlyhalo:", - "schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.", - "allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie." - }, - "placeholders": { - "configKey": "kľúč", - "configValue": "hodnota" - } - } - }, - "ra": { - "auth": { - "welcome1": "Ďakujeme, že ste si nainštalovali Navidrome!", - "welcome2": "Najskôr vytvorte účet správcu", - "confirmPassword": "Potvrďte heslo", - "buttonCreateAdmin": "Vytvoriť správcu", - "auth_check_error": "Pre pokračovanie sa prosím prihláste", - "user_menu": "Profil", - "username": "Používateľské meno", - "password": "Heslo", - "sign_in": "Prihlásiť sa", - "sign_in_error": "Overenie zlyhalo, skúste to znova", - "logout": "Odhlásiť sa", - "insightsCollectionNote": "Navidrome zhromažďuje anonymné údaje\n o používaní, aby pomohol zlepšiť projekt.\nKliknite [sem] a dozviete sa viac a v prípade\npotreby sa odhláste." - }, - "validation": { - "invalidChars": "Prosím, používajte iba písmená a čísla", - "passwordDoesNotMatch": "Heslá sa nezhodujú", - "required": "Povinné pole", - "minLength": "Musí obsahovať najmenej %{min} znakov", - "maxLength": "Môže obsahovať maximálne %{max} znakov", - "minValue": "Musí byť aspoň %{min}", - "maxValue": "Môže byť maximálne %{max}", - "number": "Musí byť číslo", - "email": "Musí byť platná e-mailová adresa", - "oneOf": "Musí spĺňať jedno z: %{options}", - "regex": "Musí byť v špecifickom formáte (regexp): %{pattern}", - "unique": "Musí byť jedinečný", - "url": "Musí byť platná URL" - }, - "action": { - "add_filter": "Pridať filter", - "add": "Pridať", - "back": "Ísť späť", - "bulk_actions": "1 vybraná |||| %{smart_count} vybraných", - "cancel": "Zrušiť", - "clear_input_value": "Vymazať hodnotu", - "clone": "Klonovať", - "confirm": "Potvrdiť", - "create": "Vytvoriť", - "delete": "Vymazať", - "edit": "Upraviť", - "export": "Exportovať", - "list": "Zoznam", - "refresh": "Obnoviť", - "remove_filter": "Odstrániť filter", - "remove": "Odstrániť", - "save": "Uložiť", - "search": "Vyhľadať", - "show": "Zobraziť", - "sort": "Zoradiť", - "undo": "Vrátiť", - "expand": "Rozbaliť", - "close": "Zavrieť", - "open_menu": "Otvoriť ponuku", - "close_menu": "Zavrieť ponuku", - "unselect": "Zrušiť výber", - "skip": "Preskočiť", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Zdieľať", - "download": "Stiahnuť" - }, - "boolean": { - "true": "Áno", - "false": "Nie" - }, - "page": { - "create": "Vytvoriť %{name}", - "dashboard": "Dashboard", - "edit": "%{name} #%{id}", - "error": "Niečo sa pokazilo", - "list": "%{name}", - "loading": "Načítavanie", - "not_found": "Nenájdené", - "show": "%{name} #%{id}", - "empty": "Zatiaľ žiaden %{name}.", - "invite": "Chcete pridať nové?" - }, - "input": { - "file": { - "upload_several": "Presuňte súbory pre nahranie alebo kliknite pre výber.", - "upload_single": "Presuňte súbor pre nahranie alebo kliknite pre jeho výber." - }, - "image": { - "upload_several": "Presuňte obrázky pre nahranie alebo kliknite pre výber.", - "upload_single": "Presuňte obrázok pre nahranie alebo kliknite pre jeho výber." - }, - "references": { - "all_missing": "Referencované dáta sa nenašli.", - "many_missing": "Aspoň jedna z referencií už nie je dostupná.", - "single_missing": "Referencia sa zdá byť nedostupná." - }, - "password": { - "toggle_visible": "Skryť heslo", - "toggle_hidden": "Zobraziť heslo" - } - }, - "message": { - "about": "O Navidrome", - "are_you_sure": "Ste si istý?", - "bulk_delete_content": "Ste si istý, že chcete vymazať %{name}? |||| Ste si istý, že chcete vymazať týchto %{smart_count} položiek?", - "bulk_delete_title": "Vymazať %{name} |||| Vymazať %{smart_count} %{name} položiek", - "delete_content": "Ste si istý, že chcete vymazať túto položku?", - "delete_title": "Vymazať %{name} #%{id}", - "details": "Detaily", - "error": "Vyskytla sa chyba klienta a vaša požiadavka nemohla byť splnená.", - "invalid_form": "Formulár nie je platný. Prosím skontrolujte ho.", - "loading": "Stránka sa načítava, prosím počkajte", - "no": "Nie", - "not_found": "Zadali ste nesprávnu adresu URL, alebo ste nasledovali nesprávny odkaz.", - "yes": "Áno", - "unsaved_changes": "Niektoré vaše zmeny neboli uložené. Ste si istí, že ich chcete ignorovať?" - }, - "navigation": { - "no_results": "Nenašli sa žiadne výsledky", - "no_more_results": "Stránka číslo %{page} je mimo rozsah. Skúste predchádzajúcu.", - "page_out_of_boundaries": "Stránka číslo %{page} je mimo rozsah", - "page_out_from_end": "Nemožno ísť za poslednú stranu", - "page_out_from_begin": "Nemožno ísť pred prvú stranu", - "page_range_info": "%{offsetBegin}-%{offsetEnd} z %{total}", - "page_rows_per_page": "Položiek na stránke:", - "next": "Ďalší", - "prev": "Predchádzajúci", - "skip_nav": "Preskočiť na obsah" - }, - "notification": { - "updated": "Prvok aktualizovaný |||| %{smart_count} prvkov aktualizovaných", - "created": "Prvok vytvorený", - "deleted": "Prvok vymazaný |||| %{smart_count} prvkov vymazaných", - "bad_item": "Nesprávny prvok", - "item_doesnt_exist": "Prvok neexistuje", - "http_error": "Chyba komunikácie servera", - "data_provider_error": "Chyba dataProvideru. Detaily nájdete v konzole.", - "i18n_error": "Nemožno načítať preklady pre vybraný jazyk", - "canceled": "Akcia zrušená", - "logged_out": "Vaša relácia skončila, prosím pripojte sa znova.", - "new_version": "Je dostupná nová verzia! Prosím obnovte toto okno." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Stĺpce na zobrazenie", - "layout": "Rozloženie", - "grid": "Mriežka", - "table": "Tabuľka" - } - }, - "message": { - "note": "POZNÁMKA", - "transcodingDisabled": "Zmena nastavení transkódovania je vo webovom prostredí vypnutá z bezpečnostných dôvodov. Ak chcete zmeniť (upraviť alebo pridať) možnosti transkódovania, reštartujte server s možnosťou %{config}.", - "transcodingEnabled": "Navidrome práve beží s možnosťou %{config}, ktorá umožňuje spúšťanie systémových príkazov z nastavení transkódovania pomocou webového rozhrania. Odporúčame ju vypnúť z bezpečnostných dôvodov a používať ju iba pri úprave nastavení transkódovania.", - "songsAddedToPlaylist": "1 skladba pridaná do zoznamu skladieb |||| %{smart_count} skladieb pridaných do zoznamu skladieb", - "noPlaylistsAvailable": "Žiadne nie sú dostupné", - "delete_user_title": "Odstrániť používateľa '%{name}'", - "delete_user_content": "Ste si istí, že chcete odstrániť tohto používateľa a všetky jeho dáta (vrátane zoznamov skladieb a nastavení)?", - "notifications_blocked": "Zablokovali ste si oznámenia pre túto stránku v nastaveniach vášho prehliadača", - "notifications_not_available": "Tento prehliadač nepodporuje oznámenia na ploche alebo nepristupujete k Navidrome cez https", - "lastfmLinkSuccess": "Last.fm úspešne pripojené a scrobbling zapnutý", - "lastfmLinkFailure": "Last.fm sa nepodarilo pripojiť", - "lastfmUnlinkSuccess": "Last.fm odpojené a scrobbling vypnutý", - "lastfmUnlinkFailure": "Last.fm sa nepodarilo odpojiť", - "openIn": { - "lastfm": "Otvoriť na Last.fm", - "musicbrainz": "Otvoriť na MusicBrainz" - }, - "lastfmLink": "Čítať ďalej...", - "listenBrainzLinkSuccess": "ListenBrainz úspešne pripojený a scrobbling zapnutý ako používateľ: %{user}", - "listenBrainzLinkFailure": "ListenBrainz sa nepodarilo pripojiť: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz odpojený a scrobbling vypnutý", - "listenBrainzUnlinkFailure": "ListenBrainz sa nepodarilo odpojiť", - "downloadOriginalFormat": "Stiahnuť v pôvodnom formáte", - "shareOriginalFormat": "Zdieľať v pôvodnom formáte", - "shareDialogTitle": "Zdieľať %{resource} '%{name}'", - "shareBatchDialogTitle": "Zdieľať 1 %{resource} |||| Zdieľať %{smart_count} %{resource}", - "shareSuccess": "URL skopírovaná do schránky: %{url}", - "shareFailure": "Chyba pri kopírovaní URL %{url} do schránky", - "downloadDialogTitle": "Stiahnuť %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter", - "remove_missing_title": "Odstráňte chýbajúce súbory", - "remove_missing_content": "Naozaj chcete odstrániť vybraté chýbajúce súbory z databázy? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", - "remove_all_missing_title": "Odstráňte všetky chýbajúce súbory", - "remove_all_missing_content": "Naozaj chcete z databázy odstrániť všetky chýbajúce súbory? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", - "noSimilarSongsFound": "Nenašli sa žiadne podobné skladby", - "noTopSongsFound": "Nenašli sa žiadne top skladby", - "startingInstantMix": "Načítava sa Instant Mix...", - "uploadCover": "Nahrať obrázok obalu", - "removeCover": "Odstrániť obrázok obalu", - "coverUploaded": "Obrázok obalu albumu aktualizovaný", - "coverRemoved": "Obrázok obalu albumu odstránený", - "coverUploadError": "Chyba pri nahrávaní obrázku obalu albumu", - "coverRemoveError": "Chyba pri odstraňovaní obrázku obalu albumu" - }, - "menu": { - "library": "Knižnica", - "settings": "Nastavenia", - "version": "Verzia", - "theme": "Téma", - "personal": { - "name": "Osobné", - "options": { - "theme": "Téma", - "language": "Jazyk", - "defaultView": "Predvolená stránka", - "desktop_notifications": "Oznámenia na ploche", - "lastfmScrobbling": "Scrobblovať na Last.fm", - "listenBrainzScrobbling": "Scrobblovať na ListenBrainz", - "replaygain": "Mód ReplayGain", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "Vypnuté", - "album": "Použiť Album Gain", - "track": "Použiť Track Gain" - }, - "lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný" - } - }, - "albumList": "Albumy", - "about": "O Navidrome", - "playlists": "Zoznamy skladieb", - "sharedPlaylists": "Zdieľané zoznamy skladieb", - "librarySelector": { - "allLibraries": "Všetky knižnice (%{count})", - "multipleLibraries": "%{selected} z %{total} knižníc", - "selectLibraries": "Vyberte knižnice", - "none": "Žiadne" - } - }, - "player": { - "playListsText": "Rad", - "openText": "Otvoriť", - "closeText": "Zavrieť", - "notContentText": "Žiadne skladby", - "clickToPlayText": "Kliknite pre prehranie", - "clickToPauseText": "Kliknite pre pozastavenie", - "nextTrackText": "Ďalšia skladba", - "previousTrackText": "Predchádzajúca skladba", - "reloadText": "Znovu načítať", - "volumeText": "Hlasitosť", - "toggleLyricText": "Prepnúť text", - "toggleMiniModeText": "Zmenšiť", - "destroyText": "Zničiť", - "downloadText": "Stiahnuť", - "removeAudioListsText": "Vymazať zoznam", - "clickToDeleteText": "Kliknite pre odstránenie %{name}", - "emptyLyricText": "Bez textu", - "playModeText": { - "order": "Po poradí", - "orderLoop": "Opakovať", - "singleLoop": "Opakovať raz", - "shufflePlay": "Zamiešať" - } - }, - "about": { - "links": { - "homepage": "Domovská stránka", - "source": "Zdrojový kód", - "featureRequests": "Požiadavky na funkcie", - "lastInsightsCollection": "Posledný zber štatistík", - "insights": { - "disabled": "Zakázané", - "waiting": "Čakanie" - } - }, - "tabs": { - "about": "O aplikácii", - "config": "Konfigurácia" - }, - "config": { - "configName": "Názov konfigurácie", - "environmentVariable": "Premenná prostredia", - "currentValue": "Aktuálna hodnota", - "configurationFile": "Konfiguračný súbor", - "exportToml": "Exportovať konfiguráciu (TOML)", - "exportSuccess": "Konfigurácia exportovaná do schránky vo formáte TOML", - "exportFailed": "Nepodarilo sa skopírovať konfiguráciu", - "devFlagsHeader": "Vývojové príznaky (môžu byť zmenené/odstránené)", - "devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách", - "downloadToml": "Stiahnuť konfiguráciu (TOML)" - } - }, - "activity": { - "title": "Aktivita", - "totalScanned": "Naskenované priečinky", - "quickScan": "Rýchly sken", - "fullScan": "Úplný sken", - "serverUptime": "Doba od spustenia", - "serverDown": "OFFLINE", - "scanType": "Posledný Sken", - "status": "Chyba skenovania", - "elapsedTime": "Uplynutý čas", - "selectiveScan": "Selektívne" - }, - "help": { - "title": "Klávesové skratky Navidrome", - "hotkeys": { - "show_help": "Zobraziť túto nápovedu", - "toggle_menu": "Prepnúť bočné menu", - "toggle_play": "Prehrať / Pozastaviť", - "prev_song": "Predchádzajúca skladba", - "next_song": "Nasledujúca skladba", - "vol_up": "Zvýšiť hlasitosť", - "vol_down": "Znížiť hlasitosť", - "toggle_love": "Pridať túto skladbu do obľúbených", - "current_song": "Prejsť na aktuálnu skladbu" - } - }, - "nowPlaying": { - "title": "Práve hrá", - "empty": "Nič sa neprehráva", - "minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami" - } -} \ No newline at end of file diff --git a/resources/i18n/sl.json b/resources/i18n/sl.json index ceb56e9b7..690c47577 100644 --- a/resources/i18n/sl.json +++ b/resources/i18n/sl.json @@ -1,715 +1,456 @@ { - "languageName": "Slovenščina", - "resources": { - "song": { - "name": "Pesem |||| Pesmi", - "fields": { - "albumArtist": "Avtor albuma", - "duration": "Dolžina", - "trackNumber": "#", - "playCount": "Predvajano", - "title": "Naslov", - "artist": "Avtor", - "album": "Album", - "path": "Pot datoteke", - "genre": "Žanr", - "compilation": "Kompilacija", - "year": "Leto", - "size": "Velikost datoteke", - "updatedAt": "Posodobljeno", - "bitRate": "Bitna hitrost", - "discSubtitle": "Podnapisi", - "starred": "Priljubljen", - "comment": "Opomba", - "rating": "Ocena", - "quality": "Kakovost", - "bpm": "BPM", - "playDate": "Zadnja predvajana", - "channels": "Kanali", - "createdAt": "Datum dodano", - "grouping": "Grupiranje", - "mood": "Razpoloženje", - "participants": "Dodatni udeleženci", - "tags": "Dodatne oznake", - "mappedTags": "Preslikane oznake", - "rawTags": "Nespremenjene oznake", - "bitDepth": "Bitna globina", - "sampleRate": "Frekvenca vzorčenja", - "missing": "Manjka", - "libraryName": "Knjižnica", - "composer": "Skladatelj", - "disc": "" - }, - "actions": { - "addToQueue": "Predvajaj kasneje", - "playNow": "Predvajaj", - "addToPlaylist": "Dodaj na seznam predvajanj", - "shuffleAll": "Premešaj vse", - "download": "Naloži", - "playNext": "Naslednji", - "info": "Več informacij", - "showInPlaylist": "Prikaži na seznamu predvajanja", - "instantMix": "Instant Mix" - } + "languageName": "Slovenščina", + "resources": { + "song": { + "name": "Pesem |||| Pesmi", + "fields": { + "albumArtist": "Avtor albuma", + "duration": "Dolžina", + "trackNumber": "#", + "playCount": "Predvajano", + "title": "Naslov", + "artist": "Avtor", + "album": "Album", + "path": "Pot datoteke", + "genre": "Žanr", + "compilation": "Kompilacija", + "year": "Leto", + "size": "Velikost datoteke", + "updatedAt": "Posodobljeno", + "bitRate": "Bitna hitrost", + "discSubtitle": "Podnapisi", + "starred": "Priljubljen", + "comment": "Opomba", + "rating": "Ocena", + "quality": "Kakovost", + "bpm": "BPM", + "playDate": "Zadnja predvajana", + "channels": "Kanali", + "createdAt": "Datum dodano" + }, + "actions": { + "addToQueue": "Predvajaj kasneje", + "playNow": "Predvajaj", + "addToPlaylist": "Dodaj na seznam predvajanj", + "shuffleAll": "Premešaj vse", + "download": "Naloži", + "playNext": "Naslednji", + "info": "Več informacij" + } + }, + "album": { + "name": "Album |||| Albumi", + "fields": { + "albumArtist": "Avtor albuma", + "artist": "Izvajalec", + "duration": "Dolžina", + "songCount": "Pesmi", + "playCount": "Predvajano", + "name": "Naslov", + "genre": "Žanr", + "compilation": "Kompilacija", + "year": "Leto", + "updatedAt": "Posodobljeno", + "comment": "Opomba", + "rating": "Ocena", + "createdAt": "Datum dodano", + "size": "Velikost" + }, + "actions": { + "playAll": "Predvajaj vse", + "playNext": "Naslednji", + "addToQueue": "Predvajaj kasneje", + "shuffle": "Premešaj", + "addToPlaylist": "Dodaj v seznam predvajanja", + "download": "Naloži", + "info": "Več informacij", + "share": "Deli" + }, + "lists": { + "all": "Vse", + "random": "Naključno", + "recentlyAdded": "Dodan nedavno", + "recentlyPlayed": "Predvajan nedavno", + "mostPlayed": "Največ predvajano", + "starred": "Priljubljeni", + "topRated": "Najvišje ocenjeno" + } + }, + "artist": { + "name": "Izvajalec |||| Izvajalci", + "fields": { + "name": "Ime", + "albumCount": "# albumov", + "songCount": "# pesmi", + "playCount": "# predvajanj", + "rating": "Ocena", + "genre": "Žanr", + "size": "Velikost" + } + }, + "user": { + "name": "Uporabnik |||| Uporabniki", + "fields": { + "userName": "Uporabnik", + "isAdmin": "Upravitelj", + "lastLoginAt": "Zadnji vpis", + "updatedAt": "Posodobljeno", + "name": "Ime", + "password": "Geslo", + "createdAt": "Ustvarjeno", + "changePassword": "Spremeni geslo?", + "currentPassword": "Trenutno geslo", + "newPassword": "Novo geslo", + "token": "Žeton" + }, + "helperTexts": { + "name": "Sprememba imena bo vidna pri naslednjem vpisu" + }, + "notifications": { + "created": "Uporabnik ustvarjen", + "updated": "Uporabnik posodobljen", + "deleted": "Uporabnik izbrisan" + }, + "message": { + "listenBrainzToken": "Vnesi žeton uporabnika ListenBrainz.", + "clickHereForToken": "Klikni za žeton" + } + }, + "player": { + "name": "Predvajalnik |||| Predvajalniki", + "fields": { + "name": "Naziv", + "transcodingId": "Transkodiranje", + "maxBitRate": "Maks. bitrate", + "client": "Klijent", + "userName": "Uporabnik", + "lastSeen": "Zadnjič viden", + "reportRealPath": "Zabeleži pravo pot", + "scrobbleEnabled": "Pošlji Scrobbles zunanjim storitvam" + } + }, + "transcoding": { + "name": "Transkodiranje |||| Transkodiranje", + "fields": { + "name": "Ime", + "targetFormat": "Ciljni format", + "defaultBitRate": "Privzet bitrate", + "command": "Ukaz" + } + }, + "playlist": { + "name": "Seznam predvajanj |||| Seznami predvajanj", + "fields": { + "name": "Ime", + "duration": "Dolžina", + "ownerName": "Lastnik", + "public": "Javno", + "updatedAt": "Posodobljen", + "createdAt": "Ustvarjen", + "songCount": "# pesmi", + "comment": "Opomba", + "sync": "Avtomatski uvoz", + "path": "Uvozi iz" + }, + "actions": { + "selectPlaylist": "Izberi seznam", + "addNewPlaylist": "Ustvari \"%{name}\"", + "export": "Izvozi", + "makePublic": "Naredi javno", + "makePrivate": "Naredi zasebno" + }, + "message": { + "duplicate_song": "Dodaj podvojene pesmi", + "song_exist": "Seznamu predvajanja boste dodali duplikate. Jih želite dodati ali izpustiti?" + } + }, + "radio": { + "name": "Radio |||| Radiji", + "fields": { + "name": "Ime", + "streamUrl": "URL toka", + "homePageUrl": "URL domače strani", + "updatedAt": "Posodobljeno ob", + "createdAt": "Ustvarjeno ob" + }, + "actions": { + "playNow": "Predvajaj" + } + }, + "share": { + "name": "Deli |||| Delitev", + "fields": { + "username": "Delil z", + "url": "URL", + "description": "Opis", + "contents": "Vsebine", + "expiresAt": "Poteče", + "lastVisitedAt": "Nazadnje obiskano", + "visitCount": "Obiski", + "format": "Oblika", + "maxBitRate": "Maks. bitna hitrost", + "updatedAt": "Posodobljeno ob", + "createdAt": "Ustvarjeno ob", + "downloadable": "" + } + } }, - "album": { - "name": "Album |||| Albumi", - "fields": { - "albumArtist": "Avtor albuma", - "artist": "Izvajalec", - "duration": "Dolžina", - "songCount": "Pesmi", - "playCount": "Predvajano", - "name": "Naslov", - "genre": "Žanr", - "compilation": "Kompilacija", - "year": "Leto", - "updatedAt": "Posodobljeno", - "comment": "Opomba", - "rating": "Ocena", - "createdAt": "Datum dodano", - "size": "Velikost", - "originalDate": "Original", - "releaseDate": "Izdano", - "releases": "Izdaja |||| Izdaje", - "released": "Izdano", - "recordLabel": "Založba", - "catalogNum": "Kataloška številka", - "releaseType": "Tip", - "grouping": "Grupiranje", - "media": "Medij", - "mood": "Razpoloženje", - "date": "Datum snemanja", - "missing": "Manjka", - "libraryName": "Knjižnica" - }, - "actions": { - "playAll": "Predvajaj vse", - "playNext": "Naslednji", - "addToQueue": "Predvajaj kasneje", - "shuffle": "Premešaj", - "addToPlaylist": "Dodaj v seznam predvajanja", - "download": "Naloži", - "info": "Več informacij", - "share": "Deli" - }, - "lists": { - "all": "Vse", - "random": "Naključno", - "recentlyAdded": "Dodan nedavno", - "recentlyPlayed": "Predvajan nedavno", - "mostPlayed": "Največ predvajano", - "starred": "Priljubljeni", - "topRated": "Najvišje ocenjeno" - } - }, - "artist": { - "name": "Izvajalec |||| Izvajalci", - "fields": { - "name": "Ime", - "albumCount": "# albumov", - "songCount": "# pesmi", - "playCount": "# predvajanj", - "rating": "Ocena", - "genre": "Žanr", - "size": "Velikost", - "role": "Vloga", - "missing": "Manjka" - }, - "roles": { - "albumartist": "Izvajalec albuma |||| Izvajalci albuma", - "artist": "Izvajalec |||| Izvajalci", - "composer": "Skladatelj |||| Skladatelji", - "conductor": "Dirigent |||| Dirigenti", - "lyricist": "Tekstopisec |||| Tekstopisci", - "arranger": "Aranžer |||| Aranžerji", - "producer": "Producent |||| Producenti", - "director": "Glasbeni vodja |||| Glasbene vodje", - "engineer": "Inženir |||| Inženirji", - "mixer": "Mešalec |||| Mešalci", - "remixer": "Remikser |||| Remikserji", - "djmixer": "DJ mešalec |||| DJ mešalci", - "performer": "Izvajalec |||| Izvajalci", - "maincredit": "Izvajalec albuma ali izvajalec |||| Izvajalci albuma ali izvajalci" - }, - "actions": { - "shuffle": "Naključno predvajanje", - "radio": "Radio", - "topSongs": "Najboljše pesmi" - } - }, - "user": { - "name": "Uporabnik |||| Uporabniki", - "fields": { - "userName": "Uporabnik", - "isAdmin": "Upravitelj", - "lastLoginAt": "Zadnji vpis", - "updatedAt": "Posodobljeno", - "name": "Ime", - "password": "Geslo", - "createdAt": "Ustvarjeno", - "changePassword": "Spremeni geslo?", - "currentPassword": "Trenutno geslo", - "newPassword": "Novo geslo", - "token": "Žeton", - "lastAccessAt": "Zadnji dostop", - "libraries": "Knjižnice" - }, - "helperTexts": { - "name": "Sprememba imena bo vidna pri naslednjem vpisu", - "libraries": "Izberite določene knjižnice za uporabnika ali pustite prazno, če želite uporabiti privzete knjižnice" - }, - "notifications": { - "created": "Uporabnik ustvarjen", - "updated": "Uporabnik posodobljen", - "deleted": "Uporabnik izbrisan" - }, - "message": { - "listenBrainzToken": "Vnesi žeton uporabnika ListenBrainz.", - "clickHereForToken": "Klikni za žeton", - "selectAllLibraries": "Izberi vse knjižnice", - "adminAutoLibraries": "Skrbniški uporabniki imajo samodejno dostop do vseh knjižnic" - }, - "validation": { - "librariesRequired": "Za uporabnike brez skrbniških pravic mora biti izbrana vsaj ena knjižnica" - } - }, - "player": { - "name": "Predvajalnik |||| Predvajalniki", - "fields": { - "name": "Naziv", - "transcodingId": "Transkodiranje", - "maxBitRate": "Maks. bitrate", - "client": "Klijent", - "userName": "Uporabnik", - "lastSeen": "Zadnjič viden", - "reportRealPath": "Zabeleži pravo pot", - "scrobbleEnabled": "Pošlji Scrobbles zunanjim storitvam" - } - }, - "transcoding": { - "name": "Transkodiranje |||| Transkodiranje", - "fields": { - "name": "Ime", - "targetFormat": "Ciljni format", - "defaultBitRate": "Privzet bitrate", - "command": "Ukaz" - } - }, - "playlist": { - "name": "Seznam predvajanj |||| Seznami predvajanj", - "fields": { - "name": "Ime", - "duration": "Dolžina", - "ownerName": "Lastnik", - "public": "Javno", - "updatedAt": "Posodobljen", - "createdAt": "Ustvarjen", - "songCount": "# pesmi", - "comment": "Opomba", - "sync": "Avtomatski uvoz", - "path": "Uvozi iz" - }, - "actions": { - "selectPlaylist": "Izberi seznam", - "addNewPlaylist": "Ustvari \"%{name}\"", - "export": "Izvozi", - "makePublic": "Naredi javno", - "makePrivate": "Naredi zasebno", - "saveQueue": "Shrani čakalno vrsto na seznam predvajanja", - "searchOrCreate": "Iščite po seznamih predvajanja ali vnesite besedilo, da ustvarite nove ...", - "pressEnterToCreate": "Pritisnite Enter za ustvarjanje novega seznama predvajanja", - "removeFromSelection": "Odstrani iz izbora" - }, - "message": { - "duplicate_song": "Dodaj podvojene pesmi", - "song_exist": "Seznamu predvajanja boste dodali duplikate. Jih želite dodati ali izpustiti?", - "noPlaylistsFound": "Ni najdenih seznamov predvajanja", - "noPlaylists": "Ni na voljo seznamov predvajanja" - } - }, - "radio": { - "name": "Radio |||| Radiji", - "fields": { - "name": "Ime", - "streamUrl": "URL toka", - "homePageUrl": "URL domače strani", - "updatedAt": "Posodobljeno ob", - "createdAt": "Ustvarjeno ob" - }, - "actions": { - "playNow": "Predvajaj" - } - }, - "share": { - "name": "Deli |||| Delitev", - "fields": { - "username": "Delil z", - "url": "URL", - "description": "Opis", - "contents": "Vsebine", - "expiresAt": "Poteče", - "lastVisitedAt": "Nazadnje obiskano", - "visitCount": "Obiski", - "format": "Oblika", - "maxBitRate": "Maks. bitna hitrost", - "updatedAt": "Posodobljeno ob", - "createdAt": "Ustvarjeno ob", - "downloadable": "Dovoli prenose?" - } - }, - "missing": { - "name": "Manjkajoča datoteka |||| Manjkajoče datoteke", - "fields": { - "path": "Pot", - "size": "Velikost", - "updatedAt": "Izginil", - "libraryName": "Knjižnica" - }, - "actions": { - "remove": "Odstrani", - "remove_all": "Odstrani vse" - }, - "notifications": { - "removed": "Manjkajoče datoteke odstranjene" - }, - "empty": "Brez manjkajočih datotek" - }, - "library": { - "name": "Knjižnica |||| Knjižnice", - "fields": { - "name": "Ime", - "path": "Pot", - "remotePath": "Oddaljena pot", - "lastScanAt": "Zadnje skeniranje", - "songCount": "Pesmi", - "albumCount": "Albumi", - "artistCount": "Umetniki", - "totalSongs": "Pesmi", - "totalAlbums": "Albumi", - "totalArtists": "Umetniki", - "totalFolders": "Mape", - "totalFiles": "Datoteke", - "totalMissingFiles": "Manjkajoče datoteke", - "totalSize": "Skupna velikost", - "totalDuration": "Trajanje", - "defaultNewUsers": "Privzeto za nove uporabnike", - "createdAt": "Ustvarjeno", - "updatedAt": "Posodobljeno" - }, - "sections": { - "basic": "Osnovne informacije", - "statistics": "Statistika" - }, - "actions": { - "scan": "Skeniraj knjižnico", - "manageUsers": "Upravljanje dostopa uporabnikov", - "viewDetails": "Ogled podrobnosti", - "quickScan": "Hitro skeniranje", - "fullScan": "Popolno skeniranje" - }, - "notifications": { - "created": "Knjižnica je uspešno ustvarjena", - "updated": "Knjižnica je bila uspešno posodobljena", - "deleted": "Knjižnica je uspešno izbrisana", - "scanStarted": "Skeniranje knjižnice se je začelo", - "scanCompleted": "Skeniranje knjižnice končano", - "quickScanStarted": "Hitro skeniranje se je začelo", - "fullScanStarted": "Popolno skeniranje se je začelo", - "scanError": "Napaka pri začetku skeniranja. Preverite dnevnike" - }, - "validation": { - "nameRequired": "Ime knjižnice je obvezno", - "pathRequired": "Pot do knjižnice je obvezna", - "pathNotDirectory": "Pot do knjižnice mora biti imenik", - "pathNotFound": "Pot do knjižnice ni bila najdena", - "pathNotAccessible": "Pot do knjižnice ni dostopna", - "pathInvalid": "Neveljavna pot do knjižnice" - }, - "messages": { - "deleteConfirm": "Ali ste prepričani, da želite izbrisati to knjižnico? S tem boste odstranili vse povezane podatke in dostop uporabnikov.", - "scanInProgress": "Skeniranje v teku...", - "noLibrariesAssigned": "Uporabnik nima dodeljenih knjižnic" - } - }, - "plugin": { - "name": "Vtičnik |||| Vtičniki", - "fields": { - "id": "ID", - "name": "Ime", - "description": "Opis", - "version": "Verzija", - "author": "Avtor", - "website": "Spletna stran", - "permissions": "Dovoljenja", - "enabled": "Vključeno", - "status": "Status", - "path": "Pot", - "lastError": "Napaka", - "hasError": "Napaka", - "updatedAt": "Posodobljeno", - "createdAt": "Inštalirano", - "configKey": "Ključ", - "configValue": "Vrednost", - "allUsers": "Dovoli vsem uporabnikom", - "selectedUsers": "Izbrani uporabniki", - "allLibraries": "Dovoli vse knjižnice", - "selectedLibraries": "Izbrane knjižnice", - "allowWriteAccess": "" - }, - "sections": { - "status": "Status", - "info": "Informacije o vtičniku", - "configuration": "Konfiguracija", - "manifest": "Manifest", - "usersPermission": "Uporabniška dovoljenja", - "libraryPermission": "Knjižnična dovoljenja" - }, - "status": { - "enabled": "Vključeno", - "disabled": "Izključeno" - }, - "actions": { - "enable": "Vključi", - "disable": "Izključi", - "disabledDueToError": "Popravi napako pred vključitvijo", - "disabledUsersRequired": "Izberi uporabnike pred vključitvijo", - "disabledLibrariesRequired": "Izberi knjižnice pred vključitvijo", - "addConfig": "Dodaj konfiguracijo", - "rescan": "Ponovi skeniranje" - }, - "notifications": { - "enabled": "Vtičnik vključen", - "disabled": "Vtičnik izključen", - "updated": "Vtičnik posodobljen", - "error": "Napaka pri posodobitvi vtičnika" - }, - "validation": { - "invalidJson": "Konfiguracija mora biti pravilen JSON" - }, - "messages": { - "configHelp": "Konfiguriraj vtičnik z uporabo key-value parov. Pusti prazno, če vtičnik ne potrebuje konfiguracije.", - "clickPermissions": "Klikni za dovoljenje o podrobnostih", - "noConfig": "Konfiguracija ni nastavljena", - "allUsersHelp": "Ko vključeno, bo vtičnik imel dostop do vseh uporabnikov, tudi prihodnjih.", - "noUsers": "Uporabniki niso izbrani", - "permissionReason": "Razlog", - "usersRequired": "Vtičnik potrebuje dostop do uporabnikovih informacij. Izberi uporabnike ali vključi dostop vsem uporabnikom.", - "allLibrariesHelp": "Ko vključeno, bo vtičnik imel dostop do vseh knjižnic, tudi prihodnjih.", - "noLibraries": "Ni izbranih knjižnic", - "librariesRequired": "Vtičnik zahteva dostop do knjižnih informacij. Izberi do katerih knjižnic lahko dostopa, ali vključi dostop do vseh knjižnic.", - "requiredHosts": "Zahtevani gostitelji", - "configValidationError": "Validacija konfiguracije neuspešna:", - "schemaRenderError": "Konfiguracijskega obrazca ni mogoče upodobiti. Shema vtičnika je morda neveljavna.", - "allowWriteAccessHelp": "" - }, - "placeholders": { - "configKey": "ključ", - "configValue": "vrednost" - } - } - }, - "ra": { - "auth": { - "welcome1": "Hvala, da ste naložili Navidrome!", - "welcome2": "Za začetek, ustvarite upraviteljski račun", - "confirmPassword": "Potrdi Geslo", - "buttonCreateAdmin": "Ustvari upravitelja", - "auth_check_error": "Vpišite se za nadaljevanje", - "user_menu": "Profil", - "username": "Uporabnik", - "password": "Geslo", - "sign_in": "Vpis", - "sign_in_error": "Avtentikacija neuspešna, poskusite ponovno", - "logout": "Izpis", - "insightsCollectionNote": "Navidrome zbira anonimne podatke o uporabi \nz namenom izboljšanja projekta. \nKliknite [tukaj], če želite izvedeti več ali se odjaviti" - }, - "validation": { - "invalidChars": "Uporabi samo alfanumerične znake", - "passwordDoesNotMatch": "Geslo se ne ujema", - "required": "Potreben", - "minLength": "Potrebnih je vsaj %{min} znakov", - "maxLength": "Potrebnih je največ %{max}", - "minValue": "Potrebnih je vsaj %{min}", - "maxValue": "Potrebnih je največ %{max}", - "number": "Mora biti številka", - "email": "Veljaven e-poštni naslov", - "oneOf": "Mora biti ena izmed %{options}", - "regex": "Mora se ujemati z določeno obliko (regexp): %{pattern}", - "unique": "Mora biti edinstven", - "url": "Biti mora veljaven URL" - }, - "action": { - "add_filter": "Dodaj filter", - "add": "Dodaj", - "back": "Nazaj", - "bulk_actions": "Izbran 1 element |||| Izbranih %{smart_count} elementov", - "cancel": "Prekliči", - "clear_input_value": "Pobriši", - "clone": "Podvoji", - "confirm": "Potrdi", - "create": "Ustvari", - "delete": "Izbriši", - "edit": "Uredi", - "export": "Izvozi", - "list": "Seznam", - "refresh": "Osveži", - "remove_filter": "Odstrani filter", - "remove": "Odstrani", - "save": "Shrani", - "search": "Išči", - "show": "Prikaži", - "sort": "Razvrsti", - "undo": "Razveljavi", - "expand": "Razširi", - "close": "Zapri", - "open_menu": "Odpri meni", - "close_menu": "Zapri meni", - "unselect": "Prekliči izbiro", - "skip": "Izpusti", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Deli", - "download": "Prenesi" - }, - "boolean": { - "true": "Da", - "false": "Ne" - }, - "page": { - "create": "Ustvari %{name}", - "dashboard": "Nadzorna plošča", - "edit": "%{name} #%{id}", - "error": "Nedoločena napaka", - "list": "%{name}", - "loading": "Nalagam", - "not_found": "Ni zadetka", - "show": "%{name} #%{id}", - "empty": "Še brez %{name}.", - "invite": "Ga želite dodati?" - }, - "input": { - "file": { - "upload_several": "Povlecite datoteke ali pa kliknite in izberite.", - "upload_single": "Povlecite datoteko ali pa kliknite in izberite." - }, - "image": { - "upload_several": "Povlecite slike, ali pa kliknite in izberite.", - "upload_single": "Povlecite sliko, ali pa kliknite in izberite." - }, - "references": { - "all_missing": "Ne najdem referenciranih podatkov.", - "many_missing": "Zdi se, da vsaj ena asociirana referenca ni več na voljo.", - "single_missing": "Zdi se, da asociirana referenca ni več na voljo." - }, - "password": { - "toggle_visible": "Skrij geslo", - "toggle_hidden": "Prikaži geslo" - } + "ra": { + "auth": { + "welcome1": "Hvala, da ste naložili Navidrome!", + "welcome2": "Za začetek, ustvarite upraviteljski račun", + "confirmPassword": "Potrdi Geslo", + "buttonCreateAdmin": "Ustvari upravitelja", + "auth_check_error": "Vpišite se za nadaljevanje", + "user_menu": "Profil", + "username": "Uporabnik", + "password": "Geslo", + "sign_in": "Vpis", + "sign_in_error": "Avtentikacija neuspešna, poskusite ponovno", + "logout": "Izpis" + }, + "validation": { + "invalidChars": "Uporabi samo alfanumerične znake", + "passwordDoesNotMatch": "Geslo se ne ujema", + "required": "Potreben", + "minLength": "Potrebnih je vsaj %{min} znakov", + "maxLength": "Potrebnih je največ %{max}", + "minValue": "Potrebnih je vsaj %{min}", + "maxValue": "Potrebnih je največ %{max}", + "number": "Mora biti številka", + "email": "Veljaven e-poštni naslov", + "oneOf": "Mora biti ena izmed %{options}", + "regex": "Mora se ujemati z določeno obliko (regexp): %{pattern}", + "unique": "Mora biti edinstven", + "url": "Biti mora veljaven URL" + }, + "action": { + "add_filter": "Dodaj filter", + "add": "Dodaj", + "back": "Nazaj", + "bulk_actions": "Izbran 1 element |||| Izbranih %{smart_count} elementov", + "cancel": "Prekliči", + "clear_input_value": "Pobriši", + "clone": "Podvoji", + "confirm": "Potrdi", + "create": "Ustvari", + "delete": "Izbriši", + "edit": "Uredi", + "export": "Izvozi", + "list": "Seznam", + "refresh": "Osveži", + "remove_filter": "Odstrani filter", + "remove": "Odstrani", + "save": "Shrani", + "search": "Išči", + "show": "Prikaži", + "sort": "Razvrsti", + "undo": "Razveljavi", + "expand": "Razširi", + "close": "Zapri", + "open_menu": "Odpri meni", + "close_menu": "Zapri meni", + "unselect": "Prekliči izbiro", + "skip": "Izpusti", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Deli", + "download": "Prenesi" + }, + "boolean": { + "true": "Da", + "false": "Ne" + }, + "page": { + "create": "Ustvari %{name}", + "dashboard": "Nadzorna plošča", + "edit": "%{name} #%{id}", + "error": "Nedoločena napaka", + "list": "%{name}", + "loading": "Nalagam", + "not_found": "Ni zadetka", + "show": "%{name} #%{id}", + "empty": "Še brez %{name}.", + "invite": "Ga želite dodati?" + }, + "input": { + "file": { + "upload_several": "Povlecite datoteke ali pa kliknite in izberite.", + "upload_single": "Povlecite datoteko ali pa kliknite in izberite." + }, + "image": { + "upload_several": "Povlecite slike, ali pa kliknite in izberite.", + "upload_single": "Povlecite sliko, ali pa kliknite in izberite." + }, + "references": { + "all_missing": "Ne najdem referenciranih podatkov.", + "many_missing": "Zdi se, da vsaj ena asociirana referenca ni več na voljo.", + "single_missing": "Zdi se, da asociirana referenca ni več na voljo." + }, + "password": { + "toggle_visible": "Skrij geslo", + "toggle_hidden": "Prikaži geslo" + } + }, + "message": { + "about": "O programu", + "are_you_sure": "Ste prepričani?", + "bulk_delete_content": "Ste prepričani, da želite izbrisati %{name}? |||| Ste prepričani, da želite izbrisati %{smart_count} elementov?", + "bulk_delete_title": "Izbriši %{name} |||| Izbriši %{smart_count} %{name}", + "delete_content": "Ste prepričani, da želite izbrisati ta element?", + "delete_title": "Izbriši %{name} #%{id}", + "details": "Podrobnosti", + "error": "Napak klijenta. Vaš zahtevek se je zaključil neuspešno.", + "invalid_form": "Oblika ni veljavna. Prosim preverite napake", + "loading": "Stran se nalaga, trenutek", + "no": "Ne", + "not_found": "Ali ste vtipkali napačen naslov (URL), ali pa sledili neobstoječi povezavi.", + "yes": "Da", + "unsaved_changes": "Nekate spremembe se niso shranile. Ste prepričani, da jih želite ignorirati?" + }, + "navigation": { + "no_results": "Ni zadetkov", + "no_more_results": "Številka strani %{page} je zunaj meja. Preizkusite prejšnjo stran.", + "page_out_of_boundaries": "Številka strani %{page} je zunaj meja", + "page_out_from_end": "Ne gre dalje od zadnje strani", + "page_out_from_begin": "Ne gre pred prvo stran", + "page_range_info": "%{offsetBegin}-%{offsetEnd} od %{total}", + "page_rows_per_page": "Elementov na stran:", + "next": "Naslednji", + "prev": "Prejšnji", + "skip_nav": "Preskoči k vsebini" + }, + "notification": { + "updated": "Element posodobljen |||| Posodobljenih %{smart_count} elementov", + "created": "Element dodan", + "deleted": "Element izbrisan |||| %{smart_count} elementov izbrisanih", + "bad_item": "Nepravilen element", + "item_doesnt_exist": "Element ne obstaja", + "http_error": "Strežnika napaka v komunikaciji", + "data_provider_error": "Napaka dataProvider error. Preverite konzolo za podrobnosti.", + "i18n_error": "Ne uspem naložiti prevode za izbran jezik", + "canceled": "Akcija preklicana", + "logged_out": "Seja je potekla, prosim povežite se ponovno.", + "new_version": "Na voljo je nova verzija! Prosim osvežite okno." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Prikaži stolpce", + "layout": "Razporeditev", + "grid": "Mreža", + "table": "Tabela" + } }, "message": { - "about": "O programu", - "are_you_sure": "Ste prepričani?", - "bulk_delete_content": "Ste prepričani, da želite izbrisati %{name}? |||| Ste prepričani, da želite izbrisati %{smart_count} elementov?", - "bulk_delete_title": "Izbriši %{name} |||| Izbriši %{smart_count} %{name}", - "delete_content": "Ste prepričani, da želite izbrisati ta element?", - "delete_title": "Izbriši %{name} #%{id}", - "details": "Podrobnosti", - "error": "Napak klijenta. Vaš zahtevek se je zaključil neuspešno.", - "invalid_form": "Oblika ni veljavna. Prosim preverite napake", - "loading": "Stran se nalaga, trenutek", - "no": "Ne", - "not_found": "Ali ste vtipkali napačen naslov (URL), ali pa sledili neobstoječi povezavi.", - "yes": "Da", - "unsaved_changes": "Nekate spremembe se niso shranile. Ste prepričani, da jih želite ignorirati?" - }, - "navigation": { - "no_results": "Ni zadetkov", - "no_more_results": "Številka strani %{page} je zunaj meja. Preizkusite prejšnjo stran.", - "page_out_of_boundaries": "Številka strani %{page} je zunaj meja", - "page_out_from_end": "Ne gre dalje od zadnje strani", - "page_out_from_begin": "Ne gre pred prvo stran", - "page_range_info": "%{offsetBegin}-%{offsetEnd} od %{total}", - "page_rows_per_page": "Elementov na stran:", - "next": "Naslednji", - "prev": "Prejšnji", - "skip_nav": "Preskoči k vsebini" - }, - "notification": { - "updated": "Element posodobljen |||| Posodobljenih %{smart_count} elementov", - "created": "Element dodan", - "deleted": "Element izbrisan |||| %{smart_count} elementov izbrisanih", - "bad_item": "Nepravilen element", - "item_doesnt_exist": "Element ne obstaja", - "http_error": "Strežnika napaka v komunikaciji", - "data_provider_error": "Napaka dataProvider error. Preverite konzolo za podrobnosti.", - "i18n_error": "Ne uspem naložiti prevode za izbran jezik", - "canceled": "Akcija preklicana", - "logged_out": "Seja je potekla, prosim povežite se ponovno.", - "new_version": "Na voljo je nova verzija! Prosim osvežite okno." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Prikaži stolpce", - "layout": "Razporeditev", - "grid": "Mreža", - "table": "Tabela" - } - }, - "message": { - "note": "OPOMBA", - "transcodingDisabled": "Sprememba konfiguracije transkodiranja skozi spletni vmesnik je onemogočeno zaradi varnostnih razlogov. Če želite spremeniti (urediti ali izbrisati) možnosti transkodiranja, ponovno zaženite strežnik z %{config} nastavitvami.", - "transcodingEnabled": "Navidrome trenutno uporablja nastavitve %{config}, kar pomeni da je možno pognati sistemske ukaze v nastavitvah transkodiranja preko spletnega vmesnika.\nZaradi varnostnih razlogov je možnost priporočeno onemogočiti , razen v primeru spreminjanja nastavitev.", - "songsAddedToPlaylist": "Dodaj pesem na seznam predvajanj |||| Dodaj %{smart_count} pesmi na seznam predvajanj", - "noPlaylistsAvailable": "Ni seznamov", - "delete_user_title": "Odstrani uporabnika '%{name}'", - "delete_user_content": "Ste prepričani o izbrisu uporabnika, vključno z njegovimi podatki (tudi seznami predvajanj in nastavitvami)?", - "notifications_blocked": "V vašem brskljalniku Imate blokirana možnost obvestil za to spletno stran", - "notifications_not_available": "Vaš brskljalnik ne omogoča obvestil na namizju ali pa do Navidrome ne dostopate po varni povezavi (https)", - "lastfmLinkSuccess": "Last.fm uspešno povezan in 'scrobbling' omogočen", - "lastfmLinkFailure": "Last.fm ni uspešno povezan", - "lastfmUnlinkSuccess": "Last.fm povezava prekinjena in 'scrobbling' onemogočen", - "lastfmUnlinkFailure": "Last.fm povezava neuspešno prekinjena", - "openIn": { - "lastfm": "Odpri v Last.fm", - "musicbrainz": "Odpri v MusicBrainz" - }, - "lastfmLink": "Preberi več...", - "listenBrainzLinkSuccess": "ListenBrainz uspešno povezan in scrobbling vključen za uporabnika: %{user}", - "listenBrainzLinkFailure": "ListBrainz neuspešno povezan: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz povezava prekinjena in scrobbling izključen", - "listenBrainzUnlinkFailure": "ListenBrainz prekinitev povezave neuspešna", - "downloadOriginalFormat": "Prenesi v izvirni obliki", - "shareOriginalFormat": "Deli v izvirni obliki", - "shareDialogTitle": "Deli %{resource} '%{name}'", - "shareBatchDialogTitle": "Deli 1 %{resource} |||| Deli %{smart_count} %{resource}", - "shareSuccess": "URL kopiran v odložišče: %{url}", - "shareFailure": "Napaka pri kopiranju URL-ja %{url} v odložišče", - "downloadDialogTitle": "Prenesi %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Kopiraj v odložišče: Ctrl+C, Enter", - "remove_missing_title": "Odstrani manjkajoče datoteke", - "remove_missing_content": "Ste prepričani, da želite odstraniti izbrane manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.", - "remove_all_missing_title": "Odstrani vse manjkajoče datoteke", - "remove_all_missing_content": "Ste prepričani, da želite odstraniti vse manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.", - "noSimilarSongsFound": "Ni najdenih podobnih pesmi", - "noTopSongsFound": "Ni najdenih najboljših pesmi", - "startingInstantMix": "Nalaganje Instant Mix..." - }, - "menu": { - "library": "Knjižnica", - "settings": "Nastavitve", - "version": "Različica", - "theme": "Tema", - "personal": { - "name": "Osebno", - "options": { - "theme": "Tema", - "language": "Jezik", - "defaultView": "Privzet pogled", - "desktop_notifications": "Namizna obvestila", - "lastfmScrobbling": "'Scrobble' do Last.fm", - "listenBrainzScrobbling": "Scrobble k ListenBrainz", - "replaygain": "ReplayGain način", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "Onemogočeno", - "album": "Uporabi Album Gain", - "track": "Uporabi Track Gain" + "note": "OPOMBA", + "transcodingDisabled": "Sprememba konfiguracije transkodiranja skozi spletni vmesnik je onemogočeno zaradi varnostnih razlogov. Če želite spremeniti (urediti ali izbrisati) možnosti transkodiranja, ponovno zaženite strežnik z %{config} nastavitvami.", + "transcodingEnabled": "Navidrome trenutno uporablja nastavitve %{config}, kar pomeni da je možno pognati sistemske ukaze v nastavitvah transkodiranja preko spletnega vmesnika.\nZaradi varnostnih razlogov je možnost priporočeno onemogočiti , razen v primeru spreminjanja nastavitev.", + "songsAddedToPlaylist": "Dodaj pesem na seznam predvajanj |||| Dodaj %{smart_count} pesmi na seznam predvajanj", + "noPlaylistsAvailable": "Ni seznamov", + "delete_user_title": "Odstrani uporabnika '%{name}'", + "delete_user_content": "Ste prepričani o izbrisu uporabnika, vključno z njegovimi podatki (tudi seznami predvajanj in nastavitvami)?", + "notifications_blocked": "V vašem brskljalniku Imate blokirana možnost obvestil za to spletno stran", + "notifications_not_available": "Vaš brskljalnik ne omogoča obvestil na namizju ali pa do Navidrome ne dostopate po varni povezavi (https)", + "lastfmLinkSuccess": "Last.fm uspešno povezan in 'scrobbling' omogočen", + "lastfmLinkFailure": "Last.fm ni uspešno povezan", + "lastfmUnlinkSuccess": "Last.fm povezava prekinjena in 'scrobbling' onemogočen", + "lastfmUnlinkFailure": "Last.fm povezava neuspešno prekinjena", + "openIn": { + "lastfm": "Odpri v Last.fm", + "musicbrainz": "Odpri v MusicBrainz" }, - "lastfmNotConfigured": "Last.fm API ključ ni konfiguriran" - } + "lastfmLink": "Preberi več...", + "listenBrainzLinkSuccess": "ListenBrainz uspešno povezan in scrobbling vključen za uporabnika: %{user}", + "listenBrainzLinkFailure": "ListBrainz neuspešno povezan: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz povezava prekinjena in scrobbling izključen", + "listenBrainzUnlinkFailure": "ListenBrainz prekinitev povezave neuspešna", + "downloadOriginalFormat": "Prenesi v izvirni obliki", + "shareOriginalFormat": "Deli v izvirni obliki", + "shareDialogTitle": "Deli %{resource} '%{name}'", + "shareBatchDialogTitle": "Deli 1 %{resource} |||| Deli %{smart_count} %{resource}", + "shareSuccess": "URL kopiran v odložišče: %{url}", + "shareFailure": "Napaka pri kopiranju URL-ja %{url} v odložišče", + "downloadDialogTitle": "Prenesi %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "" }, - "albumList": "Albumi", - "about": "O programu", - "playlists": "Seznami predvajanj", - "sharedPlaylists": "Deljeni seznami predvajanj", - "librarySelector": { - "allLibraries": "Vse knjižnice (%{count})", - "multipleLibraries": "%{selected} od %{total} knjižnic", - "selectLibraries": "Izberite knjižnice", - "none": "Nobena" - } - }, - "player": { - "playListsText": "Predvajaj vrsto", - "openText": "Odpri", - "closeText": "Zapri", - "notContentText": "Ni glasbe", - "clickToPlayText": "Predvajaj", - "clickToPauseText": "Premor predvajanja", - "nextTrackText": "Naslednje predvajanje", - "previousTrackText": "Prejšnji", - "reloadText": "Ponovno naloži", - "volumeText": "Glasnost", - "toggleLyricText": "Preklopi besedila", - "toggleMiniModeText": "Pomanjšaj", - "destroyText": "Uniči", - "downloadText": "Naloži", - "removeAudioListsText": "Izbriši avdio seznam", - "clickToDeleteText": "Klikni za izbris %{name}", - "emptyLyricText": "Ni besedila", - "playModeText": { - "order": "Po vrsti", - "orderLoop": "Ponavljaj", - "singleLoop": "Ponovi enkrat", - "shufflePlay": "Premešaj" - } - }, - "about": { - "links": { - "homepage": "Domača stran", - "source": "Izvorna koda", - "featureRequests": "Funkcionalni zahtevki", - "lastInsightsCollection": "Zbirka zadnjih vpogledov", - "insights": { - "disabled": "Onemogočeno", - "waiting": "Čakanje" - } + "menu": { + "library": "Knjižnica", + "settings": "Nastavitve", + "version": "Različica", + "theme": "Tema", + "personal": { + "name": "Osebno", + "options": { + "theme": "Tema", + "language": "Jezik", + "defaultView": "Privzet pogled", + "desktop_notifications": "Namizna obvestila", + "lastfmScrobbling": "'Scrobble' do Last.fm", + "listenBrainzScrobbling": "Scrobble k ListenBrainz", + "replaygain": "ReplayGain način", + "preAmp": "ReplayGain PreAmp (dB)", + "gain": { + "none": "Onemogočeno", + "album": "Uporabi Album Gain", + "track": "Uporabi Track Gain" + } + } + }, + "albumList": "Albumi", + "about": "O programu", + "playlists": "Seznami predvajanj", + "sharedPlaylists": "Deljeni seznami predvajanj" }, - "tabs": { - "about": "O nas", - "config": "Konfiguracija" + "player": { + "playListsText": "Predvajaj vrsto", + "openText": "Odpri", + "closeText": "Zapri", + "notContentText": "Ni glasbe", + "clickToPlayText": "Predvajaj", + "clickToPauseText": "Premor predvajanja", + "nextTrackText": "Naslednje predvajanje", + "previousTrackText": "Prejšnji", + "reloadText": "Ponovno naloži", + "volumeText": "Glasnost", + "toggleLyricText": "Preklopi besedila", + "toggleMiniModeText": "Pomanjšaj", + "destroyText": "Uniči", + "downloadText": "Naloži", + "removeAudioListsText": "Izbriši avdio seznam", + "clickToDeleteText": "Klikni za izbris %{name}", + "emptyLyricText": "Ni besedila", + "playModeText": { + "order": "Po vrsti", + "orderLoop": "Ponavljaj", + "singleLoop": "Ponovi enkrat", + "shufflePlay": "Premešaj" + } }, - "config": { - "configName": "Ime konfiguracije", - "environmentVariable": "Spremenljivka okolja", - "currentValue": "Trenutna vrednost", - "configurationFile": "Konfiguracijska datoteka", - "exportToml": "Izvozi konfiguracijo (TOML)", - "exportSuccess": "Konfiguracija izvožena v odložišče v formatu TOML", - "exportFailed": "Kopiranje konfiguracije ni uspelo", - "devFlagsHeader": "Razvojne zastavice (lahko se spremenijo/odstranijo)", - "devFlagsComment": "To so eksperimentalne nastavitve in bodo morda odstranjene v prihodnjih različicah", - "downloadToml": "Naloži konfiguracijo (TOML)" + "about": { + "links": { + "homepage": "Domača stran", + "source": "Izvorna koda", + "featureRequests": "Funkcionalni zahtevki" + } + }, + "activity": { + "title": "Aktivnost", + "totalScanned": "Skupaj preiskanih map", + "quickScan": "Hitro preišči", + "fullScan": "Polno preišči", + "serverUptime": "Čas delovanja", + "serverDown": "NEPOVEZAN" + }, + "help": { + "title": "Hitre tipke", + "hotkeys": { + "show_help": "Prikaži pomoč", + "toggle_menu": "Preklopi stransko vrstico menija", + "toggle_play": "Predvajaj / Pavza", + "prev_song": "Prejšnja", + "next_song": "Naslednja", + "vol_up": "Zvišaj glasnost", + "vol_down": "Znižaj glasnost", + "toggle_love": "Dodaj med priljubljene", + "current_song": "Skoči na predvajano" + } } - }, - "activity": { - "title": "Aktivnost", - "totalScanned": "Skupaj preiskanih map", - "quickScan": "Hitro preišči", - "fullScan": "Polno preišči", - "serverUptime": "Čas delovanja", - "serverDown": "NEPOVEZAN", - "scanType": "Tip", - "status": "Napaka pri skeniranju", - "elapsedTime": "Pretečeni čas", - "selectiveScan": "Selektivno" - }, - "help": { - "title": "Hitre tipke", - "hotkeys": { - "show_help": "Prikaži pomoč", - "toggle_menu": "Preklopi stransko vrstico menija", - "toggle_play": "Predvajaj / Pavza", - "prev_song": "Prejšnja", - "next_song": "Naslednja", - "vol_up": "Zvišaj glasnost", - "vol_down": "Znižaj glasnost", - "toggle_love": "Dodaj med priljubljene", - "current_song": "Skoči na predvajano" - } - }, - "nowPlaying": { - "title": "Zdaj se predvaja", - "empty": "Nič se ne predvaja", - "minutesAgo": "Pred %{smart_count} minuto |||| Pred %{smart_count} minutami" - } -} +} \ No newline at end of file diff --git a/resources/i18n/sr.json b/resources/i18n/sr.json deleted file mode 100644 index cf0fc5d2f..000000000 --- a/resources/i18n/sr.json +++ /dev/null @@ -1,725 +0,0 @@ -{ - "languageName": "српски", - "resources": { - "song": { - "name": "Песма |||| Песме", - "fields": { - "albumArtist": "Уметник албума", - "duration": "Трајање", - "trackNumber": "#", - "playCount": "Пуштано", - "title": "Наслов", - "artist": "Уметник", - "composer": "Композитор", - "album": "Албум", - "path": "Путања фајла", - "libraryName": "Библиотека", - "genre": "Жанр", - "compilation": "Компилација", - "year": "Година", - "size": "Величина фајла", - "updatedAt": "Ажурирано", - "bitRate": "Битски проток", - "bitDepth": "Битска дубина", - "sampleRate": "Учестаност узорковања", - "albumGain": "Појачање албума", - "trackGain": "Појачање нумере", - "channels": "Канали", - "disc": "Диск %{discNumber}", - "discSubtitle": "Поднаслов диска", - "starred": "Омиљено", - "comment": "Коментар", - "rating": "Рејтинг", - "quality": "Квалитет", - "bpm": "BPM", - "playDate": "Последње пуштано", - "createdAt": "Датум додавања", - "grouping": "Груписање", - "mood": "Расположење", - "participants": "Додатни учесници", - "tags": "Додатне ознаке", - "mappedTags": "Мапиране ознаке", - "rawTags": "Сирове ознаке", - "missing": "Недостаје" - }, - "actions": { - "addToQueue": "Пусти касније", - "playNow": "Пусти одмах", - "addToPlaylist": "Додај у плејлисту", - "showInPlaylist": "Прикажи у плејлисти", - "shuffleAll": "Измешај све", - "download": "Преузми", - "playNext": "Пусти наредно", - "info": "Прикажи инфо", - "instantMix": "Инстант микс" - } - }, - "album": { - "name": "Албум |||| Албуми", - "fields": { - "albumArtist": "Уметник албума", - "artist": "Уметник", - "duration": "Трајање", - "songCount": "Песме", - "playCount": "Пуштано", - "size": "Величина", - "name": "Назив", - "libraryName": "Библиотека", - "genre": "Жанр", - "compilation": "Компилација", - "year": "Година", - "date": "Датум снимања", - "originalDate": "Оригинално", - "releaseDate": "Објављено", - "releases": "Издање|||| Издања", - "released": "Објављено", - "updatedAt": "Ажурирано", - "comment": "Коментар", - "rating": "Рејтинг", - "createdAt": "Датум додавања", - "recordLabel": "Издавачка кућа", - "catalogNum": "Каталошки број", - "releaseType": "Тип", - "grouping": "Груписање", - "media": "Медијум", - "mood": "Расположење", - "missing": "Недостаје" - }, - "actions": { - "playAll": "Пусти", - "playNext": "Пусти наредно", - "addToQueue": "Пусти касније", - "share": "Дели", - "shuffle": "Измешај", - "addToPlaylist": "Додај у плејлисту", - "download": "Преузми", - "info": "Прикажи инфо" - }, - "lists": { - "all": "Све", - "random": "Насумично", - "recentlyAdded": "Додато недавно", - "recentlyPlayed": "Пуштано недавно", - "mostPlayed": "Најчешће пуштано", - "starred": "Омиљено", - "topRated": "Најбоље рангирано" - } - }, - "artist": { - "name": "Уметник |||| Уметници", - "fields": { - "name": "Назив", - "albumCount": "Број албума", - "songCount": "Број песама", - "size": "Величина", - "playCount": "Пуштано", - "rating": "Рејтинг", - "genre": "Жанр", - "role": "Улога", - "missing": "Недостаје" - }, - "roles": { - "albumartist": "Уметник албума |||| Уметници албума", - "artist": "Уметник |||| Уметници", - "composer": "Композитор |||| Композитори", - "conductor": "Диригент |||| Диригенти", - "lyricist": "Текстописац |||| Текстописци", - "arranger": "Аранжер |||| Аранжери", - "producer": "Продуцент |||| Продуценти", - "director": "Режисер |||| Режисери", - "engineer": "Инжењер |||| Инжењери", - "mixer": "Миксер |||| Миксери", - "remixer": "Ремиксер |||| Ремиксери", - "djmixer": "Ди-џеј миксер |||| Ди-џеј миксер", - "performer": "Извођач |||| Извођачи", - "maincredit": "Уметник албума или уметник |||| Уметници албума или уметници" - }, - "actions": { - "topSongs": "Најбоље песме", - "shuffle": "Измешај", - "radio": "Радио" - } - }, - "user": { - "name": "Корисник |||| Корисници", - "fields": { - "userName": "Корисничко име", - "isAdmin": "Да ли је Админ", - "lastLoginAt": "Последња пријава", - "lastAccessAt": "Последњи приступ", - "updatedAt": "Ажурирано", - "name": "Назив", - "password": "Лозинка", - "createdAt": "Креирана", - "changePassword": "Измени лозинку?", - "currentPassword": "Текућа лозинка", - "newPassword": "Нова лозинка", - "token": "Жетон", - "libraries": "Библиотеке" - }, - "helperTexts": { - "name": "Измене вашег имена ће постати видљиве након следеће пријаве", - "libraries": "Изаберите одређене библиотеке за овог корисника, или оставите празно да се користе подразумеване библиотеке" - }, - "notifications": { - "created": "Корисник креиран", - "updated": "Корисник ажуриран", - "deleted": "Корисник обрисан" - }, - "validation": { - "librariesRequired": "Барем једна библиотека мора да буде изабрана за кориснике који нису администратори" - }, - "message": { - "listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон.", - "clickHereForToken": "Кликните овде да преузмете свој жетон", - "selectAllLibraries": "Изабери све библиотеке", - "adminAutoLibraries": "Администратори аутоматски имају приступ свим библиотекама" - } - }, - "player": { - "name": "Плејер |||| Плејери", - "fields": { - "name": "Назив", - "transcodingId": "Транскодирање", - "maxBitRate": "Макс. битски проток", - "client": "Клијент", - "userName": "Корисничко име", - "lastSeen": "Последњи пут виђен", - "reportRealPath": "Пријављуј реалну путању", - "scrobbleEnabled": "Шаљи скроблове на спољне сервисе" - } - }, - "transcoding": { - "name": "Транскодирање |||| Транскодирања", - "fields": { - "name": "Назив", - "targetFormat": "Циљни формат", - "defaultBitRate": "Подразумевани битски проток", - "command": "Команда" - } - }, - "playlist": { - "name": "Плејлиста |||| Плејлисте", - "fields": { - "name": "Назив", - "duration": "Трајање", - "ownerName": "Власник", - "public": "Јавна", - "updatedAt": "Ажурирано", - "createdAt": "Креирана", - "songCount": "Песме", - "comment": "Коментар", - "sync": "Ауто-увоз", - "path": "Увоз из" - }, - "actions": { - "selectPlaylist": "Изабери плејлисту", - "addNewPlaylist": "Креирај „%{name}”", - "export": "Извези", - "saveQueue": "Сачувај ред у плејлисту", - "makePublic": "Учини јавном", - "makePrivate": "Учини приватном", - "searchOrCreate": "Претражите плејлисте или унесите назив за нову…", - "pressEnterToCreate": "Притисните Ентер да креирате нову плејлисту", - "removeFromSelection": "Уклони из избора" - }, - "message": { - "duplicate_song": "Додај дуплиране песме", - "song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?", - "noPlaylistsFound": "Нема пронађених плејлиста", - "noPlaylists": "Нема доступних плејлиста" - } - }, - "radio": { - "name": "Радио |||| Радио-станице", - "fields": { - "name": "Назив", - "streamUrl": "URL тока", - "homePageUrl": "URL почетне странице", - "updatedAt": "Ажурирано", - "createdAt": "Креирана" - }, - "actions": { - "playNow": "Пусти одмах" - } - }, - "share": { - "name": "Дељење |||| Дељења", - "fields": { - "username": "Поделио", - "url": "URL", - "description": "Опис", - "downloadable": "Допушта се преузимање?", - "contents": "Садржај", - "expiresAt": "Истиче", - "lastVisitedAt": "Последњи пут посећено", - "visitCount": "Број посета", - "format": "Формат", - "maxBitRate": "Макс. битски проток", - "updatedAt": "Ажурирано", - "createdAt": "Креирано" - }, - "notifications": {}, - "actions": {} - }, - "missing": { - "name": "Фајл који недостаје|||| Фајлови који недостају", - "empty": "Нема фајлова који недостају", - "fields": { - "path": "Путања", - "size": "Величина", - "libraryName": "Библиотека", - "updatedAt": "Нестао дана" - }, - "actions": { - "remove": "Уклони", - "remove_all": "Уклони све" - }, - "notifications": { - "removed": "Фајл који недостаје, или више њих, је уклоњен" - } - }, - "library": { - "name": "Библиотека |||| Библиотеке", - "fields": { - "name": "Назив", - "path": "Путања", - "remotePath": "Удаљена путања", - "lastScanAt": "Последње скенирање", - "songCount": "Песме", - "albumCount": "Албуми", - "artistCount": "Уметници", - "totalSongs": "Песме", - "totalAlbums": "Албуми", - "totalArtists": "Уметници", - "totalFolders": "Фасцикле", - "totalFiles": "Фајлови", - "totalMissingFiles": "Фајлови који недостају", - "totalSize": "Укупна величина", - "totalDuration": "Трајање", - "defaultNewUsers": "Подразумевано за нове кориснике", - "createdAt": "Креирана", - "updatedAt": "Ажурирана" - }, - "sections": { - "basic": "Основне информације", - "statistics": "Статистика" - }, - "actions": { - "scan": "Скенирај библиотеку", - "quickScan": "Брзо скенирање", - "fullScan": "Комплетно скенирање", - "manageUsers": "Управљај приступом корисника", - "viewDetails": "Прикажи детаље" - }, - "notifications": { - "created": "Библиотека је успешно креирана", - "updated": "Библиотека је успешно ажурирана", - "deleted": "Библиотека је успешно обрисана", - "scanStarted": "Скенирање библиотеке је покренуто", - "quickScanStarted": "Брзо скенирање је покренуто", - "fullScanStarted": "Комплетно скенирање је покренуто", - "scanError": "Грешка при покретању скенирања. Проверите дневнике.", - "scanCompleted": "Скенирање библиотеке је завршено" - }, - "validation": { - "nameRequired": "Назив библиотеке је обавезан", - "pathRequired": "Путања библиотеке је обавезна", - "pathNotDirectory": "Путања библиотеке мора да буде фасцикла", - "pathNotFound": "Путања библиотеке није пронађена", - "pathNotAccessible": "Путања библиотеке није доступна", - "pathInvalid": "Неисправна путања библиотеке" - }, - "messages": { - "deleteConfirm": "Да ли сте сигурни да желите да обришете ову библиотеку? Ово ће да уклони све повезане податке и приступ корисника.", - "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": "Конфигуришите додатак користећи парове кључ-вредност. Оставите празно ако додатак не захтева конфигурацију.", - "configValidationError": "Провера исправности конфигурације није успела:", - "schemaRenderError": "Не може да се прикаже образац за конфигурацију. Шема додатка можда није исправна.", - "clickPermissions": "Кликните на дозволу за детаље", - "noConfig": "Конфигурација није постављена", - "allUsersHelp": "Када је омогућено, додатак ће имати приступ свим корисницима, укључујући оне који буду креирани у будућности.", - "noUsers": "Нема изабраних корисника", - "permissionReason": "Разлог", - "usersRequired": "Овај додатак захтева приступ информацијама о корисницима. Изаберите којим корисницима додатак може да приступи, или омогућите „Дозволи свим корисницима”.", - "allLibrariesHelp": "Када је омогућено, додатак ће имати приступ свим библиотекама, укључујући оне које буду креиране у будућности.", - "noLibraries": "Нема изабраних библиотека", - "librariesRequired": "Овај додатак захтева приступ информацијама о библиотекама. Изаберите којим библиотекама додатак може да приступи, или омогућите „Дозволи све библиотеке”.", - "allowWriteAccessHelp": "Када је омогућено, додатак може да мења фајлове у фасциклама библиотеке. Подразумевано, додаци имају приступ само за читање.", - "requiredHosts": "Потребни хостови" - }, - "placeholders": { - "configKey": "кључ", - "configValue": "вредност" - } - } - }, - "ra": { - "auth": { - "welcome1": "Хвала што сте инсталирали Navidrome!", - "welcome2": "За почетак, креирајте админ корисника", - "confirmPassword": "Потврдите лозинку", - "buttonCreateAdmin": "Креирај админа", - "auth_check_error": "Ако желите да наставите, молимо вас да се пријавите", - "user_menu": "Профил", - "username": "Корисничко име", - "password": "Лозинка", - "sign_in": "Пријави се", - "sign_in_error": "Потврда идентитета није успела, покушајте поново", - "logout": "Одјави се", - "insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите" - }, - "validation": { - "invalidChars": "Молимо вас да користите само слова и цифре", - "passwordDoesNotMatch": "Лозинка се не подудара", - "required": "Неопходно", - "minLength": "Мора да буде барем %{min} карактера", - "maxLength": "Мора да буде %{max} карактера или мање", - "minValue": "Мора да буде барем %{min}", - "maxValue": "Мора да буде %{max} или мање", - "number": "Мора да буде број", - "email": "Мора да буде исправна и-мејл адреса", - "oneOf": "Мора да буде једно од: %{options}", - "regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}", - "unique": "Мора да буде јединствено", - "url": "Мора да буде исправна URL адреса" - }, - "action": { - "add_filter": "Додај филтер", - "add": "Додај", - "back": "Иди назад", - "bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "cancel": "Откажи", - "clear_input_value": "Обриши вредност", - "clone": "Клонирај", - "confirm": "Потврди", - "create": "Креирај", - "delete": "Обриши", - "edit": "Уреди", - "export": "Извези", - "list": "Листа", - "refresh": "Освежи", - "remove_filter": "Уклони овај филтер", - "remove": "Уклони", - "save": "Сачувај", - "search": "Тражи", - "show": "Прикажи", - "sort": "Сортирај", - "undo": "Поништи", - "expand": "Развиј", - "close": "Затвори", - "open_menu": "Отвори мени", - "close_menu": "Затвори мени", - "unselect": "Уклони избор", - "skip": "Прескочи", - "share": "Дели", - "download": "Преузми" - }, - "boolean": { - "true": "Да", - "false": "Не" - }, - "page": { - "create": "Креирај %{name}", - "dashboard": "Контролна табла", - "edit": "%{name} #%{id}", - "error": "Нешто је пошло наопако", - "list": "%{name}", - "loading": "Учитава се", - "not_found": "Није пронађено", - "show": "%{name} #%{id}", - "empty": "Још увек нема %{name}.", - "invite": "Желите ли да се дода?" - }, - "input": { - "file": { - "upload_several": "Превуците фајлове да се отпреме, или кликните да их изаберете.", - "upload_single": "Превуците фајл да се отпреми, или кликните да га изаберете." - }, - "image": { - "upload_several": "Превуците слике да се отпреме, или кликните да их изаберете.", - "upload_single": "Превуците слику да се отпреми, или кликните да је изаберете." - }, - "references": { - "all_missing": "Не могу да се нађу подаци референци.", - "many_missing": "Изгледа да барем једна од придружених референци више није доступна.", - "single_missing": "Изгледа да придружена референца више није доступна." - }, - "password": { - "toggle_visible": "Сакриј лозинку", - "toggle_hidden": "Прикажи лозинку" - } - }, - "message": { - "about": "О", - "are_you_sure": "Да ли сте сигурни?", - "bulk_delete_content": "Да ли заиста желите да обришете %{name}? |||| Да ли заиста желите да обришете %{smart_count} ставке?", - "bulk_delete_title": "Брисање %{name} |||| Брисање %{smart_count} %{name}", - "delete_content": "Да ли заиста желите да обришете ову ставку?", - "delete_title": "Брисање %{name} #%{id}", - "details": "Детаљи", - "error": "Дошло је до клијентске грешке и ваш захтев није могао да се изврши.", - "invalid_form": "Формулар није исправан. Молимо вас да исправите грешке", - "loading": "Страница се учитава, сачекајте мало", - "no": "Не", - "not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.", - "yes": "Да", - "unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?" - }, - "navigation": { - "no_results": "Није пронађен ниједан резултат", - "no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.", - "page_out_of_boundaries": "Број странице %{page} је ван опсега", - "page_out_from_end": "Не може да се иде након последње странице", - "page_out_from_begin": "Не може да се иде испред странице 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}", - "page_rows_per_page": "Ставки по страници:", - "next": "Наредна", - "prev": "Претх.", - "skip_nav": "Прескочи на садржај" - }, - "notification": { - "updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано", - "created": "Елемент је креиран", - "deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано", - "bad_item": "Неисправни елемент", - "item_doesnt_exist": "Елемент не постоји", - "http_error": "Грешка у комуникацији са сервером", - "data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.", - "i18n_error": "Не могу да се учитају преводи за наведени језик", - "canceled": "Акција је отказана", - "logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.", - "new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Колоне за приказ", - "layout": "Распоред", - "grid": "Мрежа", - "table": "Табела" - } - }, - "message": { - "uploadCover": "Отпреми омот", - "removeCover": "Уклони омот", - "coverUploaded": "Омот је ажуриран", - "coverRemoved": "Омот је уклоњен", - "coverUploadError": "Грешка при отпремању омота", - "coverRemoveError": "Грешка при уклањању омота", - "note": "НАПОМЕНА", - "transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.", - "transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања.", - "songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама", - "noSimilarSongsFound": "Нису пронађене сличне песме", - "startingInstantMix": "Учитава се инстант микс…", - "noTopSongsFound": "Нису пронађене најбоље песме", - "noPlaylistsAvailable": "Није доступна ниједна", - "delete_user_title": "Брисање корисника ’%{name}’", - "delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?", - "remove_missing_title": "Уклони фајлове који недостају", - "remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", - "remove_all_missing_title": "Уклони све фајлове који недостају", - "remove_all_missing_content": "Да ли сте сигурни да желите да из базе података уклоните све фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", - "notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења", - "notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола", - "lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање", - "lastfmLinkFailure": "Last.fm није могао да се повеже", - "lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено", - "lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm", - "listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}", - "listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено", - "listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz", - "openIn": { - "lastfm": "Отвори у Last.fm", - "musicbrainz": "Отвори у MusicBrainz" - }, - "lastfmLink": "Прочитај још...", - "shareOriginalFormat": "Подели у оригиналном формату", - "shareDialogTitle": "Подели %{resource} ’%{name}’", - "shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}", - "shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер", - "shareSuccess": "URL је копиран у клипборд: %{url}", - "shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд", - "downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})", - "downloadOriginalFormat": "Преузми у оригиналном формату" - }, - "menu": { - "library": "Библиотека", - "librarySelector": { - "allLibraries": "Све библиотеке (%{count})", - "multipleLibraries": "%{selected} од %{total} библиотека", - "selectLibraries": "Изабери библиотеке", - "none": "Ниједна" - }, - "settings": "Подешавања", - "version": "Верзија", - "theme": "Тема", - "personal": { - "name": "Лична", - "options": { - "theme": "Тема", - "language": "Језик", - "defaultView": "Подразумевани поглед", - "desktop_notifications": "Десктоп обавештења", - "lastfmNotConfigured": "Није подешен Last.fm API-кључ", - "lastfmScrobbling": "Скроблуј на Last.fm", - "listenBrainzScrobbling": "Скроблуј на ListenBrainz", - "replaygain": "ReplayGain режим", - "preAmp": "ReplayGain претпојачање (dB)", - "gain": { - "none": "Искључено", - "album": "Користи Album појачање", - "track": "Користи Track појачање" - } - } - }, - "albumList": "Албуми", - "playlists": "Плејлисте", - "sharedPlaylists": "Дељене плејлисте", - "about": "О" - }, - "player": { - "playListsText": "Ред за пуштање", - "openText": "Отвори", - "closeText": "Затвори", - "notContentText": "Нема музике", - "clickToPlayText": "Кликните за пуштање", - "clickToPauseText": "Кликните за паузирање", - "nextTrackText": "Наредна нумера", - "previousTrackText": "Претходна нумера", - "reloadText": "Поново учитај", - "volumeText": "Јачина", - "toggleLyricText": "Укљ./Искљ. стихове", - "toggleMiniModeText": "Умањи", - "destroyText": "Уништи", - "downloadText": "Преузми", - "removeAudioListsText": "Обриши аудио листе", - "clickToDeleteText": "Кликните да обришете %{name}", - "emptyLyricText": "Нема стихова", - "playModeText": { - "order": "По редоследу", - "orderLoop": "Понови", - "singleLoop": "Понови једну", - "shufflePlay": "Измешај" - } - }, - "about": { - "links": { - "homepage": "Почетна страница", - "source": "Изворни кôд", - "featureRequests": "Захтеви за функције", - "lastInsightsCollection": "Последња колекција увида", - "insights": { - "disabled": "Искључено", - "waiting": "Чека се" - } - }, - "tabs": { - "about": "О програму", - "config": "Конфигурација" - }, - "config": { - "configName": "Назив конфигурације", - "environmentVariable": "Променљива окружења", - "currentValue": "Тренутна вредност", - "configurationFile": "Конфигурациони фајл", - "exportToml": "Извези конфигурацију (TOML)", - "downloadToml": "Преузми конфигурацију (TOML)", - "exportSuccess": "Конфигурација је извезена у клипборд у TOML формату", - "exportFailed": "Копирање конфигурације није успело", - "devFlagsHeader": "Развојне заставице (подложне промени или уклањању)", - "devFlagsComment": "Ово су експерименталне поставке и могу бити уклоњене у будућим верзијама" - } - }, - "activity": { - "title": "Активност", - "totalScanned": "Укупан број скенираних фолдера", - "quickScan": "Брзо скенирање", - "fullScan": "Комплетно скенирање", - "selectiveScan": "Селективно", - "serverUptime": "Сервер се извршава", - "serverDown": "ВАН МРЕЖЕ", - "scanType": "Последње скенирање", - "status": "Грешка скенирања", - "elapsedTime": "Протекло време" - }, - "nowPlaying": { - "title": "Сада се пушта", - "empty": "Ништа се не пушта", - "minutesAgo": "Пре %{smart_count} минут |||| Пре %{smart_count} минута" - }, - "help": { - "title": "Navidrome пречице", - "hotkeys": { - "show_help": "Прикажи ову помоћ", - "toggle_menu": "Укљ./Искљ. бочну траку менија", - "toggle_play": "Пусти / Паузирај", - "prev_song": "Претходна песма", - "next_song": "Наредна песма", - "current_song": "Иди на текућу песму", - "vol_up": "Појачај", - "vol_down": "Утишај", - "toggle_love": "Додај ову нумеру у омиљене" - } - } -} diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json index ff4f53a6b..bdfbf4167 100644 --- a/resources/i18n/sv.json +++ b/resources/i18n/sv.json @@ -1,747 +1,456 @@ { - "languageName": "Svenska", - "resources": { - "song": { - "name": "Låt |||| Låtar", - "fields": { - "albumArtist": "Albumartist", - "duration": "Längd", - "trackNumber": "#", - "playCount": "Spelningar", - "title": "Titel", - "artist": "Artist", - "album": "Album", - "path": "Filsökväg", - "genre": "Genre", - "compilation": "Samlingsalbum", - "year": "År", - "size": "Filstorlek", - "updatedAt": "Uppdaterad", - "bitRate": "Bithastighet", - "discSubtitle": "Skivans undertitel", - "starred": "Favorit", - "comment": "Kommentar", - "rating": "Betyg", - "quality": "Kvalitet", - "bpm": "BPM", - "playDate": "Senast spelad", - "channels": "Kanaler", - "createdAt": "Tillagd", - "grouping": "Gruppering", - "mood": "Stämning", - "participants": "Ytterligare medverkande", - "tags": "Ytterligare taggar", - "mappedTags": "Mappade taggar", - "rawTags": "Omodifierade taggar", - "bitDepth": "Bitdjup", - "sampleRate": "Samplingsfrekvens", - "missing": "Saknas", - "libraryName": "Bibliotek", - "composer": "Kompositör", - "disc": "Skiva %{discNumber}", - "albumGain": "Volymjustering (album)", - "trackGain": "Volymjustering (låt)" - }, - "actions": { - "addToQueue": "Lägg till i kön", - "playNow": "Spela nu", - "addToPlaylist": "Lägg till i spellista", - "shuffleAll": "Blanda alla", - "download": "Ladda ner", - "playNext": "Spela härnäst", - "info": "Visa information", - "showInPlaylist": "Visa i spellista", - "instantMix": "Direktmix" - } + "languageName": "Svenska", + "resources": { + "song": { + "name": "Låt |||| Låtar", + "fields": { + "albumArtist": "Album artist", + "duration": "Längd", + "trackNumber": "#", + "playCount": "Spelad", + "title": "Titel", + "artist": "Artist", + "album": "Album", + "path": "Sökväg", + "genre": "Genre", + "compilation": "Samling", + "year": "År", + "size": "Filstorlek", + "updatedAt": "Uppdaterad", + "bitRate": "Bitrate", + "discSubtitle": "Underrubrik", + "starred": "Favorit", + "comment": "Kommentar", + "rating": "Betyg", + "quality": "Kvalite", + "bpm": "BPM", + "playDate": "", + "channels": "", + "createdAt": "" + }, + "actions": { + "addToQueue": "Lägg till kön", + "playNow": "Spela", + "addToPlaylist": "Lägg till i spellista", + "shuffleAll": "Blanda", + "download": "Hämta", + "playNext": "Spela nästa", + "info": "" + } + }, + "album": { + "name": "Album |||| Album", + "fields": { + "albumArtist": "Album artist", + "artist": "Artist", + "duration": "Längd", + "songCount": "Låtar", + "playCount": "Spelad", + "name": "Namn", + "genre": "Genre", + "compilation": "Samling", + "year": "År", + "updatedAt": "Uppdaterad", + "comment": "Kommentar", + "rating": "Betyg", + "createdAt": "", + "size": "" + }, + "actions": { + "playAll": "Spela", + "playNext": "Spela härnäst", + "addToQueue": "Lägg till i kön", + "shuffle": "Blanda", + "addToPlaylist": "Lägg till i spellista", + "download": "Hämta", + "info": "", + "share": "" + }, + "lists": { + "all": "Alla låtar", + "random": "Blanda", + "recentlyAdded": "Senast tillagda", + "recentlyPlayed": "Senast spelat", + "mostPlayed": "Mest spelat", + "starred": "Favoriter", + "topRated": "Betygsatta" + } + }, + "artist": { + "name": "Artist |||| Artister", + "fields": { + "name": "Namn", + "albumCount": "Antal album", + "songCount": "Antal låtar", + "playCount": "Spelade", + "rating": "Betygsatt", + "genre": "Genre", + "size": "" + } + }, + "user": { + "name": "Användare |||| Användare", + "fields": { + "userName": "Användarnamn", + "isAdmin": "Administratör", + "lastLoginAt": "Senaste inloggning", + "updatedAt": "Uppdaterad", + "name": "Namn", + "password": "Lösenord", + "createdAt": "Skapad", + "changePassword": "Byta lösenord?", + "currentPassword": "Nuvarande lösenord", + "newPassword": "Nytt lösenord", + "token": "" + }, + "helperTexts": { + "name": "Namnändringar återspeglas vid nästkommande inloggning" + }, + "notifications": { + "created": "Användare har skapats", + "updated": "Användare har uppdaterats", + "deleted": "Användare har tagits bort" + }, + "message": { + "listenBrainzToken": "", + "clickHereForToken": "" + } + }, + "player": { + "name": "Avkodare |||| Avkodare", + "fields": { + "name": "Namn", + "transcodingId": "Omkodning", + "maxBitRate": "Max. bitrate", + "client": "Klient", + "userName": "Användarnamn", + "lastSeen": "Senast sedd", + "reportRealPath": "Visa hela sökvägen", + "scrobbleEnabled": "" + } + }, + "transcoding": { + "name": "Omkodning |||| Omkodningar", + "fields": { + "name": "Namn", + "targetFormat": "Målformat", + "defaultBitRate": "Standard bitrate", + "command": "Kommando" + } + }, + "playlist": { + "name": "Spellista |||| Spellistor", + "fields": { + "name": "Namn", + "duration": "Längd", + "ownerName": "Ägare", + "public": "Offentlig", + "updatedAt": "Uppdaterad", + "createdAt": "Skapad", + "songCount": "Låt", + "comment": "Kommentar", + "sync": "Synkronisera", + "path": "Importerat från" + }, + "actions": { + "selectPlaylist": "Välj en spellista:", + "addNewPlaylist": "Lägg till \"%{name}\"", + "export": "Exportera", + "makePublic": "", + "makePrivate": "" + }, + "message": { + "duplicate_song": "Lägg till dubletter", + "song_exist": "Det finns dubletter som är på väg att läggas till i spellistan. Vill du lägga till dem ändå?" + } + }, + "radio": { + "name": "", + "fields": { + "name": "", + "streamUrl": "", + "homePageUrl": "", + "updatedAt": "", + "createdAt": "" + }, + "actions": { + "playNow": "" + } + }, + "share": { + "name": "", + "fields": { + "username": "", + "url": "", + "description": "", + "contents": "", + "expiresAt": "", + "lastVisitedAt": "", + "visitCount": "", + "format": "", + "maxBitRate": "", + "updatedAt": "", + "createdAt": "", + "downloadable": "" + } + } }, - "album": { - "name": "Album |||| Album", - "fields": { - "albumArtist": "Albumartist", - "artist": "Artist", - "duration": "Längd", - "songCount": "Antal låtar", - "playCount": "Spelningar", - "name": "Namn", - "genre": "Genre", - "compilation": "Samlingsalbum", - "year": "År", - "updatedAt": "Uppdaterad", - "comment": "Kommentar", - "rating": "Betyg", - "createdAt": "Tillagt", - "size": "Storlek", - "originalDate": "Originaldatum", - "releaseDate": "Utgivningsdatum", - "releases": "Utgåva |||| Utgåvor", - "released": "Utgiven", - "recordLabel": "Skivbolag", - "catalogNum": "Katalognummer", - "releaseType": "Typ", - "grouping": "Gruppering", - "media": "Media", - "mood": "Stämning", - "date": "Inspelningsdatum", - "missing": "Saknas", - "libraryName": "Bibliotek" - }, - "actions": { - "playAll": "Spela", - "playNext": "Spela härnäst", - "addToQueue": "Lägg till i kön", - "shuffle": "Blanda", - "addToPlaylist": "Lägg till i spellista", - "download": "Ladda ner", - "info": "Visa information", - "share": "Dela", - "refresh": "Uppdatera metadata" - }, - "lists": { - "all": "Alla", - "random": "Slumpmässiga", - "recentlyAdded": "Nyligen tillagda", - "recentlyPlayed": "Nyligen spelade", - "mostPlayed": "Mest spelade", - "starred": "Favoriter", - "topRated": "Högst betygsatta" - } - }, - "artist": { - "name": "Artist |||| Artister", - "fields": { - "name": "Namn", - "albumCount": "Antal album", - "songCount": "Antal låtar", - "playCount": "Spelningar", - "rating": "Betyg", - "genre": "Genre", - "size": "Storlek", - "role": "Roll", - "missing": "Saknas" - }, - "roles": { - "albumartist": "Albumartist |||| Albumartister", - "artist": "Artist |||| Artister", - "composer": "Kompositör |||| Kompositörer", - "conductor": "Dirigent |||| Dirigenter", - "lyricist": "Textförfattare |||| Textförfattare", - "arranger": "Arrangör |||| Arrangörer", - "producer": "Producent |||| Producenter", - "director": "Inspelningsledare |||| Inspelningsledare", - "engineer": "Ljudtekniker |||| Ljudtekniker", - "mixer": "Mixare |||| Mixare", - "remixer": "Remixare |||| Remixare", - "djmixer": "DJ-mixare |||| DJ-mixare", - "performer": "Utövande artist |||| Utövande artister", - "maincredit": "Albumartist eller artist |||| Albumartister eller artister" - }, - "actions": { - "shuffle": "Blanda", - "radio": "Radio", - "topSongs": "Topplåtar" - } - }, - "user": { - "name": "Användare |||| Användare", - "fields": { - "userName": "Användarnamn", - "isAdmin": "Administratör", - "lastLoginAt": "Senaste inloggning", - "updatedAt": "Uppdaterad", - "name": "Namn", - "password": "Lösenord", - "createdAt": "Skapad", - "changePassword": "Byt lösenord?", - "currentPassword": "Nuvarande lösenord", - "newPassword": "Nytt lösenord", - "token": "Åtkomsttoken", - "lastAccessAt": "Senaste åtkomst", - "libraries": "Bibliotek", - "scrobbleFilter": "Scrobblingsfilter" - }, - "helperTexts": { - "name": "Ändringar av ditt namn syns först vid nästa inloggning", - "libraries": "Välj vilka bibliotek användaren ska ha tillgång till, eller lämna tomt för att använda standardbiblioteken", - "scrobbleFilter": "Låtar som matchar dessa regler för smarta spellistor skickas inte till Last.fm, ListenBrainz eller scrobblingtillägg. Samma JSON-syntax och beteende som för smarta spellistor används. Exempel: {\"all\":[{\"lt\":{\"rating\":4}}]}. Lämna tomt för att scrobbla allt. Antalet spelningar som lagras lokalt påverkas inte." - }, - "notifications": { - "created": "Användare skapad", - "updated": "Användare uppdaterad", - "deleted": "Användare borttagen" - }, - "message": { - "listenBrainzToken": "Ange din användartoken för ListenBrainz.", - "clickHereForToken": "Klicka här för att hämta din token", - "selectAllLibraries": "Välj alla bibliotek", - "adminAutoLibraries": "Administratörer har automatiskt åtkomst till alla bibliotek" - }, - "validation": { - "librariesRequired": "Minst ett bibliotek måste väljas för användare som inte är administratörer", - "invalidScrobbleFilter": "Måste innehålla giltiga regler för smarta spellistor. Begränsning av antalet låtar, förskjutning och uppdateringsfördröjning stöds inte." - } - }, - "player": { - "name": "Spelare |||| Spelare", - "fields": { - "name": "Namn", - "transcodingId": "Omkodning", - "maxBitRate": "Högsta bithastighet", - "client": "Klient", - "userName": "Användarnamn", - "lastSeen": "Senast sedd", - "reportRealPath": "Rapportera den faktiska sökvägen", - "scrobbleEnabled": "Scrobbla till externa tjänster" - } - }, - "transcoding": { - "name": "Omkodning |||| Omkodningar", - "fields": { - "name": "Namn", - "targetFormat": "Målformat", - "defaultBitRate": "Standardbithastighet", - "command": "Kommando" - }, - "choices": { - "noDefaultBitRate": "Ingen" - } - }, - "playlist": { - "name": "Spellista |||| Spellistor", - "fields": { - "name": "Namn", - "duration": "Längd", - "ownerName": "Ägare", - "public": "Offentlig", - "updatedAt": "Uppdaterad", - "createdAt": "Skapad", - "songCount": "Låtar", - "comment": "Kommentar", - "sync": "Automatisk import", - "path": "Importera från", - "starred": "Favorit" - }, - "actions": { - "selectPlaylist": "Välj en spellista:", - "addNewPlaylist": "Skapa \"%{name}\"", - "export": "Exportera", - "makePublic": "Gör offentlig", - "makePrivate": "Gör privat", - "saveQueue": "Spara kön i en spellista", - "searchOrCreate": "Sök spellistor eller skriv för att skapa en ny...", - "pressEnterToCreate": "Tryck på Enter för att skapa en ny spellista", - "removeFromSelection": "Ta bort från urvalet" - }, - "message": { - "duplicate_song": "Lägg till dubbletter", - "song_exist": "Du håller på att lägga till dubbletter i spellistan. Vill du lägga till dem eller hoppa över dem?", - "noPlaylistsFound": "Inga spellistor hittades", - "noPlaylists": "Inga spellistor tillgängliga" - } - }, - "radio": { - "name": "Radiostation |||| Radiostationer", - "fields": { - "name": "Namn", - "streamUrl": "Strömmens URL", - "homePageUrl": "Webbplatsens URL", - "updatedAt": "Uppdaterad", - "createdAt": "Skapad" - }, - "actions": { - "playNow": "Spela nu" - } - }, - "share": { - "name": "Delning |||| Delningar", - "fields": { - "username": "Delad av", - "url": "URL", - "description": "Beskrivning", - "contents": "Innehåll", - "expiresAt": "Giltig till", - "lastVisitedAt": "Senast besökt", - "visitCount": "Besök", - "format": "Format", - "maxBitRate": "Högsta bithastighet", - "updatedAt": "Uppdaterad", - "createdAt": "Skapad", - "downloadable": "Tillåt nedladdningar?" - }, - "notifications": {}, - "actions": {} - }, - "missing": { - "name": "Saknad fil |||| Saknade filer", - "fields": { - "path": "Sökväg", - "size": "Storlek", - "updatedAt": "Försvann", - "libraryName": "Bibliotek" - }, - "actions": { - "remove": "Ta bort", - "remove_all": "Ta bort alla" - }, - "notifications": { - "removed": "Saknade filer har tagits bort" - }, - "empty": "Inga saknade filer" - }, - "library": { - "name": "Bibliotek |||| Bibliotek", - "fields": { - "name": "Namn", - "path": "Sökväg", - "remotePath": "Fjärrsökväg", - "lastScanAt": "Senaste skanning", - "songCount": "Låtar", - "albumCount": "Album", - "artistCount": "Artister", - "totalSongs": "Låtar", - "totalAlbums": "Album", - "totalArtists": "Artister", - "totalFolders": "Mappar", - "totalFiles": "Filer", - "totalMissingFiles": "Saknade filer", - "totalSize": "Sammanlagd storlek", - "totalDuration": "Längd", - "defaultNewUsers": "Standard för nya användare", - "createdAt": "Skapad", - "updatedAt": "Uppdaterad" - }, - "sections": { - "basic": "Grundinformation", - "statistics": "Statistik" - }, - "actions": { - "scan": "Skanna biblioteket", - "manageUsers": "Hantera användaråtkomst", - "viewDetails": "Visa detaljer", - "quickScan": "Snabbskanning", - "fullScan": "Fullständig skanning" - }, - "notifications": { - "created": "Biblioteket har skapats", - "updated": "Biblioteket har uppdaterats", - "deleted": "Biblioteket har tagits bort", - "scanStarted": "Skanning av biblioteket har startat", - "scanCompleted": "Skanning av biblioteket är klar", - "quickScanStarted": "Snabbskanning har startat", - "fullScanStarted": "Fullständig skanning har startat", - "scanError": "Kunde inte starta skanningen. Kontrollera loggarna" - }, - "validation": { - "nameRequired": "Ange ett biblioteksnamn", - "pathRequired": "Ange en sökväg till biblioteket", - "pathNotDirectory": "Bibliotekets sökväg måste peka på en katalog", - "pathNotFound": "Bibliotekets sökväg hittades inte", - "pathNotAccessible": "Bibliotekets sökväg är inte åtkomlig", - "pathInvalid": "Ogiltig sökväg till biblioteket" - }, - "messages": { - "deleteConfirm": "Är du säker på att du vill ta bort det här biblioteket? Alla tillhörande data och användarnas åtkomst till biblioteket tas bort.", - "scanInProgress": "Skanning pågår...", - "noLibrariesAssigned": "Inga bibliotek har tilldelats den här användaren" - } - }, - "plugin": { - "name": "Tillägg |||| Tillägg", - "fields": { - "id": "ID", - "name": "Namn", - "description": "Beskrivning", - "version": "Version", - "author": "Upphovsperson", - "website": "Webbplats", - "permissions": "Behörigheter", - "enabled": "Aktiverat", - "status": "Status", - "path": "Sökväg", - "lastError": "Fel", - "hasError": "Fel", - "updatedAt": "Uppdaterat", - "createdAt": "Installerat", - "configKey": "Nyckel", - "configValue": "Värde", - "allUsers": "Tillåt alla användare", - "selectedUsers": "Valda användare", - "allLibraries": "Tillåt alla bibliotek", - "selectedLibraries": "Valda bibliotek", - "allowWriteAccess": "Tillåt skrivåtkomst" - }, - "sections": { - "status": "Status", - "info": "Information om tillägget", - "configuration": "Konfiguration", - "manifest": "Manifest", - "usersPermission": "Åtkomst till användare", - "libraryPermission": "Åtkomst till bibliotek" - }, - "status": { - "enabled": "Aktiverat", - "disabled": "Inaktiverat" - }, - "actions": { - "enable": "Aktivera", - "disable": "Inaktivera", - "disabledDueToError": "Åtgärda felet före aktivering", - "disabledUsersRequired": "Välj användare före aktivering", - "disabledLibrariesRequired": "Välj bibliotek före aktivering", - "addConfig": "Lägg till konfiguration", - "rescan": "Skanna om" - }, - "notifications": { - "enabled": "Tillägg aktiverat", - "disabled": "Tillägg inaktiverat", - "updated": "Tillägg uppdaterat", - "error": "Fel vid uppdatering av tillägg" - }, - "validation": { - "invalidJson": "Konfigurationen måste vara giltig JSON" - }, - "messages": { - "configHelp": "Konfigurera tillägget med nyckel–värde-par. Lämna tomt om tillägget inte kräver någon konfiguration.", - "clickPermissions": "Klicka på en behörighet för mer information", - "noConfig": "Ingen konfiguration angiven", - "allUsersHelp": "När alternativet är aktiverat får tillägget åtkomst till alla användare, även de som skapas i framtiden.", - "noUsers": "Inga användare valda", - "permissionReason": "Orsak", - "usersRequired": "Det här tillägget behöver åtkomst till användarinformation. Välj vilka användare tillägget får åtkomst till eller aktivera 'Tillåt alla användare'.", - "allLibrariesHelp": "När alternativet är aktiverat får tillägget åtkomst till alla bibliotek, även de som skapas i framtiden.", - "noLibraries": "Inga bibliotek valda", - "librariesRequired": "Det här tillägget behöver åtkomst till biblioteksinformation. Välj vilka bibliotek tillägget får åtkomst till eller aktivera 'Tillåt alla bibliotek'.", - "requiredHosts": "Värdar som krävs", - "configValidationError": "Validering av konfigurationen misslyckades:", - "schemaRenderError": "Kunde inte visa konfigurationsformuläret. Tilläggets schema kan vara ogiltigt.", - "allowWriteAccessHelp": "När alternativet är aktiverat kan tillägget ändra filer i bibliotekens kataloger. Som standard har tillägg endast läsåtkomst.", - "idHelp": "Tilläggets ID hämtas från filnamnet. Använd det när du hänvisar till tillägget i konfigurationsalternativ, till exempel Agents." - }, - "placeholders": { - "configKey": "nyckel", - "configValue": "värde" - } - } - }, - "ra": { - "auth": { - "welcome1": "Tack för att du installerade Navidrome!", - "welcome2": "Börja med att skapa ett administratörskonto", - "confirmPassword": "Bekräfta lösenord", - "buttonCreateAdmin": "Skapa administratörskonto", - "auth_check_error": "Logga in för att fortsätta", - "user_menu": "Profil", - "username": "Användarnamn", - "password": "Lösenord", - "sign_in": "Logga in", - "sign_in_error": "Inloggningen misslyckades. Försök igen", - "logout": "Logga ut", - "insightsCollectionNote": "Navidrome samlar in anonyma användningsdata för\natt förbättra projektet. Klicka [här] för att läsa mer\noch välja bort insamlingen" - }, - "validation": { - "invalidChars": "Använd enbart bokstäver och siffror", - "passwordDoesNotMatch": "Lösenorden stämmer inte överens", - "required": "Obligatoriskt", - "minLength": "Måste innehålla minst %{min} tecken", - "maxLength": "Får innehålla högst %{max} tecken", - "minValue": "Måste vara minst %{min}", - "maxValue": "Får vara högst %{max}", - "number": "Måste vara ett tal", - "email": "Måste vara en giltig e-postadress", - "oneOf": "Måste vara något av: %{options}", - "regex": "Måste matcha det reguljära uttrycket: %{pattern}", - "unique": "Måste vara unikt", - "url": "Måste vara en giltig URL" - }, - "action": { - "add_filter": "Lägg till filter", - "add": "Lägg till", - "back": "Tillbaka", - "bulk_actions": "1 objekt valt |||| %{smart_count} objekt valda", - "cancel": "Avbryt", - "clear_input_value": "Rensa värdet", - "clone": "Klona", - "confirm": "Bekräfta", - "create": "Skapa", - "delete": "Ta bort", - "edit": "Redigera", - "export": "Exportera", - "list": "Lista", - "refresh": "Uppdatera", - "remove_filter": "Ta bort det här filtret", - "remove": "Ta bort", - "save": "Spara", - "search": "Sök", - "show": "Visa", - "sort": "Sortera", - "undo": "Ångra", - "expand": "Expandera", - "close": "Stäng", - "open_menu": "Öppna meny", - "close_menu": "Stäng meny", - "unselect": "Avmarkera", - "skip": "Hoppa över", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Dela", - "download": "Ladda ner" - }, - "boolean": { - "true": "Ja", - "false": "Nej" - }, - "page": { - "create": "Skapa %{name}", - "dashboard": "Översikt", - "edit": "%{name} #%{id}", - "error": "Ett fel uppstod", - "list": "%{name}", - "loading": "Läser in", - "not_found": "Hittades inte", - "show": "%{name} #%{id}", - "empty": "%{name}: inga poster ännu.", - "invite": "Vill du lägga till något?" - }, - "input": { - "file": { - "upload_several": "Dra och släpp filer som ska laddas upp eller klicka för att välja dem.", - "upload_single": "Dra och släpp en fil som ska laddas upp eller klicka för att välja en fil." - }, - "image": { - "upload_several": "Dra och släpp bilder som ska laddas upp eller klicka för att välja dem.", - "upload_single": "Dra och släpp en bild som ska laddas upp eller klicka för att välja en bild." - }, - "references": { - "all_missing": "Kunde inte hitta referensdata.", - "many_missing": "Minst en av de kopplade referenserna verkar inte längre vara tillgänglig.", - "single_missing": "Den kopplade referensen verkar inte längre vara tillgänglig." - }, - "password": { - "toggle_visible": "Dölj lösenord", - "toggle_hidden": "Visa lösenord" - } + "ra": { + "auth": { + "welcome1": "Tack för att du installerade Navidrome!", + "welcome2": "Lägg till en administratör för att börja", + "confirmPassword": "Bekräfta lösenord", + "buttonCreateAdmin": "Lägg till administratör", + "auth_check_error": "Var god logga in för att fortsätta", + "user_menu": "Profil", + "username": "Användarnamn", + "password": "Lösenord", + "sign_in": "Logga in", + "sign_in_error": "Felaktig inloggning, försök igen", + "logout": "Logga ut" + }, + "validation": { + "invalidChars": "Ogiltiga tecken, du kan endast använda bokstäver och siffror", + "passwordDoesNotMatch": "Lösenorden matchar ej", + "required": "Krävs", + "minLength": "Mindst %{min} tecken", + "maxLength": "Max %{max} tecken", + "minValue": "Minst %{min}", + "maxValue": "Max %{max}", + "number": "Måste vara ett nummer", + "email": "Ange giltig e-post adress", + "oneOf": "Måste vara en av: %{options}", + "regex": "Måste matcha ett förvalt format (regexp): %{pattern}", + "unique": "Måste vara unik", + "url": "" + }, + "action": { + "add_filter": "Lägg till filter", + "add": "Lägg till", + "back": "Tillbaka", + "bulk_actions": "%{smart_count} valda", + "cancel": "Avbryt", + "clear_input_value": "Rensa", + "clone": "Klona", + "confirm": "Bekräfta", + "create": "Lägg till", + "delete": "Ta bort", + "edit": "Redigera", + "export": "Exportera", + "list": "Lista", + "refresh": "Uppdatera", + "remove_filter": "Ta bort filter", + "remove": "Ta bort", + "save": "Spara", + "search": "Sök", + "show": "Visa", + "sort": "Sortera", + "undo": "Ångra", + "expand": "Expandera", + "close": "Lås", + "open_menu": "Öppna meny", + "close_menu": "Stäng meny", + "unselect": "Avmarkera", + "skip": "Skippa", + "bulk_actions_mobile": "", + "share": "", + "download": "" + }, + "boolean": { + "true": "Ja", + "false": "Nej" + }, + "page": { + "create": "Lägg till %{name}", + "dashboard": "Dashboard", + "edit": "%{name} #%{id}", + "error": "Ett fel uppstod", + "list": "%{name} lista", + "loading": "Bearbetar", + "not_found": "Hittade inget", + "show": "%{name} #%{id}", + "empty": "Ingen %{name} ännu", + "invite": "Vill du lägga till en?" + }, + "input": { + "file": { + "upload_several": "Dra och släpp filer som ska laddas upp eller klicka för att markera filer.", + "upload_single": "Dra och släpp en fil som ska laddas upp eller klicka för att markera en fil." + }, + "image": { + "upload_several": "Dra och släpp filer som ska laddas upp eller klicka för att markera filer.", + "upload_single": "Dra och släpp en bild som ska laddas upp eller klicka för att markera en fil." + }, + "references": { + "all_missing": "Det går inte att hitta några referensdata.", + "many_missing": "Minst en av de associerade referenserna verkar inte längre vara tillgänglig.", + "single_missing": "Associerade referenser verkar inte längre vara tillgängliga." + }, + "password": { + "toggle_visible": "Dölj lösenord", + "toggle_hidden": "Visa lösenord" + } + }, + "message": { + "about": "Om", + "are_you_sure": "Är du säker?", + "bulk_delete_content": "Vill du ta bort %{name}? |||| Är du säker på att du vill ta bort %{smart_count} ?", + "bulk_delete_title": "Ta bort %{name} |||| Tar bort %{smart_count} %{name}", + "delete_content": "Är du säker du vill ta bort den här posten?", + "delete_title": "Ta bort %{name} #%{id}", + "details": "Detaljer", + "error": "Ett klientfel uppstod och begäran kunde inte slutföras.", + "invalid_form": "Formuläret är ogiltigt. Kontrollera eventuella fel", + "loading": "Sidan läses in, vad god vänta", + "no": "Nej", + "not_found": "Antingen skrev du fel URL eller så följde du en ogiltig länk.", + "yes": "Ja", + "unsaved_changes": "Du har osparade ändringar. Ignorera dem?" + }, + "navigation": { + "no_results": "Inga resultat hittades", + "no_more_results": "Sidnumret %{page} existerar inte. Gå tillbaka till föregående sida.", + "page_out_of_boundaries": "Sidnumret %{page} existerar inte", + "page_out_from_end": "Finns inga fler sidor", + "page_out_from_begin": "Det finns ingen sida före sidan 1", + "page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}", + "page_rows_per_page": "Antal per sida:", + "next": "Nästa", + "prev": "Föregående", + "skip_nav": "Skippa till innehåll" + }, + "notification": { + "updated": "Spellistan har uppdaterats |||| %{smart_count} objekt som har uppdaterats", + "created": "Spellistan har skapats", + "deleted": "Spellistan har tagits bort |||| %{smart_count} objekt som tagits bort", + "bad_item": "Felaktigt element", + "item_doesnt_exist": "Spellistan hittades ej", + "http_error": "Kommunikationsfel med servern", + "data_provider_error": "dataProvider fel. Kontrollera din konsol för ytterligare detaljer.", + "i18n_error": "Det gick inte att läsa in översättningen av det begärda språket", + "canceled": "Åtgärden avbröts", + "logged_out": "Sessionen har löpt ut, anslut igen", + "new_version": "" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "", + "layout": "", + "grid": "", + "table": "" + } }, "message": { - "about": "Om", - "are_you_sure": "Är du säker?", - "bulk_delete_content": "Vill du verkligen ta bort %{name}? |||| Vill du verkligen ta bort dessa %{smart_count} objekt?", - "bulk_delete_title": "Ta bort %{name} |||| Ta bort %{smart_count} %{name}", - "delete_content": "Vill du verkligen ta bort det här objektet?", - "delete_title": "Ta bort %{name} #%{id}", - "details": "Detaljer", - "error": "Ett klientfel uppstod och begäran kunde inte slutföras.", - "invalid_form": "Formuläret innehåller fel. Kontrollera uppgifterna", - "loading": "Sidan läses in. Vänta ett ögonblick", - "no": "Nej", - "not_found": "Antingen skrev du fel URL eller så följde du en ogiltig länk.", - "yes": "Ja", - "unsaved_changes": "Vissa ändringar har inte sparats. Vill du verkligen ignorera dem?" - }, - "navigation": { - "no_results": "Inga resultat hittades", - "no_more_results": "Sidnumret %{page} finns inte. Gå tillbaka till föregående sida.", - "page_out_of_boundaries": "Sidnumret %{page} finns inte", - "page_out_from_end": "Det finns inga fler sidor", - "page_out_from_begin": "Det finns ingen sida före sida 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} av %{total}", - "page_rows_per_page": "Objekt per sida:", - "next": "Nästa", - "prev": "Föregående", - "skip_nav": "Hoppa till innehåll" - }, - "notification": { - "updated": "Objekt uppdaterat |||| %{smart_count} objekt uppdaterade", - "created": "Objekt skapat", - "deleted": "Objekt borttaget |||| %{smart_count} objekt borttagna", - "bad_item": "Felaktigt objekt", - "item_doesnt_exist": "Objektet finns inte", - "http_error": "Kommunikationsfel med servern", - "data_provider_error": "Fel i dataProvider. Kontrollera webbläsarens konsol för mer information.", - "i18n_error": "Kunde inte läsa in översättningarna för det valda språket", - "canceled": "Åtgärden avbröts", - "logged_out": "Sessionen har avslutats. Logga in igen.", - "new_version": "En ny version finns tillgänglig! Ladda om det här fönstret." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Kolumner att visa", - "layout": "Layout", - "grid": "Rutnät", - "table": "Tabell" - } - }, - "message": { - "note": "OBSERVERA", - "transcodingDisabled": "Av säkerhetsskäl går det inte att ändra omkodningsinställningarna i webbgränssnittet. Starta om servern med konfigurationsalternativet %{config} för att redigera eller lägga till omkodningsinställningar.", - "transcodingEnabled": "Navidrome körs med %{config}. Det gör det möjligt att köra systemkommandon via omkodningsinställningarna i webbgränssnittet. Av säkerhetsskäl rekommenderar vi att alternativet är inaktiverat och bara aktiveras när omkodningen ska konfigureras.", - "songsAddedToPlaylist": "1 låt har lagts till i spellistan |||| %{smart_count} låtar har lagts till i spellistan", - "noPlaylistsAvailable": "Inga tillgängliga", - "delete_user_title": "Ta bort användaren '%{name}'", - "delete_user_content": "Är du säker på att du vill ta bort den här användaren och alla användarens data, inklusive spellistor och inställningar?", - "notifications_blocked": "Du har blockerat aviseringar från den här webbplatsen i webbläsarens inställningar", - "notifications_not_available": "Webbläsaren stöder inte skrivbordsaviseringar, eller så ansluter du inte till Navidrome via HTTPS", - "lastfmLinkSuccess": "Last.fm har kopplats och scrobbling har aktiverats", - "lastfmLinkFailure": "Det gick inte att koppla Last.fm", - "lastfmUnlinkSuccess": "Kopplingen till Last.fm har tagits bort och scrobbling har inaktiverats", - "lastfmUnlinkFailure": "Det gick inte att ta bort kopplingen till Last.fm", - "openIn": { - "lastfm": "Öppna i Last.fm", - "musicbrainz": "Öppna i MusicBrainz" - }, - "lastfmLink": "Läs mer...", - "listenBrainzLinkSuccess": "ListenBrainz har kopplats och scrobbling har aktiverats för användaren: %{user}", - "listenBrainzLinkFailure": "Det gick inte att koppla ListenBrainz: %{error}", - "listenBrainzUnlinkSuccess": "Kopplingen till ListenBrainz har tagits bort och scrobbling har inaktiverats", - "listenBrainzUnlinkFailure": "Det gick inte att ta bort kopplingen till ListenBrainz", - "downloadOriginalFormat": "Ladda ner i originalformat", - "shareOriginalFormat": "Dela i originalformat", - "shareDialogTitle": "Dela %{resource} '%{name}'", - "shareBatchDialogTitle": "Dela 1 %{resource} |||| Dela %{smart_count} %{resource}", - "shareSuccess": "URL:en har kopierats till urklipp: %{url}", - "shareFailure": "Kunde inte kopiera URL:en %{url} till urklipp", - "downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter", - "remove_missing_title": "Ta bort saknade filer", - "remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.", - "remove_all_missing_title": "Ta bort alla saknade filer", - "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Alla referenser till dem, inklusive antal spelningar och betyg, tas bort permanent.", - "noSimilarSongsFound": "Hittade inga liknande låtar", - "noTopSongsFound": "Hittade inga topplåtar", - "startingInstantMix": "Laddar direktmix...", - "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", - "metadataRefreshStarted": "Metadata uppdateras i bakgrunden", - "quickConnectApproved": "%{app} på %{device} är nu inloggad", - "quickConnectInvalidCode": "Koden är ogiltig eller har gått ut", - "quickConnectError": "Kunde inte godkänna koden" - }, - "menu": { - "library": "Bibliotek", - "settings": "Inställningar", - "version": "Version", - "theme": "Tema", - "personal": { - "name": "Personligt", - "options": { - "theme": "Tema", - "language": "Språk", - "defaultView": "Standardvy", - "desktop_notifications": "Skrivbordsaviseringar", - "lastfmScrobbling": "Scrobbla till Last.fm", - "listenBrainzScrobbling": "Scrobbla till ListenBrainz", - "replaygain": "ReplayGain-läge", - "preAmp": "Förförstärkning för ReplayGain (dB)", - "gain": { - "none": "Inaktiverad", - "album": "Använd albumets volymjustering", - "track": "Använd låtens volymjustering" + "note": "NOTERA", + "transcodingDisabled": "Inställning för kodning via webbplattformen är ej aktiverat pga säkerhetsskäl. Starta om servern med alternativet %{config} markerat.", + "transcodingEnabled": "Navidrome körs för närvarande med %{config}, vilket gör att systemkommandon kan köras från webbplattformen. Du rekommenderas av säkerhetsskäl att du stänger av den och bara slår på den när du ställer in omkodning.", + "songsAddedToPlaylist": "Lade till 1 låt i spellistan |||| Lade till %{smart_count} låtar i spellistan", + "noPlaylistsAvailable": "Ingen tillgänglig", + "delete_user_title": "Ta bort användare '%{name}'", + "delete_user_content": "Vill du ta bort den här användaren och tillhörande data (inklusive spellistor och inställningar)?", + "notifications_blocked": "", + "notifications_not_available": "", + "lastfmLinkSuccess": "", + "lastfmLinkFailure": "", + "lastfmUnlinkSuccess": "", + "lastfmUnlinkFailure": "", + "openIn": { + "lastfm": "", + "musicbrainz": "" }, - "lastfmNotConfigured": "API-nyckeln för Last.fm är inte konfigurerad" - } + "lastfmLink": "", + "listenBrainzLinkSuccess": "", + "listenBrainzLinkFailure": "", + "listenBrainzUnlinkSuccess": "", + "listenBrainzUnlinkFailure": "", + "downloadOriginalFormat": "", + "shareOriginalFormat": "", + "shareDialogTitle": "", + "shareBatchDialogTitle": "", + "shareSuccess": "", + "shareFailure": "", + "downloadDialogTitle": "", + "shareCopyToClipboard": "" }, - "albumList": "Album", - "about": "Om", - "playlists": "Spellistor", - "sharedPlaylists": "Delade spellistor", - "librarySelector": { - "allLibraries": "Alla bibliotek (%{count})", - "multipleLibraries": "%{selected} av %{total} bibliotek", - "selectLibraries": "Välj bibliotek", - "none": "Inga" + "menu": { + "library": "Bibliotek", + "settings": "Inställningar", + "version": "Version", + "theme": "Tema", + "personal": { + "name": "Profil", + "options": { + "theme": "Tema", + "language": "Språk", + "defaultView": "Standardvy", + "desktop_notifications": "Skrivbords notiser", + "lastfmScrobbling": "", + "listenBrainzScrobbling": "", + "replaygain": "", + "preAmp": "", + "gain": { + "none": "", + "album": "", + "track": "" + } + } + }, + "albumList": "Album", + "about": "Om", + "playlists": "", + "sharedPlaylists": "" }, - "quickConnect": { - "name": "Snabbanslutning", - "code": "Kod", - "help": "Ange koden som visas i en Jellyfin-app för att logga in på ditt konto i appen", - "confirm": "Vill du logga in med ditt konto i %{app} %{version} på %{device}?", - "continue": "Fortsätt", - "approve": "Godkänn" + "player": { + "playListsText": "Spellista", + "openText": "Öppna", + "closeText": "Stäng", + "notContentText": "Ingen musik", + "clickToPlayText": "Tryck för att spela", + "clickToPauseText": "Tryck för att pausa", + "nextTrackText": "Nästa låt", + "previousTrackText": "Föregående låt", + "reloadText": "Ladda om", + "volumeText": "Volym", + "toggleLyricText": "Låttext", + "toggleMiniModeText": "Minimera", + "destroyText": "Ta bort", + "downloadText": "Hämta", + "removeAudioListsText": "Töm spellista", + "clickToDeleteText": "Tryck för att ta bort %{name}", + "emptyLyricText": "Ingen låttext", + "playModeText": { + "order": "Normal", + "orderLoop": "Upprepa", + "singleLoop": "Upprepa en gång", + "shufflePlay": "Blanda" + } }, - "onlyFavourites": "Visa endast favoriter" - }, - "player": { - "playListsText": "Uppspelningskö", - "openText": "Öppna", - "closeText": "Stäng", - "notContentText": "Ingen musik", - "clickToPlayText": "Klicka för att spela", - "clickToPauseText": "Klicka för att pausa", - "nextTrackText": "Nästa låt", - "previousTrackText": "Föregående låt", - "reloadText": "Ladda om", - "volumeText": "Volym", - "toggleLyricText": "Visa eller dölj låttexten", - "toggleMiniModeText": "Minimera", - "destroyText": "Stäng spelaren och rensa kön", - "downloadText": "Ladda ner", - "removeAudioListsText": "Rensa kön", - "clickToDeleteText": "Klicka för att ta bort %{name}", - "emptyLyricText": "Ingen låttext", - "playModeText": { - "order": "I ordningsföljd", - "orderLoop": "Upprepa alla", - "singleLoop": "Upprepa en låt", - "shufflePlay": "Blanda" + "about": { + "links": { + "homepage": "Hemsida", + "source": "Källkod", + "featureRequests": "Github issues" + } + }, + "activity": { + "title": "Aktivitet", + "totalScanned": "Genomsökta mappar", + "quickScan": "Snabb genomsökning", + "fullScan": "Fullständig genomsökning\n", + "serverUptime": "Server uptime", + "serverDown": "OFFLINE" + }, + "help": { + "title": "Navidrome Hotkeys", + "hotkeys": { + "show_help": "Visa denna hjälp", + "toggle_menu": "Växla meny", + "toggle_play": "Spela / Pausa", + "prev_song": "Föregående låt", + "next_song": "Nästa låt", + "vol_up": "Volym Upp", + "vol_down": "Volym Ner", + "toggle_love": "Lägg till låt i Favoriter", + "current_song": "" + } } - }, - "about": { - "links": { - "homepage": "Webbplats", - "source": "Källkod", - "featureRequests": "Förslag på nya funktioner", - "lastInsightsCollection": "Senaste insamling av användningsdata", - "insights": { - "disabled": "Inaktiverad", - "waiting": "Väntar" - } - }, - "tabs": { - "about": "Om", - "config": "Konfiguration" - }, - "config": { - "configName": "Inställning", - "environmentVariable": "Miljövariabel", - "currentValue": "Aktuellt värde", - "configurationFile": "Konfigurationsfil", - "exportToml": "Exportera konfiguration (TOML)", - "exportSuccess": "Konfigurationen har kopierats till urklipp i TOML-format", - "exportFailed": "Kunde inte kopiera konfigurationen", - "devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)", - "devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner", - "downloadToml": "Ladda ner konfiguration (TOML)" - } - }, - "activity": { - "title": "Aktivitet", - "totalScanned": "Skannade mappar totalt", - "quickScan": "Snabb", - "fullScan": "Fullständig", - "serverUptime": "Serverns drifttid", - "serverDown": "OFFLINE", - "scanType": "Senaste skanning", - "status": "Skanningsfel", - "elapsedTime": "Förfluten tid", - "selectiveScan": "Selektiv" - }, - "help": { - "title": "Kortkommandon i Navidrome", - "hotkeys": { - "show_help": "Visa denna hjälp", - "toggle_menu": "Visa eller dölj sidomenyn", - "toggle_play": "Spela / pausa", - "prev_song": "Föregående låt", - "next_song": "Nästa låt", - "vol_up": "Höj volymen", - "vol_down": "Sänk volymen", - "toggle_love": "Lägg till låten i favoriter", - "current_song": "Gå till aktuell låt" - } - }, - "nowPlaying": { - "title": "Spelas nu", - "empty": "Inget spelas", - "minutesAgo": "%{smart_count} minut sedan |||| %{smart_count} minuter sedan" - } -} +} \ No newline at end of file diff --git a/resources/i18n/th.json b/resources/i18n/th.json index 3c5c9e07b..328c74757 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -1,734 +1,456 @@ { - "languageName": "ไทย", - "resources": { - "song": { - "name": "เพลง", - "fields": { - "albumArtist": "ศิลปินในอัลบั้ม", - "duration": "ความยาว", - "trackNumber": "#", - "playCount": "เล่นแล้ว", - "title": "ชื่อเพลง", - "artist": "ศิลปิน", - "album": "อัลบั้ม", - "path": "ที่อยู่ไฟล์", - "genre": "ประเภท", - "compilation": "รวมเพลง", - "year": "ปี", - "size": "ขนาด", - "updatedAt": "อัปเดตเมื่อ", - "bitRate": "บิตเรท", - "discSubtitle": "คำบรรยาย", - "starred": "รายการโปรด", - "comment": "ความคิดเห็น", - "rating": "ความนิยม", - "quality": "คุณภาพเสียง", - "bpm": "BPM", - "playDate": "เล่นล่าสุด", - "channels": "ช่อง", - "createdAt": "เพิ่มเมื่อ", - "grouping": "จัดกลุ่ม", - "mood": "อารมณ์", - "participants": "ผู้มีส่วนร่วม", - "tags": "แทกเพิ่มเติม", - "mappedTags": "แมพแทก", - "rawTags": "แทกเริ่มต้น", - "bitDepth": "Bit depth", - "sampleRate": "แซมเปิ้ลเรต", - "missing": "หายไป", - "libraryName": "ห้องสมุด", - "composer": "ผู้แต่ง", - "disc": "พื้นที่ %{discNumber}", - "albumGain": "เนื้อหาในอัลบั้ม", - "trackGain": "เนื้อหาในเพลง" - }, - "actions": { - "addToQueue": "เพิ่มในคิว", - "playNow": "เล่นทันที", - "addToPlaylist": "เพิ่มในเพลย์ลิสต์", - "shuffleAll": "สุ่มทั้งหมด", - "download": "ดาวน์โหลด", - "playNext": "เล่นถัดไป", - "info": "ดูรายละเอียด", - "showInPlaylist": "แสดงในเพลย์ลิสต์", - "instantMix": "อินสแตนต์ มิก" - } + "languageName": "ไทย", + "resources": { + "song": { + "name": "เพลง", + "fields": { + "albumArtist": "ศิลปินอัลบั้ม", + "duration": "ความยาว", + "trackNumber": "#", + "playCount": "เล่น", + "title": "ชื่อเพลง", + "artist": "ศิลปิน", + "album": "อัลบั้ม", + "path": "ที่อยู่ไฟล์", + "genre": "ประเภท", + "compilation": "รวมเพลง", + "year": "ปี", + "size": "ขนาด", + "updatedAt": "อัปเดตล่าสุด", + "bitRate": "บิตเรท", + "discSubtitle": "คำบรรยาย", + "starred": "รายการโปรด", + "comment": "ความคิดเห็น", + "rating": "Rating", + "quality": "คุณภาพ", + "bpm": "BPM", + "playDate": "เล่นล่าสุด", + "channels": "ช่อง", + "createdAt": "" + }, + "actions": { + "addToQueue": "เล่นหลังสุด", + "playNow": "เล่นทันที", + "addToPlaylist": "เพิ่มในเพลย์ลิสต์", + "shuffleAll": "สุ่มทั้งหมด", + "download": "ดาวน์โหลด", + "playNext": "เล่นเพลงถัดไป", + "info": "ดูรายละเอียด" + } + }, + "album": { + "name": "อัลบั้ม", + "fields": { + "albumArtist": "ศิลปินอัลบั้ม", + "artist": "ศิลปิน", + "duration": "ความยาว", + "songCount": "เพลง", + "playCount": "เล่น", + "name": "ชื่อ", + "genre": "ประเภท", + "compilation": "รวมเพลง", + "year": "ปี", + "updatedAt": "อัพเดตเมื่อ", + "comment": "ความคิดเห็น", + "rating": "Rating", + "createdAt": "", + "size": "" + }, + "actions": { + "playAll": "เล่นทั้งหมด", + "playNext": "เล่นถัดไป", + "addToQueue": "เล่นหลังสุด", + "shuffle": "เล่นแบบสุ่ม", + "addToPlaylist": "เพิ่งลงในเพลย์ลิสต์", + "download": "ดาวน์โหลด", + "info": "ดูรายละเอียด", + "share": "" + }, + "lists": { + "all": "ทั้งหมด", + "random": "สุ่ม", + "recentlyAdded": "เพิ่มล่าสุด", + "recentlyPlayed": "เล่นล่าสุด", + "mostPlayed": "เล่นมากที่สุด", + "starred": "รายการโปรด", + "topRated": "Top Rated" + } + }, + "artist": { + "name": "ศิลปิน", + "fields": { + "name": "ชื่อ", + "albumCount": "อัลบั้ม", + "songCount": "จำนวนเพลง", + "playCount": "เล่น", + "rating": "Rating", + "genre": "ประเภท", + "size": "" + } + }, + "user": { + "name": "ผู้ใช้", + "fields": { + "userName": "ชื่อผู้ใช้งาน", + "isAdmin": "เป็น Admin", + "lastLoginAt": "ล็อกอินล่าสุด", + "updatedAt": "อัปเดตล่าสุด", + "name": "ชื่อ", + "password": "รหัสผ่าน", + "createdAt": "สร้างเมื่อ", + "changePassword": "เปลี่ยนรหัสผ่าน", + "currentPassword": "รหัสผ่านปัจจุบัน", + "newPassword": "รหัสผ่านใหม่", + "token": "" + }, + "helperTexts": { + "name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป" + }, + "notifications": { + "created": "สร้างผู้ใช้งาน", + "updated": "อัพเดตผู้ใช้งาน", + "deleted": "ลบผู้ใช้งาน" + }, + "message": { + "listenBrainzToken": "", + "clickHereForToken": "" + } + }, + "player": { + "name": "เพลย์เยอร์", + "fields": { + "name": "ชื่อ", + "transcodingId": "Transcoding", + "maxBitRate": "บิตเรทสูงสุด", + "client": "Client", + "userName": "ชื่อผู้ใช้งาน", + "lastSeen": "ใช้งานล่าสุดเมื่อ", + "reportRealPath": "รายงาน Real Path", + "scrobbleEnabled": "" + } + }, + "transcoding": { + "name": "Transcoding |||| Transcodings", + "fields": { + "name": "ชื่อ", + "targetFormat": "ฟอร์แมตปลายทาง", + "defaultBitRate": "บิตเรท", + "command": "คำสั่ง" + } + }, + "playlist": { + "name": "เพลย์ลิสต์", + "fields": { + "name": "ชื่อ", + "duration": "เวลา", + "ownerName": "เจ้าของ", + "public": "สาธารณะ", + "updatedAt": "อัปเดตเมื่อ", + "createdAt": "สร้างขึ้นเมื่อ", + "songCount": "เพลง", + "comment": "ความคิดเห็น", + "sync": "นำเข้าอัตโนมัติ", + "path": "นำเข้าจาก" + }, + "actions": { + "selectPlaylist": "เลือกเพลย์ลิสต์", + "addNewPlaylist": "สร้าง \"%{name}\"", + "export": "ส่งออก", + "makePublic": "", + "makePrivate": "" + }, + "message": { + "duplicate_song": "เพิ่มเพลงที่ซ้ำ", + "song_exist": "มีเพลงที่ซ้ำกันเพิ่มในเพลยลิสต์ เพิ่มเพลงนั้นหรือข้าม" + } + }, + "radio": { + "name": "", + "fields": { + "name": "", + "streamUrl": "", + "homePageUrl": "", + "updatedAt": "", + "createdAt": "" + }, + "actions": { + "playNow": "" + } + }, + "share": { + "name": "", + "fields": { + "username": "", + "url": "", + "description": "", + "contents": "", + "expiresAt": "", + "lastVisitedAt": "", + "visitCount": "", + "format": "", + "maxBitRate": "", + "updatedAt": "", + "createdAt": "", + "downloadable": "" + } + } }, - "album": { - "name": "อัลบั้ม", - "fields": { - "albumArtist": "ศิลปินในอัลบั้ม", - "artist": "ศิลปิน", - "duration": "ความยาว", - "songCount": "เพลง", - "playCount": "เล่นแล้ว", - "name": "ชื่ออัลบั้ม", - "genre": "ประเภท", - "compilation": "รวมเพลง", - "year": "ปี", - "updatedAt": "อัพเดตเมื่อ", - "comment": "ความคิดเห็น", - "rating": "ความนิยม", - "createdAt": "เพิ่มเมื่อ", - "size": "ขนาด", - "originalDate": "วันที่เริ่ม", - "releaseDate": "เผยแพร่เมื่อ", - "releases": "เผยแพร่ |||| เผยแพร่", - "released": "เผยแพร่เมื่อ", - "recordLabel": "ป้าย", - "catalogNum": "หมายเลขแคตาล็อก", - "releaseType": "ประเภท", - "grouping": "จัดกลุ่ม", - "media": "มีเดีย", - "mood": "อารมณ์", - "date": "บันทึกเมื่อ", - "missing": "หายไป", - "libraryName": "ห้องสมุด" - }, - "actions": { - "playAll": "เล่นทั้งหมด", - "playNext": "เล่นถัดไป", - "addToQueue": "เพิ่มในคิว", - "shuffle": "เล่นแบบสุ่ม", - "addToPlaylist": "เพิ่มลงในเพลย์ลิสต์", - "download": "ดาวน์โหลด", - "info": "ดูรายละเอียด", - "share": "แบ่งปัน", - "refresh": "" - }, - "lists": { - "all": "ทั้งหมด", - "random": "สุ่ม", - "recentlyAdded": "เพิ่มล่าสุด", - "recentlyPlayed": "เล่นล่าสุด", - "mostPlayed": "เล่นมากที่สุด", - "starred": "รายการโปรด", - "topRated": "ความนิยมสูง" - } - }, - "artist": { - "name": "ศิลปิน", - "fields": { - "name": "ชื่อศิลปิน", - "albumCount": "จำนวนอัลบั้ม", - "songCount": "จำนวนเพลง", - "playCount": "เล่นแล้ว", - "rating": "ความนิยม", - "genre": "ประเภท", - "size": "ขนาด", - "role": "Role", - "missing": "หายไป" - }, - "roles": { - "albumartist": "ศิลปินอัลบั้ม |||| ศิลปินอัลบั้ม", - "artist": "ศิลปิน |||| ศิลปิน", - "composer": "ผู้แต่ง |||| ผู้แต่ง", - "conductor": "คอนดักเตอร์ |||| คอนดักเตอร์", - "lyricist": "เนื้อเพลง |||| เนื้อเพลง", - "arranger": "ผู้ดำเนินการ |||| ผู้ดำเนินการ", - "producer": "ผู้จัด |||| ผู้จัด", - "director": "ไดเรกเตอร์ |||| ไดเรกเตอร์", - "engineer": "วิศวกร |||| วิศวกร", - "mixer": "มิกเซอร์ |||| มิกเซอร์", - "remixer": "รีมิกเซอร์ |||| รีมิกเซอร์", - "djmixer": "ดีเจมิกเซอร์ |||| ดีเจมิกเซอร์", - "performer": "ผู้เล่น |||| ผู้เล่น", - "maincredit": "ศิลปิน |||| ศิลปิน" - }, - "actions": { - "shuffle": "เล่นสุ่ม", - "radio": "วิทยุ", - "topSongs": "เพลงยอดนิยม" - } - }, - "user": { - "name": "บัญชีผู้ใช้", - "fields": { - "userName": "ชื่อผู้ใช้", - "isAdmin": "ผู้ดูแลระบบ?", - "lastLoginAt": "ล็อกอินล่าสุด", - "updatedAt": "อัปเดตล่าสุด", - "name": "ชื่อ", - "password": "รหัสผ่าน", - "createdAt": "สร้างเมื่อ", - "changePassword": "เปลี่ยนรหัสผ่าน", - "currentPassword": "รหัสผ่านปัจจุบัน", - "newPassword": "รหัสผ่านใหม่", - "token": "โทเคน", - "lastAccessAt": "เข้าใช้ล่าสุด", - "libraries": "ห้องสมุด", - "scrobbleFilter": "" - }, - "helperTexts": { - "name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป", - "libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น", - "scrobbleFilter": "" - }, - "notifications": { - "created": "สร้างชื่อผู้ใช้", - "updated": "อัพเดตชื่อผู้ใช้", - "deleted": "ลบชื่อผู้ใช้" - }, - "message": { - "listenBrainzToken": "ใส่โทเคน ListenBrainz ของคุณ", - "clickHereForToken": "กดที่นี่เพื่อรับโทเคนของคุณ", - "selectAllLibraries": "เลือกห้องสมุดทั้งหมด", - "adminAutoLibraries": "ผู้ดูแลเข้าถึงห้องสมุดทั้งหมดโดยอัตโนมัติ" - }, - "validation": { - "librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล", - "invalidScrobbleFilter": "" - } - }, - "player": { - "name": "เพลย์เยอร์", - "fields": { - "name": "เล่นจาก", - "transcodingId": "แปลงไฟล์", - "maxBitRate": "บิตเรทสูงสุด", - "client": "ลูกข่าย", - "userName": "ชื่อผู้ใช้", - "lastSeen": "ใช้งานล่าสุดเมื่อ", - "reportRealPath": "รายงาน Real Path", - "scrobbleEnabled": "ส่ง scrobble ไปยังบริการภายนอก" - } - }, - "transcoding": { - "name": "แปลงไฟล์", - "fields": { - "name": "ชื่อ", - "targetFormat": "ชนิดไฟล์เสียง", - "defaultBitRate": "บิตเรท", - "command": "คำสั่ง" - }, - "choices": { - "noDefaultBitRate": "" - } - }, - "playlist": { - "name": "เพลย์ลิสต์", - "fields": { - "name": "ชื่อเพลย์ลิสต์", - "duration": "ความยาว", - "ownerName": "เจ้าของ", - "public": "สาธารณะ", - "updatedAt": "อัปเดตเมื่อ", - "createdAt": "สร้างเมื่อ", - "songCount": "เพลง", - "comment": "ความคิดเห็น", - "sync": "นำเข้าอัตโนมัติ", - "path": "นำเข้าจาก", - "starred": "ชื่นชอบ" - }, - "actions": { - "selectPlaylist": "เลือกเพลย์ลิสต์", - "addNewPlaylist": "สร้าง \"%{name}\"", - "export": "ส่งออก", - "makePublic": "ทำเป็นสาธารณะ", - "makePrivate": "ทำเป็นส่วนตัว", - "saveQueue": "บันทึกคิวลงเพลย์ลิสต์", - "searchOrCreate": "ค้นหาเพลย์ลิสต์หรือพิมพ์เพื่อสร้างใหม่", - "pressEnterToCreate": "กด Enter เพื่อสร้างเพลย์ลิสต์", - "removeFromSelection": "เอาออกจากที่เลือกไว้" - }, - "message": { - "duplicate_song": "เพิ่มเพลงซ้ำ", - "song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม", - "noPlaylistsFound": "ไม่พบเพลย์ลิสต์", - "noPlaylists": "ไม่มีเพลย์ลิสต์อยู่" - } - }, - "radio": { - "name": "สถานีวิทยุ |||| สถานีวิทยุ", - "fields": { - "name": "ชื่อสถานี", - "streamUrl": "สตรีม URL", - "homePageUrl": "โฮมเพจ URL", - "updatedAt": "อัพเดทเมื่อ", - "createdAt": "สร้างเมื่อ" - }, - "actions": { - "playNow": "เล่น" - } - }, - "share": { - "name": "แบ่งปัน |||| แบ่งปัน", - "fields": { - "username": "แบ่งปันโดย", - "url": "URL", - "description": "คำอธิบาย", - "contents": "เนื้อหา", - "expiresAt": "หมดอายุเมื่อ", - "lastVisitedAt": "เยี่ยมชมครั้งล่าสุด", - "visitCount": "เยี่ยมชม", - "format": "ประเภทไฟล์", - "maxBitRate": "บิตเรตสูงสุด", - "updatedAt": "อัปเดตเมื่อ", - "createdAt": "สร้างเมื่อ", - "downloadable": "อนุญาตให้ดาวโหลด?" - } - }, - "missing": { - "name": "ไฟล์ที่หายไป |||| ไฟล์ที่หายไป", - "fields": { - "path": "พาร์ท", - "size": "ขนาด", - "updatedAt": "หายไปจาก", - "libraryName": "ห้องสมุด" - }, - "actions": { - "remove": "เอาออก", - "remove_all": "เอาออกทั้งหมด" - }, - "notifications": { - "removed": "เอาไฟล์ที่หายไปออกแล้ว" - }, - "empty": "ไม่มีไฟล์หาย" - }, - "library": { - "name": "ห้องสมุด |||| ห้องสมุด", - "fields": { - "name": "ชื่อ", - "path": "พาร์ท", - "remotePath": "รีโมทพาร์ท", - "lastScanAt": "สแกนล่าสุด", - "songCount": "เพลง", - "albumCount": "อัลบัม", - "artistCount": "ศิลปิน", - "totalSongs": "เพลง", - "totalAlbums": "อัลบัม", - "totalArtists": "ศิลปิน", - "totalFolders": "แฟ้ม", - "totalFiles": "ไฟล์", - "totalMissingFiles": "ไฟล์ที่หายไป", - "totalSize": "ขนาดทั้งหมด", - "totalDuration": "ความยาว", - "defaultNewUsers": "ค่าเริ่มต้นผู้ใช้ใหม่", - "createdAt": "สร้าง", - "updatedAt": "อัพเดท" - }, - "sections": { - "basic": "ข้อมูลเบื้องต้น", - "statistics": "สถิติ" - }, - "actions": { - "scan": "สแกนห้องสมุด", - "manageUsers": "ตั้งค่าการเข้าถึง", - "viewDetails": "ดูรายละเอียด", - "quickScan": "สแกนแบบเร็ว", - "fullScan": "สแกนแบบเต็ม" - }, - "notifications": { - "created": "สร้างห้องสมุดเรียบร้อย", - "updated": "อัพเดทห้องสมุดเรียบร้อย", - "deleted": "ลบห้องสมุดเพลงเรียบร้อยแล้ว", - "scanStarted": "เริ่มสแกนห้องสมุด", - "scanCompleted": "สแกนห้องสมุดเสร็จแล้ว", - "quickScanStarted": "เริ่มสแกนแบบเร็ว", - "fullScanStarted": "เริ่มสแกนแบบเต็ม", - "scanError": "การเริ่มสแกนผิดพลาด ดูในบันทึก" - }, - "validation": { - "nameRequired": "ต้องใส่ชื่อห้องสมุดเพลง", - "pathRequired": "ต้องใส่พาร์ทของห้องสมุด", - "pathNotDirectory": "พาร์ทของห้องสมุดต้องเป็นแฟ้ม", - "pathNotFound": "ไม่เจอพาร์ทของห้องสมุด", - "pathNotAccessible": "ไม่สามารถเข้าพาร์ทของห้องสมุด", - "pathInvalid": "พาร์ทห้องสมุดไม่ถูก" - }, - "messages": { - "deleteConfirm": "คุณแน่ใจว่าจะลบห้องสมุดนี้? นี่จะลบข้อมูลและการเข้าถึงของผู้ใช้ที่เกี่ยวข้องทั้งหมด", - "scanInProgress": "กำลังสแกน...", - "noLibrariesAssigned": "ไม่มีห้องสมุดสำหรับผู้ใช้นี้" - } - }, - "plugin": { - "name": "ปลั๊กอิน |||| ปลั๊กอิน", - "fields": { - "id": "ID", - "name": "ชื่อ", - "description": "รายละเอียด", - "version": "เวอร์ชั่น", - "author": "ผู้สร้าง", - "website": "เว็บไซต์", - "permissions": "การอนุญาติ", - "enabled": "เปิดใช้", - "status": "สถานะ", - "path": "เส้นทาง", - "lastError": "ผิดพลาด", - "hasError": "ผิดพลาด", - "updatedAt": "อัพเดทแล้ว", - "createdAt": "ติดตั้งแล้ว", - "configKey": "คีย์", - "configValue": "ค่า", - "allUsers": "อนุญาติผู้ใช้ทั้งหมด", - "selectedUsers": "ผู้ใช้ถูกเลือก", - "allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด", - "selectedLibraries": "ห้องสมุดเพลงถูกเลือก", - "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": "ต้องการ Host", - "configValidationError": "การตั้งค่าเกิดความผิดพลาด", - "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน", - "allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น", - "idHelp": "" - }, - "placeholders": { - "configKey": "คีย์", - "configValue": "ค่า" - } - } - }, - "ra": { - "auth": { - "welcome1": "ขอบคุณที่ติดตั้ง Navidrome!", - "welcome2": "สร้างบัญชี Admin เพื่อเริ่มใช้งาน", - "confirmPassword": "ยืนยันรหัสผ่าน", - "buttonCreateAdmin": "สร้างบัญชี Admin", - "auth_check_error": "กรุณาลงชื่อเข้าใช้เพื่อดำเนินการต่อ", - "user_menu": "โปรไฟล์", - "username": "ชื่อผู้ใช้", - "password": "รหัสผ่าน", - "sign_in": "เข้าสู่ระบบ", - "sign_in_error": "การยืนยันตัวตนล้มเหลว โปรดลองอีกครั้ง", - "logout": "ลงชื่อออก", - "insightsCollectionNote": "Navidrome เก็บข้อมูลการใช้ที่ไม่ระบุตัวตน\nเพื่อนำไปปรับปรุงโปรแกรม\nกดที่นี่ [here] เพื่อเรียนรู้เพิ่มเติม" - }, - "validation": { - "invalidChars": "กรุณาใช้ตัวอักษรภาษาอังกฤษและตัวเลขเท่านั้น", - "passwordDoesNotMatch": "รหัสผ่านไม่ตรงกัน", - "required": "ต้องการ", - "minLength": "ต้องมี %{min} ตัวอักษรเป็นอย่างน้อย", - "maxLength": "มีได้มากสุด %{max} ตัวอักษร", - "minValue": "ต้องมีอย่างน้อย %{min}", - "maxValue": "มีได้มากสุด %{max}", - "number": "เป็นตัวเลขเท่านั้น", - "email": "เป็นอีเมลที่ถูกต้องเท่านั้น", - "oneOf": "ต้องเป็นหนึ่งใน %{options}", - "regex": "ต้องเป็นฟอร์แมตเฉพาะ (regexp): %{pattern}", - "unique": "ต้องมีความพิเศษ", - "url": "ต้องเป็น URL ที่ถูกต้อง" - }, - "action": { - "add_filter": "เพิ่มตัวกรอง", - "add": "เพิ่ม", - "back": "ย้อนกลับ", - "bulk_actions": "เลือก %{smart_count} ไฟล์", - "cancel": "ยกเลิก", - "clear_input_value": "ล้างค่า", - "clone": "ทำสำเนา", - "confirm": "ยืนยัน", - "create": "สร้าง", - "delete": "ลบ", - "edit": "แก้ไข", - "export": "ส่งออก", - "list": "รายชื่อ", - "refresh": "รีเฟรช", - "remove_filter": "ลบตัวกรองนี้", - "remove": "ลบ", - "save": "บันทึก", - "search": "ค้นหา", - "show": "แสดง", - "sort": "เรียงลำดับ", - "undo": "เลิกทำ", - "expand": "ขยาย", - "close": "ปิด", - "open_menu": "เปิดเมนู", - "close_menu": "ปิดเมนู", - "unselect": "ยกเลิก", - "skip": "ข้าม", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "แบ่งปัน", - "download": "ดาวน์โหลด" - }, - "boolean": { - "true": "ใช่", - "false": "ไม่" - }, - "page": { - "create": "สร้าง %{name}", - "dashboard": "แดชบอร์ด", - "edit": "%{name} #%{id}", - "error": "มีบางอย่างผิดพลาด", - "list": "%{name}", - "loading": "กำลังโหลด", - "not_found": "ไม่พบ", - "show": "%{name} #%{id}", - "empty": "ยังไม่มี %{name}", - "invite": "ต้องการที่จะเพิ่มหรือไม่?" - }, - "input": { - "file": { - "upload_several": "ลากแล้ววางหรือเลือกไฟล์เพื่ออัปโหลด", - "upload_single": "ลากแล้ววางหรือเลือกไฟล์เพื่ออัปโหลด" - }, - "image": { - "upload_several": "ลากแล้ววางหรือเลือกรูปภาพเพื่ออัปโหลด", - "upload_single": "ลากแล้ววางหรือเลือกรูปภาพเพื่ออัปโหลด" - }, - "references": { - "all_missing": "ไม่สามารถหาข้อมูลได้", - "many_missing": "ข้อมูลสูญหายหลายรายการ", - "single_missing": "ข้อมูลสูญหาย" - }, - "password": { - "toggle_visible": "ซ่อนรหัสผ่าน", - "toggle_hidden": "แสดงรหัสผ่าน" - } + "ra": { + "auth": { + "welcome1": "ขอบคุณที่ติดตั้ง Navidrome!", + "welcome2": "สร้างบัญชี Admin เพื่อเริ่มใช้งาน", + "confirmPassword": "ยืนยันรหัสผ่าน", + "buttonCreateAdmin": "สร้างบัญชี Admin", + "auth_check_error": "กรุณาลงชื่อเข้าใช้เพื่อดำเนินการต่อ", + "user_menu": "โปรไฟล์", + "username": "ชื่อผู้ใช้", + "password": "รหัสผ่าน", + "sign_in": "เข้าสู่ระบบ", + "sign_in_error": "การยืนยันตัวตนล้มเหลว โปรดลองอีกครั้ง", + "logout": "ลงชื่อออก" + }, + "validation": { + "invalidChars": "กรุณาใช้ตัวอักษรภาษาอังกฤษและตัวเลขเท่านั้น", + "passwordDoesNotMatch": "รหัสผ่านไม่ตรงกัน", + "required": "ต้องการ", + "minLength": "ต้องมี %{min} ตัวอักษรเป็นอย่างน้อย", + "maxLength": "ต้องมีน้อยกว่าหรือเท่ากับ %{max} ตัวอักษร", + "minValue": "ต้องมีอย่างน้อย %{min}", + "maxValue": "ต้องมี %{max} หรือน้อยกว่า", + "number": "เป็นตัวเลขเท่านั้น", + "email": "เป็นอีเมลที่ถูกต้องเท่านั้น", + "oneOf": "ต้องเป็นหนึ่งใน %{options}", + "regex": "ต้องเป็นฟอร์แมตเฉพาะ (regexp): %{pattern}", + "unique": "ต้องมีความพิเศษ", + "url": "" + }, + "action": { + "add_filter": "เพิ่มตัวกรอง", + "add": "เพิ่ม", + "back": "ย้อนกลับ", + "bulk_actions": "เลือก %{smart_count} ไฟล์", + "cancel": "ยกเลิก", + "clear_input_value": "ล้างค่า", + "clone": "Clone", + "confirm": "ยืนยัน", + "create": "สร้าง", + "delete": "ลบ", + "edit": "แก้ไข", + "export": "ส่งออก", + "list": "รายชื่อ", + "refresh": "รีเฟรช", + "remove_filter": "ลบตัวกรองนี้", + "remove": "ลบ", + "save": "บันทึก", + "search": "ค้นหา", + "show": "แสดง", + "sort": "เรียงลำดับ", + "undo": "ก่อนหน้า", + "expand": "ขยาย", + "close": "ปิด", + "open_menu": "เปิดเมนู", + "close_menu": "ปิดเมนู", + "unselect": "ยกเลิก", + "skip": "ข้าม", + "bulk_actions_mobile": "", + "share": "", + "download": "" + }, + "boolean": { + "true": "ใช่", + "false": "ไม่" + }, + "page": { + "create": "สร้าง %{name}", + "dashboard": "แดชบอร์ด", + "edit": "%{name} #%{id}", + "error": "มีบางอย่างผิดพลาด", + "list": "%{name}", + "loading": "กำลังโหลด", + "not_found": "ไม่พบ", + "show": "%{name} #%{id}", + "empty": "ยังไม่มี %{name}", + "invite": "ต้องการที่จะเพิ่มหรือไม่?" + }, + "input": { + "file": { + "upload_several": "ลากแล้ววางหรือเลือกไฟล์เพื่ออัปโหลด", + "upload_single": "ลากแล้ววางหรือเลือกไฟล์เพื่ออัปโหลด" + }, + "image": { + "upload_several": "ลากแล้ววางหรือเลือกรูปภาพเพื่ออัปโหลด", + "upload_single": "ลากแล้ววางหรือเลือกรูปภาพเพื่ออัปโหลด" + }, + "references": { + "all_missing": "ไม่สามารถหาข้อมูลได้", + "many_missing": "ข้อมูลสูญหาย", + "single_missing": "ข้อมูลสูญหาย" + }, + "password": { + "toggle_visible": "ซ่อนรหัสผ่าน", + "toggle_hidden": "แสดงรหัสผ่าน" + } + }, + "message": { + "about": "เกี่ยวกับ", + "are_you_sure": "คุณแน่ใจหรือไม่?", + "bulk_delete_content": "คุณแน่ใจที่ต้องการลบ %{name}? |||| คุณแน่ใจที่ต้องการลบข้อมูล %{smart_count} ชิ้นนี้?\n", + "bulk_delete_title": "ลบ %{name} |||| ลบ %{smart_count} %{name}", + "delete_content": "คุณแน่ใจที่จะลบข้อมูลนี้?", + "delete_title": "ลบ %{name} #%{id}", + "details": "รายละเอียด", + "error": "เกิดข้อผิดพลาดที่ Client ไม่สามารถดำเนินคำขอของท่านได้", + "invalid_form": "แบบฟอร์มไม่ถูกต้อง กรุณาตรวจสอบข้อผิดพลาด", + "loading": "กำลังโหลดหน้านี้ โปรดรอสักครู่", + "no": "ไม่", + "not_found": "URL ผิดพลาดหรือลิงค์ไม่ทำงาน", + "yes": "ใช่", + "unsaved_changes": "การเปลี่ยนแปลงของบางส่วนจะไม่ถูกบันทึก คุณแน่ใจหรือไม่?" + }, + "navigation": { + "no_results": "ไม่พบผลการค้นหา", + "no_more_results": "หน้าที่ %{page} เกินขีดจำกัดแล้ว กรุณาลองหน้าก่อนหน้า", + "page_out_of_boundaries": "หน้าที่ %{page} เกินจำนวนหน้าสูงสุด", + "page_out_from_end": "ไม่สามารถไปต่อจากหน้าสุดท้ายได้", + "page_out_from_begin": "ไม่สามารถไปก่อนหน้าที่ 1 ได้", + "page_range_info": "%{offsetBegin}-%{offsetEnd} จาก %{total}", + "page_rows_per_page": "จำนวนในหนึ่งหน้า:", + "next": "ถัดไป", + "prev": "ก่อนหน้า", + "skip_nav": "ข้ามไปยังเนื้อหา" + }, + "notification": { + "updated": "อัพเดตองค์ประกอบเรียบร้อย |||| %{smart_count} องค์ประกอบถูกอัพเดตเรียบร้อย", + "created": "สร้างองค์ประกอบแล้ว", + "deleted": "ลบองค์ประกอบเสร็จสิ้น |||| องค์ลบ %{smart_count} องค์ประกอบเสร็จสิ้น", + "bad_item": "องค์ประกอบไม่ถูกต้อง", + "item_doesnt_exist": "ไม่มีองค์ประกอบนี้อยู่", + "http_error": "การเชื่อมต่อเซิฟเวอร์ผิดพลาด", + "data_provider_error": "dataProviderผิดพลาด โปรดตรวจสอบคอนโซลเพื่อดูรายละเอียด", + "i18n_error": "ไม่สามารถเรียกคำแปลของภาษาที่เลือกได้", + "canceled": "ยกเลิกการกระทำแล้ว", + "logged_out": "เซสชั่นของท่านสิ้นสุดแล้ว โปรดเชื่อมต่ออีกครั้ง", + "new_version": "มีเวอร์ชั่นใหม่! กรุณารีเฟรชหน้าจอนี้" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "แสดงคอลัมน์", + "layout": "เลย์เอ้าท์", + "grid": "Grid", + "table": "Table" + } }, "message": { - "about": "เกี่ยวกับ", - "are_you_sure": "คุณแน่ใจหรือไม่?", - "bulk_delete_content": "คุณแน่ใจที่จะลบ %{name}? |||| คุณแน่ใจที่จะลบข้อมูล %{smart_count} เหล่านี้?", - "bulk_delete_title": "ลบ %{name} |||| ลบ %{smart_count} %{name}", - "delete_content": "คุณแน่ใจที่จะลบข้อมูลนี้?", - "delete_title": "ลบ %{name} #%{id}", - "details": "รายละเอียด", - "error": "เกิดข้อผิดพลาดที่ลูกข่าย ไม่สามารถดำเนินการคำขอของท่านได้", - "invalid_form": "แบบฟอร์มไม่ถูกต้อง กรุณาตรวจสอบข้อผิดพลาด", - "loading": "กำลังโหลดหน้านี้ โปรดรอสักครู่", - "no": "ไม่", - "not_found": "URL ผิดพลาดหรือลิงค์ไม่ทำงาน", - "yes": "ใช่", - "unsaved_changes": "การเปลี่ยนแปลงของท่านบางส่วนจะไม่ถูกบันทึก คุณแน่ใจหรือไม่?" - }, - "navigation": { - "no_results": "ไม่พบผลการค้นหา", - "no_more_results": "หน้าที่ %{page} เกินขีดจำกัดแล้ว กรุณาลองหน้าก่อนหน้า", - "page_out_of_boundaries": "หน้าที่ %{page} เกินจำนวนหน้าสูงสุด", - "page_out_from_end": "ไม่สามารถไปต่อจากหน้าสุดท้ายได้", - "page_out_from_begin": "ไม่สามารถไปก่อนหน้าที่ 1 ได้", - "page_range_info": "%{offsetBegin}-%{offsetEnd} จาก %{total}", - "page_rows_per_page": "จำนวนในหนึ่งหน้า:", - "next": "ถัดไป", - "prev": "ก่อนหน้า", - "skip_nav": "ข้ามไปยังเนื้อหา" - }, - "notification": { - "updated": "อัพเดตองค์ประกอบเรียบร้อย |||| %{smart_count} องค์ประกอบถูกอัพเดตเรียบร้อย", - "created": "สร้างองค์ประกอบแล้ว", - "deleted": "ลบองค์ประกอบเสร็จสิ้น |||| องค์ลบ %{smart_count} องค์ประกอบเสร็จสิ้น", - "bad_item": "องค์ประกอบไม่ถูกต้อง", - "item_doesnt_exist": "ไม่มีองค์ประกอบนี้อยู่", - "http_error": "การเชื่อมต่อเซิฟเวอร์ผิดพลาด", - "data_provider_error": "dataProviderผิดพลาด โปรดตรวจสอบคอนโซลเพื่อดูรายละเอียด", - "i18n_error": "ไม่สามารถเรียกคำแปลของภาษาที่เลือกได้", - "canceled": "ยกเลิกการกระทำแล้ว", - "logged_out": "เซสชั่นของท่านสิ้นสุดแล้ว โปรดเชื่อมต่ออีกครั้ง", - "new_version": "มีเวอร์ชั่นใหม่! กรุณารีเฟรชหน้าจอนี้" - }, - "toggleFieldsMenu": { - "columnsToDisplay": "แสดงคอลัมน์", - "layout": "เลย์เอ้าท์", - "grid": "แบบรูปภาพ", - "table": "แบบตาราง" - } - }, - "message": { - "note": "หมายเหตุ", - "transcodingDisabled": "การตั้งค่าในการแปลงไฟล์บนเว็บไซต์ถูกปิดเพื่อความปลอดภัย หากต้องการเปลี่ยนแปลงการตั้งค่า (แก้ไขหรือเพิ่ม) ให้ใช้ %{config} ในอ๊อฟชั่นในไฟล์คอนฟิก จากนั้นจึงรีสตาร์ทเซิฟเวอร์", - "transcodingEnabled": "Navidrome กำลังทำงานโดยใช้ %{config} ทำให้สามารถใช้งานคำสั่งของ ระบบจากตั้งค่าการแปลงไฟล์ บนหน้าเว็บได้ ทางเราแนะนำให้ท่านปิดการตั้งค่านี้เพื่อความปลอดภัย และเปิดเมื่อต้องการแก้ไขตั้งค่าการแปลงไฟล์เท่านั้น", - "songsAddedToPlaylist": "เลือก %{smart_count} เพลงเข้าในเพลย์ลิสต์", - "noPlaylistsAvailable": "ไม่มีเพลย์ลิสต์", - "delete_user_title": "ลบชื่อผู้ใช้ '%{name}'", - "delete_user_content": "คุณแน่ใจที่จะลบชื่อผู้ใช้นี้และข้อมูลทั้งหมด (รวมถึงเพลย์ลิสต์และการตั้งค่าต่างๆ)?", - "notifications_blocked": "คุณบล็อกการแจ้งเตือนสำหรับเว็บไซต์นี้", - "notifications_not_available": "เบราเซอร์นี้ไม่รองรับการแจ้งเตือน Desktop หรือคุณไม่ได้เข้าถึง Navidrome ผ่าน https", - "lastfmLinkSuccess": "เชื่อมต่อ Last.fm สำเร็จและเปิดการ Scrobble", - "lastfmLinkFailure": "ไม่สามารถเชื่อมต่อ Last.fm ได้", - "lastfmUnlinkSuccess": "ยกเลิกการเชื่อมต่อ Last.fm สำเร็จและปิดการ Scrobble แล้ว", - "lastfmUnlinkFailure": "ไม่สามารถยกเลิกการเชิ่อมต่อกับ Last.fm ได้", - "openIn": { - "lastfm": "เปิดใน Last.fm", - "musicbrainz": "เปิดใน MusicBrainz" - }, - "lastfmLink": "อ่านต่อ...", - "listenBrainzLinkSuccess": "เชื่อมต่อ ListenBrainz สำเร็จ และสามารถใช้ Scrobbling ได้ผ่านชื่อผู้ใช้ %{user}", - "listenBrainzLinkFailure": "ไม่สามารถเชื่อมต่อ ListenBrainz ได้: %{error}", - "listenBrainzUnlinkSuccess": "ยกเลิกเชื่อมต่อ ListenBrainz และ scrobbling ใช้งานไม่ได้", - "listenBrainzUnlinkFailure": "ไม่สามารถยกเลิกเชื่อมต่อ ListenBrainz ได้", - "downloadOriginalFormat": "ดาวโหลดไฟล์ต้นฉบับ", - "shareOriginalFormat": "แบ่งปันไฟล์ต้นฉบับ", - "shareDialogTitle": "แบ่งปัน %{resource} '%{name}'", - "shareBatchDialogTitle": "แบ่งปัน 1 %{resource} |||| แบ่งปัน %{smart_count} %{resource}", - "shareSuccess": "คัดลอก 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": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร", - "noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน", - "noTopSongsFound": "ไม่พบเพลงยอดนิยม", - "startingInstantMix": "กำลังโหลดอินสแตนท์ มิก...", - "uploadCover": "อัพโหลดภาพหน้าปก", - "removeCover": "ลบถาพหน้าปก", - "coverUploaded": "ภาพหน้าปกถูกอัพเดทแล้ว", - "coverRemoved": "ภาพหน้าปกถูกลบแล้ว", - "coverUploadError": "อัพโหลดภาพหน้าปกผิดพลาด", - "coverRemoveError": "ลบภาพหน้าปกผิดพลาด", - "metadataRefreshStarted": "" - }, - "menu": { - "library": "ห้องสมุดเพลง", - "settings": "ตั้งค่า", - "version": "เวอร์ชั่น", - "theme": "ธีม", - "personal": { - "name": "ปรับแต่ง", - "options": { - "theme": "ธีม", - "language": "ภาษา", - "defaultView": "หน้าเริ่มต้น", - "desktop_notifications": "การแจ่งเตือน Desktop", - "lastfmScrobbling": "Scrobble ไปยัง Last.fm", - "listenBrainzScrobbling": "Scrobble ไปยัง ListenBrainz", - "replaygain": "โหมด ReplayGain", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "ปิดการใช้งาน", - "album": "ใช้อัลบั้ม Gain", - "track": "ใช้แทรค Gain" + "note": "หมายเหตุ", + "transcodingDisabled": "การตั้งค่า transcoding บนเว็บไซต์ถูกปิดเพื่อความปลอดภัย หากต้องการเปลี่ยนแปลงการตั้งค่า ให้ใช้ %{config} จากนั้นจึงรีสตาร์ทเซิฟเวอร์", + "transcodingEnabled": "Navidrome กำลังทำงานโดยใช้ %{config} ทำให้สามารถใช้งาน System Commands จากตั้งค่า transcoding บนหน้าเว็บได้ ทางเราแนะนำให้ท่านปิดการตั้งค่านี้เพื่อความปลอดภัยและเปิดเมื่อต้องการแก้ไขตั้งค่า Transcoding เท่านั้น", + "songsAddedToPlaylist": "เลือก %{smart_count} เพลงเข้าในเพลย์ลิสท์", + "noPlaylistsAvailable": "ไม่มีเพลย์ลิสต์", + "delete_user_title": "ลบผู้ใช้ '%{name}'", + "delete_user_content": "คุณแน่ใจที่จะลบผู้ใช้นี้และข้อมูลทั้งหมด(รวมถึงเพลย์ลิสท์และตั้งค่าต่างๆ)?", + "notifications_blocked": "คุณบล็อกการแจ้งเตือนสำหรับเว็บไซต์นี้", + "notifications_not_available": "เบราเซอร์นี้ไม่รองรับการแจ้งเตือน Desktop หรือคุณไม่ได้เข้าถึง Navidrome ผ่าน https", + "lastfmLinkSuccess": "เชื่อมต่อ Last.fm สำเร็จและเปิดการ Scrobble", + "lastfmLinkFailure": "ไม่สามารถเชื่อมต่อ Last.fm ได้", + "lastfmUnlinkSuccess": "ยกเลิกการเชิ่มต่อ Last.fm สำเร็จและปิดการ Scrobble แล้ว", + "lastfmUnlinkFailure": "ไม่สามารถยกเลิกการเชิ่อมต่อกับ Last.fm ได้", + "openIn": { + "lastfm": "เปิดใน Last.fm", + "musicbrainz": "เปิดใน MusicBrainz" }, - "lastfmNotConfigured": "ยังไม่ได้ตั้งค่า Last.fm API-Key" - } + "lastfmLink": "อ่านต่อ...", + "listenBrainzLinkSuccess": "", + "listenBrainzLinkFailure": "", + "listenBrainzUnlinkSuccess": "", + "listenBrainzUnlinkFailure": "", + "downloadOriginalFormat": "", + "shareOriginalFormat": "", + "shareDialogTitle": "", + "shareBatchDialogTitle": "", + "shareSuccess": "", + "shareFailure": "", + "downloadDialogTitle": "", + "shareCopyToClipboard": "" }, - "albumList": "อัลบั้ม", - "about": "เกี่ยวกับ", - "playlists": "เพลย์ลิสต์", - "sharedPlaylists": "เพลย์ลิสต์ที่แบ่งปัน", - "librarySelector": { - "allLibraries": "ห้องสมุด (%{count}) ห้อง", - "multipleLibraries": "%{selected} ของ %{total} ห้องสมุด", - "selectLibraries": "เลือกห้องสมุด", - "none": "ไม่มี" + "menu": { + "library": "ไลบรารี่", + "settings": "ตั้งค่า", + "version": "เวอร์ชั่น %{version}", + "theme": "ธีม", + "personal": { + "name": "ปรับแต่ง", + "options": { + "theme": "ธีม", + "language": "ภาษา", + "defaultView": "หน้าเริ่มต้น", + "desktop_notifications": "การแจ่งเตือน Desktop", + "lastfmScrobbling": "Scrobble ไป Last.fm", + "listenBrainzScrobbling": "", + "replaygain": "", + "preAmp": "", + "gain": { + "none": "", + "album": "", + "track": "" + } + } + }, + "albumList": "อัลบั้ม", + "about": "เกี่ยวกับ", + "playlists": "เพลย์ลิสต์", + "sharedPlaylists": "เพลย์ลิสต์ที่แบ่งปัน" }, - "onlyFavourites": "แสดงเฉพาะที่ชื่นชอบ" - }, - "player": { - "playListsText": "คิวเล่น", - "openText": "เปิด", - "closeText": "ปิด", - "notContentText": "ไม่มีเพลง", - "clickToPlayText": "คลิกเพื่อเล่น", - "clickToPauseText": "คลิกเพื่อหยุด", - "nextTrackText": "เพลงถัดไป", - "previousTrackText": "เพลงก่อนหน้า", - "reloadText": "โหลดอีกครั้ง", - "volumeText": "ระดับเสียง", - "toggleLyricText": "เปิดปิดเนื้อเพลง", - "toggleMiniModeText": "ย่อ", - "destroyText": "ลบ", - "downloadText": "ดาวน์โหลด", - "removeAudioListsText": "ลบรายการเพลง", - "clickToDeleteText": "คลิกเพื่อลบ %{name}", - "emptyLyricText": "ไม่มีเนื้อเพลง", - "playModeText": { - "order": "ตามลำดับ", - "orderLoop": "เล่นซ้ำ", - "singleLoop": "เล่นซ้ำเพลงนี้", - "shufflePlay": "เล่นแบบสุ่ม" + "player": { + "playListsText": "เพลย์ลิสต์", + "openText": "เปิด", + "closeText": "ปิด", + "notContentText": "ไม่มีเพลง", + "clickToPlayText": "คลิกเพื่อเล่น", + "clickToPauseText": "คลิกเพื่อหยุด", + "nextTrackText": "เพลงถัดไป", + "previousTrackText": "เพลงก่อนหน้า", + "reloadText": "โหลดอีกครั้ง", + "volumeText": "เสียง", + "toggleLyricText": "เปิดปิดเนื้อเพลง", + "toggleMiniModeText": "ย่อ", + "destroyText": "ลบ", + "downloadText": "ดาวน์โหลด", + "removeAudioListsText": "ลบอัลบั้มเสียง", + "clickToDeleteText": "คลิกเพื่อลบ %{name}", + "emptyLyricText": "ไม่มีเนื้อเพลง", + "playModeText": { + "order": "ตามลำดับ", + "orderLoop": "เล่นซ้ำ", + "singleLoop": "เล่นซ้ำเพลงนี้", + "shufflePlay": "เล่นแบบสุ่ม" + } + }, + "about": { + "links": { + "homepage": "หน้าหลัก", + "source": "Source code", + "featureRequests": "ต้องการฟีเจอร์" + } + }, + "activity": { + "title": "กิจกรรม", + "totalScanned": "โฟลเดอร์ทั้งหมด", + "quickScan": "Quick Scan", + "fullScan": "Full Scan", + "serverUptime": "เซิฟเวอร์ออนไลน์", + "serverDown": "ออฟไลน์" + }, + "help": { + "title": "คีย์ลัด Navidrome", + "hotkeys": { + "show_help": "แสดงความช่วยเหลือ", + "toggle_menu": "Toggle เมนูข้าง", + "toggle_play": "เล่น / หยุด", + "prev_song": "เพลงก่อนหน้า", + "next_song": "เพลงถัดไป", + "vol_up": "เพิ่มเสียง", + "vol_down": "ลดเสียง", + "toggle_love": "เพิ่มเพลงนี้ไปยังรายการโปรด", + "current_song": "" + } } - }, - "about": { - "links": { - "homepage": "โฮมเพจ", - "source": "ต้นฉบับซอฟต์แวร์", - "featureRequests": "ร้องขอฟีเจอร์", - "lastInsightsCollection": "เก็บข้อมูลล่าสุด", - "insights": { - "disabled": "ปิดการทำงาน", - "waiting": "รอ" - } - }, - "tabs": { - "about": "เกี่ยวกับ", - "config": "การตั้งค่า" - }, - "config": { - "configName": "ชื่อการตั้งค่า", - "environmentVariable": "ค่าทั่วไป", - "currentValue": "ค่าปัจจุบัน", - "configurationFile": "ไฟล์การตั้งค่า", - "exportToml": "นำออกการตั้งค่า (TOML)", - "exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว", - "exportFailed": "คัดลอกการตั้งค่าล้มเหลว", - "devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)", - "devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง", - "downloadToml": "ดาวน์โหลดการตั้งค่า (TOML)" - } - }, - "activity": { - "title": "กิจกรรม", - "totalScanned": "โฟลเดอร์ทั้งหมด", - "quickScan": "สแกนแบบเร็ว", - "fullScan": "สแกนทั้งหมด", - "serverUptime": "เซิร์ฟเวอร์ออนไลน์นาน", - "serverDown": "ออฟไลน์", - "scanType": "ประเภท", - "status": "สแกนผิดพลาด", - "elapsedTime": "เวลาที่ใช้", - "selectiveScan": "เลือก" - }, - "help": { - "title": "คีย์ลัด Navidrome", - "hotkeys": { - "show_help": "แสดงความช่วยเหลือ", - "toggle_menu": "ปิดเปิด เมนูข้าง", - "toggle_play": "เล่น/หยุดชั่วคราว", - "prev_song": "เพลงก่อนหน้า", - "next_song": "เพลงถัดไป", - "vol_up": "เพิ่มเสียง", - "vol_down": "ลดเสียง", - "toggle_love": "เพิ่มเพลงนี้ไปยังรายการโปรด", - "current_song": "ไปยังเพลงปัจจุบัน" - } - }, - "nowPlaying": { - "title": "กำลังเล่น", - "empty": "ไม่มีเพลงเล่น", - "minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว" - } } \ No newline at end of file diff --git a/resources/i18n/tr.json b/resources/i18n/tr.json index ff387aff8..1caf46828 100644 --- a/resources/i18n/tr.json +++ b/resources/i18n/tr.json @@ -1,723 +1,456 @@ { - "languageName": "Türkçe", - "resources": { - "song": { - "name": "Şarkı |||| Müzik", - "fields": { - "albumArtist": "Albüm Sanatçısı", - "duration": "Süre", - "trackNumber": "Şarkı No", - "playCount": "Oynatılma", - "title": "Başlık", - "artist": "Sanatçı", - "album": "Albüm", - "path": "Dosya Yolu", - "genre": "Tür", - "compilation": "Derleme", - "year": "Yıl", - "size": "Dosya Boyutu", - "updatedAt": "Yüklendiği Tarih", - "bitRate": "Bitrate", - "discSubtitle": "Disk Altyazısı", - "starred": "Favori", - "comment": "Yorum", - "rating": "Derecelendirme", - "quality": "Kalite", - "bpm": "BPM", - "playDate": "Son Oynatılma", - "channels": "Kanal", - "createdAt": "Eklenme tarihi", - "grouping": "Gruplama", - "mood": "Mod", - "participants": "Ek katılımcılar", - "tags": "Ek Etiketler", - "mappedTags": "Eşlenen etiketler", - "rawTags": "Ham etiketler", - "bitDepth": "Bit derinliği", - "sampleRate": "Örnekleme Oranı", - "missing": "Eksik", - "libraryName": "Kütüphane", - "composer": "Besteci", - "disc": "Disk %{discNumber}", - "albumGain": "Albüm Kazancı", - "trackGain": "Parça Kazancı" - }, - "actions": { - "addToQueue": "Oynatma Sırasına Ekle", - "playNow": "Şimdi Yürüt", - "addToPlaylist": "Çalma listesine ekle", - "shuffleAll": "Tümünü karıştır", - "download": "İndir", - "playNext": "Dinlenenden Sonra Oynat", - "info": "Bilgiler", - "showInPlaylist": "Çalma Listesinde Göster", - "instantMix": "Anında Karışım" - } + "languageName": "Türkçe", + "resources": { + "song": { + "name": "Şarkı |||| Şarkılar", + "fields": { + "albumArtist": "Albüm sanatçısı", + "duration": "Süre", + "trackNumber": "Parça #", + "playCount": "Oynatma", + "title": "Isim", + "artist": "Sanatçı", + "album": "Albüm", + "path": "Dosya yolu", + "genre": "Tür", + "compilation": "Derleme", + "year": "Yıl", + "size": "Dosya boyutu", + "updatedAt": "Yüklendiği zaman", + "bitRate": "Bir sayısı", + "discSubtitle": "Disk Altyazısı", + "starred": "Yıldızlı", + "comment": "", + "rating": "", + "quality": "", + "bpm": "", + "playDate": "", + "channels": "", + "createdAt": "" + }, + "actions": { + "addToQueue": "Sonra çal", + "playNow": "Şimdi çal", + "addToPlaylist": "Çalma listesine ekle", + "shuffleAll": "Tümünü karıştır", + "download": "İndir", + "playNext": "Sonrakini çal", + "info": "" + } + }, + "album": { + "name": "Albüm |||| Albümler", + "fields": { + "albumArtist": "Albüm sanatçısı", + "artist": "Sanatçı", + "duration": "Süre", + "songCount": "Şarkılar", + "playCount": "Oynatma", + "name": "Ad", + "genre": "Tür", + "compilation": "Derleme", + "year": "Yıl", + "updatedAt": "Güncellendi ", + "comment": "", + "rating": "", + "createdAt": "", + "size": "" + }, + "actions": { + "playAll": "Çaldır", + "playNext": "Sonrakini çal", + "addToQueue": "Sonra çal", + "shuffle": "Karıştır", + "addToPlaylist": "Playlist'e ekle", + "download": "İndir", + "info": "", + "share": "" + }, + "lists": { + "all": "Hepsi", + "random": "Rasgele", + "recentlyAdded": "Son Eklenenler", + "recentlyPlayed": "Son oynatılan", + "mostPlayed": "En çok çalanlar", + "starred": "Yıldızlı", + "topRated": "" + } + }, + "artist": { + "name": "Sanatçı |||| Sanatçılar", + "fields": { + "name": "Ad", + "albumCount": "Albüm Sayısı", + "songCount": "Şarkı sayısı", + "playCount": "Oynatma", + "rating": "", + "genre": "", + "size": "" + } + }, + "user": { + "name": "Kullanıcı |||| Kullanıcılar", + "fields": { + "userName": "Kullanıcı adı", + "isAdmin": "Yönetici mi", + "lastLoginAt": "Son Giriş Tarihi", + "updatedAt": "Güncelleme Tarihi", + "name": "Ad", + "password": "Şifre", + "createdAt": "Oluşturuldu", + "changePassword": "", + "currentPassword": "", + "newPassword": "", + "token": "" + }, + "helperTexts": { + "name": "" + }, + "notifications": { + "created": "", + "updated": "", + "deleted": "" + }, + "message": { + "listenBrainzToken": "", + "clickHereForToken": "" + } + }, + "player": { + "name": "Çalar |||| Çalarlar", + "fields": { + "name": "Ad", + "transcodingId": "Kod dönüştürme kimliği", + "maxBitRate": "Maks. bit orani", + "client": "Cihaz", + "userName": "Kullanıcı adı", + "lastSeen": "Son Görülme", + "reportRealPath": "", + "scrobbleEnabled": "" + } + }, + "transcoding": { + "name": "Transcoding |||| Transcodings", + "fields": { + "name": "Ad", + "targetFormat": "Hedef Formatı", + "defaultBitRate": "Varsayılan bit orani", + "command": "komut" + } + }, + "playlist": { + "name": "Çalma listesi |||| Çalma listeler", + "fields": { + "name": "Isim", + "duration": "Süre", + "ownerName": "Sahibi", + "public": "Görülebilir", + "updatedAt": "Güncelleme tarihi:", + "createdAt": "Oluşturma tarihi:", + "songCount": "Şarkılar", + "comment": "Yorum", + "sync": "otomatik-aktarma", + "path": "'dan Aktar" + }, + "actions": { + "selectPlaylist": "Bir çalma listesi seç:", + "addNewPlaylist": "Oluştur \"%{name}\"", + "export": "Aktar", + "makePublic": "", + "makePrivate": "" + }, + "message": { + "duplicate_song": "", + "song_exist": "" + } + }, + "radio": { + "name": "", + "fields": { + "name": "", + "streamUrl": "", + "homePageUrl": "", + "updatedAt": "", + "createdAt": "" + }, + "actions": { + "playNow": "" + } + }, + "share": { + "name": "", + "fields": { + "username": "", + "url": "", + "description": "", + "contents": "", + "expiresAt": "", + "lastVisitedAt": "", + "visitCount": "", + "format": "", + "maxBitRate": "", + "updatedAt": "", + "createdAt": "", + "downloadable": "" + } + } }, - "album": { - "name": "Albüm |||| Albümler", - "fields": { - "albumArtist": "Albüm sanatçısı", - "artist": "Sanatçı", - "duration": "Süre", - "songCount": "Şarkı", - "playCount": "Oynatılma", - "name": "Ad", - "genre": "Tür", - "compilation": "Derleme", - "year": "Yıl", - "updatedAt": "Güncellendi", - "comment": "Yorum", - "rating": "Derecelendirme", - "createdAt": "Eklenme tarihi", - "size": "Boyut", - "originalDate": "Orijinal", - "releaseDate": "Yayınlanma Tarihi", - "releases": "Yayınlanan |||| Yayınlananlar", - "released": "Yayınlandı", - "recordLabel": "Etiket", - "catalogNum": "Katalog Numarası", - "releaseType": "Tür", - "grouping": "Gruplama", - "media": "Medya", - "mood": "Mod", - "date": "Kayıt Tarihi", - "missing": "Eksik", - "libraryName": "Kütüphane" - }, - "actions": { - "playAll": "Oynat", - "playNext": "Dinlenenden Sonra Oynat", - "addToQueue": "Oynatma Kuyruğuna Ekle", - "shuffle": "Karıştır", - "addToPlaylist": "Çalma Listesine Ekle", - "download": "İndir", - "info": "Bilgiler", - "share": "Paylaş" - }, - "lists": { - "all": "Tümü", - "random": "Rasgele", - "recentlyAdded": "Son Eklenenler", - "recentlyPlayed": "Son Dinlenenler", - "mostPlayed": "En Çok Dinlenenler", - "starred": "Favorilenenler", - "topRated": "Yüksek Dereceliler" - } - }, - "artist": { - "name": "Sanatçı |||| Sanatçılar", - "fields": { - "name": "İsim", - "albumCount": "Albüm Sayısı", - "songCount": "Şarkı Sayısı", - "playCount": "Oynatmalar", - "rating": "Derecelendirme", - "genre": "Tür", - "size": "Boyut", - "role": "Rol", - "missing": "Eksik" - }, - "roles": { - "albumartist": "Albüm Sanatçısı |||| Albüm Sanatçısı", - "artist": "Sanatçı |||| Sanatçı", - "composer": "Besteci |||| Besteci", - "conductor": "Şef |||| Şef", - "lyricist": "Söz Yazarı |||| Söz Yazarı", - "arranger": "Düzenleyici |||| Düzenleyici", - "producer": "Yapımcı |||| Yapımcı", - "director": "Yönetmen |||| Yönetmen", - "engineer": "Teknisyen |||| Teknisyen", - "mixer": "Mikser |||| Mikser", - "remixer": "Remiks |||| Remiks", - "djmixer": "DJ Mikseri |||| DJ Mikseri", - "performer": "Sanatçı |||| Sanatçı", - "maincredit": "Albüm Sanatçısı veya Sanatçı |||| Albüm Sanatçısı veya Sanatçılar" - }, - "actions": { - "shuffle": "Karıştır", - "radio": "Radyo", - "topSongs": "En İyi Şarkılar" - } - }, - "user": { - "name": "Kullanıcı |||| Kullanıcılar", - "fields": { - "userName": "Kullanıcı adı", - "isAdmin": "Yönetici", - "lastLoginAt": "Son Giriş Tarihi", - "updatedAt": "Güncelleme Tarihi", - "name": "İsim", - "password": "Şifre", - "createdAt": "Oluşturma Tarihi", - "changePassword": "Şifreyi Değiştir", - "currentPassword": "Mevcut Şifre", - "newPassword": "Yeni Şifre", - "token": "Token", - "lastAccessAt": "Son Erişim Tarihi", - "libraries": "Kütüphaneler" - }, - "helperTexts": { - "name": "Adınızda yaptığımız değişikliğin geçerli olması için tekrar giriş yapmanız gerekmektedir", - "libraries": "Bu kullanıcı için belirli kütüphaneleri seçin veya varsayılan kütüphaneleri kullanmak için boş bırakın" - }, - "notifications": { - "created": "Kullanıcı oluşturuldu", - "updated": "Kullanıcı güncellendi", - "deleted": "Kullanıcı silindi" - }, - "message": { - "listenBrainzToken": "ListenBrainz kullanıcı Token'ınızı girin.", - "clickHereForToken": "Token almak için buraya tıklayın", - "selectAllLibraries": "Tüm kütüphaneleri seç", - "adminAutoLibraries": "Yönetici yetkili kullanıcılar tüm kütüphanelere otomatik olarak erişebilir" - }, - "validation": { - "librariesRequired": "Yönetici olmayan kullanıcılar için en az bir kütüphane seçilmelidir" - } - }, - "player": { - "name": "Cihaz |||| Cihazlar", - "fields": { - "name": "İsim", - "transcodingId": "Kod Dönüştürme", - "maxBitRate": "Maks. BitRate", - "client": "İstemci", - "userName": "Kullanıcı adı", - "lastSeen": "Son Görüldüğü Yer", - "reportRealPath": "Gerçek Yolu Bildir", - "scrobbleEnabled": "Skroplamaları harici servislere gönder" - } - }, - "transcoding": { - "name": "Kod Dönüştürme |||| Kod Dönüştürmeler", - "fields": { - "name": "İsim", - "targetFormat": "Dönüştürme Formatı", - "defaultBitRate": "Varsayılan BitRate", - "command": "Komut" - } - }, - "playlist": { - "name": "Çalma Listesi |||| Çalma Listesi", - "fields": { - "name": "İsim", - "duration": "Süre", - "ownerName": "Sahibi", - "public": "Herkese Açık", - "updatedAt": "Güncelleme Tarihi", - "createdAt": "Oluşturma tarihi:", - "songCount": "Şarkı", - "comment": "Yorum", - "sync": "Otomatik Aktar\n", - "path": "İçe Aktar" - }, - "actions": { - "selectPlaylist": "Bir Çalma Listesi Seç:", - "addNewPlaylist": "Oluştur \"%{name}\"", - "export": "Aktar", - "makePublic": "Herkese Açık Yap", - "makePrivate": "Özel Yap", - "saveQueue": "Kuyruktakileri Çalma Listesine Kaydet", - "searchOrCreate": "Çalma listelerini arayın veya yenisini oluşturmak için yazın...", - "pressEnterToCreate": "Yeni çalma listesi oluşturmak için Enter'a basın", - "removeFromSelection": "Seçimden kaldır" - }, - "message": { - "duplicate_song": "Yinelenen şarkıları ekle", - "song_exist": "Seçili müziklerin bazıları eklemek istediğin çalma listesinde mevcut. Yine de eklemek ister misin ?", - "noPlaylistsFound": "Hiç çalma listesi bulunamadı", - "noPlaylists": "Çalma listesi mevcut değil" - } - }, - "radio": { - "name": "Radyo |||| Radyo", - "fields": { - "name": "İsim", - "streamUrl": "Akış URL'si", - "homePageUrl": "Web Site URL'si", - "updatedAt": "Güncellenme Tarihi", - "createdAt": "Oluşturulma Tarihi" - }, - "actions": { - "playNow": "Şimdi Yürüt" - } - }, - "share": { - "name": "Paylaş |||| Paylaşım", - "fields": { - "username": "Paylaşan", - "url": "URL", - "description": "Tanım", - "contents": "İçindekiler", - "expiresAt": "Sona Erme Tarihi", - "lastVisitedAt": "Son Ziyaret Tarihi", - "visitCount": "Ziyaretler", - "format": "Format", - "maxBitRate": "Maks. Bit Rate", - "updatedAt": "Güncelleme Tarihi", - "createdAt": "Oluşturma Tarihi", - "downloadable": "İndirmelere İzin Ver" - } - }, - "missing": { - "name": "Eksik Dosya |||| Eksik Dosyalar", - "fields": { - "path": "Yol", - "size": "Boyut", - "updatedAt": "Kaybolma", - "libraryName": "Kütüphane" - }, - "actions": { - "remove": "Kaldır", - "remove_all": "Tümünü Kaldır" - }, - "notifications": { - "removed": "Eksik dosya(lar) kaldırıldı" - }, - "empty": "Eksik Dosya Yok" - }, - "library": { - "name": "Kütüphane |||| Kütüphaneler", - "fields": { - "name": "İsim", - "path": "Yol", - "remotePath": "Uzak Yol", - "lastScanAt": "Son Tarama", - "songCount": "Şarkılar", - "albumCount": "Albümler", - "artistCount": "Sanatçılar", - "totalSongs": "Şarkılar", - "totalAlbums": "Albümler", - "totalArtists": "Sanatçılar", - "totalFolders": "Klasörler", - "totalFiles": "Dosyalar", - "totalMissingFiles": "Eksik Dosyalar", - "totalSize": "Toplam Boyut", - "totalDuration": "Süre", - "defaultNewUsers": "Yeni Kullanıcılar için Varsayılan", - "createdAt": "Oluşturuldu", - "updatedAt": "Güncellendi" - }, - "sections": { - "basic": "Temel Bilgiler", - "statistics": "İstatistikler" - }, - "actions": { - "scan": "Kütüphaneyi Tara", - "manageUsers": "Kullanıcı Erişimini Yönet", - "viewDetails": "Ayrıntıları Görüntüle", - "quickScan": "Hızlı Tarama", - "fullScan": "Tam Tarama" - }, - "notifications": { - "created": "Kütüphane başarıyla oluşturuldu", - "updated": "Kütüphane başarıyla güncellendi", - "deleted": "Kütüphane başarıyla silindi", - "scanStarted": "Kütüphane taraması başladı", - "scanCompleted": "Kütüphane taraması tamamlandı", - "quickScanStarted": "Hızlı tarama başlatıldı", - "fullScanStarted": "Tam tarama başlatıldı", - "scanError": "Tarama başlatılırken hata oluştu. Günlükleri kontrol edin." - }, - "validation": { - "nameRequired": "Kütüphane adı gereklidir", - "pathRequired": "Kütüphane yolu gereklidir", - "pathNotDirectory": "Kütüphane yolu bir dizin olmalıdır", - "pathNotFound": "Kütüphane yolu bulunamadı", - "pathNotAccessible": "Kütüphane yoluna erişim sağlanamıyor", - "pathInvalid": "Geçersiz kütüphane yolu" - }, - "messages": { - "deleteConfirm": "Bu kütüphaneyi silmek istediğinizden emin misiniz? Bu işlem, ilgili tüm verileri ve kullanıcı erişimini kaldıracaktır.", - "scanInProgress": "Tarama devam ediyor...", - "noLibrariesAssigned": "Bu kullanıcıya hiçbir kütüphane atanmadı" - } - }, - "plugin": { - "name": "Eklenti |||| Eklentiler", - "fields": { - "id": "Kimlik", - "name": "Ad", - "description": "Açıklama", - "version": "Sürüm", - "author": "Geliştirici", - "website": "Web Sitesi", - "permissions": "İzinler", - "enabled": "Etkin", - "status": "Durum", - "path": "Yol", - "lastError": "Son Hata", - "hasError": "Hata", - "updatedAt": "Güncellendi", - "createdAt": "Yüklendi", - "configKey": "Anahtar", - "configValue": "Değer", - "allUsers": "Tüm Kullanıcılara İzin Ver", - "selectedUsers": "Seçili Kullanıcılar", - "allLibraries": "Tüm Kütüphanelere İzin Ver", - "selectedLibraries": "Seçili Kütüphaneler", - "allowWriteAccess": "Yazma Erişimine İzin Ver" - }, - "sections": { - "status": "Durum", - "info": "Eklenti Bilgileri", - "configuration": "Yapılandırma", - "manifest": "Manifest", - "usersPermission": "Kullanıcı İzinleri", - "libraryPermission": "Kütüphane İzinleri" - }, - "status": { - "enabled": "Etkin", - "disabled": "Devre Dışı" - }, - "actions": { - "enable": "Etkinleştir", - "disable": "Devre Dışı Bırak", - "disabledDueToError": "Etkinleştirmeden Önce Hatayı Düzeltin", - "disabledUsersRequired": "Etkinleştirmeden Önce Kullanıcı Seçin", - "disabledLibrariesRequired": "Etkinleştirmeden Önce Kütüphane Seçin", - "addConfig": "Yapılandırma Ekle", - "rescan": "Yeniden Tara" - }, - "notifications": { - "enabled": "Eklenti etkinleştirildi", - "disabled": "Eklenti devre dışı bırakıldı", - "updated": "Eklenti güncellendi", - "error": "Eklenti güncellenirken hata oluştu" - }, - "validation": { - "invalidJson": "Yapılandırma geçerli bir JSON olmalı" - }, - "messages": { - "configHelp": "Eklentiyi anahtar-değer çiftleriyle yapılandırın. Eklenti yapılandırma gerektirmiyorsa boş bırakın.", - "clickPermissions": "Ayrıntıları görmek için bir izne tıklayın", - "noConfig": "Yapılandırma ayarlanmamış", - "allUsersHelp": "Etkinleştirildiğinde eklenti, ileride oluşturulanlar dahil tüm kullanıcılara erişebilir.", - "noUsers": "Kullanıcı seçilmedi", - "permissionReason": "Gerekçe", - "usersRequired": "Bu eklenti kullanıcı bilgilerine erişim gerektiriyor. Eklentinin erişebileceği kullanıcıları seçin veya 'Tüm Kullanıcılara İzin Ver' seçeneğini etkinleştirin.", - "allLibrariesHelp": "Etkinleştirildiğinde eklenti, ileride oluşturulanlar dahil tüm kütüphanelere erişebilir.", - "noLibraries": "Kütüphane seçilmedi", - "librariesRequired": "Bu eklenti kütüphane bilgilerine erişim gerektiriyor. Eklentinin erişebileceği kütüphaneleri seçin veya 'Tüm Kütüphanelere İzin Ver' \nseçeneğini etkinleştirin.", - "requiredHosts": "Gerekli Sunucular", - "configValidationError": "Yapılandırma doğrulanamadı:", - "schemaRenderError": "Yapılandırma formu oluşturulamadı. Eklentinin şeması geçersiz olabilir.", - "allowWriteAccessHelp": "Etkinleştirildiğinde eklenti, kütüphane dizinlerindeki dosyaları değiştirebilir. Eklentiler varsayılan olarak salt okunur erişime sahiptir." - }, - "placeholders": { - "configKey": "anahtar", - "configValue": "değer" - } - } - }, - "ra": { - "auth": { - "welcome1": "Navidrome'yi yüklediğiniz için teşekkürler!", - "welcome2": "Başlamak için yönetici kullanıcısı oluştur", - "confirmPassword": "Şifreyi Onayla", - "buttonCreateAdmin": "Yönetici oluştur", - "auth_check_error": "Devam etmek için lütfen giriş yap", - "user_menu": "Profil", - "username": "Kullanıcı adı", - "password": "Parola", - "sign_in": "Giriş yap", - "sign_in_error": "Giriş başarısız. Lütfen tekrar deneyin", - "logout": "Çıkış", - "insightsCollectionNote": "Navidrome, projenin iyileştirilmesine yardımcı\nolmak için anonim kullanım verileri toplar.\nDaha fazla bilgi edinmek ve isterseniz\ndevre dışı bırakmak için [buraya] tıklayın" - }, - "validation": { - "invalidChars": "Lütfen sadece harf ve rakam kullan", - "passwordDoesNotMatch": "Şifre eşleşmiyor", - "required": "Zorunlu alan", - "minLength": "En az %{min} karakter olmalıdır", - "maxLength": "En fazla %{max} karakter olmalıdır", - "minValue": "En az %{min} olmalı", - "maxValue": "En az %{min} olmalıdır", - "number": "Sayısal bir değer olmalı", - "email": "E-posta geçerli değil", - "oneOf": "Şunlardan biri olmalı: %{options}", - "regex": "Belirli bir formatla eşleşmelidir (regexp): %{pattern}", - "unique": "Benzersiz", - "url": "Geçerli bir URL olmalı" - }, - "action": { - "add_filter": "Filtrele", - "add": "Ekle", - "back": "Geri Dön", - "bulk_actions": "1 öğe seçildi |||| %{smart_count} öğe seçildi", - "cancel": "Vazgeç", - "clear_input_value": "Değeri Temizle", - "clone": "Klonla", - "confirm": "Onayla", - "create": "Oluştur", - "delete": "Sil", - "edit": "Düzenle", - "export": "Dışa aktar", - "list": "Liste", - "refresh": "Yenile", - "remove_filter": "Filtreyi kaldır", - "remove": "Kaldır", - "save": "Kaydet", - "search": "Ara", - "show": "Göster", - "sort": "Sırala", - "undo": "Geri al", - "expand": "Genişlet", - "close": "Kapat", - "open_menu": "Menüyü aç", - "close_menu": "Menüyü kapat", - "unselect": "Seçimi kaldır", - "skip": "Atla", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Paylaş", - "download": "İndir" - }, - "boolean": { - "true": "Evet", - "false": "Hayır" - }, - "page": { - "create": "%{name} Oluştur", - "dashboard": "Ana Sayfa", - "edit": "%{name} #%{id}", - "error": "Bir şeyler ters gitti", - "list": "%{name}", - "loading": "Yükleniyor", - "not_found": "Bulunamadı", - "show": "%{name} #%{id}", - "empty": "%{name} henüz yok.", - "invite": "Bir tane oluşturmak ister misin?" - }, - "input": { - "file": { - "upload_several": "Yüklemek istediğiniz dosyaları buraya sürükleyin ya da seçmek için tıklayın.", - "upload_single": "Yüklemek istediğiniz dosyayı buraya sürükleyin ya da seçmek için tıklayın.." - }, - "image": { - "upload_several": "Yüklemek istediğiniz resimleri buraya sürükleyin ya da seçmek için tıklayın.", - "upload_single": "Yüklemek istediğiniz resmi buraya sürükleyin ya da seçmek için tıklayın." - }, - "references": { - "all_missing": "Referans verileri bulunamadı.", - "many_missing": "İlgili referanslardan en az biri artık mevcut görünmüyor.", - "single_missing": "İlgili referanslardan en az biri artık mevcut görünmüyor." - }, - "password": { - "toggle_visible": "Şifreyi gizle", - "toggle_hidden": "Şifreyi göster" - } + "ra": { + "auth": { + "welcome1": "Navidrome'yi yüklediğiniz için teşekkürler!", + "welcome2": "Başlamak için bir yönetici kullanıcı oluştur", + "confirmPassword": "Şifreyi Onayla", + "buttonCreateAdmin": "Yönetici oluştur", + "auth_check_error": "Devam etmek için lütfen giriş yap", + "user_menu": "Profil", + "username": "Kullanıcı adı", + "password": "Parola", + "sign_in": "Giriş yap", + "sign_in_error": "Giriş başarısız. Lütfen tekrar deneyin", + "logout": "Çıkış" + }, + "validation": { + "invalidChars": "Lütfen sadece harf ve rakam kullan", + "passwordDoesNotMatch": "Şifre eşleşmiyor", + "required": "Zorunlu alan", + "minLength": "En az %{min} karakter", + "maxLength": "En fazla %{max} karakter", + "minValue": "En az %{min} olmalı", + "maxValue": "En fazla %{max} olmali", + "number": "Sayısal bir değer olmalı", + "email": "E-posta geçerli değil", + "oneOf": "Bunlardan biri olmalı: %{options}", + "regex": "Belirli bir formatla eşleşmelidir (regexp): %{pattern}", + "unique": "", + "url": "" + }, + "action": { + "add_filter": "Filtre ekle", + "add": "Ekle", + "back": "Geri Dön", + "bulk_actions": "1 seçildi |||| %{smart_count} seçildi", + "cancel": "İptal", + "clear_input_value": "Temizle", + "clone": "Klonla", + "confirm": "Onayla", + "create": "Oluştur", + "delete": "Sil", + "edit": "Düzenle", + "export": "Dışa aktar", + "list": "Listele", + "refresh": "Yenile", + "remove_filter": "Filtreyi kaldır", + "remove": "Kaldır", + "save": "Kaydet", + "search": "Ara", + "show": "Göster", + "sort": "Sırala", + "undo": "Geri al", + "expand": "Genişlettir", + "close": "Kapat", + "open_menu": "Menüyü aç", + "close_menu": "Menüyü kapat", + "unselect": "Seçimi kaldır", + "skip": "", + "bulk_actions_mobile": "", + "share": "", + "download": "" + }, + "boolean": { + "true": "Evet", + "false": "Hayır" + }, + "page": { + "create": "%{name} oluştur", + "dashboard": "Ana Sayfa", + "edit": "%{name} #%{id}", + "error": "Bazı şeyler yolunda değil", + "list": "%{name} listesi", + "loading": "Yükleniyor", + "not_found": "Sayfa bulunamadı", + "show": "%{name} #%{id}", + "empty": "Henüz %{name} yok.", + "invite": "Bir tane eklemek ister misin?" + }, + "input": { + "file": { + "upload_several": "Yüklemek istediğiniz dosyaları buraya sürükleyin ya da seçmek için tıklayın.", + "upload_single": "Yüklemek istediğiniz dosyayı buraya sürükleyin ya da seçmek için tıklayın.." + }, + "image": { + "upload_several": "Yüklemek istediğiniz resimleri buraya sürükleyin ya da seçmek için tıklayın.", + "upload_single": "Yüklemek istediğiniz resmi buraya sürükleyin ya da seçmek için tıklayın." + }, + "references": { + "all_missing": "Referans verileri bulunamadı.", + "many_missing": "İlişkilendirilmiş referanslardan en az biri artık mevcut değil.", + "single_missing": "İlişkilendirilmiş referans artık mevcut değil." + }, + "password": { + "toggle_visible": "Şifreyi gizle", + "toggle_hidden": "Şifreyi göster" + } + }, + "message": { + "about": "Hakkında", + "are_you_sure": "Emin misiniz?", + "bulk_delete_content": "%{name} silmek istediğinizden emin misiniz? |||| %{smart_count} öğeyi silmek istediğinizden emin misiniz?", + "bulk_delete_title": "%{name} sil |||| %{smart_count} %{name} öğesi sil", + "delete_content": "Bu öğeyi silmek istediğinizden emin misiniz?", + "delete_title": "%{name} #%{id} Sil", + "details": "Detaylar", + "error": "Bir istemci hatası oluştu ve isteğiniz tamamlanamadı.", + "invalid_form": "Form geçerli değil. Lütfen hataları kontrol edin", + "loading": "Sayfa yükleniyor, lütfen bekleyiniz", + "no": "Hayır", + "not_found": "Hatalı bir URL girdiniz ya da yanlış bir linke tıkladınız", + "yes": "Evet", + "unsaved_changes": "Yaptığın değişikliklerin bazıları kaydedilmedi. Onları yoksaymak istediğinizden emin misin?" + }, + "navigation": { + "no_results": "Kayıt bulunamadı", + "no_more_results": "%{page} sayfası mevcut değil. Önceki sayfayı deneyin.", + "page_out_of_boundaries": "%{page} sayfası mevcut değil", + "page_out_from_end": "Son sayfadan ileri gidemezsin", + "page_out_from_begin": "1. sayfadan geri gidemezsin", + "page_range_info": "%{offsetBegin}-%{offsetEnd} of %{total}", + "page_rows_per_page": "Sayfa başına kayıtlar", + "next": "Sonraki", + "prev": "Önceki", + "skip_nav": "" + }, + "notification": { + "updated": "Öğe güncellendi |||| %{smart_count} öğe güncellendi", + "created": "Öğe oluşturuldu", + "deleted": "Öğe silindi |||| %{smart_count} öğe silindi", + "bad_item": "Hatalı öğe", + "item_doesnt_exist": "Öğe bulunamadı", + "http_error": "Sunucu iletişim hatası", + "data_provider_error": "dataProvider hatası. Detay için konsolu gözden geçir.", + "i18n_error": "Belirtilen dil için çeviriler yüklenemedi", + "canceled": "Eylem iptal edildi", + "logged_out": "Oturumunuz sona erdi, Lütfen yeniden bağlanın.", + "new_version": "" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "", + "layout": "", + "grid": "", + "table": "" + } }, "message": { - "about": "Hakkında", - "are_you_sure": "Emin misiniz?", - "bulk_delete_content": "Bu %{name} öğesini silmek istediğinizden emin misiniz? |||| Bu %{smart_count} öğesini silmek istediğinizden emin misiniz?", - "bulk_delete_title": "%{name} öğesini sil |||| %{smart_count} %{name} öğesini sil", - "delete_content": "Bu öğeyi silmek istediğinizden emin misiniz?", - "delete_title": "%{name} #%{id} Sil", - "details": "Detaylar", - "error": "Bir istemci hatası oluştu ve isteğiniz tamamlanamadı.", - "invalid_form": "Form geçerli değil. Lütfen hataları kontrol edin", - "loading": "Sayfa yükleniyor, lütfen bekleyin", - "no": "Hayır", - "not_found": "Ya yanlış bir URL yazdınız ya da hatalı bir bağlantıya tıkladınız.", - "yes": "Evet", - "unsaved_changes": "Değişikliklerinizin bazıları kaydedilmedi. Bunları yoksaymak istediğinizden emin misiniz?" - }, - "navigation": { - "no_results": "Kayıt bulunamadı", - "no_more_results": "Sayfa numarası %{page} sınırların dışında. Önceki sayfayı deneyin.", - "page_out_of_boundaries": "Sayfa numarası %{page} sınırların dışında", - "page_out_from_end": "Son sayfadan ilerisine gidelemez", - "page_out_from_begin": "1. sayfadan geri gidemezsin", - "page_range_info": "Listelenen %{offsetBegin} ile %{offsetEnd} arası. Toplam %{total} öğe.", - "page_rows_per_page": "Sayfa başına öğe:", - "next": "Sonraki", - "prev": "Önceki", - "skip_nav": "İçeriğe geç" - }, - "notification": { - "updated": "Öğe güncellendi |||| %{smart_count} öğesi güncellendi", - "created": "Öğe oluşturuldu", - "deleted": "Öğe silindi |||| %{smart_count} öğesi silindi", - "bad_item": "Hatalı öğe", - "item_doesnt_exist": "Öğe bulunamadı", - "http_error": "Sunucu iletişim hatası", - "data_provider_error": "dataProvider hatası. Ayrıntılar için konsolu kontrol edin.", - "i18n_error": "Belirtilen dil için çeviriler yüklenemiyor", - "canceled": "Eylem iptal edildi", - "logged_out": "Oturumunuz sona erdi, lütfen tekrar bağlanın.", - "new_version": "Yeni sürüm mevcut! Lütfen bu pencereyi yenileyin." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Görüntülenecek Sütunlar", - "layout": "Görünüm", - "grid": "Izgara", - "table": "Liste" - } - }, - "message": { - "note": "NOT", - "transcodingDisabled": "Güvenlik nedenleriyle, kod dönüştürme yapılandırmasını web arayüzü üzerinden değiştirmek devre dışıdır. Kod dönüştürme seçeneklerini değiştirmek (düzenlemek veya eklemek) isterseniz, sunucuyu %{config} yapılandırma seçeneğiyle yeniden başlatın.", - "transcodingEnabled": "Navidrome yapılandırmanızda \" %{config} \" etkin ve bu da web arayüzünü kullanarak kod dönüştürme ayarlarından sistem komutlarını çalıştırmayı mümkün kılıyor. Güvenlik nedenleriyle bunu devre dışı bırakmanızı ve yalnızca Kod dönüştürme seçeneklerini yapılandırırken etkinleştirmenizi öneririz.", - "songsAddedToPlaylist": "Çalma listesine 1 şarkı eklendi |||| Çalma listesine %{smart_count} şarkı eklendi", - "noPlaylistsAvailable": "Mevcut değil", - "delete_user_title": "'%{name}' kullanıcısını sil", - "delete_user_content": "Bu kullanıcıyı ve tüm verilerini (çalma listeleri ve tercihleri dahil) silmek istediğinden emin misin?", - "notifications_blocked": "Tarayıcınızın ayarlarında bu site için Bildirimleri engellediniz", - "notifications_not_available": "Bu tarayıcı masaüstü bildirimlerini desteklemiyor veya Navidrome'a ​​\"https://\" üzerinden erişmiyorsunuz", - "lastfmLinkSuccess": "Last.fm bağlantısı başarılı ve skroplama etkinleştirildi", - "lastfmLinkFailure": "Last.fm'e bağlanılamadı", - "lastfmUnlinkSuccess": "Last.fm bağlantısı kaldırıldı ve Skroplama devre dışı bırakıldı", - "lastfmUnlinkFailure": "Last.fm bağlantısı kaldırılamadı", - "openIn": { - "lastfm": "Last.fm'de aç", - "musicbrainz": "MusicBrainz'de aç" - }, - "lastfmLink": "Devamını Oku...", - "listenBrainzLinkSuccess": "ListenBrainz bağlantısı başarılı ve skroplama %{user} tarafından etkinleştirildi", - "listenBrainzLinkFailure": "ListenBrainz'a bağlanılamadı: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz bağlantısı kaldırıldı ve skroplama devre dışı bırakıldı", - "listenBrainzUnlinkFailure": "ListenBrainz bağlantısı kaldırılamadı", - "downloadOriginalFormat": "Orijinal formatta indir", - "shareOriginalFormat": "Orijinal Formatta Paylaş", - "shareDialogTitle": "%{resource}: '%{name}' öğesini paylaş", - "shareBatchDialogTitle": "Paylaş 1 %{resource} |||| Paylaş %{smart_count} %{resource}", - "shareSuccess": "URL panoya kopyalandı: %{url}", - "shareFailure": "%{url} panoya kopyalanırken hata oluştu", - "downloadDialogTitle": "%{resource}: '%{name}' (%{size}) dosyasını indirin", - "shareCopyToClipboard": "Panoya kopyala: Ctrl+C, Enter", - "remove_missing_title": "Eksik dosyaları kaldır", - "remove_missing_content": "Seçili eksik dosyaları veritabanından kaldırmak istediğinizden emin misiniz? Bu, oynatma sayıları ve derecelendirmeleri dahil olmak üzere bunlara ilişkin tüm referansları kalıcı olarak kaldıracaktır.", - "remove_all_missing_title": "Tüm eksik dosyaları kaldırın", - "remove_all_missing_content": "Veritabanından tüm eksik dosyaları kaldırmak istediğinizden emin misiniz? Bu, oynatma sayısı ve derecelendirmelerde dahil olmak üzere bunlara ilişkili tüm değerleri kalıcı olarak kaldıracaktır.", - "noSimilarSongsFound": "Benzer şarkı bulunamadı", - "noTopSongsFound": "En iyi şarkı listesi boş", - "startingInstantMix": "Anında Karışım yükleniyor...", - "uploadCover": "Kapak Görseli Yükle", - "removeCover": "Kapak Görselini Kaldır", - "coverUploaded": "Kapak görseli güncellendi", - "coverRemoved": "Kapak görseli kaldırıldı", - "coverUploadError": "Kapak görseli yüklenirken hata oluştu", - "coverRemoveError": "Kapak görseli kaldırılırken hata oluştu" - }, - "menu": { - "library": "Kütüphane", - "settings": "Ayarlar", - "version": "Versiyon", - "theme": "Tema", - "personal": { - "name": "Kişisel", - "options": { - "theme": "Tema", - "language": "Dil", - "defaultView": "Varsayılan Görünüm", - "desktop_notifications": "Masaüstü Bildirimleri", - "lastfmScrobbling": "Last.fm'e Skropla", - "listenBrainzScrobbling": "ListenBrainz'e Skropla", - "replaygain": "Tekrar Kazanma Modu", - "preAmp": "Tekrar Kazanım Ön Amplifikatörü (dB)", - "gain": { - "none": "Devre dışı", - "album": "Albüm Kazancını Kullan", - "track": "Parça Kazancını Kullan" + "note": "NOT", + "transcodingDisabled": "Transcoding ayarlari web arayüzü üzerinden değiştirilmesi güvenlik nedeniyle devre dışı bırakılmıştır. Kod dönüştürme seçeneklerini değiştirmek (düzenlemek veya eklemek) istiyorsan, %{config} seçeneğiyle sunucuyu yeniden başlatın.", + "transcodingEnabled": "Navidrome şu anda %{config} ile çalışıyor, web arayüzünü kullanarak kod dönüştürme ayarlarından sistem komutlarını çalıştırmayı mümkün kılıyor. Güvenlik nedeniyle devre dışı bırakmanızı ve yalnızca Kod Dönüştürme seçeneklerini yapılandırırken etkinleştirmenizi öneririz.", + "songsAddedToPlaylist": "Çalma listesine 1 şarkı eklendi |||| Çalma listesine %{smart_count} şarkı eklendi", + "noPlaylistsAvailable": "Mevcut değil", + "delete_user_title": "'%{name}' kullanıcısını sil", + "delete_user_content": "Bu kullanıcıyı ve tüm verilerini (çalma listesi ve tercihleri dahil) silmek istediğinden emin misin?", + "notifications_blocked": "", + "notifications_not_available": "", + "lastfmLinkSuccess": "", + "lastfmLinkFailure": "", + "lastfmUnlinkSuccess": "", + "lastfmUnlinkFailure": "", + "openIn": { + "lastfm": "", + "musicbrainz": "" }, - "lastfmNotConfigured": "Last.fm API Anahtarı yapılandırılmadı\n" - } + "lastfmLink": "", + "listenBrainzLinkSuccess": "", + "listenBrainzLinkFailure": "", + "listenBrainzUnlinkSuccess": "", + "listenBrainzUnlinkFailure": "", + "downloadOriginalFormat": "", + "shareOriginalFormat": "", + "shareDialogTitle": "", + "shareBatchDialogTitle": "", + "shareSuccess": "", + "shareFailure": "", + "downloadDialogTitle": "", + "shareCopyToClipboard": "" }, - "albumList": "Albümler", - "about": "Hakkında", - "playlists": "Çalma Listeleri", - "sharedPlaylists": "Paylaşılan Çalma Listeleri", - "librarySelector": { - "allLibraries": "Tüm Kitaplıklar (%{count})", - "multipleLibraries": "%{total} kütüphaneden %{selected} tanesi seçildi", - "selectLibraries": "Seçili Kütüphaneler", - "none": "Hiçbiri" - } - }, - "player": { - "playListsText": "Oynatma Sırası", - "openText": "Aç", - "closeText": "Kapat", - "notContentText": "Müzik bulunamadı", - "clickToPlayText": "Oynatmak için tıkla", - "clickToPauseText": "Duraklatmak için tıkla", - "nextTrackText": "Sonraki Şarkı", - "previousTrackText": "Önceki Şarkı", - "reloadText": "Tekrar yükle", - "volumeText": "Ses", - "toggleLyricText": "Şarkı sözü aç/kapat", - "toggleMiniModeText": "Küçült", - "destroyText": "Sonlandır", - "downloadText": "İndir", - "removeAudioListsText": "Şarkı listelerini sil", - "clickToDeleteText": "%{name} silmek için tıkla", - "emptyLyricText": "Şarkı sözü yok", - "playModeText": { - "order": "Sırayla", - "orderLoop": "Tekrarla", - "singleLoop": "Oynatılanı tekrarla", - "shufflePlay": "Karıştır" - } - }, - "about": { - "links": { - "homepage": "Ana sayfa", - "source": "Kaynak kodu", - "featureRequests": "Özellik talepleri", - "lastInsightsCollection": "Son Veri Toplama", - "insights": { - "disabled": "Pasif", - "waiting": "Bekle" - } + "menu": { + "library": "Müzik kütüphanesi", + "settings": "Ayarlar", + "version": "Sürüm", + "theme": "Tema", + "personal": { + "name": "Kişisel", + "options": { + "theme": "Tema", + "language": "Dil", + "defaultView": "Varsayılan görünüm", + "desktop_notifications": "", + "lastfmScrobbling": "", + "listenBrainzScrobbling": "", + "replaygain": "", + "preAmp": "", + "gain": { + "none": "", + "album": "", + "track": "" + } + } + }, + "albumList": "Albümler", + "about": "Hakkinda", + "playlists": "", + "sharedPlaylists": "" }, - "tabs": { - "about": "Hakkında", - "config": "Yapılandırma" + "player": { + "playListsText": "Oynatma Sırası", + "openText": "Aç", + "closeText": "Kapat", + "notContentText": "Müzik yok", + "clickToPlayText": "Oynatmak için tıkla", + "clickToPauseText": "Duraklatmak için tıkla", + "nextTrackText": "Sonraki parça", + "previousTrackText": "Önceki parça", + "reloadText": "Tekrar yükle", + "volumeText": "Ses", + "toggleLyricText": "Şarkı sözü aç/kapat", + "toggleMiniModeText": "Küçült", + "destroyText": "Yık", + "downloadText": "İndir", + "removeAudioListsText": "Ses listelerini sil", + "clickToDeleteText": "%{name} silmek için tıkla", + "emptyLyricText": "Şarkı sözü yok", + "playModeText": { + "order": "Sırayla", + "orderLoop": "Tekrar et", + "singleLoop": "Birini tekrarla", + "shufflePlay": "Karıştır" + } }, - "config": { - "configName": "Yapılandırma Adı", - "environmentVariable": "Çevre Değişkeni", - "currentValue": "Güncel Değer", - "configurationFile": "Yapılandırma Dosyası", - "exportToml": "Yapılandırmayı Dışa Aktar (TOML)", - "exportSuccess": "Yapılandırma TOML formatında dışa aktarıldı", - "exportFailed": "Yapılandırma kopyalanamadı", - "devFlagsHeader": "Geliştirme Bayrakları (değişime/kaldırılmaya tabidir)", - "devFlagsComment": "Bunlar deneysel ayarlardır ve gelecekteki sürümlerde kaldırılabilir", - "downloadToml": "Yapılandırmayı İndir (TOML)" + "about": { + "links": { + "homepage": "Ana sayfa", + "source": "Kaynak kodu", + "featureRequests": "Özellik talepleri" + } + }, + "activity": { + "title": "", + "totalScanned": "", + "quickScan": "", + "fullScan": "", + "serverUptime": "", + "serverDown": "" + }, + "help": { + "title": "", + "hotkeys": { + "show_help": "", + "toggle_menu": "", + "toggle_play": "", + "prev_song": "", + "next_song": "", + "vol_up": "", + "vol_down": "", + "toggle_love": "", + "current_song": "" + } } - }, - "activity": { - "title": "Etkinlik", - "totalScanned": "Toplam Taranan Klasör Sayısı", - "quickScan": "Hızlı Tarama", - "fullScan": "Tam Tarama", - "serverUptime": "Sunucu Çalışma Süresi", - "serverDown": "ÇEVRİMDIŞI", - "scanType": "Tür", - "status": "Tarama Hatası", - "elapsedTime": "Geçen Süre", - "selectiveScan": "Seçmeli" - }, - "help": { - "title": "Navidrome Kısayolları", - "hotkeys": { - "show_help": "Yardımı Göster", - "toggle_menu": "Menü Kenar Çubuğunu Aç/Kapat", - "toggle_play": "Oynat / Duraklat", - "prev_song": "Önceki Şarkı", - "next_song": "Sonraki Şarkı", - "vol_up": "Sesi Arttır", - "vol_down": "Sesi Azalt", - "toggle_love": "Bu şarkıyı favorilere ekle", - "current_song": "Mevcut Şarkıya Git" - } - }, - "nowPlaying": { - "title": "Şu An Çalıyor", - "empty": "Çalan şarkı yok", - "minutesAgo": "%{smart_count} dakika önce" - } } \ No newline at end of file diff --git a/resources/i18n/uk.json b/resources/i18n/uk.json index 3049f9840..f00128b4d 100644 --- a/resources/i18n/uk.json +++ b/resources/i18n/uk.json @@ -1,734 +1,456 @@ { - "languageName": "Українська", - "resources": { - "song": { - "name": "Пісня |||| Пісні", - "fields": { - "albumArtist": "Виконавець альбому", - "duration": "Тривалість", - "trackNumber": "#", - "playCount": "Грає", - "title": "Назва", - "artist": "Виконавець", - "album": "Альбом", - "path": "Шлях до файлу", - "genre": "Жанр", - "compilation": "Збірка", - "year": "Рік", - "size": "Розмір файлу", - "updatedAt": "Завантажено", - "bitRate": "Бітрейт", - "discSubtitle": "Назва диску", - "starred": "Обрані", - "comment": "Коментар", - "rating": "Рейтинг", - "quality": "Якість", - "bpm": "Темп", - "playDate": "Останнє відтворення", - "channels": "Канали", - "createdAt": "Додано", - "grouping": "Групування", - "mood": "Настрій", - "participants": "Додаткові вчасники", - "tags": "Додаткові теги", - "mappedTags": "Зіставлені теги", - "rawTags": "Вихідні теги", - "bitDepth": "Глибина розрядності", - "sampleRate": "Частота дискретизації", - "missing": "Поле відсутнє", - "libraryName": "Бібліотека", - "composer": "Композитор", - "disc": "Диск %{discNumber}", - "albumGain": "", - "trackGain": "" - }, - "actions": { - "addToQueue": "Прослухати пізніше", - "playNow": "Програвати зараз", - "addToPlaylist": "Додати у список відтворення", - "shuffleAll": "Перемішати", - "download": "Завантажити", - "playNext": "Наступна", - "info": "Отримати інформацію", - "showInPlaylist": "Показати у плейлісті", - "instantMix": "Мікс" - } + "languageName": "Українська", + "resources": { + "song": { + "name": "Пісня Пісні", + "fields": { + "albumArtist": "Виконавець альбому", + "duration": "Тривалість", + "trackNumber": "#", + "playCount": "Грає", + "title": "Назва", + "artist": "Виконавець", + "album": "Альбом", + "path": "Шлях до файлу", + "genre": "Жанр", + "compilation": "Збірка", + "year": "Рік", + "size": "Розмір файлу", + "updatedAt": "Завантажено", + "bitRate": "Бітрейт", + "discSubtitle": "Назва диску", + "starred": "Обрані", + "comment": "Коментар", + "rating": "Рейтинг", + "quality": "Якість", + "bpm": "Темп", + "playDate": "Востаннє відтворено", + "channels": "Канали", + "createdAt": "" + }, + "actions": { + "addToQueue": "Прослухати пізніше", + "playNow": "Програвати зараз", + "addToPlaylist": "Додати у список відтворення", + "shuffleAll": "Перемішати", + "download": "Завантажити", + "playNext": "Наступна", + "info": "Отримати інформацію" + } + }, + "album": { + "name": "Альбом Альбоми", + "fields": { + "albumArtist": "Автор Альбому", + "artist": "Виконавець", + "duration": "Тривалість", + "songCount": "Пісні", + "playCount": "Грає", + "name": "Назва", + "genre": "Жанр", + "compilation": "Збірка", + "year": "Рік", + "updatedAt": "Оновлено", + "comment": "Коментар", + "rating": "Рейтинг", + "createdAt": "", + "size": "" + }, + "actions": { + "playAll": "Прослухати", + "playNext": "Прослухати наступну", + "addToQueue": "Прослухати пізніше", + "shuffle": "Перемішати", + "addToPlaylist": "Додати у список відтворення", + "download": "Завантажити", + "info": "Отримати інформацію", + "share": "" + }, + "lists": { + "all": "Усі", + "random": "Випадково", + "recentlyAdded": "Нещодавно додані", + "recentlyPlayed": "Нещодавно відтворено", + "mostPlayed": "Часто відтворювані", + "starred": "Вибрані", + "topRated": "Найкраще" + } + }, + "artist": { + "name": "Виконавець Виконавці", + "fields": { + "name": "Назва", + "albumCount": "Кількість альбомів", + "songCount": "Кількість пісень", + "playCount": "Відтворено", + "rating": "Рейтинг", + "genre": "Жанр", + "size": "" + } + }, + "user": { + "name": "Користувач Користувачі", + "fields": { + "userName": "Ім’я користувача", + "isAdmin": "Є адміністратором", + "lastLoginAt": "Останній раз заходив о", + "updatedAt": "Завантажено", + "name": "Назва", + "password": "Пароль", + "createdAt": "Створено", + "changePassword": "Змінити пароль?", + "currentPassword": "Поточний пароль", + "newPassword": "Новий пароль", + "token": "" + }, + "helperTexts": { + "name": "Змінене ім'я буде відображатися при наступній авторизації" + }, + "notifications": { + "created": "Користувача створено", + "updated": "Користувач оновлений", + "deleted": "Користувач видалений" + }, + "message": { + "listenBrainzToken": "", + "clickHereForToken": "" + } + }, + "player": { + "name": "Програвач Програвачі", + "fields": { + "name": "Назва", + "transcodingId": "ID транскодування", + "maxBitRate": "Максимальний бітрейт", + "client": "Клієнт", + "userName": "Iм’я користувача", + "lastSeen": "Останній візит о", + "reportRealPath": "Повідомте про реальний шлях", + "scrobbleEnabled": "" + } + }, + "transcoding": { + "name": "Транскодування Транскодування", + "fields": { + "name": "Назва", + "targetFormat": "Цільовий формат", + "defaultBitRate": "Швидкість передачі бітів за замовчуванням", + "command": "Команда" + } + }, + "playlist": { + "name": "Список відтворення |||| Списки відтворення", + "fields": { + "name": "Назва", + "duration": "Тривалість", + "ownerName": "Власник", + "public": "Публічний", + "updatedAt": "Оновлено", + "createdAt": "Створено", + "songCount": "Пісні", + "comment": "Коментар", + "sync": "Автоімпорт", + "path": "Імпортувати із" + }, + "actions": { + "selectPlaylist": "Вибрати список відтворення:", + "addNewPlaylist": "Створити \"%{name}\"", + "export": "Експортувати", + "makePublic": "", + "makePrivate": "" + }, + "message": { + "duplicate_song": "Додати повторювані пісні", + "song_exist": "У список відтворення додаються дублікати. Хочете додати дублікати або пропустити їх?" + } + }, + "radio": { + "name": "", + "fields": { + "name": "", + "streamUrl": "", + "homePageUrl": "", + "updatedAt": "", + "createdAt": "" + }, + "actions": { + "playNow": "" + } + }, + "share": { + "name": "", + "fields": { + "username": "", + "url": "", + "description": "", + "contents": "", + "expiresAt": "", + "lastVisitedAt": "", + "visitCount": "", + "format": "", + "maxBitRate": "", + "updatedAt": "", + "createdAt": "", + "downloadable": "" + } + } }, - "album": { - "name": "Альбом |||| Альбоми", - "fields": { - "albumArtist": "Автор Альбому", - "artist": "Виконавець", - "duration": "Тривалість", - "songCount": "Пісні", - "playCount": "Грає", - "name": "Назва", - "genre": "Жанр", - "compilation": "Збірка", - "year": "Рік", - "updatedAt": "Оновлено", - "comment": "Коментар", - "rating": "Рейтинг", - "createdAt": "Додано", - "size": "Розмір", - "originalDate": "Оригінал", - "releaseDate": "Дата випуску", - "releases": "Випуск |||| Випуски", - "released": "Випущений", - "recordLabel": "Лейбл", - "catalogNum": "Номер каталогу", - "releaseType": "Тип", - "grouping": "Групування", - "media": "Медіа", - "mood": "Настрій", - "date": "Дата запису", - "missing": "Поле відсутнє", - "libraryName": "Бібліотека" - }, - "actions": { - "playAll": "Прослухати", - "playNext": "Прослухати наступну", - "addToQueue": "Прослухати пізніше", - "shuffle": "Перемішати", - "addToPlaylist": "Додати у список відтворення", - "download": "Завантажити", - "info": "Отримати інформацію", - "share": "Поширити", - "refresh": "" - }, - "lists": { - "all": "Усі", - "random": "Випадково", - "recentlyAdded": "Нещодавно додані", - "recentlyPlayed": "Нещодавно відтворено", - "mostPlayed": "Часто відтворювані", - "starred": "Вибрані", - "topRated": "Найкраще" - } - }, - "artist": { - "name": "Виконавець |||| Виконавці", - "fields": { - "name": "Назва", - "albumCount": "Кількість альбомів", - "songCount": "Кількість пісень", - "playCount": "Відтворено", - "rating": "Рейтинг", - "genre": "Жанр", - "size": "Розмір", - "role": "Роль", - "missing": "Поле відсутнє" - }, - "roles": { - "albumartist": "Виконавець альбому |||| Виконавці альбому", - "artist": "Виконавець |||| Виконавці", - "composer": "Композитор |||| Композитори", - "conductor": "Диригент |||| Диригенти", - "lyricist": "Автор текстів |||| Автори текстів", - "arranger": "Аранжувальник |||| Аранжувальники", - "producer": "Продюсер |||| Продюсери", - "director": "Режисер |||| Режисери", - "engineer": "Інженер |||| Інженери", - "mixer": "Звукоінженер |||| Звукоінженери", - "remixer": "Реміксер |||| Реміксери", - "djmixer": "DJ-звукоінженер |||| DJ-звукоінженери", - "performer": "Виконавець |||| Виконавці", - "maincredit": "Виконавець альбому або Виконавець |||| Виконавці альбому або Виконавці" - }, - "actions": { - "shuffle": "Перетасовка", - "radio": "Радіо", - "topSongs": "ТОП-треки" - } - }, - "user": { - "name": "Користувач |||| Користувачі", - "fields": { - "userName": "Ім’я користувача", - "isAdmin": "Є адміністратором", - "lastLoginAt": "Останній раз заходив о", - "updatedAt": "Завантажено", - "name": "Назва", - "password": "Пароль", - "createdAt": "Створено", - "changePassword": "Змінити пароль?", - "currentPassword": "Поточний пароль", - "newPassword": "Новий пароль", - "token": "Токен", - "lastAccessAt": "Останній доступ", - "libraries": "Бібліотеки", - "scrobbleFilter": "" - }, - "helperTexts": { - "name": "Змінене ім'я буде відображатися при наступній авторизації", - "libraries": "Виберіть конкретні бібліотеки для цього користувача, або залиште поле порожнім, щоб використовувати бібліотеки за замовчуванням", - "scrobbleFilter": "" - }, - "notifications": { - "created": "Користувача створено", - "updated": "Користувач оновлений", - "deleted": "Користувач видалений" - }, - "message": { - "listenBrainzToken": "Введіть свій токен користувача ListenBrainz.", - "clickHereForToken": "Натисніть тут для отримання токену", - "selectAllLibraries": "Вибрати всі бібліотеки", - "adminAutoLibraries": "Користувачі-адміністратори автоматично отримують доступ до всіх бібліотек" - }, - "validation": { - "librariesRequired": "Для користувачів, які не є адміністраторами, має бути обрана хоча б одна бібліотека", - "invalidScrobbleFilter": "" - } - }, - "player": { - "name": "Програвач |||| Програвачі", - "fields": { - "name": "Назва", - "transcodingId": "ID транскодування", - "maxBitRate": "Максимальний бітрейт", - "client": "Клієнт", - "userName": "Iм’я користувача", - "lastSeen": "Останній візит о", - "reportRealPath": "Повідомте про реальний шлях", - "scrobbleEnabled": "Надсилайте Scrobbles до зовнішніх сервісів" - } - }, - "transcoding": { - "name": "Транскодувальник |||| Транскодувальники", - "fields": { - "name": "Назва", - "targetFormat": "Цільовий формат", - "defaultBitRate": "Швидкість передачі бітів за замовчуванням", - "command": "Команда" - }, - "choices": { - "noDefaultBitRate": "" - } - }, - "playlist": { - "name": "Список відтворення |||| Списки відтворення", - "fields": { - "name": "Назва", - "duration": "Тривалість", - "ownerName": "Власник", - "public": "Публічний", - "updatedAt": "Оновлено", - "createdAt": "Створено", - "songCount": "Пісні", - "comment": "Коментар", - "sync": "Автоімпорт", - "path": "Імпортувати із", - "starred": "Улюблене" - }, - "actions": { - "selectPlaylist": "Вибрати список відтворення:", - "addNewPlaylist": "Створити \"%{name}\"", - "export": "Експортувати", - "makePublic": "Зробити публічним", - "makePrivate": "Зробити приватним", - "saveQueue": "Зберегти чергу до плейлиста", - "searchOrCreate": "Знайти плейлист або введіть текст, щоб створити новий...", - "pressEnterToCreate": "Натисніть Enter щоб створити новий плейлист", - "removeFromSelection": "Вилучити з вибору" - }, - "message": { - "duplicate_song": "Додати повторювані пісні", - "song_exist": "У список відтворення додаються дублікати. Хочете додати дублікати або пропустити їх?", - "noPlaylistsFound": "Не знайдено плейлистів", - "noPlaylists": "Немає доступних плейлистів" - } - }, - "radio": { - "name": "Радіостанція |||| Радіостанції", - "fields": { - "name": "Назва", - "streamUrl": "Посилання на стрім", - "homePageUrl": "Посилання на домашню сторінку", - "updatedAt": "Оновлено", - "createdAt": "Створено" - }, - "actions": { - "playNow": "Зараз грає" - } - }, - "share": { - "name": "Поширити |||| Поширення", - "fields": { - "username": "Поширено", - "url": "Посилання", - "description": "Опис", - "contents": "Вміст", - "expiresAt": "Дійсний", - "lastVisitedAt": "Останній візит", - "visitCount": "Відвідано", - "format": "Формат", - "maxBitRate": "Макс. Біт рейт", - "updatedAt": "Оновлено", - "createdAt": "Створено", - "downloadable": "Дозволити завантаження?" - } - }, - "missing": { - "name": "Файл відсутній |||| Відсутні файли", - "fields": { - "path": "Шлях файлу", - "size": "Розмір", - "updatedAt": "Зник", - "libraryName": "Бібліотека" - }, - "actions": { - "remove": "Видалити", - "remove_all": "Вилучити всі" - }, - "notifications": { - "removed": "Видалено зниклі файл(и)" - }, - "empty": "Немає відсутніх файлів" - }, - "library": { - "name": "Бібліотека |||| Бібліотеки", - "fields": { - "name": "Ім'я", - "path": "Шлях", - "remotePath": "Віддалений шлях", - "lastScanAt": "Останнє сканування", - "songCount": "Треки", - "albumCount": "Альбоми", - "artistCount": "Виконавці", - "totalSongs": "Треки", - "totalAlbums": "Альбоми", - "totalArtists": "Виконавці", - "totalFolders": "Папки", - "totalFiles": "Файлів", - "totalMissingFiles": "Зниклих файлів", - "totalSize": "Загальний розмір", - "totalDuration": "Тривалість", - "defaultNewUsers": "За замовчуванням для нових користувачів", - "createdAt": "Створено", - "updatedAt": "Оновлено" - }, - "sections": { - "basic": "Основна інформація", - "statistics": "Статистика" - }, - "actions": { - "scan": "Сканувати бібліотеку", - "manageUsers": "Керування доступом користувачів", - "viewDetails": "Переглянути подробиці", - "quickScan": "Швидке сканування", - "fullScan": "Повне сканування" - }, - "notifications": { - "created": "Бібліотеку успішно створено", - "updated": "Бібліотеку успішно оновлено", - "deleted": "Бібліотеку успішно видалено", - "scanStarted": "Сканування бібліотеки розпочато", - "scanCompleted": "Сканування бібліотеки закінчено", - "quickScanStarted": "Швидке сканування виконується", - "fullScanStarted": "Повне сканування виконується", - "scanError": "Помилка при виконанні сканування. Перевірте лоґи" - }, - "validation": { - "nameRequired": "Ім'я бібліотеки обов'язкове", - "pathRequired": "Шлях до бібліотеки обов'язковий", - "pathNotDirectory": "Шлях до бібліотеки має бути директорією", - "pathNotFound": "Шлях до бібліотеки не знайдено", - "pathNotAccessible": "Шлях до бібліотеки недоступний", - "pathInvalid": "Помилковий шлях до бібліотеки" - }, - "messages": { - "deleteConfirm": "Ви впевнені, що хочете видалити цю бібліотеку? Це призведе до видалення всіх пов'язаних з нею даних і доступу користувачів.", - "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": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання.", - "idHelp": "" - }, - "placeholders": { - "configKey": "ключ", - "configValue": "значення" - } - } - }, - "ra": { - "auth": { - "welcome1": "Дякуємо, що встановили Navidrome!", - "welcome2": "Щоб розпочати, створіть акаунт адміністратора", - "confirmPassword": "Підтвердіть пароль", - "buttonCreateAdmin": "Створіть акаунт адміністратора", - "auth_check_error": "Будь ласка, увійдіть, щоб продовжити", - "user_menu": "Профіль", - "username": "Ім'я користувача", - "password": "Пароль", - "sign_in": "Увійти", - "sign_in_error": "Помилка аутентифікації, спробуйте знову", - "logout": "Вийти", - "insightsCollectionNote": "Navidrome збирає анонімні дані про використання, \nщоб допомогти покращити проєкт.\nНатисніть [тут], щоб дізнатися більше та відмовитись, якщо хочете" - }, - "validation": { - "invalidChars": "Будь ласка, використовуйте лише букви та числа", - "passwordDoesNotMatch": "Пароль не співпадає", - "required": "Обов'язково для заповнення", - "minLength": "Мінімальна кількість символів %{min}", - "maxLength": "Максимальна кількість символів %{max}", - "minValue": "Мінімальне значення %{min}", - "maxValue": "Значення може бути менше %{max}", - "number": "Повинна бути цифра", - "email": "Хибний email", - "oneOf": "Повинен бути одним з: %{options}", - "regex": "Повинен відповідати формату (регулярний вираз): %{pattern}", - "unique": "Має бути унікальним", - "url": "Повинно бути дійсне посилання" - }, - "action": { - "add_filter": "Додати фільтр", - "add": "Додати", - "back": "Повернутися назад", - "bulk_actions": "1 обрано |||| %{smart_count} обрано", - "cancel": "Відмінити", - "clear_input_value": "Очистити", - "clone": "Клонувати", - "confirm": "Підтвердити", - "create": "Створити", - "delete": "Видалити", - "edit": "Редагувати", - "export": "Експортувати", - "list": "Перелік", - "refresh": "Оновити", - "remove_filter": "Прибрати фільтр", - "remove": "Видалити", - "save": "Зберегти", - "search": "Пошук", - "show": "Перегляд", - "sort": "Сортувати", - "undo": "Скасувати", - "expand": "Розгорнути", - "close": "Закрити", - "open_menu": "Відкрити меню", - "close_menu": "Закрити меню", - "unselect": "Забрати виділення", - "skip": "Пропустити", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "Поширити", - "download": "Завантаження" - }, - "boolean": { - "true": "Так", - "false": "Ні" - }, - "page": { - "create": "Створити %{name}", - "dashboard": "Головна", - "edit": "%{name} #%{id}", - "error": "Щось пішло не так", - "list": "%{name}", - "loading": "Завантаження", - "not_found": "Не знайдено", - "show": "%{name} #%{id}", - "empty": "Ще %{name} не існує.", - "invite": "Ви хочете додати це?" - }, - "input": { - "file": { - "upload_several": "Перетягніть файли сюди, або натисніть для вибору.", - "upload_single": "Перетягніть файл сюди, або натисніть для вибору." - }, - "image": { - "upload_several": "Перетягніть зображення сюди, або натисніть для вибору.", - "upload_single": "Перетягніть зображення сюди, або натисніть для вибору." - }, - "references": { - "all_missing": "Пов'язаних данних не знайдено.", - "many_missing": "Щонайменьше одне з пов'язаних посилань більше не доступно.", - "single_missing": "Пов'язане посилання більше не доступно." - }, - "password": { - "toggle_visible": "Приховати пароль", - "toggle_hidden": "Показати пароль" - } + "ra": { + "auth": { + "welcome1": "Дякуємо, що встановили Navidrome!", + "welcome2": "Щоб розпочати, створіть акаунт адміністратора", + "confirmPassword": "Підтвердіть пароль", + "buttonCreateAdmin": "Створіть акаунт адміністратора", + "auth_check_error": "Будь ласка, увійдіть, щоб продовжити", + "user_menu": "Профіль", + "username": "Ім'я користувача", + "password": "Пароль", + "sign_in": "Ввійти", + "sign_in_error": "Помилка аутентифікації, спробуйте знову", + "logout": "Вийти" + }, + "validation": { + "invalidChars": "Будь ласка, використовуйте лише букви і числа", + "passwordDoesNotMatch": "Пароль не співпадає", + "required": "Обов'язково для заповнення", + "minLength": "Мінімальна кількість символів %{min}", + "maxLength": "Максимальна кількість символів %{max}", + "minValue": "Мінімальне значення %{min}", + "maxValue": "Значення може бути менше %{max}", + "number": "Повинна бути цифра", + "email": "Хибний email", + "oneOf": "Повинен бути одним з: %{options}", + "regex": "Повинен відповідати формату (регулярний вираз): %{pattern}", + "unique": "Має бути унікальним", + "url": "" + }, + "action": { + "add_filter": "Додати фільтр", + "add": "Додати", + "back": "Повернутися назад", + "bulk_actions": "%{smart_count} обрано", + "cancel": "Відмінити", + "clear_input_value": "Очистити", + "clone": "Клонувати", + "confirm": "Підтвердити", + "create": "Створити", + "delete": "Видалити", + "edit": "Редагувати", + "export": "Експортувати", + "list": "Перелік", + "refresh": "Оновити", + "remove_filter": "Прибрати фільтр", + "remove": "Видалити", + "save": "Зберегти", + "search": "Пошук", + "show": "Перегляд", + "sort": "Сортувати", + "undo": "Скасувати", + "expand": "Розгорнути", + "close": "Закрити", + "open_menu": "Відкрити меню", + "close_menu": "Закрити меню", + "unselect": "Забрати виділення", + "skip": "Пропустити", + "bulk_actions_mobile": "", + "share": "", + "download": "" + }, + "boolean": { + "true": "Так", + "false": "Ні" + }, + "page": { + "create": "Створити %{name}", + "dashboard": "Головна", + "edit": "%{name} #%{id}", + "error": "Щось пішло не так", + "list": "%{name}", + "loading": "Завантаження", + "not_found": "Не знайдено", + "show": "%{name} #%{id}", + "empty": "Ще %{name} не існує.", + "invite": "Ви хочете додати це?" + }, + "input": { + "file": { + "upload_several": "Перетягніть файли сюди, або натисніть для вибору.", + "upload_single": "Перетягніть файл сюди, або натисніть для вибору." + }, + "image": { + "upload_several": "Перетягніть зображення сюди, або натисніть для вибору.", + "upload_single": "Перетягніть зображення сюди, або натисніть для вибору." + }, + "references": { + "all_missing": "Пов'язаних данних не знайдено.", + "many_missing": "Щонайменьше одне з пов'язаних посилань більше не доступно.", + "single_missing": "Пов'язане посилання більше не доступно." + }, + "password": { + "toggle_visible": "Приховати пароль", + "toggle_hidden": "Показати пароль" + } + }, + "message": { + "about": "Довідка", + "are_you_sure": "Ви впевнені?", + "bulk_delete_content": "Ви дійсно хочете видалити %{name}? |||| Ви впевнені що хочете видалити об'єкти, кількістю %{smart_count}?", + "bulk_delete_title": "Видалити %{name} |||| Видалити %{smart_count} %{name} елементів", + "delete_content": "Ви впевнені що хочете видалити цей елемент?", + "delete_title": "Видалити %{name} #%{id}", + "details": "Деталі", + "error": "Виникла помилка на стороні клієнта і ваш запит не був завершений.", + "invalid_form": "Форма заповнена не вірно. Перевірте помилки", + "loading": "Сторінка завантажується, хвилинку будь ласка", + "no": "Ні", + "not_found": "Ви набрали невірний URL-адресу, або перейшли за хибним посиланням.", + "yes": "Так", + "unsaved_changes": "Деякі зміни не було збережено. Ви впевнені, що хочете проігнорувати?" + }, + "navigation": { + "no_results": "Результатів не знайдено", + "no_more_results": "Номер сторінки %{page} знаходиться за межею нумерації. Спробуйте попередню сторінку.", + "page_out_of_boundaries": "Сторінка %{page} поза межами нумерації", + "page_out_from_end": "Неможливо переміститися далі останньої сторінки", + "page_out_from_begin": "Номер сторінки не може бути менше 1", + "page_range_info": "%{offsetBegin}-%{offsetEnd} із %{total}", + "page_rows_per_page": "Рядків на сторінці:", + "next": "Наступна", + "prev": "Попередня", + "skip_nav": "Пропустити вміст" + }, + "notification": { + "updated": "Елемент оновлено |||| %{smart_count} елемент оновлено", + "created": "Елемент створений", + "deleted": "Елемент видалений |||| %{smart_count} елемент видалено", + "bad_item": "Хибний елемент", + "item_doesnt_exist": "Елемент не існує", + "http_error": "Помилка сервера", + "data_provider_error": "Помилка в dataProvider. Перевірте деталі в консолі.", + "i18n_error": "Неможливо завантажити переклад для вказаної мови", + "canceled": "Дія відмінена", + "logged_out": "Ваш сеанс закінчився, будь ласка, увійдіть ще раз.", + "new_version": "Знайдено нову версію. Оновіть сторінку." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Колонок відображати", + "layout": "Макет", + "grid": "Сітка", + "table": "Таблиця" + } }, "message": { - "about": "Довідка", - "are_you_sure": "Ви впевнені?", - "bulk_delete_content": "Ви дійсно хочете видалити %{name}? |||| Ви впевнені, що хочете видалити об'єкти, кількістю %{smart_count}?", - "bulk_delete_title": "Видалити %{name} |||| Видалити %{smart_count} %{name} елементів", - "delete_content": "Ви впевнені, що хочете видалити цей елемент?", - "delete_title": "Видалити %{name} #%{id}", - "details": "Деталі", - "error": "Виникла помилка на стороні клієнта і ваш запит не було завершено.", - "invalid_form": "Форма заповнена не вірно. Перевірте помилки", - "loading": "Сторінка завантажується, хвилинку будь ласка", - "no": "Ні", - "not_found": "Ви набрали невірну URL-адресу, або перейшли за хибним посиланням.", - "yes": "Так", - "unsaved_changes": "Деякі зміни не було збережено. Ви впевнені, що хочете проігнорувати?" - }, - "navigation": { - "no_results": "Результатів не знайдено", - "no_more_results": "Номер сторінки %{page} знаходиться за межею нумерації. Спробуйте попередню сторінку.", - "page_out_of_boundaries": "Сторінка %{page} поза межами нумерації", - "page_out_from_end": "Неможливо переміститися далі останньої сторінки", - "page_out_from_begin": "Номер сторінки не може бути менше 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} із %{total}", - "page_rows_per_page": "Рядків на сторінці:", - "next": "Наступна", - "prev": "Попередня", - "skip_nav": "Пропустити вміст" - }, - "notification": { - "updated": "Елемент оновлено |||| %{smart_count} елемент оновлено", - "created": "Елемент створений", - "deleted": "Елемент видалений |||| %{smart_count} елемент видалено", - "bad_item": "Хибний елемент", - "item_doesnt_exist": "Елемент не існує", - "http_error": "Помилка сервера", - "data_provider_error": "Помилка в dataProvider. Перевірте деталі в консолі.", - "i18n_error": "Неможливо завантажити переклад для вказаної мови", - "canceled": "Дія відмінена", - "logged_out": "Ваш сеанс закінчився, будь ласка, увійдіть ще раз.", - "new_version": "Знайдено нову версію. Оновіть сторінку." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Колонок відображати", - "layout": "Макет", - "grid": "Сітка", - "table": "Таблиця" - } - }, - "message": { - "note": "Примітки", - "transcodingDisabled": "Зміна конфігурації перекодування через веб-інтерфейс відключена з міркувань безпеки. Якщо ви хочете змінити (відредагувати або додати) параметри перекодування, перезавантажте сервер із параметром конфігурації %{config}.", - "transcodingEnabled": "В даний час Navidrome працює з %{config}, що дозволяє запускати системні команди з налаштувань перекодування за допомогою веб-інтерфейсу. Ми рекомендуємо відключити його з міркувань безпеки та ввімкнути його лише під час налаштування параметрів транскодування.", - "songsAddedToPlaylist": "Додати 1 пісню у список відтворення |||| Додати %{smart_count} пісні у список відтворення\n", - "noPlaylistsAvailable": "Нічого немає", - "delete_user_title": "Видалити користувача '%{name}'", - "delete_user_content": "Ви справді хочете видалити цього користувача та всі його дані (включаючи списки відтворення і налаштування)?", - "notifications_blocked": "У вас заблоковані Сповіщення для цього сайту у вашому браузері", - "notifications_not_available": "Ваш браузер не підтримує сповіщення, або ви не підключені до Navidrome через HTTPS", - "lastfmLinkSuccess": "Last.fm успішно підключено, scrobbling увімкнено", - "lastfmLinkFailure": "Last.fm не вдалося підключити", - "lastfmUnlinkSuccess": "Last.fm від'єднано та вимкнено scrobbling", - "lastfmUnlinkFailure": "Last.fm не вдалося від'єднати", - "openIn": { - "lastfm": "Відкрити у Last.fm", - "musicbrainz": "Відкрити у MusicBrainz" - }, - "lastfmLink": "Читати більше...", - "listenBrainzLinkSuccess": "ListenBrainz успішно підключено і scrobbling увімкнено для користувача: %{user}.", - "listenBrainzLinkFailure": "ListenBrainz не вдалося зв'язати: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz від'єднано та вимкнено scrobbling", - "listenBrainzUnlinkFailure": "ListenBrainz не вдалося від'єднати", - "downloadOriginalFormat": "Завантажити в вихідному форматі", - "shareOriginalFormat": "Поширити у вихідному форматі", - "shareDialogTitle": "Поширити %{resource} '%{name}'", - "shareBatchDialogTitle": "Поширити 1 %{resource} |||| Поширити %{smart_count} %{resource}", - "shareSuccess": "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": "Ви впевнені, що хочете видалити всі відсутні файли з бази даних? Це назавжди видалить будь-які посилання на них, включно з кількістю відтворень та рейтингами.", - "noSimilarSongsFound": "Не знайдено схожих треків", - "noTopSongsFound": "Не знайдено ТОП-треків", - "startingInstantMix": "Завантаження міксу...", - "uploadCover": "Завантажити обкладинку", - "removeCover": "Видалити обкладинку", - "coverUploaded": "Обкладинку оновлено", - "coverRemoved": "Обкладинка видалена", - "coverUploadError": "Помилка завантаження обкладинки", - "coverRemoveError": "Помилка видалення обкладинки", - "metadataRefreshStarted": "" - }, - "menu": { - "library": "Бібліотека", - "settings": "Налаштування", - "version": "Версія", - "theme": "Тема", - "personal": { - "name": "Особисті налаштування", - "options": { - "theme": "Тема", - "language": "Мова", - "defaultView": "Вигляд по замовчуванню", - "desktop_notifications": "Сповіщення", - "lastfmScrobbling": "Скробблінг до Last.fm", - "listenBrainzScrobbling": "Скробблінг до ListenBrainz", - "replaygain": "Режим ReplayGain", - "preAmp": "ReplayGain підсилення (дБ)", - "gain": { - "none": "Вимкнено", - "album": "Використовувати підсилення для альбому", - "track": "Використовувати підсилення для треку" + "note": "Примітки", + "transcodingDisabled": "Зміна конфігурації перекодування через веб-інтерфейс відключена з міркувань безпеки. Якщо ви хочете змінити (відредагувати або додати) параметри перекодування, перезавантажте сервер із параметром конфігурації %{config}.", + "transcodingEnabled": "В даний час Navidrome працює з %{config}, що дозволяє запускати системні команди з налаштувань перекодування за допомогою веб-інтерфейсу. Ми рекомендуємо відключити його з міркувань безпеки та ввімкнути його лише під час налаштування параметрів транскодування.", + "songsAddedToPlaylist": "Додати 1 пісню у список відтворення |||| Додати %{smart_count} пісні у список відтворення\n", + "noPlaylistsAvailable": "Нічого немає", + "delete_user_title": "Видалити користувача '%{name}'", + "delete_user_content": "Ви справді хочете видалити цього користувача і усі його данні (включаючи списки відтворення і налаштування)?", + "notifications_blocked": "У вас заблоковані Сповіщення для цього сайту у вашому браузері", + "notifications_not_available": "Ваш браузер не підтримує сповіщень або доступ до Navidrome не використовує https", + "lastfmLinkSuccess": "", + "lastfmLinkFailure": "", + "lastfmUnlinkSuccess": "", + "lastfmUnlinkFailure": "", + "openIn": { + "lastfm": "Відкрити у Last.fm", + "musicbrainz": "Відкрити у MusicBrainz" }, - "lastfmNotConfigured": "API-ключ Last.fm не налаштовано" - } + "lastfmLink": "Читати більше...", + "listenBrainzLinkSuccess": "", + "listenBrainzLinkFailure": "", + "listenBrainzUnlinkSuccess": "", + "listenBrainzUnlinkFailure": "", + "downloadOriginalFormat": "", + "shareOriginalFormat": "", + "shareDialogTitle": "", + "shareBatchDialogTitle": "", + "shareSuccess": "", + "shareFailure": "", + "downloadDialogTitle": "", + "shareCopyToClipboard": "" }, - "albumList": "Альбом", - "about": "Довідка", - "playlists": "Списки відтворення", - "sharedPlaylists": "Загальнодоступний список відтворення", - "librarySelector": { - "allLibraries": "Усі бібліотеки (%{count})", - "multipleLibraries": "%{selected} з %{total} Бібліотеки", - "selectLibraries": "Вибір бібліотек", - "none": "Відсутня" + "menu": { + "library": "Бібліотека", + "settings": "Налаштування", + "version": "Версія", + "theme": "Тема", + "personal": { + "name": "Особистий", + "options": { + "theme": "Тема", + "language": "Мова", + "defaultView": "Вигляд по замовчуванню", + "desktop_notifications": "Сповіщення", + "lastfmScrobbling": "", + "listenBrainzScrobbling": "", + "replaygain": "", + "preAmp": "", + "gain": { + "none": "", + "album": "", + "track": "" + } + } + }, + "albumList": "Альбом", + "about": "Довідка", + "playlists": "Списки відтворення", + "sharedPlaylists": "Загальнодоступний список відтворення" }, - "onlyFavourites": "Показати улюблене" - }, - "player": { - "playListsText": "Грати по черзі", - "openText": "Відкрити", - "closeText": "Закрити", - "notContentText": "Немає музики", - "clickToPlayText": "Натисніть для програвання", - "clickToPauseText": "Натисніть для паузи", - "nextTrackText": "Наступний трек", - "previousTrackText": "Попередній трек", - "reloadText": "Перезавантаження", - "volumeText": "Гучність", - "toggleLyricText": "Перемикнути текст", - "toggleMiniModeText": "Мінімізувати", - "destroyText": "Видалити", - "downloadText": "Завантажити", - "removeAudioListsText": "Видалити аудіо лист", - "clickToDeleteText": "Натисніть, щоб видалити", - "emptyLyricText": "Немає тексту", - "playModeText": { - "order": "По порядку", - "orderLoop": "Повторити", - "singleLoop": "Повторити один раз", - "shufflePlay": "Перемішати" + "player": { + "playListsText": "Грати по черзі", + "openText": "Відкрити", + "closeText": "Закрити", + "notContentText": "Без музики", + "clickToPlayText": "Натисніть для програвання", + "clickToPauseText": "Натисніть для паузи", + "nextTrackText": "Наступний трек", + "previousTrackText": "Попередній трек", + "reloadText": "Перезавантаження", + "volumeText": "Гучність", + "toggleLyricText": "Перемикнути текст", + "toggleMiniModeText": "Мінімізувати", + "destroyText": "Видалити", + "downloadText": "Завантажити", + "removeAudioListsText": "Видалити аудіо лист", + "clickToDeleteText": "Натисніть, щоб видалити", + "emptyLyricText": "Без тексту", + "playModeText": { + "order": "По порядку", + "orderLoop": "Повторити", + "singleLoop": "Повторити один раз", + "shufflePlay": "Перемішати" + } + }, + "about": { + "links": { + "homepage": "Головна", + "source": "Вихідний код", + "featureRequests": "Пропозиції" + } + }, + "activity": { + "title": "Дії", + "totalScanned": "Всього каталогів відскановано", + "quickScan": "Швидке сканування", + "fullScan": "Повне сканування", + "serverUptime": "Час роботи", + "serverDown": "Оффлайн" + }, + "help": { + "title": "Navidrome гарячі клавіші", + "hotkeys": { + "show_help": "Показати довідку", + "toggle_menu": "Сховати/Показати бокове меню", + "toggle_play": "Грати / Пауза", + "prev_song": "Попередня пісня", + "next_song": "Наступна пісня", + "vol_up": "Гучність вгору", + "vol_down": "Гучність вниз", + "toggle_love": "Відмітити поточні пісні", + "current_song": "" + } } - }, - "about": { - "links": { - "homepage": "Головна", - "source": "Вихідний код", - "featureRequests": "Пропозиції", - "lastInsightsCollection": "Останній збір даних", - "insights": { - "disabled": "Вимкнено", - "waiting": "Очікування" - } - }, - "tabs": { - "about": "Про", - "config": "Конфігурація" - }, - "config": { - "configName": "Назва конфігурації", - "environmentVariable": "Змінна середовища", - "currentValue": "Поточне значення", - "configurationFile": "Файл конфігурації", - "exportToml": "Експортувати Конфігурацію (у форматі TOML)", - "exportSuccess": "Конфігурацію експортовано в буфер обміну у форматі TOML", - "exportFailed": "Не вдалося скопіювати конфігурацію", - "devFlagsHeader": "Прапорці розробки (можуть бути змінені/видалені)", - "devFlagsComment": "Це експериментальні налаштування, які можуть бути видалені в майбутніх версіях.", - "downloadToml": "Завантажити конфігурацію (TOML)" - } - }, - "activity": { - "title": "Дії", - "totalScanned": "Всього каталогів відскановано", - "quickScan": "Швидке сканування", - "fullScan": "Повне сканування", - "serverUptime": "Час роботи", - "serverDown": "Оффлайн", - "scanType": "Тип", - "status": "Помилка сканування", - "elapsedTime": "Пройдений час", - "selectiveScan": "Вибірковий" - }, - "help": { - "title": "Гарячі клавіші Navidrome", - "hotkeys": { - "show_help": "Показати довідку", - "toggle_menu": "Сховати/Показати бокове меню", - "toggle_play": "Грати / Пауза", - "prev_song": "Попередня пісня", - "next_song": "Наступна пісня", - "vol_up": "Гучність вгору", - "vol_down": "Гучність вниз", - "toggle_love": "Відмітити поточні пісні", - "current_song": "Перейти до поточної пісні" - } - }, - "nowPlaying": { - "title": "Зараз грає", - "empty": "Нічого не грає", - "minutesAgo": "%{smart_count} хвилин тому |||| %{smart_count} хвилин тому" - } } \ No newline at end of file diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json index cae42460b..e6500e963 100644 --- a/resources/i18n/zh-Hans.json +++ b/resources/i18n/zh-Hans.json @@ -1,736 +1,456 @@ { - "languageName": "简体中文", - "resources": { - "song": { - "name": "歌曲", - "fields": { - "albumArtist": "专辑艺人", - "duration": "时长", - "trackNumber": "曲目序号", - "playCount": "播放次数", - "title": "标题", - "artist": "艺人", - "composer": "作曲者", - "album": "专辑", - "path": "文件路径", - "genre": "流派", - "libraryName": "媒体库", - "compilation": "合辑", - "year": "发行年份", - "size": "文件大小", - "updatedAt": "更新于", - "bitRate": "比特率", - "bitDepth": "位深度", - "sampleRate": "采样率", - "albumGain": "专辑增益", - "trackGain": "曲目增益", - "channels": "声道", - "disc": "碟片 %{discNumber}", - "discSubtitle": "碟片副标题", - "starred": "收藏", - "comment": "注释", - "rating": "评分", - "quality": "品质", - "bpm": "BPM", - "playDate": "上次播放", - "createdAt": "加入日期", - "grouping": "分组", - "mood": "情绪", - "participants": "其他参与人员", - "tags": "附加标签", - "mappedTags": "映射标签", - "rawTags": "原始标签", - "missing": "丢失" - }, - "actions": { - "addToQueue": "添加到播放队列", - "playNow": "立即播放", - "addToPlaylist": "添加到歌单", - "showInPlaylist": "在歌单中显示", - "shuffleAll": "全部随机播放", - "download": "下载", - "playNext": "下一首播放", - "info": "查看信息", - "instantMix": "即兴推荐" - } + "languageName": "简体中文", + "resources": { + "song": { + "name": "歌曲 |||| 歌曲", + "fields": { + "albumArtist": "专辑歌手", + "duration": "时长", + "trackNumber": "歌曲序号", + "playCount": "播放次数", + "title": "曲名", + "artist": "歌手", + "album": "专辑", + "path": "文件路径", + "genre": "类型", + "compilation": "合辑", + "year": "发行年份", + "size": "文件大小", + "updatedAt": "更新于", + "bitRate": "比特率", + "discSubtitle": "字幕", + "starred": "收藏", + "comment": "注释", + "rating": "评分", + "quality": "品质", + "bpm": "BPM", + "playDate": "最后一次播放", + "channels": "声道", + "createdAt": "创建于" + }, + "actions": { + "addToQueue": "加入播放列表", + "playNow": "立即播放", + "addToPlaylist": "加入歌单", + "shuffleAll": "全部随机播放", + "download": "下载", + "playNext": "下一首播放", + "info": "查看信息" + } + }, + "album": { + "name": "专辑 |||| 专辑", + "fields": { + "albumArtist": "专辑歌手", + "artist": "歌手", + "duration": "时长", + "songCount": "歌曲数量", + "playCount": "播放次数", + "name": "名称", + "genre": "类型", + "compilation": "合辑", + "year": "发行年份", + "updatedAt": "更新于", + "comment": "注释", + "rating": "评分", + "createdAt": "创建于", + "size": "文件大小" + }, + "actions": { + "playAll": "立即播放", + "playNext": "下首播放", + "addToQueue": "加入播放列表", + "shuffle": "随机播放", + "addToPlaylist": "加入歌单", + "download": "下载", + "info": "查看信息", + "share": "分享" + }, + "lists": { + "all": "所有", + "random": "随机", + "recentlyAdded": "最近添加", + "recentlyPlayed": "最近播放", + "mostPlayed": "最多播放", + "starred": "收藏", + "topRated": "评分排行" + } + }, + "artist": { + "name": "艺术家 |||| 艺术家", + "fields": { + "name": "名称", + "albumCount": "专辑数", + "songCount": "歌曲数", + "playCount": "播放次数", + "rating": "评分", + "genre": "类型", + "size": "文件大小" + } + }, + "user": { + "name": "用户 |||| 用户", + "fields": { + "userName": "用户名", + "isAdmin": "是否管理员", + "lastLoginAt": "上次登录", + "updatedAt": "更新于", + "name": "名称", + "password": "密码", + "createdAt": "创建于", + "changePassword": "修改密码?", + "currentPassword": "当前密码", + "newPassword": "新密码", + "token": "令牌" + }, + "helperTexts": { + "name": "你名字的更改将在下次登录生效" + }, + "notifications": { + "created": "用户已创建", + "updated": "用户已更新", + "deleted": "用户已删除" + }, + "message": { + "listenBrainzToken": "输入您的 ListenBrainz 用户令牌", + "clickHereForToken": "点击这里来获得你的 ListenBrainz 令牌" + } + }, + "player": { + "name": "客户端 |||| 客户端", + "fields": { + "name": "名称", + "transcodingId": "转码编号", + "maxBitRate": "最大比特率", + "client": "客户端", + "userName": "用户名", + "lastSeen": "上次浏览", + "reportRealPath": "回报实际路径", + "scrobbleEnabled": "发送喜好记录到外部服务" + } + }, + "transcoding": { + "name": "转码 |||| 转码", + "fields": { + "name": "名称", + "targetFormat": "目标格式", + "defaultBitRate": "默认比特率", + "command": "命令" + } + }, + "playlist": { + "name": "歌单 |||| 歌单", + "fields": { + "name": "名称", + "duration": "时长", + "ownerName": "所有者", + "public": "公开", + "updatedAt": "更新于", + "createdAt": "创建于", + "songCount": "歌曲数", + "comment": "注释", + "sync": "自动导入", + "path": "导入" + }, + "actions": { + "selectPlaylist": "选择歌单", + "addNewPlaylist": "新建 %{name}", + "export": "导出", + "makePublic": "设为公开", + "makePrivate": "设为私有" + }, + "message": { + "duplicate_song": "添加重复的歌曲", + "song_exist": "部分选定的歌曲已存在歌单中,继续添加或是跳过它们?" + } + }, + "radio": { + "name": "电台 |||| 电台", + "fields": { + "name": "名称", + "streamUrl": "推流地址", + "homePageUrl": "首页链接", + "updatedAt": "更新于", + "createdAt": "创建于" + }, + "actions": { + "playNow": "开始播放" + } + }, + "share": { + "name": "分享 |||| 分享", + "fields": { + "username": "分享者", + "url": "链接", + "description": "描述", + "contents": "目录", + "expiresAt": "过期于", + "lastVisitedAt": "上次访问于", + "visitCount": "访问数", + "format": "格式", + "maxBitRate": "最大比特率", + "updatedAt": "更新于", + "createdAt": "创建于", + "downloadable": "是否允许下载" + } + } }, - "album": { - "name": "专辑", - "fields": { - "albumArtist": "专辑艺人", - "artist": "艺人", - "duration": "时长", - "songCount": "歌曲数", - "playCount": "播放次数", - "size": "文件大小", - "name": "名称", - "genre": "流派", - "libraryName": "媒体库", - "compilation": "合辑", - "year": "发行年份", - "date": "录制日期", - "originalDate": "原始日期", - "releaseDate": "发⾏日期", - "releases": "发行版本", - "released": "已发⾏", - "updatedAt": "更新于", - "comment": "注释", - "rating": "评分", - "createdAt": "加入日期", - "recordLabel": "厂牌", - "catalogNum": "目录编号", - "releaseType": "发行类型", - "grouping": "分组", - "media": "发行媒介", - "mood": "情绪", - "missing": "丢失" - }, - "actions": { - "playAll": "立即播放", - "playNext": "下一首播放", - "addToQueue": "添加到播放队列", - "share": "分享", - "shuffle": "随机播放", - "addToPlaylist": "添加到歌单", - "download": "下载", - "info": "查看信息", - "refresh": "刷新元数据" - }, - "lists": { - "all": "全部", - "random": "随机", - "recentlyAdded": "最近加入", - "recentlyPlayed": "最近播放", - "mostPlayed": "最多播放", - "starred": "收藏", - "topRated": "评分榜单" - } - }, - "artist": { - "name": "艺人", - "fields": { - "name": "名称", - "albumCount": "专辑数", - "songCount": "歌曲数", - "size": "文件大小", - "playCount": "播放次数", - "rating": "评分", - "genre": "流派", - "role": "参与角色", - "missing": "丢失" - }, - "roles": { - "albumartist": "专辑艺人", - "artist": "艺人", - "composer": "作曲者", - "conductor": "指挥家", - "lyricist": "作词者", - "arranger": "编曲者", - "producer": "制作人", - "director": "总监", - "engineer": "工程师", - "mixer": "混音师", - "remixer": "重混师", - "djmixer": "DJ混音师", - "performer": "演奏家", - "maincredit": "专辑艺人或艺人" - }, - "actions": { - "topSongs": "热门歌曲", - "shuffle": "随机播放", - "radio": "电台" - } - }, - "user": { - "name": "用户", - "fields": { - "userName": "用户名", - "isAdmin": "是否为管理员", - "lastLoginAt": "上次登录", - "lastAccessAt": "上次访问", - "updatedAt": "更新于", - "name": "名称", - "password": "密码", - "createdAt": "创建于", - "changePassword": "修改密码?", - "currentPassword": "当前密码", - "newPassword": "新密码", - "token": "令牌", - "libraries": "媒体库", - "scrobbleFilter": "个性化记录过滤器" - }, - "helperTexts": { - "name": "名称的更改将在下次登录时生效", - "libraries": "为此用户选择指定媒体库,留空则使用默认媒体库", - "scrobbleFilter": "符合这些智能歌单规则的歌曲不会发送到 Last.fm、ListenBrainz 或个性化记录插件。使用与智能歌单相同的 JSON 语法和行为。例如:{\"all\":[{\"lt\":{\"rating\":4}}]}。留空则记录所有播放。本地播放次数不受影响。" - }, - "notifications": { - "created": "用户已创建", - "updated": "用户已更新", - "deleted": "用户已删除" - }, - "validation": { - "librariesRequired": "至少为非管理员用户选择一个媒体库", - "invalidScrobbleFilter": "必须是有效的智能歌单规则。不支持数量限制(limit)、偏移量(offset)和刷新延迟(refresh delay)。" - }, - "message": { - "listenBrainzToken": "输入您的 ListenBrainz 用户令牌", - "clickHereForToken": "点此获得您的令牌", - "selectAllLibraries": "选择全部媒体库", - "adminAutoLibraries": "管理员用户自动拥有所有媒体库的访问权限" - } - }, - "player": { - "name": "播放器", - "fields": { - "name": "名称", - "transcodingId": "转码", - "maxBitRate": "最大比特率", - "client": "客户端", - "userName": "用户名", - "lastSeen": "上次使用", - "reportRealPath": "报告真实路径", - "scrobbleEnabled": "发送个性化记录到外部服务" - } - }, - "transcoding": { - "name": "转码", - "fields": { - "name": "名称", - "targetFormat": "目标格式", - "defaultBitRate": "默认比特率", - "command": "命令" - }, - "choices": { - "noDefaultBitRate": "无" - } - }, - "playlist": { - "name": "歌单", - "fields": { - "name": "名称", - "duration": "时长", - "ownerName": "所有者", - "public": "公开", - "updatedAt": "更新于", - "createdAt": "创建于", - "songCount": "歌曲数", - "comment": "注释", - "sync": "自动导入", - "path": "导入路径", - "starred": "收藏" - }, - "actions": { - "selectPlaylist": "选择歌单", - "addNewPlaylist": "创建%{name}", - "export": "导出", - "saveQueue": "保存为歌单", - "makePublic": "设为公开", - "makePrivate": "设为私有", - "searchOrCreate": "搜索歌单,或输入名称以创建…", - "pressEnterToCreate": "按 Enter 键创建歌单", - "removeFromSelection": "移除选中项" - }, - "message": { - "duplicate_song": "添加了重复的歌曲", - "song_exist": "部分选定的歌曲已存在歌单中,继续添加或是跳过它们?", - "noPlaylistsFound": "未找到歌单", - "noPlaylists": "暂无可用歌单" - } - }, - "radio": { - "name": "电台", - "fields": { - "name": "名称", - "streamUrl": "推流 URL", - "homePageUrl": "首页 URL", - "updatedAt": "更新于", - "createdAt": "创建于" - }, - "actions": { - "playNow": "开始播放" - } - }, - "share": { - "name": "分享", - "fields": { - "username": "分享者", - "url": "URL", - "description": "描述", - "downloadable": "是否允许下载?", - "contents": "目录", - "expiresAt": "过期于", - "lastVisitedAt": "上次访问", - "visitCount": "访问数", - "format": "格式", - "maxBitRate": "最大比特率", - "updatedAt": "更新于", - "createdAt": "创建于" - }, - "notifications": {}, - "actions": {} - }, - "missing": { - "name": "丢失文件", - "empty": "无丢失文件", - "fields": { - "path": "路径", - "size": "文件大小", - "libraryName": "媒体库", - "updatedAt": "丢失于" - }, - "actions": { - "remove": "移除", - "remove_all": "移除所有" - }, - "notifications": { - "removed": "丢失文件已移除" - } - }, - "library": { - "name": "媒体库", - "fields": { - "name": "名称", - "path": "路径", - "remotePath": "远程路径", - "lastScanAt": "上次扫描", - "songCount": "歌曲数", - "albumCount": "专辑数", - "artistCount": "艺人数", - "totalSongs": "歌曲数", - "totalAlbums": "专辑数", - "totalArtists": "艺人数", - "totalFolders": "目录数", - "totalFiles": "文件数", - "totalMissingFiles": "丢失的文件数", - "totalSize": "总大小", - "totalDuration": "时长", - "defaultNewUsers": "新用户默认", - "createdAt": "创建于", - "updatedAt": "更新于" - }, - "sections": { - "basic": "基本信息", - "statistics": "统计数据" - }, - "actions": { - "scan": "扫描媒体库", - "quickScan": "快速扫描", - "fullScan": "完全扫描", - "manageUsers": "管理用户权限", - "viewDetails": "查看详情" - }, - "notifications": { - "created": "媒体库创建成功", - "updated": "媒体库更新成功", - "deleted": "媒体库删除成功", - "scanStarted": "媒体库扫描已开始", - "quickScanStarted": "快速扫描已开始", - "fullScanStarted": "完全扫描已开始", - "scanError": "开始扫描时出错,请检查日志", - "scanCompleted": "媒体库扫描已完成" - }, - "validation": { - "nameRequired": "媒体库名称不能为空", - "pathRequired": "媒体库路径不能为空", - "pathNotDirectory": "媒体库路径必须为目录", - "pathNotFound": "媒体库路径未找到", - "pathNotAccessible": "媒体库路径无法访问", - "pathInvalid": "媒体库路径无效" - }, - "messages": { - "deleteConfirm": "您确定要删除此媒体库吗?这将删除所有关联数据及用户访问权限。", - "scanInProgress": "正在扫描...", - "noLibrariesAssigned": "未向此用户分配媒体库" - } - }, - "plugin": { - "name": "插件", - "fields": { - "id": "ID", - "name": "名称", - "description": "描述", - "version": "版本", - "author": "作者", - "website": "网页", - "permissions": "权限", - "enabled": "已启用", - "status": "状态", - "path": "路径", - "lastError": "错误", - "hasError": "错误", - "updatedAt": "更新于", - "createdAt": "安装于", - "configKey": "键", - "configValue": "值", - "allUsers": "允许所有用户", - "selectedUsers": "指定用户", - "allLibraries": "允许所有媒体库", - "selectedLibraries": "指定媒体库", - "allowWriteAccess": "允许写入权限" - }, - "sections": { - "status": "状态", - "info": "插件信息", - "configuration": "配置", - "manifest": "清单", - "usersPermission": "用户权限", - "libraryPermission": "媒体库权限" - }, - "status": { - "enabled": "已启用", - "disabled": "已禁用" - }, - "actions": { - "enable": "启用", - "disable": "禁用", - "disabledDueToError": "需要在启用前解决错误", - "disabledUsersRequired": "需要在启用前选择用户", - "disabledLibrariesRequired": "需要在启用前选择媒体库", - "addConfig": "添加配置", - "rescan": "重新扫描" - }, - "notifications": { - "enabled": "插件已启用", - "disabled": "插件已禁用", - "updated": "插件已更新", - "error": "插件更新时出错" - }, - "validation": { - "invalidJson": "配置必须是有效的 JSON" - }, - "messages": { - "idHelp": "由插件文件名派生的插件 ID。在 Agents 等配置选项中引用此插件时使用。", - "configHelp": "使用键值对配置插件。如果插件无需配置则留空。", - "configValidationError": "配置验证失败:", - "schemaRenderError": "无法渲染配置表单。此插件的 schema 定义可能无效。", - "clickPermissions": "点击权限以查看详情", - "noConfig": "未设定配置", - "allUsersHelp": "启用时,插件将可以访问所有用户,包括将来创建的。", - "noUsers": "未选择用户", - "permissionReason": "原因", - "usersRequired": "此插件需要访问用户信息。请选择允许此插件访问的用户,或启用 '允许所有用户'。", - "allLibrariesHelp": "启用时,插件将可以访问所有媒体库,包括将来创建的。", - "noLibraries": "未选择媒体库", - "librariesRequired": "此插件需要访问媒体库信息。请选择允许此插件访问的媒体库,或启用 '允许所有媒体库'。", - "allowWriteAccessHelp": "启用时,插件将可以修改媒体库目录中的文件。默认情况下,插件仅拥有只读权限。", - "requiredHosts": "必需的主机" - }, - "placeholders": { - "configKey": "键", - "configValue": "值" - } - } - }, - "ra": { - "auth": { - "welcome1": "感谢您安装 Navidrome!", - "welcome2": "开始使用前,请创建一个管理员账户", - "confirmPassword": "确认密码", - "buttonCreateAdmin": "创建管理员", - "auth_check_error": "请登录以继续", - "user_menu": "用户档案", - "username": "用户名", - "password": "密码", - "sign_in": "登录", - "sign_in_error": "验证失败,请重试", - "logout": "注销", - "insightsCollectionNote": "Navidrome 会收集匿名使用数据以帮助改进项目。\n点击[此处]了解详情或选择不参与收集。" - }, - "validation": { - "invalidChars": "请只使用字母和数字", - "passwordDoesNotMatch": "密码不匹配", - "required": "必填", - "minLength": "不得少于 %{min} 个字符", - "maxLength": "不得多于 %{max} 个字符", - "minValue": "不得小于 %{min}", - "maxValue": "不得大于 %{max}", - "number": "必须为数字", - "email": "必须为有效的电子邮箱", - "oneOf": "必须为: %{options} 其中一项", - "regex": "必须符合指定的格式(正则表达式):%{pattern}", - "unique": "必须唯一", - "url": "必须为有效的 URL" - }, - "action": { - "add_filter": "添加筛选", - "add": "添加", - "back": "返回", - "bulk_actions": "选中 %{smart_count} 项", - "bulk_actions_mobile": "%{smart_count}", - "cancel": "取消", - "clear_input_value": "清除", - "clone": "复制", - "confirm": "确认", - "create": "创建", - "delete": "删除", - "edit": "编辑", - "export": "导出", - "list": "列表", - "refresh": "刷新", - "remove_filter": "取消筛选", - "remove": "移除", - "save": "保存", - "search": "搜索", - "show": "显示", - "sort": "排序", - "undo": "撤销", - "expand": "展开", - "close": "关闭", - "open_menu": "打开菜单", - "close_menu": "关闭菜单", - "unselect": "取消选择", - "skip": "跳过", - "share": "分享", - "download": "下载" - }, - "boolean": { - "true": "是", - "false": "否" - }, - "page": { - "create": "创建%{name}", - "dashboard": "仪表盘", - "edit": "%{name} #%{id}", - "error": "发生错误", - "list": "%{name}", - "loading": "加载中", - "not_found": "未找到", - "show": "%{name} #%{id}", - "empty": "还没有%{name}。", - "invite": "您要创建一个吗?" - }, - "input": { - "file": { - "upload_several": "拖拽多个文件上传或点击以选择", - "upload_single": "拖拽文件上传或点击以选择" - }, - "image": { - "upload_several": "拖拽多个图片上传或点击以选择", - "upload_single": "拖拽图片上传或点击以选择" - }, - "references": { - "all_missing": "未找到引用数据", - "many_missing": "至少有一条关联的引用不再可用", - "single_missing": "关联的引用不再可用" - }, - "password": { - "toggle_visible": "隐藏密码", - "toggle_hidden": "显示密码" - } + "ra": { + "auth": { + "welcome1": "感谢您安装 Navidrome!", + "welcome2": "开始使用前,请创建一个管理员账户", + "confirmPassword": "确认密码", + "buttonCreateAdmin": "创建管理员", + "auth_check_error": "请登录访问更多内容", + "user_menu": "配置", + "username": "用户名", + "password": "密码", + "sign_in": "登录", + "sign_in_error": "验证失败,请重试", + "logout": "注销" + }, + "validation": { + "invalidChars": "请使用字母和数字", + "passwordDoesNotMatch": "密码不匹配", + "required": "必填", + "minLength": "必须不少于 %{min} 个字符", + "maxLength": "必须不多于 %{max} 个字符", + "minValue": "必须不小于 %{min}", + "maxValue": "必须不大于 %{max}", + "number": "必须为数字", + "email": "必须是有效的电子邮箱", + "oneOf": "必须为: %{options} 其中一项", + "regex": "必须符合指定的格式(正则表达式):%{pattern}", + "unique": "必须唯一", + "url": "必须是有效的链接" + }, + "action": { + "add_filter": "添加筛选", + "add": "添加", + "back": "返回", + "bulk_actions": "选中 %{smart_count} 项", + "cancel": "取消", + "clear_input_value": "清除", + "clone": "复制", + "confirm": "确认", + "create": "新建", + "delete": "删除", + "edit": "编辑", + "export": "导出", + "list": "列表", + "refresh": "刷新", + "remove_filter": "取消筛选", + "remove": "删除", + "save": "保存", + "search": "搜索", + "show": "显示", + "sort": "排序", + "undo": "撤销", + "expand": "展开", + "close": "关闭", + "open_menu": "打开菜单", + "close_menu": "关闭菜单", + "unselect": "未选择", + "skip": "跳过", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "分享", + "download": "下载" + }, + "boolean": { + "true": "是", + "false": "否" + }, + "page": { + "create": "新建 %{name}", + "dashboard": "仪表盘", + "edit": "%{name} #%{id}", + "error": "发生错误", + "list": "%{name}", + "loading": "加载中", + "not_found": "未找到", + "show": "%{name} #%{id}", + "empty": "还没有 %{name}。", + "invite": "您要创建一个吗?" + }, + "input": { + "file": { + "upload_several": "拖拽多个文件上传或点击选择一个", + "upload_single": "拖拽单个文件上传或点击选择一个" + }, + "image": { + "upload_several": "拖拽多个图片上传或点击选择一个", + "upload_single": "拖拽单个图片上传或点击选择一个" + }, + "references": { + "all_missing": "未找到参考数据", + "many_missing": "至少有一条参考数据不再可用", + "single_missing": "关联的参考数据不再可用" + }, + "password": { + "toggle_visible": "隐藏密码", + "toggle_hidden": "显示密码" + } + }, + "message": { + "about": "关于", + "are_you_sure": "您确定要进行此操作?", + "bulk_delete_content": "您确定要删除 %{name}? |||| 您确定要删除 %{smart_count} 项?", + "bulk_delete_title": "删除 %{name} |||| 删除 %{smart_count} 项 %{name}", + "delete_content": "您确定要删除该条目?", + "delete_title": "删除 %{name} #%{id}", + "details": "详情", + "error": "发生一个客户端错误,您的请求无法完成", + "invalid_form": "提交内容无效,请检查错误", + "loading": "正在加载页面,请稍候", + "no": "否", + "not_found": "您输入的链接格式不对或链接丢失", + "yes": "是", + "unsaved_changes": "某些更改尚未保存,您确定要离开此页面吗?" + }, + "navigation": { + "no_results": "无内容", + "no_more_results": "页码 %{page} 超出边界,尝试返回上一页", + "page_out_of_boundaries": "页码 %{page} 超出边界", + "page_out_from_end": "已经最后一页", + "page_out_from_begin": "已经是第一页", + "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", + "page_rows_per_page": "每页行数:", + "next": "下一页", + "prev": "上一页", + "skip_nav": "跳过" + }, + "notification": { + "updated": "已更新 1 项 |||| 已更新 %{smart_count} 项", + "created": "已新建 1 项", + "deleted": "已删除 1 项 |||| 已删除 %{smart_count} 项", + "bad_item": "不正确的项", + "item_doesnt_exist": "项不存在", + "http_error": "与服务通信出错", + "data_provider_error": "数据来源错误,请检查控制台的详细信息", + "i18n_error": "加载所选语言时出错", + "canceled": "操作已取消", + "logged_out": "您的会话已结束,请重新登录", + "new_version": "发现新版本!请刷新此页面" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "显示的项", + "layout": "布局", + "grid": "网格", + "table": "表格" + } }, "message": { - "about": "关于", - "are_you_sure": "您确定要进行此操作?", - "bulk_delete_content": "您确定要删除这 %{smart_count} 项%{name}?", - "bulk_delete_title": "删除 %{smart_count} 项%{name}", - "delete_content": "您确定要删除此项?", - "delete_title": "删除 %{name} #%{id}", - "details": "详情", - "error": "发生一个客户端错误,您的请求无法完成", - "invalid_form": "提交内容无效,请检查错误", - "loading": "页面加载中,请稍候", - "no": "否", - "not_found": "您输入了错误的 URL,或 URL 无效", - "yes": "是", - "unsaved_changes": "某些更改尚未保存,您确定要忽视吗?" + "note": "说明", + "transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。", + "transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过配置转码选项来执行任意命令,建议仅在配置转码选项时启用此功能。", + "songsAddedToPlaylist": "已添加 1 首歌到歌单 |||| 已添加 %{smart_count} 首歌到歌单", + "noPlaylistsAvailable": "没有有效的歌单", + "delete_user_title": "删除用户 %{name}", + "delete_user_content": "您确定要删除该用户及其相关数据(包括歌单和用户配置)吗?", + "notifications_blocked": "您已在浏览器的设置中屏蔽了此网站的通知", + "notifications_not_available": "此浏览器不支持桌面通知", + "lastfmLinkSuccess": "Last.fm 已关联并启用喜好记录", + "lastfmLinkFailure": "Last.fm 无法关联", + "lastfmUnlinkSuccess": "已成功解除与 Last.fm 的链接,且喜好记录已禁用", + "lastfmUnlinkFailure": "Last.fm 无法取消关联", + "openIn": { + "lastfm": "在 Last.fm 中打开", + "musicbrainz": "在 MusicBrainz 中打开" + }, + "lastfmLink": "查看更多…", + "listenBrainzLinkSuccess": "ListenBrainz 已关联并启用喜好记录", + "listenBrainzLinkFailure": "ListenBrainz 无法关联:%{error}", + "listenBrainzUnlinkSuccess": "已成功解除与 ListenBrainz 的链接,且喜好记录已禁用", + "listenBrainzUnlinkFailure": "ListenBrainz 无法取消关联", + "downloadOriginalFormat": "下载原始格式", + "shareOriginalFormat": "分享原始格式", + "shareDialogTitle": "分享 %{resource} '%{name}'", + "shareBatchDialogTitle": "分享 1 个 %{resource} |||| 分享 %{smart_count} 个 %{resource}", + "shareSuccess": "分享链接已复制: %{url}", + "shareFailure": "分享链接复制失败: %{url}", + "downloadDialogTitle": "下载 %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter" }, - "navigation": { - "no_results": "未找到结果", - "no_more_results": "页码 %{page} 超出范围,尝试返回上一页", - "page_out_of_boundaries": "页码 %{page} 超出范围", - "page_out_from_end": "已经是最后一页", - "page_out_from_begin": "已经是第一页", - "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", - "page_rows_per_page": "每页项目数:", - "next": "下一页", - "prev": "上一页", - "skip_nav": "跳转到内容" - }, - "notification": { - "updated": "项目已更新 |||| 已更新 %{smart_count} 项", - "created": "项目已创建", - "deleted": "项目已删除 |||| 已删除 %{smart_count} 项", - "bad_item": "不正确的项", - "item_doesnt_exist": "项目不存在", - "http_error": "与服务器通信出错", - "data_provider_error": "dataProvider 错误,请检查控制台以查看详情。", - "i18n_error": "加载所选语言时出错", - "canceled": "操作已取消", - "logged_out": "您的会话已结束,请重新登录。", - "new_version": "发现新版本!请刷新此页面。" - }, - "toggleFieldsMenu": { - "columnsToDisplay": "显示的列", - "layout": "布局", - "grid": "网格", - "table": "表格" - } - }, - "message": { - "uploadCover": "上传封面", - "removeCover": "移除封面", - "coverUploaded": "封面已上传", - "coverRemoved": "封面已移除", - "coverUploadError": "上传封面时出错", - "coverRemoveError": "移除封面时出错", - "metadataRefreshStarted": "正在后台刷新元数据", - "note": "注意", - "transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。", - "transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过从 Web 界面配置转码选项来执行任意命令。建议禁用此选项,并且仅在需要配置转码选项时启用此功能。", - "songsAddedToPlaylist": "已添加 %{smart_count} 首歌到歌单", - "noSimilarSongsFound": "未找到相似歌曲", - "startingInstantMix": "即兴推荐加载中...", - "noTopSongsFound": "未找到热门歌曲", - "noPlaylistsAvailable": "没有有效的歌单", - "delete_user_title": "删除用户%{name}", - "delete_user_content": "您确定要删除此用户及其相关数据(包括歌单和用户配置)吗?", - "remove_missing_title": "移除丢失文件", - "remove_missing_content": "您确定要从数据库中移除选中的丢失文件吗?这将永久删除对它们的所有引用,包括播放次数和评分。", - "remove_all_missing_title": "移除所有丢失文件", - "remove_all_missing_content": "您确定要从数据库中移除所有丢失文件吗?这将永久删除对它们的所有引用,包括播放次数和评分。", - "notifications_blocked": "您已在浏览器的设置中屏蔽了此网站的通知", - "notifications_not_available": "此浏览器不支持桌面通知,或者您未通过 https 访问 Navidrome", - "lastfmLinkSuccess": "Last.fm 成功关联并启用个性化记录", - "lastfmLinkFailure": "Last.fm 无法关联", - "lastfmUnlinkSuccess": "Last.fm 已取消关联并禁用个性化记录", - "lastfmUnlinkFailure": "Last.fm 无法取消关联", - "listenBrainzLinkSuccess": "ListenBrainz 成功关联并启用个性化记录,用户:%{user}", - "listenBrainzLinkFailure": "ListenBrainz 无法关联:%{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz 已取消关联并禁用个性化记录", - "listenBrainzUnlinkFailure": "ListenBrainz 无法取消关联", - "openIn": { - "lastfm": "在 Last.fm 中打开", - "musicbrainz": "在 MusicBrainz 中打开" - }, - "lastfmLink": "查看更多…", - "shareOriginalFormat": "分享原始格式", - "shareDialogTitle": "分享 %{resource} '%{name}'", - "shareBatchDialogTitle": "分享 %{smart_count} 项 %{resource}", - "shareCopyToClipboard": "复制到剪切板: Ctrl+C,Enter", - "shareSuccess": "URL 已复制: %{url}", - "shareFailure": "URL 复制失败: %{url}", - "downloadDialogTitle": "下载 %{resource} '%{name}' (%{size})", - "downloadOriginalFormat": "下载原始格式" - }, - "menu": { - "library": "媒体库", - "librarySelector": { - "allLibraries": "全部媒体库 (%{count})", - "multipleLibraries": "已选 %{selected} 共 %{total} 个媒体库", - "selectLibraries": "选择媒体库", - "none": "无" - }, - "settings": "设置", - "version": "版本", - "theme": "主题", - "personal": { - "name": "个性化", - "options": { + "menu": { + "library": "曲库", + "settings": "设置", + "version": "版本", "theme": "主题", - "language": "语言", - "defaultView": "默认视图", - "desktop_notifications": "桌面通知", - "lastfmNotConfigured": "未配置 Last.fm 的 API-Key", - "lastfmScrobbling": "启用 Last.fm 的个性化记录", - "listenBrainzScrobbling": "启用 ListenBrainz 的个性化记录", - "replaygain": "回放增益", - "preAmp": "回放增益 - 前置放大 (dB)", - "gain": { - "none": "禁用", - "album": "使用专辑增益", - "track": "使用曲目增益" + "personal": { + "name": "个性化", + "options": { + "theme": "主题", + "language": "语言", + "defaultView": "默认界面", + "desktop_notifications": "桌面通知", + "lastfmScrobbling": "启用 Last.fm 的喜好记录", + "listenBrainzScrobbling": "启用 ListenBrainz 的喜好记录", + "replaygain": "回放增益", + "preAmp": "前置放大器 (dB)", + "gain": { + "none": "禁用增益", + "album": "使用专辑增益信息", + "track": "使用歌曲增益信息" + } + } + }, + "albumList": "专辑", + "about": "关于", + "playlists": "歌单", + "sharedPlaylists": "共享的歌单" + }, + "player": { + "playListsText": "播放列表", + "openText": "打开", + "closeText": "关闭", + "notContentText": "没有音乐", + "clickToPlayText": "点击播放", + "clickToPauseText": "点击暂停", + "nextTrackText": "下一首", + "previousTrackText": "上一首", + "reloadText": "重新播放", + "volumeText": "音量", + "toggleLyricText": "切换歌词", + "toggleMiniModeText": "最小化", + "destroyText": "关闭", + "downloadText": "下载", + "removeAudioListsText": "清空播放列表", + "clickToDeleteText": "点击删除 %{name}", + "emptyLyricText": "无歌词", + "playModeText": { + "order": "顺序播放", + "orderLoop": "列表循环", + "singleLoop": "单曲循环", + "shufflePlay": "随机播放" } - } }, - "albumList": "专辑", - "playlists": "歌单", - "sharedPlaylists": "共享的歌单", - "onlyFavourites": "仅显示收藏项", - "about": "关于" - }, - "player": { - "playListsText": "播放队列", - "openText": "打开", - "closeText": "关闭", - "notContentText": "没有音乐", - "clickToPlayText": "点击播放", - "clickToPauseText": "点击暂停", - "nextTrackText": "下一首", - "previousTrackText": "上一首", - "reloadText": "重新播放", - "volumeText": "音量", - "toggleLyricText": "切换歌词", - "toggleMiniModeText": "最小化", - "destroyText": "关闭", - "downloadText": "下载", - "removeAudioListsText": "清空播放队列", - "clickToDeleteText": "点击删除%{name}", - "emptyLyricText": "无歌词", - "playModeText": { - "order": "顺序播放", - "orderLoop": "列表循环", - "singleLoop": "单曲循环", - "shufflePlay": "随机播放" - } - }, - "about": { - "links": { - "homepage": "主页", - "source": "源代码", - "featureRequests": "功能需求", - "lastInsightsCollection": "上次的匿名数据收集", - "insights": { - "disabled": "已禁用", - "waiting": "等待中" - } + "about": { + "links": { + "homepage": "主页", + "source": "源代码", + "featureRequests": "功能需求" + } }, - "tabs": { - "about": "关于", - "config": "配置" + "activity": { + "title": "运行情况", + "totalScanned": "已完成扫描的目录", + "quickScan": "快速扫描", + "fullScan": "完全扫描", + "serverUptime": "服务器已运行", + "serverDown": "服务器已离线" }, - "config": { - "configName": "配置名称", - "environmentVariable": "环境变量", - "currentValue": "当前值", - "configurationFile": "配置文件", - "exportToml": "导出配置(TOML)", - "downloadToml": "下载配置(TOML)", - "exportSuccess": "配置以 TOML 格式导出到剪贴板完成", - "exportFailed": "复制配置失败", - "devFlagsHeader": "开发标志(可能会更改/删除)", - "devFlagsComment": "这些是实验性设置,可能会在未来版本中删除" + "help": { + "title": "Navidrome 快捷键", + "hotkeys": { + "show_help": "显示此帮助", + "toggle_menu": "显示/隐藏菜单侧栏", + "toggle_play": "播放/暂停", + "prev_song": "上一首歌", + "next_song": "下一首歌", + "vol_up": "增大音量", + "vol_down": "减小音量", + "toggle_love": "添加/移除星标", + "current_song": "转到当前播放" + } } - }, - "activity": { - "title": "运行情况", - "totalScanned": "已完成扫描的文件夹", - "quickScan": "快速扫描", - "fullScan": "完全扫描", - "selectiveScan": "选择性扫描", - "serverUptime": "服务器已运行", - "serverDown": "服务器已离线", - "scanType": "上次扫描", - "status": "扫描错误", - "elapsedTime": "用时" - }, - "nowPlaying": { - "title": "正在播放", - "empty": "无播放内容", - "minutesAgo": "%{smart_count} 分钟前" - }, - "help": { - "title": "Navidrome 快捷键", - "hotkeys": { - "show_help": "显示此帮助", - "toggle_menu": "显示/隐藏菜单侧栏", - "toggle_play": "播放/暂停", - "prev_song": "上一首", - "next_song": "下一首", - "current_song": "转到当前播放", - "vol_up": "增大音量", - "vol_down": "减小音量", - "toggle_love": "添加/移除收藏" - } - } -} +} \ No newline at end of file diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index 7b8819da3..14b81b42a 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -1,734 +1,456 @@ { - "languageName": "繁體中文", - "resources": { - "song": { - "name": "歌曲 |||| 歌曲", - "fields": { - "albumArtist": "專輯藝人", - "duration": "長度", - "trackNumber": "#", - "playCount": "播放次數", - "title": "標題", - "artist": "藝人", - "album": "專輯", - "path": "檔案路徑", - "genre": "曲風", - "compilation": "合輯", - "year": "發行年份", - "size": "檔案大小", - "updatedAt": "更新於", - "bitRate": "位元率", - "discSubtitle": "光碟副標題", - "starred": "收藏", - "comment": "註解", - "rating": "評分", - "quality": "品質", - "bpm": "BPM", - "playDate": "上次播放", - "channels": "聲道", - "createdAt": "建立於", - "grouping": "分組", - "mood": "情緒", - "participants": "其他參與人員", - "tags": "額外標籤", - "mappedTags": "分類後標籤", - "rawTags": "原始標籤", - "bitDepth": "位元深度", - "sampleRate": "取樣率", - "missing": "遺失", - "libraryName": "媒體庫", - "composer": "作曲者", - "disc": "光碟 %{discNumber}", - "albumGain": "專輯增益", - "trackGain": "曲目增益" - }, - "actions": { - "addToQueue": "加入至播放佇列", - "playNow": "立即播放", - "addToPlaylist": "加入至播放清單", - "shuffleAll": "全部隨機播放", - "download": "下載", - "playNext": "下一首播放", - "info": "取得資訊", - "showInPlaylist": "在播放清單中顯示", - "instantMix": "即時混音" - } + "languageName": "繁體中文", + "resources": { + "song": { + "name": "歌曲 |||| 歌曲", + "fields": { + "albumArtist": "專輯藝人", + "duration": "長度", + "trackNumber": "#", + "playCount": "播放次數", + "title": "標題", + "artist": "藝人", + "album": "專輯", + "path": "文件路徑", + "genre": "類型", + "compilation": "合輯", + "year": "發行年份", + "size": "文件大小", + "updatedAt": "更新於", + "bitRate": "位元率", + "discSubtitle": "光盤字幕", + "starred": "收藏", + "comment": "註釋", + "rating": "評分", + "quality": "品質", + "bpm": "BPM", + "playDate": "上次播放", + "channels": "聲道", + "createdAt": "" + }, + "actions": { + "addToQueue": "加入至播放佇列", + "playNow": "立即播放", + "addToPlaylist": "加入至播放清單", + "shuffleAll": "全部隨機播放", + "download": "下載", + "playNext": "下一首播放", + "info": "獲取信息" + } + }, + "album": { + "name": "專輯 |||| 專輯", + "fields": { + "albumArtist": "專輯藝人", + "artist": "藝人", + "duration": "長度", + "songCount": "歌曲數量", + "playCount": "播放次數", + "name": "名稱", + "genre": "類型", + "compilation": "合輯", + "year": "發行年份", + "updatedAt": "更新於", + "comment": "註釋", + "rating": "評分", + "createdAt": "", + "size": "" + }, + "actions": { + "playAll": "立即播放", + "playNext": "下首播放", + "addToQueue": "加入至播放佇列", + "shuffle": "隨機播放", + "addToPlaylist": "加入播放清單", + "download": "下載", + "info": "獲取信息", + "share": "" + }, + "lists": { + "all": "所有", + "random": "隨機", + "recentlyAdded": "最近加入", + "recentlyPlayed": "最近播放", + "mostPlayed": "最多播放的", + "starred": "收藏", + "topRated": "最高評分" + } + }, + "artist": { + "name": "藝人 |||| 藝人", + "fields": { + "name": "名稱", + "albumCount": "專輯數", + "songCount": "歌曲數", + "playCount": "播放次數", + "rating": "評分", + "genre": "類型", + "size": "" + } + }, + "user": { + "name": "使用者 |||| 使用者", + "fields": { + "userName": "使用者", + "isAdmin": "是否管理員", + "lastLoginAt": "上次登入", + "updatedAt": "更新於", + "name": "名稱", + "password": "密碼", + "createdAt": "創建於", + "changePassword": "變更密碼?", + "currentPassword": "現在的密碼", + "newPassword": "新密碼", + "token": "權杖" + }, + "helperTexts": { + "name": "你的名稱會在下次登入時生效" + }, + "notifications": { + "created": "使用者已創建", + "updated": "使用者已更新", + "deleted": "使用者已刪除" + }, + "message": { + "listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖", + "clickHereForToken": "點擊此處來獲得你的 ListenBrainz 權杖" + } + }, + "player": { + "name": "用戶端 |||| 用戶端", + "fields": { + "name": "名稱", + "transcodingId": "轉碼", + "maxBitRate": "最大位元率", + "client": "用戶端", + "userName": "使用者名稱", + "lastSeen": "上次瀏覽", + "reportRealPath": "回報實際路徑", + "scrobbleEnabled": "傳送音樂記錄至外部服務" + } + }, + "transcoding": { + "name": "轉碼 |||| 轉碼", + "fields": { + "name": "名稱", + "targetFormat": "目標格式", + "defaultBitRate": "預設位元率", + "command": "命令" + } + }, + "playlist": { + "name": "播放清單 |||| 播放清單", + "fields": { + "name": "名稱", + "duration": "長度", + "ownerName": "擁有者", + "public": "公開", + "updatedAt": "更新於", + "createdAt": "創建於", + "songCount": "歌曲數", + "comment": "註解", + "sync": "自動導入", + "path": "導入" + }, + "actions": { + "selectPlaylist": "選擇播放清單", + "addNewPlaylist": "創建 %{name}", + "export": "導出", + "makePublic": "設爲公開", + "makePrivate": "設爲私有" + }, + "message": { + "duplicate_song": "加入重複的歌曲", + "song_exist": "有重複歌曲正在播放清單裡,您要加入或略過重複歌曲?" + } + }, + "radio": { + "name": "", + "fields": { + "name": "", + "streamUrl": "", + "homePageUrl": "", + "updatedAt": "", + "createdAt": "" + }, + "actions": { + "playNow": "" + } + }, + "share": { + "name": "", + "fields": { + "username": "", + "url": "", + "description": "", + "contents": "", + "expiresAt": "", + "lastVisitedAt": "", + "visitCount": "", + "format": "", + "maxBitRate": "", + "updatedAt": "", + "createdAt": "", + "downloadable": "" + } + } }, - "album": { - "name": "專輯 |||| 專輯", - "fields": { - "albumArtist": "專輯藝人", - "artist": "藝人", - "duration": "長度", - "songCount": "歌曲數", - "playCount": "播放次數", - "name": "名稱", - "genre": "曲風", - "compilation": "合輯", - "year": "發行年份", - "updatedAt": "更新於", - "comment": "註解", - "rating": "評分", - "createdAt": "建立於", - "size": "檔案大小", - "originalDate": "原始日期", - "releaseDate": "發行日期", - "releases": "發行", - "released": "已發行", - "recordLabel": "唱片公司", - "catalogNum": "目錄編號", - "releaseType": "發行類型", - "grouping": "分組", - "media": "媒體類型", - "mood": "情緒", - "date": "錄製日期", - "missing": "遺失", - "libraryName": "媒體庫" - }, - "actions": { - "playAll": "播放全部", - "playNext": "下一首播放", - "addToQueue": "加入至播放佇列", - "shuffle": "隨機播放", - "addToPlaylist": "加入至播放清單", - "download": "下載", - "info": "取得資訊", - "share": "分享", - "refresh": "重新整理中繼資料" - }, - "lists": { - "all": "所有", - "random": "隨機", - "recentlyAdded": "最近加入", - "recentlyPlayed": "最近播放", - "mostPlayed": "最常播放", - "starred": "收藏", - "topRated": "最高評分" - } - }, - "artist": { - "name": "藝人 |||| 藝人", - "fields": { - "name": "名稱", - "albumCount": "專輯數", - "songCount": "歌曲數", - "playCount": "播放次數", - "rating": "評分", - "genre": "曲風", - "size": "檔案大小", - "role": "參與角色", - "missing": "遺失" - }, - "roles": { - "albumartist": "專輯藝人 |||| 專輯藝人", - "artist": "藝人 |||| 藝人", - "composer": "作曲 |||| 作曲", - "conductor": "指揮 |||| 指揮", - "lyricist": "作詞 |||| 作詞", - "arranger": "編曲 |||| 編曲", - "producer": "製作人 |||| 製作人", - "director": "導演 |||| 導演", - "engineer": "工程師 |||| 工程師", - "mixer": "混音師 |||| 混音師", - "remixer": "重混師 |||| 重混師", - "djmixer": "DJ 混音師 |||| DJ 混音師", - "performer": "表演者 |||| 表演者", - "maincredit": "專輯藝人或藝人 |||| 專輯藝人或藝人" - }, - "actions": { - "shuffle": "隨機播放", - "radio": "電台", - "topSongs": "熱門歌曲" - } - }, - "user": { - "name": "使用者 |||| 使用者", - "fields": { - "userName": "使用者名稱", - "isAdmin": "管理員", - "lastLoginAt": "上次登入", - "updatedAt": "更新於", - "name": "名稱", - "password": "密碼", - "createdAt": "建立於", - "changePassword": "變更密碼?", - "currentPassword": "目前密碼", - "newPassword": "新密碼", - "token": "權杖", - "lastAccessAt": "上次存取", - "libraries": "媒體庫", - "scrobbleFilter": "紀錄過濾條件" - }, - "helperTexts": { - "name": "您的名稱會在下次登入時生效", - "libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫", - "scrobbleFilter": "符合這些智慧播放清單規則的歌曲將不會傳送至 Last.fm、ListenBrainz 或 Scrobbler 插件。語法與行為皆與智慧播放清單的 JSON 格式相同。範例:{\"all\":[{\"lt\":{\"rating\":4}}]}。若留空則會記錄所有播放紀錄。本機播放次數不受影響。" - }, - "notifications": { - "created": "使用者已建立", - "updated": "使用者已更新", - "deleted": "使用者已刪除" - }, - "message": { - "listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖", - "clickHereForToken": "點擊此處來獲得您的 ListenBrainz 權杖", - "selectAllLibraries": "選取全部媒體庫", - "adminAutoLibraries": "管理員預設可存取所有媒體庫" - }, - "validation": { - "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫", - "invalidScrobbleFilter": "必須是有效的智慧播放清單規則。不支援數量限制、位移及重新整理延遲。" - } - }, - "player": { - "name": "播放器 |||| 播放器", - "fields": { - "name": "名稱", - "transcodingId": "轉碼", - "maxBitRate": "最大位元率", - "client": "用戶端", - "userName": "使用者名稱", - "lastSeen": "上次上線", - "reportRealPath": "回報實際路徑", - "scrobbleEnabled": "傳送音樂記錄至外部服務" - } - }, - "transcoding": { - "name": "轉碼 |||| 轉碼", - "fields": { - "name": "名稱", - "targetFormat": "目標格式", - "defaultBitRate": "預設位元率", - "command": "指令" - }, - "choices": { - "noDefaultBitRate": "無" - } - }, - "playlist": { - "name": "播放清單 |||| 播放清單", - "fields": { - "name": "名稱", - "duration": "長度", - "ownerName": "擁有者", - "public": "公開", - "updatedAt": "更新於", - "createdAt": "建立於", - "songCount": "歌曲數", - "comment": "註解", - "sync": "自動匯入", - "path": "匯入來源", - "starred": "收藏" - }, - "actions": { - "selectPlaylist": "選取播放清單:", - "addNewPlaylist": "建立「%{name}」", - "export": "匯出", - "makePublic": "設為公開", - "makePrivate": "設為私人", - "saveQueue": "將播放佇列儲存到播放清單", - "searchOrCreate": "搜尋播放清單,或輸入名稱來新建…", - "pressEnterToCreate": "按 Enter 鍵建立新的播放清單", - "removeFromSelection": "移除選取項目" - }, - "message": { - "duplicate_song": "加入重複的歌曲", - "song_exist": "有重複歌曲正要加入播放清單,您要加入或略過重複歌曲?", - "noPlaylistsFound": "找不到播放清單", - "noPlaylists": "暫無播放清單" - } - }, - "radio": { - "name": "電台 |||| 電台", - "fields": { - "name": "名稱", - "streamUrl": "串流網址", - "homePageUrl": "首頁網址", - "updatedAt": "更新於", - "createdAt": "建立於" - }, - "actions": { - "playNow": "立即播放" - } - }, - "share": { - "name": "分享 |||| 分享", - "fields": { - "username": "分享者", - "url": "網址", - "description": "描述", - "contents": "內容", - "expiresAt": "過期時間", - "lastVisitedAt": "上次造訪時間", - "visitCount": "造訪次數", - "format": "格式", - "maxBitRate": "最大位元率", - "updatedAt": "更新於", - "createdAt": "建立於", - "downloadable": "允許下載?" - } - }, - "missing": { - "name": "遺失檔案 |||| 遺失檔案", - "fields": { - "path": "路徑", - "size": "檔案大小", - "updatedAt": "遺失於", - "libraryName": "媒體庫" - }, - "actions": { - "remove": "刪除", - "remove_all": "刪除所有" - }, - "notifications": { - "removed": "遺失檔案已刪除" - }, - "empty": "無遺失檔案" - }, - "library": { - "name": "媒體庫 |||| 媒體庫", - "fields": { - "name": "名稱", - "path": "路徑", - "remotePath": "遠端路徑", - "lastScanAt": "上次掃描", - "songCount": "歌曲", - "albumCount": "專輯", - "artistCount": "藝人", - "totalSongs": "歌曲", - "totalAlbums": "專輯", - "totalArtists": "藝人", - "totalFolders": "資料夾", - "totalFiles": "檔案", - "totalMissingFiles": "遺失檔案", - "totalSize": "總大小", - "totalDuration": "時長", - "defaultNewUsers": "新使用者預設媒體庫", - "createdAt": "建立於", - "updatedAt": "更新於" - }, - "sections": { - "basic": "基本資訊", - "statistics": "統計" - }, - "actions": { - "scan": "掃描媒體庫", - "manageUsers": "管理使用者權限", - "viewDetails": "查看詳細資料", - "quickScan": "快速掃描", - "fullScan": "完整掃描" - }, - "notifications": { - "created": "成功建立媒體庫", - "updated": "成功更新媒體庫", - "deleted": "成功刪除媒體庫", - "scanStarted": "開始掃描媒體庫", - "scanCompleted": "媒體庫掃描完成", - "quickScanStarted": "快速掃描已開始", - "fullScanStarted": "完整掃描已開始", - "scanError": "掃描啟動失敗,請檢查日誌" - }, - "validation": { - "nameRequired": "請輸入媒體庫名稱", - "pathRequired": "請提供媒體庫路徑", - "pathNotDirectory": "媒體庫路徑必須為目錄", - "pathNotFound": "媒體庫路徑不存在", - "pathNotAccessible": "無法存取媒體庫路徑", - "pathInvalid": "媒體庫路徑無效" - }, - "messages": { - "deleteConfirm": "您確定要刪除此媒體庫嗎?這將刪除所有相關資料和使用者存取權限。", - "scanInProgress": "正在掃描...", - "noLibrariesAssigned": "沒有為該使用者指派任何媒體庫" - } - }, - "plugin": { - "name": "外掛 |||| 外掛", - "fields": { - "id": "ID", - "name": "名稱", - "description": "描述", - "version": "版本", - "author": "作者", - "website": "網站", - "permissions": "權限", - "enabled": "已啟用", - "status": "狀態", - "path": "路徑", - "lastError": "錯誤", - "hasError": "錯誤", - "updatedAt": "更新於", - "createdAt": "安裝於", - "configKey": "鍵", - "configValue": "值", - "allUsers": "允許所有使用者", - "selectedUsers": "選定的使用者", - "allLibraries": "允許所有媒體庫", - "selectedLibraries": "選定的媒體庫", - "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": "必要的 Hosts", - "configValidationError": "設定驗證失敗:", - "schemaRenderError": "無法顯示設定表單。外掛的 schema 可能無效。", - "allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。", - "idHelp": "插件 ID,衍生自其檔案名稱。在 Agents 等設定選項中參照此插件時請使用此 ID。" - }, - "placeholders": { - "configKey": "鍵", - "configValue": "值" - } - } - }, - "ra": { - "auth": { - "welcome1": "感謝您安裝 Navidrome!", - "welcome2": "開始前,請先建立一個管理員帳號", - "confirmPassword": "確認密碼", - "buttonCreateAdmin": "建立管理員", - "auth_check_error": "請登入以繼續", - "user_menu": "個人檔案", - "username": "使用者名稱", - "password": "密碼", - "sign_in": "登入", - "sign_in_error": "驗證失敗,請重試", - "logout": "登出", - "insightsCollectionNote": "Navidrome 會收集匿名使用資料以協助改善項目。\n點擊[此處]了解更多資訊或選擇退出。" - }, - "validation": { - "invalidChars": "請使用字母和數字", - "passwordDoesNotMatch": "密碼不相符", - "required": "必填", - "minLength": "必須不少於 %{min} 個字元", - "maxLength": "必須不多於 %{max} 個字元", - "minValue": "必須不小於 %{min}", - "maxValue": "必須不大於 %{max}", - "number": "必須為數字", - "email": "必須為有效的電子郵件", - "oneOf": "必須為以下其中一項:%{options}", - "regex": "必須符合指定的格式(正規表達式):%{pattern}", - "unique": "必須是唯一的", - "url": "必須為有效的網址" - }, - "action": { - "add_filter": "加入篩選", - "add": "加入", - "back": "返回", - "bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項", - "cancel": "取消", - "clear_input_value": "清除", - "clone": "複製", - "confirm": "確認", - "create": "建立", - "delete": "刪除", - "edit": "編輯", - "export": "匯出", - "list": "清單", - "refresh": "重新整理", - "remove_filter": "清除此條件", - "remove": "移除", - "save": "儲存", - "search": "搜尋", - "show": "顯示", - "sort": "排序", - "undo": "復原", - "expand": "展開", - "close": "關閉", - "open_menu": "開啟選單", - "close_menu": "關閉選單", - "unselect": "取消選取", - "skip": "略過", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "share": "分享", - "download": "下載" - }, - "boolean": { - "true": "是", - "false": "否" - }, - "page": { - "create": "建立 %{name}", - "dashboard": "儀表板", - "edit": "%{name} #%{id}", - "error": "發生錯誤", - "list": "%{name}", - "loading": "載入中", - "not_found": "找不到", - "show": "%{name} #%{id}", - "empty": "還沒有 %{name}。", - "invite": "您要建立一個嗎?" - }, - "input": { - "file": { - "upload_several": "拖曳多個檔案上傳或點擊選擇一個", - "upload_single": "拖曳單個檔案上傳或點擊選擇一個" - }, - "image": { - "upload_several": "拖曳多個圖片上傳或點擊選擇一個", - "upload_single": "拖曳單個圖片上傳或點擊選擇一個" - }, - "references": { - "all_missing": "未找到參考資料", - "many_missing": "至少有一條參考資料不再可用", - "single_missing": "關聯的參考資料不再可用" - }, - "password": { - "toggle_visible": "隱藏密碼", - "toggle_hidden": "顯示密碼" - } + "ra": { + "auth": { + "welcome1": "感謝您安裝 Navidrome!", + "welcome2": "開始前,請創建一個管理員帳戶", + "confirmPassword": "確定密碼", + "buttonCreateAdmin": "創建管理員", + "auth_check_error": "請登入訪問更多內容", + "user_menu": "配置", + "username": "使用者名稱", + "password": "密碼", + "sign_in": "登入", + "sign_in_error": "驗證失敗,請重試", + "logout": "登出" + }, + "validation": { + "invalidChars": "請使用字母和數字", + "passwordDoesNotMatch": "密碼不匹配", + "required": "必填", + "minLength": "必須不少於 %{min} 個字元", + "maxLength": "必須不多於 %{max} 個字元", + "minValue": "必須不小於 %{min}", + "maxValue": "必須不大於 %{max}", + "number": "必須為數字", + "email": "必須是有效的電子信箱", + "oneOf": "必須為: %{options}其中一項", + "regex": "必須符合指定的格式(正则表达式):%{pattern}", + "unique": "必須是唯一的", + "url": "" + }, + "action": { + "add_filter": "加入篩選", + "add": "加入", + "back": "返回", + "bulk_actions": "選中 %{smart_count} 項", + "cancel": "取消", + "clear_input_value": "清除", + "clone": "複製", + "confirm": "確認", + "create": "創建", + "delete": "刪除", + "edit": "編輯", + "export": "導出", + "list": "列表", + "refresh": "刷新", + "remove_filter": "清除此條件", + "remove": "清除", + "save": "保存", + "search": "搜索", + "show": "顯示", + "sort": "排序", + "undo": "撤銷", + "expand": "展開", + "close": "關閉", + "open_menu": "打開選單", + "close_menu": "關閉選單", + "unselect": "未選擇", + "skip": "略過", + "bulk_actions_mobile": "", + "share": "", + "download": "" + }, + "boolean": { + "true": "是", + "false": "否" + }, + "page": { + "create": "創建 %{name}", + "dashboard": "儀表板", + "edit": "%{name} #%{id}", + "error": "發生錯誤", + "list": "%{name}", + "loading": "載入中", + "not_found": "未發現", + "show": "%{name} #%{id}", + "empty": "還沒有 %{name}。", + "invite": "你要創建一個嗎?" + }, + "input": { + "file": { + "upload_several": "拖拽多個文件上傳或點擊選擇一個", + "upload_single": "拖拽單個文件上傳或點擊選擇一個" + }, + "image": { + "upload_several": "拖拽多個圖片上傳或點擊選擇一個", + "upload_single": "拖拽單個圖片上傳或點擊選擇一個" + }, + "references": { + "all_missing": "未找到參考數據", + "many_missing": "至少有一條參考數據不再可用", + "single_missing": "關聯的參考數據不再可用" + }, + "password": { + "toggle_visible": "隱藏密碼", + "toggle_hidden": "顯示密碼" + } + }, + "message": { + "about": "關於", + "are_you_sure": "你確定此操作?", + "bulk_delete_content": "您確定要刪除 %{name}? |||| 您確定要刪除 %{smart_count} 項?", + "bulk_delete_title": "刪除 %{name} |||| 刪除 %{smart_count} 項 %{name}", + "delete_content": "您確定要刪除該項目?", + "delete_title": "刪除 %{name} #%{id}", + "details": "詳情", + "error": "發生一個用戶端錯誤,您的請求無法完成", + "invalid_form": "提交內容無效,請檢查錯誤", + "loading": "正在載入頁面,請稍候", + "no": "否", + "not_found": "您輸入的連結格式不對或連結遺失", + "yes": "是", + "unsaved_changes": "某些更改尚未保存,您確定要離開此頁面嗎?" + }, + "navigation": { + "no_results": "無內容", + "no_more_results": "頁碼 %{page} 超出邊界,嘗試返回上一頁", + "page_out_of_boundaries": "頁碼 %{page} 超出邊界", + "page_out_from_end": "已經最後一頁", + "page_out_from_begin": "已經是第一頁", + "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", + "page_rows_per_page": "每頁行數:", + "next": "下一頁", + "prev": "上一頁", + "skip_nav": "跳過" + }, + "notification": { + "updated": "項已更新 |||| %{smart_count} 項已更新", + "created": "項已創建", + "deleted": "項已刪除 |||| %{smart_count} 項已刪除", + "bad_item": "不確定的項", + "item_doesnt_exist": "項不存在", + "http_error": "伺服器通訊錯誤", + "data_provider_error": "數據來源錯誤,請檢查主控台的詳細訊息", + "i18n_error": "無法載入所選語言", + "canceled": "操作已取消", + "logged_out": "您的會話已結束,請重新登入", + "new_version": "發現新版本!請重新整理視窗" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "顯示欄目", + "layout": "版面", + "grid": "框格", + "table": "表格" + } }, "message": { - "about": "關於", - "are_you_sure": "您確定嗎?", - "bulk_delete_content": "您確定要刪除 %{name}? |||| 您確定要刪除這 %{smart_count} 個項目嗎?", - "bulk_delete_title": "刪除 %{name} |||| 刪除 %{smart_count} 項 %{name}", - "delete_content": "您確定要刪除該項目?", - "delete_title": "刪除 %{name} #%{id}", - "details": "詳細資訊", - "error": "發生用戶端錯誤,您的請求無法完成", - "invalid_form": "提交內容無效,請檢查錯誤", - "loading": "正在載入頁面,請稍候", - "no": "否", - "not_found": "您輸入了錯誤的連結或連結遺失", - "yes": "是", - "unsaved_changes": "某些更改尚未儲存,您確定要離開此頁面嗎?" - }, - "navigation": { - "no_results": "沒有找到結果", - "no_more_results": "頁碼 %{page} 超出邊界,嘗試返回上一頁", - "page_out_of_boundaries": "頁碼 %{page} 超出邊界", - "page_out_from_end": "已經是最後一頁", - "page_out_from_begin": "已經是第一頁", - "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", - "page_rows_per_page": "每頁項目數:", - "next": "下一頁", - "prev": "上一頁", - "skip_nav": "跳至內容" - }, - "notification": { - "updated": "項目已更新 |||| %{smart_count} 項已更新", - "created": "項目已建立", - "deleted": "項目已刪除 |||| %{smart_count} 項已刪除", - "bad_item": "項目不正確", - "item_doesnt_exist": "項目不存在", - "http_error": "伺服器通訊錯誤", - "data_provider_error": "資料來源錯誤,請檢查控制台的詳細資訊", - "i18n_error": "無法載入所選語言", - "canceled": "操作已取消", - "logged_out": "您的工作階段已結束,請重新登入", - "new_version": "發現新版本!請重新整理視窗" - }, - "toggleFieldsMenu": { - "columnsToDisplay": "顯示欄位", - "layout": "版面", - "grid": "網格", - "table": "表格" - } - }, - "message": { - "note": "注意", - "transcodingDisabled": "出於安全原因,已禁用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 設定選項的情況下重新啟動伺服器。", - "transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。", - "songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單", - "noPlaylistsAvailable": "沒有可用的播放清單", - "delete_user_title": "刪除使用者「%{name}」", - "delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?", - "notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知", - "notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome", - "lastfmLinkSuccess": "已成功連結 Last.fm 並開啟音樂記錄", - "lastfmLinkFailure": "無法連結 Last.fm", - "lastfmUnlinkSuccess": "已取消與 Last.fm 的連結並停用音樂記錄", - "lastfmUnlinkFailure": "無法取消與 Last.fm 的連結", - "openIn": { - "lastfm": "在 Last.fm 中開啟", - "musicbrainz": "在 MusicBrainz 中開啟" - }, - "lastfmLink": "查看更多…", - "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連結 ListenBrainz 並開啟音樂記錄", - "listenBrainzLinkFailure": "無法連結 ListenBrainz:%{error}", - "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連結並停用音樂記錄", - "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連結", - "downloadOriginalFormat": "下載原始格式", - "shareOriginalFormat": "分享原始格式", - "shareDialogTitle": "分享 %{resource} '%{name}'", - "shareBatchDialogTitle": "分享 1 個%{resource} |||| 分享 %{smart_count} 個%{resource}", - "shareSuccess": "分享成功,連結已複製到剪貼簿:%{url}", - "shareFailure": "分享連結複製失敗:%{url}", - "downloadDialogTitle": "下載 %{resource} '%{name}' (%{size})", - "shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter", - "remove_missing_title": "刪除遺失檔案", - "remove_missing_content": "您確定要從媒體庫中刪除所選的遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括其播放次數和評分。", - "remove_all_missing_title": "刪除所有遺失檔案", - "remove_all_missing_content": "您確定要從媒體庫中刪除所有遺失的檔案嗎?這將永久刪除它們的所有相關資訊,包括它們的播放次數和評分。", - "noSimilarSongsFound": "找不到相似歌曲", - "noTopSongsFound": "找不到熱門歌曲", - "startingInstantMix": "正在載入即時混音...", - "uploadCover": "上傳封面", - "removeCover": "移除封面", - "coverUploaded": "已更新封面圖", - "coverRemoved": "已移除封面圖", - "coverUploadError": "上傳封面圖時發生錯誤", - "coverRemoveError": "移除封面圖時發生錯誤", - "metadataRefreshStarted": "正在背景重新整理中繼資料" - }, - "menu": { - "library": "媒體庫", - "settings": "設定", - "version": "版本", - "theme": "主題", - "personal": { - "name": "個人化", - "options": { - "theme": "主題", - "language": "語言", - "defaultView": "預設畫面", - "desktop_notifications": "桌面通知", - "lastfmScrobbling": "啟用 Last.fm 音樂記錄", - "listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄", - "replaygain": "重播增益模式", - "preAmp": "重播增益前置放大器 (dB)", - "gain": { - "none": "無", - "album": "專輯增益", - "track": "曲目增益" + "note": "註解", + "transcodingDisabled": "出於安全原因,禁用了從 Web 介面更改參數。要更改(編輯或新增)轉檔選項,請在啟用 %{config} 選項的情況下重新啟動伺服器。", + "transcodingEnabled": "Navidrome 當前與 %{config} 一起使用,可以通過配置轉檔參數執行任意命令,建議僅在配置轉檔選項時啟用此功能。", + "songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已添加 %{smart_count} 首歌到播放清單", + "noPlaylistsAvailable": "沒有可用的播放清單", + "delete_user_title": "刪除使用者 %{name}", + "delete_user_content": "您確定要刪除該使用者及其相關數據(包括播放清單和使用者配置)嗎?", + "notifications_blocked": "您已在瀏覽器的設置中封鎖了此網站的通知", + "notifications_not_available": "此瀏覽器不支援桌面通知", + "lastfmLinkSuccess": "Last.fm 成功連接並開啟音樂記錄", + "lastfmLinkFailure": "Last.fm 無法連接", + "lastfmUnlinkSuccess": "Last.fm 已無連接並停用音樂記錄", + "lastfmUnlinkFailure": "Last.fm 無法取消連接", + "openIn": { + "lastfm": "在 Last.fm 打開", + "musicbrainz": "在 MusicBrainz 打開" }, - "lastfmNotConfigured": "Last.fm API 金鑰未設定" - } + "lastfmLink": "繼續閱讀…", + "listenBrainzLinkSuccess": "ListenBrainz 成功連接並開啟音樂記錄", + "listenBrainzLinkFailure": "ListenBrainz 無法連接:%{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz 已無連接並停用音樂記錄", + "listenBrainzUnlinkFailure": "ListenBrainz 無法取消連接", + "downloadOriginalFormat": "", + "shareOriginalFormat": "", + "shareDialogTitle": "", + "shareBatchDialogTitle": "", + "shareSuccess": "", + "shareFailure": "", + "downloadDialogTitle": "", + "shareCopyToClipboard": "" }, - "albumList": "專輯", - "about": "關於", - "playlists": "播放清單", - "sharedPlaylists": "分享的播放清單", - "librarySelector": { - "allLibraries": "所有媒體庫 (%{count})", - "multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫", - "selectLibraries": "選取媒體庫", - "none": "無" + "menu": { + "library": "音樂庫", + "settings": "設定", + "version": "版本", + "theme": "主題", + "personal": { + "name": "個性化", + "options": { + "theme": "主題", + "language": "語言", + "defaultView": "預設畫面", + "desktop_notifications": "桌面通知", + "lastfmScrobbling": "啟用 Last.fm 音樂記錄", + "listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄", + "replaygain": "", + "preAmp": "", + "gain": { + "none": "", + "album": "", + "track": "" + } + } + }, + "albumList": "專輯", + "about": "關於", + "playlists": "播放清單", + "sharedPlaylists": "分享的播放清單" }, - "onlyFavourites": "僅顯示收藏" - }, - "player": { - "playListsText": "播放佇列", - "openText": "開啟", - "closeText": "關閉", - "notContentText": "沒有音樂", - "clickToPlayText": "點擊播放", - "clickToPauseText": "點擊暫停", - "nextTrackText": "下一首", - "previousTrackText": "上一首", - "reloadText": "重新載入", - "volumeText": "音量", - "toggleLyricText": "歌詞顯示", - "toggleMiniModeText": "最小化", - "destroyText": "關閉", - "downloadText": "下載", - "removeAudioListsText": "清空播放佇列", - "clickToDeleteText": "點擊刪除 %{name}", - "emptyLyricText": "無歌詞", - "playModeText": { - "order": "順序播放", - "orderLoop": "循環播放", - "singleLoop": "單曲循環", - "shufflePlay": "隨機播放" + "player": { + "playListsText": "播放佇列", + "openText": "打開", + "closeText": "關閉", + "notContentText": "沒有音樂", + "clickToPlayText": "點擊播放", + "clickToPauseText": "點擊暫停", + "nextTrackText": "下一首", + "previousTrackText": "上一首", + "reloadText": "重新播放", + "volumeText": "音量", + "toggleLyricText": "切換歌詞", + "toggleMiniModeText": "最小化", + "destroyText": "關閉", + "downloadText": "下載", + "removeAudioListsText": "清空播放佇列", + "clickToDeleteText": "點擊删除 %{name}", + "emptyLyricText": "無歌詞", + "playModeText": { + "order": "順序播放", + "orderLoop": "列表循環", + "singleLoop": "單曲循環", + "shufflePlay": "隨機播放" + } + }, + "about": { + "links": { + "homepage": "主頁", + "source": "原始碼", + "featureRequests": "功能要求" + } + }, + "activity": { + "title": "運行狀況", + "totalScanned": "已完成掃描的目錄", + "quickScan": "快速掃描", + "fullScan": "完全掃描", + "serverUptime": "伺服器已運行時間", + "serverDown": "伺服器離線" + }, + "help": { + "title": "Navidrome 快捷鍵", + "hotkeys": { + "show_help": "顯示此幫助", + "toggle_menu": "顯示/隱藏選單側欄", + "toggle_play": "播放/暫停", + "prev_song": "上一首歌", + "next_song": "下一首歌", + "vol_up": "提高音量", + "vol_down": "降低音量", + "toggle_love": "添加或移除星標", + "current_song": "" + } } - }, - "about": { - "links": { - "homepage": "首頁", - "source": "原始碼", - "featureRequests": "功能請求", - "lastInsightsCollection": "最近一次洞察資料收集", - "insights": { - "disabled": "已停用", - "waiting": "等待中" - } - }, - "tabs": { - "about": "關於", - "config": "設定" - }, - "config": { - "configName": "設定名稱", - "environmentVariable": "環境變數", - "currentValue": "目前值", - "configurationFile": "設定檔案", - "exportToml": "匯出設定(TOML 格式)", - "exportSuccess": "設定已以 TOML 格式匯出至剪貼簿", - "exportFailed": "設定複製失敗", - "devFlagsHeader": "開發旗標(可能會更改/刪除)", - "devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除", - "downloadToml": "下載設定檔 (TOML)" - } - }, - "activity": { - "title": "運作狀況", - "totalScanned": "已掃描的資料夾總數", - "quickScan": "快速掃描", - "fullScan": "完全掃描", - "serverUptime": "伺服器運作時間", - "serverDown": "伺服器已離線", - "scanType": "掃描類型", - "status": "掃描錯誤", - "elapsedTime": "經過時間", - "selectiveScan": "選擇性掃描" - }, - "help": { - "title": "Navidrome 快捷鍵", - "hotkeys": { - "show_help": "顯示此說明", - "toggle_menu": "顯示/隱藏選單側欄", - "toggle_play": "播放/暫停", - "prev_song": "上一首歌", - "next_song": "下一首歌", - "vol_up": "提高音量", - "vol_down": "降低音量", - "toggle_love": "新增此歌曲至收藏", - "current_song": "前往目前歌曲" - } - }, - "nowPlaying": { - "title": "正在播放", - "empty": "無播放內容", - "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" - } } \ No newline at end of file diff --git a/resources/mappings.yaml b/resources/mappings.yaml deleted file mode 100644 index 294654b6a..000000000 --- a/resources/mappings.yaml +++ /dev/null @@ -1,259 +0,0 @@ -#file: noinspection SpellCheckingInspection -# Tag mapping adapted from https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html -# -# NOTE FOR USERS: -# -# This file can be used as a reference to understand how Navidrome maps the tags in your music files to its fields. -# If you want to customize these mappings, please refer to https://www.navidrome.org/docs/usage/customtags/ -# -# -# NOTE FOR DEVELOPERS: -# -# This file contains the mapping between the tags in your music files and the fields in Navidrome. -# You can add new tags, change the aliases, or add new split characters to the existing tags. -# The artists and roles keys are used to define how to split the tag values into multiple values. -# The tags are divided into two categories: main and additional. -# The main tags are handled directly by Navidrome, while the additional tags are available as fields for smart playlists. -# -# Applies to single valued ARTIST and ALBUMARTIST tags. Won't be applied if the tag is multivalued or the multivalued -# versions are available (ARTISTS and ALBUMARTISTS) -artists: - split: [" / ", " feat. ", " feat ", " ft. ", " ft ", "; "] -# Applies to all remaining single-valued role tags (composer, lyricist, arranger...) -roles: - split: ["/", ";"] - -# These tags are handled directly by Navidrome. You can add/remove/reorder aliases, but changing the tag name -# may require code changes -main: - title: - aliases: [ tit2, title, ©nam, inam ] - titlesort: - aliases: [ tsot, titlesort, sonm, wm/titlesortorder ] - artist: - aliases: [ tpe1, artist, ©art, author, iart ] - artistsort: - aliases: [ tsop, artistsort, artistsort, soar, wm/artistsortorder ] - artists: - aliases: [ txxx:artists, artists, ----:com.apple.itunes:artists, wm/artists ] - artistssort: - aliases: [ artistssort ] - arranger: - aliases: [ tipl:arranger, ipls:arranger, arranger ] - composer: - aliases: [ tcom, composer, ©wrt, wm/composer, imus, - writer, txxx:writer, iwri, - # If you need writer separated from composer, remove these tagss from the line above - # and uncomment the two lines below - ] - #writer: - # aliases: [ WRITER, TXXX:Writer, IWRI ] - composersort: - aliases: [ tsoc, txxx:composersort, composersort, soco, wm/composersortorder ] - lyricist: - aliases: [ text, lyricist, ----:com.apple.itunes:lyricist, wm/writer ] - lyricistsort: - aliases: [ lyricistsort ] - conductor: - aliases: [ tpe3, conductor, ----:com.apple.itunes:conductor, wm/conductor ] - director: - aliases: [ txxx:director, director, ©dir, wm/director ] - djmixer: - aliases: [ tipl:dj-mix, ipls:dj-mix, djmixer, ----:com.apple.itunes:djmixer, wm/djmixer ] - mixer: - aliases: [ tipl:mix, ipls:mix, mixer, ----:com.apple.itunes:mixer, wm/mixer ] - engineer: - aliases: [ tipl:engineer, ipls:engineer, engineer, ----:com.apple.itunes:engineer, wm/engineer, ieng ] - producer: - aliases: [ tipl:producer, ipls:producer, producer, ----:com.apple.itunes:producer, wm/producer, ipro ] - remixer: - aliases: [ tpe4, remixer, mixartist, ----:com.apple.itunes:remixer, wm/modifiedby ] - albumartist: - aliases: [ tpe2, albumartist, album artist, album_artist, aart, wm/albumartist ] - albumartistsort: - aliases: [ tso2, txxx:albumartistsort, albumartistsort, soaa, wm/albumartistsortorder ] - albumartists: - aliases: [ txxx:album artists, albumartists ] - albumartistssort: - aliases: [ albumartistssort ] - album: - aliases: [ talb, album, ©alb, wm/albumtitle, iprd ] - albumsort: - aliases: [ tsoa, albumsort, soal, wm/albumsortorder ] - albumversion: - aliases: [albumversion, musicbrainz_albumcomment, musicbrainz album comment] - album: true - genre: - aliases: [ tcon, genre, ©gen, wm/genre, ignr ] - split: [ ";", "/", "," ] - album: true - mood: - aliases: [ tmoo, mood, ----:com.apple.itunes:mood, wm/mood ] - split: [ ";", "/", "," ] - album: true - compilation: - aliases: [ tcmp, compilation, cpil, wm/iscompilation ] - track: - aliases: [ track, trck, tracknumber, trkn, wm/tracknumber, itrk ] - tracktotal: - aliases: [ tracktotal, totaltracks ] - album: true - disc: - aliases: [ tpos, disc, discnumber, disk, wm/partofset ] - disctotal: - aliases: [ disctotal, totaldiscs ] - album: true - discsubtitle: - aliases: [ tsst, discsubtitle, ----:com.apple.itunes:discsubtitle, setsubtitle, wm/setsubtitle ] - bpm: - aliases: [ tbpm, bpm, tmpo, wm/beatsperminute ] - lyrics: - # Note, @lyr and wm/lyrics have been removed. Taglib somehow appears to always populate `lyrics:xxx` - aliases: [ uslt:description, lyrics, unsyncedlyrics ] - # Generous cap to fit word-timed TTML/Enhanced-LRC karaoke for a full song, - # while still bounding against pathological tags. - maxLength: 1048576 - type: pair # ex: lyrics:eng, lyrics:xxx - comment: - 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, origyear, ----:com.apple.itunes:origyear ] - type: date - recordingdate: - aliases: [ tdrc, date, recordingdate, icrd, record date ] - type: date - releasedate: - aliases: [ tdrl, releasedate, ©day, wm/year, year ] - type: date - catalognumber: - aliases: [ txxx:catalognumber, catalognumber, ----:com.apple.itunes:catalognumber, wm/catalogno ] - musicbrainz_artistid: - aliases: [ txxx:musicbrainz artist id, musicbrainz_artistid, musicbrainz artist id, ----:com.apple.itunes:musicbrainz artist id, musicbrainz/artist id ] - type: uuid - musicbrainz_recordingid: - aliases: [ ufid:http://musicbrainz.org, musicbrainz_trackid, musicbrainz track id, ----:com.apple.itunes:musicbrainz track id, musicbrainz/track id ] - type: uuid - musicbrainz_trackid: - aliases: [txxx:musicbrainz release track id, musicbrainz_releasetrackid, ----:com.apple.itunes:musicbrainz release track id, musicbrainz/release track id] - type: uuid - musicbrainz_albumartistid: - aliases: [ txxx:musicbrainz album artist id, musicbrainz_albumartistid, musicbrainz album artist id, ----:com.apple.itunes:musicbrainz album artist id, musicbrainz/album artist id ] - type: uuid - musicbrainz_albumid: - aliases: [ txxx:musicbrainz album id, musicbrainz_albumid, musicbrainz album id, ----:com.apple.itunes:musicbrainz album id, musicbrainz/album id ] - type: uuid - musicbrainz_releasegroupid: - aliases: [ txxx:musicbrainz release group id, musicbrainz_releasegroupid, ----:com.apple.itunes:musicbrainz release group id, musicbrainz/release group id ] - type: uuid - musicbrainz_composerid: - aliases: [ txxx:musicbrainz composer id, musicbrainz_composerid, musicbrainz_composer_id, ----:com.apple.itunes:musicbrainz composer id, musicbrainz/composer id ] - type: uuid - musicbrainz_lyricistid: - aliases: [ txxx:musicbrainz lyricist id, musicbrainz_lyricistid, musicbrainz_lyricist_id, ----:com.apple.itunes:musicbrainz lyricist id, musicbrainz/lyricist id ] - type: uuid - musicbrainz_directorid: - aliases: [ txxx:musicbrainz director id, musicbrainz_directorid, musicbrainz_director_id, ----:com.apple.itunes:musicbrainz director id, musicbrainz/director id ] - type: uuid - musicbrainz_producerid: - aliases: [ txxx:musicbrainz producer id, musicbrainz_producerid, musicbrainz_producer_id, ----:com.apple.itunes:musicbrainz producer id, musicbrainz/producer id ] - type: uuid - musicbrainz_engineerid: - aliases: [ txxx:musicbrainz engineer id, musicbrainz_engineerid, musicbrainz_engineer_id, ----:com.apple.itunes:musicbrainz engineer id, musicbrainz/engineer id ] - type: uuid - musicbrainz_mixerid: - aliases: [ txxx:musicbrainz mixer id, musicbrainz_mixerid, musicbrainz_mixer_id, ----:com.apple.itunes:musicbrainz mixer id, musicbrainz/mixer id ] - type: uuid - musicbrainz_remixerid: - aliases: [ txxx:musicbrainz remixer id, musicbrainz_remixerid, musicbrainz_remixer_id, ----:com.apple.itunes:musicbrainz remixer id, musicbrainz/remixer id ] - type: uuid - musicbrainz_djmixerid: - aliases: [ txxx:musicbrainz djmixer id, musicbrainz_djmixerid, musicbrainz_djmixer_id, ----:com.apple.itunes:musicbrainz djmixer id, musicbrainz/djmixer id ] - type: uuid - musicbrainz_conductorid: - aliases: [ txxx:musicbrainz conductor id, musicbrainz_conductorid, musicbrainz_conductor_id, ----:com.apple.itunes:musicbrainz conductor id, musicbrainz/conductor id ] - type: uuid - musicbrainz_arrangerid: - aliases: [ txxx:musicbrainz arranger id, musicbrainz_arrangerid, musicbrainz_arranger_id, ----:com.apple.itunes:musicbrainz arranger id, musicbrainz/arranger id ] - type: uuid - releasetype: - aliases: [ txxx:musicbrainz album type, releasetype, musicbrainz_albumtype, ----:com.apple.itunes:musicbrainz album type, musicbrainz/album type ] - album: true - split: [ "," ] - replaygain_album_gain: - aliases: [ txxx:replaygain_album_gain, replaygain_album_gain, ----:com.apple.itunes:replaygain_album_gain ] - replaygain_album_peak: - aliases: [ txxx:replaygain_album_peak, replaygain_album_peak, ----:com.apple.itunes:replaygain_album_peak ] - replaygain_track_gain: - aliases: [ txxx:replaygain_track_gain, replaygain_track_gain, ----:com.apple.itunes:replaygain_track_gain ] - replaygain_track_peak: - aliases: [ txxx:replaygain_track_peak, replaygain_track_peak, ----:com.apple.itunes:replaygain_track_peak ] - r128_album_gain: - aliases: [r128_album_gain] - r128_track_gain: - aliases: [r128_track_gain] - performer: - aliases: [performer] - type: pair - musicbrainz_performerid: - aliases: [ txxx:musicbrainz performer id, musicbrainz_performerid, musicbrainz_performer_id, ----:com.apple.itunes:musicbrainz performer id, musicbrainz/performer id ] - type: pair - explicitstatus: - aliases: [ itunesadvisory, rtng ] - -# Additional tags. You can add new tags without the need to modify the code. They will be available as fields -# for smart playlists -additional: - asin: - aliases: [ txxx:asin, asin, ----:com.apple.itunes:asin ] - barcode: - aliases: [ txxx:barcode, barcode, ----:com.apple.itunes:barcode, wm/barcode ] - copyright: - aliases: [ tcop, copyright, cprt, icop ] - encodedby: - aliases: [ tenc, encodedby, ©too, wm/encodedby, ienc ] - encodersettings: - aliases: [ tsse, encodersettings, ----:com.apple.itunes:encodersettings, wm/encodingsettings ] - grouping: - aliases: [ grp1, grouping, ©grp, wm/contentgroupdescription ] - album: true - key: - aliases: [ tkey, key, ----:com.apple.itunes:initialkey, wm/initialkey ] - isrc: - aliases: [ tsrc, isrc, ----:com.apple.itunes:isrc, wm/isrc ] - language: - aliases: [ tlan, language, ----:com.apple.itunes:language, wm/language, ilng ] - license: - aliases: [ wcop, txxx:license, license, ----:com.apple.itunes:license ] - media: - aliases: [ tmed, media, ----:com.apple.itunes:media, wm/media, imed ] - album: true - movementname: - aliases: [ mvnm, movementname, ©mvn ] - movementtotal: - aliases: [ movementtotal, mvc ] - movement: - aliases: [ mvin, movement, mvi ] - recordlabel: - aliases: [ tpub, label, publisher, ----:com.apple.itunes:label, wm/publisher, organization ] - album: true - musicbrainz_discid: - aliases: [ txxx:musicbrainz disc id, musicbrainz_discid, musicbrainz disc id, ----:com.apple.itunes:musicbrainz disc id, musicbrainz/disc id ] - type: uuid - musicbrainz_workid: - aliases: [ txxx:musicbrainz work id, musicbrainz_workid, musicbrainz work id, ----:com.apple.itunes:musicbrainz work id, musicbrainz/work id ] - type: uuid - releasecountry: - aliases: [ txxx:musicbrainz album release country, releasecountry, ----:com.apple.itunes:musicbrainz album release country, musicbrainz/album release country, icnt ] - album: true - releasestatus: - aliases: [ txxx:musicbrainz album status, releasestatus, musicbrainz_albumstatus, ----:com.apple.itunes:musicbrainz album status, musicbrainz/album status ] - album: true - script: - aliases: [ txxx:script, script, ----:com.apple.itunes:script, wm/script ] - subtitle: - aliases: [ tit3, subtitle, ----:com.apple.itunes:subtitle, wm/subtitle ] - website: - aliases: [ woar, website, weblink, wm/authorurl ] - work: - aliases: [ txxx:work, tit1, work, ©wrk, wm/work ] diff --git a/resources/mime_types.yaml b/resources/mime_types.yaml deleted file mode 100644 index 1a963bdf7..000000000 --- a/resources/mime_types.yaml +++ /dev/null @@ -1,55 +0,0 @@ -# This file controls the MIME types that are used by the Navidrome. -# You can add or modify entries to match your needs. -# Any "audio/*" MIME type is considered a valid audio file for Navidrome, but will only work properly if -# supported by `taglib` and/or `ffmpeg`. -# Any "image/*" MIME type is considered a valid image file to be used as cover art. - -types: -# Audio - .mp3: audio/mpeg - .ogg: audio/ogg - .oga: audio/ogg - .opus: audio/ogg - .aac: audio/aac - .alac: audio/mp4 - .m4a: audio/mp4 - .m4b: audio/mp4 - .flac: audio/flac - .wav: audio/x-wav - .wma: audio/x-ms-wma - .ape: audio/x-monkeys-audio - .mpc: audio/x-musepack - .shn: audio/x-shn - .aif: audio/x-aiff - .aiff: audio/x-aiff - .m3u: audio/x-mpegurl - .pls: audio/x-scpls - .dsf: audio/x-dsf - .wv: audio/x-wavpack - .wvp: audio/x-wavpack - .tak: audio/tak - .mka: audio/x-matroska - .webm: audio/webm - -# Image - .gif: image/gif - .jpg: image/jpeg - .jpeg: image/jpeg - .webp: image/webp - .png: image/png - .bmp: image/bmp - .jxl: image/jxl - .heic: image/heic - .heif: image/heif - -# List of audio formats that are considered lossless -lossless: - - .flac - - .alac - - .ape - - .shn - - .dsf - - .wv - - .wvp - - .tak - - .wav \ No newline at end of file diff --git a/resources/placeholder.png b/resources/placeholder.png new file mode 100644 index 000000000..428d5c088 Binary files /dev/null and b/resources/placeholder.png differ diff --git a/scanner/README.md b/scanner/README.md deleted file mode 100644 index b2c682381..000000000 --- a/scanner/README.md +++ /dev/null @@ -1,466 +0,0 @@ -# Navidrome Scanner: Technical Overview - -This document provides a comprehensive technical explanation of Navidrome's music library scanner system. - -## Architecture Overview - -The Navidrome scanner is built on a multi-phase pipeline architecture designed for efficient processing of music files. It systematically traverses file system directories, processes metadata, and maintains a database representation of the music library. A key performance feature is that some phases run sequentially while others execute in parallel. - -```mermaid -flowchart TD - subgraph "Scanner Execution Flow" - Controller[Scanner Controller] --> Scanner[Scanner Implementation] - - Scanner --> Phase1[Phase 1: Folders Scan] - Phase1 --> Phase2[Phase 2: Missing Tracks] - - Phase2 --> ParallelPhases - - subgraph ParallelPhases["Parallel Execution"] - Phase3[Phase 3: Refresh Albums] - Phase4[Phase 4: Playlist Import] - end - - ParallelPhases --> FinalSteps[Final Steps: GC + Stats] - end - - %% Triggers that can initiate a scan - FileChanges[File System Changes] -->|Detected by| Watcher[Filesystem Watcher] - Watcher -->|Triggers| Controller - - ScheduledJob[Scheduled Job] -->|Based on Scanner.Schedule| Controller - ServerStartup[Server Startup] -->|If Scanner.ScanOnStartup=true| Controller - ManualTrigger[Manual Scan via UI/API] -->|Admin user action| Controller - CLICommand[Command Line: navidrome scan] -->|Direct invocation| Controller - PIDChange[PID Configuration Change] -->|Forces full scan| Controller - DBMigration[Database Migration] -->|May require full scan| Controller - - Scanner -.->|Alternative| External[External Scanner Process] -``` - -The execution flow shows that Phases 1 and 2 run sequentially, while Phases 3 and 4 execute in parallel to maximize performance before the final processing steps. - -## Core Components - -### Scanner Controller (`controller.go`) - -This is the entry point for all scanning operations. It provides: - -- Public API for initiating scans and checking scan status -- Event broadcasting to notify clients about scan progress -- Serialization of scan operations (prevents concurrent scans) -- Progress tracking and monitoring -- Error collection and reporting - -```go -type Scanner interface { - // ScanAll starts a full scan of the music library. This is a blocking operation. - ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) - Status(context.Context) (*StatusInfo, error) -} -``` - -### Scanner Implementation (`scanner.go`) - -The primary implementation that orchestrates the four-phase scanning pipeline. Each phase follows the Phase interface pattern: - -```go -type phase[T any] interface { - producer() ppl.Producer[T] - stages() []ppl.Stage[T] - finalize(error) error - description() string -} -``` - -This design enables: -- Type-safe pipeline construction with generics -- Modular phase implementation -- Separation of concerns -- Easy measurement of performance - -### External Scanner (`external.go`) - -The External Scanner is a specialized implementation that offloads the scanning process to a separate subprocess. This is specifically designed to address memory management challenges in long-running Navidrome instances. - -```go -// scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid -// memory leaks or retention in the main process, as the scanner can consume a lot of memory. The -// external process will be spawned with the same executable as the current process, and will run -// the "scan" command with the "--subprocess" flag. -// -// The external process will send progress updates to the main process through its STDOUT, and the main -// process will forward them to the caller. -``` - -```mermaid -sequenceDiagram - participant MP as Main Process - participant ES as External Scanner - participant SP as Subprocess (navidrome scan --subprocess) - participant FS as File System - participant DB as Database - - Note over MP: DevExternalScanner=true - MP->>ES: ScanAll(ctx, fullScan) - activate ES - - ES->>ES: Locate executable path - ES->>SP: Start subprocess with args:<br>scan --subprocess --configfile ... etc. - activate SP - - Note over ES,SP: Create pipe for communication - - par Subprocess executes scan - SP->>FS: Read files & metadata - SP->>DB: Update database - and Main process monitors progress - loop For each progress update - SP->>ES: Send encoded progress info via stdout pipe - ES->>MP: Forward progress info - end - end - - SP-->>ES: Subprocess completes (success/error) - deactivate SP - ES-->>MP: Return aggregated warnings/errors - deactivate ES -``` - -Technical details: - -1. **Process Isolation** - - Spawns a separate process using the same executable - - Uses the `--subprocess` flag to indicate it's running as a child process - - Preserves configuration by passing required flags (`--configfile`, `--datafolder`, etc.) - -2. **Inter-Process Communication** - - Uses a pipe for bidirectional communication - - Encodes/decodes progress updates using Go's `gob` encoding for efficient binary transfer - - Properly handles process termination and error propagation - -3. **Memory Management Benefits** - - Scanning operations can be memory-intensive, especially with large music libraries - - Memory leaks or excessive allocations are automatically cleaned up when the process terminates - - Main Navidrome process remains stable even if scanner encounters memory-related issues - -4. **Error Handling** - - Detects non-zero exit codes from the subprocess - - Propagates error messages back to the main process - - Ensures resources are properly cleaned up, even in error conditions - -## Scanning Process Flow - -### Phase 1: Folder Scan (`phase_1_folders.go`) - -This phase handles the initial traversal and media file processing. - -```mermaid -flowchart TD - A[Start Phase 1] --> B{Full Scan?} - B -- Yes --> C[Scan All Folders] - B -- No --> D[Scan Modified Folders] - C --> E[Read File Metadata] - D --> E - E --> F[Create Artists] - E --> G[Create Albums] - F --> H[Save to Database] - G --> H - H --> I[Mark Missing Folders] - I --> J[End Phase 1] -``` - -**Technical implementation details:** - -1. **Folder Traversal** - - Uses `walkDirTree` to traverse the directory structure - - Handles symbolic links and hidden files - - Processes `.ndignore` files for exclusions - - Maps files to appropriate types (audio, image, playlist) - -2. **Metadata Extraction** - - Processes files in batches (defined by `filesBatchSize = 200`) - - Extracts metadata using the configured storage backend - - Converts raw metadata to `MediaFile` objects - - Collects and normalizes tag information - -3. **Album and Artist Creation** - - Groups tracks by album ID - - Creates album records from track metadata - - Handles album ID changes by tracking previous IDs - - Creates artist records from track participants - -4. **Database Persistence** - - Uses transactions for atomic updates - - Preserves album annotations across ID changes - - Updates library-artist mappings - - Marks missing tracks for later processing - - Pre-caches artwork for performance - -### Phase 2: Missing Tracks Processing (`phase_2_missing_tracks.go`) - -This phase identifies tracks that have moved or been deleted. - -```mermaid -flowchart TD - A[Start Phase 2] --> B[Load Libraries] - B --> C[Get Missing and Matching Tracks] - C --> D[Group by PID] - D --> E{Match Type?} - E -- Exact --> F[Update Path] - E -- Same PID --> G[Update If Only One] - E -- Equivalent --> H[Update If No Better Match] - F --> I[End Phase 2] - G --> I - H --> I -``` - -**Technical implementation details:** - -1. **Track Identification Strategy** - - Uses persistent identifiers (PIDs) to track tracks across scans - - Loads missing tracks and potential matches from the database - - Groups tracks by PID to limit comparison scope - -2. **Match Analysis** - - Applies three levels of matching criteria: - - Exact match (full metadata equivalence) - - Single match for a PID - - Equivalent match (same base path or similar metadata) - - Prioritizes matches in order of confidence - -3. **Database Update Strategy** - - Preserves the original track ID - - Updates the path to the new location - - Deletes the duplicate entry - - Uses transactions to ensure atomicity - -### Phase 3: Album Refresh (`phase_3_refresh_albums.go`) - -This phase updates album information based on the latest track metadata. - -```mermaid -flowchart TD - A[Start Phase 3] --> B[Load Touched Albums] - B --> C[Filter Unmodified] - C --> D{Changes Detected?} - D -- Yes --> E[Refresh Album Data] - D -- No --> F[Skip] - E --> G[Update Database] - F --> H[End Phase 3] - G --> H - H --> I[Refresh Statistics] -``` - -**Technical implementation details:** - -1. **Album Selection Logic** - - Loads albums that have been "touched" in previous phases - - Uses a producer-consumer pattern for efficient processing - - Retrieves all media files for each album for completeness - -2. **Change Detection** - - Rebuilds album metadata from associated tracks - - Compares album attributes for changes - - Skips albums with no media files - - Avoids unnecessary database updates - -3. **Statistics Refreshing** - - Updates album play counts - - Updates artist play counts - - Maintains consistency between related entities - -### Phase 4: Playlist Import (`phase_4_playlists.go`) - -This phase imports and updates playlists from the file system. - -```mermaid -flowchart TD - A[Start Phase 4] --> B{AutoImportPlaylists?} - B -- No --> C[Skip] - B -- Yes --> D{Admin User Exists?} - D -- No --> E[Log Warning & Skip] - D -- Yes --> F[Load Folders with Playlists] - F --> G{For Each Folder} - G --> H[Read Directory] - H --> I{For Each Playlist} - I --> J[Import Playlist] - J --> K[Pre-cache Artwork] - K --> L[End Phase 4] - C --> L - E --> L -``` - -**Technical implementation details:** - -1. **Playlist Discovery** - - Loads folders known to contain playlists - - Focuses on folders that have been touched in previous phases - - Handles both playlist formats (M3U, NSP) - -2. **Import Process** - - Uses the core.Playlists service for import - - Handles both regular and smart playlists - - Updates existing playlists when changed - - Pre-caches playlist cover art - -3. **Configuration Awareness** - - Respects the AutoImportPlaylists setting - - Requires an admin user for playlist import - - Logs appropriate messages for configuration issues - -## Final Processing Steps - -After the four main phases, several finalization steps occur: - -1. **Garbage Collection** - - Removes dangling tracks with no files - - Cleans up empty albums - - Removes orphaned artists - - Deletes orphaned annotations - -2. **Statistics Refresh** - - Updates artist song and album counts - - Refreshes tag usage statistics - - Updates aggregate metrics - -3. **Library Status Update** - - Marks scan as completed - - Updates last scan timestamp - - Stores persistent ID configuration - -4. **Database Optimization** - - Performs database maintenance - - Optimizes tables and indexes - - Reclaims space from deleted records - -## File System Watching - -The watcher system (`watcher.go`) provides real-time monitoring of file system changes: - -```mermaid -flowchart TD - A[Start Watcher] --> B[For Each Library] - B --> C[Start Library Watcher] - C --> D[Monitor File Events] - D --> E{Change Detected?} - E -- Yes --> F[Wait for More Changes] - F --> G{Time Elapsed?} - G -- Yes --> H[Trigger Scan] - G -- No --> F - H --> I[Wait for Scan Completion] - I --> D -``` - -**Technical implementation details:** - -1. **Event Throttling** - - Uses a timer to batch changes - - Prevents excessive rescanning - - Configurable wait period - -2. **Library-specific Watching** - - Each library has its own watcher goroutine - - Translates paths to library-relative paths - - Filters irrelevant changes - -3. **Platform Adaptability** - - Uses storage-provided watcher implementation - - Supports different notification mechanisms per platform - - Graceful fallback when watching is not supported - -## Edge Cases and Optimizations - -### Handling Album ID Changes - -The scanner carefully manages album identity across scans: -- Tracks previous album IDs to handle ID generation changes -- Preserves annotations when IDs change -- Maintains creation timestamps for consistent sorting - -### Detecting Moved Files - -A sophisticated algorithm identifies moved files: -1. Groups missing and new files by their Persistent ID -2. Applies multiple matching strategies in priority order -3. Updates paths rather than creating duplicate entries - -### Resuming Interrupted Scans - -If a scan is interrupted: -- The next scan detects this condition -- Forces a full scan if the previous one was a full scan -- Continues from where it left off for incremental scans - -### Memory Efficiency - -Several strategies minimize memory usage: -- Batched file processing (200 files at a time) -- External scanner process option -- Database-side filtering where possible -- Stream processing with pipelines - -### Concurrency Control - -The scanner implements a sophisticated concurrency model to optimize performance: - -1. **Phase-Level Parallelism**: - - Phases 1 and 2 run sequentially due to their dependencies - - Phases 3 and 4 run in parallel using the `chain.RunParallel()` function - - Final steps run sequentially to ensure data consistency - -2. **Within-Phase Concurrency**: - - Each phase has configurable concurrency for its stages - - For example, `phase_1_folders.go` processes folders concurrently: `ppl.NewStage(p.processFolder, ppl.Name("process folder"), ppl.Concurrency(conf.Server.DevScannerThreads))` - - Multiple stages can exist within a phase, each with its own concurrency level - -3. **Pipeline Architecture Benefits**: - - Producer-consumer pattern minimizes memory usage - - Work is streamed through stages rather than accumulated - - Back-pressure is automatically managed - -4. **Thread Safety Mechanisms**: - - Atomic counters for statistics gathering - - Mutex protection for shared resources - - Transactional database operations - -## Configuration Options - -The scanner's behavior can be customized through several configuration settings that directly affect its operation: - -### Core Scanner Options - -| Setting | Description | Default | -|-------------------------|------------------------------------------------------------------|----------------| -| `Scanner.Enabled` | Whether the automatic scanner is enabled | true | -| `Scanner.Schedule` | Cron expression or duration for scheduled scans (e.g., "@daily") | "0" (disabled) | -| `Scanner.ScanOnStartup` | Whether to scan when the server starts | true | -| `Scanner.WatcherWait` | Delay before triggering scan after file changes detected | 5s | -| `Scanner.ArtistJoiner` | String used to join multiple artists in track metadata | " • " | - -### Playlist Processing - -| Setting | Description | Default | -|-----------------------------|----------------------------------------------------------|---------| -| `PlaylistsPath` | Path(s) to search for playlists (supports glob patterns) | "" | -| `AutoImportPlaylists` | Whether to import playlists during scanning | true | - -### Performance Options - -| Setting | Description | Default | -|----------------------|-----------------------------------------------------------|---------| -| `DevExternalScanner` | Use external process for scanning (reduces memory issues) | true | -| `DevScannerThreads` | Number of concurrent processing threads during scanning | 5 | - -### Persistent ID Options - -| Setting | Description | Default | -|-------------|---------------------------------------------------------------------|---------------------------------------------------------------------| -| `PID.Track` | Format for track persistent IDs (critical for tracking moved files) | "musicbrainz_trackid\|albumid,discnumber,tracknumber,title" | -| `PID.Album` | Format for album persistent IDs (affects album grouping) | "musicbrainz_albumid\|albumartistid,album,albumversion,releasedate" | - -These options can be set in the Navidrome configuration file (e.g., `navidrome.toml`) or via environment variables with the `ND_` prefix (e.g., `ND_SCANNER_ENABLED=false`). For environment variables, dots in option names are replaced with underscores. - -## Conclusion - -The Navidrome scanner represents a sophisticated system for efficiently managing music libraries. Its phase-based pipeline architecture, careful handling of edge cases, and performance optimizations allow it to handle libraries of significant size while maintaining data integrity and providing a responsive user experience. \ No newline at end of file diff --git a/scanner/cached_genre_repository.go b/scanner/cached_genre_repository.go new file mode 100644 index 000000000..8423a5b74 --- /dev/null +++ b/scanner/cached_genre_repository.go @@ -0,0 +1,45 @@ +package scanner + +import ( + "context" + "strings" + "time" + + "github.com/ReneKroon/ttlcache/v2" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +func newCachedGenreRepository(ctx context.Context, repo model.GenreRepository) model.GenreRepository { + r := &cachedGenreRepo{ + GenreRepository: repo, + ctx: ctx, + } + genres, err := repo.GetAll() + if err != nil { + log.Error(ctx, "Could not load genres from DB", err) + return repo + } + + r.cache = ttlcache.NewCache() + for _, g := range genres { + _ = r.cache.Set(strings.ToLower(g.Name), g.ID) + } + + return r +} + +type cachedGenreRepo struct { + model.GenreRepository + cache *ttlcache.Cache + ctx context.Context +} + +func (r *cachedGenreRepo) Put(g *model.Genre) error { + id, err := r.cache.GetByLoader(strings.ToLower(g.Name), func(key string) (interface{}, time.Duration, error) { + err := r.GenreRepository.Put(g) + return g.ID, 24 * time.Hour, err + }) + g.ID = id.(string) + return err +} diff --git a/scanner/controller.go b/scanner/controller.go deleted file mode 100644 index 1b13c1846..000000000 --- a/scanner/controller.go +++ /dev/null @@ -1,399 +0,0 @@ -package scanner - -import ( - "context" - "errors" - "fmt" - "slices" - "sync" - "sync/atomic" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/metrics" - "github.com/navidrome/navidrome/core/playlists" - "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/server/events" - "github.com/navidrome/navidrome/utils/pl" - "github.com/navidrome/navidrome/utils/singleton" - "golang.org/x/time/rate" -) - -var ( - ErrAlreadyScanning = model.ErrAlreadyScanning -) - -func New(rootCtx context.Context, ds model.DataStore, broker events.Broker, - pls playlists.Playlists, m metrics.Metrics) model.Scanner { - c := &controller{ - rootCtx: rootCtx, - ds: ds, - broker: broker, - pls: pls, - metrics: m, - devExternalScanner: conf.Server.DevExternalScanner, - } - if !c.devExternalScanner { - c.limiter = new(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate}) - } - return c -} - -func (s *controller) getScanner() scanner { - if s.devExternalScanner { - return &scannerExternal{} - } - return &scannerImpl{ds: s.ds, pls: s.pls} -} - -// CallScan starts an in-process scan of specific library/folder pairs. -// If targets is empty, it scans all libraries. -// This is meant to be called from the command line (see cmd/scan.go). -func CallScan(ctx context.Context, ds model.DataStore, pls playlists.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) { - release, err := lockScan(ctx) - if err != nil { - return nil, err - } - defer release() - - ctx = auth.WithAdminUser(ctx, ds) - progress := make(chan *ProgressInfo, 100) - go func() { - defer close(progress) - scanner := &scannerImpl{ds: ds, pls: pls} - scanner.scanFolders(ctx, fullScan, targets, progress) - }() - return progress, nil -} - -func IsScanning() bool { - return running.Load() -} - -type ProgressInfo struct { - LibID int - FileCount uint32 - Path string - Phase string - ChangesDetected bool - Warning string - Error string - ForceUpdate bool -} - -// scanner defines the interface for different scanner implementations. -// This allows for swapping between in-process and external scanners. -type scanner interface { - // scanFolders performs the actual scanning of folders. If targets is nil, it scans all libraries. - scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) -} - -type controller struct { - rootCtx context.Context //nolint:containedctx // scanner lifecycle ctx - ds model.DataStore - broker events.Broker - metrics metrics.Metrics - pls playlists.Playlists - limiter *rate.Sometimes - devExternalScanner bool - count atomic.Uint32 - folderCount atomic.Uint32 - changesDetected bool -} - -// getLastScanTime returns the most recent scan time across all libraries -func (s *controller) getLastScanTime(ctx context.Context) (time.Time, error) { - libs, err := s.ds.Library().GetAll(ctx, model.QueryOptions{ - Sort: "last_scan_at", - Order: "desc", - Max: 1, - }) - if err != nil { - return time.Time{}, fmt.Errorf("getting libraries: %w", err) - } - - if len(libs) == 0 { - return time.Time{}, nil - } - - return libs[0].LastScanAt, nil -} - -// getScanInfo retrieves scan status from the database -func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed time.Duration, lastErr string) { - lastErr, _ = s.ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "") - scanType, _ = s.ds.Property().DefaultGet(ctx, consts.LastScanTypeKey, "") - startTimeStr, _ := s.ds.Property().DefaultGet(ctx, consts.LastScanStartTimeKey, "") - - if startTimeStr != "" { - startTime, err := time.Parse(time.RFC3339, startTimeStr) - if err == nil { - if running.Load() { - elapsed = time.Since(startTime) - } else { - // If scan is not running, calculate elapsed time using the most recent scan time - lastScanTime, err := s.getLastScanTime(ctx) - if err == nil && !lastScanTime.IsZero() { - elapsed = lastScanTime.Sub(startTime) - } - } - } - } - - return scanType, elapsed, lastErr -} - -func (s *controller) Status(ctx context.Context) (*model.ScannerStatus, error) { - lastScanTime, err := s.getLastScanTime(ctx) - if err != nil { - return nil, fmt.Errorf("getting last scan time: %w", err) - } - - scanType, elapsed, lastErr := s.getScanInfo(ctx) - - if running.Load() { - status := &model.ScannerStatus{ - Scanning: true, - LastScan: lastScanTime, - Count: s.count.Load(), - FolderCount: s.folderCount.Load(), - LastError: lastErr, - ScanType: scanType, - ElapsedTime: elapsed, - } - return status, nil - } - - count, folderCount, err := s.getCounters(ctx) - if err != nil { - return nil, fmt.Errorf("getting library stats: %w", err) - } - return &model.ScannerStatus{ - Scanning: false, - LastScan: lastScanTime, - Count: uint32(count), - FolderCount: uint32(folderCount), - LastError: lastErr, - ScanType: scanType, - ElapsedTime: elapsed, - }, nil -} - -func (s *controller) getCounters(ctx context.Context) (int64, int64, error) { - libs, err := s.ds.Library().GetAll(ctx) - if err != nil { - return 0, 0, fmt.Errorf("library count: %w", err) - } - var count, folderCount int64 - for _, l := range libs { - count += int64(l.TotalSongs) - folderCount += int64(l.TotalFolders) - } - return count, folderCount, nil -} - -func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]string, error) { - return s.ScanFolders(requestCtx, fullScan, nil) -} - -func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targets []model.ScanTarget) ([]string, error) { - release, err := lockScan(requestCtx) - if err != nil { - return nil, err - } - defer release() - - // Prepare the context for the scan - ctx := request.AddValues(s.rootCtx, requestCtx) - ctx = auth.WithAdminUser(ctx, s.ds) - - // A quick scan is promoted to a full one when it resumes an interrupted full scan; that happens - // inside the scanner (possibly in a subprocess), so mirror it here for the analysis gate. Must - // be read before the scan: ScanEnd clears the flag. - effectiveFullScan := EffectiveFullScan(ctx, s.ds, fullScan, targets) - if effectiveFullScan || s.includesUnscannedLibrary(ctx, targets) { - if err := db.MarkOptimizePending(ctx); err != nil { - log.Error(ctx, "Scanner: Error marking DB analysis pending", err) - } - } - - // Send the initial scan status event - s.sendMessage(ctx, &events.ScanStatus{Scanning: true, Count: 0, FolderCount: 0}) - progress := make(chan *ProgressInfo, 100) - go func() { - defer close(progress) - scanner := s.getScanner() - scanner.scanFolders(ctx, fullScan, targets, progress) - }() - - // Wait for the scan to finish, sending progress events to all connected clients - scanWarnings, scanError := s.trackProgress(ctx, progress) - for _, w := range scanWarnings { - log.Warn(ctx, fmt.Sprintf("Scan warning: %s", w)) - } - // Store scan error in database so it can be displayed in the UI - if scanError != nil { - _ = s.ds.Property().Put(ctx, consts.LastScanErrorKey, scanError.Error()) - } - // Refresh the query-planner statistics after a successful full scan. This must run in the - // server process: with the external scanner, an ANALYZE in the subprocess is invisible to the - // server's pooled connections; their shared schema cache keeps the old statistics until the - // process restarts. - if effectiveFullScan && scanError == nil { - if err := db.Optimize(ctx); err != nil { - log.Error(ctx, "Scanner: Error analyzing DB", err) - } - } - // If changes were detected, send a refresh event to all clients - if s.changesDetected { - log.Debug(ctx, "Library changes imported. Sending refresh event") - s.broker.SendBroadcastMessage(ctx, &events.RefreshResource{}) - } - // Send the final scan status event, with totals - if count, folderCount, err := s.getCounters(ctx); err != nil { - s.metrics.WriteAfterScanMetrics(ctx, false) - return scanWarnings, err - } else { - scanType, elapsed, lastErr := s.getScanInfo(ctx) - s.metrics.WriteAfterScanMetrics(ctx, true) - s.sendMessage(ctx, &events.ScanStatus{ - Scanning: false, - Count: count, - FolderCount: folderCount, - Error: lastErr, - ScanType: scanType, - ElapsedTime: elapsed, - }) - } - return scanWarnings, scanError -} - -// This is a global variable that is used to prevent multiple scans from running at the same time. -// "There can be only one" - https://youtu.be/sqcLjcSloXs?si=VlsjEOjTJZ68zIyg -var ( - running atomic.Bool - scanMaintenanceMux sync.Mutex -) - -func lockScan(ctx context.Context) (func(), error) { - if !running.CompareAndSwap(false, true) { - log.Debug(ctx, "Scanner already running, ignoring request") - return func() {}, ErrAlreadyScanning - } - scanMaintenanceMux.Lock() - return func() { - scanMaintenanceMux.Unlock() - running.Store(false) - }, nil -} - -// LockForMaintenance prevents a scan from starting while database maintenance is running. -func LockForMaintenance() (func(), bool) { - if !scanMaintenanceMux.TryLock() { - return func() {}, false - } - if running.Load() { - scanMaintenanceMux.Unlock() - return func() {}, false - } - return scanMaintenanceMux.Unlock, true -} - -// EffectiveFullScan reports whether a scan was requested as full, will resume an interrupted full scan, -// or will rescan a library in full because its PID config changed, in one of the included libraries. -func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool { - if fullScan { - return true - } - return anyIncludedLibrary(ctx, ds, targets, func(library model.Library) bool { - return library.FullScanInProgress || library.NeedsPIDRescan() - }) -} - -func (s *controller) includesUnscannedLibrary(ctx context.Context, targets []model.ScanTarget) bool { - return anyIncludedLibrary(ctx, s.ds, targets, func(library model.Library) bool { - return library.LastScanAt.IsZero() - }) -} - -// anyIncludedLibrary reports whether any library included in the scan (all of them when targets is -// empty) matches pred. -func anyIncludedLibrary(ctx context.Context, ds model.DataStore, targets []model.ScanTarget, pred func(model.Library) bool) bool { - libraries, err := ds.Library().GetAll(ctx) - if err != nil { - return false - } - if len(targets) == 0 { - return slices.ContainsFunc(libraries, pred) - } - - targeted := make(map[int]struct{}, len(targets)) - for _, target := range targets { - targeted[target.LibraryID] = struct{}{} - } - return slices.ContainsFunc(libraries, func(library model.Library) bool { - _, ok := targeted[library.ID] - return ok && pred(library) - }) -} - -func (s *controller) trackProgress(ctx context.Context, progress <-chan *ProgressInfo) ([]string, error) { - s.count.Store(0) - s.folderCount.Store(0) - s.changesDetected = false - - var warnings []string - var errs []error - for p := range pl.ReadOrDone(ctx, progress) { - if p.Error != "" { - errs = append(errs, errors.New(p.Error)) - continue - } - if p.Warning != "" { - warnings = append(warnings, p.Warning) - continue - } - if p.ChangesDetected { - s.changesDetected = true - continue - } - s.count.Add(p.FileCount) - if p.FileCount > 0 { - s.folderCount.Add(1) - } - - scanType, elapsed, lastErr := s.getScanInfo(ctx) - status := &events.ScanStatus{ - Scanning: true, - Count: int64(s.count.Load()), - FolderCount: int64(s.folderCount.Load()), - Error: lastErr, - ScanType: scanType, - ElapsedTime: elapsed, - } - if s.limiter != nil && !p.ForceUpdate { - s.limiter.Do(func() { s.sendMessage(ctx, status) }) - } else { - s.sendMessage(ctx, status) - } - } - return warnings, errors.Join(errs...) -} - -func (s *controller) sendMessage(ctx context.Context, status *events.ScanStatus) { - s.broker.SendBroadcastMessage(ctx, status) -} - -// GetInstance returns the scanner singleton: Status reads the progress counters of the controller -// running the scan, and scheduler, watcher and signal scans do not start from the API's injector. -func GetInstance(rootCtx context.Context, ds model.DataStore, broker events.Broker, - pls playlists.Playlists, m metrics.Metrics) model.Scanner { - return singleton.GetInstance(func() *controller { - return New(rootCtx, ds, broker, pls, m).(*controller) - }) -} diff --git a/scanner/controller_test.go b/scanner/controller_test.go deleted file mode 100644 index 974540e32..000000000 --- a/scanner/controller_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package scanner_test - -import ( - "context" - "time" - - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/metrics" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" - "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" -) - -var _ = Describe("Controller", func() { - var ctx context.Context - var ds *tests.MockDataStore - var ctrl model.Scanner - - Describe("Status", func() { - BeforeEach(func() { - ctx = context.Background() - db.Init(ctx) - DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) }) - DeferCleanup(configtest.SetupConfig()) - ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - ds.MockedProperty = &tests.MockedPropertyRepo{} - ctrl = scanner.New(ctx, ds, events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) - }) - - It("includes last scan error", func() { - Expect(ds.Property().Put(ctx, consts.LastScanErrorKey, "boom")).To(Succeed()) - status, err := ctrl.Status(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(status.LastError).To(Equal("boom")) - }) - - It("includes scan type and error in status", func() { - // Set up test data in property repo - Expect(ds.Property().Put(ctx, consts.LastScanErrorKey, "test error")).To(Succeed()) - Expect(ds.Property().Put(ctx, consts.LastScanTypeKey, "full")).To(Succeed()) - - // Get status and verify basic info - status, err := ctrl.Status(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(status.LastError).To(Equal("test error")) - Expect(status.ScanType).To(Equal("full")) - }) - }) -}) - -var _ = Describe("LockForMaintenance", func() { - It("allows only one database maintenance operation at a time", func() { - release, ok := scanner.LockForMaintenance() - Expect(ok).To(BeTrue()) - DeferCleanup(release) - - _, ok = scanner.LockForMaintenance() - Expect(ok).To(BeFalse()) - }) -}) - -var _ = Describe("EffectiveFullScan", func() { - var ds *tests.MockDataStore - - BeforeEach(func() { - pid := model.Library{}.EffectivePID() - libraries := &tests.MockLibraryRepo{} - libraries.SetData(model.Libraries{ - {ID: 1, FullScanInProgress: true, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, - {ID: 2, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, - {ID: 3, LastScanAt: time.Now(), PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track}, - }) - ds = &tests.MockDataStore{MockedLibrary: libraries} - }) - - It("detects a library that needs a full rescan for a PID change", func() { - targets := []model.ScanTarget{{LibraryID: 3, FolderPath: "."}} - Expect(scanner.EffectiveFullScan(GinkgoT().Context(), ds, false, targets)).To(BeTrue()) - }) - - It("detects an interrupted full scan in a targeted library", func() { - targets := []model.ScanTarget{{LibraryID: 1, FolderPath: "."}} - Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeTrue()) - }) - - It("detects an interrupted full scan when scanning all libraries", func() { - Expect(scanner.EffectiveFullScan(context.Background(), ds, false, nil)).To(BeTrue()) - }) - - It("ignores interrupted full scans in untargeted libraries", func() { - targets := []model.ScanTarget{{LibraryID: 2, FolderPath: "."}} - Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeFalse()) - }) -}) - -var _ = Describe("GetInstance", func() { - It("returns the same controller to every caller", func() { - ds := &tests.MockDataStore{} - pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds)) - a := scanner.GetInstance(context.Background(), ds, events.NoopBroker(), pls, metrics.NewNoopInstance()) - b := scanner.GetInstance(context.Background(), ds, events.NoopBroker(), pls, metrics.NewNoopInstance()) - Expect(a).To(BeIdenticalTo(b)) - }) -}) diff --git a/scanner/external.go b/scanner/external.go deleted file mode 100644 index 7f573fe3e..000000000 --- a/scanner/external.go +++ /dev/null @@ -1,168 +0,0 @@ -package scanner - -import ( - "context" - "encoding/gob" - "errors" - "fmt" - "io" - "os" - "os/exec" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -const ( - // argLengthThreshold is the threshold for switching from command-line args to file-based target passing. - // Set conservatively at 24KB to support Windows (~32KB limit) with margin for env vars. - argLengthThreshold = 24 * 1024 -) - -// scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid -// memory leaks or retention in the main process, as the scanner can consume a lot of memory. The -// external process will be spawned with the same executable as the current process, and will run -// the "scan" command with the "--subprocess" flag. -// -// The external process will send progress updates to the main process through its STDOUT, and the main -// process will forward them to the caller. -type scannerExternal struct{} - -func (s *scannerExternal) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) { - s.scan(ctx, fullScan, targets, progress) -} - -func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) { - exe, err := os.Executable() - if err != nil { - progress <- &ProgressInfo{Error: fmt.Sprintf("failed to get executable path: %s", err)} - return - } - - // Build command arguments - args := []string{ - "scan", - "--nobanner", "--subprocess", - "--configfile", conf.Server.ConfigFile, - "--datafolder", conf.Server.DataFolder.String(), - "--cachefolder", conf.Server.CacheFolder.String(), - } - - // Add targets if provided - if len(targets) > 0 { - targetArgs, cleanup, err := targetArguments(ctx, targets, argLengthThreshold) - if err != nil { - progress <- &ProgressInfo{Error: err.Error()} - return - } - defer cleanup() - log.Debug(ctx, "Spawning external scanner process with target file", "fullScan", fullScan, "path", exe, "numTargets", len(targets)) - args = append(args, targetArgs...) - } else { - log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe) - } - - // Add full scan flag if needed - if fullScan { - args = append(args, "--full") - } - - cmd := exec.CommandContext(ctx, exe, args...) - - in, out := io.Pipe() - defer in.Close() - defer out.Close() - cmd.Stdout = out - cmd.Stderr = os.Stderr - - if err := cmd.Start(); err != nil { - progress <- &ProgressInfo{Error: fmt.Sprintf("failed to start scanner process: %s", err)} - return - } - go s.wait(cmd, out) - - decoder := gob.NewDecoder(in) - for { - var p ProgressInfo - if err := decoder.Decode(&p); err != nil { - if !errors.Is(err, io.EOF) { - progress <- &ProgressInfo{Error: fmt.Sprintf("failed to read status from scanner: %s", err)} - } - break - } - progress <- &p - } -} - -func (s *scannerExternal) wait(cmd *exec.Cmd, out *io.PipeWriter) { - if err := cmd.Wait(); err != nil { - if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { - _ = out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %w", cmd, exitErr)) - } else { - _ = out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", cmd, err)) - } - return - } - _ = out.Close() -} - -// targetArguments builds command-line arguments for the given scan targets. -// If the estimated argument length exceeds a threshold, it writes the targets to a temp file -// and returns the --target-file argument instead. -// Returns the arguments, a cleanup function to remove any temp file created, and an error if any. -func targetArguments(ctx context.Context, targets []model.ScanTarget, lengthThreshold int) ([]string, func(), error) { - var args []string - - // Estimate argument length to decide whether to use file-based approach - argLength := estimateArgLength(targets) - - if argLength > lengthThreshold { - // Write targets to temp file and pass via --target-file - targetFile, err := writeTargetsToFile(targets) - if err != nil { - return nil, nil, fmt.Errorf("failed to write targets to file: %w", err) - } - args = append(args, "--target-file", targetFile) - return args, func() { - os.Remove(targetFile) // Clean up temp file - }, nil - } - - // Use command-line arguments for small target lists - for _, target := range targets { - args = append(args, "-t", target.String()) - } - return args, func() {}, nil -} - -// estimateArgLength estimates the total length of command-line arguments for the given targets. -func estimateArgLength(targets []model.ScanTarget) int { - length := 0 - for _, target := range targets { - // Each target adds: "-t " + target string + space - length += 3 + len(target.String()) + 1 - } - return length -} - -// writeTargetsToFile writes the targets to a temporary file, one per line. -// Returns the path to the temp file, which the caller should clean up. -func writeTargetsToFile(targets []model.ScanTarget) (string, error) { - tmpFile, err := os.CreateTemp("", "navidrome-scan-targets-*.txt") - if err != nil { - return "", fmt.Errorf("failed to create temp file: %w", err) - } - defer tmpFile.Close() - - for _, target := range targets { - if _, err := fmt.Fprintln(tmpFile, target.String()); err != nil { - os.Remove(tmpFile.Name()) //nolint:gosec - return "", fmt.Errorf("failed to write to temp file: %w", err) - } - } - - return tmpFile.Name(), nil -} - -var _ scanner = (*scannerExternal)(nil) diff --git a/scanner/external_test.go b/scanner/external_test.go deleted file mode 100644 index 55f103f4d..000000000 --- a/scanner/external_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package scanner - -import ( - "context" - "os" - "strings" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("targetArguments", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) - - Context("with small target list", func() { - It("returns command-line arguments for single target", func() { - targets := []model.ScanTarget{ - {LibraryID: 1, FolderPath: "Music/Rock"}, - } - - args, cleanup, err := targetArguments(ctx, targets, argLengthThreshold) - Expect(err).ToNot(HaveOccurred()) - defer cleanup() - Expect(args).To(Equal([]string{"-t", "1:Music/Rock"})) - }) - - It("returns command-line arguments for multiple targets", func() { - targets := []model.ScanTarget{ - {LibraryID: 1, FolderPath: "Music/Rock"}, - {LibraryID: 2, FolderPath: "Music/Jazz"}, - {LibraryID: 3, FolderPath: "Classical"}, - } - - args, cleanup, err := targetArguments(ctx, targets, argLengthThreshold) - Expect(err).ToNot(HaveOccurred()) - defer cleanup() - Expect(args).To(Equal([]string{ - "-t", "1:Music/Rock", - "-t", "2:Music/Jazz", - "-t", "3:Classical", - })) - }) - - It("handles targets with special characters", func() { - targets := []model.ScanTarget{ - {LibraryID: 1, FolderPath: "Music/Rock & Roll"}, - {LibraryID: 2, FolderPath: "Music/Jazz (Modern)"}, - } - - args, cleanup, err := targetArguments(ctx, targets, argLengthThreshold) - Expect(err).ToNot(HaveOccurred()) - defer cleanup() - Expect(args).To(Equal([]string{ - "-t", "1:Music/Rock & Roll", - "-t", "2:Music/Jazz (Modern)", - })) - }) - }) - - Context("with large target list exceeding threshold", func() { - It("returns --target-file argument when exceeding threshold", func() { - // Create enough targets to exceed the threshold - var targets []model.ScanTarget - for i := 1; i <= 600; i++ { - targets = append(targets, model.ScanTarget{ - LibraryID: 1, - FolderPath: "Music/VeryLongFolderPathToSimulateRealScenario/SubFolder", - }) - } - - args, cleanup, err := targetArguments(ctx, targets, argLengthThreshold) - Expect(err).ToNot(HaveOccurred()) - defer cleanup() - Expect(args).To(HaveLen(2)) - Expect(args[0]).To(Equal("--target-file")) - - // Verify the file exists and has correct format - filePath := args[1] - Expect(filePath).To(ContainSubstring("navidrome-scan-targets-")) - Expect(filePath).To(HaveSuffix(".txt")) - - // Verify file actually exists - _, err = os.Stat(filePath) - Expect(err).ToNot(HaveOccurred()) - }) - - It("creates temp file with correct format", func() { - // Use custom threshold to easily exceed it - targets := []model.ScanTarget{ - {LibraryID: 1, FolderPath: "Music/Rock"}, - {LibraryID: 2, FolderPath: "Music/Jazz"}, - {LibraryID: 3, FolderPath: "Classical"}, - } - - // Set threshold very low to force file usage - args, cleanup, err := targetArguments(ctx, targets, 10) - Expect(err).ToNot(HaveOccurred()) - defer cleanup() - Expect(args[0]).To(Equal("--target-file")) - - // Verify file exists with correct format - filePath := args[1] - Expect(filePath).To(ContainSubstring("navidrome-scan-targets-")) - Expect(filePath).To(HaveSuffix(".txt")) - - // Verify file content - content, err := os.ReadFile(filePath) - Expect(err).ToNot(HaveOccurred()) - lines := strings.Split(strings.TrimSpace(string(content)), "\n") - Expect(lines).To(HaveLen(3)) - Expect(lines[0]).To(Equal("1:Music/Rock")) - Expect(lines[1]).To(Equal("2:Music/Jazz")) - Expect(lines[2]).To(Equal("3:Classical")) - }) - }) - - Context("edge cases", func() { - It("handles empty target list", func() { - var targets []model.ScanTarget - - args, cleanup, err := targetArguments(ctx, targets, argLengthThreshold) - Expect(err).ToNot(HaveOccurred()) - defer cleanup() - Expect(args).To(BeEmpty()) - }) - - It("uses command-line args when exactly at threshold", func() { - // Create targets that are exactly at threshold - targets := []model.ScanTarget{ - {LibraryID: 1, FolderPath: "Music"}, - } - - // Estimate length should be 11 bytes - estimatedLength := estimateArgLength(targets) - - args, cleanup, err := targetArguments(ctx, targets, estimatedLength) - Expect(err).ToNot(HaveOccurred()) - defer cleanup() - Expect(args).To(Equal([]string{"-t", "1:Music"})) - }) - - It("uses file when one byte over threshold", func() { - targets := []model.ScanTarget{ - {LibraryID: 1, FolderPath: "Music"}, - } - - // Set threshold just below the estimated length - estimatedLength := estimateArgLength(targets) - args, cleanup, err := targetArguments(ctx, targets, estimatedLength-1) - Expect(err).ToNot(HaveOccurred()) - defer cleanup() - Expect(args[0]).To(Equal("--target-file")) - }) - }) -}) diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go deleted file mode 100644 index e7eef223c..000000000 --- a/scanner/folder_entry.go +++ /dev/null @@ -1,151 +0,0 @@ -package scanner - -import ( - "crypto/md5" - "encoding/hex" - "fmt" - "io" - "io/fs" - "maps" - "slices" - "time" - - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/chrono" -) - -func newFolderEntry(job *scanJob, id, path string, info model.FolderUpdateInfo) *folderEntry { - f := &folderEntry{ - id: id, - job: job, - path: path, - audioFiles: make(map[string]fs.DirEntry), - imageFiles: make(map[string]fs.DirEntry), - playlistFiles: make(map[string]fs.DirEntry), - albumIDMap: make(map[string]string), - updTime: info.UpdatedAt, - prevHash: info.Hash, - prevImageFiles: info.ImageFiles, - prevImagesUpdatedAt: info.ImagesUpdatedAt, - } - return f -} - -type folderEntry struct { - job *scanJob - elapsed chrono.Meter - path string // Full path - id string // DB ID - modTime time.Time // From FS - updTime time.Time // from DB - audioFiles map[string]fs.DirEntry - imageFiles map[string]fs.DirEntry - playlistFiles map[string]fs.DirEntry - numSubFolders int - imagesUpdatedAt time.Time - prevHash string // Previous hash from DB - // Previous image state from DB, to detect image-only changes - prevImageFiles []string - prevImagesUpdatedAt time.Time - tracks model.MediaFiles - albums model.Albums - albumIDMap map[string]string - artists model.Artists - tags model.TagList - missingTracks []*model.MediaFile -} - -func (f *folderEntry) hasNoFiles() bool { - return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && len(f.playlistFiles) == 0 -} - -func (f *folderEntry) isEmpty() bool { - return f.hasNoFiles() && f.numSubFolders == 0 -} - -func (f *folderEntry) isNew() bool { - return f.updTime.IsZero() -} - -func (f *folderEntry) isOutdated() bool { - if f.job.lib.FullScanInProgress && f.updTime.Before(f.job.lib.LastScanStartedAt) { - return true - } - return f.prevHash != f.hash() -} - -// imagesChanged reports whether the folder's image files differ from the previously persisted -// state, and whether an artist image is involved (present in the old or the new list). -func (f *folderEntry) imagesChanged() (changed, artistImage bool) { - newNames := slices.Sorted(maps.Keys(f.imageFiles)) - prevNames := slices.Sorted(slices.Values(f.prevImageFiles)) - // Both empty also skips the timestamp check, which is noise for image-less folders. - if len(prevNames) == 0 && len(newNames) == 0 { - return false, false - } - if slices.Equal(prevNames, newNames) && f.prevImagesUpdatedAt.Equal(f.imagesUpdatedAt) { - return false, false - } - return true, slices.ContainsFunc(slices.Concat(prevNames, newNames), artwork.IsArtistImageFile) -} - -func (f *folderEntry) toFolder() *model.Folder { - folder := model.NewFolder(f.job.lib, f.path) - folder.NumAudioFiles = len(f.audioFiles) - if playlists.InPath(*folder) { - folder.NumPlaylists = len(f.playlistFiles) - } - folder.ImageFiles = slices.Collect(maps.Keys(f.imageFiles)) - folder.ImagesUpdatedAt = f.imagesUpdatedAt - folder.Hash = f.hash() - return folder -} - -func (f *folderEntry) hash() string { - h := md5.New() - _, _ = fmt.Fprintf( - h, - "%s:%d:%d:%s", - f.modTime.UTC(), - len(f.playlistFiles), // redundant with the loop below, but dropping it re-hashes every folder - f.numSubFolders, - f.imagesUpdatedAt.UTC(), - ) - - // Sort the keys of audio, image and playlist files to ensure consistent hashing - audioKeys := slices.Collect(maps.Keys(f.audioFiles)) - slices.Sort(audioKeys) - imageKeys := slices.Collect(maps.Keys(f.imageFiles)) - slices.Sort(imageKeys) - playlistKeys := slices.Collect(maps.Keys(f.playlistFiles)) - slices.Sort(playlistKeys) - - // Include audio files with their size and modtime - for _, key := range audioKeys { - _, _ = io.WriteString(h, key) - if info, err := f.audioFiles[key].Info(); err == nil { - _, _ = fmt.Fprintf(h, ":%d:%s", info.Size(), info.ModTime().UTC().String()) - } - } - - // Include image files with their size and modtime - for _, key := range imageKeys { - _, _ = io.WriteString(h, key) - if info, err := f.imageFiles[key].Info(); err == nil { - _, _ = fmt.Fprintf(h, ":%d:%s", info.Size(), info.ModTime().UTC().String()) - } - } - - // Include playlist files, so a content edit is detected even when the folder's - // mtime is preserved (rsync -a) or the playlist is not the newest file. - for _, key := range playlistKeys { - _, _ = io.WriteString(h, key) - if info, err := f.playlistFiles[key].Info(); err == nil { - _, _ = fmt.Fprintf(h, ":%d:%s", info.Size(), info.ModTime().UTC().String()) - } - } - - return hex.EncodeToString(h.Sum(nil)) -} diff --git a/scanner/folder_entry_test.go b/scanner/folder_entry_test.go deleted file mode 100644 index 4493f9309..000000000 --- a/scanner/folder_entry_test.go +++ /dev/null @@ -1,607 +0,0 @@ -package scanner - -import ( - "io/fs" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("folder_entry", func() { - var ( - lib model.Library - job *scanJob - path string - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - lib = model.Library{ - ID: 500, - Path: "/music", - LastScanStartedAt: time.Now().Add(-1 * time.Hour), - FullScanInProgress: false, - } - job = &scanJob{ - lib: lib, - lastUpdates: make(map[string]model.FolderUpdateInfo), - } - path = "test/folder" - }) - - Describe("newFolderEntry", func() { - It("creates a new folder entry with correct initialization", func() { - folderID := model.FolderID(lib, path) - updateInfo := model.FolderUpdateInfo{ - UpdatedAt: time.Now().Add(-30 * time.Minute), - Hash: "previous-hash", - } - - entry := newFolderEntry(job, folderID, path, updateInfo) - - Expect(entry.id).To(Equal(folderID)) - Expect(entry.job).To(Equal(job)) - Expect(entry.path).To(Equal(path)) - Expect(entry.audioFiles).To(BeEmpty()) - Expect(entry.imageFiles).To(BeEmpty()) - Expect(entry.playlistFiles).To(BeEmpty()) - Expect(entry.albumIDMap).To(BeEmpty()) - Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt)) - Expect(entry.prevHash).To(Equal(updateInfo.Hash)) - }) - }) - - Describe("createFolderEntry", func() { - It("removes the lastUpdate from the job after creation", func() { - folderID := model.FolderID(lib, path) - updateInfo := model.FolderUpdateInfo{ - UpdatedAt: time.Now().Add(-30 * time.Minute), - Hash: "previous-hash", - } - job.lastUpdates[folderID] = updateInfo - - entry := job.createFolderEntry(path) - - Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt)) - Expect(entry.prevHash).To(Equal(updateInfo.Hash)) - Expect(job.lastUpdates).ToNot(HaveKey(folderID)) - }) - }) - - Describe("folderEntry", func() { - var entry *folderEntry - - BeforeEach(func() { - folderID := model.FolderID(lib, path) - entry = newFolderEntry(job, folderID, path, model.FolderUpdateInfo{}) - }) - - Describe("hasNoFiles", func() { - It("returns true when folder has no files or subfolders", func() { - Expect(entry.hasNoFiles()).To(BeTrue()) - }) - - It("returns false when folder has audio files", func() { - entry.audioFiles["test.mp3"] = &fakeDirEntry{name: "test.mp3"} - Expect(entry.hasNoFiles()).To(BeFalse()) - }) - - It("returns false when folder has image files", func() { - entry.imageFiles["cover.jpg"] = &fakeDirEntry{name: "cover.jpg"} - Expect(entry.hasNoFiles()).To(BeFalse()) - }) - - It("returns false when folder has playlists", func() { - entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} - Expect(entry.hasNoFiles()).To(BeFalse()) - }) - - It("ignores subfolders when checking for no files", func() { - entry.numSubFolders = 1 - Expect(entry.hasNoFiles()).To(BeTrue()) - }) - - It("returns false when folder has multiple types of content", func() { - entry.audioFiles["test.mp3"] = &fakeDirEntry{name: "test.mp3"} - entry.imageFiles["cover.jpg"] = &fakeDirEntry{name: "cover.jpg"} - entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} - entry.numSubFolders = 3 - Expect(entry.hasNoFiles()).To(BeFalse()) - }) - }) - - Describe("isEmpty", func() { - It("returns true when folder has no files or subfolders", func() { - Expect(entry.isEmpty()).To(BeTrue()) - }) - It("returns false when folder has audio files", func() { - entry.audioFiles["test.mp3"] = &fakeDirEntry{name: "test.mp3"} - Expect(entry.isEmpty()).To(BeFalse()) - }) - It("returns false when folder has subfolders", func() { - entry.numSubFolders = 1 - Expect(entry.isEmpty()).To(BeFalse()) - }) - }) - - Describe("isNew", func() { - It("returns true when updTime is zero", func() { - entry.updTime = time.Time{} - Expect(entry.isNew()).To(BeTrue()) - }) - - It("returns false when updTime is not zero", func() { - entry.updTime = time.Now() - Expect(entry.isNew()).To(BeFalse()) - }) - }) - - Describe("toFolder", func() { - BeforeEach(func() { - entry.audioFiles = map[string]fs.DirEntry{ - "song1.mp3": &fakeDirEntry{name: "song1.mp3"}, - "song2.mp3": &fakeDirEntry{name: "song2.mp3"}, - } - entry.imageFiles = map[string]fs.DirEntry{ - "cover.jpg": &fakeDirEntry{name: "cover.jpg"}, - "folder.png": &fakeDirEntry{name: "folder.png"}, - } - entry.playlistFiles = map[string]fs.DirEntry{ - "list1.m3u": &fakeDirEntry{name: "list1.m3u"}, - "list2.m3u": &fakeDirEntry{name: "list2.m3u"}, - "list3.m3u": &fakeDirEntry{name: "list3.m3u"}, - } - entry.imagesUpdatedAt = time.Now() - }) - - It("converts folder entry to model.Folder correctly", func() { - folder := entry.toFolder() - - Expect(folder.LibraryID).To(Equal(lib.ID)) - Expect(folder.ID).To(Equal(entry.id)) - Expect(folder.NumAudioFiles).To(Equal(2)) - Expect(folder.ImageFiles).To(ConsistOf("cover.jpg", "folder.png")) - Expect(folder.ImagesUpdatedAt).To(Equal(entry.imagesUpdatedAt)) - Expect(folder.Hash).To(Equal(entry.hash())) - }) - - It("sets NumPlaylists when folder is in playlists path", func() { - // Mock InPlaylistsPath to return true by setting empty PlaylistsPath - originalPath := conf.Server.PlaylistsPath - conf.Server.PlaylistsPath = "" - DeferCleanup(func() { conf.Server.PlaylistsPath = originalPath }) - - folder := entry.toFolder() - Expect(folder.NumPlaylists).To(Equal(3)) - }) - - It("does not set NumPlaylists when folder is not in playlists path", func() { - // Mock InPlaylistsPath to return false by setting a different path - originalPath := conf.Server.PlaylistsPath - conf.Server.PlaylistsPath = "different/path" - DeferCleanup(func() { conf.Server.PlaylistsPath = originalPath }) - - folder := entry.toFolder() - Expect(folder.NumPlaylists).To(BeZero()) - }) - }) - - Describe("hash", func() { - BeforeEach(func() { - entry.modTime = time.Date(2023, 1, 15, 12, 0, 0, 0, time.UTC) - entry.imagesUpdatedAt = time.Date(2023, 1, 16, 14, 30, 0, 0, time.UTC) - }) - - It("produces deterministic hash for same content", func() { - entry.audioFiles = map[string]fs.DirEntry{ - "b.mp3": &fakeDirEntry{name: "b.mp3"}, - "a.mp3": &fakeDirEntry{name: "a.mp3"}, - } - entry.imageFiles = map[string]fs.DirEntry{ - "z.jpg": &fakeDirEntry{name: "z.jpg"}, - "x.png": &fakeDirEntry{name: "x.png"}, - } - entry.playlistFiles = map[string]fs.DirEntry{ - "q.m3u": &fakeDirEntry{name: "q.m3u"}, - "p.m3u": &fakeDirEntry{name: "p.m3u"}, - } - entry.numSubFolders = 3 - - hash1 := entry.hash() - - // Reverse order of maps - entry.audioFiles = map[string]fs.DirEntry{ - "a.mp3": &fakeDirEntry{name: "a.mp3"}, - "b.mp3": &fakeDirEntry{name: "b.mp3"}, - } - entry.imageFiles = map[string]fs.DirEntry{ - "x.png": &fakeDirEntry{name: "x.png"}, - "z.jpg": &fakeDirEntry{name: "z.jpg"}, - } - entry.playlistFiles = map[string]fs.DirEntry{ - "p.m3u": &fakeDirEntry{name: "p.m3u"}, - "q.m3u": &fakeDirEntry{name: "q.m3u"}, - } - - hash2 := entry.hash() - Expect(hash1).To(Equal(hash2)) - }) - - It("produces different hash when audio files change", func() { - entry.audioFiles = map[string]fs.DirEntry{ - "song1.mp3": &fakeDirEntry{name: "song1.mp3"}, - } - hash1 := entry.hash() - - entry.audioFiles["song2.mp3"] = &fakeDirEntry{name: "song2.mp3"} - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - - It("produces different hash when image files change", func() { - entry.imageFiles = map[string]fs.DirEntry{ - "cover.jpg": &fakeDirEntry{name: "cover.jpg"}, - } - hash1 := entry.hash() - - entry.imageFiles["folder.png"] = &fakeDirEntry{name: "folder.png"} - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - - It("produces different hash when modification time changes", func() { - hash1 := entry.hash() - - entry.modTime = entry.modTime.Add(1 * time.Hour) - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - - It("produces different hash when playlist files change", func() { - hash1 := entry.hash() - - entry.playlistFiles["new.m3u"] = &fakeDirEntry{name: "new.m3u"} - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - - It("produces different hash when subfolder count changes", func() { - hash1 := entry.hash() - - entry.numSubFolders = 3 - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - - It("produces different hash when images updated time changes", func() { - hash1 := entry.hash() - - entry.imagesUpdatedAt = entry.imagesUpdatedAt.Add(2 * time.Hour) - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - - It("produces different hash when audio file size changes", func() { - entry.audioFiles["test.mp3"] = &fakeDirEntry{ - name: "test.mp3", - fileInfo: &fakeFileInfo{ - name: "test.mp3", - size: 1000, - modTime: time.Now(), - }, - } - hash1 := entry.hash() - - entry.audioFiles["test.mp3"] = &fakeDirEntry{ - name: "test.mp3", - fileInfo: &fakeFileInfo{ - name: "test.mp3", - size: 2000, // Different size - modTime: time.Now(), - }, - } - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - - It("produces different hash when audio file modification time changes", func() { - baseTime := time.Now() - entry.audioFiles["test.mp3"] = &fakeDirEntry{ - name: "test.mp3", - fileInfo: &fakeFileInfo{ - name: "test.mp3", - size: 1000, - modTime: baseTime, - }, - } - hash1 := entry.hash() - - entry.audioFiles["test.mp3"] = &fakeDirEntry{ - name: "test.mp3", - fileInfo: &fakeFileInfo{ - name: "test.mp3", - size: 1000, - modTime: baseTime.Add(1 * time.Hour), // Different modtime - }, - } - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - - It("produces different hash when image file size changes", func() { - entry.imageFiles["cover.jpg"] = &fakeDirEntry{ - name: "cover.jpg", - fileInfo: &fakeFileInfo{ - name: "cover.jpg", - size: 5000, - modTime: time.Now(), - }, - } - hash1 := entry.hash() - - entry.imageFiles["cover.jpg"] = &fakeDirEntry{ - name: "cover.jpg", - fileInfo: &fakeFileInfo{ - name: "cover.jpg", - size: 6000, // Different size - modTime: time.Now(), - }, - } - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - - It("produces different hash when image file modification time changes", func() { - baseTime := time.Now() - entry.imageFiles["cover.jpg"] = &fakeDirEntry{ - name: "cover.jpg", - fileInfo: &fakeFileInfo{ - name: "cover.jpg", - size: 5000, - modTime: baseTime, - }, - } - hash1 := entry.hash() - - entry.imageFiles["cover.jpg"] = &fakeDirEntry{ - name: "cover.jpg", - fileInfo: &fakeFileInfo{ - name: "cover.jpg", - size: 5000, - modTime: baseTime.Add(1 * time.Hour), // Different modtime - }, - } - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - - It("produces different hash when playlist file size changes", func() { - baseTime := time.Now() - entry.playlistFiles["list.m3u"] = &fakeDirEntry{ - name: "list.m3u", - fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime}, - } - hash1 := entry.hash() - - entry.playlistFiles["list.m3u"] = &fakeDirEntry{ - name: "list.m3u", - fileInfo: &fakeFileInfo{name: "list.m3u", size: 2000, modTime: baseTime}, - } - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - - It("produces different hash when playlist file modification time changes", func() { - baseTime := time.Now() - entry.playlistFiles["list.m3u"] = &fakeDirEntry{ - name: "list.m3u", - fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime}, - } - hash1 := entry.hash() - - entry.playlistFiles["list.m3u"] = &fakeDirEntry{ - name: "list.m3u", - fileInfo: &fakeFileInfo{name: "list.m3u", size: 1000, modTime: baseTime.Add(1 * time.Hour)}, - } - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - - It("produces different hash when a playlist is renamed", func() { - baseTime := time.Now() - entry.playlistFiles["old.m3u"] = &fakeDirEntry{ - name: "old.m3u", - fileInfo: &fakeFileInfo{name: "old.m3u", size: 1000, modTime: baseTime}, - } - hash1 := entry.hash() - - delete(entry.playlistFiles, "old.m3u") - entry.playlistFiles["new.m3u"] = &fakeDirEntry{ - name: "new.m3u", - fileInfo: &fakeFileInfo{name: "new.m3u", size: 1000, modTime: baseTime}, - } - hash2 := entry.hash() - - Expect(hash1).ToNot(Equal(hash2)) - }) - - It("produces valid hex-encoded hash", func() { - hash := entry.hash() - Expect(hash).To(HaveLen(32)) // MD5 hash should be 32 hex characters - Expect(hash).To(MatchRegexp("^[a-f0-9]{32}$")) - }) - }) - - Describe("isOutdated", func() { - BeforeEach(func() { - entry.prevHash = entry.hash() - }) - - Context("when full scan is in progress", func() { - BeforeEach(func() { - entry.job.lib.FullScanInProgress = true - entry.job.lib.LastScanStartedAt = time.Now() - }) - - It("returns true when updTime is before LastScanStartedAt", func() { - entry.updTime = entry.job.lib.LastScanStartedAt.Add(-1 * time.Hour) - Expect(entry.isOutdated()).To(BeTrue()) - }) - - It("returns false when updTime is after LastScanStartedAt", func() { - entry.updTime = entry.job.lib.LastScanStartedAt.Add(1 * time.Hour) - Expect(entry.isOutdated()).To(BeFalse()) - }) - - It("returns false when updTime equals LastScanStartedAt", func() { - entry.updTime = entry.job.lib.LastScanStartedAt - Expect(entry.isOutdated()).To(BeFalse()) - }) - }) - - Context("when full scan is not in progress", func() { - BeforeEach(func() { - entry.job.lib.FullScanInProgress = false - }) - - It("returns false when hash hasn't changed", func() { - Expect(entry.isOutdated()).To(BeFalse()) - }) - - It("returns true when hash has changed", func() { - entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} // Change something to change the hash - Expect(entry.isOutdated()).To(BeTrue()) - }) - - It("returns true when prevHash is empty", func() { - entry.prevHash = "" - Expect(entry.isOutdated()).To(BeTrue()) - }) - }) - - Context("priority between conditions", func() { - BeforeEach(func() { - entry.job.lib.FullScanInProgress = true - entry.job.lib.LastScanStartedAt = time.Now() - entry.updTime = entry.job.lib.LastScanStartedAt.Add(-1 * time.Hour) - }) - - It("returns true for full scan condition even when hash hasn't changed", func() { - // Hash is the same but full scan condition should take priority - Expect(entry.isOutdated()).To(BeTrue()) - }) - - It("returns true when full scan condition is not met but hash changed", func() { - entry.updTime = entry.job.lib.LastScanStartedAt.Add(1 * time.Hour) - entry.playlistFiles["list.m3u"] = &fakeDirEntry{name: "list.m3u"} // Change hash - Expect(entry.isOutdated()).To(BeTrue()) - }) - }) - }) - }) - - Describe("integration scenarios", func() { - It("handles complete folder lifecycle", func() { - // Create new folder entry - folderPath := "music/rock/album" - folderID := model.FolderID(lib, folderPath) - entry := newFolderEntry(job, folderID, folderPath, model.FolderUpdateInfo{}) - - // Initially new and has no files - Expect(entry.isNew()).To(BeTrue()) - Expect(entry.hasNoFiles()).To(BeTrue()) - - // Add some files - entry.audioFiles["track1.mp3"] = &fakeDirEntry{name: "track1.mp3"} - entry.audioFiles["track2.mp3"] = &fakeDirEntry{name: "track2.mp3"} - entry.imageFiles["cover.jpg"] = &fakeDirEntry{name: "cover.jpg"} - entry.numSubFolders = 1 - entry.modTime = time.Now() - entry.imagesUpdatedAt = time.Now() - - // No longer empty - Expect(entry.hasNoFiles()).To(BeFalse()) - - // Set previous hash to current hash (simulating it's been saved) - entry.prevHash = entry.hash() - entry.updTime = time.Now() - - // Should not be new or outdated - Expect(entry.isNew()).To(BeFalse()) - Expect(entry.isOutdated()).To(BeFalse()) - - // Convert to model folder - folder := entry.toFolder() - Expect(folder.NumAudioFiles).To(Equal(2)) - Expect(folder.ImageFiles).To(HaveLen(1)) - Expect(folder.Hash).To(Equal(entry.hash())) - - // Modify folder and verify it becomes outdated - entry.audioFiles["track3.mp3"] = &fakeDirEntry{name: "track3.mp3"} - Expect(entry.isOutdated()).To(BeTrue()) - }) - }) -}) - -// fakeDirEntry implements fs.DirEntry for testing -type fakeDirEntry struct { - name string - isDir bool - typ fs.FileMode - fileInfo fs.FileInfo -} - -func (f *fakeDirEntry) Name() string { - return f.name -} - -func (f *fakeDirEntry) IsDir() bool { - return f.isDir -} - -func (f *fakeDirEntry) Type() fs.FileMode { - return f.typ -} - -func (f *fakeDirEntry) Info() (fs.FileInfo, error) { - if f.fileInfo != nil { - return f.fileInfo, nil - } - return &fakeFileInfo{ - name: f.name, - isDir: f.isDir, - mode: f.typ, - }, nil -} - -// fakeFileInfo implements fs.FileInfo for testing -type fakeFileInfo struct { - name string - size int64 - mode fs.FileMode - modTime time.Time - isDir bool -} - -func (f *fakeFileInfo) Name() string { return f.name } -func (f *fakeFileInfo) Size() int64 { return f.size } -func (f *fakeFileInfo) Mode() fs.FileMode { return f.mode } -func (f *fakeFileInfo) ModTime() time.Time { return f.modTime } -func (f *fakeFileInfo) IsDir() bool { return f.isDir } -func (f *fakeFileInfo) Sys() any { return nil } diff --git a/scanner/ignore_checker.go b/scanner/ignore_checker.go deleted file mode 100644 index f0aedb079..000000000 --- a/scanner/ignore_checker.go +++ /dev/null @@ -1,163 +0,0 @@ -package scanner - -import ( - "bufio" - "context" - "io/fs" - "path" - "strings" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - ignore "github.com/sabhiram/go-gitignore" -) - -// IgnoreChecker manages .ndignore patterns using a stack-based approach. -// Use Push() to add patterns when entering a folder, Pop() when leaving, -// and ShouldIgnore() to check if a path should be ignored. -type IgnoreChecker struct { - fsys fs.FS - patternStack [][]string // Stack of patterns for each folder level - currentPatterns []string // Flattened current patterns - matcher *ignore.GitIgnore // Compiled matcher for current patterns -} - -// newIgnoreChecker creates a new IgnoreChecker for the given filesystem. -func newIgnoreChecker(fsys fs.FS) *IgnoreChecker { - return &IgnoreChecker{ - fsys: fsys, - patternStack: make([][]string, 0), - } -} - -// Push loads .ndignore patterns from the specified folder and adds them to the pattern stack. -// Use this when entering a folder during directory tree traversal. -func (ic *IgnoreChecker) Push(ctx context.Context, folder string) error { - patterns := ic.loadPatternsFromFolder(ctx, folder) - ic.patternStack = append(ic.patternStack, patterns) - ic.rebuildCurrentPatterns() - return nil -} - -// Pop removes the most recent patterns from the stack. -// Use this when leaving a folder during directory tree traversal. -func (ic *IgnoreChecker) Pop() { - if len(ic.patternStack) > 0 { - ic.patternStack = ic.patternStack[:len(ic.patternStack)-1] - ic.rebuildCurrentPatterns() - } -} - -// PushAllParents pushes patterns from root down to the target path. -// This is a convenience method for when you need to check a specific path -// without recursively walking the tree. It handles the common pattern of -// pushing all parent directories from root to the target. -// This method is optimized to compile patterns only once at the end. -func (ic *IgnoreChecker) PushAllParents(ctx context.Context, targetPath string) error { - if targetPath == "." || targetPath == "" { - // Simple case: just push root - return ic.Push(ctx, ".") - } - - // Load patterns for root - patterns := ic.loadPatternsFromFolder(ctx, ".") - ic.patternStack = append(ic.patternStack, patterns) - - // Load patterns for each parent directory - currentPath := "." - parts := strings.SplitSeq(path.Clean(targetPath), "/") - for part := range parts { - if part == "." || part == "" { - continue - } - currentPath = path.Join(currentPath, part) - patterns = ic.loadPatternsFromFolder(ctx, currentPath) - ic.patternStack = append(ic.patternStack, patterns) - } - - // Rebuild and compile patterns only once at the end - ic.rebuildCurrentPatterns() - return nil -} - -// ShouldIgnore checks if the given path should be ignored based on the current patterns. -// Returns true if the path matches any ignore pattern, false otherwise. -func (ic *IgnoreChecker) ShouldIgnore(ctx context.Context, relPath string) bool { - // Handle root/empty path - never ignore - if relPath == "" || relPath == "." { - return false - } - - // If no patterns loaded, nothing to ignore - if ic.matcher == nil { - return false - } - - matches := ic.matcher.MatchesPath(relPath) - if matches { - log.Trace(ctx, "Scanner: Ignoring entry matching .ndignore", "path", relPath) - } - return matches -} - -// loadPatternsFromFolder reads the .ndignore file in the specified folder and returns the patterns. -// If the file doesn't exist, returns an empty slice. -// If the file exists but is empty, returns a pattern to ignore everything ("**/*"). -func (ic *IgnoreChecker) loadPatternsFromFolder(ctx context.Context, folder string) []string { - ignoreFilePath := path.Join(folder, consts.ScanIgnoreFile) - var patterns []string - - // Check if .ndignore file exists - if _, err := fs.Stat(ic.fsys, ignoreFilePath); err != nil { - // No .ndignore file in this folder - return patterns - } - - // Read and parse the .ndignore file - ignoreFile, err := ic.fsys.Open(ignoreFilePath) - if err != nil { - log.Warn(ctx, "Scanner: Error opening .ndignore file", "path", ignoreFilePath, err) - return patterns - } - defer ignoreFile.Close() - - lineScanner := bufio.NewScanner(ignoreFile) - for lineScanner.Scan() { - line := strings.TrimSpace(lineScanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue // Skip empty lines, whitespace-only lines, and comments - } - patterns = append(patterns, line) - } - - if err := lineScanner.Err(); err != nil { - log.Warn(ctx, "Scanner: Error reading .ndignore file", "path", ignoreFilePath, err) - return patterns - } - - // If the .ndignore file is empty, ignore everything - if len(patterns) == 0 { - log.Trace(ctx, "Scanner: .ndignore file is empty, ignoring everything", "path", folder) - patterns = []string{"**/*"} - } - - return patterns -} - -// rebuildCurrentPatterns flattens the pattern stack into currentPatterns and recompiles the matcher. -func (ic *IgnoreChecker) rebuildCurrentPatterns() { - ic.currentPatterns = make([]string, 0) - for _, patterns := range ic.patternStack { - ic.currentPatterns = append(ic.currentPatterns, patterns...) - } - ic.compilePatterns() -} - -// compilePatterns compiles the current patterns into a GitIgnore matcher. -func (ic *IgnoreChecker) compilePatterns() { - if len(ic.currentPatterns) == 0 { - ic.matcher = nil - return - } - ic.matcher = ignore.CompileIgnoreLines(ic.currentPatterns...) -} diff --git a/scanner/ignore_checker_test.go b/scanner/ignore_checker_test.go deleted file mode 100644 index 5378ed4fa..000000000 --- a/scanner/ignore_checker_test.go +++ /dev/null @@ -1,313 +0,0 @@ -package scanner - -import ( - "context" - "testing/fstest" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("IgnoreChecker", func() { - Describe("loadPatternsFromFolder", func() { - var ic *IgnoreChecker - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - }) - - Context("when .ndignore file does not exist", func() { - It("should return empty patterns", func() { - fsys := fstest.MapFS{} - ic = newIgnoreChecker(fsys) - patterns := ic.loadPatternsFromFolder(ctx, ".") - Expect(patterns).To(BeEmpty()) - }) - }) - - Context("when .ndignore file is empty", func() { - It("should return wildcard to ignore everything", func() { - fsys := fstest.MapFS{ - ".ndignore": &fstest.MapFile{Data: []byte("")}, - } - ic = newIgnoreChecker(fsys) - patterns := ic.loadPatternsFromFolder(ctx, ".") - Expect(patterns).To(Equal([]string{"**/*"})) - }) - }) - - DescribeTable("parsing .ndignore content", - func(content string, expectedPatterns []string) { - fsys := fstest.MapFS{ - ".ndignore": &fstest.MapFile{Data: []byte(content)}, - } - ic = newIgnoreChecker(fsys) - patterns := ic.loadPatternsFromFolder(ctx, ".") - Expect(patterns).To(Equal(expectedPatterns)) - }, - Entry("single pattern", "*.txt", []string{"*.txt"}), - Entry("multiple patterns", "*.txt\n*.log", []string{"*.txt", "*.log"}), - Entry("with comments", "# comment\n*.txt\n# another\n*.log", []string{"*.txt", "*.log"}), - Entry("with empty lines", "*.txt\n\n*.log\n\n", []string{"*.txt", "*.log"}), - Entry("mixed content", "# header\n\n*.txt\n# middle\n*.log\n\n", []string{"*.txt", "*.log"}), - Entry("only comments and empty lines", "# comment\n\n# another\n", []string{"**/*"}), - Entry("trailing newline", "*.txt\n*.log\n", []string{"*.txt", "*.log"}), - Entry("directory pattern", "temp/", []string{"temp/"}), - Entry("wildcard pattern", "**/*.mp3", []string{"**/*.mp3"}), - Entry("multiple wildcards", "**/*.mp3\n**/*.flac\n*.log", []string{"**/*.mp3", "**/*.flac", "*.log"}), - Entry("negation pattern", "!important.txt", []string{"!important.txt"}), - Entry("comment with hash not at start is pattern", "not#comment", []string{"not#comment"}), - Entry("whitespace-only lines skipped", "*.txt\n \n*.log\n\t\n", []string{"*.txt", "*.log"}), - Entry("patterns with whitespace trimmed", " *.txt \n\t*.log\t", []string{"*.txt", "*.log"}), - ) - }) - - Describe("Push and Pop", func() { - var ic *IgnoreChecker - var fsys fstest.MapFS - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - fsys = fstest.MapFS{ - ".ndignore": &fstest.MapFile{Data: []byte("*.txt")}, - "folder1/.ndignore": &fstest.MapFile{Data: []byte("*.mp3")}, - "folder2/.ndignore": &fstest.MapFile{Data: []byte("*.flac")}, - } - ic = newIgnoreChecker(fsys) - }) - - Context("Push", func() { - It("should add patterns to stack", func() { - err := ic.Push(ctx, ".") - Expect(err).ToNot(HaveOccurred()) - Expect(len(ic.patternStack)).To(Equal(1)) - Expect(ic.currentPatterns).To(ContainElement("*.txt")) - }) - - It("should compile matcher after push", func() { - err := ic.Push(ctx, ".") - Expect(err).ToNot(HaveOccurred()) - Expect(ic.matcher).ToNot(BeNil()) - }) - - It("should accumulate patterns from multiple levels", func() { - err := ic.Push(ctx, ".") - Expect(err).ToNot(HaveOccurred()) - err = ic.Push(ctx, "folder1") - Expect(err).ToNot(HaveOccurred()) - Expect(len(ic.patternStack)).To(Equal(2)) - Expect(ic.currentPatterns).To(ConsistOf("*.txt", "*.mp3")) - }) - - It("should handle push when no .ndignore exists", func() { - err := ic.Push(ctx, "nonexistent") - Expect(err).ToNot(HaveOccurred()) - Expect(len(ic.patternStack)).To(Equal(1)) - Expect(ic.currentPatterns).To(BeEmpty()) - }) - }) - - Context("Pop", func() { - It("should remove most recent patterns", func() { - err := ic.Push(ctx, ".") - Expect(err).ToNot(HaveOccurred()) - err = ic.Push(ctx, "folder1") - Expect(err).ToNot(HaveOccurred()) - ic.Pop() - Expect(len(ic.patternStack)).To(Equal(1)) - Expect(ic.currentPatterns).To(Equal([]string{"*.txt"})) - }) - - It("should handle Pop on empty stack gracefully", func() { - Expect(func() { ic.Pop() }).ToNot(Panic()) - Expect(ic.patternStack).To(BeEmpty()) - }) - - It("should set matcher to nil when all patterns popped", func() { - err := ic.Push(ctx, ".") - Expect(err).ToNot(HaveOccurred()) - Expect(ic.matcher).ToNot(BeNil()) - ic.Pop() - Expect(ic.matcher).To(BeNil()) - }) - - It("should update matcher after pop", func() { - err := ic.Push(ctx, ".") - Expect(err).ToNot(HaveOccurred()) - err = ic.Push(ctx, "folder1") - Expect(err).ToNot(HaveOccurred()) - matcher1 := ic.matcher - ic.Pop() - matcher2 := ic.matcher - Expect(matcher1).ToNot(Equal(matcher2)) - }) - }) - - Context("multiple Push/Pop cycles", func() { - It("should maintain correct state through cycles", func() { - err := ic.Push(ctx, ".") - Expect(err).ToNot(HaveOccurred()) - Expect(ic.currentPatterns).To(Equal([]string{"*.txt"})) - - err = ic.Push(ctx, "folder1") - Expect(err).ToNot(HaveOccurred()) - Expect(ic.currentPatterns).To(ConsistOf("*.txt", "*.mp3")) - - ic.Pop() - Expect(ic.currentPatterns).To(Equal([]string{"*.txt"})) - - err = ic.Push(ctx, "folder2") - Expect(err).ToNot(HaveOccurred()) - Expect(ic.currentPatterns).To(ConsistOf("*.txt", "*.flac")) - - ic.Pop() - Expect(ic.currentPatterns).To(Equal([]string{"*.txt"})) - - ic.Pop() - Expect(ic.currentPatterns).To(BeEmpty()) - }) - }) - }) - - Describe("PushAllParents", func() { - var ic *IgnoreChecker - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - fsys := fstest.MapFS{ - ".ndignore": &fstest.MapFile{Data: []byte("root.txt")}, - "folder1/.ndignore": &fstest.MapFile{Data: []byte("level1.txt")}, - "folder1/folder2/.ndignore": &fstest.MapFile{Data: []byte("level2.txt")}, - "folder1/folder2/folder3/.ndignore": &fstest.MapFile{Data: []byte("level3.txt")}, - } - ic = newIgnoreChecker(fsys) - }) - - DescribeTable("loading parent patterns", - func(targetPath string, expectedStackDepth int, expectedPatterns []string) { - err := ic.PushAllParents(ctx, targetPath) - Expect(err).ToNot(HaveOccurred()) - Expect(len(ic.patternStack)).To(Equal(expectedStackDepth)) - Expect(ic.currentPatterns).To(ConsistOf(expectedPatterns)) - }, - Entry("root path", ".", 1, []string{"root.txt"}), - Entry("empty path", "", 1, []string{"root.txt"}), - Entry("single level", "folder1", 2, []string{"root.txt", "level1.txt"}), - Entry("two levels", "folder1/folder2", 3, []string{"root.txt", "level1.txt", "level2.txt"}), - Entry("three levels", "folder1/folder2/folder3", 4, []string{"root.txt", "level1.txt", "level2.txt", "level3.txt"}), - ) - - It("should only compile patterns once at the end", func() { - // This is more of a behavioral test - we verify the matcher is not nil after PushAllParents - err := ic.PushAllParents(ctx, "folder1/folder2") - Expect(err).ToNot(HaveOccurred()) - Expect(ic.matcher).ToNot(BeNil()) - }) - - It("should handle paths with dot", func() { - err := ic.PushAllParents(ctx, "./folder1") - Expect(err).ToNot(HaveOccurred()) - Expect(len(ic.patternStack)).To(Equal(2)) - }) - - Context("when some parent folders have no .ndignore", func() { - BeforeEach(func() { - fsys := fstest.MapFS{ - ".ndignore": &fstest.MapFile{Data: []byte("root.txt")}, - "folder1/folder2/.ndignore": &fstest.MapFile{Data: []byte("level2.txt")}, - } - ic = newIgnoreChecker(fsys) - }) - - It("should still push all parent levels", func() { - err := ic.PushAllParents(ctx, "folder1/folder2") - Expect(err).ToNot(HaveOccurred()) - Expect(len(ic.patternStack)).To(Equal(3)) // root, folder1 (empty), folder2 - Expect(ic.currentPatterns).To(ConsistOf("root.txt", "level2.txt")) - }) - }) - }) - - Describe("ShouldIgnore", func() { - var ic *IgnoreChecker - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - }) - - Context("with no patterns loaded", func() { - It("should not ignore any path", func() { - fsys := fstest.MapFS{} - ic = newIgnoreChecker(fsys) - Expect(ic.ShouldIgnore(ctx, "anything.txt")).To(BeFalse()) - Expect(ic.ShouldIgnore(ctx, "folder/file.mp3")).To(BeFalse()) - }) - }) - - Context("special paths", func() { - BeforeEach(func() { - fsys := fstest.MapFS{ - ".ndignore": &fstest.MapFile{Data: []byte("**/*")}, - } - ic = newIgnoreChecker(fsys) - err := ic.Push(ctx, ".") - Expect(err).ToNot(HaveOccurred()) - }) - - It("should never ignore root or empty paths", func() { - Expect(ic.ShouldIgnore(ctx, "")).To(BeFalse()) - Expect(ic.ShouldIgnore(ctx, ".")).To(BeFalse()) - }) - - It("should ignore all other paths with wildcard", func() { - Expect(ic.ShouldIgnore(ctx, "file.txt")).To(BeTrue()) - Expect(ic.ShouldIgnore(ctx, "folder/file.mp3")).To(BeTrue()) - }) - }) - - DescribeTable("pattern matching", - func(pattern string, path string, shouldMatch bool) { - fsys := fstest.MapFS{ - ".ndignore": &fstest.MapFile{Data: []byte(pattern)}, - } - ic = newIgnoreChecker(fsys) - err := ic.Push(ctx, ".") - Expect(err).ToNot(HaveOccurred()) - Expect(ic.ShouldIgnore(ctx, path)).To(Equal(shouldMatch)) - }, - Entry("glob match", "*.txt", "file.txt", true), - Entry("glob no match", "*.txt", "file.mp3", false), - Entry("directory pattern match", "tmp/", "tmp/file.txt", true), - Entry("directory pattern no match", "tmp/", "temporary/file.txt", false), - Entry("nested glob match", "**/*.log", "deep/nested/file.log", true), - Entry("nested glob no match", "**/*.log", "deep/nested/file.txt", false), - Entry("specific file match", "ignore.me", "ignore.me", true), - Entry("specific file no match", "ignore.me", "keep.me", false), - Entry("wildcard all", "**/*", "any/path/file.txt", true), - Entry("nested specific match", "temp/*", "temp/cache.db", true), - Entry("nested specific no match", "temp/*", "temporary/cache.db", false), - ) - - Context("with multiple patterns", func() { - BeforeEach(func() { - fsys := fstest.MapFS{ - ".ndignore": &fstest.MapFile{Data: []byte("*.txt\n*.log\ntemp/")}, - } - ic = newIgnoreChecker(fsys) - err := ic.Push(ctx, ".") - Expect(err).ToNot(HaveOccurred()) - }) - - It("should match any of the patterns", func() { - Expect(ic.ShouldIgnore(ctx, "file.txt")).To(BeTrue()) - Expect(ic.ShouldIgnore(ctx, "debug.log")).To(BeTrue()) - Expect(ic.ShouldIgnore(ctx, "temp/cache")).To(BeTrue()) - Expect(ic.ShouldIgnore(ctx, "music.mp3")).To(BeFalse()) - }) - }) - }) -}) diff --git a/scanner/image_changes.go b/scanner/image_changes.go deleted file mode 100644 index 3ea7ce57c..000000000 --- a/scanner/image_changes.go +++ /dev/null @@ -1,112 +0,0 @@ -package scanner - -import ( - "context" - "sync" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -// imageChangedFolder records a folder whose image files changed during the scan, so the -// affected albums/artists can be re-enqueued for artwork resolution at the end of phase 1. -type imageChangedFolder struct { - id string - path string - artistImage bool -} - -// imageChangeCollector gathers those folders per library while phase 1 persists them, then turns -// them into artwork queue items once. -type imageChangeCollector struct { - libs map[int]model.Library - folders map[int][]imageChangedFolder - ds model.DataStore - mutex sync.Mutex -} - -func (c *imageChangeCollector) record(lib model.Library, folder imageChangedFolder) { - c.mutex.Lock() - defer c.mutex.Unlock() - if c.folders == nil { - c.folders = map[int][]imageChangedFolder{} - c.libs = map[int]model.Library{} - } - c.libs[lib.ID] = lib - c.folders[lib.ID] = append(c.folders[lib.ID], folder) -} - -// enqueue is best-effort: failures are logged and never fail the scan. -func (c *imageChangeCollector) enqueue(ctx context.Context) { - c.mutex.Lock() - foldersMap, libsMap := c.folders, c.libs - c.folders, c.libs = nil, nil - c.mutex.Unlock() - - for libID, folders := range foldersMap { - lib := libsMap[libID] - items, err := c.queueItems(ctx, lib, folders) - if err != nil { - log.Warn(ctx, "Scanner: could not map image changes to artwork items", "lib", lib.Name, err) - continue - } - if len(items) == 0 { - continue - } - if err := c.ds.ArtworkQueue().Enqueue(ctx, items...); err != nil { - log.Warn(ctx, "Scanner: could not enqueue artwork for image changes", "lib", lib.Name, err) - continue - } - log.Debug(ctx, "Scanner: Enqueued artwork resolution for image changes", "lib", lib.Name, - "changedFolders", len(folders), "items", len(items)) - } -} - -func (c *imageChangeCollector) queueItems(ctx context.Context, lib model.Library, - folders []imageChangedFolder, -) ([]model.ArtworkQueueItem, error) { - folderIDs := make([]string, len(folders)) - var artistFolderPaths []string - for i, f := range folders { - folderIDs[i] = f.id - if f.artistImage { - artistFolderPaths = append(artistFolderPaths, f.path) - } - } - - var items []model.ArtworkQueueItem - - albumIDs, err := c.ds.MediaFile().GetAlbumIDsByFolder(ctx, lib, folderIDs...) - if err != nil { - return nil, err - } - for _, id := range albumIDs { - items = append(items, scanArtworkItem(model.KindAlbumArtwork, id)) - } - - if len(artistFolderPaths) == 0 { - return items, nil - } - // The resolver climbs to the library root, so the subtree below the folder is the affected set. - // A failure here must not discard the album items already collected. - artistIDs, err := c.ds.Album().GetSoleAlbumArtistIDsInSubtrees(ctx, lib, artistFolderPaths...) - if err != nil { - log.Warn(ctx, "Scanner: could not map image changes to artists", "lib", lib.Name, err) - return items, nil - } - for _, id := range artistIDs { - if id == "" || id == consts.UnknownArtistID || id == consts.VariousArtistsID { - continue - } - items = append(items, scanArtworkItem(model.KindArtistArtwork, id)) - } - return items, nil -} - -func scanArtworkItem(kind model.Kind, id string) model.ArtworkQueueItem { - return model.ArtworkQueueItem{ - ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityScan, - } -} diff --git a/scanner/mapping.go b/scanner/mapping.go new file mode 100644 index 000000000..565211a32 --- /dev/null +++ b/scanner/mapping.go @@ -0,0 +1,191 @@ +package scanner + +import ( + "crypto/md5" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/deluan/sanitize" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/scanner/metadata" + "github.com/navidrome/navidrome/utils" +) + +type mediaFileMapper struct { + rootFolder string + genres model.GenreRepository +} + +func newMediaFileMapper(rootFolder string, genres model.GenreRepository) *mediaFileMapper { + return &mediaFileMapper{ + rootFolder: rootFolder, + genres: genres, + } +} + +// TODO Move most of these mapping functions to setters in the model.MediaFile +func (s mediaFileMapper) toMediaFile(md metadata.Tags) model.MediaFile { + mf := &model.MediaFile{} + mf.ID = s.trackID(md) + mf.Year, mf.Date, mf.OriginalYear, mf.OriginalDate, mf.ReleaseYear, mf.ReleaseDate = s.mapDates(md) + mf.Title = s.mapTrackTitle(md) + mf.Album = md.Album() + mf.AlbumID = s.albumID(md, mf.ReleaseDate) + mf.Album = s.mapAlbumName(md) + mf.ArtistID = s.artistID(md) + mf.Artist = s.mapArtistName(md) + mf.AlbumArtistID = s.albumArtistID(md) + mf.AlbumArtist = s.mapAlbumArtistName(md) + mf.Genre, mf.Genres = s.mapGenres(md.Genres()) + mf.Compilation = md.Compilation() + mf.TrackNumber, _ = md.TrackNumber() + mf.DiscNumber, _ = md.DiscNumber() + mf.DiscSubtitle = md.DiscSubtitle() + mf.Duration = md.Duration() + mf.BitRate = md.BitRate() + mf.Channels = md.Channels() + mf.Path = md.FilePath() + mf.Suffix = md.Suffix() + mf.Size = md.Size() + mf.HasCoverArt = md.HasPicture() + mf.SortTitle = md.SortTitle() + mf.SortAlbumName = md.SortAlbum() + mf.SortArtistName = md.SortArtist() + mf.SortAlbumArtistName = md.SortAlbumArtist() + mf.OrderTitle = strings.TrimSpace(sanitize.Accents(mf.Title)) + mf.OrderAlbumName = sanitizeFieldForSorting(mf.Album) + mf.OrderArtistName = sanitizeFieldForSorting(mf.Artist) + mf.OrderAlbumArtistName = sanitizeFieldForSorting(mf.AlbumArtist) + mf.CatalogNum = md.CatalogNum() + mf.MbzTrackID = md.MbzTrackID() + mf.MbzReleaseTrackID = md.MbzReleaseTrackID() + mf.MbzAlbumID = md.MbzAlbumID() + mf.MbzArtistID = md.MbzArtistID() + mf.MbzAlbumArtistID = md.MbzAlbumArtistID() + mf.MbzAlbumType = md.MbzAlbumType() + mf.MbzAlbumComment = md.MbzAlbumComment() + mf.RGAlbumGain = md.RGAlbumGain() + mf.RGAlbumPeak = md.RGAlbumPeak() + mf.RGTrackGain = md.RGTrackGain() + mf.RGTrackPeak = md.RGTrackPeak() + mf.Comment = utils.SanitizeText(md.Comment()) + mf.Lyrics = utils.SanitizeText(md.Lyrics()) + mf.Bpm = md.Bpm() + mf.CreatedAt = time.Now() + mf.UpdatedAt = md.ModificationTime() + + return *mf +} + +func sanitizeFieldForSorting(originalValue string) string { + v := strings.TrimSpace(sanitize.Accents(originalValue)) + return utils.NoArticle(v) +} + +func (s mediaFileMapper) mapTrackTitle(md metadata.Tags) string { + if md.Title() == "" { + s := strings.TrimPrefix(md.FilePath(), s.rootFolder+string(os.PathSeparator)) + e := filepath.Ext(s) + return strings.TrimSuffix(s, e) + } + return md.Title() +} + +func (s mediaFileMapper) mapAlbumArtistName(md metadata.Tags) string { + switch { + case md.AlbumArtist() != "": + return md.AlbumArtist() + case md.Compilation(): + return consts.VariousArtists + case md.Artist() != "": + return md.Artist() + default: + return consts.UnknownArtist + } +} + +func (s mediaFileMapper) mapArtistName(md metadata.Tags) string { + if md.Artist() != "" { + return md.Artist() + } + return consts.UnknownArtist +} + +func (s mediaFileMapper) mapAlbumName(md metadata.Tags) string { + name := md.Album() + if name == "" { + return consts.UnknownAlbum + } + return name +} + +func (s mediaFileMapper) trackID(md metadata.Tags) string { + return fmt.Sprintf("%x", md5.Sum([]byte(md.FilePath()))) +} + +func (s mediaFileMapper) albumID(md metadata.Tags, releaseDate string) string { + albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", s.mapAlbumArtistName(md), s.mapAlbumName(md))) + if !conf.Server.Scanner.GroupAlbumReleases { + if len(releaseDate) != 0 { + albumPath = fmt.Sprintf("%s\\%s", albumPath, releaseDate) + } + } + return fmt.Sprintf("%x", md5.Sum([]byte(albumPath))) +} + +func (s mediaFileMapper) artistID(md metadata.Tags) string { + return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapArtistName(md))))) +} + +func (s mediaFileMapper) albumArtistID(md metadata.Tags) string { + return fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(s.mapAlbumArtistName(md))))) +} + +func (s mediaFileMapper) mapGenres(genres []string) (string, model.Genres) { + var result model.Genres + unique := map[string]struct{}{} + var all []string + for i := range genres { + gs := strings.FieldsFunc(genres[i], func(r rune) bool { + return strings.ContainsRune(conf.Server.Scanner.GenreSeparators, r) + }) + for j := range gs { + g := strings.TrimSpace(gs[j]) + key := strings.ToLower(g) + if _, ok := unique[key]; ok { + continue + } + all = append(all, g) + unique[key] = struct{}{} + } + } + for _, g := range all { + genre := model.Genre{Name: g} + _ = s.genres.Put(&genre) + result = append(result, genre) + } + if len(result) == 0 { + return "", nil + } + return result[0].Name, result +} + +func (s mediaFileMapper) mapDates(md metadata.Tags) (int, string, int, string, int, string) { + year, date := md.Date() + originalYear, originalDate := md.OriginalDate() + releaseYear, releaseDate := md.ReleaseDate() + + // MusicBrainz Picard writes the Release Date of an album to the Date tag, and leaves the Release Date tag empty + taggedLikePicard := (originalYear != 0) && + (releaseYear == 0) && + (year >= originalYear) + if taggedLikePicard { + return originalYear, originalDate, originalYear, originalDate, year, date + } + return year, date, originalYear, originalDate, releaseYear, releaseDate +} diff --git a/scanner/mapping_internal_test.go b/scanner/mapping_internal_test.go new file mode 100644 index 000000000..35c975c5b --- /dev/null +++ b/scanner/mapping_internal_test.go @@ -0,0 +1,93 @@ +package scanner + +import ( + "context" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/scanner/metadata" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("mapping", func() { + Describe("mediaFileMapper", func() { + var mapper *mediaFileMapper + BeforeEach(func() { + mapper = newMediaFileMapper("/music", nil) + }) + Describe("mapTrackTitle", func() { + It("returns the Title when it is available", func() { + md := metadata.NewTag("/music/artist/album01/Song.mp3", nil, metadata.ParsedTags{"title": []string{"This is not a love song"}}) + Expect(mapper.mapTrackTitle(md)).To(Equal("This is not a love song")) + }) + It("returns the filename if Title is not set", func() { + md := metadata.NewTag("/music/artist/album01/Song.mp3", nil, metadata.ParsedTags{}) + Expect(mapper.mapTrackTitle(md)).To(Equal("artist/album01/Song")) + }) + }) + }) + Describe("sanitizeFieldForSorting", func() { + BeforeEach(func() { + conf.Server.IgnoredArticles = "The O" + }) + It("sanitize accents", func() { + Expect(sanitizeFieldForSorting("Céu")).To(Equal("Ceu")) + }) + It("removes articles", func() { + Expect(sanitizeFieldForSorting("The Beatles")).To(Equal("Beatles")) + }) + It("removes accented articles", func() { + Expect(sanitizeFieldForSorting("Õ Blésq Blom")).To(Equal("Blesq Blom")) + }) + }) + + Describe("mapGenres", func() { + var mapper *mediaFileMapper + var gr model.GenreRepository + var ctx context.Context + BeforeEach(func() { + ctx = context.Background() + ds := &tests.MockDataStore{} + gr = ds.Genre(ctx) + gr = newCachedGenreRepository(ctx, gr) + mapper = newMediaFileMapper("/", gr) + }) + + It("returns empty if no genres are available", func() { + g, gs := mapper.mapGenres(nil) + Expect(g).To(BeEmpty()) + Expect(gs).To(BeEmpty()) + }) + + It("returns genres", func() { + g, gs := mapper.mapGenres([]string{"Rock", "Electronic"}) + Expect(g).To(Equal("Rock")) + Expect(gs).To(HaveLen(2)) + Expect(gs[0].Name).To(Equal("Rock")) + Expect(gs[1].Name).To(Equal("Electronic")) + }) + + It("parses multi-valued genres", func() { + g, gs := mapper.mapGenres([]string{"Rock;Dance", "Electronic", "Rock"}) + Expect(g).To(Equal("Rock")) + Expect(gs).To(HaveLen(3)) + Expect(gs[0].Name).To(Equal("Rock")) + Expect(gs[1].Name).To(Equal("Dance")) + Expect(gs[2].Name).To(Equal("Electronic")) + }) + It("trims genres names", func() { + _, gs := mapper.mapGenres([]string{"Rock ; Dance", " Electronic "}) + Expect(gs).To(HaveLen(3)) + Expect(gs[0].Name).To(Equal("Rock")) + Expect(gs[1].Name).To(Equal("Dance")) + Expect(gs[2].Name).To(Equal("Electronic")) + }) + It("does not break on spaces", func() { + _, gs := mapper.mapGenres([]string{"New Wave"}) + Expect(gs).To(HaveLen(1)) + Expect(gs[0].Name).To(Equal("New Wave")) + }) + }) +}) diff --git a/scanner/metadata/ffmpeg/ffmpeg.go b/scanner/metadata/ffmpeg/ffmpeg.go new file mode 100644 index 000000000..1139a5014 --- /dev/null +++ b/scanner/metadata/ffmpeg/ffmpeg.go @@ -0,0 +1,205 @@ +package ffmpeg + +import ( + "bufio" + "context" + "errors" + "regexp" + "strconv" + "strings" + "time" + + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/scanner/metadata" +) + +const ExtractorID = "ffmpeg" + +type Extractor struct { + ffmpeg ffmpeg.FFmpeg +} + +func (e *Extractor) Parse(files ...string) (map[string]metadata.ParsedTags, error) { + output, err := e.ffmpeg.Probe(context.TODO(), files) + if err != nil { + log.Error("Cannot use ffmpeg to extract tags. Aborting", err) + return nil, err + } + fileTags := map[string]metadata.ParsedTags{} + if len(output) == 0 { + return fileTags, errors.New("error extracting metadata files") + } + infos := e.parseOutput(output) + for file, info := range infos { + tags, err := e.extractMetadata(file, info) + // Skip files with errors + if err == nil { + fileTags[file] = tags + } + } + return fileTags, nil +} + +func (e *Extractor) CustomMappings() metadata.ParsedTags { + return metadata.ParsedTags{ + "disc": {"tpa"}, + "has_picture": {"metadata_block_picture"}, + "originaldate": {"tdor"}, + } +} + +func (e *Extractor) extractMetadata(filePath, info string) (metadata.ParsedTags, error) { + tags := e.parseInfo(info) + if len(tags) == 0 { + log.Trace("Not a media file. Skipping", "filePath", filePath) + return nil, errors.New("not a media file") + } + + return tags, nil +} + +var ( + // Input #0, mp3, from 'groovin.mp3': + inputRegex = regexp.MustCompile(`(?m)^Input #\d+,.*,\sfrom\s'(.*)'`) + + // TITLE : Back In Black + tagsRx = regexp.MustCompile(`(?i)^\s{4,6}([\w\s-]+)\s*:(.*)`) + + // : Second comment line + continuationRx = regexp.MustCompile(`(?i)^\s+:(.*)`) + + // Duration: 00:04:16.00, start: 0.000000, bitrate: 995 kb/s` + durationRx = regexp.MustCompile(`^\s\sDuration: ([\d.:]+).*bitrate: (\d+)`) + + // Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s + bitRateRx = regexp.MustCompile(`^\s{2,4}Stream #\d+:\d+: Audio:.*, (\d+) kb/s`) + + // Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s + // Stream #0:0: Audio: flac, 44100 Hz, stereo, s16 + audioStreamRx = regexp.MustCompile(`^\s{2,4}Stream #\d+:\d+.*: Audio: (.*), (.* Hz), ([\w.]+),*(.*.,)*`) + + // Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 600x600 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn, 90k tbc` + coverRx = regexp.MustCompile(`^\s{2,4}Stream #\d+:.+: (Video):.*`) +) + +func (e *Extractor) parseOutput(output string) map[string]string { + outputs := map[string]string{} + all := inputRegex.FindAllStringSubmatchIndex(output, -1) + for i, loc := range all { + // Filename is the first captured group + file := output[loc[2]:loc[3]] + + // File info is everything from the match, up until the beginning of the next match + info := "" + initial := loc[1] + if i < len(all)-1 { + end := all[i+1][0] - 1 + info = output[initial:end] + } else { + // if this is the last match + info = output[initial:] + } + outputs[file] = info + } + return outputs +} + +func (e *Extractor) parseInfo(info string) map[string][]string { + tags := map[string][]string{} + + reader := strings.NewReader(info) + scanner := bufio.NewScanner(reader) + lastTag := "" + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 { + continue + } + match := tagsRx.FindStringSubmatch(line) + if len(match) > 0 { + tagName := strings.TrimSpace(strings.ToLower(match[1])) + if tagName != "" { + tagValue := strings.TrimSpace(match[2]) + tags[tagName] = append(tags[tagName], tagValue) + lastTag = tagName + continue + } + } + + if lastTag != "" { + match = continuationRx.FindStringSubmatch(line) + if len(match) > 0 { + if tags[lastTag] == nil { + tags[lastTag] = []string{""} + } + tagValue := tags[lastTag][0] + tags[lastTag][0] = tagValue + "\n" + strings.TrimSpace(match[1]) + continue + } + } + + lastTag = "" + match = coverRx.FindStringSubmatch(line) + if len(match) > 0 { + tags["has_picture"] = []string{"true"} + continue + } + + match = durationRx.FindStringSubmatch(line) + if len(match) > 0 { + tags["duration"] = []string{e.parseDuration(match[1])} + if len(match) > 1 { + tags["bitrate"] = []string{match[2]} + } + continue + } + + match = bitRateRx.FindStringSubmatch(line) + if len(match) > 0 { + tags["bitrate"] = []string{match[1]} + } + + match = audioStreamRx.FindStringSubmatch(line) + if len(match) > 0 { + tags["channels"] = []string{e.parseChannels(match[3])} + } + } + + comment := tags["comment"] + if len(comment) > 0 && comment[0] == "Cover (front)" { + delete(tags, "comment") + } + + return tags +} + +var zeroTime = time.Date(0000, time.January, 1, 0, 0, 0, 0, time.UTC) + +func (e *Extractor) parseDuration(tag string) string { + d, err := time.Parse("15:04:05", tag) + if err != nil { + return "0" + } + return strconv.FormatFloat(d.Sub(zeroTime).Seconds(), 'f', 2, 32) +} + +func (e *Extractor) parseChannels(tag string) string { + switch tag { + case "mono": + return "1" + case "stereo": + return "2" + case "5.1": + return "6" + case "7.1": + return "8" + default: + return "0" + } +} + +// Inputs will always be absolute paths +func init() { + metadata.RegisterExtractor(ExtractorID, &Extractor{ffmpeg: ffmpeg.New()}) +} diff --git a/adapters/gotaglib/gotaglib_suite_test.go b/scanner/metadata/ffmpeg/ffmpeg_suite_test.go similarity index 74% rename from adapters/gotaglib/gotaglib_suite_test.go rename to scanner/metadata/ffmpeg/ffmpeg_suite_test.go index cc7ddc471..815940381 100644 --- a/adapters/gotaglib/gotaglib_suite_test.go +++ b/scanner/metadata/ffmpeg/ffmpeg_suite_test.go @@ -1,4 +1,4 @@ -package gotaglib +package ffmpeg import ( "testing" @@ -9,9 +9,9 @@ import ( . "github.com/onsi/gomega" ) -func TestGoTagLib(t *testing.T) { +func TestFFMpeg(t *testing.T) { tests.Init(t, true) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) - RunSpecs(t, "GoTagLib Suite") + RunSpecs(t, "FFMpeg Suite") } diff --git a/scanner/metadata/ffmpeg/ffmpeg_test.go b/scanner/metadata/ffmpeg/ffmpeg_test.go new file mode 100644 index 000000000..7f1c63bf7 --- /dev/null +++ b/scanner/metadata/ffmpeg/ffmpeg_test.go @@ -0,0 +1,319 @@ +package ffmpeg + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Extractor", func() { + var e *Extractor + BeforeEach(func() { + e = &Extractor{} + }) + + Context("extractMetadata", func() { + It("extracts MusicBrainz custom tags", func() { + const output = ` +Input #0, ape, from './Capture/02 01 - Symphony No. 5 in C minor, Op. 67 I. Allegro con brio - Ludwig van Beethoven.ape': + Metadata: + ALBUM : Forever Classics + ARTIST : Ludwig van Beethoven + TITLE : Symphony No. 5 in C minor, Op. 67: I. Allegro con brio + MUSICBRAINZ_ALBUMSTATUS: official + MUSICBRAINZ_ALBUMTYPE: album + MusicBrainz_AlbumComment: MP3 + Musicbrainz_Albumid: 71eb5e4a-90e2-4a31-a2d1-a96485fcb667 + musicbrainz_trackid: ffe06940-727a-415a-b608-b7e45737f9d8 + Musicbrainz_Artistid: 1f9df192-a621-4f54-8850-2c5373b7eac9 + Musicbrainz_Albumartistid: 89ad4ac3-39f7-470e-963a-56509c546377 + Musicbrainz_Releasegroupid: 708b1ae1-2d3d-34c7-b764-2732b154f5b6 + musicbrainz_releasetrackid: 6fee2e35-3049-358f-83be-43b36141028b + CatalogNumber : PLD 1201 +` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("catalognumber", []string{"PLD 1201"})) + Expect(md).To(HaveKeyWithValue("musicbrainz_trackid", []string{"ffe06940-727a-415a-b608-b7e45737f9d8"})) + Expect(md).To(HaveKeyWithValue("musicbrainz_albumid", []string{"71eb5e4a-90e2-4a31-a2d1-a96485fcb667"})) + Expect(md).To(HaveKeyWithValue("musicbrainz_artistid", []string{"1f9df192-a621-4f54-8850-2c5373b7eac9"})) + Expect(md).To(HaveKeyWithValue("musicbrainz_albumartistid", []string{"89ad4ac3-39f7-470e-963a-56509c546377"})) + Expect(md).To(HaveKeyWithValue("musicbrainz_albumtype", []string{"album"})) + Expect(md).To(HaveKeyWithValue("musicbrainz_albumcomment", []string{"MP3"})) + }) + + It("detects embedded cover art correctly", func() { + const output = ` +Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3': + Metadata: + compilation : 1 + Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s + Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s + Stream #0:1: Video: mjpeg, yuvj444p(pc, bt470bg/unknown/unknown), 600x600 [SAR 1:1 DAR 1:1], 90k tbr, 90k tbn, 90k tbc` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("has_picture", []string{"true"})) + }) + + It("detects embedded cover art in ffmpeg 4.4 output", func() { + const output = ` +Input #0, flac, from '/run/media/naomi/Archivio/Musica/Katy Perry/Chained to the Rhythm/01 Katy Perry featuring Skip Marley - Chained to the Rhythm.flac': + Metadata: + ARTIST : Katy Perry featuring Skip Marley + Duration: 00:03:57.91, start: 0.000000, bitrate: 983 kb/s + Stream #0:0: Audio: flac, 44100 Hz, stereo, s16 + Stream #0:1: Video: mjpeg (Baseline), yuvj444p(pc, bt470bg/unknown/unknown), 599x518, 90k tbr, 90k tbn, 90k tbc (attached pic) + Metadata: + comment : Cover (front)` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("has_picture", []string{"true"})) + }) + + It("detects embedded cover art in ogg containers", func() { + const output = ` +Input #0, ogg, from '/Users/deluan/Music/iTunes/iTunes Media/Music/_Testes/Jamaican In New York/01-02 Jamaican In New York (Album Version).opus': + Duration: 00:04:28.69, start: 0.007500, bitrate: 139 kb/s + Stream #0:0(eng): Audio: opus, 48000 Hz, stereo, fltp + Metadata: + ALBUM : Jamaican In New York + metadata_block_picture: AAAAAwAAAAppbWFnZS9qcGVnAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4Id/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQ + TITLE : Jamaican In New York (Album Version)` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKey("metadata_block_picture")) + md = md.Map(e.CustomMappings()) + Expect(md).To(HaveKey("has_picture")) + }) + + It("detects embedded cover art in m4a containers", func() { + const output = ` +Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'Putumayo Presents_ Euro Groove/01 Destins et Désirs.m4a': + Metadata: + album : Putumayo Presents: Euro Groove + Duration: 00:05:15.81, start: 0.047889, bitrate: 133 kb/s + Stream #0:0[0x1](und): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 125 kb/s (default) + Metadata: + creation_time : 2008-03-11T21:03:23.000000Z + vendor_id : [0][0][0][0] + Stream #0:1[0x0]: Video: png, rgb24(pc), 350x350, 90k tbr, 90k tbn (attached pic) +` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("has_picture", []string{"true"})) + }) + + It("gets bitrate from the stream, if available", func() { + const output = ` +Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3': + Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s + Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("bitrate", []string{"192"})) + }) + + It("parses duration with milliseconds", func() { + const output = ` +Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3': + Duration: 00:05:02.63, start: 0.000000, bitrate: 140 kb/s` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("duration", []string{"302.63"})) + }) + + It("parse flac bitrates", func() { + const output = ` +Input #0, mp3, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.mp3': + Duration: 00:00:01.02, start: 0.000000, bitrate: 477 kb/s + Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 192 kb/s` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("channels", []string{"2"})) + }) + + It("parse channels from the stream with bitrate", func() { + const output = ` +Input #0, flac, from '/Users/deluan/Music/Music/Media/__/Crazy For You/01-01 Crazy For You.flac': + Metadata: + TITLE : Crazy For You + Duration: 00:04:13.00, start: 0.000000, bitrate: 852 kb/s + Stream #0:0: Audio: flac, 44100 Hz, stereo, s16 + Stream #0:1: Video: mjpeg (Progressive), yuvj444p(pc, bt470bg/unknown/unknown), 600x600, 90k tbr, 90k tbn, 90k tbc (attached pic) +` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("bitrate", []string{"852"})) + }) + + It("parse 7.1 channels from the stream", func() { + const output = ` +Input #0, wav, from '/Users/deluan/Music/Music/Media/_/multichannel/Nums_7dot1_24_48000.wav': + Duration: 00:00:09.05, bitrate: 9216 kb/s + Stream #0:0: Audio: pcm_s24le ([1][0][0][0] / 0x0001), 48000 Hz, 7.1, s32 (24 bit), 9216 kb/s` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("channels", []string{"8"})) + }) + + It("parse channels from the stream without bitrate", func() { + const output = ` +Input #0, flac, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.flac': + Duration: 00:00:01.02, start: 0.000000, bitrate: 1371 kb/s + Stream #0:0: Audio: flac, 44100 Hz, stereo, fltp, s32 (24 bit)` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("channels", []string{"2"})) + }) + + It("parse channels from the stream with lang", func() { + const output = ` +Input #0, flac, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.m4a': + Duration: 00:00:01.02, start: 0.000000, bitrate: 1371 kb/s + Stream #0:0(eng): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 262 kb/s (default)` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("channels", []string{"2"})) + }) + + It("parse channels from the stream with lang 2", func() { + const output = ` +Input #0, flac, from '/Users/deluan/Music/iTunes/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/09 Pablo's Blues.m4a': + Duration: 00:00:01.02, start: 0.000000, bitrate: 1371 kb/s + Stream #0:0(eng): Audio: vorbis, 44100 Hz, stereo, fltp, 192 kb/s` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("channels", []string{"2"})) + }) + + It("parses stream level tags", func() { + const output = ` +Input #0, ogg, from './01-02 Drive (Teku).opus': + Metadata: + ALBUM : Hot Wheels Acceleracers Soundtrack + Duration: 00:03:37.37, start: 0.007500, bitrate: 135 kb/s + Stream #0:0(eng): Audio: opus, 48000 Hz, stereo, fltp + Metadata: + TITLE : Drive (Teku)` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("title", []string{"Drive (Teku)"})) + }) + + It("does not overlap top level tags with the stream level tags", func() { + const output = ` +Input #0, mp3, from 'groovin.mp3': + Metadata: + title : Groovin' (feat. Daniel Sneijers, Susanne Alt) + Duration: 00:03:34.28, start: 0.025056, bitrate: 323 kb/s + Metadata: + title : garbage` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("title", []string{"Groovin' (feat. Daniel Sneijers, Susanne Alt)", "garbage"})) + }) + + It("parses multiline tags", func() { + const outputWithMultilineComment = ` +Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'modulo.m4a': + Metadata: + comment : https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/ + : + : Tracklist: + : + : 01. Saara Saara + : 02. Carta Corrente + : 03. X + : 04. Eclipse Lunar + : 05. Vírus de Sírius + : 06. Doktor Fritz + : 07. Wunderbar + : 08. Quarta Dimensão + Duration: 00:26:46.96, start: 0.052971, bitrate: 69 kb/s` + const expectedComment = `https://www.mixcloud.com/codigorock/30-minutos-com-saara-saara/ + +Tracklist: + +01. Saara Saara +02. Carta Corrente +03. X +04. Eclipse Lunar +05. Vírus de Sírius +06. Doktor Fritz +07. Wunderbar +08. Quarta Dimensão` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", outputWithMultilineComment) + Expect(md).To(HaveKeyWithValue("comment", []string{expectedComment})) + }) + + It("parses sort tags correctly", func() { + const output = ` +Input #0, mp3, from '/Users/deluan/Downloads/椎名林檎 - 加爾基 精液 栗ノ花 - 2003/02 - ドツペルゲンガー.mp3': + Metadata: + title-sort : Dopperugengā + album : 加爾基 精液 栗ノ花 + artist : 椎名林檎 + album_artist : 椎名林檎 + title : ドツペルゲンガー + albumsort : Kalk Samen Kuri No Hana + artist_sort : Shiina, Ringo + ALBUMARTISTSORT : Shiina, Ringo +` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("title", []string{"ドツペルゲンガー"})) + Expect(md).To(HaveKeyWithValue("album", []string{"加爾基 精液 栗ノ花"})) + Expect(md).To(HaveKeyWithValue("artist", []string{"椎名林檎"})) + Expect(md).To(HaveKeyWithValue("album_artist", []string{"椎名林檎"})) + Expect(md).To(HaveKeyWithValue("title-sort", []string{"Dopperugengā"})) + Expect(md).To(HaveKeyWithValue("albumsort", []string{"Kalk Samen Kuri No Hana"})) + Expect(md).To(HaveKeyWithValue("artist_sort", []string{"Shiina, Ringo"})) + Expect(md).To(HaveKeyWithValue("albumartistsort", []string{"Shiina, Ringo"})) + }) + + It("ignores cover comment", func() { + const output = ` +Input #0, mp3, from './Edie Brickell/Picture Perfect Morning/01-01 Tomorrow Comes.mp3': + Metadata: + title : Tomorrow Comes + artist : Edie Brickell + Duration: 00:03:56.12, start: 0.000000, bitrate: 332 kb/s + Stream #0:0: Audio: mp3, 44100 Hz, stereo, s16p, 320 kb/s + Stream #0:1: Video: mjpeg, yuvj420p(pc, bt470bg/unknown/unknown), 1200x1200 [SAR 72:72 DAR 1:1], 90k tbr, 90k tbn, 90k tbc + Metadata: + comment : Cover (front)` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).ToNot(HaveKey("comment")) + }) + + It("parses tags with spaces in the name", func() { + const output = ` +Input #0, mp3, from '/Users/deluan/Music/Music/Media/_/Wyclef Jean - From the Hut, to the Projects, to the Mansion/10 - The Struggle (interlude).mp3': + Metadata: + ALBUM ARTIST : Wyclef Jean +` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("album artist", []string{"Wyclef Jean"})) + }) + }) + + It("parses an integer TBPM tag", func() { + const output = ` + Input #0, mp3, from 'tests/fixtures/test.mp3': + Metadata: + TBPM : 123` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("tbpm", []string{"123"})) + }) + + It("parses and rounds a floating point fBPM tag", func() { + const output = ` + Input #0, ogg, from 'tests/fixtures/test.ogg': + Metadata: + FBPM : 141.7` + md, _ := e.extractMetadata("tests/fixtures/test.ogg", output) + Expect(md).To(HaveKeyWithValue("fbpm", []string{"141.7"})) + }) + + It("parses replaygain data correctly", func() { + const output = ` + Input #0, mp3, from 'test.mp3': + Metadata: + REPLAYGAIN_ALBUM_PEAK: 0.9125 + REPLAYGAIN_TRACK_PEAK: 0.4512 + REPLAYGAIN_TRACK_GAIN: -1.48 dB + REPLAYGAIN_ALBUM_GAIN: +3.21518 dB + Side data: + replaygain: track gain - -1.480000, track peak - 0.000011, album gain - 3.215180, album peak - 0.000021, + ` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"})) + Expect(md).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"})) + Expect(md).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"})) + Expect(md).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"})) + + }) +}) diff --git a/scanner/metadata/metadata.go b/scanner/metadata/metadata.go new file mode 100644 index 000000000..d6268c07f --- /dev/null +++ b/scanner/metadata/metadata.go @@ -0,0 +1,303 @@ +package metadata + +import ( + "fmt" + "math" + "os" + "path" + "regexp" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" +) + +type Extractor interface { + Parse(files ...string) (map[string]ParsedTags, error) + CustomMappings() ParsedTags +} + +var extractors = map[string]Extractor{} + +func RegisterExtractor(id string, parser Extractor) { + extractors[id] = parser +} + +func Extract(files ...string) (map[string]Tags, error) { + p, ok := extractors[conf.Server.Scanner.Extractor] + if !ok { + log.Warn("Invalid 'Scanner.Extractor' option. Using default", "requested", conf.Server.Scanner.Extractor, + "validOptions", "ffmpeg,taglib", "default", consts.DefaultScannerExtractor) + p = extractors[consts.DefaultScannerExtractor] + } + + extractedTags, err := p.Parse(files...) + if err != nil { + return nil, err + } + + result := map[string]Tags{} + for filePath, tags := range extractedTags { + fileInfo, err := os.Stat(filePath) + if err != nil { + log.Warn("Error stating file. Skipping", "filePath", filePath, err) + continue + } + + tags = tags.Map(p.CustomMappings()) + result[filePath] = NewTag(filePath, fileInfo, tags) + } + + return result, nil +} + +func NewTag(filePath string, fileInfo os.FileInfo, tags ParsedTags) Tags { + return Tags{ + filePath: filePath, + fileInfo: fileInfo, + tags: tags, + } +} + +type ParsedTags map[string][]string + +func (p ParsedTags) Map(customMappings ParsedTags) ParsedTags { + if customMappings == nil { + return p + } + for tagName, alternatives := range customMappings { + for _, altName := range alternatives { + if altValue, ok := p[altName]; ok { + p[tagName] = append(p[tagName], altValue...) + delete(p, altName) + } + } + } + return p +} + +type Tags struct { + filePath string + fileInfo os.FileInfo + tags ParsedTags +} + +// Common tags + +func (t Tags) Title() string { return t.getFirstTagValue("title", "sort_name", "titlesort") } +func (t Tags) Album() string { return t.getFirstTagValue("album", "sort_album", "albumsort") } +func (t Tags) Artist() string { return t.getFirstTagValue("artist", "sort_artist", "artistsort") } +func (t Tags) AlbumArtist() string { + return t.getFirstTagValue("album_artist", "album artist", "albumartist") +} +func (t Tags) SortTitle() string { return t.getSortTag("", "title", "name") } +func (t Tags) SortAlbum() string { return t.getSortTag("", "album") } +func (t Tags) SortArtist() string { return t.getSortTag("", "artist") } +func (t Tags) SortAlbumArtist() string { return t.getSortTag("tso2", "albumartist", "album_artist") } +func (t Tags) Genres() []string { return t.getAllTagValues("genre") } +func (t Tags) Date() (int, string) { return t.getDate("date") } +func (t Tags) OriginalDate() (int, string) { return t.getDate("originaldate") } +func (t Tags) ReleaseDate() (int, string) { return t.getDate("releasedate") } +func (t Tags) Comment() string { return t.getFirstTagValue("comment") } +func (t Tags) Lyrics() string { return t.getFirstTagValue("lyrics", "lyrics-eng") } +func (t Tags) Compilation() bool { return t.getBool("tcmp", "compilation") } +func (t Tags) TrackNumber() (int, int) { return t.getTuple("track", "tracknumber") } +func (t Tags) DiscNumber() (int, int) { return t.getTuple("disc", "discnumber") } +func (t Tags) DiscSubtitle() string { + return t.getFirstTagValue("tsst", "discsubtitle", "setsubtitle") +} +func (t Tags) CatalogNum() string { return t.getFirstTagValue("catalognumber") } +func (t Tags) Bpm() int { return (int)(math.Round(t.getFloat("tbpm", "bpm", "fbpm"))) } +func (t Tags) HasPicture() bool { return t.getFirstTagValue("has_picture") != "" } + +// MusicBrainz Identifiers + +func (t Tags) MbzReleaseTrackID() string { + return t.getMbzID("musicbrainz_releasetrackid", "musicbrainz release track id") +} + +func (t Tags) MbzTrackID() string { return t.getMbzID("musicbrainz_trackid", "musicbrainz track id") } +func (t Tags) MbzAlbumID() string { return t.getMbzID("musicbrainz_albumid", "musicbrainz album id") } +func (t Tags) MbzArtistID() string { + return t.getMbzID("musicbrainz_artistid", "musicbrainz artist id") +} +func (t Tags) MbzAlbumArtistID() string { + return t.getMbzID("musicbrainz_albumartistid", "musicbrainz album artist id") +} +func (t Tags) MbzAlbumType() string { + return t.getFirstTagValue("musicbrainz_albumtype", "musicbrainz album type") +} +func (t Tags) MbzAlbumComment() string { + return t.getFirstTagValue("musicbrainz_albumcomment", "musicbrainz album comment") +} + +// File properties + +func (t Tags) Duration() float32 { return float32(t.getFloat("duration")) } +func (t Tags) BitRate() int { return t.getInt("bitrate") } +func (t Tags) Channels() int { return t.getInt("channels") } +func (t Tags) ModificationTime() time.Time { return t.fileInfo.ModTime() } +func (t Tags) Size() int64 { return t.fileInfo.Size() } +func (t Tags) FilePath() string { return t.filePath } +func (t Tags) Suffix() string { return strings.ToLower(strings.TrimPrefix(path.Ext(t.filePath), ".")) } + +// Replaygain Properties +func (t Tags) RGAlbumGain() float64 { return t.getGainValue("replaygain_album_gain") } +func (t Tags) RGAlbumPeak() float64 { return t.getPeakValue("replaygain_album_peak") } +func (t Tags) RGTrackGain() float64 { return t.getGainValue("replaygain_track_gain") } +func (t Tags) RGTrackPeak() float64 { return t.getPeakValue("replaygain_track_peak") } + +func (t Tags) getGainValue(tagName string) float64 { + // Gain is in the form [-]a.bb dB + var tag = t.getFirstTagValue(tagName) + + if tag == "" { + return 0 + } + + tag = strings.TrimSpace(strings.Replace(tag, "dB", "", 1)) + + var value, err = strconv.ParseFloat(tag, 64) + if err != nil { + return 0 + } + return value +} + +func (t Tags) getPeakValue(tagName string) float64 { + var tag = t.getFirstTagValue(tagName) + var value, err = strconv.ParseFloat(tag, 64) + if err != nil { + // A default of 1 for peak value resulds in no changes + return 1 + } + return value +} + +func (t Tags) getTags(tagNames ...string) []string { + for _, tag := range tagNames { + if v, ok := t.tags[tag]; ok { + return v + } + } + return nil +} + +func (t Tags) getFirstTagValue(tagNames ...string) string { + ts := t.getTags(tagNames...) + if len(ts) > 0 { + return ts[0] + } + return "" +} + +func (t Tags) getAllTagValues(tagNames ...string) []string { + var values []string + for _, tag := range tagNames { + if v, ok := t.tags[tag]; ok { + values = append(values, v...) + } + } + return values +} + +func (t Tags) getSortTag(originalTag string, tagNames ...string) string { + formats := []string{"sort%s", "sort_%s", "sort-%s", "%ssort", "%s_sort", "%s-sort"} + all := []string{originalTag} + for _, tag := range tagNames { + for _, format := range formats { + name := fmt.Sprintf(format, tag) + all = append(all, name) + } + } + return t.getFirstTagValue(all...) +} + +var dateRegex = regexp.MustCompile(`([12]\d\d\d)`) + +func (t Tags) getDate(tagNames ...string) (int, string) { + tag := t.getFirstTagValue(tagNames...) + if len(tag) < 4 { + return 0, "" + } + // first get just the year + match := dateRegex.FindStringSubmatch(tag) + if len(match) == 0 { + log.Warn("Error parsing "+tagNames[0]+" field for year", "file", t.filePath, "date", tag) + return 0, "" + } + year, _ := strconv.Atoi(match[1]) + + if len(tag) < 5 { + return year, match[1] + } + + //then try YYYY-MM-DD + if len(tag) > 10 { + tag = tag[:10] + } + layout := "2006-01-02" + _, err := time.Parse(layout, tag) + if err != nil { + layout = "2006-01" + _, err = time.Parse(layout, tag) + if err != nil { + log.Warn("Error parsing "+tagNames[0]+" field for month + day", "file", t.filePath, "date", tag) + return year, match[1] + } + } + return year, tag +} + +func (t Tags) getBool(tagNames ...string) bool { + tag := t.getFirstTagValue(tagNames...) + if tag == "" { + return false + } + i, _ := strconv.Atoi(strings.TrimSpace(tag)) + return i == 1 +} + +func (t Tags) getTuple(tagNames ...string) (int, int) { + tag := t.getFirstTagValue(tagNames...) + if tag == "" { + return 0, 0 + } + tuple := strings.Split(tag, "/") + t1, t2 := 0, 0 + t1, _ = strconv.Atoi(tuple[0]) + if len(tuple) > 1 { + t2, _ = strconv.Atoi(tuple[1]) + } else { + t2tag := t.getFirstTagValue(tagNames[0] + "total") + t2, _ = strconv.Atoi(t2tag) + } + return t1, t2 +} + +func (t Tags) getMbzID(tagNames ...string) string { + tag := t.getFirstTagValue(tagNames...) + if _, err := uuid.Parse(tag); err != nil { + return "" + } + return tag +} + +func (t Tags) getInt(tagNames ...string) int { + tag := t.getFirstTagValue(tagNames...) + i, _ := strconv.Atoi(tag) + return i +} + +func (t Tags) getFloat(tagNames ...string) float64 { + var tag = t.getFirstTagValue(tagNames...) + var value, err = strconv.ParseFloat(tag, 64) + if err != nil { + return 0 + } + return value +} diff --git a/scanner/metadata/metadata_internal_test.go b/scanner/metadata/metadata_internal_test.go new file mode 100644 index 000000000..2749e69a4 --- /dev/null +++ b/scanner/metadata/metadata_internal_test.go @@ -0,0 +1,83 @@ +package metadata + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Tags", func() { + DescribeTable("getDate", + func(tag string, expectedYear int, expectedDate string) { + md := &Tags{} + md.tags = map[string][]string{"date": {tag}} + testYear, testDate := md.Date() + Expect(testYear).To(Equal(expectedYear)) + Expect(testDate).To(Equal(expectedDate)) + }, + Entry(nil, "1985", 1985, "1985"), + Entry(nil, "2002-01", 2002, "2002-01"), + Entry(nil, "1969.06", 1969, "1969"), + Entry(nil, "1980.07.25", 1980, "1980"), + Entry(nil, "2004-00-00", 2004, "2004"), + Entry(nil, "2016-12-31", 2016, "2016-12-31"), + Entry(nil, "2013-May-12", 2013, "2013"), + Entry(nil, "May 12, 2016", 2016, "2016"), + Entry(nil, "01/10/1990", 1990, "1990"), + Entry(nil, "invalid", 0, ""), + ) + + Describe("getMbzID", func() { + It("return a valid MBID", func() { + md := &Tags{} + md.tags = map[string][]string{ + "musicbrainz_trackid": {"8f84da07-09a0-477b-b216-cc982dabcde1"}, + "musicbrainz_releasetrackid": {"6caf16d3-0b20-3fe6-8020-52e31831bc11"}, + "musicbrainz_albumid": {"f68c985d-f18b-4f4a-b7f0-87837cf3fbf9"}, + "musicbrainz_artistid": {"89ad4ac3-39f7-470e-963a-56509c546377"}, + "musicbrainz_albumartistid": {"ada7a83c-e3e1-40f1-93f9-3e73dbc9298a"}, + } + Expect(md.MbzTrackID()).To(Equal("8f84da07-09a0-477b-b216-cc982dabcde1")) + Expect(md.MbzReleaseTrackID()).To(Equal("6caf16d3-0b20-3fe6-8020-52e31831bc11")) + Expect(md.MbzAlbumID()).To(Equal("f68c985d-f18b-4f4a-b7f0-87837cf3fbf9")) + Expect(md.MbzArtistID()).To(Equal("89ad4ac3-39f7-470e-963a-56509c546377")) + Expect(md.MbzAlbumArtistID()).To(Equal("ada7a83c-e3e1-40f1-93f9-3e73dbc9298a")) + }) + It("return empty string for invalid MBID", func() { + md := &Tags{} + md.tags = map[string][]string{ + "musicbrainz_trackid": {"11406732-6"}, + "musicbrainz_albumid": {"11406732"}, + "musicbrainz_artistid": {"200455"}, + "musicbrainz_albumartistid": {"194"}, + } + Expect(md.MbzTrackID()).To(Equal("")) + Expect(md.MbzAlbumID()).To(Equal("")) + Expect(md.MbzArtistID()).To(Equal("")) + Expect(md.MbzAlbumArtistID()).To(Equal("")) + }) + }) + + Describe("getAllTagValues", func() { + It("returns values from all tag names", func() { + md := &Tags{} + md.tags = map[string][]string{ + "genre": {"Rock", "Pop", "New Wave"}, + } + + Expect(md.Genres()).To(ConsistOf("Rock", "Pop", "New Wave")) + }) + }) + + Describe("Bpm", func() { + var t *Tags + BeforeEach(func() { + t = &Tags{tags: map[string][]string{ + "fbpm": []string{"141.7"}, + }} + }) + + It("rounds a floating point fBPM tag", func() { + Expect(t.Bpm()).To(Equal(142)) + }) + }) +}) diff --git a/cmd/cmd_suite_test.go b/scanner/metadata/metadata_suite_test.go similarity index 68% rename from cmd/cmd_suite_test.go rename to scanner/metadata/metadata_suite_test.go index f2ddf6a9c..095895d63 100644 --- a/cmd/cmd_suite_test.go +++ b/scanner/metadata/metadata_suite_test.go @@ -1,4 +1,4 @@ -package cmd +package metadata import ( "testing" @@ -9,9 +9,9 @@ import ( . "github.com/onsi/gomega" ) -func TestCmd(t *testing.T) { - tests.Init(t, false) +func TestMetadata(t *testing.T) { + tests.Init(t, true) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) - RunSpecs(t, "Cmd Suite") + RunSpecs(t, "Metadata Suite") } diff --git a/scanner/metadata/metadata_test.go b/scanner/metadata/metadata_test.go new file mode 100644 index 000000000..0b1a78554 --- /dev/null +++ b/scanner/metadata/metadata_test.go @@ -0,0 +1,70 @@ +package metadata_test + +import ( + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/scanner/metadata" + _ "github.com/navidrome/navidrome/scanner/metadata/ffmpeg" + _ "github.com/navidrome/navidrome/scanner/metadata/taglib" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Tags", func() { + Context("Extract", func() { + BeforeEach(func() { + conf.Server.Scanner.Extractor = "taglib" + }) + + It("correctly parses metadata from all files in folder", func() { + mds, err := metadata.Extract("tests/fixtures/test.mp3", "tests/fixtures/test.ogg") + Expect(err).NotTo(HaveOccurred()) + Expect(mds).To(HaveLen(2)) + + m := mds["tests/fixtures/test.mp3"] + Expect(m.Title()).To(Equal("Song")) + Expect(m.Album()).To(Equal("Album")) + Expect(m.Artist()).To(Equal("Artist")) + Expect(m.AlbumArtist()).To(Equal("Album Artist")) + Expect(m.Compilation()).To(BeTrue()) + Expect(m.Genres()).To(Equal([]string{"Rock"})) + y, d := m.Date() + Expect(y).To(Equal(2014)) + Expect(d).To(Equal("2014-05-21")) + y, d = m.OriginalDate() + Expect(y).To(Equal(1996)) + Expect(d).To(Equal("1996-11-21")) + y, d = m.ReleaseDate() + Expect(y).To(Equal(2020)) + Expect(d).To(Equal("2020-12-31")) + n, t := m.TrackNumber() + Expect(n).To(Equal(2)) + Expect(t).To(Equal(10)) + n, t = m.DiscNumber() + Expect(n).To(Equal(1)) + Expect(t).To(Equal(2)) + Expect(m.HasPicture()).To(BeTrue()) + Expect(m.Duration()).To(BeNumerically("~", 1.02, 0.01)) + Expect(m.BitRate()).To(Equal(192)) + Expect(m.Channels()).To(Equal(2)) + Expect(m.FilePath()).To(Equal("tests/fixtures/test.mp3")) + Expect(m.Suffix()).To(Equal("mp3")) + Expect(m.Size()).To(Equal(int64(51876))) + Expect(m.RGAlbumGain()).To(Equal(3.21518)) + Expect(m.RGAlbumPeak()).To(Equal(0.9125)) + Expect(m.RGTrackGain()).To(Equal(-1.48)) + Expect(m.RGTrackPeak()).To(Equal(0.4512)) + + m = mds["tests/fixtures/test.ogg"] + Expect(err).To(BeNil()) + Expect(m.Title()).To(BeEmpty()) + Expect(m.HasPicture()).To(BeFalse()) + Expect(m.Duration()).To(BeNumerically("~", 1.04, 0.01)) + Expect(m.Suffix()).To(Equal("ogg")) + Expect(m.FilePath()).To(Equal("tests/fixtures/test.ogg")) + Expect(m.Size()).To(Equal(int64(5178))) + // TabLib 1.12 returns 18, previous versions return 39. + // See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b + Expect(m.BitRate()).To(BeElementOf(18, 39, 40)) + }) + }) +}) diff --git a/scanner/metadata/taglib/get_filename.go b/scanner/metadata/taglib/get_filename.go new file mode 100644 index 000000000..df7cab860 --- /dev/null +++ b/scanner/metadata/taglib/get_filename.go @@ -0,0 +1,9 @@ +//go:build !windows + +package taglib + +import "C" + +func getFilename(s string) *C.char { + return C.CString(s) +} diff --git a/scanner/metadata/taglib/get_filename_win.go b/scanner/metadata/taglib/get_filename_win.go new file mode 100644 index 000000000..2093616c8 --- /dev/null +++ b/scanner/metadata/taglib/get_filename_win.go @@ -0,0 +1,96 @@ +//go:build windows + +package taglib + +// From https://github.com/orofarne/gowchar + +/* +#include <wchar.h> + +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/scanner/metadata/taglib/taglib.go b/scanner/metadata/taglib/taglib.go new file mode 100644 index 000000000..0a2aa0098 --- /dev/null +++ b/scanner/metadata/taglib/taglib.go @@ -0,0 +1,55 @@ +package taglib + +import ( + "errors" + "os" + "strconv" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/scanner/metadata" +) + +const ExtractorID = "taglib" + +type Extractor struct{} + +func (e *Extractor) Parse(paths ...string) (map[string]metadata.ParsedTags, error) { + fileTags := map[string]metadata.ParsedTags{} + for _, path := range paths { + tags, err := e.extractMetadata(path) + if !errors.Is(err, os.ErrPermission) { + fileTags[path] = tags + } + } + return fileTags, nil +} + +func (e *Extractor) CustomMappings() metadata.ParsedTags { + return metadata.ParsedTags{ + "title": {"titlesort"}, + "album": {"albumsort"}, + "artist": {"artistsort"}, + "tracknumber": {"trck", "_track"}, + } +} + +func (e *Extractor) extractMetadata(filePath string) (metadata.ParsedTags, error) { + tags, err := Read(filePath) + if err != nil { + log.Warn("TagLib: Error reading metadata from file. Skipping", "filePath", filePath, err) + return nil, err + } + + if length, ok := tags["lengthinmilliseconds"]; ok && len(length) > 0 { + millis, _ := strconv.Atoi(length[0]) + if duration := float64(millis) / 1000.0; duration > 0 { + tags["duration"] = []string{strconv.FormatFloat(duration, 'f', 2, 32)} + } + } + + return tags, nil +} + +func init() { + metadata.RegisterExtractor(ExtractorID, &Extractor{}) +} diff --git a/core/playback/mpv/mpv_suite_test.go b/scanner/metadata/taglib/taglib_suite_test.go similarity index 69% rename from core/playback/mpv/mpv_suite_test.go rename to scanner/metadata/taglib/taglib_suite_test.go index f8f827620..2b26612cf 100644 --- a/core/playback/mpv/mpv_suite_test.go +++ b/scanner/metadata/taglib/taglib_suite_test.go @@ -1,4 +1,4 @@ -package mpv +package taglib import ( "testing" @@ -9,9 +9,9 @@ import ( . "github.com/onsi/gomega" ) -func TestMPV(t *testing.T) { - tests.Init(t, false) +func TestTagLib(t *testing.T) { + tests.Init(t, true) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) - RunSpecs(t, "MPV Suite") + RunSpecs(t, "TagLib Suite") } diff --git a/scanner/metadata/taglib/taglib_test.go b/scanner/metadata/taglib/taglib_test.go new file mode 100644 index 000000000..342cb03c8 --- /dev/null +++ b/scanner/metadata/taglib/taglib_test.go @@ -0,0 +1,105 @@ +package taglib + +import ( + "io/fs" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Extractor", func() { + var e *Extractor + // This file will have 0222 (no read) permission during these tests + var accessForbiddenFile = "tests/fixtures/test_no_read_permission.ogg" + + BeforeEach(func() { + e = &Extractor{} + + err := os.Chmod(accessForbiddenFile, 0222) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + err = os.Chmod(accessForbiddenFile, 0644) + Expect(err).ToNot(HaveOccurred()) + }) + }) + Context("Parse", func() { + It("correctly parses metadata from all files in folder", func() { + mds, err := e.Parse( + "tests/fixtures/test.mp3", + "tests/fixtures/test.ogg", + accessForbiddenFile, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(mds).To(HaveLen(2)) + Expect(mds).ToNot(HaveKey(accessForbiddenFile)) + + m := mds["tests/fixtures/test.mp3"] + Expect(m).To(HaveKeyWithValue("title", []string{"Song", "Song"})) + Expect(m).To(HaveKeyWithValue("album", []string{"Album", "Album"})) + Expect(m).To(HaveKeyWithValue("artist", []string{"Artist", "Artist"})) + Expect(m).To(HaveKeyWithValue("albumartist", []string{"Album Artist"})) + Expect(m).To(HaveKeyWithValue("tcmp", []string{"1"})) // Compilation + Expect(m).To(HaveKeyWithValue("genre", []string{"Rock"})) + Expect(m).To(HaveKeyWithValue("date", []string{"2014-05-21", "2014"})) + Expect(m).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"})) + Expect(m).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"})) + Expect(m).To(HaveKeyWithValue("discnumber", []string{"1/2"})) + Expect(m).To(HaveKeyWithValue("has_picture", []string{"true"})) + Expect(m).To(HaveKeyWithValue("duration", []string{"1.02"})) + Expect(m).To(HaveKeyWithValue("bitrate", []string{"192"})) + Expect(m).To(HaveKeyWithValue("channels", []string{"2"})) + Expect(m).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"})) + Expect(m).To(HaveKeyWithValue("lyrics", []string{"Lyrics 1\rLyrics 2"})) + Expect(m).To(HaveKeyWithValue("bpm", []string{"123"})) + + Expect(m).To(HaveKeyWithValue("tracknumber", []string{"2/10"})) + m = m.Map(e.CustomMappings()) + Expect(m).To(HaveKeyWithValue("tracknumber", []string{"2/10", "2/10", "2"})) + + m = mds["tests/fixtures/test.ogg"] + Expect(err).To(BeNil()) + Expect(m).ToNot(HaveKey("title")) + Expect(m).ToNot(HaveKey("has_picture")) + Expect(m).To(HaveKeyWithValue("duration", []string{"1.04"})) + Expect(m).To(HaveKeyWithValue("fbpm", []string{"141.7"})) + + // TabLib 1.12 returns 18, previous versions return 39. + // See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b + Expect(m).To(HaveKey("bitrate")) + Expect(m["bitrate"][0]).To(BeElementOf("18", "39", "40")) + }) + + DescribeTable("ReplayGain", + func(file, albumGain, albumPeak, trackGain, trackPeak string) { + file = "tests/fixtures/" + file + mds, err := e.Parse(file) + Expect(err).NotTo(HaveOccurred()) + Expect(mds).To(HaveLen(1)) + + m := mds[file] + + Expect(m).To(HaveKeyWithValue("replaygain_album_gain", []string{albumGain})) + Expect(m).To(HaveKeyWithValue("replaygain_album_peak", []string{albumPeak})) + Expect(m).To(HaveKeyWithValue("replaygain_track_gain", []string{trackGain})) + Expect(m).To(HaveKeyWithValue("replaygain_track_peak", []string{trackPeak})) + }, + Entry("Correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "0.37", "0.48", "0.37", "0.48"), + Entry("correctly parses mp3 tags", "test.mp3", "+3.21518 dB", "0.9125", "-1.48 dB", "0.4512"), + Entry("correctly parses ogg (vorbis) tags", "test.ogg", "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506"), + ) + }) + + Context("Error Checking", func() { + It("correctly handle unreadable file due to insufficient read permission", func() { + _, err := e.extractMetadata(accessForbiddenFile) + Expect(err).To(MatchError(os.ErrPermission)) + }) + 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)) + }) + }) + +}) diff --git a/scanner/metadata/taglib/taglib_wrapper.cpp b/scanner/metadata/taglib/taglib_wrapper.cpp new file mode 100644 index 000000000..3f111c137 --- /dev/null +++ b/scanner/metadata/taglib/taglib_wrapper.cpp @@ -0,0 +1,162 @@ +#include <stdlib.h> +#include <string.h> +#include <typeinfo> + +#define TAGLIB_STATIC +#include <asffile.h> +#include <fileref.h> +#include <flacfile.h> +#include <id3v2tag.h> +#include <mp4file.h> +#include <mpegfile.h> +#include <opusfile.h> +#include <tpropertymap.h> +#include <vorbisfile.h> + +#include "taglib_wrapper.h" + +// Tags necessary for M4a parsing +const char *RG_TAGS[] = { + "replaygain_album_gain", + "replaygain_album_peak", + "replaygain_track_gain", + "replaygain_track_peak"}; + +char has_cover(const TagLib::FileRef f); + +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()); + go_map_put_int(id, (char *)"duration", props->length()); + go_map_put_int(id, (char *)"lengthinmilliseconds", props->lengthInMilliseconds()); + go_map_put_int(id, (char *)"bitrate", props->bitrate()); + go_map_put_int(id, (char *)"channels", props->channels()); + + 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->year() > 0) { + tags.insert("date", TagLib::String::number(basic->year())); + } + if (basic->track() > 0) { + tags.insert("_track", TagLib::String::number(basic->track())); + } + } + + // Get some extended/non-standard ID3-only tags (ex: iTunes extended frames) + TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file())); + if (mp3File != NULL) { + if (mp3File->ID3v2Tag()) { + const auto &frameListMap(mp3File->ID3v2Tag()->frameListMap()); + + for (const auto &kv : frameListMap) { + if (!kv.second.isEmpty()) + tags.insert(kv.first, kv.second.front()->toString()); + } + } + } + + TagLib::MP4::File *m4afile(dynamic_cast<TagLib::MP4::File *>(f.file())); + if (m4afile != NULL) + { + const auto itemListMap = m4afile->tag(); + { + char buf[200]; + + for (const char *key : RG_TAGS) + { + snprintf(buf, sizeof(buf), "----:com.apple.iTunes:%s", key); + const auto item = itemListMap->item(buf); + if (item.isValid()) + { + char *dup = ::strdup(key); + char *val = ::strdup(item.toStringList().front().toCString(true)); + go_map_put_str(id, dup, val); + free(dup); + free(val); + } + } + } + } + + if (has_cover(f)) { + go_map_put_str(id, (char *)"has_picture", (char *)"true"); + } + + for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end(); + ++i) { + for (TagLib::StringList::ConstIterator j = i->second.begin(); + j != i->second.end(); ++j) { + char *key = ::strdup(i->first.toCString(true)); + char *val = ::strdup((*j).toCString(true)); + go_map_put_str(id, key, val); + free(key); + free(val); + } + } + + return 0; +} + +char has_cover(const TagLib::FileRef f) { + char hasCover = 0; + // ----- MP3 + if (TagLib::MPEG::File * + mp3File{dynamic_cast<TagLib::MPEG::File *>(f.file())}) { + if (mp3File->ID3v2Tag()) { + const auto &frameListMap{mp3File->ID3v2Tag()->frameListMap()}; + hasCover = !frameListMap["APIC"].isEmpty(); + } + } + // ----- FLAC + else if (TagLib::FLAC::File * + flacFile{dynamic_cast<TagLib::FLAC::File *>(f.file())}) { + hasCover = !flacFile->pictureList().isEmpty(); + } + // ----- MP4 + else if (TagLib::MP4::File * + mp4File{dynamic_cast<TagLib::MP4::File *>(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<TagLib::Ogg::Vorbis::File *>(f.file())}) { + hasCover = !vorbisFile->tag()->pictureList().isEmpty(); + } + // ----- Opus + else if (TagLib::Ogg::Opus::File * + opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) { + hasCover = !opusFile->tag()->pictureList().isEmpty(); + } + // ----- WMA + if (TagLib::ASF::File * + asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) { + const TagLib::ASF::Tag *tag{asfFile->tag()}; + hasCover = tag && tag->attributeListMap().contains("WM/Picture"); + } + + return hasCover; +} diff --git a/scanner/metadata/taglib/taglib_wrapper.go b/scanner/metadata/taglib/taglib_wrapper.go new file mode 100644 index 000000000..55bbe8fe8 --- /dev/null +++ b/scanner/metadata/taglib/taglib_wrapper.go @@ -0,0 +1,98 @@ +package taglib + +/* +#cgo pkg-config: taglib +#cgo !illumos LDFLAGS: -lstdc++ +#cgo illumos LDFLAGS: -lstdc++ -lsendfile +#cgo linux darwin CXXFLAGS: -std=c++11 +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include "taglib_wrapper.h" +*/ +import "C" +import ( + "fmt" + "os" + "runtime/debug" + "strconv" + "strings" + "sync" + "unsafe" + + "github.com/navidrome/navidrome/log" +) + +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("TagLib: recovered from panic when reading tags", "file", filename, "error", r) + err = fmt.Errorf("TagLib: recovered from panic: %s", r) + } + }() + + fp := getFilename(filename) + defer C.free(unsafe.Pointer(fp)) + id, m := newMap() + defer deleteMap(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") + } + log.Trace("TagLib: read tags", "tags", m, "filename", filename, "id", id) + return m, nil +} + +var lock sync.RWMutex +var maps = make(map[uint32]map[string][]string) +var mapsNextID uint32 + +func newMap() (id uint32, m map[string][]string) { + lock.Lock() + defer lock.Unlock() + id = mapsNextID + mapsNextID++ + m = make(map[string][]string) + maps[id] = m + return +} + +func deleteMap(id uint32) { + lock.Lock() + defer lock.Unlock() + delete(maps, id) +} + +//export go_map_put_str +func go_map_put_str(id C.ulong, key *C.char, val *C.char) { + lock.RLock() + defer lock.RUnlock() + m := maps[uint32(id)] + k := strings.ToLower(C.GoString(key)) + v := strings.TrimSpace(C.GoString(val)) + m[k] = append(m[k], v) +} + +//export go_map_put_int +func go_map_put_int(id C.ulong, key *C.char, val C.int) { + valStr := strconv.Itoa(int(val)) + vp := C.CString(valStr) + defer C.free(unsafe.Pointer(vp)) + go_map_put_str(id, key, vp) +} diff --git a/scanner/metadata/taglib/taglib_wrapper.h b/scanner/metadata/taglib/taglib_wrapper.h new file mode 100644 index 000000000..d80ac1f83 --- /dev/null +++ b/scanner/metadata/taglib/taglib_wrapper.h @@ -0,0 +1,20 @@ +#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 go_map_put_str(unsigned long id, char *key, char *val); +extern void go_map_put_int(unsigned long id, char *key, int val); +int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id); + +#ifdef __cplusplus +} +#endif diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go deleted file mode 100644 index 4edaecacd..000000000 --- a/scanner/phase_1_folders.go +++ /dev/null @@ -1,549 +0,0 @@ -package scanner - -import ( - "cmp" - "context" - "errors" - "fmt" - "maps" - "path" - "slices" - "sync" - "sync/atomic" - "time" - - "github.com/Masterminds/squirrel" - ppl "github.com/google/go-pipeline/pkg/pipeline" - "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" - "github.com/navidrome/navidrome/model/metadata" - "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/pl" - "github.com/navidrome/navidrome/utils/slice" -) - -func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore) *phaseFolders { - var jobs []*scanJob - - // Create scan jobs for all libraries - for _, lib := range state.libraries { - // Get target folders for this library if selective scan - var targetFolders []string - if state.isSelectiveScan() { - targetFolders = state.targets[lib.ID] - } - - job, err := newScanJob(ctx, ds, lib, state.fullScan, targetFolders) - if err != nil { - log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err) - state.sendError(err) - state.markFailed(lib.ID) - continue - } - jobs = append(jobs, job) - } - - walkCtx, stopWalk := context.WithCancelCause(ctx) - return &phaseFolders{jobs: jobs, ctx: ctx, walkCtx: walkCtx, stopWalk: stopWalk, ds: ds, state: state, - imageChanges: &imageChangeCollector{ds: ds}} -} - -type scanJob struct { - lib model.Library - fs storage.MusicFS - lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library - targetFolders []string // Specific folders to scan (including all descendants) - prevAlbumPIDConf string // Album PID spec of the last finished scan, only when it differs from the current one - lock sync.Mutex - numFolders atomic.Int64 -} - -func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { - // Get folder updates, optionally filtered to specific target folders - lastUpdates, err := ds.Folder().GetFolderUpdateInfo(ctx, lib, targetFolders...) - if err != nil { - return nil, fmt.Errorf("getting last updates: %w", err) - } - - fileStore, err := storage.For(lib.Path) - if err != nil { - log.Error(ctx, "Error getting storage for library", "library", lib.Name, "path", lib.Path, err) - return nil, fmt.Errorf("getting storage for library: %w", err) - } - fsys, err := fileStore.FS() - if err != nil { - log.Error(ctx, "Error getting fs for library", "library", lib.Name, "path", lib.Path, err) - return nil, fmt.Errorf("getting fs for library: %w", err) - } - - pid := lib.EffectivePID() - if lib.NeedsPIDRescan() { - msg := "Scanner: PID config changed, rescanning library in full" - if len(targetFolders) > 0 { - msg = "Scanner: PID config changed, rescanning target folders in full" - } - log.Info(ctx, msg, "lib", lib.Name, "targetFolders", targetFolders, - "album", pid.Album, "track", pid.Track, "scannedAlbum", lib.ScannedPIDAlbum, "scannedTrack", lib.ScannedPIDTrack) - fullScan = true - } - var prevAlbumPIDConf string - if lib.ScannedPIDAlbum != pid.Album { - prevAlbumPIDConf = lib.ScannedPIDAlbum - } - - // Ensure FullScanInProgress reflects the current scan request. - // This is important when resuming an interrupted quick scan as a full scan: - // the DB may have FullScanInProgress=false, but we need it true for isOutdated() to work correctly. - lib.FullScanInProgress = lib.FullScanInProgress || fullScan - - return &scanJob{ - lib: lib, - fs: fsys, - lastUpdates: lastUpdates, - targetFolders: targetFolders, - prevAlbumPIDConf: prevAlbumPIDConf, - }, nil -} - -// popLastUpdate retrieves and removes the last update info for the given folder ID -// This is used to track which folders have been found during the walk_dir_tree -func (j *scanJob) popLastUpdate(folderID string) model.FolderUpdateInfo { - j.lock.Lock() - defer j.lock.Unlock() - - lastUpdate := j.lastUpdates[folderID] - delete(j.lastUpdates, folderID) - return lastUpdate -} - -// createFolderEntry creates a new folderEntry for the given path, using the last update info from the job -// to populate the previous update time and hash. It also removes the folder from the job's lastUpdates map. -// This is used to track which folders have been found during the walk_dir_tree. -func (j *scanJob) createFolderEntry(path string) *folderEntry { - id := model.FolderID(j.lib, path) - info := j.popLastUpdate(id) - return newFolderEntry(j, id, path, info) -} - -// phaseFolders represents the first phase of the scanning process, which is responsible -// for scanning all libraries and importing new or updated files. This phase involves -// traversing the directory tree of each library, identifying new or modified media files, -// and updating the database with the relevant information. -// -// The phaseFolders struct holds the context, data store, and jobs required for the scanning -// process. Each job represents a library being scanned, and contains information about the -// library, file system, and the last updates of the folders. -// -// The phaseFolders struct implements the phase interface, providing methods to produce -// folder entries, process folders, persist changes to the database, and log the results. -type phaseFolders struct { - jobs []*scanJob - ds model.DataStore - ctx context.Context //nolint:containedctx // phase runs under a single scan ctx - walkCtx context.Context //nolint:containedctx // cancelled when a folder fails to persist, so the walk stops early - stopWalk context.CancelCauseFunc - state *scanState - imageChanges *imageChangeCollector -} - -func (p *phaseFolders) description() string { - return "Scan all libraries and import new/updated files" -} - -func (p *phaseFolders) producer() ppl.Producer[*folderEntry] { - return ppl.NewProducer(func(put func(entry *folderEntry)) error { - // TODO Parallelize multiple job when we have multiple libraries - var total int64 - var totalChanged int64 - for _, job := range p.jobs { - if utils.IsCtxDone(p.walkCtx) { - break - } - - outputChan, err := walkDirTree(p.walkCtx, job, job.targetFolders...) - if err != nil { - log.Warn(p.ctx, "Scanner: Error scanning library", "lib", job.lib.Name, err) - } - for folder := range pl.ReadOrDone(p.walkCtx, outputChan) { - job.numFolders.Add(1) - p.state.sendProgress(&ProgressInfo{ - LibID: job.lib.ID, - FileCount: uint32(len(folder.audioFiles)), - Path: folder.path, - Phase: "1", - }) - - // Log folder info - log.Trace(p.ctx, "Scanner: Checking folder state", " folder", folder.path, "_updTime", folder.updTime, - "_modTime", folder.modTime, "_lastScanStartedAt", folder.job.lib.LastScanStartedAt, - "numAudioFiles", len(folder.audioFiles), "numImageFiles", len(folder.imageFiles), - "numPlaylists", len(folder.playlistFiles), "numSubfolders", folder.numSubFolders) - - // Check if folder is outdated - if folder.isOutdated() { - if !folder.job.lib.FullScanInProgress { - // Ancestor folders need a row even with no files of their own: artwork - // resolution climbs them, and an image added later needs a state to diff. - if folder.isEmpty() && folder.isNew() { - log.Trace(p.ctx, "Scanner: Skipping new empty folder", "folder", folder.path, "lib", job.lib.Name) - continue - } - log.Debug(p.ctx, "Scanner: Detected changes in folder", "folder", folder.path, "lastUpdate", folder.modTime, "lib", job.lib.Name) - } - totalChanged++ - folder.elapsed.Stop() - put(folder) - } else { - log.Trace(p.ctx, "Scanner: Skipping up-to-date folder", "folder", folder.path, "lastUpdate", folder.modTime, "lib", job.lib.Name) - } - } - total += job.numFolders.Load() - } - log.Debug(p.ctx, "Scanner: Finished loading all folders", "numFolders", total, "numChanged", totalChanged) - return nil - }, ppl.Name("traverse filesystem")) -} - -func (p *phaseFolders) measure(entry *folderEntry) func() time.Duration { - entry.elapsed.Start() - return func() time.Duration { return entry.elapsed.Stop() } -} - -func (p *phaseFolders) stages() []ppl.Stage[*folderEntry] { - return []ppl.Stage[*folderEntry]{ - ppl.NewStage(p.processFolder, ppl.Name("process folder"), ppl.Concurrency(conf.Server.DevScannerThreads)), - // persistChanges is not reentrant, so it always has to run with concurrency=1 - ppl.NewStage(p.persistChanges, ppl.Name("persist changes"), ppl.Concurrency(1)), - ppl.NewStage(p.logFolder, ppl.Name("log results")), - } -} - -func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) { - defer p.measure(entry)() - if err := context.Cause(p.walkCtx); err != nil { - return entry, err - } - - // Load children mediafiles from DB - cursor, err := p.ds.MediaFile().GetCursor(p.ctx, model.QueryOptions{ - Filters: squirrel.And{squirrel.Eq{"folder_id": entry.id}}, - }) - if err != nil { - log.Error(p.ctx, "Scanner: Error loading mediafiles from DB", "folder", entry.path, err) - return entry, err - } - dbTracks := make(map[string]*model.MediaFile) - for mf, err := range cursor { - if err != nil { - log.Error(p.ctx, "Scanner: Error loading mediafiles from DB", "folder", entry.path, err) - return entry, err - } - dbTracks[mf.Path] = &mf - } - - // Get list of files to import, based on modtime (or all if fullScan), - // leave in dbTracks only tracks that are missing (not found in the FS) - filesToImport := make(map[string]*model.MediaFile, len(entry.audioFiles)) - for afPath, af := range entry.audioFiles { - fullPath := path.Join(entry.path, afPath) - dbTrack, foundInDB := dbTracks[fullPath] - if !foundInDB || entry.job.lib.FullScanInProgress { - filesToImport[fullPath] = dbTrack - } else { - info, err := af.Info() - if err != nil { - log.Warn(p.ctx, "Scanner: Error getting file info", "folder", entry.path, "file", af.Name(), err) - p.state.sendWarning(fmt.Sprintf("Error getting file info for %s/%s: %v", entry.path, af.Name(), err)) - return entry, nil - } - if info.ModTime().After(dbTrack.UpdatedAt) || dbTrack.Missing { - filesToImport[fullPath] = dbTrack - } - } - delete(dbTracks, fullPath) - } - - // Remaining dbTracks are tracks that were not found in the FS, so they should be marked as missing - entry.missingTracks = slices.Collect(maps.Values(dbTracks)) - - // Load metadata from files that need to be imported - if len(filesToImport) > 0 { - err = p.loadTagsFromFiles(entry, filesToImport) - if err != nil { - log.Warn(p.ctx, "Scanner: Error loading tags from files. Skipping", "folder", entry.path, err) - p.state.sendWarning(fmt.Sprintf("Error loading tags from files in %s: %v", entry.path, err)) - return entry, nil - } - - p.createAlbumsFromMediaFiles(entry) - p.createArtistsFromMediaFiles(entry) - } - - return entry, nil -} - -const filesBatchSize = 200 - -// loadTagsFromFiles reads metadata from the files in the given list and populates -// the entry's tracks and tags with the results. -func (p *phaseFolders) loadTagsFromFiles(entry *folderEntry, toImport map[string]*model.MediaFile) error { - tracks := make([]model.MediaFile, 0, len(toImport)) - uniqueTags := make(map[string]model.Tag, len(toImport)) - for chunk := range slice.CollectChunks(maps.Keys(toImport), filesBatchSize) { - allInfo, err := entry.job.fs.ReadTags(chunk...) - if err != nil { - log.Warn(p.ctx, "Scanner: Error extracting metadata from files. Skipping", "folder", entry.path, err) - return err - } - for filePath, info := range allInfo { - md := metadata.New(filePath, info) - track := md.ToMediaFile(entry.job.lib, entry.id) - tracks = append(tracks, track) - for _, t := range track.Tags.FlattenAll() { - uniqueTags[t.ID] = t - } - - // Keep track of any album ID changes, to reassign annotations later - prevAlbumID := track.AlbumID - if prev := toImport[filePath]; prev != nil { - prevAlbumID = prev.AlbumID - } else if entry.job.prevAlbumPIDConf != "" { - prevAlbumID = md.AlbumID(track, entry.job.prevAlbumPIDConf) - } - _, ok := entry.albumIDMap[track.AlbumID] - if prevAlbumID != track.AlbumID && !ok { - entry.albumIDMap[track.AlbumID] = prevAlbumID - } - } - } - entry.tracks = tracks - entry.tags = slices.Collect(maps.Values(uniqueTags)) - return nil -} - -// createAlbumsFromMediaFiles groups the entry's tracks by album ID and creates albums -func (p *phaseFolders) createAlbumsFromMediaFiles(entry *folderEntry) { - grouped := slice.Group(entry.tracks, func(mf model.MediaFile) string { return mf.AlbumID }) - albums := make(model.Albums, 0, len(grouped)) - for _, group := range grouped { - songs := model.MediaFiles(group) - album := songs.ToAlbum() - albums = append(albums, album) - } - entry.albums = albums -} - -// createArtistsFromMediaFiles creates artists from the entry's tracks -func (p *phaseFolders) createArtistsFromMediaFiles(entry *folderEntry) { - participants := make(model.Participants, len(entry.tracks)*3) // preallocate ~3 artists per track - for _, track := range entry.tracks { - participants.Merge(track.Participants) - } - entry.artists = participants.AllArtists() -} - -func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error) { - defer p.measure(entry)() - p.state.changesDetected.Store(true) - - ctx := log.NewContext(p.ctx, "folder", entry.path) - err := p.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - return p.persistFolder(ctx, tx, entry) - }, "scanner: persist changes") - if err != nil { - log.Error(ctx, "Scanner: Error persisting changes to DB", err) - p.stopWalk(err) - return entry, err - } - - // A new folder's albums/artists were enqueued with it; only pre-existing folders need the diff. - if !entry.isNew() { - if changed, artistImage := entry.imagesChanged(); changed { - p.imageChanges.record(entry.job.lib, imageChangedFolder{ - id: entry.id, path: entry.path, artistImage: artistImage, - }) - } - } - return entry, nil -} - -// persistFolder writes the folder in tx. WithTxRetry may rerun it after a rollback. -func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, entry *folderEntry) error { - // Collect artwork queue items for changed albums/artists, enqueued in the same transaction - var queueItems []model.ArtworkQueueItem - // persistAlbum consumes the map, so a rerun needs the original - albumIDMap := maps.Clone(entry.albumIDMap) - - // Instantiate all repositories just once per folder - folderRepo := tx.Folder() - tagRepo := tx.Tag() - artistRepo := tx.Artist() - libraryRepo := tx.Library() - albumRepo := tx.Album() - mfRepo := tx.MediaFile() - - // Save folder to DB - folder := entry.toFolder() - err := folderRepo.Put(ctx, folder) - if err != nil { - return fmt.Errorf("persisting folder: %w", err) - } - - // Save all tags to DB - err = tagRepo.Add(ctx, entry.job.lib.ID, entry.tags...) - if err != nil { - return fmt.Errorf("persisting tags: %w", err) - } - - // Save all new/modified artists to DB. Their information will be incomplete, but they will be refreshed later - for i := range entry.artists { - err = artistRepo.Put(ctx, &entry.artists[i], "name", - "mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "search_normalized", "updated_at") - if err != nil { - return fmt.Errorf("persisting artist %q: %w", entry.artists[i].Name, err) - } - err = libraryRepo.AddArtist(ctx, entry.job.lib.ID, entry.artists[i].ID) - if err != nil { - return fmt.Errorf("adding artist %q to library: %w", entry.artists[i].Name, err) - } - if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists { - queueItems = append(queueItems, scanArtworkItem(model.KindArtistArtwork, entry.artists[i].ID)) - } - } - - // Save all new/modified albums to DB. Their information will be incomplete, but they will be refreshed later - for i := range entry.albums { - err = p.persistAlbum(albumRepo, &entry.albums[i], albumIDMap) - if err != nil { - return err - } - if entry.albums[i].Name != consts.UnknownAlbum { - queueItems = append(queueItems, scanArtworkItem(model.KindAlbumArtwork, entry.albums[i].ID)) - } - } - - // Save all tracks to DB - for i := range entry.tracks { - err = mfRepo.Put(ctx, &entry.tracks[i]) - if err != nil { - return fmt.Errorf("persisting track %q: %w", entry.tracks[i].Path, err) - } - } - - // A re-imported track returns to unresolved so new embedded art is picked up lazily. - if len(entry.tracks) > 0 { - trackIDs := slice.Map(entry.tracks, func(t model.MediaFile) string { return t.ID }) - if err := tx.Artwork().DeleteForItems(ctx, model.KindMediaFileArtwork, trackIDs); err != nil { - log.Warn(ctx, "Scanner: could not invalidate media_file artwork", err) - } - } - - // Mark all missing tracks as not available - if len(entry.missingTracks) > 0 { - err = mfRepo.MarkMissing(ctx, true, entry.missingTracks...) - if err != nil { - return fmt.Errorf("marking missing tracks: %w", err) - } - - // Touch all albums that have missing tracks, so they get refreshed in later phases - groupedMissingTracks := slice.ToMap(entry.missingTracks, func(mf *model.MediaFile) (string, struct{}) { - return mf.AlbumID, struct{}{} - }) - albumsToUpdate := slices.Collect(maps.Keys(groupedMissingTracks)) - err = albumRepo.Touch(ctx, albumsToUpdate...) - if err != nil { - return fmt.Errorf("touching albums %v: %w", albumsToUpdate, err) - } - } - - // Enqueue artwork resolution for changed albums/artists. Never fails the scan. - // A full scan re-imports every track, so a re-import is no evidence the art changed. - if len(queueItems) > 0 { - queue := tx.ArtworkQueue() - enqueue := queue.Enqueue - if entry.job.lib.FullScanInProgress { - enqueue = queue.EnqueueIfMissing - } - if err := enqueue(ctx, queueItems...); err != nil { - log.Warn(ctx, "Scanner: could not enqueue artwork resolution", err) - } - } - return nil -} - -// persistAlbum persists the given album to the database, and reassigns annotations from the previous album ID -func (p *phaseFolders) persistAlbum(repo model.AlbumRepository, a *model.Album, idMap map[string]string) error { - prevID := idMap[a.ID] - log.Trace(p.ctx, "Persisting album", "album", a.Name, "albumArtist", a.AlbumArtist, "id", a.ID, "prevID", cmp.Or(prevID, "nil")) - if err := repo.Put(p.ctx, a); err != nil { - return fmt.Errorf("persisting album %s: %w", a.ID, err) - } - if prevID == "" { - return nil - } - - // Reassign annotation from previous album to new album - log.Trace(p.ctx, "Reassigning album annotations", "from", prevID, "to", a.ID, "album", a.Name) - if err := repo.ReassignAnnotation(p.ctx, prevID, a.ID); err != nil { - log.Warn(p.ctx, "Scanner: Could not reassign annotations", "from", prevID, "to", a.ID, "album", a.Name, err) - p.state.sendWarning(fmt.Sprintf("Could not reassign annotations from %s to %s ('%s'): %v", prevID, a.ID, a.Name, err)) - } - - // Keep created_at field from previous instance of the album - if err := repo.CopyAttributes(p.ctx, prevID, a.ID, "created_at"); err != nil { - // Silently ignore when the previous album is not found - if !errors.Is(err, model.ErrNotFound) { - log.Warn(p.ctx, "Scanner: Could not copy fields", "from", prevID, "to", a.ID, "album", a.Name, err) - p.state.sendWarning(fmt.Sprintf("Could not copy fields from %s to %s ('%s'): %v", prevID, a.ID, a.Name, err)) - } - } - // Don't keep track of this mapping anymore - delete(idMap, a.ID) - return nil -} - -func (p *phaseFolders) logFolder(entry *folderEntry) (*folderEntry, error) { - logCall := log.Info - if entry.isEmpty() { - logCall = log.Trace - } - logCall(p.ctx, "Scanner: Completed processing folder", - "audioCount", len(entry.audioFiles), "imageCount", len(entry.imageFiles), "plsCount", len(entry.playlistFiles), - "elapsed", entry.elapsed.Elapsed(), "tracksMissing", len(entry.missingTracks), - "tracksImported", len(entry.tracks), "library", entry.job.lib.Name, consts.Zwsp+"folder", entry.path) - return entry, nil -} - -func (p *phaseFolders) finalize(err error) error { - p.stopWalk(nil) - defer p.imageChanges.enqueue(p.ctx) - // A failed phase may not have walked every folder, and unvisited ones must not be marked missing - if err != nil { - return err - } - return p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { - for _, job := range p.jobs { - // Mark all folders that were not updated as missing - if len(job.lastUpdates) == 0 { - continue - } - folderIDs := slices.Collect(maps.Keys(job.lastUpdates)) - if err := tx.Folder().MarkMissing(ctx, true, folderIDs...); err != nil { - return fmt.Errorf("marking missing folders in %s: %w", job.lib.Name, err) - } - if err := tx.MediaFile().MarkMissingByFolder(ctx, true, folderIDs...); err != nil { - return fmt.Errorf("marking tracks in missing folders in %s: %w", job.lib.Name, err) - } - // Touch all albums that have missing folders, so they get refreshed in later phases - if _, err := tx.Album().TouchByMissingFolder(ctx); err != nil { - return fmt.Errorf("touching albums with missing folders in %s: %w", job.lib.Name, err) - } - } - return nil - }, "scanner: finalize phaseFolders") -} - -var _ phase[*folderEntry] = (*phaseFolders)(nil) diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go deleted file mode 100644 index 945257e47..000000000 --- a/scanner/phase_2_missing_tracks.go +++ /dev/null @@ -1,381 +0,0 @@ -package scanner - -import ( - "context" - "errors" - "fmt" - "sync" - "sync/atomic" - - ppl "github.com/google/go-pipeline/pkg/pipeline" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -type missingTracks struct { - lib model.Library - pid string - missing model.MediaFiles - matched model.MediaFiles -} - -// phaseMissingTracks is responsible for processing missing media files during the scan process. -// It identifies media files that are marked as missing and attempts to find matching files that -// may have been moved or renamed. This phase helps in maintaining the integrity of the media -// library by ensuring that moved or renamed files are correctly updated in the database. -// -// The phaseMissingTracks phase performs the following steps: -// 1. Loads all libraries and their missing media files from the database. -// 2. For each library, it sorts the missing files by their PID (persistent identifier). -// 3. Groups missing and matched files by their PID and processes them to find exact or equivalent matches. -// 4. Updates the database with the new locations of the matched files and removes the old entries. -// 5. Logs the results and finalizes the phase by reporting the total number of matched files. -type phaseMissingTracks struct { - ctx context.Context //nolint:containedctx // phase runs under a single scan ctx - ds model.DataStore - totalMatched atomic.Uint32 - state *scanState - processedAlbumAnnotations map[string]bool // Track processed album annotation reassignments - annotationMutex sync.RWMutex // Protects processedAlbumAnnotations -} - -func createPhaseMissingTracks(ctx context.Context, state *scanState, ds model.DataStore) *phaseMissingTracks { - return &phaseMissingTracks{ - ctx: ctx, - ds: ds, - state: state, - processedAlbumAnnotations: make(map[string]bool), - } -} - -func (p *phaseMissingTracks) description() string { - return "Process missing files, checking for moves" -} - -func (p *phaseMissingTracks) producer() ppl.Producer[*missingTracks] { - return ppl.NewProducer(p.produce, ppl.Name("load missing tracks from db")) -} - -func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error { - count := 0 - var putIfMatched = func(mt missingTracks) { - if mt.pid != "" && len(mt.missing) > 0 { - log.Trace(p.ctx, "Scanner: Found missing tracks", "pid", mt.pid, "missing", "title", mt.missing[0].Title, - len(mt.missing), "matched", len(mt.matched), "lib", mt.lib.Name, - ) - count++ - put(&mt) - } - } - for _, lib := range p.state.libraries { - log.Debug(p.ctx, "Scanner: Checking missing tracks", "libraryId", lib.ID, "libraryName", lib.Name) - cursor, err := p.ds.MediaFile().GetMissingAndMatching(p.ctx, lib.ID) - if err != nil { - return fmt.Errorf("loading missing tracks for library %s: %w", lib.Name, err) - } - - // Group missing and matched tracks by PID - mt := missingTracks{lib: lib} - for mf, err := range cursor { - if err != nil { - return fmt.Errorf("loading missing tracks for library %s: %w", lib.Name, err) - } - if mt.pid != mf.PID { - putIfMatched(mt) - mt.pid = mf.PID - mt.missing = nil - mt.matched = nil - } - if mf.Missing { - mt.missing = append(mt.missing, mf) - } else { - mt.matched = append(mt.matched, mf) - } - } - putIfMatched(mt) - if count == 0 { - log.Debug(p.ctx, "Scanner: No potential moves found", "libraryId", lib.ID, "libraryName", lib.Name) - } else { - log.Debug(p.ctx, "Scanner: Found potential moves", "libraryId", lib.ID, "count", count) - } - } - - return nil -} - -func (p *phaseMissingTracks) stages() []ppl.Stage[*missingTracks] { - return []ppl.Stage[*missingTracks]{ - ppl.NewStage(p.processMissingTracks, ppl.Name("process missing tracks")), - ppl.NewStage(p.processCrossLibraryMoves, ppl.Name("process cross-library moves")), - } -} - -func (p *phaseMissingTracks) processMissingTracks(in *missingTracks) (*missingTracks, error) { - hasMatches := false - // Track which matched entries have already been consumed, so each matched track - // is only used once. Without this, the same matched track could be paired with - // multiple missing tracks, creating duplicate records with the same path. - usedMatched := make(map[string]bool, len(in.matched)) - - for _, ms := range in.missing { - var exactMatch model.MediaFile - var equivalentMatch model.MediaFile - - // Identify exact and equivalent matches - for _, mt := range in.matched { - if usedMatched[mt.ID] { - continue - } - if ms.Equals(mt) { - exactMatch = mt - break // Prioritize exact match - } - if ms.IsEquivalent(mt) { - equivalentMatch = mt - } - } - - // Use the exact match if found - if exactMatch.ID != "" { - log.Debug(p.ctx, "Scanner: Found missing track in a new place", "missing", ms.Path, "movedTo", exactMatch.Path, "lib", in.lib.Name) - err := p.moveMatched(exactMatch, ms) - if err != nil { - log.Error(p.ctx, "Scanner: Error moving matched track", "missing", ms.Path, "movedTo", exactMatch.Path, "lib", in.lib.Name, err) - return nil, err - } - usedMatched[exactMatch.ID] = true - p.totalMatched.Add(1) - hasMatches = true - continue - } - - // If there is only one missing and one matched track, consider them equivalent (same PID) - if len(in.missing) == 1 && len(in.matched) == 1 && !usedMatched[in.matched[0].ID] { - singleMatch := in.matched[0] - log.Debug(p.ctx, "Scanner: Found track with same persistent ID in a new place", "missing", ms.Path, "movedTo", singleMatch.Path, "lib", in.lib.Name) - err := p.moveMatched(singleMatch, ms) - if err != nil { - log.Error(p.ctx, "Scanner: Error updating matched track", "missing", ms.Path, "movedTo", singleMatch.Path, "lib", in.lib.Name, err) - return nil, err - } - usedMatched[singleMatch.ID] = true - p.totalMatched.Add(1) - hasMatches = true - continue - } - - // Use the equivalent match if no other better match was found - if equivalentMatch.ID != "" { - log.Debug(p.ctx, "Scanner: Found missing track with same base path", "missing", ms.Path, "movedTo", equivalentMatch.Path, "lib", in.lib.Name) - err := p.moveMatched(equivalentMatch, ms) - if err != nil { - log.Error(p.ctx, "Scanner: Error updating matched track", "missing", ms.Path, "movedTo", equivalentMatch.Path, "lib", in.lib.Name, err) - return nil, err - } - usedMatched[equivalentMatch.ID] = true - p.totalMatched.Add(1) - hasMatches = true - } - } - - // If any matches were found in this missingTracks group, return nil - // This signals the next stage to skip processing this group - if hasMatches { - return nil, nil - } - - // If no matches found, pass through to next stage - return in, nil -} - -// processCrossLibraryMoves processes files that weren't matched within their library -// and attempts to find matches in other libraries -func (p *phaseMissingTracks) processCrossLibraryMoves(in *missingTracks) (*missingTracks, error) { - // Skip if input is nil (meaning previous stage found matches) - if in == nil { - return nil, nil - } - - // Skip cross-library move detection when only one library is configured - // since there are no other libraries to search in. - if p.state.totalLibraryCount == 1 { - log.Debug(p.ctx, "Scanner: Skipping cross-library move detection (single library)") - return in, nil - } - - log.Debug(p.ctx, "Scanner: Processing cross-library moves", "pid", in.pid, "missing", len(in.missing), "lib", in.lib.Name) - - for _, missing := range in.missing { - found, err := p.findCrossLibraryMatch(missing) - if err != nil { - log.Error(p.ctx, "Scanner: Error searching for cross-library matches", "missing", missing.Path, "lib", in.lib.Name, err) - continue - } - - if found.ID != "" { - log.Debug(p.ctx, "Scanner: Found cross-library moved track", "missing", missing.Path, "movedTo", found.Path, "fromLib", in.lib.Name, "toLib", found.LibraryName) - err := p.moveMatched(found, missing) - if err != nil { - log.Error(p.ctx, "Scanner: Error moving cross-library track", "missing", missing.Path, "movedTo", found.Path, err) - continue - } - p.totalMatched.Add(1) - } - } - - return in, nil -} - -// findCrossLibraryMatch searches for a missing file in other libraries using two-tier matching -func (p *phaseMissingTracks) findCrossLibraryMatch(missing model.MediaFile) (model.MediaFile, error) { - // First tier: Search by MusicBrainz Track ID if available - if missing.MbzReleaseTrackID != "" { - matches, err := p.ds.MediaFile().FindRecentFilesByMBZTrackID(p.ctx, missing, missing.CreatedAt) - if err != nil { - log.Error(p.ctx, "Scanner: Error searching for recent files by MBZ Track ID", "mbzTrackID", missing.MbzReleaseTrackID, err) - } else { - // Apply the same matching logic as within-library matching - for _, match := range matches { - if missing.Equals(match) { - return match, nil // Exact match found - } - } - - // If only one match and it's equivalent, use it - if len(matches) == 1 && missing.IsEquivalent(matches[0]) { - return matches[0], nil - } - } - } - - // Second tier: Search by intrinsic properties (title, size, suffix, etc.) - matches, err := p.ds.MediaFile().FindRecentFilesByProperties(p.ctx, missing, missing.CreatedAt) - if err != nil { - log.Error(p.ctx, "Scanner: Error searching for recent files by properties", "missing", missing.Path, err) - return model.MediaFile{}, err - } - - // Apply the same matching logic as within-library matching - for _, match := range matches { - if missing.Equals(match) { - return match, nil // Exact match found - } - } - - // If only one match and it's equivalent, use it - if len(matches) == 1 && missing.IsEquivalent(matches[0]) { - return matches[0], nil - } - - return model.MediaFile{}, nil -} - -func (p *phaseMissingTracks) moveMatched(target, missing model.MediaFile) error { - oldAlbumID := missing.AlbumID - newAlbumID := target.AlbumID - // Use newAlbumID as key since we only care about avoiding duplicate reassignments to the same target. - // Claimed before the transaction so a concurrent move skips it, and released if the move fails. - reassignAlbum := oldAlbumID != newAlbumID - if reassignAlbum { - p.annotationMutex.Lock() - reassignAlbum = !p.processedAlbumAnnotations[newAlbumID] - p.processedAlbumAnnotations[newAlbumID] = true - p.annotationMutex.Unlock() - if !reassignAlbum { - log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID) - } - } - - err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { - // A rerun must start from the original target, not the one the rolled-back attempt changed - moved := target - // Preserve the original created_at from the missing file, so moved tracks - // don't appear in "Recently Added" - moved.CreatedAt = missing.CreatedAt - - // Update the target media file with the missing file's ID. This effectively "moves" the track - // to the new location while keeping its annotations and references intact. - moved.ID = missing.ID - if err := tx.MediaFile().Put(ctx, &moved); err != nil { - return fmt.Errorf("update matched track: %w", err) - } - - // Discard the new mediafile row (the one that was moved to) - if err := tx.MediaFile().Delete(ctx, target.ID); err != nil { - return fmt.Errorf("delete discarded track: %w", err) - } - - if reassignAlbum { - // Reassign direct album annotations (starred, rating) - log.Debug(ctx, "Scanner: Reassigning album annotations", "from", oldAlbumID, "to", newAlbumID) - if err := tx.Album().ReassignAnnotation(ctx, oldAlbumID, newAlbumID); err != nil { - log.Warn(ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err) - } - - // Keep created_at field from previous instance of the album, so moved albums - // don't appear in "Recently Added" - if err := tx.Album().CopyAttributes(ctx, oldAlbumID, newAlbumID, "created_at"); err != nil { - if !errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Scanner: Could not copy album created_at", "from", oldAlbumID, "to", newAlbumID, err) - } - } - // Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here - } - return nil - }, "scanner: move matched track") - if err != nil { - if reassignAlbum { - p.annotationMutex.Lock() - delete(p.processedAlbumAnnotations, newAlbumID) - p.annotationMutex.Unlock() - } - return err - } - p.state.changesDetected.Store(true) - return nil -} - -func (p *phaseMissingTracks) finalize(err error) error { - matched := p.totalMatched.Load() - if matched > 0 { - log.Info(p.ctx, "Scanner: Found moved files", "total", matched, err) - } - if err != nil { - return err - } - - // Check if we should purge missing items - if conf.Server.Scanner.PurgeMissing == consts.PurgeMissingAlways || (conf.Server.Scanner.PurgeMissing == consts.PurgeMissingFull && p.state.fullScan) { - if err = p.purgeMissing(); err != nil { - log.Error(p.ctx, "Scanner: Error purging missing items", err) - } - } - - return err -} - -func (p *phaseMissingTracks) purgeMissing() error { - var deletedCount int64 - err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { - var err error - deletedCount, err = tx.MediaFile().DeleteAllMissing(ctx) - return err - }, "scanner: purge missing") - if err != nil { - return fmt.Errorf("error deleting missing files: %w", err) - } - - if deletedCount > 0 { - log.Info(p.ctx, "Scanner: Purged missing items from the database", "mediaFiles", deletedCount) - // Set changesDetected to true so that garbage collection will run at the end of the scan process - p.state.changesDetected.Store(true) - } else { - log.Debug(p.ctx, "Scanner: No missing items to purge") - } - - return nil -} - -var _ phase[*missingTracks] = (*phaseMissingTracks)(nil) diff --git a/scanner/phase_2_missing_tracks_test.go b/scanner/phase_2_missing_tracks_test.go deleted file mode 100644 index f61aa4244..000000000 --- a/scanner/phase_2_missing_tracks_test.go +++ /dev/null @@ -1,1073 +0,0 @@ -package scanner - -import ( - "context" - "errors" - "maps" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("phaseMissingTracks", func() { - var ( - phase *phaseMissingTracks - ctx context.Context - ds model.DataStore - mr *tests.MockMediaFileRepo - lr *tests.MockLibraryRepo - state *scanState - ) - - BeforeEach(func() { - ctx = context.Background() - mr = tests.CreateMockMediaFileRepo() - lr = &tests.MockLibraryRepo{} - lr.SetData(model.Libraries{{ID: 1, LastScanStartedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)}}) - ds = &tests.MockDataStore{MockedMediaFile: mr, MockedLibrary: lr} - state = &scanState{ - libraries: model.Libraries{{ID: 1, LastScanStartedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)}}, - totalLibraryCount: 1, - } - phase = createPhaseMissingTracks(ctx, state, ds) - }) - - Describe("produceMissingTracks", func() { - var ( - put func(tracks *missingTracks) - produced []*missingTracks - ) - - BeforeEach(func() { - produced = nil - put = func(tracks *missingTracks) { - produced = append(produced, tracks) - } - }) - - When("there are no missing tracks", func() { - It("should not call put", func() { - mr.SetData(model.MediaFiles{ - {ID: "1", PID: "A", Missing: false}, - {ID: "2", PID: "A", Missing: false}, - }) - - err := phase.produce(put) - Expect(err).ToNot(HaveOccurred()) - Expect(produced).To(BeEmpty()) - }) - }) - - When("there are missing tracks", func() { - It("should call put for any missing tracks with corresponding matches", func() { - mr.SetData(model.MediaFiles{ - {ID: "1", PID: "A", Missing: true, LibraryID: 1}, - {ID: "2", PID: "B", Missing: true, LibraryID: 1}, - {ID: "3", PID: "A", Missing: false, LibraryID: 1}, - }) - - err := phase.produce(put) - Expect(err).ToNot(HaveOccurred()) - Expect(produced).To(HaveLen(2)) - // PID A should have both missing and matched tracks - var pidA *missingTracks - for _, p := range produced { - if p.pid == "A" { - pidA = p - break - } - } - Expect(pidA).ToNot(BeNil()) - Expect(pidA.missing).To(HaveLen(1)) - Expect(pidA.matched).To(HaveLen(1)) - // PID B should have only missing tracks - var pidB *missingTracks - for _, p := range produced { - if p.pid == "B" { - pidB = p - break - } - } - Expect(pidB).ToNot(BeNil()) - Expect(pidB.missing).To(HaveLen(1)) - Expect(pidB.matched).To(HaveLen(0)) - }) - It("should call put for any missing tracks even without matches", func() { - mr.SetData(model.MediaFiles{ - {ID: "1", PID: "A", Missing: true, LibraryID: 1}, - {ID: "2", PID: "B", Missing: true, LibraryID: 1}, - {ID: "3", PID: "C", Missing: false, LibraryID: 1}, - }) - - err := phase.produce(put) - Expect(err).ToNot(HaveOccurred()) - Expect(produced).To(HaveLen(2)) - // Both PID A and PID B should be produced even without matches - var pidA, pidB *missingTracks - for _, p := range produced { - if p.pid == "A" { - pidA = p - } else if p.pid == "B" { - pidB = p - } - } - Expect(pidA).ToNot(BeNil()) - Expect(pidA.missing).To(HaveLen(1)) - Expect(pidA.matched).To(HaveLen(0)) - Expect(pidB).ToNot(BeNil()) - Expect(pidB.missing).To(HaveLen(1)) - Expect(pidB.matched).To(HaveLen(0)) - }) - }) - }) - - Describe("processMissingTracks", func() { - It("should move the matched track when the missing track is the exact same", func() { - missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) - - in := &missingTracks{ - missing: []model.MediaFile{missingTrack}, - matched: []model.MediaFile{matchedTrack}, - } - - _, err := phase.processMissingTracks(in) - Expect(err).ToNot(HaveOccurred()) - Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) - Expect(state.changesDetected.Load()).To(BeTrue()) - - movedTrack, _ := ds.MediaFile().Get(ctx, "1") - Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) - }) - - Context("claiming the album annotation reassignment", func() { - var probe *probeTxDS - missingTrack := model.MediaFile{ID: "1", PID: "A", AlbumID: "old-album", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - matchedTrack := model.MediaFile{ID: "2", PID: "A", AlbumID: "new-album", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - BeforeEach(func() { - probe = &probeTxDS{MockDataStore: ds.(*tests.MockDataStore)} - probe.MockedAlbum = tests.CreateMockAlbumRepo() - phase = createPhaseMissingTracks(ctx, state, probe) - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) - }) - - It("claims the target album before the transaction, so a concurrent move skips it", func() { - probe.during = func() { - phase.annotationMutex.RLock() - defer phase.annotationMutex.RUnlock() - Expect(phase.processedAlbumAnnotations).To(HaveKeyWithValue("new-album", true)) - } - Expect(phase.moveMatched(matchedTrack, missingTrack)).To(Succeed()) - }) - - It("releases the claim when the move fails, so a later move can reassign", func() { - probe.err = errors.New("boom") - Expect(phase.moveMatched(matchedTrack, missingTrack)).To(MatchError("boom")) - Expect(phase.processedAlbumAnnotations).ToNot(HaveKey("new-album")) - }) - }) - - Context("when the move transaction is rerun after a busy rollback", func() { - var rerunDS *rerunTxDS - BeforeEach(func() { - rerunDS = &rerunTxDS{MockDataStore: ds.(*tests.MockDataStore)} - rerunDS.snapshot = func() func() { - saved := maps.Clone(mr.Data) - return func() { mr.Data = saved } - } - phase = createPhaseMissingTracks(ctx, state, rerunDS) - }) - - It("keeps the moved track", func() { - missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) - - _, err := phase.processMissingTracks(&missingTracks{ - missing: []model.MediaFile{missingTrack}, - matched: []model.MediaFile{matchedTrack}, - }) - Expect(err).ToNot(HaveOccurred()) - - movedTrack, err := ds.MediaFile().Get(ctx, "1") - Expect(err).ToNot(HaveOccurred()) - Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) - }) - - It("reassigns the album annotations in the attempt that commits", func() { - albumRepo := tests.CreateMockAlbumRepo() - rerunDS.MockedAlbum = albumRepo - restoreTracks := rerunDS.snapshot - rerunDS.snapshot = func() func() { - restore := restoreTracks() - return func() { - restore() - albumRepo.ReassignAnnotationCalls = nil - } - } - missingTrack := model.MediaFile{ID: "1", PID: "A", AlbumID: "old-album", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - matchedTrack := model.MediaFile{ID: "2", PID: "A", AlbumID: "new-album", Path: "dir2/path2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) - - _, err := phase.processMissingTracks(&missingTracks{ - missing: []model.MediaFile{missingTrack}, - matched: []model.MediaFile{matchedTrack}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue("old-album", "new-album")) - }) - }) - - It("should move the matched track when the missing track has the same tags and filename", func() { - missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "path1.flac", Tags: model.Tags{"title": []string{"title1"}}, Size: 200} - - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) - - in := &missingTracks{ - missing: []model.MediaFile{missingTrack}, - matched: []model.MediaFile{matchedTrack}, - } - - _, err := phase.processMissingTracks(in) - Expect(err).ToNot(HaveOccurred()) - Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) - Expect(state.changesDetected.Load()).To(BeTrue()) - - movedTrack, _ := ds.MediaFile().Get(ctx, "1") - Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) - Expect(movedTrack.Size).To(Equal(matchedTrack.Size)) - }) - - It("should move the matched track when there's only one missing track and one matched track (same PID)", func() { - missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/path1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "dir2/path2.flac", Tags: model.Tags{"title": []string{"different title"}}, Size: 200} - - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) - - in := &missingTracks{ - missing: []model.MediaFile{missingTrack}, - matched: []model.MediaFile{matchedTrack}, - } - - _, err := phase.processMissingTracks(in) - Expect(err).ToNot(HaveOccurred()) - Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) - Expect(state.changesDetected.Load()).To(BeTrue()) - - movedTrack, _ := ds.MediaFile().Get(ctx, "1") - Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) - Expect(movedTrack.Size).To(Equal(matchedTrack.Size)) - }) - - It("should prioritize exact matches", func() { - missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/file1.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - matchedEquivalent := model.MediaFile{ID: "2", PID: "A", Path: "dir1/file1.flac", Tags: model.Tags{"title": []string{"title1"}}, Size: 200} - matchedExact := model.MediaFile{ID: "3", PID: "A", Path: "dir2/file2.mp3", Tags: model.Tags{"title": []string{"title1"}}, Size: 100} - - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedEquivalent) - _ = ds.MediaFile().Put(ctx, &matchedExact) - - in := &missingTracks{ - missing: []model.MediaFile{missingTrack}, - // Note that equivalent comes before the exact match - matched: []model.MediaFile{matchedEquivalent, matchedExact}, - } - - _, err := phase.processMissingTracks(in) - Expect(err).ToNot(HaveOccurred()) - Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) - Expect(state.changesDetected.Load()).To(BeTrue()) - - movedTrack, _ := ds.MediaFile().Get(ctx, "1") - Expect(movedTrack.Path).To(Equal(matchedExact.Path)) - Expect(movedTrack.Size).To(Equal(matchedExact.Size)) - }) - - It("should not move anything if there's more than one match and they don't are not exact nor equivalent", func() { - missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "dir1/file1.mp3", Title: "title1", Size: 100} - matched1 := model.MediaFile{ID: "2", PID: "A", Path: "dir1/file2.flac", Title: "another title", Size: 200} - matched2 := model.MediaFile{ID: "3", PID: "A", Path: "dir2/file3.mp3", Title: "different title", Size: 100} - - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matched1) - _ = ds.MediaFile().Put(ctx, &matched2) - - in := &missingTracks{ - missing: []model.MediaFile{missingTrack}, - matched: []model.MediaFile{matched1, matched2}, - } - - _, err := phase.processMissingTracks(in) - Expect(err).ToNot(HaveOccurred()) - Expect(phase.totalMatched.Load()).To(Equal(uint32(0))) - Expect(state.changesDetected.Load()).To(BeFalse()) - - // The missing track should still be the same - movedTrack, _ := ds.MediaFile().Get(ctx, "1") - Expect(movedTrack.Path).To(Equal(missingTrack.Path)) - Expect(movedTrack.Title).To(Equal(missingTrack.Title)) - Expect(movedTrack.Size).To(Equal(missingTrack.Size)) - }) - - It("should not match the same target to multiple missing tracks (prevents duplicate paths)", func() { - // Simulate a scenario where two missing tracks from different locations have the same - // base filename and match the same newly imported track via IsEquivalent. - // Without deduplication, both missing tracks would be "moved" to the same target, - // creating two non-missing records with the same path. - missingTrack1 := model.MediaFile{ID: "1", PID: "A", Path: "old_dir1/song.mp3", Title: "title1", Size: 100} - missingTrack2 := model.MediaFile{ID: "2", PID: "A", Path: "old_dir2/song.mp3", Title: "title1", Size: 100} - matchedTrack := model.MediaFile{ID: "3", PID: "A", Path: "new_dir/song.mp3", Title: "title1", Size: 200} - - _ = ds.MediaFile().Put(ctx, &missingTrack1) - _ = ds.MediaFile().Put(ctx, &missingTrack2) - _ = ds.MediaFile().Put(ctx, &matchedTrack) - - in := &missingTracks{ - missing: []model.MediaFile{missingTrack1, missingTrack2}, - matched: []model.MediaFile{matchedTrack}, - } - - _, err := phase.processMissingTracks(in) - Expect(err).ToNot(HaveOccurred()) - // Only one of the missing tracks should be matched - Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) - Expect(state.changesDetected.Load()).To(BeTrue()) - - // The matched track should have been consumed by the first missing track - movedTrack, _ := ds.MediaFile().Get(ctx, "1") - Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) - - // The second missing track should remain unchanged - unmatchedTrack, _ := ds.MediaFile().Get(ctx, "2") - Expect(unmatchedTrack.Path).To(Equal(missingTrack2.Path)) - }) - - It("should return an error when there's an error moving the matched track", func() { - missingTrack := model.MediaFile{ID: "1", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}} - matchedTrack := model.MediaFile{ID: "2", PID: "A", Path: "path1.mp3", Tags: model.Tags{"title": []string{"title1"}}} - - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) - - in := &missingTracks{ - missing: []model.MediaFile{missingTrack}, - matched: []model.MediaFile{matchedTrack}, - } - - // Simulate an error when moving the matched track by deleting the track from the DB - _ = ds.MediaFile().Delete(ctx, "2") - - _, err := phase.processMissingTracks(in) - Expect(err).To(HaveOccurred()) - Expect(state.changesDetected.Load()).To(BeFalse()) - }) - }) - - Describe("finalize", func() { - It("should return nil if no error", func() { - err := phase.finalize(nil) - Expect(err).To(BeNil()) - Expect(state.changesDetected.Load()).To(BeFalse()) - }) - - It("should return the error if provided", func() { - err := phase.finalize(context.DeadlineExceeded) - Expect(err).To(Equal(context.DeadlineExceeded)) - Expect(state.changesDetected.Load()).To(BeFalse()) - }) - - When("PurgeMissing is 'always'", func() { - BeforeEach(func() { - conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways - mr.SetCountAll(3) - mr.DeleteAllMissingValue = 3 - }) - It("should purge missing files", func() { - Expect(state.changesDetected.Load()).To(BeFalse()) - err := phase.finalize(nil) - Expect(err).To(BeNil()) - Expect(state.changesDetected.Load()).To(BeTrue()) - }) - }) - - When("PurgeMissing is 'full'", func() { - BeforeEach(func() { - conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull - mr.SetCountAll(2) - mr.DeleteAllMissingValue = 2 - }) - It("should not purge missing files if not a full scan", func() { - state.fullScan = false - err := phase.finalize(nil) - Expect(err).To(BeNil()) - Expect(state.changesDetected.Load()).To(BeFalse()) - }) - It("should purge missing files if full scan", func() { - Expect(state.changesDetected.Load()).To(BeFalse()) - state.fullScan = true - err := phase.finalize(nil) - Expect(err).To(BeNil()) - Expect(state.changesDetected.Load()).To(BeTrue()) - }) - }) - - When("PurgeMissing is 'never'", func() { - BeforeEach(func() { - conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever - mr.SetCountAll(1) - mr.DeleteAllMissingValue = 1 - }) - It("should not purge missing files", func() { - err := phase.finalize(nil) - Expect(err).To(BeNil()) - Expect(state.changesDetected.Load()).To(BeFalse()) - }) - }) - }) - - Describe("processCrossLibraryMoves", func() { - It("should skip processing if input is nil", func() { - result, err := phase.processCrossLibraryMoves(nil) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(BeNil()) - }) - - It("should skip cross-library move detection when only one library is configured", func() { - // Default BeforeEach sets up single library, so we just need to verify skip behavior - Expect(state.totalLibraryCount).To(Equal(1)) - - missingTrack := model.MediaFile{ - ID: "missing1", - LibraryID: 1, - MbzReleaseTrackID: "mbz-track-123", - Title: "Test Track", - Size: 1000, - Suffix: "mp3", - Path: "/lib1/track.mp3", - Missing: true, - CreatedAt: time.Now().Add(-30 * time.Minute), - } - - in := &missingTracks{ - lib: model.Library{ID: 1, Name: "Library 1"}, - missing: []model.MediaFile{missingTrack}, - } - - result, err := phase.processCrossLibraryMoves(in) - Expect(err).ToNot(HaveOccurred()) - // Should return input unchanged (no processing done) - Expect(result).To(Equal(in)) - // No matches should be found since cross-library search was skipped - Expect(phase.totalMatched.Load()).To(Equal(uint32(0))) - // No changes should be detected - Expect(state.changesDetected.Load()).To(BeFalse()) - }) - - Context("with multiple libraries", func() { - BeforeEach(func() { - // Set up multiple libraries for cross-library move tests - state.libraries = model.Libraries{ - {ID: 1, LastScanStartedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)}, - {ID: 2, LastScanStartedAt: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)}, - } - state.totalLibraryCount = 2 - }) - - It("should process cross-library moves using MusicBrainz Track ID", func() { - scanStartTime := time.Now().Add(-1 * time.Hour) - missingTrack := model.MediaFile{ - ID: "missing1", - LibraryID: 1, - MbzReleaseTrackID: "mbz-track-123", - Title: "Test Track", - Size: 1000, - Suffix: "mp3", - Path: "/lib1/track.mp3", - Missing: true, - CreatedAt: scanStartTime.Add(-30 * time.Minute), - } - - movedTrack := model.MediaFile{ - ID: "moved1", - LibraryID: 2, - MbzReleaseTrackID: "mbz-track-123", - Title: "Test Track", - Size: 1000, - Suffix: "mp3", - Path: "/lib2/track.mp3", - Missing: false, - CreatedAt: scanStartTime.Add(-10 * time.Minute), - } - - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &movedTrack) - - in := &missingTracks{ - lib: model.Library{ID: 1, Name: "Library 1"}, - missing: []model.MediaFile{missingTrack}, - } - - result, err := phase.processCrossLibraryMoves(in) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(Equal(in)) - Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) - Expect(state.changesDetected.Load()).To(BeTrue()) - - // Verify the move was performed - updatedTrack, _ := ds.MediaFile().Get(ctx, "missing1") - Expect(updatedTrack.Path).To(Equal("/lib2/track.mp3")) - Expect(updatedTrack.LibraryID).To(Equal(2)) - }) - - It("should fall back to intrinsic properties when MBZ Track ID is empty", func() { - scanStartTime := time.Now().Add(-1 * time.Hour) - missingTrack := model.MediaFile{ - ID: "missing2", - LibraryID: 1, - MbzReleaseTrackID: "", - Title: "Test Track 2", - Size: 2000, - Suffix: "flac", - DiscNumber: 1, - TrackNumber: 1, - Album: "Test Album", - Path: "/lib1/track2.flac", - Missing: true, - CreatedAt: scanStartTime.Add(-30 * time.Minute), - } - - movedTrack := model.MediaFile{ - ID: "moved2", - LibraryID: 2, - MbzReleaseTrackID: "", - Title: "Test Track 2", - Size: 2000, - Suffix: "flac", - DiscNumber: 1, - TrackNumber: 1, - Album: "Test Album", - Path: "/lib2/track2.flac", - Missing: false, - CreatedAt: scanStartTime.Add(-10 * time.Minute), - } - - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &movedTrack) - - in := &missingTracks{ - lib: model.Library{ID: 1, Name: "Library 1"}, - missing: []model.MediaFile{missingTrack}, - } - - result, err := phase.processCrossLibraryMoves(in) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(Equal(in)) - Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) - Expect(state.changesDetected.Load()).To(BeTrue()) - - // Verify the move was performed - updatedTrack, _ := ds.MediaFile().Get(ctx, "missing2") - Expect(updatedTrack.Path).To(Equal("/lib2/track2.flac")) - Expect(updatedTrack.LibraryID).To(Equal(2)) - }) - - It("should not match files in the same library", func() { - scanStartTime := time.Now().Add(-1 * time.Hour) - missingTrack := model.MediaFile{ - ID: "missing3", - LibraryID: 1, - MbzReleaseTrackID: "mbz-track-456", - Title: "Test Track 3", - Size: 3000, - Suffix: "mp3", - Path: "/lib1/track3.mp3", - Missing: true, - CreatedAt: scanStartTime.Add(-30 * time.Minute), - } - - sameLibTrack := model.MediaFile{ - ID: "same1", - LibraryID: 1, // Same library - MbzReleaseTrackID: "mbz-track-456", - Title: "Test Track 3", - Size: 3000, - Suffix: "mp3", - Path: "/lib1/other/track3.mp3", - Missing: false, - CreatedAt: scanStartTime.Add(-10 * time.Minute), - } - - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &sameLibTrack) - - in := &missingTracks{ - lib: model.Library{ID: 1, Name: "Library 1"}, - missing: []model.MediaFile{missingTrack}, - } - - result, err := phase.processCrossLibraryMoves(in) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(Equal(in)) - Expect(phase.totalMatched.Load()).To(Equal(uint32(0))) - Expect(state.changesDetected.Load()).To(BeFalse()) - }) - - It("should prioritize MBZ Track ID over intrinsic properties", func() { - scanStartTime := time.Now().Add(-1 * time.Hour) - missingTrack := model.MediaFile{ - ID: "missing4", - LibraryID: 1, - MbzReleaseTrackID: "mbz-track-789", - Title: "Test Track 4", - Size: 4000, - Suffix: "mp3", - Path: "/lib1/track4.mp3", - Missing: true, - CreatedAt: scanStartTime.Add(-30 * time.Minute), - } - - // Track with same MBZ ID - mbzTrack := model.MediaFile{ - ID: "mbz1", - LibraryID: 2, - MbzReleaseTrackID: "mbz-track-789", - Title: "Test Track 4", - Size: 4000, - Suffix: "mp3", - Path: "/lib2/track4.mp3", - Missing: false, - CreatedAt: scanStartTime.Add(-10 * time.Minute), - } - - // Track with same intrinsic properties but no MBZ ID - intrinsicTrack := model.MediaFile{ - ID: "intrinsic1", - LibraryID: 3, - MbzReleaseTrackID: "", - Title: "Test Track 4", - Size: 4000, - Suffix: "mp3", - DiscNumber: 1, - TrackNumber: 1, - Album: "Test Album", - Path: "/lib3/track4.mp3", - Missing: false, - CreatedAt: scanStartTime.Add(-5 * time.Minute), - } - - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &mbzTrack) - _ = ds.MediaFile().Put(ctx, &intrinsicTrack) - - in := &missingTracks{ - lib: model.Library{ID: 1, Name: "Library 1"}, - missing: []model.MediaFile{missingTrack}, - } - - result, err := phase.processCrossLibraryMoves(in) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(Equal(in)) - Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) - Expect(state.changesDetected.Load()).To(BeTrue()) - - // Verify the MBZ track was chosen (not the intrinsic one) - updatedTrack, _ := ds.MediaFile().Get(ctx, "missing4") - Expect(updatedTrack.Path).To(Equal("/lib2/track4.mp3")) - Expect(updatedTrack.LibraryID).To(Equal(2)) - }) - - It("should handle equivalent matches correctly", func() { - scanStartTime := time.Now().Add(-1 * time.Hour) - missingTrack := model.MediaFile{ - ID: "missing5", - LibraryID: 1, - MbzReleaseTrackID: "", - Title: "Test Track 5", - Size: 5000, - Suffix: "mp3", - Path: "/lib1/path/track5.mp3", - Missing: true, - CreatedAt: scanStartTime.Add(-30 * time.Minute), - } - - // Equivalent match (same filename, different directory) - equivalentTrack := model.MediaFile{ - ID: "equiv1", - LibraryID: 2, - MbzReleaseTrackID: "", - Title: "Test Track 5", - Size: 5000, - Suffix: "mp3", - Path: "/lib2/different/track5.mp3", - Missing: false, - CreatedAt: scanStartTime.Add(-10 * time.Minute), - } - - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &equivalentTrack) - - in := &missingTracks{ - lib: model.Library{ID: 1, Name: "Library 1"}, - missing: []model.MediaFile{missingTrack}, - } - - result, err := phase.processCrossLibraryMoves(in) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(Equal(in)) - Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) - Expect(state.changesDetected.Load()).To(BeTrue()) - - // Verify the equivalent match was accepted - updatedTrack, _ := ds.MediaFile().Get(ctx, "missing5") - Expect(updatedTrack.Path).To(Equal("/lib2/different/track5.mp3")) - Expect(updatedTrack.LibraryID).To(Equal(2)) - }) - - It("should skip matching when multiple matches are found but none are exact", func() { - scanStartTime := time.Now().Add(-1 * time.Hour) - missingTrack := model.MediaFile{ - ID: "missing6", - LibraryID: 1, - MbzReleaseTrackID: "", - Title: "Test Track 6", - Size: 6000, - Suffix: "mp3", - DiscNumber: 1, - TrackNumber: 1, - Album: "Test Album", - Path: "/lib1/track6.mp3", - Missing: true, - CreatedAt: scanStartTime.Add(-30 * time.Minute), - } - - // Multiple matches with different metadata (not exact matches) - match1 := model.MediaFile{ - ID: "match1", - LibraryID: 2, - MbzReleaseTrackID: "", - Title: "Test Track 6", - Size: 6000, - Suffix: "mp3", - DiscNumber: 1, - TrackNumber: 1, - Album: "Test Album", - Path: "/lib2/different_track.mp3", - Artist: "Different Artist", // This makes it non-exact - Missing: false, - CreatedAt: scanStartTime.Add(-10 * time.Minute), - } - - match2 := model.MediaFile{ - ID: "match2", - LibraryID: 3, - MbzReleaseTrackID: "", - Title: "Test Track 6", - Size: 6000, - Suffix: "mp3", - DiscNumber: 1, - TrackNumber: 1, - Album: "Test Album", - Path: "/lib3/another_track.mp3", - Artist: "Another Artist", // This makes it non-exact - Missing: false, - CreatedAt: scanStartTime.Add(-5 * time.Minute), - } - - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &match1) - _ = ds.MediaFile().Put(ctx, &match2) - - in := &missingTracks{ - lib: model.Library{ID: 1, Name: "Library 1"}, - missing: []model.MediaFile{missingTrack}, - } - - result, err := phase.processCrossLibraryMoves(in) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(Equal(in)) - Expect(phase.totalMatched.Load()).To(Equal(uint32(0))) - Expect(state.changesDetected.Load()).To(BeFalse()) - - // Verify no move was performed - unchangedTrack, _ := ds.MediaFile().Get(ctx, "missing6") - Expect(unchangedTrack.Path).To(Equal("/lib1/track6.mp3")) - Expect(unchangedTrack.LibraryID).To(Equal(1)) - }) - - It("should handle errors gracefully", func() { - // Set up mock to return error - mr.Err = true - - missingTrack := model.MediaFile{ - ID: "missing7", - LibraryID: 1, - MbzReleaseTrackID: "mbz-track-error", - Title: "Test Track 7", - Size: 7000, - Suffix: "mp3", - Path: "/lib1/track7.mp3", - Missing: true, - CreatedAt: time.Now().Add(-30 * time.Minute), - } - - in := &missingTracks{ - lib: model.Library{ID: 1, Name: "Library 1"}, - missing: []model.MediaFile{missingTrack}, - } - - // Should not fail completely, just skip the problematic file - result, err := phase.processCrossLibraryMoves(in) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(Equal(in)) - Expect(phase.totalMatched.Load()).To(Equal(uint32(0))) - Expect(state.changesDetected.Load()).To(BeFalse()) - }) - }) // End of Context "with multiple libraries" - }) - - Describe("CreatedAt preservation (#5050)", func() { - var albumRepo *tests.MockAlbumRepo - - BeforeEach(func() { - albumRepo = ds.Album().(*tests.MockAlbumRepo) - albumRepo.ReassignAnnotationCalls = make(map[string]string) - albumRepo.CopyAttributesCalls = make(map[string]string) - }) - - It("should preserve the missing track's created_at when moving within a library", func() { - originalTime := time.Date(2020, 3, 15, 10, 0, 0, 0, time.UTC) - missingTrack := model.MediaFile{ - ID: "1", PID: "A", Path: "old/song.mp3", - AlbumID: "album-1", - LibraryID: 1, - CreatedAt: originalTime, - Tags: model.Tags{"title": []string{"My Song"}}, - Size: 100, - } - matchedTrack := model.MediaFile{ - ID: "2", PID: "A", Path: "new/song.mp3", - AlbumID: "album-1", // Same album - LibraryID: 1, - CreatedAt: time.Now(), // Much newer - Tags: model.Tags{"title": []string{"My Song"}}, - Size: 100, - } - - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) - - in := &missingTracks{ - missing: []model.MediaFile{missingTrack}, - matched: []model.MediaFile{matchedTrack}, - } - - _, err := phase.processMissingTracks(in) - Expect(err).ToNot(HaveOccurred()) - - movedTrack, _ := ds.MediaFile().Get(ctx, "1") - Expect(movedTrack.Path).To(Equal("new/song.mp3")) - Expect(movedTrack.CreatedAt).To(Equal(originalTime)) - }) - - It("should preserve created_at during cross-library moves with album change", func() { - originalTime := time.Date(2019, 6, 1, 12, 0, 0, 0, time.UTC) - missingTrack := model.MediaFile{ - ID: "missing-ca", PID: "B", Path: "lib1/song.mp3", - AlbumID: "old-album", - LibraryID: 1, - CreatedAt: originalTime, - } - matchedTrack := model.MediaFile{ - ID: "matched-ca", PID: "B", Path: "lib2/song.mp3", - AlbumID: "new-album", - LibraryID: 2, - CreatedAt: time.Now(), - } - - // Set up albums so CopyAttributes can find them - albumRepo.SetData(model.Albums{ - {ID: "old-album", LibraryID: 1, CreatedAt: originalTime}, - {ID: "new-album", LibraryID: 2, CreatedAt: time.Now()}, - }) - - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) - - err := phase.moveMatched(matchedTrack, missingTrack) - Expect(err).ToNot(HaveOccurred()) - - // Track's created_at should be preserved from the missing file - movedTrack, _ := ds.MediaFile().Get(ctx, "missing-ca") - Expect(movedTrack.CreatedAt).To(Equal(originalTime)) - - // Album's created_at should be copied from old to new - Expect(albumRepo.CopyAttributesCalls).To(HaveKeyWithValue("old-album", "new-album")) - - // Verify the new album's CreatedAt was actually updated - newAlbum, err := albumRepo.Get(ctx, "new-album") - Expect(err).ToNot(HaveOccurred()) - Expect(newAlbum.CreatedAt).To(Equal(originalTime)) - }) - - It("should not copy album created_at when album ID does not change", func() { - originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) - missingTrack := model.MediaFile{ - ID: "missing-same", PID: "C", Path: "dir1/song.mp3", - AlbumID: "same-album", - LibraryID: 1, - CreatedAt: originalTime, - } - matchedTrack := model.MediaFile{ - ID: "matched-same", PID: "C", Path: "dir2/song.mp3", - AlbumID: "same-album", // Same album - LibraryID: 1, - CreatedAt: time.Now(), - } - - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) - - err := phase.moveMatched(matchedTrack, missingTrack) - Expect(err).ToNot(HaveOccurred()) - - // Track's created_at should still be preserved - movedTrack, _ := ds.MediaFile().Get(ctx, "missing-same") - Expect(movedTrack.CreatedAt).To(Equal(originalTime)) - - // CopyAttributes should NOT have been called (same album) - Expect(albumRepo.CopyAttributesCalls).To(BeEmpty()) - }) - }) - - Describe("Album Annotation Reassignment", func() { - var ( - albumRepo *tests.MockAlbumRepo - missingTrack model.MediaFile - matchedTrack model.MediaFile - oldAlbumID string - newAlbumID string - ) - - BeforeEach(func() { - albumRepo = ds.Album().(*tests.MockAlbumRepo) - albumRepo.ReassignAnnotationCalls = make(map[string]string) - - oldAlbumID = "old-album-id" - newAlbumID = "new-album-id" - - missingTrack = model.MediaFile{ - ID: "missing-track-id", - PID: "same-pid", - Path: "old/path.mp3", - AlbumID: oldAlbumID, - LibraryID: 1, - Missing: true, - Annotations: model.Annotations{ - PlayCount: 5, - Rating: 4, - Starred: true, - }, - } - - matchedTrack = model.MediaFile{ - ID: "matched-track-id", - PID: "same-pid", - Path: "new/path.mp3", - AlbumID: newAlbumID, - LibraryID: 2, // Different library - Missing: false, - Annotations: model.Annotations{ - PlayCount: 2, - Rating: 3, - Starred: false, - }, - } - - // Store both tracks in the database - _ = ds.MediaFile().Put(ctx, &missingTrack) - _ = ds.MediaFile().Put(ctx, &matchedTrack) - }) - - When("album ID changes during cross-library move", func() { - It("should reassign album annotations when AlbumID changes", func() { - err := phase.moveMatched(matchedTrack, missingTrack) - Expect(err).ToNot(HaveOccurred()) - - // Verify that ReassignAnnotation was called - Expect(albumRepo.ReassignAnnotationCalls).To(HaveKeyWithValue(oldAlbumID, newAlbumID)) - }) - - It("should not reassign annotations when AlbumID is the same", func() { - missingTrack.AlbumID = newAlbumID // Same album - - err := phase.moveMatched(matchedTrack, missingTrack) - Expect(err).ToNot(HaveOccurred()) - - // Verify that ReassignAnnotation was NOT called - Expect(albumRepo.ReassignAnnotationCalls).To(BeEmpty()) - }) - }) - - When("error handling", func() { - It("should handle ReassignAnnotation errors gracefully", func() { - // Make the album repo return an error - albumRepo.SetError(true) - - // The move should still succeed even if annotation reassignment fails - err := phase.moveMatched(matchedTrack, missingTrack) - Expect(err).ToNot(HaveOccurred()) - - // Verify that the track was still moved (ID should be updated) - movedTrack, err := ds.MediaFile().Get(ctx, missingTrack.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) - }) - }) - }) -}) - -// rerunTxDS runs every WithTxRetry block twice, as a retry after a rolled-back busy attempt would. -// The mock is not transactional, so snapshot returns the function that plays the rollback. -type rerunTxDS struct { - *tests.MockDataStore - snapshot func() (rollback func()) -} - -func (d *rerunTxDS) WithTxRetry(ctx context.Context, block func(context.Context, model.DataStore) error, _ ...string) error { - rollback := d.snapshot() - _ = block(ctx, d.MockDataStore) - rollback() - return block(ctx, d.MockDataStore) -} - -// probeTxDS runs a hook inside each WithTxRetry block, and can fail the transaction after it. -type probeTxDS struct { - *tests.MockDataStore - during func() - err error -} - -func (d *probeTxDS) WithTxRetry(ctx context.Context, block func(context.Context, model.DataStore) error, _ ...string) error { - if err := block(ctx, d.MockDataStore); err != nil { - return err - } - if d.during != nil { - d.during() - } - return d.err -} diff --git a/scanner/phase_3_refresh_albums.go b/scanner/phase_3_refresh_albums.go deleted file mode 100644 index 58ad9cb80..000000000 --- a/scanner/phase_3_refresh_albums.go +++ /dev/null @@ -1,159 +0,0 @@ -// nolint:unused -package scanner - -import ( - "context" - "fmt" - "sync/atomic" - "time" - - "github.com/Masterminds/squirrel" - ppl "github.com/google/go-pipeline/pkg/pipeline" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -// phaseRefreshAlbums is responsible for refreshing albums that have been -// newly added or changed during the scan process. This phase ensures that -// the album information in the database is up-to-date by performing the -// following steps: -// 1. Loads all libraries and their albums that have been touched (new or changed). -// 2. For each album, it filters out unmodified albums by comparing the current -// state with the state in the database. -// 3. Refreshes the album information in the database if any changes are detected. -// 4. Logs the results and finalizes the phase by reporting the total number of -// refreshed and skipped albums. -// 5. As a last step, it refreshes the artist statistics to reflect the changes -type phaseRefreshAlbums struct { - ds model.DataStore - ctx context.Context //nolint:containedctx // phase runs under a single scan ctx - refreshed atomic.Uint32 - skipped atomic.Uint32 - state *scanState -} - -func createPhaseRefreshAlbums(ctx context.Context, state *scanState, ds model.DataStore) *phaseRefreshAlbums { - return &phaseRefreshAlbums{ctx: ctx, ds: ds, state: state} -} - -func (p *phaseRefreshAlbums) description() string { - return "Refresh all new/changed albums" -} - -func (p *phaseRefreshAlbums) producer() ppl.Producer[*model.Album] { - return ppl.NewProducer(p.produce, ppl.Name("load albums from db")) -} - -func (p *phaseRefreshAlbums) produce(put func(album *model.Album)) error { - count := 0 - for _, lib := range p.state.libraries { - cursor, err := p.ds.Album().GetTouchedAlbums(p.ctx, lib.ID) - if err != nil { - return fmt.Errorf("loading touched albums: %w", err) - } - log.Debug(p.ctx, "Scanner: Checking albums that may need refresh", "libraryId", lib.ID, "libraryName", lib.Name) - for album, err := range cursor { - if err != nil { - return fmt.Errorf("loading touched albums: %w", err) - } - count++ - put(&album) - } - } - if count == 0 { - log.Debug(p.ctx, "Scanner: No albums needing refresh") - } else { - log.Debug(p.ctx, "Scanner: Found albums that may need refreshing", "count", count) - } - return nil -} - -func (p *phaseRefreshAlbums) stages() []ppl.Stage[*model.Album] { - return []ppl.Stage[*model.Album]{ - ppl.NewStage(p.filterUnmodified, ppl.Name("filter unmodified"), ppl.Concurrency(5)), - ppl.NewStage(p.refreshAlbum, ppl.Name("refresh albums")), - } -} - -func (p *phaseRefreshAlbums) filterUnmodified(album *model.Album) (*model.Album, error) { - mfs, err := p.ds.MediaFile().GetAll(p.ctx, model.QueryOptions{Filters: squirrel.Eq{"album_id": album.ID}}) - if err != nil { - log.Error(p.ctx, "Error loading media files for album", "album_id", album.ID, err) - return nil, err - } - if len(mfs) == 0 { - log.Debug(p.ctx, "Scanner: album has no media files. Skipping", "album_id", album.ID, - "name", album.Name, "songCount", album.SongCount, "updatedAt", album.UpdatedAt) - p.skipped.Add(1) - return nil, nil - } - - newAlbum := mfs.ToAlbum() - if album.Equals(newAlbum) { - log.Trace("Scanner: album is up to date. Skipping", "album_id", album.ID, - "name", album.Name, "songCount", album.SongCount, "updatedAt", album.UpdatedAt) - p.skipped.Add(1) - return nil, nil - } - return &newAlbum, nil -} - -func (p *phaseRefreshAlbums) refreshAlbum(album *model.Album) (*model.Album, error) { - if album == nil { - return nil, nil - } - start := time.Now() - err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { - return tx.Album().Put(ctx, album) - }, "scanner: refresh album") - log.Debug(p.ctx, "Scanner: refreshing album", "album_id", album.ID, "name", album.Name, "songCount", album.SongCount, "elapsed", time.Since(start), err) - if err != nil { - return nil, fmt.Errorf("refreshing album %s: %w", album.ID, err) - } - p.refreshed.Add(1) - p.state.changesDetected.Store(true) - return album, nil -} - -func (p *phaseRefreshAlbums) finalize(err error) error { - if err != nil { - return err - } - logF := log.Info - refreshed := p.refreshed.Load() - skipped := p.skipped.Load() - if refreshed == 0 { - logF = log.Debug - } - logF(p.ctx, "Scanner: Finished refreshing albums", "refreshed", refreshed, "skipped", skipped, err) - if !p.state.changesDetected.Load() { - log.Debug(p.ctx, "Scanner: No changes detected, skipping refreshing annotations") - return nil - } - // Refresh album annotations - start := time.Now() - var cnt int64 - err = p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { - var txErr error - cnt, txErr = tx.Album().RefreshPlayCounts(ctx) - return txErr - }, "scanner: refresh album play counts") - if err != nil { - return fmt.Errorf("refreshing album annotations: %w", err) - } - log.Debug(p.ctx, "Scanner: Refreshed album annotations", "albums", cnt, "elapsed", time.Since(start)) - - // Refresh artist annotations - start = time.Now() - err = p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { - var txErr error - cnt, txErr = tx.Artist().RefreshPlayCounts(ctx) - return txErr - }, "scanner: refresh artist play counts") - if err != nil { - return fmt.Errorf("refreshing artist annotations: %w", err) - } - log.Debug(p.ctx, "Scanner: Refreshed artist annotations", "artists", cnt, "elapsed", time.Since(start)) - p.state.changesDetected.Store(true) - return nil -} diff --git a/scanner/phase_3_refresh_albums_test.go b/scanner/phase_3_refresh_albums_test.go deleted file mode 100644 index 2743da070..000000000 --- a/scanner/phase_3_refresh_albums_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package scanner - -import ( - "context" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("phaseRefreshAlbums", func() { - var ( - phase *phaseRefreshAlbums - ctx context.Context - albumRepo *tests.MockAlbumRepo - mfRepo *tests.MockMediaFileRepo - ds *tests.MockDataStore - libs model.Libraries - state *scanState - ) - - BeforeEach(func() { - ctx = context.Background() - albumRepo = tests.CreateMockAlbumRepo() - mfRepo = tests.CreateMockMediaFileRepo() - ds = &tests.MockDataStore{ - MockedAlbum: albumRepo, - MockedMediaFile: mfRepo, - } - libs = model.Libraries{ - {ID: 1, Name: "Library 1"}, - {ID: 2, Name: "Library 2"}, - } - state = &scanState{libraries: libs} - phase = createPhaseRefreshAlbums(ctx, state, ds) - }) - - Describe("description", func() { - It("returns the correct description", func() { - Expect(phase.description()).To(Equal("Refresh all new/changed albums")) - }) - }) - - Describe("producer", func() { - It("produces albums that need refreshing", func() { - albumRepo.SetData(model.Albums{ - {LibraryID: 1, ID: "album1", Name: "Album 1"}, - }) - - var produced []*model.Album - err := phase.produce(func(album *model.Album) { - produced = append(produced, album) - }) - - Expect(err).ToNot(HaveOccurred()) - Expect(produced).To(HaveLen(1)) - Expect(produced[0].ID).To(Equal("album1")) - }) - - It("returns an error if there is an error loading albums", func() { - albumRepo.SetData(model.Albums{ - {ID: "error"}, - }) - - err := phase.produce(func(album *model.Album) {}) - - Expect(err).To(MatchError(ContainSubstring("loading touched albums"))) - }) - }) - - Describe("filterUnmodified", func() { - It("filters out unmodified albums", func() { - album := &model.Album{ID: "album1", Name: "Album 1", SongCount: 1, - FolderIDs: []string{"folder1"}, Discs: model.Discs{1: ""}} - mfRepo.SetData(model.MediaFiles{ - {AlbumID: "album1", Title: "Song 1", Album: "Album 1", FolderID: "folder1"}, - }) - - result, err := phase.filterUnmodified(album) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(BeNil()) - }) - It("keep modified albums", func() { - album := &model.Album{ID: "album1", Name: "Album 1"} - mfRepo.SetData(model.MediaFiles{ - {AlbumID: "album1", Title: "Song 1", Album: "Album 2"}, - }) - - result, err := phase.filterUnmodified(album) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - Expect(result.ID).To(Equal("album1")) - }) - It("skips albums with no media files", func() { - album := &model.Album{ID: "album1", Name: "Album 1"} - mfRepo.SetData(model.MediaFiles{}) - - result, err := phase.filterUnmodified(album) - Expect(err).ToNot(HaveOccurred()) - Expect(result).To(BeNil()) - }) - }) - - Describe("refreshAlbum", func() { - It("refreshes the album in the database", func() { - Expect(albumRepo.CountAll(ctx)).To(Equal(int64(0))) - - album := &model.Album{ID: "album1", Name: "Album 1"} - result, err := phase.refreshAlbum(album) - Expect(err).ToNot(HaveOccurred()) - Expect(result).ToNot(BeNil()) - Expect(result.ID).To(Equal("album1")) - - savedAlbum, err := albumRepo.Get(ctx, "album1") - Expect(err).ToNot(HaveOccurred()) - - Expect(savedAlbum).ToNot(BeNil()) - Expect(savedAlbum.ID).To(Equal("album1")) - Expect(phase.refreshed.Load()).To(Equal(uint32(1))) - Expect(state.changesDetected.Load()).To(BeTrue()) - }) - - It("returns an error if there is an error refreshing the album", func() { - album := &model.Album{ID: "album1", Name: "Album 1"} - albumRepo.SetError(true) - - result, err := phase.refreshAlbum(album) - Expect(result).To(BeNil()) - Expect(err).To(MatchError(ContainSubstring("refreshing album"))) - Expect(phase.refreshed.Load()).To(Equal(uint32(0))) - Expect(state.changesDetected.Load()).To(BeFalse()) - }) - }) -}) diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go deleted file mode 100644 index bb67c1ba3..000000000 --- a/scanner/phase_4_playlists.go +++ /dev/null @@ -1,178 +0,0 @@ -package scanner - -import ( - "context" - "errors" - "fmt" - "os" - "strings" - "sync/atomic" - "time" - - ppl "github.com/google/go-pipeline/pkg/pipeline" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" -) - -type phasePlaylists struct { - ctx context.Context //nolint:containedctx // phase runs under a single scan ctx - scanState *scanState - ds model.DataStore - pls playlists.Playlists - refreshed atomic.Uint32 - pendingImport bool -} - -func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls playlists.Playlists) *phasePlaylists { - return &phasePlaylists{ - ctx: ctx, - scanState: scanState, - ds: ds, - pls: pls, - } -} - -func (p *phasePlaylists) description() string { - return "Import/update playlists" -} - -func (p *phasePlaylists) producer() ppl.Producer[*model.Folder] { - return ppl.NewProducer(p.produce, ppl.Name("load folders with playlists from db")) -} - -func (p *phasePlaylists) produce(put func(entry *model.Folder)) error { - if !conf.Server.AutoImportPlaylists { - log.Info(p.ctx, "Playlists will not be imported, AutoImportPlaylists is set to false") - return nil - } - - // Resolve the admin at phase time (the producer runs late in the scan), so an - // admin created while the scan was in progress is picked up. Assigned once, - // before any put() below, so the channel send synchronizes it with the stages. - admin, err := p.ds.User().FindFirstAdmin(p.ctx) - if err != nil && !errors.Is(err, model.ErrNotFound) { - return fmt.Errorf("finding admin user: %w", err) - } - noAdmin := admin == nil || admin.ID == "" - if noAdmin { - return p.deferImport() - } - p.ctx = request.WithUser(p.ctx, *admin) - - // When recovering a deferred import, scan all playlist folders, not just touched ones. - pending, err := p.importPending() - if err != nil { - return fmt.Errorf("checking pending playlist import: %w", err) - } - p.pendingImport = pending - var cursor model.FolderCursor - if p.pendingImport { - cursor, err = p.ds.Folder().GetAllWithPlaylists(p.ctx) - } else { - cursor, err = p.ds.Folder().GetTouchedWithPlaylists(p.ctx) - } - if err != nil { - return fmt.Errorf("loading folders with playlists: %w", err) - } - - count := 0 - log.Debug(p.ctx, "Scanner: Checking playlists that may need refresh", "pendingImport", p.pendingImport) - for folder, err := range cursor { - if err != nil { - return fmt.Errorf("loading folder with playlists: %w", err) - } - count++ - put(&folder) - } - if count == 0 { - log.Debug(p.ctx, "Scanner: No playlists need refreshing") - } else { - log.Debug(p.ctx, "Scanner: Found folders with playlists that may need refreshing", "count", count) - } - - return nil -} - -// deferImport records the pending-import flag so a later scan with an admin can -// import the playlists, and returns an error if the flag can't be persisted (so -// the scan does not complete as successful without recording the recovery). -func (p *phasePlaylists) deferImport() error { - err := p.ds.WithTxRetry(p.ctx, func(ctx context.Context, tx model.DataStore) error { - return tx.Property().Put(ctx, consts.PlaylistsImportPendingFlagKey, "1") - }, "scanner: defer playlist import") - if err != nil { - return fmt.Errorf("recording pending playlist import: %w", err) - } - log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet. "+ - "They will be imported automatically once an admin user is created.") - return nil -} - -func (p *phasePlaylists) importPending() (bool, error) { - v, err := p.ds.Property().DefaultGet(p.ctx, consts.PlaylistsImportPendingFlagKey, "0") - return v == "1", err -} - -func (p *phasePlaylists) stages() []ppl.Stage[*model.Folder] { - return []ppl.Stage[*model.Folder]{ - ppl.NewStage(p.processPlaylistsInFolder, ppl.Name("process playlists in folder"), ppl.Concurrency(3)), - } -} - -func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model.Folder, error) { - files, err := os.ReadDir(folder.AbsolutePath()) - if err != nil { - log.Error(p.ctx, "Scanner: Error reading files", "folder", folder, err) - p.scanState.sendWarning(err.Error()) - return folder, nil - } - for _, f := range files { - started := time.Now() - if strings.HasPrefix(f.Name(), ".") { - continue - } - if !model.IsValidPlaylist(f.Name()) { - continue - } - // BFR: Check if playlist needs to be refreshed (timestamp, sync flag, etc) - pls, err := p.pls.ImportFromFolder(p.ctx, folder, f.Name()) - if err != nil { - continue - } - if pls.IsSmartPlaylist() { - log.Debug("Scanner: Imported smart playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "elapsed", time.Since(started)) - } else { - log.Debug("Scanner: Imported playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks), "elapsed", time.Since(started)) - } - item := model.ArtworkQueueItem{ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: pls.ID, ImageType: model.ImageTypePrimary, - Priority: model.ArtworkPriorityScan} - if err := p.ds.ArtworkQueue().Enqueue(p.ctx, item); err != nil { - log.Warn(p.ctx, "Scanner: could not enqueue playlist artwork", "id", pls.ID, err) - } - p.refreshed.Add(1) - } - return folder, nil -} - -func (p *phasePlaylists) finalize(err error) error { - refreshed := p.refreshed.Load() - logF := log.Info - if refreshed == 0 { - logF = log.Debug - } else { - p.scanState.changesDetected.Store(true) - } - if p.pendingImport && err == nil { - if derr := p.ds.Property().Delete(p.ctx, consts.PlaylistsImportPendingFlagKey); derr != nil { - log.Warn(p.ctx, "Scanner: Could not clear pending playlist-import flag", derr) - } - } - logF(p.ctx, "Scanner: Finished refreshing playlists", "refreshed", refreshed, err) - return err -} - -var _ phase[*model.Folder] = (*phasePlaylists)(nil) diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go deleted file mode 100644 index 303af338f..000000000 --- a/scanner/phase_4_playlists_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package scanner - -import ( - "context" - "errors" - "os" - "path/filepath" - "sort" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/playlists" - "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("phasePlaylists", func() { - var ( - phase *phasePlaylists - ctx context.Context - state *scanState - folderRepo *mockFolderRepository - ds *tests.MockDataStore - pls *mockPlaylists - ) - - var userRepo *tests.MockedUserRepo - var propRepo *tests.MockedPropertyRepo - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.AutoImportPlaylists = true - ctx = context.Background() - folderRepo = &mockFolderRepository{} - userRepo = tests.CreateMockUserRepo() - // An admin user exists by default, so playlist import proceeds. - Expect(userRepo.Put(ctx, &model.User{ID: "123", UserName: "admin", IsAdmin: true})).To(Succeed()) - propRepo = &tests.MockedPropertyRepo{} - ds = &tests.MockDataStore{ - MockedFolder: folderRepo, - MockedUser: userRepo, - MockedProperty: propRepo, - } - pls = &mockPlaylists{} - state = &scanState{} - phase = createPhasePlaylists(ctx, state, ds, pls) - }) - - Describe("description", func() { - It("returns the correct description", func() { - Expect(phase.description()).To(Equal("Import/update playlists")) - }) - }) - - Describe("producer", func() { - It("produces folders with playlists", func() { - folderRepo.SetData(map[*model.Folder]error{ - {Path: "/path/to/folder1"}: nil, - {Path: "/path/to/folder2"}: nil, - }) - - var produced []*model.Folder - err := phase.produce(func(folder *model.Folder) { - produced = append(produced, folder) - }) - - sort.Slice(produced, func(i, j int) bool { - return produced[i].Path < produced[j].Path - }) - Expect(err).ToNot(HaveOccurred()) - Expect(produced).To(HaveLen(2)) - Expect(produced[0].Path).To(Equal("/path/to/folder1")) - Expect(produced[1].Path).To(Equal("/path/to/folder2")) - }) - - It("returns an error if there is an error loading folders", func() { - folderRepo.SetData(map[*model.Folder]error{ - nil: errors.New("error loading folders"), - }) - - called := false - err := phase.produce(func(folder *model.Folder) { called = true }) - - Expect(err).To(HaveOccurred()) - Expect(called).To(BeFalse()) - Expect(err).To(MatchError(ContainSubstring("error loading folders"))) - }) - - It("sets the pending flag and imports nothing when no admin user exists", func() { - // Remove the admin user; produce resolves the admin at phase time. - userRepo.Data = map[string]*model.User{} - folderRepo.SetData(map[*model.Folder]error{ - {Path: "/path/to/folder1"}: nil, - }) - - called := false - err := phase.produce(func(folder *model.Folder) { called = true }) - - Expect(err).ToNot(HaveOccurred()) - Expect(called).To(BeFalse()) - v, _ := propRepo.Get(ctx, consts.PlaylistsImportPendingFlagKey) - Expect(v).To(Equal("1")) - }) - - It("returns an error (not a silent defer) on a datastore failure resolving the admin", func() { - userRepo.Error = errors.New("db is locked") - - err := phase.produce(func(folder *model.Folder) {}) - - Expect(err).To(MatchError(ContainSubstring("finding admin user"))) - // Must NOT have set the pending flag on a real error. - _, getErr := propRepo.Get(ctx, consts.PlaylistsImportPendingFlagKey) - Expect(getErr).To(HaveOccurred()) - }) - - It("returns an error when the pending flag cannot be persisted", func() { - userRepo.Data = map[string]*model.User{} // no admin -> defer path - propRepo.Error = errors.New("property table unavailable") - - err := phase.produce(func(folder *model.Folder) {}) - - Expect(err).To(MatchError(ContainSubstring("recording pending playlist import"))) - }) - - It("imports all playlist folders when the pending flag is set", func() { - Expect(propRepo.Put(ctx, consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) - folderRepo.SetAllData(map[*model.Folder]error{ - {Path: "/path/to/folder1"}: nil, - {Path: "/path/to/folder2"}: nil, - }) - // Touched set is empty: proves selection used GetAllWithPlaylists. - folderRepo.SetData(map[*model.Folder]error{}) - - var produced []*model.Folder - err := phase.produce(func(folder *model.Folder) { produced = append(produced, folder) }) - - Expect(err).ToNot(HaveOccurred()) - Expect(produced).To(HaveLen(2)) - Expect(phase.pendingImport).To(BeTrue()) - }) - }) - - Describe("finalize", func() { - It("clears the pending flag after a successful pending import", func() { - Expect(propRepo.Put(ctx, consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) - phase.pendingImport = true - - Expect(phase.finalize(nil)).To(Succeed()) - - _, err := propRepo.Get(ctx, consts.PlaylistsImportPendingFlagKey) - Expect(err).To(HaveOccurred()) // deleted - }) - - It("keeps the pending flag when the import failed", func() { - Expect(propRepo.Put(ctx, consts.PlaylistsImportPendingFlagKey, "1")).To(Succeed()) - phase.pendingImport = true - - Expect(phase.finalize(errors.New("boom"))).To(HaveOccurred()) - - v, _ := propRepo.Get(ctx, consts.PlaylistsImportPendingFlagKey) - Expect(v).To(Equal("1")) - }) - }) - - Describe("processPlaylistsInFolder", func() { - It("processes playlists in a folder", func() { - libPath := GinkgoT().TempDir() - folder := &model.Folder{LibraryPath: libPath, Path: "path/to", Name: "folder"} - _ = os.MkdirAll(folder.AbsolutePath(), 0755) - - file1 := filepath.Join(folder.AbsolutePath(), "playlist1.m3u") - file2 := filepath.Join(folder.AbsolutePath(), "playlist2.m3u") - _ = os.WriteFile(file1, []byte{}, 0600) - _ = os.WriteFile(file2, []byte{}, 0600) - - pls.On("ImportFromFolder", mock.Anything, folder, "playlist1.m3u"). - Return(&model.Playlist{}, nil) - pls.On("ImportFromFolder", mock.Anything, folder, "playlist2.m3u"). - Return(&model.Playlist{}, nil) - - _, err := phase.processPlaylistsInFolder(folder) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.Calls).To(HaveLen(2)) - Expect(pls.Calls[0].Arguments[2]).To(Equal("playlist1.m3u")) - Expect(pls.Calls[1].Arguments[2]).To(Equal("playlist2.m3u")) - Expect(phase.refreshed.Load()).To(Equal(uint32(2))) - }) - - It("enqueues artwork resolution for the imported playlist", func() { - libPath := GinkgoT().TempDir() - folder := &model.Folder{LibraryPath: libPath, Path: "path/to", Name: "folder"} - _ = os.MkdirAll(folder.AbsolutePath(), 0755) - - file1 := filepath.Join(folder.AbsolutePath(), "playlist1.m3u") - _ = os.WriteFile(file1, []byte{}, 0600) - - pls.On("ImportFromFolder", mock.Anything, folder, "playlist1.m3u"). - Return(&model.Playlist{ID: "pl1"}, nil) - - _, err := phase.processPlaylistsInFolder(folder) - Expect(err).ToNot(HaveOccurred()) - - queued, err := ds.ArtworkQueue().DequeueBatch(ctx, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "pl"), - HaveField("ItemID", "pl1"), - HaveField("Priority", model.ArtworkPriorityScan), - ))) - }) - - 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"} - go func() { - _, err := phase.processPlaylistsInFolder(folder) - // I/O errors are ignored - Expect(err).ToNot(HaveOccurred()) - }() - - // But are reported - info := &ProgressInfo{} - Eventually(progress).Should(Receive(&info)) - Expect(info.Warning).To(ContainSubstring("no such file or directory")) - }) - }) -}) - -type mockPlaylists struct { - mock.Mock - playlists.Playlists -} - -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) -} - -type mockFolderRepository struct { - model.FolderRepository - data map[*model.Folder]error - allData map[*model.Folder]error -} - -func cursorFromData(data map[*model.Folder]error) model.FolderCursor { - return func(yield func(model.Folder, error) bool) { - for folder, err := range data { - if err != nil { - if !yield(model.Folder{}, err) { - return - } - continue - } - if !yield(*folder, err) { - return - } - } - } -} - -func (f *mockFolderRepository) GetTouchedWithPlaylists(context.Context) (model.FolderCursor, error) { - return cursorFromData(f.data), nil -} - -func (f *mockFolderRepository) GetAllWithPlaylists(context.Context) (model.FolderCursor, error) { - return cursorFromData(f.allData), nil -} - -func (f *mockFolderRepository) SetData(m map[*model.Folder]error) { - f.data = m -} - -func (f *mockFolderRepository) SetAllData(m map[*model.Folder]error) { - f.allData = m -} diff --git a/scanner/playlist_importer.go b/scanner/playlist_importer.go new file mode 100644 index 000000000..ef044b7bc --- /dev/null +++ b/scanner/playlist_importer.go @@ -0,0 +1,65 @@ +package scanner + +import ( + "context" + "os" + "path/filepath" + "strings" + + "github.com/mattn/go-zglob" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +type playlistImporter struct { + ds model.DataStore + pls core.Playlists + cacheWarmer artwork.CacheWarmer + rootFolder string +} + +func newPlaylistImporter(ds model.DataStore, playlists core.Playlists, cacheWarmer artwork.CacheWarmer, rootFolder string) *playlistImporter { + return &playlistImporter{ds: ds, pls: playlists, cacheWarmer: cacheWarmer, rootFolder: rootFolder} +} + +func (s *playlistImporter) processPlaylists(ctx context.Context, dir string) int64 { + if !s.inPlaylistsPath(dir) { + return 0 + } + var count int64 + files, err := os.ReadDir(dir) + if err != nil { + log.Error(ctx, "Error reading files", "dir", dir, err) + return count + } + for _, f := range files { + if !model.IsValidPlaylist(f.Name()) { + continue + } + pls, err := s.pls.ImportFile(ctx, dir, f.Name()) + if err != nil { + continue + } + if pls.IsSmartPlaylist() { + log.Debug("Imported smart playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", pls.SongCount) + } else { + log.Debug("Imported playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", pls.SongCount) + } + s.cacheWarmer.PreCache(pls.CoverArtID()) + count++ + } + return count +} + +func (s *playlistImporter) inPlaylistsPath(dir string) bool { + rel, _ := filepath.Rel(s.rootFolder, dir) + for _, path := range strings.Split(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) { + if match, _ := zglob.Match(path, rel); match { + return true + } + } + return false +} diff --git a/scanner/playlist_importer_test.go b/scanner/playlist_importer_test.go new file mode 100644 index 000000000..1b60f5c0b --- /dev/null +++ b/scanner/playlist_importer_test.go @@ -0,0 +1,94 @@ +package scanner + +import ( + "context" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("playlistImporter", func() { + var ds model.DataStore + var ps *playlistImporter + var pls core.Playlists + var cw artwork.CacheWarmer + ctx := context.Background() + + BeforeEach(func() { + ds = &tests.MockDataStore{ + MockedMediaFile: &mockedMediaFile{}, + MockedPlaylist: &mockedPlaylist{}, + } + pls = core.NewPlaylists(ds) + + cw = &noopCacheWarmer{} + }) + + Describe("processPlaylists", func() { + Context("Default PlaylistsPath", func() { + BeforeEach(func() { + conf.Server.PlaylistsPath = consts.DefaultPlaylistsPath + }) + It("finds and import playlists at the top level", func() { + ps = newPlaylistImporter(ds, pls, cw, "tests/fixtures/playlists/subfolder1") + Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists/subfolder1")).To(Equal(int64(1))) + }) + + It("finds and import playlists at any subfolder level", func() { + ps = newPlaylistImporter(ds, pls, cw, "tests") + Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists/subfolder1")).To(Equal(int64(1))) + }) + }) + + It("ignores playlists not in the PlaylistsPath", func() { + conf.Server.PlaylistsPath = "subfolder1" + ps = newPlaylistImporter(ds, pls, cw, "tests/fixtures/playlists") + + Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists/subfolder1")).To(Equal(int64(1))) + Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists/subfolder2")).To(Equal(int64(0))) + }) + + It("only imports playlists from the root of MusicFolder if PlaylistsPath is '.'", func() { + conf.Server.PlaylistsPath = "." + ps = newPlaylistImporter(ds, pls, cw, "tests/fixtures/playlists") + + Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists")).To(Equal(int64(3))) + Expect(ps.processPlaylists(ctx, "tests/fixtures/playlists/subfolder1")).To(Equal(int64(0))) + }) + + }) +}) + +type mockedMediaFile struct { + model.MediaFileRepository +} + +func (r *mockedMediaFile) FindByPath(s string) (*model.MediaFile, error) { + return &model.MediaFile{ + ID: "123", + Path: s, + }, nil +} + +type mockedPlaylist struct { + model.PlaylistRepository +} + +func (r *mockedPlaylist) FindByPath(_ string) (*model.Playlist, error) { + return nil, model.ErrNotFound +} + +func (r *mockedPlaylist) Put(_ *model.Playlist) error { + return nil +} + +type noopCacheWarmer struct{} + +func (a *noopCacheWarmer) PreCache(_ model.ArtworkID) {} diff --git a/scanner/refresher.go b/scanner/refresher.go new file mode 100644 index 000000000..2d8a8da55 --- /dev/null +++ b/scanner/refresher.go @@ -0,0 +1,152 @@ +package scanner + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/slice" + "golang.org/x/exp/maps" +) + +// refresher is responsible for rolling up mediafiles attributes into albums attributes, +// and albums attributes into artists attributes. This is done by accumulating all album and artist IDs +// found during scan, and "refreshing" the albums and artists when flush is called. +// +// The actual mappings happen in MediaFiles.ToAlbum() and Albums.ToAlbumArtist() +type refresher struct { + ds model.DataStore + album map[string]struct{} + artist map[string]struct{} + dirMap dirMap + cacheWarmer artwork.CacheWarmer +} + +func newRefresher(ds model.DataStore, cw artwork.CacheWarmer, dirMap dirMap) *refresher { + return &refresher{ + ds: ds, + album: map[string]struct{}{}, + artist: map[string]struct{}{}, + dirMap: dirMap, + cacheWarmer: cw, + } +} + +func (r *refresher) accumulate(mf model.MediaFile) { + if mf.AlbumID != "" { + r.album[mf.AlbumID] = struct{}{} + } + if mf.AlbumArtistID != "" { + r.artist[mf.AlbumArtistID] = struct{}{} + } +} + +func (r *refresher) flush(ctx context.Context) error { + err := r.flushMap(ctx, r.album, "album", r.refreshAlbums) + if err != nil { + return err + } + r.album = map[string]struct{}{} + err = r.flushMap(ctx, r.artist, "artist", r.refreshArtists) + if err != nil { + return err + } + r.artist = map[string]struct{}{} + return nil +} + +type refreshCallbackFunc = func(ctx context.Context, ids ...string) error + +func (r *refresher) flushMap(ctx context.Context, m map[string]struct{}, entity string, refresh refreshCallbackFunc) error { + if len(m) == 0 { + return nil + } + + ids := maps.Keys(m) + chunks := utils.BreakUpStringSlice(ids, 100) + for _, chunk := range chunks { + err := refresh(ctx, chunk...) + if err != nil { + log.Error(ctx, fmt.Sprintf("Error writing %ss to the DB", entity), err) + return err + } + } + return nil +} + +func (r *refresher) refreshAlbums(ctx context.Context, ids ...string) error { + mfs, err := r.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": ids}}) + if err != nil { + return err + } + if len(mfs) == 0 { + return nil + } + + repo := r.ds.Album(ctx) + grouped := slice.Group(mfs, func(m model.MediaFile) string { return m.AlbumID }) + for _, group := range grouped { + songs := model.MediaFiles(group) + a := songs.ToAlbum() + var updatedAt time.Time + a.ImageFiles, updatedAt = r.getImageFiles(songs.Dirs()) + if updatedAt.After(a.UpdatedAt) { + a.UpdatedAt = updatedAt + } + err := repo.Put(&a) + if err != nil { + return err + } + r.cacheWarmer.PreCache(a.CoverArtID()) + } + return nil +} + +func (r *refresher) getImageFiles(dirs []string) (string, time.Time) { + var imageFiles []string + var updatedAt time.Time + for _, dir := range dirs { + stats := r.dirMap[dir] + for _, img := range stats.Images { + imageFiles = append(imageFiles, filepath.Join(dir, img)) + } + if stats.ImagesUpdatedAt.After(updatedAt) { + updatedAt = stats.ImagesUpdatedAt + } + } + return strings.Join(imageFiles, consts.Zwsp), updatedAt +} + +func (r *refresher) refreshArtists(ctx context.Context, ids ...string) error { + albums, err := r.ds.Album(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_artist_id": ids}}) + if err != nil { + return err + } + if len(albums) == 0 { + return nil + } + + repo := r.ds.Artist(ctx) + grouped := slice.Group(albums, func(al model.Album) string { return al.AlbumArtistID }) + for _, group := range grouped { + a := model.Albums(group).ToAlbumArtist() + + // Force a external metadata lookup on next access + a.ExternalInfoUpdatedAt = time.Time{} + + err := repo.Put(&a) + if err != nil { + return err + } + r.cacheWarmer.PreCache(a.CoverArtID()) + } + return nil +} diff --git a/scanner/scanner.go b/scanner/scanner.go index 305c443f4..41a7925da 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -2,404 +2,250 @@ package scanner import ( "context" + "errors" "fmt" - "maps" - "slices" - "sync/atomic" + "strconv" + "sync" "time" - ppl "github.com/google/go-pipeline/pkg/pipeline" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/run" - "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/server/events" ) -type scannerImpl struct { - ds model.DataStore - pls playlists.Playlists +type Scanner interface { + RescanAll(ctx context.Context, fullRescan bool) error + Status(mediaFolder string) (*StatusInfo, error) } -// scanState holds the state of an in-progress scan, to be passed to the various phases -type scanState struct { - progress chan<- *ProgressInfo - fullScan bool - changesDetected atomic.Bool - libraries model.Libraries // Store libraries list for consistency across phases - targets map[int][]string // Optional: map[libraryID][]folderPaths for selective scans - totalLibraryCount int // Total number of libraries (unfiltered), for cross-library move detection - failedLibs map[int]bool // Libraries that could not be scanned in this run +type StatusInfo struct { + MediaFolder string + Scanning bool + LastScan time.Time + Count uint32 + FolderCount uint32 } -func (s *scanState) sendProgress(info *ProgressInfo) { - if s.progress != nil { - s.progress <- info +var ( + ErrAlreadyScanning = errors.New("already scanning") + ErrScanError = errors.New("scan error") +) + +type FolderScanner interface { + // Scan process finds any changes after `lastModifiedSince` and returns the number of changes found + Scan(ctx context.Context, lastModifiedSince time.Time, progress chan uint32) (int64, error) +} + +var isScanning sync.Mutex + +type scanner struct { + folders map[string]FolderScanner + status map[string]*scanStatus + lock *sync.RWMutex + ds model.DataStore + pls core.Playlists + broker events.Broker + cacheWarmer artwork.CacheWarmer +} + +type scanStatus struct { + active bool + fileCount uint32 + folderCount uint32 + lastUpdate time.Time +} + +func New(ds model.DataStore, playlists core.Playlists, cacheWarmer artwork.CacheWarmer, broker events.Broker) Scanner { + s := &scanner{ + ds: ds, + pls: playlists, + broker: broker, + folders: map[string]FolderScanner{}, + status: map[string]*scanStatus{}, + lock: &sync.RWMutex{}, + cacheWarmer: cacheWarmer, } + s.loadFolders() + return s } -func (s *scanState) isSelectiveScan() bool { - return len(s.targets) > 0 -} +func (s *scanner) rescan(ctx context.Context, mediaFolder string, fullRescan bool) error { + folderScanner := s.folders[mediaFolder] + start := time.Now() -func (s *scanState) sendWarning(msg string) { - s.sendProgress(&ProgressInfo{Warning: msg}) -} + s.setStatusStart(mediaFolder) + defer s.setStatusEnd(mediaFolder, start) -func (s *scanState) markFailed(libID int) { - if s.failedLibs == nil { - s.failedLibs = map[int]bool{} - } - s.failedLibs[libID] = true -} - -func (s *scanState) sendError(err error) { - s.sendProgress(&ProgressInfo{Error: err.Error()}) -} - -func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) { - startTime := time.Now() - - state := scanState{ - progress: progress, - fullScan: fullScan, - changesDetected: atomic.Bool{}, - } - - // Set changesDetected to true for full scans to ensure all maintenance operations run - if fullScan { - state.changesDetected.Store(true) - } - - // Get libraries and optionally filter by targets - allLibs, err := s.ds.Library().GetAll(ctx) - if err != nil { - state.sendWarning(fmt.Sprintf("getting libraries: %s", err)) - return - } - state.totalLibraryCount = len(allLibs) - - if len(targets) > 0 { - // Selective scan: filter libraries and build targets map - state.targets = make(map[int][]string) - - libPaths := slice.ToMap(allLibs, func(lib model.Library) (int, string) { - return lib.ID, lib.Path - }) - - for _, target := range targets { - folderPath := model.LibraryRelativePath(libPaths[target.LibraryID], target.FolderPath) - if folderPath == "" { - folderPath = "." - } - state.targets[target.LibraryID] = append(state.targets[target.LibraryID], folderPath) - } - - // Filter libraries to only those in targets - state.libraries = slice.Filter(allLibs, func(lib model.Library) bool { - return len(state.targets[lib.ID]) > 0 - }) - - log.Info(ctx, "Scanner: Starting selective scan", "fullScan", state.fullScan, "numLibraries", len(state.libraries), "numTargets", len(targets)) + lastModifiedSince := time.Time{} + if !fullRescan { + lastModifiedSince = s.getLastModifiedSince(ctx, mediaFolder) + log.Debug("Scanning folder", "folder", mediaFolder, "lastModifiedSince", lastModifiedSince) } else { - // Full library scan - state.libraries = allLibs - log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(state.libraries)) + log.Debug("Scanning folder (full scan)", "folder", mediaFolder) } - // Store scan type and start time - scanType := "quick" - if state.fullScan { - scanType = "full" - } - if state.isSelectiveScan() { - scanType += "-selective" - } - _ = s.ds.Property().Put(ctx, consts.LastScanTypeKey, scanType) - _ = s.ds.Property().Put(ctx, consts.LastScanStartTimeKey, startTime.Format(time.RFC3339)) + progress, cancel := s.startProgressTracker(mediaFolder) + defer cancel() - // if there was a full scan in progress, force a full scan - if !state.fullScan { - for _, lib := range state.libraries { - // A pending PID rescan already restarts in full through its own job - if lib.NeedsPIDRescan() { - continue + changeCount, err := folderScanner.Scan(ctx, lastModifiedSince, progress) + if err != nil { + log.Error("Error importing MediaFolder", "folder", mediaFolder, err) + } + + if changeCount > 0 { + log.Debug(ctx, "Detected changes in the music folder. Sending refresh event", + "folder", mediaFolder, "changeCount", changeCount) + // Don't use real context, forcing a refresh in all open windows, including the one that triggered the scan + s.broker.SendMessage(context.Background(), &events.RefreshResource{}) + } + + s.updateLastModifiedSince(mediaFolder, start) + return err +} + +func (s *scanner) startProgressTracker(mediaFolder string) (chan uint32, context.CancelFunc) { + ctx, cancel := context.WithCancel(context.Background()) + progress := make(chan uint32, 100) + go func() { + s.broker.SendMessage(ctx, &events.ScanStatus{Scanning: true, Count: 0, FolderCount: 0}) + defer func() { + if status, ok := s.getStatus(mediaFolder); ok { + s.broker.SendMessage(ctx, &events.ScanStatus{ + Scanning: false, + Count: int64(status.fileCount), + FolderCount: int64(status.folderCount), + }) } - if lib.FullScanInProgress { - log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name) - state.fullScan = true - if state.isSelectiveScan() { - _ = s.ds.Property().Put(ctx, consts.LastScanTypeKey, "full-selective") - } else { - _ = s.ds.Property().Put(ctx, consts.LastScanTypeKey, "full") + }() + for { + select { + case <-ctx.Done(): + return + case count := <-progress: + if count == 0 { + continue } - break + totalFolders, totalFiles := s.incStatusCounter(mediaFolder, count) + s.broker.SendMessage(ctx, &events.ScanStatus{ + Scanning: true, + Count: int64(totalFiles), + FolderCount: int64(totalFolders), + }) } } - } - - // Prepare libraries for scanning (initialize LastScanStartedAt if needed) - err = s.prepareLibrariesForScan(ctx, &state) - if err != nil { - log.Error(ctx, "Scanner: Error preparing libraries for scan", err) - state.sendError(err) - return - } - - err = run.Sequentially( - // Phase 1: Scan all libraries and import new/updated files - runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds)), - - // Phase 2: Process missing files, checking for moves - runPhase[*missingTracks](ctx, 2, createPhaseMissingTracks(ctx, &state, s.ds)), - - // Phases 3 and 4 can be run in parallel - run.Parallel( - // Phase 3: Refresh all new/changed albums and update artists - runPhase[*model.Album](ctx, 3, createPhaseRefreshAlbums(ctx, &state, s.ds)), - - // Phase 4: Import/update playlists - runPhase[*model.Folder](ctx, 4, createPhasePlaylists(ctx, &state, s.ds, s.pls)), - ), - - // Final Steps (cannot be parallelized): - - // Run GC if there were any changes (Remove dangling tracks, empty albums and artists, and orphan annotations) - s.runGC(ctx, &state), - - // Queue artwork for entities that never resolved (after GC, so nothing dangling is queued) - s.runEnqueueMissingArtwork(ctx, &state), - - // Refresh artist and tags stats - s.runRefreshStats(ctx, &state), - - // Update last_scan_completed_at for all libraries - s.runUpdateLibraries(ctx, &state), - ) - if err != nil { - log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err) - _ = s.ds.Property().Put(ctx, consts.LastScanErrorKey, err.Error()) - state.sendError(err) - return - } - - _ = s.ds.Property().Put(ctx, consts.LastScanErrorKey, "") - - if state.changesDetected.Load() { - state.sendProgress(&ProgressInfo{ChangesDetected: true}) - } - - if state.isSelectiveScan() { - log.Info(ctx, "Scanner: Finished scanning selected folders", "duration", time.Since(startTime), "numTargets", len(targets)) - } else { - log.Info(ctx, "Scanner: Finished scanning all libraries", "duration", time.Since(startTime)) - } + }() + return progress, cancel } -// prepareLibrariesForScan initializes the scan for all libraries in the state. -// It calls ScanBegin for libraries that haven't started scanning yet (LastScanStartedAt is zero), -// reloads them to get the updated state, and filters out any libraries that fail to initialize. -func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanState) error { - var successfulLibs []model.Library - - for _, lib := range state.libraries { - // A library with a changed PID config restarts its scan: resuming would skip the folders that - // the interrupted scan already processed with the old config - pidRescan := lib.NeedsPIDRescan() - if lib.LastScanStartedAt.IsZero() || pidRescan { - // This is a new scan - mark it as started - err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - return tx.Library().ScanBegin(ctx, lib.ID, state.fullScan || pidRescan) - }, "scanner: begin library scan") - if err != nil { - log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err) - state.sendWarning(err.Error()) - continue - } - - // Reload library to get updated state (timestamps, etc.) - reloadedLib, err := s.ds.Library().Get(ctx, lib.ID) - if err != nil { - log.Error(ctx, "Scanner: Error reloading library", "lib", lib.Name, err) - state.sendWarning(err.Error()) - continue - } - lib = *reloadedLib - } else { - // This is a resumed scan - log.Debug(ctx, "Scanner: Resuming previous scan", "lib", lib.Name, - "lastScanStartedAt", lib.LastScanStartedAt, "fullScan", lib.FullScanInProgress) - } - - successfulLibs = append(successfulLibs, lib) +func (s *scanner) RescanAll(ctx context.Context, fullRescan bool) error { + if !isScanning.TryLock() { + log.Debug("Scanner already running, ignoring request for rescan.") + return ErrAlreadyScanning } + defer isScanning.Unlock() - if len(successfulLibs) == 0 { - return fmt.Errorf("no libraries available for scanning") + var hasError bool + for folder := range s.folders { + err := s.rescan(ctx, folder, fullRescan) + hasError = hasError || err != nil } - - // Update state with only successfully initialized libraries - state.libraries = successfulLibs + if hasError { + log.Error("Errors while scanning media. Please check the logs") + core.WriteAfterScanMetrics(ctx, s.ds, false) + return ErrScanError + } + core.WriteAfterScanMetrics(ctx, s.ds, true) return nil } -func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error { - return func() error { - state.sendProgress(&ProgressInfo{ForceUpdate: true}) - return s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - if state.changesDetected.Load() { - start := time.Now() +func (s *scanner) getStatus(folder string) (scanStatus, bool) { + s.lock.RLock() + defer s.lock.RUnlock() + status, ok := s.status[folder] + return *status, ok +} - // For selective scans, extract library IDs to scope GC operations - var libraryIDs []int - if state.isSelectiveScan() { - libraryIDs = slices.Collect(maps.Keys(state.targets)) - log.Debug(ctx, "Scanner: Running selective GC", "libraryIDs", libraryIDs) - } +func (s *scanner) incStatusCounter(folder string, numFiles uint32) (totalFolders uint32, totalFiles uint32) { + s.lock.Lock() + defer s.lock.Unlock() + if status, ok := s.status[folder]; ok { + status.fileCount += numFiles + status.folderCount++ + totalFolders = status.folderCount + totalFiles = status.fileCount + } + return +} - if err := tx.GC(ctx, libraryIDs...); err != nil { - return fmt.Errorf("running GC: %w", err) - } - log.Debug(ctx, "Scanner: GC completed", "elapsed", time.Since(start)) - } else { - log.Debug(ctx, "Scanner: No changes detected, skipping GC") - } - return nil - }, "scanner: GC") +func (s *scanner) setStatusStart(folder string) { + s.lock.Lock() + defer s.lock.Unlock() + if status, ok := s.status[folder]; ok { + status.active = true + status.fileCount = 0 + status.folderCount = 0 } } -// runEnqueueMissingArtwork is the safety net for entities phase 1 never enqueued. -func (s *scannerImpl) runEnqueueMissingArtwork(ctx context.Context, state *scanState) func() error { - return func() error { - if !state.changesDetected.Load() { - log.Debug(ctx, "Scanner: No changes detected, skipping artwork enqueue") - return nil - } - start := time.Now() - var total int64 - for _, kind := range []model.Kind{model.KindAlbumArtwork, model.KindArtistArtwork} { - var n int64 - err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - var err error - n, err = tx.ArtworkQueue().EnqueueAllMissing(ctx, kind, model.ArtworkPriorityScan) - return err - }, "scanner: enqueue missing artwork") - if err != nil { - log.Error(ctx, "Scanner: Error enqueueing missing artwork", "kind", kind, err) - return fmt.Errorf("enqueueing missing artwork: %w", err) - } - total += n - } - log.Debug(ctx, "Scanner: Enqueued missing artwork", "items", total, "elapsed", time.Since(start)) - return nil +func (s *scanner) setStatusEnd(folder string, lastUpdate time.Time) { + s.lock.Lock() + defer s.lock.Unlock() + if status, ok := s.status[folder]; ok { + status.active = false + status.lastUpdate = lastUpdate } } -func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) func() error { - return func() error { - if !state.changesDetected.Load() { - log.Debug(ctx, "Scanner: No changes detected, skipping refreshing stats") - return nil - } - start := time.Now() - stats, err := s.ds.Artist().RefreshStats(ctx, state.fullScan) - if err != nil { - log.Error(ctx, "Scanner: Error refreshing artists stats", err) - return fmt.Errorf("refreshing artists stats: %w", err) - } - log.Debug(ctx, "Scanner: Refreshed artist stats", "stats", stats, "elapsed", time.Since(start)) +func (s *scanner) Status(mediaFolder string) (*StatusInfo, error) { + status, ok := s.getStatus(mediaFolder) + if !ok { + return nil, errors.New("mediaFolder not found") + } + return &StatusInfo{ + MediaFolder: mediaFolder, + Scanning: status.active, + LastScan: status.lastUpdate, + Count: status.fileCount, + FolderCount: status.folderCount, + }, nil +} - start = time.Now() - err = s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - return tx.Tag().UpdateCounts(ctx) - }, "scanner: update tag counts") - if err != nil { - log.Error(ctx, "Scanner: Error updating tag counts", err) - return fmt.Errorf("updating tag counts: %w", err) - } - log.Debug(ctx, "Scanner: Updated tag counts", "elapsed", time.Since(start)) - return nil +func (s *scanner) getLastModifiedSince(ctx context.Context, folder string) time.Time { + ms, err := s.ds.Property(ctx).Get(model.PropLastScan + "-" + folder) + if err != nil { + return time.Time{} + } + if ms == "" { + return time.Time{} + } + i, _ := strconv.ParseInt(ms, 10, 64) + return time.Unix(0, i*int64(time.Millisecond)) +} + +func (s *scanner) updateLastModifiedSince(folder string, t time.Time) { + millis := t.UnixNano() / int64(time.Millisecond) + if err := s.ds.Property(context.TODO()).Put(model.PropLastScan+"-"+folder, fmt.Sprint(millis)); err != nil { + log.Error("Error updating DB after scan", err) } } -func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) func() error { - return func() error { - start := time.Now() - return s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error { - for _, lib := range state.libraries { - if err := tx.Library().ScanEnd(ctx, lib.ID); err != nil { - return fmt.Errorf("updating last scan completed for %s: %w", lib.Name, err) - } - // A selective scan covers only part of the library, so the rest may still use the old PID - // config. A library that could not be scanned did not apply it either. - if !state.isSelectiveScan() && !state.failedLibs[lib.ID] { - if err := tx.Library().SetScannedPID(ctx, lib.ID, lib.EffectivePID()); err != nil { - return fmt.Errorf("updating PID conf for %s: %w", lib.Name, err) - } - } - if state.changesDetected.Load() { - log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name) - if err := tx.Library().RefreshStats(ctx, lib.ID); err != nil { - return fmt.Errorf("refreshing library stats for %s: %w", lib.Name, err) - } - } else { - log.Debug(ctx, "Scanner: No changes detected, skipping library stats refresh", "lib", lib.Name) - } - } - log.Debug(ctx, "Scanner: Updated libraries after scan", "elapsed", time.Since(start), "numLibraries", len(state.libraries)) - return nil - }, "scanner: update libraries") - } -} - -type phase[T any] interface { - producer() ppl.Producer[T] - stages() []ppl.Stage[T] - finalize(error) error - description() string -} - -func runPhase[T any](ctx context.Context, phaseNum int, phase phase[T]) func() error { - return func() error { - log.Debug(ctx, fmt.Sprintf("Scanner: Starting phase %d: %s", phaseNum, phase.description())) - start := time.Now() - - producer := phase.producer() - stages := phase.stages() - - // Prepend a counter stage to the phase's pipeline - counter, countStageFn := countTasks[T]() - stages = append([]ppl.Stage[T]{ppl.NewStage(countStageFn, ppl.Name("count tasks"))}, stages...) - - var err error - if log.IsGreaterOrEqualTo(log.LevelDebug) { - var m *ppl.Metrics - m, err = ppl.Measure(producer, stages...) - log.Info(ctx, "Scanner: "+m.String(), err) - } else { - err = ppl.Do(producer, stages...) +func (s *scanner) loadFolders() { + ctx := context.TODO() + fs, _ := s.ds.MediaFolder(ctx).GetAll() + for _, f := range fs { + log.Info("Configuring Media Folder", "name", f.Name, "path", f.Path) + s.folders[f.Path] = s.newScanner(f) + s.status[f.Path] = &scanStatus{ + active: false, + fileCount: 0, + folderCount: 0, + lastUpdate: s.getLastModifiedSince(ctx, f.Path), } - - err = phase.finalize(err) - - if err != nil { - log.Error(ctx, fmt.Sprintf("Scanner: Error processing libraries in phase %d", phaseNum), "elapsed", time.Since(start), err) - } else { - log.Debug(ctx, fmt.Sprintf("Scanner: Finished phase %d", phaseNum), "elapsed", time.Since(start), "totalTasks", counter.Load()) - } - - return err } } -func countTasks[T any]() (*atomic.Int64, func(T) (T, error)) { - counter := atomic.Int64{} - return &counter, func(in T) (T, error) { - counter.Add(1) - return in, nil - } +func (s *scanner) newScanner(f model.MediaFolder) FolderScanner { + return NewTagScanner(f.Path, s.ds, s.pls, s.cacheWarmer) } - -var _ scanner = (*scannerImpl)(nil) diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go deleted file mode 100644 index e6797df36..000000000 --- a/scanner/scanner_benchmark_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package scanner_test - -import ( - "context" - "database/sql" - "fmt" - "path/filepath" - "runtime" - "testing" - "testing/fstest" - - "github.com/dustin/go-humanize" - "github.com/google/uuid" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/metrics" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/scanner" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/singleton" - "go.uber.org/goleak" -) - -func BenchmarkScan(b *testing.B) { - // Detect any goroutine leaks in the scanner code under test - defer goleak.VerifyNone(b, - goleak.IgnoreTopFunction("testing.(*B).run1"), - goleak.IgnoreAnyFunction("testing.(*B).doBench"), - // Ignore database/sql.(*DB).connectionOpener, as we are not closing the database connection - goleak.IgnoreAnyFunction("database/sql.(*DB).connectionOpener"), - // A preceding TestScanner leaves Ginkgo's interrupt handler running. - goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), - // The notify library keeps watcher goroutines alive after Stop(); recursive on macOS, nonrecursive on Linux. - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), - ) - - tests.Init(b, false) - - tmpDir := b.TempDir() - conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") - // The default library is seeded from MusicFolder, and its path cannot be changed afterwards - conf.Server.MusicFolder = "fake:///music" - // TestScanner may run first and close the shared DB singleton; drop it so db.Init - // opens a fresh one whether or not the test suite ran before this benchmark. - singleton.DeleteInstance[*sql.DB]() - // Close before b.TempDir cleanup runs, or Windows cannot delete the open DB/WAL files. - defer db.Init(context.Background())() - - ds := persistence.New(db.Db()) - conf.Server.DevExternalScanner = false - s := scanner.New(context.Background(), ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) - - fs := storagetest.FakeFS{} - storagetest.Register("fake", &fs) - var beatlesMBID = uuid.NewString() - beatles := _t{ - "artist": "The Beatles", - "artistsort": "Beatles, The", - "musicbrainz_artistid": beatlesMBID, - "albumartist": "The Beatles", - "albumartistsort": "Beatles The", - "musicbrainz_albumartistid": beatlesMBID, - } - revolver := template(beatles, _t{"album": "Revolver", "year": 1966, "composer": "Lennon/McCartney"}) - help := template(beatles, _t{"album": "Help!", "year": 1965, "composer": "Lennon/McCartney"}) - fs.SetFiles(fstest.MapFS{ - "The Beatles/Revolver/01 - Taxman.mp3": revolver(track(1, "Taxman")), - "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(track(2, "Eleanor Rigby")), - "The Beatles/Revolver/03 - I'm Only Sleeping.mp3": revolver(track(3, "I'm Only Sleeping")), - "The Beatles/Revolver/04 - Love You To.mp3": revolver(track(4, "Love You To")), - "The Beatles/Help!/01 - Help!.mp3": help(track(1, "Help!")), - "The Beatles/Help!/02 - The Night Before.mp3": help(track(2, "The Night Before")), - "The Beatles/Help!/03 - You've Got to Hide Your Love Away.mp3": help(track(3, "You've Got to Hide Your Love Away")), - }) - - lib := model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} - err := ds.Library().Put(b.Context(), &lib) - if err != nil { - b.Fatal(err) - } - - var m1, m2 runtime.MemStats - runtime.GC() - runtime.ReadMemStats(&m1) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, err := s.ScanAll(context.Background(), true) - if err != nil { - b.Fatal(err) - } - } - - runtime.ReadMemStats(&m2) - fmt.Println("total:", humanize.Bytes(m2.TotalAlloc-m1.TotalAlloc)) - fmt.Println("mallocs:", humanize.Comma(int64(m2.Mallocs-m1.Mallocs))) -} diff --git a/scanner/scanner_internal_test.go b/scanner/scanner_internal_test.go deleted file mode 100644 index e8abb7c7d..000000000 --- a/scanner/scanner_internal_test.go +++ /dev/null @@ -1,98 +0,0 @@ -// nolint unused -package scanner - -import ( - "context" - "errors" - "sync/atomic" - - ppl "github.com/google/go-pipeline/pkg/pipeline" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type mockPhase struct { - num int - produceFunc func() ppl.Producer[int] - stagesFunc func() []ppl.Stage[int] - finalizeFunc func(error) error - descriptionFn func() string -} - -func (m *mockPhase) producer() ppl.Producer[int] { - return m.produceFunc() -} - -func (m *mockPhase) stages() []ppl.Stage[int] { - return m.stagesFunc() -} - -func (m *mockPhase) finalize(err error) error { - return m.finalizeFunc(err) -} - -func (m *mockPhase) description() string { - return m.descriptionFn() -} - -var _ = Describe("runPhase", func() { - var ( - ctx context.Context - phaseNum int - phase *mockPhase - sum atomic.Int32 - ) - - BeforeEach(func() { - ctx = context.Background() - phaseNum = 1 - phase = &mockPhase{ - num: 3, - produceFunc: func() ppl.Producer[int] { - return ppl.NewProducer(func(put func(int)) error { - for i := 1; i <= phase.num; i++ { - put(i) - } - return nil - }) - }, - stagesFunc: func() []ppl.Stage[int] { - return []ppl.Stage[int]{ppl.NewStage(func(i int) (int, error) { - sum.Add(int32(i)) - return i, nil - })} - }, - finalizeFunc: func(err error) error { - return err - }, - descriptionFn: func() string { - return "Mock Phase" - }, - } - }) - - It("should run the phase successfully", func() { - err := runPhase(ctx, phaseNum, phase)() - Expect(err).ToNot(HaveOccurred()) - Expect(sum.Load()).To(Equal(int32(1 * 2 * 3))) - }) - - It("should log an error if the phase fails", func() { - phase.finalizeFunc = func(err error) error { - return errors.New("finalize error") - } - err := runPhase(ctx, phaseNum, phase)() - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("finalize error")) - }) - - It("should count the tasks", func() { - counter, countStageFn := countTasks[int]() - phase.stagesFunc = func() []ppl.Stage[int] { - return []ppl.Stage[int]{ppl.NewStage(countStageFn, ppl.Name("count tasks"))} - } - err := runPhase(ctx, phaseNum, phase)() - Expect(err).ToNot(HaveOccurred()) - Expect(counter.Load()).To(Equal(int64(3))) - }) -}) diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go deleted file mode 100644 index f6634c875..000000000 --- a/scanner/scanner_multilibrary_test.go +++ /dev/null @@ -1,1004 +0,0 @@ -package scanner_test - -import ( - "context" - "errors" - "path/filepath" - "testing/fstest" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/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/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Scanner - Multi-Library", Ordered, func() { - var ctx context.Context - var lib1, lib2 model.Library - var ds *tests.MockDataStore - var s model.Scanner - - createFS := func(path string, files fstest.MapFS) storagetest.FakeFS { - fs := storagetest.FakeFS{} - fs.SetFiles(files) - storagetest.Register(path, &fs) - return fs - } - - 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") - log.Warn("Using DB at " + conf.Server.DbPath) - db.Db().SetMaxOpenConns(1) - }) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.MusicFolder = "default:///music" // Use a distinct schema for the default library - conf.Server.DevExternalScanner = false - - // Register an empty fake storage for the default library - emptyFS := storagetest.FakeFS{} - emptyFS.SetFiles(fstest.MapFS{}) - storagetest.Register("default", &emptyFS) - - db.Init(ctx) - DeferCleanup(func() { - Expect(tests.ClearDB()).To(Succeed()) - }) - - ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - - // Create the admin user in the database to match the context - adminUser := model.User{ - ID: "123", - UserName: "admin", - Name: "Admin User", - IsAdmin: true, - NewPassword: "password", - } - Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) - - s = scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) - - // Create two test libraries (let DB auto-assign IDs) - lib1 = model.Library{Name: "Rock Collection", Path: "rock:///music"} - lib2 = model.Library{Name: "Jazz Collection", Path: "jazz:///music"} - Expect(ds.Library().Put(ctx, &lib1)).To(Succeed()) - Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) - }) - - runScanner := func(ctx context.Context, fullScan bool) error { - _, err := s.ScanAll(ctx, fullScan) - return err - } - - Context("Two Libraries with Different Content", func() { - BeforeEach(func() { - // Rock library content - beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) - zeppelin := template(_t{"albumartist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) - - _ = createFS("rock", fstest.MapFS{ - "The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")), - "The Beatles/Abbey Road/02 - Something.mp3": beatles(track(2, "Something")), - "Led Zeppelin/IV/01 - Black Dog.mp3": zeppelin(track(1, "Black Dog")), - "Led Zeppelin/IV/02 - Rock and Roll.mp3": zeppelin(track(2, "Rock and Roll")), - }) - - // Jazz library content - miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) - coltrane := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960, "genre": "Jazz"}) - - _ = createFS("jazz", fstest.MapFS{ - "Miles Davis/Kind of Blue/01 - So What.mp3": miles(track(1, "So What")), - "Miles Davis/Kind of Blue/02 - Freddie Freeloader.mp3": miles(track(2, "Freddie Freeloader")), - "John Coltrane/Giant Steps/01 - Giant Steps.mp3": coltrane(track(1, "Giant Steps")), - "John Coltrane/Giant Steps/02 - Cousin Mary.mp3": coltrane(track(2, "Cousin Mary")), - }) - }) - - When("scanning both libraries", func() { - It("should import files with correct library_id", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - // Check Rock library media files - rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib1.ID}, - Sort: "title", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(rockFiles).To(HaveLen(4)) - - rockTitles := slice.Map(rockFiles, func(f model.MediaFile) string { return f.Title }) - Expect(rockTitles).To(ContainElements("Come Together", "Something", "Black Dog", "Rock and Roll")) - - // Verify all rock files have correct library_id - for _, mf := range rockFiles { - Expect(mf.LibraryID).To(Equal(lib1.ID), "Rock file %s should have library_id %d", mf.Title, lib1.ID) - } - - // Check Jazz library media files - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib2.ID}, - Sort: "title", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzFiles).To(HaveLen(4)) - - jazzTitles := slice.Map(jazzFiles, func(f model.MediaFile) string { return f.Title }) - Expect(jazzTitles).To(ContainElements("So What", "Freddie Freeloader", "Giant Steps", "Cousin Mary")) - - // Verify all jazz files have correct library_id - for _, mf := range jazzFiles { - Expect(mf.LibraryID).To(Equal(lib2.ID), "Jazz file %s should have library_id %d", mf.Title, lib2.ID) - } - }) - - It("should create albums with correct library_id", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - // Check Rock library albums - rockAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib1.ID}, - Sort: "name", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(rockAlbums).To(HaveLen(2)) - Expect(rockAlbums[0].Name).To(Equal("Abbey Road")) - Expect(rockAlbums[0].LibraryID).To(Equal(lib1.ID)) - Expect(rockAlbums[0].SongCount).To(Equal(2)) - Expect(rockAlbums[1].Name).To(Equal("IV")) - Expect(rockAlbums[1].LibraryID).To(Equal(lib1.ID)) - Expect(rockAlbums[1].SongCount).To(Equal(2)) - - // Check Jazz library albums - jazzAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib2.ID}, - Sort: "name", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzAlbums).To(HaveLen(2)) - Expect(jazzAlbums[0].Name).To(Equal("Giant Steps")) - Expect(jazzAlbums[0].LibraryID).To(Equal(lib2.ID)) - Expect(jazzAlbums[0].SongCount).To(Equal(2)) - Expect(jazzAlbums[1].Name).To(Equal("Kind of Blue")) - Expect(jazzAlbums[1].LibraryID).To(Equal(lib2.ID)) - Expect(jazzAlbums[1].SongCount).To(Equal(2)) - }) - - It("should create folders with correct library_id", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - // Check Rock library folders - rockFolders, err := ds.Folder().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib1.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(rockFolders).To(HaveLen(5)) // ., The Beatles, Led Zeppelin, Abbey Road, IV - - for _, folder := range rockFolders { - Expect(folder.LibraryID).To(Equal(lib1.ID), "Rock folder %s should have library_id %d", folder.Name, lib1.ID) - } - - // Check Jazz library folders - jazzFolders, err := ds.Folder().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib2.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzFolders).To(HaveLen(5)) // ., Miles Davis, John Coltrane, Kind of Blue, Giant Steps - - for _, folder := range jazzFolders { - Expect(folder.LibraryID).To(Equal(lib2.ID), "Jazz folder %s should have library_id %d", folder.Name, lib2.ID) - } - }) - - It("should create library-artist associations correctly", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - // Check library-artist associations - - // Get all artists and check library associations - allArtists, err := ds.Artist().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - - rockArtistNames := []string{} - jazzArtistNames := []string{} - - for _, artist := range allArtists { - // Check if artist is associated with rock library - var count int64 - err := db.Db().QueryRow( - "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?", - lib1.ID, artist.ID, - ).Scan(&count) - Expect(err).ToNot(HaveOccurred()) - if count > 0 { - rockArtistNames = append(rockArtistNames, artist.Name) - } - - // Check if artist is associated with jazz library - err = db.Db().QueryRow( - "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?", - lib2.ID, artist.ID, - ).Scan(&count) - Expect(err).ToNot(HaveOccurred()) - if count > 0 { - jazzArtistNames = append(jazzArtistNames, artist.Name) - } - } - - Expect(rockArtistNames).To(ContainElements("The Beatles", "Led Zeppelin")) - Expect(jazzArtistNames).To(ContainElements("Miles Davis", "John Coltrane")) - - // Artists should not be shared between libraries (except [Unknown Artist]) - for _, name := range rockArtistNames { - if name != "[Unknown Artist]" { - Expect(jazzArtistNames).ToNot(ContainElement(name)) - } - } - }) - - It("should update library statistics correctly", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - // Check Rock library stats - rockLib, err := ds.Library().Get(ctx, lib1.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(rockLib.TotalSongs).To(Equal(4)) - Expect(rockLib.TotalAlbums).To(Equal(2)) - - Expect(rockLib.TotalArtists).To(Equal(3)) // The Beatles, Led Zeppelin, [Unknown Artist] - Expect(rockLib.TotalFolders).To(Equal(2)) // Abbey Road, IV (only folders with audio files) - - // Check Jazz library stats - jazzLib, err := ds.Library().Get(ctx, lib2.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzLib.TotalSongs).To(Equal(4)) - Expect(jazzLib.TotalAlbums).To(Equal(2)) - Expect(jazzLib.TotalArtists).To(Equal(3)) // Miles Davis, John Coltrane, [Unknown Artist] - Expect(jazzLib.TotalFolders).To(Equal(2)) // Kind of Blue, Giant Steps (only folders with audio files) - }) - }) - - When("libraries have different content", func() { - It("should maintain separate statistics per library", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - // Verify rock library stats - rockLib, err := ds.Library().Get(ctx, lib1.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(rockLib.TotalSongs).To(Equal(4)) - Expect(rockLib.TotalAlbums).To(Equal(2)) - - // Verify jazz library stats - jazzLib, err := ds.Library().Get(ctx, lib2.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzLib.TotalSongs).To(Equal(4)) - Expect(jazzLib.TotalAlbums).To(Equal(2)) - - // Verify that libraries don't interfere with each other - rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib1.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(rockFiles).To(HaveLen(4)) - - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib2.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzFiles).To(HaveLen(4)) - }) - }) - - When("verifying library isolation", func() { - It("should keep library data completely separate", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - // Verify that rock library only contains rock content - rockAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib1.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - rockAlbumNames := slice.Map(rockAlbums, func(a model.Album) string { return a.Name }) - Expect(rockAlbumNames).To(ContainElements("Abbey Road", "IV")) - Expect(rockAlbumNames).ToNot(ContainElements("Kind of Blue", "Giant Steps")) - - // Verify that jazz library only contains jazz content - jazzAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib2.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - jazzAlbumNames := slice.Map(jazzAlbums, func(a model.Album) string { return a.Name }) - Expect(jazzAlbumNames).To(ContainElements("Kind of Blue", "Giant Steps")) - Expect(jazzAlbumNames).ToNot(ContainElements("Abbey Road", "IV")) - }) - }) - - When("same artist appears in different libraries", func() { - It("should associate artist with both libraries correctly", func() { - // Create libraries with Jeff Beck albums in both - jeffRock := template(_t{"albumartist": "Jeff Beck", "album": "Truth", "year": 1968, "genre": "Rock"}) - jeffJazz := template(_t{"albumartist": "Jeff Beck", "album": "Blow by Blow", "year": 1975, "genre": "Jazz"}) - beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) - miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) - - // Create rock library with Jeff Beck's Truth album - _ = createFS("rock", fstest.MapFS{ - "The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")), - "The Beatles/Abbey Road/02 - Something.mp3": beatles(track(2, "Something")), - "Jeff Beck/Truth/01 - Beck's Bolero.mp3": jeffRock(track(1, "Beck's Bolero")), - "Jeff Beck/Truth/02 - Ol' Man River.mp3": jeffRock(track(2, "Ol' Man River")), - }) - - // Create jazz library with Jeff Beck's Blow by Blow album - _ = createFS("jazz", fstest.MapFS{ - "Miles Davis/Kind of Blue/01 - So What.mp3": miles(track(1, "So What")), - "Miles Davis/Kind of Blue/02 - Freddie Freeloader.mp3": miles(track(2, "Freddie Freeloader")), - "Jeff Beck/Blow by Blow/01 - You Know What I Mean.mp3": jeffJazz(track(1, "You Know What I Mean")), - "Jeff Beck/Blow by Blow/02 - She's a Woman.mp3": jeffJazz(track(2, "She's a Woman")), - }) - - Expect(runScanner(ctx, true)).To(Succeed()) - - // Jeff Beck should be associated with both libraries - var rockCount, jazzCount int64 - - // Get Jeff Beck artist ID - jeffArtists, err := ds.Artist().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"name": "Jeff Beck"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(jeffArtists).To(HaveLen(1)) - jeffID := jeffArtists[0].ID - - // Check rock library association - err = db.Db().QueryRow( - "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?", - lib1.ID, jeffID, - ).Scan(&rockCount) - Expect(err).ToNot(HaveOccurred()) - Expect(rockCount).To(Equal(int64(1))) - - // Check jazz library association - err = db.Db().QueryRow( - "SELECT COUNT(*) FROM library_artist WHERE library_id = ? AND artist_id = ?", - lib2.ID, jeffID, - ).Scan(&jazzCount) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzCount).To(Equal(int64(1))) - - // Verify Jeff Beck albums are in correct libraries - rockAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib1.ID, "album_artist": "Jeff Beck"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(rockAlbums).To(HaveLen(1)) - Expect(rockAlbums[0].Name).To(Equal("Truth")) - - jazzAlbums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib2.ID, "album_artist": "Jeff Beck"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzAlbums).To(HaveLen(1)) - Expect(jazzAlbums[0].Name).To(Equal("Blow by Blow")) - }) - }) - }) - - Context("Incremental Scan Behavior", func() { - BeforeEach(func() { - // Start with minimal content in both libraries - rock := template(_t{"albumartist": "Queen", "album": "News of the World", "year": 1977, "genre": "Rock"}) - jazz := template(_t{"albumartist": "Bill Evans", "album": "Waltz for Debby", "year": 1961, "genre": "Jazz"}) - - createFS("rock", fstest.MapFS{ - "Queen/News of the World/01 - We Will Rock You.mp3": rock(track(1, "We Will Rock You")), - }) - - createFS("jazz", fstest.MapFS{ - "Bill Evans/Waltz for Debby/01 - My Foolish Heart.mp3": jazz(track(1, "My Foolish Heart")), - }) - }) - - It("should handle incremental scans per library correctly", func() { - // Initial full scan - Expect(runScanner(ctx, true)).To(Succeed()) - - // Verify initial state - rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib1.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(rockFiles).To(HaveLen(1)) - - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib2.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzFiles).To(HaveLen(1)) - - // Incremental scan should not duplicate existing files - Expect(runScanner(ctx, false)).To(Succeed()) - - // Verify counts remain the same - rockFiles, err = ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib1.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(rockFiles).To(HaveLen(1)) - - jazzFiles, err = ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib2.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzFiles).To(HaveLen(1)) - }) - }) - - Context("Missing Files Handling", func() { - var rockFS storagetest.FakeFS - - BeforeEach(func() { - rock := template(_t{"albumartist": "AC/DC", "album": "Back in Black", "year": 1980, "genre": "Rock"}) - - rockFS = createFS("rock", fstest.MapFS{ - "AC-DC/Back in Black/01 - Hells Bells.mp3": rock(track(1, "Hells Bells")), - "AC-DC/Back in Black/02 - Shoot to Thrill.mp3": rock(track(2, "Shoot to Thrill")), - }) - - createFS("jazz", fstest.MapFS{ - "Herbie Hancock/Head Hunters/01 - Chameleon.mp3": template(_t{ - "albumartist": "Herbie Hancock", "album": "Head Hunters", "year": 1973, "genre": "Jazz", - })(track(1, "Chameleon")), - }) - }) - - It("should mark missing files correctly per library", func() { - // Initial scan - Expect(runScanner(ctx, true)).To(Succeed()) - - // Remove one file from rock library only - rockFS.Remove("AC-DC/Back in Black/02 - Shoot to Thrill.mp3") - - // Rescan - Expect(runScanner(ctx, false)).To(Succeed()) - - // Check that only the rock library file is marked as missing - missingRockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"library_id": lib1.ID}, - squirrel.Eq{"missing": true}, - }, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(missingRockFiles).To(HaveLen(1)) - Expect(missingRockFiles[0].Title).To(Equal("Shoot to Thrill")) - - // Check that jazz library files are not affected - missingJazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"library_id": lib2.ID}, - squirrel.Eq{"missing": true}, - }, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(missingJazzFiles).To(HaveLen(0)) - - // Verify non-missing files - presentRockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"library_id": lib1.ID}, - squirrel.Eq{"missing": false}, - }, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(presentRockFiles).To(HaveLen(1)) - Expect(presentRockFiles[0].Title).To(Equal("Hells Bells")) - }) - }) - - Context("Error Handling - Multi-Library", func() { - Context("Filesystem errors affecting one library", func() { - var rockFS storagetest.FakeFS - - BeforeEach(func() { - // Set up content for both libraries - rock := template(_t{"albumartist": "AC/DC", "album": "Back in Black", "year": 1980, "genre": "Rock"}) - jazz := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) - - rockFS = createFS("rock", fstest.MapFS{ - "AC-DC/Back in Black/01 - Hells Bells.mp3": rock(track(1, "Hells Bells")), - "AC-DC/Back in Black/02 - Shoot to Thrill.mp3": rock(track(2, "Shoot to Thrill")), - }) - - createFS("jazz", fstest.MapFS{ - "Miles Davis/Kind of Blue/01 - So What.mp3": jazz(track(1, "So What")), - "Miles Davis/Kind of Blue/02 - Freddie Freeloader.mp3": jazz(track(2, "Freddie Freeloader")), - }) - }) - - It("should not affect scanning of other libraries", func() { - // Inject filesystem read error in rock library only - rockFS.SetError("AC-DC/Back in Black/01 - Hells Bells.mp3", errors.New("filesystem read error")) - - // Scan should succeed overall and return warnings - warnings, err := s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - Expect(warnings).ToNot(BeEmpty(), "Should have warnings for filesystem errors") - - // Jazz library should have been scanned successfully - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib2.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzFiles).To(HaveLen(2)) - Expect(jazzFiles[0].Title).To(BeElementOf("So What", "Freddie Freeloader")) - Expect(jazzFiles[1].Title).To(BeElementOf("So What", "Freddie Freeloader")) - - // Rock library may have partial content (depending on scanner implementation) - rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib1.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - // No specific expectation - some files may have been imported despite errors - _ = rockFiles - - // Verify jazz library stats are correct - jazzLib, err := ds.Library().Get(ctx, lib2.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzLib.TotalSongs).To(Equal(2)) - - // Error should be empty (warnings don't count as scan errors) - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") - Expect(err).ToNot(HaveOccurred()) - Expect(lastError).To(BeEmpty()) - }) - - It("should continue with warnings for affected library", func() { - // Inject read errors on multiple files in rock library - rockFS.SetError("AC-DC/Back in Black/01 - Hells Bells.mp3", errors.New("read error 1")) - rockFS.SetError("AC-DC/Back in Black/02 - Shoot to Thrill.mp3", errors.New("read error 2")) - - // Scan should complete with warnings for multiple filesystem errors - warnings, err := s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - Expect(warnings).ToNot(BeEmpty(), "Should have warnings for multiple filesystem errors") - - // Jazz library should be completely unaffected - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib2.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzFiles).To(HaveLen(2)) - - // Jazz library statistics should be accurate - jazzLib, err := ds.Library().Get(ctx, lib2.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzLib.TotalSongs).To(Equal(2)) - Expect(jazzLib.TotalAlbums).To(Equal(1)) - - // Error should be empty (warnings don't count as scan errors) - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") - Expect(err).ToNot(HaveOccurred()) - Expect(lastError).To(BeEmpty()) - }) - }) - - Context("Database errors during multi-library scanning", func() { - BeforeEach(func() { - // Set up content for both libraries - rock := template(_t{"albumartist": "Queen", "album": "News of the World", "year": 1977, "genre": "Rock"}) - jazz := template(_t{"albumartist": "Bill Evans", "album": "Waltz for Debby", "year": 1961, "genre": "Jazz"}) - - createFS("rock", fstest.MapFS{ - "Queen/News of the World/01 - We Will Rock You.mp3": rock(track(1, "We Will Rock You")), - }) - - createFS("jazz", fstest.MapFS{ - "Bill Evans/Waltz for Debby/01 - My Foolish Heart.mp3": jazz(track(1, "My Foolish Heart")), - }) - }) - - It("should propagate database errors and stop scanning", func() { - // Install mock repo that injects DB error - mfRepo := &mockMediaFileRepo{ - MediaFileRepository: ds.RealDS.MediaFile(), - GetMissingAndMatchingError: errors.New("database connection failed"), - } - ds.MockedMediaFile = mfRepo - - // Scan should return the database error - Expect(runScanner(ctx, false)).To(MatchError(ContainSubstring("database connection failed"))) - - // Error should be recorded in scanner properties - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "") - Expect(err).ToNot(HaveOccurred()) - Expect(lastError).To(ContainSubstring("database connection failed")) - }) - - It("should preserve error information in scanner properties", func() { - // Install mock repo that injects DB error - mfRepo := &mockMediaFileRepo{ - MediaFileRepository: ds.RealDS.MediaFile(), - GetMissingAndMatchingError: errors.New("critical database error"), - } - ds.MockedMediaFile = mfRepo - - // Attempt scan (should fail) - Expect(runScanner(ctx, false)).To(HaveOccurred()) - - // Check that error is recorded in scanner properties - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "") - Expect(err).ToNot(HaveOccurred()) - Expect(lastError).To(ContainSubstring("critical database error")) - - // Scan type should still be recorded - scanType, _ := ds.Property().DefaultGet(ctx, consts.LastScanTypeKey, "") - Expect(scanType).To(BeElementOf("incremental", "quick")) - }) - }) - - Context("Mixed error scenarios", func() { - var rockFS storagetest.FakeFS - - BeforeEach(func() { - // Set up rock library with filesystem that can error - rock := template(_t{"albumartist": "Metallica", "album": "Master of Puppets", "year": 1986, "genre": "Metal"}) - rockFS = createFS("rock", fstest.MapFS{ - "Metallica/Master of Puppets/01 - Battery.mp3": rock(track(1, "Battery")), - "Metallica/Master of Puppets/02 - Master of Puppets.mp3": rock(track(2, "Master of Puppets")), - }) - - // Set up jazz library normally - jazz := template(_t{"albumartist": "Herbie Hancock", "album": "Head Hunters", "year": 1973, "genre": "Jazz"}) - createFS("jazz", fstest.MapFS{ - "Herbie Hancock/Head Hunters/01 - Chameleon.mp3": jazz(track(1, "Chameleon")), - }) - }) - - It("should handle filesystem errors in one library while other succeeds", func() { - // Inject filesystem error in rock library - rockFS.SetError("Metallica/Master of Puppets/01 - Battery.mp3", errors.New("disk read error")) - - // Scan should complete with warnings (not hard error) - warnings, err := s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - Expect(warnings).ToNot(BeEmpty(), "Should have warnings for filesystem error") - - // Jazz library should scan completely successfully - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib2.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzFiles).To(HaveLen(1)) - Expect(jazzFiles[0].Title).To(Equal("Chameleon")) - - // Jazz library statistics should be accurate - jazzLib, err := ds.Library().Get(ctx, lib2.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzLib.TotalSongs).To(Equal(1)) - Expect(jazzLib.TotalAlbums).To(Equal(1)) - - // Rock library may have partial content (depending on scanner implementation) - rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib1.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - // No specific expectation - some files may have been imported despite errors - _ = rockFiles - - // Error should be empty (warnings don't count as scan errors) - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") - Expect(err).ToNot(HaveOccurred()) - Expect(lastError).To(BeEmpty()) - }) - - It("should handle partial failures gracefully", func() { - // Create a scenario where rock has filesystem issues and jazz has normal content - rockFS.SetError("Metallica/Master of Puppets/01 - Battery.mp3", errors.New("file corruption")) - - // Do an initial scan with filesystem error - warnings, err := s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - Expect(warnings).ToNot(BeEmpty(), "Should have warnings for file corruption") - - // Verify that the working parts completed successfully - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib2.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzFiles).To(HaveLen(1)) - - // Scanner properties should reflect successful completion despite warnings - scanType, _ := ds.Property().DefaultGet(ctx, consts.LastScanTypeKey, "") - Expect(scanType).To(Equal("full")) - - // Start time should be recorded - startTimeStr, _ := ds.Property().DefaultGet(ctx, consts.LastScanStartTimeKey, "") - Expect(startTimeStr).ToNot(BeEmpty()) - - // Error should be empty (warnings don't count as scan errors) - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") - Expect(err).ToNot(HaveOccurred()) - Expect(lastError).To(BeEmpty()) - }) - }) - - Context("Error recovery in multi-library context", func() { - It("should recover from previous library-specific errors", func() { - // Set up initial content - rock := template(_t{"albumartist": "Iron Maiden", "album": "The Number of the Beast", "year": 1982, "genre": "Metal"}) - jazz := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960, "genre": "Jazz"}) - - rockFS := createFS("rock", fstest.MapFS{ - "Iron Maiden/The Number of the Beast/01 - Invaders.mp3": rock(track(1, "Invaders")), - }) - - createFS("jazz", fstest.MapFS{ - "John Coltrane/Giant Steps/01 - Giant Steps.mp3": jazz(track(1, "Giant Steps")), - }) - - // First scan with filesystem error in rock - rockFS.SetError("Iron Maiden/The Number of the Beast/01 - Invaders.mp3", errors.New("temporary disk error")) - warnings, err := s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) // Should succeed with warnings - Expect(warnings).ToNot(BeEmpty(), "Should have warnings for temporary disk error") - - // Clear the error and add more content - recreate the filesystem completely - rockFS.ClearError("Iron Maiden/The Number of the Beast/01 - Invaders.mp3") - - // Create a new filesystem with both files - createFS("rock", fstest.MapFS{ - "Iron Maiden/The Number of the Beast/01 - Invaders.mp3": rock(track(1, "Invaders")), - "Iron Maiden/The Number of the Beast/02 - Children of the Damned.mp3": rock(track(2, "Children of the Damned")), - }) - - // Second scan should recover and import all rock content - warnings, err = s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - Expect(warnings).To(BeEmpty(), "Should have no warnings after error recovery") - - // Verify both libraries now have content (at least jazz should work) - rockFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib1.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - // The scanner should recover and import both rock files - Expect(len(rockFiles)).To(Equal(2)) - - jazzFiles, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": lib2.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzFiles).To(HaveLen(1)) - - // Both libraries should have correct content counts - rockLib, err := ds.Library().Get(ctx, lib1.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(rockLib.TotalSongs).To(Equal(2)) - - jazzLib, err := ds.Library().Get(ctx, lib2.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(jazzLib.TotalSongs).To(Equal(1)) - - // Error should be empty (successful recovery) - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") - Expect(err).ToNot(HaveOccurred()) - Expect(lastError).To(BeEmpty()) - }) - }) - }) - - Context("Scanner Properties", func() { - It("should persist last scan type, start time and error properties", func() { - // trivial FS setup - rock := template(_t{"albumartist": "AC/DC", "album": "Back in Black", "year": 1980, "genre": "Rock"}) - _ = createFS("rock", fstest.MapFS{ - "AC-DC/Back in Black/01 - Hells Bells.mp3": rock(track(1, "Hells Bells")), - }) - - // Run a full scan - Expect(runScanner(ctx, true)).To(Succeed()) - - // Validate properties - scanType, _ := ds.Property().DefaultGet(ctx, consts.LastScanTypeKey, "") - Expect(scanType).To(Equal("full")) - - startTimeStr, _ := ds.Property().DefaultGet(ctx, consts.LastScanStartTimeKey, "") - Expect(startTimeStr).ToNot(BeEmpty()) - _, err := time.Parse(time.RFC3339, startTimeStr) - Expect(err).ToNot(HaveOccurred()) - - lastError, err := ds.Property().DefaultGet(ctx, consts.LastScanErrorKey, "unset") - Expect(err).ToNot(HaveOccurred()) - Expect(lastError).To(BeEmpty()) - }) - }) - - Context("Per-library PID config", func() { - albumsOf := func(libID int) model.Albums { - // The mock datastore's GC is a no-op, so run the real one to purge the albums left - // empty by a regroup, as the scanner does in production - Expect(ds.RealDS.GC(ctx)).To(Succeed()) - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": libID, "missing": false}, - Sort: "name", - }) - Expect(err).ToNot(HaveOccurred()) - return albums - } - trackByTitle := func(libID int, title string) model.MediaFile { - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"library_id": libID, "title": title}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).To(HaveLen(1)) - return mfs[0] - } - rockTitles := func() []string { - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib1.ID}}) - Expect(err).ToNot(HaveOccurred()) - return slice.Map(mfs, func(mf model.MediaFile) string { return mf.Title }) - } - // changeRockInDB edits the rock track in the DB only. A full rescan of the rock library would - // restore the title from the file tags, a quick scan leaves it alone. - changeRockInDB := func() { - _, err := db.Db().ExecContext(ctx, "update media_file set title = 'Changed In DB' where library_id = ?", lib1.ID) - Expect(err).ToNot(HaveOccurred()) - } - // changeBlueTrainInDB does the same for one jazz track - changeBlueTrainInDB := func() { - _, err := db.Db().ExecContext(ctx, "update media_file set title = 'Blue Train In DB' where library_id = ? and title = 'Blue Train'", lib2.ID) - Expect(err).ToNot(HaveOccurred()) - } - - BeforeEach(func() { - beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969}) - _ = createFS("rock", fstest.MapFS{ - "The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")), - }) - - miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959}) - coltrane := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960}) - blueTrain := template(_t{"albumartist": "John Coltrane", "album": "Blue Train", "year": 1957}) - _ = createFS("jazz", fstest.MapFS{ - "Loose/01 - So What.mp3": miles(track(1, "So What")), - "Loose/02 - Giant Steps.mp3": coltrane(track(1, "Giant Steps")), - "Coltrane/Blue Train/01 - Blue Train.mp3": blueTrain(track(1, "Blue Train")), - }) - }) - - It("regroups only the library whose PID config changed, keeping annotations", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - Expect(albumsOf(lib2.ID)).To(HaveLen(3)) - - // Star Blue Train, to check the star follows the album to its new ID - oldBlueTrain := trackByTitle(lib2.ID, "Blue Train") - Expect(ds.Album().SetStar(ctx, true, oldBlueTrain.AlbumID)).To(Succeed()) - changeRockInDB() - - lib2.PIDAlbum = "folder" - Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) - Expect(runScanner(ctx, false)).To(Succeed()) - - // Jazz is grouped by folder now: "Loose" is one album - Expect(albumsOf(lib2.ID)).To(HaveLen(2)) - Expect(trackByTitle(lib2.ID, "So What").AlbumID).To(Equal(trackByTitle(lib2.ID, "Giant Steps").AlbumID)) - - newBlueTrain := trackByTitle(lib2.ID, "Blue Train") - Expect(newBlueTrain.AlbumID).ToNot(Equal(oldBlueTrain.AlbumID)) - album, err := ds.Album().Get(ctx, newBlueTrain.AlbumID) - Expect(err).ToNot(HaveOccurred()) - Expect(album.Starred).To(BeTrue()) - - // Rock only got a quick scan - Expect(rockTitles()).To(ConsistOf("Changed In DB")) - - jazz, err := ds.Library().Get(ctx, lib2.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(jazz.ScannedPIDAlbum).To(Equal("folder")) - Expect(jazz.PIDChanged()).To(BeFalse()) - rock, err := ds.Library().Get(ctx, lib1.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(rock.PIDChanged()).To(BeFalse()) - }) - - It("rescans only libraries that follow the global config", func() { - lib2.PIDAlbum = "folder" - Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) - Expect(runScanner(ctx, true)).To(Succeed()) - changeRockInDB() - changeBlueTrainInDB() - - conf.Server.PID.Album = "album" - Expect(runScanner(ctx, false)).To(Succeed()) - - // Rock follows the global config, so it was rescanned in full and its title restored - Expect(rockTitles()).To(ConsistOf("Come Together")) - // Jazz has its own override, so it only got a quick scan - trackByTitle(lib2.ID, "Blue Train In DB") - jazz, err := ds.Library().Get(ctx, lib2.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(jazz.ScannedPIDAlbum).To(Equal("folder")) - }) - - It("restarts an interrupted scan when the PID config changed meanwhile", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - // Simulate a quick scan of jazz that was interrupted after it had processed every folder: - // the folders were updated after the (old) scan start time - _, err := db.Db().ExecContext(ctx, "update library set last_scan_started_at = ?, full_scan_in_progress = false where id = ?", - time.Now().Add(-time.Hour), lib2.ID) - Expect(err).ToNot(HaveOccurred()) - - lib2.PIDAlbum = "folder" - Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) - Expect(runScanner(ctx, false)).To(Succeed()) - - // Every folder was revisited with the new config - Expect(albumsOf(lib2.ID)).To(HaveLen(2)) - }) - - It("does not turn an interrupted PID rescan into a full scan of every library", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - changeRockInDB() - lib2.PIDAlbum = "folder" - Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) - - // Simulate a PID full scan of jazz that was interrupted - Expect(ds.Library().ScanBegin(ctx, lib2.ID, true)).To(Succeed()) - Expect(runScanner(ctx, false)).To(Succeed()) - - // Rock only got a quick scan, jazz was rescanned with the new config - Expect(rockTitles()).To(ConsistOf("Changed In DB")) - Expect(albumsOf(lib2.ID)).To(HaveLen(2)) - }) - - It("does not record the PID config for a library that could not be scanned", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - broken := model.Library{Name: "Broken", Path: "unregistered:///music", PIDAlbum: "folder"} - Expect(ds.Library().Put(ctx, &broken)).To(Succeed()) - - // The scan reports an error for the broken library, and still finishes the others - _ = runScanner(ctx, false) - - reloaded, err := ds.Library().Get(ctx, broken.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(reloaded.PIDChanged()).To(BeTrue()) - }) - - It("does not record the PID config after a selective scan", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - lib2.PIDAlbum = "folder" - Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) - - _, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: lib2.ID, FolderPath: "Loose"}}) - Expect(err).ToNot(HaveOccurred()) - - jazz, err := ds.Library().Get(ctx, lib2.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(jazz.PIDChanged()).To(BeTrue()) - }) - }) -}) diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go deleted file mode 100644 index 2f27b74ce..000000000 --- a/scanner/scanner_selective_test.go +++ /dev/null @@ -1,328 +0,0 @@ -package scanner_test - -import ( - "context" - "path/filepath" - "testing/fstest" - "time" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/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/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("ScanFolders", Ordered, func() { - var ctx context.Context - var lib model.Library - var ds model.DataStore - var s model.Scanner - 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") - log.Warn("Using DB at " + conf.Server.DbPath) - db.Db().SetMaxOpenConns(1) - }) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.MusicFolder = "fake:///music" - conf.Server.DevExternalScanner = false - - db.Init(ctx) - DeferCleanup(func() { - Expect(tests.ClearDB()).To(Succeed()) - }) - - ds = persistence.New(db.Db()) - - // Create the admin user in the database to match the context - adminUser := model.User{ - ID: "123", - UserName: "admin", - Name: "Admin User", - IsAdmin: true, - NewPassword: "password", - } - Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) - - s = scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) - - lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} - Expect(ds.Library().Put(ctx, &lib)).To(Succeed()) - - // Initialize fake filesystem - fsys = storagetest.FakeFS{} - storagetest.Register("fake", &fsys) - }) - - Describe("Adding tracks to the library", func() { - It("scans specified folders recursively including all subdirectories", func() { - rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"}) - jazz := template(_t{"albumartist": "Jazz Artist", "album": "Jazz Album"}) - pop := template(_t{"albumartist": "Pop Artist", "album": "Pop Album"}) - fsys = createFS(fstest.MapFS{ - "rock/track1.mp3": rock(track(1, "Rock Track 1")), - "rock/track2.mp3": rock(track(2, "Rock Track 2")), - "rock/subdir/track3.mp3": rock(track(3, "Rock Track 3")), - "jazz/track4.mp3": jazz(track(1, "Jazz Track 1")), - "jazz/subdir/track5.mp3": jazz(track(2, "Jazz Track 2")), - "pop/track6.mp3": pop(track(1, "Pop Track 1")), - }) - - // Scan only the "rock" and "jazz" folders (including their subdirectories) - targets := []model.ScanTarget{ - {LibraryID: lib.ID, FolderPath: "rock"}, - {LibraryID: lib.ID, FolderPath: "jazz"}, - } - - warnings, err := s.ScanFolders(ctx, false, targets) - Expect(err).ToNot(HaveOccurred()) - Expect(warnings).To(BeEmpty()) - - // Verify all tracks in rock and jazz folders (including subdirectories) were imported - allFiles, err := ds.MediaFile().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - - // Should have 5 tracks (all rock and jazz tracks including subdirectories) - Expect(allFiles).To(HaveLen(5)) - - // Get the file paths - paths := slice.Map(allFiles, func(mf model.MediaFile) string { - return filepath.ToSlash(mf.Path) - }) - - // Verify the correct files were scanned (including subdirectories) - Expect(paths).To(ContainElements( - "rock/track1.mp3", - "rock/track2.mp3", - "rock/subdir/track3.mp3", - "jazz/track4.mp3", - "jazz/subdir/track5.mp3", - )) - - // Verify files in the pop folder were NOT scanned - Expect(paths).ToNot(ContainElement("pop/track6.mp3")) - Expect(ds.Property().Get(ctx, consts.DBAnalyzePendingKey)).To(Equal("1")) - }) - }) - - Describe("Planner statistics maintenance", func() { - It("does not mark routine quick-scan changes for immediate analysis", func() { - rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"}) - fsys = createFS(fstest.MapFS{ - "rock/track1.mp3": rock(track(1, "Rock Track 1")), - }) - _, err := s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - Expect(ds.Property().Get(ctx, consts.DBAnalyzePendingKey)).To(Equal("0")) - - fsys.Add("rock/track2.mp3", rock(track(2, "Rock Track 2")), time.Now().Add(time.Second)) - _, err = s.ScanAll(ctx, false) - Expect(err).ToNot(HaveOccurred()) - Expect(ds.Property().Get(ctx, consts.DBAnalyzePendingKey)).To(Equal("0")) - }) - - It("does not treat an interrupted scan in an untargeted library as a full scan", func() { - otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"} - Expect(ds.Library().Put(ctx, &otherLib)).To(Succeed()) - Expect(ds.Library().ScanBegin(ctx, lib.ID, true)).To(Succeed()) - - lastAnalyze := "2026-07-09T12:00:00Z" - Expect(ds.Property().Put(ctx, consts.LastDBAnalyzeAtKey, lastAnalyze)).To(Succeed()) - Expect(ds.Property().Put(ctx, consts.DBAnalyzePendingKey, "0")).To(Succeed()) - - _, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: otherLib.ID, FolderPath: "."}}) - Expect(err).ToNot(HaveOccurred()) - Expect(ds.Property().Get(ctx, consts.LastDBAnalyzeAtKey)).To(Equal(lastAnalyze)) - }) - }) - - Describe("Deleting folders", func() { - Context("when a child folder is deleted", func() { - var ( - revolver, help func(...map[string]any) *fstest.MapFile - artistFolderID string - album1FolderID string - album2FolderID string - album1TrackIDs []string - album2TrackIDs []string - ) - - BeforeEach(func() { - // Setup template functions for creating test files - revolver = storagetest.Template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966}) - help = storagetest.Template(_t{"albumartist": "The Beatles", "album": "Help!", "year": 1965}) - - // Initial filesystem with nested folders - fsys.SetFiles(fstest.MapFS{ - "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")), - "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")), - "The Beatles/Help!/01 - Help!.mp3": help(storagetest.Track(1, "Help!")), - "The Beatles/Help!/02 - The Night Before.mp3": help(storagetest.Track(2, "The Night Before")), - }) - - // First scan - import everything - _, err := s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - - // Verify initial state - all folders exist - folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) - Expect(err).ToNot(HaveOccurred()) - Expect(folders).To(HaveLen(4)) // root, Artist, Album1, Album2 - - // Store folder IDs for later verification - for _, f := range folders { - switch f.Name { - case "The Beatles": - artistFolderID = f.ID - case "Revolver": - album1FolderID = f.ID - case "Help!": - album2FolderID = f.ID - } - } - - // Verify all tracks exist - allTracks, err := ds.MediaFile().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(allTracks).To(HaveLen(4)) - - // Store track IDs for later verification - for _, t := range allTracks { - if t.Album == "Revolver" { - album1TrackIDs = append(album1TrackIDs, t.ID) - } else if t.Album == "Help!" { - album2TrackIDs = append(album2TrackIDs, t.ID) - } - } - - // Verify no tracks are missing initially - for _, t := range allTracks { - Expect(t.Missing).To(BeFalse()) - } - }) - - It("should mark child folder and its tracks as missing when parent is scanned", func() { - // Delete the child folder (Help!) from the filesystem - fsys.SetFiles(fstest.MapFS{ - "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")), - "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")), - // "The Beatles/Help!" folder and its contents are DELETED - }) - - // Run selective scan on the parent folder (Artist) - // This simulates what the watcher does when a child folder is deleted - _, err := s.ScanFolders(ctx, false, []model.ScanTarget{ - {LibraryID: lib.ID, FolderPath: "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - - // Verify the deleted child folder is now marked as missing - deletedFolder, err := ds.Folder().Get(ctx, album2FolderID) - Expect(err).ToNot(HaveOccurred()) - Expect(deletedFolder.Missing).To(BeTrue(), "Deleted child folder should be marked as missing") - - // Verify the deleted folder's tracks are marked as missing - for _, trackID := range album2TrackIDs { - track, err := ds.MediaFile().Get(ctx, trackID) - Expect(err).ToNot(HaveOccurred()) - Expect(track.Missing).To(BeTrue(), "Track in deleted folder should be marked as missing") - } - - // Verify the parent folder is still present and not marked as missing - parentFolder, err := ds.Folder().Get(ctx, artistFolderID) - Expect(err).ToNot(HaveOccurred()) - Expect(parentFolder.Missing).To(BeFalse(), "Parent folder should not be marked as missing") - - // Verify the sibling folder and its tracks are still present and not missing - siblingFolder, err := ds.Folder().Get(ctx, album1FolderID) - Expect(err).ToNot(HaveOccurred()) - Expect(siblingFolder.Missing).To(BeFalse(), "Sibling folder should not be marked as missing") - - for _, trackID := range album1TrackIDs { - track, err := ds.MediaFile().Get(ctx, trackID) - Expect(err).ToNot(HaveOccurred()) - Expect(track.Missing).To(BeFalse(), "Track in sibling folder should not be marked as missing") - } - }) - - It("should mark deeply nested child folders as missing", func() { - // Add a deeply nested folder structure - fsys.SetFiles(fstest.MapFS{ - "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")), - "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")), - "The Beatles/Help!/01 - Help!.mp3": help(storagetest.Track(1, "Help!")), - "The Beatles/Help!/02 - The Night Before.mp3": help(storagetest.Track(2, "The Night Before")), - "The Beatles/Help!/Bonus/01 - Bonus Track.mp3": help(storagetest.Track(99, "Bonus Track")), - "The Beatles/Help!/Bonus/Nested/01 - Deep Track.mp3": help(storagetest.Track(100, "Deep Track")), - }) - - // Rescan to import the new nested structure - _, err := s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - - // Verify nested folders were created - allFolders, err := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) - Expect(err).ToNot(HaveOccurred()) - Expect(len(allFolders)).To(BeNumerically(">", 4), "Should have more folders with nested structure") - - // Now delete the entire Help! folder including nested children - fsys.SetFiles(fstest.MapFS{ - "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")), - "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")), - // All Help! subfolders are deleted - }) - - // Run selective scan on parent - _, err = s.ScanFolders(ctx, false, []model.ScanTarget{ - {LibraryID: lib.ID, FolderPath: "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - - // Verify all Help! folders (including nested ones) are marked as missing - missingFolders, err := ds.Folder().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.And{ - squirrel.Eq{"library_id": lib.ID}, - squirrel.Eq{"missing": true}, - }, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(len(missingFolders)).To(BeNumerically(">", 0), "At least one folder should be marked as missing") - - // Verify all tracks in deleted folders are marked as missing - allTracks, err := ds.MediaFile().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(allTracks).To(HaveLen(6)) - - for _, track := range allTracks { - if track.Album == "Help!" { - Expect(track.Missing).To(BeTrue(), "All tracks in deleted Help! folder should be marked as missing") - } else if track.Album == "Revolver" { - Expect(track.Missing).To(BeFalse(), "Tracks in Revolver folder should not be marked as missing") - } - } - }) - }) - }) -}) diff --git a/scanner/scanner_suite_test.go b/scanner/scanner_suite_test.go index 07ffd6e3a..6c7f58c09 100644 --- a/scanner/scanner_suite_test.go +++ b/scanner/scanner_suite_test.go @@ -1,47 +1,22 @@ -package scanner_test +package scanner import ( - "context" - "io/fs" "testing" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/storage/local" + "github.com/beego/beego/v2/client/orm" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/db" "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" - "go.uber.org/goleak" ) -// The local storage is registered in this test binary, so any spec (or background watcher) -// touching a file:// library needs a default extractor to avoid a startup fatal. -type noopSuiteExtractor struct{} - -func (noopSuiteExtractor) Parse(...string) (map[string]metadata.Info, error) { return nil, nil } -func (noopSuiteExtractor) Version() string { return "0" } - -func init() { - local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { - return noopSuiteExtractor{} - }) -} - func TestScanner(t *testing.T) { - // Detect any goroutine leaks in the scanner code under test - defer goleak.VerifyNone(t, - goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"), - // The notify library keeps internal goroutines alive after Stop(). The backend picks the tree per - // platform: recursive on macOS (FSEvents), nonrecursive on Linux (inotify), so ignore both. - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*recursiveTree).dispatch"), - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).dispatch"), - goleak.IgnoreTopFunction("github.com/rjeczalik/notify.(*nonrecursiveTree).internal"), - ) - tests.Init(t, true) - defer db.Close(context.Background()) + conf.Server.DbPath = "file::memory:?cache=shared" + _ = orm.RegisterDataBase("default", db.Driver, conf.Server.DbPath) + db.Init() log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) RunSpecs(t, "Scanner Suite") diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go deleted file mode 100644 index 4ce8cce1e..000000000 --- a/scanner/scanner_test.go +++ /dev/null @@ -1,1338 +0,0 @@ -package scanner_test - -import ( - "context" - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "sync/atomic" - "testing/fstest" - "time" - - "github.com/Masterminds/squirrel" - "github.com/google/uuid" - "github.com/mattn/go-sqlite3" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/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/navidrome/navidrome/utils/slice" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Easy aliases for the storagetest package -type _t = map[string]any - -var template = storagetest.Template -var track = storagetest.Track - -func createFS(files fstest.MapFS) storagetest.FakeFS { - fs := storagetest.FakeFS{} - fs.SetFiles(files) - storagetest.Register("fake", &fs) - return fs -} - -var _ = Describe("Scanner", Ordered, func() { - var ctx context.Context - var lib model.Library - var ds *tests.MockDataStore - var mfRepo *mockMediaFileRepo - var s model.Scanner - - BeforeAll(func() { - ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true}) - // The DB stays open until the suite ends, and Windows can't delete an open file - tmpDir, err := os.MkdirTemp("", "scanner-test") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { _ = os.RemoveAll(tmpDir) }) - conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") - log.Warn("Using DB at " + conf.Server.DbPath) - //conf.Server.DbPath = ":memory:" - db.Db().SetMaxOpenConns(1) - }) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.MusicFolder = "fake:///music" // Set to match test library path - conf.Server.DevExternalScanner = false - - db.Init(ctx) - DeferCleanup(func() { - Expect(tests.ClearDB()).To(Succeed()) - }) - - ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - mfRepo = &mockMediaFileRepo{ - MediaFileRepository: ds.RealDS.MediaFile(), - } - ds.MockedMediaFile = mfRepo - - // Create the admin user in the database to match the context - adminUser := model.User{ - ID: "123", - UserName: "admin", - Name: "Admin User", - IsAdmin: true, - NewPassword: "password", - } - Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) - - s = scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) - - lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} - Expect(ds.Library().Put(ctx, &lib)).To(Succeed()) - }) - - runScanner := func(ctx context.Context, fullScan bool) error { - _, err := s.ScanAll(ctx, fullScan) - return err - } - - // Stands in for the artwork worker: drains the queue and records every item as resolved, - // so a later scan can only queue genuine reprocessing. - resolveQueuedArtwork := func() []model.ArtworkQueueItem { - GinkgoHelper() - queued, err := ds.ArtworkQueue().DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - for _, it := range queued { - Expect(ds.Artwork().PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: it.ItemKind, ItemID: it.ItemID, ImageType: it.ImageType, - Hash: "resolved", Source: "embedded", UpdatedAt: time.Now(), - })).To(Succeed()) - Expect(ds.ArtworkQueue().DeleteIfUnchanged(ctx, it.ItemKind, it.ItemID, it.ImageType, it.RetryAt)).To(Succeed()) - } - return queued - } - - Context("Simple library, 'artis/album/track - title.mp3'", func() { - var help, revolver func(...map[string]any) *fstest.MapFile - var fsys storagetest.FakeFS - BeforeEach(func() { - revolver = template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966}) - help = template(_t{"albumartist": "The Beatles", "album": "Help!", "year": 1965}) - fsys = createFS(fstest.MapFS{ - "The Beatles/Revolver/01 - Taxman.mp3": revolver(track(1, "Taxman")), - "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(track(2, "Eleanor Rigby")), - "The Beatles/Revolver/03 - I'm Only Sleeping.mp3": revolver(track(3, "I'm Only Sleeping")), - "The Beatles/Revolver/04 - Love You To.mp3": revolver(track(4, "Love You To")), - "The Beatles/Help!/01 - Help!.mp3": help(track(1, "Help!")), - "The Beatles/Help!/02 - The Night Before.mp3": help(track(2, "The Night Before")), - "The Beatles/Help!/03 - You've Got to Hide Your Love Away.mp3": help(track(3, "You've Got to Hide Your Love Away")), - }) - }) - When("it is the first scan", func() { - It("should import all folders", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - folders, _ := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}}) - paths := slice.Map(folders, func(f model.Folder) string { return f.Name }) - Expect(paths).To(SatisfyAll( - HaveLen(4), - ContainElements(".", "The Beatles", "Revolver", "Help!"), - )) - }) - It("should import all mediafiles", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - mfs, _ := ds.MediaFile().GetAll(ctx) - paths := slice.Map(mfs, func(f model.MediaFile) string { return f.Title }) - Expect(paths).To(SatisfyAll( - HaveLen(7), - ContainElements( - "Taxman", "Eleanor Rigby", "I'm Only Sleeping", "Love You To", - "Help!", "The Night Before", "You've Got to Hide Your Love Away", - ), - )) - }) - It("should import all albums", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - albums, _ := ds.Album().GetAll(ctx, model.QueryOptions{Sort: "name"}) - Expect(albums).To(HaveLen(2)) - Expect(albums[0]).To(SatisfyAll( - HaveField("Name", Equal("Help!")), - HaveField("SongCount", Equal(3)), - )) - Expect(albums[1]).To(SatisfyAll( - HaveField("Name", Equal("Revolver")), - HaveField("SongCount", Equal(4)), - )) - }) - It("should enqueue artwork resolution for the scanned albums and artists", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - albums, _ := ds.Album().GetAll(ctx) - artists, _ := ds.Artist().GetAll(ctx, model.QueryOptions{Filters: squirrel.NotEq{"name": consts.UnknownArtist}}) - queued, err := ds.ArtworkQueue().DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - - for _, al := range albums { - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "al"), - HaveField("ItemID", al.ID), - HaveField("Priority", model.ArtworkPriorityScan), - ))) - } - for _, ar := range artists { - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "ar"), - HaveField("ItemID", ar.ID), - HaveField("Priority", model.ArtworkPriorityScan), - ))) - } - }) - It("should not re-enqueue already resolved artwork on a repeat full scan", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - Expect(resolveQueuedArtwork()).ToNot(BeEmpty()) - - Expect(runScanner(ctx, true)).To(Succeed()) - - requeued, err := ds.ArtworkQueue().DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - Expect(requeued).To(BeEmpty()) - }) - }) - When("a file was changed", func() { - It("should update the media_file", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - mf, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) - Expect(err).ToNot(HaveOccurred()) - Expect(mf[0].Tags).ToNot(HaveKey("barcode")) - - fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"barcode": "123"}) - Expect(runScanner(ctx, true)).To(Succeed()) - - mf, err = ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) - Expect(err).ToNot(HaveOccurred()) - Expect(mf[0].Tags).To(HaveKeyWithValue(model.TagName("barcode"), []string{"123"})) - }) - - It("should re-enqueue the album artwork even though it already resolved", func() { - tests.SkipOnWindows("path separator bug (#TBD-path-sep-scanner)") - Expect(runScanner(ctx, true)).To(Succeed()) - - resolveQueuedArtwork() - - fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"producer": "George Martin"}) - Expect(runScanner(ctx, false)).To(Succeed()) - - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) - Expect(err).ToNot(HaveOccurred()) - requeued, err := ds.ArtworkQueue().DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - Expect(requeued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "al"), - HaveField("ItemID", albums[0].ID), - ))) - }) - - 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().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).ToNot(BeEmpty()) - Expect(albums[0].Participants.First(model.RoleProducer).Name).To(BeEmpty()) - Expect(albums[0].SongCount).To(Equal(3)) - - fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"producer": "George Martin"}) - Expect(runScanner(ctx, false)).To(Succeed()) - - albums, err = ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.name": "Help!"}}) - Expect(err).ToNot(HaveOccurred()) - Expect(albums[0].Participants.First(model.RoleProducer).Name).To(Equal("George Martin")) - Expect(albums[0].SongCount).To(Equal(3)) - }) - - It("invalidates the media_file artwork state so new embedded art is picked up lazily", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - mf, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) - Expect(err).ToNot(HaveOccurred()) - Expect(mf).ToNot(BeEmpty()) - trackID := mf[0].ID - - Expect(ds.Artwork().PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "mf", ItemID: trackID, ImageType: model.ImageTypePrimary, - Source: "embedded", Hash: "stalehash", - })).To(Succeed()) - - fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "reimport"}) - Expect(runScanner(ctx, true)).To(Succeed()) - - _, err = ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, trackID, model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - }) - - Context("Library with image files", func() { - var fsys storagetest.FakeFS - image := func(data string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(data)} } - - albumID := func(name string) string { - GinkgoHelper() - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.name": name}}) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).To(HaveLen(1)) - return albums[0].ID - } - artistID := func(name string) string { - GinkgoHelper() - artists, err := ds.Artist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"artist.name": name}}) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(HaveLen(1)) - return artists[0].ID - } - queuedItems := func() []model.ArtworkQueueItem { - GinkgoHelper() - queued, err := ds.ArtworkQueue().DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - return queued - } - queueItemFor := func(kind, id string) OmegaMatcher { - return ContainElement(SatisfyAll( - HaveField("ItemKind", kind), - HaveField("ItemID", id), - HaveField("Priority", model.ArtworkPriorityScan), - )) - } - - BeforeEach(func() { - revolver := template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966}) - wall := template(_t{"albumartist": "Pink Floyd", "album": "The Wall", "year": 1979}) - fsys = createFS(fstest.MapFS{ - "The Beatles/artist.jpg": image("beatles-artist-v1"), - "The Beatles/Revolver/cover.jpg": image("revolver-cover-v1"), - "The Beatles/Revolver/01 - Taxman.mp3": revolver(track(1, "Taxman")), - "Pink Floyd/The Wall/cover.jpg": image("wall-cover-v1"), - "Pink Floyd/The Wall/CD1/01 - In the Flesh.mp3": wall(track(1, "In the Flesh?")), - "Pink Floyd/The Wall/CD2/01 - Hey You.mp3": wall(track(1, "Hey You")), - }) - Expect(runScanner(ctx, true)).To(Succeed()) - resolveQueuedArtwork() - }) - - It("re-enqueues only the album whose cover was replaced in place", func() { - fsys.Add("The Beatles/Revolver/cover.jpg", image("revolver-cover-v2")) - - Expect(runScanner(ctx, false)).To(Succeed()) - - queued := queuedItems() - Expect(queued).To(queueItemFor("al", albumID("Revolver"))) - Expect(queued).ToNot(ContainElement(HaveField("ItemID", albumID("The Wall")))) - Expect(queued).ToNot(ContainElement(HaveField("ItemKind", "ar"))) - }) - - It("re-enqueues the album when the cover above its disc folders changes", func() { - fsys.Add("Pink Floyd/The Wall/cover.jpg", image("wall-cover-v2")) - - Expect(runScanner(ctx, false)).To(Succeed()) - - Expect(queuedItems()).To(queueItemFor("al", albumID("The Wall"))) - }) - - It("re-enqueues the album when its cover is removed", func() { - fsys.Remove("The Beatles/Revolver/cover.jpg") - - Expect(runScanner(ctx, false)).To(Succeed()) - - Expect(queuedItems()).To(queueItemFor("al", albumID("Revolver"))) - }) - - It("enqueues the artist when an artist image is added to their folder", func() { - fsys.Add("Pink Floyd/artist.jpg", image("floyd-artist-v1")) - - Expect(runScanner(ctx, false)).To(Succeed()) - - queued := queuedItems() - Expect(queued).To(queueItemFor("ar", artistID("Pink Floyd"))) - Expect(queued).ToNot(ContainElement(HaveField("ItemID", artistID("The Beatles")))) - }) - - It("re-enqueues the artist when their artist image is replaced in place", func() { - fsys.Add("The Beatles/artist.jpg", image("beatles-artist-v2")) - - Expect(runScanner(ctx, false)).To(Succeed()) - - Expect(queuedItems()).To(queueItemFor("ar", artistID("The Beatles"))) - }) - - It("enqueues every artist under the folder when a shared artist image is added", func() { - fsys.Add("artist.png", image("shared-artist-v1")) - - Expect(runScanner(ctx, false)).To(Succeed()) - - queued := queuedItems() - Expect(queued).To(queueItemFor("ar", artistID("The Beatles"))) - Expect(queued).To(queueItemFor("ar", artistID("Pink Floyd"))) - }) - - It("enqueues the artist when an image lands in a folder first seen by a quick scan", func() { - // A quick scan must persist an artist folder that holds only subfolders, or the - // artist.jpg added later has no previous state to diff against. - kraftwerk := template(_t{"albumartist": "Kraftwerk", "album": "Autobahn", "year": 1974}) - files := fsys.MapFS - files["Kraftwerk/Autobahn/01 - Autobahn.mp3"] = kraftwerk(track(1, "Autobahn")) - fsys.SetFiles(files) - // Backdate the previous scan so this one's new artists are unambiguously newer: - // RefreshStats picks touched artists with a strict artist.updated_at > - // library.last_scan_at, and Windows' coarse clock can put both in one tick. - _, err := db.Db().ExecContext(ctx, "UPDATE library SET last_scan_at = ?", time.Now().Add(-time.Hour)) - Expect(err).ToNot(HaveOccurred()) - Expect(runScanner(ctx, false)).To(Succeed()) - resolveQueuedArtwork() - - fsys.Add("Kraftwerk/artist.jpg", image("kraftwerk-artist-v1")) - Expect(runScanner(ctx, false)).To(Succeed()) - - Expect(queuedItems()).To(queueItemFor("ar", artistID("Kraftwerk"))) - }) - - It("does not enqueue anything on a repeat full scan with no image changes", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - Expect(queuedItems()).To(BeEmpty()) - }) - }) - - Context("Artist with atomic non-ASCII letters, 'GØGGS'", func() { - BeforeEach(func() { - goggs := template(_t{"albumartist": "GØGGS", "album": "Pre Strike Sweep", "year": 2018}) - createFS(fstest.MapFS{ - "GØGGS/Pre Strike Sweep/01 - Falling For You.mp3": goggs(track(1, "Falling For You")), - }) - }) - - searchNormalized := func() string { - var sn string - Expect(db.Db().QueryRowContext(ctx, - "SELECT search_normalized FROM artist WHERE name = 'GØGGS'").Scan(&sn)).To(Succeed()) - return sn - } - - It("repopulates a stale search_normalized on a full rescan", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - Expect(searchNormalized()).To(Equal("GOGGS")) - - // Simulate the stale value left by the FTS5 migration's SQL back-fill - _, err := db.Db().ExecContext(ctx, "UPDATE artist SET search_normalized = '' WHERE name = 'GØGGS'") - Expect(err).ToNot(HaveOccurred()) - - // Backdate the folder so the next full scan reliably sees it as outdated. - // isOutdated() compares folder.updated_at (written by this scan) against the - // next scan's last_scan_started_at with a strict Before(); back-to-back scans - // can capture both within one clock tick on Windows (coarse wall-clock), making - // the refresh flaky. Backdating forces the comparison to be unambiguous. - _, err = db.Db().ExecContext(ctx, - "UPDATE folder SET updated_at = ?", time.Now().Add(-time.Hour)) - Expect(err).ToNot(HaveOccurred()) - - Expect(runScanner(ctx, true)).To(Succeed()) - Expect(searchNormalized()).To(Equal("GOGGS")) - }) - }) - - Context("Ignored entries", func() { - BeforeEach(func() { - revolver := template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966}) - createFS(fstest.MapFS{ - "The Beatles/Revolver/01 - Taxman.mp3": revolver(track(1, "Taxman")), - "The Beatles/Revolver/._01 - Taxman.mp3": &fstest.MapFile{Data: []byte("garbage data")}, - }) - }) - - It("should not import the ignored file", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - mfs, err := ds.MediaFile().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).To(HaveLen(1)) - for _, mf := range mfs { - Expect(mf.Title).To(Equal("Taxman")) - Expect(mf.Path).To(Equal("The Beatles/Revolver/01 - Taxman.mp3")) - } - }) - }) - - Context("Same album in two different folders", func() { - BeforeEach(func() { - revolver := template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966}) - createFS(fstest.MapFS{ - "The Beatles/Revolver/01 - Taxman.mp3": revolver(track(1, "Taxman")), - "The Beatles/Revolver2/02 - Eleanor Rigby.mp3": revolver(track(2, "Eleanor Rigby")), - }) - }) - - It("should import as one album", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - albums, err := ds.Album().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).To(HaveLen(1)) - - mfs, err := ds.MediaFile().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).To(HaveLen(2)) - for _, mf := range mfs { - Expect(mf.AlbumID).To(Equal(albums[0].ID)) - } - }) - }) - - Context("Same album, different release dates", func() { - BeforeEach(func() { - help := template(_t{"albumartist": "The Beatles", "album": "Help!", "releasedate": 1965}) - help2 := template(_t{"albumartist": "The Beatles", "album": "Help!", "releasedate": 2000}) - createFS(fstest.MapFS{ - "The Beatles/Help!/01 - Help!.mp3": help(track(1, "Help!")), - "The Beatles/Help! (remaster)/01 - Help!.mp3": help2(track(1, "Help!")), - }) - }) - - It("should import as two distinct albums", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Sort: "release_date"}) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).To(HaveLen(2)) - Expect(albums[0]).To(SatisfyAll( - HaveField("Name", Equal("Help!")), - HaveField("ReleaseDate", Equal("1965")), - )) - Expect(albums[1]).To(SatisfyAll( - HaveField("Name", Equal("Help!")), - HaveField("ReleaseDate", Equal("2000")), - )) - }) - }) - - Describe("Library changes'", func() { - var help, revolver func(...map[string]any) *fstest.MapFile - var fsys storagetest.FakeFS - var findByPath func(string) (*model.MediaFile, error) - 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", - "artistsort": "Beatles, The", - "musicbrainz_artistid": beatlesMBID, - } - help = template(beatles, _t{"album": "Help!", "year": 1965}) - revolver = template(beatles, _t{"album": "Revolver", "year": 1966}) - fsys = createFS(fstest.MapFS{ - "The Beatles/Help!/01 - Help!.mp3": help(track(1, "Help!")), - "The Beatles/Help!/02 - The Night Before.mp3": help(track(2, "The Night Before")), - "The Beatles/Revolver/01 - Taxman.mp3": revolver(track(1, "Taxman")), - "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(track(2, "Eleanor Rigby")), - }) - - By("Doing a full scan") - Expect(runScanner(ctx, true)).To(Succeed()) - Expect(ds.MediaFile().CountAll(ctx)).To(Equal(int64(4))) - findByPath = createFindByPath(ctx, ds) - }) - - It("adds new files to the library", func() { - fsys.Add("The Beatles/Revolver/03 - I'm Only Sleeping.mp3", revolver(track(3, "I'm Only Sleeping"))) - - Expect(runScanner(ctx, false)).To(Succeed()) - Expect(ds.MediaFile().CountAll(ctx)).To(Equal(int64(5))) - mf, err := findByPath("The Beatles/Revolver/03 - I'm Only Sleeping.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Title).To(Equal("I'm Only Sleeping")) - }) - - It("updates tags of a file in the library", func() { - fsys.UpdateTags("The Beatles/Revolver/02 - Eleanor Rigby.mp3", _t{"title": "Eleanor Rigby (remix)"}) - - Expect(runScanner(ctx, false)).To(Succeed()) - Expect(ds.MediaFile().CountAll(ctx)).To(Equal(int64(4))) - mf, _ := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - Expect(mf.Title).To(Equal("Eleanor Rigby (remix)")) - }) - - It("upgrades file with same format in the library", func() { - fsys.Add("The Beatles/Revolver/01 - Taxman.mp3", revolver(track(1, "Taxman", _t{"bitrate": 640}))) - - Expect(runScanner(ctx, false)).To(Succeed()) - Expect(ds.MediaFile().CountAll(ctx)).To(Equal(int64(4))) - mf, _ := findByPath("The Beatles/Revolver/01 - Taxman.mp3") - Expect(mf.BitRate).To(Equal(640)) - }) - - It("detects a file was removed from the library", func() { - By("Removing a file") - fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - - By("Rescanning the library") - Expect(runScanner(ctx, false)).To(Succeed()) - - By("Checking the file is marked as missing") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": false}, - })).To(Equal(int64(3))) - mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Missing).To(BeTrue()) - }) - - It("detects a file was moved to a different folder", func() { - By("Storing the original ID") - original, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - Expect(err).ToNot(HaveOccurred()) - originalId := original.ID - - By("Moving the file to a different folder") - fsys.Move("The Beatles/Revolver/02 - Eleanor Rigby.mp3", "The Beatles/Help!/02 - Eleanor Rigby.mp3") - - By("Rescanning the library") - Expect(runScanner(ctx, false)).To(Succeed()) - - By("Checking the old file is not in the library") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": false}, - })).To(Equal(int64(4))) - _, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - Expect(err).To(MatchError(model.ErrNotFound)) - - By("Checking the new file is in the library") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": true}, - })).To(BeZero()) - mf, err := findByPath("The Beatles/Help!/02 - Eleanor Rigby.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Title).To(Equal("Eleanor Rigby")) - Expect(mf.Missing).To(BeFalse()) - - By("Checking the new file has the same ID as the original") - Expect(mf.ID).To(Equal(originalId)) - }) - - It("detects a move after a scan is interrupted by an error", func() { - By("Storing the original ID") - By("Moving the file to a different folder") - fsys.Move("The Beatles/Revolver/01 - Taxman.mp3", "The Beatles/Help!/01 - Taxman.mp3") - - By("Interrupting the scan with an error before the move is processed") - mfRepo.GetMissingAndMatchingError = errors.New("I/O read error") - Expect(runScanner(ctx, false)).To(MatchError(ContainSubstring("I/O read error"))) - - By("Checking the both instances of the file are in the lib") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"title": "Taxman"}, - })).To(Equal(int64(2))) - - By("Rescanning the library without error") - mfRepo.GetMissingAndMatchingError = nil - Expect(runScanner(ctx, false)).To(Succeed()) - - By("Checking the old file is not in the library") - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"title": "Taxman"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).To(HaveLen(1)) - Expect(mfs[0].Path).To(Equal("The Beatles/Help!/01 - Taxman.mp3")) - }) - - It("detects file format upgrades", func() { - By("Storing the original ID") - original, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - Expect(err).ToNot(HaveOccurred()) - originalId := original.ID - - By("Replacing the file with a different format") - fsys.Move("The Beatles/Revolver/02 - Eleanor Rigby.mp3", "The Beatles/Revolver/02 - Eleanor Rigby.flac") - - By("Rescanning the library") - Expect(runScanner(ctx, false)).To(Succeed()) - - By("Checking the old file is not in the library") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": true}, - })).To(BeZero()) - _, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - Expect(err).To(MatchError(model.ErrNotFound)) - - By("Checking the new file is in the library") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": false}, - })).To(Equal(int64(4))) - mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.flac") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Title).To(Equal("Eleanor Rigby")) - Expect(mf.Missing).To(BeFalse()) - - By("Checking the new file has the same ID as the original") - Expect(mf.ID).To(Equal(originalId)) - }) - - It("detects old missing tracks being added back", func() { - By("Removing a file") - origFile := fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - - By("Rescanning the library") - Expect(runScanner(ctx, false)).To(Succeed()) - - By("Checking the file is marked as missing") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": false}, - })).To(Equal(int64(3))) - mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Missing).To(BeTrue()) - - By("Adding the file back") - fsys.Add("The Beatles/Revolver/02 - Eleanor Rigby.mp3", origFile) - - By("Rescanning the library again") - Expect(runScanner(ctx, false)).To(Succeed()) - - By("Checking the file is not marked as missing") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": false}, - })).To(Equal(int64(4))) - mf, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Missing).To(BeFalse()) - - By("Removing it again") - fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - - By("Rescanning the library again") - Expect(runScanner(ctx, false)).To(Succeed()) - - By("Checking the file is marked as missing") - mf, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Missing).To(BeTrue()) - - By("Adding the file back in a different folder") - fsys.Add("The Beatles/Help!/02 - Eleanor Rigby.mp3", origFile) - - By("Rescanning the library once more") - Expect(runScanner(ctx, false)).To(Succeed()) - - By("Checking the file was found in the new folder") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": false}, - })).To(Equal(int64(4))) - mf, err = findByPath("The Beatles/Help!/02 - Eleanor Rigby.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Missing).To(BeFalse()) - }) - - It("marks tracks as missing when scanning a deleted folder with ScanFolders", func() { - By("Adding a third track to Revolver to have more test data") - fsys.Add("The Beatles/Revolver/03 - I'm Only Sleeping.mp3", revolver(track(3, "I'm Only Sleeping"))) - Expect(runScanner(ctx, false)).To(Succeed()) - - By("Verifying initial state has 5 tracks") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": false}, - })).To(Equal(int64(5))) - - By("Removing the entire Revolver folder from filesystem") - fsys.Remove("The Beatles/Revolver/01 - Taxman.mp3") - fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - fsys.Remove("The Beatles/Revolver/03 - I'm Only Sleeping.mp3") - - By("Scanning the parent folder (simulating watcher behavior)") - targets := []model.ScanTarget{ - {LibraryID: lib.ID, FolderPath: "The Beatles"}, - } - _, err := s.ScanFolders(ctx, false, targets) - Expect(err).To(Succeed()) - - By("Checking all Revolver tracks are marked as missing") - mf, err := findByPath("The Beatles/Revolver/01 - Taxman.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Missing).To(BeTrue()) - - mf, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Missing).To(BeTrue()) - - mf, err = findByPath("The Beatles/Revolver/03 - I'm Only Sleeping.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Missing).To(BeTrue()) - - By("Checking the Help! tracks are not affected") - mf, err = findByPath("The Beatles/Help!/01 - Help!.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Missing).To(BeFalse()) - - mf, err = findByPath("The Beatles/Help!/02 - The Night Before.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Missing).To(BeFalse()) - - By("Verifying only 2 non-missing tracks remain (Help! tracks)") - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": false}, - })).To(Equal(int64(2))) - }) - - It("leaves no non-missing orphan artist after purging an artist's only content", func() { - // Guards the orphan case: with PurgeMissing on, removing an artist's last file hard-deletes - // its media_file_artists rows, RefreshStats recomputes its stats to '{}', and the cleanup - // drops its last library_artist row — leaving the artist row alive but orphaned. RefreshStats - // must then mark it missing (see markOrphansMissing). - DeferCleanup(configtest.SetupConfig()) - conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways - - By("Starting from a library where Pink Floyd has its own single album") - floyd := template(_t{"artist": "Pink Floyd", "album": "The Wall", "year": 1979}) - fsys = createFS(fstest.MapFS{ - "The Beatles/Help!/01 - Help!.mp3": help(track(1, "Help!")), - "The Beatles/Help!/02 - The Night Before.mp3": help(track(2, "The Night Before")), - "The Beatles/Revolver/01 - Taxman.mp3": revolver(track(1, "Taxman")), - "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(track(2, "Eleanor Rigby")), - "Pink Floyd/The Wall/01 - Another Brick.mp3": floyd(track(1, "Another Brick in the Wall")), - }) - Expect(runScanner(ctx, true)).To(Succeed()) - - nonMissingArtists := func() []string { - aa, err := ds.Artist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"missing": false}}) - Expect(err).ToNot(HaveOccurred()) - return slice.Map(aa, func(a model.Artist) string { return a.Name }) - } - orphanCount := func() int64 { - var n int64 - Expect(db.Db().QueryRowContext(ctx, - "SELECT count(*) FROM artist WHERE missing = false "+ - "AND id NOT IN (SELECT artist_id FROM library_artist)").Scan(&n)).To(Succeed()) - return n - } - // Read the artist row directly: selectArtist inner-joins library_artist, so an orphan never - // surfaces through the repository. Returns a descriptive string for clear test failures. - floydState := func() string { - var m bool - err := db.Db().QueryRowContext(ctx, - "SELECT missing FROM artist WHERE name = 'Pink Floyd'").Scan(&m) - if errors.Is(err, sql.ErrNoRows) { - return "NOT_FOUND" - } - Expect(err).ToNot(HaveOccurred()) - if m { - return "MISSING" - } - return "PRESENT" - } - - By("Confirming Pink Floyd is visible after the import, with no orphan") - Expect(nonMissingArtists()).To(ContainElement("Pink Floyd")) - Expect(floydState()).To(Equal("PRESENT")) - Expect(orphanCount()).To(BeZero()) - - By("Removing all of Pink Floyd's files and rescanning") - fsys.Remove("Pink Floyd/The Wall/01 - Another Brick.mp3") - Expect(runScanner(ctx, true)).To(Succeed()) - - By("Checking Pink Floyd's row survives but is marked missing, leaving no orphan") - Expect(floydState()).To(Equal("MISSING")) - Expect(orphanCount()).To(BeZero()) - // The Beatles keep their content, so the fix must not over-mark them. - Expect(nonMissingArtists()).To(ContainElement("The Beatles")) - }) - - It("does not override artist fields when importing an undertagged file", func() { - By("Making sure artist in the DB contains MBID and sort name") - aa, err := ds.Artist().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(aa).To(HaveLen(1)) - Expect(aa[0].Name).To(Equal("The Beatles")) - Expect(aa[0].MbzArtistID).To(Equal(beatlesMBID)) - Expect(aa[0].SortArtistName).To(Equal("Beatles, The")) - - By("Adding a new undertagged file (no MBID or sort name)") - newTrack := revolver(track(4, "Love You Too", - _t{"artist": "The Beatles", "musicbrainz_artistid": "", "artistsort": ""}), - ) - fsys.Add("The Beatles/Revolver/04 - Love You Too.mp3", newTrack) - - By("Doing a partial scan") - Expect(runScanner(ctx, false)).To(Succeed()) - - By("Asserting MediaFile have the artist name, but not the MBID or sort name") - mf, err := findByPath("The Beatles/Revolver/04 - Love You Too.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Title).To(Equal("Love You Too")) - Expect(mf.AlbumArtist).To(Equal("The Beatles")) - Expect(mf.MbzAlbumArtistID).To(BeEmpty()) - Expect(mf.SortArtistName).To(BeEmpty()) - - By("Makingsure the artist in the DB has not changed") - aa, err = ds.Artist().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(aa).To(HaveLen(1)) - Expect(aa[0].Name).To(Equal("The Beatles")) - Expect(aa[0].MbzArtistID).To(Equal(beatlesMBID)) - Expect(aa[0].SortArtistName).To(Equal("Beatles, The")) - }) - - Context("When PurgeMissing is configured", func() { - When("PurgeMissing is set to 'never'", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Scanner.PurgeMissing = consts.PurgeMissingNever - }) - - It("should mark files as missing but not delete them", func() { - By("Running initial scan") - Expect(runScanner(ctx, true)).To(Succeed()) - - By("Removing a file") - fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - - By("Running another scan") - Expect(runScanner(ctx, true)).To(Succeed()) - - By("Checking files are marked as missing but not deleted") - count, err := ds.MediaFile().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": true}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(1))) - - mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Missing).To(BeTrue()) - }) - }) - - When("PurgeMissing is set to 'always'", func() { - BeforeEach(func() { - conf.Server.Scanner.PurgeMissing = consts.PurgeMissingAlways - }) - - It("should purge missing files on any scan", func() { - By("Running initial scan") - Expect(runScanner(ctx, false)).To(Succeed()) - - By("Removing a file") - fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - - By("Running an incremental scan") - Expect(runScanner(ctx, false)).To(Succeed()) - - By("Checking missing files are deleted") - count, err := ds.MediaFile().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": true}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeZero()) - - _, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - - When("PurgeMissing is set to 'full'", func() { - BeforeEach(func() { - conf.Server.Scanner.PurgeMissing = consts.PurgeMissingFull - }) - - It("should not purge missing files on incremental scans", func() { - By("Running initial scan") - Expect(runScanner(ctx, true)).To(Succeed()) - - By("Removing a file") - fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - - By("Running an incremental scan") - Expect(runScanner(ctx, false)).To(Succeed()) - - By("Checking files are marked as missing but not deleted") - count, err := ds.MediaFile().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": true}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(Equal(int64(1))) - - mf, err := findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - Expect(err).ToNot(HaveOccurred()) - Expect(mf.Missing).To(BeTrue()) - }) - - It("should purge missing files only on full scans", func() { - By("Running initial scan") - Expect(runScanner(ctx, true)).To(Succeed()) - - By("Removing a file") - fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - - By("Running a full scan") - Expect(runScanner(ctx, true)).To(Succeed()) - - By("Checking missing files are deleted") - count, err := ds.MediaFile().CountAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"missing": true}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(count).To(BeZero()) - - _, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3") - Expect(err).To(MatchError(model.ErrNotFound)) - }) - }) - }) - }) - - Describe("Interrupted scan resumption", func() { - var fsys storagetest.FakeFS - var help func(...map[string]any) *fstest.MapFile - - BeforeEach(func() { - help = template(_t{"albumartist": "The Beatles", "album": "Help!", "year": 1965}) - fsys = createFS(fstest.MapFS{ - "The Beatles/Help!/01 - Help!.mp3": help(track(1, "Help!")), - "The Beatles/Help!/02 - The Night Before.mp3": help(track(2, "The Night Before")), - }) - }) - - simulateInterruptedScan := func(fullScan bool) { - // Call ScanBegin to properly set LastScanStartedAt and FullScanInProgress - // This simulates what would happen if a scan was interrupted (ScanBegin called but ScanEnd not) - Expect(ds.Library().ScanBegin(ctx, lib.ID, fullScan)).To(Succeed()) - - // Verify the update was persisted - reloaded, err := ds.Library().Get(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(reloaded.LastScanStartedAt).ToNot(BeZero()) - Expect(reloaded.FullScanInProgress).To(Equal(fullScan)) - } - - Context("when a quick scan is interrupted and resumed with a full scan request", func() { - BeforeEach(func() { - // First, complete a full scan to populate the database - Expect(runScanner(ctx, true)).To(Succeed()) - - // Verify files were imported - mfs, err := ds.MediaFile().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).To(HaveLen(2)) - - // Now simulate an interrupted quick scan - // (LastScanStartedAt is set, FullScanInProgress is false) - simulateInterruptedScan(false) - }) - - It("should rescan all folders when resumed as full scan", func() { - // Update a tag without changing the folder hash by preserving the original modtime. - // In a quick scan, this wouldn't be detected because the folder hash hasn't changed. - // But in a full scan, all files should be re-read regardless of hash. - origModTime := fsys.MapFS["The Beatles/Help!/01 - Help!.mp3"].ModTime - fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "updated comment"}, origModTime) - - // Resume with a full scan - this should process all folders - // even though folder hashes haven't changed - Expect(runScanner(ctx, true)).To(Succeed()) - - // Verify the comment was updated (which means the folder was processed and file re-imported) - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"title": "Help!"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).To(HaveLen(1)) - Expect(mfs[0].Comment).To(Equal("updated comment")) - }) - }) - - Context("when a full scan is interrupted and resumed with a quick scan request", func() { - BeforeEach(func() { - // First, complete a full scan to populate the database - Expect(runScanner(ctx, true)).To(Succeed()) - - // Verify files were imported - mfs, err := ds.MediaFile().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).To(HaveLen(2)) - - // Now simulate an interrupted full scan - // (LastScanStartedAt is set, FullScanInProgress is true) - simulateInterruptedScan(true) - }) - - It("should continue as full scan even when quick scan is requested", func() { - // Update a tag without changing the folder hash by preserving the original modtime. - origModTime := fsys.MapFS["The Beatles/Help!/01 - Help!.mp3"].ModTime - fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "full scan comment"}, origModTime) - - // Request a quick scan - but because a full scan was in progress, - // it should continue as a full scan - Expect(runScanner(ctx, false)).To(Succeed()) - - // Verify the comment was updated (folder was processed despite unchanged hash) - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"title": "Help!"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).To(HaveLen(1)) - Expect(mfs[0].Comment).To(Equal("full scan comment")) - }) - }) - - Context("when no scan was in progress", func() { - BeforeEach(func() { - // First, complete a full scan to populate the database - Expect(runScanner(ctx, true)).To(Succeed()) - - // Verify files were imported - mfs, err := ds.MediaFile().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).To(HaveLen(2)) - - // Library should have LastScanStartedAt cleared after successful scan - updatedLib, err := ds.Library().Get(ctx, lib.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(updatedLib.LastScanStartedAt).To(BeZero()) - Expect(updatedLib.FullScanInProgress).To(BeFalse()) - }) - - It("should respect the full scan flag for new scans", func() { - // Update a tag without changing the folder hash by preserving the original modtime. - origModTime := fsys.MapFS["The Beatles/Help!/01 - Help!.mp3"].ModTime - fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "new full scan"}, origModTime) - - // Start a new full scan - Expect(runScanner(ctx, true)).To(Succeed()) - - // Verify the comment was updated - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"title": "Help!"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).To(HaveLen(1)) - Expect(mfs[0].Comment).To(Equal("new full scan")) - }) - - It("should not rescan unchanged folders during quick scan", func() { - // Update a tag without changing the folder hash by preserving the original modtime. - // This simulates editing tags in a file (e.g., with a tag editor) without modifying its timestamp. - // In a quick scan, this should NOT be detected because the folder hash remains unchanged. - origModTime := fsys.MapFS["The Beatles/Help!/01 - Help!.mp3"].ModTime - fsys.UpdateTags("The Beatles/Help!/01 - Help!.mp3", _t{"comment": "should not appear"}, origModTime) - - // Do a quick scan - unchanged folders should be skipped - Expect(runScanner(ctx, false)).To(Succeed()) - - // Verify the comment was NOT updated (folder was skipped) - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"title": "Help!"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).To(HaveLen(1)) - Expect(mfs[0].Comment).To(BeEmpty()) - }) - }) - }) - - Describe("RefreshStats", func() { - var refreshStatsCalls []bool - var fsys storagetest.FakeFS - var help func(...map[string]any) *fstest.MapFile - - BeforeEach(func() { - refreshStatsCalls = nil - - // Create a mock artist repository that tracks RefreshStats calls - originalArtistRepo := ds.RealDS.Artist() - ds.MockedArtist = &testArtistRepo{ - ArtistRepository: originalArtistRepo, - callTracker: &refreshStatsCalls, - } - - // Create a simple filesystem for testing - help = template(_t{"albumartist": "The Beatles", "album": "Help!", "year": 1965}) - fsys = createFS(fstest.MapFS{ - "The Beatles/Help!/01 - Help!.mp3": help(track(1, "Help!")), - }) - }) - - It("should call RefreshStats with allArtists=true for full scans", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - - Expect(refreshStatsCalls).To(HaveLen(1)) - Expect(refreshStatsCalls[0]).To(BeTrue(), "RefreshStats should be called with allArtists=true for full scans") - }) - - It("should call RefreshStats with allArtists=false for incremental scans", func() { - // First do a full scan to set up the data - Expect(runScanner(ctx, true)).To(Succeed()) - - // Reset the tracker to only track the incremental scan - refreshStatsCalls = nil - - // Add a new file to trigger changes detection - fsys.Add("The Beatles/Help!/02 - The Night Before.mp3", help(track(2, "The Night Before"))) - - // Do an incremental scan - Expect(runScanner(ctx, false)).To(Succeed()) - - Expect(refreshStatsCalls).To(HaveLen(1)) - Expect(refreshStatsCalls[0]).To(BeFalse(), "RefreshStats should be called with allArtists=false for incremental scans") - }) - - 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 - - By("Initial scan with one album") - Expect(runScanner(ctx, true)).To(Succeed()) - - // Verify initial artist stats - should have 1 album, 1 song - artists, err := ds.Artist().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(HaveLen(1)) - artist := artists[0] - Expect(artist.AlbumCount).To(Equal(1)) // 1 album - Expect(artist.SongCount).To(Equal(1)) // 1 song - - By("Adding files to an existing directory during incremental scan") - // Add more files to the existing Help! album - this should trigger artist stats update during incremental scan - fsys.Add("The Beatles/Help!/02 - The Night Before.mp3", help(track(2, "The Night Before"))) - fsys.Add("The Beatles/Help!/03 - You've Got to Hide Your Love Away.mp3", help(track(3, "You've Got to Hide Your Love Away"))) - - // Do a quick scan (incremental) - Expect(runScanner(ctx, false)).To(Succeed()) - - By("Verifying artist stats were updated correctly") - // Fetch the artist again to check updated stats - artists, err = ds.Artist().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).To(HaveLen(1)) - updatedArtist := artists[0] - - // Should now have 1 album and 3 songs total - // This is the key test - that artist stats are updated during quick scans - Expect(updatedArtist.AlbumCount).To(Equal(1)) // 1 album - Expect(updatedArtist.SongCount).To(Equal(3)) // 3 songs - - // Also verify that role-specific stats are updated (albumartist role) - Expect(updatedArtist.Stats).To(HaveKey(model.RoleAlbumArtist)) - albumArtistStats := updatedArtist.Stats[model.RoleAlbumArtist] - Expect(albumArtistStats.AlbumCount).To(Equal(1)) // 1 album - Expect(albumArtistStats.SongCount).To(Equal(3)) // 3 songs - }) - }) - - Context("when the database is busy", func() { - var busyDS *busyPersistDS - BeforeEach(func() { - // One album across many folders: the suite's single DB connection deadlocks phase 3 on many albums - album := template(_t{"albumartist": "Artist", "album": "Album"}) - files := fstest.MapFS{} - for i := range 30 { - files[fmt.Sprintf("Artist/Part %02d/%02d - Song.mp3", i, i+1)] = album(track(i+1, fmt.Sprintf("Song %02d", i+1))) - } - createFS(files) - busyDS = &busyPersistDS{MockDataStore: ds} - s = scanner.New(ctx, busyDS, events.NoopBroker(), - playlists.NewPlaylists(busyDS, artwork.NewUploader(busyDS)), metrics.NewNoopInstance()) - }) - - It("gives up and stops walking the library when the database stays busy", func() { - busyDS.failures.Store(1000) - - Expect(runScanner(ctx, true)).To(MatchError(ContainSubstring("database is locked"))) - - Expect(mfRepo.cursorCalls.Load()).To(BeNumerically("<", 30)) - }) - - It("does not mark unvisited folders missing when the scan gives up", func() { - Expect(runScanner(ctx, true)).To(Succeed()) - busyDS.failures.Store(1000) - - Expect(runScanner(ctx, true)).ToNot(Succeed()) - - Expect(ds.Folder().CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"missing": true}})).To(BeZero()) - Expect(ds.MediaFile().CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"missing": true}})).To(BeZero()) - }) - }) -}) - -// busyPersistDS fails the scanner's folder saves with SQLITE_BUSY, as if WithTxRetry ran out of retries. -type busyPersistDS struct { - *tests.MockDataStore - failures atomic.Int32 -} - -func (b *busyPersistDS) WithTxRetry(ctx context.Context, block func(context.Context, model.DataStore) error, label ...string) error { - if len(label) > 0 && label[0] == "scanner: persist changes" && b.failures.Add(-1) >= 0 { - return sqlite3.Error{Code: sqlite3.ErrBusy} - } - return b.MockDataStore.WithTxRetry(ctx, block, label...) -} - -func createFindByPath(ctx context.Context, ds model.DataStore) func(string) (*model.MediaFile, error) { - return func(path string) (*model.MediaFile, error) { - list, err := ds.MediaFile().FindByPaths(ctx, []string{path}) - if err != nil { - return nil, err - } - if len(list) == 0 { - return nil, model.ErrNotFound - } - return &list[0], nil - } -} - -type mockMediaFileRepo struct { - model.MediaFileRepository - GetMissingAndMatchingError error - cursorCalls atomic.Int32 -} - -func (m *mockMediaFileRepo) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.MediaFileCursor, error) { - m.cursorCalls.Add(1) - return m.MediaFileRepository.GetCursor(ctx, options...) -} - -func (m *mockMediaFileRepo) GetMissingAndMatching(ctx context.Context, libId int) (model.MediaFileCursor, error) { - if m.GetMissingAndMatchingError != nil { - return nil, m.GetMissingAndMatchingError - } - return m.MediaFileRepository.GetMissingAndMatching(ctx, libId) -} - -type testArtistRepo struct { - model.ArtistRepository - callTracker *[]bool -} - -func (m *testArtistRepo) RefreshStats(ctx context.Context, allArtists bool) (int64, error) { - *m.callTracker = append(*m.callTracker, allArtists) - return m.ArtistRepository.RefreshStats(ctx, allArtists) -} diff --git a/scanner/tag_scanner.go b/scanner/tag_scanner.go new file mode 100644 index 000000000..c4c893556 --- /dev/null +++ b/scanner/tag_scanner.go @@ -0,0 +1,417 @@ +package scanner + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/scanner/metadata" + _ "github.com/navidrome/navidrome/scanner/metadata/ffmpeg" + _ "github.com/navidrome/navidrome/scanner/metadata/taglib" + "github.com/navidrome/navidrome/utils" +) + +type TagScanner struct { + rootFolder string + ds model.DataStore + plsSync *playlistImporter + cnt *counters + mapper *mediaFileMapper + cacheWarmer artwork.CacheWarmer +} + +func NewTagScanner(rootFolder string, ds model.DataStore, playlists core.Playlists, cacheWarmer artwork.CacheWarmer) FolderScanner { + s := &TagScanner{ + rootFolder: rootFolder, + plsSync: newPlaylistImporter(ds, playlists, cacheWarmer, rootFolder), + ds: ds, + cacheWarmer: cacheWarmer, + } + + return s +} + +type dirMap map[string]dirStats + +type counters struct { + added int64 + updated int64 + deleted int64 + playlists int64 +} + +func (cnt *counters) total() int64 { return cnt.added + cnt.updated + cnt.deleted } + +const ( + // filesBatchSize used for batching file metadata extraction + filesBatchSize = 100 +) + +// Scan algorithm overview: +// Load all directories from the DB +// Traverse the music folder, collecting each subfolder's ModTime (self or any non-dir children, whichever is newer) +// For each changed folder: get all files from DB whose path starts with the changed folder (non-recursively), check each file: +// - if file in folder is newer, update the one in DB +// - if file in folder does not exists in DB, add it +// - for each file in the DB that is not found in the folder, delete it from DB +// Compare directories in the fs with the ones in the DB to find deleted folders +// For each deleted folder: delete all files from DB whose path starts with the delete folder path (non-recursively) +// Create new albums/artists, update counters: +// - collect all albumIDs and artistIDs from previous steps +// - refresh the collected albums and artists with the metadata from the mediafiles +// For each changed folder, process playlists: +// - If the playlist is not in the DB, import it, setting sync = true +// - If the playlist is in the DB and sync == true, import it, or else skip it +// Delete all empty albums, delete all empty artists, clean-up playlists +func (s *TagScanner) Scan(ctx context.Context, lastModifiedSince time.Time, progress chan uint32) (int64, error) { + ctx = auth.WithAdminUser(ctx, s.ds) + start := time.Now() + + // Special case: if lastModifiedSince is zero, re-import all files + fullScan := lastModifiedSince.IsZero() + rootFS := os.DirFS(s.rootFolder) + + // If the media folder is empty (no music and no subfolders), abort to avoid deleting all data from DB + empty, err := isDirEmpty(ctx, rootFS, ".") + if err != nil { + return 0, err + } + if empty && !fullScan { + log.Error(ctx, "Media Folder is empty. Aborting scan.", "folder", s.rootFolder) + return 0, nil + } + + allDBDirs, err := s.getDBDirTree(ctx) + if err != nil { + return 0, err + } + + allFSDirs := dirMap{} + var changedDirs []string + s.cnt = &counters{} + genres := newCachedGenreRepository(ctx, s.ds.Genre(ctx)) + s.mapper = newMediaFileMapper(s.rootFolder, genres) + refresher := newRefresher(s.ds, s.cacheWarmer, allFSDirs) + + log.Trace(ctx, "Loading directory tree from music folder", "folder", s.rootFolder) + foldersFound, walkerError := walkDirTree(ctx, rootFS, s.rootFolder) + + for { + folderStats, more := <-foldersFound + if !more { + break + } + progress <- folderStats.AudioFilesCount + allFSDirs[folderStats.Path] = folderStats + + if s.folderHasChanged(folderStats, allDBDirs, lastModifiedSince) { + changedDirs = append(changedDirs, folderStats.Path) + log.Debug("Processing changed folder", "dir", folderStats.Path) + err := s.processChangedDir(ctx, refresher, fullScan, folderStats.Path) + if err != nil { + log.Error("Error updating folder in the DB", "dir", folderStats.Path, err) + } + } + } + + if err := <-walkerError; err != nil { + log.Error("Scan was interrupted by error. See errors above", err) + return 0, err + } + + deletedDirs := s.getDeletedDirs(ctx, allFSDirs, allDBDirs) + if len(deletedDirs)+len(changedDirs) == 0 { + log.Debug(ctx, "No changes found in Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start)) + return 0, nil + } + + for _, dir := range deletedDirs { + err := s.processDeletedDir(ctx, refresher, dir) + if err != nil { + log.Error("Error removing deleted folder from DB", "dir", dir, err) + } + } + + s.cnt.playlists = 0 + if conf.Server.AutoImportPlaylists { + // Now that all mediafiles are imported/updated, search for and import/update playlists + u, _ := request.UserFrom(ctx) + for _, dir := range changedDirs { + info := allFSDirs[dir] + if info.HasPlaylist { + if !u.IsAdmin { + log.Warn("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", "dir", dir) + } else { + s.cnt.playlists = s.plsSync.processPlaylists(ctx, dir) + } + } + } + } else { + log.Debug("Playlist auto-import is disabled") + } + + err = s.ds.GC(log.NewContext(ctx), s.rootFolder) + log.Info("Finished processing Music Folder", "folder", s.rootFolder, "elapsed", time.Since(start), + "added", s.cnt.added, "updated", s.cnt.updated, "deleted", s.cnt.deleted, "playlistsImported", s.cnt.playlists) + + return s.cnt.total(), err +} + +func isDirEmpty(ctx context.Context, rootFS fs.FS, dir string) (bool, error) { + children, stats, err := loadDir(ctx, rootFS, dir) + if err != nil { + return false, err + } + return len(children) == 0 && stats.AudioFilesCount == 0, nil +} + +func (s *TagScanner) getDBDirTree(ctx context.Context) (map[string]struct{}, error) { + start := time.Now() + log.Trace(ctx, "Loading directory tree from database", "folder", s.rootFolder) + + repo := s.ds.MediaFile(ctx) + dirs, err := repo.FindPathsRecursively(s.rootFolder) + if err != nil { + return nil, err + } + resp := map[string]struct{}{} + for _, d := range dirs { + resp[filepath.Clean(d)] = struct{}{} + } + + log.Debug("Directory tree loaded from DB", "total", len(resp), "elapsed", time.Since(start)) + return resp, nil +} + +func (s *TagScanner) folderHasChanged(folder dirStats, dbDirs map[string]struct{}, lastModified time.Time) bool { + _, inDB := dbDirs[folder.Path] + // If is a new folder with at least one song OR it was modified after lastModified + return (!inDB && (folder.AudioFilesCount > 0)) || folder.ModTime.After(lastModified) +} + +func (s *TagScanner) getDeletedDirs(ctx context.Context, fsDirs dirMap, dbDirs map[string]struct{}) []string { + start := time.Now() + log.Trace(ctx, "Checking for deleted folders") + var deleted []string + + for d := range dbDirs { + if _, ok := fsDirs[d]; !ok { + deleted = append(deleted, d) + } + } + + sort.Strings(deleted) + log.Debug(ctx, "Finished deleted folders check", "total", len(deleted), "elapsed", time.Since(start)) + return deleted +} + +func (s *TagScanner) processDeletedDir(ctx context.Context, refresher *refresher, dir string) error { + start := time.Now() + + mfs, err := s.ds.MediaFile(ctx).FindAllByPath(dir) + if err != nil { + return err + } + + c, err := s.ds.MediaFile(ctx).DeleteByPath(dir) + if err != nil { + return err + } + s.cnt.deleted += c + + for _, t := range mfs { + refresher.accumulate(t) + } + + err = refresher.flush(ctx) + log.Info(ctx, "Finished processing deleted folder", "dir", dir, "purged", len(mfs), "elapsed", time.Since(start)) + return err +} + +func (s *TagScanner) processChangedDir(ctx context.Context, refresher *refresher, fullScan bool, dir string) error { + start := time.Now() + + // Load folder's current tracks from DB into a map + currentTracks := map[string]model.MediaFile{} + ct, err := s.ds.MediaFile(ctx).FindAllByPath(dir) + if err != nil { + return err + } + for _, t := range ct { + currentTracks[t.Path] = t + } + + // Load track list from the folder + files, err := loadAllAudioFiles(dir) + if err != nil { + return err + } + + // If no files to process, return + if len(files)+len(currentTracks) == 0 { + return nil + } + + orphanTracks := map[string]model.MediaFile{} + for k, v := range currentTracks { + orphanTracks[k] = v + } + + // If track from folder is newer than the one in DB, select for update/insert in DB + log.Trace(ctx, "Processing changed folder", "dir", dir, "tracksInDB", len(currentTracks), "tracksInFolder", len(files)) + var filesToUpdate []string + for filePath, entry := range files { + c, inDB := currentTracks[filePath] + if !inDB || fullScan { + filesToUpdate = append(filesToUpdate, filePath) + s.cnt.added++ + } else { + info, err := entry.Info() + if err != nil { + log.Error("Could not stat file", "filePath", filePath, err) + continue + } + if info.ModTime().After(c.UpdatedAt) { + filesToUpdate = append(filesToUpdate, filePath) + s.cnt.updated++ + } + } + + // Force a refresh of the album and artist, to cater for cover art files + refresher.accumulate(c) + + // Only leaves in orphanTracks the ones not found in the folder. After this loop any remaining orphanTracks + // are considered gone from the music folder and will be deleted from DB + delete(orphanTracks, filePath) + } + + numUpdatedTracks := 0 + numPurgedTracks := 0 + + if len(filesToUpdate) > 0 { + numUpdatedTracks, err = s.addOrUpdateTracksInDB(ctx, refresher, dir, currentTracks, filesToUpdate) + if err != nil { + return err + } + } + + if len(orphanTracks) > 0 { + numPurgedTracks, err = s.deleteOrphanSongs(ctx, refresher, dir, orphanTracks) + if err != nil { + return err + } + } + + err = refresher.flush(ctx) + log.Info(ctx, "Finished processing changed folder", "dir", dir, "updated", numUpdatedTracks, + "deleted", numPurgedTracks, "elapsed", time.Since(start)) + return err +} + +func (s *TagScanner) deleteOrphanSongs( + ctx context.Context, + refresher *refresher, + dir string, + tracksToDelete map[string]model.MediaFile, +) (int, error) { + numPurgedTracks := 0 + + log.Debug(ctx, "Deleting orphan tracks from DB", "dir", dir, "numTracks", len(tracksToDelete)) + // Remaining tracks from DB that are not in the folder are deleted + for _, ct := range tracksToDelete { + numPurgedTracks++ + refresher.accumulate(ct) + if err := s.ds.MediaFile(ctx).Delete(ct.ID); err != nil { + return 0, err + } + s.cnt.deleted++ + } + return numPurgedTracks, nil +} + +func (s *TagScanner) addOrUpdateTracksInDB( + ctx context.Context, + refresher *refresher, + dir string, + currentTracks map[string]model.MediaFile, + filesToUpdate []string, +) (int, error) { + numUpdatedTracks := 0 + + log.Trace(ctx, "Updating mediaFiles in DB", "dir", dir, "numFiles", len(filesToUpdate)) + // Break the file list in chunks to avoid calling ffmpeg with too many parameters + chunks := utils.BreakUpStringSlice(filesToUpdate, filesBatchSize) + for _, chunk := range chunks { + // Load tracks Metadata from the folder + newTracks, err := s.loadTracks(chunk) + if err != nil { + return 0, err + } + + // If track from folder is newer than the one in DB, update/insert in DB + log.Trace(ctx, "Updating mediaFiles in DB", "dir", dir, "files", chunk, "numFiles", len(chunk)) + for i := range newTracks { + n := newTracks[i] + // Keep current annotations if the track is in the DB + if t, ok := currentTracks[n.Path]; ok { + n.Annotations = t.Annotations + } + err := s.ds.MediaFile(ctx).Put(&n) + if err != nil { + return 0, err + } + refresher.accumulate(n) + numUpdatedTracks++ + } + } + return numUpdatedTracks, nil +} + +func (s *TagScanner) loadTracks(filePaths []string) (model.MediaFiles, error) { + mds, err := metadata.Extract(filePaths...) + if err != nil { + return nil, err + } + + var mfs model.MediaFiles + for _, md := range mds { + mf := s.mapper.toMediaFile(md) + mfs = append(mfs, mf) + } + return mfs, nil +} + +func loadAllAudioFiles(dirPath string) (map[string]fs.DirEntry, error) { + files, err := fs.ReadDir(os.DirFS(dirPath), ".") + if err != nil { + return nil, err + } + fileInfos := make(map[string]fs.DirEntry) + for _, f := range files { + if f.IsDir() { + continue + } + if strings.HasPrefix(f.Name(), ".") { + continue + } + filePath := filepath.Join(dirPath, f.Name()) + if !model.IsAudioFile(filePath) { + continue + } + fileInfos[filePath] = f + } + + return fileInfos, nil +} diff --git a/scanner/tag_scanner_test.go b/scanner/tag_scanner_test.go new file mode 100644 index 000000000..215e06bc3 --- /dev/null +++ b/scanner/tag_scanner_test.go @@ -0,0 +1,32 @@ +package scanner + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TagScanner", func() { + Describe("loadAllAudioFiles", func() { + It("return all audio files from the folder", func() { + files, err := loadAllAudioFiles("tests/fixtures") + Expect(err).ToNot(HaveOccurred()) + Expect(files).To(HaveLen(5)) + Expect(files).To(HaveKey("tests/fixtures/test.ogg")) + Expect(files).To(HaveKey("tests/fixtures/test.mp3")) + Expect(files).To(HaveKey("tests/fixtures/test_no_read_permission.ogg")) + Expect(files).To(HaveKey("tests/fixtures/01 Invisible (RED) Edit Version.mp3")) + Expect(files).To(HaveKey("tests/fixtures/01 Invisible (RED) Edit Version.m4a")) + Expect(files).ToNot(HaveKey("tests/fixtures/._02 Invisible.mp3")) + Expect(files).ToNot(HaveKey("tests/fixtures/playlist.m3u")) + }) + + It("returns error if path does not exist", func() { + _, err := loadAllAudioFiles("./INVALID/PATH") + Expect(err).To(HaveOccurred()) + }) + + It("returns empty map if there are no audio files in path", func() { + Expect(loadAllAudioFiles("tests/fixtures/empty_folder")).To(BeEmpty()) + }) + }) +}) diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go index d39cad5f6..48a0a6f8c 100644 --- a/scanner/walk_dir_tree.go +++ b/scanner/walk_dir_tree.go @@ -3,181 +3,120 @@ package scanner import ( "context" "io/fs" - "maps" - "path" + "os" "path/filepath" - "slices" "sort" "strings" + "time" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/storage" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils" ) -// walkDirTree recursively walks the directory tree starting from the given targetFolders. -// If no targetFolders are provided, it starts from the root folder ("."). -// It returns a channel of folderEntry pointers representing each folder found. -func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<-chan *folderEntry, error) { - results := make(chan *folderEntry) - folders := targetFolders - if len(targetFolders) == 0 { - // No specific folders provided, scan the root folder - folders = []string{"."} +type ( + dirStats struct { + Path string + ModTime time.Time + Images []string + ImagesUpdatedAt time.Time + HasPlaylist bool + AudioFilesCount uint32 } +) + +func walkDirTree(ctx context.Context, fsys fs.FS, rootFolder string) (<-chan dirStats, chan error) { + results := make(chan dirStats) + errC := make(chan error) go func() { defer close(results) - for _, folderPath := range folders { - if utils.IsCtxDone(ctx) { - return - } - - // Check if target folder exists before walking it - // If it doesn't exist (e.g., deleted between watcher detection and scan execution), - // skip it so it remains in job.lastUpdates and gets handled in following steps - _, err := fs.Stat(job.fs, folderPath) - if err != nil { - log.Warn(ctx, "Scanner: Target folder does not exist.", "path", folderPath, err) - continue - } - - // A full walk never descends into symlinked folders when following is disabled, so a - // target reached through one (e.g. a watcher event for a new link) is skipped too. - if !conf.Server.Scanner.FollowSymlinks && isSymlinkedPath(job.fs, folderPath) { - log.Debug(ctx, "Scanner: Skipping symlinked target folder, following is disabled", "path", folderPath) - continue - } - - // Create checker and push patterns from root to this folder - checker := newIgnoreChecker(job.fs) - err = checker.PushAllParents(ctx, folderPath) - if err != nil { - log.Error(ctx, "Scanner: Error pushing ignore patterns for target folder", "path", folderPath, err) - continue - } - - // Recursively walk this folder and all its children - err = walkFolder(ctx, job, folderPath, checker, results) - if utils.IsCtxDone(ctx) { - return - } - if err != nil { - log.Error(ctx, "Scanner: Error walking target folder", "path", folderPath, err) - continue - } + defer close(errC) + err := walkFolder(ctx, fsys, rootFolder, ".", results) + if err != nil { + log.Error(ctx, "There were errors reading directories from filesystem", "path", rootFolder, err) + errC <- err } - log.Debug(ctx, "Scanner: Finished reading target folders", "lib", job.lib.Name, "path", job.lib.Path, "numFolders", job.numFolders.Load()) + log.Debug(ctx, "Finished reading directories from filesystem", "path", rootFolder) }() - return results, nil + return results, errC } -func walkFolder(ctx context.Context, job *scanJob, currentFolder string, checker *IgnoreChecker, results chan<- *folderEntry) error { - // Push patterns for this folder onto the stack - _ = checker.Push(ctx, currentFolder) - defer checker.Pop() // Pop patterns when leaving this folder - - folder, children, err := loadDir(ctx, job, currentFolder, checker) +func walkFolder(ctx context.Context, fsys fs.FS, rootPath string, currentFolder string, results chan<- dirStats) error { + children, stats, err := loadDir(ctx, fsys, currentFolder) if err != nil { - log.Warn(ctx, "Scanner: Error loading dir. Skipping", "path", currentFolder, err) - return nil + return err } for _, c := range children { - err := walkFolder(ctx, job, c, checker, results) + err := walkFolder(ctx, fsys, rootPath, c, results) if err != nil { return err } } - dir := path.Clean(currentFolder) - log.Trace(ctx, "Scanner: Found directory", " path", dir, "audioFiles", maps.Keys(folder.audioFiles), - "images", maps.Keys(folder.imageFiles), "playlists", len(folder.playlistFiles), "imagesUpdatedAt", folder.imagesUpdatedAt, - "updTime", folder.updTime, "modTime", folder.modTime, "numChildren", len(children)) - folder.path = dir - folder.elapsed.Start() + dir := filepath.Clean(filepath.Join(rootPath, currentFolder)) + log.Trace(ctx, "Found directory", "dir", dir, "audioCount", stats.AudioFilesCount, + "images", stats.Images, "hasPlaylist", stats.HasPlaylist) + stats.Path = dir + results <- *stats - select { - case results <- folder: - return nil - case <-ctx.Done(): - return ctx.Err() - } + return nil } -func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreChecker) (folder *folderEntry, children []string, err error) { - // Check if directory exists before creating the folder entry - // This is important to avoid removing the folder from lastUpdates if it doesn't exist - dirInfo, err := fs.Stat(job.fs, dirPath) +func loadDir(ctx context.Context, fsys fs.FS, dirPath string) ([]string, *dirStats, error) { + var children []string + stats := &dirStats{} + + dirInfo, err := fs.Stat(fsys, dirPath) if err != nil { - log.Warn(ctx, "Scanner: Error stating dir", "path", dirPath, err) + log.Error(ctx, "Error stating dir", "path", dirPath, err) return nil, nil, err } + stats.ModTime = dirInfo.ModTime() - // Now that we know the folder exists, create the entry (which removes it from lastUpdates) - folder = job.createFolderEntry(dirPath) - folder.modTime = dirInfo.ModTime() - - dir, err := job.fs.Open(dirPath) + dir, err := fsys.Open(dirPath) if err != nil { - log.Warn(ctx, "Scanner: Error in Opening directory", "path", dirPath, err) - return folder, children, err + log.Error(ctx, "Error in Opening directory", "path", dirPath, err) + return children, stats, err } defer dir.Close() dirFile, ok := dir.(fs.ReadDirFile) if !ok { log.Error(ctx, "Not a directory", "path", dirPath) - return folder, children, err + return children, stats, err } - entries := fullReadDir(ctx, dirFile) - children = make([]string, 0, len(entries)) - for _, entry := range entries { - entryPath := path.Join(dirPath, entry.Name()) - if checker.ShouldIgnore(ctx, entryPath) { - log.Trace(ctx, "Scanner: Ignoring entry", "path", entryPath) - continue - } - if ctx.Err() != nil { - return folder, children, ctx.Err() - } - isDir, err := isDirOrSymlinkToDir(job.fs, dirPath, entry) + for _, entry := range fullReadDir(ctx, dirFile) { + isDir, err := isDirOrSymlinkToDir(fsys, dirPath, entry) // Skip invalid symlinks if err != nil { - log.Warn(ctx, "Scanner: Invalid symlink", "dir", entryPath, err) + log.Error(ctx, "Invalid symlink", "dir", filepath.Join(dirPath, entry.Name()), err) continue } - if isIgnoredEntry(entry.Name(), isDir) { - continue - } - if isDir && isDirReadable(ctx, job.fs, entryPath) { - children = append(children, entryPath) - folder.numSubFolders++ + if isDir && !isDirIgnored(fsys, dirPath, entry) && isDirReadable(ctx, fsys, dirPath, entry) { + children = append(children, filepath.Join(dirPath, entry.Name())) } else { fileInfo, err := entry.Info() if err != nil { - log.Warn(ctx, "Scanner: Error getting fileInfo", "name", entry.Name(), err) - return folder, children, err + log.Error(ctx, "Error getting fileInfo", "name", entry.Name(), err) + return children, stats, err } - if fileInfo.ModTime().After(folder.modTime) { - folder.modTime = fileInfo.ModTime() - } - name, ok := resolveEntryName(ctx, job.fs, dirPath, entry) - if !ok { - continue + if fileInfo.ModTime().After(stats.ModTime) { + stats.ModTime = fileInfo.ModTime() } switch { - case model.IsAudioFile(name): - folder.audioFiles[entry.Name()] = entry - case model.IsValidPlaylist(name): - folder.playlistFiles[entry.Name()] = entry - case model.IsImageFile(name): - folder.imageFiles[entry.Name()] = entry - folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime) + case model.IsAudioFile(entry.Name()): + stats.AudioFilesCount++ + case model.IsValidPlaylist(entry.Name()): + stats.HasPlaylist = true + case model.IsImageFile(entry.Name()): + stats.Images = append(stats.Images, entry.Name()) + if fileInfo.ModTime().After(stats.ImagesUpdatedAt) { + stats.ImagesUpdatedAt = fileInfo.ModTime() + } } } } - return folder, children, nil + return children, stats, nil } // fullReadDir reads all files in the folder, skipping the ones with errors. @@ -188,9 +127,6 @@ func fullReadDir(ctx context.Context, dir fs.ReadDirFile) []fs.DirEntry { var allEntries []fs.DirEntry var prevErrStr = "" for { - if ctx.Err() != nil { - return nil - } entries, err := dir.ReadDir(-1) allEntries = append(allEntries, entries...) if err == nil { @@ -198,7 +134,7 @@ func fullReadDir(ctx context.Context, dir fs.ReadDirFile) []fs.DirEntry { } log.Warn(ctx, "Skipping DirEntry", err) if prevErrStr == err.Error() { - log.Error(ctx, "Scanner: Duplicate DirEntry failure, bailing", err) + log.Error(ctx, "Duplicate DirEntry failure, bailing", err) break } prevErrStr = err.Error() @@ -217,136 +153,42 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool, if dirEnt.IsDir() { return true, nil } - if dirEnt.Type()&fs.ModeSymlink == 0 { - return false, nil - } - // If symlinks are disabled, return false for symlinks - if !conf.Server.Scanner.FollowSymlinks { + if dirEnt.Type()&os.ModeSymlink == 0 { return false, nil } // Does this symlink point to a directory? - fileInfo, err := fs.Stat(fsys, path.Join(baseDir, dirEnt.Name())) + fileInfo, err := fs.Stat(fsys, filepath.Join(baseDir, dirEnt.Name())) if err != nil { return false, err } return fileInfo.IsDir(), nil } -// isSymlinkedPath returns true if folderPath, or any of its parent folders, is a symbolic link. -// It needs fsys to implement fs.ReadLinkFS, otherwise links are followed and never detected. -func isSymlinkedPath(fsys fs.FS, folderPath string) bool { - for p := path.Clean(folderPath); p != "." && p != "/"; p = path.Dir(p) { - info, err := fs.Lstat(fsys, p) - if err == nil && info.Mode()&fs.ModeSymlink != 0 { - return true - } +// isDirIgnored returns true if the directory represented by dirEnt contains an +// `ignore` file (named after skipScanFile) +func isDirIgnored(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) bool { + // allows Album folders for albums which eg start with ellipses + if strings.HasPrefix(dirEnt.Name(), ".") && !strings.HasPrefix(dirEnt.Name(), "..") { + return true } - return false -} - -const maxSymlinkHops = 40 - -// resolveEntryName returns the name to classify the entry by, and whether to -// consider it at all. Symlinks are resolved to their final target so the caller -// classifies by the target's extension, not the link's name. Returns ok=false -// when symlinks are disabled or the target can't be resolved. -func resolveEntryName(ctx context.Context, fsys fs.FS, dirPath string, entry fs.DirEntry) (string, bool) { - if entry.Type()&fs.ModeSymlink == 0 { - return entry.Name(), true - } - linkPath := path.Join(dirPath, entry.Name()) - if !conf.Server.Scanner.FollowSymlinks { - log.Trace(ctx, "Scanner: Skipping symlink, following is disabled", "path", linkPath) - return "", false - } - // OS-backed filesystems can resolve the whole chain, even when it leaves the FS root - // (e.g. a link into another folder/drive), so the final target is always what gets - // classified. The fs.ReadLink loop below can't see past the root: it classifies by the - // last in-chain name it can reach. - if resolver, ok := fsys.(storage.SymlinkResolverFS); ok { - target, err := resolver.ResolveSymlink(linkPath) - if err != nil { - log.Trace(ctx, "Scanner: Skipping symlink, cannot resolve target", "path", linkPath, err) - return "", false - } - resolved := filepath.Base(target) - log.Trace(ctx, "Scanner: Resolved symlink", "path", linkPath, "target", target, "name", resolved) - return resolved, true - } - cur := linkPath - for hop := 0; hop < maxSymlinkHops; hop++ { - target, err := fs.ReadLink(fsys, cur) - if err != nil { - if hop == 0 { - log.Trace(ctx, "Scanner: Skipping symlink, cannot resolve target", "path", linkPath, err) - return "", false - } - resolved := path.Base(cur) - log.Trace(ctx, "Scanner: Resolved symlink", "path", linkPath, "target", cur, "name", resolved) - return resolved, true - } - if path.IsAbs(target) { - // Absolute targets are not valid fs.FS paths, so the next ReadLink fails and - // resolution stops here, leaving cur as the target to classify by name. - cur = target - } else { - cur = path.Join(path.Dir(cur), target) - } - } - log.Trace(ctx, "Scanner: Skipping symlink, too many hops (possible loop)", "path", linkPath) - return "", false + _, err := fs.Stat(fsys, filepath.Join(baseDir, dirEnt.Name(), consts.SkipScanFile)) + return err == nil } // isDirReadable returns true if the directory represented by dirEnt is readable -func isDirReadable(ctx context.Context, fsys fs.FS, dirPath string) bool { - dir, err := fsys.Open(dirPath) +func isDirReadable(ctx context.Context, fsys fs.FS, baseDir string, dirEnt fs.DirEntry) bool { + path := filepath.Join(baseDir, dirEnt.Name()) + + dir, err := fsys.Open(path) if err != nil { - log.Warn("Scanner: Skipping unreadable directory", "path", dirPath, err) + log.Warn("Skipping unreadable directory", "path", path, err) return false } + err = dir.Close() if err != nil { - log.Warn(ctx, "Scanner: Error closing directory", "path", dirPath, err) + log.Warn(ctx, "Error closing directory", "path", path, err) } + return true } - -// List of special directories to ignore -var ignoredDirs = []string{ - "$RECYCLE.BIN", - "#snapshot", - "@Recycle", - "@eaDir", - "@Recently-Snapshot", - ".git", - ".streams", - "lost+found", -} - -// isIgnoredEntry returns true if a directory entry with the given name should be -// skipped during scanning. It centralizes all name- and type-based ignore policy: -// - special system directories in ignoredDirs are always ignored; -// - dot-prefixed files are always ignored; -// - dot-prefixed folders are ignored unless Scanner.IgnoreDotFolders is disabled, -// allowing albums like ".Hack Sign" to be scanned when the option is off. -func isIgnoredEntry(name string, isDir bool) bool { - if isDir && isDirIgnored(name) { - return true - } - return isDotEntry(name) && (!isDir || conf.Server.Scanner.IgnoreDotFolders) -} - -// isDirIgnored returns true if the directory name is in the explicit ignoredDirs -// blocklist. Used both while walking the tree and by the file watcher. -func isDirIgnored(name string) bool { - return slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) }) -} - -// isDotEntry returns true only for names with exactly one leading dot (the -// convention for hidden entries), e.g. ".hidden". Names with two or more leading -// dots are not considered hidden: "." and ".." are the special self/parent -// references, and anything like "..foo" or "...Album" is a regular name (album -// folders sometimes start with ellipses), so all of these return false. -func isDotEntry(name string) bool { - return name != "." && strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") -} diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go index 0c2aba3e0..163754a69 100644 --- a/scanner/walk_dir_tree_test.go +++ b/scanner/walk_dir_tree_test.go @@ -4,681 +4,123 @@ import ( "context" "fmt" "io/fs" - "maps" "os" "path/filepath" - "slices" "testing/fstest" - "github.com/navidrome/navidrome/conf" - "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" + . "github.com/onsi/gomega/gstruct" ) var _ = Describe("walk_dir_tree", func() { + dir, _ := os.Getwd() + baseDir := filepath.Join(dir, "tests", "fixtures") + fsys := os.DirFS(baseDir) + Describe("walkDirTree", func() { - var ( - fsys storage.MusicFS - job *scanJob - ctx context.Context - ) + It("reads all info correctly", func() { + var collected = dirMap{} + results, errC := walkDirTree(context.Background(), fsys, baseDir) - Context("full library", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = GinkgoT().Context() - fsys = &mockMusicFS{ - FS: fstest.MapFS{ - "root/a/.ndignore": {Data: []byte("ignored/*")}, - "root/a/f1.mp3": {}, - "root/a/f2.mp3": {}, - "root/a/ignored/bad.mp3": {}, - "root/b/cover.jpg": {}, - "root/c/f3": {}, - "root/d": {}, - "root/d/.ndignore": {}, - "root/d/f1.mp3": {}, - "root/d/f2.mp3": {}, - "root/d/f3.mp3": {}, - "root/e/original/f1.mp3": {}, - "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")}, - "root/f/realsong.mp3": {Data: []byte("AUDIO")}, - "root/f/legit.mp3": {Mode: fs.ModeSymlink, Data: []byte("realsong.mp3")}, - "root/f/secret": {Data: []byte("TOPSECRET")}, - "root/f/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("secret")}, - "root/g/.Hack Sign Original Soundtrack/track.mp3": {}, - "root/h/.hidden.mp3": {}, - "root/i/.git/config": {}, - "root/i/.streams/stream.mp3": {}, - }, + for { + stats, more := <-results + if !more { + break } - job = &scanJob{ - fs: fsys, - lib: model.Library{Path: "/music"}, - } - }) - - // Helper function to call walkDirTree and collect folders from the results channel - getFolders := func() map[string]*folderEntry { - results, err := walkDirTree(ctx, job) - Expect(err).ToNot(HaveOccurred()) - - folders := map[string]*folderEntry{} - g := errgroup.Group{} - g.Go(func() error { - for folder := range results { - folders[folder.path] = folder - } - return nil - }) - _ = g.Wait() - return folders + collected[stats.Path] = stats } - DescribeTable("symlink handling", - func(followSymlinks bool, expectedFolderCount int) { - conf.Server.Scanner.FollowSymlinks = followSymlinks - folders := getFolders() - - Expect(folders).To(HaveLen(expectedFolderCount + 2)) // +2 for `.` and `root` - - // Basic folder structure checks - Expect(folders["root/a"].audioFiles).To(SatisfyAll( - HaveLen(2), - HaveKey("f1.mp3"), - HaveKey("f2.mp3"), - )) - Expect(folders["root/a"].imageFiles).To(BeEmpty()) - Expect(folders["root/b"].audioFiles).To(BeEmpty()) - Expect(folders["root/b"].imageFiles).To(SatisfyAll( - HaveLen(1), - HaveKey("cover.jpg"), - )) - Expect(folders["root/c"].audioFiles).To(BeEmpty()) - Expect(folders["root/c"].imageFiles).To(BeEmpty()) - Expect(folders).ToNot(HaveKey("root/d")) - - // By default (Scanner.IgnoreDotFolders == true), dot-prefixed - // folders are skipped, dot-prefixed files are not indexed, and - // the special ignoredDirs (.git, .streams) are never traversed. - Expect(folders).ToNot(HaveKey("root/g/.Hack Sign Original Soundtrack")) - Expect(folders["root/h"].audioFiles).To(BeEmpty()) - Expect(folders).ToNot(HaveKey("root/i/.git")) - Expect(folders).ToNot(HaveKey("root/i/.streams")) - - // Symlink specific checks - if followSymlinks { - Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1)) - Expect(folders["root/f"].audioFiles).To(HaveKey("legit.mp3")) - Expect(folders["root/f"].audioFiles).To(HaveKey("realsong.mp3")) - Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3")) - } else { - Expect(folders).ToNot(HaveKey("root/e/symlink")) - Expect(folders["root/f"].audioFiles).To(HaveKey("realsong.mp3")) - Expect(folders["root/f"].audioFiles).ToNot(HaveKey("legit.mp3")) - Expect(folders["root/f"].audioFiles).ToNot(HaveKey("evil.mp3")) - } - }, - Entry("with symlinks enabled", true, 11), - Entry("with symlinks disabled", false, 10), - ) - - DescribeTable("dot-prefixed folders with IgnoreDotFolders disabled", - func(followSymlinks bool) { - conf.Server.Scanner.FollowSymlinks = followSymlinks - conf.Server.Scanner.IgnoreDotFolders = false - folders := getFolders() - - // Dot-prefixed album folders are now traversed and indexed - Expect(folders["root/g/.Hack Sign Original Soundtrack"].audioFiles).To(SatisfyAll( - HaveLen(1), - HaveKey("track.mp3"), - )) - - // Dot-prefixed files are still ignored, even with the flag off - Expect(folders["root/h"].audioFiles).To(BeEmpty()) - - // Special ignoredDirs remain blocked regardless of the flag - Expect(folders).ToNot(HaveKey("root/i/.git")) - Expect(folders).ToNot(HaveKey("root/i/.streams")) - }, - Entry("with symlinks enabled", true), - Entry("with symlinks disabled", false), - ) - }) - - Context("with target folders", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ctx = GinkgoT().Context() - fsys = &mockMusicFS{ - FS: fstest.MapFS{ - "Artist/Album1/track1.mp3": {}, - "Artist/Album1/track2.mp3": {}, - "Artist/Album2/track1.mp3": {}, - "Artist/Album2/track2.mp3": {}, - "Artist/Album2/Sub/track3.mp3": {}, - "OtherArtist/Album3/track1.mp3": {}, - }, - } - job = &scanJob{ - fs: fsys, - lib: model.Library{Path: "/music"}, - } - }) - - It("should recursively walk all subdirectories of target folders", func() { - results, err := walkDirTree(ctx, job, "Artist") - Expect(err).ToNot(HaveOccurred()) - - folders := map[string]*folderEntry{} - g := errgroup.Group{} - g.Go(func() error { - for folder := range results { - folders[folder.path] = folder - } - return nil - }) - _ = g.Wait() - - // Should include the target folder and all its descendants - Expect(folders).To(SatisfyAll( - HaveKey("Artist"), - HaveKey("Artist/Album1"), - HaveKey("Artist/Album2"), - HaveKey("Artist/Album2/Sub"), - )) - - // Should not include folders outside the target - Expect(folders).ToNot(HaveKey("OtherArtist")) - Expect(folders).ToNot(HaveKey("OtherArtist/Album3")) - - // Verify audio files are present - Expect(folders["Artist/Album1"].audioFiles).To(HaveLen(2)) - Expect(folders["Artist/Album2"].audioFiles).To(HaveLen(2)) - Expect(folders["Artist/Album2/Sub"].audioFiles).To(HaveLen(1)) - }) - - It("should handle multiple target folders", func() { - results, err := walkDirTree(ctx, job, "Artist/Album1", "OtherArtist") - Expect(err).ToNot(HaveOccurred()) - - folders := map[string]*folderEntry{} - g := errgroup.Group{} - g.Go(func() error { - for folder := range results { - folders[folder.path] = folder - } - return nil - }) - _ = g.Wait() - - // Should include both target folders and their descendants - Expect(folders).To(SatisfyAll( - HaveKey("Artist/Album1"), - HaveKey("OtherArtist"), - HaveKey("OtherArtist/Album3"), - )) - - // Should not include other folders - Expect(folders).ToNot(HaveKey("Artist")) - Expect(folders).ToNot(HaveKey("Artist/Album2")) - Expect(folders).ToNot(HaveKey("Artist/Album2/Sub")) - }) - - It("should skip non-existent target folders and preserve them in lastUpdates", func() { - // Setup job with lastUpdates for both existing and non-existing folders - job.lastUpdates = map[string]model.FolderUpdateInfo{ - model.FolderID(job.lib, "Artist/Album1"): {}, - model.FolderID(job.lib, "NonExistent/DeletedFolder"): {}, - model.FolderID(job.lib, "OtherArtist/Album3"): {}, - } - - // Try to scan existing folder and non-existing folder - results, err := walkDirTree(ctx, job, "Artist/Album1", "NonExistent/DeletedFolder") - Expect(err).ToNot(HaveOccurred()) - - // Collect results - folders := map[string]struct{}{} - for folder := range results { - folders[folder.path] = struct{}{} - } - - // Should only include the existing folder - Expect(folders).To(HaveKey("Artist/Album1")) - Expect(folders).ToNot(HaveKey("NonExistent/DeletedFolder")) - - // The non-existent folder should still be in lastUpdates (not removed by popLastUpdate) - Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "NonExistent/DeletedFolder"))) - - // The existing folder should have been removed from lastUpdates - Expect(job.lastUpdates).ToNot(HaveKey(model.FolderID(job.lib, "Artist/Album1"))) - - // Folders not in targets should remain in lastUpdates - Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3"))) - }) - - // #6292: a watcher event for a new folder symlink makes the link itself a scan target - Context("symlinked target folders (production local storage FS)", func() { - BeforeEach(func() { - libRoot := GinkgoT().TempDir() - Expect(os.MkdirAll(filepath.Join(libRoot, "Mozart", "Album1"), 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(libRoot, "Mozart", "Album1", "track.mp3"), []byte("AUDIO"), 0600)).To(Succeed()) - Expect(os.Symlink("Mozart", filepath.Join(libRoot, "Wolfgang Amadeus Mozart"))).To(Succeed()) - job = &scanJob{fs: newLocalMusicFS(libRoot), lib: model.Library{Path: libRoot}} - }) - - walkTargets := func(targets ...string) map[string]*folderEntry { - results, err := walkDirTree(ctx, job, targets...) - Expect(err).ToNot(HaveOccurred()) - folders := map[string]*folderEntry{} - for folder := range results { - folders[folder.path] = folder - } - return folders - } - - DescribeTable("with FollowSymlinks disabled", - func(target string, expected ...string) { - conf.Server.Scanner.FollowSymlinks = false - Expect(slices.Collect(maps.Keys(walkTargets(target)))).To(ConsistOf(expected)) - }, - Entry("skips a target that is a symlink", "Wolfgang Amadeus Mozart"), - Entry("skips a target under a symlinked folder", "Wolfgang Amadeus Mozart/Album1"), - Entry("walks a regular target", "Mozart", "Mozart", "Mozart/Album1"), - ) - - It("walks a symlinked target when FollowSymlinks is enabled", func() { - conf.Server.Scanner.FollowSymlinks = true - folders := walkTargets("Wolfgang Amadeus Mozart") - Expect(folders).To(HaveKey("Wolfgang Amadeus Mozart/Album1")) - Expect(folders["Wolfgang Amadeus Mozart/Album1"].audioFiles).To(HaveKey("track.mp3")) - }) - }) + Consistently(errC).ShouldNot(Receive()) + Expect(collected[baseDir]).To(MatchFields(IgnoreExtras, Fields{ + "Images": BeEmpty(), + "HasPlaylist": BeFalse(), + "AudioFilesCount": BeNumerically("==", 6), + })) + Expect(collected[filepath.Join(baseDir, "artist", "an-album")]).To(MatchFields(IgnoreExtras, Fields{ + "Images": ConsistOf("cover.jpg", "front.png", "artist.png"), + "HasPlaylist": BeFalse(), + "AudioFilesCount": BeNumerically("==", 1), + })) + Expect(collected[filepath.Join(baseDir, "playlists")].HasPlaylist).To(BeTrue()) + Expect(collected).To(HaveKey(filepath.Join(baseDir, "symlink2dir"))) + Expect(collected).To(HaveKey(filepath.Join(baseDir, "empty_folder"))) }) }) - Describe("helper functions", func() { - dir, _ := os.Getwd() - fsys := os.DirFS(dir) - baseDir := filepath.Join("tests", "fixtures") - - Describe("isDirOrSymlinkToDir", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - Context("with symlinks enabled", func() { - BeforeEach(func() { - tests.SkipOnWindows("symlink semantics") - conf.Server.Scanner.FollowSymlinks = true - }) - - DescribeTable("returns expected result", - func(dirName string, expected bool) { - dirEntry := getDirEntry("tests/fixtures", dirName) - Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(Equal(expected)) - }, - Entry("normal dir", "empty_folder", true), - Entry("symlink to dir", "symlink2dir", true), - Entry("regular file", "test.mp3", false), - Entry("symlink to file", "symlink", false), - ) - }) - - Context("with symlinks disabled", func() { - BeforeEach(func() { - conf.Server.Scanner.FollowSymlinks = false - }) - - DescribeTable("returns expected result", - func(dirName string, expected bool) { - dirEntry := getDirEntry("tests/fixtures", dirName) - Expect(isDirOrSymlinkToDir(fsys, baseDir, dirEntry)).To(Equal(expected)) - }, - Entry("normal dir", "empty_folder", true), - Entry("symlink to dir", "symlink2dir", false), - Entry("regular file", "test.mp3", false), - Entry("symlink to file", "symlink", false), - ) - }) + Describe("isDirOrSymlinkToDir", func() { + It("returns true for normal dirs", func() { + dirEntry := getDirEntry("tests", "fixtures") + Expect(isDirOrSymlinkToDir(fsys, ".", dirEntry)).To(BeTrue()) }) - - Describe("resolveEntryName", func() { - var fsys fs.FS - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - fsys = fstest.MapFS{ - "dir/real.mp3": {Data: []byte("AUDIO")}, - "dir/mid.mp3": {Mode: fs.ModeSymlink, Data: []byte("real.mp3")}, - "dir/chain.mp3": {Mode: fs.ModeSymlink, Data: []byte("mid.mp3")}, - "dir/audio.mp3": {Mode: fs.ModeSymlink, Data: []byte("real.mp3")}, - "dir/evil.mp3": {Mode: fs.ModeSymlink, Data: []byte("../outside/passwd")}, - "dir/loop1.mp3": {Mode: fs.ModeSymlink, Data: []byte("loop2.mp3")}, - "dir/loop2.mp3": {Mode: fs.ModeSymlink, Data: []byte("loop1.mp3")}, - "dir/dangle.mp3": {Mode: fs.ModeSymlink, Data: []byte("missing.mp3")}, - } - }) - - resolve := func(name string) (string, bool) { - entries, err := fs.ReadDir(fsys, "dir") - Expect(err).ToNot(HaveOccurred()) - for _, e := range entries { - if e.Name() == name { - return resolveEntryName(GinkgoT().Context(), fsys, "dir", e) - } - } - Fail("entry not found: " + name) - return "", false - } - - Context("with symlinks enabled", func() { - BeforeEach(func() { conf.Server.Scanner.FollowSymlinks = true }) - - It("returns the entry name for a plain file", func() { - name, ok := resolve("real.mp3") - Expect(ok).To(BeTrue()) - Expect(name).To(Equal("real.mp3")) - }) - It("resolves a direct symlink to its audio target name", func() { - name, ok := resolve("audio.mp3") - Expect(ok).To(BeTrue()) - Expect(name).To(Equal("real.mp3")) - }) - It("resolves a symlink CHAIN to the final target name", func() { - name, ok := resolve("chain.mp3") - Expect(ok).To(BeTrue()) - Expect(name).To(Equal("real.mp3")) - }) - It("resolves a symlink to a non-audio target name (so caller can reject it)", func() { - name, ok := resolve("evil.mp3") - Expect(ok).To(BeTrue()) - Expect(name).To(Equal("passwd")) - }) - It("rejects a symlink loop", func() { - _, ok := resolve("loop1.mp3") - Expect(ok).To(BeFalse()) - }) - }) - - Context("with symlinks disabled", func() { - BeforeEach(func() { conf.Server.Scanner.FollowSymlinks = false }) - - It("returns the entry name for a plain file", func() { - name, ok := resolve("real.mp3") - Expect(ok).To(BeTrue()) - Expect(name).To(Equal("real.mp3")) - }) - It("skips any file symlink", func() { - _, ok := resolve("audio.mp3") - Expect(ok).To(BeFalse()) - }) - }) + It("returns true for symlinks to dirs", func() { + dirEntry := getDirEntry(baseDir, "symlink2dir") + Expect(isDirOrSymlinkToDir(fsys, ".", dirEntry)).To(BeTrue()) }) - - Describe("symlink chain (real fs)", func() { - BeforeEach(func() { - tests.SkipOnWindows("symlink semantics") - DeferCleanup(configtest.SetupConfig()) - }) - - classify := func(fsys fs.FS, dirPath, name string) (string, bool) { - entries, err := fs.ReadDir(fsys, dirPath) - Expect(err).ToNot(HaveOccurred()) - for _, e := range entries { - if e.Name() == name { - return resolveEntryName(GinkgoT().Context(), fsys, dirPath, e) - } - } - Fail("entry not found: " + name) - return "", false - } - - Context("committed 3-level fixtures", func() { - // tests.Init chdirs to the repo root, so the committed fixtures are at "tests/fixtures". - var fsys fs.FS - BeforeEach(func() { - conf.Server.Scanner.FollowSymlinks = true - wd, err := os.Getwd() - Expect(err).ToNot(HaveOccurred()) - fsys = os.DirFS(wd) - }) - - It("keeps a 3-level chain that resolves to real audio", func() { - name, ok := classify(fsys, "tests/fixtures/symlink_chain", "level3.mp3") - Expect(ok).To(BeTrue()) - Expect(name).To(Equal("test.mp3")) - Expect(model.IsAudioFile(name)).To(BeTrue()) - }) - - It("rejects a 3-level chain that resolves to a non-audio file", func() { - name, ok := classify(fsys, "tests/fixtures/symlink_chain", "evil3.mp3") - Expect(ok).To(BeTrue()) - Expect(name).To(Equal("index.html")) - Expect(model.IsAudioFile(name)).To(BeFalse()) - }) - - It("skips the chain entirely when FollowSymlinks is disabled", func() { - conf.Server.Scanner.FollowSymlinks = false - _, ok := classify(fsys, "tests/fixtures/symlink_chain", "level3.mp3") - Expect(ok).To(BeFalse()) - _, ok = classify(fsys, "tests/fixtures/symlink_chain", "evil3.mp3") - Expect(ok).To(BeFalse()) - }) - }) - - // Regression for #5752: the production localFS must resolve file symlinks. - // fs.ReadLink-based resolution can't follow targets outside the library - // root, so full OS-level resolution is required. - Context("production local storage FS", func() { - var libRoot string - var musicFS storage.MusicFS - - BeforeEach(func() { - conf.Server.Scanner.FollowSymlinks = true - - // Reproduces the reported layout: a "pool" with the real files and a - // library containing only symlinks into the pool. - base := GinkgoT().TempDir() - pool := filepath.Join(base, "pool") - libRoot = filepath.Join(base, "userlib") - Expect(os.MkdirAll(pool, 0755)).To(Succeed()) - Expect(os.MkdirAll(libRoot, 0755)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(pool, "real.mp3"), []byte("AUDIO"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(pool, "secrets.txt"), []byte("TOPSECRET"), 0600)).To(Succeed()) - // mid.wav lives OUTSIDE the library and has an audio name, but points at a - // non-audio file. A chain through it must be classified by the FINAL target. - Expect(os.Symlink(filepath.Join(pool, "secrets.txt"), filepath.Join(pool, "mid.wav"))).To(Succeed()) - - Expect(os.Symlink("../pool/real.mp3", filepath.Join(libRoot, "relative.mp3"))).To(Succeed()) - Expect(os.Symlink(filepath.Join(pool, "real.mp3"), filepath.Join(libRoot, "absolute.mp3"))).To(Succeed()) - Expect(os.Symlink(filepath.Join(pool, "mid.wav"), filepath.Join(libRoot, "evil.wav"))).To(Succeed()) - Expect(os.Symlink(filepath.Join(pool, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).To(Succeed()) - - musicFS = newLocalMusicFS(libRoot) - }) - - walkRoot := func() *folderEntry { - job := &scanJob{fs: musicFS, lib: model.Library{Path: libRoot}} - results, err := walkDirTree(GinkgoT().Context(), job) - Expect(err).ToNot(HaveOccurred()) - var root *folderEntry - for folder := range results { - if folder.path == "." { - root = folder - } - } - Expect(root).ToNot(BeNil()) - return root - } - - It("imports symlinks to out-of-library audio files", func() { - root := walkRoot() - Expect(root.audioFiles).To(HaveKey("relative.mp3")) - Expect(root.audioFiles).To(HaveKey("absolute.mp3")) - }) - - It("rejects a chain that ends in a non-audio file, even through an audio-named intermediate", func() { - root := walkRoot() - Expect(root.audioFiles).ToNot(HaveKey("evil.wav")) - }) - - It("skips broken symlinks", func() { - root := walkRoot() - Expect(root.audioFiles).ToNot(HaveKey("broken.mp3")) - }) - - It("skips all file symlinks when FollowSymlinks is disabled", func() { - conf.Server.Scanner.FollowSymlinks = false - root := walkRoot() - Expect(root.audioFiles).To(BeEmpty()) - }) - }) - - Context("out-of-tree escape (temp dir)", func() { - var root string - BeforeEach(func() { - conf.Server.Scanner.FollowSymlinks = true - root = GinkgoT().TempDir() - outside := GinkgoT().TempDir() - Expect(os.WriteFile(filepath.Join(outside, "passwd"), []byte("TOPSECRET"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(outside, "real.flac"), []byte("AUDIO"), 0600)).To(Succeed()) - Expect(os.WriteFile(filepath.Join(root, "song.mp3"), []byte("AUDIO"), 0600)).To(Succeed()) - // evil.mp3 escapes to a non-audio target; legit.flac is a valid out-of-tree audio symlink. - Expect(os.Symlink(filepath.Join(outside, "passwd"), filepath.Join(root, "evil.mp3"))).To(Succeed()) - Expect(os.Symlink(filepath.Join(outside, "real.flac"), filepath.Join(root, "legit.flac"))).To(Succeed()) - }) - - It("rejects the absolute-path escape but keeps legit out-of-tree audio", func() { - fsys := os.DirFS(root) - - name, ok := classify(fsys, ".", "song.mp3") - Expect(ok).To(BeTrue()) - Expect(model.IsAudioFile(name)).To(BeTrue()) - - name, ok = classify(fsys, ".", "legit.flac") - Expect(ok).To(BeTrue()) - Expect(model.IsAudioFile(name)).To(BeTrue()) - - name, ok = classify(fsys, ".", "evil.mp3") - Expect(ok).To(BeTrue()) - Expect(model.IsAudioFile(name)).To(BeFalse()) - }) - - It("skips all file symlinks when FollowSymlinks is disabled", func() { - conf.Server.Scanner.FollowSymlinks = false - fsys := os.DirFS(root) - entries, err := fs.ReadDir(fsys, ".") - Expect(err).ToNot(HaveOccurred()) - for _, e := range entries { - _, ok := resolveEntryName(GinkgoT().Context(), fsys, ".", e) - if e.Type()&fs.ModeSymlink != 0 { - Expect(ok).To(BeFalse(), e.Name()) - } else { - Expect(ok).To(BeTrue(), e.Name()) - } - } - }) - }) + It("returns false for files", func() { + dirEntry := getDirEntry(baseDir, "test.mp3") + Expect(isDirOrSymlinkToDir(fsys, ".", dirEntry)).To(BeFalse()) }) - - Describe("isDirIgnored", func() { - DescribeTable("returns expected result", - func(dirName string, expected bool) { - Expect(isDirIgnored(dirName)).To(Equal(expected)) - }, - Entry("normal dir", "empty_folder", false), - Entry("dot-prefixed album dir", ".Hack Sign Original Soundtrack", false), - Entry("git dir", ".git", true), - Entry("streams dir", ".streams", true), - Entry("dir starting with ellipsis", "...unhidden_folder", false), - Entry("recycle bin", "$Recycle.Bin", true), - Entry("snapshot dir", "#snapshot", true), - Entry("synology metadata dir", "@eaDir", true), - ) + It("returns false for symlinks to files", func() { + dirEntry := getDirEntry(baseDir, "symlink") + Expect(isDirOrSymlinkToDir(fsys, ".", dirEntry)).To(BeFalse()) }) - - Describe("isIgnoredEntry", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - DescribeTable("with IgnoreDotFolders enabled (default)", - func(name string, isDir, expected bool) { - conf.Server.Scanner.IgnoreDotFolders = true - Expect(isIgnoredEntry(name, isDir)).To(Equal(expected)) - }, - Entry("normal dir", "Album", true, false), - Entry("normal file", "track.mp3", false, false), - Entry("dot folder", ".Hack Sign Original Soundtrack", true, true), - Entry("dot file", ".hidden.mp3", false, true), - Entry("blocklisted dir", ".git", true, true), - Entry("ellipsis dir", "...unhidden", true, false), - ) - - DescribeTable("with IgnoreDotFolders disabled", - func(name string, isDir, expected bool) { - conf.Server.Scanner.IgnoreDotFolders = false - Expect(isIgnoredEntry(name, isDir)).To(Equal(expected)) - }, - Entry("normal dir", "Album", true, false), - Entry("normal file", "track.mp3", false, false), - Entry("dot folder is allowed", ".Hack Sign Original Soundtrack", true, false), - Entry("dot file is still ignored", ".hidden.mp3", false, true), - Entry("blocklisted dir still ignored", ".git", true, true), - ) + }) + Describe("isDirIgnored", func() { + It("returns false for normal dirs", func() { + dirEntry := getDirEntry(baseDir, "empty_folder") + Expect(isDirIgnored(fsys, ".", dirEntry)).To(BeFalse()) }) - - Describe("isDotEntry", func() { - DescribeTable("returns expected result", - func(name string, expected bool) { - Expect(isDotEntry(name)).To(Equal(expected)) - }, - Entry("dot folder", ".Hidden", true), - Entry("dot file", ".hidden.mp3", true), - Entry("current dir", ".", false), - Entry("parent dir", "..", false), - Entry("two leading dots", "..foo", false), - Entry("ellipsis", "...unhidden", false), - Entry("normal name", "Album", false), - ) + It("returns true when folder contains .ndignore file", func() { + dirEntry := getDirEntry(baseDir, "ignored_folder") + Expect(isDirIgnored(fsys, ".", dirEntry)).To(BeTrue()) }) + It("returns true when folder name starts with a `.`", func() { + dirEntry := getDirEntry(baseDir, ".hidden_folder") + Expect(isDirIgnored(fsys, ".", dirEntry)).To(BeTrue()) + }) + It("returns false when folder name starts with ellipses", func() { + dirEntry := getDirEntry(baseDir, "...unhidden_folder") + Expect(isDirIgnored(fsys, ".", dirEntry)).To(BeFalse()) + }) + It("returns false when folder name is $Recycle.Bin", func() { + dirEntry := getDirEntry(baseDir, "$Recycle.Bin") + Expect(isDirIgnored(fsys, ".", dirEntry)).To(BeFalse()) + }) + }) - Describe("fullReadDir", func() { - var ( - fsys fakeFS - ctx context.Context - ) - - BeforeEach(func() { - ctx = GinkgoT().Context() - fsys = fakeFS{MapFS: fstest.MapFS{ - "root/a/f1": {}, - "root/b/f2": {}, - "root/c/f3": {}, - }} - }) - - DescribeTable("reading directory entries", - func(failOn string, expectedErr error, expectedNames []string) { - fsys.failOn = failOn - fsys.err = expectedErr - dir, _ := fsys.Open("root") - entries := fullReadDir(ctx, dir.(fs.ReadDirFile)) - Expect(entries).To(HaveLen(len(expectedNames))) - for i, name := range expectedNames { - Expect(entries[i].Name()).To(Equal(name)) - } - }, - Entry("reads all entries", "", nil, []string{"a", "b", "c"}), - Entry("skips entries with permission error", "b", nil, []string{"a", "c"}), - Entry("aborts on fs.ErrNotExist", "", fs.ErrNotExist, []string{}), - ) + Describe("fullReadDir", func() { + var fsys fakeFS + var ctx context.Context + BeforeEach(func() { + ctx = context.Background() + fsys = fakeFS{MapFS: fstest.MapFS{ + "root/a/f1": {}, + "root/b/f2": {}, + "root/c/f3": {}, + }} + }) + It("reads all entries", func() { + dir, _ := fsys.Open("root") + entries := fullReadDir(ctx, dir.(fs.ReadDirFile)) + Expect(entries).To(HaveLen(3)) + Expect(entries[0].Name()).To(Equal("a")) + Expect(entries[1].Name()).To(Equal("b")) + Expect(entries[2].Name()).To(Equal("c")) + }) + It("skips entries with permission error", func() { + fsys.failOn = "b" + dir, _ := fsys.Open("root") + entries := fullReadDir(ctx, dir.(fs.ReadDirFile)) + Expect(entries).To(HaveLen(2)) + Expect(entries[0].Name()).To(Equal("a")) + Expect(entries[1].Name()).To(Equal("c")) + }) + It("aborts if it keeps getting 'readdirent: no such file or directory'", func() { + fsys.err = fs.ErrNotExist + dir, _ := fsys.Open("root") + entries := fullReadDir(ctx, dir.(fs.ReadDirFile)) + Expect(entries).To(BeEmpty()) }) }) }) @@ -703,7 +145,7 @@ type fakeDirFile struct { } // Only works with n == -1 -func (fd *fakeDirFile) ReadDir(int) ([]fs.DirEntry, error) { +func (fd *fakeDirFile) ReadDir(n int) ([]fs.DirEntry, error) { if fd.err != nil { return nil, fd.err } @@ -734,93 +176,3 @@ func getDirEntry(baseDir, name string) os.DirEntry { } panic(fmt.Sprintf("Could not find %s in %s", name, baseDir)) } - -// newLocalMusicFS returns the production local storage MusicFS rooted at libRoot -func newLocalMusicFS(libRoot string) storage.MusicFS { - u, err := storage.LocalPathToURL(libRoot) - Expect(err).ToNot(HaveOccurred()) - s, err := storage.For(u.String()) - Expect(err).ToNot(HaveOccurred()) - musicFS, err := s.FS() - Expect(err).ToNot(HaveOccurred()) - return musicFS -} - -// mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks -type mockMusicFS struct { - storage.MusicFS - fs.FS -} - -// Open resolves symlinks -func (m *mockMusicFS) Open(name string) (fs.File, error) { - f, err := m.FS.Open(name) - if err != nil { - return nil, err - } - - info, err := f.Stat() - if err != nil { - f.Close() - return nil, err - } - - if info.Mode()&fs.ModeSymlink != 0 { - // For symlinks, read the target path from the Data field - target := string(m.FS.(fstest.MapFS)[name].Data) - f.Close() - return m.FS.Open(target) - } - - return f, nil -} - -// Stat uses Open to resolve symlinks -func (m *mockMusicFS) Stat(name string) (fs.FileInfo, error) { - f, err := m.Open(name) - if err != nil { - return nil, err - } - defer f.Close() - return f.Stat() -} - -// ReadDir uses Open to resolve symlinks -func (m *mockMusicFS) ReadDir(name string) ([]fs.DirEntry, error) { - f, err := m.Open(name) - if err != nil { - return nil, err - } - defer f.Close() - if dirFile, ok := f.(fs.ReadDirFile); ok { - return dirFile.ReadDir(-1) - } - return nil, fmt.Errorf("not a directory") -} - -// ReadLink returns the target of the named symbolic link (implements fs.ReadLinkFS). -func (m *mockMusicFS) ReadLink(name string) (string, error) { - mapFS := m.FS.(fstest.MapFS) - entry, ok := mapFS[name] - if !ok { - return "", &fs.PathError{Op: "readlink", Path: name, Err: fs.ErrNotExist} - } - if entry.Mode&fs.ModeSymlink == 0 { - return "", &fs.PathError{Op: "readlink", Path: name, Err: fmt.Errorf("not a symlink")} - } - return string(entry.Data), nil -} - -// Lstat returns FileInfo for the named file without following symlinks (implements fs.ReadLinkFS). -func (m *mockMusicFS) Lstat(name string) (fs.FileInfo, error) { - mapFS := m.FS.(fstest.MapFS) - if _, ok := mapFS[name]; !ok { - return nil, &fs.PathError{Op: "lstat", Path: name, Err: fs.ErrNotExist} - } - f, err := m.FS.Open(name) - if err != nil { - return nil, err - } - defer f.Close() - return f.Stat() -} diff --git a/scanner/walk_dir_tree_windows_test.go b/scanner/walk_dir_tree_windows_test.go new file mode 100644 index 000000000..ef88779c9 --- /dev/null +++ b/scanner/walk_dir_tree_windows_test.go @@ -0,0 +1,35 @@ +package scanner + +import ( + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("walk_dir_tree_windows", func() { + baseDir := filepath.Join("tests", "fixtures") + + Describe("isDirIgnored", func() { + It("returns false for normal dirs", func() { + dirEntry, _ := getDirEntry(baseDir, "empty_folder") + Expect(isDirIgnored(baseDir, dirEntry)).To(BeFalse()) + }) + It("returns true when folder contains .ndignore file", func() { + dirEntry, _ := getDirEntry(baseDir, "ignored_folder") + Expect(isDirIgnored(baseDir, dirEntry)).To(BeTrue()) + }) + It("returns true when folder name starts with a `.`", func() { + dirEntry, _ := getDirEntry(baseDir, ".hidden_folder") + Expect(isDirIgnored(baseDir, dirEntry)).To(BeTrue()) + }) + It("returns false when folder name starts with ellipses", func() { + dirEntry, _ := getDirEntry(baseDir, "...unhidden_folder") + Expect(isDirIgnored(baseDir, dirEntry)).To(BeFalse()) + }) + It("returns true when folder name is $Recycle.Bin", func() { + dirEntry, _ := getDirEntry(baseDir, "$Recycle.Bin") + Expect(isDirIgnored(baseDir, dirEntry)).To(BeTrue()) + }) + }) +}) diff --git a/scanner/watcher.go b/scanner/watcher.go deleted file mode 100644 index 1ac5468f0..000000000 --- a/scanner/watcher.go +++ /dev/null @@ -1,355 +0,0 @@ -package scanner - -import ( - "context" - "fmt" - "io/fs" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/storage" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/singleton" -) - -type Watcher interface { - Run(ctx context.Context) error - Watch(ctx context.Context, lib *model.Library) error - StopWatching(ctx context.Context, libraryID int) error -} - -type watcher struct { - mainCtx context.Context //nolint:containedctx // watcher lifecycle ctx - ds model.DataStore - scanner model.Scanner - triggerWait time.Duration - watcherNotify chan scanNotification - libraryWatchers map[int]*libraryWatcherInstance - mu sync.RWMutex -} - -type libraryWatcherInstance struct { - library *model.Library - cancel context.CancelFunc -} - -type scanNotification struct { - Library *model.Library - FolderPath string -} - -// GetWatcher returns the watcher singleton -func GetWatcher(ds model.DataStore, s model.Scanner) Watcher { - return singleton.GetInstance(func() *watcher { - return &watcher{ - ds: ds, - scanner: s, - triggerWait: conf.Server.Scanner.WatcherWait, - watcherNotify: make(chan scanNotification, 500), - libraryWatchers: make(map[int]*libraryWatcherInstance), - } - }) -} - -func (w *watcher) Run(ctx context.Context) error { - // Keep the main context to be used in all watchers added later - w.mainCtx = ctx - - // Start watchers for all existing libraries - libs, err := w.ds.Library().GetAll(ctx) - if err != nil { - return fmt.Errorf("getting libraries: %w", err) - } - - for _, lib := range libs { - if err := w.Watch(ctx, &lib); err != nil { - log.Warn(ctx, "Failed to start watcher for existing library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) - } - } - - // Main scan triggering loop - trigger := time.NewTimer(w.triggerWait) - trigger.Stop() - targets := make(map[model.ScanTarget]struct{}) - for { - select { - case <-trigger.C: - log.Info("Watcher: Triggering scan for changed folders", "numTargets", len(targets)) - status, err := w.scanner.Status(ctx) - if err != nil { - log.Error(ctx, "Watcher: Error retrieving Scanner status", err) - break - } - if status.Scanning { - log.Debug(ctx, "Watcher: Already scanning, will retry later", "waitTime", w.triggerWait*3) - trigger.Reset(w.triggerWait * 3) - continue - } - - // Convert targets map to slice - targetSlice := make([]model.ScanTarget, 0, len(targets)) - for target := range targets { - targetSlice = append(targetSlice, target) - } - - // Clear targets for next batch - targets = make(map[model.ScanTarget]struct{}) - - go func() { - var err error - if conf.Server.DevSelectiveWatcher { - _, err = w.scanner.ScanFolders(ctx, false, targetSlice) - } else { - _, err = w.scanner.ScanAll(ctx, false) - } - if err != nil { - log.Error(ctx, "Watcher: Error scanning", err) - } else { - log.Info(ctx, "Watcher: Scan completed") - } - }() - case <-ctx.Done(): - // Stop all library watchers - w.mu.Lock() - for libraryID, instance := range w.libraryWatchers { - log.Debug(ctx, "Stopping library watcher due to context cancellation", "libraryID", libraryID) - instance.cancel() - } - w.libraryWatchers = make(map[int]*libraryWatcherInstance) - w.mu.Unlock() - return nil - case notification := <-w.watcherNotify: - // Reset the trigger timer for debounce - trigger.Reset(w.triggerWait) - - lib := notification.Library - folderPath := notification.FolderPath - - // If already scheduled for scan, skip - target := model.ScanTarget{LibraryID: lib.ID, FolderPath: folderPath} - if _, exists := targets[target]; exists { - continue - } - targets[target] = struct{}{} - - log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan", - "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "folderPath", folderPath) - } - } -} - -func (w *watcher) Watch(ctx context.Context, lib *model.Library) error { - w.mu.Lock() - defer w.mu.Unlock() - - // If Run() hasn't been called yet, mainCtx will be nil - skip watching - if w.mainCtx == nil { - log.Debug(ctx, "Watcher not started yet, skipping watch for library", "libraryID", lib.ID, "name", lib.Name) - return nil - } - - // Stop existing watcher if any - if existingInstance, exists := w.libraryWatchers[lib.ID]; exists { - log.Debug(ctx, "Stopping existing watcher before starting new one", "libraryID", lib.ID, "name", lib.Name) - existingInstance.cancel() - } - - // Start new watcher - watcherCtx, cancel := context.WithCancel(w.mainCtx) //nolint:gosec // cancel is stored in instance and called on shutdown - instance := &libraryWatcherInstance{ - library: lib, - cancel: cancel, - } - - w.libraryWatchers[lib.ID] = instance - - // Start watching in a goroutine - go func() { - defer func() { - w.mu.Lock() - if currentInstance, exists := w.libraryWatchers[lib.ID]; exists && currentInstance == instance { - delete(w.libraryWatchers, lib.ID) - } - w.mu.Unlock() - }() - - err := w.watchLibrary(watcherCtx, lib) - if err != nil && watcherCtx.Err() == nil { // Only log error if not due to cancellation - log.Error(ctx, "Watcher error", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) - } - }() - - log.Info(ctx, "Started watcher for library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) - return nil -} - -func (w *watcher) StopWatching(ctx context.Context, libraryID int) error { - w.mu.Lock() - defer w.mu.Unlock() - - instance, exists := w.libraryWatchers[libraryID] - if !exists { - log.Debug(ctx, "No watcher found to stop", "libraryID", libraryID) - return nil - } - - instance.cancel() - delete(w.libraryWatchers, libraryID) - - log.Info(ctx, "Stopped watcher for library", "libraryID", libraryID, "name", instance.library.Name) - return nil -} - -// watchLibrary implements the core watching logic for a single library (extracted from old watchLib function) -func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error { - s, err := storage.For(lib.Path) - if err != nil { - return fmt.Errorf("creating storage: %w", err) - } - - fsys, err := s.FS() - if err != nil { - return fmt.Errorf("getting FS: %w", err) - } - - watcher, ok := s.(storage.Watcher) - if !ok { - log.Info(ctx, "Watcher not supported for storage type", "libraryID", lib.ID, "path", lib.Path) - return nil - } - - c, err := watcher.Start(ctx) - if err != nil { - return fmt.Errorf("starting watcher: %w", err) - } - - absLibPath, err := filepath.Abs(lib.Path) - if err != nil { - return fmt.Errorf("converting to absolute path: %w", err) - } - - log.Info(ctx, "Watcher started for library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "absoluteLibPath", absLibPath) - - return w.processLibraryEvents(ctx, lib, fsys, c, absLibPath) -} - -// processLibraryEvents processes filesystem events for a library. -func (w *watcher) processLibraryEvents(ctx context.Context, lib *model.Library, fsys storage.MusicFS, events <-chan string, absLibPath string) error { - for { - select { - case <-ctx.Done(): - log.Debug(ctx, "Watcher stopped due to context cancellation", "libraryID", lib.ID, "name", lib.Name) - return nil - case path := <-events: - path, err := filepath.Rel(absLibPath, path) - if err != nil { - log.Error(ctx, "Error getting relative path", "libraryID", lib.ID, "absolutePath", absLibPath, "path", path, err) - continue - } - - if isIgnoredPath(ctx, fsys, path) { - log.Trace(ctx, "Ignoring change", "libraryID", lib.ID, "path", path) - continue - } - log.Trace(ctx, "Detected change", "libraryID", lib.ID, "path", path, "absoluteLibPath", absLibPath) - - // Check if the original path (before resolution) matches .ndignore patterns - // This is crucial for deleted folders - if a deleted folder matches .ndignore, - // we should ignore it BEFORE resolveFolderPath walks up to the parent - if w.shouldIgnoreFolderPath(ctx, fsys, path) { - log.Debug(ctx, "Ignoring change matching .ndignore pattern", "libraryID", lib.ID, "path", path) - continue - } - - // Find the folder to scan - validate path exists as directory, walk up if needed - folderPath := resolveFolderPath(fsys, path) - // Double-check after resolution in case the resolved path is different and also matches patterns - if folderPath != path && w.shouldIgnoreFolderPath(ctx, fsys, folderPath) { - log.Trace(ctx, "Ignoring change in folder matching .ndignore pattern", "libraryID", lib.ID, "folderPath", folderPath) - continue - } - - // Notify the main watcher of changes. This will trigger a scan after the debounce period. - w.watcherNotify <- scanNotification{Library: lib, FolderPath: folderPath} - } - } -} - -// resolveFolderPath takes a path (which may be a file or directory) and returns -// the folder path to scan. If the path is a file, it walks up to find the parent -// directory. Returns empty string if the path should scan the library root. -func resolveFolderPath(fsys fs.FS, path string) string { - // Handle root paths immediately - if path == "." || path == "" { - return "" - } - - folderPath := path - for { - info, err := fs.Stat(fsys, folderPath) - if err == nil && info.IsDir() { - // Found a valid directory - return folderPath - } - if folderPath == "." || folderPath == "" { - // Reached root, scan entire library - return "" - } - // Walk up the tree - dir, _ := filepath.Split(folderPath) - if dir == "" || dir == "." { - return "" - } - // Remove trailing slash - folderPath = filepath.Clean(dir) - } -} - -// shouldIgnoreFolderPath checks if the given folderPath should be ignored based on .ndignore patterns -// in the library. It pushes all parent folders onto the IgnoreChecker stack before checking. -func (w *watcher) shouldIgnoreFolderPath(ctx context.Context, fsys storage.MusicFS, folderPath string) bool { - checker := newIgnoreChecker(fsys) - err := checker.PushAllParents(ctx, folderPath) - if err != nil { - log.Warn(ctx, "Watcher: Error pushing ignore patterns for folder", "path", folderPath, err) - } - return checker.ShouldIgnore(ctx, folderPath) -} - -func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool { - _, name := filepath.Split(path) - // A change anywhere inside an ignored directory (a dot-folder when - // Scanner.IgnoreDotFolders is enabled, or a special system folder) must not - // trigger a scan, even for media files: the scan would skip it anyway. - if isUnderIgnoredDir(path) { - return true - } - switch { - case model.IsAudioFile(path), model.IsValidPlaylist(path), model.IsImageFile(path): - // A media file is normally not ignored, but a dot-prefixed one (e.g. - // ".hidden.mp3") is always skipped by the scanner, so don't scan for it. - return isDotEntry(name) - case name == ".DS_Store": - return true - } - // As it can be a deletion and not a change, we cannot reliably know if the - // path is a file or directory. But at this point, we can assume it's a - // directory. If it's a file, it would be ignored anyway. - return isIgnoredEntry(name, true) -} - -// isUnderIgnoredDir returns true if any parent directory component of the given -// path is an ignored directory, reusing the same policy as the scanner walk. -func isUnderIgnoredDir(path string) bool { - dir, _ := filepath.Split(path) - for part := range strings.SplitSeq(filepath.ToSlash(dir), "/") { - if part != "" && isIgnoredEntry(part, true) { - return true - } - } - return false -} diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go deleted file mode 100644 index 12c94602c..000000000 --- a/scanner/watcher_test.go +++ /dev/null @@ -1,543 +0,0 @@ -package scanner - -import ( - "context" - "io/fs" - "path/filepath" - "testing/fstest" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "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("Watcher", func() { - var ctx context.Context - var cancel context.CancelFunc - var mockScanner *tests.MockScanner - var mockDS *tests.MockDataStore - var w *watcher - var lib *model.Library - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - // Must dwarf the 20ms Consistently windows below, or a loaded runner's delayed timer reset flakes the debouncing spec. - conf.Server.Scanner.WatcherWait = 200 * time.Millisecond - - ctx, cancel = context.WithCancel(GinkgoT().Context()) - DeferCleanup(cancel) - - // Use a fake storage scheme: watchLibrary goroutines spawned by Run/Watch are not - // joined on spec teardown, and the real file:// storage reads conf.Server on - // construction, racing with the configtest cleanup that restores the config. - storagetest.Register("fake-watcher", &storagetest.FakeFS{}) - lib = &model.Library{ - ID: 1, - Name: "Test Library", - Path: "fake-watcher:///test/library", - } - - // Set up mocks - mockScanner = tests.NewMockScanner() - mockDS = &tests.MockDataStore{} - mockLibRepo := &tests.MockLibraryRepo{} - mockLibRepo.SetData(model.Libraries{*lib}) - mockDS.MockedLibrary = mockLibRepo - - // Create a new watcher instance (not singleton) for testing - w = &watcher{ - ds: mockDS, - scanner: mockScanner, - triggerWait: conf.Server.Scanner.WatcherWait, - watcherNotify: make(chan scanNotification, 10), - libraryWatchers: make(map[int]*libraryWatcherInstance), - mainCtx: ctx, - } - }) - - Describe("Watch before Run", func() { - It("returns nil and does not panic when mainCtx is nil", func() { - w.mainCtx = nil - err := w.Watch(ctx, lib) - Expect(err).ToNot(HaveOccurred()) - Expect(w.libraryWatchers).To(BeEmpty()) - }) - }) - - Describe("Target Collection and Deduplication", func() { - BeforeEach(func() { - // Start watcher in background - go func() { - _ = w.Run(ctx) - }() - - // Give watcher time to initialize - time.Sleep(10 * time.Millisecond) - }) - - It("creates separate targets for different folders", FlakeAttempts(3), func() { - // Send notifications for different folders - w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist2"} - - // 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 - }, 2*time.Second, 10*time.Millisecond).Should(HaveLen(2)) - - // Verify targets - calls := mockScanner.GetScanFoldersCalls() - folderPaths := make(map[string]bool) - for _, target := range calls[0].Targets { - Expect(target.LibraryID).To(Equal(1)) - folderPaths[target.FolderPath] = true - } - Expect(folderPaths).To(HaveKey("artist1")) - Expect(folderPaths).To(HaveKey("artist2")) - }) - - It("handles different folder paths correctly", func() { - // Send notification for nested folder - w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} - - // Wait for watcher to process and trigger scan - Eventually(func() int { - return mockScanner.GetScanFoldersCallCount() - }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) - - // Verify the target - calls := mockScanner.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].Targets).To(HaveLen(1)) - Expect(calls[0].Targets[0].FolderPath).To(Equal("artist1/album1")) - }) - - It("deduplicates folder and file within same folder", func() { - // Send multiple notifications for the same folder - w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} - w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} - w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} - - // Wait for watcher to process and trigger scan - Eventually(func() int { - return mockScanner.GetScanFoldersCallCount() - }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) - - // Verify only one target despite multiple file/folder changes - calls := mockScanner.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].Targets).To(HaveLen(1)) - Expect(calls[0].Targets[0].FolderPath).To(Equal("artist1/album1")) - }) - }) - - Describe("Timer Behavior", func() { - BeforeEach(func() { - // Start watcher in background - go func() { - _ = w.Run(ctx) - }() - - // Give watcher time to initialize - time.Sleep(10 * time.Millisecond) - }) - - It("resets timer on each change (debouncing)", FlakeAttempts(3), func() { - // Send first notification - w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - - // 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"} - - // Again, no scan should fire within a short window - Consistently(func() int { - return mockScanner.GetScanFoldersCallCount() - }, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0)) - - // Now wait for the debounce timer to expire and trigger scan - Eventually(func() int { - return mockScanner.GetScanFoldersCallCount() - }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) - }) - - It("triggers scan after quiet period", func() { - // Send notification - w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - - // No scan immediately - Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0)) - - // Wait for quiet period - Eventually(func() int { - return mockScanner.GetScanFoldersCallCount() - }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) - }) - }) - - Describe("Empty and Root Paths", func() { - BeforeEach(func() { - // Start watcher in background - go func() { - _ = w.Run(ctx) - }() - - // Give watcher time to initialize - time.Sleep(10 * time.Millisecond) - }) - - It("handles empty folder path (library root)", func() { - // Send notification with empty folder path - w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""} - - // Wait for scan - Eventually(func() int { - return mockScanner.GetScanFoldersCallCount() - }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) - - // Should scan the library root - calls := mockScanner.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].Targets).To(HaveLen(1)) - Expect(calls[0].Targets[0].FolderPath).To(Equal("")) - }) - - It("deduplicates empty and dot paths", func() { - // Send notifications with empty and dot paths - w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""} - w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""} - - // Wait for scan - Eventually(func() int { - return mockScanner.GetScanFoldersCallCount() - }, 2*time.Second, 10*time.Millisecond).Should(Equal(1)) - - // Should have only one target - calls := mockScanner.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].Targets).To(HaveLen(1)) - }) - }) - - Describe("Multiple Libraries", func() { - var lib2 *model.Library - - BeforeEach(func() { - // Create second library - lib2 = &model.Library{ - ID: 2, - Name: "Test Library 2", - Path: "fake-watcher:///test/library2", - } - - mockLibRepo := mockDS.MockedLibrary.(*tests.MockLibraryRepo) - mockLibRepo.SetData(model.Libraries{*lib, *lib2}) - - // Start watcher in background - go func() { - _ = w.Run(ctx) - }() - - // Give watcher time to initialize - time.Sleep(10 * time.Millisecond) - }) - - It("creates separate targets for different libraries", func() { - // Send notifications for both libraries - w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - w.watcherNotify <- scanNotification{Library: lib2, FolderPath: "artist2"} - - // 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 - }, 2*time.Second, 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 - } - Expect(libraryIDs).To(HaveKey(1)) - Expect(libraryIDs).To(HaveKey(2)) - }) - }) - - Describe(".ndignore handling", func() { - var ctx context.Context - var cancel context.CancelFunc - var w *watcher - var mockFS *mockMusicFS - var lib *model.Library - var eventChan chan string - var absLibPath string - - BeforeEach(func() { - ctx, cancel = context.WithCancel(GinkgoT().Context()) - DeferCleanup(cancel) - - // Set up library - var err error - absLibPath, err = filepath.Abs(".") - Expect(err).NotTo(HaveOccurred()) - - lib = &model.Library{ - ID: 1, - Name: "Test Library", - Path: absLibPath, - } - - // Create watcher with notification channel - w = &watcher{ - watcherNotify: make(chan scanNotification, 10), - } - - eventChan = make(chan string, 10) - }) - - // Helper to send an event - converts relative path to absolute - sendEvent := func(relativePath string) { - path := filepath.Join(absLibPath, relativePath) - eventChan <- path - } - - // Helper to start the real event processing loop - startEventProcessing := func() { - go func() { - defer GinkgoRecover() - // Call the actual processLibraryEvents method - testing the real implementation! - _ = w.processLibraryEvents(ctx, lib, mockFS, eventChan, absLibPath) - }() - } - - Context("when a folder matching .ndignore is deleted", func() { - BeforeEach(func() { - // Create filesystem with .ndignore containing _TEMP pattern - // The deleted folder (_TEMP) will NOT exist in the filesystem - mockFS = &mockMusicFS{ - FS: fstest.MapFS{ - "rock": &fstest.MapFile{Mode: fs.ModeDir}, - "rock/.ndignore": &fstest.MapFile{Data: []byte("_TEMP\n")}, - "rock/valid_album": &fstest.MapFile{Mode: fs.ModeDir}, - "rock/valid_album/track.mp3": &fstest.MapFile{Data: []byte("audio")}, - }, - } - }) - - It("should NOT send scan notification when deleted folder matches .ndignore", func() { - startEventProcessing() - - // Simulate deletion event for rock/_TEMP - sendEvent("rock/_TEMP") - - // Wait a bit to ensure event is processed - time.Sleep(50 * time.Millisecond) - - // No notification should have been sent - Consistently(eventChan, 100*time.Millisecond).Should(BeEmpty()) - }) - - It("should send scan notification for valid folder deletion", func() { - startEventProcessing() - - // Simulate deletion event for rock/other_folder (not in .ndignore and doesn't exist) - // Since it doesn't exist in mockFS, resolveFolderPath will walk up to "rock" - sendEvent("rock/other_folder") - - // Should receive notification for parent folder - Eventually(w.watcherNotify, 200*time.Millisecond).Should(Receive(Equal(scanNotification{ - Library: lib, - FolderPath: "rock", - }))) - }) - }) - - Context("with nested folder patterns", func() { - BeforeEach(func() { - mockFS = &mockMusicFS{ - FS: fstest.MapFS{ - "music": &fstest.MapFile{Mode: fs.ModeDir}, - "music/.ndignore": &fstest.MapFile{Data: []byte("**/temp\n**/cache\n")}, - "music/rock": &fstest.MapFile{Mode: fs.ModeDir}, - "music/rock/artist": &fstest.MapFile{Mode: fs.ModeDir}, - }, - } - }) - - 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) - sendEvent("music/rock/artist/temp") - - // Wait to ensure event is processed - time.Sleep(50 * time.Millisecond) - - // No notification should be sent - Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for nested ignored folder") - }) - - 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) - sendEvent("music/rock/artist") - - // Should receive notification - Eventually(w.watcherNotify, 200*time.Millisecond).Should(Receive(Equal(scanNotification{ - Library: lib, - FolderPath: "music/rock/artist", - }))) - }) - }) - - Context("with file events in ignored folders", func() { - BeforeEach(func() { - mockFS = &mockMusicFS{ - FS: fstest.MapFS{ - "rock": &fstest.MapFile{Mode: fs.ModeDir}, - "rock/.ndignore": &fstest.MapFile{Data: []byte("_TEMP\n")}, - }, - } - }) - - 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 - sendEvent("rock/_TEMP/file.mp3") - - // Wait to ensure event is processed - time.Sleep(50 * time.Millisecond) - - // No notification should be sent - Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for file in ignored folder") - }) - }) - - }) -}) - -var _ = Describe("isIgnoredPath", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - Context("with IgnoreDotFolders enabled (default)", func() { - BeforeEach(func() { - conf.Server.Scanner.IgnoreDotFolders = true - }) - - DescribeTable("returns expected result", - func(p string, expected bool) { - Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected)) - }, - Entry("media file in normal folder", "rock/Album/track.mp3", false), - Entry("dot-prefixed media file", "rock/Album/.hidden.mp3", true), - Entry("media file inside a dot-folder", "rock/.Hidden Album/track.mp3", true), - Entry("media file inside a blocklisted folder", "rock/.streams/stream.mp3", true), - Entry("media file inside .git", "rock/.git/track.mp3", true), - Entry("dot-folder itself", "rock/.Hidden Album", true), - Entry("normal folder itself", "rock/Album", false), - Entry(".DS_Store file", "rock/Album/.DS_Store", true), - ) - }) - - Context("with IgnoreDotFolders disabled", func() { - BeforeEach(func() { - conf.Server.Scanner.IgnoreDotFolders = false - }) - - DescribeTable("returns expected result", - func(p string, expected bool) { - Expect(isIgnoredPath(context.Background(), nil, filepath.FromSlash(p))).To(Equal(expected)) - }, - Entry("media file inside a dot-folder is allowed", "rock/.Hidden Album/track.mp3", false), - Entry("dot-prefixed media file is still ignored", "rock/Album/.hidden.mp3", true), - Entry("dot-folder itself is allowed", "rock/.Hidden Album", false), - Entry("blocklisted folder still ignored", "rock/.streams/stream.mp3", true), - Entry(".git still ignored", "rock/.git/config", true), - ) - }) -}) - -var _ = Describe("resolveFolderPath", func() { - var mockFS fs.FS - - BeforeEach(func() { - // Create a mock filesystem with some directories and files - mockFS = fstest.MapFS{ - "artist1": &fstest.MapFile{Mode: fs.ModeDir}, - "artist1/album1": &fstest.MapFile{Mode: fs.ModeDir}, - "artist1/album1/track1.mp3": &fstest.MapFile{Data: []byte("audio")}, - "artist1/album1/track2.mp3": &fstest.MapFile{Data: []byte("audio")}, - "artist1/album2": &fstest.MapFile{Mode: fs.ModeDir}, - "artist1/album2/song.flac": &fstest.MapFile{Data: []byte("audio")}, - "artist2": &fstest.MapFile{Mode: fs.ModeDir}, - "artist2/cover.jpg": &fstest.MapFile{Data: []byte("image")}, - } - }) - - It("returns directory path when given a directory", func() { - result := resolveFolderPath(mockFS, "artist1/album1") - Expect(result).To(Equal("artist1/album1")) - }) - - 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")) - }) - - It("returns empty string for non-existent paths at root", func() { - result := resolveFolderPath(mockFS, "nonexistent/path/file.mp3") - Expect(result).To(Equal("")) - }) - - It("returns empty string for dot path", func() { - result := resolveFolderPath(mockFS, ".") - Expect(result).To(Equal("")) - }) - - It("returns empty string for empty path", func() { - result := resolveFolderPath(mockFS, "") - Expect(result).To(Equal("")) - }) - - 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")) - }) - - It("resolves to top-level directory", func() { - result := resolveFolderPath(mockFS, "artist2/cover.jpg") - Expect(result).To(Equal("artist2")) - }) -}) diff --git a/scheduler/crontab_schedule.go b/scheduler/crontab_schedule.go deleted file mode 100644 index 5de1ae145..000000000 --- a/scheduler/crontab_schedule.go +++ /dev/null @@ -1,133 +0,0 @@ -package scheduler - -import ( - "fmt" - "math/rand/v2" - "strconv" - "strings" - "time" - - "github.com/robfig/cron/v3" -) - -var parser = cron.NewParser( - cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor, -) - -// ParseCrontab parses a cron expression with support for the crontab(5) random ~ syntax. -// Random values are resolved once at parse time. If no ~ is present, it delegates to -// robfig/cron's standard parser. Duration strings (e.g., "5m") are converted to "@every 5m". -func ParseCrontab(spec string) (cron.Schedule, error) { - if spec == "" { - return nil, fmt.Errorf("empty spec string") - } - - if _, err := time.ParseDuration(spec); err == nil { - spec = "@every " + spec - } - - if !strings.Contains(spec, "~") { - return parser.Parse(spec) - } - - // Handle TZ=/CRON_TZ= prefix - var tzPrefix string - if strings.HasPrefix(spec, "TZ=") || strings.HasPrefix(spec, "CRON_TZ=") { - i := strings.Index(spec, " ") - if i == -1 { - return nil, fmt.Errorf("missing spec after timezone") - } - tzPrefix = spec[:i] + " " - spec = strings.TrimSpace(spec[i:]) - } - - // @ descriptors cannot contain ~ - if strings.HasPrefix(spec, "@") { - return nil, fmt.Errorf("random ~ syntax cannot be used with descriptors: %s", spec) - } - - fields := strings.Fields(spec) - fields, err := normalizeFields(fields) - if err != nil { - return nil, err - } - - // Resolve each ~ field to a concrete random value - for i, field := range fields { - if !strings.Contains(field, "~") { - continue - } - if strings.ContainsAny(field, ",/") { - return nil, fmt.Errorf("random ~ cannot be combined with lists or steps: %s", field) - } - v, parseErr := resolveRandomField(field, fieldBounds[i]) - if parseErr != nil { - return nil, parseErr - } - fields[i] = strconv.FormatUint(uint64(v), 10) - } - - // Re-assemble and parse with robfig - resolved := tzPrefix + strings.Join(fields, " ") - return parser.Parse(resolved) -} - -type bounds struct { - min, max uint -} - -var fieldBounds = [6]bounds{ - {0, 59}, // Second - {0, 59}, // Minute - {0, 23}, // Hour - {1, 31}, // Dom - {1, 12}, // Month - {0, 6}, // Dow -} - -// resolveRandomField parses a ~ field and returns a random value within the range. -func resolveRandomField(field string, b bounds) (uint, error) { - parts := strings.SplitN(field, "~", 2) - - min := b.min - max := b.max - - if parts[0] != "" { - v, err := strconv.ParseUint(parts[0], 10, 0) - if err != nil { - return 0, fmt.Errorf("invalid random range start: %s", parts[0]) - } - min = uint(v) - } - - if parts[1] != "" { - v, err := strconv.ParseUint(parts[1], 10, 0) - if err != nil { - return 0, fmt.Errorf("invalid random range end: %s", parts[1]) - } - max = uint(v) - } - - if min < b.min { - return 0, fmt.Errorf("random range start (%d) below minimum (%d): %s", min, b.min, field) - } - if max > b.max { - return 0, fmt.Errorf("random range end (%d) above maximum (%d): %s", max, b.max, field) - } - if min > max { - return 0, fmt.Errorf("random range start (%d) beyond end (%d): %s", min, max, field) - } - - return min + uint(rand.IntN(int(max-min+1))), nil //nolint:gosec // Cryptographic randomness not needed for schedule jitter -} - -func normalizeFields(fields []string) ([]string, error) { - switch len(fields) { - case 5: - return append([]string{"0"}, fields...), nil - case 6: - return fields, nil - default: - return nil, fmt.Errorf("expected 5 or 6 fields, found %d: %v", len(fields), fields) - } -} diff --git a/scheduler/crontab_schedule_test.go b/scheduler/crontab_schedule_test.go deleted file mode 100644 index b616f0884..000000000 --- a/scheduler/crontab_schedule_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package scheduler - -import ( - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/robfig/cron/v3" -) - -var _ = Describe("ParseCrontab", func() { - Describe("standard expressions", func() { - It("parses a 5-field expression", func() { - sched, err := ParseCrontab("5 * * * *") - Expect(err).ToNot(HaveOccurred()) - Expect(sched).To(BeAssignableToTypeOf(&cron.SpecSchedule{})) - }) - - It("parses a 6-field expression with seconds", func() { - sched, err := ParseCrontab("30 5 * * * *") - Expect(err).ToNot(HaveOccurred()) - Expect(sched).To(BeAssignableToTypeOf(&cron.SpecSchedule{})) - }) - - It("converts duration string to @every", func() { - sched, err := ParseCrontab("5m") - Expect(err).ToNot(HaveOccurred()) - Expect(sched).To(BeAssignableToTypeOf(cron.ConstantDelaySchedule{})) - }) - - It("returns error for empty string", func() { - _, err := ParseCrontab("") - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("random ~ syntax", func() { - It("resolves A~B to a value within range", func() { - sched, err := ParseCrontab("0~30 * * * *") - Expect(err).ToNot(HaveOccurred()) - spec := sched.(*cron.SpecSchedule) - minute := findSetBit(spec.Minute) - Expect(minute).To(BeNumerically(">=", 0)) - Expect(minute).To(BeNumerically("<=", 30)) - }) - - It("resolves ~ alone to full field range", func() { - sched, err := ParseCrontab("~ * * * *") - Expect(err).ToNot(HaveOccurred()) - spec := sched.(*cron.SpecSchedule) - minute := findSetBit(spec.Minute) - Expect(minute).To(BeNumerically(">=", 0)) - Expect(minute).To(BeNumerically("<=", 59)) - }) - - It("resolves ~B as min~B", func() { - sched, err := ParseCrontab("~15 * * * *") - Expect(err).ToNot(HaveOccurred()) - spec := sched.(*cron.SpecSchedule) - minute := findSetBit(spec.Minute) - Expect(minute).To(BeNumerically(">=", 0)) - Expect(minute).To(BeNumerically("<=", 15)) - }) - - It("resolves A~ as A~max", func() { - sched, err := ParseCrontab("15~ * * * *") - Expect(err).ToNot(HaveOccurred()) - spec := sched.(*cron.SpecSchedule) - minute := findSetBit(spec.Minute) - Expect(minute).To(BeNumerically(">=", 15)) - Expect(minute).To(BeNumerically("<=", 59)) - }) - - It("resolves multiple random fields independently", func() { - sched, err := ParseCrontab("0~30 0~12 * * *") - Expect(err).ToNot(HaveOccurred()) - spec := sched.(*cron.SpecSchedule) - Expect(findSetBit(spec.Minute)).To(BeNumerically("<=", 30)) - Expect(findSetBit(spec.Hour)).To(BeNumerically("<=", 12)) - }) - - It("resolves ~ in DOM field with correct bounds", func() { - sched, err := ParseCrontab("0 0 ~ * *") - Expect(err).ToNot(HaveOccurred()) - spec := sched.(*cron.SpecSchedule) - dom := findSetBit(spec.Dom) - Expect(dom).To(BeNumerically(">=", 1)) - Expect(dom).To(BeNumerically("<=", 31)) - }) - - It("resolves ~ in month field with correct bounds", func() { - sched, err := ParseCrontab("0 0 1 ~ *") - Expect(err).ToNot(HaveOccurred()) - spec := sched.(*cron.SpecSchedule) - month := findSetBit(spec.Month) - Expect(month).To(BeNumerically(">=", 1)) - Expect(month).To(BeNumerically("<=", 12)) - }) - - It("resolves ~ in DOW field with correct bounds", func() { - sched, err := ParseCrontab("0 0 * * ~") - Expect(err).ToNot(HaveOccurred()) - spec := sched.(*cron.SpecSchedule) - dow := findSetBit(spec.Dow) - Expect(dow).To(BeNumerically(">=", 0)) - Expect(dow).To(BeNumerically("<=", 6)) - }) - - It("preserves TZ= prefix through resolution", func() { - sched, err := ParseCrontab("TZ=America/New_York 0~30 * * * *") - Expect(err).ToNot(HaveOccurred()) - spec := sched.(*cron.SpecSchedule) - nyc, _ := time.LoadLocation("America/New_York") - Expect(spec.Location).To(Equal(nyc)) - }) - - It("preserves non-random fields", func() { - sched, err := ParseCrontab("0~30 10 * * *") - Expect(err).ToNot(HaveOccurred()) - spec := sched.(*cron.SpecSchedule) - Expect(spec.Hour & (1 << 10)).ToNot(BeZero()) - }) - - It("resolves to a stable value across repeated Next calls", func() { - sched, err := ParseCrontab("0~30 * * * *") - Expect(err).ToNot(HaveOccurred()) - - ref := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) - first := sched.Next(ref) - for range 50 { - Expect(sched.Next(ref)).To(Equal(first)) - } - }) - }) - - Describe("error cases", func() { - It("rejects min > max", func() { - _, err := ParseCrontab("30~0 * * * *") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("beyond end")) - }) - - It("rejects value above field maximum", func() { - _, err := ParseCrontab("0~60 * * * *") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("above maximum")) - }) - - It("rejects value below field minimum", func() { - _, err := ParseCrontab("0 0 0~15 * *") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("below minimum")) - }) - - It("rejects ~ mixed with comma (list)", func() { - _, err := ParseCrontab("0~30,45 * * * *") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("cannot be combined")) - }) - - It("rejects ~ mixed with slash (step)", func() { - _, err := ParseCrontab("0~30/5 * * * *") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("cannot be combined")) - }) - - It("rejects @ descriptor with ~", func() { - _, err := ParseCrontab("@every 0~30m") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("descriptor")) - }) - - It("rejects wrong number of fields", func() { - _, err := ParseCrontab("0~30 * *") - Expect(err).To(HaveOccurred()) - }) - - It("rejects non-numeric range values", func() { - _, err := ParseCrontab("a~b * * * *") - Expect(err).To(HaveOccurred()) - }) - }) -}) - -// findSetBit returns the lowest bit position set in v, ignoring the starBit (bit 63). -func findSetBit(v uint64) int { - v &^= 1 << 63 // clear starBit - for i := range 63 { - if v&(1<<uint(i)) != 0 { - return i - } - } - return -1 -} diff --git a/scheduler/log_adapter.go b/scheduler/log_adapter.go index f7089f97c..ccaab0cd4 100644 --- a/scheduler/log_adapter.go +++ b/scheduler/log_adapter.go @@ -6,16 +6,16 @@ import ( type logger struct{} -func (l *logger) Info(msg string, keysAndValues ...any) { - args := []any{ +func (l *logger) Info(msg string, keysAndValues ...interface{}) { + args := []interface{}{ "Scheduler: " + msg, } args = append(args, keysAndValues...) - log.Trace(args...) + log.Debug(args...) } -func (l *logger) Error(err error, msg string, keysAndValues ...any) { - args := []any{ +func (l *logger) Error(err error, msg string, keysAndValues ...interface{}) { + args := []interface{}{ "Scheduler: " + msg, } args = append(args, keysAndValues...) diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go index cb53af421..062bf4344 100644 --- a/scheduler/scheduler.go +++ b/scheduler/scheduler.go @@ -9,8 +9,7 @@ import ( type Scheduler interface { Run(ctx context.Context) - Add(crontab string, cmd func()) (int, error) - Remove(id int) + Add(crontab string, cmd func()) error } func GetInstance() Scheduler { @@ -32,15 +31,7 @@ func (s *scheduler) Run(ctx context.Context) { s.c.Stop() } -func (s *scheduler) Add(crontab string, cmd func()) (int, error) { - schedule, err := ParseCrontab(crontab) - if err != nil { - return 0, err - } - entryID := s.c.Schedule(schedule, cron.FuncJob(cmd)) - return int(entryID), nil -} - -func (s *scheduler) Remove(id int) { - s.c.Remove(cron.EntryID(id)) +func (s *scheduler) Add(crontab string, cmd func()) error { + _, err := s.c.AddFunc(crontab, cmd) + return err } diff --git a/scheduler/scheduler_test.go b/scheduler/scheduler_test.go deleted file mode 100644 index 8ae69c19b..000000000 --- a/scheduler/scheduler_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package scheduler - -import ( - "testing" - "time" - - "github.com/navidrome/navidrome/log" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/robfig/cron/v3" -) - -func TestScheduler(t *testing.T) { - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Scheduler Suite") -} - -var _ = Describe("Scheduler", func() { - var s *scheduler - - BeforeEach(func() { - c := cron.New(cron.WithLogger(&logger{})) - s = &scheduler{c: c} - s.c.Start() // Start the scheduler for tests - }) - - AfterEach(func() { - s.c.Stop() // Stop the scheduler after tests - }) - - It("adds and executes a job", FlakeAttempts(3), func() { - done := make(chan struct{}) - - id, err := s.Add("@every 50ms", func() { - close(done) - }) - - Expect(err).ToNot(HaveOccurred()) - Expect(id).ToNot(BeZero()) - - Eventually(done, 5*time.Second).Should(BeClosed()) - }) - - It("adds a job with random ~ syntax", func() { - id, err := s.Add("0~59 * * * *", func() {}) - Expect(err).ToNot(HaveOccurred()) - Expect(id).ToNot(BeZero()) - s.Remove(id) - }) -}) diff --git a/scripts/setup-worktree.sh b/scripts/setup-worktree.sh deleted file mode 100755 index 65113f3b2..000000000 --- a/scripts/setup-worktree.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -# -# Setup a git worktree for Navidrome development. -# This script is called automatically by `make worktree` and by Claude Code's -# worktree isolation, but can also be run standalone: -# -# ./scripts/setup-worktree.sh <worktree-path> [--go-only] -# -# Options: -# --go-only Skip frontend (npm) setup. Useful for agents working only on Go code. -# -set -euo pipefail - -WORKTREE_PATH="${1:?Usage: $0 <worktree-path> [--go-only]}" -GO_ONLY="${2:-}" - -# Resolve the main worktree root (where the original repo lives) -MAIN_WORKTREE="$(git -C "$WORKTREE_PATH" worktree list --porcelain | head -1 | sed 's/^worktree //')" - -if [ ! -d "$WORKTREE_PATH" ]; then - echo "ERROR: Worktree path does not exist: $WORKTREE_PATH" - exit 1 -fi - -cd "$WORKTREE_PATH" - -echo "==> Setting up worktree at $WORKTREE_PATH" - -# 1. Download Go dependencies -echo "==> Downloading Go dependencies..." -go mod download - -# 2. Install frontend dependencies (unless --go-only) -if [ "$GO_ONLY" != "--go-only" ]; then - echo "==> Installing frontend dependencies..." - (cd ui && npm ci --prefer-offline --no-audit 2>/dev/null || npm ci) -else - echo "==> Skipping frontend setup (--go-only)" -fi - -# 3. Create required directories -mkdir -p data - -# 4. Copy navidrome.toml from main worktree if it exists and not already present -if [ ! -f navidrome.toml ] && [ -f "$MAIN_WORKTREE/navidrome.toml" ]; then - echo "==> Copying navidrome.toml from main worktree..." - cp "$MAIN_WORKTREE/navidrome.toml" navidrome.toml -fi - -# 5. Copy existing database from main worktree (already migrated and scanned) -# This is much faster than running migrations + a full scan from scratch. -if [ ! -f data/navidrome.db ] && [ -f "$MAIN_WORKTREE/data/navidrome.db" ]; then - echo "==> Copying database from main worktree (pre-migrated, pre-scanned)..." - cp "$MAIN_WORKTREE/data/navidrome.db" data/navidrome.db -fi - -echo "==> Worktree setup complete: $WORKTREE_PATH" diff --git a/server/api/api.go b/server/api/api.go new file mode 100644 index 000000000..011825747 --- /dev/null +++ b/server/api/api.go @@ -0,0 +1,157 @@ +//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen -config ./openapi_api.cfg.yaml "../../api/openapi.yaml" +//go:generate go run github.com/deepmap/oapi-codegen/cmd/oapi-codegen -config ./openapi_types.cfg.yaml "../../api/openapi.yaml" + +package api + +import ( + "context" + "net/http" + + middleware "github.com/deepmap/oapi-codegen/pkg/chi-middleware" + "github.com/getkin/kin-openapi/openapi3" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" +) + +var spec = func() *openapi3.T { + s, _ := GetSwagger() + //s.Servers = nil + //s.Components.SecuritySchemes = nil + s.Security = nil //TODO + return s +}() + +func New(ds model.DataStore) *Router { + r := &Router{ds: ds} + mux := chi.NewRouter() + mux.Use(server.Authenticator(ds)) + mux.Use(server.JWTRefresher) + mux.Use(middleware.OapiRequestValidatorWithOptions(spec, &middleware.Options{ + ErrorHandler: validationErrorHandler, + })) + + handler := NewStrictHandlerWithOptions(r, nil, StrictHTTPServerOptions{ + RequestErrorHandlerFunc: apiErrorHandler, + ResponseErrorHandlerFunc: apiErrorHandler, + }) + r.Handler = HandlerWithOptions(handler, ChiServerOptions{ + BaseRouter: mux, + Middlewares: []MiddlewareFunc{storeRequestInContext}, + }) + return r +} + +var _ StrictServerInterface = (*Router)(nil) + +type Router struct { + http.Handler + ds model.DataStore +} + +func (a *Router) GetServerInfo(_ context.Context, _ GetServerInfoRequestObject) (GetServerInfoResponseObject, error) { + return GetServerInfo200JSONResponse{ + Data: ServerInfo{ + AuthRequired: true, + Features: []ServerInfoFeatures{}, + Server: consts.AppName, + ServerVersion: consts.Version, + }, + }, nil +} + +func (a *Router) GetTracks(ctx context.Context, request GetTracksRequestObject) (GetTracksResponseObject, error) { + options := toQueryOptions(ctx, request.Params) + mfs, err := a.ds.MediaFile(ctx).GetAll(options) + if err != nil { + return nil, err + } + cnt, err := a.ds.MediaFile(ctx).CountAll(options) + if err != nil { + return nil, err + } + baseUrl := baseResourceUrl(ctx, "tracks") + links, meta := buildPaginationLinksAndMeta(int32(cnt), request.Params, baseUrl) + + resources := newIncludedResources(ctx, a.ds, request.Params.Include) + resources.Artists(mfs.ArtistIDs()...) + resources.Albums(mfs.AlbumIDs()...) + + response := GetTracks200JSONResponse{ + Data: toAPITracks(mfs), + Links: links, + Meta: &meta, + } + response.Included, err = resources.Build() + return response, err +} + +func (a *Router) GetTrack(ctx context.Context, request GetTrackRequestObject) (GetTrackResponseObject, error) { + mf, err := a.ds.MediaFile(ctx).Get(request.TrackId) + if err != nil { + return nil, err + } + + resources := newIncludedResources(ctx, a.ds, request.Params.Include) + resources.Artists(mf.ArtistID, mf.AlbumArtistID) + resources.Albums(mf.AlbumID) + + response := GetTrack200JSONResponse{ + Data: toAPITrack(*mf), + } + response.Included, err = resources.Build() + return response, err +} + +func (a *Router) GetAlbums(ctx context.Context, request GetAlbumsRequestObject) (GetAlbumsResponseObject, error) { + options := toQueryOptions(ctx, request.Params) + albums, err := a.ds.Album(ctx).GetAll(options) + if err != nil { + return nil, err + } + cnt, err := a.ds.MediaFile(ctx).CountAll(options) + if err != nil { + return nil, err + } + baseUrl := baseResourceUrl(ctx, "albums") + links, meta := buildPaginationLinksAndMeta(int32(cnt), request.Params, baseUrl) + + resources := newIncludedResources(ctx, a.ds, request.Params.Include) + resources.Artists(albums.ArtistIDs()...) + + response := GetAlbums200JSONResponse{ + Data: toAPIAlbums(albums), + Links: links, + Meta: &meta, + } + response.Included, err = resources.Build() + return response, err +} + +func (a *Router) GetAlbum(ctx context.Context, request GetAlbumRequestObject) (GetAlbumResponseObject, error) { + album, err := a.ds.Album(ctx).Get(request.AlbumId) + if err != nil { + return nil, err + } + + resources := newIncludedResources(ctx, a.ds, request.Params.Include) + resources.Artists(album.ArtistID, album.AlbumArtistID) + resources.Tracks(album.ID) + + response := GetAlbum200JSONResponse{ + Data: toAPIAlbum(*album), + } + response.Included, err = resources.Build() + return response, err +} + +func (a *Router) GetArtists(ctx context.Context, request GetArtistsRequestObject) (GetArtistsResponseObject, error) { + //TODO implement me + panic("implement me") +} + +func (a *Router) GetArtist(ctx context.Context, request GetArtistRequestObject) (GetArtistResponseObject, error) { + //TODO implement me + panic("implement me") +} diff --git a/api/api_suite_test.go b/server/api/api_suite_test.go similarity index 74% rename from api/api_suite_test.go rename to server/api/api_suite_test.go index 62a547b7c..a322cebba 100644 --- a/api/api_suite_test.go +++ b/server/api/api_suite_test.go @@ -1,4 +1,4 @@ -package api_test +package api import ( "testing" @@ -9,9 +9,9 @@ import ( . "github.com/onsi/gomega" ) -func TestAPI(t *testing.T) { +func TestApi(t *testing.T) { tests.Init(t, false) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) - RunSpecs(t, "API Spec Suite") + RunSpecs(t, "Navidrome JSON:API Suite") } diff --git a/server/api/helpers.go b/server/api/helpers.go new file mode 100644 index 000000000..5eed3ec18 --- /dev/null +++ b/server/api/helpers.go @@ -0,0 +1,328 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" + . "github.com/navidrome/navidrome/utils/gg" +) + +type contextKey string + +const requestInContext contextKey = "request" + +type includeSlice []string + +// storeRequestInContext is a middleware function that adds the full request object to the context. +func storeRequestInContext(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), requestInContext, r) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func toAPITrack(mf model.MediaFile) Track { + return Track{ + Type: ResourceTypeTrack, + Id: mf.ID, + Attributes: &TrackAttributes{ + Album: mf.Album, + Albumartist: mf.AlbumArtist, + Artist: mf.Artist, + Bitrate: mf.BitRate, + Bpm: P(mf.Bpm), + Channels: mf.Channels, + Comments: P(mf.Comment), + Disc: P(mf.DiscNumber), + Duration: mf.Duration, + Genre: P(mf.Genre), + Mimetype: mf.ContentType(), + RecordingMbid: P(mf.MbzTrackID), + Size: int(mf.Size), + Title: mf.Title, + Track: mf.TrackNumber, + TrackMbid: P(mf.MbzReleaseTrackID), + Year: P(mf.Year), + }, + Relationships: &TrackRelationships{ + Albums: &[]AlbumTrackRelationship{toAlbumRelationship(mf)}, + Artists: trackArtistRelationships(mf), + }, + } +} + +func trackArtistRelationships(mf model.MediaFile) []TrackArtistRelationship { + var r []TrackArtistRelationship + if mf.AlbumArtistID != "" { + r = append(r, toArtistRelationship(mf.AlbumArtistID, ArtistRoleAlbumArtist)) + } + if mf.ArtistID != "" { + r = append(r, toArtistRelationship(mf.ArtistID, ArtistRoleArtist)) + } + return r +} + +func toArtistRelationship(id string, artist ArtistRole) TrackArtistRelationship { + return TrackArtistRelationship{ + Data: ResourceObject{ + Type: ResourceTypeArtist, + Id: id, + }, + Meta: ArtistMetaObject{Role: artist}, + } +} + +func toAlbumRelationship(mf model.MediaFile) AlbumTrackRelationship { + return AlbumTrackRelationship{ + Data: ResourceObject{ + Type: ResourceTypeAlbum, + Id: mf.AlbumID, + }, + } +} + +func toAPITracks(mfs model.MediaFiles) []Track { + tracks := make([]Track, len(mfs)) + for i := range mfs { + tracks[i] = toAPITrack(mfs[i]) + } + return tracks +} + +func toAPIAlbum(ma model.Album) Album { + return Album{ + Type: ResourceTypeAlbum, + Id: ma.ID, + Attributes: &AlbumAttributes{ + Artist: ma.AlbumArtist, + Genre: P(ma.Genre), + ReleaseDate: P(ma.ReleaseDate), + Title: ma.Name, + Tracktotal: P(ma.SongCount), + }, + } +} + +func toAPIAlbums(mas model.Albums) []Album { + albums := make([]Album, len(mas)) + for i := range mas { + albums[i] = toAPIAlbum(mas[i]) + } + return albums +} + +func toAPIArtist(ma model.Artist) Artist { + return Artist{ + Type: ResourceTypeArtist, + Id: ma.ID, + Attributes: &ArtistAttributes{ + Name: ma.Name, + Bio: P(ma.Biography), + }, + } +} + +type GetParams interface { + GetParams() GetTracksParams +} + +func (p GetTracksParams) GetParams() GetTracksParams { return p } + +func (p GetAlbumsParams) GetParams() GetTracksParams { return GetTracksParams(p) } + +// toQueryOptions convert a params struct to a model.QueryOptions struct, to be used by the +// GetAll and CountAll functions. It assumes all GetXxxxParams functions have the exact same structure. +func toQueryOptions(ctx context.Context, p GetParams) model.QueryOptions { + params := p.GetParams() + var filters squirrel.And + parseFilter := func(fs *[]string, op func(f, v string) squirrel.Sqlizer) { + if fs != nil { + for _, f := range *fs { + parts := strings.SplitN(f, ":", 2) + filters = append(filters, op(parts[0], parts[1])) + } + } + } + parseFilter(params.FilterEquals, func(f, v string) squirrel.Sqlizer { return squirrel.Eq{f: v} }) + parseFilter(params.FilterContains, func(f, v string) squirrel.Sqlizer { return squirrel.Like{f: "%" + v + "%"} }) + parseFilter(params.FilterStartsWith, func(f, v string) squirrel.Sqlizer { return squirrel.Like{f: v + "%"} }) + parseFilter(params.FilterEndsWith, func(f, v string) squirrel.Sqlizer { return squirrel.Like{f: "%" + v} }) + parseFilter(params.FilterGreaterThan, func(f, v string) squirrel.Sqlizer { return squirrel.Gt{f: v} }) + parseFilter(params.FilterGreaterOrEqual, func(f, v string) squirrel.Sqlizer { return squirrel.GtOrEq{f: v} }) + parseFilter(params.FilterLessThan, func(f, v string) squirrel.Sqlizer { return squirrel.Lt{f: v} }) + parseFilter(params.FilterLessOrEqual, func(f, v string) squirrel.Sqlizer { return squirrel.LtOrEq{f: v} }) + offset := V(params.PageOffset) + limit := V(params.PageLimit) + sort, err := toSortParams(params.Sort) + if err != nil { + log.Warn(ctx, "Ignoring invalid sort parameter", err) + } + return model.QueryOptions{Max: int(limit), Offset: int(offset), Filters: filters, Sort: sort} +} + +var validSortPattern = regexp.MustCompile(`[a-zA-Z0-9_\-]`) + +func toSortParams(sort *string) (string, error) { + if sort == nil || *sort == "" { + return "", nil + } + + // Split input by comma + inputCols := strings.Split(*sort, ",") + + var resultCols []string + + for _, col := range inputCols { + trimmedCol := strings.TrimSpace(col) + if trimmedCol == "" { + continue + } + + // Check for invalid prefix + if !validSortPattern.Match([]byte(string(trimmedCol[0]))) { + return "", errors.New("invalid sort parameter: " + trimmedCol) + } + + colName := strings.TrimSpace(trimmedCol[1:]) + // Check for descending order + if strings.HasPrefix(trimmedCol, "-") { + resultCols = append(resultCols, fmt.Sprintf("%s desc", colName)) + } else { + resultCols = append(resultCols, fmt.Sprintf("%s asc", trimmedCol)) + } + } + + return strings.Join(resultCols, ","), nil +} + +func apiErrorHandler(w http.ResponseWriter, _ *http.Request, err error) { + var res ErrorObject + switch { + case errors.Is(err, model.ErrNotAuthorized): + res = ErrorObject{Status: P(strconv.Itoa(http.StatusForbidden)), Title: P(http.StatusText(http.StatusForbidden))} + case errors.Is(err, model.ErrNotFound): + res = ErrorObject{Status: P(strconv.Itoa(http.StatusNotFound)), Title: P(http.StatusText(http.StatusNotFound))} + default: + res = ErrorObject{Status: P(strconv.Itoa(http.StatusInternalServerError)), Title: P(http.StatusText(http.StatusInternalServerError))} + } + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(403) + + _ = json.NewEncoder(w).Encode(ErrorList{[]ErrorObject{res}}) +} + +func validationErrorHandler(w http.ResponseWriter, message string, statusCode int) { + _ = GetTracks400JSONResponse{BadRequestJSONResponse{Errors: []ErrorObject{ + { + Status: P(strconv.Itoa(statusCode)), + Title: P(http.StatusText(statusCode)), + Detail: P(message), + }, + }}}.VisitGetTracksResponse(w) +} + +func buildPaginationLinksAndMeta(totalItems int32, p GetParams, resourceName string) (PaginationLinks, PaginationMeta) { + params := p.GetParams() + pageLimit := *params.PageLimit + pageOffset := *params.PageOffset + + totalPages := (totalItems + pageLimit - 1) / pageLimit + currentPage := pageOffset/pageLimit + 1 + + meta := PaginationMeta{ + CurrentPage: ¤tPage, + TotalItems: &totalItems, + TotalPages: &totalPages, + } + + var first, last, next, prev *string + + buildLink := func(page int32) *string { + query := url.Values{} + query.Add("page[offset]", strconv.Itoa(int(page*pageLimit))) + query.Add("page[limit]", strconv.Itoa(int(pageLimit))) + + addFilterParams := func(paramName string, values *[]string) { + if values == nil { + return + } + for _, value := range *values { + query.Add(paramName, value) + } + } + + addFilterParams("filter[equals]", params.FilterEquals) + addFilterParams("filter[contains]", params.FilterContains) + addFilterParams("filter[lessThan]", params.FilterLessThan) + addFilterParams("filter[lessOrEqual]", params.FilterLessOrEqual) + addFilterParams("filter[greaterThan]", params.FilterGreaterThan) + addFilterParams("filter[greaterOrEqual]", params.FilterGreaterOrEqual) + addFilterParams("filter[startsWith]", params.FilterStartsWith) + addFilterParams("filter[endsWith]", params.FilterEndsWith) + + if params.Sort != nil { + query.Add("sort", *params.Sort) + } + if params.Include != nil { + query.Add("include", A(*params.Include)) + } + + link := resourceName + if len(query) > 0 { + link += "?" + query.Encode() + } + return &link + } + + if totalPages > 0 { + firstLink := buildLink(0) + first = firstLink + + lastLink := buildLink(totalPages - 1) + last = lastLink + } + + if currentPage < totalPages { + nextLink := buildLink(currentPage) + next = nextLink + } + + if currentPage > 1 { + prevLink := buildLink(currentPage - 2) + prev = prevLink + } + + links := PaginationLinks{ + First: first, + Last: last, + Next: next, + Prev: prev, + } + + return links, meta +} + +func A[T any](slice []T) string { + var buf []string + for _, v := range slice { + buf = append(buf, fmt.Sprintf("%v", v)) + } + return strings.Join(buf, ",") +} + +func baseResourceUrl(ctx context.Context, resourceName string) string { + r := ctx.Value(requestInContext).(*http.Request) + baseUrl, _ := url.JoinPath(spec.Servers[0].URL, resourceName) + return server.AbsoluteURL(r, baseUrl, nil) +} diff --git a/server/api/helpers_test.go b/server/api/helpers_test.go new file mode 100644 index 000000000..fddb7b70f --- /dev/null +++ b/server/api/helpers_test.go @@ -0,0 +1,185 @@ +package api + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + + . "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("BuildPaginationLinksAndMeta", func() { + var ( + totalItems int32 + params GetTracksParams + resourceName string + ) + + BeforeEach(func() { + totalItems = 150 + resourceName = "api/resource" + }) + + Context("with default page limit and offset", func() { + BeforeEach(func() { + l, o := int32(10), int32(0) + params = GetTracksParams{ + PageLimit: &l, + PageOffset: &o, + } + }) + + It("returns correct pagination links and meta", func() { + links, meta := buildPaginationLinksAndMeta(totalItems, params, resourceName) + + testLinkEquality(links.First, P("api/resource?page[offset]=0&page[limit]=10")) + testLinkEquality(links.Last, P("api/resource?page[offset]=140&page[limit]=10")) + testLinkEquality(links.Next, P("api/resource?page[offset]=10&page[limit]=10")) + Expect(links.Prev).To(BeNil()) + + Expect(meta.CurrentPage).To(Equal(P(int32(1)))) + Expect(meta.TotalItems).To(Equal(P(int32(150)))) + Expect(meta.TotalPages).To(Equal(P(int32(15)))) + }) + }) + + Context("with custom page limit and offset", func() { + BeforeEach(func() { + params = GetTracksParams{ + PageLimit: P((PageLimit)(20)), + PageOffset: P((PageOffset)(40)), + } + }) + + It("returns correct pagination links and meta", func() { + links, meta := buildPaginationLinksAndMeta(totalItems, params, resourceName) + + testLinkEquality(links.First, P("api/resource?page[offset]=0&page[limit]=20")) + testLinkEquality(links.Last, P("api/resource?page[offset]=140&page[limit]=20")) + testLinkEquality(links.Next, P("api/resource?page[offset]=60&page[limit]=20")) + testLinkEquality(links.Prev, P("api/resource?page[offset]=20&page[limit]=20")) + + Expect(meta.CurrentPage).To(Equal(P(int32(3)))) + Expect(meta.TotalItems).To(Equal(P(int32(150)))) + Expect(meta.TotalPages).To(Equal(P(int32(8)))) + }) + }) + + Context("with various filter params", func() { + BeforeEach(func() { + params = GetTracksParams{ + PageLimit: P((PageLimit)(20)), + PageOffset: P((PageOffset)(40)), + FilterEquals: &[]string{"property1:value1", "property2:value2"}, + FilterContains: &[]string{"property3:value3"}, + FilterLessThan: &[]string{"property4:value4"}, + FilterLessOrEqual: &[]string{"property5:value5"}, + FilterGreaterThan: &[]string{"property6:value6"}, + FilterGreaterOrEqual: &[]string{"property7:value7"}, + FilterStartsWith: &[]string{"property8:value8"}, + FilterEndsWith: &[]string{"property9:value9"}, + } + }) + + It("returns correct pagination links with filter params", func() { + links, _ := buildPaginationLinksAndMeta(totalItems, params, resourceName) + + validateLink := func(link *string, expectedOffset string) { + parsedLink, err := url.Parse(*link) + Expect(err).NotTo(HaveOccurred()) + + queryParams, _ := url.ParseQuery(parsedLink.RawQuery) + Expect(queryParams["page[offset]"]).To(ConsistOf(expectedOffset)) + Expect(queryParams["page[limit]"]).To(ConsistOf("20")) + + for _, param := range *params.FilterEquals { + Expect(queryParams["filter[equals]"]).To(ContainElements(param)) + } + for _, param := range *params.FilterContains { + Expect(queryParams["filter[contains]"]).To(ContainElement(param)) + } + for _, param := range *params.FilterLessThan { + Expect(queryParams["filter[lessThan]"]).To(ContainElement(param)) + } + for _, param := range *params.FilterLessOrEqual { + Expect(queryParams["filter[lessOrEqual]"]).To(ContainElement(param)) + } + for _, param := range *params.FilterGreaterThan { + Expect(queryParams["filter[greaterThan]"]).To(ContainElement(param)) + } + for _, param := range *params.FilterGreaterOrEqual { + Expect(queryParams["filter[greaterOrEqual]"]).To(ContainElement(param)) + } + for _, param := range *params.FilterStartsWith { + Expect(queryParams["filter[startsWith]"]).To(ContainElement(param)) + } + for _, param := range *params.FilterEndsWith { + Expect(queryParams["filter[endsWith]"]).To(ContainElement(param)) + } + } + + validateLink(links.First, "0") + validateLink(links.Last, "140") + validateLink(links.Next, "60") + validateLink(links.Prev, "20") + }) + }) +}) + +var _ = Describe("toSortParams", func() { + DescribeTable("toSortParams", + func(sort string, expected string, expectedError error) { + order, err := toSortParams(&sort) + Expect(order).To(Equal(expected)) + if expectedError == nil { + Expect(err).To(BeNil()) + } else { + Expect(err).To(Equal(expectedError)) + } + }, + Entry("should handle nil input", "", "", nil), + Entry("should handle empty input", "", "", nil), + Entry("should handle single column input", "name", "name asc", nil), + Entry("should handle single column input with descending order", "-name", "name desc", nil), + Entry("should handle multiple columns input", "name,,date,", "name asc,date asc", nil), + Entry("should handle multiple columns input with mixed order and spaces", "name, -age", "name asc,age desc", nil), + Entry("should handle relationship columns", "-artist.name", "artist.name desc", nil), + Entry("should return an error for invalid input with invalid prefix", "+name", "", errors.New("invalid sort parameter: +name")), + Entry("should return an error for invalid prefix in any column", "name,*age", "", errors.New("invalid sort parameter: *age")), + ) +}) + +var _ = Describe("storeRequestInContext", func() { + var ( + nextHandler http.Handler + handler http.Handler + ) + + BeforeEach(func() { + nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Context().Value(requestInContext).(*http.Request).URL).To(Equal(r.URL)) + }) + handler = storeRequestInContext(nextHandler) + }) + + It("adds the full request object to the context", func() { + req := httptest.NewRequest("GET", "http://example.com", nil) + resp := httptest.NewRecorder() + handler.ServeHTTP(resp, req) + }) +}) + +func testLinkEquality(link1, link2 *string) { + parsedLink1, err := url.Parse(*link1) + Expect(err).NotTo(HaveOccurred()) + queryParams1, _ := url.ParseQuery(parsedLink1.RawQuery) + + parsedLink2, err := url.Parse(*link2) + Expect(err).NotTo(HaveOccurred()) + queryParams2, _ := url.ParseQuery(parsedLink2.RawQuery) + + Expect(queryParams1).To(Equal(queryParams2)) +} diff --git a/server/api/includes.go b/server/api/includes.go new file mode 100644 index 000000000..ffd86ba39 --- /dev/null +++ b/server/api/includes.go @@ -0,0 +1,123 @@ +package api + +import ( + "context" + "sort" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "golang.org/x/exp/slices" +) + +type includedResources struct { + ctx context.Context + ds model.DataStore + includes *includeSlice + resources []IncludedResource + ids map[ResourceType][]string +} + +func newIncludedResources(ctx context.Context, ds model.DataStore, includes *includeSlice) *includedResources { + i := &includedResources{ + ctx: ctx, + ds: ds, + includes: includes, + } + if includes != nil { + i.ids = make(map[ResourceType][]string) + for _, inc := range *includes { + i.ids[ResourceType(inc)] = []string{} + } + } + return i +} + +func (i *includedResources) Tracks(trackIds ...string) { + if i.ids == nil || i.ids[ResourceTypeTrack] == nil { + return + } + i.ids[ResourceTypeTrack] = append(i.ids[ResourceTypeTrack], trackIds...) +} + +func (i *includedResources) Albums(albumIds ...string) { + if i.ids == nil || i.ids[ResourceTypeAlbum] == nil { + return + } + i.ids[ResourceTypeAlbum] = append(i.ids[ResourceTypeAlbum], albumIds...) +} + +func (i *includedResources) Artists(artistIds ...string) { + if i.ids == nil || i.ids[ResourceTypeArtist] == nil { + return + } + i.ids[ResourceTypeArtist] = append(i.ids[ResourceTypeArtist], artistIds...) +} + +func (i *includedResources) addTracks(albumIds []string) error { + tracks, err := i.ds.MediaFile(i.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album_id": albumIds}}) + if err != nil { + return err + } + for _, tr := range tracks { + inc := &IncludedResource{} + _ = inc.FromTrack(toAPITrack(tr)) + i.resources = append(i.resources, *inc) + } + return nil +} + +func (i *includedResources) addAlbums(albumIds []string) error { + albums, err := i.ds.Album(i.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": albumIds}}) + if err != nil { + return err + } + for _, al := range albums { + inc := &IncludedResource{} + _ = inc.FromAlbum(toAPIAlbum(al)) + i.resources = append(i.resources, *inc) + } + return nil +} + +func (i *includedResources) addArtists(artistIds []string) error { + artists, err := i.ds.Artist(i.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"artist.id": artistIds}}) + if err != nil { + return err + } + for _, ar := range artists { + inc := &IncludedResource{} + _ = inc.FromArtist(toAPIArtist(ar)) + i.resources = append(i.resources, *inc) + } + return nil +} + +func (i *includedResources) Build() (*[]IncludedResource, error) { + if i.includes == nil { + return nil, nil + } + for _, typ := range *i.includes { + ids := i.ids[ResourceType(typ)] + sort.Strings(ids) + slices.Compact(ids) + if len(ids) == 0 { + continue + } + switch ResourceType(typ) { + case ResourceTypeAlbum: + if err := i.addAlbums(ids); err != nil { + return nil, err + } + case ResourceTypeArtist: + if err := i.addArtists(ids); err != nil { + return nil, err + } + case ResourceTypeTrack: + if err := i.addTracks(ids); err != nil { + return nil, err + } + } + } + + return &i.resources, nil +} diff --git a/server/api/openapi_api.cfg.yaml b/server/api/openapi_api.cfg.yaml new file mode 100644 index 000000000..e046b5986 --- /dev/null +++ b/server/api/openapi_api.cfg.yaml @@ -0,0 +1,6 @@ +package: api +generate: + chi-server: true + strict-server: true + embedded-spec: true +output: openapi_api.gen.go diff --git a/server/api/openapi_api.gen.go b/server/api/openapi_api.gen.go new file mode 100644 index 000000000..755ec5313 --- /dev/null +++ b/server/api/openapi_api.gen.go @@ -0,0 +1,1401 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen version v1.12.5-0.20230513000919-14548c7e7bbe DO NOT EDIT. +package api + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path" + "strings" + + "github.com/deepmap/oapi-codegen/pkg/runtime" + "github.com/getkin/kin-openapi/openapi3" + "github.com/go-chi/chi/v5" +) + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Retrieve a list of albums + // (GET /albums) + GetAlbums(w http.ResponseWriter, r *http.Request, params GetAlbumsParams) + // Retrieve an individual album + // (GET /albums/{albumId}) + GetAlbum(w http.ResponseWriter, r *http.Request, albumId string, params GetAlbumParams) + // Retrieve a list of artists + // (GET /artists) + GetArtists(w http.ResponseWriter, r *http.Request, params GetArtistsParams) + // Retrieve an individual artist + // (GET /artists/{artistId}) + GetArtist(w http.ResponseWriter, r *http.Request, artistId string, params GetArtistParams) + // Get server's global info + // (GET /server) + GetServerInfo(w http.ResponseWriter, r *http.Request) + // Retrieve a list of tracks + // (GET /tracks) + GetTracks(w http.ResponseWriter, r *http.Request, params GetTracksParams) + // Retrieve an individual track + // (GET /tracks/{trackId}) + GetTrack(w http.ResponseWriter, r *http.Request, trackId string, params GetTrackParams) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// GetAlbums operation middleware +func (siw *ServerInterfaceWrapper) GetAlbums(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetAlbumsParams + + // ------------- Optional query parameter "page[limit]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "page[limit]", r.URL.Query(), ¶ms.PageLimit) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page[limit]", Err: err}) + return + } + + // ------------- Optional query parameter "page[offset]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "page[offset]", r.URL.Query(), ¶ms.PageOffset) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page[offset]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[equals]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[equals]", r.URL.Query(), ¶ms.FilterEquals) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[equals]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[contains]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[contains]", r.URL.Query(), ¶ms.FilterContains) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[contains]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[lessThan]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[lessThan]", r.URL.Query(), ¶ms.FilterLessThan) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[lessThan]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[lessOrEqual]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[lessOrEqual]", r.URL.Query(), ¶ms.FilterLessOrEqual) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[lessOrEqual]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[greaterThan]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[greaterThan]", r.URL.Query(), ¶ms.FilterGreaterThan) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[greaterThan]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[greaterOrEqual]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[greaterOrEqual]", r.URL.Query(), ¶ms.FilterGreaterOrEqual) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[greaterOrEqual]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[startsWith]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[startsWith]", r.URL.Query(), ¶ms.FilterStartsWith) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[startsWith]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[endsWith]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[endsWith]", r.URL.Query(), ¶ms.FilterEndsWith) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[endsWith]", Err: err}) + return + } + + // ------------- Optional query parameter "sort" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort", r.URL.Query(), ¶ms.Sort) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sort", Err: err}) + return + } + + // ------------- Optional query parameter "include" ------------- + + err = runtime.BindQueryParameter("form", false, false, "include", r.URL.Query(), ¶ms.Include) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "include", Err: err}) + return + } + + var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetAlbums(w, r, params) + }) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +// GetAlbum operation middleware +func (siw *ServerInterfaceWrapper) GetAlbum(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // ------------- Path parameter "albumId" ------------- + var albumId string + + err = runtime.BindStyledParameterWithLocation("simple", false, "albumId", runtime.ParamLocationPath, chi.URLParam(r, "albumId"), &albumId) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "albumId", Err: err}) + return + } + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetAlbumParams + + // ------------- Optional query parameter "include" ------------- + + err = runtime.BindQueryParameter("form", false, false, "include", r.URL.Query(), ¶ms.Include) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "include", Err: err}) + return + } + + var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetAlbum(w, r, albumId, params) + }) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +// GetArtists operation middleware +func (siw *ServerInterfaceWrapper) GetArtists(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetArtistsParams + + // ------------- Optional query parameter "page[limit]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "page[limit]", r.URL.Query(), ¶ms.PageLimit) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page[limit]", Err: err}) + return + } + + // ------------- Optional query parameter "page[offset]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "page[offset]", r.URL.Query(), ¶ms.PageOffset) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page[offset]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[equals]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[equals]", r.URL.Query(), ¶ms.FilterEquals) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[equals]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[contains]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[contains]", r.URL.Query(), ¶ms.FilterContains) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[contains]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[lessThan]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[lessThan]", r.URL.Query(), ¶ms.FilterLessThan) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[lessThan]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[lessOrEqual]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[lessOrEqual]", r.URL.Query(), ¶ms.FilterLessOrEqual) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[lessOrEqual]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[greaterThan]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[greaterThan]", r.URL.Query(), ¶ms.FilterGreaterThan) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[greaterThan]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[greaterOrEqual]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[greaterOrEqual]", r.URL.Query(), ¶ms.FilterGreaterOrEqual) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[greaterOrEqual]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[startsWith]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[startsWith]", r.URL.Query(), ¶ms.FilterStartsWith) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[startsWith]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[endsWith]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[endsWith]", r.URL.Query(), ¶ms.FilterEndsWith) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[endsWith]", Err: err}) + return + } + + // ------------- Optional query parameter "sort" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort", r.URL.Query(), ¶ms.Sort) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sort", Err: err}) + return + } + + // ------------- Optional query parameter "include" ------------- + + err = runtime.BindQueryParameter("form", true, false, "include", r.URL.Query(), ¶ms.Include) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "include", Err: err}) + return + } + + var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetArtists(w, r, params) + }) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +// GetArtist operation middleware +func (siw *ServerInterfaceWrapper) GetArtist(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // ------------- Path parameter "artistId" ------------- + var artistId string + + err = runtime.BindStyledParameterWithLocation("simple", false, "artistId", runtime.ParamLocationPath, chi.URLParam(r, "artistId"), &artistId) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "artistId", Err: err}) + return + } + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetArtistParams + + // ------------- Optional query parameter "include" ------------- + + err = runtime.BindQueryParameter("form", true, false, "include", r.URL.Query(), ¶ms.Include) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "include", Err: err}) + return + } + + var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetArtist(w, r, artistId, params) + }) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +// GetServerInfo operation middleware +func (siw *ServerInterfaceWrapper) GetServerInfo(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetServerInfo(w, r) + }) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +// GetTracks operation middleware +func (siw *ServerInterfaceWrapper) GetTracks(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetTracksParams + + // ------------- Optional query parameter "page[limit]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "page[limit]", r.URL.Query(), ¶ms.PageLimit) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page[limit]", Err: err}) + return + } + + // ------------- Optional query parameter "page[offset]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "page[offset]", r.URL.Query(), ¶ms.PageOffset) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page[offset]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[equals]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[equals]", r.URL.Query(), ¶ms.FilterEquals) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[equals]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[contains]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[contains]", r.URL.Query(), ¶ms.FilterContains) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[contains]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[lessThan]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[lessThan]", r.URL.Query(), ¶ms.FilterLessThan) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[lessThan]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[lessOrEqual]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[lessOrEqual]", r.URL.Query(), ¶ms.FilterLessOrEqual) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[lessOrEqual]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[greaterThan]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[greaterThan]", r.URL.Query(), ¶ms.FilterGreaterThan) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[greaterThan]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[greaterOrEqual]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[greaterOrEqual]", r.URL.Query(), ¶ms.FilterGreaterOrEqual) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[greaterOrEqual]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[startsWith]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[startsWith]", r.URL.Query(), ¶ms.FilterStartsWith) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[startsWith]", Err: err}) + return + } + + // ------------- Optional query parameter "filter[endsWith]" ------------- + + err = runtime.BindQueryParameter("form", true, false, "filter[endsWith]", r.URL.Query(), ¶ms.FilterEndsWith) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "filter[endsWith]", Err: err}) + return + } + + // ------------- Optional query parameter "sort" ------------- + + err = runtime.BindQueryParameter("form", true, false, "sort", r.URL.Query(), ¶ms.Sort) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sort", Err: err}) + return + } + + // ------------- Optional query parameter "include" ------------- + + err = runtime.BindQueryParameter("form", false, false, "include", r.URL.Query(), ¶ms.Include) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "include", Err: err}) + return + } + + var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetTracks(w, r, params) + }) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +// GetTrack operation middleware +func (siw *ServerInterfaceWrapper) GetTrack(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // ------------- Path parameter "trackId" ------------- + var trackId string + + err = runtime.BindStyledParameterWithLocation("simple", false, "trackId", runtime.ParamLocationPath, chi.URLParam(r, "trackId"), &trackId) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "trackId", Err: err}) + return + } + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetTrackParams + + // ------------- Optional query parameter "include" ------------- + + err = runtime.BindQueryParameter("form", false, false, "include", r.URL.Query(), ¶ms.Include) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "include", Err: err}) + return + } + + var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetTrack(w, r, trackId, params) + }) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshallingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshallingParamError) Error() string { + return fmt.Sprintf("Error unmarshalling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshallingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{}) +} + +type ChiServerOptions struct { + BaseURL string + BaseRouter chi.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = chi.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/albums", wrapper.GetAlbums) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/albums/{albumId}", wrapper.GetAlbum) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/artists", wrapper.GetArtists) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/artists/{artistId}", wrapper.GetArtist) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/server", wrapper.GetServerInfo) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/tracks", wrapper.GetTracks) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/tracks/{trackId}", wrapper.GetTrack) + }) + + return r +} + +type BadRequestJSONResponse ErrorList + +type InternalServerErrorJSONResponse ErrorList + +type NotAuthorizedJSONResponse ErrorList + +type NotFoundJSONResponse ErrorList + +type GetAlbumsRequestObject struct { + Params GetAlbumsParams +} + +type GetAlbumsResponseObject interface { + VisitGetAlbumsResponse(w http.ResponseWriter) error +} + +type GetAlbums200JSONResponse struct { + Data []Album `json:"data"` + + // Included Included resources, as requested by the `include` query parameter + Included *[]IncludedResource `json:"included,omitempty"` + Links PaginationLinks `json:"links"` + Meta *PaginationMeta `json:"meta,omitempty"` +} + +func (response GetAlbums200JSONResponse) VisitGetAlbumsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetAlbums400JSONResponse struct{ BadRequestJSONResponse } + +func (response GetAlbums400JSONResponse) VisitGetAlbumsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type GetAlbums403JSONResponse struct{ NotAuthorizedJSONResponse } + +func (response GetAlbums403JSONResponse) VisitGetAlbumsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetAlbums500JSONResponse struct { + InternalServerErrorJSONResponse +} + +func (response GetAlbums500JSONResponse) VisitGetAlbumsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type GetAlbumRequestObject struct { + AlbumId string `json:"albumId"` + Params GetAlbumParams +} + +type GetAlbumResponseObject interface { + VisitGetAlbumResponse(w http.ResponseWriter) error +} + +type GetAlbum200JSONResponse struct { + Data Album `json:"data"` + + // Included Included resources, as requested by the `include` query parameter + Included *[]IncludedResource `json:"included,omitempty"` +} + +func (response GetAlbum200JSONResponse) VisitGetAlbumResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetAlbum403JSONResponse struct{ NotAuthorizedJSONResponse } + +func (response GetAlbum403JSONResponse) VisitGetAlbumResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetAlbum404JSONResponse struct{ NotFoundJSONResponse } + +func (response GetAlbum404JSONResponse) VisitGetAlbumResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type GetAlbum500JSONResponse struct { + InternalServerErrorJSONResponse +} + +func (response GetAlbum500JSONResponse) VisitGetAlbumResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type GetArtistsRequestObject struct { + Params GetArtistsParams +} + +type GetArtistsResponseObject interface { + VisitGetArtistsResponse(w http.ResponseWriter) error +} + +type GetArtists200JSONResponse struct { + Data []Artist `json:"data"` + Links PaginationLinks `json:"links"` + Meta *PaginationMeta `json:"meta,omitempty"` +} + +func (response GetArtists200JSONResponse) VisitGetArtistsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetArtists400JSONResponse struct{ BadRequestJSONResponse } + +func (response GetArtists400JSONResponse) VisitGetArtistsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type GetArtists403JSONResponse struct{ NotAuthorizedJSONResponse } + +func (response GetArtists403JSONResponse) VisitGetArtistsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetArtists500JSONResponse struct { + InternalServerErrorJSONResponse +} + +func (response GetArtists500JSONResponse) VisitGetArtistsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type GetArtistRequestObject struct { + ArtistId string `json:"artistId"` + Params GetArtistParams +} + +type GetArtistResponseObject interface { + VisitGetArtistResponse(w http.ResponseWriter) error +} + +type GetArtist200JSONResponse struct { + Data Artist `json:"data"` +} + +func (response GetArtist200JSONResponse) VisitGetArtistResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetArtist403JSONResponse struct{ NotAuthorizedJSONResponse } + +func (response GetArtist403JSONResponse) VisitGetArtistResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetArtist404JSONResponse struct{ NotFoundJSONResponse } + +func (response GetArtist404JSONResponse) VisitGetArtistResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type GetArtist500JSONResponse struct { + InternalServerErrorJSONResponse +} + +func (response GetArtist500JSONResponse) VisitGetArtistResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type GetServerInfoRequestObject struct { +} + +type GetServerInfoResponseObject interface { + VisitGetServerInfoResponse(w http.ResponseWriter) error +} + +type GetServerInfo200JSONResponse struct { + Data ServerInfo `json:"data"` +} + +func (response GetServerInfo200JSONResponse) VisitGetServerInfoResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetServerInfo403JSONResponse struct{ NotAuthorizedJSONResponse } + +func (response GetServerInfo403JSONResponse) VisitGetServerInfoResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetServerInfo500JSONResponse struct { + InternalServerErrorJSONResponse +} + +func (response GetServerInfo500JSONResponse) VisitGetServerInfoResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type GetTracksRequestObject struct { + Params GetTracksParams +} + +type GetTracksResponseObject interface { + VisitGetTracksResponse(w http.ResponseWriter) error +} + +type GetTracks200JSONResponse struct { + Data []Track `json:"data"` + + // Included Included resources, as requested by the `include` query parameter + Included *[]IncludedResource `json:"included,omitempty"` + Links PaginationLinks `json:"links"` + Meta *PaginationMeta `json:"meta,omitempty"` +} + +func (response GetTracks200JSONResponse) VisitGetTracksResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTracks400JSONResponse struct{ BadRequestJSONResponse } + +func (response GetTracks400JSONResponse) VisitGetTracksResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type GetTracks403JSONResponse struct{ NotAuthorizedJSONResponse } + +func (response GetTracks403JSONResponse) VisitGetTracksResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetTracks500JSONResponse struct { + InternalServerErrorJSONResponse +} + +func (response GetTracks500JSONResponse) VisitGetTracksResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type GetTrackRequestObject struct { + TrackId string `json:"trackId"` + Params GetTrackParams +} + +type GetTrackResponseObject interface { + VisitGetTrackResponse(w http.ResponseWriter) error +} + +type GetTrack200JSONResponse struct { + Data Track `json:"data"` + + // Included Included resources, as requested by the `include` query parameter + Included *[]IncludedResource `json:"included,omitempty"` +} + +func (response GetTrack200JSONResponse) VisitGetTrackResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetTrack403JSONResponse struct{ NotAuthorizedJSONResponse } + +func (response GetTrack403JSONResponse) VisitGetTrackResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetTrack404JSONResponse struct{ NotFoundJSONResponse } + +func (response GetTrack404JSONResponse) VisitGetTrackResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type GetTrack500JSONResponse struct { + InternalServerErrorJSONResponse +} + +func (response GetTrack500JSONResponse) VisitGetTrackResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Retrieve a list of albums + // (GET /albums) + GetAlbums(ctx context.Context, request GetAlbumsRequestObject) (GetAlbumsResponseObject, error) + // Retrieve an individual album + // (GET /albums/{albumId}) + GetAlbum(ctx context.Context, request GetAlbumRequestObject) (GetAlbumResponseObject, error) + // Retrieve a list of artists + // (GET /artists) + GetArtists(ctx context.Context, request GetArtistsRequestObject) (GetArtistsResponseObject, error) + // Retrieve an individual artist + // (GET /artists/{artistId}) + GetArtist(ctx context.Context, request GetArtistRequestObject) (GetArtistResponseObject, error) + // Get server's global info + // (GET /server) + GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error) + // Retrieve a list of tracks + // (GET /tracks) + GetTracks(ctx context.Context, request GetTracksRequestObject) (GetTracksResponseObject, error) + // Retrieve an individual track + // (GET /tracks/{trackId}) + GetTrack(ctx context.Context, request GetTrackRequestObject) (GetTrackResponseObject, error) +} + +type StrictHandlerFunc = runtime.StrictHttpHandlerFunc +type StrictMiddlewareFunc = runtime.StrictHttpMiddlewareFunc + +type StrictHTTPServerOptions struct { + RequestErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) + ResponseErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: StrictHTTPServerOptions{ + RequestErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + }, + ResponseErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusInternalServerError) + }, + }} +} + +func NewStrictHandlerWithOptions(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc, options StrictHTTPServerOptions) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: options} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc + options StrictHTTPServerOptions +} + +// GetAlbums operation middleware +func (sh *strictHandler) GetAlbums(w http.ResponseWriter, r *http.Request, params GetAlbumsParams) { + var request GetAlbumsRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetAlbums(ctx, request.(GetAlbumsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetAlbums") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetAlbumsResponseObject); ok { + if err := validResponse.VisitGetAlbumsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("Unexpected response type: %T", response)) + } +} + +// GetAlbum operation middleware +func (sh *strictHandler) GetAlbum(w http.ResponseWriter, r *http.Request, albumId string, params GetAlbumParams) { + var request GetAlbumRequestObject + + request.AlbumId = albumId + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetAlbum(ctx, request.(GetAlbumRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetAlbum") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetAlbumResponseObject); ok { + if err := validResponse.VisitGetAlbumResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("Unexpected response type: %T", response)) + } +} + +// GetArtists operation middleware +func (sh *strictHandler) GetArtists(w http.ResponseWriter, r *http.Request, params GetArtistsParams) { + var request GetArtistsRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetArtists(ctx, request.(GetArtistsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetArtists") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetArtistsResponseObject); ok { + if err := validResponse.VisitGetArtistsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("Unexpected response type: %T", response)) + } +} + +// GetArtist operation middleware +func (sh *strictHandler) GetArtist(w http.ResponseWriter, r *http.Request, artistId string, params GetArtistParams) { + var request GetArtistRequestObject + + request.ArtistId = artistId + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetArtist(ctx, request.(GetArtistRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetArtist") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetArtistResponseObject); ok { + if err := validResponse.VisitGetArtistResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("Unexpected response type: %T", response)) + } +} + +// GetServerInfo operation middleware +func (sh *strictHandler) GetServerInfo(w http.ResponseWriter, r *http.Request) { + var request GetServerInfoRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetServerInfo(ctx, request.(GetServerInfoRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetServerInfo") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetServerInfoResponseObject); ok { + if err := validResponse.VisitGetServerInfoResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("Unexpected response type: %T", response)) + } +} + +// GetTracks operation middleware +func (sh *strictHandler) GetTracks(w http.ResponseWriter, r *http.Request, params GetTracksParams) { + var request GetTracksRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetTracks(ctx, request.(GetTracksRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTracks") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetTracksResponseObject); ok { + if err := validResponse.VisitGetTracksResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("Unexpected response type: %T", response)) + } +} + +// GetTrack operation middleware +func (sh *strictHandler) GetTrack(w http.ResponseWriter, r *http.Request, trackId string, params GetTrackParams) { + var request GetTrackRequestObject + + request.TrackId = trackId + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetTrack(ctx, request.(GetTrackRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTrack") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetTrackResponseObject); ok { + if err := validResponse.VisitGetTrackResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("Unexpected response type: %T", response)) + } +} + +// Base64 encoded, gzipped, json marshaled Swagger object +var swaggerSpec = []string{ + + "H4sIAAAAAAAC/+w73W7bONavQuj7gJnBqHa2nd2LAHuRzrZFBm0aJJntRSeLoaRji1OJVEnKiacwsK+x", + "r7dPsuAhKckWZctu0p+gN20i8vzy8Pzx5EOUirISHLhW0fGHqKKSlqBB4m8zVmiQPwuuKeP4JQOVSlZp", + "Jnh0HD3HdZIsCeVLUklRgdRLkloAxudEw62ekF8VncMxsejeumV1/XcPcbygRQ1RHDGD9X0NchnFEacl", + "RMfRJlQURyrNoaSGH6ahdIxrDdKA/+u3325+PDb/RHGkl5XBobRkfB6tmg9USrqMVqvYoX/GM/WG6Xy0", + "jDqnmgDPFLlhOg/KCQ7nfnI2UPcj5/uaFuNPEmWjnMAtTTUpqU7znpSIcU8ZLcy9SPhCAtUgX1tRd0nK", + "6xIkS1uJ5xbcnC8nQhJklWhBKEHBNsWfr5HbTw0bsGPVkR2gjquc8o/TxXb5Df6DhEfAe5H8JSh1qBUU", + "oNRoEyhaQvupoAt4byo46ORb+bfIvP+ZN1D3Iu2lplIf4MUVwg37cdXg3U/aDtwdezrG06LOoC/mBRRU", + "Q0YkKFHLFJQxW7ebME50DmatElxBTBSYcG/2JyZolyVVAxJ5gl0xNths2Xou5EmR1OX9sAe3VSGM7DNa", + "KBjPbqN14Ia1t5GWNH0XxRGVmikdXe9SfBzdPpqLR+6bI3FZsBRCwqsvXPo7l/rKqPOLlxrt8s7OvKJz", + "eMlKpvtiX+Vg3GoCkogZQUZIBZIYkIFLZpbeFgbdur/IYEbrQkfHfzmKo5mQJdXIjH7yOIqjknFWGumO", + "Gt4Z1zAH2bD4ejZTMMCjwDUyE8ga4xTXtjBoAQY4PIBBJWSAtUshtTeNutDKmIPgYMJxKSR4D85ABW1m", + "Qs4lzNgtoljPZr979B1Ka+gBz0yRImQGcjIgNPK3ze+t4sjbL9raU5pdwPsaFIplyhbg+COtqoKlqODp", + "gmcTWrEf/1ACo3OL/v8lzKLj6P+mbWE2tatq+kxKIV8ay0Wy6yp7SjPiCa/i6JSb2EKLS5ALkAj5afnx", + "DBDLAbEsrOLoTOiTWudCsj8h+7Q8nQlNOrQtM89FzT8DH5YsXgELYjA2gZMWxetZdPx2O5EL51RfJ39A", + "ao79Q9TeDESjtWRJre1v21Ah5ZN2O5p1gRpQOatswrKOG52oWnO0uykg0EUHcz/DiW1s3hMzBqDtiFGm", + "9zWTxu7eNgI09Np4IJxCV70v16s4GpKkp6KM6p2G0jvDqITdUJb6K9D0dctqVzhEElsO+nJ5GdbMI3S8", + "4ahh10xkMw7WR9VeLM2YSrXQoQJsPT6ajcpnAxvommARR3PgEsK4cGknQxIKoAr+QfUAGreBZFTvxqaZ", + "Lgbw4NJuBMbsRinIGigR2zW0YQKWv0C2s2EF/ZtzJ4a8wc2wJTaG9umdHpLez+s12X1YQ+MclqVrUO3l", + "sQZ12DrMj+Zqfz86wNVqrDcdUMZX5U03DanHfMJE/5KfEJWbTDdhYi5plS8bl2HvRMBn2LQ06C1oCbvg", + "N8RCZMPydBTSk0cK6/t2q/XC7NykjODDlC/EkGc1gEZKyn0UYpxQ6yBNeeB940ahG9uLezJc+A1Z/x2b", + "4afQ4p7G26aqPf7ALI1PxBBTK/NWr+FQDzI0ZHgZaMqKQCUWRywLflaa6loFl5oQ3r8nPa5Obfmf+bNF", + "Zpixz9LUzba+KmlVGQw+UmxNWdvYvPXofYI6tAtt1nDoK90zW7qiBKs4EhxGhFWPZUSqvXuX5Rvd+3nT", + "VnjJeChGzZi0pte0DmrJQq6voOP2cbgdta+SsNi9L2QKrUyvgtc6raUErs/pfMCLuQ3YDvJ5byqKAlLX", + "f+m1UfrZMCaOp/5aBnJQs95rQn0MMSPPSGJGsAOJhRS+4U97Crc3v89Wzdn7GgjLgGs2YyCx9+MaS/YW", + "D/YAx3n4K7xkG76NZR7t9RZhrhyZgDaXVRPKO4wOtDG9ewhFNdt8OeUzEUhla51fNFxv8vEmB52DVVat", + "QJKcKkLTFJ+GBH5WiHyCHVpaVsaTallDw0YiRAGUGz5mQHUtQ9ZzQgpXSwr8RAviN3eIEFVXlZBaYZ8u", + "2NdVjUbwGaNEY93Z4jXxASnsTqo8J2Kmb6iENbkjThcsk6IMGpSF/CdIxQQPE1rYxW7NF9ZwdDT529Hk", + "aGdu56TaJB6vH3vnZEKmaoPC5yjNkPKOymwn/MUaxED9YSl93d2cTW2Fy9YRdYPZhj/ZhDqBQvC5ufAh", + "s8bd2/pElWQllctQvyhIZBKksoVAv+YhN7kgFUgTZiBrqYQwJ0zLwVaQW2yw1xkTZMYKjNfvWCESpu2b", + "joJU8Ix8/y6p1A+TYPhMqgHlJ0AdlpLxWgP5/un5qx88UeQ8jDHNKedQqF2dI8u33+0j8jbEoiz9ONiG", + "p+ZLQrOMOS/tN5qqiwsNitBE1DqIf70nGObZrHjGb5jOsa4r60KzR7iEZhPmOaulfbgKI3ara1o1mrAH", + "p7pJyawQtFM3W3b26T0OS16yEvRgyH91+urZWtxvDW4SbmWmQmaMzx+VyVD286pWLH0qKeN/hlMgh2I3", + "74r9OcC3WQnfkWSpQYXPa2zndPDmah+XAgjwdNftqPE6A+wYkMP1OKy2JVC5vdFsdqzbpe9hTPZu8MZt", + "YthxzHEz6dAYYOfCdBxJ6w/deQ8GmouxLdI7fb2J935xGorsYx+Ggp0VBWktmV5eGiKutwdUgjyp7eSR", + "/e259yi/vLny78eYE+Nqe7a51pV9ImQuScdJV1vkuDfos05uWcvCQanj6bTJOidCWve6YWlMEVVBSuz3", + "xOXUDUJycn4ak5ucpTmhRSFuFOb6mOEnUtwoIJRnpKTcFKo6ByZJaW4DKVgiTWhfMEoo+eXy9dnxyfkp", + "IQlVkBFjsXJGU5gQY+1mpZJiwTJQBHhWCWYCx8zbuordC0fsoriKSVXQpakMFHJg0/mYUJMquFkt5Ias", + "ZYUT8nPBMCillBMJWjJYGG9kHbwJAm2QWiuInTyxm/pCokpI7ScQYvziMwtCsZxVRNVGc4qkEqg2rtTs", + "yqAA/KWRYULeWJaZsirPYAGFuTSW06RmReY0S6vKyozZP8ORmZxq1OkcUxIUvz1DC2bT/NjpGVkhCmiJ", + "s3xwW4FkwFNApTdn7Oo5pBY45LZwn/xmfEXBUuAKOqb54uxX8gI4SFqQ8zopWEpe2k1k8QSrlHWDnTOd", + "18kkFWVru52fkkIk05IqDXL68vTnZ2eXzzrxIlqz2yiOFr6iio4mjydH2OyqgNOKRcfRk4ktkyqqc7ym", + "09Yxze08jDkAtIrTzMgC+sQXkt1B+IGip90ybQeBhjpjG5vdSM6I3Wtz26P3NzP7oyGaWdG9IPyA7Wig", + "7jzyvkB7E+tMhI7XtP9LgBEQOBs0Yl9vNnB1vTE09Pjo6KAJlI957XOd3F6UddwGciHfAW+H+WLj+6Sd", + "OrIDWMaX/u5Q/E5wmIo0quj2brbx1mu1B9gsfDd5G6LN5vPIenyjvxt88PQcBJKEXhxue1zOBa3i6Cd7", + "5CE2GtOYdobJEOTJbpD18apVHP11DKHQuBhmO3VpKngc5HTBlPaEWcXet04/4P+n2Wqnl93byW6OF5uL", + "N6bjuzGFgUN+Jia0M36O56h7yraROTz2d783eNTF/Sou6shZgf6V4a5P1NYeh5n/T0c/jYJy83j3cF84", + "YTxjC5bVtHBmaK9MW84M3pSmn/0tIfmWkNx1QvJl5CHurfjBRHh3ZR9IiPfSdBzW9IP9YVeU9/2ng8L8", + "3uHdUwvFd8fu1xLg3Y34mNDpHlweWOxsFTNtH22H7K/z9P3ZjrLDw8HHedX5C6n//vs/ihhI8g6WpKSV", + "7eI0eZ47cZIx7NpQuSQSKgnKXBc+X3tT/tze5gVox8t3iswLkdACG3X2fNuJ1qHzdX9x9i01+pYa3Uuv", + "xtnXl5AjNRN+33o1nzCTcy7oYSRyXpjWt04/4P87srgr93h4aK/G36L9sjn/ZBlI5hzTX0ku19zch92s", + "8W/XDyvftFa46r76ovF333vfXhvrsnmMvRr2wWtKKzZdPI5W16v/BQAA//9EQaqoZUkAAA==", +} + +// GetSwagger returns the content of the embedded swagger specification file +// or error if failed to decode +func decodeSpec() ([]byte, error) { + zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, "")) + if err != nil { + return nil, fmt.Errorf("error base64 decoding spec: %s", err) + } + zr, err := gzip.NewReader(bytes.NewReader(zipped)) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %s", err) + } + var buf bytes.Buffer + _, err = buf.ReadFrom(zr) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %s", err) + } + + return buf.Bytes(), nil +} + +var rawSpec = decodeSpecCached() + +// a naive cached of a decoded swagger spec +func decodeSpecCached() func() ([]byte, error) { + data, err := decodeSpec() + return func() ([]byte, error) { + return data, err + } +} + +// Constructs a synthetic filesystem for resolving external references when loading openapi specifications. +func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { + var res = make(map[string]func() ([]byte, error)) + if len(pathToFile) > 0 { + res[pathToFile] = rawSpec + } + + return res +} + +// GetSwagger returns the Swagger specification corresponding to the generated code +// in this file. The external references of Swagger specification are resolved. +// The logic of resolving external references is tightly connected to "import-mapping" feature. +// Externally referenced files must be embedded in the corresponding golang packages. +// Urls can be supported but this task was out of the scope. +func GetSwagger() (swagger *openapi3.T, err error) { + var resolvePath = PathToRawSpec("") + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) { + var pathToFile = url.String() + pathToFile = path.Clean(pathToFile) + getSpec, ok := resolvePath[pathToFile] + if !ok { + err1 := fmt.Errorf("path not found: %s", pathToFile) + return nil, err1 + } + return getSpec() + } + var specData []byte + specData, err = rawSpec() + if err != nil { + return + } + swagger, err = loader.LoadFromData(specData) + if err != nil { + return + } + return +} diff --git a/server/api/openapi_types.cfg.yaml b/server/api/openapi_types.cfg.yaml new file mode 100644 index 000000000..8cf96c5c9 --- /dev/null +++ b/server/api/openapi_types.cfg.yaml @@ -0,0 +1,4 @@ +package: api +generate: + models: true +output: openapi_types.gen.go diff --git a/server/api/openapi_types.gen.go b/server/api/openapi_types.gen.go new file mode 100644 index 000000000..7ae8e491f --- /dev/null +++ b/server/api/openapi_types.gen.go @@ -0,0 +1,579 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen version v1.12.5-0.20230513000919-14548c7e7bbe DO NOT EDIT. +package api + +import ( + "encoding/json" + "errors" + + "github.com/deepmap/oapi-codegen/pkg/runtime" +) + +const ( + BearerAuthScopes = "bearerAuth.Scopes" +) + +// Defines values for ArtistRole. +const ( + ArtistRoleAlbumArtist ArtistRole = "albumArtist" + ArtistRoleArtist ArtistRole = "artist" +) + +// Defines values for ResourceType. +const ( + ResourceTypeAlbum ResourceType = "album" + ResourceTypeArtist ResourceType = "artist" + ResourceTypeTrack ResourceType = "track" +) + +// Defines values for ServerInfoFeatures. +const ( + Albums ServerInfoFeatures = "albums" + Artists ServerInfoFeatures = "artists" + Images ServerInfoFeatures = "images" +) + +// Album defines model for Album. +type Album struct { + Attributes *AlbumAttributes `json:"attributes,omitempty"` + + // Id The unique identifier for the resource + Id string `json:"id"` + Relationships *struct { + Artists []AlbumArtistRelationship `json:"artists"` + Tracks []AlbumTrackRelationship `json:"tracks"` + } `json:"relationships,omitempty"` + + // Type The type of the resource + Type ResourceType `json:"type"` +} + +// AlbumArtistRelationship defines model for AlbumArtistRelationship. +type AlbumArtistRelationship struct { + Data ResourceObject `json:"data"` + Meta ArtistMetaObject `json:"meta"` +} + +// AlbumAttributes defines model for AlbumAttributes. +type AlbumAttributes struct { + // Artist The artist of the album + Artist string `json:"artist"` + + // Disctotal The number of discs in the album + Disctotal *int `json:"disctotal,omitempty"` + + // Genre The genre of the album + Genre *string `json:"genre,omitempty"` + + // ReleaseDate The release date of the album + ReleaseDate *string `json:"releaseDate,omitempty"` + + // Title The title of the album + Title string `json:"title"` + + // Tracktotal The number of tracks on the album + Tracktotal *int `json:"tracktotal,omitempty"` +} + +// AlbumTrackRelationship defines model for AlbumTrackRelationship. +type AlbumTrackRelationship struct { + Data ResourceObject `json:"data"` +} + +// Artist defines model for Artist. +type Artist struct { + Attributes *ArtistAttributes `json:"attributes,omitempty"` + + // Id The unique identifier for the resource + Id string `json:"id"` + Relationships *struct { + Albums *struct { + Data []ArtistAlbumRelationship `json:"data"` + } `json:"albums,omitempty"` + Tracks *struct { + Data []ArtistTrackRelationship `json:"data"` + } `json:"tracks,omitempty"` + } `json:"relationships,omitempty"` + + // Type The type of the resource + Type ResourceType `json:"type"` +} + +// ArtistAlbumRelationship defines model for ArtistAlbumRelationship. +type ArtistAlbumRelationship struct { + Data ResourceObject `json:"data"` + Meta ArtistMetaObject `json:"meta"` +} + +// ArtistAttributes defines model for ArtistAttributes. +type ArtistAttributes struct { + // Bio A short biography of the artist + Bio *string `json:"bio,omitempty"` + + // Name The name of the artist + Name string `json:"name"` +} + +// ArtistMetaObject defines model for ArtistMetaObject. +type ArtistMetaObject struct { + // Role The role of an artist in a track or album + Role ArtistRole `json:"role"` +} + +// ArtistRole The role of an artist in a track or album +type ArtistRole string + +// ArtistTrackRelationship defines model for ArtistTrackRelationship. +type ArtistTrackRelationship struct { + Data ResourceObject `json:"data"` + Meta struct { + // Role The role of an artist in a track or album + Role ArtistRole `json:"role"` + } `json:"meta"` +} + +// ErrorList defines model for ErrorList. +type ErrorList struct { + Errors []ErrorObject `json:"errors"` +} + +// ErrorObject defines model for ErrorObject. +type ErrorObject struct { + Detail *string `json:"detail,omitempty"` + Id *string `json:"id,omitempty"` + Status *string `json:"status,omitempty"` + Title *string `json:"title,omitempty"` +} + +// IncludedResource defines model for IncludedResource. +type IncludedResource struct { + union json.RawMessage +} + +// PaginationLinks defines model for PaginationLinks. +type PaginationLinks struct { + First *string `json:"first,omitempty"` + Last *string `json:"last,omitempty"` + Next *string `json:"next,omitempty"` + Prev *string `json:"prev,omitempty"` +} + +// PaginationMeta defines model for PaginationMeta. +type PaginationMeta struct { + // CurrentPage The current page in the collection + CurrentPage *int32 `json:"currentPage,omitempty"` + + // TotalItems The total number of items in the collection + TotalItems *int32 `json:"totalItems,omitempty"` + + // TotalPages The total number of pages in the collection + TotalPages *int32 `json:"totalPages,omitempty"` +} + +// ResourceObject defines model for ResourceObject. +type ResourceObject struct { + // Id The unique identifier for the resource + Id string `json:"id"` + + // Type The type of the resource + Type ResourceType `json:"type"` +} + +// ResourceType The type of the resource +type ResourceType string + +// ServerInfo defines model for ServerInfo. +type ServerInfo struct { + // AuthRequired Whether the user has access to the server. + AuthRequired bool `json:"authRequired"` + + // Features A list of optional features the server supports. + Features []ServerInfoFeatures `json:"features"` + + // Server The name of the server software. + Server string `json:"server"` + + // ServerVersion The version number of the server. + ServerVersion string `json:"serverVersion"` +} + +// ServerInfoFeatures defines model for ServerInfo.Features. +type ServerInfoFeatures string + +// Track defines model for Track. +type Track struct { + Attributes *TrackAttributes `json:"attributes,omitempty"` + + // Id The unique identifier for the resource + Id string `json:"id"` + Relationships *TrackRelationships `json:"relationships,omitempty"` + + // Type The type of the resource + Type ResourceType `json:"type"` +} + +// TrackArtistRelationship defines model for TrackArtistRelationship. +type TrackArtistRelationship struct { + Data ResourceObject `json:"data"` + Meta ArtistMetaObject `json:"meta"` +} + +// TrackAttributes defines model for TrackAttributes. +type TrackAttributes struct { + // Album The name of the album the track belongs to + Album string `json:"album"` + + // Albumartist The primary artist of the album the track belongs to. + Albumartist string `json:"albumartist"` + + // Artist The name of the artist who performed the track + Artist string `json:"artist"` + + // Bitrate The bitrate of the audio file in kilobits per second (kbps). + Bitrate int `json:"bitrate"` + + // Bpm The beats per minute (BPM) of the track. + Bpm *int `json:"bpm,omitempty"` + + // Channels The number of audio channels in the track. + Channels int `json:"channels"` + + // Comments Any additional comments or notes about the track. + Comments *string `json:"comments,omitempty"` + + // Disc The disc number within a multi-disc album. + Disc *int `json:"disc,omitempty"` + + // Duration The duration of the track in seconds + Duration float32 `json:"duration"` + + // Genre The genre of the track. + Genre *string `json:"genre,omitempty"` + + // Mimetype The MIME type of the audio file. + Mimetype string `json:"mimetype"` + + // RecordingMbid The MusicBrainz identifier for the recording of the track. + RecordingMbid *string `json:"recording-mbid,omitempty"` + + // Size The size of the audio file in bytes. + Size int `json:"size"` + + // Title The title of the track + Title string `json:"title"` + + // Track The track number within the album. + Track int `json:"track"` + + // TrackMbid The MusicBrainz identifier for the track. + TrackMbid *string `json:"track-mbid,omitempty"` + + // Year The release year of the track or album. + Year *int `json:"year,omitempty"` +} + +// TrackRelationships defines model for TrackRelationships. +type TrackRelationships struct { + Albums *[]AlbumTrackRelationship `json:"albums,omitempty"` + Artists []TrackArtistRelationship `json:"artists"` +} + +// FilterContains defines model for filterContains. +type FilterContains = []string + +// FilterEndsWith defines model for filterEndsWith. +type FilterEndsWith = []string + +// FilterEquals defines model for filterEquals. +type FilterEquals = []string + +// FilterGreaterOrEqual defines model for filterGreaterOrEqual. +type FilterGreaterOrEqual = []string + +// FilterGreaterThan defines model for filterGreaterThan. +type FilterGreaterThan = []string + +// FilterLessOrEqual defines model for filterLessOrEqual. +type FilterLessOrEqual = []string + +// FilterLessThan defines model for filterLessThan. +type FilterLessThan = []string + +// FilterStartsWith defines model for filterStartsWith. +type FilterStartsWith = []string + +// Include defines model for include. +type Include = string + +// IncludeForAlbum defines model for includeForAlbum. +type IncludeForAlbum = includeSlice + +// IncludeForAlbums defines model for includeForAlbums. +type IncludeForAlbums = includeSlice + +// IncludeForTracks defines model for includeForTracks. +type IncludeForTracks = includeSlice + +// PageLimit defines model for pageLimit. +type PageLimit = int32 + +// PageOffset defines model for pageOffset. +type PageOffset = int32 + +// Sort defines model for sort. +type Sort = string + +// GetAlbumsParams defines parameters for GetAlbums. +type GetAlbumsParams struct { + // PageLimit The number of items per page + PageLimit *PageLimit `form:"page[limit],omitempty" json:"page[limit],omitempty"` + + // PageOffset The offset for pagination + PageOffset *PageOffset `form:"page[offset],omitempty" json:"page[offset],omitempty"` + + // FilterEquals Filter by any property with an exact match. Usage: filter[equals]=property:value + FilterEquals *FilterEquals `form:"filter[equals],omitempty" json:"filter[equals],omitempty"` + + // FilterContains Filter by any property containing text. Usage: filter[contains]=property:value + FilterContains *FilterContains `form:"filter[contains],omitempty" json:"filter[contains],omitempty"` + + // FilterLessThan Filter by any numeric property less than a value. Usage: filter[lessThan]=property:value + FilterLessThan *FilterLessThan `form:"filter[lessThan],omitempty" json:"filter[lessThan],omitempty"` + + // FilterLessOrEqual Filter by any numeric property less than or equal to a value. Usage: filter[lessOrEqual]=property:value + FilterLessOrEqual *FilterLessOrEqual `form:"filter[lessOrEqual],omitempty" json:"filter[lessOrEqual],omitempty"` + + // FilterGreaterThan Filter by any numeric property greater than a value. Usage: filter[greaterThan]=property:value + FilterGreaterThan *FilterGreaterThan `form:"filter[greaterThan],omitempty" json:"filter[greaterThan],omitempty"` + + // FilterGreaterOrEqual Filter by any numeric property greater than or equal to a value. Usage: filter[greaterOrEqual]=property:value + FilterGreaterOrEqual *FilterGreaterOrEqual `form:"filter[greaterOrEqual],omitempty" json:"filter[greaterOrEqual],omitempty"` + + // FilterStartsWith Filter by any property that starts with text. Usage: filter[startsWith]=property:value + FilterStartsWith *FilterStartsWith `form:"filter[startsWith],omitempty" json:"filter[startsWith],omitempty"` + + // FilterEndsWith Filter by any property that ends with text. Usage: filter[endsWith]=property:value + FilterEndsWith *FilterEndsWith `form:"filter[endsWith],omitempty" json:"filter[endsWith],omitempty"` + + // Sort Sort the results by one or more properties, separated by commas. Prefix the property with '-' for descending order. + Sort *Sort `form:"sort,omitempty" json:"sort,omitempty"` + + // Include Related resources to include in the response, separated by commas + Include *IncludeForAlbums `form:"include,omitempty" json:"include,omitempty"` +} + +// GetAlbumParams defines parameters for GetAlbum. +type GetAlbumParams struct { + // Include Related resources to include in the response, separated by commas + Include *IncludeForAlbum `form:"include,omitempty" json:"include,omitempty"` +} + +// GetArtistsParams defines parameters for GetArtists. +type GetArtistsParams struct { + // PageLimit The number of items per page + PageLimit *PageLimit `form:"page[limit],omitempty" json:"page[limit],omitempty"` + + // PageOffset The offset for pagination + PageOffset *PageOffset `form:"page[offset],omitempty" json:"page[offset],omitempty"` + + // FilterEquals Filter by any property with an exact match. Usage: filter[equals]=property:value + FilterEquals *FilterEquals `form:"filter[equals],omitempty" json:"filter[equals],omitempty"` + + // FilterContains Filter by any property containing text. Usage: filter[contains]=property:value + FilterContains *FilterContains `form:"filter[contains],omitempty" json:"filter[contains],omitempty"` + + // FilterLessThan Filter by any numeric property less than a value. Usage: filter[lessThan]=property:value + FilterLessThan *FilterLessThan `form:"filter[lessThan],omitempty" json:"filter[lessThan],omitempty"` + + // FilterLessOrEqual Filter by any numeric property less than or equal to a value. Usage: filter[lessOrEqual]=property:value + FilterLessOrEqual *FilterLessOrEqual `form:"filter[lessOrEqual],omitempty" json:"filter[lessOrEqual],omitempty"` + + // FilterGreaterThan Filter by any numeric property greater than a value. Usage: filter[greaterThan]=property:value + FilterGreaterThan *FilterGreaterThan `form:"filter[greaterThan],omitempty" json:"filter[greaterThan],omitempty"` + + // FilterGreaterOrEqual Filter by any numeric property greater than or equal to a value. Usage: filter[greaterOrEqual]=property:value + FilterGreaterOrEqual *FilterGreaterOrEqual `form:"filter[greaterOrEqual],omitempty" json:"filter[greaterOrEqual],omitempty"` + + // FilterStartsWith Filter by any property that starts with text. Usage: filter[startsWith]=property:value + FilterStartsWith *FilterStartsWith `form:"filter[startsWith],omitempty" json:"filter[startsWith],omitempty"` + + // FilterEndsWith Filter by any property that ends with text. Usage: filter[endsWith]=property:value + FilterEndsWith *FilterEndsWith `form:"filter[endsWith],omitempty" json:"filter[endsWith],omitempty"` + + // Sort Sort the results by one or more properties, separated by commas. Prefix the property with '-' for descending order. + Sort *Sort `form:"sort,omitempty" json:"sort,omitempty"` + + // Include Related resources to include in the response, separated by commas + Include *Include `form:"include,omitempty" json:"include,omitempty"` +} + +// GetArtistParams defines parameters for GetArtist. +type GetArtistParams struct { + // Include Related resources to include in the response, separated by commas + Include *Include `form:"include,omitempty" json:"include,omitempty"` +} + +// GetTracksParams defines parameters for GetTracks. +type GetTracksParams struct { + // PageLimit The number of items per page + PageLimit *PageLimit `form:"page[limit],omitempty" json:"page[limit],omitempty"` + + // PageOffset The offset for pagination + PageOffset *PageOffset `form:"page[offset],omitempty" json:"page[offset],omitempty"` + + // FilterEquals Filter by any property with an exact match. Usage: filter[equals]=property:value + FilterEquals *FilterEquals `form:"filter[equals],omitempty" json:"filter[equals],omitempty"` + + // FilterContains Filter by any property containing text. Usage: filter[contains]=property:value + FilterContains *FilterContains `form:"filter[contains],omitempty" json:"filter[contains],omitempty"` + + // FilterLessThan Filter by any numeric property less than a value. Usage: filter[lessThan]=property:value + FilterLessThan *FilterLessThan `form:"filter[lessThan],omitempty" json:"filter[lessThan],omitempty"` + + // FilterLessOrEqual Filter by any numeric property less than or equal to a value. Usage: filter[lessOrEqual]=property:value + FilterLessOrEqual *FilterLessOrEqual `form:"filter[lessOrEqual],omitempty" json:"filter[lessOrEqual],omitempty"` + + // FilterGreaterThan Filter by any numeric property greater than a value. Usage: filter[greaterThan]=property:value + FilterGreaterThan *FilterGreaterThan `form:"filter[greaterThan],omitempty" json:"filter[greaterThan],omitempty"` + + // FilterGreaterOrEqual Filter by any numeric property greater than or equal to a value. Usage: filter[greaterOrEqual]=property:value + FilterGreaterOrEqual *FilterGreaterOrEqual `form:"filter[greaterOrEqual],omitempty" json:"filter[greaterOrEqual],omitempty"` + + // FilterStartsWith Filter by any property that starts with text. Usage: filter[startsWith]=property:value + FilterStartsWith *FilterStartsWith `form:"filter[startsWith],omitempty" json:"filter[startsWith],omitempty"` + + // FilterEndsWith Filter by any property that ends with text. Usage: filter[endsWith]=property:value + FilterEndsWith *FilterEndsWith `form:"filter[endsWith],omitempty" json:"filter[endsWith],omitempty"` + + // Sort Sort the results by one or more properties, separated by commas. Prefix the property with '-' for descending order. + Sort *Sort `form:"sort,omitempty" json:"sort,omitempty"` + + // Include Related resources to include in the response, separated by commas + Include *IncludeForTracks `form:"include,omitempty" json:"include,omitempty"` +} + +// GetTrackParams defines parameters for GetTrack. +type GetTrackParams struct { + // Include Related resources to include in the response, separated by commas + Include *IncludeForTracks `form:"include,omitempty" json:"include,omitempty"` +} + +// AsTrack returns the union data inside the IncludedResource as a Track +func (t IncludedResource) AsTrack() (Track, error) { + var body Track + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromTrack overwrites any union data inside the IncludedResource as the provided Track +func (t *IncludedResource) FromTrack(v Track) error { + v.Type = "track" + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeTrack performs a merge with any union data inside the IncludedResource, using the provided Track +func (t *IncludedResource) MergeTrack(v Track) error { + v.Type = "track" + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JsonMerge(t.union, b) + t.union = merged + return err +} + +// AsAlbum returns the union data inside the IncludedResource as a Album +func (t IncludedResource) AsAlbum() (Album, error) { + var body Album + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromAlbum overwrites any union data inside the IncludedResource as the provided Album +func (t *IncludedResource) FromAlbum(v Album) error { + v.Type = "album" + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeAlbum performs a merge with any union data inside the IncludedResource, using the provided Album +func (t *IncludedResource) MergeAlbum(v Album) error { + v.Type = "album" + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JsonMerge(t.union, b) + t.union = merged + return err +} + +// AsArtist returns the union data inside the IncludedResource as a Artist +func (t IncludedResource) AsArtist() (Artist, error) { + var body Artist + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromArtist overwrites any union data inside the IncludedResource as the provided Artist +func (t *IncludedResource) FromArtist(v Artist) error { + v.Type = "artist" + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeArtist performs a merge with any union data inside the IncludedResource, using the provided Artist +func (t *IncludedResource) MergeArtist(v Artist) error { + v.Type = "artist" + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JsonMerge(t.union, b) + t.union = merged + return err +} + +func (t IncludedResource) Discriminator() (string, error) { + var discriminator struct { + Discriminator string `json:"type"` + } + err := json.Unmarshal(t.union, &discriminator) + return discriminator.Discriminator, err +} + +func (t IncludedResource) ValueByDiscriminator() (interface{}, error) { + discriminator, err := t.Discriminator() + if err != nil { + return nil, err + } + switch discriminator { + case "album": + return t.AsAlbum() + case "artist": + return t.AsArtist() + case "track": + return t.AsTrack() + default: + return nil, errors.New("unknown discriminator value: " + discriminator) + } +} + +func (t IncludedResource) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *IncludedResource) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} diff --git a/server/apiv1/api.go b/server/apiv1/api.go deleted file mode 100644 index a75de3d8d..000000000 --- a/server/apiv1/api.go +++ /dev/null @@ -1,98 +0,0 @@ -package apiv1 - -import ( - "errors" - "net/http" - "runtime/debug" - "slices" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/api" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -type Router struct { - http.Handler - ds model.DataStore -} - -func New(ds model.DataStore) *Router { - rt := &Router{ds: ds} - rt.Handler = rt.routes() - return rt -} - -func (rt *Router) routes() http.Handler { - r := chi.NewRouter() - r.Use(problemRecoverer, headAsGet(r)) - r.NotFound(func(w http.ResponseWriter, req *http.Request) { - writeProblemStatus(w, req, http.StatusNotFound, ProblemCodeNotFound, "no such endpoint") - }) - r.MethodNotAllowed(func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Allow", strings.Join(allowedMethods(r, req), ", ")) - writeProblemStatus(w, req, http.StatusMethodNotAllowed, ProblemCodeMethodNotAllowed, "") - }) - - r.Get("/openapi.json", specHandler(withBasePath(api.SpecJSON(), `"url": `, true), "application/json")) - r.Get("/openapi.yaml", specHandler(withBasePath(api.SpecYAML(), "url: ", false), "application/yaml")) - - strict := NewStrictHandlerWithOptions(rt, nil, StrictHTTPServerOptions{ - RequestErrorHandlerFunc: func(w http.ResponseWriter, req *http.Request, err error) { - writeProblemStatus(w, req, http.StatusBadRequest, "validation", err.Error()) - }, - ResponseErrorHandlerFunc: writeProblem, - }) - HandlerWithOptions(strict, ChiServerOptions{BaseRouter: r, ErrorHandlerFunc: bindingErrorHandler}) - return r -} - -func problemRecoverer(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer func() { - rec := recover() - if rec == nil { - return - } - if err, ok := rec.(error); ok && errors.Is(err, http.ErrAbortHandler) { - panic(rec) - } - log.Error(r.Context(), "API v1: panic in handler", "panic", rec, "stack", string(debug.Stack())) - writeProblemStatus(w, r, http.StatusInternalServerError, ProblemCodeInternal, "") - }() - next.ServeHTTP(w, r) - }) -} - -var routableMethods = []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} - -// Looks routes up on mux itself: chi's RouteContext().Routes points at the parent router when mounted. -func allowedMethods(mux chi.Routes, req *http.Request) []string { - path := routePath(req) - var allowed []string - for _, m := range routableMethods { - if mux.Match(chi.NewRouteContext(), m, path) || (m == http.MethodHead && slices.Contains(allowed, http.MethodGet)) { - allowed = append(allowed, m) - } - } - return allowed -} - -func headAsGet(mux chi.Routes) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if req.Method == http.MethodHead && !mux.Match(chi.NewRouteContext(), http.MethodHead, routePath(req)) { - chi.RouteContext(req.Context()).RouteMethod = http.MethodGet - } - next.ServeHTTP(w, req) - }) - } -} - -func routePath(req *http.Request) string { - if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" { - return rctx.RoutePath - } - return req.URL.Path -} diff --git a/server/apiv1/api_gen.go b/server/apiv1/api_gen.go deleted file mode 100644 index 4bffd47fc..000000000 --- a/server/apiv1/api_gen.go +++ /dev/null @@ -1,390 +0,0 @@ -// Package apiv1 provides primitives to interact with the openapi HTTP API. -// -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.8.0 DO NOT EDIT. -package apiv1 - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - - "github.com/go-chi/chi/v5" -) - -// Defines values for ProblemCode. -const ( - ProblemCodeForbidden ProblemCode = "forbidden" - ProblemCodeInternal ProblemCode = "internal" - ProblemCodeMethodNotAllowed ProblemCode = "method_not_allowed" - ProblemCodeNotFound ProblemCode = "not_found" - ProblemCodeUnauthorized ProblemCode = "unauthorized" - ProblemCodeUnavailable ProblemCode = "unavailable" - ProblemCodeValidation ProblemCode = "validation" -) - -// Valid indicates whether the value is a known member of the ProblemCode enum. -func (e ProblemCode) Valid() bool { - switch e { - case ProblemCodeForbidden: - return true - case ProblemCodeInternal: - return true - case ProblemCodeMethodNotAllowed: - return true - case ProblemCodeNotFound: - return true - case ProblemCodeUnauthorized: - return true - case ProblemCodeUnavailable: - return true - case ProblemCodeValidation: - return true - default: - return false - } -} - -// Defines values for ServerInfoLoginMethods. -const ( - ServerInfoLoginMethodsPassword ServerInfoLoginMethods = "password" -) - -// Valid indicates whether the value is a known member of the ServerInfoLoginMethods enum. -func (e ServerInfoLoginMethods) Valid() bool { - switch e { - case ServerInfoLoginMethodsPassword: - return true - default: - return false - } -} - -// Problem RFC 9457 problem details, returned for every 4xx and 5xx response. -type Problem struct { - // Code Machine-readable error code, and the value clients switch on. New codes may be added. - Code ProblemCode `json:"code"` - - // Detail Human-readable explanation specific to this occurrence. Omitted for internal errors. - Detail *string `json:"detail,omitempty"` - - // Errors Per-field failures. Present only when `code` is `validation`. - Errors *[]ValidationError `json:"errors,omitempty"` - - // Status HTTP status code of this response. - Status int `json:"status"` - - // Title Short human-readable summary, the same for all occurrences of this problem type. - Title string `json:"title"` - - // Type URI reference identifying the problem type. Omitted while the problem carries no semantics - // beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their - // own semantics get their own URI; switch on `code` instead. - Type *string `json:"type,omitempty"` -} - -// ProblemCode Machine-readable error code, and the value clients switch on. New codes may be added. -type ProblemCode string - -// ServerInfo Public server description. Everything an add-server screen needs before login. -type ServerInfo struct { - // LoginMethods Login methods this server accepts. New methods may be added; clients ignore values they do not recognise. - LoginMethods []ServerInfoLoginMethods `json:"loginMethods"` - - // Name Human-readable server product name. - Name string `json:"name"` - - // ServerVersion Version of the running server build. - ServerVersion string `json:"serverVersion"` - - // SetupRequired True until the first admin user has been created. - SetupRequired bool `json:"setupRequired"` - - // SpecVersion Version of the OpenAPI document this server implements. - SpecVersion string `json:"specVersion"` -} - -// ServerInfoLoginMethods defines model for ServerInfo.LoginMethods. -type ServerInfoLoginMethods string - -// ValidationError One field-level validation failure. -type ValidationError struct { - // Field Name of the offending query parameter, path parameter, or body field (dotted for nested). - Field string `json:"field"` - - // Message Why the value was rejected. - Message string `json:"message"` -} - -// InternalError RFC 9457 problem details, returned for every 4xx and 5xx response. -type InternalError = Problem - -// ServerInterface represents all server handlers. -type ServerInterface interface { - // GetServerInfo Describe the server - // (GET /server) - GetServerInfo(w http.ResponseWriter, r *http.Request) -} - -// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. - -type Unimplemented struct{} - -// GetServerInfo Describe the server -// (GET /server) -func (_ Unimplemented) GetServerInfo(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) -} - -// ServerInterfaceWrapper converts contexts to parameters. -type ServerInterfaceWrapper struct { - Handler ServerInterface - HandlerMiddlewares []MiddlewareFunc - ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) -} - -type MiddlewareFunc func(http.Handler) http.Handler - -// GetServerInfo operation middleware -func (siw *ServerInterfaceWrapper) GetServerInfo(w http.ResponseWriter, r *http.Request) { - - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.GetServerInfo(w, r) - })) - - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) - } - - handler.ServeHTTP(w, r) -} - -type UnescapedCookieParamError struct { - ParamName string - Err error -} - -func (e *UnescapedCookieParamError) Error() string { - return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) -} - -func (e *UnescapedCookieParamError) Unwrap() error { - return e.Err -} - -type UnmarshalingParamError struct { - ParamName string - Err error -} - -func (e *UnmarshalingParamError) Error() string { - return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) -} - -func (e *UnmarshalingParamError) Unwrap() error { - return e.Err -} - -type RequiredParamError struct { - ParamName string -} - -func (e *RequiredParamError) Error() string { - return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) -} - -type RequiredHeaderError struct { - ParamName string - Err error -} - -func (e *RequiredHeaderError) Error() string { - return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) -} - -func (e *RequiredHeaderError) Unwrap() error { - return e.Err -} - -type InvalidParamFormatError struct { - ParamName string - Err error -} - -func (e *InvalidParamFormatError) Error() string { - return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) -} - -func (e *InvalidParamFormatError) Unwrap() error { - return e.Err -} - -type TooManyValuesForParamError struct { - ParamName string - Count int -} - -func (e *TooManyValuesForParamError) Error() string { - return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) -} - -// Handler creates http.Handler with routing matching OpenAPI spec. -func Handler(si ServerInterface) http.Handler { - return HandlerWithOptions(si, ChiServerOptions{}) -} - -type ChiServerOptions struct { - BaseURL string - BaseRouter chi.Router - Middlewares []MiddlewareFunc - ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) -} - -// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. -func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { - return HandlerWithOptions(si, ChiServerOptions{ - BaseRouter: r, - }) -} - -func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { - return HandlerWithOptions(si, ChiServerOptions{ - BaseURL: baseURL, - BaseRouter: r, - }) -} - -// HandlerWithOptions creates http.Handler with additional options -func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { - r := options.BaseRouter - - if r == nil { - r = chi.NewRouter() - } - if options.ErrorHandlerFunc == nil { - options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { - http.Error(w, err.Error(), http.StatusBadRequest) - } - } - wrapper := ServerInterfaceWrapper{ - Handler: si, - HandlerMiddlewares: options.Middlewares, - ErrorHandlerFunc: options.ErrorHandlerFunc, - } - - r.Group(func(r chi.Router) { - r.Get(options.BaseURL+"/server", wrapper.GetServerInfo) - }) - - return r -} - -type InternalErrorApplicationProblemPlusJSONResponse Problem - -type GetServerInfoRequestObject struct { -} - -type GetServerInfoResponseObject interface { - VisitGetServerInfoResponse(w http.ResponseWriter) error -} - -type GetServerInfo200JSONResponse ServerInfo - -func (response GetServerInfo200JSONResponse) VisitGetServerInfoResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - _, err := buf.WriteTo(w) - return err -} - -type GetServerInfo500ApplicationProblemPlusJSONResponse struct { - InternalErrorApplicationProblemPlusJSONResponse -} - -func (response GetServerInfo500ApplicationProblemPlusJSONResponse) VisitGetServerInfoResponse(w http.ResponseWriter) error { - - var buf bytes.Buffer - if err := json.NewEncoder(&buf).Encode(response); err != nil { - return err - } - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(500) - _, err := buf.WriteTo(w) - return err -} - -// StrictServerInterface represents all server handlers. -type StrictServerInterface interface { - // GetServerInfo Describe the server - // (GET /server) - GetServerInfo(ctx context.Context, request GetServerInfoRequestObject) (GetServerInfoResponseObject, error) -} - -type StrictHandlerFunc func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error) -type StrictMiddlewareFunc func(f StrictHandlerFunc, operationID string) StrictHandlerFunc - -type StrictHTTPServerOptions struct { - RequestErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) - ResponseErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) -} - -func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { - return &strictHandler{ssi: ssi, middlewares: middlewares, options: StrictHTTPServerOptions{ - RequestErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { - http.Error(w, err.Error(), http.StatusBadRequest) - }, - ResponseErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { - http.Error(w, err.Error(), http.StatusInternalServerError) - }, - }} -} - -func NewStrictHandlerWithOptions(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc, options StrictHTTPServerOptions) ServerInterface { - if options.RequestErrorHandlerFunc == nil { - options.RequestErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { - http.Error(w, err.Error(), http.StatusBadRequest) - } - } - if options.ResponseErrorHandlerFunc == nil { - options.ResponseErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - } - return &strictHandler{ssi: ssi, middlewares: middlewares, options: options} -} - -type strictHandler struct { - ssi StrictServerInterface - middlewares []StrictMiddlewareFunc - options StrictHTTPServerOptions -} - -// GetServerInfo operation middleware -func (sh *strictHandler) GetServerInfo(w http.ResponseWriter, r *http.Request) { - var request GetServerInfoRequestObject - - handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { - return sh.ssi.GetServerInfo(ctx, request.(GetServerInfoRequestObject)) - } - for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetServerInfo") - } - - response, err := handler(r.Context(), w, r, request) - - if err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } else if validResponse, ok := response.(GetServerInfoResponseObject); ok { - if err := validResponse.VisitGetServerInfoResponse(w); err != nil { - sh.options.ResponseErrorHandlerFunc(w, r, err) - } - } else if response != nil { - sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) - } -} diff --git a/server/apiv1/api_test.go b/server/apiv1/api_test.go deleted file mode 100644 index 2552e40b0..000000000 --- a/server/apiv1/api_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package apiv1 - -import ( - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Router", func() { - var router *Router - - BeforeEach(func() { - router = New(&tests.MockDataStore{}) - }) - - It("returns a 404 problem for unknown paths", func() { - w := serve(router, httptest.NewRequest(http.MethodGet, "/api/v1/nope", nil)) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(w.Header().Get("Content-Type")).To(Equal(problemContentType)) - Expect(decodeProblem(w).Code).To(Equal(ProblemCodeNotFound)) - }) - - It("returns a 405 problem listing the allowed methods for a wrong method on a known path", func() { - w := serve(router, httptest.NewRequest(http.MethodPost, "/api/v1/server", nil)) - Expect(w.Code).To(Equal(http.StatusMethodNotAllowed)) - Expect(w.Header().Get("Allow")).To(Equal("GET, HEAD")) - Expect(decodeProblem(w).Code).To(Equal(ProblemCodeMethodNotAllowed)) - }) - - DescribeTable("answers HEAD wherever GET is routed", - func(path, contentType string) { - w := serve(router, httptest.NewRequest(http.MethodHead, path, nil)) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal(contentType)) - }, - Entry("server info", "/api/v1/server", "application/json"), - Entry("JSON spec", "/api/v1/openapi.json", "application/json"), - Entry("YAML spec", "/api/v1/openapi.yaml", "application/yaml"), - ) - - It("revalidates HEAD requests with If-None-Match", func() { - etag := serve(router, httptest.NewRequest(http.MethodHead, "/api/v1/openapi.json", nil)).Header().Get("ETag") - req := httptest.NewRequest(http.MethodHead, "/api/v1/openapi.json", nil) - req.Header.Set("If-None-Match", etag) - Expect(serve(router, req).Code).To(Equal(http.StatusNotModified)) - }) - - It("returns a 404 problem for HEAD on unknown paths", func() { - w := serve(router, httptest.NewRequest(http.MethodHead, "/api/v1/nope", nil)) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(w.Header().Get("Allow")).To(BeEmpty()) - }) - - panicking := func(v any) http.Handler { - return problemRecoverer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic(v) })) - } - - It("turns a handler panic into a 500 problem", func() { - w := httptest.NewRecorder() - panicking("kaboom").ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/boom", nil)) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - p := decodeProblem(w) - Expect(p.Code).To(Equal(ProblemCodeInternal)) - Expect(p.Detail).To(BeNil()) - }) - - It("re-panics http.ErrAbortHandler so the server can drop the connection", func() { - Expect(func() { - panicking(http.ErrAbortHandler).ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/abort", nil)) - }).To(PanicWith(http.ErrAbortHandler)) - }) -}) diff --git a/server/apiv1/apiv1_suite_test.go b/server/apiv1/apiv1_suite_test.go deleted file mode 100644 index f89244e38..000000000 --- a/server/apiv1/apiv1_suite_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package apiv1 - -import ( - "bytes" - "errors" - "io" - "net/http" - "net/http/httptest" - "testing" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/getkin/kin-openapi/openapi3filter" - "github.com/getkin/kin-openapi/routers" - "github.com/getkin/kin-openapi/routers/gorillamux" - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/api" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestAPIv1(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "API v1 Suite") -} - -var specRouter routers.Router - -var _ = BeforeSuite(func() { - doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON()) - Expect(err).ToNot(HaveOccurred()) - specRouter, err = gorillamux.NewRouter(doc) - Expect(err).ToNot(HaveOccurred()) -}) - -// serve routes req through h mounted at /api/v1 and asserts the response conforms to the spec. -func serve(h http.Handler, req *http.Request) *httptest.ResponseRecorder { - root := chi.NewRouter() - root.Mount("/api/v1", h) - w := httptest.NewRecorder() - root.ServeHTTP(w, req) - validateAgainstSpec(req, w) - return w -} - -func validateAgainstSpec(req *http.Request, w *httptest.ResponseRecorder) { - route, pathParams, err := specRouter.FindRoute(req) - if errors.Is(err, routers.ErrPathNotFound) || errors.Is(err, routers.ErrMethodNotAllowed) { - return - } - ExpectWithOffset(2, err).ToNot(HaveOccurred()) - input := &openapi3filter.ResponseValidationInput{ - RequestValidationInput: &openapi3filter.RequestValidationInput{ - Request: req, PathParams: pathParams, Route: route, - }, - Status: w.Code, - Header: w.Header(), - Body: io.NopCloser(bytes.NewReader(w.Body.Bytes())), - Options: &openapi3filter.Options{IncludeResponseStatus: true}, - } - ExpectWithOffset(2, openapi3filter.ValidateResponse(req.Context(), input)).To(Succeed(), - "response for %s %s does not conform to the spec", req.Method, req.URL.Path) -} diff --git a/server/apiv1/oapi-codegen.yaml b/server/apiv1/oapi-codegen.yaml deleted file mode 100644 index b9236de1a..000000000 --- a/server/apiv1/oapi-codegen.yaml +++ /dev/null @@ -1,12 +0,0 @@ -package: apiv1 -output: server/apiv1/api_gen.go -generate: - chi-server: true - strict-server: true - models: true -output-options: - exclude-operation-ids: - - getOpenAPISpecJSON - - getOpenAPISpecYAML -compatibility: - always-prefix-enum-values: true diff --git a/server/apiv1/problem.go b/server/apiv1/problem.go deleted file mode 100644 index aa6389357..000000000 --- a/server/apiv1/problem.go +++ /dev/null @@ -1,72 +0,0 @@ -package apiv1 - -import ( - "encoding/json" - "errors" - "net/http" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -const problemContentType = "application/problem+json" - -func writeProblem(w http.ResponseWriter, r *http.Request, err error) { - status, code := classifyError(err) - detail := err.Error() - if status == http.StatusInternalServerError { - log.Error(r.Context(), "API v1: unexpected error", "path", r.URL.Path, err) - detail = "" - } - writeProblemStatus(w, r, status, code, detail) -} - -func classifyError(err error) (int, ProblemCode) { - switch { - case errors.Is(err, model.ErrNotFound): - return http.StatusNotFound, ProblemCodeNotFound - case errors.Is(err, model.ErrNotAuthorized): - return http.StatusForbidden, ProblemCodeForbidden - case errors.Is(err, model.ErrInvalidAuth), errors.Is(err, model.ErrExpired): - return http.StatusUnauthorized, ProblemCodeUnauthorized - case errors.Is(err, model.ErrValidation): - return http.StatusBadRequest, ProblemCodeValidation - case errors.Is(err, model.ErrNotAvailable): - return http.StatusServiceUnavailable, ProblemCodeUnavailable - } - return http.StatusInternalServerError, ProblemCodeInternal -} - -func writeProblemStatus(w http.ResponseWriter, r *http.Request, status int, code ProblemCode, detail string, fieldErrors ...ValidationError) { - p := Problem{Title: http.StatusText(status), Status: status, Code: code} - if detail != "" { - p.Detail = &detail - } - if len(fieldErrors) > 0 { - p.Errors = &fieldErrors - } - w.Header().Set("Content-Type", problemContentType) - w.WriteHeader(status) - if err := json.NewEncoder(w).Encode(p); err != nil { - log.Warn(r.Context(), "API v1: could not write problem response", err) - } -} - -func bindingErrorHandler(w http.ResponseWriter, r *http.Request, err error) { - var fieldErrors []ValidationError - var required *RequiredParamError - var invalid *InvalidParamFormatError - var tooMany *TooManyValuesForParamError - var unmarshal *UnmarshalingParamError - switch { - case errors.As(err, &required): - fieldErrors = append(fieldErrors, ValidationError{Field: required.ParamName, Message: "is required"}) - case errors.As(err, &invalid): - fieldErrors = append(fieldErrors, ValidationError{Field: invalid.ParamName, Message: invalid.Err.Error()}) - case errors.As(err, &tooMany): - fieldErrors = append(fieldErrors, ValidationError{Field: tooMany.ParamName, Message: "expected a single value"}) - case errors.As(err, &unmarshal): - fieldErrors = append(fieldErrors, ValidationError{Field: unmarshal.ParamName, Message: unmarshal.Err.Error()}) - } - writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, err.Error(), fieldErrors...) -} diff --git a/server/apiv1/problem_test.go b/server/apiv1/problem_test.go deleted file mode 100644 index 256296b3c..000000000 --- a/server/apiv1/problem_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package apiv1 - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func decodeProblem(w *httptest.ResponseRecorder) Problem { - var p Problem - ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), &p)).To(Succeed()) - return p -} - -var _ = Describe("problem", func() { - var w *httptest.ResponseRecorder - var r *http.Request - - BeforeEach(func() { - w = httptest.NewRecorder() - r = httptest.NewRequest(http.MethodGet, "/api/v1/server", nil) - }) - - Describe("writeProblem", func() { - DescribeTable("maps domain errors to status and code", - func(err error, status int, code ProblemCode) { - writeProblem(w, r, err) - Expect(w.Code).To(Equal(status)) - Expect(w.Header().Get("Content-Type")).To(Equal(problemContentType)) - p := decodeProblem(w) - Expect(p.Status).To(Equal(status)) - Expect(p.Code).To(Equal(code)) - Expect(p.Title).To(Equal(http.StatusText(status))) - Expect(p.Type).To(BeNil()) - Expect(w.Body.String()).ToNot(ContainSubstring(`"type"`)) - }, - Entry("not found", model.ErrNotFound, http.StatusNotFound, ProblemCodeNotFound), - Entry("not authorized", model.ErrNotAuthorized, http.StatusForbidden, ProblemCodeForbidden), - Entry("invalid auth", model.ErrInvalidAuth, http.StatusUnauthorized, ProblemCodeUnauthorized), - Entry("expired", model.ErrExpired, http.StatusUnauthorized, ProblemCodeUnauthorized), - Entry("validation", model.ErrValidation, http.StatusBadRequest, ProblemCodeValidation), - Entry("not available", model.ErrNotAvailable, http.StatusServiceUnavailable, ProblemCodeUnavailable), - Entry("unknown", errors.New("boom"), http.StatusInternalServerError, ProblemCodeInternal), - ) - - DescribeTable("keeps the wrapping context as detail for client errors", - func(err error) { - writeProblem(w, r, err) - p := decodeProblem(w) - Expect(p.Status).To(Equal(http.StatusNotFound)) - Expect(p.Detail).ToNot(BeNil()) - Expect(*p.Detail).To(ContainSubstring("album 123")) - }, - Entry("fmt.Errorf %w", fmt.Errorf("album 123: %w", model.ErrNotFound)), - Entry("errors.Join", errors.Join(errors.New("album 123"), model.ErrNotFound)), - ) - - It("hides details for internal errors", func() { - writeProblem(w, r, errors.New("db password is hunter2")) - p := decodeProblem(w) - Expect(p.Detail).To(BeNil()) - }) - }) - - Describe("writeProblemStatus", func() { - It("writes field errors only when provided", func() { - writeProblemStatus(w, r, http.StatusBadRequest, ProblemCodeValidation, "bad input", - ValidationError{Field: "limit", Message: "must be <= 2000"}) - p := decodeProblem(w) - Expect(p.Errors).ToNot(BeNil()) - Expect(*p.Errors).To(HaveLen(1)) - Expect((*p.Errors)[0].Field).To(Equal("limit")) - }) - - It("omits detail when empty", func() { - writeProblemStatus(w, r, http.StatusMethodNotAllowed, ProblemCodeMethodNotAllowed, "") - Expect(w.Body.String()).ToNot(ContainSubstring(`"detail"`)) - Expect(w.Body.String()).ToNot(ContainSubstring(`"errors"`)) - }) - }) - - Describe("bindingErrorHandler", func() { - DescribeTable("maps parameter binding errors to a validation problem with the field", - func(err error, field, message string) { - bindingErrorHandler(w, r, err) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - p := decodeProblem(w) - Expect(p.Code).To(Equal(ProblemCodeValidation)) - Expect(p.Errors).ToNot(BeNil()) - Expect(*p.Errors).To(HaveLen(1)) - Expect((*p.Errors)[0].Field).To(Equal(field)) - Expect((*p.Errors)[0].Message).To(ContainSubstring(message)) - }, - Entry("required", &RequiredParamError{ParamName: "limit"}, "limit", "is required"), - Entry("invalid format", &InvalidParamFormatError{ParamName: "offset", Err: errors.New("not a number")}, "offset", "not a number"), - Entry("too many values", &TooManyValuesForParamError{ParamName: "sort", Count: 2}, "sort", "single value"), - Entry("unmarshaling", &UnmarshalingParamError{ParamName: "ids", Err: errors.New("bad json")}, "ids", "bad json"), - ) - - It("still returns a validation problem for unknown binding errors", func() { - bindingErrorHandler(w, r, errors.New("weird")) - p := decodeProblem(w) - Expect(p.Status).To(Equal(http.StatusBadRequest)) - Expect(p.Code).To(Equal(ProblemCodeValidation)) - Expect(p.Errors).To(BeNil()) - }) - }) -}) diff --git a/server/apiv1/server_info.go b/server/apiv1/server_info.go deleted file mode 100644 index 458363efe..000000000 --- a/server/apiv1/server_info.go +++ /dev/null @@ -1,23 +0,0 @@ -package apiv1 - -import ( - "context" - "fmt" - - "github.com/navidrome/navidrome/api" - "github.com/navidrome/navidrome/consts" -) - -func (rt *Router) GetServerInfo(ctx context.Context, _ GetServerInfoRequestObject) (GetServerInfoResponseObject, error) { - count, err := rt.ds.User().CountAll(ctx) - if err != nil { - return nil, fmt.Errorf("counting users: %w", err) - } - return GetServerInfo200JSONResponse{ - Name: "Navidrome", - ServerVersion: consts.Version, - SpecVersion: api.SpecVersion(), - SetupRequired: count == 0, - LoginMethods: []ServerInfoLoginMethods{ServerInfoLoginMethodsPassword}, - }, nil -} diff --git a/server/apiv1/server_info_test.go b/server/apiv1/server_info_test.go deleted file mode 100644 index b9dae6f8d..000000000 --- a/server/apiv1/server_info_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package apiv1 - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/api" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("GET /server", func() { - var ctx context.Context - var ds *tests.MockDataStore - var users *tests.MockedUserRepo - - BeforeEach(func() { - ctx = GinkgoT().Context() - users = tests.CreateMockUserRepo() - ds = &tests.MockDataStore{MockedUser: users} - }) - - get := func() (*httptest.ResponseRecorder, ServerInfo) { - w := serve(New(ds), httptest.NewRequest(http.MethodGet, "/api/v1/server", nil)) - var info ServerInfo - if w.Code == http.StatusOK { - ExpectWithOffset(1, json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) - } - return w, info - } - - It("describes the server with setupRequired when there are no users", func() { - w, info := get() - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(HavePrefix("application/json")) - Expect(info.Name).To(Equal("Navidrome")) - Expect(info.ServerVersion).To(Equal(consts.Version)) - Expect(info.SpecVersion).To(Equal(api.SpecVersion())) - Expect(info.SetupRequired).To(BeTrue()) - Expect(info.LoginMethods).To(ConsistOf(ServerInfoLoginMethodsPassword)) - }) - - It("reports setupRequired=false once a user exists", func() { - Expect(users.Put(ctx, &model.User{ID: "u1", UserName: "admin", IsAdmin: true})).To(Succeed()) - _, info := get() - Expect(info.SetupRequired).To(BeFalse()) - }) - - It("returns a 500 problem when the user count fails", func() { - users.Error = errors.New("db down") - w, _ := get() - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - Expect(decodeProblem(w).Code).To(Equal(ProblemCodeInternal)) - }) -}) diff --git a/server/apiv1/spec.go b/server/apiv1/spec.go deleted file mode 100644 index bdca19693..000000000 --- a/server/apiv1/spec.go +++ /dev/null @@ -1,51 +0,0 @@ -package apiv1 - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "path" - "strconv" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/req" - "github.com/zeebo/xxh3" -) - -func specHandler(body []byte, contentType string) http.HandlerFunc { - etag := fmt.Sprintf("%016x", xxh3.Hash(body)) - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("ETag", `"`+etag+`"`) - w.Header().Set("Cache-Control", "no-cache") - if req.IfNoneMatch(r, etag) { - w.WriteHeader(http.StatusNotModified) - return - } - w.Header().Set("Content-Type", contentType) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) - } -} - -// withBasePath adds BasePath to the advertised server URL, since only the running server knows it. -func withBasePath(body []byte, key string, quotedInBundle bool) []byte { - serverURL := path.Join(conf.Server.BasePath, consts.URLPathAPIv1) - if serverURL == consts.URLPathAPIv1 { - return body - } - oldURL := consts.URLPathAPIv1 - if quotedInBundle { - oldURL = strconv.Quote(oldURL) - } - old := []byte(key + oldURL) - if bytes.Count(body, old) != 1 { - log.Error("API v1: server URL not found in the bundled spec, serving it without the base path", "key", key) - return body - } - // A JSON string is also a valid YAML double-quoted scalar, so one encoding escapes both formats. - newURL, _ := json.Marshal(serverURL) - return bytes.Replace(body, old, append([]byte(key), newURL...), 1) -} diff --git a/server/apiv1/spec_test.go b/server/apiv1/spec_test.go deleted file mode 100644 index a2368f899..000000000 --- a/server/apiv1/spec_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package apiv1 - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/api" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "gopkg.in/yaml.v3" -) - -var _ = Describe("OpenAPI document routes", func() { - var router *Router - - BeforeEach(func() { - router = New(&tests.MockDataStore{}) - }) - - get := func(path string, headers map[string]string) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodGet, path, nil) - for k, v := range headers { - req.Header.Set(k, v) - } - return serve(router, req) - } - - DescribeTable("serves the embedded bundle", - func(path, contentType string, body []byte) { - w := get(path, nil) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal(contentType)) - Expect(w.Header().Get("ETag")).ToNot(BeEmpty()) - Expect(w.Header().Get("Cache-Control")).To(Equal("no-cache")) - Expect(w.Body.Bytes()).To(Equal(body)) - }, - Entry("JSON", "/api/v1/openapi.json", "application/json", api.SpecJSON()), - Entry("YAML", "/api/v1/openapi.yaml", "application/yaml", api.SpecYAML()), - ) - - DescribeTable("revalidates with If-None-Match", - func(ifNoneMatch func(etag string) string, expected int) { - etag := get("/api/v1/openapi.json", nil).Header().Get("ETag") - w := get("/api/v1/openapi.json", map[string]string{"If-None-Match": ifNoneMatch(etag)}) - Expect(w.Code).To(Equal(expected)) - if expected == http.StatusNotModified { - Expect(w.Body.Len()).To(BeZero()) - Expect(w.Header().Get("ETag")).To(Equal(etag)) - } else { - Expect(w.Body.Bytes()).To(Equal(api.SpecJSON())) - } - }, - Entry("exact ETag", func(etag string) string { return etag }, http.StatusNotModified), - Entry("ETag in a list", func(etag string) string { return `"other", ` + etag }, http.StatusNotModified), - Entry("weak ETag", func(etag string) string { return "W/" + etag }, http.StatusNotModified), - Entry("wildcard", func(string) string { return "*" }, http.StatusNotModified), - Entry("stale ETag", func(string) string { return `"stale"` }, http.StatusOK), - ) - - It("ignores Range and returns the full document", func() { - w := get("/api/v1/openapi.json", map[string]string{"Range": "bytes=0-9"}) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Accept-Ranges")).To(BeEmpty()) - Expect(w.Body.Bytes()).To(Equal(api.SpecJSON())) - }) - - It("uses different ETags for JSON and YAML", func() { - j := get("/api/v1/openapi.json", nil) - y := get("/api/v1/openapi.yaml", nil) - Expect(j.Header().Get("ETag")).ToNot(Equal(y.Header().Get("ETag"))) - }) - - Describe("with a base path", func() { - decode := func(format string, body []byte) map[string]any { - var doc map[string]any - if format == "json" { - ExpectWithOffset(1, json.Unmarshal(body, &doc)).To(Succeed()) - } else { - ExpectWithOffset(1, yaml.Unmarshal(body, &doc)).To(Succeed()) - } - return doc - } - serverURL := func(doc map[string]any) any { - return doc["servers"].([]any)[0].(map[string]any)["url"] - } - var plainETag string - - BeforeEach(func() { - plainETag = get("/api/v1/openapi.json", nil).Header().Get("ETag") - DeferCleanup(configtest.SetupConfig()) - conf.Server.BasePath = "/music" - router = New(&tests.MockDataStore{}) - }) - - DescribeTable("advertises the server under the base path and changes nothing else", - func(path, format string, bundle []byte) { - w := get(path, nil) - Expect(w.Code).To(Equal(http.StatusOK)) - served, original := decode(format, w.Body.Bytes()), decode(format, bundle) - Expect(serverURL(served)).To(Equal("/music/api/v1")) - delete(served, "servers") - delete(original, "servers") - Expect(served).To(Equal(original)) - }, - Entry("JSON", "/api/v1/openapi.json", "json", api.SpecJSON()), - Entry("YAML", "/api/v1/openapi.yaml", "yaml", api.SpecYAML()), - ) - - It("uses its own ETag, and still revalidates", func() { - etag := get("/api/v1/openapi.json", nil).Header().Get("ETag") - Expect(etag).ToNot(Equal(plainETag)) - Expect(get("/api/v1/openapi.json", map[string]string{"If-None-Match": etag}).Code).To(Equal(http.StatusNotModified)) - }) - - It("escapes base paths that need quoting", func() { - conf.Server.BasePath = "/my music: \"live\"" - router = New(&tests.MockDataStore{}) - Expect(serverURL(decode("json", get("/api/v1/openapi.json", nil).Body.Bytes()))).To(Equal("/my music: \"live\"/api/v1")) - Expect(serverURL(decode("yaml", get("/api/v1/openapi.yaml", nil).Body.Bytes()))).To(Equal("/my music: \"live\"/api/v1")) - }) - }) - - DescribeTable("the bundle advertises the API path exactly once, which the base-path rewrite relies on", - func(bundle []byte) { - Expect(bytes.Count(bundle, []byte(consts.URLPathAPIv1))).To(Equal(1)) - }, - Entry("JSON", api.SpecJSON()), - Entry("YAML", api.SpecYAML()), - ) -}) diff --git a/server/auth.go b/server/auth.go index 3e58359da..e36324168 100644 --- a/server/auth.go +++ b/server/auth.go @@ -10,20 +10,17 @@ import ( "fmt" "net" "net/http" - "slices" "strings" - "sync" "time" "github.com/deluan/rest" "github.com/go-chi/jwtauth/v5" - "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/gravatar" "golang.org/x/text/cases" @@ -49,7 +46,7 @@ func login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) { } func doLogin(ds model.DataStore, username string, password string, w http.ResponseWriter, r *http.Request) { - user, err := validateLogin(r.Context(), ds.User(), username, password) + user, err := validateLogin(ds.User(r.Context()), username, password) if err != nil { _ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authentication user. Please try again") return @@ -70,8 +67,8 @@ func doLogin(ds model.DataStore, username string, password string, w http.Respon _ = rest.RespondWithJSON(w, http.StatusOK, payload) } -func buildAuthPayload(user *model.User) map[string]any { - payload := map[string]any{ +func buildAuthPayload(user *model.User) map[string]interface{} { + payload := map[string]interface{}{ "id": user.ID, "name": user.Name, "username": user.UserName, @@ -96,16 +93,6 @@ func buildAuthPayload(user *model.User) map[string]any { return payload } -// MaxLoginBodySize bounds the payload of unauthenticated login routes across all APIs. -const MaxLoginBodySize = 8 << 10 - -func LimitLoginBody(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, MaxLoginBodySize) - next.ServeHTTP(w, r) - }) -} - func getCredentialsFromBody(r *http.Request) (username string, password string, err error) { data := make(map[string]string) decoder := json.NewDecoder(r.Body) @@ -127,7 +114,7 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request _ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error()) return } - c, err := ds.User().CountAll(r.Context()) + c, err := ds.User(r.Context()).CountAll() if err != nil { _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) return @@ -147,26 +134,26 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request func createAdminUser(ctx context.Context, ds model.DataStore, username, password string) error { log.Warn(ctx, "Creating initial user", "user", username) + now := time.Now() caser := cases.Title(language.Und) initialUser := model.User{ - ID: id.NewRandom(), + ID: uuid.NewString(), UserName: username, Name: caser.String(username), Email: "", NewPassword: password, IsAdmin: true, - LastLoginAt: new(time.Now()), + LastLoginAt: now, } - err := ds.User().Put(ctx, &initialUser) + err := ds.User(ctx).Put(&initialUser) if err != nil { - log.Error(ctx, "Could not create initial user", "user", initialUser.UserName, err) - return fmt.Errorf("creating initial user: %w", err) + log.Error(ctx, "Could not create initial user", "user", initialUser, err) } return nil } -func validateLogin(ctx context.Context, userRepo model.UserRepository, userName, password string) (*model.User, error) { - u, err := userRepo.FindByUsernameWithPassword(ctx, userName) +func validateLogin(userRepo model.UserRepository, userName, password string) (*model.User, error) { + u, err := userRepo.FindByUsernameWithPassword(userName) if errors.Is(err, model.ErrNotFound) { return nil, nil } @@ -176,75 +163,59 @@ func validateLogin(ctx context.Context, userRepo model.UserRepository, userName, if u.Password != password { return nil, nil } - err = userRepo.UpdateLastLoginAt(ctx, u.ID) + err = userRepo.UpdateLastLoginAt(u.ID) if err != nil { - log.Error(ctx, "Could not update LastLoginAt", "user", userName) + log.Error("Could not update LastLoginAt", "user", userName) } return u, nil } -func JWTVerifier(next http.Handler) http.Handler { - return jwtauth.Verify(auth.TokenAuth, tokenFromHeader, jwtauth.TokenFromCookie, jwtauth.TokenFromQuery)(next) +// This method maps the custom authorization header to the default 'Authorization', used by the jwtauth library +func authHeaderMapper(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + bearer := r.Header.Get(consts.UIAuthorizationHeader) + if bearer != "" { + r.Header.Set("Authorization", bearer) + } + next.ServeHTTP(w, r) + }) } -func tokenFromHeader(r *http.Request) string { - // Get token from authorization header. - bearer := r.Header.Get(consts.UIAuthorizationHeader) - if len(bearer) > 7 && strings.ToUpper(bearer[0:6]) == "BEARER" { - return bearer[7:] - } - return "" +func jwtVerifier(next http.Handler) http.Handler { + return jwtauth.Verify(auth.TokenAuth, jwtauth.TokenFromHeader, jwtauth.TokenFromCookie, jwtauth.TokenFromQuery)(next) } func UsernameFromToken(r *http.Request) string { - token, _, err := jwtauth.FromContext(r.Context()) - if err != nil || token == nil { + token, claims, err := jwtauth.FromContext(r.Context()) + if err != nil || claims["sub"] == nil || token == nil { return "" } - sub, _ := token.Subject() - if sub == "" { - return "" - } - log.Trace(r, "Found username in JWT token", "username", sub) - return sub + log.Trace(r, "Found username in JWT token", "username", token.Subject()) + return token.Subject() } -func UsernameFromExtAuthHeader(r *http.Request) string { - if conf.Server.ExtAuth.TrustedSources == "" { +func UsernameFromReverseProxyHeader(r *http.Request) string { + if conf.Server.ReverseProxyWhitelist == "" { return "" } - reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context()) - if !ok { - log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.") + if !validateIPAgainstList(r.RemoteAddr, conf.Server.ReverseProxyWhitelist) { + log.Warn(r.Context(), "IP is not whitelisted for reverse proxy login", "ip", r.RemoteAddr) return "" } - username := r.Header.Get(conf.Server.ExtAuth.UserHeader) + username := r.Header.Get(conf.Server.ReverseProxyUserHeader) if username == "" { return "" } - if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) { - log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr) - return "" - } - log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username) + log.Trace(r, "Found username in ReverseProxyUserHeader", "username", username) return username } -func InternalAuth(r *http.Request) string { - username, ok := request.InternalAuthFrom(r.Context()) - if !ok { - return "" - } - log.Trace(r, "Found username in InternalAuth", "username", username) - return username -} - -func UsernameFromConfig(*http.Request) string { +func UsernameFromConfig(r *http.Request) string { return conf.Server.DevAutoLoginUsername } func contextWithUser(ctx context.Context, ds model.DataStore, username string) (context.Context, error) { - user, err := ds.User().FindByUsername(ctx, username) + user, err := ds.User(ctx).FindByUsername(username) if err == nil { ctx = log.NewContext(ctx, "username", username) ctx = request.WithUsername(ctx, user.UserName) @@ -272,8 +243,8 @@ func authenticateRequest(ds model.DataStore, r *http.Request, findUsernameFns .. func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader) - if err != nil || !tokenAllowed(ctx) { + ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromReverseProxyHeader) + if err != nil { _ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") return } @@ -283,130 +254,61 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { } } -// tokenAllowed re-checks a JWT that actually identifies the resolved user. Header and -// config auth carry no token, so they short-circuit to true. -func tokenAllowed(ctx context.Context) bool { - token, _, err := jwtauth.FromContext(ctx) - if err != nil || token == nil { - return true - } - usr, ok := request.UserFrom(ctx) - if !ok { - return true - } - claims := auth.ClaimsFromToken(token) - if !strings.EqualFold(claims.Subject, usr.UserName) { - return true - } - if err := auth.CheckClaims(claims, usr, auth.AudienceNative); err != nil { - log.Warn(ctx, "Native API: rejected token", "user", claims.Subject, err) - return false - } - return true -} - -// refreshingWriter defers the refreshed-token header until the handler's first write, so an -// epoch the handler bumped reaches the token the client stores. -type refreshingWriter struct { - http.ResponseWriter - ctx context.Context //nolint:containedctx // ResponseWriter wrapper defers work to Write, which has no ctx - token jwt.Token - once sync.Once -} - -func (w *refreshingWriter) setToken() { - w.once.Do(func() { - claims := auth.ClaimsFromToken(w.token) - if epoch, ok := request.TokenEpochFrom(w.ctx); ok { - claims.Epoch = epoch - } - newToken, err := auth.TouchClaims(claims) - if err != nil { - log.Error(w.ctx, "Could not sign new token", err) - return - } - w.Header().Set(consts.UIAuthorizationHeader, newToken) - }) -} - -func (w *refreshingWriter) WriteHeader(code int) { - w.setToken() - w.ResponseWriter.WriteHeader(code) -} - -func (w *refreshingWriter) Write(b []byte) (int, error) { - w.setToken() - return w.ResponseWriter.Write(b) -} - -// Flush keeps the SSE events route working through the wrap. -func (w *refreshingWriter) Flush() { - w.setToken() - if f, ok := w.ResponseWriter.(http.Flusher); ok { - f.Flush() - } -} - -// Unwrap lets capability lookups, such as SSE's write deadline, see past this wrap. -func (w *refreshingWriter) Unwrap() http.ResponseWriter { - return w.ResponseWriter -} - -// JWTRefresher updates the expiry date of the received JWT token, and adds the new one to -// the Authorization Header. +// JWTRefresher updates the expiry date of the received JWT token, and add the new one to the Authorization Header func JWTRefresher(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token, _, err := jwtauth.FromContext(r.Context()) - if err != nil || token == nil { + ctx := r.Context() + token, _, err := jwtauth.FromContext(ctx) + if err != nil { next.ServeHTTP(w, r) return } - ctx := request.WithTokenEpochHolder(r.Context()) - rw := &refreshingWriter{ResponseWriter: w, ctx: ctx, token: token} - next.ServeHTTP(rw, r.WithContext(ctx)) - rw.setToken() + newTokenString, err := auth.TouchToken(token) + if err != nil { + log.Error(r, "Could not sign new token", err) + _ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") + return + } + + w.Header().Set(consts.UIAuthorizationHeader, newTokenString) + next.ServeHTTP(w, r) }) } -func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]any { +func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} { username := UsernameFromConfig(r) if username == "" { - username = UsernameFromExtAuthHeader(r) + username = UsernameFromReverseProxyHeader(r) if username == "" { return nil } } - ctx := r.Context() - userRepo := ds.User() - user, err := userRepo.FindByUsernameWithPassword(ctx, username) + userRepo := ds.User(r.Context()) + user, err := userRepo.FindByUsernameWithPassword(username) if user == nil || err != nil { log.Info(r, "User passed in header not found", "user", username) - // Check if this is the first user being created - count, _ := userRepo.CountAll(ctx) - isFirstUser := count == 0 - newUser := model.User{ - ID: id.NewRandom(), + ID: uuid.NewString(), UserName: username, Name: username, Email: "", - NewPassword: consts.PasswordAutogenPrefix + id.NewRandom(), - IsAdmin: isFirstUser, // Make the first user an admin + NewPassword: consts.PasswordAutogenPrefix + uuid.NewString(), + IsAdmin: false, } - err := userRepo.Put(ctx, &newUser) + err := userRepo.Put(&newUser) if err != nil { log.Error(r, "Could not create new user", "user", username, err) return nil } - user, err = userRepo.FindByUsernameWithPassword(ctx, username) + user, err = userRepo.FindByUsernameWithPassword(username) if user == nil || err != nil { log.Error(r, "Created user but failed to fetch it", "user", username) return nil } } - err = userRepo.UpdateLastLoginAt(ctx, user.ID) + err = userRepo.UpdateLastLoginAt(user.ID) if err != nil { log.Error(r, "Could not update LastLoginAt", "user", username, err) return nil @@ -420,14 +322,6 @@ func validateIPAgainstList(ip string, comaSeparatedList string) bool { return false } - cidrs := strings.Split(comaSeparatedList, ",") - - // Per https://github.com/golang/go/issues/49825, the remote address - // on a unix socket is '@' - if ip == "@" && strings.HasPrefix(conf.Server.Address, "unix:") { - return slices.Contains(cidrs, "@") - } - if net.ParseIP(ip) == nil { ip, _, _ = net.SplitHostPort(ip) } @@ -436,7 +330,9 @@ func validateIPAgainstList(ip string, comaSeparatedList string) bool { return false } + cidrs := strings.Split(comaSeparatedList, ",") testedIP, _, err := net.ParseCIDR(fmt.Sprintf("%s/32", ip)) + if err != nil { return false } diff --git a/server/auth_test.go b/server/auth_test.go index 1095fafc9..a23c12a1c 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -4,36 +4,22 @@ import ( "context" "crypto/md5" "encoding/json" - "errors" "fmt" "net/http" "net/http/httptest" "os" "strings" - "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/sirupsen/logrus" - "github.com/sirupsen/logrus/hooks/test" ) var _ = Describe("Auth", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) - Describe("User login", func() { var ds model.DataStore var req *http.Request @@ -45,26 +31,23 @@ var _ = Describe("Auth", func() { }) Describe("createAdmin", func() { - var createdAt time.Time BeforeEach(func() { req = httptest.NewRequest("POST", "/createAdmin", strings.NewReader(`{"username":"johndoe", "password":"secret"}`)) resp = httptest.NewRecorder() - createdAt = time.Now() createAdmin(ds)(resp, req) }) It("creates an admin user with the specified password", func() { - usr := ds.User() - u, err := usr.FindByUsername(ctx, "johndoe") + usr := ds.User(context.Background()) + u, err := usr.FindByUsername("johndoe") Expect(err).To(BeNil()) Expect(u.Password).ToNot(BeEmpty()) Expect(u.IsAdmin).To(BeTrue()) - Expect(*u.LastLoginAt).To(BeTemporally(">=", createdAt, time.Second)) }) It("returns the expected payload", func() { Expect(resp.Code).To(Equal(http.StatusOK)) - var parsed map[string]any + var parsed map[string]interface{} Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) Expect(parsed["isAdmin"]).To(Equal(true)) Expect(parsed["username"]).To(Equal("johndoe")) @@ -74,58 +57,31 @@ var _ = Describe("Auth", func() { }) }) - Describe("createAdminUser", func() { - It("returns the error when the user cannot be saved", func() { - ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: errors.New("db is down")}} - err := createAdminUser(context.Background(), ds, "johndoe", "secret") - Expect(err).To(MatchError(ContainSubstring("db is down"))) - }) - }) - - Describe("createAdmin when the user cannot be stored", func() { - It("responds 500 rather than falling through to login", func() { - failing := dsWithFailingPut(errors.New("db is down")) - req = httptest.NewRequest("POST", "/createAdmin", strings.NewReader(`{"username":"johndoe", "password":"secret"}`)) - resp = httptest.NewRecorder() - - createAdmin(failing)(resp, req) - - Expect(resp.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - Describe("Login from HTTP headers", func() { - const ( - trustedIpv4 = "192.168.0.42" - untrustedIpv4 = "8.8.8.8" - trustedIpv6 = "2001:4860:4860:1234:5678:0000:4242:8888" - untrustedIpv6 = "5005:0:3003" - ) - fs := os.DirFS("tests/fixtures") BeforeEach(func() { - usr := ds.User() - _ = usr.Put(ctx, &model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false}) + usr := ds.User(context.Background()) + _ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false}) req = httptest.NewRequest("GET", "/index.html", nil) req.Header.Add("Remote-User", "janedoe") resp = httptest.NewRecorder() conf.Server.UILoginBackgroundURL = "" - conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16,2001:4860:4860::/48" + conf.Server.ReverseProxyWhitelist = "192.168.0.0/16,2001:4860:4860::/48" }) It("sets auth data if IPv4 matches whitelist", func() { - req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIpv4)) + req.RemoteAddr = "192.168.0.42:25293" serveIndex(ds, fs, nil)(resp, req) config := extractAppConfig(resp.Body.String()) - parsed := config["auth"].(map[string]any) + parsed := config["auth"].(map[string]interface{}) Expect(parsed["id"]).To(Equal("111")) }) It("sets no auth data if IPv4 does not match whitelist", func() { - req = req.WithContext(request.WithReverseProxyIp(req.Context(), untrustedIpv4)) + req.RemoteAddr = "8.8.8.8:25293" serveIndex(ds, fs, nil)(resp, req) config := extractAppConfig(resp.Body.String()) @@ -133,42 +89,37 @@ var _ = Describe("Auth", func() { }) It("sets auth data if IPv6 matches whitelist", func() { - req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIpv6)) + req.RemoteAddr = "[2001:4860:4860:1234:5678:0000:4242:8888]:25293" serveIndex(ds, fs, nil)(resp, req) config := extractAppConfig(resp.Body.String()) - parsed := config["auth"].(map[string]any) + parsed := config["auth"].(map[string]interface{}) Expect(parsed["id"]).To(Equal("111")) }) It("sets no auth data if IPv6 does not match whitelist", func() { - req = req.WithContext(request.WithReverseProxyIp(req.Context(), untrustedIpv6)) + req.RemoteAddr = "[5005:0:3003]:25293" serveIndex(ds, fs, nil)(resp, req) config := extractAppConfig(resp.Body.String()) Expect(config["auth"]).To(BeNil()) }) - It("creates user and sets auth data if user does not exist", func() { - newUser := "NEW_USER_" + id.NewRandom() - - req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIpv4)) - req.Header.Set("Remote-User", newUser) + It("sets no auth data if user does not exist", func() { + req.Header.Set("Remote-User", "INVALID_USER") serveIndex(ds, fs, nil)(resp, req) config := extractAppConfig(resp.Body.String()) - parsed := config["auth"].(map[string]any) - - Expect(parsed["username"]).To(Equal(newUser)) + Expect(config["auth"]).To(BeNil()) }) It("sets auth data if user exists", func() { - req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIpv4)) + req.RemoteAddr = "192.168.0.42:25293" serveIndex(ds, fs, nil)(resp, req) config := extractAppConfig(resp.Body.String()) - parsed := config["auth"].(map[string]any) + parsed := config["auth"].(map[string]interface{}) Expect(parsed["id"]).To(Equal("111")) Expect(parsed["isAdmin"]).To(BeFalse()) @@ -183,40 +134,6 @@ var _ = Describe("Auth", func() { // Request Header authentication should not generate a JWT token Expect(parsed).ToNot(HaveKey("token")) }) - - It("does not set auth data when listening on unix socket without whitelist", func() { - conf.Server.Address = "unix:/tmp/navidrome-test" - conf.Server.ExtAuth.TrustedSources = "" - - // No ReverseProxyIp in request context - serveIndex(ds, fs, nil)(resp, req) - - config := extractAppConfig(resp.Body.String()) - Expect(config["auth"]).To(BeNil()) - }) - - It("does not set auth data when listening on unix socket with incorrect whitelist", func() { - conf.Server.Address = "unix:/tmp/navidrome-test" - - req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@")) - serveIndex(ds, fs, nil)(resp, req) - - config := extractAppConfig(resp.Body.String()) - Expect(config["auth"]).To(BeNil()) - }) - - It("sets auth data when listening on unix socket with correct whitelist", func() { - conf.Server.Address = "unix:/tmp/navidrome-test" - conf.Server.ExtAuth.TrustedSources = conf.Server.ExtAuth.TrustedSources + ",@" - - req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@")) - serveIndex(ds, fs, nil)(resp, req) - - config := extractAppConfig(resp.Body.String()) - parsed := config["auth"].(map[string]any) - - Expect(parsed["id"]).To(Equal("111")) - }) }) Describe("login", func() { @@ -230,21 +147,14 @@ var _ = Describe("Auth", func() { Expect(resp.Code).To(Equal(http.StatusUnauthorized)) }) - It("rejects a request body larger than the limit", func() { - body := `{"username":"janedoe", "password":"abc123", "padding":"` + strings.Repeat("x", MaxLoginBodySize) + `"}` - req = httptest.NewRequest("POST", "/login", strings.NewReader(body)) - LimitLoginBody(http.HandlerFunc(login(ds))).ServeHTTP(resp, req) - Expect(resp.Code).To(Equal(http.StatusUnprocessableEntity)) - }) - It("logs in successfully if user exists", func() { - usr := ds.User() - _ = usr.Put(ctx, &model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false}) + usr := ds.User(context.Background()) + _ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false}) login(ds)(resp, req) Expect(resp.Code).To(Equal(http.StatusOK)) - var parsed map[string]any + var parsed map[string]interface{} Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) Expect(parsed["isAdmin"]).To(Equal(false)) Expect(parsed["username"]).To(Equal("janedoe")) @@ -255,86 +165,30 @@ var _ = Describe("Auth", func() { }) }) - Describe("UsernameFromExtAuthHeader", func() { - var hook *test.Hook - var r *http.Request + Describe("authHeaderMapper", func() { + It("maps the custom header to Authorization header", func() { + r := httptest.NewRequest("GET", "/index.html", nil) + r.Header.Set(consts.UIAuthorizationHeader, "test authorization bearer") + w := httptest.NewRecorder() - BeforeEach(func() { - conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16" - prevLevel := log.CurrentLevel() - l, h := test.NewNullLogger() - hook = h - prevLogger := log.SetDefaultLogger(l) - log.SetLevel(log.LevelWarn) - DeferCleanup(func() { - log.SetDefaultLogger(prevLogger) - log.SetLevel(prevLevel) - }) - r = httptest.NewRequest("GET", "/", nil) + authHeaderMapper(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Header.Get("Authorization")).To(Equal("test authorization bearer")) + w.WriteHeader(200) + })).ServeHTTP(w, r) + + Expect(w.Code).To(Equal(200)) }) + It("does not override the Authorization header if x-nd-authorizer is not present", func() { + r := httptest.NewRequest("GET", "/index.html", nil) + r.Header.Set("Authorization", "original bearer") + w := httptest.NewRecorder() - warnings := func() []*logrus.Entry { - var ws []*logrus.Entry - for _, e := range hook.AllEntries() { - if e.Level == logrus.WarnLevel { - ws = append(ws, e) - } - } - return ws - } + authHeaderMapper(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Expect(r.Header.Get("Authorization")).To(Equal("original bearer")) + w.WriteHeader(200) + })).ServeHTTP(w, r) - It("returns the username from a trusted source", func() { - r.Header.Set("Remote-User", "janedoe") - r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.0.42")) - Expect(UsernameFromExtAuthHeader(r)).To(Equal("janedoe")) - Expect(warnings()).To(BeEmpty()) - }) - - It("does not warn when an untrusted source sends no user header", func() { - r = r.WithContext(request.WithReverseProxyIp(r.Context(), "8.8.8.8")) - Expect(UsernameFromExtAuthHeader(r)).To(BeEmpty()) - Expect(warnings()).To(BeEmpty()) - }) - - It("warns when an untrusted source sends the user header", func() { - r.Header.Set("Remote-User", "janedoe") - r = r.WithContext(request.WithReverseProxyIp(r.Context(), "8.8.8.8")) - Expect(UsernameFromExtAuthHeader(r)).To(BeEmpty()) - Expect(warnings()).To(HaveLen(1)) - Expect(warnings()[0].Message).To(Equal("IP is not whitelisted for external authentication")) - }) - }) - - Describe("tokenFromHeader", func() { - It("returns the token when the Authorization header is set correctly", func() { - req := httptest.NewRequest("GET", "/", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer testtoken") - - token := tokenFromHeader(req) - Expect(token).To(Equal("testtoken")) - }) - - It("returns an empty string when the Authorization header is not set", func() { - req := httptest.NewRequest("GET", "/", nil) - - token := tokenFromHeader(req) - Expect(token).To(BeEmpty()) - }) - - It("returns an empty string when the Authorization header is not a Bearer token", func() { - req := httptest.NewRequest("GET", "/", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Basic testtoken") - - token := tokenFromHeader(req) - Expect(token).To(BeEmpty()) - }) - - It("returns an empty string when the Bearer token is too short", func() { - req := httptest.NewRequest("GET", "/", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer") - - token := tokenFromHeader(req) - Expect(token).To(BeEmpty()) + Expect(w.Code).To(Equal(200)) }) }) @@ -380,188 +234,4 @@ var _ = Describe("Auth", func() { }) }) }) - - Describe("handleLoginFromHeaders", func() { - var ds model.DataStore - var req *http.Request - const trustedIP = "192.168.0.42" - - BeforeEach(func() { - ds = &tests.MockDataStore{} - req = httptest.NewRequest("GET", "/", nil) - req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP)) - conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16" - conf.Server.ExtAuth.UserHeader = "Remote-User" - }) - - It("makes the first user an admin", func() { - // No existing users - req.Header.Set("Remote-User", "firstuser") - result := handleLoginFromHeaders(ds, req) - - Expect(result).ToNot(BeNil()) - Expect(result["isAdmin"]).To(BeTrue()) - - // Verify user was created as admin - u, err := ds.User().FindByUsername(ctx, "firstuser") - Expect(err).To(BeNil()) - Expect(u.IsAdmin).To(BeTrue()) - }) - - It("does not make subsequent users admins", func() { - // Create the first user - _ = ds.User().Put(ctx, &model.User{ - ID: "existing-user-id", - UserName: "existinguser", - Name: "Existing User", - IsAdmin: true, - }) - - // Try to create a second user via proxy header - req.Header.Set("Remote-User", "seconduser") - result := handleLoginFromHeaders(ds, req) - - Expect(result).ToNot(BeNil()) - Expect(result["isAdmin"]).To(BeFalse()) - - // Verify user was created as non-admin - u, err := ds.User().FindByUsername(ctx, "seconduser") - Expect(err).To(BeNil()) - Expect(u.IsAdmin).To(BeFalse()) - }) - }) - - Describe("Authenticator token gating", func() { - var ds *tests.MockDataStore - var usr *model.User - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SessionTimeout = time.Hour - ds = &tests.MockDataStore{} - auth.Init(ds) - ur := ds.User().(*tests.MockedUserRepo) - usr = &model.User{ID: "u1", UserName: "johndoe", NewPassword: "pw", TokenEpoch: 2} - Expect(ur.Put(ctx, usr)).To(Succeed()) - }) - - serve := func(token string) *httptest.ResponseRecorder { - r := httptest.NewRequest("GET", "/api/song", nil) - r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) - w := httptest.NewRecorder() - handler := JWTVerifier(Authenticator(ds)(http.HandlerFunc( - func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }, - ))) - handler.ServeHTTP(w, r) - return w - } - - It("accepts a current session token", func() { - tokenStr, err := auth.CreateToken(usr) - Expect(err).ToNot(HaveOccurred()) - Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) - }) - - It("rejects a jellyfin-scoped token", func() { - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects a token with a stale epoch", func() { - tokenStr, err := auth.CreateToken(usr) - Expect(err).ToNot(HaveOccurred()) - usr.TokenEpoch = 3 - Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) - }) - - It("ignores a stray token for someone else when config auto-login resolves the user", func() { - conf.Server.DevAutoLoginUsername = usr.UserName - tokenStr, err := auth.CreateToken(&model.User{UserName: "someone-else"}) - Expect(err).ToNot(HaveOccurred()) - Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) - }) - - It("rejects a stale-epoch token whose subject differs only in case from the resolved user", func() { - tokenStr, err := auth.CreateToken(&model.User{UserName: strings.ToUpper(usr.UserName), TokenEpoch: usr.TokenEpoch}) - Expect(err).ToNot(HaveOccurred()) - usr.TokenEpoch = 5 - Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("JWTRefresher", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - // TouchClaims reads this; left at zero every refreshed token is born expired. - conf.Server.SessionTimeout = time.Hour - auth.Init(&tests.MockDataStore{}) - }) - - serveWith := func(handler http.HandlerFunc) *httptest.ResponseRecorder { - usr := model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1} - tokenStr, err := auth.CreateToken(&usr) - Expect(err).ToNot(HaveOccurred()) - - r := httptest.NewRequest("GET", "/api/song", nil) - r.Header.Set(consts.UIAuthorizationHeader, "Bearer "+tokenStr) - w := httptest.NewRecorder() - JWTVerifier(JWTRefresher(handler)).ServeHTTP(w, r) - return w - } - - It("writes a refreshed token when the handler writes a body", func() { - w := serveWith(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write([]byte("ok")) - }) - Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty()) - }) - - It("writes a refreshed token when the handler writes no body", func() { - w := serveWith(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }) - Expect(w.Header().Get(consts.UIAuthorizationHeader)).ToNot(BeEmpty()) - }) - - It("picks up an epoch the handler reported", func() { - w := serveWith(func(w http.ResponseWriter, r *http.Request) { - request.SetTokenEpoch(r.Context(), 42) - w.WriteHeader(http.StatusOK) - }) - - claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader)) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.Epoch).To(Equal(42)) - }) - - It("keeps the original epoch when the handler reports nothing", func() { - w := serveWith(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - claims, err := auth.Validate(w.Header().Get(consts.UIAuthorizationHeader)) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.Epoch).To(Equal(1)) - }) - - It("propagates Flush to the underlying ResponseWriter", func() { - w := serveWith(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - w.(http.Flusher).Flush() - }) - Expect(w.Flushed).To(BeTrue()) - }) - - It("exposes the underlying ResponseWriter via Unwrap, for http.ResponseController lookups", func() { - var unwrapped http.ResponseWriter - w := serveWith(func(w http.ResponseWriter, _ *http.Request) { - u, ok := w.(interface{ Unwrap() http.ResponseWriter }) - Expect(ok).To(BeTrue()) - unwrapped = u.Unwrap() - w.WriteHeader(http.StatusOK) - }) - Expect(unwrapped).To(BeIdenticalTo(w)) - }) - }) }) diff --git a/server/backgrounds/backgrounds_suite_test.go b/server/backgrounds/backgrounds_suite_test.go deleted file mode 100644 index 5ad81907b..000000000 --- a/server/backgrounds/backgrounds_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package backgrounds - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestBackgrounds(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Backgrounds Suite") -} diff --git a/server/backgrounds/handler.go b/server/backgrounds/handler.go index dcaaa9c66..611bba3f5 100644 --- a/server/backgrounds/handler.go +++ b/server/backgrounds/handler.go @@ -4,98 +4,43 @@ import ( "context" "encoding/base64" "errors" - "fmt" - "io" "net/http" - "strings" + "net/url" + "path" + "sync" "time" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/cache" - "github.com/navidrome/navidrome/utils/httpclient" - "github.com/navidrome/navidrome/utils/random" + "github.com/navidrome/navidrome/utils/number" "gopkg.in/yaml.v3" ) -const ( - //imageHostingUrl = "https://unsplash.com/photos/%s/download?fm=jpg&w=1600&h=900&fit=max" - imageHostingUrl = "https://www.navidrome.org/images/%s.webp" - imageListURL = "https://www.navidrome.org/images/index.yml" - imageListTTL = 24 * time.Hour - imageCacheDir = "backgrounds" - imageCacheSize = "100MB" - imageCacheMaxItems = 1000 - imageRequestTimeout = 5 * time.Second -) - type Handler struct { - httpClient *cache.HTTPClient - cache cache.FileCache + list []string + lock sync.RWMutex } func NewHandler() *Handler { h := &Handler{} - h.httpClient = cache.NewHTTPClient(httpclient.New(5*time.Second), imageListTTL) - h.cache = cache.NewFileCache(imageCacheDir, imageCacheSize, imageCacheDir, imageCacheMaxItems, h.serveImage) go func() { - _, _ = h.getImageList(log.NewContext(context.Background())) + _, _ = h.getImageList(context.Background()) }() return h } -type cacheKey string - -func (k cacheKey) Key() string { - return string(k) -} +const ndImageServiceURL = "https://www.navidrome.org/images" func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { image, err := h.getRandomImage(r.Context()) if err != nil { - h.serveDefaultImage(w) - return - } - s, err := h.cache.Get(r.Context(), cacheKey(image)) - if err != nil { - h.serveDefaultImage(w) - return - } - defer s.Close() - - w.Header().Set("content-type", "image/webp") - _, _ = io.Copy(w, s.Reader) -} - -func (h *Handler) serveDefaultImage(w http.ResponseWriter) { - defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline) - w.Header().Set("content-type", "image/png") - _, _ = w.Write(defaultImage) -} - -func (h *Handler) serveImage(ctx context.Context, item cache.Item) (io.Reader, error) { - start := time.Now() - image := item.Key() - if image == "" { - return nil, errors.New("empty image name") - } - c := httpclient.New(imageRequestTimeout) - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil) - resp, err := c.Do(req) //nolint:bodyclose,gosec // On success the body is closed via the CachedStream wrapper - if errors.Is(err, context.DeadlineExceeded) { defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline) - return strings.NewReader(string(defaultImage)), nil + w.Header().Set("content-type", "image/png") + _, _ = w.Write(defaultImage) + return } - if err != nil { - return nil, fmt.Errorf("could not get background image from hosting service: %w", err) - } - if resp.StatusCode != http.StatusOK { - _ = resp.Body.Close() - return nil, fmt.Errorf("unexpected status code getting background image from hosting service: %d", resp.StatusCode) - } - log.Debug(ctx, "Got background image from hosting service", "image", image, "elapsed", time.Since(start)) - return resp.Body, nil + http.Redirect(w, r, buildPath(ndImageServiceURL, image), http.StatusFound) } func (h *Handler) getRandomImage(ctx context.Context) (string, error) { @@ -106,37 +51,42 @@ func (h *Handler) getRandomImage(ctx context.Context) (string, error) { if len(list) == 0 { return "", errors.New("no images available") } - rnd := random.Int64N(len(list)) + rnd := number.RandomInt64(int64(len(list))) return list[rnd], nil } func (h *Handler) getImageList(ctx context.Context) ([]string, error) { + h.lock.RLock() + if len(h.list) > 0 { + defer h.lock.RUnlock() + return h.list, nil + } + + h.lock.RUnlock() + h.lock.Lock() + defer h.lock.Unlock() start := time.Now() - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageListURL, nil) - resp, err := h.httpClient.Do(req) + c := http.Client{ + Timeout: time.Minute, + } + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, buildPath(ndImageServiceURL, "index.yml"), nil) + resp, err := c.Do(req) if err != nil { log.Warn(ctx, "Could not get background images from image service", err) return nil, err } defer resp.Body.Close() - - var list []string dec := yaml.NewDecoder(resp.Body) - err = dec.Decode(&list) - if err != nil { - log.Warn(ctx, "Could not decode background images from image service", err) - return nil, err - } - log.Debug(ctx, "Loaded background images from image service", "total", len(list), "elapsed", time.Since(start)) - return list, nil + err = dec.Decode(&h.list) + log.Debug(ctx, "Loaded background images from image service", "total", len(h.list), "elapsed", time.Since(start)) + return h.list, err } -func imageURL(imageName string) string { - // Discard extension - parts := strings.Split(imageName, ".") - if len(parts) > 1 { - imageName = parts[0] - } - return fmt.Sprintf(imageHostingUrl, imageName) +func buildPath(baseURL string, endpoint ...string) string { + u, _ := url.Parse(baseURL) + p := path.Join(endpoint...) + u.Path = path.Join(u.Path, p) + return u.String() } diff --git a/server/backgrounds/handler_test.go b/server/backgrounds/handler_test.go deleted file mode 100644 index e8c77380f..000000000 --- a/server/backgrounds/handler_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package backgrounds - -import ( - "context" - "io" - "net/http" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type recordingBody struct { - io.Reader - closed *bool -} - -func (b recordingBody) Close() error { - *b.closed = true - return nil -} - -type stubTransport struct { - statusCode int - closed *bool -} - -func (t stubTransport) RoundTrip(*http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: t.statusCode, - Header: make(http.Header), - Body: recordingBody{Reader: strings.NewReader("image-bytes"), closed: t.closed}, - }, nil -} - -var _ = Describe("serveImage", func() { - var closed bool - - BeforeEach(func() { - closed = false - }) - - stubStatus := func(statusCode int) { - original := http.DefaultTransport - http.DefaultTransport = stubTransport{statusCode: statusCode, closed: &closed} - DeferCleanup(func() { http.DefaultTransport = original }) - } - - It("closes the response body when the hosting service returns an error", func() { - stubStatus(http.StatusNotFound) - - _, err := (&Handler{}).serveImage(context.Background(), cacheKey("some-image.webp")) - - Expect(err).To(MatchError(ContainSubstring("unexpected status code"))) - Expect(closed).To(BeTrue(), "response body was left open") - }) - - It("hands the still-open body to the caller on success", func() { - stubStatus(http.StatusOK) - - reader, err := (&Handler{}).serveImage(context.Background(), cacheKey("some-image.webp")) - - Expect(err).ToNot(HaveOccurred()) - Expect(closed).To(BeFalse(), "response body must stay open for the CachedStream wrapper") - body, _ := io.ReadAll(reader) - Expect(string(body)).To(Equal("image-bytes")) - }) -}) diff --git a/server/events/events.go b/server/events/events.go index ff0a8a40a..306e6fb52 100644 --- a/server/events/events.go +++ b/server/events/events.go @@ -1,7 +1,6 @@ package events import ( - "context" "encoding/json" "reflect" "strings" @@ -9,15 +8,6 @@ import ( "unicode" ) -type eventCtxKey string - -const broadcastToAllKey eventCtxKey = "broadcastToAll" - -// broadcastToAll is a context key that can be used to broadcast an event to all clients -func broadcastToAll(ctx context.Context) context.Context { - return context.WithValue(ctx, broadcastToAllKey, true) -} - type Event interface { Name(Event) string Data(Event) string @@ -37,12 +27,9 @@ func (e *baseEvent) Data(evt Event) string { type ScanStatus struct { baseEvent - Scanning bool `json:"scanning"` - Count int64 `json:"count"` - FolderCount int64 `json:"folderCount"` - Error string `json:"error"` - ScanType string `json:"scanType"` - ElapsedTime time.Duration `json:"elapsedTime"` + Scanning bool `json:"scanning"` + Count int64 `json:"count"` + FolderCount int64 `json:"folderCount"` } type KeepAlive struct { @@ -63,11 +50,6 @@ type RefreshResource struct { resources map[string][]string } -type NowPlayingCount struct { - baseEvent - Count int `json:"count"` -} - func (rr *RefreshResource) With(resource string, ids ...string) *RefreshResource { if rr.resources == nil { rr.resources = make(map[string][]string) diff --git a/server/events/sse.go b/server/events/sse.go index e4d6a05e5..23720169f 100644 --- a/server/events/sse.go +++ b/server/events/sse.go @@ -8,9 +8,9 @@ import ( "net/http" "time" + "github.com/google/uuid" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/pl" "github.com/navidrome/navidrome/utils/singleton" @@ -19,14 +19,12 @@ import ( type Broker interface { http.Handler SendMessage(ctx context.Context, event Event) - SendBroadcastMessage(ctx context.Context, event Event) } const ( keepAliveFrequency = 15 * time.Second - // The timeout must be higher than the keepAliveFrequency, or the lack of activity will cause the channel to close. - writeTimeOut = keepAliveFrequency + 5*time.Second - bufferSize = 1 + writeTimeOut = 5 * time.Second + bufferSize = 1 ) type ( @@ -34,7 +32,7 @@ type ( id uint64 event string data string - senderCtx context.Context //nolint:containedctx // queued message carries the sender ctx + senderCtx context.Context } messageChan chan message clientsChan chan client @@ -44,13 +42,15 @@ type ( username string userAgent string clientUniqueId string - displayString string msgC chan message } ) func (c client) String() string { - return c.displayString + if log.CurrentLevel() >= log.LevelTrace { + return fmt.Sprintf("%s (%s - %s - %s - %s)", c.id, c.username, c.address, c.clientUniqueId, c.userAgent) + } + return fmt.Sprintf("%s (%s - %s - %s)", c.id, c.username, c.address, c.clientUniqueId) } type broker struct { @@ -79,11 +79,6 @@ func GetBroker() Broker { }) } -func (b *broker) SendBroadcastMessage(ctx context.Context, evt Event) { - ctx = broadcastToAll(ctx) - b.SendMessage(ctx, evt) -} - func (b *broker) SendMessage(ctx context.Context, evt Event) { msg := b.prepareMessage(ctx, evt) log.Trace("Broker received new event", "type", msg.event, "data", msg.data) @@ -99,18 +94,18 @@ func (b *broker) prepareMessage(ctx context.Context, event Event) message { } // writeEvent writes a message to the given io.Writer, formatted as a Server-Sent Event. -// If the writer is a http.Flusher, it flushes the data immediately instead of buffering it. +// If the writer is an http.Flusher, it flushes the data immediately instead of buffering it. func writeEvent(ctx context.Context, w io.Writer, event message, timeout time.Duration) error { if err := setWriteTimeout(w, timeout); err != nil { log.Debug(ctx, "Error setting write timeout", err) } - _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.id, event.event, event.data) //nolint:gosec + _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", event.id, event.event, event.data) if err != nil { return err } - // If the writer is a http.Flusher, flush the data immediately. + // If the writer is an http.Flusher, flush the data immediately. if flusher, ok := w.(http.Flusher); ok && flusher != nil { flusher.Flush() } @@ -146,6 +141,7 @@ func (b *broker) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache, no-transform") w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") // Tells Nginx to not buffer this response. See https://stackoverflow.com/a/33414096 w.Header().Set("X-Accel-Buffering", "no") @@ -170,18 +166,12 @@ func (b *broker) subscribe(r *http.Request) client { user, _ := request.UserFrom(ctx) clientUniqueId, _ := request.ClientUniqueIdFrom(ctx) c := client{ - id: id.NewRandom(), + id: uuid.NewString(), username: user.UserName, address: r.RemoteAddr, userAgent: r.UserAgent(), clientUniqueId: clientUniqueId, } - if log.IsGreaterOrEqualTo(log.LevelTrace) { - c.displayString = fmt.Sprintf("%s (%s - %s - %s - %s)", c.id, c.username, c.address, c.clientUniqueId, c.userAgent) - } else { - c.displayString = fmt.Sprintf("%s (%s - %s - %s)", c.id, c.username, c.address, c.clientUniqueId) - } - c.msgC = make(chan message, bufferSize) // Signal the broker that we have a new client @@ -194,9 +184,6 @@ func (b *broker) unsubscribe(c client) { } func (b *broker) shouldSend(msg message, c client) bool { - if broadcastToAll, ok := msg.senderCtx.Value(broadcastToAllKey).(bool); ok && broadcastToAll { - return true - } clientUniqueId, originatedFromClient := request.ClientUniqueIdFrom(msg.senderCtx) if !originatedFromClient { return true @@ -273,20 +260,8 @@ func sendOrDrop(client client, msg message) { select { case client.msgC <- msg: default: - if log.IsGreaterOrEqualTo(log.LevelTrace) { + if log.CurrentLevel() >= log.LevelTrace { log.Trace("Event dropped because client's channel is full", "event", msg, "client", client.String()) } } } - -func NoopBroker() Broker { - return noopBroker{} -} - -type noopBroker struct { - http.Handler -} - -func (b noopBroker) SendBroadcastMessage(context.Context, Event) {} - -func (noopBroker) SendMessage(context.Context, Event) {} diff --git a/server/filter/filters.go b/server/filter/filters.go deleted file mode 100644 index 9014bb799..000000000 --- a/server/filter/filters.go +++ /dev/null @@ -1,227 +0,0 @@ -package filter - -import ( - "time" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" -) - -type Options = model.QueryOptions - -var defaultFilters = Eq{"missing": false} - -func addDefaultFilters(options Options) Options { - if options.Filters == nil { - options.Filters = defaultFilters - } else { - options.Filters = And{defaultFilters, options.Filters} - } - return options -} - -func AlbumsByNewest() Options { - return addDefaultFilters(addDefaultFilters(Options{Sort: "recently_added", Order: "desc"})) -} - -func AlbumsByRecent() Options { - return addDefaultFilters(Options{Sort: "playDate", Order: "desc", Filters: Gt{"play_date": time.Time{}}}) -} - -func AlbumsByFrequent() Options { - return addDefaultFilters(Options{Sort: "playCount", Order: "desc", Filters: Gt{"play_count": 0}}) -} - -func AlbumsByRandom() Options { - return addDefaultFilters(Options{Sort: "random"}) -} - -func AlbumsByName() Options { - return addDefaultFilters(Options{Sort: "name"}) -} - -func AlbumsByArtist() Options { - return addDefaultFilters(Options{Sort: "artist"}) -} - -func AlbumsByArtistID(artistId string) Options { - roles := []model.Role{model.RoleAlbumArtist} - if conf.Server.Subsonic.ArtistParticipations { - roles = append(roles, model.RoleArtist) - } - return addDefaultFilters(Options{ - Sort: "max_year", - Filters: persistence.ParticipantIDFilter("album", artistId, roles...), - }) -} - -// AlbumsByContributingArtistID matches albums where the artist performs on a track but is not the -// album artist — Jellyfin's "Featured On". The disjoint complement of AlbumsByArtistID, so an -// artist's own discography never leaks into it. -func AlbumsByContributingArtistID(artistId string) Options { - return addDefaultFilters(Options{ - Sort: "max_year", - Filters: And{ - persistence.ParticipantIDFilter("album", artistId, model.RoleArtist), - persistence.NotParticipantIDFilter("album", artistId, model.RoleAlbumArtist), - }, - }) -} - -func AlbumsByYear(fromYear, toYear int) Options { - orderOption := "" - if fromYear > toYear { - fromYear, toYear = toYear, fromYear - orderOption = "desc" - } - return addDefaultFilters(Options{ - Sort: "max_year", - Order: orderOption, - Filters: Or{ - And{ - GtOrEq{"min_year": fromYear}, - LtOrEq{"min_year": toYear}, - }, - And{ - GtOrEq{"max_year": fromYear}, - LtOrEq{"max_year": toYear}, - }, - }, - }) -} - -func SongsByAlbum(albumId string) Options { - return addDefaultFilters(Options{ - Filters: Eq{"album_id": albumId}, - Sort: "album", - }) -} - -// SongsByArtistID matches media files where the artist participates as album or track artist, in -// album order. -func SongsByArtistID(artistId string) Options { - return addDefaultFilters(Options{ - Sort: "album", - Filters: persistence.ParticipantIDFilter("media_file", artistId, model.RoleArtist, model.RoleAlbumArtist), - }) -} - -func SongsByGenreAndYearRange(genre string, fromYear, toYear int) Options { - options := Options{} - ff := And{} - if genre != "" { - ff = append(ff, persistence.SongGenres.ByName(genre)) - } - if fromYear != 0 { - ff = append(ff, GtOrEq{"year": fromYear}) - } - if toYear != 0 { - ff = append(ff, LtOrEq{"year": toYear}) - } - options.Filters = ff - return addDefaultFilters(options) -} - -func ApplyLibraryFilter(opts Options, musicFolderIds []int) Options { - if len(musicFolderIds) == 0 { - return opts - } - - libraryFilter := Eq{"library_id": musicFolderIds} - if opts.Filters == nil { - opts.Filters = libraryFilter - } else { - opts.Filters = And{opts.Filters, libraryFilter} - } - - return opts -} - -// ApplyArtistLibraryFilter applies a filter to the given Options to ensure that only artists -// that are associated with the specified music folders are included in the results. -func ApplyArtistLibraryFilter(opts Options, musicFolderIds []int) Options { - if len(musicFolderIds) == 0 { - return opts - } - - artistLibraryFilter := Eq{"library_artist.library_id": musicFolderIds} - if opts.Filters == nil { - opts.Filters = artistLibraryFilter - } else { - opts.Filters = And{opts.Filters, artistLibraryFilter} - } - - return opts -} - -// ArtistsByRole restricts an artist query to artists appearing in the given role (album artist, -// performer, composer, ...) via library_artist.stats. An unknown role is ignored (no filter). -func ArtistsByRole(opts Options, role model.Role) Options { - if _, ok := model.AllRoles[role.String()]; !ok { - return opts - } - roleFilter := Expr("JSON_EXTRACT(library_artist.stats, '$." + role.String() + ".m') IS NOT NULL") - if opts.Filters == nil { - opts.Filters = roleFilter - } else { - opts.Filters = And{opts.Filters, roleFilter} - } - return opts -} - -// SongsByGenreID / AlbumsByGenreID (by tag id) and AlbumsByGenre / SongsByGenre (by name, wrapped -// as Options for Subsonic) delegate to the persistence genre filters, which own the join schema. -func SongsByGenreID(genreIds []string) Sqlizer { return persistence.SongGenres.ByID(genreIds) } -func AlbumsByGenreID(genreIds []string) Sqlizer { return persistence.AlbumGenres.ByID(genreIds) } - -func AlbumsByGenre(genre string) Options { - return addDefaultFilters(Options{Sort: "name", Filters: persistence.AlbumGenres.ByName(genre)}) -} - -func SongsByGenre(genre string) Options { - return addDefaultFilters(Options{Sort: "name", Filters: persistence.SongGenres.ByName(genre)}) -} - -// ByAlbumID matches media files belonging to any of the given albums. -func ByAlbumID(albumIds []string) Sqlizer { - return Eq{"album_id": albumIds} -} - -// AlbumsByYears matches albums whose production year (max_year) is in years. -func AlbumsByYears(years []int) Sqlizer { - return Eq{"max_year": years} -} - -// SongsByYears matches media files whose year is in years. -func SongsByYears(years []int) Sqlizer { - return Eq{"year": years} -} - -func ArtistsByGenreID(genreIds []string) Sqlizer { - return persistence.AlbumArtistsByGenreID(genreIds) -} - -// tagIDFilter builds an EXISTS over the given tag role's entries in the tags JSON, matching each -// entry against cond (its name via Like, or its tag id via Eq/IN). -func tagIDFilter(tagName string, cond Sqlizer) Sqlizer { - return persistence.Exists(`json_tree(tags, "$.`+tagName+`")`, And{NotEq{"atom": nil}, cond}) -} - -// ByStudioID matches items (albums or songs) whose record-label tag id is in ids. -func ByStudioID(ids []string) Sqlizer { - return tagIDFilter("recordlabel", Eq{"value": ids}) -} - -func ByRating() Options { - return addDefaultFilters(Options{Sort: "rating", Order: "desc", Filters: Gt{"rating": 0}}) -} - -func ByStarred() Options { - return addDefaultFilters(Options{Sort: "starred_at", Order: "desc", Filters: Eq{"starred": true}}) -} - -func ArtistsByStarred() Options { - return Options{Sort: "starred_at", Order: "desc", Filters: Eq{"starred": true}} -} diff --git a/server/imghttp/headers.go b/server/imghttp/headers.go deleted file mode 100644 index 354b11793..000000000 --- a/server/imghttp/headers.go +++ /dev/null @@ -1,48 +0,0 @@ -// Package imghttp holds the HTTP caching contract shared by the subsonic, public, and jellyfin -// image handlers, so they apply identical headers without importing each other. -package imghttp - -import ( - "net/http" - - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/utils/req" -) - -// WriteImageHeaders applies the artwork caching contract and reports whether a 304 was written -// (the caller must then not write a body). requestedHash is the hash the client asserted, or "". -func WriteImageHeaders(w http.ResponseWriter, r *http.Request, img *artwork.Image, requestedHash string) (wrote304 bool) { - h := w.Header() - // Placeholders are transient stand-ins for unresolved art: never cached, no validators. - if img.Placeholder { - h.Set("Cache-Control", "no-store") - return false - } - - // The ETag versions the served representation, so a re-encoding config change invalidates a - // revalidating client's cache; full-size originals are their own pixel hash. - etag := img.ETag - if etag == "" { - etag = img.Hash - } - // An empty validator would be shared by every such response, 304ing clients that echo it back. - if etag != "" { - h.Set("ETag", `"`+etag+`"`) - } - if !img.LastUpdated.IsZero() { - h.Set("Last-Modified", img.LastUpdated.UTC().Format(http.TimeFormat)) - } - // Immutable only when the client asked for the current pixel hash; anything else revalidates, - // so art that gets re-resolved is never pinned. - if requestedHash != "" && requestedHash == img.Hash { - h.Set("Cache-Control", "public, max-age=31536000, immutable") - } else { - h.Set("Cache-Control", "public, no-cache") - } - - if etag != "" && req.IfNoneMatch(r, etag) { - w.WriteHeader(http.StatusNotModified) - return true - } - return false -} diff --git a/server/imghttp/headers_test.go b/server/imghttp/headers_test.go deleted file mode 100644 index 2e8313faf..000000000 --- a/server/imghttp/headers_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package imghttp_test - -import ( - "io" - "net/http" - "net/http/httptest" - "strings" - "time" - - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/server/imghttp" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -const testHash = "0123456789abcdef" -const testRepTag = testHash + ".300.false.q75" - -var lastMod = time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) - -func found() *artwork.Image { - return &artwork.Image{ - ReadCloser: io.NopCloser(strings.NewReader("IMG")), - Hash: testHash, - LastUpdated: lastMod, - } -} - -func placeholder() *artwork.Image { - return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("PH")), Placeholder: true} -} - -// A re-encoded response carries a representation ETag distinct from the pixel hash, so the -// validator versions with the encode settings. -func resized() *artwork.Image { - return &artwork.Image{ - ReadCloser: io.NopCloser(strings.NewReader("IMG")), - Hash: testHash, - ETag: testRepTag, - LastUpdated: lastMod, - } -} - -func unvalidated() *artwork.Image { - return &artwork.Image{ReadCloser: io.NopCloser(strings.NewReader("IMG")), LastUpdated: lastMod} -} - -var _ = Describe("WriteImageHeaders", func() { - type testCase struct { - img *artwork.Image - requestedHash string - ifNoneMatch string - want304 bool - wantCache string - wantETag string - wantLastMod bool - } - - DescribeTable("applies the artwork caching contract", - func(c testCase) { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/img", nil) - if c.ifNoneMatch != "" { - r.Header.Set("If-None-Match", c.ifNoneMatch) - } - - Expect(imghttp.WriteImageHeaders(w, r, c.img, c.requestedHash)).To(Equal(c.want304)) - - h := w.Header() - Expect(h.Get("Cache-Control")).To(Equal(c.wantCache)) - Expect(h.Get("ETag")).To(Equal(c.wantETag)) - if c.img.Placeholder { - Expect(h.Get("ETag")).To(BeEmpty(), "placeholder must not set an ETag") - Expect(h.Get("Last-Modified")).To(BeEmpty(), "placeholder must not set Last-Modified") - } - Expect(h.Get("Last-Modified") != "").To(Equal(c.wantLastMod)) - if c.want304 { - Expect(w.Code).To(Equal(http.StatusNotModified)) - Expect(w.Body.Len()).To(BeZero(), "a 304 must have an empty body") - } - }, - Entry("placeholder is never cached and carries no validators", - testCase{img: placeholder(), wantCache: "no-store"}), - Entry("found with matching requested hash is immutable", - testCase{img: found(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("found with bare id revalidates via no-cache", - testCase{img: found(), wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("found with mismatched requested hash revalidates", - testCase{img: found(), requestedHash: "ffffffffffffffff", wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("resized keeps pixel-hash immutable but serves the representation ETag", - testCase{img: resized(), requestedHash: testHash, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), - Entry("resized 304s on the representation ETag, not the pixel hash", - testCase{img: resized(), ifNoneMatch: `"` + testRepTag + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), - Entry("resized does not 304 on a stale pixel-hash validator (config changed)", - testCase{img: resized(), ifNoneMatch: `"` + testHash + `"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testRepTag + `"`, wantLastMod: true}), - Entry("If-None-Match matching the hash yields 304", - testCase{img: found(), requestedHash: testHash, ifNoneMatch: `"` + testHash + `"`, want304: true, wantCache: "public, max-age=31536000, immutable", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("weak If-None-Match matches (weak comparison)", - testCase{img: found(), ifNoneMatch: `W/"` + testHash + `"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("If-None-Match with multiple values matches one", - testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef", W/"` + testHash + `", "cafecafecafecafe"`, want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("If-None-Match star matches any current representation", - testCase{img: found(), ifNoneMatch: "*", want304: true, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("non-matching If-None-Match serves body", - testCase{img: found(), ifNoneMatch: `"deadbeefdeadbeef"`, want304: false, wantCache: "public, no-cache", wantETag: `"` + testHash + `"`, wantLastMod: true}), - Entry("placeholder ignores If-None-Match and never 304s", - testCase{img: placeholder(), ifNoneMatch: "*", want304: false, wantCache: "no-store"}), - - // With no validator, an emitted ETag would be the same empty tag on every such response, - // and matching it would 304 changed bytes. - Entry("omits the ETag entirely when there is no validator", - testCase{img: unvalidated(), wantCache: "public, no-cache", wantLastMod: true}), - Entry("never 304s an empty validator echoed back by the client", - testCase{img: unvalidated(), ifNoneMatch: `""`, want304: false, - wantCache: "public, no-cache", wantLastMod: true}), - ) -}) diff --git a/server/imghttp/imghttp_suite_test.go b/server/imghttp/imghttp_suite_test.go deleted file mode 100644 index 7a747db5b..000000000 --- a/server/imghttp/imghttp_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package imghttp_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestImgHTTP(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "ImgHTTP Suite") -} diff --git a/server/initial_setup.go b/server/initial_setup.go index 462e22e54..39f6f3884 100644 --- a/server/initial_setup.go +++ b/server/initial_setup.go @@ -5,77 +5,87 @@ import ( "fmt" "time" - "github.com/Masterminds/squirrel" + "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" ) func initialSetup(ds model.DataStore) { - ctx := context.TODO() - err := ds.WithTx(func(tx model.DataStore) error { - if err := tx.Library().StoreMusicFolder(ctx); err != nil { - return err - } - - properties := tx.Property() - _, err := properties.Get(ctx, consts.InitialSetupFlagKey) + _ = ds.WithTx(func(tx model.DataStore) error { + properties := ds.Property(context.TODO()) + _, err := properties.Get(consts.InitialSetupFlagKey) if err == nil { return nil } log.Info("Running initial setup") + if err = createJWTSecret(ds); err != nil { + return err + } + if conf.Server.DevAutoCreateAdminPassword != "" { - if err = createInitialAdminUser(ctx, tx, conf.Server.DevAutoCreateAdminPassword); err != nil { + if err = createInitialAdminUser(ds, conf.Server.DevAutoCreateAdminPassword); err != nil { return err } } - err = properties.Put(ctx, consts.InitialSetupFlagKey, time.Now().String()) + err = properties.Put(consts.InitialSetupFlagKey, time.Now().String()) return err - }, "initial setup") - if err != nil { - log.Fatal("Error running initial setup", err) - } + }) } -// If the Dev Admin user is not present, create it -func createInitialAdminUser(ctx context.Context, ds model.DataStore, initialPassword string) error { - users := ds.User() - c, err := users.CountAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"user_name": consts.DevInitialUserName}}) +func createInitialAdminUser(ds model.DataStore, initialPassword string) error { + users := ds.User(context.TODO()) + c, err := users.CountAll() if err != nil { - return fmt.Errorf("could not access User table: %w", err) + panic(fmt.Sprintf("Could not access User table: %s", err)) } if c == 0 { - newID := id.NewRandom() + id := uuid.NewString() log.Warn("Creating initial admin user. This should only be used for development purposes!!", - "user", consts.DevInitialUserName, "password", initialPassword, "id", newID) + "user", consts.DevInitialUserName, "password", initialPassword, "id", id) initialUser := model.User{ - ID: newID, + ID: id, UserName: consts.DevInitialUserName, Name: consts.DevInitialName, Email: "", NewPassword: initialPassword, IsAdmin: true, } - if err := users.Put(ctx, &initialUser); err != nil { - return fmt.Errorf("could not create initial admin user: %w", err) + err := users.Put(&initialUser) + if err != nil { + log.Error("Could not create initial admin user", "user", initialUser, err) } } - return nil + return err } -func checkFFmpegInstallation() { +func createJWTSecret(ds model.DataStore) error { + properties := ds.Property(context.TODO()) + _, err := properties.Get(consts.JWTSecretKey) + if err == nil { + return nil + } + log.Info("Creating new JWT secret, used for encrypting UI sessions") + err = properties.Put(consts.JWTSecretKey, uuid.NewString()) + if err != nil { + log.Error("Could not save JWT secret in DB", err) + } + return err +} + +func checkFfmpegInstallation() { f := ffmpeg.New() _, err := f.CmdPath() - if err != nil { - log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err) + if err == nil { return } - if !f.IsProbeAvailable() { - log.Warn("Unable to find ffprobe. Transcoding decisions will be limited") + 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" } } @@ -92,5 +102,11 @@ func checkExternalCredentials() { } else { log.Debug("ListenBrainz integration is ENABLED", "ListenBrainz.BaseURL", conf.Server.ListenBrainz.BaseURL) } + + if conf.Server.Spotify.ID == "" || conf.Server.Spotify.Secret == "" { + log.Info("Spotify integration is not enabled: missing ID/Secret") + } else { + log.Debug("Spotify integration is ENABLED") + } } } diff --git a/server/initial_setup_test.go b/server/initial_setup_test.go index 0c85d9d0a..982046f78 100644 --- a/server/initial_setup_test.go +++ b/server/initial_setup_test.go @@ -2,7 +2,6 @@ package server import ( "context" - "errors" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -10,52 +9,28 @@ import ( . "github.com/onsi/gomega" ) -type failingPutUserRepo struct { - model.UserRepository - err error -} - -func (r *failingPutUserRepo) Put(context.Context, *model.User) error { return r.err } - -func dsWithFailingPut(err error) model.DataStore { - return &tests.MockDataStore{MockedUser: &failingPutUserRepo{UserRepository: tests.CreateMockUserRepo(), err: err}} -} - var _ = Describe("initial_setup", func() { var ds model.DataStore - var ctx context.Context BeforeEach(func() { ds = &tests.MockDataStore{} - ctx = GinkgoT().Context() }) Describe("createInitialAdminUser", func() { It("creates a new admin user with specified password if User table is empty", func() { - Expect(createInitialAdminUser(ctx, ds, "pass123")).To(BeNil()) - ur := ds.User() - admin, err := ur.FindByUsername(ctx, "admin") + Expect(createInitialAdminUser(ds, "pass123")).To(BeNil()) + ur := ds.User(context.TODO()) + admin, err := ur.FindByUsername("admin") Expect(err).To(BeNil()) Expect(admin.Password).To(Equal("pass123")) }) It("does not create a new admin user if User table is not empty", func() { - Expect(createInitialAdminUser(ctx, ds, "first")).To(BeNil()) - ur := ds.User() - Expect(ur.CountAll(ctx)).To(Equal(int64(1))) - Expect(createInitialAdminUser(ctx, ds, "second")).To(BeNil()) - Expect(ur.CountAll(ctx)).To(Equal(int64(1))) - }) - - It("returns the error when the user cannot be stored", func() { - boom := errors.New("db is down") - Expect(createInitialAdminUser(ctx, dsWithFailingPut(boom), "pass123")).To(MatchError(boom)) - }) - - It("returns the error when the user table cannot be read", func() { - boom := errors.New("db is down") - ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: boom}} - Expect(createInitialAdminUser(ctx, ds, "pass123")).To(MatchError(boom)) + Expect(createInitialAdminUser(ds, "first")).To(BeNil()) + ur := ds.User(context.TODO()) + Expect(ur.CountAll()).To(Equal(int64(1))) + Expect(createInitialAdminUser(ds, "second")).To(BeNil()) + Expect(ur.CountAll()).To(Equal(int64(1))) }) }) }) diff --git a/server/jellyfin/README.md b/server/jellyfin/README.md deleted file mode 100644 index 9315ac4f9..000000000 --- a/server/jellyfin/README.md +++ /dev/null @@ -1,421 +0,0 @@ -# Jellyfin API - -This package implements a subset of the [Jellyfin](https://jellyfin.org/) REST API on top of -Navidrome's existing library, users, playlists and scrobbling infrastructure. It lets -Jellyfin-compatible clients (e.g. [Finamp](https://github.com/jmshrv/finamp), -[jftui](https://github.com/Aanok/jftui)) browse and stream a Navidrome library without -requiring a real Jellyfin server. - -It is **not** a full Jellyfin server implementation: only the endpoints needed to browse a music -library, stream audio, manage favorites/ratings for songs, albums, artists, and playlists, report -playback, and manage playlists are implemented. Video, live TV, plugins, and Jellyfin's -admin/dashboard APIs are out of scope. - -## Enabling - -The Jellyfin API is disabled by default. Enable it via `navidrome.toml`: - -```toml -[Jellyfin] -Enabled = true -# Optional: override the server name reported to clients (defaults to "Navidrome <version>") -ServerName = "My Music Server" -# Optional: usernames to show in the client login user-picker (default: none). See "Public user list". -ExposedPublicUsers = "alice, bob" -# Optional: answer LAN auto-discovery broadcasts on UDP 7359 (default: false). See "Auto discovery". -AutoDiscovery = true -# Optional: let users sign in new devices with a 6-digit code (default: true). See "Quick Connect". -QuickConnect = false -# Optional: max collection responses streaming at once (default: half the DB connection pool, -# min 2). Each streaming response holds a DB connection for its whole duration; excess requests -# queue rather than fail. -MaxConcurrentStreams = 4 -``` - -or via environment variables: - -```bash -ND_JELLYFIN_ENABLED=true -ND_JELLYFIN_SERVERNAME="My Music Server" -ND_JELLYFIN_EXPOSEDPUBLICUSERS="alice,bob" -ND_JELLYFIN_AUTODISCOVERY=true -ND_JELLYFIN_QUICKCONNECT=false -ND_JELLYFIN_MAXCONCURRENTSTREAMS=4 -``` - -Once enabled, the API is mounted at: - -``` -http://<host>:<port>/jellyfin -``` - -All the paths below are relative to that base URL (e.g. `System/Info/Public` means -`http://localhost:4533/jellyfin/System/Info/Public`). Routes are matched **case-insensitively**, -since real Jellyfin clients (and `jellyfin-apiclient-python`) send mixed-case paths. - -## Auto discovery - -With `AutoDiscovery = true`, Navidrome answers the Jellyfin LAN discovery broadcast -(`who is JellyfinServer?` on UDP port 7359), so clients list the server without a typed URL. -It is off by default because a real Jellyfin server on the same host owns that port. If the port -is taken, Navidrome logs a warning and keeps running without discovery. - -The advertised address is `BaseURL` when it includes a host. Otherwise it is the bind `Address` -when that is a specific IP, or else the local IP that faces the requesting client, plus `Port`. With a -unix socket `Address` there is no port to advertise, so discovery only starts when `BaseURL` has a host. - -Discovery answers on all IPv4 interfaces, but the advertised address follows `BaseURL`, `Address` and -`Port`. If `Address` is a loopback or a single interface IP and `BaseURL` has no host, clients on other -networks get an address they cannot reach. Set `BaseURL` to the address clients should use. - -Docker: use host networking (`network_mode: host` / `--network host`). On Linux, bridge mode does not -deliver broadcasts to the container, even with `-p 7359:7359/udp`, so clients never find the server. -With host networking the server sees the host's IP, so `BaseURL` is not needed for discovery. If host -networking is not an option, leave discovery off. Keep UDP 7359 on the LAN: never forward it from the internet. - -## Authentication - -Jellyfin clients authenticate with `POST /Users/AuthenticateByName` using the user's Navidrome -username/password, and get back an `AccessToken` (a Navidrome JWT). That token is then sent on -every subsequent request as the `X-Emby-Token` header (or embedded in the -`X-Emby-Authorization`/`Authorization` header's `Token="..."` field, or as an `api_key`/`ApiKey` -query param — all forms are accepted, matching what different clients do). - -`POST /Users/AuthenticateByName` is rate-limited per IP with the same limiter as the native -`/auth/login` (`AuthRequestLimit`/`AuthWindowLength`), since it's an unauthenticated brute-force -surface. - -Access tokens do not expire, matching real Jellyfin. They are revoked by a password change, which bumps the user's token epoch. - -### Quick Connect - -Quick Connect signs a new device in without typing a password. The client shows a 6-digit code, -a signed-in user approves it, and the client gets its `AccessToken`. It is on by default -(`Jellyfin.QuickConnect`); when off, `GET QuickConnect/Enabled` returns `false` and every other -Quick Connect call returns 401. - -1. `POST QuickConnect/Initiate` (public; needs `Client`, `Device`, `DeviceId` and `Version` in the - auth header) returns the `Code` and a `Secret`. -2. The user approves the code, either in the Navidrome web UI (user menu → **Quick Connect**, which - shows the app and device before approving) or from a signed-in Jellyfin client with - `POST QuickConnect/Authorize?Code=`. Admins may pass `UserId` to approve for another user. -3. The client polls `GET QuickConnect/Connect?Secret=` until `Authenticated` is `true`. -4. `POST Users/AuthenticateWithQuickConnect` with `{"Secret": "..."}` returns the same result as - `AuthenticateByName`. - -Pending codes live in memory and expire after 10 minutes (a server restart drops them). Unlike -Jellyfin, a secret signs in only once. `Initiate`, `AuthenticateWithQuickConnect` and code approval -(`Authorize` and the web UI) are rate-limited per IP like the login; `Connect` is not, since some -clients poll it every second. - -### Public user list (login picker) - -`GET /Users/Public` lets a client render a login user-picker (tap a user, then just type the -password) instead of a blank username field. It's **unauthenticated**, so by default it exposes -**no** users. Set `Jellyfin.ExposedPublicUsers` to a comma-separated list of usernames to advertise: - -```toml -[Jellyfin] -ExposedPublicUsers = "alice, bob" -``` - -Only the named users are listed (never the full user table), resolved live per request; a configured -name that doesn't exist is skipped and logged at `Warn`. Each entry is a minimal DTO (`Name`, `Id`) -with no `Policy`/`Configuration`, so admin status isn't leaked to unauthenticated callers, and no -avatar (`PrimaryImageTag` omitted — Navidrome has no per-user profile images). - -## Players and sessions - -Every authenticated request registers (or refreshes) the calling device as a Navidrome player, -mirroring Subsonic's `getPlayer` — so a Jellyfin client shows up in the players list (and scrobbling -has a player) as soon as it makes any authenticated call, not only when it reports playback. The -player id is the device id from `X-Emby-Authorization` (`DeviceId="..."`); the player name is -`Client [Device]`. Those field values are URL-decoded, since some clients percent-encode them -(Jellify sends `Device="Pixel%208%20Pro"`, Finamp sends it raw). A request that carries no -client/device info (e.g. the `GET socket` handshake, which authenticates via `?api_key=` only) is -skipped, so it doesn't create a nameless player. - -## Multi-library behavior - -Jellyfin has no native concept of multiple music libraries the way Navidrome does, so each -Navidrome library the current user can access is exposed as its own top-level Jellyfin -"CollectionFolder" view (`GET /UserViews`), instead of merging every library into a single view. -Browsing (`/Items`), artists, and the "Latest" list are all scoped to the libraries the -authenticated user has access to. Fetching an item the user cannot access returns `404`, never -`403`, so ids can't be used as an existence oracle. An inaccessible library id sent as `ParentId` -is not a `404`: it is simply not treated as a library, so none of that library's content is -returned. - -### Browsing filters - -`GET /Items` accepts the filter params clients use to build screens: `ParentId` (a library view id -for scoping, an artist id when browsing into an artist's albums, or an album id when browsing into -an album's tracks); `AlbumArtistIds`/`ArtistIds`/`contributingArtistIds` (an artist's albums or -tracks — Finamp's artist screen sends these *alongside* `ParentId=<libraryId>`); `AlbumIds` (an -album's tracks — Feishin fetches them this way instead of `ParentId`); `GenreIds` (a -genre's albums or tracks — Finamp's genre screen sends it the same way; `/Artists/AlbumArtists` -and `MusicArtist` queries accept it too, matching artists credited on an album of that genre); -`Years`; `StudioIds` (record labels, as listed by `GET Studios`); `SearchTerm`; -`Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone -`isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as -in Jellyfin; `Likes`, `Dislikes`, `IsFolder`, `IsNotFolder` and `IsResumable` have no Navidrome -equivalent and are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties; -unrecognized keys are skipped, and `Random` always sorts alone); -`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId` -returns direct children only (no tracks — no track is a library's direct child). - -## Implemented endpoints - -| Area | Endpoints | -|---|---| -| Handshake / system | `GET System/Info/Public`, `GET System/Info` (authenticated), `GET`/`POST System/Ping`, `GET System/Endpoint` (authenticated) | -| Quick Connect | `GET QuickConnect/Enabled`, `POST QuickConnect/Initiate`, `GET QuickConnect/Connect`, `POST QuickConnect/Authorize` (authenticated), `POST Users/AuthenticateWithQuickConnect` | -| Auth | `POST Users/AuthenticateByName`, `GET Users/Public` | -| Users | `GET UserViews`, `GET Users/{userId}/Views`, `GET Users/Me`, `GET Users/{userId}` | -| Browsing | `GET Items`, `GET Users/{userId}/Items`, `GET Items/{itemId}`, `GET Users/{userId}/Items/{itemId}`, `GET Items/Latest`, `GET Users/{userId}/Items/Latest`, `DELETE Items/{itemId}` (playlists only) | -| Artists / genres / labels | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres`, `GET Studios`, `GET Items/Filters` | -| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Albums/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` | -| Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) | -| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` | -| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) | -| Lyrics | `GET Audio/{itemId}/Lyrics` | -| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Playing/Ping` (no-op), `POST Sessions/Capabilities[/Full]` | -| Playlists | `POST Playlists`, `GET Playlists/{playlistId}`, `POST Playlists/{playlistId}` (rename / visibility / replace tracks), `GET Playlists/{playlistId}/Items`, `POST`/`DELETE Playlists/{playlistId}/Items` (`POST` honors `position`), `POST Playlists/{playlistId}/Items/{entryId}/Move/{newIndex}`, `GET Playlists/{playlistId}/Users[/{userId}]` | -| Real-time | `GET socket` (WebSocket; keeps clients like Finamp from 404-loop-reconnecting) | -| AudioMuse-AI (see below) | `GET AudioMuseAI/info`, `GET AudioMuseAI/health`, `GET AudioMuseAI/similar_tracks`, `GET AudioMuseAI/find_path` | - -Any other path returns a `404` with a `{}` JSON body, and is logged server-side at `Debug` level -as `Jellyfin API: unhandled route` (method + path). If a client you're testing needs an endpoint -that isn't in the table above, check the server logs for these lines to see exactly what it's -requesting. - -## Playlist management - -Playlists are the main writable surface of this API: - -- **Container expansion.** When creating (`POST Playlists`), adding to (`POST Playlists/{id}/Items`) - or replacing (`POST Playlists/{id}`) a playlist, the `Ids` may contain **containers** — album, - artist or playlist ids — not just song ids. Each is expanded into its tracks (in order) before - the write, matching how Jellyfin clients populate these lists. A bare song id passes through. -- **Id list encoding.** `POST`/`DELETE Playlists/{id}/Items` accept the id list both ways clients - spell it: repeated params (`ids=X&ids=Y`, how Jellify's `@jellyfin/sdk` serializes arrays) and a - single comma-separated value (`ids=X,Y`, Finamp). Reading only the first value would add just one - track of an expanded album. -- **Update** (`POST Playlists/{id}`): with `Ids` present, the track list is **replaced** (Finamp - uses this for reordering) — an explicit empty `Ids` (`[]`) **clears** the playlist, while an - omitted `Ids` leaves the tracks untouched and only updates `Name`/`IsPublic`. `IsPublic` maps to - Navidrome's `Public` flag, surfaced to clients as `OpenAccess` on `GET Playlists/{id}`. -- **Cover art**: `POST Items/{id}/Images/Primary` uploads a playlist cover (raw or base64 body, - JPEG/PNG/WebP/GIF detected by magic number, extension from `Content-Type`); `DELETE` removes it. - Only playlists are writable through this API — album/artist covers come from tag/sidecar scanning, - so a non-playlist id returns `501`. Uploads honor the same gates as the native endpoint: they're - bounded by `MaxImageUploadSize` and require `EnableArtworkUpload` for non-admins. -- **`PlaylistItemId`**: `GET Playlists/{id}/Items` tags each entry with `PlaylistItemId` (the - playlist-track row id, distinct from the song id) so a client can echo it back via - `DELETE Playlists/{id}/Items?EntryIds=...` to remove one occurrence of a song that appears more - than once in the same playlist. - -Ownership is enforced by `core/playlists`: a non-owner editing/deleting a playlist gets `403` if -it is visible to them (public) or `404` if it is not (private) — the API never reveals that -someone else's private playlist exists. - -## Images - -The `GET Items/{itemId}/Images/{type}` route is intentionally **public** (artwork isn't sensitive, -matching Jellyfin's lenient image handling), so it carries no authenticated user. Artwork is -therefore resolved under an **elevated admin context** — the same approach `core/artwork`'s cache -warmer uses — so user-scoped items like private playlists still resolve their cover instead of -falling back to the placeholder. Album, artist, media-file and playlist ids are all resolved to -their Navidrome `ArtworkID`. - -## Item ids are GUIDs - -Jellyfin item ids are GUIDs, serialized as 32 lowercase hex chars with no dashes -(`Guid.ToString("N")`). Navidrome ids are canonical 22-char base62 encodings of a 128-bit value, -so `dto.EncodeID`/`dto.DecodeID` map between the two via `model/id` — losslessly except for the -~2⁻⁹⁶ chance an id's 128-bit value falls in the reserved space below (leading 12 bytes all zero). - -Three emitted ids aren't 128-bit values: integer library ids, the synthetic playlists folder, and -`PlaylistItemId` (a playlist *entry position* — `playlist_tracks.id` is an `integer` column). -They use a reserved GUID space — 12 zero bytes, a non-zero kind tag, a 24-bit payload — so -library `1` is `00000000000000000000000001000001`. The tag is never zero, because Jellyfin -serializes the all-zero GUID as `null`. - -`DecodeID` accepts dashed and uppercase GUIDs (Jellyfin's `Guid.Parse` does) and returns -`ok=false` for anything malformed — including "" — which handlers surface as a 404. - -The wire format must stay GUID-shaped for this reason: Finamp's saved-queue persistence bit-packs -each item id into exactly 16 bytes (`packIds()` in `lib/models/finamp_models.dart`), so a 32-hex -GUID round-trips exactly, whereas a longer id would be silently truncated. - -## Streaming and transcoding - -The stream endpoints reuse the same transcode-decision pipeline as the Subsonic `/stream` endpoint: - -- **`GET Audio/{id}/stream[.{container}]` / `universal`** — the target format comes from the - `.{container}` path suffix, the `container` param, or (when neither is present) `audioCodec`. - `audioBitRate`/`maxStreamingBitrate` are bits/sec, per Jellyfin convention. `static=true` - forces direct play (raw), never a transcode. -- **`GET Items/{id}/File` / `Download`** — always the original file bytes, matching real Jellyfin. - Finamp plays through `File` when its transcoding setting is off, so an undecodable format (e.g. - DSF) can't be rescued server-side on this path. -- **`GET Audio/{id}/main.m3u8`** — the endpoint Finamp plays through when its transcoding setting - is on. Implemented as a single-segment HLS VOD playlist whose one segment is the progressive - transcode endpoint above, so the whole pipeline (decision, cache, forced transcoding) is reused. - Segment codec honors `audioCodec` but is limited to what HLS packed-audio can carry (`aac`, - `mp3`); anything else falls back to `aac`. Seeking re-reads from the start, like Subsonic - transcoded streams. -- **Server-forced transcoding.** A format/bitrate configured on the registered player (Settings → - Players) is applied to `stream`, `universal` and `main.m3u8` — same override semantics as - Subsonic. `File`/`Download` stay raw. For HLS clients, force `aac` or `mp3`; other formats are - advertised and served but packed-audio players won't decode them. - -## Lyrics - -`GET Audio/{id}/Lyrics` serves the main lyric track as a `LyricDto` (`Start` in 100ns ticks, -word-level `Cues` when present), resolved through the full `core/lyrics` pipeline (embedded, `.lrc` -sidecars, plugins per `LyricsPriority`). No lyrics returns `404`, never an empty `200`. - -Results, misses included, are cached for 5 minutes: Jellify fetches lyrics for every played track and -Feishin on every song change, so lyric-less tracks are the hot path. Concurrent misses on the same -track share one pipeline run. That run is detached from the request, so a cancelled request doesn't -fail it for other waiters, and its context has a one-minute deadline. - -Finamp opens its lyrics view only when the track has a `Lyric` `MediaStream` (`HasLyrics` is just a -list badge). Browse lists set both from embedded lyrics only. `PlaybackInfo` runs the full pipeline -per track, so sidecar and plugin lyrics show up there. Feishin also requires server version ≥ 10.9 -(we advertise 12.1.0). - -## AudioMuse-AI compatible endpoints - -Compatibility shim for Jellyfin front-ends that integrate [AudioMuse-AI](https://github.com/NeptuneHub/audiomuse-ai-plugin) -— e.g. [Symfonium](https://symfonium.app/) can use these endpoints for sonic mixes when -connected as a Jellyfin client. -Backed natively by Navidrome's `core/sonic` engine (the `SonicSimilarity` plugin capability) — no -external AudioMuse-AI backend or proxy is involved. The endpoints are gated on a `SonicSimilarity` -plugin being loaded, like the Subsonic `sonicSimilarity` OpenSubsonic extension. - -- `GET /AudioMuseAI/info` — returns `{"Version": <navidrome version>, "AvailableEndpoints": [...]}` (200). - `AvailableEndpoints` lists the endpoints below only when a provider is loaded; otherwise it is empty. -- `GET /AudioMuseAI/health` — liveness probe: 200 with an empty body when a provider is loaded, else 404. -- `GET /AudioMuseAI/similar_tracks?item_id=<id>&n=10&eliminate_duplicates=true` — 404 when no provider is - loaded; otherwise a JSON array of `{author, distance, item_id, title}` (200; `[]` when there is no match - or no `item_id`). `eliminate_duplicates` (default true) limits results to one track per artist. -- `GET /AudioMuseAI/find_path?start_song_id=<id>&end_song_id=<id>&max_steps=25` — 404 when no provider is - loaded; otherwise `{"path": [{author, item_id, title, tempo?}], "total_distance": <float>}` (200), or 400 - with `start_song_id and end_song_id are required.` when either id is missing. - -`item_id`/`start_song_id`/`end_song_id` are the GUID-form ids Navidrome hands Jellyfin clients. -`tempo` comes from the track's BPM when known; the richer AudioMuse per-track features -(`energy`, `key`, `mood_vector`, `scale`, `other_features`) are not provided. In multi-library -setups, `find_path`'s `path` and `total_distance` only reflect hops through tracks in libraries -the caller can access, since hops through inaccessible libraries are filtered out of the result. - -## curl walkthrough - -This mirrors the sequence a real client (e.g. Finamp) follows: handshake, login, browse the -library hierarchy, fetch playback info, stream, favorite, report playback, and manage a playlist. - -```bash -BASE=http://localhost:4533/jellyfin - -# 1. Handshake (no auth required) -curl -s "$BASE/System/Info/Public" | jq . - -# 2. Login - capture the AccessToken -TOKEN=$(curl -s -X POST "$BASE/Users/AuthenticateByName" \ - -H 'Content-Type: application/json' \ - -d '{"Username":"admin","Pw":"password"}' | jq -r .AccessToken) - -AUTH=(-H "X-Emby-Token: $TOKEN") - -# 3. List the user's views (one per accessible library) -curl -s "${AUTH[@]}" "$BASE/UserViews" | jq . - -# 4. Browse artists -curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist" | jq . -ARTIST_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?IncludeItemTypes=MusicArtist&Limit=1" | jq -r '.Items[0].Id') - -# 5. Drill into that artist's albums (ParentId with no IncludeItemTypes defaults to MusicAlbum) -ALBUM_ID=$(curl -s "${AUTH[@]}" "$BASE/Items?ParentId=$ARTIST_ID" | jq -r '.Items[0].Id') - -# 6. List the album's songs -USER_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/Me" | jq -r .Id) -SONG_ID=$(curl -s "${AUTH[@]}" "$BASE/Users/$USER_ID/Items?ParentId=$ALBUM_ID&IncludeItemTypes=Audio" \ - | jq -r '.Items[0].Id') - -# 7. Ask for playback info, then stream the song -curl -s -X POST "${AUTH[@]}" "$BASE/Items/$SONG_ID/PlaybackInfo" | jq . -curl -s "${AUTH[@]}" "$BASE/Audio/$SONG_ID/stream" -o /tmp/song.audio - -# 8. Favorite the song -curl -s -X POST "${AUTH[@]}" "$BASE/Users/$USER_ID/FavoriteItems/$SONG_ID" | jq . - -# 9. Report playback start/stop (also drives scrobbling) -curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ - -d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":0}" "$BASE/Sessions/Playing" -curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ - -d "{\"ItemId\":\"$SONG_ID\",\"PositionTicks\":1200000000}" "$BASE/Sessions/Playing/Stopped" - -# 10. Create a playlist from a whole album (the album id is expanded to its tracks) -PLAYLIST_ID=$(curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ - -d "{\"Name\":\"My Playlist\",\"Ids\":[\"$ALBUM_ID\"]}" "$BASE/Playlists" | jq -r .Id) - -# 11. Make it public, then remove one entry -curl -s -X POST "${AUTH[@]}" -H 'Content-Type: application/json' \ - -d '{"IsPublic":true}' "$BASE/Playlists/$PLAYLIST_ID" -ENTRY_ID=$(curl -s "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items" | jq -r '.Items[0].PlaylistItemId') -curl -s -X DELETE "${AUTH[@]}" "$BASE/Playlists/$PLAYLIST_ID/Items?EntryIds=$ENTRY_ID" - -# 12. Delete the playlist -curl -s -X DELETE "${AUTH[@]}" "$BASE/Items/$PLAYLIST_ID" -``` - -## Testing - -Handler-level unit tests live alongside each file (`*_test.go`). A full end-to-end suite in -[`e2e/`](e2e) exercises every endpoint through the real router against a real SQLite database and -real repositories (only artwork, streaming, ffmpeg, external metadata agents and sonic similarity -are stubbed), with per-`Describe` snapshot isolation — mirroring the Subsonic `server/subsonic/e2e` -suite. Run it with: - -```bash -make test PKG=./server/jellyfin/... -``` - -## Known limitations - -- **Genre lists ignore `ParentId`.** `GET Genres`/`MusicGenres` and - `Items?IncludeItemTypes=MusicGenre` list the genres of every library the user can access, never - only the `ParentId` library. `Items?IncludeItemTypes=MusicGenre` also ignores `SearchTerm`. - `GET Items/Filters` is the exception: its genres follow `ParentId`. -- **Search skips some filters.** With `SearchTerm`, `Filters=IsFavorite`, `IsPlayed`, `IsUnplayed` - and `isFavorite`/`isPlayed` are skipped, so the response holds every search match. The full-text - search's first phase has no annotation join to filter on. Artist searches also skip the role (album - artist vs. artist) and `GenreIds`. Playlist lists ignore `SearchTerm`. -- **Search pages are capped at 2,000 items.** A larger `Limit` is lowered to 2,000. Jellyfin has no - cap. -- **One-character searches return nothing.** The shared search layer ignores terms shorter than two - characters, so album, artist and song searches for `a` come back empty. Real Jellyfin has no - minimum. -- **Playlist entry ids are positions, not song ids.** `PlaylistItemId` encodes the entry's position - in the playlist, so ids change when entries are inserted or removed; re-read the list after an - edit. Real Jellyfin uses the song id. Clients that echo `PlaylistItemId` back (Finamp) work; - clients that send the song id as `EntryIds` or to `Move` (Jellify) get `404`. -- **Rating uses the `rating` param.** `POST Users/{userId}/Items/{itemId}/Rating` reads a `rating` - (0-10) and stores it as 0-5 stars. Jellyfin's `likes` boolean is not read, so a client that sends - only `likes` clears the rating. -- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed), `Search/Hints` and the non-legacy - `UserItems/{itemId}/Rating` are not implemented. Play counts only change through playback - reporting. -- **Some `Fields` are never emitted.** `ProviderIds`, `People`, `Etag` and `DateLastMediaAdded` are - accepted but never returned. -- **Blurhashes can be missing.** `ImageBlurHashes` carries the real blurhash that the artwork - pipeline computes and stores. Until that happens for an item, the field is omitted: a fake value - would pin the wrong placeholder in clients that key their image cache on it. -- **The WebSocket only keeps alive; it pushes no events.** `GET socket` sends `ForceKeepAlive` and - answers `KeepAlive` pings, so real-time clients (Finamp) keep a working session instead of - reconnecting in a loop. It never pushes session, playstate or library-change events. -- **List lyric badges only see embedded lyrics.** Tracks with only sidecar or plugin lyrics show no - `HasLyrics` badge in lists, because those sources can't be known at list time without per-row I/O. - `PlaybackInfo` and `Audio/{id}/Lyrics` still find them (see "Lyrics"). diff --git a/server/jellyfin/annotations.go b/server/jellyfin/annotations.go deleted file mode 100644 index a589798be..000000000 --- a/server/jellyfin/annotations.go +++ /dev/null @@ -1,129 +0,0 @@ -package jellyfin - -import ( - "errors" - "math" - "net/http" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" -) - -// resolveAnnotated finds which annotated repo owns id, returning the resource name used in -// refreshResource events. Albums and songs 404 when the user can't access their library; artists -// span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time -// scoping. PlaylistRepository.Get enforces playlist visibility. When repo is nil the response has -// already been written, so callers must return without writing the annotation. -func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, resource string) { - ctx := r.Context() - entity, err := model.GetEntityByID(ctx, api.ds, id) - if err != nil && !errors.Is(err, model.ErrNotFound) { - api.internalError(w, r, err) - return nil, "" - } - u, _ := request.UserFrom(ctx) - switch e := entity.(type) { - case *model.Album: - if u.HasLibraryAccess(e.LibraryID) { - return api.ds.Album(), "album" - } - case *model.Artist: - return api.ds.Artist(), "artist" - case *model.MediaFile: - if u.HasLibraryAccess(e.LibraryID) { - return api.ds.MediaFile(), "song" - } - case *model.Playlist: - return api.ds.Playlist(), "playlist" - } - // Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent. - http.Error(w, "Not Found", http.StatusNotFound) - return nil, "" -} - -// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify -// fetches this per item to render played/favourite indicators; resolveItemByID enforces the -// library-access gate. -func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) { - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - item, ok := api.resolveItemByID(r.Context(), id, nil) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - data := item.UserData - if data == nil { - // Items without annotations still return a valid empty UserData. - data = dto.UserData(model.Annotations{}, id) - } - api.ok(w, r, data) -} - -func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) { - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - repo, resource := api.resolveAnnotated(w, r, id) - if repo == nil { - return - } - if err := repo.SetStar(r.Context(), starred, id); err != nil { - api.internalError(w, r, err) - return - } - api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id)) - encodedID := dto.EncodeID(id) - api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID}) -} - -func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) { api.setFavorite(w, r, true) } -func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) { - api.setFavorite(w, r, false) -} - -func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) { - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - repo, resource := api.resolveAnnotated(w, r, id) - if repo == nil { - return - } - if err := repo.SetRating(r.Context(), rating, id); err != nil { - api.internalError(w, r, err) - return - } - api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id)) - encodedID := dto.EncodeID(id) - d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID} - if rating > 0 { - jfRating := float64(rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10, mirrors dto.UserData - d.Rating = &jfRating - } - api.ok(w, r, d) -} - -// setRating maps Jellyfin's 0-10 rating (a nullable double, so fractional values are valid) to -// Navidrome's 0-5 stars. A nonzero rating floors at one star: rounding to 0 would clear it, since -// SetRating(0) is the delete path. -func (api *Router) setRating(w http.ResponseWriter, r *http.Request) { - jfRating := req.Params(r).Float64Or("rating", 0) - jfRating = min(max(jfRating, 0), 10) // clamp: a client sending e.g. Rating=100 must not write an out-of-domain rating - rating := int(math.Round(jfRating / 2)) - if jfRating > 0 { - rating = max(rating, 1) - } - api.setItemRating(w, r, rating) -} - -func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) { - api.setItemRating(w, r, 0) -} diff --git a/server/jellyfin/annotations_test.go b/server/jellyfin/annotations_test.go deleted file mode 100644 index a436503ba..000000000 --- a/server/jellyfin/annotations_test.go +++ /dev/null @@ -1,319 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Annotations", func() { - var api *Router - var ds *tests.MockDataStore - var broker *fakeEventBroker - // alice has access to library 1 only. - ctxUser := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) - } - - BeforeEach(func() { - ds = &tests.MockDataStore{} - broker = &fakeEventBroker{} - api = &Router{ds: ds, broker: broker} - }) - - Describe("markFavorite / unmarkFavorite", func() { - It("stars a song and returns IsFavorite=true", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var d dto.UserItemDataDto - Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) - Expect(d.IsFavorite).To(BeTrue()) - Expect(mfRepo.Data[testID("s1")].Starred).To(BeTrue()) - }) - - It("stars an album and returns IsFavorite=true", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var d dto.UserItemDataDto - Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) - Expect(d.IsFavorite).To(BeTrue()) - Expect(albumRepo.Data[testID("a1")].Starred).To(BeTrue()) - }) - - It("stars an artist without checking library access (artists span multiple libraries)", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - // alice only has access to library 1, but artists aren't gated per-library. - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/ar1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("ar1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var d dto.UserItemDataDto - Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) - Expect(d.IsFavorite).To(BeTrue()) - Expect(artistRepo.Data[testID("ar1")].Starred).To(BeTrue()) - }) - - It("stars a visible playlist", func() { - playlistRepo := ds.Playlist().(*tests.MockPlaylistRepo) - playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("u1")}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(playlistRepo.Starred[testID("p1")]).To(BeTrue()) - }) - - It("unstars a song and returns IsFavorite=false", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Starred: true}}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.unmarkFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var d dto.UserItemDataDto - Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) - Expect(d.IsFavorite).To(BeFalse()) - Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse()) - }) - - It("returns 404 and does not star an album in a library the user can't access", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(albumRepo.Data[testID("a1")].Starred).To(BeFalse()) - }) - - It("returns 404 and does not star a song in a library the user can't access", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse()) - }) - - It("returns 404 when the id doesn't match any entity", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/missing", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 500 (not 404) when a repository lookup fails for a reason other than not-found", func() { - ds.Album().(*tests.MockAlbumRepo).SetError(true) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/x1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("x1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - - It("emits a refreshResource event when starring a song", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/s1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.markFavorite, w, r) - Expect(broker.Events).To(HaveLen(1)) - Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`)) - }) - - It("emits a refreshResource event when starring an album", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.markFavorite, w, r) - Expect(broker.Events).To(HaveLen(1)) - Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"album":["` + testID("a1") + `"]}`)) - }) - - It("does not emit an event when the item is not accessible", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/FavoriteItems/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.markFavorite, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(broker.Events).To(BeEmpty()) - }) - }) - - Describe("setRating / removeRating", func() { - It("maps a Jellyfin 0-10 rating to Navidrome's 0-5 scale", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) - var d dto.UserItemDataDto - Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) - Expect(d.Rating).NotTo(BeNil()) - Expect(*d.Rating).To(Equal(8.0)) - }) - - It("rates an album", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(5)) - }) - - It("rates a visible playlist", func() { - playlistRepo := ds.Playlist().(*tests.MockPlaylistRepo) - playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "Mix", OwnerID: testID("u1")}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("p1"))+"/Rating?Rating=8", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(playlistRepo.Ratings[testID("p1")]).To(Equal(4)) - }) - - It("removes a rating", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Users/u1/Items/s1/Rating", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.removeRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0)) - var d dto.UserItemDataDto - Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed()) - Expect(d.Rating).To(BeNil()) - }) - - It("returns 404 and does not rate an album in a library the user can't access", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/"+dto.EncodeID(testID("a1"))+"/Rating?Rating=10", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(0)) - }) - - It("rounds an odd rating to the nearest star instead of truncating", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=9", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5)) - }) - - It("stores the minimum star for Rating=1 instead of clearing the rating", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: model.Annotations{Rating: 4}}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=1", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1)) - }) - - It("accepts a fractional rating (UserItemDataDto.Rating is a double)", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=7.5", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4)) - }) - - It("clamps a Rating above 10 to Navidrome's max (5)", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5)) - }) - - It("clamps a negative Rating to Navidrome's min (0)", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0)) - }) - - It("emits a refreshResource event when rating a song", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.setRating, w, r) - Expect(broker.Events).To(HaveLen(1)) - Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`)) - }) - }) -}) - -type fakeEventBroker struct { - http.Handler - Events []events.Event -} - -func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { - f.Events = append(f.Events, event) -} - -func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { - f.Events = append(f.Events, event) -} - -var _ events.Broker = (*fakeEventBroker)(nil) diff --git a/server/jellyfin/api.go b/server/jellyfin/api.go deleted file mode 100644 index 4a471d1f0..000000000 --- a/server/jellyfin/api.go +++ /dev/null @@ -1,265 +0,0 @@ -package jellyfin - -import ( - "encoding/json" - "net/http" - "time" - - "github.com/go-chi/chi/v5" - "golang.org/x/sync/singleflight" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/lyrics" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/quickconnect" - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/core/sonic" - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/cache" -) - -type Router struct { - http.Handler - ds model.DataStore - artwork artwork.Artwork - streamer stream.MediaStreamer - transcodeDecider stream.TranscodeDecider - players core.Players - scrobbler scrobbler.PlayTracker - playlists playlists.Playlists - provider external.Provider - sonic sonic.Engine - lyrics lyrics.Lyrics - broker events.Broker - lyricsCache cache.SimpleCache[string, model.LyricList] - similarFlight singleflight.Group - quickConnect quickconnect.QuickConnect - serverIDVal string -} - -func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, - transcodeDecider stream.TranscodeDecider, players core.Players, - scrobbler scrobbler.PlayTracker, playlists playlists.Playlists, provider external.Provider, - sonicSvc sonic.Engine, lyricsSvc lyrics.Lyrics, broker events.Broker, quickConnect quickconnect.QuickConnect) *Router { - r := &Router{ - ds: ds, artwork: artwork, streamer: streamer, transcodeDecider: transcodeDecider, - players: players, scrobbler: scrobbler, playlists: playlists, provider: provider, - sonic: sonicSvc, lyrics: lyricsSvc, broker: broker, quickConnect: quickConnect, - lyricsCache: cache.NewSimpleCache[string, model.LyricList](cache.Options{ - SizeLimit: 1000, - DefaultTTL: 5 * time.Minute, - }), - } - r.Handler = r.routes() - return r -} - -func (api *Router) routes() http.Handler { - inner := chi.NewRouter() - - // Read query params case-insensitively, like real Jellyfin. Must precede all routes so every - // handler and the api_key check see folded keys. - inner.Use(normalizeQueryKeys) - - // Routes are lowercase; caseInsensitivePaths lowercases the request path. Keep new routes lowercase. - - // Public (no auth): handshake + login. - inner.Get("/system/info/public", api.getPublicSystemInfo) - inner.Get("/system/ping", api.ping) - inner.Post("/system/ping", api.ping) - inner.Get("/quickconnect/enabled", api.quickConnectEnabled) - // Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated - // brute-force surface, so it must share the same per-client throttle when one is configured. - login := inner.With(server.LimitLoginBody) - if conf.Server.AuthRequestLimit > 0 { - login = login.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) - } - login.Post("/users/authenticatebyname", api.authenticateByName) - quickConnectLogin := login.With(requireQuickConnect) - quickConnectLogin.Post("/quickconnect/initiate", api.quickConnectInitiate) - quickConnectLogin.Post("/users/authenticatewithquickconnect", api.authenticateWithQuickConnect) - // Not rate-limited: Finamp and Streamyfin poll it every second while the code is shown. - inner.With(requireQuickConnect).Get("/quickconnect/connect", api.quickConnectConnect) - inner.Get("/users/public", api.getPublicUsers) - - // Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling. - // Bound concurrency like Subsonic's getCoverArt: image decode/resize is CPU- and memory-heavy, - // and an unbounded burst (a client fetching artwork across a large library) can exhaust memory. - inner.Group(func(r chi.Router) { - r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, - conf.Server.DevArtworkThrottleBacklogTimeout)) - r.Get("/items/{itemId}/images/{type}", api.getItemImage) - r.Get("/items/{itemId}/images/{type}/{index}", api.getItemImage) - r.Head("/items/{itemId}/images/{type}", api.getItemImage) - r.Head("/items/{itemId}/images/{type}/{index}", api.getItemImage) - }) - - inner.Group(func(r chi.Router) { - r.Use(api.authenticate) - // Register/refresh the calling device as a player on every authenticated request, like - // Subsonic's getPlayer, so Jellyfin clients show up in the players list (and scrobbling has a - // player) even before the first playback report. - r.Use(api.withPlayer) - r.Get("/system/info", api.getSystemInfo) - r.Get("/system/endpoint", api.getEndpointInfo) - r.Get("/userviews", api.getUserViews) - r.Get("/users/{userId}/views", api.getUserViews) - r.Get("/users/me", api.getCurrentUser) - r.Get("/users/{userId}", api.getCurrentUser) - // Throttled like login so a signed-in user cannot enumerate other people's pending codes. - approve := r.With(requireQuickConnect) - if conf.Server.AuthRequestLimit > 0 { - approve = approve.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) - } - approve.Post("/quickconnect/authorize", api.quickConnectAuthorize) - - // Cursor-backed collections: each streams straight from the DB, holding a connection for the - // whole client-paced response, so enough slow clients would take the entire pool and stall the - // scanner, scrobbles and the UI. Cap them at half the pool (see conf.MaxOpenConns); excess - // requests queue rather than fail. - r.Group(func(r chi.Router) { - r.Use(throttleStreams(conf.Server.Jellyfin.MaxConcurrentStreams)) - r.Get("/items", api.getItems) - r.Get("/users/{userId}/items", api.getItems) - r.Get("/items/latest", api.getLatest) - r.Get("/users/{userId}/items/latest", api.getLatest) - r.Get("/artists", api.getArtists) - r.Get("/artists/albumartists", api.getAlbumArtists) - r.Get("/playlists/{playlistId}/items", api.getPlaylistItems) - }) - - r.Get("/items/{itemId}", api.getItem) - r.Get("/users/{userId}/items/{itemId}", api.getItem) - r.Delete("/items/{itemId}", api.deleteItem) - - // /UserFavoriteItems is the current @jellyfin/sdk spelling (Jellify); the - // /Users/{userId}/FavoriteItems form is the legacy one Finamp still uses. - r.Post("/userfavoriteitems/{itemId}", api.markFavorite) - r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite) - r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite) - r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite) - r.Post("/users/{userId}/items/{itemId}/rating", api.setRating) - r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating) - - // Per-item play/favorite/rating state. Jellify uses the /UserItems form; - // /Users/{userId}/Items is the legacy spelling. - r.Get("/useritems/{itemId}/userdata", api.getUserItemData) - r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData) - - r.Get("/artists/{itemId}/similar", api.getSimilarArtists) - r.Get("/items/{itemId}/similar", api.getSimilarItems) - r.Get("/albums/{itemId}/similar", api.getSimilarAlbums) - r.Get("/items/{itemId}/instantmix", api.getInstantMix) - r.Get("/songs/{itemId}/instantmix", api.getInstantMix) - r.Get("/albums/{itemId}/instantmix", api.getInstantMix) - r.Get("/artists/{itemId}/instantmix", api.getInstantMix) - r.Get("/playlists/{itemId}/instantmix", api.getInstantMix) - r.Get("/artists/instantmix", api.getInstantMixByQuery) - r.Get("/musicgenres/instantmix", api.getInstantMixByQuery) - r.Get("/genres", api.getGenres) - r.Get("/musicgenres", api.getGenres) - r.Get("/studios", api.getStudios) - r.Get("/items/filters", api.getQueryFiltersLegacy) - - r.Post("/playlists", api.createPlaylist) - r.Get("/playlists/{playlistId}", api.getPlaylist) - r.Post("/playlists/{playlistId}", api.updatePlaylist) - r.Post("/playlists/{playlistId}/items", api.addToPlaylist) - r.Delete("/playlists/{playlistId}/items", api.removeFromPlaylist) - r.Post("/playlists/{playlistId}/items/{entryId}/move/{newIndex}", api.movePlaylistItem) - r.Get("/playlists/{playlistId}/users", api.getPlaylistUsers) - r.Get("/playlists/{playlistId}/users/{userId}", api.getPlaylistUser) - - // Cover upload/delete: only playlists are writable (see postItemImage); the GET routes - // above stay public. - r.Post("/items/{itemId}/images/{type}", api.postItemImage) - r.Delete("/items/{itemId}/images/{type}", api.deleteItemImage) - - r.Get("/audio/{itemId}/stream", api.streamAudio) - r.Get("/audio/{itemId}/stream.{container}", api.streamAudio) - r.Get("/audio/{itemId}/universal", api.streamUniversal) - // Fintunes probes these with HEAD for the content type before playing or downloading. - r.Head("/audio/{itemId}/stream", api.streamAudio) - r.Head("/audio/{itemId}/stream.{container}", api.streamAudio) - r.Head("/audio/{itemId}/universal", api.streamUniversal) - r.Get("/audio/{itemId}/main.m3u8", api.streamHls) - r.Get("/items/{itemId}/playbackinfo", api.getPlaybackInfo) - r.Post("/items/{itemId}/playbackinfo", api.getPlaybackInfo) - r.Get("/audio/{itemId}/lyrics", api.getLyrics) - // Direct-file endpoints: some clients (Finamp's just_audio) fetch here instead of - // /Audio/{id}/stream; /Download reuses the direct-play handler as Jellyfin serves the same file. - r.Get("/items/{itemId}/file", api.streamFile) - r.Get("/items/{itemId}/download", api.streamFile) - r.Head("/items/{itemId}/file", api.streamFile) - r.Head("/items/{itemId}/download", api.streamFile) - - r.Post("/sessions/playing", api.reportPlaybackStart) - r.Post("/sessions/playing/progress", api.reportPlaybackProgress) - r.Post("/sessions/playing/stopped", api.reportPlaybackStopped) - r.Post("/sessions/playing/ping", api.acknowledge) - r.Post("/sessions/capabilities", api.acknowledge) - r.Post("/sessions/capabilities/full", api.acknowledge) - - // Real-time clients (e.g. Finamp) open this right after login; without it they 404-loop-reconnect. - r.Get("/socket", api.handleSocket) - - r.Get("/audiomuseai/info", api.audioMuseInfo) - r.Get("/audiomuseai/health", api.audioMuseHealth) - r.Get("/audiomuseai/similar_tracks", api.audioMuseSimilarTracks) - r.Get("/audiomuseai/find_path", api.audioMuseFindPath) - }) - - // Logged at Debug, not Warn/Error: clients probing for optional/legacy endpoints is expected - // traffic, and this just surfaces what's missing. - inner.NotFound(api.notFound) - inner.MethodNotAllowed(api.notFound) - - // Real Jellyfin clients route case-insensitively; chi does not. - return caseInsensitivePaths(inner) -} - -// ok writes payload as JSON — the single entry point for every handler. Collections are routed to -// the streaming writer, so callers needn't know whether theirs is cursor-backed. ServerId is stamped -// on any item(s): real Jellyfin always sets it, and it's constant per request. -// -// Only /Items/Latest bypasses this, for its bare-array shape (see writeItemsArray). -func (api *Router) ok(w http.ResponseWriter, r *http.Request, payload any) { - switch p := payload.(type) { - case itemsResult: - api.writeItems(w, r, p) - return - case dto.QueryResult: - api.writeItems(w, r, materialized(p)) - return - case dto.BaseItemDto: - payload = stampItem(p, api.serverID(r.Context()), requestFields(r)) - } - w.Header().Set("Content-Type", "application/json; charset=utf-8") - if err := json.NewEncoder(w).Encode(payload); err != nil { - log.Error(r.Context(), "Jellyfin API: error encoding response", err) - } -} - -// notFound handles unmatched routes and unsupported methods, logging them so unimplemented -// endpoints surface instead of returning chi's default plain-text 404/405. -func (api *Router) notFound(w http.ResponseWriter, r *http.Request) { - log.Debug(r.Context(), "Jellyfin API: unhandled route", "method", r.Method, "path", r.URL.Path) - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{}`)) -} - -// internalError logs the real error and writes a generic 500, so internal detail (ffmpeg output, -// file paths) never reaches the client. -func (api *Router) internalError(w http.ResponseWriter, r *http.Request, err error) { - log.Error(r.Context(), "Jellyfin API: internal error", "method", r.Method, "path", r.URL.Path, err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) -} diff --git a/server/jellyfin/api_test.go b/server/jellyfin/api_test.go deleted file mode 100644 index 0c6ac9b98..000000000 --- a/server/jellyfin/api_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package jellyfin - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "time" - - "github.com/go-chi/chi/v5/middleware" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/quickconnect" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Router", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = GinkgoT().Context() - }) - - It("serves the public handshake through the mounted handler", func() { - ds := &tests.MockDataStore{} - api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Info/Public", nil) - api.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - }) - - It("returns 404 JSON for unknown routes", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Nonexistent/Route", nil) - api.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) - Expect(w.Body.String()).To(Equal("{}")) - }) - - It("returns 404 JSON for a known path with an unsupported method", func() { - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - w := httptest.NewRecorder() - r := httptest.NewRequest("PATCH", "/System/Info/Public", nil) - api.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(w.Body.String()).To(Equal("{}")) - }) - - It("registers a player on a general authenticated request, not just playback reports", func() { - ds := &tests.MockDataStore{} - auth.Init(ds) - ur := ds.User().(*tests.MockedUserRepo) - Expect(ur.Put(ctx, &model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) - token, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: "alice"}) - Expect(err).ToNot(HaveOccurred()) - - fp := &fakePlayers{} - api := New(ds, nil, nil, nil, fp, nil, nil, nil, nil, nil, nil, nil) - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Users/Me", nil) - r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Jellify", Device="Phone", DeviceId="dev-1", Version="1.0"`) - r.Header.Set("X-Emby-Token", token) - api.ServeHTTP(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fp.registerCalls).To(Equal(1)) - Expect(fp.lastClient).To(Equal("Jellify")) - }) - - It("rate-limits AuthenticateByName by IP when a login limit is configured", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.AuthRequestLimit = 2 - conf.Server.AuthWindowLength = time.Minute - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - - login := func() int { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`)) - r.RemoteAddr = "10.0.0.1:1234" - api.ServeHTTP(w, r) - return w.Code - } - // The bad credentials would be 401; the limiter cuts in on the 3rd attempt with 429. - Expect(login()).To(Equal(http.StatusUnauthorized)) - Expect(login()).To(Equal(http.StatusUnauthorized)) - Expect(login()).To(Equal(http.StatusTooManyRequests)) - }) - - It("rate-limits Quick Connect approval by IP when a login limit is configured", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.AuthRequestLimit = 2 - conf.Server.AuthWindowLength = time.Minute - conf.Server.Jellyfin.QuickConnect = true - ds := &tests.MockDataStore{} - auth.Init(ds) - usr := model.User{ID: testID("alice"), UserName: "alice"} - Expect(ds.User().Put(ctx, &usr)).To(Succeed()) - token, err := auth.CreateAPIToken(&usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - api := New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, quickconnect.New()) - - authorize := func() int { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/QuickConnect/Authorize?code=000000", nil) - r.RemoteAddr = "10.0.0.1:1234" - r.Header.Set("X-Emby-Token", token) - api.ServeHTTP(w, r) - return w.Code - } - // An unknown code is 404; the limiter cuts in on the 3rd attempt with 429. - Expect(authorize()).To(Equal(http.StatusNotFound)) - Expect(authorize()).To(Equal(http.StatusNotFound)) - Expect(authorize()).To(Equal(http.StatusTooManyRequests)) - }) - - It("rate-limits AuthenticateByName by resolved client IP, not by the proxy connection", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.AuthRequestLimit = 1 - conf.Server.AuthWindowLength = time.Minute - api := New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - // Every request arrives on the same proxy connection, so only the resolved client IP - // can separate the buckets. - handler := middleware.ClientIPFromHeader("X-Real-IP")(api) - - login := func(clientIP string) int { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(`{"Username":"x","Pw":"y"}`)) - r.RemoteAddr = "10.0.0.1:1234" - r.Header.Set("X-Real-IP", clientIP) - handler.ServeHTTP(w, r) - return w.Code - } - Expect(login("203.0.113.1")).To(Equal(http.StatusUnauthorized)) - Expect(login("203.0.113.1")).To(Equal(http.StatusTooManyRequests)) - Expect(login("203.0.113.2")).To(Equal(http.StatusUnauthorized)) - }) -}) diff --git a/server/jellyfin/audiomuse.go b/server/jellyfin/audiomuse.go deleted file mode 100644 index 4cc1a2cc5..000000000 --- a/server/jellyfin/audiomuse.go +++ /dev/null @@ -1,167 +0,0 @@ -package jellyfin - -import ( - "net/http" - "strings" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" -) - -// audioMuseEndpoints is what /AudioMuseAI/info advertises; it omits info itself, like the plugin, -// and is sorted the same way (the plugin builds it with OrderBy). -var audioMuseEndpoints = []string{ - "GET /AudioMuseAI/find_path", - "GET /AudioMuseAI/health", - "GET /AudioMuseAI/similar_tracks", -} - -type audioMuseInfoResponse struct { - Version string `json:"Version"` - AvailableEndpoints []string `json:"AvailableEndpoints"` -} - -func (api *Router) audioMuseInfo(w http.ResponseWriter, r *http.Request) { - endpoints := []string{} // non-nil so an empty list serializes as [], not null - if api.sonic != nil && api.sonic.HasProvider() { - endpoints = audioMuseEndpoints - } - api.ok(w, r, audioMuseInfoResponse{ - Version: consts.Version, - AvailableEndpoints: endpoints, - }) -} - -// audioMuseHealth is a liveness probe: 200 with an empty body when a sonic provider is loaded, else -// 404 — mirroring the reference plugin, which returns 200 when its backend is reachable. -func (api *Router) audioMuseHealth(w http.ResponseWriter, r *http.Request) { - if api.sonic == nil || !api.sonic.HasProvider() { - api.notFound(w, r) - return - } - w.WriteHeader(http.StatusOK) -} - -type audioMuseSimilarTrack struct { - Author string `json:"author"` - Distance float64 `json:"distance"` - ItemID string `json:"item_id"` - Title string `json:"title"` -} - -func (api *Router) audioMuseSimilarTracks(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - // 404 without a provider, like the Subsonic sonicSimilarity handlers. - if api.sonic == nil || !api.sonic.HasProvider() { - api.notFound(w, r) - return - } - p := req.Params(r) - tracks := []audioMuseSimilarTrack{} - - itemID := p.StringOr("item_id", "") - id, ok := dto.DecodeID(itemID) - if !ok { - // Every other failure here (no provider, no match) degrades to an empty list rather than an - // error, so a malformed id does too instead of being the one path that 404s. - api.ok(w, r, tracks) - return - } - n := min(p.IntOr("n", 10), maxSimilarLimit) // cap a user-controlled count, like clampLimit - eliminateDuplicates := p.BoolOr("eliminate_duplicates", true) - - matches, err := api.sonic.GetSonicSimilarTracks(ctx, id, n) - if err != nil { - api.ok(w, r, tracks) - return - } - - u, _ := request.UserFrom(ctx) - seenArtists := make(map[string]bool, len(matches)) - for _, m := range matches { - mf := m.MediaFile - if !u.HasLibraryAccess(mf.LibraryID) { - continue - } - if eliminateDuplicates { - key := strings.ToLower(mf.Artist) - if seenArtists[key] { - continue - } - seenArtists[key] = true - } - tracks = append(tracks, audioMuseSimilarTrack{ - Author: mf.Artist, - Distance: m.Similarity, - ItemID: dto.EncodeID(mf.ID), - Title: mf.Title, - }) - } - api.ok(w, r, tracks) -} - -type audioMusePathTrack struct { - Author string `json:"author"` - ItemID string `json:"item_id"` - Title string `json:"title"` - Tempo *float64 `json:"tempo,omitempty"` -} - -type audioMusePathResponse struct { - Path []audioMusePathTrack `json:"path"` - TotalDistance float64 `json:"total_distance"` -} - -func (api *Router) audioMuseFindPath(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - if api.sonic == nil || !api.sonic.HasProvider() { - api.notFound(w, r) - return - } - p := req.Params(r) - - startID := p.StringOr("start_song_id", "") - endID := p.StringOr("end_song_id", "") - if startID == "" || endID == "" { - http.Error(w, "start_song_id and end_song_id are required.", http.StatusBadRequest) - return - } - - resp := audioMusePathResponse{Path: []audioMusePathTrack{}} - startDecoded, startOk := dto.DecodeID(startID) - endDecoded, endOk := dto.DecodeID(endID) - if !startOk || !endOk { - // This endpoint already 400s when an endpoint id is absent, so an unusable one reports the - // same way rather than looking like a successful search that found no path. - http.Error(w, "start_song_id and end_song_id must be valid item ids.", http.StatusBadRequest) - return - } - maxSteps := min(p.IntOr("max_steps", 25), maxSimilarLimit) // cap a user-controlled count - matches, err := api.sonic.FindSonicPath(ctx, startDecoded, endDecoded, maxSteps) - if err != nil { - api.ok(w, r, resp) - return - } - - u, _ := request.UserFrom(ctx) - for _, m := range matches { - mf := m.MediaFile - if !u.HasLibraryAccess(mf.LibraryID) { - continue - } - track := audioMusePathTrack{ - Author: mf.Artist, - ItemID: dto.EncodeID(mf.ID), - Title: mf.Title, - } - if mf.BPM != nil { - tempo := float64(*mf.BPM) - track.Tempo = &tempo - } - resp.Path = append(resp.Path, track) - resp.TotalDistance += m.Similarity - } - api.ok(w, r, resp) -} diff --git a/server/jellyfin/audiomuse_test.go b/server/jellyfin/audiomuse_test.go deleted file mode 100644 index 6815753fd..000000000 --- a/server/jellyfin/audiomuse_test.go +++ /dev/null @@ -1,266 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strings" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/sonic" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("AudioMuse info", func() { - It("lists the sonic endpoints (excluding info) when a provider is present", func() { - api := &Router{sonic: &fakeSonicEngine{provider: true}} - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/AudioMuseAI/info", nil) - - api.audioMuseInfo(w, r) - - Expect(w.Code).To(Equal(200)) - var body audioMuseInfoResponse - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) - Expect(body.Version).To(Equal(consts.Version)) - Expect(body.AvailableEndpoints).To(ConsistOf( - "GET /AudioMuseAI/find_path", - "GET /AudioMuseAI/health", - "GET /AudioMuseAI/similar_tracks", - )) - }) - - It("returns an empty endpoint list when no provider is loaded", func() { - api := &Router{} - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/AudioMuseAI/info", nil) - - api.audioMuseInfo(w, r) - - Expect(w.Code).To(Equal(200)) - var body audioMuseInfoResponse - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) - Expect(body.AvailableEndpoints).To(BeEmpty()) - Expect(w.Body.String()).To(ContainSubstring(`"AvailableEndpoints":[]`)) - }) -}) - -type fakeSonicEngine struct { - provider bool - similar []sonic.SimilarMatch - similarErr error - path []sonic.SimilarMatch - pathErr error - gotID string - gotStart string - gotEnd string - gotCount int -} - -func (f *fakeSonicEngine) HasProvider() bool { return f.provider } - -func (f *fakeSonicEngine) GetSonicSimilarTracks(_ context.Context, id string, count int) ([]sonic.SimilarMatch, error) { - f.gotID, f.gotCount = id, count - return f.similar, f.similarErr -} - -func (f *fakeSonicEngine) FindSonicPath(_ context.Context, startID, endID string, count int) ([]sonic.SimilarMatch, error) { - f.gotStart, f.gotEnd, f.gotCount = startID, endID, count - return f.path, f.pathErr -} - -func mf(id, artist, title string, lib int) model.MediaFile { - return model.MediaFile{ID: id, Artist: artist, Title: title, LibraryID: lib} -} - -var _ = Describe("AudioMuse health", func() { - It("returns 200 with an empty body when a provider is loaded", func() { - api := &Router{sonic: &fakeSonicEngine{provider: true}} - w := audioMuseGet(api.audioMuseHealth, "/AudioMuseAI/health", "", model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(200)) - Expect(w.Body.Len()).To(Equal(0)) - }) - - It("returns 404 when no provider is loaded", func() { - api := &Router{} - w := audioMuseGet(api.audioMuseHealth, "/AudioMuseAI/health", "", model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(404)) - }) -}) - -// audioMuseGet drives a GET through normalizeQueryKeys as the given user, mirroring a real request. -func audioMuseGet(handler http.HandlerFunc, path, query string, user model.User) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", path+"?"+query, nil) - r = r.WithContext(request.WithUser(r.Context(), user)) - invoke(handler, w, r) - return w -} - -var _ = Describe("AudioMuse similar_tracks", func() { - var fake *fakeSonicEngine - var api *Router - - call := func(query string, user model.User) *httptest.ResponseRecorder { - return audioMuseGet(api.audioMuseSimilarTracks, "/AudioMuseAI/similar_tracks", query, user) - } - - BeforeEach(func() { - fake = &fakeSonicEngine{provider: true} - api = &Router{sonic: fake} - }) - - It("maps matches, decodes the seed id, encodes item ids, copies distance", func() { - fake.similar = []sonic.SimilarMatch{ - {MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}, - {MediaFile: mf(testID("mf2"), "B", "T2", 1), Similarity: 0.5}, - } - w := call("item_id="+dto.EncodeID(testID("seed"))+"&n=5", model.User{IsAdmin: true}) - - Expect(w.Code).To(Equal(200)) - Expect(fake.gotID).To(Equal(testID("seed"))) - Expect(fake.gotCount).To(Equal(5)) - var body []audioMuseSimilarTrack - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) - Expect(body).To(HaveLen(2)) - Expect(body[0]).To(Equal(audioMuseSimilarTrack{ - Author: "A", Distance: 0.3, ItemID: dto.EncodeID(testID("mf1")), Title: "T1", - })) - }) - - It("collapses to one track per artist when eliminate_duplicates defaults on", func() { - fake.similar = []sonic.SimilarMatch{ - {MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}, - {MediaFile: mf(testID("mf2"), "A", "T2", 1), Similarity: 0.5}, - } - w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true}) - var body []audioMuseSimilarTrack - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) - Expect(body).To(HaveLen(1)) - }) - - It("keeps same-artist tracks when eliminate_duplicates=false", func() { - fake.similar = []sonic.SimilarMatch{ - {MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}, - {MediaFile: mf(testID("mf2"), "A", "T2", 1), Similarity: 0.5}, - } - w := call("item_id="+dto.EncodeID(testID("seed"))+"&eliminate_duplicates=false", model.User{IsAdmin: true}) - var body []audioMuseSimilarTrack - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) - Expect(body).To(HaveLen(2)) - }) - - It("filters out tracks in libraries the user cannot access", func() { - fake.similar = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 2), Similarity: 0.3}} - w := call("item_id="+dto.EncodeID(testID("seed")), model.User{Libraries: model.Libraries{{ID: 1}}}) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) - }) - - It("returns an empty array without calling the engine when item_id is missing", func() { - w := call("n=5", model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(200)) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) - Expect(fake.gotID).To(Equal("")) - }) - - // A malformed item_id degrades the same way as a missing one (200, empty array) rather than - // 404ing: every other failure on this endpoint already degrades gracefully. - It("returns an empty array without calling the engine when item_id is malformed", func() { - w := call("item_id=not-a-valid-id", model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(200)) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) - Expect(fake.gotID).To(Equal("")) - }) - - It("returns 404 when no sonic provider is loaded", func() { - fake.provider = false - w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(404)) - }) - - It("returns an empty array when the engine errors", func() { - fake.similarErr = errors.New("boom") - fake.similar = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 0.3}} - w := call("item_id="+dto.EncodeID(testID("seed")), model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(200)) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) - }) -}) - -var _ = Describe("AudioMuse find_path", func() { - var fake *fakeSonicEngine - var api *Router - - call := func(query string, user model.User) *httptest.ResponseRecorder { - return audioMuseGet(api.audioMuseFindPath, "/AudioMuseAI/find_path", query, user) - } - - BeforeEach(func() { - fake = &fakeSonicEngine{provider: true} - api = &Router{sonic: fake} - }) - - It("returns 400 with the exact message when start_song_id is missing", func() { - w := call("end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(400)) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required.")) - }) - - It("returns 400 when end_song_id is missing", func() { - w := call("start_song_id="+dto.EncodeID(testID("s")), model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(400)) - }) - - It("maps the path, decodes ids, sums total_distance, fills tempo from BPM", func() { - bpm := 120 - withBPM := mf(testID("mf1"), "A", "T1", 1) - withBPM.BPM = &bpm - fake.path = []sonic.SimilarMatch{ - {MediaFile: withBPM, Similarity: 1.5}, - {MediaFile: mf(testID("mf2"), "B", "T2", 1), Similarity: 2.0}, - } - w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e"))+"&max_steps=10", model.User{IsAdmin: true}) - - Expect(w.Code).To(Equal(200)) - Expect(fake.gotStart).To(Equal(testID("s"))) - Expect(fake.gotEnd).To(Equal(testID("e"))) - Expect(fake.gotCount).To(Equal(10)) - var body audioMusePathResponse - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) - Expect(body.Path).To(HaveLen(2)) - Expect(body.TotalDistance).To(Equal(3.5)) - Expect(body.Path[0].ItemID).To(Equal(dto.EncodeID(testID("mf1")))) - Expect(*body.Path[0].Tempo).To(Equal(120.0)) - Expect(body.Path[1].Tempo).To(BeNil()) - }) - - It("returns 404 when no sonic provider is loaded", func() { - fake.provider = false - w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(404)) - }) - - It("returns an empty path object when the engine errors", func() { - fake.pathErr = errors.New("boom") - fake.path = []sonic.SimilarMatch{{MediaFile: mf(testID("mf1"), "A", "T1", 1), Similarity: 1.0}} - w := call("start_song_id="+dto.EncodeID(testID("s"))+"&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(200)) - var body audioMusePathResponse - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed()) - Expect(body.Path).To(BeEmpty()) - Expect(body.TotalDistance).To(Equal(0.0)) - }) - - It("400s without calling the engine when an id is malformed", func() { - w := call("start_song_id=not-a-valid-id&end_song_id="+dto.EncodeID(testID("e")), model.User{IsAdmin: true}) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(fake.gotStart).To(Equal("")) - Expect(fake.gotEnd).To(Equal("")) - }) -}) diff --git a/server/jellyfin/auth.go b/server/jellyfin/auth.go deleted file mode 100644 index 264d5b6ca..000000000 --- a/server/jellyfin/auth.go +++ /dev/null @@ -1,140 +0,0 @@ -package jellyfin - -import ( - "encoding/json" - "net/http" - - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/jellyfin/dto" -) - -type authenticateByNameRequest struct { - Username string `json:"Username"` - Pw string `json:"Pw"` -} - -func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - var body authenticateByNameRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - - // Navidrome stores recoverable passwords; this mirrors Subsonic's validateCredentials plaintext path. - usr, err := api.ds.User().FindByUsernameWithPassword(ctx, body.Username) - if body.Pw == "" || err != nil || usr == nil || usr.Password != body.Pw { - log.Warn(ctx, "Jellyfin API: invalid login", "username", body.Username, "remoteAddr", r.RemoteAddr) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - api.signIn(w, r, usr) -} - -func (api *Router) signIn(w http.ResponseWriter, r *http.Request, usr *model.User) { - ctx := r.Context() - // Best-effort, like the web UI's validateLogin: without it, Jellyfin-only users show a - // never/stale "Last Login" in the admin UI. - if err := api.ds.User().UpdateLastLoginAt(ctx, usr.ID); err != nil { - log.Error(ctx, "Jellyfin API: could not update last login date", "username", usr.UserName, err) - } - - token, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - if err != nil { - api.internalError(w, r, err) - return - } - - a := parseMediaBrowserAuth(r) - serverID := api.serverID(ctx) - api.ok(w, r, dto.AuthenticationResult{ - User: userToDto(usr, serverName(), serverID), - SessionInfo: dto.NewSessionInfo(usr, a.Client, a.DeviceId, a.Device, a.Version, serverID), - AccessToken: token, - ServerId: serverID, - }) -} - -// userToDto builds the User object clients expect. Finamp reads Policy and Configuration right after -// login and null-crashes if absent, so both are filled with Navidrome-appropriate defaults. -func userToDto(u *model.User, serverName, serverID string) *dto.UserDto { - return &dto.UserDto{ - Name: u.UserName, - Id: dto.EncodeID(u.ID), // hex like every other id, so lowercased paths stay valid - ServerId: serverID, - ServerName: serverName, - HasPassword: true, - HasConfiguredPassword: true, - Policy: userPolicy(u), - Configuration: userConfiguration(), - } -} - -func userPolicy(u *model.User) *dto.UserPolicy { - return &dto.UserPolicy{ - IsAdministrator: u.IsAdmin, - IsHidden: false, - EnableCollectionManagement: false, - EnableSubtitleManagement: false, - EnableLyricManagement: false, - IsDisabled: false, - BlockedTags: []string{}, - AllowedTags: []string{}, - EnableUserPreferenceAccess: true, - AccessSchedules: []string{}, - BlockUnratedItems: []string{}, - EnableRemoteControlOfOtherUsers: false, - EnableSharedDeviceControl: false, - EnableRemoteAccess: true, - EnableLiveTvManagement: false, - EnableLiveTvAccess: false, - EnableMediaPlayback: true, - EnableAudioPlaybackTranscoding: true, - EnableVideoPlaybackTranscoding: true, - EnablePlaybackRemuxing: true, - ForceRemoteSourceTranscoding: false, - EnableContentDeletion: false, - EnableContentDeletionFromFolders: []string{}, - EnableContentDownloading: true, - EnableSyncTranscoding: true, - EnableMediaConversion: true, - EnabledDevices: []string{}, - EnableAllDevices: true, - EnabledChannels: []string{}, - EnableAllChannels: false, - EnabledFolders: []string{}, - EnableAllFolders: true, - InvalidLoginAttemptCount: 0, - LoginAttemptsBeforeLockout: -1, - MaxActiveSessions: 0, - EnablePublicSharing: true, - BlockedMediaFolders: []string{}, - BlockedChannels: []string{}, - RemoteClientBitrateLimit: 0, - AuthenticationProviderId: "", - PasswordResetProviderId: "", - SyncPlayAccess: "CreateAndJoinGroups", - } -} - -func userConfiguration() *dto.UserConfiguration { - return &dto.UserConfiguration{ - PlayDefaultAudioTrack: true, - SubtitleLanguagePreference: "", - DisplayMissingEpisodes: false, - GroupedFolders: []string{}, - SubtitleMode: "Default", - DisplayCollectionsView: false, - EnableLocalPassword: false, - OrderedViews: []string{}, - LatestItemsExcludes: []string{}, - MyMediaExcludes: []string{}, - HidePlayedInLatest: true, - RememberAudioSelections: true, - RememberSubtitleSelections: true, - EnableNextEpisodeAutoPlay: true, - CastReceiverId: "", - } -} diff --git a/server/jellyfin/auth_test.go b/server/jellyfin/auth_test.go deleted file mode 100644 index dafad9244..000000000 --- a/server/jellyfin/auth_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("AuthenticateByName", func() { - var ctx context.Context - var api *Router - var ds *tests.MockDataStore - BeforeEach(func() { - ctx = GinkgoT().Context() - ds = &tests.MockDataStore{} - auth.Init(ds) - ur := ds.User().(*tests.MockedUserRepo) - Expect(ur.Put(ctx, &model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) - api = &Router{ds: ds} - }) - - It("issues a token for valid credentials", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", - strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) - api.authenticateByName(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.AuthenticationResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.AccessToken).ToNot(BeEmpty()) - Expect(res.User.Name).To(Equal("alice")) - claims, err := auth.Validate(res.AccessToken) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.Subject).To(Equal("alice")) - - // Finamp reads Policy/Configuration right after login and null-crashes if they're absent. - Expect(res.User.Policy).ToNot(BeNil()) - Expect(res.User.Policy.IsAdministrator).To(BeFalse()) - Expect(res.User.Policy.EnableAllFolders).To(BeTrue()) - Expect(res.User.Policy.EnableMediaPlayback).To(BeTrue()) - Expect(res.User.Configuration).ToNot(BeNil()) - }) - - Describe("SessionInfo", func() { - login := func(authHeader string) map[string]any { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", - strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) - r.Header.Set("Authorization", authHeader) - api.authenticateByName(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var raw map[string]any - Expect(json.Unmarshal(w.Body.Bytes(), &raw)).To(Succeed()) - Expect(raw).To(HaveKey("SessionInfo")) - return raw["SessionInfo"].(map[string]any) - } - const jellybox = `MediaBrowser Client="JellyBox", Device="Mac", DeviceId="dev-1", Version="2.1"` - - It("has the fields strict clients require", func() { - s := login(jellybox) - Expect(s["Id"]).To(And(BeAssignableToTypeOf(""), Not(BeEmpty()))) - Expect(s["UserId"]).To(Equal(dto.EncodeID(testID("u1")))) - Expect(s["LastActivityDate"]).To(And(BeAssignableToTypeOf(""), Not(BeEmpty()))) - for _, k := range []string{"SupportsRemoteControl", "SupportsMediaControl", "HasCustomDeviceName"} { - Expect(s[k]).To(BeAssignableToTypeOf(false), k) - } - ps, ok := s["PlayState"].(map[string]any) - Expect(ok).To(BeTrue()) - for _, k := range []string{"CanSeek", "IsPaused", "IsMuted"} { - Expect(ps[k]).To(BeAssignableToTypeOf(false), k) - } - }) - - It("describes the calling client", func() { - s := login(jellybox) - Expect(s).To(HaveKeyWithValue("UserName", "alice")) - Expect(s).To(HaveKeyWithValue("Client", "JellyBox")) - Expect(s).To(HaveKeyWithValue("DeviceName", "Mac")) - Expect(s).To(HaveKeyWithValue("DeviceId", "dev-1")) - Expect(s).To(HaveKeyWithValue("ApplicationVersion", "2.1")) - Expect(s).To(HaveKeyWithValue("IsActive", true)) - }) - - It("keeps the same Id for the same device, and a new one for another device", func() { - first := login(jellybox)["Id"] - Expect(login(jellybox)["Id"]).To(Equal(first)) - other := login(`MediaBrowser Client="JellyBox", Device="Mac", DeviceId="dev-2", Version="2.1"`) - Expect(other["Id"]).ToNot(Equal(first)) - }) - }) - - It("records the login time, like the web UI login does", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", - strings.NewReader(`{"Username":"alice","Pw":"secret"}`)) - api.authenticateByName(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - ur := ds.User().(*tests.MockedUserRepo) - usr, err := ur.FindByUsername(ctx, "alice") - Expect(err).ToNot(HaveOccurred()) - Expect(usr.LastLoginAt).ToNot(BeNil()) - }) - - It("reflects an administrator in the User.Policy", func() { - ur := ds.User().(*tests.MockedUserRepo) - Expect(ur.Put(ctx, &model.User{ID: testID("admin1"), UserName: "root", NewPassword: "secret", IsAdmin: true})).To(Succeed()) - - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", - strings.NewReader(`{"Username":"root","Pw":"secret"}`)) - api.authenticateByName(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.AuthenticationResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.User.Policy).ToNot(BeNil()) - Expect(res.User.Policy.IsAdministrator).To(BeTrue()) - }) - - It("rejects invalid credentials with 401", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", - strings.NewReader(`{"Username":"alice","Pw":"wrong"}`)) - api.authenticateByName(w, r) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects an empty password even for a user with an empty stored password with 401", func() { - ur := ds.User().(*tests.MockedUserRepo) - Expect(ur.Put(ctx, &model.User{ID: testID("e"), UserName: "empty", NewPassword: ""})).To(Succeed()) - - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", - strings.NewReader(`{"Username":"empty","Pw":""}`)) - api.authenticateByName(w, r) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) -}) - -var _ = Describe("AuthenticateByName body limit", func() { - It("rejects a request body larger than the limit", func() { - ds := &tests.MockDataStore{} - api := &Router{ds: ds} - w := httptest.NewRecorder() - body := `{"Username":"alice","Pw":"secret","Padding":"` + strings.Repeat("x", server.MaxLoginBodySize) + `"}` - r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(body)) - server.LimitLoginBody(http.HandlerFunc(api.authenticateByName)).ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) -}) diff --git a/server/jellyfin/browsing.go b/server/jellyfin/browsing.go deleted file mode 100644 index baebbf0f3..000000000 --- a/server/jellyfin/browsing.go +++ /dev/null @@ -1,116 +0,0 @@ -package jellyfin - -import ( - "net/http" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/slice" -) - -// getArtists handles GET /Artists (performing artists, Finamp's "Artists" tab); getAlbumArtists -// handles GET /Artists/AlbumArtists (album artists only). Distinct roles, so composers/arrangers -// don't appear identically in both. -func (api *Router) getArtists(w http.ResponseWriter, r *http.Request) { - api.listArtistsByRole(w, r, model.RoleArtist) -} - -func (api *Router) getAlbumArtists(w http.ResponseWriter, r *http.Request) { - api.listArtistsByRole(w, r, model.RoleAlbumArtist) -} - -// listArtistsByRole is the shared body of the /Artists* handlers, scoping to ParentId's library -// when accessible (like queryItems) or all accessible libraries otherwise. -func (api *Router) listArtistsByRole(w http.ResponseWriter, r *http.Request, role model.Role) { - ctx := r.Context() - scopeIDs, _, ok := parentIDScope(ctx, r) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - genreIds, ok := decodedQueryIDs(r, "genreids") - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - // This route resolves its own scope, so it shares only the plain query params with /Items. - q := listParams(req.Params(r)) - q.scopeIDs = scopeIDs - q.genreIds = genreIds - - opts := model.QueryOptions{Offset: q.offset, Max: q.limit} - applySort(&opts, "MusicArtist", q.sortBy, q.sortOrder) - if q.search != "" { - opts.Max = clampLimit(opts.Max, defaultSearchLimit, maxSearchLimit) - } - - res, err := api.listArtists(ctx, opts, q, role) - if err != nil { - api.internalError(w, r, err) - return - } - api.ok(w, r, res) -} - -// getGenres handles /Genres and /MusicGenres. Genres are global, so no library scoping applies. -func (api *Router) getGenres(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - p := req.Params(r) - opts := model.QueryOptions{Offset: p.IntOr("startindex", 0), Max: p.IntOr("limit", 0)} - res, err := api.listGenres(ctx, opts) - if err != nil { - api.internalError(w, r, err) - return - } - api.ok(w, r, res) -} - -// getStudios handles GET /Studios, exposing record labels (Jellyfin's audio "studio" source) as -// Studio items, scoped to ParentId's library when accessible. -func (api *Router) getStudios(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - p := req.Params(r) - scope, _, ok := parentIDScope(ctx, r) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - opts := model.QueryOptions{Sort: "tag_value", Filters: libraryScopeFilter(scope)} - labels, err := api.ds.Tag().GetAll(ctx, model.TagRecordLabel, opts) - if err != nil { - api.internalError(w, r, err) - return - } - items := slice.Map(labels, dto.StudioToBaseItem) - offset, max := p.IntOr("startindex", 0), p.IntOr("limit", 0) - api.ok(w, r, result(paginate(items, offset, max), len(items), offset)) -} - -// getQueryFiltersLegacy handles GET /Items/Filters. Genres and Years are scoped to ParentId's -// library when accessible. Tags/OfficialRatings have no music source, so they are always empty. -func (api *Router) getQueryFiltersLegacy(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - scope, _, ok := parentIDScope(ctx, r) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - genreOpts := model.QueryOptions{Sort: "name", Filters: libraryScopeFilter(scope)} - genres, err := api.ds.Genre().GetAll(ctx, genreOpts) - if err != nil { - api.internalError(w, r, err) - return - } - years, err := api.ds.Album().GetYears(ctx, scope...) - if err != nil { - api.internalError(w, r, err) - return - } - api.ok(w, r, dto.QueryFiltersLegacy{ - Genres: slice.Map(genres, func(g model.Genre) string { return g.Name }), - Tags: []string{}, - OfficialRatings: []string{}, - Years: years, - }) -} diff --git a/server/jellyfin/browsing_test.go b/server/jellyfin/browsing_test.go deleted file mode 100644 index 48a62c4e5..000000000 --- a/server/jellyfin/browsing_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Browsing", func() { - var api *Router - var ds *tests.MockDataStore - ctxUser := func(libs model.Libraries) context.Context { - return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs}) - } - - // admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership. - ctxAdmin := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: testID("admin"), IsAdmin: true, Libraries: nil}) - } - - BeforeEach(func() { - ds = &tests.MockDataStore{} - api = &Router{ds: ds} - }) - - Describe("getArtists", func() { - It("lists artists via /Artists", func() { - ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Type).To(Equal("MusicArtist")) - }) - - It("handles /Artists/AlbumArtists the same way", func() { - ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "A"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Artists/AlbumArtists", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - }) - - It("scopes results to the user's accessible libraries", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxUser(libs)) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := artistRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_artist.library_id")) - Expect(args).To(ContainElements(1, 2)) - }) - - It("scopes to a single library when ParentId is an accessible library id", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Artists?ParentId="+dto.EncodeLibraryID(2), nil).WithContext(ctxUser(libs)) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := artistRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_artist.library_id")) - Expect(args).To(ContainElement(2)) - Expect(args).NotTo(ContainElement(1)) - }) - - It("does not let ParentId=<inaccessible library id> narrow the scope", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}} // no access to library 99 - r := httptest.NewRequest("GET", "/Artists?ParentId="+dto.EncodeLibraryID(99), nil).WithContext(ctxUser(libs)) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := artistRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_artist.library_id")) - Expect(args).To(ContainElement(1)) - Expect(args).NotTo(ContainElement(99)) - }) - - It("forwards SearchTerm to the repo's Search method", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - }) - - It("bounds a search the client left unbounded, and clamps an oversized one", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Artists?SearchTerm=art", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artistRepo.Options.Max).To(Equal(defaultSearchLimit + 1)) - - w = httptest.NewRecorder() - r = httptest.NewRequest("GET", "/Artists?SearchTerm=art&Limit=999999", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artistRepo.Options.Max).To(Equal(maxSearchLimit + 1)) - }) - - It("forwards StartIndex/Limit as Offset/Max", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Artists?StartIndex=5&Limit=10", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artistRepo.Options.Offset).To(Equal(5)) - Expect(artistRepo.Options.Max).To(Equal(10)) - }) - - It("does not restrict results for an admin user", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Artists", nil).WithContext(ctxAdmin()) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - // accessibleLibraryIDs is empty for an admin (Libraries is nil), so - // ApplyArtistLibraryFilter([]) is a no-op: no library_id restriction is added. - if artistRepo.Options.Filters == nil { - return - } - sql, _, err := artistRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).NotTo(ContainSubstring("library_artist.library_id")) - }) - - DescribeTable("restricts to favorites", - func(url string, handler func(*Router) http.HandlerFunc) { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", url, nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(handler(api), w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := artistRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("starred")) - // listArtists always ANDs notMissing, favorites filter or not. - Expect(sql).To(ContainSubstring("missing")) - Expect(args).To(ContainElement(true)) - }, - Entry("Filters=IsFavorite", "/Artists?Filters=IsFavorite", - func(a *Router) http.HandlerFunc { return a.getArtists }), - Entry("isFavorite=true", "/Artists?isFavorite=true", - func(a *Router) http.HandlerFunc { return a.getArtists }), - Entry("on /Artists/AlbumArtists", "/Artists/AlbumArtists?Filters=IsFavorite", - func(a *Router) http.HandlerFunc { return a.getAlbumArtists }), - ) - - It("404s a malformed ParentId instead of listing every library's artists", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Artists?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getArtists, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("getGenres", func() { - It("lists genres via /Genres", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Genres", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getGenres, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).NotTo(BeNil()) - }) - - It("handles /MusicGenres the same way", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/MusicGenres", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getGenres, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - }) - }) - - Describe("getStudios", func() { - It("scopes results to the user's accessible libraries", func() { - tagRepo := ds.Tag().(*tests.MockTagRepo) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}})) - invoke(api.getStudios, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := tagRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_tag.library_id")) - Expect(args).To(ContainElements(1, 2)) - }) - - // An empty scope (admin, or a non-admin with no explicit library grants) must be treated - // as unrestricted, matching accessibleLibraryIDs' documented contract, not as "match nothing". - It("does not restrict results for an admin user", func() { - tagRepo := ds.Tag().(*tests.MockTagRepo) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Studios", nil).WithContext(ctxAdmin()) - invoke(api.getStudios, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(tagRepo.Options.Filters).To(BeNil()) - }) - - It("404s a malformed ParentId instead of listing every library's studios", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Studios?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getStudios, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("getQueryFiltersLegacy", func() { - It("scopes genres to the user's accessible libraries", func() { - genreRepo := ds.Genre().(*tests.MockedGenreRepo) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxUser(model.Libraries{{ID: 1}, {ID: 2}})) - invoke(api.getQueryFiltersLegacy, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := genreRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_tag.library_id")) - Expect(args).To(ContainElements(1, 2)) - }) - - It("does not restrict genres for an admin user", func() { - genreRepo := ds.Genre().(*tests.MockedGenreRepo) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/Filters", nil).WithContext(ctxAdmin()) - invoke(api.getQueryFiltersLegacy, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(genreRepo.Options.Filters).To(BeNil()) - }) - - It("404s a malformed ParentId instead of listing every library's filters", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/Filters?ParentId=not-a-valid-id", nil).WithContext(ctxUser(model.Libraries{{ID: 1}})) - invoke(api.getQueryFiltersLegacy, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) -}) diff --git a/server/jellyfin/discovery.go b/server/jellyfin/discovery.go deleted file mode 100644 index d111287a6..000000000 --- a/server/jellyfin/discovery.go +++ /dev/null @@ -1,110 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "net" - "strconv" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/publicurl" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils/gg" -) - -// Jellyfin clients broadcast this query text to this UDP port. -const ( - discoveryPort = 7359 - discoveryQuery = "who is jellyfinserver?" -) - -type discoveryInfo struct { - Address string `json:"Address"` - Id string `json:"Id"` - Name string `json:"Name"` - EndpointAddress *string `json:"EndpointAddress"` -} - -// Discovery answers LAN auto-discovery broadcasts with the same identity the Router reports. -type Discovery struct { - ds model.DataStore - serverIDVal string -} - -func NewDiscovery(ds model.DataStore) *Discovery { - return &Discovery{ds: ds} -} - -func (d *Discovery) serverID(ctx context.Context) string { - return resolveServerID(ctx, d.ds, &d.serverIDVal) -} - -// Serve runs until ctx is done. A failed bind is only logged: discovery is best-effort. -func (d *Discovery) Serve(ctx context.Context) { - if !hasAdvertisableAddress() { - log.Warn(ctx, "Jellyfin API: auto-discovery is off, a unix socket server needs a BaseURL with a host to advertise") - return - } - // udp4 only: a dual-stack bind can share the port with another server and never get a packet. - conn, err := net.ListenPacket("udp4", net.JoinHostPort("0.0.0.0", strconv.Itoa(discoveryPort))) - if err != nil { - log.Warn(ctx, "Jellyfin API: auto-discovery is off, the UDP port is unavailable. Is another Jellyfin server running?", "port", discoveryPort, err) - return - } - log.Info(ctx, "Jellyfin API: listening for auto-discovery broadcasts", "port", discoveryPort) - d.ServeOn(ctx, conn) -} - -// ServeOn answers discovery queries on conn until ctx is done, then closes conn. -func (d *Discovery) ServeOn(ctx context.Context, conn net.PacketConn) { - defer conn.Close() - stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) - defer stop() - buf := make([]byte, 1024) - for { - n, remote, err := conn.ReadFrom(buf) - if err != nil { - if ctx.Err() == nil { - log.Error(ctx, "Jellyfin API: auto-discovery listener stopped", err) - } - return - } - if !strings.Contains(strings.ToLower(string(buf[:n])), discoveryQuery) { - continue - } - info := discoveryInfo{Address: discoveryAddress(ctx, remote), Id: d.serverID(ctx), Name: serverName()} - res, _ := json.Marshal(info) - log.Debug(ctx, "Jellyfin API: answering auto-discovery request", "from", remote.String(), "address", info.Address) - if _, err := conn.WriteTo(res, remote); err != nil { - log.Debug(ctx, "Jellyfin API: could not answer auto-discovery request", "to", remote.String(), err) - } - } -} - -// Behind a unix socket nothing listens on Port, so only a BaseURL host gives clients an address. -func hasAdvertisableAddress() bool { - return conf.Server.BaseHost != "" || !strings.HasPrefix(conf.Server.Address, "unix:") -} - -func discoveryAddress(ctx context.Context, remote net.Addr) string { - scheme := gg.If(conf.Server.TLSEnabled(), "https", "http") - host := net.JoinHostPort(localIPFor(remote), strconv.Itoa(conf.Server.Port)) - return publicurl.AbsoluteURL(request.WithServerAddress(ctx, scheme, host), consts.URLPathJellyfinAPI, nil) -} - -// On a multi-homed host, only the interface that routes to the requester is reachable by it. -func localIPFor(remote net.Addr) string { - if ip := parseIP(conf.Server.Address); ip.IsValid() && !ip.IsUnspecified() { - return ip.String() - } - c, err := net.Dial("udp", remote.String()) - if err != nil { - return conf.Server.Address - } - defer c.Close() - return c.LocalAddr().(*net.UDPAddr).IP.String() -} diff --git a/server/jellyfin/discovery_test.go b/server/jellyfin/discovery_test.go deleted file mode 100644 index bcd11fe76..000000000 --- a/server/jellyfin/discovery_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "errors" - "net" - "os" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type failingConn struct { - net.PacketConn - closed bool -} - -func (c *failingConn) ReadFrom([]byte) (int, net.Addr, error) { - return 0, nil, errors.New("read failed") -} - -func (c *failingConn) Close() error { - c.closed = true - return nil -} - -var _ = Describe("Discovery", func() { - var d *Discovery - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.ServerName = "Test Server" - conf.Server.Address = "0.0.0.0" - conf.Server.Port = 4533 - conf.Server.BaseHost = "" - conf.Server.BaseScheme = "" - conf.Server.BasePath = "" - conf.Server.TLSCert = "" - conf.Server.TLSKey = "" - d = &Discovery{} - }) - - DescribeTable("discoveryAddress", - func(setup func(), expected string) { - setup() - remote := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 50000} - Expect(discoveryAddress(context.Background(), remote)).To(Equal(expected)) - }, - Entry("uses the BaseURL host and scheme when set", func() { - conf.Server.BaseScheme = "https" - conf.Server.BaseHost = "music.example.com" - conf.Server.BasePath = "/nd" - }, "https://music.example.com/nd/jellyfin"), - Entry("uses a specific bind Address with the Port", func() { - conf.Server.Address = "192.168.1.10" - }, "http://192.168.1.10:4533/jellyfin"), - Entry("falls back to the interface facing the requester when Address is unspecified", func() {}, - "http://127.0.0.1:4533/jellyfin"), - Entry("falls back to the interface facing the requester when Address is empty", func() { - conf.Server.Address = "" - }, "http://127.0.0.1:4533/jellyfin"), - Entry("advertises https when TLS is configured", func() { - conf.Server.TLSCert = "/path/cert.pem" - conf.Server.TLSKey = "/path/key.pem" - }, "https://127.0.0.1:4533/jellyfin"), - Entry("advertises http when only the TLS cert is configured", func() { - conf.Server.TLSCert = "/path/cert.pem" - }, "http://127.0.0.1:4533/jellyfin"), - Entry("keeps a path-only BaseURL as the path prefix", func() { - conf.Server.BasePath = "/music" - }, "http://127.0.0.1:4533/music/jellyfin"), - Entry("does not double the slash when BasePath has a trailing slash", func() { - conf.Server.BasePath = "/music/" - }, "http://127.0.0.1:4533/music/jellyfin"), - ) - - DescribeTable("hasAdvertisableAddress", - func(address, baseHost string, expected bool) { - conf.Server.Address = address - conf.Server.BaseHost = baseHost - Expect(hasAdvertisableAddress()).To(Equal(expected)) - }, - Entry("TCP listener", "0.0.0.0", "", true), - Entry("unix socket without a BaseURL host", "unix:/tmp/navidrome.sock", "", false), - Entry("unix socket behind a proxy named by BaseURL", "unix:/tmp/navidrome.sock", "music.example.com", true), - ) - - It("closes the connection when the read loop fails", func() { - fake := &failingConn{} - d.ServeOn(context.Background(), fake) - Expect(fake.closed).To(BeTrue()) - }) - - Describe("ServeOn", func() { - var ( - server net.PacketConn - client net.PacketConn - cancel context.CancelFunc - done chan struct{} - ) - - BeforeEach(func() { - var err error - server, err = net.ListenPacket("udp4", "127.0.0.1:0") - Expect(err).ToNot(HaveOccurred()) - client, err = net.ListenPacket("udp4", "127.0.0.1:0") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(client.Close) - - var ctx context.Context - ctx, cancel = context.WithCancel(context.Background()) - done = make(chan struct{}) - go func() { - defer close(done) - d.ServeOn(ctx, server) - }() - DeferCleanup(func() { - cancel() - Eventually(done).Should(BeClosed()) - }) - }) - - send := func(msg string) { - _, err := client.WriteTo([]byte(msg), server.LocalAddr()) - Expect(err).ToNot(HaveOccurred()) - } - receive := func() ([]byte, error) { - Expect(client.SetReadDeadline(time.Now().Add(time.Second))).To(Succeed()) - buf := make([]byte, 1024) - n, _, err := client.ReadFrom(buf) - return buf[:n], err - } - - It("answers the discovery query with the server identity", func() { - send("who is JellyfinServer?") - res, err := receive() - Expect(err).ToNot(HaveOccurred()) - - var info discoveryInfo - Expect(json.Unmarshal(res, &info)).To(Succeed()) - Expect(info.Address).To(Equal("http://127.0.0.1:4533/jellyfin")) - Expect(info.Id).To(Equal(d.serverID(context.Background()))) - Expect(info.Name).To(Equal("Test Server")) - Expect(string(res)).To(ContainSubstring(`"EndpointAddress":null`)) - }) - - It("matches the query case-insensitively", func() { - send("WHO IS JELLYFINSERVER?") - _, err := receive() - Expect(err).ToNot(HaveOccurred()) - }) - - // The loop is serial, so a reply to the first packet would arrive before the second's. - It("ignores unrelated packets", func() { - send("who is PlexServer?") - send("who is JellyfinServer?") - _, err := receive() - Expect(err).ToNot(HaveOccurred()) - Expect(client.SetReadDeadline(time.Now().Add(50 * time.Millisecond))).To(Succeed()) - _, _, err = client.ReadFrom(make([]byte, 1024)) - Expect(errors.Is(err, os.ErrDeadlineExceeded)).To(BeTrue()) - }) - - It("stops and closes the socket when the context is cancelled", func() { - cancel() - Eventually(done).Should(BeClosed()) - _, _, err := server.ReadFrom(make([]byte, 1)) - Expect(err).To(HaveOccurred()) - }) - }) -}) diff --git a/server/jellyfin/dto/dto.go b/server/jellyfin/dto/dto.go deleted file mode 100644 index 4a28eb313..000000000 --- a/server/jellyfin/dto/dto.go +++ /dev/null @@ -1,349 +0,0 @@ -package dto - -// PublicSystemInfo is the unauthenticated handshake payload (GET /System/Info/Public). -type PublicSystemInfo struct { - LocalAddress string `json:"LocalAddress,omitempty"` - ServerName string `json:"ServerName"` - Version string `json:"Version"` - ProductName string `json:"ProductName"` - OperatingSystem string `json:"OperatingSystem,omitempty"` - Id string `json:"Id"` - StartupWizardCompleted bool `json:"StartupWizardCompleted"` -} - -// SystemInfo is the authenticated variant (GET /System/Info). -type SystemInfo struct { - PublicSystemInfo - HasPendingRestart bool `json:"HasPendingRestart"` - IsShuttingDown bool `json:"IsShuttingDown"` - SupportsLibraryMonitor bool `json:"SupportsLibraryMonitor"` - CachePath string `json:"CachePath,omitempty"` -} - -// EndPointInfo describes the caller's network location (GET /System/Endpoint). -type EndPointInfo struct { - IsLocal bool `json:"IsLocal"` - IsInNetwork bool `json:"IsInNetwork"` -} - -type NameGuidPair struct { - Name string `json:"Name"` - Id string `json:"Id"` -} - -type UserItemDataDto struct { - Rating *float64 `json:"Rating,omitempty"` - PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"` - PlayCount int `json:"PlayCount"` - IsFavorite bool `json:"IsFavorite"` - Played bool `json:"Played"` - Key string `json:"Key"` - ItemId string `json:"ItemId,omitempty"` - LastPlayedDate *string `json:"LastPlayedDate,omitempty"` -} - -type BaseItemDto struct { - Name string `json:"Name"` - ServerId string `json:"ServerId,omitempty"` - Id string `json:"Id"` - // PlaylistItemId identifies an entry within a playlist listing (GET /Playlists/{id}/Items), - // distinct from Id so a song appearing more than once can be removed by occurrence - // (DELETE .../Items?EntryIds=...) rather than by song id. - PlaylistItemId string `json:"PlaylistItemId,omitempty"` - Type string `json:"Type"` - IsFolder bool `json:"IsFolder"` - MediaType string `json:"MediaType,omitempty"` - CollectionType string `json:"CollectionType,omitempty"` - LocationType string `json:"LocationType,omitempty"` - HasLyrics *bool `json:"HasLyrics,omitempty"` - // ChannelId is always null for music, but Jellyfin emits it on every item and clients may require it. - ChannelId *string `json:"ChannelId"` - Tags []string `json:"Tags,omitzero"` - SortName string `json:"SortName,omitempty"` - Path string `json:"Path,omitempty"` - ParentId string `json:"ParentId,omitempty"` - RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` - IndexNumber *int `json:"IndexNumber,omitempty"` - ParentIndexNumber *int `json:"ParentIndexNumber,omitempty"` - ProductionYear *int `json:"ProductionYear,omitempty"` - // PremiereDate is the ISO 8601 release date; Finamp sorts "Latest Releases" by it client-side. - PremiereDate *string `json:"PremiereDate,omitempty"` - // DateCreated is the ISO 8601 date the item was added to the library; clients show it as - // "Date Added" and sort "Recently Added" by it. - DateCreated string `json:"DateCreated,omitempty"` - Album string `json:"Album,omitempty"` - AlbumId string `json:"AlbumId,omitempty"` - AlbumArtist string `json:"AlbumArtist,omitempty"` - AlbumArtists []NameGuidPair `json:"AlbumArtists,omitempty"` - AlbumPrimaryImageTag string `json:"AlbumPrimaryImageTag,omitempty"` - Artists []string `json:"Artists,omitzero"` - ArtistItems []NameGuidPair `json:"ArtistItems,omitempty"` - Genres []string `json:"Genres,omitzero"` - GenreItems []NameGuidPair `json:"GenreItems,omitzero"` - Studios []NameGuidPair `json:"Studios,omitempty"` - NormalizationGain *float64 `json:"NormalizationGain,omitempty"` - AlbumNormalizationGain *float64 `json:"AlbumNormalizationGain,omitempty"` - ChildCount *int `json:"ChildCount,omitempty"` - SongCount *int `json:"SongCount,omitempty"` - AlbumCount *int `json:"AlbumCount,omitempty"` - ImageTags map[string]string `json:"ImageTags"` - // ImageBlurHashes is keyed by image type (e.g. "Primary") then image tag. Finamp uses it as a - // de-dup key for image downloads (and a placeholder); absent, it warns the server isn't - // calculating blurhashes. - ImageBlurHashes map[string]map[string]string `json:"ImageBlurHashes,omitempty"` - // PrimaryImageAspectRatio is width/height of the Primary image, attached only when the request's - // Fields asks for it; omitted rather than guessed, since a wrong ratio mis-shapes a placeholder. - PrimaryImageAspectRatio *float64 `json:"PrimaryImageAspectRatio,omitempty"` - BackdropImageTags []string `json:"BackdropImageTags"` - UserData *UserItemDataDto `json:"UserData,omitempty"` - MediaSources []MediaSourceInfo `json:"MediaSources,omitempty"` - Container string `json:"Container,omitempty"` - CanDownload bool `json:"CanDownload"` -} - -// PlaylistUserPermissions is the response shape for GET /Playlists/{id}/Users(/{userId}), which -// Finamp probes before allowing playlist edits. -type PlaylistUserPermissions struct { - UserId string `json:"UserId"` - CanEdit bool `json:"CanEdit"` -} - -// PlaylistInfo is the response shape for GET /Playlists/{id}. ItemIds are media item ids, not -// playlist-entry ids (matching real Jellyfin); Finamp reads OpenAccess for the public-visibility toggle. -type PlaylistInfo struct { - OpenAccess bool `json:"OpenAccess"` - Shares []PlaylistUserPermissions `json:"Shares"` - ItemIds []string `json:"ItemIds"` -} - -type QueryResult struct { - Items []BaseItemDto `json:"Items"` - TotalRecordCount int `json:"TotalRecordCount"` - StartIndex int `json:"StartIndex"` -} - -type UserDto struct { - Name string `json:"Name"` - ServerId string `json:"ServerId,omitempty"` - ServerName string `json:"ServerName,omitempty"` - Id string `json:"Id"` - HasPassword bool `json:"HasPassword"` - HasConfiguredPassword bool `json:"HasConfiguredPassword"` - HasConfiguredEasyPassword bool `json:"HasConfiguredEasyPassword"` - PrimaryImageTag string `json:"PrimaryImageTag,omitempty"` - Policy *UserPolicy `json:"Policy,omitempty"` - Configuration *UserConfiguration `json:"Configuration,omitempty"` -} - -// UserPolicy mirrors real Jellyfin's User.Policy. Finamp reads it right after login and crashes if -// it's absent, so every field must be present even though Navidrome lacks most of these concepts. -type UserPolicy struct { - IsAdministrator bool `json:"IsAdministrator"` - IsHidden bool `json:"IsHidden"` - EnableCollectionManagement bool `json:"EnableCollectionManagement"` - EnableSubtitleManagement bool `json:"EnableSubtitleManagement"` - EnableLyricManagement bool `json:"EnableLyricManagement"` - IsDisabled bool `json:"IsDisabled"` - BlockedTags []string `json:"BlockedTags"` - AllowedTags []string `json:"AllowedTags"` - EnableUserPreferenceAccess bool `json:"EnableUserPreferenceAccess"` - AccessSchedules []string `json:"AccessSchedules"` - BlockUnratedItems []string `json:"BlockUnratedItems"` - EnableRemoteControlOfOtherUsers bool `json:"EnableRemoteControlOfOtherUsers"` - EnableSharedDeviceControl bool `json:"EnableSharedDeviceControl"` - EnableRemoteAccess bool `json:"EnableRemoteAccess"` - EnableLiveTvManagement bool `json:"EnableLiveTvManagement"` - EnableLiveTvAccess bool `json:"EnableLiveTvAccess"` - EnableMediaPlayback bool `json:"EnableMediaPlayback"` - EnableAudioPlaybackTranscoding bool `json:"EnableAudioPlaybackTranscoding"` - EnableVideoPlaybackTranscoding bool `json:"EnableVideoPlaybackTranscoding"` - EnablePlaybackRemuxing bool `json:"EnablePlaybackRemuxing"` - ForceRemoteSourceTranscoding bool `json:"ForceRemoteSourceTranscoding"` - EnableContentDeletion bool `json:"EnableContentDeletion"` - EnableContentDeletionFromFolders []string `json:"EnableContentDeletionFromFolders"` - EnableContentDownloading bool `json:"EnableContentDownloading"` - EnableSyncTranscoding bool `json:"EnableSyncTranscoding"` - EnableMediaConversion bool `json:"EnableMediaConversion"` - EnabledDevices []string `json:"EnabledDevices"` - EnableAllDevices bool `json:"EnableAllDevices"` - EnabledChannels []string `json:"EnabledChannels"` - EnableAllChannels bool `json:"EnableAllChannels"` - EnabledFolders []string `json:"EnabledFolders"` - EnableAllFolders bool `json:"EnableAllFolders"` - InvalidLoginAttemptCount int `json:"InvalidLoginAttemptCount"` - LoginAttemptsBeforeLockout int `json:"LoginAttemptsBeforeLockout"` - MaxActiveSessions int `json:"MaxActiveSessions"` - EnablePublicSharing bool `json:"EnablePublicSharing"` - BlockedMediaFolders []string `json:"BlockedMediaFolders"` - BlockedChannels []string `json:"BlockedChannels"` - RemoteClientBitrateLimit int `json:"RemoteClientBitrateLimit"` - AuthenticationProviderId string `json:"AuthenticationProviderId"` - PasswordResetProviderId string `json:"PasswordResetProviderId"` - SyncPlayAccess string `json:"SyncPlayAccess"` -} - -// UserConfiguration mirrors real Jellyfin's User.Configuration. Like UserPolicy, clients expect it -// always present, even though most settings don't apply to Navidrome's audio-only library. -type UserConfiguration struct { - PlayDefaultAudioTrack bool `json:"PlayDefaultAudioTrack"` - SubtitleLanguagePreference string `json:"SubtitleLanguagePreference"` - DisplayMissingEpisodes bool `json:"DisplayMissingEpisodes"` - GroupedFolders []string `json:"GroupedFolders"` - SubtitleMode string `json:"SubtitleMode"` - DisplayCollectionsView bool `json:"DisplayCollectionsView"` - EnableLocalPassword bool `json:"EnableLocalPassword"` - OrderedViews []string `json:"OrderedViews"` - LatestItemsExcludes []string `json:"LatestItemsExcludes"` - MyMediaExcludes []string `json:"MyMediaExcludes"` - HidePlayedInLatest bool `json:"HidePlayedInLatest"` - RememberAudioSelections bool `json:"RememberAudioSelections"` - RememberSubtitleSelections bool `json:"RememberSubtitleSelections"` - EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"` - CastReceiverId string `json:"CastReceiverId"` -} - -// SessionInfo mirrors real Jellyfin's SessionInfoDto. JellyBox requires Id and PlayState, and Finamp -// requires UserId, LastActivityDate and the bools, so none of those may be omitted. -type SessionInfo struct { - Id string `json:"Id"` - UserId string `json:"UserId"` - UserName string `json:"UserName"` - Client string `json:"Client"` - DeviceId string `json:"DeviceId"` - DeviceName string `json:"DeviceName"` - ApplicationVersion string `json:"ApplicationVersion"` - ServerId string `json:"ServerId"` - LastActivityDate string `json:"LastActivityDate"` - IsActive bool `json:"IsActive"` - SupportsMediaControl bool `json:"SupportsMediaControl"` - SupportsRemoteControl bool `json:"SupportsRemoteControl"` - HasCustomDeviceName bool `json:"HasCustomDeviceName"` - PlayableMediaTypes []string `json:"PlayableMediaTypes"` - SupportedCommands []string `json:"SupportedCommands"` - AdditionalUsers []any `json:"AdditionalUsers"` - NowPlayingQueue []any `json:"NowPlayingQueue"` - PlayState PlayerStateInfo `json:"PlayState"` -} - -type PlayerStateInfo struct { - CanSeek bool `json:"CanSeek"` - IsPaused bool `json:"IsPaused"` - IsMuted bool `json:"IsMuted"` - RepeatMode string `json:"RepeatMode"` - PlaybackOrder string `json:"PlaybackOrder"` -} - -type QuickConnectResult struct { - Authenticated bool `json:"Authenticated"` - Secret string `json:"Secret"` - Code string `json:"Code"` - DeviceId string `json:"DeviceId"` - DeviceName string `json:"DeviceName"` - AppName string `json:"AppName"` - AppVersion string `json:"AppVersion"` - DateAdded string `json:"DateAdded"` -} - -type AuthenticationResult struct { - User *UserDto `json:"User"` - SessionInfo *SessionInfo `json:"SessionInfo"` - AccessToken string `json:"AccessToken"` - ServerId string `json:"ServerId"` -} - -// MediaStream mirrors real Jellyfin's MediaStream. Finamp declares several bools as non-nullable, so -// they must always be emitted (no omitempty). Finamp also does MediaStreams.firstWhere((s) => s.type -// == 'Audio'), so MediaSourceInfo must include at least one Audio stream or that lookup throws. -type MediaStream struct { - Codec string `json:"Codec,omitempty"` - Type string `json:"Type"` - Index int `json:"Index"` - BitRate int `json:"BitRate,omitempty"` - Channels int `json:"Channels,omitempty"` - SampleRate int `json:"SampleRate,omitempty"` - ChannelLayout string `json:"ChannelLayout,omitempty"` - IsInterlaced bool `json:"IsInterlaced"` - IsDefault bool `json:"IsDefault"` - IsForced bool `json:"IsForced"` - IsExternal bool `json:"IsExternal"` - IsTextSubtitleStream bool `json:"IsTextSubtitleStream"` - SupportsExternalStream bool `json:"SupportsExternalStream"` -} - -// MediaSourceInfo mirrors real Jellyfin's MediaSourceInfo. Finamp declares several bools/arrays as -// non-nullable, so a missing field deserializes to null and throws a cast error that aborts parsing -// of the whole item list; emit them always (no omitempty on bools). -type MediaSourceInfo struct { - Id string `json:"Id"` - Path string `json:"Path,omitempty"` - Protocol string `json:"Protocol"` - Container string `json:"Container,omitempty"` - TranscodingUrl string `json:"TranscodingUrl,omitempty"` - TranscodingSubProtocol string `json:"TranscodingSubProtocol,omitempty"` - Size int64 `json:"Size,omitempty"` - Name string `json:"Name,omitempty"` - IsRemote bool `json:"IsRemote"` - RunTimeTicks int64 `json:"RunTimeTicks,omitempty"` - Bitrate int `json:"Bitrate,omitempty"` - SupportsTranscoding bool `json:"SupportsTranscoding"` - SupportsDirectStream bool `json:"SupportsDirectStream"` - SupportsDirectPlay bool `json:"SupportsDirectPlay"` - Type string `json:"Type"` - ReadAtNativeFramerate bool `json:"ReadAtNativeFramerate"` - IgnoreDts bool `json:"IgnoreDts"` - IgnoreIndex bool `json:"IgnoreIndex"` - GenPtsInput bool `json:"GenPtsInput"` - IsInfiniteStream bool `json:"IsInfiniteStream"` - UseMostCompatibleTranscodingProfile bool `json:"UseMostCompatibleTranscodingProfile"` - RequiresOpening bool `json:"RequiresOpening"` - RequiresClosing bool `json:"RequiresClosing"` - RequiresLooping bool `json:"RequiresLooping"` - SupportsProbing bool `json:"SupportsProbing"` - HasSegments bool `json:"HasSegments"` - MediaStreams []MediaStream `json:"MediaStreams"` - MediaAttachments []any `json:"MediaAttachments"` - Formats []string `json:"Formats"` -} - -type PlaybackInfoResponse struct { - MediaSources []MediaSourceInfo `json:"MediaSources"` - PlaySessionId string `json:"PlaySessionId"` -} - -// LyricDto mirrors Jellyfin's GET /Audio/{itemId}/Lyrics response. Feishin and Jellify read only -// Lyrics[].Text and Start (ticks); Metadata is for completeness/Finamp. -type LyricDto struct { - Metadata LyricMetadata `json:"Metadata"` - Lyrics []LyricLine `json:"Lyrics"` -} - -type LyricMetadata struct { - Artist string `json:"Artist,omitempty"` - Album string `json:"Album,omitempty"` - Title string `json:"Title,omitempty"` - Length int64 `json:"Length,omitempty"` - Offset *int64 `json:"Offset,omitempty"` - IsSynced bool `json:"IsSynced"` -} - -type LyricLine struct { - Text string `json:"Text"` - Start *int64 `json:"Start,omitempty"` - Cues []LyricLineCue `json:"Cues,omitempty"` -} - -type LyricLineCue struct { - Position int `json:"Position"` - EndPosition int `json:"EndPosition"` - Start int64 `json:"Start"` - End *int64 `json:"End,omitempty"` -} - -// QueryFiltersLegacy is the response for GET /Items/Filters. All four lists are always present; -// clients (jellyfin-web) render each unconditionally. -type QueryFiltersLegacy struct { - Genres []string `json:"Genres"` - Tags []string `json:"Tags"` - OfficialRatings []string `json:"OfficialRatings"` - Years []int `json:"Years"` -} diff --git a/server/jellyfin/dto/dto_suite_test.go b/server/jellyfin/dto/dto_suite_test.go deleted file mode 100644 index a4eebb0d3..000000000 --- a/server/jellyfin/dto/dto_suite_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package dto - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestDto(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Jellyfin DTO Suite") -} - -// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same -// id shape production uses. -func testID(label string) string { return id.NewHash("jellyfin-test", label) } diff --git a/server/jellyfin/dto/fields.go b/server/jellyfin/dto/fields.go deleted file mode 100644 index fb49e43c7..000000000 --- a/server/jellyfin/dto/fields.go +++ /dev/null @@ -1,27 +0,0 @@ -package dto - -import "strings" - -// Fields is the parsed set of a Jellyfin request's Fields param (lowercased). It controls which -// conditional fields a mapped item carries — chiefly MediaSources — matching real Jellyfin, which -// omits those unless the client asks for them. -type Fields map[string]struct{} - -// ParseFields builds a lowercased set from the Fields param. It accepts each value comma-separated -// (Fields=a,b) and across repeated params (Fields=a&Fields=b), both of which real Jellyfin honors. -func ParseFields(values ...string) Fields { - f := Fields{} - for _, csv := range values { - for name := range strings.SplitSeq(csv, ",") { - if name = strings.TrimSpace(strings.ToLower(name)); name != "" { - f[name] = struct{}{} - } - } - } - return f -} - -func (f Fields) Has(name string) bool { - _, ok := f[strings.ToLower(name)] - return ok -} diff --git a/server/jellyfin/dto/fields_test.go b/server/jellyfin/dto/fields_test.go deleted file mode 100644 index 59d06734d..000000000 --- a/server/jellyfin/dto/fields_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package dto - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("ParseFields", func() { - It("parses a single comma-separated value", func() { - f := ParseFields("Genres,MediaSources") - Expect(f.Has("Genres")).To(BeTrue()) - Expect(f.Has("MediaSources")).To(BeTrue()) - }) - - It("parses fields spread across repeated params", func() { - f := ParseFields("Genres", "MediaSources", "SortName") - Expect(f.Has("Genres")).To(BeTrue()) - Expect(f.Has("MediaSources")).To(BeTrue()) - Expect(f.Has("SortName")).To(BeTrue()) - }) - - It("returns an empty set for no values", func() { - Expect(ParseFields()).To(BeEmpty()) - Expect(ParseFields("")).To(BeEmpty()) - }) -}) diff --git a/server/jellyfin/dto/filters_test.go b/server/jellyfin/dto/filters_test.go deleted file mode 100644 index ad3367264..000000000 --- a/server/jellyfin/dto/filters_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package dto - -import ( - "encoding/json" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("QueryFiltersLegacy", func() { - It("marshals all four keys, empty ones as [] not null", func() { - b, err := json.Marshal(QueryFiltersLegacy{ - Genres: []string{"Rock"}, Tags: []string{}, OfficialRatings: []string{}, Years: []int{1999}, - }) - Expect(err).ToNot(HaveOccurred()) - j := string(b) - Expect(j).To(ContainSubstring(`"Genres":["Rock"]`)) - Expect(j).To(ContainSubstring(`"Tags":[]`)) - Expect(j).To(ContainSubstring(`"OfficialRatings":[]`)) - Expect(j).To(ContainSubstring(`"Years":[1999]`)) - }) -}) diff --git a/server/jellyfin/dto/ids.go b/server/jellyfin/dto/ids.go deleted file mode 100644 index 2c8b5317b..000000000 --- a/server/jellyfin/dto/ids.go +++ /dev/null @@ -1,143 +0,0 @@ -package dto - -import ( - "bytes" - "encoding/hex" - "strconv" - "strings" - - "github.com/navidrome/navidrome/model/id" -) - -// guidLen is the length of a Jellyfin GUID on the wire: 16 bytes as lowercase hex, no dashes -// (what Guid.ToString("N") produces). -const guidLen = 32 - -// Reserved GUIDs stand in for ids that aren't 128-bit values: 12 zero bytes, a non-zero kind tag, -// then a 24-bit payload. The tag is never zero because Jellyfin serializes the all-zero GUID as null. -const ( - kindIdx = 12 - maxPayload = 1<<24 - 1 -) - -const ( - kindLibrary byte = iota + 1 - kindPlaylistsFolder - kindPlaylistEntry -) - -var zeroPrefix [kindIdx]byte - -// PlaylistsFolderID is the internal id of the synthetic "playlists library" folder. It can't be -// mistaken for a real id, which is always 22-char base62. -const PlaylistsFolderID = "playlists" - -// PlaylistsFolderGUID is the wire form of PlaylistsFolderID. -var PlaylistsFolderGUID = encodeReserved(kindPlaylistsFolder, 0) - -// EncodeID renders a canonical Navidrome id as a Jellyfin GUID. Anything that isn't one encodes -// to "" rather than to a shape clients can't parse. -func EncodeID(ndID string) string { - b, err := id.Decode(ndID) - if err != nil { - return "" - } - return hex.EncodeToString(b) -} - -// EncodeLibraryID renders a library's integer id in the reserved GUID space. -func EncodeLibraryID(libID int) string { - return encodeReserved(kindLibrary, libID) -} - -// EncodePlaylistEntryID renders a playlist entry's position (model.PlaylistTrack.ID, an integer -// column) in the reserved GUID space. Clients echo it back to remove one occurrence of a song. -func EncodePlaylistEntryID(entryID string) string { - n, err := strconv.Atoi(entryID) - if err != nil { - return "" - } - return encodeReserved(kindPlaylistEntry, n) -} - -func encodeReserved(kind byte, payload int) string { - if payload < 0 || payload > maxPayload { - return "" - } - var b [16]byte - b[kindIdx] = kind - b[13], b[14], b[15] = byte(payload>>16), byte(payload>>8), byte(payload) - return hex.EncodeToString(b[:]) -} - -// DecodeID maps an inbound GUID back to the identifier the rest of the API uses: a canonical id, -// a decimal library id, or PlaylistsFolderID. ok is false for anything that isn't a well-formed -// GUID — including the empty string — so an undecodable id can't reach a caller as "no filter". -// Dashed and uppercase forms are accepted, as Jellyfin's Guid.Parse accepts them. Playlist entries -// decode through DecodePlaylistEntryID instead, so a position can't reach a caller expecting an -// entity id. -func DecodeID(guid string) (string, bool) { - b, ok := decodeGUID(guid) - if !ok { - return "", false - } - kind, payload, reserved := reservedFields(b) - if !reserved { - return id.Encode(b), true - } - switch kind { - case kindLibrary: - return strconv.Itoa(payload), true - case kindPlaylistsFolder: - return PlaylistsFolderID, true - } - return "", false -} - -// DecodeIDs decodes a list of GUIDs, all-or-nothing: ok is false if any entry is malformed, so a -// caller can't mistake "every entry failed" for "no filter" (see DecodeID). -func DecodeIDs(guids []string) ([]string, bool) { - out := make([]string, len(guids)) - for i, guid := range guids { - decoded, ok := DecodeID(guid) - if !ok { - return nil, false - } - out[i] = decoded - } - return out, true -} - -// DecodePlaylistEntryID decodes a playlist entry GUID to its position. It rejects every other kind, -// so an entity id can't be taken for a playlist_tracks row. -func DecodePlaylistEntryID(guid string) (string, bool) { - b, ok := decodeGUID(guid) - if !ok { - return "", false - } - if kind, payload, reserved := reservedFields(b); reserved && kind == kindPlaylistEntry { - return strconv.Itoa(payload), true - } - return "", false -} - -func decodeGUID(guid string) ([16]byte, bool) { - guid = strings.ToLower(strings.ReplaceAll(guid, "-", "")) - if len(guid) != guidLen { - return [16]byte{}, false - } - bs, err := hex.DecodeString(guid) - if err != nil { - return [16]byte{}, false - } - return [16]byte(bs), true -} - -// reservedFields reports the kind tag and payload of a reserved GUID; reserved is false for the -// entity GUIDs that make up almost all traffic. -func reservedFields(b [16]byte) (kind byte, payload int, reserved bool) { - if !bytes.Equal(b[:kindIdx], zeroPrefix[:]) { - return 0, 0, false - } - return b[kindIdx], int(b[13])<<16 | int(b[14])<<8 | int(b[15]), true -} diff --git a/server/jellyfin/dto/ids_test.go b/server/jellyfin/dto/ids_test.go deleted file mode 100644 index c95dbd790..000000000 --- a/server/jellyfin/dto/ids_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package dto - -import ( - "encoding/hex" - "strconv" - "strings" - - "github.com/navidrome/navidrome/model/id" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("id codec", func() { - Describe("canonical ids", func() { - It("round-trips a random id", func() { - ndID := id.NewRandom() - decoded, ok := DecodeID(EncodeID(ndID)) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(ndID)) - }) - - It("round-trips a hash id", func() { - ndID := id.NewHash("artist", "Weird Al") - decoded, ok := DecodeID(EncodeID(ndID)) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(ndID)) - }) - - It("emits exactly 32 lowercase hex chars", func() { - for range 20 { - Expect(EncodeID(id.NewRandom())).To(MatchRegexp("^[0-9a-f]{32}$")) - } - }) - - It("accepts a dashed GUID, matching Jellyfin's Guid.Parse", func() { - ndID := id.NewRandom() - guid := EncodeID(ndID) - dashed := guid[0:8] + "-" + guid[8:12] + "-" + guid[12:16] + "-" + guid[16:20] + "-" + guid[20:32] - decoded, ok := DecodeID(dashed) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(ndID)) - }) - - It("accepts an uppercase GUID", func() { - ndID := id.NewRandom() - decoded, ok := DecodeID(strings.ToUpper(EncodeID(ndID))) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(ndID)) - }) - }) - - Describe("reserved space", func() { - DescribeTable("round-trips library ids", - func(libID int, guid string) { - Expect(EncodeLibraryID(libID)).To(Equal(guid)) - decoded, ok := DecodeID(guid) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(strconv.Itoa(libID))) - }, - Entry("first library", 1, "00000000000000000000000001000001"), - Entry("double digit", 42, "0000000000000000000000000100002a"), - Entry("max payload", 0xffffff, "00000000000000000000000001ffffff"), - ) - - It("round-trips the playlists folder", func() { - Expect(PlaylistsFolderGUID).To(Equal("00000000000000000000000002000000")) - decoded, ok := DecodeID(PlaylistsFolderGUID) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(PlaylistsFolderID)) - }) - - DescribeTable("round-trips playlist entry positions", - func(entryID, guid string) { - Expect(EncodePlaylistEntryID(entryID)).To(Equal(guid)) - decoded, ok := DecodePlaylistEntryID(guid) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(entryID)) - }, - Entry("first entry", "1", "00000000000000000000000003000001"), - Entry("later entry", "300", "0000000000000000000000000300012c"), - ) - - It("rejects a non-integer playlist entry id", func() { - Expect(EncodePlaylistEntryID("s1")).To(Equal("")) - Expect(EncodePlaylistEntryID("")).To(Equal("")) - Expect(EncodePlaylistEntryID("-1")).To(Equal("")) - }) - - It("rejects a payload wider than the reserved 24 bits", func() { - Expect(EncodeLibraryID(1 << 24)).To(Equal("")) - Expect(EncodeLibraryID(-1)).To(Equal("")) - Expect(EncodePlaylistEntryID("16777216")).To(Equal("")) - }) - - It("keeps library and playlist-entry GUIDs distinct for the same number", func() { - Expect(EncodeLibraryID(3)).ToNot(Equal(EncodePlaylistEntryID("3"))) - }) - - It("does not let one reserved kind decode as another", func() { - _, ok := DecodeID(EncodePlaylistEntryID("3")) - Expect(ok).To(BeFalse()) - _, ok = DecodePlaylistEntryID(EncodeLibraryID(3)) - Expect(ok).To(BeFalse()) - _, ok = DecodePlaylistEntryID(PlaylistsFolderGUID) - Expect(ok).To(BeFalse()) - }) - - It("rejects an unknown kind tag", func() { - _, ok := DecodeID("000000000000000000000000ff000000") - Expect(ok).To(BeFalse()) - }) - - It("never emits the all-zero GUID, which Jellyfin serializes as null", func() { - Expect(EncodeLibraryID(0)).ToNot(Equal("00000000000000000000000000000000")) - Expect(PlaylistsFolderGUID).ToNot(Equal("00000000000000000000000000000000")) - }) - }) - - Describe("malformed input", func() { - DescribeTable("DecodeID reports ok=false", - func(input string) { - decoded, ok := DecodeID(input) - Expect(ok).To(BeFalse()) - Expect(decoded).To(BeEmpty()) - }, - Entry("empty", ""), - Entry("too short", "abc123"), - Entry("too long", "000000000000000000000000010000011"), - Entry("non-hex chars", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"), - Entry("a raw base62 id", id.NewRandom()), - Entry("the old 44-char hex format", hex.EncodeToString([]byte("5QFKvMsJrd57QE2Le2dKKo"))), - ) - - It("encodes a non-canonical id to the empty string", func() { - Expect(EncodeID("")).To(Equal("")) - Expect(EncodeID("playlists")).To(Equal("")) - Expect(EncodeID("42")).To(Equal("")) - }) - }) - - Describe("DecodeIDs", func() { - It("decodes every entry when all are well-formed", func() { - a, b := id.NewRandom(), id.NewRandom() - decoded, ok := DecodeIDs([]string{EncodeID(a), EncodeID(b)}) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal([]string{a, b})) - }) - - It("is all-or-nothing: one malformed entry fails the whole list", func() { - decoded, ok := DecodeIDs([]string{EncodeID(id.NewRandom()), "not-a-guid"}) - Expect(ok).To(BeFalse()) - Expect(decoded).To(BeNil()) - }) - - It("succeeds on an empty list", func() { - decoded, ok := DecodeIDs(nil) - Expect(ok).To(BeTrue()) - Expect(decoded).To(BeEmpty()) - }) - }) -}) diff --git a/server/jellyfin/dto/mappers.go b/server/jellyfin/dto/mappers.go deleted file mode 100644 index fc7e329de..000000000 --- a/server/jellyfin/dto/mappers.go +++ /dev/null @@ -1,471 +0,0 @@ -package dto - -import ( - "cmp" - "fmt" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/utils/slice" -) - -// sortName must match the persistence ORDER BY key (see setSortMappings): Finamp's A-Z jump -// scans SortName client-side, and any mismatch with the server's sort order scrolls to the top. -func sortName(sortTag, orderName, displayName string) string { - if conf.Server.PreferSortTags { - return cmp.Or(sortTag, orderName, displayName) - } - return cmp.Or(orderName, displayName) -} - -// Jellyfin wire times are ticks: 100ns units, i.e. 10,000 per millisecond. -const ticksPerMillis = 10_000 - -func TicksFromSeconds(sec float32) int64 { return int64(float64(sec) * 1000 * ticksPerMillis) } - -// TicksFromMillis converts milliseconds (Navidrome lyric timestamps) to ticks. -func TicksFromMillis(ms int64) int64 { return ms * ticksPerMillis } - -// MillisFromTicks converts ticks (client-reported playback positions) to milliseconds. -func MillisFromTicks(ticks int64) int64 { return ticks / ticksPerMillis } - -// premiereDate converts a possibly partial date tag ("2007", "2007-02") into the ISO 8601 -// PremiereDate clients parse, falling back to year; nil when neither exists. -func premiereDate(date string, year int) *string { - d := date - switch len(d) { - case 4: - d += "-01-01" - case 7: - d += "-01" - case 10: // already yyyy-mm-dd - default: - if year <= 0 { - return nil - } - d = fmt.Sprintf("%04d-01-01", year) - } - parsed, err := time.Parse(time.DateOnly, d) - if err != nil { - return nil - } - s := JellyfinDate(&parsed) - return &s -} - -// Dates use .NET's round-trip layout, 7 fractional digits and all: Manet rejects plain RFC3339. -const jellyfinDateLayout = "2006-01-02T15:04:05.0000000Z07:00" - -// JellyfinDate formats t as the date string clients expect, or "" for the zero time so the -// field is omitted rather than sent as a meaningless epoch. -func JellyfinDate(t *time.Time) string { - if t == nil || t.IsZero() { - return "" - } - return t.UTC().Format(jellyfinDateLayout) -} - -// channelLayout maps a channel count to the label Jellyfin clients expect on a MediaStream. -func channelLayout(n int) string { - switch n { - case 1: - return "mono" - case 2: - return "stereo" - case 6: - return "5.1" - case 8: - return "7.1" - default: - return "" - } -} - -// MediaSourceFromMediaFile builds the MediaSourceInfo for direct playback of mf's source file. -// Shared by SongToBaseItem and getPlaybackInfo so Size/Bitrate match across browse and /PlaybackInfo -// responses (Finamp's download dialog reads MediaSources[0].Size from the browse response). -func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo { - streams := make([]MediaStream, 1, 2) - streams[0] = MediaStream{ - Type: "Audio", - Index: 0, - Codec: mf.Codec, - BitRate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's BitRate is bps. - Channels: mf.Channels, - SampleRate: mf.SampleRate, - ChannelLayout: channelLayout(mf.Channels), - } - // Finamp gates its lyrics view on a Lyric stream in PlaybackInfo, not on HasLyrics. - if mf.HasEmbeddedLyrics() { - streams = append(streams, MediaStream{Type: "Lyric", Index: 1, IsExternal: true}) - } - return MediaSourceInfo{ - Id: EncodeID(mf.ID), - Protocol: "Http", - Container: mf.Suffix, - Size: mf.Size, - Name: mf.Title, - Type: "Default", - RunTimeTicks: TicksFromSeconds(mf.Duration), - Bitrate: mf.BitRate * 1000, // Navidrome stores kbps; Jellyfin's Bitrate is bps. - SupportsDirectPlay: true, - SupportsDirectStream: true, - SupportsTranscoding: true, - IsRemote: false, - SupportsProbing: true, - MediaStreams: streams, - MediaAttachments: []any{}, - Formats: []string{}, - } -} - -func UserData(a model.Annotations, itemID string) *UserItemDataDto { - // Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto. - encodedID := EncodeID(itemID) - d := &UserItemDataDto{ - PlayCount: int(a.PlayCount), - IsFavorite: a.Starred, - Played: a.PlayCount > 0, - Key: encodedID, - ItemId: encodedID, - } - if a.Rating > 0 { - r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10 - d.Rating = &r - } - if s := JellyfinDate(a.PlayDate); s != "" { - d.LastPlayedDate = &s - } - return d -} - -// SongToBaseItem maps a media file to an Audio BaseItemDto. MediaSources and SortName are attached -// only when the request's Fields asks for them, mirroring real Jellyfin (which omits both from a -// plain list response); a nil fields set means neither. -func SongToBaseItem(mf model.MediaFile, fields Fields) BaseItemDto { - albumID := EncodeID(mf.AlbumID) - item := BaseItemDto{ - Name: mf.Title, - Id: EncodeID(mf.ID), - Type: "Audio", - MediaType: "Audio", - IsFolder: false, - LocationType: "FileSystem", - HasLyrics: new(mf.HasEmbeddedLyrics()), - ParentId: albumID, - Album: mf.Album, - AlbumId: albumID, - AlbumArtist: mf.AlbumArtist, - RunTimeTicks: TicksFromSeconds(mf.Duration), - DateCreated: JellyfinDate(&mf.CreatedAt), - Container: mf.Suffix, - CanDownload: true, - BackdropImageTags: []string{}, - UserData: UserData(mf.Annotations, mf.ID), - } - if fields.Has("MediaSources") { - item.MediaSources = []MediaSourceInfo{MediaSourceFromMediaFile(mf)} - } - if fields.Has("SortName") { - item.SortName = sortName(mf.SortTitle, mf.OrderTitle, mf.Title) - } - // Real Jellyfin splits Artists/ArtistItems per track artist (AlbumArtists stays a single credit). - // Participants holds the per-artist list; fall back to the flattened display fields when absent. - if artists := mf.Participants[model.RoleArtist]; len(artists) > 0 { - item.Artists = slice.Map(artists, func(p model.Participant) string { return p.Name }) - item.ArtistItems = slice.Map(artists, func(p model.Participant) NameGuidPair { - return NameGuidPair{Name: p.Name, Id: EncodeID(p.ID)} - }) - } else { - if mf.Artist != "" { - item.Artists = []string{mf.Artist} - } - if mf.ArtistID != "" { - item.ArtistItems = []NameGuidPair{{Name: mf.Artist, Id: EncodeID(mf.ArtistID)}} - } - } - if mf.AlbumArtistID != "" { - item.AlbumArtists = []NameGuidPair{{Name: mf.AlbumArtist, Id: EncodeID(mf.AlbumArtistID)}} - } - // dB to apply at the RG2 -18 LUFS reference, same convention real Jellyfin uses; no conversion. - item.NormalizationGain = mf.RGTrackGain - item.AlbumNormalizationGain = mf.RGAlbumGain - if mf.Year > 0 { - item.ProductionYear = new(mf.Year) - } - item.PremiereDate = premiereDate(mf.Date, mf.Year) - if mf.TrackNumber > 0 { - item.IndexNumber = new(mf.TrackNumber) - } - if mf.DiscNumber > 0 { - item.ParentIndexNumber = new(mf.DiscNumber) - } - if len(mf.Genres) > 0 { - for _, g := range mf.Genres { - item.Genres = append(item.Genres, g.Name) - item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) - } - } else if mf.Genre != "" { - item.Genres = []string{mf.Genre} - } - // Clients prefer ImageTags.Primary over AlbumId, so a track's own cover must win here. - if mf.ImageHash != "" && mf.ImageHash != mf.AlbumImage.ImageHash { - tag, blurs, ratio := primaryImage(mf.ItemImage, mf.ID, fields) - item.ImageTags = map[string]string{"Primary": tag} - item.ImageBlurHashes = blurs - item.PrimaryImageAspectRatio = ratio - } else if embeddedArtPending(mf) { - // Nothing enqueues media files: advertising the id is what makes a client ask, and that - // request is what extracts the embedded art and queues the track. - item.ImageTags = map[string]string{"Primary": mf.ID} - } else if mf.AlbumID != "" { - if tag, blurs, ratio := primaryImage(mf.AlbumImage, mf.AlbumID, fields); tag != "" { - item.AlbumPrimaryImageTag = tag - item.ImageBlurHashes = blurs - item.PrimaryImageAspectRatio = ratio - } - } - return item -} - -func embeddedArtPending(mf model.MediaFile) bool { - return mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt && - mf.ImageHash == "" && !mf.ItemImage.ImageAbsent -} - -// primaryImage never fakes a blurhash: clients key their cover cache on the value, which would -// pin a stale cover forever. -func primaryImage(img model.ItemImage, fallback string, fields Fields) (tag string, blurs map[string]map[string]string, ratio *float64) { - if img.ImageAbsent { - return "", nil, nil - } - tag = cmp.Or(img.ImageHash, fallback) - if img.BlurHash != "" { - blurs = map[string]map[string]string{"Primary": {tag: img.BlurHash}} - } - if fields.Has("PrimaryImageAspectRatio") { - ratio = img.AspectRatio() - } - return tag, blurs, ratio -} - -func AlbumToBaseItem(al model.Album, fields Fields) BaseItemDto { - tag, blurs, ratio := primaryImage(al.ItemImage, al.ID, fields) - item := BaseItemDto{ - Name: al.Name, - Id: EncodeID(al.ID), - Type: "MusicAlbum", - IsFolder: true, - LocationType: "FileSystem", - ParentId: EncodeID(al.AlbumArtistID), - AlbumArtist: al.AlbumArtist, - Album: al.Name, - ChildCount: new(al.SongCount), - SongCount: new(al.SongCount), - RunTimeTicks: TicksFromSeconds(al.Duration), - DateCreated: JellyfinDate(&al.CreatedAt), - ImageBlurHashes: blurs, - PrimaryImageAspectRatio: ratio, - BackdropImageTags: []string{}, - UserData: UserData(al.Annotations, al.ID), - } - if tag != "" { - item.ImageTags = map[string]string{"Primary": tag} - } - item.Artists = []string{} - if al.AlbumArtist != "" { - item.Artists = append(item.Artists, al.AlbumArtist) - } - if al.AlbumArtistID != "" { - item.AlbumArtists = []NameGuidPair{{Name: al.AlbumArtist, Id: EncodeID(al.AlbumArtistID)}} - item.ArtistItems = item.AlbumArtists - } - if al.MaxYear > 0 { - item.ProductionYear = new(al.MaxYear) - } - item.PremiereDate = premiereDate(al.Date, al.MaxYear) - if len(al.Genres) > 0 { - for _, g := range al.Genres { - item.Genres = append(item.Genres, g.Name) - item.GenreItems = append(item.GenreItems, NameGuidPair{Id: EncodeID(g.ID), Name: g.Name}) - } - } - // Jellyfin leaves Studios empty for music; we expose record labels here to match our /Studios - // list and StudioIds= filter, so a client can display and click through to filter by label. - if fields.Has("Studios") { - for _, label := range al.Tags.Values(model.TagRecordLabel) { - id := EncodeID(model.NewTag(model.TagRecordLabel, label).ID) - item.Studios = append(item.Studios, NameGuidPair{Name: label, Id: id}) - } - } - // The album's own ReplayGain gain (dB at the RG2 -18 LUFS reference) — same - // convention as tracks; clients read it off the album item as NormalizationGain. - item.NormalizationGain = al.RGAlbumGain - if fields.Has("SortName") { - item.SortName = sortName(al.SortAlbumName, al.OrderAlbumName, al.Name) - } - return item -} - -func ArtistToBaseItem(ar model.Artist, fields Fields) BaseItemDto { - tag, blurs, ratio := primaryImage(ar.ItemImage, ar.ID, fields) - item := BaseItemDto{ - Name: ar.Name, - Id: EncodeID(ar.ID), - Type: "MusicArtist", - IsFolder: true, - AlbumCount: new(ar.AlbumCount), - SongCount: new(ar.SongCount), - DateCreated: JellyfinDate(ar.CreatedAt), - ImageBlurHashes: blurs, - PrimaryImageAspectRatio: ratio, - BackdropImageTags: []string{}, - UserData: UserData(ar.Annotations, ar.ID), - } - if tag != "" { - item.ImageTags = map[string]string{"Primary": tag} - } - if fields.Has("SortName") { - item.SortName = sortName(ar.SortArtistName, ar.OrderArtistName, ar.Name) - } - return item -} - -// LibraryToBaseItem maps a library to the CollectionFolder item clients browse as a top-level node. -// Manet keeps no library, and so syncs nothing, unless it carries the fields Jellyfin sends here. -func LibraryToBaseItem(lib model.Library) BaseItemDto { - id := EncodeLibraryID(lib.ID) - return BaseItemDto{ - Id: id, - Name: lib.Name, - SortName: lib.Name, - Type: "CollectionFolder", - CollectionType: "music", - IsFolder: true, - Path: lib.Path, - LocationType: "FileSystem", - DateCreated: JellyfinDate(&lib.CreatedAt), - ChildCount: new(lib.TotalAlbums), - UserData: &UserItemDataDto{Key: id, ItemId: id}, - BackdropImageTags: []string{}, - } -} - -func GenreToBaseItem(g model.Genre) BaseItemDto { - return BaseItemDto{ - Name: g.Name, - Id: EncodeID(g.ID), - Type: "MusicGenre", - IsFolder: true, - BackdropImageTags: []string{}, - } -} - -func StudioToBaseItem(t model.Tag) BaseItemDto { - return BaseItemDto{ - Name: t.TagValue, - Id: EncodeID(t.ID), - Type: "Studio", - BackdropImageTags: []string{}, - } -} - -// PlaylistToBaseItem maps a playlist to a Playlist BaseItemDto. -func PlaylistToBaseItem(p model.Playlist, fields Fields) BaseItemDto { - tag, blurs, ratio := primaryImage(p.ItemImage, p.ID, fields) - item := BaseItemDto{ - Name: p.Name, - Id: EncodeID(p.ID), - Type: "Playlist", - // Synthetic path: Jellify only surfaces playlists whose Path contains "data" (real Jellyfin - // stores them under its data folder), so without this its Playlists tab hides them all. - Path: "/data/playlists/" + p.ID, - IsFolder: true, - MediaType: "Audio", - ChildCount: new(p.SongCount), - RunTimeTicks: TicksFromSeconds(p.Duration), - DateCreated: JellyfinDate(&p.CreatedAt), - ImageBlurHashes: blurs, - PrimaryImageAspectRatio: ratio, - BackdropImageTags: []string{}, - UserData: UserData(p.Annotations, p.ID), - } - if tag != "" { - item.ImageTags = map[string]string{"Primary": tag} - } - // Playlists have no sort tag; the repository orders them by name. - if fields.Has("SortName") { - item.SortName = p.Name - } - return item -} - -// LyricDtoFromLyrics maps one lyric track to Jellyfin's LyricDto. Clients infer synced-vs-plain -// from per-line Start presence, so synced drops start-less lines and unsynced never emits Start. -func LyricDtoFromLyrics(mf model.MediaFile, lyrics model.Lyrics) LyricDto { - d := LyricDto{ - Metadata: LyricMetadata{ - Artist: cmp.Or(lyrics.DisplayArtist, mf.Artist), - Album: mf.Album, - Title: cmp.Or(lyrics.DisplayTitle, mf.Title), - Length: TicksFromSeconds(mf.Duration), - IsSynced: lyrics.Synced, - }, - Lyrics: make([]LyricLine, 0, len(lyrics.Line)), - } - if lyrics.Offset != nil { - offset := TicksFromMillis(*lyrics.Offset) - d.Metadata.Offset = &offset - } - for _, line := range lyrics.Line { - out := LyricLine{Text: line.Value} - if lyrics.Synced { - if line.Start == nil { - continue - } - start := TicksFromMillis(*line.Start) - out.Start = &start - for _, cue := range line.Cue { - if cue.Start == nil { - continue - } - c := LyricLineCue{ - Position: cue.ByteStart, - EndPosition: cue.ByteEnd, - Start: TicksFromMillis(*cue.Start), - } - if cue.End != nil { - end := TicksFromMillis(*cue.End) - c.End = &end - } - out.Cues = append(out.Cues, c) - } - } - d.Lyrics = append(d.Lyrics, out) - } - return d -} - -// NewSessionInfo's Id is stable per client install, as Jellyfin reuses a device's session across logins. -func NewSessionInfo(u *model.User, client, deviceID, deviceName, version, serverID string) *SessionInfo { - now := time.Now() - return &SessionInfo{ - Id: EncodeID(id.NewHash(client, deviceID)), - UserId: EncodeID(u.ID), - UserName: u.UserName, - Client: client, - DeviceId: deviceID, - DeviceName: deviceName, - ApplicationVersion: version, - ServerId: serverID, - LastActivityDate: JellyfinDate(&now), - IsActive: true, - PlayableMediaTypes: []string{"Audio"}, - SupportedCommands: []string{}, - AdditionalUsers: []any{}, - NowPlayingQueue: []any{}, - PlayState: PlayerStateInfo{RepeatMode: "RepeatNone", PlaybackOrder: "Default"}, - } -} diff --git a/server/jellyfin/dto/mappers_test.go b/server/jellyfin/dto/mappers_test.go deleted file mode 100644 index 259904673..000000000 --- a/server/jellyfin/dto/mappers_test.go +++ /dev/null @@ -1,768 +0,0 @@ -package dto - -import ( - "encoding/json" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("mappers", func() { - It("maps a song to an Audio BaseItemDto", func() { - mf := model.MediaFile{ - ID: testID("song-1"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"), - Artist: "Art", AlbumArtist: "AA", TrackNumber: 3, DiscNumber: 1, - Year: 1999, Duration: 60, Size: 2_500_000, - Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}, - PlayCount: 2, - Starred: true, - } - item := SongToBaseItem(mf, nil) - Expect(item.Type).To(Equal("Audio")) - Expect(item.MediaType).To(Equal("Audio")) - Expect(item.IsFolder).To(BeFalse()) - Expect(item.LocationType).To(Equal("FileSystem")) - Expect(item.Id).To(Equal(EncodeID(testID("song-1")))) - Expect(item.AlbumId).To(Equal(EncodeID(testID("alb-1")))) - Expect(item.ParentId).To(Equal(EncodeID(testID("alb-1")))) - Expect(item.RunTimeTicks).To(Equal(int64(600_000_000))) - Expect(*item.IndexNumber).To(Equal(3)) - Expect(item.UserData.IsFavorite).To(BeTrue()) - Expect(item.UserData.PlayCount).To(Equal(2)) - Expect(item.UserData.Played).To(BeTrue()) - Expect(item.UserData.Key).To(Equal(EncodeID(testID("song-1")))) - Expect(item.UserData.ItemId).To(Equal(EncodeID(testID("song-1")))) - Expect(item.AlbumPrimaryImageTag).To(Equal(testID("alb-1"))) - Expect(item.ImageBlurHashes).To(BeNil()) - Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) - Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}})) - }) - - Describe("Fields gating (matches real Jellyfin)", func() { - mf := model.MediaFile{ID: testID("s1"), Title: "Song", Size: 2_500_000, Suffix: "mp3", Duration: 60, - SortTitle: "sort song", Lyrics: `[{"line":[{"value":"la"}]}]`} - - It("omits MediaSources when Fields does not ask for them", func() { - item := SongToBaseItem(mf, nil) - Expect(item.MediaSources).To(BeNil()) - }) - - It("includes MediaSources only when Fields=MediaSources", func() { - item := SongToBaseItem(mf, ParseFields("ChildCount,MediaSources,SortName")) - Expect(item.MediaSources).To(HaveLen(1)) - Expect(item.MediaSources[0].Size).To(Equal(int64(2_500_000))) - }) - - // SortName must match the server sort order — see the sortName helper. - Describe("SortName", func() { - song := model.MediaFile{ID: testID("s1"), Title: "The Song", SortTitle: "Song, The", OrderTitle: "song"} - ar := model.Artist{ID: testID("art-1"), Name: "The B-52's", SortArtistName: "B-52's, The", OrderArtistName: "b-52's"} - al := model.Album{ID: testID("alb-1"), Name: "The Wall", SortAlbumName: "Wall, The", OrderAlbumName: "wall"} - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - It("is omitted unless Fields=SortName", func() { - Expect(SongToBaseItem(song, nil).SortName).To(BeEmpty()) - Expect(ArtistToBaseItem(ar, nil).SortName).To(BeEmpty()) - Expect(AlbumToBaseItem(al, nil).SortName).To(BeEmpty()) - }) - - It("uses the order names by default, ignoring sort tags", func() { - Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("song")) - Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("b-52's")) - Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("wall")) - }) - - Context("with PreferSortTags", func() { - BeforeEach(func() { - conf.Server.PreferSortTags = true - }) - - It("prefers the sort tags", func() { - Expect(SongToBaseItem(song, ParseFields("SortName")).SortName).To(Equal("Song, The")) - Expect(ArtistToBaseItem(ar, ParseFields("SortName")).SortName).To(Equal("B-52's, The")) - Expect(AlbumToBaseItem(al, ParseFields("SortName")).SortName).To(Equal("Wall, The")) - }) - - It("falls back to the order name when there is no sort tag", func() { - Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "The X", OrderArtistName: "x"}, - ParseFields("SortName")).SortName).To(Equal("x")) - }) - }) - - It("falls back to the display name when order name and sort tag are empty", func() { - Expect(SongToBaseItem(model.MediaFile{ID: testID("s"), Title: "T"}, ParseFields("SortName")).SortName).To(Equal("T")) - Expect(ArtistToBaseItem(model.Artist{ID: testID("a"), Name: "N"}, ParseFields("SortName")).SortName).To(Equal("N")) - Expect(AlbumToBaseItem(model.Album{ID: testID("al"), Name: "A"}, ParseFields("SortName")).SortName).To(Equal("A")) - }) - }) - - It("sets HasLyrics from the media file's lyrics", func() { - Expect(*SongToBaseItem(mf, nil).HasLyrics).To(BeTrue()) - Expect(*SongToBaseItem(model.MediaFile{ID: testID("s2"), Title: "No Lyrics"}, nil).HasLyrics).To(BeFalse()) - // "[]" is the no-lyrics sentinel, not a truthy value. - Expect(*SongToBaseItem(model.MediaFile{ID: testID("s3"), Title: "Empty Lyrics", Lyrics: "[]"}, nil).HasLyrics).To(BeFalse()) - }) - }) - - It("omits ImageBlurHashes when a song has no album", func() { - mf := model.MediaFile{ID: testID("song-noalbum"), Title: "Song", Duration: 60} - item := SongToBaseItem(mf, nil) - Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) - Expect(item.ImageBlurHashes).To(BeNil()) - }) - - It("sets DateCreated from the media file's CreatedAt", func() { - mf := model.MediaFile{ID: testID("s1"), Title: "Song", CreatedAt: time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)} - Expect(SongToBaseItem(mf, nil).DateCreated).To(Equal("2024-01-15T10:30:00.0000000Z")) - }) - - It("omits DateCreated when CreatedAt is the zero time", func() { - Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).DateCreated).To(BeEmpty()) - }) - - It("sets ArtistItems and AlbumArtists (encoded ids) from the track and album artist", func() { - mf := model.MediaFile{ - ID: testID("s1"), Title: "Song", - Artist: "The Band", ArtistID: testID("ar-1"), - AlbumArtist: "Various", AlbumArtistID: testID("ar-2"), - } - item := SongToBaseItem(mf, nil) - Expect(item.ArtistItems).To(Equal([]NameGuidPair{{Name: "The Band", Id: EncodeID(testID("ar-1"))}})) - Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "Various", Id: EncodeID(testID("ar-2"))}})) - }) - - It("omits ArtistItems when the track has no artist id", func() { - Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "X"}, nil).ArtistItems).To(BeNil()) - }) - - It("omits Artists when the track has no artist name or participants", func() { - Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).Artists).To(BeNil()) - }) - - It("splits Artists and ArtistItems per track artist from Participants", func() { - mf := model.MediaFile{ - ID: testID("s1"), Title: "Oooh", - Artist: "De La Soul feat. Redman", ArtistID: testID("ar-delasoul"), - AlbumArtist: "De La Soul", AlbumArtistID: testID("ar-delasoul"), - } - mf.Participants = model.Participants{ - model.RoleArtist: model.ParticipantList{ - {Artist: model.Artist{ID: testID("ar-delasoul"), Name: "De La Soul"}}, - {Artist: model.Artist{ID: testID("ar-redman"), Name: "Redman"}}, - }, - } - item := SongToBaseItem(mf, nil) - Expect(item.Artists).To(Equal([]string{"De La Soul", "Redman"})) - Expect(item.ArtistItems).To(Equal([]NameGuidPair{ - {Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))}, - {Name: "Redman", Id: EncodeID(testID("ar-redman"))}, - })) - // AlbumArtists stays single, matching real Jellyfin. - Expect(item.AlbumArtists).To(Equal([]NameGuidPair{{Name: "De La Soul", Id: EncodeID(testID("ar-delasoul"))}})) - }) - - It("serializes normalization gains with Jellyfin's exact key casing", func() { - mf := model.MediaFile{ID: testID("s1"), Title: "Song", - RGTrackGain: new(-3.5), RGAlbumGain: new(-4.25)} - b, err := json.Marshal(SongToBaseItem(mf, nil)) - Expect(err).ToNot(HaveOccurred()) - Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-3.5`)) - Expect(string(b)).To(ContainSubstring(`"AlbumNormalizationGain":-4.25`)) - }) - - It("omits normalization gains when the file has no ReplayGain tags", func() { - b, err := json.Marshal(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil)) - Expect(err).ToNot(HaveOccurred()) - // Substring check covers both keys (AlbumNormalizationGain contains NormalizationGain). - Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) - }) - - It("builds a MediaSourceInfo from a media file", func() { - mf := model.MediaFile{ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} - src := MediaSourceFromMediaFile(mf) - Expect(src.Id).To(Equal(EncodeID(testID("s1")))) - Expect(src.Size).To(Equal(int64(5242880))) - Expect(src.Container).To(Equal("mp3")) - Expect(src.Bitrate).To(Equal(320_000)) - Expect(src.RunTimeTicks).To(Equal(int64(1_000_000_000))) - Expect(src.Protocol).To(Equal("Http")) - Expect(src.SupportsDirectPlay).To(BeTrue()) - }) - - It("populates MediaStreams with a single Audio stream so Finamp can size downloads", func() { - mf := model.MediaFile{ - ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100, - Channels: 2, SampleRate: 44100, Codec: "mp3", - } - src := MediaSourceFromMediaFile(mf) - Expect(src.MediaStreams).To(HaveLen(1)) - stream := src.MediaStreams[0] - Expect(stream.Type).To(Equal("Audio")) - Expect(stream.Channels).To(Equal(2)) - Expect(stream.SampleRate).To(Equal(44100)) - Expect(stream.BitRate).To(Equal(320_000)) - Expect(stream.Codec).To(Equal("mp3")) - Expect(stream.ChannelLayout).To(Equal("stereo")) - }) - - It("serializes all Finamp-required MediaSourceInfo bools and arrays, never as null", func() { - mf := model.MediaFile{ID: testID("s1"), Size: 5242880, Suffix: "mp3", BitRate: 320, Duration: 100} - src := MediaSourceFromMediaFile(mf) - b, err := json.Marshal(src) - Expect(err).ToNot(HaveOccurred()) - j := string(b) - Expect(j).To(ContainSubstring(`"SupportsProbing":true`)) - Expect(j).To(ContainSubstring(`"IsInfiniteStream":false`)) - Expect(j).To(ContainSubstring(`"RequiresOpening":false`)) - Expect(j).To(ContainSubstring(`"MediaAttachments":[]`)) - Expect(j).To(ContainSubstring(`"Formats":[]`)) - }) - - It("serializes MediaStream's required non-nullable bools, never omitted", func() { - stream := MediaStream{Type: "Audio", Index: 0} - b, err := json.Marshal(stream) - Expect(err).ToNot(HaveOccurred()) - j := string(b) - Expect(j).To(ContainSubstring(`"Type":"Audio"`)) - Expect(j).To(ContainSubstring(`"IsDefault":false`)) - Expect(j).To(ContainSubstring(`"IsInterlaced":false`)) - Expect(j).To(ContainSubstring(`"IsForced":false`)) - Expect(j).To(ContainSubstring(`"IsExternal":false`)) - Expect(j).To(ContainSubstring(`"IsTextSubtitleStream":false`)) - Expect(j).To(ContainSubstring(`"SupportsExternalStream":false`)) - }) - - Describe("Lyric media stream advertising", func() { - It("adds a Lyric media stream when the file has embedded lyrics", func() { - mf := model.MediaFile{ID: testID("s1"), Lyrics: `[{"line":[{"value":"la"}]}]`} - src := MediaSourceFromMediaFile(mf) - Expect(src.MediaStreams).To(HaveLen(2)) - Expect(src.MediaStreams[0].Type).To(Equal("Audio")) - Expect(src.MediaStreams[1].Type).To(Equal("Lyric")) - Expect(src.MediaStreams[1].Index).To(Equal(1)) - Expect(src.MediaStreams[1].IsExternal).To(BeTrue()) - }) - - It("emits only the Audio stream without lyrics", func() { - src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1")}) - Expect(src.MediaStreams).To(HaveLen(1)) - Expect(src.MediaStreams[0].Type).To(Equal("Audio")) - }) - - It("emits only the Audio stream for the post-scan empty-lyrics sentinel", func() { - src := MediaSourceFromMediaFile(model.MediaFile{ID: testID("s1"), Lyrics: "[]"}) - Expect(src.MediaStreams).To(HaveLen(1)) - }) - }) - - It("omits IndexNumber and ParentIndexNumber when track/disc numbers are untagged", func() { - mf := model.MediaFile{ - ID: testID("song-2"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"), - Artist: "Art", AlbumArtist: "AA", TrackNumber: 0, DiscNumber: 0, - Duration: 60, - } - item := SongToBaseItem(mf, nil) - Expect(item.IndexNumber).To(BeNil()) - Expect(item.ParentIndexNumber).To(BeNil()) - }) - - It("maps PlayDate to UserData.LastPlayedDate", func() { - playDate := time.Date(2023, 5, 17, 12, 30, 0, 0, time.UTC) - mf := model.MediaFile{ - ID: testID("song-3"), Title: "Song", Album: "Alb", AlbumID: testID("alb-1"), - Artist: "Art", AlbumArtist: "AA", Duration: 60, - } - mf.PlayDate = &playDate - item := SongToBaseItem(mf, nil) - Expect(item.UserData.LastPlayedDate).NotTo(BeNil()) - Expect(*item.UserData.LastPlayedDate).To(Equal("2023-05-17T12:30:00.0000000Z")) - }) - - It("maps an album to a MusicAlbum folder item", func() { - al := model.Album{ID: testID("alb-1"), Name: "Alb", AlbumArtist: "AA", AlbumArtistID: testID("art-1"), MaxYear: 1999, SongCount: 10, Genres: []model.Genre{{ID: testID("1"), Name: "genre 1"}, {ID: testID("2"), Name: "genre 2"}}} - item := AlbumToBaseItem(al, nil) - Expect(item.Type).To(Equal("MusicAlbum")) - Expect(item.IsFolder).To(BeTrue()) - Expect(item.Id).To(Equal(EncodeID(testID("alb-1")))) - Expect(item.ParentId).To(Equal(EncodeID(testID("art-1")))) - Expect(item.AlbumArtists).To(HaveLen(1)) - Expect(item.AlbumArtists[0].Id).To(Equal(EncodeID(testID("art-1")))) - Expect(item.ArtistItems).To(Equal(item.AlbumArtists)) - Expect(*item.ProductionYear).To(Equal(1999)) - Expect(*item.ChildCount).To(Equal(10)) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-1"))) - Expect(item.ImageBlurHashes).To(BeNil()) - // Jellyfin sends both on every album; Manet stops its sync on an album without them. - Expect(item.Artists).To(Equal([]string{"AA"})) - Expect(item.LocationType).To(Equal("FileSystem")) - Expect(item.Genres).To(Equal([]string{"genre 1", "genre 2"})) - Expect(item.GenreItems).To(Equal([]NameGuidPair{{Id: EncodeID(testID("1")), Name: "genre 1"}, {Id: EncodeID(testID("2")), Name: "genre 2"}})) - }) - - It("populates album Studios from record-label tags only when Fields=Studios", func() { - al := model.Album{ID: testID("alb-2"), Name: "Alb2"} - al.Tags = model.Tags{model.TagRecordLabel: []string{"Columbia", "Legacy"}} - - Expect(AlbumToBaseItem(al, nil).Studios).To(BeEmpty()) - - item := AlbumToBaseItem(al, ParseFields("Studios")) - Expect(item.Studios).To(Equal([]NameGuidPair{ - {Name: "Columbia", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Columbia").ID)}, - {Name: "Legacy", Id: EncodeID(model.NewTag(model.TagRecordLabel, "Legacy").ID)}, - })) - }) - - It("sets NormalizationGain on the album from its ReplayGain", func() { - al := model.Album{ID: testID("al1"), Name: "Album", RGAlbumGain: new(-6.0)} - b, err := json.Marshal(AlbumToBaseItem(al, nil)) - Expect(err).ToNot(HaveOccurred()) - Expect(string(b)).To(ContainSubstring(`"NormalizationGain":-6`)) - // Real Jellyfin never sets AlbumNormalizationGain on an album item. - Expect(string(b)).ToNot(ContainSubstring("AlbumNormalizationGain")) - }) - - It("omits NormalizationGain when the album has no ReplayGain", func() { - b, err := json.Marshal(AlbumToBaseItem(model.Album{ID: testID("al1"), Name: "Album"}, nil)) - Expect(err).ToNot(HaveOccurred()) - Expect(string(b)).ToNot(ContainSubstring("NormalizationGain")) - }) - - Describe("PrimaryImageAspectRatio", func() { - nonSquare := func() model.Album { - al := model.Album{ID: testID("al1"), Name: "Album"} - al.ImageHash, al.ImageWidth, al.ImageHeight = "abc", 1200, 800 - return al - } - - It("is omitted unless the request asks for it", func() { - Expect(AlbumToBaseItem(nonSquare(), nil).PrimaryImageAspectRatio).To(BeNil()) - b, err := json.Marshal(AlbumToBaseItem(nonSquare(), nil)) - Expect(err).ToNot(HaveOccurred()) - Expect(string(b)).ToNot(ContainSubstring("PrimaryImageAspectRatio")) - }) - - It("carries the real ratio when asked", func() { - item := AlbumToBaseItem(nonSquare(), ParseFields("PrimaryImageAspectRatio")) - Expect(*item.PrimaryImageAspectRatio).To(BeNumerically("~", 1.5, 0.0001)) - }) - - It("is omitted when the dimensions are unknown, rather than guessing square", func() { - al := model.Album{ID: testID("al1"), Name: "Album"} - al.ImageHash = "abc" - item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio")) - Expect(item.PrimaryImageAspectRatio).To(BeNil()) - }) - - It("is omitted when the item has no image at all", func() { - al := model.Album{ID: testID("al1"), Name: "Album"} - al.ImageAbsent = true - al.ImageWidth, al.ImageHeight = 1200, 800 - item := AlbumToBaseItem(al, ParseFields("PrimaryImageAspectRatio")) - Expect(item.PrimaryImageAspectRatio).To(BeNil()) - }) - - It("carries the ratio for an artist", func() { - ar := model.Artist{ID: testID("ar1"), Name: "Artist"} - ar.ImageHash, ar.ImageWidth, ar.ImageHeight = "abc", 1000, 500 - Expect(*ArtistToBaseItem(ar, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). - To(BeNumerically("~", 2.0, 0.0001)) - }) - - It("carries the ratio for a playlist", func() { - pl := model.Playlist{ID: testID("pl1"), Name: "Playlist"} - pl.ImageHash, pl.ImageWidth, pl.ImageHeight = "abc", 400, 800 - Expect(*PlaylistToBaseItem(pl, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). - To(BeNumerically("~", 0.5, 0.0001)) - }) - - It("carries the ratio for a song with its own art", func() { - mf := model.MediaFile{ID: testID("mf1"), Title: "Song"} - mf.ImageHash, mf.ImageWidth, mf.ImageHeight = "abc", 300, 600 - Expect(*SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")).PrimaryImageAspectRatio). - To(BeNumerically("~", 0.5, 0.0001)) - }) - - // A track without its own art shows the album's, so the ratio has to describe that image. - It("uses the album's dimensions for a track falling back to album art", func() { - mf := model.MediaFile{ID: testID("mf1"), Title: "Song", AlbumID: testID("al1")} - mf.AlbumImage.ImageHash, mf.AlbumImage.ImageWidth, mf.AlbumImage.ImageHeight = "abc", 1200, 800 - item := SongToBaseItem(mf, ParseFields("PrimaryImageAspectRatio")) - Expect(item.AlbumPrimaryImageTag).To(Equal("abc")) - Expect(*item.PrimaryImageAspectRatio).To(BeNumerically("~", 1.5, 0.0001)) - }) - }) - - It("maps an artist to a MusicArtist folder item", func() { - ar := model.Artist{ID: testID("art-1"), Name: "AA", AlbumCount: 2, SongCount: 20} - item := ArtistToBaseItem(ar, nil) - Expect(item.Type).To(Equal("MusicArtist")) - Expect(item.IsFolder).To(BeTrue()) - Expect(item.Id).To(Equal(EncodeID(testID("art-1")))) - Expect(*item.AlbumCount).To(Equal(2)) - }) - - It("maps a genre to a MusicGenre folder item", func() { - g := model.Genre{ID: testID("genre-1"), Name: "Rock"} - item := GenreToBaseItem(g) - Expect(item.Type).To(Equal("MusicGenre")) - Expect(item.IsFolder).To(BeTrue()) - Expect(item.Id).To(Equal(EncodeID(testID("genre-1")))) - Expect(item.Name).To(Equal("Rock")) - }) - - It("maps a tag to a Studio BaseItemDto", func() { - item := StudioToBaseItem(model.Tag{ID: testID("t1"), TagValue: "Blue Note"}) - Expect(item.Type).To(Equal("Studio")) - Expect(item.Name).To(Equal("Blue Note")) - Expect(item.Id).To(Equal(EncodeID(testID("t1")))) - }) - - Describe("premiereDate", func() { - // Finamp re-sorts "Latest Releases" client-side by PremiereDate; absent values sort arbitrarily. - It("serializes a full date", func() { - mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02-01", Year: 2007} - item := SongToBaseItem(mf, nil) - Expect(*item.PremiereDate).To(Equal("2007-02-01T00:00:00.0000000Z")) - }) - - It("pads a year-only date so clients can parse it", func() { - mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007", Year: 2007} - Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-01-01T00:00:00.0000000Z")) - }) - - It("pads a year-month date", func() { - mf := model.MediaFile{ID: testID("s1"), Title: "Song", Date: "2007-02"} - Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("2007-02-01T00:00:00.0000000Z")) - }) - - It("falls back to the year when no date tag exists", func() { - mf := model.MediaFile{ID: testID("s1"), Title: "Song", Year: 1999} - Expect(*SongToBaseItem(mf, nil).PremiereDate).To(Equal("1999-01-01T00:00:00.0000000Z")) - }) - - It("is omitted when the track has no date at all", func() { - Expect(SongToBaseItem(model.MediaFile{ID: testID("s1"), Title: "Song"}, nil).PremiereDate).To(BeNil()) - }) - - It("is set on albums from their date, falling back to MaxYear", func() { - Expect(*AlbumToBaseItem(model.Album{ID: testID("a1"), Date: "2013-09-06"}, nil).PremiereDate).To(Equal("2013-09-06T00:00:00.0000000Z")) - Expect(*AlbumToBaseItem(model.Album{ID: testID("a2"), MaxYear: 2013}, nil).PremiereDate).To(Equal("2013-01-01T00:00:00.0000000Z")) - Expect(AlbumToBaseItem(model.Album{ID: testID("a3")}, nil).PremiereDate).To(BeNil()) - }) - }) - - It("maps a playlist to a Playlist BaseItemDto", func() { - p := model.Playlist{ - ID: testID("pl-1"), Name: "Chill", SongCount: 7, Duration: 120, - Annotations: model.Annotations{Starred: true, Rating: 4, PlayCount: 2}, - } - item := PlaylistToBaseItem(p, nil) - Expect(item.Type).To(Equal("Playlist")) - Expect(item.IsFolder).To(BeTrue()) - Expect(item.Id).To(Equal(EncodeID(testID("pl-1")))) - Expect(item.Name).To(Equal("Chill")) - Expect(item.MediaType).To(Equal("Audio")) - Expect(*item.ChildCount).To(Equal(7)) - Expect(item.RunTimeTicks).To(Equal(int64(1_200_000_000))) - Expect(item.UserData.IsFavorite).To(BeTrue()) - Expect(item.UserData.PlayCount).To(Equal(2)) - Expect(*item.UserData.Rating).To(Equal(8.0)) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("pl-1"))) - Expect(item.ImageBlurHashes).To(BeNil()) - }) - - It("emits DateCreated and SortName on playlists, like albums and artists", func() { - p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)} - Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z")) - Expect(PlaylistToBaseItem(p, nil).SortName).To(BeEmpty()) - Expect(PlaylistToBaseItem(p, ParseFields("SortName")).SortName).To(Equal("Chill")) - }) - - It("changes the playlist image tag when the cover content changes", func() { - p := model.Playlist{ID: testID("pl-1"), Name: "Chill"} - p.ImageHash = "1111111111111111" - before := PlaylistToBaseItem(p, nil) - p.ImageHash = "2222222222222222" - after := PlaylistToBaseItem(p, nil) - - Expect(before.ImageTags["Primary"]).To(Equal("1111111111111111")) - Expect(after.ImageTags["Primary"]).To(Equal("2222222222222222")) - }) - - It("keeps the playlist image tag stable across a metadata-only edit", func() { - p := model.Playlist{ID: testID("pl-1"), UpdatedAt: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)} - p.ImageHash = "1111111111111111" - before := PlaylistToBaseItem(p, nil) - p.UpdatedAt = time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC) - after := PlaylistToBaseItem(p, nil) - - Expect(after.ImageTags["Primary"]).To(Equal(before.ImageTags["Primary"])) - }) - - // Nothing enqueues media files, so a track's own art only resolves when a client requests it; - // advertising just the album image would leave that cover unreachable. - Describe("unresolved embedded art", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableMediaFileCoverArt = true - }) - - It("advertises the track id so the client triggers the read-through", func() { - mf := model.MediaFile{ID: testID("mf-1"), AlbumID: testID("alb-1"), HasCoverArt: true} - mf.AlbumImage.ImageHash = "0123456789abcdef" - - item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("mf-1"))) - Expect(item.ImageBlurHashes).To(BeNil(), "no resolved image means no blurhash to send") - Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) - }) - - It("falls back to the album when the track has no art of its own", func() { - mf := model.MediaFile{ID: testID("mf-2"), AlbumID: testID("alb-1"), HasCoverArt: false} - mf.AlbumImage.ImageHash = "0123456789abcdef" - - item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(BeEmpty()) - Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) - }) - - It("falls back to the album once the track's art is known absent", func() { - mf := model.MediaFile{ID: testID("mf-3"), AlbumID: testID("alb-1"), HasCoverArt: true} - mf.ItemImage.ImageAbsent = true - mf.AlbumImage.ImageHash = "0123456789abcdef" - - item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(BeEmpty()) - Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) - }) - - It("falls back to the album when per-track art is disabled", func() { - conf.Server.EnableMediaFileCoverArt = false - mf := model.MediaFile{ID: testID("mf-4"), AlbumID: testID("alb-1"), HasCoverArt: true} - mf.AlbumImage.ImageHash = "0123456789abcdef" - - item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(BeEmpty()) - Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) - }) - }) - - Describe("primary image tags", func() { - It("uses the content hash as the tag and emits the real blurhash", func() { - al := model.Album{ID: testID("alb-1"), Name: "Album"} - al.ImageHash = "0123456789abcdef" - al.BlurHash = "LEHV6nWB2yk8" - - item := AlbumToBaseItem(al, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) - }) - - It("omits the blurhash entirely when none was computed", func() { - al := model.Album{ID: testID("alb-2"), Name: "Album"} - al.ImageHash = "0123456789abcdef" - - item := AlbumToBaseItem(al, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "0123456789abcdef")) - Expect(item.ImageBlurHashes).To(BeNil(), "a synthesized blurhash pins stale covers in Finamp") - }) - - It("omits tags for known-absent artwork", func() { - al := model.Album{ID: testID("alb-3"), Name: "Album"} - al.ImageAbsent = true - - item := AlbumToBaseItem(al, nil) - Expect(item.ImageTags).To(BeEmpty()) - Expect(item.ImageBlurHashes).To(BeNil()) - }) - - It("falls back to the entity id while artwork is still unresolved", func() { - item := AlbumToBaseItem(model.Album{ID: testID("alb-4"), Name: "Album"}, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", testID("alb-4"))) - Expect(item.ImageBlurHashes).To(BeNil()) - }) - - It("versions an artist's tag by content hash", func() { - ar := model.Artist{ID: testID("art-1"), Name: "Artist"} - ar.ImageHash = "fedcba9876543210" - ar.BlurHash = "L6PZfSi_.AyE" - - item := ArtistToBaseItem(ar, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "fedcba9876543210")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("fedcba9876543210", "L6PZfSi_.AyE")) - }) - }) - - Describe("song and playlist image tags", func() { - It("versions a song's album tag by the album's content hash", func() { - mf := model.MediaFile{ID: testID("song-1"), Title: "Song", AlbumID: testID("alb-1")} - mf.AlbumImage.ImageHash = "0123456789abcdef" - mf.AlbumImage.BlurHash = "LEHV6nWB2yk8" - - item := SongToBaseItem(mf, nil) - Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("0123456789abcdef", "LEHV6nWB2yk8")) - }) - - It("never synthesizes a song blurhash when the album has none", func() { - mf := model.MediaFile{ID: testID("song-2"), Title: "Song", AlbumID: testID("alb-2")} - mf.AlbumImage.ImageHash = "0123456789abcdef" - - item := SongToBaseItem(mf, nil) - Expect(item.AlbumPrimaryImageTag).To(Equal("0123456789abcdef")) - Expect(item.ImageBlurHashes).To(BeNil()) - }) - - It("omits a song's album tag when the album art is known absent", func() { - mf := model.MediaFile{ID: testID("song-3"), Title: "Song", AlbumID: testID("alb-3")} - mf.AlbumImage.ImageAbsent = true - - item := SongToBaseItem(mf, nil) - Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) - Expect(item.ImageBlurHashes).To(BeNil()) - }) - - It("versions a playlist tag by content hash instead of UpdatedAt", func() { - pl := model.Playlist{ID: testID("pl-1"), Name: "Playlist"} - pl.ImageHash = "abcdef0123456789" - - item := PlaylistToBaseItem(pl, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "abcdef0123456789")) - }) - }) - - Describe("per-song artwork", func() { - It("emits the track's own Primary tag when it has distinct art", func() { - mf := model.MediaFile{ID: testID("song-own"), Title: "Song", AlbumID: testID("alb-1")} - mf.ImageHash = "aaaaaaaaaaaaaaaa" - mf.BlurHash = "LTRACKblur" - mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb" - mf.AlbumImage.BlurHash = "LALBUMblur" - - item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(HaveKeyWithValue("Primary", "aaaaaaaaaaaaaaaa")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveLen(1), - "exactly one Primary entry: Go sorts map keys, so a second entry could pair the wrong blurhash with imageId") - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("aaaaaaaaaaaaaaaa", "LTRACKblur")) - }) - - It("falls back to the album tag when the track has no distinct art", func() { - mf := model.MediaFile{ID: testID("song-inherit"), Title: "Song", AlbumID: testID("alb-1")} - mf.ImageHash = "bbbbbbbbbbbbbbbb" - mf.BlurHash = "LALBUMblur" - mf.AlbumImage.ImageHash = "bbbbbbbbbbbbbbbb" - mf.AlbumImage.BlurHash = "LALBUMblur" - - item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(BeEmpty(), "an inherited cover is the album's image, not the track's") - Expect(item.AlbumPrimaryImageTag).To(Equal("bbbbbbbbbbbbbbbb")) - Expect(item.ImageBlurHashes["Primary"]).To(HaveLen(1)) - Expect(item.ImageBlurHashes["Primary"]).To(HaveKeyWithValue("bbbbbbbbbbbbbbbb", "LALBUMblur")) - }) - - It("omits the track tag when its own art is known absent", func() { - mf := model.MediaFile{ID: testID("song-absent"), Title: "Song", AlbumID: testID("alb-1")} - mf.ImageAbsent = true - mf.AlbumImage.ImageAbsent = true - - item := SongToBaseItem(mf, nil) - Expect(item.ImageTags).To(BeEmpty()) - Expect(item.AlbumPrimaryImageTag).To(BeEmpty()) - Expect(item.ImageBlurHashes).To(BeNil()) - }) - }) -}) - -var _ = Describe("LyricDtoFromLyrics", func() { - ms := func(v int64) *int64 { return &v } - - mf := model.MediaFile{ID: testID("s1"), Title: "Song", Artist: "Artist", Album: "Album", Duration: 100} - - It("maps synced lyrics with tick conversion", func() { - l := model.Lyrics{ - DisplayArtist: "Display Artist", - DisplayTitle: "Display Title", - Synced: true, - Offset: ms(-150), - Line: []model.Line{ - {Start: ms(1000), Value: "line one"}, - {Start: ms(2500), Value: "line two"}, - }, - } - d := LyricDtoFromLyrics(mf, l) - Expect(d.Metadata.Artist).To(Equal("Display Artist")) - Expect(d.Metadata.Title).To(Equal("Display Title")) - Expect(d.Metadata.Album).To(Equal("Album")) - Expect(d.Metadata.IsSynced).To(BeTrue()) - Expect(*d.Metadata.Offset).To(Equal(int64(-1_500_000))) - Expect(d.Metadata.Length).To(Equal(TicksFromSeconds(100))) - Expect(d.Lyrics).To(HaveLen(2)) - Expect(d.Lyrics[0].Text).To(Equal("line one")) - Expect(*d.Lyrics[0].Start).To(Equal(int64(10_000_000))) - Expect(*d.Lyrics[1].Start).To(Equal(int64(25_000_000))) - }) - - It("falls back to the media file's artist and title", func() { - d := LyricDtoFromLyrics(mf, model.Lyrics{Line: []model.Line{{Value: "x"}}}) - Expect(d.Metadata.Artist).To(Equal("Artist")) - Expect(d.Metadata.Title).To(Equal("Song")) - }) - - It("drops start-less lines from synced lyrics", func() { - l := model.Lyrics{Synced: true, Line: []model.Line{ - {Start: ms(0), Value: "kept"}, - {Value: "dropped"}, - }} - d := LyricDtoFromLyrics(mf, l) - Expect(d.Lyrics).To(HaveLen(1)) - Expect(d.Lyrics[0].Text).To(Equal("kept")) - }) - - It("emits no Start on unsynced lyrics even when lines have one", func() { - l := model.Lyrics{Synced: false, Line: []model.Line{{Start: ms(1000), Value: "plain"}}} - d := LyricDtoFromLyrics(mf, l) - Expect(d.Lyrics).To(HaveLen(1)) - Expect(d.Lyrics[0].Start).To(BeNil()) - Expect(d.Metadata.IsSynced).To(BeFalse()) - }) - - It("maps word cues", func() { - end := int64(1500) - l := model.Lyrics{Synced: true, Line: []model.Line{{ - Start: ms(1000), - Value: "word cue", - Cue: []model.Cue{{Start: ms(1000), End: &end, Value: "word", ByteStart: 0, ByteEnd: 4}}, - }}} - d := LyricDtoFromLyrics(mf, l) - Expect(d.Lyrics[0].Cues).To(HaveLen(1)) - c := d.Lyrics[0].Cues[0] - Expect(c.Position).To(Equal(0)) - Expect(c.EndPosition).To(Equal(4)) - Expect(c.Start).To(Equal(int64(10_000_000))) - Expect(*c.End).To(Equal(int64(15_000_000))) - }) - - It("skips a start-less cue while keeping its sibling", func() { - l := model.Lyrics{Synced: true, Line: []model.Line{{ - Start: ms(1000), - Value: "word cue", - Cue: []model.Cue{ - {Start: nil, Value: "dropped", ByteStart: 0, ByteEnd: 7}, - {Start: ms(1000), Value: "kept", ByteStart: 8, ByteEnd: 12}, - }, - }}} - d := LyricDtoFromLyrics(mf, l) - Expect(d.Lyrics[0].Cues).To(HaveLen(1)) - c := d.Lyrics[0].Cues[0] - Expect(c.Position).To(Equal(8)) - Expect(c.EndPosition).To(Equal(12)) - Expect(c.Start).To(Equal(int64(10_000_000))) - }) -}) diff --git a/server/jellyfin/e2e/annotations_test.go b/server/jellyfin/e2e/annotations_test.go deleted file mode 100644 index 182fd5436..000000000 --- a/server/jellyfin/e2e/annotations_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package e2e - -import ( - "net/http" - - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Annotations", func() { - BeforeEach(func() { setupTestDB() }) - - itemUserData := func(id string) *dto.UserItemDataDto { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(id)), &item) - return item.UserData - } - - Describe("favorites", func() { - It("marks and unmarks an album as favorite", func() { - id := albumID("Abbey Road") - - var marked dto.UserItemDataDto - parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &marked) - Expect(marked.IsFavorite).To(BeTrue()) - Expect(itemUserData(id).IsFavorite).To(BeTrue()) - - var unmarked dto.UserItemDataDto - parseInto(del("/Users/admin-1/FavoriteItems/"+enc(id)), &unmarked) - Expect(unmarked.IsFavorite).To(BeFalse()) - Expect(itemUserData(id).IsFavorite).To(BeFalse()) - }) - - It("marks a song as favorite", func() { - id := songID("So What") - var data dto.UserItemDataDto - parseInto(post("/Users/admin-1/FavoriteItems/"+enc(id), ""), &data) - Expect(itemUserData(id).IsFavorite).To(BeTrue()) - }) - - It("marks and unmarks via the current SDK endpoint /UserFavoriteItems/{id} (Jellify)", func() { - id := songID("Come Together") - - var marked dto.UserItemDataDto - parseInto(post("/UserFavoriteItems/"+enc(id), ""), &marked) - Expect(marked.IsFavorite).To(BeTrue()) - Expect(itemUserData(id).IsFavorite).To(BeTrue()) - - var unmarked dto.UserItemDataDto - parseInto(del("/UserFavoriteItems/"+enc(id)), &unmarked) - Expect(unmarked.IsFavorite).To(BeFalse()) - Expect(itemUserData(id).IsFavorite).To(BeFalse()) - }) - - It("filters items to favorites only", func() { - post("/Users/admin-1/FavoriteItems/"+enc(albumID("Abbey Road")), "") - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Filters=IsFavorite")) - Expect(q.TotalRecordCount).To(Equal(1)) - Expect(q.Items[0].Name).To(Equal("Abbey Road")) - }) - - It("marks and lists a playlist as favorite", func() { - id := createPlaylist("Favorite Mix", nil) - Expect(post("/Users/admin-1/FavoriteItems/"+enc(id), "").Code).To(Equal(http.StatusOK)) - Expect(itemUserData(id).IsFavorite).To(BeTrue()) - - q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true&Filters=IsFavorite")) - Expect(q.TotalRecordCount).To(Equal(1)) - Expect(q.Items[0].Name).To(Equal("Favorite Mix")) - }) - - It("filters to favorites via the isFavorite query param (Finamp's artist widget form)", func() { - // Finamp's "Favourite tracks" widget sends isFavorite=true as a query param (not - // Filters=IsFavorite), combined with ArtistIds. - post("/Users/admin-1/FavoriteItems/"+enc(songID("Help!")), "") - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ArtistIds=" + enc(artistID("The Beatles")) + "&isFavorite=true")) - Expect(names(q.Items)).To(ConsistOf("Help!")) - }) - - It("returns 404 when favoriting an unknown item", func() { - Expect(post("/Users/admin-1/FavoriteItems/"+enc(testID("nope")), "").Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("GET /UserItems/{id}/UserData", func() { - It("returns per-item favorite/played state (Jellify's played/favourite indicators)", func() { - id := songID("So What") - post("/Users/admin-1/FavoriteItems/"+enc(id), "") - - var data dto.UserItemDataDto - parseInto(get("/UserItems/"+enc(id)+"/UserData?userId=admin-1"), &data) - Expect(data.IsFavorite).To(BeTrue()) - Expect(data.ItemId).To(Equal(enc(id))) - }) - - It("returns a valid (unfavorited) UserData for an item with no annotations", func() { - var data dto.UserItemDataDto - parseInto(get("/UserItems/"+enc(albumID("Kind of Blue"))+"/UserData"), &data) - Expect(data.IsFavorite).To(BeFalse()) - Expect(data.ItemId).To(Equal(enc(albumID("Kind of Blue")))) - }) - - It("returns 404 for an unknown item", func() { - Expect(get("/UserItems/" + enc(testID("nope")) + "/UserData").Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("ratings", func() { - It("sets and clears an album rating (Jellyfin 0-10 scale)", func() { - id := albumID("IV") - - var set dto.UserItemDataDto - parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=10", ""), &set) - Expect(set.Rating).ToNot(BeNil()) - Expect(*set.Rating).To(Equal(float64(10))) - Expect(*itemUserData(id).Rating).To(Equal(float64(10))) - - // Fresh struct: the DELETE response omits the (now-nil) Rating field, so reusing `set` - // would leave the stale value. - var cleared dto.UserItemDataDto - parseInto(del("/Users/admin-1/Items/"+enc(id)+"/Rating"), &cleared) - Expect(cleared.Rating).To(BeNil()) - Expect(itemUserData(id).Rating).To(BeNil()) - }) - - It("sets and reads a playlist rating", func() { - id := createPlaylist("Rated Mix", nil) - Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=8", "").Code).To(Equal(http.StatusOK)) - Expect(*itemUserData(id).Rating).To(Equal(float64(8))) - }) - - It("clamps an out-of-range rating to the valid domain", func() { - id := albumID("Help!") - var data dto.UserItemDataDto - parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", ""), &data) - // 100 clamps to 10 (Jellyfin) -> 5 (Navidrome) -> 10 back out. - Expect(data.Rating).ToNot(BeNil()) - Expect(*data.Rating).To(Equal(float64(10))) - }) - }) -}) diff --git a/server/jellyfin/e2e/audiomuse_test.go b/server/jellyfin/e2e/audiomuse_test.go deleted file mode 100644 index 074c8e1a4..000000000 --- a/server/jellyfin/e2e/audiomuse_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package e2e - -import ( - "net/http" - "strings" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/sonic" - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("AudioMuse endpoints", func() { - BeforeEach(func() { setupTestDB() }) - - Describe("GET /AudioMuseAI/info", func() { - It("returns version and available endpoints", func() { - var body struct { - Version string `json:"Version"` - AvailableEndpoints []string `json:"AvailableEndpoints"` - } - parseInto(get("/AudioMuseAI/info"), &body) - Expect(body.Version).To(Equal(consts.Version)) - Expect(body.AvailableEndpoints).To(ConsistOf( - "GET /AudioMuseAI/find_path", - "GET /AudioMuseAI/health", - "GET /AudioMuseAI/similar_tracks", - )) - }) - - It("requires authentication", func() { - Expect(rawReq("GET", "/AudioMuseAI/info", "").Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("GET /AudioMuseAI/health", func() { - It("returns 200 with an empty body when a provider is loaded", func() { - w := get("/AudioMuseAI/health") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.Len()).To(Equal(0)) - }) - - It("requires authentication", func() { - Expect(rawReq("GET", "/AudioMuseAI/health", "").Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("GET /AudioMuseAI/similar_tracks", func() { - It("maps provider results to seeded tracks, encoding item ids", func() { - sonicProviderFake.similar = []sonic.SimilarResult{ - {Song: songAgent("Something"), Similarity: 0.3}, - {Song: songAgent("So What"), Similarity: 0.5}, - } - var body []struct { - Author string `json:"author"` - Distance float64 `json:"distance"` - ItemID string `json:"item_id"` - Title string `json:"title"` - } - parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Come Together"))+"&n=10"), &body) - Expect(body).To(HaveLen(2)) - Expect([]string{body[0].Title, body[1].Title}).To(ConsistOf("Something", "So What")) - decoded, ok := dto.DecodeID(body[0].ItemID) - Expect(ok).To(BeTrue()) - Expect(decoded).To(Equal(songID(body[0].Title))) - }) - - It("collapses to one track per artist by default", func() { - sonicProviderFake.similar = []sonic.SimilarResult{ - {Song: songAgent("Something"), Similarity: 0.3}, - {Song: songAgent("Come Together"), Similarity: 0.5}, - } - var body []map[string]any - parseInto(get("/AudioMuseAI/similar_tracks?item_id="+enc(songID("Help!"))), &body) - Expect(body).To(HaveLen(1)) // both similar tracks are by The Beatles - }) - - It("returns an empty array (not null) when there are no results", func() { - sonicProviderFake.similar = nil - w := get("/AudioMuseAI/similar_tracks?item_id=" + enc(songID("Come Together"))) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("[]")) - }) - - It("requires authentication", func() { - Expect(rawReq("GET", "/AudioMuseAI/similar_tracks?item_id=x", "").Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("GET /AudioMuseAI/find_path", func() { - It("returns 400 with the exact message when a required id is missing", func() { - w := get("/AudioMuseAI/find_path?start_song_id=" + enc(songID("Something"))) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(strings.TrimSpace(w.Body.String())).To(Equal("start_song_id and end_song_id are required.")) - }) - - It("returns the path and summed total_distance", func() { - sonicProviderFake.path = []sonic.SimilarResult{ - {Song: songAgent("Come Together"), Similarity: 1.5}, - {Song: songAgent("So What"), Similarity: 2.0}, - } - var body struct { - Path []struct { - ItemID string `json:"item_id"` - Title string `json:"title"` - } `json:"path"` - TotalDistance float64 `json:"total_distance"` - } - parseInto(get("/AudioMuseAI/find_path?start_song_id="+enc(songID("Something"))+"&end_song_id="+enc(songID("So What"))+"&max_steps=10"), &body) - Expect(body.Path).To(HaveLen(2)) - Expect(body.TotalDistance).To(Equal(3.5)) - }) - }) -}) diff --git a/server/jellyfin/e2e/auth_test.go b/server/jellyfin/e2e/auth_test.go deleted file mode 100644 index 35daaf598..000000000 --- a/server/jellyfin/e2e/auth_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package e2e - -import ( - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Authentication", func() { - BeforeEach(func() { setupTestDB() }) - - authenticate := func(username, pw string) *httptest.ResponseRecorder { - body := `{"Username":"` + username + `","Pw":"` + pw + `"}` - return rawReq("POST", "/Users/AuthenticateByName", body) - } - - Describe("POST /Users/AuthenticateByName", func() { - It("authenticates a valid user and returns a usable token", func() { - w := authenticate("admin", "password") - var res dto.AuthenticationResult - parseInto(w, &res) - Expect(res.AccessToken).ToNot(BeEmpty()) - Expect(res.User).ToNot(BeNil()) - Expect(res.User.Name).To(Equal("admin")) - Expect(res.User.Id).To(Equal(enc(testID("admin-1")))) - Expect(res.User.Policy.IsAdministrator).To(BeTrue()) - Expect(res.ServerId).To(MatchRegexp("^[0-9a-f]{32}$")) - - // The returned token must actually authenticate a protected request. - r := httptest.NewRequest("GET", "/Users/Me", nil) - r.Header.Set("X-Emby-Token", res.AccessToken) - pw := httptest.NewRecorder() - router.ServeHTTP(pw, r) - Expect(pw.Code).To(Equal(http.StatusOK)) - }) - - It("marks a non-admin user's policy as non-administrator", func() { - w := authenticate("regular", "password") - var res dto.AuthenticationResult - parseInto(w, &res) - Expect(res.User.Policy.IsAdministrator).To(BeFalse()) - }) - - It("rejects a wrong password", func() { - Expect(authenticate("admin", "wrong").Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects an empty password", func() { - Expect(authenticate("admin", "").Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects an unknown user", func() { - Expect(authenticate("nobody", "password").Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects a malformed body", func() { - Expect(rawReq("POST", "/Users/AuthenticateByName", "not json").Code).To(Equal(http.StatusBadRequest)) - }) - - It("mints a non-expiring token scoped to the Jellyfin audience", func() { - w := authenticate("admin", "password") - var res dto.AuthenticationResult - parseInto(w, &res) - - claims, err := auth.Validate(res.AccessToken) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.ExpiresAt.IsZero()).To(BeTrue()) - Expect(claims.Audience).To(Equal([]string{"jellyfin"})) - Expect(claims.Subject).To(Equal("admin")) - }) - - It("revokes an already-issued token when the user's epoch is bumped", func() { - w := authenticate("admin", "password") - var res dto.AuthenticationResult - parseInto(w, &res) - - r := httptest.NewRequest("GET", "/Users/Me", nil) - r.Header.Set("X-Emby-Token", res.AccessToken) - pw := httptest.NewRecorder() - router.ServeHTTP(pw, r) - Expect(pw.Code).To(Equal(http.StatusOK)) - - // A real password change through the repository, which is what revokes in production. - admin, err := ds.User().Get(ctx, testID("admin-1")) - Expect(err).ToNot(HaveOccurred()) - admin.NewPassword = "rotated" - Expect(ds.User().Put(ctx, admin)).To(Succeed()) - - r = httptest.NewRequest("GET", "/Users/Me", nil) - r.Header.Set("X-Emby-Token", res.AccessToken) - pw = httptest.NewRecorder() - router.ServeHTTP(pw, r) - Expect(pw.Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("GET /Users/Public", func() { - publicUsers := func() []dto.UserDto { - w := rawReq("GET", "/Users/Public", "") - Expect(w.Code).To(Equal(http.StatusOK)) - var users []dto.UserDto - parseInto(w, &users) - return users - } - - It("returns an empty list when no users are exposed", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.ExposedPublicUsers = "" - Expect(publicUsers()).To(BeEmpty()) - }) - - It("lists the configured users to an unauthenticated caller, without policy", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.ExposedPublicUsers = "regular" - users := publicUsers() - Expect(users).To(HaveLen(1)) - Expect(users[0].Name).To(Equal("regular")) - Expect(users[0].Id).To(Equal(enc(testID("regular-1")))) - Expect(users[0].Policy).To(BeNil()) // must not leak admin status pre-login - }) - }) - - Describe("current user", func() { - It("returns the caller from GET /Users/Me", func() { - var u dto.UserDto - parseInto(getAs(regularUser, "/Users/Me"), &u) - Expect(u.Name).To(Equal("regular")) - Expect(u.Id).To(Equal(enc(testID("regular-1")))) - }) - - It("returns the caller from GET /Users/{userId}", func() { - var u dto.UserDto - parseInto(get("/Users/admin-1"), &u) - Expect(u.Name).To(Equal("admin")) - }) - }) - - Describe("auth enforcement", func() { - It("rejects a protected request with no token", func() { - Expect(rawReq("GET", "/Users/Me", "").Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects a protected request with a bogus token", func() { - r := httptest.NewRequest("GET", "/Users/Me", nil) - r.Header.Set("X-Emby-Token", "not-a-valid-jwt") - w := httptest.NewRecorder() - router.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) -}) diff --git a/server/jellyfin/e2e/browsing_test.go b/server/jellyfin/e2e/browsing_test.go deleted file mode 100644 index 68d87cd25..000000000 --- a/server/jellyfin/e2e/browsing_test.go +++ /dev/null @@ -1,701 +0,0 @@ -package e2e - -import ( - "encoding/json" - "net/http" - "slices" - "sort" - "time" - - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func names(items []dto.BaseItemDto) []string { - out := make([]string, len(items)) - for i, it := range items { - out[i] = it.Name - } - return out -} - -var _ = Describe("Browsing", func() { - BeforeEach(func() { setupTestDB() }) - - Describe("GET /UserViews", func() { - It("returns the user's libraries as CollectionFolders", func() { - q := queryResult(get("/UserViews")) - Expect(q.TotalRecordCount).To(Equal(1)) - Expect(q.Items[0].Name).To(Equal("Music Library")) - Expect(q.Items[0].Type).To(Equal("CollectionFolder")) - Expect(q.Items[0].CollectionType).To(Equal("music")) - }) - }) - - Describe("GET /Items by type", func() { - It("lists all albums", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(5)) - Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) - }) - - It("lists all songs with Audio type and an AlbumId", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(7)) - for _, it := range q.Items { - Expect(it.Type).To(Equal("Audio")) - Expect(it.MediaType).To(Equal("Audio")) - Expect(it.LocationType).To(Equal("FileSystem")) - Expect(it.ServerId).To(MatchRegexp("^[0-9a-f]{32}$")) // real Jellyfin always sets it, no-dash GUID form - Expect(it.AlbumId).ToNot(BeEmpty()) - } - }) - - // Real Jellyfin omits MediaSources from a plain list response, returning it only when the - // client asks via Fields=MediaSources (Finamp's download dialog does). - It("omits MediaSources unless Fields=MediaSources is requested", func() { - plain := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")) - for _, it := range plain.Items { - Expect(it.MediaSources).To(BeEmpty()) - } - withSources := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=MediaSources")) - for _, it := range withSources.Items { - Expect(it.MediaSources).To(HaveLen(1)) - } - }) - - // Clients (Finamp, Feishin) send Fields as repeated params rather than one comma-separated - // value; real Jellyfin accepts both, so a later Fields=MediaSources must still take effect. - It("honors MediaSources when Fields is sent as repeated params", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Fields=Genres&Fields=MediaSources")) - Expect(q.Items).ToNot(BeEmpty()) - for _, it := range q.Items { - Expect(it.MediaSources).To(HaveLen(1)) - } - }) - - It("lists all album artists", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(4)) - Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist")) - }) - - // Finamp's A-Z jump scans SortName client-side, so it must follow the response order. - It("returns artists' SortName matching the server sort order when Fields=SortName", func() { - plain := queryResult(get("/Artists/AlbumArtists?Recursive=true&SortBy=SortName")) - for _, it := range plain.Items { - Expect(it.SortName).To(BeEmpty()) - } - q := queryResult(get("/Artists/AlbumArtists?Recursive=true&SortBy=SortName&Fields=SortName")) - Expect(q.Items).ToNot(BeEmpty()) - sortNames := make([]string, 0, len(q.Items)) - for _, it := range q.Items { - Expect(it.SortName).ToNot(BeEmpty()) - sortNames = append(sortNames, it.SortName) - } - Expect(slices.IsSorted(sortNames)).To(BeTrue(), "SortName values must follow the response order: %v", sortNames) - // "The Beatles" must be filed under B, exposing the article-stripped key to clients. - Expect(sortNames).To(ContainElement("beatles")) - }) - - It("lists all genres", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicGenre&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(3)) - Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop")) - }) - - It("returns no playlists when none exist", func() { - q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(0)) - Expect(q.Items).To(BeEmpty()) - }) - - // Manet syncs collections as Boxset, and took albums coming back instead as a sync failure. - DescribeTable("returns nothing for a type it does not serve", - func(itemType string) { - q := queryResult(get("/Items?IncludeItemTypes=" + itemType + "&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(0)) - Expect(q.Items).To(BeEmpty()) - }, - Entry("a Jellyfin kind Navidrome has none of", "Boxset"), - ) - - It("treats a name Jellyfin does not know as an absent IncludeItemTypes", func() { - q := queryResult(get("/Items?IncludeItemTypes=Nonsense&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(queryResult(get("/Items?Recursive=true")).TotalRecordCount)) - Expect(q.TotalRecordCount).To(BeNumerically(">", 0)) - }) - - // A strict client (Manet) fails its whole sync on the first item missing any of these. - DescribeTable("sends the keys Jellyfin puts on every item", - func(itemType string, fields string, nonNull ...string) { - var body struct{ Items []map[string]json.RawMessage } - res := get("/Items?IncludeItemTypes=" + itemType + "&Fields=" + fields + "&Recursive=true") - Expect(json.Unmarshal(res.Body.Bytes(), &body)).To(Succeed()) - Expect(body.Items).ToNot(BeEmpty()) - for _, it := range body.Items { - Expect(it).To(HaveKey("ChannelId"), "ChannelId is null, but always present") - for _, k := range nonNull { - Expect(it).To(HaveKey(k)) - Expect(string(it[k])).ToNot(Equal("null"), k) - } - } - }, - Entry("songs", "Audio", "Genres,Tags", "ImageTags", "HasLyrics", "Genres", "GenreItems", "Tags"), - Entry("albums", "MusicAlbum", "Genres", "ImageTags", "Genres", "GenreItems"), - Entry("artists", "MusicArtist", "Genres", "ImageTags", "Genres", "GenreItems"), - ) - - It("sends MediaType Unknown on items without one, as Jellyfin always emits it", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) - Expect(q.Items).ToNot(BeEmpty()) - for _, it := range q.Items { - Expect(it.MediaType).To(Equal("Unknown")) - } - Expect(queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true")).Items[0].MediaType).To(Equal("Audio")) - }) - - }) - - Describe("ParentId browsing", func() { - It("browses an artist's albums", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + enc(artistID("The Beatles")))) - Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) - }) - - It("browses an album's tracks in track order by default", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")))) - Expect(q.TotalRecordCount).To(Equal(2)) - // Track order (Something=1, Come Together=2) differs from alphabetical title order, - // proving the sort is by track number, not name. - Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) - Expect(*q.Items[0].IndexNumber).To(Equal(1)) - Expect(*q.Items[1].IndexNumber).To(Equal(2)) - }) - - // "Latest Releases": if PremiereDate isn't recognized, applySort falls through to album-name order. - It("sorts an artist's tracks by release year for SortBy=PremiereDate (Latest Releases)", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumArtistIds=" + enc(artistID("The Beatles")) + - "&SortBy=PremiereDate%2CAlbum%2CParentIndexNumber%2CIndexNumber%2CSortName&SortOrder=Descending")) - got := names(q.Items) - Expect(got).To(HaveLen(3)) - Expect(got[:2]).To(ConsistOf("Come Together", "Something")) - Expect(got[2]).To(Equal("Help!")) - }) - - It("respects Finamp's explicit ParentIndexNumber/IndexNumber SortBy on an album", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&SortBy=ParentIndexNumber,IndexNumber,SortName")) - Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) - }) - }) - - // Finamp's download sync asks a library for the tracks outside any album this way; answering - // with every track would stream the whole library. - Describe("Recursive=false", func() { - lib1 := dto.EncodeLibraryID(1) - - It("returns no songs for a library parent", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + lib1 + "&Recursive=false")) - Expect(q.Items).To(BeEmpty()) - Expect(q.TotalRecordCount).To(BeZero()) - }) - - It("still lists the library's albums", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&ParentId=" + lib1 + "&Recursive=false")) - Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) - }) - - It("still lists an album's tracks", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(albumID("Abbey Road")) + "&Recursive=false")) - Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) - }) - }) - - // Finamp's artist screen sends ParentId=<libraryId> (scoping) plus AlbumArtistIds/ArtistIds - // for the actual artist filter, not ParentId=<artistId>. - Describe("artist filtering (AlbumArtistIds / ArtistIds)", func() { - lib1 := dto.EncodeLibraryID(1) - - It("filters albums by AlbumArtistIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(artistID("The Beatles")))) - Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) - }) - - It("filters songs by ArtistIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=" + enc(artistID("The Beatles")))) - Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!")) - }) - - It("filters albums by a single-album artist", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&AlbumArtistIds=" + enc(artistID("Led Zeppelin")))) - Expect(names(q.Items)).To(ConsistOf("IV")) - }) - - It("filters songs by a single-track artist", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ArtistIds=" + enc(artistID("Miles Davis")))) - Expect(names(q.Items)).To(ConsistOf("So What")) - }) - - // contributingArtistIds is Jellify's "Featured On" section: albums the artist only appears - // on, which must exclude their own discography (albums where they are the album artist). - It("lists Featured On albums (contributingArtistIds) a performer only guests on", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("Featured Guest")))) - Expect(names(q.Items)).To(ConsistOf("Singles")) - }) - - It("excludes an album artist's own discography from Featured On (contributingArtistIds)", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&contributingArtistIds=" + enc(artistID("The Beatles")))) - Expect(names(q.Items)).ToNot(ContainElement("Abbey Road")) - Expect(names(q.Items)).ToNot(ContainElement("Help!")) - }) - }) - - // A malformed id must 404, not silently drop the filter and widen the query to the whole - // library; a well-formed but unknown one must still fail closed to zero results. - Describe("stale and malformed id filtering", func() { - lib1 := dto.EncodeLibraryID(1) - - It("404s a malformed ParentId instead of listing every song", func() { - w := get("/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns zero songs, not the whole library, for a well-formed but unknown ParentId", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&ParentId=" + enc(testID("no-such-album")))) - Expect(q.TotalRecordCount).To(BeZero()) - Expect(q.Items).To(BeEmpty()) - }) - - It("404s a malformed AlbumArtistIds instead of listing every album", func() { - w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns zero albums, not every album, for a well-formed but unknown AlbumArtistIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&AlbumArtistIds=" + enc(testID("no-such-artist")))) - Expect(q.TotalRecordCount).To(BeZero()) - Expect(q.Items).To(BeEmpty()) - }) - - It("404s a malformed ArtistIds instead of listing every song", func() { - w := get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns zero songs, not every song, for a well-formed but unknown ArtistIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&ArtistIds=" + enc(testID("no-such-artist")))) - Expect(q.TotalRecordCount).To(BeZero()) - Expect(q.Items).To(BeEmpty()) - }) - - It("404s a malformed Ids entry instead of batch-fetching nothing", func() { - w := get("/Items?Ids=not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("404s when only one of several Ids entries is malformed", func() { - w := get("/Items?Ids=" + enc(songID("Something")) + ",not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns zero items, not an error, for a well-formed but unknown Ids entry", func() { - q := queryResult(get("/Items?Ids=" + enc(testID("no-such-item")))) - Expect(q.TotalRecordCount).To(BeZero()) - Expect(q.Items).To(BeEmpty()) - }) - - It("404s a malformed GenreIds entry instead of listing every album", func() { - w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("404s when only one of several GenreIds entries is malformed", func() { - w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + ",not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("404s a malformed AlbumIds entry instead of listing every song", func() { - w := get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("404s a malformed StudioIds entry instead of listing every album", func() { - w := get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=not-a-valid-id") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns zero albums, not every album, for a well-formed but unknown StudioIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + enc(testID("no-such-studio")))) - Expect(q.TotalRecordCount).To(BeZero()) - Expect(q.Items).To(BeEmpty()) - }) - }) - - // Feishin fetches an album's tracks with AlbumIds=<albumId>&IncludeItemTypes=Audio&Recursive=true. - Describe("album filtering (AlbumIds)", func() { - It("filters songs by AlbumIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(albumID("Abbey Road")))) - Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) - Expect(q.TotalRecordCount).To(Equal(2)) - }) - - It("matches any of multiple comma-separated AlbumIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(albumID("Abbey Road")) + "," + enc(albumID("IV")))) - Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Stairway To Heaven")) - Expect(q.TotalRecordCount).To(Equal(3)) - }) - - It("returns nothing for an unknown album id", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&AlbumIds=" + enc(testID("no-such-album")))) - Expect(q.Items).To(BeEmpty()) - Expect(q.TotalRecordCount).To(Equal(0)) - }) - }) - - Describe("year filtering (Years=)", func() { - It("filters items by Years=", func() { - albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Years=1959")) - Expect(names(albums.Items)).To(ConsistOf("Kind of Blue")) - - songs := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&Years=1959")) - for _, it := range songs.Items { - Expect(it.ProductionYear).ToNot(BeNil()) - Expect(*it.ProductionYear).To(Equal(1959)) - } - Expect(songs.Items).ToNot(BeEmpty()) - }) - }) - - Describe("studio filtering (StudioIds=)", func() { - It("filters items by StudioIds=", func() { - studios := queryResult(get("/Studios")) - var columbiaID string - for _, it := range studios.Items { - if it.Name == "Columbia" { - columbiaID = it.Id - } - } - Expect(columbiaID).ToNot(BeEmpty()) - - albums := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&StudioIds=" + columbiaID)) - Expect(names(albums.Items)).To(ConsistOf("Kind of Blue")) - }) - - It("returns filter lists scoped to a ParentId library", func() { - var filters dto.QueryFiltersLegacy - parseInto(get("/Items/Filters?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio&Recursive=true"), &filters) - Expect(filters.Years).To(ContainElements(1959, 1965)) - studios := queryResult(get("/Studios?ParentId=" + dto.EncodeLibraryID(1))) - Expect(names(studios.Items)).To(ContainElement("Columbia")) - }) - }) - - // Finamp's genre screen sends ParentId=<libraryId> (scoping) plus GenreIds=<genreId>. - Describe("genre filtering (GenreIds)", func() { - lib1 := dto.EncodeLibraryID(1) - - It("filters albums by GenreIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz")))) - Expect(names(q.Items)).To(ConsistOf("Kind of Blue")) - Expect(q.TotalRecordCount).To(Equal(1)) - }) - - It("filters songs by GenreIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Rock")))) - Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!", "Stairway To Heaven")) - Expect(q.TotalRecordCount).To(Equal(4)) - }) - - It("matches any of multiple comma-separated GenreIds", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + "," + enc(genreID("Pop")))) - Expect(names(q.Items)).To(ConsistOf("Kind of Blue", "Singles")) - }) - - It("matches any of multiple repeated GenreIds params (@jellyfin/sdk spelling)", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(genreID("Jazz")) + "&GenreIds=" + enc(genreID("Pop")))) - Expect(names(q.Items)).To(ConsistOf("Kind of Blue", "Singles")) - }) - - It("returns nothing for an unknown genre id", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&GenreIds=" + enc(testID("no-such-genre")))) - Expect(q.Items).To(BeEmpty()) - Expect(q.TotalRecordCount).To(Equal(0)) - }) - - It("filters album artists by GenreIds on /Artists/AlbumArtists", func() { - q := queryResult(get("/Artists/AlbumArtists?ParentId=" + lib1 + "&GenreIds=" + enc(genreID("Jazz")))) - Expect(names(q.Items)).To(ConsistOf("Miles Davis")) - Expect(q.TotalRecordCount).To(Equal(1)) - }) - - It("matches album artists of any of multiple GenreIds", func() { - q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc(genreID("Jazz")) + "," + enc(genreID("Pop")))) - Expect(names(q.Items)).To(ConsistOf("Miles Davis", "Solo Artist")) - }) - - It("filters album artists by GenreIds via /Items?IncludeItemTypes=MusicArtist", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicArtist&Recursive=true&GenreIds=" + enc(genreID("Rock")))) - Expect(names(q.Items)).To(ConsistOf("The Beatles", "Led Zeppelin")) - }) - - It("returns no artists for an unknown genre id", func() { - q := queryResult(get("/Artists/AlbumArtists?GenreIds=" + enc(testID("no-such-genre")))) - Expect(q.Items).To(BeEmpty()) - }) - }) - - // Jellify (and the official Jellyfin TypeScript SDK) send query params in camelCase - // (parentId, includeItemTypes, albumArtistIds), where Finamp sends PascalCase. Real Jellyfin - // binds them case-insensitively; these guard that our dispatcher does too, and that browsing an - // album with only parentId (no IncludeItemTypes, as Jellify does) returns its tracks. - Describe("camelCase query params (Jellify / JS SDK)", func() { - lib1 := dto.EncodeLibraryID(1) - - It("filters albums by camelCase albumArtistIds", func() { - q := queryResult(get("/Items?includeItemTypes=MusicAlbum&recursive=true&parentId=" + lib1 + "&albumArtistIds=" + enc(artistID("The Beatles")))) - Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) - }) - - It("filters songs by camelCase artistIds", func() { - q := queryResult(get("/Items?includeItemTypes=Audio&recursive=true&parentId=" + lib1 + "&artistIds=" + enc(artistID("The Beatles")))) - Expect(names(q.Items)).To(ConsistOf("Come Together", "Something", "Help!")) - }) - - It("browses an album's tracks with only camelCase parentId (no IncludeItemTypes)", func() { - q := queryResult(get("/Items?parentId=" + enc(albumID("Abbey Road")) + "&sortBy=ParentIndexNumber&sortBy=IndexNumber&sortBy=SortName")) - Expect(q.TotalRecordCount).To(Equal(2)) - Expect(names(q.Items)).To(Equal([]string{"Something", "Come Together"})) - }) - - It("browses an artist's albums with only camelCase parentId (no IncludeItemTypes)", func() { - q := queryResult(get("/Items?parentId=" + enc(artistID("The Beatles")))) - Expect(names(q.Items)).To(ConsistOf("Abbey Road", "Help!")) - }) - }) - - Describe("search, batch and pagination", func() { - It("searches albums by term", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SearchTerm=Abbey")) - Expect(names(q.Items)).To(ContainElement("Abbey Road")) - }) - - It("batch-fetches specific items by Ids", func() { - ids := enc(albumID("Abbey Road")) + "," + enc(albumID("IV")) - q := queryResult(get("/Items?ids=" + ids)) - Expect(q.TotalRecordCount).To(Equal(2)) - Expect(names(q.Items)).To(ConsistOf("Abbey Road", "IV")) - }) - - It("applies Limit while reporting the full TotalRecordCount", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&Limit=2")) - Expect(q.Items).To(HaveLen(2)) - Expect(q.TotalRecordCount).To(Equal(5)) - }) - - It("pages distinct items via StartIndex", func() { - p1 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=0")) - p2 := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SortBy=SortName&Limit=2&StartIndex=2")) - Expect(p1.Items).To(HaveLen(2)) - Expect(p2.Items).To(HaveLen(2)) - Expect(names(p1.Items)).ToNot(ContainElement(BeElementOf(names(p2.Items)))) - }) - - It("merges multiple types into one paginated result", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(12)) // 5 albums + 7 songs - }) - - // Chaining the per-type cursors must preserve the merged order. - It("streams an unbounded multi-type merge, honoring StartIndex", func() { - all := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true")) - Expect(all.Items).To(HaveLen(12)) - - skipped := queryResult(get("/Items?IncludeItemTypes=MusicAlbum,Audio&Recursive=true&StartIndex=2")) - Expect(skipped.Items).To(HaveLen(10)) - Expect(skipped.TotalRecordCount).To(Equal(12)) - Expect(skipped.StartIndex).To(Equal(2)) - Expect(names(skipped.Items)).To(Equal(names(all.Items)[2:])) - }) - - // Paging must ride on the cursor query's LIMIT/OFFSET, not be applied after materializing. - It("pages songs via StartIndex/Limit while reporting the full total", func() { - all := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName")) - Expect(all.TotalRecordCount).To(Equal(7)) - Expect(all.Items).To(HaveLen(7)) - - p1 := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Limit=3&StartIndex=0")) - p2 := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SortBy=SortName&Limit=3&StartIndex=3")) - Expect(p1.Items).To(HaveLen(3)) - Expect(p2.Items).To(HaveLen(3)) - Expect(p1.TotalRecordCount).To(Equal(7)) - // The two pages are distinct and match the head of the unpaged, identically-sorted list. - Expect(names(p1.Items)).ToNot(ContainElement(BeElementOf(names(p2.Items)))) - Expect(append(names(p1.Items), names(p2.Items)...)).To(Equal(names(all.Items)[:6])) - }) - }) - - Describe("GET /Items/{id}", func() { - It("resolves an album", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(albumID("Kind of Blue"))), &item) - Expect(item.Name).To(Equal("Kind of Blue")) - Expect(item.Type).To(Equal("MusicAlbum")) - }) - - It("resolves a song", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(songID("So What"))), &item) - Expect(item.Type).To(Equal("Audio")) - }) - - It("includes a parseable DateCreated (Date Added) on a song", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(songID("So What"))), &item) - Expect(item.DateCreated).ToNot(BeEmpty()) - _, err := time.Parse(time.RFC3339, item.DateCreated) - Expect(err).ToNot(HaveOccurred()) - }) - - It("includes structured ArtistItems and AlbumArtists on a song (now-playing artist)", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(songID("So What"))), &item) - Expect(item.ArtistItems).ToNot(BeEmpty()) - Expect(item.ArtistItems[0].Name).To(Equal("Miles Davis")) - Expect(item.ArtistItems[0].Id).ToNot(BeEmpty()) - Expect(item.AlbumArtists).ToNot(BeEmpty()) - Expect(item.AlbumArtists[0].Name).To(Equal("Miles Davis")) - }) - - It("exposes NormalizationGain and AlbumNormalizationGain from ReplayGain tags", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &item) - Expect(item.NormalizationGain).ToNot(BeNil()) - Expect(*item.NormalizationGain).To(BeNumerically("~", -3.5, 0.001)) - Expect(item.AlbumNormalizationGain).ToNot(BeNil()) - Expect(*item.AlbumNormalizationGain).To(BeNumerically("~", -4.25, 0.001)) - }) - - It("omits normalization gains for files without ReplayGain tags", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(songID("So What"))), &item) - Expect(item.NormalizationGain).To(BeNil()) - Expect(item.AlbumNormalizationGain).To(BeNil()) - }) - - It("resolves an artist", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(artistID("Miles Davis"))), &item) - Expect(item.Type).To(Equal("MusicArtist")) - }) - - It("returns 404 for an unknown id", func() { - Expect(get("/Items/" + enc(testID("does-not-exist"))).Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("GET /Users/{userId}/Items/Latest", func() { - It("returns recent albums as a bare array, respecting Limit", func() { - var items []dto.BaseItemDto - parseInto(get("/Users/admin-1/Items/Latest?Limit=3"), &items) - Expect(items).To(HaveLen(3)) - for _, it := range items { - Expect(it.Type).To(Equal("MusicAlbum")) - } - }) - }) - - Describe("GET /Items/Latest", func() { - // Jellyfin marks the /Users/{userId} form obsolete and hides it from the OpenAPI spec, so - // SDK-generated clients (Jellify) only ever call this one. - It("serves the same response as the legacy /Users/{userId} route", func() { - Expect(get("/Items/Latest?Limit=3").Body.String()). - To(Equal(get("/Users/admin-1/Items/Latest?Limit=3").Body.String())) - }) - - It("scopes to ParentId when it names a library", func() { - var items []dto.BaseItemDto - parseInto(get("/Items/Latest?ParentId="+dto.EncodeLibraryID(1)), &items) - Expect(names(items)).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) - }) - - It("scopes to ParentId when it names an artist", func() { - var items []dto.BaseItemDto - parseInto(get("/Items/Latest?ParentId="+enc(artistID("The Beatles"))), &items) - Expect(names(items)).To(ConsistOf("Abbey Road", "Help!")) - }) - - It("returns nothing for a library the user cannot access", func() { - var items []dto.BaseItemDto - parseInto(get("/Items/Latest?ParentId="+dto.EncodeLibraryID(99)), &items) - Expect(items).To(BeEmpty()) - }) - - It("returns nothing for an id that is neither a library nor an artist", func() { - var items []dto.BaseItemDto - parseInto(get("/Items/Latest?ParentId="+enc(testID("does-not-exist"))), &items) - Expect(items).To(BeEmpty()) - }) - - It("404s a malformed ParentId, like every other filtered endpoint", func() { - Expect(get("/Items/Latest?ParentId=not-a-valid-id").Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("GET /Artists and /Genres", func() { - It("lists album artists only on /Artists/AlbumArtists (excludes performer-only artists)", func() { - names := names(queryResult(get("/Artists/AlbumArtists")).Items) - Expect(names).To(ConsistOf("The Beatles", "Led Zeppelin", "Miles Davis", "Solo Artist")) - Expect(names).ToNot(ContainElement("Featured Guest")) - }) - - It("lists performing artists on /Artists (includes a track's guest artist)", func() { - names := names(queryResult(get("/Artists")).Items) - Expect(names).To(ContainElement("Featured Guest")) - Expect(names).To(ContainElement("Solo Artist")) - }) - - It("returns different lists for album artists and performing artists", func() { - aa := names(queryResult(get("/Artists/AlbumArtists")).Items) - ar := names(queryResult(get("/Artists")).Items) - Expect(aa).ToNot(Equal(ar)) - }) - - It("lists genres", func() { - q := queryResult(get("/Genres")) - Expect(names(q.Items)).To(ConsistOf("Rock", "Jazz", "Pop")) - }) - - It("pages genres with StartIndex/Limit and still reports the full total", func() { - q := queryResult(get("/Genres?StartIndex=1&Limit=1")) - Expect(q.Items).To(HaveLen(1)) - Expect(q.TotalRecordCount).To(Equal(3)) - }) - - It("returns record labels as Studio items", func() { - q := queryResult(get("/Studios")) - names := make([]string, 0, len(q.Items)) - for _, it := range q.Items { - Expect(it.Type).To(Equal("Studio")) - names = append(names, it.Name) - } - Expect(names).To(ContainElement("Columbia")) - }) - }) - - Describe("GET /Items/Filters", func() { - It("returns legacy query filters with genres, years, and empty tags/ratings", func() { - var filters dto.QueryFiltersLegacy - parseInto(get("/Items/Filters?IncludeItemTypes=Audio&Recursive=true"), &filters) - Expect(filters.Genres).To(ContainElements("Rock", "Jazz")) - Expect(filters.Years).To(ContainElements(1959, 1965, 1969, 1971)) - // Verify ascending sort by checking it equals itself sorted. - sorted := make([]int, len(filters.Years)) - copy(sorted, filters.Years) - sort.Ints(sorted) - Expect(filters.Years).To(Equal(sorted)) - Expect(filters.Tags).To(BeEmpty()) - Expect(filters.OfficialRatings).To(BeEmpty()) - }) - }) -}) diff --git a/server/jellyfin/e2e/discovery_test.go b/server/jellyfin/e2e/discovery_test.go deleted file mode 100644 index 0126ef3c6..000000000 --- a/server/jellyfin/e2e/discovery_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package e2e - -import ( - "context" - "encoding/json" - "net" - "time" - - "github.com/navidrome/navidrome/server/jellyfin" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Auto discovery", func() { - BeforeEach(func() { setupTestDB() }) - - It("advertises the same Id and Name as /System/Info/Public", func() { - server, err := net.ListenPacket("udp4", "127.0.0.1:0") - Expect(err).ToNot(HaveOccurred()) - client, err := net.ListenPacket("udp4", "127.0.0.1:0") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(client.Close) - - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan struct{}) - go func() { - defer close(done) - jellyfin.NewDiscovery(ds).ServeOn(ctx, server) - }() - DeferCleanup(func() { - cancel() - Eventually(done).Should(BeClosed()) - }) - - _, err = client.WriteTo([]byte("who is JellyfinServer?"), server.LocalAddr()) - Expect(err).ToNot(HaveOccurred()) - Expect(client.SetReadDeadline(time.Now().Add(time.Second))).To(Succeed()) - buf := make([]byte, 1024) - n, _, err := client.ReadFrom(buf) - Expect(err).ToNot(HaveOccurred()) - var reply map[string]any - Expect(json.Unmarshal(buf[:n], &reply)).To(Succeed()) - - var pub map[string]any - parseInto(rawReq("GET", "/System/Info/Public", ""), &pub) - Expect(reply["Id"]).To(Equal(pub["Id"])) - Expect(reply["Name"]).To(Equal(pub["ServerName"])) - Expect(reply["Address"]).To(HaveSuffix("/jellyfin")) - }) -}) diff --git a/server/jellyfin/e2e/e2e_suite_test.go b/server/jellyfin/e2e/e2e_suite_test.go deleted file mode 100644 index 4f3cd82b5..000000000 --- a/server/jellyfin/e2e/e2e_suite_test.go +++ /dev/null @@ -1,437 +0,0 @@ -// Package e2e provides end-to-end integration tests for the Navidrome Jellyfin API. -// -// These tests exercise the full HTTP request/response cycle through the Jellyfin API router, -// using a real SQLite database and real repository implementations while stubbing out external -// services (artwork, streaming, transcoding) with spy/noop implementations. -// -// The harness mirrors server/subsonic/e2e (the Subsonic suite): BeforeSuite creates a temporary SQLite -// database, seeds two users (admin + regular) and one library backed by a fake in-memory -// filesystem, runs the scanner, and snapshots the golden DB. Each top-level Describe restores -// that snapshot and builds a fresh jellyfin.Router. -// -// # Seeded library (see buildTestFS) -// -// Rock/The Beatles/Abbey Road/ 01 Something (1969), 02 Come Together (1969) -// Rock/The Beatles/Help!/ 01 Help! (1965) -// Rock/Led Zeppelin/IV/ 01 Stairway To Heaven (1971) -// Jazz/Miles Davis/Kind of Blue/01 So What (1959) -// Pop/Solo Artist/Singles/ 01 Standalone Track (2020), 02 Duet (artist "Featured Guest") -// -// Totals: 7 songs, 5 albums, 4 album artists (+ 1 performer-only "Featured Guest" = 5 artists), -// 3 genres (Rock=4, Jazz=1, Pop=2). -package e2e - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "testing/fstest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/lyrics" - "github.com/navidrome/navidrome/core/matcher" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/quickconnect" - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/core/sonic" - "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/jellyfin" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/tests/harness" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestJellyfinE2E(t *testing.T) { - tests.Init(t, false) - defer db.Close(t.Context()) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Jellyfin API E2E Suite") -} - -// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same -// id shape production uses. -func testID(label string) string { return id.NewHash("jellyfin-test", label) } - -// Easy aliases for the storagetest package -type _t = map[string]any - -var ( - template = storagetest.Template - track = storagetest.Track -) - -// Shared test state -var ( - ctx context.Context - ds *tests.MockDataStore - router http.Handler - streamerSpy *harness.SpyStreamer - artworkSpy *spyArtwork - providerFake *fakeExternalProvider - sonicProviderFake *fakeSonicProvider - goldenDB *harness.DB - dataFolder string - - adminUser = model.User{ - ID: testID("admin-1"), - UserName: "admin", - Name: "Admin User", - IsAdmin: true, - } - - regularUser = model.User{ - ID: testID("regular-1"), - UserName: "regular", - Name: "Regular User", - IsAdmin: false, - } -) - -// buildTestFS creates the seeded test filesystem (see package doc for totals). -func buildTestFS() storagetest.FakeFS { - abbeyRoad := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Abbey Road", "year": 1969, "genre": "Rock"}) - help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"}) - ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) - kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz", "label": "Columbia"}) - singles := template(_t{"albumartist": "Solo Artist", "artist": "Solo Artist", "album": "Singles", "year": 2020, "genre": "Pop"}) - - return harness.CreateFS(fstest.MapFS{ - // Track numbers are deliberately reversed vs. alphabetical title order (Something=1, - // Come Together=2) so tests can tell track-order sorting apart from title sorting. - "Rock/The Beatles/Abbey Road/01 - Something.mp3": abbeyRoad(track(1, "Something")), - "Rock/The Beatles/Abbey Road/02 - Come Together.mp3": abbeyRoad(track(2, "Come Together")), - "Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")), - "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven", _t{ - "lyrics:eng": "[00:01.00]There's a lady who's sure\n[00:05.50]All that glitters is gold", - "replaygain_track_gain": "-3.50 dB", - "replaygain_album_gain": "-4.25 dB", - })), - "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), - "Pop/Solo Artist/Singles/01 - Standalone Track.mp3": singles(track(1, "Standalone Track")), - // "Featured Guest" is the track artist here (album artist stays "Solo Artist"), so it's a - // performer but not an album artist — lets tests tell /Artists from /Artists/AlbumArtists. - "Pop/Solo Artist/Singles/02 - Duet.mp3": singles(track(2, "Duet", _t{"artist": "Featured Guest"})), - }) -} - -// --- Request helpers --- - -// jReq performs a full HTTP round-trip as the given user (token auth) and returns the recorder. -func jReq(user model.User, method, path, body string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - var reader io.Reader - if body != "" { - reader = strings.NewReader(body) - } - r := httptest.NewRequest(method, path, reader) - token, err := auth.CreateToken(&user) - Expect(err).ToNot(HaveOccurred()) - r.Header.Set("X-Emby-Token", token) - r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`) - if body != "" { - r.Header.Set("Content-Type", "application/json") - } - router.ServeHTTP(w, r) - return w -} - -// rawReq performs a request with no authentication (for public routes). -func rawReq(method, path, body string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - var reader io.Reader - if body != "" { - reader = strings.NewReader(body) - } - r := httptest.NewRequest(method, path, reader) - if body != "" { - r.Header.Set("Content-Type", "application/json") - } - router.ServeHTTP(w, r) - return w -} - -func get(path string) *httptest.ResponseRecorder { return jReq(adminUser, "GET", path, "") } -func getAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "GET", path, "") } -func post(path, body string) *httptest.ResponseRecorder { return jReq(adminUser, "POST", path, body) } -func postAs(u model.User, path, body string) *httptest.ResponseRecorder { - return jReq(u, "POST", path, body) -} -func del(path string) *httptest.ResponseRecorder { return jReq(adminUser, "DELETE", path, "") } -func delAs(u model.User, path string) *httptest.ResponseRecorder { return jReq(u, "DELETE", path, "") } - -// upload performs an authenticated POST with a custom Content-Type and raw body (image upload). -func upload(user model.User, path, contentType string, body []byte) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", path, bytes.NewReader(body)) - token, err := auth.CreateToken(&user) - Expect(err).ToNot(HaveOccurred()) - r.Header.Set("X-Emby-Token", token) - r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="e2e", Device="test", DeviceId="e2e-device", Version="1.0"`) - r.Header.Set("Content-Type", contentType) - router.ServeHTTP(w, r) - return w -} - -// parseInto asserts a 200 and unmarshals the JSON body into target. -func parseInto(w *httptest.ResponseRecorder, target any) { - Expect(w.Code).To(Equal(http.StatusOK), "body: %s", w.Body.String()) - Expect(json.Unmarshal(w.Body.Bytes(), target)).To(Succeed()) -} - -// queryResult asserts a 200 and returns the parsed QueryResult. -func queryResult(w *httptest.ResponseRecorder) dto.QueryResult { - var q dto.QueryResult - parseInto(w, &q) - return q -} - -// createPlaylist creates a playlist as admin (encodedIds are the Jellyfin-encoded item ids a -// client would send) and returns its decoded Navidrome id. -func createPlaylist(name string, encodedIds []string) string { - return createPlaylistAs(adminUser, name, encodedIds...) -} - -// createPlaylistAs creates a playlist owned by the given user and returns its decoded id. -func createPlaylistAs(user model.User, name string, encodedIds ...string) string { - if encodedIds == nil { - encodedIds = []string{} - } - body, err := json.Marshal(map[string]any{"Name": name, "Ids": encodedIds}) - Expect(err).ToNot(HaveOccurred()) - return createPlaylistBodyAs(user, string(body)) -} - -// createPlaylistBodyAs posts a raw create body, for tests that need fields the helpers above don't -// build, and returns the new playlist's decoded id. -func createPlaylistBodyAs(user model.User, body string) string { - var res map[string]string - parseInto(postAs(user, "/Playlists", body), &res) - Expect(res["Id"]).ToNot(BeEmpty()) - id, ok := dto.DecodeID(res["Id"]) - Expect(ok).To(BeTrue()) - return id -} - -// --- Seeded-id lookup helpers (return Navidrome ids; wrap with enc() for URLs) --- - -func enc(id string) string { return dto.EncodeID(id) } - -// The seeded library is tiny, so the id lookups fetch-all and match by name in Go rather than -// guessing repository filter column names. - -func albumID(name string) string { - albums, err := ds.Album().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - for _, a := range albums { - if a.Name == name { - return a.ID - } - } - Fail("album not found: " + name) - return "" -} - -func songID(title string) string { - mfs, err := ds.MediaFile().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - for _, mf := range mfs { - if mf.Title == title { - return mf.ID - } - } - Fail("song not found: " + title) - return "" -} - -func artistID(name string) string { - artists, err := ds.Artist().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - for _, a := range artists { - if a.Name == name { - return a.ID - } - } - Fail("artist not found: " + name) - return "" -} - -func genreID(name string) string { - genres, err := ds.Genre().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - for _, g := range genres { - if g.Name == name { - return g.ID - } - } - Fail("genre not found: " + name) - return "" -} - -// --- Suite lifecycle --- - -var _ = BeforeSuite(func() { - ctx = request.WithUser(GinkgoT().Context(), adminUser) - dataFolder = filepath.Join(GinkgoT().TempDir(), "data") - Expect(os.MkdirAll(dataFolder, 0o755)).To(Succeed()) - - conf.Server.MusicFolder = "fake:///music" - conf.Server.DataFolder = conf.NewDir(dataFolder) - conf.Server.DevExternalScanner = false - - buildTestFS() - goldenDB = harness.SetupDB(ctx, &adminUser, ®ularUser) - ctx = request.WithUser(GinkgoT().Context(), adminUser) -}) - -var _ = AfterSuite(func() { - db.Close(ctx) -}) - -// setupTestDB restores the golden snapshot and builds a fresh jellyfin.Router. Call from -// BeforeEach in each test container. -func setupTestDB() { - ctx = request.WithUser(GinkgoT().Context(), adminUser) - - DeferCleanup(configtest.SetupConfig()) - conf.Server.MusicFolder = "fake:///music" - conf.Server.DataFolder = conf.NewDir(dataFolder) - conf.Server.DevExternalScanner = false - conf.Server.DevEnableMediaFileProbe = false - - goldenDB.Restore() - - ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - auth.Init(ds) - - streamerSpy = &harness.SpyStreamer{} - artworkSpy = &spyArtwork{} - providerFake = &fakeExternalProvider{} - sonicProviderFake = &fakeSonicProvider{} - sonicSvc := sonic.New(ds, &fakeSonicLoader{provider: sonicProviderFake}, matcher.New(ds)) - decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) - router = jellyfin.New( - ds, - artworkSpy, - streamerSpy, - decider, - core.NewPlayers(ds), - scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), - providerFake, - sonicSvc, - lyrics.NewLyrics(ds, nil), - events.NoopBroker(), - quickconnect.New(), - ) -} - -// fakeExternalProvider is a configurable stand-in for external.Provider. Tests set the return -// values they need; unset fields yield empty similar lists. Only the methods the Jellyfin API uses -// are overridden — the embedded interface panics for anything else, flagging unexpected calls. -type fakeExternalProvider struct { - external.Provider - similarArtists model.Artists - similarSongs model.MediaFiles -} - -func (f *fakeExternalProvider) UpdateArtistInfo(_ context.Context, id string, _ int, _ bool) (*model.Artist, error) { - return &model.Artist{ID: id, SimilarArtists: f.similarArtists}, nil -} - -func (f *fakeExternalProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { - return f.similarSongs, nil -} - -// fakeSonicLoader always advertises a SonicSimilarity provider so the AudioMuse endpoints are -// active in e2e; the provider it hands back returns test-configured results. -type fakeSonicLoader struct{ provider sonic.Provider } - -func (f *fakeSonicLoader) PluginNames(capability string) []string { - if capability == "SonicSimilarity" { - return []string{"fake"} - } - return nil -} - -func (f *fakeSonicLoader) LoadSonicSimilarity(string) (sonic.Provider, bool) { - return f.provider, true -} - -// fakeSonicProvider is a configurable stand-in for a sonic-similarity plugin. Tests set the -// agents.Song results; the real matcher resolves them back to seeded library tracks. -type fakeSonicProvider struct { - similar []sonic.SimilarResult - path []sonic.SimilarResult -} - -func (f *fakeSonicProvider) GetSonicSimilarTracks(context.Context, *model.MediaFile, int) ([]sonic.SimilarResult, error) { - return f.similar, nil -} - -func (f *fakeSonicProvider) FindSonicPath(context.Context, *model.MediaFile, *model.MediaFile, int) ([]sonic.SimilarResult, error) { - return f.path, nil -} - -// songAgent looks a seeded track up by title (titles are unique in the seed) and builds an -// agents.Song carrying its title+artist, so the matcher resolves it back to that MediaFile. -func songAgent(title string) agents.Song { - mfs, err := ds.MediaFile().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - for _, mf := range mfs { - if mf.Title == title { - return agents.Song{Name: mf.Title, Artists: []agents.Artist{{Name: mf.Artist}}} - } - } - Fail("song not found: " + title) - return agents.Song{} -} - -// --- Spy/noop dependencies (shared ones live in tests/harness) --- - -// spyArtwork captures the id and context passed to GetOrPlaceholder so image tests can assert the -// resolved ArtworkID and that resolution runs under an elevated (admin) context. -type spyArtwork struct { - lastID string - lastCtx context.Context - data []byte -} - -func (s *spyArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { - return nil, model.ErrNotFound -} - -func (s *spyArtwork) GetOrPlaceholder(c context.Context, id string, _ int, _ bool) (*artwork.Image, error) { - s.lastID = id - s.lastCtx = c - d := s.data - if d == nil { - d = []byte("IMG") - } - return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(d))}, nil -} - -var _ artwork.Artwork = &spyArtwork{} diff --git a/server/jellyfin/e2e/images_test.go b/server/jellyfin/e2e/images_test.go deleted file mode 100644 index d1c3c132b..000000000 --- a/server/jellyfin/e2e/images_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package e2e - -import ( - "net/http" - - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// The image endpoint is public and resolves artwork under an elevated (admin) context. The suite -// wires a spyArtwork that captures the resolved ArtworkID and the context, so these tests assert -// resolution and elevation without needing real image processing. -var _ = Describe("Item images", func() { - BeforeEach(func() { setupTestDB() }) - - It("resolves an album's Primary image", func() { - id := albumID("Abbey Road") - w := get("/Items/" + enc(id) + "/Images/Primary") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(Equal("IMG")) - Expect(artworkSpy.lastID).To(ContainSubstring(id)) - }) - - It("resolves an artist's Primary image", func() { - id := artistID("Miles Davis") - Expect(get("/Items/" + enc(id) + "/Images/Primary").Code).To(Equal(http.StatusOK)) - Expect(artworkSpy.lastID).To(ContainSubstring(id)) - }) - - It("resolves a private playlist's cover under an elevated context", func() { - // The route carries no user in ctx (public); resolution runs elevated so the visibility - // filter doesn't eat the cover. - plID := createPlaylist("Private Mix", nil) - w := get("/Items/" + enc(plID) + "/Images/Primary") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artworkSpy.lastID).To(ContainSubstring(plID)) - - u, ok := request.UserFrom(artworkSpy.lastCtx) - Expect(ok).To(BeTrue()) - Expect(u.IsAdmin).To(BeTrue()) - }) - - It("answers HEAD without authentication", func() { - Expect(rawReq("HEAD", "/Items/"+enc(albumID("Abbey Road"))+"/Images/Primary", "").Code).To(Equal(http.StatusOK)) - }) - - It("serves images without authentication (public route)", func() { - id := albumID("IV") - w := rawReq("GET", "/Items/"+enc(id)+"/Images/Primary", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(Equal("IMG")) - }) - - // Covers are served regardless of playlist visibility — see getItemImage for the rationale. - It("resolves a private playlist's cover for an unauthenticated caller", func() { - plID := createPlaylist("Secret Mix", nil) // owned by admin, private - w := rawReq("GET", "/Items/"+enc(plID)+"/Images/Primary", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(artworkSpy.lastID).To(ContainSubstring(plID)) - }) -}) diff --git a/server/jellyfin/e2e/lyrics_test.go b/server/jellyfin/e2e/lyrics_test.go deleted file mode 100644 index d52a3b00f..000000000 --- a/server/jellyfin/e2e/lyrics_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package e2e - -import ( - "net/http" - - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Lyrics", func() { - BeforeEach(func() { setupTestDB() }) - - Describe("PlaybackInfo", func() { - It("advertises a Lyric stream for a track with embedded lyrics", func() { - id := songID("Stairway To Heaven") - var info dto.PlaybackInfoResponse - parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) - var found bool - for _, s := range info.MediaSources[0].MediaStreams { - if s.Type == "Lyric" { - found = true - } - } - Expect(found).To(BeTrue()) - }) - - It("does not advertise a Lyric stream for a track without lyrics", func() { - id := songID("So What") - var info dto.PlaybackInfoResponse - parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) - for _, s := range info.MediaSources[0].MediaStreams { - Expect(s.Type).ToNot(Equal("Lyric")) - } - }) - }) - - Describe("GET /Audio/{id}/Lyrics", func() { - It("returns the LyricDto for a track with embedded synced lyrics", func() { - id := songID("Stairway To Heaven") - var lyrics dto.LyricDto - parseInto(get("/Audio/"+enc(id)+"/Lyrics"), &lyrics) - Expect(lyrics.Lyrics).To(HaveLen(2)) - Expect(lyrics.Lyrics[0].Text).To(Equal("There's a lady who's sure")) - Expect(lyrics.Lyrics[0].Start).ToNot(BeNil()) - Expect(*lyrics.Lyrics[0].Start).To(Equal(int64(10000000))) - Expect(lyrics.Metadata.IsSynced).To(BeTrue()) - }) - - It("returns 404 for a track without lyrics", func() { - id := songID("So What") - Expect(get("/Audio/" + enc(id) + "/Lyrics").Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 for a fabricated id", func() { - Expect(get("/Audio/" + enc(testID("nope")) + "/Lyrics").Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("HasLyrics badge", func() { - It("is true for a track with embedded lyrics and false otherwise", func() { - var stairway dto.BaseItemDto - parseInto(get("/Items/"+enc(songID("Stairway To Heaven"))), &stairway) - Expect(*stairway.HasLyrics).To(BeTrue()) - - var soWhat dto.BaseItemDto - parseInto(get("/Items/"+enc(songID("So What"))), &soWhat) - Expect(*soWhat.HasLyrics).To(BeFalse()) - }) - }) -}) diff --git a/server/jellyfin/e2e/multiuser_test.go b/server/jellyfin/e2e/multiuser_test.go deleted file mode 100644 index 905d4e16f..000000000 --- a/server/jellyfin/e2e/multiuser_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package e2e - -import ( - "net/http" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Multi-user access control", func() { - BeforeEach(func() { setupTestDB() }) - - Describe("library scoping", func() { - It("lets a library member browse its content", func() { - q := queryResult(getAs(regularUser, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(5)) - }) - - It("hides all content from a user with no library access", func() { - noAccess := model.User{ID: testID("noaccess-1"), UserName: "noaccess", Name: "No Access", NewPassword: "password"} - Expect(ds.User().Put(ctx, &noAccess)).To(Succeed()) - loaded, err := ds.User().FindByUsername(ctx, "noaccess") - Expect(err).ToNot(HaveOccurred()) - - q := queryResult(getAs(*loaded, "/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(0)) - }) - }) - - Describe("private playlists", func() { - It("does not expose another user's private playlist", func() { - adminPl := createPlaylist("Admin Private", nil) - - // Owner sees it. - Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) - // A different user does not. - Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0)) - // And can't read its items. - Expect(getAs(regularUser, "/Playlists/"+enc(adminPl)+"/Items").Code).To(Equal(http.StatusNotFound)) - }) - - It("does not let a non-owner delete another user's private playlist", func() { - adminPl := createPlaylist("Admin Private", nil) - // The playlist is invisible to the regular user, so delete resolves to 404 (not 403) — - // the API never reveals that someone else's private playlist exists. - Expect(delAs(regularUser, "/Items/"+enc(adminPl)).Code).To(Equal(http.StatusNotFound)) - // Still present for the owner. - Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) - }) - - It("does not let a non-owner annotate another user's private playlist", func() { - adminPl := createPlaylist("Admin Private", nil) - Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound)) - Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound)) - }) - - It("lets each user manage their own playlist", func() { - regularPl := createPlaylistAs(regularUser, "Regular's Mix") - Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) - Expect(delAs(regularUser, "/Items/"+enc(regularPl)).Code).To(Equal(http.StatusNoContent)) - }) - }) -}) diff --git a/server/jellyfin/e2e/playlists_test.go b/server/jellyfin/e2e/playlists_test.go deleted file mode 100644 index 49c8e5a8c..000000000 --- a/server/jellyfin/e2e/playlists_test.go +++ /dev/null @@ -1,450 +0,0 @@ -package e2e - -import ( - "bytes" - "image" - jpeglib "image/jpeg" - "net/http" - "os" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Playlists", func() { - BeforeEach(func() { setupTestDB() }) - - playlistItems := func(plID string) dto.QueryResult { - return queryResult(get("/Playlists/" + enc(plID) + "/Items")) - } - order := func(plID string) []string { return names(playlistItems(plID).Items) } - - createWith := func(body string) string { return createPlaylistBodyAs(adminUser, body) } - openAccess := func(plID string) bool { - var info dto.PlaylistInfo - parseInto(get("/Playlists/"+enc(plID)), &info) - return info.OpenAccess - } - - Describe("create", func() { - It("creates an empty playlist", func() { - plID := createPlaylist("Empty", nil) - var info dto.PlaylistInfo - parseInto(get("/Playlists/"+enc(plID)), &info) - Expect(info.OpenAccess).To(BeFalse(), "a playlist created without IsPublic stays private") - Expect(info.Shares).To(BeEmpty()) - Expect(info.ItemIds).To(BeEmpty()) - }) - - It("creates a playlist from song ids", func() { - plID := createPlaylist("Songs", []string{enc(songID("Come Together")), enc(songID("So What"))}) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(2)) - }) - - It("expands an album id into its tracks", func() { - plID := createPlaylist("From Album", []string{enc(albumID("Abbey Road"))}) - q := playlistItems(plID) - Expect(q.TotalRecordCount).To(Equal(2)) - Expect(names(q.Items)).To(ConsistOf("Come Together", "Something")) - }) - - It("expands an artist id into its tracks", func() { - plID := createPlaylist("From Artist", []string{enc(artistID("The Beatles"))}) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // Abbey Road (2) + Help! (1) - }) - - It("creates a public playlist when the client sends IsPublic true", func() { - Expect(openAccess(createWith(`{"Name":"Public","Ids":[],"IsPublic":true}`))).To(BeTrue()) - }) - - It("creates a private playlist when the client sends IsPublic false", func() { - Expect(openAccess(createWith(`{"Name":"Private","Ids":[],"IsPublic":false}`))).To(BeFalse()) - }) - - // dto.DecodeIDs is all-or-nothing: a malformed entry must 404 the whole request, not get - // dropped while the well-formed entries are still used to create a playlist. - It("404s when one of the Ids is malformed, without creating a playlist", func() { - before, err := ds.Playlist().CountAll(ctx) - Expect(err).ToNot(HaveOccurred()) - - body := `{"Name":"ShouldNotExist","Ids":["` + enc(songID("So What")) + `","not-a-valid-id"]}` - Expect(post("/Playlists", body).Code).To(Equal(http.StatusNotFound)) - - after, err := ds.Playlist().CountAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(after).To(Equal(before)) - }) - }) - - Describe("items", func() { - It("tags each entry with a PlaylistItemId", func() { - plID := createPlaylist("Tagged", []string{enc(songID("Help!"))}) - q := playlistItems(plID) - Expect(q.Items).To(HaveLen(1)) - Expect(q.Items[0].Type).To(Equal("Audio")) - Expect(q.Items[0].PlaylistItemId).ToNot(BeEmpty()) - }) - }) - - Describe("add and remove", func() { - It("adds a song by id", func() { - plID := createPlaylist("Add", nil) - Expect(post("/Playlists/"+enc(plID)+"/Items?ids="+enc(songID("So What")), "").Code).To(Equal(http.StatusNoContent)) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) - }) - - It("adds an album (expanding to its tracks)", func() { - plID := createPlaylist("AddAlbum", []string{enc(songID("So What"))}) - post("/Playlists/"+enc(plID)+"/Items?ids="+enc(albumID("Abbey Road")), "") - Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) // 1 + Abbey Road (2) - }) - - // Jellify's @jellyfin/sdk serializes id arrays as repeated params (ids=X&ids=Y), not a - // comma-joined value; all ids must be added, not just the first. - It("adds multiple songs sent as repeated ids params", func() { - plID := createPlaylist("Multi", nil) - url := "/Playlists/" + enc(plID) + "/Items?ids=" + enc(songID("So What")) + - "&ids=" + enc(songID("Come Together")) + "&ids=" + enc(songID("Help!")) - Expect(post(url, "").Code).To(Equal(http.StatusNoContent)) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(3)) - }) - - It("404s when one of the ids to add is malformed, without adding any track", func() { - plID := createPlaylist("AddMalformed", nil) - url := "/Playlists/" + enc(plID) + "/Items?ids=" + enc(songID("So What")) + ",not-a-valid-id" - Expect(post(url, "").Code).To(Equal(http.StatusNotFound)) - Expect(playlistItems(plID).TotalRecordCount).To(BeZero()) - }) - - Describe("at a position", func() { - var plID string - addAt := func(position string, ids ...string) { - url := "/Playlists/" + enc(plID) + "/Items?position=" + position - for _, id := range ids { - url += "&ids=" + enc(id) - } - Expect(post(url, "").Code).To(Equal(http.StatusNoContent)) - } - BeforeEach(func() { - plID = createPlaylist("Insert", []string{enc(songID("Come Together")), enc(songID("Something"))}) - }) - - DescribeTable("inserts at Jellyfin's zero-based position", - func(position string, want []string) { - addAt(position, songID("So What"), songID("Help!")) - Expect(order(plID)).To(Equal(want)) - }, - Entry("in the middle", "1", []string{"Come Together", "So What", "Help!", "Something"}), - Entry("first, for zero or less", "-3", []string{"So What", "Help!", "Come Together", "Something"}), - Entry("last, past the end", "9", []string{"Come Together", "Something", "So What", "Help!"}), - ) - - It("keeps an expanded album's track order", func() { - albumOrder := order(createPlaylist("Album", []string{enc(albumID("Abbey Road"))})) - addAt("1", albumID("Abbey Road")) - Expect(order(plID)).To(Equal(append(append([]string{"Come Together"}, albumOrder...), "Something"))) - }) - }) - - It("removes an entry by its PlaylistItemId", func() { - plID := createPlaylist("Remove", []string{enc(songID("Come Together")), enc(songID("Something"))}) - entryID := playlistItems(plID).Items[0].PlaylistItemId - Expect(del("/Playlists/" + enc(plID) + "/Items?entryIds=" + entryID).Code).To(Equal(http.StatusNoContent)) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) - }) - - It("removes multiple entries sent as repeated entryIds params", func() { - plID := createPlaylist("MultiRemove", []string{enc(songID("Come Together")), enc(songID("Something")), enc(songID("So What"))}) - items := playlistItems(plID).Items - url := "/Playlists/" + enc(plID) + "/Items?entryIds=" + items[0].PlaylistItemId + "&entryIds=" + items[1].PlaylistItemId - Expect(del(url).Code).To(Equal(http.StatusNoContent)) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(1)) - }) - }) - - Describe("move", func() { - move := func(u model.User, plID, entryID string, newIndex string) int { - return jReq(u, "POST", "/Playlists/"+enc(plID)+"/Items/"+entryID+"/Move/"+newIndex, "").Code - } - var plID string - var entries []dto.BaseItemDto - BeforeEach(func() { - plID = createPlaylist("Move", []string{enc(songID("Come Together")), enc(songID("Something")), enc(songID("So What"))}) - entries = playlistItems(plID).Items - }) - - DescribeTable("moves an entry to Jellyfin's zero-based index", - func(entry int, newIndex string, want []string) { - Expect(move(adminUser, plID, entries[entry].PlaylistItemId, newIndex)).To(Equal(http.StatusNoContent)) - Expect(order(plID)).To(Equal(want)) - var positions []string - for _, it := range playlistItems(plID).Items { - positions = append(positions, it.PlaylistItemId) - } - Expect(positions).To(Equal([]string{dto.EncodePlaylistEntryID("1"), dto.EncodePlaylistEntryID("2"), dto.EncodePlaylistEntryID("3")})) - }, - Entry("towards the end", 0, "2", []string{"Something", "So What", "Come Together"}), - Entry("towards the start", 2, "0", []string{"So What", "Come Together", "Something"}), - Entry("to the end, past the last index", 0, "99", []string{"Something", "So What", "Come Together"}), - Entry("to the end, for the largest int", 0, "9223372036854775807", []string{"Something", "So What", "Come Together"}), - ) - - It("ignores an entry that is not in the playlist", func() { - Expect(move(adminUser, plID, dto.EncodePlaylistEntryID("42"), "0")).To(Equal(http.StatusNoContent)) - Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"})) - }) - - It("404s on a malformed entry id", func() { - Expect(move(adminUser, plID, enc(songID("So What")), "0")).To(Equal(http.StatusNotFound)) - }) - - It("hides another user's private playlist", func() { - Expect(move(regularUser, plID, entries[0].PlaylistItemId, "2")).To(Equal(http.StatusNotFound)) - Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"})) - }) - - It("forbids a non-owner on a public playlist", func() { - Expect(post("/Playlists/"+enc(plID), `{"Name":"Move","IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) - Expect(move(regularUser, plID, entries[0].PlaylistItemId, "2")).To(Equal(http.StatusForbidden)) - Expect(order(plID)).To(Equal([]string{"Come Together", "Something", "So What"})) - }) - - It("rejects a negative index", func() { - Expect(move(adminUser, plID, entries[0].PlaylistItemId, "-1")).To(Equal(http.StatusBadRequest)) - }) - }) - - Describe("users", func() { - It("reports the current user as an editor", func() { - plID := createPlaylist("Perms", nil) - var perms []dto.PlaylistUserPermissions - parseInto(get("/Playlists/"+enc(plID)+"/Users"), &perms) - Expect(perms).To(HaveLen(1)) - Expect(perms[0].UserId).To(Equal(enc(testID("admin-1")))) - Expect(perms[0].CanEdit).To(BeTrue()) - }) - }) - - Describe("listing", func() { - It("lists a created playlist advertising a Primary image tag", func() { - createPlaylist("Listed", nil) - q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(1)) - Expect(q.Items[0].Name).To(Equal("Listed")) - Expect(q.Items[0].ImageTags).To(HaveKey("Primary")) - }) - - It("sorts playlists by name when SortBy=SortName", func() { - createPlaylist("Charlie", nil) - createPlaylist("Alpha", nil) - createPlaylist("Bravo", nil) - q := queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true&SortBy=SortName")) - Expect(names(q.Items)).To(Equal([]string{"Alpha", "Bravo", "Charlie"})) - }) - }) - - // Jellify resolves the "playlists library" via a ManualPlaylistsFolder query, then lists - // playlists with ParentId set to that folder's id (no IncludeItemTypes). Without a folder item - // whose CollectionType is "playlists", its query resolves undefined and React Query retries in a - // backoff loop that stalls the home screen. - Describe("playlists library folder (ManualPlaylistsFolder)", func() { - It("returns a synthetic playlists folder with CollectionType=playlists", func() { - q := queryResult(get("/Items?includeItemTypes=ManualPlaylistsFolder&excludeItemTypes=CollectionFolder")) - Expect(q.Items).To(HaveLen(1)) - Expect(q.Items[0].CollectionType).To(Equal("playlists")) - Expect(q.Items[0].Id).To(Equal(dto.PlaylistsFolderGUID)) - }) - - It("lists the user's playlists when browsing the folder by ParentId (no IncludeItemTypes)", func() { - createPlaylist("My Mix", nil) - q := queryResult(get("/Items?parentId=" + dto.PlaylistsFolderGUID)) - Expect(names(q.Items)).To(ContainElement("My Mix")) - Expect(q.Items[0].Type).To(Equal("Playlist")) - // Jellify keeps only playlists whose Path contains "data". - Expect(q.Items[0].Path).To(ContainSubstring("data")) - }) - - It("resolves the synthetic playlists folder by its own advertised id", func() { - var item dto.BaseItemDto - parseInto(get("/Items/"+dto.PlaylistsFolderGUID), &item) - Expect(item.Type).To(Equal("ManualPlaylistsFolder")) - Expect(item.CollectionType).To(Equal("playlists")) - Expect(item.Id).To(Equal(dto.PlaylistsFolderGUID)) - }) - }) - - // Real Jellyfin returns a playlist's children for /Items?ParentId=<playlistId> with no - // IncludeItemTypes; generic clients (not Finamp/Jellify) browse playlists this way. - Describe("browsing a playlist via the generic /Items path", func() { - It("lists the playlist's tracks for a typeless ParentId query", func() { - plID := createPlaylist("Browse Me", []string{enc(songID("Come Together")), enc(songID("So What"))}) - q := queryResult(get("/Items?parentId=" + enc(plID))) - Expect(q.TotalRecordCount).To(Equal(2)) - Expect(names(q.Items)).To(ConsistOf("Come Together", "So What")) - Expect(q.Items[0].Type).To(Equal("Audio")) - }) - - It("pages the playlist's tracks", func() { - plID := createPlaylist("Browse Paged", []string{enc(songID("Come Together")), enc(songID("So What"))}) - q := queryResult(get("/Items?parentId=" + enc(plID) + "&startIndex=1&limit=1")) - Expect(q.Items).To(HaveLen(1)) - Expect(q.TotalRecordCount).To(Equal(2)) - }) - - // Jellify opens a playlist with ParentId=<playlist>&IncludeItemTypes=Audio&Recursive=false. - // The playlist id must resolve to its tracks, not be treated as an album id (which returns none). - It("lists the playlist's tracks even when IncludeItemTypes=Audio is set", func() { - plID := createPlaylist("Typed Browse", []string{enc(songID("Come Together")), enc(songID("So What"))}) - q := queryResult(get("/Items?parentId=" + enc(plID) + "&includeItemTypes=Audio&recursive=false")) - Expect(q.TotalRecordCount).To(Equal(2)) - Expect(names(q.Items)).To(ConsistOf("Come Together", "So What")) - }) - }) - - Describe("cover art", func() { - // A real (decodable) image: the upload endpoint validates by decoding, like the native one. - var jpeg []byte - BeforeEach(func() { - var buf bytes.Buffer - Expect(jpeglib.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) - jpeg = buf.Bytes() - }) - - It("uploads and removes a playlist cover", func() { - plID := createPlaylist("Cover", nil) - - Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code). - To(Equal(http.StatusNoContent)) - - pls, err := ds.Playlist().Get(ctx, plID) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.UploadedImage).ToNot(BeEmpty()) - _, statErr := os.Stat(pls.UploadedImagePath()) - Expect(statErr).ToNot(HaveOccurred(), "cover file should exist on disk") - - Expect(del("/Items/" + enc(plID) + "/Images/Primary").Code).To(Equal(http.StatusNoContent)) - pls, _ = ds.Playlist().Get(ctx, plID) - Expect(pls.UploadedImage).To(BeEmpty()) - }) - - It("rejects cover upload for a non-playlist item", func() { - Expect(upload(adminUser, "/Items/"+enc(albumID("IV"))+"/Images/Primary", "image/jpeg", jpeg).Code). - To(Equal(http.StatusNotImplemented)) - }) - - // An upload must clear the resolved artwork state, or clients keep serving the stale cover - // from their tag-keyed cache until the next scan. - It("clears the resolved image tag after a cover upload", func() { - plID := createPlaylist("Cover Tag", nil) - Expect(ds.Artwork().PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: model.KindPlaylistArtwork.Prefix(), ItemID: plID, Hash: "1111111111111111", - })).To(Succeed()) - - imageTag := func() string { - q := queryResult(get("/Items?ids=" + enc(plID))) - Expect(q.Items).To(HaveLen(1)) - return q.Items[0].ImageTags["Primary"] - } - Expect(imageTag()).To(Equal("1111111111111111")) - - Expect(upload(adminUser, "/Items/"+enc(plID)+"/Images/Primary", "image/jpeg", jpeg).Code). - To(Equal(http.StatusNoContent)) - - // The upload re-queues resolution instead of resolving inline, so the tag goes bare. - Expect(imageTag()).ToNot(Equal("1111111111111111")) - }) - }) - - Describe("update", func() { - It("404s when one of the replacement Ids is malformed, leaving the track list unchanged", func() { - plID := createPlaylist("UpdateMalformed", []string{enc(songID("Come Together"))}) - body := `{"Ids":["` + enc(songID("So What")) + `","not-a-valid-id"]}` - Expect(post("/Playlists/"+enc(plID), body).Code).To(Equal(http.StatusNotFound)) - q := playlistItems(plID) - Expect(q.TotalRecordCount).To(Equal(1)) - Expect(names(q.Items)).To(ConsistOf("Come Together")) - }) - - It("makes a playlist public", func() { - plID := createPlaylist("Make Public", nil) - Expect(post("/Playlists/"+enc(plID), `{"Name":"Make Public","IsPublic":true}`).Code).To(Equal(http.StatusNoContent)) - Expect(openAccess(plID)).To(BeTrue()) - // Now visible to other users. - Expect(queryResult(getAs(regularUser, "/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(1)) - }) - - It("renames a playlist", func() { - plID := createPlaylist("Old Name", nil) - Expect(post("/Playlists/"+enc(plID), `{"Name":"New Name"}`).Code).To(Equal(http.StatusNoContent)) - pls, _ := ds.Playlist().Get(ctx, plID) - Expect(pls.Name).To(Equal("New Name")) - }) - - It("replaces the track list when Ids are provided", func() { - plID := createPlaylist("Reorder", []string{enc(songID("Come Together")), enc(songID("Something"))}) - // Replace with a single different track. - Expect(post("/Playlists/"+enc(plID), `{"Ids":["`+enc(songID("So What"))+`"]}`).Code).To(Equal(http.StatusNoContent)) - q := playlistItems(plID) - Expect(q.TotalRecordCount).To(Equal(1)) - Expect(q.Items[0].Name).To(Equal("So What")) - }) - - It("clears the track list when an explicit empty Ids array is sent", func() { - plID := createPlaylist("Clear Me", []string{enc(songID("Come Together")), enc(songID("Something"))}) - Expect(post("/Playlists/"+enc(plID), `{"Ids":[]}`).Code).To(Equal(http.StatusNoContent)) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(0)) - }) - - It("leaves the track list intact when Ids is omitted (metadata-only update)", func() { - plID := createPlaylist("Keep Tracks", []string{enc(songID("Come Together")), enc(songID("Something"))}) - Expect(post("/Playlists/"+enc(plID), `{"Name":"Renamed"}`).Code).To(Equal(http.StatusNoContent)) - Expect(playlistItems(plID).TotalRecordCount).To(Equal(2)) - }) - - It("applies Name and IsPublic sent together with a track replacement", func() { - plID := createPlaylist("Combo", []string{enc(songID("Come Together"))}) - body := `{"Name":"Combo Renamed","IsPublic":true,"Ids":["` + enc(songID("So What")) + `"]}` - Expect(post("/Playlists/"+enc(plID), body).Code).To(Equal(http.StatusNoContent)) - q := playlistItems(plID) - Expect(q.TotalRecordCount).To(Equal(1)) - Expect(q.Items[0].Name).To(Equal("So What")) - pls, _ := ds.Playlist().Get(ctx, plID) - Expect(pls.Name).To(Equal("Combo Renamed")) - Expect(pls.Public).To(BeTrue()) - }) - - It("forbids a non-owner from updating a public playlist", func() { - plID := createPlaylist("Owned", nil) - post("/Playlists/"+enc(plID), `{"IsPublic":true}`) // make it visible to the regular user - Expect(postAs(regularUser, "/Playlists/"+enc(plID), `{"Name":"Hijacked"}`).Code).To(Equal(http.StatusForbidden)) - }) - - // An id that decodes to "" would tell Create to make a new playlist instead of updating one — - // itemIDParam must 404 before that decode ever runs, not silently create one. - It("404s for a malformed playlist id, without creating a playlist", func() { - before, err := ds.Playlist().CountAll(ctx) - Expect(err).ToNot(HaveOccurred()) - - w := post("/Playlists/00000000000000000000000000000000", `{"Ids":["`+enc(songID("So What"))+`"]}`) - Expect(w.Code).To(Equal(http.StatusNotFound)) - - after, err := ds.Playlist().CountAll(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(after).To(Equal(before)) - }) - }) - - Describe("delete", func() { - It("deletes a playlist", func() { - plID := createPlaylist("ToDelete", nil) - Expect(del("/Items/" + enc(plID)).Code).To(Equal(http.StatusNoContent)) - Expect(queryResult(get("/Items?IncludeItemTypes=Playlist&Recursive=true")).TotalRecordCount).To(Equal(0)) - }) - - It("returns 404 when deleting a non-playlist item", func() { - Expect(del("/Items/" + enc(albumID("IV"))).Code).To(Equal(http.StatusNotFound)) - }) - }) -}) diff --git a/server/jellyfin/e2e/quickconnect_test.go b/server/jellyfin/e2e/quickconnect_test.go deleted file mode 100644 index 1beffc56f..000000000 --- a/server/jellyfin/e2e/quickconnect_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package e2e - -import ( - "net/http" - "net/http/httptest" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("QuickConnect", func() { - BeforeEach(func() { - setupTestDB() - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.QuickConnect = true - }) - - clientReq := func(deviceID, method, path, body string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := httptest.NewRequest(method, path, strings.NewReader(body)) - r.Header.Set("Authorization", `MediaBrowser Client="Finamp", Device="Pixel 7", DeviceId="`+deviceID+`", Version="1.0"`) - r.Header.Set("Content-Type", "application/json") - router.ServeHTTP(w, r) - return w - } - initiate := func() dto.QuickConnectResult { - var pending dto.QuickConnectResult - parseInto(clientReq("new-device", "POST", "/QuickConnect/Initiate", ""), &pending) - Expect(pending.Authenticated).To(BeFalse()) - return pending - } - redeem := func(deviceID, secret string) *httptest.ResponseRecorder { - return clientReq(deviceID, "POST", "/Users/AuthenticateWithQuickConnect", `{"Secret":"`+secret+`"}`) - } - - It("signs a new client in with a code approved by a signed-in user", func() { - Expect(rawReq("GET", "/QuickConnect/Enabled", "").Body.String()).To(MatchJSON("true")) - pending := initiate() - - Expect(postAs(regularUser, "/QuickConnect/Authorize?Code="+pending.Code, "").Code).To(Equal(http.StatusOK)) - - var status dto.QuickConnectResult - parseInto(rawReq("GET", "/QuickConnect/Connect?Secret="+pending.Secret, ""), &status) - Expect(status.Authenticated).To(BeTrue()) - - // Android TV redeems with a different DeviceId than it initiated with. - w := redeem("other-device", pending.Secret) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.AuthenticationResult - parseInto(w, &res) - Expect(res.User.Name).To(Equal(regularUser.UserName)) - Expect(res.SessionInfo).ToNot(BeNil()) - Expect(res.SessionInfo.DeviceId).To(Equal("other-device")) - - r := httptest.NewRequest("GET", "/Users/Me", nil) - r.Header.Set("X-Emby-Token", res.AccessToken) - me := httptest.NewRecorder() - router.ServeHTTP(me, r) - Expect(me.Code).To(Equal(http.StatusOK)) - - Expect(redeem("new-device", pending.Secret).Code).To(Equal(http.StatusNotFound)) - }) - - It("lets an admin approve a code for another user", func() { - pending := initiate() - Expect(post("/QuickConnect/Authorize?Code="+pending.Code+"&UserId="+enc(regularUser.ID), "").Code). - To(Equal(http.StatusOK)) - - var res dto.AuthenticationResult - parseInto(redeem("new-device", pending.Secret), &res) - Expect(res.User.Name).To(Equal(regularUser.UserName)) - }) - - It("requires authentication to approve a code", func() { - pending := initiate() - Expect(rawReq("POST", "/QuickConnect/Authorize?Code="+pending.Code, "").Code).To(Equal(http.StatusUnauthorized)) - }) - - It("answers 401 on every Quick Connect call when disabled", func() { - conf.Server.Jellyfin.QuickConnect = false - Expect(rawReq("GET", "/QuickConnect/Enabled", "").Body.String()).To(MatchJSON("false")) - Expect(clientReq("new-device", "POST", "/QuickConnect/Initiate", "").Code).To(Equal(http.StatusUnauthorized)) - Expect(rawReq("GET", "/QuickConnect/Connect?Secret=x", "").Code).To(Equal(http.StatusUnauthorized)) - Expect(post("/QuickConnect/Authorize?Code=123456", "").Code).To(Equal(http.StatusUnauthorized)) - Expect(redeem("new-device", "x").Code).To(Equal(http.StatusUnauthorized)) - }) -}) diff --git a/server/jellyfin/e2e/routing_test.go b/server/jellyfin/e2e/routing_test.go deleted file mode 100644 index 49faad342..000000000 --- a/server/jellyfin/e2e/routing_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package e2e - -import ( - "net/http" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Routing", func() { - BeforeEach(func() { setupTestDB() }) - - It("routes authenticated endpoints case-insensitively", func() { - // Lowercase path variant of GET /Items — real clients (jellyfin-apiclient-python) send these. - lower := queryResult(get("/items?IncludeItemTypes=MusicAlbum&Recursive=true")) - Expect(lower.TotalRecordCount).To(Equal(5)) - }) - - It("returns a JSON 404 for an unknown route", func() { - w := get("/Nonexistent/Route") - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(w.Header().Get("Content-Type")).To(HavePrefix("application/json")) - Expect(w.Body.String()).To(ContainSubstring("{}")) - }) - - It("returns 404 for an unsupported method on a known path", func() { - // PUT isn't registered for /Items; the MethodNotAllowed handler maps to the same JSON 404. - w := jReq(adminUser, "PUT", "/Items", "") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) -}) diff --git a/server/jellyfin/e2e/search_test.go b/server/jellyfin/e2e/search_test.go deleted file mode 100644 index fa14f6811..000000000 --- a/server/jellyfin/e2e/search_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package e2e - -import ( - "fmt" - - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Search with a ParentId library scope is how Finamp drives its search screen. Artists are the -// tricky case: they have no library_id column, so the repo's Search does its own scope handling. -var _ = Describe("Search", func() { - BeforeEach(func() { setupTestDB() }) - - lib1 := func() string { return dto.EncodeLibraryID(1) } - - Describe("artists", func() { - It("searches all album artists", func() { - q := queryResult(get("/Artists/AlbumArtists?SearchTerm=Beatles")) - Expect(names(q.Items)).To(ConsistOf("The Beatles")) - }) - - It("searches album artists scoped to a library (ParentId)", func() { - q := queryResult(get("/Artists/AlbumArtists?ParentId=" + lib1() + "&SearchTerm=Beatles&Recursive=true&SortBy=SortName")) - Expect(names(q.Items)).To(ConsistOf("The Beatles")) - }) - - It("returns an empty result for a non-matching term", func() { - q := queryResult(get("/Artists?ParentId=" + lib1() + "&SearchTerm=nonexistentxyz")) - Expect(q.Items).To(BeEmpty()) - }) - }) - - Describe("albums and songs", func() { - It("searches albums scoped to a library", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&ParentId=" + lib1() + "&SearchTerm=Abbey")) - Expect(names(q.Items)).To(ContainElement("Abbey Road")) - }) - - It("searches songs scoped to a library", func() { - q := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&ParentId=" + lib1() + "&SearchTerm=Stairway")) - Expect(names(q.Items)).To(ContainElement("Stairway To Heaven")) - }) - }) - - Describe("pagination totals", func() { - It("reports the search match count, not the unfiltered library count", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true&SearchTerm=Abbey&Limit=50")) - Expect(q.Items).To(HaveLen(1)) - Expect(q.TotalRecordCount).To(Equal(1)) // not the 5-album library total - }) - - It("reaches the true total when paging song search results", func() { - // "So" prefix-matches several songs (titles and Solo Artist's tracks); learn the true - // count from an unpaged query, then walk one-item pages: the reported total must keep - // the client paging until the last match and stop it exactly there. - all := queryResult(get("/Items?IncludeItemTypes=Audio&Recursive=true&SearchTerm=So")) - total := all.TotalRecordCount - Expect(total).To(Equal(len(all.Items))) - Expect(total).To(BeNumerically(">=", 2)) - - var collected []string - for start := range total { - page := queryResult(get(fmt.Sprintf("/Items?IncludeItemTypes=Audio&Recursive=true&SearchTerm=So&Limit=1&StartIndex=%d", start))) - Expect(page.Items).To(HaveLen(1)) - if start+1 < total { - Expect(page.TotalRecordCount).To(BeNumerically(">", start+1)) // more remain: keep paging - } else { - Expect(page.TotalRecordCount).To(Equal(total)) // last page: exact, so the client stops - } - collected = append(collected, page.Items[0].Name) - } - Expect(collected).To(ConsistOf(names(all.Items))) - }) - }) -}) diff --git a/server/jellyfin/e2e/sessions_test.go b/server/jellyfin/e2e/sessions_test.go deleted file mode 100644 index 22c87b108..000000000 --- a/server/jellyfin/e2e/sessions_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package e2e - -import ( - "net/http" - "strconv" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Sessions", func() { - BeforeEach(func() { setupTestDB() }) - - ticks := func(ms int64) int64 { return ms * 10_000 } - reportBody := func(itemID string, positionTicks int64) string { - return `{"ItemId":"` + enc(itemID) + `","PositionTicks":` + strconv.FormatInt(positionTicks, 10) + `}` - } - - Describe("playback reporting", func() { - It("accepts a playback start report", func() { - Expect(post("/Sessions/Playing", reportBody(songID("Come Together"), 0)).Code).To(Equal(http.StatusNoContent)) - }) - - It("accepts a playback progress report", func() { - Expect(post("/Sessions/Playing/Progress", reportBody(songID("Come Together"), ticks(5000))).Code).To(Equal(http.StatusNoContent)) - }) - - It("counts a play stopped past the threshold", func() { - id := songID("So What") - mf, err := ds.MediaFile().Get(ctx, id) - Expect(err).ToNot(HaveOccurred()) - // Report a stop at the end of the track — comfortably past 50% / the 4-minute cap. - Expect(post("/Sessions/Playing/Stopped", reportBody(id, ticks(int64(mf.Duration*1000)))).Code).To(Equal(http.StatusNoContent)) - - mf, err = ds.MediaFile().Get(ctx, id) - Expect(err).ToNot(HaveOccurred()) - Expect(mf.PlayCount).To(BeNumerically(">=", 1)) - }) - - It("acknowledges a playback session ping", func() { - Expect(post("/Sessions/Playing/Ping?playSessionId=abc", "").Code).To(Equal(http.StatusNoContent)) - }) - - It("does not count a brief play stopped before the threshold", func() { - // Regression: Finamp sends a Stopped report on every track switch, so an immediate skip - // (1 second in) must not mark the track played. Seeded tracks are >= 120s, so the 50% - // threshold is always well above 1s. - id := songID("Help!") - Expect(post("/Sessions/Playing/Stopped", reportBody(id, ticks(1000))).Code).To(Equal(http.StatusNoContent)) - - mf, err := ds.MediaFile().Get(ctx, id) - Expect(err).ToNot(HaveOccurred()) - Expect(mf.PlayCount).To(Equal(int64(0))) - }) - }) - - Describe("capabilities", func() { - It("acknowledges POST /Sessions/Capabilities", func() { - Expect(post("/Sessions/Capabilities", "{}").Code).To(Equal(http.StatusNoContent)) - }) - - It("acknowledges POST /Sessions/Capabilities/Full", func() { - Expect(post("/Sessions/Capabilities/Full", "{}").Code).To(Equal(http.StatusNoContent)) - }) - }) -}) diff --git a/server/jellyfin/e2e/similar_test.go b/server/jellyfin/e2e/similar_test.go deleted file mode 100644 index a5d2505ba..000000000 --- a/server/jellyfin/e2e/similar_test.go +++ /dev/null @@ -1,182 +0,0 @@ -package e2e - -import ( - "net/http" - - "github.com/navidrome/navidrome/model" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Similar", func() { - BeforeEach(func() { setupTestDB() }) - - Describe("GET /Artists/{id}/Similar", func() { - It("returns the provider's similar artists, excluding ones not in the library", func() { - providerFake.similarArtists = model.Artists{ - {ID: testID("z"), Name: "Led Zeppelin"}, - {ID: "", Name: "Not In Library"}, // no id -> not present -> excluded - } - q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar")) - Expect(names(q.Items)).To(ConsistOf("Led Zeppelin")) - Expect(q.Items[0].Type).To(Equal("MusicArtist")) - }) - - It("returns an empty result (not 404) when the provider has nothing", func() { - q := queryResult(get("/Artists/" + enc(artistID("The Beatles")) + "/Similar")) - Expect(q.Items).To(BeEmpty()) - Expect(q.TotalRecordCount).To(Equal(0)) - }) - - // Unlike an unresolvable-but-well-formed id (empty result above), a malformed itemId never - // reaches provider lookup at all — itemIDParam rejects it first. - It("404s a malformed itemId", func() { - w := get("/Artists/not-a-valid-id/Similar") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("GET /Items/{id}/Similar", func() { - It("returns similar songs for a track", func() { - providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Similar Song", LibraryID: 1}} - q := queryResult(get("/Items/" + enc(songID("So What")) + "/Similar")) - Expect(names(q.Items)).To(ConsistOf("Similar Song")) - Expect(q.Items[0].Type).To(Equal("Audio")) - }) - - It("excludes similar songs from libraries the user can't access", func() { - providerFake.similarSongs = model.MediaFiles{ - {ID: testID("x1"), Title: "In Library", LibraryID: 1}, - {ID: testID("x2"), Title: "Other Library", LibraryID: 2}, // regularUser has no access - } - q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/Similar")) - Expect(names(q.Items)).To(ConsistOf("In Library")) - }) - - It("returns similar albums (derived from similar songs, de-duplicated) for an album", func() { - providerFake.similarSongs = model.MediaFiles{ - {ID: testID("x1"), AlbumID: albumID("IV")}, - {ID: testID("x2"), AlbumID: albumID("IV")}, // same album -> counted once - {ID: testID("x3"), AlbumID: albumID("Kind of Blue")}, - } - q := queryResult(get("/Items/" + enc(albumID("Abbey Road")) + "/Similar")) - Expect(names(q.Items)).To(Equal([]string{"IV", "Kind of Blue"})) - Expect(q.Items[0].Type).To(Equal("MusicAlbum")) - }) - - It("excludes similar albums from libraries the user can't access", func() { - // Seed an album in a second library the regular user has no access to, and point a - // provider similar-song at it. - otherLib := model.Library{ID: 2, Name: "Other Library", Path: "fake:///other"} - Expect(ds.Library().Put(ctx, &otherLib)).To(Succeed()) - otherAlbum := model.Album{ID: testID("other-album"), Name: "Other Album", LibraryID: 2} - Expect(ds.Album().Put(ctx, &otherAlbum)).To(Succeed()) - - providerFake.similarSongs = model.MediaFiles{ - {ID: testID("x1"), AlbumID: albumID("IV")}, // library 1 -> visible - {ID: testID("x2"), AlbumID: testID("other-album")}, // library 2 -> filtered for regularUser - } - q := queryResult(getAs(regularUser, "/Items/"+enc(albumID("Abbey Road"))+"/Similar")) - Expect(names(q.Items)).To(ConsistOf("IV")) - }) - - It("returns an empty result (not 404) for an unknown item, so the client stops retrying", func() { - q := queryResult(get("/Items/" + enc(testID("does-not-exist")) + "/Similar")) - Expect(q.Items).To(BeEmpty()) - }) - - It("404s a malformed itemId", func() { - w := get("/Items/not-a-valid-id/Similar") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - // Finamp plays exactly what InstantMix returns, so a track seed must lead its own mix. - Describe("GET /Items/{id}/InstantMix", func() { - It("returns the seed track first, followed by similar songs", func() { - providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Similar Song", LibraryID: 1}} - q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=19")) - Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"})) - Expect(q.Items[0].Type).To(Equal("Audio")) - }) - - It("does not duplicate the seed when the provider returns it", func() { - providerFake.similarSongs = model.MediaFiles{ - {ID: songID("So What"), Title: "So What", LibraryID: 1}, - {ID: testID("x1"), Title: "Similar Song", LibraryID: 1}, - } - q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix")) - Expect(names(q.Items)).To(Equal([]string{"So What", "Similar Song"})) - }) - - It("caps the mix at the requested limit", func() { - providerFake.similarSongs = model.MediaFiles{ - {ID: testID("x1"), Title: "S1", LibraryID: 1}, - {ID: testID("x2"), Title: "S2", LibraryID: 1}, - {ID: testID("x3"), Title: "S3", LibraryID: 1}, - } - q := queryResult(get("/Items/" + enc(songID("So What")) + "/InstantMix?limit=2")) - Expect(names(q.Items)).To(Equal([]string{"So What", "S1"})) - }) - - It("excludes similar songs from libraries the user can't access", func() { - providerFake.similarSongs = model.MediaFiles{ - {ID: testID("x1"), Title: "In Library", LibraryID: 1}, - {ID: testID("x2"), Title: "Other Library", LibraryID: 2}, - } - q := queryResult(getAs(regularUser, "/Items/"+enc(songID("So What"))+"/InstantMix")) - Expect(names(q.Items)).To(Equal([]string{"So What", "In Library"})) - }) - - It("returns a mix of the provider's similar songs for an artist seed", func() { - providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Artist Mix Song", LibraryID: 1}} - q := queryResult(get("/Items/" + enc(artistID("Miles Davis")) + "/InstantMix")) - Expect(names(q.Items)).To(Equal([]string{"Artist Mix Song"})) - }) - - It("returns an empty result (not 404) for an unknown item", func() { - w := get("/Items/" + enc(testID("does-not-exist")) + "/InstantMix") - Expect(w.Code).To(Equal(200)) - Expect(queryResult(w).Items).To(BeEmpty()) - }) - - It("returns only the seed when the provider has nothing", func() { - q := queryResult(get("/Items/" + enc(songID("Help!")) + "/InstantMix")) - Expect(names(q.Items)).To(Equal([]string{"Help!"})) - }) - - It("404s a malformed itemId", func() { - w := get("/Items/not-a-valid-id/InstantMix") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("type-specific InstantMix routes", func() { - BeforeEach(func() { - providerFake.similarSongs = model.MediaFiles{{ID: testID("x1"), Title: "Mix Song", LibraryID: 1}} - }) - - It("leads a song mix with the seed on /Songs/{id}/InstantMix", func() { - q := queryResult(get("/Songs/" + enc(songID("So What")) + "/InstantMix")) - Expect(names(q.Items)).To(Equal([]string{"So What", "Mix Song"})) - }) - - DescribeTable("returns the provider's mix", - func(path func() string) { - q := queryResult(get(path())) - Expect(names(q.Items)).To(Equal([]string{"Mix Song"})) - }, - Entry("Albums/{id}", func() string { return "/Albums/" + enc(albumID("Abbey Road")) + "/InstantMix" }), - Entry("Artists/{id}", func() string { return "/Artists/" + enc(artistID("Miles Davis")) + "/InstantMix" }), - Entry("Playlists/{id}", func() string { - return "/Playlists/" + enc(createPlaylist("Seed", []string{enc(songID("So What"))})) + "/InstantMix" - }), - Entry("Artists/InstantMix?id=", func() string { return "/Artists/InstantMix?id=" + enc(artistID("Miles Davis")) }), - Entry("MusicGenres/InstantMix?id=", func() string { return "/MusicGenres/InstantMix?id=" + enc(genreID("Jazz")) }), - ) - - It("404s a malformed id query param", func() { - Expect(get("/Artists/InstantMix?id=not-a-valid-id").Code).To(Equal(http.StatusNotFound)) - }) - }) -}) diff --git a/server/jellyfin/e2e/smoke_test.go b/server/jellyfin/e2e/smoke_test.go deleted file mode 100644 index b26955632..000000000 --- a/server/jellyfin/e2e/smoke_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package e2e - -import ( - "net/http" - - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Smoke test: proves the harness boots (DB, scan, snapshot, router, token auth) and the seeded -// library is queryable end-to-end. Broader per-endpoint coverage lives in the sibling files. -var _ = Describe("Smoke", func() { - BeforeEach(func() { setupTestDB() }) - - It("serves public system info without auth", func() { - w := rawReq("GET", "/System/Info/Public", "") - Expect(w.Code).To(Equal(http.StatusOK)) - var info map[string]any - parseInto(w, &info) - Expect(info).To(HaveKey("ServerName")) - Expect(info).To(HaveKey("Version")) - }) - - It("rejects an authenticated endpoint without a token", func() { - w := rawReq("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", "") - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("lists the seeded albums for an authenticated user", func() { - q := queryResult(get("/Items?IncludeItemTypes=MusicAlbum&Recursive=true")) - Expect(q.TotalRecordCount).To(Equal(5)) - names := make([]string, 0, len(q.Items)) - for _, it := range q.Items { - Expect(it.Type).To(Equal("MusicAlbum")) - names = append(names, it.Name) - } - Expect(names).To(ConsistOf("Abbey Road", "Help!", "IV", "Kind of Blue", "Singles")) - }) - - It("resolves a seeded album id round-trip (encoded in the URL)", func() { - id := albumID("Abbey Road") - var item dto.BaseItemDto - parseInto(get("/Items/"+enc(id)), &item) - Expect(item.Id).To(Equal(enc(id))) - Expect(item.Name).To(Equal("Abbey Road")) - Expect(item.Type).To(Equal("MusicAlbum")) - }) -}) diff --git a/server/jellyfin/e2e/streaming_test.go b/server/jellyfin/e2e/streaming_test.go deleted file mode 100644 index 9b7380185..000000000 --- a/server/jellyfin/e2e/streaming_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package e2e - -import ( - "fmt" - "net/http" - "strings" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Streaming", func() { - BeforeEach(func() { setupTestDB() }) - - Describe("GET /Audio/{id}/stream", func() { - It("streams the requested track", func() { - id := songID("Come Together") - w := get("/Audio/" + enc(id) + "/stream") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(Equal("fake audio data")) - Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) - }) - - It("streams via the /universal endpoint", func() { - id := songID("So What") - Expect(get("/Audio/" + enc(id) + "/universal").Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) - }) - - It("serves the stream.{container} path form", func() { - id := songID("Help!") - Expect(get("/Audio/" + enc(id) + "/stream.mp3").Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) - }) - - It("forces raw format when static=true", func() { - // With ffmpeg unavailable the decider direct-plays regardless, but static=true must - // never resolve to a transcode. - id := songID("Help!") - get("/Audio/" + enc(id) + "/stream?static=true") - Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) - }) - - It("returns 404 for an unknown track", func() { - Expect(get("/Audio/" + enc(testID("nope")) + "/stream").Code).To(Equal(http.StatusNotFound)) - }) - - DescribeTable("answers HEAD (Fintunes probes the type before playing or downloading)", - func(path string) { - w := jReq(adminUser, "HEAD", fmt.Sprintf(path, enc(songID("Help!"))), "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).ToNot(BeEmpty()) - }, - Entry("stream", "/Audio/%s/stream"), - Entry("stream.{container}", "/Audio/%s/stream.mp3"), - Entry("universal", "/Audio/%s/universal"), - Entry("File", "/Items/%s/File"), - Entry("Download", "/Items/%s/Download"), - ) - }) - - Describe("GET /Audio/{id}/main.m3u8 (Finamp transcoding mode)", func() { - It("returns a VOD playlist whose segment streams through the transcode pipeline", func() { - id := songID("Come Together") - w := get("/Audio/" + enc(id) + "/main.m3u8?audioCodec=aac&audioBitRate=320000") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/vnd.apple.mpegurl")) - body := w.Body.String() - Expect(body).To(HavePrefix("#EXTM3U\n")) - Expect(body).To(HaveSuffix("#EXT-X-ENDLIST\n")) - - // Fetch the advertised segment like an HLS player would. - var segment string - for _, line := range strings.Split(body, "\n") { - if line != "" && !strings.HasPrefix(line, "#") { - segment = line - } - } - Expect(segment).To(HavePrefix("stream.aac?")) - Expect(get("/Audio/" + enc(id) + "/" + segment).Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) - Expect(streamerSpy.LastRequest.Format).To(Equal("aac")) - Expect(streamerSpy.LastRequest.BitRate).To(Equal(320)) - }) - - It("is reachable with Jellyfin's case-insensitive routing", func() { - id := songID("Come Together") - Expect(get("/audio/" + enc(id) + "/Main.m3u8").Code).To(Equal(http.StatusOK)) - }) - }) - - Describe("direct-file endpoints", func() { - It("serves /Items/{id}/File as direct play (raw)", func() { - id := songID("Something") - w := get("/Items/" + enc(id) + "/File") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) - }) - - It("serves /Items/{id}/Download", func() { - id := songID("Something") - Expect(get("/Items/" + enc(id) + "/Download").Code).To(Equal(http.StatusOK)) - }) - }) - - Describe("PlaybackInfo", func() { - It("returns a single direct-play MediaSource via GET", func() { - id := songID("So What") - var info dto.PlaybackInfoResponse - parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) - Expect(info.MediaSources).To(HaveLen(1)) - Expect(info.MediaSources[0].Id).ToNot(BeEmpty()) - Expect(info.PlaySessionId).To(MatchRegexp("^[0-9a-f]{32}$")) - }) - - It("returns a MediaSource via POST", func() { - id := songID("So What") - var info dto.PlaybackInfoResponse - parseInto(post("/Items/"+enc(id)+"/PlaybackInfo", "{}"), &info) - Expect(info.MediaSources).To(HaveLen(1)) - }) - - It("embeds a self-authenticating TranscodingUrl (for native players that omit auth headers)", func() { - id := songID("So What") - var info dto.PlaybackInfoResponse - parseInto(get("/Items/"+enc(id)+"/PlaybackInfo"), &info) - streamURL := info.MediaSources[0].TranscodingUrl - // Server-relative: clients append it to a base URL already carrying /jellyfin. - Expect(streamURL).To(HavePrefix("/Audio/" + enc(id) + "/universal")) - Expect(streamURL).ToNot(HavePrefix(consts.URLPathJellyfinAPI)) - Expect(streamURL).To(ContainSubstring("api_key=")) - // The embedded api_key alone must authenticate the stream — no auth header sent. - w := rawReq("GET", streamURL, "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastMediaFile.ID).To(Equal(id)) - }) - }) -}) diff --git a/server/jellyfin/e2e/system_test.go b/server/jellyfin/e2e/system_test.go deleted file mode 100644 index 51443bdc9..000000000 --- a/server/jellyfin/e2e/system_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package e2e - -import ( - "net/http" - "strings" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("System", func() { - BeforeEach(func() { setupTestDB() }) - - Describe("GET /System/Info/Public", func() { - It("returns public server info without authentication", func() { - w := rawReq("GET", "/System/Info/Public", "") - Expect(w.Code).To(Equal(http.StatusOK)) - var info map[string]any - parseInto(w, &info) - Expect(info["ServerName"]).To(HavePrefix("Navidrome")) - Expect(info["ProductName"]).To(Equal("Jellyfin Server")) - Expect(info["StartupWizardCompleted"]).To(BeTrue()) - Expect(info["Id"]).ToNot(BeEmpty()) - Expect(info["Version"]).ToNot(BeEmpty()) - }) - - It("routes case-insensitively (lowercase path)", func() { - w := rawReq("GET", "/system/info/public", "") - Expect(w.Code).To(Equal(http.StatusOK)) - }) - }) - - Describe("GET /System/Info", func() { - It("returns system info to any authenticated user, matching the public Version", func() { - w := getAs(regularUser, "/System/Info") - var info map[string]any - parseInto(w, &info) - Expect(info["Version"]).ToNot(BeEmpty()) - Expect(info["SupportsLibraryMonitor"]).To(BeTrue()) - - pub := rawReq("GET", "/System/Info/Public", "") - var pubInfo map[string]any - parseInto(pub, &pubInfo) - Expect(info["Version"]).To(Equal(pubInfo["Version"])) - Expect(info["Id"]).To(Equal(pubInfo["Id"])) - }) - - It("rejects unauthenticated requests", func() { - w := rawReq("GET", "/System/Info", "") - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("GET /System/Endpoint", func() { - It("always reports IsInNetwork, which Finamp's connection test probes for", func() { - w := getAs(regularUser, "/System/Endpoint") - Expect(w.Code).To(Equal(http.StatusOK)) - var info map[string]any - parseInto(w, &info) - Expect(info).To(HaveKey("IsInNetwork")) - Expect(info).To(HaveKey("IsLocal")) - }) - - It("rejects unauthenticated requests", func() { - w := rawReq("GET", "/System/Endpoint", "") - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) - - Describe("GET/POST /System/Ping", func() { - It("answers GET with a plain-text server name", func() { - w := rawReq("GET", "/System/Ping", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(HavePrefix("text/plain")) - Expect(w.Body.String()).To(HavePrefix("Navidrome")) - }) - - It("answers POST identically", func() { - w := rawReq("POST", "/System/Ping", "") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(strings.TrimSpace(w.Body.String())).To(HavePrefix("Navidrome")) - }) - }) -}) diff --git a/server/jellyfin/images.go b/server/jellyfin/images.go deleted file mode 100644 index 6af533dbb..000000000 --- a/server/jellyfin/images.go +++ /dev/null @@ -1,203 +0,0 @@ -package jellyfin - -import ( - "bytes" - "context" - "encoding/base64" - "errors" - "image" - _ "image/gif" - _ "image/jpeg" - _ "image/png" - "io" - "net/http" - - "github.com/dustin/go-humanize" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/imghttp" - "github.com/navidrome/navidrome/utils/req" - _ "golang.org/x/image/webp" -) - -// imageSize reduces Jellyfin's size params to Navidrome's single bound. Width/Height/Max* are -// bounds, so the tightest wins; Fill* must cover its box, so its larger side is the bound. -func imageSize(p *req.Values) int { - fill := max(p.IntOr("fillwidth", 0), p.IntOr("fillheight", 0)) - size := 0 - for _, v := range []int{p.IntOr("width", 0), p.IntOr("height", 0), p.IntOr("maxwidth", 0), p.IntOr("maxheight", 0), fill} { - if v > 0 && (size == 0 || v < size) { - size = v - } - } - return size -} - -func (api *Router) getItemImage(w http.ResponseWriter, r *http.Request) { - // Public, like Jellyfin's own image routes: clients build cover URLs without credentials, and - // upstream resolves them with no visibility check either (LibraryManager.ItemIsVisible, null user). - ctx := request.WithUser(r.Context(), model.User{IsAdmin: true}) - itemId, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - size := imageSize(req.Params(r)) - - artID := api.resolveArtworkID(ctx, itemId) - img, err := api.artwork.GetOrPlaceholder(ctx, artID, size, false) - switch { - case errors.Is(err, context.Canceled): - return - case err != nil: - log.Warn(ctx, "Error retrieving artwork", "id", itemId, err) - http.Error(w, "Not Found", http.StatusNotFound) - return - } - defer img.Close() - if imghttp.WriteImageHeaders(w, r, img, hashFromTag(r)) { - return - } - // Leave Content-Type unset so net/http sniffs it (covers may be PNG/WebP/JPEG). - _, _ = io.Copy(w, img) -} - -// hashFromTag returns the ?tag query param when it is exactly a 16-char lowercase-hex content -// hash (what Finamp/Jellyfin clients append), so a matching request can be served immutable. -func hashFromTag(r *http.Request) string { - tag := r.URL.Query().Get("tag") - if len(tag) != 16 { - return "" - } - for _, c := range tag { - if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { - return "" - } - } - return tag -} - -// resolveArtworkID maps a Jellyfin item id to a Navidrome ArtworkID, probing -// album -> artist -> media file -> playlist. -func (api *Router) resolveArtworkID(ctx context.Context, itemId string) string { - if al, err := api.ds.Album().Get(ctx, itemId); err == nil { - return al.CoverArtID().String() - } - if ar, err := api.ds.Artist().Get(ctx, itemId); err == nil { - return ar.CoverArtID().String() - } - if mf, err := api.ds.MediaFile().Get(ctx, itemId); err == nil { - return mf.CoverArtID().String() - } - if pl, err := api.ds.Playlist().Get(ctx, itemId); err == nil { - return pl.CoverArtID().String() - } - return (model.ArtworkID{}).String() -} - -// postItemImage handles cover upload. Only playlists are writable here; album/artist covers come -// from scanning. Past the auth and id gates the body is drained before answering — including on the -// not-implemented path — because Finamp writes it synchronously and would see a broken pipe. -func (api *Router) postItemImage(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - - // Honor the same artwork-upload gate and size cap as the native endpoint. - u, _ := request.UserFrom(ctx) - if !conf.Server.EnableArtworkUpload && !u.IsAdmin { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - // The limit caps the decoded image (native endpoint semantics); Jellyfin clients base64-encode - // the wire body (4/3 bigger), so the read cap allows for inflation. - limit := artwork.MaxImageUploadSize() - body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit*4/3+4)) - if err != nil { - log.Warn(ctx, "Jellyfin API: cover upload rejected: body exceeds MaxImageUploadSize", - "playlistId", id, "limit", humanize.Bytes(uint64(limit)), err) - http.Error(w, "file too large", http.StatusBadRequest) - return - } - - if _, err := api.playlists.Get(ctx, id); err != nil { - http.Error(w, "Not Implemented", http.StatusNotImplemented) - return - } - - imgBytes, err := decodeImageBody(body) - if err != nil { - log.Warn(ctx, "Jellyfin API: cover upload rejected: body is neither an image nor base64", "playlistId", id, err) - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - if int64(len(imgBytes)) > limit { - log.Warn(ctx, "Jellyfin API: cover upload rejected: image exceeds MaxImageUploadSize", - "playlistId", id, "size", humanize.Bytes(uint64(len(imgBytes))), "limit", humanize.Bytes(uint64(limit))) - http.Error(w, "file too large", http.StatusBadRequest) - return - } - // Validate by decoding and derive the extension from the real format — clients lie in Content-Type. - _, format, err := image.DecodeConfig(bytes.NewReader(imgBytes)) - if err != nil { - log.Warn(ctx, "Jellyfin API: cover upload rejected: not a valid image", "playlistId", id, err) - http.Error(w, "invalid image file", http.StatusBadRequest) - return - } - ext := "." + format - - if err := api.playlists.SetImage(ctx, id, bytes.NewReader(imgBytes), ext); err != nil { - api.internalError(w, r, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// deleteItemImage removes a playlist's uploaded cover. Only playlists are supported. -func (api *Router) deleteItemImage(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - - if _, err := api.playlists.Get(ctx, id); err != nil { - http.Error(w, "Not Implemented", http.StatusNotImplemented) - return - } - - if err := api.playlists.RemoveImage(ctx, id); err != nil { - api.internalError(w, r, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// decodeImageBody returns the raw image bytes. Jellyfin base64-encodes the body, but some clients -// send raw bytes, so input already starting with an image magic number is passed through as-is. -func decodeImageBody(body []byte) ([]byte, error) { - if isImageMagic(body) { - return body, nil - } - trimmed := bytes.TrimSpace(body) - return base64.StdEncoding.DecodeString(string(trimmed)) -} - -func isImageMagic(b []byte) bool { - switch { - case len(b) >= 2 && b[0] == 0xFF && b[1] == 0xD8: // JPEG - return true - case bytes.HasPrefix(b, []byte{0x89, 'P', 'N', 'G'}): // PNG - return true - case bytes.HasPrefix(b, []byte("GIF8")): // GIF (GIF87a/GIF89a) - return true - case len(b) >= 12 && bytes.HasPrefix(b, []byte("RIFF")) && bytes.Equal(b[8:12], []byte("WEBP")): // WebP - return true - default: - return false - } -} diff --git a/server/jellyfin/images_test.go b/server/jellyfin/images_test.go deleted file mode 100644 index b7cf9b7e7..000000000 --- a/server/jellyfin/images_test.go +++ /dev/null @@ -1,479 +0,0 @@ -package jellyfin - -import ( - "bytes" - "context" - "encoding/base64" - "errors" - "image" - "image/gif" - "image/jpeg" - "image/png" - "io" - "net/http" - "net/http/httptest" - "strconv" - "strings" - "time" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type fakeArtwork struct { - artwork.Artwork - recvId string - recvSize int - recvCtx context.Context - data []byte - hash string -} - -func (f *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*artwork.Image, error) { - f.recvId = id - f.recvSize = size - f.recvCtx = ctx - data := f.data - if data == nil { - data = []byte("IMG") - } - return &artwork.Image{ - ReadCloser: io.NopCloser(bytes.NewReader(data)), - Hash: f.hash, - LastUpdated: time.Now(), - }, nil -} - -func newImageRequest(itemId string) (*httptest.ResponseRecorder, *http.Request) { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+itemId+"/Images/Primary", nil) - rctx := chi.NewRouteContext() - rctx.URLParams.Add("itemId", itemId) - rctx.URLParams.Add("type", "Primary") - r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) - return w, r -} - -var _ = Describe("Images", func() { - // Real Jellyfin fits the image inside either bound, so a client that sends only MaxHeight must - // still get a resized image rather than the full-size original. - DescribeTable("derives the requested size from the Jellyfin size params", - func(query string, wantSize int) { - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("a1"))) - r.URL.RawQuery = query - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvSize).To(Equal(wantSize)) - }, - Entry("MaxWidth only", "maxwidth=300", 300), - Entry("MaxHeight only", "maxheight=300", 300), - Entry("both, smaller bound wins", "maxwidth=200&maxheight=300", 200), - Entry("both, smaller bound wins regardless of order", "maxwidth=300&maxheight=200", 200), - Entry("neither", "", 0), - Entry("Width only", "width=300", 300), - Entry("Height only", "height=300", 300), - Entry("Width capped by MaxWidth", "width=500&maxwidth=300", 300), - Entry("FillWidth only", "fillwidth=300", 300), - Entry("FillHeight only", "fillheight=300", 300), - Entry("both fill sides, larger one covers the box", "fillwidth=200&fillheight=300", 300), - Entry("fill smaller than MaxWidth wins", "maxwidth=500&fillwidth=300&fillheight=300", 300), - Entry("MaxWidth smaller than fill wins", "maxwidth=200&fillwidth=300&fillheight=300", 200), - Entry("non-positive values are ignored", "fillwidth=0&maxwidth=-5&height=250", 250), - ) - - It("streams album artwork", func() { - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("a1"))) - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(Equal("IMG")) - Expect(fa.recvId).To(ContainSubstring(testID("a1"))) - }) - - // A malformed itemId now 404s via itemIDParam instead of falling through to a placeholder image. - It("404s a malformed itemId instead of serving a placeholder", func() { - ds := &tests.MockDataStore{} - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest("not-a-valid-id") - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(fa.recvId).To(BeEmpty(), "artwork resolution must not run for an undecodable id") - }) - - // resolveArtworkID probes the entity tables, so a deleted item yields no artwork id at all. - It("asks for no artwork once the item is deleted, rather than its lingering state", func() { - ds := &tests.MockDataStore{} // no albums/artists/tracks/playlists at all - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("deleted-item"))) - api.getItemImage(w, r) - - // 200 here is what separates a well-formed unknown id from a malformed one, which 404s. - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).To(BeEmpty(), "an empty artwork id can only yield a placeholder") - }) - - It("sniffs the Content-Type instead of hardcoding it", func() { - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - - png := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, make([]byte, 512)...) - fa := &fakeArtwork{data: png} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("a1"))) - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("image/png")) - }) - - It("resolves a playlist's cover regardless of visibility, even for an anonymous caller", func() { - ds := &tests.MockDataStore{} - ds.Playlist().(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("pl1"), Name: "Mix", OwnerID: testID("someone")}}) - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("pl1"))) - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fa.recvId).To(ContainSubstring(testID("pl1"))) - }) - - // This endpoint is public (no user in the request), so artwork must be resolved under an - // elevated context; otherwise a private playlist's cover fails its visibility filter and - // silently falls back to the placeholder. - It("resolves artwork under an elevated admin context", func() { - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - fa := &fakeArtwork{} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("a1"))) - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - u, ok := request.UserFrom(fa.recvCtx) - Expect(ok).To(BeTrue()) - Expect(u.IsAdmin).To(BeTrue()) - }) - - It("serves immutable when the tag param asserts the current hash", func() { - const hash = "0123456789abcdef" - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - fa := &fakeArtwork{hash: hash} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("a1"))) - q := r.URL.Query() - q.Set("tag", hash) - r.URL.RawQuery = q.Encode() - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) - Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) - }) - - It("revalidates via no-cache when no tag is provided", func() { - const hash = "0123456789abcdef" - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - fa := &fakeArtwork{hash: hash} - api := &Router{ds: ds, artwork: fa} - - w, r := newImageRequest(dto.EncodeID(testID("a1"))) - api.getItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) - }) -}) - -// Real image fixtures: postItemImage validates uploads by decoding them. -func pngBytes() []byte { - var b bytes.Buffer - Expect(png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)))).To(Succeed()) - return b.Bytes() -} - -func jpegBytes() []byte { - var b bytes.Buffer - Expect(jpeg.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) - return b.Bytes() -} - -func gifBytes() []byte { - var b bytes.Buffer - Expect(gif.Encode(&b, image.NewRGBA(image.Rect(0, 0, 1, 1)), nil)).To(Succeed()) - return b.Bytes() -} - -// 1x1 WebP (Go's webp support is decode-only, so this one is pre-encoded). -func webpBytes() []byte { - b, err := base64.StdEncoding.DecodeString( - "UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoBAAEAAgA0JaACdLoB+AADsAD+8Oj3/yC5YXXI1/8gP+QH/ID/+PIAAAA=") - Expect(err).ToNot(HaveOccurred()) - return b -} - -var _ = Describe("postItemImage", func() { - var api *Router - var fp *fakePlaylists - - BeforeEach(func() { - fp = &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}} - api = &Router{playlists: fp} - }) - - It("uploads a raw JPEG body and returns 204", func() { - body := jpegBytes() - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.setImagePlaylistID).To(Equal(testID("pl1"))) - Expect(fp.setImageBytes).To(Equal(body)) - Expect(fp.setImageExt).To(Equal(".jpeg")) - }) - - It("base64-decodes the body and derives the extension from the actual format, not Content-Type", func() { - raw := pngBytes() - encoded := base64.StdEncoding.EncodeToString(raw) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader([]byte(encoded))) - r.Header.Set("Content-Type", "image/jpeg") // lies: the payload is a PNG - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.setImageBytes).To(Equal(raw)) - Expect(fp.setImageExt).To(Equal(".png")) - }) - - It("returns 501 for a non-playlist item, draining the body first", func() { - fp.getByIDPls = nil - fp.getByIDErr = model.ErrNotFound - bodyReader := bytes.NewReader([]byte("some-bytes-that-must-be-drained")) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("al1"))+"/Images/Primary", bodyReader) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNotImplemented)) - Expect(bodyReader.Len()).To(Equal(0)) - }) - - It("returns 500 when the service fails", func() { - fp.setImageErr = errors.New("boom") - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - - It("accepts a raw WebP body", func() { - body := webpBytes() - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) - r.Header.Set("Content-Type", "image/webp") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.setImageBytes).To(Equal(body)) - Expect(fp.setImageExt).To(Equal(".webp")) - }) - - It("accepts a raw GIF body", func() { - body := gifBytes() - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) - r.Header.Set("Content-Type", "image/gif") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.setImageBytes).To(Equal(body)) - Expect(fp.setImageExt).To(Equal(".gif")) - }) - - It("rejects an oversized body with 400, like the native endpoint", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.MaxImageUploadSize = "16" // 16 bytes - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(fp.setImagePlaylistID).To(BeEmpty(), "must not persist an over-limit upload") - }) - - It("applies the size limit to the decoded image, not the base64 body", func() { - DeferCleanup(configtest.SetupConfig()) - img := pngBytes() - // The raw image is exactly at the limit; its base64 form is 4/3 bigger. - conf.Server.MaxImageUploadSize = strconv.Itoa(len(img)) - body := []byte(base64.StdEncoding.EncodeToString(img)) - Expect(len(body)).To(BeNumerically(">", len(img))) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) - r.Header.Set("Content-Type", "image/png") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.setImageBytes).To(Equal(img)) - }) - - It("rejects a base64 body whose decoded image exceeds the limit with 400", func() { - DeferCleanup(configtest.SetupConfig()) - img := pngBytes() - conf.Server.MaxImageUploadSize = strconv.Itoa(len(img) - 1) - body := []byte(base64.StdEncoding.EncodeToString(img)) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) - r.Header.Set("Content-Type", "image/png") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(fp.setImagePlaylistID).To(BeEmpty()) - }) - - It("rejects a body that is neither an image nor base64 with 400", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", strings.NewReader("!!not base64!!")) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(fp.setImagePlaylistID).To(BeEmpty()) - }) - - It("rejects bytes that sniff as an image but don't decode (e.g. a truncated or renamed file)", func() { - body := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'} // JPEG magic, not a JPEG - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(body)) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(fp.setImagePlaylistID).To(BeEmpty()) - }) - - It("forbids a non-admin upload when artwork upload is disabled", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableArtworkUpload = false - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("u1"), IsAdmin: false})) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusForbidden)) - Expect(fp.setImagePlaylistID).To(BeEmpty()) - }) - - It("still allows an admin upload when artwork upload is disabled", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableArtworkUpload = false - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", bytes.NewReader(jpegBytes())) - r.Header.Set("Content-Type", "image/jpeg") - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - r = r.WithContext(request.WithUser(r.Context(), model.User{ID: testID("admin"), IsAdmin: true})) - - api.postItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - }) -}) - -var _ = Describe("deleteItemImage", func() { - It("removes the playlist image and returns 204", func() { - fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}} - api := &Router{playlists: fp} - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.deleteItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removeImagePlaylistID).To(Equal(testID("pl1"))) - }) - - It("returns 501 for a non-playlist item", func() { - fp := &fakePlaylists{getByIDErr: model.ErrNotFound} - api := &Router{playlists: fp} - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("al1"))+"/Images/Primary", nil) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al1"))) - - api.deleteItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusNotImplemented)) - }) - - It("returns 500 when the service fails", func() { - fp := &fakePlaylists{getByIDPls: &model.Playlist{ID: testID("pl1")}, removeImageErr: errors.New("boom")} - api := &Router{playlists: fp} - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(testID("pl1"))+"/Images/Primary", nil) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("pl1"))) - - api.deleteItemImage(w, r) - - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) -}) diff --git a/server/jellyfin/items.go b/server/jellyfin/items.go deleted file mode 100644 index 28de4c31e..000000000 --- a/server/jellyfin/items.go +++ /dev/null @@ -1,1106 +0,0 @@ -package jellyfin - -import ( - "context" - "errors" - "io" - "iter" - "net/http" - "slices" - "strconv" - "strings" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/filter" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/slice" - "golang.org/x/sync/errgroup" -) - -// notMissing excludes items whose backing files are all gone ("missing" is a real column on -// album, artist and media_file). -var notMissing = squirrel.Eq{"missing": false} - -// searchTerm trims, so a whitespace-only term is not a search: doSearch would read it as "match -// everything" and materialize the library, where the unfiltered path streams. -func searchTerm(p *req.Values) string { - return strings.TrimSpace(p.StringOr("searchterm", "")) -} - -// itemFilters is the parsed Filters=... list together with the standalone isFavorite/isPlayed params -// clients may send instead. A nil field means the client asked for no filtering on that dimension. -type itemFilters struct { - favorite *bool - played *bool -} - -// parseItemFilters reads the standalone params first and lets the Filters list win, matching real -// Jellyfin. Tokens with no Navidrome equivalent (Likes, IsFolder, IsResumable) are dropped. -func parseItemFilters(p *req.Values) itemFilters { - f := itemFilters{favorite: p.BoolPtr("isfavorite"), played: p.BoolPtr("isplayed")} - for token := range strings.SplitSeq(p.StringOr("filters", ""), ",") { - switch strings.TrimSpace(token) { - case "IsFavorite", "IsFavoriteOrLikes": - f.favorite = new(true) - case "IsPlayed": - f.played = new(true) - case "IsUnplayed": - f.played = new(false) - } - } - return f -} - -// predicates renders the filters as annotation-column conditions. The negative cases have to match -// NULL as well: annotations are LEFT JOINed, so an item nobody has touched has no row at all. -func (f itemFilters) predicates() []squirrel.Sqlizer { - var out []squirrel.Sqlizer - if f.favorite != nil { - if *f.favorite { - out = append(out, squirrel.Eq{"starred": true}) - } else { - out = append(out, squirrel.Or{squirrel.Eq{"starred": nil}, squirrel.Eq{"starred": false}}) - } - } - if f.played != nil { - if *f.played { - out = append(out, squirrel.Gt{"play_count": 0}) - } else { - out = append(out, squirrel.Or{squirrel.Eq{"play_count": nil}, squirrel.Eq{"play_count": 0}}) - } - } - return out -} - -func (api *Router) getItems(w http.ResponseWriter, r *http.Request) { - res, err := api.queryItems(r.Context(), r) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - api.internalError(w, r, err) - return - } - api.ok(w, r, res) -} - -// itemsResult is the outcome of a collection query: a materialized page, or a cursor opener so a -// full-library response never builds every DTO at once. Exactly one of items/openCursor is set. -// -// openCursor is deferred rather than opened here: it must run after the ServerId lookup, which -// writes to the DB on first use and would deadlock against an open reader, but before the first -// response byte, so a failed open is still a clean error rather than a truncated 200. -type itemsResult struct { - items []dto.BaseItemDto - openCursor func() (iter.Seq2[dto.BaseItemDto, error], error) - total int - start int -} - -func materialized(q dto.QueryResult) itemsResult { - return itemsResult{items: q.Items, total: q.TotalRecordCount, start: q.StartIndex} -} - -func streamed(open func() (iter.Seq2[dto.BaseItemDto, error], error), total, start int) itemsResult { - return itemsResult{openCursor: open, total: total, start: start} -} - -// chained streams several results back to back, skipping the first skip items — the unbounded -// multi-type merge, where paginate(items, offset, 0) is just the concatenation minus its head. -func chained(results []itemsResult, total, skip int) itemsResult { - open := func() (iter.Seq2[dto.BaseItemDto, error], error) { - if len(results) == 0 { - return sliceItems(nil), nil - } - // Only the first opens eagerly (so the usual failure is still a clean error); the rest open as - // the stream reaches them, so only one cursor pins a DB connection at a time. - first, err := results[0].seq() - if err != nil { - return nil, err - } - return func(yield func(dto.BaseItemDto, error) bool) { - n := 0 - emit := func(seq iter.Seq2[dto.BaseItemDto, error]) bool { - for it, err := range seq { - if err != nil { - yield(dto.BaseItemDto{}, err) - return false - } - if n < skip { - n++ - continue - } - if !yield(it, nil) { - return false - } - } - return true - } - if !emit(first) { - return - } - for _, res := range results[1:] { - seq, err := res.seq() - if err != nil { - yield(dto.BaseItemDto{}, err) - return - } - if !emit(seq) { - return - } - } - }, nil - } - return streamed(open, total, skip) -} - -// streamCursor builds a deferred opener that maps each row as it's yielded. It takes the cursor's -// underlying func type, so callers wrap repo.GetCursor for the named type to infer T. -func streamCursor[T any](openCursor func() (func(func(T, error) bool), error), toItem func(T) dto.BaseItemDto) func() (iter.Seq2[dto.BaseItemDto, error], error) { - return func() (iter.Seq2[dto.BaseItemDto, error], error) { - cursor, err := openCursor() - if err != nil { - return nil, err - } - return func(yield func(dto.BaseItemDto, error) bool) { - for row, err := range cursor { - if err != nil { - yield(dto.BaseItemDto{}, err) - return - } - if !yield(toItem(row), nil) { - return - } - } - }, nil - } -} - -// seq returns the items as one sequence, opening the cursor if there is one. -func (ir itemsResult) seq() (iter.Seq2[dto.BaseItemDto, error], error) { - if ir.openCursor != nil { - return ir.openCursor() - } - return sliceItems(ir.items), nil -} - -// collect drains the result into a slice, for the merge that combines types before paginating. -func (ir itemsResult) collect() ([]dto.BaseItemDto, error) { - if ir.openCursor == nil { - return ir.items, nil - } - seq, err := ir.openCursor() - if err != nil { - return nil, err - } - var out []dto.BaseItemDto - for it, err := range seq { - if err != nil { - return nil, err - } - out = append(out, it) - } - return out, nil -} - -func (api *Router) writeItems(w http.ResponseWriter, r *http.Request, res itemsResult) { - api.streamResult(w, r, res, func(w io.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { - return streamItemsEnvelope(w, items, res.total, res.start) - }) -} - -// writeItemsArray writes the bare-array shape (/Items/Latest), which has no QueryResult envelope. -func (api *Router) writeItemsArray(w http.ResponseWriter, r *http.Request, res itemsResult) { - api.streamResult(w, r, res, streamItemsArray) -} - -// stampItem fills in what real Jellyfin puts on every item it returns, so a client that requires a -// key never meets an item without it: ServerId, MediaType, ImageTags, and the Fields-gated lists. -func stampItem(it dto.BaseItemDto, serverID string, fields dto.Fields) dto.BaseItemDto { - it.ServerId = serverID - if it.MediaType == "" { - it.MediaType = "Unknown" - } - if it.ImageTags == nil { - it.ImageTags = map[string]string{} - } - if fields.Has("Genres") { - if it.Genres == nil { - it.Genres = []string{} - } - if it.GenreItems == nil { - it.GenreItems = []dto.NameGuidPair{} - } - } - if fields.Has("Tags") && it.Tags == nil { - it.Tags = []string{} - } - return it -} - -// requestFields parses the Fields param, which gates what stampItem and the mappers attach. -func requestFields(r *http.Request) dto.Fields { - return dto.ParseFields(req.Params(r).Strings("fields")...) -} - -// streamResult stamps every item (see stampItem). The cursor opens before the first byte, so a -// failed open is still a clean 500. -func (api *Router) streamResult(w http.ResponseWriter, r *http.Request, res itemsResult, - write func(io.Writer, iter.Seq2[dto.BaseItemDto, error]) error) { - sid, fields := api.serverID(r.Context()), requestFields(r) - seq, err := res.seq() - if err != nil { - api.internalError(w, r, err) - return - } - stamped := func(yield func(dto.BaseItemDto, error) bool) { - for it, err := range seq { - if err != nil { - yield(dto.BaseItemDto{}, err) - return - } - if !yield(stampItem(it, sid, fields), nil) { - return - } - } - } - w.Header().Set("Content-Type", "application/json; charset=utf-8") - if err := write(w, stamped); err != nil { - log.Error(r.Context(), "Jellyfin API: error streaming response", err) - } -} - -// itemsQuery is a parsed /Items request, so the dispatch and every listXxx take one value instead -// of a long positional parameter list. -type itemsQuery struct { - fields dto.Fields - ids []string - rawTypes string - types []string - search string - sortBy string - sortOrder string - offset int - limit int - filters itemFilters - // parentId scopes the query. entityParent is the same id only when it names an entity (an artist - // for MusicAlbum, an album for Audio) rather than a library. - parentId string - entityParent string - isLibraryParent bool - scopeIDs []int - // artistId selects that artist's own discography; contributingOnly means albums they merely - // appear on (Jellyfin's "Featured On"), which must exclude that discography. - artistId string - contributingOnly bool - genreIds []string - albumIds []string - years []int - studioIds []string -} - -// listParams reads the itemsQuery fields that come straight from query params. -func listParams(p *req.Values) itemsQuery { - return itemsQuery{ - fields: dto.ParseFields(p.Strings("fields")...), - search: searchTerm(p), - sortBy: p.StringOr("sortby", ""), - sortOrder: p.StringOr("sortorder", ""), - offset: p.IntOr("startindex", 0), - limit: p.IntOr("limit", 0), - filters: parseItemFilters(p), - } -} - -// parseItemsQuery also resolves the entity types (inferring them from the parent when -// IncludeItemTypes is absent) and the library scope. Query keys are read lowercase because -// normalizeQueryKeys folded them (Jellyfin binds case-insensitively). A non-empty id param that -// fails to decode reports model.ErrNotFound rather than silently dropping the filter (see decodeFilterParam). -func (api *Router) parseItemsQuery(ctx context.Context, r *http.Request) (itemsQuery, error) { - p := req.Params(r) - parentId, ok := decodeFilterParam(p.StringOr("parentid", "")) - if !ok { - return itemsQuery{}, model.ErrNotFound - } - // Any malformed entry in one of these id lists must 404, not silently drop out of the filter - // (see dto.DecodeIDs) — an all-malformed list would otherwise widen the query to everything. - ids, ok := decodedQueryIDs(r, "ids") - if !ok { - return itemsQuery{}, model.ErrNotFound - } - // Finamp's genre screen sends ParentId=<libraryId> for scoping plus GenreIds for the genre. - genreIds, ok := decodedQueryIDs(r, "genreids") - if !ok { - return itemsQuery{}, model.ErrNotFound - } - // Feishin fetches an album's tracks with AlbumIds instead of ParentId. - albumIds, ok := decodedQueryIDs(r, "albumids") - if !ok { - return itemsQuery{}, model.ErrNotFound - } - studioIds, ok := decodedQueryIDs(r, "studioids") - if !ok { - return itemsQuery{}, model.ErrNotFound - } - q := listParams(p) - q.ids = ids - q.rawTypes = knownItemKinds(p.StringOr("includeitemtypes", "")) - q.parentId = parentId - q.genreIds = genreIds - q.albumIds = albumIds - q.years = parseYears(r) - q.studioIds = studioIds - // An artist's page filters by artist, not ParentId: Finamp sends ParentId=<libraryId> for scoping - // plus AlbumArtistIds/ArtistIds/contributingArtistIds for the artist. - albumArtistScope := firstNonEmpty(p.StringOr("albumartistids", ""), p.StringOr("artistids", "")) - contributingScope := p.StringOr("contributingartistids", "") - artistId, ok := firstDecodedID(firstNonEmpty(albumArtistScope, contributingScope)) - if !ok { - return itemsQuery{}, model.ErrNotFound - } - q.artistId = artistId - q.contributingOnly = albumArtistScope == "" && contributingScope != "" - - q.types = parseTypes(q.rawTypes) - q.scopeIDs, q.isLibraryParent = resolveLibraryScope(ctx, q.parentId) - - // Recursive=false asks for direct children only, and no track is a library's direct child. - // Finamp's sync probes a library this way, and every track is a wrong, unbounded answer. - if q.isLibraryParent && !p.BoolOr("recursive", false) { - q.types = slices.DeleteFunc(q.types, func(t string) bool { return t == "Audio" }) - } - - // With no item type, Jellyfin infers the child type from the parent: album parent -> its tracks - // (Jellify opens albums this way). An artist parent keeps parseTypes' MusicAlbum default (browse - // its albums). - if q.rawTypes == "" && q.parentId != "" && !q.isLibraryParent { - if q.parentId == dto.PlaylistsFolderID { - // Browsing into the synthetic playlists folder lists the user's playlists. - q.types = []string{"Playlist"} - } else if _, err := api.ds.Album().Get(ctx, q.parentId); err == nil { - q.types = []string{"Audio"} - } - } - // ParentId-as-entity-id only makes sense for a single type; a multi-type query has no natural - // parent entity, so there ParentId is only library scoping. - q.entityParent = q.parentId - if q.isLibraryParent || len(q.types) > 1 { - q.entityParent = "" - } - return q, nil -} - -// queryItems is the /Items dispatcher: it resolves the request to entity types and queries each via -// the matching listXxx, merging multi-type results into one paginated list (as Finamp's favorites -// screen requests). -func (api *Router) queryItems(ctx context.Context, r *http.Request) (itemsResult, error) { - q, err := api.parseItemsQuery(ctx, r) - if err != nil { - return itemsResult{}, err - } - switch { - // /Items?ids= is a batch-fetch-by-id that bypasses the type dispatch. - case len(q.ids) > 0: - return materialized(api.itemsByIDs(ctx, q.ids, q.fields)), nil - // A ManualPlaylistsFolder query asks for the synthetic "playlists library" container, not real items. - case strings.Contains(strings.ToLower(q.rawTypes), "manualplaylistsfolder"): - return materialized(result([]dto.BaseItemDto{playlistsFolder()}, 1, 0)), nil - } - if repo, ok := api.playlistTracksRepo(ctx, q); ok { - return api.playlistTrackPage(ctx, repo, q.fields, q.offset, q.limit) - } - if q.search != "" { - q.limit = clampLimit(q.limit, defaultSearchLimit, maxSearchLimit) - } - if len(q.types) == 1 { - opts := model.QueryOptions{Offset: q.offset, Max: q.limit} - applySort(&opts, q.types[0], q.sortBy, q.sortOrder) - return api.queryItemsOfType(ctx, q.types[0], opts, q) - } - return api.mergeTypes(ctx, q) -} - -// playlistTracksRepo resolves a playlist parent, whatever IncludeItemTypes says: Jellify opens a -// playlist with ParentId=<playlist>&IncludeItemTypes=Audio, and routing that through listSongs would -// treat the playlist id as an album id and return nothing. -// -// ok is false when ParentId isn't a visible playlist, so the caller falls through to the type -// dispatch: ParentId is usually an album or artist. -func (api *Router) playlistTracksRepo(ctx context.Context, q itemsQuery) (model.PlaylistTrackRepository, bool) { - if q.parentId == "" || q.isLibraryParent || q.parentId == dto.PlaylistsFolderID { - return nil, false - } - // Tracks enforces visibility. - repo, err := api.playlists.Tracks(ctx, q.parentId) - return repo, err == nil -} - -func (api *Router) mergeTypes(ctx context.Context, q itemsQuery) (itemsResult, error) { - if q.limit == 0 { - return api.mergeTypesStreaming(ctx, q) - } - // A random page doesn't stack on the previous one (the order reshuffles each request), so serving - // from 0 is an equivalent fresh draw and avoids materializing offset+limit rows per type. - offset := q.offset - if randomlySorted(q) { - offset = 0 - } - return api.mergeTypesPaged(ctx, q, offset) -} - -// randomlySorted reports whether every merged type resolves to a random sort — the case where a page -// is an independent draw, so the offset can be collapsed to 0. Resolving via applySort (rather than -// matching the raw SortBy) keeps this in step with how each type's sort is actually chosen. -func randomlySorted(q itemsQuery) bool { - for _, itemType := range q.types { - var opts model.QueryOptions - applySort(&opts, itemType, q.sortBy, q.sortOrder) - if opts.Sort != "random" { - return false - } - } - return true -} - -// mergeTypesStreaming keeps the unbounded path lazy: chaining the per-type cursors yields their rows -// in order minus the first offset, without pulling every row into memory. -func (api *Router) mergeTypesStreaming(ctx context.Context, q itemsQuery) (itemsResult, error) { - var results []itemsResult - total := 0 - for _, itemType := range q.types { - res, err := api.queryTypeWindow(ctx, itemType, 0, q) - if err != nil { - return itemsResult{}, err - } - results = append(results, res) - total += res.total - } - return chained(results, total, q.offset), nil -} - -// queryTypeWindow queries one type for the merge paths, capping it to window rows with the sort applied. -func (api *Router) queryTypeWindow(ctx context.Context, itemType string, window int, q itemsQuery) (itemsResult, error) { - var opts model.QueryOptions - opts.Max = window - applySort(&opts, itemType, q.sortBy, q.sortOrder) - return api.queryItemsOfType(ctx, itemType, opts, q) -} - -// mergeTypesPaged runs each type's query concurrently, then round-robins the per-type rows so the limited page -// is a mix rather than one type's rows followed by the next. -func (api *Router) mergeTypesPaged(ctx context.Context, q itemsQuery, offset int) (itemsResult, error) { - // Each per-type query needs at most offset+limit rows (worst case: one type fills the whole window). - window := offset + q.limit - if q.search != "" { - window = min(window, maxSearchLimit) - } - lists := make([][]dto.BaseItemDto, len(q.types)) - totals := make([]int, len(q.types)) - g, ctx := errgroup.WithContext(ctx) - for i, itemType := range q.types { - g.Go(func() error { - res, err := api.queryTypeWindow(ctx, itemType, window, q) - if err != nil { - return err - } - items, err := res.collect() - if err != nil { - return err - } - lists[i] = items - totals[i] = res.total - return nil - }) - } - if err := g.Wait(); err != nil { - return itemsResult{}, err - } - total := 0 - for _, t := range totals { - total += t - } - items := interleave(lists) - if q.search != "" { - // Past the window the merged order isn't the true one, so drop it rather than serve another - // type's rows. The total is what's pageable overall, so a client paging on it won't stop early. - items = items[:min(window, len(items))] - total = min(total, maxSearchLimit) - } - return materialized(result(paginate(items, offset, q.limit), total, q.offset)), nil -} - -func (api *Router) queryItemsOfType(ctx context.Context, itemType string, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { - switch itemType { - case "Audio": - return api.listSongs(ctx, opts, q) - case "MusicArtist": - // The MusicArtist browse hierarchy (UserViews -> artists -> albums) means album artists. - return api.listArtists(ctx, opts, q, model.RoleAlbumArtist) - case "MusicGenre": - return api.listGenres(ctx, opts) - case "Playlist": - return api.listPlaylists(ctx, opts, q) - default: // MusicAlbum - return api.listAlbums(ctx, opts, q) - } -} - -// firstNonEmpty returns the first non-empty string, or "". -func firstNonEmpty(vals ...string) string { - for _, v := range vals { - if v != "" { - return v - } - } - return "" -} - -// firstDecodedID decodes the first id from a (possibly comma-separated) Jellyfin id list, reporting -// whether it decoded successfully (see decodeFilterParam). -func firstDecodedID(s string) (string, bool) { - if s == "" { - return "", true - } - first, _, _ := strings.Cut(s, ",") - return decodeFilterParam(strings.TrimSpace(first)) -} - -// decodedQueryIDs reads an id-list param in both client spellings (see queryIDs). ok is false if -// any entry is malformed, so a dropped entry can't shrink the list into an empty, no-op filter. -func decodedQueryIDs(r *http.Request, key string) ([]string, bool) { - return dto.DecodeIDs(queryIDs(r, key)) -} - -// parseYears reads Years= as a discrete list, accepting comma-separated and repeated params. -func parseYears(r *http.Request) []int { - var years []int - for _, v := range queryIDs(r, "years") { - if y, err := strconv.Atoi(v); err == nil && y > 0 { - years = append(years, y) - } - } - return years -} - -// supportedTypes maps lowercased IncludeItemTypes names (Jellyfin binds them case-insensitively) -// to the item types Navidrome serves. -var supportedTypes = map[string]string{ - "audio": "Audio", "musicartist": "MusicArtist", "musicalbum": "MusicAlbum", "musicgenre": "MusicGenre", "playlist": "Playlist", -} - -// jellyfinItemKinds lists Jellyfin's BaseItemKind names, lowercased. -var jellyfinItemKinds = map[string]bool{ - "aggregatefolder": true, "audio": true, "audiobook": true, "basepluginfolder": true, "book": true, - "boxset": true, "channel": true, "channelfolderitem": true, "collectionfolder": true, "episode": true, - "folder": true, "genre": true, "manualplaylistsfolder": true, "movie": true, "livetvchannel": true, - "livetvprogram": true, "musicalbum": true, "musicartist": true, "musicgenre": true, "musicvideo": true, - "person": true, "photo": true, "photoalbum": true, "playlist": true, "playlistsfolder": true, - "program": true, "recording": true, "season": true, "series": true, "studio": true, "trailer": true, - "tvchannel": true, "tvprogram": true, "userrootfolder": true, "userview": true, "video": true, "year": true, -} - -// knownItemKinds drops IncludeItemTypes entries that aren't BaseItemKind names, as Jellyfin's binder -// does, so an all-unknown list (JellyBox sends "music") behaves like an absent one. -func knownItemKinds(types string) string { - var known []string - for t := range strings.SplitSeq(types, ",") { - if t = strings.TrimSpace(t); jellyfinItemKinds[strings.ToLower(t)] { - known = append(known, t) - } - } - return strings.Join(known, ",") -} - -// parseTypes returns the supported entries in IncludeItemTypes in order. Only an absent param -// defaults to albums, so ParentId=<artistId> still browses that artist's albums. -func parseTypes(types string) []string { - if strings.TrimSpace(types) == "" { - return []string{"MusicAlbum"} - } - var recognized []string - for t := range strings.SplitSeq(types, ",") { - if name, ok := supportedTypes[strings.ToLower(strings.TrimSpace(t))]; ok { - recognized = append(recognized, name) - } - } - // Dedupe: a repeated type would duplicate items in the merge and spawn a redundant query. - return slice.Unique(recognized) -} - -// paginate applies StartIndex/Limit to an in-memory item list, for the multi-type merge path only -// (single-type queries push Offset/Max down to SQL instead). -func paginate(items []dto.BaseItemDto, offset, limit int) []dto.BaseItemDto { - if offset >= len(items) { - return []dto.BaseItemDto{} - } - items = items[offset:] - if limit > 0 && limit < len(items) { - items = items[:limit] - } - return items -} - -// interleave merges per-type item lists round-robin: one item from each list in turn, preserving -// each list's own order, so no single type dominates the head of a mixed-type result. -func interleave(lists [][]dto.BaseItemDto) []dto.BaseItemDto { - total, maxLen := 0, 0 - for _, l := range lists { - total += len(l) - maxLen = max(maxLen, len(l)) - } - out := make([]dto.BaseItemDto, 0, total) - for i := 0; i < maxLen; i++ { - for _, l := range lists { - if i < len(l) { - out = append(out, l[i]) - } - } - } - return out -} - -// Search can't stream (Search returns a slice), so it needs both a default and a ceiling: without -// the ceiling, Limit=999999 still materializes every match. -const ( - defaultSearchLimit = 100 - maxSearchLimit = 2000 -) - -// clampLimit bounds a client-supplied limit, 0 or less meaning it sent none, so it can't drive an -// oversized allocation or provider fetch (flagged by CodeQL as a user-controlled allocation size). -// -// Searches clamp their Limit here rather than in searchPage, which also sees mergeTypes' larger -// offset+limit window: bounding that would truncate each type before the merged page is cut. -func clampLimit(limit, def, ceiling int) int { - if limit <= 0 { - return def - } - return min(limit, ceiling) -} - -// searchPage runs a repository Search fetching one extra row to derive TotalRecordCount, since the -// Search API returns no match count and CountAll can't see the search term. offset+len(rows) is -// exact once matches end (and a growing lower bound before), so paging terminates at the last match. -func searchPage[S ~[]E, E any](opts model.QueryOptions, search func(model.QueryOptions) (S, error)) (S, int, error) { - fetch := opts - fetch.Max++ - rows, err := search(fetch) - if err != nil { - return nil, 0, err - } - total := opts.Offset + len(rows) - if len(rows) > opts.Max { - rows = rows[:opts.Max] - } - return rows, total, nil -} - -func (api *Router) listAlbums(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { - toItem := func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, q.fields) } - repo := api.ds.Album() - filters := squirrel.And{} - // For albums, ParentId (browse an artist) and AlbumArtistIds/ArtistIds both mean "this artist's - // albums"; contributingArtistIds means "albums they only appear on" (Featured On). - switch { - case q.contributingOnly && q.artistId != "": - filters = append(filters, filter.AlbumsByContributingArtistID(q.artistId).Filters) - case firstNonEmpty(q.artistId, q.entityParent) != "": - filters = append(filters, filter.AlbumsByArtistID(firstNonEmpty(q.artistId, q.entityParent)).Filters) - default: - filters = append(filters, notMissing) - } - if len(q.genreIds) > 0 { - filters = append(filters, filter.AlbumsByGenreID(q.genreIds)) - } - if len(q.years) > 0 { - filters = append(filters, filter.AlbumsByYears(q.years)) - } - if len(q.studioIds) > 0 { - filters = append(filters, filter.ByStudioID(q.studioIds)) - } - // Not on the search path: its first FTS phase selects rowids with no annotation join, so a - // starred/play_count predicate there is "no such column" rather than a filter. - if q.search == "" { - filters = append(filters, q.filters.predicates()...) - } - opts.Filters = filters - opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) - - if q.search != "" { - albums, total, err := searchPage(opts, func(o model.QueryOptions) (model.Albums, error) { - return repo.Search(ctx, q.search, o) - }) - if err != nil { - return itemsResult{}, err - } - return materialized(result(slice.Map(albums, toItem), total, opts.Offset)), nil - } - total, _ := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters}) - open := streamCursor(func() (func(func(model.Album, error) bool), error) { - return repo.GetCursor(ctx, opts) - }, toItem) - return streamed(open, int(total), opts.Offset), nil -} - -func (api *Router) listSongs(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { - toItem := func(mf model.MediaFile) dto.BaseItemDto { return dto.SongToBaseItem(mf, q.fields) } - repo := api.ds.MediaFile() - filters := squirrel.And{} - // For songs, ArtistIds/AlbumArtistIds selects an artist's tracks; ParentId selects an album's. - switch { - case q.artistId != "": - filters = append(filters, filter.SongsByArtistID(q.artistId).Filters) - case q.entityParent != "": - filters = append(filters, filter.SongsByAlbum(q.entityParent).Filters) - default: - filters = append(filters, notMissing) - } - if len(q.albumIds) > 0 { - filters = append(filters, filter.ByAlbumID(q.albumIds)) - } - if len(q.genreIds) > 0 { - filters = append(filters, filter.SongsByGenreID(q.genreIds)) - } - if len(q.years) > 0 { - filters = append(filters, filter.SongsByYears(q.years)) - } - if len(q.studioIds) > 0 { - filters = append(filters, filter.ByStudioID(q.studioIds)) - } - // Not on the search path: its first FTS phase selects rowids with no annotation join, so a - // starred/play_count predicate there is "no such column" rather than a filter. - if q.search == "" { - filters = append(filters, q.filters.predicates()...) - } - opts.Filters = filters - opts = filter.ApplyLibraryFilter(opts, q.scopeIDs) - - if q.search != "" { - mfs, total, err := searchPage(opts, func(o model.QueryOptions) (model.MediaFiles, error) { - return repo.Search(ctx, q.search, o) - }) - if err != nil { - return itemsResult{}, err - } - return materialized(result(slice.Map(mfs, toItem), total, opts.Offset)), nil - } - // When browsing an album's tracks, default to disc+track order (like Subsonic's GetAlbum); an - // explicit client SortBy still wins, since applySort already set opts.Sort. - if q.artistId == "" && q.entityParent != "" && opts.Sort == "" { - opts.Sort = filter.SongsByAlbum(q.entityParent).Sort - } - // A full-library request (Finamp's sync, with MediaSources) is tens of thousands of fat rows. - total, _ := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters}) - open := streamCursor(func() (func(func(model.MediaFile, error) bool), error) { - return repo.GetCursorWithArtwork(ctx, opts) - }, toItem) - return streamed(open, int(total), opts.Offset), nil -} - -// listArtists lists artists in the given role: RoleAlbumArtist for the "album artists" views, -// RoleArtist for performing artists (/Artists). Without the role filter both lists would be identical. -// genreIds isn't applied to search — a name lookup, like role (see below). -func (api *Router) listArtists(ctx context.Context, opts model.QueryOptions, q itemsQuery, role model.Role) (itemsResult, error) { - repo := api.ds.Artist() - toItem := func(ar model.Artist) dto.BaseItemDto { return dto.ArtistToBaseItem(ar, q.fields) } - - // Artist Search does its own library scoping: it consumes a sole Eq{"library_id": ...} filter as a - // search scope (artists have no library_id column). A compound or join-based filter - // (ApplyArtistLibraryFilter) would leak into the FTS query and 500, so search and browse build - // filters differently. Role isn't applied to search for the same reason — it's a name lookup. - if q.search != "" { - if len(q.scopeIDs) > 0 { - opts.Filters = squirrel.Eq{"library_id": q.scopeIDs} - } - artists, total, err := searchPage(opts, func(o model.QueryOptions) (model.Artists, error) { - return repo.Search(ctx, q.search, o) - }) - if err != nil { - return itemsResult{}, err - } - return materialized(result(slice.Map(artists, toItem), total, opts.Offset)), nil - } - - filters := squirrel.And{notMissing} - filters = append(filters, q.filters.predicates()...) - if len(q.genreIds) > 0 { - filters = append(filters, filter.ArtistsByGenreID(q.genreIds)) - } - opts.Filters = filters - opts = filter.ArtistsByRole(opts, role) - opts = filter.ApplyArtistLibraryFilter(opts, q.scopeIDs) - total, _ := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters}) - open := streamCursor(func() (func(func(model.Artist, error) bool), error) { - return repo.GetCursor(ctx, opts) - }, toItem) - return streamed(open, int(total), opts.Offset), nil -} - -// listGenres is intentionally unscoped: genres are global tags, not per-library entities. It's also -// the one listXxx that stays materialized: GenreRepository has no CountAll, so the total is the -// length of the full list and paging is in-memory — nothing for a cursor to page over. -func (api *Router) listGenres(ctx context.Context, opts model.QueryOptions) (itemsResult, error) { - genres, err := api.ds.Genre().GetAll(ctx, model.QueryOptions{Sort: opts.Sort, Order: opts.Order}) - if err != nil { - return itemsResult{}, err - } - items := slice.Map(genres, dto.GenreToBaseItem) - return materialized(result(paginate(items, opts.Offset, opts.Max), len(items), opts.Offset)), nil -} - -// listPlaylists lists playlists visible to the current user. Visibility (public or owned) is -// enforced by playlistRepository, not scopeIDs. -func (api *Router) listPlaylists(ctx context.Context, opts model.QueryOptions, q itemsQuery) (itemsResult, error) { - if preds := q.filters.predicates(); len(preds) > 0 { - opts.Filters = squirrel.And(preds) - } - repo := api.ds.Playlist() - total, err := repo.CountAll(ctx, model.QueryOptions{Filters: opts.Filters}) - if err != nil { - return itemsResult{}, err - } - open := streamCursor(func() (func(func(model.Playlist, error) bool), error) { - return repo.GetCursor(ctx, opts) - }, func(p model.Playlist) dto.BaseItemDto { return dto.PlaylistToBaseItem(p, q.fields) }) - return streamed(open, int(total), opts.Offset), nil -} - -// resolveItemByID resolves a decoded navidrome id to its BaseItemDto, trying library view, album, -// artist, song, playlist and genre in turn. Albums and songs report not-found when the user lacks access -// to their library, so an id can't probe content outside the user's libraries. -func (api *Router) resolveItemByID(ctx context.Context, id string, fields dto.Fields) (dto.BaseItemDto, bool) { - // The synthetic playlists folder must resolve by the id we advertised, not 404. - if id == dto.PlaylistsFolderID { - return playlistsFolder(), true - } - u, _ := request.UserFrom(ctx) - // Finamp resolves a /UserViews entry (Id=library id) by fetching it as a plain item; without this - // the home screen and library tabs 404. - if libID, err := strconv.Atoi(id); err == nil && u.HasLibraryAccess(libID) { - if lib, err := api.ds.Library().Get(ctx, libID); err == nil { - return dto.LibraryToBaseItem(*lib), true - } - } - if al, err := api.ds.Album().Get(ctx, id); err == nil { - if !u.HasLibraryAccess(al.LibraryID) { - return dto.BaseItemDto{}, false - } - return dto.AlbumToBaseItem(*al, fields), true - } - if ar, err := api.ds.Artist().Get(ctx, id); err == nil { - // Artist.Get already scopes to the user's libraries via library_artist. - return dto.ArtistToBaseItem(*ar, fields), true - } - if mf, err := api.ds.MediaFile().Get(ctx, id); err == nil { - if !u.HasLibraryAccess(mf.LibraryID) { - return dto.BaseItemDto{}, false - } - return dto.SongToBaseItem(*mf, fields), true - } - // api.playlists.Get enforces ownership/visibility, so a non-owned or missing id falls through. - if pl, err := api.playlists.Get(ctx, id); err == nil { - return dto.PlaylistToBaseItem(*pl, fields), true - } - if g, err := api.ds.Genre().Get(ctx, id); err == nil { - return dto.GenreToBaseItem(*g), true - } - return dto.BaseItemDto{}, false -} - -// songsByIDs fetches the media files among ids with chunked IN queries instead of a Get per id. -func (api *Router) songsByIDs(ctx context.Context, ids []string) map[string]model.MediaFile { - songs := make(map[string]model.MediaFile, len(ids)) - // Chunked to stay under SQLITE_MAX_VARIABLE_NUMBER, like playqueue's loadTracks. - for chunk := range slice.CollectChunks(slices.Values(ids), 500) { - mfs, err := api.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"media_file.id": chunk}}) - if err != nil { - log.Error(ctx, "Jellyfin API: error fetching songs by id", err) - continue - } - for _, mf := range mfs { - songs[mf.ID] = mf - } - } - return songs -} - -// itemsByIDs resolves a decoded id list, keeping input order and skipping unresolvable ids. -func (api *Router) itemsByIDs(ctx context.Context, ids []string, fields dto.Fields) dto.QueryResult { - u, _ := request.UserFrom(ctx) - songs := api.songsByIDs(ctx, ids) - var items []dto.BaseItemDto - for _, id := range ids { - var item dto.BaseItemDto - if mf, ok := songs[id]; ok { - if !u.HasLibraryAccess(mf.LibraryID) { - continue - } - item = dto.SongToBaseItem(mf, fields) - } else if item, ok = api.resolveItemByID(ctx, id, fields); !ok { - continue - } - items = append(items, item) - } - return result(items, len(items), 0) -} - -func (api *Router) getItem(w http.ResponseWriter, r *http.Request) { - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - fields := dto.ParseFields(req.Params(r).Strings("fields")...) - if item, ok := api.resolveItemByID(r.Context(), id, fields); ok { - api.ok(w, r, item) - return - } - http.Error(w, "Not Found", http.StatusNotFound) -} - -// deleteItem handles DELETE /Items/{id}. Only playlists are deletable here (albums/songs come from -// scanning), so a non-playlist id 404s. core/playlists.Delete enforces ownership. -func (api *Router) deleteItem(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - if err := api.playlists.Delete(ctx, id); err != nil { - api.playlistError(w, r, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// getLatest returns a bare array, not a QueryResult envelope — real Jellyfin's shape for -// /Items/Latest, and why it writes directly instead of going through api.ok. -func (api *Router) getLatest(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - p := req.Params(r) - fields := dto.ParseFields(p.Strings("fields")...) - opts := filter.AlbumsByNewest() - opts.Max = p.IntOr("limit", 20) - parentID, ok := decodeFilterParam(p.StringOr("parentid", "")) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - // A ParentId naming neither a library nor an artist (a stale id, an album) narrows to nothing - // rather than widening back to every library. - scopeIDs, isLibrary := resolveLibraryScope(ctx, parentID) - if parentID != "" && !isLibrary { - opts.Filters = squirrel.And{opts.Filters, filter.AlbumsByArtistID(parentID).Filters} - } - opts = filter.ApplyLibraryFilter(opts, scopeIDs) - repo := api.ds.Album() - open := streamCursor(func() (func(func(model.Album, error) bool), error) { - return repo.GetCursor(ctx, opts) - }, func(al model.Album) dto.BaseItemDto { return dto.AlbumToBaseItem(al, fields) }) - api.writeItemsArray(w, r, streamed(open, 0, 0)) -} - -func result(items []dto.BaseItemDto, total, start int) dto.QueryResult { - if items == nil { - items = []dto.BaseItemDto{} - } - return dto.QueryResult{Items: items, TotalRecordCount: total, StartIndex: start} -} - -// applySort keeps every recognized SortBy key, so secondary keys break ties as Jellyfin intends. -// Unrecognized keys are skipped, not passed through raw where they could make an invalid ORDER BY. -func applySort(opts *model.QueryOptions, itemType, sortBy, order string) { - var cols []string - for key := range strings.SplitSeq(sortBy, ",") { - col, ok := sortColumn(itemType, strings.TrimSpace(key)) - // The repo matches random by exact string equality, so it can only ever sort alone. - if !ok || slices.Contains(cols, col) || (col == "random" && len(cols) > 0) { - continue - } - cols = append(cols, col) - if col == "random" { - break - } - } - switch { - case len(cols) > 0: - opts.Sort = strings.Join(cols, ", ") - case sortBy != "": - log.Debug("Jellyfin API: no usable SortBy key, falling back to the default order", - "itemType", itemType, "sortBy", sortBy) - } - // Jellyfin allows a per-key SortOrder list, which one Order can't express; honor the first value - // for every key, as Jellyfin does for keys past the end of the list. - first, _, _ := strings.Cut(order, ",") - if strings.EqualFold(first, "Descending") { - opts.Order = "desc" - } -} - -// sortColumnsByType maps lowercased-SortBy -> repo-sort-key per item type (repos map logical fields -// to different real columns, e.g. media_file has "title" not "name"). -var sortColumnsByType = map[string]map[string]string{ - "Audio": { - "sortname": "title", "name": "title", - "album": "album", - // Finamp's album view sorts by ParentIndexNumber,IndexNumber (disc, track); Navidrome's - // "album" sort key is disc+track order within an album, so map both to it. - "indexnumber": "album", - "parentindexnumber": "album", - "artist": "artist", - "albumartist": "album_artist", - "datecreated": "recently_added", - "playcount": "play_count", - "dateplayed": "play_date", - "communityrating": "rating", - "random": "random", - "runtime": "duration", - "runtimeticks": "duration", - // Finamp's "Latest Releases" sorts by PremiereDate; "year" matches songs' ProductionYear. - "premieredate": "year", - "productionyear": "year", - }, - "MusicArtist": { - "sortname": "name", "name": "name", - "albumcount": "album_count", - "songcount": "song_count", - "datecreated": "created_at", - "playcount": "play_count", - "dateplayed": "play_date", - "communityrating": "rating", - "random": "random", - }, - "MusicAlbum": { - "sortname": "name", "name": "name", "album": "name", - "artist": "artist", - "albumartist": "album_artist", - "datecreated": "recently_added", - "random": "random", - "playcount": "play_count", - "dateplayed": "play_date", - "communityrating": "rating", - "runtime": "duration", - "runtimeticks": "duration", - "premieredate": "max_year", "productionyear": "max_year", - }, - "MusicGenre": { - "sortname": "name", "name": "name", - "random": "random", - }, - "Playlist": { - "sortname": "name", "name": "name", - "datecreated": "created_at", - "random": "random", - }, -} - -// sortColumn maps a single (non comma-list) Jellyfin SortBy key to the repo sort key for -// itemType, reporting false when it isn't recognized for that type. -func sortColumn(itemType, sortBy string) (string, bool) { - col, ok := sortColumnsByType[itemType][strings.ToLower(sortBy)] - return col, ok -} diff --git a/server/jellyfin/items_test.go b/server/jellyfin/items_test.go deleted file mode 100644 index 18789a34b..000000000 --- a/server/jellyfin/items_test.go +++ /dev/null @@ -1,1203 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "slices" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// withChiURLParam simulates chi's routing having captured a path parameter, since these -// tests call handlers directly instead of going through the full router. -func withChiURLParam(r *http.Request, key, value string) *http.Request { - rctx := chi.NewRouteContext() - rctx.URLParams.Add(key, value) - return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) -} - -var _ = Describe("Items", func() { - var api *Router - var ds *tests.MockDataStore - var fp *fakePlaylists - // alice has access to library 1 only; used by tests that don't care about scoping. - ctxUser := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) - } - ctxUserWithLibraries := func(libs model.Libraries) context.Context { - return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: libs}) - } - // admin has no explicit Libraries; access is granted via the IsAdmin bypass, not membership. - ctxAdmin := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: testID("admin"), IsAdmin: true, Libraries: nil}) - } - BeforeEach(func() { - ds = &tests.MockDataStore{} - fp = &fakePlaylists{} - api = &Router{ds: ds, playlists: fp} - }) - - Describe("getItems", func() { - It("lists albums when IncludeItemTypes=MusicAlbum", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&Recursive=true", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - Expect(res.Items[0].Type).To(Equal("MusicAlbum")) - Expect(res.TotalRecordCount).To(Equal(2)) - }) - - It("lists an album's songs when ParentId is an album and type is Audio", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Type).To(Equal("Audio")) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - }) - - It("ignores IncludeItemTypes names that aren't Jellyfin item kinds", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", AlbumID: testID("a1")}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=music", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - }) - - It("lists a playlist's tracks when ParentId is a playlist, whatever the type", func() { - fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, - {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, - }} - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("pl1"))+"&IncludeItemTypes=Audio", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("1"))) - Expect(res.TotalRecordCount).To(Equal(2)) - }) - - It("pages a playlist parent's tracks in the query, not in memory", func() { - fp.getPls = &model.Playlist{ID: testID("pl1"), Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, - {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, - {ID: "3", MediaFileID: testID("s3"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s3")}}, - }} - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("pl1"))+"&StartIndex=1&Limit=1", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.TotalRecordCount).To(Equal(3)) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s2")))) - Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) - Expect(fp.tracksRepo.Options.Max).To(Equal(1)) - }) - - It("falls through to the type dispatch when ParentId is not a playlist", func() { - fp.getErr = model.ErrNotFound - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - }) - - It("returns 500 when the song cursor fails to open, instead of a truncated 200", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetError(true) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=true", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - - // Recursive=false asks for direct children only. Finamp's sync probes a library this way - // looking for tracks outside any album; answering with every track streams the whole library. - Describe("Recursive=false", func() { - BeforeEach(func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), AlbumID: testID("a1")}}) - }) - - It("returns no songs for a library parent, as tracks are never its direct children", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio&Recursive=false", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(BeEmpty()) - Expect(res.TotalRecordCount).To(BeZero()) - }) - - It("drops only Audio from a multi-type library query", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio,MusicAlbum&Recursive=false", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Type).To(Equal("MusicAlbum")) - }) - - It("still lists albums for a library parent, as they are its direct children", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=MusicAlbum&Recursive=false", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - }) - - It("still lists an album's tracks, as they are its direct children", func() { - fp.getErr = model.ErrNotFound - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("a1"))+"&IncludeItemTypes=Audio&Recursive=false", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - }) - - It("keeps returning every song when no parent scopes the query", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&Recursive=false", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - }) - - // Jellyfin's own default: ItemsController binds `bool? recursive` and reads it as - // `recursive ?? false`, so an omitted Recursive is a non-recursive request. - It("treats an omitted Recursive as false, like Jellyfin", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(1)+"&IncludeItemTypes=Audio", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(BeEmpty()) - }) - }) - - It("lists an artist's albums when ParentId is an artist and type is MusicAlbum", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", AlbumArtistID: testID("ar1")}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeID(testID("ar1"))+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - albumRepo := ds.Album().(*tests.MockAlbumRepo) - sql, _, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("album_artists")) - }) - - It("lists artists when IncludeItemTypes=MusicArtist", func() { - ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Type).To(Equal("MusicArtist")) - }) - - It("lists genres when IncludeItemTypes=MusicGenre", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicGenre", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).NotTo(BeNil()) - }) - - It("lists playlists when IncludeItemTypes=Playlist", func() { - ds.Playlist().(*tests.MockPlaylistRepo).SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", SongCount: 5}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Playlist", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Type).To(Equal("Playlist")) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("p1")))) - Expect(res.TotalRecordCount).To(Equal(1)) - }) - - It("merges results from every requested type in IncludeItemTypes", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - types := []string{res.Items[0].Type, res.Items[1].Type} - Expect(types).To(ConsistOf("Audio", "MusicAlbum")) - Expect(res.TotalRecordCount).To(Equal(2)) - }) - - It("merges favorite songs, albums, and playlists", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - playlistRepo := ds.Playlist().(*tests.MockPlaylistRepo) - playlistRepo.SetData(model.Playlists{{ID: testID("p1"), Name: "My Mix", Annotations: model.Annotations{Starred: true}}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum,Playlist&Filters=IsFavorite", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(3)) - types := []string{res.Items[0].Type, res.Items[1].Type} - types = append(types, res.Items[2].Type) - Expect(types).To(ConsistOf("Audio", "MusicAlbum", "Playlist")) - sql, _, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("starred")) - playlistSQL, _, err := playlistRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(playlistSQL).To(ContainSubstring("starred")) - }) - - It("applies StartIndex/Limit to the merged multi-type result set", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}}) - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - Expect(res.TotalRecordCount).To(Equal(4)) - Expect(res.StartIndex).To(Equal(1)) - }) - - It("caps each per-type query at StartIndex+Limit instead of fetching everything", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}, {ID: testID("s2"), Title: "Song2"}}) - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&StartIndex=1&Limit=2", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - // The merged window is [1, 3): each type needs at most its first 3 rows, not the table. - Expect(mfRepo.Options.Max).To(Equal(3)) - Expect(albumRepo.Options.Max).To(Equal(3)) - }) - - DescribeTable("translates the Filters list and its standalone equivalents", - func(query string, wantSQL, notWantSQL []string) { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&"+query, nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, _, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - for _, want := range wantSQL { - Expect(sql).To(ContainSubstring(want)) - } - for _, not := range notWantSQL { - Expect(sql).NotTo(ContainSubstring(not)) - } - }, - Entry("IsFavorite", "Filters=IsFavorite", []string{"starred"}, nil), - Entry("IsFavorite,IsUnplayed combined", "Filters=IsFavorite,IsUnplayed", - []string{"starred", "play_count"}, nil), - Entry("IsUnplayed", "Filters=IsUnplayed", []string{"play_count"}, []string{"starred"}), - Entry("IsPlayed", "Filters=IsPlayed", []string{"play_count"}, []string{"starred"}), - Entry("IsFavoriteOrLikes is treated as favorites", "Filters=IsFavoriteOrLikes", []string{"starred"}, nil), - Entry("isPlayed=false", "isPlayed=false", []string{"play_count"}, nil), - Entry("isFavorite=false still filters", "isFavorite=false", []string{"starred"}, nil), - // Jellyfin builds the query from the standalone params, then applies Filters over the top. - Entry("Filters wins over the standalone param", "isFavorite=false&Filters=IsFavorite", - []string{"starred = "}, nil), - // No Navidrome equivalent: these must be dropped, not half-applied. - Entry("Likes is ignored", "Filters=Likes", nil, []string{"starred", "play_count"}), - Entry("IsResumable is ignored", "Filters=IsResumable", nil, []string{"starred", "play_count"}), - // The artist-parent branch gets notMissing from filter.AlbumsByArtistID, not the default - // branch, so favorites must not be the only predicate left on it. - Entry("keeps missing excluded under an artist parent", - "Filters=IsFavorite&ArtistIds="+dto.EncodeID(testID("ar1")), - []string{"starred", "missing"}, nil), - ) - - // Search runs a two-phase FTS query whose first phase has no annotation join, so an - // annotation predicate there is "no such column: starred" -> 500. - DescribeTable("does not push annotation filters into a search", - func(itemType, filters string) { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", - "/Items?IncludeItemTypes="+itemType+"&SearchTerm=one&Filters="+filters, nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var opts model.QueryOptions - if itemType == "MusicAlbum" { - opts = ds.Album().(*tests.MockAlbumRepo).Options - } else { - opts = ds.MediaFile().(*tests.MockMediaFileRepo).Options - } - if opts.Filters == nil { - return - } - sql, _, err := opts.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).NotTo(ContainSubstring("starred")) - Expect(sql).NotTo(ContainSubstring("play_count")) - }, - Entry("albums, IsFavorite", "MusicAlbum", "IsFavorite"), - Entry("albums, IsUnplayed", "MusicAlbum", "IsUnplayed"), - Entry("albums, IsPlayed", "MusicAlbum", "IsPlayed"), - Entry("songs, IsFavorite", "Audio", "IsFavorite"), - Entry("songs, IsUnplayed", "Audio", "IsUnplayed"), - ) - - It("forwards SearchTerm to the repo's Search method", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - }) - - It("caps a search the client left unbounded", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Max).To(Equal(defaultSearchLimit + 1)) - }) - - It("honors an explicit search Limit up to the ceiling", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=500", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Max).To(Equal(501)) - }) - - It("clamps a search Limit that would materialize the library", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=one&Limit=999999", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1)) - }) - - It("treats an all-whitespace SearchTerm as no search, streaming the unfiltered list", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&SearchTerm=%20%20", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - Expect(albumRepo.SearchQuery).To(BeEmpty()) - }) - - It("reports a multi-type search total past the page, so clients keep paging", func() { - songs := make(model.MediaFiles, defaultSearchLimit*2) - for i := range songs { - songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} - } - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&Limit=10", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(10)) - Expect(res.TotalRecordCount).To(BeNumerically(">", 10)) - }) - - It("bounds the multi-type search window however large StartIndex is", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=500000&Limit=1", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - // Without the bound this asks each type for ~500001 rows. - Expect(albumRepo.Options.Max).To(Equal(maxSearchLimit + 1)) - }) - - It("stops a multi-type search at the ceiling rather than serving another type's rows", func() { - // Bounding the per-type window is what keeps StartIndex from driving it without limit, and - // past that window the merged order is no longer the true one. - songs := make(model.MediaFiles, maxSearchLimit+1) - for i := range songs { - songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} - } - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", - fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=1", maxSearchLimit), - nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(BeEmpty()) - Expect(res.TotalRecordCount).To(Equal(maxSearchLimit)) - }) - - It("serves the last page below the ceiling in full", func() { - songs := make(model.MediaFiles, maxSearchLimit+1) - for i := range songs { - songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} - } - // The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold. - slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) }) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", - fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d&Limit=10", maxSearchLimit-1), - nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - // Clipped to the window; the interleaved album takes one slot, shifting this song in by one. - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[maxSearchLimit-2].ID))) - }) - - It("bounds an unbounded multi-type search to the default in total, not per type", func() { - songs := make(model.MediaFiles, defaultSearchLimit*2) - for i := range songs { - songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} - } - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song", nil). - WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(defaultSearchLimit)) - }) - - It("pages an unbounded multi-type search past the default without dropping matches", func() { - songs := make(model.MediaFiles, defaultSearchLimit*2) - for i := range songs { - songs[i] = model.MediaFile{ID: testID(fmt.Sprintf("s%05d", i)), Title: "Song"} - } - // The mock repo returns rows sorted by ID; reorder to match so index-based assertions hold. - slices.SortFunc(songs, func(a, b model.MediaFile) int { return strings.Compare(a.ID, b.ID) }) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", - fmt.Sprintf("/Items?IncludeItemTypes=Audio,MusicAlbum&SearchTerm=song&StartIndex=%d", defaultSearchLimit+50), - nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).ToNot(BeEmpty()) - // The interleaved album takes one slot ahead of it, shifting this song in by one. - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(songs[defaultSearchLimit+49].ID))) - }) - - It("reports a search total beyond the fetched page instead of the page length", func() { - ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{ - {ID: testID("r1"), Name: "Alpha"}, {ID: testID("r2"), Name: "Beta"}, {ID: testID("r3"), Name: "Gamma"}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist&SearchTerm=a&Limit=1", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.TotalRecordCount).To(Equal(3)) - }) - - It("forwards StartIndex/Limit as Offset/Max", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&StartIndex=5&Limit=10", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Offset).To(Equal(5)) - Expect(albumRepo.Options.Max).To(Equal(10)) - }) - - Describe("Ids batch-fetch", func() { - // Finamp's download/sync fetches a track's BaseItemDto via /Items?ids=<id>; without - // this, queryItems ignored Ids and returned the default type-dispatched list instead. - It("returns exactly the requested item when Ids has a single id", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - Expect(res.Items[0].Name).To(Equal("Song")) - Expect(res.TotalRecordCount).To(Equal(1)) - }) - - It("returns items of different types for a lowercase ids param with multiple ids", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - ids := []string{res.Items[0].Id, res.Items[1].Id} - Expect(ids).To(ConsistOf(dto.EncodeID(testID("a1")), dto.EncodeID(testID("s1")))) - types := []string{res.Items[0].Type, res.Items[1].Type} - Expect(types).To(ConsistOf("MusicAlbum", "Audio")) - Expect(res.TotalRecordCount).To(Equal(2)) - }) - - It("resolves song ids with one batched IN query, not a Get per id", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}, {ID: testID("s2"), Title: "Song2", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - sql, args, err := mfRepo.Options.Filters.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(sql).To(ContainSubstring("media_file.id IN")) - Expect(args).To(ConsistOf(testID("s1"), testID("s2"))) - }) - - It("omits an id in a library the user can't access, without erroring the whole batch", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) // alice only has access to library 1 - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?Ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("a1")))) - Expect(res.TotalRecordCount).To(Equal(1)) - }) - }) - - Describe("sorting", func() { - DescribeTable("translates SortBy into the repo's sort keys", - func(itemType, sortBy, want string) { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes="+itemType+"&SortBy="+sortBy, nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - got := mfRepo.Options.Sort - if itemType == "MusicAlbum" { - got = albumRepo.Options.Sort - } - Expect(got).To(Equal(want)) - }, - Entry("PlayCount", "MusicAlbum", "PlayCount", "play_count"), - Entry("DatePlayed", "Audio", "DatePlayed", "play_date"), - Entry("Runtime on albums", "MusicAlbum", "Runtime", "duration"), - Entry("RunTimeTicks alias", "MusicAlbum", "RunTimeTicks", "duration"), - // Finamp leads its track sort with Runtime: unless that resolves, the first recognized - // key is AlbumArtist and the list looks sorted while being sorted by the wrong thing. - Entry("Finamp's Runtime-led track sort", "Audio", "Runtime,AlbumArtist,Album,SortName", - "duration, album_artist, album, title"), - Entry("every recognized key, in order", "MusicAlbum", "DateCreated,SortName", "recently_added, name"), - Entry("a key repeating a column is dropped", "Audio", - "PremiereDate,Album,ParentIndexNumber,IndexNumber,SortName", "year, album, title"), - // random is matched by exact string equality in the repo, so it can never share a sort. - Entry("Random stays alone", "MusicAlbum", "Random,SortName", "random"), - Entry("unrecognized keys are skipped", "Audio", "Runtime,Nonsense,SortName", "duration, title"), - Entry("only the last key recognized", "Audio", "Unknown1,Unknown2,SortName", "title"), - Entry("Finamp's album view is disc+track", "Audio", "ParentIndexNumber,IndexNumber,SortName", "album, title"), - Entry("nothing recognized leaves the repo default", "MusicAlbum", "SeriesSortName", ""), - ) - - // Jellyfin allows a per-key SortOrder list; we cannot express that through one Order, so - // we honor the first value for all keys, matching Jellyfin's fallback for extra keys. - DescribeTable("reads the first SortOrder value for the whole sort", - func(sortOrder, want string) { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", - "/Items?IncludeItemTypes=MusicAlbum&SortBy=Runtime,SortName&SortOrder="+sortOrder, nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(albumRepo.Options.Order).To(Equal(want)) - }, - Entry("ascending", "Ascending", ""), - Entry("descending", "Descending", "desc"), - Entry("descending leading a list", "Descending,Ascending", "desc"), - Entry("ascending leading a list", "Ascending,Descending", ""), - ) - }) - - Describe("library scoping", func() { - It("scopes a MusicAlbum listing (no ParentId) to the user's accessible libraries", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_id")) - Expect(args).To(ContainElements(1, 2)) - }) - - It("scopes a Audio listing (no ParentId) to the user's accessible libraries", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio", nil).WithContext(ctxUserWithLibraries(libs)) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := mfRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_id")) - Expect(args).To(ContainElements(1, 2)) - }) - - It("scopes a MusicArtist listing to the user's accessible libraries", func() { - artistRepo := ds.Artist().(*tests.MockArtistRepo) - artistRepo.SetData(model.Artists{{ID: testID("ar1"), Name: "Artist"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicArtist", nil).WithContext(ctxUserWithLibraries(libs)) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := artistRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_artist.library_id")) - Expect(args).To(ContainElements(1, 2)) - }) - - It("treats a numeric ParentId matching an accessible library as a library scope, not an artist id", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(2)+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).NotTo(ContainSubstring("album_artists")) // not treated as an artist-parent filter - Expect(sql).To(ContainSubstring("library_id")) - Expect(args).To(ContainElement(2)) - }) - - It("does not let ParentId=<inaccessible library id> scope results to that library", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}} // no access to library 99 - r := httptest.NewRequest("GET", "/Items?ParentId="+dto.EncodeLibraryID(99)+"&IncludeItemTypes=MusicAlbum", nil).WithContext(ctxUserWithLibraries(libs)) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - // Falls back to treating "99" as an (empty-matching) artist-parent id... - Expect(sql).To(ContainSubstring("album_artists")) - // ...while still scoping to the user's own accessible libraries. - Expect(sql).To(ContainSubstring("library_id")) - Expect(args).To(ContainElement(1)) - Expect(args).NotTo(ContainElement(99)) - }) - - It("does not restrict a default MusicAlbum listing for an admin user", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}, {ID: testID("a2"), Name: "Two", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum", nil).WithContext(ctxAdmin()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - // accessibleLibraryIDs is empty for an admin (Libraries is nil), so - // ApplyLibraryFilter([]) is a no-op: no library_id restriction is added. - if albumRepo.Options.Filters == nil { - return - } - sql, _, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).NotTo(ContainSubstring("library_id")) - }) - }) - - // A malformed id must 404, not silently drop the filter; a well-formed but unknown one must - // still reach the entity filter, not the unfiltered default. - Describe("stale and malformed id filtering", func() { - It("404s a malformed ParentId instead of listing every song", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId=not-a-valid-id", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("404s a malformed AlbumArtistIds instead of listing every album", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds=not-a-valid-id", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("404s a malformed ArtistIds instead of listing every song", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ArtistIds=not-a-valid-id", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("still applies the artist filter (rather than dropping it) for a well-formed but unknown AlbumArtistIds", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=MusicAlbum&AlbumArtistIds="+dto.EncodeID(testID("no-such-artist")), nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, _, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("album_artists")) - }) - - It("still applies the album filter (rather than dropping it) for a well-formed but unknown ParentId", func() { - mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio&ParentId="+dto.EncodeID(testID("no-such-album")), nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := mfRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("album_id")) - Expect(args).To(ContainElement(testID("no-such-album"))) - }) - }) - - Describe("mixed IncludeItemTypes merge", func() { - BeforeEach(func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One"}, {ID: testID("a2"), Name: "Two"}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "S1"}, {ID: testID("s2"), Title: "S2"}}) - }) - - It("returns a mix of both types, not all of one", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(4)) - Expect(res.TotalRecordCount).To(Equal(4)) - types := map[string]int{} - for _, it := range res.Items { - types[it.Type]++ - } - Expect(types["Audio"]).To(Equal(2)) - Expect(types["MusicAlbum"]).To(Equal(2)) - }) - - It("interleaves types round-robin (Audio first, per IncludeItemTypes order)", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - got := []string{res.Items[0].Type, res.Items[1].Type, res.Items[2].Type, res.Items[3].Type} - Expect(got).To(Equal([]string{"Audio", "MusicAlbum", "Audio", "MusicAlbum"})) - }) - - It("honors Limit across the merged set", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=1", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.TotalRecordCount).To(Equal(4)) - }) - - It("serves a full random page from offset 0 regardless of StartIndex", func() { - // A deep StartIndex on a random merge must not materialize offset+limit rows; since random - // reshuffles per request, offset 0 is an equivalent fresh draw. Old behavior returned empty. - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&SortBy=Random&Recursive=true&StartIndex=1000&Limit=4", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(4)) - }) - - It("propagates a per-type query error", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetError(true) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items?IncludeItemTypes=Audio,MusicAlbum&Recursive=true&Limit=4", nil).WithContext(ctxUser()) - invoke(api.getItems, w, r) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - }) - - Describe("getItem", func() { - It("returns an album by id", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var item dto.BaseItemDto - Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID(testID("a1")))) - Expect(item.Type).To(Equal("MusicAlbum")) - }) - - It("returns 404 when the id doesn't match any entity", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/missing", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 for an album in a library the user can't access", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 for a song in a library the user can't access", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1")), nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns an album to an admin even when it's outside their (empty) Libraries", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("a1")), nil).WithContext(ctxAdmin()) // admin, Libraries: nil - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var item dto.BaseItemDto - Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID(testID("a1")))) - }) - - // Finamp fetches a /UserViews entry (Id=library id) as a plain item to resolve the - // library node before it can load the home screen or any library tab. - It("resolves a library-view id (from /UserViews) as a CollectionFolder item", func() { - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1, Name: "Music Library"}} - ds.Library().(*tests.MockLibraryRepo).SetData(libs) - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs)) - r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var item dto.BaseItemDto - Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeLibraryID(1))) - Expect(item.Name).To(Equal("Music Library")) - Expect(item.Type).To(Equal("CollectionFolder")) - Expect(item.CollectionType).To(Equal("music")) - Expect(item.IsFolder).To(BeTrue()) - }) - - It("does not resolve a library-view id the user has no access to", func() { - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 2, Name: "Other"}} // no access to library 1 - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxUserWithLibraries(libs)) - r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - // Finamp's SyncBuffer fetches a playlist by id as a plain item; without this probe it - // 404s with "Could not fetch BaseItemDto <playlist> from server." - It("resolves a playlist id via the playlists service", func() { - fp.getByIDPls = &model.Playlist{ID: testID("p1"), Name: "My Mix", SongCount: 5} - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var item dto.BaseItemDto - Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID(testID("p1")))) - Expect(item.Name).To(Equal("My Mix")) - Expect(item.Type).To(Equal("Playlist")) - }) - - It("returns 404 for a non-owned or absent playlist id", func() { - fp.getByIDErr = model.ErrNotFound - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("p1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("p1"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - // Finamp's genre "See all" fetches the genre by id; a 404 white-screens it (see resolveItemByID). - It("resolves a genre id as a MusicGenre item", func() { - Expect(ds.Genre().(*tests.MockedGenreRepo).Put(&model.Genre{ID: testID("g1"), Name: "Rock"})).To(Succeed()) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("g1")), nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("g1"))) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var item dto.BaseItemDto - Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeID(testID("g1")))) - Expect(item.Name).To(Equal("Rock")) - Expect(item.Type).To(Equal("MusicGenre")) - }) - - It("resolves a library-view id for an admin even though their Libraries slice is empty", func() { - ds.Library().(*tests.MockLibraryRepo).SetData(model.Libraries{{ID: 1, Name: "Music Library"}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeLibraryID(1), nil).WithContext(ctxAdmin()) - r = withChiURLParam(r, "itemId", dto.EncodeLibraryID(1)) - invoke(api.getItem, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var item dto.BaseItemDto - Expect(json.Unmarshal(w.Body.Bytes(), &item)).To(Succeed()) - Expect(item.Id).To(Equal(dto.EncodeLibraryID(1))) - Expect(item.Name).To(Equal("Music Library")) - Expect(item.Type).To(Equal("CollectionFolder")) - }) - }) - - Describe("getLatest", func() { - It("returns a bare array of the newest albums", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUser()) - invoke(api.getLatest, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var items []dto.BaseItemDto - Expect(json.Unmarshal(w.Body.Bytes(), &items)).To(Succeed()) - Expect(items).To(HaveLen(1)) - Expect(items[0].Id).To(Equal(dto.EncodeID(testID("a1")))) - }) - - It("scopes to the user's accessible libraries", func() { - albumRepo := ds.Album().(*tests.MockAlbumRepo) - albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 1}}) - w := httptest.NewRecorder() - libs := model.Libraries{{ID: 1}, {ID: 2}} - r := httptest.NewRequest("GET", "/Users/u1/Items/Latest", nil).WithContext(ctxUserWithLibraries(libs)) - invoke(api.getLatest, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - sql, args, err := albumRepo.Options.Filters.ToSql() - Expect(err).NotTo(HaveOccurred()) - Expect(sql).To(ContainSubstring("library_id")) - Expect(args).To(ContainElements(1, 2)) - }) - }) - - Describe("applySort random for all merge types", func() { - DescribeTable("maps Random -> random", - func(itemType string) { - var opts model.QueryOptions - applySort(&opts, itemType, "Random", "") - Expect(opts.Sort).To(Equal("random")) - }, - Entry("Audio", "Audio"), - Entry("MusicAlbum", "MusicAlbum"), - Entry("MusicArtist", "MusicArtist"), - Entry("MusicGenre", "MusicGenre"), - Entry("Playlist", "Playlist"), - ) - }) - - Describe("interleave", func() { - It("round-robins one item per list in turn", func() { - lists := [][]dto.BaseItemDto{ - {{Id: "a0"}, {Id: "a1"}, {Id: "a2"}}, - {{Id: "b0"}, {Id: "b1"}}, - } - got := interleave(lists) - ids := make([]string, len(got)) - for i, it := range got { - ids[i] = it.Id - } - Expect(ids).To(Equal([]string{"a0", "b0", "a1", "b1", "a2"})) - }) - - It("returns empty for no lists", func() { - Expect(interleave(nil)).To(BeEmpty()) - }) - }) - - Describe("parseTypes", func() { - It("dedupes repeated types, preserving first-seen order", func() { - Expect(parseTypes("Audio,MusicAlbum,Audio")).To(Equal([]string{"Audio", "MusicAlbum"})) - }) - - It("matches type names case-insensitively, like Jellyfin's enum binding", func() { - Expect(parseTypes("musicalbum, AUDIO")).To(Equal([]string{"MusicAlbum", "Audio"})) - }) - - It("returns no types for real Jellyfin kinds Navidrome has none of", func() { - Expect(parseTypes("Boxset")).To(BeEmpty()) - Expect(parseTypes("BoxSet,Movie")).To(BeEmpty()) - }) - - It("drops names that aren't Jellyfin item kinds, case-insensitively", func() { - Expect(knownItemKinds("music")).To(BeEmpty()) - Expect(knownItemKinds("music, audio,MUSICVIDEO")).To(Equal("audio,MUSICVIDEO")) - }) - - It("defaults to albums only when IncludeItemTypes is absent", func() { - Expect(parseTypes("")).To(Equal([]string{"MusicAlbum"})) - Expect(parseTypes("Nonsense")).To(BeEmpty()) - }) - }) - - Describe("decodeFilterParam", func() { - It("reports ok for an absent param, decoding to \"\"", func() { - id, ok := decodeFilterParam("") - Expect(id).To(BeEmpty()) - Expect(ok).To(BeTrue()) - }) - - It("reports ok for a well-formed id, whether or not it exists", func() { - id, ok := decodeFilterParam(dto.EncodeID(testID("a1"))) - Expect(id).To(Equal(testID("a1"))) - Expect(ok).To(BeTrue()) - }) - - It("reports not ok for a non-empty param that fails to decode", func() { - id, ok := decodeFilterParam("not-a-valid-id") - Expect(id).To(BeEmpty()) - Expect(ok).To(BeFalse()) - }) - }) - - Describe("decodedQueryIDs", func() { - It("decodes every entry when all are well-formed", func() { - r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+","+dto.EncodeID(testID("a2")), nil) - ids, ok := decodedQueryIDs(r, "ids") - Expect(ok).To(BeTrue()) - Expect(ids).To(Equal([]string{testID("a1"), testID("a2")})) - }) - - It("reports not ok and an empty list, not a partially-decoded one, for a mix of valid and malformed entries", func() { - r := httptest.NewRequest("GET", "/Items?ids="+dto.EncodeID(testID("a1"))+",not-a-valid-id", nil) - ids, ok := decodedQueryIDs(r, "ids") - Expect(ok).To(BeFalse()) - Expect(ids).To(BeEmpty()) - }) - - It("reports ok for an absent param, decoding to an empty list", func() { - r := httptest.NewRequest("GET", "/Items", nil) - ids, ok := decodedQueryIDs(r, "ids") - Expect(ok).To(BeTrue()) - Expect(ids).To(BeEmpty()) - }) - }) -}) diff --git a/server/jellyfin/jellyfin_suite_test.go b/server/jellyfin/jellyfin_suite_test.go deleted file mode 100644 index 2ae7da6e2..000000000 --- a/server/jellyfin/jellyfin_suite_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package jellyfin - -import ( - "net/http" - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/id" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestJellyfinApi(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Jellyfin API Suite") -} - -// testID maps a readable label to a deterministic canonical id, so fixtures exercise the same -// id shape production uses. -func testID(label string) string { return id.NewHash("jellyfin-test", label) } - -// invoke runs a handler through normalizeQueryKeys, mirroring the router. These unit tests call -// handlers directly (with withChiURLParam for path params) instead of routing, so without this the -// case-insensitive query folding real requests get would be skipped and PascalCase params dropped. -func invoke(h http.HandlerFunc, w http.ResponseWriter, r *http.Request) { - normalizeQueryKeys(h).ServeHTTP(w, r) -} diff --git a/server/jellyfin/library.go b/server/jellyfin/library.go deleted file mode 100644 index 9f1f7ee73..000000000 --- a/server/jellyfin/library.go +++ /dev/null @@ -1,75 +0,0 @@ -package jellyfin - -import ( - "context" - "net/http" - "strconv" - - "github.com/Masterminds/squirrel" - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" -) - -// accessibleLibraryIDs returns the ids of the libraries the current user can access. An empty -// slice (non-admin with no libraries) is treated as a no-op/unrestricted by the library filters. -func accessibleLibraryIDs(ctx context.Context) []int { - u, _ := request.UserFrom(ctx) - return u.Libraries.IDs() -} - -// resolveLibraryScope handles ParentId's ambiguity: a library id (browsing a UserView) or an -// entity id (artist/album). It's treated as a library only when the user has access; otherwise -// isLibraryParent is false and callers fall through to entity-id handling. -func resolveLibraryScope(ctx context.Context, parentId string) (scopeIDs []int, isLibraryParent bool) { - if parentId != "" { - if id, err := strconv.Atoi(parentId); err == nil { - if u, _ := request.UserFrom(ctx); u.HasLibraryAccess(id) { - return []int{id}, true - } - } - } - return accessibleLibraryIDs(ctx), false -} - -// decodeFilterParam separates an absent param from an undecodable one: dropping the filter for a -// stale id would silently widen the query to the whole library. It is the only place that opts -// into treating "absent" as fine — every other caller of dto.DecodeID must 404 on ok=false. -func decodeFilterParam(raw string) (id string, ok bool) { - if raw == "" { - return "", true - } - return dto.DecodeID(raw) -} - -// itemIDParam decodes a chi URL id param, writing 404 and reporting false when it isn't a -// well-formed GUID. Handlers must return immediately when ok is false. -func itemIDParam(w http.ResponseWriter, r *http.Request, key string) (string, bool) { - id, ok := dto.DecodeID(chi.URLParam(r, key)) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return "", false - } - return id, true -} - -// parentIDScope resolves the request's ParentId param to a library scope (see resolveLibraryScope). -// ok is false when a non-empty ParentId fails to decode (see decodeFilterParam). -func parentIDScope(ctx context.Context, r *http.Request) (scopeIDs []int, isLibraryParent bool, ok bool) { - parentId, ok := decodeFilterParam(req.Params(r).StringOr("parentid", "")) - if !ok { - return nil, false, false - } - scopeIDs, isLibraryParent = resolveLibraryScope(ctx, parentId) - return scopeIDs, isLibraryParent, true -} - -// libraryScopeFilter restricts a tag query to the given library scope. Empty scope means -// unrestricted (see accessibleLibraryIDs), so it returns nil rather than an impossible IN (). -func libraryScopeFilter(scope []int) squirrel.Sqlizer { - if len(scope) == 0 { - return nil - } - return squirrel.Eq{"library_tag.library_id": scope} -} diff --git a/server/jellyfin/lyrics.go b/server/jellyfin/lyrics.go deleted file mode 100644 index a9d77ba19..000000000 --- a/server/jellyfin/lyrics.go +++ /dev/null @@ -1,54 +0,0 @@ -package jellyfin - -import ( - "context" - "net/http" - "time" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/jellyfin/dto" -) - -const lyricsLoadTimeout = time.Minute - -// cachedLyrics resolves lyrics through the full source pipeline (embedded, sidecar, plugins), -// caching results — including empty: clients poll per played track, so misses are the hot path. -func (api *Router) cachedLyrics(ctx context.Context, mf *model.MediaFile) model.LyricList { - // The load is shared across requests (singleflight) and cached, so don't let one - // cancelled request abort it for everybody — detach it from the request's lifetime, - // keeping a bound so a hung plugin can't pin the fetch (and its plugin slot) forever. - loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), lyricsLoadTimeout) - defer cancel() - list, err := api.lyricsCache.GetWithLoader(mf.ID, func(string) (model.LyricList, time.Duration, error) { - l, err := api.lyrics.GetLyrics(loadCtx, mf) - return l, 0, err // 0 → cache DefaultTTL - }) - if err != nil { - log.Error(ctx, "Error getting lyrics", "id", mf.ID, "title", mf.Title, err) - return nil - } - return list -} - -// getLyrics serves GET /Audio/{itemId}/Lyrics. Jellyfin returns 404 when a track has no lyrics -// (never an empty 200); all surveyed clients treat that gracefully. -func (api *Router) getLyrics(w http.ResponseWriter, r *http.Request) { - mf, ok := api.mediaFileForRequest(w, r) - if !ok { - return - } - main, found := servableLyric(api.cachedLyrics(r.Context(), mf)) - if !found { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - api.ok(w, r, dto.LyricDtoFromLyrics(*mf, main)) -} - -// servableLyric is the single predicate for both serving and advertising, so PlaybackInfo never -// advertises a Lyric stream that this endpoint would 404. -func servableLyric(list model.LyricList) (model.Lyrics, bool) { - main, found := list.Main() - return main, found && !main.IsEmpty() -} diff --git a/server/jellyfin/lyrics_test.go b/server/jellyfin/lyrics_test.go deleted file mode 100644 index fb00563de..000000000 --- a/server/jellyfin/lyrics_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/cache" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// fakeLyricsService returns canned lyrics per media-file ID and counts calls. -type fakeLyricsService struct { - lyrics map[string]model.LyricList - err error - calls int - hadDeadline bool -} - -func (f *fakeLyricsService) GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { - f.calls++ - _, f.hadDeadline = ctx.Deadline() - if err := ctx.Err(); err != nil { - return nil, err - } - if f.err != nil { - return nil, f.err - } - return f.lyrics[mf.ID], nil -} - -func (f *fakeLyricsService) GetLyricsByArtistTitle(context.Context, string, string) (model.LyricList, error) { - return nil, nil -} - -func p(ms int64) *int64 { return &ms } - -func newTestLyricsCache() cache.SimpleCache[string, model.LyricList] { - return cache.NewSimpleCache[string, model.LyricList](cache.Options{SizeLimit: 1000}) -} - -var _ = Describe("getLyrics", func() { - var api *Router - var ds *tests.MockDataStore - var fake *fakeLyricsService - - BeforeEach(func() { - ds = &tests.MockDataStore{} - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", LibraryID: 1}, - {ID: testID("s2"), Title: "Silent Song", LibraryID: 1}, - }) - fake = &fakeLyricsService{lyrics: map[string]model.LyricList{}} - api = &Router{ - ds: ds, - lyrics: fake, - lyricsCache: newTestLyricsCache(), - } - }) - - doRequest := func(id string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}}) - // Clients send hex-encoded ids (matching real traffic and the other handler tests). - enc := dto.EncodeID(id) - r := httptest.NewRequest("GET", "/Audio/"+enc+"/Lyrics", nil).WithContext(ctx) - r = withChiURLParam(r, "itemId", enc) - invoke(api.getLyrics, w, r) - return w - } - - It("returns 200 with a LyricDto for a track with synced lyrics", func() { - fake.lyrics[testID("s1")] = model.LyricList{ - {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, - } - w := doRequest(testID("s1")) - - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.LyricDto - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Lyrics).To(HaveLen(1)) - Expect(res.Lyrics[0].Text).To(Equal("hello")) - Expect(res.Lyrics[0].Start).ToNot(BeNil()) - Expect(*res.Lyrics[0].Start).To(Equal(int64(10000000))) - }) - - It("serves the main-kind lyric when a translation is also present", func() { - fake.lyrics[testID("s1")] = model.LyricList{ - {Kind: "translation", Synced: true, Line: []model.Line{{Start: p(1000), Value: "bonjour"}}}, - {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, - } - w := doRequest(testID("s1")) - - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.LyricDto - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Lyrics).To(HaveLen(1)) - Expect(res.Lyrics[0].Text).To(Equal("hello")) - }) - - It("returns 404 when the service returns no lyrics", func() { - w := doRequest(testID("s2")) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 when the main lyric has no lines", func() { - fake.lyrics[testID("s1")] = model.LyricList{{Kind: "main", Lang: "eng"}} - w := doRequest(testID("s1")) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 for an unknown item id", func() { - w := doRequest("unknown") - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("caches results so a second request doesn't re-invoke the service", func() { - fake.lyrics[testID("s1")] = model.LyricList{ - {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, - } - Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK)) - Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK)) - Expect(fake.calls).To(Equal(1)) - }) - - It("caches empty results too", func() { - Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound)) - Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound)) - Expect(fake.calls).To(Equal(1)) - }) - - It("completes and caches the fetch even when the request context is cancelled", func() { - fake.lyrics[testID("s1")] = model.LyricList{ - {Kind: "main", Synced: true, Line: []model.Line{{Start: p(1000), Value: "hello"}}}, - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - list := api.cachedLyrics(ctx, &model.MediaFile{ID: testID("s1")}) - Expect(list).ToNot(BeEmpty()) - Expect(doRequest(testID("s1")).Code).To(Equal(http.StatusOK)) - Expect(fake.calls).To(Equal(1)) - }) - - It("bounds the detached fetch with a timeout", func() { - Expect(doRequest(testID("s2")).Code).To(Equal(http.StatusNotFound)) - Expect(fake.hadDeadline).To(BeTrue()) - }) -}) diff --git a/server/jellyfin/middlewares.go b/server/jellyfin/middlewares.go deleted file mode 100644 index 4788c84be..000000000 --- a/server/jellyfin/middlewares.go +++ /dev/null @@ -1,224 +0,0 @@ -package jellyfin - -import ( - "net" - "net/http" - "net/url" - "regexp" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" -) - -// throttleStreams bounds how many collection responses stream concurrently, so they can't take every -// connection in the shared DB pool: each holds a cursor, and its connection, for the whole -// client-paced response. Excess requests queue rather than fail. limit <= 0 disables it. -// -// Deliberately chi's ThrottleBacklog and not server.ThrottleBacklog: the latter buffers the entire -// response to release its token early, which is right for artwork but would undo the streaming here. -// chi's panics on a non-positive limit, hence the guard. -func throttleStreams(limit int) func(http.Handler) http.Handler { - if limit <= 0 { - return func(next http.Handler) http.Handler { return next } - } - return middleware.ThrottleBacklog(limit, consts.RequestThrottleBacklogLimit, consts.RequestThrottleBacklogTimeout) -} - -// caseInsensitivePaths lowercases the request path so chi (case-sensitive) matches the -// lowercase-registered routes; Jellyfin clients route case-insensitively. It lowercases id/param -// segments too, which is safe because every id the API emits — user ids included — is lowercase hex -// (dto.EncodeID). -func caseInsensitivePaths(r chi.Router) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - // Mounted under a parent, chi matches RouteContext.RoutePath, not r.URL.Path. - if rctx := chi.RouteContext(req.Context()); rctx != nil && rctx.RoutePath != "" { - rctx.RoutePath = strings.ToLower(rctx.RoutePath) - } else { - req.URL.Path = strings.ToLower(req.URL.Path) - } - r.ServeHTTP(w, req) - }) -} - -// normalizeQueryKeys folds query-parameter keys to lowercase so handlers can read params -// case-insensitively, matching real Jellyfin. Clients disagree on casing (Finamp sends PascalCase, -// Jellify and the Jellyfin TypeScript SDK camelCase), so a case-sensitive read would drop one -// client's filters, sort and paging. Only keys are folded — values keep their case. The original -// request is left untouched (a rewritten copy goes downstream) so logging shows the client's casing. -func normalizeQueryKeys(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - folded := make(url.Values, len(q)) - changed := false - for k, vs := range q { - lk := strings.ToLower(k) - // Append, don't assign: two casings of the same key must merge, not overwrite. - folded[lk] = append(folded[lk], vs...) - if lk != k { - changed = true - } - } - if changed { - r2 := *r - u := *r.URL - u.RawQuery = folded.Encode() - r2.URL = &u - r = &r2 - } - next.ServeHTTP(w, r) - }) -} - -type mediaBrowserAuth struct { - Client, Device, DeviceId, Version, Token string -} - -var mediaBrowserAuthField = regexp.MustCompile(`(\w+)="([^"]*)"`) - -// parseMediaBrowserAuth reads the MediaBrowser-scheme authorization header, e.g. -// `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="abc", Version="1.0", Token="jwt"`. -// The recommended Authorization header is preferred, but only when it actually carries -// MediaBrowser data — a reverse proxy may inject Basic/Digest credentials there while the client -// sends the deprecated X-Emby-Authorization. Field values are URL-decoded: Jellify (@jellyfin/sdk) -// percent-encodes them (Device="Pixel%208%20Pro"), while Finamp sends them raw; unescapeField -// leaves a raw value untouched. -func parseMediaBrowserAuth(r *http.Request) mediaBrowserAuth { - if a, ok := parseAuthHeader(r.Header.Get("Authorization")); ok { - return a - } - a, _ := parseAuthHeader(r.Header.Get("X-Emby-Authorization")) - return a -} - -// parseAuthHeader extracts the MediaBrowser fields from one header value; ok reports whether the -// value uses the MediaBrowser scheme ("Emby" is the legacy spelling real Jellyfin also accepts). -func parseAuthHeader(h string) (mediaBrowserAuth, bool) { - var a mediaBrowserAuth - scheme, params, found := strings.Cut(h, " ") - if !found || (!strings.EqualFold(scheme, "MediaBrowser") && !strings.EqualFold(scheme, "Emby")) { - return a, false - } - for _, m := range mediaBrowserAuthField.FindAllStringSubmatch(params, -1) { - switch m[1] { - case "Client": - a.Client = unescapeField(m[2]) - case "Device": - a.Device = unescapeField(m[2]) - case "DeviceId": - a.DeviceId = unescapeField(m[2]) - case "Version": - a.Version = unescapeField(m[2]) - case "Token": - a.Token = unescapeField(m[2]) - } - } - return a, true -} - -// unescapeField percent-decodes a header field value, falling back to the raw value when it isn't -// valid encoding (Finamp sends raw values that may contain a literal '%'). PathUnescape, not -// QueryUnescape, so a literal '+' in a value is preserved rather than turned into a space. -func unescapeField(v string) string { - if decoded, err := url.PathUnescape(v); err == nil { - return decoded - } - return v -} - -// tokenFromRequest prefers the recommended Authorization scheme; the rest are legacy spellings -// deprecated by Jellyfin but still sent by clients. -func tokenFromRequest(r *http.Request) string { - if t := parseMediaBrowserAuth(r).Token; t != "" { - return t - } - if t := r.Header.Get("X-Emby-Token"); t != "" { - return t - } - if t := r.Header.Get("X-MediaBrowser-Token"); t != "" { - return t - } - // api_key and apikey differ by an underscore, not case, so normalizeQueryKeys' folding doesn't - // merge them; both are checked (Finamp's just_audio engine fetches direct-file URLs with ?ApiKey=). - if t := r.URL.Query().Get("api_key"); t != "" { - return t - } - return r.URL.Query().Get("apikey") -} - -// userFromToken resolves the user for the request's token; ok is false for a missing/invalid token -// or unknown subject. -func (api *Router) userFromToken(r *http.Request) (model.User, bool) { - token := tokenFromRequest(r) - if token == "" { - return model.User{}, false - } - claims, err := auth.Validate(token) - if err != nil || claims.Subject == "" { - return model.User{}, false - } - usr, err := api.ds.User().FindByUsername(r.Context(), claims.Subject) - if err != nil { - log.Warn(r.Context(), "Jellyfin API: token subject not found", "user", claims.Subject, err) - return model.User{}, false - } - if err := auth.CheckClaims(claims, *usr, auth.AudienceJellyfin); err != nil { - log.Warn(r.Context(), "Jellyfin API: rejected token", "user", claims.Subject, err) - return model.User{}, false - } - return *usr, true -} - -func (api *Router) authenticate(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - usr, ok := api.userFromToken(r) - if !ok { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - ctx := request.WithUser(r.Context(), usr) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -// withPlayer resolves/registers a model.Player for the calling device into the context, mirroring -// Subsonic's getPlayer. Jellyfin clients always send a DeviceId in the auth header (unlike Subsonic), -// so it's used directly as the player id and reports from the same install share a player/scrobbling -// session. -func (api *Router) withPlayer(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if api.players == nil { // fail open when players isn't wired (e.g. in unit tests) - next.ServeHTTP(w, r) - return - } - ctx := r.Context() - a := parseMediaBrowserAuth(r) - // Skip registration when the request can't identify a client (no X-Emby-Authorization, e.g. - // the /socket handshake that authenticates via ?api_key= only). Otherwise Register would - // create a junk player with an empty name (" []"). - if a.Client == "" && a.DeviceId == "" { - next.ServeHTTP(w, r) - return - } - ip, _, _ := net.SplitHostPort(r.RemoteAddr) - player, trc, err := api.players.Register(ctx, a.DeviceId, a.Client, a.Device, ip) - if err != nil { - // Fail open, like Subsonic's getPlayer: proceed without a player; reporting handlers - // degrade gracefully. - log.Warn(ctx, "Jellyfin API: could not register player", "client", a.Client, "device", a.Device, err) - next.ServeHTTP(w, r) - return - } - ctx = request.WithPlayer(ctx, *player) - // Like Subsonic's getPlayer: the forced transcoding must reach ResolveRequest's override. - if trc != nil { - ctx = request.WithTranscoding(ctx, *trc) - } - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} diff --git a/server/jellyfin/middlewares_test.go b/server/jellyfin/middlewares_test.go deleted file mode 100644 index 2ab9f35eb..000000000 --- a/server/jellyfin/middlewares_test.go +++ /dev/null @@ -1,430 +0,0 @@ -package jellyfin - -import ( - "context" - "net/http" - "net/http/httptest" - "sync" - "sync/atomic" - "time" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("authenticate middleware", func() { - var ctx context.Context - var api *Router - var ds *tests.MockDataStore - BeforeEach(func() { - ctx = GinkgoT().Context() - ds = &tests.MockDataStore{} - auth.Init(ds) - ur := ds.User().(*tests.MockedUserRepo) - Expect(ur.Put(ctx, &model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) - api = &Router{ds: ds} - }) - - tokenFor := func(name string) string { - t, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: name}) - Expect(err).ToNot(HaveOccurred()) - return t - } - - It("passes with a valid X-Emby-Token and injects the user", func() { - var gotUser model.User - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotUser, _ = request.UserFrom(r.Context()) - w.WriteHeader(http.StatusOK) - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items", nil) - r.Header.Set("X-Emby-Token", tokenFor("alice")) - api.authenticate(next).ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(gotUser.UserName).To(Equal("alice")) - }) - - It("passes with the recommended Authorization: MediaBrowser scheme and injects the user", func() { - var gotUser model.User - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotUser, _ = request.UserFrom(r.Context()) - w.WriteHeader(http.StatusOK) - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items", nil) - r.Header.Set("Authorization", `MediaBrowser Token="`+tokenFor("alice")+`", Client="Test", DeviceId="dev1"`) - api.authenticate(next).ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(gotUser.UserName).To(Equal("alice")) - }) - - It("rejects a missing token with 401", func() { - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items", nil) - api.authenticate(next).ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects a garbage token with 401 and does not call next", func() { - nextCalled := false - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - nextCalled = true - w.WriteHeader(http.StatusOK) - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items", nil) - r.Header.Set("X-Emby-Token", "not-a-jwt") - api.authenticate(next).ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - Expect(nextCalled).To(BeFalse()) - }) - - It("rejects a valid token whose subject user does not exist with 401", func() { - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) - t, err := auth.CreateToken(&model.User{ID: "x", UserName: "ghost"}) - Expect(err).ToNot(HaveOccurred()) - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items", nil) - r.Header.Set("X-Emby-Token", t) - api.authenticate(next).ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - Context("token scoping and revocation", func() { - var usr *model.User - - BeforeEach(func() { - ur := ds.User().(*tests.MockedUserRepo) - usr = &model.User{ID: testID("u2"), UserName: "bob", NewPassword: "secret", TokenEpoch: 3} - Expect(ur.Put(ctx, usr)).To(Succeed()) - }) - - serve := func(token string) *httptest.ResponseRecorder { - next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items", nil) - r.Header.Set("X-Emby-Token", token) - api.authenticate(next).ServeHTTP(w, r) - return w - } - - It("accepts a jellyfin-scoped token with the current epoch", func() { - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) - }) - - It("rejects a token whose epoch is stale", func() { - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - usr.TokenEpoch = 4 - Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects a token minted for another API", func() { - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceNative) - Expect(err).ToNot(HaveOccurred()) - Expect(serve(tokenStr).Code).To(Equal(http.StatusUnauthorized)) - }) - - It("still accepts an unscoped session token", func() { - tokenStr, err := auth.CreateToken(usr) - Expect(err).ToNot(HaveOccurred()) - Expect(serve(tokenStr).Code).To(Equal(http.StatusOK)) - }) - }) -}) - -var _ = Describe("withPlayer middleware", func() { - var api *Router - var players *fakePlayers - - BeforeEach(func() { - players = &fakePlayers{} - api = &Router{ds: &tests.MockDataStore{}, players: players} - }) - - callWith := func() (model.Player, model.Transcoding, bool) { - var gotPlayer model.Player - var gotTrc model.Transcoding - var hasTrc bool - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPlayer, _ = request.PlayerFrom(r.Context()) - gotTrc, hasTrc = request.TranscodingFrom(r.Context()) - w.WriteHeader(http.StatusOK) - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/s1/stream", nil) - r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) - api.withPlayer(next).ServeHTTP(w, r) - return gotPlayer, gotTrc, hasTrc - } - - It("injects the registered player into the context", func() { - player, _, hasTrc := callWith() - Expect(player.ID).To(Equal("dev1")) - Expect(hasTrc).To(BeFalse()) - }) - - It("injects the player's server-forced transcoding into the context", func() { - players.trc = &model.Transcoding{ID: testID("t1"), TargetFormat: "opus"} - _, trc, hasTrc := callWith() - Expect(hasTrc).To(BeTrue()) - Expect(trc.TargetFormat).To(Equal("opus")) - }) -}) - -var _ = Describe("tokenFromRequest", func() { - It("accepts the recommended Authorization: MediaBrowser scheme", func() { - r := httptest.NewRequest("GET", "/Items", nil) - r.Header.Set("Authorization", `MediaBrowser Token="tok123", Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) - Expect(tokenFromRequest(r)).To(Equal("tok123")) - }) - - It("prefers the Authorization scheme token over deprecated token headers", func() { - r := httptest.NewRequest("GET", "/Items", nil) - r.Header.Set("Authorization", `MediaBrowser Token="scheme-token"`) - r.Header.Set("X-Emby-Token", "legacy-token") - Expect(tokenFromRequest(r)).To(Equal("scheme-token")) - }) - - It("accepts the lowercase api_key query param", func() { - r := httptest.NewRequest("GET", "/Items/s1/File?api_key=tok123", nil) - Expect(tokenFromRequest(r)).To(Equal("tok123")) - }) - - It("accepts a PascalCase ApiKey query param once normalizeQueryKeys has folded it", func() { - r := httptest.NewRequest("GET", "/Items/s1/File?ApiKey=tok123", nil) - var got string - invoke(func(_ http.ResponseWriter, r *http.Request) { got = tokenFromRequest(r) }, httptest.NewRecorder(), r) - Expect(got).To(Equal("tok123")) - }) -}) - -var _ = Describe("parseMediaBrowserAuth", func() { - authFor := func(header string) mediaBrowserAuth { - r := httptest.NewRequest("GET", "/", nil) - r.Header.Set("X-Emby-Authorization", header) - return parseMediaBrowserAuth(r) - } - - It("reads Finamp's raw (unencoded) field values", func() { - a := authFor(`MediaBrowser Client="Finamp", Device="Pixel 8 Pro", DeviceId="dev1", Version="1.0", Token="tok"`) - Expect(a.Client).To(Equal("Finamp")) - Expect(a.Device).To(Equal("Pixel 8 Pro")) - Expect(a.DeviceId).To(Equal("dev1")) - }) - - It("percent-decodes Jellify's URL-encoded field values", func() { - a := authFor(`MediaBrowser Client="Jellify", Device="Pixel%208%20Pro", DeviceId="dev1", Version="1.0", Token="tok"`) - Expect(a.Client).To(Equal("Jellify")) - Expect(a.Device).To(Equal("Pixel 8 Pro")) - }) - - It("keeps a literal '%' that isn't valid percent-encoding", func() { - a := authFor(`MediaBrowser Client="100% Player", Device="d"`) - Expect(a.Client).To(Equal("100% Player")) - }) - - It("prefers the recommended Authorization header over the deprecated X-Emby-Authorization", func() { - r := httptest.NewRequest("GET", "/", nil) - r.Header.Set("Authorization", `MediaBrowser Client="New", DeviceId="dev-new"`) - r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Old", DeviceId="dev-old"`) - a := parseMediaBrowserAuth(r) - Expect(a.Client).To(Equal("New")) - Expect(a.DeviceId).To(Equal("dev-new")) - }) - - It("falls back to X-Emby-Authorization when Authorization carries a foreign scheme", func() { - // A reverse proxy may inject Basic/Digest credentials; the client's MediaBrowser data must - // still be honored. - r := httptest.NewRequest("GET", "/", nil) - r.Header.Set("Authorization", `Digest username="proxy", realm="site"`) - r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", DeviceId="dev1", Token="tok"`) - a := parseMediaBrowserAuth(r) - Expect(a.Client).To(Equal("Finamp")) - Expect(a.Token).To(Equal("tok")) - }) - - It("rejects a foreign scheme even when its parameters mimic MediaBrowser fields", func() { - r := httptest.NewRequest("GET", "/", nil) - r.Header.Set("Authorization", `Custom Token="not-for-us"`) - Expect(parseMediaBrowserAuth(r).Token).To(BeEmpty()) - }) - - It("accepts the legacy Emby scheme spelling, like real Jellyfin", func() { - a := authFor(`Emby Client="OldClient", DeviceId="dev1", Token="tok"`) - Expect(a.Client).To(Equal("OldClient")) - Expect(a.Token).To(Equal("tok")) - }) - - It("matches the scheme case-insensitively (HTTP auth schemes are)", func() { - a := authFor(`mediabrowser Token="tok"`) - Expect(a.Token).To(Equal("tok")) - }) -}) - -var _ = Describe("normalizeQueryKeys", func() { - // keyFor runs a request through normalizeQueryKeys and reports the value the handler sees for - // the given (lowercase) key — i.e. what a case-insensitive read would find. - keyFor := func(rawQuery, key string) string { - r := httptest.NewRequest("GET", "/Items?"+rawQuery, nil) - var got string - invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query().Get(key) }, httptest.NewRecorder(), r) - return got - } - - It("folds PascalCase (Finamp) and camelCase (Jellify) keys to lowercase", func() { - Expect(keyFor("ParentId=abc", "parentid")).To(Equal("abc")) - Expect(keyFor("parentId=abc", "parentid")).To(Equal("abc")) - }) - - It("leaves values untouched", func() { - Expect(keyFor("IncludeItemTypes=MusicAlbum,Audio", "includeitemtypes")).To(Equal("MusicAlbum,Audio")) - }) - - It("passes already-lowercase keys through unchanged", func() { - Expect(keyFor("container=mp3", "container")).To(Equal("mp3")) - }) - - It("merges values when two keys fold to the same name instead of dropping one", func() { - r := httptest.NewRequest("GET", "/Items?Ids=aaa&ids=bbb", nil) - var got []string - invoke(func(_ http.ResponseWriter, r *http.Request) { got = r.URL.Query()["ids"] }, httptest.NewRecorder(), r) - Expect(got).To(ConsistOf("aaa", "bbb")) - }) -}) - -var _ = Describe("throttleStreams", func() { - // serve fires n concurrent requests through the middleware and reports the highest number that - // were ever inside the handler at once. - serve := func(limit, n int) int32 { - var inFlight, peak int32 - release := make(chan struct{}) - h := throttleStreams(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - cur := atomic.AddInt32(&inFlight, 1) - for { - old := atomic.LoadInt32(&peak) - if cur <= old || atomic.CompareAndSwapInt32(&peak, old, cur) { - break - } - } - <-release // hold the slot until every request has had a chance to enter - atomic.AddInt32(&inFlight, -1) - })) - - var wg sync.WaitGroup - for range n { - wg.Add(1) - go func() { - defer wg.Done() - h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil)) - }() - } - // Give the admitted requests time to pile up before letting them finish. - time.Sleep(100 * time.Millisecond) - close(release) - wg.Wait() - return atomic.LoadInt32(&peak) - } - - It("admits no more than the limit at once", func() { - Expect(serve(2, 8)).To(Equal(int32(2))) - }) - - It("queues the excess rather than rejecting it", func() { - // All 8 still complete — they wait for a slot instead of getting a 429. - var served int32 - release := make(chan struct{}) - h := throttleStreams(2)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - <-release - atomic.AddInt32(&served, 1) - })) - var wg sync.WaitGroup - for range 8 { - wg.Add(1) - go func() { - defer wg.Done() - h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/Items", nil)) - }() - } - close(release) - wg.Wait() - Expect(served).To(Equal(int32(8))) - }) - - // chi's ThrottleBacklog panics on a non-positive limit, so a user disabling the cap must not - // crash the server at startup. - It("is disabled, not panicking, when the limit is zero", func() { - Expect(func() { serve(0, 4) }).ToNot(Panic()) - Expect(serve(0, 4)).To(BeNumerically(">", int32(1))) - }) -}) - -var _ = Describe("caseInsensitivePaths", func() { - var handler http.Handler - var gotID, gotContainer string - - BeforeEach(func() { - gotID, gotContainer = "", "" - r := chi.NewRouter() - // Routes are registered lowercase, mirroring the real router. - r.Get("/foo/{id}/bar", func(w http.ResponseWriter, req *http.Request) { - gotID = chi.URLParam(req, "id") - w.WriteHeader(http.StatusOK) - }) - r.Get("/audio/{id}/stream.{container}", func(w http.ResponseWriter, req *http.Request) { - gotContainer = chi.URLParam(req, "container") - w.WriteHeader(http.StatusOK) - }) - // A second route reusing the "bar" segment name at a different position. - r.Get("/bar/{id}", func(w http.ResponseWriter, req *http.Request) { - w.WriteHeader(http.StatusOK) - }) - handler = caseInsensitivePaths(r) - }) - - serve := func(path string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil)) - return w - } - - It("routes a mixed-case request to its lowercase-registered route", func() { - Expect(serve("/FOO/abc/BAR").Code).To(Equal(http.StatusOK)) - }) - - It("routes both routes that share a segment name, regardless of casing", func() { - Expect(serve("/Foo/abc/Bar").Code).To(Equal(http.StatusOK)) - Expect(serve("/BAR/abc").Code).To(Equal(http.StatusOK)) - }) - - It("lowercases the mixed literal.extension segment so the route and container match", func() { - w := serve("/Audio/abc/STREAM.MP3") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(gotContainer).To(Equal("mp3")) - }) - - It("lowercases id/param segments (safe: Jellyfin ids are lowercase hex)", func() { - serve("/foo/DEADBEEF/bar") - Expect(gotID).To(Equal("deadbeef")) - }) - - It("normalizes the RoutePath branch when mounted under a parent", func() { - parent := chi.NewRouter() - parent.Mount("/jellyfin", handler) - w := httptest.NewRecorder() - parent.ServeHTTP(w, httptest.NewRequest("GET", "/jellyfin/FOO/abc/BAR", nil)) - Expect(w.Code).To(Equal(http.StatusOK)) - }) -}) diff --git a/server/jellyfin/playlists.go b/server/jellyfin/playlists.go deleted file mode 100644 index 5569f4f83..000000000 --- a/server/jellyfin/playlists.go +++ /dev/null @@ -1,403 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "errors" - "math" - "net/http" - "strconv" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/filter" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/slice" -) - -// playlistsFolder is the item returned for a ManualPlaylistsFolder query. CollectionType must be -// "playlists" — how the client identifies it; without it Jellify's playlist-library query loops. -func playlistsFolder() dto.BaseItemDto { - return dto.BaseItemDto{ - Id: dto.PlaylistsFolderGUID, - Name: "Playlists", - Type: "ManualPlaylistsFolder", - CollectionType: "playlists", - IsFolder: true, - } -} - -// playlistError maps core/playlists write errors to HTTP status: ownership or locked -> 403, -// missing/invisible -> 404 (never revealing another user's private playlist), else -> 500. -func (api *Router) playlistError(w http.ResponseWriter, r *http.Request, err error) { - switch { - case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, model.ErrPlaylistNotEditable): - http.Error(w, "Forbidden", http.StatusForbidden) - case errors.Is(err, model.ErrNotFound): - http.Error(w, "Not Found", http.StatusNotFound) - default: - api.internalError(w, r, err) - } -} - -type createPlaylistRequest struct { - Name string `json:"Name"` - Ids []string `json:"Ids"` - MediaType string `json:"MediaType"` - IsPublic *bool `json:"IsPublic"` -} - -// createPlaylist always creates a new playlist (playlistId "" tells core/playlists.Create not to -// replace an existing one), owned by the authenticated user. -func (api *Router) createPlaylist(w http.ResponseWriter, r *http.Request) { - var body createPlaylistRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - decoded, ok := dto.DecodeIDs(body.Ids) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - ids := api.expandContainerIDs(r.Context(), decoded) - id, err := api.playlists.Create(r.Context(), "", body.Name, ids) - if err != nil { - api.internalError(w, r, err) - return - } - // Create takes no visibility, so a requested one costs a second write. - if body.IsPublic != nil { - if err := api.playlists.Update(r.Context(), id, nil, nil, body.IsPublic, nil, nil); err != nil { - api.playlistError(w, r, err) - return - } - } - api.ok(w, r, map[string]string{"Id": dto.EncodeID(id)}) -} - -// updatePlaylistRequest mirrors Jellyfin's NewPlaylist body. Pointers so an absent field means -// "leave unchanged", distinguishing an omitted Ids (no change) from an explicit empty list (clear). -type updatePlaylistRequest struct { - Name *string `json:"Name"` - Ids *[]string `json:"Ids"` - IsPublic *bool `json:"IsPublic"` -} - -func (api *Router) updatePlaylist(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - var body updatePlaylistRequest - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - - // A present Ids replaces the track list. An empty list must clear it explicitly, since Create - // can't persist an empty track list (the repository skips track writes when the list is empty). - if body.Ids != nil { - if len(*body.Ids) == 0 { - if err := api.clearPlaylist(ctx, id); err != nil { - api.playlistError(w, r, err) - return - } - } else { - decoded, ok := dto.DecodeIDs(*body.Ids) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - ids := api.expandContainerIDs(ctx, decoded) - if _, err := api.playlists.Create(ctx, id, "", ids); err != nil { - api.playlistError(w, r, err) - return - } - } - } - if body.Ids == nil || body.Name != nil || body.IsPublic != nil { - if err := api.playlists.Update(ctx, id, body.Name, nil, body.IsPublic, nil, nil); err != nil { - api.playlistError(w, r, err) - return - } - } - w.WriteHeader(http.StatusNoContent) -} - -// clearPlaylist removes every track from a playlist. RemoveTracks enforces ownership. -func (api *Router) clearPlaylist(ctx context.Context, id string) error { - pls, err := api.playlists.GetWithTracks(ctx, id) - if err != nil { - return err - } - if len(pls.Tracks) == 0 { - return nil - } - entryIDs := slice.Map(pls.Tracks, func(t model.PlaylistTrack) string { return t.ID }) - return api.playlists.RemoveTracks(ctx, id, entryIDs) -} - -// playlistTrackPage streams one page of a playlist's tracks. Streams because a playlist can be the -// whole library (a smart playlist matching everything) and clients may omit Limit. Excludes missing -// tracks, and counts the same set, like GetWithTracks. -func (api *Router) playlistTrackPage(ctx context.Context, repo model.PlaylistTrackRepository, fields dto.Fields, offset, limit int) (itemsResult, error) { - total, err := repo.CountAll(ctx, model.QueryOptions{Filters: notMissing}) - if err != nil { - return itemsResult{}, err - } - opts := model.QueryOptions{Sort: "id", Offset: offset, Max: limit, Filters: notMissing} - open := streamCursor(func() (func(func(model.PlaylistTrack, error) bool), error) { - return repo.GetCursor(ctx, opts) - }, func(t model.PlaylistTrack) dto.BaseItemDto { return trackToBaseItem(t, fields) }) - return streamed(open, int(total), offset), nil -} - -// trackToBaseItem maps a playlist entry to a BaseItemDto, tagging it with PlaylistItemId (the -// entry's id, model.PlaylistTrack.ID, not the song id). Clients echo it back via -// DELETE .../Items?EntryIds= to remove a specific occurrence, so duplicates of the same song remain -// individually removable. -func trackToBaseItem(t model.PlaylistTrack, fields dto.Fields) dto.BaseItemDto { - item := dto.SongToBaseItem(t.MediaFile, fields) - item.PlaylistItemId = dto.EncodePlaylistEntryID(t.ID) - return item -} - -// getPlaylist returns a playlist's visibility flag and item ids (Finamp reads OpenAccess before the -// edit screen). Get and Tracks enforce visibility; any error maps to 404 so private playlists can't -// be probed. -func (api *Router) getPlaylist(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - pls, err := api.playlists.Get(ctx, id) - if err != nil { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - repo, err := api.playlists.Tracks(ctx, id) - if err != nil { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - // PlaylistInfo carries every track id, so this can't be paged — but it needs no track data. - trackIDs, err := repo.GetMediaFileIDs(ctx, model.QueryOptions{Sort: "id", Filters: notMissing}) - if err != nil { - api.internalError(w, r, err) - return - } - itemIds := slice.Map(trackIDs, dto.EncodeID) - api.ok(w, r, dto.PlaylistInfo{ - OpenAccess: pls.Public, - Shares: []dto.PlaylistUserPermissions{}, - ItemIds: itemIds, - }) -} - -// getPlaylistItems relies on Tracks to enforce visibility; any error maps to a generic 404 so a -// playlist id can't probe for private playlists. -func (api *Router) getPlaylistItems(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - repo, err := api.playlists.Tracks(ctx, id) - if err != nil { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - p := req.Params(r) - fields := dto.ParseFields(p.Strings("fields")...) - res, err := api.playlistTrackPage(ctx, repo, fields, p.IntOr("startindex", 0), p.IntOr("limit", 0)) - if err != nil { - api.internalError(w, r, err) - return - } - api.ok(w, r, res) -} - -// queryIDs reads an id-list query param that clients spell two ways: comma-separated in a single -// param (Finamp: ids=X,Y) or as repeated params (Jellify's @jellyfin/sdk: ids=X&ids=Y). It returns -// the flattened, non-empty ids across both forms. -func queryIDs(r *http.Request, key string) []string { - var ids []string - for _, v := range r.URL.Query()[key] { - for id := range strings.SplitSeq(v, ",") { - if id != "" { - ids = append(ids, id) - } - } - } - return ids -} - -// expandContainerIDs expands the container ids (albums, artists, playlists) a client sends when -// building a playlist into their track ids, in order, since core/playlists only understands media -// file ids. Unknown ids pass through unchanged. Songs are classified with one batched query; only -// the rest pays per-id container probes. -func (api *Router) expandContainerIDs(ctx context.Context, ids []string) []string { - songs := api.songsByIDs(ctx, ids) - out := make([]string, 0, len(ids)) - for _, id := range ids { - if _, ok := songs[id]; ok { - out = append(out, id) // already a song - } else if _, err := api.ds.Album().Get(ctx, id); err == nil { - out = append(out, api.songIDs(ctx, filter.SongsByAlbum(id))...) - } else if _, err := api.ds.Artist().Get(ctx, id); err == nil { - out = append(out, api.songIDs(ctx, filter.SongsByArtistID(id))...) - } else if pl, err := api.playlists.GetWithTracks(ctx, id); err == nil { - out = append(out, slice.Map(pl.Tracks, func(t model.PlaylistTrack) string { return t.MediaFileID })...) - } else { - out = append(out, id) // well-formed but unresolved — left for the caller's write to handle - } - } - return out -} - -func (api *Router) songIDs(ctx context.Context, opts model.QueryOptions) []string { - mfs, err := api.ds.MediaFile().GetAll(ctx, opts) - if err != nil { - log.Error(ctx, "Jellyfin: error expanding container to tracks", err) - return nil - } - return slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID }) -} - -// addToPlaylist adds items by id (containers expand to tracks), inserting at the zero-based position -// when given. Core enforces ownership: a locked playlist maps to 403, any other error to 404. -func (api *Router) addToPlaylist(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - decoded, ok := dto.DecodeIDs(queryIDs(r, "ids")) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - ids := api.expandContainerIDs(ctx, decoded) - var err error - if position, perr := req.Params(r).Int64("position"); perr == nil { - _, err = api.playlists.InsertTracks(ctx, id, ids, insertPosition(position)) - } else { - _, err = api.playlists.AddTracks(ctx, id, ids) - } - if err != nil { - if errors.Is(err, model.ErrPlaylistNotEditable) { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - http.Error(w, "Not Found", http.StatusNotFound) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// insertPosition maps Jellyfin's zero-based position to a 1-based one, clamped in int64 first so -// it can't wrap on 32-bit builds. -func insertPosition(position int64) int { - return int(min(max(position, 0), math.MaxInt32-1) + 1) -} - -// removeFromPlaylist removes entries by entryIds — playlist-entry ids (PlaylistItemId), not media -// file ids, since RemoveTracks deletes playlist_tracks rows by that id. RemoveTracks enforces -// ownership; a locked playlist maps to 403, any other error to 404. -func (api *Router) removeFromPlaylist(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - raw := queryIDs(r, "entryids") - ids := make([]string, 0, len(raw)) - for _, entryGUID := range raw { - entry, ok := dto.DecodePlaylistEntryID(entryGUID) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - ids = append(ids, entry) - } - if err := api.playlists.RemoveTracks(ctx, id, ids); err != nil { - if errors.Is(err, model.ErrPlaylistNotEditable) { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - http.Error(w, "Not Found", http.StatusNotFound) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// movePlaylistItem moves an entry to Jellyfin's zero-based newIndex. Reorder clamps past-the-end -// indexes and rejects unknown entries, which Jellyfin treats as a no-op. -func (api *Router) movePlaylistItem(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - entry, ok := dto.DecodePlaylistEntryID(chi.URLParam(r, "entryId")) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - newIndex, err := strconv.Atoi(chi.URLParam(r, "newIndex")) - if err != nil || newIndex < 0 { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - // Resolve first, so a missing or hidden playlist is still a 404 below the unknown-entry no-op. - if _, err := api.playlists.Get(ctx, id); err != nil { - api.playlistError(w, r, err) - return - } - pos, _ := strconv.Atoi(entry) - err = api.playlists.ReorderTrack(ctx, id, pos, min(newIndex, math.MaxInt32-1)+1) - if err != nil && !errors.Is(err, model.ErrNotFound) { - api.playlistError(w, r, err) - return - } - w.WriteHeader(http.StatusNoContent) -} - -// Clients probe these before offering edits. Navidrome has no per-playlist ACL, so CanEdit carries -// only editability; ownership is enforced on write, and a lookup error 404s to prevent probing. -func (api *Router) getPlaylistUsers(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - pls, err := api.playlists.Get(ctx, id) - if err != nil { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - u, _ := request.UserFrom(ctx) - api.ok(w, r, []dto.PlaylistUserPermissions{{UserId: dto.EncodeID(u.ID), CanEdit: pls.TracksEditable()}}) -} - -func (api *Router) getPlaylistUser(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "playlistId") - if !ok { - return - } - pls, err := api.playlists.Get(ctx, id) - if err != nil { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - userId := chi.URLParam(r, "userId") - api.ok(w, r, dto.PlaylistUserPermissions{UserId: userId, CanEdit: pls.TracksEditable()}) -} diff --git a/server/jellyfin/playlists_test.go b/server/jellyfin/playlists_test.go deleted file mode 100644 index d38ef3b44..000000000 --- a/server/jellyfin/playlists_test.go +++ /dev/null @@ -1,577 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "errors" - "io" - "math" - "net/http" - "net/http/httptest" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/filter" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// fakePlaylists is a local fake for core/playlists.Playlists. It embeds the interface so -// unimplemented methods aren't needed here; only the ones this test exercises are overridden. -type fakePlaylists struct { - playlists.Playlists - - createdName string - createdIds []string - createErr error - - getPls *model.Playlist - getErr error - tracksRepo *tests.MockPlaylistTrackRepo - - getByIDPls *model.Playlist - getByIDErr error - - addPlaylistID string - addIds []string - addErr error - - removePlaylistID string - removeIds []string - removeErr error - - setImagePlaylistID string - setImageBytes []byte - setImageExt string - setImageErr error - - removeImagePlaylistID string - removeImageErr error - - deletePlaylistID string - deleteErr error - - updatePlaylistID string - updatePublic *bool - updateErr error -} - -func (f *fakePlaylists) Update(_ context.Context, playlistID string, _ *string, _ *string, public *bool, _ []string, _ []int) error { - f.updatePlaylistID = playlistID - f.updatePublic = public - return f.updateErr -} - -func (f *fakePlaylists) Delete(_ context.Context, id string) error { - f.deletePlaylistID = id - return f.deleteErr -} - -func (f *fakePlaylists) Create(_ context.Context, _ string, name string, ids []string) (string, error) { - f.createdName = name - f.createdIds = ids - if f.createErr != nil { - return "", f.createErr - } - return testID("pl-new"), nil -} - -// Get defaults to model.ErrNotFound when getByIDPls/getByIDErr aren't set, matching the real -// service's behavior for a missing or inaccessible playlist and letting getItem tests that don't -// care about playlists leave it unconfigured. -func (f *fakePlaylists) Get(_ context.Context, _ string) (*model.Playlist, error) { - if f.getByIDErr != nil { - return nil, f.getByIDErr - } - if f.getByIDPls == nil { - return nil, model.ErrNotFound - } - return f.getByIDPls, nil -} - -func (f *fakePlaylists) GetWithTracks(_ context.Context, _ string) (*model.Playlist, error) { - if f.getErr != nil { - return nil, f.getErr - } - if f.getPls == nil { - return nil, model.ErrNotFound // mirror the real repo: never (nil, nil) - } - return f.getPls, nil -} - -// Tracks serves the same getPls fixture as GetWithTracks. tracksRepo is kept so tests can assert -// what was pushed down to the query. -func (f *fakePlaylists) Tracks(_ context.Context, _ string) (model.PlaylistTrackRepository, error) { - if f.getErr != nil { - return nil, f.getErr - } - if f.getPls == nil { - return nil, model.ErrNotFound - } - f.tracksRepo = &tests.MockPlaylistTrackRepo{} - f.tracksRepo.SetData(f.getPls.Tracks) - return f.tracksRepo, nil -} - -func (f *fakePlaylists) AddTracks(_ context.Context, playlistID string, ids []string) (int, error) { - f.addPlaylistID = playlistID - f.addIds = ids - return len(ids), f.addErr -} - -func (f *fakePlaylists) RemoveTracks(_ context.Context, playlistID string, trackIds []string) error { - f.removePlaylistID = playlistID - f.removeIds = trackIds - return f.removeErr -} - -func (f *fakePlaylists) SetImage(_ context.Context, playlistID string, reader io.Reader, ext string) error { - f.setImagePlaylistID = playlistID - f.setImageExt = ext - if reader != nil { - f.setImageBytes, _ = io.ReadAll(reader) - } - return f.setImageErr -} - -func (f *fakePlaylists) RemoveImage(_ context.Context, playlistID string) error { - f.removeImagePlaylistID = playlistID - return f.removeImageErr -} - -var _ = DescribeTable("insertPosition", - func(position int64, want int) { - Expect(insertPosition(position)).To(Equal(want)) - }, - Entry("zero-based index becomes a 1-based position", int64(2), 3), - Entry("negative prepends", int64(-5), 1), - Entry("beyond int32 stays past the end instead of wrapping", int64(1)<<32+1, math.MaxInt32), - Entry("largest int64 stays past the end", int64(math.MaxInt64), math.MaxInt32), - Entry("smallest int64 prepends", int64(math.MinInt64), 1), -) - -var _ = Describe("Playlists", func() { - var api *Router - var fp *fakePlaylists - - BeforeEach(func() { - fp = &fakePlaylists{} - api = &Router{ds: &tests.MockDataStore{}, playlists: fp} - }) - - Describe("createPlaylist", func() { - It("creates a playlist and returns its id", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+dto.EncodeID(testID("s1"))+`","`+dto.EncodeID(testID("s2"))+`"]}`)). - WithContext(context.Background()) - invoke(api.createPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res map[string]string - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res["Id"]).To(Equal(dto.EncodeID(testID("pl-new")))) - Expect(fp.createdName).To(Equal("Mix")) - Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) - }) - - It("returns 400 on an invalid JSON body", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`not json`)). - WithContext(context.Background()) - invoke(api.createPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns 500 when the service fails", func() { - fp.createErr = errors.New("boom") - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix"}`)). - WithContext(context.Background()) - invoke(api.createPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - - createReq := func(body string) *http.Request { - return httptest.NewRequest("POST", "/Playlists", strings.NewReader(body)). - WithContext(GinkgoT().Context()) - } - - DescribeTable("visibility", - func(body string, wantPublic *bool, wantUpdatedID string) { - w := httptest.NewRecorder() - invoke(api.createPlaylist, w, createReq(body)) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(fp.updatePublic).To(Equal(wantPublic)) - Expect(fp.updatePlaylistID).To(Equal(wantUpdatedID)) - }, - Entry("applies IsPublic true", `{"Name":"Mix","IsPublic":true}`, new(true), testID("pl-new")), - Entry("applies an explicit IsPublic false", `{"Name":"Mix","IsPublic":false}`, new(false), testID("pl-new")), - Entry("leaves visibility alone when IsPublic is omitted", `{"Name":"Mix"}`, nil, ""), - ) - - It("returns 500 when the visibility update fails", func() { - fp.updateErr = errors.New("boom") - w := httptest.NewRecorder() - invoke(api.createPlaylist, w, createReq(`{"Name":"Mix","IsPublic":true}`)) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - - Describe("getPlaylistItems", func() { - It("maps playlist tracks to Audio BaseItemDtos, tagging each with its PlaylistItemId", func() { - fp.getPls = &model.Playlist{ - ID: testID("pl1"), - Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1"), Title: "Song One"}}, - {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2"), Title: "Song Two"}}, - }, - } - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - api.getPlaylistItems(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.TotalRecordCount).To(Equal(2)) - Expect(res.Items).To(HaveLen(2)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - Expect(res.Items[0].Type).To(Equal("Audio")) - Expect(res.Items[0].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("1"))) - Expect(res.Items[1].Id).To(Equal(dto.EncodeID(testID("s2")))) - Expect(res.Items[1].PlaylistItemId).To(Equal(dto.EncodePlaylistEntryID("2"))) - }) - - It("pages with StartIndex/Limit, pushing them down to the query", func() { - fp.getPls = &model.Playlist{ - ID: testID("pl1"), - Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, - {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, - {ID: "3", MediaFileID: testID("s3"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s3")}}, - }, - } - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Items?StartIndex=1&Limit=1", nil). - WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.getPlaylistItems, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.TotalRecordCount).To(Equal(3)) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeID(testID("s2")))) - Expect(fp.tracksRepo.Options.Offset).To(Equal(1)) - Expect(fp.tracksRepo.Options.Max).To(Equal(1)) - }) - - It("returns 404 for a non-owned or absent playlist", func() { - fp.getErr = model.ErrNotFound - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/missing/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("missing"))) - api.getPlaylistItems(w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("container id expansion", func() { - var ds *tests.MockDataStore - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - ds = &tests.MockDataStore{} - api = &Router{ds: ds, playlists: fp} - }) - - createWith := func(id string) { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists", strings.NewReader(`{"Name":"Mix","Ids":["`+dto.EncodeID(id)+`"]}`)). - WithContext(ctx) - invoke(api.createPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - } - - It("passes a bare song id through unchanged", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}}) - createWith(testID("s1")) - Expect(fp.createdIds).To(Equal([]string{testID("s1")})) - }) - - It("expands an album id into its songs, filtered by album", func() { - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{{ID: testID("al1")}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), AlbumID: testID("al1")}, {ID: testID("s2"), AlbumID: testID("al1")}, - }) - createWith(testID("al1")) - Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) - Expect(ds.MediaFile().(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByAlbum(testID("al1")).Filters)) - }) - - It("expands an artist id into its songs", func() { - ds.Artist().(*tests.MockArtistRepo).SetData(model.Artists{{ID: testID("ar1")}}) - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{{ID: testID("s1")}, {ID: testID("s2")}}) - createWith(testID("ar1")) - Expect(fp.createdIds).To(Equal([]string{testID("s1"), testID("s2")})) - Expect(ds.MediaFile().(*tests.MockMediaFileRepo).Options.Filters).To(Equal(filter.SongsByArtistID(testID("ar1")).Filters)) - }) - - It("expands a playlist id into its tracks' media file ids", func() { - fp.getPls = &model.Playlist{ID: testID("pl9"), Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: testID("s3")}, {ID: "2", MediaFileID: testID("s4")}, - }} - createWith(testID("pl9")) - Expect(fp.createdIds).To(Equal([]string{testID("s3"), testID("s4")})) - }) - }) - - Describe("getPlaylist", func() { - It("returns OpenAccess from Public and item ids (encoded media file ids, not entry ids)", func() { - pls := &model.Playlist{ - ID: testID("pl1"), - Public: true, - Tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: testID("s1"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s1")}}, - {ID: "2", MediaFileID: testID("s2"), PlaylistID: testID("pl1"), MediaFile: model.MediaFile{ID: testID("s2")}}, - }, - } - fp.getPls, fp.getByIDPls = pls, pls - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1"), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.getPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.PlaylistInfo - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.OpenAccess).To(BeTrue()) - Expect(res.Shares).To(BeEmpty()) - Expect(res.ItemIds).To(Equal([]string{dto.EncodeID(testID("s1")), dto.EncodeID(testID("s2"))})) - }) - - It("returns 404 for a non-owned or absent playlist", func() { - fp.getErr = model.ErrNotFound - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/missing", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("missing"))) - invoke(api.getPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("deleteItem", func() { - deleteReq := func(id string) *http.Request { - r := httptest.NewRequest("DELETE", "/Items/"+dto.EncodeID(id), nil).WithContext(context.Background()) - return withChiURLParam(r, "itemId", dto.EncodeID(id)) - } - - It("deletes the playlist and returns 204", func() { - w := httptest.NewRecorder() - api.deleteItem(w, deleteReq(testID("pl1"))) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.deletePlaylistID).To(Equal(testID("pl1"))) - }) - - It("returns 403 when the user doesn't own the playlist", func() { - fp.deleteErr = model.ErrNotAuthorized - w := httptest.NewRecorder() - api.deleteItem(w, deleteReq(testID("pl1"))) - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - - It("returns 404 for a missing playlist or non-playlist id", func() { - fp.deleteErr = model.ErrNotFound - w := httptest.NewRecorder() - api.deleteItem(w, deleteReq(testID("al1"))) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 500 on an unexpected error", func() { - fp.deleteErr = errors.New("boom") - w := httptest.NewRecorder() - api.deleteItem(w, deleteReq(testID("pl1"))) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - - Describe("addToPlaylist", func() { - It("adds tracks by song id from the lowercase ids param real Jellyfin clients send", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.addToPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.addPlaylistID).To(Equal(testID("pl1"))) - Expect(fp.addIds).To(Equal([]string{testID("s1"), testID("s2")})) - }) - - It("accepts a PascalCase Ids param (case-folded by the middleware)", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?Ids="+dto.EncodeID(testID("s1"))+","+dto.EncodeID(testID("s2")), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.addToPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.addIds).To(Equal([]string{testID("s1"), testID("s2")})) - }) - - It("returns 404 when the service rejects the request (not found/not owned)", func() { - fp.addErr = model.ErrNotAuthorized - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.addToPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 403 when the playlist is not editable (synced/smart), like Jellyfin", func() { - fp.addErr = model.ErrPlaylistNotEditable - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?ids="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.addToPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - - It("passes no ids (not a spurious empty string) when the ids param is absent", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.addToPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.addPlaylistID).To(Equal(testID("pl1"))) - Expect(fp.addIds).To(BeEmpty()) - }) - }) - - Describe("removeFromPlaylist", func() { - It("removes entries by the lowercase entryIds param real Jellyfin clients send (playlist-track position ids, not song ids)", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1")+","+dto.EncodePlaylistEntryID("2"), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.removeFromPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removePlaylistID).To(Equal(testID("pl1"))) - Expect(fp.removeIds).To(Equal([]string{"1", "2"})) - }) - - It("accepts a PascalCase EntryIds param (case-folded by the middleware)", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?EntryIds="+dto.EncodePlaylistEntryID("1")+","+dto.EncodePlaylistEntryID("2"), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.removeFromPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removeIds).To(Equal([]string{"1", "2"})) - }) - - It("404s an entryId that is a song id rather than a playlist-entry position", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodeID(testID("s1")), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.removeFromPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(fp.removeIds).To(BeEmpty()) - }) - - It("returns 404 when the service rejects the request (not found/not owned)", func() { - fp.removeErr = model.ErrNotFound - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1"), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.removeFromPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 403 when the playlist is not editable (synced/smart), like Jellyfin", func() { - fp.removeErr = model.ErrPlaylistNotEditable - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items?entryIds="+dto.EncodePlaylistEntryID("1"), nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.removeFromPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - - It("passes no ids (not a spurious empty string) when the entryIds param is absent", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("DELETE", "/Playlists/"+dto.EncodeID(testID("pl1"))+"/Items", nil).WithContext(context.Background()) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - invoke(api.removeFromPlaylist, w, r) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.removePlaylistID).To(Equal(testID("pl1"))) - Expect(fp.removeIds).To(BeEmpty()) - }) - }) - - Describe("getPlaylistUsers", func() { - It("returns the current user with CanEdit true for an editable playlist", func() { - fp.getByIDPls = &model.Playlist{ID: testID("pl1")} - w := httptest.NewRecorder() - ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) - r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - api.getPlaylistUsers(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res []dto.PlaylistUserPermissions - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res).To(Equal([]dto.PlaylistUserPermissions{{UserId: dto.EncodeID(testID("u1")), CanEdit: true}})) - }) - - It("reports CanEdit false for a synced playlist", func() { - fp.getByIDPls = &model.Playlist{ID: testID("pl1"), Sync: true} - w := httptest.NewRecorder() - ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) - r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - api.getPlaylistUsers(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - var res []dto.PlaylistUserPermissions - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res[0].CanEdit).To(BeFalse()) - }) - - It("returns 404 when the playlist is not visible", func() { - fp.getByIDErr = model.ErrNotFound - w := httptest.NewRecorder() - ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) - r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users", nil).WithContext(ctx) - r = withChiURLParam(r, "playlistId", dto.EncodeID(testID("pl1"))) - api.getPlaylistUsers(w, r) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("getPlaylistUser", func() { - requestUser := func() *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Playlists/"+testID("pl1")+"/Users/"+testID("u1"), nil).WithContext(context.Background()) - rctx := chi.NewRouteContext() - rctx.URLParams.Add("playlistId", dto.EncodeID(testID("pl1"))) - rctx.URLParams.Add("userId", testID("u1")) - r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) - api.getPlaylistUser(w, r) - return w - } - - It("returns CanEdit true for an editable playlist", func() { - fp.getByIDPls = &model.Playlist{ID: testID("pl1")} - w := requestUser() - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.PlaylistUserPermissions - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res).To(Equal(dto.PlaylistUserPermissions{UserId: testID("u1"), CanEdit: true})) - }) - - It("reports CanEdit false for a synced playlist", func() { - fp.getByIDPls = &model.Playlist{ID: testID("pl1"), Sync: true} - w := requestUser() - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.PlaylistUserPermissions - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.CanEdit).To(BeFalse()) - }) - }) -}) diff --git a/server/jellyfin/quickconnect.go b/server/jellyfin/quickconnect.go deleted file mode 100644 index f7fa1b979..000000000 --- a/server/jellyfin/quickconnect.go +++ /dev/null @@ -1,145 +0,0 @@ -package jellyfin - -import ( - "encoding/json" - "errors" - "net/http" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/quickconnect" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" -) - -func requireQuickConnect(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !conf.Server.Jellyfin.QuickConnect { - http.Error(w, "Quick connect is disabled", http.StatusUnauthorized) - return - } - next.ServeHTTP(w, r) - }) -} - -func (api *Router) quickConnectEnabled(w http.ResponseWriter, r *http.Request) { - api.ok(w, r, conf.Server.Jellyfin.QuickConnect) -} - -// Initiate is unauthenticated and its fields are kept for minutes, so their size must be bounded. -const maxQuickConnectField = 512 - -func (api *Router) quickConnectInitiate(w http.ResponseWriter, r *http.Request) { - a := parseMediaBrowserAuth(r) - if a.Client == "" || a.Device == "" || a.DeviceId == "" || a.Version == "" || - max(len(a.Client), len(a.Device), len(a.DeviceId), len(a.Version)) > maxQuickConnectField { - http.Error(w, "Client, Device, DeviceId and Version are required", http.StatusBadRequest) - return - } - req, err := api.quickConnect.Initiate(quickconnect.Device{ - ID: a.DeviceId, Name: a.Device, App: a.Client, AppVersion: a.Version, - }) - if errors.Is(err, quickconnect.ErrTooManyRequests) { - http.Error(w, "Too Many Requests", http.StatusTooManyRequests) - return - } - if err != nil { - api.internalError(w, r, err) - return - } - api.ok(w, r, quickConnectResult(req)) -} - -func (api *Router) quickConnectConnect(w http.ResponseWriter, r *http.Request) { - req, err := api.quickConnect.Status(r.URL.Query().Get("secret")) - if err != nil { - http.Error(w, "Unknown secret", http.StatusNotFound) - return - } - api.ok(w, r, quickConnectResult(req)) -} - -func (api *Router) quickConnectAuthorize(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - caller, _ := request.UserFrom(ctx) - userID := caller.ID - if encoded := r.URL.Query().Get("userid"); encoded != "" { - var ok bool - if userID, ok = dto.DecodeID(encoded); !ok { - http.Error(w, "Invalid userId", http.StatusBadRequest) - return - } - } - target := caller - if userID != caller.ID { - if !caller.IsAdmin { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - usr, err := api.ds.User().Get(ctx, userID) - if errors.Is(err, model.ErrNotFound) { - http.Error(w, "Unknown user", http.StatusNotFound) - return - } - if err != nil { - api.internalError(w, r, err) - return - } - target = *usr - } - - req, err := api.quickConnect.Authorize(r.URL.Query().Get("code"), target.ID) - switch { - case errors.Is(err, model.ErrNotFound): - http.Error(w, "Unknown code", http.StatusNotFound) - case errors.Is(err, quickconnect.ErrAlreadyAuthorized): - http.Error(w, "Code already used", http.StatusConflict) - case err != nil: - api.internalError(w, r, err) - default: - log.Info(ctx, "Jellyfin API: Quick Connect sign-in approved", "username", target.UserName, - "approvedBy", caller.UserName, "client", req.Device.App, "device", req.Device.Name) - api.ok(w, r, true) - } -} - -func (api *Router) authenticateWithQuickConnect(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - var body struct { - Secret string `json:"Secret"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Secret == "" { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - userID, err := api.quickConnect.Redeem(body.Secret) - if err != nil { - http.Error(w, "Unknown secret", http.StatusNotFound) - return - } - usr, err := api.ds.User().Get(ctx, userID) - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Jellyfin API: Quick Connect user not found", "userID", userID) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - if err != nil { - api.internalError(w, r, err) - return - } - api.signIn(w, r, usr) -} - -func quickConnectResult(req quickconnect.Request) dto.QuickConnectResult { - return dto.QuickConnectResult{ - Authenticated: req.Authorized(), - Secret: req.Secret, - Code: req.Code, - DeviceId: req.Device.ID, - DeviceName: req.Device.Name, - AppName: req.Device.App, - AppVersion: req.Device.AppVersion, - DateAdded: dto.JellyfinDate(&req.DateAdded), - } -} diff --git a/server/jellyfin/quickconnect_test.go b/server/jellyfin/quickconnect_test.go deleted file mode 100644 index 94573e6ff..000000000 --- a/server/jellyfin/quickconnect_test.go +++ /dev/null @@ -1,300 +0,0 @@ -package jellyfin - -import ( - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/quickconnect" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type fullQuickConnect struct{ quickconnect.QuickConnect } - -func (fullQuickConnect) Initiate(quickconnect.Device) (quickconnect.Request, error) { - return quickconnect.Request{}, quickconnect.ErrTooManyRequests -} - -var _ = Describe("QuickConnect", func() { - const finamp = `MediaBrowser Client="Finamp", Device="Pixel 7", DeviceId="dev-1", Version="1.0.0"` - var ( - api *Router - ds *tests.MockDataStore - qc quickconnect.QuickConnect - alice = model.User{ID: testID("alice"), UserName: "alice"} - bob = model.User{ID: testID("bob"), UserName: "bob"} - admin = model.User{ID: testID("admin"), UserName: "admin", IsAdmin: true} - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.QuickConnect = true - ds = &tests.MockDataStore{} - auth.Init(ds) - ur := ds.User().(*tests.MockedUserRepo) - for _, u := range []model.User{alice, bob, admin} { - Expect(ur.Put(GinkgoT().Context(), &u)).To(Succeed()) - } - qc = quickconnect.New() - api = &Router{ds: ds, quickConnect: qc} - }) - - as := func(r *http.Request, u model.User) *http.Request { - return r.WithContext(request.WithUser(r.Context(), u)) - } - initiate := func() quickconnect.Request { - req, err := qc.Initiate(quickconnect.Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"}) - Expect(err).ToNot(HaveOccurred()) - return req - } - - Describe("GET /QuickConnect/Enabled", func() { - DescribeTable("reports the config value", - func(enabled bool, expected string) { - conf.Server.Jellyfin.QuickConnect = enabled - w := httptest.NewRecorder() - api.quickConnectEnabled(w, httptest.NewRequest("GET", "/QuickConnect/Enabled", nil)) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(MatchJSON(expected)) - }, - Entry("enabled", true, "true"), - Entry("disabled", false, "false"), - ) - }) - - Describe("requireQuickConnect", func() { - next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusTeapot) }) - - It("rejects requests with 401 when Quick Connect is disabled", func() { - conf.Server.Jellyfin.QuickConnect = false - w := httptest.NewRecorder() - requireQuickConnect(next).ServeHTTP(w, httptest.NewRequest("POST", "/QuickConnect/Initiate", nil)) - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("passes requests through when Quick Connect is enabled", func() { - w := httptest.NewRecorder() - requireQuickConnect(next).ServeHTTP(w, httptest.NewRequest("POST", "/QuickConnect/Initiate", nil)) - Expect(w.Code).To(Equal(http.StatusTeapot)) - }) - }) - - Describe("POST /QuickConnect/Initiate", func() { - initiateWith := func(authHeader string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/QuickConnect/Initiate", nil) - r.Header.Set("Authorization", authHeader) - api.quickConnectInitiate(w, r) - return w - } - - It("returns all QuickConnectResult fields, with the device from the auth header", func() { - w := initiateWith(finamp) - - Expect(w.Code).To(Equal(http.StatusOK)) - var raw map[string]any - Expect(json.Unmarshal(w.Body.Bytes(), &raw)).To(Succeed()) - // sdk-kotlin declares every field non-null. - Expect(raw).To(HaveKeyWithValue("Authenticated", false)) - Expect(raw).To(HaveKeyWithValue("Secret", MatchRegexp(`^[0-9a-f]{64}$`))) - Expect(raw).To(HaveKeyWithValue("Code", MatchRegexp(`^\d{6}$`))) - Expect(raw).To(HaveKeyWithValue("DeviceId", "dev-1")) - Expect(raw).To(HaveKeyWithValue("DeviceName", "Pixel 7")) - Expect(raw).To(HaveKeyWithValue("AppName", "Finamp")) - Expect(raw).To(HaveKeyWithValue("AppVersion", "1.0.0")) - Expect(raw).To(HaveKeyWithValue("DateAdded", Not(BeEmpty()))) - - _, err := qc.Status(raw["Secret"].(string)) - Expect(err).ToNot(HaveOccurred()) - }) - - It("returns 400 when the auth header does not identify the client", func() { - w := initiateWith(`MediaBrowser Client="Finamp", Device="Pixel 7", Version="1.0.0"`) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns 400 when a client field is oversized", func() { - long := strings.Repeat("x", maxQuickConnectField+1) - api.quickConnect = fullQuickConnect{qc} - w := initiateWith(`MediaBrowser Client="Finamp", Device="` + long + `", DeviceId="dev-1", Version="1.0.0"`) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns 429 when too many requests are pending", func() { - api.quickConnect = fullQuickConnect{qc} - Expect(initiateWith(finamp).Code).To(Equal(http.StatusTooManyRequests)) - }) - }) - - Describe("GET /QuickConnect/Connect", func() { - connect := func(secret string) (int, dto.QuickConnectResult) { - w := httptest.NewRecorder() - invoke(api.quickConnectConnect, w, httptest.NewRequest("GET", "/QuickConnect/Connect?Secret="+secret, nil)) - var res dto.QuickConnectResult - if w.Code == http.StatusOK { - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - } - return w.Code, res - } - - It("reports whether the request has been authorized", func() { - req := initiate() - code, res := connect(req.Secret) - Expect(code).To(Equal(http.StatusOK)) - Expect(res.Authenticated).To(BeFalse()) - Expect(res.Code).To(Equal(req.Code)) - - _, err := qc.Authorize(req.Code, alice.ID) - Expect(err).ToNot(HaveOccurred()) - code, res = connect(req.Secret) - Expect(code).To(Equal(http.StatusOK)) - Expect(res.Authenticated).To(BeTrue()) - }) - - It("returns 404 for an unknown secret", func() { - code, _ := connect("unknown") - Expect(code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("POST /QuickConnect/Authorize", func() { - authorize := func(query string, u model.User) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - invoke(api.quickConnectAuthorize, w, as(httptest.NewRequest("POST", "/QuickConnect/Authorize?"+query, nil), u)) - return w - } - approver := func(req quickconnect.Request) string { - got, err := qc.Status(req.Secret) - Expect(err).ToNot(HaveOccurred()) - return got.UserID - } - - It("approves the code for the caller when no userId is given", func() { - req := initiate() - w := authorize("code="+req.Code, alice) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(MatchJSON("true")) - Expect(approver(req)).To(Equal(alice.ID)) - }) - - It("accepts the caller's own userId", func() { - req := initiate() - w := authorize("Code="+req.Code+"&UserId="+dto.EncodeID(alice.ID), alice) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(approver(req)).To(Equal(alice.ID)) - }) - - It("forbids a non-admin from approving for another user", func() { - req := initiate() - w := authorize("code="+req.Code+"&userId="+dto.EncodeID(bob.ID), alice) - Expect(w.Code).To(Equal(http.StatusForbidden)) - Expect(approver(req)).To(BeEmpty()) - }) - - It("lets an admin approve for another user", func() { - req := initiate() - w := authorize("code="+req.Code+"&userId="+dto.EncodeID(bob.ID), admin) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(approver(req)).To(Equal(bob.ID)) - }) - - It("returns 404 when an admin approves for an unknown user", func() { - req := initiate() - w := authorize("code="+req.Code+"&userId="+dto.EncodeID(testID("ghost")), admin) - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(approver(req)).To(BeEmpty()) - }) - - It("returns 400 for a malformed userId", func() { - req := initiate() - w := authorize("code="+req.Code+"&userId=not-a-guid", admin) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns 404 for an unknown code", func() { - w := authorize("code=000000", alice) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 409 for a code that is already approved", func() { - req := initiate() - Expect(authorize("code="+req.Code, alice).Code).To(Equal(http.StatusOK)) - Expect(authorize("code="+req.Code, bob).Code).To(Equal(http.StatusConflict)) - Expect(approver(req)).To(Equal(alice.ID)) - }) - }) - - Describe("POST /Users/AuthenticateWithQuickConnect", func() { - redeem := func(body string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Users/AuthenticateWithQuickConnect", strings.NewReader(body)) - r.Header.Set("Authorization", finamp) - api.authenticateWithQuickConnect(w, r) - return w - } - redeemSecret := func(secret string) *httptest.ResponseRecorder { - return redeem(`{"Secret":"` + secret + `"}`) - } - - It("signs in the approving user once", func() { - req := initiate() - _, _ = qc.Authorize(req.Code, alice.ID) - - w := redeemSecret(req.Secret) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.AuthenticationResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.User.Name).To(Equal("alice")) - Expect(res.ServerId).ToNot(BeEmpty()) - Expect(res.SessionInfo).ToNot(BeNil()) - Expect(res.SessionInfo.DeviceId).To(Equal("dev-1")) - claims, err := auth.Validate(res.AccessToken) - Expect(err).ToNot(HaveOccurred()) - Expect(claims.Subject).To(Equal("alice")) - - Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusNotFound)) - }) - - It("accepts a camelCase body", func() { - req := initiate() - _, _ = qc.Authorize(req.Code, alice.ID) - Expect(redeem(`{"secret":"` + req.Secret + `"}`).Code).To(Equal(http.StatusOK)) - }) - - It("returns 404 while the request is not approved", func() { - req := initiate() - Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusNotFound)) - }) - - DescribeTable("returns 400 for a bad body", - func(body string) { - Expect(redeem(body).Code).To(Equal(http.StatusBadRequest)) - }, - Entry("not JSON", `nope`), - Entry("no secret", `{}`), - ) - - It("returns 401 when the approving user no longer exists", func() { - req := initiate() - _, _ = qc.Authorize(req.Code, testID("ghost")) - Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusUnauthorized)) - }) - - It("returns 500 when the user lookup fails", func() { - req := initiate() - _, _ = qc.Authorize(req.Code, alice.ID) - ds.User().(*tests.MockedUserRepo).Error = errors.New("db down") - Expect(redeemSecret(req.Secret).Code).To(Equal(http.StatusInternalServerError)) - }) - }) -}) diff --git a/server/jellyfin/response.go b/server/jellyfin/response.go deleted file mode 100644 index f4b96eda3..000000000 --- a/server/jellyfin/response.go +++ /dev/null @@ -1,87 +0,0 @@ -package jellyfin - -import ( - "bufio" - "bytes" - "encoding/json" - "io" - "iter" - "strconv" - - "github.com/navidrome/navidrome/server/jellyfin/dto" -) - -// streamItemsEnvelope writes a QueryResult, byte-identical to json.NewEncoder(w).Encode(q). -// -// A mid-stream error aborts without closing the envelope: the 200 is already committed, so a -// truncated-but-valid body would let a sync client treat the short list as the whole library and -// prune local tracks. Malformed JSON forces its parser to fail instead. Callers open the cursor -// before the first byte, so this only fires on a rare mid-iteration failure. -func streamItemsEnvelope(w io.Writer, items iter.Seq2[dto.BaseItemDto, error], total, start int) error { - bw := bufio.NewWriterSize(w, 64*1024) - _, _ = bw.WriteString(`{"Items":[`) - if err := encodeItems(bw, items); err != nil { - _ = bw.Flush() - return err - } - _, _ = bw.WriteString(`],"TotalRecordCount":`) - _, _ = bw.WriteString(strconv.Itoa(total)) - _, _ = bw.WriteString(`,"StartIndex":`) - _, _ = bw.WriteString(strconv.Itoa(start)) - _, _ = bw.WriteString("}\n") - return bw.Flush() -} - -// streamItemsArray writes a bare JSON array — the shape /Items/Latest returns, with no envelope. -func streamItemsArray(w io.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { - bw := bufio.NewWriterSize(w, 64*1024) - _, _ = bw.WriteString("[") - if err := encodeItems(bw, items); err != nil { - _ = bw.Flush() - return err - } - _, _ = bw.WriteString("]\n") - return bw.Flush() -} - -// encodeItems writes items comma-separated. Unlike the fixed envelope writes, these are checked: -// bufio surfaces a latched write error here once a flush fails, and a client that has gone away must -// abandon the scan rather than pull the rest of the library through the cursor — which would hold its -// pooled DB connection and stream slot for a response nobody is reading. -func encodeItems(bw *bufio.Writer, items iter.Seq2[dto.BaseItemDto, error]) error { - // One reused buffer+encoder, so per-item JSON doesn't allocate. Encode HTML-escapes like - // json.Marshal, and appends a newline that's dropped below. - var itemBuf bytes.Buffer - enc := json.NewEncoder(&itemBuf) - first := true - for item, err := range items { - if err != nil { - return err - } - if !first { - if _, err := bw.WriteString(","); err != nil { - return err - } - } - first = false - itemBuf.Reset() - if err := enc.Encode(item); err != nil { - return err - } - b := itemBuf.Bytes() - if _, err := bw.Write(b[:len(b)-1]); err != nil { - return err - } - } - return nil -} - -func sliceItems(items []dto.BaseItemDto) iter.Seq2[dto.BaseItemDto, error] { - return func(yield func(dto.BaseItemDto, error) bool) { - for i := range items { - if !yield(items[i], nil) { - return - } - } - } -} diff --git a/server/jellyfin/response_test.go b/server/jellyfin/response_test.go deleted file mode 100644 index c32c566fc..000000000 --- a/server/jellyfin/response_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package jellyfin - -import ( - "bytes" - "encoding/json" - "errors" - "iter" - "strings" - - "github.com/navidrome/navidrome/server/jellyfin/dto" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// deadWriter stands in for a client that went away mid-response. -type deadWriter struct{} - -func (deadWriter) Write([]byte) (int, error) { return 0, errors.New("connection reset by peer") } - -var _ = Describe("streaming a materialized QueryResult", func() { - // The materialized path (api.ok -> writeItems -> sliceItems) must stay byte-for-byte identical to - // what json.Encoder.Encode produced before, so no client sees a different response. - assertIdenticalToEncoder := func(q dto.QueryResult) { - var got bytes.Buffer - Expect(streamItemsEnvelope(&got, sliceItems(q.Items), q.TotalRecordCount, q.StartIndex)).To(Succeed()) - - var want bytes.Buffer - Expect(json.NewEncoder(&want).Encode(q)).To(Succeed()) - - Expect(got.String()).To(Equal(want.String())) - } - - It("encodes an empty item list", func() { - assertIdenticalToEncoder(dto.QueryResult{Items: []dto.BaseItemDto{}}) - }) - - It("encodes a single item", func() { - assertIdenticalToEncoder(dto.QueryResult{ - Items: []dto.BaseItemDto{{Id: "a", Name: "One"}}, - TotalRecordCount: 1, - }) - }) - - It("encodes multiple items, honoring HTML escaping and StartIndex", func() { - assertIdenticalToEncoder(dto.QueryResult{ - Items: []dto.BaseItemDto{ - {Id: "a", Name: "One"}, - {Id: "b", Name: "Two & <Three>"}, - }, - TotalRecordCount: 500, - StartIndex: 100, - }) - }) -}) - -var _ = Describe("streamItemsEnvelope", func() { - seqOf := func(items ...dto.BaseItemDto) iter.Seq2[dto.BaseItemDto, error] { - return func(yield func(dto.BaseItemDto, error) bool) { - for _, it := range items { - if !yield(it, nil) { - return - } - } - } - } - - It("produces the same bytes as encoding an equivalent QueryResult", func() { - items := []dto.BaseItemDto{{Id: "a", Name: "One"}, {Id: "b", Name: "Two & <Three>"}} - var got bytes.Buffer - Expect(streamItemsEnvelope(&got, seqOf(items...), 500, 100)).To(Succeed()) - - var want bytes.Buffer - Expect(json.NewEncoder(&want).Encode(dto.QueryResult{Items: items, TotalRecordCount: 500, StartIndex: 100})).To(Succeed()) - Expect(got.String()).To(Equal(want.String())) - }) - - It("emits an empty array (not null) for a sequence that yields nothing", func() { - var got bytes.Buffer - Expect(streamItemsEnvelope(&got, seqOf(), 0, 0)).To(Succeed()) - Expect(got.String()).To(Equal("{\"Items\":[],\"TotalRecordCount\":0,\"StartIndex\":0}\n")) - }) - - // A client that goes away must not keep the source (a DB cursor, holding its pooled connection - // and a stream slot) running to the end of the library. - It("stops pulling from the source once writing fails", func() { - const total = 20000 - pulled := 0 - seq := func(yield func(dto.BaseItemDto, error) bool) { - for range total { - pulled++ - if !yield(dto.BaseItemDto{Id: "a", Name: strings.Repeat("x", 200)}, nil) { - return - } - } - } - err := streamItemsEnvelope(deadWriter{}, seq, total, 0) - Expect(err).To(HaveOccurred()) - Expect(pulled).To(BeNumerically("<", total), "should abandon the scan, not drain it") - }) - - It("aborts on a mid-stream error, leaving the envelope open (malformed) so the client fails loudly", func() { - boom := errors.New("scan failed") - first := dto.BaseItemDto{Id: "a", Name: "One"} - seq := func(yield func(dto.BaseItemDto, error) bool) { - if !yield(first, nil) { - return - } - yield(dto.BaseItemDto{}, boom) - } - var got bytes.Buffer - err := streamItemsEnvelope(&got, seq, 7, 0) - Expect(err).To(MatchError(boom)) - firstJSON, _ := json.Marshal(first) - Expect(got.String()).To(Equal("{\"Items\":[" + string(firstJSON))) - }) -}) diff --git a/server/jellyfin/routing_test.go b/server/jellyfin/routing_test.go deleted file mode 100644 index 70da5b1eb..000000000 --- a/server/jellyfin/routing_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package jellyfin - -import ( - "net/http" - "net/http/httptest" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Real Jellyfin servers route path segments case-insensitively, but chi's default matching is -// case-sensitive. Jellyfin wires up server.CaseInsensitivePaths (see server/case_insensitive_routes.go -// for the unit-level tests of that helper) to work around this. These tests are an end-to-end proof -// that requests using non-canonical casing are still routed correctly, both when the router is used -// directly and when mounted under a parent (as it is in production via server.MountRouter). -var _ = Describe("Case-insensitive routing", func() { - var api *Router - - BeforeEach(func() { - api = New(&tests.MockDataStore{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - }) - - It("serves a fully lowercase path directly", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/system/info/public", nil) - api.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - }) - - It("serves a mixed/weird-case path directly", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/SYSTEM/Info/PUBLIC", nil) - api.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - }) - - It("serves a lowercase login path directly", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/users/authenticatebyname", nil) - api.ServeHTTP(w, r) - // MockDataStore has no users, so authentication itself may fail downstream, but the - // route must be found (not a 404) to prove case-insensitive matching worked. - Expect(w.Code).ToNot(Equal(http.StatusNotFound)) - }) - - It("serves a lowercase path when mounted under a parent router, replicating production", func() { - parent := chi.NewRouter() - parent.Mount("/jellyfin", api) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/jellyfin/system/info/public", nil) - parent.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - }) -}) diff --git a/server/jellyfin/sessions.go b/server/jellyfin/sessions.go deleted file mode 100644 index e60b1bf5b..000000000 --- a/server/jellyfin/sessions.go +++ /dev/null @@ -1,120 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "net/http" - - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" -) - -// playbackReport is the subset of Jellyfin's PlaybackStartInfo/PlaybackProgressInfo -// fields Navidrome needs to keep its playback/scrobbling state in sync. -type playbackReport struct { - ItemId string `json:"ItemId"` - PositionTicks int64 `json:"PositionTicks"` - IsPaused bool `json:"IsPaused"` -} - -// decodeReport reads the playback report body. ItemId falls back to a query param (some clients send -// it there) and is decoded here since it flows straight into scrobbler lookups by media file id. -// These endpoints always answer 204 (see reportPlaybackStart), so a malformed id isn't a request -// failure; it just can't identify a track, same as an absent one. -func (api *Router) decodeReport(r *http.Request) playbackReport { - var body playbackReport - _ = json.NewDecoder(r.Body).Decode(&body) - if body.ItemId == "" { - body.ItemId = r.URL.Query().Get("itemid") - } - if id, ok := dto.DecodeID(body.ItemId); ok { - body.ItemId = id - } else { - body.ItemId = "" - } - return body -} - -// clientIdentity returns the scrobbler cache key/display name for the caller's -// player. Both are zero values if withPlayer could not resolve a player. -func clientIdentity(ctx context.Context) (id, name string) { - player, _ := request.PlayerFrom(ctx) - return player.ID, player.Client -} - -// reportPlaybackStart handles POST /Sessions/Playing, sent once when a client starts an item. -// -// These Sessions endpoints report only the caller's own playback and never expose content, so unlike -// browse/stream they are intentionally not library-access-gated. -func (api *Router) reportPlaybackStart(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - body := api.decodeReport(r) - clientId, clientName := clientIdentity(ctx) - err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ - MediaId: body.ItemId, - PositionMs: dto.MillisFromTicks(body.PositionTicks), - State: scrobbler.StatePlaying, - PlaybackRate: 1.0, - ClientId: clientId, - ClientName: clientName, - }) - if err != nil { - log.Warn(ctx, "Jellyfin API: report playback start failed", "id", body.ItemId, err) - } - w.WriteHeader(http.StatusNoContent) -} - -// reportPlaybackProgress handles POST /Sessions/Playing/Progress, sent periodically -// (and on pause/resume/seek) while a client keeps playing an item. -func (api *Router) reportPlaybackProgress(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - body := api.decodeReport(r) - state := scrobbler.StatePlaying - if body.IsPaused { - state = scrobbler.StatePaused - } - clientId, clientName := clientIdentity(ctx) - err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ - MediaId: body.ItemId, - PositionMs: dto.MillisFromTicks(body.PositionTicks), - State: state, - PlaybackRate: 1.0, - ClientId: clientId, - ClientName: clientName, - }) - if err != nil { - log.Warn(ctx, "Jellyfin API: report playback progress failed", "id", body.ItemId, err) - } - w.WriteHeader(http.StatusNoContent) -} - -// reportPlaybackStopped handles POST /Sessions/Playing/Stopped, sent once when playback ends. -// -// Jellyfin clients (Finamp) send a Stopped report on *every* stop, even an immediate track switch, -// so the play threshold is applied server-side: ReportPlayback's StateStopped logic counts the play -// only past 50% (capped at 4 minutes). Force-submitting here would mark a one-second skip as played. -func (api *Router) reportPlaybackStopped(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - body := api.decodeReport(r) - clientId, clientName := clientIdentity(ctx) - - err := api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ - MediaId: body.ItemId, - PositionMs: dto.MillisFromTicks(body.PositionTicks), - State: scrobbler.StateStopped, - ClientId: clientId, - ClientName: clientName, - }) - if err != nil { - log.Warn(ctx, "Jellyfin API: report playback stopped failed", "id", body.ItemId, err) - } - w.WriteHeader(http.StatusNoContent) -} - -// acknowledge answers requests Navidrome keeps no state for: session capabilities (not tracked per -// session) and playback pings (transcodes live only as long as their stream request). -func (api *Router) acknowledge(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) -} diff --git a/server/jellyfin/sessions_test.go b/server/jellyfin/sessions_test.go deleted file mode 100644 index f2f04497c..000000000 --- a/server/jellyfin/sessions_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package jellyfin - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "strings" - - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// fakePlayTracker is a local double for scrobbler.PlayTracker, mirroring -// server/subsonic's fakePlayTracker. -type fakePlayTracker struct { - scrobbler.PlayTracker - reported []scrobbler.ReportPlaybackParams - submitted []scrobbler.Submission -} - -func (f *fakePlayTracker) ReportPlayback(_ context.Context, p scrobbler.ReportPlaybackParams) error { - f.reported = append(f.reported, p) - return nil -} - -func (f *fakePlayTracker) Submit(_ context.Context, s []scrobbler.Submission) error { - f.submitted = append(f.submitted, s...) - return nil -} - -// fakePlayers is a local double for core.Players, used to exercise withPlayer. -type fakePlayers struct { - core.Players - err error - registerCalls int - lastClient string - trc *model.Transcoding -} - -func (f *fakePlayers) Register(_ context.Context, id, client, _, _ string) (*model.Player, *model.Transcoding, error) { - f.registerCalls++ - f.lastClient = client - if f.err != nil { - return nil, nil, f.err - } - return &model.Player{ID: id, Client: client}, f.trc, nil -} - -var _ = Describe("Sessions", func() { - var api *Router - var pt *fakePlayTracker - - authed := func(r *http.Request) *http.Request { - ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice"}) - ctx = request.WithPlayer(ctx, model.Player{ID: testID("p1"), Client: "Finamp"}) - return r.WithContext(ctx) - } - - BeforeEach(func() { - pt = &fakePlayTracker{} - api = &Router{ds: &tests.MockDataStore{}, scrobbler: pt} - }) - - Describe("reportPlaybackStart", func() { - It("reports playback start with the item id and position", func() { - w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":10000000}`))) - - invoke(api.reportPlaybackStart, w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].MediaId).To(Equal(testID("s1"))) - Expect(pt.reported[0].PositionMs).To(Equal(int64(1000))) - Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying)) - Expect(pt.reported[0].ClientId).To(Equal(testID("p1"))) - Expect(pt.reported[0].ClientName).To(Equal("Finamp")) - }) - - It("falls back to the ItemId query param when the body has none", func() { - w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing?ItemId="+dto.EncodeID(testID("s2")), nil)) - - invoke(api.reportPlaybackStart, w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].MediaId).To(Equal(testID("s2"))) - }) - - It("still reports (with an empty MediaId) for a malformed ItemId, rather than failing the request", func() { - w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing", strings.NewReader(`{"ItemId":"not-a-valid-id","PositionTicks":10000000}`))) - - invoke(api.reportPlaybackStart, w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].MediaId).To(BeEmpty()) - }) - }) - - Describe("reportPlaybackProgress", func() { - It("reports the playing state when not paused", func() { - w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":20000000,"IsPaused":false}`))) - - invoke(api.reportPlaybackProgress, w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].State).To(Equal(scrobbler.StatePlaying)) - Expect(pt.reported[0].PositionMs).To(Equal(int64(2000))) - }) - - It("reports the paused state when IsPaused is true", func() { - w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Progress", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":20000000,"IsPaused":true}`))) - - invoke(api.reportPlaybackProgress, w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].State).To(Equal(scrobbler.StatePaused)) - }) - }) - - Describe("reportPlaybackStopped", func() { - It("reports the stopped state and lets the scrobbler apply its play threshold", func() { - w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Playing/Stopped", strings.NewReader(`{"ItemId":"`+dto.EncodeID(testID("s1"))+`","PositionTicks":600000000}`))) - - invoke(api.reportPlaybackStopped, w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - - Expect(pt.reported).To(HaveLen(1)) - Expect(pt.reported[0].MediaId).To(Equal(testID("s1"))) - Expect(pt.reported[0].State).To(Equal(scrobbler.StateStopped)) - Expect(pt.reported[0].PositionMs).To(Equal(int64(60000))) - // IgnoreScrobble stays false so ReportPlayback's own StateStopped threshold decides - // whether the play counts; we no longer force a Submit that would bypass it. - Expect(pt.reported[0].IgnoreScrobble).To(BeFalse()) - Expect(pt.submitted).To(BeEmpty()) - }) - }) - - Describe("acknowledge", func() { - It("returns 204 No Content and does not touch the scrobbler", func() { - w := httptest.NewRecorder() - r := authed(httptest.NewRequest("POST", "/Sessions/Capabilities", strings.NewReader(`{"SupportsMediaControl":true}`))) - - api.acknowledge(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(pt.reported).To(BeEmpty()) - Expect(pt.submitted).To(BeEmpty()) - }) - }) -}) - -var _ = Describe("withPlayer middleware", func() { - var api *Router - var fp *fakePlayers - - BeforeEach(func() { - fp = &fakePlayers{} - api = &Router{ds: &tests.MockDataStore{}, players: fp} - }) - - It("registers a player from the Emby device info and injects it into the context", func() { - var gotPlayer model.Player - var gotOk bool - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPlayer, gotOk = request.PlayerFrom(r.Context()) - w.WriteHeader(http.StatusNoContent) - }) - - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Sessions/Playing", nil) - r.Header.Set("X-Emby-Authorization", `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="dev1", Version="1.0"`) - - api.withPlayer(next).ServeHTTP(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(gotOk).To(BeTrue()) - Expect(gotPlayer.ID).To(Equal("dev1")) - Expect(gotPlayer.Client).To(Equal("Finamp")) - }) - - It("fails open (no player in context) when registration errors", func() { - fp.err = errors.New("boom") - var gotOk bool - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, gotOk = request.PlayerFrom(r.Context()) - w.WriteHeader(http.StatusNoContent) - }) - - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Sessions/Playing", nil) - - api.withPlayer(next).ServeHTTP(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(gotOk).To(BeFalse()) - }) - - // The /socket handshake authenticates via ?api_key= with no X-Emby-Authorization header, so it - // carries no client/device info; registering it would create a junk player named " []". - It("skips registration when the request has no client or device info", func() { - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) }) - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/socket?api_key=tok", nil) - - api.withPlayer(next).ServeHTTP(w, r) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(fp.registerCalls).To(Equal(0)) - }) -}) diff --git a/server/jellyfin/similar.go b/server/jellyfin/similar.go deleted file mode 100644 index 50503160c..000000000 --- a/server/jellyfin/similar.go +++ /dev/null @@ -1,227 +0,0 @@ -package jellyfin - -import ( - "context" - "errors" - "fmt" - "net/http" - "time" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/slice" -) - -// similarWait bounds how long a Similar request waits for the provider fetch. Returning the real -// result beats an instant empty list, which clients cache as "no similar items exist". A var so -// tests can shorten it. -var similarWait = 10 * time.Second - -const ( - defaultSimilarLimit = 20 - maxSimilarLimit = 100 - // A mix is a playback queue, not a "related items" list: Finamp's Radio Mix asks for 250, so the - // Similar ceiling would truncate it. Real Jellyfin builds mixes from a 200-track genre query. - maxInstantMixLimit = 500 -) - -// similarFetchTimeout bounds the detached background fetch so a hung provider can't hold a goroutine -// indefinitely. -const similarFetchTimeout = time.Minute - -// awaitSimilar runs fetch on a detached background context (so it completes and caches even if the -// request times out or the client disconnects), waiting up to similarWait then answering empty. -// Identical concurrent requests share one fetch via singleflight; the key includes the user since -// mapped items embed that user's annotations. -func (api *Router) awaitSimilar(ctx context.Context, id string, limit int, fetch func(context.Context) dto.QueryResult) dto.QueryResult { - u, _ := request.UserFrom(ctx) - key := fmt.Sprintf("%s|%s|%d", u.ID, id, limit) - ch := api.similarFlight.DoChan(key, func() (any, error) { - bgCtx, cancel := context.WithTimeout(request.WithUser(context.Background(), u), similarFetchTimeout) - defer cancel() - return fetch(bgCtx), nil - }) - select { - case res := <-ch: - return res.Val.(dto.QueryResult) - case <-time.After(similarWait): - return result(nil, 0, 0) - } -} - -// getSimilarArtists answers GET /Artists/{itemId}/Similar with related artists from the same -// external.Provider that powers Subsonic's getArtistInfo2. Only artists present in the library are -// returned. Any provider error degrades to an empty result, not a 404 the client would keep retrying. -func (api *Router) getSimilarArtists(w http.ResponseWriter, r *http.Request) { - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) - api.ok(w, r, api.awaitSimilar(r.Context(), id, limit, func(ctx context.Context) dto.QueryResult { - return api.similarArtists(ctx, id, limit) - })) -} - -// getSimilarItems answers GET /Items/{itemId}/Similar with items of the target's kind: similar -// songs for a track, albums for an album, artists for an artist. An unresolvable id yields an empty -// result (not 404) so the client stops retrying. -func (api *Router) getSimilarItems(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) - - entity, err := model.GetEntityByID(ctx, api.ds, id) - if err != nil { - api.ok(w, r, result(nil, 0, 0)) - return - } - api.ok(w, r, api.awaitSimilar(ctx, id, limit, func(ctx context.Context) dto.QueryResult { - switch entity.(type) { - case *model.Artist: - return api.similarArtists(ctx, id, limit) - case *model.Album: - return api.similarAlbums(ctx, id, limit) - default: // *model.MediaFile - return api.similarSongs(ctx, id, limit) - } - })) -} - -// getSimilarAlbums answers GET /Albums/{itemId}/Similar, powering Finamp's albumMix radio mode. -func (api *Router) getSimilarAlbums(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxSimilarLimit) - api.ok(w, r, api.awaitSimilar(ctx, "albsim|"+id, limit, func(ctx context.Context) dto.QueryResult { - return api.similarAlbums(ctx, id, limit) - })) -} - -// getInstantMix answers GET /Items/{itemId}/InstantMix. Finamp plays exactly what is returned, so -// a track seed leads its own mix; provider errors and unknown seeds degrade to seed-only/empty -// results, never a 404 the client would surface as an error. -func (api *Router) getInstantMix(w http.ResponseWriter, r *http.Request) { - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return - } - api.instantMix(w, r, id) -} - -// getInstantMixByQuery serves the legacy Artists/InstantMix and MusicGenres/InstantMix forms, -// which take the seed as ?id= instead of a path segment. -func (api *Router) getInstantMixByQuery(w http.ResponseWriter, r *http.Request) { - id, ok := dto.DecodeID(req.Params(r).StringOr("id", "")) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - api.instantMix(w, r, id) -} - -func (api *Router) instantMix(w http.ResponseWriter, r *http.Request, id string) { - ctx := r.Context() - limit := clampLimit(req.Params(r).IntOr("limit", 0), defaultSimilarLimit, maxInstantMixLimit) - - // Genre ids don't resolve via GetEntityByID, so a not-found entity is fine: it is just "not a - // song" and the provider knows what to do with it. A real lookup failure still stops here. - entity, err := model.GetEntityByID(ctx, api.ds, id) - if err != nil && !errors.Is(err, model.ErrNotFound) { - api.ok(w, r, result(nil, 0, 0)) - return - } - mf, isSong := entity.(*model.MediaFile) - if isSong { - if u, _ := request.UserFrom(ctx); !u.HasLibraryAccess(mf.LibraryID) { - api.ok(w, r, result(nil, 0, 0)) - return - } - } - // Prefixed key: a mix must not share the singleflight/cache slot with a Similar request. - tail := api.awaitSimilar(ctx, "mix|"+id, limit, func(ctx context.Context) dto.QueryResult { - return api.similarSongs(ctx, id, limit) - }) - if !isSong { - // Container seeds: the provider's similar songs already blend the seed's own tracks. - api.ok(w, r, tail) - return - } - // The seed leads the mix and must not depend on the provider: a slow or failing provider times - // the await out with an empty tail, but the tapped track still plays. - items := []dto.BaseItemDto{dto.SongToBaseItem(*mf, nil)} - for _, it := range tail.Items { - if len(items) >= limit { - break - } - if it.Id != items[0].Id { - items = append(items, it) - } - } - api.ok(w, r, result(items, len(items), 0)) -} - -func (api *Router) similarArtists(ctx context.Context, id string, limit int) dto.QueryResult { - artist, err := api.provider.UpdateArtistInfo(ctx, id, limit, false) - if err != nil { - log.Debug(ctx, "Jellyfin API: no similar artists", "id", id, err) - return result(nil, 0, 0) - } - present := slice.Filter(artist.SimilarArtists, func(a model.Artist) bool { return a.ID != "" }) - items := slice.Map(present, func(a model.Artist) dto.BaseItemDto { return dto.ArtistToBaseItem(a, nil) }) - return result(items, len(items), 0) -} - -func (api *Router) similarSongs(ctx context.Context, id string, limit int) dto.QueryResult { - songs, err := api.provider.SimilarSongs(ctx, id, limit) - if err != nil { - log.Debug(ctx, "Jellyfin API: no similar songs", "id", id, err) - return result(nil, 0, 0) - } - // Filter to the caller's libraries; the provider can return songs from any library. - u, _ := request.UserFrom(ctx) - var items []dto.BaseItemDto - for _, mf := range songs { - if u.HasLibraryAccess(mf.LibraryID) { - items = append(items, dto.SongToBaseItem(mf, nil)) - } - } - return result(items, len(items), 0) -} - -// similarAlbums derives similar albums from the provider's similar-songs signal (there's no direct -// "similar albums" source), keeping each album once in first-seen order and resolving it to a full -// model.Album for cover art and metadata. -func (api *Router) similarAlbums(ctx context.Context, id string, limit int) dto.QueryResult { - songs, err := api.provider.SimilarSongs(ctx, id, limit*5) - if err != nil { - log.Debug(ctx, "Jellyfin API: no similar albums", "id", id, err) - return result(nil, 0, 0) - } - u, _ := request.UserFrom(ctx) - // An album is not similar to itself, and the sampled-seed fallback returns its own tracks. - seen := map[string]bool{id: true} - var items []dto.BaseItemDto - for _, s := range songs { - if s.AlbumID == "" || seen[s.AlbumID] { - continue - } - seen[s.AlbumID] = true - if al, err := api.ds.Album().Get(ctx, s.AlbumID); err == nil && u.HasLibraryAccess(al.LibraryID) { - items = append(items, dto.AlbumToBaseItem(*al, nil)) - if len(items) >= limit { - break - } - } - } - return result(items, len(items), 0) -} diff --git a/server/jellyfin/similar_test.go b/server/jellyfin/similar_test.go deleted file mode 100644 index 42d04a632..000000000 --- a/server/jellyfin/similar_test.go +++ /dev/null @@ -1,234 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "fmt" - "net/http/httptest" - "strconv" - "sync/atomic" - "time" - - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("awaitSimilar", func() { - var api *Router - ctxFor := func(userID string) context.Context { - return request.WithUser(context.Background(), model.User{ID: userID}) - } - shortenWait := func() { - old := similarWait - similarWait = 20 * time.Millisecond - DeferCleanup(func() { similarWait = old }) - } - - BeforeEach(func() { - api = &Router{} - }) - - It("returns the fetch result when it completes within the wait", func() { - res := api.awaitSimilar(ctxFor(testID("u1")), "id1", 20, func(context.Context) dto.QueryResult { - return result([]dto.BaseItemDto{{Name: "fast"}}, 1, 0) - }) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Name).To(Equal("fast")) - }) - - It("returns an empty result when the fetch exceeds the wait", func() { - shortenWait() - release := make(chan struct{}) - DeferCleanup(func() { close(release) }) - res := api.awaitSimilar(ctxFor(testID("u1")), "id2", 20, func(context.Context) dto.QueryResult { - <-release // hung provider; would finish caching in the background - return result([]dto.BaseItemDto{{Name: "late"}}, 1, 0) - }) - Expect(res.Items).To(BeEmpty()) - Expect(res.TotalRecordCount).To(Equal(0)) - }) - - It("dedupes requests into the in-flight fetch", func() { - shortenWait() - var calls atomic.Int32 - release := make(chan struct{}) - fetch := func(context.Context) dto.QueryResult { - calls.Add(1) - <-release - return result(nil, 0, 0) - } - // Both calls time out, but the flight can't complete before release closes, so the - // second call must join it rather than start a new fetch. - api.awaitSimilar(ctxFor(testID("u1")), "id3", 20, fetch) - api.awaitSimilar(ctxFor(testID("u1")), "id3", 20, fetch) - close(release) - Eventually(calls.Load).Should(Equal(int32(1))) - Consistently(calls.Load, "50ms").Should(Equal(int32(1))) - }) - - It("does not share fetches across users (items embed the user's annotations)", func() { - var calls atomic.Int32 - fetch := func(context.Context) dto.QueryResult { - calls.Add(1) - return result(nil, 0, 0) - } - api.awaitSimilar(ctxFor(testID("u1")), "id4", 20, fetch) - api.awaitSimilar(ctxFor(testID("u2")), "id4", 20, fetch) - Expect(calls.Load()).To(Equal(int32(2))) - }) - - It("hands the fetch a deadline-bounded background context", func() { - var deadline time.Time - var hasDeadline bool - api.awaitSimilar(ctxFor(testID("u1")), "id5", 20, func(ctx context.Context) dto.QueryResult { - deadline, hasDeadline = ctx.Deadline() - return result(nil, 0, 0) - }) - Expect(hasDeadline).To(BeTrue(), "background fetch must not be able to run forever") - Expect(time.Until(deadline)).To(BeNumerically("<=", similarFetchTimeout)) - }) -}) - -// blockingProvider hangs SimilarSongs until release is closed, simulating a slow/unreachable agent. -type blockingProvider struct { - external.Provider - release chan struct{} -} - -func (p *blockingProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { - <-p.release - return nil, nil -} - -// fakeSimilarProvider returns up to count of its canned songs, like a real agent honoring the limit. -type fakeSimilarProvider struct { - external.Provider - songs model.MediaFiles -} - -func (p *fakeSimilarProvider) SimilarSongs(_ context.Context, _ string, count int) (model.MediaFiles, error) { - return p.songs[:min(count, len(p.songs))], nil -} - -var _ = Describe("getInstantMix", func() { - It("returns the seed track even when the provider fetch exceeds the wait", func() { - old := similarWait - similarWait = 20 * time.Millisecond - DeferCleanup(func() { similarWait = old }) - - ds := &tests.MockDataStore{} - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Seed Song", LibraryID: 1}, - }) - release := make(chan struct{}) - DeferCleanup(func() { close(release) }) - api := &Router{ds: ds, provider: &blockingProvider{release: release}} - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/InstantMix", nil). - WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - api.getInstantMix(w, r) - - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Name).To(Equal("Seed Song")) - }) - - // Finamp's Radio Mix asks for limit=250. Clamping that to the Similar ceiling (100) truncated the - // queue, so InstantMix gets its own, higher ceiling. - It("honors a mix-sized limit above the Similar ceiling", func() { - const want = 250 - songs := model.MediaFiles{{ID: testID("s1"), Title: "Seed Song", LibraryID: 1}} - for i := range want + 50 { // more than requested, so only the limit bounds the result - songs = append(songs, model.MediaFile{ID: testID(fmt.Sprintf("t%d", i)), Title: fmt.Sprintf("Track %d", i), LibraryID: 1}) - } - ds := &tests.MockDataStore{} - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(songs) - api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs[1:]}} - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/InstantMix?limit="+strconv.Itoa(want), nil). - WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - api.getInstantMix(w, r) - - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(want), "a Radio Mix-sized request must not be truncated to the Similar ceiling") - Expect(res.Items[0].Name).To(Equal("Seed Song"), "the seed must still lead the mix") - }) - - It("returns a mix for a genre id, which GetEntityByID can't resolve", func() { - ds := &tests.MockDataStore{} - songs := model.MediaFiles{ - {ID: testID("m1"), Title: "Track 1", LibraryID: 1}, - {ID: testID("m2"), Title: "Track 2", LibraryID: 1}, - } - api := &Router{ds: ds, provider: &fakeSimilarProvider{songs: songs}} - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("g1"))+"/InstantMix", nil). - WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("g1"))) - api.getInstantMix(w, r) - - Expect(w.Code).To(Equal(200)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - }) -}) - -var _ = Describe("getSimilarAlbums", func() { - It("does not return the seed album as its own similar album", func() { - // With no external agent the provider falls back to the album's own tracks, which map - // straight back to the requested album. - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: testID("al-1"), Name: "Seed Album", LibraryID: 1}, - }) - api := &Router{ds: ds, provider: &fakeSimilarProvider{ - songs: model.MediaFiles{{ID: testID("m1"), AlbumID: testID("al-1"), LibraryID: 1}}, - }} - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Albums/"+dto.EncodeID(testID("al-1"))+"/Similar?limit=10", nil). - WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al-1"))) - api.getSimilarAlbums(w, r) - - Expect(w.Code).To(Equal(200)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(BeEmpty()) - }) - - It("returns albums derived from the provider's similar songs", func() { - ds := &tests.MockDataStore{} - ds.Album().(*tests.MockAlbumRepo).SetData(model.Albums{ - {ID: testID("al-2"), Name: "Other", LibraryID: 1}, - }) - api := &Router{ds: ds, provider: &fakeSimilarProvider{ - songs: model.MediaFiles{{ID: testID("m1"), AlbumID: testID("al-2"), LibraryID: 1}}, - }} - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Albums/"+dto.EncodeID(testID("al-1"))+"/Similar?limit=10", nil). - WithContext(request.WithUser(context.Background(), model.User{ID: testID("u1"), Libraries: model.Libraries{{ID: 1}}})) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("al-1"))) - api.getSimilarAlbums(w, r) - - Expect(w.Code).To(Equal(200)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Name).To(Equal("Other")) - }) -}) diff --git a/server/jellyfin/socket.go b/server/jellyfin/socket.go deleted file mode 100644 index 80a49cd4c..000000000 --- a/server/jellyfin/socket.go +++ /dev/null @@ -1,55 +0,0 @@ -package jellyfin - -import ( - "net/http" - "time" - - "github.com/gorilla/websocket" - "github.com/navidrome/navidrome/log" -) - -// socketKeepAliveInterval (seconds) is sent in the initial ForceKeepAlive telling the client how -// often to send KeepAlive, and bounds the local read deadline. -const socketKeepAliveInterval = 60 - -// socketReadTimeout is generous relative to socketKeepAliveInterval so a single delayed -// KeepAlive doesn't drop the connection. -const socketReadTimeout = 90 * time.Second - -var socketUpgrader = websocket.Upgrader{ - // Jellyfin clients aren't browsers, so there's no cross-origin risk; the connection is - // already authenticated via api_key. - CheckOrigin: func(*http.Request) bool { return true }, -} - -// handleSocket implements Jellyfin's /socket WebSocket endpoint. Finamp opens it right after login -// and 404-loop-reconnects without it. Minimal: keeps the connection alive and answers KeepAlive -// pings, with no session/playstate push. -func (api *Router) handleSocket(w http.ResponseWriter, r *http.Request) { - conn, err := socketUpgrader.Upgrade(w, r, nil) - if err != nil { - log.Warn(r.Context(), "Jellyfin API: WebSocket upgrade failed", err) - return - } - defer conn.Close() - - if err := conn.WriteJSON(map[string]any{"MessageType": "ForceKeepAlive", "Data": socketKeepAliveInterval}); err != nil { - log.Warn(r.Context(), "Jellyfin API: WebSocket failed to send ForceKeepAlive", err) - return - } - - for { - _ = conn.SetReadDeadline(time.Now().Add(socketReadTimeout)) - var msg struct { - MessageType string `json:"MessageType"` - } - if err := conn.ReadJSON(&msg); err != nil { - return - } - if msg.MessageType == "KeepAlive" { - if err := conn.WriteJSON(map[string]any{"MessageType": "KeepAlive"}); err != nil { - return - } - } - } -} diff --git a/server/jellyfin/socket_test.go b/server/jellyfin/socket_test.go deleted file mode 100644 index 79098aafd..000000000 --- a/server/jellyfin/socket_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package jellyfin - -import ( - "net/http" - "net/http/httptest" - "strings" - "time" - - "github.com/gorilla/websocket" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("handleSocket", func() { - var api *Router - - BeforeEach(func() { - api = &Router{} - }) - - // Jellyfin's real-time clients (e.g. Finamp) open a WebSocket right after login; without - // a working handshake here they 404-loop-reconnect instead of settling into a session. - It("upgrades the connection and sends ForceKeepAlive", func() { - srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) - defer srv.Close() - - wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - Expect(err).ToNot(HaveOccurred()) - defer conn.Close() - - Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) - var msg map[string]any - Expect(conn.ReadJSON(&msg)).To(Succeed()) - Expect(msg["MessageType"]).To(Equal("ForceKeepAlive")) - Expect(msg["Data"]).To(BeNumerically("==", 60)) - }) - - It("replies to a KeepAlive message with a KeepAlive of its own", func() { - srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) - defer srv.Close() - - wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - Expect(err).ToNot(HaveOccurred()) - defer conn.Close() - - Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) - var handshake map[string]any - Expect(conn.ReadJSON(&handshake)).To(Succeed()) - Expect(handshake["MessageType"]).To(Equal("ForceKeepAlive")) - - Expect(conn.WriteJSON(map[string]any{"MessageType": "KeepAlive"})).To(Succeed()) - - Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) - var reply map[string]any - Expect(conn.ReadJSON(&reply)).To(Succeed()) - Expect(reply["MessageType"]).To(Equal("KeepAlive")) - }) - - It("closes the connection when the client disconnects, without leaving the handler hanging", func() { - srv := httptest.NewServer(http.HandlerFunc(api.handleSocket)) - defer srv.Close() - - wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - Expect(err).ToNot(HaveOccurred()) - - Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) - var handshake map[string]any - Expect(conn.ReadJSON(&handshake)).To(Succeed()) - - Expect(conn.Close()).To(Succeed()) - }) - - // End-to-end: proves /socket is reachable through the full router (case-insensitive - // wrapper + chi mux + auth middleware) with a real network listener, exactly as Finamp - // hits it in production with ?api_key=<jwt>. - Context("mounted behind the full router and auth middleware", func() { - var ds *tests.MockDataStore - var token string - - BeforeEach(func() { - ds = &tests.MockDataStore{} - auth.Init(ds) - ur := ds.User().(*tests.MockedUserRepo) - Expect(ur.Put(GinkgoT().Context(), &model.User{ID: testID("u1"), UserName: "alice", NewPassword: "secret"})).To(Succeed()) - - t, err := auth.CreateToken(&model.User{ID: testID("u1"), UserName: "alice"}) - Expect(err).ToNot(HaveOccurred()) - token = t - - api = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - }) - - It("upgrades when authenticated via the api_key query parameter", func() { - srv := httptest.NewServer(api) - defer srv.Close() - - wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/socket?api_key=" + token - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - Expect(err).ToNot(HaveOccurred()) - defer conn.Close() - - Expect(conn.SetReadDeadline(time.Now().Add(2 * time.Second))).To(Succeed()) - var msg map[string]any - Expect(conn.ReadJSON(&msg)).To(Succeed()) - Expect(msg["MessageType"]).To(Equal("ForceKeepAlive")) - }) - - It("rejects the upgrade with no api_key", func() { - srv := httptest.NewServer(api) - defer srv.Close() - - wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/socket" - _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) - Expect(err).To(HaveOccurred()) - Expect(resp.StatusCode).To(Equal(http.StatusUnauthorized)) - }) - }) -}) diff --git a/server/jellyfin/stream.go b/server/jellyfin/stream.go deleted file mode 100644 index 515a6fcb8..000000000 --- a/server/jellyfin/stream.go +++ /dev/null @@ -1,217 +0,0 @@ -package jellyfin - -import ( - "cmp" - "fmt" - "math" - "mime" - "net/http" - "net/url" - "slices" - "strconv" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/utils/req" -) - -// mediaFileForRequest resolves {itemId} to a MediaFile and verifies the user has access to its -// library, writing 404 (never 403, to avoid an existence oracle) and returning ok=false otherwise. -// Shared by getPlaybackInfo and streamAudio so a guessed id can't probe or stream another library. -func (api *Router) mediaFileForRequest(w http.ResponseWriter, r *http.Request) (*model.MediaFile, bool) { - ctx := r.Context() - id, ok := itemIDParam(w, r, "itemId") - if !ok { - return nil, false - } - mf, err := api.ds.MediaFile().Get(ctx, id) - if err != nil { - http.Error(w, "Not Found", http.StatusNotFound) - return nil, false - } - u, _ := request.UserFrom(ctx) - if !u.HasLibraryAccess(mf.LibraryID) { - http.Error(w, "Not Found", http.StatusNotFound) - return nil, false - } - return mf, true -} - -// getPlaybackInfo answers /Items/{itemId}/PlaybackInfo with a single MediaSource for direct -// playback. Format negotiation happens later in streamAudio (like Subsonic defers it to /stream). -func (api *Router) getPlaybackInfo(w http.ResponseWriter, r *http.Request) { - mf, ok := api.mediaFileForRequest(w, r) - if !ok { - return - } - src := dto.MediaSourceFromMediaFile(*mf) - // The mapper only sees embedded lyrics; per-track we can afford the full pipeline - // (sidecars, plugins) so Finamp's Lyric-stream gate reflects every source. - if !slices.ContainsFunc(src.MediaStreams, func(s dto.MediaStream) bool { return s.Type == "Lyric" }) { - if _, found := servableLyric(api.cachedLyrics(r.Context(), mf)); found { - src.MediaStreams = append(src.MediaStreams, dto.MediaStream{ - Type: "Lyric", Index: len(src.MediaStreams), IsExternal: true, - }) - } - } - // Self-authenticating: native players fetch this without an auth header. Server-relative: - // clients append it to a base URL already carrying /jellyfin. - if token := tokenFromRequest(r); token != "" { - src.TranscodingSubProtocol = "http" - src.TranscodingUrl = "/Audio/" + src.Id + "/universal?static=true&api_key=" + url.QueryEscape(token) - } - api.ok(w, r, dto.PlaybackInfoResponse{MediaSources: []dto.MediaSourceInfo{src}, PlaySessionId: dto.EncodeID(mf.ID)}) -} - -// streamAudio serves /Audio/{itemId}/stream[.container], reusing the same transcode-decision + -// streaming pipeline as the Subsonic /stream endpoint. -func (api *Router) streamAudio(w http.ResponseWriter, r *http.Request) { - mf, ok := api.mediaFileForRequest(w, r) - if !ok { - return - } - p := req.Params(r) - format := p.StringOr("container", "") - if format == "" { - // The /stream.{container} route form carries the format as a path segment, not a query param. - format = chi.URLParam(r, "container") - } - if format == "" { - // Jellyfin's audioCodec param names the target codec when no container is given. - format = p.StringOr("audiocodec", "") - } - api.serveAudio(w, r, mf, format) -} - -// streamUniversal serves /Audio/{itemId}/universal, where Container lists the "container|codec" -// entries the client direct-plays, and TranscodingContainer/AudioCodec name the fallback target. -func (api *Router) streamUniversal(w http.ResponseWriter, r *http.Request) { - mf, ok := api.mediaFileForRequest(w, r) - if !ok { - return - } - p := req.Params(r) - var streamReq stream.Request - if p.BoolOr("static", false) { - streamReq = api.transcodeDecider.ResolveRequest(r.Context(), mf, "raw", 0, 0) - } else { - streamReq = api.transcodeDecider.ResolveClientRequest(r.Context(), mf, universalClientInfo(p), 0) - } - api.serveStream(w, r, mf, streamReq) -} - -func universalClientInfo(p *req.Values) *stream.ClientInfo { - ci := &stream.ClientInfo{Name: "jellyfin-universal", MaxAudioBitrate: bitRateParam(p)} - for entry := range strings.SplitSeq(p.StringOr("container", ""), ",") { - container, codec, _ := strings.Cut(strings.TrimSpace(entry), "|") - if container == "" { - continue - } - profile := stream.DirectPlayProfile{Containers: []string{container}, Protocols: []string{stream.ProtocolHTTP}} - if codec != "" { - profile.AudioCodecs = []string{codec} - } - ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, profile) - } - codec := p.StringOr("audiocodec", "") - if container := cmp.Or(p.StringOr("transcodingcontainer", ""), codec); container != "" { - ci.TranscodingProfiles = []stream.Profile{{Container: container, AudioCodec: cmp.Or(codec, container), Protocol: stream.ProtocolHTTP}} - ci.MaxTranscodingAudioBitrate = ci.MaxAudioBitrate - } - return ci -} - -// bitRateParam reads Jellyfin's bits/sec bitrate params as the kbps the stream package expects. -func bitRateParam(p *req.Values) int { - return cmp.Or(p.IntOr("audiobitrate", 0), p.IntOr("maxstreamingbitrate", 0)) / 1000 -} - -func (api *Router) serveAudio(w http.ResponseWriter, r *http.Request, mf *model.MediaFile, format string) { - if req.Params(r).BoolOr("static", false) { - format = "raw" - } - api.serveStream(w, r, mf, api.transcodeDecider.ResolveRequest(r.Context(), mf, format, bitRateParam(req.Params(r)), 0)) -} - -func (api *Router) serveStream(w http.ResponseWriter, r *http.Request, mf *model.MediaFile, streamReq stream.Request) { - ctx := r.Context() - // A probe must not start a transcode. Omitting Content-Length is what tells a client (Fintunes) - // this is not direct play; direct play falls through to Serve, which answers HEAD itself. - if r.Method == http.MethodHead && streamReq.Format != "" && streamReq.Format != "raw" { - w.Header().Set("Content-Type", mime.TypeByExtension("."+streamReq.Format)) - w.Header().Set("Accept-Ranges", "none") - w.WriteHeader(http.StatusOK) - return - } - s, err := api.streamer.NewStream(ctx, mf, streamReq) - if err != nil { - api.internalError(w, r, err) - return - } - defer s.Close() - if _, err := s.Serve(ctx, w, r); err != nil { - log.Error(ctx, "Jellyfin API: error streaming", "id", mf.ID, err) - } -} - -// streamHls serves /Audio/{itemId}/main.m3u8 (Finamp's transcoding mode) as a single-segment VOD -// playlist whose one segment is the progressive transcode endpoint, reusing that whole pipeline. -// Trade-off: seeking re-reads from the start, like Subsonic transcoded streams. -func (api *Router) streamHls(w http.ResponseWriter, r *http.Request) { - mf, ok := api.mediaFileForRequest(w, r) - if !ok { - return - } - p := req.Params(r) - - // HLS packed audio can only carry ADTS/AAC or MP3; other codecs fall back to aac. A forced - // transcoding wins verbatim — its override rewrites the segment anyway, and the playlist must match. - codec := strings.ToLower(p.StringOr("audiocodec", "")) - if codec != "mp3" { - codec = "aac" - } - if trc, ok := request.TranscodingFrom(r.Context()); ok && trc.TargetFormat != "" { - codec = strings.ToLower(trc.TargetFormat) - } - - // Relative to the playlist URL. HLS fetches drop auth headers, so the token rides in the query. - segment := "stream." + codec - q := url.Values{} - if token := tokenFromRequest(r); token != "" { - q.Set("api_key", token) - } - if bitRate := p.IntOr("audiobitrate", 0); bitRate > 0 { - q.Set("audioBitRate", strconv.Itoa(bitRate)) - } - if len(q) > 0 { - segment += "?" + q.Encode() - } - - w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") - //nolint:gosec // not HTML; the only tainted value is query-escaped - fmt.Fprintf(w, "#EXTM3U\n"+ - "#EXT-X-VERSION:3\n"+ - "#EXT-X-PLAYLIST-TYPE:VOD\n"+ - "#EXT-X-TARGETDURATION:%d\n"+ - "#EXT-X-MEDIA-SEQUENCE:0\n"+ - "#EXTINF:%.3f,\n"+ - "%s\n"+ - "#EXT-X-ENDLIST\n", - int(math.Ceil(float64(mf.Duration))), mf.Duration, segment) -} - -// streamFile serves /Items/{itemId}/File and /Download, Jellyfin's direct-file endpoints. Some -// clients (Finamp's just_audio engine) fetch playback audio here instead of /Audio/{id}/stream, so -// it must always resolve to direct play ("raw"), never a forced transcode. -func (api *Router) streamFile(w http.ResponseWriter, r *http.Request) { - mf, ok := api.mediaFileForRequest(w, r) - if !ok { - return - } - api.serveStream(w, r, mf, api.transcodeDecider.ResolveRequest(r.Context(), mf, "raw", 0, 0)) -} diff --git a/server/jellyfin/stream_test.go b/server/jellyfin/stream_test.go deleted file mode 100644 index 7a1112f37..000000000 --- a/server/jellyfin/stream_test.go +++ /dev/null @@ -1,479 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "strings" - - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Stream", func() { - var api *Router - var ds *tests.MockDataStore - var streamer *fakeMediaStreamer - var decider *fakeTranscodeDecider - - // alice has access to library 1 only. - ctxUser := func() context.Context { - return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}}) - } - - BeforeEach(func() { - ds = &tests.MockDataStore{} - streamer = &fakeMediaStreamer{} - decider = &fakeTranscodeDecider{} - api = &Router{ - ds: ds, streamer: streamer, transcodeDecider: decider, - lyrics: &fakeLyricsService{lyrics: map[string]model.LyricList{}}, - lyricsCache: newTestLyricsCache(), - } - }) - - Describe("getPlaybackInfo", func() { - It("returns a media source for an accessible track", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - api.getPlaybackInfo(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.PlaybackInfoResponse - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.MediaSources).To(HaveLen(1)) - Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID(testID("s1")))) - Expect(res.MediaSources[0].Container).To(Equal("mp3")) - Expect(res.MediaSources[0].Size).To(Equal(int64(1000))) - Expect(res.PlaySessionId).To(MatchRegexp("^[0-9a-f]{32}$")) - }) - - It("returns 404 for a track in a library the user can't access", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - api.getPlaybackInfo(w, r) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 when the id doesn't match any media file", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/missing/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) - api.getPlaybackInfo(w, r) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - playbackInfo := func() dto.PlaybackInfoResponse { - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s1"))+"/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - api.getPlaybackInfo(w, r) - var res dto.PlaybackInfoResponse - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - return res - } - - lyricStreams := func(res dto.PlaybackInfoResponse) []dto.MediaStream { - var out []dto.MediaStream - for _, s := range res.MediaSources[0].MediaStreams { - if s.Type == "Lyric" { - out = append(out, s) - } - } - return out - } - - It("advertises a Lyric stream for plugin/sidecar-sourced lyrics not embedded in the file", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, - }) - api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ - testID("s1"): {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, - }} - - Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) - }) - - It("advertises no Lyric stream when the pipeline finds nothing", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, - }) - - Expect(lyricStreams(playbackInfo())).To(BeEmpty()) - }) - - It("advertises no Lyric stream when the lyrics endpoint would 404 (main lyric has no lines)", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, - }) - api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ - testID("s1"): {{Kind: "main", Lang: "eng"}}, - }} - - Expect(lyricStreams(playbackInfo())).To(BeEmpty()) - }) - - It("doesn't duplicate the Lyric stream when lyrics are already embedded", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1, Lyrics: `[{"lang":"xxx","line":[]}]`}, - }) - api.lyrics = &fakeLyricsService{lyrics: map[string]model.LyricList{ - testID("s1"): {{Kind: "main", Synced: true, Line: []model.Line{{Value: "hello"}}}}, - }} - - Expect(lyricStreams(playbackInfo())).To(HaveLen(1)) - }) - - It("still returns 200 with a valid MediaSource and no Lyric stream when the lyrics pipeline errors", func() { - // Own ID: an erroring loader isn't cached, but a shared ID could still pick up - // another test's cached (non-error) result and mask this assertion. - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s-err"), Title: "Song", Suffix: "mp3", Duration: 100, Size: 1000, LibraryID: 1}, - }) - api.lyrics = &fakeLyricsService{err: errors.New("boom")} - - w := httptest.NewRecorder() - r := httptest.NewRequest("POST", "/Items/"+dto.EncodeID(testID("s-err"))+"/PlaybackInfo", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s-err"))) - api.getPlaybackInfo(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.PlaybackInfoResponse - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.MediaSources).To(HaveLen(1)) - Expect(res.MediaSources[0].Id).To(Equal(dto.EncodeID(testID("s-err")))) - Expect(lyricStreams(res)).To(BeEmpty()) - }) - }) - - Describe("streamAudio", func() { - It("invokes the transcode decider and streamer for an accessible track", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, - }) - streamer.content = "audio-bytes" - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamAudio, w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(decider.invoked).To(BeTrue()) - Expect(streamer.invoked).To(BeTrue()) - Expect(w.Body.String()).To(Equal("audio-bytes")) - }) - - It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamAudio, w, r) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(decider.invoked).To(BeFalse()) - Expect(streamer.invoked).To(BeFalse()) - }) - - It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/missing/stream", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) - invoke(api.streamAudio, w, r) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(decider.invoked).To(BeFalse()) - Expect(streamer.invoked).To(BeFalse()) - }) - - It("converts the bps audioBitRate param to kbps", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?audiobitrate=320000", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamAudio, w, r) - - Expect(decider.req.BitRate).To(Equal(320)) - }) - - It("uses the audioCodec param as target format when no container is given", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?audiocodec=aac", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamAudio, w, r) - - Expect(decider.req.Format).To(Equal("aac")) - }) - - It("returns 500 and logs when the streamer fails", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, - }) - streamer.err = errors.New("boom") - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamAudio, w, r) - - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - - Describe("HEAD requests", func() { - head := func(query string) *httptest.ResponseRecorder { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "flac", LibraryID: 1}, - }) - streamer.content = "audio-bytes" - w := httptest.NewRecorder() - r := httptest.NewRequest("HEAD", "/Audio/"+dto.EncodeID(testID("s1"))+"/stream?"+query, nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamAudio, w, r) - return w - } - - It("answers a transcode with the target type and no length, without starting it", func() { - w := head("audioCodec=mp3") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("audio/mpeg")) - Expect(w.Header().Get("Content-Length")).To(BeEmpty()) - Expect(w.Body.String()).To(BeEmpty()) - Expect(streamer.invoked).To(BeFalse()) - }) - - It("answers direct play through the streamer, without a body", func() { - w := head("static=true") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamer.invoked).To(BeTrue()) - Expect(w.Body.String()).To(BeEmpty()) - }) - }) - - Describe("streamUniversal", func() { - universal := func(query string) { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Suffix: "mp3", LibraryID: 1}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/universal?"+query, nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamUniversal, w, r) - Expect(w.Code).To(Equal(http.StatusOK)) - } - - It("turns Container into direct play profiles and TranscodingContainer into the target", func() { - universal("Container=mp3,m4a|aac&TranscodingContainer=m4a&AudioCodec=aac&MaxStreamingBitrate=128000") - Expect(decider.client.DirectPlayProfiles).To(Equal([]stream.DirectPlayProfile{ - {Containers: []string{"mp3"}, Protocols: []string{stream.ProtocolHTTP}}, - {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{stream.ProtocolHTTP}}, - })) - Expect(decider.client.TranscodingProfiles).To(Equal([]stream.Profile{ - {Container: "m4a", AudioCodec: "aac", Protocol: stream.ProtocolHTTP}, - })) - Expect(decider.client.MaxAudioBitrate).To(Equal(128)) - Expect(decider.client.MaxTranscodingAudioBitrate).To(Equal(128)) - }) - - It("uses AudioCodec as the target when no TranscodingContainer is given", func() { - universal("Container=mp3&AudioCodec=aac") - Expect(decider.client.TranscodingProfiles).To(Equal([]stream.Profile{ - {Container: "aac", AudioCodec: "aac", Protocol: stream.ProtocolHTTP}, - })) - }) - - It("serves the file as is for static=true", func() { - universal("static=true&Container=ogg") - Expect(decider.req.Format).To(Equal("raw")) - Expect(decider.client).To(BeNil()) - }) - }) - - Describe("streamHls", func() { - BeforeEach(func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "dsf", Duration: 100.5, LibraryID: 1}, - }) - }) - - hls := func(query string, ctx context.Context) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Audio/"+dto.EncodeID(testID("s1"))+"/main.m3u8"+query, nil).WithContext(ctx) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - invoke(api.streamHls, w, r) - return w - } - - It("returns a single-segment VOD playlist pointing at the progressive stream endpoint", func() { - w := hls("?audiocodec=aac&audiobitrate=320000&api_key=tok", ctxUser()) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/vnd.apple.mpegurl")) - body := w.Body.String() - Expect(body).To(HavePrefix("#EXTM3U\n")) - Expect(body).To(ContainSubstring("#EXT-X-PLAYLIST-TYPE:VOD\n")) - Expect(body).To(ContainSubstring("#EXT-X-TARGETDURATION:101\n")) - Expect(body).To(ContainSubstring("#EXTINF:100.500,\n")) - Expect(body).To(ContainSubstring("\nstream.aac?api_key=tok&audioBitRate=320000\n")) - Expect(body).To(HaveSuffix("#EXT-X-ENDLIST\n")) - }) - - It("omits the bitrate param when the client doesn't send one", func() { - w := hls("?audiocodec=aac&api_key=tok", ctxUser()) - Expect(w.Body.String()).To(ContainSubstring("\nstream.aac?api_key=tok\n")) - }) - - It("falls back to aac for codecs HLS packed-audio can't carry", func() { - w := hls("?audiocodec=opus", ctxUser()) - Expect(w.Body.String()).To(ContainSubstring("\nstream.aac\n")) - }) - - It("honors mp3 as segment codec", func() { - w := hls("?audiocodec=mp3", ctxUser()) - Expect(w.Body.String()).To(ContainSubstring("\nstream.mp3\n")) - }) - - It("prefers the server-forced transcoding format over the requested codec", func() { - ctx := request.WithTranscoding(ctxUser(), model.Transcoding{TargetFormat: "mp3"}) - w := hls("?audiocodec=aac", ctx) - Expect(w.Body.String()).To(ContainSubstring("\nstream.mp3\n")) - }) - - It("advertises an HLS-incompatible forced format verbatim, matching what the segment will contain", func() { - ctx := request.WithTranscoding(ctxUser(), model.Transcoding{TargetFormat: "opus"}) - w := hls("?audiocodec=aac", ctx) - Expect(w.Body.String()).To(ContainSubstring("\nstream.opus\n")) - }) - - It("returns 404 for a track in a library the user can't access", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "dsf", LibraryID: 2}, - }) - Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 when the id doesn't match any media file", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{}) - Expect(hls("", ctxUser()).Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("streamFile", func() { - It("invokes the decider with a raw/direct-play request and the streamer for an accessible track", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 1}, - }) - streamer.content = "audio-bytes" - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/File", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - api.streamFile(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(decider.invoked).To(BeTrue()) - Expect(decider.req.Format).To(Equal("raw")) - Expect(streamer.invoked).To(BeTrue()) - Expect(w.Body.String()).To(Equal("audio-bytes")) - }) - - It("returns 404 for a track in a library the user can't access, without invoking the streamer or decider", func() { - ds.MediaFile().(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ - {ID: testID("s1"), Title: "Song", Suffix: "mp3", LibraryID: 2}, - }) - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/"+dto.EncodeID(testID("s1"))+"/File", nil).WithContext(ctxUser()) // only has access to library 1 - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1"))) - api.streamFile(w, r) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(decider.invoked).To(BeFalse()) - Expect(streamer.invoked).To(BeFalse()) - }) - - It("returns 404 when the id doesn't match any media file, without invoking the streamer or decider", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/Items/missing/File", nil).WithContext(ctxUser()) - r = withChiURLParam(r, "itemId", dto.EncodeID(testID("missing"))) - api.streamFile(w, r) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(decider.invoked).To(BeFalse()) - Expect(streamer.invoked).To(BeFalse()) - }) - }) -}) - -// fakeTranscodeDecider is a local test double for stream.TranscodeDecider: it records whether -// (and how) ResolveRequest was invoked, so tests can assert it's never called on the -// access-denied path, without needing a real transcode decision pipeline. -type fakeTranscodeDecider struct { - invoked bool - req stream.Request - client *stream.ClientInfo -} - -func (f *fakeTranscodeDecider) MakeDecision(context.Context, *model.MediaFile, *stream.ClientInfo, stream.TranscodeOptions) (*stream.TranscodeDecision, error) { - return &stream.TranscodeDecision{}, nil -} - -func (f *fakeTranscodeDecider) CreateTranscodeParams(*stream.TranscodeDecision) (string, error) { - return "", nil -} - -func (f *fakeTranscodeDecider) ResolveRequestFromToken(context.Context, string, *model.MediaFile, int) (stream.Request, error) { - return stream.Request{}, nil -} - -func (f *fakeTranscodeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request { - f.invoked = true - f.req = stream.Request{Format: format, BitRate: bitRate, Offset: offset} - return f.req -} - -func (f *fakeTranscodeDecider) ResolveClientRequest(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, offset int) stream.Request { - f.invoked = true - f.client = ci - f.req = stream.Request{Offset: offset} - return f.req -} - -// fakeMediaStreamer is a local test double for stream.MediaStreamer: it records whether -// NewStream was invoked and, on success, returns a real (non-seekable) *stream.Stream backed -// by an in-memory reader, so streamAudio's call to Stream.Serve exercises real code. -type fakeMediaStreamer struct { - invoked bool - content string - err error -} - -func (f *fakeMediaStreamer) NewStream(_ context.Context, mf *model.MediaFile, _ stream.Request) (*stream.Stream, error) { - f.invoked = true - if f.err != nil { - return nil, f.err - } - return stream.NewStream(mf, mf.Suffix, 0, io.NopCloser(strings.NewReader(f.content))), nil -} diff --git a/server/jellyfin/system.go b/server/jellyfin/system.go deleted file mode 100644 index 2aae4c9c0..000000000 --- a/server/jellyfin/system.go +++ /dev/null @@ -1,155 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/hex" - "errors" - "fmt" - "net" - "net/http" - "net/netip" - "path" - "strings" - "sync" - - "github.com/google/uuid" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/server/jellyfin/dto" -) - -// jellyfinVersion is the Jellyfin API version advertised in the handshake. Clients and SDKs gate on -// it (Streamyfin and the Android apps refuse < 10.10), and some parsers need exactly three parts. -const jellyfinVersion = "12.1.0" - -func serverName() string { - if conf.Server.Jellyfin.ServerName != "" { - return conf.Server.Jellyfin.ServerName - } - return fmt.Sprintf("Navidrome %s", consts.Version) -} - -func (api *Router) serverID(ctx context.Context) string { - return resolveServerID(ctx, api.ds, &api.serverIDVal) -} - -// Package-level: the Router and Discovery are separate objects and must not persist different ids. -var serverIDMu sync.Mutex - -// Clients cache ServerId across sessions, so it is persisted. Only a successful read or write is -// cached: a transient failure yields a temporary id and retries on the next call. -func resolveServerID(ctx context.Context, ds model.DataStore, cached *string) string { - serverIDMu.Lock() - defer serverIDMu.Unlock() - if *cached != "" { - return *cached - } - if ds == nil { - *cached = newServerID() - return *cached - } - id, err := ds.Property().Get(ctx, consts.JellyfinServerIDKey) - switch { - case errors.Is(err, model.ErrNotFound): - id = newServerID() - if err := ds.Property().Put(ctx, consts.JellyfinServerIDKey, id); err != nil { - log.Error(ctx, "Jellyfin API: could not persist server id", err) - return id - } - case err != nil: - log.Error(ctx, "Jellyfin API: could not read server id", err) - return newServerID() - } - // Ids persisted before this change are dashed; normalize on read rather than rewriting the DB. - *cached = strings.ReplaceAll(id, "-", "") - return *cached -} - -// newServerID returns a UUID in Jellyfin's no-dash GUID form (Guid.ToString("N")). -func newServerID() string { - u := uuid.New() - return hex.EncodeToString(u[:]) -} - -func (api *Router) publicInfo(r *http.Request) dto.PublicSystemInfo { - return dto.PublicSystemInfo{ - LocalAddress: localAddress(r), - ServerName: serverName(), - Version: jellyfinVersion, - ProductName: "Jellyfin Server", - Id: api.serverID(r.Context()), - StartupWizardCompleted: true, - } -} - -// localAddress reconstructs the base URL the client used (scheme/host from the request, honoring -// X-Forwarded-* headers, plus the mount path), advertised as LocalAddress. Jellify adopts it as -// its server base URL; without it its SDK api instance is undefined and sign-in crashes. -func localAddress(r *http.Request) string { - scheme, host := server.ServerAddress(r) - return scheme + "://" + host + path.Join(conf.Server.BasePath, consts.URLPathJellyfinAPI) -} - -func (api *Router) getPublicSystemInfo(w http.ResponseWriter, r *http.Request) { - api.ok(w, r, api.publicInfo(r)) -} - -func (api *Router) getSystemInfo(w http.ResponseWriter, r *http.Request) { - api.ok(w, r, dto.SystemInfo{ - PublicSystemInfo: api.publicInfo(r), - SupportsLibraryMonitor: true, - }) -} - -// ping answers /System/Ping with a bare plain-text server name (not JSON-quoted): Jellyfin's -// server does this and clients parse the raw body. -func (api *Router) ping(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(serverName())) -} - -// getEndpointInfo answers /System/Endpoint, which Finamp's connection test uses to pick between a -// dual-connection setup's addresses; a missing IsInNetwork reads to it as "not a Jellyfin server". -func (api *Router) getEndpointInfo(w http.ResponseWriter, r *http.Request) { - remote := remoteIP(r) - api.ok(w, r, dto.EndPointInfo{ - IsLocal: isSameMachine(r, remote), - IsInNetwork: isInLocalNetwork(remote), - }) -} - -// isInLocalNetwork mirrors Jellyfin's default LAN set (NetworkManager.UpdateSettings with no -// LocalNetworkSubnets configured): loopback, the RFC 1918 ranges, fc00::/7 and fe80::/10. -func isInLocalNetwork(ip netip.Addr) bool { - return ip.IsLoopback() || ip.IsPrivate() || (ip.Is6() && ip.IsLinkLocalUnicast()) -} - -// isSameMachine mirrors Jellyfin's HttpContext.IsLocal(): the caller shares the connection's local -// address. The local address is missing in tests and unreliable behind a proxy, so fall back to loopback. -func isSameMachine(r *http.Request, remote netip.Addr) bool { - local, ok := r.Context().Value(http.LocalAddrContextKey).(net.Addr) - if !ok { - return remote.IsLoopback() - } - return parseIP(local.String()) == remote -} - -// remoteIP parses RemoteAddr, which realIPMiddleware may have rewritten to a bare client IP. -func remoteIP(r *http.Request) netip.Addr { - return parseIP(r.RemoteAddr) -} - -func parseIP(addr string) netip.Addr { - if h, _, err := net.SplitHostPort(addr); err == nil { - addr = h - } - ip, err := netip.ParseAddr(addr) - if err != nil { - return netip.Addr{} - } - return ip.Unmap() -} diff --git a/server/jellyfin/system_test.go b/server/jellyfin/system_test.go deleted file mode 100644 index d339043a7..000000000 --- a/server/jellyfin/system_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "errors" - "net" - "net/http" - "net/http/httptest" - "sync" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("System", func() { - var api *Router - BeforeEach(func() { api = &Router{} }) - - It("returns public system info without auth", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.ServerName = "" - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Info/Public", nil) - api.getPublicSystemInfo(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) - var info dto.PublicSystemInfo - Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) - Expect(info.Id).ToNot(BeEmpty()) - Expect(info.Version).To(Equal(jellyfinVersion)) - Expect(info.ProductName).To(Equal("Jellyfin Server")) - Expect(info.ServerName).To(HavePrefix("Navidrome")) - }) - - It("returns authenticated system info with the public fields plus library monitor support", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.ServerName = "" - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Info", nil) - api.getSystemInfo(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) - var info dto.SystemInfo - Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) - Expect(info.Id).ToNot(BeEmpty()) - Expect(info.Version).To(Equal(jellyfinVersion)) - Expect(info.ProductName).To(Equal("Jellyfin Server")) - Expect(info.ServerName).To(HavePrefix("Navidrome")) - Expect(info.SupportsLibraryMonitor).To(BeTrue()) - Expect(info.HasPendingRestart).To(BeFalse()) - Expect(info.IsShuttingDown).To(BeFalse()) - }) - - It("advertises a LocalAddress with the request scheme, host and Jellyfin base path", func() { - DeferCleanup(configtest.SetupConfig()) - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Info/Public", nil) - r.Host = "music.example.com:4599" - api.getPublicSystemInfo(w, r) - - var info dto.PublicSystemInfo - Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) - // Jellify connecting over HTTP sets its server base URL from LocalAddress; without it the - // SDK `api` is undefined and sign-in crashes. It must include the /jellyfin mount path. - Expect(info.LocalAddress).To(Equal("http://music.example.com:4599/jellyfin")) - }) - - It("responds to ping with the server name as plain text", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.ServerName = "" - - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Ping", nil) - api.ping(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(ContainSubstring("text/plain")) - // Plain text, not a JSON-quoted string: Jellyfin clients expect the bare server name. - Expect(w.Body.String()).To(HavePrefix("Navidrome")) - }) - - DescribeTable("reports the caller's network location on /System/Endpoint", - func(remoteAddr string, isLocal, isInNetwork bool) { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Endpoint", nil) - r.RemoteAddr = remoteAddr - api.getEndpointInfo(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - // Finamp's connection test only probes for the key's presence, so it must always be emitted. - Expect(w.Body.String()).To(ContainSubstring(`"IsInNetwork"`)) - var info dto.EndPointInfo - Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) - Expect(info.IsLocal).To(Equal(isLocal)) - Expect(info.IsInNetwork).To(Equal(isInNetwork)) - }, - Entry("loopback", "127.0.0.1:12345", true, true), - Entry("IPv6 loopback", "[::1]:12345", true, true), - Entry("LAN address", "192.168.1.20:54321", false, true), - Entry("bare IP, as left by the RealIP middleware", "10.0.0.5", false, true), - Entry("IPv4-mapped IPv6 LAN address", "[::ffff:172.16.0.9]:80", false, true), - Entry("IPv6 link-local", "[fe80::1]:80", false, true), - Entry("IPv6 unique-local", "[fd00::1]:80", false, true), - // Jellyfin's default LAN set omits 169.254.0.0/16, so we do too. - Entry("IPv4 link-local", "169.254.1.1:80", false, false), - Entry("public address", "8.8.8.8:443", false, false), - Entry("unparseable address", "not-an-ip", false, false), - ) - - It("reports IsLocal when the caller shares the connection's local address", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Endpoint", nil) - r.RemoteAddr = "192.168.1.20:54321" - ctx := context.WithValue(r.Context(), http.LocalAddrContextKey, - &net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533}) - api.getEndpointInfo(w, r.WithContext(ctx)) - - var info dto.EndPointInfo - Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) - Expect(info.IsLocal).To(BeTrue()) - }) - - It("does not report IsLocal for a different host on the same LAN", func() { - w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/System/Endpoint", nil) - r.RemoteAddr = "192.168.1.99:54321" - ctx := context.WithValue(r.Context(), http.LocalAddrContextKey, - &net.TCPAddr{IP: net.ParseIP("192.168.1.20"), Port: 4533}) - api.getEndpointInfo(w, r.WithContext(ctx)) - - var info dto.EndPointInfo - Expect(json.Unmarshal(w.Body.Bytes(), &info)).To(Succeed()) - Expect(info.IsLocal).To(BeFalse()) - Expect(info.IsInNetwork).To(BeTrue()) - }) - - Context("serverID with a real DataStore", func() { - var ctx context.Context - var ds *tests.MockDataStore - - BeforeEach(func() { - ctx = context.Background() - ds = &tests.MockDataStore{} - }) - - It("persists the generated id so it can be read back by another Router sharing the same DataStore", func() { - first := &Router{ds: ds} - id := first.serverID(ctx) - Expect(id).ToNot(BeEmpty()) - - second := &Router{ds: ds} - Expect(second.serverID(ctx)).To(Equal(id)) - }) - - It("resolves one id when a Router and a Discovery race on first boot", func() { - r, d := &Router{ds: ds}, NewDiscovery(ds) - ids := make([]string, 2) - var wg sync.WaitGroup - wg.Go(func() { ids[0] = r.serverID(ctx) }) - wg.Go(func() { ids[1] = d.serverID(ctx) }) - wg.Wait() - Expect(ids[0]).ToNot(BeEmpty()) - Expect(ids[1]).To(Equal(ids[0])) - }) - - It("memoizes the id across repeated calls on the same Router", func() { - r := &Router{ds: ds} - id := r.serverID(ctx) - Expect(r.serverID(ctx)).To(Equal(id)) - Expect(r.serverID(ctx)).To(Equal(id)) - }) - - It("does not overwrite or pin over a stored id when the property read fails transiently", func() { - Expect(ds.Property().Put(ctx, consts.JellyfinServerIDKey, "6ba7b8109dad11d180b400c04fd430c8")).To(Succeed()) - - r := &Router{ds: ds} - props := ds.Property().(*tests.MockedPropertyRepo) - props.Error = errors.New("database is locked") - degraded := r.serverID(ctx) - Expect(degraded).ToNot(BeEmpty()) - Expect(degraded).ToNot(Equal("6ba7b8109dad11d180b400c04fd430c8")) // temporary value, not the (unreadable) stored one - props.Error = nil - - // Once the DB recovers, the stored id is intact and served again. - Expect(r.serverID(ctx)).To(Equal("6ba7b8109dad11d180b400c04fd430c8")) - stored, err := ds.Property().Get(ctx, consts.JellyfinServerIDKey) - Expect(err).ToNot(HaveOccurred()) - Expect(stored).To(Equal("6ba7b8109dad11d180b400c04fd430c8")) - }) - - It("returns Jellyfin's no-dash GUID form", func() { - r := &Router{ds: ds} - Expect(r.serverID(ctx)).To(MatchRegexp("^[0-9a-f]{32}$")) - }) - - It("strips dashes from an already-persisted id", func() { - Expect(ds.Property().Put(ctx, - consts.JellyfinServerIDKey, "1b4e28ba-2fa1-11d2-883f-0016d3cca427")).To(Succeed()) - r := &Router{ds: ds} - Expect(r.serverID(ctx)).To(Equal("1b4e28ba2fa111d2883f0016d3cca427")) - }) - }) -}) diff --git a/server/jellyfin/users.go b/server/jellyfin/users.go deleted file mode 100644 index d0a54ee63..000000000 --- a/server/jellyfin/users.go +++ /dev/null @@ -1,71 +0,0 @@ -package jellyfin - -import ( - "net/http" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" -) - -// getUserViews returns one CollectionFolder view per accessible library, so clients browse each -// library as its own top-level view rather than one aggregate. -func (api *Router) getUserViews(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - u, _ := request.UserFrom(ctx) - // u.Libraries comes from a projection without counts or stats, and clients hide a library that - // looks empty, so the rows are re-read in full here. - libs, err := api.ds.Library().GetAll(ctx) - if err != nil { - api.internalError(w, r, err) - return - } - views := make([]dto.BaseItemDto, 0, len(libs)) - for _, lib := range libs { - if u.HasLibraryAccess(lib.ID) { - views = append(views, dto.LibraryToBaseItem(lib)) - } - } - api.ok(w, r, dto.QueryResult{Items: views, TotalRecordCount: len(views), StartIndex: 0}) -} - -func (api *Router) getCurrentUser(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - u, _ := request.UserFrom(ctx) - api.ok(w, r, userToDto(&u, serverName(), api.serverID(ctx))) -} - -// getPublicUsers advertises the users named in Jellyfin.ExposedPublicUsers for a client login -// picker. The route is unauthenticated, so it lists only the configured allowlist (never the full -// user table) and returns a minimal DTO — no Policy/Configuration, which would leak admin status. -func (api *Router) getPublicUsers(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - serverID := api.serverID(ctx) - seen := make(map[string]bool) - users := []dto.UserDto{} - for name := range strings.SplitSeq(conf.Server.Jellyfin.ExposedPublicUsers, ",") { - name = strings.TrimSpace(name) - if name == "" { - continue - } - key := strings.ToLower(name) - if seen[key] { - continue - } - seen[key] = true - usr, err := api.ds.User().FindByUsername(ctx, name) - if err != nil { - log.Warn(ctx, "Jellyfin API: configured public user not found", "username", name, err) - continue - } - users = append(users, dto.UserDto{ - Name: usr.UserName, - Id: dto.EncodeID(usr.ID), - ServerId: serverID, - HasPassword: true, - }) - } - api.ok(w, r, users) -} diff --git a/server/jellyfin/users_test.go b/server/jellyfin/users_test.go deleted file mode 100644 index 793a64a29..000000000 --- a/server/jellyfin/users_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package jellyfin - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/jellyfin/dto" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Users", func() { - var ctx context.Context - var api *Router - // The repo holds the full rows; the user carries the id/name-only copy its projection returns. - authedWithLibraries := func(r *http.Request, libs model.Libraries) *http.Request { - api.ds.Library().(*tests.MockLibraryRepo).SetData(libs) - stripped := make(model.Libraries, len(libs)) - for i, lib := range libs { - stripped[i] = model.Library{ID: lib.ID, Name: lib.Name} - } - ctx := request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: stripped}) - return r.WithContext(ctx) - } - BeforeEach(func() { - ctx = GinkgoT().Context() - api = &Router{ds: &tests.MockDataStore{}} - }) - - Describe("getUserViews", func() { - It("returns one view per accessible library", func() { - libs := model.Libraries{{ID: 1, Name: "Music"}, {ID: 2, Name: "Podcasts"}} - w := httptest.NewRecorder() - api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(2)) - Expect(res.TotalRecordCount).To(Equal(2)) - - Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(1))) - Expect(res.Items[0].Name).To(Equal("Music")) - Expect(res.Items[0].Type).To(Equal("CollectionFolder")) - Expect(res.Items[0].CollectionType).To(Equal("music")) - Expect(res.Items[0].IsFolder).To(BeTrue()) - - Expect(res.Items[1].Id).To(Equal(dto.EncodeLibraryID(2))) - Expect(res.Items[1].Name).To(Equal("Podcasts")) - }) - - // Manet keeps no library, and so syncs no artists/albums/tracks, when these are missing. - It("describes the library like Jellyfin's CollectionFolder", func() { - created := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC) - libs := model.Libraries{{ID: 1, Name: "Music", Path: "/music", TotalAlbums: 42, CreatedAt: created}} - w := httptest.NewRecorder() - api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - - item := res.Items[0] - Expect(*item.ChildCount).To(Equal(42)) - Expect(item.DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z")) - Expect(item.SortName).To(Equal("Music")) - Expect(item.Path).To(Equal("/music")) - Expect(item.LocationType).To(Equal("FileSystem")) - Expect(item.UserData).ToNot(BeNil()) - Expect(item.UserData.ItemId).To(Equal(dto.EncodeLibraryID(1))) - }) - - It("returns a single view for a user with one library", func() { - libs := model.Libraries{{ID: 1, Name: "Music"}} - w := httptest.NewRecorder() - api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), libs)) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(1)) - Expect(res.Items[0].Id).To(Equal(dto.EncodeLibraryID(1))) - }) - - It("returns no views for a user with no library access", func() { - w := httptest.NewRecorder() - api.getUserViews(w, authedWithLibraries(httptest.NewRequest("GET", "/UserViews", nil), nil)) - Expect(w.Code).To(Equal(http.StatusOK)) - var res dto.QueryResult - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - Expect(res.Items).To(HaveLen(0)) - Expect(res.TotalRecordCount).To(Equal(0)) - }) - }) - - It("returns the current user", func() { - w := httptest.NewRecorder() - api.getCurrentUser(w, authedWithLibraries(httptest.NewRequest("GET", "/Users/Me", nil), nil)) - var u dto.UserDto - Expect(json.Unmarshal(w.Body.Bytes(), &u)).To(Succeed()) - Expect(u.Name).To(Equal("alice")) - Expect(u.Policy).ToNot(BeNil()) - Expect(u.Policy.IsAdministrator).To(BeFalse()) - Expect(u.Configuration).ToNot(BeNil()) - }) - - Describe("getPublicUsers", func() { - var ur *tests.MockedUserRepo - publicUsers := func() []dto.UserDto { - w := httptest.NewRecorder() - api.getPublicUsers(w, httptest.NewRequest("GET", "/Users/Public", nil)) - Expect(w.Code).To(Equal(http.StatusOK)) - var users []dto.UserDto - Expect(json.Unmarshal(w.Body.Bytes(), &users)).To(Succeed()) - return users - } - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - ur = api.ds.User().(*tests.MockedUserRepo) - Expect(ur.Put(ctx, &model.User{ID: testID("u1"), UserName: "alice"})).To(Succeed()) - Expect(ur.Put(ctx, &model.User{ID: testID("u2"), UserName: "bob"})).To(Succeed()) - }) - - It("returns an empty list when the config is unset", func() { - conf.Server.Jellyfin.ExposedPublicUsers = "" - Expect(publicUsers()).To(BeEmpty()) - }) - - It("lists the configured users in order, without leaking policy", func() { - conf.Server.Jellyfin.ExposedPublicUsers = "bob, alice" - users := publicUsers() - Expect(users).To(HaveLen(2)) - Expect(users[0].Name).To(Equal("bob")) - Expect(users[0].Id).To(Equal(dto.EncodeID(testID("u2")))) - Expect(users[1].Name).To(Equal("alice")) - // The public list must not expose Policy/Configuration to unauthenticated callers. - Expect(users[0].Policy).To(BeNil()) - Expect(users[0].Configuration).To(BeNil()) - }) - - It("skips a configured username that does not exist", func() { - conf.Server.Jellyfin.ExposedPublicUsers = "alice,ghost" - users := publicUsers() - Expect(users).To(HaveLen(1)) - Expect(users[0].Name).To(Equal("alice")) - }) - - It("matches usernames case-insensitively and de-duplicates", func() { - conf.Server.Jellyfin.ExposedPublicUsers = "ALICE, alice" - users := publicUsers() - Expect(users).To(HaveLen(1)) - Expect(users[0].Name).To(Equal("alice")) - }) - }) -}) diff --git a/server/middlewares.go b/server/middlewares.go index b65a2d6e1..8591d3de0 100644 --- a/server/middlewares.go +++ b/server/middlewares.go @@ -1,29 +1,23 @@ package server import ( - "cmp" - "context" - "encoding/json" "errors" "fmt" "io/fs" - "net" "net/http" - "net/netip" "net/url" "strings" "time" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" - "github.com/go-chi/httprate" "github.com/navidrome/navidrome/conf" + . "github.com/navidrome/navidrome/utils/gg" + + "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/utils" "github.com/unrolled/secure" ) @@ -40,7 +34,7 @@ func requestLogger(next http.Handler) http.Handler { status := ww.Status() message := fmt.Sprintf("HTTP: %s %s://%s%s", r.Method, scheme, r.Host, r.RequestURI) - logArgs := []any{ + logArgs := []interface{}{ r.Context(), message, "remoteAddr", r.RemoteAddr, @@ -48,10 +42,7 @@ func requestLogger(next http.Handler) http.Handler { "httpStatus", ww.Status(), "responseSize", ww.BytesWritten(), } - if log.IsGreaterOrEqualTo(log.LevelTrace) { - headers, _ := json.Marshal(r.Header) - logArgs = append(logArgs, "header", string(headers)) - } else if log.IsGreaterOrEqualTo(log.LevelDebug) { + if log.CurrentLevel() >= log.LevelDebug { logArgs = append(logArgs, "userAgent", r.UserAgent()) } @@ -79,7 +70,7 @@ func robotsTXT(fs fs.FS) func(http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/robots.txt") { r.URL.Path = "/robots.txt" - http.FileServerFS(fs).ServeHTTP(w, r) + http.FileServer(http.FS(fs)).ServeHTTP(w, r) } else { next.ServeHTTP(w, r) } @@ -106,11 +97,10 @@ func corsHandler() func(http.Handler) http.Handler { func secureMiddleware() func(http.Handler) http.Handler { sec := secure.New(secure.Options{ - ContentTypeNosniff: true, - FrameDeny: true, - ReferrerPolicy: "same-origin", - PermissionsPolicy: "autoplay=(), camera=(), microphone=(), usb=()", - CustomFrameOptionsValue: conf.Server.HTTPHeaders.FrameOptions, + ContentTypeNosniff: true, + FrameDeny: true, + ReferrerPolicy: "same-origin", + PermissionsPolicy: "autoplay=(), camera=(), microphone=(), usb=()", //ContentSecurityPolicy: "script-src 'self' 'unsafe-inline'", }) return sec.Handler @@ -126,7 +116,6 @@ func compressMiddleware() func(http.Handler) http.Handler { "text/plain", "text/css", "text/javascript", - "text/event-stream", ) } @@ -146,7 +135,7 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler { HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, - Path: cmp.Or(conf.Server.BasePath, "/"), + Path: If(conf.Server.BasePath, "/"), } http.SetCookie(w, c) } else { @@ -168,120 +157,25 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler { }) } -// realIPMiddleware resolves the request's client IP into the context, where it can be read with -// middleware.GetClientIP, and mirrors it into RemoteAddr for logging and player registration. -// Forwarding headers are only honoured when the peer is listed in ExtAuth.TrustedSources, so that -// a client cannot pick its own identity and evade controls keyed on it. The peer address is kept -// in the context as request.ReverseProxyIp. -func realIPMiddleware(next http.Handler) http.Handler { - trusted := conf.Server.ExtAuth.TrustedSources - fromPeer := middleware.ClientIPFromRemoteAddr(next) - if trusted == "" { - return fromPeer - } - - // Last match wins, so this order reproduces RealIP's precedence: True-Client-IP, X-Real-IP, - // X-Forwarded-For, peer. Only X-Forwarded-For is checked against the trusted list. - fromProxy := chi.Chain( - middleware.ClientIPFromRemoteAddr, - middleware.ClientIPFromXFF(trustedProxyPrefixes(trusted)...), - middleware.ClientIPFromHeader("X-Real-IP"), - middleware.ClientIPFromHeader("True-Client-IP"), - ).Handler(mirrorClientIP(next)) - - dispatch := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if validateIPAgainstList(r.RemoteAddr, trusted) { - fromProxy.ServeHTTP(w, r) - return - } - log.Trace(r.Context(), "Ignoring forwarding headers from untrusted peer", "peer", r.RemoteAddr) - fromPeer.ServeHTTP(w, r) - }) - return reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr })(dispatch) -} - -// mirrorClientIP copies the resolved client IP into RemoteAddr when it differs from the peer. -func mirrorClientIP(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if ip := middleware.GetClientIP(r.Context()); ip != "" && ip != peerHost(r) { - r.RemoteAddr = ip - } - next.ServeHTTP(w, r) - }) -} - -// peerHost returns the host part of RemoteAddr, which may already be a bare IP. -func peerHost(r *http.Request) string { - if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { - return host - } - return r.RemoteAddr -} - -// trustedProxyPrefixes returns the CIDR entries of a trusted sources list, skipping non-CIDR -// entries such as the "@" unix socket marker. An empty result makes ClientIPFromXFF trust -// exactly one hop. -func trustedProxyPrefixes(list string) []string { - var prefixes []string - for _, entry := range strings.Split(list, ",") { - entry = strings.TrimSpace(entry) - if _, err := netip.ParsePrefix(entry); err == nil { - prefixes = append(prefixes, entry) - } - } - return prefixes -} - -// ClientIPRateLimiter returns a rate limiter keyed by ClientIP, so spoofed forwarding headers -// cannot be rotated for a fresh bucket. -func ClientIPRateLimiter(requestLimit int, windowLength time.Duration) func(http.Handler) http.Handler { - return httprate.LimitBy(requestLimit, windowLength, func(r *http.Request) (string, error) { - return ClientIP(r), nil - }) -} - -// ClientIP returns the canonical client IP resolved by realIPMiddleware, for keying rate limits. The -// peer address fallback degrades a missing middleware to per-peer limiting, not one shared bucket. -func ClientIP(r *http.Request) string { - return httprate.CanonicalizeIP(cmp.Or(middleware.GetClientIP(r.Context()), peerHost(r))) -} - -// reqToCtx creates a middleware that updates the request's context with a value computed from the request. A given key -// can only be set once. -func reqToCtx(key any, fn func(req *http.Request) any) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Context().Value(key) == nil { - ctx := context.WithValue(r.Context(), key, fn(r)) - r = r.WithContext(ctx) - } - - next.ServeHTTP(w, r) - }) - } -} - // serverAddressMiddleware is a middleware function that modifies the request object // to reflect the address of the server handling the request, as determined by the // presence of X-Forwarded-* headers or the scheme and host of the request URL. func serverAddressMiddleware(h http.Handler) http.Handler { // Define a new handler function that will be returned by this middleware function. fn := func(w http.ResponseWriter, r *http.Request) { - // Call the ServerAddress function to get the scheme and host of the server + // Call the serverAddress function to get the scheme and host of the server // handling the request. If a host is found, modify the request object to use // that host and scheme instead of the original ones. - if rScheme, rHost := ServerAddress(r); rHost != "" { + if rScheme, rHost := serverAddress(r); rHost != "" { r.Host = rHost r.URL.Scheme = rScheme - // Recorded so code running without the request (e.g. plugins) can build public URLs. - r = r.WithContext(request.WithServerAddress(r.Context(), rScheme, rHost)) } // Call the next handler in the chain with the modified request and response. h.ServeHTTP(w, r) } - // Return the new handler function as a http.Handler object. + // Return the new handler function as an http.Handler object. return http.HandlerFunc(fn) } @@ -292,45 +186,35 @@ var ( xForwardedScheme = http.CanonicalHeaderKey("X-Forwarded-Scheme") ) -// ServerAddress is a helper function that returns the scheme and host of the server +// serverAddress is a helper function that returns the scheme and host of the server // handling the given request, as determined by the presence of X-Forwarded-* headers // or the scheme and host of the request URL. -func ServerAddress(r *http.Request) (scheme, host string) { - // Save the original request host for later comparison. - origHost := r.Host - +func serverAddress(r *http.Request) (scheme, host string) { // Determine the protocol of the request based on the presence of a TLS connection. protocol := "http" if r.TLS != nil { protocol = "https" } - // Get the X-Forwarded-Host header and extract the first host name if there are - // multiple hosts listed. If there is no X-Forwarded-Host header, use the original - // request host as the default. - xfh := r.Header.Get(xForwardedHost) - if xfh != "" { - i := strings.Index(xfh, ",") - if i == -1 { - i = len(xfh) - } - xfh = xfh[:i] - } - host = cmp.Or(xfh, r.Host) - // Determine the protocol and scheme of the request based on the presence of // X-Forwarded-* headers or the scheme of the request URL. - scheme = cmp.Or( + scheme = Coalesce( r.Header.Get(xForwardedProto), r.Header.Get(xForwardedScheme), r.URL.Scheme, protocol, ) + // Get the X-Forwarded-Host header and extract the first host name if there are + // multiple hosts listed. If there is no X-Forwarded-Host header, use the original + // request host as the default. + parts := strings.Split(r.Header.Get(xForwardedHost), ",") + host = Coalesce(parts[0], r.Host) + // If the request host has changed due to the X-Forwarded-Host header, log a trace // message with the original and new host values, as well as the scheme and URL. - if host != origHost { - log.Trace(r.Context(), "Request host has changed", "origHost", origHost, "host", host, "scheme", scheme, "url", r.URL) + if host != r.Host { + log.Trace(r.Context(), "Request host has changed", "origHost", r.Host, "host", host, "scheme", scheme, "url", r.URL) } // Return the scheme and host of the server handling the request. @@ -367,30 +251,3 @@ func URLParamsMiddleware(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } - -func UpdateLastAccessMiddleware(ds model.DataStore) func(next http.Handler) http.Handler { - userAccessLimiter := utils.Limiter{Interval: consts.UpdateLastAccessFrequency} - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - usr, ok := request.UserFrom(ctx) - if ok { - userAccessLimiter.Do(usr.ID, func() { - start := time.Now() - ctx, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - - err := ds.User().UpdateLastAccessAt(ctx, usr.ID) - if err != nil { - log.Warn(ctx, "Could not update user's lastAccessAt", "username", usr.UserName, - "elapsed", time.Since(start), err) - } else { - log.Trace(ctx, "Update user's lastAccessAt", "username", usr.UserName, - "elapsed", time.Since(start)) - } - }) - } - next.ServeHTTP(w, r) - }) - } -} diff --git a/server/middlewares_test.go b/server/middlewares_test.go index 15cf70341..28ebe0971 100644 --- a/server/middlewares_test.go +++ b/server/middlewares_test.go @@ -1,23 +1,16 @@ package server import ( - "context" "net/http" "net/http/httptest" "net/url" "os" - "time" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/google/uuid" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/publicurl" - "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -71,15 +64,10 @@ var _ = Describe("middlewares", func() { middleware http.Handler recorder *httptest.ResponseRecorder req *http.Request - gotScheme string - gotHost string - gotOK bool ) BeforeEach(func() { - gotScheme, gotHost, gotOK = "", "", false nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotScheme, gotHost, gotOK = request.ServerAddressFrom(r.Context()) w.WriteHeader(http.StatusOK) }) middleware = serverAddressMiddleware(nextHandler) @@ -96,16 +84,9 @@ var _ = Describe("middlewares", func() { Expect(req.Host).To(Equal("example.com")) Expect(req.URL.Scheme).To(Equal("http")) }) - - It("should record the address in the context", func() { - middleware.ServeHTTP(recorder, req) - Expect(gotOK).To(BeTrue()) - Expect(gotScheme).To(Equal("http")) - Expect(gotHost).To(Equal("example.com")) - }) }) - Context("with X-Forwarded-Host header", func() { + Context("with X-Forwarded-Host header with one host", func() { BeforeEach(func() { req, _ = http.NewRequest("GET", "http://example.com", nil) req.Header.Set("X-Forwarded-Host", "forwarded.example.com") @@ -118,6 +99,19 @@ var _ = Describe("middlewares", func() { }) }) + Context("with X-Forwarded-Host header with multiple hosts", func() { + BeforeEach(func() { + req, _ = http.NewRequest("GET", "http://example.com", nil) + req.Header.Set("X-Forwarded-Host", "forwarded.example.com,forwarded2.example.com") + }) + + It("should modify the request with the X-Forwarded-Host header value", func() { + middleware.ServeHTTP(recorder, req) + Expect(req.Host).To(Equal("forwarded.example.com")) + Expect(req.URL.Scheme).To(Equal("http")) + }) + }) + Context("with X-Forwarded-Proto header", func() { BeforeEach(func() { req, _ = http.NewRequest("GET", "http://example.com", nil) @@ -157,22 +151,6 @@ var _ = Describe("middlewares", func() { Expect(req.Host).To(Equal("forwarded.example.com")) Expect(req.URL.Scheme).To(Equal("https")) }) - - It("should record the forwarded address in the context", func() { - middleware.ServeHTTP(recorder, req) - Expect(gotOK).To(BeTrue()) - Expect(gotScheme).To(Equal("https")) - Expect(gotHost).To(Equal("forwarded.example.com")) - }) - - It("lets a handler build a public URL on the forwarded address", func() { - var got string - serverAddressMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - got = publicurl.AbsoluteURL(r.Context(), "/share/img/token", nil) - })).ServeHTTP(recorder, req) - - Expect(got).To(Equal("https://forwarded.example.com/share/img/token")) - }) }) Context("with multiple values in X-Forwarded-Host header", func() { @@ -362,174 +340,4 @@ var _ = Describe("middlewares", func() { }) }) }) - - Describe("UpdateLastAccessMiddleware", func() { - var ( - middleware func(next http.Handler) http.Handler - req *http.Request - ctx context.Context - ds *tests.MockDataStore - id string - lastAccessTime time.Time - ) - - callMiddleware := func(req *http.Request) { - middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).ServeHTTP(nil, req) - } - - BeforeEach(func() { - id = uuid.NewString() - ds = &tests.MockDataStore{} - lastAccessTime = time.Now() - Expect(ds.User().Put(ctx, &model.User{ID: id, UserName: "johndoe", LastAccessAt: &lastAccessTime})). - To(Succeed()) - - middleware = UpdateLastAccessMiddleware(ds) - ctx = request.WithUser( - context.Background(), - model.User{ID: id, UserName: "johndoe"}, - ) - req, _ = http.NewRequest(http.MethodGet, "/", nil) - req = req.WithContext(ctx) - }) - - Context("when the request has a user", func() { - It("does calls the next handler", func() { - called := false - middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - called = true - })).ServeHTTP(nil, req) - Expect(called).To(BeTrue()) - }) - - It("updates the last access time", func() { - time.Sleep(3 * time.Millisecond) - - callMiddleware(req) - - user, _ := ds.MockedUser.FindByUsername(ctx, "johndoe") - Expect(*user.LastAccessAt).To(BeTemporally(">", lastAccessTime, time.Second)) - }) - - It("skip fast successive requests", func() { - // First request - callMiddleware(req) - user, _ := ds.MockedUser.FindByUsername(ctx, "johndoe") - lastAccessTime = *user.LastAccessAt // Store the last access time - - // Second request - time.Sleep(3 * time.Millisecond) - callMiddleware(req) - - // The second request should not have changed the last access time - user, _ = ds.MockedUser.FindByUsername(ctx, "johndoe") - Expect(user.LastAccessAt).To(Equal(&lastAccessTime)) - }) - }) - Context("when the request has no user", func() { - It("does not update the last access time", func() { - req = req.WithContext(context.Background()) - callMiddleware(req) - - usr, _ := ds.MockedUser.FindByUsername(ctx, "johndoe") - Expect(usr.LastAccessAt).To(Equal(&lastAccessTime)) - }) - }) - }) - Describe("realIPMiddleware", func() { - var resolved, remoteAddr string - var proxyIP any - next := func(w http.ResponseWriter, r *http.Request) { - resolved = middleware.GetClientIP(r.Context()) - remoteAddr = r.RemoteAddr - proxyIP = r.Context().Value(request.ReverseProxyIp) - } - call := func(peer string, headers map[string]string) { - resolved, remoteAddr, proxyIP = "", "", nil - r := httptest.NewRequest("POST", "/auth/login", nil) - r.RemoteAddr = peer - for k, v := range headers { - r.Header.Set(k, v) - } - realIPMiddleware(http.HandlerFunc(next)).ServeHTTP(httptest.NewRecorder(), r) - } - - Context("without a trusted proxy", func() { - It("ignores client-supplied forwarding headers", func() { - call("10.0.0.1:1234", map[string]string{ - "X-Forwarded-For": "203.0.113.5", - "X-Real-IP": "203.0.113.6", - "True-Client-IP": "203.0.113.7", - }) - Expect(resolved).To(Equal("10.0.0.1")) - }) - It("leaves RemoteAddr untouched", func() { - call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}) - Expect(remoteAddr).To(Equal("10.0.0.1:1234")) - }) - }) - - Context("with a trusted proxy", func() { - BeforeEach(func() { - conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8" - }) - It("uses the forwarded client IP when the peer is a trusted proxy", func() { - call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5, 10.0.0.1"}) - Expect(resolved).To(Equal("203.0.113.5")) - Expect(remoteAddr).To(Equal("203.0.113.5")) - }) - It("honours X-Real-IP from a trusted proxy", func() { - call("10.0.0.1:1234", map[string]string{"X-Real-IP": "203.0.113.6"}) - Expect(resolved).To(Equal("203.0.113.6")) - }) - It("ignores forwarding headers when the peer is not a trusted proxy", func() { - call("198.51.100.9:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}) - Expect(resolved).To(Equal("198.51.100.9")) - Expect(remoteAddr).To(Equal("198.51.100.9:1234")) - }) - It("keeps the peer address in the context for external auth", func() { - call("10.0.0.1:1234", map[string]string{"X-Forwarded-For": "203.0.113.5"}) - Expect(proxyIP).To(Equal("10.0.0.1:1234")) - }) - }) - }) - - Describe("ClientIPRateLimiter", func() { - var handler http.Handler - JustBeforeEach(func() { - handler = realIPMiddleware(ClientIPRateLimiter(2, time.Minute)( - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }))) - }) - attempt := func(peer string, header, value string) int { - r := httptest.NewRequest("POST", "/auth/login", nil) - r.RemoteAddr = peer - r.Header.Set(header, value) - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - return w.Code - } - - DescribeTable("keeps one bucket per peer when the forwarding header is rotated", - func(header string) { - Expect(attempt("198.51.100.9:1", header, "203.0.113.1")).To(Equal(http.StatusOK)) - Expect(attempt("198.51.100.9:2", header, "203.0.113.2")).To(Equal(http.StatusOK)) - Expect(attempt("198.51.100.9:3", header, "203.0.113.3")).To(Equal(http.StatusTooManyRequests)) - }, - Entry("X-Forwarded-For", "X-Forwarded-For"), - Entry("X-Real-IP", "X-Real-IP"), - Entry("True-Client-IP", "True-Client-IP"), - ) - - Context("behind a trusted proxy", func() { - BeforeEach(func() { - conf.Server.ExtAuth.TrustedSources = "10.0.0.0/8" - }) - It("gives each real client its own bucket", func() { - Expect(attempt("10.0.0.1:1", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK)) - Expect(attempt("10.0.0.1:2", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusOK)) - Expect(attempt("10.0.0.1:3", "X-Forwarded-For", "203.0.113.1")).To(Equal(http.StatusTooManyRequests)) - Expect(attempt("10.0.0.1:4", "X-Forwarded-For", "203.0.113.2")).To(Equal(http.StatusOK)) - }) - }) - }) }) diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go deleted file mode 100644 index 91508c825..000000000 --- a/server/nativeapi/artists.go +++ /dev/null @@ -1,76 +0,0 @@ -package nativeapi - -import ( - "context" - "errors" - "io" - "net/http" - "time" - - "github.com/deluan/rest" - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" -) - -func (api *Router) addArtistRoute(r chi.Router) { - repo := api.ds.Artist() - r.Route("/artist", func(r chi.Router) { - r.Get("/", rest.GetAll(repo)) - r.Route("/{id}", func(r chi.Router) { - r.Use(server.URLParamsMiddleware) - r.Get("/", rest.Get(repo)) - r.Post("/image", api.uploadArtistImage()) - r.Delete("/image", api.deleteArtistImage()) - }) - }) -} - -func (api *Router) uploadArtistImage() http.HandlerFunc { - return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { - artistID := chi.URLParamFromCtx(ctx, "id") - ar, err := api.ds.Artist().Get(ctx, artistID) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - return model.ErrNotFound - } - return err - } - oldPath := ar.UploadedImagePath() - filename, err := api.imgUpload.SetImage(ctx, consts.EntityArtist, ar.ID, ar.Name, oldPath, reader, ext) - if err != nil { - return err - } - ar.UploadedImage = filename - ar.UpdatedAt = new(time.Now()) - if err := api.ds.Artist().Put(ctx, ar, "uploaded_image", "updated_at"); err != nil { - return err - } - api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID) - return nil - }) -} - -func (api *Router) deleteArtistImage() http.HandlerFunc { - return handleImageDelete(func(ctx context.Context) error { - artistID := chi.URLParamFromCtx(ctx, "id") - ar, err := api.ds.Artist().Get(ctx, artistID) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - return model.ErrNotFound - } - return err - } - if err := api.imgUpload.RemoveImage(ctx, ar.UploadedImagePath()); err != nil { - return err - } - ar.UploadedImage = "" - ar.UpdatedAt = new(time.Now()) - if err := api.ds.Artist().Put(ctx, ar, "uploaded_image", "updated_at"); err != nil { - return err - } - api.imgUpload.EnqueueArtwork(ctx, consts.EntityArtist, ar.ID) - return nil - }) -} diff --git a/server/nativeapi/config.go b/server/nativeapi/config.go deleted file mode 100644 index cfecfa663..000000000 --- a/server/nativeapi/config.go +++ /dev/null @@ -1,129 +0,0 @@ -package nativeapi - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "slices" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" -) - -// sensitiveFieldsPartialMask contains configuration field names that should be redacted -// using partial masking (first and last character visible, middle replaced with *). -// For values with 7+ characters: "secretvalue123" becomes "s***********3" -// For values with <7 characters: "short" becomes "****" -// Add field paths using dot notation (e.g., "LastFM.ApiKey") -var sensitiveFieldsPartialMask = []string{ - "LastFM.ApiKey", - "LastFM.Secret", - "Prometheus.MetricsPath", - "DevAutoLoginUsername", -} - -// sensitiveFieldsFullMask contains configuration field names that should always be -// completely masked with "****" regardless of their length. -// Add field paths using dot notation for any fields that should never show any content. -var sensitiveFieldsFullMask = []string{ - "DevAutoCreateAdminPassword", - "PasswordEncryptionKey", - "Prometheus.Password", -} - -type configResponse struct { - ID string `json:"id"` - ConfigFile string `json:"configFile"` - Config map[string]any `json:"config"` -} - -func redactValue(key string, value string) string { - // Return empty values as-is - if len(value) == 0 { - return value - } - - // Check if this field should be fully masked - if slices.Contains(sensitiveFieldsFullMask, key) { - return "****" - } - - // Check if this field should be partially masked - for _, field := range sensitiveFieldsPartialMask { - if field == key { - if len(value) < 7 { - return "****" - } - // Show first and last character with * in between - return string(value[0]) + strings.Repeat("*", len(value)-2) + string(value[len(value)-1]) - } - } - - // Return original value if not sensitive - return value -} - -// applySensitiveFieldMasking recursively applies masking to sensitive fields in the configuration map -func applySensitiveFieldMasking(ctx context.Context, config map[string]any, prefix string) { - for key, value := range config { - fullKey := key - if prefix != "" { - fullKey = prefix + "." + key - } - - switch v := value.(type) { - case map[string]any: - // Recursively process nested maps - applySensitiveFieldMasking(ctx, v, fullKey) - case string: - // Apply masking to string values - config[key] = redactValue(fullKey, v) - default: - // For other types (numbers, booleans, etc.), convert to string and check for masking - if str := fmt.Sprint(v); str != "" { - masked := redactValue(fullKey, str) - if masked != str { - // Only replace if masking was applied - config[key] = masked - } - } - } - } -} - -func getConfig(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Marshal the actual configuration struct to preserve original field names - configBytes, err := json.Marshal(conf.Server) - if err != nil { - log.Error(ctx, "Error marshaling config", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - // Unmarshal back to map to get the structure with proper field names - var configMap map[string]any - err = json.Unmarshal(configBytes, &configMap) - if err != nil { - log.Error(ctx, "Error unmarshaling config to map", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - // Apply sensitive field masking - applySensitiveFieldMasking(ctx, configMap, "") - - resp := configResponse{ - ID: "config", - ConfigFile: conf.Server.ConfigFile, - Config: configMap, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(resp); err != nil { - log.Error(ctx, "Error encoding config response", err) - } -} diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go deleted file mode 100644 index 6ac41f07a..000000000 --- a/server/nativeapi/config_test.go +++ /dev/null @@ -1,222 +0,0 @@ -package nativeapi - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Config API", func() { - var ctx context.Context - var ds model.DataStore - var router http.Handler - var adminUser, regularUser model.User - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableSharing = false - conf.Server.DevUIShowConfig = true // Enable config endpoint for tests - ds = &tests.MockDataStore{} - auth.Init(ds) - nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) - router = server.JWTVerifier(nativeRouter) - - // Create test users - adminUser = model.User{ - ID: "admin-1", - UserName: "admin", - Name: "Admin User", - IsAdmin: true, - NewPassword: "adminpass", - } - regularUser = model.User{ - ID: "user-1", - UserName: "regular", - Name: "Regular User", - IsAdmin: false, - NewPassword: "userpass", - } - - // Store in mock datastore - Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) - Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed()) - }) - - Describe("GET /api/config", func() { - Context("as admin user", func() { - var adminToken string - - BeforeEach(func() { - var err error - adminToken, err = auth.CreateToken(&adminUser) - Expect(err).ToNot(HaveOccurred()) - }) - - It("returns config successfully", func() { - req := createAuthenticatedConfigRequest(adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - var resp configResponse - Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) - Expect(resp.ID).To(Equal("config")) - Expect(resp.ConfigFile).To(Equal(conf.Server.ConfigFile)) - Expect(resp.Config).ToNot(BeEmpty()) - }) - - It("redacts sensitive fields", func() { - conf.Server.LastFM.ApiKey = "secretapikey123" - conf.Server.PasswordEncryptionKey = "encryptionkey789" - conf.Server.DevAutoCreateAdminPassword = "adminpassword123" - conf.Server.Prometheus.Password = "prometheuspass" - - req := createAuthenticatedConfigRequest(adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - var resp configResponse - Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) - - // Check LastFM.ApiKey (partially masked) - lastfm, ok := resp.Config["LastFM"].(map[string]any) - Expect(ok).To(BeTrue()) - Expect(lastfm["ApiKey"]).To(Equal("s*************3")) - - // Check PasswordEncryptionKey (fully masked) - Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("****")) - - // Check DevAutoCreateAdminPassword (fully masked) - Expect(resp.Config["DevAutoCreateAdminPassword"]).To(Equal("****")) - - // Check Prometheus.Password (fully masked) - prometheus, ok := resp.Config["Prometheus"].(map[string]any) - Expect(ok).To(BeTrue()) - Expect(prometheus["Password"]).To(Equal("****")) - }) - - It("handles empty sensitive values", func() { - conf.Server.LastFM.ApiKey = "" - conf.Server.PasswordEncryptionKey = "" - - req := createAuthenticatedConfigRequest(adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - var resp configResponse - Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) - - // Check LastFM.ApiKey - should be preserved because it's sensitive - lastfm, ok := resp.Config["LastFM"].(map[string]any) - Expect(ok).To(BeTrue()) - Expect(lastfm["ApiKey"]).To(Equal("")) - - // Empty sensitive values should remain empty - should be preserved because it's sensitive - Expect(resp.Config["PasswordEncryptionKey"]).To(Equal("")) - }) - }) - - Context("as regular user", func() { - var userToken string - - BeforeEach(func() { - var err error - userToken, err = auth.CreateToken(®ularUser) - Expect(err).ToNot(HaveOccurred()) - }) - - It("denies access with forbidden status", func() { - req := createAuthenticatedConfigRequest(userToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - }) - - Context("without authentication", func() { - It("denies access with unauthorized status", func() { - req := createUnauthenticatedConfigRequest("GET", "/config/", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) - }) -}) - -var _ = Describe("redactValue function", func() { - It("partially masks long sensitive values", func() { - Expect(redactValue("LastFM.ApiKey", "ba46f0e84a")).To(Equal("b********a")) - }) - - It("fully masks long sensitive values that should be completely hidden", func() { - Expect(redactValue("PasswordEncryptionKey", "1234567890")).To(Equal("****")) - Expect(redactValue("DevAutoCreateAdminPassword", "1234567890")).To(Equal("****")) - Expect(redactValue("Prometheus.Password", "1234567890")).To(Equal("****")) - }) - - It("fully masks short sensitive values", func() { - Expect(redactValue("LastFM.Secret", "short")).To(Equal("****")) - Expect(redactValue("PasswordEncryptionKey", "12345")).To(Equal("****")) - Expect(redactValue("DevAutoCreateAdminPassword", "short")).To(Equal("****")) - Expect(redactValue("Prometheus.Password", "short")).To(Equal("****")) - }) - - It("does not mask non-sensitive values", func() { - Expect(redactValue("MusicFolder", "/path/to/music")).To(Equal("/path/to/music")) - Expect(redactValue("Port", "4533")).To(Equal("4533")) - Expect(redactValue("SomeOtherField", "secretvalue")).To(Equal("secretvalue")) - }) - - It("handles empty values", func() { - Expect(redactValue("LastFM.ApiKey", "")).To(Equal("")) - Expect(redactValue("NonSensitive", "")).To(Equal("")) - }) - - It("handles edge case values", func() { - Expect(redactValue("LastFM.ApiKey", "a")).To(Equal("****")) - Expect(redactValue("LastFM.ApiKey", "ab")).To(Equal("****")) - Expect(redactValue("LastFM.ApiKey", "abcdefg")).To(Equal("a*****g")) - }) -}) - -// Helper functions - -func createAuthenticatedConfigRequest(token string) *http.Request { - req := httptest.NewRequest(http.MethodGet, "/config/config", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) - req.Header.Set("Content-Type", "application/json") - return req -} - -func createUnauthenticatedConfigRequest(method, path string, body *bytes.Buffer) *http.Request { - if body == nil { - body = &bytes.Buffer{} - } - req := httptest.NewRequest(method, path, body) - req.Header.Set("Content-Type", "application/json") - return req -} diff --git a/server/nativeapi/delete_many_response_test.go b/server/nativeapi/delete_many_response_test.go deleted file mode 100644 index 7d911d6cd..000000000 --- a/server/nativeapi/delete_many_response_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package nativeapi - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("writeDeleteManyResponse", func() { - var w *httptest.ResponseRecorder - - write := func(ids ...string) map[string]any { - w = httptest.NewRecorder() - writeDeleteManyResponse(w, httptest.NewRequest("DELETE", "/missing", nil), ids) - - var body map[string]any - Expect(json.Unmarshal(w.Body.Bytes(), &body)).To(Succeed(), "response body must be valid JSON: %s", w.Body.String()) - return body - } - - It("returns a single id as an object", func() { - Expect(write("abc123")).To(HaveKeyWithValue("id", "abc123")) - }) - - It("returns multiple ids as a list", func() { - Expect(write("a", "b")).To(HaveKeyWithValue("ids", ConsistOf("a", "b"))) - }) - - It("stays valid JSON when the id contains a backslash", func() { - Expect(write(`a\`)).To(HaveKeyWithValue("id", `a\`)) - }) - - It("stays valid JSON when the id contains a quote", func() { - Expect(write(`a"b`)).To(HaveKeyWithValue("id", `a"b`)) - }) - - It("does not HTML-escape the id into entities", func() { - Expect(write("a&b")).To(HaveKeyWithValue("id", "a&b")) - }) - - It("responds 200 with a JSON content type", func() { - write("abc123") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - }) -}) diff --git a/server/nativeapi/image_upload.go b/server/nativeapi/image_upload.go deleted file mode 100644 index c4ff62e42..000000000 --- a/server/nativeapi/image_upload.go +++ /dev/null @@ -1,120 +0,0 @@ -package nativeapi - -import ( - "context" - "errors" - "fmt" - "image" - _ "image/gif" - _ "image/jpeg" - _ "image/png" - "io" - "net/http" - "path/filepath" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - _ "golang.org/x/image/webp" -) - -func checkImageUploadPermission(w http.ResponseWriter, r *http.Request) bool { - user, _ := request.UserFrom(r.Context()) - 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 := artwork.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(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 - } - defer func() { - if r.MultipartForm != nil { - if err := r.MultipartForm.RemoveAll(); err != nil { - log.Warn(ctx, "Error removing multipart temp files", err) - } - } - }() - file, header, err := r.FormFile("image") - if err != nil { - log.Error(ctx, "Error reading uploaded file", err) - http.Error(w, "missing image file", http.StatusBadRequest) - return - } - defer file.Close() - _, format, err := image.DecodeConfig(file) - if err != nil { - log.Error(ctx, "Uploaded file is not a valid image", err) - http.Error(w, "invalid image file", http.StatusBadRequest) - return - } - if seeker, ok := file.(io.Seeker); ok { - if _, err := seeker.Seek(0, io.SeekStart); err != nil { - log.Error(ctx, "Error seeking file", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } - ext := "." + format - if ext == "." { - ext = strings.ToLower(filepath.Ext(header.Filename)) - } - if ext == "" || ext == "." { - log.Error(ctx, "Could not determine image type", "filename", header.Filename) - http.Error(w, "could not determine image type", http.StatusBadRequest) - return - } - if err := saveFn(ctx, file, ext); err != nil { - if errors.Is(err, model.ErrNotAuthorized) { - http.Error(w, "not authorized", http.StatusForbidden) - return - } - if errors.Is(err, model.ErrNotFound) { - http.Error(w, "not found", http.StatusNotFound) - return - } - log.Error(ctx, "Error saving image", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - _, _ = fmt.Fprintf(w, `{"status":"ok"}`) - } -} - -func handleImageDelete(deleteFn func(ctx context.Context) error) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - if !checkImageUploadPermission(w, r) { - return - } - if err := deleteFn(ctx); err != nil { - if errors.Is(err, model.ErrNotAuthorized) { - http.Error(w, "not authorized", http.StatusForbidden) - return - } - if errors.Is(err, model.ErrNotFound) { - http.Error(w, "not found", http.StatusNotFound) - return - } - log.Error(ctx, "Error removing image", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - _, _ = fmt.Fprintf(w, `{"status":"ok"}`) - } -} diff --git a/server/nativeapi/inspect.go b/server/nativeapi/inspect.go deleted file mode 100644 index 61013dd5a..000000000 --- a/server/nativeapi/inspect.go +++ /dev/null @@ -1,72 +0,0 @@ -package nativeapi - -import ( - "context" - "encoding/json" - "errors" - "net/http" - - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/req" -) - -func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.InspectOutput, error) { - file, err := ds.MediaFile().Get(ctx, id) - if err != nil { - return nil, err - } - - if file.Missing { - return nil, model.ErrNotFound - } - - lib, err := ds.Library().Get(ctx, file.LibraryID) - if err != nil { - return nil, err - } - - return core.Inspect(file.AbsolutePath(), *lib, file.FolderID) -} - -func inspect(ds model.DataStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - p := req.Params(r) - id, err := p.String("id") - - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - output, err := doInspect(ctx, ds, id) - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "could not find file", "id", id) - http.Error(w, "not found", http.StatusNotFound) - return - } - - if err != nil { - log.Error(ctx, "Error reading tags", "id", id, err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - output.MappedTags = nil - response, err := json.Marshal(output) - if err != nil { - log.Error(ctx, "Error marshalling json", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - - if _, err := w.Write(response); err != nil { //nolint:gosec - log.Error(ctx, "Error sending response to client", err) - } - } -} diff --git a/server/nativeapi/library.go b/server/nativeapi/library.go deleted file mode 100644 index 1636e1dbb..000000000 --- a/server/nativeapi/library.go +++ /dev/null @@ -1,101 +0,0 @@ -package nativeapi - -import ( - "context" - "encoding/json" - "errors" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -// User-library association endpoints (admin only) -func (api *Router) addUserLibraryRoute(r chi.Router) { - r.Route("/user/{id}/library", func(r chi.Router) { - r.Use(parseUserIDMiddleware) - r.Get("/", getUserLibraries(api.libs)) - r.Put("/", setUserLibraries(api.libs)) - }) -} - -// Middleware to parse user ID from URL -func parseUserIDMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - userID := chi.URLParam(r, "id") - if userID == "" { - http.Error(w, "Invalid user ID", http.StatusBadRequest) - return - } - ctx := context.WithValue(r.Context(), "userID", userID) - next.ServeHTTP(w, r.WithContext(ctx)) - }) -} - -// User-library association handlers - -func getUserLibraries(service core.Library) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID := r.Context().Value("userID").(string) - - libraries, err := service.GetUserLibraries(r.Context(), userID) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - http.Error(w, "User not found", http.StatusNotFound) - return - } - log.Error(r.Context(), "Error getting user libraries", "userID", userID, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(libraries); err != nil { - log.Error(r.Context(), "Error encoding user libraries response", err) - } - } -} - -func setUserLibraries(service core.Library) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - userID := r.Context().Value("userID").(string) - - var request struct { - LibraryIDs []int `json:"libraryIds"` - } - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - log.Error(r.Context(), "Error decoding request", err) - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if err := service.SetUserLibraries(r.Context(), userID, request.LibraryIDs); err != nil { - log.Error(r.Context(), "Error setting user libraries", "userID", userID, err) - if errors.Is(err, model.ErrNotFound) { - http.Error(w, "User not found", http.StatusNotFound) - return - } - if errors.Is(err, model.ErrValidation) { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - http.Error(w, "Failed to set user libraries", http.StatusInternalServerError) - return - } - - // Return updated user libraries - libraries, err := service.GetUserLibraries(r.Context(), userID) - if err != nil { - log.Error(r.Context(), "Error getting updated user libraries", "userID", userID, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(libraries); err != nil { - log.Error(r.Context(), "Error encoding user libraries response", err) - } - } -} diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go deleted file mode 100644 index cc05a30e5..000000000 --- a/server/nativeapi/library_test.go +++ /dev/null @@ -1,428 +0,0 @@ -package nativeapi - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Library API", func() { - var ctx context.Context - var ds model.DataStore - var router http.Handler - var adminUser, regularUser model.User - var library1, library2 model.Library - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableSharing = false - ds = &tests.MockDataStore{} - auth.Init(ds) - nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) - router = server.JWTVerifier(nativeRouter) - - // Create test users - adminUser = model.User{ - ID: "admin-1", - UserName: "admin", - Name: "Admin User", - IsAdmin: true, - NewPassword: "adminpass", - } - regularUser = model.User{ - ID: "user-1", - UserName: "regular", - Name: "Regular User", - IsAdmin: false, - NewPassword: "userpass", - } - - // Create test libraries - library1 = model.Library{ - ID: 1, - Name: "Test Library 1", - Path: "/music/library1", - } - library2 = model.Library{ - ID: 2, - Name: "Test Library 2", - Path: "/music/library2", - } - - // Store in mock datastore - Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) - Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed()) - Expect(ds.Library().Put(ctx, &library1)).To(Succeed()) - Expect(ds.Library().Put(ctx, &library2)).To(Succeed()) - }) - - Describe("Library CRUD Operations", func() { - Context("as admin user", func() { - var adminToken string - - BeforeEach(func() { - var err error - adminToken, err = auth.CreateToken(&adminUser) - Expect(err).ToNot(HaveOccurred()) - }) - - Describe("GET /api/library", func() { - It("returns all libraries", func() { - req := createAuthenticatedRequest("GET", "/library", nil, adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var libraries []model.Library - err := json.Unmarshal(w.Body.Bytes(), &libraries) - Expect(err).ToNot(HaveOccurred()) - Expect(libraries).To(HaveLen(2)) - Expect(libraries[0].Name).To(Equal("Test Library 1")) - Expect(libraries[1].Name).To(Equal("Test Library 2")) - }) - }) - - Describe("GET /api/library/{id}", func() { - It("returns a specific library", func() { - req := createAuthenticatedRequest("GET", "/library/1", nil, adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var library model.Library - err := json.Unmarshal(w.Body.Bytes(), &library) - Expect(err).ToNot(HaveOccurred()) - Expect(library.Name).To(Equal("Test Library 1")) - Expect(library.Path).To(Equal("/music/library1")) - }) - - It("returns 404 for non-existent library", func() { - req := createAuthenticatedRequest("GET", "/library/999", nil, adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 400 for invalid library ID", func() { - req := createAuthenticatedRequest("GET", "/library/invalid", nil, adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("POST /api/library", func() { - It("creates a new library", func() { - newLibrary := model.Library{ - Name: "New Library", - Path: "/music/new", - } - body, _ := json.Marshal(newLibrary) - req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - }) - - It("validates required fields", func() { - invalidLibrary := model.Library{ - Name: "", // Missing name - Path: "/music/invalid", - } - body, _ := json.Marshal(invalidLibrary) - req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(w.Body.String()).To(ContainSubstring("library name is required")) - }) - - It("validates path field", func() { - invalidLibrary := model.Library{ - Name: "Valid Name", - Path: "", // Missing path - } - body, _ := json.Marshal(invalidLibrary) - req := createAuthenticatedRequest("POST", "/library", bytes.NewBuffer(body), adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(w.Body.String()).To(ContainSubstring("library path is required")) - }) - }) - - Describe("PUT /api/library/{id}", func() { - It("updates an existing library", func() { - updatedLibrary := model.Library{ - Name: "Updated Library 1", - Path: "/music/updated", - } - body, _ := json.Marshal(updatedLibrary) - req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var updated model.Library - err := json.Unmarshal(w.Body.Bytes(), &updated) - Expect(err).ToNot(HaveOccurred()) - Expect(updated.ID).To(Equal(1)) - Expect(updated.Name).To(Equal("Updated Library 1")) - Expect(updated.Path).To(Equal("/music/updated")) - }) - - It("validates required fields on update", func() { - invalidLibrary := model.Library{ - Name: "", - Path: "/music/path", - } - body, _ := json.Marshal(invalidLibrary) - req := createAuthenticatedRequest("PUT", "/library/1", bytes.NewBuffer(body), adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - }) - - Describe("DELETE /api/library/{id}", func() { - It("deletes an existing library", func() { - req := createAuthenticatedRequest("DELETE", "/library/1", nil, adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - }) - - It("returns 404 for non-existent library", func() { - req := createAuthenticatedRequest("DELETE", "/library/999", nil, adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - }) - - Context("as regular user", func() { - var userToken string - - BeforeEach(func() { - var err error - userToken, err = auth.CreateToken(®ularUser) - Expect(err).ToNot(HaveOccurred()) - }) - - It("denies access to library management endpoints", func() { - endpoints := []string{ - "GET /library", - "POST /library", - "GET /library/1", - "PUT /library/1", - "DELETE /library/1", - } - - for _, endpoint := range endpoints { - parts := strings.Split(endpoint, " ") - method, path := parts[0], parts[1] - - req := createAuthenticatedRequest(method, path, nil, userToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusForbidden)) - } - }) - }) - - Context("without authentication", func() { - It("denies access to library management endpoints", func() { - req := createUnauthenticatedRequest("GET", "/library", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) - }) - - Describe("User-Library Association Operations", func() { - Context("as admin user", func() { - var adminToken string - - BeforeEach(func() { - var err error - adminToken, err = auth.CreateToken(&adminUser) - Expect(err).ToNot(HaveOccurred()) - }) - - Describe("GET /api/user/{id}/library", func() { - It("returns user's libraries", func() { - // Set up user libraries - err := ds.User().SetUserLibraries(ctx, regularUser.ID, []int{1, 2}) - Expect(err).ToNot(HaveOccurred()) - - req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var libraries []model.Library - err = json.Unmarshal(w.Body.Bytes(), &libraries) - Expect(err).ToNot(HaveOccurred()) - Expect(libraries).To(HaveLen(2)) - }) - - It("returns 404 for non-existent user", func() { - req := createAuthenticatedRequest("GET", "/user/non-existent/library", nil, adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("PUT /api/user/{id}/library", func() { - It("sets user's libraries", func() { - request := map[string][]int{ - "libraryIds": {1, 2}, - } - body, _ := json.Marshal(request) - req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var libraries []model.Library - err := json.Unmarshal(w.Body.Bytes(), &libraries) - Expect(err).ToNot(HaveOccurred()) - Expect(libraries).To(HaveLen(2)) - }) - - It("validates library IDs exist", func() { - request := map[string][]int{ - "libraryIds": {999}, // Non-existent library - } - body, _ := json.Marshal(request) - req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(w.Body.String()).To(ContainSubstring("library ID 999 does not exist")) - }) - - It("requires at least one library for regular users", func() { - request := map[string][]int{ - "libraryIds": {}, // Empty libraries - } - body, _ := json.Marshal(request) - req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", regularUser.ID), bytes.NewBuffer(body), adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(w.Body.String()).To(ContainSubstring("at least one library must be assigned")) - }) - - It("prevents manual assignment to admin users", func() { - request := map[string][]int{ - "libraryIds": {1}, - } - body, _ := json.Marshal(request) - req := createAuthenticatedRequest("PUT", fmt.Sprintf("/user/%s/library", adminUser.ID), bytes.NewBuffer(body), adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(w.Body.String()).To(ContainSubstring("cannot manually assign libraries to admin users")) - }) - }) - }) - - Context("as regular user", func() { - var userToken string - - BeforeEach(func() { - var err error - userToken, err = auth.CreateToken(®ularUser) - Expect(err).ToNot(HaveOccurred()) - }) - - It("denies access to user-library association endpoints", func() { - req := createAuthenticatedRequest("GET", fmt.Sprintf("/user/%s/library", regularUser.ID), nil, userToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - }) - }) -}) - -// Helper functions - -func createAuthenticatedRequest(method, path string, body *bytes.Buffer, token string) *http.Request { - if body == nil { - body = &bytes.Buffer{} - } - req := httptest.NewRequest(method, path, body) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) - req.Header.Set("Content-Type", "application/json") - return req -} - -func createUnauthenticatedRequest(method, path string, body *bytes.Buffer) *http.Request { - if body == nil { - body = &bytes.Buffer{} - } - req := httptest.NewRequest(method, path, body) - req.Header.Set("Content-Type", "application/json") - return req -} diff --git a/server/nativeapi/metadata.go b/server/nativeapi/metadata.go deleted file mode 100644 index 913ab0471..000000000 --- a/server/nativeapi/metadata.go +++ /dev/null @@ -1,56 +0,0 @@ -package nativeapi - -import ( - "context" - "errors" - "net/http" - "slices" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" -) - -func (api *Router) addMetadataRoute(r chi.Router) { - r.Post("/metadata/{kind}/{id}/refresh", api.refreshMetadata()) -} - -// refreshMetadata clears the artwork state deliberately, so a wrong pick cannot be served from -// cache again; reads fall back to local resolution while the worker re-runs the chain at Bump. -func (api *Router) refreshMetadata() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - kind, _ := model.ParseKind(chi.URLParam(r, "kind")) - id := chi.URLParam(r, "id") - if !slices.Contains(artwork.RefreshableKinds, kind) { - http.Error(w, "invalid artwork kind", http.StatusBadRequest) - return - } - if _, err := artwork.ItemName(ctx, api.ds, kind, id); err != nil { - if errors.Is(err, model.ErrNotFound) { - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - return - } - log.Error(ctx, "Error looking up item to refresh", "kind", kind, "id", id, err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - if err := artwork.Refresh(ctx, api.ds, kind, id); err != nil { - log.Error(ctx, "Error refreshing artwork", "kind", kind, "id", id, err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - if external.HasInfo(kind) { - // Detached: the request context is cancelled the moment this handler returns 204. - bg := context.WithoutCancel(ctx) - go func() { - if err := api.provider.RefreshInfo(bg, kind, id); err != nil { - log.Error(bg, "Error refreshing external info", "kind", kind, "id", id, err) - } - }() - } - w.WriteHeader(http.StatusNoContent) - } -} diff --git a/server/nativeapi/metadata_test.go b/server/nativeapi/metadata_test.go deleted file mode 100644 index 294a26efe..000000000 --- a/server/nativeapi/metadata_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package nativeapi - -import ( - "context" - "net/http" - "net/http/httptest" - "slices" - "sync" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type fakeProvider struct { - external.Provider - mu sync.Mutex - called []string -} - -func (f *fakeProvider) RefreshInfo(_ context.Context, kind model.Kind, id string) error { - f.mu.Lock() - defer f.mu.Unlock() - f.called = append(f.called, kind.Prefix()+"/"+id) - return nil -} - -func (f *fakeProvider) calls() []string { - f.mu.Lock() - defer f.mu.Unlock() - return slices.Clone(f.called) -} - -var _ = Describe("Metadata API", func() { - var ctx context.Context - var ds *tests.MockDataStore - var artRepo *tests.MockArtworkRepo - var queueRepo *tests.MockArtworkQueueRepo - var albumRepo *tests.MockAlbumRepo - var provider *fakeProvider - var router http.Handler - var adminToken, userToken string - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableSharing = false - artRepo = tests.CreateMockArtworkRepo() - queueRepo = tests.CreateMockArtworkQueueRepo() - albumRepo = tests.CreateMockAlbumRepo() - artistRepo := tests.CreateMockArtistRepo() - playlistRepo := tests.CreateMockPlaylistRepo() - Expect(albumRepo.Put(ctx, &model.Album{ID: "al-1", Name: "Kid A"})).To(Succeed()) - Expect(artistRepo.Put(ctx, &model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed()) - Expect(playlistRepo.Put(ctx, &model.Playlist{ID: "pl-1", Name: "My Playlist"})).To(Succeed()) - ds = &tests.MockDataStore{ - MockedArtwork: artRepo, - MockedArtworkQueue: queueRepo, - MockedAlbum: albumRepo, - MockedArtist: artistRepo, - MockedPlaylist: playlistRepo, - } - auth.Init(ds) - provider = &fakeProvider{} - nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, provider, nil) - router = server.JWTVerifier(nativeRouter) - - adminUser := model.User{ID: "admin-1", UserName: "admin", IsAdmin: true, NewPassword: "adminpass"} - regularUser := model.User{ID: "user-1", UserName: "regular", IsAdmin: false, NewPassword: "userpass"} - Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) - Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed()) - - var err error - adminToken, err = auth.CreateToken(&adminUser) - Expect(err).ToNot(HaveOccurred()) - userToken, err = auth.CreateToken(®ularUser) - Expect(err).ToNot(HaveOccurred()) - }) - - Describe("POST /api/metadata/{kind}/{id}/refresh", func() { - It("clears state and enqueues a Bump for admins", func() { - Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ - ItemKind: "al", ItemID: "al-1", Hash: "oldhash", Source: "external", - })).To(Succeed()) - - req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - - _, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary) - Expect(err).To(MatchError(model.ErrNotFound)) - - queued, err := queueRepo.DequeueBatch(ctx, 1000) - Expect(err).ToNot(HaveOccurred()) - Expect(queued).To(ContainElement(SatisfyAll( - HaveField("ItemKind", "al"), - HaveField("ItemID", "al-1"), - HaveField("Priority", model.ArtworkPriorityBump), - ))) - }) - - It("returns 400 for an invalid kind", func() { - req := createAuthenticatedRequest("POST", "/metadata/xx/id-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("denies access to regular users", func() { - req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, userToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - - It("denies access without authentication", func() { - req := createUnauthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("triggers an external info refresh for albums", func() { - req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Eventually(provider.calls).Should(ContainElement("al/al-1")) - }) - - It("triggers an external info refresh for artists", func() { - req := createAuthenticatedRequest("POST", "/metadata/ar/ar-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Eventually(provider.calls).Should(ContainElement("ar/ar-1")) - }) - - It("skips the external info refresh for kinds without external info", func() { - req := createAuthenticatedRequest("POST", "/metadata/pl/pl-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNoContent)) - Consistently(provider.calls).ShouldNot(ContainElement("pl/pl-1")) - }) - - It("returns 404 for an unknown id", func() { - req := createAuthenticatedRequest("POST", "/metadata/al/nope/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 500 when the lookup fails for a reason other than not-found", func() { - albumRepo.SetError(true) - - req := createAuthenticatedRequest("POST", "/metadata/al/al-1/refresh", nil, adminToken) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) -}) diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go deleted file mode 100644 index a906e36b9..000000000 --- a/server/nativeapi/missing.go +++ /dev/null @@ -1,81 +0,0 @@ -package nativeapi - -import ( - "context" - "errors" - "maps" - "net/http" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/req" -) - -type missingRepository struct { - rest.Repository[model.MediaFile] - mfRepo model.MediaFileRepository -} - -func newMissingRepository(ds model.DataStore) rest.Repository[model.MediaFile] { - mf := ds.MediaFile() - return &missingRepository{Repository: mf, mfRepo: mf} -} - -func (r *missingRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) { - return r.Repository.Count(ctx, r.parseOptions(options)) -} - -func (r *missingRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.MediaFile, error) { - return r.Repository.ReadAll(ctx, r.parseOptions(options)) -} - -func (r *missingRepository) parseOptions(options []rest.QueryOptions) rest.QueryOptions { - var opt rest.QueryOptions - if len(options) > 0 { - opt = options[0] - opt.Filters = maps.Clone(opt.Filters) - } - opt.Filters["missing"] = "true" - return opt -} - -func (r *missingRepository) Read(ctx context.Context, id string) (*model.MediaFile, error) { - mf, err := r.mfRepo.Get(ctx, id) - if err != nil { - return nil, err - } - if !mf.Missing { - return nil, model.ErrNotFound - } - return mf, nil -} - -func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - p := req.Params(r) - ids := p.Strings("id") - - var err error - if len(ids) == 0 { - err = maintenance.DeleteAllMissingFiles(ctx) - } else { - err = maintenance.DeleteMissingFiles(ctx, ids) - } - - if len(ids) == 1 && errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Missing file not found", "id", ids[0]) - http.Error(w, "not found", http.StatusNotFound) - return - } - if err != nil { - http.Error(w, "failed to delete missing files", http.StatusInternalServerError) - return - } - - writeDeleteManyResponse(w, r, ids) - } -} diff --git a/server/nativeapi/missing_test.go b/server/nativeapi/missing_test.go deleted file mode 100644 index a53f13fc3..000000000 --- a/server/nativeapi/missing_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package nativeapi - -import ( - "bytes" - "net/http" - "net/http/httptest" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Missing Files Endpoint", func() { - var router http.Handler - var token string - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SessionTimeout = time.Minute - conf.Server.EnableSharing = false - - mfRepo := tests.CreateMockMediaFileRepo() - mfRepo.SetData(model.MediaFiles{ - {ID: "missing-1", Title: "Gone", Missing: true}, - {ID: "present-1", Title: "Here"}, - }) - userRepo := tests.CreateMockUserRepo() - ds := &tests.MockDataStore{MockedMediaFile: mfRepo, MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}} - auth.Init(ds) - - user := model.User{ID: "user-1", UserName: "user", NewPassword: "pass"} - Expect(userRepo.Put(GinkgoT().Context(), &user)).To(Succeed()) - var err error - token, err = auth.CreateToken(&user) - Expect(err).ToNot(HaveOccurred()) - - router = server.JWTVerifier(New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil)) - }) - - DescribeTable("GET /missing/{id}", - func(id string, status int) { - w := httptest.NewRecorder() - router.ServeHTTP(w, createAuthenticatedRequest(http.MethodGet, "/missing/"+id, &bytes.Buffer{}, token)) - Expect(w.Code).To(Equal(status), w.Body.String()) - }, - Entry("returns a missing file", "missing-1", http.StatusOK), - Entry("returns 404 for a file that is not missing", "present-1", http.StatusNotFound), - Entry("returns 404 for an unknown id", "unknown", http.StatusNotFound), - ) -}) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 97ad14be2..76ddbdf54 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -3,258 +3,106 @@ package nativeapi import ( "context" "net/http" - "strconv" - "time" "github.com/deluan/rest" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/metrics" - playlistsvc "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/quickconnect" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server" ) -// PluginManager defines the interface for plugin management operations. -// This interface is used by the API handlers to enable/disable plugins and update configuration. -type PluginManager interface { - EnablePlugin(ctx context.Context, id string) error - DisablePlugin(ctx context.Context, id string) error - ValidatePluginConfig(ctx context.Context, id, configJSON string) error - UpdatePluginConfig(ctx context.Context, id, configJSON string) error - UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error - UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error - RescanPlugins(ctx context.Context) error - UnloadDisabledPlugins(ctx context.Context) -} - type Router struct { http.Handler - ds model.DataStore - share core.Share - playlists playlistsvc.Playlists - insights metrics.Insights - libs core.Library - users core.User - maintenance core.Maintenance - pluginManager PluginManager - imgUpload artwork.Uploader - provider external.Provider - quickConnect quickconnect.QuickConnect + ds model.DataStore + share core.Share } -func New(ds model.DataStore, share core.Share, playlists playlistsvc.Playlists, insights metrics.Insights, libraryService core.Library, userService core.User, maintenance core.Maintenance, pluginManager PluginManager, imgUpload artwork.Uploader, provider external.Provider, quickConnect quickconnect.QuickConnect) *Router { - r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, users: userService, maintenance: maintenance, pluginManager: pluginManager, imgUpload: imgUpload, provider: provider, quickConnect: quickConnect} +func New(ds model.DataStore, share core.Share) *Router { + r := &Router{ds: ds, share: share} r.Handler = r.routes() return r } -func (api *Router) routes() http.Handler { +func (n *Router) routes() http.Handler { r := chi.NewRouter() // Public - rx(r, "/translation", newTranslationRepository(), false) + n.RX(r, "/translation", newTranslationRepository, false) // Protected r.Group(func(r chi.Router) { - r.Use(server.Authenticator(api.ds)) + r.Use(server.Authenticator(n.ds)) r.Use(server.JWTRefresher) - r.Use(server.UpdateLastAccessMiddleware(api.ds)) - rx(r, "/user", api.users.Repository(), true) - rx(r, "/song", api.ds.MediaFile(), false) - rx(r, "/album", api.ds.Album(), false) - api.addArtistRoute(r) - rx(r, "/genre", api.ds.Genre(), false) - rx(r, "/player", api.ds.Player(), true) - rx(r, "/transcoding", api.ds.Transcoding(), conf.Server.EnableTranscodingConfig) - api.addRadioRoute(r) - rx(r, "/tag", api.ds.Tag(), false) - rx(r, "/scrobble", api.ds.Scrobble(), false) + n.R(r, "/user", model.User{}, true) + n.R(r, "/song", model.MediaFile{}, false) + n.R(r, "/album", model.Album{}, false) + n.R(r, "/artist", model.Artist{}, false) + n.R(r, "/genre", model.Genre{}, false) + n.R(r, "/player", model.Player{}, true) + n.R(r, "/playlist", model.Playlist{}, true) + n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) + n.R(r, "/radio", model.Radio{}, true) if conf.Server.EnableSharing { - rx(r, "/share", api.share.Repository(), true) + n.RX(r, "/share", n.share.NewRepository, true) } - api.addPlaylistRoute(r) - api.addPlaylistTrackRoute(r) - api.addSongPlaylistsRoute(r) - api.addQueueRoute(r) - api.addMissingFilesRoute(r) - api.addKeepAliveRoute(r) - api.addInsightsRoute(r) - api.addQuickConnectRoute(r) + n.addPlaylistTrackRoute(r) - r.With(adminOnlyMiddleware).Group(func(r chi.Router) { - api.addInspectRoute(r) - api.addConfigRoute(r) - api.addUserLibraryRoute(r) - api.addPluginRoute(r) - api.addMetadataRoute(r) - rx(r, "/library", api.libs.Repository(), true) + // Keepalive endpoint to be used to keep the session valid (ex: while playing songs) + r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`)) }) }) return r } -func rx[T any](r chi.Router, pathPrefix string, repo rest.Repository[T], persistable bool) { +func (n *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) { + constructor := func(ctx context.Context) rest.Repository { + return n.ds.Resource(ctx, model) + } + n.RX(r, pathPrefix, constructor, persistable) +} + +func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) { r.Route(pathPrefix, func(r chi.Router) { - r.Get("/", rest.GetAll(repo)) + r.Get("/", rest.GetAll(constructor)) if persistable { - r.Post("/", rest.Post(repo)) + r.Post("/", rest.Post(constructor)) } r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) - r.Get("/", rest.Get(repo)) + r.Get("/", rest.Get(constructor)) if persistable { - r.Put("/", rest.Put(repo)) - r.Delete("/", rest.Delete(repo)) + r.Put("/", rest.Put(constructor)) + r.Delete("/", rest.Delete(constructor)) } }) }) } -func (api *Router) addPlaylistRoute(r chi.Router) { - repo := api.playlists.Repository() - - r.Route("/playlist", func(r chi.Router) { - r.Get("/", rest.GetAll(repo)) - r.Post("/", func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Content-type") == "application/json" { - rest.Post(repo)(w, r) - return - } - createPlaylistFromM3U(api.playlists)(w, r) - }) - - r.Route("/{id}", func(r chi.Router) { - r.Use(server.URLParamsMiddleware) - r.Get("/", rest.Get(repo)) - r.Put("/", rest.Put(repo)) - r.Delete("/", rest.Delete(repo)) - r.Post("/image", uploadPlaylistImage(api.playlists)) - r.Delete("/image", deletePlaylistImage(api.playlists)) - }) - }) -} - -func (api *Router) addPlaylistTrackRoute(r chi.Router) { +func (n *Router) addPlaylistTrackRoute(r chi.Router) { r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { - getPlaylist(api.playlists)(w, r) + getPlaylist(n.ds)(w, r) }) r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) { r.Delete("/", func(w http.ResponseWriter, r *http.Request) { - deleteFromPlaylist(api.playlists)(w, r) + deleteFromPlaylist(n.ds)(w, r) }) r.Post("/", func(w http.ResponseWriter, r *http.Request) { - addToPlaylist(api.playlists)(w, r) + addToPlaylist(n.ds)(w, r) }) }) r.Route("/{id}", func(r chi.Router) { r.Use(server.URLParamsMiddleware) - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - getPlaylistTrack(api.playlists)(w, r) - }) r.Put("/", func(w http.ResponseWriter, r *http.Request) { - reorderItem(api.playlists)(w, r) + reorderItem(n.ds)(w, r) }) r.Delete("/", func(w http.ResponseWriter, r *http.Request) { - deleteFromPlaylist(api.playlists)(w, r) + deleteFromPlaylist(n.ds)(w, r) }) }) }) } - -func (api *Router) addSongPlaylistsRoute(r chi.Router) { - r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) { - getSongPlaylists(api.playlists)(w, r) - }) -} - -func (api *Router) addQueueRoute(r chi.Router) { - r.Route("/queue", func(r chi.Router) { - r.Get("/", getQueue(api.ds)) - r.Post("/", saveQueue(api.ds)) - r.Put("/", updateQueue(api.ds)) - r.Delete("/", clearQueue(api.ds)) - }) -} - -func (api *Router) addMissingFilesRoute(r chi.Router) { - r.Route("/missing", func(r chi.Router) { - rx(r, "/", newMissingRepository(api.ds), false) - r.Delete("/", deleteMissingFiles(api.maintenance)) - }) -} - -func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []string) { - var payload any - if len(ids) == 1 { - payload = struct { - ID string `json:"id"` - }{ID: ids[0]} - } else { - payload = struct { - Ids []string `json:"ids"` - }{Ids: ids} - } - if err := rest.RespondWithJSON(w, http.StatusOK, payload); err != nil { - log.Error(r.Context(), "Error writing response", "ids", ids, err) - } -} - -func (api *Router) addInspectRoute(r chi.Router) { - if conf.Server.Inspect.Enabled { - r.Group(func(r chi.Router) { - if conf.Server.Inspect.MaxRequests > 0 { - log.Debug("Throttling inspect", "maxRequests", conf.Server.Inspect.MaxRequests, - "backlogLimit", conf.Server.Inspect.BacklogLimit, "backlogTimeout", - conf.Server.Inspect.BacklogTimeout) - r.Use(middleware.ThrottleBacklog(conf.Server.Inspect.MaxRequests, conf.Server.Inspect.BacklogLimit, time.Duration(conf.Server.Inspect.BacklogTimeout))) - } - r.Get("/inspect", inspect(api.ds)) - }) - } -} - -func (api *Router) addConfigRoute(r chi.Router) { - if conf.Server.DevUIShowConfig { - r.Get("/config/*", getConfig) - } -} - -func (api *Router) addKeepAliveRoute(r chi.Router) { - r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`)) - }) -} - -func (api *Router) addInsightsRoute(r chi.Router) { - r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) { - last, success := api.insights.LastRun(r.Context()) - if conf.Server.EnableInsightsCollector { - _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`)) //nolint:gosec - } else { - _, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"disabled", "success":false}`)) - } - }) -} - -// Middleware to ensure only admin users can access endpoints -func adminOnlyMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, ok := request.UserFrom(r.Context()) - if !ok || !user.IsAdmin { - http.Error(w, "Access denied: admin privileges required", http.StatusForbidden) - return - } - next.ServeHTTP(w, r) - }) -} diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go deleted file mode 100644 index f151b1d72..000000000 --- a/server/nativeapi/native_api_song_test.go +++ /dev/null @@ -1,435 +0,0 @@ -package nativeapi - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Song Endpoints", func() { - var ( - ctx context.Context - router http.Handler - ds *tests.MockDataStore - mfRepo *tests.MockMediaFileRepo - userRepo *tests.MockedUserRepo - w *httptest.ResponseRecorder - testUser model.User - testSongs model.MediaFiles - ) - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableSharing = false - conf.Server.SessionTimeout = time.Minute - - // Setup mock repositories - mfRepo = tests.CreateMockMediaFileRepo() - userRepo = tests.CreateMockUserRepo() - - ds = &tests.MockDataStore{ - MockedMediaFile: mfRepo, - MockedUser: userRepo, - MockedProperty: &tests.MockedPropertyRepo{}, - } - - // Initialize auth system - auth.Init(ds) - - // Create test user - testUser = model.User{ - ID: "user-1", - UserName: "testuser", - Name: "Test User", - IsAdmin: false, - NewPassword: "testpass", - } - err := userRepo.Put(ctx, &testUser) - Expect(err).ToNot(HaveOccurred()) - - // Create test songs - testSongs = model.MediaFiles{ - { - ID: "song-1", - Title: "Test Song 1", - Artist: "Test Artist 1", - Album: "Test Album 1", - AlbumID: "album-1", - ArtistID: "artist-1", - Duration: 180.5, - BitRate: 320, - Path: "/music/song1.mp3", - Suffix: "mp3", - Size: 5242880, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }, - { - ID: "song-2", - Title: "Test Song 2", - Artist: "Test Artist 2", - Album: "Test Album 2", - AlbumID: "album-2", - ArtistID: "artist-2", - Duration: 240.0, - BitRate: 256, - Path: "/music/song2.mp3", - Suffix: "mp3", - Size: 7340032, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }, - } - mfRepo.SetData(testSongs) - - // Create the native API router and wrap it with the JWTVerifier middleware - nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) - router = server.JWTVerifier(nativeRouter) - w = httptest.NewRecorder() - }) - - // Helper function to create unauthenticated request - createUnauthenticatedRequest := func(method, path string, body []byte) *http.Request { - var req *http.Request - if body != nil { - req = httptest.NewRequest(method, path, bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - } else { - req = httptest.NewRequest(method, path, nil) - } - return req - } - - // Helper function to create authenticated request with JWT token - createAuthenticatedRequest := func(method, path string, body []byte) *http.Request { - req := createUnauthenticatedRequest(method, path, body) - - // Create JWT token for the test user - token, err := auth.CreateToken(&testUser) - Expect(err).ToNot(HaveOccurred()) - - // Add JWT token to Authorization header - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) - - return req - } - - Describe("GET /song", func() { - Context("when user is authenticated", func() { - It("returns all songs", func() { - req := createAuthenticatedRequest("GET", "/song", nil) - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var response []model.MediaFile - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).ToNot(HaveOccurred()) - - Expect(response).To(HaveLen(2)) - Expect(response[0].ID).To(Equal("song-1")) - Expect(response[0].Title).To(Equal("Test Song 1")) - Expect(response[1].ID).To(Equal("song-2")) - Expect(response[1].Title).To(Equal("Test Song 2")) - }) - - It("handles repository errors gracefully", func() { - mfRepo.SetError(true) - - req := createAuthenticatedRequest("GET", "/song", nil) - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - - Context("when user is not authenticated", func() { - It("returns unauthorized", func() { - req := createUnauthenticatedRequest("GET", "/song", nil) - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) - }) - - Describe("GET /song/{id}", func() { - Context("when user is authenticated", func() { - It("returns the specific song", func() { - req := createAuthenticatedRequest("GET", "/song/song-1", nil) - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var response model.MediaFile - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).ToNot(HaveOccurred()) - - Expect(response.ID).To(Equal("song-1")) - Expect(response.Title).To(Equal("Test Song 1")) - Expect(response.Artist).To(Equal("Test Artist 1")) - }) - - It("returns 404 for non-existent song", func() { - req := createAuthenticatedRequest("GET", "/song/non-existent", nil) - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("handles repository errors gracefully", func() { - mfRepo.SetError(true) - - req := createAuthenticatedRequest("GET", "/song/song-1", nil) - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - - Context("when user is not authenticated", func() { - It("returns unauthorized", func() { - req := createUnauthenticatedRequest("GET", "/song/song-1", nil) - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) - }) - - Describe("Song endpoints are read-only", func() { - Context("POST /song", func() { - It("should not be available (songs are not persistable)", func() { - newSong := model.MediaFile{ - Title: "New Song", - Artist: "New Artist", - Album: "New Album", - Duration: 200.0, - } - - body, _ := json.Marshal(newSong) - req := createAuthenticatedRequest("POST", "/song", body) - router.ServeHTTP(w, req) - - // Should return 405 Method Not Allowed or 404 Not Found - Expect(w.Code).To(Equal(http.StatusMethodNotAllowed)) - }) - }) - - Context("PUT /song/{id}", func() { - It("should not be available (songs are not persistable)", func() { - updatedSong := model.MediaFile{ - ID: "song-1", - Title: "Updated Song", - Artist: "Updated Artist", - Album: "Updated Album", - Duration: 250.0, - } - - body, _ := json.Marshal(updatedSong) - req := createAuthenticatedRequest("PUT", "/song/song-1", body) - router.ServeHTTP(w, req) - - // Should return 405 Method Not Allowed or 404 Not Found - Expect(w.Code).To(Equal(http.StatusMethodNotAllowed)) - }) - }) - - Context("DELETE /song/{id}", func() { - It("should not be available (songs are not persistable)", func() { - req := createAuthenticatedRequest("DELETE", "/song/song-1", nil) - router.ServeHTTP(w, req) - - // Should return 405 Method Not Allowed or 404 Not Found - Expect(w.Code).To(Equal(http.StatusMethodNotAllowed)) - }) - }) - }) - - Describe("Query parameters and filtering", func() { - Context("when using query parameters", func() { - It("handles pagination parameters", func() { - req := createAuthenticatedRequest("GET", "/song?_start=0&_end=1", nil) - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var response []model.MediaFile - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).ToNot(HaveOccurred()) - - // Should still return all songs since our mock doesn't implement pagination - // but the request should be processed successfully - Expect(len(response)).To(BeNumerically(">=", 1)) - }) - - It("handles sort parameters", func() { - req := createAuthenticatedRequest("GET", "/song?_sort=title&_order=ASC", nil) - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var response []model.MediaFile - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).ToNot(HaveOccurred()) - - Expect(response).To(HaveLen(2)) - }) - - It("handles filter parameters", func() { - // Properly encode the URL with query parameters - baseURL := "/song" - params := url.Values{} - params.Add("title", "Test Song 1") - fullURL := baseURL + "?" + params.Encode() - - req := createAuthenticatedRequest("GET", fullURL, nil) - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var response []model.MediaFile - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).ToNot(HaveOccurred()) - - // Mock doesn't implement filtering, but request should be processed - Expect(len(response)).To(BeNumerically(">=", 1)) - }) - }) - }) - - Describe("Response headers and content type", func() { - It("sets correct content type for JSON responses", func() { - req := createAuthenticatedRequest("GET", "/song", nil) - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(ContainSubstring("application/json")) - }) - - It("includes total count header when available", func() { - req := createAuthenticatedRequest("GET", "/song", nil) - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - // The X-Total-Count header might be set by the REST framework - // We just verify the request is processed successfully - }) - }) - - Describe("Edge cases and error handling", func() { - Context("when repository is unavailable", func() { - It("handles database connection errors", func() { - mfRepo.SetError(true) - - req := createAuthenticatedRequest("GET", "/song", nil) - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - - Context("when no songs exist", func() { - It("returns empty array when no songs are found", func() { - mfRepo.SetData(model.MediaFiles{}) // Empty dataset - - req := createAuthenticatedRequest("GET", "/song", nil) - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var response []model.MediaFile - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).ToNot(HaveOccurred()) - - Expect(response).To(HaveLen(0)) - }) - }) - }) - - Describe("Authentication middleware integration", func() { - Context("with different user types", func() { - It("works with admin users", func() { - adminUser := model.User{ - ID: "admin-1", - UserName: "admin", - Name: "Admin User", - IsAdmin: true, - NewPassword: "adminpass", - } - err := userRepo.Put(ctx, &adminUser) - Expect(err).ToNot(HaveOccurred()) - - // Create JWT token for admin user - token, err := auth.CreateToken(&adminUser) - Expect(err).ToNot(HaveOccurred()) - - req := createUnauthenticatedRequest("GET", "/song", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - }) - - It("works with regular users", func() { - regularUser := model.User{ - ID: "user-2", - UserName: "regular", - Name: "Regular User", - IsAdmin: false, - NewPassword: "userpass", - } - err := userRepo.Put(ctx, ®ularUser) - Expect(err).ToNot(HaveOccurred()) - - // Create JWT token for regular user - token, err := auth.CreateToken(®ularUser) - Expect(err).ToNot(HaveOccurred()) - - req := createUnauthenticatedRequest("GET", "/song", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - }) - }) - - Context("with missing authentication context", func() { - It("rejects requests without user context", func() { - req := createUnauthenticatedRequest("GET", "/song", nil) - // No authentication header added - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - - It("rejects requests with invalid JWT tokens", func() { - req := createUnauthenticatedRequest("GET", "/song", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer invalid.token.here") - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) - }) -}) diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go index 82f138492..494e4dcff 100644 --- a/server/nativeapi/playlists.go +++ b/server/nativeapi/playlists.go @@ -5,117 +5,81 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "strconv" "strings" "github.com/deluan/rest" "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/str" + "github.com/navidrome/navidrome/utils" ) -// writePlaylistError maps a playlist service error to an HTTP status, or defaultStatus if unknown. -func writePlaylistError(w http.ResponseWriter, err error, defaultStatus int) { - switch { - case errors.Is(err, model.ErrNotFound): - http.Error(w, err.Error(), http.StatusNotFound) - case errors.Is(err, model.ErrNotAuthorized): - http.Error(w, err.Error(), http.StatusForbidden) - case errors.Is(err, model.ErrPlaylistNotEditable): - http.Error(w, err.Error(), http.StatusConflict) - default: - http.Error(w, err.Error(), defaultStatus) - } -} +type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc + +func getPlaylist(ds model.DataStore) http.HandlerFunc { + // Add a middleware to capture the playlistId + wrapper := func(handler restHandler) http.HandlerFunc { + return func(res http.ResponseWriter, req *http.Request) { + constructor := func(ctx context.Context) rest.Repository { + plsRepo := ds.Playlist(ctx) + plsId := chi.URLParam(req, "playlistId") + return plsRepo.Tracks(plsId, true) + } + + handler(constructor).ServeHTTP(res, req) + } + } -func playlistTracksHandler(pls playlists.Playlists, handler func(rest.Repository[model.PlaylistTrack]) http.HandlerFunc, refreshSmartPlaylist func(*http.Request) bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - plsId := chi.URLParam(r, "playlistId") - tracks := pls.TracksRepository(r.Context(), plsId, refreshSmartPlaylist(r)) - if tracks == nil { - http.Error(w, "not found", http.StatusNotFound) + accept := r.Header.Get("accept") + if strings.ToLower(accept) == "audio/x-mpegurl" { + handleExportPlaylist(ds)(w, r) return } - handler(tracks).ServeHTTP(w, r) + wrapper(rest.GetAll)(w, r) } } -func getPlaylist(pls playlists.Playlists) http.HandlerFunc { - handler := playlistTracksHandler(pls, rest.GetAll[model.PlaylistTrack], func(r *http.Request) bool { - return req.Params(r).Int64Or("_start", 0) == 0 - }) - return func(w http.ResponseWriter, r *http.Request) { - if strings.ToLower(r.Header.Get("accept")) == "audio/x-mpegurl" { - handleExportPlaylist(pls)(w, r) - return - } - handler(w, r) - } -} - -func getPlaylistTrack(pls playlists.Playlists) http.HandlerFunc { - return playlistTracksHandler(pls, rest.Get[model.PlaylistTrack], func(*http.Request) bool { return true }) -} - -func createPlaylistFromM3U(pls playlists.Playlists) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - pl, err := pls.ImportM3U(ctx, r.Body) - if err != nil { - log.Error(r.Context(), "Error parsing playlist", err) - // TODO: consider returning StatusBadRequest for playlists that are malformed - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusCreated) - _, err = w.Write([]byte(pl.ToM3U8())) //nolint:gosec - if err != nil { - log.Error(ctx, "Error sending m3u contents", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } -} - -func handleExportPlaylist(pls playlists.Playlists) http.HandlerFunc { +func handleExportPlaylist(ds model.DataStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + plsRepo := ds.Playlist(ctx) plsId := chi.URLParam(r, "playlistId") - playlist, err := pls.GetWithTracks(ctx, plsId) + pls, err := plsRepo.GetWithTracks(plsId, true) if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Playlist not found", "playlistId", plsId) + log.Warn(r.Context(), "Playlist not found", "playlistId", plsId) http.Error(w, "not found", http.StatusNotFound) return } if err != nil { - log.Error(ctx, "Error retrieving the playlist", "playlistId", plsId, err) + log.Error(r.Context(), "Error retrieving the playlist", "playlistId", plsId, err) http.Error(w, err.Error(), http.StatusInternalServerError) return } - log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", playlist.Name) + log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", pls.Name) w.Header().Set("Content-Type", "audio/x-mpegurl") - w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(playlist.Name+".m3u")) + disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", pls.Name) + w.Header().Set("Content-Disposition", disposition) - _, err = w.Write([]byte(playlist.ToM3U8())) //nolint:gosec + _, err = w.Write([]byte(pls.ToM3U8())) if err != nil { - log.Error(ctx, "Error sending playlist", "name", playlist.Name) + log.Error(ctx, "Error sending playlist", "name", pls.Name) return } } } -func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc { +func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - p := req.Params(r) - playlistId, _ := p.String(":playlistId") - ids := p.Strings("id") - err := pls.RemoveTracks(r.Context(), playlistId, ids) + playlistId := utils.ParamString(r, ":playlistId") + ids := r.URL.Query()["id"] + err := ds.WithTx(func(tx model.DataStore) error { + tracksRepo := tx.Playlist(r.Context()).Tracks(playlistId, true) + return tracksRepo.Delete(ids...) + }) if len(ids) == 1 && errors.Is(err, model.ErrNotFound) { log.Warn(r.Context(), "Track not found in playlist", "playlistId", playlistId, "id", ids[0]) http.Error(w, "not found", http.StatusNotFound) @@ -123,14 +87,29 @@ func deleteFromPlaylist(pls playlists.Playlists) http.HandlerFunc { } if err != nil { log.Error(r.Context(), "Error deleting tracks from playlist", "playlistId", playlistId, "ids", ids, err) - writePlaylistError(w, err, http.StatusInternalServerError) + http.Error(w, err.Error(), http.StatusInternalServerError) return } - writeDeleteManyResponse(w, r, ids) + var resp []byte + if len(ids) == 1 { + resp = []byte(`{"id":"` + ids[0] + `"}`) + } else { + resp, err = json.Marshal(&struct { + Ids []string `json:"ids"` + }{Ids: ids}) + if err != nil { + log.Error(r.Context(), "Error marshaling delete response", "playlistId", playlistId, "ids", ids, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } + _, err = w.Write(resp) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } } -func addToPlaylist(pls playlists.Playlists) http.HandlerFunc { +func addToPlaylist(ds model.DataStore) http.HandlerFunc { type addTracksPayload struct { Ids []string `json:"ids"` AlbumIds []string `json:"albumIds"` @@ -139,55 +118,52 @@ func addToPlaylist(pls playlists.Playlists) http.HandlerFunc { } return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - p := req.Params(r) - playlistId, _ := p.String(":playlistId") + playlistId := utils.ParamString(r, ":playlistId") var payload addTracksPayload err := json.NewDecoder(r.Body).Decode(&payload) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } + tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true) count, c := 0, 0 - if c, err = pls.AddTracks(ctx, playlistId, payload.Ids); err != nil { - writePlaylistError(w, err, http.StatusBadRequest) + if c, err = tracksRepo.Add(payload.Ids); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } count += c - if c, err = pls.AddAlbums(ctx, playlistId, payload.AlbumIds); err != nil { - writePlaylistError(w, err, http.StatusBadRequest) + if c, err = tracksRepo.AddAlbums(payload.AlbumIds); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } count += c - if c, err = pls.AddArtists(ctx, playlistId, payload.ArtistIds); err != nil { - writePlaylistError(w, err, http.StatusBadRequest) + if c, err = tracksRepo.AddArtists(payload.ArtistIds); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } count += c - if c, err = pls.AddDiscs(ctx, playlistId, payload.Discs); err != nil { - writePlaylistError(w, err, http.StatusBadRequest) + if c, err = tracksRepo.AddDiscs(payload.Discs); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } count += c // Must return an object with an ID, to satisfy ReactAdmin `create` call - _, err = fmt.Fprintf(w, `{"added":%d}`, count) //nolint:gosec + _, err = fmt.Fprintf(w, `{"added":%d}`, count) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } -func reorderItem(pls playlists.Playlists) http.HandlerFunc { +func reorderItem(ds model.DataStore) http.HandlerFunc { type reorderPayload struct { InsertBefore string `json:"insert_before"` } return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - p := req.Params(r) - playlistId, _ := p.String(":playlistId") - id := p.IntOr(":id", 0) + playlistId := utils.ParamString(r, ":playlistId") + id := utils.ParamInt(r, ":id", 0) if id == 0 { http.Error(w, "invalid id", http.StatusBadRequest) return @@ -203,47 +179,20 @@ func reorderItem(pls playlists.Playlists) http.HandlerFunc { http.Error(w, err.Error(), http.StatusBadRequest) return } - err = pls.ReorderTrack(ctx, playlistId, id, newPos) + tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true) + err = tracksRepo.Reorder(id, newPos) + if errors.Is(err, rest.ErrPermissionDenied) { + http.Error(w, err.Error(), http.StatusForbidden) + return + } if err != nil { - writePlaylistError(w, err, http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } - _, err = w.Write(fmt.Appendf(nil, `{"id":"%d"}`, id)) //nolint:gosec + _, err = w.Write([]byte(fmt.Sprintf(`{"id":"%d"}`, id))) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } } - -func getSongPlaylists(svc playlists.Playlists) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - p := req.Params(r) - trackId, _ := p.String(":id") - playlists, err := svc.GetPlaylists(r.Context(), trackId) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - data, err := json.Marshal(playlists) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - _, _ = w.Write(data) //nolint:gosec - } -} - -func uploadPlaylistImage(pls playlists.Playlists) http.HandlerFunc { - return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { - playlistId := chi.URLParamFromCtx(ctx, "id") - return pls.SetImage(ctx, playlistId, reader, ext) - }) -} - -func deletePlaylistImage(pls playlists.Playlists) http.HandlerFunc { - return handleImageDelete(func(ctx context.Context) error { - playlistId := chi.URLParamFromCtx(ctx, "id") - return pls.RemoveImage(ctx, playlistId) - }) -} diff --git a/server/nativeapi/playlists_test.go b/server/nativeapi/playlists_test.go deleted file mode 100644 index 82e3bc86a..000000000 --- a/server/nativeapi/playlists_test.go +++ /dev/null @@ -1,291 +0,0 @@ -package nativeapi - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "time" - - "github.com/deluan/rest" - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Playlist Image Endpoints", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - DescribeTable("uploadPlaylistImage guard", - func(enableArtworkUpload, isAdmin bool, expectedStatus int) { - conf.Server.EnableArtworkUpload = enableArtworkUpload - handler := uploadPlaylistImage(&mockPlaylistsService{}) - - req := httptest.NewRequest("POST", "/playlist/pls-1/image", nil) - ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin}) - req = req.WithContext(ctx) - - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - Expect(w.Code).To(Equal(expectedStatus)) - }, - Entry("enabled, regular user passes guard", true, false, http.StatusBadRequest), - Entry("enabled, admin passes guard", true, true, http.StatusBadRequest), - Entry("disabled, admin passes guard", false, true, http.StatusBadRequest), - Entry("disabled, regular user is forbidden", false, false, http.StatusForbidden), - ) - - DescribeTable("deletePlaylistImage guard", - func(enableArtworkUpload, isAdmin bool, expectedStatus int) { - conf.Server.EnableArtworkUpload = enableArtworkUpload - handler := deletePlaylistImage(&mockPlaylistsService{}) - - req := httptest.NewRequest("DELETE", "/playlist/pls-1/image", nil) - ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "user-1", IsAdmin: isAdmin}) - req = req.WithContext(ctx) - - w := httptest.NewRecorder() - handler.ServeHTTP(w, req) - Expect(w.Code).To(Equal(expectedStatus)) - }, - Entry("enabled, regular user passes guard", true, false, http.StatusNotFound), - Entry("enabled, admin passes guard", true, true, http.StatusNotFound), - Entry("disabled, admin passes guard", false, true, http.StatusNotFound), - Entry("disabled, regular user is forbidden", false, false, http.StatusForbidden), - ) -}) - -var _ = Describe("Playlist Tracks Endpoint", func() { - var ( - router http.Handler - plsSvc *mockPlaylistsService - userRepo *tests.MockedUserRepo - w *httptest.ResponseRecorder - ) - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableSharing = false - conf.Server.SessionTimeout = time.Minute - - plsSvc = &mockPlaylistsService{} - userRepo = tests.CreateMockUserRepo() - - ds := &tests.MockDataStore{ - MockedUser: userRepo, - MockedProperty: &tests.MockedPropertyRepo{}, - } - - auth.Init(ds) - - testUser := model.User{ - ID: "user-1", - UserName: "testuser", - Name: "Test User", - IsAdmin: false, - NewPassword: "testpass", - } - err := userRepo.Put(GinkgoT().Context(), &testUser) - Expect(err).ToNot(HaveOccurred()) - - nativeRouter := New(ds, nil, plsSvc, nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, nil, nil, nil, nil) - router = server.JWTVerifier(nativeRouter) - w = httptest.NewRecorder() - }) - - createAuthenticatedRequest := func(method, path string) *http.Request { - req := httptest.NewRequest(method, path, nil) - testUser := model.User{ID: "user-1", UserName: "testuser"} - token, err := auth.CreateToken(&testUser) - Expect(err).ToNot(HaveOccurred()) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+token) - return req - } - - Describe("GET /playlist/{playlistId}/tracks", func() { - It("returns 404 when playlist does not exist", func() { - req := createAuthenticatedRequest("GET", "/playlist/non-existent/tracks") - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns tracks when playlist exists", func() { - plsSvc.tracksRepo = &mockPlaylistTrackRepo{ - tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"}, - {ID: "2", MediaFileID: "mf-2", PlaylistID: "pls-1"}, - }, - } - - req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks") - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var response []model.PlaylistTrack - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).ToNot(HaveOccurred()) - Expect(response).To(HaveLen(2)) - Expect(response[0].ID).To(Equal("1")) - Expect(response[1].ID).To(Equal("2")) - }) - }) - - Describe("GET /playlist/{playlistId}/tracks/{id}", func() { - It("returns 404 when playlist does not exist", func() { - req := createAuthenticatedRequest("GET", "/playlist/non-existent/tracks/1") - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns the track when playlist exists", func() { - plsSvc.tracksRepo = &mockPlaylistTrackRepo{ - tracks: model.PlaylistTracks{ - {ID: "1", MediaFileID: "mf-1", PlaylistID: "pls-1"}, - }, - } - - req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks/1") - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var response model.PlaylistTrack - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).ToNot(HaveOccurred()) - Expect(response.ID).To(Equal("1")) - Expect(response.MediaFileID).To(Equal("mf-1")) - }) - - It("returns 404 when track does not exist in playlist", func() { - plsSvc.tracksRepo = &mockPlaylistTrackRepo{ - tracks: model.PlaylistTracks{}, - } - - req := createAuthenticatedRequest("GET", "/playlist/pls-1/tracks/999") - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) -}) - -var _ = Describe("handleExportPlaylist", func() { - export := func(name string) *httptest.ResponseRecorder { - r := chi.NewRouter() - r.Get("/playlist/{playlistId}", handleExportPlaylist(&mockPlaylistsService{ - playlist: &model.Playlist{ID: "pls-1", Name: name}, - })) - w := httptest.NewRecorder() - r.ServeHTTP(w, httptest.NewRequest("GET", "/playlist/pls-1", nil)) - return w - } - - It("names the download after the playlist", func() { - w := export("Road Trip") - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="Road Trip.m3u"`)) - }) - - It("does not let the playlist name inject a second filename parameter", func() { - w := export(`party"; filename="evil.html`) - - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="party_; filename=_evil.html.m3u"`)) - }) - - It("keeps non-ASCII names in filename*", func() { - w := export("Кино") - - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="download.m3u"; filename*=utf-8''%D0%9A%D0%B8%D0%BD%D0%BE.m3u`)) - }) -}) - -var _ = Describe("writePlaylistError", func() { - DescribeTable("maps a service error to an HTTP status", - func(err error, expected int) { - w := httptest.NewRecorder() - writePlaylistError(w, err, http.StatusBadRequest) - Expect(w.Code).To(Equal(expected)) - }, - Entry("not found -> 404", model.ErrNotFound, http.StatusNotFound), - Entry("not authorized -> 403", model.ErrNotAuthorized, http.StatusForbidden), - Entry("rest permission denied -> 403", rest.ErrPermissionDenied, http.StatusForbidden), - Entry("not editable -> 409", model.ErrPlaylistNotEditable, http.StatusConflict), - Entry("unrecognized -> default", model.ErrValidation, http.StatusBadRequest), - ) -}) - -type mockPlaylistTrackRepo struct { - model.PlaylistTrackRepository - tracks model.PlaylistTracks -} - -func (m *mockPlaylistTrackRepo) Count(context.Context, ...rest.QueryOptions) (int64, error) { - return int64(len(m.tracks)), nil -} - -func (m *mockPlaylistTrackRepo) ReadAll(context.Context, ...rest.QueryOptions) ([]model.PlaylistTrack, error) { - return m.tracks, nil -} - -func (m *mockPlaylistTrackRepo) Read(_ context.Context, id string) (*model.PlaylistTrack, error) { - for _, t := range m.tracks { - if t.ID == id { - return &t, nil - } - } - return nil, rest.ErrNotFound -} - -type mockPlaylistsService struct { - playlists.Playlists - repo rest.Repository[model.Playlist] - tracksRepo rest.Repository[model.PlaylistTrack] - playlist *model.Playlist - removeImageFn func(ctx context.Context, id string) error - setImageFn func(ctx context.Context, id string, reader io.Reader, ext string) error -} - -func (m *mockPlaylistsService) RemoveImage(ctx context.Context, id string) error { - if m.removeImageFn != nil { - return m.removeImageFn(ctx, id) - } - return model.ErrNotFound -} - -func (m *mockPlaylistsService) SetImage(ctx context.Context, id string, reader io.Reader, ext string) error { - if m.setImageFn != nil { - return m.setImageFn(ctx, id, reader, ext) - } - return model.ErrNotFound -} - -func (m *mockPlaylistsService) Repository() rest.Repository[model.Playlist] { - return m.repo -} - -func (m *mockPlaylistsService) GetWithTracks(_ context.Context, _ string) (*model.Playlist, error) { - if m.playlist == nil { - return nil, model.ErrNotFound - } - return m.playlist, nil -} - -func (m *mockPlaylistsService) TracksRepository(_ context.Context, _ string, _ bool) rest.Repository[model.PlaylistTrack] { - return m.tracksRepo -} diff --git a/server/nativeapi/plugin.go b/server/nativeapi/plugin.go deleted file mode 100644 index d34bf23fc..000000000 --- a/server/nativeapi/plugin.go +++ /dev/null @@ -1,269 +0,0 @@ -package nativeapi - -import ( - "context" - "encoding/json" - "errors" - "net/http" - - "github.com/deluan/rest" - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" -) - -func (api *Router) addPluginRoute(r chi.Router) { - repo := api.ds.Plugin() - - r.Route("/plugin", func(r chi.Router) { - r.Use(pluginsEnabledMiddleware) - r.Get("/", rest.GetAll(repo)) - r.Post("/rescan", api.rescanPlugins) - r.Route("/{id}", func(r chi.Router) { - r.Use(server.URLParamsMiddleware) - r.Get("/", rest.Get(repo)) - r.Put("/", api.updatePlugin) - }) - }) -} - -func (api *Router) rescanPlugins(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - if err := api.pluginManager.RescanPlugins(ctx); err != nil { - log.Error(ctx, "Error rescanning plugins", err) - http.Error(w, "Error rescanning plugins: "+err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -// Middleware to check if plugins feature is enabled -func pluginsEnabledMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !conf.Server.Plugins.Enabled { - http.Error(w, "Not found", http.StatusNotFound) - return - } - next.ServeHTTP(w, r) - }) -} - -// PluginUpdateRequest represents the fields that can be updated via the API -type PluginUpdateRequest struct { - Enabled *bool `json:"enabled,omitempty"` - Config *string `json:"config,omitempty"` - Users *string `json:"users,omitempty"` - AllUsers *bool `json:"allUsers,omitempty"` - Libraries *string `json:"libraries,omitempty"` - AllLibraries *bool `json:"allLibraries,omitempty"` - AllowWriteAccess *bool `json:"allowWriteAccess,omitempty"` -} - -func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - ctx := r.Context() - repo := api.ds.Plugin() - - // Get existing plugin to verify it exists - if _, err := repo.Get(ctx, id); err != nil { - if errors.Is(err, rest.ErrPermissionDenied) { - http.Error(w, "Access denied: admin privileges required", http.StatusForbidden) - return - } - if errors.Is(err, model.ErrNotFound) { - http.Error(w, "Plugin not found", http.StatusNotFound) - return - } - log.Error(ctx, "Error getting plugin", "id", id, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - // Parse update request - var req PluginUpdateRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - log.Error(ctx, "Error decoding request", err) - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - // Handle config update (if provided) - if req.Config != nil { - if err := validateAndUpdateConfig(ctx, api.pluginManager, id, *req.Config, w); err != nil { - log.Error(ctx, "Error updating plugin config", err) - return - } - } - - // Handle users permission update (if provided) - if req.Users != nil || req.AllUsers != nil { - if err := validateAndUpdateUsers(ctx, api.pluginManager, repo, id, req, w); err != nil { - log.Error(ctx, "Error updating plugin users", err) - return - } - } - - // Handle libraries permission update (if provided) - if req.Libraries != nil || req.AllLibraries != nil || req.AllowWriteAccess != nil { - if err := validateAndUpdateLibraries(ctx, api.pluginManager, repo, id, req, w); err != nil { - log.Error(ctx, "Error updating plugin libraries", err) - return - } - } - - // Handle enable/disable - if req.Enabled != nil { - if *req.Enabled { - if enableErr := api.pluginManager.EnablePlugin(ctx, id); enableErr != nil { - log.Error(ctx, "Error enabling plugin", "id", id, enableErr) - // Refresh plugin from DB to get the error - plugin, err := repo.Get(ctx, id) - if err != nil { - log.Error(ctx, "Error getting updated plugin after enable failure", "id", id, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - // Return error response with message field for React-Admin compatibility - // and include the plugin data so UI can update its state - errorResponse := struct { - Message string `json:"message"` - Plugin *model.Plugin `json:"plugin"` - }{ - Message: enableErr.Error(), - Plugin: plugin, - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnprocessableEntity) - _ = json.NewEncoder(w).Encode(errorResponse) - return - } - } else { - if err := api.pluginManager.DisablePlugin(ctx, id); err != nil { - log.Error(ctx, "Error disabling plugin", "id", id, err) - http.Error(w, "Error disabling plugin: "+err.Error(), http.StatusInternalServerError) - return - } - } - } - - // Refresh and return updated plugin - plugin, err := repo.Get(ctx, id) - if err != nil { - log.Error(ctx, "Error getting updated plugin", "id", id, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(plugin); err != nil { - log.Error(ctx, "Error encoding plugin response", err) - } -} - -// isValidJSON checks if a string is valid JSON -func isValidJSON(s string) bool { - var js json.RawMessage - return json.Unmarshal([]byte(s), &js) == nil -} - -// validateAndUpdateConfig validates the config JSON against the plugin's schema and updates the plugin. -// Returns an error if validation or update fails (error response already written). -func validateAndUpdateConfig(ctx context.Context, pm PluginManager, id, configJSON string, w http.ResponseWriter) error { - // Basic JSON syntax check - if configJSON != "" && !isValidJSON(configJSON) { - http.Error(w, "Invalid JSON in config field", http.StatusBadRequest) - return errors.New("invalid JSON") - } - - // Validate against plugin's config schema - if err := pm.ValidatePluginConfig(ctx, id, configJSON); err != nil { - log.Warn(ctx, "Config validation failed", "id", id, err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - // Try to return structured validation errors if available - response := map[string]any{"message": err.Error()} - _ = json.NewEncoder(w).Encode(response) - return err - } - - if err := pm.UpdatePluginConfig(ctx, id, configJSON); err != nil { - log.Error(ctx, "Error updating plugin config", "id", id, err) - http.Error(w, "Error updating plugin configuration: "+err.Error(), http.StatusInternalServerError) - return err - } - return nil -} - -// validateAndUpdateUsers validates the users JSON and updates the plugin. -// Returns an error if validation or update fails (error response already written). -func validateAndUpdateUsers(ctx context.Context, pm PluginManager, repo model.PluginRepository, id string, req PluginUpdateRequest, w http.ResponseWriter) error { - // Get current values if not provided in request - plugin, err := repo.Get(ctx, id) - if err != nil { - log.Error(ctx, "Error getting plugin for users update", "id", id, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return err - } - - usersJSON := plugin.Users - allUsers := plugin.AllUsers - - if req.Users != nil { - if *req.Users != "" && !isValidJSON(*req.Users) { - http.Error(w, "Invalid JSON in users field", http.StatusBadRequest) - return errors.New("invalid JSON") - } - usersJSON = *req.Users - } - if req.AllUsers != nil { - allUsers = *req.AllUsers - } - - if err := pm.UpdatePluginUsers(ctx, id, usersJSON, allUsers); err != nil { - log.Error(ctx, "Error updating plugin users", "id", id, err) - http.Error(w, "Error updating plugin users: "+err.Error(), http.StatusInternalServerError) - return err - } - return nil -} - -// validateAndUpdateLibraries validates the libraries JSON and updates the plugin. -// Returns an error if validation or update fails (error response already written). -func validateAndUpdateLibraries(ctx context.Context, pm PluginManager, repo model.PluginRepository, id string, req PluginUpdateRequest, w http.ResponseWriter) error { - // Get current values if not provided in request - plugin, err := repo.Get(ctx, id) - if err != nil { - log.Error(ctx, "Error getting plugin for libraries update", "id", id, err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return err - } - - librariesJSON := plugin.Libraries - allLibraries := plugin.AllLibraries - allowWriteAccess := plugin.AllowWriteAccess - - if req.Libraries != nil { - if *req.Libraries != "" && !isValidJSON(*req.Libraries) { - http.Error(w, "Invalid JSON in libraries field", http.StatusBadRequest) - return errors.New("invalid JSON") - } - librariesJSON = *req.Libraries - } - if req.AllLibraries != nil { - allLibraries = *req.AllLibraries - } - if req.AllowWriteAccess != nil { - allowWriteAccess = *req.AllowWriteAccess - } - - if err := pm.UpdatePluginLibraries(ctx, id, librariesJSON, allLibraries, allowWriteAccess); err != nil { - log.Error(ctx, "Error updating plugin libraries", "id", id, err) - http.Error(w, "Error updating plugin libraries: "+err.Error(), http.StatusInternalServerError) - return err - } - return nil -} diff --git a/server/nativeapi/plugin_test.go b/server/nativeapi/plugin_test.go deleted file mode 100644 index c18d61e65..000000000 --- a/server/nativeapi/plugin_test.go +++ /dev/null @@ -1,490 +0,0 @@ -package nativeapi - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Plugin API", func() { - var ctx context.Context - var ds *tests.MockDataStore - var mockManager *tests.MockPluginManager - var router http.Handler - var adminUser, regularUser model.User - var testPlugin1, testPlugin2 model.Plugin - - BeforeEach(func() { - ctx = GinkgoT().Context() - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableSharing = false - conf.Server.Plugins.Enabled = true - ds = &tests.MockDataStore{} - mockManager = &tests.MockPluginManager{} - auth.Init(ds) - nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), tests.NewMockUserService(), nil, mockManager, nil, nil, nil) - router = server.JWTVerifier(nativeRouter) - - // Create test users - adminUser = model.User{ - ID: "admin-1", - UserName: "admin", - Name: "Admin User", - IsAdmin: true, - NewPassword: "adminpass", - } - regularUser = model.User{ - ID: "user-1", - UserName: "regular", - Name: "Regular User", - IsAdmin: false, - NewPassword: "userpass", - } - - // Create test plugins - testPlugin1 = model.Plugin{ - ID: "test-plugin-1", - Path: "/plugins/test1.wasm", - Manifest: `{"name":"Test Plugin 1","version":"1.0.0"}`, - SHA256: "abc123", - Enabled: false, - } - testPlugin2 = model.Plugin{ - ID: "test-plugin-2", - Path: "/plugins/test2.wasm", - Manifest: `{"name":"Test Plugin 2","version":"2.0.0"}`, - Config: `{"setting":"value"}`, - SHA256: "def456", - Enabled: true, - } - - // Store users in mock datastore - Expect(ds.User().Put(ctx, &adminUser)).To(Succeed()) - Expect(ds.User().Put(ctx, ®ularUser)).To(Succeed()) - }) - - Context("when plugins are disabled", func() { - BeforeEach(func() { - conf.Server.Plugins.Enabled = false - }) - - It("returns 404 for all plugin endpoints", func() { - adminToken, err := auth.CreateToken(&adminUser) - Expect(err).ToNot(HaveOccurred()) - - req := httptest.NewRequest("GET", "/plugin", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Context("when plugins are enabled", func() { - Describe("as admin user", func() { - var adminToken string - - BeforeEach(func() { - var err error - adminToken, err = auth.CreateToken(&adminUser) - Expect(err).ToNot(HaveOccurred()) - - // Store test plugins as admin - adminCtx := request.WithUser(ctx, adminUser) - Expect(ds.Plugin().Put(adminCtx, &testPlugin1)).To(Succeed()) - Expect(ds.Plugin().Put(adminCtx, &testPlugin2)).To(Succeed()) - }) - - Describe("GET /api/plugin", func() { - It("returns all plugins", func() { - req := httptest.NewRequest("GET", "/plugin", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var plugins []model.Plugin - err := json.Unmarshal(w.Body.Bytes(), &plugins) - Expect(err).ToNot(HaveOccurred()) - Expect(plugins).To(HaveLen(2)) - }) - }) - - Describe("GET /api/plugin/{id}", func() { - It("returns a specific plugin", func() { - req := httptest.NewRequest("GET", "/plugin/test-plugin-1", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var plugin model.Plugin - err := json.Unmarshal(w.Body.Bytes(), &plugin) - Expect(err).ToNot(HaveOccurred()) - Expect(plugin.ID).To(Equal("test-plugin-1")) - Expect(plugin.Path).To(Equal("/plugins/test1.wasm")) - }) - - It("returns 404 for non-existent plugin", func() { - req := httptest.NewRequest("GET", "/plugin/non-existent", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Describe("PUT /api/plugin/{id}", func() { - It("updates plugin enabled state", func() { - // Configure mock to update the repo when EnablePlugin is called - mockManager.EnablePluginFn = func(ctx context.Context, id string) error { - adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin().Get(adminCtx, id) - p.Enabled = true - return ds.Plugin().Put(adminCtx, p) - } - - body := bytes.NewBufferString(`{"enabled":true}`) - req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var plugin model.Plugin - err := json.Unmarshal(w.Body.Bytes(), &plugin) - Expect(err).ToNot(HaveOccurred()) - Expect(plugin.Enabled).To(BeTrue()) - Expect(mockManager.EnablePluginCalls).To(ContainElement("test-plugin-1")) - }) - - It("updates plugin config with valid JSON", func() { - // Configure mock to update the repo when UpdatePluginConfig is called - mockManager.UpdatePluginConfigFn = func(ctx context.Context, id, configJSON string) error { - adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin().Get(adminCtx, id) - p.Config = configJSON - return ds.Plugin().Put(adminCtx, p) - } - - body := bytes.NewBufferString(`{"config":"{\"key\":\"value\"}"}`) - req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var plugin model.Plugin - err := json.Unmarshal(w.Body.Bytes(), &plugin) - Expect(err).ToNot(HaveOccurred()) - Expect(plugin.Config).To(Equal(`{"key":"value"}`)) - Expect(mockManager.UpdatePluginConfigCalls).To(HaveLen(1)) - Expect(mockManager.UpdatePluginConfigCalls[0].ConfigJSON).To(Equal(`{"key":"value"}`)) - }) - - It("rejects invalid JSON in config field", func() { - body := bytes.NewBufferString(`{"config":"not valid json"}`) - req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(w.Body.String()).To(ContainSubstring("Invalid JSON")) - }) - - It("allows empty config", func() { - // Configure mock to update the repo when UpdatePluginConfig is called - mockManager.UpdatePluginConfigFn = func(ctx context.Context, id, configJSON string) error { - adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin().Get(adminCtx, id) - p.Config = configJSON - return ds.Plugin().Put(adminCtx, p) - } - - body := bytes.NewBufferString(`{"config":""}`) - req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var plugin model.Plugin - err := json.Unmarshal(w.Body.Bytes(), &plugin) - Expect(err).ToNot(HaveOccurred()) - Expect(plugin.Config).To(Equal("")) - }) - - It("updates users field", func() { - // Configure mock to update the repo when UpdatePluginUsers is called - mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { - adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin().Get(adminCtx, id) - p.Users = usersJSON - p.AllUsers = allUsers - return ds.Plugin().Put(adminCtx, p) - } - - body := bytes.NewBufferString(`{"users":"[\"user1\",\"user2\"]"}`) - req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var plugin model.Plugin - err := json.Unmarshal(w.Body.Bytes(), &plugin) - Expect(err).ToNot(HaveOccurred()) - Expect(plugin.Users).To(Equal(`["user1","user2"]`)) - Expect(mockManager.UpdatePluginUsersCalls).To(HaveLen(1)) - Expect(mockManager.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["user1","user2"]`)) - }) - - It("updates allUsers field", func() { - // Configure mock to update the repo when UpdatePluginUsers is called - mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { - adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin().Get(adminCtx, id) - p.Users = usersJSON - p.AllUsers = allUsers - return ds.Plugin().Put(adminCtx, p) - } - - body := bytes.NewBufferString(`{"allUsers":true}`) - req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var plugin model.Plugin - err := json.Unmarshal(w.Body.Bytes(), &plugin) - Expect(err).ToNot(HaveOccurred()) - Expect(plugin.AllUsers).To(BeTrue()) - Expect(mockManager.UpdatePluginUsersCalls).To(HaveLen(1)) - Expect(mockManager.UpdatePluginUsersCalls[0].AllUsers).To(BeTrue()) - }) - - It("updates both users and allUsers fields together", func() { - // Configure mock to update the repo when UpdatePluginUsers is called - mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { - adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin().Get(adminCtx, id) - p.Users = usersJSON - p.AllUsers = allUsers - return ds.Plugin().Put(adminCtx, p) - } - - body := bytes.NewBufferString(`{"users":"[\"user1\"]","allUsers":false}`) - req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var plugin model.Plugin - err := json.Unmarshal(w.Body.Bytes(), &plugin) - Expect(err).ToNot(HaveOccurred()) - Expect(plugin.Users).To(Equal(`["user1"]`)) - Expect(plugin.AllUsers).To(BeFalse()) - Expect(mockManager.UpdatePluginUsersCalls).To(HaveLen(1)) - }) - - It("rejects invalid JSON in users field", func() { - body := bytes.NewBufferString(`{"users":"not valid json"}`) - req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(w.Body.String()).To(ContainSubstring("Invalid JSON")) - }) - - It("allows empty users", func() { - // Configure mock to update the repo when UpdatePluginUsers is called - mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error { - adminCtx := request.WithUser(ctx, adminUser) - p, _ := ds.Plugin().Get(adminCtx, id) - p.Users = usersJSON - p.AllUsers = allUsers - return ds.Plugin().Put(adminCtx, p) - } - - body := bytes.NewBufferString(`{"users":""}`) - req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - - var plugin model.Plugin - err := json.Unmarshal(w.Body.Bytes(), &plugin) - Expect(err).ToNot(HaveOccurred()) - Expect(plugin.Users).To(Equal("")) - }) - - It("returns 404 for non-existent plugin", func() { - body := bytes.NewBufferString(`{"enabled":true}`) - req := httptest.NewRequest("PUT", "/plugin/non-existent", body) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 400 for invalid request body", func() { - body := bytes.NewBufferString(`not json`) - req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - }) - - Describe("POST /api/plugin/rescan", func() { - It("triggers plugin rescan", func() { - req := httptest.NewRequest("POST", "/plugin/rescan", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(mockManager.RescanPluginsCalls).To(Equal(1)) - }) - - It("returns error when rescan fails", func() { - mockManager.RescanError = errors.New("folder not configured") - - req := httptest.NewRequest("POST", "/plugin/rescan", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - Expect(w.Body.String()).To(ContainSubstring("folder not configured")) - }) - }) - }) - - Describe("as regular user", func() { - var userToken string - - BeforeEach(func() { - var err error - userToken, err = auth.CreateToken(®ularUser) - Expect(err).ToNot(HaveOccurred()) - }) - - It("denies access to GET /api/plugin", func() { - req := httptest.NewRequest("GET", "/plugin", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+userToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - - It("denies access to GET /api/plugin/{id}", func() { - req := httptest.NewRequest("GET", "/plugin/test-plugin-1", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+userToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - - It("denies access to PUT /api/plugin/{id}", func() { - body := bytes.NewBufferString(`{"enabled":true}`) - req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+userToken) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - - It("denies access to POST /api/plugin/rescan", func() { - req := httptest.NewRequest("POST", "/plugin/rescan", nil) - req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+userToken) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusForbidden)) - }) - }) - - Describe("without authentication", func() { - It("denies access to plugin endpoints", func() { - req := httptest.NewRequest("GET", "/plugin", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusUnauthorized)) - }) - }) - }) -}) diff --git a/server/nativeapi/queue.go b/server/nativeapi/queue.go deleted file mode 100644 index 05188106c..000000000 --- a/server/nativeapi/queue.go +++ /dev/null @@ -1,214 +0,0 @@ -package nativeapi - -import ( - "context" - "encoding/json" - "errors" - "net/http" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/navidrome/navidrome/utils/gg" - "github.com/navidrome/navidrome/utils/slice" -) - -type updateQueuePayload struct { - Ids *[]string `json:"ids,omitempty"` - Current *int `json:"current,omitempty"` - Position *int64 `json:"position,omitempty"` -} - -// validateCurrentIndex validates that the current index is within bounds of the items array. -// Returns false if validation fails (and sends error response), true if validation passes. -func validateCurrentIndex(w http.ResponseWriter, current int, itemsLength int) bool { - if current < 0 || current >= itemsLength { - http.Error(w, "current index out of bounds", http.StatusBadRequest) - return false - } - return true -} - -// retrieveExistingQueue retrieves an existing play queue for a user with proper error handling. -// Returns the queue (nil if not found) and false if an error occurred and response was sent. -func retrieveExistingQueue(ctx context.Context, w http.ResponseWriter, ds model.DataStore, userID string) (*model.PlayQueue, bool) { - existing, err := ds.PlayQueue().Retrieve(ctx, userID) - if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Error(ctx, "Error retrieving queue", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return nil, false - } - return existing, true -} - -// decodeUpdatePayload decodes the JSON payload from the request body. -// Returns false if decoding fails (and sends error response), true if successful. -func decodeUpdatePayload(w http.ResponseWriter, r *http.Request) (*updateQueuePayload, bool) { - var payload updateQueuePayload - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return nil, false - } - return &payload, true -} - -// createMediaFileItems converts a slice of IDs to MediaFile items. -func createMediaFileItems(ids []string) []model.MediaFile { - return slice.Map(ids, func(id string) model.MediaFile { - return model.MediaFile{ID: id} - }) -} - -// extractUserAndClient extracts user and client from the request context. -func extractUserAndClient(ctx context.Context) (model.User, string) { - user, _ := request.UserFrom(ctx) - client, _ := request.ClientFrom(ctx) - return user, client -} - -func getQueue(ds model.DataStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - user, _ := request.UserFrom(ctx) - repo := ds.PlayQueue() - pq, err := repo.RetrieveWithMediaFiles(ctx, user.ID) - if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Error(ctx, "Error retrieving queue", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - if pq == nil { - pq = &model.PlayQueue{} - } - resp, err := json.Marshal(pq) - if err != nil { - log.Error(ctx, "Error marshalling queue", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(resp) //nolint:gosec - } -} - -func saveQueue(ds model.DataStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - payload, ok := decodeUpdatePayload(w, r) - if !ok { - return - } - user, client := extractUserAndClient(ctx) - ids := V(payload.Ids) - items := createMediaFileItems(ids) - current := V(payload.Current) - if len(ids) > 0 && !validateCurrentIndex(w, current, len(ids)) { - return - } - pq := &model.PlayQueue{ - UserID: user.ID, - Current: current, - Position: max(V(payload.Position), 0), - ChangedBy: client, - Items: items, - } - if err := ds.PlayQueue().Store(ctx, pq); err != nil { - log.Error(ctx, "Error saving queue", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusNoContent) - } -} - -func updateQueue(ds model.DataStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Decode and validate the JSON payload - payload, ok := decodeUpdatePayload(w, r) - if !ok { - return - } - - // Extract user and client information from request context - user, client := extractUserAndClient(ctx) - - // Initialize play queue with user ID and client info - pq := &model.PlayQueue{UserID: user.ID, ChangedBy: client} - var cols []string // Track which columns to update in the database - - // Handle queue items update - if payload.Ids != nil { - pq.Items = createMediaFileItems(*payload.Ids) - cols = append(cols, "items") - - // If current index is not being updated, validate existing current index - // against the new items list to ensure it remains valid - if payload.Current == nil { - existing, ok := retrieveExistingQueue(ctx, w, ds, user.ID) - if !ok { - return - } - if existing != nil && !validateCurrentIndex(w, existing.Current, len(*payload.Ids)) { - return - } - } - } - - // Handle current track index update - if payload.Current != nil { - pq.Current = *payload.Current - cols = append(cols, "current") - - if payload.Ids != nil { - // If items are also being updated, validate current index against new items - if !validateCurrentIndex(w, *payload.Current, len(*payload.Ids)) { - return - } - } else { - // If only current index is being updated, validate against existing items - existing, ok := retrieveExistingQueue(ctx, w, ds, user.ID) - if !ok { - return - } - if existing != nil && !validateCurrentIndex(w, *payload.Current, len(existing.Items)) { - return - } - } - } - - // Handle playback position update - if payload.Position != nil { - pq.Position = max(*payload.Position, 0) // Ensure position is non-negative - cols = append(cols, "position") - } - - // If no fields were specified for update, return success without doing anything - if len(cols) == 0 { - w.WriteHeader(http.StatusNoContent) - return - } - - // Perform partial update of the specified columns only - if err := ds.PlayQueue().Store(ctx, pq, cols...); err != nil { - log.Error(ctx, "Error updating queue", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusNoContent) - } -} - -func clearQueue(ds model.DataStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - user, _ := request.UserFrom(ctx) - if err := ds.PlayQueue().Clear(ctx, user.ID); err != nil { - log.Error(ctx, "Error clearing queue", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusNoContent) - } -} diff --git a/server/nativeapi/queue_test.go b/server/nativeapi/queue_test.go deleted file mode 100644 index 6e68fec0e..000000000 --- a/server/nativeapi/queue_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package nativeapi - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Queue Endpoints", func() { - var ( - ds *tests.MockDataStore - repo *tests.MockPlayQueueRepo - user model.User - userRepo *tests.MockedUserRepo - ) - - BeforeEach(func() { - repo = &tests.MockPlayQueueRepo{} - user = model.User{ID: "u1", UserName: "user"} - userRepo = tests.CreateMockUserRepo() - _ = userRepo.Put(GinkgoT().Context(), &user) - ds = &tests.MockDataStore{MockedPlayQueue: repo, MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}} - }) - - Describe("POST /queue", func() { - It("saves the queue", func() { - payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1), Position: new(int64(10))} - body, _ := json.Marshal(payload) - req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) - ctx := request.WithUser(req.Context(), user) - ctx = request.WithClient(ctx, "TestClient") - req = req.WithContext(ctx) - w := httptest.NewRecorder() - - saveQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(repo.Queue).ToNot(BeNil()) - Expect(repo.Queue.Current).To(Equal(1)) - Expect(repo.Queue.Items).To(HaveLen(2)) - Expect(repo.Queue.Items[1].ID).To(Equal("s2")) - Expect(repo.Queue.ChangedBy).To(Equal("TestClient")) - }) - - It("saves an empty queue", func() { - payload := updateQueuePayload{Ids: new([]string{}), Current: new(0), Position: new(int64(0))} - body, _ := json.Marshal(payload) - req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - saveQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(repo.Queue).ToNot(BeNil()) - Expect(repo.Queue.Items).To(HaveLen(0)) - }) - - It("returns bad request for invalid current index (negative)", func() { - payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(-1), Position: new(int64(10))} - body, _ := json.Marshal(payload) - req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - saveQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(w.Body.String()).To(ContainSubstring("current index out of bounds")) - }) - - It("returns bad request for invalid current index (too large)", func() { - payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(2), Position: new(int64(10))} - body, _ := json.Marshal(payload) - req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - saveQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(w.Body.String()).To(ContainSubstring("current index out of bounds")) - }) - - It("returns bad request for malformed JSON", func() { - req := httptest.NewRequest("POST", "/queue", bytes.NewReader([]byte("invalid json"))) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - saveQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns internal server error when store fails", func() { - repo.Err = true - payload := updateQueuePayload{Ids: new([]string{"s1"}), Current: new(0), Position: new(int64(10))} - body, _ := json.Marshal(payload) - req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - saveQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - - Describe("GET /queue", func() { - It("returns the queue", func() { - queue := &model.PlayQueue{ - UserID: user.ID, - Current: 1, - Position: 55, - Items: model.MediaFiles{ - {ID: "track1", Title: "Song 1"}, - {ID: "track2", Title: "Song 2"}, - {ID: "track3", Title: "Song 3"}, - }, - } - repo.Queue = queue - req := httptest.NewRequest("GET", "/queue", nil) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - getQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var resp model.PlayQueue - Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) - Expect(resp.Current).To(Equal(1)) - Expect(resp.Position).To(Equal(int64(55))) - Expect(resp.Items).To(HaveLen(3)) - Expect(resp.Items[0].ID).To(Equal("track1")) - Expect(resp.Items[1].ID).To(Equal("track2")) - Expect(resp.Items[2].ID).To(Equal("track3")) - }) - - It("returns empty queue when user has no queue", func() { - req := httptest.NewRequest("GET", "/queue", nil) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - getQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusOK)) - var resp model.PlayQueue - Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed()) - Expect(resp.Items).To(BeEmpty()) - Expect(resp.Current).To(Equal(0)) - Expect(resp.Position).To(Equal(int64(0))) - }) - - It("returns internal server error when retrieve fails", func() { - repo.Err = true - req := httptest.NewRequest("GET", "/queue", nil) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - getQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - - Describe("PUT /queue", func() { - It("updates the queue fields", func() { - repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}, {ID: "s2"}, {ID: "s3"}}} - payload := updateQueuePayload{Current: new(2), Position: new(int64(20))} - body, _ := json.Marshal(payload) - req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) - ctx := request.WithUser(req.Context(), user) - ctx = request.WithClient(ctx, "TestClient") - req = req.WithContext(ctx) - w := httptest.NewRecorder() - - updateQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(repo.Queue).ToNot(BeNil()) - Expect(repo.Queue.Current).To(Equal(2)) - Expect(repo.Queue.Position).To(Equal(int64(20))) - Expect(repo.Queue.ChangedBy).To(Equal("TestClient")) - }) - - It("updates only ids", func() { - repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 1} - payload := updateQueuePayload{Ids: new([]string{"s1", "s2"})} - body, _ := json.Marshal(payload) - req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - updateQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(repo.Queue.Items).To(HaveLen(2)) - Expect(repo.LastCols).To(ConsistOf("items")) - }) - - It("updates ids and current", func() { - repo.Queue = &model.PlayQueue{UserID: user.ID} - payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1)} - body, _ := json.Marshal(payload) - req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - updateQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(repo.Queue.Items).To(HaveLen(2)) - Expect(repo.Queue.Current).To(Equal(1)) - Expect(repo.LastCols).To(ConsistOf("items", "current")) - }) - - It("returns bad request when new ids invalidate current", func() { - repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 2} - payload := updateQueuePayload{Ids: new([]string{"s1", "s2"})} - body, _ := json.Marshal(payload) - req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - updateQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns bad request when current out of bounds", func() { - repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}} - payload := updateQueuePayload{Current: new(3)} - body, _ := json.Marshal(payload) - req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - updateQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns bad request for malformed JSON", func() { - req := httptest.NewRequest("PUT", "/queue", bytes.NewReader([]byte("{"))) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - updateQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns internal server error when store fails", func() { - repo.Err = true - payload := updateQueuePayload{Position: new(int64(10))} - body, _ := json.Marshal(payload) - req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - updateQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - - Describe("DELETE /queue", func() { - It("clears the queue", func() { - repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}} - req := httptest.NewRequest("DELETE", "/queue", nil) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - clearQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusNoContent)) - Expect(repo.Queue).To(BeNil()) - }) - - It("returns internal server error when clear fails", func() { - repo.Err = true - req := httptest.NewRequest("DELETE", "/queue", nil) - req = req.WithContext(request.WithUser(req.Context(), user)) - w := httptest.NewRecorder() - - clearQueue(ds)(w, req) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) -}) diff --git a/server/nativeapi/quickconnect.go b/server/nativeapi/quickconnect.go deleted file mode 100644 index 61d402cc5..000000000 --- a/server/nativeapi/quickconnect.go +++ /dev/null @@ -1,76 +0,0 @@ -package nativeapi - -import ( - "encoding/json" - "errors" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/quickconnect" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server" -) - -type quickConnectDevice struct { - AppName string `json:"appName"` - AppVersion string `json:"appVersion"` - DeviceName string `json:"deviceName"` -} - -func (api *Router) addQuickConnectRoute(r chi.Router) { - if !quickconnect.Enabled() { - return - } - r.Route("/quickconnect", func(r chi.Router) { - // Throttled like login so a signed-in user cannot enumerate other people's pending codes. - if conf.Server.AuthRequestLimit > 0 { - r.Use(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)) - } - r.Get("/", lookupQuickConnect(api.quickConnect)) - r.Post("/authorize", authorizeQuickConnect(api.quickConnect)) - }) -} - -func lookupQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - req, err := qc.Lookup(r.URL.Query().Get("code")) - writeQuickConnectResult(w, r, req, err) - } -} - -func authorizeQuickConnect(qc quickconnect.QuickConnect) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - var body struct { - Code string `json:"code"` - } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - ctx := r.Context() - user, _ := request.UserFrom(ctx) - req, err := qc.Authorize(body.Code, user.ID) - if err == nil { - log.Info(ctx, "Quick Connect sign-in approved", "username", user.UserName, "client", req.Device.App, "device", req.Device.Name) - } - writeQuickConnectResult(w, r, req, err) - } -} - -func writeQuickConnectResult(w http.ResponseWriter, r *http.Request, req quickconnect.Request, err error) { - switch { - case errors.Is(err, model.ErrNotFound): - http.Error(w, "Unknown code", http.StatusNotFound) - case errors.Is(err, quickconnect.ErrAlreadyAuthorized): - http.Error(w, "Code already used", http.StatusConflict) - case err != nil: - log.Error(r.Context(), "Quick Connect failed", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - default: - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(quickConnectDevice{AppName: req.Device.App, AppVersion: req.Device.AppVersion, DeviceName: req.Device.Name}) - } -} diff --git a/server/nativeapi/quickconnect_test.go b/server/nativeapi/quickconnect_test.go deleted file mode 100644 index f2f06f0f3..000000000 --- a/server/nativeapi/quickconnect_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package nativeapi - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "time" - - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/quickconnect" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Quick Connect endpoints", func() { - var ( - qc quickconnect.QuickConnect - pending quickconnect.Request - user = model.User{ID: "u1", UserName: "alice"} - ) - - BeforeEach(func() { - qc = quickconnect.New() - var err error - pending, err = qc.Initiate(quickconnect.Device{ID: "dev-1", Name: "Pixel 7", App: "Finamp", AppVersion: "1.0.0"}) - Expect(err).ToNot(HaveOccurred()) - }) - - asUser := func(r *http.Request) *http.Request { - return r.WithContext(request.WithUser(r.Context(), user)) - } - decode := func(w *httptest.ResponseRecorder) quickConnectDevice { - var res quickConnectDevice - Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed()) - return res - } - finamp := quickConnectDevice{AppName: "Finamp", AppVersion: "1.0.0", DeviceName: "Pixel 7"} - - Describe("GET /quickconnect", func() { - lookup := func(code string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - lookupQuickConnect(qc)(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+code, nil))) - return w - } - - It("describes the device waiting for the code", func() { - w := lookup(pending.Code) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(decode(w)).To(Equal(finamp)) - }) - - It("does not approve the code", func() { - lookup(pending.Code) - got, _ := qc.Status(pending.Secret) - Expect(got.Authorized()).To(BeFalse()) - }) - - It("returns 404 for an unknown code", func() { - Expect(lookup("000000").Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 409 for a code that is already approved", func() { - _, _ = qc.Authorize(pending.Code, "someone") - Expect(lookup(pending.Code).Code).To(Equal(http.StatusConflict)) - }) - }) - - Describe("POST /quickconnect/authorize", func() { - authorize := func(body string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - authorizeQuickConnect(qc)(w, asUser(httptest.NewRequest("POST", "/quickconnect/authorize", strings.NewReader(body)))) - return w - } - - It("approves the code for the signed-in user", func() { - w := authorize(`{"code":"` + pending.Code + `"}`) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(decode(w)).To(Equal(finamp)) - got, _ := qc.Status(pending.Secret) - Expect(got.UserID).To(Equal(user.ID)) - }) - - It("returns 404 for an unknown code", func() { - Expect(authorize(`{"code":"000000"}`).Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 409 for a code that is already approved", func() { - _, _ = qc.Authorize(pending.Code, "someone") - Expect(authorize(`{"code":"` + pending.Code + `"}`).Code).To(Equal(http.StatusConflict)) - }) - - It("returns 400 for a bad body", func() { - Expect(authorize(`nope`).Code).To(Equal(http.StatusBadRequest)) - }) - }) - - Describe("route registration", func() { - serve := func() int { - r := chi.NewRouter() - (&Router{quickConnect: qc}).addQuickConnectRoute(r) - w := httptest.NewRecorder() - r.ServeHTTP(w, asUser(httptest.NewRequest("GET", "/quickconnect?code="+pending.Code, nil))) - return w.Code - } - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Jellyfin.Enabled = true - conf.Server.Jellyfin.QuickConnect = true - }) - - It("mounts the routes when Jellyfin Quick Connect is on", func() { - Expect(serve()).To(Equal(http.StatusOK)) - }) - - It("rate-limits code attempts when a login limit is configured", func() { - conf.Server.AuthRequestLimit = 2 - conf.Server.AuthWindowLength = time.Minute - r := chi.NewRouter() - (&Router{quickConnect: qc}).addQuickConnectRoute(r) - attempt := func() int { - w := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/quickconnect?code=000000", nil) - req.RemoteAddr = "10.0.0.1:1234" - r.ServeHTTP(w, asUser(req)) - return w.Code - } - Expect(attempt()).To(Equal(http.StatusNotFound)) - Expect(attempt()).To(Equal(http.StatusNotFound)) - Expect(attempt()).To(Equal(http.StatusTooManyRequests)) - }) - - It("skips the routes when the Jellyfin API is off", func() { - conf.Server.Jellyfin.Enabled = false - Expect(serve()).To(Equal(http.StatusNotFound)) - }) - - It("skips the routes when Quick Connect is off", func() { - conf.Server.Jellyfin.QuickConnect = false - Expect(serve()).To(Equal(http.StatusNotFound)) - }) - }) -}) diff --git a/server/nativeapi/radios.go b/server/nativeapi/radios.go deleted file mode 100644 index 4bacbea7a..000000000 --- a/server/nativeapi/radios.go +++ /dev/null @@ -1,76 +0,0 @@ -package nativeapi - -import ( - "context" - "errors" - "io" - "net/http" - - "github.com/deluan/rest" - "github.com/go-chi/chi/v5" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" -) - -func (api *Router) addRadioRoute(r chi.Router) { - repo := api.ds.Radio() - r.Route("/radio", func(r chi.Router) { - r.Get("/", rest.GetAll(repo)) - r.Post("/", rest.Post(repo)) - r.Route("/{id}", func(r chi.Router) { - r.Use(server.URLParamsMiddleware) - r.Get("/", rest.Get(repo)) - r.Put("/", rest.Put(repo)) - r.Delete("/", rest.Delete(repo)) - r.Post("/image", api.uploadRadioImage()) - r.Delete("/image", api.deleteRadioImage()) - }) - }) -} - -func (api *Router) uploadRadioImage() http.HandlerFunc { - return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { - radioID := chi.URLParamFromCtx(ctx, "id") - radio, err := api.ds.Radio().Get(ctx, radioID) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - return model.ErrNotFound - } - return err - } - oldPath := radio.UploadedImagePath() - filename, err := api.imgUpload.SetImage(ctx, consts.EntityRadio, radio.ID, radio.Name, oldPath, reader, ext) - if err != nil { - return err - } - radio.UploadedImage = filename - if err := api.ds.Radio().Put(ctx, radio, "UploadedImage"); err != nil { - return err - } - api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID) - return nil - }) -} - -func (api *Router) deleteRadioImage() http.HandlerFunc { - return handleImageDelete(func(ctx context.Context) error { - radioID := chi.URLParamFromCtx(ctx, "id") - radio, err := api.ds.Radio().Get(ctx, radioID) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - return model.ErrNotFound - } - return err - } - if err := api.imgUpload.RemoveImage(ctx, radio.UploadedImagePath()); err != nil { - return err - } - radio.UploadedImage = "" - if err := api.ds.Radio().Put(ctx, radio, "UploadedImage"); err != nil { - return err - } - api.imgUpload.EnqueueArtwork(ctx, consts.EntityRadio, radio.ID) - return nil - }) -} diff --git a/server/nativeapi/translations.go b/server/nativeapi/translations.go index fc4651c4c..c425ae343 100644 --- a/server/nativeapi/translations.go +++ b/server/nativeapi/translations.go @@ -17,35 +17,39 @@ import ( ) type translation struct { - ID string `json:"id"` - Name string `json:"name"` - Data string `json:"data"` - TermCount int `json:"termCount"` + ID string `json:"id"` + Name string `json:"name"` + Data string `json:"data"` } -func newTranslationRepository() rest.Repository[translation] { +var ( + once sync.Once + translations map[string]translation +) + +func newTranslationRepository(ctx context.Context) rest.Repository { + if err := loadTranslations(ctx, resources.FS()); err != nil { + log.Error(ctx, "Error loading translation files", err) + } return &translationRepository{} } type translationRepository struct{} -func (r *translationRepository) Read(_ context.Context, id string) (*translation, error) { - translations, _ := loadTranslations() +func (r *translationRepository) Read(id string) (interface{}, error) { if t, ok := translations[id]; ok { - return &t, nil + return t, nil } return nil, rest.ErrNotFound } // Count simple implementation, does not support any `options` -func (r *translationRepository) Count(context.Context, ...rest.QueryOptions) (int64, error) { - _, count := loadTranslations() - return count, nil +func (r *translationRepository) Count(...rest.QueryOptions) (int64, error) { + return int64(len(translations)), nil } // ReadAll simple implementation, only returns IDs. Does not support any `options` -func (r *translationRepository) ReadAll(context.Context, ...rest.QueryOptions) ([]translation, error) { - translations, _ := loadTranslations() +func (r *translationRepository) ReadAll(...rest.QueryOptions) (interface{}, error) { var result []translation for _, t := range translations { t.Data = "" @@ -54,32 +58,41 @@ func (r *translationRepository) ReadAll(context.Context, ...rest.QueryOptions) ( return result, nil } -var loadTranslations = sync.OnceValues(func() (map[string]translation, int64) { - translations := make(map[string]translation) - fsys := resources.FS() - dir, err := fsys.Open(consts.I18nFolder) - if err != nil { - log.Error("Error opening translation folder", err) - return translations, 0 - } - files, err := dir.(fs.ReadDirFile).ReadDir(-1) - if err != nil { - log.Error("Error reading translation folder", err) - return translations, 0 - } - var languages []string - for _, f := range files { - t, err := loadTranslation(fsys, f.Name()) +func (r *translationRepository) EntityName() string { + return "translation" +} + +func (r *translationRepository) NewInstance() interface{} { + return &translation{} +} + +func loadTranslations(ctx context.Context, fsys fs.FS) (loadError error) { + once.Do(func() { + translations = make(map[string]translation) + dir, err := fsys.Open(consts.I18nFolder) if err != nil { - log.Error("Error loading translation file", "file", f.Name(), err) - continue + loadError = err + return } - translations[t.ID] = t - languages = append(languages, t.ID) - } - log.Info("Loaded translations", "languages", languages) - return translations, int64(len(translations)) -}) + files, err := dir.(fs.ReadDirFile).ReadDir(-1) + if err != nil { + loadError = err + return + } + var languages []string + for _, f := range files { + t, err := loadTranslation(fsys, f.Name()) + if err != nil { + log.Error(ctx, "Error loading translation file", "file", f.Name(), err) + continue + } + translations[t.ID] = t + languages = append(languages, t.ID) + } + log.Info(ctx, "Loading translations", "languages", languages) + }) + return +} func loadTranslation(fsys fs.FS, fileName string) (translation translation, err error) { // Get id and full path @@ -90,46 +103,27 @@ func loadTranslation(fsys fs.FS, fileName string) (translation translation, err // Load translation from json file file, err := fsys.Open(filePath) if err != nil { - return translation, err + return } data, err := io.ReadAll(file) if err != nil { - return translation, err + return } - var out map[string]any + var out map[string]interface{} if err = json.Unmarshal(data, &out); err != nil { - return translation, err + return } // Compress JSON buf := new(bytes.Buffer) if err = json.Compact(buf, data); err != nil { - return translation, err + return } translation.Data = buf.String() translation.Name = out["languageName"].(string) translation.ID = id - translation.TermCount = countTranslatedTerms(out) - return translation, nil + return } -// countTranslatedTerms counts non-empty leaf values, matching the UI's notion of a translated term -func countTranslatedTerms(obj map[string]any) int { - count := 0 - for _, v := range obj { - switch v := v.(type) { - case map[string]any: - count += countTranslatedTerms(v) - case string: - if v != "" { - count++ - } - default: - count++ - } - } - return count -} - -var _ rest.Repository[translation] = (*translationRepository)(nil) +var _ rest.Repository = (*translationRepository)(nil) diff --git a/server/nativeapi/translations_test.go b/server/nativeapi/translations_test.go index 1c641da77..c49c26c67 100644 --- a/server/nativeapi/translations_test.go +++ b/server/nativeapi/translations_test.go @@ -5,9 +5,7 @@ import ( "io" "io/fs" "os" - "path" "path/filepath" - "testing/fstest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/resources" @@ -23,10 +21,10 @@ var _ = Describe("Translations", func() { files, _ := dir.(fs.ReadDirFile).ReadDir(-1) for _, f := range files { name := filepath.Base(f.Name()) - filePath := path.Join(consts.I18nFolder, name) + filePath := filepath.Join(consts.I18nFolder, name) file, _ := fsys.Open(filePath) data, _ := io.ReadAll(file) - var out map[string]any + var out map[string]interface{} Expect(filepath.Ext(filePath)).To(Equal(".json"), filePath) Expect(json.Unmarshal(data, &out)).To(BeNil(), filePath) @@ -42,19 +40,8 @@ var _ = Describe("Translations", func() { Expect(err).To(BeNil()) Expect(tr.ID).To(Equal("en")) Expect(tr.Name).To(Equal("English")) - var out map[string]any + var out map[string]interface{} Expect(json.Unmarshal([]byte(tr.Data), &out)).To(BeNil()) }) - - It("counts only non-empty leaf terms", func() { - fsys := fstest.MapFS{ - "i18n/test.json": &fstest.MapFile{ - Data: []byte(`{"languageName":"Test","a":"x","b":"","nested":{"c":"y","d":""}}`), - }, - } - tr, err := loadTranslation(fsys, "test.json") - Expect(err).To(BeNil()) - Expect(tr.TermCount).To(Equal(3)) - }) }) }) diff --git a/server/nativeapi/user_password_token_refresh_test.go b/server/nativeapi/user_password_token_refresh_test.go deleted file mode 100644 index 81f28893f..000000000 --- a/server/nativeapi/user_password_token_refresh_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package nativeapi - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "path/filepath" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/server" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type noopPluginUnloader struct{} - -func (noopPluginUnloader) UnloadDisabledPlugins(context.Context) {} - -// Pins that the token-epoch handoff survives a real request through the real middleware chain. -var _ = Describe("PUT /user/{id}: token refresh on self password change", func() { - var ctx context.Context - var ds model.DataStore - var router http.Handler - - BeforeEach(func() { - ctx = GinkgoT().Context() - // db.Db() is a process-wide singleton that this DeferCleanup closes for the whole binary; keep this the only real-DB spec in this package. - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableUserEditing = true - conf.Server.EnableSharing = false - conf.Server.SessionTimeout = time.Hour - conf.Server.DbPath = filepath.Join(GinkgoT().TempDir(), "nativeapi-user-refresh.db") + "?_journal_mode=WAL" - DeferCleanup(db.Init(GinkgoT().Context())) - - ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - auth.Init(ds) - - userService := core.NewUser(ds, noopPluginUnloader{}) - nativeRouter := New(ds, nil, playlists.NewPlaylists(ds, nil), nil, tests.NewMockLibraryService(), userService, nil, nil, nil, nil, nil) - router = server.JWTVerifier(nativeRouter) - }) - - It("carries the bumped epoch in the refreshed token, not the epoch the token was minted with", func() { - usr := model.User{UserName: "selfchanger", Name: "Self Changer", NewPassword: "old-password"} - Expect(ds.User().Put(ctx, &usr)).To(Succeed()) - - token, err := auth.CreateToken(&usr) - Expect(err).ToNot(HaveOccurred()) - - body, _ := json.Marshal(map[string]any{ - "userName": usr.UserName, - "name": usr.Name, - "currentPassword": "old-password", - "password": "new-password", - }) - req := createAuthenticatedRequest(http.MethodPut, "/user/"+usr.ID, bytes.NewBuffer(body), token) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusOK), w.Body.String()) - - refreshed := w.Header().Get(consts.UIAuthorizationHeader) - Expect(refreshed).ToNot(BeEmpty()) - claims, err := auth.Validate(refreshed) - Expect(err).ToNot(HaveOccurred()) - - reloaded, err := ds.User().Get(ctx, usr.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(reloaded.TokenEpoch).To(Equal(1)) - Expect(claims.Epoch).To(Equal(reloaded.TokenEpoch)) - }) -}) diff --git a/server/public/encode_id.go b/server/public/encode_id.go new file mode 100644 index 000000000..77660c861 --- /dev/null +++ b/server/public/encode_id.go @@ -0,0 +1,71 @@ +package public + +import ( + "context" + "errors" + "net/http" + "net/url" + "path" + "strconv" + + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" +) + +func ImageURL(r *http.Request, artID model.ArtworkID, size int) string { + link := encodeArtworkID(artID) + uri := path.Join(consts.URLPathPublicImages, link) + params := url.Values{} + if size > 0 { + params.Add("size", strconv.Itoa(size)) + } + return server.AbsoluteURL(r, uri, params) +} + +func encodeArtworkID(artID model.ArtworkID) string { + token, _ := auth.CreatePublicToken(map[string]any{"id": artID.String()}) + return token +} + +func decodeArtworkID(tokenString string) (model.ArtworkID, error) { + token, err := auth.TokenAuth.Decode(tokenString) + if err != nil { + return model.ArtworkID{}, err + } + if token == nil { + return model.ArtworkID{}, errors.New("unauthorized") + } + err = jwt.Validate(token, jwt.WithRequiredClaim("id")) + if err != nil { + return model.ArtworkID{}, err + } + claims, err := token.AsMap(context.Background()) + if err != nil { + return model.ArtworkID{}, err + } + id, ok := claims["id"].(string) + if !ok { + return model.ArtworkID{}, errors.New("invalid id type") + } + artID, err := model.ParseArtworkID(id) + if err == nil { + return artID, nil + } + // Try to default to mediafile artworkId (if used with a mediafileShare token) + return model.ParseArtworkID("mf-" + id) +} + +func encodeMediafileShare(s model.Share, id string) string { + claims := map[string]any{"id": id} + if s.Format != "" { + claims["f"] = s.Format + } + if s.MaxBitRate != 0 { + claims["b"] = s.MaxBitRate + } + token, _ := auth.CreateExpiringPublicToken(s.ExpiresAt, claims) + return token +} diff --git a/server/public/encode_id_test.go b/server/public/encode_id_test.go new file mode 100644 index 000000000..efd252e4e --- /dev/null +++ b/server/public/encode_id_test.go @@ -0,0 +1,39 @@ +package public + +import ( + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("encodeArtworkID", func() { + Context("Public ID Encoding", func() { + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) + }) + It("returns a reversible string representation", func() { + id := model.NewArtworkID(model.KindArtistArtwork, "1234", nil) + encoded := encodeArtworkID(id) + decoded, err := decodeArtworkID(encoded) + Expect(err).ToNot(HaveOccurred()) + Expect(decoded).To(Equal(id)) + }) + It("fails to decode an invalid token", func() { + _, err := decodeArtworkID("xx-123") + Expect(err).To(MatchError("invalid JWT")) + }) + It("defaults to kind mediafile", func() { + encoded := encodeArtworkID(model.ArtworkID{}) + id, err := decodeArtworkID(encoded) + Expect(err).ToNot(HaveOccurred()) + Expect(id.Kind).To(Equal(model.KindMediaFileArtwork)) + }) + It("fails to decode a token without an id", func() { + token, _ := auth.CreatePublicToken(map[string]any{}) + _, err := decodeArtworkID(token) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/server/public/handle_downloads.go b/server/public/handle_downloads.go index e50d7609a..c6cf5a52f 100644 --- a/server/public/handle_downloads.go +++ b/server/public/handle_downloads.go @@ -1,38 +1,16 @@ package public import ( - "cmp" "net/http" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/str" ) -func (pub *Router) handleDownloads(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - id, err := req.Params(r).String(":id") - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) +func (p *Router) handleDownloads(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get(":id") + if id == "" { + http.Error(w, "invalid id", http.StatusBadRequest) return } - // Load the share before streaming: once ZipShare writes its first byte the - // status is locked at 200, so errors could no longer be reported. - s, err := pub.share.Load(ctx, id) - if err != nil { - checkShareError(ctx, w, err, id) - return - } - if !s.Downloadable { - checkShareError(ctx, w, model.ErrNotAuthorized, id) - return - } - - name := cmp.Or(s.Description, s.ID) - w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(name+".zip")) - w.Header().Set("Content-Type", "application/zip") - - err = pub.archiver.ZipShare(ctx, s, w) - checkShareError(ctx, w, err, id) + err := p.archiver.ZipShare(r.Context(), id, w) + checkShareError(r.Context(), w, err, id) } diff --git a/server/public/handle_downloads_test.go b/server/public/handle_downloads_test.go deleted file mode 100644 index d2118f47e..000000000 --- a/server/public/handle_downloads_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package public - -import ( - "context" - "errors" - "io" - "mime" - "net/http" - "net/http/httptest" - "time" - - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -type mockArchiver struct { - called bool - err error -} - -func (m *mockArchiver) ZipAlbum(context.Context, string, string, int, io.Writer) error { - return nil -} - -func (m *mockArchiver) ZipArtist(context.Context, string, string, int, io.Writer) error { - return nil -} - -func (m *mockArchiver) ZipPlaylist(context.Context, string, string, int, io.Writer) error { - return nil -} - -func (m *mockArchiver) ZipShare(_ context.Context, _ *model.Share, w io.Writer) error { - m.called = true - if m.err != nil { - return m.err - } - _, _ = w.Write([]byte("zip-contents")) - return nil -} - -var _ = Describe("handleDownloads", func() { - var ds *tests.MockDataStore - var shareRepo *tests.MockShareRepo - var archiver *mockArchiver - var pub *Router - - BeforeEach(func() { - ds = &tests.MockDataStore{} - shareRepo = &tests.MockShareRepo{} - ds.MockedShare = shareRepo - archiver = &mockArchiver{} - pub = &Router{ds: ds, archiver: archiver, share: core.NewShare(ds)} - }) - - shareIs := func(s *model.Share) { - shareRepo.ID = s.ID - shareRepo.Entity = s - } - - makeRequest := func(id string) *httptest.ResponseRecorder { - r := httptest.NewRequest("GET", "/public/d/"+id+"?%3Aid="+id, nil) - w := httptest.NewRecorder() - pub.handleDownloads(w, r) - return w - } - - It("sets a Content-Disposition filename from the share description", func() { - shareIs(&model.Share{ID: "abc123", Description: "My Mixtape", Downloadable: true}) - - w := makeRequest("abc123") - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="My Mixtape.zip"`)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/zip")) - Expect(archiver.called).To(BeTrue()) - Expect(w.Body.String()).To(Equal("zip-contents")) - }) - - It("falls back to the share ID when there is no description", func() { - shareIs(&model.Share{ID: "abc123", Downloadable: true}) - - w := makeRequest("abc123") - - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="abc123.zip"`)) - }) - - It("sanitizes characters that are unsafe in a filename", func() { - shareIs(&model.Share{ID: "abc123", Description: `AC/DC: Live, 1979`, Downloadable: true}) - - w := makeRequest("abc123") - - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="AC_DC_ Live_ 1979.zip"`)) - }) - - It("sanitizes the UTF-8 filename* as well", func() { - shareIs(&model.Share{ID: "abc123", Description: "Sigur Rós/Live", Downloadable: true}) - - w := makeRequest("abc123") - - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="Sigur Ros_Live.zip"; filename*=utf-8''Sigur%20R%C3%B3s_Live.zip`)) - }) - - It("does not let the share description inject a second filename parameter", func() { - shareIs(&model.Share{ID: "abc123", Description: `mix"; filename="evil.html`, Downloadable: true}) - - w := makeRequest("abc123") - - disposition := w.Header().Get("Content-Disposition") - Expect(disposition).ToNot(ContainSubstring(`filename="evil.html`)) - _, params, err := mime.ParseMediaType(disposition) - Expect(err).ToNot(HaveOccurred()) - Expect(params["filename"]).To(Equal(`mix_; filename=_evil.html.zip`)) - }) - - It("returns 403 without invoking the archiver when the share is not downloadable", func() { - shareIs(&model.Share{ID: "abc123", Description: "No Download", Downloadable: false}) - - w := makeRequest("abc123") - - Expect(w.Code).To(Equal(http.StatusForbidden)) - Expect(archiver.called).To(BeFalse()) - Expect(w.Header().Get("Content-Disposition")).To(BeEmpty()) - }) - - It("returns 404 when the share does not exist", func() { - shareIs(&model.Share{ID: "other", Downloadable: true}) - - w := makeRequest("missing") - - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(archiver.called).To(BeFalse()) - }) - - It("returns 410 when the share has expired", func() { - shareIs(&model.Share{ID: "abc123", Downloadable: true, ExpiresAt: new(time.Now().Add(-time.Hour))}) - - w := makeRequest("abc123") - - Expect(w.Code).To(Equal(http.StatusGone)) - Expect(archiver.called).To(BeFalse()) - }) - - It("returns 500 when the share lookup fails", func() { - shareRepo.Error = errors.New("db error") - - w := makeRequest("abc123") - - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - Expect(archiver.called).To(BeFalse()) - }) -}) diff --git a/server/public/handle_images.go b/server/public/handle_images.go index 6416190bd..7d73c8b95 100644 --- a/server/public/handle_images.go +++ b/server/public/handle_images.go @@ -8,15 +8,12 @@ import ( "time" "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/imghttp" - "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils" ) -func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { +func (p *Router) handleImages(w http.ResponseWriter, r *http.Request) { // If context is already canceled, discard request without further processing if r.Context().Err() != nil { return @@ -24,28 +21,20 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() - - p := req.Params(r) - id, _ := p.String(":id") + id := r.URL.Query().Get(":id") if id == "" { - log.Warn(r, "No id provided") http.Error(w, "invalid id", http.StatusBadRequest) return } artId, err := decodeArtworkID(id) if err != nil { - log.Error(r, "Error decoding artwork id", "id", id, err) - http.Error(w, "invalid request", http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } - // The token is the authorization, so the entity check must ask "is it still there", not - // "may this user see it" -- the latter would hide a shared private playlist. - ctx = request.WithUser(ctx, model.User{IsAdmin: true}) - size := p.IntOr("size", 0) - square := p.BoolOr("square", false) + size := utils.ParamInt(r, "size", 0) - img, err := pub.artwork.Get(ctx, artId, size, square) + imgReader, lastUpdate, err := p.artwork.Get(ctx, artId, size) switch { case errors.Is(err, context.Canceled): return @@ -63,32 +52,11 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { return } - defer img.Close() - if imghttp.WriteImageHeaders(w, r, img, artId.Hash) { - return - } - cnt, err := io.Copy(w, img) + defer imgReader.Close() + w.Header().Set("Cache-Control", "public, max-age=315360000") + w.Header().Set("Last-Modified", lastUpdate.Format(time.RFC1123)) + cnt, err := io.Copy(w, imgReader) if err != nil { log.Warn(ctx, "Error sending image", "count", cnt, err) } } - -func decodeArtworkID(tokenString string) (model.ArtworkID, error) { - token, err := auth.PublicTokenAuth.Decode(tokenString) - if err != nil { - return model.ArtworkID{}, err - } - if token == nil { - return model.ArtworkID{}, errors.New("unauthorized") - } - c := auth.ClaimsFromToken(token) - if c.ID == "" { - return model.ArtworkID{}, errors.New("required claim \"id\" not found") - } - artID, err := model.ParseArtworkID(c.ID) - if err == nil { - return artID, nil - } - // Try to default to mediafile artworkId (if used with a mediafileShare token) - return model.ParseArtworkID("mf-" + c.ID) -} diff --git a/server/public/handle_images_test.go b/server/public/handle_images_test.go deleted file mode 100644 index d77b6f427..000000000 --- a/server/public/handle_images_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package public - -import ( - "bytes" - "context" - "io" - "net/http" - "net/http/httptest" - "net/url" - - "github.com/go-chi/jwtauth/v5" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("decodeArtworkID", func() { - BeforeEach(func() { - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) - }) - - It("fails to decode an invalid token", func() { - _, err := decodeArtworkID("xx-123") - Expect(err).To(HaveOccurred()) - }) - - It("fails to decode a token without an id", func() { - token, _ := auth.CreatePublicToken(auth.Claims{}) - _, err := decodeArtworkID(token) - Expect(err).To(HaveOccurred()) - }) -}) - -type fakeArtwork struct { - artwork.Artwork - img *artwork.Image - err error -} - -func (f *fakeArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { - return f.img, f.err -} - -var _ = Describe("handleImages", func() { - var w *httptest.ResponseRecorder - - newImageRequest := func(claimID string) *http.Request { - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) - token, _ := auth.CreatePublicToken(auth.Claims{ID: claimID}) - return httptest.NewRequest("GET", "/img?:id="+url.QueryEscape(token), nil) - } - - // The handler re-checks that the entity behind the token still exists, so "1" must be live. - var ds *tests.MockDataStore - - BeforeEach(func() { - w = httptest.NewRecorder() - albumRepo := tests.CreateMockAlbumRepo() - albumRepo.SetData(model.Albums{{ID: "1"}}) - ds = &tests.MockDataStore{MockedAlbum: albumRepo} - }) - - It("returns 404 when the artwork is unavailable", func() { - pub := &Router{ds: ds, artwork: &fakeArtwork{err: artwork.ErrUnavailable}} - pub.handleImages(w, newImageRequest("al-1")) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 404 when the artwork is not found", func() { - pub := &Router{ds: ds, artwork: &fakeArtwork{err: model.ErrNotFound}} - pub.handleImages(w, newImageRequest("al-1")) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("serves the image immutable when the token asserts the current hash", func() { - const hash = "0123456789abcdef" - img := &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader([]byte("IMG"))), Hash: hash} - pub := &Router{ds: ds, artwork: &fakeArtwork{img: img}} - pub.handleImages(w, newImageRequest("al-1_"+hash)) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) - Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) - }) - -}) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 367bff501..aa09cfa17 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -4,63 +4,37 @@ import ( "context" "errors" "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" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/ui" - . "github.com/navidrome/navidrome/utils/gg" - "github.com/navidrome/navidrome/utils/req" ) -func (pub *Router) handleShares(w http.ResponseWriter, r *http.Request) { - id, err := req.Params(r).String(":id") - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) +func (p *Router) handleShares(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get(":id") + if id == "" { + http.Error(w, "invalid id", http.StatusBadRequest) return } // If requested file is a UI asset, just serve it - _, err = ui.BuildAssets().Open(id) + _, err := ui.BuildAssets().Open(id) if err == nil { - pub.assetsHandler.ServeHTTP(w, r) + p.assetsHandler.ServeHTTP(w, r) return } // If it is not, consider it a share ID - s, err := pub.share.Load(r.Context(), id) + s, err := p.share.Load(r.Context(), id) if err != nil { checkShareError(r.Context(), w, err, id) return } - s = pub.mapShareInfo(r, *s) - server.IndexWithShare(pub.ds, ui.BuildAssets(), s)(w, r) -} - -func (pub *Router) handleM3U(w http.ResponseWriter, r *http.Request) { - id, err := req.Params(r).String(":id") - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - // If it is not, consider it a share ID - s, err := pub.share.Load(r.Context(), id) - if err != nil { - checkShareError(r.Context(), w, err, id) - return - } - - s = pub.mapShareToM3U(r, *s) - w.Header().Set("Content-Type", "audio/x-mpegurl") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(s.ToM3U8())) //nolint:gosec + s = p.mapShareInfo(r, *s) + server.IndexWithShare(p.ds, ui.BuildAssets(), s)(w, r) } func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id string) { @@ -80,46 +54,11 @@ 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.Context(), s.ID) - s.ImageURL = publicurl.ImageURL(r.Context(), s.CoverArtID(), conf.Server.UICoverArtSize) +func (p *Router) mapShareInfo(r *http.Request, s model.Share) *model.Share { + s.URL = ShareURL(r, s.ID) + s.ImageURL = ImageURL(r, s.CoverArtID(), consts.UICoverArtSize) for i := range s.Tracks { s.Tracks[i].ID = encodeMediafileShare(s, s.Tracks[i].ID) } return &s } - -func (pub *Router) mapShareToM3U(r *http.Request, s model.Share) *model.Share { - for i := range s.Tracks { - id := encodeMediafileShare(s, s.Tracks[i].ID) - s.Tracks[i].Path = publicurl.PublicURL(r.Context(), path.Join(consts.URLPathPublic, "s", id), nil) - } - return &s -} - -// encodeMediafileShare builds the signed token embedded in a public share link -// for a single track. -// -// NOTE ON JWT USAGE: This is deliberately NOT part of Navidrome's authentication. -// The token is a signed, opaque capability that identifies one shared track -// (plus its transcode format/bitrate and the parent share id). We use a JWT here -// (reusing the library we already have) because it is a simple way to get three -// properties for a public link: the embedded ids can't be enumerated by guessing, -// the signature -// makes the claims tamper-evident, and the self-contained exp lets us reject -// stale links without a DB lookup. It carries no user identity (no subject, no -// admin flag) and grants access to nothing beyond the share it belongs to; the -// stream handler still verifies the share exists, is unexpired, and that the -// track is actually a member of it. An attacker who can forge these tokens -// necessarily already holds the public-link signing secret, a full-server -// compromise that is out of scope for the share boundary specifically. -func encodeMediafileShare(s model.Share, id string) string { - claims := auth.Claims{ - 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_shares_test.go b/server/public/handle_shares_test.go deleted file mode 100644 index 4de2a75c1..000000000 --- a/server/public/handle_shares_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package public - -import ( - "net/http" - "net/http/httptest" - - "github.com/go-chi/jwtauth/v5" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("handleM3U", func() { - var ds *tests.MockDataStore - var shareRepo *tests.MockShareRepo - var pub *Router - - BeforeEach(func() { - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) - ds = &tests.MockDataStore{} - shareRepo = &tests.MockShareRepo{} - ds.MockedShare = shareRepo - pub = &Router{ds: ds, share: core.NewShare(ds)} - }) - - makeRequest := func(id string) *httptest.ResponseRecorder { - r := httptest.NewRequest("GET", "/public/"+id+"/m3u?%3Aid="+id, nil) - w := httptest.NewRecorder() - pub.handleM3U(w, r) - return w - } - - It("sets the M3U content type", func() { - share := &model.Share{ID: "abc123", Tracks: model.MediaFiles{{ID: "t1", Title: "Track 1"}}} - shareRepo.ID = share.ID - shareRepo.Entity = share - - w := makeRequest("abc123") - - Expect(w.Code).To(Equal(http.StatusOK)) - // Result() has the headers sent at WriteHeader time, unlike w.Header() - Expect(w.Result().Header.Get("Content-Type")).To(Equal("audio/x-mpegurl")) - Expect(w.Body.String()).To(HavePrefix("#EXTM3U")) - }) - - It("returns 404 when the share does not exist", func() { - shareRepo.ID = "other" - shareRepo.Entity = &model.Share{ID: "other"} - - Expect(makeRequest("missing").Code).To(Equal(http.StatusNotFound)) - }) -}) diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 46c7ca210..2a1410333 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -1,24 +1,21 @@ package public import ( + "context" "errors" + "io" "net/http" - "slices" "strconv" - "time" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/navidrome/navidrome/core/auth" - streampkg "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" + "github.com/navidrome/navidrome/utils" ) -func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { +func (p *Router) handleStream(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - p := req.Params(r) - tokenId, _ := p.String(":id") + tokenId := r.URL.Query().Get(":id") info, err := decodeStreamInfo(tokenId) if err != nil { log.Error(ctx, "Error parsing shared stream info", err) @@ -26,56 +23,15 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - share, err := pub.ds.Share().Get(ctx, info.shareID) + stream, err := p.streamer.NewStream(ctx, info.id, info.format, info.bitrate) 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 - } - shareOwner, err := pub.ds.User().Get(ctx, share.UserID) - if err != nil { - log.Error(ctx, "Error retrieving share owner for shared stream", "share", info.shareID, "owner", share.UserID, err) - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - - mf, err := pub.ds.MediaFile().Get(ctx, info.id) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - http.Error(w, "not found", http.StatusNotFound) - } else { - log.Error(ctx, "Error retrieving media file for shared stream", "id", info.id, err) - http.Error(w, "internal error", http.StatusInternalServerError) - } - return - } - - // 404 rather than 403 so the response doesn't reveal whether the id exists. - // The track must belong to the share AND be within the owner's libraries. - if !shareContainsTrack(share, mf.ID) || !shareOwner.HasLibraryAccess(mf.LibraryID) { - http.Error(w, "not found", http.StatusNotFound) - return - } - - streamReq := pub.decider.ResolveRequest(ctx, mf, info.format, info.bitrate, 0) - stream, err := pub.streamer.NewStream(ctx, mf, streamReq) - if err != nil { - if errors.Is(err, streampkg.ErrTooManyTranscodes) { - w.Header().Set("Retry-After", strconv.Itoa(streampkg.RetryAfterSeconds)) - http.Error(w, "too many concurrent transcodes, please retry shortly", http.StatusTooManyRequests) - return - } log.Error(ctx, "Error starting shared stream", err) http.Error(w, "invalid request", http.StatusInternalServerError) - return } // Make sure the stream will be closed at the end, to avoid leakage defer func() { - if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { + if err := stream.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug { log.Error("Error closing shared stream", "id", info.id, "file", stream.Name(), err) } }() @@ -83,9 +39,34 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32)) - n, err := stream.Serve(ctx, w, r) - if err != nil || n == 0 { - http.Error(w, "internal error", http.StatusInternalServerError) + if stream.Seekable() { + http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream) + } else { + // If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length + w.Header().Set("Accept-Ranges", "none") + w.Header().Set("Content-Type", stream.ContentType()) + + estimateContentLength := utils.ParamBool(r, "estimateContentLength", false) + + // if Client requests the estimated content-length, send it + if estimateContentLength { + length := strconv.Itoa(stream.EstimatedContentLength()) + log.Trace(ctx, "Estimated content-length", "contentLength", length) + w.Header().Set("Content-Length", length) + } + + if r.Method == http.MethodHead { + go func() { _, _ = io.Copy(io.Discard, stream) }() + } else { + c, err := io.Copy(w, stream) + if log.CurrentLevel() >= log.LevelDebug { + if err != nil { + log.Error(ctx, "Error sending shared transcoded file", "id", info.id, err) + } else { + log.Trace(ctx, "Success sending shared transcode file", "id", info.id, "size", c) + } + } + } } } @@ -93,33 +74,31 @@ type shareTrackInfo struct { id string format string bitrate int - shareID string } -func shareContainsTrack(share *model.Share, mediaFileID string) bool { - return slices.ContainsFunc(share.Tracks, func(mf model.MediaFile) bool { - return mf.ID == mediaFileID - }) -} - -// decodeStreamInfo decodes the signed share-link token. This is a scoped -// public-share capability, not an auth credential; see encodeMediafileShare for -// why a JWT is used here. func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { - c, err := auth.ValidatePublic(tokenString) + token, err := auth.TokenAuth.Decode(tokenString) if err != nil { return shareTrackInfo{}, err } - if c.ID == "" { - return shareTrackInfo{}, errors.New("required claim \"id\" not found") + if token == nil { + return shareTrackInfo{}, errors.New("unauthorized") } - if c.ShareID == "" { - return shareTrackInfo{}, errors.New("required claim \"sid\" not found") + err = jwt.Validate(token, jwt.WithRequiredClaim("id")) + if err != nil { + return shareTrackInfo{}, err } - return shareTrackInfo{ - id: c.ID, - format: c.Format, - bitrate: c.BitRate, - shareID: c.ShareID, - }, nil + claims, err := token.AsMap(context.Background()) + if err != nil { + return shareTrackInfo{}, err + } + id, ok := claims["id"].(string) + if !ok { + return shareTrackInfo{}, errors.New("invalid id type") + } + resp := shareTrackInfo{} + resp.id = id + resp.format, _ = claims["f"].(string) + resp.bitrate, _ = claims["b"].(int) + return resp, nil } diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go deleted file mode 100644 index 965bc7e05..000000000 --- a/server/public/handle_streams_test.go +++ /dev/null @@ -1,259 +0,0 @@ -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/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.PublicTokenAuth = 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("rejects a token without a shareID claim", func() { - claims := auth.Claims{ID: "mf-123", Format: "opus"} - token, _ := auth.CreatePublicToken(claims) - _, err := decodeStreamInfo(token) - Expect(err).To(HaveOccurred()) - }) -}) - -var _ = Describe("encodeMediafileShare", func() { - BeforeEach(func() { - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) - }) - - It("includes the share ID in the token", func() { - exp := new(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 ctx context.Context - var ds *tests.MockDataStore - var shareRepo *tests.MockShareRepo - var streamer *mockStreamer - var pub *Router - - BeforeEach(func() { - ctx = GinkgoT().Context() - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) - ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} - shareRepo = &tests.MockShareRepo{} - ds.MockedShare = shareRepo - streamer = &mockStreamer{} - pub = &Router{ds: ds, streamer: streamer, decider: stream.NewTranscodeDecider(ds, tests.NewMockFFmpeg(""))} - }) - - 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 - } - - shareOwnedBy := func(owner model.User, mf model.MediaFile) { - shareRepo.ID = "share123" - shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{mf}} - userRepo := tests.CreateMockUserRepo() - Expect(userRepo.Put(ctx, &owner)).To(Succeed()) - ds.MockedUser = userRepo - mfRepo := tests.CreateMockMediaFileRepo() - mfRepo.SetData(model.MediaFiles{mf}) - ds.MockedMediaFile = mfRepo - } - - It("passes all validation and reaches the streamer for a valid token", func() { - shareOwnedBy( - model.User{ID: "owner1", UserName: "owner1", IsAdmin: true}, - model.MediaFile{ID: "mf-123", Title: "Test Song"}, - ) - - 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()) - }) - - It("resolves the full stream request like the Subsonic endpoint, so transcodes share the cache", func() { - mf := model.MediaFile{ID: "mf-123", Suffix: "flac", BitRate: 1500, SampleRate: 44100, BitDepth: new(24), Channels: 2} - shareOwnedBy(model.User{ID: "owner1", UserName: "owner1", IsAdmin: true}, mf) - - claims := auth.Claims{ID: "mf-123", Format: "opus", BitRate: 128, ShareID: "share123"} - token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) - makeRequest(token) - - Expect(streamer.req).To(Equal(stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2})) - }) - - It("returns 404 when the track is outside the share owner's libraries", func() { - shareOwnedBy( - model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}}, - model.MediaFile{ID: "mf-restricted", Title: "Other Lib Track", LibraryID: 2}, - ) - - claims := auth.Claims{ID: "mf-restricted", ShareID: "share123"} - token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) - w := makeRequest(token) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(streamer.called).To(BeFalse()) - }) - - It("returns 404 when the track is not a member of the share", func() { - owner := model.User{ID: "owner1", UserName: "owner1", IsAdmin: true} - userRepo := tests.CreateMockUserRepo() - Expect(userRepo.Put(ctx, &owner)).To(Succeed()) - ds.MockedUser = userRepo - mfRepo := tests.CreateMockMediaFileRepo() - mfRepo.SetData(model.MediaFiles{{ID: "mf-shared"}, {ID: "mf-other"}}) - ds.MockedMediaFile = mfRepo - shareRepo.ID = "share123" - shareRepo.Entity = &model.Share{ID: "share123", UserID: owner.ID, Tracks: model.MediaFiles{{ID: "mf-shared"}}} - - claims := auth.Claims{ID: "mf-other", ShareID: "share123"} - token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) - w := makeRequest(token) - - Expect(w.Code).To(Equal(http.StatusNotFound)) - Expect(streamer.called).To(BeFalse()) - }) - - It("streams a track inside the share owner's libraries", func() { - shareOwnedBy( - model.User{ID: "owner1", UserName: "owner1", Libraries: model.Libraries{{ID: 1}}}, - model.MediaFile{ID: "mf-ok", Title: "OK", LibraryID: 1}, - ) - - claims := auth.Claims{ID: "mf-ok", Format: "mp3", ShareID: "share123"} - token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) - makeRequest(token) - - Expect(streamer.called).To(BeTrue()) - }) - - 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" - shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: new(time.Now().Add(-time.Hour))} - - 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("returns 400 for tokens without a shareID", func() { - claims := auth.Claims{ID: "mf-123"} - token, _ := auth.CreatePublicToken(claims) - w := makeRequest(token) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - Expect(streamer.called).To(BeFalse()) - }) - - 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/public/public.go b/server/public/public.go index 8239ef927..5ead6b320 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -1,17 +1,16 @@ package public import ( - "context" "net/http" "path" "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/publicurl" - "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/ui" @@ -20,16 +19,15 @@ import ( type Router struct { http.Handler artwork artwork.Artwork - streamer stream.MediaStreamer - decider stream.TranscodeDecider + streamer core.MediaStreamer archiver core.Archiver share core.Share assetsHandler http.Handler ds model.DataStore } -func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, decider stream.TranscodeDecider, share core.Share, archiver core.Archiver) *Router { - p := &Router{ds: ds, artwork: artwork, streamer: streamer, decider: decider, share: share, archiver: archiver} +func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, share core.Share, archiver core.Archiver) *Router { + p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share, archiver: archiver} shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic) p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets()))) p.Handler = p.routes() @@ -37,31 +35,35 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStrea return p } -func (pub *Router) routes() http.Handler { +func (p *Router) routes() http.Handler { r := chi.NewRouter() r.Group(func(r chi.Router) { r.Use(server.URLParamsMiddleware) r.Group(func(r chi.Router) { - r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, - conf.Server.DevArtworkThrottleBacklogTimeout)) - r.HandleFunc("/img/{id}", pub.handleImages) + if conf.Server.DevArtworkMaxRequests > 0 { + log.Debug("Throttling public images endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests, + "backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout", + conf.Server.DevArtworkThrottleBacklogTimeout) + r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, + conf.Server.DevArtworkThrottleBacklogTimeout)) + } + r.HandleFunc("/img/{id}", p.handleImages) }) if conf.Server.EnableSharing { - r.HandleFunc("/s/{id}", pub.handleStream) + r.HandleFunc("/s/{id}", p.handleStream) if conf.Server.EnableDownloads { - r.HandleFunc("/d/{id}", pub.handleDownloads) + r.HandleFunc("/d/{id}", p.handleDownloads) } - r.HandleFunc("/{id}/m3u", pub.handleM3U) - r.HandleFunc("/{id}", pub.handleShares) - r.HandleFunc("/", pub.handleShares) - r.Handle("/*", pub.assetsHandler) + r.HandleFunc("/{id}", p.handleShares) + r.HandleFunc("/", p.handleShares) + r.Handle("/*", p.assetsHandler) } }) return r } -func ShareURL(ctx context.Context, id string) string { +func ShareURL(r *http.Request, id string) string { uri := path.Join(consts.URLPathPublic, id) - return publicurl.PublicURL(ctx, uri, nil) + return server.AbsoluteURL(r, uri, nil) } diff --git a/server/serve_index.go b/server/serve_index.go index 167197403..cb4271b4e 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -6,19 +6,16 @@ import ( "io" "io/fs" "net/http" - "os" "path" "strings" "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/mime" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/quickconnect" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/slice" - "github.com/navidrome/navidrome/utils/str" ) func Index(ds model.DataStore, fs fs.FS) http.HandlerFunc { @@ -32,7 +29,7 @@ func IndexWithShare(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.H // Injects the config in the `index.html` template func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - c, err := ds.User().CountAll(r.Context()) + c, err := ds.User(r.Context()).CountAll() firstTime := c == 0 && err == nil t, err := getIndexTemplate(r, fs) @@ -40,13 +37,13 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl http.NotFound(w, r) return } - appConfig := map[string]any{ + appConfig := map[string]interface{}{ "version": consts.Version, "firstTime": firstTime, "variousArtistsId": consts.VariousArtistsID, - "baseURL": str.SanitizeText(strings.TrimSuffix(conf.Server.BasePath, "/")), - "loginBackgroundURL": str.SanitizeText(conf.Server.UILoginBackgroundURL), - "welcomeMessage": str.SanitizeHTML(conf.Server.UIWelcomeMessage), + "baseURL": utils.SanitizeText(strings.TrimSuffix(conf.Server.BasePath, "/")), + "loginBackgroundURL": utils.SanitizeText(conf.Server.UILoginBackgroundURL), + "welcomeMessage": utils.SanitizeText(conf.Server.UIWelcomeMessage), "maxSidebarPlaylists": conf.Server.MaxSidebarPlaylists, "enableTranscodingConfig": conf.Server.EnableTranscodingConfig, "enableDownloads": conf.Server.EnableDownloads, @@ -55,35 +52,21 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "defaultTheme": conf.Server.DefaultTheme, "defaultLanguage": conf.Server.DefaultLanguage, "defaultUIVolume": conf.Server.DefaultUIVolume, - "uiSearchDebounceMs": conf.Server.UISearchDebounceMs, - "uiCoverArtSize": conf.Server.UICoverArtSize, "enableCoverAnimation": conf.Server.EnableCoverAnimation, - "pidAlbum": conf.Server.PID.Album, - "pidTrack": conf.Server.PID.Track, - "enableNowPlaying": conf.Server.EnableNowPlaying, - "playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(), "gaTrackingId": conf.Server.GATrackingID, - "losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")), + "losslessFormats": strings.ToUpper(strings.Join(consts.LosslessFormats, ",")), "devActivityPanel": conf.Server.DevActivityPanel, "enableUserEditing": conf.Server.EnableUserEditing, - "enableArtworkUpload": conf.Server.EnableArtworkUpload, "enableSharing": conf.Server.EnableSharing, - "shareURL": conf.Server.ShareURL, "defaultDownloadableShare": conf.Server.DefaultDownloadableShare, "devSidebarPlaylists": conf.Server.DevSidebarPlaylists, "lastFMEnabled": conf.Server.LastFM.Enabled, + "lastFMApiKey": conf.Server.LastFM.ApiKey, "devShowArtistPage": conf.Server.DevShowArtistPage, - "devUIShowConfig": conf.Server.DevUIShowConfig, - "devNewEventStream": conf.Server.DevNewEventStream, "listenBrainzEnabled": conf.Server.ListenBrainz.Enabled, "enableExternalServices": conf.Server.EnableExternalServices, "enableReplayGain": conf.Server.EnableReplayGain, - "enableQuickConnect": quickconnect.Enabled(), "defaultDownsamplingFormat": conf.Server.DefaultDownsamplingFormat, - "separator": string(os.PathSeparator), - "enableInspect": conf.Server.Inspect.Enabled, - "pluginsEnabled": conf.Server.Plugins.Enabled, - "extAuthLogoutURL": conf.Server.ExtAuth.LogoutURL, } if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL) @@ -104,14 +87,13 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl if version != "dev" { version = "v" + version } - data := map[string]any{ + data := map[string]interface{}{ "AppConfig": string(appConfigJson), "Version": version, } addShareData(r, data, shareInfo) w.Header().Set("Content-Type", "text/html") - w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") err = t.Execute(w, data) if err != nil { log.Error(r, "Could not execute `index.html` template", err) @@ -155,7 +137,7 @@ type shareTrack struct { Duration float32 `json:"duration,omitempty"` } -func addShareData(r *http.Request, data map[string]any, shareInfo *model.Share) { +func addShareData(r *http.Request, data map[string]interface{}, shareInfo *model.Share) { ctx := r.Context() if shareInfo == nil || shareInfo.ID == "" { return diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 277513768..01f36a79e 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -1,7 +1,6 @@ package server import ( - "context" "encoding/json" "fmt" "net/http" @@ -14,7 +13,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/conf/mime" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -40,7 +38,7 @@ var _ = Describe("serveIndex", func() { Expect(w.Code).To(Equal(200)) config := extractAppConfig(w.Body.String()) - Expect(config).To(BeAssignableToTypeOf(map[string]any{})) + Expect(config).To(BeAssignableToTypeOf(map[string]interface{}{})) }) It("sets firstTime = true when User table is empty", func() { @@ -54,6 +52,17 @@ var _ = Describe("serveIndex", func() { Expect(config).To(HaveKeyWithValue("firstTime", true)) }) + It("includes the VariousArtistsID", func() { + mockUser.empty = true + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("variousArtistsId", consts.VariousArtistsID)) + }) + It("sets firstTime = false when User table is not empty", func() { mockUser.empty = false r := httptest.NewRequest("GET", "/index.html", nil) @@ -65,84 +74,276 @@ var _ = Describe("serveIndex", func() { Expect(config).To(HaveKeyWithValue("firstTime", false)) }) - DescribeTable("sets configuration values", - func(configSetter func(), configKey string, expectedValue any) { - configSetter() - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() - - serveIndex(ds, fs, nil)(w, r) - - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue(configKey, expectedValue)) - }, - Entry("baseURL", func() { conf.Server.BasePath = "base_url_test" }, "baseURL", "base_url_test"), - Entry("welcomeMessage", func() { conf.Server.UIWelcomeMessage = "Hello" }, "welcomeMessage", "Hello"), - Entry("maxSidebarPlaylists", func() { conf.Server.MaxSidebarPlaylists = 42 }, "maxSidebarPlaylists", float64(42)), - Entry("enableTranscodingConfig", func() { conf.Server.EnableTranscodingConfig = true }, "enableTranscodingConfig", true), - Entry("enableDownloads", func() { conf.Server.EnableDownloads = true }, "enableDownloads", true), - Entry("enableFavourites", func() { conf.Server.EnableFavourites = true }, "enableFavourites", true), - Entry("enableStarRating", func() { conf.Server.EnableStarRating = true }, "enableStarRating", true), - Entry("defaultTheme", func() { conf.Server.DefaultTheme = "Light" }, "defaultTheme", "Light"), - Entry("defaultLanguage", func() { conf.Server.DefaultLanguage = "pt" }, "defaultLanguage", "pt"), - Entry("defaultUIVolume", func() { conf.Server.DefaultUIVolume = 45 }, "defaultUIVolume", float64(45)), - Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)), - Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)), - Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true), - Entry("pidAlbum", func() { conf.Server.PID.Album = "folder" }, "pidAlbum", "folder"), - Entry("pidTrack", func() { conf.Server.PID.Track = "title" }, "pidTrack", "title"), - Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true), - Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"), - Entry("defaultDownloadableShare", func() { conf.Server.DefaultDownloadableShare = true }, "defaultDownloadableShare", true), - Entry("devSidebarPlaylists", func() { conf.Server.DevSidebarPlaylists = true }, "devSidebarPlaylists", true), - Entry("lastFMEnabled", func() { conf.Server.LastFM.Enabled = true }, "lastFMEnabled", true), - Entry("devShowArtistPage", func() { conf.Server.DevShowArtistPage = true }, "devShowArtistPage", true), - Entry("devUIShowConfig", func() { conf.Server.DevUIShowConfig = true }, "devUIShowConfig", true), - Entry("listenBrainzEnabled", func() { conf.Server.ListenBrainz.Enabled = true }, "listenBrainzEnabled", true), - Entry("enableReplayGain", func() { conf.Server.EnableReplayGain = true }, "enableReplayGain", true), - Entry("enableQuickConnect", func() { conf.Server.Jellyfin.Enabled = true; conf.Server.Jellyfin.QuickConnect = true }, "enableQuickConnect", true), - Entry("enableQuickConnect without the Jellyfin API", func() { conf.Server.Jellyfin.Enabled = false; conf.Server.Jellyfin.QuickConnect = true }, "enableQuickConnect", false), - Entry("enableExternalServices", func() { conf.Server.EnableExternalServices = true }, "enableExternalServices", true), - Entry("devActivityPanel", func() { conf.Server.DevActivityPanel = true }, "devActivityPanel", true), - Entry("shareURL", func() { conf.Server.ShareURL = "https://share.example.com" }, "shareURL", "https://share.example.com"), - Entry("enableInspect", func() { conf.Server.Inspect.Enabled = true }, "enableInspect", true), - Entry("defaultDownsamplingFormat", func() { conf.Server.DefaultDownsamplingFormat = "mp3" }, "defaultDownsamplingFormat", "mp3"), - Entry("enableUserEditing", func() { conf.Server.EnableUserEditing = false }, "enableUserEditing", false), - 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>` + It("sets baseURL", func() { + conf.Server.BasePath = "base_url_test" 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(`<img src="x"><b>Hello</b>`)) + Expect(config).To(HaveKeyWithValue("baseURL", "base_url_test")) }) - DescribeTable("sets other UI configuration values", - func(configKey string, expectedValueFunc func() any) { - r := httptest.NewRequest("GET", "/index.html", nil) - w := httptest.NewRecorder() + It("sets the welcomeMessage", func() { + conf.Server.UIWelcomeMessage = "Hello" + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() - serveIndex(ds, fs, nil)(w, r) + serveIndex(ds, fs, nil)(w, r) - config := extractAppConfig(w.Body.String()) - Expect(config).To(HaveKeyWithValue(configKey, expectedValueFunc())) - }, - Entry("version", "version", func() any { return consts.Version }), - Entry("variousArtistsId", "variousArtistsId", func() any { return consts.VariousArtistsID }), - Entry("losslessFormats", "losslessFormats", func() any { - return strings.ToUpper(strings.Join(mime.LosslessFormats, ",")) - }), - Entry("separator", "separator", func() any { return string(os.PathSeparator) }), - ) + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("welcomeMessage", "Hello")) + }) + + It("sets the maxSidebarPlaylists", func() { + conf.Server.MaxSidebarPlaylists = 42 + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("maxSidebarPlaylists", float64(42))) + }) + + It("sets the enableTranscodingConfig", func() { + conf.Server.EnableTranscodingConfig = true + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("enableTranscodingConfig", true)) + }) + + It("sets the enableDownloads", func() { + conf.Server.EnableDownloads = true + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("enableDownloads", true)) + }) + + It("sets the enableLoved", func() { + conf.Server.EnableFavourites = true + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("enableFavourites", true)) + }) + + It("sets the enableStarRating", func() { + conf.Server.EnableStarRating = true + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("enableStarRating", true)) + }) + + It("sets the defaultTheme", func() { + conf.Server.DefaultTheme = "Light" + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("defaultTheme", "Light")) + }) + + It("sets the defaultLanguage", func() { + conf.Server.DefaultLanguage = "pt" + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("defaultLanguage", "pt")) + }) + + It("sets the defaultUIVolume", func() { + conf.Server.DefaultUIVolume = 45 + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("defaultUIVolume", float64(45))) + }) + + It("sets the enableCoverAnimation", func() { + conf.Server.EnableCoverAnimation = true + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("enableCoverAnimation", true)) + }) + + It("sets the gaTrackingId", func() { + conf.Server.GATrackingID = "UA-12345" + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("gaTrackingId", "UA-12345")) + }) + + It("sets the version", func() { + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("version", consts.Version)) + }) + + It("sets the losslessFormats", func() { + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + expected := strings.ToUpper(strings.Join(consts.LosslessFormats, ",")) + Expect(config).To(HaveKeyWithValue("losslessFormats", expected)) + }) + + It("sets the enableUserEditing", func() { + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("enableUserEditing", true)) + }) + + It("sets the enableSharing", func() { + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("enableSharing", false)) + }) + + It("sets the defaultDownloadableShare", func() { + conf.Server.DefaultDownloadableShare = true + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("defaultDownloadableShare", true)) + }) + + It("sets the defaultDownsamplingFormat", func() { + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("defaultDownsamplingFormat", conf.Server.DefaultDownsamplingFormat)) + }) + + It("sets the devSidebarPlaylists", func() { + conf.Server.DevSidebarPlaylists = true + + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("devSidebarPlaylists", true)) + }) + + It("sets the lastFMEnabled", func() { + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("lastFMEnabled", true)) + }) + + It("sets the lastFMApiKey", func() { + conf.Server.LastFM.ApiKey = "APIKEY-123" + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("lastFMApiKey", "APIKEY-123")) + }) + + It("sets the devShowArtistPage", func() { + conf.Server.DevShowArtistPage = true + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("devShowArtistPage", true)) + }) + + It("sets the listenBrainzEnabled", func() { + conf.Server.ListenBrainz.Enabled = true + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("listenBrainzEnabled", true)) + }) + + It("sets the enableReplayGain", func() { + conf.Server.EnableReplayGain = true + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("enableReplayGain", true)) + }) + + It("sets the enableExternalServices", func() { + conf.Server.EnableExternalServices = true + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("enableExternalServices", true)) + }) Describe("loginBackgroundURL", func() { Context("empty BaseURL", func() { @@ -233,12 +434,12 @@ var _ = Describe("serveIndex", func() { var _ = Describe("addShareData", func() { var ( r *http.Request - data map[string]any + data map[string]interface{} shareInfo *model.Share ) BeforeEach(func() { - data = make(map[string]any) + data = make(map[string]interface{}) r = httptest.NewRequest("GET", "/", nil) }) @@ -323,8 +524,8 @@ var _ = Describe("addShareData", func() { var appConfigRegex = regexp.MustCompile(`(?m)window.__APP_CONFIG__=(.*);</script>`) -func extractAppConfig(body string) map[string]any { - config := make(map[string]any) +func extractAppConfig(body string) map[string]interface{} { + config := make(map[string]interface{}) match := appConfigRegex.FindStringSubmatch(body) if match == nil { return config @@ -344,7 +545,7 @@ type mockedUserRepo struct { empty bool } -func (u *mockedUserRepo) CountAll(context.Context, ...model.QueryOptions) (int64, error) { +func (u *mockedUserRepo) CountAll(...model.QueryOptions) (int64, error) { if u.empty { return 0, nil } diff --git a/server/server.go b/server/server.go index 7e7e2f6bd..2ee7022bc 100644 --- a/server/server.go +++ b/server/server.go @@ -1,48 +1,45 @@ package server import ( - "bytes" "context" - "crypto/tls" - "encoding/pem" "errors" "fmt" "net" "net/http" + "net/url" "os" "path" - "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/httprate" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/ui" + . "github.com/navidrome/navidrome/utils/gg" ) type Server struct { - router chi.Router - ds model.DataStore - appRoot string - broker events.Broker - insights metrics.Insights + router chi.Router + ds model.DataStore + appRoot string + broker events.Broker } -func New(ds model.DataStore, broker events.Broker, insights metrics.Insights) *Server { - s := &Server{ds: ds, broker: broker, insights: insights} - initialSetup(ds) +func New(ds model.DataStore, broker events.Broker) *Server { + s := &Server{ds: ds, broker: broker} auth.Init(s.ds) + initialSetup(ds) s.initRoutes() s.mountAuthenticationRoutes() s.mountRootRedirector() - checkFFmpegInstallation() + checkFfmpegInstallation() checkExternalCredentials() return s } @@ -69,63 +66,61 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string, // Determine if TLS is enabled tlsEnabled := tlsCert != "" && tlsKey != "" - // Validate TLS certificates before starting the server - if tlsEnabled { - if err := validateTLSCertificates(tlsCert, tlsKey); err != nil { - return err - } - } - // Create a listener based on the address type (either Unix socket or TCP) var listener net.Listener var err error - if after, ok := strings.CutPrefix(addr, "unix:"); ok { - socketPath := after - listener, err = createUnixSocketFile(socketPath, conf.Server.UnixSocketPerm) + if strings.HasPrefix(addr, "unix:") { + socketPath := strings.TrimPrefix(addr, "unix:") + // Remove the socket file if it already exists + if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("error removing previous unix socket file: %w", err) + } + listener, err = net.Listen("unix", socketPath) if err != nil { - return err + return fmt.Errorf("error creating unix socket listener: %w", err) } } else { addr = fmt.Sprintf("%s:%d", addr, port) listener, err = net.Listen("tcp", addr) if err != nil { - return fmt.Errorf("creating tcp listener: %w", err) + return fmt.Errorf("error creating tcp listener: %w", err) } } // Start the server in a new goroutine and send an error signal to errC if there's an error errC := make(chan error) go func() { - var err error if tlsEnabled { // Start the HTTPS server log.Info("Starting server with TLS (HTTPS) enabled", "tlsCert", tlsCert, "tlsKey", tlsKey) - err = server.ServeTLS(listener, tlsCert, tlsKey) + if err := server.ServeTLS(listener, tlsCert, tlsKey); !errors.Is(err, http.ErrServerClosed) { + errC <- err + } } else { // Start the HTTP server - err = server.Serve(listener) - } - if !errors.Is(err, http.ErrServerClosed) { - errC <- err + if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) { + errC <- err + } } }() // Measure server startup time startupTime := time.Since(consts.ServerStart) - // Wait a short time to make sure the server has started successfully + // Wait a short time before checking if the server has started successfully + time.Sleep(50 * time.Millisecond) select { case err := <-errC: log.Error(ctx, "Could not start server. Aborting", err) - return fmt.Errorf("starting server: %w", err) - case <-time.After(50 * time.Millisecond): + return fmt.Errorf("error starting server: %w", err) + default: log.Info(ctx, "----> Navidrome server is ready!", "address", addr, "startupTime", startupTime, "tlsEnabled", tlsEnabled) } // Wait for a signal to terminate select { case err := <-errC: - return fmt.Errorf("running server: %w", err) + return fmt.Errorf("error running server: %w", err) case <-ctx.Done(): // If the context is done (i.e. the server should stop), proceed to shutting down the server } @@ -141,62 +136,50 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string, return nil } -func createUnixSocketFile(socketPath string, socketPerm string) (net.Listener, error) { - // Remove the socket file if it already exists - if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("removing previous unix socket file: %w", err) - } - // Create listener - listener, err := net.Listen("unix", socketPath) - if err != nil { - return nil, fmt.Errorf("creating unix socket listener: %w", err) - } - // Converts the socketPerm to uint and updates the permission of the unix socket file - perm, err := strconv.ParseUint(socketPerm, 8, 32) - if err != nil { - return nil, fmt.Errorf("parsing unix socket file permissions: %w", err) - } - err = os.Chmod(socketPath, os.FileMode(perm)) - if err != nil { - return nil, fmt.Errorf("updating permission of unix socket file: %w", err) - } - return listener, nil -} - func (s *Server) initRoutes() { s.appRoot = path.Join(conf.Server.BasePath, consts.URLPathUI) r := chi.NewRouter() - defaultMiddlewares := chi.Middlewares{ + middlewares := chi.Middlewares{ secureMiddleware(), corsHandler(), middleware.RequestID, - realIPMiddleware, + } + if conf.Server.ReverseProxyWhitelist == "" { + middlewares = append(middlewares, middleware.RealIP) + } + + middlewares = append(middlewares, middleware.Recoverer, middleware.Heartbeat("/ping"), robotsTXT(ui.BuildAssets()), serverAddressMiddleware, clientUniqueIDMiddleware, - compressMiddleware(), - loggerInjector, - JWTVerifier, - } + ) - // Mount the Native API /events endpoint with all default middlewares, adding the authentication middlewares + // Mount the Native API /events endpoint with all middlewares, except the compress and request logger, + // adding the authentication middlewares if conf.Server.DevActivityPanel { r.Group(func(r chi.Router) { - r.Use(defaultMiddlewares...) + r.Use(middlewares...) + r.Use(loggerInjector) + r.Use(authHeaderMapper) + r.Use(jwtVerifier) r.Use(Authenticator(s.ds)) r.Use(JWTRefresher) r.Handle(path.Join(conf.Server.BasePath, consts.URLPathNativeAPI, "events"), s.broker) }) } - // Configure the router with the default middlewares and requestLogger + // Configure the router with the default middlewares r.Group(func(r chi.Router) { - r.Use(defaultMiddlewares...) + r.Use(middlewares...) + r.Use(compressMiddleware()) + r.Use(loggerInjector) r.Use(requestLogger) + r.Use(authHeaderMapper) + r.Use(jwtVerifier) s.router = r }) } @@ -204,12 +187,11 @@ func (s *Server) initRoutes() { func (s *Server) mountAuthenticationRoutes() chi.Router { r := s.router return r.Route(path.Join(conf.Server.BasePath, "/auth"), func(r chi.Router) { - r.Use(LimitLoginBody) if conf.Server.AuthRequestLimit > 0 { log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit, "windowLength", conf.Server.AuthWindowLength) - rateLimiter := ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) + rateLimiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) r.With(rateLimiter).Post("/login", login(s.ds)) } else { log.Warn("Login rate limit is disabled! Consider enabling it to be protected against brute-force attacks") @@ -240,55 +222,20 @@ func (s *Server) frontendAssetsHandler() http.Handler { return r } -// validateTLSCertificates validates the TLS certificate and key files before starting the server. -// It provides detailed error messages for common issues like encrypted private keys. -func validateTLSCertificates(certFile, keyFile string) error { - // Read the key file to check for encryption - keyData, err := os.ReadFile(keyFile) //nolint:gosec - if err != nil { - return fmt.Errorf("reading TLS key file: %w", err) - } - - // Parse PEM blocks and check for encryption - block, _ := pem.Decode(keyData) - if block == nil { - return errors.New("TLS key file does not contain a valid PEM block") - } - - // Check for encrypted private key indicators - if isEncryptedPEM(block, keyData) { - return errors.New("TLS private key is encrypted (password-protected). " + - "Navidrome does not support encrypted private keys. " + - "Please decrypt your key using: openssl pkey -in <encrypted-key> -out <decrypted-key>") - } - - // Try to load the certificate pair to validate it - _, err = tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - return fmt.Errorf("loading TLS certificate/key pair: %w", err) - } - - return nil -} - -// isEncryptedPEM checks if a PEM block represents an encrypted private key. -func isEncryptedPEM(block *pem.Block, rawData []byte) bool { - // Check for PKCS#8 encrypted format (BEGIN ENCRYPTED PRIVATE KEY) - if block.Type == "ENCRYPTED PRIVATE KEY" { - return true - } - - // Check for legacy encrypted format with Proc-Type header - if block.Headers != nil { - if procType, ok := block.Headers["Proc-Type"]; ok && strings.Contains(procType, "ENCRYPTED") { - return true +func AbsoluteURL(r *http.Request, u string, params url.Values) string { + buildUrl, _ := url.Parse(u) + if strings.HasPrefix(u, "/") { + buildUrl.Path = path.Join(conf.Server.BasePath, buildUrl.Path) + if conf.Server.BaseHost != "" { + buildUrl.Scheme = If(conf.Server.BaseScheme, "http") + buildUrl.Host = conf.Server.BaseHost + } else { + buildUrl.Scheme = r.URL.Scheme + buildUrl.Host = r.Host } } - - // Also check raw data for DEK-Info header (in case pem.Decode doesn't parse headers correctly) - if bytes.Contains(rawData, []byte("DEK-Info:")) || bytes.Contains(rawData, []byte("Proc-Type: 4,ENCRYPTED")) { - return true + if len(params) > 0 { + buildUrl.RawQuery = params.Encode() } - - return false + return buildUrl.String() } diff --git a/server/server_test.go b/server/server_test.go index 178c0015a..26155f3c0 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1,209 +1,64 @@ package server import ( - "context" - "crypto/tls" - "crypto/x509" - "fmt" - "io/fs" "net/http" - "os" - "path/filepath" - "time" + "net/url" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("createUnixSocketFile", func() { - var socketPath string - +var _ = Describe("AbsoluteURL", func() { BeforeEach(func() { - tempDir, _ := os.MkdirTemp("", "create_unix_socket_file_test") - socketPath = filepath.Join(tempDir, "test.sock") - DeferCleanup(func() { - _ = os.RemoveAll(tempDir) + DeferCleanup(configtest.SetupConfig()) + }) + When("BaseURL is empty", func() { + BeforeEach(func() { + conf.Server.BasePath = "" + }) + It("uses the scheme/host from the request", func() { + r, _ := http.NewRequest("GET", "https://myserver.com/rest/ping?id=123", nil) + actual := AbsoluteURL(r, "/share/img/123", url.Values{"a": []string{"xyz"}}) + Expect(actual).To(Equal("https://myserver.com/share/img/123?a=xyz")) + }) + It("does not override provided schema/host", func() { + r, _ := http.NewRequest("GET", "http://127.0.0.1/rest/ping?id=123", nil) + actual := AbsoluteURL(r, "http://public.myserver.com/share/img/123", url.Values{"a": []string{"xyz"}}) + Expect(actual).To(Equal("http://public.myserver.com/share/img/123?a=xyz")) }) }) - - 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() - - Expect(actualPermission).To(Equal(os.FileMode(0777))) - Expect(err).ToNot(HaveOccurred()) - + When("BaseURL has only path", func() { + BeforeEach(func() { + conf.Server.BasePath = "/music" + }) + It("uses the scheme/host from the request", func() { + r, _ := http.NewRequest("GET", "https://myserver.com/rest/ping?id=123", nil) + actual := AbsoluteURL(r, "/share/img/123", url.Values{"a": []string{"xyz"}}) + Expect(actual).To(Equal("https://myserver.com/music/share/img/123?a=xyz")) + }) + It("does not override provided schema/host", func() { + r, _ := http.NewRequest("GET", "http://127.0.0.1/rest/ping?id=123", nil) + actual := AbsoluteURL(r, "http://public.myserver.com/share/img/123", url.Values{"a": []string{"xyz"}}) + Expect(actual).To(Equal("http://public.myserver.com/share/img/123?a=xyz")) }) }) - - When("unixSocketPerm is invalid", func() { - It("returns an error", func() { - _, err := createUnixSocketFile(socketPath, "invalid") - Expect(err).To(HaveOccurred()) - + When("BaseURL has full URL", func() { + BeforeEach(func() { + conf.Server.BaseScheme = "https" + conf.Server.BaseHost = "myserver.com:8080" + conf.Server.BasePath = "/music" }) - }) - - 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()) - - _, err = createUnixSocketFile(socketPath, "0600") - Expect(err).ToNot(HaveOccurred()) - fileInfo, _ := os.Stat(socketPath) - Expect(fileInfo.Mode().Perm()).To(Equal(os.FileMode(0600))) - Expect(fileInfo.Mode().Type()).To(Equal(fs.ModeSocket)) - }) - }) -}) - -var _ = Describe("TLS support", func() { - Describe("validateTLSCertificates", func() { - const testDataDir = "server/testdata" - - When("certificate and key are valid and unencrypted", func() { - It("returns nil", func() { - certFile := filepath.Join(testDataDir, "test_cert.pem") - keyFile := filepath.Join(testDataDir, "test_key.pem") - err := validateTLSCertificates(certFile, keyFile) - Expect(err).ToNot(HaveOccurred()) - }) - }) - - When("private key is encrypted with PKCS#8 format", func() { - It("returns an error with helpful message", func() { - certFile := filepath.Join(testDataDir, "test_cert_encrypted.pem") - keyFile := filepath.Join(testDataDir, "test_key_encrypted.pem") - err := validateTLSCertificates(certFile, keyFile) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("encrypted")) - Expect(err.Error()).To(ContainSubstring("openssl")) - }) - }) - - When("private key is encrypted with legacy format (Proc-Type header)", func() { - It("returns an error with helpful message", func() { - certFile := filepath.Join(testDataDir, "test_cert.pem") - keyFile := filepath.Join(testDataDir, "test_key_encrypted_legacy.pem") - err := validateTLSCertificates(certFile, keyFile) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("encrypted")) - Expect(err.Error()).To(ContainSubstring("openssl")) - }) - }) - - When("key file does not exist", func() { - It("returns an error", func() { - certFile := filepath.Join(testDataDir, "test_cert.pem") - keyFile := filepath.Join(testDataDir, "nonexistent.pem") - err := validateTLSCertificates(certFile, keyFile) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("reading TLS key file")) - }) - }) - - When("key file does not contain valid PEM", func() { - It("returns an error", func() { - // Create a temp file with invalid PEM content - tmpFile, err := os.CreateTemp("", "invalid_key*.pem") - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { - _ = os.Remove(tmpFile.Name()) - }) - _, err = tmpFile.WriteString("not a valid PEM file") - Expect(err).ToNot(HaveOccurred()) - _ = tmpFile.Close() - - certFile := filepath.Join(testDataDir, "test_cert.pem") - err = validateTLSCertificates(certFile, tmpFile.Name()) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("valid PEM block")) - }) - }) - - When("certificate file does not exist", func() { - It("returns an error from tls.LoadX509KeyPair", func() { - certFile := filepath.Join(testDataDir, "nonexistent_cert.pem") - keyFile := filepath.Join(testDataDir, "test_key.pem") - err := validateTLSCertificates(certFile, keyFile) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("loading TLS certificate/key pair")) - }) - }) - }) - - Describe("Server TLS", func() { - const testDataDir = "server/testdata" - - When("server is started with valid TLS certificates", func() { - It("accepts HTTPS connections", func() { - DeferCleanup(configtest.SetupConfig()) - - // Create server with mock dependencies - ds := &tests.MockDataStore{} - server := New(ds, nil, nil) - - // Load the test certificate to create a trusted CA pool - certFile := filepath.Join(testDataDir, "test_cert.pem") - keyFile := filepath.Join(testDataDir, "test_key.pem") - caCert, err := os.ReadFile(certFile) - Expect(err).ToNot(HaveOccurred()) - - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCert) - - // Create an HTTPS client that trusts our test certificate - httpClient := &http.Client{ - Timeout: 5 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - RootCAs: caCertPool, - MinVersion: tls.VersionTLS12, - }, - }, - } - - // Start the server in a goroutine - ctx, cancel := context.WithCancel(GinkgoT().Context()) - defer cancel() - - errChan := make(chan error, 1) - go func() { - errChan <- server.Run(ctx, "127.0.0.1", 14534, certFile, keyFile) - }() - - Eventually(func() error { - // Make an HTTPS request to the server - resp, err := httpClient.Get("https://127.0.0.1:14534/ping") - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - return nil - }, 2*time.Second, 100*time.Millisecond).Should(Succeed()) - - // Stop the server - cancel() - - // Wait for server to stop (with timeout) - select { - case <-errChan: - // Server stopped - case <-time.After(2 * time.Second): - Fail("Server did not stop in time") - } - }) + It("use the configured scheme/host/path", func() { + r, _ := http.NewRequest("GET", "https://localhost:4533/rest/ping?id=123", nil) + actual := AbsoluteURL(r, "/share/img/123", url.Values{"a": []string{"xyz"}}) + Expect(actual).To(Equal("https://myserver.com:8080/music/share/img/123?a=xyz")) + }) + It("does not override provided schema/host", func() { + r, _ := http.NewRequest("GET", "http://127.0.0.1/rest/ping?id=123", nil) + actual := AbsoluteURL(r, "http://public.myserver.com/share/img/123", url.Values{"a": []string{"xyz"}}) + Expect(actual).To(Equal("http://public.myserver.com/share/img/123?a=xyz")) }) }) }) diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 0a0c65ec7..7f1ecea6b 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -6,19 +6,16 @@ import ( "strconv" "time" - "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/filter" + "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/run" - "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/number" ) func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { - p := req.Params(r) - typ, err := p.String("type") + typ, err := requiredParamString(r, "type") if err != nil { return nil, 0, err } @@ -38,21 +35,21 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { case "frequent": opts = filter.AlbumsByFrequent() case "starred": - opts = filter.ByStarred() + opts = filter.AlbumsByStarred() case "highest": - opts = filter.ByRating() + opts = filter.AlbumsByRating() case "byGenre": - genre, err := p.String("genre") + genre, err := requiredParamString(r, "genre") if err != nil { return nil, 0, err } opts = filter.AlbumsByGenre(genre) case "byYear": - fromYear, err := p.Int("fromYear") + fromYear, err := requiredParamInt(r, "fromYear") if err != nil { return nil, 0, err } - toYear, err := p.Int("toYear") + toYear, err := requiredParamInt(r, "toYear") if err != nil { return nil, 0, err } @@ -62,25 +59,18 @@ func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { return nil, 0, newError(responses.ErrorGeneric, "type '%s' not implemented", typ) } - // Get optional library IDs from musicFolderId parameter - musicFolderIds, err := selectedMusicFolderIds(r, false) - if err != nil { - return nil, 0, err - } - opts = filter.ApplyLibraryFilter(opts, musicFolderIds) - - opts.Offset = p.IntOr("offset", 0) - opts.Max = min(p.IntOr("size", 10), 500) - albums, err := api.ds.Album().GetAll(r.Context(), opts) + opts.Offset = utils.ParamInt(r, "offset", 0) + opts.Max = number.Min(utils.ParamInt(r, "size", 10), 500) + albums, err := api.ds.Album(r.Context()).GetAllWithoutGenres(opts) if err != nil { - log.Error(r, "Error retrieving albums", err) + log.Error(r, "Error retrieving albums", "error", err) return nil, 0, newError(responses.ErrorGeneric, "internal error") } - count, err := api.ds.Album().CountAll(r.Context(), opts) + count, err := api.ds.Album(r.Context()).CountAll(opts) if err != nil { - log.Error(r, "Error counting albums", err) + log.Error(r, "Error counting albums", "error", err) return nil, 0, newError(responses.ErrorGeneric, "internal error") } @@ -96,9 +86,7 @@ func (api *Router) GetAlbumList(w http.ResponseWriter, r *http.Request) (*respon w.Header().Set("x-total-count", strconv.Itoa(int(count))) response := newResponse() - response.AlbumList = &responses.AlbumList{ - Album: slice.MapWithArg(albums, r.Context(), childFromAlbum), - } + response.AlbumList = &responses.AlbumList{Album: childrenFromAlbums(r.Context(), albums)} return response, nil } @@ -111,93 +99,45 @@ func (api *Router) GetAlbumList2(w http.ResponseWriter, r *http.Request) (*respo w.Header().Set("x-total-count", strconv.FormatInt(pageCount, 10)) response := newResponse() - response.AlbumList2 = &responses.AlbumList2{ - Album: slice.MapWithArg(albums, r.Context(), buildAlbumID3), - } + response.AlbumList2 = &responses.AlbumList{Album: childrenFromAlbums(r.Context(), albums)} return response, nil } -func (api *Router) getStarredItems(r *http.Request) (model.Artists, model.Albums, model.MediaFiles, error) { - ctx := r.Context() - - // Get optional library IDs from musicFolderId parameter - musicFolderIds, err := selectedMusicFolderIds(r, false) - if err != nil { - return nil, nil, nil, err - } - - // Prepare variables to capture results from parallel execution - var artists model.Artists - var albums model.Albums - var mediaFiles model.MediaFiles - - // Execute all three queries in parallel for better performance - err = run.Parallel( - // Query starred artists - func() error { - artistOpts := filter.ApplyArtistLibraryFilter(filter.ArtistsByStarred(), musicFolderIds) - var err error - artists, err = api.ds.Artist().GetAll(ctx, artistOpts) - if err != nil { - log.Error(r, "Error retrieving starred artists", err) - } - return err - }, - // Query starred albums - func() error { - albumOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds) - var err error - albums, err = api.ds.Album().GetAll(ctx, albumOpts) - if err != nil { - log.Error(r, "Error retrieving starred albums", err) - } - return err - }, - // Query starred media files - func() error { - mediaFileOpts := filter.ApplyLibraryFilter(filter.ByStarred(), musicFolderIds) - var err error - mediaFiles, err = api.ds.MediaFile().GetAll(ctx, mediaFileOpts) - if err != nil { - log.Error(r, "Error retrieving starred mediaFiles", err) - } - return err - }, - )() - - // Return the first error if any occurred - if err != nil { - return nil, nil, nil, err - } - - return artists, albums, mediaFiles, nil -} - func (api *Router) GetStarred(r *http.Request) (*responses.Subsonic, error) { - artists, albums, mediaFiles, err := api.getStarredItems(r) + ctx := r.Context() + options := filter.Starred() + artists, err := api.ds.Artist(ctx).GetAll(options) if err != nil { + log.Error(r, "Error retrieving starred artists", "error", err) + return nil, err + } + albums, err := api.ds.Album(ctx).GetAllWithoutGenres(options) + if err != nil { + log.Error(r, "Error retrieving starred albums", "error", err) + return nil, err + } + mediaFiles, err := api.ds.MediaFile(ctx).GetAll(options) + if err != nil { + log.Error(r, "Error retrieving starred mediaFiles", "error", err) return nil, err } response := newResponse() response.Starred = &responses.Starred{} - response.Starred.Artist = slice.MapWithArg(artists, r, toArtist) - response.Starred.Album = slice.MapWithArg(albums, r.Context(), childFromAlbum) - response.Starred.Song = slice.MapWithArg(mediaFiles, r.Context(), childFromMediaFile) + response.Starred.Artist = toArtists(r, artists) + response.Starred.Album = childrenFromAlbums(r.Context(), albums) + response.Starred.Song = childrenFromMediaFiles(r.Context(), mediaFiles) return response, nil } func (api *Router) GetStarred2(r *http.Request) (*responses.Subsonic, error) { - artists, albums, mediaFiles, err := api.getStarredItems(r) + resp, err := api.GetStarred(r) if err != nil { return nil, err } response := newResponse() - response.Starred2 = &responses.Starred2{} - response.Starred2.Artist = slice.MapWithArg(artists, r, toArtistID3) - response.Starred2.Album = slice.MapWithArg(albums, r.Context(), buildAlbumID3) - response.Starred2.Song = slice.MapWithArg(mediaFiles, r.Context(), childFromMediaFile) + response.Starred2 = resp.Starred return response, nil } @@ -205,86 +145,60 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() npInfo, err := api.scrobbler.GetNowPlaying(ctx) if err != nil { - log.Error(r, "Error retrieving now playing list", err) + log.Error(r, "Error retrieving now playing list", "error", err) return nil, err } response := newResponse() response.NowPlaying = &responses.NowPlaying{} - var i int32 - 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, - PlayerName: np.PlayerName, - State: np.State, - PositionMs: np.PositionMs, - PlaybackRate: np.PlaybackRate, - } - }) + response.NowPlaying.Entry = make([]responses.NowPlayingEntry, len(npInfo)) + for i, np := range npInfo { + response.NowPlaying.Entry[i].Child = childFromMediaFile(ctx, np.MediaFile) + response.NowPlaying.Entry[i].UserName = np.Username + response.NowPlaying.Entry[i].MinutesAgo = int32(time.Since(np.Start).Minutes()) + response.NowPlaying.Entry[i].PlayerId = int32(i + 1) // Fake numeric playerId, it does not seem to be used for anything + response.NowPlaying.Entry[i].PlayerName = np.PlayerName + } return response, nil } func (api *Router) GetRandomSongs(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - size := min(p.IntOr("size", 10), 500) - genre, _ := p.String("genre") - fromYear := p.IntOr("fromYear", 0) - toYear := p.IntOr("toYear", 0) + size := number.Min(utils.ParamInt(r, "size", 10), 500) + genre := utils.ParamString(r, "genre") + fromYear := utils.ParamInt(r, "fromYear", 0) + toYear := utils.ParamInt(r, "toYear", 0) - // Get optional library IDs from musicFolderId parameter - musicFolderIds, err := selectedMusicFolderIds(r, false) + songs, err := api.getSongs(r.Context(), 0, size, filter.SongsByRandom(genre, fromYear, toYear)) if err != nil { - return nil, err - } - opts := filter.SongsByGenreAndYearRange(genre, fromYear, toYear) - opts = filter.ApplyLibraryFilter(opts, musicFolderIds) - opts.Max = size - - songs, err := api.ds.MediaFile().GetRandom(r.Context(), opts) - if err != nil { - log.Error(r, "Error retrieving random songs", err) + log.Error(r, "Error retrieving random songs", "error", err) return nil, err } response := newResponse() response.RandomSongs = &responses.Songs{} - response.RandomSongs.Songs = slice.MapWithArg(songs, r.Context(), childFromMediaFile) + response.RandomSongs.Songs = childrenFromMediaFiles(r.Context(), songs) return response, nil } func (api *Router) GetSongsByGenre(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - count := min(p.IntOr("count", 10), 500) - offset := p.IntOr("offset", 0) - genre, _ := p.String("genre") + count := number.Min(utils.ParamInt(r, "count", 10), 500) + offset := utils.ParamInt(r, "offset", 0) + genre := utils.ParamString(r, "genre") - // Get optional library IDs from musicFolderId parameter - musicFolderIds, err := selectedMusicFolderIds(r, false) + songs, err := api.getSongs(r.Context(), offset, count, filter.SongsByGenre(genre)) if err != nil { - return nil, err - } - opts := filter.SongsByGenre(genre) - opts = filter.ApplyLibraryFilter(opts, musicFolderIds) - - ctx := r.Context() - songs, err := api.getSongs(ctx, offset, count, opts) - if err != nil { - log.Error(r, "Error retrieving random songs", err) + log.Error(r, "Error retrieving random songs", "error", err) return nil, err } response := newResponse() response.SongsByGenre = &responses.Songs{} - response.SongsByGenre.Songs = slice.MapWithArg(songs, ctx, childFromMediaFile) + response.SongsByGenre.Songs = childrenFromMediaFiles(r.Context(), songs) return response, nil } func (api *Router) getSongs(ctx context.Context, offset, size int, opts filter.Options) (model.MediaFiles, error) { opts.Offset = offset opts.Max = size - return api.ds.MediaFile().GetAll(ctx, opts) + return api.ds.MediaFile(ctx).GetAll(opts) } diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index c4a8847d4..85a994486 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -5,12 +5,11 @@ import ( "errors" "net/http/httptest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/req" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -20,12 +19,12 @@ var _ = Describe("Album Lists", func() { var ds model.DataStore var mockRepo *tests.MockAlbumRepo var w *httptest.ResponseRecorder + ctx := log.NewContext(context.TODO()) BeforeEach(func() { ds = &tests.MockDataStore{} - auth.Init(ds) - mockRepo = ds.Album().(*tests.MockAlbumRepo) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() }) @@ -37,7 +36,7 @@ var _ = Describe("Album Lists", func() { }) resp, err := router.GetAlbumList(w, r) - Expect(err).ToNot(HaveOccurred()) + Expect(err).To(BeNil()) Expect(resp.AlbumList.Album[0].Id).To(Equal("1")) Expect(resp.AlbumList.Album[1].Id).To(Equal("2")) Expect(w.Header().Get("x-total-count")).To(Equal("2")) @@ -48,8 +47,12 @@ var _ = Describe("Album Lists", func() { It("should fail if missing type parameter", func() { r := newGetRequest() _, err := router.GetAlbumList(w, r) + var subErr subError + isSubError := errors.As(err, &subErr) - Expect(err).To(MatchError(req.ErrMissingParam)) + Expect(isSubError).To(BeTrue()) + Expect(subErr).To(MatchError("required 'type' parameter is missing")) + Expect(subErr.code).To(Equal(responses.ErrorMissingParameter)) }) It("should return error if call fails", func() { @@ -58,79 +61,11 @@ var _ = Describe("Album Lists", func() { _, err := router.GetAlbumList(w, r) - Expect(err).To(MatchError(errSubsonic)) + Expect(err).ToNot(BeNil()) var subErr subError errors.As(err, &subErr) Expect(subErr.code).To(Equal(responses.ErrorGeneric)) }) - - Context("with musicFolderId parameter", func() { - var user model.User - var ctx context.Context - - BeforeEach(func() { - user = model.User{ - ID: "test-user", - Libraries: []model.Library{ - {ID: 1, Name: "Library 1"}, - {ID: 2, Name: "Library 2"}, - {ID: 3, Name: "Library 3"}, - }, - } - ctx = request.WithUser(context.Background(), user) - }) - - It("should filter albums by specific library when musicFolderId is provided", func() { - r := newGetRequest("type=newest", "musicFolderId=1") - r = r.WithContext(ctx) - mockRepo.SetData(model.Albums{ - {ID: "1"}, {ID: "2"}, - }) - - resp, err := router.GetAlbumList(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.AlbumList.Album).To(HaveLen(2)) - // Verify that library filter was applied - query, args, _ := mockRepo.Options.Filters.ToSql() - Expect(query).To(ContainSubstring("library_id IN (?)")) - Expect(args).To(ContainElement(1)) - }) - - It("should filter albums by multiple libraries when multiple musicFolderId are provided", func() { - r := newGetRequest("type=newest", "musicFolderId=1", "musicFolderId=2") - r = r.WithContext(ctx) - mockRepo.SetData(model.Albums{ - {ID: "1"}, {ID: "2"}, - }) - - resp, err := router.GetAlbumList(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.AlbumList.Album).To(HaveLen(2)) - // Verify that library filter was applied - query, args, _ := mockRepo.Options.Filters.ToSql() - Expect(query).To(ContainSubstring("library_id IN (?,?)")) - Expect(args).To(ContainElements(1, 2)) - }) - - It("should return all accessible albums when no musicFolderId is provided", func() { - r := newGetRequest("type=newest") - r = r.WithContext(ctx) - mockRepo.SetData(model.Albums{ - {ID: "1"}, {ID: "2"}, - }) - - resp, err := router.GetAlbumList(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.AlbumList.Album).To(HaveLen(2)) - // Verify that library filter was applied - query, args, _ := mockRepo.Options.Filters.ToSql() - Expect(query).To(ContainSubstring("library_id IN (?,?,?)")) - Expect(args).To(ContainElements(1, 2, 3)) - }) - }) }) Describe("GetAlbumList2", func() { @@ -141,7 +76,7 @@ var _ = Describe("Album Lists", func() { }) resp, err := router.GetAlbumList2(w, r) - Expect(err).ToNot(HaveOccurred()) + Expect(err).To(BeNil()) Expect(resp.AlbumList2.Album[0].Id).To(Equal("1")) Expect(resp.AlbumList2.Album[1].Id).To(Equal("2")) Expect(w.Header().Get("x-total-count")).To(Equal("2")) @@ -151,10 +86,13 @@ var _ = Describe("Album Lists", func() { It("should fail if missing type parameter", func() { r := newGetRequest() - _, err := router.GetAlbumList2(w, r) - Expect(err).To(MatchError(req.ErrMissingParam)) + var subErr subError + errors.As(err, &subErr) + + Expect(subErr).To(MatchError("required 'type' parameter is missing")) + Expect(subErr.code).To(Equal(responses.ErrorMissingParameter)) }) It("should return error if call fails", func() { @@ -163,378 +101,10 @@ var _ = Describe("Album Lists", func() { _, err := router.GetAlbumList2(w, r) - Expect(err).To(MatchError(errSubsonic)) var subErr subError errors.As(err, &subErr) + Expect(subErr).ToNot(BeNil()) Expect(subErr.code).To(Equal(responses.ErrorGeneric)) }) - - Context("with musicFolderId parameter", func() { - var user model.User - var ctx context.Context - - BeforeEach(func() { - user = model.User{ - ID: "test-user", - Libraries: []model.Library{ - {ID: 1, Name: "Library 1"}, - {ID: 2, Name: "Library 2"}, - {ID: 3, Name: "Library 3"}, - }, - } - ctx = request.WithUser(context.Background(), user) - }) - - It("should filter albums by specific library when musicFolderId is provided", func() { - r := newGetRequest("type=newest", "musicFolderId=1") - r = r.WithContext(ctx) - mockRepo.SetData(model.Albums{ - {ID: "1"}, {ID: "2"}, - }) - - resp, err := router.GetAlbumList2(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.AlbumList2.Album).To(HaveLen(2)) - // Verify that library filter was applied - Expect(mockRepo.Options.Filters).ToNot(BeNil()) - }) - - It("should filter albums by multiple libraries when multiple musicFolderId are provided", func() { - r := newGetRequest("type=newest", "musicFolderId=1", "musicFolderId=2") - r = r.WithContext(ctx) - mockRepo.SetData(model.Albums{ - {ID: "1"}, {ID: "2"}, - }) - - resp, err := router.GetAlbumList2(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.AlbumList2.Album).To(HaveLen(2)) - // Verify that library filter was applied - Expect(mockRepo.Options.Filters).ToNot(BeNil()) - }) - - It("should return all accessible albums when no musicFolderId is provided", func() { - r := newGetRequest("type=newest") - r = r.WithContext(ctx) - mockRepo.SetData(model.Albums{ - {ID: "1"}, {ID: "2"}, - }) - - resp, err := router.GetAlbumList2(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.AlbumList2.Album).To(HaveLen(2)) - }) - }) - }) - - Describe("GetRandomSongs", func() { - var mockMediaFileRepo *tests.MockMediaFileRepo - - BeforeEach(func() { - mockMediaFileRepo = ds.MediaFile().(*tests.MockMediaFileRepo) - }) - - It("should return random songs", func() { - mockMediaFileRepo.SetData(model.MediaFiles{ - {ID: "1", Title: "Song 1"}, - {ID: "2", Title: "Song 2"}, - }) - r := newGetRequest("size=2") - - resp, err := router.GetRandomSongs(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.RandomSongs.Songs).To(HaveLen(2)) - }) - - Context("with musicFolderId parameter", func() { - var user model.User - var ctx context.Context - - BeforeEach(func() { - user = model.User{ - ID: "test-user", - Libraries: []model.Library{ - {ID: 1, Name: "Library 1"}, - {ID: 2, Name: "Library 2"}, - {ID: 3, Name: "Library 3"}, - }, - } - ctx = request.WithUser(context.Background(), user) - }) - - It("should filter songs by specific library when musicFolderId is provided", func() { - mockMediaFileRepo.SetData(model.MediaFiles{ - {ID: "1", Title: "Song 1"}, - {ID: "2", Title: "Song 2"}, - }) - r := newGetRequest("size=2", "musicFolderId=1") - r = r.WithContext(ctx) - - resp, err := router.GetRandomSongs(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.RandomSongs.Songs).To(HaveLen(2)) - // Verify that library filter was applied - query, args, _ := mockMediaFileRepo.Options.Filters.ToSql() - Expect(query).To(ContainSubstring("library_id IN (?)")) - Expect(args).To(ContainElement(1)) - }) - - It("should filter songs by multiple libraries when multiple musicFolderId are provided", func() { - mockMediaFileRepo.SetData(model.MediaFiles{ - {ID: "1", Title: "Song 1"}, - {ID: "2", Title: "Song 2"}, - }) - r := newGetRequest("size=2", "musicFolderId=1", "musicFolderId=2") - r = r.WithContext(ctx) - - resp, err := router.GetRandomSongs(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.RandomSongs.Songs).To(HaveLen(2)) - // Verify that library filter was applied - query, args, _ := mockMediaFileRepo.Options.Filters.ToSql() - Expect(query).To(ContainSubstring("library_id IN (?,?)")) - Expect(args).To(ContainElements(1, 2)) - }) - - It("should return all accessible songs when no musicFolderId is provided", func() { - mockMediaFileRepo.SetData(model.MediaFiles{ - {ID: "1", Title: "Song 1"}, - {ID: "2", Title: "Song 2"}, - }) - r := newGetRequest("size=2") - r = r.WithContext(ctx) - - resp, err := router.GetRandomSongs(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.RandomSongs.Songs).To(HaveLen(2)) - // Verify that library filter was applied - query, args, _ := mockMediaFileRepo.Options.Filters.ToSql() - Expect(query).To(ContainSubstring("library_id IN (?,?,?)")) - Expect(args).To(ContainElements(1, 2, 3)) - }) - }) - }) - - Describe("GetSongsByGenre", func() { - var mockMediaFileRepo *tests.MockMediaFileRepo - - BeforeEach(func() { - mockMediaFileRepo = ds.MediaFile().(*tests.MockMediaFileRepo) - }) - - It("should return songs by genre", func() { - mockMediaFileRepo.SetData(model.MediaFiles{ - {ID: "1", Title: "Song 1"}, - {ID: "2", Title: "Song 2"}, - }) - r := newGetRequest("count=2", "genre=rock") - - resp, err := router.GetSongsByGenre(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.SongsByGenre.Songs).To(HaveLen(2)) - }) - - Context("with musicFolderId parameter", func() { - var user model.User - var ctx context.Context - - BeforeEach(func() { - user = model.User{ - ID: "test-user", - Libraries: []model.Library{ - {ID: 1, Name: "Library 1"}, - {ID: 2, Name: "Library 2"}, - {ID: 3, Name: "Library 3"}, - }, - } - ctx = request.WithUser(context.Background(), user) - }) - - It("should filter songs by specific library when musicFolderId is provided", func() { - mockMediaFileRepo.SetData(model.MediaFiles{ - {ID: "1", Title: "Song 1"}, - {ID: "2", Title: "Song 2"}, - }) - r := newGetRequest("count=2", "genre=rock", "musicFolderId=1") - r = r.WithContext(ctx) - - resp, err := router.GetSongsByGenre(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.SongsByGenre.Songs).To(HaveLen(2)) - // Verify that library filter was applied - query, args, _ := mockMediaFileRepo.Options.Filters.ToSql() - Expect(query).To(ContainSubstring("library_id IN (?)")) - Expect(args).To(ContainElement(1)) - }) - - It("should filter songs by multiple libraries when multiple musicFolderId are provided", func() { - mockMediaFileRepo.SetData(model.MediaFiles{ - {ID: "1", Title: "Song 1"}, - {ID: "2", Title: "Song 2"}, - }) - r := newGetRequest("count=2", "genre=rock", "musicFolderId=1", "musicFolderId=2") - r = r.WithContext(ctx) - - resp, err := router.GetSongsByGenre(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.SongsByGenre.Songs).To(HaveLen(2)) - // Verify that library filter was applied - query, args, _ := mockMediaFileRepo.Options.Filters.ToSql() - Expect(query).To(ContainSubstring("library_id IN (?,?)")) - Expect(args).To(ContainElements(1, 2)) - }) - - It("should return all accessible songs when no musicFolderId is provided", func() { - mockMediaFileRepo.SetData(model.MediaFiles{ - {ID: "1", Title: "Song 1"}, - {ID: "2", Title: "Song 2"}, - }) - r := newGetRequest("count=2", "genre=rock") - r = r.WithContext(ctx) - - resp, err := router.GetSongsByGenre(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.SongsByGenre.Songs).To(HaveLen(2)) - // Verify that library filter was applied - query, args, _ := mockMediaFileRepo.Options.Filters.ToSql() - Expect(query).To(ContainSubstring("library_id IN (?,?,?)")) - Expect(args).To(ContainElements(1, 2, 3)) - }) - }) - }) - - Describe("GetStarred", func() { - var mockArtistRepo *tests.MockArtistRepo - var mockAlbumRepo *tests.MockAlbumRepo - var mockMediaFileRepo *tests.MockMediaFileRepo - - BeforeEach(func() { - mockArtistRepo = ds.Artist().(*tests.MockArtistRepo) - mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo) - mockMediaFileRepo = ds.MediaFile().(*tests.MockMediaFileRepo) - }) - - It("should return starred items", func() { - mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}}) - mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}}) - mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}}) - r := newGetRequest() - - resp, err := router.GetStarred(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.Starred.Artist).To(HaveLen(1)) - Expect(resp.Starred.Album).To(HaveLen(1)) - Expect(resp.Starred.Song).To(HaveLen(1)) - }) - - Context("with musicFolderId parameter", func() { - var user model.User - var ctx context.Context - - BeforeEach(func() { - user = model.User{ - ID: "test-user", - Libraries: []model.Library{ - {ID: 1, Name: "Library 1"}, - {ID: 2, Name: "Library 2"}, - {ID: 3, Name: "Library 3"}, - }, - } - ctx = request.WithUser(context.Background(), user) - }) - - It("should filter starred items by specific library when musicFolderId is provided", func() { - mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}}) - mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}}) - mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}}) - r := newGetRequest("musicFolderId=1") - r = r.WithContext(ctx) - - resp, err := router.GetStarred(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.Starred.Artist).To(HaveLen(1)) - Expect(resp.Starred.Album).To(HaveLen(1)) - Expect(resp.Starred.Song).To(HaveLen(1)) - // Verify that library filter was applied to all types - artistQuery, artistArgs, _ := mockArtistRepo.Options.Filters.ToSql() - Expect(artistQuery).To(ContainSubstring("library_id IN (?)")) - Expect(artistArgs).To(ContainElement(1)) - }) - }) - }) - - Describe("GetStarred2", func() { - var mockArtistRepo *tests.MockArtistRepo - var mockAlbumRepo *tests.MockAlbumRepo - var mockMediaFileRepo *tests.MockMediaFileRepo - - BeforeEach(func() { - mockArtistRepo = ds.Artist().(*tests.MockArtistRepo) - mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo) - mockMediaFileRepo = ds.MediaFile().(*tests.MockMediaFileRepo) - }) - - It("should return starred items in ID3 format", func() { - mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}}) - mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}}) - mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}}) - r := newGetRequest() - - resp, err := router.GetStarred2(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.Starred2.Artist).To(HaveLen(1)) - Expect(resp.Starred2.Album).To(HaveLen(1)) - Expect(resp.Starred2.Song).To(HaveLen(1)) - }) - - Context("with musicFolderId parameter", func() { - var user model.User - var ctx context.Context - - BeforeEach(func() { - user = model.User{ - ID: "test-user", - Libraries: []model.Library{ - {ID: 1, Name: "Library 1"}, - {ID: 2, Name: "Library 2"}, - {ID: 3, Name: "Library 3"}, - }, - } - ctx = request.WithUser(context.Background(), user) - }) - - It("should filter starred items by specific library when musicFolderId is provided", func() { - mockArtistRepo.SetData(model.Artists{{ID: "1", Name: "Artist 1"}}) - mockAlbumRepo.SetData(model.Albums{{ID: "1", Name: "Album 1"}}) - mockMediaFileRepo.SetData(model.MediaFiles{{ID: "1", Title: "Song 1"}}) - r := newGetRequest("musicFolderId=1") - r = r.WithContext(ctx) - - resp, err := router.GetStarred2(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.Starred2.Artist).To(HaveLen(1)) - Expect(resp.Starred2.Album).To(HaveLen(1)) - Expect(resp.Starred2.Song).To(HaveLen(1)) - // Verify that library filter was applied to all types - artistQuery, artistArgs, _ := mockArtistRepo.Options.Filters.ToSql() - Expect(artistQuery).To(ContainSubstring("library_id IN (?)")) - Expect(artistArgs).To(ContainElement(1)) - }) - }) }) }) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index fe724741c..bdad5e2d6 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -6,79 +6,56 @@ import ( "errors" "fmt" "net/http" - "regexp" - "strconv" "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/external" - lyricssvc "github.com/navidrome/navidrome/core/lyrics" - "github.com/navidrome/navidrome/core/metrics" - "github.com/navidrome/navidrome/core/playback" - playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" - sonicsvc "github.com/navidrome/navidrome/core/sonic" - "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils" ) const Version = "1.16.1" -var validJSIdentifier = regexp.MustCompile(`^[a-zA-Z_$][a-zA-Z0-9_$.]*$`) - type handler = func(*http.Request) (*responses.Subsonic, error) type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, error) type Router struct { http.Handler - ds model.DataStore - artwork artwork.Artwork - streamer stream.MediaStreamer - archiver core.Archiver - players core.Players - provider external.Provider - playlists playlistsvc.Playlists - scanner model.Scanner - broker events.Broker - scrobbler scrobbler.PlayTracker - share core.Share - playback playback.PlaybackServer - metrics metrics.Metrics - lyrics lyricssvc.Lyrics - transcodeDecision stream.TranscodeDecider - sonic *sonicsvc.Sonic + ds model.DataStore + artwork artwork.Artwork + streamer core.MediaStreamer + archiver core.Archiver + players core.Players + externalMetadata core.ExternalMetadata + playlists core.Playlists + scanner scanner.Scanner + broker events.Broker + scrobbler scrobbler.PlayTracker + share core.Share } -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 { +func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver, + players core.Players, externalMetadata core.ExternalMetadata, scanner scanner.Scanner, broker events.Broker, + playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share) *Router { r := &Router{ - ds: ds, - artwork: artwork, - streamer: streamer, - archiver: archiver, - players: players, - provider: provider, - playlists: playlists, - scanner: scanner, - broker: broker, - scrobbler: scrobbler, - share: share, - playback: playback, - metrics: metrics, - lyrics: lyrics, - transcodeDecision: transcodeDecision, - sonic: sonic, + ds: ds, + artwork: artwork, + streamer: streamer, + archiver: archiver, + players: players, + externalMetadata: externalMetadata, + playlists: playlists, + scanner: scanner, + broker: broker, + scrobbler: scrobbler, + share: share, } r.Handler = r.routes() return r @@ -87,155 +64,131 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStrea func (api *Router) routes() http.Handler { r := chi.NewRouter() - if conf.Server.Prometheus.Enabled { - r.Use(recordStats(api.metrics)) - } - r.Use(postFormToQueryParams) + r.Use(checkRequiredParameters) + r.Use(authenticate(api.ds)) + // TODO Validate version - // Public - h(r, "getOpenSubsonicExtensions", api.GetOpenSubsonicExtensions) - - // Protected + // Subsonic endpoints, grouped by controller r.Group(func(r chi.Router) { - r.Use(checkRequiredParameters) - r.Use(authenticate(api.ds)) - r.Use(server.UpdateLastAccessMiddleware(api.ds)) - - // Subsonic endpoints, grouped by controller - r.Group(func(r chi.Router) { - r.Use(getPlayer(api.players)) - h(r, "ping", api.Ping) - h(r, "getLicense", api.GetLicense) - h(r, "tokenInfo", api.TokenInfo) - }) - r.Group(func(r chi.Router) { - r.Use(getPlayer(api.players)) - h(r, "getMusicFolders", api.GetMusicFolders) - h(r, "getIndexes", api.GetIndexes) - h(r, "getArtists", api.GetArtists) - h(r, "getGenres", api.GetGenres) - h(r, "getMusicDirectory", api.GetMusicDirectory) - h(r, "getArtist", api.GetArtist) - h(r, "getAlbum", api.GetAlbum) - h(r, "getSong", api.GetSong) - h(r, "getAlbumInfo", api.GetAlbumInfo) - h(r, "getAlbumInfo2", api.GetAlbumInfo) - h(r, "getArtistInfo", api.GetArtistInfo) - h(r, "getArtistInfo2", api.GetArtistInfo2) - 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)) - hr(r, "getAlbumList", api.GetAlbumList) - hr(r, "getAlbumList2", api.GetAlbumList2) - h(r, "getStarred", api.GetStarred) - h(r, "getStarred2", api.GetStarred2) - h(r, "getNowPlaying", api.GetNowPlaying) - h(r, "getRandomSongs", api.GetRandomSongs) - h(r, "getSongsByGenre", api.GetSongsByGenre) - }) - r.Group(func(r chi.Router) { - r.Use(getPlayer(api.players)) - h(r, "setRating", api.SetRating) - 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)) - h(r, "getPlaylists", api.GetPlaylists) - h(r, "getPlaylist", api.GetPlaylist) - h(r, "createPlaylist", api.CreatePlaylist) - h(r, "deletePlaylist", api.DeletePlaylist) - h(r, "updatePlaylist", api.UpdatePlaylist) - }) - r.Group(func(r chi.Router) { - r.Use(getPlayer(api.players)) - h(r, "getBookmarks", api.GetBookmarks) - h(r, "createBookmark", api.CreateBookmark) - h(r, "deleteBookmark", api.DeleteBookmark) - h(r, "getPlayQueue", api.GetPlayQueue) - h(r, "getPlayQueueByIndex", api.GetPlayQueueByIndex) - h(r, "savePlayQueue", api.SavePlayQueue) - h(r, "savePlayQueueByIndex", api.SavePlayQueueByIndex) - }) - r.Group(func(r chi.Router) { - r.Use(getPlayer(api.players)) - h(r, "search2", api.Search2) - h(r, "search3", api.Search3) - }) - r.Group(func(r chi.Router) { - r.Use(getPlayer(api.players)) - h(r, "getUser", api.GetUser) - h(r.With(adminOnly), "getUsers", api.GetUsers) - }) - r.Group(func(r chi.Router) { - r.Use(getPlayer(api.players)) - h(r, "getScanStatus", api.GetScanStatus) - h(r.With(adminOnly), "startScan", api.StartScan) - }) - r.Group(func(r chi.Router) { - r.Use(getPlayer(api.players)) - hr(r, "getAvatar", api.GetAvatar) - h(r, "getLyrics", api.GetLyrics) - h(r, "getLyricsBySongId", api.GetLyricsBySongId) - hr(r, "stream", api.Stream) - hr(r, "download", api.Download) - hr(r, "getTranscodeDecision", api.GetTranscodeDecision) - hr(r, "getTranscodeStream", api.GetTranscodeStream) - }) - r.Group(func(r chi.Router) { - r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, - conf.Server.DevArtworkThrottleBacklogTimeout)) - hr(r, "getCoverArt", api.GetCoverArt) - }) - r.Group(func(r chi.Router) { - r.Use(getPlayer(api.players)) - h(r, "getInternetRadioStations", api.GetInternetRadios) - r.Group(func(r chi.Router) { - r.Use(adminOnly) - h(r, "createInternetRadioStation", api.CreateInternetRadio) - h(r, "deleteInternetRadioStation", api.DeleteInternetRadio) - h(r, "updateInternetRadioStation", api.UpdateInternetRadio) - }) - }) - if conf.Server.EnableSharing { - r.Group(func(r chi.Router) { - r.Use(getPlayer(api.players)) - h(r, "getShares", api.GetShares) - h(r, "createShare", api.CreateShare) - h(r, "updateShare", api.UpdateShare) - h(r, "deleteShare", api.DeleteShare) - }) - } else { - h501(r, "getShares", "createShare", "updateShare", "deleteShare") - } - - if conf.Server.Jukebox.Enabled { - r.Group(func(r chi.Router) { - r.Use(getPlayer(api.players)) - h(r, "jukeboxControl", api.JukeboxControl) - }) - } else { - h501(r, "jukeboxControl") - } - - // Not Implemented (yet?) - h501(r, "getPodcasts", "getNewestPodcasts", "refreshPodcasts", "createPodcastChannel", "deletePodcastChannel", - "deletePodcastEpisode", "downloadPodcastEpisode") - h501(r, "createUser", "updateUser", "deleteUser", "changePassword") - - // Deprecated/Won't implement/Out of scope endpoints - h410(r, "search") - h410(r, "getChatMessages", "addChatMessage") - h410(r, "getVideos", "getVideoInfo", "getCaptions", "hls") + r.Use(getPlayer(api.players)) + h(r, "ping", api.Ping) + h(r, "getLicense", api.GetLicense) }) + r.Group(func(r chi.Router) { + r.Use(getPlayer(api.players)) + h(r, "getMusicFolders", api.GetMusicFolders) + h(r, "getIndexes", api.GetIndexes) + h(r, "getArtists", api.GetArtists) + h(r, "getGenres", api.GetGenres) + h(r, "getMusicDirectory", api.GetMusicDirectory) + h(r, "getArtist", api.GetArtist) + h(r, "getAlbum", api.GetAlbum) + h(r, "getSong", api.GetSong) + h(r, "getAlbumInfo", api.GetAlbumInfo) + h(r, "getAlbumInfo2", api.GetAlbumInfo) + h(r, "getArtistInfo", api.GetArtistInfo) + h(r, "getArtistInfo2", api.GetArtistInfo2) + h(r, "getTopSongs", api.GetTopSongs) + h(r, "getSimilarSongs", api.GetSimilarSongs) + h(r, "getSimilarSongs2", api.GetSimilarSongs2) + }) + r.Group(func(r chi.Router) { + r.Use(getPlayer(api.players)) + hr(r, "getAlbumList", api.GetAlbumList) + hr(r, "getAlbumList2", api.GetAlbumList2) + h(r, "getStarred", api.GetStarred) + h(r, "getStarred2", api.GetStarred2) + h(r, "getNowPlaying", api.GetNowPlaying) + h(r, "getRandomSongs", api.GetRandomSongs) + h(r, "getSongsByGenre", api.GetSongsByGenre) + }) + r.Group(func(r chi.Router) { + r.Use(getPlayer(api.players)) + h(r, "setRating", api.SetRating) + h(r, "star", api.Star) + h(r, "unstar", api.Unstar) + h(r, "scrobble", api.Scrobble) + }) + r.Group(func(r chi.Router) { + r.Use(getPlayer(api.players)) + h(r, "getPlaylists", api.GetPlaylists) + h(r, "getPlaylist", api.GetPlaylist) + h(r, "createPlaylist", api.CreatePlaylist) + h(r, "deletePlaylist", api.DeletePlaylist) + h(r, "updatePlaylist", api.UpdatePlaylist) + }) + r.Group(func(r chi.Router) { + r.Use(getPlayer(api.players)) + h(r, "getBookmarks", api.GetBookmarks) + h(r, "createBookmark", api.CreateBookmark) + h(r, "deleteBookmark", api.DeleteBookmark) + h(r, "getPlayQueue", api.GetPlayQueue) + h(r, "savePlayQueue", api.SavePlayQueue) + }) + r.Group(func(r chi.Router) { + r.Use(getPlayer(api.players)) + h(r, "search2", api.Search2) + h(r, "search3", api.Search3) + }) + r.Group(func(r chi.Router) { + h(r, "getUser", api.GetUser) + h(r, "getUsers", api.GetUsers) + }) + r.Group(func(r chi.Router) { + h(r, "getScanStatus", api.GetScanStatus) + h(r, "startScan", api.StartScan) + }) + r.Group(func(r chi.Router) { + hr(r, "getAvatar", api.GetAvatar) + h(r, "getLyrics", api.GetLyrics) + }) + r.Group(func(r chi.Router) { + // configure request throttling + if conf.Server.DevArtworkMaxRequests > 0 { + log.Debug("Throttling Subsonic getCoverArt endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests, + "backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout", + conf.Server.DevArtworkThrottleBacklogTimeout) + r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, + conf.Server.DevArtworkThrottleBacklogTimeout)) + } + hr(r, "getCoverArt", api.GetCoverArt) + }) + r.Group(func(r chi.Router) { + r.Use(getPlayer(api.players)) + hr(r, "stream", api.Stream) + hr(r, "download", api.Download) + }) + r.Group(func(r chi.Router) { + h(r, "createInternetRadioStation", api.CreateInternetRadio) + h(r, "deleteInternetRadioStation", api.DeleteInternetRadio) + h(r, "getInternetRadioStations", api.GetInternetRadios) + h(r, "updateInternetRadioStation", api.UpdateInternetRadio) + }) + if conf.Server.EnableSharing { + r.Group(func(r chi.Router) { + h(r, "getShares", api.GetShares) + h(r, "createShare", api.CreateShare) + h(r, "updateShare", api.UpdateShare) + h(r, "deleteShare", api.DeleteShare) + }) + } else { + h501(r, "getShares", "createShare", "updateShare", "deleteShare") + } + r.Group(func(r chi.Router) { + h(r, "getOpenSubsonicExtensions", api.GetOpenSubsonicExtensions) + }) + + // Not Implemented (yet?) + h501(r, "jukeboxControl") + h501(r, "getPodcasts", "getNewestPodcasts", "refreshPodcasts", "createPodcastChannel", "deletePodcastChannel", + "deletePodcastEpisode", "downloadPodcastEpisode") + h501(r, "createUser", "updateUser", "deleteUser", "changePassword") + + // Deprecated/Won't implement/Out of scope endpoints + h410(r, "search") + h410(r, "getChatMessages", "addChatMessage") + h410(r, "getVideos", "getVideoInfo", "getCaptions", "hls") return r } @@ -246,16 +199,25 @@ func h(r chi.Router, path string, f handler) { }) } -// Add a Subsonic handler that requires an http.ResponseWriter (ex: stream, getCoverArt...) +// Add a Subsonic handler that requires a http.ResponseWriter (ex: stream, getCoverArt...) func hr(r chi.Router, path string, f handlerRaw) { handle := func(w http.ResponseWriter, r *http.Request) { res, err := f(w, r) if err != nil { + // If it is not a Subsonic error, convert it to an ErrorGeneric + var subErr subError + if !errors.As(err, &subErr) { + if errors.Is(err, model.ErrNotFound) { + err = newError(responses.ErrorDataNotFound, "data not found") + } else { + err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err)) + } + } sendError(w, r, err) return } if r.Context().Err() != nil { - if log.IsGreaterOrEqualTo(log.LevelDebug) { + if log.CurrentLevel() >= log.LevelDebug { log.Warn(r.Context(), "Request was interrupted", "endpoint", r.URL.Path, r.Context().Err()) } return @@ -272,7 +234,7 @@ func h501(r chi.Router, paths ...string) { for _, path := range paths { handle := func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Cache-Control", "no-cache") - w.WriteHeader(http.StatusNotImplemented) + w.WriteHeader(501) _, _ = w.Write([]byte("This endpoint is not implemented, but may be in future releases")) } addHandler(r, path, handle) @@ -283,7 +245,7 @@ func h501(r chi.Router, paths ...string) { func h410(r chi.Router, paths ...string) { for _, path := range paths { handle := func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusGone) + w.WriteHeader(410) _, _ = w.Write([]byte("This endpoint will not be implemented")) } addHandler(r, path, handle) @@ -295,93 +257,39 @@ func addHandler(r chi.Router, path string, handle func(w http.ResponseWriter, r r.HandleFunc("/"+path+".view", handle) } -func mapToSubsonicError(err error) subError { - switch { - case errors.Is(err, errSubsonic): // do nothing - case errors.Is(err, req.ErrMissingParam): - err = newError(responses.ErrorMissingParameter, err.Error()) - case errors.Is(err, req.ErrInvalidParam): - err = newError(responses.ErrorGeneric, err.Error()) - case errors.Is(err, model.ErrNotFound): - err = newError(responses.ErrorDataNotFound, "data not found") - case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, model.ErrPlaylistNotEditable): // Subsonic has no code for "read-only resource" - err = newError(responses.ErrorAuthorizationFail) - case errors.Is(err, stream.ErrTooManyTranscodes): - err = newError(responses.ErrorGeneric, "too many concurrent transcodes, please retry shortly") - default: - err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err)) - } - var subErr subError - errors.As(err, &subErr) - return subErr -} - func sendError(w http.ResponseWriter, r *http.Request, err error) { - if errors.Is(err, stream.ErrTooManyTranscodes) { - w.Header().Set("Retry-After", strconv.Itoa(stream.RetryAfterSeconds)) - sendResponseWithStatus(w, r, errorResponse(err), http.StatusTooManyRequests) - return - } - sendResponse(w, r, errorResponse(err)) -} - -func errorResponse(err error) *responses.Subsonic { - subErr := mapToSubsonicError(err) response := newResponse() - response.Status = responses.StatusFailed - response.Error = &responses.Error{Code: subErr.code, Message: subErr.Error()} - return response + code := responses.ErrorGeneric + var subErr subError + if errors.As(err, &subErr) { + code = subErr.code + } + response.Status = "failed" + response.Error = &responses.Error{Code: int32(code), Message: err.Error()} + + sendResponse(w, r, response) } func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic) { - sendResponseWithStatus(w, r, payload, 0) -} - -// sendResponseWithStatus writes the response body in the format requested by -// the client. When status is non-zero, WriteHeader is called with that code -// before the body is written; callers that need to set additional headers -// (e.g. Retry-After) must set them before calling. -func sendResponseWithStatus(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic, status int) { - p := req.Params(r) - f, _ := p.String("f") + f := utils.ParamString(r, "f") var response []byte - var err error switch f { case "json": w.Header().Set("Content-Type", "application/json") wrapper := &responses.JsonWrapper{Subsonic: *payload} - response, err = json.Marshal(wrapper) + response, _ = json.Marshal(wrapper) case "jsonp": - callback, _ := p.String("callback") - if !validJSIdentifier.MatchString(callback) { - log.Warn(r.Context(), "Invalid JSONP callback parameter", "callback", callback) - w.Header().Set("Content-Type", "application/json") - errResp := newResponse() - errResp.Status = responses.StatusFailed - errResp.Error = &responses.Error{Code: responses.ErrorGeneric, Message: "invalid callback parameter"} - response, _ = json.Marshal(responses.JsonWrapper{Subsonic: *errResp}) - break - } w.Header().Set("Content-Type", "application/javascript") + callback := utils.ParamString(r, "callback") wrapper := &responses.JsonWrapper{Subsonic: *payload} - response, err = json.Marshal(wrapper) - response = fmt.Appendf(nil, "%s(%s)", callback, response) + data, _ := json.Marshal(wrapper) + response = []byte(fmt.Sprintf("%s(%s)", callback, data)) default: w.Header().Set("Content-Type", "application/xml") - response, err = xml.Marshal(payload) + response, _ = xml.Marshal(payload) } - // This should never happen, but if it does, we need to know - if err != nil { - log.Error(r.Context(), "Error marshalling response", "format", f, err) - sendError(w, r, err) - return - } - if status != 0 { - w.WriteHeader(status) - } - - if payload.Status == responses.StatusOK { - if log.IsGreaterOrEqualTo(log.LevelTrace) { + if payload.Status == "ok" { + if log.CurrentLevel() >= log.LevelTrace { log.Debug(r.Context(), "API: Successful response", "endpoint", r.URL.Path, "status", "OK", "body", string(response)) } else { log.Debug(r.Context(), "API: Successful response", "endpoint", r.URL.Path, "status", "OK") @@ -389,22 +297,7 @@ func sendResponseWithStatus(w http.ResponseWriter, r *http.Request, payload *res } else { log.Warn(r.Context(), "API: Failed response", "endpoint", r.URL.Path, "error", payload.Error.Code, "message", payload.Error.Message) } - - statusPointer, ok := r.Context().Value(subsonicErrorPointer).(*int32) - - if ok && statusPointer != nil { - if payload.Status == responses.StatusOK { - *statusPointer = 0 - } else { - *statusPointer = payload.Error.Code - } - } - - if _, err := w.Write(response); err != nil { //nolint:gosec - if log.IsGreaterOrEqualTo(log.LevelTrace) { - log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err) - } else { - log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, err) - } + if _, err := w.Write(response); err != nil { + log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err) } } diff --git a/server/subsonic/api_suite_test.go b/server/subsonic/api_suite_test.go index 485daca58..4d5b293e5 100644 --- a/server/subsonic/api_suite_test.go +++ b/server/subsonic/api_suite_test.go @@ -1,34 +1,15 @@ package subsonic import ( - "io/fs" "testing" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/storage/local" "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" ) func TestSubsonicApi(t *testing.T) { - tests.Init(t, false) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) RunSpecs(t, "Subsonic API Suite") } - -// newLocalStorage fatals if the default extractor is not registered. -// Register a no-op so storage.For works in sidecar-lyrics tests. -var _ = BeforeSuite(func() { - local.RegisterExtractor(consts.DefaultScannerExtractor, func(fs.FS, string) local.Extractor { - return &subsonicNoopExtractor{} - }) -}) - -type subsonicNoopExtractor struct{} - -func (e *subsonicNoopExtractor) Parse(_ ...string) (map[string]metadata.Info, error) { return nil, nil } -func (e *subsonicNoopExtractor) Version() string { return "noop" } diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go deleted file mode 100644 index f8d5b6642..000000000 --- a/server/subsonic/api_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package subsonic - -import ( - "context" - "encoding/json" - "encoding/xml" - "errors" - "fmt" - "math" - "net/http" - "net/http/httptest" - "strings" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("sendResponse", func() { - var ( - w *httptest.ResponseRecorder - r *http.Request - payload *responses.Subsonic - ) - - BeforeEach(func() { - w = httptest.NewRecorder() - r = httptest.NewRequest("GET", "/somepath", nil) - payload = &responses.Subsonic{ - Status: responses.StatusOK, - Version: "1.16.1", - } - }) - - When("format is JSON", func() { - It("should set Content-Type to application/json and return the correct body", func() { - q := r.URL.Query() - q.Add("f", "json") - r.URL.RawQuery = q.Encode() - - sendResponse(w, r, payload) - - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - Expect(w.Body.String()).NotTo(BeEmpty()) - - var wrapper responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &wrapper) - Expect(err).NotTo(HaveOccurred()) - Expect(wrapper.Subsonic.Status).To(Equal(payload.Status)) - Expect(wrapper.Subsonic.Version).To(Equal(payload.Version)) - }) - }) - - When("format is JSONP", func() { - It("should set Content-Type to application/javascript and return the correct callback body", func() { - q := r.URL.Query() - q.Add("f", "jsonp") - q.Add("callback", "testCallback") - r.URL.RawQuery = q.Encode() - - sendResponse(w, r, payload) - - Expect(w.Header().Get("Content-Type")).To(Equal("application/javascript")) - body := w.Body.String() - Expect(body).To(SatisfyAll( - HavePrefix("testCallback("), - HaveSuffix(")"), - )) - - // Extract JSON from the JSONP response - jsonBody := body[strings.Index(body, "(")+1 : strings.LastIndex(body, ")")] - var wrapper responses.JsonWrapper - err := json.Unmarshal([]byte(jsonBody), &wrapper) - Expect(err).NotTo(HaveOccurred()) - Expect(wrapper.Subsonic.Status).To(Equal(payload.Status)) - }) - - It("should accept valid callback names with dots", func() { - q := r.URL.Query() - q.Add("f", "jsonp") - q.Add("callback", "jQuery.callback_123") - r.URL.RawQuery = q.Encode() - - sendResponse(w, r, payload) - - body := w.Body.String() - Expect(body).To(HavePrefix("jQuery.callback_123(")) - }) - - It("should reject callback with invalid characters", func() { - q := r.URL.Query() - q.Add("f", "jsonp") - q.Add("callback", "alert(1)//") - r.URL.RawQuery = q.Encode() - - sendResponse(w, r, payload) - - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var wrapper responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &wrapper) - Expect(err).NotTo(HaveOccurred()) - Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed)) - Expect(wrapper.Subsonic.Error.Message).To(ContainSubstring("invalid callback parameter")) - }) - - It("should reject empty callback parameter", func() { - q := r.URL.Query() - q.Add("f", "jsonp") - q.Add("callback", "") - r.URL.RawQuery = q.Encode() - - sendResponse(w, r, payload) - - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var wrapper responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &wrapper) - Expect(err).NotTo(HaveOccurred()) - Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed)) - }) - }) - - When("format is XML or unspecified", func() { - It("should set Content-Type to application/xml and return the correct body", func() { - // No format specified, expecting XML by default - sendResponse(w, r, payload) - - Expect(w.Header().Get("Content-Type")).To(Equal("application/xml")) - var subsonicResponse responses.Subsonic - err := xml.Unmarshal(w.Body.Bytes(), &subsonicResponse) - Expect(err).NotTo(HaveOccurred()) - Expect(subsonicResponse.Status).To(Equal(payload.Status)) - Expect(subsonicResponse.Version).To(Equal(payload.Version)) - }) - }) - - When("an error occurs during marshalling", func() { - It("should return a fail response", func() { - payload.Song = &responses.Child{OpenSubsonicChild: &responses.OpenSubsonicChild{}} - // An +Inf value will cause an error when marshalling to JSON - payload.Song.ReplayGain = responses.ReplayGain{TrackGain: new(math.Inf(1))} - q := r.URL.Query() - q.Add("f", "json") - r.URL.RawQuery = q.Encode() - - sendResponse(w, r, payload) - - Expect(w.Code).To(Equal(http.StatusOK)) - var wrapper responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &wrapper) - Expect(err).NotTo(HaveOccurred()) - Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed)) - Expect(wrapper.Subsonic.Version).To(Equal(payload.Version)) - Expect(wrapper.Subsonic.Error.Message).To(ContainSubstring("json: unsupported value: +Inf")) - }) - }) - - It("responds with HTTP 429 and Retry-After when the transcode limiter rejects", func() { - w = httptest.NewRecorder() - r = httptest.NewRequest("GET", "/rest/stream", nil) - - sendError(w, r, fmt.Errorf("rejected: %w", stream.ErrTooManyTranscodes)) - - Expect(w.Code).To(Equal(http.StatusTooManyRequests)) - Expect(w.Header().Get("Retry-After")).ToNot(BeEmpty()) - - var subsonicResponse responses.Subsonic - err := xml.Unmarshal(w.Body.Bytes(), &subsonicResponse) - Expect(err).NotTo(HaveOccurred()) - Expect(subsonicResponse.Status).To(Equal(responses.StatusFailed)) - Expect(subsonicResponse.Error).ToNot(BeNil()) - Expect(subsonicResponse.Error.Code).To(Equal(responses.ErrorGeneric)) - Expect(subsonicResponse.Error.Message).To(ContainSubstring("transcode")) - }) - - It("updates status pointer when an error occurs", func() { - pointer := int32(0) - - ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) //nolint:govet - r = r.WithContext(ctx) - - payload.Status = responses.StatusFailed - payload.Error = &responses.Error{Code: responses.ErrorDataNotFound} - - sendResponse(w, r, payload) - Expect(w.Code).To(Equal(http.StatusOK)) - - Expect(pointer).To(Equal(responses.ErrorDataNotFound)) - }) -}) - -var _ = Describe("mapToSubsonicError", func() { - DescribeTable("maps repository errors to the correct Subsonic error code", - func(err error, expectedCode int32) { - subErr := mapToSubsonicError(err) - Expect(subErr.code).To(Equal(expectedCode)) - }, - Entry("rest.ErrPermissionDenied -> not authorized (50)", - rest.ErrPermissionDenied, responses.ErrorAuthorizationFail), - Entry("rest.ErrNotFound -> data not found (70)", - rest.ErrNotFound, responses.ErrorDataNotFound), - Entry("model.ErrNotAuthorized -> not authorized (50)", - model.ErrNotAuthorized, responses.ErrorAuthorizationFail), - Entry("model.ErrNotFound -> data not found (70)", - model.ErrNotFound, responses.ErrorDataNotFound), - Entry("wrapped rest.ErrPermissionDenied is still mapped", - fmt.Errorf("update share: %w", rest.ErrPermissionDenied), responses.ErrorAuthorizationFail), - Entry("unknown error -> generic (0)", - errors.New("boom"), responses.ErrorGeneric), - ) -}) diff --git a/server/subsonic/auth_limiter.go b/server/subsonic/auth_limiter.go deleted file mode 100644 index 88df6eb50..000000000 --- a/server/subsonic/auth_limiter.go +++ /dev/null @@ -1,134 +0,0 @@ -package subsonic - -import ( - "cmp" - "context" - "hash/maphash" - "sync" - "time" - - "github.com/navidrome/navidrome/consts" -) - -// authLimiter caps failed Subsonic logins per key. Checks run at most `limit` at a time and failures -// are recorded afterwards, so a window admits up to 2*limit-1 guesses and valid requests only wait. -type authLimiter struct { - limit int - window time.Duration - seed maphash.Seed - mu sync.Mutex - keys map[uint64]*authAttempts // hashed, so attacker-chosen usernames cannot bloat memory - lastSweep time.Time -} - -type authAttempts struct { - failures int - start time.Time - slots chan struct{} - refs int -} - -// authSlot is a reserved credential check. A nil slot releases nothing, which is what a disabled -// limiter hands back. -type authSlot struct { - limiter *authLimiter - entry *authAttempts -} - -// newAuthLimiter returns nil when limit is not positive. A nil limiter allows everything. -func newAuthLimiter(limit int, window time.Duration) *authLimiter { - if limit <= 0 { - return nil - } - return &authLimiter{ - limit: limit, - window: cmp.Or(window, consts.DefaultAuthWindowLength), - seed: maphash.MakeSeed(), - keys: map[uint64]*authAttempts{}, - } -} - -// acquire reserves a credential check for key, waiting while other checks for the same key are in -// flight. It only fails when the key already reached `limit` failures in the current window. -func (l *authLimiter) acquire(ctx context.Context, key string) (*authSlot, bool) { - if l == nil { - return nil, true - } - a, ok := l.reserve(key) - if !ok { - return nil, false - } - - select { - case a.slots <- struct{}{}: - case <-ctx.Done(): - l.unref(a) - return nil, false - } - - l.mu.Lock() - blocked := a.failures >= l.limit - if blocked { - a.refs-- - } - l.mu.Unlock() - if blocked { - <-a.slots - return nil, false - } - return &authSlot{limiter: l, entry: a}, true -} - -func (l *authLimiter) reserve(key string) (*authAttempts, bool) { - now := time.Now() - l.mu.Lock() - defer l.mu.Unlock() - l.sweep(now) - - h := maphash.String(l.seed, key) - a := l.keys[h] - switch { - case a == nil: - a = &authAttempts{start: now, slots: make(chan struct{}, l.limit)} - l.keys[h] = a - case now.Sub(a.start) >= l.window: - a.failures, a.start = 0, now - } - if a.failures >= l.limit { - return nil, false - } - a.refs++ - return a, true -} - -func (l *authLimiter) unref(a *authAttempts) { - l.mu.Lock() - a.refs-- - l.mu.Unlock() -} - -func (s *authSlot) release(failed bool) { - if s == nil { - return - } - s.limiter.mu.Lock() - if failed { - s.entry.failures++ - } - s.entry.refs-- - s.limiter.mu.Unlock() - <-s.entry.slots -} - -// sweep drops idle expired keys once per window, so memory is bounded by recent attempts. -func (l *authLimiter) sweep(now time.Time) { - if now.Sub(l.lastSweep) < l.window { - return - } - for h, a := range l.keys { - if a.refs == 0 && now.Sub(a.start) >= l.window { - delete(l.keys, h) - } - } - l.lastSweep = now -} diff --git a/server/subsonic/auth_limiter_test.go b/server/subsonic/auth_limiter_test.go deleted file mode 100644 index 66e1a594f..000000000 --- a/server/subsonic/auth_limiter_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package subsonic - -import ( - "context" - "sync" - "sync/atomic" - "testing" - "testing/synctest" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("authLimiter", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - }) - - acquire := func(l *authLimiter, key string) (*authSlot, bool) { - GinkgoHelper() - return l.acquire(ctx, key) - } - - It("blocks a key after the configured number of failures", func() { - l := newAuthLimiter(2, time.Minute) - for range 2 { - slot, ok := acquire(l, "k") - Expect(ok).To(BeTrue()) - slot.release(true) - } - - _, ok := acquire(l, "k") - Expect(ok).To(BeFalse()) - }) - - It("never counts successful checks", func() { - l := newAuthLimiter(2, time.Minute) - for range 50 { - slot, ok := acquire(l, "k") - Expect(ok).To(BeTrue()) - slot.release(false) - } - }) - - It("keeps keys independent", func() { - l := newAuthLimiter(1, time.Minute) - slot, _ := acquire(l, "a") - slot.release(true) - _, ok := acquire(l, "a") - Expect(ok).To(BeFalse()) - - _, ok = acquire(l, "b") - Expect(ok).To(BeTrue()) - }) - - It("waits for an in-flight check instead of failing the request", func() { - l := newAuthLimiter(1, time.Minute) - held, ok := acquire(l, "k") - Expect(ok).To(BeTrue()) - - waiting := make(chan bool, 1) - go func() { - slot, ok := l.acquire(ctx, "k") - slot.release(false) - waiting <- ok - }() - Consistently(waiting, 50*time.Millisecond).ShouldNot(Receive()) - - held.release(false) - Eventually(waiting).Should(Receive(BeTrue())) - }) - - It("stops waiting when the request is canceled", func() { - l := newAuthLimiter(1, time.Minute) - held, _ := acquire(l, "k") - DeferCleanup(func() { held.release(false) }) - - canceled, cancel := context.WithCancel(context.Background()) - cancel() - _, ok := l.acquire(canceled, "k") - Expect(ok).To(BeFalse()) - }) - - It("does not let concurrent guesses overshoot the limit", func() { - l := newAuthLimiter(5, time.Minute) - hold := make(chan struct{}) - var checks atomic.Int32 - var wg sync.WaitGroup - for range 50 { - wg.Go(func() { - slot, ok := l.acquire(ctx, "k") - if !ok { - return - } - checks.Add(1) - <-hold - slot.release(true) - }) - } - - Eventually(checks.Load).Should(Equal(int32(5))) - Consistently(checks.Load, 100*time.Millisecond).Should(Equal(int32(5))) - close(hold) - wg.Wait() - - _, ok := acquire(l, "k") - Expect(ok).To(BeFalse()) - }) - - It("allows everything when the limit is disabled", func() { - l := newAuthLimiter(0, time.Minute) - for range 10 { - slot, ok := acquire(l, "k") - Expect(ok).To(BeTrue()) - slot.release(true) - } - }) -}) - -// testing/synctest's fake clock needs a *testing.T, which Ginkgo doesn't give. -func TestAuthLimiterWindow(t *testing.T) { - synctest.Test(t, func(t *testing.T) { - g := NewWithT(t) - ctx := context.Background() - l := newAuthLimiter(1, 20*time.Second) - for _, key := range []string{"a", "b"} { - slot, ok := l.acquire(ctx, key) - g.Expect(ok).To(BeTrue()) - slot.release(true) - } - _, ok := l.acquire(ctx, "a") - g.Expect(ok).To(BeFalse()) - - time.Sleep(20 * time.Second) - slot, ok := l.acquire(ctx, "a") - g.Expect(ok).To(BeTrue()) - slot.release(false) - g.Expect(l.keys).To(HaveLen(1), "expired keys must be dropped") - }) -} diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go index 6a4c4962d..e2dd3daff 100644 --- a/server/subsonic/bookmarks.go +++ b/server/subsonic/bookmarks.go @@ -1,30 +1,28 @@ package subsonic import ( - "errors" "net/http" "time" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils" ) func (api *Router) GetBookmarks(r *http.Request) (*responses.Subsonic, error) { user, _ := request.UserFrom(r.Context()) - repo := api.ds.MediaFile() - bookmarks, err := repo.GetBookmarks(r.Context()) + repo := api.ds.MediaFile(r.Context()) + bmks, err := repo.GetBookmarks() if err != nil { return nil, err } response := newResponse() response.Bookmarks = &responses.Bookmarks{} - response.Bookmarks.Bookmark = slice.Map(bookmarks, func(bmk model.Bookmark) responses.Bookmark { - return responses.Bookmark{ + for _, bmk := range bmks { + b := responses.Bookmark{ Entry: childFromMediaFile(r.Context(), bmk.Item), Position: bmk.Position, Username: user.UserName, @@ -32,30 +30,22 @@ func (api *Router) GetBookmarks(r *http.Request) (*responses.Subsonic, error) { Created: bmk.CreatedAt, Changed: bmk.UpdatedAt, } - }) + response.Bookmarks.Bookmark = append(response.Bookmarks.Bookmark, b) + } return response, nil } func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - id, err := p.String("id") + id, err := requiredParamString(r, "id") if err != nil { return nil, err } - comment, _ := p.String("comment") - position := p.Int64Or("position", 0) + comment := utils.ParamString(r, "comment") + position := utils.ParamInt64(r, "position", 0) - repo := api.ds.MediaFile() - ok, err := repo.Exists(r.Context(), id) - if err != nil { - return nil, err - } - if !ok { - return nil, newError(responses.ErrorDataNotFound, "Song not found") - } - - err = repo.AddBookmark(r.Context(), id, comment, position) + repo := api.ds.MediaFile(r.Context()) + err = repo.AddBookmark(id, comment, position) if err != nil { return nil, err } @@ -63,14 +53,13 @@ func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error) } func (api *Router) DeleteBookmark(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - id, err := p.String("id") + id, err := requiredParamString(r, "id") if err != nil { return nil, err } - repo := api.ds.MediaFile() - err = repo.DeleteBookmark(r.Context(), id) + repo := api.ds.MediaFile(r.Context()) + err = repo.DeleteBookmark(id) if err != nil { return nil, err } @@ -80,59 +69,44 @@ func (api *Router) DeleteBookmark(r *http.Request) (*responses.Subsonic, error) func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) { user, _ := request.UserFrom(r.Context()) - repo := api.ds.PlayQueue() - pq, err := repo.RetrieveWithMediaFiles(r.Context(), user.ID) - if err != nil && !errors.Is(err, model.ErrNotFound) { + repo := api.ds.PlayQueue(r.Context()) + pq, err := repo.Retrieve(user.ID) + if err != nil { return nil, err } - if pq == nil || len(pq.Items) == 0 { - response := newResponse() - response.PlayQueue = &responses.PlayQueue{ - Username: user.UserName, - } - return response, nil - } response := newResponse() - var currentID string - if pq.Current >= 0 && pq.Current < len(pq.Items) { - currentID = pq.Items[pq.Current].ID - } response.PlayQueue = &responses.PlayQueue{ - Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile), - Current: currentID, + Entry: childrenFromMediaFiles(r.Context(), pq.Items), + Current: pq.Current, Position: pq.Position, Username: user.UserName, - Changed: pq.UpdatedAt, + Changed: &pq.UpdatedAt, ChangedBy: pq.ChangedBy, } return response, nil } func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - ids := p.Strings("id") - currentID, _ := p.String("current") - position := p.Int64Or("position", 0) + ids, err := requiredParamStrings(r, "id") + if err != nil { + return nil, err + } + + current := utils.ParamString(r, "current") + position := utils.ParamInt64(r, "position", 0) user, _ := request.UserFrom(r.Context()) client, _ := request.ClientFrom(r.Context()) - items := slice.Map(ids, func(id string) model.MediaFile { - return model.MediaFile{ID: id} - }) - - currentIndex := 0 - for i, id := range ids { - if id == currentID { - currentIndex = i - break - } + var items model.MediaFiles + for _, id := range ids { + items = append(items, model.MediaFile{ID: id}) } pq := &model.PlayQueue{ UserID: user.ID, - Current: currentIndex, + Current: current, Position: position, ChangedBy: client, Items: items, @@ -140,83 +114,8 @@ func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) { UpdatedAt: time.Time{}, } - repo := api.ds.PlayQueue() - err := repo.Store(r.Context(), pq) - if err != nil { - return nil, err - } - return newResponse(), nil -} - -func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) { - user, _ := request.UserFrom(r.Context()) - - repo := api.ds.PlayQueue() - pq, err := repo.RetrieveWithMediaFiles(r.Context(), user.ID) - if err != nil && !errors.Is(err, model.ErrNotFound) { - return nil, err - } - if pq == nil || len(pq.Items) == 0 { - response := newResponse() - response.PlayQueueByIndex = &responses.PlayQueueByIndex{ - Username: user.UserName, - } - return response, nil - } - - response := newResponse() - - var index *int - if len(pq.Items) > 0 { - index = &pq.Current - } - - response.PlayQueueByIndex = &responses.PlayQueueByIndex{ - Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile), - CurrentIndex: index, - Position: pq.Position, - Username: user.UserName, - Changed: pq.UpdatedAt, - ChangedBy: pq.ChangedBy, - } - return response, nil -} - -func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - ids := p.Strings("id") - - position := p.Int64Or("position", 0) - - var err error - var currentIndex int - - if len(ids) > 0 { - currentIndex, err = p.Int("currentIndex") - if err != nil || currentIndex < 0 || currentIndex >= len(ids) { - return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err) - } - } - - items := slice.Map(ids, func(id string) model.MediaFile { - return model.MediaFile{ID: id} - }) - - user, _ := request.UserFrom(r.Context()) - client, _ := request.ClientFrom(r.Context()) - - pq := &model.PlayQueue{ - UserID: user.ID, - Current: currentIndex, - Position: position, - ChangedBy: client, - Items: items, - CreatedAt: time.Time{}, - UpdatedAt: time.Time{}, - } - - repo := api.ds.PlayQueue() - err = repo.Store(r.Context(), pq) + repo := api.ds.PlayQueue(r.Context()) + err = repo.Store(pq) if err != nil { return nil, err } diff --git a/server/subsonic/bookmarks_test.go b/server/subsonic/bookmarks_test.go deleted file mode 100644 index 387ba6ab2..000000000 --- a/server/subsonic/bookmarks_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package subsonic - -import ( - "context" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Bookmarks", func() { - var router *Router - var ds *tests.MockDataStore - var mfRepo *tests.MockMediaFileRepo - var ctx context.Context - - BeforeEach(func() { - ds = &tests.MockDataStore{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - ctx = request.WithUser(context.Background(), model.User{ID: "u1", UserName: "u1"}) - mfRepo = ds.MediaFile().(*tests.MockMediaFileRepo) - mfRepo.SetData(model.MediaFiles{{ID: "visible"}}) - }) - - Describe("CreateBookmark", func() { - It("rejects an id the user cannot read", func() { - r := newGetRequest("id=hidden", "position=1").WithContext(ctx) - - _, err := router.CreateBookmark(r) - - Expect(err).To(HaveOccurred()) - Expect(mapToSubsonicError(err).code).To(Equal(responses.ErrorDataNotFound)) - Expect(mfRepo.BookmarksAdded).To(BeEmpty()) - }) - - It("accepts an id the user can read", func() { - r := newGetRequest("id=visible", "position=1").WithContext(ctx) - - _, err := router.CreateBookmark(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mfRepo.BookmarksAdded).To(ConsistOf("visible")) - }) - }) -}) diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index d74468940..53323db60 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -4,25 +4,23 @@ import ( "context" "errors" "net/http" + "strconv" "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/filter" + "github.com/navidrome/navidrome/server/public" + "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils" ) func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) { - libraries := getUserAccessibleLibraries(r.Context()) - - folders := make([]responses.MusicFolder, len(libraries)) - for i, f := range libraries { - folders[i].Id = int32(f.ID) + mediaFolderList, _ := api.ds.MediaFolder(r.Context()).GetAll() + folders := make([]responses.MusicFolder, len(mediaFolderList)) + for i, f := range mediaFolderList { + folders[i].Id = f.ID folders[i].Name = f.Name } response := newResponse() @@ -30,79 +28,49 @@ func (api *Router) GetMusicFolders(r *http.Request) (*responses.Subsonic, error) return response, nil } -func (api *Router) getArtist(r *http.Request, libIds []int, ifModifiedSince time.Time) (model.ArtistIndexes, int64, error) { +func (api *Router) getArtistIndex(r *http.Request, mediaFolderId int, ifModifiedSince time.Time) (*responses.Indexes, error) { ctx := r.Context() - - lastScanStr, err := api.ds.Property().DefaultGet(ctx, consts.LastScanStartTimeKey, "") + folder, err := api.ds.MediaFolder(ctx).Get(int32(mediaFolderId)) if err != nil { - log.Error(ctx, "Error retrieving last scan start time", err) - return nil, 0, err + log.Error(ctx, "Error retrieving MediaFolder", "id", mediaFolderId, err) + return nil, err } - lastScan := time.Now() - if lastScanStr != "" { - lastScan, err = time.Parse(time.RFC3339, lastScanStr) + + l, err := api.ds.Property(ctx).DefaultGet(model.PropLastScan+"-"+folder.Path, "-1") + if err != nil { + log.Error(ctx, "Error retrieving LastScan property", err) + return nil, err } var indexes model.ArtistIndexes - if lastScan.After(ifModifiedSince) { - indexes, err = api.ds.Artist().GetIndex(ctx, false, libIds, model.RoleAlbumArtist) + ms, _ := strconv.ParseInt(l, 10, 64) + lastModified := utils.ToTime(ms) + if lastModified.After(ifModifiedSince) { + indexes, err = api.ds.Artist(ctx).GetIndex() if err != nil { log.Error(ctx, "Error retrieving Indexes", err) - return nil, 0, err + return nil, err } - if len(indexes) == 0 { - log.Debug(ctx, "No artists found in library", "libId", libIds) - return nil, 0, newError(responses.ErrorDataNotFound, "Library not found or empty") - } - } - - return indexes, lastScan.UnixMilli(), err -} - -func (api *Router) getArtistIndex(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Indexes, error) { - indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince) - if err != nil { - return nil, err } res := &responses.Indexes{ IgnoredArticles: conf.Server.IgnoredArticles, - LastModified: modified, + LastModified: utils.ToMillis(lastModified), } res.Index = make([]responses.Index, len(indexes)) for i, idx := range indexes { res.Index[i].Name = idx.ID - res.Index[i].Artists = slice.MapWithArg(idx.Artists, r, toArtist) - } - return res, nil -} - -func (api *Router) getArtistIndexID3(r *http.Request, libIds []int, ifModifiedSince time.Time) (*responses.Artists, error) { - indexes, modified, err := api.getArtist(r, libIds, ifModifiedSince) - if err != nil { - return nil, err - } - - res := &responses.Artists{ - IgnoredArticles: conf.Server.IgnoredArticles, - LastModified: modified, - } - - res.Index = make([]responses.IndexID3, len(indexes)) - for i, idx := range indexes { - res.Index[i].Name = idx.ID - res.Index[i].Artists = slice.MapWithArg(idx.Artists, r, toArtistID3) + res.Index[i].Artists = toArtists(r, idx.Artists) } return res, nil } func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - musicFolderIds, _ := selectedMusicFolderIds(r, false) - ifModifiedSince := p.TimeOr("ifModifiedSince", time.Time{}) + musicFolderId := utils.ParamInt(r, "musicFolderId", 0) + ifModifiedSince := utils.ParamTime(r, "ifModifiedSince", time.Time{}) - res, err := api.getArtistIndex(r, musicFolderIds, ifModifiedSince) + res, err := api.getArtistIndex(r, musicFolderId, ifModifiedSince) if err != nil { return nil, err } @@ -113,9 +81,8 @@ func (api *Router) GetIndexes(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) GetArtists(r *http.Request) (*responses.Subsonic, error) { - musicFolderIds, _ := selectedMusicFolderIds(r, false) - - res, err := api.getArtistIndexID3(r, musicFolderIds, time.Time{}) + musicFolderId := utils.ParamInt(r, "musicFolderId", 0) + res, err := api.getArtistIndex(r, musicFolderId, time.Time{}) if err != nil { return nil, err } @@ -126,8 +93,7 @@ func (api *Router) GetArtists(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) GetMusicDirectory(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - id, _ := p.String("id") + id := utils.ParamString(r, "id") ctx := r.Context() entity, err := model.GetEntityByID(ctx, api.ds, id) @@ -163,11 +129,10 @@ func (api *Router) GetMusicDirectory(r *http.Request) (*responses.Subsonic, erro } func (api *Router) GetArtist(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - id, _ := p.String("id") + id := utils.ParamString(r, "id") ctx := r.Context() - artist, err := api.ds.Artist().Get(ctx, id) + artist, err := api.ds.Artist(ctx).Get(id) if errors.Is(err, model.ErrNotFound) { log.Error(ctx, "Requested ArtistID not found ", "id", id) return nil, newError(responses.ErrorDataNotFound, "Artist not found") @@ -186,12 +151,11 @@ func (api *Router) GetArtist(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - id, _ := p.String("id") + id := utils.ParamString(r, "id") ctx := r.Context() - album, err := api.ds.Album().Get(ctx, id) + album, err := api.ds.Album(ctx).Get(id) if errors.Is(err, model.ErrNotFound) { log.Error(ctx, "Requested AlbumID not found ", "id", id) return nil, newError(responses.ErrorDataNotFound, "Album not found") @@ -201,7 +165,7 @@ func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) { return nil, err } - mfs, err := api.ds.MediaFile().GetAll(ctx, filter.SongsByAlbum(id)) + mfs, err := api.ds.MediaFile(ctx).GetAll(filter.SongsByAlbum(id)) if err != nil { log.Error(ctx, "Error retrieving tracks from album", "id", id, "name", album.Name, err) return nil, err @@ -213,15 +177,14 @@ func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - id, err := p.String("id") + id, err := requiredParamString(r, "id") ctx := r.Context() if err != nil { return nil, err } - album, err := api.provider.UpdateAlbumInfo(ctx, id) + album, err := api.externalMetadata.UpdateAlbumInfo(ctx, id) if err != nil { return nil, err @@ -230,11 +193,9 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { response := newResponse() response.AlbumInfo = &responses.AlbumInfo{} response.AlbumInfo.Notes = album.Description - if !album.ImageAbsent { - response.AlbumInfo.SmallImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 300) - response.AlbumInfo.MediumImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 600) - response.AlbumInfo.LargeImageUrl = publicurl.ImageURL(r.Context(), album.CoverArtID(), 1200) - } + response.AlbumInfo.SmallImageUrl = public.ImageURL(r, album.CoverArtID(), 150) + response.AlbumInfo.MediumImageUrl = public.ImageURL(r, album.CoverArtID(), 300) + response.AlbumInfo.LargeImageUrl = public.ImageURL(r, album.CoverArtID(), 600) response.AlbumInfo.LastFmUrl = album.ExternalUrl response.AlbumInfo.MusicBrainzID = album.MbzAlbumID @@ -243,11 +204,10 @@ func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - id, _ := p.String("id") + id := utils.ParamString(r, "id") ctx := r.Context() - mf, err := api.ds.MediaFile().Get(ctx, id) + mf, err := api.ds.MediaFile(ctx).Get(id) if errors.Is(err, model.ErrNotFound) { log.Error(r, "Requested MediaFileID not found ", "id", id) return nil, newError(responses.ErrorDataNotFound, "Song not found") @@ -258,13 +218,14 @@ func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) { } response := newResponse() - response.Song = new(childFromMediaFile(ctx, *mf)) + child := childFromMediaFile(ctx, *mf) + response.Song = &child return response, nil } func (api *Router) GetGenres(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - genres, err := api.ds.Genre().GetAll(ctx, model.QueryOptions{Sort: "song_count, album_count, name desc", Order: "desc"}) + genres, err := api.ds.Genre(ctx).GetAll(model.QueryOptions{Sort: "song_count, album_count, name desc", Order: "desc"}) if err != nil { log.Error(r, err) return nil, err @@ -280,69 +241,53 @@ func (api *Router) GetGenres(r *http.Request) (*responses.Subsonic, error) { return response, nil } -func (api *Router) getArtistInfo(r *http.Request) (*responses.ArtistInfoBase, *model.Artists, error) { - ctx := r.Context() - p := req.Params(r) - id, err := p.String("id") - if err != nil { - return nil, nil, err - } - count := p.IntOr("count", 20) - includeNotPresent := p.BoolOr("includeNotPresent", false) - - artist, err := api.provider.UpdateArtistInfo(ctx, id, count, includeNotPresent) - if err != nil { - return nil, nil, err - } - - base := responses.ArtistInfoBase{} - base.Biography = artist.Biography - if !artist.ImageAbsent { - base.SmallImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 300) - base.MediumImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 600) - base.LargeImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 1200) - } - base.LastFmUrl = artist.ExternalUrl - base.MusicBrainzID = artist.MbzArtistID - - return &base, &artist.SimilarArtists, nil -} - func (api *Router) GetArtistInfo(r *http.Request) (*responses.Subsonic, error) { - base, similarArtists, err := api.getArtistInfo(r) + ctx := r.Context() + id, err := requiredParamString(r, "id") + if err != nil { + return nil, err + } + count := utils.ParamInt(r, "count", 20) + includeNotPresent := utils.ParamBool(r, "includeNotPresent", false) + + artist, err := api.externalMetadata.UpdateArtistInfo(ctx, id, count, includeNotPresent) if err != nil { return nil, err } response := newResponse() response.ArtistInfo = &responses.ArtistInfo{} - response.ArtistInfo.ArtistInfoBase = *base - - for _, s := range *similarArtists { + response.ArtistInfo.Biography = artist.Biography + response.ArtistInfo.SmallImageUrl = public.ImageURL(r, artist.CoverArtID(), 150) + response.ArtistInfo.MediumImageUrl = public.ImageURL(r, artist.CoverArtID(), 300) + response.ArtistInfo.LargeImageUrl = public.ImageURL(r, artist.CoverArtID(), 600) + response.ArtistInfo.LastFmUrl = artist.ExternalUrl + response.ArtistInfo.MusicBrainzID = artist.MbzArtistID + for _, s := range artist.SimilarArtists { similar := toArtist(r, s) - if s.ID == "" { - similar.Id = "-1" - } response.ArtistInfo.SimilarArtist = append(response.ArtistInfo.SimilarArtist, similar) } return response, nil } func (api *Router) GetArtistInfo2(r *http.Request) (*responses.Subsonic, error) { - base, similarArtists, err := api.getArtistInfo(r) + info, err := api.GetArtistInfo(r) if err != nil { return nil, err } response := newResponse() response.ArtistInfo2 = &responses.ArtistInfo2{} - response.ArtistInfo2.ArtistInfoBase = *base - - for _, s := range *similarArtists { - similar := toArtistID3(r, s) - if s.ID == "" { - similar.Id = "-1" - } + response.ArtistInfo2.ArtistInfoBase = info.ArtistInfo.ArtistInfoBase + for _, s := range info.ArtistInfo.SimilarArtist { + similar := responses.ArtistID3{} + similar.Id = s.Id + similar.Name = s.Name + similar.AlbumCount = s.AlbumCount + similar.Starred = s.Starred + similar.UserRating = s.UserRating + similar.CoverArt = s.CoverArt + similar.ArtistImageUrl = s.ArtistImageUrl response.ArtistInfo2.SimilarArtist = append(response.ArtistInfo2.SimilarArtist, similar) } return response, nil @@ -350,21 +295,20 @@ func (api *Router) GetArtistInfo2(r *http.Request) (*responses.Subsonic, error) func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - p := req.Params(r) - id, err := p.String("id") + id, err := requiredParamString(r, "id") if err != nil { return nil, err } - count := p.IntOr("count", 50) + count := utils.ParamInt(r, "count", 50) - songs, err := api.provider.SimilarSongs(ctx, id, count) + songs, err := api.externalMetadata.SimilarSongs(ctx, id, count) if err != nil { return nil, err } response := newResponse() response.SimilarSongs = &responses.SimilarSongs{ - Song: slice.MapWithArg(songs, ctx, childFromMediaFile), + Song: childrenFromMediaFiles(ctx, songs), } return response, nil } @@ -384,22 +328,20 @@ func (api *Router) GetSimilarSongs2(r *http.Request) (*responses.Subsonic, error func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - p := req.Params(r) - id, idErr := p.String("id") - artist, err := p.String("artist") - if err != nil && idErr != nil { + artist, err := requiredParamString(r, "artist") + if err != nil { return nil, err } - count := p.IntOr("count", 50) + count := utils.ParamInt(r, "count", 50) - songs, err := api.provider.TopSongs(ctx, artist, id, count) - if err != nil && !errors.Is(err, model.ErrNotFound) { + songs, err := api.externalMetadata.TopSongs(ctx, artist, count) + if err != nil { return nil, err } response := newResponse() response.TopSongs = &responses.TopSongs{ - Song: slice.MapWithArg(songs, ctx, childFromMediaFile), + Song: childrenFromMediaFiles(ctx, songs), } return response, nil } @@ -410,23 +352,20 @@ func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artis dir.Name = artist.Name dir.PlayCount = artist.PlayCount if artist.PlayCount > 0 { - dir.Played = artist.PlayDate + dir.Played = &artist.PlayDate } - dir.AlbumCount = getArtistAlbumCount(artist) + dir.AlbumCount = int32(artist.AlbumCount) dir.UserRating = int32(artist.Rating) - if conf.Server.Subsonic.EnableAverageRating { - dir.AverageRating = artist.AverageRating - } if artist.Starred { - dir.Starred = artist.StarredAt + dir.Starred = &artist.StarredAt } - albums, err := api.ds.Album().GetAll(ctx, filter.AlbumsByArtistID(artist.ID)) + albums, err := api.ds.Album(ctx).GetAllWithoutGenres(filter.AlbumsByArtistID(artist.ID)) if err != nil { return nil, err } - dir.Child = slice.MapWithArg(albums, ctx, childFromAlbum) + dir.Child = childrenFromAlbums(ctx, albums) return dir, nil } @@ -435,46 +374,62 @@ func (api *Router) buildArtist(r *http.Request, artist *model.Artist) (*response a := &responses.ArtistWithAlbumsID3{} a.ArtistID3 = toArtistID3(r, *artist) - albums, err := api.ds.Album().GetAll(ctx, filter.AlbumsByArtistID(artist.ID)) + albums, err := api.ds.Album(ctx).GetAllWithoutGenres(filter.AlbumsByArtistID(artist.ID)) if err != nil { return nil, err } - a.Album = slice.MapWithArg(albums, ctx, buildAlbumID3) + a.Album = childrenFromAlbums(r.Context(), albums) return a, nil } func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album) (*responses.Directory, error) { dir := &responses.Directory{} dir.Id = album.ID - dir.Name = album.FullName() + dir.Name = album.Name dir.Parent = album.AlbumArtistID dir.PlayCount = album.PlayCount if album.PlayCount > 0 { - dir.Played = album.PlayDate + dir.Played = &album.PlayDate } dir.UserRating = int32(album.Rating) - if conf.Server.Subsonic.EnableAverageRating { - dir.AverageRating = album.AverageRating - } dir.SongCount = int32(album.SongCount) - dir.CoverArt = coverArtOrEmpty(album.CoverArtID(), album.ImageAbsent) + dir.CoverArt = album.CoverArtID().String() if album.Starred { - dir.Starred = album.StarredAt + dir.Starred = &album.StarredAt } - mfs, err := api.ds.MediaFile().GetAll(ctx, filter.SongsByAlbum(album.ID)) + mfs, err := api.ds.MediaFile(ctx).GetAll(filter.SongsByAlbum(album.ID)) if err != nil { return nil, err } - dir.Child = slice.MapWithArg(mfs, ctx, childFromMediaFile) + dir.Child = childrenFromMediaFiles(ctx, mfs) return dir, nil } func (api *Router) buildAlbum(ctx context.Context, album *model.Album, mfs model.MediaFiles) *responses.AlbumWithSongsID3 { dir := &responses.AlbumWithSongsID3{} - dir.AlbumID3 = buildAlbumID3(ctx, *album) - dir.Song = slice.MapWithArg(mfs, ctx, childFromMediaFile) + dir.Id = album.ID + dir.Name = album.Name + dir.Artist = album.AlbumArtist + dir.ArtistId = album.AlbumArtistID + dir.CoverArt = album.CoverArtID().String() + dir.SongCount = int32(album.SongCount) + dir.Duration = int32(album.Duration) + dir.PlayCount = album.PlayCount + if album.PlayCount > 0 { + dir.Played = &album.PlayDate + } + dir.Year = int32(album.MaxYear) + dir.Genre = album.Genre + dir.UserRating = int32(album.Rating) + if !album.CreatedAt.IsZero() { + dir.Created = &album.CreatedAt + } + if album.Starred { + dir.Starred = &album.StarredAt + } + dir.Song = childrenFromMediaFiles(ctx, mfs) return dir } diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go deleted file mode 100644 index 71d758cfd..000000000 --- a/server/subsonic/browsing_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package subsonic - -import ( - "context" - "fmt" - "net/http/httptest" - - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/external" - "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" -) - -func contextWithUser(ctx context.Context, userID string, libraryIDs ...int) context.Context { - libraries := make([]model.Library, len(libraryIDs)) - for i, id := range libraryIDs { - libraries[i] = model.Library{ID: id, Name: fmt.Sprintf("Test Library %d", id), Path: fmt.Sprintf("/music/library%d", id)} - } - user := model.User{ - ID: userID, - Libraries: libraries, - } - return request.WithUser(ctx, user) -} - -var _ = Describe("Browsing", func() { - var api *Router - var ctx context.Context - var ds model.DataStore - - BeforeEach(func() { - ds = &tests.MockDataStore{} - auth.Init(ds) - api = &Router{ds: ds} - ctx = context.Background() - }) - - Describe("GetMusicFolders", func() { - It("should return all libraries the user has access", func() { - // Create mock user with libraries - ctx := contextWithUser(ctx, "user-id", 1, 2, 3) - - // Create request - r := httptest.NewRequest("GET", "/rest/getMusicFolders", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.GetMusicFolders(r) - - // Verify results - Expect(err).ToNot(HaveOccurred()) - Expect(response).ToNot(BeNil()) - Expect(response.MusicFolders).ToNot(BeNil()) - Expect(response.MusicFolders.Folders).To(HaveLen(3)) - Expect(response.MusicFolders.Folders[0].Name).To(Equal("Test Library 1")) - Expect(response.MusicFolders.Folders[1].Name).To(Equal("Test Library 2")) - Expect(response.MusicFolders.Folders[2].Name).To(Equal("Test Library 3")) - }) - }) - - Describe("GetIndexes", func() { - It("should validate user access to the specified musicFolderId", func() { - // Create mock user with access to library 1 only - ctx = contextWithUser(ctx, "user-id", 1) - - // Create request with musicFolderId=2 (not accessible) - r := httptest.NewRequest("GET", "/rest/getIndexes?musicFolderId=2", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.GetIndexes(r) - - // Should return error due to lack of access - Expect(err).To(HaveOccurred()) - Expect(response).To(BeNil()) - }) - - It("should default to first accessible library when no musicFolderId specified", func() { - // Create mock user with access to libraries 2 and 3 - ctx = contextWithUser(ctx, "user-id", 2, 3) - - // Setup minimal mock library data for working tests - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) - mockLibRepo.SetData(model.Libraries{ - {ID: 2, Name: "Test Library 2", Path: "/music/library2"}, - {ID: 3, Name: "Test Library 3", Path: "/music/library3"}, - }) - - // Setup mock artist data - mockArtistRepo := ds.Artist().(*tests.MockArtistRepo) - mockArtistRepo.SetData(model.Artists{ - {ID: "1", Name: "Test Artist 1"}, - {ID: "2", Name: "Test Artist 2"}, - }) - - // Create request without musicFolderId - r := httptest.NewRequest("GET", "/rest/getIndexes", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.GetIndexes(r) - - // Should succeed and use first accessible library (2) - Expect(err).ToNot(HaveOccurred()) - Expect(response).ToNot(BeNil()) - Expect(response.Indexes).ToNot(BeNil()) - }) - }) - - Describe("GetArtists", func() { - It("should validate user access to the specified musicFolderId", func() { - // Create mock user with access to library 1 only - ctx = contextWithUser(ctx, "user-id", 1) - - // Create request with musicFolderId=3 (not accessible) - r := httptest.NewRequest("GET", "/rest/getArtists?musicFolderId=3", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.GetArtists(r) - - // Should return error due to lack of access - Expect(err).To(HaveOccurred()) - Expect(response).To(BeNil()) - }) - - It("should default to first accessible library when no musicFolderId specified", func() { - // Create mock user with access to libraries 1 and 2 - ctx = contextWithUser(ctx, "user-id", 1, 2) - - // Setup minimal mock library data for working tests - mockLibRepo := ds.Library().(*tests.MockLibraryRepo) - mockLibRepo.SetData(model.Libraries{ - {ID: 1, Name: "Test Library 1", Path: "/music/library1"}, - {ID: 2, Name: "Test Library 2", Path: "/music/library2"}, - }) - - // Setup mock artist data - mockArtistRepo := ds.Artist().(*tests.MockArtistRepo) - mockArtistRepo.SetData(model.Artists{ - {ID: "1", Name: "Test Artist 1"}, - {ID: "2", Name: "Test Artist 2"}, - }) - - // Create request without musicFolderId - r := httptest.NewRequest("GET", "/rest/getArtists", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.GetArtists(r) - - // Should succeed and use first accessible library (1) - Expect(err).ToNot(HaveOccurred()) - Expect(response).ToNot(BeNil()) - Expect(response.Artist).ToNot(BeNil()) - }) - }) - - Describe("GetAlbumInfo", func() { - It("emits image URLs when the album artwork is unresolved", func() { - api.provider = &fakeInfoProvider{album: &model.Album{ID: "al-1"}} - r := httptest.NewRequest("GET", "/rest/getAlbumInfo?id=al-1", nil) - resp, err := api.GetAlbumInfo(r) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.AlbumInfo.SmallImageUrl).ToNot(BeEmpty()) - Expect(resp.AlbumInfo.LargeImageUrl).ToNot(BeEmpty()) - }) - It("omits image URLs when the album artwork is known absent", func() { - api.provider = &fakeInfoProvider{album: &model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}}} - r := httptest.NewRequest("GET", "/rest/getAlbumInfo?id=al-1", nil) - resp, err := api.GetAlbumInfo(r) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.AlbumInfo.SmallImageUrl).To(BeEmpty()) - Expect(resp.AlbumInfo.MediumImageUrl).To(BeEmpty()) - Expect(resp.AlbumInfo.LargeImageUrl).To(BeEmpty()) - }) - }) - - Describe("GetArtistInfo", func() { - It("emits image URLs when the artist artwork is unresolved", func() { - api.provider = &fakeInfoProvider{artist: &model.Artist{ID: "ar-1"}} - r := httptest.NewRequest("GET", "/rest/getArtistInfo?id=ar-1", nil) - resp, err := api.GetArtistInfo(r) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.ArtistInfo.SmallImageUrl).ToNot(BeEmpty()) - }) - It("omits image URLs when the artist artwork is known absent", func() { - api.provider = &fakeInfoProvider{artist: &model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageAbsent: true}}} - r := httptest.NewRequest("GET", "/rest/getArtistInfo?id=ar-1", nil) - resp, err := api.GetArtistInfo(r) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.ArtistInfo.SmallImageUrl).To(BeEmpty()) - Expect(resp.ArtistInfo.MediumImageUrl).To(BeEmpty()) - Expect(resp.ArtistInfo.LargeImageUrl).To(BeEmpty()) - }) - }) -}) - -type fakeInfoProvider struct { - external.Provider - album *model.Album - artist *model.Artist -} - -func (f *fakeInfoProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) { - return f.album, nil -} - -func (f *fakeInfoProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) { - return f.artist, nil -} diff --git a/server/subsonic/e2e/doc.go b/server/subsonic/e2e/doc.go deleted file mode 100644 index 9435d1f60..000000000 --- a/server/subsonic/e2e/doc.go +++ /dev/null @@ -1,113 +0,0 @@ -// Package e2e provides end-to-end integration tests for the Navidrome Subsonic API. -// -// These tests exercise the full HTTP request/response cycle through the Subsonic API router, -// using a real SQLite database and real repository implementations while stubbing out external -// services (artwork, streaming, scrobbling, etc.) with noop implementations. -// -// # Test Infrastructure -// -// The suite uses [Ginkgo] v2 as the test runner and [Gomega] for assertions. It is invoked -// through the standard Go test entry point [TestSubsonicE2E], which initializes the test -// environment, creates a temporary SQLite database, and runs the specs. -// -// # Setup and Teardown -// -// During [BeforeSuite], the test infrastructure: -// -// 1. Creates a temporary SQLite database with WAL journal mode. -// 2. Initializes the schema via [db.Init]. -// 3. Creates two test users: an admin ("admin") and a regular user ("regular"), -// both with the password "password". -// 4. Creates a single library ("Music Library") backed by a fake in-memory filesystem -// (scheme "fake:///music") using the [storagetest] package. -// 5. Populates the filesystem with a set of test tracks spanning multiple artists, -// albums, genres, and years. -// 6. Runs the scanner to import all metadata into the database. -// 7. Takes a snapshot of the database to serve as a golden baseline for test isolation. -// -// # Test Data -// -// The fake filesystem contains the following music library structure: -// -// Rock/The Beatles/Abbey Road/ -// 01 - Come Together.mp3 (1969, Rock) -// 02 - Something.mp3 (1969, Rock) -// Rock/The Beatles/Help!/ -// 01 - Help.mp3 (1965, Rock) -// Rock/Led Zeppelin/IV/ -// 01 - Stairway To Heaven.mp3 (1971, Rock) -// Jazz/Miles Davis/Kind of Blue/ -// 01 - So What.mp3 (1959, Jazz) -// Pop/ -// 01 - Standalone Track.mp3 (2020, Pop) -// -// # Database Isolation -// -// Before each top-level Describe block, the [setupTestDB] function restores the database -// to its golden snapshot state using SQLite's ATTACH DATABASE mechanism. This copies all -// table data from the snapshot back into the main database, providing each test group with -// a clean, consistent starting state without the overhead of re-scanning the filesystem. -// -// A fresh [subsonic.Router] is also created for each test group, wired with real data store -// repositories and noop stubs for external services: -// -// - noopArtwork: returns [model.ErrNotFound] for all artwork requests. -// - noopStreamer: returns [model.ErrNotFound] for all stream requests. -// - noopArchiver: returns [model.ErrNotFound] for all archive requests. -// - noopProvider: returns empty results for all external metadata lookups. -// - noopPlayTracker: silently discards all scrobble events. -// -// # Request Helpers -// -// Tests build HTTP requests using the [buildReq] helper, which constructs a Subsonic API -// request with authentication parameters (username, password, API version "1.16.1", client -// name "test-client", and JSON format). Convenience wrappers include: -// -// - [doReq]: sends a request as the admin user and returns the parsed JSON response. -// - [doReqWithUser]: sends a request as a specific user. -// - [doRawReq] / [doRawReqWithUser]: returns the raw [httptest.ResponseRecorder] for -// binary content or status code inspection. -// -// Responses are parsed via [parseJSONResponse], which unwraps the Subsonic JSON envelope -// and returns the inner response map. -// -// # Test Organization -// -// Each test file covers a logical group of Subsonic API endpoints: -// -// - subsonic_system_test.go: ping, getLicense, getOpenSubsonicExtensions -// - subsonic_browsing_test.go: getMusicFolders, getIndexes, getArtists, getMusicDirectory, -// getArtist, getAlbum, getSong, getGenres -// - subsonic_searching_test.go: search2, search3 -// - subsonic_album_lists_test.go: getAlbumList, getAlbumList2 -// - subsonic_playlists_test.go: createPlaylist, getPlaylist, getPlaylists, -// updatePlaylist, deletePlaylist -// - subsonic_media_annotation_test.go: star, unstar, getStarred, setRating, scrobble -// - subsonic_media_retrieval_test.go: stream, download, getCoverArt, getAvatar, -// getLyrics, getLyricsBySongId -// - subsonic_bookmarks_test.go: createBookmark, getBookmarks, deleteBookmark, -// savePlayQueue, getPlayQueue -// - subsonic_radio_test.go: getInternetRadioStations, createInternetRadioStation, -// updateInternetRadioStation, deleteInternetRadioStation -// - subsonic_sharing_test.go: createShare, getShares, updateShare, deleteShare -// - subsonic_users_test.go: getUser, getUsers -// - subsonic_scan_test.go: getScanStatus, startScan -// - subsonic_multiuser_test.go: multi-user isolation and permission enforcement -// - subsonic_multilibrary_test.go: multi-library access control and data isolation -// -// Some test groups use Ginkgo's Ordered decorator to run tests sequentially within a block, -// allowing later tests to depend on state created by earlier ones (e.g., creating a playlist -// and then verifying it can be retrieved). -// -// # Running -// -// The e2e tests are included in the standard test suite and can be run with: -// -// make test PKG=./server/subsonic/e2e # Run only e2e tests -// make test # Run all tests including e2e -// make test-race # Run with race detector -// -// [Ginkgo]: https://onsi.github.io/ginkgo/ -// [Gomega]: https://onsi.github.io/gomega/ -// [storagetest]: /core/storage/storagetest -package e2e diff --git a/server/subsonic/e2e/e2e_suite_test.go b/server/subsonic/e2e/e2e_suite_test.go deleted file mode 100644 index fdfb562fd..000000000 --- a/server/subsonic/e2e/e2e_suite_test.go +++ /dev/null @@ -1,435 +0,0 @@ -package e2e - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "testing" - "testing/fstest" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/external" - "github.com/navidrome/navidrome/core/lyrics" - "github.com/navidrome/navidrome/core/metrics" - "github.com/navidrome/navidrome/core/playback" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/scanner" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/subsonic" - "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/tests/harness" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestSubsonicE2E(t *testing.T) { - tests.Init(t, false) - defer db.Close(t.Context()) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Subsonic API E2E Suite") -} - -// Easy aliases for the storagetest package -type _t = map[string]any - -var template = storagetest.Template -var track = storagetest.Track -var file = storagetest.File - -// MusicBrainz ID constants for test data (valid UUID v4 values) -const ( - mbidBeatlesArtist = "b10bbbfc-cf9e-42e0-be17-e2c3e1d2600d" - mbidAbbeyRoadAlbum = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" - mbidAbbeyRoadRelGroup = "d4c3b2a1-f6e5-4b7a-9d8c-1f0e3a2b5c4d" - mbidComeTogether = "11111111-1111-4111-a111-111111111111" // mbz_release_track_id - mbidComeTogetherRec = "22222222-2222-4222-a222-222222222222" // mbz_recording_id - mbidSomething = "33333333-3333-4333-a333-333333333333" // mbz_release_track_id - mbidSomethingRec = "44444444-4444-4444-a444-444444444444" // mbz_recording_id -) - -// lyricFixture reads a public-domain lyric fixture (the same files the parser -// benchmarks use) so the e2e fixtures stay in sync with real-world content, -// including the word-level timing carried by the .elrc and .yaml variants. -func lyricFixture(name string) string { - // tests.Init chdirs to the project root, so reference fixtures from there. - data, err := os.ReadFile(filepath.Join("tests", "fixtures", "lyrics", name)) - Expect(err).ToNot(HaveOccurred(), "reading lyric fixture %q", name) - return string(data) -} - -// firstFixtureLine is the opening lyric line shared by every auld-lang-syne -// fixture; tests assert against it regardless of source format. -const firstFixtureLine = "Should auld acquaintance be forgot," - -// Shared test state -var ( - ctx context.Context - ds *tests.MockDataStore - router *subsonic.Router - streamerSpy *harness.SpyStreamer - goldenDB *harness.DB - lib model.Library - - // Admin user used for most tests - adminUser = model.User{ - ID: "admin-1", - UserName: "admin", - Name: "Admin User", - IsAdmin: true, - } - - // Regular (non-admin) user for permission tests - regularUser = model.User{ - ID: "regular-1", - UserName: "regular", - Name: "Regular User", - IsAdmin: false, - } -) - -// buildTestFS creates the full test filesystem matching the plan -func buildTestFS() storagetest.FakeFS { - abbeyRoad := template(_t{ - "albumartist": "The Beatles", - "artist": "The Beatles", - "album": "Abbey Road", - "year": 1969, - "genre": "Rock", - "musicbrainz_artistid": mbidBeatlesArtist, - "musicbrainz_albumartistid": mbidBeatlesArtist, - "musicbrainz_albumid": mbidAbbeyRoadAlbum, - "musicbrainz_releasegroupid": mbidAbbeyRoadRelGroup, - }) - help := template(_t{"albumartist": "The Beatles", "artist": "The Beatles", "album": "Help!", "year": 1965, "genre": "Rock"}) - ledZepIV := template(_t{"albumartist": "Led Zeppelin", "artist": "Led Zeppelin", "album": "IV", "year": 1971, "genre": "Rock"}) - kindOfBlue := template(_t{"albumartist": "Miles Davis", "artist": "Miles Davis", "album": "Kind of Blue", "year": 1959, "genre": "Jazz"}) - popTrack := template(_t{"albumartist": "Various", "artist": "Various", "album": "Pop", "year": 2020, "genre": "Pop"}) - cowboyBebop := template(_t{"albumartist": "シートベルツ", "artist": "シートベルツ", "album": "COWBOY BEBOP", "year": 1998, "genre": "Jazz"}) - - // Template for diverse-format transcode test tracks - tcBase := _t{"albumartist": "Test Artist", "artist": "Test Artist", "album": "Transcode Formats", "year": 2024, "genre": "Test"} - - // Template for lyrics e2e fixture tracks — isolated under Lyrics/ to keep other suite counts stable - lyricsAlbum := template(_t{"albumartist": "Lyric Tester", "artist": "Lyric Tester", "album": "Lyrics", "year": 2024, "genre": "Test"}) - - return harness.CreateFS(fstest.MapFS{ - // Rock / The Beatles / Abbey Road (with MBIDs) - // Note: "musicbrainz_trackid" is an alias for the musicbrainz_recordingid tag (populates MbzRecordingID), - // "musicbrainz_releasetrackid" is an alias for the musicbrainz_trackid tag (populates MbzReleaseTrackID). - "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together", - _t{"musicbrainz_releasetrackid": mbidComeTogether, "musicbrainz_trackid": mbidComeTogetherRec, "bpm": 120})), - "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", - _t{"musicbrainz_releasetrackid": mbidSomething, "musicbrainz_trackid": mbidSomethingRec})), - // Rock / The Beatles / Help! (no MBIDs) - "Rock/The Beatles/Help!/01 - Help.mp3": help(track(1, "Help!")), - // Rock / Led Zeppelin / IV (no MBIDs) - "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.mp3": ledZepIV(track(1, "Stairway To Heaven")), - // Jazz / Miles Davis / Kind of Blue (no MBIDs) - "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What")), - // Pop (standalone track, no MBIDs) - "Pop/01 - Standalone Track.mp3": popTrack(track(1, "Standalone Track")), - // CJK / シートベルツ / COWBOY BEBOP (Japanese artist, for CJK search tests) - "CJK/シートベルツ/COWBOY BEBOP/01 - プラチナ・ジェット.mp3": cowboyBebop(track(1, "プラチナ・ジェット")), - - // Diverse audio format tracks for transcode e2e tests - "Test/Transcode Formats/01 - TC FLAC Standard.flac": file(tcBase, _t{ - "title": "TC FLAC Standard", "track": 1, "suffix": "flac", - "bitrate": 900, "samplerate": 44100, "bitdepth": 16, "channels": 2, "duration": int64(240), - }), - "Test/Transcode Formats/02 - TC FLAC HiRes.flac": file(tcBase, _t{ - "title": "TC FLAC HiRes", "track": 2, "suffix": "flac", - "bitrate": 3000, "samplerate": 96000, "bitdepth": 24, "channels": 2, "duration": int64(180), - }), - "Test/Transcode Formats/03 - TC ALAC Track.m4a": file(tcBase, _t{ - "title": "TC ALAC Track", "track": 3, "suffix": "m4a", - "bitrate": 900, "samplerate": 44100, "bitdepth": 16, "channels": 2, "duration": int64(200), - }), - "Test/Transcode Formats/04 - TC DSD Track.dsf": file(tcBase, _t{ - "title": "TC DSD Track", "track": 4, "suffix": "dsf", - "bitrate": 5645, "samplerate": 2822400, "bitdepth": 1, "channels": 2, "duration": int64(300), - }), - "Test/Transcode Formats/05 - TC Opus Track.opus": file(tcBase, _t{ - "title": "TC Opus Track", "track": 5, "suffix": "opus", - "bitrate": 128, "samplerate": 48000, "bitdepth": 0, "channels": 2, "duration": int64(210), - }), - "Test/Transcode Formats/06 - TC MKA Opus.mka": file(tcBase, _t{ - "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), - }), - - // Lyrics fixtures (isolated under Lyrics/ to keep other suite counts stable). - // Content comes from tests/fixtures/lyrics (the same public-domain files the - // parser benchmarks use); the .elrc and .yaml variants carry word-level - // timing, which drives the v1 (line-level) vs v2 (enhanced/word-level) tests. - // - // Embedded — lyrics delivered via the "lyrics" tag, parsed at scan time. - // "Enhanced LRC" embeds ELRC (word-level) content; the title is kept generic - // since ELRC is still valid LRC. - "Lyrics/Embedded/01 - Embedded Enhanced LRC.mp3": lyricsAlbum(track(1, "Embedded Enhanced LRC", - _t{"lyrics": lyricFixture("auld-lang-syne.elrc")})), - "Lyrics/Embedded/02 - Embedded Plain.mp3": lyricsAlbum(track(2, "Embedded Plain", - _t{"lyrics": lyricFixture("auld-lang-syne.txt")})), - "Lyrics/Embedded/03 - Embedded TTML.mp3": lyricsAlbum(track(3, "Embedded TTML", - _t{"lyrics": lyricFixture("auld-lang-syne.ttml")})), - - // Sidecar — raw lyric text files read from the library FS at request time via fromExternalFile. - // The scanner skips non-audio extensions (.lrc, .srt, .yaml), so placing them as raw MapFile - // entries is safe: they are visible to the fake FS but invisible to the scanner. - "Lyrics/Sidecar/01 - Sidecar LRC.mp3": lyricsAlbum(track(1, "Sidecar LRC")), - "Lyrics/Sidecar/01 - Sidecar LRC.lrc": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.lrc")), ModTime: time.Now()}, - "Lyrics/Sidecar/02 - Sidecar SRT.mp3": lyricsAlbum(track(2, "Sidecar SRT")), - "Lyrics/Sidecar/02 - Sidecar SRT.srt": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.srt")), ModTime: time.Now()}, - "Lyrics/Sidecar/03 - Sidecar YAML.mp3": lyricsAlbum(track(3, "Sidecar YAML")), - "Lyrics/Sidecar/03 - Sidecar YAML.yaml": &fstest.MapFile{Data: []byte(lyricFixture("auld-lang-syne.yaml")), ModTime: time.Now()}, - - // _empty folder (directory with no audio) - "_empty/.keep": &fstest.MapFile{Data: []byte{}, ModTime: time.Now()}, - }) -} - -// createUser creates a user in the database with the given properties, assigns them to the test -// library, and returns the fully-loaded user (with Libraries populated). -func createUser(id, username, name string, isAdmin bool) model.User { - user := model.User{ - ID: id, - UserName: username, - Name: name, - IsAdmin: isAdmin, - NewPassword: "password", - } - Expect(ds.User().Put(ctx, &user)).To(Succeed()) - Expect(ds.User().SetUserLibraries(ctx, user.ID, []int{lib.ID})).To(Succeed()) - - loadedUser, err := ds.User().FindByUsername(ctx, user.UserName) - Expect(err).ToNot(HaveOccurred()) - user.Libraries = loadedUser.Libraries - return user -} - -// doReq makes a full HTTP round-trip through the router and returns the parsed Subsonic response. -func doReq(endpoint string, params ...string) *responses.Subsonic { - return doReqWithUser(adminUser, endpoint, params...) -} - -// doReqWithUser makes a full HTTP round-trip for the given user and returns the parsed Subsonic response. -func doReqWithUser(user model.User, endpoint string, params ...string) *responses.Subsonic { - w := httptest.NewRecorder() - r := buildReq(user, endpoint, params...) - router.ServeHTTP(w, r) - return parseJSONResponse(w) -} - -// doRawReq returns the raw ResponseRecorder for endpoints that write binary data (stream, download, getCoverArt). -func doRawReq(endpoint string, params ...string) *httptest.ResponseRecorder { - return doRawReqWithUser(adminUser, endpoint, params...) -} - -// doRawReqWithUser returns the raw ResponseRecorder for the given user. -func doRawReqWithUser(user model.User, endpoint string, params ...string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := buildReq(user, endpoint, params...) - router.ServeHTTP(w, r) - return w -} - -// buildReq creates a GET request with Subsonic auth params (u, p, v, c, f=json). -func buildReq(user model.User, endpoint string, params ...string) *http.Request { - if len(params)%2 != 0 { - panic("buildReq: odd number of parameters") - } - q := url.Values{} - q.Add("u", user.UserName) - q.Add("p", "password") - q.Add("v", "1.16.1") - q.Add("c", "test-client") - q.Add("f", "json") - for i := 0; i < len(params); i += 2 { - q.Add(params[i], params[i+1]) - } - return httptest.NewRequest("GET", "/"+endpoint+"?"+q.Encode(), nil) -} - -// buildPostReq creates a POST request with a JSON body and Subsonic auth params in the query string. -func buildPostReq(user model.User, endpoint string, body string, params ...string) *http.Request { - getReq := buildReq(user, endpoint, params...) - r := httptest.NewRequest("POST", getReq.URL.RequestURI(), bytes.NewReader([]byte(body))) - r.Header.Set("Content-Type", "application/json") - return r -} - -// doPostReq makes a POST round-trip as admin and returns the parsed Subsonic response. -func doPostReq(endpoint string, body string, params ...string) *responses.Subsonic { - w := httptest.NewRecorder() - r := buildPostReq(adminUser, endpoint, body, params...) - router.ServeHTTP(w, r) - return parseJSONResponse(w) -} - -// doRawPostReq makes a POST round-trip as admin and returns the raw recorder. -func doRawPostReq(endpoint string, body string, params ...string) *httptest.ResponseRecorder { - w := httptest.NewRecorder() - r := buildPostReq(adminUser, endpoint, body, params...) - router.ServeHTTP(w, r) - return w -} - -// parseJSONResponse parses the JSON response body into a Subsonic response struct. -func parseJSONResponse(w *httptest.ResponseRecorder) *responses.Subsonic { - Expect(w.Code).To(Equal(http.StatusOK)) - var wrapper responses.JsonWrapper - Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed()) - return &wrapper.Subsonic -} - -// --- Noop stub implementations for Router dependencies --- - -// noopArtwork implements artwork.Artwork -type noopArtwork struct{} - -func (n noopArtwork) Get(context.Context, model.ArtworkID, int, bool) (*artwork.Image, error) { - return nil, model.ErrNotFound -} - -func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool) (*artwork.Image, error) { - return &artwork.Image{ReadCloser: io.NopCloser(io.LimitReader(nil, 0))}, nil -} - -// noopArchiver implements core.Archiver -type noopArchiver struct{} - -func (n noopArchiver) ZipAlbum(context.Context, string, string, int, io.Writer) error { - return model.ErrNotFound -} - -func (n noopArchiver) ZipArtist(context.Context, string, string, int, io.Writer) error { - return model.ErrNotFound -} - -func (n noopArchiver) ZipShare(context.Context, *model.Share, io.Writer) error { - return model.ErrNotFound -} - -func (n noopArchiver) ZipPlaylist(context.Context, string, string, int, io.Writer) error { - return model.ErrNotFound -} - -// noopProvider implements external.Provider -type noopProvider struct{} - -func (n noopProvider) UpdateAlbumInfo(_ context.Context, _ string) (*model.Album, error) { - return &model.Album{}, nil -} - -func (n noopProvider) UpdateArtistInfo(_ context.Context, _ string, _ int, _ bool) (*model.Artist, error) { - return &model.Artist{}, nil -} - -func (n noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { - return nil, nil -} - -func (n noopProvider) TopSongs(context.Context, string, string, int) (model.MediaFiles, error) { - return nil, nil -} - -func (n noopProvider) RefreshInfo(context.Context, model.Kind, string) error { - return nil -} - -// Compile-time interface checks -var ( - _ artwork.Artwork = noopArtwork{} - _ core.Archiver = noopArchiver{} - _ external.Provider = noopProvider{} -) - -var _ = BeforeSuite(func() { - ctx = request.WithUser(GinkgoT().Context(), adminUser) - - conf.Server.MusicFolder = "fake:///music" - conf.Server.LyricsPriority = "embedded,.lrc,.srt,.yaml" - conf.Server.DevExternalScanner = false - - buildTestFS() - goldenDB = harness.SetupDB(ctx, &adminUser, ®ularUser) - lib = goldenDB.Library - ctx = request.WithUser(GinkgoT().Context(), adminUser) -}) - -// 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() { - ctx = request.WithUser(GinkgoT().Context(), adminUser) - - DeferCleanup(configtest.SetupConfig()) - DeferCleanup(func() { - // Wait for any background scan (e.g. from startScan endpoint) to finish - // before config cleanup runs, to avoid a data race on conf.Server. - Eventually(scanner.IsScanning).Should(BeFalse()) - }) - conf.Server.MusicFolder = "fake:///music" - conf.Server.DevExternalScanner = false - conf.Server.DevEnableMediaFileProbe = false - - // Restore DB to golden state (no scan needed) - goldenDB.Restore() - - ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - auth.Init(ds) - - // Create the Subsonic Router with real DS, streamer spy, and real Decider - streamerSpy = &harness.SpyStreamer{} - decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) - s := scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) - router = subsonic.New( - ds, - noopArtwork{}, - streamerSpy, - noopArchiver{}, - core.NewPlayers(ds), - noopProvider{}, - s, - events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), - scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), - core.NewShare(ds), - playback.PlaybackServer(nil), - metrics.NewNoopInstance(), - lyrics.NewLyrics(ds, nil), - decider, - nil, - ) -} diff --git a/server/subsonic/e2e/subsonic_album_lists_test.go b/server/subsonic/e2e/subsonic_album_lists_test.go deleted file mode 100644 index e1be3f133..000000000 --- a/server/subsonic/e2e/subsonic_album_lists_test.go +++ /dev/null @@ -1,305 +0,0 @@ -package e2e - -import ( - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Album List Endpoints", func() { - BeforeEach(func() { - setupTestDB() - }) - - Describe("GetAlbumList", func() { - It("type=newest returns albums sorted by creation date", func() { - resp := doReq("getAlbumList", "type", "newest") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(8)) - }) - - It("type=alphabeticalByName sorts albums by name", func() { - resp := doReq("getAlbumList", "type", "alphabeticalByName") - - Expect(resp.AlbumList).ToNot(BeNil()) - albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(8)) - // Verify alphabetical order: Abbey Road, COWBOY BEBOP, Help!, IV, Kind of Blue, Lyrics, Pop, Transcode Formats - Expect(albums[0].Title).To(Equal("Abbey Road")) - Expect(albums[1].Title).To(Equal("COWBOY BEBOP")) - Expect(albums[2].Title).To(Equal("Help!")) - Expect(albums[3].Title).To(Equal("IV")) - Expect(albums[4].Title).To(Equal("Kind of Blue")) - Expect(albums[5].Title).To(Equal("Lyrics")) - Expect(albums[6].Title).To(Equal("Pop")) - Expect(albums[7].Title).To(Equal("Transcode Formats")) - }) - - It("type=alphabeticalByArtist sorts albums by artist name", func() { - resp := doReq("getAlbumList", "type", "alphabeticalByArtist") - - Expect(resp.AlbumList).ToNot(BeNil()) - albums := resp.AlbumList.Album - Expect(albums).To(HaveLen(8)) - // Articles like "The" are stripped for sorting, so "The Beatles" sorts as "Beatles" - // Non-compilations: Beatles (x2), Led Zeppelin, Lyric Tester, Miles Davis, Test Artist, then compilations: Various, then CJK: シートベルツ - Expect(albums[0].Artist).To(Equal("The Beatles")) - Expect(albums[1].Artist).To(Equal("The Beatles")) - Expect(albums[2].Artist).To(Equal("Led Zeppelin")) - Expect(albums[3].Artist).To(Equal("Lyric Tester")) - Expect(albums[4].Artist).To(Equal("Miles Davis")) - Expect(albums[5].Artist).To(Equal("Test Artist")) - Expect(albums[6].Artist).To(Equal("Various")) - Expect(albums[7].Artist).To(Equal("シートベルツ")) - }) - - It("type=random returns albums", func() { - resp := doReq("getAlbumList", "type", "random") - - Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(8)) - }) - - It("type=byGenre filters by genre parameter", func() { - resp := doReq("getAlbumList", "type", "byGenre", "genre", "Jazz") - - Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(2)) - for _, a := range resp.AlbumList.Album { - Expect(a.Genre).To(Equal("Jazz")) - } - }) - - It("type=byYear filters by fromYear/toYear range", func() { - resp := doReq("getAlbumList", "type", "byYear", "fromYear", "1965", "toYear", "1970") - - Expect(resp.AlbumList).ToNot(BeNil()) - // Should include Abbey Road (1969) and Help! (1965) - Expect(resp.AlbumList.Album).To(HaveLen(2)) - years := make([]int32, len(resp.AlbumList.Album)) - for i, a := range resp.AlbumList.Album { - years[i] = a.Year - } - Expect(years).To(ConsistOf(int32(1965), int32(1969))) - }) - - It("respects size parameter", func() { - resp := doReq("getAlbumList", "type", "newest", "size", "2") - - Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(2)) - }) - - It("supports offset for pagination", func() { - // First get all albums sorted by name to know the expected order - resp1 := doReq("getAlbumList", "type", "alphabeticalByName", "size", "5") - allAlbums := resp1.AlbumList.Album - - // Now get with offset=2, size=2 - resp2 := doReq("getAlbumList", "type", "alphabeticalByName", "size", "2", "offset", "2") - - Expect(resp2.AlbumList).ToNot(BeNil()) - Expect(resp2.AlbumList.Album).To(HaveLen(2)) - Expect(resp2.AlbumList.Album[0].Title).To(Equal(allAlbums[2].Title)) - Expect(resp2.AlbumList.Album[1].Title).To(Equal(allAlbums[3].Title)) - }) - - It("returns error when type parameter is missing", func() { - resp := doReq("getAlbumList") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("returns error for unknown type", func() { - resp := doReq("getAlbumList", "type", "invalid_type") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("type=frequent returns empty when no albums have been played", func() { - resp := doReq("getAlbumList", "type", "frequent") - - Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(BeEmpty()) - }) - - It("type=recent returns empty when no albums have been played", func() { - resp := doReq("getAlbumList", "type", "recent") - - Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(BeEmpty()) - }) - }) - - Describe("GetAlbumList - starred type", Ordered, func() { - BeforeAll(func() { - setupTestDB() - - // Star an album so the starred filter returns results - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"album.name": "Abbey Road"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).ToNot(BeEmpty()) - - resp := doReq("star", "albumId", albums[0].ID) - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("type=starred returns only starred albums", func() { - resp := doReq("getAlbumList", "type", "starred") - - Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(1)) - Expect(resp.AlbumList.Album[0].Title).To(Equal("Abbey Road")) - }) - }) - - Describe("GetAlbumList - highest type", Ordered, func() { - BeforeAll(func() { - setupTestDB() - - // Rate an album so the highest filter returns results - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"album.name": "Kind of Blue"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).ToNot(BeEmpty()) - - resp := doReq("setRating", "id", albums[0].ID, "rating", "5") - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("type=highest returns only rated albums", func() { - resp := doReq("getAlbumList", "type", "highest") - - Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(1)) - Expect(resp.AlbumList.Album[0].Title).To(Equal("Kind of Blue")) - }) - }) - - Describe("GetAlbumList2", func() { - It("returns albums in AlbumID3 format", func() { - resp := doReq("getAlbumList2", "type", "alphabeticalByName") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.AlbumList2).ToNot(BeNil()) - albums := resp.AlbumList2.Album - Expect(albums).To(HaveLen(8)) - // Verify AlbumID3 format fields - Expect(albums[0].Name).To(Equal("Abbey Road")) - Expect(albums[0].Id).ToNot(BeEmpty()) - Expect(albums[0].Artist).ToNot(BeEmpty()) - }) - - It("type=newest works correctly", func() { - resp := doReq("getAlbumList2", "type", "newest") - - Expect(resp.AlbumList2).ToNot(BeNil()) - Expect(resp.AlbumList2.Album).To(HaveLen(8)) - }) - }) - - Describe("GetStarred", func() { - It("returns empty lists when nothing is starred", func() { - resp := doReq("getStarred") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Starred).ToNot(BeNil()) - Expect(resp.Starred.Artist).To(BeEmpty()) - Expect(resp.Starred.Album).To(BeEmpty()) - Expect(resp.Starred.Song).To(BeEmpty()) - }) - }) - - Describe("GetStarred2", func() { - It("returns empty lists when nothing is starred", func() { - resp := doReq("getStarred2") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Starred2).ToNot(BeNil()) - Expect(resp.Starred2.Artist).To(BeEmpty()) - Expect(resp.Starred2.Album).To(BeEmpty()) - Expect(resp.Starred2.Song).To(BeEmpty()) - }) - }) - - Describe("GetNowPlaying", func() { - It("returns empty list when nobody is playing", func() { - resp := doReq("getNowPlaying") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.NowPlaying).ToNot(BeNil()) - Expect(resp.NowPlaying.Entry).To(BeEmpty()) - }) - }) - - Describe("GetRandomSongs", func() { - It("returns random songs from library", func() { - resp := doReq("getRandomSongs") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.RandomSongs).ToNot(BeNil()) - Expect(resp.RandomSongs.Songs).ToNot(BeEmpty()) - Expect(resp.RandomSongs.Songs).To(HaveLen(10)) - }) - - It("respects size parameter", func() { - resp := doReq("getRandomSongs", "size", "2") - - Expect(resp.RandomSongs).ToNot(BeNil()) - Expect(resp.RandomSongs.Songs).To(HaveLen(2)) - }) - - It("filters by genre when specified", func() { - resp := doReq("getRandomSongs", "size", "500", "genre", "Jazz") - - Expect(resp.RandomSongs).ToNot(BeNil()) - Expect(resp.RandomSongs.Songs).To(HaveLen(2)) - for _, s := range resp.RandomSongs.Songs { - Expect(s.Genre).To(Equal("Jazz")) - } - }) - }) - - Describe("GetSongsByGenre", func() { - It("returns songs matching the genre", func() { - resp := doReq("getSongsByGenre", "genre", "Rock") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SongsByGenre).ToNot(BeNil()) - // 4 Rock songs: Come Together, Something, Help!, Stairway To Heaven - Expect(resp.SongsByGenre.Songs).To(HaveLen(4)) - for _, song := range resp.SongsByGenre.Songs { - Expect(song.Genre).To(Equal("Rock")) - } - }) - - It("supports count and offset parameters", func() { - // First get all Rock songs - resp1 := doReq("getSongsByGenre", "genre", "Rock", "count", "500") - allSongs := resp1.SongsByGenre.Songs - - // Now get with count=2, offset=1 - resp2 := doReq("getSongsByGenre", "genre", "Rock", "count", "2", "offset", "1") - - Expect(resp2.SongsByGenre).ToNot(BeNil()) - Expect(resp2.SongsByGenre.Songs).To(HaveLen(2)) - Expect(resp2.SongsByGenre.Songs[0].Id).To(Equal(allSongs[1].Id)) - }) - - It("returns empty for non-existent genre", func() { - resp := doReq("getSongsByGenre", "genre", "NonExistentGenre") - - Expect(resp.SongsByGenre).ToNot(BeNil()) - Expect(resp.SongsByGenre.Songs).To(BeEmpty()) - }) - }) -}) diff --git a/server/subsonic/e2e/subsonic_apikey_test.go b/server/subsonic/e2e/subsonic_apikey_test.go deleted file mode 100644 index bb263c15d..000000000 --- a/server/subsonic/e2e/subsonic_apikey_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package e2e - -import ( - "net/http/httptest" - "net/url" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("API key authentication", func() { - var key string - - BeforeEach(func() { - setupTestDB() - userCtx := request.WithUser(ctx, regularUser) - player := &model.Player{ID: "apikey-player", Name: "Phone", UserId: regularUser.ID, Client: "test-client"} - Expect(ds.Player().Put(userCtx, player)).To(Succeed()) - key = "nds_0123456789abcdefghijkl" - Expect(ds.Player().SetAPIKey(userCtx, player.ID, key)).To(Succeed()) - }) - - doKeyReq := func(endpoint, apiKey string) *responses.Subsonic { - q := url.Values{"apiKey": {apiKey}, "v": {"1.16.1"}, "c": {"test-client"}, "f": {"json"}} - w := httptest.NewRecorder() - router.ServeHTTP(w, httptest.NewRequest("GET", "/"+endpoint+"?"+q.Encode(), nil)) - return parseJSONResponse(w) - } - - It("authenticates ping with only the key", func() { - resp := doKeyReq("ping", key) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("reports the key owner in tokenInfo", func() { - resp := doKeyReq("tokenInfo", key) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TokenInfo).ToNot(BeNil()) - Expect(resp.TokenInfo.Username).To(Equal(regularUser.UserName)) - }) - - It("rejects an unknown key with error 44", func() { - resp := doKeyReq("ping", "nds_unknown") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(int32(44))) - }) -}) diff --git a/server/subsonic/e2e/subsonic_artwork_test.go b/server/subsonic/e2e/subsonic_artwork_test.go deleted file mode 100644 index 530588759..000000000 --- a/server/subsonic/e2e/subsonic_artwork_test.go +++ /dev/null @@ -1,328 +0,0 @@ -package e2e - -import ( - "context" - "errors" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "time" - - "github.com/Masterminds/squirrel" - _ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/agents" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/lyrics" - "github.com/navidrome/navidrome/core/metrics" - "github.com/navidrome/navidrome/core/playback" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/scrobbler" - _ "github.com/navidrome/navidrome/core/storage/local" // registers the "file" storage scheme - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/persistence" - "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/scanner" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/public" - "github.com/navidrome/navidrome/server/subsonic" - "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/tests/harness" - "github.com/navidrome/navidrome/utils/cache" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "go.senan.xyz/taglib" -) - -// The serving path streams folder-backed originals via os.Open, which a fake FS cannot back, so -// this suite scans a small REAL on-disk library and drives the real worker and artwork.Artwork. -var _ = Describe("Artwork Serving", Ordered, func() { - var ( - artRouter *subsonic.Router - pubRouter *public.Router - artSvc artwork.Artwork - worker *artwork.Worker - artfulID string - artlessID string - artfulHash string - placeholder []byte - ) - - albumCoverInList := func(albumID string) string { - GinkgoHelper() - resp := doReq("getAlbumList2", "type", "alphabeticalByName", "size", "500") - Expect(resp.AlbumList2).ToNot(BeNil()) - for _, al := range resp.AlbumList2.Album { - if al.Id == albumID { - return al.CoverArt - } - } - Fail("album " + albumID + " not found in getAlbumList2") - return "" - } - - getCover := func(params ...string) *httptest.ResponseRecorder { - GinkgoHelper() - w := httptest.NewRecorder() - r := buildReq(adminUser, "getCoverArt", params...) - artRouter.ServeHTTP(w, r) - return w - } - - BeforeAll(func() { - DeferCleanup(configtest.SetupConfig()) - DeferCleanup(func() { Eventually(scanner.IsScanning).Should(BeFalse()) }) - ctx = request.WithUser(GinkgoT().Context(), adminUser) - - musicDir := GinkgoT().TempDir() - writeArtworkTrack(musicDir, "Artful Artist", "Artful Album", "01 - Come Together", true) - writeArtworkTrack(musicDir, "Artless Artist", "Artless Album", "01 - Lonely", false) - - conf.Server.MusicFolder = musicDir - conf.Server.DevExternalScanner = false - conf.Server.DevEnableMediaFileProbe = false - conf.Server.CoverArtPriority = "cover.jpg" - conf.Server.ArtistArtPriority = "artist.png" // offline: artists resolve absent, out of scope here - conf.Server.EnableMediaFileCoverArt = false - conf.Server.DevArtworkWorkerConcurrency = 1 - conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir()) - conf.Server.EnableSharing = true - conf.Server.DevArtworkMaxRequests = 100 - conf.Server.DevArtworkThrottleBacklogLimit = 100 - conf.Server.DevArtworkThrottleBacklogTimeout = time.Minute - - goldenDB.Restore() - ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} - auth.Init(ds) - - // Re-scanning already-populated data deadlocks the parallel album-refresh phase at the - // harness's MaxOpenConns=1, so wipe the golden content and import this library fresh. - wipeScannedContent() - artLib := model.Library{Name: "Artwork Library", Path: musicDir} - Expect(ds.Library().Put(ctx, &artLib)).To(Succeed()) - Expect(ds.User().SetUserLibraries(ctx, adminUser.ID, []int{artLib.ID})).To(Succeed()) - - s := scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) - _, err := s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - - // Focus the worker on the entities under test. - _, err = db.Db().Exec("DELETE FROM artwork_queue") - Expect(err).ToNot(HaveOccurred()) - - artfulID = albumIDByName("Artful Album") - artlessID = albumIDByName("Artless Album") - - ph, err := resources.FS().Open(consts.PlaceholderAlbumArt) - Expect(err).ToNot(HaveOccurred()) - placeholder, err = io.ReadAll(ph) - Expect(err).ToNot(HaveOccurred()) - _ = ph.Close() - - store := artwork.NewImageStore(GinkgoT().TempDir()) - imgCache := newDummyImageCache(ctx) - ffm := harness.NoopFFmpeg{} - artSvc = artwork.NewArtwork(ds, imgCache, store, ffm) - worker = artwork.NewWorker(ds, store, agents.GetAgents(ds, nil), ffm, events.NoopBroker(), imgCache) - - artRouter = buildArtworkRouter(artSvc) - router = artRouter // so the shared doReq/doRawReq helpers hit the artwork-wired router - pubRouter = public.New(ds, artSvc, streamerSpy, stream.NewTranscodeDecider(ds, ffm), core.NewShare(ds), noopArchiver{}) - }) - - It("emits a bare optimistic coverArt id before the queue is drained", func() { - Expect(albumCoverInList(artfulID)).To(Equal("al-" + artfulID)) - Expect(albumCoverInList(artlessID)).To(Equal("al-" + artlessID)) - }) - - It("drains the queue: folder art is acquired, the artless album settles absent", func() { - // Enqueues the way the serving paths do, so the drain is driven by a plain queue row. - for _, id := range []string{artfulID, artlessID} { - Expect(ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{ - ItemKind: model.KindAlbumArtwork.Prefix(), ItemID: id, - ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump, - })).To(Succeed()) - } - runWorkerUntil(ctx, worker, func() bool { - found, err := ds.Artwork().GetItemArtwork(ctx, model.KindAlbumArtwork, artfulID, model.ImageTypePrimary) - if err != nil || found.Hash == "" { - return false - } - absent, err := ds.Artwork().GetItemArtwork(ctx, model.KindAlbumArtwork, artlessID, model.ImageTypePrimary) - return err == nil && absent.Hash == "" - }) - ia, err := ds.Artwork().GetItemArtwork(ctx, model.KindAlbumArtwork, artfulID, model.ImageTypePrimary) - Expect(err).ToNot(HaveOccurred()) - Expect(ia.Source).To(Equal("folder")) - artfulHash = ia.Hash - Expect(artfulHash).ToNot(BeEmpty()) - }) - - It("promotes the list id to the hash suffix and omits the absent album's coverArt", func() { - Expect(albumCoverInList(artfulID)).To(Equal("al-" + artfulID + "_" + artfulHash)) - Expect(albumCoverInList(artlessID)).To(BeEmpty()) - }) - - It("serves the found album's bytes with revalidation headers for a bare id", func() { - w := getCover("id", "al-"+artfulID) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) - Expect(w.Header().Get("ETag")).To(Equal(`"` + artfulHash + `"`)) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) - }) - - It("serves the found album immutably when the exact hash is requested", func() { - w := getCover("id", "al-"+artfulID+"_"+artfulHash) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) - }) - - It("returns 304 with an empty body when the client already holds the hash", func() { - w := httptest.NewRecorder() - r := buildReq(adminUser, "getCoverArt", "id", "al-"+artfulID) - r.Header.Set("If-None-Match", `"`+artfulHash+`"`) - artRouter.ServeHTTP(w, r) - Expect(w.Code).To(Equal(http.StatusNotModified)) - Expect(w.Body.Bytes()).To(BeEmpty()) - }) - - It("serves the placeholder with no-store for an absent album", func() { - w := getCover("id", "al-"+artlessID) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.Bytes()).To(Equal(placeholder)) - Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) - }) - - // Only adminUser was granted this library, so regularUser must get the placeholder even - // though the artwork state and bytes are perfectly servable by id. - It("serves the placeholder for an album in a library the caller cannot see", func() { - w := httptest.NewRecorder() - artRouter.ServeHTTP(w, buildReq(regularUser, "getCoverArt", "id", "al-"+artfulID)) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.Bytes()).To(Equal(placeholder), "must not leak the real cover") - Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) - - Expect(getCover("id", "al-"+artfulID).Body.Bytes()).ToNot(Equal(placeholder)) - }) - - // An id naming no entity is a different answer from an album with no art: no placeholder. - It("answers error 70 for an id that matches no entity", func() { - w := getCover("id", "al-nosuchalbum") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(ContainSubstring(`"code":70`)) - Expect(w.Body.Bytes()).ToNot(Equal(placeholder)) - }) - - It("serves /share/img immutably for a JWT whose payload carries the hash", func() { - token, err := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), - auth.Claims{ID: "al-" + artfulID + "_" + artfulHash}) - Expect(err).ToNot(HaveOccurred()) - - w := httptest.NewRecorder() - pubRouter.ServeHTTP(w, httptest.NewRequest("GET", "/img/"+token, nil)) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.Bytes()).To(Equal(readArtworkFixture("cover.jpg"))) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) - }) - - It("returns 404 from /share/img for an absent entity", func() { - token, err := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), - auth.Claims{ID: "al-" + artlessID}) - Expect(err).ToNot(HaveOccurred()) - - w := httptest.NewRecorder() - pubRouter.ServeHTTP(w, httptest.NewRequest("GET", "/img/"+token, nil)) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) -}) - -// buildArtworkRouter mirrors setupTestDB's Subsonic wiring but with the real artwork.Artwork. -func buildArtworkRouter(art artwork.Artwork) *subsonic.Router { - decider := stream.NewTranscodeDecider(ds, harness.NoopFFmpeg{}) - s := scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) - return subsonic.New( - ds, art, streamerSpy, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, s, - events.NoopBroker(), playlists.NewPlaylists(ds, artwork.NewUploader(ds)), - scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), - playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(ds, nil), decider, nil, - ) -} - -// wipeScannedContent clears all scanned content so the next scan is a clean import. FKs are -// disabled for the bulk delete, mirroring harness.Restore. -func wipeScannedContent() { - GinkgoHelper() - _, err := db.Db().Exec("PRAGMA foreign_keys = OFF") - Expect(err).ToNot(HaveOccurred()) - for _, t := range []string{"media_file", "album", "artist", "folder", "item_artwork", "artwork_queue", "artwork", "library"} { - _, err = db.Db().Exec(`DELETE FROM "` + t + `"`) - Expect(err).ToNot(HaveOccurred(), "wiping %s", t) - } - _, err = db.Db().Exec("PRAGMA foreign_keys = ON") - Expect(err).ToNot(HaveOccurred()) -} - -func albumIDByName(name string) string { - GinkgoHelper() - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"album.name": name}}) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).To(HaveLen(1), "expected exactly one album named %q", name) - return albums[0].ID -} - -// Distinct ALBUM/ALBUMARTIST tags keep the generated albums from merging into one. -func writeArtworkTrack(root, artist, album, title string, withCover bool) { - GinkgoHelper() - dir := filepath.Join(root, artist, album) - Expect(os.MkdirAll(dir, 0o755)).To(Succeed()) - mp3 := filepath.Join(dir, title+".mp3") - Expect(os.WriteFile(mp3, readArtworkFixture("test.mp3"), 0o600)).To(Succeed()) - Expect(taglib.WriteTags(mp3, map[string][]string{ - "ALBUM": {album}, "ALBUMARTIST": {artist}, "ARTIST": {artist}, "TITLE": {title}, - }, taglib.Clear)).To(Succeed()) - if withCover { - Expect(os.WriteFile(filepath.Join(dir, "cover.jpg"), readArtworkFixture("cover.jpg"), 0o600)).To(Succeed()) - } -} - -func readArtworkFixture(name string) []byte { - GinkgoHelper() - data, err := os.ReadFile(filepath.Join("tests", "fixtures", "artist", "an-album", name)) - Expect(err).ToNot(HaveOccurred()) - return data -} - -// size=0 requests stream originals and never invoke the reader, so this resize cache only has -// to satisfy the constructor. -func newDummyImageCache(ctx context.Context) cache.FileCache { - GinkgoHelper() - c := cache.NewFileCache("SubsonicArtworkE2E", "100MB", "images", 0, - func(context.Context, cache.Item) (io.Reader, error) { - return nil, errors.New("resize not exercised in subsonic artwork e2e") - }) - Eventually(func() bool { return c.Available(ctx) }, 10*time.Second).Should(BeTrue()) - return c -} - -func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bool) { - GinkgoHelper() - runCtx, cancel := context.WithCancel(ctx) - done := make(chan error, 1) - go func() { done <- worker.Run(runCtx) }() - Eventually(until, 10*time.Second, 20*time.Millisecond).Should(BeTrue()) - cancel() - Eventually(done, 2*time.Second).Should(Receive(BeNil())) -} diff --git a/server/subsonic/e2e/subsonic_bookmarks_test.go b/server/subsonic/e2e/subsonic_bookmarks_test.go deleted file mode 100644 index e2d659b9b..000000000 --- a/server/subsonic/e2e/subsonic_bookmarks_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package e2e - -import ( - "fmt" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Bookmark and PlayQueue Endpoints", Ordered, func() { - BeforeAll(func() { - setupTestDB() - }) - - Describe("Bookmark Endpoints", Ordered, func() { - var trackID string - - BeforeAll(func() { - // Get a media file ID from the database to use for bookmarks - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1}) - Expect(err).ToNot(HaveOccurred()) - Expect(mfs).ToNot(BeEmpty()) - trackID = mfs[0].ID - }) - - It("getBookmarks returns empty initially", func() { - resp := doReq("getBookmarks") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Bookmarks).ToNot(BeNil()) - Expect(resp.Bookmarks.Bookmark).To(BeEmpty()) - }) - - It("createBookmark creates a bookmark with position", func() { - resp := doReq("createBookmark", "id", trackID, "position", "12345", "comment", "test bookmark") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("getBookmarks shows the created bookmark", func() { - resp := doReq("getBookmarks") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Bookmarks).ToNot(BeNil()) - Expect(resp.Bookmarks.Bookmark).To(HaveLen(1)) - - bmk := resp.Bookmarks.Bookmark[0] - Expect(bmk.Entry.Id).To(Equal(trackID)) - Expect(bmk.Position).To(Equal(int64(12345))) - Expect(bmk.Comment).To(Equal("test bookmark")) - Expect(bmk.Username).To(Equal(adminUser.UserName)) - }) - - It("deleteBookmark removes the bookmark", func() { - resp := doReq("deleteBookmark", "id", trackID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - - // Verify it's gone - resp = doReq("getBookmarks") - Expect(resp.Bookmarks.Bookmark).To(BeEmpty()) - }) - }) - - Describe("PlayQueue Endpoints", Ordered, func() { - var trackIDs []string - - BeforeAll(func() { - // Get multiple media file IDs from the database - mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 3, Sort: "title"}) - Expect(err).ToNot(HaveOccurred()) - Expect(len(mfs)).To(BeNumerically(">=", 2)) - for _, mf := range mfs { - trackIDs = append(trackIDs, mf.ID) - } - }) - - It("getPlayQueue returns minimum required fields when nothing specified", func() { - resp := doReq("getPlayQueue") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.PlayQueue).ToNot(BeNil()) - Expect(resp.PlayQueue.Entry).To(HaveLen(0)) - Expect(resp.PlayQueue.Current).To(BeEmpty()) - Expect(resp.PlayQueue.Position).To(Equal(int64(0))) - Expect(resp.PlayQueue.Username).To(Equal(adminUser.UserName)) - Expect(resp.PlayQueue.ChangedBy).To(BeEmpty()) - }) - - It("getPlayQueueByIndex returns minimum required fields when nothing specified", func() { - resp := doReq("getPlayQueueByIndex") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.PlayQueueByIndex).ToNot(BeNil()) - Expect(resp.PlayQueueByIndex.Entry).To(HaveLen(0)) - Expect(resp.PlayQueueByIndex.CurrentIndex).To(BeNil()) - Expect(resp.PlayQueueByIndex.Position).To(Equal(int64(0))) - Expect(resp.PlayQueueByIndex.Username).To(Equal(adminUser.UserName)) - Expect(resp.PlayQueueByIndex.ChangedBy).To(BeEmpty()) - }) - - It("savePlayQueue stores current play queue", func() { - resp := doReq("savePlayQueue", - "id", trackIDs[0], - "id", trackIDs[1], - "current", trackIDs[1], - "position", "5000", - ) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("getPlayQueue returns saved queue with tracks", func() { - resp := doReq("getPlayQueue") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.PlayQueue).ToNot(BeNil()) - Expect(resp.PlayQueue.Entry).To(HaveLen(2)) - Expect(resp.PlayQueue.Current).To(Equal(trackIDs[1])) - Expect(resp.PlayQueue.Position).To(Equal(int64(5000))) - Expect(resp.PlayQueue.Username).To(Equal(adminUser.UserName)) - Expect(resp.PlayQueue.ChangedBy).To(Equal("test-client")) - }) - - It("getPlayQueueByIndex returns data with current index", func() { - resp := doReq("getPlayQueueByIndex") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.PlayQueueByIndex).ToNot(BeNil()) - Expect(resp.PlayQueueByIndex.Entry).To(HaveLen(2)) - Expect(resp.PlayQueueByIndex.CurrentIndex).ToNot(BeNil()) - Expect(*resp.PlayQueueByIndex.CurrentIndex).To(Equal(1)) - Expect(resp.PlayQueueByIndex.Position).To(Equal(int64(5000))) - }) - - It("savePlayQueueByIndex stores queue by index", func() { - resp := doReq("savePlayQueueByIndex", - "id", trackIDs[0], - "id", trackIDs[1], - "id", trackIDs[2], - "currentIndex", fmt.Sprintf("%d", 0), - "position", "9999", - ) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - - // Verify with getPlayQueueByIndex - resp = doReq("getPlayQueueByIndex") - Expect(resp.PlayQueueByIndex).ToNot(BeNil()) - Expect(resp.PlayQueueByIndex.Entry).To(HaveLen(3)) - Expect(resp.PlayQueueByIndex.CurrentIndex).ToNot(BeNil()) - Expect(*resp.PlayQueueByIndex.CurrentIndex).To(Equal(0)) - Expect(resp.PlayQueueByIndex.Position).To(Equal(int64(9999))) - }) - }) -}) diff --git a/server/subsonic/e2e/subsonic_browsing_test.go b/server/subsonic/e2e/subsonic_browsing_test.go deleted file mode 100644 index 8aa93ee2e..000000000 --- a/server/subsonic/e2e/subsonic_browsing_test.go +++ /dev/null @@ -1,468 +0,0 @@ -package e2e - -import ( - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Browsing Endpoints", func() { - BeforeEach(func() { - setupTestDB() - }) - - getBeatlesId := func() string { - artists, err := ds.Artist().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"name": "The Beatles"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).ToNot(BeEmpty()) - return artists[0].ID - } - - Describe("getMusicFolders", func() { - It("returns the configured music library", func() { - resp := doReq("getMusicFolders") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.MusicFolders).ToNot(BeNil()) - Expect(resp.MusicFolders.Folders).To(HaveLen(1)) - Expect(resp.MusicFolders.Folders[0].Name).To(Equal("Music Library")) - Expect(resp.MusicFolders.Folders[0].Id).To(Equal(int32(lib.ID))) - }) - }) - - Describe("getIndexes", func() { - It("returns artist indexes", func() { - resp := doReq("getIndexes") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Indexes).ToNot(BeNil()) - Expect(resp.Indexes.Index).ToNot(BeEmpty()) - }) - - It("includes all artists across indexes", func() { - resp := doReq("getIndexes") - - var allArtistNames []string - for _, idx := range resp.Indexes.Index { - for _, a := range idx.Artists { - allArtistNames = append(allArtistNames, a.Name) - } - } - Expect(allArtistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Various")) - }) - }) - - Describe("getArtists", func() { - It("returns artist indexes in ID3 format", func() { - resp := doReq("getArtists") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Artist).ToNot(BeNil()) - Expect(resp.Artist.Index).ToNot(BeEmpty()) - }) - - It("includes all artists across ID3 indexes", func() { - resp := doReq("getArtists") - - var allArtistNames []string - for _, idx := range resp.Artist.Index { - for _, a := range idx.Artists { - allArtistNames = append(allArtistNames, a.Name) - } - } - Expect(allArtistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Various")) - }) - - It("reports correct album counts for artists", func() { - resp := doReq("getArtists") - - var beatlesAlbumCount int32 - for _, idx := range resp.Artist.Index { - for _, a := range idx.Artists { - if a.Name == "The Beatles" { - beatlesAlbumCount = a.AlbumCount - } - } - } - Expect(beatlesAlbumCount).To(Equal(int32(2))) - }) - }) - - Describe("getMusicDirectory", func() { - It("returns an artist directory with its albums as children", func() { - beatlesID := getBeatlesId() - - resp := doReq("getMusicDirectory", "id", beatlesID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Directory).ToNot(BeNil()) - Expect(resp.Directory.Name).To(Equal("The Beatles")) - Expect(resp.Directory.Child).To(HaveLen(2)) // Abbey Road, Help! - }) - - It("returns an album directory with its tracks as children", func() { - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"album.name": "Abbey Road"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).ToNot(BeEmpty()) - abbeyRoadID := albums[0].ID - - resp := doReq("getMusicDirectory", "id", abbeyRoadID) - - Expect(resp.Directory).ToNot(BeNil()) - Expect(resp.Directory.Name).To(Equal("Abbey Road")) - Expect(resp.Directory.Child).To(HaveLen(2)) // Come Together, Something - }) - - It("returns an error for a non-existent ID", func() { - resp := doReq("getMusicDirectory", "id", "non-existent-id") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - }) - - Describe("getArtist", func() { - It("returns artist with albums in ID3 format", func() { - beatlesID := getBeatlesId() - - resp := doReq("getArtist", "id", beatlesID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.ArtistWithAlbumsID3).ToNot(BeNil()) - Expect(resp.ArtistWithAlbumsID3.Name).To(Equal("The Beatles")) - Expect(resp.ArtistWithAlbumsID3.Album).To(HaveLen(2)) - }) - - It("returns album names for the artist", func() { - beatlesID := getBeatlesId() - - resp := doReq("getArtist", "id", beatlesID) - - var albumNames []string - for _, a := range resp.ArtistWithAlbumsID3.Album { - albumNames = append(albumNames, a.Name) - } - Expect(albumNames).To(ContainElements("Abbey Road", "Help!")) - }) - - It("returns an error for a non-existent artist", func() { - resp := doReq("getArtist", "id", "non-existent-id") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("returns artist with a single album", func() { - artists, err := ds.Artist().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"name": "Led Zeppelin"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).ToNot(BeEmpty()) - ledZepID := artists[0].ID - - resp := doReq("getArtist", "id", ledZepID) - - Expect(resp.ArtistWithAlbumsID3).ToNot(BeNil()) - Expect(resp.ArtistWithAlbumsID3.Name).To(Equal("Led Zeppelin")) - Expect(resp.ArtistWithAlbumsID3.Album).To(HaveLen(1)) - Expect(resp.ArtistWithAlbumsID3.Album[0].Name).To(Equal("IV")) - }) - }) - - Describe("getAlbum", func() { - It("returns album with its tracks", func() { - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"album.name": "Abbey Road"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).ToNot(BeEmpty()) - abbeyRoadID := albums[0].ID - - resp := doReq("getAlbum", "id", abbeyRoadID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.AlbumWithSongsID3).ToNot(BeNil()) - Expect(resp.AlbumWithSongsID3.Name).To(Equal("Abbey Road")) - Expect(resp.AlbumWithSongsID3.Song).To(HaveLen(2)) - }) - - It("includes correct track metadata", func() { - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"album.name": "Abbey Road"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).ToNot(BeEmpty()) - abbeyRoadID := albums[0].ID - - resp := doReq("getAlbum", "id", abbeyRoadID) - - var trackTitles []string - for _, s := range resp.AlbumWithSongsID3.Song { - trackTitles = append(trackTitles, s.Title) - } - Expect(trackTitles).To(ContainElements("Come Together", "Something")) - }) - - It("returns album with correct artist and year", func() { - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"album.name": "Kind of Blue"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).ToNot(BeEmpty()) - kindOfBlueID := albums[0].ID - - resp := doReq("getAlbum", "id", kindOfBlueID) - - Expect(resp.AlbumWithSongsID3).ToNot(BeNil()) - Expect(resp.AlbumWithSongsID3.Name).To(Equal("Kind of Blue")) - Expect(resp.AlbumWithSongsID3.Artist).To(Equal("Miles Davis")) - Expect(resp.AlbumWithSongsID3.Year).To(Equal(int32(1959))) - Expect(resp.AlbumWithSongsID3.Song).To(HaveLen(1)) - }) - - It("returns an error for a non-existent album", func() { - resp := doReq("getAlbum", "id", "non-existent-id") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - }) - - Describe("getSong", func() { - It("returns a song by its ID", func() { - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"title": "Come Together"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - songID := songs[0].ID - - resp := doReq("getSong", "id", songID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Song).ToNot(BeNil()) - Expect(resp.Song.Title).To(Equal("Come Together")) - Expect(resp.Song.Album).To(Equal("Abbey Road")) - Expect(resp.Song.Artist).To(Equal("The Beatles")) - }) - - It("returns an error for a non-existent song", func() { - resp := doReq("getSong", "id", "non-existent-id") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("returns correct metadata for a jazz track", func() { - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"title": "So What"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - songID := songs[0].ID - - resp := doReq("getSong", "id", songID) - - Expect(resp.Song).ToNot(BeNil()) - Expect(resp.Song.Title).To(Equal("So What")) - Expect(resp.Song.Album).To(Equal("Kind of Blue")) - Expect(resp.Song.Artist).To(Equal("Miles Davis")) - }) - }) - - Describe("getGenres", func() { - It("returns all genres", func() { - resp := doReq("getGenres") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Genres).ToNot(BeNil()) - Expect(resp.Genres.Genre).To(HaveLen(4)) - }) - - It("includes correct genre names", func() { - resp := doReq("getGenres") - - var genreNames []string - for _, g := range resp.Genres.Genre { - genreNames = append(genreNames, g.Name) - } - Expect(genreNames).To(ContainElements("Rock", "Jazz", "Pop")) - }) - - It("reports correct song and album counts for Rock", func() { - resp := doReq("getGenres") - - var rockGenre *responses.Genre - for i, g := range resp.Genres.Genre { - if g.Name == "Rock" { - rockGenre = &resp.Genres.Genre[i] - break - } - } - Expect(rockGenre).ToNot(BeNil()) - Expect(rockGenre.SongCount).To(Equal(int32(4))) - Expect(rockGenre.AlbumCount).To(Equal(int32(3))) - }) - - It("reports correct song and album counts for Jazz", func() { - resp := doReq("getGenres") - - var jazzGenre *responses.Genre - for i, g := range resp.Genres.Genre { - if g.Name == "Jazz" { - jazzGenre = &resp.Genres.Genre[i] - break - } - } - Expect(jazzGenre).ToNot(BeNil()) - Expect(jazzGenre.SongCount).To(Equal(int32(2))) - Expect(jazzGenre.AlbumCount).To(Equal(int32(2))) - }) - - It("reports correct song and album counts for Pop", func() { - resp := doReq("getGenres") - - var popGenre *responses.Genre - for i, g := range resp.Genres.Genre { - if g.Name == "Pop" { - popGenre = &resp.Genres.Genre[i] - break - } - } - Expect(popGenre).ToNot(BeNil()) - Expect(popGenre.SongCount).To(Equal(int32(1))) - Expect(popGenre.AlbumCount).To(Equal(int32(1))) - }) - }) - - Describe("getAlbumInfo", func() { - It("returns album info for a valid album", func() { - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"album.name": "Abbey Road"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).ToNot(BeEmpty()) - abbeyRoadID := albums[0].ID - - resp := doReq("getAlbumInfo", "id", abbeyRoadID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.AlbumInfo).ToNot(BeNil()) - }) - }) - - Describe("getAlbumInfo2", func() { - It("returns album info for a valid album", func() { - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"album.name": "Abbey Road"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).ToNot(BeEmpty()) - abbeyRoadID := albums[0].ID - - resp := doReq("getAlbumInfo2", "id", abbeyRoadID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.AlbumInfo).ToNot(BeNil()) - }) - }) - - Describe("getArtistInfo", func() { - It("returns artist info for a valid artist", func() { - beatlesID := getBeatlesId() - resp := doReq("getArtistInfo", "id", beatlesID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.ArtistInfo).ToNot(BeNil()) - }) - }) - - Describe("getArtistInfo2", func() { - It("returns artist info2 for a valid artist", func() { - beatlesID := getBeatlesId() - resp := doReq("getArtistInfo2", "id", beatlesID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.ArtistInfo2).ToNot(BeNil()) - }) - }) - - Describe("getTopSongs", func() { - It("returns a response for a known artist name", func() { - resp := doReq("getTopSongs", "artist", "The Beatles") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TopSongs).ToNot(BeNil()) - // noopProvider returns empty list, so Songs may be empty - }) - - It("returns an empty list for an unknown artist", func() { - resp := doReq("getTopSongs", "artist", "Unknown Artist") - - Expect(resp.TopSongs).ToNot(BeNil()) - Expect(resp.TopSongs.Song).To(BeEmpty()) - }) - - It("returns an error if no arguments are specified", func() { - resp := doReq("getTopSongs") - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.TopSongs).To(BeNil()) - }) - - It("returns a response only by id", func() { - beatlesID := getBeatlesId() - resp := doReq("getTopSongs", "id", beatlesID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TopSongs).ToNot(BeNil()) - }) - - It("returns a response when both id and name are included", func() { - beatlesID := getBeatlesId() - resp := doReq("getTopSongs", "id", beatlesID, "artist", "The Beatles") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TopSongs).ToNot(BeNil()) - }) - }) - - Describe("getSimilarSongs", func() { - It("returns a response for a valid song ID", func() { - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"title": "Come Together"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - songID := songs[0].ID - - resp := doReq("getSimilarSongs", "id", songID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SimilarSongs).ToNot(BeNil()) - // noopProvider returns empty list - }) - }) - - Describe("getSimilarSongs2", func() { - It("returns a response for a valid song ID", func() { - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"title": "Come Together"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - songID := songs[0].ID - - resp := doReq("getSimilarSongs2", "id", songID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SimilarSongs2).ToNot(BeNil()) - // noopProvider returns empty list - }) - }) -}) diff --git a/server/subsonic/e2e/subsonic_lyrics_test.go b/server/subsonic/e2e/subsonic_lyrics_test.go deleted file mode 100644 index da8d513a3..000000000 --- a/server/subsonic/e2e/subsonic_lyrics_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package e2e - -import ( - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Lyrics endpoints", func() { - BeforeEach(func() { - setupTestDB() - }) - - // songID resolves a track title to its Subsonic ID via search3. - songID := func(title string) string { - resp := doReq("search3", "query", title, "songCount", "1", "artistCount", "0", "albumCount", "0") - Expect(resp.Status).To(Equal("ok")) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Song).ToNot(BeEmpty(), "expected to find song %q", title) - return resp.SearchResult3.Song[0].Id - } - - // firstLyric extracts the first StructuredLyric from a LyricsList response. - firstLyric := func(list *responses.LyricsList) responses.StructuredLyric { - Expect(list).ToNot(BeNil()) - Expect(list.StructuredLyrics).ToNot(BeEmpty()) - return list.StructuredLyrics[0] - } - - // songLyrics extension v1: getLyricsBySongId without the enhanced parameter - // returns line-level structured lyrics (line[], lang, synced) and must NOT - // emit any v2/enhanced fields — no cueLine, kind, or agents — even for - // formats that carry word-level timing (ELRC, Lyricsfile YAML). - Describe("getLyricsBySongId v1 (line-level, not enhanced)", func() { - DescribeTable("returns line-level lyrics without enhanced fields", - func(title string, wantSynced bool, wantLang string) { - resp := doReq("getLyricsBySongId", "id", songID(title)) - Expect(resp.Status).To(Equal("ok")) - got := firstLyric(resp.LyricsList) - - Expect(got.Synced).To(Equal(wantSynced)) - Expect(got.Lang).To(Equal(wantLang)) - Expect(got.Line).ToNot(BeEmpty()) - Expect(got.Line[0].Value).To(Equal(firstFixtureLine)) - - // v1 must not expose any enhanced (v2) data. - Expect(got.CueLine).To(BeEmpty()) - Expect(got.Kind).To(BeEmpty()) - Expect(got.Agents).To(BeEmpty()) - }, - // "xxx" is the ISO 639-2 code for "no language specified"; the .lrc/.elrc - // fixtures declare [lang:eng], the .ttml declares xml:lang, the .yaml sets - // language: eng, while .srt carries no language and the embedded plain - // text has none — so each format exercises a different language path. - Entry("embedded enhanced LRC (word-level)", "Embedded Enhanced LRC", true, "eng"), - Entry("embedded plain text", "Embedded Plain", false, "xxx"), - Entry("embedded TTML", "Embedded TTML", true, "eng"), - Entry("LRC sidecar", "Sidecar LRC", true, "eng"), - Entry("SRT sidecar", "Sidecar SRT", true, "xxx"), - Entry("YAML sidecar (word-level)", "Sidecar YAML", true, "eng"), - ) - }) - - // songLyrics extension v2: getLyricsBySongId?enhanced=true opts in to - // word/syllable-level timing (cueLine) and the kind classification. Every - // format gains kind="main" for a single untyped lyric layer; only formats - // that carry word-level timing (ELRC, TTML word spans, Lyricsfile YAML) - // surface a cueLine. Line-level formats (LRC, SRT, plain) still yield none. - Describe("getLyricsBySongId v2 (enhanced)", func() { - DescribeTable("returns enhanced lyrics, with cueLine only for word-level sources", - func(title string, wantCueLine bool) { - resp := doReq("getLyricsBySongId", "id", songID(title), "enhanced", "true") - Expect(resp.Status).To(Equal("ok")) - got := firstLyric(resp.LyricsList) - - Expect(got.Kind).To(Equal("main")) - if wantCueLine { - Expect(got.CueLine).ToNot(BeEmpty()) - // The first line has one cue per word: "Should auld acquaintance be forgot,". - Expect(got.CueLine[0].Cue).To(HaveLen(5)) - Expect(got.CueLine[0].Cue[0].Value).To(Equal("Should ")) - } else { - Expect(got.CueLine).To(BeEmpty()) - Expect(got.Line).ToNot(BeEmpty()) - } - }, - Entry("embedded enhanced LRC (word-level)", "Embedded Enhanced LRC", true), - Entry("embedded TTML (word-level spans)", "Embedded TTML", true), - Entry("YAML sidecar (word-level)", "Sidecar YAML", true), - Entry("embedded plain text (no timing)", "Embedded Plain", false), - Entry("LRC sidecar (line-level)", "Sidecar LRC", false), - Entry("SRT sidecar (line-level)", "Sidecar SRT", false), - ) - }) - - // getLyrics is the original Subsonic (pre-OpenSubsonic) endpoint. It looks up - // by artist/title and returns the main lyric flattened to plain text — every - // line's Value joined by newlines, with all timing/markup dropped. Synced and - // word-level formats (ELRC/TTML/SRT/YAML) all degrade to plain text here. - Describe("getLyrics (legacy artist/title)", func() { - DescribeTable("returns the main lyric as plain text across formats and sources", - func(title string) { - resp := doReq("getLyrics", "artist", "Lyric Tester", "title", title) - Expect(resp.Status).To(Equal("ok")) - Expect(resp.Lyrics).ToNot(BeNil()) - Expect(resp.Lyrics.Artist).To(Equal("Lyric Tester")) - Expect(resp.Lyrics.Title).To(Equal(title)) - Expect(resp.Lyrics.Value).To(ContainSubstring(firstFixtureLine)) - - // No timing markup leaks into the plain-text value, regardless of the - // source format: no LRC brackets/word markers, SRT arrows, or XML tags. - Expect(resp.Lyrics.Value).ToNot(ContainSubstring("[")) - Expect(resp.Lyrics.Value).ToNot(ContainSubstring("-->")) - Expect(resp.Lyrics.Value).ToNot(ContainSubstring("<")) - }, - Entry("embedded enhanced LRC", "Embedded Enhanced LRC"), - Entry("embedded plain text", "Embedded Plain"), - Entry("embedded TTML", "Embedded TTML"), - Entry("LRC sidecar", "Sidecar LRC"), - Entry("SRT sidecar", "Sidecar SRT"), - Entry("YAML sidecar", "Sidecar YAML"), - ) - }) -}) diff --git a/server/subsonic/e2e/subsonic_media_annotation_test.go b/server/subsonic/e2e/subsonic_media_annotation_test.go deleted file mode 100644 index 4b90dd143..000000000 --- a/server/subsonic/e2e/subsonic_media_annotation_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package e2e - -import ( - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Media Annotation Endpoints", Ordered, func() { - BeforeAll(func() { - setupTestDB() - }) - - Describe("Star/Unstar", Ordered, func() { - var songID, albumID, artistID string - - BeforeAll(func() { - // Look up a song from the scanned data - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - songID = songs[0].ID - - // Look up an album - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "name"}) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).ToNot(BeEmpty()) - albumID = albums[0].ID - - // Look up an artist - artists, err := ds.Artist().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "name"}) - Expect(err).ToNot(HaveOccurred()) - Expect(artists).ToNot(BeEmpty()) - artistID = artists[0].ID - }) - - It("stars a song by id", func() { - resp := doReq("star", "id", songID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("starred song appears in getStarred response", func() { - resp := doReq("getStarred") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Starred).ToNot(BeNil()) - Expect(resp.Starred.Song).To(HaveLen(1)) - Expect(resp.Starred.Song[0].Id).To(Equal(songID)) - }) - - It("unstars a previously starred song", func() { - resp := doReq("unstar", "id", songID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - - // Verify song no longer appears in starred - resp = doReq("getStarred") - - Expect(resp.Starred.Song).To(BeEmpty()) - }) - - It("stars an album by albumId", func() { - resp := doReq("star", "albumId", albumID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - - // Verify album appears in starred - resp = doReq("getStarred") - - Expect(resp.Starred.Album).To(HaveLen(1)) - Expect(resp.Starred.Album[0].Id).To(Equal(albumID)) - }) - - It("stars an artist by artistId", func() { - resp := doReq("star", "artistId", artistID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - - // Verify artist appears in starred - resp = doReq("getStarred") - - Expect(resp.Starred.Artist).To(HaveLen(1)) - Expect(resp.Starred.Artist[0].Id).To(Equal(artistID)) - }) - - It("returns error when no id provided", func() { - resp := doReq("star") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - }) - - Describe("SetRating", Ordered, func() { - var songID, albumID string - - BeforeAll(func() { - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - songID = songs[0].ID - - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "name"}) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).ToNot(BeEmpty()) - albumID = albums[0].ID - }) - - It("sets rating on a song", func() { - resp := doReq("setRating", "id", songID, "rating", "4") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("rated song has correct userRating in getSong", func() { - resp := doReq("getSong", "id", songID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Song).ToNot(BeNil()) - Expect(resp.Song.UserRating).To(Equal(int32(4))) - }) - - It("sets rating on an album", func() { - resp := doReq("setRating", "id", albumID, "rating", "3") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("returns error for missing parameters", func() { - // Missing both id and rating - resp := doReq("setRating") - Expect(resp.Status).To(Equal(responses.StatusFailed)) - - // Missing rating - resp = doReq("setRating", "id", songID) - Expect(resp.Status).To(Equal(responses.StatusFailed)) - }) - }) - - Describe("Scrobble", func() { - It("submits a scrobble for a song", func() { - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - - resp := doReq("scrobble", "id", songs[0].ID, "submission", "true") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("returns error when id is missing", func() { - resp := doReq("scrobble") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) - }) - }) - - Describe("ReportPlayback", Ordered, func() { - var songID string - - BeforeAll(func() { - songs, err := ds.MediaFile().GetAll(ctx, 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/subsonic/e2e/subsonic_media_retrieval_test.go b/server/subsonic/e2e/subsonic_media_retrieval_test.go deleted file mode 100644 index b0ef4fd72..000000000 --- a/server/subsonic/e2e/subsonic_media_retrieval_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package e2e - -import ( - "net/http" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Media Retrieval Endpoints", Ordered, func() { - BeforeAll(func() { - setupTestDB() - }) - - Describe("Stream", func() { - var trackID string - - BeforeAll(func() { - // All test tracks are mp3 at 320kbps - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - trackID = songs[0].ID - }) - - It("returns error when id parameter is missing", func() { - resp := doReq("stream") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("streams raw when no format or bitrate specified", func() { - w := doRawReq("stream", "id", trackID) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) - }) - - It("streams raw when format=raw", func() { - w := doRawReq("stream", "id", trackID, "format", "raw") - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) - }) - - It("transcodes to different format with bitrate", func() { - w := doRawReq("stream", "id", trackID, "format", "opus", "maxBitRate", "128") - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) - Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) - }) - - It("downsamples when only maxBitRate is specified (lower than source)", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DefaultDownsamplingFormat = "opus" - - w := doRawReq("stream", "id", trackID, "maxBitRate", "128") - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) - Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) - }) - - It("streams raw when maxBitRate is higher than source", func() { - w := doRawReq("stream", "id", trackID, "maxBitRate", "999") - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) - }) - - It("streams raw when format matches source and no bitrate reduction", func() { - w := doRawReq("stream", "id", trackID, "format", "mp3", "maxBitRate", "320") - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) - }) - - It("transcodes when same format but lower bitrate", func() { - w := doRawReq("stream", "id", trackID, "format", "mp3", "maxBitRate", "128") - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) - Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) - }) - - It("falls back to default downsampling format for unknown format", func() { - w := doRawReq("stream", "id", trackID, "format", "xyz") - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) - }) - - It("passes timeOffset through", func() { - w := doRawReq("stream", "id", trackID, "format", "opus", "maxBitRate", "128", "timeOffset", "30") - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) - Expect(streamerSpy.LastRequest.Offset).To(Equal(30)) - }) - }) - - Describe("Download", func() { - var trackID, trackTitle string - - BeforeAll(func() { - // All test tracks are mp3 at 320kbps - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Max: 1, Sort: "title"}) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - trackID = songs[0].ID - trackTitle = songs[0].Title - }) - - It("returns error when id parameter is missing", func() { - resp := doReq("download") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("downloads raw when no format specified and AutoTranscodeDownload is false", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableDownloads = true - conf.Server.AutoTranscodeDownload = false - - w := doRawReq("download", "id", trackID) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) - }) - - It("downloads with explicit format and bitrate", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableDownloads = true - - w := doRawReq("download", "id", trackID, "format", "opus", "bitrate", "128") - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) - Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) - Expect(w.Header().Get("Content-Disposition")).To(Equal(`attachment; filename="` + trackTitle + `.opus"`)) - }) - - It("returns error when downloads are disabled", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.EnableDownloads = false - - resp := doReq("download", "id", trackID) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - }) - }) - - Describe("GetCoverArt", func() { - It("handles request without error", func() { - w := doRawReq("getCoverArt") - - Expect(w.Code).To(Equal(http.StatusOK)) - }) - }) - - Describe("GetAvatar", func() { - It("returns placeholder avatar when gravatar disabled", func() { - w := doRawReq("getAvatar", "username", "admin") - - Expect(w.Code).To(Equal(http.StatusOK)) - }) - }) - - Describe("GetLyrics", func() { - It("returns empty lyrics when no match found", func() { - resp := doReq("getLyrics", "artist", "NonExistentArtist", "title", "NonExistentTitle") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Lyrics).ToNot(BeNil()) - Expect(resp.Lyrics.Value).To(BeEmpty()) - }) - }) - - Describe("GetLyricsBySongId", func() { - It("returns error when id parameter is missing", func() { - resp := doReq("getLyricsBySongId") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("returns error for non-existent song id", func() { - resp := doReq("getLyricsBySongId", "id", "non-existent-id") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - }) -}) diff --git a/server/subsonic/e2e/subsonic_multilibrary_test.go b/server/subsonic/e2e/subsonic_multilibrary_test.go deleted file mode 100644 index 8158be1de..000000000 --- a/server/subsonic/e2e/subsonic_multilibrary_test.go +++ /dev/null @@ -1,313 +0,0 @@ -package e2e - -import ( - "fmt" - "testing/fstest" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/metrics" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/scanner" - "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Multi-Library Support", Ordered, func() { - var lib2 model.Library - var adminWithLibs model.User // admin reloaded with both libraries - var userLib1Only model.User // non-admin with lib1 access only - - BeforeAll(func() { - conf.Server.EnableSharing = true - setupTestDB() - - // Create a second FakeFS with Classical music content - classical := template(_t{ - "albumartist": "Ludwig van Beethoven", - "artist": "Ludwig van Beethoven", - "album": "Symphony No. 9", - "year": 1824, - "genre": "Classical", - }) - classicalFS := storagetest.FakeFS{} - classicalFS.SetFiles(fstest.MapFS{ - "Classical/Beethoven/Symphony No. 9/01 - Allegro ma non troppo.mp3": classical(track(1, "Allegro ma non troppo")), - "Classical/Beethoven/Symphony No. 9/02 - Ode to Joy.mp3": classical(track(2, "Ode to Joy")), - }) - storagetest.Register("fake2", &classicalFS) - - // Create the second library in the DB (Put auto-assigns admin users) - lib2 = model.Library{ID: 2, Name: "Classical Library", Path: "fake2:///classical"} - Expect(ds.Library().Put(ctx, &lib2)).To(Succeed()) - - // Reload admin user to get both libraries in the Libraries field - loadedAdmin, err := ds.User().FindByUsername(ctx, adminUser.UserName) - Expect(err).ToNot(HaveOccurred()) - adminWithLibs = *loadedAdmin - - // Run incremental scan to import lib2 content (lib1 files unchanged → skipped) - s := scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) - _, err = s.ScanAll(ctx, false) - Expect(err).ToNot(HaveOccurred()) - - // Create a non-admin user with access only to lib1 - userLib1Only = model.User{ - ID: "multilib-user-1", - UserName: "lib1user", - Name: "Lib1 User", - IsAdmin: false, - NewPassword: "password", - } - Expect(ds.User().Put(ctx, &userLib1Only)).To(Succeed()) - Expect(ds.User().SetUserLibraries(ctx, userLib1Only.ID, []int{lib.ID})).To(Succeed()) - - loadedUser, err := ds.User().FindByUsername(ctx, userLib1Only.UserName) - Expect(err).ToNot(HaveOccurred()) - userLib1Only.Libraries = loadedUser.Libraries - }) - - Describe("getMusicFolders", func() { - It("returns both libraries for admin user", func() { - resp := doReqWithUser(adminWithLibs, "getMusicFolders") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.MusicFolders.Folders).To(HaveLen(2)) - - names := make([]string, len(resp.MusicFolders.Folders)) - for i, f := range resp.MusicFolders.Folders { - names[i] = f.Name - } - Expect(names).To(ConsistOf("Music Library", "Classical Library")) - }) - }) - - Describe("getArtists - library filtering", func() { - It("returns only lib1 artists when musicFolderId=1", func() { - resp := doReqWithUser(adminWithLibs, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib.ID)) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Artist).ToNot(BeNil()) - - var artistNames []string - for _, idx := range resp.Artist.Index { - for _, a := range idx.Artists { - artistNames = append(artistNames, a.Name) - } - } - Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis")) - Expect(artistNames).ToNot(ContainElement("Ludwig van Beethoven")) - }) - - It("returns only lib2 artists when musicFolderId=2", func() { - resp := doReqWithUser(adminWithLibs, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib2.ID)) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Artist).ToNot(BeNil()) - - var artistNames []string - for _, idx := range resp.Artist.Index { - for _, a := range idx.Artists { - artistNames = append(artistNames, a.Name) - } - } - Expect(artistNames).To(ContainElement("Ludwig van Beethoven")) - Expect(artistNames).ToNot(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis")) - }) - - It("returns artists from all libraries when no musicFolderId is specified", func() { - resp := doReqWithUser(adminWithLibs, "getArtists") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - - var artistNames []string - for _, idx := range resp.Artist.Index { - for _, a := range idx.Artists { - artistNames = append(artistNames, a.Name) - } - } - Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis", "Ludwig van Beethoven")) - }) - }) - - Describe("getAlbumList - library filtering", func() { - It("returns only lib1 albums when musicFolderId=1", func() { - resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib.ID)) - - Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(8)) - for _, a := range resp.AlbumList.Album { - Expect(a.Title).ToNot(Equal("Symphony No. 9")) - } - }) - - It("returns only lib2 albums when musicFolderId=2", func() { - resp := doReqWithUser(adminWithLibs, "getAlbumList", "type", "alphabeticalByName", "musicFolderId", fmt.Sprintf("%d", lib2.ID)) - - Expect(resp.AlbumList).ToNot(BeNil()) - Expect(resp.AlbumList.Album).To(HaveLen(1)) - Expect(resp.AlbumList.Album[0].Title).To(Equal("Symphony No. 9")) - }) - }) - - Describe("search3 - library filtering", func() { - It("does not find lib1 content when searching in lib2 only", func() { - resp := doReqWithUser(adminWithLibs, "search3", "query", "Beatles", "musicFolderId", fmt.Sprintf("%d", lib2.ID)) - - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).To(BeEmpty()) - Expect(resp.SearchResult3.Album).To(BeEmpty()) - Expect(resp.SearchResult3.Song).To(BeEmpty()) - }) - - It("finds lib2 content when searching in lib2", func() { - resp := doReqWithUser(adminWithLibs, "search3", "query", "Beethoven", "musicFolderId", fmt.Sprintf("%d", lib2.ID)) - - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).ToNot(BeEmpty()) - Expect(resp.SearchResult3.Artist[0].Name).To(Equal("Ludwig van Beethoven")) - }) - }) - - Describe("Cross-library playlists", Ordered, func() { - var playlistID string - var lib1SongID, lib2SongID string - - BeforeAll(func() { - // Look up one song from each library - lib1Songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"media_file.library_id": lib.ID}, - Max: 1, Sort: "title", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(lib1Songs).ToNot(BeEmpty()) - lib1SongID = lib1Songs[0].ID - - lib2Songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"media_file.library_id": lib2.ID}, - Max: 1, Sort: "title", - }) - Expect(err).ToNot(HaveOccurred()) - Expect(lib2Songs).ToNot(BeEmpty()) - lib2SongID = lib2Songs[0].ID - }) - - It("admin creates a playlist with songs from both libraries", func() { - resp := doReqWithUser(adminWithLibs, "createPlaylist", - "name", "Cross-Library Playlist", "songId", lib1SongID, "songId", lib2SongID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Playlist).ToNot(BeNil()) - Expect(resp.Playlist.SongCount).To(Equal(int32(2))) - Expect(resp.Playlist.Entry).To(HaveLen(2)) - playlistID = resp.Playlist.Id - }) - - It("admin makes the playlist public", func() { - resp := doReqWithUser(adminWithLibs, "updatePlaylist", - "playlistId", playlistID, "public", "true") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("non-admin user with lib1 only sees only lib1 tracks in the playlist", func() { - resp := doReqWithUser(userLib1Only, "getPlaylist", "id", playlistID) - - Expect(resp.Playlist).ToNot(BeNil()) - // The playlist has 2 songs total, but the non-admin user only has access to lib1 - Expect(resp.Playlist.Entry).To(HaveLen(1)) - Expect(resp.Playlist.Entry[0].Id).To(Equal(lib1SongID)) - }) - - It("non-admin user cannot store a song from another library through createPlaylist", func() { - resp := doReqWithUser(userLib1Only, "createPlaylist", - "name", "Restricted Playlist", "songId", lib1SongID, "songId", lib2SongID) - Expect(resp.Status).To(Equal(responses.StatusOK)) - ownID := resp.Playlist.Id - - stored := doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID) - Expect(stored.Playlist.Entry).To(HaveLen(1), "the lib2 song must not be persisted") - Expect(stored.Playlist.Entry[0].Id).To(Equal(lib1SongID)) - - By("replacing the tracks of the same playlist") - resp = doReqWithUser(userLib1Only, "createPlaylist", "playlistId", ownID, "songId", lib2SongID) - Expect(resp.Status).To(Equal(responses.StatusOK)) - - stored = doReqWithUser(adminWithLibs, "getPlaylist", "id", ownID) - Expect(stored.Playlist.Entry).To(BeEmpty()) - }) - }) - - Describe("Cross-library shares", Ordered, func() { - var lib2AlbumID string - - BeforeAll(func() { - lib2Albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"album.library_id": lib2.ID}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(lib2Albums).ToNot(BeEmpty()) - lib2AlbumID = lib2Albums[0].ID - }) - - It("admin creates a share for a lib2 album", func() { - resp := doReqWithUser(adminWithLibs, "createShare", - "id", lib2AlbumID, "description", "Classical album share") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares).ToNot(BeNil()) - Expect(resp.Shares.Share).To(HaveLen(1)) - - share := resp.Shares.Share[0] - Expect(share.Description).To(Equal("Classical album share")) - Expect(share.Entry).ToNot(BeEmpty()) - Expect(share.Entry[0].Title).To(Equal("Symphony No. 9")) - }) - }) - - Describe("Library access control", func() { - It("returns error when non-admin user requests inaccessible library", func() { - resp := doReqWithUser(userLib1Only, "getArtists", "musicFolderId", fmt.Sprintf("%d", lib2.ID)) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("non-admin user sees only their library's content without musicFolderId", func() { - resp := doReqWithUser(userLib1Only, "getArtists") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - - var artistNames []string - for _, idx := range resp.Artist.Index { - for _, a := range idx.Artists { - artistNames = append(artistNames, a.Name) - } - } - Expect(artistNames).To(ContainElements("The Beatles", "Led Zeppelin", "Miles Davis")) - Expect(artistNames).ToNot(ContainElement("Ludwig van Beethoven")) - }) - - It("non-admin user search returns only their library's content", func() { - resp := doReqWithUser(userLib1Only, "search3", "query", "Beethoven") - - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).To(BeEmpty(), "userLib1Only should not see Beethoven (lib2)") - Expect(resp.SearchResult3.Album).To(BeEmpty()) - Expect(resp.SearchResult3.Song).To(BeEmpty()) - }) - - It("non-admin user search finds content from their library", func() { - resp := doReqWithUser(userLib1Only, "search3", "query", "Beatles") - - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).ToNot(BeEmpty(), "userLib1Only should find Beatles (lib1)") - }) - }) -}) diff --git a/server/subsonic/e2e/subsonic_multiuser_test.go b/server/subsonic/e2e/subsonic_multiuser_test.go deleted file mode 100644 index d8c5d3689..000000000 --- a/server/subsonic/e2e/subsonic_multiuser_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package e2e - -import ( - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Multi-User Isolation", Ordered, func() { - var regularUser model.User - - BeforeAll(func() { - setupTestDB() - - regularUser = createUser("regular-1", "regular", "Regular User", false) - }) - - Describe("Admin-only endpoint restrictions", func() { - It("startScan fails for regular user", func() { - resp := doReqWithUser(regularUser, "startScan") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - }) - - Describe("Browsing as regular user", func() { - It("regular user can browse the library", func() { - resp := doReqWithUser(regularUser, "getArtists") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Artist).ToNot(BeNil()) - Expect(resp.Artist.Index).ToNot(BeEmpty()) - }) - - It("regular user can search", func() { - resp := doReqWithUser(regularUser, "search3", "query", "Beatles") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).ToNot(BeEmpty()) - }) - }) - - Describe("getUser authorization", func() { - It("regular user can get their own info", func() { - resp := doReqWithUser(regularUser, "getUser", "username", "regular") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.User.Username).To(Equal("regular")) - Expect(resp.User.AdminRole).To(BeFalse()) - }) - - It("regular user cannot get another user's info", func() { - resp := doReqWithUser(regularUser, "getUser", "username", "admin") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - }) - - Describe("getUsers authorization", func() { - It("succeeds for admin user", func() { - resp := doReqWithUser(adminUser, "getUsers") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Users).ToNot(BeNil()) - Expect(resp.Users.User).To(HaveLen(1)) - Expect(resp.Users.User[0].Username).To(Equal(adminUser.UserName)) - Expect(resp.Users.User[0].AdminRole).To(BeTrue()) - }) - - It("fails for regular user because getUsers is admin-only", func() { - resp := doReqWithUser(regularUser, "getUsers") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) - }) - }) -}) diff --git a/server/subsonic/e2e/subsonic_playlists_test.go b/server/subsonic/e2e/subsonic_playlists_test.go deleted file mode 100644 index 7a6d7df5d..000000000 --- a/server/subsonic/e2e/subsonic_playlists_test.go +++ /dev/null @@ -1,688 +0,0 @@ -package e2e - -import ( - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Playlist Endpoints", Ordered, func() { - var playlistID string - var songIDs []string - - BeforeAll(func() { - setupTestDB() - - // Look up song IDs from scanned data for playlist operations - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Sort: "title", Max: 6}) - Expect(err).ToNot(HaveOccurred()) - Expect(len(songs)).To(BeNumerically(">=", 5)) - for _, s := range songs { - songIDs = append(songIDs, s.ID) - } - }) - - It("getPlaylists returns empty list initially", func() { - resp := doReq("getPlaylists") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Playlists).ToNot(BeNil()) - Expect(resp.Playlists.Playlist).To(BeEmpty()) - }) - - It("createPlaylist creates a new playlist with songs", func() { - resp := doReq("createPlaylist", "name", "Test Playlist", - "songId", songIDs[0], "songId", songIDs[1], "songId", songIDs[2]) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Playlist).ToNot(BeNil()) - Expect(resp.Playlist.Name).To(Equal("Test Playlist")) - Expect(resp.Playlist.SongCount).To(Equal(int32(3))) - Expect(resp.Playlist.Entry).To(HaveLen(3)) - Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0])) - Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1])) - Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2])) - playlistID = resp.Playlist.Id - }) - - It("getPlaylist returns playlist with tracks in order", func() { - resp := doReq("getPlaylist", "id", playlistID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Playlist).ToNot(BeNil()) - Expect(resp.Playlist.Name).To(Equal("Test Playlist")) - Expect(resp.Playlist.Entry).To(HaveLen(3)) - Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0])) - Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1])) - Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2])) - }) - - It("createPlaylist without name or playlistId returns error", func() { - resp := doReq("createPlaylist", "songId", songIDs[0]) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("createPlaylist with playlistId replaces tracks on existing playlist", func() { - // Replace tracks: the playlist had [song0, song1, song2], replace with [song3, song4] - resp := doReq("createPlaylist", "playlistId", playlistID, - "songId", songIDs[3], "songId", songIDs[4]) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Playlist).ToNot(BeNil()) - Expect(resp.Playlist.Id).To(Equal(playlistID)) - Expect(resp.Playlist.SongCount).To(Equal(int32(2))) - Expect(resp.Playlist.Entry).To(HaveLen(2)) - Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[3])) - Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[4])) - }) - - It("updatePlaylist can rename the playlist", func() { - resp := doReq("updatePlaylist", "playlistId", playlistID, "name", "Renamed Playlist") - Expect(resp.Status).To(Equal(responses.StatusOK)) - - // Verify the rename - resp = doReq("getPlaylist", "id", playlistID) - Expect(resp.Playlist.Name).To(Equal("Renamed Playlist")) - // Tracks should be unchanged - Expect(resp.Playlist.SongCount).To(Equal(int32(2))) - }) - - It("updatePlaylist can set comment", func() { - resp := doReq("updatePlaylist", "playlistId", playlistID, "comment", "My favorite songs") - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReq("getPlaylist", "id", playlistID) - Expect(resp.Playlist.Comment).To(Equal("My favorite songs")) - }) - - It("updatePlaylist can set public visibility", func() { - resp := doReq("updatePlaylist", "playlistId", playlistID, "public", "true") - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReq("getPlaylist", "id", playlistID) - Expect(resp.Playlist.Public).To(BeTrue()) - }) - - It("updatePlaylist can add songs", func() { - // Playlist currently has [song3, song4], add song0 - resp := doReq("updatePlaylist", "playlistId", playlistID, "songIdToAdd", songIDs[0]) - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReq("getPlaylist", "id", playlistID) - Expect(resp.Playlist.SongCount).To(Equal(int32(3))) - Expect(resp.Playlist.Entry).To(HaveLen(3)) - Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[3])) - Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[4])) - Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[0])) - }) - - It("updatePlaylist can add multiple songs at once", func() { - // Playlist currently has [song3, song4, song0], add song1 and song2 - resp := doReq("updatePlaylist", "playlistId", playlistID, - "songIdToAdd", songIDs[1], "songIdToAdd", songIDs[2]) - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReq("getPlaylist", "id", playlistID) - Expect(resp.Playlist.SongCount).To(Equal(int32(5))) - Expect(resp.Playlist.Entry).To(HaveLen(5)) - }) - - It("updatePlaylist can remove songs by index and verifies correct songs remain", func() { - // Playlist has [song3, song4, song0, song1, song2] - // Remove index 0 (song3) and index 2 (song0) - resp := doReq("updatePlaylist", "playlistId", playlistID, - "songIndexToRemove", "0", "songIndexToRemove", "2") - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReq("getPlaylist", "id", playlistID) - Expect(resp.Playlist.SongCount).To(Equal(int32(3))) - Expect(resp.Playlist.Entry).To(HaveLen(3)) - Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[4])) - Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[1])) - Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[2])) - }) - - It("updatePlaylist can remove and add songs in a single call", func() { - // Playlist has [song4, song1, song2] - // Remove index 1 (song1) and add song3 - resp := doReq("updatePlaylist", "playlistId", playlistID, - "songIndexToRemove", "1", "songIdToAdd", songIDs[3]) - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReq("getPlaylist", "id", playlistID) - Expect(resp.Playlist.SongCount).To(Equal(int32(3))) - Expect(resp.Playlist.Entry).To(HaveLen(3)) - Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[4])) - Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[2])) - Expect(resp.Playlist.Entry[2].Id).To(Equal(songIDs[3])) - }) - - It("updatePlaylist can combine metadata change with track removal", func() { - // Playlist has [song4, song2, song3] - // Rename + remove index 0 (song4) - resp := doReq("updatePlaylist", "playlistId", playlistID, - "name", "Final Playlist", "songIndexToRemove", "0") - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReq("getPlaylist", "id", playlistID) - Expect(resp.Playlist.Name).To(Equal("Final Playlist")) - Expect(resp.Playlist.SongCount).To(Equal(int32(2))) - Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[2])) - Expect(resp.Playlist.Entry[1].Id).To(Equal(songIDs[3])) - }) - - It("updatePlaylist can remove all songs from playlist", func() { - // Playlist has [song2, song3] — remove both - resp := doReq("updatePlaylist", "playlistId", playlistID, - "songIndexToRemove", "0", "songIndexToRemove", "1") - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReq("getPlaylist", "id", playlistID) - Expect(resp.Playlist.SongCount).To(Equal(int32(0))) - Expect(resp.Playlist.Entry).To(BeEmpty()) - }) - - It("updatePlaylist can add songs to an empty playlist", func() { - resp := doReq("updatePlaylist", "playlistId", playlistID, - "songIdToAdd", songIDs[0]) - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReq("getPlaylist", "id", playlistID) - Expect(resp.Playlist.SongCount).To(Equal(int32(1))) - Expect(resp.Playlist.Entry).To(HaveLen(1)) - Expect(resp.Playlist.Entry[0].Id).To(Equal(songIDs[0])) - }) - - It("updatePlaylist without playlistId returns error", func() { - resp := doReq("updatePlaylist", "name", "No ID") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("getPlaylists shows the playlist", func() { - resp := doReq("getPlaylists") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Playlists.Playlist).To(HaveLen(1)) - Expect(resp.Playlists.Playlist[0].Id).To(Equal(playlistID)) - }) - - It("deletePlaylist removes the playlist", func() { - resp := doReq("deletePlaylist", "id", playlistID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("getPlaylist on deleted playlist returns error", func() { - resp := doReq("getPlaylist", "id", playlistID) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("getPlaylists returns empty after deletion", func() { - resp := doReq("getPlaylists") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Playlists.Playlist).To(BeEmpty()) - }) - - Describe("Playlist Permissions", Ordered, func() { - var songIDs []string - var adminPrivateID string - var adminPublicID string - var regularPlaylistID string - - BeforeAll(func() { - setupTestDB() - - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Sort: "title", Max: 6}) - Expect(err).ToNot(HaveOccurred()) - Expect(len(songs)).To(BeNumerically(">=", 3)) - for _, s := range songs { - songIDs = append(songIDs, s.ID) - } - }) - - It("admin creates a private playlist", func() { - resp := doReqWithUser(adminUser, "createPlaylist", "name", "Admin Private", - "songId", songIDs[0], "songId", songIDs[1]) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - adminPrivateID = resp.Playlist.Id - }) - - It("admin creates a public playlist", func() { - resp := doReqWithUser(adminUser, "createPlaylist", "name", "Admin Public", - "songId", songIDs[0], "songId", songIDs[1]) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - adminPublicID = resp.Playlist.Id - - // Make it public - resp = doReqWithUser(adminUser, "updatePlaylist", - "playlistId", adminPublicID, "public", "true") - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("regular user creates a playlist", func() { - resp := doReqWithUser(regularUser, "createPlaylist", "name", "Regular Playlist", - "songId", songIDs[0]) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - regularPlaylistID = resp.Playlist.Id - }) - - // --- Private playlist: regular user gets "not found" (repo hides it entirely) --- - - It("regular user cannot see admin's private playlist", func() { - resp := doReqWithUser(regularUser, "getPlaylist", "id", adminPrivateID) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("regular user cannot update admin's private playlist (not found)", func() { - resp := doReqWithUser(regularUser, "updatePlaylist", - "playlistId", adminPrivateID, "name", "Hacked") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("regular user cannot delete admin's private playlist (not found)", func() { - resp := doReqWithUser(regularUser, "deletePlaylist", "id", adminPrivateID) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - // --- Public playlist: regular user can see but cannot modify (authorization fail, code 50) --- - - It("regular user can see admin's public playlist", func() { - resp := doReqWithUser(regularUser, "getPlaylist", "id", adminPublicID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Playlist.Name).To(Equal("Admin Public")) - }) - - It("regular user cannot update admin's public playlist", func() { - resp := doReqWithUser(regularUser, "updatePlaylist", - "playlistId", adminPublicID, "name", "Hacked") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(int32(50))) - }) - - It("regular user cannot add songs to admin's public playlist", func() { - resp := doReqWithUser(regularUser, "updatePlaylist", - "playlistId", adminPublicID, "songIdToAdd", songIDs[2]) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error.Code).To(Equal(int32(50))) - }) - - It("regular user cannot remove songs from admin's public playlist", func() { - resp := doReqWithUser(regularUser, "updatePlaylist", - "playlistId", adminPublicID, "songIndexToRemove", "0") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error.Code).To(Equal(int32(50))) - }) - - It("regular user cannot delete admin's public playlist", func() { - resp := doReqWithUser(regularUser, "deletePlaylist", "id", adminPublicID) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error.Code).To(Equal(int32(50))) - }) - - It("regular user cannot replace tracks on admin's public playlist via createPlaylist", func() { - resp := doReqWithUser(regularUser, "createPlaylist", - "playlistId", adminPublicID, "songId", songIDs[2]) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - // --- Regular user can manage their own playlists --- - - It("regular user can update their own playlist", func() { - resp := doReqWithUser(regularUser, "updatePlaylist", - "playlistId", regularPlaylistID, "name", "My Updated Playlist") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReqWithUser(regularUser, "getPlaylist", "id", regularPlaylistID) - Expect(resp.Playlist.Name).To(Equal("My Updated Playlist")) - }) - - It("regular user can add songs to their own playlist", func() { - resp := doReqWithUser(regularUser, "updatePlaylist", - "playlistId", regularPlaylistID, "songIdToAdd", songIDs[1]) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReqWithUser(regularUser, "getPlaylist", "id", regularPlaylistID) - Expect(resp.Playlist.SongCount).To(Equal(int32(2))) - }) - - It("regular user can delete their own playlist", func() { - resp := doReqWithUser(regularUser, "deletePlaylist", "id", regularPlaylistID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - // --- Admin can manage any user's playlists --- - - It("admin can update any user's playlist", func() { - resp := doReqWithUser(regularUser, "createPlaylist", "name", "To Be Admin-Edited", - "songId", songIDs[0]) - Expect(resp.Status).To(Equal(responses.StatusOK)) - plsID := resp.Playlist.Id - - resp = doReqWithUser(adminUser, "updatePlaylist", - "playlistId", plsID, "name", "Admin Edited") - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReqWithUser(adminUser, "getPlaylist", "id", plsID) - Expect(resp.Playlist.Name).To(Equal("Admin Edited")) - }) - - It("admin can delete any user's playlist", func() { - resp := doReqWithUser(regularUser, "createPlaylist", "name", "To Be Admin-Deleted", - "songId", songIDs[0]) - Expect(resp.Status).To(Equal(responses.StatusOK)) - plsID := resp.Playlist.Id - - resp = doReqWithUser(adminUser, "deletePlaylist", "id", plsID) - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReqWithUser(adminUser, "getPlaylist", "id", plsID) - Expect(resp.Status).To(Equal(responses.StatusFailed)) - }) - - // --- Verify admin's playlists are unchanged --- - - It("admin's private playlist is unchanged after failed regular user operations", func() { - resp := doReqWithUser(adminUser, "getPlaylist", "id", adminPrivateID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Playlist.Name).To(Equal("Admin Private")) - Expect(resp.Playlist.SongCount).To(Equal(int32(2))) - }) - - It("admin's public playlist is unchanged after failed regular user operations", func() { - resp := doReqWithUser(adminUser, "getPlaylist", "id", adminPublicID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Playlist.Name).To(Equal("Admin Public")) - Expect(resp.Playlist.SongCount).To(Equal(int32(2))) - }) - }) - - Describe("Smart Playlist Protection", Ordered, func() { - var smartPlaylistID string - var songID string - - BeforeAll(func() { - setupTestDB() - - // Look up a song ID for mutation tests - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Sort: "title", Max: 1}) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - songID = songs[0].ID - - // Insert a smart playlist directly into the DB - smartPls := &model.Playlist{ - Name: "Smart Playlist", - OwnerID: adminUser.ID, - Public: false, - Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}}, - } - Expect(ds.Playlist().Put(ctx, smartPls)).To(Succeed()) - smartPlaylistID = smartPls.ID - }) - - It("getPlaylist returns smart playlist with readonly flag and validUntil", func() { - resp := doReq("getPlaylist", "id", smartPlaylistID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Playlist.Name).To(Equal("Smart Playlist")) - Expect(resp.Playlist.OpenSubsonicPlaylist).ToNot(BeNil()) - Expect(resp.Playlist.OpenSubsonicPlaylist.Readonly).To(BeTrue()) - expectedValidUntil := time.Now().Add(conf.Server.SmartPlaylistRefreshDelay) - Expect(*resp.Playlist.OpenSubsonicPlaylist.ValidUntil).To(BeTemporally("~", expectedValidUntil, time.Second)) - }) - - It("createPlaylist rejects replacing tracks on smart playlist", func() { - resp := doReq("createPlaylist", "playlistId", smartPlaylistID, "songId", songID) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(int32(50))) - }) - - It("updatePlaylist rejects adding songs to smart playlist", func() { - resp := doReq("updatePlaylist", "playlistId", smartPlaylistID, - "songIdToAdd", songID) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(int32(50))) - }) - - It("updatePlaylist rejects removing songs from smart playlist", func() { - resp := doReq("updatePlaylist", "playlistId", smartPlaylistID, - "songIndexToRemove", "0") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(int32(50))) - }) - - It("updatePlaylist allows renaming smart playlist", func() { - resp := doReq("updatePlaylist", "playlistId", smartPlaylistID, - "name", "Renamed Smart") - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReq("getPlaylist", "id", smartPlaylistID) - Expect(resp.Playlist.Name).To(Equal("Renamed Smart")) - }) - - It("updatePlaylist allows setting comment on smart playlist", func() { - resp := doReq("updatePlaylist", "playlistId", smartPlaylistID, - "comment", "Auto-generated playlist") - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReq("getPlaylist", "id", smartPlaylistID) - Expect(resp.Playlist.Comment).To(Equal("Auto-generated playlist")) - }) - - It("deletePlaylist can delete smart playlist", func() { - resp := doReq("deletePlaylist", "id", smartPlaylistID) - Expect(resp.Status).To(Equal(responses.StatusOK)) - - resp = doReq("getPlaylist", "id", smartPlaylistID) - Expect(resp.Status).To(Equal(responses.StatusFailed)) - }) - }) - - 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().GetAll(ctx, 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().Put(ctx, 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().Put(ctx, 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().Put(ctx, 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().Put(ctx, 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().Put(ctx, 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().Put(ctx, 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().Put(ctx, stringPls)).To(Succeed()) - - boolResp := doReq("getPlaylist", "id", boolPls.ID) - stringResp := doReq("getPlaylist", "id", stringPls.ID) - Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount)) - }) - - DescribeTable("isMissing/isPresent partition all songs for nullable column fields", - func(fieldName string) { - allPls := &model.Playlist{ - Name: "All Songs " + fieldName, - OwnerID: adminUser.ID, - Rules: &criteria.Criteria{Expression: criteria.Contains{"title": ""}}, - } - Expect(ds.Playlist().Put(ctx, allPls)).To(Succeed()) - missingPls := &model.Playlist{ - Name: "Missing " + fieldName, - OwnerID: adminUser.ID, - Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{fieldName: true}}}, - } - Expect(ds.Playlist().Put(ctx, missingPls)).To(Succeed()) - presentPls := &model.Playlist{ - Name: "Present " + fieldName, - OwnerID: adminUser.ID, - Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{fieldName: true}}}, - } - Expect(ds.Playlist().Put(ctx, presentPls)).To(Succeed()) - - allResp := doReq("getPlaylist", "id", allPls.ID) - missingResp := doReq("getPlaylist", "id", missingPls.ID) - presentResp := doReq("getPlaylist", "id", presentPls.ID) - - Expect(allResp.Status).To(Equal(responses.StatusOK)) - Expect(allResp.Playlist.SongCount).To(BeNumerically(">", int32(0))) - Expect(missingResp.Playlist.SongCount + presentResp.Playlist.SongCount). - To(Equal(allResp.Playlist.SongCount)) - }, - Entry("bpm", "bpm"), - Entry("bitdepth", "bitdepth"), - Entry("lyrics", "lyrics"), - Entry("mbz_recording_id", "mbz_recording_id"), - Entry("album", "album"), - Entry("comment", "comment"), - ) - }) -}) diff --git a/server/subsonic/e2e/subsonic_radio_test.go b/server/subsonic/e2e/subsonic_radio_test.go deleted file mode 100644 index d51cf6b14..000000000 --- a/server/subsonic/e2e/subsonic_radio_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package e2e - -import ( - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Internet Radio Endpoints", Ordered, func() { - var radioID string - - BeforeAll(func() { - setupTestDB() - }) - - It("getInternetRadioStations returns empty initially", func() { - resp := doReq("getInternetRadioStations") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.InternetRadioStations).ToNot(BeNil()) - Expect(resp.InternetRadioStations.Radios).To(BeEmpty()) - }) - - It("createInternetRadioStation adds a station", func() { - resp := doReq("createInternetRadioStation", - "streamUrl", "https://stream.example.com/radio", - "name", "Test Radio", - "homepageUrl", "https://example.com", - ) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("getInternetRadioStations returns the created station", func() { - resp := doReq("getInternetRadioStations") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.InternetRadioStations).ToNot(BeNil()) - Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) - - radio := resp.InternetRadioStations.Radios[0] - Expect(radio.Name).To(Equal("Test Radio")) - Expect(radio.StreamUrl).To(Equal("https://stream.example.com/radio")) - Expect(radio.HomepageUrl).To(Equal("https://example.com")) - radioID = radio.ID - Expect(radioID).ToNot(BeEmpty()) - }) - - It("getInternetRadioStations remains available to regular users", func() { - resp := doReqWithUser(regularUser, "getInternetRadioStations") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.InternetRadioStations).ToNot(BeNil()) - Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) - Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Test Radio")) - }) - - It("createInternetRadioStation requires admin user", func() { - resp := doReqWithUser(regularUser, "createInternetRadioStation", - "streamUrl", "https://stream.example.com/hacked", - "name", "Hacked Radio", - ) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) - - resp = doReq("getInternetRadioStations") - Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) - Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Test Radio")) - }) - - It("updateInternetRadioStation modifies the station", func() { - resp := doReq("updateInternetRadioStation", - "id", radioID, - "streamUrl", "https://stream.example.com/radio-v2", - "name", "Updated Radio", - "homepageUrl", "https://updated.example.com", - ) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - - // Verify update - resp = doReq("getInternetRadioStations") - Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) - Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Updated Radio")) - Expect(resp.InternetRadioStations.Radios[0].StreamUrl).To(Equal("https://stream.example.com/radio-v2")) - Expect(resp.InternetRadioStations.Radios[0].HomepageUrl).To(Equal("https://updated.example.com")) - }) - - It("updateInternetRadioStation requires admin user", func() { - resp := doReqWithUser(regularUser, "updateInternetRadioStation", - "id", radioID, - "streamUrl", "https://stream.example.com/hacked", - "name", "Hacked Radio", - ) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) - - resp = doReq("getInternetRadioStations") - Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) - Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Updated Radio")) - Expect(resp.InternetRadioStations.Radios[0].StreamUrl).To(Equal("https://stream.example.com/radio-v2")) - }) - - It("deleteInternetRadioStation requires admin user", func() { - resp := doReqWithUser(regularUser, "deleteInternetRadioStation", "id", radioID) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) - - resp = doReq("getInternetRadioStations") - Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) - Expect(resp.InternetRadioStations.Radios[0].ID).To(Equal(radioID)) - }) - - It("deleteInternetRadioStation removes it", func() { - resp := doReq("deleteInternetRadioStation", "id", radioID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("getInternetRadioStations returns empty after deletion", func() { - resp := doReq("getInternetRadioStations") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.InternetRadioStations).ToNot(BeNil()) - Expect(resp.InternetRadioStations.Radios).To(BeEmpty()) - }) - - It("deleteInternetRadioStation returns not found for a missing station", func() { - resp := doReq("deleteInternetRadioStation", "id", radioID) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(responses.ErrorDataNotFound)) - }) -}) diff --git a/server/subsonic/e2e/subsonic_scan_test.go b/server/subsonic/e2e/subsonic_scan_test.go deleted file mode 100644 index 8bac6fe96..000000000 --- a/server/subsonic/e2e/subsonic_scan_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package e2e - -import ( - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Scan Endpoints", func() { - BeforeEach(func() { - setupTestDB() - }) - - It("getScanStatus returns status", func() { - resp := doReq("getScanStatus") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.ScanStatus).ToNot(BeNil()) - Expect(resp.ScanStatus.Scanning).To(BeFalse()) - Expect(resp.ScanStatus.Count).To(BeNumerically(">", 0)) - Expect(resp.ScanStatus.LastScan).ToNot(BeNil()) - }) - - It("startScan requires admin user", func() { - resp := doReqWithUser(regularUser, "startScan") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("startScan returns scan status response", func() { - resp := doReq("startScan") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.ScanStatus).ToNot(BeNil()) - }) -}) diff --git a/server/subsonic/e2e/subsonic_searching_test.go b/server/subsonic/e2e/subsonic_searching_test.go deleted file mode 100644 index 00b60ad6f..000000000 --- a/server/subsonic/e2e/subsonic_searching_test.go +++ /dev/null @@ -1,274 +0,0 @@ -package e2e - -import ( - "github.com/google/uuid" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Search Endpoints", func() { - BeforeEach(func() { - setupTestDB() - }) - - Describe("Search2", func() { - It("finds artists by name", func() { - resp := doReq("search2", "query", "Beatles") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SearchResult2).ToNot(BeNil()) - Expect(resp.SearchResult2.Artist).ToNot(BeEmpty()) - - found := false - for _, a := range resp.SearchResult2.Artist { - if a.Name == "The Beatles" { - found = true - break - } - } - Expect(found).To(BeTrue(), "expected to find artist 'The Beatles'") - }) - - It("finds albums by name", func() { - resp := doReq("search2", "query", "Abbey Road") - - Expect(resp.SearchResult2).ToNot(BeNil()) - Expect(resp.SearchResult2.Album).ToNot(BeEmpty()) - - found := false - for _, a := range resp.SearchResult2.Album { - if a.Title == "Abbey Road" { - found = true - break - } - } - Expect(found).To(BeTrue(), "expected to find album 'Abbey Road'") - }) - - It("finds songs by title", func() { - resp := doReq("search2", "query", "Come Together") - - Expect(resp.SearchResult2).ToNot(BeNil()) - Expect(resp.SearchResult2.Song).ToNot(BeEmpty()) - - found := false - for _, s := range resp.SearchResult2.Song { - if s.Title == "Come Together" { - found = true - break - } - } - Expect(found).To(BeTrue(), "expected to find song 'Come Together'") - }) - - It("respects artistCount/albumCount/songCount limits", func() { - resp := doReq("search2", "query", "Beatles", - "artistCount", "1", "albumCount", "1", "songCount", "1") - - Expect(resp.SearchResult2).ToNot(BeNil()) - Expect(len(resp.SearchResult2.Artist)).To(BeNumerically("<=", 1)) - Expect(len(resp.SearchResult2.Album)).To(BeNumerically("<=", 1)) - Expect(len(resp.SearchResult2.Song)).To(BeNumerically("<=", 1)) - }) - - It("supports offset parameters", func() { - // First get all results for Beatles - resp1 := doReq("search2", "query", "Beatles", "songCount", "500") - allSongs := resp1.SearchResult2.Song - - if len(allSongs) > 1 { - // Get with offset to skip the first song - resp2 := doReq("search2", "query", "Beatles", "songOffset", "1", "songCount", "500") - - Expect(resp2.SearchResult2).ToNot(BeNil()) - Expect(len(resp2.SearchResult2.Song)).To(Equal(len(allSongs) - 1)) - } - }) - - It("returns empty results for non-matching query", func() { - resp := doReq("search2", "query", "ZZZZNONEXISTENT99999") - - Expect(resp.SearchResult2).ToNot(BeNil()) - Expect(resp.SearchResult2.Artist).To(BeEmpty()) - Expect(resp.SearchResult2.Album).To(BeEmpty()) - Expect(resp.SearchResult2.Song).To(BeEmpty()) - }) - }) - - Describe("Search3", func() { - It("returns results in ID3 format", func() { - resp := doReq("search3", "query", "Beatles") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SearchResult3).ToNot(BeNil()) - // Verify ID3 format: Artist should be ArtistID3 with Name and AlbumCount - Expect(resp.SearchResult3.Artist).ToNot(BeEmpty()) - Expect(resp.SearchResult3.Artist[0].Name).ToNot(BeEmpty()) - Expect(resp.SearchResult3.Artist[0].Id).ToNot(BeEmpty()) - }) - - It("returns all results when query is empty (OpenSubsonic)", func() { - resp := doReq("search3", "query", "") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).To(HaveLen(7)) - Expect(resp.SearchResult3.Album).To(HaveLen(8)) - Expect(resp.SearchResult3.Song).To(HaveLen(20)) - }) - - It("finds across all entity types simultaneously", func() { - // "Beatles" should match artist, albums, and songs by The Beatles - resp := doReq("search3", "query", "Beatles") - - Expect(resp.SearchResult3).ToNot(BeNil()) - - // Should find at least the artist "The Beatles" - artistFound := false - for _, a := range resp.SearchResult3.Artist { - if a.Name == "The Beatles" { - artistFound = true - break - } - } - Expect(artistFound).To(BeTrue(), "expected to find artist 'The Beatles'") - - // Should find albums by The Beatles (albums contain "Beatles" in artist field) - // Albums are returned as AlbumID3 type - for _, a := range resp.SearchResult3.Album { - Expect(a.Id).ToNot(BeEmpty()) - Expect(a.Name).ToNot(BeEmpty()) - } - - // Songs are returned as Child type - for _, s := range resp.SearchResult3.Song { - Expect(s.Id).ToNot(BeEmpty()) - Expect(s.Title).ToNot(BeEmpty()) - } - }) - - Describe("MBID search", func() { - It("finds songs by mbz_recording_id", func() { - resp := doReq("search3", "query", mbidComeTogetherRec) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Song).To(HaveLen(1)) - Expect(resp.SearchResult3.Song[0].Title).To(Equal("Come Together")) - }) - - It("finds songs by mbz_release_track_id", func() { - resp := doReq("search3", "query", mbidSomething) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Song).To(HaveLen(1)) - Expect(resp.SearchResult3.Song[0].Title).To(Equal("Something")) - }) - - It("finds albums by mbz_album_id", func() { - resp := doReq("search3", "query", mbidAbbeyRoadAlbum) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Album).To(HaveLen(1)) - Expect(resp.SearchResult3.Album[0].Name).To(Equal("Abbey Road")) - }) - - It("finds albums by mbz_release_group_id", func() { - resp := doReq("search3", "query", mbidAbbeyRoadRelGroup) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Album).To(HaveLen(1)) - Expect(resp.SearchResult3.Album[0].Name).To(Equal("Abbey Road")) - }) - - It("finds artists by mbz_artist_id", func() { - resp := doReq("search3", "query", mbidBeatlesArtist) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).To(HaveLen(1)) - Expect(resp.SearchResult3.Artist[0].Name).To(Equal("The Beatles")) - }) - - It("returns empty results for non-matching UUID", func() { - nonMatchingUUID := uuid.NewString() - resp := doReq("search3", "query", nonMatchingUUID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).To(BeEmpty()) - Expect(resp.SearchResult3.Album).To(BeEmpty()) - Expect(resp.SearchResult3.Song).To(BeEmpty()) - }) - - It("does not return songs for artist MBID", func() { - // media_file MBID search only checks mbz_recording_id and mbz_release_track_id, - // so an artist MBID should return only the artist, not songs - resp := doReq("search3", "query", mbidBeatlesArtist) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).To(HaveLen(1)) - Expect(resp.SearchResult3.Artist[0].Name).To(Equal("The Beatles")) - Expect(resp.SearchResult3.Song).To(BeEmpty()) - }) - }) - - Describe("CJK search", func() { - It("finds songs by CJK title", func() { - resp := doReq("search3", "query", "プラチナ") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Song).To(HaveLen(1)) - Expect(resp.SearchResult3.Song[0].Title).To(Equal("プラチナ・ジェット")) - }) - - It("finds artists by CJK name", func() { - resp := doReq("search3", "query", "シートベルツ") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).To(HaveLen(1)) - Expect(resp.SearchResult3.Artist[0].Name).To(Equal("シートベルツ")) - }) - - It("finds albums by CJK artist name", func() { - resp := doReq("search3", "query", "シートベルツ") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Album).To(HaveLen(1)) - Expect(resp.SearchResult3.Album[0].Name).To(Equal("COWBOY BEBOP")) - }) - }) - - Describe("Legacy backend", func() { - It("returns results using legacy LIKE-based search when configured", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Search.Backend = "legacy" - - resp := doReq("search3", "query", "Beatles") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(resp.SearchResult3.Artist).ToNot(BeEmpty()) - - found := false - for _, a := range resp.SearchResult3.Artist { - if a.Name == "The Beatles" { - found = true - break - } - } - Expect(found).To(BeTrue(), "expected to find artist 'The Beatles' with legacy backend") - }) - }) - }) -}) diff --git a/server/subsonic/e2e/subsonic_sharing_test.go b/server/subsonic/e2e/subsonic_sharing_test.go deleted file mode 100644 index cf3e55d74..000000000 --- a/server/subsonic/e2e/subsonic_sharing_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package e2e - -import ( - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Sharing Endpoints", Ordered, func() { - var shareID string - var albumID string - var songID string - - BeforeAll(func() { - conf.Server.EnableSharing = true - setupTestDB() - - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"album.name": "Abbey Road"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).ToNot(BeEmpty()) - albumID = albums[0].ID - - songs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"title": "Come Together"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - songID = songs[0].ID - }) - - It("getShares returns empty initially", func() { - resp := doReq("getShares") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares).ToNot(BeNil()) - Expect(resp.Shares.Share).To(BeEmpty()) - }) - - It("createShare creates a share for an album", func() { - resp := doReq("createShare", "id", albumID, "description", "Check out this album") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares).ToNot(BeNil()) - Expect(resp.Shares.Share).To(HaveLen(1)) - - share := resp.Shares.Share[0] - Expect(share.ID).ToNot(BeEmpty()) - Expect(share.Description).To(Equal("Check out this album")) - Expect(share.Username).To(Equal(adminUser.UserName)) - shareID = share.ID - }) - - It("getShares returns the created share", func() { - resp := doReq("getShares") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares).ToNot(BeNil()) - Expect(resp.Shares.Share).To(HaveLen(1)) - - share := resp.Shares.Share[0] - Expect(share.ID).To(Equal(shareID)) - Expect(share.Description).To(Equal("Check out this album")) - Expect(share.Username).To(Equal(adminUser.UserName)) - Expect(share.Entry).ToNot(BeEmpty()) - }) - - It("updateShare modifies the description", func() { - resp := doReq("updateShare", "id", shareID, "description", "Updated description") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - - // Verify update - resp = doReq("getShares") - Expect(resp.Shares.Share).To(HaveLen(1)) - Expect(resp.Shares.Share[0].Description).To(Equal("Updated description")) - }) - - It("deleteShare removes it", func() { - resp := doReq("deleteShare", "id", shareID) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - - It("getShares returns empty after deletion", func() { - resp := doReq("getShares") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares).ToNot(BeNil()) - Expect(resp.Shares.Share).To(BeEmpty()) - }) - - It("createShare works with a song ID", func() { - resp := doReq("createShare", "id", songID, "description", "Great song") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares).ToNot(BeNil()) - Expect(resp.Shares.Share).To(HaveLen(1)) - Expect(resp.Shares.Share[0].Description).To(Equal("Great song")) - Expect(resp.Shares.Share[0].Entry).To(HaveLen(1)) - }) - - It("createShare returns error when id parameter is missing", func() { - resp := doReq("createShare") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) - }) - - It("updateShare returns error when id parameter is missing", func() { - resp := doReq("updateShare") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("deleteShare returns error when id parameter is missing", func() { - resp := doReq("deleteShare") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) -}) - -var _ = Describe("Sharing Cross-User Isolation", Ordered, func() { - var userA, userB model.User - var shareID string - var albumID string - - BeforeAll(func() { - conf.Server.EnableSharing = true - setupTestDB() - - userA = createUser("share-user-a", "share-user-a", "Share User A", false) - userB = createUser("share-user-b", "share-user-b", "Share User B", false) - - albums, err := ds.Album().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"album.name": "Abbey Road"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(albums).ToNot(BeEmpty()) - albumID = albums[0].ID - - resp := doReqWithUser(userA, "createShare", "id", albumID, "description", "User A's share") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares.Share).To(HaveLen(1)) - shareID = resp.Shares.Share[0].ID - Expect(resp.Shares.Share[0].Username).To(Equal(userA.UserName)) - }) - - It("userB's getShares does not leak userA's share", func() { - resp := doReqWithUser(userB, "getShares") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares).ToNot(BeNil()) - Expect(resp.Shares.Share).To(BeEmpty()) - }) - - It("userA still sees own share", func() { - resp := doReqWithUser(userA, "getShares") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares.Share).To(HaveLen(1)) - Expect(resp.Shares.Share[0].ID).To(Equal(shareID)) - Expect(resp.Shares.Share[0].Description).To(Equal("User A's share")) - }) - - It("admin sees userA's share", func() { - resp := doReqWithUser(adminUser, "getShares") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - ids := make([]string, len(resp.Shares.Share)) - for i, s := range resp.Shares.Share { - ids[i] = s.ID - } - Expect(ids).To(ContainElement(shareID)) - }) - - It("userB cannot updateShare on userA's share", func() { - resp := doReqWithUser(userB, "updateShare", "id", shareID, "description", "hijacked") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - - // Confirm description unchanged for userA. - check := doReqWithUser(userA, "getShares") - Expect(check.Shares.Share).To(HaveLen(1)) - Expect(check.Shares.Share[0].Description).To(Equal("User A's share")) - }) - - It("userB cannot deleteShare on userA's share", func() { - resp := doReqWithUser(userB, "deleteShare", "id", shareID) - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - - // Confirm share still present for userA. - check := doReqWithUser(userA, "getShares") - Expect(check.Shares.Share).To(HaveLen(1)) - Expect(check.Shares.Share[0].ID).To(Equal(shareID)) - }) -}) - -var _ = Describe("Sharing Downloadable Default", func() { - var albumID string - - BeforeEach(func() { - conf.Server.EnableSharing = true - setupTestDB() - conf.Server.EnableDownloads = true - albumID = albumIDByName("Abbey Road") - }) - - createShare := func(params ...string) *model.Share { - GinkgoHelper() - resp := doReq("createShare", append([]string{"id", albumID}, params...)...) - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Shares.Share).To(HaveLen(1)) - share, err := ds.Share().Get(ctx, resp.Shares.Share[0].ID) - Expect(err).ToNot(HaveOccurred()) - return share - } - - DescribeTable("createShare resolves downloadable", - func(defaultDownloadable, enableDownloads bool, params []string, expected bool) { - conf.Server.DefaultDownloadableShare = defaultDownloadable - conf.Server.EnableDownloads = enableDownloads - - Expect(createShare(params...).Downloadable).To(Equal(expected)) - }, - Entry("applies the default when the param is absent", true, true, nil, true), - Entry("stays off when the default is off", false, true, nil, false), - Entry("ignores the default when downloads are disabled", true, false, nil, false), - Entry("honors an explicit false over the default", true, true, []string{"downloadable", "false"}, false), - Entry("honors an explicit true over the default", false, true, []string{"downloadable", "true"}, true), - ) - - It("updateShare keeps the current downloadable when the param is absent", func() { - conf.Server.DefaultDownloadableShare = true - share := createShare() - Expect(share.Downloadable).To(BeTrue()) - - resp := doReq("updateShare", "id", share.ID, "description", "Updated") - Expect(resp.Status).To(Equal(responses.StatusOK)) - - updated, err := ds.Share().Get(ctx, share.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Description).To(Equal("Updated")) - Expect(updated.Downloadable).To(BeTrue()) - }) - - It("updateShare applies an explicit downloadable and keeps the description", func() { - conf.Server.DefaultDownloadableShare = true - share := createShare("description", "Keep me") - - resp := doReq("updateShare", "id", share.ID, "downloadable", "false") - Expect(resp.Status).To(Equal(responses.StatusOK)) - - updated, err := ds.Share().Get(ctx, share.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Downloadable).To(BeFalse()) - Expect(updated.Description).To(Equal("Keep me")) - }) - - It("updateShare clears the description when it is sent empty", func() { - share := createShare("description", "Clear me") - - resp := doReq("updateShare", "id", share.ID, "description", "") - Expect(resp.Status).To(Equal(responses.StatusOK)) - - updated, err := ds.Share().Get(ctx, share.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(updated.Description).To(BeEmpty()) - }) -}) diff --git a/server/subsonic/e2e/subsonic_sonic_similarity_test.go b/server/subsonic/e2e/subsonic_sonic_similarity_test.go deleted file mode 100644 index 80fa658b7..000000000 --- a/server/subsonic/e2e/subsonic_sonic_similarity_test.go +++ /dev/null @@ -1,275 +0,0 @@ -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/artwork" - "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/navidrome/navidrome/tests/harness" - - . "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, harness.NoopFFmpeg{}) - return subsonic.New( - ds, - noopArtwork{}, - &harness.SpyStreamer{}, - noopArchiver{}, - core.NewPlayers(ds), - noopProvider{}, - nil, // scanner - events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), - scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), - core.NewShare(ds), - playback.PlaybackServer(nil), - metrics.NewNoopInstance(), - lyrics.NewLyrics(ds, 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().GetAll(ctx, model.QueryOptions{ - Filters: squirrel.Eq{"title": "Come Together"}, - }) - Expect(err).ToNot(HaveOccurred()) - Expect(songs).ToNot(BeEmpty()) - comeTogether = songs[0] - - songs, err = ds.MediaFile().GetAll(ctx, 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/subsonic/e2e/subsonic_stream_test.go b/server/subsonic/e2e/subsonic_stream_test.go deleted file mode 100644 index 81998760f..000000000 --- a/server/subsonic/e2e/subsonic_stream_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package e2e - -import ( - "encoding/json" - "errors" - "net/http" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("stream.view (legacy streaming)", Ordered, func() { - var ( - mp3TrackID string // Come Together (mp3, 320kbps) - flacTrackID string // TC FLAC Standard (flac, 900kbps) - flacMultichTrackID string // TC FLAC Multichannel (flac, 6ch) - ) - - BeforeAll(func() { - setupTestDB() - - songs, err := ds.MediaFile().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - byTitle := map[string]string{} - for _, s := range songs { - byTitle[s.Title] = s.ID - } - mp3TrackID = byTitle["Come Together"] - 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() { - It("streams raw when no format or maxBitRate is specified", func() { - w := doRawReq("stream", "id", flacTrackID) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", "")) - }) - - It("streams raw when format=raw is explicitly requested", func() { - w := doRawReq("stream", "id", flacTrackID, "format", "raw") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", "")) - }) - - It("streams raw when maxBitRate is >= source bitrate", func() { - w := doRawReq("stream", "id", flacTrackID, "maxBitRate", "1000") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", "")) - }) - - It("streams raw when format matches source and bitrate is not lower", func() { - w := doRawReq("stream", "id", mp3TrackID, "format", "mp3", "maxBitRate", "320") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("raw")) - }) - }) - - Describe("transcoding with explicit format", func() { - It("transcodes to mp3 when format=mp3 is requested", func() { - w := doRawReq("stream", "id", flacTrackID, "format", "mp3") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) - // Should use the mp3 default bitrate (192kbps) - Expect(streamerSpy.LastRequest.BitRate).To(Equal(192)) - }) - - It("transcodes to opus when format=opus is requested (no maxBitRate)", func() { - w := doRawReq("stream", "id", flacTrackID, "format", "opus") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) - // Should use the opus default bitrate (128kbps) - Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) - }) - - It("transcodes to opus with specified maxBitRate", func() { - w := doRawReq("stream", "id", flacTrackID, "format", "opus", "maxBitRate", "192") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) - Expect(streamerSpy.LastRequest.BitRate).To(Equal(192)) - }) - - It("transcodes to mp3 with specified maxBitRate", func() { - w := doRawReq("stream", "id", flacTrackID, "format", "mp3", "maxBitRate", "128") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) - Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) - }) - - It("transcodes MP3 to opus when format=opus is requested", func() { - w := doRawReq("stream", "id", mp3TrackID, "format", "opus") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) - }) - - It("transcodes same format when maxBitRate is lower than source", func() { - w := doRawReq("stream", "id", mp3TrackID, "format", "mp3", "maxBitRate", "128") - Expect(w.Code).To(Equal(http.StatusOK)) - 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() { - It("transcodes using default downsampling format when maxBitRate < source bitrate", func() { - conf.Server.DefaultDownsamplingFormat = "opus" - w := doRawReq("stream", "id", flacTrackID, "maxBitRate", "192") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("opus")) - Expect(streamerSpy.LastRequest.BitRate).To(Equal(192)) - }) - - It("streams raw when maxBitRate >= source bitrate (no downsampling needed)", func() { - conf.Server.DefaultDownsamplingFormat = "opus" - w := doRawReq("stream", "id", mp3TrackID, "maxBitRate", "320") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", "")) - }) - }) - - Describe("timeOffset", func() { - It("passes timeOffset to the stream request", func() { - w := doRawReq("stream", "id", flacTrackID, "format", "mp3", "timeOffset", "30") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Offset).To(Equal(30)) - }) - }) - - Describe("stream creation failure", func() { - BeforeEach(func() { - streamerSpy.SimulateError = errors.New("ffmpeg exited with non-zero status code: 1: Unknown encoder 'libopus'") - }) - AfterEach(func() { - streamerSpy.SimulateError = nil - }) - - It("returns a Subsonic error for stream endpoint", func() { - w := doRawReq("stream", "id", flacTrackID, "format", "opus") - Expect(w.Code).To(Equal(http.StatusOK)) // Subsonic errors are returned as 200 - - var wrapper responses.JsonWrapper - Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed()) - Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed)) - Expect(wrapper.Subsonic.Error).ToNot(BeNil()) - }) - - It("returns a Subsonic error for download endpoint", func() { - conf.Server.EnableDownloads = true - w := doRawReq("download", "id", flacTrackID, "format", "opus") - Expect(w.Code).To(Equal(http.StatusOK)) - - var wrapper responses.JsonWrapper - Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed()) - Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed)) - Expect(wrapper.Subsonic.Error).ToNot(BeNil()) - }) - }) - - Describe("empty transcoded output", func() { - BeforeEach(func() { - streamerSpy.SimulateEmptyStream = true - }) - AfterEach(func() { - streamerSpy.SimulateEmptyStream = false - }) - - It("returns 200 with empty body for stream endpoint", func() { - w := doRawReq("stream", "id", flacTrackID, "format", "opus") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.Len()).To(Equal(0)) - }) - - It("returns 200 with empty body for download endpoint", func() { - conf.Server.EnableDownloads = true - w := doRawReq("download", "id", flacTrackID, "format", "opus") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.Len()).To(Equal(0)) - }) - }) -}) diff --git a/server/subsonic/e2e/subsonic_system_test.go b/server/subsonic/e2e/subsonic_system_test.go deleted file mode 100644 index 16078f702..000000000 --- a/server/subsonic/e2e/subsonic_system_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package e2e - -import ( - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("System Endpoints", func() { - BeforeEach(func() { - setupTestDB() - }) - - Describe("ping", func() { - It("returns a successful response", func() { - resp := doReq("ping") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - }) - }) - - Describe("getLicense", func() { - It("returns a valid license", func() { - resp := doReq("getLicense") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.License).ToNot(BeNil()) - Expect(resp.License.Valid).To(BeTrue()) - }) - }) - - Describe("getOpenSubsonicExtensions", func() { - It("returns a list of supported extensions", func() { - resp := doReq("getOpenSubsonicExtensions") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.OpenSubsonicExtensions).ToNot(BeNil()) - Expect(*resp.OpenSubsonicExtensions).ToNot(BeEmpty()) - }) - - It("includes the transcodeOffset extension", func() { - resp := doReq("getOpenSubsonicExtensions") - - extensions := *resp.OpenSubsonicExtensions - var names []string - for _, ext := range extensions { - names = append(names, ext.Name) - } - Expect(names).To(ContainElement("transcodeOffset")) - }) - - It("includes the formPost extension", func() { - resp := doReq("getOpenSubsonicExtensions") - - extensions := *resp.OpenSubsonicExtensions - var names []string - for _, ext := range extensions { - names = append(names, ext.Name) - } - Expect(names).To(ContainElement("formPost")) - }) - - It("includes the songLyrics extension", func() { - resp := doReq("getOpenSubsonicExtensions") - - extensions := *resp.OpenSubsonicExtensions - var names []string - for _, ext := range extensions { - names = append(names, ext.Name) - } - Expect(names).To(ContainElement("songLyrics")) - }) - }) -}) diff --git a/server/subsonic/e2e/subsonic_transcode_test.go b/server/subsonic/e2e/subsonic_transcode_test.go deleted file mode 100644 index 313066938..000000000 --- a/server/subsonic/e2e/subsonic_transcode_test.go +++ /dev/null @@ -1,691 +0,0 @@ -package e2e - -import ( - "errors" - "net/http" - "time" - - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -// Client profile JSON bodies for getTranscodeDecision requests. -// All bitrate values are in bps (per OpenSubsonic spec). -const ( - // mp3OnlyClient can direct-play mp3 and transcode to mp3 - mp3OnlyClient = `{ - "name": "test-mp3-only", - "directPlayProfiles": [ - {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]} - ], - "transcodingProfiles": [ - {"container": "mp3", "audioCodec": "mp3", "protocol": "http"} - ] - }` - - // flacAndMp3Client can direct-play flac and mp3, transcode to mp3 - flacAndMp3Client = `{ - "name": "test-flac-mp3", - "directPlayProfiles": [ - {"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]}, - {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]} - ], - "transcodingProfiles": [ - {"container": "mp3", "audioCodec": "mp3", "protocol": "http"} - ] - }` - - // universalClient can direct-play most formats - universalClient = `{ - "name": "test-universal", - "directPlayProfiles": [ - {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}, - {"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]}, - {"containers": ["m4a"], "audioCodecs": ["alac", "aac"], "protocols": ["http"]}, - {"containers": ["opus", "ogg"], "audioCodecs": ["opus"], "protocols": ["http"]}, - {"containers": ["wav"], "audioCodecs": ["pcm"], "protocols": ["http"]}, - {"containers": ["dsf"], "audioCodecs": ["dsd"], "protocols": ["http"]} - ], - "transcodingProfiles": [ - {"container": "mp3", "audioCodec": "mp3", "protocol": "http"} - ] - }` - - // bitrateCapClient has maxAudioBitrate set to 320000 bps (320 kbps) - bitrateCapClient = `{ - "name": "test-bitrate-cap", - "maxAudioBitrate": 320000, - "directPlayProfiles": [ - {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]}, - {"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]} - ], - "transcodingProfiles": [ - {"container": "mp3", "audioCodec": "mp3", "protocol": "http"} - ] - }` - - // opusTranscodeClient can direct-play mp3, transcode to opus - opusTranscodeClient = `{ - "name": "test-opus-transcode", - "directPlayProfiles": [ - {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]} - ], - "transcodingProfiles": [ - {"container": "opus", "audioCodec": "opus", "protocol": "http"} - ] - }` - - // flacOnlyClient can direct-play flac, transcode to flac (no mp3 support at all) - flacOnlyClient = `{ - "name": "test-flac-only", - "directPlayProfiles": [ - {"containers": ["flac"], "audioCodecs": ["flac"], "protocols": ["http"]} - ], - "transcodingProfiles": [ - {"container": "flac", "audioCodec": "flac", "protocol": "http"} - ] - }` - - // maxTranscodeBitrateClient has maxTranscodingAudioBitrate set - maxTranscodeBitrateClient = `{ - "name": "test-max-transcode-bitrate", - "maxTranscodingAudioBitrate": 192000, - "directPlayProfiles": [ - {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]} - ], - "transcodingProfiles": [ - {"container": "mp3", "audioCodec": "mp3", "protocol": "http"} - ] - }` - - // dsdToFlacClient can direct-play mp3, transcode to flac - dsdToFlacClient = `{ - "name": "test-dsd-to-flac", - "directPlayProfiles": [ - {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]} - ], - "transcodingProfiles": [ - {"container": "flac", "audioCodec": "flac", "protocol": "http"} - ] - }` -) - -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) - 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() { - setupTestDB() - - songs, err := ds.MediaFile().GetAll(ctx) - Expect(err).ToNot(HaveOccurred()) - byTitle := map[string]string{} - for _, s := range songs { - byTitle[s.Title] = s.ID - } - ensureGetTrackID := func(title string) string { - id := byTitle[title] - Expect(id).ToNot(BeEmpty()) - return id - } - 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") - mkaOpusTrackID = ensureGetTrackID("TC MKA Opus") - }) - - Describe("getTranscodeDecision", func() { - // setPlayerMaxBitRate ensures a player exists for the test-client and sets its MaxBitRate. - // It makes a dummy request to register the player, then updates it via the repository. - setPlayerMaxBitRate := func(maxBitRate int) { - doReq("ping") - player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "") - Expect(err).ToNot(HaveOccurred()) - player.MaxBitRate = maxBitRate - Expect(ds.Player().Put(ctx, player)).To(Succeed()) - } - - setPlayerForcedFormat := func(format string) { - doReq("ping") - player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "") - Expect(err).ToNot(HaveOccurred()) - trc, err := ds.Transcoding().FindByFormat(ctx, format) - Expect(err).ToNot(HaveOccurred()) - player.TranscodingId = trc.ID - Expect(ds.Player().Put(ctx, player)).To(Succeed()) - } - - AfterEach(func() { - // Reset player MaxBitRate to 0 after each test - player, err := ds.Player().FindMatch(ctx, adminUser.ID, "test-client", "") - if err == nil { - player.MaxBitRate = 0 - player.TranscodingId = "" - _ = ds.Player().Put(ctx, player) - } - }) - - Describe("error cases", func() { - It("returns 405 for GET request", func() { - w := doRawReq("getTranscodeDecision", "mediaId", mp3TrackID, "mediaType", "song") - Expect(w.Code).To(Equal(http.StatusMethodNotAllowed)) - }) - - It("returns error when mediaId is missing", func() { - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) - }) - - It("returns error when mediaType is missing", func() { - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID) - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(responses.ErrorMissingParameter)) - }) - - It("returns error for unsupported mediaType", func() { - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "video") - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(responses.ErrorGeneric)) - }) - - It("returns error for invalid JSON body", func() { - resp := doPostReq("getTranscodeDecision", "{invalid-json", "mediaId", mp3TrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("returns error for empty JSON body", func() { - w := doRawPostReq("getTranscodeDecision", "", "mediaId", mp3TrackID, "mediaType", "song") - Expect(w.Code).To(Equal(http.StatusOK)) // Subsonic errors are returned as 200 with error status - resp := parseJSONResponse(w) - Expect(resp.Status).To(Equal(responses.StatusFailed)) - }) - - It("returns error for non-existent media ID", func() { - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", "non-existent-id", "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - Expect(resp.Error.Code).To(Equal(responses.ErrorDataNotFound)) - }) - - It("returns error for invalid protocol in body", func() { - invalidBody := `{ - "directPlayProfiles": [ - {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["invalid-protocol"]} - ] - }` - resp := doPostReq("getTranscodeDecision", invalidBody, "mediaId", mp3TrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("returns error for invalid comparison operator in body", func() { - invalidBody := `{ - "directPlayProfiles": [ - {"containers": ["mp3"], "audioCodecs": ["mp3"], "protocols": ["http"]} - ], - "codecProfiles": [{ - "type": "AudioCodec", "name": "mp3", - "limitations": [{"name": "audioBitrate", "comparison": "InvalidOp", "values": ["320000"]}] - }] - }` - resp := doPostReq("getTranscodeDecision", invalidBody, "mediaId", mp3TrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - }) - - Describe("direct play decisions", func() { - It("allows MP3 direct play when client supports mp3", func() { - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).To(BeNil()) - Expect(resp.TranscodeDecision.TranscodeParams).ToNot(BeEmpty()) - }) - - It("allows FLAC direct play when client supports flac", func() { - resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) - }) - - It("allows ALAC direct play via m4a container + alac codec matching", func() { - resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", alacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) - }) - - It("allows Opus direct play when client supports opus", func() { - resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", opusTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) - }) - - It("denies direct play when container mismatches", func() { - // mp3OnlyClient cannot play FLAC container - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) - }) - - It("denies direct play when codec mismatches", func() { - // MKA container with opus codec — client only supports mp3 - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mkaOpusTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) - }) - - It("denies direct play when maxAudioBitrate exceeded", func() { - // bitrateCapClient caps at 320kbps, FLAC is 900kbps - resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) - }) - }) - - Describe("transcode decisions", func() { - It("transcodes FLAC to MP3 when client only supports MP3", func() { - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) - Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("mp3")) - Expect(resp.TranscodeDecision.TranscodeParams).ToNot(BeEmpty()) - }) - - It("transcodes FLAC hi-res to Opus with correct sample rate", func() { - resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacHiResTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) - // Opus always outputs 48000 Hz - Expect(resp.TranscodeDecision.TranscodeStream.AudioSamplerate).To(Equal(int32(48000))) - }) - - It("transcodes DSD to FLAC with normalized sample rate and bit depth", func() { - resp := doPostReq("getTranscodeDecision", dsdToFlacClient, "mediaId", dsdTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("flac")) - // DSD sample rate normalized: 2822400 / 8 = 352800 - Expect(resp.TranscodeDecision.TranscodeStream.AudioSamplerate).To(Equal(int32(352800))) - // DSD 1-bit → 24-bit PCM - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitdepth).To(Equal(int32(24))) - }) - - It("refuses lossy to lossless transcoding: MP3 to FLAC", func() { - // flacOnlyClient can't direct-play mp3, and lossy→lossless transcode is rejected - resp := doPostReq("getTranscodeDecision", flacOnlyClient, "mediaId", mp3TrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - // MP3 is lossy, FLAC is lossless — should not allow transcoding - Expect(resp.TranscodeDecision.CanTranscode).To(BeFalse()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) - Expect(resp.TranscodeDecision.TranscodeParams).To(BeEmpty()) - }) - - It("caps transcode bitrate via maxTranscodingAudioBitrate", func() { - resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // 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() { - It("has correct sourceStream details", func() { - resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - src := resp.TranscodeDecision.SourceStream - Expect(src).ToNot(BeNil()) - Expect(src.Container).To(Equal("flac")) - Expect(src.Codec).To(Equal("flac")) - // AudioBitrate is in bps: 900 kbps * 1000 = 900000 bps - Expect(src.AudioBitrate).To(Equal(int32(900000))) - Expect(src.AudioSamplerate).To(Equal(int32(44100))) - Expect(src.AudioChannels).To(Equal(int32(2))) - Expect(src.Protocol).To(Equal("http")) - }) - - It("reports audioBitrate in bps (kbps * 1000)", func() { - resp := doPostReq("getTranscodeDecision", universalClient, "mediaId", mp3TrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - src := resp.TranscodeDecision.SourceStream - Expect(src).ToNot(BeNil()) - // MP3 is 320 kbps → 320000 bps - Expect(src.AudioBitrate).To(Equal(int32(320000))) - }) - }) - - Describe("player MaxBitRate cap is enforced", func() { - It("forces transcode when source bitrate exceeds player MaxBitRate", func() { - setPlayerMaxBitRate(320) // 320 kbps cap - - // FLAC is 900kbps. Player cap (320) < source → direct play is - // rejected and the file is transcoded down. - resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // Target bitrate is capped at the player MaxBitRate (320kbps). - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) - }) - - It("uses the player cap when it is more restrictive than the client limit", func() { - setPlayerMaxBitRate(192) // 192 kbps player cap - - // Client caps at 320kbps (bitrateCapClient); player is more - // restrictive at 192 → player cap wins. - resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // Player cap (192kbps) applies → 192000 bps. - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) - }) - }) - - Describe("format-aware default bitrate", func() { - It("uses mp3 format default (192kbps) for lossless-to-mp3 with no bitrate limits", func() { - // mp3OnlyClient has no maxAudioBitrate or maxTranscodingAudioBitrate - // FLAC → MP3 should use the mp3 default bitrate (192kbps), not hardcoded 256 - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) - // mp3 default is 192kbps = 192000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) - }) - - It("uses opus format default (128kbps) for lossless-to-opus with no bitrate limits", func() { - // opusTranscodeClient has no maxAudioBitrate or maxTranscodingAudioBitrate - // FLAC → Opus should use the opus default bitrate (128kbps) - resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) - // opus default is 128kbps = 128000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(128000))) - }) - - It("uses maxAudioBitrate as fallback for lossless-to-lossy when no maxTranscodingAudioBitrate", func() { - // bitrateCapClient has maxAudioBitrate=320000 but no maxTranscodingAudioBitrate - // FLAC → MP3: maxAudioBitrate (320kbps) should be used as the target - resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // maxAudioBitrate is 320kbps = 320000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) - }) - - It("prefers maxTranscodingAudioBitrate over maxAudioBitrate for lossless-to-lossy", func() { - // maxTranscodeBitrateClient has maxTranscodingAudioBitrate=192000 - // FLAC → MP3: should use 192kbps, not format default or maxAudioBitrate - resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // maxTranscodingAudioBitrate is 192kbps = 192000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) - }) - }) - - Describe("player MaxBitRate injected by getTranscodeDecision", func() { - It("injects the player cap as the transcode target when the client declares none", func() { - setPlayerMaxBitRate(320) - - // opusTranscodeClient has no client bitrate limits. The player - // cap (320) is injected, so FLAC (900kbps) → opus is capped at 320. - resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) - // Bitrate is the player cap (320kbps), not the opus format default. - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) - }) - - It("keeps the lower client maxTranscodingAudioBitrate over a higher player cap", func() { - setPlayerMaxBitRate(320) - - // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps). - // Player cap (320) is higher → the lower client limit wins. - resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // Client limit (192kbps) wins → 192000 bps. - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) - }) - }) - - Describe("player forced format", func() { - It("transcodes a FLAC to the forced opus format when the client supports it", func() { - setPlayerForcedFormat("opus") - - resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) - }) - - It("falls back to negotiation when the client does not support the forced format", func() { - setPlayerForcedFormat("opus") - - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) - }) - - It("applies maxBitRate on top of the forced format", func() { - setPlayerForcedFormat("opus") - setPlayerMaxBitRate(96) - - resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(96000))) - }) - }) - }) - - Describe("getTranscodeStream", func() { - Describe("error cases", func() { - It("returns 400 when mediaId is missing", func() { - w := doRawReq("getTranscodeStream", "mediaType", "song", "transcodeParams", "some-token") - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns 400 when mediaType is missing", func() { - w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "transcodeParams", "some-token") - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns 400 when transcodeParams is missing", func() { - w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song") - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns 400 for unsupported mediaType", func() { - w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "video", "transcodeParams", "some-token") - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns 410 for malformed token", func() { - w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", "invalid-token") - Expect(w.Code).To(Equal(http.StatusGone)) - }) - - It("returns 410 for stale token (media file updated after token issued)", func() { - // Get a valid decision token - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - token := resp.TranscodeDecision.TranscodeParams - Expect(token).ToNot(BeEmpty()) - - // Save original UpdatedAt and restore after test - mf, err := ds.MediaFile().Get(ctx, mp3TrackID) - Expect(err).ToNot(HaveOccurred()) - originalUpdatedAt := mf.UpdatedAt - - // Update the media file's UpdatedAt to simulate a change after token issuance - mf.UpdatedAt = time.Now().Add(time.Hour) - Expect(ds.MediaFile().Put(ctx, mf)).To(Succeed()) - - // Attempt to stream with the now-stale token - w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", token) - Expect(w.Code).To(Equal(http.StatusGone)) - - // Restore original UpdatedAt - mf.UpdatedAt = originalUpdatedAt - Expect(ds.MediaFile().Put(ctx, mf)).To(Succeed()) - }) - - It("returns 500 when stream creation fails", func() { - // Get a valid decision token - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - token := resp.TranscodeDecision.TranscodeParams - Expect(token).ToNot(BeEmpty()) - - // Simulate streamer failure (e.g., ffmpeg missing codec) - streamerSpy.SimulateError = errors.New("ffmpeg exited with non-zero status code: 1: Unknown encoder 'libopus'") - defer func() { streamerSpy.SimulateError = nil }() - - w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - - It("returns 500 when transcoded stream is empty", func() { - // Get a valid decision token - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - token := resp.TranscodeDecision.TranscodeParams - Expect(token).ToNot(BeEmpty()) - - // Simulate ffmpeg producing 0 bytes - streamerSpy.SimulateEmptyStream = true - defer func() { streamerSpy.SimulateEmptyStream = false }() - - w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - }) - }) - - Describe("round-trip: decision then stream", func() { - It("streams direct play for MP3", func() { - // Get decision - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) - token := resp.TranscodeDecision.TranscodeParams - Expect(token).ToNot(BeEmpty()) - - // Stream using the token - w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", "transcodeParams", token) - Expect(w.Code).To(Equal(http.StatusOK)) - // Direct play: format should be "raw" or empty - Expect(streamerSpy.LastRequest.Format).To(BeElementOf("raw", "")) - }) - - It("streams transcoded FLAC to MP3", func() { - // Get decision - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - token := resp.TranscodeDecision.TranscodeParams - Expect(token).ToNot(BeEmpty()) - - // Stream using the token - w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) - }) - - It("passes offset through to stream request", func() { - // Get decision - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - token := resp.TranscodeDecision.TranscodeParams - Expect(token).ToNot(BeEmpty()) - - // Stream with offset - w := doRawReq("getTranscodeStream", "mediaId", mp3TrackID, "mediaType", "song", - "transcodeParams", token, "offset", "30") - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Offset).To(Equal(30)) - }) - }) - }) -}) diff --git a/server/subsonic/e2e/subsonic_users_test.go b/server/subsonic/e2e/subsonic_users_test.go deleted file mode 100644 index 849089f11..000000000 --- a/server/subsonic/e2e/subsonic_users_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package e2e - -import ( - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("User Endpoints", func() { - BeforeEach(func() { - setupTestDB() - }) - - It("getUser returns current user info", func() { - resp := doReq("getUser", "username", adminUser.UserName) - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.User).ToNot(BeNil()) - Expect(resp.User.Username).To(Equal(adminUser.UserName)) - Expect(resp.User.AdminRole).To(BeTrue()) - Expect(resp.User.StreamRole).To(BeTrue()) - Expect(resp.User.Folder).ToNot(BeEmpty()) - }) - - It("getUser with matching username case-insensitive succeeds", func() { - resp := doReq("getUser", "username", "Admin") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.User).ToNot(BeNil()) - Expect(resp.User.Username).To(Equal(adminUser.UserName)) - }) - - It("getUser with different username returns authorization error", func() { - resp := doReq("getUser", "username", "otheruser") - - Expect(resp.Status).To(Equal(responses.StatusFailed)) - Expect(resp.Error).ToNot(BeNil()) - }) - - It("getUsers returns list with current user only", func() { - resp := doReq("getUsers") - - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.Users).ToNot(BeNil()) - Expect(resp.Users.User).To(HaveLen(1)) - Expect(resp.Users.User[0].Username).To(Equal(adminUser.UserName)) - Expect(resp.Users.User[0].AdminRole).To(BeTrue()) - }) -}) diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go new file mode 100644 index 000000000..fca482f33 --- /dev/null +++ b/server/subsonic/filter/filters.go @@ -0,0 +1,129 @@ +package filter + +import ( + "fmt" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" +) + +type Options = model.QueryOptions + +func AlbumsByNewest() Options { + return Options{Sort: "recently_added", Order: "desc"} +} + +func AlbumsByRecent() Options { + return Options{Sort: "playDate", Order: "desc", Filters: squirrel.Gt{"play_date": time.Time{}}} +} + +func AlbumsByFrequent() Options { + return Options{Sort: "playCount", Order: "desc", Filters: squirrel.Gt{"play_count": 0}} +} + +func AlbumsByRandom() Options { + return Options{Sort: "random()"} +} + +func AlbumsByName() Options { + return Options{Sort: "name"} +} + +func AlbumsByArtist() Options { + return Options{Sort: "artist"} +} + +func AlbumsByStarred() Options { + return Options{Sort: "starred_at", Order: "desc", Filters: squirrel.Eq{"starred": true}} +} + +func AlbumsByRating() Options { + return Options{Sort: "Rating", Order: "desc", Filters: squirrel.Gt{"rating": 0}} +} + +func AlbumsByGenre(genre string) Options { + return Options{ + Sort: "genre.name asc, name asc", + Filters: squirrel.Eq{"genre.name": genre}, + } +} + +func AlbumsByArtistID(artistId string) Options { + var filters squirrel.Sqlizer + if conf.Server.SubsonicArtistParticipations { + filters = squirrel.Like{"all_artist_ids": fmt.Sprintf("%%%s%%", artistId)} + } else { + filters = squirrel.Eq{"album_artist_id": artistId} + } + return Options{ + Sort: "max_year", + Filters: filters, + } +} + +func AlbumsByYear(fromYear, toYear int) Options { + sortOption := "max_year, name" + if fromYear > toYear { + fromYear, toYear = toYear, fromYear + sortOption = "max_year desc, name" + } + return Options{ + Sort: sortOption, + Filters: squirrel.Or{ + squirrel.And{ + squirrel.GtOrEq{"min_year": fromYear}, + squirrel.LtOrEq{"min_year": toYear}, + }, + squirrel.And{ + squirrel.GtOrEq{"max_year": fromYear}, + squirrel.LtOrEq{"max_year": toYear}, + }, + }, + } +} + +func SongsByGenre(genre string) Options { + return Options{ + Sort: "genre.name asc, title asc", + Filters: squirrel.Eq{"genre.name": genre}, + } +} + +func SongsByAlbum(albumId string) Options { + return Options{ + Filters: squirrel.Eq{"album_id": albumId}, + Sort: "album", + } +} + +func SongsByRandom(genre string, fromYear, toYear int) Options { + options := Options{ + Sort: "random()", + } + ff := squirrel.And{} + if genre != "" { + ff = append(ff, squirrel.Eq{"genre.name": genre}) + } + if fromYear != 0 { + ff = append(ff, squirrel.GtOrEq{"year": fromYear}) + } + if toYear != 0 { + ff = append(ff, squirrel.LtOrEq{"year": toYear}) + } + options.Filters = ff + return options +} + +func Starred() Options { + return Options{Sort: "starred_at", Order: "desc", Filters: squirrel.Eq{"starred": true}} +} + +func SongsWithLyrics(artist, title string) Options { + return Options{ + Sort: "updated_at", + Order: "desc", + Filters: squirrel.And{squirrel.Eq{"artist": artist, "title": title}, squirrel.NotEq{"lyrics": ""}}, + } +} diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 55b1b213e..f7b13d10a 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -1,32 +1,23 @@ package subsonic import ( - "cmp" "context" - "errors" "fmt" "mime" "net/http" - "slices" - "sort" "strings" - "time" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/public" "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" + "github.com/navidrome/navidrome/utils" ) func newResponse() *responses.Subsonic { return &responses.Subsonic{ - Status: responses.StatusOK, + Status: "ok", Version: Version, Type: consts.AppName, ServerVersion: consts.Version, @@ -34,25 +25,42 @@ func newResponse() *responses.Subsonic { } } -type subError struct { - code int32 - messages []any +func requiredParamString(r *http.Request, param string) (string, error) { + p := utils.ParamString(r, param) + if p == "" { + return "", newError(responses.ErrorMissingParameter, "required '%s' parameter is missing", param) + } + return p, nil } -func newError(code int32, message ...any) error { +func requiredParamStrings(r *http.Request, param string) ([]string, error) { + ps := utils.ParamStrings(r, param) + if len(ps) == 0 { + return nil, newError(responses.ErrorMissingParameter, "required '%s' parameter is missing", param) + } + return ps, nil +} + +func requiredParamInt(r *http.Request, param string) (int, error) { + p := utils.ParamString(r, param) + if p == "" { + return 0, newError(responses.ErrorMissingParameter, "required '%s' parameter is missing", param) + } + return utils.ParamInt(r, param, 0), nil +} + +type subError struct { + code int + messages []interface{} +} + +func newError(code int, message ...interface{}) error { return subError{ code: code, messages: message, } } -// errSubsonic and Unwrap are used to allow `errors.Is(err, errSubsonic)` to work -var errSubsonic = errors.New("subsonic API error") - -func (e subError) Unwrap() error { - return fmt.Errorf("%w: %d", errSubsonic, e.code) -} - func (e subError) Error() string { var msg string if len(e.messages) == 0 { @@ -71,92 +79,44 @@ func getUser(ctx context.Context) model.User { return model.User{} } -func sortName(sortName, orderName string) string { - if conf.Server.PreferSortTags { - return cmp.Or( - sortName, - orderName, - ) +func toArtists(r *http.Request, artists model.Artists) []responses.Artist { + as := make([]responses.Artist, len(artists)) + for i, artist := range artists { + as[i] = toArtist(r, artist) } - return orderName -} - -func getArtistAlbumCount(a *model.Artist) int32 { - // If ArtistParticipations are set, then `getArtist` will return albums - // where the artist is an album artist OR artist. Use the custom stat - // main credit for this calculation. - // Otherwise, return just the roles as album artist (precise) - if conf.Server.Subsonic.ArtistParticipations { - mainCreditStats := a.Stats[model.RoleMainCredit] - return int32(mainCreditStats.AlbumCount) - } else { - albumStats := a.Stats[model.RoleAlbumArtist] - return int32(albumStats.AlbumCount) - } -} - -// coverArtOrEmpty stamps the (hash-suffixed) coverArt id, or omits it (empty -// string + the field's omitempty tag) when artwork is known absent. -func coverArtOrEmpty(id model.ArtworkID, absent bool) string { - if absent { - return "" - } - return id.String() + return as } func toArtist(r *http.Request, a model.Artist) responses.Artist { artist := responses.Artist{ - Id: a.ID, - Name: a.Name, - UserRating: int32(a.Rating), - CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), - } - if !a.ImageAbsent { - artist.ArtistImageUrl = publicurl.ImageURL(r.Context(), a.CoverArtID(), 600) - } - if conf.Server.Subsonic.EnableAverageRating { - artist.AverageRating = a.AverageRating + Id: a.ID, + Name: a.Name, + AlbumCount: int32(a.AlbumCount), + UserRating: int32(a.Rating), + CoverArt: a.CoverArtID().String(), + ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600), } if a.Starred { - artist.Starred = a.StarredAt + artist.Starred = &a.StarredAt } return artist } func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 { artist := responses.ArtistID3{ - Id: a.ID, - Name: a.Name, - AlbumCount: getArtistAlbumCount(&a), - CoverArt: coverArtOrEmpty(a.CoverArtID(), a.ImageAbsent), - UserRating: int32(a.Rating), - } - if !a.ImageAbsent { - artist.ArtistImageUrl = publicurl.ImageURL(r.Context(), a.CoverArtID(), 600) - } - if conf.Server.Subsonic.EnableAverageRating { - artist.AverageRating = a.AverageRating + Id: a.ID, + Name: a.Name, + AlbumCount: int32(a.AlbumCount), + CoverArt: a.CoverArtID().String(), + ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600), + UserRating: int32(a.Rating), } if a.Starred { - artist.Starred = a.StarredAt + artist.Starred = &a.StarredAt } - artist.OpenSubsonicArtistID3 = toOSArtistID3(r.Context(), a) return artist } -func toOSArtistID3(ctx context.Context, a model.Artist) *responses.OpenSubsonicArtistID3 { - player, _ := request.PlayerFrom(ctx) - if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) { - return nil - } - artist := responses.OpenSubsonicArtistID3{ - MusicBrainzId: a.MbzArtistID, - SortName: sortName(a.SortArtistName, a.OrderArtistName), - } - artist.Roles = slice.Map(a.Roles(), func(r model.Role) string { return r.String() }) - return &artist -} - func toGenres(genres model.Genres) *responses.Genres { response := make([]responses.Genre, len(genres)) for i, g := range genres { @@ -169,14 +129,6 @@ func toGenres(genres model.Genres) *responses.Genres { return &responses.Genres{Genre: response} } -func toItemGenres(genres model.Genres) []responses.ItemGenre { - itemGenres := make([]responses.ItemGenre, len(genres)) - for i, g := range genres { - itemGenres[i] = responses.ItemGenre{Name: g.Name} - } - return itemGenres -} - func getTranscoding(ctx context.Context) (format string, bitRate int) { if trc, ok := request.TranscodingFrom(ctx); ok { format = trc.TargetFormat @@ -187,32 +139,15 @@ func getTranscoding(ctx context.Context) (format string, bitRate int) { return } -func isClientInList(clientList, client string) bool { - if clientList == "" || client == "" { - return false - } - clients := strings.SplitSeq(clientList, ",") - for c := range clients { - if strings.TrimSpace(c) == client { - return true - } - } - return false -} - +// This seems to be duplicated, but it is an initial step into merging `engine` and the `subsonic` packages, +// In the future there won't be any conversion to/from `engine. Entry` anymore func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child { child := responses.Child{} child.Id = mf.ID - child.Title = mf.FullTitle() + child.Title = mf.Title child.IsDir = false - - player, ok := request.PlayerFrom(ctx) - if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) { - return child - } - child.Parent = mf.AlbumID - child.Album = mf.FullAlbumName() + child.Album = mf.Album child.Year = int32(mf.Year) child.Artist = mf.Artist child.Genre = mf.Genre @@ -221,27 +156,27 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.Size = mf.Size child.Suffix = mf.Suffix child.BitRate = int32(mf.BitRate) - child.CoverArt = coverArtOrEmpty(mf.CoverArtID(), mf.ImageAbsent) + child.CoverArt = mf.CoverArtID().String() child.ContentType = mf.ContentType() - + player, ok := request.PlayerFrom(ctx) if ok && player.ReportRealPath { - child.Path = mf.AbsolutePath() + child.Path = mf.Path } else { child.Path = fakePath(mf) } child.DiscNumber = int32(mf.DiscNumber) - child.Created = new(mediaFileCreatedAt(mf)) + child.Created = &mf.CreatedAt child.AlbumId = mf.AlbumID child.ArtistId = mf.ArtistID child.Type = "music" child.PlayCount = mf.PlayCount + if mf.PlayCount > 0 { + child.Played = &mf.PlayDate + } if mf.Starred { - child.Starred = mf.StarredAt + child.Starred = &mf.StarredAt } child.UserRating = int32(mf.Rating) - if conf.Server.Subsonic.EnableAverageRating { - child.AverageRating = mf.AverageRating - } format, _ := getTranscoding(ctx) if mf.Suffix != "" && format != "" && mf.Suffix != format { @@ -249,320 +184,60 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.TranscodedContentType = mime.TypeByExtension("." + format) } child.BookmarkPosition = mf.BookmarkPosition - child.OpenSubsonicChild = osChildFromMediaFile(ctx, mf) return child } -func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.OpenSubsonicChild { - player, ok := request.PlayerFrom(ctx) - if ok && isClientInList(conf.Server.Subsonic.LegacyClients, player.Client) { - return nil - } - child := responses.OpenSubsonicChild{} - if mf.PlayCount > 0 { - child.Played = mf.PlayDate - } - child.Comment = mf.Comment - child.SortName = sortName(mf.SortTitle, mf.OrderTitle) - child.BPM = int32(gg.V(mf.BPM)) - child.MediaType = responses.MediaTypeSong - child.MusicBrainzId = mf.MbzRecordingID - child.Isrc = mf.Tags.Values(model.TagISRC) - child.ReplayGain = responses.ReplayGain{ - TrackGain: mf.RGTrackGain, - AlbumGain: mf.RGAlbumGain, - TrackPeak: mf.RGTrackPeak, - AlbumPeak: mf.RGAlbumPeak, - } - child.ChannelCount = int32(mf.Channels) - child.SamplingRate = int32(mf.SampleRate) - child.BitDepth = int32(gg.V(mf.BitDepth)) - child.Genres = toItemGenres(mf.Genres) - child.Moods = mf.Tags.Values(model.TagMood) - child.Groupings = mf.Tags.Values(model.TagGrouping) - child.DisplayArtist = mf.Artist - child.Artists = artistRefs(mf.Participants[model.RoleArtist]) - child.DisplayAlbumArtist = mf.AlbumArtist - child.AlbumArtists = artistRefs(mf.Participants[model.RoleAlbumArtist]) - var contributors []responses.Contributor - child.DisplayComposer = mf.Participants[model.RoleComposer].Join(consts.ArtistJoiner) - for role, participants := range mf.Participants { - if role == model.RoleArtist || role == model.RoleAlbumArtist { - continue - } - for _, participant := range participants { - contributors = append(contributors, responses.Contributor{ - Role: role.String(), - SubRole: participant.SubRole, - Artist: responses.ArtistID3Ref{ - Id: participant.ID, - Name: participant.Name, - }, - }) - } - } - child.Contributors = contributors - child.ExplicitStatus = mapExplicitStatus(mf.ExplicitStatus) - child.Works = slice.Map(mf.Works(), func(w model.Work) responses.Work { - return responses.Work{Name: w.Name, MusicBrainzId: w.MbzWorkID} - }) - child.Movements = slice.Map(mf.Movements(), func(m model.Movement) responses.Movement { - return responses.Movement{Name: m.Name, Number: m.Number, Count: m.Count} - }) - return &child -} - -func artistRefs(participants model.ParticipantList) []responses.ArtistID3Ref { - return slice.Map(participants, func(p model.Participant) responses.ArtistID3Ref { - return responses.ArtistID3Ref{ - Id: p.ID, - Name: p.Name, - } - }) -} - func fakePath(mf model.MediaFile) string { - builder := strings.Builder{} - - builder.WriteString(fmt.Sprintf("%s/%s/", sanitizeSlashes(mf.AlbumArtist), sanitizeSlashes(mf.FullAlbumName()))) - if mf.DiscNumber != 0 { - builder.WriteString(fmt.Sprintf("%02d-", mf.DiscNumber)) - } + filename := mapSlashToDash(mf.Title) if mf.TrackNumber != 0 { - builder.WriteString(fmt.Sprintf("%02d - ", mf.TrackNumber)) + filename = fmt.Sprintf("%02d - %s", mf.TrackNumber, filename) } - builder.WriteString(fmt.Sprintf("%s.%s", sanitizeSlashes(mf.FullTitle()), mf.Suffix)) - return builder.String() + return fmt.Sprintf("%s/%s/%s.%s", mapSlashToDash(mf.AlbumArtist), mapSlashToDash(mf.Album), filename, mf.Suffix) } -func sanitizeSlashes(target string) string { +func mapSlashToDash(target string) string { return strings.ReplaceAll(target, "/", "_") } -// albumCreatedAt mirrors the column used by recentlyAddedSort so clients can -// reproduce the "recently added" order locally: UpdatedAt when -// RecentlyAddedByModTime is set, CreatedAt otherwise. The other timestamps are -// fallbacks for legacy rows; returns zero only when all three are unset. -func albumCreatedAt(al model.Album) time.Time { - candidates := []time.Time{al.CreatedAt, al.UpdatedAt, al.ImportedAt} - if conf.Server.RecentlyAddedByModTime { - candidates = []time.Time{al.UpdatedAt, al.CreatedAt, al.ImportedAt} +func childrenFromMediaFiles(ctx context.Context, mfs model.MediaFiles) []responses.Child { + children := make([]responses.Child, len(mfs)) + for i, mf := range mfs { + children[i] = childFromMediaFile(ctx, mf) } - for _, t := range candidates { - if !t.IsZero() { - return t - } - } - return time.Time{} + return children } -// mediaFileCreatedAt is the song counterpart of albumCreatedAt, tracking -// mediaFileRecentlyAddedSort; BirthTime is the legacy fallback. -func mediaFileCreatedAt(mf model.MediaFile) time.Time { - candidates := []time.Time{mf.CreatedAt, mf.UpdatedAt, mf.BirthTime} - if conf.Server.RecentlyAddedByModTime { - candidates = []time.Time{mf.UpdatedAt, mf.CreatedAt, mf.BirthTime} - } - for _, t := range candidates { - if !t.IsZero() { - return t - } - } - return time.Time{} -} - -func childFromAlbum(ctx context.Context, al model.Album) responses.Child { +func childFromAlbum(_ context.Context, al model.Album) responses.Child { child := responses.Child{} child.Id = al.ID child.IsDir = true - fullName := al.FullName() - child.Title = fullName - child.Name = fullName - child.Album = fullName + child.Title = al.Name + child.Name = al.Name + child.Album = al.Name child.Artist = al.AlbumArtist - child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear)) + child.Year = int32(al.MaxYear) child.Genre = al.Genre - child.CoverArt = coverArtOrEmpty(al.CoverArtID(), al.ImageAbsent) - child.Created = new(albumCreatedAt(al)) + child.CoverArt = al.CoverArtID().String() + child.Created = &al.CreatedAt child.Parent = al.AlbumArtistID child.ArtistId = al.AlbumArtistID child.Duration = int32(al.Duration) child.SongCount = int32(al.SongCount) if al.Starred { - child.Starred = al.StarredAt + child.Starred = &al.StarredAt } child.PlayCount = al.PlayCount - child.UserRating = int32(al.Rating) - if conf.Server.Subsonic.EnableAverageRating { - child.AverageRating = al.AverageRating + if al.PlayCount > 0 { + child.Played = &al.PlayDate } - child.OpenSubsonicChild = osChildFromAlbum(ctx, al) + child.UserRating = int32(al.Rating) return child } -func osChildFromAlbum(ctx context.Context, al model.Album) *responses.OpenSubsonicChild { - player, _ := request.PlayerFrom(ctx) - if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) { - return nil +func childrenFromAlbums(ctx context.Context, als model.Albums) []responses.Child { + children := make([]responses.Child, len(als)) + for i, al := range als { + children[i] = childFromAlbum(ctx, al) } - child := responses.OpenSubsonicChild{} - if al.PlayCount > 0 { - child.Played = al.PlayDate - } - child.MediaType = responses.MediaTypeAlbum - child.MusicBrainzId = al.MbzAlbumID - child.Genres = toItemGenres(al.Genres) - child.Moods = al.Tags.Values(model.TagMood) - child.Groupings = al.Tags.Values(model.TagGrouping) - child.DisplayArtist = al.AlbumArtist - child.Artists = artistRefs(al.Participants[model.RoleAlbumArtist]) - child.DisplayAlbumArtist = al.AlbumArtist - child.AlbumArtists = artistRefs(al.Participants[model.RoleAlbumArtist]) - child.ExplicitStatus = mapExplicitStatus(al.ExplicitStatus) - child.SortName = sortName(al.SortAlbumName, al.OrderAlbumName) - return &child -} - -// toItemDate converts a string date in the formats 'YYYY-MM-DD', 'YYYY-MM' or 'YYYY' to an OS ItemDate -func toItemDate(date string) responses.ItemDate { - itemDate := responses.ItemDate{} - if date == "" { - return itemDate - } - parts := strings.Split(date, "-") - if len(parts) > 2 { - itemDate.Day = number.ParseInt[int32](parts[2]) - } - if len(parts) > 1 { - itemDate.Month = number.ParseInt[int32](parts[1]) - } - itemDate.Year = number.ParseInt[int32](parts[0]) - - return itemDate -} - -func buildDiscSubtitles(a model.Album) []responses.DiscTitle { - if len(a.Discs) == 0 { - 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), &updatedAt) - discTitles = append(discTitles, responses.DiscTitle{ - Disc: int32(num), - Title: title, - CoverArt: artID.String(), - }) - } - if len(discTitles) == 1 && discTitles[0].Title == "" { - return nil - } - sort.Slice(discTitles, func(i, j int) bool { - return discTitles[i].Disc < discTitles[j].Disc - }) - return discTitles -} - -func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { - dir := responses.AlbumID3{} - dir.Id = album.ID - dir.Name = album.FullName() - dir.Artist = album.AlbumArtist - dir.ArtistId = album.AlbumArtistID - dir.CoverArt = coverArtOrEmpty(album.CoverArtID(), album.ImageAbsent) - dir.SongCount = int32(album.SongCount) - dir.Duration = int32(album.Duration) - dir.PlayCount = album.PlayCount - dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear)) - dir.Genre = album.Genre - dir.Created = albumCreatedAt(album) - if album.Starred { - dir.Starred = album.StarredAt - } - dir.OpenSubsonicAlbumID3 = buildOSAlbumID3(ctx, album) - return dir -} - -func buildOSAlbumID3(ctx context.Context, album model.Album) *responses.OpenSubsonicAlbumID3 { - player, _ := request.PlayerFrom(ctx) - if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) { - return nil - } - dir := responses.OpenSubsonicAlbumID3{} - if album.PlayCount > 0 { - dir.Played = album.PlayDate - } - dir.UserRating = int32(album.Rating) - if conf.Server.Subsonic.EnableAverageRating { - dir.AverageRating = album.AverageRating - } - dir.RecordLabels = slice.Map(album.Tags.Values(model.TagRecordLabel), func(s string) responses.RecordLabel { - return responses.RecordLabel{Name: s} - }) - dir.MusicBrainzId = album.MbzAlbumID - dir.Genres = toItemGenres(album.Genres) - dir.Artists = artistRefs(album.Participants[model.RoleAlbumArtist]) - dir.DisplayArtist = album.AlbumArtist - dir.ReleaseTypes = album.Tags.Values(model.TagReleaseType) - dir.Moods = album.Tags.Values(model.TagMood) - dir.SortName = sortName(album.SortAlbumName, album.OrderAlbumName) - dir.OriginalReleaseDate = toItemDate(album.OriginalDate) - dir.ReleaseDate = toItemDate(album.ReleaseDate) - dir.IsCompilation = album.Compilation - dir.DiscTitles = buildDiscSubtitles(album) - dir.ExplicitStatus = mapExplicitStatus(album.ExplicitStatus) - dir.Version = album.Tags.First(model.TagAlbumVersion) - - return &dir -} - -func mapExplicitStatus(explicitStatus string) string { - switch explicitStatus { - case "c": - return "clean" - case "e": - return "explicit" - } - return "" -} - -// getUserAccessibleLibraries returns the list of libraries the current user has access to. -func getUserAccessibleLibraries(ctx context.Context) []model.Library { - user := getUser(ctx) - return user.Libraries -} - -// selectedMusicFolderIds retrieves the music folder IDs from the request parameters. -// If no IDs are provided, it returns all libraries the user has access to (based on the user found in the context). -// If the parameter is required and not present, it returns an error. -// If any of the provided library IDs are invalid (don't exist or user doesn't have access), returns ErrorDataNotFound. -func selectedMusicFolderIds(r *http.Request, required bool) ([]int, error) { - p := req.Params(r) - musicFolderIds, err := p.Ints("musicFolderId") - - // If the parameter is not present, it returns an error if it is required. - if errors.Is(err, req.ErrMissingParam) && required { - return nil, err - } - - // Get user's accessible libraries for validation - libraries := getUserAccessibleLibraries(r.Context()) - accessibleLibraryIds := slice.Map(libraries, func(lib model.Library) int { return lib.ID }) - - if len(musicFolderIds) > 0 { - // Validate all provided library IDs - if any are invalid, return an error - for _, id := range musicFolderIds { - if !slices.Contains(accessibleLibraryIds, id) { - return nil, newError(responses.ErrorDataNotFound, "Library %d not found or not accessible", id) - } - } - return musicFolderIds, nil - } - - // If no musicFolderId is provided, return all libraries the user has access to. - return accessibleLibraryIds, nil + return children } diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index 69fda4681..91b2f1000 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -1,30 +1,12 @@ package subsonic import ( - "context" - "encoding/json" - "net/http/httptest" - "time" - - "github.com/go-chi/jwtauth/v5" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("helpers", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - auth.TokenAuth = jwtauth.New("HS256", []byte("test secret"), nil) - auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test public secret"), nil) - }) - Describe("fakePath", func() { var mf model.MediaFile BeforeEach(func() { @@ -44,815 +26,11 @@ var _ = Describe("helpers", func() { Expect(fakePath(mf)).To(Equal("Brock Berrigan/Point Pleasant/04 - Split Decision.flac")) }) }) - When("TrackNumber and DiscNumber are available", func() { - It("adds the trackNumber to the path", func() { - mf.TrackNumber = 4 - mf.DiscNumber = 1 - Expect(fakePath(mf)).To(Equal("Brock Berrigan/Point Pleasant/01-04 - Split Decision.flac")) - }) - }) }) - Describe("sanitizeSlashes", func() { + Describe("mapSlashToDash", func() { It("maps / to _", func() { - Expect(sanitizeSlashes("AC/DC")).To(Equal("AC_DC")) - }) - }) - - Describe("sortName", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - When("PreferSortTags is false", func() { - BeforeEach(func() { - conf.Server.PreferSortTags = false - }) - It("returns the order name even if sort name is provided", func() { - Expect(sortName("Sort Album Name", "Order Album Name")).To(Equal("Order Album Name")) - }) - It("returns the order name if sort name is empty", func() { - Expect(sortName("", "Order Album Name")).To(Equal("Order Album Name")) - }) - }) - When("PreferSortTags is true", func() { - BeforeEach(func() { - conf.Server.PreferSortTags = true - }) - It("returns the sort name if provided", func() { - Expect(sortName("Sort Album Name", "Order Album Name")).To(Equal("Sort Album Name")) - }) - - It("returns the order name if sort name is empty", func() { - Expect(sortName("", "Order Album Name")).To(Equal("Order Album Name")) - }) - }) - It("returns an empty string if both sort name and order name are empty", func() { - Expect(sortName("", "")).To(Equal("")) - }) - }) - - Describe("buildDiscTitles", func() { - It("should return nil when album has no discs", func() { - album := model.Album{} - Expect(buildDiscSubtitles(album)).To(BeNil()) - }) - - It("should return nil when album has only one disc without title", func() { - album := model.Album{ - Discs: map[int]string{ - 1: "", - }, - } - Expect(buildDiscSubtitles(album)).To(BeNil()) - }) - - It("should return the disc title with cover art for a single disc", func() { - updatedAt := time.Now().Truncate(time.Second) - album := model.Album{ - ID: "album1", - UpdatedAt: updatedAt, - Discs: map[int]string{ - 1: "Special Edition", - }, - } - result := buildDiscSubtitles(album) - Expect(result).To(HaveLen(1)) - Expect(result[0].Disc).To(Equal(int32(1))) - Expect(result[0].Title).To(Equal("Special Edition")) - expectedArtID := model.NewArtworkID(model.KindDiscArtwork, "album1:1", &updatedAt) - Expect(result[0].CoverArt).To(Equal(expectedArtID.String())) - }) - - It("should return correct disc titles with cover art when album has multiple discs", func() { - updatedAt := time.Now().Truncate(time.Second) - album := model.Album{ - ID: "album1", - UpdatedAt: updatedAt, - Discs: map[int]string{ - 1: "Disc 1", - 2: "Disc 2", - }, - } - result := buildDiscSubtitles(album) - Expect(result).To(HaveLen(2)) - Expect(result[0].Disc).To(Equal(int32(1))) - Expect(result[0].Title).To(Equal("Disc 1")) - expectedArtID1 := model.NewArtworkID(model.KindDiscArtwork, "album1:1", &updatedAt) - Expect(result[0].CoverArt).To(Equal(expectedArtID1.String())) - Expect(result[1].Disc).To(Equal(int32(2))) - Expect(result[1].Title).To(Equal("Disc 2")) - expectedArtID2 := model.NewArtworkID(model.KindDiscArtwork, "album1:2", &updatedAt) - Expect(result[1].CoverArt).To(Equal(expectedArtID2.String())) - }) - }) - - DescribeTable("toItemDate", - func(date string, expected responses.ItemDate) { - Expect(toItemDate(date)).To(Equal(expected)) - }, - Entry("1994-02-04", "1994-02-04", responses.ItemDate{Year: 1994, Month: 2, Day: 4}), - Entry("1994-02", "1994-02", responses.ItemDate{Year: 1994, Month: 2}), - Entry("1994", "1994", responses.ItemDate{Year: 1994}), - Entry("19940201", "", responses.ItemDate{}), - Entry("", "", responses.ItemDate{}), - ) - - DescribeTable("mapExplicitStatus", - func(explicitStatus string, expected string) { - Expect(mapExplicitStatus(explicitStatus)).To(Equal(expected)) - }, - Entry("returns \"clean\" when the db value is \"c\"", "c", "clean"), - Entry("returns \"explicit\" when the db value is \"e\"", "e", "explicit"), - Entry("returns an empty string when the db value is \"\"", "", ""), - Entry("returns an empty string when there are unexpected values on the db", "abc", "")) - - Describe("getArtistAlbumCount", func() { - artist := model.Artist{ - Stats: map[model.Role]model.ArtistStats{ - model.RoleAlbumArtist: { - AlbumCount: 3, - }, - model.RoleMainCredit: { - AlbumCount: 4, - }, - }, - } - - It("Handles album count without artist participations", func() { - conf.Server.Subsonic.ArtistParticipations = false - result := getArtistAlbumCount(&artist) - Expect(result).To(Equal(int32(3))) - }) - - It("Handles album count without with participations", func() { - conf.Server.Subsonic.ArtistParticipations = true - result := getArtistAlbumCount(&artist) - Expect(result).To(Equal(int32(4))) - }) - }) - - DescribeTable("isClientInList", - func(list, client string, expected bool) { - Expect(isClientInList(list, client)).To(Equal(expected)) - }, - Entry("returns false when clientList is empty", "", "some-client", false), - Entry("returns false when client is empty", "client1,client2", "", false), - Entry("returns false when both are empty", "", "", false), - Entry("returns true when client matches single entry", "my-client", "my-client", true), - Entry("returns true when client matches first in list", "client1,client2,client3", "client1", true), - Entry("returns true when client matches middle in list", "client1,client2,client3", "client2", true), - Entry("returns true when client matches last in list", "client1,client2,client3", "client3", true), - Entry("returns false when client does not match", "client1,client2", "client3", false), - Entry("trims whitespace from client list entries", "client1, client2 , client3", "client2", true), - Entry("does not trim the client parameter", "client1,client2", " client1", false), - ) - - Describe("childFromMediaFile", func() { - var mf model.MediaFile - var ctx context.Context - - BeforeEach(func() { - mf = model.MediaFile{ - ID: "mf-1", - Title: "Test Song", - Album: "Test Album", - AlbumID: "album-1", - Artist: "Test Artist", - ArtistID: "artist-1", - Year: 2023, - Genre: "Rock", - TrackNumber: 5, - Duration: 180.5, - Size: 5000000, - Suffix: "mp3", - BitRate: 320, - } - ctx = context.Background() - }) - - Context("with minimal client", func() { - BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "minimal-client" - player := model.Player{Client: "minimal-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("returns only basic fields", func() { - child := childFromMediaFile(ctx, mf) - Expect(child.Id).To(Equal("mf-1")) - Expect(child.Title).To(Equal("Test Song")) - Expect(child.IsDir).To(BeFalse()) - - // These should not be set - Expect(child.Album).To(BeEmpty()) - Expect(child.Artist).To(BeEmpty()) - Expect(child.Parent).To(BeEmpty()) - Expect(child.Year).To(BeZero()) - Expect(child.Genre).To(BeEmpty()) - Expect(child.Track).To(BeZero()) - Expect(child.Duration).To(BeZero()) - Expect(child.Size).To(BeZero()) - Expect(child.Suffix).To(BeEmpty()) - Expect(child.BitRate).To(BeZero()) - Expect(child.CoverArt).To(BeEmpty()) - Expect(child.ContentType).To(BeEmpty()) - Expect(child.Path).To(BeEmpty()) - }) - - It("does not include OpenSubsonic extension", func() { - child := childFromMediaFile(ctx, mf) - Expect(child.OpenSubsonicChild).To(BeNil()) - }) - }) - - Context("with non-minimal client", func() { - BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "minimal-client" - player := model.Player{Client: "regular-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("returns all fields", func() { - child := childFromMediaFile(ctx, mf) - Expect(child.Id).To(Equal("mf-1")) - Expect(child.Title).To(Equal("Test Song")) - Expect(child.IsDir).To(BeFalse()) - Expect(child.Album).To(Equal("Test Album")) - Expect(child.Artist).To(Equal("Test Artist")) - Expect(child.Parent).To(Equal("album-1")) - Expect(child.Year).To(Equal(int32(2023))) - Expect(child.Genre).To(Equal("Rock")) - Expect(child.Track).To(Equal(int32(5))) - Expect(child.Duration).To(Equal(int32(180))) - Expect(child.Size).To(Equal(int64(5000000))) - Expect(child.Suffix).To(Equal("mp3")) - Expect(child.BitRate).To(Equal(int32(320))) - }) - }) - - Context("when minimal clients list is empty", func() { - BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "" - player := model.Player{Client: "any-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("returns all fields", func() { - child := childFromMediaFile(ctx, mf) - Expect(child.Album).To(Equal("Test Album")) - Expect(child.Artist).To(Equal("Test Artist")) - }) - }) - - Context("when no player in context", func() { - It("returns all fields", func() { - child := childFromMediaFile(ctx, mf) - Expect(child.Album).To(Equal("Test Album")) - Expect(child.Artist).To(Equal("Test Artist")) - }) - }) - - Context("when MediaFile has an empty title", func() { - It("still includes the title field in the response", func() { - mf.Title = "" - child := childFromMediaFile(ctx, mf) - Expect(child.Title).To(Equal("")) - }) - }) - }) - - Describe("artwork coverArt emission", func() { - const hash = "0123456789abcdef" - ctx := context.Background() - - DescribeTable("coverArtOrEmpty", - func(id model.ArtworkID, absent bool, expected string) { - Expect(coverArtOrEmpty(id, absent)).To(Equal(expected)) - }, - Entry("emits the bare id when unresolved", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1"}, false, "al-1"), - Entry("emits the hash-suffixed id when resolved", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1", Hash: hash}, false, "al-1_"+hash), - Entry("omits (empty) when known absent", model.ArtworkID{Kind: model.KindAlbumArtwork, ID: "1", Hash: hash}, true, ""), - ) - - Describe("childFromAlbum", func() { - It("suffixes coverArt with the content hash when resolved", func() { - al := model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageHash: hash}} - Expect(childFromAlbum(ctx, al).CoverArt).To(Equal("al-al-1_" + hash)) - }) - It("omits coverArt when known absent", func() { - al := model.Album{ID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}} - Expect(childFromAlbum(ctx, al).CoverArt).To(BeEmpty()) - }) - It("emits the bare id when unresolved", func() { - al := model.Album{ID: "al-1"} - Expect(childFromAlbum(ctx, al).CoverArt).To(Equal("al-al-1")) - }) - }) - - Describe("childFromMediaFile", func() { - // An al- id must carry the album's hash (AlbumImage), never the track's own. - It("suffixes coverArt with the album's content hash when resolved", func() { - mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", AlbumImage: model.ItemImage{ImageHash: hash}} - Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1_" + hash)) - }) - - It("does not stamp a track's own art hash onto the album id", func() { - mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", - ItemImage: model.ItemImage{ImageHash: "0000ownarthash00"}, - AlbumImage: model.ItemImage{ImageHash: hash}} - Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1_" + hash)) - }) - It("omits coverArt when known absent", func() { - mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1", ItemImage: model.ItemImage{ImageAbsent: true}} - Expect(childFromMediaFile(ctx, mf).CoverArt).To(BeEmpty()) - }) - It("emits the bare id when unresolved", func() { - mf := model.MediaFile{ID: "mf-1", AlbumID: "al-1"} - Expect(childFromMediaFile(ctx, mf).CoverArt).To(Equal("al-al-1")) - }) - }) - - Describe("toArtist / toArtistID3", func() { - r := httptest.NewRequest("GET", "/test", nil) - It("emits both coverArt and imageUrl when resolved", func() { - a := model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageHash: hash}} - artist := toArtist(r, a) - Expect(artist.CoverArt).To(Equal("ar-ar-1_" + hash)) - Expect(artist.ArtistImageUrl).ToNot(BeEmpty()) - }) - It("omits coverArt and imageUrl when known absent", func() { - a := model.Artist{ID: "ar-1", ItemImage: model.ItemImage{ImageAbsent: true}} - artist := toArtist(r, a) - Expect(artist.CoverArt).To(BeEmpty()) - Expect(artist.ArtistImageUrl).To(BeEmpty()) - - id3 := toArtistID3(r, a) - Expect(id3.CoverArt).To(BeEmpty()) - Expect(id3.ArtistImageUrl).To(BeEmpty()) - }) - It("emits an imageUrl when unresolved", func() { - a := model.Artist{ID: "ar-1"} - Expect(toArtist(r, a).ArtistImageUrl).ToNot(BeEmpty()) - }) - }) - }) - - Describe("osChildFromMediaFile", func() { - var mf model.MediaFile - var ctx context.Context - - BeforeEach(func() { - mf = model.MediaFile{ - ID: "mf-1", - Title: "Test Song", - Artist: "Test Artist", - Comment: "Test Comment", - } - ctx = context.Background() - }) - - 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 nil", func() { - osChild := osChildFromMediaFile(ctx, mf) - Expect(osChild).To(BeNil()) - }) - }) - - Context("with non-legacy client", func() { - BeforeEach(func() { - conf.Server.Subsonic.LegacyClients = "legacy-client" - player := model.Player{Client: "regular-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("returns OpenSubsonic child fields", func() { - osChild := osChildFromMediaFile(ctx, mf) - Expect(osChild).ToNot(BeNil()) - Expect(osChild.Comment).To(Equal("Test Comment")) - }) - - It("populates works and movements from tags", func() { - mf.Tags = model.Tags{ - model.TagWork: {"Symphony No. 5"}, - model.TagMusicBrainzWorkID: {"abc-123"}, - model.TagMovementName: {"I. Allegro"}, - model.TagMovementNumber: {"1"}, - model.TagMovementTotal: {"4"}, - } - osChild := osChildFromMediaFile(ctx, mf) - Expect(osChild).ToNot(BeNil()) - Expect(osChild.Works).To(Equal(responses.Array[responses.Work]{ - {Name: "Symphony No. 5", MusicBrainzId: "abc-123"}, - })) - Expect(osChild.Movements).To(Equal(responses.Array[responses.Movement]{ - {Name: "I. Allegro", Number: 1, Count: 4}, - })) - }) - - It("returns empty works and movements when no classical tags are present", func() { - osChild := osChildFromMediaFile(ctx, mf) - Expect(osChild).ToNot(BeNil()) - Expect(osChild.Works).To(BeEmpty()) - Expect(osChild.Movements).To(BeEmpty()) - }) - - It("serializes works and movements to spec-compliant JSON", func() { - mf.Tags = model.Tags{ - model.TagWork: {"Symphony No. 5"}, - model.TagMovementName: {"I. Allegro"}, - model.TagMovementNumber: {"1"}, - } - osChild := osChildFromMediaFile(ctx, mf) - data, err := json.Marshal(osChild) - Expect(err).ToNot(HaveOccurred()) - - var got map[string]any - Expect(json.Unmarshal(data, &got)).To(Succeed()) - // Required name present; optional musicBrainzId/count omitted (omitempty); number present. - Expect(got).To(HaveKeyWithValue("works", []any{ - map[string]any{"name": "Symphony No. 5"}, - })) - Expect(got).To(HaveKeyWithValue("movements", []any{ - map[string]any{"name": "I. Allegro", "number": float64(1)}, - })) - }) - }) - - Context("when legacy clients list is empty", func() { - BeforeEach(func() { - conf.Server.Subsonic.LegacyClients = "" - player := model.Player{Client: "any-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("returns OpenSubsonic child fields", func() { - osChild := osChildFromMediaFile(ctx, mf) - Expect(osChild).ToNot(BeNil()) - }) - }) - - Context("when no player in context", func() { - It("returns OpenSubsonic child fields", func() { - osChild := osChildFromMediaFile(ctx, mf) - Expect(osChild).ToNot(BeNil()) - }) - }) - }) - - Describe("selectedMusicFolderIds", func() { - var user model.User - var ctx context.Context - - BeforeEach(func() { - user = model.User{ - ID: "test-user", - Libraries: []model.Library{ - {ID: 1, Name: "Library 1"}, - {ID: 2, Name: "Library 2"}, - {ID: 3, Name: "Library 3"}, - }, - } - ctx = request.WithUser(context.Background(), user) - }) - - Context("when musicFolderId parameter is provided", func() { - It("should return the specified musicFolderId values", func() { - r := httptest.NewRequest("GET", "/test?musicFolderId=1&musicFolderId=3", nil) - r = r.WithContext(ctx) - - ids, err := selectedMusicFolderIds(r, false) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(Equal([]int{1, 3})) - }) - - It("should ignore invalid musicFolderId parameter values", func() { - r := httptest.NewRequest("GET", "/test?musicFolderId=invalid&musicFolderId=2", nil) - r = r.WithContext(ctx) - - ids, err := selectedMusicFolderIds(r, false) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(Equal([]int{2})) // Only valid ID is returned - }) - - It("should return error when any library ID is not accessible", func() { - r := httptest.NewRequest("GET", "/test?musicFolderId=1&musicFolderId=5&musicFolderId=2&musicFolderId=99", nil) - r = r.WithContext(ctx) - - ids, err := selectedMusicFolderIds(r, false) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("Library 5 not found or not accessible")) - Expect(ids).To(BeNil()) - }) - }) - - Context("when musicFolderId parameter is not provided", func() { - Context("and required is false", func() { - It("should return all user's library IDs", func() { - r := httptest.NewRequest("GET", "/test", nil) - r = r.WithContext(ctx) - - ids, err := selectedMusicFolderIds(r, false) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(Equal([]int{1, 2, 3})) - }) - - It("should return empty slice when user has no libraries", func() { - userWithoutLibs := model.User{ID: "no-libs-user", Libraries: []model.Library{}} - ctxWithoutLibs := request.WithUser(context.Background(), userWithoutLibs) - r := httptest.NewRequest("GET", "/test", nil) - r = r.WithContext(ctxWithoutLibs) - - ids, err := selectedMusicFolderIds(r, false) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(Equal([]int{})) - }) - }) - - Context("and required is true", func() { - It("should return ErrMissingParam error", func() { - r := httptest.NewRequest("GET", "/test", nil) - r = r.WithContext(ctx) - - ids, err := selectedMusicFolderIds(r, true) - Expect(err).To(MatchError(req.ErrMissingParam)) - Expect(ids).To(BeNil()) - }) - }) - }) - - Context("when musicFolderId parameter is empty", func() { - It("should return all user's library IDs even when empty parameter is provided", func() { - r := httptest.NewRequest("GET", "/test?musicFolderId=", nil) - r = r.WithContext(ctx) - - ids, err := selectedMusicFolderIds(r, false) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(Equal([]int{1, 2, 3})) - }) - }) - - Context("when all musicFolderId parameters are invalid", func() { - It("should return all user libraries when all musicFolderId parameters are invalid", func() { - r := httptest.NewRequest("GET", "/test?musicFolderId=invalid&musicFolderId=notanumber", nil) - r = r.WithContext(ctx) - - ids, err := selectedMusicFolderIds(r, false) - Expect(err).ToNot(HaveOccurred()) - Expect(ids).To(Equal([]int{1, 2, 3})) // Falls back to all user libraries - }) - }) - }) - - Describe("AverageRating in responses", func() { - var ctx context.Context - - BeforeEach(func() { - ctx = context.Background() - conf.Server.Subsonic.EnableAverageRating = true - }) - - Describe("childFromMediaFile", func() { - It("includes averageRating when set", func() { - mf := model.MediaFile{ - ID: "mf-avg-1", - Title: "Test Song", - Annotations: model.Annotations{ - AverageRating: 4.5, - }, - } - child := childFromMediaFile(ctx, mf) - Expect(child.AverageRating).To(Equal(4.5)) - }) - - It("returns 0 for averageRating when not set", func() { - mf := model.MediaFile{ - ID: "mf-avg-2", - Title: "Test Song No Rating", - } - child := childFromMediaFile(ctx, mf) - Expect(child.AverageRating).To(Equal(0.0)) - }) - }) - - Describe("childFromAlbum", func() { - It("includes averageRating when set", func() { - al := model.Album{ - ID: "al-avg-1", - Name: "Test Album", - Annotations: model.Annotations{ - AverageRating: 3.75, - }, - } - child := childFromAlbum(ctx, al) - Expect(child.AverageRating).To(Equal(3.75)) - }) - - It("returns 0 for averageRating when not set", func() { - al := model.Album{ - ID: "al-avg-2", - Name: "Test Album No Rating", - } - child := childFromAlbum(ctx, al) - Expect(child.AverageRating).To(Equal(0.0)) - }) - }) - - Describe("toArtist", func() { - It("includes averageRating when set", func() { - conf.Server.Subsonic.EnableAverageRating = true - r := httptest.NewRequest("GET", "/test", nil) - a := model.Artist{ - ID: "ar-avg-1", - Name: "Test Artist", - Annotations: model.Annotations{ - AverageRating: 5.0, - }, - } - artist := toArtist(r, a) - Expect(artist.AverageRating).To(Equal(5.0)) - }) - }) - - Describe("toArtistID3", func() { - It("includes averageRating when set", func() { - conf.Server.Subsonic.EnableAverageRating = true - r := httptest.NewRequest("GET", "/test", nil) - a := model.Artist{ - ID: "ar-avg-2", - Name: "Test Artist ID3", - Annotations: model.Annotations{ - AverageRating: 2.5, - }, - } - artist := toArtistID3(r, a) - Expect(artist.AverageRating).To(Equal(2.5)) - }) - }) - - Describe("buildAlbumID3 Created field", func() { - When("RecentlyAddedByModTime is false", func() { - BeforeEach(func() { - conf.Server.RecentlyAddedByModTime = false - }) - - 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).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).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).To(Equal(imported)) - }) - - It("leaves Created as zero time when all timestamps are zero", func() { - al := model.Album{ID: "a4", Name: "A"} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created.IsZero()).To(BeTrue()) - }) - }) - - When("RecentlyAddedByModTime is true", func() { - BeforeEach(func() { - conf.Server.RecentlyAddedByModTime = true - }) - - It("uses UpdatedAt even when CreatedAt is also set", func() { - created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) - updated := time.Date(2022, 6, 7, 8, 9, 10, 0, time.UTC) - al := model.Album{ID: "a5", Name: "A", CreatedAt: created, UpdatedAt: updated} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created).To(Equal(updated)) - }) - - It("falls back to CreatedAt when UpdatedAt is zero", func() { - created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) - al := model.Album{ID: "a6", Name: "A", CreatedAt: created} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created).To(Equal(created)) - }) - - It("falls back to ImportedAt when UpdatedAt and CreatedAt are zero", func() { - imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC) - al := model.Album{ID: "a7", Name: "A", ImportedAt: imported} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created).To(Equal(imported)) - }) - - It("leaves Created as zero time when all timestamps are zero", func() { - al := model.Album{ID: "a8", Name: "A"} - dir := buildAlbumID3(ctx, al) - Expect(dir.Created.IsZero()).To(BeTrue()) - }) - }) - }) - - Describe("childFromMediaFile Created field", func() { - birth := time.Date(2018, 1, 1, 0, 0, 0, 0, time.UTC) - - When("RecentlyAddedByModTime is false", func() { - BeforeEach(func() { - conf.Server.RecentlyAddedByModTime = false - }) - - It("uses CreatedAt, not BirthTime", func() { - created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) - mf := model.MediaFile{ID: "s1", BirthTime: birth, CreatedAt: created} - child := childFromMediaFile(ctx, mf) - Expect(*child.Created).To(Equal(created)) - }) - - It("falls back to UpdatedAt when CreatedAt is zero", func() { - updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC) - mf := model.MediaFile{ID: "s2", BirthTime: birth, UpdatedAt: updated} - child := childFromMediaFile(ctx, mf) - Expect(*child.Created).To(Equal(updated)) - }) - - It("falls back to BirthTime when CreatedAt and UpdatedAt are zero", func() { - mf := model.MediaFile{ID: "s3", BirthTime: birth} - child := childFromMediaFile(ctx, mf) - Expect(*child.Created).To(Equal(birth)) - }) - }) - - When("RecentlyAddedByModTime is true", func() { - BeforeEach(func() { - conf.Server.RecentlyAddedByModTime = true - }) - - It("uses UpdatedAt even when CreatedAt is also set", func() { - created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) - updated := time.Date(2022, 6, 7, 8, 9, 10, 0, time.UTC) - mf := model.MediaFile{ID: "s4", BirthTime: birth, CreatedAt: created, UpdatedAt: updated} - child := childFromMediaFile(ctx, mf) - Expect(*child.Created).To(Equal(updated)) - }) - - It("falls back to CreatedAt when UpdatedAt is zero", func() { - created := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) - mf := model.MediaFile{ID: "s5", BirthTime: birth, CreatedAt: created} - child := childFromMediaFile(ctx, mf) - Expect(*child.Created).To(Equal(created)) - }) - }) - }) - - Describe("EnableAverageRating config", func() { - It("excludes averageRating when disabled", func() { - conf.Server.Subsonic.EnableAverageRating = false - - mf := model.MediaFile{ - ID: "mf-cfg-1", - Title: "Test Song", - Annotations: model.Annotations{ - AverageRating: 4.5, - }, - } - child := childFromMediaFile(ctx, mf) - Expect(child.AverageRating).To(Equal(0.0)) - - al := model.Album{ - ID: "al-cfg-1", - Name: "Test Album", - Annotations: model.Annotations{ - AverageRating: 3.75, - }, - } - albumChild := childFromAlbum(ctx, al) - Expect(albumChild.AverageRating).To(Equal(0.0)) - - r := httptest.NewRequest("GET", "/test", nil) - a := model.Artist{ - ID: "ar-cfg-1", - Name: "Test Artist", - Annotations: model.Annotations{ - AverageRating: 5.0, - }, - } - artist := toArtist(r, a) - Expect(artist.AverageRating).To(Equal(0.0)) - - artistID3 := toArtistID3(r, a) - Expect(artistID3.AverageRating).To(Equal(0.0)) - }) + Expect(mapSlashToDash("AC/DC")).To(Equal("AC_DC")) }) }) }) diff --git a/server/subsonic/jukebox.go b/server/subsonic/jukebox.go deleted file mode 100644 index d8bf53360..000000000 --- a/server/subsonic/jukebox.go +++ /dev/null @@ -1,136 +0,0 @@ -package subsonic - -import ( - "net/http" - "strconv" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/playback" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/slice" -) - -const ( - ActionGet = "get" - ActionStatus = "status" - ActionSet = "set" - ActionStart = "start" - ActionStop = "stop" - ActionSkip = "skip" - ActionAdd = "add" - ActionClear = "clear" - ActionRemove = "remove" - ActionShuffle = "shuffle" - ActionSetGain = "setGain" -) - -func (api *Router) JukeboxControl(r *http.Request) (*responses.Subsonic, error) { - ctx := r.Context() - user := getUser(ctx) - p := req.Params(r) - - if !conf.Server.Jukebox.Enabled { - return nil, newError(responses.ErrorGeneric, "Jukebox is disabled") - } - - if conf.Server.Jukebox.AdminOnly && !user.IsAdmin { - return nil, newError(responses.ErrorAuthorizationFail, "Jukebox is admin only") - } - - actionString, err := p.String("action") - if err != nil { - return nil, err - } - - pb, err := api.playback.GetDeviceForUser(user.UserName) - if err != nil { - return nil, err - } - log.Info(ctx, "JukeboxControl request received", "action", actionString) - - switch actionString { - case ActionGet: - mediafiles, status, err := pb.Get(ctx) - if err != nil { - return nil, err - } - - playlist := responses.JukeboxPlaylist{ - JukeboxStatus: *deviceStatusToJukeboxStatus(status), - Entry: slice.MapWithArg(mediafiles, ctx, childFromMediaFile), - } - - response := newResponse() - response.JukeboxPlaylist = &playlist - return response, nil - case ActionStatus: - return createResponse(pb.Status(ctx)) - case ActionSet: - ids := p.Strings("id") - return createResponse(pb.Set(ctx, ids)) - case ActionStart: - return createResponse(pb.Start(ctx)) - case ActionStop: - return createResponse(pb.Stop(ctx)) - case ActionSkip: - index, err := p.Int("index") - if err != nil { - return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err) - } - offset := p.IntOr("offset", 0) - return createResponse(pb.Skip(ctx, index, offset)) - case ActionAdd: - ids := p.Strings("id") - return createResponse(pb.Add(ctx, ids)) - case ActionClear: - return createResponse(pb.Clear(ctx)) - case ActionRemove: - index, err := p.Int("index") - if err != nil { - return nil, err - } - - return createResponse(pb.Remove(ctx, index)) - case ActionShuffle: - return createResponse(pb.Shuffle(ctx)) - case ActionSetGain: - gainStr, err := p.String("gain") - if err != nil { - return nil, newError(responses.ErrorMissingParameter, "missing parameter gain, err: %s", err) - } - - gain, err := strconv.ParseFloat(gainStr, 32) - if err != nil { - return nil, newError(responses.ErrorMissingParameter, "error parsing gain float value, err: %s", err) - } - - return createResponse(pb.SetGain(ctx, float32(gain))) - default: - return nil, newError(responses.ErrorMissingParameter, "Unknown action: %s", actionString) - } -} - -// createResponse is to shorten the case-switch in the JukeboxController -func createResponse(status playback.DeviceStatus, err error) (*responses.Subsonic, error) { - if err != nil { - return nil, err - } - return statusResponse(status), nil -} - -func statusResponse(status playback.DeviceStatus) *responses.Subsonic { - response := newResponse() - response.JukeboxStatus = deviceStatusToJukeboxStatus(status) - return response -} - -func deviceStatusToJukeboxStatus(status playback.DeviceStatus) *responses.JukeboxStatus { - return &responses.JukeboxStatus{ - CurrentIndex: int32(status.CurrentIndex), - Playing: status.Playing, - Gain: status.Gain, - Position: int32(status.Position), - } -} diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go index 89e5cbc9b..dc0838a93 100644 --- a/server/subsonic/library_scanning.go +++ b/server/subsonic/library_scanning.go @@ -1,21 +1,21 @@ package subsonic import ( - "fmt" "net/http" - "slices" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils" ) func (api *Router) GetScanStatus(r *http.Request) (*responses.Subsonic, error) { + // TODO handle multiple mediafolders ctx := r.Context() - status, err := api.scanner.Status(ctx) + mediaFolder := conf.Server.MusicFolder + status, err := api.scanner.Status(mediaFolder) if err != nil { log.Error(ctx, "Error retrieving Scanner status", err) return nil, newError(responses.ErrorGeneric, "Internal Error") @@ -26,9 +26,6 @@ func (api *Router) GetScanStatus(r *http.Request) (*responses.Subsonic, error) { Count: int64(status.Count), FolderCount: int64(status.FolderCount), LastScan: &status.LastScan, - Error: status.LastError, - ScanType: status.ScanType, - ElapsedTime: int64(status.ElapsedTime), } return response, nil } @@ -40,93 +37,22 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { return nil, newError(responses.ErrorGeneric, "Internal error") } - p := req.Params(r) - fullScan := p.BoolOr("fullScan", false) - - // Parse optional target parameters for selective scanning - var targets []model.ScanTarget - if targetParams := p.Strings("target"); len(targetParams) > 0 { - var err error - targets, err = model.ParseTargets(targetParams) - if err != nil { - return nil, newError(responses.ErrorGeneric, fmt.Sprintf("Invalid target parameter: %v", err)) - } - - // Validate all libraries in targets exist and user has access to them - userLibraries, err := api.ds.User().GetUserLibraries(ctx, loggedUser.ID) - if err != nil { - return nil, newError(responses.ErrorGeneric, "Internal error") - } - - // Check each target library - for _, target := range targets { - if !slices.ContainsFunc(userLibraries, func(lib model.Library) bool { return lib.ID == target.LibraryID }) { - return nil, newError(responses.ErrorDataNotFound, fmt.Sprintf("Library with ID %d not found", target.LibraryID)) - } - } - - // Special case: if single library with empty path and it's the only library in DB, call ScanAll - if len(targets) == 1 && targets[0].FolderPath == "" { - allLibs, err := api.ds.Library().GetAll(ctx) - if err != nil { - return nil, newError(responses.ErrorGeneric, "Internal error") - } - if len(allLibs) == 1 { - targets = nil // This will trigger ScanAll below - } - } + if !loggedUser.IsAdmin { + return nil, newError(responses.ErrorAuthorizationFail) } - fastScanCompleted := make(chan struct{}) + fullScan := utils.ParamBool(r, "fullScan", false) + go func() { - defer close(fastScanCompleted) start := time.Now() - var err error - - if len(targets) > 0 { - log.Info(ctx, "Triggering on-demand scan", "fullScan", fullScan, "targets", len(targets), "user", loggedUser.UserName) - _, err = api.scanner.ScanFolders(ctx, fullScan, targets) - } else { - log.Info(ctx, "Triggering on-demand scan", "fullScan", fullScan, "user", loggedUser.UserName) - _, err = api.scanner.ScanAll(ctx, fullScan) - } - + log.Info(ctx, "Triggering manual scan", "fullScan", fullScan, "user", loggedUser.UserName) + err := api.scanner.RescanAll(ctx, fullScan) if err != nil { log.Error(ctx, "Error scanning", err) return } - log.Info(ctx, "On-demand scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start)) + log.Info(ctx, "Manual scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start).Round(100*time.Millisecond)) }() - // Wait briefly for the scanner to start and update its status, so the response - // reflects the current scan (not stale data from a previous scan). - const ( - pollInterval = 50 * time.Millisecond - pollTimeout = 3 * time.Second - ) - ticker := time.NewTicker(pollInterval) - defer ticker.Stop() - timer := time.NewTimer(pollTimeout) - defer timer.Stop() - -loop: - for { - status, err := api.scanner.Status(ctx) - if err == nil && status.Scanning { - break - } - select { - case <-fastScanCompleted: - log.Info(ctx, "Fast scan completed", "user", loggedUser.UserName) - break loop - case <-timer.C: - log.Warn(ctx, "Timed out waiting for scanner to start; response may be stale") - break loop - case <-ctx.Done(): - return nil, newError(responses.ErrorGeneric, "Request cancelled while waiting for scanner to start") - case <-ticker.C: - } - } - return api.GetScanStatus(r) } diff --git a/server/subsonic/library_scanning_test.go b/server/subsonic/library_scanning_test.go deleted file mode 100644 index e2b8827f4..000000000 --- a/server/subsonic/library_scanning_test.go +++ /dev/null @@ -1,433 +0,0 @@ -package subsonic - -import ( - "context" - "errors" - "net/http/httptest" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("LibraryScanning", func() { - var api *Router - var ms *tests.MockScanner - - BeforeEach(func() { - ms = tests.NewMockScanner() - api = &Router{scanner: ms} - }) - - Describe("StartScan", func() { - It("triggers a full scan with no parameters", func() { - // Create admin user - ctx := request.WithUser(context.Background(), model.User{ - ID: "admin-id", - IsAdmin: true, - }) - - // Create request with no parameters - r := httptest.NewRequest("GET", "/rest/startScan", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.StartScan(r) - - // Should succeed - Expect(err).ToNot(HaveOccurred()) - Expect(response).ToNot(BeNil()) - - // Verify ScanAll was called (eventually, since it's in a goroutine) - Eventually(func() int { - return ms.GetScanAllCallCount() - }).Should(BeNumerically(">", 0)) - calls := ms.GetScanAllCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].FullScan).To(BeFalse()) - }) - - It("triggers a full scan with fullScan=true", func() { - // Create admin user - ctx := request.WithUser(context.Background(), model.User{ - ID: "admin-id", - IsAdmin: true, - }) - - // Create request with fullScan parameter - r := httptest.NewRequest("GET", "/rest/startScan?fullScan=true", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.StartScan(r) - - // Should succeed - Expect(err).ToNot(HaveOccurred()) - Expect(response).ToNot(BeNil()) - - // Verify ScanAll was called with fullScan=true - Eventually(func() int { - return ms.GetScanAllCallCount() - }).Should(BeNumerically(">", 0)) - calls := ms.GetScanAllCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].FullScan).To(BeTrue()) - }) - - It("triggers a selective scan with single target parameter", func() { - // Setup mocks - mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1, 2}) - mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} - api.ds = mockDS - - // Create admin user - ctx := request.WithUser(context.Background(), model.User{ - ID: "admin-id", - IsAdmin: true, - }) - - // Create request with single target parameter - r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Rock", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.StartScan(r) - - // Should succeed - Expect(err).ToNot(HaveOccurred()) - Expect(response).ToNot(BeNil()) - - // Verify ScanFolders was called with correct targets - Eventually(func() int { - return ms.GetScanFoldersCallCount() - }).Should(BeNumerically(">", 0)) - calls := ms.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - targets := calls[0].Targets - Expect(targets).To(HaveLen(1)) - Expect(targets[0].LibraryID).To(Equal(1)) - Expect(targets[0].FolderPath).To(Equal("Music/Rock")) - }) - - It("triggers a selective scan with multiple target parameters", func() { - // Setup mocks - mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1, 2}) - mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} - api.ds = mockDS - - // Create admin user - ctx := request.WithUser(context.Background(), model.User{ - ID: "admin-id", - IsAdmin: true, - }) - - // Create request with multiple target parameters - r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Reggae&target=2:Classical/Bach", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.StartScan(r) - - // Should succeed - Expect(err).ToNot(HaveOccurred()) - Expect(response).ToNot(BeNil()) - - // Verify ScanFolders was called with correct targets - Eventually(func() int { - return ms.GetScanFoldersCallCount() - }).Should(BeNumerically(">", 0)) - calls := ms.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - targets := calls[0].Targets - Expect(targets).To(HaveLen(2)) - Expect(targets[0].LibraryID).To(Equal(1)) - Expect(targets[0].FolderPath).To(Equal("Music/Reggae")) - Expect(targets[1].LibraryID).To(Equal(2)) - Expect(targets[1].FolderPath).To(Equal("Classical/Bach")) - }) - - It("triggers a selective full scan with target and fullScan parameters", func() { - // Setup mocks - mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1}) - mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} - api.ds = mockDS - - // Create admin user - ctx := request.WithUser(context.Background(), model.User{ - ID: "admin-id", - IsAdmin: true, - }) - - // Create request with target and fullScan parameters - r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Jazz&fullScan=true", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.StartScan(r) - - // Should succeed - Expect(err).ToNot(HaveOccurred()) - Expect(response).ToNot(BeNil()) - - // Verify ScanFolders was called with fullScan=true - Eventually(func() int { - return ms.GetScanFoldersCallCount() - }).Should(BeNumerically(">", 0)) - calls := ms.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].FullScan).To(BeTrue()) - targets := calls[0].Targets - Expect(targets).To(HaveLen(1)) - }) - - It("returns error for invalid target format", func() { - // Create admin user - ctx := request.WithUser(context.Background(), model.User{ - ID: "admin-id", - IsAdmin: true, - }) - - // Create request with invalid target format (missing colon) - r := httptest.NewRequest("GET", "/rest/startScan?target=1MusicRock", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.StartScan(r) - - // Should return error - Expect(err).To(HaveOccurred()) - Expect(response).To(BeNil()) - var subErr subError - ok := errors.As(err, &subErr) - Expect(ok).To(BeTrue()) - Expect(subErr.code).To(Equal(responses.ErrorGeneric)) - }) - - It("returns error for invalid library ID in target", func() { - // Create admin user - ctx := request.WithUser(context.Background(), model.User{ - ID: "admin-id", - IsAdmin: true, - }) - - // Create request with invalid library ID - r := httptest.NewRequest("GET", "/rest/startScan?target=0:Music/Rock", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.StartScan(r) - - // Should return error - Expect(err).To(HaveOccurred()) - Expect(response).To(BeNil()) - var subErr subError - ok := errors.As(err, &subErr) - Expect(ok).To(BeTrue()) - Expect(subErr.code).To(Equal(responses.ErrorGeneric)) - }) - - It("returns error when library does not exist", func() { - // Setup mocks - user has access to library 1 and 2 only - mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1, 2}) - mockDS := &tests.MockDataStore{MockedUser: mockUserRepo} - api.ds = mockDS - - // Create admin user - ctx := request.WithUser(context.Background(), model.User{ - ID: "admin-id", - IsAdmin: true, - }) - - // Create request with library ID that doesn't exist - r := httptest.NewRequest("GET", "/rest/startScan?target=999:Music/Rock", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.StartScan(r) - - // Should return ErrorDataNotFound - Expect(err).To(HaveOccurred()) - Expect(response).To(BeNil()) - var subErr subError - ok := errors.As(err, &subErr) - Expect(ok).To(BeTrue()) - Expect(subErr.code).To(Equal(responses.ErrorDataNotFound)) - }) - - It("calls ScanAll when single library with empty path and only one library exists", func() { - // Setup mocks - single library in DB - mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1}) - mockLibraryRepo := &tests.MockLibraryRepo{} - mockLibraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Music Library", Path: "/music"}, - }) - mockDS := &tests.MockDataStore{ - MockedUser: mockUserRepo, - MockedLibrary: mockLibraryRepo, - } - api.ds = mockDS - - // Create admin user - ctx := request.WithUser(context.Background(), model.User{ - ID: "admin-id", - IsAdmin: true, - }) - - // Create request with single library and empty path - r := httptest.NewRequest("GET", "/rest/startScan?target=1:", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.StartScan(r) - - // Should succeed - Expect(err).ToNot(HaveOccurred()) - Expect(response).ToNot(BeNil()) - - // Verify ScanAll was called instead of ScanFolders - Eventually(func() int { - return ms.GetScanAllCallCount() - }).Should(BeNumerically(">", 0)) - Expect(ms.GetScanFoldersCallCount()).To(Equal(0)) - }) - - It("calls ScanFolders when single library with empty path but multiple libraries exist", func() { - // Setup mocks - multiple libraries in DB - mockUserRepo := tests.CreateMockUserRepo() - _ = mockUserRepo.SetUserLibraries(GinkgoT().Context(), "admin-id", []int{1, 2}) - mockLibraryRepo := &tests.MockLibraryRepo{} - mockLibraryRepo.SetData(model.Libraries{ - {ID: 1, Name: "Music Library", Path: "/music"}, - {ID: 2, Name: "Audiobooks", Path: "/audiobooks"}, - }) - mockDS := &tests.MockDataStore{ - MockedUser: mockUserRepo, - MockedLibrary: mockLibraryRepo, - } - api.ds = mockDS - - // Create admin user - ctx := request.WithUser(context.Background(), model.User{ - ID: "admin-id", - IsAdmin: true, - }) - - // Create request with single library and empty path - r := httptest.NewRequest("GET", "/rest/startScan?target=1:", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.StartScan(r) - - // Should succeed - Expect(err).ToNot(HaveOccurred()) - Expect(response).ToNot(BeNil()) - - // Verify ScanFolders was called (not ScanAll) - Eventually(func() int { - return ms.GetScanFoldersCallCount() - }).Should(BeNumerically(">", 0)) - calls := ms.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - targets := calls[0].Targets - Expect(targets).To(HaveLen(1)) - Expect(targets[0].LibraryID).To(Equal(1)) - Expect(targets[0].FolderPath).To(Equal("")) - }) - - It("returns correct scanType in response when fullScan=false", func() { - // Setup mock to update status when scan starts (simulating the real scanner) - ms.SetScanStatusFunc(func(fullScan bool, targets []model.ScanTarget) *model.ScannerStatus { - scanType := "quick" - if fullScan { - scanType = "full" - } - return &model.ScannerStatus{ - Scanning: true, - ScanType: scanType, - } - }) - - ctx := request.WithUser(context.Background(), model.User{ - ID: "admin-id", - IsAdmin: true, - }) - - r := httptest.NewRequest("GET", "/rest/startScan", nil) - r = r.WithContext(ctx) - - response, err := api.StartScan(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(response).ToNot(BeNil()) - Expect(response.ScanStatus).ToNot(BeNil()) - Expect(response.ScanStatus.Scanning).To(BeTrue()) - Expect(response.ScanStatus.ScanType).To(Equal("quick")) - }) - - It("returns correct scanType in response when fullScan=true", func() { - // Setup mock to update status when scan starts (simulating the real scanner) - ms.SetScanStatusFunc(func(fullScan bool, targets []model.ScanTarget) *model.ScannerStatus { - scanType := "quick" - if fullScan { - scanType = "full" - } - return &model.ScannerStatus{ - Scanning: true, - ScanType: scanType, - } - }) - - ctx := request.WithUser(context.Background(), model.User{ - ID: "admin-id", - IsAdmin: true, - }) - - r := httptest.NewRequest("GET", "/rest/startScan?fullScan=true", nil) - r = r.WithContext(ctx) - - response, err := api.StartScan(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(response).ToNot(BeNil()) - Expect(response.ScanStatus).ToNot(BeNil()) - Expect(response.ScanStatus.Scanning).To(BeTrue()) - Expect(response.ScanStatus.ScanType).To(Equal("full")) - }) - }) - - Describe("GetScanStatus", func() { - It("returns scan status", func() { - // Setup mock scanner status - ms.SetStatusResponse(&model.ScannerStatus{ - Scanning: false, - Count: 100, - FolderCount: 10, - }) - - // Create request - ctx := context.Background() - r := httptest.NewRequest("GET", "/rest/getScanStatus", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.GetScanStatus(r) - - // Should succeed - Expect(err).ToNot(HaveOccurred()) - Expect(response).ToNot(BeNil()) - Expect(response.ScanStatus).ToNot(BeNil()) - Expect(response.ScanStatus.Scanning).To(BeFalse()) - Expect(response.ScanStatus.Count).To(Equal(int64(100))) - Expect(response.ScanStatus.FolderCount).To(Equal(int64(10))) - }) - }) -}) diff --git a/server/subsonic/lyrics.go b/server/subsonic/lyrics.go deleted file mode 100644 index bfe1f1899..000000000 --- a/server/subsonic/lyrics.go +++ /dev/null @@ -1,265 +0,0 @@ -package subsonic - -import ( - "slices" - "sort" - "strings" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" -) - -// agentRoleMain is the OpenSubsonic agent role that marks the primary vocal -// layer; its cue line is emitted before other agents sharing the same index. -const agentRoleMain = "main" - -func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList, enhanced bool) *responses.LyricsList { - filtered := lyricsList - if !enhanced { - // Without enhanced, only return main-kind entries (a blank kind is main). - filtered = nil - for _, l := range lyricsList { - if l.IsMainKind() { - filtered = append(filtered, l) - } - } - } - - lyricList := make(responses.StructuredLyrics, len(filtered)) - for i, lyrics := range filtered { - lyricList[i] = buildStructuredLyric(mf, lyrics, enhanced) - } - return &responses.LyricsList{StructuredLyrics: lyricList} -} - -func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics, enhanced bool) responses.StructuredLyric { - agents := newLyricAgents(lyrics.Agents) - - lines := make([]responses.Line, len(lyrics.Line)) - var cueLines []responses.CueLine - for i, line := range lyrics.Line { - lines[i] = responses.Line{Start: line.Start, Value: line.Value} - if enhanced && len(line.Cue) > 0 { - cueLines = append(cueLines, buildCueLines(line, int32(i), agents)...) - } - } - - structured := responses.StructuredLyric{ - DisplayArtist: lyrics.DisplayArtist, - DisplayTitle: lyrics.DisplayTitle, - Lang: lyrics.Lang, - Line: lines, - CueLine: cueLines, - Offset: lyrics.Offset, - Synced: lyrics.Synced, - } - - if enhanced { - structured.Kind = lyrics.EffectiveKind() - if len(cueLines) > 0 && len(agents.response) > 0 { - structured.Agents = agents.response - } - } - - if structured.DisplayArtist == "" { - structured.DisplayArtist = mf.Artist - } - if structured.DisplayTitle == "" { - structured.DisplayTitle = mf.Title - } - return structured -} - -// lyricAgents indexes a lyric's agents by ID so cue lines can be ordered and -// the response agent list reused without rescanning the slice per line. -type lyricAgents struct { - orderByID map[string]int - roleByID map[string]string - response []responses.Agent -} - -func newLyricAgents(agents []model.Agent) lyricAgents { - a := lyricAgents{ - orderByID: make(map[string]int, len(agents)), - roleByID: make(map[string]string, len(agents)), - response: make([]responses.Agent, 0, len(agents)), - } - for i, agent := range agents { - a.orderByID[agent.ID] = i - a.roleByID[agent.ID] = agent.Role - a.response = append(a.response, responses.Agent{ID: agent.ID, Role: agent.Role, Name: agent.Name}) - } - return a -} - -// buildCueLines splits a line's cues by agent and emits one CueLine per agent, -// ordered main-role first then by the agent's declared order. -func buildCueLines(line model.Line, index int32, agents lyricAgents) []responses.CueLine { - agentOrder := make([]string, 0, 2) - cuesByAgent := make(map[string][]model.Cue) - for _, cue := range line.Cue { - if cue.Start == nil { - continue - } - agentID := strings.TrimSpace(cue.AgentID) - if _, exists := cuesByAgent[agentID]; !exists { - agentOrder = append(agentOrder, agentID) - } - cuesByAgent[agentID] = append(cuesByAgent[agentID], cue) - } - - sort.SliceStable(agentOrder, func(i, j int) bool { - return agents.less(agentOrder[i], agentOrder[j], i, j) - }) - - cueLines := make([]responses.CueLine, 0, len(agentOrder)) - for _, agentID := range agentOrder { - value := line.Value - cues := cuesByAgent[agentID] - if len(agentOrder) > 1 { - value, cues = buildAgentCueLineValue(line.Value, cues, line.Cue, agentID) - } - cueLine := responses.CueLine{ - Index: index, - Start: line.Start, - End: line.End, - Value: value, - Cue: buildLyricCues(cues, line.End), - } - if agentID != "" { - cueLine.AgentID = agentID - } - cueLines = append(cueLines, cueLine) - } - return cueLines -} - -// less orders two agent IDs: the main role wins, then the declared agent order, -// then known-before-unknown, then the original encounter order (origI/origJ). -func (a lyricAgents) less(left, right string, origI, origJ int) bool { - leftMain := a.roleByID[left] == agentRoleMain - rightMain := a.roleByID[right] == agentRoleMain - if leftMain != rightMain { - return leftMain - } - - leftOrder, leftOK := a.orderByID[left] - rightOrder, rightOK := a.orderByID[right] - if leftOK && rightOK && leftOrder != rightOrder { - return leftOrder < rightOrder - } - if leftOK != rightOK { - return leftOK - } - return origI < origJ -} - -func buildAgentCueLineValue(lineValue string, cues, allCues []model.Cue, agentID string) (string, []model.Cue) { - if len(cues) == 0 { - return "", nil - } - - remapped := slices.Clone(cues) - var value strings.Builder - leadingGap := cueLineGap(lineValue, 0, remapped[0].ByteStart, allCues, agentID) - if strings.TrimSpace(leadingGap) != "" { - value.WriteString(leadingGap) - } - - previousEnd := -1 - for i := range remapped { - originalStart := remapped[i].ByteStart - originalEnd := remapped[i].ByteEnd - if i > 0 { - value.WriteString(cueLineGap(lineValue, previousEnd+1, originalStart, allCues, agentID)) - } - - remapped[i].ByteStart = value.Len() - value.WriteString(remapped[i].Value) - remapped[i].ByteEnd = value.Len() - 1 - previousEnd = originalEnd - } - - trailingGap := cueLineGap(lineValue, previousEnd+1, len(lineValue), allCues, agentID) - if strings.TrimSpace(trailingGap) != "" { - value.WriteString(trailingGap) - } - return value.String(), remapped -} - -type byteRange struct { - start int - end int -} - -func cueLineGap(source string, start, end int, allCues []model.Cue, agentID string) string { - start = max(start, 0) - end = min(end, len(source)) - if start >= end { - return "" - } - - excluded := make([]byteRange, 0, 1) - for _, cue := range allCues { - if strings.TrimSpace(cue.AgentID) == agentID { - continue - } - cueStart := max(cue.ByteStart, start) - cueEnd := min(cue.ByteEnd+1, end) - if cueStart < cueEnd { - excluded = append(excluded, byteRange{start: cueStart, end: cueEnd}) - } - } - - if len(excluded) == 0 { - return source[start:end] - } - - sort.SliceStable(excluded, func(i, j int) bool { - return excluded[i].start < excluded[j].start - }) - - var gap strings.Builder - cursor := start - for _, r := range excluded { - if r.start > cursor { - gap.WriteString(source[cursor:r.start]) - } - cursor = max(cursor, r.end) - } - if cursor < end { - gap.WriteString(source[cursor:end]) - } - return gap.String() -} - -func buildLyricCues(cues []model.Cue, lineEnd *int64) []responses.LyricCue { - if len(cues) == 0 { - return nil - } - - // Only resolve end times when at least one cue carries one; otherwise the - // group is start-only and must stay that way. - hasAnyEnd := slices.ContainsFunc(cues, func(c model.Cue) bool { return c.End != nil }) - if hasAnyEnd { - cues = model.NormalizeCueEnds(cues, lineEnd) - } - - out := make([]responses.LyricCue, 0, len(cues)) - for i := range cues { - if cues[i].Start == nil { - continue - } - cue := responses.LyricCue{ - Start: *cues[i].Start, - Value: cues[i].Value, - ByteStart: cues[i].ByteStart, - ByteEnd: cues[i].ByteEnd, - } - if hasAnyEnd { - cue.End = cues[i].End - } - out = append(out, cue) - } - return out -} diff --git a/server/subsonic/lyrics_test.go b/server/subsonic/lyrics_test.go deleted file mode 100644 index 8713b7a3b..000000000 --- a/server/subsonic/lyrics_test.go +++ /dev/null @@ -1,842 +0,0 @@ -package subsonic - -import ( - "encoding/json" - "path/filepath" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/lyrics" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("GetLyricsBySongId", func() { - var router *Router - var ds model.DataStore - mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}} - - BeforeEach(func() { - ds = &tests.MockDataStore{ - MockedMediaFile: mockRepo, - } - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) - DeferCleanup(configtest.SetupConfig()) - conf.Server.LyricsPriority = "embedded,.lrc" - }) - - const syncedLyrics = "[00:18.80]We're no strangers to love\n[00:22.801]You know the rules and so do I" - const unsyncedLyrics = "We're no strangers to love\nYou know the rules and so do I" - const metadata = "[ar:Rick Astley]\n[ti:That one song]\n[offset:-100]" - var times = []int64{18800, 22801} - - compareResponses := func(actual *responses.LyricsList, expected responses.LyricsList) { - Expect(actual).ToNot(BeNil()) - Expect(actual.StructuredLyrics).To(HaveLen(len(expected.StructuredLyrics))) - for i, realLyric := range actual.StructuredLyrics { - expectedLyric := expected.StructuredLyrics[i] - - Expect(realLyric.DisplayArtist).To(Equal(expectedLyric.DisplayArtist)) - Expect(realLyric.DisplayTitle).To(Equal(expectedLyric.DisplayTitle)) - Expect(realLyric.Kind).To(Equal(expectedLyric.Kind)) - Expect(realLyric.Lang).To(Equal(expectedLyric.Lang)) - Expect(realLyric.Synced).To(Equal(expectedLyric.Synced)) - Expect(realLyric.Agents).To(Equal(expectedLyric.Agents)) - - if expectedLyric.Offset == nil { - Expect(realLyric.Offset).To(BeNil()) - } else { - Expect(*realLyric.Offset).To(Equal(*expectedLyric.Offset)) - } - - Expect(realLyric.Line).To(HaveLen(len(expectedLyric.Line))) - for j, realLine := range realLyric.Line { - expectedLine := expectedLyric.Line[j] - Expect(realLine.Value).To(Equal(expectedLine.Value)) - - if expectedLine.Start == nil { - Expect(realLine.Start).To(BeNil()) - } else { - Expect(*realLine.Start).To(Equal(*expectedLine.Start)) - } - } - - Expect(realLyric.CueLine).To(HaveLen(len(expectedLyric.CueLine))) - for j, realCueLine := range realLyric.CueLine { - expectedCueLine := expectedLyric.CueLine[j] - Expect(realCueLine.Index).To(Equal(expectedCueLine.Index)) - Expect(realCueLine.Value).To(Equal(expectedCueLine.Value)) - Expect(realCueLine.AgentID).To(Equal(expectedCueLine.AgentID)) - if expectedCueLine.Start == nil { - Expect(realCueLine.Start).To(BeNil()) - } else { - Expect(*realCueLine.Start).To(Equal(*expectedCueLine.Start)) - } - if expectedCueLine.End == nil { - Expect(realCueLine.End).To(BeNil()) - } else { - Expect(*realCueLine.End).To(Equal(*expectedCueLine.End)) - } - - Expect(realCueLine.Cue).To(HaveLen(len(expectedCueLine.Cue))) - for k, realCue := range realCueLine.Cue { - expectedCue := expectedCueLine.Cue[k] - Expect(realCue.Value).To(Equal(expectedCue.Value)) - Expect(realCue.Start).To(Equal(expectedCue.Start)) - Expect(realCue.ByteStart).To(Equal(expectedCue.ByteStart)) - Expect(realCue.ByteEnd).To(Equal(expectedCue.ByteEnd)) - if expectedCue.End == nil { - Expect(realCue.End).To(BeNil()) - } else { - Expect(*realCue.End).To(Equal(*expectedCue.End)) - } - } - } - } - } - - It("should return mixed lyrics", func() { - r := newGetRequest("id=1") - syncedList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "eng", []byte(syncedLyrics)) - unsyncedList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "xxx", []byte(unsyncedLyrics)) - synced, _ := syncedList.Main() - unsynced, _ := unsyncedList.Main() - lyricsJson, err := json.Marshal(model.LyricList{ - synced, unsynced, - }) - Expect(err).ToNot(HaveOccurred()) - - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - Lang: "eng", - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Synced: true, - Line: []responses.Line{ - { - Start: ×[0], - Value: "We're no strangers to love", - }, - { - Start: ×[1], - Value: "You know the rules and so do I", - }, - }, - }, - { - Lang: "xxx", - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Synced: false, - Line: []responses.Line{ - { - Value: "We're no strangers to love", - }, - { - Value: "You know the rules and so do I", - }, - }, - }, - }, - }) - }) - - It("should parse lrc metadata", func() { - r := newGetRequest("id=1") - syncedList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "eng", []byte(metadata+"\n"+syncedLyrics)) - synced, _ := syncedList.Main() - lyricsJson, err := json.Marshal(model.LyricList{ - synced, - }) - Expect(err).ToNot(HaveOccurred()) - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "That one song", - Lang: "eng", - Synced: true, - Line: []responses.Line{ - { - Start: ×[0], - Value: "We're no strangers to love", - }, - { - Start: ×[1], - Value: "You know the rules and so do I", - }, - }, - Offset: new(int64(-100)), - }, - }, - }) - }) - - It("should return multilingual TTML sidecar lyrics", func() { - conf.Server.LyricsPriority = ".ttml,embedded" - r := newGetRequest("id=1") - - fixturesDir, err := filepath.Abs("tests/fixtures") - Expect(err).ToNot(HaveOccurred()) - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - LibraryPath: fixturesDir, - Path: "test.mp3", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - - porTime := int64(18800) - ttmlTime := int64(22800) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Lang: "eng", - Synced: true, - Line: []responses.Line{ - { - Start: ×[0], - Value: "We're no strangers to love", - }, - { - Start: &ttmlTime, - Value: "You know the rules and so do I", - }, - }, - }, - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Lang: "por", - Synced: true, - Line: []responses.Line{ - { - Start: &porTime, - Value: "Nao somos estranhos ao amor", - }, - }, - }, - }, - }) - }) - - It("should return metadata-linked translation and pronunciation tracks from TTML", func() { - conf.Server.LyricsPriority = ".ttml,embedded" - r := newGetRequest("id=1&enhanced=true") - - fixturesDir, err := filepath.Abs("tests/fixtures") - Expect(err).ToNot(HaveOccurred()) - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - LibraryPath: fixturesDir, - Path: "test-metadata.mp3", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: "[]", - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - - mainStartA := int64(1000) - mainStartB := int64(2000) - tokenStartA := int64(2000) - tokenEndA := int64(2300) - tokenStartB := int64(2300) - tokenEndB := int64(2600) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Kind: "main", - Lang: "ja", - Synced: true, - Line: []responses.Line{ - { - Start: &mainStartA, - Value: "こんにちは", - }, - { - Start: &mainStartB, - Value: "こんばんは", - }, - }, - }, - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Kind: "translation", - Lang: "es", - Synced: true, - Line: []responses.Line{ - { - Start: &mainStartA, - Value: "Hola", - }, - }, - }, - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Kind: "pronunciation", - Lang: "ja-latn", - Synced: true, - Line: []responses.Line{ - { - Start: &mainStartB, - Value: "konni", - }, - }, - CueLine: []responses.CueLine{ - { - Index: 0, - Start: &mainStartB, - End: &tokenEndB, - Value: "konni", - Cue: []responses.LyricCue{ - { - Start: tokenStartA, - End: &tokenEndA, - ByteStart: 0, - ByteEnd: 1, - Value: "ko", - }, - { - Start: tokenStartB, - End: &tokenEndB, - ByteStart: 2, - ByteEnd: 4, - Value: "nni", - }, - }, - }, - }, - }, - }, - }) - }) - - It("should return cue lines for songLyrics v2 clients with enhanced=true", func() { - r := newGetRequest("id=1&enhanced=true") - - lineStart := int64(1000) - lineEnd := int64(3000) - tokenStartA := int64(1000) - tokenEndA := int64(1400) - tokenStartB := int64(2000) - tokenEndB := int64(2500) - lyricsJson, err := json.Marshal(model.LyricList{ - { - Lang: "eng", - Agents: []model.Agent{{ID: "lead", Role: "main"}, {ID: "__nd_bg__|lead", Role: "bg"}}, - Synced: true, - Line: []model.Line{ - { - Start: &lineStart, - End: &lineEnd, - Value: "Hello echo", - Cue: []model.Cue{ - { - Start: &tokenStartA, - End: &tokenEndA, - Value: "Hello", - ByteStart: 0, - ByteEnd: 4, - AgentID: "lead", - }, - { - Start: &tokenStartB, - End: &tokenEndB, - Value: "echo", - ByteStart: 6, - ByteEnd: 9, - AgentID: "__nd_bg__|lead", - }, - }, - }, - }, - }, - }) - Expect(err).ToNot(HaveOccurred()) - - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Kind: "main", - Lang: "eng", - Synced: true, - Agents: []responses.Agent{ - {ID: "lead", Role: "main"}, - {ID: "__nd_bg__|lead", Role: "bg"}, - }, - Line: []responses.Line{ - { - Start: &lineStart, - Value: "Hello echo", - }, - }, - CueLine: []responses.CueLine{ - { - Index: 0, - Start: &lineStart, - End: &lineEnd, - Value: "Hello", - AgentID: "lead", - Cue: []responses.LyricCue{ - { - Start: tokenStartA, - End: &tokenEndA, - ByteStart: 0, - ByteEnd: 4, - Value: "Hello", - }, - }, - }, - { - Index: 0, - Start: &lineStart, - End: &lineEnd, - Value: "echo", - AgentID: "__nd_bg__|lead", - Cue: []responses.LyricCue{ - { - Start: tokenStartB, - End: &tokenEndB, - ByteStart: 0, - ByteEnd: 3, - Value: "echo", - }, - }, - }, - }, - }, - }, - }) - }) - - It("should preserve shared edge text when remapping agent cue lines", func() { - lineStart := int64(1000) - lineEnd := int64(2000) - cueStart := int64(1200) - cueEnd := int64(1800) - - cueLines := buildCueLines(model.Line{ - Start: &lineStart, - End: &lineEnd, - Value: "(Hello)", - Cue: []model.Cue{ - { - Start: &cueStart, - End: &cueEnd, - Value: "Hello", - ByteStart: 1, - ByteEnd: 5, - AgentID: "lead", - }, - { - Start: &cueStart, - End: &cueEnd, - Value: "Hello", - ByteStart: 1, - ByteEnd: 5, - AgentID: "__nd_bg__|lead", - }, - }, - }, 0, newLyricAgents([]model.Agent{ - {ID: "lead", Role: "main"}, - {ID: "__nd_bg__|lead", Role: "bg"}, - })) - - Expect(cueLines).To(Equal([]responses.CueLine{ - { - Index: 0, - Start: &lineStart, - End: &lineEnd, - Value: "(Hello)", - AgentID: "lead", - Cue: []responses.LyricCue{ - { - Start: cueStart, - End: &cueEnd, - Value: "Hello", - ByteStart: 1, - ByteEnd: 5, - }, - }, - }, - { - Index: 0, - Start: &lineStart, - End: &lineEnd, - Value: "(Hello)", - AgentID: "__nd_bg__|lead", - Cue: []responses.LyricCue{ - { - Start: cueStart, - End: &cueEnd, - Value: "Hello", - ByteStart: 1, - ByteEnd: 5, - }, - }, - }, - })) - }) - - It("should remap cue offsets for interleaved agent cue lines", func() { - r := newGetRequest("id=1&enhanced=true") - - lineStart := int64(82889) - lineEnd := int64(86859) - realStart := int64(85593) - realEnd := int64(85934) - slowStart := int64(85934) - slowEnd := int64(86751) - bgStartA := int64(83881) - bgEndA := int64(84243) - bgStartB := int64(86232) - bgEndB := int64(86859) - lyricsJSON, err := json.Marshal(model.LyricList{ - { - Lang: "eng", - Agents: []model.Agent{{ID: "v2", Role: "main"}, {ID: "__nd_bg__|v2", Role: "bg"}}, - Synced: true, - Line: []model.Line{ - { - Start: &lineStart, - End: &lineEnd, - Value: "real slow (When you slide)", - Cue: []model.Cue{ - { - Start: &realStart, - End: &realEnd, - Value: "real", - ByteStart: 0, - ByteEnd: 3, - AgentID: "v2", - }, - { - Start: &slowStart, - End: &slowEnd, - Value: "slow", - ByteStart: 5, - ByteEnd: 8, - AgentID: "v2", - }, - { - Start: &bgStartA, - End: &bgEndA, - Value: "(When you", - ByteStart: 10, - ByteEnd: 18, - AgentID: "__nd_bg__|v2", - }, - { - Start: &bgStartB, - End: &bgEndB, - Value: "slide)", - ByteStart: 20, - ByteEnd: 25, - AgentID: "__nd_bg__|v2", - }, - }, - }, - }, - }, - }) - Expect(err).ToNot(HaveOccurred()) - - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJSON), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Kind: "main", - Lang: "eng", - Synced: true, - Agents: []responses.Agent{ - {ID: "v2", Role: "main"}, - {ID: "__nd_bg__|v2", Role: "bg"}, - }, - Line: []responses.Line{ - { - Start: &lineStart, - Value: "real slow (When you slide)", - }, - }, - CueLine: []responses.CueLine{ - { - Index: 0, - Start: &lineStart, - End: &lineEnd, - Value: "real slow", - AgentID: "v2", - Cue: []responses.LyricCue{ - { - Start: realStart, - End: &realEnd, - ByteStart: 0, - ByteEnd: 3, - Value: "real", - }, - { - Start: slowStart, - End: &slowEnd, - ByteStart: 5, - ByteEnd: 8, - Value: "slow", - }, - }, - }, - { - Index: 0, - Start: &lineStart, - End: &lineEnd, - Value: "(When you slide)", - AgentID: "__nd_bg__|v2", - Cue: []responses.LyricCue{ - { - Start: bgStartA, - End: &bgEndA, - ByteStart: 0, - ByteEnd: 8, - Value: "(When you", - }, - { - Start: bgStartB, - End: &bgEndB, - ByteStart: 10, - ByteEnd: 15, - Value: "slide)", - }, - }, - }, - }, - }, - }, - }) - }) - - It("should keep enhanced line-level lyrics when no cue data is available", func() { - r := newGetRequest("id=1&enhanced=true") - - lineStart := int64(1000) - lineEnd := int64(3000) - lyricsJSON, err := json.Marshal(model.LyricList{ - { - Kind: "main", - Lang: "eng", - Synced: true, - Line: []model.Line{ - { - Start: &lineStart, - End: &lineEnd, - Value: "Line without word timing", - }, - }, - }, - }) - Expect(err).ToNot(HaveOccurred()) - - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJSON), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Kind: "main", - Lang: "eng", - Synced: true, - Line: []responses.Line{ - { - Start: &lineStart, - Value: "Line without word timing", - }, - }, - }, - }, - }) - }) - - It("should return required cue byte offsets for ambiguous and multibyte cue lines", func() { - r := newGetRequest("id=1&enhanced=true") - - asciiLineStart := int64(0) - asciiLineEnd := int64(2400) - asciiCueStartA := int64(0) - asciiCueEndA := int64(300) - asciiCueStartB := int64(900) - asciiCueEndB := int64(1300) - asciiCueStartC := int64(1300) - asciiCueEndC := int64(1600) - asciiCueStartD := int64(1600) - - utfLineStart := int64(2747) - utfLineEnd := int64(6214) - utfCueStartA := int64(2747) - utfCueEndA := int64(3018) - utfCueStartB := int64(3018) - utfCueEndB := int64(3179) - utfCueStartC := int64(3582) - utfCueEndC := int64(4100) - utfCueStartD := int64(4500) - utfCueEndD := int64(6214) - - lyricsJSON, err := json.Marshal(model.LyricList{ - { - Lang: "eng", - Synced: true, - Line: []model.Line{ - { - Start: &asciiLineStart, - End: &asciiLineEnd, - Value: "Oh love love me tonight", - Cue: []model.Cue{ - {Start: &asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1}, - {Start: &asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11}, - {Start: &asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14}, - {Start: &asciiCueStartD, Value: "tonight", ByteStart: 16, ByteEnd: 22}, - }, - }, - { - Start: &utfLineStart, - End: &utfLineEnd, - Value: "눈을 뜬 순간", - Cue: []model.Cue{ - {Start: &utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2}, - {Start: &utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5}, - {Start: &utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9}, - {Start: &utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16}, - }, - }, - }, - }, - }) - Expect(err).ToNot(HaveOccurred()) - - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJSON), - }, - }) - - response, err := router.GetLyricsBySongId(r) - Expect(err).ToNot(HaveOccurred()) - compareResponses(response.LyricsList, responses.LyricsList{ - StructuredLyrics: responses.StructuredLyrics{ - { - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Kind: "main", - Lang: "eng", - Synced: true, - Line: []responses.Line{ - {Start: &asciiLineStart, Value: "Oh love love me tonight"}, - {Start: &utfLineStart, Value: "눈을 뜬 순간"}, - }, - CueLine: []responses.CueLine{ - { - Index: 0, - Start: &asciiLineStart, - End: &asciiLineEnd, - Value: "Oh love love me tonight", - Cue: []responses.LyricCue{ - {Start: asciiCueStartA, End: &asciiCueEndA, Value: "Oh", ByteStart: 0, ByteEnd: 1}, - {Start: asciiCueStartB, End: &asciiCueEndB, Value: "love", ByteStart: 8, ByteEnd: 11}, - {Start: asciiCueStartC, End: &asciiCueEndC, Value: "me", ByteStart: 13, ByteEnd: 14}, - {Start: asciiCueStartD, End: &asciiLineEnd, Value: "tonight", ByteStart: 16, ByteEnd: 22}, - }, - }, - { - Index: 1, - Start: &utfLineStart, - End: &utfLineEnd, - Value: "눈을 뜬 순간", - Cue: []responses.LyricCue{ - {Start: utfCueStartA, End: &utfCueEndA, Value: "눈", ByteStart: 0, ByteEnd: 2}, - {Start: utfCueStartB, End: &utfCueEndB, Value: "을", ByteStart: 3, ByteEnd: 5}, - {Start: utfCueStartC, End: &utfCueEndC, Value: "뜬", ByteStart: 7, ByteEnd: 9}, - {Start: utfCueStartD, End: &utfCueEndD, Value: "순간", ByteStart: 11, ByteEnd: 16}, - }, - }, - }, - }, - }, - }) - }) -}) diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index d6d160570..f6576b157 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "math" "net/http" "time" @@ -14,22 +13,26 @@ import ( "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils" ) func (api *Router) SetRating(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - id, err := p.String("id") + id, err := requiredParamString(r, "id") if err != nil { return nil, err } - rating, err := p.Int("rating") + rating, err := requiredParamInt(r, "rating") if err != nil { return nil, err } log.Debug(r, "Setting rating", "rating", rating, "id", id) err = api.setRating(r.Context(), id, rating) + + if errors.Is(err, model.ErrNotFound) { + log.Error(r, err) + return nil, newError(responses.ErrorDataNotFound, "ID not found") + } if err != nil { log.Error(r, err) return nil, err @@ -48,19 +51,16 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error { } switch entity.(type) { case *model.Artist: - repo = api.ds.Artist() + repo = api.ds.Artist(ctx) resource = "artist" case *model.Album: - repo = api.ds.Album() + repo = api.ds.Album(ctx) resource = "album" - case *model.Playlist: - repo = api.ds.Playlist() - resource = "playlist" default: - repo = api.ds.MediaFile() + repo = api.ds.MediaFile(ctx) resource = "song" } - err = repo.SetRating(ctx, rating, id) + err = repo.SetRating(rating, id) if err != nil { return err } @@ -70,10 +70,9 @@ func (api *Router) setRating(ctx context.Context, id string, rating int) error { } func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - ids := p.Strings("id") - albumIds := p.Strings("albumId") - artistIds := p.Strings("artistId") + ids := utils.ParamStrings(r, "id") + albumIds := utils.ParamStrings(r, "albumId") + artistIds := utils.ParamStrings(r, "artistId") if len(ids)+len(albumIds)+len(artistIds) == 0 { return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } @@ -89,10 +88,9 @@ func (api *Router) Star(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - ids := p.Strings("id") - albumIds := p.Strings("albumId") - artistIds := p.Strings("artistId") + ids := utils.ParamStrings(r, "id") + albumIds := utils.ParamStrings(r, "albumId") + artistIds := utils.ParamStrings(r, "artistId") if len(ids)+len(albumIds)+len(artistIds) == 0 { return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } @@ -109,51 +107,54 @@ func (api *Router) Unstar(r *http.Request) (*responses.Subsonic, error) { func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error { if len(ids) == 0 { - log.Warn(ctx, "Cannot star/unstar an empty list of ids") return nil } log.Debug(ctx, "Changing starred", "ids", ids, "starred", star) - err := api.ds.WithTxImmediate(func(tx model.DataStore) error { - event := &events.RefreshResource{} - changed := false + if len(ids) == 0 { + log.Warn(ctx, "Cannot star/unstar an empty list of ids") + return nil + } + event := &events.RefreshResource{} + err := api.ds.WithTx(func(tx model.DataStore) error { for _, id := range ids { - var repo model.AnnotatedRepository - var resource string - entity, err := model.GetEntityByID(ctx, tx, id) + exist, err := tx.Album(ctx).Exists(id) if err != nil { - if !errors.Is(err, model.ErrNotFound) { - return err - } - log.Warn(ctx, "Cannot star/unstar unknown id, skipping", "id", id) - continue - } - switch entity.(type) { - case *model.Artist: - repo = tx.Artist() - resource = "artist" - case *model.Album: - repo = tx.Album() - resource = "album" - case *model.Playlist: - repo = tx.Playlist() - resource = "playlist" - default: - repo = tx.MediaFile() - resource = "song" - } - if err := repo.SetStar(ctx, star, id); err != nil { return err } - event = event.With(resource, id) - changed = true - } - // Skip the broadcast when nothing changed: an empty RefreshResource - // serializes as a "{*:*}" wildcard, forcing every client to refresh. - if changed { - api.broker.SendMessage(ctx, event) + if exist { + err = tx.Album(ctx).SetStar(star, id) + if err != nil { + return err + } + event = event.With("album", id) + continue + } + exist, err = tx.Artist(ctx).Exists(id) + if err != nil { + return err + } + if exist { + err = tx.Artist(ctx).SetStar(star, id) + if err != nil { + return err + } + event = event.With("artist", id) + continue + } + err = tx.MediaFile(ctx).SetStar(star, id) + if err != nil { + return err + } + event = event.With("song", id) } + api.broker.SendMessage(ctx, event) return nil }) + + if errors.Is(err, model.ErrNotFound) { + log.Error(ctx, err) + return newError(responses.ErrorDataNotFound, "ID not found") + } if err != nil { log.Error(ctx, err) return err @@ -162,17 +163,15 @@ func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error } func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - ids := p.Strings("id") - if len(ids) == 0 { - return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'") + ids, err := requiredParamStrings(r, "id") + if err != nil { + return nil, err } - times, _ := p.Times("time") + times := utils.ParamTimes(r, "time") if len(times) > 0 && len(times) != len(ids) { return nil, newError(responses.ErrorGeneric, "Wrong number of timestamps: %d, should be %d", len(times), len(ids)) } - submission := p.BoolOr("submission", true) - position := p.IntOr("position", 0) + submission := utils.ParamBool(r, "submission", true) ctx := r.Context() if submission { @@ -181,10 +180,7 @@ func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) { log.Error(ctx, "Error registering scrobbles", "ids", ids, "times", times, err) } } else { - if len(ids) > 1 { - log.Warn(ctx, "Multiple ids sent to a nowPlaying notification, only the first one will be used", "ids", ids) - } - err := api.scrobblerNowPlaying(ctx, ids[0], position) + err := api.scrobblerNowPlaying(ctx, ids[0]) if err != nil { log.Error(ctx, "Error setting NowPlaying", "id", ids[0], err) } @@ -209,8 +205,8 @@ func (api *Router) scrobblerSubmit(ctx context.Context, ids []string, times []ti return api.scrobbler.Submit(ctx, submissions) } -func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, position int) error { - mf, err := api.ds.MediaFile().Get(ctx, trackId) +func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string) error { + mf, err := api.ds.MediaFile(ctx).Get(trackId) if err != nil { return err } @@ -226,74 +222,7 @@ func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, posi clientId = player.ID } - log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name, "position", position) - 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 + log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name) + err = api.scrobbler.NowPlaying(ctx, clientId, client, trackId) + return err } diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 9948929e3..8c64e0f1d 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -6,12 +6,13 @@ import ( "net/http" "time" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/core/scrobbler" "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/navidrome/navidrome/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -28,14 +29,12 @@ 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, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil) }) Describe("Scrobble", func() { It("submit all scrobbles with only the id", func() { - // Back-date the baseline so the assertion still passes on platforms - // with millisecond clock resolution (e.g. Windows). - submissionTime := time.Now().Add(-time.Second) + submissionTime := time.Now() r := newGetRequest("id=12", "id=34") _, err := router.Scrobble(r) @@ -50,9 +49,9 @@ var _ = Describe("MediaAnnotationController", func() { It("submit all scrobbles with respective times", func() { time1 := time.Now().Add(-20 * time.Minute) - t1 := time1.UnixMilli() + t1 := utils.ToMillis(time1) time2 := time.Now().Add(-10 * time.Minute) - t2 := time2.UnixMilli() + t2 := utils.ToMillis(time2) r := newGetRequest("id=12", "id=34", fmt.Sprintf("time=%d", t1), fmt.Sprintf("time=%d", t2)) _, err := router.Scrobble(r) @@ -77,7 +76,7 @@ var _ = Describe("MediaAnnotationController", func() { Context("submission=false", func() { var req *http.Request BeforeEach(func() { - _ = ds.MediaFile().Put(ctx, &model.MediaFile{ID: "12"}) + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "12"}) ctx = request.WithPlayer(ctx, model.Player{ID: "player-1"}) req = newGetRequest("id=12", "submission=false") req = req.WithContext(ctx) @@ -90,168 +89,35 @@ var _ = Describe("MediaAnnotationController", func() { Expect(playTracker.Submissions).To(BeEmpty()) }) - It("registers a NowPlaying via ReportPlayback", func() { + It("registers a NowPlaying", func() { _, err := router.Scrobble(req) Expect(err).ToNot(HaveOccurred()) - 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")) + Expect(playTracker.Playing).To(HaveLen(1)) + Expect(playTracker.Playing).To(HaveKey("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()) - }) - }) - - Describe("Star/Unstar playlists", func() { - var plRepo *tests.MockPlaylistRepo - - BeforeEach(func() { - plRepo = tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pl-1", Name: "My Playlist", OwnerID: "u1"}}) - ds.(*tests.MockDataStore).MockedPlaylist = plRepo - }) - - It("stars a playlist by dispatching to the Playlist repo", func() { - r := newGetRequest("id=pl-1") - - _, err := router.Star(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(plRepo.Starred).To(HaveKeyWithValue("pl-1", true)) - }) - - It("unstars a playlist by dispatching to the Playlist repo", func() { - r := newGetRequest("id=pl-1") - - _, err := router.Unstar(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(plRepo.Starred).To(HaveKeyWithValue("pl-1", false)) - }) - }) - - Describe("SetRating playlists", func() { - var plRepo *tests.MockPlaylistRepo - - BeforeEach(func() { - plRepo = tests.CreateMockPlaylistRepo() - plRepo.SetData(model.Playlists{{ID: "pl-1", Name: "My Playlist", OwnerID: "u1"}}) - ds.(*tests.MockDataStore).MockedPlaylist = plRepo - }) - - It("rates a playlist by dispatching to the Playlist repo", func() { - r := newGetRequest("id=pl-1", "rating=4") - - _, err := router.SetRating(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(plRepo.Ratings).To(HaveKeyWithValue("pl-1", 4)) - }) - }) - - Describe("Star with an unresolvable id", func() { - It("skips the id without broadcasting an empty (wildcard) refresh", func() { - r := newGetRequest("id=does-not-exist") - - _, err := router.Star(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(eventBroker.Events).To(BeEmpty()) - }) - }) }) type fakePlayTracker struct { - Submissions []scrobbler.Submission - ReportedPlayback []scrobbler.ReportPlaybackParams - Error error + Submissions []scrobbler.Submission + Playing map[string]string + Error error } -func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.PlaybackSession, error) { +func (f *fakePlayTracker) NowPlaying(_ context.Context, playerId string, _ string, trackId string) 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) { return nil, f.Error } @@ -263,14 +129,6 @@ 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 { @@ -282,8 +140,4 @@ func (f *fakeEventBroker) SendMessage(_ context.Context, event events.Event) { f.Events = append(f.Events, event) } -func (f *fakeEventBroker) SendBroadcastMessage(_ context.Context, event events.Event) { - f.Events = append(f.Events, event) -} - var _ events.Broker = (*fakeEventBroker)(nil) diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index f5e2ceb25..1190c6306 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -5,7 +5,7 @@ import ( "errors" "io" "net/http" - "strings" + "regexp" "time" "github.com/navidrome/navidrome/conf" @@ -13,23 +13,22 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/server/imghttp" + "github.com/navidrome/navidrome/server/subsonic/filter" "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/gravatar" - "github.com/navidrome/navidrome/utils/req" ) func (api *Router) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { if !conf.Server.EnableGravatar { return api.getPlaceHolderAvatar(w, r) } - p := req.Params(r) - username, err := p.String("username") + username, err := requiredParamString(r, "username") if err != nil { return nil, err } ctx := r.Context() - u, err := api.ds.User().FindByUsername(ctx, username) + u, err := api.ds.User(ctx).FindByUsername(username) if err != nil { return nil, err } @@ -37,7 +36,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) //nolint:gosec // URL is not constructed from user input + http.Redirect(w, r, gravatar.Url(u.Email, 0), http.StatusFound) return nil, nil } @@ -62,12 +61,13 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() - p := req.Params(r) - id, _ := p.String("id") - size := p.IntOr("size", 0) - square := p.BoolOr("square", false) + id := utils.ParamString(r, "id") + size := utils.ParamInt(r, "size", 0) + + imgReader, lastUpdate, err := api.artwork.GetOrPlaceholder(ctx, id, size) + w.Header().Set("cache-control", "public, max-age=315360000") + w.Header().Set("last-modified", lastUpdate.Format(time.RFC1123)) - img, err := api.artwork.GetOrPlaceholder(ctx, id, size, square) switch { case errors.Is(err, context.Canceled): return nil, nil @@ -79,13 +79,8 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons return nil, err } - defer img.Close() - - artID, _ := model.ParseArtworkID(id) - if imghttp.WriteImageHeaders(w, r, img, artID.Hash) { - return nil, nil - } - cnt, err := io.Copy(w, img) + defer imgReader.Close() + cnt, err := io.Copy(w, imgReader) if err != nil { log.Warn(ctx, "Error sending image", "count", cnt, err) } @@ -93,55 +88,41 @@ func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*respons return nil, err } +const timeStampRegex string = `(\[([0-9]{1,2}:)?([0-9]{1,2}:)([0-9]{1,2})(\.[0-9]{1,2})?\])` + +func isSynced(rawLyrics string) bool { + r := regexp.MustCompile(timeStampRegex) + // Eg: [04:02:50.85] + // [02:50.85] + // [02:50] + return r.MatchString(rawLyrics) +} + func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - artist, _ := p.String("artist") - title, _ := p.String("title") + artist := utils.ParamString(r, "artist") + title := utils.ParamString(r, "title") response := newResponse() - lyricsResponse := responses.Lyrics{} - response.Lyrics = &lyricsResponse - structuredLyrics, err := api.lyrics.GetLyricsByArtistTitle(r.Context(), artist, title) + lyrics := responses.Lyrics{} + response.Lyrics = &lyrics + mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongsWithLyrics(artist, title)) + if err != nil { return nil, err } - mainLyric, ok := structuredLyrics.Main() - if !ok { + if len(mediaFiles) == 0 { return response, nil } - lyricsResponse.Artist = artist - lyricsResponse.Title = title + lyrics.Artist = artist + lyrics.Title = title - var lyricsText strings.Builder - for _, line := range mainLyric.Line { - lyricsText.WriteString(line.Value + "\n") + if isSynced(mediaFiles[0].Lyrics) { + r := regexp.MustCompile(timeStampRegex) + lyrics.Value = r.ReplaceAllString(mediaFiles[0].Lyrics, "") + } else { + lyrics.Value = mediaFiles[0].Lyrics } - lyricsResponse.Value = lyricsText.String() - - return response, nil -} - -func (api *Router) GetLyricsBySongId(r *http.Request) (*responses.Subsonic, error) { - id, err := req.Params(r).String("id") - if err != nil { - return nil, err - } - - mediaFile, err := api.ds.MediaFile().Get(r.Context(), id) - if err != nil { - return nil, err - } - - structuredLyrics, err := api.lyrics.GetLyrics(r.Context(), mediaFile) - if err != nil { - return nil, err - } - - enhanced, _ := req.Params(r).Bool("enhanced") - - response := newResponse() - response.LyricsList = buildLyricsList(mediaFile, structuredLyrics, enhanced) return response, nil } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index ad193518d..5246fd61c 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -2,22 +2,15 @@ package subsonic import ( "bytes" - "cmp" "context" - "encoding/json" "errors" "io" "net/http/httptest" - "path/filepath" - "slices" "time" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/lyrics" + "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" @@ -26,66 +19,42 @@ import ( var _ = Describe("MediaRetrievalController", func() { var router *Router var ds model.DataStore - mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}} + mockRepo := &mockedMediaFile{} var artwork *fakeArtwork var w *httptest.ResponseRecorder BeforeEach(func() { - albumRepo := &tests.MockAlbumRepo{} - albumRepo.SetData(model.Albums{{ID: "34"}}) // the id the specs request, made accessible - radioRepo := tests.CreateMockedRadioRepo() - Expect(radioRepo.Put(GinkgoT().Context(), &model.Radio{ID: "rd1", Name: "Radio"})).To(Succeed()) ds = &tests.MockDataStore{ MockedMediaFile: mockRepo, - MockedAlbum: albumRepo, - MockedRadio: radioRepo, } - artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(ds, nil), nil, nil) + artwork = &fakeArtwork{} + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() - DeferCleanup(configtest.SetupConfig()) - conf.Server.LyricsPriority = "embedded,.lrc" }) Describe("GetCoverArt", func() { It("should return data for that id", func() { - r := newGetRequest("id=34", "size=128", "square=true") + artwork.data = "image data" + r := newGetRequest("id=34", "size=128") _, err := router.GetCoverArt(w, r) - Expect(err).ToNot(HaveOccurred()) + Expect(err).To(BeNil()) + Expect(artwork.recvId).To(Equal("34")) Expect(artwork.recvSize).To(Equal(128)) - Expect(artwork.recvSquare).To(BeTrue()) Expect(w.Body.String()).To(Equal(artwork.data)) }) It("should return placeholder if id parameter is missing (mimicking Subsonic)", func() { - r := newGetRequest() // No id parameter + r := newGetRequest() _, err := router.GetCoverArt(w, r) Expect(err).To(BeNil()) - Expect(artwork.recvId).To(BeEmpty()) Expect(w.Body.String()).To(Equal(artwork.data)) }) - // The service applies the library filter from the caller's context, so elevating here - // would bypass it. - It("passes the caller's context to the service rather than elevating", func() { - r := newGetRequest("id=al-34") - usr := model.User{ID: "u1", UserName: "u1"} - r = r.WithContext(request.WithUser(r.Context(), usr)) - - _, err := router.GetCoverArt(w, r) - - Expect(err).ToNot(HaveOccurred()) - got, ok := request.UserFrom(artwork.recvCtx) - Expect(ok).To(BeTrue(), "the service must see who is asking") - Expect(got.ID).To(Equal("u1")) - Expect(got.IsAdmin).To(BeFalse(), "the handler must not elevate") - }) - It("should fail when the file is not found", func() { artwork.err = model.ErrNotFound - r := newGetRequest("id=34", "size=128", "square=true") + r := newGetRequest("id=34", "size=128") _, err := router.GetCoverArt(w, r) Expect(err).To(MatchError("Artwork not found")) @@ -98,220 +67,90 @@ var _ = Describe("MediaRetrievalController", func() { Expect(err).To(MatchError("weird error")) }) - - When("client disconnects (context is cancelled)", func() { - It("should not call the service if cancelled before the call", func() { - ctx, cancel := context.WithCancel(GinkgoT().Context()) - r := newGetRequest("id=34", "size=128", "square=true") - r = r.WithContext(ctx) - cancel() - - _, err := router.GetCoverArt(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(artwork.recvId).To(Equal("")) - Expect(artwork.recvSize).To(Equal(0)) - Expect(artwork.recvSquare).To(BeFalse()) - Expect(w.Body.String()).To(BeEmpty()) - }) - - It("should not return data if cancelled during the call", func() { - ctx, cancel := context.WithCancel(GinkgoT().Context()) - defer cancel() - r := newGetRequest("id=34", "size=128", "square=true") - r = r.WithContext(ctx) - artwork.ctxCancelFunc = cancel - - _, err := router.GetCoverArt(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(artwork.recvId).To(Equal("34")) - Expect(artwork.recvSize).To(Equal(128)) - Expect(artwork.recvSquare).To(BeTrue()) - Expect(w.Body.String()).To(BeEmpty()) - }) - }) - - Describe("caching headers", func() { - const hash = "0123456789abcdef" - - It("sets an ETag and no-cache for a bare id", func() { - artwork.hash = hash - r := newGetRequest("id=al-34") - _, err := router.GetCoverArt(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(w.Header().Get("ETag")).To(Equal(`"` + hash + `"`)) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, no-cache")) - Expect(w.Body.String()).To(Equal(artwork.data)) - }) - - It("marks the response immutable when the id asserts the current hash", func() { - artwork.hash = hash - r := newGetRequest("id=al-34_" + hash) - _, err := router.GetCoverArt(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(w.Header().Get("Cache-Control")).To(Equal("public, max-age=31536000, immutable")) - }) - - It("returns 304 with no body when If-None-Match matches", func() { - artwork.hash = hash - r := newGetRequest("id=al-34") - r.Header.Set("If-None-Match", `"`+hash+`"`) - _, err := router.GetCoverArt(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(w.Code).To(Equal(304)) - Expect(w.Body.Len()).To(BeZero()) - }) - - It("never caches a placeholder", func() { - artwork.placeholder = true - r := newGetRequest("id=al-missing") - _, err := router.GetCoverArt(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(w.Code).To(Equal(200)) - Expect(w.Header().Get("Cache-Control")).To(Equal("no-store")) - Expect(w.Header().Get("ETag")).To(BeEmpty()) - Expect(w.Body.String()).To(Equal(artwork.data)) - }) - }) }) Describe("GetLyrics", func() { It("should return data for given artist & title", func() { r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") - lyricsList, _ := model.ParseLyrics(GinkgoT().Context(), ".lrc", "eng", []byte("[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I")) - lyrics, _ := lyricsList.Main() - lyricsJson, err := json.Marshal(model.LyricList{ - lyrics, - }) - Expect(err).ToNot(HaveOccurred()) - mockRepo.SetData(model.MediaFiles{ { ID: "1", Artist: "Rick Astley", Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJson), + Lyrics: "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I", }, }) response, err := router.GetLyrics(r) - Expect(err).ToNot(HaveOccurred()) + if err != nil { + log.Error("You're missing something.", err) + } + Expect(err).To(BeNil()) Expect(response.Lyrics.Artist).To(Equal("Rick Astley")) Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up")) - Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) - }) - It("should surface the main-kind track when translation tracks are present", func() { - r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") - start := int64(0) - lyricsJSON, err := json.Marshal(model.LyricList{ - {Kind: model.LyricKindTranslation, Lang: "por", Line: []model.Line{{Start: &start, Value: "Nunca vou te decepcionar"}}}, - {Kind: model.LyricKindMain, Lang: "eng", Line: []model.Line{{Start: &start, Value: "Never gonna let you down"}}}, - }) - Expect(err).ToNot(HaveOccurred()) - mockRepo.SetData(model.MediaFiles{ - { - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - Lyrics: string(lyricsJSON), - }, - }) - response, err := router.GetLyrics(r) - Expect(err).ToNot(HaveOccurred()) - Expect(response.Lyrics.Value).To(Equal("Never gonna let you down\n")) + Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I")) }) It("should return empty subsonic response if the record corresponding to the given artist & title is not found", func() { r := newGetRequest("artist=Dheeraj", "title=Rinkiya+Ke+Papa") mockRepo.SetData(model.MediaFiles{}) response, err := router.GetLyrics(r) - Expect(err).ToNot(HaveOccurred()) + if err != nil { + log.Error("You're missing something.", err) + } + Expect(err).To(BeNil()) Expect(response.Lyrics.Artist).To(Equal("")) Expect(response.Lyrics.Title).To(Equal("")) Expect(response.Lyrics.Value).To(Equal("")) - }) - It("should return lyric file when finding mediafile with no embedded lyrics but present on filesystem", func() { - r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") - fixturesDir, err := filepath.Abs("tests/fixtures") - Expect(err).ToNot(HaveOccurred()) - mockRepo.SetData(model.MediaFiles{ - { - LibraryPath: fixturesDir, - Path: "test.mp3", - ID: "1", - Artist: "Rick Astley", - Title: "Never Gonna Give You Up", - }, - }) - response, err := router.GetLyrics(r) - Expect(err).ToNot(HaveOccurred()) - Expect(response.Lyrics.Artist).To(Equal("Rick Astley")) - Expect(response.Lyrics.Title).To(Equal("Never Gonna Give You Up")) - Expect(response.Lyrics.Value).To(Equal("We're no strangers to love\nYou know the rules and so do I\n")) + }) }) }) type fakeArtwork struct { artwork.Artwork - data string - hash string - lastUpdated time.Time - placeholder bool - err error - ctxCancelFunc func() - recvId string - recvSize int - recvSquare bool - recvCtx context.Context + data string + err error + recvId string + recvSize int } -func (c *fakeArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (*artwork.Image, error) { - c.recvCtx = ctx +func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int) (io.ReadCloser, time.Time, error) { if c.err != nil { - return nil, c.err + return nil, time.Time{}, c.err } c.recvId = id c.recvSize = size - c.recvSquare = square - if c.ctxCancelFunc != nil { - c.ctxCancelFunc() - return nil, context.Canceled - } - return &artwork.Image{ - ReadCloser: io.NopCloser(bytes.NewReader([]byte(c.data))), - Hash: c.hash, - LastUpdated: c.lastUpdated, - Placeholder: c.placeholder, - }, nil + return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil } +var _ = Describe("isSynced", func() { + It("returns false if lyrics contain no timestamps", func() { + Expect(isSynced("Just in case my car goes off the highway")).To(Equal(false)) + Expect(isSynced("[02.50] Just in case my car goes off the highway")).To(Equal(false)) + }) + It("returns false if lyrics is an empty string", func() { + Expect(isSynced("")).To(Equal(false)) + }) + It("returns true if lyrics contain timestamps", func() { + Expect(isSynced(`NF Real Music + [00:00] First line + [00:00.85] JUST LIKE YOU + [00:00.85] Just in case my car goes off the highway`)).To(Equal(true)) + Expect(isSynced("[04:02:50.85] Never gonna give you up")).To(Equal(true)) + Expect(isSynced("[02:50.85] Never gonna give you up")).To(Equal(true)) + Expect(isSynced("[02:50] Never gonna give you up")).To(Equal(true)) + }) + +}) + type mockedMediaFile struct { - tests.MockMediaFileRepo + model.MediaFileRepository + data model.MediaFiles } -func (m *mockedMediaFile) GetAll(ctx context.Context, opts ...model.QueryOptions) (model.MediaFiles, error) { - data, err := m.MockMediaFileRepo.GetAll(ctx, opts...) - if err != nil { - return nil, err - } - if len(opts) == 0 || opts[0].Sort != "lyrics, updated_at" { - return data, nil - } - - result := slices.Clone(data) - slices.SortFunc(result, func(a, b model.MediaFile) int { - diff := cmp.Or( - cmp.Compare(a.Lyrics, b.Lyrics), - cmp.Compare(a.UpdatedAt.Unix(), b.UpdatedAt.Unix()), - ) - if opts[0].Order == "desc" { - return -diff - } - return diff - }) - return result, nil +func (m *mockedMediaFile) SetData(mfs model.MediaFiles) { + m.data = mfs +} + +func (m *mockedMediaFile) GetAll(...model.QueryOptions) (model.MediaFiles, error) { + return m.data, nil } diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index fdb4af28d..1efe61663 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -1,7 +1,6 @@ package subsonic import ( - "cmp" "context" "crypto/md5" "encoding/hex" @@ -11,31 +10,25 @@ import ( "net/http" "net/url" "strings" - "time" - "github.com/go-chi/chi/v5/middleware" ua "github.com/mileusna/useragent" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils" . "github.com/navidrome/navidrome/utils/gg" - "github.com/navidrome/navidrome/utils/req" ) func postFormToQueryParams(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, 10<<20) // 10MB err := r.ParseForm() if err != nil { sendError(w, r, newError(responses.ErrorGeneric, err.Error())) - return } var parts []string for key, values := range r.Form { @@ -49,127 +42,49 @@ func postFormToQueryParams(next http.Handler) http.Handler { }) } -func fromInternalOrProxyAuth(r *http.Request) (string, bool) { - username := server.InternalAuth(r) - - // If the username comes from internal auth, do not also do reverse proxy auth, as - // the request will have no reverse proxy IP - if username != "" { - return username, true - } - - return server.UsernameFromExtAuthHeader(r), false -} - func checkRequiredParameters(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var requiredParameters []string + requiredParameters := []string{"u", "v", "c"} - p := req.Params(r) - username, _ := fromInternalOrProxyAuth(r) - apiKey, _ := p.String("apiKey") - if username != "" || apiKey != "" { - requiredParameters = []string{"v", "c"} - } else { - requiredParameters = []string{"u", "v", "c"} - } - - for _, param := range requiredParameters { - if _, err := p.String(param); err != nil { - log.Warn(r, err) - sendError(w, r, err) + for _, p := range requiredParameters { + if utils.ParamString(r, p) == "" { + msg := fmt.Sprintf(`Missing required parameter "%s"`, p) + log.Warn(r, msg) + sendError(w, r, newError(responses.ErrorMissingParameter, msg)) return } } - if username == "" { - username, _ = p.String("u") - } - client, _ := p.String("c") - version, _ := p.String("v") - + username := utils.ParamString(r, "u") + client := utils.ParamString(r, "c") + version := utils.ParamString(r, "v") ctx := r.Context() ctx = request.WithUsername(ctx, username) ctx = request.WithClient(ctx, client) ctx = request.WithVersion(ctx, version) log.Debug(ctx, "API: New request "+r.URL.Path, "username", username, "client", client, "version", version) - next.ServeHTTP(w, r.WithContext(ctx)) + r = r.WithContext(ctx) + next.ServeHTTP(w, r) }) } func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { - limiter := newAuthLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + username := utils.ParamString(r, "u") - var usr *model.User - var keyPlayer *model.Player - var err error + pass := utils.ParamString(r, "p") + token := utils.ParamString(r, "t") + salt := utils.ParamString(r, "s") + jwt := utils.ParamString(r, "jwt") - p := req.Params(r) - apiKey, _ := p.String("apiKey") - username, isInternalAuth := fromInternalOrProxyAuth(r) - switch { - case username != "": - authType := If(isInternalAuth, "internal", "reverse-proxy") - usr, err = ds.User().FindByUsername(ctx, username) - if errors.Is(err, context.Canceled) { - log.Debug(ctx, "API: Request canceled when authenticating", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err) - return - } - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "API: Invalid login", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err) - } else if err != nil { - log.Error(ctx, "API: Error authenticating username", "auth", authType, "username", username, "remoteAddr", r.RemoteAddr, err) - } - case apiKey != "": - usr, keyPlayer, err = authenticateAPIKey(ctx, ds, limiter, r, apiKey) - if err != nil { - if ctx.Err() == nil { - sendError(w, r, err) - } - return - } - ctx = request.WithUsername(ctx, usr.UserName) - default: - username, _ := p.String("u") - pass, _ := p.String("p") - token, _ := p.String("t") - salt, _ := p.String("s") - jwt, _ := p.String("jwt") - - // Blocked attempts get the same response as a wrong password, so they reveal nothing - limitKey := server.ClientIP(r) + "\x00" + strings.ToLower(username) - slot, allowed := limiter.acquire(ctx, limitKey) - if !allowed { - if ctx.Err() != nil { - return - } - log.Warn(ctx, "API: Too many failed login attempts", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr) - sendError(w, r, newError(responses.ErrorAuthenticationFail)) - return - } - - usr, err = ds.User().FindByUsernameWithPassword(ctx, username) - if err == nil { - err = validateCredentials(usr, pass, token, salt, jwt) - if errors.Is(err, model.ErrInvalidAuth) && pass != "" && jwt == "" { - keyPlayer, err = playerFromPasswordKey(ctx, ds, usr, pass) - } - } - invalidLogin := errors.Is(err, model.ErrNotFound) || errors.Is(err, model.ErrInvalidAuth) - slot.release(invalidLogin) - switch { - case errors.Is(err, context.Canceled): - log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - return - case invalidLogin: - log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - case err != nil: - log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) - } + usr, err := validateUser(ctx, ds, username, pass, token, salt, jwt) + if errors.Is(err, model.ErrInvalidAuth) { + log.Warn(ctx, "API: Invalid login", "username", username, "remoteAddr", r.RemoteAddr, err) + } else if err != nil { + log.Error(ctx, "API: Error authenticating username", "username", username, "remoteAddr", r.RemoteAddr, err) } if err != nil { @@ -177,115 +92,53 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { return } + // TODO: Find a way to update LastAccessAt without causing too much retention in the DB + //go func() { + // err := ds.User(ctx).UpdateLastAccessAt(usr.ID) + // if err != nil { + // log.Error(ctx, "Could not update user's lastAccessAt", "user", usr.UserName) + // } + //}() + + ctx = log.NewContext(r.Context(), "username", username) ctx = request.WithUser(ctx, *usr) - if keyPlayer != nil { - ctx = request.WithPlayer(ctx, *keyPlayer) - } - next.ServeHTTP(w, r.WithContext(ctx)) + r = r.WithContext(ctx) + + next.ServeHTTP(w, r) }) } } -var apiKeyConflicts = []string{"u", "p", "t", "s", "jwt"} - -func authenticateAPIKey(ctx context.Context, ds model.DataStore, limiter *authLimiter, r *http.Request, key string) (*model.User, *model.Player, error) { - query := r.URL.Query() - for _, param := range apiKeyConflicts { - if query.Has(param) { - log.Warn(ctx, "API: apiKey sent with other credentials", "auth", "apikey", "param", param, "remoteAddr", r.RemoteAddr) - return nil, nil, newError(responses.ErrorMultipleAuthMechanismsProvided) - } - } - - // Per key, so a stale key on one device cannot lock out valid keys sharing the IP - slot, allowed := limiter.acquire(ctx, "apikey\x00"+server.ClientIP(r)+"\x00"+key) - if !allowed { - if err := ctx.Err(); err != nil { - return nil, nil, err - } - log.Warn(ctx, "API: Too many failed API key attempts", "auth", "apikey", "remoteAddr", r.RemoteAddr) - return nil, nil, newError(responses.ErrorInvalidAPIKey) - } - - player, err := ds.Player().FindByAPIKey(ctx, key) - var usr *model.User - if err == nil { - usr, err = ds.User().Get(ctx, player.UserId) - } - slot.release(errors.Is(err, model.ErrNotFound)) - switch { - case errors.Is(err, context.Canceled): - return nil, nil, err - case errors.Is(err, model.ErrNotFound): - log.Warn(ctx, "API: Invalid API key", "auth", "apikey", "remoteAddr", r.RemoteAddr) - return nil, nil, newError(responses.ErrorInvalidAPIKey) - case err != nil: - log.Error(ctx, "API: Error authenticating API key", "auth", "apikey", "remoteAddr", r.RemoteAddr, err) - return nil, nil, newError(responses.ErrorAuthenticationFail) - } - return usr, player, nil -} - -// playerFromPasswordKey lets clients that only have a password field log in with an API key. -// It returns ErrInvalidAuth when pass is not a key of usr, so only real failures skip the limiter count. -func playerFromPasswordKey(ctx context.Context, ds model.DataStore, usr *model.User, pass string) (*model.Player, error) { - key := decodePassword(pass) - if !strings.HasPrefix(key, consts.APIKeyPrefix) { +func validateUser(ctx context.Context, ds model.DataStore, username, pass, token, salt, jwt string) (*model.User, error) { + user, err := ds.User(ctx).FindByUsernameWithPassword(username) + if errors.Is(err, model.ErrNotFound) { return nil, model.ErrInvalidAuth } - plr, err := ds.Player().FindByAPIKey(ctx, key) - if errors.Is(err, model.ErrNotFound) || (err == nil && plr.UserId != usr.ID) { - return nil, model.ErrInvalidAuth + if err != nil { + return nil, err } - return plr, err -} - -func decodePassword(pass string) string { - if strings.HasPrefix(pass, "enc:") { - if dec, err := hex.DecodeString(pass[4:]); err == nil { - return string(dec) - } - } - return pass -} - -func adminOnly(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - loggedUser, ok := request.UserFrom(r.Context()) - if !ok { - sendError(w, r, newError(responses.ErrorGeneric, "Internal error")) - return - } - - if !loggedUser.IsAdmin { - sendError(w, r, newError(responses.ErrorAuthorizationFail)) - return - } - - next.ServeHTTP(w, r) - }) -} - -func validateCredentials(user *model.User, pass, token, salt, jwt string) error { valid := false switch { case jwt != "": claims, err := auth.Validate(jwt) - valid = err == nil && - claims.Subject == user.UserName && - auth.CheckClaims(claims, *user, auth.AudienceSubsonic) == nil + valid = err == nil && claims["sub"] == user.UserName case pass != "": - valid = decodePassword(pass) == user.Password + if strings.HasPrefix(pass, "enc:") { + if dec, err := hex.DecodeString(pass[4:]); err == nil { + pass = string(dec) + } + } + valid = pass == user.Password case token != "": t := fmt.Sprintf("%x", md5.Sum([]byte(user.Password+salt))) valid = t == token } if !valid { - return model.ErrInvalidAuth + return nil, model.ErrInvalidAuth } - return nil + return user, nil } func getPlayer(players core.Players) func(next http.Handler) http.Handler { @@ -294,20 +147,12 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler { ctx := r.Context() userName, _ := request.UsernameFrom(ctx) client, _ := request.ClientFrom(ctx) - ip, _, _ := net.SplitHostPort(r.RemoteAddr) + playerId := playerIDFromCookie(r, userName) + ip, _, _ := net.SplitHostPort(realIP(r)) userAgent := canonicalUserAgent(r) - - var player *model.Player - var trc *model.Transcoding - var err error - keyPlayer, boundByKey := request.PlayerFrom(ctx) - if boundByKey { - player, trc, err = players.Touch(ctx, keyPlayer, client, userAgent, ip) - } else { - player, trc, err = players.Register(ctx, playerIDFromCookie(r, userName), client, userAgent, ip) - } + player, trc, err := players.Register(ctx, playerId, client, userAgent, ip) if err != nil { - log.Error(ctx, "Could not resolve player", "username", userName, "client", client, err) + log.Error(r.Context(), "Could not register player", "username", userName, "client", client, err) } else { ctx = request.WithPlayer(ctx, *player) if trc != nil { @@ -315,18 +160,13 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler { } r = r.WithContext(ctx) - // A key already identifies the player, so the cookie would only add a second, weaker signal - if boundByKey { - next.ServeHTTP(w, r) - return - } - cookie := &http.Cookie{ //nolint:gosec // Secure omitted: Navidrome may run over plain HTTP + cookie := &http.Cookie{ Name: playerIDCookieName(userName), Value: player.ID, MaxAge: consts.CookieExpiry, HttpOnly: true, SameSite: http.SameSiteStrictMode, - Path: cmp.Or(conf.Server.BasePath, "/"), + Path: If(conf.Server.BasePath, "/"), } http.SetCookie(w, cookie) } @@ -345,6 +185,19 @@ func canonicalUserAgent(r *http.Request) string { return userAgent } +func realIP(r *http.Request) string { + if xrip := r.Header.Get("X-Real-IP"); xrip != "" { + return xrip + } else if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + i := strings.Index(xff, ", ") + if i == -1 { + i = len(xff) + } + return xff[:i] + } + return r.RemoteAddr +} + func playerIDFromCookie(r *http.Request, userName string) string { cookieName := playerIDCookieName(userName) var playerId string @@ -359,39 +212,3 @@ func playerIDCookieName(userName string) string { cookieName := fmt.Sprintf("nd-player-%x", userName) return cookieName } - -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 { - fn := func(w http.ResponseWriter, r *http.Request) { - ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) - - status := int32(-1) - contextWithStatus := context.WithValue(r.Context(), subsonicErrorPointer, &status) - - start := time.Now() - defer func() { - elapsed := time.Since(start).Milliseconds() - - // We want to get the client name (even if not present for certain endpoints) - p := req.Params(r) - client, _ := p.String("c") - - // If there is no Subsonic status (e.g., HTTP 501 not implemented), fallback to HTTP - if status == -1 { - status = int32(ww.Status()) - } - - shortPath := strings.Replace(r.URL.Path, ".view", "", 1) - - metrics.RecordRequest(r.Context(), shortPath, r.Method, client, status, elapsed) - }() - - next.ServeHTTP(ww, r.WithContext(contextWithStatus)) - } - return http.HandlerFunc(fn) - } -} diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index b3ad972b7..5ef7d1b07 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -2,19 +2,13 @@ package subsonic import ( "context" - "crypto/md5" - "encoding/hex" "errors" - "fmt" "net/http" "net/http/httptest" "strings" - "sync" - "sync/atomic" "time" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/auth" @@ -43,13 +37,11 @@ func newPostRequest(queryParam string, formFields ...string) *http.Request { } var _ = Describe("Middlewares", func() { - var ctx context.Context var next *mockHandler var w *httptest.ResponseRecorder var ds model.DataStore BeforeEach(func() { - ctx = GinkgoT().Context() next = &mockHandler{} w = httptest.NewRecorder() ds = &tests.MockDataStore{} @@ -84,7 +76,7 @@ var _ = Describe("Middlewares", func() { }) Describe("CheckParams", func() { - It("passes when all required params are available (subsonicauth case)", func() { + It("passes when all required params are available", func() { r := newGetRequest("u=user", "v=1.15", "c=test") cp := checkRequiredParameters(next) cp.ServeHTTP(w, r) @@ -99,35 +91,6 @@ var _ = Describe("Middlewares", func() { Expect(next.called).To(BeTrue()) }) - It("passes when all required params are available (reverse-proxy case)", func() { - conf.Server.ExtAuth.TrustedSources = "127.0.0.234/32" - conf.Server.ExtAuth.UserHeader = "Remote-User" - - r := newGetRequest("v=1.15", "c=test") - r.Header.Add("Remote-User", "user") - r = r.WithContext(request.WithReverseProxyIp(r.Context(), "127.0.0.234")) - - cp := checkRequiredParameters(next) - cp.ServeHTTP(w, r) - - username, _ := request.UsernameFrom(next.req.Context()) - Expect(username).To(Equal("user")) - version, _ := request.VersionFrom(next.req.Context()) - Expect(version).To(Equal("1.15")) - client, _ := request.ClientFrom(next.req.Context()) - Expect(client).To(Equal("test")) - - Expect(next.called).To(BeTrue()) - }) - - It("does not require u when apiKey is present", func() { - r := newGetRequest("apiKey=nds_abc", "v=1.15", "c=test") - cp := checkRequiredParameters(next) - cp.ServeHTTP(w, r) - - Expect(next.called).To(BeTrue()) - }) - It("fails when user is missing", func() { r := newGetRequest("v=1.15", "c=test") cp := checkRequiredParameters(next) @@ -158,484 +121,28 @@ var _ = Describe("Middlewares", func() { Describe("Authenticate", func() { BeforeEach(func() { - ur := ds.User() - _ = ur.Put(ctx, &model.User{ + ur := ds.User(context.TODO()) + _ = ur.Put(&model.User{ UserName: "admin", NewPassword: "wordpass", }) }) - - When("using password authentication", func() { - It("passes authentication with correct credentials", func() { - r := newGetRequest("u=admin", "p=wordpass") - cp := authenticate(ds)(next) - cp.ServeHTTP(w, r) - - Expect(next.called).To(BeTrue()) - user, _ := request.UserFrom(next.req.Context()) - Expect(user.UserName).To(Equal("admin")) - }) - - It("fails authentication with invalid user", func() { - r := newGetRequest("u=invalid", "p=wordpass") - cp := authenticate(ds)(next) - cp.ServeHTTP(w, r) - - Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) - Expect(next.called).To(BeFalse()) - }) - - It("fails authentication with invalid password", func() { - r := newGetRequest("u=admin", "p=INVALID") - cp := authenticate(ds)(next) - cp.ServeHTTP(w, r) - - Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) - Expect(next.called).To(BeFalse()) - }) - }) - - When("using token authentication", func() { - var salt = "12345" - - It("passes authentication with correct token", func() { - token := fmt.Sprintf("%x", md5.Sum([]byte("wordpass"+salt))) - r := newGetRequest("u=admin", "t="+token, "s="+salt) - cp := authenticate(ds)(next) - cp.ServeHTTP(w, r) - - Expect(next.called).To(BeTrue()) - user, _ := request.UserFrom(next.req.Context()) - Expect(user.UserName).To(Equal("admin")) - }) - - It("fails authentication with invalid token", func() { - r := newGetRequest("u=admin", "t=INVALID", "s="+salt) - cp := authenticate(ds)(next) - cp.ServeHTTP(w, r) - - Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) - Expect(next.called).To(BeFalse()) - }) - - It("fails authentication with empty password", func() { - // Token generated with random Salt, empty password - token := fmt.Sprintf("%x", md5.Sum([]byte(""+salt))) - r := newGetRequest("u=NON_EXISTENT_USER", "t="+token, "s="+salt) - cp := authenticate(ds)(next) - cp.ServeHTTP(w, r) - - Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) - Expect(next.called).To(BeFalse()) - }) - }) - - When("using JWT authentication", func() { - var validToken string - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SessionTimeout = time.Minute - auth.Init(ds) - }) - - It("passes authentication with correct token", func() { - usr := &model.User{UserName: "admin"} - var err error - validToken, err = auth.CreateToken(usr) - Expect(err).NotTo(HaveOccurred()) - - r := newGetRequest("u=admin", "jwt="+validToken) - cp := authenticate(ds)(next) - cp.ServeHTTP(w, r) - - Expect(next.called).To(BeTrue()) - user, _ := request.UserFrom(next.req.Context()) - Expect(user.UserName).To(Equal("admin")) - }) - - It("fails authentication with invalid token", func() { - r := newGetRequest("u=admin", "jwt=INVALID_TOKEN") - cp := authenticate(ds)(next) - cp.ServeHTTP(w, r) - - Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) - Expect(next.called).To(BeFalse()) - }) - }) - - When("using reverse proxy authentication", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24" - conf.Server.ExtAuth.UserHeader = "Remote-User" - }) - - It("passes authentication with correct IP and header", func() { - r := newGetRequest("u=admin") - r.Header.Add("Remote-User", "admin") - r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.1.1")) - cp := authenticate(ds)(next) - cp.ServeHTTP(w, r) - - Expect(next.called).To(BeTrue()) - user, _ := request.UserFrom(next.req.Context()) - Expect(user.UserName).To(Equal("admin")) - }) - - It("fails authentication with wrong IP", func() { - r := newGetRequest("u=admin") - r.Header.Add("Remote-User", "admin") - r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.2.1")) - cp := authenticate(ds)(next) - cp.ServeHTTP(w, r) - - Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) - Expect(next.called).To(BeFalse()) - }) - }) - - When("using internal authentication", func() { - It("passes authentication with correct internal credentials", func() { - // Simulate internal authentication by setting the context with WithInternalAuth - r := newGetRequest() - r = r.WithContext(request.WithInternalAuth(r.Context(), "admin")) - cp := authenticate(ds)(next) - cp.ServeHTTP(w, r) - - Expect(next.called).To(BeTrue()) - user, _ := request.UserFrom(next.req.Context()) - Expect(user.UserName).To(Equal("admin")) - }) - - It("fails authentication with missing internal context", func() { - r := newGetRequest("u=admin") - // Do not set the internal auth context - cp := authenticate(ds)(next) - cp.ServeHTTP(w, r) - - // Internal auth requires the context, so this should fail - Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) - Expect(next.called).To(BeFalse()) - }) - }) - - When("using API key authentication", func() { - var key string - serve := func(params ...string) { - authenticate(ds)(next).ServeHTTP(w, newGetRequest(params...)) - } - - BeforeEach(func() { - usr, err := ds.User().FindByUsername(ctx, "admin") - Expect(err).ToNot(HaveOccurred()) - Expect(ds.Player().Put(ctx, &model.Player{ID: "player-1", Name: "My Phone", UserId: usr.ID, Client: "Symfonium"})).To(Succeed()) - key = "nds_0123456789abcdefghijkl" - Expect(ds.Player().SetAPIKey(ctx, "player-1", key)).To(Succeed()) - }) - - It("authenticates the owner and binds the key's player", func() { - serve("apiKey=" + key) - - Expect(next.called).To(BeTrue()) - user, _ := request.UserFrom(next.req.Context()) - Expect(user.UserName).To(Equal("admin")) - username, _ := request.UsernameFrom(next.req.Context()) - Expect(username).To(Equal("admin")) - player, ok := request.PlayerFrom(next.req.Context()) - Expect(ok).To(BeTrue()) - Expect(player.ID).To(Equal("player-1")) - }) - - It("accepts the key in a POST form body", func() { - r := newPostRequest("", "apiKey="+key) - cp := postFormToQueryParams(authenticate(ds)(next)) - cp.ServeHTTP(w, r) - - Expect(next.called).To(BeTrue()) - player, _ := request.PlayerFrom(next.req.Context()) - Expect(player.ID).To(Equal("player-1")) - }) - - It("rejects an unknown key with error 44", func() { - serve("apiKey=nds_unknown") - - Expect(w.Body.String()).To(ContainSubstring(`code="44"`)) - Expect(next.called).To(BeFalse()) - }) - - DescribeTable("rejects apiKey mixed with other credentials with error 43", - func(extra string) { - serve("apiKey="+key, extra) - - Expect(w.Body.String()).To(ContainSubstring(`code="43"`)) - Expect(next.called).To(BeFalse()) - }, - Entry("u", "u=admin"), - Entry("p", "p=wordpass"), - Entry("t", "t=abc"), - Entry("s", "s=abc"), - Entry("jwt", "jwt=abc"), - Entry("empty u", "u="), - Entry("empty p", "p="), - ) - - Context("key sent as the password", func() { - It("authenticates and binds the key's player", func() { - serve("u=admin", "p="+key) - - Expect(next.called).To(BeTrue()) - player, ok := request.PlayerFrom(next.req.Context()) - Expect(ok).To(BeTrue()) - Expect(player.ID).To(Equal("player-1")) - }) - - It("accepts the hex-encoded form", func() { - serve("u=admin", "p=enc:"+hex.EncodeToString([]byte(key))) - - Expect(next.called).To(BeTrue()) - }) - - It("still accepts a real password that starts with the key prefix", func() { - Expect(ds.User().Put(ctx, &model.User{UserName: "prefixed", NewPassword: "nds_secret"})).To(Succeed()) - serve("u=prefixed", "p=nds_secret") - - Expect(next.called).To(BeTrue()) - _, ok := request.PlayerFrom(next.req.Context()) - Expect(ok).To(BeFalse()) - }) - - It("rejects another user's key with error 40", func() { - Expect(ds.User().Put(ctx, &model.User{UserName: "other", NewPassword: "pw"})).To(Succeed()) - serve("u=other", "p="+key) - - Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) - Expect(next.called).To(BeFalse()) - }) - }) - }) - - When("failed attempts reach AuthRequestLimit", func() { - var cp http.Handler - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.AuthRequestLimit = 3 - conf.Server.AuthWindowLength = time.Minute - cp = authenticate(ds)(next) - }) - - serve := func(r *http.Request) *httptest.ResponseRecorder { - next.called = false - rec := httptest.NewRecorder() - cp.ServeHTTP(rec, r) - return rec - } - failTimes := func(n int, params ...string) { - for range n { - Expect(serve(newGetRequest(params...)).Body.String()).To(ContainSubstring(`code="40"`)) - } - } - - It("rejects the correct password exactly like a wrong one", func() { - failTimes(3, "u=admin", "p=WRONG") - - rec := serve(newGetRequest("u=admin", "p=wordpass")) - - Expect(next.called).To(BeFalse()) - Expect(rec.Code).To(Equal(http.StatusOK)) - Expect(rec.Body.String()).To(ContainSubstring(`code="40"`)) - Expect(rec.Header().Get("Retry-After")).To(BeEmpty()) - }) - - It("counts attempts against unknown usernames", func() { - failTimes(3, "u=newuser", "p=secret") - _ = ds.User().Put(ctx, &model.User{UserName: "newuser", NewPassword: "secret"}) - - serve(newGetRequest("u=newuser", "p=secret")) - Expect(next.called).To(BeFalse()) - }) - - It("treats usernames case-insensitively", func() { - failTimes(3, "u=ADMIN", "p=WRONG") - - serve(newGetRequest("u=admin", "p=wordpass")) - Expect(next.called).To(BeFalse()) - }) - - It("does not count successful logins", func() { - for range 10 { - serve(newGetRequest("u=admin", "p=wordpass")) - Expect(next.called).To(BeTrue()) - } - }) - - It("does not count server errors", func() { - userRepo := ds.User().(*tests.MockedUserRepo) - userRepo.Error = errors.New("db down") - failTimes(5, "u=admin", "p=wordpass") - userRepo.Error = nil - - serve(newGetRequest("u=admin", "p=wordpass")) - Expect(next.called).To(BeTrue()) - }) - - It("does not count server errors when a key is sent as the password", func() { - usr, _ := ds.User().FindByUsername(ctx, "admin") - playerRepo := ds.Player().(*tests.MockPlayerRepo) - Expect(playerRepo.Put(ctx, &model.Player{ID: "player-1", UserId: usr.ID})).To(Succeed()) - key := "nds_0123456789abcdefghijkl" - Expect(playerRepo.SetAPIKey(ctx, "player-1", key)).To(Succeed()) - - playerRepo.Error = errors.New("db down") - failTimes(5, "u=admin", "p="+key) - playerRepo.Error = nil - - serve(newGetRequest("u=admin", "p="+key)) - Expect(next.called).To(BeTrue()) - }) - - It("does not block other usernames from the same IP", func() { - _ = ds.User().Put(ctx, &model.User{UserName: "other", NewPassword: "otherpass"}) - failTimes(3, "u=admin", "p=WRONG") - - serve(newGetRequest("u=other", "p=otherpass")) - Expect(next.called).To(BeTrue()) - }) - - It("does not block the same username from another IP", func() { - failTimes(3, "u=admin", "p=WRONG") - - r := newGetRequest("u=admin", "p=wordpass") - r.RemoteAddr = "198.51.100.7:1234" - serve(r) - Expect(next.called).To(BeTrue()) - }) - - It("does not limit reverse proxy authentication", func() { - conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24" - conf.Server.ExtAuth.UserHeader = "Remote-User" - failTimes(3, "u=admin", "p=WRONG") - - r := newGetRequest() - r.Header.Add("Remote-User", "admin") - r = r.WithContext(request.WithReverseProxyIp(r.Context(), "192.168.1.1")) - serve(r) - Expect(next.called).To(BeTrue()) - }) - - It("throttles a repeated bad key without locking out valid keys from the same IP", func() { - usr, _ := ds.User().FindByUsername(ctx, "admin") - playerRepo := ds.Player().(*tests.MockPlayerRepo) - Expect(playerRepo.Put(ctx, &model.Player{ID: "player-1", UserId: usr.ID})).To(Succeed()) - key := "nds_0123456789abcdefghijkl" - Expect(playerRepo.SetAPIKey(ctx, "player-1", key)).To(Succeed()) - - for range 3 { - Expect(serve(newGetRequest("apiKey=nds_bad")).Body.String()).To(ContainSubstring(`code="44"`)) - } - playerRepo.APIKeys["nds_bad"] = "player-1" - rec := serve(newGetRequest("apiKey=nds_bad")) - Expect(next.called).To(BeFalse()) - Expect(rec.Body.String()).To(ContainSubstring(`code="44"`)) - - serve(newGetRequest("apiKey=" + key)) - Expect(next.called).To(BeTrue()) - }) - - It("is disabled when AuthRequestLimit is 0", func() { - conf.Server.AuthRequestLimit = 0 - cp = authenticate(ds)(next) - failTimes(10, "u=admin", "p=WRONG") - - serve(newGetRequest("u=admin", "p=wordpass")) - Expect(next.called).To(BeTrue()) - }) - }) - - When("valid requests overlap", func() { - var gate *gatedUserRepo - var gatedDS model.DataStore - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.AuthRequestLimit = 5 - conf.Server.AuthWindowLength = time.Minute - gate = &gatedUserRepo{ - UserRepository: ds.User(), - entered: make(chan struct{}, 64), - proceed: make(chan struct{}), - } - gatedDS = &gatedDataStore{DataStore: ds, users: gate} - }) - - It("lets every valid request through while checks are in flight", func() { - const burst = 6 - cp := authenticate(gatedDS)(&countingHandler{}) - var passed atomic.Int32 - var wg sync.WaitGroup - for range burst { - wg.Go(func() { - rec := httptest.NewRecorder() - cp.ServeHTTP(rec, newGetRequest("u=admin", "p=wordpass")) - if !strings.Contains(rec.Body.String(), `code="40"`) { - passed.Add(1) - } - }) - } - for range conf.Server.AuthRequestLimit { - Eventually(gate.entered).Should(Receive()) - } - close(gate.proceed) - wg.Wait() - - Expect(passed.Load()).To(Equal(int32(burst))) - }) - - It("caps concurrent credential checks for wrong passwords", func() { - cp := authenticate(gatedDS)(&countingHandler{}) - var wg sync.WaitGroup - for i := range 100 { - wg.Go(func() { - cp.ServeHTTP(httptest.NewRecorder(), newGetRequest("u=admin", fmt.Sprintf("p=wrong%d", i))) - }) - } - - limit := int32(conf.Server.AuthRequestLimit) - Eventually(gate.lookups.Load).Should(Equal(limit)) - Consistently(gate.lookups.Load, 100*time.Millisecond).Should(Equal(limit)) - close(gate.proceed) - wg.Wait() - }) - }) - }) - - Describe("AdminOnly", func() { - It("passes admin users", func() { - r := newGetRequest() - r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin-id", IsAdmin: true})) - - adminOnly(next).ServeHTTP(w, r) + It("passes authentication with correct credentials", func() { + r := newGetRequest("u=admin", "p=wordpass") + cp := authenticate(ds)(next) + cp.ServeHTTP(w, r) Expect(next.called).To(BeTrue()) + user, _ := request.UserFrom(next.req.Context()) + Expect(user.UserName).To(Equal("admin")) }) - It("rejects non-admin users", func() { - r := newGetRequest() - r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "user-id", IsAdmin: false})) + It("fails authentication with wrong password", func() { + r := newGetRequest("u=invalid", "", "", "") + cp := authenticate(ds)(next) + cp.ServeHTTP(w, r) - adminOnly(next).ServeHTTP(w, r) - - Expect(w.Body.String()).To(ContainSubstring(`code="50"`)) - Expect(next.called).To(BeFalse()) - }) - - It("returns an internal error when user is missing from context", func() { - r := newGetRequest() - - adminOnly(next).ServeHTTP(w, r) - - Expect(w.Body.String()).To(ContainSubstring(`code="0"`)) + Expect(w.Body.String()).To(ContainSubstring(`code="40"`)) Expect(next.called).To(BeFalse()) }) }) @@ -670,24 +177,6 @@ var _ = Describe("Middlewares", func() { Expect(cookieStr).To(BeEmpty()) }) - Context("player bound by an API key", func() { - BeforeEach(func() { - r = r.WithContext(request.WithPlayer(r.Context(), model.Player{ID: "keyed"})) - gp := getPlayer(mockedPlayers)(next) - gp.ServeHTTP(w, r) - }) - - It("uses the key's player", func() { - Expect(mockedPlayers.touched).To(BeTrue()) - player, _ := request.PlayerFrom(next.req.Context()) - Expect(player.ID).To(Equal("keyed")) - }) - - It("does not set the player cookie", func() { - Expect(w.Header().Get("Set-Cookie")).To(BeEmpty()) - }) - }) - Context("PlayerId specified in Cookies", func() { BeforeEach(func() { cookie := &http.Cookie{ @@ -737,119 +226,80 @@ var _ = Describe("Middlewares", func() { }) }) - Describe("validateCredentials", func() { - var usr *model.User - + Describe("validateUser", func() { BeforeEach(func() { - ur := ds.User() - _ = ur.Put(ctx, &model.User{ + ur := ds.User(context.TODO()) + _ = ur.Put(&model.User{ UserName: "admin", NewPassword: "wordpass", }) - - var err error - usr, err = ur.FindByUsernameWithPassword(ctx, "admin") - if err != nil { - panic(err) - } }) - Context("Plaintext password", func() { It("authenticates with plaintext password ", func() { - err := validateCredentials(usr, "wordpass", "", "", "") + usr, err := validateUser(context.TODO(), ds, "admin", "wordpass", "", "", "") Expect(err).NotTo(HaveOccurred()) + Expect(usr.UserName).To(Equal("admin")) }) It("fails authentication with wrong password", func() { - err := validateCredentials(usr, "INVALID", "", "", "") + _, err := validateUser(context.TODO(), ds, "admin", "INVALID", "", "", "") Expect(err).To(MatchError(model.ErrInvalidAuth)) }) }) Context("Encoded password", func() { It("authenticates with simple encoded password ", func() { - err := validateCredentials(usr, "enc:776f726470617373", "", "", "") + usr, err := validateUser(context.TODO(), ds, "admin", "enc:776f726470617373", "", "", "") Expect(err).NotTo(HaveOccurred()) + Expect(usr.UserName).To(Equal("admin")) }) }) Context("Token based authentication", func() { It("authenticates with token based authentication", func() { - err := validateCredentials(usr, "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt", "") + usr, err := validateUser(context.TODO(), ds, "admin", "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt", "") Expect(err).NotTo(HaveOccurred()) + Expect(usr.UserName).To(Equal("admin")) }) It("fails if salt is missing", func() { - err := validateCredentials(usr, "", "23b342970e25c7928831c3317edd0b67", "", "") + _, err := validateUser(context.TODO(), ds, "admin", "", "23b342970e25c7928831c3317edd0b67", "", "") Expect(err).To(MatchError(model.ErrInvalidAuth)) }) }) Context("JWT based authentication", func() { - var usr *model.User var validToken string - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) conf.Server.SessionTimeout = time.Minute auth.Init(ds) - usr = &model.User{UserName: "admin"} + u := &model.User{UserName: "admin"} var err error - validToken, err = auth.CreateToken(usr) + validToken, err = auth.CreateToken(u) if err != nil { panic(err) } }) - It("authenticates with JWT token based authentication", func() { - err := validateCredentials(usr, "", "", "", validToken) + usr, err := validateUser(context.TODO(), ds, "admin", "", "", "", validToken) Expect(err).NotTo(HaveOccurred()) + Expect(usr.UserName).To(Equal("admin")) }) It("fails if JWT token is invalid", func() { - err := validateCredentials(usr, "", "", "", "invalid.token") + _, err := validateUser(context.TODO(), ds, "admin", "", "", "", "invalid.token") Expect(err).To(MatchError(model.ErrInvalidAuth)) }) It("fails if JWT token sub is different than username", func() { u := &model.User{UserName: "hacker"} validToken, _ = auth.CreateToken(u) - err := validateCredentials(usr, "", "", "", validToken) + _, err := validateUser(context.TODO(), ds, "admin", "", "", "", validToken) Expect(err).To(MatchError(model.ErrInvalidAuth)) }) }) - - Context("JWT credentials", func() { - var usr *model.User - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.SessionTimeout = time.Minute - auth.Init(ds) - usr = &model.User{ID: "u1", UserName: "johndoe", TokenEpoch: 1} - }) - - It("accepts an unscoped session token", func() { - tokenStr, err := auth.CreateToken(usr) - Expect(err).ToNot(HaveOccurred()) - Expect(validateCredentials(usr, "", "", "", tokenStr)).To(Succeed()) - }) - - It("rejects a jellyfin-scoped token", func() { - tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin) - Expect(err).ToNot(HaveOccurred()) - Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth)) - }) - - It("rejects a token with a stale epoch", func() { - tokenStr, err := auth.CreateToken(usr) - Expect(err).ToNot(HaveOccurred()) - usr.TokenEpoch = 2 - Expect(validateCredentials(usr, "", "", "", tokenStr)).To(MatchError(model.ErrInvalidAuth)) - }) - }) }) }) @@ -861,19 +311,11 @@ type mockHandler struct { func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { mh.req = r mh.called = true - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("OK")) } type mockPlayers struct { core.Players transcoding *model.Transcoding - touched bool -} - -func (mp *mockPlayers) Touch(_ context.Context, plr model.Player, _, _, _ string) (*model.Player, *model.Transcoding, error) { - mp.touched = true - return &plr, mp.transcoding, nil } func (mp *mockPlayers) Get(ctx context.Context, playerId string) (*model.Player, error) { @@ -886,28 +328,3 @@ func (mp *mockPlayers) Register(ctx context.Context, id, client, typ, ip string) } return &model.Player{ID: id}, mp.transcoding, nil } - -type gatedDataStore struct { - model.DataStore - users model.UserRepository -} - -func (g *gatedDataStore) User() model.UserRepository { return g.users } - -type gatedUserRepo struct { - model.UserRepository - entered chan struct{} - proceed chan struct{} - lookups atomic.Int32 -} - -func (g *gatedUserRepo) FindByUsernameWithPassword(ctx context.Context, username string) (*model.User, error) { - g.lookups.Add(1) - g.entered <- struct{}{} - <-g.proceed - return g.UserRepository.FindByUsernameWithPassword(ctx, username) -} - -type countingHandler struct{ calls atomic.Int32 } - -func (c *countingHandler) ServeHTTP(http.ResponseWriter, *http.Request) { c.calls.Add(1) } diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 21c407039..106029daf 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -8,21 +8,6 @@ import ( func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subsonic, error) { response := newResponse() - extensions := responses.OpenSubsonicExtensions{ - {Name: "transcodeOffset", Versions: []int32{1}}, - {Name: "formPost", Versions: []int32{1}}, - {Name: "songLyrics", Versions: []int32{1, 2}}, - {Name: "indexBasedQueue", Versions: []int32{1}}, - {Name: "transcoding", Versions: []int32{1}}, - {Name: "playbackReport", Versions: []int32{1}}, - {Name: "topSongsByArtistId", Versions: []int32{1}}, - {Name: "apiKeyAuthentication", Versions: []int32{1}}, - } - if api.sonic != nil && api.sonic.HasProvider() { - extensions = append(extensions, responses.OpenSubsonicExtension{ - Name: "sonicSimilarity", Versions: []int32{1}, - }) - } - response.OpenSubsonicExtensions = &extensions + response.OpenSubsonicExtensions = &responses.OpenSubsonicExtensions{} return response, nil } diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go deleted file mode 100644 index 8740c9971..000000000 --- a/server/subsonic/opensubsonic_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package subsonic_test - -import ( - "encoding/json" - "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 - w *httptest.ResponseRecorder - r *http.Request - ) - - JustBeforeEach(func() { - w = httptest.NewRecorder() - r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil) - }) - - 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) - }) - - It("should return the base 8 OpenSubsonicExtensions without sonicSimilarity", func() { - router.ServeHTTP(w, r) - - // 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(8), - ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), - 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: "topSongsByArtistId", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "apiKeyAuthentication", 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 9 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(9), - ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1, 2}}), - 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}}), - ContainElement(responses.OpenSubsonicExtension{Name: "topSongsByArtistId", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "apiKeyAuthentication", Versions: []int32{1}}), - )) - }) - }) -}) diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index e64fc9e82..2927f2197 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -5,34 +5,33 @@ import ( "errors" "fmt" "net/http" + "time" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils" ) func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - allPls, err := api.playlists.GetAll(ctx, model.QueryOptions{Sort: "name"}) + allPls, err := api.ds.Playlist(ctx).GetAll(model.QueryOptions{Sort: "name"}) if err != nil { log.Error(r, err) return nil, err } - response := newResponse() - response.Playlists = &responses.Playlists{ - Playlist: slice.MapWithArg(allPls, ctx, api.buildPlaylist), + playlists := make([]responses.Playlist, len(allPls)) + for i, p := range allPls { + playlists[i] = *api.buildPlaylist(p) } + response := newResponse() + response.Playlists = &responses.Playlists{Playlist: playlists} return response, nil } func (api *Router) GetPlaylist(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - p := req.Params(r) - id, err := p.String("id") + id, err := requiredParamString(r, "id") if err != nil { return nil, err } @@ -40,10 +39,10 @@ func (api *Router) GetPlaylist(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subsonic, error) { - pls, err := api.playlists.GetWithTracks(ctx, id) + pls, err := api.ds.Playlist(ctx).GetWithTracks(id, true) if errors.Is(err, model.ErrNotFound) { log.Error(ctx, err.Error(), "id", id) - return nil, newError(responses.ErrorDataNotFound, "playlist not found") + return nil, newError(responses.ErrorDataNotFound, "Directory not found") } if err != nil { log.Error(ctx, err) @@ -51,23 +50,47 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso } response := newResponse() - response.Playlist = &responses.PlaylistWithSongs{ - Playlist: api.buildPlaylist(ctx, *pls), - } - response.Playlist.Entry = slice.MapWithArg(pls.MediaFiles(), ctx, childFromMediaFile) + response.Playlist = api.buildPlaylistWithSongs(ctx, pls) return response, nil } +func (api *Router) create(ctx context.Context, playlistId, name string, ids []string) (string, error) { + err := api.ds.WithTx(func(tx model.DataStore) error { + owner := getUser(ctx) + var pls *model.Playlist + var err error + + if playlistId != "" { + pls, err = tx.Playlist(ctx).Get(playlistId) + if err != nil { + return err + } + if owner.ID != pls.OwnerID { + return model.ErrNotAuthorized + } + } else { + pls = &model.Playlist{Name: name} + pls.OwnerID = owner.ID + } + pls.Tracks = nil + pls.AddTracks(ids) + + err = tx.Playlist(ctx).Put(pls) + playlistId = pls.ID + return err + }) + return playlistId, err +} + func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - p := req.Params(r) - songIds := p.Strings("songId") - playlistId, _ := p.String("playlistId") - name, _ := p.String("name") + songIds := utils.ParamStrings(r, "songId") + playlistId := utils.ParamString(r, "playlistId") + name := utils.ParamString(r, "name") if playlistId == "" && name == "" { return nil, errors.New("required parameter name is missing") } - id, err := api.playlists.Create(ctx, playlistId, name, songIds) + id, err := api.create(ctx, playlistId, name, songIds) if err != nil { log.Error(r, err) return nil, err @@ -76,12 +99,11 @@ func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) } func (api *Router) DeletePlaylist(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - id, err := p.String("id") + id, err := requiredParamString(r, "id") if err != nil { return nil, err } - err = api.playlists.Delete(r.Context(), id) + err = api.ds.Playlist(r.Context()).Delete(id) if errors.Is(err, model.ErrNotAuthorized) { return nil, newError(responses.ErrorAuthorizationFail) } @@ -93,19 +115,25 @@ func (api *Router) DeletePlaylist(r *http.Request) (*responses.Subsonic, error) } func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - playlistId, err := p.String("playlistId") + playlistId, err := requiredParamString(r, "playlistId") if err != nil { return nil, err } - songsToAdd := p.Strings("songIdToAdd") - songIndexesToRemove, _ := p.Ints("songIndexToRemove") + songsToAdd := utils.ParamStrings(r, "songIdToAdd") + songIndexesToRemove := utils.ParamInts(r, "songIndexToRemove") var plsName *string - if s, err := p.String("name"); err == nil { - plsName = &s + if s, ok := r.URL.Query()["name"]; ok { + plsName = &s[0] + } + var comment *string + if c, ok := r.URL.Query()["comment"]; ok { + comment = &c[0] + } + var public *bool + if _, ok := r.URL.Query()["public"]; ok { + p := utils.ParamBool(r, "public", false) + public = &p } - comment := p.StringPtr("comment") - public := p.BoolPtr("public") log.Debug(r, "Updating playlist", "id", playlistId) if plsName != nil { @@ -119,52 +147,35 @@ func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error) return nil, newError(responses.ErrorAuthorizationFail) } if err != nil { - log.Error(r, "Error updating playlist", "id", playlistId, err) + log.Error(r, err) return nil, err } return newResponse(), nil } -func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) responses.Playlist { - pls := responses.Playlist{} - pls.Id = p.ID - pls.Name = p.Name - pls.SongCount = int32(p.SongCount) - pls.Duration = int32(p.Duration) - pls.Created = p.CreatedAt - pls.Changed = p.UpdatedAt - - player, ok := request.PlayerFrom(ctx) - if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) { - return pls +func (api *Router) buildPlaylistWithSongs(ctx context.Context, p *model.Playlist) *responses.PlaylistWithSongs { + pls := &responses.PlaylistWithSongs{ + Playlist: *api.buildPlaylist(*p), } - - pls.Comment = p.Comment - pls.Owner = p.OwnerName - pls.Public = p.Public - pls.CoverArt = coverArtOrEmpty(p.CoverArtID(), p.ImageAbsent) - pls.OpenSubsonicPlaylist = buildOSPlaylist(ctx, p) - + pls.Entry = childrenFromMediaFiles(ctx, p.MediaFiles()) return pls } -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{} - +func (api *Router) buildPlaylist(p model.Playlist) *responses.Playlist { + pls := &responses.Playlist{} + pls.Id = p.ID + pls.Name = p.Name + pls.Comment = p.Comment + pls.SongCount = int32(p.SongCount) + pls.Owner = p.OwnerName + pls.Duration = int32(p.Duration) + pls.Public = p.Public + pls.Created = p.CreatedAt + pls.CoverArt = p.CoverArtID().String() if p.IsSmartPlaylist() { - pls.Readonly = true - - if p.EvaluatedAt != nil { - pls.ValidUntil = new(p.EvaluatedAt.Add(p.RefreshDelay())) - } + pls.Changed = time.Now() } else { - user, ok := request.UserFrom(ctx) - pls.Readonly = !ok || p.OwnerID != user.ID || !p.TracksEditable() + pls.Changed = p.UpdatedAt } - - return &pls + return pls } diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go deleted file mode 100644 index c7775c0fa..000000000 --- a/server/subsonic/playlists_test.go +++ /dev/null @@ -1,399 +0,0 @@ -package subsonic - -import ( - "context" - "encoding/json" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ playlists.Playlists = (*fakePlaylists)(nil) - -var _ = Describe("buildPlaylist", func() { - var router *Router - var ds model.DataStore - var ctx context.Context - var playlist model.Playlist - - BeforeEach(func() { - ds = &tests.MockDataStore{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - ctx = context.Background() - }) - - Describe("normal playlist", func() { - BeforeEach(func() { - createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC) - updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC) - - playlist = model.Playlist{ - ID: "pls-1", - Name: "My Playlist", - Comment: "Test comment", - OwnerName: "admin", - OwnerID: "1234", - Public: true, - SongCount: 10, - Duration: 600, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - }) - - Context("with minimal client", func() { - BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "minimal-client" - player := model.Player{Client: "minimal-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("returns only basic fields", func() { - result := router.buildPlaylist(ctx, playlist) - - Expect(result.Id).To(Equal("pls-1")) - Expect(result.Name).To(Equal("My Playlist")) - Expect(result.SongCount).To(Equal(int32(10))) - Expect(result.Duration).To(Equal(int32(600))) - Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(playlist.UpdatedAt)) - - // These should not be set - Expect(result.Comment).To(BeEmpty()) - Expect(result.Owner).To(BeEmpty()) - Expect(result.Public).To(BeFalse()) - Expect(result.CoverArt).To(BeEmpty()) - }) - }) - - Context("with non-minimal client", func() { - BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "minimal-client" - player := model.Player{Client: "regular-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("returns all fields", func() { - result := router.buildPlaylist(ctx, playlist) - - Expect(result.Id).To(Equal("pls-1")) - Expect(result.Name).To(Equal("My Playlist")) - Expect(result.SongCount).To(Equal(int32(10))) - Expect(result.Duration).To(Equal(int32(600))) - Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(playlist.UpdatedAt)) - Expect(result.Comment).To(Equal("Test comment")) - Expect(result.Owner).To(Equal("admin")) - Expect(result.Public).To(BeTrue()) - Expect(result.Readonly).To(BeTrue()) - }) - - It("returns all fields when as owner", func() { - ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"}) - - result := router.buildPlaylist(ctx, playlist) - - Expect(result.Id).To(Equal("pls-1")) - Expect(result.Name).To(Equal("My Playlist")) - Expect(result.SongCount).To(Equal(int32(10))) - Expect(result.Duration).To(Equal(int32(600))) - Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(playlist.UpdatedAt)) - Expect(result.Comment).To(Equal("Test comment")) - Expect(result.Owner).To(Equal("admin")) - Expect(result.Public).To(BeTrue()) - Expect(result.Readonly).To(BeFalse()) - }) - - It("is read-only for a synced playlist even as owner", func() { - ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"}) - playlist.Sync = true - - result := router.buildPlaylist(ctx, playlist) - - Expect(result.Readonly).To(BeTrue()) - }) - }) - - Context("when minimal clients list is empty", func() { - BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "" - player := model.Player{Client: "any-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("returns all fields", func() { - result := router.buildPlaylist(ctx, playlist) - - Expect(result.Comment).To(Equal("Test comment")) - Expect(result.Owner).To(Equal("admin")) - Expect(result.Public).To(BeTrue()) - }) - }) - - Context("artwork emission", func() { - It("suffixes coverArt with the content hash when resolved", func() { - playlist.ImageHash = "0123456789abcdef" - result := router.buildPlaylist(ctx, playlist) - Expect(result.CoverArt).To(Equal("pl-pls-1_0123456789abcdef")) - }) - It("omits coverArt when known absent", func() { - playlist.ImageAbsent = true - result := router.buildPlaylist(ctx, playlist) - Expect(result.CoverArt).To(BeEmpty()) - }) - }) - - 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) - - Expect(result.Comment).To(Equal("Test comment")) - Expect(result.Owner).To(Equal("admin")) - Expect(result.Public).To(BeTrue()) - }) - }) - }) - - Describe("smart playlist", func() { - evaluatedAt := time.Date(2023, 2, 20, 15, 45, 0, 0, time.UTC) - validUntil := evaluatedAt.Add(5 * time.Second) - - BeforeEach(func() { - createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC) - updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC) - - playlist = model.Playlist{ - ID: "pls-1", - Name: "My Playlist", - Comment: "Test comment", - OwnerName: "admin", - OwnerID: "1234", - Public: true, - SongCount: 10, - Duration: 600, - CreatedAt: createdAt, - UpdatedAt: updatedAt, - EvaluatedAt: &evaluatedAt, - Rules: &criteria.Criteria{ - Expression: criteria.All{criteria.Contains{"title": "title"}}, - }, - } - }) - - Context("with minimal client", func() { - BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "minimal-client" - player := model.Player{Client: "minimal-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("returns only basic fields", func() { - result := router.buildPlaylist(ctx, playlist) - - Expect(result.Id).To(Equal("pls-1")) - Expect(result.Name).To(Equal("My Playlist")) - Expect(result.SongCount).To(Equal(int32(10))) - Expect(result.Duration).To(Equal(int32(600))) - Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(playlist.UpdatedAt)) - - // These should not be set - Expect(result.Comment).To(BeEmpty()) - Expect(result.Owner).To(BeEmpty()) - Expect(result.Public).To(BeFalse()) - Expect(result.CoverArt).To(BeEmpty()) - Expect(result.OpenSubsonicPlaylist).To(BeNil()) - }) - }) - - Context("with non-minimal client", func() { - BeforeEach(func() { - conf.Server.Subsonic.MinimalClients = "minimal-client" - player := model.Player{Client: "regular-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("returns all fields", func() { - result := router.buildPlaylist(ctx, playlist) - Expect(result.Id).To(Equal("pls-1")) - Expect(result.Name).To(Equal("My Playlist")) - Expect(result.SongCount).To(Equal(int32(10))) - Expect(result.Duration).To(Equal(int32(600))) - Expect(result.Created).To(Equal(playlist.CreatedAt)) - Expect(result.Changed).To(Equal(playlist.UpdatedAt)) - Expect(result.Comment).To(Equal("Test comment")) - Expect(result.Owner).To(Equal("admin")) - Expect(result.Public).To(BeTrue()) - Expect(result.Readonly).To(BeTrue()) - Expect(result.ValidUntil).To(Equal(&validUntil)) - }) - }) - - Context("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()) - }) - }) - - Context("when it was never evaluated", func() { - BeforeEach(func() { - playlist.EvaluatedAt = nil - player := model.Player{Client: "regular-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("omits validUntil but still reports changed", func() { - result := router.buildPlaylist(ctx, playlist) - - Expect(result.ValidUntil).To(BeNil()) - Expect(result.Changed).To(Equal(playlist.UpdatedAt)) - }) - }) - - Context("with a per-playlist refreshDelay", func() { - BeforeEach(func() { - playlist.Rules.RefreshDelay = 24 * time.Hour - player := model.Player{Client: "regular-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("computes validUntil from the playlist's own delay", func() { - result := router.buildPlaylist(ctx, playlist) - expected := evaluatedAt.Add(24 * time.Hour) - Expect(result.ValidUntil).To(Equal(&expected)) - }) - }) - }) - - Describe("annotation leakage", func() { - It("does not serialize starred/rating even when the model carries them", func() { - p := model.Playlist{ID: "pl-1", Name: "My Playlist"} - p.Starred = true - p.Rating = 5 - - resp := router.buildPlaylist(ctx, p) - - data, err := json.Marshal(resp) - Expect(err).ToNot(HaveOccurred()) - var fields map[string]any - Expect(json.Unmarshal(data, &fields)).To(Succeed()) - Expect(fields).ToNot(HaveKey("starred")) - Expect(fields).ToNot(HaveKey("starredAt")) - Expect(fields).ToNot(HaveKey("rating")) - Expect(fields).ToNot(HaveKey("userRating")) - Expect(fields).ToNot(HaveKey("averageRating")) - Expect(fields).ToNot(HaveKey("playCount")) - }) - }) -}) - -var _ = Describe("UpdatePlaylist", func() { - var router *Router - var ds model.DataStore - var playlists *fakePlaylists - - BeforeEach(func() { - ds = &tests.MockDataStore{} - playlists = &fakePlaylists{} - 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() { - r := newGetRequest("playlistId=123", "comment=") - _, err := router.UpdatePlaylist(r) - Expect(err).ToNot(HaveOccurred()) - Expect(playlists.lastPlaylistID).To(Equal("123")) - Expect(playlists.lastComment).ToNot(BeNil()) - Expect(*playlists.lastComment).To(Equal("")) - }) - - It("leaves comment unchanged when parameter is missing", func() { - r := newGetRequest("playlistId=123") - _, err := router.UpdatePlaylist(r) - Expect(err).ToNot(HaveOccurred()) - Expect(playlists.lastPlaylistID).To(Equal("123")) - Expect(playlists.lastComment).To(BeNil()) - }) - - It("sets public to true when parameter is 'true'", func() { - r := newGetRequest("playlistId=123", "public=true") - _, err := router.UpdatePlaylist(r) - Expect(err).ToNot(HaveOccurred()) - Expect(playlists.lastPlaylistID).To(Equal("123")) - Expect(playlists.lastPublic).ToNot(BeNil()) - Expect(*playlists.lastPublic).To(BeTrue()) - }) - - It("sets public to false when parameter is 'false'", func() { - r := newGetRequest("playlistId=123", "public=false") - _, err := router.UpdatePlaylist(r) - Expect(err).ToNot(HaveOccurred()) - Expect(playlists.lastPlaylistID).To(Equal("123")) - Expect(playlists.lastPublic).ToNot(BeNil()) - Expect(*playlists.lastPublic).To(BeFalse()) - }) - - It("leaves public unchanged when parameter is missing", func() { - r := newGetRequest("playlistId=123") - _, err := router.UpdatePlaylist(r) - Expect(err).ToNot(HaveOccurred()) - Expect(playlists.lastPlaylistID).To(Equal("123")) - Expect(playlists.lastPublic).To(BeNil()) - }) -}) - -type fakePlaylists struct { - playlists.Playlists - lastPlaylistID string - lastName *string - lastComment *string - lastPublic *bool - lastAdd []string - lastRemove []int -} - -func (f *fakePlaylists) Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error { - f.lastPlaylistID = playlistID - f.lastName = name - f.lastComment = comment - f.lastPublic = public - f.lastAdd = idsToAdd - f.lastRemove = idxToRemove - return nil -} diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index 2c4033467..bc2497f54 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -2,28 +2,24 @@ package subsonic import ( "net/http" - "strings" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils" ) func (api *Router) CreateInternetRadio(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - streamUrl, err := p.String("streamUrl") + streamUrl, err := requiredParamString(r, "streamUrl") if err != nil { return nil, err } - name, err := p.String("name") + name, err := requiredParamString(r, "name") if err != nil { return nil, err } - homepageUrl, _ := p.String("homepageUrl") + homepageUrl := utils.ParamString(r, "homepageUrl") ctx := r.Context() radio := &model.Radio{ @@ -32,7 +28,7 @@ func (api *Router) CreateInternetRadio(r *http.Request) (*responses.Subsonic, er Name: name, } - err = api.ds.Radio().Put(ctx, radio) + err = api.ds.Radio(ctx).Put(radio) if err != nil { return nil, err } @@ -40,14 +36,13 @@ func (api *Router) CreateInternetRadio(r *http.Request) (*responses.Subsonic, er } func (api *Router) DeleteInternetRadio(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - id, err := p.String("id") + id, err := requiredParamString(r, "id") if err != nil { return nil, err } - err = api.ds.Radio().Delete(r.Context(), id) + err = api.ds.Radio(r.Context()).Delete(id) if err != nil { return nil, err } @@ -56,7 +51,7 @@ func (api *Router) DeleteInternetRadio(r *http.Request) (*responses.Subsonic, er func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - radios, err := api.ds.Radio().GetAll(ctx, model.QueryOptions{Sort: "name"}) + radios, err := api.ds.Radio(ctx).GetAll(model.QueryOptions{Sort: "name"}) if err != nil { return nil, err } @@ -69,19 +64,6 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro StreamUrl: g.StreamUrl, HomepageUrl: g.HomePageUrl, } - - player, _ := request.PlayerFrom(ctx) - if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) { - continue - } - // Add coverArt if not legacy client; only radios have an uploaded image as their sole art source - var coverArt string - if g.UploadedImage != "" { - coverArt = coverArtOrEmpty(g.CoverArtID(), g.ImageAbsent) - } - res[i].OpenSubsonicRadio = &responses.OpenSubsonicRadio{ - CoverArt: coverArt, - } } response := newResponse() @@ -93,23 +75,22 @@ func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, erro } func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - id, err := p.String("id") + id, err := requiredParamString(r, "id") if err != nil { return nil, err } - streamUrl, err := p.String("streamUrl") + streamUrl, err := requiredParamString(r, "streamUrl") if err != nil { return nil, err } - name, err := p.String("name") + name, err := requiredParamString(r, "name") if err != nil { return nil, err } - homepageUrl, _ := p.String("homepageUrl") + homepageUrl := utils.ParamString(r, "homepageUrl") ctx := r.Context() radio := &model.Radio{ @@ -119,7 +100,7 @@ func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, er Name: name, } - err = api.ds.Radio().Put(ctx, radio, "StreamUrl", "HomePageUrl", "Name") + err = api.ds.Radio(ctx).Put(radio) if err != nil { return nil, err } diff --git a/server/subsonic/radio_test.go b/server/subsonic/radio_test.go deleted file mode 100644 index aa805abbf..000000000 --- a/server/subsonic/radio_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package subsonic - -import ( - "context" - "net/http/httptest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Radio", func() { - var api *Router - var ds *tests.MockDataStore - var ctx context.Context - var radioRepo *tests.MockedRadioRepo - - BeforeEach(func() { - ds = &tests.MockDataStore{} - auth.Init(ds) - api = &Router{ds: ds} - ctx = context.Background() - radioRepo = tests.CreateMockedRadioRepo() - ds.MockedRadio = radioRepo - }) - - Describe("GetInternetRadios", func() { - BeforeEach(func() { - radioRepo.All = model.Radios{ - {ID: "rd-1", Name: "Radio 1", StreamUrl: "http://stream1.example.com", HomePageUrl: "http://home1.example.com", UploadedImage: "rd-1_cover.jpg"}, - {ID: "rd-2", Name: "Radio 2", StreamUrl: "http://stream2.example.com"}, - } - }) - - It("returns all radios with basic fields", func() { - r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) - r = r.WithContext(ctx) - - response, err := api.GetInternetRadios(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(response.InternetRadioStations).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios).To(HaveLen(2)) - Expect(response.InternetRadioStations.Radios[0].ID).To(Equal("rd-1")) - Expect(response.InternetRadioStations.Radios[0].Name).To(Equal("Radio 1")) - Expect(response.InternetRadioStations.Radios[0].StreamUrl).To(Equal("http://stream1.example.com")) - Expect(response.InternetRadioStations.Radios[0].HomepageUrl).To(Equal("http://home1.example.com")) - Expect(response.InternetRadioStations.Radios[1].ID).To(Equal("rd-2")) - Expect(response.InternetRadioStations.Radios[1].HomepageUrl).To(BeEmpty()) - }) - - Context("with a non-legacy client", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Subsonic.LegacyClients = "legacy-client" - player := model.Player{Client: "modern-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("includes coverArt from UploadedImage", func() { - r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) - r = r.WithContext(ctx) - - response, err := api.GetInternetRadios(r) - - 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("ra-rd-1")) - Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) - }) - - It("suffixes coverArt with the content hash when resolved and omits it when known absent", func() { - radioRepo.All = model.Radios{ - {ID: "rd-1", Name: "Radio 1", UploadedImage: "rd-1_cover.jpg", ItemImage: model.ItemImage{ImageHash: "0123456789abcdef"}}, - {ID: "rd-2", Name: "Radio 2", UploadedImage: "rd-2_cover.jpg", ItemImage: model.ItemImage{ImageAbsent: true}}, - } - r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) - r = r.WithContext(ctx) - - response, err := api.GetInternetRadios(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1_0123456789abcdef")) - Expect(response.InternetRadioStations.Radios[1].CoverArt).To(BeEmpty()) - }) - }) - - Context("with a legacy client", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Subsonic.LegacyClients = "legacy-client" - player := model.Player{Client: "legacy-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("does not include coverArt", func() { - r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) - r = r.WithContext(ctx) - - response, err := api.GetInternetRadios(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(response.InternetRadioStations.Radios).To(HaveLen(2)) - Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).To(BeNil()) - Expect(response.InternetRadioStations.Radios[1].OpenSubsonicRadio).To(BeNil()) - }) - }) - - Context("when no player in context", func() { - It("does not include coverArt (empty client matches legacy list)", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Subsonic.LegacyClients = "legacy-client" - - r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) - r = r.WithContext(ctx) - - response, err := api.GetInternetRadios(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).To(BeNil()) - }) - }) - - Context("when legacy clients list is empty", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.Subsonic.LegacyClients = "" - player := model.Player{Client: "any-client"} - ctx = request.WithPlayer(ctx, player) - }) - - It("includes coverArt for all clients", func() { - r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) - r = r.WithContext(ctx) - - response, err := api.GetInternetRadios(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(response.InternetRadioStations.Radios[0].OpenSubsonicRadio).ToNot(BeNil()) - Expect(response.InternetRadioStations.Radios[0].CoverArt).To(Equal("ra-rd-1")) - }) - }) - - It("returns error when repository fails", func() { - radioRepo.SetError(true) - - r := httptest.NewRequest("GET", "/rest/getInternetRadios", nil) - r = r.WithContext(ctx) - - _, err := api.GetInternetRadios(r) - Expect(err).To(HaveOccurred()) - }) - }) -}) diff --git a/server/subsonic/responses/.snapshots/.gitattributes b/server/subsonic/responses/.snapshots/.gitattributes deleted file mode 100644 index 44b4224b1..000000000 --- a/server/subsonic/responses/.snapshots/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* eol=lf \ No newline at end of file diff --git a/server/subsonic/responses/.snapshots/Responses AlbumInfo with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumInfo with data should match .JSON index 597737fde..bd0907693 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumInfo with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumInfo with data should match .JSON @@ -1,15 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "albumInfo": { - "notes": "Believe is the twenty-third studio album by American singer-actress Cher...", - "musicBrainzId": "03c91c40-49a6-44a7-90e7-a700edf97a62", - "lastFmUrl": "https://www.last.fm/music/Cher/Believe", - "smallImageUrl": "https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png", - "mediumImageUrl": "https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png", - "largeImageUrl": "https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png" - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"albumInfo":{"notes":"Believe is the twenty-third studio album by American singer-actress Cher...","musicBrainzId":"03c91c40-49a6-44a7-90e7-a700edf97a62","lastFmUrl":"https://www.last.fm/music/Cher/Believe","smallImageUrl":"https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png","mediumImageUrl":"https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png","largeImageUrl":"https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png"}} diff --git a/server/subsonic/responses/.snapshots/Responses AlbumInfo with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumInfo with data should match .XML index be7651c14..4a1015d82 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumInfo with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumInfo with data should match .XML @@ -1,10 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <albumInfo> - <notes>Believe is the twenty-third studio album by American singer-actress Cher...</notes> - <musicBrainzId>03c91c40-49a6-44a7-90e7-a700edf97a62</musicBrainzId> - <lastFmUrl>https://www.last.fm/music/Cher/Believe</lastFmUrl> - <smallImageUrl>https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png</smallImageUrl> - <mediumImageUrl>https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png</mediumImageUrl> - <largeImageUrl>https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png</largeImageUrl> - </albumInfo> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><albumInfo><notes>Believe is the twenty-third studio album by American singer-actress Cher...</notes><musicBrainzId>03c91c40-49a6-44a7-90e7-a700edf97a62</musicBrainzId><lastFmUrl>https://www.last.fm/music/Cher/Believe</lastFmUrl><smallImageUrl>https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png</smallImageUrl><mediumImageUrl>https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png</mediumImageUrl><largeImageUrl>https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png</largeImageUrl></albumInfo></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses AlbumInfo without data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumInfo without data should match .JSON index 27f0b26fa..8322d184b 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumInfo without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumInfo without data should match .JSON @@ -1,8 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "albumInfo": {} -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"albumInfo":{}} diff --git a/server/subsonic/responses/.snapshots/Responses AlbumInfo without data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumInfo without data should match .XML index 80aff1358..422a75469 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumInfo without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumInfo without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <albumInfo></albumInfo> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><albumInfo></albumInfo></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON deleted file mode 100644 index d6b195f58..000000000 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON +++ /dev/null @@ -1,68 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "albumList": { - "album": [ - { - "id": "1", - "isDir": false, - "title": "", - "bpm": 0, - "comment": "", - "sortName": "sort name", - "mediaType": "album", - "musicBrainzId": "00000000-0000-0000-0000-000000000000", - "isrc": [], - "genres": [ - { - "name": "Genre 1" - }, - { - "name": "Genre 2" - } - ], - "replayGain": {}, - "channelCount": 0, - "samplingRate": 0, - "bitDepth": 0, - "moods": [ - "mood1", - "mood2" - ], - "artists": [ - { - "id": "artist-1", - "name": "Artist 1" - }, - { - "id": "artist-2", - "name": "Artist 2" - } - ], - "displayArtist": "Display artist", - "albumArtists": [ - { - "id": "album-artist-1", - "name": "Artist 1" - }, - { - "id": "album-artist-2", - "name": "Artist 2" - } - ], - "displayAlbumArtist": "Display album artist", - "contributors": [], - "displayComposer": "", - "explicitStatus": "explicit", - "groupings": [ - "Soundtrack" - ], - "works": [], - "movements": [] - } - ] - } -} diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML deleted file mode 100644 index d39fe2e7d..000000000 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML +++ /dev/null @@ -1,15 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <albumList> - <album id="1" isDir="false" title="" sortName="sort name" mediaType="album" musicBrainzId="00000000-0000-0000-0000-000000000000" displayArtist="Display artist" displayAlbumArtist="Display album artist" explicitStatus="explicit"> - <genres name="Genre 1"></genres> - <genres name="Genre 2"></genres> - <moods>mood1</moods> - <moods>mood2</moods> - <artists id="artist-1" name="Artist 1"></artists> - <artists id="artist-2" name="Artist 2"></artists> - <albumArtists id="album-artist-1" name="Artist 1"></albumArtists> - <albumArtists id="album-artist-2" name="Artist 2"></albumArtists> - <groupings>Soundtrack</groupings> - </album> - </albumList> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumList with data should match .JSON index 7963b44f4..40c691954 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with data should match .JSON @@ -1,16 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "albumList": { - "album": [ - { - "id": "1", - "isDir": false, - "title": "title" - } - ] - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"albumList":{"album":[{"id":"1","isDir":false,"title":"title","isVideo":false}]}} diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumList with data should match .XML index 693bbfa58..e4f61a704 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with data should match .XML @@ -1,5 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <albumList> - <album id="1" isDir="false" title="title"></album> - </albumList> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><albumList><album id="1" isDir="false" title="title" isVideo="false"></album></albumList></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList without data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumList without data should match .JSON index 706eefc08..441f17c9c 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumList without data should match .JSON @@ -1,8 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "albumList": {} -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"albumList":{}} diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList without data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumList without data should match .XML index d3012157e..7647de2f9 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumList without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <albumList></albumList> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><albumList></albumList></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON deleted file mode 100644 index f776c7535..000000000 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON +++ /dev/null @@ -1,228 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "album": { - "id": "1", - "name": "album", - "artist": "artist", - "songCount": 0, - "duration": 292, - "created": "0001-01-01T00:00:00Z", - "genre": "rock", - "userRating": 4, - "genres": [ - { - "name": "rock" - }, - { - "name": "progressive" - } - ], - "musicBrainzId": "1234", - "isCompilation": true, - "sortName": "sorted album", - "discTitles": [ - { - "disc": 1, - "title": "disc 1" - }, - { - "disc": 2, - "title": "disc 2" - }, - { - "disc": 3, - "title": "" - } - ], - "originalReleaseDate": { - "year": 1994, - "month": 2, - "day": 4 - }, - "releaseDate": { - "year": 2000, - "month": 5, - "day": 10 - }, - "releaseTypes": [ - "album", - "live" - ], - "recordLabels": [ - { - "name": "label1" - }, - { - "name": "label2" - } - ], - "moods": [ - "happy", - "sad" - ], - "artists": [ - { - "id": "1", - "name": "artist1" - }, - { - "id": "2", - "name": "artist2" - } - ], - "displayArtist": "artist1 \u0026 artist2", - "explicitStatus": "clean", - "version": "Deluxe Edition", - "song": [ - { - "id": "1", - "isDir": true, - "title": "title", - "album": "album", - "artist": "artist", - "track": 1, - "year": 1985, - "genre": "Rock", - "coverArt": "1", - "size": 8421341, - "contentType": "audio/flac", - "suffix": "flac", - "starred": "2016-03-02T20:30:00Z", - "transcodedContentType": "audio/mpeg", - "transcodedSuffix": "mp3", - "duration": 146, - "bitRate": 320, - "bpm": 127, - "comment": "a comment", - "sortName": "sorted song", - "mediaType": "song", - "musicBrainzId": "4321", - "isrc": [ - "ISRC-1" - ], - "genres": [ - { - "name": "rock" - }, - { - "name": "progressive" - } - ], - "replayGain": { - "trackGain": 1, - "albumGain": 2, - "trackPeak": 3, - "albumPeak": 4, - "baseGain": 5, - "fallbackGain": 6 - }, - "channelCount": 2, - "samplingRate": 44100, - "bitDepth": 16, - "moods": [ - "happy", - "sad" - ], - "artists": [ - { - "id": "1", - "name": "artist1" - }, - { - "id": "2", - "name": "artist2" - } - ], - "displayArtist": "artist1 \u0026 artist2", - "albumArtists": [ - { - "id": "1", - "name": "album artist1" - }, - { - "id": "2", - "name": "album artist2" - } - ], - "displayAlbumArtist": "album artist1 \u0026 album artist2", - "contributors": [ - { - "role": "role1", - "artist": { - "id": "1", - "name": "artist1" - } - }, - { - "role": "role2", - "subRole": "subrole4", - "artist": { - "id": "2", - "name": "artist2" - } - } - ], - "displayComposer": "composer 1 \u0026 composer 2", - "explicitStatus": "clean", - "groupings": [ - "Soundtrack", - "Live" - ], - "works": [], - "movements": [] - }, - { - "id": "2", - "isDir": true, - "title": "title", - "album": "album", - "artist": "artist", - "track": 1, - "year": 1985, - "genre": "Rock", - "coverArt": "1", - "size": 8421341, - "contentType": "audio/flac", - "suffix": "flac", - "starred": "2016-03-02T20:30:00Z", - "transcodedContentType": "audio/mpeg", - "transcodedSuffix": "mp3", - "duration": 146, - "bitRate": 320, - "bpm": 0, - "comment": "", - "sortName": "", - "mediaType": "", - "musicBrainzId": "", - "isrc": [], - "genres": [], - "replayGain": { - "trackGain": 0, - "albumGain": 0, - "trackPeak": 0, - "albumPeak": 0, - "baseGain": 0, - "fallbackGain": 0 - }, - "channelCount": 0, - "samplingRate": 0, - "bitDepth": 0, - "moods": [], - "artists": [], - "displayArtist": "", - "albumArtists": [], - "displayAlbumArtist": "", - "contributors": [], - "displayComposer": "", - "explicitStatus": "", - "groupings": [], - "works": [], - "movements": [] - } - ] - } -} diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML deleted file mode 100644 index 16a7748aa..000000000 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML +++ /dev/null @@ -1,42 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <album id="1" name="album" artist="artist" songCount="0" duration="292" created="0001-01-01T00:00:00Z" genre="rock" userRating="4" musicBrainzId="1234" isCompilation="true" sortName="sorted album" displayArtist="artist1 & artist2" explicitStatus="clean" version="Deluxe Edition"> - <genres name="rock"></genres> - <genres name="progressive"></genres> - <discTitles disc="1" title="disc 1"></discTitles> - <discTitles disc="2" title="disc 2"></discTitles> - <discTitles disc="3" title=""></discTitles> - <originalReleaseDate year="1994" month="2" day="4"></originalReleaseDate> - <releaseDate year="2000" month="5" day="10"></releaseDate> - <releaseTypes>album</releaseTypes> - <releaseTypes>live</releaseTypes> - <recordLabels name="label1"></recordLabels> - <recordLabels name="label2"></recordLabels> - <moods>happy</moods> - <moods>sad</moods> - <artists id="1" name="artist1"></artists> - <artists id="2" name="artist2"></artists> - <song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist1 & artist2" displayAlbumArtist="album artist1 & album artist2" displayComposer="composer 1 & composer 2" explicitStatus="clean"> - <isrc>ISRC-1</isrc> - <genres name="rock"></genres> - <genres name="progressive"></genres> - <replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain> - <moods>happy</moods> - <moods>sad</moods> - <artists id="1" name="artist1"></artists> - <artists id="2" name="artist2"></artists> - <albumArtists id="1" name="album artist1"></albumArtists> - <albumArtists id="2" name="album artist2"></albumArtists> - <contributors role="role1"> - <artist id="1" name="artist1"></artist> - </contributors> - <contributors role="role2" subRole="subrole4"> - <artist id="2" name="artist2"></artist> - </contributors> - <groupings>Soundtrack</groupings> - <groupings>Live</groupings> - </song> - <song id="2" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320"> - <replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain> - </song> - </album> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON deleted file mode 100644 index 030502618..000000000 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON +++ /dev/null @@ -1,14 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "album": { - "id": "", - "name": "", - "songCount": 0, - "duration": 0, - "created": "0001-01-01T00:00:00Z" - } -} diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML deleted file mode 100644 index 0d3882080..000000000 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML +++ /dev/null @@ -1,3 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <album id="" name="" songCount="0" duration="0" created="0001-01-01T00:00:00Z"></album> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON deleted file mode 100644 index d3964663b..000000000 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON +++ /dev/null @@ -1,29 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "album": { - "id": "", - "name": "", - "songCount": 0, - "duration": 0, - "created": "0001-01-01T00:00:00Z", - "userRating": 0, - "genres": [], - "musicBrainzId": "", - "isCompilation": false, - "sortName": "", - "discTitles": [], - "originalReleaseDate": {}, - "releaseDate": {}, - "releaseTypes": [], - "recordLabels": [], - "moods": [], - "artists": [], - "displayArtist": "", - "explicitStatus": "", - "version": "" - } -} diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML deleted file mode 100644 index 0d3882080..000000000 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML +++ /dev/null @@ -1,3 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <album id="" name="" songCount="0" duration="0" created="0001-01-01T00:00:00Z"></album> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Artist with OpenSubsonic data should match .JSON b/server/subsonic/responses/.snapshots/Responses Artist with OpenSubsonic data should match .JSON deleted file mode 100644 index 71d365dda..000000000 --- a/server/subsonic/responses/.snapshots/Responses Artist with OpenSubsonic data should match .JSON +++ /dev/null @@ -1,32 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "artists": { - "index": [ - { - "name": "A", - "artist": [ - { - "id": "111", - "name": "aaa", - "albumCount": 2, - "starred": "2016-03-02T20:30:00Z", - "userRating": 3, - "artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", - "musicBrainzId": "1234", - "sortName": "sort name", - "roles": [ - "role1", - "role2" - ] - } - ] - } - ], - "lastModified": 1, - "ignoredArticles": "A" - } -} diff --git a/server/subsonic/responses/.snapshots/Responses Artist with OpenSubsonic data should match .XML b/server/subsonic/responses/.snapshots/Responses Artist with OpenSubsonic data should match .XML deleted file mode 100644 index 799d21054..000000000 --- a/server/subsonic/responses/.snapshots/Responses Artist with OpenSubsonic data should match .XML +++ /dev/null @@ -1,10 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <artists lastModified="1" ignoredArticles="A"> - <index name="A"> - <artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" musicBrainzId="1234" sortName="sort name"> - <roles>role1</roles> - <roles>role2</roles> - </artist> - </index> - </artists> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Artist with data and MBID and Sort Name should match .JSON b/server/subsonic/responses/.snapshots/Responses Artist with data and MBID and Sort Name should match .JSON deleted file mode 100644 index f7d701d03..000000000 --- a/server/subsonic/responses/.snapshots/Responses Artist with data and MBID and Sort Name should match .JSON +++ /dev/null @@ -1,32 +0,0 @@ -{ - "status": "ok", - "version": "1.8.0", - "type": "navidrome", - "serverVersion": "v0.0.0", - "openSubsonic": true, - "artists": { - "index": [ - { - "name": "A", - "artist": [ - { - "id": "111", - "name": "aaa", - "albumCount": 2, - "starred": "2016-03-02T20:30:00Z", - "userRating": 3, - "artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", - "musicBrainzId": "1234", - "sortName": "sort name", - "roles": [ - "role1", - "role2" - ] - } - ] - } - ], - "lastModified": 1, - "ignoredArticles": "A" - } -} diff --git a/server/subsonic/responses/.snapshots/Responses Artist with data and MBID and Sort Name should match .XML b/server/subsonic/responses/.snapshots/Responses Artist with data and MBID and Sort Name should match .XML deleted file mode 100644 index 630ef919b..000000000 --- a/server/subsonic/responses/.snapshots/Responses Artist with data and MBID and Sort Name should match .XML +++ /dev/null @@ -1,10 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"> - <artists lastModified="1" ignoredArticles="A"> - <index name="A"> - <artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" musicBrainzId="1234" sortName="sort name"> - <roles>role1</roles> - <roles>role2</roles> - </artist> - </index> - </artists> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Artist with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Artist with data should match .JSON deleted file mode 100644 index f60df3ebf..000000000 --- a/server/subsonic/responses/.snapshots/Responses Artist with data should match .JSON +++ /dev/null @@ -1,26 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "artists": { - "index": [ - { - "name": "A", - "artist": [ - { - "id": "111", - "name": "aaa", - "albumCount": 2, - "starred": "2016-03-02T20:30:00Z", - "userRating": 3, - "artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" - } - ] - } - ], - "lastModified": 1, - "ignoredArticles": "A" - } -} diff --git a/server/subsonic/responses/.snapshots/Responses Artist with data should match .XML b/server/subsonic/responses/.snapshots/Responses Artist with data should match .XML deleted file mode 100644 index 21bea828c..000000000 --- a/server/subsonic/responses/.snapshots/Responses Artist with data should match .XML +++ /dev/null @@ -1,7 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <artists lastModified="1" ignoredArticles="A"> - <index name="A"> - <artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"></artist> - </index> - </artists> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Artist without data should match .JSON b/server/subsonic/responses/.snapshots/Responses Artist without data should match .JSON deleted file mode 100644 index 74bb5683b..000000000 --- a/server/subsonic/responses/.snapshots/Responses Artist without data should match .JSON +++ /dev/null @@ -1,11 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "artists": { - "lastModified": 1, - "ignoredArticles": "A" - } -} diff --git a/server/subsonic/responses/.snapshots/Responses Artist without data should match .XML b/server/subsonic/responses/.snapshots/Responses Artist without data should match .XML deleted file mode 100644 index 781599731..000000000 --- a/server/subsonic/responses/.snapshots/Responses Artist without data should match .XML +++ /dev/null @@ -1,3 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <artists lastModified="1" ignoredArticles="A"></artists> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses ArtistInfo with data should match .JSON b/server/subsonic/responses/.snapshots/Responses ArtistInfo with data should match .JSON index 2edaa7edc..6190d65e3 100644 --- a/server/subsonic/responses/.snapshots/Responses ArtistInfo with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses ArtistInfo with data should match .JSON @@ -1,29 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "artistInfo": { - "biography": "Black Sabbath is an English \u003ca target='_blank' href=\"https://www.last.fm/tag/heavy%20metal\" class=\"bbcode_tag\" rel=\"tag\"\u003eheavy metal\u003c/a\u003e band", - "musicBrainzId": "5182c1d9-c7d2-4dad-afa0-ccfeada921a8", - "lastFmUrl": "https://www.last.fm/music/Black+Sabbath", - "smallImageUrl": "https://userserve-ak.last.fm/serve/64/27904353.jpg", - "mediumImageUrl": "https://userserve-ak.last.fm/serve/126/27904353.jpg", - "largeImageUrl": "https://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg", - "similarArtist": [ - { - "id": "22", - "name": "Accept" - }, - { - "id": "101", - "name": "Bruce Dickinson" - }, - { - "id": "26", - "name": "Aerosmith" - } - ] - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"artistInfo":{"biography":"Black Sabbath is an English \u003ca target='_blank' href=\"http://www.last.fm/tag/heavy%20metal\" class=\"bbcode_tag\" rel=\"tag\"\u003eheavy metal\u003c/a\u003e band","musicBrainzId":"5182c1d9-c7d2-4dad-afa0-ccfeada921a8","lastFmUrl":"https://www.last.fm/music/Black+Sabbath","smallImageUrl":"https://userserve-ak.last.fm/serve/64/27904353.jpg","mediumImageUrl":"https://userserve-ak.last.fm/serve/126/27904353.jpg","largeImageUrl":"https://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg","similarArtist":[{"id":"22","name":"Accept"},{"id":"101","name":"Bruce Dickinson"},{"id":"26","name":"Aerosmith"}]}} diff --git a/server/subsonic/responses/.snapshots/Responses ArtistInfo with data should match .XML b/server/subsonic/responses/.snapshots/Responses ArtistInfo with data should match .XML index 16c6c5fe0..d53a9d2bb 100644 --- a/server/subsonic/responses/.snapshots/Responses ArtistInfo with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses ArtistInfo with data should match .XML @@ -1,13 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <artistInfo> - <biography>Black Sabbath is an English <a target='_blank' href="https://www.last.fm/tag/heavy%20metal" class="bbcode_tag" rel="tag">heavy metal</a> band</biography> - <musicBrainzId>5182c1d9-c7d2-4dad-afa0-ccfeada921a8</musicBrainzId> - <lastFmUrl>https://www.last.fm/music/Black+Sabbath</lastFmUrl> - <smallImageUrl>https://userserve-ak.last.fm/serve/64/27904353.jpg</smallImageUrl> - <mediumImageUrl>https://userserve-ak.last.fm/serve/126/27904353.jpg</mediumImageUrl> - <largeImageUrl>https://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg</largeImageUrl> - <similarArtist id="22" name="Accept"></similarArtist> - <similarArtist id="101" name="Bruce Dickinson"></similarArtist> - <similarArtist id="26" name="Aerosmith"></similarArtist> - </artistInfo> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><artistInfo><biography>Black Sabbath is an English <a target='_blank' href="http://www.last.fm/tag/heavy%20metal" class="bbcode_tag" rel="tag">heavy metal</a> band</biography><musicBrainzId>5182c1d9-c7d2-4dad-afa0-ccfeada921a8</musicBrainzId><lastFmUrl>https://www.last.fm/music/Black+Sabbath</lastFmUrl><smallImageUrl>https://userserve-ak.last.fm/serve/64/27904353.jpg</smallImageUrl><mediumImageUrl>https://userserve-ak.last.fm/serve/126/27904353.jpg</mediumImageUrl><largeImageUrl>https://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg</largeImageUrl><similarArtist id="22" name="Accept"></similarArtist><similarArtist id="101" name="Bruce Dickinson"></similarArtist><similarArtist id="26" name="Aerosmith"></similarArtist></artistInfo></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses ArtistInfo without data should match .JSON b/server/subsonic/responses/.snapshots/Responses ArtistInfo without data should match .JSON index 8e2807982..43d477ab3 100644 --- a/server/subsonic/responses/.snapshots/Responses ArtistInfo without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses ArtistInfo without data should match .JSON @@ -1,8 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "artistInfo": {} -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"artistInfo":{}} diff --git a/server/subsonic/responses/.snapshots/Responses ArtistInfo without data should match .XML b/server/subsonic/responses/.snapshots/Responses ArtistInfo without data should match .XML index 16f0ad2c5..8e2fa628c 100644 --- a/server/subsonic/responses/.snapshots/Responses ArtistInfo without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses ArtistInfo without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <artistInfo></artistInfo> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><artistInfo></artistInfo></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Bookmarks with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Bookmarks with data should match .JSON index 5b3367e75..602279801 100644 --- a/server/subsonic/responses/.snapshots/Responses Bookmarks with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Bookmarks with data should match .JSON @@ -1,23 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "bookmarks": { - "bookmark": [ - { - "entry": { - "id": "1", - "isDir": false, - "title": "title" - }, - "position": 123, - "username": "user2", - "comment": "a comment", - "created": "0001-01-01T00:00:00Z", - "changed": "0001-01-01T00:00:00Z" - } - ] - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"bookmarks":{"bookmark":[{"entry":{"id":"1","isDir":false,"title":"title","isVideo":false},"position":123,"username":"user2","comment":"a comment","created":"0001-01-01T00:00:00Z","changed":"0001-01-01T00:00:00Z"}]}} diff --git a/server/subsonic/responses/.snapshots/Responses Bookmarks with data should match .XML b/server/subsonic/responses/.snapshots/Responses Bookmarks with data should match .XML index 79200bb63..080d4fc48 100644 --- a/server/subsonic/responses/.snapshots/Responses Bookmarks with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Bookmarks with data should match .XML @@ -1,7 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <bookmarks> - <bookmark position="123" username="user2" comment="a comment" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"> - <entry id="1" isDir="false" title="title"></entry> - </bookmark> - </bookmarks> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><bookmarks><bookmark position="123" username="user2" comment="a comment" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"><entry id="1" isDir="false" title="title" isVideo="false"></entry></bookmark></bookmarks></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Bookmarks without data should match .JSON b/server/subsonic/responses/.snapshots/Responses Bookmarks without data should match .JSON index 267b06eea..41dfd3651 100644 --- a/server/subsonic/responses/.snapshots/Responses Bookmarks without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Bookmarks without data should match .JSON @@ -1,8 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "bookmarks": {} -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"bookmarks":{}} diff --git a/server/subsonic/responses/.snapshots/Responses Bookmarks without data should match .XML b/server/subsonic/responses/.snapshots/Responses Bookmarks without data should match .XML index c0f16179a..9424d82a2 100644 --- a/server/subsonic/responses/.snapshots/Responses Bookmarks without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Bookmarks without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <bookmarks></bookmarks> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><bookmarks></bookmarks></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON index fef60c9b1..0444d8e7c 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON @@ -1,173 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "directory": { - "child": [ - { - "id": "1", - "isDir": true, - "title": "title", - "album": "album", - "artist": "artist", - "track": 1, - "year": 1985, - "genre": "Rock", - "coverArt": "1", - "size": 8421341, - "contentType": "audio/flac", - "suffix": "flac", - "starred": "2016-03-02T20:30:00Z", - "transcodedContentType": "audio/mpeg", - "transcodedSuffix": "mp3", - "duration": 146, - "bitRate": 320, - "bpm": 127, - "comment": "a comment", - "sortName": "sorted title", - "mediaType": "song", - "musicBrainzId": "4321", - "isrc": [ - "ISRC-1", - "ISRC-2" - ], - "genres": [ - { - "name": "rock" - }, - { - "name": "progressive" - } - ], - "replayGain": { - "trackGain": 1, - "albumGain": 2, - "trackPeak": 3, - "albumPeak": 4, - "baseGain": 5, - "fallbackGain": 6 - }, - "channelCount": 2, - "samplingRate": 44100, - "bitDepth": 16, - "moods": [ - "happy", - "sad" - ], - "artists": [ - { - "id": "1", - "name": "artist1" - }, - { - "id": "2", - "name": "artist2" - } - ], - "displayArtist": "artist 1 \u0026 artist 2", - "albumArtists": [ - { - "id": "1", - "name": "album artist1" - }, - { - "id": "2", - "name": "album artist2" - } - ], - "displayAlbumArtist": "album artist 1 \u0026 album artist 2", - "contributors": [ - { - "role": "role1", - "subRole": "subrole3", - "artist": { - "id": "1", - "name": "artist1" - } - }, - { - "role": "role2", - "artist": { - "id": "2", - "name": "artist2" - } - }, - { - "role": "composer", - "artist": { - "id": "3", - "name": "composer1" - } - }, - { - "role": "composer", - "artist": { - "id": "4", - "name": "composer2" - } - } - ], - "displayComposer": "composer 1 \u0026 composer 2", - "explicitStatus": "clean", - "groupings": [ - "Soundtrack", - "Live" - ], - "works": [ - { - "name": "Symphony No. 5", - "musicBrainzId": "mbz-work-1" - }, - { - "name": "Encore" - } - ], - "movements": [ - { - "name": "I. Allegro", - "number": 1, - "count": 4 - } - ] - }, - { - "id": "", - "isDir": false, - "title": "", - "bpm": 0, - "comment": "", - "sortName": "", - "mediaType": "", - "musicBrainzId": "", - "isrc": [], - "genres": [], - "replayGain": { - "trackGain": 0, - "albumGain": 0, - "trackPeak": 0, - "albumPeak": 0, - "baseGain": 0, - "fallbackGain": 0 - }, - "channelCount": 0, - "samplingRate": 0, - "bitDepth": 0, - "moods": [], - "artists": [], - "displayArtist": "", - "albumArtists": [], - "displayAlbumArtist": "", - "contributors": [], - "displayComposer": "", - "explicitStatus": "", - "groupings": [], - "works": [], - "movements": [] - } - ], - "id": "1", - "name": "N" - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"directory":{"child":[{"id":"1","isDir":true,"title":"title","album":"album","artist":"artist","track":1,"year":1985,"genre":"Rock","coverArt":"1","size":8421341,"contentType":"audio/flac","suffix":"flac","starred":"2016-03-02T20:30:00Z","transcodedContentType":"audio/mpeg","transcodedSuffix":"mp3","duration":146,"bitRate":320,"isVideo":false}],"id":"1","name":"N"}} diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML index b626fd6ea..1ce29024f 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML @@ -1,37 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <directory id="1" name="N"> - <child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" bpm="127" comment="a comment" sortName="sorted title" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist 1 & artist 2" displayAlbumArtist="album artist 1 & album artist 2" displayComposer="composer 1 & composer 2" explicitStatus="clean"> - <isrc>ISRC-1</isrc> - <isrc>ISRC-2</isrc> - <genres name="rock"></genres> - <genres name="progressive"></genres> - <replayGain trackGain="1" albumGain="2" trackPeak="3" albumPeak="4" baseGain="5" fallbackGain="6"></replayGain> - <moods>happy</moods> - <moods>sad</moods> - <artists id="1" name="artist1"></artists> - <artists id="2" name="artist2"></artists> - <albumArtists id="1" name="album artist1"></albumArtists> - <albumArtists id="2" name="album artist2"></albumArtists> - <contributors role="role1" subRole="subrole3"> - <artist id="1" name="artist1"></artist> - </contributors> - <contributors role="role2"> - <artist id="2" name="artist2"></artist> - </contributors> - <contributors role="composer"> - <artist id="3" name="composer1"></artist> - </contributors> - <contributors role="composer"> - <artist id="4" name="composer2"></artist> - </contributors> - <groupings>Soundtrack</groupings> - <groupings>Live</groupings> - <works name="Symphony No. 5" musicBrainzId="mbz-work-1"></works> - <works name="Encore"></works> - <movements name="I. Allegro" number="1" count="4"></movements> - </child> - <child id="" isDir="false" title=""> - <replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain> - </child> - </directory> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><directory id="1" name="N"><child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false"></child></directory></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match .JSON b/server/subsonic/responses/.snapshots/Responses Child without data should match .JSON deleted file mode 100644 index b66a2bdea..000000000 --- a/server/subsonic/responses/.snapshots/Responses Child without data should match .JSON +++ /dev/null @@ -1,18 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "directory": { - "child": [ - { - "id": "1", - "isDir": false, - "title": "" - } - ], - "id": "", - "name": "" - } -} diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match .XML b/server/subsonic/responses/.snapshots/Responses Child without data should match .XML deleted file mode 100644 index d64d526d6..000000000 --- a/server/subsonic/responses/.snapshots/Responses Child without data should match .XML +++ /dev/null @@ -1,5 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <directory id="" name=""> - <child id="1" isDir="false" title=""></child> - </directory> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON deleted file mode 100644 index ea23dc5d6..000000000 --- a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON +++ /dev/null @@ -1,40 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "directory": { - "child": [ - { - "id": "1", - "isDir": false, - "title": "", - "bpm": 0, - "comment": "", - "sortName": "", - "mediaType": "", - "musicBrainzId": "", - "isrc": [], - "genres": [], - "replayGain": {}, - "channelCount": 0, - "samplingRate": 0, - "bitDepth": 0, - "moods": [], - "artists": [], - "displayArtist": "", - "albumArtists": [], - "displayAlbumArtist": "", - "contributors": [], - "displayComposer": "", - "explicitStatus": "", - "groupings": [], - "works": [], - "movements": [] - } - ], - "id": "", - "name": "" - } -} diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .XML b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .XML deleted file mode 100644 index d64d526d6..000000000 --- a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .XML +++ /dev/null @@ -1,5 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <directory id="" name=""> - <child id="1" isDir="false" title=""></child> - </directory> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Directory with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Directory with data should match .JSON index c984c70dc..7b3f7de85 100644 --- a/server/subsonic/responses/.snapshots/Responses Directory with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Directory with data should match .JSON @@ -1,18 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "directory": { - "child": [ - { - "id": "1", - "isDir": false, - "title": "title" - } - ], - "id": "1", - "name": "N" - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"directory":{"child":[{"id":"1","isDir":false,"title":"title","isVideo":false}],"id":"1","name":"N"}} diff --git a/server/subsonic/responses/.snapshots/Responses Directory with data should match .XML b/server/subsonic/responses/.snapshots/Responses Directory with data should match .XML index 0b1191baf..b11f3fce6 100644 --- a/server/subsonic/responses/.snapshots/Responses Directory with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Directory with data should match .XML @@ -1,5 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <directory id="1" name="N"> - <child id="1" isDir="false" title="title"></child> - </directory> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><directory id="1" name="N"><child id="1" isDir="false" title="title" isVideo="false"></child></directory></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Directory without data should match .JSON b/server/subsonic/responses/.snapshots/Responses Directory without data should match .JSON index c76abb908..e66f5ac2f 100644 --- a/server/subsonic/responses/.snapshots/Responses Directory without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Directory without data should match .JSON @@ -1,11 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "directory": { - "id": "1", - "name": "N" - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"directory":{"id":"1","name":"N"}} diff --git a/server/subsonic/responses/.snapshots/Responses Directory without data should match .XML b/server/subsonic/responses/.snapshots/Responses Directory without data should match .XML index 1c1f1d2ad..cf5cea641 100644 --- a/server/subsonic/responses/.snapshots/Responses Directory without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Directory without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <directory id="1" name="N"></directory> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><directory id="1" name="N"></directory></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses EmptyResponse should match .JSON b/server/subsonic/responses/.snapshots/Responses EmptyResponse should match .JSON index d53ba841f..3150c9a63 100644 --- a/server/subsonic/responses/.snapshots/Responses EmptyResponse should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses EmptyResponse should match .JSON @@ -1,7 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true} diff --git a/server/subsonic/responses/.snapshots/Responses EmptyResponse should match .XML b/server/subsonic/responses/.snapshots/Responses EmptyResponse should match .XML index 184228a0e..651d6df0d 100644 --- a/server/subsonic/responses/.snapshots/Responses EmptyResponse should match .XML +++ b/server/subsonic/responses/.snapshots/Responses EmptyResponse should match .XML @@ -1 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"></subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Genres with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Genres with data should match .JSON index 90d86535a..a81a9666e 100644 --- a/server/subsonic/responses/.snapshots/Responses Genres with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Genres with data should match .JSON @@ -1,26 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "genres": { - "genre": [ - { - "value": "Rock", - "songCount": 1000, - "albumCount": 100 - }, - { - "value": "Reggae", - "songCount": 500, - "albumCount": 50 - }, - { - "value": "Pop", - "songCount": 0, - "albumCount": 0 - } - ] - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"genres":{"genre":[{"value":"Rock","songCount":1000,"albumCount":100},{"value":"Reggae","songCount":500,"albumCount":50},{"value":"Pop","songCount":0,"albumCount":0}]}} diff --git a/server/subsonic/responses/.snapshots/Responses Genres with data should match .XML b/server/subsonic/responses/.snapshots/Responses Genres with data should match .XML index 75497c403..cc69c08ae 100644 --- a/server/subsonic/responses/.snapshots/Responses Genres with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Genres with data should match .XML @@ -1,7 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <genres> - <genre songCount="1000" albumCount="100">Rock</genre> - <genre songCount="500" albumCount="50">Reggae</genre> - <genre songCount="0" albumCount="0">Pop</genre> - </genres> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><genres><genre songCount="1000" albumCount="100">Rock</genre><genre songCount="500" albumCount="50">Reggae</genre><genre songCount="0" albumCount="0">Pop</genre></genres></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Genres without data should match .JSON b/server/subsonic/responses/.snapshots/Responses Genres without data should match .JSON index 0e473a617..80049cd50 100644 --- a/server/subsonic/responses/.snapshots/Responses Genres without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Genres without data should match .JSON @@ -1,8 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "genres": {} -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"genres":{}} diff --git a/server/subsonic/responses/.snapshots/Responses Genres without data should match .XML b/server/subsonic/responses/.snapshots/Responses Genres without data should match .XML index 4f4217d43..48a18fa5b 100644 --- a/server/subsonic/responses/.snapshots/Responses Genres without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Genres without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <genres></genres> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><genres></genres></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Indexes with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Indexes with data should match .JSON index 9704eab58..3ae846832 100644 --- a/server/subsonic/responses/.snapshots/Responses Indexes with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Indexes with data should match .JSON @@ -1,25 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "indexes": { - "index": [ - { - "name": "A", - "artist": [ - { - "id": "111", - "name": "aaa", - "starred": "2016-03-02T20:30:00Z", - "userRating": 3, - "artistImageUrl": "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png" - } - ] - } - ], - "lastModified": 1, - "ignoredArticles": "A" - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"indexes":{"index":[{"name":"A","artist":[{"id":"111","name":"aaa","albumCount":2,"starred":"2016-03-02T20:30:00Z","userRating":3,"artistImageUrl":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"}]}],"lastModified":1,"ignoredArticles":"A"}} diff --git a/server/subsonic/responses/.snapshots/Responses Indexes with data should match .XML b/server/subsonic/responses/.snapshots/Responses Indexes with data should match .XML index 6fc70b498..92836929f 100644 --- a/server/subsonic/responses/.snapshots/Responses Indexes with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Indexes with data should match .XML @@ -1,7 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <indexes lastModified="1" ignoredArticles="A"> - <index name="A"> - <artist id="111" name="aaa" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"></artist> - </index> - </indexes> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><indexes lastModified="1" ignoredArticles="A"><index name="A"><artist id="111" name="aaa" albumCount="2" starred="2016-03-02T20:30:00Z" userRating="3" artistImageUrl="https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png"></artist></index></indexes></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Indexes without data should match .JSON b/server/subsonic/responses/.snapshots/Responses Indexes without data should match .JSON index e267fcc01..c5c0607d8 100644 --- a/server/subsonic/responses/.snapshots/Responses Indexes without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Indexes without data should match .JSON @@ -1,11 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "indexes": { - "lastModified": 1, - "ignoredArticles": "A" - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"indexes":{"lastModified":1,"ignoredArticles":"A"}} diff --git a/server/subsonic/responses/.snapshots/Responses Indexes without data should match .XML b/server/subsonic/responses/.snapshots/Responses Indexes without data should match .XML index f433b62bc..b4cdef1dd 100644 --- a/server/subsonic/responses/.snapshots/Responses Indexes without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Indexes without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <indexes lastModified="1" ignoredArticles="A"></indexes> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><indexes lastModified="1" ignoredArticles="A"></indexes></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .JSON b/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .JSON index 5762011ae..eb1a8dc41 100644 --- a/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .JSON @@ -1,17 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "internetRadioStations": { - "internetRadioStation": [ - { - "id": "12345678", - "name": "Example Stream", - "streamUrl": "https://example.com/stream", - "homePageUrl": "https://example.com" - } - ] - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"internetRadioStations":{"internetRadioStation":[{"id":"12345678","name":"Example Stream","streamUrl":"https://example.com/stream","homePageUrl":"https://example.com"}]}} diff --git a/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .XML b/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .XML index 24cd687c5..a0794eeb1 100644 --- a/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .XML @@ -1,5 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <internetRadioStations> - <internetRadioStation id="12345678" name="Example Stream" streamUrl="https://example.com/stream" homePageUrl="https://example.com"></internetRadioStation> - </internetRadioStations> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><internetRadioStations><internetRadioStation id="12345678" name="Example Stream" streamUrl="https://example.com/stream" homePageUrl="https://example.com"></internetRadioStation></internetRadioStations></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .JSON b/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .JSON index 30d81f29d..a5dfc5e18 100644 --- a/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .JSON @@ -1,8 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "internetRadioStations": {} -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"internetRadioStations":{}} diff --git a/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .XML b/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .XML index ba81e4215..d670c3170 100644 --- a/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <internetRadioStations></internetRadioStations> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><internetRadioStations></internetRadioStations></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses License should match .JSON b/server/subsonic/responses/.snapshots/Responses License should match .JSON index 00f3ab7cb..f815a2098 100644 --- a/server/subsonic/responses/.snapshots/Responses License should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses License should match .JSON @@ -1,10 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "license": { - "valid": true - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"license":{"valid":true}} diff --git a/server/subsonic/responses/.snapshots/Responses License should match .XML b/server/subsonic/responses/.snapshots/Responses License should match .XML index f892e6f95..e5a90c467 100644 --- a/server/subsonic/responses/.snapshots/Responses License should match .XML +++ b/server/subsonic/responses/.snapshots/Responses License should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <license valid="true"></license> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><license valid="true"></license></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Lyrics with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Lyrics with data should match .JSON index e2c2b4dbf..204a358ff 100644 --- a/server/subsonic/responses/.snapshots/Responses Lyrics with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Lyrics with data should match .JSON @@ -1,12 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "lyrics": { - "artist": "Rick Astley", - "title": "Never Gonna Give You Up", - "value": "Never gonna give you up\n\t\t\t\tNever gonna let you down\n\t\t\t\tNever gonna run around and desert you\n\t\t\t\tNever gonna say goodbye" - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"lyrics":{"artist":"Rick Astley","title":"Never Gonna Give You Up","value":"Never gonna give you up\n\t\t\t\tNever gonna let you down\n\t\t\t\tNever gonna run around and desert you\n\t\t\t\tNever gonna say goodbye"}} diff --git a/server/subsonic/responses/.snapshots/Responses Lyrics with data should match .XML b/server/subsonic/responses/.snapshots/Responses Lyrics with data should match .XML index 52c0ff39b..c7e6f82d7 100644 --- a/server/subsonic/responses/.snapshots/Responses Lyrics with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Lyrics with data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <lyrics artist="Rick Astley" title="Never Gonna Give You Up">Never gonna give you up Never gonna let you down Never gonna run around and desert you Never gonna say goodbye</lyrics> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><lyrics artist="Rick Astley" title="Never Gonna Give You Up">Never gonna give you up Never gonna let you down Never gonna run around and desert you Never gonna say goodbye</lyrics></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Lyrics without data should match .JSON b/server/subsonic/responses/.snapshots/Responses Lyrics without data should match .JSON index d6d40298a..10f5c211d 100644 --- a/server/subsonic/responses/.snapshots/Responses Lyrics without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Lyrics without data should match .JSON @@ -1,10 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "lyrics": { - "value": "" - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"lyrics":{"value":""}} diff --git a/server/subsonic/responses/.snapshots/Responses Lyrics without data should match .XML b/server/subsonic/responses/.snapshots/Responses Lyrics without data should match .XML index d7fcb284e..9abc77e1f 100644 --- a/server/subsonic/responses/.snapshots/Responses Lyrics without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Lyrics without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <lyrics></lyrics> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><lyrics></lyrics></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses LyricsList with data should match .JSON b/server/subsonic/responses/.snapshots/Responses LyricsList with data should match .JSON deleted file mode 100644 index e027d62e6..000000000 --- a/server/subsonic/responses/.snapshots/Responses LyricsList with data should match .JSON +++ /dev/null @@ -1,43 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "lyricsList": { - "structuredLyrics": [ - { - "displayArtist": "Rick Astley", - "displayTitle": "Never Gonna Give You Up", - "lang": "eng", - "line": [ - { - "start": 18800, - "value": "We're no strangers to love" - }, - { - "start": 22801, - "value": "You know the rules and so do I" - } - ], - "offset": 100, - "synced": true - }, - { - "displayArtist": "Rick Astley", - "displayTitle": "Never Gonna Give You Up", - "lang": "xxx", - "line": [ - { - "value": "We're no strangers to love" - }, - { - "value": "You know the rules and so do I" - } - ], - "offset": 100, - "synced": false - } - ] - } -} diff --git a/server/subsonic/responses/.snapshots/Responses LyricsList with data should match .XML b/server/subsonic/responses/.snapshots/Responses LyricsList with data should match .XML deleted file mode 100644 index 0f1c6c565..000000000 --- a/server/subsonic/responses/.snapshots/Responses LyricsList with data should match .XML +++ /dev/null @@ -1,12 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <lyricsList> - <structuredLyrics displayArtist="Rick Astley" displayTitle="Never Gonna Give You Up" lang="eng" offset="100" synced="true"> - <line start="18800">We're no strangers to love</line> - <line start="22801">You know the rules and so do I</line> - </structuredLyrics> - <structuredLyrics displayArtist="Rick Astley" displayTitle="Never Gonna Give You Up" lang="xxx" offset="100" synced="false"> - <line>We're no strangers to love</line> - <line>You know the rules and so do I</line> - </structuredLyrics> - </lyricsList> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses LyricsList without data should match .JSON b/server/subsonic/responses/.snapshots/Responses LyricsList without data should match .JSON deleted file mode 100644 index c552df1b0..000000000 --- a/server/subsonic/responses/.snapshots/Responses LyricsList without data should match .JSON +++ /dev/null @@ -1,8 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "lyricsList": {} -} diff --git a/server/subsonic/responses/.snapshots/Responses LyricsList without data should match .XML b/server/subsonic/responses/.snapshots/Responses LyricsList without data should match .XML deleted file mode 100644 index 3cc86c32a..000000000 --- a/server/subsonic/responses/.snapshots/Responses LyricsList without data should match .XML +++ /dev/null @@ -1,3 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <lyricsList></lyricsList> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses MusicFolders with data should match .JSON b/server/subsonic/responses/.snapshots/Responses MusicFolders with data should match .JSON index 84555b7a2..fb79b75c1 100644 --- a/server/subsonic/responses/.snapshots/Responses MusicFolders with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses MusicFolders with data should match .JSON @@ -1,19 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "musicFolders": { - "musicFolder": [ - { - "id": 111, - "name": "aaa" - }, - { - "id": 222, - "name": "bbb" - } - ] - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"musicFolders":{"musicFolder":[{"id":111,"name":"aaa"},{"id":222,"name":"bbb"}]}} diff --git a/server/subsonic/responses/.snapshots/Responses MusicFolders with data should match .XML b/server/subsonic/responses/.snapshots/Responses MusicFolders with data should match .XML index a9517ea2f..da940c3ff 100644 --- a/server/subsonic/responses/.snapshots/Responses MusicFolders with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses MusicFolders with data should match .XML @@ -1,6 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <musicFolders> - <musicFolder id="111" name="aaa"></musicFolder> - <musicFolder id="222" name="bbb"></musicFolder> - </musicFolders> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><musicFolders><musicFolder id="111" name="aaa"></musicFolder><musicFolder id="222" name="bbb"></musicFolder></musicFolders></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses MusicFolders without data should match .JSON b/server/subsonic/responses/.snapshots/Responses MusicFolders without data should match .JSON index 5c0fb8be8..ee512ae47 100644 --- a/server/subsonic/responses/.snapshots/Responses MusicFolders without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses MusicFolders without data should match .JSON @@ -1,8 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "musicFolders": {} -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"musicFolders":{}} diff --git a/server/subsonic/responses/.snapshots/Responses MusicFolders without data should match .XML b/server/subsonic/responses/.snapshots/Responses MusicFolders without data should match .XML index 5237139a6..63848d75d 100644 --- a/server/subsonic/responses/.snapshots/Responses MusicFolders without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses MusicFolders without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <musicFolders></musicFolders> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><musicFolders></musicFolders></subsonic-response> 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 deleted file mode 100644 index f99cb62af..000000000 --- a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 deleted file mode 100644 index ae05b68d0..000000000 --- a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML +++ /dev/null @@ -1,5 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" 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"></entry> - </nowPlaying> -</subsonic-response> 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 deleted file mode 100644 index ab8f793fa..000000000 --- a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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 deleted file mode 100644 index 763b4b583..000000000 --- a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML +++ /dev/null @@ -1,3 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <nowPlaying></nowPlaying> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions with data should match .JSON b/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions with data should match .JSON deleted file mode 100644 index d3972e7ba..000000000 --- a/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions with data should match .JSON +++ /dev/null @@ -1,16 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "openSubsonicExtensions": [ - { - "name": "template", - "versions": [ - 1, - 2 - ] - } - ] -} diff --git a/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions with data should match .XML b/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions with data should match .XML deleted file mode 100644 index adcb0086b..000000000 --- a/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions with data should match .XML +++ /dev/null @@ -1,6 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <openSubsonicExtensions name="template"> - <versions>1</versions> - <versions>2</versions> - </openSubsonicExtensions> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions without data should match .JSON b/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions without data should match .JSON deleted file mode 100644 index b81ecd039..000000000 --- a/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions without data should match .JSON +++ /dev/null @@ -1,8 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "openSubsonicExtensions": [] -} diff --git a/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions without data should match .XML b/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions without data should match .XML deleted file mode 100644 index 184228a0e..000000000 --- a/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions without data should match .XML +++ /dev/null @@ -1 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueue with data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueue with data should match .JSON index e5875873d..2603112b5 100644 --- a/server/subsonic/responses/.snapshots/Responses PlayQueue with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses PlayQueue with data should match .JSON @@ -1,21 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "playQueue": { - "entry": [ - { - "id": "1", - "isDir": false, - "title": "title" - } - ], - "current": "111", - "position": 243, - "username": "user1", - "changed": "0001-01-01T00:00:00Z", - "changedBy": "a_client" - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"playQueue":{"entry":[{"id":"1","isDir":false,"title":"title","isVideo":false}],"current":"111","position":243,"username":"user1","changed":"0001-01-01T00:00:00Z","changedBy":"a_client"}} diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueue with data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueue with data should match .XML index b7a054b8c..d96030df5 100644 --- a/server/subsonic/responses/.snapshots/Responses PlayQueue with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses PlayQueue with data should match .XML @@ -1,5 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <playQueue current="111" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client"> - <entry id="1" isDir="false" title="title"></entry> - </playQueue> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><playQueue current="111" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client"><entry id="1" isDir="false" title="title" isVideo="false"></entry></playQueue></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON index 70b10c059..d13e3ae8c 100644 --- a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON @@ -1,12 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "playQueue": { - "username": "", - "changed": "0001-01-01T00:00:00Z", - "changedBy": "" - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"playQueue":{"username":"","changedBy":""}} diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML index 597781cbd..b2a09b360 100644 --- a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <playQueue username="" changed="0001-01-01T00:00:00Z" changedBy=""></playQueue> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><playQueue username="" changedBy=""></playQueue></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON deleted file mode 100644 index 3fa5b6082..000000000 --- a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON +++ /dev/null @@ -1,21 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "playQueueByIndex": { - "entry": [ - { - "id": "1", - "isDir": false, - "title": "title" - } - ], - "currentIndex": 0, - "position": 243, - "username": "user1", - "changed": "0001-01-01T00:00:00Z", - "changedBy": "a_client" - } -} diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML deleted file mode 100644 index 20f4994da..000000000 --- a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML +++ /dev/null @@ -1,5 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <playQueueByIndex currentIndex="0" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client"> - <entry id="1" isDir="false" title="title"></entry> - </playQueueByIndex> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON deleted file mode 100644 index ad49a35e5..000000000 --- a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON +++ /dev/null @@ -1,12 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "playQueueByIndex": { - "username": "", - "changed": "0001-01-01T00:00:00Z", - "changedBy": "" - } -} diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML deleted file mode 100644 index d99681f4c..000000000 --- a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML +++ /dev/null @@ -1,3 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <playQueueByIndex username="" changed="0001-01-01T00:00:00Z" changedBy=""></playQueueByIndex> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .JSON index 963a207d5..9f1f57e01 100644 --- a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .JSON @@ -1,42 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "playlists": { - "playlist": [ - { - "id": "111", - "name": "aaa", - "comment": "comment", - "songCount": 2, - "duration": 120, - "public": true, - "owner": "admin", - "created": "2023-02-20T14:45:00Z", - "changed": "2023-02-20T14:45:00Z", - "coverArt": "pl-123123123123", - "readonly": true, - "validUntil": "2023-02-20T14:45:00Z" - }, - { - "id": "333", - "name": "ccc", - "songCount": 0, - "duration": 0, - "created": "0001-01-01T00:00:00Z", - "changed": "0001-01-01T00:00:00Z", - "readonly": false - }, - { - "id": "222", - "name": "bbb", - "songCount": 0, - "duration": 0, - "created": "0001-01-01T00:00:00Z", - "changed": "0001-01-01T00:00:00Z" - } - ] - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"playlists":{"playlist":[{"id":"111","name":"aaa","comment":"comment","songCount":2,"duration":120,"public":true,"owner":"admin","created":"0001-01-01T00:00:00Z","changed":"0001-01-01T00:00:00Z","coverArt":"pl-123123123123"},{"id":"222","name":"bbb","songCount":0,"duration":0,"public":false,"created":"0001-01-01T00:00:00Z","changed":"0001-01-01T00:00:00Z"}]}} diff --git a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML index 38e0944cf..cd6d1ccac 100644 --- a/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML @@ -1,7 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <playlists> - <playlist id="111" name="aaa" comment="comment" songCount="2" duration="120" public="true" owner="admin" created="2023-02-20T14:45:00Z" changed="2023-02-20T14:45:00Z" coverArt="pl-123123123123" readonly="true" validUntil="2023-02-20T14:45:00Z"></playlist> - <playlist id="333" name="ccc" songCount="0" duration="0" public="false" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist> - <playlist id="222" name="bbb" songCount="0" duration="0" public="false" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist> - </playlists> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><playlists><playlist id="111" name="aaa" comment="comment" songCount="2" duration="120" public="true" owner="admin" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z" coverArt="pl-123123123123"></playlist><playlist id="222" name="bbb" songCount="0" duration="0" public="false" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist></playlists></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Playlists without data should match .JSON b/server/subsonic/responses/.snapshots/Responses Playlists without data should match .JSON index c4510a7eb..cd9ece060 100644 --- a/server/subsonic/responses/.snapshots/Responses Playlists without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Playlists without data should match .JSON @@ -1,8 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "playlists": {} -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"playlists":{}} diff --git a/server/subsonic/responses/.snapshots/Responses Playlists without data should match .XML b/server/subsonic/responses/.snapshots/Responses Playlists without data should match .XML index acdb6732e..90bf400ed 100644 --- a/server/subsonic/responses/.snapshots/Responses Playlists without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Playlists without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <playlists></playlists> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><playlists></playlists></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses ScanStatus with data should match .JSON b/server/subsonic/responses/.snapshots/Responses ScanStatus with data should match .JSON index af26f09e6..8e8b0e1bf 100644 --- a/server/subsonic/responses/.snapshots/Responses ScanStatus with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses ScanStatus with data should match .JSON @@ -1,13 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "scanStatus": { - "scanning": true, - "count": 456, - "folderCount": 123, - "lastScan": "2006-01-02T15:04:00Z" - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"scanStatus":{"scanning":true,"count":456,"folderCount":123,"lastScan":"2006-01-02T15:04:00Z"}} diff --git a/server/subsonic/responses/.snapshots/Responses ScanStatus with data should match .XML b/server/subsonic/responses/.snapshots/Responses ScanStatus with data should match .XML index 6ce0dac7b..69c2e9c05 100644 --- a/server/subsonic/responses/.snapshots/Responses ScanStatus with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses ScanStatus with data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <scanStatus scanning="true" count="456" folderCount="123" lastScan="2006-01-02T15:04:00Z"></scanStatus> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><scanStatus scanning="true" count="456" folderCount="123" lastScan="2006-01-02T15:04:00Z"></scanStatus></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses ScanStatus without data should match .JSON b/server/subsonic/responses/.snapshots/Responses ScanStatus without data should match .JSON index fed45c51c..4b65e3fee 100644 --- a/server/subsonic/responses/.snapshots/Responses ScanStatus without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses ScanStatus without data should match .JSON @@ -1,12 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "scanStatus": { - "scanning": false, - "count": 0, - "folderCount": 0 - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"scanStatus":{"scanning":false,"count":0,"folderCount":0}} diff --git a/server/subsonic/responses/.snapshots/Responses ScanStatus without data should match .XML b/server/subsonic/responses/.snapshots/Responses ScanStatus without data should match .XML index 8e622d813..9d3b38d19 100644 --- a/server/subsonic/responses/.snapshots/Responses ScanStatus without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses ScanStatus without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <scanStatus scanning="false" count="0" folderCount="0"></scanStatus> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><scanStatus scanning="false" count="0" folderCount="0"></scanStatus></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Shares with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Shares with data should match .JSON index cca38ba52..e35b2729b 100644 --- a/server/subsonic/responses/.snapshots/Responses Shares with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Shares with data should match .JSON @@ -1,39 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "shares": { - "share": [ - { - "entry": [ - { - "id": "1", - "isDir": false, - "title": "title", - "album": "album", - "artist": "artist", - "duration": 120 - }, - { - "id": "2", - "isDir": false, - "title": "title 2", - "album": "album", - "artist": "artist", - "duration": 300 - } - ], - "id": "ABC123", - "url": "http://localhost/p/ABC123", - "description": "Check it out!", - "username": "deluan", - "created": "2016-03-02T20:30:00Z", - "expires": "2016-03-02T20:30:00Z", - "lastVisited": "2016-03-02T20:30:00Z", - "visitCount": 2 - } - ] - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"shares":{"share":[{"entry":[{"id":"1","isDir":false,"title":"title","album":"album","artist":"artist","duration":120,"isVideo":false},{"id":"2","isDir":false,"title":"title 2","album":"album","artist":"artist","duration":300,"isVideo":false}],"id":"ABC123","url":"http://localhost/p/ABC123","description":"Check it out!","username":"deluan","created":"0001-01-01T00:00:00Z","expires":"0001-01-01T00:00:00Z","lastVisited":"0001-01-01T00:00:00Z","visitCount":2}]}} diff --git a/server/subsonic/responses/.snapshots/Responses Shares with data should match .XML b/server/subsonic/responses/.snapshots/Responses Shares with data should match .XML index ba63071bf..4fa5cda16 100644 --- a/server/subsonic/responses/.snapshots/Responses Shares with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Shares with data should match .XML @@ -1,8 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <shares> - <share id="ABC123" url="http://localhost/p/ABC123" description="Check it out!" username="deluan" created="2016-03-02T20:30:00Z" expires="2016-03-02T20:30:00Z" lastVisited="2016-03-02T20:30:00Z" visitCount="2"> - <entry id="1" isDir="false" title="title" album="album" artist="artist" duration="120"></entry> - <entry id="2" isDir="false" title="title 2" album="album" artist="artist" duration="300"></entry> - </share> - </shares> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><shares><share id="ABC123" url="http://localhost/p/ABC123" description="Check it out!" username="deluan" created="0001-01-01T00:00:00Z" expires="0001-01-01T00:00:00Z" lastVisited="0001-01-01T00:00:00Z" visitCount="2"><entry id="1" isDir="false" title="title" album="album" artist="artist" duration="120" isVideo="false"></entry><entry id="2" isDir="false" title="title 2" album="album" artist="artist" duration="300" isVideo="false"></entry></share></shares></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Shares with only required fields should match .JSON b/server/subsonic/responses/.snapshots/Responses Shares with only required fields should match .JSON deleted file mode 100644 index 2856ac7f6..000000000 --- a/server/subsonic/responses/.snapshots/Responses Shares with only required fields should match .JSON +++ /dev/null @@ -1,18 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "shares": { - "share": [ - { - "id": "ABC123", - "url": "http://localhost/s/ABC123", - "username": "johndoe", - "created": "2016-03-02T20:30:00Z", - "visitCount": 1 - } - ] - } -} diff --git a/server/subsonic/responses/.snapshots/Responses Shares with only required fields should match .XML b/server/subsonic/responses/.snapshots/Responses Shares with only required fields should match .XML deleted file mode 100644 index 12e8f6bea..000000000 --- a/server/subsonic/responses/.snapshots/Responses Shares with only required fields should match .XML +++ /dev/null @@ -1,5 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <shares> - <share id="ABC123" url="http://localhost/s/ABC123" username="johndoe" created="2016-03-02T20:30:00Z" visitCount="1"></share> - </shares> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Shares without data should match .JSON b/server/subsonic/responses/.snapshots/Responses Shares without data should match .JSON index d05e1407e..bf12c71ed 100644 --- a/server/subsonic/responses/.snapshots/Responses Shares without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Shares without data should match .JSON @@ -1,8 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "shares": {} -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"shares":{}} diff --git a/server/subsonic/responses/.snapshots/Responses Shares without data should match .XML b/server/subsonic/responses/.snapshots/Responses Shares without data should match .XML index 9217c7850..6b8aec3fd 100644 --- a/server/subsonic/responses/.snapshots/Responses Shares without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Shares without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <shares></shares> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><shares></shares></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses SimilarSongs with data should match .JSON b/server/subsonic/responses/.snapshots/Responses SimilarSongs with data should match .JSON index ff30c1a25..1a09565e5 100644 --- a/server/subsonic/responses/.snapshots/Responses SimilarSongs with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs with data should match .JSON @@ -1,16 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "similarSongs": { - "song": [ - { - "id": "1", - "isDir": false, - "title": "title" - } - ] - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"similarSongs":{"song":[{"id":"1","isDir":false,"title":"title","isVideo":false}]}} diff --git a/server/subsonic/responses/.snapshots/Responses SimilarSongs with data should match .XML b/server/subsonic/responses/.snapshots/Responses SimilarSongs with data should match .XML index 06f07a3bb..f0a2e0163 100644 --- a/server/subsonic/responses/.snapshots/Responses SimilarSongs with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs with data should match .XML @@ -1,5 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <similarSongs> - <song id="1" isDir="false" title="title"></song> - </similarSongs> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><similarSongs><song id="1" isDir="false" title="title" isVideo="false"></song></similarSongs></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses SimilarSongs without data should match .JSON b/server/subsonic/responses/.snapshots/Responses SimilarSongs without data should match .JSON index 2436e38cf..642ee62b5 100644 --- a/server/subsonic/responses/.snapshots/Responses SimilarSongs without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs without data should match .JSON @@ -1,8 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "similarSongs": {} -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"similarSongs":{}} diff --git a/server/subsonic/responses/.snapshots/Responses SimilarSongs without data should match .XML b/server/subsonic/responses/.snapshots/Responses SimilarSongs without data should match .XML index c3e020af0..ff1ad253d 100644 --- a/server/subsonic/responses/.snapshots/Responses SimilarSongs without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <similarSongs></similarSongs> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><similarSongs></similarSongs></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses SimilarSongs2 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses SimilarSongs2 with data should match .JSON index 49331cf11..2236dc35b 100644 --- a/server/subsonic/responses/.snapshots/Responses SimilarSongs2 with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs2 with data should match .JSON @@ -1,16 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "similarSongs2": { - "song": [ - { - "id": "1", - "isDir": false, - "title": "title" - } - ] - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"similarSongs2":{"song":[{"id":"1","isDir":false,"title":"title","isVideo":false}]}} diff --git a/server/subsonic/responses/.snapshots/Responses SimilarSongs2 with data should match .XML b/server/subsonic/responses/.snapshots/Responses SimilarSongs2 with data should match .XML index 4e80da12f..090a59d73 100644 --- a/server/subsonic/responses/.snapshots/Responses SimilarSongs2 with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs2 with data should match .XML @@ -1,5 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <similarSongs2> - <song id="1" isDir="false" title="title"></song> - </similarSongs2> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><similarSongs2><song id="1" isDir="false" title="title" isVideo="false"></song></similarSongs2></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses SimilarSongs2 without data should match .JSON b/server/subsonic/responses/.snapshots/Responses SimilarSongs2 without data should match .JSON index 1d86c944a..e7e220622 100644 --- a/server/subsonic/responses/.snapshots/Responses SimilarSongs2 without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs2 without data should match .JSON @@ -1,8 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "similarSongs2": {} -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"similarSongs2":{}} diff --git a/server/subsonic/responses/.snapshots/Responses SimilarSongs2 without data should match .XML b/server/subsonic/responses/.snapshots/Responses SimilarSongs2 without data should match .XML index aa301249e..0c07e9348 100644 --- a/server/subsonic/responses/.snapshots/Responses SimilarSongs2 without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs2 without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <similarSongs2></similarSongs2> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><similarSongs2></similarSongs2></subsonic-response> 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 deleted file mode 100644 index b22f18e83..000000000 --- a/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON +++ /dev/null @@ -1,25 +0,0 @@ -{ - "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 deleted file mode 100644 index 14d17d00d..000000000 --- a/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML +++ /dev/null @@ -1,10 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <sonicMatch> - <entry id="1" isDir="false" title="Bohemian Rhapsody"></entry> - <similarity>0.95</similarity> - </sonicMatch> - <sonicMatch> - <entry id="2" isDir="false" title="We Will Rock You"></entry> - <similarity>0.78</similarity> - </sonicMatch> -</subsonic-response> 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 deleted file mode 100644 index 75562871f..000000000 --- a/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON +++ /dev/null @@ -1,8 +0,0 @@ -{ - "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 deleted file mode 100644 index 184228a0e..000000000 --- a/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML +++ /dev/null @@ -1 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses TokenInfo should match .JSON b/server/subsonic/responses/.snapshots/Responses TokenInfo should match .JSON deleted file mode 100644 index f2e251f49..000000000 --- a/server/subsonic/responses/.snapshots/Responses TokenInfo should match .JSON +++ /dev/null @@ -1,10 +0,0 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "tokenInfo": { - "username": "deluan" - } -} diff --git a/server/subsonic/responses/.snapshots/Responses TokenInfo should match .XML b/server/subsonic/responses/.snapshots/Responses TokenInfo should match .XML deleted file mode 100644 index 7ea786bb9..000000000 --- a/server/subsonic/responses/.snapshots/Responses TokenInfo should match .XML +++ /dev/null @@ -1,3 +0,0 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <tokenInfo username="deluan"></tokenInfo> -</subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses TopSongs with data should match .JSON b/server/subsonic/responses/.snapshots/Responses TopSongs with data should match .JSON index 1c871a43f..889707ae2 100644 --- a/server/subsonic/responses/.snapshots/Responses TopSongs with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses TopSongs with data should match .JSON @@ -1,16 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "topSongs": { - "song": [ - { - "id": "1", - "isDir": false, - "title": "title" - } - ] - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"topSongs":{"song":[{"id":"1","isDir":false,"title":"title","isVideo":false}]}} diff --git a/server/subsonic/responses/.snapshots/Responses TopSongs with data should match .XML b/server/subsonic/responses/.snapshots/Responses TopSongs with data should match .XML index 8991725b8..bda5a0a60 100644 --- a/server/subsonic/responses/.snapshots/Responses TopSongs with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses TopSongs with data should match .XML @@ -1,5 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <topSongs> - <song id="1" isDir="false" title="title"></song> - </topSongs> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><topSongs><song id="1" isDir="false" title="title" isVideo="false"></song></topSongs></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses TopSongs without data should match .JSON b/server/subsonic/responses/.snapshots/Responses TopSongs without data should match .JSON index 68ef26569..dba6277cd 100644 --- a/server/subsonic/responses/.snapshots/Responses TopSongs without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses TopSongs without data should match .JSON @@ -1,8 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "topSongs": {} -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"topSongs":{}} diff --git a/server/subsonic/responses/.snapshots/Responses TopSongs without data should match .XML b/server/subsonic/responses/.snapshots/Responses TopSongs without data should match .XML index 74f5d1cb1..479cf867a 100644 --- a/server/subsonic/responses/.snapshots/Responses TopSongs without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses TopSongs without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <topSongs></topSongs> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><topSongs></topSongs></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses User with data should match .JSON b/server/subsonic/responses/.snapshots/Responses User with data should match .JSON index 94ca289a2..f54aba0d3 100644 --- a/server/subsonic/responses/.snapshots/Responses User with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses User with data should match .JSON @@ -1,27 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "user": { - "username": "deluan", - "email": "navidrome@deluan.com", - "scrobblingEnabled": false, - "adminRole": false, - "settingsRole": false, - "downloadRole": false, - "uploadRole": false, - "playlistRole": false, - "coverArtRole": false, - "commentRole": false, - "podcastRole": false, - "streamRole": false, - "jukeboxRole": false, - "shareRole": false, - "videoConversionRole": false, - "folder": [ - 1 - ] - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"user":{"username":"deluan","email":"navidrome@deluan.com","scrobblingEnabled":false,"adminRole":false,"settingsRole":false,"downloadRole":false,"uploadRole":false,"playlistRole":false,"coverArtRole":false,"commentRole":false,"podcastRole":false,"streamRole":false,"jukeboxRole":false,"shareRole":false,"videoConversionRole":false,"folder":[1]}} diff --git a/server/subsonic/responses/.snapshots/Responses User with data should match .XML b/server/subsonic/responses/.snapshots/Responses User with data should match .XML index 18fae22f3..cc107ad20 100644 --- a/server/subsonic/responses/.snapshots/Responses User with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses User with data should match .XML @@ -1,5 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <user username="deluan" email="navidrome@deluan.com" scrobblingEnabled="false" adminRole="false" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false"> - <folder>1</folder> - </user> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><user username="deluan" email="navidrome@deluan.com" scrobblingEnabled="false" adminRole="false" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false"><folder>1</folder></user></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses User without data should match .JSON b/server/subsonic/responses/.snapshots/Responses User without data should match .JSON index fb7881974..f904447a7 100644 --- a/server/subsonic/responses/.snapshots/Responses User without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses User without data should match .JSON @@ -1,23 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "user": { - "username": "deluan", - "scrobblingEnabled": false, - "adminRole": false, - "settingsRole": false, - "downloadRole": false, - "uploadRole": false, - "playlistRole": false, - "coverArtRole": false, - "commentRole": false, - "podcastRole": false, - "streamRole": false, - "jukeboxRole": false, - "shareRole": false, - "videoConversionRole": false - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"user":{"username":"deluan","scrobblingEnabled":false,"adminRole":false,"settingsRole":false,"downloadRole":false,"uploadRole":false,"playlistRole":false,"coverArtRole":false,"commentRole":false,"podcastRole":false,"streamRole":false,"jukeboxRole":false,"shareRole":false,"videoConversionRole":false}} diff --git a/server/subsonic/responses/.snapshots/Responses User without data should match .XML b/server/subsonic/responses/.snapshots/Responses User without data should match .XML index 16ebce7ba..094b1e333 100644 --- a/server/subsonic/responses/.snapshots/Responses User without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses User without data should match .XML @@ -1,3 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <user username="deluan" scrobblingEnabled="false" adminRole="false" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false"></user> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><user username="deluan" scrobblingEnabled="false" adminRole="false" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false"></user></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Users with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Users with data should match .JSON index 4688feb9e..eee65ff25 100644 --- a/server/subsonic/responses/.snapshots/Responses Users with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Users with data should match .JSON @@ -1,31 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "users": { - "user": [ - { - "username": "deluan", - "email": "navidrome@deluan.com", - "scrobblingEnabled": false, - "adminRole": true, - "settingsRole": false, - "downloadRole": false, - "uploadRole": false, - "playlistRole": false, - "coverArtRole": false, - "commentRole": false, - "podcastRole": false, - "streamRole": false, - "jukeboxRole": false, - "shareRole": false, - "videoConversionRole": false, - "folder": [ - 1 - ] - } - ] - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"users":{"user":[{"username":"deluan","email":"navidrome@deluan.com","scrobblingEnabled":false,"adminRole":true,"settingsRole":false,"downloadRole":false,"uploadRole":false,"playlistRole":false,"coverArtRole":false,"commentRole":false,"podcastRole":false,"streamRole":false,"jukeboxRole":false,"shareRole":false,"videoConversionRole":false,"folder":[1]}]}} diff --git a/server/subsonic/responses/.snapshots/Responses Users with data should match .XML b/server/subsonic/responses/.snapshots/Responses Users with data should match .XML index f40d32379..4c5ebbe9e 100644 --- a/server/subsonic/responses/.snapshots/Responses Users with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Users with data should match .XML @@ -1,7 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <users> - <user username="deluan" email="navidrome@deluan.com" scrobblingEnabled="false" adminRole="true" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false"> - <folder>1</folder> - </user> - </users> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><users><user username="deluan" email="navidrome@deluan.com" scrobblingEnabled="false" adminRole="true" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false"><folder>1</folder></user></users></subsonic-response> diff --git a/server/subsonic/responses/.snapshots/Responses Users without data should match .JSON b/server/subsonic/responses/.snapshots/Responses Users without data should match .JSON index 96b697300..601ea1d6e 100644 --- a/server/subsonic/responses/.snapshots/Responses Users without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Users without data should match .JSON @@ -1,27 +1 @@ -{ - "status": "ok", - "version": "1.16.1", - "type": "navidrome", - "serverVersion": "v0.55.0", - "openSubsonic": true, - "users": { - "user": [ - { - "username": "deluan", - "scrobblingEnabled": false, - "adminRole": false, - "settingsRole": false, - "downloadRole": false, - "uploadRole": false, - "playlistRole": false, - "coverArtRole": false, - "commentRole": false, - "podcastRole": false, - "streamRole": false, - "jukeboxRole": false, - "shareRole": false, - "videoConversionRole": false - } - ] - } -} +{"status":"ok","version":"1.8.0","type":"navidrome","serverVersion":"v0.0.0","openSubsonic":true,"users":{"user":[{"username":"deluan","scrobblingEnabled":false,"adminRole":false,"settingsRole":false,"downloadRole":false,"uploadRole":false,"playlistRole":false,"coverArtRole":false,"commentRole":false,"podcastRole":false,"streamRole":false,"jukeboxRole":false,"shareRole":false,"videoConversionRole":false}]}} diff --git a/server/subsonic/responses/.snapshots/Responses Users without data should match .XML b/server/subsonic/responses/.snapshots/Responses Users without data should match .XML index 3033ad9bc..fb7ce1161 100644 --- a/server/subsonic/responses/.snapshots/Responses Users without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Users without data should match .XML @@ -1,5 +1 @@ -<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> - <users> - <user username="deluan" scrobblingEnabled="false" adminRole="false" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false"></user> - </users> -</subsonic-response> +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.8.0" type="navidrome" serverVersion="v0.0.0" openSubsonic="true"><users><user username="deluan" scrobblingEnabled="false" adminRole="false" settingsRole="false" downloadRole="false" uploadRole="false" playlistRole="false" coverArtRole="false" commentRole="false" podcastRole="false" streamRole="false" jukeboxRole="false" shareRole="false" videoConversionRole="false"></user></users></subsonic-response> diff --git a/server/subsonic/responses/errors.go b/server/subsonic/responses/errors.go index 9c9dd10f6..c15ccd92c 100644 --- a/server/subsonic/responses/errors.go +++ b/server/subsonic/responses/errors.go @@ -1,32 +1,28 @@ package responses const ( - ErrorGeneric int32 = 0 - ErrorMissingParameter int32 = 10 - ErrorClientTooOld int32 = 20 - ErrorServerTooOld int32 = 30 - ErrorAuthenticationFail int32 = 40 - ErrorMultipleAuthMechanismsProvided int32 = 43 - ErrorInvalidAPIKey int32 = 44 - ErrorAuthorizationFail int32 = 50 - ErrorTrialExpired int32 = 60 - ErrorDataNotFound int32 = 70 + ErrorGeneric = 0 + ErrorMissingParameter = 10 + ErrorClientTooOld = 20 + ErrorServerTooOld = 30 + ErrorAuthenticationFail = 40 + ErrorAuthorizationFail = 50 + ErrorTrialExpired = 60 + ErrorDataNotFound = 70 ) -var errors = map[int32]string{ //nolint:gosec // G101 false positive: error messages, not credentials - ErrorGeneric: "A generic error", - ErrorMissingParameter: "Required parameter is missing", - ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade", - ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade", - ErrorAuthenticationFail: "Wrong username or password", - ErrorMultipleAuthMechanismsProvided: "Multiple conflicting authentication mechanisms provided", - ErrorInvalidAPIKey: "Invalid API key", - ErrorAuthorizationFail: "User is not authorized for the given operation", - ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details", - ErrorDataNotFound: "The requested data was not found", +var errors = map[int]string{ + ErrorGeneric: "A generic error", + ErrorMissingParameter: "Required parameter is missing", + ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade", + ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade", + ErrorAuthenticationFail: "Wrong username or password", + ErrorAuthorizationFail: "User is not authorized for the given operation", + ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details", + ErrorDataNotFound: "The requested data was not found", } -func ErrorMsg(code int32) string { +func ErrorMsg(code int) string { if v, found := errors[code]; found { return v } diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 51d0020b2..0df2c0ad8 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -1,7 +1,6 @@ package responses import ( - "encoding/json" "encoding/xml" "time" ) @@ -21,13 +20,13 @@ type Subsonic struct { User *User `xml:"user,omitempty" json:"user,omitempty"` Users *Users `xml:"users,omitempty" json:"users,omitempty"` AlbumList *AlbumList `xml:"albumList,omitempty" json:"albumList,omitempty"` - AlbumList2 *AlbumList2 `xml:"albumList2,omitempty" json:"albumList2,omitempty"` + AlbumList2 *AlbumList `xml:"albumList2,omitempty" json:"albumList2,omitempty"` Playlists *Playlists `xml:"playlists,omitempty" json:"playlists,omitempty"` Playlist *PlaylistWithSongs `xml:"playlist,omitempty" json:"playlist,omitempty"` SearchResult2 *SearchResult2 `xml:"searchResult2,omitempty" json:"searchResult2,omitempty"` SearchResult3 *SearchResult3 `xml:"searchResult3,omitempty" json:"searchResult3,omitempty"` Starred *Starred `xml:"starred,omitempty" json:"starred,omitempty"` - Starred2 *Starred2 `xml:"starred2,omitempty" json:"starred2,omitempty"` + Starred2 *Starred `xml:"starred2,omitempty" json:"starred2,omitempty"` NowPlaying *NowPlaying `xml:"nowPlaying,omitempty" json:"nowPlaying,omitempty"` Song *Child `xml:"song,omitempty" json:"song,omitempty"` RandomSongs *Songs `xml:"randomSongs,omitempty" json:"randomSongs,omitempty"` @@ -35,7 +34,7 @@ type Subsonic struct { Genres *Genres `xml:"genres,omitempty" json:"genres,omitempty"` // ID3 - Artist *Artists `xml:"artists,omitempty" json:"artists,omitempty"` + Artist *Indexes `xml:"artists,omitempty" json:"artists,omitempty"` ArtistWithAlbumsID3 *ArtistWithAlbumsID3 `xml:"artist,omitempty" json:"artist,omitempty"` AlbumWithSongsID3 *AlbumWithSongsID3 `xml:"album,omitempty" json:"album,omitempty"` @@ -52,25 +51,10 @@ type Subsonic struct { ScanStatus *ScanStatus `xml:"scanStatus,omitempty" json:"scanStatus,omitempty"` Lyrics *Lyrics `xml:"lyrics,omitempty" json:"lyrics,omitempty"` - InternetRadioStations *InternetRadioStations `xml:"internetRadioStations,omitempty" json:"internetRadioStations,omitempty"` - - JukeboxStatus *JukeboxStatus `xml:"jukeboxStatus,omitempty" json:"jukeboxStatus,omitempty"` - JukeboxPlaylist *JukeboxPlaylist `xml:"jukeboxPlaylist,omitempty" json:"jukeboxPlaylist,omitempty"` - - // OpenSubsonic extensions + InternetRadioStations *InternetRadioStations `xml:"internetRadioStations,omitempty" json:"internetRadioStations,omitempty"` OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"` - 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"` - TokenInfo *TokenInfo `xml:"tokenInfo,omitempty" json:"tokenInfo,omitempty"` } -const ( - StatusOK = "ok" - StatusFailed = "failed" -) - type JsonWrapper struct { Subsonic Subsonic `json:"subsonic-response"` } @@ -96,11 +80,14 @@ type MusicFolders struct { type Artist struct { Id string `xml:"id,attr" json:"id"` Name string `xml:"name,attr" json:"name"` + AlbumCount int32 `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"` Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` - AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"` CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"` + /* TODO: + <xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 --> + */ } type Index struct { @@ -114,30 +101,11 @@ type Indexes struct { IgnoredArticles string `xml:"ignoredArticles,attr" json:"ignoredArticles"` } -type IndexID3 struct { - Name string `xml:"name,attr" json:"name"` - Artists []ArtistID3 `xml:"artist" json:"artist"` -} - -type Artists struct { - Index []IndexID3 `xml:"index" json:"index,omitempty"` - LastModified int64 `xml:"lastModified,attr" json:"lastModified"` - IgnoredArticles string `xml:"ignoredArticles,attr" json:"ignoredArticles"` -} - -type MediaType string - -const ( - MediaTypeSong MediaType = "song" - MediaTypeAlbum MediaType = "album" - MediaTypeArtist MediaType = "artist" -) - type Child struct { Id string `xml:"id,attr" json:"id"` Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"` IsDir bool `xml:"isDir,attr" json:"isDir"` - Title string `xml:"title,attr" json:"title"` + Title string `xml:"title,attr,omitempty" json:"title,omitempty"` Name string `xml:"name,attr,omitempty" json:"name,omitempty"` Album string `xml:"album,attr,omitempty" json:"album,omitempty"` Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"` @@ -155,44 +123,19 @@ type Child struct { BitRate int32 `xml:"bitRate,attr,omitempty" json:"bitRate,omitempty"` Path string `xml:"path,attr,omitempty" json:"path,omitempty"` PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"` + Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"` DiscNumber int32 `xml:"discNumber,attr,omitempty" json:"discNumber,omitempty"` Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"` AlbumId string `xml:"albumId,attr,omitempty" json:"albumId,omitempty"` ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"` Type string `xml:"type,attr,omitempty" json:"type,omitempty"` UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` - AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"` SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` - IsVideo bool `xml:"isVideo,attr,omitempty" json:"isVideo,omitempty"` + IsVideo bool `xml:"isVideo,attr" json:"isVideo"` BookmarkPosition int64 `xml:"bookmarkPosition,attr,omitempty" json:"bookmarkPosition,omitempty"` - *OpenSubsonicChild `xml:",omitempty" json:",omitempty"` -} - -type OpenSubsonicChild struct { - // OpenSubsonic extensions - Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"` - BPM int32 `xml:"bpm,attr,omitempty" json:"bpm"` - Comment string `xml:"comment,attr,omitempty" json:"comment"` - SortName string `xml:"sortName,attr,omitempty" json:"sortName"` - MediaType MediaType `xml:"mediaType,attr,omitempty" json:"mediaType"` - MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"` - Isrc Array[string] `xml:"isrc,omitempty" json:"isrc"` - Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"` - ReplayGain ReplayGain `xml:"replayGain,omitempty" json:"replayGain"` - ChannelCount int32 `xml:"channelCount,attr,omitempty" json:"channelCount"` - SamplingRate int32 `xml:"samplingRate,attr,omitempty" json:"samplingRate"` - BitDepth int32 `xml:"bitDepth,attr,omitempty" json:"bitDepth"` - Moods Array[string] `xml:"moods,omitempty" json:"moods"` - Artists Array[ArtistID3Ref] `xml:"artists,omitempty" json:"artists"` - DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist"` - AlbumArtists Array[ArtistID3Ref] `xml:"albumArtists,omitempty" json:"albumArtists"` - DisplayAlbumArtist string `xml:"displayAlbumArtist,attr,omitempty" json:"displayAlbumArtist"` - Contributors Array[Contributor] `xml:"contributors,omitempty" json:"contributors"` - DisplayComposer string `xml:"displayComposer,attr,omitempty" json:"displayComposer"` - ExplicitStatus string `xml:"explicitStatus,attr,omitempty" json:"explicitStatus"` - Groupings Array[string] `xml:"groupings,omitempty" json:"groupings"` - Works Array[Work] `xml:"works,omitempty" json:"works"` - Movements Array[Movement] `xml:"movements,omitempty" json:"movements"` + /* + <xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 --> + */ } type Songs struct { @@ -200,15 +143,14 @@ type Songs struct { } type Directory struct { - Child []Child `xml:"child" json:"child,omitempty"` - Id string `xml:"id,attr" json:"id"` - Name string `xml:"name,attr" json:"name"` - Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"` - Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` - PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"` - Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"` - UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` - AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"` + Child []Child `xml:"child" json:"child,omitempty"` + Id string `xml:"id,attr" json:"id"` + Name string `xml:"name,attr" json:"name"` + Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"` + Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` + PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"` + Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"` + UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` // ID3 Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"` @@ -220,74 +162,42 @@ type Directory struct { Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"` Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"` Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"` -} -// ArtistID3Ref is a reference to an artist, a simplified version of ArtistID3. This is used to resolve the -// documentation conflict in OpenSubsonic: https://github.com/opensubsonic/open-subsonic-api/discussions/120 -type ArtistID3Ref struct { - Id string `xml:"id,attr" json:"id"` - Name string `xml:"name,attr" json:"name"` + /* + <xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 --> + */ } type ArtistID3 struct { - Id string `xml:"id,attr" json:"id"` - Name string `xml:"name,attr" json:"name"` - CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` - AlbumCount int32 `xml:"albumCount,attr" json:"albumCount"` - Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` - UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` - AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"` - ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"` - *OpenSubsonicArtistID3 `xml:",omitempty" json:",omitempty"` -} - -type OpenSubsonicArtistID3 struct { - // OpenSubsonic extensions - MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"` - SortName string `xml:"sortName,attr,omitempty" json:"sortName"` - Roles Array[string] `xml:"roles,omitempty" json:"roles"` + Id string `xml:"id,attr" json:"id"` + Name string `xml:"name,attr" json:"name"` + CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` + AlbumCount int32 `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"` + Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` + UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` + ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"` } type AlbumID3 struct { - Id string `xml:"id,attr" json:"id"` - Name string `xml:"name,attr" json:"name"` - Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"` - ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"` - CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` - SongCount int32 `xml:"songCount,attr" json:"songCount"` - Duration int32 `xml:"duration,attr" json:"duration"` - PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"` - Created time.Time `xml:"created,attr" json:"created"` - Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` - Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"` - Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"` - *OpenSubsonicAlbumID3 `xml:",omitempty" json:",omitempty"` -} - -type OpenSubsonicAlbumID3 struct { - // OpenSubsonic extensions - Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"` - UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating"` - AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"` - Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"` - MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"` - IsCompilation bool `xml:"isCompilation,attr,omitempty" json:"isCompilation"` - SortName string `xml:"sortName,attr,omitempty" json:"sortName"` - DiscTitles Array[DiscTitle] `xml:"discTitles,omitempty" json:"discTitles"` - OriginalReleaseDate ItemDate `xml:"originalReleaseDate,omitempty" json:"originalReleaseDate"` - ReleaseDate ItemDate `xml:"releaseDate,omitempty" json:"releaseDate"` - ReleaseTypes Array[string] `xml:"releaseTypes,omitempty" json:"releaseTypes"` - RecordLabels Array[RecordLabel] `xml:"recordLabels,omitempty" json:"recordLabels"` - Moods Array[string] `xml:"moods,omitempty" json:"moods"` - Artists Array[ArtistID3Ref] `xml:"artists,omitempty" json:"artists"` - DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist"` - ExplicitStatus string `xml:"explicitStatus,attr,omitempty" json:"explicitStatus"` - Version string `xml:"version,attr,omitempty" json:"version"` + Id string `xml:"id,attr" json:"id"` + Name string `xml:"name,attr" json:"name"` + Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"` + ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"` + CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` + SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` + Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"` + PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"` + Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"` + Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"` + Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` + UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"` + Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"` + Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"` } type ArtistWithAlbumsID3 struct { ArtistID3 - Album []AlbumID3 `xml:"album" json:"album,omitempty"` + Album []Child `xml:"album" json:"album,omitempty"` } type AlbumWithSongsID3 struct { @@ -299,22 +209,17 @@ type AlbumList struct { Album []Child `xml:"album" json:"album,omitempty"` } -type AlbumList2 struct { - Album []AlbumID3 `xml:"album" json:"album,omitempty"` -} - type Playlist struct { - Id string `xml:"id,attr" json:"id"` - Name string `xml:"name,attr" json:"name"` - Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"` - SongCount int32 `xml:"songCount,attr" json:"songCount"` - Duration int32 `xml:"duration,attr" json:"duration"` - Public bool `xml:"public,attr" json:"public,omitempty"` - Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"` - Created time.Time `xml:"created,attr" json:"created"` - Changed time.Time `xml:"changed,attr" json:"changed"` - CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` - *OpenSubsonicPlaylist `xml:",omitempty" json:",omitempty"` + Id string `xml:"id,attr" json:"id"` + Name string `xml:"name,attr" json:"name"` + Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"` + SongCount int32 `xml:"songCount,attr" json:"songCount"` + Duration int32 `xml:"duration,attr" json:"duration"` + Public bool `xml:"public,attr" json:"public"` + Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"` + Created time.Time `xml:"created,attr" json:"created"` + Changed time.Time `xml:"changed,attr" json:"changed"` + CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` /* <xs:sequence> <xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0--> @@ -322,11 +227,6 @@ type Playlist struct { */ } -type OpenSubsonicPlaylist struct { - Readonly bool `xml:"readonly,attr,omitempty" json:"readonly"` - ValidUntil *time.Time `xml:"validUntil,attr,omitempty" json:"validUntil,omitempty"` -} - type Playlists struct { Playlist []Playlist `xml:"playlist" json:"playlist,omitempty"` } @@ -344,7 +244,7 @@ type SearchResult2 struct { type SearchResult3 struct { Artist []ArtistID3 `xml:"artist" json:"artist,omitempty"` - Album []AlbumID3 `xml:"album" json:"album,omitempty"` + Album []Child `xml:"album" json:"album,omitempty"` Song []Child `xml:"song" json:"song,omitempty"` } @@ -354,21 +254,12 @@ type Starred struct { Song []Child `xml:"song" json:"song,omitempty"` } -type Starred2 struct { - Artist []ArtistID3 `xml:"artist" json:"artist,omitempty"` - Album []AlbumID3 `xml:"album" json:"album,omitempty"` - Song []Child `xml:"song" json:"song,omitempty"` -} - 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"` - State string `xml:"state,attr" json:"state"` - PositionMs int64 `xml:"positionMs,attr" json:"positionMs"` - PlaybackRate float64 `xml:"playbackRate,attr" json:"playbackRate"` + 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"` } type NowPlaying struct { @@ -449,31 +340,17 @@ 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"` - Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"` - Username string `xml:"username,attr" json:"username"` - Changed time.Time `xml:"changed,attr" json:"changed"` - ChangedBy string `xml:"changedBy,attr" json:"changedBy"` -} - -type PlayQueueByIndex struct { - Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"` - CurrentIndex *int `xml:"currentIndex,attr,omitempty" json:"currentIndex,omitempty"` - Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"` - Username string `xml:"username,attr" json:"username"` - Changed time.Time `xml:"changed,attr" json:"changed"` - ChangedBy string `xml:"changedBy,attr" json:"changedBy"` + Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"` + Current string `xml:"current,attr,omitempty" json:"current,omitempty"` + Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"` + Username string `xml:"username,attr" json:"username"` + Changed *time.Time `xml:"changed,attr,omitempty" json:"changed,omitempty"` + ChangedBy string `xml:"changedBy,attr" json:"changedBy"` } type Bookmark struct { - Entry Child `xml:"entry,omitempty" json:"entry"` + Entry Child `xml:"entry,omitempty" json:"entry,omitempty"` Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"` Username string `xml:"username,attr" json:"username"` Comment string `xml:"comment,attr" json:"comment"` @@ -493,7 +370,7 @@ type Share struct { Username string `xml:"username,attr" json:"username"` Created time.Time `xml:"created,attr" json:"created"` Expires *time.Time `xml:"expires,omitempty,attr" json:"expires,omitempty"` - LastVisited *time.Time `xml:"lastVisited,omitempty,attr" json:"lastVisited,omitempty"` + LastVisited time.Time `xml:"lastVisited,omitempty,attr" json:"lastVisited"` VisitCount int32 `xml:"visitCount,attr" json:"visitCount"` } @@ -502,13 +379,10 @@ type Shares struct { } type ScanStatus struct { - Scanning bool `xml:"scanning,attr" json:"scanning"` - Count int64 `xml:"count,attr" json:"count"` - FolderCount int64 `xml:"folderCount,attr" json:"folderCount"` - LastScan *time.Time `xml:"lastScan,attr,omitempty" json:"lastScan,omitempty"` - Error string `xml:"error,attr,omitempty" json:"error,omitempty"` - ScanType string `xml:"scanType,attr,omitempty" json:"scanType,omitempty"` - ElapsedTime int64 `xml:"elapsedTime,attr,omitempty" json:"elapsedTime,omitempty"` + Scanning bool `xml:"scanning,attr" json:"scanning"` + Count int64 `xml:"count,attr" json:"count"` + FolderCount int64 `xml:"folderCount,attr" json:"folderCount"` + LastScan *time.Time `xml:"lastScan,attr,omitempty" json:"lastScan,omitempty"` } type Lyrics struct { @@ -522,182 +396,10 @@ type InternetRadioStations struct { } type Radio struct { - ID string `xml:"id,attr" json:"id"` - Name string `xml:"name,attr" json:"name"` - StreamUrl string `xml:"streamUrl,attr" json:"streamUrl"` - HomepageUrl string `xml:"homePageUrl,omitempty,attr" json:"homePageUrl,omitempty"` - *OpenSubsonicRadio `xml:",omitempty" json:",omitempty"` + ID string `xml:"id,attr" json:"id"` + Name string `xml:"name,attr" json:"name"` + StreamUrl string `xml:"streamUrl,attr" json:"streamUrl"` + HomepageUrl string `xml:"homePageUrl,omitempty,attr" json:"homePageUrl,omitempty"` } -type OpenSubsonicRadio struct { - CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt"` -} - -type JukeboxStatus struct { - CurrentIndex int32 `xml:"currentIndex,attr" json:"currentIndex"` - Playing bool `xml:"playing,attr" json:"playing"` - Gain float32 `xml:"gain,attr" json:"gain"` - Position int32 `xml:"position,omitempty,attr" json:"position"` -} - -type JukeboxPlaylist struct { - JukeboxStatus - Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"` -} - -type Line struct { - Start *int64 `xml:"start,attr,omitempty" json:"start,omitempty"` - Value string `xml:",chardata" json:"value"` -} - -type LyricCue struct { - Start int64 `xml:"start,attr" json:"start"` - End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"` - ByteStart int `xml:"byteStart,attr" json:"byteStart"` - ByteEnd int `xml:"byteEnd,attr" json:"byteEnd"` - Value string `xml:",chardata" json:"value"` -} - -type Agent struct { - ID string `xml:"id,attr" json:"id"` - Role string `xml:"role,attr" json:"role"` - Name string `xml:"name,attr,omitempty" json:"name,omitempty"` -} - -type CueLine struct { - Index int32 `xml:"index,attr" json:"index"` - Start *int64 `xml:"start,attr,omitempty" json:"start,omitempty"` - End *int64 `xml:"end,attr,omitempty" json:"end,omitempty"` - Value string `xml:"value,attr" json:"value"` - AgentID string `xml:"agentId,attr,omitempty" json:"agentId,omitempty"` - Cue []LyricCue `xml:"cue,omitempty" json:"cue,omitempty"` -} - -type StructuredLyric struct { - DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"` - DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"` - Kind string `xml:"kind,attr,omitempty" json:"kind,omitempty"` - Lang string `xml:"lang,attr" json:"lang"` - Line []Line `xml:"line" json:"line"` - Agents []Agent `xml:"agent,omitempty" json:"agents,omitempty"` - CueLine []CueLine `xml:"cueLine,omitempty" json:"cueLine,omitempty"` - Offset *int64 `xml:"offset,attr,omitempty" json:"offset,omitempty"` - Synced bool `xml:"synced,attr" json:"synced"` -} - -type StructuredLyrics []StructuredLyric -type LyricsList struct { - StructuredLyrics []StructuredLyric `xml:"structuredLyrics,omitempty" json:"structuredLyrics,omitempty"` -} - -type OpenSubsonicExtension struct { - Name string `xml:"name,attr" json:"name"` - Versions []int32 `xml:"versions" json:"versions"` -} - -type OpenSubsonicExtensions []OpenSubsonicExtension - -type TokenInfo struct { - Username string `xml:"username,attr" json:"username"` -} - -type ItemGenre struct { - Name string `xml:"name,attr" json:"name"` -} - -type Work struct { - Name string `xml:"name,attr" json:"name"` - MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId,omitempty"` -} - -type Movement struct { - Name string `xml:"name,attr" json:"name"` - Number int32 `xml:"number,attr,omitempty" json:"number,omitempty"` - Count int32 `xml:"count,attr,omitempty" json:"count,omitempty"` -} - -type ReplayGain struct { - TrackGain *float64 `xml:"trackGain,omitempty,attr" json:"trackGain,omitempty"` - AlbumGain *float64 `xml:"albumGain,omitempty,attr" json:"albumGain,omitempty"` - TrackPeak *float64 `xml:"trackPeak,omitempty,attr" json:"trackPeak,omitempty"` - AlbumPeak *float64 `xml:"albumPeak,omitempty,attr" json:"albumPeak,omitempty"` - BaseGain *float64 `xml:"baseGain,omitempty,attr" json:"baseGain,omitempty"` - FallbackGain *float64 `xml:"fallbackGain,omitempty,attr" json:"fallbackGain,omitempty"` -} - -func (r ReplayGain) MarshalXML(e *xml.Encoder, start xml.StartElement) error { - if r.TrackGain == nil && r.AlbumGain == nil && r.TrackPeak == nil && r.AlbumPeak == nil && r.BaseGain == nil && r.FallbackGain == nil { - return nil - } - type replayGain ReplayGain - return e.EncodeElement(replayGain(r), start) -} - -type DiscTitle struct { - Disc int32 `xml:"disc,attr" json:"disc"` - Title string `xml:"title,attr" json:"title"` - CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` -} - -type ItemDate struct { - Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"` - Month int32 `xml:"month,attr,omitempty" json:"month,omitempty"` - Day int32 `xml:"day,attr,omitempty" json:"day,omitempty"` -} - -func (d ItemDate) MarshalXML(e *xml.Encoder, start xml.StartElement) error { - if d.Year == 0 && d.Month == 0 && d.Day == 0 { - return nil - } - type itemDate ItemDate - return e.EncodeElement(itemDate(d), start) -} - -type RecordLabel struct { - Name string `xml:"name,attr" json:"name"` -} - -type Contributor struct { - Role string `xml:"role,attr" json:"role"` - SubRole string `xml:"subRole,attr,omitempty" json:"subRole,omitempty"` - Artist ArtistID3Ref `xml:"artist" json:"artist"` -} - -// Array is a generic type for marshalling slices to JSON. It is used to avoid marshalling empty slices as null. -type Array[T any] []T - -func (a Array[T]) MarshalJSON() ([]byte, error) { - return marshalJSONArray(a) -} - -// marshalJSONArray marshals a slice of any type to JSON. If the slice is empty, it is marshalled as an -// empty array instead of null. -func marshalJSONArray[T any](v []T) ([]byte, error) { - if len(v) == 0 { - return json.Marshal([]T{}) - } - return json.Marshal(v) -} - -// TranscodeDecision represents the response for getTranscodeDecision (OpenSubsonic transcoding extension) -type TranscodeDecision struct { - CanDirectPlay bool `xml:"canDirectPlay,attr" json:"canDirectPlay"` - CanTranscode bool `xml:"canTranscode,attr" json:"canTranscode"` - TranscodeReasons []string `xml:"transcodeReason,omitempty" json:"transcodeReason,omitempty"` - ErrorReason string `xml:"errorReason,attr,omitempty" json:"errorReason,omitempty"` - TranscodeParams string `xml:"transcodeParams,attr,omitempty" json:"transcodeParams,omitempty"` - SourceStream *StreamDetails `xml:"sourceStream,omitempty" json:"sourceStream,omitempty"` - TranscodeStream *StreamDetails `xml:"transcodeStream,omitempty" json:"transcodeStream,omitempty"` -} - -// StreamDetails describes audio stream properties for transcoding decisions -type StreamDetails struct { - Protocol string `xml:"protocol,attr,omitempty" json:"protocol,omitempty"` - Container string `xml:"container,attr,omitempty" json:"container,omitempty"` - Codec string `xml:"codec,attr,omitempty" json:"codec,omitempty"` - AudioChannels int32 `xml:"audioChannels,attr,omitempty" json:"audioChannels,omitempty"` - AudioBitrate int32 `xml:"audioBitrate,attr,omitempty" json:"audioBitrate,omitempty"` - AudioProfile string `xml:"audioProfile,attr,omitempty" json:"audioProfile,omitempty"` - AudioSamplerate int32 `xml:"audioSamplerate,attr,omitempty" json:"audioSamplerate,omitempty"` - AudioBitdepth int32 `xml:"audioBitdepth,attr,omitempty" json:"audioBitdepth,omitempty"` -} +type OpenSubsonicExtensions struct{} diff --git a/server/subsonic/responses/responses_suite_test.go b/server/subsonic/responses/responses_suite_test.go index 7fc10aa15..8728b949a 100644 --- a/server/subsonic/responses/responses_suite_test.go +++ b/server/subsonic/responses/responses_suite_test.go @@ -1,17 +1,14 @@ 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) { @@ -21,61 +18,25 @@ func TestSubsonicApiResponses(t *testing.T) { } func MatchSnapshot() types.GomegaMatcher { - return &snapshotMatcher{} + c := cupaloy.New(cupaloy.FailOnUpdate(false)) + return &snapshotMatcher{c} } type snapshotMatcher struct { - diff string + c *cupaloy.Config } -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) Match(actual interface{}) (success bool, err error) { + actualJson := strings.TrimSpace(string(actual.([]byte))) + err = matcher.c.SnapshotWithName(ginkgo.CurrentSpecReport().FullText(), actualJson) + success = err == nil + return } -func (matcher *snapshotMatcher) FailureMessage(_ any) (message string) { - return "Expected to match saved snapshot\n" + matcher.diff +func (matcher snapshotMatcher) FailureMessage(_ interface{}) (message string) { + return "Expected to match saved snapshot\n" } -func (matcher *snapshotMatcher) NegatedFailureMessage(_ any) (message string) { +func (matcher snapshotMatcher) NegatedFailureMessage(_ interface{}) (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 027ac11d5..c4681221e 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -1,3 +1,8 @@ +//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 ( @@ -6,7 +11,6 @@ import ( "time" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -16,20 +20,20 @@ var _ = Describe("Responses", func() { var response *Subsonic BeforeEach(func() { response = &Subsonic{ - Status: StatusOK, - Version: "1.16.1", + Status: "ok", + Version: "1.8.0", Type: consts.AppName, - ServerVersion: "v0.55.0", + ServerVersion: "v0.0.0", OpenSubsonic: true, } }) Describe("EmptyResponse", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -38,10 +42,10 @@ var _ = Describe("Responses", func() { response.License = &License{Valid: true} }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -52,10 +56,10 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -68,10 +72,10 @@ var _ = Describe("Responses", func() { }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -83,21 +87,23 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) Context("with data", func() { BeforeEach(func() { artists := make([]Artist, 1) + t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) artists[0] = Artist{ Id: "111", Name: "aaa", - Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), + Starred: &t, UserRating: 3, + AlbumCount: 2, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", } index := make([]Index, 1) @@ -106,254 +112,34 @@ var _ = Describe("Responses", func() { }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - }) - }) - - Describe("Artist", func() { - BeforeEach(func() { - response.Artist = &Artists{LastModified: 1, IgnoredArticles: "A"} - }) - - Context("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()) - }) - }) - - Context("with data", func() { - BeforeEach(func() { - artists := make([]ArtistID3, 1) - artists[0] = ArtistID3{ - Id: "111", - Name: "aaa", - Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), - UserRating: 3, - AlbumCount: 2, - ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", - } - index := make([]IndexID3, 1) - index[0] = IndexID3{Name: "A", Artists: artists} - response.Artist.Index = index - }) - - It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - }) - - Context("with OpenSubsonic data", func() { - BeforeEach(func() { - artists := make([]ArtistID3, 1) - artists[0] = ArtistID3{ - Id: "111", - Name: "aaa", - Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), - UserRating: 3, - AlbumCount: 2, - ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", - } - artists[0].OpenSubsonicArtistID3 = &OpenSubsonicArtistID3{ - MusicBrainzId: "1234", - SortName: "sort name", - Roles: []string{"role1", "role2"}, - } - - index := make([]IndexID3, 1) - index[0] = IndexID3{Name: "A", Artists: artists} - response.Artist.Index = index - }) - - It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) Describe("Child", func() { - Context("without data", func() { - BeforeEach(func() { - response.Directory = &Directory{Child: []Child{{Id: "1"}}} - }) - It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - It("should match OpenSubsonic .XML", func() { - response.Directory.Child[0].OpenSubsonicChild = &OpenSubsonicChild{} - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - It("should match OpenSubsonic .JSON", func() { - response.Directory.Child[0].OpenSubsonicChild = &OpenSubsonicChild{} - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - }) Context("with data", func() { BeforeEach(func() { response.Directory = &Directory{Id: "1", Name: "N"} - child := make([]Child, 2) + child := make([]Child, 1) + t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) child[0] = Child{ Id: "1", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1, Year: 1985, Genre: "Rock", CoverArt: "1", Size: 8421341, ContentType: "audio/flac", Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3", - Duration: 146, BitRate: 320, Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), - } - child[0].OpenSubsonicChild = &OpenSubsonicChild{ - Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, - Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted title", - Isrc: []string{"ISRC-1", "ISRC-2"}, - BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, - Moods: []string{"happy", "sad"}, - Groupings: []string{"Soundtrack", "Live"}, - ReplayGain: ReplayGain{TrackGain: new(1.0), AlbumGain: new(2.0), TrackPeak: new(3.0), AlbumPeak: new(4.0), BaseGain: new(5.0), FallbackGain: new(6.0)}, - DisplayArtist: "artist 1 & artist 2", - Artists: []ArtistID3Ref{ - {Id: "1", Name: "artist1"}, - {Id: "2", Name: "artist2"}, - }, - DisplayAlbumArtist: "album artist 1 & album artist 2", - AlbumArtists: []ArtistID3Ref{ - {Id: "1", Name: "album artist1"}, - {Id: "2", Name: "album artist2"}, - }, - DisplayComposer: "composer 1 & composer 2", - Contributors: []Contributor{ - {Role: "role1", SubRole: "subrole3", Artist: ArtistID3Ref{Id: "1", Name: "artist1"}}, - {Role: "role2", Artist: ArtistID3Ref{Id: "2", Name: "artist2"}}, - {Role: "composer", Artist: ArtistID3Ref{Id: "3", Name: "composer1"}}, - {Role: "composer", Artist: ArtistID3Ref{Id: "4", Name: "composer2"}}, - }, - ExplicitStatus: "clean", - Works: []Work{ - {Name: "Symphony No. 5", MusicBrainzId: "mbz-work-1"}, - {Name: "Encore"}, - }, - Movements: []Movement{ - {Name: "I. Allegro", Number: 1, Count: 4}, - }, - } - child[1].OpenSubsonicChild = &OpenSubsonicChild{ - ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)}, + Duration: 146, BitRate: 320, Starred: &t, } response.Directory.Child = child }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - }) - }) - - Describe("AlbumWithSongsID3", func() { - BeforeEach(func() { - response.AlbumWithSongsID3 = &AlbumWithSongsID3{} - }) - Context("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()) - }) - It("should match OpenSubsonic .XML", func() { - response.AlbumWithSongsID3.OpenSubsonicAlbumID3 = &OpenSubsonicAlbumID3{} - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - It("should match OpenSubsonic .JSON", func() { - response.AlbumWithSongsID3.OpenSubsonicAlbumID3 = &OpenSubsonicAlbumID3{} - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - }) - - Context("with data", func() { - BeforeEach(func() { - album := AlbumID3{ - Id: "1", Name: "album", Artist: "artist", Duration: 292, Genre: "rock", - } - album.OpenSubsonicAlbumID3 = &OpenSubsonicAlbumID3{ - Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, - UserRating: 4, - MusicBrainzId: "1234", IsCompilation: true, SortName: "sorted album", - DiscTitles: Array[DiscTitle]{{Disc: 1, Title: "disc 1"}, {Disc: 2, Title: "disc 2"}, {Disc: 3}}, - OriginalReleaseDate: ItemDate{Year: 1994, Month: 2, Day: 4}, - ReleaseDate: ItemDate{Year: 2000, Month: 5, Day: 10}, - ReleaseTypes: []string{"album", "live"}, - RecordLabels: []RecordLabel{{Name: "label1"}, {Name: "label2"}}, - Moods: []string{"happy", "sad"}, - DisplayArtist: "artist1 & artist2", - Artists: []ArtistID3Ref{ - {Id: "1", Name: "artist1"}, - {Id: "2", Name: "artist2"}, - }, - ExplicitStatus: "clean", - Version: "Deluxe Edition", - } - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) - songs := []Child{{ - Id: "1", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1, - Year: 1985, Genre: "Rock", CoverArt: "1", Size: 8421341, ContentType: "audio/flac", - Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3", - Duration: 146, BitRate: 320, Starred: &t, - }, { - Id: "2", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1, - Year: 1985, Genre: "Rock", CoverArt: "1", Size: 8421341, ContentType: "audio/flac", - Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3", - Duration: 146, BitRate: 320, Starred: &t, - }} - songs[0].OpenSubsonicChild = &OpenSubsonicChild{ - Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, - Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song", - Isrc: []string{"ISRC-1"}, - Moods: []string{"happy", "sad"}, - Groupings: []string{"Soundtrack", "Live"}, - ReplayGain: ReplayGain{TrackGain: new(1.0), AlbumGain: new(2.0), TrackPeak: new(3.0), AlbumPeak: new(4.0), BaseGain: new(5.0), FallbackGain: new(6.0)}, - BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, - DisplayArtist: "artist1 & artist2", - Artists: []ArtistID3Ref{ - {Id: "1", Name: "artist1"}, - {Id: "2", Name: "artist2"}, - }, - DisplayAlbumArtist: "album artist1 & album artist2", - AlbumArtists: []ArtistID3Ref{ - {Id: "1", Name: "album artist1"}, - {Id: "2", Name: "album artist2"}, - }, - Contributors: []Contributor{ - {Role: "role1", Artist: ArtistID3Ref{Id: "1", Name: "artist1"}}, - {Role: "role2", SubRole: "subrole4", Artist: ArtistID3Ref{Id: "2", Name: "artist2"}}, - }, - DisplayComposer: "composer 1 & composer 2", - ExplicitStatus: "clean", - } - songs[1].OpenSubsonicChild = &OpenSubsonicChild{ - ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)}, - } - response.AlbumWithSongsID3.AlbumID3 = album - response.AlbumWithSongsID3.Song = songs - }) - It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -365,10 +151,10 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -380,10 +166,10 @@ var _ = Describe("Responses", func() { }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -395,10 +181,10 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -410,47 +196,10 @@ var _ = Describe("Responses", func() { }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - }) - - Context("with OS data", func() { - BeforeEach(func() { - child := make([]Child, 1) - child[0] = Child{Id: "1", OpenSubsonicChild: &OpenSubsonicChild{ - MediaType: MediaTypeAlbum, - MusicBrainzId: "00000000-0000-0000-0000-000000000000", - Genres: Array[ItemGenre]{ - ItemGenre{Name: "Genre 1"}, - ItemGenre{Name: "Genre 2"}, - }, - Moods: []string{"mood1", "mood2"}, - Groupings: []string{"Soundtrack"}, - DisplayArtist: "Display artist", - Artists: Array[ArtistID3Ref]{ - ArtistID3Ref{Id: "artist-1", Name: "Artist 1"}, - ArtistID3Ref{Id: "artist-2", Name: "Artist 2"}, - }, - DisplayAlbumArtist: "Display album artist", - AlbumArtists: Array[ArtistID3Ref]{ - ArtistID3Ref{Id: "album-artist-1", Name: "Artist 1"}, - ArtistID3Ref{Id: "album-artist-2", Name: "Artist 2"}, - }, - ExplicitStatus: "explicit", - SortName: "sort name", - }} - response.AlbumList.Album = child - - }) - - It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -462,10 +211,10 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -476,10 +225,10 @@ var _ = Describe("Responses", func() { }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -492,10 +241,10 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -509,10 +258,10 @@ var _ = Describe("Responses", func() { }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -524,17 +273,17 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) Context("with data", func() { - timestamp := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC) + timestamp, _ := time.Parse(time.RFC3339, "2020-04-11T16:43:00Z04:00") BeforeEach(func() { - pls := make([]Playlist, 3) + pls := make([]Playlist, 2) pls[0] = Playlist{ Id: "111", Name: "aaa", @@ -546,21 +295,16 @@ var _ = Describe("Responses", func() { CoverArt: "pl-123123123123", Created: timestamp, Changed: timestamp, - OpenSubsonicPlaylist: &responses.OpenSubsonicPlaylist{ - Readonly: true, - ValidUntil: ×tamp, - }, } - pls[1] = Playlist{Id: "333", Name: "ccc", OpenSubsonicPlaylist: &responses.OpenSubsonicPlaylist{}} - pls[2] = Playlist{Id: "222", Name: "bbb"} + pls[1] = Playlist{Id: "222", Name: "bbb"} response.Playlists.Playlist = pls }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -572,10 +316,10 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -589,10 +333,10 @@ var _ = Describe("Responses", func() { }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -604,10 +348,10 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -622,10 +366,10 @@ var _ = Describe("Responses", func() { }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -637,18 +381,17 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) Context("with data", func() { BeforeEach(func() { - response.ArtistInfo.Biography = `Black Sabbath is an English <a target='_blank' href="https://www.last.fm/tag/heavy%20metal" class="bbcode_tag" rel="tag">heavy metal</a> band` + response.ArtistInfo.Biography = `Black Sabbath is an English <a target='_blank' href="http://www.last.fm/tag/heavy%20metal" class="bbcode_tag" rel="tag">heavy metal</a> band` response.ArtistInfo.MusicBrainzID = "5182c1d9-c7d2-4dad-afa0-ccfeada921a8" - response.ArtistInfo.LastFmUrl = "https://www.last.fm/music/Black+Sabbath" response.ArtistInfo.SmallImageUrl = "https://userserve-ak.last.fm/serve/64/27904353.jpg" response.ArtistInfo.MediumImageUrl = "https://userserve-ak.last.fm/serve/126/27904353.jpg" @@ -660,11 +403,12 @@ var _ = Describe("Responses", func() { } }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) + }) }) @@ -675,10 +419,10 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -689,10 +433,10 @@ var _ = Describe("Responses", func() { response.TopSongs.Song = child }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -704,10 +448,10 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -718,10 +462,10 @@ var _ = Describe("Responses", func() { response.SimilarSongs.Song = child }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -733,10 +477,10 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -747,10 +491,10 @@ var _ = Describe("Responses", func() { response.SimilarSongs2.Song = child }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -762,10 +506,10 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -774,51 +518,17 @@ var _ = Describe("Responses", func() { response.PlayQueue.Username = "user1" response.PlayQueue.Current = "111" response.PlayQueue.Position = 243 - response.PlayQueue.Changed = time.Time{} + response.PlayQueue.Changed = &time.Time{} response.PlayQueue.ChangedBy = "a_client" child := make([]Child, 1) child[0] = Child{Id: "1", Title: "title", IsDir: false} response.PlayQueue.Entry = child }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - }) - }) - - Describe("PlayQueueByIndex", func() { - BeforeEach(func() { - response.PlayQueueByIndex = &PlayQueueByIndex{} - }) - - Context("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()) - }) - }) - - Context("with data", func() { - BeforeEach(func() { - response.PlayQueueByIndex.Username = "user1" - response.PlayQueueByIndex.CurrentIndex = new(0) - response.PlayQueueByIndex.Position = 243 - response.PlayQueueByIndex.Changed = time.Time{} - response.PlayQueueByIndex.ChangedBy = "a_client" - child := make([]Child, 1) - child[0] = Child{Id: "1", Title: "title", IsDir: false} - response.PlayQueueByIndex.Entry = child - }) - It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -830,35 +540,16 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - }) - - Context("with only required fields", func() { - BeforeEach(func() { - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) - response.Shares.Share = []Share{{ - ID: "ABC123", - Url: "http://localhost/s/ABC123", - Username: "johndoe", - Created: t, - VisitCount: 1, - }} - }) - It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) Context("with data", func() { BeforeEach(func() { - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) + t := time.Time{} share := Share{ ID: "ABC123", Url: "http://localhost/p/ABC123", @@ -866,7 +557,7 @@ var _ = Describe("Responses", func() { Username: "deluan", Created: t, Expires: &t, - LastVisited: &t, + LastVisited: t, VisitCount: 2, } share.Entry = make([]Child, 2) @@ -875,10 +566,10 @@ var _ = Describe("Responses", func() { response.Shares.Share = []Share{share} }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -890,10 +581,10 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -910,10 +601,10 @@ var _ = Describe("Responses", func() { response.Bookmarks.Bookmark = []Bookmark{bmk} }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -925,17 +616,16 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) Context("with data", func() { BeforeEach(func() { - timeFmt := "2006-01-02 15:04:00" - t, _ := time.Parse(timeFmt, timeFmt) + t, _ := time.Parse(time.RFC822, time.RFC822) response.ScanStatus = &ScanStatus{ Scanning: true, FolderCount: 123, @@ -944,10 +634,10 @@ var _ = Describe("Responses", func() { } }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) @@ -959,10 +649,10 @@ var _ = Describe("Responses", func() { Context("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -976,59 +666,15 @@ var _ = Describe("Responses", func() { Never gonna say goodbye` }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) - Describe("OpenSubsonicExtensions", func() { - BeforeEach(func() { - response.OpenSubsonic = true - response.OpenSubsonicExtensions = &OpenSubsonicExtensions{} - }) - - 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.OpenSubsonicExtensions = &OpenSubsonicExtensions{ - OpenSubsonicExtension{Name: "template", Versions: []int32{1, 2}}, - } - }) - It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - }) - }) - - Describe("TokenInfo", func() { - BeforeEach(func() { - response.OpenSubsonic = true - response.TokenInfo = &TokenInfo{Username: "deluan"} - }) - - It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - }) - Describe("InternetRadioStations", func() { BeforeEach(func() { response.InternetRadioStations = &InternetRadioStations{} @@ -1036,10 +682,10 @@ var _ = Describe("Responses", func() { Describe("without data", func() { It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) @@ -1056,141 +702,11 @@ var _ = Describe("Responses", func() { }) It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(xml.Marshal(response)).To(MatchSnapshot()) }) It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + Expect(json.Marshal(response)).To(MatchSnapshot()) }) }) }) - - Describe("LyricsList", func() { - BeforeEach(func() { - response.LyricsList = &LyricsList{} - }) - - 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() { - times := []int64{18800, 22801} - offset := int64(100) - - response.LyricsList.StructuredLyrics = StructuredLyrics{ - { - Lang: "eng", - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Offset: &offset, - Synced: true, - Line: []Line{ - { - Start: ×[0], - Value: "We're no strangers to love", - }, - { - Start: ×[1], - Value: "You know the rules and so do I", - }, - }, - }, - { - Lang: "xxx", - DisplayArtist: "Rick Astley", - DisplayTitle: "Never Gonna Give You Up", - Offset: &offset, - Synced: false, - Line: []Line{ - { - Value: "We're no strangers to love", - }, - { - Value: "You know the rules and so do I", - }, - }, - }, - } - }) - - It("should match .XML", func() { - Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - It("should match .JSON", func() { - Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) - }) - }) - }) - - 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.go b/server/subsonic/searching.go index fb370fc9d..3aafa3c53 100644 --- a/server/subsonic/searching.go +++ b/server/subsonic/searching.go @@ -6,17 +6,15 @@ import ( "net/http" "reflect" "strings" + "sync" "time" - . "github.com/Masterminds/squirrel" "github.com/deluan/sanitize" - "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/slice" - "golang.org/x/sync/errgroup" + "github.com/navidrome/navidrome/utils" ) type searchParams struct { @@ -29,125 +27,114 @@ type searchParams struct { songOffset int } -func (api *Router) getSearchParams(r *http.Request) (*searchParams, error) { - p := req.Params(r) +func (api *Router) getParams(r *http.Request) (*searchParams, error) { + var err error sp := &searchParams{} - sp.query = p.StringOr("query", `""`) - sp.artistCount = p.IntOr("artistCount", 20) - sp.artistOffset = p.IntOr("artistOffset", 0) - sp.albumCount = p.IntOr("albumCount", 20) - sp.albumOffset = p.IntOr("albumOffset", 0) - sp.songCount = p.IntOr("songCount", 20) - sp.songOffset = p.IntOr("songOffset", 0) + sp.query, err = requiredParamString(r, "query") + if err != nil { + return nil, err + } + sp.artistCount = utils.ParamInt(r, "artistCount", 20) + sp.artistOffset = utils.ParamInt(r, "artistOffset", 0) + sp.albumCount = utils.ParamInt(r, "albumCount", 20) + sp.albumOffset = utils.ParamInt(r, "albumOffset", 0) + sp.songCount = utils.ParamInt(r, "songCount", 20) + sp.songOffset = utils.ParamInt(r, "songOffset", 0) return sp, nil } -type searchFunc[T any] func(ctx context.Context, q string, options ...model.QueryOptions) (T, error) +type searchFunc[T any] func(q string, offset int, size int) (T, error) -func callSearch[T any](ctx context.Context, s searchFunc[T], q string, options model.QueryOptions, result *T) func() error { - return func() error { - if options.Max == 0 { - return nil - } +func doSearch[T any](ctx context.Context, wg *sync.WaitGroup, s searchFunc[T], q string, offset, size int, result *T) { + defer wg.Done() + if size == 0 { + return + } + done := make(chan struct{}) + go func() { typ := strings.TrimPrefix(reflect.TypeOf(*result).String(), "model.") var err error start := time.Now() - *result, err = s(ctx, q, options) + *result, err = s(q, offset, size) if err != nil { - log.Error(ctx, "Error searching "+typ, "query", q, "elapsed", time.Since(start), err) + log.Error(ctx, "Error searching "+typ, "query", q, err) } else { log.Trace(ctx, "Search for "+typ+" completed", "query", q, "elapsed", time.Since(start)) } - return nil + done <- struct{}{} + }() + select { + case <-done: + case <-ctx.Done(): } } -func (api *Router) searchAll(ctx context.Context, sp *searchParams, musicFolderIds []int) (mediaFiles model.MediaFiles, albums model.Albums, artists model.Artists) { +func (api *Router) searchAll(ctx context.Context, sp *searchParams) (mediaFiles model.MediaFiles, albums model.Albums, artists model.Artists) { start := time.Now() q := sanitize.Accents(strings.ToLower(strings.TrimSuffix(sp.query, "*"))) + wg := &sync.WaitGroup{} + wg.Add(3) + go doSearch(ctx, wg, api.ds.MediaFile(ctx).Search, q, sp.songOffset, sp.songCount, &mediaFiles) + go doSearch(ctx, wg, api.ds.Album(ctx).Search, q, sp.albumOffset, sp.albumCount, &albums) + go doSearch(ctx, wg, api.ds.Artist(ctx).Search, q, sp.artistOffset, sp.artistCount, &artists) + wg.Wait() - // Build options with offset/size/filters packed in - songOpts := model.QueryOptions{Max: sp.songCount, Offset: sp.songOffset} - albumOpts := model.QueryOptions{Max: sp.albumCount, Offset: sp.albumOffset} - artistOpts := model.QueryOptions{Max: sp.artistCount, Offset: sp.artistOffset} - - if len(musicFolderIds) > 0 { - songOpts.Filters = Eq{"library_id": musicFolderIds} - albumOpts.Filters = Eq{"library_id": musicFolderIds} - artistOpts.Filters = Eq{"library_id": musicFolderIds} - } - - // Run searches in parallel - g, ctx := errgroup.WithContext(ctx) - g.Go(callSearch(ctx, api.ds.MediaFile().Search, q, songOpts, &mediaFiles)) - g.Go(callSearch(ctx, api.ds.Album().Search, q, albumOpts, &albums)) - g.Go(callSearch(ctx, api.ds.Artist().Search, q, artistOpts, &artists)) - err := g.Wait() - if err == nil { + if ctx.Err() == nil { log.Debug(ctx, fmt.Sprintf("Search resulted in %d songs, %d albums and %d artists", len(mediaFiles), len(albums), len(artists)), "query", sp.query, "elapsedTime", time.Since(start)) } else { - log.Warn(ctx, "Search was interrupted", "query", sp.query, "elapsedTime", time.Since(start), err) + log.Warn(ctx, "Search was interrupted", ctx.Err(), "query", sp.query, "elapsedTime", time.Since(start)) } return mediaFiles, albums, artists } func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - sp, err := api.getSearchParams(r) + sp, err := api.getParams(r) if err != nil { return nil, err } - - // Get optional library IDs from musicFolderId parameter - musicFolderIds, err := selectedMusicFolderIds(r, false) - if err != nil { - return nil, err - } - mfs, als, as := api.searchAll(ctx, sp, musicFolderIds) + mfs, als, as := api.searchAll(ctx, sp) response := newResponse() searchResult2 := &responses.SearchResult2{} - searchResult2.Artist = slice.Map(as, func(artist model.Artist) responses.Artist { - a := responses.Artist{ - Id: artist.ID, - Name: artist.Name, - UserRating: int32(artist.Rating), - CoverArt: coverArtOrEmpty(artist.CoverArtID(), artist.ImageAbsent), - } - if !artist.ImageAbsent { - a.ArtistImageUrl = publicurl.ImageURL(r.Context(), artist.CoverArtID(), 600) + searchResult2.Artist = make([]responses.Artist, len(as)) + for i, artist := range as { + artist := artist + searchResult2.Artist[i] = responses.Artist{ + Id: artist.ID, + Name: artist.Name, + AlbumCount: int32(artist.AlbumCount), + UserRating: int32(artist.Rating), + CoverArt: artist.CoverArtID().String(), + ArtistImageUrl: public.ImageURL(r, artist.CoverArtID(), 600), } if artist.Starred { - a.Starred = artist.StarredAt + searchResult2.Artist[i].Starred = &as[i].StarredAt } - return a - }) - searchResult2.Album = slice.MapWithArg(als, ctx, childFromAlbum) - searchResult2.Song = slice.MapWithArg(mfs, ctx, childFromMediaFile) + } + searchResult2.Album = childrenFromAlbums(ctx, als) + searchResult2.Song = childrenFromMediaFiles(ctx, mfs) response.SearchResult2 = searchResult2 return response, nil } func (api *Router) Search3(r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - sp, err := api.getSearchParams(r) + sp, err := api.getParams(r) if err != nil { return nil, err } - - // Get optional library IDs from musicFolderId parameter - musicFolderIds, err := selectedMusicFolderIds(r, false) - if err != nil { - return nil, err - } - mfs, als, as := api.searchAll(ctx, sp, musicFolderIds) + mfs, als, as := api.searchAll(ctx, sp) response := newResponse() searchResult3 := &responses.SearchResult3{} - searchResult3.Artist = slice.MapWithArg(as, r, toArtistID3) - searchResult3.Album = slice.MapWithArg(als, ctx, buildAlbumID3) - searchResult3.Song = slice.MapWithArg(mfs, ctx, childFromMediaFile) + searchResult3.Artist = make([]responses.ArtistID3, len(as)) + for i, artist := range as { + searchResult3.Artist[i] = toArtistID3(r, artist) + } + searchResult3.Album = childrenFromAlbums(ctx, als) + searchResult3.Song = childrenFromMediaFiles(ctx, mfs) response.SearchResult3 = searchResult3 return response, nil } diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go deleted file mode 100644 index 177fa2133..000000000 --- a/server/subsonic/searching_test.go +++ /dev/null @@ -1,242 +0,0 @@ -package subsonic - -import ( - "net/http" - - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Search", func() { - var router *Router - var ds model.DataStore - var mockAlbumRepo *tests.MockAlbumRepo - var mockArtistRepo *tests.MockArtistRepo - var mockMediaFileRepo *tests.MockMediaFileRepo - - BeforeEach(func() { - ds = &tests.MockDataStore{} - auth.Init(ds) - - 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().(*tests.MockAlbumRepo) - mockArtistRepo = ds.Artist().(*tests.MockArtistRepo) - mockMediaFileRepo = ds.MediaFile().(*tests.MockMediaFileRepo) - }) - - Context("musicFolderId parameter", func() { - assertQueryOptions := func(filter squirrel.Sqlizer, expectedQuery string, expectedArgs ...any) { - GinkgoHelper() - query, args, err := filter.ToSql() - Expect(err).ToNot(HaveOccurred()) - Expect(query).To(ContainSubstring(expectedQuery)) - Expect(args).To(ContainElements(expectedArgs...)) - } - - Describe("Search2", func() { - It("scopes all entity types to the requested libraries", func() { - // The subsonic layer passes the same library_id filter to all three repos; the - // artist repository translates it to the join-free library_artist predicate itself. - r := newGetRequest("query=test", "musicFolderId=1") - ctx := request.WithUser(r.Context(), model.User{ - ID: "user1", - UserName: "testuser", - Libraries: []model.Library{ - {ID: 1, Name: "Library 1"}, - {ID: 2, Name: "Library 2"}, - }, - }) - r = r.WithContext(ctx) - - resp, err := router.Search2(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp).ToNot(BeNil()) - Expect(resp.SearchResult2).ToNot(BeNil()) - - assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1) - assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) - }) - - It("applies no library filter when musicFolderId is not provided", func() { - r := newGetRequest("query=test") // no musicFolderId → all accessible libraries - ctx := request.WithUser(r.Context(), model.User{ - ID: "user1", - UserName: "testuser", - Libraries: []model.Library{ - {ID: 1, Name: "Library 1"}, - {ID: 2, Name: "Library 2"}, - {ID: 3, Name: "Library 3"}, - }, - }) - r = r.WithContext(ctx) - - resp, err := router.Search2(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp).ToNot(BeNil()) - Expect(resp.SearchResult2).ToNot(BeNil()) - - assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) - assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) - }) - - It("should return empty results when user has no accessible libraries", func() { - r := newGetRequest("query=test") - ctx := request.WithUser(r.Context(), model.User{ - ID: "user1", - UserName: "testuser", - Libraries: []model.Library{}, // No libraries - }) - r = r.WithContext(ctx) - - resp, err := router.Search2(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp).ToNot(BeNil()) - Expect(resp.SearchResult2).ToNot(BeNil()) - Expect(mockAlbumRepo.Options.Filters).To(BeNil()) - Expect(mockArtistRepo.Options.Filters).To(BeNil()) - Expect(mockMediaFileRepo.Options.Filters).To(BeNil()) - }) - - It("should return error for inaccessible musicFolderId", func() { - r := newGetRequest("query=test", "musicFolderId=999") - ctx := request.WithUser(r.Context(), model.User{ - ID: "user1", - UserName: "testuser", - Libraries: []model.Library{{ID: 1, Name: "Library 1"}}, - }) - r = r.WithContext(ctx) - - resp, err := router.Search2(r) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("Library 999 not found or not accessible")) - Expect(resp).To(BeNil()) - }) - }) - - Describe("Search3", func() { - It("scopes all entity types to the requested libraries", func() { - r := newGetRequest("query=test", "musicFolderId=1") - ctx := request.WithUser(r.Context(), model.User{ - ID: "user1", - UserName: "testuser", - Libraries: []model.Library{ - {ID: 1, Name: "Library 1"}, - {ID: 2, Name: "Library 2"}, - }, - }) - r = r.WithContext(ctx) - - resp, err := router.Search3(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp).ToNot(BeNil()) - Expect(resp.SearchResult3).ToNot(BeNil()) - - assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1) - assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) - }) - - It("applies no library filter when musicFolderId is not provided", func() { - r := newGetRequest("query=test") // no musicFolderId → all accessible libraries - ctx := request.WithUser(r.Context(), model.User{ - ID: "user1", - UserName: "testuser", - Libraries: []model.Library{ - {ID: 1, Name: "Library 1"}, - {ID: 2, Name: "Library 2"}, - {ID: 3, Name: "Library 3"}, - }, - }) - r = r.WithContext(ctx) - - resp, err := router.Search3(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp).ToNot(BeNil()) - Expect(resp.SearchResult3).ToNot(BeNil()) - - assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) - assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) - assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) - }) - - It("should return empty results when user has no accessible libraries", func() { - r := newGetRequest("query=test") - ctx := request.WithUser(r.Context(), model.User{ - ID: "user1", - UserName: "testuser", - Libraries: []model.Library{}, // No libraries - }) - r = r.WithContext(ctx) - - resp, err := router.Search3(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp).ToNot(BeNil()) - Expect(resp.SearchResult3).ToNot(BeNil()) - Expect(mockAlbumRepo.Options.Filters).To(BeNil()) - Expect(mockArtistRepo.Options.Filters).To(BeNil()) - Expect(mockMediaFileRepo.Options.Filters).To(BeNil()) - }) - - It("should return error for inaccessible musicFolderId", func() { - // Test that the endpoint returns an error when user tries to access a library they don't have access to - r := newGetRequest("query=test", "musicFolderId=999") - ctx := request.WithUser(r.Context(), model.User{ - ID: "user1", - UserName: "testuser", - Libraries: []model.Library{{ID: 1, Name: "Library 1"}}, - }) - r = r.WithContext(ctx) - - resp, err := router.Search3(r) - - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("Library 999 not found or not accessible")) - Expect(resp).To(BeNil()) - }) - }) - }) - - Describe("artwork emission", func() { - newSearchRequest := func() *http.Request { - r := newGetRequest("query=test") - ctx := request.WithUser(r.Context(), model.User{ID: "user1", Libraries: []model.Library{{ID: 1}}}) - return r.WithContext(ctx) - } - - It("omits coverArt and imageUrl for a known-absent artist", func() { - mockArtistRepo.SetData(model.Artists{ - {ID: "ar-1", Name: "Absent Artist", ItemImage: model.ItemImage{ImageAbsent: true}}, - }) - resp, err := router.Search2(newSearchRequest()) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.SearchResult2.Artist).To(HaveLen(1)) - Expect(resp.SearchResult2.Artist[0].CoverArt).To(BeEmpty()) - Expect(resp.SearchResult2.Artist[0].ArtistImageUrl).To(BeEmpty()) - }) - - It("emits coverArt and imageUrl for an unresolved artist", func() { - mockArtistRepo.SetData(model.Artists{{ID: "ar-1", Name: "Unresolved Artist"}}) - resp, err := router.Search2(newSearchRequest()) - Expect(err).ToNot(HaveOccurred()) - Expect(resp.SearchResult2.Artist).To(HaveLen(1)) - Expect(resp.SearchResult2.Artist[0].CoverArt).To(Equal("ar-ar-1")) - Expect(resp.SearchResult2.Artist[0].ArtistImageUrl).ToNot(BeEmpty()) - }) - }) -}) diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index e5f4f2a54..792ecfc4b 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -1,22 +1,20 @@ package subsonic import ( - "cmp" "net/http" "strings" "time" - "github.com/navidrome/navidrome/conf" + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/public" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils" ) func (api *Router) GetShares(r *http.Request) (*responses.Subsonic, error) { - repo := api.share.Repository() - shares, err := repo.GetAll(r.Context(), model.QueryOptions{Sort: "created_at desc"}) + repo := api.share.NewRepository(r.Context()).(model.ShareRepository) + shares, err := repo.GetAll(model.QueryOptions{Sort: "created_at desc"}) if err != nil { return nil, err } @@ -32,11 +30,11 @@ func (api *Router) GetShares(r *http.Request) (*responses.Subsonic, error) { func (api *Router) buildShare(r *http.Request, share model.Share) responses.Share { resp := responses.Share{ ID: share.ID, - Url: public.ShareURL(r.Context(), share.ID), + Url: public.ShareURL(r, share.ID), Description: share.Description, Username: share.Username, Created: share.CreatedAt, - Expires: share.ExpiresAt, + Expires: &share.ExpiresAt, LastVisited: share.LastVisitedAt, VisitCount: int32(share.VisitCount), } @@ -44,35 +42,35 @@ func (api *Router) buildShare(r *http.Request, share model.Share) responses.Shar resp.Description = share.Contents } if len(share.Albums) > 0 { - resp.Entry = slice.MapWithArg(share.Albums, r.Context(), childFromAlbum) + resp.Entry = childrenFromAlbums(r.Context(), share.Albums) } else { - resp.Entry = slice.MapWithArg(share.Tracks, r.Context(), childFromMediaFile) + resp.Entry = childrenFromMediaFiles(r.Context(), share.Tracks) } return resp } func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - ids := p.Strings("id") + ids := utils.ParamStrings(r, "id") if len(ids) == 0 { - return nil, newError(responses.ErrorMissingParameter, "missing parameter: 'id'") + return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } - description, _ := p.String("description") - repo := api.share.Repository() + description := utils.ParamString(r, "description") + expires := utils.ParamTime(r, "expires", time.Time{}) + + repo := api.share.NewRepository(r.Context()) share := &model.Share{ - Description: description, - Downloadable: p.BoolOr("downloadable", conf.Server.DefaultDownloadableShare && conf.Server.EnableDownloads), - ExpiresAt: new(p.TimeOr("expires", time.Time{})), - ResourceIDs: strings.Join(ids, ","), + Description: description, + ExpiresAt: expires, + ResourceIDs: strings.Join(ids, ","), } - id, err := repo.Save(r.Context(), share) + id, err := repo.(rest.Persistable).Save(share) if err != nil { return nil, err } - share, err = repo.Get(r.Context(), id) + share, err = repo.(model.ShareRepository).Get(id) if err != nil { return nil, err } @@ -83,35 +81,22 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - id, err := p.String("id") - if err != nil { - return nil, err + id := utils.ParamString(r, "id") + if id == "" { + return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } - repo := api.share.Repository() - - // The update always writes description and downloadable, so read back the - // stored value for whichever one the client omitted. - description := p.StringPtr("description") - downloadable := p.BoolPtr("downloadable") - if description == nil || downloadable == nil { - cur, err := repo.Read(r.Context(), id) - if err != nil { - return nil, err - } - description = cmp.Or(description, &cur.Description) - downloadable = cmp.Or(downloadable, &cur.Downloadable) - } + description := utils.ParamString(r, "description") + expires := utils.ParamTime(r, "expires", time.Time{}) + repo := api.share.NewRepository(r.Context()) share := &model.Share{ - ID: id, - Description: *description, - Downloadable: *downloadable, - ExpiresAt: new(p.TimeOr("expires", time.Time{})), + ID: id, + Description: description, + ExpiresAt: expires, } - err = repo.Update(r.Context(), id, *share) + err := repo.(rest.Persistable).Update(id, share) if err != nil { return nil, err } @@ -120,14 +105,13 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) { } func (api *Router) DeleteShare(r *http.Request) (*responses.Subsonic, error) { - p := req.Params(r) - id, err := p.String("id") - if err != nil { - return nil, err + id := utils.ParamString(r, "id") + if id == "" { + return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") } - repo := api.share.Repository() - err = repo.Delete(r.Context(), id) + repo := api.share.NewRepository(r.Context()) + err := repo.(rest.Persistable).Delete(id) if err != nil { return nil, err } diff --git a/server/subsonic/sonic_similarity.go b/server/subsonic/sonic_similarity.go deleted file mode 100644 index 3640a116b..000000000 --- a/server/subsonic/sonic_similarity.go +++ /dev/null @@ -1,69 +0,0 @@ -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/stream.go b/server/subsonic/stream.go index fd26ccc4d..1b01a2025 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -2,45 +2,70 @@ package subsonic import ( "context" - "errors" + "fmt" + "io" "net/http" "strconv" + "strings" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/str" + "github.com/navidrome/navidrome/utils" ) +func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *core.Stream, id string) { + if stream.Seekable() { + http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream) + } else { + // If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length + w.Header().Set("Accept-Ranges", "none") + w.Header().Set("Content-Type", stream.ContentType()) + + estimateContentLength := utils.ParamBool(r, "estimateContentLength", false) + + // if Client requests the estimated content-length, send it + if estimateContentLength { + length := strconv.Itoa(stream.EstimatedContentLength()) + log.Trace(ctx, "Estimated content-length", "contentLength", length) + w.Header().Set("Content-Length", length) + } + + if r.Method == http.MethodHead { + go func() { _, _ = io.Copy(io.Discard, stream) }() + } else { + c, err := io.Copy(w, stream) + if log.CurrentLevel() >= log.LevelDebug { + if err != nil { + log.Error(ctx, "Error sending transcoded file", "id", id, err) + } else { + log.Trace(ctx, "Success sending transcode file", "id", id, "size", c) + } + } + } + } +} + func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() - p := req.Params(r) - id, err := p.String("id") + id, err := requiredParamString(r, "id") if err != nil { return nil, err } - maxBitRate := p.IntOr("maxBitRate", 0) - format, _ := p.String("format") - timeOffset := p.IntOr("timeOffset", 0) + maxBitRate := utils.ParamInt(r, "maxBitRate", 0) + format := utils.ParamString(r, "format") - mf, err := api.ds.MediaFile().Get(ctx, id) - if err != nil { - return nil, err - } - - streamReq := api.transcodeDecision.ResolveRequest(ctx, mf, format, maxBitRate, timeOffset) - stream, err := api.streamer.NewStream(ctx, mf, streamReq) + stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate) if err != nil { return nil, err } // Make sure the stream will be closed at the end, to avoid leakage defer func() { - if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { + if err := stream.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug { log.Error("Error closing stream", "id", id, "file", stream.Name(), err) } }() @@ -48,15 +73,15 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32)) - _, err = stream.Serve(ctx, w, r) - return nil, err + api.serveStream(ctx, w, r, stream, id) + + return nil, nil } func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() username, _ := request.UsernameFrom(ctx) - p := req.Params(r) - id, err := p.String("id") + id, err := requiredParamString(r, "id") if err != nil { return nil, err } @@ -71,8 +96,8 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. return nil, err } - maxBitRate := p.IntOr("bitrate", 0) - format, _ := p.String("format") + maxBitRate := utils.ParamInt(r, "bitrate", 0) + format := utils.ParamString(r, "format") if format == "" { if conf.Server.AutoTranscodeDownload { @@ -93,53 +118,43 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. } setHeaders := func(name string) { - w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(name+".zip")) + name = strings.ReplaceAll(name, ",", "_") + disposition := fmt.Sprintf("attachment; filename=\"%s.zip\"", name) + w.Header().Set("Content-Disposition", disposition) w.Header().Set("Content-Type", "application/zip") } switch v := entity.(type) { case *model.MediaFile: - streamReq := api.transcodeDecision.ResolveRequest(ctx, v, format, maxBitRate, 0) - stream, err := api.streamer.NewStream(ctx, v, streamReq) + stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate) if err != nil { return nil, err } // Make sure the stream will be closed at the end, to avoid leakage defer func() { - if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { + if err := stream.Close(); err != nil && log.CurrentLevel() >= log.LevelDebug { log.Error("Error closing stream", "id", id, "file", stream.Name(), err) } }() - w.Header().Set("Content-Disposition", str.ContentDispositionAttachment(stream.Name())) + disposition := fmt.Sprintf("attachment; filename=\"%s\"", stream.Name()) + w.Header().Set("Content-Disposition", disposition) - _, err = stream.Serve(ctx, w, r) - return nil, err + api.serveStream(ctx, w, r, stream, id) + return nil, nil case *model.Album: setHeaders(v.Name) - return nil, handleArchiveErr(ctx, id, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w)) + err = api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w) case *model.Artist: setHeaders(v.Name) - return nil, handleArchiveErr(ctx, id, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w)) + err = api.archiver.ZipArtist(ctx, id, format, maxBitRate, w) case *model.Playlist: setHeaders(v.Name) - return nil, handleArchiveErr(ctx, id, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w)) + err = api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w) default: - return nil, model.ErrNotFound + err = model.ErrNotFound } -} -// handleArchiveErr swallows ErrTooManyTranscodes from archive downloads so the -// outer error handler does not try to write a 429 onto a response whose status -// and Content-Disposition have already been flushed. The archive ends up with -// the tracks that were written before the rejection (the rejected track and -// any following ones are omitted); the server-side log is the unambiguous -// signal operators can act on. -func handleArchiveErr(ctx context.Context, id string, err error) error { - if errors.Is(err, stream.ErrTooManyTranscodes) { - log.Warn(ctx, "Archive download finalized early: transcode cap reached", "id", id, err) - return nil - } - return err + return nil, err } diff --git a/server/subsonic/system.go b/server/subsonic/system.go index 4a59e7898..e14099942 100644 --- a/server/subsonic/system.go +++ b/server/subsonic/system.go @@ -3,7 +3,6 @@ package subsonic import ( "net/http" - "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" ) @@ -16,10 +15,3 @@ func (api *Router) GetLicense(_ *http.Request) (*responses.Subsonic, error) { response.License = &responses.License{Valid: true} return response, nil } - -func (api *Router) TokenInfo(r *http.Request) (*responses.Subsonic, error) { - user, _ := request.UserFrom(r.Context()) - response := newResponse() - response.TokenInfo = &responses.TokenInfo{Username: user.UserName} - return response, nil -} diff --git a/server/subsonic/system_test.go b/server/subsonic/system_test.go deleted file mode 100644 index a8c85238e..000000000 --- a/server/subsonic/system_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package subsonic - -import ( - "net/http/httptest" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("TokenInfo", func() { - It("returns the authenticated username", func() { - api := &Router{} - r := httptest.NewRequest("GET", "/tokenInfo", nil) - r = r.WithContext(request.WithUser(r.Context(), model.User{UserName: "deluan"})) - - resp, err := api.TokenInfo(r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.TokenInfo.Username).To(Equal("deluan")) - }) -}) diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go deleted file mode 100644 index 7a011a616..000000000 --- a/server/subsonic/transcode.go +++ /dev/null @@ -1,449 +0,0 @@ -package subsonic - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "slices" - "strconv" - - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" -) - -// API-layer request structs for JSON unmarshaling (decoupled from core structs) - -// clientInfoRequest represents client playback capabilities from the request body -type clientInfoRequest struct { - Name string `json:"name,omitempty"` - Platform string `json:"platform,omitempty"` - MaxAudioBitrate int `json:"maxAudioBitrate,omitempty"` - MaxTranscodingAudioBitrate int `json:"maxTranscodingAudioBitrate,omitempty"` - DirectPlayProfiles []directPlayProfileRequest `json:"directPlayProfiles,omitempty"` - TranscodingProfiles []transcodingProfileRequest `json:"transcodingProfiles,omitempty"` - CodecProfiles []codecProfileRequest `json:"codecProfiles,omitempty"` -} - -// directPlayProfileRequest describes a format the client can play directly -type directPlayProfileRequest struct { - Containers []string `json:"containers,omitempty"` - AudioCodecs []string `json:"audioCodecs,omitempty"` - Protocols []string `json:"protocols,omitempty"` - MaxAudioChannels int `json:"maxAudioChannels,omitempty"` -} - -// transcodingProfileRequest describes a transcoding target the client supports -type transcodingProfileRequest struct { - Container string `json:"container,omitempty"` - AudioCodec string `json:"audioCodec,omitempty"` - Protocol string `json:"protocol,omitempty"` - MaxAudioChannels int `json:"maxAudioChannels,omitempty"` -} - -// codecProfileRequest describes codec-specific limitations -type codecProfileRequest struct { - Type string `json:"type,omitempty"` - Name string `json:"name,omitempty"` - Limitations []limitationRequest `json:"limitations,omitempty"` -} - -// limitationRequest describes a specific codec limitation -type limitationRequest struct { - Name string `json:"name,omitempty"` - Comparison string `json:"comparison,omitempty"` - Values []string `json:"values,omitempty"` - Required bool `json:"required,omitempty"` -} - -// toCoreClientInfo converts the API request struct to the stream.ClientInfo struct. -// The OpenSubsonic spec uses bps for bitrate values; core uses kbps. -func (r *clientInfoRequest) toCoreClientInfo() *stream.ClientInfo { - ci := &stream.ClientInfo{ - Name: r.Name, - Platform: r.Platform, - MaxAudioBitrate: bpsToKbps(r.MaxAudioBitrate), - MaxTranscodingAudioBitrate: bpsToKbps(r.MaxTranscodingAudioBitrate), - } - - for _, dp := range r.DirectPlayProfiles { - ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, stream.DirectPlayProfile{ - Containers: dp.Containers, - AudioCodecs: dp.AudioCodecs, - Protocols: dp.Protocols, - MaxAudioChannels: dp.MaxAudioChannels, - }) - } - - for _, tp := range r.TranscodingProfiles { - ci.TranscodingProfiles = append(ci.TranscodingProfiles, stream.Profile{ - Container: tp.Container, - AudioCodec: tp.AudioCodec, - Protocol: tp.Protocol, - MaxAudioChannels: tp.MaxAudioChannels, - }) - } - - for _, cp := range r.CodecProfiles { - coreCP := stream.CodecProfile{ - Type: cp.Type, - Name: cp.Name, - } - for _, lim := range cp.Limitations { - coreLim := stream.Limitation{ - Name: lim.Name, - Comparison: lim.Comparison, - Values: lim.Values, - Required: lim.Required, - } - // Convert audioBitrate limitation values from bps to kbps - if lim.Name == stream.LimitationAudioBitrate { - coreLim.Values = convertBitrateValues(lim.Values) - } - coreCP.Limitations = append(coreCP.Limitations, coreLim) - } - ci.CodecProfiles = append(ci.CodecProfiles, coreCP) - } - - return ci -} - -// bpsToKbps converts bits per second to kilobits per second (rounded). -func bpsToKbps(bps int) int { - if bps < 0 { - return 0 - } - return (bps + 500) / 1000 -} - -// kbpsToBps converts kilobits per second to bits per second. -func kbpsToBps(kbps int) int { - return kbps * 1000 -} - -// convertBitrateValues converts a slice of bps string values to kbps string values. -func convertBitrateValues(bpsValues []string) []string { - result := make([]string, len(bpsValues)) - for i, v := range bpsValues { - n, err := strconv.Atoi(v) - if err == nil { - result[i] = strconv.Itoa(bpsToKbps(n)) - } else { - result[i] = v // preserve unparseable values as-is - } - } - return result -} - -// validate checks that all enum fields in the request contain valid values per the OpenSubsonic spec. -func (r *clientInfoRequest) validate() error { - for _, dp := range r.DirectPlayProfiles { - for _, p := range dp.Protocols { - if !isValidProtocol(p) { - return fmt.Errorf("invalid protocol: %s", p) - } - } - } - for _, tp := range r.TranscodingProfiles { - if tp.Protocol != "" && !isValidProtocol(tp.Protocol) { - return fmt.Errorf("invalid protocol: %s", tp.Protocol) - } - } - for _, cp := range r.CodecProfiles { - if !isValidCodecProfileType(cp.Type) { - return fmt.Errorf("invalid codec profile type: %s", cp.Type) - } - for _, lim := range cp.Limitations { - if !isValidLimitationName(lim.Name) { - return fmt.Errorf("invalid limitation name: %s", lim.Name) - } - if !isValidComparison(lim.Comparison) { - return fmt.Errorf("invalid comparison: %s", lim.Comparison) - } - } - } - return nil -} - -// Only support songs for now -var validMediaTypes = []string{ - "song", -} - -func isValidMediaType(mediaType string) bool { - return slices.Contains(validMediaTypes, mediaType) -} - -var validProtocols = []string{ - stream.ProtocolHTTP, - stream.ProtocolHLS, -} - -func isValidProtocol(p string) bool { - return slices.Contains(validProtocols, p) -} - -var validCodecProfileTypes = []string{ - stream.CodecProfileTypeAudio, -} - -func isValidCodecProfileType(t string) bool { - return slices.Contains(validCodecProfileTypes, t) -} - -var validLimitationNames = []string{ - stream.LimitationAudioChannels, - stream.LimitationAudioBitrate, - stream.LimitationAudioProfile, - stream.LimitationAudioSamplerate, - stream.LimitationAudioBitdepth, -} - -func isValidLimitationName(n string) bool { - return slices.Contains(validLimitationNames, n) -} - -var validComparisons = []string{ - stream.ComparisonEquals, - stream.ComparisonNotEquals, - stream.ComparisonLessThanEqual, - stream.ComparisonGreaterThanEqual, -} - -func isValidComparison(c string) bool { - return slices.Contains(validComparisons, c) -} - -// toResponseStreamDetails converts a core StreamDetails to the API response type. -func toResponseStreamDetails(sd *stream.Details) *responses.StreamDetails { - return &responses.StreamDetails{ - Protocol: stream.ProtocolHTTP, // TODO: derive from decision when HLS support is added - Container: sd.Container, - Codec: sd.Codec, - AudioBitrate: int32(kbpsToBps(sd.Bitrate)), - AudioProfile: sd.Profile, - AudioSamplerate: int32(sd.SampleRate), - AudioBitdepth: int32(sd.BitDepth), - AudioChannels: int32(sd.Channels), - } -} - -// GetTranscodeDecision handles the OpenSubsonic getTranscodeDecision endpoint. -// It receives client capabilities and returns a decision on whether to direct play or stream. -func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { - if r.Method != http.MethodPost { - w.Header().Set("Allow", "POST") - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - return nil, nil - } - - ctx := r.Context() - p := req.Params(r) - - mediaID, err := p.String("mediaId") - if err != nil { - return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaId") - } - - mediaType, err := p.String("mediaType") - if err != nil { - return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaType") - } - - if !isValidMediaType(mediaType) { - return nil, newError(responses.ErrorGeneric, "mediaType '%s' is not yet supported", mediaType) - } - - // Parse and validate ClientInfo from request body (required per OpenSubsonic spec) - var clientInfoReq clientInfoRequest - r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB limit - if err := json.NewDecoder(r.Body).Decode(&clientInfoReq); err != nil { - return nil, newError(responses.ErrorGeneric, "invalid JSON request body") - } - if err := clientInfoReq.validate(); err != nil { - return nil, newError(responses.ErrorGeneric, "%v", err) - } - clientInfo := clientInfoReq.toCoreClientInfo() - - // TODO: Remove this filter once AAC transcoding works reliably - // with streaming clients (Sonos, etc). - // See https://github.com/navidrome/navidrome/discussions/4832#discussioncomment-16068231 - clientInfo.TranscodingProfiles = slices.DeleteFunc(clientInfo.TranscodingProfiles, func(p stream.Profile) bool { - if p.AudioCodec != "" { - return stream.IsAACCodec(p.AudioCodec) - } - return stream.IsAACCodec(p.Container) - }) - - player, hasPlayer := request.PlayerFrom(ctx) - - // Honor the player's forced transcoding format, falling back to normal - // negotiation when the client can't play it (issue #5583). - maxBitRate := 0 - if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { - if clientInfo.ForceFormat(trc.TargetFormat) { - // DirectPlayProfile carries no bitrate, so this ceiling is the only - // thing keeping an over-bitrate source out of direct play. - maxBitRate = trc.DefaultBitRate - } else { - clientName := clientInfo.Name - if hasPlayer && player.Client != "" { - clientName = player.Client - } - log.Debug(ctx, "Player forced format not supported by client; falling back to negotiation", - "forcedFormat", trc.TargetFormat, "client", clientName) - } - } - - // The player's own MaxBitRate outranks the forced-format default (issue #5583). - if hasPlayer && player.MaxBitRate > 0 { - maxBitRate = player.MaxBitRate - } - if clientInfo.CapBitrate(maxBitRate) { - log.Debug(ctx, "Applied bitrate ceiling to transcode decision", - "maxBitRate", maxBitRate, "client", clientInfo.Name) - } - - // Get media file - mf, err := api.ds.MediaFile().Get(ctx, mediaID) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - return nil, newError(responses.ErrorDataNotFound, "media file not found: %s", mediaID) - } - log.Error(ctx, "Error retrieving media file", "mediaID", mediaID, err) - return nil, newError(responses.ErrorGeneric, "error retrieving media file") - } - - // Make the decision - decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, stream.TranscodeOptions{}) - if err != nil { - log.Error(ctx, "Failed to make transcode decision", "mediaID", mediaID, err) - code, reason := transcodeFailure(err) - return nil, newError(code, "failed to make transcode decision: %s", reason) - } - - // Only create a token when there is a valid playback path - var transcodeParams string - if decision.CanDirectPlay || decision.CanTranscode { - transcodeParams, err = api.transcodeDecision.CreateTranscodeParams(decision) - if err != nil { - log.Error(ctx, "Failed to create transcode token", "mediaID", mediaID, err) - return nil, newError(responses.ErrorGeneric, "failed to create transcode token") - } - } - - // Build response (convert kbps from core to bps for the API) - response := newResponse() - response.TranscodeDecision = &responses.TranscodeDecision{ - CanDirectPlay: decision.CanDirectPlay, - CanTranscode: decision.CanTranscode, - TranscodeReasons: decision.TranscodeReasons, - ErrorReason: decision.ErrorReason, - TranscodeParams: transcodeParams, - SourceStream: toResponseStreamDetails(&decision.SourceStream), - } - - if decision.TranscodeStream != nil { - response.TranscodeDecision.TranscodeStream = toResponseStreamDetails(decision.TranscodeStream) - } - - return response, nil -} - -// transcodeFailure maps a decision error to a Subsonic error code and a reason -// safe to send to clients, omitting server file paths. -func transcodeFailure(err error) (int32, string) { - pe, ok := errors.AsType[*ffmpeg.ProbeError](err) - if !ok { - return responses.ErrorGeneric, "internal error" - } - if pe.NotFound { - return responses.ErrorDataNotFound, pe.SafeReason() - } - return responses.ErrorGeneric, pe.SafeReason() -} - -// GetTranscodeStream handles the OpenSubsonic getTranscodeStream endpoint. -// It streams media using the decision encoded in the transcodeParams JWT token. -// All errors are returned as proper HTTP status codes (not Subsonic error responses). -func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { - ctx := r.Context() - p := req.Params(r) - - mediaID, err := p.String("mediaId") - if err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) - return nil, nil - } - - mediaType, err := p.String("mediaType") - if err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) - return nil, nil - } - - transcodeParamsToken, err := p.String("transcodeParams") - if err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) - return nil, nil - } - - if !isValidMediaType(mediaType) { - http.Error(w, "Bad Request", http.StatusBadRequest) - return nil, nil - } - - // Fetch the media file - mf, err := api.ds.MediaFile().Get(ctx, mediaID) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - http.Error(w, "Not Found", http.StatusNotFound) - } else { - log.Error(ctx, "Error retrieving media file", "mediaID", mediaID, err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } - return nil, nil - } - - // Validate the token and resolve streaming parameters - streamReq, err := api.transcodeDecision.ResolveRequestFromToken(ctx, transcodeParamsToken, mf, p.IntOr("offset", 0)) - if err != nil { - switch { - case errors.Is(err, stream.ErrTokenInvalid), errors.Is(err, stream.ErrTokenStale): - log.Warn(ctx, "Invalid or stale transcode token", "mediaID", mediaID, err) - http.Error(w, "Gone", http.StatusGone) - default: - log.Error(ctx, "Error validating transcode params", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } - return nil, nil - } - - // Create stream - stream, err := api.streamer.NewStream(ctx, mf, streamReq) - if err != nil { - log.Error(ctx, "Error creating stream", "mediaID", mediaID, err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return nil, nil - } - - // Make sure the stream will be closed at the end - defer func() { - if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { - log.Error("Error closing stream", "id", mediaID, "file", stream.Name(), err) - } - }() - - w.Header().Set("X-Content-Type-Options", "nosniff") - - n, err := stream.Serve(ctx, w, r) - if err != nil || n == 0 { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } - return nil, nil -} diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go deleted file mode 100644 index 1f2fdfcef..000000000 --- a/server/subsonic/transcode_test.go +++ /dev/null @@ -1,656 +0,0 @@ -package subsonic - -import ( - "bytes" - "context" - "errors" - "fmt" - "io/fs" - "net/http" - "net/http/httptest" - - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Transcode endpoints", func() { - var ( - router *Router - ds *tests.MockDataStore - mockTD *mockTranscodeDecision - w *httptest.ResponseRecorder - mockMFRepo *tests.MockMediaFileRepo - ) - - BeforeEach(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, nil) - w = httptest.NewRecorder() - }) - - Describe("GetTranscodeDecision", func() { - It("returns 405 for non-POST requests", func() { - r := newGetRequest("mediaId=123", "mediaType=song") - resp, err := router.GetTranscodeDecision(w, r) - Expect(err).ToNot(HaveOccurred()) - Expect(resp).To(BeNil()) - Expect(w.Code).To(Equal(http.StatusMethodNotAllowed)) - Expect(w.Header().Get("Allow")).To(Equal("POST")) - }) - - It("returns error when mediaId is missing", func() { - r := newJSONPostRequest("mediaType=song", "{}") - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - }) - - It("returns error when mediaType is missing", func() { - r := newJSONPostRequest("mediaId=123", "{}") - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - }) - - It("returns error for unsupported mediaType", func() { - r := newJSONPostRequest("mediaId=123&mediaType=podcast", "{}") - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("not yet supported")) - }) - - It("returns ErrorDataNotFound when media file does not exist", func() { - // mockMFRepo has no data set, so Get() returns model.ErrNotFound - r := newJSONPostRequest("mediaId=nonexistent&mediaType=song", "{}") - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("media file not found")) - }) - - It("returns error when media file retrieval fails", func() { - mockMFRepo.SetError(true) - r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("error retrieving media file")) - }) - - It("enriches the decision error with the reason, without leaking the file path", func() { - mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) - mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w", - &ffmpeg.ProbeError{Path: "/music/secret/foo.flac", Reason: "the file: Invalid data found when processing input"}) - r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("failed to make transcode decision")) - Expect(err.Error()).To(ContainSubstring("Invalid data found when processing input")) - Expect(err.Error()).ToNot(ContainSubstring("/music/secret")) - var subErr subError - Expect(errors.As(err, &subErr)).To(BeTrue()) - Expect(subErr.code).To(Equal(responses.ErrorGeneric)) - }) - - It("returns ErrorDataNotFound when the source file is missing on disk", func() { - mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) - mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w", - &ffmpeg.ProbeError{Path: "/music/gone.flac", Reason: "file not found", NotFound: true}) - r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("file not found")) - var subErr subError - Expect(errors.As(err, &subErr)).To(BeTrue()) - Expect(subErr.code).To(Equal(responses.ErrorDataNotFound)) - }) - - It("keeps ErrorGeneric when ffprobe is missing, even though the cause wraps fs.ErrNotExist", func() { - mockMFRepo.SetData(model.MediaFiles{{ID: "song-1", Suffix: "flac"}}) - pe := &ffmpeg.ProbeError{Path: "/music/song.flac", Reason: "could not read file"} - mockTD.decisionErr = fmt.Errorf("probing media file song-1: %w (%w)", pe, fs.ErrNotExist) - Expect(errors.Is(mockTD.decisionErr, fs.ErrNotExist)).To(BeTrue()) - r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - var subErr subError - Expect(errors.As(err, &subErr)).To(BeTrue()) - Expect(subErr.code).To(Equal(responses.ErrorGeneric)) - }) - - It("returns error when body is empty", func() { - r := newJSONPostRequest("mediaId=song-1&mediaType=song", "") - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - }) - - It("returns error when body contains invalid JSON", func() { - r := newJSONPostRequest("mediaId=song-1&mediaType=song", "not-json{{{") - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - }) - - It("returns error for invalid protocol in direct play profile", func() { - body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["ftp"]}]}` - r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid protocol")) - }) - - It("returns error for invalid comparison operator", func() { - body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"InvalidOp","values":["320"]}]}]}` - r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid comparison")) - }) - - It("returns error for invalid limitation name", func() { - body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"unknownField","comparison":"Equals","values":["320"]}]}]}` - r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid limitation name")) - }) - - It("returns error for invalid codec profile type", func() { - body := `{"codecProfiles":[{"type":"VideoCodec","name":"mp3"}]}` - r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid codec profile type")) - }) - - It("rejects wrong-case protocol", func() { - body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["HTTP"]}]}` - r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid protocol")) - }) - - It("rejects wrong-case codec profile type", func() { - body := `{"codecProfiles":[{"type":"audiocodec","name":"mp3"}]}` - r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid codec profile type")) - }) - - It("rejects wrong-case comparison operator", func() { - body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"lessthanequal","values":["320"]}]}]}` - r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid comparison")) - }) - - It("rejects wrong-case limitation name", func() { - body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"AudioBitrate","comparison":"Equals","values":["320"]}]}]}` - r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) - _, err := router.GetTranscodeDecision(w, r) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("invalid limitation name")) - }) - - It("returns a valid decision response", func() { - mockMFRepo.SetData(model.MediaFiles{ - {ID: "song-1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}, - }) - mockTD.decision = &stream.TranscodeDecision{ - MediaID: "song-1", - CanDirectPlay: true, - SourceStream: stream.Details{ - Container: "mp3", Codec: "mp3", Bitrate: 320, - SampleRate: 44100, Channels: 2, - }, - } - mockTD.token = "test-jwt-token" - - body := `{"directPlayProfiles":[{"containers":["mp3"],"protocols":["http"]}]}` - r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) - resp, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeParams).To(Equal("test-jwt-token")) - Expect(resp.TranscodeDecision.SourceStream).ToNot(BeNil()) - Expect(resp.TranscodeDecision.SourceStream.Protocol).To(Equal("http")) - Expect(resp.TranscodeDecision.SourceStream.Container).To(Equal("mp3")) - Expect(resp.TranscodeDecision.SourceStream.AudioBitrate).To(Equal(int32(320_000))) - }) - - It("filters AAC from transcoding profiles", func() { - mockMFRepo.SetData(model.MediaFiles{ - {ID: "song-1", Suffix: "opus", Codec: "opus", BitRate: 128, Channels: 2, SampleRate: 48000}, - }) - mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanDirectPlay: true} - mockTD.token = "token" - - body := `{ - "transcodingProfiles": [ - {"container": "aac", "audioCodec": "aac", "protocol": "http"}, - {"container": "mp3", "audioCodec": "mp3", "protocol": "http"}, - {"container": "m4a", "audioCodec": "aac", "protocol": "http"} - ] - }` - r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) - _, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mockTD.capturedClient).ToNot(BeNil()) - Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1)) - Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("mp3")) - }) - - It("includes transcode stream when transcoding", func() { - mockMFRepo.SetData(model.MediaFiles{ - {ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: new(24)}, - }) - mockTD.decision = &stream.TranscodeDecision{ - MediaID: "song-2", - CanDirectPlay: false, - CanTranscode: true, - TargetFormat: "mp3", - TargetBitrate: 256, - TranscodeReasons: []string{"container not supported"}, - SourceStream: stream.Details{ - Container: "flac", Codec: "flac", Bitrate: 1000, - SampleRate: 96000, BitDepth: 24, Channels: 2, - }, - TranscodeStream: &stream.Details{ - Container: "mp3", Codec: "mp3", Bitrate: 256, - SampleRate: 96000, Channels: 2, - }, - } - mockTD.token = "transcode-token" - - r := newJSONPostRequest("mediaId=song-2&mediaType=song", "{}") - resp, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeReasons).To(ConsistOf("container not supported")) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) - }) - - Describe("player MaxBitRate cap", func() { - withPlayer := func(r *http.Request, maxBitRate int) *http.Request { - ctx := request.WithPlayer(r.Context(), model.Player{Client: "NavidromeUI", MaxBitRate: maxBitRate}) - return r.WithContext(ctx) - } - - BeforeEach(func() { - mockMFRepo.SetData(model.MediaFiles{ - {ID: "song-1", Suffix: "flac", Codec: "FLAC", BitRate: 900, Channels: 2, SampleRate: 44100}, - }) - mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanDirectPlay: true} - mockTD.token = "token" - }) - - It("caps client MaxAudioBitrate at the player MaxBitRate when client declares none", func() { - body := `{"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` - r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 320) - - _, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mockTD.capturedClient).ToNot(BeNil()) - Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) - Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(320)) - }) - - It("does not raise a lower client-declared limit", func() { - // Client declares 192 kbps (192000 bps); player cap is 320 — client wins. - body := `{"maxAudioBitrate":192000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` - r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 320) - - _, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192)) - }) - - It("lowers a higher client-declared limit to the player cap", func() { - // Client declares 320 kbps (320000 bps); player cap is 192 — player wins. - body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` - r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 192) - - _, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192)) - Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192)) - }) - - It("does nothing when no player is in context", func() { - body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` - r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) - - _, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) - }) - - It("does nothing when player MaxBitRate is 0", func() { - body := `{"maxAudioBitrate":320000,"directPlayProfiles":[{"containers":["flac"],"protocols":["http"]}]}` - r := withPlayer(newJSONPostRequest("mediaId=song-1&mediaType=song", body), 0) - - _, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) - }) - }) - - Describe("player forced format", func() { - withForcedFormat := func(r *http.Request, format string, maxBitRate int) *http.Request { - ctx := r.Context() - ctx = request.WithTranscoding(ctx, model.Transcoding{TargetFormat: format}) - if maxBitRate > 0 { - ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: maxBitRate}) - } - return r.WithContext(ctx) - } - - BeforeEach(func() { - mockMFRepo.SetData(model.MediaFiles{ - {ID: "song-1", Suffix: "flac", Codec: "FLAC", BitRate: 900, Channels: 2, SampleRate: 44100}, - }) - mockTD.decision = &stream.TranscodeDecision{MediaID: "song-1", CanTranscode: true} - mockTD.token = "token" - }) - - It("forces a supported format and narrows direct play to it", func() { - body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}], - "transcodingProfiles":[{"container":"ogg","audioCodec":"opus","protocol":"http"}, - {"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` - r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 0) - - _, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1)) - Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) - Expect(mockTD.capturedClient.DirectPlayProfiles).To(ConsistOf(stream.DirectPlayProfile{ - Containers: []string{"ogg"}, - AudioCodecs: []string{"opus"}, - Protocols: []string{"http"}, - })) - }) - - It("falls back to negotiation when the forced format is unsupported", func() { - // Forced format is opus, but the client only declares mp3 and flac. - // Should fall back to negotiating among the client's own profiles. - body := `{"directPlayProfiles":[{"containers":["flac"],"audioCodecs":["flac"],"protocols":["http"]}], - "transcodingProfiles":[ - {"container":"flac","audioCodec":"flac","protocol":"http"}, - {"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` - r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 0) - - _, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - // Profiles left intact for normal negotiation (forced format not applied). - Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(2)) - Expect(mockTD.capturedClient.DirectPlayProfiles).ToNot(BeEmpty()) - }) - - It("applies the maxBitRate cap on top of the forced format", func() { - // Client supports opus + mp3; forced format opus must be selected, - // and the maxBitRate cap applied on top. - body := `{"transcodingProfiles":[ - {"container":"ogg","audioCodec":"opus","protocol":"http"}, - {"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` - r := withForcedFormat(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 128) - - _, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mockTD.capturedClient.TranscodingProfiles).To(HaveLen(1)) - Expect(mockTD.capturedClient.TranscodingProfiles[0].AudioCodec).To(Equal("opus")) - Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(128)) - Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(128)) - }) - - withForcedBitRate := func(r *http.Request, format string, defaultBitRate, playerMaxBitRate int) *http.Request { - ctx := request.WithTranscoding(r.Context(), model.Transcoding{TargetFormat: format, DefaultBitRate: defaultBitRate}) - ctx = request.WithPlayer(ctx, model.Player{Client: "NavidromeUI", MaxBitRate: playerMaxBitRate}) - return r.WithContext(ctx) - } - - It("applies the transcoding default bitrate when the player sets no maxBitRate", func() { - body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` - r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 0) - - _, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(192)) - Expect(mockTD.capturedClient.MaxTranscodingAudioBitrate).To(Equal(192)) - }) - - It("prefers the player maxBitRate over the transcoding default bitrate", func() { - body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` - r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "mp3", 192, 320) - - _, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mockTD.capturedClient.MaxAudioBitrate).To(Equal(320)) - }) - - It("ignores the transcoding default bitrate when the forced format is unsupported", func() { - body := `{"transcodingProfiles":[{"container":"mp3","audioCodec":"mp3","protocol":"http"}]}` - r := withForcedBitRate(newJSONPostRequest("mediaId=song-1&mediaType=song", body), "opus", 192, 0) - - _, err := router.GetTranscodeDecision(w, r) - - Expect(err).ToNot(HaveOccurred()) - Expect(mockTD.capturedClient.MaxAudioBitrate).To(BeZero()) - }) - }) - }) - - Describe("GetTranscodeStream", func() { - It("returns 400 when mediaId is missing", func() { - r := newGetRequest("mediaType=song", "transcodeParams=abc") - resp, err := router.GetTranscodeStream(w, r) - Expect(err).ToNot(HaveOccurred()) - Expect(resp).To(BeNil()) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns 400 when transcodeParams is missing", func() { - r := newGetRequest("mediaId=123", "mediaType=song") - resp, err := router.GetTranscodeStream(w, r) - Expect(err).ToNot(HaveOccurred()) - Expect(resp).To(BeNil()) - Expect(w.Code).To(Equal(http.StatusBadRequest)) - }) - - It("returns 410 for invalid or mismatched token", func() { - mockMFRepo.SetData(model.MediaFiles{{ID: "123"}}) - mockTD.resolveErr = stream.ErrTokenInvalid - r := newGetRequest("mediaId=123", "mediaType=song", "transcodeParams=bad-token") - resp, err := router.GetTranscodeStream(w, r) - Expect(err).ToNot(HaveOccurred()) - Expect(resp).To(BeNil()) - Expect(w.Code).To(Equal(http.StatusGone)) - }) - - It("returns 404 when media file not found", func() { - // mockMFRepo has no data, so Get() returns ErrNotFound - r := newGetRequest("mediaId=gone-id", "mediaType=song", "transcodeParams=valid-token") - resp, err := router.GetTranscodeStream(w, r) - Expect(err).ToNot(HaveOccurred()) - Expect(resp).To(BeNil()) - Expect(w.Code).To(Equal(http.StatusNotFound)) - }) - - It("returns 410 when media file has changed (stale token)", func() { - mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}}) - mockTD.resolveErr = stream.ErrTokenStale - r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=stale-token") - resp, err := router.GetTranscodeStream(w, r) - Expect(err).ToNot(HaveOccurred()) - Expect(resp).To(BeNil()) - Expect(w.Code).To(Equal(http.StatusGone)) - }) - - 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, nil) - mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}}) - mockTD.resolvedReq = stream.Request{} - - r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=valid-token") - _, _ = router.GetTranscodeStream(w, r) - - Expect(fakeStreamer.captured).ToNot(BeNil()) - Expect(fakeStreamer.captured.Format).To(BeEmpty()) - Expect(fakeStreamer.captured.BitRate).To(BeZero()) - Expect(fakeStreamer.captured.SampleRate).To(BeZero()) - Expect(fakeStreamer.captured.BitDepth).To(BeZero()) - Expect(fakeStreamer.captured.Channels).To(BeZero()) - }) - - 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, nil) - mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}}) - mockTD.resolvedReq = stream.Request{ - Format: "mp3", - BitRate: 256, - SampleRate: 44100, - BitDepth: 16, - Channels: 2, - } - - r := newGetRequest("mediaId=song-2", "mediaType=song", "transcodeParams=valid-token", "offset=10") - _, _ = router.GetTranscodeStream(w, r) - - Expect(fakeStreamer.captured).ToNot(BeNil()) - Expect(fakeStreamer.captured.Format).To(Equal("mp3")) - Expect(fakeStreamer.captured.BitRate).To(Equal(256)) - Expect(fakeStreamer.captured.SampleRate).To(Equal(44100)) - Expect(fakeStreamer.captured.BitDepth).To(Equal(16)) - Expect(fakeStreamer.captured.Channels).To(Equal(2)) - Expect(fakeStreamer.captured.Offset).To(Equal(10)) - }) - }) - - Describe("bpsToKbps", func() { - It("converts standard bitrates", func() { - Expect(bpsToKbps(128000)).To(Equal(128)) - Expect(bpsToKbps(320000)).To(Equal(320)) - Expect(bpsToKbps(256000)).To(Equal(256)) - }) - It("returns 0 for 0", func() { - Expect(bpsToKbps(0)).To(Equal(0)) - }) - It("rounds instead of truncating", func() { - Expect(bpsToKbps(999)).To(Equal(1)) - Expect(bpsToKbps(500)).To(Equal(1)) - Expect(bpsToKbps(499)).To(Equal(0)) - }) - It("returns 0 for negative values", func() { - Expect(bpsToKbps(-1)).To(Equal(0)) - Expect(bpsToKbps(-1000)).To(Equal(0)) - Expect(bpsToKbps(-1000000)).To(Equal(0)) - }) - }) - - Describe("kbpsToBps", func() { - It("converts standard bitrates", func() { - Expect(kbpsToBps(128)).To(Equal(128000)) - Expect(kbpsToBps(320)).To(Equal(320000)) - }) - It("returns 0 for 0", func() { - Expect(kbpsToBps(0)).To(Equal(0)) - }) - }) - - Describe("convertBitrateValues", func() { - It("converts valid bps strings to kbps", func() { - Expect(convertBitrateValues([]string{"128000", "320000"})).To(Equal([]string{"128", "320"})) - }) - It("preserves unparseable values", func() { - Expect(convertBitrateValues([]string{"128000", "bad", "320000"})).To(Equal([]string{"128", "bad", "320"})) - }) - It("handles empty slice", func() { - Expect(convertBitrateValues([]string{})).To(Equal([]string{})) - }) - }) -}) - -// newJSONPostRequest creates an HTTP POST request with JSON body and query params -func newJSONPostRequest(queryParams string, jsonBody string) *http.Request { - r := httptest.NewRequest("POST", "/getTranscodeDecision?"+queryParams, bytes.NewBufferString(jsonBody)) - r.Header.Set("Content-Type", "application/json") - return r -} - -// mockTranscodeDecision is a test double for stream.TranscodeDecider -type mockTranscodeDecision struct { - decision *stream.TranscodeDecision - decisionErr error - token string - tokenErr error - resolvedReq stream.Request - resolveErr error - capturedClient *stream.ClientInfo -} - -func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, ci *stream.ClientInfo, _ stream.TranscodeOptions) (*stream.TranscodeDecision, error) { - m.capturedClient = ci - if m.decisionErr != nil { - return nil, m.decisionErr - } - if m.decision != nil { - return m.decision, nil - } - return &stream.TranscodeDecision{}, nil -} - -func (m *mockTranscodeDecision) ResolveRequest(_ context.Context, _ *model.MediaFile, _ string, _ int, _ int) stream.Request { - return stream.Request{Format: "raw"} -} - -func (m *mockTranscodeDecision) ResolveClientRequest(context.Context, *model.MediaFile, *stream.ClientInfo, int) stream.Request { - return stream.Request{Format: "raw"} -} - -func (m *mockTranscodeDecision) CreateTranscodeParams(_ *stream.TranscodeDecision) (string, error) { - return m.token, m.tokenErr -} - -func (m *mockTranscodeDecision) ResolveRequestFromToken(_ context.Context, _ string, _ *model.MediaFile, offset int) (stream.Request, error) { - if m.resolveErr != nil { - return stream.Request{}, m.resolveErr - } - req := m.resolvedReq - req.Offset = offset - return req, nil -} - -// fakeMediaStreamer captures the StreamRequest and returns a sentinel error, -// allowing tests to verify parameter passing without constructing a real Stream. -var errStreamCaptured = errors.New("stream request captured") - -type fakeMediaStreamer struct { - captured *stream.Request -} - -func (f *fakeMediaStreamer) NewStream(_ context.Context, _ *model.MediaFile, req stream.Request) (*stream.Stream, error) { - f.captured = &req - return nil, errStreamCaptured -} diff --git a/server/subsonic/users.go b/server/subsonic/users.go index acf8de3e8..8018fd439 100644 --- a/server/subsonic/users.go +++ b/server/subsonic/users.go @@ -2,51 +2,27 @@ package subsonic import ( "net/http" - "strings" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/req" - "github.com/navidrome/navidrome/utils/slice" ) -// buildUserResponse creates a User response object from a User model -func buildUserResponse(user model.User) responses.User { - userResponse := responses.User{ - Username: user.UserName, - AdminRole: user.IsAdmin, - Email: user.Email, - StreamRole: true, - ScrobblingEnabled: true, - DownloadRole: conf.Server.EnableDownloads, - ShareRole: conf.Server.EnableSharing, - CoverArtRole: conf.Server.EnableArtworkUpload || user.IsAdmin, - Folder: slice.Map(user.Libraries, func(lib model.Library) int32 { return int32(lib.ID) }), - } - - if conf.Server.Jukebox.Enabled { - userResponse.JukeboxRole = !conf.Server.Jukebox.AdminOnly || user.IsAdmin - } - - return userResponse -} - +// TODO This is a placeholder. The real one has to read this info from a config file or the database func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) { loggedUser, ok := request.UserFrom(r.Context()) if !ok { return nil, newError(responses.ErrorGeneric, "Internal error") } - username, err := req.Params(r).String("username") - if err != nil { - return nil, err - } - if !strings.EqualFold(username, loggedUser.UserName) { - return nil, newError(responses.ErrorAuthorizationFail) - } response := newResponse() - response.User = new(buildUserResponse(loggedUser)) + response.User = &responses.User{} + response.User.Username = loggedUser.UserName + response.User.AdminRole = loggedUser.IsAdmin + response.User.Email = loggedUser.Email + response.User.StreamRole = true + response.User.ScrobblingEnabled = true + response.User.DownloadRole = conf.Server.EnableDownloads + response.User.ShareRole = conf.Server.EnableSharing return response, nil } @@ -55,8 +31,14 @@ func (api *Router) GetUsers(r *http.Request) (*responses.Subsonic, error) { if !ok { return nil, newError(responses.ErrorGeneric, "Internal error") } - - user := buildUserResponse(loggedUser) + user := responses.User{} + user.Username = loggedUser.Name + user.AdminRole = loggedUser.IsAdmin + user.Email = loggedUser.Email + user.StreamRole = true + user.ScrobblingEnabled = true + user.DownloadRole = conf.Server.EnableDownloads + user.ShareRole = conf.Server.EnableSharing response := newResponse() response.Users = &responses.Users{User: []responses.User{user}} return response, nil diff --git a/server/subsonic/users_test.go b/server/subsonic/users_test.go deleted file mode 100644 index 2d08b3377..000000000 --- a/server/subsonic/users_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package subsonic - -import ( - "errors" - "net/http/httptest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Users", func() { - var router *Router - var testUser model.User - - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - router = &Router{} - - testUser = model.User{ - ID: "user123", - UserName: "testuser", - Name: "Test User", - Email: "test@example.com", - IsAdmin: false, - } - }) - - Describe("Happy path", func() { - It("should return consistent user data in both GetUser and GetUsers", func() { - conf.Server.EnableDownloads = true - conf.Server.EnableSharing = true - conf.Server.Jukebox.Enabled = false - - // Set up user with libraries - testUser.Libraries = model.Libraries{ - {ID: 10, Name: "Music"}, - {ID: 20, Name: "Podcasts"}, - } - - // Create request with user in context - req := httptest.NewRequest("GET", "/rest/getUser?username=testuser", nil) - ctx := request.WithUser(GinkgoT().Context(), testUser) - req = req.WithContext(ctx) - - userResponse, err1 := router.GetUser(req) - usersResponse, err2 := router.GetUsers(req) - - Expect(err1).ToNot(HaveOccurred()) - Expect(err2).ToNot(HaveOccurred()) - - // Verify GetUser response structure - Expect(userResponse.Status).To(Equal(responses.StatusOK)) - Expect(userResponse.User).ToNot(BeNil()) - Expect(userResponse.User.Username).To(Equal("testuser")) - Expect(userResponse.User.Email).To(Equal("test@example.com")) - Expect(userResponse.User.AdminRole).To(BeFalse()) - Expect(userResponse.User.StreamRole).To(BeTrue()) - Expect(userResponse.User.ScrobblingEnabled).To(BeTrue()) - Expect(userResponse.User.DownloadRole).To(BeTrue()) - Expect(userResponse.User.ShareRole).To(BeTrue()) - Expect(userResponse.User.CoverArtRole).To(BeTrue()) - Expect(userResponse.User.Folder).To(ContainElements(int32(10), int32(20))) - - // Verify GetUsers response structure - Expect(usersResponse.Status).To(Equal(responses.StatusOK)) - Expect(usersResponse.Users).ToNot(BeNil()) - Expect(usersResponse.Users.User).To(HaveLen(1)) - - // Verify both methods return identical user data - singleUser := userResponse.User - userFromList := &usersResponse.Users.User[0] - - Expect(singleUser.Username).To(Equal(userFromList.Username)) - Expect(singleUser.Email).To(Equal(userFromList.Email)) - Expect(singleUser.AdminRole).To(Equal(userFromList.AdminRole)) - Expect(singleUser.StreamRole).To(Equal(userFromList.StreamRole)) - Expect(singleUser.ScrobblingEnabled).To(Equal(userFromList.ScrobblingEnabled)) - Expect(singleUser.DownloadRole).To(Equal(userFromList.DownloadRole)) - Expect(singleUser.ShareRole).To(Equal(userFromList.ShareRole)) - Expect(singleUser.CoverArtRole).To(Equal(userFromList.CoverArtRole)) - Expect(singleUser.JukeboxRole).To(Equal(userFromList.JukeboxRole)) - Expect(singleUser.Folder).To(Equal(userFromList.Folder)) - }) - }) - - DescribeTable("Jukebox role permissions", - func(jukeboxEnabled, adminOnly, isAdmin, expectedJukeboxRole bool) { - conf.Server.Jukebox.Enabled = jukeboxEnabled - conf.Server.Jukebox.AdminOnly = adminOnly - testUser.IsAdmin = isAdmin - - response := buildUserResponse(testUser) - Expect(response.JukeboxRole).To(Equal(expectedJukeboxRole)) - }, - Entry("jukebox disabled", false, false, false, false), - Entry("jukebox enabled, not admin-only, regular user", true, false, false, true), - Entry("jukebox enabled, not admin-only, admin user", true, false, true, true), - Entry("jukebox enabled, admin-only, regular user", true, true, false, false), - Entry("jukebox enabled, admin-only, admin user", true, true, true, true), - ) - - DescribeTable("CoverArt role permissions", - func(enableArtworkUpload, isAdmin, expectedCoverArtRole bool) { - conf.Server.EnableArtworkUpload = enableArtworkUpload - testUser.IsAdmin = isAdmin - - response := buildUserResponse(testUser) - Expect(response.CoverArtRole).To(Equal(expectedCoverArtRole)) - }, - Entry("enabled, regular user", true, false, true), - Entry("enabled, admin user", true, true, true), - Entry("disabled, regular user", false, false, false), - Entry("disabled, admin user", false, true, true), - ) - - Describe("Folder list population", func() { - It("should populate Folder field with user's accessible library IDs", func() { - testUser.Libraries = model.Libraries{ - {ID: 1, Name: "Music"}, - {ID: 2, Name: "Podcasts"}, - {ID: 5, Name: "Audiobooks"}, - } - - response := buildUserResponse(testUser) - - Expect(response.Folder).To(HaveLen(3)) - Expect(response.Folder).To(ContainElements(int32(1), int32(2), int32(5))) - }) - }) - - Describe("GetUser authorization", func() { - It("should allow user to request their own information", func() { - req := httptest.NewRequest("GET", "/rest/getUser?username=testuser", nil) - ctx := request.WithUser(GinkgoT().Context(), testUser) - req = req.WithContext(ctx) - - response, err := router.GetUser(req) - - Expect(err).ToNot(HaveOccurred()) - Expect(response).ToNot(BeNil()) - Expect(response.User).ToNot(BeNil()) - Expect(response.User.Username).To(Equal("testuser")) - }) - - It("should deny user from requesting another user's information", func() { - req := httptest.NewRequest("GET", "/rest/getUser?username=anotheruser", nil) - ctx := request.WithUser(GinkgoT().Context(), testUser) - req = req.WithContext(ctx) - - response, err := router.GetUser(req) - - Expect(err).To(HaveOccurred()) - Expect(response).To(BeNil()) - - var subErr subError - ok := errors.As(err, &subErr) - Expect(ok).To(BeTrue()) - Expect(subErr.code).To(Equal(responses.ErrorAuthorizationFail)) - }) - - It("should return error when username parameter is missing", func() { - req := httptest.NewRequest("GET", "/rest/getUser", nil) - ctx := request.WithUser(GinkgoT().Context(), testUser) - req = req.WithContext(ctx) - - response, err := router.GetUser(req) - - Expect(err).To(MatchError("missing parameter: 'username'")) - Expect(response).To(BeNil()) - }) - - It("should return error when user context is missing", func() { - req := httptest.NewRequest("GET", "/rest/getUser?username=testuser", nil) - - response, err := router.GetUser(req) - - Expect(err).To(HaveOccurred()) - Expect(response).To(BeNil()) - - var subErr subError - ok := errors.As(err, &subErr) - Expect(ok).To(BeTrue()) - Expect(subErr.code).To(Equal(responses.ErrorGeneric)) - }) - }) -}) diff --git a/server/testdata/test_cert.pem b/server/testdata/test_cert.pem deleted file mode 100644 index 1dfa573d6..000000000 --- a/server/testdata/test_cert.pem +++ /dev/null @@ -1,23 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDwzCCAqugAwIBAgIUXqdUxUOo8kmsDe71iTR+Vr7btP8wDQYJKoZIhvcNAQEL -BQAwYjELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx -EjAQBgNVBAoMCU5hdmlkcm9tZTENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJbG9j -YWxob3N0MCAXDTI1MTEyODE5NTkxNVoYDzIxMjUxMTA0MTk1OTE1WjBiMQswCQYD -VQQGEwJVUzENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDESMBAGA1UECgwJ -TmF2aWRyb21lMQ0wCwYDVQQLDARUZXN0MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi -MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCkB/TQgl5ei5KRSHt5OJim8rKS -MzRlkK4BjSEM4D9ESbebdpEVjX48QuBYACrCvgvVp7mQGF5anl8Hm89trvd8ooVQ -x9IPQQ6gRKM+4gLrt9FHvFGGzZQS8UTQXN5oBi11E+8/Vs47HLUNXC2TRtRLCMyK -LYXQIXbhdp9anImlt+IHUxIQUchK6Zkld/gCm56X1bbzN/Zq91PQLpx2FZ0eZTjN -KaNgztLa+K/BDnTuk3iTTs9GEp6VCvqQE/6fk/UN/tkk2dLwKIFvPVR/YeAhVdz/ -OHC4L3B36QN3+VQ2yDjsp1PVAPX07UnzXO3Oj7uGYnMQxwprGMEubm3nADDxAgMB -AAGjbzBtMB0GA1UdDgQWBBRAZHUVuLyzc0CfuZR9ApqMbawIqzAfBgNVHSMEGDAW -gBRAZHUVuLyzc0CfuZR9ApqMbawIqzAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQT -MBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAmDLXcPx9LNHs -GxQIE6Q5BXbVO7c8qrWmJf5FK5VWaifNZ9U+IBi+VlB4jCLK/OkwsviN/jOnwRYx -owjq0QG0YdRT4uD9fEMrAj+EwbnrQYZQvT0yGEWA+KW5TW08wt+/qnGJDwEgbjYJ -HTdICVMhs/e8Ex48fAgO8WSsdTDekOrhuwzIfeJ1LU4ZptLsD2ePFxuzutdIuW51 -/mspQGsjXqZ1qnLsavLXh/lds2g602rTpYBNZVjV9WiOvaQS8vviOxBN6f+9vgRz -a8SEbHqBG6jeyVqVZ7MjxcYxaIkxeBwMyMwgb+wwDfVXo2FZzX2TVeB7ZppI+IKv -TXYurWPYsQ== ------END CERTIFICATE----- diff --git a/server/testdata/test_cert_encrypted.pem b/server/testdata/test_cert_encrypted.pem deleted file mode 100644 index 6f8de623a..000000000 --- a/server/testdata/test_cert_encrypted.pem +++ /dev/null @@ -1,22 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDpzCCAo+gAwIBAgIUEa7gEJYwJqYEJjTY7otQ+oUyELwwDQYJKoZIhvcNAQEL -BQAwYjELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx -EjAQBgNVBAoMCU5hdmlkcm9tZTENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJbG9j -YWxob3N0MCAXDTI1MTEyODE5NTI0OVoYDzIxMjUxMTA0MTk1MjQ5WjBiMQswCQYD -VQQGEwJVUzENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDESMBAGA1UECgwJ -TmF2aWRyb21lMQ0wCwYDVQQLDARUZXN0MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi -MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDBHgqJ1d9EnNxqoSZ6xXrIz/mV -Y0nWJW16/qIAvCdovSeTZhG9iqG8dUqcuu2BdD9MMHndJ2oFn3iD8EJR92dH8KBA -8xOmtZ0BEEWgXPBivywZVd1ChIflEWj6m5wwLNjb57SPpUiwaLxBQB8ByEaAAZE/ -bLqvHI3vW/4s5apky17SPIqmkmqEYlRcg97tlRXsPuwoAVM9cvLMMEqtIR1CB/72 -gboY2Gi2r/plLF/Rg3Dom6QljMWi57XXWJFwGYSXaZuM0gvn04e3oLu+1E+WMoq/ -9rExWij2DlsmXd/RiScliFp6R4H84wQUyqrAUNytvgRO+oVnRjEA0l3oCYdRAgMB -AAGjUzBRMB0GA1UdDgQWBBQQKpB1UaKm98FnBdl8uKdRscrVTzAfBgNVHSMEGDAW -gBQQKpB1UaKm98FnBdl8uKdRscrVTzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 -DQEBCwUAA4IBAQBP07l+2LmpFtcxqMGmsiNYwFuHpQCxJd4YRZHjLX7O+oJExMgR -2yP4mpMKurgKOv7unTDLwvjQRa6ZTYJCsYtvC6hbyqlGc7AfNTu6DKz8r35/2/V5 -hPsG5lNb91HhvHE839mLAvpi02LoFH2Sr8BR7s6qxfNKYcP8PUOJQXltJ6yAa8YJ -syeXQQ3RIyGsJANeaC06S3UdkBM5H5BLfIHnHu3GybJjwL51va4WCdHe8QV6GI0g -RDiThDVkBSXAr136vnMdlrYCxMoxY56itJ0zbYg2ELQKU9o1w/ZJQo9uvmy9jCoZ -Hy1L5a2vUDbsdONdvRkYZRHqMpG4bdD8D3j2 ------END CERTIFICATE----- diff --git a/server/testdata/test_key.pem b/server/testdata/test_key.pem deleted file mode 100644 index bac61f4a4..000000000 --- a/server/testdata/test_key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCkB/TQgl5ei5KR -SHt5OJim8rKSMzRlkK4BjSEM4D9ESbebdpEVjX48QuBYACrCvgvVp7mQGF5anl8H -m89trvd8ooVQx9IPQQ6gRKM+4gLrt9FHvFGGzZQS8UTQXN5oBi11E+8/Vs47HLUN -XC2TRtRLCMyKLYXQIXbhdp9anImlt+IHUxIQUchK6Zkld/gCm56X1bbzN/Zq91PQ -Lpx2FZ0eZTjNKaNgztLa+K/BDnTuk3iTTs9GEp6VCvqQE/6fk/UN/tkk2dLwKIFv -PVR/YeAhVdz/OHC4L3B36QN3+VQ2yDjsp1PVAPX07UnzXO3Oj7uGYnMQxwprGMEu -bm3nADDxAgMBAAECggEABqJFvesP2v4FEvgd+kSWM+ZL34rPmy3zQ5/MDuPA20ep -89EjQ/5hdRl1TknPcOnTu7PZVuENa9fM2xdrl7GEU9eU0bQLJE/KwiOUgJYObS8V -eTO+DlghHXUBhfXDjux1CS+htOuTUqOyFNS+CR9Lta8o6ou1xjmcP7kW78i17mxF -TuH5SZlS8W9PFLXHCInbMtqGFaT2ss09kvoPk2FDvHfxEdy6M9tKkguz02g+4bqI -aAMp2N7AOfmRpC0HvVa1ZfZo5Z8/KMoNcIm3pV9DEVM369J9EzhnMNpkGben90aT -FqO2JNsy52wmXFZUc9xe8uPdfDahALCkBGncLyLNmQKBgQDZREjocjdzOoPSlCdx -mRNe9suHz2FpUpsHCPOCotG63hFVKpah/ZvpHSsQx5rXs/mawDTmzGY9GQiBrSvg -OhfHIyT3NOhVaNcMxTqJX7rs7OG8D0MBacD9ASSeZ89MUn8q1EHZr5qxLtXl5Ikw -mHtiGRdiKGFFrG9H0zncbGhy7QKBgQDBRhQ9RAasTdmUiNQly9GVFkXto4T/9UHx -rVU44htCI2IVZUMTGlNfclfxpByDrzyA56rMzN9SAkiIp4nPpMDs5hayXaaPoojs -CPzV7r2OjemZ6CTeQ1ODImRL8L/E3jJSgWd6YYoHSQ5hjEX4yT6ft0u0tZUfdMKd -VENWIJ/hlQKBgQCo2hXjeOi5R8+tN3EUKwhP9HOnX7dv+D/9jqpZa5qdpPpJeyjI -SmYCHKYci1Q+sWOaLiiu+km20B65UVFZGSzjmd+fs+GghzMifKGKo/iNK2ggFKhZ -j8vplRrVdQ45XZ/xNDbdLEmHzEN2QE+Skd7KFYADzCgU0vdFFdbRBPuD3QKBgGIq -fQctMRJ9LCE0akSURGwr9vKflmMHKCpfdqTAu0WZgS0K1Mm0GlqlUiPKzizYaauz -f14sRNV7kWnPZsDPlqn8p9SKmpnj3RW97uWeMCtiyx6/+VHm8ljts/GaY1zT2s1r -KqrPNfNDWQmU3MljNeqbh9lOTWK/xEVy0gzB31MNAoGAQNWrZvVdAbL95XW6STUu -JmQlqJTlluuqS0Rrd/uVEQwW0Vd1dZjRQcFAFiSiCQWTbtId5gFZd6hiIQl53Xz0 -5cd+9mcyA/TaoCJYbMOFYsKbZMCBhefsovJlVQXedqJrIY6BdeGlet4GTAH5Qyl0 -ytEIUnvn5YmmbI7PDz80XpU= ------END PRIVATE KEY----- diff --git a/server/testdata/test_key_encrypted.pem b/server/testdata/test_key_encrypted.pem deleted file mode 100644 index 0ac715890..000000000 --- a/server/testdata/test_key_encrypted.pem +++ /dev/null @@ -1,30 +0,0 @@ ------BEGIN ENCRYPTED PRIVATE KEY----- -MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQPH9PYzryCI3smm81 -J8rm+QICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEI+9XxNfKSiMYIVB -UfcGfncEggTQVw7tPslGy3mlofCNnhBSnMViv9kj6M11smD6Y8vHG0k9Kq+6g+Dx -mQE9ILrSZBzM0uS3y484u+vkdqlT4KehhjIx0IiezurOcM45UdTAwLFLPzeEDlHI -lOWQ3gOTB3J5AxiUQOa6QsDIM7AZilidQG0BxQYWyRBA5B8evJwJoAvdzzA9wGSm -2YdNm3tA6rU5U8cVG+qTJP9pjbtRx0medC/CBZdxGkrWBQH+aySfahJdU8X1JI2e -SY4WJRw1rLCow+DnHjZS/IVHFJivJSRYvnvw8fwjOMVtkf+dAVctKlb1Fj9X+RdG -T1sq3i6zwFLE/RRz4qM4DKZ6UaD9wRFLow8FmNWVuJJiPgCLx2rrNMe32quS/kQP -iOsXAUeA/Yg1fdMCJORxl0nWDmLYcNtBghCmS1lyk+t+AKWwJudrds5tQQe8ha2t -Q41is+tDKwGDC1wt4WXJvBhgAJzuqFtr30H0M1eBhwwDdaDd9v0Zr3r8V49WZM2c -i3qkwPPYkQD+pOcR12xBV8ptvDxaUl7RGlVqnEWHagT51BaIaXQ9teUrG6UPt8o2 -LELJXF6CiwkbN6Y9sYx5XiKrIGxVhlQSZ1nB3XSFRHbu6e7VHPjnVwUeeg87J2Am -MEwqDzPU5sjKRn84+M91Y4uFAIeinaOJAQ0/tZVrf1iSeCMQyMUhW/8m7JPfG19F -NbJSPRXQuKmYKbWfXcMW2UFbp0zDs7s7p4zzbfde9IbVdq/o2nv3ZrNbrLak6O7y -FVt9q/xG4Tty6hSK6xtqtNZWcmfiMcTlk1Qcz2STvScbXtqgcgR6WUZfkLuzi09I -EDYFnzU5JNSY3U3VTv2hAPeU4xjTNM6kjF7L9JFGvdjH8Ko9UdxG9RZMd8xhBM/n -hxdzdVba4bDDz2z+0A2blSObrPrNsKr/3ZbnfuUiSs5NmqmUOifZ1t1PqGGO2Y5S -/cDKtrPk226hGomsUBfHtiIJPG1VRl4UaZiduqK3GGhtF491KU1mAfYzueok3TPq -JhLtLDIvEaFgmOmitFzROI/ifm6s4ssUvcvtbjwJumbjkU38OxYZFwbhwbe268G2 -vgspJamlEGJNdGDzrCFQlA2+A9kazCttztikfh5QGV6WFfkc3Bt1XTPL51vtliQy -MS2gUnJUY2fuYCfz8rxLH1kQmyYsHQz5rUYyBkeDffrG9MzarmzSJXR63FRzVMf1 -LQ7BSzei7dF6+J4KVCxjbGWF3GUGmGeOP5g5vJ3xb3YPJNJLT4Vai103pay59TGP -tESM2Vn0gJEvYApi707noFH5uFTW1cp7lloF41ddIUkL/QO7j+sjvBww+4DqBB7J -BmvLMnswa23yw9egYRG5jOXyCgIr+1rnNcph1HGJsvxvgJ2gwwo5NKCG8SC6LcZQ -fbDjX+ssmobLE3ktN03FZPMp32/ciexzuZoamfyiPXh7xE++ckifNEKJlNhx+kCG -mSR2wh+UGigQkgp/JxOzl6C4fhUbrEZr17oBqGim2p8h+GE0zD5JSHcn1rP86gGU -8JG/ilG4I8uMxUwhGj7amrWXUlJBd1by7e1EAL+utCo14/Tx3otB9/JtqY+lm9Ey -1ptPhMRQxvDNWrCmYM2kyrGghdNfEMir6GKDWI6PY9cwAFv/PLOxr1c= ------END ENCRYPTED PRIVATE KEY----- diff --git a/server/testdata/test_key_encrypted_legacy.pem b/server/testdata/test_key_encrypted_legacy.pem deleted file mode 100644 index 4b9215cdf..000000000 --- a/server/testdata/test_key_encrypted_legacy.pem +++ /dev/null @@ -1,30 +0,0 @@ ------BEGIN RSA PRIVATE KEY----- -Proc-Type: 4,ENCRYPTED -DEK-Info: AES-256-CBC,3C969050EAB73F121B7F0E6B75C42525 - -V6pSaAsrn9CQNo4p88QshJLbg8zkQJEom81dPbYSVqQSZa9YlPtpLZ9YtuLj/Ay0 -TScEKIj/gzQ32wNl6nhcSNIL9yy+X11r5gNv1kIHkecf+EbDW20VOiJsfD+6LUyW -hA96AIbPOwc76iCuvsKHPKU9MlEmjGipmk/C2RQLHCZJ3WkiDRgCM8KQ7vKhfACT -w908yj4cB1e/P0JPq8t/3F7kPJ+6SVM1vMEffHl0otQR3rAyrK8QikwJ0K9qX62d -cqchTVlEyyZBYovR8DrRRUDbsXS5j1ZmX3NQpvTSTFowr+33fMrY+4Oz8sdR4yx1 -CQc0A0sHHxSEIr2xu4KzczwOYVJN8PVdU0pgvFj9KEm66N6EY5CSFIBHyO/ycOt9 -U+wpkRjf3zS6ZaUU0NKdOcop4YX33i99/tZF2RNR1i7ETLYph+/LCf09286Bi3u/ -UCCuWedyECPdz0c6j0s27Fdfc/HEK90OEzeWh/fc+H2gJZhqJYK9V47HPTQNNMnB -U1a6FsJlrKE3E6nfSnTLxrSx9m/XTV7HV+HkgX+q8VhN7Q2VHUqkPzE7ZOPYpZ+A -dQzsm1TmEMxym6osYqFzQScXR1NZasrV2MTQ2J16dUgCdGAM2YMUD9JaoJR+u77M -WAjYzDiRg84rLr/KbJPAwHbsfo2KpiapJGSBBEDhz4W1/LOrFhsjaqIMSy4yZDGm -1KqXGHIlqmuHI7v4fD8vuzhj7GUujRx85HSZWakE/uc6s5WrhkSeVKYJWPfpsxTv -dT3oLOGJ+nRzWxM3aFtuJghX0nIGdKxT4EAUNXz0/vLT3OP1QCZR+oELrriFzmtj -+O30bGH2SAFZEQJ/uTQg6celoNh89IzH4DJkcn67hqpX6mUiU9CrIr/eR9C/en8Q -smTbbC1C1pDUaCwR26Z+zgM90amh4yfOFKK2geO2Kj+TmwFHUvi6ZnSzMzCvty3t -+wdIrUtf55Lw51JCpLGl70mg4b/zBj5hqBkU2YvAAnz/htjfH/wrD6ZAF1TCdlRO -gyODrJjGRnLd/v0XLk0wp+RkAjBcSlRlkUvZY5BtugL7dIdwiNGGQPcOni9IVeG0 -6vDUEQnDOLYDj4d/JcckTLuHdrP+SW+0RQl2HK5+/w1hScGXN4O48gccu7yR/MN8 -DmpCg5rD/nq8sxJosmSt07GrN36KppYt8LCXQbSg3NG2Ad715caS2C+0Qtdm5MPD -rM1UyTXQYSJXgUN9yZS/pmzlguCywnnvsBPU6j3ljZwcoD41QJ/1OU09/W6sIMQR -IAiM35JHiLJiccFgxSE1qx5F1UZqX4P47jF0Wzi/sE/DYXg5qw2DoauqXNzqnumH -71UDGK1V6wQIV7UCZDa0WUfFzu470XpuFb8VmMOuHSQxkZESc9cz8k/ueAuO438Q -jnlkF1Ge2EEPuaK2zeaTj/lGyYA1AUfHRRgt/EMUQSBntmhlpnwVPYTVvYtHO2N5 -wp7/y39KirnlTl99i3XiOJ4WF4gIU2IaSlqMo4+e/A32h2JFi9QfNyfItXe6Fm1X -d0j2XGHzwMfHEFKdWyrgtVZwc38/1d6xWYAhs02b2basV/0AQhFTaKf5Z268eBNJ ------END RSA PRIVATE KEY----- diff --git a/server/throttle_backlog.go b/server/throttle_backlog.go deleted file mode 100644 index 0d31d289e..000000000 --- a/server/throttle_backlog.go +++ /dev/null @@ -1,149 +0,0 @@ -package server - -import ( - "bytes" - "context" - "errors" - "maps" - "net/http" - "sync" - "time" - - "github.com/go-chi/chi/v5/middleware" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" -) - -var ( - ErrThrottleCapacityExceeded = errors.New("throttle: capacity exceeded") - ErrThrottleTimeout = errors.New("throttle: backlog timeout") -) - -type requestThrottle struct { - tokens chan struct{} - backlogTokens chan struct{} - backlogTimeout time.Duration -} - -// ThrottleBacklog creates a Chi-compatible middleware that limits concurrent -// request processing. Unlike Chi's ThrottleBacklog, it buffers the handler's -// response while holding the token, releases it, then flushes the buffer to -// the client with a write deadline. This prevents slow clients from holding -// throttle capacity. -// -// Because it buffers the entire response in memory, this middleware should only -// be used for endpoints that return small responses (e.g., artwork images). Do -// not use it for audio streaming or download endpoints. -func ThrottleBacklog(limit, backlogLimit int, backlogTimeout time.Duration) func(http.Handler) http.Handler { - if limit <= 0 { - return func(next http.Handler) http.Handler { return next } - } - if !conf.Server.DevArtworkThrottleBuffered { - return middleware.ThrottleBacklog(limit, backlogLimit, backlogTimeout) - } - t := &requestThrottle{ - tokens: make(chan struct{}, limit), - backlogTokens: make(chan struct{}, limit+backlogLimit), - backlogTimeout: backlogTimeout, - } - for range limit { - t.tokens <- struct{}{} - } - for range limit + backlogLimit { - t.backlogTokens <- struct{}{} - } - return t.handler -} - -func (t *requestThrottle) handler(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - release, err := t.acquire(ctx) - if err != nil { - switch { - case errors.Is(err, ErrThrottleCapacityExceeded): - log.Warn(ctx, "Request throttle capacity exceeded", "path", r.URL.Path) - case errors.Is(err, ErrThrottleTimeout): - log.Warn(ctx, "Request throttle backlog timeout", "path", r.URL.Path) - } - http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests) - return - } - - buf := &bufferedResponseWriter{header: make(http.Header)} - func() { - defer release() - next.ServeHTTP(buf, r) - }() - - maps.Copy(w.Header(), buf.header) - if buf.code > 0 { - w.WriteHeader(buf.code) - } - if _, err := w.Write(buf.body.Bytes()); err != nil { - log.Warn(ctx, "Error writing throttled response", err) - } - }) -} - -func (t *requestThrottle) acquire(ctx context.Context) (release func(), err error) { - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-t.backlogTokens: - default: - return nil, ErrThrottleCapacityExceeded - } - - select { - case <-t.tokens: - return t.releaseFunc(), nil - default: - } - - timer := time.NewTimer(t.backlogTimeout) - select { - case <-timer.C: - t.backlogTokens <- struct{}{} - return nil, ErrThrottleTimeout - case <-ctx.Done(): - timer.Stop() - t.backlogTokens <- struct{}{} - return nil, ctx.Err() - case <-t.tokens: - timer.Stop() - return t.releaseFunc(), nil - } -} - -func (t *requestThrottle) releaseFunc() func() { - var once sync.Once - return func() { - once.Do(func() { - t.tokens <- struct{}{} - t.backlogTokens <- struct{}{} - }) - } -} - -type bufferedResponseWriter struct { - header http.Header - body bytes.Buffer - code int -} - -func (w *bufferedResponseWriter) Header() http.Header { - return w.header -} - -func (w *bufferedResponseWriter) Write(b []byte) (int, error) { - return w.body.Write(b) -} - -func (w *bufferedResponseWriter) WriteHeader(code int) { - if w.code != 0 { - return - } - w.code = code -} diff --git a/server/throttle_backlog_test.go b/server/throttle_backlog_test.go deleted file mode 100644 index 4d8567db5..000000000 --- a/server/throttle_backlog_test.go +++ /dev/null @@ -1,266 +0,0 @@ -package server - -import ( - "bytes" - "io" - "net/http" - "net/http/httptest" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("ThrottleBacklog", func() { - It("is a passthrough when limit is 0", func() { - m := ThrottleBacklog(0, 10, time.Second) - r := chi.NewRouter() - r.Use(m) - r.Get("/test", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("ok")) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", nil) - r.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(Equal("ok")) - }) - - It("returns 429 when capacity is exceeded", func() { - _, secondStatus := runTwoRequests(ThrottleBacklog(1, 0, time.Second)) - Expect(secondStatus).To(Equal(http.StatusTooManyRequests)) - }) - - It("returns 429 when backlog times out", func() { - _, secondStatus := runTwoRequests(ThrottleBacklog(1, 1, 50*time.Millisecond)) - Expect(secondStatus).To(Equal(http.StatusTooManyRequests)) - }) - - It("releases capacity when the handler panics", func() { - m := ThrottleBacklog(1, 0, time.Second) - r := chi.NewRouter() - r.Use(middleware.Recoverer) - r.Use(m) - r.Get("/panic", func(w http.ResponseWriter, r *http.Request) { - panic("boom") - }) - r.Get("/test", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("ok")) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/panic", nil) - r.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusInternalServerError)) - - w = httptest.NewRecorder() - req, _ = http.NewRequest("GET", "/test", nil) - r.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Body.String()).To(Equal("ok")) - }) - - It("preserves response headers and status code", func() { - m := ThrottleBacklog(2, 0, time.Second) - r := chi.NewRouter() - r.Use(m) - r.Get("/test", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/jpeg") - w.Header().Set("Cache-Control", "public") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte("body")) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", nil) - r.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusCreated)) - Expect(w.Header().Get("Content-Type")).To(Equal("image/jpeg")) - Expect(w.Header().Get("Cache-Control")).To(Equal("public")) - Expect(w.Body.String()).To(Equal("body")) - }) - - It("uses the first response status code", func() { - m := ThrottleBacklog(2, 0, time.Second) - r := chi.NewRouter() - r.Use(m) - r.Get("/test", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusCreated) - w.WriteHeader(http.StatusAccepted) - _, _ = w.Write([]byte("body")) - }) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", nil) - r.ServeHTTP(w, req) - - Expect(w.Code).To(Equal(http.StatusCreated)) - Expect(w.Body.String()).To(Equal("body")) - }) - - It("never exceeds the concurrency limit", func() { - const limit = 3 - const goroutines = 20 - m := ThrottleBacklog(limit, goroutines, 5*time.Second) - - var concurrent atomic.Int32 - var maxConcurrent atomic.Int32 - - r := chi.NewRouter() - r.Use(m) - r.Get("/test", func(w http.ResponseWriter, r *http.Request) { - cur := concurrent.Add(1) - for { - old := maxConcurrent.Load() - if cur <= old || maxConcurrent.CompareAndSwap(old, cur) { - break - } - } - time.Sleep(5 * time.Millisecond) - concurrent.Add(-1) - _, _ = w.Write([]byte("ok")) - }) - - var wg sync.WaitGroup - for range goroutines { - wg.Go(func() { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", nil) - r.ServeHTTP(w, req) - }) - } - - wg.Wait() - Expect(maxConcurrent.Load()).To(BeNumerically("<=", limit)) - }) - - // Regression: with only 1 token, a slow client blocking during response - // writing must NOT prevent other requests from being served. Chi's original - // ThrottleBacklog holds the token for the entire handler lifecycle including - // io.Copy, causing starvation. The buffered implementation releases it first. - Context("when a client is slow to read the response", func() { - slowClientTest := func(m func(http.Handler) http.Handler) (*chi.Mux, chan struct{}, chan struct{}) { - handlerReached := make(chan struct{}, 1) - router := chi.NewRouter() - router.Use(m) - router.Get("/test", func(w http.ResponseWriter, r *http.Request) { - select { - case handlerReached <- struct{}{}: - default: - } - _, _ = io.Copy(w, strings.NewReader("image data")) - }) - - unblocked := make(chan struct{}) - slow := newSlowTestWriter(unblocked) - - reqDone := make(chan struct{}) - go func() { - defer close(reqDone) - req, _ := http.NewRequest("GET", "/test", nil) - router.ServeHTTP(slow, req) - }() - <-handlerReached - - return router, unblocked, reqDone - } - - It("does not starve concurrent requests with buffered middleware", func() { - router, unblocked, reqDone := slowClientTest(ThrottleBacklog(1, 1, 500*time.Millisecond)) - - Eventually(func() int { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", nil) - router.ServeHTTP(w, req) - return w.Code - }, 2*time.Second, 10*time.Millisecond).Should(Equal(http.StatusOK)) - - close(unblocked) - Eventually(reqDone, 2*time.Second).Should(BeClosed()) - }) - - It("starves concurrent requests with Chi's original middleware", func() { - DeferCleanup(configtest.SetupConfig()) - conf.Server.DevArtworkThrottleBuffered = false - - router, unblocked, reqDone := slowClientTest(ThrottleBacklog(1, 1, 500*time.Millisecond)) - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", nil) - router.ServeHTTP(w, req) - Expect(w.Code).To(Equal(http.StatusTooManyRequests)) - - close(unblocked) - Eventually(reqDone, 2*time.Second).Should(BeClosed()) - }) - }) -}) - -// runTwoRequests sends two concurrent requests through a throttled router. The -// first request holds the token until the second has been dispatched. -func runTwoRequests(m func(http.Handler) http.Handler) (firstStatus, secondStatus int) { - held := make(chan struct{}, 1) - release := make(chan struct{}) - r := chi.NewRouter() - r.Use(m) - r.Get("/test", func(w http.ResponseWriter, r *http.Request) { - select { - case held <- struct{}{}: - default: - } - <-release - _, _ = w.Write([]byte("ok")) - }) - - done := make(chan int) - go func() { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", nil) - r.ServeHTTP(w, req) - done <- w.Code - }() - <-held - - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/test", nil) - r.ServeHTTP(w, req) - secondStatus = w.Code - - close(release) - firstStatus = <-done - return firstStatus, secondStatus -} - -// slowTestWriter implements http.ResponseWriter without embedding -// httptest.ResponseRecorder. This is necessary because ResponseRecorder -// promotes io.ReaderFrom, which io.Copy prefers over Write — bypassing -// our blocking Write and defeating the slow-client simulation. -type slowTestWriter struct { - header http.Header - body bytes.Buffer - code int - unblocked chan struct{} -} - -func newSlowTestWriter(unblocked chan struct{}) *slowTestWriter { - return &slowTestWriter{header: make(http.Header), unblocked: unblocked} -} - -func (w *slowTestWriter) Header() http.Header { return w.header } - -func (w *slowTestWriter) WriteHeader(code int) { w.code = code } - -func (w *slowTestWriter) Write(p []byte) (int, error) { - <-w.unblocked - return w.body.Write(p) -} diff --git a/tests/dns_stub.go b/tests/dns_stub.go deleted file mode 100644 index 7cd7faf6d..000000000 --- a/tests/dns_stub.go +++ /dev/null @@ -1,54 +0,0 @@ -package tests - -import ( - "context" - "encoding/binary" - "io" - "net" - "net/netip" - - "golang.org/x/net/dns/dnsmessage" -) - -// StubResolver returns a net.Resolver that answers A queries from records (absolute name to IPv4, -// e.g. "localhost." to "127.0.0.1") instead of the system DNS. Other names do not resolve. -func StubResolver(records map[string]string) *net.Resolver { - return &net.Resolver{ - PreferGo: true, - Dial: func(context.Context, string, string) (net.Conn, error) { - client, server := net.Pipe() - go serveStubDNS(server, records) - return client, nil - }, - } -} - -// net.Pipe is not a PacketConn, so the resolver sends one query framed with a 2-byte length prefix. -func serveStubDNS(conn net.Conn, records map[string]string) { - defer conn.Close() - var size [2]byte - if _, err := io.ReadFull(conn, size[:]); err != nil { - return - } - buf := make([]byte, binary.BigEndian.Uint16(size[:])) - if _, err := io.ReadFull(conn, buf); err != nil { - return - } - var msg dnsmessage.Message - if err := msg.Unpack(buf); err != nil || len(msg.Questions) != 1 { - return - } - msg.Response = true - q := msg.Questions[0] - if ip, err := netip.ParseAddr(records[q.Name.String()]); err == nil && q.Type == dnsmessage.TypeA { - msg.Answers = []dnsmessage.Resource{{ - Header: dnsmessage.ResourceHeader{Name: q.Name, Type: q.Type, Class: q.Class}, - Body: &dnsmessage.AResource{A: ip.As4()}, - }} - } - resp, err := msg.Pack() - if err != nil { - return - } - _, _ = conn.Write(append(binary.BigEndian.AppendUint16(nil, uint16(len(resp))), resp...)) -} diff --git a/tests/fixtures/01 Invisible (RED) Edit Version.m4a b/tests/fixtures/01 Invisible (RED) Edit Version.m4a index 76b61a2d4..55d360d89 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/bom-test.lrc b/tests/fixtures/bom-test.lrc deleted file mode 100644 index 223c37de0..000000000 --- a/tests/fixtures/bom-test.lrc +++ /dev/null @@ -1,4 +0,0 @@ -[00:00.00] 作曲 : 柏大輔 -NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at byte 0. -This tests BOM handling in lyrics parsing (GitHub issue #4631). -The BOM bytes are: 0xEF 0xBB 0xBF \ No newline at end of file diff --git a/tests/fixtures/bom-test.ttml b/tests/fixtures/bom-test.ttml deleted file mode 100644 index 319ab1f07..000000000 --- a/tests/fixtures/bom-test.ttml +++ /dev/null @@ -1,2 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<tt xmlns="http://www.w3.org/ns/ttml"><body><div xml:lang="eng"><p begin="00:00:00.00">BOM test line</p></div></body></tt> diff --git a/tests/fixtures/bom-utf16-test.lrc b/tests/fixtures/bom-utf16-test.lrc deleted file mode 100644 index e40ea3255..000000000 Binary files a/tests/fixtures/bom-utf16-test.lrc and /dev/null differ diff --git a/tests/fixtures/bom-utf16-test.ttml b/tests/fixtures/bom-utf16-test.ttml deleted file mode 100644 index a5621ef5d..000000000 Binary files a/tests/fixtures/bom-utf16-test.ttml and /dev/null differ diff --git a/tests/fixtures/deezer.artist.bio.empty.json b/tests/fixtures/deezer.artist.bio.empty.json deleted file mode 100644 index 8bc0f6932..000000000 --- a/tests/fixtures/deezer.artist.bio.empty.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "data": { - "artist": { - "bio": null - } - }, - "extensions": { - "queryCost": 3 - } -} diff --git a/tests/fixtures/deezer.artist.bio.en.json b/tests/fixtures/deezer.artist.bio.en.json deleted file mode 100644 index a1b838aa4..000000000 --- a/tests/fixtures/deezer.artist.bio.en.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "data": { - "artist": { - "bio": { - "full": "<p>Schoolmates Thomas and Guy-Manuel began their career in 1992 with the indie rock trio Darlin' (named after The Beach Boys song) but were scathingly dismissed by Melody Maker magazine as \"daft punk.\" Turning to house-inspired electronica, they used the put down as a name for their DJ-ing partnership and became a hugely successful and influential dance act. First major single <em>\"Da Funk\"</em> was accompanied by a Spike Jonze-directed video and more success followed with global dance floor anthem <em>\"Around the World,\" \"One More Time,\"</em> and <em>\"Harder, Faster, Better, Stronger\"</em> - which was sampled by Kanye West for his hit <em>\"Stronger.\"</em> Albums <em>Homework</em> (1997), <em>Discovery</em> (2001) and <em>Human After All</em> (2005) all made the UK Top 10 establishing a style of simple, Chicago house-inspired grooves exploding into a robotic, rave sound.</p>" - } - } - }, - "extensions": { - "queryCost": 3 - } -} diff --git a/tests/fixtures/deezer.artist.bio.fr.json b/tests/fixtures/deezer.artist.bio.fr.json deleted file mode 100644 index 435f6fcf0..000000000 --- a/tests/fixtures/deezer.artist.bio.fr.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "data": { - "artist": { - "bio": { - "full": "Guy-Manuel de Homem Christo et Thomas Bangalter se rencontrent en 1987 au lycée Carnot de Paris. Partageant une même passion pour la musique, les deux amis fondent en 1992 Darlin', un groupe de rock influencé par les Stooges et MC5, dont la production sera taxée par un critique de la presse anglaise de «daft punk» (« punk idiot »). <br />\n<br />\nDécouragés face à l'apathie du milieu rock, ils décident un peu plus tard de se lancer à corps perdus dans le courant Techno alors en pleine explosion. Arrive alors la découverte de la House, des clubs et des raves, dont une en particulier qui déterminera leur avenir : en 1993 est organisé à EuroDisney une rave où notre duo rencontre les dirigeants du label techno écossais Soma." - } - } - }, - "extensions": { - "queryCost": 3 - } -} diff --git a/tests/fixtures/deezer.artist.related.json b/tests/fixtures/deezer.artist.related.json deleted file mode 100644 index 2a55b303e..000000000 --- a/tests/fixtures/deezer.artist.related.json +++ /dev/null @@ -1 +0,0 @@ -{"data":[{"id":6404,"name":"Justice","link":"https:\/\/www.deezer.com\/artist\/6404","picture":"https:\/\/api.deezer.com\/artist\/6404\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/1000x1000-000000-80-0-0.jpg","nb_album":41,"nb_fan":774236,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/6404\/top?limit=50","type":"artist"},{"id":2049,"name":"Cassius","link":"https:\/\/www.deezer.com\/artist\/2049","picture":"https:\/\/api.deezer.com\/artist\/2049\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/1000x1000-000000-80-0-0.jpg","nb_album":25,"nb_fan":127692,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2049\/top?limit=50","type":"artist"},{"id":2318,"name":"Etienne de Cr\u00e9cy","link":"https:\/\/www.deezer.com\/artist\/2318","picture":"https:\/\/api.deezer.com\/artist\/2318\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/1000x1000-000000-80-0-0.jpg","nb_album":58,"nb_fan":104626,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2318\/top?limit=50","type":"artist"},{"id":72041,"name":"Yuksek","link":"https:\/\/www.deezer.com\/artist\/72041","picture":"https:\/\/api.deezer.com\/artist\/72041\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/1000x1000-000000-80-0-0.jpg","nb_album":102,"nb_fan":115772,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/72041\/top?limit=50","type":"artist"},{"id":81,"name":"The Chemical Brothers","link":"https:\/\/www.deezer.com\/artist\/81","picture":"https:\/\/api.deezer.com\/artist\/81\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/1000x1000-000000-80-0-0.jpg","nb_album":83,"nb_fan":1433333,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/81\/top?limit=50","type":"artist"},{"id":3771,"name":"Mr. Oizo","link":"https:\/\/www.deezer.com\/artist\/3771","picture":"https:\/\/api.deezer.com\/artist\/3771\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/1000x1000-000000-80-0-0.jpg","nb_album":31,"nb_fan":172085,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/3771\/top?limit=50","type":"artist"},{"id":9905,"name":"Alex Gopher","link":"https:\/\/www.deezer.com\/artist\/9905","picture":"https:\/\/api.deezer.com\/artist\/9905\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/1000x1000-000000-80-0-0.jpg","nb_album":46,"nb_fan":10430,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/9905\/top?limit=50","type":"artist"},{"id":7914,"name":"Demon","link":"https:\/\/www.deezer.com\/artist\/7914","picture":"https:\/\/api.deezer.com\/artist\/7914\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/1000x1000-000000-80-0-0.jpg","nb_album":21,"nb_fan":9286,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7914\/top?limit=50","type":"artist"},{"id":8937,"name":"SebastiAn","link":"https:\/\/www.deezer.com\/artist\/8937","picture":"https:\/\/api.deezer.com\/artist\/8937\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/1000x1000-000000-80-0-0.jpg","nb_album":48,"nb_fan":74884,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/8937\/top?limit=50","type":"artist"},{"id":2508,"name":"Digitalism","link":"https:\/\/www.deezer.com\/artist\/2508","picture":"https:\/\/api.deezer.com\/artist\/2508\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/1000x1000-000000-80-0-0.jpg","nb_album":79,"nb_fan":158628,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2508\/top?limit=50","type":"artist"},{"id":11703,"name":"Alan Braxe","link":"https:\/\/www.deezer.com\/artist\/11703","picture":"https:\/\/api.deezer.com\/artist\/11703\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/1000x1000-000000-80-0-0.jpg","nb_album":25,"nb_fan":12595,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11703\/top?limit=50","type":"artist"},{"id":574,"name":"Para One","link":"https:\/\/www.deezer.com\/artist\/574","picture":"https:\/\/api.deezer.com\/artist\/574\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/1000x1000-000000-80-0-0.jpg","nb_album":40,"nb_fan":30828,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/574\/top?limit=50","type":"artist"},{"id":4397,"name":"Kojak","link":"https:\/\/www.deezer.com\/artist\/4397","picture":"https:\/\/api.deezer.com\/artist\/4397\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/1000x1000-000000-80-0-0.jpg","nb_album":55,"nb_fan":1522,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/4397\/top?limit=50","type":"artist"},{"id":12439,"name":"Busy P","link":"https:\/\/www.deezer.com\/artist\/12439","picture":"https:\/\/api.deezer.com\/artist\/12439\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/1000x1000-000000-80-0-0.jpg","nb_album":12,"nb_fan":65585,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/12439\/top?limit=50","type":"artist"},{"id":11656979,"name":"Mr Flash","link":"https:\/\/www.deezer.com\/artist\/11656979","picture":"https:\/\/api.deezer.com\/artist\/11656979\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/1000x1000-000000-80-0-0.jpg","nb_album":7,"nb_fan":769,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11656979\/top?limit=50","type":"artist"},{"id":76,"name":"Fatboy Slim","link":"https:\/\/www.deezer.com\/artist\/76","picture":"https:\/\/api.deezer.com\/artist\/76\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/1000x1000-000000-80-0-0.jpg","nb_album":76,"nb_fan":1231355,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/76\/top?limit=50","type":"artist"},{"id":11265,"name":"Lifelike","link":"https:\/\/www.deezer.com\/artist\/11265","picture":"https:\/\/api.deezer.com\/artist\/11265\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/1000x1000-000000-80-0-0.jpg","nb_album":38,"nb_fan":8316,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11265\/top?limit=50","type":"artist"},{"id":2048,"name":"Groove Armada","link":"https:\/\/www.deezer.com\/artist\/2048","picture":"https:\/\/api.deezer.com\/artist\/2048\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/1000x1000-000000-80-0-0.jpg","nb_album":92,"nb_fan":173879,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2048\/top?limit=50","type":"artist"},{"id":71708,"name":"Surkin","link":"https:\/\/www.deezer.com\/artist\/71708","picture":"https:\/\/api.deezer.com\/artist\/71708\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/1000x1000-000000-80-0-0.jpg","nb_album":15,"nb_fan":23101,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/71708\/top?limit=50","type":"artist"},{"id":166713,"name":"Fred Falke","link":"https:\/\/www.deezer.com\/artist\/166713","picture":"https:\/\/api.deezer.com\/artist\/166713\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/1000x1000-000000-80-0-0.jpg","nb_album":67,"nb_fan":9688,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/166713\/top?limit=50","type":"artist"}],"total":20} \ No newline at end of file diff --git a/tests/fixtures/deezer.artist.top.json b/tests/fixtures/deezer.artist.top.json deleted file mode 100644 index e3f22a1aa..000000000 --- a/tests/fixtures/deezer.artist.top.json +++ /dev/null @@ -1 +0,0 @@ -{"data":[{"id":67238732,"readable":true,"title":"Instant Crush (feat. Julian Casablancas)","title_short":"Instant Crush","title_version":"(feat. Julian Casablancas)","link":"https:\/\/www.deezer.com\/track\/67238732","duration":337,"rank":944042,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/d\/6\/b\/0\/d6bc80aadfa1d7625d59a6620f229371.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/d\/6\/b\/0\/d6bc80aadfa1d7625d59a6620f229371.mp3*~data=user_id=0,application_id=42~hmac=66213cecf953c7ef8b4d89e3539a1355d318679c5ab54cac2007d4effa6c3bf4","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":295821,"name":"Julian Casablancas","link":"https:\/\/www.deezer.com\/artist\/295821","share":"https:\/\/www.deezer.com\/artist\/295821?utm_source=deezer&utm_content=artist-295821&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/295821\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/295821\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"311bba0fc112d15f72c8b5a65f0456c1","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6575789,"title":"Random Access Memories","cover":"https:\/\/api.deezer.com\/album\/6575789\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/1000x1000-000000-80-0-0.jpg","md5_image":"311bba0fc112d15f72c8b5a65f0456c1","tracklist":"https:\/\/api.deezer.com\/album\/6575789\/tracks","type":"album"},"type":"track"},{"id":3135553,"readable":true,"title":"One More Time","title_short":"One More Time","title_version":"","link":"https:\/\/www.deezer.com\/track\/3135553","duration":320,"rank":888570,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/f\/8\/c\/0\/f8c5dc3837912dba37c9a1ab3170cc3f.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/f\/8\/c\/0\/f8c5dc3837912dba37c9a1ab3170cc3f.mp3*~data=user_id=0,application_id=42~hmac=0824ec7ad045b82c04904fcd5f2a8ec2175acbe3d1649030d457023fdef45620","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"5718f7c81c27e0b2417e2a4c45224f8a","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":302127,"title":"Discovery","cover":"https:\/\/api.deezer.com\/album\/302127\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/1000x1000-000000-80-0-0.jpg","md5_image":"5718f7c81c27e0b2417e2a4c45224f8a","tracklist":"https:\/\/api.deezer.com\/album\/302127\/tracks","type":"album"},"type":"track"},{"id":66609426,"readable":true,"title":"Get Lucky (Radio Edit - feat. Pharrell Williams and Nile Rodgers)","title_short":"Get Lucky","title_version":"(Radio Edit - feat. Pharrell Williams and Nile Rodgers)","link":"https:\/\/www.deezer.com\/track\/66609426","duration":248,"rank":952197,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/1\/b\/f\/0\/1bf80a82992903ff685ba1b7275223f8.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/1\/b\/f\/0\/1bf80a82992903ff685ba1b7275223f8.mp3*~data=user_id=0,application_id=42~hmac=c6dfe58571df62f41e7b326dd9afebf87015541c06a521ebc88fc18671d8d06d","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":103,"name":"Pharrell Williams","link":"https:\/\/www.deezer.com\/artist\/103","share":"https:\/\/www.deezer.com\/artist\/103?utm_source=deezer&utm_content=artist-103&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/103\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/103\/top?limit=50","type":"artist","role":"Main"},{"id":7207,"name":"Nile Rodgers","link":"https:\/\/www.deezer.com\/artist\/7207","share":"https:\/\/www.deezer.com\/artist\/7207?utm_source=deezer&utm_content=artist-7207&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/7207\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7207\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"bc49adb87758e0c8c4e508a9c5cce85d","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6516139,"title":"Get Lucky (Radio Edit - feat. Pharrell Williams and Nile Rodgers)","cover":"https:\/\/api.deezer.com\/album\/6516139\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/1000x1000-000000-80-0-0.jpg","md5_image":"bc49adb87758e0c8c4e508a9c5cce85d","tracklist":"https:\/\/api.deezer.com\/album\/6516139\/tracks","type":"album"},"type":"track"},{"id":67238735,"readable":true,"title":"Get Lucky (feat. Pharrell Williams and Nile Rodgers)","title_short":"Get Lucky","title_version":"(feat. Pharrell Williams and Nile Rodgers)","link":"https:\/\/www.deezer.com\/track\/67238735","duration":367,"rank":873875,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/c\/8\/a\/0\/c8a61130657a2cf58e3ac751e7950617.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/c\/8\/a\/0\/c8a61130657a2cf58e3ac751e7950617.mp3*~data=user_id=0,application_id=42~hmac=92002e6bade5ff82dd44751e8998beaa60844210df1d73b8f1bf7dafb02dc5c3","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":103,"name":"Pharrell Williams","link":"https:\/\/www.deezer.com\/artist\/103","share":"https:\/\/www.deezer.com\/artist\/103?utm_source=deezer&utm_content=artist-103&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/103\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/103\/top?limit=50","type":"artist","role":"Main"},{"id":7207,"name":"Nile Rodgers","link":"https:\/\/www.deezer.com\/artist\/7207","share":"https:\/\/www.deezer.com\/artist\/7207?utm_source=deezer&utm_content=artist-7207&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/7207\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7207\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"311bba0fc112d15f72c8b5a65f0456c1","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6575789,"title":"Random Access Memories","cover":"https:\/\/api.deezer.com\/album\/6575789\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/1000x1000-000000-80-0-0.jpg","md5_image":"311bba0fc112d15f72c8b5a65f0456c1","tracklist":"https:\/\/api.deezer.com\/album\/6575789\/tracks","type":"album"},"type":"track"},{"id":3129775,"readable":true,"title":"Around the World","title_short":"Around the World","title_version":"","link":"https:\/\/www.deezer.com\/track\/3129775","duration":429,"rank":829911,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/a\/4\/7\/0\/a47dbed01e6d9b0ac4e39a134f745ca2.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/a\/4\/7\/0\/a47dbed01e6d9b0ac4e39a134f745ca2.mp3*~data=user_id=0,application_id=42~hmac=9b7aa12b647cabd3219779e0270e51e639dc326442071fceb6d723c331059a67","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"b870579c8650cd59b1cce656dde2ef17","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":301775,"title":"Homework","cover":"https:\/\/api.deezer.com\/album\/301775\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/1000x1000-000000-80-0-0.jpg","md5_image":"b870579c8650cd59b1cce656dde2ef17","tracklist":"https:\/\/api.deezer.com\/album\/301775\/tracks","type":"album"},"type":"track"}],"total":100,"next":"https:\/\/api.deezer.com\/artist\/27\/top?index=5"} \ No newline at end of file diff --git a/tests/fixtures/deezer.search.artist.json b/tests/fixtures/deezer.search.artist.json deleted file mode 100644 index 29f138d34..000000000 --- a/tests/fixtures/deezer.search.artist.json +++ /dev/null @@ -1 +0,0 @@ -{"data":[{"id":259,"name":"Michael Jackson","link":"https:\/\/www.deezer.com\/artist\/259","picture":"https:\/\/api.deezer.com\/artist\/259\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/97fae13b2b30e4aec2e8c9e0c7839d92\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/97fae13b2b30e4aec2e8c9e0c7839d92\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/97fae13b2b30e4aec2e8c9e0c7839d92\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/97fae13b2b30e4aec2e8c9e0c7839d92\/1000x1000-000000-80-0-0.jpg","nb_album":43,"nb_fan":12074101,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/259\/top?limit=50","type":"artist"},{"id":719,"name":"Bob Marley & The Wailers","link":"https:\/\/www.deezer.com\/artist\/719","picture":"https:\/\/api.deezer.com\/artist\/719\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c8241e15efdefa9465c7b470643efb3b\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c8241e15efdefa9465c7b470643efb3b\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c8241e15efdefa9465c7b470643efb3b\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c8241e15efdefa9465c7b470643efb3b\/1000x1000-000000-80-0-0.jpg","nb_album":80,"nb_fan":12014466,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/719\/top?limit=50","type":"artist"},{"id":14031649,"name":"jay emcee, Micheal Jackson","link":"https:\/\/www.deezer.com\/artist\/14031649","picture":"https:\/\/api.deezer.com\/artist\/14031649\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":104,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/14031649\/top?limit=50","type":"artist"},{"id":137159102,"name":"Micheal Collins The Mic Jackson Of Rap","link":"https:\/\/www.deezer.com\/artist\/137159102","picture":"https:\/\/api.deezer.com\/artist\/137159102\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":13,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/137159102\/top?limit=50","type":"artist"},{"id":259786511,"name":"Consev","link":"https:\/\/www.deezer.com\/artist\/259786511","picture":"https:\/\/api.deezer.com\/artist\/259786511\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/342048482aae9fb1f1272510b1c1f54a\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/342048482aae9fb1f1272510b1c1f54a\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/342048482aae9fb1f1272510b1c1f54a\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/342048482aae9fb1f1272510b1c1f54a\/1000x1000-000000-80-0-0.jpg","nb_album":7,"nb_fan":1,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/259786511\/top?limit=50","type":"artist"},{"id":262255,"name":"Michael Jackson Tribute","link":"https:\/\/www.deezer.com\/artist\/262255","picture":"https:\/\/api.deezer.com\/artist\/262255\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c2f4036de3a412d9b84a213663163189\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c2f4036de3a412d9b84a213663163189\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c2f4036de3a412d9b84a213663163189\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c2f4036de3a412d9b84a213663163189\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":9339,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/262255\/top?limit=50","type":"artist"},{"id":193820797,"name":"Michael Jackman","link":"https:\/\/www.deezer.com\/artist\/193820797","picture":"https:\/\/api.deezer.com\/artist\/193820797\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/da3f7e21c8e78bd2048f47ab60f5399c\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/da3f7e21c8e78bd2048f47ab60f5399c\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/da3f7e21c8e78bd2048f47ab60f5399c\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/da3f7e21c8e78bd2048f47ab60f5399c\/1000x1000-000000-80-0-0.jpg","nb_album":1,"nb_fan":0,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/193820797\/top?limit=50","type":"artist"},{"id":374060,"name":"Simply The Best Sax: The Hits Of Michael Jackson","link":"https:\/\/www.deezer.com\/artist\/374060","picture":"https:\/\/api.deezer.com\/artist\/374060\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/3bfd0455b269dd2ccb25776c6b26197c\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/3bfd0455b269dd2ccb25776c6b26197c\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/3bfd0455b269dd2ccb25776c6b26197c\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/3bfd0455b269dd2ccb25776c6b26197c\/1000x1000-000000-80-0-0.jpg","nb_album":1,"nb_fan":1507,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/374060\/top?limit=50","type":"artist"},{"id":4969823,"name":"Jackson Michael","link":"https:\/\/www.deezer.com\/artist\/4969823","picture":"https:\/\/api.deezer.com\/artist\/4969823\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e25091f350716af9f4484939de692de6\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e25091f350716af9f4484939de692de6\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e25091f350716af9f4484939de692de6\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e25091f350716af9f4484939de692de6\/1000x1000-000000-80-0-0.jpg","nb_album":1,"nb_fan":17,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/4969823\/top?limit=50","type":"artist"},{"id":1278001,"name":"David Michael Jackson","link":"https:\/\/www.deezer.com\/artist\/1278001","picture":"https:\/\/api.deezer.com\/artist\/1278001\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/fe2e46ba3c550a4747ca5fd9026d6f95\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/fe2e46ba3c550a4747ca5fd9026d6f95\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/fe2e46ba3c550a4747ca5fd9026d6f95\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/fe2e46ba3c550a4747ca5fd9026d6f95\/1000x1000-000000-80-0-0.jpg","nb_album":54,"nb_fan":178,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/1278001\/top?limit=50","type":"artist"},{"id":4142968,"name":"Cheyenne Jackson, Michael Feinstein","link":"https:\/\/www.deezer.com\/artist\/4142968","picture":"https:\/\/api.deezer.com\/artist\/4142968\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":251,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/4142968\/top?limit=50","type":"artist"},{"id":766502,"name":"Michael Jackson Tribute Band","link":"https:\/\/www.deezer.com\/artist\/766502","picture":"https:\/\/api.deezer.com\/artist\/766502\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/82614ae5c3f98c571369f49aba675d1e\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/82614ae5c3f98c571369f49aba675d1e\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/82614ae5c3f98c571369f49aba675d1e\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/82614ae5c3f98c571369f49aba675d1e\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":623,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/766502\/top?limit=50","type":"artist"},{"id":1394615,"name":"Michael Jameson","link":"https:\/\/www.deezer.com\/artist\/1394615","picture":"https:\/\/api.deezer.com\/artist\/1394615\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0f680ba29a076b70d5027a68ad3a5c58\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0f680ba29a076b70d5027a68ad3a5c58\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0f680ba29a076b70d5027a68ad3a5c58\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0f680ba29a076b70d5027a68ad3a5c58\/1000x1000-000000-80-0-0.jpg","nb_album":7,"nb_fan":78,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/1394615\/top?limit=50","type":"artist"},{"id":490836,"name":"Michael Blackson","link":"https:\/\/www.deezer.com\/artist\/490836","picture":"https:\/\/api.deezer.com\/artist\/490836\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/caa27786458e9459819f6ea28c4ed1cb\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/caa27786458e9459819f6ea28c4ed1cb\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/caa27786458e9459819f6ea28c4ed1cb\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/caa27786458e9459819f6ea28c4ed1cb\/1000x1000-000000-80-0-0.jpg","nb_album":1,"nb_fan":391,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/490836\/top?limit=50","type":"artist"},{"id":1229617,"name":"The Michael Jackson Tribute Band","link":"https:\/\/www.deezer.com\/artist\/1229617","picture":"https:\/\/api.deezer.com\/artist\/1229617\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":344,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/1229617\/top?limit=50","type":"artist"},{"id":3662911,"name":"Fran London feat. Michael Jackson","link":"https:\/\/www.deezer.com\/artist\/3662911","picture":"https:\/\/api.deezer.com\/artist\/3662911\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":247,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/3662911\/top?limit=50","type":"artist"},{"id":13014917,"name":"Scott Michael Bennett, Naomi Jackson, Gary Sewell & The Emmanuel Quartet","link":"https:\/\/www.deezer.com\/artist\/13014917","picture":"https:\/\/api.deezer.com\/artist\/13014917\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/\/1000x1000-000000-80-0-0.jpg","nb_album":0,"nb_fan":66,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/13014917\/top?limit=50","type":"artist"}],"total":17} \ No newline at end of file diff --git a/tests/fixtures/empty.txt b/tests/fixtures/empty.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/fixtures/invalid-files/test-invalid-frame.mp3 b/tests/fixtures/invalid-files/test-invalid-frame.mp3 deleted file mode 100644 index 2f0fbc7b2..000000000 Binary files a/tests/fixtures/invalid-files/test-invalid-frame.mp3 and /dev/null differ diff --git a/tests/fixtures/lastfm.album.getinfo.empty.json b/tests/fixtures/lastfm.album.getinfo.empty.json deleted file mode 100644 index 06403ff8c..000000000 --- a/tests/fixtures/lastfm.album.getinfo.empty.json +++ /dev/null @@ -1 +0,0 @@ -{"album":{"artist":"Legião Urbana","mbid":"1749dd07-5aa9-436e-babc-e3e982deb273","tags":{"tag":[{"url":"https:\/\/www.last.fm\/tag\/rock","name":"rock"},{"url":"https:\/\/www.last.fm\/tag\/80s","name":"80s"},{"url":"https:\/\/www.last.fm\/tag\/brazilian","name":"brazilian"},{"url":"https:\/\/www.last.fm\/tag\/brasil","name":"brasil"},{"url":"https:\/\/www.last.fm\/tag\/brazilian+rock","name":"brazilian rock"}]},"name":"Dois","image":[{"size":"small","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/34s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"medium","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/64s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"large","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/174s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"extralarge","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"mega","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"}],"tracks":{"track":[{"streamable":{"fulltrack":"0","#text":"0"},"duration":232,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Daniel+na+Cova+dos+Le%C3%B5es","name":"Daniel na Cova dos Leões","@attr":{"rank":1},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":null,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Quase+Sem+Querer","name":"Quase Sem Querer","@attr":{"rank":2},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":280,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Acrilic+On+Canvas","name":"Acrilic On Canvas","@attr":{"rank":3},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":271,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Eduardo+e+M%C3%B4nica","name":"Eduardo e Mônica","@attr":{"rank":4},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":94,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Central+Do+Brasil","name":"Central Do Brasil","@attr":{"rank":5},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":302,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Tempo+Perdido","name":"Tempo Perdido","@attr":{"rank":6},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":170,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Metr%C3%B3pole","name":"Metrópole","@attr":{"rank":7},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":175,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Plantas+Em+Baixo+Do+Aqu%C3%A1rio","name":"Plantas Em Baixo Do Aquário","@attr":{"rank":8},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":162,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/M%C3%BAsica+urbana+2","name":"Música urbana 2","@attr":{"rank":9},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":184,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Andrea+Doria","name":"Andrea Doria","@attr":{"rank":10},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":231,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/F%C3%A1brica","name":"Fábrica","@attr":{"rank":11},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":258,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/%C3%8Dndios","name":"Índios","@attr":{"rank":12},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}}]},"listeners":"494356","playcount":"9833783","url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois"}} \ No newline at end of file diff --git a/tests/fixtures/lastfm.album.getinfo.en.json b/tests/fixtures/lastfm.album.getinfo.en.json deleted file mode 100644 index f5444f35b..000000000 --- a/tests/fixtures/lastfm.album.getinfo.en.json +++ /dev/null @@ -1 +0,0 @@ -{"album":{"artist":"Legião Urbana","mbid":"1749dd07-5aa9-436e-babc-e3e982deb273","tags":{"tag":[{"url":"https:\/\/www.last.fm\/tag\/rock","name":"rock"},{"url":"https:\/\/www.last.fm\/tag\/80s","name":"80s"},{"url":"https:\/\/www.last.fm\/tag\/brazilian","name":"brazilian"},{"url":"https:\/\/www.last.fm\/tag\/brasil","name":"brasil"},{"url":"https:\/\/www.last.fm\/tag\/brazilian+rock","name":"brazilian rock"}]},"playcount":"9833783","image":[{"size":"small","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/34s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"medium","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/64s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"large","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/174s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"extralarge","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"mega","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"}],"tracks":{"track":[{"streamable":{"fulltrack":"0","#text":"0"},"duration":232,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Daniel+na+Cova+dos+Le%C3%B5es","name":"Daniel na Cova dos Leões","@attr":{"rank":1},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":null,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Quase+Sem+Querer","name":"Quase Sem Querer","@attr":{"rank":2},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":280,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Acrilic+On+Canvas","name":"Acrilic On Canvas","@attr":{"rank":3},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":271,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Eduardo+e+M%C3%B4nica","name":"Eduardo e Mônica","@attr":{"rank":4},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":94,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Central+Do+Brasil","name":"Central Do Brasil","@attr":{"rank":5},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":302,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Tempo+Perdido","name":"Tempo Perdido","@attr":{"rank":6},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":170,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Metr%C3%B3pole","name":"Metrópole","@attr":{"rank":7},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":175,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Plantas+Em+Baixo+Do+Aqu%C3%A1rio","name":"Plantas Em Baixo Do Aquário","@attr":{"rank":8},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":162,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/M%C3%BAsica+urbana+2","name":"Música urbana 2","@attr":{"rank":9},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":184,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Andrea+Doria","name":"Andrea Doria","@attr":{"rank":10},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":231,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/F%C3%A1brica","name":"Fábrica","@attr":{"rank":11},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":258,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/%C3%8Dndios","name":"Índios","@attr":{"rank":12},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}}]},"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois","name":"Dois","listeners":"494356","wiki":{"published":"10 Oct 2023, 13:56","summary":"Dois é o segundo álbum de estúdio da banda brasileira de rock Legião Urbana, lançado em 20 de julho de 1986 pela EMI. Ocupa a 21ª posição da lista dos 100 maiores discos da música brasileira pela Rolling Stone Brasil. Em setembro de 2012, foi eleito pelo público da rádio Eldorado FM, do portal Estadao.com e do Caderno C2+Música (estes dois últimos pertencentes ao jornal O Estado de S. Paulo) como o terceiro melhor disco brasileiro da história. O álbum vendeu mais de 900 mil cópias no Brasil. \"Tempo Perdido\" fez um grande sucesso e se tornou num dos clássicos <a href=\"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\">Read more on Last.fm<\/a>.","content":"Dois é o segundo álbum de estúdio da banda brasileira de rock Legião Urbana, lançado em 20 de julho de 1986 pela EMI. Ocupa a 21ª posição da lista dos 100 maiores discos da música brasileira pela Rolling Stone Brasil. Em setembro de 2012, foi eleito pelo público da rádio Eldorado FM, do portal Estadao.com e do Caderno C2+Música (estes dois últimos pertencentes ao jornal O Estado de S. Paulo) como o terceiro melhor disco brasileiro da história. O álbum vendeu mais de 900 mil cópias no Brasil. \"Tempo Perdido\" fez um grande sucesso e se tornou num dos clássicos da Legião. \"Eduardo e Mônica\", \"\"Índios\"\" e \"Quase sem Querer\" também fizeram sucesso. <a href=\"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\">Read more on Last.fm<\/a>. User-contributed text is available under the Creative Commons By-SA License; additional terms may apply."}}} \ No newline at end of file diff --git a/tests/fixtures/lastfm.artist.getinfo.empty.json b/tests/fixtures/lastfm.artist.getinfo.empty.json deleted file mode 100644 index 015b51701..000000000 --- a/tests/fixtures/lastfm.artist.getinfo.empty.json +++ /dev/null @@ -1 +0,0 @@ -{"artist":{"name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99","url":"https://www.last.fm/music/+noredirect/Legi%C3%A3o+Urbana","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}],"streamable":"0","ontour":"0","stats":{"listeners":"740591","playcount":"44493504"},"similar":{"artist":[{"name":"Renato Russo","url":"https://www.last.fm/music/Renato+Russo","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Engenheiros Do Hawaii","url":"https://www.last.fm/music/Engenheiros+Do+Hawaii","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Os Paralamas Do Sucesso","url":"https://www.last.fm/music/Os+Paralamas+Do+Sucesso","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Barão Vermelho","url":"https://www.last.fm/music/Bar%C3%A3o+Vermelho","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Capital Inicial","url":"https://www.last.fm/music/Capital+Inicial","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]}]},"tags":{"tag":[{"name":"rock","url":"https://www.last.fm/tag/rock"},{"name":"brazilian rock","url":"https://www.last.fm/tag/brazilian+rock"},{"name":"80s","url":"https://www.last.fm/tag/80s"},{"name":"brazilian","url":"https://www.last.fm/tag/brazilian"},{"name":"brasil","url":"https://www.last.fm/tag/brasil"}]},"bio":{"links":{"link":{"#text":"","rel":"original","href":"https://last.fm/music/+noredirect/Legi%C3%A3o+Urbana/+wiki"}},"published":"01 Jan 1970, 00:00","summary":" <a href=\"https://www.last.fm/music/+noredirect/Legi%C3%A3o+Urbana\">Read more on Last.fm</a>","content":""}}} \ No newline at end of file diff --git a/tests/fixtures/lastfm.artist.getinfo.en.json b/tests/fixtures/lastfm.artist.getinfo.en.json deleted file mode 100644 index 6c643b8e2..000000000 --- a/tests/fixtures/lastfm.artist.getinfo.en.json +++ /dev/null @@ -1 +0,0 @@ -{"artist":{"name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99","url":"https://www.last.fm/music/+noredirect/Legi%C3%A3o+Urbana","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}],"streamable":"0","ontour":"0","stats":{"listeners":"740367","playcount":"44476703"},"similar":{"artist":[{"name":"Renato Russo","url":"https://www.last.fm/music/Renato+Russo","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Engenheiros Do Hawaii","url":"https://www.last.fm/music/Engenheiros+Do+Hawaii","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Os Paralamas Do Sucesso","url":"https://www.last.fm/music/Os+Paralamas+Do+Sucesso","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Barão Vermelho","url":"https://www.last.fm/music/Bar%C3%A3o+Vermelho","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Capital Inicial","url":"https://www.last.fm/music/Capital+Inicial","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]}]},"tags":{"tag":[{"name":"rock","url":"https://www.last.fm/tag/rock"},{"name":"brazilian rock","url":"https://www.last.fm/tag/brazilian+rock"},{"name":"80s","url":"https://www.last.fm/tag/80s"},{"name":"brazilian","url":"https://www.last.fm/tag/brazilian"},{"name":"brasil","url":"https://www.last.fm/tag/brasil"}]},"bio":{"links":{"link":{"#text":"","rel":"original","href":"https://last.fm/music/+noredirect/Legi%C3%A3o+Urbana/+wiki"}},"published":"03 Mar 2006, 04:04","summary":"Legião Urbana was a Brazilian post-punk band from Brasília, Distrito Federal, Brazil.\n\nFronted by lead singer and lyricist Renato Russo, Legião Urbana was founded in 1983 and existed until 1996, when Renato passed away due to complications caused by AIDS. Besides being the lead vocalist, Renato was an occasional guitar, bass and keyboards player. He also wrote most of the band's songs. In 13 years of career, they released 8 studio albums - the last being posthumous - and one live. <a href=\"https://www.last.fm/music/+noredirect/Legi%C3%A3o+Urbana\">Read more on Last.fm</a>","content":"Legião Urbana was a Brazilian post-punk band from Brasília, Distrito Federal, Brazil.\n\nFronted by lead singer and lyricist Renato Russo, Legião Urbana was founded in 1983 and existed until 1996, when Renato passed away due to complications caused by AIDS. Besides being the lead vocalist, Renato was an occasional guitar, bass and keyboards player. He also wrote most of the band's songs. In 13 years of career, they released 8 studio albums - the last being posthumous - and one live.\n\nLegião Urbana is probably the most famous Brazilian rock bands, especially known for Renato's poetic lyrics, which range from love and spiritualism to politics, family, sex and drugs.\n\nNowadays, Dado Villa-Lobos (ex-guitar player of Legião Urbana) has a solo career and recorded his first album, named \"Jardim De Cactus\", in 2005. Drum player Marcelo Bonfá also tried a solo career. <a href=\"https://www.last.fm/music/+noredirect/Legi%C3%A3o+Urbana\">Read more on Last.fm</a>. User-contributed text is available under the Creative Commons By-SA License; additional terms may apply."}}} \ No newline at end of file diff --git a/tests/fixtures/lastfm.artist.page.challenge.html b/tests/fixtures/lastfm.artist.page.challenge.html deleted file mode 100644 index f431891bd..000000000 --- a/tests/fixtures/lastfm.artist.page.challenge.html +++ /dev/null @@ -1,88 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> - <head> - <meta - http-equiv="Content-Security-Policy" - content="default-src 'self'; img-src 'self' data:; media-src 'self' data:; object-src 'none'; style-src 'self' 'sha256-o4vzfmmUENEg4chMjjRP9EuW9ucGnGIGVdbl8d0SHQQ='; script-src 'self' 'sha256-a9bHdQGvRzDwDVzx8m+Rzw+0FHZad8L0zjtBwkxOIz4=';" - /> - <link - href="/_fs-ch-1T1wmsGaOgGaSxcX/assets/inter-var.woff2" - rel="preload" - as="font" - type="font/woff2" - crossorigin - /> - <link href="/_fs-ch-1T1wmsGaOgGaSxcX/assets/styles.css" rel="stylesheet" /> - <meta name="viewport" content="width=device-width, initial-scale=1" /> - <title>Client Challenge - - - - - - - - diff --git a/tests/fixtures/lastfm.artist.page.html b/tests/fixtures/lastfm.artist.page.html deleted file mode 100644 index 1922e313b..000000000 --- a/tests/fixtures/lastfm.artist.page.html +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/tests/fixtures/lastfm.artist.page.ignored.html b/tests/fixtures/lastfm.artist.page.ignored.html deleted file mode 100644 index 96eda2377..000000000 --- a/tests/fixtures/lastfm.artist.page.ignored.html +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/tests/fixtures/lastfm.artist.page.no_meta.html b/tests/fixtures/lastfm.artist.page.no_meta.html deleted file mode 100644 index aa7b9c934..000000000 --- a/tests/fixtures/lastfm.artist.page.no_meta.html +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/tests/fixtures/lastfm.track.getsimilar.json b/tests/fixtures/lastfm.track.getsimilar.json deleted file mode 100644 index 45041b289..000000000 --- a/tests/fixtures/lastfm.track.getsimilar.json +++ /dev/null @@ -1 +0,0 @@ -{"similartracks":{"track":[{"name":"Dreaming of Me","mbid":"027b553e-7c74-3ed4-a95e-1d4fea51f174","match":1.0,"url":"https://www.last.fm/music/Depeche+Mode/_/Dreaming+of+Me","artist":{"name":"Depeche Mode","mbid":"8538e728-ca0b-4321-b7e5-cff6565dd4c0","url":"https://www.last.fm/music/Depeche+Mode"}},{"name":"Everything Counts","mbid":"5a5a3ca4-bdb8-4641-a674-9b54b9b319a6","match":0.892602,"url":"https://www.last.fm/music/Depeche+Mode/_/Everything+Counts","artist":{"name":"Depeche Mode","mbid":"8538e728-ca0b-4321-b7e5-cff6565dd4c0","url":"https://www.last.fm/music/Depeche+Mode"}},{"name":"Don't You Want Me","mbid":"","match":0.491341,"url":"https://www.last.fm/music/The+Human+League/_/Don%27t+You+Want+Me","artist":{"name":"The Human League","mbid":"7adaabfb-acfb-47bc-8c7c-59471c2f0db8","url":"https://www.last.fm/music/The+Human+League"}},{"name":"Tainted Love","mbid":"","match":0.454811,"url":"https://www.last.fm/music/Soft+Cell/_/Tainted+Love","artist":{"name":"Soft Cell","mbid":"7fb50287-029d-47cc-825a-235ca28024b2","url":"https://www.last.fm/music/Soft+Cell"}},{"name":"Blue Monday","mbid":"727e84c6-1b56-31dd-a958-a5f46305cec0","match":0.381057,"url":"https://www.last.fm/music/New+Order/_/Blue+Monday","artist":{"name":"New Order","mbid":"f1106b17-dcbb-45f6-b938-199ccfab50cc","url":"https://www.last.fm/music/New+Order"}}],"@attr":{"artist":"Depeche Mode","track":"Just Can't Get Enough"}}} diff --git a/tests/fixtures/lastfm.track.getsimilar.unknown.json b/tests/fixtures/lastfm.track.getsimilar.unknown.json deleted file mode 100644 index 9b879fa05..000000000 --- a/tests/fixtures/lastfm.track.getsimilar.unknown.json +++ /dev/null @@ -1 +0,0 @@ -{"similartracks":{"track":[],"@attr":{"track":"UnknownTrack","artist":"UnknownArtist"}}} diff --git a/tests/fixtures/listenbrainz.artist.metadata.homepage.json b/tests/fixtures/listenbrainz.artist.metadata.homepage.json deleted file mode 100644 index be15ea45f..000000000 --- a/tests/fixtures/listenbrainz.artist.metadata.homepage.json +++ /dev/null @@ -1,19 +0,0 @@ -[ - { - "area": "Japan", - "artist_mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", - "begin_year": 2012, - "mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", - "name": "Mili", - "rels": { - "free streaming": "https://www.deezer.com/artist/56563392", - "official homepage": "http://projectmili.com/", - "purchase for download": "https://recochoku.jp/artist/2000285803/", - "social network": "https://www.instagram.com/projectmili/", - "streaming": "https://tidal.com/artist/3848902", - "wikidata": "https://www.wikidata.org/wiki/Q27309228", - "youtube": "https://www.youtube.com/channel/UCVh47EKH9VLresRqiYi9txw" - }, - "type": "Group" - } -] diff --git a/tests/fixtures/listenbrainz.artist.metadata.no_homepage.json b/tests/fixtures/listenbrainz.artist.metadata.no_homepage.json deleted file mode 100644 index 6105556a2..000000000 --- a/tests/fixtures/listenbrainz.artist.metadata.no_homepage.json +++ /dev/null @@ -1,15 +0,0 @@ -[ - { - "area": "Japan", - "artist_mbid": "7c2cc610-f998-43ef-a08f-dae3344b8973", - "mbid": "7c2cc610-f998-43ef-a08f-dae3344b8973", - "name": "Feryquitous", - "rels": { - "free streaming": "https://www.deezer.com/artist/9841008", - "purchase for download": "https://itunes.apple.com/jp/artist/id1083544578", - "social network": "https://twitter.com/Feryquitous_", - "youtube": "https://www.youtube.com/channel/UCj2nw_9puY3sJoDbkE-FCQA" - }, - "type": "Person" - } -] diff --git a/tests/fixtures/listenbrainz.labs.similar-artists.json b/tests/fixtures/listenbrainz.labs.similar-artists.json deleted file mode 100644 index 1cd2c85b5..000000000 --- a/tests/fixtures/listenbrainz.labs.similar-artists.json +++ /dev/null @@ -1 +0,0 @@ -[{"artist_mbid": "f27ec8db-af05-4f36-916e-3d57f91ecf5e", "name": "Michael Jackson", "comment": "\u201cKing of Pop\u201d", "type": "Person", "gender": "Male", "score": 800, "reference_mbid": "db92a151-1ac2-438b-bc43-b82e149ddd50"}, {"artist_mbid": "7364dea6-ca9a-48e3-be01-b44ad0d19897", "name": "a-ha", "comment": "Norwegian synth\u2010pop band", "type": "Group", "gender": null, "score": 792, "reference_mbid": "db92a151-1ac2-438b-bc43-b82e149ddd50"}] \ No newline at end of file diff --git a/tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json b/tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json deleted file mode 100644 index 61913bcf5..000000000 --- a/tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json +++ /dev/null @@ -1 +0,0 @@ -[{"recording_mbid":"12f65dca-de8f-43fe-a65d-f12a02aaadf3","recording_name":"Take On Me","artist_credit_name":"a‐ha","artist_credit_mbids":null,"release_name":"Hunting High and Low","release_mbid":"4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc","caa_id":13015069966,"caa_release_mbid":"181b9a01-0446-4601-99be-b011ab615631","score":124,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"12f65dca-de8f-43fe-a65d-f12a02aaadf3","recording_name":"Take On Me","artist_credit_name":"a‐ha","artist_credit_mbids":null,"release_name":"Hunting High and Low","release_mbid":"4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc","caa_id":13015069966,"caa_release_mbid":"181b9a01-0446-4601-99be-b011ab615631","score":124,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"80033c72-aa19-4ba8-9227-afb075fec46e","recording_name":"Wake Me Up Before You Go‐Go","artist_credit_name":"Wham!","artist_credit_mbids":null,"release_name":"Make It Big","release_mbid":"c143d542-48dc-446b-b523-1762da721638","caa_id":2622532701,"caa_release_mbid":"ec01ad0c-a28f-4d45-bed7-d73014161c38","score":65,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"ef4c6855-949e-4e22-b41e-8e0a2d372d5f","recording_name":"Tainted Love","artist_credit_name":"Soft Cell","artist_credit_mbids":null,"release_name":"Non-Stop Erotic Cabaret","release_mbid":"1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1","caa_id":1031647403,"caa_release_mbid":"c3367d3a-2f6c-48d1-95c5-c1ee7a49c479","score":61,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5","recording_name":"Everybody Wants to Rule the World","artist_credit_name":"Tears for Fears","artist_credit_mbids":null,"release_name":"Songs From the Big Chair","release_mbid":"21f19b06-81f1-347a-add5-5d0c77696597","caa_id":19682986993,"caa_release_mbid":"9aefc6dd-216a-4271-ada1-d9cf67956f39","score":68,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"}] \ No newline at end of file diff --git a/tests/fixtures/listenbrainz.labs.similar-recordings.json b/tests/fixtures/listenbrainz.labs.similar-recordings.json deleted file mode 100644 index 87323f426..000000000 --- a/tests/fixtures/listenbrainz.labs.similar-recordings.json +++ /dev/null @@ -1 +0,0 @@ -[{"recording_mbid":"12f65dca-de8f-43fe-a65d-f12a02aaadf3","recording_name":"Take On Me","artist_credit_name":"a‐ha","artist_credit_mbids":null,"release_name":"Hunting High and Low","release_mbid":"4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc","caa_id":13015069966,"caa_release_mbid":"181b9a01-0446-4601-99be-b011ab615631","score":124,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"},{"recording_mbid":"80033c72-aa19-4ba8-9227-afb075fec46e","recording_name":"Wake Me Up Before You Go‐Go","artist_credit_name":"Wham!","artist_credit_mbids":null,"release_name":"Make It Big","release_mbid":"c143d542-48dc-446b-b523-1762da721638","caa_id":2622532701,"caa_release_mbid":"ec01ad0c-a28f-4d45-bed7-d73014161c38","score":65,"reference_mbid":"8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"}] \ No newline at end of file diff --git a/tests/fixtures/listenbrainz.nowplaying.request.json b/tests/fixtures/listenbrainz.nowplaying.request.json index a9c5def08..6dec0ed66 100644 --- a/tests/fixtures/listenbrainz.nowplaying.request.json +++ b/tests/fixtures/listenbrainz.nowplaying.request.json @@ -1,24 +1 @@ -{ - "listen_type": "playing_now", - "payload": [ - { - "track_metadata": { - "artist_name": "Track Artist", - "track_name": "Track Title", - "release_name": "Track Album", - "additional_info": { - "tracknumber": 1, - "recording_mbid": "mbz-123", - "artist_names": [ - "Artist 1", "Artist 2" - ], - "artist_mbids": [ - "mbz-789", "mbz-012" - ], - "release_mbid": "mbz-456", - "duration_ms": 142200 - } - } - } - ] -} + {"listen_type": "playing_now", "payload": [{"track_metadata": { "artist_name": "Track Artist", "track_name": "Track Title", "release_name": "Track Album", "additional_info": { "tracknumber": 1, "track_mbid": "mbz-123", "artist_mbids": ["mbz-789"], "release_mbid": "mbz-456"}}}]} diff --git a/tests/fixtures/listenbrainz.popularity.json b/tests/fixtures/listenbrainz.popularity.json deleted file mode 100644 index ea459b120..000000000 --- a/tests/fixtures/listenbrainz.popularity.json +++ /dev/null @@ -1,81 +0,0 @@ -[ - { - "artist_mbids": ["d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"], - "artist_name": "Mili", - "artists": [ - { - "artist_credit_name": "Mili", - "artist_mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", - "join_phrase": "" - } - ], - "caa_id": 14987576054, - "caa_release_mbid": "38a8f6e1-0e34-4418-a89d-78240a367408", - "length": 211912, - "recording_mbid": "9980309d-3480-4e7e-89ce-fce971a452be", - "recording_name": "world.execute(me);", - "release_color": { "blue": 109, "green": 94, "red": 95 }, - "release_mbid": "38a8f6e1-0e34-4418-a89d-78240a367408", - "release_name": "Miracle Milk", - "tags": [ - { - "count": 1, - "genre_mbid": "911c7bbb-172d-4df8-9478-dbff4296e791", - "tag": "pop" - }, - { - "count": 1, - "genre_mbid": "b739a895-85ed-4ad3-8717-4e9ef5387dd8", - "tag": "dance-pop" - }, - { - "count": 1, - "genre_mbid": "9c8ba153-740e-4b88-b7ff-31d004944c95", - "tag": "nerdcore" - }, - { - "count": 1, - "genre_mbid": "c4a69842-f891-4569-9506-1882aa5db433", - "tag": "electronic rock" - }, - { "count": 1, "tag": "hackercore" }, - { "count": 1, "tag": "meter:4/4" }, - { "count": 1, "tag": "vocal:true" }, - { "count": 1, "tag": "bpm:130" }, - { - "count": 1, - "genre_mbid": "e5bba957-8c91-496a-a675-c6d0c6b51c33", - "tag": "dance" - }, - { - "count": 1, - "genre_mbid": "89255676-1f14-4dd8-bbad-fca839d6aff4", - "tag": "electronic" - } - ], - "total_listen_count": 19440, - "total_user_count": 1102 - }, - { - "artist_mbids": ["d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"], - "artist_name": "Mili", - "artists": [ - { - "artist_credit_name": "Mili", - "artist_mbid": "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", - "join_phrase": "" - } - ], - "caa_id": 31388973421, - "caa_release_mbid": "e58ed9ef-2bc1-4480-9d6d-2d799beb5ba9", - "length": 174000, - "recording_mbid": "afa2c83d-b17f-4029-b9da-790ea9250cf9", - "recording_name": "String Theocracy", - "release_color": { "blue": 92, "green": 147, "red": 164 }, - "release_mbid": "d79a38e3-7016-4f39-a31a-f495ce914b8e", - "release_name": "String Theocracy", - "tags": [], - "total_listen_count": 8986, - "total_user_count": 712 - } -] diff --git a/tests/fixtures/listenbrainz.scrobble.request.json b/tests/fixtures/listenbrainz.scrobble.request.json index f6667775f..58c8b8398 100644 --- a/tests/fixtures/listenbrainz.scrobble.request.json +++ b/tests/fixtures/listenbrainz.scrobble.request.json @@ -1,25 +1 @@ -{ - "listen_type": "single", - "payload": [ - { - "listened_at": 1635000000, - "track_metadata": { - "artist_name": "Track Artist", - "track_name": "Track Title", - "release_name": "Track Album", - "additional_info": { - "tracknumber": 1, - "recording_mbid": "mbz-123", - "artist_names": [ - "Artist 1", "Artist 2" - ], - "artist_mbids": [ - "mbz-789", "mbz-012" - ], - "release_mbid": "mbz-456", - "duration_ms": 142200 - } - } - } - ] -} + {"listen_type": "single", "payload": [{"listened_at": 1635000000, "track_metadata": { "artist_name": "Track Artist", "track_name": "Track Title", "release_name": "Track Album", "additional_info": { "tracknumber": 1, "track_mbid": "mbz-123", "artist_mbids": ["mbz-789"], "release_mbid": "mbz-456"}}}]} diff --git a/tests/fixtures/lyrics/auld-lang-syne.elrc b/tests/fixtures/lyrics/auld-lang-syne.elrc deleted file mode 100644 index 41342d910..000000000 --- a/tests/fixtures/lyrics/auld-lang-syne.elrc +++ /dev/null @@ -1,27 +0,0 @@ -[ar:Robert Burns] -[ti:Auld Lang Syne] -[lang:eng] -[00:00.00]<00:00.00>Should <00:00.90>auld <00:01.80>acquaintance <00:02.70>be <00:03.60>forgot, -[00:04.50]<00:04.50>And <00:05.40>never <00:06.30>brought <00:07.20>to <00:08.10>mind? -[00:09.00]<00:09.00>Should <00:09.90>auld <00:10.80>acquaintance <00:11.70>be <00:12.60>forgot, -[00:13.50]<00:13.50>And <00:14.62>auld <00:15.75>lang <00:16.88>syne? -[00:18.00]<00:18.00>For <00:18.75>auld <00:19.50>lang <00:20.25>syne, <00:21.00>my <00:21.75>dear, -[00:22.50]<00:22.50>For <00:23.62>auld <00:24.75>lang <00:25.88>syne, -[00:27.00]<00:27.00>We'll <00:27.64>tak <00:28.29>a <00:28.93>cup <00:29.57>o' <00:30.21>kindness <00:30.86>yet, -[00:31.50]<00:31.50>For <00:32.62>auld <00:33.75>lang <00:34.88>syne. -[00:36.00]<00:36.00>And <00:36.75>surely <00:37.50>ye'll <00:38.25>be <00:39.00>your <00:39.75>pint-stowp, -[00:40.50]<00:40.50>And <00:41.40>surely <00:42.30>I'll <00:43.20>be <00:44.10>mine, -[00:45.00]<00:45.00>And <00:45.56>we'll <00:46.12>tak <00:46.69>a <00:47.25>cup <00:47.81>o' <00:48.38>kindness <00:48.94>yet, -[00:49.50]<00:49.50>For <00:50.62>auld <00:51.75>lang <00:52.88>syne. -[00:54.00]<00:54.00>We <00:54.64>twa <00:55.29>hae <00:55.93>run <00:56.57>about <00:57.21>the <00:57.86>braes, -[00:58.50]<00:58.50>And <00:59.40>pou'd <01:00.30>the <01:01.20>gowans <01:02.10>fine, -[01:03.00]<01:03.00>But <01:03.64>we've <01:04.29>wander'd <01:04.93>mony <01:05.57>a <01:06.21>weary <01:06.86>fit, -[01:07.50]<01:07.50>Sin <01:08.62>auld <01:09.75>lang <01:10.88>syne. -[01:12.00]<01:12.00>We <01:12.64>twa <01:13.29>hae <01:13.93>paidl'd <01:14.57>in <01:15.21>the <01:15.86>burn, -[01:16.50]<01:16.50>Frae <01:17.40>morning <01:18.30>sun <01:19.20>till <01:20.10>dine, -[01:21.00]<01:21.00>But <01:21.64>seas <01:22.29>between <01:22.93>us <01:23.57>braid <01:24.21>hae <01:24.86>roar'd -[01:25.50]<01:25.50>Sin <01:26.62>auld <01:27.75>lang <01:28.88>syne. -[01:30.00]<01:30.00>And <01:30.64>there's <01:31.29>a <01:31.93>hand, <01:32.57>my <01:33.21>trusty <01:33.86>fiere, -[01:34.50]<01:34.50>And <01:35.25>gie's <01:36.00>a <01:36.75>hand <01:37.50>o' <01:38.25>thine, -[01:39.00]<01:39.00>And <01:39.64>we'll <01:40.29>tak <01:40.93>a <01:41.57>right <01:42.21>gude-willie <01:42.86>waught, -[01:43.50]<01:43.50>For <01:44.62>auld <01:45.75>lang <01:46.88>syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.lrc b/tests/fixtures/lyrics/auld-lang-syne.lrc deleted file mode 100644 index 56021870a..000000000 --- a/tests/fixtures/lyrics/auld-lang-syne.lrc +++ /dev/null @@ -1,28 +0,0 @@ -[ar:Robert Burns] -[ti:Auld Lang Syne] -[al:Traditional] -[lang:eng] -[00:00.00]Should auld acquaintance be forgot, -[00:04.50]And never brought to mind? -[00:09.00]Should auld acquaintance be forgot, -[00:13.50]And auld lang syne? -[00:18.00]For auld lang syne, my dear, -[00:22.50]For auld lang syne, -[00:27.00]We'll tak a cup o' kindness yet, -[00:31.50]For auld lang syne. -[00:36.00]And surely ye'll be your pint-stowp, -[00:40.50]And surely I'll be mine, -[00:45.00]And we'll tak a cup o' kindness yet, -[00:49.50]For auld lang syne. -[00:54.00]We twa hae run about the braes, -[00:58.50]And pou'd the gowans fine, -[01:03.00]But we've wander'd mony a weary fit, -[01:07.50]Sin auld lang syne. -[01:12.00]We twa hae paidl'd in the burn, -[01:16.50]Frae morning sun till dine, -[01:21.00]But seas between us braid hae roar'd -[01:25.50]Sin auld lang syne. -[01:30.00]And there's a hand, my trusty fiere, -[01:34.50]And gie's a hand o' thine, -[01:39.00]And we'll tak a right gude-willie waught, -[01:43.50]For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.srt b/tests/fixtures/lyrics/auld-lang-syne.srt deleted file mode 100644 index 116bec0bf..000000000 --- a/tests/fixtures/lyrics/auld-lang-syne.srt +++ /dev/null @@ -1,95 +0,0 @@ -1 -00:00:00,000 --> 00:00:04,500 -Should auld acquaintance be forgot, - -2 -00:00:04,500 --> 00:00:09,000 -And never brought to mind? - -3 -00:00:09,000 --> 00:00:13,500 -Should auld acquaintance be forgot, - -4 -00:00:13,500 --> 00:00:18,000 -And auld lang syne? - -5 -00:00:18,000 --> 00:00:22,500 -For auld lang syne, my dear, - -6 -00:00:22,500 --> 00:00:27,000 -For auld lang syne, - -7 -00:00:27,000 --> 00:00:31,500 -We'll tak a cup o' kindness yet, - -8 -00:00:31,500 --> 00:00:36,000 -For auld lang syne. - -9 -00:00:36,000 --> 00:00:40,500 -And surely ye'll be your pint-stowp, - -10 -00:00:40,500 --> 00:00:45,000 -And surely I'll be mine, - -11 -00:00:45,000 --> 00:00:49,500 -And we'll tak a cup o' kindness yet, - -12 -00:00:49,500 --> 00:00:54,000 -For auld lang syne. - -13 -00:00:54,000 --> 00:00:58,500 -We twa hae run about the braes, - -14 -00:00:58,500 --> 00:01:03,000 -And pou'd the gowans fine, - -15 -00:01:03,000 --> 00:01:07,500 -But we've wander'd mony a weary fit, - -16 -00:01:07,500 --> 00:01:12,000 -Sin auld lang syne. - -17 -00:01:12,000 --> 00:01:16,500 -We twa hae paidl'd in the burn, - -18 -00:01:16,500 --> 00:01:21,000 -Frae morning sun till dine, - -19 -00:01:21,000 --> 00:01:25,500 -But seas between us braid hae roar'd - -20 -00:01:25,500 --> 00:01:30,000 -Sin auld lang syne. - -21 -00:01:30,000 --> 00:01:34,500 -And there's a hand, my trusty fiere, - -22 -00:01:34,500 --> 00:01:39,000 -And gie's a hand o' thine, - -23 -00:01:39,000 --> 00:01:43,500 -And we'll tak a right gude-willie waught, - -24 -00:01:43,500 --> 00:01:48,000 -For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.ttml b/tests/fixtures/lyrics/auld-lang-syne.ttml deleted file mode 100644 index a08be29e2..000000000 --- a/tests/fixtures/lyrics/auld-lang-syne.ttml +++ /dev/null @@ -1,31 +0,0 @@ - - - -
-

Should auld acquaintance be forgot,

-

And never brought to mind?

-

Should auld acquaintance be forgot,

-

And auld lang syne?

-

For auld lang syne, my dear,

-

For auld lang syne,

-

We'll tak a cup o' kindness yet,

-

For auld lang syne.

-

And surely ye'll be your pint-stowp,

-

And surely I'll be mine,

-

And we'll tak a cup o' kindness yet,

-

For auld lang syne.

-

We twa hae run about the braes,

-

And pou'd the gowans fine,

-

But we've wander'd mony a weary fit,

-

Sin auld lang syne.

-

We twa hae paidl'd in the burn,

-

Frae morning sun till dine,

-

But seas between us braid hae roar'd

-

Sin auld lang syne.

-

And there's a hand, my trusty fiere,

-

And gie's a hand o' thine,

-

And we'll tak a right gude-willie waught,

-

For auld lang syne.

-
- -
diff --git a/tests/fixtures/lyrics/auld-lang-syne.txt b/tests/fixtures/lyrics/auld-lang-syne.txt deleted file mode 100644 index 42ab8330e..000000000 --- a/tests/fixtures/lyrics/auld-lang-syne.txt +++ /dev/null @@ -1,24 +0,0 @@ -Should auld acquaintance be forgot, -And never brought to mind? -Should auld acquaintance be forgot, -And auld lang syne? -For auld lang syne, my dear, -For auld lang syne, -We'll tak a cup o' kindness yet, -For auld lang syne. -And surely ye'll be your pint-stowp, -And surely I'll be mine, -And we'll tak a cup o' kindness yet, -For auld lang syne. -We twa hae run about the braes, -And pou'd the gowans fine, -But we've wander'd mony a weary fit, -Sin auld lang syne. -We twa hae paidl'd in the burn, -Frae morning sun till dine, -But seas between us braid hae roar'd -Sin auld lang syne. -And there's a hand, my trusty fiere, -And gie's a hand o' thine, -And we'll tak a right gude-willie waught, -For auld lang syne. diff --git a/tests/fixtures/lyrics/auld-lang-syne.yaml b/tests/fixtures/lyrics/auld-lang-syne.yaml deleted file mode 100644 index ca2a3d32e..000000000 --- a/tests/fixtures/lyrics/auld-lang-syne.yaml +++ /dev/null @@ -1,95 +0,0 @@ -version: '1.0' -metadata: - title: 'Auld Lang Syne' - artist: 'Robert Burns' - album: 'Traditional' - language: 'eng' -lines: - - text: "Should auld acquaintance be forgot," - start_ms: 0 - end_ms: 4500 - words: - - text: "Should " - start_ms: 0 - end_ms: 900 - - text: "auld " - start_ms: 900 - end_ms: 1800 - - text: "acquaintance " - start_ms: 1800 - end_ms: 2700 - - text: "be " - start_ms: 2700 - end_ms: 3600 - - text: "forgot," - start_ms: 3600 - end_ms: 4500 - - text: "And never brought to mind?" - start_ms: 4500 - end_ms: 9000 - - text: "Should auld acquaintance be forgot," - start_ms: 9000 - end_ms: 13500 - - text: "And auld lang syne?" - start_ms: 13500 - end_ms: 18000 - - text: "For auld lang syne, my dear," - start_ms: 18000 - end_ms: 22500 - - text: "For auld lang syne," - start_ms: 22500 - end_ms: 27000 - - text: "We'll tak a cup o' kindness yet," - start_ms: 27000 - end_ms: 31500 - - text: "For auld lang syne." - start_ms: 31500 - end_ms: 36000 - - text: "And surely ye'll be your pint-stowp," - start_ms: 36000 - end_ms: 40500 - - text: "And surely I'll be mine," - start_ms: 40500 - end_ms: 45000 - - text: "And we'll tak a cup o' kindness yet," - start_ms: 45000 - end_ms: 49500 - - text: "For auld lang syne." - start_ms: 49500 - end_ms: 54000 - - text: "We twa hae run about the braes," - start_ms: 54000 - end_ms: 58500 - - text: "And pou'd the gowans fine," - start_ms: 58500 - end_ms: 63000 - - text: "But we've wander'd mony a weary fit," - start_ms: 63000 - end_ms: 67500 - - text: "Sin auld lang syne." - start_ms: 67500 - end_ms: 72000 - - text: "We twa hae paidl'd in the burn," - start_ms: 72000 - end_ms: 76500 - - text: "Frae morning sun till dine," - start_ms: 76500 - end_ms: 81000 - - text: "But seas between us braid hae roar'd" - start_ms: 81000 - end_ms: 85500 - - text: "Sin auld lang syne." - start_ms: 85500 - end_ms: 90000 - - text: "And there's a hand, my trusty fiere," - start_ms: 90000 - end_ms: 94500 - - text: "And gie's a hand o' thine," - start_ms: 94500 - end_ms: 99000 - - text: "And we'll tak a right gude-willie waught," - start_ms: 99000 - end_ms: 103500 - - text: "For auld lang syne." - start_ms: 103500 - end_ms: 108000 diff --git a/tests/fixtures/mixed-lyrics.flac b/tests/fixtures/mixed-lyrics.flac deleted file mode 100644 index d048234f5..000000000 Binary files a/tests/fixtures/mixed-lyrics.flac and /dev/null differ diff --git a/tests/fixtures/no_replaygain.mp3 b/tests/fixtures/no_replaygain.mp3 deleted file mode 100644 index 45c2176e3..000000000 Binary files a/tests/fixtures/no_replaygain.mp3 and /dev/null differ diff --git a/tests/fixtures/playlists/bom-test-utf16.m3u b/tests/fixtures/playlists/bom-test-utf16.m3u deleted file mode 100644 index 9c2e9d599..000000000 Binary files a/tests/fixtures/playlists/bom-test-utf16.m3u and /dev/null differ diff --git a/tests/fixtures/playlists/bom-test.m3u b/tests/fixtures/playlists/bom-test.m3u deleted file mode 100644 index f5a00806c..000000000 --- a/tests/fixtures/playlists/bom-test.m3u +++ /dev/null @@ -1,6 +0,0 @@ -#EXTM3U -# NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at the beginning -# (bytes 0xEF 0xBB 0xBF) to test BOM handling in playlist parsing. -#PLAYLIST:Test Playlist -#EXTINF:123,Test Artist - Test Song -test.mp3 diff --git a/tests/fixtures/playlists/invalid_json.nsp b/tests/fixtures/playlists/invalid_json.nsp deleted file mode 100644 index 7fd1e7bc5..000000000 --- a/tests/fixtures/playlists/invalid_json.nsp +++ /dev/null @@ -1,42 +0,0 @@ -{ - "all": [ - {"is": {"loved": true}}, - {"isNot": {"genre": "Hip-Hop"}}, - {"isNot": {"genre": "Hip Hop"}}, - {"isNot": {"genre": "Rap"}}, - {"isNot": {"genre": "Alternative Hip Hop"}}, - {"isNot": {"genre": "Deutsch-Rap"}}, - {"isNot": {"genre": "Deutsche Musik"}}, - {"isNot": {"genre": "Uk Hip Hop"}}, - {"isNot": {"genre": "UK Rap"}}, - {"isNot": {"genre": "Boom Bap"}}, - {"isNot": {"genre": "Lo-Fi Hip Hop"}}, - {"isNot": {"genre": "Jazzy Hip-Hop"}}, - {"isNot": {"genre": "Jazz Rap"}}, - {"isNot": {"genre": "Jazz Rap"}}, - {"isNot": {"genre": "Southern Hip Hop"}}, - {"isNot": {"genre": "Alternative Hip Hop}}, - {"isNot": {"genre": "Underground"}}, - {"isNot": {"genre": "Trap"}}, - {"isNot": {"genre": "Mixtape"}}, - {"isNot": {"genre": "Boom-Bap"}}, - {"isNot": {"genre": "Conscious"}}, - {"isNot": {"genre": "Turntablism"}}, - {"isNot": {"genre": "Pop Rap"}}, - {"isNot": {"genre": "Aussie"}}, - {"isNot": {"genre": "Horror-Core"}}, - {"isNot": {"genre": "Pop Rap"}}, - {"isNot": {"genre": "Female-Rap"}}, - {"isNot": {"genre": "Female Rap"}}, - {"isNot": {"genre": "East Coast"}}, - {"isNot": {"genre": "East Coast Hip Hop"}}, - {"isNot": {"genre": "West Coast"}}, - {"isNot": {"genre": "Gangsta Rap"}}, - {"isNot": {"genre": "Cloudrap"}}, - {"isNot": {"genre": "Hardcore Hip Hop"}}, - {"isNot": {"genre": "Mixtape"}}, - {"isNot": {"genre": "Deutschrap"}} - ], - "sort": "dateLoved", - "order": "desc" -} \ No newline at end of file diff --git a/tests/fixtures/playlists/pls-with-art-url.m3u b/tests/fixtures/playlists/pls-with-art-url.m3u deleted file mode 100644 index 9dbf180f8..000000000 --- a/tests/fixtures/playlists/pls-with-art-url.m3u +++ /dev/null @@ -1,5 +0,0 @@ -#EXTM3U -#PLAYLIST:Playlist With Art -#EXTALBUMARTURL:https://example.com/cover.jpg -test.mp3 -test.ogg diff --git a/tests/fixtures/playlists/pls-with-name.m3u b/tests/fixtures/playlists/pls-with-name.m3u deleted file mode 100644 index a214b703c..000000000 --- a/tests/fixtures/playlists/pls-with-name.m3u +++ /dev/null @@ -1,4 +0,0 @@ -#PLAYLIST:playlist 1 -tests/fixtures/test.mp3 -tests/fixtures/test.ogg -file:///tests/fixtures/01%20Invisible%20(RED)%20Edit%20Version.mp3 \ No newline at end of file diff --git a/tests/fixtures/playlists/pls-without-name.m3u b/tests/fixtures/playlists/pls-without-name.m3u deleted file mode 100644 index d665c89a0..000000000 --- a/tests/fixtures/playlists/pls-without-name.m3u +++ /dev/null @@ -1,3 +0,0 @@ -tests/fixtures/test.mp3 -tests/fixtures/test.ogg -file:///tests/fixtures/01%20Invisible%20(RED)%20Edit%20Version.mp3 \ No newline at end of file diff --git a/tests/fixtures/playlists/pls1.m3u b/tests/fixtures/playlists/pls1.m3u index 98e6d9675..d8f30e943 100644 --- a/tests/fixtures/playlists/pls1.m3u +++ b/tests/fixtures/playlists/pls1.m3u @@ -1,2 +1,3 @@ test.mp3 -test.ogg \ No newline at end of file +test.ogg +file:///tests/fixtures/01%20Invisible%20(RED)%20Edit%20Version.mp3 \ No newline at end of file diff --git a/tests/fixtures/playlists/private_playlist.nsp b/tests/fixtures/playlists/private_playlist.nsp deleted file mode 100644 index de73b369c..000000000 --- a/tests/fixtures/playlists/private_playlist.nsp +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "Private Playlist", - "comment": "A smart playlist that is explicitly private", - "public": false, - "all": [ - {"is": {"loved": true}} - ], - "sort": "title", - "order": "asc", - "limit": 100 -} diff --git a/tests/fixtures/playlists/public_playlist.nsp b/tests/fixtures/playlists/public_playlist.nsp deleted file mode 100644 index e303169e1..000000000 --- a/tests/fixtures/playlists/public_playlist.nsp +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "Public Playlist", - "comment": "A smart playlist that is public", - "public": true, - "all": [ - {"inTheLast": {"lastPlayed": 30}} - ], - "sort": "lastPlayed", - "order": "desc", - "limit": 50 -} diff --git a/tests/fixtures/playlists/recently_played.nsp b/tests/fixtures/playlists/recently_played.nsp deleted file mode 100644 index 08fe685a6..000000000 --- a/tests/fixtures/playlists/recently_played.nsp +++ /dev/null @@ -1,14 +0,0 @@ -/* - Top Level Comment -*/ -{ - "name": "Recently Played", - "comment": "Recently played tracks", - "all": [ - // This is an inline comment - {"inTheLast": {"lastPlayed": 30}} - ], - "sort": "lastPlayed", - "order": "desc", - "limit": 100 -} \ No newline at end of file diff --git a/tests/fixtures/playlists/subfolder1/.hidden_playlist1.m3u b/tests/fixtures/playlists/subfolder1/.hidden_playlist1.m3u deleted file mode 100644 index af745ba59..000000000 --- a/tests/fixtures/playlists/subfolder1/.hidden_playlist1.m3u +++ /dev/null @@ -1,2 +0,0 @@ -test.mp3 -test.ogg diff --git a/tests/fixtures/playlists/subfolder2/.hidden_playlist2.m3u b/tests/fixtures/playlists/subfolder2/.hidden_playlist2.m3u deleted file mode 100644 index af745ba59..000000000 --- a/tests/fixtures/playlists/subfolder2/.hidden_playlist2.m3u +++ /dev/null @@ -1,2 +0,0 @@ -test.mp3 -test.ogg diff --git a/tests/fixtures/playlists/subfolder2/pls2.m3u b/tests/fixtures/playlists/subfolder2/pls2.m3u index cfe699471..af745ba59 100644 --- a/tests/fixtures/playlists/subfolder2/pls2.m3u +++ b/tests/fixtures/playlists/subfolder2/pls2.m3u @@ -1,4 +1,2 @@ -../test.mp3 -../test.ogg -/tests/fixtures/01%20Invisible%20(RED)%20Edit%20Version.mp3 -/invalid/path/xyz.mp3 \ No newline at end of file +test.mp3 +test.ogg diff --git a/tests/fixtures/symlink_chain/evil1.mp3 b/tests/fixtures/symlink_chain/evil1.mp3 deleted file mode 120000 index 79c5d6f02..000000000 --- a/tests/fixtures/symlink_chain/evil1.mp3 +++ /dev/null @@ -1 +0,0 @@ -../index.html \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/evil2.mp3 b/tests/fixtures/symlink_chain/evil2.mp3 deleted file mode 120000 index 56d18ad24..000000000 --- a/tests/fixtures/symlink_chain/evil2.mp3 +++ /dev/null @@ -1 +0,0 @@ -evil1.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/evil3.mp3 b/tests/fixtures/symlink_chain/evil3.mp3 deleted file mode 120000 index e1cac02e9..000000000 --- a/tests/fixtures/symlink_chain/evil3.mp3 +++ /dev/null @@ -1 +0,0 @@ -evil2.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level1.mp3 b/tests/fixtures/symlink_chain/level1.mp3 deleted file mode 120000 index 887033521..000000000 --- a/tests/fixtures/symlink_chain/level1.mp3 +++ /dev/null @@ -1 +0,0 @@ -../test.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level2.mp3 b/tests/fixtures/symlink_chain/level2.mp3 deleted file mode 120000 index eca2115ee..000000000 --- a/tests/fixtures/symlink_chain/level2.mp3 +++ /dev/null @@ -1 +0,0 @@ -level1.mp3 \ No newline at end of file diff --git a/tests/fixtures/symlink_chain/level3.mp3 b/tests/fixtures/symlink_chain/level3.mp3 deleted file mode 120000 index dd72f3cca..000000000 --- a/tests/fixtures/symlink_chain/level3.mp3 +++ /dev/null @@ -1 +0,0 @@ -level2.mp3 \ No newline at end of file diff --git a/tests/fixtures/synlink_invalid b/tests/fixtures/synlink_invalid new file mode 120000 index 000000000..d30fa5577 --- /dev/null +++ b/tests/fixtures/synlink_invalid @@ -0,0 +1 @@ +INVALID \ No newline at end of file diff --git a/tests/fixtures/test-enhanced.lrc b/tests/fixtures/test-enhanced.lrc deleted file mode 100644 index 8f7b60f8c..000000000 --- a/tests/fixtures/test-enhanced.lrc +++ /dev/null @@ -1,6 +0,0 @@ -[ar:Test Artist] -[ti:Enhanced Test] -[lang:eng] -[00:01.00]<00:01.00>Some <00:01.50>lyrics <00:02.00>here -[00:03.00]<00:03.00>More <00:03.50>words -[00:05.00]Plain line without inline markers diff --git a/tests/fixtures/test-instrumental.yaml b/tests/fixtures/test-instrumental.yaml deleted file mode 100644 index 84190a3b0..000000000 --- a/tests/fixtures/test-instrumental.yaml +++ /dev/null @@ -1,6 +0,0 @@ -version: '1.0' -metadata: - title: 'Solo Piano' - artist: 'Composer' - language: 'eng' - instrumental: true diff --git a/tests/fixtures/test-metadata.ttml b/tests/fixtures/test-metadata.ttml deleted file mode 100644 index c0243c18f..000000000 --- a/tests/fixtures/test-metadata.ttml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - Hola - - - - - konni - - - - - - -
-

こんにちは

-

こんばんは

-
- -
diff --git a/tests/fixtures/test-overlapping.yaml b/tests/fixtures/test-overlapping.yaml deleted file mode 100644 index c1f95a87b..000000000 --- a/tests/fixtures/test-overlapping.yaml +++ /dev/null @@ -1,24 +0,0 @@ -version: '1.0' -metadata: - title: 'Duet' - artist: 'Lead and Echo' - language: 'eng' - -lines: - - text: "Lead vocal" - start_ms: 1000 - end_ms: 4000 - words: - - text: "Lead " - start_ms: 1000 - end_ms: 2000 - - text: "vocal" - start_ms: 2000 - end_ms: 4000 - - text: "echo" - start_ms: 2000 - end_ms: 3000 - words: - - text: "echo" - start_ms: 2000 - end_ms: 3000 diff --git a/tests/fixtures/test-words.yaml b/tests/fixtures/test-words.yaml deleted file mode 100644 index 625098d6a..000000000 --- a/tests/fixtures/test-words.yaml +++ /dev/null @@ -1,17 +0,0 @@ -version: '1.0' -metadata: - title: 'Karaoke Test' - artist: 'Test Artist' - language: 'eng' - -lines: - - text: "Hello world" - start_ms: 1000 - end_ms: 3000 - words: - - text: "Hello " - start_ms: 1000 - end_ms: 1500 - - text: "world" - start_ms: 1500 - end_ms: 3000 diff --git a/tests/fixtures/test.aiff b/tests/fixtures/test.aiff deleted file mode 100644 index d179f0714..000000000 Binary files a/tests/fixtures/test.aiff and /dev/null differ diff --git a/tests/fixtures/test.elrc b/tests/fixtures/test.elrc deleted file mode 100644 index 01c3d2cdd..000000000 --- a/tests/fixtures/test.elrc +++ /dev/null @@ -1,5 +0,0 @@ -[ar:ELRC Artist] -[ti:ELRC Song] -[lang:eng] -[00:01.00]<00:01.00>Lead <00:01.50>words -[00:03.00]Fallback line diff --git a/tests/fixtures/test.flac b/tests/fixtures/test.flac deleted file mode 100644 index 50430f539..000000000 Binary files a/tests/fixtures/test.flac and /dev/null differ diff --git a/tests/fixtures/test.lrc b/tests/fixtures/test.lrc deleted file mode 100644 index d1730dfe9..000000000 --- a/tests/fixtures/test.lrc +++ /dev/null @@ -1,6 +0,0 @@ -[ar:Rick Astley] -[ti:That one song] -[offset:-100] -[lang:eng] -[00:18.80]We're no strangers to love -[00:22.801]You know the rules and so do I diff --git a/tests/fixtures/test.m4a b/tests/fixtures/test.m4a deleted file mode 100644 index e9b54d44d..000000000 Binary files a/tests/fixtures/test.m4a and /dev/null differ diff --git a/tests/fixtures/test.mp3 b/tests/fixtures/test.mp3 index 18cb90674..1e40de9eb 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 2b1deea98..9a5490d31 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 deleted file mode 100644 index 044947c70..000000000 Binary files a/tests/fixtures/test.opus and /dev/null differ diff --git a/tests/fixtures/test.srt b/tests/fixtures/test.srt deleted file mode 100644 index 3c9c09a39..000000000 --- a/tests/fixtures/test.srt +++ /dev/null @@ -1,7 +0,0 @@ -1 -00:00:18,800 --> 00:00:22,800 -We're from subtitles - -2 -00:00:22,801 --> 00:00:26,000 -Another subtitle line diff --git a/tests/fixtures/test.tak b/tests/fixtures/test.tak deleted file mode 100644 index 3f64080ec..000000000 Binary files a/tests/fixtures/test.tak and /dev/null differ diff --git a/tests/fixtures/test.ttml b/tests/fixtures/test.ttml deleted file mode 100644 index a85673a1b..000000000 --- a/tests/fixtures/test.ttml +++ /dev/null @@ -1,12 +0,0 @@ - - - -
-

We're no strangers to love

-

You know the rules and so do I

-
-
-

Nao somos estranhos ao amor

-
- -
diff --git a/tests/fixtures/test.txt b/tests/fixtures/test.txt deleted file mode 100644 index c5a9c852c..000000000 --- a/tests/fixtures/test.txt +++ /dev/null @@ -1,2 +0,0 @@ -We're no strangers to love -You know the rules and so do I \ No newline at end of file diff --git a/tests/fixtures/test.wav b/tests/fixtures/test.wav deleted file mode 100644 index b8c1f9a65..000000000 Binary files a/tests/fixtures/test.wav and /dev/null differ diff --git a/tests/fixtures/test.wma b/tests/fixtures/test.wma deleted file mode 100644 index 2e7341cf5..000000000 Binary files a/tests/fixtures/test.wma and /dev/null differ diff --git a/tests/fixtures/test.wv b/tests/fixtures/test.wv deleted file mode 100644 index 7f4118bdb..000000000 Binary files a/tests/fixtures/test.wv and /dev/null differ diff --git a/tests/fixtures/test.yaml b/tests/fixtures/test.yaml deleted file mode 100644 index bc5022b75..000000000 --- a/tests/fixtures/test.yaml +++ /dev/null @@ -1,12 +0,0 @@ -version: '1.0' -metadata: - title: 'Sample Track' - artist: 'Test Artist' - language: 'eng' - offset_ms: -100 - -lines: - - text: "We're no strangers to love" - start_ms: 18800 - - text: "You know the rules and so do I" - start_ms: 22801 diff --git a/tests/fixtures/test_no_read_permission.ogg b/tests/fixtures/test_no_read_permission.ogg new file mode 100644 index 000000000..be6728129 Binary files /dev/null and b/tests/fixtures/test_no_read_permission.ogg differ diff --git a/tests/fixtures/zero_replaygain.mp3 b/tests/fixtures/zero_replaygain.mp3 deleted file mode 100644 index 96e6d21f0..000000000 Binary files a/tests/fixtures/zero_replaygain.mp3 and /dev/null differ diff --git a/tests/harness/harness.go b/tests/harness/harness.go deleted file mode 100644 index 67f3150a2..000000000 --- a/tests/harness/harness.go +++ /dev/null @@ -1,195 +0,0 @@ -// Package harness holds the pieces shared by the API e2e suites (server/subsonic/e2e and -// server/jellyfin/e2e): golden-database lifecycle, snapshot restore, fixture-FS registration, -// and service doubles. Like core/storage/storagetest, it must only be imported from test code. -package harness - -import ( - "context" - "errors" - "io" - "os" - "path/filepath" - "strings" - "testing/fstest" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/ffmpeg" - "github.com/navidrome/navidrome/core/metrics" - "github.com/navidrome/navidrome/core/playlists" - "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/model" - "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" //nolint:staticcheck -) - -// DB is a golden e2e database: scanned once in BeforeSuite, restored per test via Restore. -type DB struct { - FilePath string - SnapshotPath string - Library model.Library -} - -// CreateFS registers files under the "fake:" storage scheme the suites use as MusicFolder. -func CreateFS(files fstest.MapFS) storagetest.FakeFS { - fs := storagetest.FakeFS{} - fs.SetFiles(files) - storagetest.Register("fake", &fs) - return fs -} - -// SetupDB boots the golden database: a temp SQLite file, the given users (password "password", -// all with access to the seeded "Music Library"), a full scan of the registered fake FS, and a -// snapshot for per-test restore. Callers must set conf.Server.MusicFolder and register the FS -// first; each user's Libraries field is populated in place. -func SetupDB(ctx context.Context, users ...*model.User) *DB { - tmpDir := ginkgo.GinkgoT().TempDir() - h := &DB{FilePath: filepath.Join(tmpDir, "test-e2e.db")} - h.SnapshotPath = h.FilePath + ".snapshot" - conf.Server.DbPath = h.FilePath + "?_journal_mode=WAL" - db.Db().SetMaxOpenConns(1) - db.Init(ctx) - - ds := &tests.MockDataStore{RealDS: persistence.New(db.Db())} - auth.Init(ds) - - h.Library = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} - Expect(ds.Library().Put(ctx, &h.Library)).To(Succeed()) - - for _, u := range users { - seeded := *u - seeded.NewPassword = "password" - Expect(ds.User().Put(ctx, &seeded)).To(Succeed()) - Expect(ds.User().SetUserLibraries(ctx, u.ID, []int{h.Library.ID})).To(Succeed()) - loaded, err := ds.User().FindByUsername(ctx, u.UserName) - Expect(err).ToNot(HaveOccurred()) - u.Libraries = loaded.Libraries - } - - s := scanner.New(ctx, ds, events.NoopBroker(), - playlists.NewPlaylists(ds, artwork.NewUploader(ds)), metrics.NewNoopInstance()) - _, err := s.ScanAll(ctx, true) - Expect(err).ToNot(HaveOccurred()) - - _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") - Expect(err).ToNot(HaveOccurred()) - data, err := os.ReadFile(h.FilePath) - Expect(err).ToNot(HaveOccurred()) - Expect(os.WriteFile(h.SnapshotPath, data, 0o600)).To(Succeed()) //nolint:gosec // path derives from TempDir - return h -} - -// ResettableTables lists the tables a per-spec reset may write. FTS shadow tables are excluded: -// their content tables' triggers keep them in sync, and writing them directly corrupts the index. -func ResettableTables() []string { - rows, err := db.Db().Query("SELECT name FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") - Expect(err).ToNot(HaveOccurred()) - defer rows.Close() - var tables []string - for rows.Next() { - var name string - Expect(rows.Scan(&name)).To(Succeed()) - tables = append(tables, name) - } - Expect(rows.Err()).ToNot(HaveOccurred()) - return tables -} - -// TruncateDB empties every resettable table, leaving the migrated schema in place — for suites -// whose specs each build their own library, so the schema is not re-migrated per spec. -func TruncateDB(tables []string) { - sqlDB := db.Db() - _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") - Expect(err).ToNot(HaveOccurred()) - defer func() { _, _ = sqlDB.Exec("PRAGMA foreign_keys = ON") }() - - for _, table := range tables { - // Table names come from sqlite_master, not user input. - _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec - Expect(err).ToNot(HaveOccurred()) - } -} - -// Restore reloads every table from the golden snapshot via ATTACH DATABASE — much faster than a -// rescan. -func (h *DB) Restore() { - sqlDB := db.Db() - _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") - Expect(err).ToNot(HaveOccurred()) - _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", h.SnapshotPath) - Expect(err).ToNot(HaveOccurred()) - - for _, table := range ResettableTables() { - // Table names come from sqlite_master, not user input. - _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec - Expect(err).ToNot(HaveOccurred()) - _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec - Expect(err).ToNot(HaveOccurred()) - } - - _, err = sqlDB.Exec("DETACH DATABASE snapshot") - Expect(err).ToNot(HaveOccurred()) - _, err = sqlDB.Exec("PRAGMA foreign_keys = ON") - Expect(err).ToNot(HaveOccurred()) -} - -// SpyStreamer captures the Request passed to NewStream and returns a minimal fake stream. -type SpyStreamer struct { - LastRequest stream.Request - LastMediaFile *model.MediaFile - SimulateError error // when set, NewStream returns this error - SimulateEmptyStream bool // when true, returns a 0-byte stream (ffmpeg produced no output) -} - -func (s *SpyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { - s.LastRequest = req - s.LastMediaFile = mf - if s.SimulateError != nil { - return nil, s.SimulateError - } - format := req.Format - if format == "" || format == "raw" { - format = mf.Suffix - } - content := "fake audio data" - if s.SimulateEmptyStream { - content = "" - } - return stream.NewStream(mf, format, req.BitRate, io.NopCloser(strings.NewReader(content))), nil -} - -// NoopFFmpeg implements ffmpeg.FFmpeg; transcoding never actually runs in e2e. -type NoopFFmpeg struct{} - -func (NoopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: transcode not supported") -} - -func (NoopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: extract image not supported") -} - -func (NoopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { - return nil, errors.New("noop ffmpeg: probe not supported") -} - -func (NoopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) (io.ReadCloser, error) { - return nil, errors.New("noop ffmpeg: convert animated image not supported") -} - -func (NoopFFmpeg) CmdPath() (string, error) { return "", nil } -func (NoopFFmpeg) IsAvailable() bool { return false } -func (NoopFFmpeg) IsProbeAvailable() bool { return true } -func (NoopFFmpeg) Version() string { return "noop" } - -var ( - _ stream.MediaStreamer = &SpyStreamer{} - _ ffmpeg.FFmpeg = NoopFFmpeg{} -) diff --git a/tests/init_tests.go b/tests/init_tests.go index eee4428a8..582ad95fc 100644 --- a/tests/init_tests.go +++ b/tests/init_tests.go @@ -8,13 +8,12 @@ import ( "testing" "github.com/navidrome/navidrome/conf" - _ "github.com/navidrome/navidrome/conf/mime" // registers mime_types.yaml, so tests see the same image types as the server "github.com/navidrome/navidrome/log" ) var once sync.Once -func Init(t testing.TB, skipOnShort bool) { +func Init(t *testing.T, skipOnShort bool) { if skipOnShort && testing.Short() { t.Skip("skipping test in short mode.") } diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go index f8100c189..65ddfecdb 100644 --- a/tests/mock_album_repo.go +++ b/tests/mock_album_repo.go @@ -1,225 +1,96 @@ package tests import ( - "context" "errors" - "sync" "time" + "github.com/google/uuid" + "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" ) func CreateMockAlbumRepo() *MockAlbumRepo { return &MockAlbumRepo{ - Data: make(map[string]*model.Album), + data: make(map[string]*model.Album), } } type MockAlbumRepo struct { model.AlbumRepository - Data map[string]*model.Album - All model.Albums - Err bool - Options model.QueryOptions - optionsMu sync.Mutex - SearchQuery string // last query passed to Search - ReassignAnnotationCalls map[string]string // prevID -> newID - CopyAttributesCalls map[string]string // fromID -> toID + data map[string]*model.Album + all model.Albums + err bool + Options model.QueryOptions } func (m *MockAlbumRepo) SetError(err bool) { - m.Err = err + m.err = err } func (m *MockAlbumRepo) SetData(albums model.Albums) { - m.Data = make(map[string]*model.Album, len(albums)) - m.All = albums - for i, a := range m.All { - m.Data[a.ID] = &m.All[i] + m.data = make(map[string]*model.Album) + m.all = albums + for i, a := range m.all { + m.data[a.ID] = &m.all[i] } } -func (m *MockAlbumRepo) Exists(_ context.Context, id string) (bool, error) { - if m.Err { - return false, errors.New("unexpected error") +func (m *MockAlbumRepo) Exists(id string) (bool, error) { + if m.err { + return false, errors.New("Error!") } - _, found := m.Data[id] + _, found := m.data[id] return found, nil } -func (m *MockAlbumRepo) Get(_ context.Context, id string) (*model.Album, error) { - if m.Err { - return nil, errors.New("unexpected error") +func (m *MockAlbumRepo) Get(id string) (*model.Album, error) { + if m.err { + return nil, errors.New("Error!") } - if d, ok := m.Data[id]; ok { + if d, ok := m.data[id]; ok { return d, nil } return nil, model.ErrNotFound } -func (m *MockAlbumRepo) Put(_ context.Context, al *model.Album) error { - if m.Err { - return errors.New("unexpected error") +func (m *MockAlbumRepo) Put(al *model.Album) error { + if m.err { + return errors.New("error") } if al.ID == "" { - al.ID = id.NewRandom() + al.ID = uuid.NewString() } - if m.Data == nil { - m.Data = make(map[string]*model.Album) - } - m.Data[al.ID] = al + m.data[al.ID] = al return nil } -func (m *MockAlbumRepo) GetAll(_ context.Context, qo ...model.QueryOptions) (model.Albums, error) { +func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { if len(qo) > 0 { - // Recording the last options is a read-path write, and callers resolve concurrently. - m.optionsMu.Lock() m.Options = qo[0] - m.optionsMu.Unlock() } - if m.Err { - return nil, errors.New("unexpected error") + if m.err { + return nil, errors.New("Error!") } - return m.All, nil + return m.all, nil } -func (m *MockAlbumRepo) GetCursor(ctx context.Context, qo ...model.QueryOptions) (model.AlbumCursor, error) { - res, err := m.GetAll(ctx, qo...) - if err != nil { - return nil, err - } - return func(yield func(model.Album, error) bool) { - for _, a := range res { - if !yield(a, nil) { - return - } - } - }, nil +func (m *MockAlbumRepo) GetAllWithoutGenres(qo ...model.QueryOptions) (model.Albums, error) { + return m.GetAll(qo...) } -func (m *MockAlbumRepo) IncPlayCount(_ context.Context, id string, timestamp time.Time) error { - if m.Err { - return errors.New("unexpected error") +func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error { + if m.err { + return errors.New("error") } - if d, ok := m.Data[id]; ok { + if d, ok := m.data[id]; ok { d.PlayCount++ - d.PlayDate = ×tamp + d.PlayDate = timestamp return nil } return model.ErrNotFound } -func (m *MockAlbumRepo) CountAll(_ context.Context, _ ...model.QueryOptions) (int64, error) { - return int64(len(m.All)), nil -} - -func (m *MockAlbumRepo) GetTouchedAlbums(_ context.Context, libID int) (model.AlbumCursor, error) { - if m.Err { - return nil, errors.New("unexpected error") - } - return func(yield func(model.Album, error) bool) { - for _, a := range m.Data { - if a.ID == "error" { - if !yield(*a, errors.New("error")) { - break - } - continue - } - if a.LibraryID != libID { - continue - } - if !yield(*a, nil) { - break - } - } - }, nil -} - -func (m *MockAlbumRepo) UpdateExternalInfo(ctx context.Context, album *model.Album) error { - return m.Put(ctx, album) -} - -func (m *MockAlbumRepo) Search(_ context.Context, q string, options ...model.QueryOptions) (model.Albums, error) { - m.SearchQuery = q - if len(options) > 0 { - m.Options = options[0] - } - if m.Err { - return nil, errors.New("unexpected error") - } - // Simple mock implementation - just return all albums for testing - return m.All, nil -} - -// ReassignAnnotation reassigns annotations from one album to another -func (m *MockAlbumRepo) ReassignAnnotation(_ context.Context, prevID string, newID string) error { - if m.Err { - return errors.New("unexpected error") - } - // Mock implementation - track the reassignment calls - if m.ReassignAnnotationCalls == nil { - m.ReassignAnnotationCalls = make(map[string]string) - } - m.ReassignAnnotationCalls[prevID] = newID - return nil -} - -// CopyAttributes copies attributes from one album to another -func (m *MockAlbumRepo) CopyAttributes(_ context.Context, fromID, toID string, columns ...string) error { - if m.Err { - return errors.New("unexpected error") - } - from, ok := m.Data[fromID] - if !ok { - return model.ErrNotFound - } - to, ok := m.Data[toID] - if !ok { - return model.ErrNotFound - } - for _, col := range columns { - switch col { - case "created_at": - to.CreatedAt = from.CreatedAt - } - } - if m.CopyAttributesCalls == nil { - m.CopyAttributesCalls = make(map[string]string) - } - m.CopyAttributesCalls[fromID] = toID - return nil -} - -// SetRating sets the rating for an album -func (m *MockAlbumRepo) SetRating(_ context.Context, rating int, itemID string) error { - if m.Err { - return errors.New("unexpected error") - } - if d, ok := m.Data[itemID]; ok { - d.Rating = rating - } - return nil -} - -// SetStar sets the starred status for albums -func (m *MockAlbumRepo) SetStar(_ context.Context, starred bool, itemIDs ...string) error { - if m.Err { - return errors.New("unexpected error") - } - for _, id := range itemIDs { - if d, ok := m.Data[id]; ok { - d.Starred = starred - } - } - return nil -} - -func (m *MockAlbumRepo) GetYears(_ context.Context, libraryIDs ...int) ([]int, error) { - if m.Err { - return nil, errors.New("error") - } - return []int{}, nil +func (m *MockAlbumRepo) CountAll(...model.QueryOptions) (int64, error) { + return int64(len(m.all)), nil } var _ model.AlbumRepository = (*MockAlbumRepo)(nil) diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go index fe63edd94..6f30679fb 100644 --- a/tests/mock_artist_repo.go +++ b/tests/mock_artist_repo.go @@ -1,203 +1,76 @@ package tests import ( - "context" "errors" "time" + "github.com/google/uuid" + "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" ) func CreateMockArtistRepo() *MockArtistRepo { return &MockArtistRepo{ - Data: make(map[string]*model.Artist), + data: make(map[string]*model.Artist), } } type MockArtistRepo struct { model.ArtistRepository - Data map[string]*model.Artist - Err bool - Options model.QueryOptions + data map[string]*model.Artist + err bool } func (m *MockArtistRepo) SetError(err bool) { - m.Err = err + m.err = err } func (m *MockArtistRepo) SetData(artists model.Artists) { - m.Data = make(map[string]*model.Artist) + m.data = make(map[string]*model.Artist) for i, a := range artists { - m.Data[a.ID] = &artists[i] + m.data[a.ID] = &artists[i] } } -func (m *MockArtistRepo) Exists(_ context.Context, id string) (bool, error) { - if m.Err { +func (m *MockArtistRepo) Exists(id string) (bool, error) { + if m.err { return false, errors.New("Error!") } - _, found := m.Data[id] + _, found := m.data[id] return found, nil } -func (m *MockArtistRepo) Get(_ context.Context, id string) (*model.Artist, error) { - if m.Err { +func (m *MockArtistRepo) Get(id string) (*model.Artist, error) { + if m.err { return nil, errors.New("Error!") } - if d, ok := m.Data[id]; ok { + if d, ok := m.data[id]; ok { return d, nil } return nil, model.ErrNotFound } -func (m *MockArtistRepo) Put(_ context.Context, ar *model.Artist, columsToUpdate ...string) error { - if m.Err { +func (m *MockArtistRepo) Put(ar *model.Artist) error { + if m.err { return errors.New("error") } if ar.ID == "" { - ar.ID = id.NewRandom() + ar.ID = uuid.NewString() } - if m.Data == nil { - m.Data = make(map[string]*model.Artist) - } - m.Data[ar.ID] = ar + m.data[ar.ID] = ar return nil } -func (m *MockArtistRepo) IncPlayCount(_ context.Context, id string, timestamp time.Time) error { - if m.Err { +func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error { + if m.err { return errors.New("error") } - if d, ok := m.Data[id]; ok { + if d, ok := m.data[id]; ok { d.PlayCount++ - d.PlayDate = ×tamp + d.PlayDate = timestamp return nil } return model.ErrNotFound } -func (m *MockArtistRepo) SetStar(_ context.Context, starred bool, itemIDs ...string) error { - if m.Err { - return errors.New("error") - } - for _, id := range itemIDs { - if d, ok := m.Data[id]; ok { - d.Starred = starred - } - } - return nil -} - -func (m *MockArtistRepo) SetRating(_ context.Context, rating int, itemID string) error { - if m.Err { - return errors.New("error") - } - if d, ok := m.Data[itemID]; ok { - d.Rating = rating - } - return nil -} - -func (m *MockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Artists, error) { - if len(options) > 0 { - m.Options = options[0] - } - if m.Err { - return nil, errors.New("mock repo error") - } - var allArtists model.Artists - for _, artist := range m.Data { - allArtists = append(allArtists, *artist) - } - // Apply Max=1 if present (simple simulation for findArtistByName) - if len(options) > 0 && options[0].Max == 1 && len(allArtists) > 0 { - return allArtists[:1], nil - } - return allArtists, nil -} - -func (m *MockArtistRepo) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.ArtistCursor, error) { - res, err := m.GetAll(ctx, options...) - if err != nil { - return nil, err - } - return func(yield func(model.Artist, error) bool) { - for _, a := range res { - if !yield(a, nil) { - return - } - } - }, nil -} - -func (m *MockArtistRepo) UpdateExternalInfo(ctx context.Context, artist *model.Artist) error { - return m.Put(ctx, artist) -} - -func (m *MockArtistRepo) RefreshStats(_ context.Context, allArtists bool) (int64, error) { - if m.Err { - return 0, errors.New("mock repo error") - } - return int64(len(m.Data)), nil -} - -func (m *MockArtistRepo) RefreshPlayCounts(_ context.Context) (int64, error) { - if m.Err { - return 0, errors.New("mock repo error") - } - return int64(len(m.Data)), nil -} - -func (m *MockArtistRepo) GetIndex(ctx context.Context, includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) { - if m.Err { - return nil, errors.New("mock repo error") - } - - artists, err := m.GetAll(ctx) - if err != nil { - return nil, err - } - - // For mock purposes, if no artists available, return empty result - if len(artists) == 0 { - return model.ArtistIndexes{}, nil - } - - // Simple index grouping by first letter (simplified implementation for mocks) - indexMap := make(map[string]model.Artists) - for _, artist := range artists { - key := "#" - if len(artist.Name) > 0 { - key = string(artist.Name[0]) - } - indexMap[key] = append(indexMap[key], artist) - } - - var result model.ArtistIndexes - for k, artists := range indexMap { - result = append(result, model.ArtistIndex{ID: k, Artists: artists}) - } - - return result, nil -} - -func (m *MockArtistRepo) CountAll(context.Context, ...model.QueryOptions) (int64, error) { - if m.Err { - return 0, errors.New("mock repo error") - } - return int64(len(m.Data)), nil -} - -func (m *MockArtistRepo) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.Artists, error) { - if len(options) > 0 { - m.Options = options[0] - } - if m.Err { - return nil, errors.New("unexpected error") - } - // Simple mock implementation - just return all artists for testing - return m.GetAll(ctx) -} - var _ model.ArtistRepository = (*MockArtistRepo)(nil) diff --git a/tests/mock_artwork_queue_repo.go b/tests/mock_artwork_queue_repo.go deleted file mode 100644 index c6a7917f0..000000000 --- a/tests/mock_artwork_queue_repo.go +++ /dev/null @@ -1,364 +0,0 @@ -package tests - -import ( - "cmp" - "context" - "slices" - "sync" - "time" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" -) - -type MockArtworkQueueRepo struct { - model.ArtworkQueueRepository - // mu guards Data: the worker drains this mock concurrently. - mu sync.Mutex - Data map[string]model.ArtworkQueueItem // keyed by iaKey(kind, id, imageType) - Err error - // ItemArtworkSource, when set, backs the set-difference insert with real item_artwork state. - ItemArtworkSource *MockArtworkRepo - // ExistingIDs is keyed by item_kind; a nil per-kind map means PurgeDangling keeps that kind. - ExistingIDs map[string]map[string]bool -} - -func CreateMockArtworkQueueRepo() *MockArtworkQueueRepo { - return &MockArtworkQueueRepo{Data: map[string]model.ArtworkQueueItem{}} -} - -func (m *MockArtworkQueueRepo) Get(_ context.Context, kind model.Kind, id, imageType string) (*model.ArtworkQueueItem, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - it, ok := m.Data[iaKey(kind.Prefix(), id, imageType)] - if !ok { - return nil, model.ErrNotFound - } - return &it, nil -} - -func (m *MockArtworkQueueRepo) Enqueue(_ context.Context, items ...model.ArtworkQueueItem) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - m.enqueueLocked(items) - return nil -} - -func (m *MockArtworkQueueRepo) enqueueLocked(items []model.ArtworkQueueItem) { - now := time.Now() - for _, it := range items { - if it.ImageType == "" { - it.ImageType = model.ImageTypePrimary - } - k := iaKey(it.ItemKind, it.ItemID, it.ImageType) - // Mirror the SQL: retry_at/enqueued_at are server-set, never taken from the caller. - if prev, ok := m.Data[k]; ok { - prev.Priority = max(prev.Priority, it.Priority) - prev.RetryAt = now - prev.Attempts = 0 - prev.EnqueuedAt = now - m.Data[k] = prev - continue - } - it.Attempts = 0 - it.RetryAt = now - it.EnqueuedAt = now - m.Data[k] = it - } -} - -// EnqueueIfMissing mirrors the SQL anti-join: skip anything that already has an item_artwork row. -func (m *MockArtworkQueueRepo) EnqueueIfMissing(_ context.Context, items ...model.ArtworkQueueItem) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - var fresh []model.ArtworkQueueItem - for _, it := range items { - k := iaKey(it.ItemKind, it.ItemID, cmp.Or(it.ImageType, model.ImageTypePrimary)) - if m.ItemArtworkSource != nil { - if _, ok := m.ItemArtworkSource.ItemData[k]; ok { - continue - } - } - if _, ok := m.Data[k]; ok { // DO NOTHING - continue - } - fresh = append(fresh, it) - } - m.enqueueLocked(fresh) - return nil -} - -func (m *MockArtworkQueueRepo) DequeueBatch(_ context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - var res []model.ArtworkQueueItem - now := time.Now() - for _, it := range m.Data { - if !it.RetryAt.After(now) && (len(kinds) == 0 || slices.Contains(kinds, it.ItemKind)) { - res = append(res, it) - } - } - slices.SortFunc(res, func(a, b model.ArtworkQueueItem) int { - return cmp.Or(cmp.Compare(b.Priority, a.Priority), a.EnqueuedAt.Compare(b.EnqueuedAt)) - }) - if len(res) > n { - res = res[:n] - } - return res, nil -} - -func (m *MockArtworkQueueRepo) MarkFailedIfUnchanged(_ context.Context, kind, id, imageType string, seenRetryAt, retryAt time.Time, trace string) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - k := iaKey(kind, id, imageType) - if it, ok := m.Data[k]; ok && it.RetryAt.Equal(seenRetryAt) { - it.Attempts++ - it.RetryAt = retryAt - it.Trace = trace - m.Data[k] = it - } - return nil -} - -func (m *MockArtworkQueueRepo) DeleteIfUnchanged(_ context.Context, kind, id, imageType string, retryAt time.Time) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - k := iaKey(kind, id, imageType) - if it, ok := m.Data[k]; ok && it.RetryAt.Equal(retryAt) { - delete(m.Data, k) - } - return nil -} - -func (m *MockArtworkQueueRepo) PurgeDangling(context.Context) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - var purged int64 - for k, it := range m.Data { - existing := m.ExistingIDs[it.ItemKind] - if existing == nil { - continue - } - if !existing[it.ItemID] { - delete(m.Data, k) - purged++ - } - } - return purged, nil -} - -// queueFilterMatches mirrors artworkQueueFilter, so the mock cannot let a preview and a delete disagree. -func queueFilterMatches(it model.ArtworkQueueItem, kinds []model.Kind, priorities []int) bool { - prefixes := model.KindPrefixes(kinds) - return (len(prefixes) == 0 || slices.Contains(prefixes, it.ItemKind)) && - (len(priorities) == 0 || slices.Contains(priorities, it.Priority)) -} - -func (m *MockArtworkQueueRepo) PurgeQueued(_ context.Context, kinds []model.Kind, priorities []int) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - var purged int64 - for k, it := range m.Data { - if !queueFilterMatches(it, kinds, priorities) { - continue - } - delete(m.Data, k) - purged++ - } - return purged, nil -} - -func (m *MockArtworkQueueRepo) Count(context.Context) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - return int64(len(m.Data)), nil -} - -func (m *MockArtworkQueueRepo) CountQueued(_ context.Context, kinds []model.Kind, priorities []int) ([]model.ArtworkQueueStat, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - var res []model.ArtworkQueueStat - for _, it := range m.Data { - if !queueFilterMatches(it, kinds, priorities) { - continue - } - i := slices.IndexFunc(res, func(s model.ArtworkQueueStat) bool { - return s.ItemKind == it.ItemKind && s.Priority == it.Priority - }) - if i < 0 { - res = append(res, model.ArtworkQueueStat{ItemKind: it.ItemKind, Priority: it.Priority, Count: 1}) - continue - } - res[i].Count++ - } - slices.SortFunc(res, func(a, b model.ArtworkQueueStat) int { - return cmp.Or(cmp.Compare(a.ItemKind, b.ItemKind), cmp.Compare(b.Priority, a.Priority)) - }) - return res, nil -} - -func (m *MockArtworkQueueRepo) EnqueuePreservingBackoff(_ context.Context, items ...model.ArtworkQueueItem) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - now := time.Now() - for _, it := range items { - if it.ImageType == "" { - it.ImageType = model.ImageTypePrimary - } - k := iaKey(it.ItemKind, it.ItemID, it.ImageType) - // Preserve an existing row's retry_at: a bump raises priority without resetting backoff. - if prev, ok := m.Data[k]; ok { - prev.Priority = max(prev.Priority, it.Priority) - m.Data[k] = prev - continue - } - it.Attempts = 0 - it.RetryAt = now - it.EnqueuedAt = now - m.Data[k] = it - } - return nil -} - -// matchingSource mirrors the SQL filter: no sources means every source, "" the absent state, and -// ArtworkSourceFailed the absent states that gave up. -func (m *MockArtworkQueueRepo) matchingSource(kind model.Kind, sources []string) []model.ItemArtwork { - if m.ItemArtworkSource == nil { - return nil - } - matches := func(ia model.ItemArtwork) bool { - if len(sources) == 0 { - return true - } - if slices.Contains(sources, ia.Source) { - return true - } - return slices.Contains(sources, model.ArtworkSourceFailed) && ia.Hash == "" && ia.LastFailure != "" - } - var res []model.ItemArtwork - for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind == kind.Prefix() && matches(ia) { - res = append(res, ia) - } - } - return res -} - -func (m *MockArtworkQueueRepo) CountBySource(_ context.Context, kind model.Kind, sources []string) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - return int64(len(m.matchingSource(kind, sources))), nil -} - -func (m *MockArtworkQueueRepo) SourcesInUse(_ context.Context, kind model.Kind) ([]string, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - sources := slice.Map(m.matchingSource(kind, nil), func(ia model.ItemArtwork) string { return ia.Source }) - return slice.Unique(sources), nil -} - -func (m *MockArtworkQueueRepo) EnqueueBySource(_ context.Context, kind model.Kind, sources []string, priority int) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - now := time.Now() - var inserted int64 - for _, ia := range m.matchingSource(kind, sources) { - k := iaKey(ia.ItemKind, ia.ItemID, ia.ImageType) - if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows - continue - } - m.Data[k] = model.ArtworkQueueItem{ - ItemKind: ia.ItemKind, - ItemID: ia.ItemID, - ImageType: ia.ImageType, - Priority: priority, - RetryAt: now, - EnqueuedAt: now, - } - inserted++ - } - return inserted, nil -} - -// EnqueueAllMissing mirrors the SQL set-difference insert: ExistingIDs[kind] minus ItemArtworkSource. -func (m *MockArtworkQueueRepo) EnqueueAllMissing(_ context.Context, kind model.Kind, priority int) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - hasRow := func(id string) bool { - if m.ItemArtworkSource == nil { - return false - } - for _, ia := range m.ItemArtworkSource.ItemData { - if ia.ItemKind == kind.Prefix() && ia.ItemID == id { - return true - } - } - return false - } - now := time.Now() - var inserted int64 - for id := range m.ExistingIDs[kind.Prefix()] { - if hasRow(id) { - continue - } - k := iaKey(kind.Prefix(), id, model.ImageTypePrimary) - if _, ok := m.Data[k]; ok { // DO NOTHING: never touch existing queue rows - continue - } - m.Data[k] = model.ArtworkQueueItem{ - ItemKind: kind.Prefix(), - ItemID: id, - ImageType: model.ImageTypePrimary, - Priority: priority, - RetryAt: now, - EnqueuedAt: now, - } - inserted++ - } - return inserted, nil -} diff --git a/tests/mock_artwork_repo.go b/tests/mock_artwork_repo.go deleted file mode 100644 index 350c0e9f6..000000000 --- a/tests/mock_artwork_repo.go +++ /dev/null @@ -1,190 +0,0 @@ -package tests - -import ( - "context" - "maps" - "sync" - "time" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" -) - -type MockArtworkRepo struct { - model.ArtworkRepository - // mu guards the maps so the worker's concurrent drain can hit this mock race-free. - mu sync.Mutex - Data map[string]model.Artwork - ItemData map[string]model.ItemArtwork // keyed by iaKey(kind, id, imageType) - Err error - // ExistingIDs, keyed by item_kind, backs PurgeDanglingItems; nil map keeps everything. - ExistingIDs map[string]map[string]bool -} - -func CreateMockArtworkRepo() *MockArtworkRepo { - return &MockArtworkRepo{Data: map[string]model.Artwork{}, ItemData: map[string]model.ItemArtwork{}} -} - -func iaKey(kind, id, imageType string) string { return kind + "|" + id + "|" + imageType } - -func (m *MockArtworkRepo) GetImage(_ context.Context, hash string) (*model.Artwork, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - if a, ok := m.Data[hash]; ok { - return &a, nil - } - return nil, model.ErrNotFound -} - -func (m *MockArtworkRepo) PutImage(_ context.Context, a *model.Artwork) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - // Mirrors the SQL repository: every upsert refreshes created_at. Age fixtures via Data directly. - a.CreatedAt = time.Now() - m.Data[a.Hash] = *a - return nil -} - -func (m *MockArtworkRepo) GetMimeByHash(context.Context) (map[string]string, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - mimes := make(map[string]string, len(m.Data)) - for h, a := range m.Data { - mimes[h] = a.Mime - } - return mimes, nil -} - -func (m *MockArtworkRepo) PurgeDanglingItems(context.Context) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - var purged int64 - for k, ia := range m.ItemData { - // A nil per-kind map means that kind isn't tracked by the test, so keep it. - existing := m.ExistingIDs[ia.ItemKind] - if existing == nil { - continue - } - if !existing[ia.ItemID] { - delete(m.ItemData, k) - purged++ - } - } - return purged, nil -} - -func (m *MockArtworkRepo) PurgeOrphans(_ context.Context, createdBefore time.Time) (int64, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return 0, m.Err - } - var deleted int64 - for h, a := range m.Data { - if m.referenced(h) || !a.CreatedAt.Before(createdBefore) { - continue - } - delete(m.Data, h) - deleted++ - } - return deleted, nil -} - -func (m *MockArtworkRepo) referenced(hash string) bool { - for _, ia := range m.ItemData { - if ia.Hash == hash { - return true - } - } - return false -} - -func (m *MockArtworkRepo) GetItemArtwork(_ context.Context, kind model.Kind, id, imageType string) (*model.ItemArtwork, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - if ia, ok := m.ItemData[iaKey(kind.Prefix(), id, imageType)]; ok { - return &ia, nil - } - return nil, model.ErrNotFound -} - -func (m *MockArtworkRepo) PutLastFailure(_ context.Context, kind model.Kind, id, imageType, trace string) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - key := iaKey(kind.Prefix(), id, imageType) - if ia, ok := m.ItemData[key]; ok { - ia.LastFailure = trace - m.ItemData[key] = ia - } - return nil -} - -func (m *MockArtworkRepo) PutItemArtwork(_ context.Context, ia *model.ItemArtwork) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - if ia.ImageType == "" { - ia.ImageType = model.ImageTypePrimary - } - ia.UpdatedAt = time.Now() - if ia.AttemptedAt.IsZero() { - ia.AttemptedAt = ia.UpdatedAt - } - m.ItemData[iaKey(ia.ItemKind, ia.ItemID, ia.ImageType)] = *ia - return nil -} - -func (m *MockArtworkRepo) DeleteForItems(_ context.Context, kind model.Kind, ids []string) error { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return m.Err - } - idSet := slice.ToSet(ids) - maps.DeleteFunc(m.ItemData, func(_ string, ia model.ItemArtwork) bool { - _, ok := idSet[ia.ItemID] - return ok && ia.ItemKind == kind.Prefix() - }) - return nil -} - -func (m *MockArtworkRepo) GetInfoForItems(_ context.Context, kind model.Kind, ids []string) (map[string]model.ItemArtworkInfo, error) { - m.mu.Lock() - defer m.mu.Unlock() - if m.Err != nil { - return nil, m.Err - } - res := map[string]model.ItemArtworkInfo{} - for _, id := range ids { - if ia, ok := m.ItemData[iaKey(kind.Prefix(), id, model.ImageTypePrimary)]; ok { - info := model.ItemArtworkInfo{ItemID: id, Hash: ia.Hash} - if a, ok := m.Data[ia.Hash]; ok { - info.BlurHash, info.ThumbHash = a.BlurHash, a.ThumbHash - info.DominantColor = a.DominantColor - info.Width, info.Height = a.Width, a.Height - } - res[id] = info - } - } - return res, nil -} diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go deleted file mode 100644 index a5e4126cf..000000000 --- a/tests/mock_data_store.go +++ /dev/null @@ -1,342 +0,0 @@ -package tests - -import ( - "context" - "sync" - - "github.com/navidrome/navidrome/model" -) - -type MockDataStore struct { - RealDS model.DataStore - MockedLibrary model.LibraryRepository - MockedFolder model.FolderRepository - MockedGenre model.GenreRepository - MockedAlbum model.AlbumRepository - MockedArtist model.ArtistRepository - MockedMediaFile model.MediaFileRepository - MockedTag model.TagRepository - MockedUser model.UserRepository - MockedProperty model.PropertyRepository - MockedPlayer model.PlayerRepository - MockedPlaylist model.PlaylistRepository - MockedPlayQueue model.PlayQueueRepository - MockedShare model.ShareRepository - MockedTranscoding model.TranscodingRepository - MockedUserProps model.UserPropsRepository - MockedScrobbleBuffer model.ScrobbleBufferRepository - MockedScrobble model.ScrobbleRepository - MockedRadio model.RadioRepository - MockedPlugin model.PluginRepository - MockedArtwork model.ArtworkRepository - MockedArtworkQueue model.ArtworkQueueRepository - scrobbleBufferMu sync.Mutex - repoMu sync.Mutex - - // GC tracking - GCCalled bool - GCError error -} - -func (db *MockDataStore) Library() model.LibraryRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedLibrary != nil { - return db.MockedLibrary - } - if db.RealDS != nil { - return db.RealDS.Library() - } - db.MockedLibrary = &MockLibraryRepo{} - return db.MockedLibrary -} - -func (db *MockDataStore) Folder() model.FolderRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedFolder != nil { - return db.MockedFolder - } - if db.RealDS != nil { - return db.RealDS.Folder() - } - db.MockedFolder = struct{ model.FolderRepository }{} - return db.MockedFolder -} - -func (db *MockDataStore) Tag() model.TagRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedTag != nil { - return db.MockedTag - } - if db.RealDS != nil { - return db.RealDS.Tag() - } - db.MockedTag = &MockTagRepo{} - return db.MockedTag -} - -func (db *MockDataStore) Album() model.AlbumRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedAlbum != nil { - return db.MockedAlbum - } - if db.RealDS != nil { - return db.RealDS.Album() - } - db.MockedAlbum = CreateMockAlbumRepo() - return db.MockedAlbum -} - -func (db *MockDataStore) Artist() model.ArtistRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedArtist != nil { - return db.MockedArtist - } - if db.RealDS != nil { - return db.RealDS.Artist() - } - db.MockedArtist = CreateMockArtistRepo() - return db.MockedArtist -} - -func (db *MockDataStore) MediaFile() model.MediaFileRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.RealDS != nil && db.MockedMediaFile == nil { - return db.RealDS.MediaFile() - } - if db.MockedMediaFile == nil { - db.MockedMediaFile = CreateMockMediaFileRepo() - } - return db.MockedMediaFile -} - -func (db *MockDataStore) Genre() model.GenreRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedGenre != nil { - return db.MockedGenre - } - if db.RealDS != nil { - return db.RealDS.Genre() - } - db.MockedGenre = &MockedGenreRepo{} - return db.MockedGenre -} - -func (db *MockDataStore) Playlist() model.PlaylistRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedPlaylist != nil { - return db.MockedPlaylist - } - if db.RealDS != nil { - return db.RealDS.Playlist() - } - db.MockedPlaylist = CreateMockPlaylistRepo() - return db.MockedPlaylist -} - -func (db *MockDataStore) PlayQueue() model.PlayQueueRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedPlayQueue != nil { - return db.MockedPlayQueue - } - if db.RealDS != nil { - return db.RealDS.PlayQueue() - } - db.MockedPlayQueue = &MockPlayQueueRepo{} - return db.MockedPlayQueue -} - -func (db *MockDataStore) UserProps() model.UserPropsRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedUserProps != nil { - return db.MockedUserProps - } - if db.RealDS != nil { - return db.RealDS.UserProps() - } - db.MockedUserProps = &MockedUserPropsRepo{} - return db.MockedUserProps -} - -func (db *MockDataStore) Property() model.PropertyRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedProperty != nil { - return db.MockedProperty - } - if db.RealDS != nil { - return db.RealDS.Property() - } - db.MockedProperty = &MockedPropertyRepo{} - return db.MockedProperty -} - -func (db *MockDataStore) Share() model.ShareRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedShare != nil { - return db.MockedShare - } - if db.RealDS != nil { - return db.RealDS.Share() - } - db.MockedShare = &MockShareRepo{} - return db.MockedShare -} - -func (db *MockDataStore) User() model.UserRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedUser != nil { - return db.MockedUser - } - if db.RealDS != nil { - return db.RealDS.User() - } - db.MockedUser = CreateMockUserRepo() - return db.MockedUser -} - -func (db *MockDataStore) Transcoding() model.TranscodingRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedTranscoding != nil { - return db.MockedTranscoding - } - if db.RealDS != nil { - return db.RealDS.Transcoding() - } - db.MockedTranscoding = struct{ model.TranscodingRepository }{} - return db.MockedTranscoding -} - -func (db *MockDataStore) Player() model.PlayerRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedPlayer != nil { - return db.MockedPlayer - } - if db.RealDS != nil { - return db.RealDS.Player() - } - db.MockedPlayer = CreateMockPlayerRepo() - return db.MockedPlayer -} - -func (db *MockDataStore) ScrobbleBuffer() model.ScrobbleBufferRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.RealDS != nil && db.MockedScrobbleBuffer == nil { - return db.RealDS.ScrobbleBuffer() - } - db.scrobbleBufferMu.Lock() - defer db.scrobbleBufferMu.Unlock() - if db.MockedScrobbleBuffer == nil { - db.MockedScrobbleBuffer = &MockedScrobbleBufferRepo{} - } - return db.MockedScrobbleBuffer -} - -func (db *MockDataStore) Scrobble() model.ScrobbleRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedScrobble != nil { - return db.MockedScrobble - } - if db.RealDS != nil { - return db.RealDS.Scrobble() - } - db.MockedScrobble = &MockScrobbleRepo{} - return db.MockedScrobble -} - -func (db *MockDataStore) Radio() model.RadioRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedRadio != nil { - return db.MockedRadio - } - if db.RealDS != nil { - return db.RealDS.Radio() - } - db.MockedRadio = CreateMockedRadioRepo() - return db.MockedRadio -} - -func (db *MockDataStore) Plugin() model.PluginRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedPlugin != nil { - return db.MockedPlugin - } - if db.RealDS != nil { - return db.RealDS.Plugin() - } - db.MockedPlugin = CreateMockPluginRepo() - return db.MockedPlugin -} - -func (db *MockDataStore) Artwork() model.ArtworkRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - return db.artworkLocked() -} - -// artworkLocked is the body of Artwork for callers already holding repoMu; repoMu is a plain -// Mutex, so re-entering through the exported method would deadlock. -func (db *MockDataStore) artworkLocked() model.ArtworkRepository { - if db.MockedArtwork != nil { - return db.MockedArtwork - } - if db.RealDS != nil { - return db.RealDS.Artwork() - } - db.MockedArtwork = CreateMockArtworkRepo() - return db.MockedArtwork -} - -func (db *MockDataStore) ArtworkQueue() model.ArtworkQueueRepository { - db.repoMu.Lock() - defer db.repoMu.Unlock() - if db.MockedArtworkQueue != nil { - return db.MockedArtworkQueue - } - if db.RealDS != nil { - return db.RealDS.ArtworkQueue() - } - q := CreateMockArtworkQueueRepo() - if aw, ok := db.artworkLocked().(*MockArtworkRepo); ok { - q.ItemArtworkSource = aw - } - db.MockedArtworkQueue = q - return db.MockedArtworkQueue -} - -func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { - return block(db) -} - -func (db *MockDataStore) WithTxImmediate(block func(tx model.DataStore) error, label ...string) error { - return block(db) -} - -func (db *MockDataStore) WithTxRetry(ctx context.Context, block func(ctx context.Context, tx model.DataStore) error, label ...string) error { - return block(ctx, db) -} - -func (db *MockDataStore) GC(context.Context, ...int) error { - db.GCCalled = true - if db.GCError != nil { - return db.GCError - } - return nil -} diff --git a/tests/mock_ffmpeg.go b/tests/mock_ffmpeg.go index 8e4d12f0b..09e651d96 100644 --- a/tests/mock_ffmpeg.go +++ b/tests/mock_ffmpeg.go @@ -1,38 +1,25 @@ package tests import ( - "bytes" "context" "io" "strings" "sync" "sync/atomic" - - "github.com/navidrome/navidrome/core/ffmpeg" ) func NewMockFFmpeg(data string) *MockFFmpeg { - return &MockFFmpeg{Reader: strings.NewReader(data), ProbeAvailable: true} + return &MockFFmpeg{Reader: strings.NewReader(data)} } type MockFFmpeg struct { io.Reader - lock sync.Mutex - closed atomic.Bool - Error error - ProbeAudioResult *ffmpeg.AudioProbeResult - ProbeAvailable bool + lock sync.Mutex + closed atomic.Bool + Error error } -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) { +func (ff *MockFFmpeg) Transcode(_ context.Context, _, _ string, _ int) (f io.ReadCloser, err error) { if ff.Error != nil { return nil, ff.Error } @@ -46,24 +33,12 @@ func (ff *MockFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, erro return ff, nil } -func (ff *MockFFmpeg) ConvertAnimatedImage(_ context.Context, reader io.Reader, _ int, _ int) (io.ReadCloser, error) { +func (ff *MockFFmpeg) Probe(context.Context, []string) (string, error) { if ff.Error != nil { - return nil, ff.Error + return "", ff.Error } - data, err := io.ReadAll(reader) - if err != nil { - return nil, err - } - return io.NopCloser(bytes.NewReader(data)), nil + return "", nil } - -func (ff *MockFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { - if ff.Error != nil { - return nil, ff.Error - } - return ff.ProbeAudioResult, nil -} - func (ff *MockFFmpeg) CmdPath() (string, error) { if ff.Error != nil { return "", ff.Error @@ -71,10 +46,6 @@ func (ff *MockFFmpeg) CmdPath() (string, error) { return "ffmpeg", nil } -func (ff *MockFFmpeg) Version() string { - return "1.0" -} - func (ff *MockFFmpeg) Read(p []byte) (n int, err error) { ff.lock.Lock() defer ff.lock.Unlock() diff --git a/tests/mock_genre_repo.go b/tests/mock_genre_repo.go index 917e2c626..a24f2b0c8 100644 --- a/tests/mock_genre_repo.go +++ b/tests/mock_genre_repo.go @@ -1,74 +1,38 @@ package tests import ( - "context" - - "github.com/deluan/rest" "github.com/navidrome/navidrome/model" ) type MockedGenreRepo struct { - Error error - Data map[string]model.Genre - Options model.QueryOptions + Error error + data map[string]model.Genre } func (r *MockedGenreRepo) init() { - if r.Data == nil { - r.Data = make(map[string]model.Genre) + if r.data == nil { + r.data = make(map[string]model.Genre) } } -func (r *MockedGenreRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Genres, error) { - if len(options) > 0 { - r.Options = options[0] - } +func (r *MockedGenreRepo) GetAll(...model.QueryOptions) (model.Genres, error) { if r.Error != nil { return nil, r.Error } r.init() var all model.Genres - for _, g := range r.Data { + for _, g := range r.data { all = append(all, g) } return all, nil } -func (r *MockedGenreRepo) Get(_ context.Context, id string) (*model.Genre, error) { - if r.Error != nil { - return nil, r.Error - } - r.init() - if g, ok := r.Data[id]; ok { - return &g, nil - } - return nil, model.ErrNotFound -} - func (r *MockedGenreRepo) Put(g *model.Genre) error { if r.Error != nil { return r.Error } r.init() - r.Data[g.ID] = *g + r.data[g.ID] = *g return nil } - -func (r *MockedGenreRepo) Count(context.Context, ...rest.QueryOptions) (int64, error) { - if r.Error != nil { - return 0, r.Error - } - r.init() - return int64(len(r.Data)), nil -} - -func (r *MockedGenreRepo) Read(ctx context.Context, id string) (*model.Genre, error) { - return r.Get(ctx, id) -} - -func (r *MockedGenreRepo) ReadAll(ctx context.Context, _ ...rest.QueryOptions) ([]model.Genre, error) { - return r.GetAll(ctx) -} - -var _ model.GenreRepository = (*MockedGenreRepo)(nil) diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go deleted file mode 100644 index 6de2b9265..000000000 --- a/tests/mock_library_repo.go +++ /dev/null @@ -1,312 +0,0 @@ -package tests - -import ( - "context" - "errors" - "fmt" - "slices" - "strconv" - - "github.com/Masterminds/squirrel" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/model" -) - -type MockLibraryRepo struct { - model.LibraryRepository - Data map[int]model.Library - Err error - PutFn func(*model.Library) error // Allow custom Put behavior for testing - PutCols []string -} - -func (m *MockLibraryRepo) SetData(data model.Libraries) { - m.Data = make(map[int]model.Library) - for _, d := range data { - m.Data[d.ID] = d - } -} - -func (m *MockLibraryRepo) GetAll(_ context.Context, _ ...model.QueryOptions) (model.Libraries, error) { - if m.Err != nil { - return nil, m.Err - } - var libraries model.Libraries - for _, lib := range m.Data { - libraries = append(libraries, lib) - } - // Sort by ID for predictable order - slices.SortFunc(libraries, func(a, b model.Library) int { - return a.ID - b.ID - }) - return libraries, nil -} - -func (m *MockLibraryRepo) CountAll(_ context.Context, qo ...model.QueryOptions) (int64, error) { - if m.Err != nil { - return 0, m.Err - } - - // If no query options, return total count - if len(qo) == 0 || qo[0].Filters == nil { - return int64(len(m.Data)), nil - } - - // Handle squirrel.Eq filter for ID validation - if eq, ok := qo[0].Filters.(squirrel.Eq); ok { - if idFilter, exists := eq["id"]; exists { - if ids, isSlice := idFilter.([]int); isSlice { - count := 0 - for _, id := range ids { - if _, exists := m.Data[id]; exists { - count++ - } - } - return int64(count), nil - } - } - } - - // Default to total count for other filters - return int64(len(m.Data)), nil -} - -func (m *MockLibraryRepo) Get(_ context.Context, id int) (*model.Library, error) { - if m.Err != nil { - return nil, m.Err - } - if lib, ok := m.Data[id]; ok { - return &lib, nil - } - return nil, model.ErrNotFound -} - -func (m *MockLibraryRepo) GetPath(_ context.Context, id int) (string, error) { - if m.Err != nil { - return "", m.Err - } - if lib, ok := m.Data[id]; ok { - return lib.Path, nil - } - return "", model.ErrNotFound -} - -func (m *MockLibraryRepo) Put(_ context.Context, library *model.Library, colsToUpdate ...string) error { - m.PutCols = colsToUpdate - if m.PutFn != nil { - return m.PutFn(library) - } - if m.Err != nil { - return m.Err - } - if m.Data == nil { - m.Data = make(map[int]model.Library) - } - m.Data[library.ID] = *library - return nil -} - -func (m *MockLibraryRepo) Delete(_ context.Context, id int) error { - if m.Err != nil { - return m.Err - } - if _, ok := m.Data[id]; !ok { - return model.ErrNotFound - } - delete(m.Data, id) - return nil -} - -func (m *MockLibraryRepo) StoreMusicFolder(_ context.Context) error { - if m.Err != nil { - return m.Err - } - return nil -} - -func (m *MockLibraryRepo) AddArtist(_ context.Context, id int, artistID string) error { - if m.Err != nil { - return m.Err - } - return nil -} - -func (m *MockLibraryRepo) ScanBegin(_ context.Context, id int, fullScan bool) error { - if m.Err != nil { - return m.Err - } - return nil -} - -func (m *MockLibraryRepo) ScanEnd(_ context.Context, id int) error { - if m.Err != nil { - return m.Err - } - return nil -} - -func (m *MockLibraryRepo) SetScannedPID(_ context.Context, id int, pid model.PIDConfig) error { - if m.Err != nil { - return m.Err - } - if lib, ok := m.Data[id]; ok { - lib.ScannedPIDAlbum, lib.ScannedPIDTrack = pid.Album, pid.Track - m.Data[id] = lib - } - return nil -} - -func (m *MockLibraryRepo) ScanInProgress(_ context.Context) (bool, error) { - if m.Err != nil { - return false, m.Err - } - return false, nil -} - -func (m *MockLibraryRepo) RefreshStats(_ context.Context, id int) error { - return nil -} - -// User-library association methods - mock implementations - -func (m *MockLibraryRepo) GetUsersWithLibraryAccess(_ context.Context, libraryID int) (model.Users, error) { - if m.Err != nil { - return nil, m.Err - } - // Mock: return empty users for now - return model.Users{}, nil -} - -func (m *MockLibraryRepo) Count(ctx context.Context, _ ...rest.QueryOptions) (int64, error) { - return m.CountAll(ctx) -} - -func (m *MockLibraryRepo) Read(ctx context.Context, id string) (*model.Library, error) { - idInt, _ := strconv.Atoi(id) - return m.Get(ctx, idInt) -} - -func (m *MockLibraryRepo) ReadAll(ctx context.Context, _ ...rest.QueryOptions) ([]model.Library, error) { - return m.GetAll(ctx) -} - -// REST Repository methods (string-based IDs) - -func (m *MockLibraryRepo) Save(_ context.Context, lib *model.Library) (string, error) { - if m.Err != nil { - return "", m.Err - } - - // Validate required fields - if lib.Name == "" { - return "", &rest.ValidationError{Errors: map[string]string{"name": "library name is required"}} - } - if lib.Path == "" { - return "", &rest.ValidationError{Errors: map[string]string{"path": "library path is required"}} - } - - // Generate ID if not set - if lib.ID == 0 { - lib.ID = len(m.Data) + 1 - } - if m.Data == nil { - m.Data = make(map[int]model.Library) - } - m.Data[lib.ID] = *lib - return strconv.Itoa(lib.ID), nil -} - -func (m *MockLibraryRepo) Update(_ context.Context, id string, entity model.Library, _ ...string) error { - lib := &entity - if m.Err != nil { - return m.Err - } - - // Validate required fields - if lib.Name == "" { - return &rest.ValidationError{Errors: map[string]string{"name": "library name is required"}} - } - if lib.Path == "" { - return &rest.ValidationError{Errors: map[string]string{"path": "library path is required"}} - } - - idInt, err := strconv.Atoi(id) - if err != nil { - return errors.New("invalid ID format") - } - if _, exists := m.Data[idInt]; !exists { - return rest.ErrNotFound - } - lib.ID = idInt - m.Data[idInt] = *lib - return nil -} - -func (m *MockLibraryRepo) DeleteByStringID(id string) error { - if m.Err != nil { - return m.Err - } - idInt, err := strconv.Atoi(id) - if err != nil { - return errors.New("invalid ID format") - } - if _, exists := m.Data[idInt]; !exists { - return rest.ErrNotFound - } - delete(m.Data, idInt) - return nil -} - -// Service-level methods for core.Library interface - -func (m *MockLibraryRepo) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) { - if m.Err != nil { - return nil, m.Err - } - if userID == "non-existent" { - return nil, model.ErrNotFound - } - // Convert map to slice for return - var libraries model.Libraries - for _, lib := range m.Data { - libraries = append(libraries, lib) - } - // Sort by ID for predictable order - slices.SortFunc(libraries, func(a, b model.Library) int { - return a.ID - b.ID - }) - return libraries, nil -} - -func (m *MockLibraryRepo) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error { - if m.Err != nil { - return m.Err - } - if userID == "non-existent" { - return model.ErrNotFound - } - if userID == "admin-1" { - return fmt.Errorf("%w: cannot manually assign libraries to admin users", model.ErrValidation) - } - if len(libraryIDs) == 0 { - return fmt.Errorf("%w: at least one library must be assigned to non-admin users", model.ErrValidation) - } - // Validate all library IDs exist - for _, id := range libraryIDs { - if _, exists := m.Data[id]; !exists { - return fmt.Errorf("%w: library ID %d does not exist", model.ErrValidation, id) - } - } - return nil -} - -func (m *MockLibraryRepo) ValidateLibraryAccess(ctx context.Context, userID string, libraryID int) error { - if m.Err != nil { - return m.Err - } - // For testing purposes, allow access to all libraries - return nil -} - -var _ model.LibraryRepository = (*MockLibraryRepo)(nil) -var _ rest.Repository[model.Library] = (*MockLibraryRepo)(nil) diff --git a/tests/mock_library_service.go b/tests/mock_library_service.go deleted file mode 100644 index f5e1f0387..000000000 --- a/tests/mock_library_service.go +++ /dev/null @@ -1,47 +0,0 @@ -package tests - -import ( - "context" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/model" -) - -// MockLibraryService provides a simple wrapper around MockLibraryRepo -// that implements the core.Library interface for testing. -// Returns concrete type to avoid import cycles - callers assign to core.Library. -type MockLibraryService struct { - *MockLibraryRepo -} - -// MockLibraryRestAdapter adapts MockLibraryRepo to the REST repository interface -type MockLibraryRestAdapter struct { - *MockLibraryRepo -} - -// NewMockLibraryService creates a new mock library service for testing. -// Returns concrete type - assign to core.Library at call site. -func NewMockLibraryService() *MockLibraryService { - repo := &MockLibraryRepo{ - Data: make(map[int]model.Library), - } - // Set up default test data - repo.SetData(model.Libraries{ - {ID: 1, Name: "Test Library 1", Path: "/music/library1"}, - {ID: 2, Name: "Test Library 2", Path: "/music/library2"}, - }) - return &MockLibraryService{MockLibraryRepo: repo} -} - -func (m *MockLibraryService) Repository() rest.Repository[model.Library] { - return &MockLibraryRestAdapter{MockLibraryRepo: m.MockLibraryRepo} -} - -func (a *MockLibraryRestAdapter) Delete(_ context.Context, ids ...string) error { - for _, id := range ids { - if err := a.DeleteByStringID(id); err != nil { - return err - } - } - return nil -} diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 392a4d8e0..03db44d00 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -1,237 +1,97 @@ package tests import ( - "cmp" - "context" "errors" - "maps" - "slices" "time" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" - "github.com/navidrome/navidrome/model/id" + "github.com/google/uuid" "github.com/navidrome/navidrome/utils/slice" + "golang.org/x/exp/maps" + + "github.com/navidrome/navidrome/model" ) func CreateMockMediaFileRepo() *MockMediaFileRepo { return &MockMediaFileRepo{ - Data: make(map[string]*model.MediaFile), + data: make(map[string]*model.MediaFile), } } type MockMediaFileRepo struct { model.MediaFileRepository - Data map[string]*model.MediaFile - Err bool - // Add fields and methods for controlling CountAll and DeleteAllMissing in tests. - // A nil CountAllValue is unset, and CountAll falls back to counting rows in Data. - CountAllValue *int64 - CountAllOptions model.QueryOptions - DeleteAllMissingValue int64 - // ReassignReferencesCalls records prevID -> newID - ReassignReferencesCalls map[string]string - Options model.QueryOptions - // Add fields for cross-library move detection tests - FindRecentFilesByMBZTrackIDFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) - FindRecentFilesByPropertiesFunc func(missing model.MediaFile, since time.Time) (model.MediaFiles, error) - MatchesCriteriaValue bool - MatchesCriteriaErr error - BookmarksAdded []string + data map[string]*model.MediaFile + err bool } func (m *MockMediaFileRepo) SetError(err bool) { - m.Err = err -} - -func (m *MockMediaFileRepo) SetCountAll(count int64) { - m.CountAllValue = &count + m.err = err } func (m *MockMediaFileRepo) SetData(mfs model.MediaFiles) { - m.Data = make(map[string]*model.MediaFile) + m.data = make(map[string]*model.MediaFile) for i, mf := range mfs { - m.Data[mf.ID] = &mfs[i] + m.data[mf.ID] = &mfs[i] } } -func (m *MockMediaFileRepo) Exists(_ context.Context, id string) (bool, error) { - if m.Err { +func (m *MockMediaFileRepo) Exists(id string) (bool, error) { + if m.err { return false, errors.New("error") } - _, found := m.Data[id] + _, found := m.data[id] return found, nil } -func (m *MockMediaFileRepo) Get(_ context.Context, id string) (*model.MediaFile, error) { - if m.Err { +func (m *MockMediaFileRepo) Get(id string) (*model.MediaFile, error) { + if m.err { return nil, errors.New("error") } - if d, ok := m.Data[id]; ok { - // Intentionally clone the file and remove participants. This should - // catch any caller that actually means to call GetWithParticipants - res := *d - res.Participants = model.Participants{} - return &res, nil - } - return nil, model.ErrNotFound -} - -func (m *MockMediaFileRepo) AddBookmark(_ context.Context, id, _ string, _ int64) error { - if m.Err { - return errors.New("error") - } - m.BookmarksAdded = append(m.BookmarksAdded, id) - return nil -} - -func (m *MockMediaFileRepo) GetWithParticipants(_ context.Context, id string) (*model.MediaFile, error) { - if m.Err { - return nil, errors.New("error") - } - if d, ok := m.Data[id]; ok { + if d, ok := m.data[id]; ok { return d, nil } return nil, model.ErrNotFound } -func (m *MockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) { - return m.GetAll(ctx, options...) -} - -func (m *MockMediaFileRepo) GetAll(_ context.Context, qo ...model.QueryOptions) (model.MediaFiles, error) { - if len(qo) > 0 { - m.Options = qo[0] - } - if m.Err { +func (m *MockMediaFileRepo) GetAll(...model.QueryOptions) (model.MediaFiles, error) { + if m.err { return nil, errors.New("error") } - values := slices.Collect(maps.Values(m.Data)) - result := slice.Map(values, func(p *model.MediaFile) model.MediaFile { + values := maps.Values(m.data) + return slice.Map(values, func(p *model.MediaFile) model.MediaFile { return *p - }) - // Sort by ID to ensure deterministic ordering for tests - slices.SortFunc(result, func(a, b model.MediaFile) int { - return cmp.Compare(a.ID, b.ID) - }) - return result, nil + }), nil } -func (m *MockMediaFileRepo) GetRandom(ctx context.Context, qo ...model.QueryOptions) (model.MediaFiles, error) { - res, err := m.GetAll(ctx, qo...) - if err != nil { - return nil, err - } - if len(qo) > 0 && qo[0].Max > 0 && len(res) > qo[0].Max { - res = res[:qo[0].Max] - } - return res, nil -} - -func (m *MockMediaFileRepo) GetCursor(ctx context.Context, qo ...model.QueryOptions) (model.MediaFileCursor, error) { - res, err := m.GetAll(ctx, qo...) - if err != nil { - return nil, err - } - return func(yield func(model.MediaFile, error) bool) { - for _, mf := range res { - if !yield(mf, nil) { - return - } - } - }, nil -} - -func (m *MockMediaFileRepo) GetCursorWithArtwork(ctx context.Context, qo ...model.QueryOptions) (model.MediaFileCursor, error) { - return m.GetCursor(ctx, qo...) -} - -func (m *MockMediaFileRepo) Put(_ context.Context, mf *model.MediaFile) error { - if m.Err { +func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { + if m.err { return errors.New("error") } if mf.ID == "" { - mf.ID = id.NewRandom() + mf.ID = uuid.NewString() } - m.Data[mf.ID] = mf + m.data[mf.ID] = mf return nil } -func (m *MockMediaFileRepo) UpdateProbeData(_ context.Context, id string, data string) error { - if m.Err { +func (m *MockMediaFileRepo) IncPlayCount(id string, timestamp time.Time) error { + if m.err { return errors.New("error") } - if d, ok := m.Data[id]; ok { - d.ProbeData = data - return nil - } - return model.ErrNotFound -} - -func (m *MockMediaFileRepo) Delete(_ context.Context, id string) error { - if m.Err { - return errors.New("error") - } - if _, ok := m.Data[id]; !ok { - return model.ErrNotFound - } - delete(m.Data, id) - return nil -} - -func (m *MockMediaFileRepo) ReassignReferences(_ context.Context, prevID, newID string) error { - if m.Err { - return errors.New("error") - } - if m.ReassignReferencesCalls == nil { - m.ReassignReferencesCalls = make(map[string]string) - } - m.ReassignReferencesCalls[prevID] = newID - return nil -} - -func (m *MockMediaFileRepo) IncPlayCount(_ context.Context, id string, timestamp time.Time) error { - if m.Err { - return errors.New("error") - } - if d, ok := m.Data[id]; ok { + if d, ok := m.data[id]; ok { d.PlayCount++ - d.PlayDate = ×tamp + d.PlayDate = timestamp return nil } return model.ErrNotFound } -func (m *MockMediaFileRepo) SetStar(_ context.Context, starred bool, itemIDs ...string) error { - if m.Err { - return errors.New("error") - } - for _, id := range itemIDs { - if d, ok := m.Data[id]; ok { - d.Starred = starred - } - } - return nil -} - -func (m *MockMediaFileRepo) SetRating(_ context.Context, rating int, itemID string) error { - if m.Err { - return errors.New("error") - } - if d, ok := m.Data[itemID]; ok { - d.Rating = rating - } - return nil -} - func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, error) { - if m.Err { + if m.err { return nil, errors.New("error") } - var res = make(model.MediaFiles, len(m.Data)) + var res = make(model.MediaFiles, len(m.data)) i := 0 - for _, a := range m.Data { + for _, a := range m.data { if a.AlbumID == artistId { res[i] = *a i++ @@ -241,150 +101,4 @@ func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, erro return res, nil } -func (m *MockMediaFileRepo) GetMissingAndMatching(_ context.Context, libId int) (model.MediaFileCursor, error) { - if m.Err { - return nil, errors.New("error") - } - var res model.MediaFiles - for _, a := range m.Data { - if a.LibraryID == libId && a.Missing { - res = append(res, *a) - } - } - - for _, a := range m.Data { - if a.LibraryID == libId && !(*a).Missing && slices.IndexFunc(res, func(mediaFile model.MediaFile) bool { - return mediaFile.PID == a.PID - }) != -1 { - res = append(res, *a) - } - } - slices.SortFunc(res, func(i, j model.MediaFile) int { - return cmp.Or( - cmp.Compare(i.PID, j.PID), - cmp.Compare(i.ID, j.ID), - ) - }) - - return func(yield func(model.MediaFile, error) bool) { - for _, a := range res { - if !yield(a, nil) { - break - } - } - }, nil -} - -func (m *MockMediaFileRepo) CountAll(_ context.Context, opts ...model.QueryOptions) (int64, error) { - if m.Err { - return 0, errors.New("error") - } - if len(opts) > 0 { - m.CountAllOptions = opts[0] - } - if m.CountAllValue != nil { - return *m.CountAllValue, nil - } - return int64(len(m.Data)), nil -} - -func (m *MockMediaFileRepo) DeleteAllMissing(_ context.Context) (int64, error) { - if m.Err { - return 0, errors.New("error") - } - if m.DeleteAllMissingValue != 0 { - return m.DeleteAllMissingValue, nil - } - // Remove all missing files from Data - var count int64 - for id, mf := range m.Data { - if mf.Missing { - delete(m.Data, id) - count++ - } - } - return count, nil -} - -// REST repository methods -func (m *MockMediaFileRepo) Count(ctx context.Context, _ ...rest.QueryOptions) (int64, error) { - return m.CountAll(ctx) -} - -func (m *MockMediaFileRepo) Read(ctx context.Context, id string) (*model.MediaFile, error) { - return m.Get(ctx, id) -} - -func (m *MockMediaFileRepo) ReadAll(ctx context.Context, _ ...rest.QueryOptions) ([]model.MediaFile, error) { - return m.GetAll(ctx) -} - -func (m *MockMediaFileRepo) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.MediaFiles, error) { - if len(options) > 0 { - m.Options = options[0] - } - if m.Err { - return nil, errors.New("unexpected error") - } - // Simple mock implementation - just return all media files for testing - return m.GetAll(ctx) -} - -// Cross-library move detection mock methods -func (m *MockMediaFileRepo) FindRecentFilesByMBZTrackID(_ context.Context, missing model.MediaFile, since time.Time) (model.MediaFiles, error) { - if m.Err { - return nil, errors.New("error") - } - if m.FindRecentFilesByMBZTrackIDFunc != nil { - return m.FindRecentFilesByMBZTrackIDFunc(missing, since) - } - // Default implementation: find files with same MBZ Track ID in other libraries - var result model.MediaFiles - for _, mf := range m.Data { - if mf.LibraryID != missing.LibraryID && - mf.MbzReleaseTrackID == missing.MbzReleaseTrackID && - mf.MbzReleaseTrackID != "" && - mf.Suffix == missing.Suffix && - mf.CreatedAt.After(since) && - !mf.Missing { - result = append(result, *mf) - } - } - return result, nil -} - -func (m *MockMediaFileRepo) FindRecentFilesByProperties(_ context.Context, missing model.MediaFile, since time.Time) (model.MediaFiles, error) { - if m.Err { - return nil, errors.New("error") - } - if m.FindRecentFilesByPropertiesFunc != nil { - return m.FindRecentFilesByPropertiesFunc(missing, since) - } - // Default implementation: find files with same properties in other libraries - var result model.MediaFiles - for _, mf := range m.Data { - if mf.LibraryID != missing.LibraryID && - mf.Title == missing.Title && - mf.Size == missing.Size && - mf.Suffix == missing.Suffix && - mf.DiscNumber == missing.DiscNumber && - mf.TrackNumber == missing.TrackNumber && - mf.Album == missing.Album && - mf.MbzReleaseTrackID == "" && // Exclude files with MBZ Track ID - mf.CreatedAt.After(since) && - !mf.Missing { - result = append(result, *mf) - } - } - return result, nil -} - -func (m *MockMediaFileRepo) MatchesCriteria(context.Context, string, criteria.Criteria) (bool, error) { - if m.MatchesCriteriaErr != nil { - return false, m.MatchesCriteriaErr - } - return m.MatchesCriteriaValue, nil -} - var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil) -var _ rest.Repository[model.MediaFile] = (*MockMediaFileRepo)(nil) diff --git a/tests/mock_persistence.go b/tests/mock_persistence.go new file mode 100644 index 000000000..08c961d11 --- /dev/null +++ b/tests/mock_persistence.go @@ -0,0 +1,134 @@ +package tests + +import ( + "context" + + "github.com/navidrome/navidrome/model" +) + +type MockDataStore struct { + MockedGenre model.GenreRepository + MockedAlbum model.AlbumRepository + MockedArtist model.ArtistRepository + MockedMediaFile model.MediaFileRepository + MockedUser model.UserRepository + MockedProperty model.PropertyRepository + MockedPlayer model.PlayerRepository + MockedPlaylist model.PlaylistRepository + MockedShare model.ShareRepository + MockedTranscoding model.TranscodingRepository + MockedUserProps model.UserPropsRepository + MockedScrobbleBuffer model.ScrobbleBufferRepository + MockedRadioBuffer model.RadioRepository +} + +func (db *MockDataStore) Album(context.Context) model.AlbumRepository { + if db.MockedAlbum == nil { + db.MockedAlbum = CreateMockAlbumRepo() + } + return db.MockedAlbum +} + +func (db *MockDataStore) Artist(context.Context) model.ArtistRepository { + if db.MockedArtist == nil { + db.MockedArtist = CreateMockArtistRepo() + } + return db.MockedArtist +} + +func (db *MockDataStore) MediaFile(context.Context) model.MediaFileRepository { + if db.MockedMediaFile == nil { + db.MockedMediaFile = CreateMockMediaFileRepo() + } + return db.MockedMediaFile +} + +func (db *MockDataStore) MediaFolder(context.Context) model.MediaFolderRepository { + return struct{ model.MediaFolderRepository }{} +} + +func (db *MockDataStore) Genre(context.Context) model.GenreRepository { + if db.MockedGenre == nil { + db.MockedGenre = &MockedGenreRepo{} + } + return db.MockedGenre +} + +func (db *MockDataStore) Playlist(context.Context) model.PlaylistRepository { + if db.MockedPlaylist == nil { + db.MockedPlaylist = &MockPlaylistRepo{} + } + return db.MockedPlaylist +} + +func (db *MockDataStore) PlayQueue(context.Context) model.PlayQueueRepository { + return struct{ model.PlayQueueRepository }{} +} + +func (db *MockDataStore) UserProps(context.Context) model.UserPropsRepository { + if db.MockedUserProps == nil { + db.MockedUserProps = &MockedUserPropsRepo{} + } + return db.MockedUserProps +} + +func (db *MockDataStore) Property(context.Context) model.PropertyRepository { + if db.MockedProperty == nil { + db.MockedProperty = &MockedPropertyRepo{} + } + return db.MockedProperty +} + +func (db *MockDataStore) Share(context.Context) model.ShareRepository { + if db.MockedShare == nil { + db.MockedShare = &MockShareRepo{} + } + return db.MockedShare +} + +func (db *MockDataStore) User(context.Context) model.UserRepository { + if db.MockedUser == nil { + db.MockedUser = CreateMockUserRepo() + } + return db.MockedUser +} + +func (db *MockDataStore) Transcoding(context.Context) model.TranscodingRepository { + if db.MockedTranscoding != nil { + return db.MockedTranscoding + } + return struct{ model.TranscodingRepository }{} +} + +func (db *MockDataStore) Player(context.Context) model.PlayerRepository { + if db.MockedPlayer != nil { + return db.MockedPlayer + } + return struct{ model.PlayerRepository }{} +} + +func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepository { + if db.MockedScrobbleBuffer == nil { + db.MockedScrobbleBuffer = CreateMockedScrobbleBufferRepo() + } + return db.MockedScrobbleBuffer +} + +func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository { + if db.MockedRadioBuffer == nil { + db.MockedRadioBuffer = CreateMockedRadioRepo() + } + return db.MockedRadioBuffer +} + +func (db *MockDataStore) WithTx(block func(db model.DataStore) error) error { + return block(db) +} + +func (db *MockDataStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository { + return struct{ model.ResourceRepository }{} +} + +func (db *MockDataStore) GC(ctx context.Context, rootFolder string) error { + return nil +} diff --git a/tests/mock_player_repo.go b/tests/mock_player_repo.go deleted file mode 100644 index 56835f0cc..000000000 --- a/tests/mock_player_repo.go +++ /dev/null @@ -1,75 +0,0 @@ -package tests - -import ( - "context" - "maps" - "slices" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" -) - -func CreateMockPlayerRepo() *MockPlayerRepo { - return &MockPlayerRepo{Data: map[string]*model.Player{}, APIKeys: map[string]string{}} -} - -// MockPlayerRepo keeps API keys in plaintext (key -> player ID); hashing belongs to the real repository. -type MockPlayerRepo struct { - model.PlayerRepository - Error error - Data map[string]*model.Player - APIKeys map[string]string -} - -func (m *MockPlayerRepo) Get(_ context.Context, id string) (*model.Player, error) { - if m.Error != nil { - return nil, m.Error - } - p, ok := m.Data[id] - if !ok { - return nil, model.ErrNotFound - } - cp := *p - cp.HasAPIKey = slices.Contains(slices.Collect(maps.Values(m.APIKeys)), id) - return &cp, nil -} - -func (m *MockPlayerRepo) Put(_ context.Context, p *model.Player) error { - if m.Error != nil { - return m.Error - } - if p.ID == "" { - p.ID = id.NewRandom() - } - cp := *p - m.Data[p.ID] = &cp - return nil -} - -func (m *MockPlayerRepo) FindByAPIKey(ctx context.Context, key string) (*model.Player, error) { - if m.Error != nil { - return nil, m.Error - } - if playerID, ok := m.APIKeys[key]; ok { - return m.Get(ctx, playerID) - } - return nil, model.ErrNotFound -} - -func (m *MockPlayerRepo) SetAPIKey(_ context.Context, playerID, key string) error { - if m.Error != nil { - return m.Error - } - if _, ok := m.Data[playerID]; !ok { - return model.ErrNotFound - } - m.removeKeys(playerID) - if key != "" { - m.APIKeys[key] = playerID - } - return nil -} - -func (m *MockPlayerRepo) removeKeys(playerID string) { - maps.DeleteFunc(m.APIKeys, func(_ string, v string) bool { return v == playerID }) -} diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 824e701f6..60dc98be9 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -1,189 +1,33 @@ package tests import ( - "context" - "errors" - "time" - "github.com/deluan/rest" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" ) -func CreateMockPlaylistRepo() *MockPlaylistRepo { - return &MockPlaylistRepo{ - Data: make(map[string]*model.Playlist), - PathMap: make(map[string]*model.Playlist), - } -} - type MockPlaylistRepo struct { model.PlaylistRepository - Data map[string]*model.Playlist // keyed by ID - PathMap map[string]*model.Playlist // keyed by path - All model.Playlists - Options model.QueryOptions - Last *model.Playlist - Deleted []string - Starred map[string]bool // itemID -> starred - Ratings map[string]int // itemID -> rating - Err bool - TracksRepo model.PlaylistTrackRepository - TracksRefreshed bool + + Entity *model.Playlist + Error error } -func (m *MockPlaylistRepo) SetError(err bool) { - m.Err = err +func (m *MockPlaylistRepo) Get(_ string) (*model.Playlist, error) { + if m.Error != nil { + return nil, m.Error + } + if m.Entity == nil { + return nil, model.ErrNotFound + } + return m.Entity, nil } -func (m *MockPlaylistRepo) SetData(playlists model.Playlists) { - m.Data = make(map[string]*model.Playlist, len(playlists)) - m.All = playlists - for i, p := range m.All { - m.Data[p.ID] = &m.All[i] +func (m *MockPlaylistRepo) Count(_ ...rest.QueryOptions) (int64, error) { + if m.Error != nil { + return 0, m.Error } + if m.Entity == nil { + return 0, nil + } + return 1, nil } - -func (m *MockPlaylistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Playlists, error) { - if len(options) > 0 { - m.Options = options[0] - } - if m.Err { - return nil, errors.New("error") - } - return m.All, nil -} - -func (m *MockPlaylistRepo) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.PlaylistCursor, error) { - res, err := m.GetAll(ctx, options...) - if err != nil { - return nil, err - } - return func(yield func(model.Playlist, error) bool) { - for _, p := range res { - if !yield(p, nil) { - return - } - } - }, nil -} - -func (m *MockPlaylistRepo) Get(_ context.Context, id string) (*model.Playlist, error) { - if m.Err { - return nil, errors.New("error") - } - if m.Data != nil { - if pls, ok := m.Data[id]; ok { - return pls, nil - } - } - return nil, model.ErrNotFound -} - -func (m *MockPlaylistRepo) GetWithTracks(ctx context.Context, id string, _, _ bool) (*model.Playlist, error) { - return m.Get(ctx, id) -} - -func (m *MockPlaylistRepo) Put(_ context.Context, pls *model.Playlist, _ ...string) error { - if m.Err { - return errors.New("error") - } - if pls.ID == "" { - pls.ID = id.NewRandom() - } - m.Last = pls - if m.Data != nil { - m.Data[pls.ID] = pls - } - return nil -} - -func (m *MockPlaylistRepo) FindByPath(_ context.Context, path string) (*model.Playlist, error) { - if m.Err { - return nil, errors.New("error") - } - if m.PathMap != nil { - if pls, ok := m.PathMap[path]; ok { - return pls, nil - } - } - return nil, model.ErrNotFound -} - -func (m *MockPlaylistRepo) Delete(_ context.Context, ids ...string) error { - if m.Err { - return errors.New("error") - } - m.Deleted = append(m.Deleted, ids...) - return nil -} - -func (m *MockPlaylistRepo) SetStar(_ context.Context, starred bool, ids ...string) error { - if m.Err { - return errors.New("error") - } - if m.Starred == nil { - m.Starred = map[string]bool{} - } - for _, id := range ids { - m.Starred[id] = starred - } - return nil -} - -func (m *MockPlaylistRepo) SetRating(_ context.Context, rating int, id string) error { - if m.Err { - return errors.New("error") - } - if m.Ratings == nil { - m.Ratings = map[string]int{} - } - m.Ratings[id] = rating - return nil -} - -func (m *MockPlaylistRepo) IncPlayCount(context.Context, string, time.Time) error { - if m.Err { - return errors.New("error") - } - return nil -} - -func (m *MockPlaylistRepo) ReassignAnnotation(context.Context, string, string) error { - if m.Err { - return errors.New("error") - } - return nil -} - -func (m *MockPlaylistRepo) Tracks(_ context.Context, _ string, refreshSmartPlaylist bool) model.PlaylistTrackRepository { - m.TracksRefreshed = refreshSmartPlaylist - return m.TracksRepo -} - -func (m *MockPlaylistRepo) Exists(_ context.Context, id string) (bool, error) { - if m.Err { - return false, errors.New("error") - } - if m.Data != nil { - _, found := m.Data[id] - return found, nil - } - return false, nil -} - -func (m *MockPlaylistRepo) Count(_ context.Context, _ ...rest.QueryOptions) (int64, error) { - if m.Err { - return 0, errors.New("error") - } - return int64(len(m.Data)), nil -} - -func (m *MockPlaylistRepo) CountAll(_ context.Context, _ ...model.QueryOptions) (int64, error) { - if m.Err { - return 0, errors.New("error") - } - return int64(len(m.Data)), nil -} - -var _ model.PlaylistRepository = (*MockPlaylistRepo)(nil) diff --git a/tests/mock_playlist_track_repo.go b/tests/mock_playlist_track_repo.go deleted file mode 100644 index 794393371..000000000 --- a/tests/mock_playlist_track_repo.go +++ /dev/null @@ -1,131 +0,0 @@ -package tests - -import ( - "context" - - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/slice" -) - -type MockPlaylistTrackRepo struct { - model.PlaylistTrackRepository - Data model.PlaylistTracks - Options model.QueryOptions - AddedIds []string - DeletedIds []string - Reordered bool - AddCount int - InsertPos int - Err error - AlbumIDs []string // stubbed result for GetAlbumIDs, ignoring options -} - -func (m *MockPlaylistTrackRepo) SetData(tracks model.PlaylistTracks) { - m.Data = tracks -} - -// page applies Max/Offset as the real repository's SQL would. -func (m *MockPlaylistTrackRepo) page(options ...model.QueryOptions) model.PlaylistTracks { - var opts model.QueryOptions - if len(options) > 0 { - opts = options[0] - m.Options = opts - } - tracks := m.Data - if opts.Offset >= len(tracks) { - return nil - } - tracks = tracks[opts.Offset:] - if opts.Max > 0 && opts.Max < len(tracks) { - tracks = tracks[:opts.Max] - } - return tracks -} - -func (m *MockPlaylistTrackRepo) CountAll(_ context.Context, _ ...model.QueryOptions) (int64, error) { - if m.Err != nil { - return 0, m.Err - } - return int64(len(m.Data)), nil -} - -func (m *MockPlaylistTrackRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.PlaylistTracks, error) { - if m.Err != nil { - return nil, m.Err - } - return m.page(options...), nil -} - -func (m *MockPlaylistTrackRepo) GetCursor(_ context.Context, options ...model.QueryOptions) (model.PlaylistTrackCursor, error) { - if m.Err != nil { - return nil, m.Err - } - tracks := m.page(options...) - return func(yield func(model.PlaylistTrack, error) bool) { - for _, t := range tracks { - if !yield(t, nil) { - return - } - } - }, nil -} - -func (m *MockPlaylistTrackRepo) GetAlbumIDs(context.Context, ...model.QueryOptions) ([]string, error) { - if m.Err != nil { - return nil, m.Err - } - return m.AlbumIDs, nil -} - -func (m *MockPlaylistTrackRepo) GetMediaFileIDs(_ context.Context, options ...model.QueryOptions) ([]string, error) { - if m.Err != nil { - return nil, m.Err - } - return slice.Map(m.page(options...), func(t model.PlaylistTrack) string { return t.MediaFileID }), nil -} - -func (m *MockPlaylistTrackRepo) Add(_ context.Context, ids []string) (int, error) { - m.AddedIds = append(m.AddedIds, ids...) - if m.Err != nil { - return 0, m.Err - } - return m.AddCount, nil -} - -func (m *MockPlaylistTrackRepo) Insert(ctx context.Context, ids []string, pos int) (int, error) { - m.InsertPos = pos - return m.Add(ctx, ids) -} - -func (m *MockPlaylistTrackRepo) AddAlbums(_ context.Context, _ []string) (int, error) { - if m.Err != nil { - return 0, m.Err - } - return m.AddCount, nil -} - -func (m *MockPlaylistTrackRepo) AddArtists(_ context.Context, _ []string) (int, error) { - if m.Err != nil { - return 0, m.Err - } - return m.AddCount, nil -} - -func (m *MockPlaylistTrackRepo) AddDiscs(_ context.Context, _ []model.DiscID) (int, error) { - if m.Err != nil { - return 0, m.Err - } - return m.AddCount, nil -} - -func (m *MockPlaylistTrackRepo) Delete(_ context.Context, ids ...string) error { - m.DeletedIds = append(m.DeletedIds, ids...) - return m.Err -} - -func (m *MockPlaylistTrackRepo) Reorder(_ context.Context, _, _ int) error { - m.Reordered = true - return m.Err -} - -var _ model.PlaylistTrackRepository = (*MockPlaylistTrackRepo)(nil) diff --git a/tests/mock_playqueue_repo.go b/tests/mock_playqueue_repo.go deleted file mode 100644 index b445a5085..000000000 --- a/tests/mock_playqueue_repo.go +++ /dev/null @@ -1,66 +0,0 @@ -package tests - -import ( - "context" - "errors" - - "github.com/navidrome/navidrome/model" -) - -type MockPlayQueueRepo struct { - model.PlayQueueRepository - Queue *model.PlayQueue - Err bool - LastCols []string -} - -func (m *MockPlayQueueRepo) Store(_ context.Context, q *model.PlayQueue, cols ...string) error { - if m.Err { - return errors.New("error") - } - copyItems := make(model.MediaFiles, len(q.Items)) - copy(copyItems, q.Items) - qCopy := *q - qCopy.Items = copyItems - m.Queue = &qCopy - m.LastCols = cols - return nil -} - -func (m *MockPlayQueueRepo) RetrieveWithMediaFiles(_ context.Context, userId string) (*model.PlayQueue, error) { - if m.Err { - return nil, errors.New("error") - } - if m.Queue == nil || m.Queue.UserID != userId { - return nil, model.ErrNotFound - } - copyItems := make(model.MediaFiles, len(m.Queue.Items)) - copy(copyItems, m.Queue.Items) - qCopy := *m.Queue - qCopy.Items = copyItems - return &qCopy, nil -} - -func (m *MockPlayQueueRepo) Retrieve(_ context.Context, userId string) (*model.PlayQueue, error) { - if m.Err { - return nil, errors.New("error") - } - if m.Queue == nil || m.Queue.UserID != userId { - return nil, model.ErrNotFound - } - copyItems := make(model.MediaFiles, len(m.Queue.Items)) - for i, t := range m.Queue.Items { - copyItems[i] = model.MediaFile{ID: t.ID} - } - qCopy := *m.Queue - qCopy.Items = copyItems - return &qCopy, nil -} - -func (m *MockPlayQueueRepo) Clear(_ context.Context, userId string) error { - if m.Err { - return errors.New("error") - } - m.Queue = nil - return nil -} diff --git a/tests/mock_plugin_manager.go b/tests/mock_plugin_manager.go deleted file mode 100644 index 05375f31c..000000000 --- a/tests/mock_plugin_manager.go +++ /dev/null @@ -1,132 +0,0 @@ -package tests - -import ( - "context" -) - -// MockPluginManager is a mock implementation of plugins.PluginManager for testing. -// It implements EnablePlugin, DisablePlugin, UpdatePluginConfig, ValidatePluginConfig, UpdatePluginUsers, UpdatePluginLibraries and RescanPlugins methods. -type MockPluginManager struct { - // EnablePluginFn is called when EnablePlugin is invoked. If nil, returns EnableError. - EnablePluginFn func(ctx context.Context, id string) error - // DisablePluginFn is called when DisablePlugin is invoked. If nil, returns DisableError. - DisablePluginFn func(ctx context.Context, id string) error - // UpdatePluginConfigFn is called when UpdatePluginConfig is invoked. If nil, returns ConfigError. - UpdatePluginConfigFn func(ctx context.Context, id, configJSON string) error - // ValidatePluginConfigFn is called when ValidatePluginConfig is invoked. If nil, returns ValidateError. - ValidatePluginConfigFn func(ctx context.Context, id, configJSON string) error - // UpdatePluginUsersFn is called when UpdatePluginUsers is invoked. If nil, returns UsersError. - UpdatePluginUsersFn func(ctx context.Context, id, usersJSON string, allUsers bool) error - // UpdatePluginLibrariesFn is called when UpdatePluginLibraries is invoked. If nil, returns LibrariesError. - UpdatePluginLibrariesFn func(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error - // RescanPluginsFn is called when RescanPlugins is invoked. If nil, returns RescanError. - RescanPluginsFn func(ctx context.Context) error - - // Default errors to return when Fn callbacks are not set - EnableError error - DisableError error - ConfigError error - ValidateError error - UsersError error - LibrariesError error - RescanError error - - // Track calls for assertions - EnablePluginCalls []string - DisablePluginCalls []string - UpdatePluginConfigCalls []struct { - ID string - ConfigJSON string - } - ValidatePluginConfigCalls []struct { - ID string - ConfigJSON string - } - UpdatePluginUsersCalls []struct { - ID string - UsersJSON string - AllUsers bool - } - UpdatePluginLibrariesCalls []struct { - ID string - LibrariesJSON string - AllLibraries bool - AllowWriteAccess bool - } - RescanPluginsCalls int -} - -func (m *MockPluginManager) EnablePlugin(ctx context.Context, id string) error { - m.EnablePluginCalls = append(m.EnablePluginCalls, id) - if m.EnablePluginFn != nil { - return m.EnablePluginFn(ctx, id) - } - return m.EnableError -} - -func (m *MockPluginManager) DisablePlugin(ctx context.Context, id string) error { - m.DisablePluginCalls = append(m.DisablePluginCalls, id) - if m.DisablePluginFn != nil { - return m.DisablePluginFn(ctx, id) - } - return m.DisableError -} - -func (m *MockPluginManager) UpdatePluginConfig(ctx context.Context, id, configJSON string) error { - m.UpdatePluginConfigCalls = append(m.UpdatePluginConfigCalls, struct { - ID string - ConfigJSON string - }{ID: id, ConfigJSON: configJSON}) - if m.UpdatePluginConfigFn != nil { - return m.UpdatePluginConfigFn(ctx, id, configJSON) - } - return m.ConfigError -} - -func (m *MockPluginManager) ValidatePluginConfig(ctx context.Context, id, configJSON string) error { - m.ValidatePluginConfigCalls = append(m.ValidatePluginConfigCalls, struct { - ID string - ConfigJSON string - }{ID: id, ConfigJSON: configJSON}) - if m.ValidatePluginConfigFn != nil { - return m.ValidatePluginConfigFn(ctx, id, configJSON) - } - return m.ValidateError -} - -func (m *MockPluginManager) UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error { - m.UpdatePluginUsersCalls = append(m.UpdatePluginUsersCalls, struct { - ID string - UsersJSON string - AllUsers bool - }{ID: id, UsersJSON: usersJSON, AllUsers: allUsers}) - if m.UpdatePluginUsersFn != nil { - return m.UpdatePluginUsersFn(ctx, id, usersJSON, allUsers) - } - return m.UsersError -} - -func (m *MockPluginManager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error { - m.UpdatePluginLibrariesCalls = append(m.UpdatePluginLibrariesCalls, struct { - ID string - LibrariesJSON string - AllLibraries bool - AllowWriteAccess bool - }{ID: id, LibrariesJSON: librariesJSON, AllLibraries: allLibraries, AllowWriteAccess: allowWriteAccess}) - if m.UpdatePluginLibrariesFn != nil { - return m.UpdatePluginLibrariesFn(ctx, id, librariesJSON, allLibraries, allowWriteAccess) - } - return m.LibrariesError -} - -func (m *MockPluginManager) RescanPlugins(ctx context.Context) error { - m.RescanPluginsCalls++ - if m.RescanPluginsFn != nil { - return m.RescanPluginsFn(ctx) - } - return m.RescanError -} - -func (m *MockPluginManager) UnloadDisabledPlugins(ctx context.Context) { - // No-op for mock - plugins are not actually loaded in tests -} diff --git a/tests/mock_plugin_repo.go b/tests/mock_plugin_repo.go deleted file mode 100644 index feaf9b212..000000000 --- a/tests/mock_plugin_repo.go +++ /dev/null @@ -1,165 +0,0 @@ -package tests - -import ( - "context" - "errors" - "time" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/model" -) - -func CreateMockPluginRepo() *MockPluginRepo { - return &MockPluginRepo{ - Data: make(map[string]*model.Plugin), - IsAdmin: true, // Default to admin access - Permitted: true, - } -} - -type MockPluginRepo struct { - Data map[string]*model.Plugin - All model.Plugins - Err bool - Options model.QueryOptions - IsAdmin bool - Permitted bool -} - -func (m *MockPluginRepo) SetError(err bool) { - m.Err = err -} - -func (m *MockPluginRepo) ClearErrors(context.Context) error { - if m.Err { - return errors.New("unexpected error") - } - for i := range m.All { - m.All[i].LastError = "" - } - for k, p := range m.Data { - p.LastError = "" - m.Data[k] = p - } - return nil -} - -func (m *MockPluginRepo) SetData(plugins model.Plugins) { - m.Data = make(map[string]*model.Plugin, len(plugins)) - m.All = plugins - for i, p := range m.All { - m.Data[p.ID] = &m.All[i] - } -} - -func (m *MockPluginRepo) SetPermitted(permitted bool) { - m.Permitted = permitted -} - -func (m *MockPluginRepo) Get(_ context.Context, id string) (*model.Plugin, error) { - if !m.Permitted { - return nil, rest.ErrPermissionDenied - } - if m.Err { - return nil, errors.New("unexpected error") - } - if d, ok := m.Data[id]; ok { - return d, nil - } - return nil, model.ErrNotFound -} - -func (m *MockPluginRepo) Read(ctx context.Context, id string) (*model.Plugin, error) { - return m.Get(ctx, id) -} - -func (m *MockPluginRepo) Put(_ context.Context, p *model.Plugin) error { - if !m.Permitted { - return rest.ErrPermissionDenied - } - if m.Err { - return errors.New("unexpected error") - } - if p.ID == "" { - return errors.New("plugin ID cannot be empty") - } - now := time.Now() - if existing, ok := m.Data[p.ID]; ok { - p.CreatedAt = existing.CreatedAt - } else { - p.CreatedAt = now - } - p.UpdatedAt = now - m.Data[p.ID] = p - // Update All slice - found := false - for i, existing := range m.All { - if existing.ID == p.ID { - m.All[i] = *p - found = true - break - } - } - if !found { - m.All = append(m.All, *p) - } - return nil -} - -func (m *MockPluginRepo) Delete(_ context.Context, id string) error { - if !m.Permitted { - return rest.ErrPermissionDenied - } - if m.Err { - return errors.New("unexpected error") - } - delete(m.Data, id) - // Update All slice - for i, p := range m.All { - if p.ID == id { - m.All = append(m.All[:i], m.All[i+1:]...) - break - } - } - return nil -} - -func (m *MockPluginRepo) GetAll(_ context.Context, qo ...model.QueryOptions) (model.Plugins, error) { - if len(qo) > 0 { - m.Options = qo[0] - } - if !m.Permitted { - return nil, rest.ErrPermissionDenied - } - if m.Err { - return nil, errors.New("unexpected error") - } - return m.All, nil -} - -func (m *MockPluginRepo) CountAll(_ context.Context, qo ...model.QueryOptions) (int64, error) { - if len(qo) > 0 { - m.Options = qo[0] - } - if !m.Permitted { - return 0, rest.ErrPermissionDenied - } - if m.Err { - return 0, errors.New("unexpected error") - } - return int64(len(m.All)), nil -} - -// REST repository methods -func (m *MockPluginRepo) Count(_ context.Context, _ ...rest.QueryOptions) (int64, error) { - if !m.Permitted { - return 0, rest.ErrPermissionDenied - } - return int64(len(m.All)), nil -} - -func (m *MockPluginRepo) ReadAll(ctx context.Context, _ ...rest.QueryOptions) ([]model.Plugin, error) { - return m.GetAll(ctx) -} - -var _ model.PluginRepository = (*MockPluginRepo)(nil) diff --git a/tests/mock_property_repo.go b/tests/mock_property_repo.go index 949f894c1..39dec17b3 100644 --- a/tests/mock_property_repo.go +++ b/tests/mock_property_repo.go @@ -1,61 +1,57 @@ package tests -import ( - "context" - - "github.com/navidrome/navidrome/model" -) +import "github.com/navidrome/navidrome/model" type MockedPropertyRepo struct { model.PropertyRepository Error error - Data map[string]string + data map[string]string } func (p *MockedPropertyRepo) init() { - if p.Data == nil { - p.Data = make(map[string]string) + if p.data == nil { + p.data = make(map[string]string) } } -func (p *MockedPropertyRepo) Put(_ context.Context, id string, value string) error { +func (p *MockedPropertyRepo) Put(id string, value string) error { if p.Error != nil { return p.Error } p.init() - p.Data[id] = value + p.data[id] = value return nil } -func (p *MockedPropertyRepo) Get(_ context.Context, id string) (string, error) { +func (p *MockedPropertyRepo) Get(id string) (string, error) { if p.Error != nil { return "", p.Error } p.init() - if v, ok := p.Data[id]; ok { + if v, ok := p.data[id]; ok { return v, nil } return "", model.ErrNotFound } -func (p *MockedPropertyRepo) Delete(_ context.Context, id string) error { +func (p *MockedPropertyRepo) Delete(id string) error { if p.Error != nil { return p.Error } p.init() - if _, ok := p.Data[id]; ok { - delete(p.Data, id) + if _, ok := p.data[id]; ok { + delete(p.data, id) return nil } return model.ErrNotFound } -func (p *MockedPropertyRepo) DefaultGet(ctx context.Context, id string, defaultValue string) (string, error) { +func (p *MockedPropertyRepo) DefaultGet(id string, defaultValue string) (string, error) { if p.Error != nil { return "", p.Error } p.init() - v, err := p.Get(ctx, id) + v, err := p.Get(id) if err != nil { return defaultValue, nil //nolint:nilerr } diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go index 21898ea53..ec5af68fc 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -1,84 +1,85 @@ package tests import ( - "context" "errors" + "github.com/google/uuid" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" ) type MockedRadioRepo struct { model.RadioRepository - Data map[string]*model.Radio - All model.Radios - Err bool + data map[string]*model.Radio + all model.Radios + err bool Options model.QueryOptions } func CreateMockedRadioRepo() *MockedRadioRepo { - return &MockedRadioRepo{Data: map[string]*model.Radio{}} + return &MockedRadioRepo{} } func (m *MockedRadioRepo) SetError(err bool) { - m.Err = err + m.err = err } -func (m *MockedRadioRepo) CountAll(_ context.Context, options ...model.QueryOptions) (int64, error) { - if m.Err { +func (m *MockedRadioRepo) CountAll(options ...model.QueryOptions) (int64, error) { + if m.err { return 0, errors.New("error") } - return int64(len(m.Data)), nil + return int64(len(m.data)), nil } -func (m *MockedRadioRepo) Delete(_ context.Context, ids ...string) error { - if m.Err { +func (m *MockedRadioRepo) Delete(id string) error { + if m.err { return errors.New("Error!") } - for _, id := range ids { - if _, found := m.Data[id]; !found { - return errors.New("not found") - } - delete(m.Data, id) + + _, found := m.data[id] + + if !found { + return errors.New("not found") } + + delete(m.data, id) return nil } -func (m *MockedRadioRepo) Exists(_ context.Context, id string) (bool, error) { - if m.Err { +func (m *MockedRadioRepo) Exists(id string) (bool, error) { + if m.err { return false, errors.New("Error!") } - _, found := m.Data[id] + _, found := m.data[id] return found, nil } -func (m *MockedRadioRepo) Get(_ context.Context, id string) (*model.Radio, error) { - if m.Err { +func (m *MockedRadioRepo) Get(id string) (*model.Radio, error) { + if m.err { return nil, errors.New("Error!") } - if d, ok := m.Data[id]; ok { + if d, ok := m.data[id]; ok { return d, nil } return nil, model.ErrNotFound } -func (m *MockedRadioRepo) GetAll(_ context.Context, qo ...model.QueryOptions) (model.Radios, error) { +func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) { if len(qo) > 0 { m.Options = qo[0] } - if m.Err { + if m.err { return nil, errors.New("Error!") } - return m.All, nil + return m.all, nil } -func (m *MockedRadioRepo) Put(_ context.Context, radio *model.Radio, _ ...string) error { - if m.Err { +func (m *MockedRadioRepo) Put(radio *model.Radio) error { + if m.err { return errors.New("error") } if radio.ID == "" { - radio.ID = id.NewRandom() + radio.ID = uuid.NewString() } - m.Data[radio.ID] = radio + m.data[radio.ID] = radio return nil } diff --git a/tests/mock_scanner.go b/tests/mock_scanner.go deleted file mode 100644 index 495e8fe53..000000000 --- a/tests/mock_scanner.go +++ /dev/null @@ -1,143 +0,0 @@ -package tests - -import ( - "context" - "sync" - - "github.com/navidrome/navidrome/model" -) - -// MockScanner implements scanner.Scanner for testing with proper synchronization -type MockScanner struct { - mu sync.Mutex - scanAllCalls []ScanAllCall - scanFoldersCalls []ScanFoldersCall - scanningStatus bool - statusResponse *model.ScannerStatus - scanStatusFunc func(fullScan bool, targets []model.ScanTarget) *model.ScannerStatus -} - -type ScanAllCall struct { - FullScan bool -} - -type ScanFoldersCall struct { - FullScan bool - Targets []model.ScanTarget -} - -func NewMockScanner() *MockScanner { - return &MockScanner{ - scanAllCalls: make([]ScanAllCall, 0), - scanFoldersCalls: make([]ScanFoldersCall, 0), - } -} - -func (m *MockScanner) ScanAll(_ context.Context, fullScan bool) ([]string, error) { - m.mu.Lock() - defer m.mu.Unlock() - - m.scanAllCalls = append(m.scanAllCalls, ScanAllCall{FullScan: fullScan}) - - // Simulate the scanner updating its status when the scan starts - if m.scanStatusFunc != nil { - m.statusResponse = m.scanStatusFunc(fullScan, nil) - } else { - m.scanningStatus = true - } - - return nil, nil -} - -func (m *MockScanner) ScanFolders(_ context.Context, fullScan bool, targets []model.ScanTarget) ([]string, error) { - m.mu.Lock() - defer m.mu.Unlock() - - // Make a copy of targets to avoid race conditions - targetsCopy := make([]model.ScanTarget, len(targets)) - copy(targetsCopy, targets) - - m.scanFoldersCalls = append(m.scanFoldersCalls, ScanFoldersCall{ - FullScan: fullScan, - Targets: targetsCopy, - }) - - // Simulate the scanner updating its status when the scan starts - if m.scanStatusFunc != nil { - m.statusResponse = m.scanStatusFunc(fullScan, targetsCopy) - } else { - m.scanningStatus = true - } - - return nil, nil -} - -func (m *MockScanner) Status(_ context.Context) (*model.ScannerStatus, error) { - m.mu.Lock() - defer m.mu.Unlock() - - if m.statusResponse != nil { - return m.statusResponse, nil - } - - return &model.ScannerStatus{ - Scanning: m.scanningStatus, - }, nil -} - -func (m *MockScanner) GetScanAllCallCount() int { - m.mu.Lock() - defer m.mu.Unlock() - return len(m.scanAllCalls) -} - -func (m *MockScanner) GetScanAllCalls() []ScanAllCall { - m.mu.Lock() - defer m.mu.Unlock() - // Return a copy to avoid race conditions - calls := make([]ScanAllCall, len(m.scanAllCalls)) - copy(calls, m.scanAllCalls) - return calls -} - -func (m *MockScanner) GetScanFoldersCallCount() int { - m.mu.Lock() - defer m.mu.Unlock() - return len(m.scanFoldersCalls) -} - -func (m *MockScanner) GetScanFoldersCalls() []ScanFoldersCall { - m.mu.Lock() - defer m.mu.Unlock() - // Return a copy to avoid race conditions - calls := make([]ScanFoldersCall, len(m.scanFoldersCalls)) - copy(calls, m.scanFoldersCalls) - return calls -} - -func (m *MockScanner) Reset() { - m.mu.Lock() - defer m.mu.Unlock() - m.scanAllCalls = make([]ScanAllCall, 0) - m.scanFoldersCalls = make([]ScanFoldersCall, 0) -} - -func (m *MockScanner) SetScanning(scanning bool) { - m.mu.Lock() - defer m.mu.Unlock() - m.scanningStatus = scanning -} - -func (m *MockScanner) SetStatusResponse(status *model.ScannerStatus) { - m.mu.Lock() - defer m.mu.Unlock() - m.statusResponse = status -} - -// SetScanStatusFunc sets a function that will be called when ScanAll/ScanFolders is invoked, -// simulating the scanner updating its status when the scan starts. -func (m *MockScanner) SetScanStatusFunc(fn func(fullScan bool, targets []model.ScanTarget) *model.ScannerStatus) { - m.mu.Lock() - defer m.mu.Unlock() - m.scanStatusFunc = fn -} diff --git a/tests/mock_scrobble_buffer_repo.go b/tests/mock_scrobble_buffer_repo.go index 91177eeef..06b28af75 100644 --- a/tests/mock_scrobble_buffer_repo.go +++ b/tests/mock_scrobble_buffer_repo.go @@ -1,8 +1,6 @@ package tests import ( - "context" - "sync" "time" "github.com/navidrome/navidrome/model" @@ -10,22 +8,19 @@ import ( type MockedScrobbleBufferRepo struct { Error error - Data model.ScrobbleEntries - mu sync.RWMutex + data model.ScrobbleEntries } func CreateMockedScrobbleBufferRepo() *MockedScrobbleBufferRepo { return &MockedScrobbleBufferRepo{} } -func (m *MockedScrobbleBufferRepo) UserIDs(_ context.Context, service string) ([]string, error) { +func (m *MockedScrobbleBufferRepo) UserIDs(service string) ([]string, error) { if m.Error != nil { return nil, m.Error } - m.mu.RLock() - defer m.mu.RUnlock() userIds := make(map[string]struct{}) - for _, e := range m.Data { + for _, e := range m.data { if e.Service == service { userIds[e.UserID] = struct{}{} } @@ -37,13 +32,11 @@ func (m *MockedScrobbleBufferRepo) UserIDs(_ context.Context, service string) ([ return result, nil } -func (m *MockedScrobbleBufferRepo) Enqueue(_ context.Context, service, userId, mediaFileId string, playTime time.Time) error { +func (m *MockedScrobbleBufferRepo) Enqueue(service, userId, mediaFileId string, playTime time.Time) error { if m.Error != nil { return m.Error } - m.mu.Lock() - defer m.mu.Unlock() - m.Data = append(m.Data, model.ScrobbleEntry{ + m.data = append(m.data, model.ScrobbleEntry{ MediaFile: model.MediaFile{ID: mediaFileId}, Service: service, UserID: userId, @@ -53,13 +46,11 @@ func (m *MockedScrobbleBufferRepo) Enqueue(_ context.Context, service, userId, m return nil } -func (m *MockedScrobbleBufferRepo) Next(_ context.Context, service, userId string) (*model.ScrobbleEntry, error) { +func (m *MockedScrobbleBufferRepo) Next(service, userId string) (*model.ScrobbleEntry, error) { if m.Error != nil { return nil, m.Error } - m.mu.RLock() - defer m.mu.RUnlock() - for _, e := range m.Data { + for _, e := range m.data { if e.Service == service && e.UserID == userId { return &e, nil } @@ -67,46 +58,24 @@ func (m *MockedScrobbleBufferRepo) Next(_ context.Context, service, userId strin return nil, nil } -func (m *MockedScrobbleBufferRepo) Dequeue(_ context.Context, entry *model.ScrobbleEntry) error { +func (m *MockedScrobbleBufferRepo) Dequeue(entry *model.ScrobbleEntry) error { if m.Error != nil { return m.Error } - m.mu.Lock() - defer m.mu.Unlock() newData := model.ScrobbleEntries{} - for _, e := range m.Data { + for _, e := range m.data { if e.Service == entry.Service && e.UserID == entry.UserID && e.PlayTime == entry.PlayTime && e.MediaFile.ID == entry.MediaFile.ID { continue } newData = append(newData, e) } - m.Data = newData + m.data = newData return nil } -func (m *MockedScrobbleBufferRepo) Discard(_ context.Context, service string) error { - if m.Error != nil { - return m.Error - } - m.mu.Lock() - defer m.mu.Unlock() - newData := model.ScrobbleEntries{} - for _, e := range m.Data { - if e.Service != service { - newData = append(newData, e) - } - } - m.Data = newData - return nil -} - -func (m *MockedScrobbleBufferRepo) Length(context.Context) (int64, error) { +func (m *MockedScrobbleBufferRepo) Length() (int64, error) { if m.Error != nil { return 0, m.Error } - m.mu.RLock() - defer m.mu.RUnlock() - return int64(len(m.Data)), nil + return int64(len(m.data)), nil } - -var _ model.ScrobbleBufferRepository = (*MockedScrobbleBufferRepo)(nil) diff --git a/tests/mock_scrobble_repo.go b/tests/mock_scrobble_repo.go deleted file mode 100644 index a76bc5d59..000000000 --- a/tests/mock_scrobble_repo.go +++ /dev/null @@ -1,57 +0,0 @@ -package tests - -import ( - "context" - "strconv" - "time" - - "github.com/deluan/rest" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" -) - -type MockScrobbleRepo struct { - RecordedScrobbles []model.Scrobble -} - -func (m *MockScrobbleRepo) Get(_ context.Context, id string) (*model.Scrobble, error) { - for idx := range m.RecordedScrobbles { - if strconv.FormatInt(m.RecordedScrobbles[idx].ID, 10) == id { - return &m.RecordedScrobbles[idx], nil - } - } - - return nil, model.ErrNotFound -} - -func (m *MockScrobbleRepo) GetAll(_ context.Context, _ ...model.QueryOptions) (model.Scrobbles, error) { - return m.RecordedScrobbles, nil -} - -func (m *MockScrobbleRepo) CountAll(_ context.Context, _ ...model.QueryOptions) (int64, error) { - return int64(len(m.RecordedScrobbles)), nil -} - -func (m *MockScrobbleRepo) RecordScrobble(ctx context.Context, fileID string, submissionTime time.Time) error { - user, _ := request.UserFrom(ctx) - m.RecordedScrobbles = append(m.RecordedScrobbles, model.Scrobble{ - MediaFileID: fileID, - UserID: user.ID, - SubmissionTime: submissionTime.Unix(), - }) - return nil -} - -func (m *MockScrobbleRepo) Count(ctx context.Context, _ ...rest.QueryOptions) (int64, error) { - return m.CountAll(ctx) -} - -func (m *MockScrobbleRepo) Read(ctx context.Context, id string) (*model.Scrobble, error) { - return m.Get(ctx, id) -} - -func (m *MockScrobbleRepo) ReadAll(ctx context.Context, _ ...rest.QueryOptions) ([]model.Scrobble, error) { - return m.GetAll(ctx) -} - -var _ model.ScrobbleRepository = (*MockScrobbleRepo)(nil) diff --git a/tests/mock_share_repo.go b/tests/mock_share_repo.go index 0c872dc0d..ef026ca34 100644 --- a/tests/mock_share_repo.go +++ b/tests/mock_share_repo.go @@ -1,24 +1,26 @@ package tests import ( - "context" - + "github.com/deluan/rest" "github.com/navidrome/navidrome/model" ) type MockShareRepo struct { model.ShareRepository + rest.Repository + rest.Persistable - Entity any + Entity interface{} ID string Cols []string Error error } -func (m *MockShareRepo) Save(_ context.Context, s *model.Share) (string, error) { +func (m *MockShareRepo) Save(entity interface{}) (string, error) { if m.Error != nil { return "", m.Error } + s := entity.(*model.Share) if s.ID == "" { s.ID = "id" } @@ -26,32 +28,19 @@ func (m *MockShareRepo) Save(_ context.Context, s *model.Share) (string, error) return s.ID, nil } -func (m *MockShareRepo) Update(_ context.Context, id string, entity model.Share, cols ...string) error { +func (m *MockShareRepo) Update(id string, entity interface{}, cols ...string) error { if m.Error != nil { return m.Error } m.ID = id - m.Entity = &entity + m.Entity = entity m.Cols = cols return nil } -func (m *MockShareRepo) Exists(_ context.Context, id string) (bool, error) { +func (m *MockShareRepo) Exists(id string) (bool, error) { if m.Error != nil { return false, m.Error } return id == m.ID, nil } - -func (m *MockShareRepo) Get(_ context.Context, 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/mock_tag_repo.go b/tests/mock_tag_repo.go deleted file mode 100644 index f1f252efe..000000000 --- a/tests/mock_tag_repo.go +++ /dev/null @@ -1,26 +0,0 @@ -package tests - -import ( - "context" - - "github.com/navidrome/navidrome/model" -) - -// MockTagRepo records the QueryOptions passed to GetAll, mirroring MockArtistRepo, so tests can -// assert on which filters a caller attached (e.g. a library scope). -type MockTagRepo struct { - model.TagRepository - Data model.TagList - Options model.QueryOptions - Err error -} - -func (r *MockTagRepo) GetAll(_ context.Context, _ model.TagName, options ...model.QueryOptions) (model.TagList, error) { - if len(options) > 0 { - r.Options = options[0] - } - if r.Err != nil { - return nil, r.Err - } - return r.Data, nil -} diff --git a/tests/mock_transcoding_repo.go b/tests/mock_transcoding_repo.go index 52eb16eed..12db0d7be 100644 --- a/tests/mock_transcoding_repo.go +++ b/tests/mock_transcoding_repo.go @@ -1,20 +1,16 @@ package tests -import ( - "context" - - "github.com/navidrome/navidrome/model" -) +import "github.com/navidrome/navidrome/model" type MockTranscodingRepo struct { model.TranscodingRepository } -func (m *MockTranscodingRepo) Get(_ context.Context, id string) (*model.Transcoding, error) { +func (m *MockTranscodingRepo) Get(id string) (*model.Transcoding, error) { return &model.Transcoding{ID: id, TargetFormat: "mp3", DefaultBitRate: 160}, nil } -func (m *MockTranscodingRepo) FindByFormat(_ context.Context, format string) (*model.Transcoding, error) { +func (m *MockTranscodingRepo) FindByFormat(format string) (*model.Transcoding, error) { switch format { case "mp3": return &model.Transcoding{ID: "mp31", TargetFormat: "mp3", DefaultBitRate: 160}, nil @@ -22,10 +18,6 @@ func (m *MockTranscodingRepo) FindByFormat(_ context.Context, format string) (*m return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil case "opus": return &model.Transcoding{ID: "opus1", TargetFormat: "opus", DefaultBitRate: 96}, nil - case "flac": - return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil - case "aac": - return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"}, nil default: return nil, model.ErrNotFound } diff --git a/tests/mock_user_props_repo.go b/tests/mock_user_props_repo.go index 278a68431..b1880c999 100644 --- a/tests/mock_user_props_repo.go +++ b/tests/mock_user_props_repo.go @@ -1,61 +1,57 @@ package tests -import ( - "context" - - "github.com/navidrome/navidrome/model" -) +import "github.com/navidrome/navidrome/model" type MockedUserPropsRepo struct { model.UserPropsRepository Error error - Data map[string]string + data map[string]string } func (p *MockedUserPropsRepo) init() { - if p.Data == nil { - p.Data = make(map[string]string) + if p.data == nil { + p.data = make(map[string]string) } } -func (p *MockedUserPropsRepo) Put(_ context.Context, userId, key string, value string) error { +func (p *MockedUserPropsRepo) Put(userId, key string, value string) error { if p.Error != nil { return p.Error } p.init() - p.Data[userId+key] = value + p.data[userId+key] = value return nil } -func (p *MockedUserPropsRepo) Get(_ context.Context, userId, key string) (string, error) { +func (p *MockedUserPropsRepo) Get(userId, key string) (string, error) { if p.Error != nil { return "", p.Error } p.init() - if v, ok := p.Data[userId+key]; ok { + if v, ok := p.data[userId+key]; ok { return v, nil } return "", model.ErrNotFound } -func (p *MockedUserPropsRepo) Delete(_ context.Context, userId, key string) error { +func (p *MockedUserPropsRepo) Delete(userId, key string) error { if p.Error != nil { return p.Error } p.init() - if _, ok := p.Data[userId+key]; ok { - delete(p.Data, userId+key) + if _, ok := p.data[userId+key]; ok { + delete(p.data, userId+key) return nil } return model.ErrNotFound } -func (p *MockedUserPropsRepo) DefaultGet(ctx context.Context, userId, key string, defaultValue string) (string, error) { +func (p *MockedUserPropsRepo) DefaultGet(userId, key string, defaultValue string) (string, error) { if p.Error != nil { return "", p.Error } p.init() - v, err := p.Get(ctx, userId, key) + v, err := p.Get(userId, key) if err != nil { return defaultValue, nil //nolint:nilerr } diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index 58c985157..31be2c8b1 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -1,37 +1,32 @@ package tests import ( - "context" "encoding/base64" - "fmt" "strings" - "time" "github.com/navidrome/navidrome/model" ) func CreateMockUserRepo() *MockedUserRepo { return &MockedUserRepo{ - Data: map[string]*model.User{}, - UserLibraries: map[string][]int{}, + Data: map[string]*model.User{}, } } type MockedUserRepo struct { model.UserRepository - Error error - Data map[string]*model.User - UserLibraries map[string][]int // userID -> libraryIDs + Error error + Data map[string]*model.User } -func (u *MockedUserRepo) CountAll(_ context.Context, qo ...model.QueryOptions) (int64, error) { +func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) { if u.Error != nil { return 0, u.Error } return int64(len(u.Data)), nil } -func (u *MockedUserRepo) Put(_ context.Context, usr *model.User) error { +func (u *MockedUserRepo) Put(usr *model.User) error { if u.Error != nil { return u.Error } @@ -43,7 +38,7 @@ func (u *MockedUserRepo) Put(_ context.Context, usr *model.User) error { return nil } -func (u *MockedUserRepo) FindByUsername(_ context.Context, username string) (*model.User, error) { +func (u *MockedUserRepo) FindByUsername(username string) (*model.User, error) { if u.Error != nil { return nil, u.Error } @@ -54,133 +49,10 @@ func (u *MockedUserRepo) FindByUsername(_ context.Context, username string) (*mo return usr, nil } -func (u *MockedUserRepo) FindByUsernameWithPassword(ctx context.Context, username string) (*model.User, error) { - return u.FindByUsername(ctx, username) +func (u *MockedUserRepo) FindByUsernameWithPassword(username string) (*model.User, error) { + return u.FindByUsername(username) } -func (u *MockedUserRepo) FindFirstAdmin(_ context.Context) (*model.User, error) { - if u.Error != nil { - return nil, u.Error - } - for _, usr := range u.Data { - if usr.IsAdmin { - return usr, nil - } - } - return nil, model.ErrNotFound -} - -func (u *MockedUserRepo) Get(_ context.Context, id string) (*model.User, error) { - if u.Error != nil { - return nil, u.Error - } - for _, usr := range u.Data { - if usr.ID == id { - return usr, nil - } - } - return nil, model.ErrNotFound -} - -func (u *MockedUserRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Users, error) { - if u.Error != nil { - return nil, u.Error - } - var users model.Users - for _, usr := range u.Data { - users = append(users, *usr) - } - return users, nil -} - -func (u *MockedUserRepo) UpdateLastLoginAt(_ context.Context, id string) error { - for _, usr := range u.Data { - if usr.ID == id { - usr.LastLoginAt = new(time.Now()) - return nil - } - } +func (u *MockedUserRepo) UpdateLastLoginAt(id string) error { return u.Error } - -func (u *MockedUserRepo) UpdateLastAccessAt(_ context.Context, id string) error { - for _, usr := range u.Data { - if usr.ID == id { - usr.LastAccessAt = new(time.Now()) - return nil - } - } - return u.Error -} - -// Library association methods - mock implementations - -func (u *MockedUserRepo) GetUserLibraries(_ context.Context, userID string) (model.Libraries, error) { - if u.Error != nil { - return nil, u.Error - } - libraryIDs, exists := u.UserLibraries[userID] - if !exists { - return model.Libraries{}, nil - } - - // Mock: Create libraries based on IDs - var libraries model.Libraries - for _, id := range libraryIDs { - libraries = append(libraries, model.Library{ - ID: id, - Name: fmt.Sprintf("Test Library %d", id), - Path: fmt.Sprintf("/music/library%d", id), - }) - } - return libraries, nil -} - -func (u *MockedUserRepo) SetUserLibraries(_ context.Context, userID string, libraryIDs []int) error { - if u.Error != nil { - return u.Error - } - if u.UserLibraries == nil { - u.UserLibraries = make(map[string][]int) - } - u.UserLibraries[userID] = libraryIDs - return nil -} - -func (u *MockedUserRepo) Delete(_ context.Context, ids ...string) error { - if u.Error != nil { - return u.Error - } - for _, id := range ids { - if err := u.deleteOne(id); err != nil { - return err - } - } - return nil -} - -func (u *MockedUserRepo) deleteOne(id string) error { - for key, usr := range u.Data { - if usr.ID == id { - delete(u.Data, key) - delete(u.UserLibraries, id) - return nil - } - } - return model.ErrNotFound -} - -func (u *MockedUserRepo) Save(ctx context.Context, usr *model.User) (string, error) { - if err := u.Put(ctx, usr); err != nil { - return "", err - } - return usr.ID, nil -} - -func (u *MockedUserRepo) Update(ctx context.Context, id string, entity model.User, _ ...string) error { - if u.Error != nil { - return u.Error - } - entity.ID = id - return u.Put(ctx, &entity) -} diff --git a/tests/mock_user_service.go b/tests/mock_user_service.go deleted file mode 100644 index bde843d1a..000000000 --- a/tests/mock_user_service.go +++ /dev/null @@ -1,29 +0,0 @@ -package tests - -import ( - "github.com/deluan/rest" - "github.com/navidrome/navidrome/model" -) - -// MockUserService provides a simple wrapper around MockedUserRepo -// that implements the core.User interface for testing. -// Returns concrete type to avoid import cycles - callers assign to core.User. -type MockUserService struct { - *MockedUserRepo -} - -// MockUserRestAdapter adapts MockedUserRepo to the REST repository interface -type MockUserRestAdapter struct { - *MockedUserRepo -} - -// NewMockUserService creates a new mock user service for testing. -// Returns concrete type - assign to core.User at call site. -func NewMockUserService() *MockUserService { - repo := CreateMockUserRepo() - return &MockUserService{MockedUserRepo: repo} -} - -func (m *MockUserService) Repository() rest.Repository[model.User] { - return &MockUserRestAdapter{MockedUserRepo: m.MockedUserRepo} -} diff --git a/tests/navidrome-test.toml b/tests/navidrome-test.toml index 117178a76..35b340f49 100644 --- a/tests/navidrome-test.toml +++ b/tests/navidrome-test.toml @@ -1,7 +1,6 @@ User = "deluan" Password = "wordpass" DbPath = "file::memory:?cache=shared" -DataFolder = "tmp/tests" +MusicFolder = "./tests/fixtures" +DataFolder = "data/tests" ScanSchedule="0" -Plugins.Enabled = true -Plugins.Folder = "plugins/testdata" diff --git a/tests/test_helpers.go b/tests/test_helpers.go deleted file mode 100644 index bdcd40d00..000000000 --- a/tests/test_helpers.go +++ /dev/null @@ -1,81 +0,0 @@ -package tests - -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 -} - -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) - 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 -// https://stackoverflow.com/questions/525512/drop-all-tables-command -func ClearDB() error { - _, err := db.Db().ExecContext(context.Background(), ` - PRAGMA writable_schema = 1; - DELETE FROM sqlite_master; - PRAGMA writable_schema = 0; - VACUUM; - PRAGMA integrity_check; - `) - return err -} - -// LogHook sets up a logrus test hook and configures the default logger to use it. -// It returns the hook and a cleanup function to restore the default logger. -// Example usage: -// -// hook, cleanup := LogHook() -// defer cleanup() -// // ... perform logging operations ... -// Expect(hook.LastEntry()).ToNot(BeNil()) -// Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel)) -// Expect(hook.LastEntry().Message).To(Equal("log message")) -func LogHook() (*test.Hook, func()) { - l, hook := test.NewNullLogger() - log.SetLevel(log.LevelWarn) - log.SetDefaultLogger(l) - return hook, func() { - // Restore default logger after test - log.SetDefaultLogger(logrus.New()) - } -} diff --git a/tools.go b/tools.go new file mode 100644 index 000000000..135af85a9 --- /dev/null +++ b/tools.go @@ -0,0 +1,7 @@ +//go:build tools + +package main + +import ( + _ "golang.org/x/tools/cmd/goimports" +) diff --git a/ui/.eslintignore b/ui/.eslintignore deleted file mode 100644 index 86c39adb2..000000000 --- a/ui/.eslintignore +++ /dev/null @@ -1,7 +0,0 @@ -node_modules/ -build/ -prettier.config.js -.eslintrc -vite.config.js -public/3rdparty/workbox -coverage/ \ No newline at end of file diff --git a/ui/.eslintrc b/ui/.eslintrc deleted file mode 100644 index bb17b134f..000000000 --- a/ui/.eslintrc +++ /dev/null @@ -1,61 +0,0 @@ -{ - "env": { - "browser": true, - "node": true, - "es6": true - }, - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:react-hooks/recommended", -// "plugin:jsx-a11y/recommended", - "eslint-config-prettier", - "plugin:@typescript-eslint/recommended", - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "warnOnUnsupportedTypeScriptVersion": false - }, - "settings": { - "react": { - "version": "detect" - }, - "import/resolver": { - "node": { - "paths": [ - "src" - ], - "extensions": [ - ".js", - ".jsx", - ".ts", - ".tsx" - ] - } - } - }, - "plugins": ["react-refresh"], - "rules": { - "no-console": "error", -// "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": "off", - "react-refresh/only-export-components": [ - "warn", - { "allowConstantExport": true } - ], - "react/jsx-uses-react": "off", - "react/react-in-jsx-scope": "off", - "react/prop-types": "off", - }, - // Fix Vitest - "globals": { - "describe": "readonly", - "it": "readonly", - "expect": "readonly", - "vi": "readonly", - "beforeAll": "readonly", - "afterAll": "readonly", - "beforeEach": "readonly", - "afterEach": "readonly", - } -} \ No newline at end of file diff --git a/ui/.gitignore b/ui/.gitignore index 3459a0e4d..bd1906e59 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -1,7 +1,23 @@ -node_modules -.eslintcache +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* build/* !build/.gitkeep -/coverage/ -public/3rdparty/workbox \ No newline at end of file diff --git a/ui/.prettierrc.js b/ui/.prettierrc.js new file mode 100644 index 000000000..70cd434a4 --- /dev/null +++ b/ui/.prettierrc.js @@ -0,0 +1,5 @@ +module.exports = { + singleQuote: true, + semi: false, + arrowParens: 'always' +} diff --git a/ui/bin/update-workbox.sh b/ui/bin/update-workbox.sh index f2282ca32..ced314f71 100755 --- a/ui/bin/update-workbox.sh +++ b/ui/bin/update-workbox.sh @@ -7,11 +7,10 @@ export WORKBOX_DIR=public/3rdparty/workbox rm -rf ${WORKBOX_DIR} workbox copyLibraries build/3rdparty/ -mkdir -p ${WORKBOX_DIR} +mkdir -p public/3rdparty/workbox mv build/3rdparty/workbox-*/workbox-sw.js ${WORKBOX_DIR} mv build/3rdparty/workbox-*/workbox-core.prod.js ${WORKBOX_DIR} mv build/3rdparty/workbox-*/workbox-strategies.prod.js ${WORKBOX_DIR} mv build/3rdparty/workbox-*/workbox-routing.prod.js ${WORKBOX_DIR} mv build/3rdparty/workbox-*/workbox-navigation-preload.prod.js ${WORKBOX_DIR} -mv build/3rdparty/workbox-*/workbox-precaching.prod.js ${WORKBOX_DIR} rm -rf build/3rdparty/workbox-* diff --git a/ui/embed.go b/ui/embed.go index 2d5fcd979..3e2c413b3 100644 --- a/ui/embed.go +++ b/ui/embed.go @@ -5,7 +5,7 @@ import ( "io/fs" ) -//go:embed all:build +//go:embed build/* var filesystem embed.FS func BuildAssets() fs.FS { diff --git a/ui/package-lock.json b/ui/package-lock.json index 0feab6e7d..511ff8222 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,151 +1,120 @@ { - "name": "ui", - "lockfileVersion": 3, + "name": "navidrome-ui", + "version": "0.1.0", + "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "ui", - "hasInstallScript": true, + "name": "navidrome-ui", + "version": "0.1.0", "dependencies": { - "@jsonforms/core": "^2.5.2", - "@jsonforms/material-renderers": "^2.5.2", - "@jsonforms/react": "^2.5.2", - "@material-ui/core": "^4.12.4", - "@material-ui/icons": "^4.11.3", - "@material-ui/lab": "^4.0.0-alpha.61", + "@material-ui/core": "^4.11.4", + "@material-ui/icons": "^4.11.2", + "@material-ui/lab": "^4.0.0-alpha.58", "@material-ui/styles": "^4.11.5", "blueimp-md5": "^2.19.0", - "clsx": "^2.1.1", - "connected-react-router": "^6.9.3", - "deepmerge": "^4.3.1", - "dompurify": "^3.3.2", + "clsx": "^1.1.1", + "connected-react-router": "^6.9.1", + "deepmerge": "^4.2.2", "history": "^4.10.1", - "inflection": "^3.0.2", - "jwt-decode": "^4.0.0", + "inflection": "^1.13.1", + "jwt-decode": "^3.1.2", + "lodash.pick": "^4.4.0", "lodash.throttle": "^4.1.1", - "navidrome-music-player": "4.25.4", - "prop-types": "^15.8.1", + "navidrome-music-player": "4.25.1", + "prop-types": "^15.7.2", "ra-data-json-server": "^3.19.12", "ra-i18n-polyglot": "^3.19.12", "react": "^17.0.2", "react-admin": "^3.19.12", - "react-dnd": "^14.0.5", - "react-dnd-html5-backend": "^14.1.0", + "react-dnd": "^14.0.4", + "react-dnd-html5-backend": "^14.0.2", "react-dom": "^17.0.2", - "react-drag-listview": "^0.1.9", + "react-drag-listview": "^0.1.8", "react-ga": "^3.3.1", "react-hotkeys": "^2.0.0", - "react-icons": "^5.5.0", + "react-icons": "^4.4.0", "react-image-lightbox": "^5.1.4", "react-measure": "^2.5.2", "react-redux": "^7.2.9", - "react-router-dom": "^5.3.4", - "redux": "^4.2.1", - "redux-saga": "^1.4.2", - "uuid": "^13.0.0", - "workbox-cli": "^7.3.0" + "react-router-dom": "^5.3.0", + "redux": "^4.2.0", + "redux-saga": "^1.1.3", + "uuid": "^8.3.2" }, "devDependencies": { - "@testing-library/jest-dom": "^6.6.3", + "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^7.0.2", - "@testing-library/user-event": "^14.6.1", - "@types/node": "^24.9.1", - "@types/react": "^17.0.89", - "@types/react-dom": "^17.0.26", - "@typescript-eslint/eslint-plugin": "^6.21.0", - "@typescript-eslint/parser": "^6.21.0", - "@vitejs/plugin-react": "^5.1.0", - "@vitest/coverage-v8": "^4.0.3", - "eslint": "^8.57.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.24", - "happy-dom": "^20.0.8", - "jsdom": "^26.1.0", - "prettier": "^3.6.2", + "@testing-library/user-event": "^13.5.0", + "css-mediaquery": "^0.1.2", + "prettier": "2.8.2", "ra-test": "^3.19.12", - "typescript": "^5.8.3", - "vite": "^7.3.2", - "vite-plugin-pwa": "^1.1.0", - "vitest": "^4.0.3" + "react-scripts": "5.0.1", + "workbox-cli": "^6.3.0" } }, "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" + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.0.1.tgz", + "integrity": "sha512-+u76oB43nOHrF4DDWRLWDCtci7f3QJoEBigemIdIeTi1ODqjx6Tad9NCVnPRwewWlKkVab5PlK8DCtPTyX7S8g==", + "dev": true }, - "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==", + "node_modules/@ampproject/remapping": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", + "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==", "dev": true, - "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" + "@jridgewell/gen-mapping": "^0.1.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" } }, - "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.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", + "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", + "dev": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "@babel/highlight": "^7.18.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "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", + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.19.3.tgz", + "integrity": "sha512-prBHMK4JYYK+wDjJF1q99KK4JLL+egWS4nmNqdlMUgCExMZ+iZW0hGhyC3VEbsPjvaN0TBhW//VIFwBrk8sEiw==", + "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "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", - "peer": true, + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.3.tgz", + "integrity": "sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==", + "dev": true, "dependencies": { - "@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.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.18.6", + "@babel/generator": "^7.19.3", + "@babel/helper-compilation-targets": "^7.19.3", + "@babel/helper-module-transforms": "^7.19.0", + "@babel/helpers": "^7.19.0", + "@babel/parser": "^7.19.3", + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.19.3", + "@babel/types": "^7.19.3", + "convert-source-map": "^1.7.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" + "json5": "^2.2.1", + "semver": "^6.3.0" }, "engines": { "node": ">=6.9.0" @@ -156,80 +125,143 @@ } }, "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", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/eslint-parser": { + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.19.1.tgz", + "integrity": "sha512-AqNf2QWt1rtu2/1rLswy6CDP7H9Oh3mMhk177Y67Rg8d7RD9WfOLLv8CGn6tisFvS2htm86yIe1yLF6I1UDaGQ==", + "dev": true, + "dependencies": { + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", + "eslint-visitor-keys": "^2.1.0", + "semver": "^6.3.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || >=14.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.11.0", + "eslint": "^7.5.0 || ^8.0.0" + } + }, + "node_modules/@babel/eslint-parser/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@babel/eslint-parser/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/generator": { - "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", + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.19.3.tgz", + "integrity": "sha512-fqVZnmp1ncvZU757UzDheKZpfPgatqY59XtW2/j/18H7u76akb8xqvjw82f+i2UKd/ksYsSick/BCLQUUtJ/qQ==", + "dev": true, "dependencies": { - "@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" + "@babel/types": "^7.19.3", + "@jridgewell/gen-mapping": "^0.3.2", + "jsesc": "^2.5.1" }, "engines": { "node": ">=6.9.0" } }, - "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", + "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "dev": true, "dependencies": { - "@babel/types": "^7.27.3" + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz", + "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.9.tgz", + "integrity": "sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw==", + "dev": true, + "dependencies": { + "@babel/helper-explode-assignable-expression": "^7.18.6", + "@babel/types": "^7.18.9" }, "engines": { "node": ">=6.9.0" } }, "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", + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.3.tgz", + "integrity": "sha512-65ESqLGyGmLvgR0mst5AdW1FkNlj9rQsCKduzEoEPhBCDFGXvz2jW6bXFG6i0/MrV2s7hhXjjb2yAzcPuQlLwg==", + "dev": true, "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" + "@babel/compat-data": "^7.19.3", + "@babel/helper-validator-option": "^7.18.6", + "browserslist": "^4.21.3", + "semver": "^6.3.0" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, "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", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } }, "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", + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.19.0.tgz", + "integrity": "sha512-NRz8DwF4jT3UfrmUoZjd0Uph9HQnP30t7Ash+weACcyNkiYTywpIjDBgReJMKgr+n86sn2nPVVmJ28Dm053Kqw==", + "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", - "semver": "^6.3.1" + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-function-name": "^7.19.0", + "@babel/helper-member-expression-to-functions": "^7.18.9", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/helper-replace-supers": "^7.18.9", + "@babel/helper-split-export-declaration": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -238,24 +270,14 @@ "@babel/core": "^7.0.0" } }, - "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" - } - }, "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", + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.19.0.tgz", + "integrity": "sha512-htnV+mHX32DF81amCDrwIDr8nrp1PTm+3wfBN9/v8QJOLEioOCOG7qNyq0nHeFiWbT3Eb7gsPwEmV64UCQ1jzw==", + "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "regexpu-core": "^6.3.1", - "semver": "^6.3.1" + "@babel/helper-annotate-as-pure": "^7.18.6", + "regexpu-core": "^5.1.0" }, "engines": { "node": ">=6.9.0" @@ -264,133 +286,152 @@ "@babel/core": "^7.0.0" } }, - "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", + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz", + "integrity": "sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-plugin-utils": "^7.16.7", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2", + "semver": "^6.1.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0-0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } }, - "node_modules/@babel/helper-define-polyfill-provider": { - "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.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "debug": "^4.4.3", - "lodash.debounce": "^4.0.8", - "resolve": "^1.22.11" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + "node_modules/@babel/helper-environment-visitor": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz", + "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==", + "dev": true, + "engines": { + "node": ">=6.9.0" } }, - "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", + "node_modules/@babel/helper-explode-assignable-expression": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz", + "integrity": "sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==", + "dev": true, "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "@babel/types": "^7.18.6" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6.9.0" } }, - "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", + "node_modules/@babel/helper-function-name": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz", + "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==", + "dev": true, + "dependencies": { + "@babel/template": "^7.18.10", + "@babel/types": "^7.19.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", + "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.18.6" + }, "engines": { "node": ">=6.9.0" } }, "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", + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz", + "integrity": "sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg==", + "dev": true, "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" + "@babel/types": "^7.18.9" }, "engines": { "node": ">=6.9.0" } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", + "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", + "dev": true, "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.18.6" }, "engines": { "node": ">=6.9.0" } }, "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", + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz", + "integrity": "sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ==", + "dev": true, "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-simple-access": "^7.18.6", + "@babel/helper-split-export-declaration": "^7.18.6", + "@babel/helper-validator-identifier": "^7.18.6", + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.19.0", + "@babel/types": "^7.19.0" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz", + "integrity": "sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==", + "dev": true, "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.18.6" }, "engines": { "node": ">=6.9.0" } }, "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", + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz", + "integrity": "sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==", + "dev": true, "engines": { "node": ">=6.9.0" } }, "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", + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz", + "integrity": "sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==", + "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-wrap-function": "^7.18.9", + "@babel/types": "^7.18.9" }, "engines": { "node": ">=6.9.0" @@ -400,97 +441,194 @@ } }, "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", + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.19.1.tgz", + "integrity": "sha512-T7ahH7wV0Hfs46SFh5Jz3s0B6+o8g3c+7TMxu7xKfmHikg7EAZ3I2Qk9LFhjxXq8sL7UkP5JflezNwoZa8WvWw==", + "dev": true, "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-member-expression-to-functions": "^7.18.9", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/traverse": "^7.19.1", + "@babel/types": "^7.19.0" }, "engines": { "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz", + "integrity": "sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.18.6" }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "engines": { + "node": ">=6.9.0" } }, "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", + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.18.9.tgz", + "integrity": "sha512-imytd2gHi3cJPsybLRbmFrF7u5BIEuI2cNheyKi3/iOBC63kNn3q8Crn2xVuESli0aM4KYsyEqKyS7lFL8YVtw==", + "dev": true, "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/types": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", + "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.18.6" }, "engines": { "node": ">=6.9.0" } }, "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", + "version": "7.18.10", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz", + "integrity": "sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw==", + "dev": true, "engines": { "node": ">=6.9.0" } }, "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", + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", + "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", + "dev": true, "engines": { "node": ">=6.9.0" } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz", + "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==", + "dev": true, "engines": { "node": ">=6.9.0" } }, "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", + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.19.0.tgz", + "integrity": "sha512-txX8aN8CZyYGTwcLhlk87KRqncAzhh5TpQamZUa0/u3an36NtDpUP6bQgBCBcLeBs09R/OwQu3OjK0k/HwfNDg==", + "dev": true, "dependencies": { - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/helper-function-name": "^7.19.0", + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.19.0", + "@babel/types": "^7.19.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", - "license": "MIT", + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.19.0.tgz", + "integrity": "sha512-DRBCKGwIEdqY3+rPJgG/dKfQy9+08rHIAJx8q2p+HSWP87s2HCrQmaAMMyMll2kIXKCW0cO1RdQskx15Xakftg==", + "dev": true, "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.19.0", + "@babel/types": "^7.19.0" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "license": "MIT", + "node_modules/@babel/highlight": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", + "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", + "dev": true, "dependencies": { - "@babel/types": "^7.29.0" + "@babel/helper-validator-identifier": "^7.18.6", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.19.3.tgz", + "integrity": "sha512-pJ9xOlNWHiy9+FuFP09DEAFbAn4JskgRsVcc169w2xRBC3FRGuQEwjeIMMND9L2zc0iEhO/tGv4Zq+km+hxNpQ==", + "dev": true, "bin": { "parser": "bin/babel-parser.js" }, @@ -498,134 +636,12 @@ "node": ">=6.0.0" } }, - "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", - "@babel/traverse": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "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" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "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" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "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", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" - } - }, - "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", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "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" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "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" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "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" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "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", + "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.18.6.tgz", + "integrity": "sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==", + "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { @@ -635,13 +651,33 @@ "@babel/core": "^7.0.0" } }, - "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", + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.18.9.tgz", + "integrity": "sha512-AHrP9jadvH7qlOj6PINbgSuphjQUAK7AOT7DPjBo9EHoLhQTnnK5u45e1Hd4DbSQEO9nqPWtQ89r+XEOWFScKg==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.18.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9", + "@babel/plugin-proposal-optional-chaining": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-proposal-async-generator-functions": { + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.19.1.tgz", + "integrity": "sha512-0yu8vNATgLy4ivqMNBIwb1HebCelqN7YX8SL3FDXORv/RqT0zEEWUCH4GH44JsSrvCu6GqnAdR5EBFAPeNBB4Q==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/helper-remap-async-to-generator": "^7.18.9", + "@babel/plugin-syntax-async-generators": "^7.8.4" }, "engines": { "node": ">=6.9.0" @@ -650,15 +686,512 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "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", + "node_modules/@babel/plugin-proposal-class-properties": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", + "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.29.0" + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-class-static-block": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.18.6.tgz", + "integrity": "sha512-+I3oIiNxrCpup3Gi8n5IGMwj0gOCAjcJUSQEcotNnCCPMEnixawOQ+KeJPlgfjzx+FKQ1QSyZOWe7wmoJp7vhw==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.19.3.tgz", + "integrity": "sha512-MbgXtNXqo7RTKYIXVchVJGPvaVufQH3pxvQyfbGvNw1DObIhph+PesYXJTcd8J4DdWibvf6Z2eanOyItX8WnJg==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.19.0", + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/helper-replace-supers": "^7.19.1", + "@babel/helper-split-export-declaration": "^7.18.6", + "@babel/plugin-syntax-decorators": "^7.19.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-dynamic-import": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz", + "integrity": "sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-namespace-from": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz", + "integrity": "sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-json-strings": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz", + "integrity": "sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-json-strings": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-logical-assignment-operators": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.18.9.tgz", + "integrity": "sha512-128YbMpjCrP35IOExw2Fq+x55LMP42DzhOhX2aNNIdI9avSWl2PI0yuBWarr3RYpZBSPtabfadkH2yeRiMD61Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz", + "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-numeric-separator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz", + "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-object-rest-spread": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.18.9.tgz", + "integrity": "sha512-kDDHQ5rflIeY5xl69CEqGEZ0KY369ehsCIEbTGb4siHG5BE9sga/T0r0OUwyZNLMmZE79E1kbsqAjwFCW4ds6Q==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.18.8", + "@babel/helper-compilation-targets": "^7.18.9", + "@babel/helper-plugin-utils": "^7.18.9", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.18.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-optional-catch-binding": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz", + "integrity": "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-optional-chaining": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.18.9.tgz", + "integrity": "sha512-v5nwt4IqBXihxGsW2QmCWMDS3B3bzGIk/EQVZz2ei7f3NJl8NzAJVvUmpDW5q1CRNY+Beb/k58UAH1Km1N411w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-methods": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", + "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.18.6.tgz", + "integrity": "sha512-9Rysx7FOctvT5ouj5JODjAFAkgGoudQuLPamZb0v1TGLpapdNaftzifU8NTWQm0IRjqoYypdrSmyWgkocDQ8Dw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-unicode-property-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz", + "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.19.0.tgz", + "integrity": "sha512-xaBZUEDntt4faL1yN8oIFlhfXeQAWJW7CLKYsHTUqriCUbj8xOra8bfxxKGi/UwExPFBuPdH4XfHc9rGQhrVkQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.19.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.18.6.tgz", + "integrity": "sha512-LUbR+KNTBWCUAqRG9ex5Gnzu2IOkt8jRJbHHXFT9q+L9zm7M/QQbEqXyw1n1pohYvOyWC8CjeyjrSaIwiYjK7A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.18.6.tgz", + "integrity": "sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz", + "integrity": "sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.18.6.tgz", + "integrity": "sha512-mAWAuq4rvOepWCBid55JuRNvpTNf2UGVgoz4JV0fXEKolsVZDzsa4NqCef758WZJj/GDu0gVGItjKFiClTAmZA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.18.6.tgz", + "integrity": "sha512-9S9X9RUefzrsHZmKMbDXxweEH+YlE8JJEuat9FdvW9Qh1cw7W64jELCtWNkPBPX5En45uy28KGvA/AySqUh8CQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -668,14 +1201,14 @@ } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.18.6.tgz", + "integrity": "sha512-ARE5wZLKnTgPW7/1ftQmSi1CmkqqHo2DNmtztFhvgtOWSDfq0Cq9/9L+KnZNYSNrydBekhW3rwShduf59RoXag==", + "dev": true, "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1" + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-remap-async-to-generator": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -685,12 +1218,12 @@ } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz", + "integrity": "sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -700,12 +1233,12 @@ } }, "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", + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.18.9.tgz", + "integrity": "sha512-5sDIJRV1KtQVEbt/EIBwGy4T01uYIo4KRB3VUqzkhrAIOGx7AoctL9+Ux88btY0zXdDyPJ9mW+bg+v+XEkGmtw==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.18.9" }, "engines": { "node": ">=6.9.0" @@ -714,50 +1247,21 @@ "@babel/core": "^7.0.0-0" } }, - "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", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "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", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, "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", + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.19.0.tgz", + "integrity": "sha512-YfeEE9kCjqTS9IitkgfJuxjcEtLUHMqa8yUJ6zdz8vR7hKuo6mOy2C05P0F1tdMmDCeuyidKnlrw/iTppHcr2A==", + "dev": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/traverse": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-compilation-targets": "^7.19.0", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-function-name": "^7.19.0", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/helper-replace-supers": "^7.18.9", + "@babel/helper-split-export-declaration": "^7.18.6", + "globals": "^11.1.0" }, "engines": { "node": ">=6.9.0" @@ -767,13 +1271,12 @@ } }, "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", + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.18.9.tgz", + "integrity": "sha512-+i0ZU1bCDymKakLxn5srGHrsAPRELC2WIbzwjLhHW9SIE1cPYkLCL0NlnXMZaM1vhfgA2+M7hySk42VBvrkBRw==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/template": "^7.28.6" + "@babel/helper-plugin-utils": "^7.18.9" }, "engines": { "node": ">=6.9.0" @@ -783,13 +1286,12 @@ } }, "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", + "version": "7.18.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.13.tgz", + "integrity": "sha512-TodpQ29XekIsex2A+YJPj5ax2plkGa8YYY6mFjCohk/IG9IY42Rtuj1FuDeemfg2ipxIFLzPeA83SIBnlhSIow==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" + "@babel/helper-plugin-utils": "^7.18.9" }, "engines": { "node": ">=6.9.0" @@ -799,13 +1301,13 @@ } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz", + "integrity": "sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==", + "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -815,59 +1317,12 @@ } }, "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", + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz", + "integrity": "sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "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", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "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" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "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", - "@babel/plugin-transform-destructuring": "^7.28.5" + "@babel/helper-plugin-utils": "^7.18.9" }, "engines": { "node": ">=6.9.0" @@ -877,12 +1332,13 @@ } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz", + "integrity": "sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -891,13 +1347,14 @@ "@babel/core": "^7.0.0-0" } }, - "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", + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.19.0.tgz", + "integrity": "sha512-sgeMlNaQVbCSpgLSKP4ZZKfsJVnFnNQlUSk6gPYzR/q7tzCgQF2t8RBKAP6cKJeZdveei7Q7Jm527xepI8lNLg==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/plugin-syntax-flow": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -907,13 +1364,12 @@ } }, "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", + "version": "7.18.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz", + "integrity": "sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -923,29 +1379,14 @@ } }, "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", + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz", + "integrity": "sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==", + "dev": true, "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "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" + "@babel/helper-compilation-targets": "^7.18.9", + "@babel/helper-function-name": "^7.18.9", + "@babel/helper-plugin-utils": "^7.18.9" }, "engines": { "node": ">=6.9.0" @@ -955,27 +1396,12 @@ } }, "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", + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz", + "integrity": "sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "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" + "@babel/helper-plugin-utils": "^7.18.9" }, "engines": { "node": ">=6.9.0" @@ -985,12 +1411,12 @@ } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz", + "integrity": "sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -1000,13 +1426,14 @@ } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.18.6.tgz", + "integrity": "sha512-Pra5aXsmTsOnjM3IajS8rTaLCy++nGM4v3YR4esk5PCsyg9z8NA5oQLwxzMUtDBd8F+UmVza3VxoAaWCbzH1rg==", + "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-transforms": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6", + "babel-plugin-dynamic-import-node": "^2.3.3" }, "engines": { "node": ">=6.9.0" @@ -1016,13 +1443,15 @@ } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.18.6.tgz", + "integrity": "sha512-Qfv2ZOWikpvmedXQJDSbxNqy7Xr/j2Y8/KfijM0iJyKkBTmWuvCA1yeH1yDM7NJhBW/2aXxeucLj6i80/LAJ/Q==", + "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-module-transforms": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-simple-access": "^7.18.6", + "babel-plugin-dynamic-import-node": "^2.3.3" }, "engines": { "node": ">=6.9.0" @@ -1032,15 +1461,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "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", + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.19.0.tgz", + "integrity": "sha512-x9aiR0WXAWmOWsqcsnrzGR+ieaTMVyGyffPVA7F8cXAGt/UxefYv6uSHZLkAFChN5M5Iy1+wjE+xJuPt22H39A==", + "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" + "@babel/helper-hoist-variables": "^7.18.6", + "@babel/helper-module-transforms": "^7.19.0", + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/helper-validator-identifier": "^7.18.6", + "babel-plugin-dynamic-import-node": "^2.3.3" }, "engines": { "node": ">=6.9.0" @@ -1050,13 +1480,13 @@ } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz", + "integrity": "sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==", + "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-transforms": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -1066,13 +1496,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "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", + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.19.1.tgz", + "integrity": "sha512-oWk9l9WItWBQYS4FgXD4Uyy5kq898lvkXpXQxoJEY1RnvPk4R/Dvu2ebXU9q8lP+rlMwUQTFf2Ok6d78ODa0kw==", + "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.19.0", + "@babel/helper-plugin-utils": "^7.19.0" }, "engines": { "node": ">=6.9.0" @@ -1082,61 +1512,12 @@ } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz", + "integrity": "sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "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" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "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" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "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", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.6" + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -1146,44 +1527,13 @@ } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz", + "integrity": "sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "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" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "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", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-replace-supers": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -1193,45 +1543,12 @@ } }, "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", + "version": "7.18.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.18.8.tgz", + "integrity": "sha512-ivfbE3X2Ss+Fj8nnXvKJS6sjRG4gzwPMsP+taZC+ZzEGjAYlvENixmt1sZ5Ca6tWls+BlKSGKPJ6OOXvXCbkFg==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "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", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "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", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -1241,12 +1558,12 @@ } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz", + "integrity": "sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -1255,14 +1572,22 @@ "@babel/core": "^7.0.0-0" } }, - "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==", + "node_modules/@babel/plugin-transform-react-constant-elements": { + "version": "7.13.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.13.13.tgz", + "integrity": "sha512-SNJU53VM/SjQL0bZhyU+f4kJQz7bQQajnrZRSaU21hruG/NWY41AEM9AWXeXX90pYr/C2yAmTgI6yW3LlLrAUQ==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.13.0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.18.6.tgz", + "integrity": "sha512-TV4sQ+T013n61uMoygyMRm+xf04Bd5oqFpv2jAEQwSZ8NwQA7zeRPg1LMVg2PWi3zWBz+CLKD+v5bcpZ/BS0aA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -1271,14 +1596,48 @@ "@babel/core": "^7.0.0-0" } }, - "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==", + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.19.0.tgz", + "integrity": "sha512-UVEvX3tXie3Szm3emi1+G63jyw1w5IcMY0FSKM+CRnKRI5Mr1YbCNgsSTwoTwKphQEG9P+QqmuRFneJPZuHNhg==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/plugin-syntax-jsx": "^7.18.6", + "@babel/types": "^7.19.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.18.6.tgz", + "integrity": "sha512-SA6HEjwYFKF7WDjWcMcMGUimmw/nhNRDWxr+KaLSCrkD/LMDBvWRmHAYgE1HDeF8KUuI8OAu+RT6EOtKxSW2qA==", + "dev": true, + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.18.6.tgz", + "integrity": "sha512-I8VfEPg9r2TRDdvnHgPepTKvuRomzA8+u+nhY7qSI1fR2hRNebasZEETLyM5mAUr0Ku56OkXJ0I7NHJnO6cJiQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -1288,12 +1647,13 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz", + "integrity": "sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.18.6", + "regenerator-transform": "^0.15.0" }, "engines": { "node": ">=6.9.0" @@ -1302,29 +1662,13 @@ "@babel/core": "^7.0.0-0" } }, - "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", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz", + "integrity": "sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -1333,13 +1677,42 @@ "@babel/core": "^7.0.0-0" } }, - "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", + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.19.1.tgz", + "integrity": "sha512-2nJjTUFIzBMP/f/miLxEK9vxwW/KUXsdvN4sR//TmuDhe6yU2h57WmIOE12Gng3MDP/xpjUV/ToZRdcf8Yj4fA==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-plugin-utils": "^7.19.0", + "babel-plugin-polyfill-corejs2": "^0.3.3", + "babel-plugin-polyfill-corejs3": "^0.6.0", + "babel-plugin-polyfill-regenerator": "^0.4.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz", + "integrity": "sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -1349,13 +1722,13 @@ } }, "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", + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.19.0.tgz", + "integrity": "sha512-RsuMk7j6n+r752EtzyScnWkQyuJdli6LdO5Klv8Yx0OfPVTcQkIUfS8clx5e9yHXzlnhOZF3CbQ8C2uP5j074w==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9" }, "engines": { "node": ">=6.9.0" @@ -1365,12 +1738,12 @@ } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz", + "integrity": "sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -1380,12 +1753,12 @@ } }, "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", + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz", + "integrity": "sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.18.9" }, "engines": { "node": ">=6.9.0" @@ -1395,12 +1768,29 @@ } }, "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", + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz", + "integrity": "sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.18.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.19.3.tgz", + "integrity": "sha512-z6fnuK9ve9u/0X0rRvI9MY0xg+DOUaABDYOe+/SQTxtlptaBB/V9JIUxJn6xp3lMBeb9qe8xSFmHU35oZDXD+w==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.19.0", + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/plugin-syntax-typescript": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -1410,28 +1800,12 @@ } }, "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", + "version": "7.18.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz", + "integrity": "sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ==", + "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "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", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.18.9" }, "engines": { "node": ">=6.9.0" @@ -1441,13 +1815,13 @@ } }, "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", + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz", + "integrity": "sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==", + "dev": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" }, "engines": { "node": ">=6.9.0" @@ -1456,98 +1830,87 @@ "@babel/core": "^7.0.0-0" } }, - "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", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/@babel/preset-env": { - "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", + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.19.3.tgz", + "integrity": "sha512-ziye1OTc9dGFOAXSWKUqQblYHNlBOaDl8wzqf2iKXJAltYiR3hKHUKmkt+S9PppW7RQpq4fFCrwwpIDj/f5P4w==", + "dev": true, "dependencies": { - "@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", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.28.6", - "@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.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", - "@babel/plugin-transform-class-properties": "^7.28.6", - "@babel/plugin-transform-class-static-block": "^7.28.6", - "@babel/plugin-transform-classes": "^7.28.6", - "@babel/plugin-transform-computed-properties": "^7.28.6", - "@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.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", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.28.6", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", - "@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.29.0", - "@babel/plugin-transform-modules-umd": "^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", - "@babel/plugin-transform-object-rest-spread": "^7.28.6", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.28.6", - "@babel/plugin-transform-optional-chaining": "^7.28.6", - "@babel/plugin-transform-parameters": "^7.27.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.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", - "@babel/plugin-transform-spread": "^7.28.6", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.28.6", - "@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.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" + "@babel/compat-data": "^7.19.3", + "@babel/helper-compilation-targets": "^7.19.3", + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/helper-validator-option": "^7.18.6", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.18.6", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.18.9", + "@babel/plugin-proposal-async-generator-functions": "^7.19.1", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-proposal-class-static-block": "^7.18.6", + "@babel/plugin-proposal-dynamic-import": "^7.18.6", + "@babel/plugin-proposal-export-namespace-from": "^7.18.9", + "@babel/plugin-proposal-json-strings": "^7.18.6", + "@babel/plugin-proposal-logical-assignment-operators": "^7.18.9", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6", + "@babel/plugin-proposal-numeric-separator": "^7.18.6", + "@babel/plugin-proposal-object-rest-spread": "^7.18.9", + "@babel/plugin-proposal-optional-catch-binding": "^7.18.6", + "@babel/plugin-proposal-optional-chaining": "^7.18.9", + "@babel/plugin-proposal-private-methods": "^7.18.6", + "@babel/plugin-proposal-private-property-in-object": "^7.18.6", + "@babel/plugin-proposal-unicode-property-regex": "^7.18.6", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-import-assertions": "^7.18.6", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-transform-arrow-functions": "^7.18.6", + "@babel/plugin-transform-async-to-generator": "^7.18.6", + "@babel/plugin-transform-block-scoped-functions": "^7.18.6", + "@babel/plugin-transform-block-scoping": "^7.18.9", + "@babel/plugin-transform-classes": "^7.19.0", + "@babel/plugin-transform-computed-properties": "^7.18.9", + "@babel/plugin-transform-destructuring": "^7.18.13", + "@babel/plugin-transform-dotall-regex": "^7.18.6", + "@babel/plugin-transform-duplicate-keys": "^7.18.9", + "@babel/plugin-transform-exponentiation-operator": "^7.18.6", + "@babel/plugin-transform-for-of": "^7.18.8", + "@babel/plugin-transform-function-name": "^7.18.9", + "@babel/plugin-transform-literals": "^7.18.9", + "@babel/plugin-transform-member-expression-literals": "^7.18.6", + "@babel/plugin-transform-modules-amd": "^7.18.6", + "@babel/plugin-transform-modules-commonjs": "^7.18.6", + "@babel/plugin-transform-modules-systemjs": "^7.19.0", + "@babel/plugin-transform-modules-umd": "^7.18.6", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.19.1", + "@babel/plugin-transform-new-target": "^7.18.6", + "@babel/plugin-transform-object-super": "^7.18.6", + "@babel/plugin-transform-parameters": "^7.18.8", + "@babel/plugin-transform-property-literals": "^7.18.6", + "@babel/plugin-transform-regenerator": "^7.18.6", + "@babel/plugin-transform-reserved-words": "^7.18.6", + "@babel/plugin-transform-shorthand-properties": "^7.18.6", + "@babel/plugin-transform-spread": "^7.19.0", + "@babel/plugin-transform-sticky-regex": "^7.18.6", + "@babel/plugin-transform-template-literals": "^7.18.9", + "@babel/plugin-transform-typeof-symbol": "^7.18.9", + "@babel/plugin-transform-unicode-escapes": "^7.18.10", + "@babel/plugin-transform-unicode-regex": "^7.18.6", + "@babel/preset-modules": "^0.1.5", + "@babel/types": "^7.19.3", + "babel-plugin-polyfill-corejs2": "^0.3.3", + "babel-plugin-polyfill-corejs3": "^0.6.0", + "babel-plugin-polyfill-regenerator": "^0.4.1", + "core-js-compat": "^3.25.1", + "semver": "^6.3.0" }, "engines": { "node": ">=6.9.0" @@ -1557,728 +1920,445 @@ } }, "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", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } }, "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", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz", + "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-transform-dotall-regex": "^7.4.4", "@babel/types": "^7.4.4", "esutils": "^2.0.2" }, "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.18.6.tgz", + "integrity": "sha512-zXr6atUmyYdiWRVLOZahakYmOBHtWc2WGCkP8PYTgZi0iJXDY2CN180TdrIW4OGOAdLc7TifzDIvtx6izaRIzg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-validator-option": "^7.18.6", + "@babel/plugin-transform-react-display-name": "^7.18.6", + "@babel/plugin-transform-react-jsx": "^7.18.6", + "@babel/plugin-transform-react-jsx-development": "^7.18.6", + "@babel/plugin-transform-react-pure-annotations": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.18.6.tgz", + "integrity": "sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-validator-option": "^7.18.6", + "@babel/plugin-transform-typescript": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.5.tgz", + "integrity": "sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==", + "dependencies": { + "regenerator-runtime": "^0.13.11" + }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/runtime-corejs3": { - "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==", + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.11.2.tgz", + "integrity": "sha512-qh5IR+8VgFz83VBa6OkaET6uN/mJOhHONuy3m1sgF0CV6mXdPSEBdA7e1eUbVvyNtANjMbg22JUv71BaDXLY6A==", "dev": true, - "license": "MIT", "dependencies": { - "core-js-pure": "^3.48.0" - }, - "engines": { - "node": ">=6.9.0" + "core-js-pure": "^3.0.0", + "regenerator-runtime": "^0.13.4" } }, "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", + "version": "7.18.10", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz", + "integrity": "sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==", + "dev": true, "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.18.6", + "@babel/parser": "^7.18.10", + "@babel/types": "^7.18.10" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "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", + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.19.3.tgz", + "integrity": "sha512-qh5yf6149zhq2sgIXmwjnsvmnNQC2iw70UFjp4olxucKrWd/dvlUsBI88VSLUsnMNF7/vnOiA+nk1+yLoCqROQ==", + "dev": true, "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" + "@babel/code-frame": "^7.18.6", + "@babel/generator": "^7.19.3", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-function-name": "^7.19.0", + "@babel/helper-hoist-variables": "^7.18.6", + "@babel/helper-split-export-declaration": "^7.18.6", + "@babel/parser": "^7.19.3", + "@babel/types": "^7.19.3", + "debug": "^4.1.0", + "globals": "^11.1.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "license": "MIT", + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.19.3.tgz", + "integrity": "sha512-hGCaQzIY22DJlDh9CH7NOxgKkFjBk0Cw9xDO1Xmh2151ti7wiGfQ3LauXzL4HP1fmFlTX6XjpRETTpUcv7wQLw==", + "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.18.10", + "@babel/helper-validator-identifier": "^7.19.1", + "to-fast-properties": "^2.0.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "node_modules/@csstools/normalize.css": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.0.0.tgz", + "integrity": "sha512-M0qqxAcwCsIVfpFQSlGN5XjXWu8l5JDZN+fPt1LeW5SZexQTgnaEvgXAY+CeygRw0EeppWHi12JxESWiWrB0Sg==", + "dev": true + }, + "node_modules/@csstools/postcss-cascade-layers": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-1.1.1.tgz", + "integrity": "sha512-+KdYrpKC5TgomQr2DlZF4lDEpHcoxnj5IGddYYfBWJAKfj1JtuHUIqMa+E1pJJ+z3kvDViWMqyqPlG4Ja7amQA==", + "dev": true, + "dependencies": { + "@csstools/selector-specificity": "^2.0.2", + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-color-function": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-1.1.1.tgz", + "integrity": "sha512-Bc0f62WmHdtRDjf5f3e2STwRAl89N2CLb+9iAwzrv4L2hncrbDwnQD9PCq0gtAt7pOI2leIV08HIBUd4jxD8cw==", + "dev": true, + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-font-format-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-1.0.1.tgz", + "integrity": "sha512-ZgrlzuUAjXIOc2JueK0X5sZDjCtgimVp/O5CEqTcs5ShWBa6smhWYbS0x5cVc/+rycTDbjjzoP0KTDnUneZGOg==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-hwb-function": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-1.0.2.tgz", + "integrity": "sha512-YHdEru4o3Rsbjmu6vHy4UKOXZD+Rn2zmkAmLRfPet6+Jz4Ojw8cbWxe1n42VaXQhD3CQUXXTooIy8OkVbUcL+w==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "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": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "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": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "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": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" + "postcss-value-parser": "^4.2.0" }, "engines": { - "node": ">=18" + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "postcss": "^8.2" } }, - "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==", + "node_modules/@csstools/postcss-ic-unit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-1.0.1.tgz", + "integrity": "sha512-Ot1rcwRAaRHNKC9tAqoqNZhjdYBzKk1POgWfhN4uCOE47ebGcLRqXjKkApVDpjifL6u2/55ekkpnFcp+s/OZUw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "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": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - } - }, - "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" + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" }, "peerDependencies": { - "moment": "^2.24.0" + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-2.0.7.tgz", + "integrity": "sha512-7JPeVVZHd+jxYdULl87lvjgvWldYu+Bc62s9vD/ED6/QTGjy0jy0US/f6BG53sVMTBJ1lzKZFpYmofBN9eaRiA==", + "dev": true, + "dependencies": { + "@csstools/selector-specificity": "^2.0.0", + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-nested-calc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-1.0.0.tgz", + "integrity": "sha512-JCsQsw1wjYwv1bJmgjKSoZNvf7R6+wuHDAbi5f/7MbFhl2d/+v+TvBTU4BJH3G1X1H87dHl0mh6TfYogbT/dJQ==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-normalize-display-values": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-1.0.1.tgz", + "integrity": "sha512-jcOanIbv55OFKQ3sYeFD/T0Ti7AMXc9nM1hZWu8m/2722gOTxFg7xYu4RDLJLeZmPUVQlGzo4jhzvTUq3x4ZUw==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-oklab-function": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-1.1.1.tgz", + "integrity": "sha512-nJpJgsdA3dA9y5pgyb/UfEzE7W5Ka7u0CX0/HIMVBNWzWemdcTH3XwANECU6anWv/ao4vVNLTMxhiPNZsTK6iA==", + "dev": true, + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-progressive-custom-properties": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-1.3.0.tgz", + "integrity": "sha512-ASA9W1aIy5ygskZYuWams4BzafD12ULvSypmaLJT2jvQ8G0M3I8PRQhC0h7mG0Z3LI05+agZjqSR9+K9yaQQjA==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.3" + } + }, + "node_modules/@csstools/postcss-stepped-value-functions": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-1.0.1.tgz", + "integrity": "sha512-dz0LNoo3ijpTOQqEJLY8nyaapl6umbmDcgj4AD0lgVQ572b2eqA1iGZYTTWhrcrHztWDDRAX2DGYyw2VBjvCvQ==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-text-decoration-shorthand": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-1.0.0.tgz", + "integrity": "sha512-c1XwKJ2eMIWrzQenN0XbcfzckOLLJiczqy+YvfGmzoVXd7pT9FfObiSEfzs84bpE/VqfpEuAZ9tCRbZkZxxbdw==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-trigonometric-functions": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-1.0.2.tgz", + "integrity": "sha512-woKaLO///4bb+zZC2s80l+7cm07M7268MsyG3M0ActXXEFi6SuhvriQYcb58iiKGbjwwIU7n45iRLEHypB47Og==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/postcss-unset-value": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-1.0.2.tgz", + "integrity": "sha512-c8J4roPBILnelAsdLr4XOAR/GsTm0GJi4XpcfvoWk3U6KiTCqiFYc63KhRMQQX35jYMp4Ao8Ij9+IZRgMfJp1g==", + "dev": true, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.0.2.tgz", + "integrity": "sha512-IkpVW/ehM1hWKln4fCA3NzJU8KwD+kIOvPZA4cqxoJHtE21CCzjyp+Kxbu0i5I4tBNOlXPL9mjwnWlL0VEG4Fg==", + "dev": true, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2", + "postcss-selector-parser": "^6.0.10" } }, "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.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "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" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "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": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "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": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" }, "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.2.tgz", + "integrity": "sha512-AXYd23w1S/bv3fTs3Lz0vjiYemS08jWkI3hYyS9I1ry+0f+Yjs1wm+sU0BS8qDOPrBIkp4qHYC16I8uVtpLajQ==", "dev": true, - "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", + "espree": "^9.4.0", + "globals": "^13.15.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", @@ -2292,78 +2372,73 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "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": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } + "node_modules/@eslint/eslintrc/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 }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.17.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.17.0.tgz", + "integrity": "sha512-1C+6nQRb1GwGMKm2dH/E7enFAMxGTmGI7/dEdhy/DNelv85w9B72t3uc5frtMNXIbzrarJJ/lTCjcaZwbLJmyw==", "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "type-fest": "^0.20.2" }, "engines": { - "node": "*" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/eslintrc/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", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "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", + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.6.tgz", + "integrity": "sha512-U/piU+VwXZsIgwnl+N+nRK12jCpHdc3s0UAc6zc1+HUgiESJxClpvYao/x9JwaN7onNeVb7kTlxlAvuEoaJ3ig==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "@humanwhocodes/object-schema": "^1.2.1", + "debug": "^4.1.1", + "minimatch": "^3.0.4" }, "engines": { "node": ">=10.10.0" } }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "node_modules/@humanwhocodes/gitignore-to-minimatch": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz", + "integrity": "sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA==", "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "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": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, "node_modules/@humanwhocodes/module-importer": { @@ -2371,7 +2446,6 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": ">=12.22" }, @@ -2381,20 +2455,645 @@ } }, "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" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true }, - "node_modules/@isaacs/cliui": { - "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", + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, "engines": { - "node": ">=18" + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", + "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/console/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/console/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/console/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/core": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz", + "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==", + "dev": true, + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/reporters": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^27.5.1", + "jest-config": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-resolve-dependencies": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "jest-watcher": "^27.5.1", + "micromatch": "^4.0.4", + "rimraf": "^3.0.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/core/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/core/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/core/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/environment": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", + "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/environment/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/environment/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/environment/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/fake-timers": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", + "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "@sinonjs/fake-timers": "^8.0.1", + "@types/node": "*", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/fake-timers/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/fake-timers/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/fake-timers/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/globals": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", + "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/types": "^27.5.1", + "expect": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/globals/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/globals/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/globals/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/reporters": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.5.1.tgz", + "integrity": "sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.2", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^5.1.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-haste-map": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "slash": "^3.0.0", + "source-map": "^0.6.0", + "string-length": "^4.0.1", + "terminal-link": "^2.0.0", + "v8-to-istanbul": "^8.1.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/reporters/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/reporters/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/reporters/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/@jest/reporters/node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@jest/schemas": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", + "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.24.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", + "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9", + "source-map": "^0.6.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", + "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", + "dev": true, + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/test-result/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/test-result/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/test-result/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz", + "integrity": "sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==", + "dev": true, + "dependencies": { + "@jest/test-result": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-runtime": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", + "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.1.0", + "@jest/types": "^27.5.1", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-util": "^27.5.1", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@jest/transform/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "node_modules/@jest/types": { @@ -2402,7 +3101,6 @@ "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", "dev": true, - "license": "MIT", "dependencies": { "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", @@ -2414,137 +3112,101 @@ "node": ">= 10.14.2" } }, - "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", + "node_modules/@jest/types/node_modules/chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" } }, - "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", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", + "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==", + "dev": true, "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "@jridgewell/set-array": "^1.0.0", + "@jridgewell/sourcemap-codec": "^1.4.10" + }, + "engines": { + "node": ">=6.0.0" } }, "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", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, "engines": { "node": ">=6.0.0" } }, "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", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", + "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", + "dev": true, "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/source-map/node_modules/@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" } }, "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" + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", + "dev": true }, "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", + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz", + "integrity": "sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==", + "dev": true, "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "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", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.3", - "ajv": "^6.10.2", - "json-schema-ref-parser": "7.1.3", - "lodash": "^4.17.15", - "uri-js": "^4.2.2", - "uuid": "^3.3.3" - } - }, - "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" - } - }, - "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", - "@material-ui/pickers": "^3.2.8", - "@types/uuid": "^3.4.6", - "moment": "^2.24.0", - "uuid": "^3.3.3" - }, - "peerDependencies": { - "@jsonforms/core": "^2.5.2", - "@jsonforms/react": "^2.5.2", - "@material-ui/core": "^4.7.0", - "@material-ui/icons": "^4.5.1" - } - }, - "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" - } - }, - "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", - "peer": true, - "dependencies": { - "lodash": "^4.17.15", - "object-hash": "^2.0.0" - }, - "peerDependencies": { - "@jsonforms/core": "^2.5.2", - "react": "^16.12.0 || ^17.0.0" - } + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", + "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", + "dev": true }, "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", - "peer": true, "dependencies": { "@babel/runtime": "^7.4.4", "@material-ui/styles": "^4.11.5", @@ -2577,105 +3239,30 @@ } } }, - "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" - } - }, "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", - "peer": true, + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.2.tgz", + "integrity": "sha512-fQNsKX2TxBmqIGJCSi3tGTO/gZ+eJgWmMJkgDiOfyNaunNaxcklJQFaFogYcFl0qFuaEz1qaXYXboa/bUXVSOQ==", "dependencies": { "@babel/runtime": "^7.4.4" }, "engines": { "node": ">=8.0.0" - }, - "peerDependencies": { - "@material-ui/core": "^4.0.0", - "@types/react": "^16.8.6 || ^17.0.0", - "react": "^16.8.0 || ^17.0.0", - "react-dom": "^16.8.0 || ^17.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } } }, "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", + "version": "4.0.0-alpha.58", + "resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.58.tgz", + "integrity": "sha512-GKHlJqLxUeHH3L3dGQ48ZavYrqGOTXkFkiEiuYMAnAvXAZP4rhMIqeHOPXSUQan4Bd8QnafDcpovOSLnadDmKw==", "dependencies": { "@babel/runtime": "^7.4.4", - "@material-ui/utils": "^4.11.3", + "@material-ui/utils": "^4.11.2", "clsx": "^1.0.4", "prop-types": "^15.7.2", "react-is": "^16.8.0 || ^17.0.0" }, "engines": { "node": ">=8.0.0" - }, - "peerDependencies": { - "@material-ui/core": "^4.12.1", - "@types/react": "^16.8.6 || ^17.0.0", - "react": "^16.8.0 || ^17.0.0", - "react-dom": "^16.8.0 || ^17.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "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" - } - }, - "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", - "@date-io/core": "1.x", - "@types/styled-jsx": "^2.2.8", - "clsx": "^1.0.2", - "react-transition-group": "^4.0.0", - "rifm": "^0.7.0" - }, - "peerDependencies": { - "@date-io/core": "^1.3.6", - "@material-ui/core": "^4.0.0", - "prop-types": "^15.6.0", - "react": "^16.8.0 || ^17.0.0", - "react-dom": "^16.8.0 || ^17.0.0" - } - }, - "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" } }, "node_modules/@material-ui/styles": { @@ -2683,7 +3270,6 @@ "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", "@emotion/hash": "^0.8.0", @@ -2720,20 +3306,10 @@ } } }, - "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" - } - }, "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", "@material-ui/utils": "^4.11.3", @@ -2761,22 +3337,12 @@ "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": "*" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==" }, "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", "prop-types": "^15.7.2", @@ -2790,12 +3356,42 @@ "react-dom": "^16.8.0 || ^17.0.0" } }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", + "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "dev": true, + "dependencies": { + "eslint-scope": "5.1.1" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, "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": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -2809,7 +3405,6 @@ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, - "license": "MIT", "engines": { "node": ">= 8" } @@ -2819,7 +3414,6 @@ "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": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -2828,325 +3422,494 @@ "node": ">= 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" - } - }, - "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" - }, - "engines": { - "node": ">=12.22.0" - } - }, - "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", - "@pnpm/network.ca-file": "^1.0.1", - "config-chain": "^1.1.11" - }, - "engines": { - "node": ">=12" - } - }, "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" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.0.tgz", + "integrity": "sha512-0XhqJSc6pPoNnf8DhdsPHtUhRzZALVzYMTzRwV4VI6DJNJ/5xxfL9OQUwb8IH5/2x7lSf7nAZrnzUD+16VyOVQ==" }, "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" + "integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==" }, "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" + "integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==" }, "node_modules/@react-icons/all-files": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@react-icons/all-files/-/all-files-4.1.0.tgz", "integrity": "sha512-hxBI2UOuVaI3O/BhQfhtb4kcGn9ft12RWAFVMUeNjqqhLsHvFtzIkFaptBJpFDANTKoDfdVoHTKZDlwKCACbMQ==", - "license": "MIT", "peerDependencies": { "react": "*" } }, "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", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.1.3.tgz", + "integrity": "sha512-8tInBftak8TPzE6X13ABmEtRJGjtK17w7VUs7qV17S8hCO5S3+aUTWZ/DBsBJPdE8Z5jOPwYALyvofgq1Ws+kg==", "dependencies": { - "@babel/runtime": "^7.28.4", - "@redux-saga/deferred": "^1.3.1", - "@redux-saga/delay-p": "^1.3.1", - "@redux-saga/is": "^1.2.1", - "@redux-saga/symbols": "^1.2.1", - "@redux-saga/types": "^1.3.1", + "@babel/runtime": "^7.6.3", + "@redux-saga/deferred": "^1.1.2", + "@redux-saga/delay-p": "^1.1.2", + "@redux-saga/is": "^1.1.2", + "@redux-saga/symbols": "^1.1.2", + "@redux-saga/types": "^1.1.0", + "redux": "^4.0.4", "typescript-tuple": "^2.2.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/redux-saga" } }, "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" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.1.2.tgz", + "integrity": "sha512-908rDLHFN2UUzt2jb4uOzj6afpjgJe3MjICaUNO3bvkV/kN/cNeI9PMr8BsFXB/MR8WTAZQq/PlTq8Kww3TBSQ==" }, "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", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.1.2.tgz", + "integrity": "sha512-ojc+1IoC6OP65Ts5+ZHbEYdrohmIw1j9P7HS9MOJezqMYtCDgpkoqB5enAAZrNtnbSL6gVCWPHaoaTY5KeO0/g==", "dependencies": { - "@redux-saga/symbols": "^1.2.1" + "@redux-saga/symbols": "^1.1.2" } }, "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", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.1.2.tgz", + "integrity": "sha512-OLbunKVsCVNTKEf2cH4TYyNbbPgvmZ52iaxBD4I1fTif4+MTXMa4/Z07L83zW/hTCXwpSZvXogqMqLfex2Tg6w==", "dependencies": { - "@redux-saga/symbols": "^1.2.1", - "@redux-saga/types": "^1.3.1" + "@redux-saga/symbols": "^1.1.2", + "@redux-saga/types": "^1.1.0" } }, "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" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.1.2.tgz", + "integrity": "sha512-EfdGnF423glv3uMwLsGAtE6bg+R9MdqlHEzExnfagXPrIiuxwr3bdiAwz3gi+PsrQ3yBlaBpfGLtDG8rf3LgQQ==" }, "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" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.1.0.tgz", + "integrity": "sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg==" }, - "node_modules/@rolldown/pluginutils": { - "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==", + "node_modules/@rollup/plugin-babel": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz", + "integrity": "sha512-9uIC8HZOnVLrLHxayq/PTzw+uS25E14KPUBh5ktF+18Mjo5yK0ToMMx6epY0uEgkjwJw0aBW4x2horYXh8juWw==", "dev": true, - "license": "MIT" + "dependencies": { + "@babel/helper-module-imports": "^7.10.4", + "@rollup/pluginutils": "^3.1.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + } + } }, "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", + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz", + "integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==", + "dev": true, "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", + "@rollup/pluginutils": "^3.1.0", + "@types/resolve": "1.17.1", + "builtin-modules": "^3.1.0", "deepmerge": "^4.2.2", "is-module": "^1.0.0", - "resolve": "^1.22.1" + "resolve": "^1.19.0" }, "engines": { - "node": ">=14.0.0" + "node": ">= 10.0.0" }, "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "rollup": "^1.20.0||^2.0.0" } }, - "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", + "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==", + "dev": true, "dependencies": { - "is-core-module": "^2.16.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/@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", - "smob": "^1.0.0", - "terser": "^5.17.4" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" } }, "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", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "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": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "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==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", "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", - "json5": "^2.2.0", - "magic-string": "^0.25.0", - "string.prototype.matchall": "^4.0.6" + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" } }, - "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", + "node_modules/@rollup/pluginutils/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==", + "dev": true + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz", + "integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==", + "dev": true + }, + "node_modules/@sinclair/typebox": { + "version": "0.24.44", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.44.tgz", + "integrity": "sha512-ka0W0KN5i6LfrSocduwliMMpqVgohtPFidKdMEOUjoOFCHcOOYkKsPRxfs5f15oPNHTm6ERAm0GV/+/LTKeiWg==", + "dev": true + }, + "node_modules/@sindresorhus/is": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", + "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sinonjs/commons": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", + "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", + "dev": true, "dependencies": { - "sourcemap-codec": "^1.4.8" + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", + "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", + "integrity": "sha512-ZFf2gs/8/6B8PnSofI0inYXr2SDNTDScPXhN7k5EqD4aZ3gi6u+rbmZHVB8IM3wDyx8ntKACZbtXSm7oZGRqVg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-5.4.0.tgz", + "integrity": "sha512-yaS4o2PgUtwLFGTKbsiAy6D0o3ugcUhWK0Z45umJ66EPWunAz9fuFw2gJuje6wqQvQWOTJvIahUwndOXb7QCPg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-5.0.1.tgz", + "integrity": "sha512-LA72+88A11ND/yFIMzyuLRSMJ+tRKeYKeQ+mR3DcAZ5I4h5CPWN9AHyUzJbWSYp/u2u0xhmgOe0+E41+GjEueA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-5.0.1.tgz", + "integrity": "sha512-PoiE6ZD2Eiy5mK+fjHqwGOS+IXX0wq/YDtNyIgOrc6ejFnxN4b13pRpiIPbtPwHEc+NT2KCjteAcq33/F1Y9KQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-5.4.0.tgz", + "integrity": "sha512-zSOZH8PdZOpuG1ZVx/cLVePB2ibo3WPpqo7gFIjLV9a0QsuQAzJiwwqmuEdTaW2pegyBE17Uu15mOgOcgabQZg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-5.4.0.tgz", + "integrity": "sha512-cPzDbDA5oT/sPXDCUYoVXEmm3VIoAWAPT6mSPTJNbQaBNUuEKVKyGH93oDY4e42PYHRW67N5alJx/eEol20abw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-5.4.0.tgz", + "integrity": "sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.5.0.tgz", + "integrity": "sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.5.0.tgz", + "integrity": "sha512-4FiXBjvQ+z2j7yASeGPEi8VD/5rrGQk4Xrq3EdJmoZgz/tpqChpo5hgXDvmEauwtvOc52q8ghhZK4Oy7qph4ig==", + "dev": true, + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "^5.0.1", + "@svgr/babel-plugin-replace-jsx-attribute-value": "^5.0.1", + "@svgr/babel-plugin-svg-dynamic-title": "^5.4.0", + "@svgr/babel-plugin-svg-em-dimensions": "^5.4.0", + "@svgr/babel-plugin-transform-react-native-svg": "^5.4.0", + "@svgr/babel-plugin-transform-svg-component": "^5.5.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@svgr/core": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-5.5.0.tgz", + "integrity": "sha512-q52VOcsJPvV3jO1wkPtzTuKlvX7Y3xIcWRpCMtBF3MrteZJtBfQw/+u0B1BHy5ColpQc1/YVTrPEtSYIMNZlrQ==", + "dev": true, + "dependencies": { + "@svgr/plugin-jsx": "^5.5.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^7.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.5.0.tgz", + "integrity": "sha512-cAaR/CAiZRB8GP32N+1jocovUtvlj0+e65TB50/6Lcime+EA49m/8l+P2ko+XPJ4dw3xaPS3jOL4F2X4KWxoeQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.12.6" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.5.0.tgz", + "integrity": "sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@svgr/babel-preset": "^5.5.0", + "@svgr/hast-util-to-babel-ast": "^5.5.0", + "svg-parser": "^2.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@svgr/plugin-svgo": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-5.5.0.tgz", + "integrity": "sha512-r5swKk46GuQl4RrVejVwpeeJaydoxkdwkM1mBKOgJLBUJPGaLci6ylg/IjhrRsREKDkr4kbMWdgOtbXEh0fyLQ==", + "dev": true, + "dependencies": { + "cosmiconfig": "^7.0.0", + "deepmerge": "^4.2.2", + "svgo": "^1.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@svgr/webpack": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-5.5.0.tgz", + "integrity": "sha512-DOBOK255wfQxguUta2INKkzPj6AIS6iafZYiYmHn6W3pHlycSRRlvWKCfLDG10fXfLWqE3DJHgRUOyJYmARa7g==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/plugin-transform-react-constant-elements": "^7.12.1", + "@babel/preset-env": "^7.12.1", + "@babel/preset-react": "^7.12.5", + "@svgr/core": "^5.5.0", + "@svgr/plugin-jsx": "^5.5.0", + "@svgr/plugin-svgo": "^5.5.0", + "loader-utils": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", + "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", + "dev": true, + "dependencies": { + "defer-to-connect": "^1.0.1" + }, + "engines": { + "node": ">=6" } }, "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==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.1.0.tgz", + "integrity": "sha512-kmW9alndr19qd6DABzQ978zKQ+J65gU2Rzkl8hriIetPnwpesRaK4//jEQyYh8fEALmGhomD/LBQqt+o+DL95Q==", "dev": true, - "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", + "@types/aria-query": "^4.2.0", + "aria-query": "^4.2.2", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.6", + "lz-string": "^1.4.4", "pretty-format": "^27.0.2" }, "engines": { - "node": ">=18" + "node": ">=12" } }, - "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==", + "node_modules/@testing-library/dom/node_modules/@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", "dev": true, - "license": "MIT", "dependencies": { - "@adobe/css-tools": "^4.4.0", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@testing-library/dom/node_modules/chalk": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", + "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.0.6.tgz", + "integrity": "sha512-8tGD7gBIENgzqA+UBzObyWqQ5B778VIFZA/S66cclyd5YkFLYs2Js7gxDKf0MXtTc9zcS7t1xhdfcElJ3YIvkQ==", + "dev": true, + "dependencies": { + "@jest/types": "^27.0.6", + "ansi-regex": "^5.0.0", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/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==", + "dev": true + }, + "node_modules/@testing-library/jest-dom": { + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz", + "integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.0.1", + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", "aria-query": "^5.0.0", + "chalk": "^3.0.0", "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", "redent": "^3.0.0" }, "engines": { - "node": ">=14", + "node": ">=8", "npm": ">=6", "yarn": ">=1" } }, - "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==", + "node_modules/@testing-library/jest-dom/node_modules/aria-query": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.2.tgz", + "integrity": "sha512-eigU3vhqSO+Z8BKDnVLN/ompjhf3pYzecKXz8+whRy+9gZu8n1TCGfwzQUUPnqdHl9ax1Hr9031orZ+UOEYr7Q==", "dev": true, - "license": "MIT" + "engines": { + "node": ">=6.0" + } }, "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": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^8.0.0", @@ -3165,7 +3928,6 @@ "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": { "@babel/runtime": "^7.12.5", "@types/react": ">=16.9.0", @@ -3190,228 +3952,340 @@ } } }, - "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==", + "node_modules/@testing-library/user-event": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", + "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" + "@babel/runtime": "^7.12.5" }, "engines": { - "node": ">=12" - } - }, - "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": { - "deep-equal": "^2.0.5" - } - }, - "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": { - "node": ">=12", + "node": ">=10", "npm": ">=6" }, "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, - "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==", + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", "dev": true, - "license": "MIT" + "engines": { + "node": ">= 6" + } + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "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 }, "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", + "version": "7.1.14", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.14.tgz", + "integrity": "sha512-zGZJzzBUVDo/eV6KgbE0f0ZI7dInEYvo12Rb70uNQDshC3SkRMb67ja0GgRHZgAX3Za6rhaWlvbDO8rrGyAb1g==", + "dev": true, "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "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", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.2.tgz", + "integrity": "sha512-MdSJnBjl+bdwkLskZ3NGFp9YcXGx5ggLpQQPqtgakVhsWK0hTtNYhjpZLlWQTviGTvF8at+Bvli3jV7faPdgeQ==", + "dev": true, "dependencies": { "@babel/types": "^7.0.0" } }, "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", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.0.tgz", + "integrity": "sha512-NTPErx4/FiPCGScH7foPyr+/1Dkzkni+rHiYHHoTjvwou7AQzJkNeD60A9CXRy+ZEN2B1bggmkTMCDb+Mv5k+A==", + "dev": true, "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "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", + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.11.1.tgz", + "integrity": "sha512-Vs0hm0vPahPMYi9tDjtP66llufgO3ST16WXaSTtDGEl9cewAl3AibmxWw6TINOqHPT9z0uABKAYjT9jNSg4npw==", + "dev": true, "dependencies": { - "@babel/types": "^7.28.2" + "@babel/types": "^7.3.0" } }, - "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==", + "node_modules/@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", "dev": true, - "license": "MIT", "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "@types/connect": "*", + "@types/node": "*" } }, - "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==", + "node_modules/@types/bonjour": { + "version": "3.5.10", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.10.tgz", + "integrity": "sha512-p7ienRMiS41Nu2/igbJxxLDWrSZ0WxM8UQgCeO9KhoVF7cOVFkrKsiDr1EsJIla8vV3oEEjGcz11jc5yimhzZw==", "dev": true, - "license": "MIT" + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/color-name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", + "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", + "dev": true + }, + "node_modules/@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.3.5.tgz", + "integrity": "sha512-h8QJa8xSb1WD4fpKBDcATDNGXghFj6/3GRWG6dhmRcu0RX1Ubasur2Uvx5aeEwlf0MwblEC2bMzzMQntxnw/Cw==", + "dev": true, + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "8.4.6", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz", + "integrity": "sha512-/fqTbjxyFUaYNO7VcW5g+4npmqVACz1bB7RTHYuLj+PRjw9hrCwrUXVQFpChUS0JsyEFvMZ7U/PfmvWgxJhI9g==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", + "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", + "dev": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } }, "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" + "version": "0.0.51", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", + "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", + "dev": true + }, + "node_modules/@types/express": { + "version": "4.17.14", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.14.tgz", + "integrity": "sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.31", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz", + "integrity": "sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", + "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } }, "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", - "peer": true, + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", "dependencies": { + "@types/react": "*", "hoist-non-react-statics": "^3.3.0" - }, - "peerDependencies": { - "@types/react": "*" + } + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "dev": true + }, + "node_modules/@types/http-proxy": { + "version": "1.17.9", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.9.tgz", + "integrity": "sha512-QsbSjA/fSk7xB+UXlCT3wHBy5ai9wOcNDWwZAtud+jXhwOM3l+EYZh8Lng4+/6n8uar0J7xILzqftJdJ/Wdfkw==", + "dev": true, + "dependencies": { + "@types/node": "*" } }, "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" + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", + "integrity": "sha512-hRJD2ahnnpLgsj6KWMYSrmXkM3rm2Dl1qkx6IOFD5FnuNPXJIG5L0dhgKXCYTRMGzU4n0wImQ/xfmRc4POUFlg==", + "dev": true }, "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==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", "dev": true, - "license": "MIT", "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "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==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", + "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", "dev": true, - "license": "MIT", "dependencies": { "@types/istanbul-lib-report": "*" } }, - "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.12.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", - "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", - "devOptional": true, - "license": "MIT", - "peer": true, + "node_modules/@types/jest": { + "version": "26.0.23", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.23.tgz", + "integrity": "sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA==", + "dev": true, "dependencies": { - "undici-types": "~7.16.0" + "jest-diff": "^26.0.0", + "pretty-format": "^26.0.0" } }, + "node_modules/@types/json-schema": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", + "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", + "dev": true + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true + }, + "node_modules/@types/mime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", + "integrity": "sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==", + "dev": true + }, + "node_modules/@types/minimist": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", + "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==", + "dev": true + }, + "node_modules/@types/node": { + "version": "14.0.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.27.tgz", + "integrity": "sha512-kVrqXhbclHNHGu9ztnAwSncIgJv/FaxmzXJvGXNdcCpV1b8u1/Mi6z6m0vwy0LzKeXFTPLH0NzwmoJ3fNCIq0g==", + "devOptional": true + }, "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" + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", + "dev": true + }, + "node_modules/@types/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", + "dev": true + }, + "node_modules/@types/prettier": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz", + "integrity": "sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==", + "dev": true }, "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" + "version": "15.7.3", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", + "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==" + }, + "node_modules/@types/q": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.4.tgz", + "integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==", + "dev": true + }, + "node_modules/@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true }, "node_modules/@types/react": { - "version": "17.0.91", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.91.tgz", - "integrity": "sha512-xauZca6qMeCU3Moy0KxCM9jtf1vyk6qRYK39Ryf3afUqwgNUjRIGoDdS9BcGWgAMGSg1hvP4XcmlYrM66PtqeA==", - "license": "MIT", - "peer": true, + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.0.tgz", + "integrity": "sha512-aj/L7RIMsRlWML3YB6KZiXB3fV2t41+5RBGYF8z+tAKU43Px8C3cYUZsDvf1/+Bm4FK21QWBrDutu8ZJ/70qOw==", "dependencies": { "@types/prop-types": "*", - "@types/scheduler": "^0.16", - "csstype": "^3.2.2" + "csstype": "^3.0.2" } }, "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==", + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.5.tgz", + "integrity": "sha512-ikqukEhH4H9gr4iJCmQVNzTB307kROe3XFfHAOTxOXPOw7lAoEXnM5KWTkzeANGL5Ce6ABfiMl/zJBYNi7ObmQ==", "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^17.0.0" + "dependencies": { + "@types/react": "*" } }, "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", + "version": "7.1.24", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.24.tgz", + "integrity": "sha512-7FkurKcS1k0FHZEtdbbgN8Oc6b+stGSfZYjQGicofJ0j4U0qIn/jaSvnP2pLwZKiai3/17xqqxkkrxTgN8UNbQ==", "dependencies": { "@types/hoist-non-react-statics": "^3.3.0", "@types/react": "*", @@ -3420,133 +4294,140 @@ } }, "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==", + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz", + "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==", "dev": true, - "license": "MIT", "dependencies": { "@types/react": "*" } }, "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": "*" - } - }, - "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", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.1.tgz", + "integrity": "sha512-vIo69qKKcYoJ8wKCJjwSgCTM+z3chw3g18dkrDfVX665tMH7tmbDxEAnPdey4gTlwZz5QuHGzd+hul0OVZDqqQ==", "dependencies": { "@types/react": "*" } }, - "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/react/node_modules/csstype": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz", + "integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw==" }, - "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==", + "node_modules/@types/resolve": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", + "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", "dev": true, - "license": "MIT" + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true + }, + "node_modules/@types/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-d/Hs3nWDxNL2xAczmOVZNj92YZCS6RGxfBPjKzuu/XirCgXdpKEb88dYNbrYGint6IVWLNP+yonwVAuRC0T2Dg==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==", + "dev": true, + "dependencies": { + "@types/mime": "*", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.33", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", + "integrity": "sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", + "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", + "dev": true + }, + "node_modules/@types/testing-library__jest-dom": { + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz", + "integrity": "sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ==", + "dev": true, + "dependencies": { + "@types/jest": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", + "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==", + "dev": true }, "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==", + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", + "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", "dev": true, - "license": "MIT", "dependencies": { "@types/node": "*" } }, "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==", + "version": "15.0.13", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.13.tgz", + "integrity": "sha512-kQ5JNTrbDv3Rp5X2n/iUu37IJBDU2gsZ5R/g1/KHOOEc5IKfUFjXT6DENPGduh08I/pamwtEq4oul7gUqKTQDQ==", "dev": true, - "license": "MIT", "dependencies": { "@types/yargs-parser": "*" } }, "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" + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz", + "integrity": "sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw==", + "dev": true }, "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==", + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.38.1.tgz", + "integrity": "sha512-ky7EFzPhqz3XlhS7vPOoMDaQnQMn+9o5ICR9CPr/6bw8HrFkzhMSxuA3gRfiJVvs7geYrSeawGJjZoZQKCOglQ==", "dev": true, - "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/type-utils": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/scope-manager": "5.38.1", + "@typescript-eslint/type-utils": "5.38.1", + "@typescript-eslint/utils": "5.38.1", "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.4", - "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "ignore": "^5.2.0", + "regexpp": "^3.2.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -3554,29 +4435,45 @@ } } }, - "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==", + "node_modules/@typescript-eslint/experimental-utils": { + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.38.1.tgz", + "integrity": "sha512-Zv0EcU0iu64DiVG3pRZU0QYCgANO//U1fS3oEs3eqHD1eIVVcQsFd/T01ckaNbL2H2aCqRojY2xZuMAPcOArEA==", "dev": true, - "license": "BSD-2-Clause", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4" + "@typescript-eslint/utils": "5.38.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.38.1.tgz", + "integrity": "sha512-LDqxZBVFFQnQRz9rUZJhLmox+Ep5kdUmLatLQnCRR6523YV+XhRjfYzStQ4MheFA8kMAfUlclHSbu+RKdRwQKw==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "5.38.1", + "@typescript-eslint/types": "5.38.1", + "@typescript-eslint/typescript-estree": "5.38.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -3585,17 +4482,16 @@ } }, "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==", + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.38.1.tgz", + "integrity": "sha512-BfRDq5RidVU3RbqApKmS7RFMtkyWMM50qWnDAkKgQiezRtLKsoyRKIvz1Ok5ilRWeD9IuHvaidaLxvGx/2eqTQ==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" + "@typescript-eslint/types": "5.38.1", + "@typescript-eslint/visitor-keys": "5.38.1" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "type": "opencollective", @@ -3603,26 +4499,25 @@ } }, "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==", + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.38.1.tgz", + "integrity": "sha512-UU3j43TM66gYtzo15ivK2ZFoDFKKP0k03MItzLdq0zV92CeGCXRfXlfQX5ILdd4/DSpHkSjIgLLLh1NtkOJOAw==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/typescript-estree": "5.38.1", + "@typescript-eslint/utils": "5.38.1", "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" + "tsutils": "^3.21.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "*" }, "peerDependenciesMeta": { "typescript": { @@ -3631,13 +4526,12 @@ } }, "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==", + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.38.1.tgz", + "integrity": "sha512-QTW1iHq1Tffp9lNfbfPm4WJabbvpyaehQ0SrvVK2yfV79SytD9XDVxqiPvdrv2LK7DGSFo91TB2FgWanbJAZXg==", "dev": true, - "license": "MIT", "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "type": "opencollective", @@ -3645,23 +4539,21 @@ } }, "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==", + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.38.1.tgz", + "integrity": "sha512-99b5e/Enoe8fKMLdSuwrfH/C0EIbpUWmeEKHmQlGZb8msY33qn1KlkFww0z26o5Omx7EVjzVDCWEfrfCDHfE7g==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", + "@typescript-eslint/types": "5.38.1", + "@typescript-eslint/visitor-keys": "5.38.1", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" + "semver": "^7.3.7", + "tsutils": "^3.21.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "type": "opencollective", @@ -3674,227 +4566,250 @@ } }, "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==", + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.38.1.tgz", + "integrity": "sha512-oIuUiVxPBsndrN81oP8tXnFa/+EcZ03qLqPDfSZ5xIJVm7A9V0rlkQwwBOAGtrdN70ZKDlKv+l1BeT4eSFxwXA==", "dev": true, - "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "semver": "^7.5.4" + "@types/json-schema": "^7.0.9", + "@typescript-eslint/scope-manager": "5.38.1", + "@typescript-eslint/types": "5.38.1", + "@typescript-eslint/typescript-estree": "5.38.1", + "eslint-scope": "^5.1.1", + "eslint-utils": "^3.0.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" } }, "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==", + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.38.1.tgz", + "integrity": "sha512-bSHr1rRxXt54+j2n4k54p4fj8AHJ49VDWtjpImOpzQj4qjAiOpPni+V1Tyajh19Api1i844F757cur8wH3YvOA==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "6.21.0", - "eslint-visitor-keys": "^3.4.1" + "@typescript-eslint/types": "5.38.1", + "eslint-visitor-keys": "^3.3.0" }, "engines": { - "node": "^16.0.0 || >=18.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" } }, - "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==", + "node_modules/@webassemblyjs/ast": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", + "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", "dev": true, - "license": "ISC" - }, - "node_modules/@vitejs/plugin-react": { - "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.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-rc.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" + "@webassemblyjs/helper-numbers": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", + "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", + "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", + "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", + "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", + "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", + "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", + "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", + "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", + "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", + "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/helper-wasm-section": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-opt": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "@webassemblyjs/wast-printer": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", + "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", + "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", + "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", + "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "dev": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" }, "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/@vitest/coverage-v8": { - "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.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.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.2", - "vitest": "4.1.2" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "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.1.0", - "@types/chai": "^5.2.2", - "@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.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.1.2", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "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.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "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.1.2", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "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.1.2", - "@vitest/utils": "4.1.2", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "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.1.2", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "node": ">= 0.6" } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "license": "MIT", - "peer": true, + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -3902,31 +4817,84 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-globals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", + "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", + "dev": true, + "dependencies": { + "acorn": "^7.1.1", + "acorn-walk": "^7.1.1" + } + }, "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": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "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==", + "node_modules/acorn-node": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", + "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", + "dev": true, + "dependencies": { + "acorn": "^7.0.0", + "acorn-walk": "^7.0.0", + "xtend": "^4.0.2" + } + }, + "node_modules/acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", "dev": true, - "license": "MIT", "engines": { - "node": ">= 14" + "node": ">=0.4.0" + } + }, + "node_modules/address": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/address/-/address-1.2.1.tgz", + "integrity": "sha512-B+6bi5D34+fDYENiH5qOlA0cV2rAGKuWZ9LeyUUehbXy8e0VS9e498yO0Jeeh+iM+6KbfudHTFjXw2MmJD4QRA==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/adjust-sourcemap-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", + "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", + "dev": true, + "dependencies": { + "loader-utils": "^2.0.0", + "regex-parser": "^2.2.11" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" } }, "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", + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3938,71 +4906,173 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "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", + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, "dependencies": { - "string-width": "^4.1.0" + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/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==", + "dev": true + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-align": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz", + "integrity": "sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw==", + "dev": true, + "dependencies": { + "string-width": "^3.0.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "node_modules/ansi-align/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "dependencies": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" } }, "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", + "dev": true, "dependencies": { "type-fest": "^0.21.3" }, "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, "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)", + "dev": true, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "bin": { + "ansi-html": "bin/ansi-html" } }, "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", + "dev": true, "engines": { "node": ">=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", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, "dependencies": { + "@types/color-name": "^1.1.1", "color-convert": "^2.0.1" }, "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "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", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -4011,54 +5081,51 @@ "node": ">= 8" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, "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==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, - "license": "Python-2.0" + "dependencies": { + "sprintf-js": "~1.0.2" + } }, "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==", + "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": { - "dequal": "^2.0.3" - } - }, - "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", - "is-array-buffer": "^3.0.5" + "@babel/runtime": "^7.10.2", + "@babel/runtime-corejs3": "^7.10.2" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6.0" } }, + "node_modules/array-flatten": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", + "dev": true + }, "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==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.5.tgz", + "integrity": "sha512-iSDYZMMyTPkiFasVqfuAQnWAYcvO/SeBSCGKePoEthjp4LEMTe4uLc7b025o4jAZpHhihh8xPo99TNWUWWkGDQ==", "dev": true, - "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.19.5", + "get-intrinsic": "^1.1.1", + "is-string": "^1.0.7" }, "engines": { "node": ">= 0.4" @@ -4072,43 +5139,20 @@ "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": { "node": ">=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": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "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==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.0.tgz", + "integrity": "sha512-12IUEkHsAhA4DY5s0FPgNXIdc8VRSqD9Zp78a5au9abH/SOBrsp082JOWFNTjkMozh8mqcdiKuaLGhPeYztxSw==", "dev": true, - "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.2", + "es-shim-unscopables": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -4118,16 +5162,15 @@ } }, "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==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.0.tgz", + "integrity": "sha512-PZC9/8TKAIxcWKdyeb77EzULHPrIX/tIZebLJUQOMR1OwYosT8yggdfWScfTBCDj5utONvOuPQQumYsU2ULbkg==", "dev": true, - "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.2", + "es-shim-unscopables": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -4136,244 +5179,471 @@ "url": "https://github.com/sponsors/ljharb" } }, - "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": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "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", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arrify": { + "node_modules/array.prototype.foreach": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", - "license": "MIT", + "resolved": "https://registry.npmjs.org/array.prototype.foreach/-/array.prototype.foreach-1.0.1.tgz", + "integrity": "sha512-5/+XXc6Sq/X0nKTqrnYfckvE4tIAMEJDSkGsdBl0OC6/Kvr38PHgT6amItyCKP2Fng1Ifi3mI+1r01f0opJQdQ==", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.5", + "es-array-method-boxes-properly": "^1.0.0", + "get-intrinsic": "^1.1.1", + "is-string": "^1.0.7" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "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": { - "node": ">=12" - } + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true }, "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": "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": "^10.0.0" - } - }, - "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "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" + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", + "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==", + "dev": true }, "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" + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", + "dev": true }, - "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" - } + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true }, "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", + "dev": true, "engines": { "node": ">= 4.0.0" } }, - "node_modules/atomically": { - "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", - "when-exit": "^2.1.4" - } - }, "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", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", + "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==", "engines": { "node": ">=4" } }, + "node_modules/autoprefixer": { + "version": "10.4.12", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.12.tgz", + "integrity": "sha512-WrCGV9/b97Pa+jtwf5UGaRjgQIg7OK3D06GnoYoZNcG1Xb8Gt3EfuKjlhh9i/VtT16g6PYjZ69jdJ2g8FxSC4Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + } + ], + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-lite": "^1.0.30001407", + "fraction.js": "^4.2.0", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "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" } }, - "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" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/axe-core": { - "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==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.3.tgz", + "integrity": "sha512-32+ub6kkdhhWick/UjvEwRchgoetXqTK14INLqbGm5U2TzBkBNF3nQtLYm8ovxSkQWArjEQvftCKryjZaATu3w==", "dev": true, - "license": "MPL-2.0", "engines": { "node": ">=4" } }, "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==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", + "integrity": "sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==", + "dev": true + }, + "node_modules/babel-jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", + "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==", "dev": true, - "license": "Apache-2.0", + "dependencies": { + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, "engines": { - "node": ">= 0.4" + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-jest/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/babel-jest/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/babel-jest/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/babel-loader": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.5.tgz", + "integrity": "sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==", + "dev": true, + "dependencies": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "engines": { + "node": ">= 8.9" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "webpack": ">=2" + } + }, + "node_modules/babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "dev": true, + "dependencies": { + "object.assign": "^4.1.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz", + "integrity": "sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==", + "dev": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.0.0", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-named-asset-import": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.8.tgz", + "integrity": "sha512-WXiAc++qo7XcJ1ZnTYGtLxmBCVbddAml3CEXgWaBzNzLNoxtQ8AiGEFDMOhot9XjTCQbvP5E77Fj9Gk924f00Q==", + "dev": true, + "peerDependencies": { + "@babel/core": "^7.1.0" } }, "node_modules/babel-plugin-polyfill-corejs2": { - "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", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz", + "integrity": "sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==", + "dev": true, "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.8", - "semver": "^6.3.1" + "@babel/compat-data": "^7.17.7", + "@babel/helper-define-polyfill-provider": "^0.3.3", + "semver": "^6.1.1" }, "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + "@babel/core": "^7.0.0-0" } }, "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", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, "bin": { "semver": "bin/semver.js" } }, "node_modules/babel-plugin-polyfill-corejs3": { - "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", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz", + "integrity": "sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA==", + "dev": true, "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8", - "core-js-compat": "^3.48.0" + "@babel/helper-define-polyfill-provider": "^0.3.3", + "core-js-compat": "^3.25.1" }, "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + "@babel/core": "^7.0.0-0" } }, "node_modules/babel-plugin-polyfill-regenerator": { - "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", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz", + "integrity": "sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==", + "dev": true, "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8" + "@babel/helper-define-polyfill-provider": "^0.3.3" }, "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/babel-plugin-transform-react-remove-prop-types": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz", + "integrity": "sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA==", + "dev": true + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", + "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "dev": true, + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.8.3", + "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-top-level-await": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz", + "integrity": "sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==", + "dev": true, + "dependencies": { + "babel-plugin-jest-hoist": "^27.5.1", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-react-app": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-react-app/-/babel-preset-react-app-10.0.1.tgz", + "integrity": "sha512-b0D9IZ1WhhCWkrTXyFuIIgqGzSkRIH5D5AmB0bXbzYAB1OBAwHcUeyWW2LorutLWF5btNo/N7r/cIdmvvKJlYg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.16.0", + "@babel/plugin-proposal-class-properties": "^7.16.0", + "@babel/plugin-proposal-decorators": "^7.16.4", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.0", + "@babel/plugin-proposal-numeric-separator": "^7.16.0", + "@babel/plugin-proposal-optional-chaining": "^7.16.0", + "@babel/plugin-proposal-private-methods": "^7.16.0", + "@babel/plugin-transform-flow-strip-types": "^7.16.0", + "@babel/plugin-transform-react-display-name": "^7.16.0", + "@babel/plugin-transform-runtime": "^7.16.4", + "@babel/preset-env": "^7.16.4", + "@babel/preset-react": "^7.16.0", + "@babel/preset-typescript": "^7.16.0", + "@babel/runtime": "^7.16.3", + "babel-plugin-macros": "^3.1.0", + "babel-plugin-transform-react-remove-prop-types": "^0.4.24" } }, "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", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", "dependencies": { "core-js": "^2.4.0", "regenerator-runtime": "^0.11.0" } }, + "node_modules/babel-runtime/node_modules/core-js": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", + "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==" + }, + "node_modules/babel-runtime/node_modules/regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" + }, "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" + "dev": true }, "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==", + "dev": true + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true + }, + "node_modules/bfj": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.0.2.tgz", + "integrity": "sha512-+e/UqUzwmzJamNF50tBV6tZPTORow7gQ96iFow+8b562OdMpEK0BcJEq2OSPEDmAbSMBQ7PKZ87ubFkgxpYWgw==", + "dev": true, + "dependencies": { + "bluebird": "^3.5.5", + "check-types": "^11.1.1", + "hoopy": "^0.1.4", + "tryer": "^1.0.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, "funding": [ { "type": "github", @@ -4388,182 +5658,152 @@ "url": "https://feross.org/support" } ], - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "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.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" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, "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" + "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==" }, - "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", + "node_modules/body-parser": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz", + "integrity": "sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==", + "dev": true, "dependencies": { - "ansi-align": "^3.0.1", - "camelcase": "^8.0.0", - "chalk": "^5.3.0", - "cli-boxes": "^3.0.0", - "string-width": "^7.2.0", - "type-fest": "^4.21.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0" + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.10.3", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" }, "engines": { - "node": ">=18" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/bonjour-service": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.0.14.tgz", + "integrity": "sha512-HIMbgLnk1Vqvs6B4Wq5ep7mxvj9sGz5d1JJyDNSGNIdA/w2MCz6GTjWTdjqOJV1bEPj+6IkxDvWNFKEBxNt4kQ==", + "dev": true, + "dependencies": { + "array-flatten": "^2.1.2", + "dns-equal": "^1.0.0", + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=", + "dev": true + }, + "node_modules/boxen": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-4.2.0.tgz", + "integrity": "sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ==", + "dev": true, + "dependencies": { + "ansi-align": "^3.0.0", + "camelcase": "^5.3.1", + "chalk": "^3.0.0", + "cli-boxes": "^2.2.0", + "string-width": "^4.1.0", + "term-size": "^2.1.0", + "type-fest": "^0.8.1", + "widest-line": "^3.1.0" + }, + "engines": { + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "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" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "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", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "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", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/boxen/node_modules/strip-ansi": { - "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.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "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" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" } }, "node_modules/brace-expansion": { - "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", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "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", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, "dependencies": { - "fill-range": "^7.1.1" + "fill-range": "^7.0.1" }, "engines": { "node": ">=8" } }, + "node_modules/browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", + "dev": true + }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.21.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz", + "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -4572,20 +5812,13 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", - "peer": true, "dependencies": { - "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" + "caniuse-lite": "^1.0.30001400", + "electron-to-chromium": "^1.4.251", + "node-releases": "^2.0.6", + "update-browserslist-db": "^1.0.9" }, "bin": { "browserslist": "cli.js" @@ -4594,113 +5827,150 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "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", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "node-int64": "^0.4.0" } }, "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" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "node_modules/builtin-modules": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz", + "integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable-request": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", + "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", + "dev": true, + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^3.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^4.1.0", + "responselike": "^1.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cacheable-request/node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/node_modules/normalize-url": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz", + "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==", + "dev": true, + "engines": { + "node": ">=8" + } }, "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", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" } }, - "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", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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": { "node": ">=6" } }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/camel-case/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true + }, "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", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, "engines": { - "node": ">=6" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" } }, "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", + "dev": true, "dependencies": { "camelcase": "^5.3.1", "map-obj": "^4.0.0", @@ -4713,10 +5983,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/camelcase-keys/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, "node_modules/caniuse-lite": { - "version": "1.0.30001785", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", - "integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==", + "version": "1.0.30001488", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz", + "integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -4730,46 +6022,62 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ], - "license": "CC-BY-4.0" + ] }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "node_modules/case-sensitive-paths-webpack-plugin": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz", + "integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==", "dev": true, - "license": "MIT", "engines": { - "node": ">=18" + "node": ">=4" } }, "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", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" }, + "engines": { + "node": ">=8" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" } }, "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" + "dev": true + }, + "node_modules/check-types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.1.2.tgz", + "integrity": "sha512-tzWzvgePgLORb9/3a0YenggReLKAIb2owL03H2Xdoe5pKcUyWRSEQ8xfCar8t2SIAuEDwtmx2da1YB52YuHQMQ==", + "dev": true }, "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", + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -4782,38 +6090,55 @@ "engines": { "node": ">= 8.10.0" }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, "optionalDependencies": { "fsevents": "~2.3.2" } }, - "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" - }, + "node_modules/chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true, "engines": { - "node": ">= 6" + "node": ">=6.0" } }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "node_modules/cjs-module-lexer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz", + "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", + "dev": true + }, "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "license": "MIT" + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", + "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + }, + "node_modules/clean-css": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.1.tgz", + "integrity": "sha512-lCr8OHhiWCTw4v8POJovCoh4T7I9U11yVsPjMWWnnMmp9ZowCxyad1Pathle/9HjaDp+fdQKjO9fQydE6RHTZg==", + "dev": true, + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } }, "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", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "dev": true, "engines": { - "node": ">=10" + "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -4823,7 +6148,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "license": "MIT", + "dev": true, "dependencies": { "restore-cursor": "^3.1.0" }, @@ -4832,10 +6157,10 @@ } }, "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", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.0.tgz", + "integrity": "sha512-t+4/y50K/+4xcCRosKkA7W4gTr1MySvLV0q+PxmG7FJ5g+66ChKurYjxBCjHggHH3HA5Hh9cy+lcUGWDqVH+4Q==", + "dev": true, "engines": { "node": ">=6" }, @@ -4847,34 +6172,145 @@ "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", + "dev": true, "engines": { "node": ">= 10" } }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, "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", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "dev": true, "engines": { "node": ">=0.8" } }, + "node_modules/clone-response": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", + "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", + "dev": true, + "dependencies": { + "mimic-response": "^1.0.0" + } + }, "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", + "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", "engines": { "node": ">=6" } }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/coa": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/coa/-/coa-2.0.2.tgz", + "integrity": "sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA==", + "dev": true, + "dependencies": { + "@types/q": "^1.5.1", + "chalk": "^2.4.1", + "q": "^1.1.2" + }, + "engines": { + "node": ">= 4.0" + } + }, + "node_modules/coa/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/coa/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/coa/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/coa/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "node_modules/coa/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/coa/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", + "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==", + "dev": true + }, "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", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -4886,113 +6322,246 @@ "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" + "dev": true + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true + }, + "node_modules/colorette": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", + "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } }, "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" + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true }, "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", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.0.tgz", + "integrity": "sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==", + "dev": true, "engines": { "node": ">=4.0.0" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dev": true, + "dependencies": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, "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" + "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==" }, "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", - "proto-list": "~1.2.1" - } - }, - "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" + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true }, "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", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", + "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", + "dev": true, "dependencies": { - "atomically": "^2.0.3", - "dot-prop": "^9.0.0", - "graceful-fs": "^4.2.11", - "xdg-basedir": "^5.1.0" + "dot-prop": "^5.2.0", + "graceful-fs": "^4.1.2", + "make-dir": "^3.0.0", + "unique-string": "^2.0.0", + "write-file-atomic": "^3.0.0", + "xdg-basedir": "^4.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" + } + }, + "node_modules/confusing-browser-globals": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", + "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", + "dev": true + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true, + "engines": { + "node": ">=0.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", - "peer": true, + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.9.1.tgz", + "integrity": "sha512-BbtB6t0iqAwGwygDenJl9zmlk7vpKWIRSycULmkAOn2RUaF6+bqETprl0qcIqQmY5CTqSwKanaxkLXYWiffAfQ==", "dependencies": { "lodash.isequalwith": "^4.4.0", "prop-types": "^15.7.2" }, "optionalDependencies": { - "immutable": "^3.8.1 || ^4.0.0", + "immutable": "^3.8.1 || ^4.0.0-rc.1", "seamless-immutable": "^7.1.3" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "dependencies": { + "safe-buffer": "5.2.1" }, - "peerDependencies": { - "history": "^4.7.2", - "react": "^16.4.0 || ^17.0.0", - "react-redux": "^6.0.0 || ^7.1.0", - "react-router": "^4.3.1 || ^5.0.0", - "redux": "^3.6.0 || ^4.0.0" + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition/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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "dev": true, + "engines": { + "node": ">= 0.6" } }, "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" + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.1" + } + }, + "node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true }, "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.", + "version": "3.25.3", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.3.tgz", + "integrity": "sha512-y1hvKXmPHvm5B7w4ln1S4uc9eV/O5+iFExSRUimnvIph11uaizFR8LFMdONN8hG3P2pipUfX4Y/fR8rAEtcHcQ==", + "dev": true, "hasInstallScript": true, - "license": "MIT" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } }, "node_modules/core-js-compat": { - "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", + "version": "3.25.3", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.25.3.tgz", + "integrity": "sha512-xVtYpJQ5grszDHEUU9O7XbjjcZ0ccX3LgQsyqSvTnjX97ZqEgn9F5srmrwwwMtbKzDllyFPL+O+2OFMl1lU4TQ==", + "dev": true, "dependencies": { - "browserslist": "^4.28.1" + "browserslist": "^4.21.4" }, "funding": { "type": "opencollective", @@ -5000,22 +6569,43 @@ } }, "node_modules/core-js-pure": { - "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==", + "version": "3.25.3", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.25.3.tgz", + "integrity": "sha512-T/7qvgv70MEvRkZ8p6BasLZmOVYKzOaWNBEHAU8FmveCJkl4nko2quqPQOmy6AJIp5MBanhz9no3A94NoRb0XA==", "dev": true, "hasInstallScript": true, - "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "node_modules/cosmiconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz", + "integrity": "sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==", + "dev": true, + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, "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", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -5029,149 +6619,470 @@ "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", + "dev": true, "engines": { "node": ">=8" } }, + "node_modules/css-blank-pseudo": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz", + "integrity": "sha512-VS90XWtsHGqoM0t4KpH053c4ehxZ2E6HtGI7x68YFV0pTo/QmkV/YFA+NnlvK8guxZVNWGQhVNJGC39Q8XF4OQ==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "bin": { + "css-blank-pseudo": "dist/cli.cjs" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-declaration-sorter": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.3.1.tgz", + "integrity": "sha512-fBffmak0bPAnyqc/HO8C3n2sHrp9wcqQz6ES9koRF2/mLOVAx9zIQ3Y7R29sYCteTPqMCwns4WYQoCX91Xl3+w==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-has-pseudo": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-3.0.4.tgz", + "integrity": "sha512-Vse0xpR1K9MNlp2j5w1pgWIJtm1a8qS0JwS9goFYcImjlHEmywP9VUF05aGBXzGpDJF86QXk4L0ypBmwPhGArw==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "bin": { + "css-has-pseudo": "dist/cli.cjs" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-loader": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.1.tgz", + "integrity": "sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==", + "dev": true, + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.7", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, "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" + "integrity": "sha1-aiw3NEkoYYYxxUvTPO3TAdoYvqA=" + }, + "node_modules/css-minimizer-webpack-plugin": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz", + "integrity": "sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q==", + "dev": true, + "dependencies": { + "cssnano": "^5.0.6", + "jest-worker": "^27.0.2", + "postcss": "^8.3.5", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@parcel/css": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/css-minimizer-webpack-plugin/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==", + "dev": true + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/css-prefers-color-scheme": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-6.0.3.tgz", + "integrity": "sha512-4BqMbZksRkJQx2zAjrokiGMd07RqOa2IxIrrN10lyBe9xhn9DEvjUK79J6jkeiv9D9hQFXKb6g1jwU62jziJZA==", + "dev": true, + "bin": { + "css-prefers-color-scheme": "dist/cli.cjs" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz", + "integrity": "sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^3.2.1", + "domutils": "^1.7.0", + "nth-check": "^1.0.2" + } + }, + "node_modules/css-select-base-adapter": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", + "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==", + "dev": true + }, + "node_modules/css-tree": { + "version": "1.0.0-alpha.37", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", + "integrity": "sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==", + "dev": true, + "dependencies": { + "mdn-data": "2.0.4", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } }, "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", "is-in-browser": "^1.0.2" } }, + "node_modules/css-what": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz", + "integrity": "sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "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" + "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=", + "dev": true }, - "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "node_modules/cssdb": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.0.1.tgz", + "integrity": "sha512-pT3nzyGM78poCKLAEy2zWIVX2hikq6dIrjuZzLV98MumBg+xMTNYfHx7paUlfiRTgg91O/vR889CIf+qiv79Rw==", "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "5.1.13", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.13.tgz", + "integrity": "sha512-S2SL2ekdEz6w6a2epXn4CmMKU4K3KpcyXLKfAYc9UQQqJRkD/2eLUG0vJ3Db/9OvO5GuAdgXw3pFbR6abqghDQ==", + "dev": true, + "dependencies": { + "cssnano-preset-default": "^5.2.12", + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-preset-default": { + "version": "5.2.12", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.12.tgz", + "integrity": "sha512-OyCBTZi+PXgylz9HAA5kHyoYhfGcYdwFmyaJzWnzxuGRtnMw/kR6ilW9XzlzlRAtB6PLT/r+prYgkef7hngFew==", + "dev": true, + "dependencies": { + "css-declaration-sorter": "^6.3.0", + "cssnano-utils": "^3.1.0", + "postcss-calc": "^8.2.3", + "postcss-colormin": "^5.3.0", + "postcss-convert-values": "^5.1.2", + "postcss-discard-comments": "^5.1.2", + "postcss-discard-duplicates": "^5.1.0", + "postcss-discard-empty": "^5.1.1", + "postcss-discard-overridden": "^5.1.0", + "postcss-merge-longhand": "^5.1.6", + "postcss-merge-rules": "^5.1.2", + "postcss-minify-font-values": "^5.1.0", + "postcss-minify-gradients": "^5.1.1", + "postcss-minify-params": "^5.1.3", + "postcss-minify-selectors": "^5.2.1", + "postcss-normalize-charset": "^5.1.0", + "postcss-normalize-display-values": "^5.1.0", + "postcss-normalize-positions": "^5.1.1", + "postcss-normalize-repeat-style": "^5.1.1", + "postcss-normalize-string": "^5.1.0", + "postcss-normalize-timing-functions": "^5.1.0", + "postcss-normalize-unicode": "^5.1.0", + "postcss-normalize-url": "^5.1.0", + "postcss-normalize-whitespace": "^5.1.1", + "postcss-ordered-values": "^5.1.3", + "postcss-reduce-initial": "^5.1.0", + "postcss-reduce-transforms": "^5.1.0", + "postcss-svgo": "^5.1.0", + "postcss-unique-selectors": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", + "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/csso": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dev": true, + "dependencies": { + "css-tree": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true + }, + "node_modules/cssom": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", + "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==", + "dev": true + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true + }, "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" + "version": "2.6.17", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.17.tgz", + "integrity": "sha512-u1wmTI1jJGzCJzWndZo8mk4wnPTZd1eOIYTYvuEyOQGfmDl3TrabCCfKnOC86FZwW/9djqTl933UF/cS425i9A==" }, "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" + "dev": true }, "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==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", + "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", "dev": true, - "license": "MIT", "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" + "abab": "^2.0.3", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.0.0" }, "engines": { - "node": ">=18" - } - }, - "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": { - "node": ">=18" - } - }, - "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", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "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", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=10" } }, "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" + "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==" }, "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, "dependencies": { - "ms": "^2.1.3" + "ms": "2.1.2" }, "engines": { "node": ">=6.0" @@ -5185,98 +7096,71 @@ "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", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true, "engines": { "node": ">=0.10.0" } }, "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", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.0.tgz", + "integrity": "sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=", + "dev": true, "dependencies": { "decamelize": "^1.1.0", "map-obj": "^1.0.0" }, "engines": { "node": ">=0.10.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, "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", + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=", + "dev": true, "engines": { "node": ">=0.10.0" } }, "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" + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.1.tgz", + "integrity": "sha512-F29o+vci4DodHYT9UrR5IEbfBw9pE5eSapIJdTqXK5+6hq+t8VRxwQyKlW2i+KDKFkkJQRvFyI/QXD83h8LyQw==", + "dev": true }, "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" } }, - "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==", + "node_modules/decompress-response": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", + "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", "dev": true, - "license": "MIT", "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" + "mimic-response": "^1.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4" } }, - "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/dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", + "dev": true }, "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", + "dev": true, "engines": { "node": ">=4.0.0" } @@ -5285,54 +7169,57 @@ "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" + "dev": true }, "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", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", "engines": { "node": ">=0.10.0" } }, - "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", + "node_modules/default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dev": true, "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" + "execa": "^5.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 10" + } + }, + "node_modules/defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "dev": true, + "dependencies": { + "clone": "^1.0.2" + } + }, + "node_modules/defer-to-connect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", + "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==", + "dev": true + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "engines": { + "node": ">=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", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", + "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", "dependencies": { - "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" }, @@ -5343,14 +7230,117 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "node_modules/defined": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", + "integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==", + "dev": true + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, - "license": "MIT", "engines": { - "node": ">=6" + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true + }, + "node_modules/detect-port-alt": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz", + "integrity": "sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==", + "dev": true, + "dependencies": { + "address": "^1.0.1", + "debug": "^2.6.0" + }, + "bin": { + "detect": "bin/detect-port", + "detect-port": "bin/detect-port" + }, + "engines": { + "node": ">= 4.2.1" + } + }, + "node_modules/detect-port-alt/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/detect-port-alt/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/detective": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz", + "integrity": "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==", + "dev": true, + "dependencies": { + "acorn-node": "^1.8.2", + "defined": "^1.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "detective": "bin/detective.js" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/diff-sequences": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", + "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==", + "dev": true, + "engines": { + "node": ">= 10.14.2" } }, "node_modules/dir-glob": { @@ -5358,7 +7348,6 @@ "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "dev": true, - "license": "MIT", "dependencies": { "path-type": "^4.0.0" }, @@ -5366,23 +7355,45 @@ "node": ">=8" } }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, "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", "@react-dnd/invariant": "^2.0.0", "redux": "^4.1.1" } }, + "node_modules/dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==", + "dev": true + }, + "node_modules/dns-packet": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.4.0.tgz", + "integrity": "sha512-EgqGeaBB8hLiHLZtp/IbaDQTL8pZ0+IvwzSHA6d7VyMDM+B9hgddEMa9xjK5oYnw0ci0JQ6g2XCD7/f6cafU6g==", + "dev": true, + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "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": { "esutils": "^2.0.2" }, @@ -5391,81 +7402,176 @@ } }, "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" + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.6.tgz", + "integrity": "sha512-DplGLZd8L1lN64jlT27N9TVSESFR5STaEJvX+thCby7fuCHonfPpAlodYc3vuUYbDuDec5w8AMP7oCM5TWFsqw==", + "dev": true }, "node_modules/dom-align": { - "version": "1.12.4", - "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz", - "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==", - "license": "MIT" + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.2.tgz", + "integrity": "sha512-pHuazgqrsTFrGU2WLDdXxCFabkdQDx72ddkraZNih1KsMcN5qsRSTR9O4VJRlwTPCPb5COYg3LOfiMHHcPInHg==" + }, + "node_modules/dom-converter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "dev": true, + "dependencies": { + "utila": "~0.4" + } }, "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", "csstype": "^3.0.2" } }, "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" + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz", + "integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==" + }, + "node_modules/dom-serializer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", + "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", + "dev": true, + "dependencies": { + "domelementtype": "^2.0.1", + "entities": "^2.0.0" + } + }, + "node_modules/dom-serializer/node_modules/domelementtype": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", + "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==", + "dev": true + }, + "node_modules/domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", + "dev": true + }, + "node_modules/domexception": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", + "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", + "dev": true, + "dependencies": { + "webidl-conversions": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/domexception/node_modules/webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domhandler/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] }, "node_modules/dompurify": { - "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)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.5.tgz", + "integrity": "sha512-jggCCd+8Iqp4Tsz0nIvpcb22InKEBrGz5dw3EQJMs8HPJDsKbFIO3STYtAvCfDx26Muevn1MHVI0XxjgFfmiSA==" + }, + "node_modules/domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "dev": true, + "dependencies": { + "dom-serializer": "0", + "domelementtype": "1" } }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dot-case/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true + }, "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", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, "dependencies": { - "type-fest": "^4.18.2" + "is-obj": "^2.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=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)", + "node_modules/dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "dev": true, "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=10" } }, + "node_modules/dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "dev": true + }, "node_modules/downloadjs": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/downloadjs/-/downloadjs-1.4.7.tgz", - "integrity": "sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q==", - "license": "MIT" + "integrity": "sha1-9p+W+UDg0FU9rCkROYZaPNAQHjw=" }, "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", "compute-scroll-into-view": "^1.0.9", @@ -5476,405 +7582,332 @@ "react": ">=0.14.9" } }, - "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/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true }, - "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", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } + "node_modules/duplexer3": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", + "dev": true }, - "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" - }, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.10.0" - } + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true }, "node_modules/electron-to-chromium": { - "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" + "version": "1.4.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.267.tgz", + "integrity": "sha512-ik4QnU3vFRsVgwt0vsn7og28++2cGnsdgqYagaE3ur1f3wj5AzmWu+1k3//SOc6CwkP2xfu46PNfVP6X+SRepg==", + "dev": true }, - "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==", + "node_modules/emittery": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", + "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==", "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "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": { - "node": ">=0.12" + "node": ">=10" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/sindresorhus/emittery?sponsor=1" } }, + "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==", + "dev": true + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz", + "integrity": "sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true + }, "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", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, "dependencies": { "is-arrayish": "^0.2.1" } }, - "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", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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" - } - }, - "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" - } - }, - "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==", + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", "dev": true, - "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-abstract": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.3.tgz", + "integrity": "sha512-AyrnaKVpMzljIdwjzrj+LxGmj8ik2LckwXacHqrJJ/jxz6dDDBcZ7I7nlHM0FvEW8MfbWJwOd+yT2XzYW49Frw==", "dependencies": { "call-bind": "^1.0.2", + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "function.prototype.name": "^1.1.5", "get-intrinsic": "^1.1.3", + "get-symbol-description": "^1.0.0", + "has": "^1.0.3", + "has-property-descriptors": "^1.0.0", "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", + "internal-slot": "^1.0.3", + "is-callable": "^1.2.6", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" + "is-weakref": "^1.0.2", + "object-inspect": "^1.12.2", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.4.3", + "safe-regex-test": "^1.0.0", + "string.prototype.trimend": "^1.0.5", + "string.prototype.trimstart": "^1.0.5", + "unbox-primitive": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "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.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": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.1", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.1.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.3.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "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": { - "node": ">= 0.4" - } + "node_modules/es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==" }, "node_modules/es-module-lexer": { - "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" - }, - "engines": { - "node": ">= 0.4" - } - }, - "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", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", + "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", + "dev": true }, "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==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", + "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", "dev": true, - "license": "MIT", "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" + "has": "^1.0.3" } }, "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", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/esbuild": { - "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", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@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", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, "engines": { "node": ">=6" } }, "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", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", + "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==", + "dev": true, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true + }, "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==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", "dev": true, - "license": "MIT", "engines": { - "node": ">=10" + "node": ">=0.8.0" + } + }, + "node_modules/escodegen": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", + "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", + "dev": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "dev": true, + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dev": true, + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "dev": true, + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" } }, "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.", + "version": "8.24.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.24.0.tgz", + "integrity": "sha512-dWFaPhGhTAiPcCgm3f6LI2MBWbogMnTJzFBbhXVRQDJPkr9pGZvVjlVfXd+vyDcWPA2Ic9L2AXPIQM0+vk/cSQ==", "dev": true, - "license": "MIT", - "peer": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", + "@eslint/eslintrc": "^1.3.2", + "@humanwhocodes/config-array": "^0.10.5", + "@humanwhocodes/gitignore-to-minimatch": "^1.0.2", "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", + "ajv": "^6.10.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", + "eslint-scope": "^7.1.1", + "eslint-utils": "^3.0.0", + "eslint-visitor-keys": "^3.3.0", + "espree": "^9.4.0", + "esquery": "^1.4.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", + "glob-parent": "^6.0.1", + "globals": "^13.15.0", + "globby": "^11.1.0", + "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", + "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", + "js-sdsl": "^4.1.4", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.3", + "optionator": "^0.9.1", + "regexpp": "^3.2.0", "strip-ansi": "^6.0.1", + "strip-json-comments": "^3.1.0", "text-table": "^0.2.0" }, "bin": { @@ -5887,159 +7920,138 @@ "url": "https://opencollective.com/eslint" } }, - "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==", + "node_modules/eslint-config-react-app": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz", + "integrity": "sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA==", "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" + "dependencies": { + "@babel/core": "^7.16.0", + "@babel/eslint-parser": "^7.16.3", + "@rushstack/eslint-patch": "^1.1.0", + "@typescript-eslint/eslint-plugin": "^5.5.0", + "@typescript-eslint/parser": "^5.5.0", + "babel-preset-react-app": "^10.0.1", + "confusing-browser-globals": "^1.0.11", + "eslint-plugin-flowtype": "^8.0.3", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-jest": "^25.3.0", + "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-react": "^7.27.1", + "eslint-plugin-react-hooks": "^4.3.0", + "eslint-plugin-testing-library": "^5.0.1" }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" + "engines": { + "node": ">=14.0.0" }, "peerDependencies": { - "eslint": ">=7.0.0" + "eslint": "^8.0.0" } }, - "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==", + "node_modules/eslint-import-resolver-node": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", + "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", "dev": true, - "license": "MIT", "dependencies": { - "aria-query": "^5.3.2", - "array-includes": "^3.1.8", - "array.prototype.flatmap": "^1.3.2", - "ast-types-flow": "^0.0.8", - "axe-core": "^4.10.0", - "axobject-query": "^4.1.0", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "hasown": "^2.0.2", - "jsx-ast-utils": "^3.3.5", - "language-tags": "^1.0.9", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "safe-regex-test": "^1.0.3", - "string.prototype.includes": "^2.0.1" + "debug": "^3.2.7", + "resolve": "^1.20.0" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz", + "integrity": "sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==", + "dev": true, + "dependencies": { + "debug": "^3.2.7" }, "engines": { - "node": ">=4.0" + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-flowtype": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-flowtype/-/eslint-plugin-flowtype-8.0.3.tgz", + "integrity": "sha512-dX8l6qUL6O+fYPtpNRideCFSpmWOUVx5QcaGLVqe/vlDiBSe4vYljDWDETwnyFzpl7By/WVIu6rcrniCgH9BqQ==", + "dev": true, + "dependencies": { + "lodash": "^4.17.21", + "string-natural-compare": "^3.0.1" + }, + "engines": { + "node": ">=12.0.0" }, "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + "@babel/plugin-syntax-flow": "^7.14.5", + "@babel/plugin-transform-react-jsx": "^7.14.9", + "eslint": "^8.1.0" } }, - "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==", + "node_modules/eslint-plugin-import": { + "version": "2.26.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz", + "integrity": "sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { - "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": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": { - "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": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "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": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", + "array-includes": "^3.1.4", + "array.prototype.flat": "^1.2.5", + "debug": "^2.6.9", "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-module-utils": "^2.7.3", + "has": "^1.0.3", + "is-core-module": "^2.8.1", + "is-glob": "^4.0.3", "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" + "object.values": "^1.1.5", + "resolve": "^1.22.0", + "tsconfig-paths": "^3.14.1" }, "engines": { "node": ">=4" }, "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^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==", + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" - } - }, - "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": { - "eslint": ">=8.40" - } - }, - "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "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": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "ms": "2.0.0" } }, - "node_modules/eslint-plugin-react/node_modules/doctrine": { + "node_modules/eslint-plugin-import/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": { "esutils": "^2.0.2" }, @@ -6047,93 +8059,482 @@ "node": ">=0.10.0" } }, - "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/eslint-plugin-import/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/eslint-plugin-jest": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-25.7.0.tgz", + "integrity": "sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==", "dev": true, - "license": "ISC", "dependencies": { - "brace-expansion": "^1.1.7" + "@typescript-eslint/experimental-utils": "^5.0.0" }, "engines": { - "node": "*" + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^4.0.0 || ^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + } } }, - "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==", + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.6.1.tgz", + "integrity": "sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.18.9", + "aria-query": "^4.2.2", + "array-includes": "^3.1.5", + "ast-types-flow": "^0.0.7", + "axe-core": "^4.4.3", + "axobject-query": "^2.2.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "has": "^1.0.3", + "jsx-ast-utils": "^3.3.2", + "language-tags": "^1.0.5", + "minimatch": "^3.1.2", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "node_modules/eslint-plugin-jsx-a11y/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 + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true, - "license": "ISC", "bin": { "semver": "bin/semver.js" } }, - "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==", + "node_modules/eslint-plugin-react": { + "version": "7.31.8", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.31.8.tgz", + "integrity": "sha512-5lBTZmgQmARLLSYiwI71tiGVTLUuqXantZM6vlSY39OaDSV0M7+32K5DnLkmFrwTe+Ksz0ffuLUC91RUviVZfw==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.5", + "array.prototype.flatmap": "^1.3.0", + "doctrine": "^2.1.0", + "estraverse": "^5.3.0", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.5", + "object.fromentries": "^2.0.5", + "object.hasown": "^1.1.1", + "object.values": "^1.1.5", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.3", + "semver": "^6.3.0", + "string.prototype.matchall": "^4.0.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", + "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "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, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.4", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz", + "integrity": "sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.9.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-testing-library": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.7.0.tgz", + "integrity": "sha512-pI8LKtFiAflBpN4h14vTtfhKqLwtIW40TNhWyw0ckqHm0W/J0VmYtThoxpTAdHrvEWnkALSG1Z8ABBkIncMIHA==", + "dev": true, + "dependencies": { + "@typescript-eslint/utils": "^5.13.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0", + "npm": ">=6" + }, + "peerDependencies": { + "eslint": "^7.5.0 || ^8.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", + "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^2.0.0" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=5" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "engines": { + "node": ">=10" } }, "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==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", + "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "node_modules/eslint-webpack-plugin": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz", + "integrity": "sha512-avrKcGncpPbPSUHX6B3stNGzkKFto3eL+DKM4+VyMrVnhPc3vRczVlCq3uhuFOdRvDHTVXuzwk1ZKUrqDQHQ9w==", "dev": true, - "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "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": { - "brace-expansion": "^1.1.7" + "@types/eslint": "^7.29.0 || ^8.4.1", + "jest-worker": "^28.0.2", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0" }, "engines": { - "node": "*" + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0", + "webpack": "^5.0.0" + } + }, + "node_modules/eslint-webpack-plugin/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/eslint-webpack-plugin/node_modules/jest-worker": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-28.1.3.tgz", + "integrity": "sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/eslint-webpack-plugin/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==", + "dev": true + }, + "node_modules/eslint-webpack-plugin/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/eslint-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/eslint/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 + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/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, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/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, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.17.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.17.0.tgz", + "integrity": "sha512-1C+6nQRb1GwGMKm2dH/E7enFAMxGTmGI7/dEdhy/DNelv85w9B72t3uc5frtMNXIbzrarJJ/lTCjcaZwbLJmyw==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/eslint/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, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/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, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/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, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "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==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.0.tgz", + "integrity": "sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.9.0", + "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" + "eslint-visitor-keys": "^3.3.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -6142,11 +8543,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/espree/node_modules/acorn": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", + "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "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", + "dev": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -6156,11 +8569,10 @@ } }, "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==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", + "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -6173,7 +8585,6 @@ "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": { "estraverse": "^5.2.0" }, @@ -6186,57 +8597,256 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, "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": { - "@types/estree": "^1.0.0" - } + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true }, "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", + "dev": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, "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" + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "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" + "integrity": "sha1-KueOhdmJQVhnCwPUe+wfA72Ru50=" }, - "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==", + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", "dev": true, - "license": "Apache-2.0", "engines": { - "node": ">=12.0.0" + "node": ">= 0.8.0" } }, + "node_modules/expect": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/expect/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/expect/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/expect/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/expect/node_modules/jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/express": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.1.tgz", + "integrity": "sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==", + "dev": true, + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.0", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.10.3", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "dev": true + }, + "node_modules/express/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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "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", + "dev": true, "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", @@ -6246,98 +8856,74 @@ "node": ">=4" } }, - "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" - }, - "engines": { - "node": ">=0.10.0" - } - }, "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" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "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==", + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", "dev": true, - "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.8" + "micromatch": "^4.0.4" }, "engines": { "node": ">=8.6.0" } }, - "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": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "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" + "dev": true }, "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", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" + "dev": true }, "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", + "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", "dev": true, - "license": "ISC", "dependencies": { "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "dependencies": { + "bser": "2.1.1" + } + }, "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", + "dev": true, "dependencies": { "escape-string-regexp": "^1.0.5" }, @@ -6348,21 +8934,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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" - } - }, "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": { "flat-cache": "^3.0.4" }, @@ -6370,11 +8946,48 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "dev": true, + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/file-loader/node_modules/schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "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" }, @@ -6382,20 +8995,34 @@ "node": ">= 10" } }, + "node_modules/file-selector/node_modules/tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" + }, "node_modules/filelist": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", - "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", - "license": "Apache-2.0", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, "dependencies": { "minimatch": "^5.0.1" } }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "license": "ISC", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz", + "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==", + "dev": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -6403,11 +9030,20 @@ "node": ">=10" } }, + "node_modules/filesize": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", + "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, "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", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -6416,17 +9052,12 @@ } }, "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", - "peer": true, + "version": "4.20.9", + "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.9.tgz", + "integrity": "sha512-shA1X/7v8RmukWMNRHx0l7+Bm41hOivY78IvOiBrPVHjyWFIyqqIEMCz7yTVRc9Ea+EU4WkZ5r4MH6whSo5taw==", "dependencies": { "@babel/runtime": "^7.10.0" }, - "engines": { - "node": ">=8" - }, "funding": { "type": "opencollective", "url": "https://opencollective.com/final-form" @@ -6436,38 +9067,80 @@ "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", - "peer": true, "peerDependencies": { "final-form": "^4.20.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==", + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", "dev": true, - "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "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==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", "path-exists": "^4.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=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==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", "dev": true, - "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", + "flatted": "^3.1.0", "rimraf": "^3.0.2" }, "engines": { @@ -6475,60 +9148,179 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", + "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", + "dev": true + }, + "node_modules/follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", "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" - }, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], "engines": { - "node": ">= 0.4" + "node": ">=4.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependenciesMeta": { + "debug": { + "optional": true + } } }, - "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", + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz", + "integrity": "sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA==", + "dev": true, "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "chokidar": "^3.4.2", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "glob": "^7.1.6", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" }, "engines": { - "node": ">=14" + "node": ">=10", + "yarn": ">=1.0.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependencies": { + "eslint": ">= 6", + "typescript": ">= 2.7", + "vue-template-compiler": "*", + "webpack": ">= 4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + }, + "vue-template-compiler": { + "optional": true + } } }, - "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", + "node_modules/fork-ts-checker-webpack-plugin/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, "engines": { - "node": ">=14" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "dev": true, + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", + "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/infusion" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "engines": { + "node": ">= 0.6" } }, "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", + "dev": true, "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", @@ -6539,19 +9331,23 @@ "node": ">=10" } }, + "node_modules/fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "dev": true + }, "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" + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true }, "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", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "optional": true, "os": [ "darwin" @@ -6561,26 +9357,19 @@ } }, "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" - } + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "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", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", + "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.0", + "functions-have-names": "^1.2.2" }, "engines": { "node": ">= 0.4" @@ -6593,60 +9382,36 @@ "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" } }, - "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" - } - }, "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", + "dev": true, "engines": { "node": ">=6.9.0" } }, - "node_modules/get-east-asian-width": { - "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", + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "6.* || 8.* || >= 10.*" } }, "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", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", + "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.3" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6655,37 +9420,42 @@ "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" + "integrity": "sha512-2MSPMu7S1iOTL+BOa6K1S62hB2zUAYNF/lV0gSVlOaacd087lc6nR1H1r0e3B1CerTo+RceOmi1iJW+vp21xcQ==" }, "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" + "dev": true }, - "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", + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "pump": "^3.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=6" } }, "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", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -6695,87 +9465,47 @@ } }, "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", + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", "dev": true, - "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", - "minimatch": "^3.1.1", + "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" }, "engines": { "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" } }, "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==", + "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": { - "is-glob": "^4.0.3" + "is-glob": "^4.0.1" }, "engines": { - "node": ">=10.13.0" + "node": ">= 6" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "node_modules/global-dirs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-2.1.0.tgz", + "integrity": "sha512-MG6kdOUh/xBnyo9cJFeIKkLEc1AyFq42QTU4XiX51i2NEdxLxLWXIjEjmqKeSuKR7pAZjTqUVoT2b2huxVLgYQ==", "dev": true, - "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "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": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "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" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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": { - "type-fest": "^0.20.2" + "ini": "1.3.7" }, "engines": { "node": ">=8" @@ -6784,20 +9514,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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", + "node_modules/global-dirs/node_modules/ini": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz", + "integrity": "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==", + "dev": true + }, + "node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" + "global-prefix": "^3.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" } }, "node_modules/globby": { @@ -6805,7 +9572,6 @@ "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, - "license": "MIT", "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", @@ -6821,67 +9587,91 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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" + "node_modules/got": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", + "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", + "dev": true, + "dependencies": { + "@sindresorhus/is": "^0.14.0", + "@szmarczak/http-timer": "^1.1.2", + "cacheable-request": "^6.0.0", + "decompress-response": "^3.3.0", + "duplexer3": "^0.1.4", + "get-stream": "^4.1.0", + "lowercase-keys": "^1.0.1", + "mimic-response": "^1.0.1", + "p-cancelable": "^1.0.0", + "to-readable-stream": "^1.0.0", + "url-parse-lax": "^3.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=8.6" } }, "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" + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "dev": true }, - "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/grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "dev": true }, - "node_modules/happy-dom": { - "version": "20.8.9", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", - "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", "dev": true, - "license": "MIT", - "peer": true, "dependencies": { - "@types/node": ">=20.0.0", - "@types/whatwg-mimetype": "^3.0.2", - "@types/ws": "^8.18.1", - "entities": "^7.0.1", - "whatwg-mimetype": "^3.0.0", - "ws": "^8.18.3" + "duplexer": "^0.1.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true + }, "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", + "dev": true, "engines": { "node": ">=6" } }, - "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" + "node_modules/harmony-reflect": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", + "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==", + "dev": true + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dependencies": { + "function-bind": "^1.1.1" }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -6890,43 +9680,26 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", + "dev": true, "engines": { "node": ">=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", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", + "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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" - }, - "engines": { - "node": ">= 0.4" + "get-intrinsic": "^1.1.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "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", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", "engines": { "node": ">= 0.4" }, @@ -6935,12 +9708,11 @@ } }, "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", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", "dependencies": { - "has-symbols": "^1.0.3" + "has-symbols": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -6949,24 +9721,28 @@ "url": "https://github.com/sponsors/ljharb" } }, - "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" - }, + "node_modules/has-yarn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz", + "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==", + "dev": true, "engines": { - "node": ">= 0.4" + "node": ">=8" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" } }, "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", - "peer": true, "dependencies": { "@babel/runtime": "^7.1.2", "loose-envify": "^1.2.0", @@ -6980,145 +9756,428 @@ "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" } }, - "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/hoopy": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", + "integrity": "sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==", + "dev": true, + "engines": { + "node": ">= 6.0.0" + } }, "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" + "dev": true + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } }, "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==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", + "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", "dev": true, - "license": "MIT", "dependencies": { - "whatwg-encoding": "^3.1.1" + "whatwg-encoding": "^1.0.5" }, "engines": { - "node": ">=18" + "node": ">=10" } }, + "node_modules/html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "dev": true + }, "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" + "dev": true }, - "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==", + "node_modules/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", "dev": true, - "license": "MIT", "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" }, "engines": { - "node": ">= 14" + "node": ">=12" } }, - "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==", + "node_modules/html-webpack-plugin": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz", + "integrity": "sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw==", "dev": true, - "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "webpack": "^5.20.0" + } + }, + "node_modules/htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/htmlparser2/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/htmlparser2/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", + "dev": true + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", "debug": "4" }, "engines": { - "node": ">= 14" + "node": ">= 6" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", + "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "dev": true, + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/http-proxy-middleware/node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/http-proxy/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" } }, "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" + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", + "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==" }, "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==", + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, - "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "safer-buffer": ">= 2.1.2 < 3" }, "engines": { "node": ">=0.10.0" } }, - "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/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/identity-obj-proxy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", + "integrity": "sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ=", + "dev": true, + "dependencies": { + "harmony-reflect": "^1.4.6" + }, + "engines": { + "node": ">=4" + } }, "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", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" + "dev": true }, "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", + "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", "dev": true, - "license": "MIT", "engines": { "node": ">= 4" } }, + "node_modules/immer": { + "version": "9.0.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.15.tgz", + "integrity": "sha512-2eB/sswms9AEUSkOm4SbV5Y7Vmt/bKRwByd52jfLkW4OLYeaTP3EEiJ9agqU0O/tq6Dk62Zfj+TJSqfm1rLVGQ==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "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", + "version": "4.0.0-rc.12", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0-rc.12.tgz", + "integrity": "sha512-0M2XxkZLx/mi3t8NVwIm1g8nHoEmM9p9UBl/G9k4+hm0kBgOVdMV/B3CY5dQ8qG8qc80NN4gDV4HQv6FTJ5q7A==", "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==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", "dev": true, - "license": "MIT", "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" }, "engines": { "node": ">=6" + } + }, + "node_modules/import-lazy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", + "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -7127,38 +10186,42 @@ "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true, - "license": "MIT", "engines": { "node": ">=0.8.19" } }, + "node_modules/indefinite-observable": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/indefinite-observable/-/indefinite-observable-2.0.1.tgz", + "integrity": "sha512-G8vgmork+6H9S8lUAg1gtXEj2JxIQTo0g2PbFiYOdjkziSI0F7UYBiVwhZRuixhBCNGczAls34+5HJPyZysvxQ==", + "dependencies": { + "symbol-observable": "1.2.0" + } + }, "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", + "dev": true, "engines": { "node": ">=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" - } + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.1.tgz", + "integrity": "sha512-dldYtl2WlN0QDkIDtg8+xFwOS2Tbmp12t1cHa5/YClU6ZQjTFm7B66UcVbh9NQB+HvT5BAd2t5+yKsBkw5pcqA==", + "engines": [ + "node >= 0.4.0" + ] }, "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.", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "dev": true, - "license": "ISC", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -7168,22 +10231,19 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "dev": true }, "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" - } + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true }, "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", + "dev": true, "dependencies": { "ansi-escapes": "^4.2.1", "chalk": "^4.1.0", @@ -7203,89 +10263,56 @@ "node": ">=8.0.0" } }, - "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", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "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==", + "node_modules/inquirer/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "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", + "node_modules/internal-slot": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", + "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "get-intrinsic": "^1.1.0", + "has": "^1.0.3", + "side-channel": "^1.0.4" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ipaddr.js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz", + "integrity": "sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==", + "dev": true, + "engines": { + "node": ">= 10" } }, "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", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true }, "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", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "has-bigints": "^1.0.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7295,7 +10322,7 @@ "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", + "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -7304,13 +10331,12 @@ } }, "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", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -7323,7 +10349,6 @@ "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" }, @@ -7331,111 +10356,85 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "dependencies": { + "ci-info": "^2.0.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, "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", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.10.0.tgz", + "integrity": "sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==", + "dev": true, "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" + "has": "^1.0.3" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "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", - "has-tostringtag": "^1.0.2" - }, + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", "engines": { "node": ">= 0.4" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, "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", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true, "engines": { "node": ">=0.10.0" } }, - "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" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "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", + "dev": true, "engines": { "node": ">=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", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, "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", + "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -7446,90 +10445,57 @@ "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" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=" }, "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", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz", + "integrity": "sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g==", + "dev": true, "dependencies": { - "global-directory": "^4.0.1", - "is-path-inside": "^4.0.0" + "global-dirs": "^2.0.1", + "is-path-inside": "^3.0.1" }, "engines": { - "node": ">=18" + "node": ">=8" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "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", + "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, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=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", + "dev": true, "engines": { "node": ">=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" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-mobile": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-2.2.2.tgz", - "integrity": "sha512-wW/SXnYJkTjs++tVK5b6kVITZpAZPtUrt9SF80vvxGiF/Oywal+COk1jlRkiVq15RFNEQKQY31TkV24/1T5cVg==", - "license": "MIT" + "integrity": "sha512-wW/SXnYJkTjs++tVK5b6kVITZpAZPtUrt9SF80vvxGiF/Oywal+COk1jlRkiVq15RFNEQKQY31TkV24/1T5cVg==" }, "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" + "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", + "dev": true }, "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", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", "engines": { "node": ">= 0.4" }, @@ -7538,34 +10504,29 @@ } }, "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", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-4.0.0.tgz", + "integrity": "sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig==", + "dev": true, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=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", + "dev": true, "engines": { "node": ">=0.12.0" } }, "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", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "has-tostringtag": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -7575,20 +10536,10 @@ } }, "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" - } - }, - "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==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } @@ -7596,8 +10547,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", + "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -7606,19 +10557,15 @@ "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" + "dev": true }, "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", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -7630,34 +10577,27 @@ "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", + "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=", + "dev": true, "engines": { "node": ">=0.10.0" } }, - "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", + "node_modules/is-root": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", + "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==", + "dev": true, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, "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", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", + "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" + "call-bind": "^1.0.2" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7667,7 +10607,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", + "dev": true, "engines": { "node": ">=8" }, @@ -7676,13 +10616,11 @@ } }, "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", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "has-tostringtag": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -7692,42 +10630,27 @@ } }, "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", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" + "has-symbols": "^1.0.1" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "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" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true }, "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", + "dev": true, "engines": { "node": ">=10" }, @@ -7735,92 +10658,113 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "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", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" + "call-bind": "^1.0.2" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "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", + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "is-docker": "^2.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, + "node_modules/is-yarn-global": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz", + "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==", + "dev": true + }, "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" + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" + "dev": true }, "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==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", + "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", "dev": true, - "license": "BSD-3-Clause", "engines": { "node": ">=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==", + "node_modules/istanbul-lib-instrument": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.0.tgz", + "integrity": "sha512-6Lthe1hqXHBNsqvgDzGO6l03XNeu3CrG4RqQ1KM9+l5+jNGpEJfIELx1NS3SEHmJQA8np/u+E4EPRKRiu6m19A==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", + "make-dir": "^3.0.0", "supports-color": "^7.1.0" }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, "engines": { "node": ">=10" } }, "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==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz", + "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" @@ -7829,48 +10773,16 @@ "node": ">=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": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/jackspeak": { - "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": "^9.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "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", + "version": "10.8.5", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz", + "integrity": "sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==", + "dev": true, "dependencies": { - "async": "^3.2.6", - "filelist": "^1.0.4", - "picocolors": "^1.1.1" + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.1", + "minimatch": "^3.0.4" }, "bin": { "jake": "bin/cli.js" @@ -7879,126 +10791,2210 @@ "node": ">=10" } }, - "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==", + "node_modules/jake/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, - "license": "MIT", "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "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": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=18" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", + "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", + "dev": true, + "dependencies": { + "@jest/core": "^27.5.1", + "import-local": "^3.0.2", + "jest-cli": "^27.5.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" }, "peerDependencies": { - "canvas": "^3.0.0" + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "peerDependenciesMeta": { - "canvas": { + "node-notifier": { "optional": true } } }, - "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==", + "node_modules/jest-changed-files": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz", + "integrity": "sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "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" + "dependencies": { + "@jest/types": "^27.5.1", + "execa": "^5.0.0", + "throat": "^6.0.1" }, "engines": { - "node": ">=6" + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "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==", + "node_modules/jest-changed-files/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", "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-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", - "js-yaml": "^3.13.1", - "ono": "^6.0.0" + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "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", + "node_modules/jest-changed-files/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, "dependencies": { - "sprintf-js": "~1.0.2" + "@types/yargs-parser": "*" } }, - "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", + "node_modules/jest-changed-files/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-circus": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.5.1.tgz", + "integrity": "sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^0.7.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-circus/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-circus/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, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-circus/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==", + "dev": true + }, + "node_modules/jest-cli": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.5.1.tgz", + "integrity": "sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==", + "dev": true, + "dependencies": { + "@jest/core": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "import-local": "^3.0.2", + "jest-config": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "prompts": "^2.0.1", + "yargs": "^16.2.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-cli/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-cli/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-cli/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-config": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.5.1.tgz", + "integrity": "sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.8.0", + "@jest/test-sequencer": "^27.5.1", + "@jest/types": "^27.5.1", + "babel-jest": "^27.5.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.1", + "graceful-fs": "^4.2.9", + "jest-circus": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-jasmine2": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-config/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-config/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-config/node_modules/ci-info": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.4.0.tgz", + "integrity": "sha512-t5QdPT5jq3o262DOQ8zA6E1tlH2upmUc4Hlvrbx1pGYJuiiHl7O7rvVNI+l8HTVhd/q3Qc9vqimkNk5yiXsAug==", + "dev": true + }, + "node_modules/jest-config/node_modules/jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-config/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, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-config/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-config/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==", + "dev": true + }, + "node_modules/jest-diff": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", + "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^26.6.2", + "jest-get-type": "^26.3.0", + "pretty-format": "^26.6.2" + }, + "engines": { + "node": ">= 10.14.2" + } + }, + "node_modules/jest-diff/node_modules/chalk": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", + "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-docblock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.5.1.tgz", + "integrity": "sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==", + "dev": true, + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", + "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-each/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each/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, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-each/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==", + "dev": true + }, + "node_modules/jest-environment-jsdom": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz", + "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1", + "jsdom": "^16.6.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-environment-jsdom/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-environment-node": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz", + "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-environment-node/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-environment-node/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-get-type": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", + "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true, + "engines": { + "node": ">= 10.14.2" + } + }, + "node_modules/jest-haste-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", + "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^27.5.1", + "jest-serializer": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "micromatch": "^4.0.4", + "walker": "^1.0.7" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-haste-map/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-haste-map/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-haste-map/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-haste-map/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-haste-map/node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jest-jasmine2": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", + "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-jasmine2/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-jasmine2/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, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-jasmine2/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==", + "dev": true + }, + "node_modules/jest-leak-detector": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", + "integrity": "sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==", + "dev": true, + "dependencies": { + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-leak-detector/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-leak-detector/node_modules/jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-leak-detector/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, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-leak-detector/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==", + "dev": true + }, + "node_modules/jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/diff-sequences": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils/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, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/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==", + "dev": true + }, + "node_modules/jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-message-util/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-message-util/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-message-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-message-util/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, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-message-util/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/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==", + "dev": true + }, + "node_modules/jest-mock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-mock/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-mock/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-mock/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", + "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", + "dev": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", + "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "resolve": "^1.20.0", + "resolve.exports": "^1.1.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz", + "integrity": "sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-snapshot": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve-dependencies/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve-dependencies/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-resolve-dependencies/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-resolve/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-resolve/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-runner": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.5.1.tgz", + "integrity": "sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==", + "dev": true, + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-leak-detector": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "source-map-support": "^0.5.6", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runner/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runner/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-runner/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-runner/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-runner/node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jest-runtime": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", + "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/globals": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "execa": "^5.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runtime/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runtime/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-runtime/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-serializer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", + "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==", + "dev": true, + "dependencies": { + "@types/node": "*", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", + "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.7.2", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", + "@babel/types": "^7.0.0", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__traverse": "^7.0.4", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "natural-compare": "^1.4.0", + "pretty-format": "^27.5.1", + "semver": "^7.3.2" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-snapshot/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-snapshot/node_modules/diff-sequences": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/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, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-snapshot/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==", + "dev": true + }, + "node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-util/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-util/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-util/node_modules/ci-info": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.4.0.tgz", + "integrity": "sha512-t5QdPT5jq3o262DOQ8zA6E1tlH2upmUc4Hlvrbx1pGYJuiiHl7O7rvVNI+l8HTVhd/q3Qc9vqimkNk5yiXsAug==", + "dev": true + }, + "node_modules/jest-validate": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", + "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "leven": "^3.1.0", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-validate/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-validate/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-validate/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-validate/node_modules/jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-validate/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, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-validate/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-validate/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==", + "dev": true + }, + "node_modules/jest-watch-typeahead": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jest-watch-typeahead/-/jest-watch-typeahead-1.1.0.tgz", + "integrity": "sha512-Va5nLSJTN7YFtC2jd+7wsoe1pNe5K4ShLux/E5iHEwlB9AxaxmggY7to9KUqKojhaJw3aXqt5WAb4jGPOolpEw==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.3.1", + "chalk": "^4.0.0", + "jest-regex-util": "^28.0.0", + "jest-watcher": "^28.0.0", + "slash": "^4.0.0", + "string-length": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "jest": "^27.0.0 || ^28.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/console": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-28.1.3.tgz", + "integrity": "sha512-QPAkP5EwKdK/bxIr6C1I4Vs0rm2nHiANzj/Z5X2JQkrZo6IqvC4ldZ9K95tF0HdidhA8Bo6egxSzUFPYKcEXLw==", + "dev": true, + "dependencies": { + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^28.1.3", + "jest-util": "^28.1.3", + "slash": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/console/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/test-result": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-28.1.3.tgz", + "integrity": "sha512-kZAkxnSE+FqE8YjW8gNuoVkkC9I7S1qmenl8sGcDOLropASP+BkcGKwhXoyqQuGOGeYY0y/ixjrd/iERpEXHNg==", + "dev": true, + "dependencies": { + "@jest/console": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@jest/types": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-28.1.3.tgz", + "integrity": "sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/@types/yargs": { + "version": "17.0.13", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.13.tgz", + "integrity": "sha512-9sWaruZk2JGxIQU+IhI1fhPYRcQ0UuTNuKuCW9bR5fp7qi2Llf7WDzNa17Cy7TKnh3cdxDOiyTu6gaLS0eDatg==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-watch-typeahead/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/ci-info": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.4.0.tgz", + "integrity": "sha512-t5QdPT5jq3o262DOQ8zA6E1tlH2upmUc4Hlvrbx1pGYJuiiHl7O7rvVNI+l8HTVhd/q3Qc9vqimkNk5yiXsAug==", + "dev": true + }, + "node_modules/jest-watch-typeahead/node_modules/emittery": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.10.2.tgz", + "integrity": "sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-message-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-28.1.3.tgz", + "integrity": "sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^28.1.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^28.1.3", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-message-util/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-regex-util": { + "version": "28.0.2", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-28.0.2.tgz", + "integrity": "sha512-4s0IgyNIy0y9FK+cjoVYoxamT7Zeo7MhzqRGx7YDYmaQn1wucY9rotiGkBzzcMXTtjrCAP/f7f+E0F7+fxPNdw==", + "dev": true, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-28.1.3.tgz", + "integrity": "sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ==", + "dev": true, + "dependencies": { + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-watcher": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-28.1.3.tgz", + "integrity": "sha512-t4qcqj9hze+jviFPUN3YAtAEeFnr/azITXQEMARf5cMwKY2SMBRnCQTXLixTl20OR6mLh9KLMrgVJgJISym+1g==", + "dev": true, + "dependencies": { + "@jest/test-result": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.10.2", + "jest-util": "^28.1.3", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-watcher/node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-watch-typeahead/node_modules/jest-watcher/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-watch-typeahead/node_modules/pretty-format": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", + "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "dev": true, + "dependencies": { + "@jest/schemas": "^28.1.3", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-watch-typeahead/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "node_modules/jest-watch-typeahead/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watch-typeahead/node_modules/string-length": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz", + "integrity": "sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==", + "dev": true, + "dependencies": { + "char-regex": "^2.0.0", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watch-typeahead/node_modules/string-length/node_modules/char-regex": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-2.0.1.tgz", + "integrity": "sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw==", + "dev": true, + "engines": { + "node": ">=12.20" + } + }, + "node_modules/jest-watch-typeahead/node_modules/strip-ansi": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", + "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/jest-watcher": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.5.1.tgz", + "integrity": "sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==", + "dev": true, + "dependencies": { + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "jest-util": "^27.5.1", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-watcher/node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-watcher/node_modules/@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/jest-watcher/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-worker": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", + "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/js-sdsl": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.1.4.tgz", + "integrity": "sha512-Y2/yD55y5jteOAmY50JbUZYwk3CP3wnLPEZnlR1w9oKhITrBEtAxwuWKebFf8hMrPMgbYwFoWK/lH2sBkErELw==", + "dev": true + }, + "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==" + }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -8007,24 +13003,105 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", + "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", + "dev": true, + "dependencies": { + "abab": "^2.0.5", + "acorn": "^8.2.4", + "acorn-globals": "^6.0.0", + "cssom": "^0.4.4", + "cssstyle": "^2.3.0", + "data-urls": "^2.0.0", + "decimal.js": "^10.2.1", + "domexception": "^2.0.1", + "escodegen": "^2.0.0", + "form-data": "^3.0.0", + "html-encoding-sniffer": "^2.0.1", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.0.0", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^2.0.0", + "webidl-conversions": "^6.1.0", + "whatwg-encoding": "^1.0.5", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.5.0", + "ws": "^7.4.6", + "xml-name-validator": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/acorn": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", + "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", + "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=", + "dev": true + }, + "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==", + "dev": true + }, "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" + "dev": true }, "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" + "dev": true }, "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", + "dev": true, "bin": { "json5": "lib/cli.js" }, @@ -8036,224 +13113,190 @@ "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" } }, "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", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, "dependencies": { + "graceful-fs": "^4.1.6", "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "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" } }, "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", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss/-/jss-10.6.0.tgz", + "integrity": "sha512-n7SHdCozmxnzYGXBHe0NsO0eUf9TvsHVq2MXvi4JmTn3x5raynodDVE/9VQmBdWFyyj9HpHZ2B4xNZ7MMy7lkw==", "dependencies": { "@babel/runtime": "^7.3.1", "csstype": "^3.0.2", + "indefinite-observable": "^2.0.1", "is-in-browser": "^1.1.3", "tiny-warning": "^1.0.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/jss" } }, "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", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.6.0.tgz", + "integrity": "sha512-JdLpA3aI/npwj3nDMKk308pvnhoSzkW3PXlbgHAzfx0yHWnPPVUjPhXFtLJzgKZge8lsfkUxvYSQ3X2OYIFU6A==", "dependencies": { "@babel/runtime": "^7.3.1", "hyphenate-style-name": "^1.0.3", - "jss": "10.10.0" + "jss": "10.6.0" } }, "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", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.6.0.tgz", + "integrity": "sha512-7y4cAScMHAxvslBK2JRK37ES9UT0YfTIXWgzUWD5euvR+JR3q+o8sQKzBw7GmkQRfZijrRJKNTiSt1PBsLI9/w==", "dependencies": { "@babel/runtime": "^7.3.1", - "jss": "10.10.0" + "jss": "10.6.0" } }, "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", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.6.0.tgz", + "integrity": "sha512-I3w7ji/UXPi3VuWrTCbHG9rVCgB4yoBQLehGDTmsnDfXQb3r1l3WIdcO8JFp9m0YMmyy2CU7UOV6oPI7/Tmu+w==", "dependencies": { "@babel/runtime": "^7.3.1", - "jss": "10.10.0" + "jss": "10.6.0" } }, "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", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.6.0.tgz", + "integrity": "sha512-fOFQWgd98H89E6aJSNkEh2fAXquC9aZcAVjSw4q4RoQ9gU++emg18encR4AT4OOIFl4lQwt5nEyBBRn9V1Rk8g==", "dependencies": { "@babel/runtime": "^7.3.1", - "jss": "10.10.0", + "jss": "10.6.0", "tiny-warning": "^1.0.2" } }, "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", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.6.0.tgz", + "integrity": "sha512-oMCe7hgho2FllNc60d9VAfdtMrZPo9n1Iu6RNa+3p9n0Bkvnv/XX5San8fTPujrTBScPqv9mOE0nWVvIaohNuw==", "dependencies": { "@babel/runtime": "^7.3.1", - "jss": "10.10.0" + "jss": "10.6.0" } }, "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", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.6.0.tgz", + "integrity": "sha512-TKFqhRTDHN1QrPTMYRlIQUOC2FFQb271+AbnetURKlGvRl/eWLswcgHQajwuxI464uZk91sPiTtdGi7r7XaWfA==", "dependencies": { "@babel/runtime": "^7.3.1", - "jss": "10.10.0", + "jss": "10.6.0", "tiny-warning": "^1.0.2" } }, "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", + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.6.0.tgz", + "integrity": "sha512-doJ7MouBXT1lypLLctCwb4nJ6lDYqrTfVS3LtXgox42Xz0gXusXIIDboeh6UwnSmox90QpVnub7au8ybrb0krQ==", "dependencies": { "@babel/runtime": "^7.3.1", "css-vendor": "^2.0.8", - "jss": "10.10.0" + "jss": "10.6.0" } }, "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" + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz", + "integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==" }, "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==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", + "integrity": "sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==", "dev": true, - "license": "MIT", "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" + "array-includes": "^3.1.5", + "object.assign": "^4.1.3" }, "engines": { "node": ">=4.0" } }, "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" - } + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" }, "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==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", + "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", "dev": true, - "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "json-buffer": "3.0.0" } }, "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", + "dev": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/ky": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz", - "integrity": "sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==", - "license": "MIT", + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/ky?sponsor=1" + "node": ">=6" + } + }, + "node_modules/klona": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz", + "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==", + "dev": true, + "engines": { + "node": ">= 8" } }, "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" + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", + "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==", + "dev": true }, "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==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", + "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==", "dev": true, - "license": "MIT", "dependencies": { - "language-subtag-registry": "^0.3.20" - }, - "engines": { - "node": ">=0.10" + "language-subtag-registry": "~0.3.2" } }, "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", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz", + "integrity": "sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==", + "dev": true, "dependencies": { - "package-json": "^10.0.0" + "package-json": "^6.3.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=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", + "dev": true, "engines": { "node": ">=6" } @@ -8263,7 +13306,6 @@ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, - "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -8272,70 +13314,111 @@ "node": ">= 0.8.0" } }, - "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==", + "node_modules/lilconfig": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz", + "integrity": "sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==", "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, "engines": { "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", + "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", + "dev": true + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=8.9.0" + } + }, + "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==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "license": "MIT" + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "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" + "dev": true }, "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" + "integrity": "sha1-Jmcm3dUo+FTyH06pigZWBuD7xrA=" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true }, "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" + "dev": true + }, + "node_modules/lodash.pick": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", + "integrity": "sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM=" }, "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" + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", + "dev": true }, "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" + "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true }, "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", + "dev": true, "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" @@ -8347,11 +13430,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "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" }, @@ -8359,68 +13457,68 @@ "loose-envify": "cli.js" } }, - "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", + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, "dependencies": { - "yallist": "^3.0.2" + "tslib": "^2.0.3" + } + }, + "node_modules/lower-case/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true + }, + "node_modules/lowercase-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" } }, "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==", + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", + "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=", "dev": true, - "license": "MIT", "bin": { "lz-string": "bin/bin.js" } }, "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==", + "version": "0.25.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", + "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", "dev": true, - "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "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.29.0", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" + "sourcemap-codec": "^1.4.4" } }, "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==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", "dev": true, - "license": "MIT", "dependencies": { - "semver": "^7.5.3" + "semver": "^6.0.0" }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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" }, @@ -8428,20 +13526,68 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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", + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, + "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==", + "dev": true, "engines": { - "node": ">= 0.4" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdn-data": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", + "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==", + "dev": true + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.7.tgz", + "integrity": "sha512-ygaiUSNalBX85388uskeCyhSAoOSgzBbtVCr9jA2RROssFL9Q19/ZXFqS+2Th2sr1ewNIWgFdLzLC3Yl1Zv+lw==", + "dev": true, + "dependencies": { + "fs-monkey": "^1.0.3" + }, + "engines": { + "node": ">= 4.0.0" } }, "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", + "dev": true, "dependencies": { "@types/minimist": "^1.2.0", "camelcase-keys": "^6.2.2", @@ -8462,11 +13608,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/meow/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==", + "dev": true, + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/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==", + "dev": true, + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/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==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/meow/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==", + "dev": true, + "engines": { + "node": ">=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)", + "dev": true, "engines": { "node": ">=10" }, @@ -8474,78 +13670,219 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "dev": true + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, "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": { "node": ">= 8" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", "dev": true, - "license": "MIT", "dependencies": { - "braces": "^3.0.3", + "braces": "^3.0.2", "picomatch": "^2.3.1" }, "engines": { "node": ">=8.6" } }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "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", + "dev": true, "engines": { "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "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", + "dev": true, "engines": { "node": ">=4" } }, - "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", + "node_modules/mini-create-react-context": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz", + "integrity": "sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==", "dependencies": { - "brace-expansion": "^2.0.1" + "@babel/runtime": "^7.12.1", + "tiny-warning": "^1.0.3" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.6.1.tgz", + "integrity": "sha512-wd+SD57/K6DiV7jIR34P+s3uckTRuQvx0tKPcvjFlrEylk6P4mQ2KSWk1hblj1Kxaqok7LogKOieygXqBczNlg==", + "dev": true, + "dependencies": { + "schema-utils": "^4.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">= 12.13.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/mini-css-extract-plugin/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==", + "dev": true + }, + "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, "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" - } + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "dev": true }, "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", + "dev": true, "dependencies": { "arrify": "^1.0.1", "is-plain-obj": "^1.1.0", @@ -8555,49 +13892,57 @@ "node": ">= 6" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", + "node_modules/minimist-options/node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "dev": true, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=0.10.0" } }, - "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", - "peer": true, - "engines": { - "node": "*" + "node_modules/mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, + "dependencies": { + "minimist": "^1.2.5" + }, + "bin": { + "mkdirp": "bin/cmd.js" } }, "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" + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } }, "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" + "dev": true }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -8609,14 +13954,12 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" + "dev": true }, "node_modules/navidrome-music-player": { - "version": "4.25.4", - "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.4.tgz", - "integrity": "sha512-5N7N94aJMIAfKZ0EKFfsHD95q2HBTAnAWyju0JflKnF+2u9Gr3qwnp3ZaaIC2K3z2GA69z0hqK0aOQ40FYsFrw==", - "license": "MIT", + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.1.tgz", + "integrity": "sha512-bHYr84ATUf/4+/PUoTpUSmpF4/igBx2UPhgnPqvda4FND+GJZtb1ikbMs1U+mhkNEUebe+2I29ob1zY7YZdtjg==", "dependencies": { "@react-icons/all-files": "^4.1.0", "classnames": "^2.3.1", @@ -8633,60 +13976,75 @@ "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==", + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "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": ">= 0.6" } }, - "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==", + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" } }, + "node_modules/no-case/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true + }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true, + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true + }, "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", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/node-polyglot/-/node-polyglot-2.4.2.tgz", + "integrity": "sha512-AgTVpQ32BQ5XPI+tFHJ9bCYxWwSLvtmEodX8ooftFhEuyCgBG6ijWulIVb7pH3THigtgvc9uLiPn0IO51KHpkg==", "dependencies": { - "hasown": "^2.0.2", - "object.entries": "^1.1.8", + "array.prototype.foreach": "^1.0.0", + "has": "^1.0.3", + "object.entries": "^1.1.4", + "string.prototype.trim": "^1.2.4", "warning": "^4.0.3" - }, - "engines": { - "node": ">= 0.4" } }, "node_modules/node-releases": { - "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" + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz", + "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", + "dev": true }, "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", + "dev": true, "dependencies": { "hosted-git-info": "^2.1.4", "resolve": "^1.10.0", @@ -8694,31 +14052,11 @@ "validate-npm-package-license": "^3.0.1" } }, - "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", - "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/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", + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true, "bin": { "semver": "bin/semver" } @@ -8727,61 +14065,80 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", + "dev": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/nwsapi": { - "version": "2.2.23", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", - "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", "dev": true, - "license": "MIT" + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nth-check": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", + "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", + "dev": true, + "dependencies": { + "boolbase": "~1.0.0" + } + }, + "node_modules/nwsapi": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.2.tgz", + "integrity": "sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==", + "dev": true }, "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", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "engines": { "node": ">=0.10.0" } }, "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", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, "engines": { "node": ">= 6" } }, "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" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", + "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==", "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8790,22 +14147,18 @@ "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" } }, "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", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", + "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "has-symbols": "^1.0.3", "object-keys": "^1.1.1" }, "engines": { @@ -8816,31 +14169,27 @@ } }, "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", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz", + "integrity": "sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g==", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" }, "engines": { "node": ">= 0.4" } }, "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==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.5.tgz", + "integrity": "sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw==", "dev": true, - "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" }, "engines": { "node": ">= 0.4" @@ -8849,17 +14198,42 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object.getownpropertydescriptors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.2.tgz", + "integrity": "sha512-WtxeKSzfBjlzL+F9b7M7hewDzMwy+C8NRssHd1YrNlzHzIDrXcXiNOMrezdAEM4UXixgV+vvnyBeN7Rygl2ttQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/object.hasown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.1.tgz", + "integrity": "sha512-LYLe4tivNQzq4JdaWW6WO3HMZZJWzkkH8fnI6EebWl0VZth2wL2Lovm74ep2/gZzlaTdV62JZHEqHQ2yVn8Q/A==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.4", + "es-abstract": "^1.19.5" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "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==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz", + "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==", "dev": true, - "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" }, "engines": { "node": ">= 0.4" @@ -8868,23 +14242,38 @@ "url": "https://github.com/sponsors/ljharb" } }, - "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==", + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true, + "engines": { + "node": ">= 0.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==", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, - "license": "ISC", "dependencies": { "wrappy": "1" } @@ -8893,36 +14282,43 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", + "dev": true, "dependencies": { "mimic-fn": "^2.1.0" }, "engines": { "node": ">=6" + } + }, + "node_modules/open": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz", + "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==", + "dev": true, + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", "dev": true, - "license": "MIT", "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", - "word-wrap": "^1.2.5" + "word-wrap": "^1.2.3" }, "engines": { "node": ">= 0.8.0" @@ -8932,7 +14328,7 @@ "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "license": "MIT", + "dev": true, "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", @@ -8951,103 +14347,131 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ora/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "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", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true, "engines": { "node": ">=0.10.0" } }, - "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", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, + "node_modules/p-cancelable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", + "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==", + "dev": true, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, "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==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, - "license": "MIT", "dependencies": { - "yocto-queue": "^0.1.0" + "p-try": "^2.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" } }, "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==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, - "license": "MIT", "dependencies": { - "p-limit": "^3.0.2" + "p-limit": "^2.2.0" }, "engines": { - "node": ">=10" + "node": ">=8" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=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", + "dev": true, "engines": { "node": ">=6" } }, "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", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz", + "integrity": "sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==", + "dev": true, "dependencies": { - "ky": "^1.2.0", - "registry-auth-token": "^5.0.2", - "registry-url": "^6.0.1", - "semver": "^7.6.0" + "got": "^9.6.0", + "registry-auth-token": "^4.0.0", + "registry-url": "^5.0.0", + "semver": "^6.2.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "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/package-json/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/param-case/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true }, "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": { "callsites": "^3.0.0" }, @@ -9059,7 +14483,7 @@ "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", + "dev": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -9068,42 +14492,44 @@ }, "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "engines": { + "node": ">= 0.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==", + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" } }, + "node_modules/pascal-case/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true + }, "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", + "dev": true, "engines": { "node": ">=8" } @@ -9111,9 +14537,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==", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -9122,7 +14547,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", + "dev": true, "engines": { "node": ">=8" } @@ -9131,38 +14556,12 @@ "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.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": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "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" - } + "dev": true }, "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", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", "dependencies": { "isarray": "0.0.1" } @@ -9172,29 +14571,27 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" } }, - "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/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "dev": true }, "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" + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "engines": { "node": ">=8.6" }, @@ -9202,25 +14599,103 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", + "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dev": true, + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "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" - } + "integrity": "sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==" }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.4.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.16.tgz", + "integrity": "sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ==", "dev": true, "funding": [ { @@ -9230,95 +14705,1400 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "nanoid": "^3.3.4", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" }, "engines": { "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-attribute-case-insensitive": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.2.tgz", + "integrity": "sha512-XIidXV8fDr0kKt28vqki84fRK8VW8eTuIa4PChv2MqKuT6C9UjmSKzen6KaWhWEoYvwxFCa7n/tC1SZ3tyq4SQ==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-browser-comments": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-browser-comments/-/postcss-browser-comments-4.0.0.tgz", + "integrity": "sha512-X9X9/WN3KIvY9+hNERUqX9gncsgBA25XaeR+jshHz2j8+sYyHktHw1JdKuMjeLpGktXidqDhA7b/qm1mrBDmgg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "browserslist": ">=4", + "postcss": ">=8" + } + }, + "node_modules/postcss-calc": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", + "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.9", + "postcss-value-parser": "^4.2.0" + }, + "peerDependencies": { + "postcss": "^8.2.2" + } + }, + "node_modules/postcss-clamp": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", + "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=7.6.0" + }, + "peerDependencies": { + "postcss": "^8.4.6" + } + }, + "node_modules/postcss-color-functional-notation": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-4.2.4.tgz", + "integrity": "sha512-2yrTAUZUab9s6CpxkxC4rVgFEVaR6/2Pipvi6qcgvnYiVqZcbDHEoBDhrXzyb7Efh2CCfHQNtcqWcIruDTIUeg==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-color-hex-alpha": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-8.0.4.tgz", + "integrity": "sha512-nLo2DCRC9eE4w2JmuKgVA3fGL3d01kGq752pVALF68qpGLmx2Qrk91QTKkdUqqp45T1K1XV8IhQpcu1hoAQflQ==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-color-rebeccapurple": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-7.1.1.tgz", + "integrity": "sha512-pGxkuVEInwLHgkNxUc4sdg4g3py7zUeCQ9sMfwyHAT+Ezk8a4OaaVZ8lIY5+oNqA/BXXgLyXv0+5wHP68R79hg==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-colormin": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.0.tgz", + "integrity": "sha512-WdDO4gOFG2Z8n4P8TWBpshnL3JpmNmJwdnfP2gbk2qBA8PWwOYcmjmI/t3CmMeL72a7Hkd+x/Mg9O2/0rD54Pg==", + "dev": true, + "dependencies": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0", + "colord": "^2.9.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-convert-values": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.2.tgz", + "integrity": "sha512-c6Hzc4GAv95B7suy4udszX9Zy4ETyMCgFPUDtWjdFTKH1SE9eFY/jEpHSwTH1QPuwxHpWslhckUQWbNRM4ho5g==", + "dev": true, + "dependencies": { + "browserslist": "^4.20.3", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-custom-media": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-8.0.2.tgz", + "integrity": "sha512-7yi25vDAoHAkbhAzX9dHx2yc6ntS4jQvejrNcC+csQJAXjj15e7VcWfMgLqBNAbOvqi5uIa9huOVwdHbf+sKqg==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.3" + } + }, + "node_modules/postcss-custom-properties": { + "version": "12.1.9", + "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-12.1.9.tgz", + "integrity": "sha512-/E7PRvK8DAVljBbeWrcEQJPG72jaImxF3vvCNFwv9cC8CzigVoNIpeyfnJzphnN3Fd8/auBf5wvkw6W9MfmTyg==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-custom-selectors": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-6.0.3.tgz", + "integrity": "sha512-fgVkmyiWDwmD3JbpCmB45SvvlCD6z9CG6Ie6Iere22W5aHea6oWa7EM2bpnv2Fj3I94L3VbtvX9KqwSi5aFzSg==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.3" + } + }, + "node_modules/postcss-dir-pseudo-class": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-6.0.5.tgz", + "integrity": "sha512-eqn4m70P031PF7ZQIvSgy9RSJ5uI2171O/OO/zcRNYpJbvaeKFUlar1aJ7rmgiQtbm0FSPsRewjpdS0Oew7MPA==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-discard-comments": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", + "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", + "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-empty": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", + "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", + "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-double-position-gradients": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-3.1.2.tgz", + "integrity": "sha512-GX+FuE/uBR6eskOK+4vkXgT6pDkexLokPaz/AbJna9s5Kzp/yl488pKPjhy0obB475ovfT1Wv8ho7U/cHNaRgQ==", + "dev": true, + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-env-function": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/postcss-env-function/-/postcss-env-function-4.0.6.tgz", + "integrity": "sha512-kpA6FsLra+NqcFnL81TnsU+Z7orGtDTxcOhl6pwXeEq1yFPpRMkCDpHhrz8CFQDr/Wfm0jLiNQ1OsGGPjlqPwA==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-flexbugs-fixes": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-5.0.2.tgz", + "integrity": "sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ==", + "dev": true, + "peerDependencies": { + "postcss": "^8.1.4" + } + }, + "node_modules/postcss-focus-visible": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-6.0.4.tgz", + "integrity": "sha512-QcKuUU/dgNsstIK6HELFRT5Y3lbrMLEOwG+A4s5cA+fx3A3y/JTq3X9LaOj3OC3ALH0XqyrgQIgey/MIZ8Wczw==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-focus-within": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-5.0.4.tgz", + "integrity": "sha512-vvjDN++C0mu8jz4af5d52CB184ogg/sSxAFS+oUJQq2SuCe7T5U2iIsVJtsCp2d6R4j0jr5+q3rPkBVZkXD9fQ==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.9" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-font-variant": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", + "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", + "dev": true, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-gap-properties": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-3.0.5.tgz", + "integrity": "sha512-IuE6gKSdoUNcvkGIqdtjtcMtZIFyXZhmFd5RUlg97iVEvp1BZKV5ngsAjCjrVy+14uhGBQl9tzmi1Qwq4kqVOg==", + "dev": true, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-image-set-function": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-4.0.7.tgz", + "integrity": "sha512-9T2r9rsvYzm5ndsBE8WgtrMlIT7VbtTfE7b3BQnudUqnBcBo7L758oc+o+pdj/dUV0l5wjwSdjeOH2DZtfv8qw==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-import": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-14.1.0.tgz", + "integrity": "sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-initial": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-initial/-/postcss-initial-4.0.1.tgz", + "integrity": "sha512-0ueD7rPqX8Pn1xJIjay0AZeIuDoF+V+VvMt/uOnn+4ezUKhZM/NokDeP6DwMNyIoYByuN/94IQnt5FEkaN59xQ==", + "dev": true, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz", + "integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==", + "dev": true, + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.3.3" + } + }, + "node_modules/postcss-lab-function": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-4.2.1.tgz", + "integrity": "sha512-xuXll4isR03CrQsmxyz92LJB2xX9n+pZJ5jE9JgcnmsCammLyKdlzrBin+25dy6wIjfhJpKBAN80gsTlCgRk2w==", + "dev": true, + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-loader": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz", + "integrity": "sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==", + "dev": true, + "dependencies": { + "cosmiconfig": "^7.0.0", + "klona": "^2.0.5", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + } + }, + "node_modules/postcss-logical": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-5.0.4.tgz", + "integrity": "sha512-RHXxplCeLh9VjinvMrZONq7im4wjWGlRJAqmAVLXyZaXwfDWP73/oq4NdIp+OZwhQUMj0zjqDfM5Fj7qby+B4g==", + "dev": true, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-media-minmax": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-media-minmax/-/postcss-media-minmax-5.0.0.tgz", + "integrity": "sha512-yDUvFf9QdFZTuCUg0g0uNSHVlJ5X1lSzDZjPSFaiCWvjgsvu8vEVxtahPrLMinIDEEGnx6cBe6iqdx5YWz08wQ==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-merge-longhand": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.6.tgz", + "integrity": "sha512-6C/UGF/3T5OE2CEbOuX7iNO63dnvqhGZeUnKkDeifebY0XqkkvrctYSZurpNE902LDf2yKwwPFgotnfSoPhQiw==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^5.1.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-merge-rules": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.2.tgz", + "integrity": "sha512-zKMUlnw+zYCWoPN6yhPjtcEdlJaMUZ0WyVcxTAmw3lkkN/NDMRkOkiuctQEoWAOvH7twaxUUdvBWl0d4+hifRQ==", + "dev": true, + "dependencies": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^3.1.0", + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", + "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", + "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "dev": true, + "dependencies": { + "colord": "^2.9.1", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-params": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.3.tgz", + "integrity": "sha512-bkzpWcjykkqIujNL+EVEPOlLYi/eZ050oImVtHU7b4lFS82jPnsCb44gvC6pxaNt38Els3jWYDHTjHKf0koTgg==", + "dev": true, + "dependencies": { + "browserslist": "^4.16.6", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz", + "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-nested": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.6.tgz", + "integrity": "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.6" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nesting": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-10.2.0.tgz", + "integrity": "sha512-EwMkYchxiDiKUhlJGzWsD9b2zvq/r2SSubcRrgP+jujMXFzqvANLt16lJANC+5uZ6hjI7lpRmI6O8JIl+8l1KA==", + "dev": true, + "dependencies": { + "@csstools/selector-specificity": "^2.0.0", + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-normalize": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize/-/postcss-normalize-10.0.1.tgz", + "integrity": "sha512-+5w18/rDev5mqERcG3W5GZNMJa1eoYYNGo8gB7tEwaos0ajk3ZXAI4mHGcNT47NE+ZnZD1pEpUOFLvltIwmeJA==", + "dev": true, + "dependencies": { + "@csstools/normalize.css": "*", + "postcss-browser-comments": "^4", + "sanitize.css": "*" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "browserslist": ">= 4", + "postcss": ">= 8" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", + "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", + "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz", + "integrity": "sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz", + "integrity": "sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-string": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", + "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", + "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.0.tgz", + "integrity": "sha512-J6M3MizAAZ2dOdSjy2caayJLQT8E8K9XjLce8AUQMwOrCvjCHv24aLC/Lps1R1ylOfol5VIDMaM/Lo9NGlk1SQ==", + "dev": true, + "dependencies": { + "browserslist": "^4.16.6", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", + "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "dev": true, + "dependencies": { + "normalize-url": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", + "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-opacity-percentage": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-1.1.2.tgz", + "integrity": "sha512-lyUfF7miG+yewZ8EAk9XUBIlrHyUE6fijnesuz+Mj5zrIHIEw6KcIZSOk/elVMqzLvREmXB83Zi/5QpNRYd47w==", + "dev": true, + "funding": [ + { + "type": "kofi", + "url": "https://ko-fi.com/mrcgrtz" + }, + { + "type": "liberapay", + "url": "https://liberapay.com/mrcgrtz" + } + ], + "engines": { + "node": "^12 || ^14 || >=16" + } + }, + "node_modules/postcss-ordered-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz", + "integrity": "sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==", + "dev": true, + "dependencies": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-overflow-shorthand": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-3.0.4.tgz", + "integrity": "sha512-otYl/ylHK8Y9bcBnPLo3foYFLL6a6Ak+3EQBPOTR7luMYCOsiVTUk1iLvNf6tVPNGXcoL9Hoz37kpfriRIFb4A==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-page-break": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", + "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", + "dev": true, + "peerDependencies": { + "postcss": "^8" + } + }, + "node_modules/postcss-place": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-7.0.5.tgz", + "integrity": "sha512-wR8igaZROA6Z4pv0d+bvVrvGY4GVHihBCBQieXFY3kuSuMyOmEnnfFzHl/tQuqHZkfkIVBEbDvYcFfHmpSet9g==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-preset-env": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.8.2.tgz", + "integrity": "sha512-rSMUEaOCnovKnwc5LvBDHUDzpGP+nrUeWZGWt9M72fBvckCi45JmnJigUr4QG4zZeOHmOCNCZnd2LKDvP++ZuQ==", + "dev": true, + "dependencies": { + "@csstools/postcss-cascade-layers": "^1.1.0", + "@csstools/postcss-color-function": "^1.1.1", + "@csstools/postcss-font-format-keywords": "^1.0.1", + "@csstools/postcss-hwb-function": "^1.0.2", + "@csstools/postcss-ic-unit": "^1.0.1", + "@csstools/postcss-is-pseudo-class": "^2.0.7", + "@csstools/postcss-nested-calc": "^1.0.0", + "@csstools/postcss-normalize-display-values": "^1.0.1", + "@csstools/postcss-oklab-function": "^1.1.1", + "@csstools/postcss-progressive-custom-properties": "^1.3.0", + "@csstools/postcss-stepped-value-functions": "^1.0.1", + "@csstools/postcss-text-decoration-shorthand": "^1.0.0", + "@csstools/postcss-trigonometric-functions": "^1.0.2", + "@csstools/postcss-unset-value": "^1.0.2", + "autoprefixer": "^10.4.11", + "browserslist": "^4.21.3", + "css-blank-pseudo": "^3.0.3", + "css-has-pseudo": "^3.0.4", + "css-prefers-color-scheme": "^6.0.3", + "cssdb": "^7.0.1", + "postcss-attribute-case-insensitive": "^5.0.2", + "postcss-clamp": "^4.1.0", + "postcss-color-functional-notation": "^4.2.4", + "postcss-color-hex-alpha": "^8.0.4", + "postcss-color-rebeccapurple": "^7.1.1", + "postcss-custom-media": "^8.0.2", + "postcss-custom-properties": "^12.1.9", + "postcss-custom-selectors": "^6.0.3", + "postcss-dir-pseudo-class": "^6.0.5", + "postcss-double-position-gradients": "^3.1.2", + "postcss-env-function": "^4.0.6", + "postcss-focus-visible": "^6.0.4", + "postcss-focus-within": "^5.0.4", + "postcss-font-variant": "^5.0.0", + "postcss-gap-properties": "^3.0.5", + "postcss-image-set-function": "^4.0.7", + "postcss-initial": "^4.0.1", + "postcss-lab-function": "^4.2.1", + "postcss-logical": "^5.0.4", + "postcss-media-minmax": "^5.0.0", + "postcss-nesting": "^10.2.0", + "postcss-opacity-percentage": "^1.1.2", + "postcss-overflow-shorthand": "^3.0.4", + "postcss-page-break": "^3.0.4", + "postcss-place": "^7.0.5", + "postcss-pseudo-class-any-link": "^7.1.6", + "postcss-replace-overflow-wrap": "^4.0.0", + "postcss-selector-not": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-pseudo-class-any-link": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-7.1.6.tgz", + "integrity": "sha512-9sCtZkO6f/5ML9WcTLcIyV1yz9D1rf0tWc+ulKcvV30s0iZKS/ONyETvoWsr6vnrmW+X+KmuK3gV/w5EWnT37w==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-reduce-initial": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.0.tgz", + "integrity": "sha512-5OgTUviz0aeH6MtBjHfbr57tml13PuedK/Ecg8szzd4XRMbYxH4572JFG067z+FqBIf6Zp/d+0581glkvvWMFw==", + "dev": true, + "dependencies": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-transforms": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", + "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-replace-overflow-wrap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", + "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", + "dev": true, + "peerDependencies": { + "postcss": "^8.0.3" + } + }, + "node_modules/postcss-selector-not": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-6.0.1.tgz", + "integrity": "sha512-1i9affjAe9xu/y9uqWH+tD4r6/hDaXJruk8xn2x1vzxC2U3J3LKO3zJW4CyxlNhA56pADJ/djpEwpH1RClI2rQ==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^12 || ^14 || >=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-svgo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", + "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^2.7.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-svgo/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/postcss-svgo/node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/postcss-svgo/node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/postcss-svgo/node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/postcss-svgo/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/postcss-svgo/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/postcss-svgo/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/postcss-svgo/node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true + }, + "node_modules/postcss-svgo/node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/postcss-svgo/node_modules/svgo": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", + "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "dev": true, + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "stable": "^0.1.8" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss-unique-selectors": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", + "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, "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": { "node": ">= 0.8.0" } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.2.tgz", + "integrity": "sha512-BtRV9BcncDyI2tsuS19zzhzoxD8Dh8LiCx7j7tHzrkz8GFXAexeWFdi22mjE1d16dftH2qNaytVxqiRTGlMfpw==", "dev": true, - "license": "MIT", "bin": { - "prettier": "bin/prettier.cjs" + "prettier": "bin-prettier.js" }, "engines": { - "node": ">=14" + "node": ">=10.13.0" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" } }, "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==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", "dev": true, - "license": "MIT", "engines": { - "node": "^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" + } + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "dev": true, + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" } }, "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==", + "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": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", "react-is": "^17.0.1" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">= 10" } }, - "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==", + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", + "dev": true + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "node_modules/promise": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.2.0.tgz", + "integrity": "sha512-+CMAlLHqwRYwBMXKCP+o8ns7DN+xHDUiI+0nArsiJ9y+kJVPLFxEaSw6Ha9s9H0tftxg2Yzl25wqj9G7m5wLZg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": ">= 6" } }, "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", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -9328,44 +16108,96 @@ "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" + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, - "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/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } }, "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", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true, "engines": { "node": ">=6" } }, "node_modules/pupa": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.3.0.tgz", - "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", - "license": "MIT", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.1.1.tgz", + "integrity": "sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==", + "dev": true, "dependencies": { - "escape-goat": "^4.0.0" + "escape-goat": "^2.0.0" }, "engines": { - "node": ">=12.20" + "node": ">=8" + } + }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=", + "dev": true, + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, + "node_modules/qs": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", + "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, "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", "object-assign": "^4.1.0", @@ -9375,6 +16207,12 @@ "node": ">=0.10.0" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9393,14 +16231,13 @@ "type": "consulting", "url": "https://feross.org/support" } - ], - "license": "MIT" + ] }, "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", + "dev": true, "engines": { "node": ">=8" } @@ -9409,8 +16246,6 @@ "version": "3.19.12", "resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.19.12.tgz", "integrity": "sha512-E0cM6OjEUtccaR+dR5mL1MLiVVYML0Yf7aPhpLEq4iue73X3+CKcLztInoBhWgeevPbFQwgAtsXhlpedeyrNNg==", - "license": "MIT", - "peer": true, "dependencies": { "classnames": "~2.3.1", "date-fns": "^1.29.0", @@ -9435,32 +16270,10 @@ "redux-saga": "^1.0.0" } }, - "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", "ra-core": "^3.19.12" @@ -9470,7 +16283,6 @@ "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", "ra-core": "^3.19.12" @@ -9480,7 +16292,6 @@ "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" } @@ -9490,7 +16301,6 @@ "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": { "@testing-library/react": "^11.2.3", "classnames": "~2.3.1", @@ -9511,7 +16321,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.31.2.tgz", "integrity": "sha512-3UqjCpey6HiTZT92vODYLPxTBWlM8ZOOjr3LX5F37/VRipW2M1kX6I/Cm4VXzteZqfGfagg8yXywpcOgQBlNsQ==", "dev": true, - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -9531,7 +16340,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.7.tgz", "integrity": "sha512-tzRNp7pzd5QmbtXNG/mhdcl7Awfu/Iz1RaVHY75zTdOkmHCuzMhRL83gWHSgOAcjS3CCbyfwUHMZgRJb4kAfpA==", "dev": true, - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5", "@testing-library/dom": "^7.28.1" @@ -9544,62 +16352,26 @@ "react-dom": "*" } }, - "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==", + "node_modules/ra-test/node_modules/chalk": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", + "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", "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": { - "@babel/runtime": "^7.10.2", - "@babel/runtime-corejs3": "^7.10.2" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=6.0" - } - }, - "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": { - "@jest/types": "^26.6.2", - "ansi-regex": "^5.0.0", - "ansi-styles": "^4.0.0", - "react-is": "^17.0.1" + "node": ">=10" }, - "engines": { - "node": ">= 10" + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "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", "classnames": "~2.2.5", @@ -9635,44 +16407,64 @@ "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" + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" }, - "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/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dev": true, + "dependencies": { + "performance-now": "^2.1.0" + } }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", + "dev": true, "dependencies": { "safe-buffer": "^5.1.0" } }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "engines": { + "node": ">= 0.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)", + "dev": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -9684,138 +16476,91 @@ } }, "node_modules/rc-align": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/rc-align/-/rc-align-4.0.15.tgz", - "integrity": "sha512-wqJtVH60pka/nOX7/IspElA8gjPNQKIx/ZqJ6heATCkXpe1Zg4cPVrMD2vC96wjsFFL8WsmhPbx9tdMo1qqlIA==", - "license": "MIT", + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/rc-align/-/rc-align-4.0.9.tgz", + "integrity": "sha512-myAM2R4qoB6LqBul0leaqY8gFaiECDJ3MtQDmzDo9xM9NRT/04TvWOYd2YHU9zvGzqk9QXF6S9/MifzSKDZeMw==", "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "dom-align": "^1.7.0", - "rc-util": "^5.26.0", + "rc-util": "^5.3.0", "resize-observer-polyfill": "^1.5.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" } }, "node_modules/rc-motion": { - "version": "2.9.5", - "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", - "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", - "license": "MIT", + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.4.3.tgz", + "integrity": "sha512-GZLLFXHl/VqTfI7bSZNNZozcblNmDka1AAoQig7EZ6s0rWg5y0RlgrcHWO+W+nrOVbYfJDxoaQUoP2fEmvCWmA==", "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", - "rc-util": "^5.44.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "rc-util": "^5.2.1" } }, "node_modules/rc-slider": { - "version": "9.7.5", - "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-9.7.5.tgz", - "integrity": "sha512-LV/MWcXFjco1epPbdw1JlLXlTgmWpB9/Y/P2yinf8Pg3wElHxA9uajN21lJiWtZjf5SCUekfSP6QMJfDo4t1hg==", - "license": "MIT", + "version": "9.7.2", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-9.7.2.tgz", + "integrity": "sha512-mVaLRpDo6otasBs6yVnG02ykI3K6hIrLTNfT5eyaqduFv95UODI9PDS6fWuVVehVpdS4ENgOSwsTjrPVun+k9g==", "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.5", "rc-tooltip": "^5.0.1", - "rc-util": "^5.16.1", + "rc-util": "^5.0.0", "shallowequal": "^1.1.0" }, "engines": { "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" } }, "node_modules/rc-switch": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-3.2.2.tgz", "integrity": "sha512-+gUJClsZZzvAHGy1vZfnwySxj+MjLlGRyXKXScrtCTcmiYNPzxDFOxdQ/3pK1Kt/0POvwJ/6ALOR8gwdXGhs+A==", - "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.1", "rc-util": "^5.0.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" } }, "node_modules/rc-tooltip": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-5.3.1.tgz", - "integrity": "sha512-e6H0dMD38EPaSPD2XC8dRfct27VvT2TkPdoBSuNl3RRZ5tspiY/c5xYEmGC0IrABvMBgque4Mr2SMZuliCvoiQ==", - "license": "MIT", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-5.1.1.tgz", + "integrity": "sha512-alt8eGMJulio6+4/uDm7nvV+rJq9bsfxFDCI0ljPdbuoygUscbsMYb6EQgwib/uqsXQUvzk+S7A59uYHmEgmDA==", "dependencies": { "@babel/runtime": "^7.11.2", - "classnames": "^2.3.1", - "rc-trigger": "^5.3.1" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "rc-trigger": "^5.0.0" } }, "node_modules/rc-trigger": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-5.3.4.tgz", - "integrity": "sha512-mQv+vas0TwKcjAO2izNPkqR4j86OemLRmvL2nOzdP9OWNWA1ivoTt5hzFqYNW9zACwmTezRiN8bttrC7cZzYSw==", - "license": "MIT", + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-5.2.8.tgz", + "integrity": "sha512-Tn84oGmvNBLXI+ptpzxyJx4ArKTduuB6l74ShDLhDaJaF9f5JAMizfx31L30ELVIzRr3Ze4sekG7rzwPGwVOdw==", "dependencies": { - "@babel/runtime": "^7.18.3", + "@babel/runtime": "^7.11.2", "classnames": "^2.2.6", "rc-align": "^4.0.0", "rc-motion": "^2.0.0", - "rc-util": "^5.19.2" + "rc-util": "^5.5.0" }, "engines": { "node": ">=8.x" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" } }, "node_modules/rc-util": { - "version": "5.44.4", - "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", - "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", - "license": "MIT", + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.13.0.tgz", + "integrity": "sha512-hDnFgTt7uLowVNAtWxkCrVdVipqt+4IFyvasMglk9iiWMz/zXB5RjYArPp8hZ6TrxtrdctExh0qTJB3AqwLLRQ==", "dependencies": { - "@babel/runtime": "^7.18.3", - "react-is": "^18.2.0" - }, - "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "@babel/runtime": "^7.12.5", + "react-is": "^16.12.0", + "shallowequal": "^1.1.0" } }, - "node_modules/rc-util/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "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", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -9824,8 +16569,6 @@ "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", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -9838,7 +16581,6 @@ "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", "@material-ui/icons": "^4.11.2", @@ -9863,11 +16605,160 @@ "react-dom": "^16.9.0 || ^17.0.0" } }, + "node_modules/react-app-polyfill": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-app-polyfill/-/react-app-polyfill-3.0.0.tgz", + "integrity": "sha512-sZ41cxiU5llIB003yxxQBYrARBqe0repqPTTYBTmMqTz9szeBbE37BehCE891NZsmdZqqP+xWKdT3eo3vOzN8w==", + "dev": true, + "dependencies": { + "core-js": "^3.19.2", + "object-assign": "^4.1.1", + "promise": "^8.1.0", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.9", + "whatwg-fetch": "^3.6.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/react-dev-utils": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", + "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.16.0", + "address": "^1.1.2", + "browserslist": "^4.18.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "detect-port-alt": "^1.1.6", + "escape-string-regexp": "^4.0.0", + "filesize": "^8.0.6", + "find-up": "^5.0.0", + "fork-ts-checker-webpack-plugin": "^6.5.0", + "global-modules": "^2.0.0", + "globby": "^11.0.4", + "gzip-size": "^6.0.0", + "immer": "^9.0.7", + "is-root": "^2.1.0", + "loader-utils": "^3.2.0", + "open": "^8.4.0", + "pkg-up": "^3.1.0", + "prompts": "^2.4.2", + "react-error-overlay": "^6.0.11", + "recursive-readdir": "^2.2.2", + "shell-quote": "^1.7.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/react-dev-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/react-dev-utils/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/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, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/node_modules/loader-utils": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", + "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", + "dev": true, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/react-dev-utils/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, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/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, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/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, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "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", + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.4.tgz", + "integrity": "sha512-AFJJXzUIWp5WAhgvI85ESkDCawM0lhoVvfo/lrseLXwFdH3kEO3v8I2C81QPqBW2UEyJBIPStOhPMGYGFtq/bg==", "dependencies": { "@react-dnd/invariant": "^2.0.0", "@react-dnd/shallowequal": "^2.0.0", @@ -9894,10 +16785,9 @@ } }, "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", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.0.2.tgz", + "integrity": "sha512-QgN6rYrOm4UUj6tIvN8ovImu6uP48xBXF2rzVsp6tvj6d5XQ7OjHI4SJ/ZgGobOneRAU3WCX4f8DGCYx0tuhlw==", "dependencies": { "dnd-core": "14.0.1" } @@ -9906,46 +16796,34 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", - "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "scheduler": "^0.20.2" - }, - "peerDependencies": { - "react": "17.0.2" } }, "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", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/react-drag-listview/-/react-drag-listview-0.1.8.tgz", + "integrity": "sha512-ZJnjFEz89RPZ1DzI8f6LngmtsmJbLry/pMz2tEqABxHA+d8cUFRmVPS1DxZdoz/htc+uri9fCdv4dqIiPz0xIA==", "dependencies": { "babel-runtime": "^6.26.0", "prop-types": "^15.5.8" } }, "node_modules/react-draggable": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", - "integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==", - "license": "MIT", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.3.tgz", + "integrity": "sha512-jV4TE59MBuWm7gb6Ns3Q1mxX8Azffb7oTtDtBgFkxRvhDp38YAARmRplrj0+XGkhOJB5XziArX+4HUUABtyZ0w==", "dependencies": { - "clsx": "^2.1.1", - "prop-types": "^15.8.1" - }, - "peerDependencies": { - "react": ">= 16.3.0", - "react-dom": ">= 16.3.0" + "classnames": "^2.2.5", + "prop-types": "^15.6.0" } }, "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", "file-selector": "^0.1.12", @@ -9959,28 +16837,28 @@ } }, "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==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.3.tgz", + "integrity": "sha512-A+F9HHy9fvt9t8SNDlonq01prnU8AmkjvGKV4kk8seB9kU3xMEO8J/PQlLVmoOIDODl5U2kufSBs4vrWIqhsAA==", "dev": true, - "license": "MIT", "dependencies": { "@babel/runtime": "^7.12.5" }, "engines": { "node": ">=10", "npm": ">=6" - }, - "peerDependencies": { - "react": ">=16.13.1" } }, + "node_modules/react-error-overlay": { + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", + "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==", + "dev": true + }, "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", - "peer": true, "dependencies": { "@babel/runtime": "^7.15.4" }, @@ -9997,8 +16875,6 @@ "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", - "peer": true, "dependencies": { "@babel/runtime": "^7.19.4" }, @@ -10013,7 +16889,6 @@ "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", "react": "^15.6.2 || ^16.0 || ^17 || ^18" @@ -10023,19 +16898,14 @@ "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" - }, - "peerDependencies": { - "react": ">= 0.14.0" } }, "node_modules/react-icons": { - "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", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.4.0.tgz", + "integrity": "sha512-fSbvHeVYo/B5/L4VhB7sBA1i2tS8MkT0Hb9t2H1AVPkwGfVHLJCqyr2Py9dKMxsyM63Eng1GkdZfbWj+Fmv8Rg==", "peerDependencies": { "react": "*" } @@ -10044,8 +16914,6 @@ "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", "react-modal": "^3.11.1" @@ -10056,55 +16924,41 @@ } }, "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" + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz", + "integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q==" }, "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" + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, "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", "get-node-dimensions": "^1.2.1", "prop-types": "^15.6.2", "resize-observer-polyfill": "^1.5.0" - }, - "peerDependencies": { - "react": ">0.13.0", - "react-dom": ">0.13.0" } }, "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", + "version": "3.11.2", + "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.11.2.tgz", + "integrity": "sha512-o8gvvCOFaG1T7W6JUvsYjRjMVToLZgLIsi5kdhFIQCtHxDkA47LznX62j+l6YQkpXDbvQegsDyxe/+JJsFQN7w==", "dependencies": { "exenv": "^1.2.0", - "prop-types": "^15.7.2", + "prop-types": "^15.5.10", "react-lifecycles-compat": "^3.0.0", "warning": "^4.0.3" - }, - "peerDependencies": { - "react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19", - "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19" } }, "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", - "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -10125,27 +16979,30 @@ } } }, + "node_modules/react-redux/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==" + }, "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==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", + "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } }, "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", - "peer": true, + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.1.tgz", + "integrity": "sha512-lIboRiOtDLFdg1VTemMwud9vRVuOCZmUIT/7lUoZiSpPODiiH1UQlfXy+vPLC/7IWdFYnhRwAyNqA/+I7wnvKQ==", "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", "hoist-non-react-statics": "^3.1.0", "loose-envify": "^1.3.1", + "mini-create-react-context": "^0.4.0", "path-to-regexp": "^1.7.0", "prop-types": "^15.6.2", "react-is": "^16.6.0", @@ -10157,17 +17014,15 @@ } }, "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", - "peer": true, + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.0.tgz", + "integrity": "sha512-ObVBLjUZsphUUMVycibxgMdh5jJ1e3o+KpAZBVeHcNQZ4W+uUGGWsokurzlF4YOldQYRQL4y6yFRWM4m3svmuQ==", "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", "loose-envify": "^1.3.1", "prop-types": "^15.6.2", - "react-router": "5.3.4", + "react-router": "5.2.1", "tiny-invariant": "^1.0.2", "tiny-warning": "^1.0.0" }, @@ -10175,135 +17030,270 @@ "react": ">=15" } }, - "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-scripts": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", + "integrity": "sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.16.0", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3", + "@svgr/webpack": "^5.5.0", + "babel-jest": "^27.4.2", + "babel-loader": "^8.2.3", + "babel-plugin-named-asset-import": "^0.3.8", + "babel-preset-react-app": "^10.0.1", + "bfj": "^7.0.2", + "browserslist": "^4.18.1", + "camelcase": "^6.2.1", + "case-sensitive-paths-webpack-plugin": "^2.4.0", + "css-loader": "^6.5.1", + "css-minimizer-webpack-plugin": "^3.2.0", + "dotenv": "^10.0.0", + "dotenv-expand": "^5.1.0", + "eslint": "^8.3.0", + "eslint-config-react-app": "^7.0.1", + "eslint-webpack-plugin": "^3.1.1", + "file-loader": "^6.2.0", + "fs-extra": "^10.0.0", + "html-webpack-plugin": "^5.5.0", + "identity-obj-proxy": "^3.0.0", + "jest": "^27.4.3", + "jest-resolve": "^27.4.2", + "jest-watch-typeahead": "^1.0.0", + "mini-css-extract-plugin": "^2.4.5", + "postcss": "^8.4.4", + "postcss-flexbugs-fixes": "^5.0.2", + "postcss-loader": "^6.2.1", + "postcss-normalize": "^10.0.1", + "postcss-preset-env": "^7.0.1", + "prompts": "^2.4.2", + "react-app-polyfill": "^3.0.0", + "react-dev-utils": "^12.0.1", + "react-refresh": "^0.11.0", + "resolve": "^1.20.0", + "resolve-url-loader": "^4.0.0", + "sass-loader": "^12.3.0", + "semver": "^7.3.5", + "source-map-loader": "^3.0.0", + "style-loader": "^3.3.1", + "tailwindcss": "^3.0.2", + "terser-webpack-plugin": "^5.2.5", + "webpack": "^5.64.4", + "webpack-dev-server": "^4.6.0", + "webpack-manifest-plugin": "^4.0.2", + "workbox-webpack-plugin": "^6.4.1" + }, + "bin": { + "react-scripts": "bin/react-scripts.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + }, + "peerDependencies": { + "react": ">= 16", + "typescript": "^3.2.1 || ^4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/react-scripts/node_modules/@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.7.tgz", + "integrity": "sha512-bcKCAzF0DV2IIROp9ZHkRJa6O4jy7NlnHdWL3GmcUxYWNjLXkK5kfELELwEfSP5hXPfVL/qOGMAROuMQb9GG8Q==", + "dev": true, + "dependencies": { + "ansi-html-community": "^0.0.8", + "common-path-prefix": "^3.0.0", + "core-js-pure": "^3.8.1", + "error-stack-parser": "^2.0.6", + "find-up": "^5.0.0", + "html-entities": "^2.1.0", + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "@types/webpack": "4.x || 5.x", + "react-refresh": ">=0.10.0 <1.0.0", + "sockjs-client": "^1.4.0", + "type-fest": ">=0.17.0 <3.0.0", + "webpack": ">=4.43.0 <6.0.0", + "webpack-dev-server": "3.x || 4.x", + "webpack-hot-middleware": "2.x", + "webpack-plugin-serve": "0.x || 1.x" + }, + "peerDependenciesMeta": { + "@types/webpack": { + "optional": true + }, + "sockjs-client": { + "optional": true + }, + "type-fest": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + }, + "webpack-hot-middleware": { + "optional": true + }, + "webpack-plugin-serve": { + "optional": true + } + } + }, + "node_modules/react-scripts/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, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-scripts/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/react-scripts/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, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-scripts/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, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-scripts/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, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-scripts/node_modules/schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/react-scripts/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/react-scripts/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "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", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz", + "integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==", "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" } }, - "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", + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, "dependencies": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" - }, - "engines": { - "node": ">=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", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=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" - }, - "engines": { - "node": ">=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" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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" - }, - "engines": { - "node": ">=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" - } - }, - "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" + "pify": "^2.3.0" } }, "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", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -10317,7 +17307,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", + "dev": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -10325,11 +17315,23 @@ "node": ">=8.10.0" } }, + "node_modules/recursive-readdir": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", + "integrity": "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==", + "dev": true, + "dependencies": { + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "license": "MIT", + "dev": true, "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" @@ -10339,58 +17341,32 @@ } }, "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", - "peer": true, + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.0.tgz", + "integrity": "sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA==", "dependencies": { "@babel/runtime": "^7.9.2" } }, "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", - "peer": true, + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.1.3.tgz", + "integrity": "sha512-RkSn/z0mwaSa5/xH/hQLo8gNf4tlvT18qXDNvedihLcfzh+jMchDgaariQoehCpgRltEm4zHKJyINEz6aqswTw==", "dependencies": { - "@redux-saga/core": "^1.4.2" - } - }, - "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", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "@redux-saga/core": "^1.1.3" } }, "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" + "dev": true }, "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", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz", + "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==", + "dev": true, "dependencies": { "regenerate": "^1.4.2" }, @@ -10399,23 +17375,33 @@ } }, "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" + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, + "node_modules/regenerator-transform": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.0.tgz", + "integrity": "sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regex-parser": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.2.11.tgz", + "integrity": "sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==", + "dev": true }, "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", + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", + "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "functions-have-names": "^1.2.2" }, "engines": { "node": ">= 0.4" @@ -10424,125 +17410,270 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + } + }, "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", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.2.1.tgz", + "integrity": "sha512-HrnlNtpvqP1Xkb28tMhBUO2EbyUHdQlsnlAhzWcwHy8WJR53UWr7/MAvqrsQKMbV4qdpv03oTMG8iIhfsPFktQ==", + "dev": true, "dependencies": { "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.2", - "regjsgen": "^0.8.0", - "regjsparser": "^0.13.0", + "regenerate-unicode-properties": "^10.1.0", + "regjsgen": "^0.7.1", + "regjsparser": "^0.9.1", "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.2.1" + "unicode-match-property-value-ecmascript": "^2.0.0" }, "engines": { "node": ">=4" } }, "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", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.1.tgz", + "integrity": "sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw==", + "dev": true, "dependencies": { - "@pnpm/npm-conf": "^3.0.2" + "rc": "^1.2.8" }, "engines": { - "node": ">=14" + "node": ">=6.0.0" } }, "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", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-5.1.0.tgz", + "integrity": "sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==", + "dev": true, "dependencies": { - "rc": "1.2.8" + "rc": "^1.2.8" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "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" + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.7.1.tgz", + "integrity": "sha512-RAt+8H2ZEzHeYWxZ3H2z6tF18zyyOnlcdaafLrm21Bguj7uZy6ULibiAFdXEtKQY4Sy7wDTwDiOazasMLc4KPA==", + "dev": true }, "node_modules/regjsparser": { - "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", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", + "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "dev": true, "dependencies": { - "jsesc": "~3.1.0" + "jsesc": "~0.5.0" }, "bin": { "regjsparser": "bin/parser" } }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, "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" + "integrity": "sha512-EpFcOa/ISetVHEXqu+VwI96KZBmq+a8LJnGkaeFw45epGlxIZz5dhEEnNZMsQXgORu3qaMoLX4qJCzOik6ytAg==" + }, + "node_modules/renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "dev": true, + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "node_modules/renderkid/node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/renderkid/node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/renderkid/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/renderkid/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/renderkid/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/renderkid/node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, "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", + "dev": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, "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" + "integrity": "sha1-79qpjqdFEyTQkrKyFjpqHXqaIUc=" }, "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" + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" }, "node_modules/resolve": { - "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", + "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", "dev": true, - "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "node-exports-info": "^1.6.0", - "object-keys": "^1.1.1", + "is-core-module": "^2.9.0", "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-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "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": { "node": ">=4" } @@ -10550,14 +17681,82 @@ "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" + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" + }, + "node_modules/resolve-url-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-4.0.0.tgz", + "integrity": "sha512-05VEMczVREcbtT7Bz+C+96eUO5HDNvdthIiMB34t7FcF8ehcu4wC0sSgPUubs3XW2Q3CNLJk/BJrCU9wVRymiA==", + "dev": true, + "dependencies": { + "adjust-sourcemap-loader": "^4.0.0", + "convert-source-map": "^1.7.0", + "loader-utils": "^2.0.0", + "postcss": "^7.0.35", + "source-map": "0.6.1" + }, + "engines": { + "node": ">=8.9" + }, + "peerDependencies": { + "rework": "1.0.1", + "rework-visit": "1.0.0" + }, + "peerDependenciesMeta": { + "rework": { + "optional": true + }, + "rework-visit": { + "optional": true + } + } + }, + "node_modules/resolve-url-loader/node_modules/picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true + }, + "node_modules/resolve-url-loader/node_modules/postcss": { + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "dev": true, + "dependencies": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + } + }, + "node_modules/resolve.exports": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.0.tgz", + "integrity": "sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/responselike": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", + "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", + "dev": true, + "dependencies": { + "lowercase-keys": "^1.0.0" + } }, "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", + "dev": true, "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" @@ -10566,36 +17765,30 @@ "node": ">=8" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "dev": true, - "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" } }, - "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" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, "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": { "glob": "^7.1.3" }, @@ -10607,39 +17800,49 @@ } }, "node_modules/rollup": { - "name": "@rollup/wasm-node", - "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", - "peer": true, - "dependencies": { - "@types/estree": "1.0.8" - }, + "version": "2.79.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", + "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "dev": true, "bin": { "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": ">=10.0.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, - "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==", + "node_modules/rollup-plugin-terser": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", + "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", "dev": true, - "license": "MIT" + "dependencies": { + "@babel/code-frame": "^7.10.4", + "jest-worker": "^26.2.1", + "serialize-javascript": "^4.0.0", + "terser": "^5.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0" + } + }, + "node_modules/rollup-plugin-terser/node_modules/serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } }, "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", + "dev": true, "engines": { "node": ">=0.12.0" } @@ -10663,7 +17866,6 @@ "url": "https://feross.org/support" } ], - "license": "MIT", "dependencies": { "queue-microtask": "^1.2.2" } @@ -10672,7 +17874,7 @@ "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", - "license": "Apache-2.0", + "dev": true, "dependencies": { "tslib": "^1.9.0" }, @@ -10680,91 +17882,20 @@ "npm": ">=2.0.0" } }, - "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", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "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", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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" + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true }, "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", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", + "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "is-regex": "^1.1.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -10774,43 +17905,129 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" + "dev": true + }, + "node_modules/sanitize.css": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz", + "integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==", + "dev": true + }, + "node_modules/sass-loader": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", + "integrity": "sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==", + "dev": true, + "dependencies": { + "klona": "^2.0.4", + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "fibers": ">= 3.1.0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "fibers": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + } + } + }, + "node_modules/sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true }, "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", "dev": true, - "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" }, "engines": { - "node": ">=v12.22.7" + "node": ">=10" } }, "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", "object-assign": "^4.1.1" } }, + "node_modules/schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "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/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true + }, + "node_modules/selfsigned": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.1.1.tgz", + "integrity": "sha512-GSL3aowiF7wa/WtSFwnUrludWFoNhftq8bUkH9pkzjpN2XSPOAYEgg6e0sS9s0rZwgJzJiQRPU18A6clnoW5wQ==", + "dev": true, + "dependencies": { + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", + "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, "bin": { "semver": "bin/semver.js" }, @@ -10818,72 +18035,190 @@ "node": ">=10" } }, + "node_modules/semver-diff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz", + "integrity": "sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==", + "dev": true, + "dependencies": { + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/semver-diff/node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/send/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==", + "dev": true + }, "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", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, "dependencies": { "randombytes": "^2.1.0" } }, - "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", + "node_modules/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "dev": true, "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" }, "engines": { - "node": ">= 0.4" + "node": ">= 0.8.0" } }, - "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", + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "ms": "2.0.0" } }, - "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", + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dev": true, "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" }, "engines": { - "node": ">= 0.4" + "node": ">= 0.6" } }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dev": true, + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", - "license": "MIT" + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" }, "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", + "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -10895,266 +18230,251 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", + "dev": true, "engines": { "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz", + "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==", + "dev": true + }, "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", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" } }, - "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", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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" + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true }, "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": { "node": ">=8" } }, - "node_modules/smob": { - "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/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" } }, "node_modules/sortablejs": { - "version": "1.15.7", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz", - "integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==", - "license": "MIT" + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz", + "integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==" + }, + "node_modules/source-list-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", + "dev": true }, "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" - }, + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "engines": { - "node": ">= 8" + "node": ">=0.10.0" } }, "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==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", "dev": true, - "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-3.0.1.tgz", + "integrity": "sha512-Vp1UsfyPvgujKQzi4pyDiTOnE3E4H+yHvkVRN3c/9PJmQS4CQJExvcDvaX/D+RV+xQben9HJ56jMJS3CgUeWyA==", + "dev": true, + "dependencies": { + "abab": "^2.0.5", + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/source-map-loader/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, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { "node": ">=0.10.0" } }, "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", + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.20.tgz", + "integrity": "sha512-n1lZZ8Ve4ksRqizaBQgxXDgKwttHDhyfQjA6YZZn8+AroHbsIz+JjwxQDxbp+7y5OYCI8t1Yk7etjD9CRd2hIw==", + "dev": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, - "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" - } - }, - "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" - } - }, - "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", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - }, "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" + "dev": true }, "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", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", + "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", + "dev": true, "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" } }, "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" + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true }, "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", + "dev": true, "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "node_modules/spdx-license-ids": { - "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" + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.9.tgz", + "integrity": "sha512-Ki212dKK4ogX+xDo4CtOZBVIwhsKBEfsEEcwmJfLQzirgc2jIWdzg40Unxz/HzEUqM1WFzVlQSMF9kZZ2HboLQ==", + "dev": true + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } }, "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" + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true }, - "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==", + "node_modules/stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "dev": true + }, + "node_modules/stack-utils": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz", + "integrity": "sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA==", "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "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", - "internal-slot": "^1.1.0" + "escape-string-regexp": "^2.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "dev": true + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.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", + "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=", "engines": { "node": ">=0.10.0" } @@ -11163,97 +18483,77 @@ "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", + "dev": true, "dependencies": { "safe-buffer": "~5.2.0" } }, + "node_modules/string_decoder/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==", + "dev": true + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-natural-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz", + "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==", + "dev": true + }, "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", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz", + "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==", + "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "strip-ansi": "^6.0.0" }, "engines": { "node": ">=8" } }, - "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": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3" - }, - "engines": { - "node": ">= 0.4" - } - }, "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", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz", + "integrity": "sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg==", + "dev": true, "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1", + "get-intrinsic": "^1.1.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.3", + "regexp.prototype.flags": "^1.4.1", + "side-channel": "^1.0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "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": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, "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", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.4.tgz", + "integrity": "sha512-hWCk/iqf7lp0/AgTF7/ddO1IWtSNPASjlzCicV5irAVdE1grjsneK26YG6xACMBEdCvO8fUST0UzDMh/2Qy+9Q==", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.2" }, "engines": { "node": ">= 0.4" @@ -11263,35 +18563,26 @@ } }, "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", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz", + "integrity": "sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog==", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.19.5" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "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", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.5.tgz", + "integrity": "sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg==", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.19.5" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -11301,7 +18592,7 @@ "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", + "dev": true, "dependencies": { "get-own-enumerable-property-symbols": "^3.0.0", "is-obj": "^1.0.1", @@ -11311,11 +18602,20 @@ "node": ">=4" } }, + "node_modules/stringify-object/node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/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", + "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -11323,20 +18623,38 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=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", + "dev": true, "engines": { "node": ">=10" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "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", + "dev": true, "dependencies": { "min-indent": "^1.0.0" }, @@ -11349,7 +18667,6 @@ "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": { "node": ">=8" }, @@ -11357,26 +18674,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "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" + "node_modules/style-loader": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.1.tgz", + "integrity": "sha512-GPcQ+LDJbrcxHORTRes6Jy2sfvK2kS6hpSfI/fXhPt+spVzxF6LJ1dHLN9zIGmVaaP044YKaIatFaufENRiDoQ==", + "dev": true, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" } }, - "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/stylehacks": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.0.tgz", + "integrity": "sha512-SzLmvHQTrIWfSgljkQCw2++C9+Ne91d/6Sp92I8c5uHTcy/PgeHamwITIbBW9wnFTY/3ZfSXR9HIL6Ikqmcu6Q==", + "dev": true, + "dependencies": { + "browserslist": "^4.16.6", + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } }, "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", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -11384,11 +18718,24 @@ "node": ">=8" } }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=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", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -11396,18 +18743,193 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true + }, + "node_modules/svgo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz", + "integrity": "sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==", + "dev": true, + "dependencies": { + "chalk": "^2.4.1", + "coa": "^2.0.2", + "css-select": "^2.0.0", + "css-select-base-adapter": "^0.1.1", + "css-tree": "1.0.0-alpha.37", + "csso": "^4.0.2", + "js-yaml": "^3.13.1", + "mkdirp": "~0.5.1", + "object.values": "^1.1.0", + "sax": "~1.2.4", + "stable": "^0.1.8", + "unquote": "~1.1.1", + "util.promisify": "~1.0.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/svgo/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/svgo/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/svgo/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/svgo/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "node_modules/svgo/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/svgo/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "engines": { + "node": ">=0.10.0" + } + }, "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 + }, + "node_modules/tailwindcss": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.1.8.tgz", + "integrity": "sha512-YSneUCZSFDYMwk+TGq8qYFdCA3yfBRdBlS7txSq0LUmzyeqRe3a8fBQzbz9M3WS/iFT4BNf/nmw9mEzrnSaC0g==", "dev": true, - "license": "MIT" + "dependencies": { + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "color-name": "^1.1.4", + "detective": "^5.2.1", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.2.11", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "lilconfig": "^2.0.6", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.14", + "postcss-import": "^14.1.0", + "postcss-js": "^4.0.0", + "postcss-load-config": "^3.1.4", + "postcss-nested": "5.0.6", + "postcss-selector-parser": "^6.0.10", + "postcss-value-parser": "^4.2.0", + "quick-lru": "^5.1.1", + "resolve": "^1.22.1" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/tailwindcss/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, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tailwindcss/node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } }, "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", + "dev": true, "engines": { "node": ">=8" } @@ -11416,7 +18938,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", - "license": "MIT", + "dev": true, "dependencies": { "is-stream": "^2.0.0", "temp-dir": "^2.0.0", @@ -11434,7 +18956,7 @@ "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)", + "dev": true, "engines": { "node": ">=10" }, @@ -11442,14 +18964,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/terser": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", - "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", - "license": "BSD-2-Clause", + "node_modules/term-size": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", + "integrity": "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "dev": true, "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.15.0.tgz", + "integrity": "sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.2", + "acorn": "^8.5.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -11460,132 +19010,158 @@ "node": ">=10" } }, - "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==", + "node_modules/terser-webpack-plugin": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.6.tgz", + "integrity": "sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==", "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.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "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": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "@jridgewell/trace-mapping": "^0.3.14", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.0", + "terser": "^5.14.1" }, "engines": { - "node": ">=12.0.0" + "node": ">= 10.13.0" }, "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "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": { - "node": ">=12.0.0" + "type": "opencollective", + "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "picomatch": "^3 || ^4" + "webpack": "^5.1.0" }, "peerDependenciesMeta": { - "picomatch": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { "optional": true } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/terser-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, - "license": "MIT", - "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, "engines": { - "node": ">=12" + "node": ">= 10.13.0" + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "node_modules/terser-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "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": { - "tldts-core": "^6.1.86" + "has-flag": "^4.0.0" }, - "bin": { - "tldts": "bin/cli.js" + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "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==", + "node_modules/terser/node_modules/acorn": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", + "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", "dev": true, - "license": "MIT" + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/terser/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==", + "dev": true + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "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 + }, + "node_modules/throat": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.1.tgz", + "integrity": "sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==", + "dev": true + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true + }, + "node_modules/tiny-invariant": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", + "integrity": "sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==" + }, + "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==" }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "license": "MIT", + "dev": true, "dependencies": { "os-tmpdir": "~1.0.2" }, @@ -11593,11 +19169,35 @@ "node": ">=0.6.0" } }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-readable-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", + "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "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", + "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -11605,66 +19205,125 @@ "node": ">=8.0" } }, - "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==", + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", + "integrity": "sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { - "tldts": "^6.1.32" + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" }, "engines": { - "node": ">=16" + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" } }, "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", + "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", "dev": true, - "license": "MIT", "dependencies": { - "punycode": "^2.3.1" + "punycode": "^2.1.1" }, "engines": { - "node": ">=18" + "node": ">=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", + "dev": true, "engines": { "node": ">=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==", + "node_modules/tryer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", + "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==", + "dev": true + }, + "node_modules/tsconfig-paths": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", + "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.1", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" }, - "peerDependencies": { - "typescript": ">=4.2.0" + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" } }, "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" + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } }, "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": { "prelude-ls": "^1.2.1" }, @@ -11672,113 +19331,64 @@ "node": ">= 0.8.0" } }, - "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==", + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=4" } }, - "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", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, + "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==", + "dev": true, "engines": { - "node": ">= 0.4" + "node": ">=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", + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" + "media-typer": "0.3.0", + "mime-types": "~2.1.24" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.6" } }, - "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", + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "is-typedarray": "^1.0.0" } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", + "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", "dev": true, - "license": "Apache-2.0", "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=14.17" + "node": ">=4.2.0" } }, "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" } @@ -11786,48 +19396,35 @@ "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" + "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==" }, "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" } }, "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", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", "dependencies": { - "call-bound": "^1.0.3", + "call-bind": "^1.0.2", "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "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", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "dev": true, "engines": { "node": ">=4" } @@ -11836,7 +19433,7 @@ "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", + "dev": true, "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" @@ -11846,19 +19443,19 @@ } }, "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", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz", + "integrity": "sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==", + "dev": true, "engines": { "node": ">=4" } }, "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", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, "engines": { "node": ">=4" } @@ -11867,7 +19464,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", - "license": "MIT", + "dev": true, "dependencies": { "crypto-random-string": "^2.0.0" }, @@ -11876,28 +19473,44 @@ } }, "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", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true, "engines": { "node": ">= 10.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unquote": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ=", + "dev": true + }, "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", + "dev": true, "engines": { "node": ">=4", "yarn": "*" } }, "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==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.9.tgz", + "integrity": "sha512-/xsqn21EGVdXI3EXSum1Yckj3ZVZugqyOZQ/CxYPBD/R+ko9NSUScf8tFF4dOKY+2pvSSJA/S+5B8s4Zr4kyvg==", + "dev": true, "funding": [ { "type": "opencollective", @@ -11906,93 +19519,155 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" } ], - "license": "MIT", "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" + "escalade": "^3.1.1", + "picocolors": "^1.0.0" }, "bin": { - "update-browserslist-db": "cli.js" + "browserslist-lint": "cli.js" }, "peerDependencies": { "browserslist": ">= 4.21.0" } }, "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", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-4.1.3.tgz", + "integrity": "sha512-Yld6Z0RyCYGB6ckIjffGOSOmHXj1gMeE7aROz4MG+XMkmixBX4jUngrGXNYz7wPKBmtoD4MnBa2Anu7RSKht/A==", + "dev": true, "dependencies": { - "boxen": "^8.0.1", - "chalk": "^5.3.0", - "configstore": "^7.0.0", - "is-in-ci": "^1.0.0", - "is-installed-globally": "^1.0.0", - "is-npm": "^6.0.0", - "latest-version": "^9.0.0", - "pupa": "^3.1.0", - "semver": "^7.6.3", - "xdg-basedir": "^5.1.0" + "boxen": "^4.2.0", + "chalk": "^3.0.0", + "configstore": "^5.0.1", + "has-yarn": "^2.1.0", + "import-lazy": "^2.1.0", + "is-ci": "^2.0.0", + "is-installed-globally": "^0.3.1", + "is-npm": "^4.0.0", + "is-yarn-global": "^0.3.0", + "latest-version": "^5.0.0", + "pupa": "^2.0.1", + "semver-diff": "^3.1.1", + "xdg-basedir": "^4.0.0" }, "engines": { - "node": ">=18" + "node": ">=8" }, "funding": { "url": "https://github.com/yeoman/update-notifier?sponsor=1" } }, - "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" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "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", + "dev": true, "dependencies": { "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/url-parse-lax": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", + "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", + "dev": true, + "dependencies": { + "prepend-http": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/url-parse-lax/node_modules/prepend-http": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", + "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", + "dev": true, + "engines": { + "node": ">=4" + } + }, "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" + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "node_modules/util.promisify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz", + "integrity": "sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.2", + "has-symbols": "^1.0.1", + "object.getownpropertydescriptors": "^2.1.0" + } + }, + "node_modules/utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", + "dev": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } }, "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" - ], - "license": "MIT", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "bin": { - "uuid": "dist-node/bin/uuid" + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", + "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^1.6.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "engines": { + "node": ">= 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", + "dev": true, "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" @@ -12001,334 +19676,509 @@ "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" + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" }, - "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/w3c-hr-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", + "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", "dev": true, - "license": "MIT", - "peer": true, "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "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": { - "debug": "^4.3.6", - "pretty-bytes": "^6.1.1", - "tinyglobby": "^0.2.10", - "workbox-build": "^7.4.0", - "workbox-window": "^7.4.0" - }, - "engines": { - "node": ">=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "@vite-pwa/assets-generator": "^1.0.0", - "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", - "workbox-build": "^7.4.0", - "workbox-window": "^7.4.0" - }, - "peerDependenciesMeta": { - "@vite-pwa/assets-generator": { - "optional": true - } - } - }, - "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": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest": { - "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", - "peer": true, - "dependencies": { - "@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": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@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": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "browser-process-hrtime": "^1.0.0" } }, "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==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", + "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", "dev": true, - "license": "MIT", "dependencies": { - "xml-name-validator": "^5.0.0" + "xml-name-validator": "^3.0.0" }, "engines": { - "node": ">=18" + "node": ">=10" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "dependencies": { + "makeerror": "1.0.12" } }, "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" } }, + "node_modules/watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, "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", + "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "dev": true, "dependencies": { "defaults": "^1.0.3" } }, "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==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", "dev": true, - "license": "BSD-2-Clause", "engines": { - "node": ">=12" + "node": ">=10.4" + } + }, + "node_modules/webpack": { + "version": "5.76.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.1.tgz", + "integrity": "sha512-4+YIK4Abzv8172/SGqObnUjaIHjLEuUasz9EwQj/9xmPPkYJy2Mh03Q/lJfSD3YLzbxy5FeTq5Uw0323Oh6SJQ==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^0.0.51", + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/wasm-edit": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.7.6", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.10.0", + "es-module-lexer": "^0.9.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.1.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.1.3", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", + "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "dev": true, + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^3.4.3", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/webpack-dev-middleware/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack-dev-middleware/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==", + "dev": true + }, + "node_modules/webpack-dev-middleware/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack-dev-server": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.11.1.tgz", + "integrity": "sha512-lILVz9tAUy1zGFwieuaQtYiadImb5M3d+H+L1zDYalYoDl0cksAB1UNyuE5MMWJrG6zR1tXkCP2fitl7yoUJiw==", + "dev": true, + "dependencies": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/serve-static": "^1.13.10", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.1", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.1.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.1", + "ws": "^8.4.2" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.37.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-server/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack-dev-server/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==", + "dev": true + }, + "node_modules/webpack-dev-server/node_modules/schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack-dev-server/node_modules/ws": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.9.0.tgz", + "integrity": "sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webpack-manifest-plugin": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-4.1.1.tgz", + "integrity": "sha512-YXUAwxtfKIJIKkhg03MKuiFAD72PlrqCiwdwO4VEXdRO5V0ORCNwaOwAZawPZalCbmH9kBDmXnNeQOw+BIEiow==", + "dev": true, + "dependencies": { + "tapable": "^2.0.0", + "webpack-sources": "^2.2.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "webpack": "^4.44.2 || ^5.47.0" + } + }, + "node_modules/webpack-manifest-plugin/node_modules/webpack-sources": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.3.1.tgz", + "integrity": "sha512-y9EI9AO42JjEcrTJFOYmVywVZdKVUfOvDUPsJea5GIr1JOEGFVqwlY2K098fFoIjOkDzHn2AjRvM8dsBZu+gCA==", + "dev": true, + "dependencies": { + "source-list-map": "^2.0.1", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/acorn": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", + "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack/node_modules/acorn-import-assertions": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", + "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", + "dev": true, + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true, + "engines": { + "node": ">=0.8.0" } }, "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", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", + "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", "dev": true, - "license": "MIT", "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" + "iconv-lite": "0.4.24" } }, + "node_modules/whatwg-fetch": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz", + "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==", + "dev": true + }, "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": { - "node": ">=12" - } + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", + "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", + "dev": true }, "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==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", "dev": true, - "license": "MIT", "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" + "lodash": "^4.7.0", + "tr46": "^2.1.0", + "webidl-conversions": "^6.1.0" }, "engines": { - "node": ">=18" + "node": ">=10" } }, - "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", + "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -12340,262 +20190,122 @@ } }, "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", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "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", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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==", + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", "dev": true, - "license": "MIT", "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" + "string-width": "^4.0.0" }, "engines": { "node": ">=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" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "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", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/widest-line/node_modules/strip-ansi": { - "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.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "dev": true, - "license": "MIT", "engines": { "node": ">=0.10.0" } }, "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", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.5.4.tgz", + "integrity": "sha512-0r4INQZMyPky/lj4Ou98qxcThrETucOde+7mRGJl13MPJugQNKeZQOdIJe/1AchOP23cTqHcN/YVpD6r8E6I8g==", + "dev": true, "dependencies": { "idb": "^7.0.1", - "workbox-core": "7.4.0" + "workbox-core": "6.5.4" } }, + "node_modules/workbox-background-sync/node_modules/idb": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.0.tgz", + "integrity": "sha512-Wsk07aAxDsntgYJY4h0knZJuTxM73eQ4reRAO+Z1liOh8eMCJ/MoDS8fCui1vGT9mnjtl1sOu3I2i/W1swPYZg==", + "dev": true + }, "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", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-6.5.4.tgz", + "integrity": "sha512-I/lBERoH1u3zyBosnpPEtcAVe5lwykx9Yg1k6f8/BGEPGaMMgZrwVrqL1uA9QZ1NGGFoyE6t9i7lBjOlDhFEEw==", + "dev": true, "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "6.5.4" } }, "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", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-6.5.4.tgz", + "integrity": "sha512-kgRevLXEYvUW9WS4XoziYqZ8Q9j/2ziJYEtTrjdz5/L/cTUa2XfyMP2i7c3p34lgqJ03+mTiz13SdFef2POwbA==", + "dev": true, "dependencies": { "@apideck/better-ajv-errors": "^0.3.1", - "@babel/core": "^7.24.4", + "@babel/core": "^7.11.1", "@babel/preset-env": "^7.11.0", "@babel/runtime": "^7.11.2", "@rollup/plugin-babel": "^5.2.0", - "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-node-resolve": "^11.2.1", "@rollup/plugin-replace": "^2.4.1", - "@rollup/plugin-terser": "^0.4.3", "@surma/rollup-plugin-off-main-thread": "^2.2.3", "ajv": "^8.6.0", "common-tags": "^1.8.0", "fast-json-stable-stringify": "^2.1.0", "fs-extra": "^9.0.1", - "glob": "^11.0.1", + "glob": "^7.1.6", "lodash": "^4.17.20", "pretty-bytes": "^5.3.0", - "rollup": "^2.79.2", + "rollup": "^2.43.1", + "rollup-plugin-terser": "^7.0.0", "source-map": "^0.8.0-beta.0", "stringify-object": "^3.3.0", "strip-comments": "^2.0.1", "tempy": "^0.6.0", "upath": "^1.2.0", - "workbox-background-sync": "7.4.0", - "workbox-broadcast-update": "7.4.0", - "workbox-cacheable-response": "7.4.0", - "workbox-core": "7.4.0", - "workbox-expiration": "7.4.0", - "workbox-google-analytics": "7.4.0", - "workbox-navigation-preload": "7.4.0", - "workbox-precaching": "7.4.0", - "workbox-range-requests": "7.4.0", - "workbox-recipes": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0", - "workbox-streams": "7.4.0", - "workbox-sw": "7.4.0", - "workbox-window": "7.4.0" + "workbox-background-sync": "6.5.4", + "workbox-broadcast-update": "6.5.4", + "workbox-cacheable-response": "6.5.4", + "workbox-core": "6.5.4", + "workbox-expiration": "6.5.4", + "workbox-google-analytics": "6.5.4", + "workbox-navigation-preload": "6.5.4", + "workbox-precaching": "6.5.4", + "workbox-range-requests": "6.5.4", + "workbox-recipes": "6.5.4", + "workbox-routing": "6.5.4", + "workbox-strategies": "6.5.4", + "workbox-streams": "6.5.4", + "workbox-sw": "6.5.4", + "workbox-window": "6.5.4" }, "engines": { - "node": ">=20.0.0" + "node": ">=10.0.0" } }, "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { - "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", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", + "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", + "dev": true, "dependencies": { - "jsonpointer": "^5.0.1", + "json-schema": "^0.4.0", + "jsonpointer": "^5.0.0", "leven": "^3.1.0" }, "engines": { @@ -12605,512 +20315,356 @@ "ajv": ">=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", + "node_modules/workbox-build/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==", + "dev": true, "dependencies": { - "@babel/helper-module-imports": "^7.10.4", - "@rollup/pluginutils": "^3.1.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0", - "@types/babel__core": "^7.1.9", - "rollup": "^1.20.0||^2.0.0" - }, - "peerDependenciesMeta": { - "@types/babel__core": { - "optional": true - } + "ejs": "^3.1.6", + "json5": "^2.2.0", + "magic-string": "^0.25.0", + "string.prototype.matchall": "^4.0.6" } }, - "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", - "magic-string": "^0.25.7" - }, - "peerDependencies": { - "rollup": "^1.20.0 || ^2.0.0" - } - }, - "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", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" - } - }, - "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", - "peer": true, + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", + "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" }, "funding": { "type": "github", "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", + "node_modules/workbox-build/node_modules/ejs": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.8.tgz", + "integrity": "sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ==", + "dev": true, "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", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" + "jake": "^10.8.5" }, "bin": { - "glob": "dist/esm/bin.mjs" + "ejs": "bin/cli.js" }, "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=0.10.0" } }, + "node_modules/workbox-build/node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true + }, "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" + "dev": true }, - "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", + "node_modules/workbox-build/node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workbox-build/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==", + "dev": true, "dependencies": { - "sourcemap-codec": "^1.4.8" + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" } }, - "node_modules/workbox-build/node_modules/minimatch": { - "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", + "node_modules/workbox-build/node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "punycode": "^2.1.0" } }, - "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" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node_modules/workbox-build/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==", + "dev": true + }, + "node_modules/workbox-build/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==", + "dev": true, + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" } }, - "node_modules/workbox-build/node_modules/rollup": { - "version": "2.80.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", - "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", - "license": "MIT", - "peer": true, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "node_modules/workbox-build/node_modules/workbox-recipes": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-6.5.4.tgz", + "integrity": "sha512-QZNO8Ez708NNwzLNEXTG4QYSKQ1ochzEtRLGaq+mr2PyoEIC1xFW7MrWxrONUxBFOByksds9Z4//lKAX8tHyUA==", + "dev": true, + "dependencies": { + "workbox-cacheable-response": "6.5.4", + "workbox-core": "6.5.4", + "workbox-expiration": "6.5.4", + "workbox-precaching": "6.5.4", + "workbox-routing": "6.5.4", + "workbox-strategies": "6.5.4" } }, "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", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-6.5.4.tgz", + "integrity": "sha512-DCR9uD0Fqj8oB2TSWQEm1hbFs/85hXXoayVwFKLVuIuxwJaihBsLsp4y7J9bvZbqtPJ1KlCkmYVGQKrBU4KAug==", + "dev": true, "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "6.5.4" } }, "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", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-cli/-/workbox-cli-6.5.4.tgz", + "integrity": "sha512-+Cc0jYh25MofhCROZqfQkpYSAGvykyrUVekuuPaLFbJ8qxX/zzX8hRRpglfwxDwokAjz8S20oEph4s+MyQc+Yw==", + "dev": true, "dependencies": { "chalk": "^4.1.0", - "chokidar": "^3.6.0", + "chokidar": "^3.5.2", "common-tags": "^1.8.0", "fs-extra": "^9.0.1", - "glob": "^11.0.1", + "glob": "^7.1.6", "inquirer": "^7.3.3", "meow": "^7.1.0", "ora": "^5.0.0", "pretty-bytes": "^5.3.0", "stringify-object": "^3.3.0", "upath": "^1.2.0", - "update-notifier": "^7.3.1", - "workbox-build": "7.4.0" + "update-notifier": "^4.1.0", + "workbox-build": "6.5.4" }, "bin": { "workbox": "build/bin.js" }, "engines": { - "node": ">=20.0.0" + "node": ">=10.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", + "node_modules/workbox-cli/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { - "balanced-match": "^4.0.2" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "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", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/workbox-cli/node_modules/minimatch": { - "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": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, "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" + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-6.5.4.tgz", + "integrity": "sha512-OXYb+m9wZm8GrORlV2vBbE5EC1FKu71GGp0H4rjmxmF4/HLbMCoTFws87M3dFwgpmg0v00K++PImpNQ6J5NQ6Q==", + "dev": true }, "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", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-6.5.4.tgz", + "integrity": "sha512-jUP5qPOpH1nXtjGGh1fRBa1wJL2QlIb5mGpct3NzepjGG2uFFBn4iiEBiI9GUmfAFR2ApuRhDydjcRmYXddiEQ==", + "dev": true, "dependencies": { "idb": "^7.0.1", - "workbox-core": "7.4.0" + "workbox-core": "6.5.4" } }, + "node_modules/workbox-expiration/node_modules/idb": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.0.tgz", + "integrity": "sha512-Wsk07aAxDsntgYJY4h0knZJuTxM73eQ4reRAO+Z1liOh8eMCJ/MoDS8fCui1vGT9mnjtl1sOu3I2i/W1swPYZg==", + "dev": true + }, "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", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-6.5.4.tgz", + "integrity": "sha512-8AU1WuaXsD49249Wq0B2zn4a/vvFfHkpcFfqAFHNHwln3jK9QUYmzdkKXGIZl9wyKNP+RRX30vcgcyWMcZ9VAg==", + "dev": true, "dependencies": { - "workbox-background-sync": "7.4.0", - "workbox-core": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0" + "workbox-background-sync": "6.5.4", + "workbox-core": "6.5.4", + "workbox-routing": "6.5.4", + "workbox-strategies": "6.5.4" } }, "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", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-6.5.4.tgz", + "integrity": "sha512-IIwf80eO3cr8h6XSQJF+Hxj26rg2RPFVUmJLUlM0+A2GzB4HFbQyKkrgD5y2d84g2IbJzP4B4j5dPBRzamHrng==", + "dev": true, "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "6.5.4" } }, "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", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-6.5.4.tgz", + "integrity": "sha512-hSMezMsW6btKnxHB4bFy2Qfwey/8SYdGWvVIKFaUm8vJ4E53JAY+U2JwLTRD8wbLWoP6OVUdFlXsTdKu9yoLTg==", + "dev": true, "dependencies": { - "workbox-core": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0" + "workbox-core": "6.5.4", + "workbox-routing": "6.5.4", + "workbox-strategies": "6.5.4" } }, "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", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-6.5.4.tgz", + "integrity": "sha512-Je2qR1NXCFC8xVJ/Lux6saH6IrQGhMpDrPXWZWWS8n/RD+WZfKa6dSZwU+/QksfEadJEr/NfY+aP/CXFFK5JFg==", + "dev": true, "dependencies": { - "workbox-core": "7.4.0" - } - }, - "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", - "workbox-core": "7.4.0", - "workbox-expiration": "7.4.0", - "workbox-precaching": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0" + "workbox-core": "6.5.4" } }, "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", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-6.5.4.tgz", + "integrity": "sha512-apQswLsbrrOsBUWtr9Lf80F+P1sHnQdYodRo32SjiByYi36IDyL2r7BH1lJtFX8fwNHDa1QOVY74WKLLS6o5Pg==", + "dev": true, "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "6.5.4" } }, "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", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-6.5.4.tgz", + "integrity": "sha512-DEtsxhx0LIYWkJBTQolRxG4EI0setTJkqR4m7r4YpBdxtWJH1Mbg01Cj8ZjNOO8etqfA3IZaOPHUxCs8cBsKLw==", + "dev": true, "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "6.5.4" } }, "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", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-6.5.4.tgz", + "integrity": "sha512-FXKVh87d2RFXkliAIheBojBELIPnWbQdyDvsH3t74Cwhg0fDheL1T8BqSM86hZvC0ZESLsznSYWw+Va+KVbUzg==", + "dev": true, "dependencies": { - "workbox-core": "7.4.0", - "workbox-routing": "7.4.0" + "workbox-core": "6.5.4", + "workbox-routing": "6.5.4" } }, "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" + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-6.5.4.tgz", + "integrity": "sha512-vo2RQo7DILVRoH5LjGqw3nphavEjK4Qk+FenXeUsknKn14eCNedHOXWbmnvP4ipKhlE35pvJ4yl4YYf6YsJArA==", + "dev": true + }, + "node_modules/workbox-webpack-plugin": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-webpack-plugin/-/workbox-webpack-plugin-6.5.4.tgz", + "integrity": "sha512-LmWm/zoaahe0EGmMTrSLUi+BjyR3cdGEfU3fS6PN1zKFYbqAKuQ+Oy/27e4VSXsyIwAw8+QDfk1XHNGtZu9nQg==", + "dev": true, + "dependencies": { + "fast-json-stable-stringify": "^2.1.0", + "pretty-bytes": "^5.4.1", + "upath": "^1.2.0", + "webpack-sources": "^1.4.3", + "workbox-build": "6.5.4" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "webpack": "^4.4.0 || ^5.9.0" + } + }, + "node_modules/workbox-webpack-plugin/node_modules/webpack-sources": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", + "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", + "dev": true, + "dependencies": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + } }, "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", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-6.5.4.tgz", + "integrity": "sha512-HnLZJDwYBE+hpG25AQBO8RUWBJRaCsI9ksQJEp3aCOFCaG5kqaToAYXFRAHxzRluM2cQbGzdQF5rjKPWPA1fug==", + "dev": true, "dependencies": { "@types/trusted-types": "^2.0.2", - "workbox-core": "7.4.0" + "workbox-core": "6.5.4" } }, "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", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=18" + "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" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "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" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "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", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "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.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "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==", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", "dev": true, - "license": "ISC" + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", "dev": true, - "license": "MIT", "engines": { - "node": ">=10.0.0" + "node": ">=8.3.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" + "utf-8-validate": "^5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -13122,45 +20676,82 @@ } }, "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", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "dev": true, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=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": { - "node": ">=18" - } + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", + "dev": true }, "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 + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", "dev": true, - "license": "MIT" + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } }, "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "license": "ISC" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } }, "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", + "dev": true, "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" @@ -13169,12 +20760,29 @@ "node": ">=6" } }, + "node_modules/yargs-parser/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "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": { "node": ">=10" }, @@ -13182,5 +20790,15651 @@ "url": "https://github.com/sponsors/sindresorhus" } } + }, + "dependencies": { + "@adobe/css-tools": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.0.1.tgz", + "integrity": "sha512-+u76oB43nOHrF4DDWRLWDCtci7f3QJoEBigemIdIeTi1ODqjx6Tad9NCVnPRwewWlKkVab5PlK8DCtPTyX7S8g==", + "dev": true + }, + "@ampproject/remapping": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", + "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.1.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@babel/code-frame": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", + "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", + "dev": true, + "requires": { + "@babel/highlight": "^7.18.6" + } + }, + "@babel/compat-data": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.19.3.tgz", + "integrity": "sha512-prBHMK4JYYK+wDjJF1q99KK4JLL+egWS4nmNqdlMUgCExMZ+iZW0hGhyC3VEbsPjvaN0TBhW//VIFwBrk8sEiw==", + "dev": true + }, + "@babel/core": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.3.tgz", + "integrity": "sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.18.6", + "@babel/generator": "^7.19.3", + "@babel/helper-compilation-targets": "^7.19.3", + "@babel/helper-module-transforms": "^7.19.0", + "@babel/helpers": "^7.19.0", + "@babel/parser": "^7.19.3", + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.19.3", + "@babel/types": "^7.19.3", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@babel/eslint-parser": { + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.19.1.tgz", + "integrity": "sha512-AqNf2QWt1rtu2/1rLswy6CDP7H9Oh3mMhk177Y67Rg8d7RD9WfOLLv8CGn6tisFvS2htm86yIe1yLF6I1UDaGQ==", + "dev": true, + "requires": { + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", + "eslint-visitor-keys": "^2.1.0", + "semver": "^6.3.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@babel/generator": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.19.3.tgz", + "integrity": "sha512-fqVZnmp1ncvZU757UzDheKZpfPgatqY59XtW2/j/18H7u76akb8xqvjw82f+i2UKd/ksYsSick/BCLQUUtJ/qQ==", + "dev": true, + "requires": { + "@babel/types": "^7.19.3", + "@jridgewell/gen-mapping": "^0.3.2", + "jsesc": "^2.5.1" + }, + "dependencies": { + "@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + } + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz", + "integrity": "sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA==", + "dev": true, + "requires": { + "@babel/types": "^7.18.6" + } + }, + "@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.9.tgz", + "integrity": "sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw==", + "dev": true, + "requires": { + "@babel/helper-explode-assignable-expression": "^7.18.6", + "@babel/types": "^7.18.9" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.3.tgz", + "integrity": "sha512-65ESqLGyGmLvgR0mst5AdW1FkNlj9rQsCKduzEoEPhBCDFGXvz2jW6bXFG6i0/MrV2s7hhXjjb2yAzcPuQlLwg==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.19.3", + "@babel/helper-validator-option": "^7.18.6", + "browserslist": "^4.21.3", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@babel/helper-create-class-features-plugin": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.19.0.tgz", + "integrity": "sha512-NRz8DwF4jT3UfrmUoZjd0Uph9HQnP30t7Ash+weACcyNkiYTywpIjDBgReJMKgr+n86sn2nPVVmJ28Dm053Kqw==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-function-name": "^7.19.0", + "@babel/helper-member-expression-to-functions": "^7.18.9", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/helper-replace-supers": "^7.18.9", + "@babel/helper-split-export-declaration": "^7.18.6" + } + }, + "@babel/helper-create-regexp-features-plugin": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.19.0.tgz", + "integrity": "sha512-htnV+mHX32DF81amCDrwIDr8nrp1PTm+3wfBN9/v8QJOLEioOCOG7qNyq0nHeFiWbT3Eb7gsPwEmV64UCQ1jzw==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "regexpu-core": "^5.1.0" + } + }, + "@babel/helper-define-polyfill-provider": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz", + "integrity": "sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww==", + "dev": true, + "requires": { + "@babel/helper-compilation-targets": "^7.17.7", + "@babel/helper-plugin-utils": "^7.16.7", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2", + "semver": "^6.1.2" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@babel/helper-environment-visitor": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz", + "integrity": "sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg==", + "dev": true + }, + "@babel/helper-explode-assignable-expression": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz", + "integrity": "sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg==", + "dev": true, + "requires": { + "@babel/types": "^7.18.6" + } + }, + "@babel/helper-function-name": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz", + "integrity": "sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w==", + "dev": true, + "requires": { + "@babel/template": "^7.18.10", + "@babel/types": "^7.19.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz", + "integrity": "sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q==", + "dev": true, + "requires": { + "@babel/types": "^7.18.6" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz", + "integrity": "sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg==", + "dev": true, + "requires": { + "@babel/types": "^7.18.9" + } + }, + "@babel/helper-module-imports": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", + "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", + "dev": true, + "requires": { + "@babel/types": "^7.18.6" + } + }, + "@babel/helper-module-transforms": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz", + "integrity": "sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-simple-access": "^7.18.6", + "@babel/helper-split-export-declaration": "^7.18.6", + "@babel/helper-validator-identifier": "^7.18.6", + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.19.0", + "@babel/types": "^7.19.0" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz", + "integrity": "sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA==", + "dev": true, + "requires": { + "@babel/types": "^7.18.6" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz", + "integrity": "sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw==", + "dev": true + }, + "@babel/helper-remap-async-to-generator": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz", + "integrity": "sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-wrap-function": "^7.18.9", + "@babel/types": "^7.18.9" + } + }, + "@babel/helper-replace-supers": { + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.19.1.tgz", + "integrity": "sha512-T7ahH7wV0Hfs46SFh5Jz3s0B6+o8g3c+7TMxu7xKfmHikg7EAZ3I2Qk9LFhjxXq8sL7UkP5JflezNwoZa8WvWw==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-member-expression-to-functions": "^7.18.9", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/traverse": "^7.19.1", + "@babel/types": "^7.19.0" + } + }, + "@babel/helper-simple-access": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz", + "integrity": "sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g==", + "dev": true, + "requires": { + "@babel/types": "^7.18.6" + } + }, + "@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.18.9.tgz", + "integrity": "sha512-imytd2gHi3cJPsybLRbmFrF7u5BIEuI2cNheyKi3/iOBC63kNn3q8Crn2xVuESli0aM4KYsyEqKyS7lFL8YVtw==", + "dev": true, + "requires": { + "@babel/types": "^7.18.9" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz", + "integrity": "sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA==", + "dev": true, + "requires": { + "@babel/types": "^7.18.6" + } + }, + "@babel/helper-string-parser": { + "version": "7.18.10", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz", + "integrity": "sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", + "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz", + "integrity": "sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==", + "dev": true + }, + "@babel/helper-wrap-function": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.19.0.tgz", + "integrity": "sha512-txX8aN8CZyYGTwcLhlk87KRqncAzhh5TpQamZUa0/u3an36NtDpUP6bQgBCBcLeBs09R/OwQu3OjK0k/HwfNDg==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.19.0", + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.19.0", + "@babel/types": "^7.19.0" + } + }, + "@babel/helpers": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.19.0.tgz", + "integrity": "sha512-DRBCKGwIEdqY3+rPJgG/dKfQy9+08rHIAJx8q2p+HSWP87s2HCrQmaAMMyMll2kIXKCW0cO1RdQskx15Xakftg==", + "dev": true, + "requires": { + "@babel/template": "^7.18.10", + "@babel/traverse": "^7.19.0", + "@babel/types": "^7.19.0" + } + }, + "@babel/highlight": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", + "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.18.6", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "@babel/parser": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.19.3.tgz", + "integrity": "sha512-pJ9xOlNWHiy9+FuFP09DEAFbAn4JskgRsVcc169w2xRBC3FRGuQEwjeIMMND9L2zc0iEhO/tGv4Zq+km+hxNpQ==", + "dev": true + }, + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.18.6", + "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.18.6.tgz", + "integrity": "sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.18.9.tgz", + "integrity": "sha512-AHrP9jadvH7qlOj6PINbgSuphjQUAK7AOT7DPjBo9EHoLhQTnnK5u45e1Hd4DbSQEO9nqPWtQ89r+XEOWFScKg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9", + "@babel/plugin-proposal-optional-chaining": "^7.18.9" + } + }, + "@babel/plugin-proposal-async-generator-functions": { + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.19.1.tgz", + "integrity": "sha512-0yu8vNATgLy4ivqMNBIwb1HebCelqN7YX8SL3FDXORv/RqT0zEEWUCH4GH44JsSrvCu6GqnAdR5EBFAPeNBB4Q==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/helper-remap-async-to-generator": "^7.18.9", + "@babel/plugin-syntax-async-generators": "^7.8.4" + } + }, + "@babel/plugin-proposal-class-properties": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz", + "integrity": "sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-proposal-class-static-block": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.18.6.tgz", + "integrity": "sha512-+I3oIiNxrCpup3Gi8n5IGMwj0gOCAjcJUSQEcotNnCCPMEnixawOQ+KeJPlgfjzx+FKQ1QSyZOWe7wmoJp7vhw==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + } + }, + "@babel/plugin-proposal-decorators": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.19.3.tgz", + "integrity": "sha512-MbgXtNXqo7RTKYIXVchVJGPvaVufQH3pxvQyfbGvNw1DObIhph+PesYXJTcd8J4DdWibvf6Z2eanOyItX8WnJg==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.19.0", + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/helper-replace-supers": "^7.19.1", + "@babel/helper-split-export-declaration": "^7.18.6", + "@babel/plugin-syntax-decorators": "^7.19.0" + } + }, + "@babel/plugin-proposal-dynamic-import": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz", + "integrity": "sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + } + }, + "@babel/plugin-proposal-export-namespace-from": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz", + "integrity": "sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + } + }, + "@babel/plugin-proposal-json-strings": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz", + "integrity": "sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-json-strings": "^7.8.3" + } + }, + "@babel/plugin-proposal-logical-assignment-operators": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.18.9.tgz", + "integrity": "sha512-128YbMpjCrP35IOExw2Fq+x55LMP42DzhOhX2aNNIdI9avSWl2PI0yuBWarr3RYpZBSPtabfadkH2yeRiMD61Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + } + }, + "@babel/plugin-proposal-nullish-coalescing-operator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz", + "integrity": "sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + } + }, + "@babel/plugin-proposal-numeric-separator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz", + "integrity": "sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + } + }, + "@babel/plugin-proposal-object-rest-spread": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.18.9.tgz", + "integrity": "sha512-kDDHQ5rflIeY5xl69CEqGEZ0KY369ehsCIEbTGb4siHG5BE9sga/T0r0OUwyZNLMmZE79E1kbsqAjwFCW4ds6Q==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.18.8", + "@babel/helper-compilation-targets": "^7.18.9", + "@babel/helper-plugin-utils": "^7.18.9", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.18.8" + } + }, + "@babel/plugin-proposal-optional-catch-binding": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz", + "integrity": "sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + } + }, + "@babel/plugin-proposal-optional-chaining": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.18.9.tgz", + "integrity": "sha512-v5nwt4IqBXihxGsW2QmCWMDS3B3bzGIk/EQVZz2ei7f3NJl8NzAJVvUmpDW5q1CRNY+Beb/k58UAH1Km1N411w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + } + }, + "@babel/plugin-proposal-private-methods": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz", + "integrity": "sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-proposal-private-property-in-object": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.18.6.tgz", + "integrity": "sha512-9Rysx7FOctvT5ouj5JODjAFAkgGoudQuLPamZb0v1TGLpapdNaftzifU8NTWQm0IRjqoYypdrSmyWgkocDQ8Dw==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-create-class-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + } + }, + "@babel/plugin-proposal-unicode-property-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz", + "integrity": "sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-decorators": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.19.0.tgz", + "integrity": "sha512-xaBZUEDntt4faL1yN8oIFlhfXeQAWJW7CLKYsHTUqriCUbj8xOra8bfxxKGi/UwExPFBuPdH4XfHc9rGQhrVkQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.19.0" + } + }, + "@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-syntax-flow": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.18.6.tgz", + "integrity": "sha512-LUbR+KNTBWCUAqRG9ex5Gnzu2IOkt8jRJbHHXFT9q+L9zm7M/QQbEqXyw1n1pohYvOyWC8CjeyjrSaIwiYjK7A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-syntax-import-assertions": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.18.6.tgz", + "integrity": "sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-jsx": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz", + "integrity": "sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-typescript": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.18.6.tgz", + "integrity": "sha512-mAWAuq4rvOepWCBid55JuRNvpTNf2UGVgoz4JV0fXEKolsVZDzsa4NqCef758WZJj/GDu0gVGItjKFiClTAmZA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-arrow-functions": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.18.6.tgz", + "integrity": "sha512-9S9X9RUefzrsHZmKMbDXxweEH+YlE8JJEuat9FdvW9Qh1cw7W64jELCtWNkPBPX5En45uy28KGvA/AySqUh8CQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-async-to-generator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.18.6.tgz", + "integrity": "sha512-ARE5wZLKnTgPW7/1ftQmSi1CmkqqHo2DNmtztFhvgtOWSDfq0Cq9/9L+KnZNYSNrydBekhW3rwShduf59RoXag==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-remap-async-to-generator": "^7.18.6" + } + }, + "@babel/plugin-transform-block-scoped-functions": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz", + "integrity": "sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-block-scoping": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.18.9.tgz", + "integrity": "sha512-5sDIJRV1KtQVEbt/EIBwGy4T01uYIo4KRB3VUqzkhrAIOGx7AoctL9+Ux88btY0zXdDyPJ9mW+bg+v+XEkGmtw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9" + } + }, + "@babel/plugin-transform-classes": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.19.0.tgz", + "integrity": "sha512-YfeEE9kCjqTS9IitkgfJuxjcEtLUHMqa8yUJ6zdz8vR7hKuo6mOy2C05P0F1tdMmDCeuyidKnlrw/iTppHcr2A==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-compilation-targets": "^7.19.0", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-function-name": "^7.19.0", + "@babel/helper-optimise-call-expression": "^7.18.6", + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/helper-replace-supers": "^7.18.9", + "@babel/helper-split-export-declaration": "^7.18.6", + "globals": "^11.1.0" + } + }, + "@babel/plugin-transform-computed-properties": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.18.9.tgz", + "integrity": "sha512-+i0ZU1bCDymKakLxn5srGHrsAPRELC2WIbzwjLhHW9SIE1cPYkLCL0NlnXMZaM1vhfgA2+M7hySk42VBvrkBRw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9" + } + }, + "@babel/plugin-transform-destructuring": { + "version": "7.18.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.13.tgz", + "integrity": "sha512-TodpQ29XekIsex2A+YJPj5ax2plkGa8YYY6mFjCohk/IG9IY42Rtuj1FuDeemfg2ipxIFLzPeA83SIBnlhSIow==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9" + } + }, + "@babel/plugin-transform-dotall-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz", + "integrity": "sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-duplicate-keys": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz", + "integrity": "sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9" + } + }, + "@babel/plugin-transform-exponentiation-operator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz", + "integrity": "sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw==", + "dev": true, + "requires": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-flow-strip-types": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.19.0.tgz", + "integrity": "sha512-sgeMlNaQVbCSpgLSKP4ZZKfsJVnFnNQlUSk6gPYzR/q7tzCgQF2t8RBKAP6cKJeZdveei7Q7Jm527xepI8lNLg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/plugin-syntax-flow": "^7.18.6" + } + }, + "@babel/plugin-transform-for-of": { + "version": "7.18.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz", + "integrity": "sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-function-name": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz", + "integrity": "sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ==", + "dev": true, + "requires": { + "@babel/helper-compilation-targets": "^7.18.9", + "@babel/helper-function-name": "^7.18.9", + "@babel/helper-plugin-utils": "^7.18.9" + } + }, + "@babel/plugin-transform-literals": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz", + "integrity": "sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9" + } + }, + "@babel/plugin-transform-member-expression-literals": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz", + "integrity": "sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-modules-amd": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.18.6.tgz", + "integrity": "sha512-Pra5aXsmTsOnjM3IajS8rTaLCy++nGM4v3YR4esk5PCsyg9z8NA5oQLwxzMUtDBd8F+UmVza3VxoAaWCbzH1rg==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6", + "babel-plugin-dynamic-import-node": "^2.3.3" + } + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.18.6.tgz", + "integrity": "sha512-Qfv2ZOWikpvmedXQJDSbxNqy7Xr/j2Y8/KfijM0iJyKkBTmWuvCA1yeH1yDM7NJhBW/2aXxeucLj6i80/LAJ/Q==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-simple-access": "^7.18.6", + "babel-plugin-dynamic-import-node": "^2.3.3" + } + }, + "@babel/plugin-transform-modules-systemjs": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.19.0.tgz", + "integrity": "sha512-x9aiR0WXAWmOWsqcsnrzGR+ieaTMVyGyffPVA7F8cXAGt/UxefYv6uSHZLkAFChN5M5Iy1+wjE+xJuPt22H39A==", + "dev": true, + "requires": { + "@babel/helper-hoist-variables": "^7.18.6", + "@babel/helper-module-transforms": "^7.19.0", + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/helper-validator-identifier": "^7.18.6", + "babel-plugin-dynamic-import-node": "^2.3.3" + } + }, + "@babel/plugin-transform-modules-umd": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz", + "integrity": "sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.19.1.tgz", + "integrity": "sha512-oWk9l9WItWBQYS4FgXD4Uyy5kq898lvkXpXQxoJEY1RnvPk4R/Dvu2ebXU9q8lP+rlMwUQTFf2Ok6d78ODa0kw==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.19.0", + "@babel/helper-plugin-utils": "^7.19.0" + } + }, + "@babel/plugin-transform-new-target": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz", + "integrity": "sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-object-super": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz", + "integrity": "sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-replace-supers": "^7.18.6" + } + }, + "@babel/plugin-transform-parameters": { + "version": "7.18.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.18.8.tgz", + "integrity": "sha512-ivfbE3X2Ss+Fj8nnXvKJS6sjRG4gzwPMsP+taZC+ZzEGjAYlvENixmt1sZ5Ca6tWls+BlKSGKPJ6OOXvXCbkFg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-property-literals": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz", + "integrity": "sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-react-constant-elements": { + "version": "7.13.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.13.13.tgz", + "integrity": "sha512-SNJU53VM/SjQL0bZhyU+f4kJQz7bQQajnrZRSaU21hruG/NWY41AEM9AWXeXX90pYr/C2yAmTgI6yW3LlLrAUQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.13.0" + } + }, + "@babel/plugin-transform-react-display-name": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.18.6.tgz", + "integrity": "sha512-TV4sQ+T013n61uMoygyMRm+xf04Bd5oqFpv2jAEQwSZ8NwQA7zeRPg1LMVg2PWi3zWBz+CLKD+v5bcpZ/BS0aA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-react-jsx": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.19.0.tgz", + "integrity": "sha512-UVEvX3tXie3Szm3emi1+G63jyw1w5IcMY0FSKM+CRnKRI5Mr1YbCNgsSTwoTwKphQEG9P+QqmuRFneJPZuHNhg==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/plugin-syntax-jsx": "^7.18.6", + "@babel/types": "^7.19.0" + } + }, + "@babel/plugin-transform-react-jsx-development": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.18.6.tgz", + "integrity": "sha512-SA6HEjwYFKF7WDjWcMcMGUimmw/nhNRDWxr+KaLSCrkD/LMDBvWRmHAYgE1HDeF8KUuI8OAu+RT6EOtKxSW2qA==", + "dev": true, + "requires": { + "@babel/plugin-transform-react-jsx": "^7.18.6" + } + }, + "@babel/plugin-transform-react-pure-annotations": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.18.6.tgz", + "integrity": "sha512-I8VfEPg9r2TRDdvnHgPepTKvuRomzA8+u+nhY7qSI1fR2hRNebasZEETLyM5mAUr0Ku56OkXJ0I7NHJnO6cJiQ==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-regenerator": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz", + "integrity": "sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "regenerator-transform": "^0.15.0" + } + }, + "@babel/plugin-transform-reserved-words": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz", + "integrity": "sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-runtime": { + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.19.1.tgz", + "integrity": "sha512-2nJjTUFIzBMP/f/miLxEK9vxwW/KUXsdvN4sR//TmuDhe6yU2h57WmIOE12Gng3MDP/xpjUV/ToZRdcf8Yj4fA==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.18.6", + "@babel/helper-plugin-utils": "^7.19.0", + "babel-plugin-polyfill-corejs2": "^0.3.3", + "babel-plugin-polyfill-corejs3": "^0.6.0", + "babel-plugin-polyfill-regenerator": "^0.4.1", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@babel/plugin-transform-shorthand-properties": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz", + "integrity": "sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-spread": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.19.0.tgz", + "integrity": "sha512-RsuMk7j6n+r752EtzyScnWkQyuJdli6LdO5Klv8Yx0OfPVTcQkIUfS8clx5e9yHXzlnhOZF3CbQ8C2uP5j074w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.18.9" + } + }, + "@babel/plugin-transform-sticky-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz", + "integrity": "sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-template-literals": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz", + "integrity": "sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9" + } + }, + "@babel/plugin-transform-typeof-symbol": { + "version": "7.18.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz", + "integrity": "sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9" + } + }, + "@babel/plugin-transform-typescript": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.19.3.tgz", + "integrity": "sha512-z6fnuK9ve9u/0X0rRvI9MY0xg+DOUaABDYOe+/SQTxtlptaBB/V9JIUxJn6xp3lMBeb9qe8xSFmHU35oZDXD+w==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.19.0", + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/plugin-syntax-typescript": "^7.18.6" + } + }, + "@babel/plugin-transform-unicode-escapes": { + "version": "7.18.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz", + "integrity": "sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.9" + } + }, + "@babel/plugin-transform-unicode-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz", + "integrity": "sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/preset-env": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.19.3.tgz", + "integrity": "sha512-ziye1OTc9dGFOAXSWKUqQblYHNlBOaDl8wzqf2iKXJAltYiR3hKHUKmkt+S9PppW7RQpq4fFCrwwpIDj/f5P4w==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.19.3", + "@babel/helper-compilation-targets": "^7.19.3", + "@babel/helper-plugin-utils": "^7.19.0", + "@babel/helper-validator-option": "^7.18.6", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.18.6", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.18.9", + "@babel/plugin-proposal-async-generator-functions": "^7.19.1", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-proposal-class-static-block": "^7.18.6", + "@babel/plugin-proposal-dynamic-import": "^7.18.6", + "@babel/plugin-proposal-export-namespace-from": "^7.18.9", + "@babel/plugin-proposal-json-strings": "^7.18.6", + "@babel/plugin-proposal-logical-assignment-operators": "^7.18.9", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6", + "@babel/plugin-proposal-numeric-separator": "^7.18.6", + "@babel/plugin-proposal-object-rest-spread": "^7.18.9", + "@babel/plugin-proposal-optional-catch-binding": "^7.18.6", + "@babel/plugin-proposal-optional-chaining": "^7.18.9", + "@babel/plugin-proposal-private-methods": "^7.18.6", + "@babel/plugin-proposal-private-property-in-object": "^7.18.6", + "@babel/plugin-proposal-unicode-property-regex": "^7.18.6", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-import-assertions": "^7.18.6", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-transform-arrow-functions": "^7.18.6", + "@babel/plugin-transform-async-to-generator": "^7.18.6", + "@babel/plugin-transform-block-scoped-functions": "^7.18.6", + "@babel/plugin-transform-block-scoping": "^7.18.9", + "@babel/plugin-transform-classes": "^7.19.0", + "@babel/plugin-transform-computed-properties": "^7.18.9", + "@babel/plugin-transform-destructuring": "^7.18.13", + "@babel/plugin-transform-dotall-regex": "^7.18.6", + "@babel/plugin-transform-duplicate-keys": "^7.18.9", + "@babel/plugin-transform-exponentiation-operator": "^7.18.6", + "@babel/plugin-transform-for-of": "^7.18.8", + "@babel/plugin-transform-function-name": "^7.18.9", + "@babel/plugin-transform-literals": "^7.18.9", + "@babel/plugin-transform-member-expression-literals": "^7.18.6", + "@babel/plugin-transform-modules-amd": "^7.18.6", + "@babel/plugin-transform-modules-commonjs": "^7.18.6", + "@babel/plugin-transform-modules-systemjs": "^7.19.0", + "@babel/plugin-transform-modules-umd": "^7.18.6", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.19.1", + "@babel/plugin-transform-new-target": "^7.18.6", + "@babel/plugin-transform-object-super": "^7.18.6", + "@babel/plugin-transform-parameters": "^7.18.8", + "@babel/plugin-transform-property-literals": "^7.18.6", + "@babel/plugin-transform-regenerator": "^7.18.6", + "@babel/plugin-transform-reserved-words": "^7.18.6", + "@babel/plugin-transform-shorthand-properties": "^7.18.6", + "@babel/plugin-transform-spread": "^7.19.0", + "@babel/plugin-transform-sticky-regex": "^7.18.6", + "@babel/plugin-transform-template-literals": "^7.18.9", + "@babel/plugin-transform-typeof-symbol": "^7.18.9", + "@babel/plugin-transform-unicode-escapes": "^7.18.10", + "@babel/plugin-transform-unicode-regex": "^7.18.6", + "@babel/preset-modules": "^0.1.5", + "@babel/types": "^7.19.3", + "babel-plugin-polyfill-corejs2": "^0.3.3", + "babel-plugin-polyfill-corejs3": "^0.6.0", + "babel-plugin-polyfill-regenerator": "^0.4.1", + "core-js-compat": "^3.25.1", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "@babel/preset-modules": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.5.tgz", + "integrity": "sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/plugin-proposal-unicode-property-regex": "^7.4.4", + "@babel/plugin-transform-dotall-regex": "^7.4.4", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + } + }, + "@babel/preset-react": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.18.6.tgz", + "integrity": "sha512-zXr6atUmyYdiWRVLOZahakYmOBHtWc2WGCkP8PYTgZi0iJXDY2CN180TdrIW4OGOAdLc7TifzDIvtx6izaRIzg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-validator-option": "^7.18.6", + "@babel/plugin-transform-react-display-name": "^7.18.6", + "@babel/plugin-transform-react-jsx": "^7.18.6", + "@babel/plugin-transform-react-jsx-development": "^7.18.6", + "@babel/plugin-transform-react-pure-annotations": "^7.18.6" + } + }, + "@babel/preset-typescript": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.18.6.tgz", + "integrity": "sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.18.6", + "@babel/helper-validator-option": "^7.18.6", + "@babel/plugin-transform-typescript": "^7.18.6" + } + }, + "@babel/runtime": { + "version": "7.21.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.5.tgz", + "integrity": "sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, + "@babel/runtime-corejs3": { + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.11.2.tgz", + "integrity": "sha512-qh5IR+8VgFz83VBa6OkaET6uN/mJOhHONuy3m1sgF0CV6mXdPSEBdA7e1eUbVvyNtANjMbg22JUv71BaDXLY6A==", + "dev": true, + "requires": { + "core-js-pure": "^3.0.0", + "regenerator-runtime": "^0.13.4" + } + }, + "@babel/template": { + "version": "7.18.10", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz", + "integrity": "sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.18.6", + "@babel/parser": "^7.18.10", + "@babel/types": "^7.18.10" + } + }, + "@babel/traverse": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.19.3.tgz", + "integrity": "sha512-qh5yf6149zhq2sgIXmwjnsvmnNQC2iw70UFjp4olxucKrWd/dvlUsBI88VSLUsnMNF7/vnOiA+nk1+yLoCqROQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.18.6", + "@babel/generator": "^7.19.3", + "@babel/helper-environment-visitor": "^7.18.9", + "@babel/helper-function-name": "^7.19.0", + "@babel/helper-hoist-variables": "^7.18.6", + "@babel/helper-split-export-declaration": "^7.18.6", + "@babel/parser": "^7.19.3", + "@babel/types": "^7.19.3", + "debug": "^4.1.0", + "globals": "^11.1.0" + } + }, + "@babel/types": { + "version": "7.19.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.19.3.tgz", + "integrity": "sha512-hGCaQzIY22DJlDh9CH7NOxgKkFjBk0Cw9xDO1Xmh2151ti7wiGfQ3LauXzL4HP1fmFlTX6XjpRETTpUcv7wQLw==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.18.10", + "@babel/helper-validator-identifier": "^7.19.1", + "to-fast-properties": "^2.0.0" + } + }, + "@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "@csstools/normalize.css": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.0.0.tgz", + "integrity": "sha512-M0qqxAcwCsIVfpFQSlGN5XjXWu8l5JDZN+fPt1LeW5SZexQTgnaEvgXAY+CeygRw0EeppWHi12JxESWiWrB0Sg==", + "dev": true + }, + "@csstools/postcss-cascade-layers": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-1.1.1.tgz", + "integrity": "sha512-+KdYrpKC5TgomQr2DlZF4lDEpHcoxnj5IGddYYfBWJAKfj1JtuHUIqMa+E1pJJ+z3kvDViWMqyqPlG4Ja7amQA==", + "dev": true, + "requires": { + "@csstools/selector-specificity": "^2.0.2", + "postcss-selector-parser": "^6.0.10" + } + }, + "@csstools/postcss-color-function": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-1.1.1.tgz", + "integrity": "sha512-Bc0f62WmHdtRDjf5f3e2STwRAl89N2CLb+9iAwzrv4L2hncrbDwnQD9PCq0gtAt7pOI2leIV08HIBUd4jxD8cw==", + "dev": true, + "requires": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + } + }, + "@csstools/postcss-font-format-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-1.0.1.tgz", + "integrity": "sha512-ZgrlzuUAjXIOc2JueK0X5sZDjCtgimVp/O5CEqTcs5ShWBa6smhWYbS0x5cVc/+rycTDbjjzoP0KTDnUneZGOg==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "@csstools/postcss-hwb-function": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-1.0.2.tgz", + "integrity": "sha512-YHdEru4o3Rsbjmu6vHy4UKOXZD+Rn2zmkAmLRfPet6+Jz4Ojw8cbWxe1n42VaXQhD3CQUXXTooIy8OkVbUcL+w==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "@csstools/postcss-ic-unit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-1.0.1.tgz", + "integrity": "sha512-Ot1rcwRAaRHNKC9tAqoqNZhjdYBzKk1POgWfhN4uCOE47ebGcLRqXjKkApVDpjifL6u2/55ekkpnFcp+s/OZUw==", + "dev": true, + "requires": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + } + }, + "@csstools/postcss-is-pseudo-class": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-2.0.7.tgz", + "integrity": "sha512-7JPeVVZHd+jxYdULl87lvjgvWldYu+Bc62s9vD/ED6/QTGjy0jy0US/f6BG53sVMTBJ1lzKZFpYmofBN9eaRiA==", + "dev": true, + "requires": { + "@csstools/selector-specificity": "^2.0.0", + "postcss-selector-parser": "^6.0.10" + } + }, + "@csstools/postcss-nested-calc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-1.0.0.tgz", + "integrity": "sha512-JCsQsw1wjYwv1bJmgjKSoZNvf7R6+wuHDAbi5f/7MbFhl2d/+v+TvBTU4BJH3G1X1H87dHl0mh6TfYogbT/dJQ==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "@csstools/postcss-normalize-display-values": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-1.0.1.tgz", + "integrity": "sha512-jcOanIbv55OFKQ3sYeFD/T0Ti7AMXc9nM1hZWu8m/2722gOTxFg7xYu4RDLJLeZmPUVQlGzo4jhzvTUq3x4ZUw==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "@csstools/postcss-oklab-function": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-1.1.1.tgz", + "integrity": "sha512-nJpJgsdA3dA9y5pgyb/UfEzE7W5Ka7u0CX0/HIMVBNWzWemdcTH3XwANECU6anWv/ao4vVNLTMxhiPNZsTK6iA==", + "dev": true, + "requires": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + } + }, + "@csstools/postcss-progressive-custom-properties": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-1.3.0.tgz", + "integrity": "sha512-ASA9W1aIy5ygskZYuWams4BzafD12ULvSypmaLJT2jvQ8G0M3I8PRQhC0h7mG0Z3LI05+agZjqSR9+K9yaQQjA==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "@csstools/postcss-stepped-value-functions": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-1.0.1.tgz", + "integrity": "sha512-dz0LNoo3ijpTOQqEJLY8nyaapl6umbmDcgj4AD0lgVQ572b2eqA1iGZYTTWhrcrHztWDDRAX2DGYyw2VBjvCvQ==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "@csstools/postcss-text-decoration-shorthand": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-1.0.0.tgz", + "integrity": "sha512-c1XwKJ2eMIWrzQenN0XbcfzckOLLJiczqy+YvfGmzoVXd7pT9FfObiSEfzs84bpE/VqfpEuAZ9tCRbZkZxxbdw==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "@csstools/postcss-trigonometric-functions": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-1.0.2.tgz", + "integrity": "sha512-woKaLO///4bb+zZC2s80l+7cm07M7268MsyG3M0ActXXEFi6SuhvriQYcb58iiKGbjwwIU7n45iRLEHypB47Og==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "@csstools/postcss-unset-value": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-1.0.2.tgz", + "integrity": "sha512-c8J4roPBILnelAsdLr4XOAR/GsTm0GJi4XpcfvoWk3U6KiTCqiFYc63KhRMQQX35jYMp4Ao8Ij9+IZRgMfJp1g==", + "dev": true, + "requires": {} + }, + "@csstools/selector-specificity": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.0.2.tgz", + "integrity": "sha512-IkpVW/ehM1hWKln4fCA3NzJU8KwD+kIOvPZA4cqxoJHtE21CCzjyp+Kxbu0i5I4tBNOlXPL9mjwnWlL0VEG4Fg==", + "dev": true, + "requires": {} + }, + "@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" + }, + "@eslint/eslintrc": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.2.tgz", + "integrity": "sha512-AXYd23w1S/bv3fTs3Lz0vjiYemS08jWkI3hYyS9I1ry+0f+Yjs1wm+sU0BS8qDOPrBIkp4qHYC16I8uVtpLajQ==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.4.0", + "globals": "^13.15.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "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 + }, + "globals": { + "version": "13.17.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.17.0.tgz", + "integrity": "sha512-1C+6nQRb1GwGMKm2dH/E7enFAMxGTmGI7/dEdhy/DNelv85w9B72t3uc5frtMNXIbzrarJJ/lTCjcaZwbLJmyw==", + "dev": true, + "requires": { + "type-fest": "^0.20.2" + } + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "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 + } + } + }, + "@humanwhocodes/config-array": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.6.tgz", + "integrity": "sha512-U/piU+VwXZsIgwnl+N+nRK12jCpHdc3s0UAc6zc1+HUgiESJxClpvYao/x9JwaN7onNeVb7kTlxlAvuEoaJ3ig==", + "dev": true, + "requires": { + "@humanwhocodes/object-schema": "^1.2.1", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + } + }, + "@humanwhocodes/gitignore-to-minimatch": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/gitignore-to-minimatch/-/gitignore-to-minimatch-1.0.2.tgz", + "integrity": "sha512-rSqmMJDdLFUsyxR6FMtD00nfQKKLFb1kv+qBbOVKqErvloEIJLo5bDTJTQNTYgeyp78JsA7u/NPi5jT1GR/MuA==", + "dev": true + }, + "@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 + }, + "@humanwhocodes/object-schema": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", + "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "dev": true + }, + "@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true + }, + "@jest/console": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", + "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "dev": true, + "requires": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jest/core": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz", + "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==", + "dev": true, + "requires": { + "@jest/console": "^27.5.1", + "@jest/reporters": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^27.5.1", + "jest-config": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-resolve-dependencies": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "jest-watcher": "^27.5.1", + "micromatch": "^4.0.4", + "rimraf": "^3.0.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jest/environment": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", + "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", + "dev": true, + "requires": { + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jest/fake-timers": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", + "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", + "dev": true, + "requires": { + "@jest/types": "^27.5.1", + "@sinonjs/fake-timers": "^8.0.1", + "@types/node": "*", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jest/globals": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", + "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", + "dev": true, + "requires": { + "@jest/environment": "^27.5.1", + "@jest/types": "^27.5.1", + "expect": "^27.5.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jest/reporters": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.5.1.tgz", + "integrity": "sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==", + "dev": true, + "requires": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.2", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^5.1.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-haste-map": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "slash": "^3.0.0", + "source-map": "^0.6.0", + "string-length": "^4.0.1", + "terminal-link": "^2.0.0", + "v8-to-istanbul": "^8.1.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + } + } + }, + "@jest/schemas": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", + "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", + "dev": true, + "requires": { + "@sinclair/typebox": "^0.24.1" + } + }, + "@jest/source-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", + "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", + "dev": true, + "requires": { + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9", + "source-map": "^0.6.0" + } + }, + "@jest/test-result": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", + "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", + "dev": true, + "requires": { + "@jest/console": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jest/test-sequencer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz", + "integrity": "sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==", + "dev": true, + "requires": { + "@jest/test-result": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-runtime": "^27.5.1" + } + }, + "@jest/transform": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", + "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", + "dev": true, + "requires": { + "@babel/core": "^7.1.0", + "@jest/types": "^27.5.1", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-util": "^27.5.1", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + }, + "dependencies": { + "chalk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "@jridgewell/gen-mapping": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", + "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.0", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "dev": true + }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true + }, + "@jridgewell/source-map": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", + "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "dependencies": { + "@jridgewell/gen-mapping": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", + "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + } + } + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz", + "integrity": "sha512-oWZNOULl+UbhsgB51uuZzglikfIKSUBO/M9W2OfEjn7cmqoAiCgmv9lyACTUacZwBz0ITnJ2NqjU8Tx0DHL88g==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "@leichtgewicht/ip-codec": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", + "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", + "dev": true + }, + "@material-ui/core": { + "version": "4.12.4", + "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.4.tgz", + "integrity": "sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ==", + "requires": { + "@babel/runtime": "^7.4.4", + "@material-ui/styles": "^4.11.5", + "@material-ui/system": "^4.12.2", + "@material-ui/types": "5.1.0", + "@material-ui/utils": "^4.11.3", + "@types/react-transition-group": "^4.2.0", + "clsx": "^1.0.4", + "hoist-non-react-statics": "^3.3.2", + "popper.js": "1.16.1-lts", + "prop-types": "^15.7.2", + "react-is": "^16.8.0 || ^17.0.0", + "react-transition-group": "^4.4.0" + } + }, + "@material-ui/icons": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.2.tgz", + "integrity": "sha512-fQNsKX2TxBmqIGJCSi3tGTO/gZ+eJgWmMJkgDiOfyNaunNaxcklJQFaFogYcFl0qFuaEz1qaXYXboa/bUXVSOQ==", + "requires": { + "@babel/runtime": "^7.4.4" + } + }, + "@material-ui/lab": { + "version": "4.0.0-alpha.58", + "resolved": "https://registry.npmjs.org/@material-ui/lab/-/lab-4.0.0-alpha.58.tgz", + "integrity": "sha512-GKHlJqLxUeHH3L3dGQ48ZavYrqGOTXkFkiEiuYMAnAvXAZP4rhMIqeHOPXSUQan4Bd8QnafDcpovOSLnadDmKw==", + "requires": { + "@babel/runtime": "^7.4.4", + "@material-ui/utils": "^4.11.2", + "clsx": "^1.0.4", + "prop-types": "^15.7.2", + "react-is": "^16.8.0 || ^17.0.0" + } + }, + "@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==", + "requires": { + "@babel/runtime": "^7.4.4", + "@emotion/hash": "^0.8.0", + "@material-ui/types": "5.1.0", + "@material-ui/utils": "^4.11.3", + "clsx": "^1.0.4", + "csstype": "^2.5.2", + "hoist-non-react-statics": "^3.3.2", + "jss": "^10.5.1", + "jss-plugin-camel-case": "^10.5.1", + "jss-plugin-default-unit": "^10.5.1", + "jss-plugin-global": "^10.5.1", + "jss-plugin-nested": "^10.5.1", + "jss-plugin-props-sort": "^10.5.1", + "jss-plugin-rule-value-function": "^10.5.1", + "jss-plugin-vendor-prefixer": "^10.5.1", + "prop-types": "^15.7.2" + } + }, + "@material-ui/system": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.12.2.tgz", + "integrity": "sha512-6CSKu2MtmiJgcCGf6nBQpM8fLkuB9F55EKfbdTC80NND5wpTmKzwdhLYLH3zL4cLlK0gVaaltW7/wMuyTnN0Lw==", + "requires": { + "@babel/runtime": "^7.4.4", + "@material-ui/utils": "^4.11.3", + "csstype": "^2.5.2", + "prop-types": "^15.7.2" + } + }, + "@material-ui/types": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", + "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==" + }, + "@material-ui/utils": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.3.tgz", + "integrity": "sha512-ZuQPV4rBK/V1j2dIkSSEcH5uT6AaHuKWFfotADHsC0wVL1NLd2WkFCm4ZZbX33iO4ydl6V0GPngKm8HZQ2oujg==", + "requires": { + "@babel/runtime": "^7.4.4", + "prop-types": "^15.7.2", + "react-is": "^16.8.0 || ^17.0.0" + } + }, + "@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", + "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "dev": true, + "requires": { + "eslint-scope": "5.1.1" + }, + "dependencies": { + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + } + } + }, + "@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, + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@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 + }, + "@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, + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@react-dnd/asap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.0.tgz", + "integrity": "sha512-0XhqJSc6pPoNnf8DhdsPHtUhRzZALVzYMTzRwV4VI6DJNJ/5xxfL9OQUwb8IH5/2x7lSf7nAZrnzUD+16VyOVQ==" + }, + "@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==" + }, + "@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==" + }, + "@react-icons/all-files": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@react-icons/all-files/-/all-files-4.1.0.tgz", + "integrity": "sha512-hxBI2UOuVaI3O/BhQfhtb4kcGn9ft12RWAFVMUeNjqqhLsHvFtzIkFaptBJpFDANTKoDfdVoHTKZDlwKCACbMQ==", + "requires": {} + }, + "@redux-saga/core": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.1.3.tgz", + "integrity": "sha512-8tInBftak8TPzE6X13ABmEtRJGjtK17w7VUs7qV17S8hCO5S3+aUTWZ/DBsBJPdE8Z5jOPwYALyvofgq1Ws+kg==", + "requires": { + "@babel/runtime": "^7.6.3", + "@redux-saga/deferred": "^1.1.2", + "@redux-saga/delay-p": "^1.1.2", + "@redux-saga/is": "^1.1.2", + "@redux-saga/symbols": "^1.1.2", + "@redux-saga/types": "^1.1.0", + "redux": "^4.0.4", + "typescript-tuple": "^2.2.1" + } + }, + "@redux-saga/deferred": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.1.2.tgz", + "integrity": "sha512-908rDLHFN2UUzt2jb4uOzj6afpjgJe3MjICaUNO3bvkV/kN/cNeI9PMr8BsFXB/MR8WTAZQq/PlTq8Kww3TBSQ==" + }, + "@redux-saga/delay-p": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.1.2.tgz", + "integrity": "sha512-ojc+1IoC6OP65Ts5+ZHbEYdrohmIw1j9P7HS9MOJezqMYtCDgpkoqB5enAAZrNtnbSL6gVCWPHaoaTY5KeO0/g==", + "requires": { + "@redux-saga/symbols": "^1.1.2" + } + }, + "@redux-saga/is": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.1.2.tgz", + "integrity": "sha512-OLbunKVsCVNTKEf2cH4TYyNbbPgvmZ52iaxBD4I1fTif4+MTXMa4/Z07L83zW/hTCXwpSZvXogqMqLfex2Tg6w==", + "requires": { + "@redux-saga/symbols": "^1.1.2", + "@redux-saga/types": "^1.1.0" + } + }, + "@redux-saga/symbols": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.1.2.tgz", + "integrity": "sha512-EfdGnF423glv3uMwLsGAtE6bg+R9MdqlHEzExnfagXPrIiuxwr3bdiAwz3gi+PsrQ3yBlaBpfGLtDG8rf3LgQQ==" + }, + "@redux-saga/types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.1.0.tgz", + "integrity": "sha512-afmTuJrylUU/0OtqzaRkbyYFFNgCF73Bvel/sw90pvGrWIZ+vyoIJqA6eMSoA6+nb443kTmulmBtC9NerXboNg==" + }, + "@rollup/plugin-babel": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz", + "integrity": "sha512-9uIC8HZOnVLrLHxayq/PTzw+uS25E14KPUBh5ktF+18Mjo5yK0ToMMx6epY0uEgkjwJw0aBW4x2horYXh8juWw==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.10.4", + "@rollup/pluginutils": "^3.1.0" + } + }, + "@rollup/plugin-node-resolve": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-11.2.1.tgz", + "integrity": "sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.1.0", + "@types/resolve": "1.17.1", + "builtin-modules": "^3.1.0", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.19.0" + } + }, + "@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==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" + } + }, + "@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "requires": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "dependencies": { + "@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==", + "dev": true + } + } + }, + "@rushstack/eslint-patch": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz", + "integrity": "sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg==", + "dev": true + }, + "@sinclair/typebox": { + "version": "0.24.44", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.44.tgz", + "integrity": "sha512-ka0W0KN5i6LfrSocduwliMMpqVgohtPFidKdMEOUjoOFCHcOOYkKsPRxfs5f15oPNHTm6ERAm0GV/+/LTKeiWg==", + "dev": true + }, + "@sindresorhus/is": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", + "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", + "dev": true + }, + "@sinonjs/commons": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", + "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", + "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.7.0" + } + }, + "@svgr/babel-plugin-add-jsx-attribute": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", + "integrity": "sha512-ZFf2gs/8/6B8PnSofI0inYXr2SDNTDScPXhN7k5EqD4aZ3gi6u+rbmZHVB8IM3wDyx8ntKACZbtXSm7oZGRqVg==", + "dev": true + }, + "@svgr/babel-plugin-remove-jsx-attribute": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-5.4.0.tgz", + "integrity": "sha512-yaS4o2PgUtwLFGTKbsiAy6D0o3ugcUhWK0Z45umJ66EPWunAz9fuFw2gJuje6wqQvQWOTJvIahUwndOXb7QCPg==", + "dev": true + }, + "@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-5.0.1.tgz", + "integrity": "sha512-LA72+88A11ND/yFIMzyuLRSMJ+tRKeYKeQ+mR3DcAZ5I4h5CPWN9AHyUzJbWSYp/u2u0xhmgOe0+E41+GjEueA==", + "dev": true + }, + "@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-5.0.1.tgz", + "integrity": "sha512-PoiE6ZD2Eiy5mK+fjHqwGOS+IXX0wq/YDtNyIgOrc6ejFnxN4b13pRpiIPbtPwHEc+NT2KCjteAcq33/F1Y9KQ==", + "dev": true + }, + "@svgr/babel-plugin-svg-dynamic-title": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-5.4.0.tgz", + "integrity": "sha512-zSOZH8PdZOpuG1ZVx/cLVePB2ibo3WPpqo7gFIjLV9a0QsuQAzJiwwqmuEdTaW2pegyBE17Uu15mOgOcgabQZg==", + "dev": true + }, + "@svgr/babel-plugin-svg-em-dimensions": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-5.4.0.tgz", + "integrity": "sha512-cPzDbDA5oT/sPXDCUYoVXEmm3VIoAWAPT6mSPTJNbQaBNUuEKVKyGH93oDY4e42PYHRW67N5alJx/eEol20abw==", + "dev": true + }, + "@svgr/babel-plugin-transform-react-native-svg": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-5.4.0.tgz", + "integrity": "sha512-3eYP/SaopZ41GHwXma7Rmxcv9uRslRDTY1estspeB1w1ueZWd/tPlMfEOoccYpEMZU3jD4OU7YitnXcF5hLW2Q==", + "dev": true + }, + "@svgr/babel-plugin-transform-svg-component": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-5.5.0.tgz", + "integrity": "sha512-q4jSH1UUvbrsOtlo/tKcgSeiCHRSBdXoIoqX1pgcKK/aU3JD27wmMKwGtpB8qRYUYoyXvfGxUVKchLuR5pB3rQ==", + "dev": true + }, + "@svgr/babel-preset": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-5.5.0.tgz", + "integrity": "sha512-4FiXBjvQ+z2j7yASeGPEi8VD/5rrGQk4Xrq3EdJmoZgz/tpqChpo5hgXDvmEauwtvOc52q8ghhZK4Oy7qph4ig==", + "dev": true, + "requires": { + "@svgr/babel-plugin-add-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-attribute": "^5.4.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "^5.0.1", + "@svgr/babel-plugin-replace-jsx-attribute-value": "^5.0.1", + "@svgr/babel-plugin-svg-dynamic-title": "^5.4.0", + "@svgr/babel-plugin-svg-em-dimensions": "^5.4.0", + "@svgr/babel-plugin-transform-react-native-svg": "^5.4.0", + "@svgr/babel-plugin-transform-svg-component": "^5.5.0" + } + }, + "@svgr/core": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-5.5.0.tgz", + "integrity": "sha512-q52VOcsJPvV3jO1wkPtzTuKlvX7Y3xIcWRpCMtBF3MrteZJtBfQw/+u0B1BHy5ColpQc1/YVTrPEtSYIMNZlrQ==", + "dev": true, + "requires": { + "@svgr/plugin-jsx": "^5.5.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^7.0.0" + } + }, + "@svgr/hast-util-to-babel-ast": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-5.5.0.tgz", + "integrity": "sha512-cAaR/CAiZRB8GP32N+1jocovUtvlj0+e65TB50/6Lcime+EA49m/8l+P2ko+XPJ4dw3xaPS3jOL4F2X4KWxoeQ==", + "dev": true, + "requires": { + "@babel/types": "^7.12.6" + } + }, + "@svgr/plugin-jsx": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-5.5.0.tgz", + "integrity": "sha512-V/wVh33j12hGh05IDg8GpIUXbjAPnTdPTKuP4VNLggnwaHMPNQNae2pRnyTAILWCQdz5GyMqtO488g7CKM8CBA==", + "dev": true, + "requires": { + "@babel/core": "^7.12.3", + "@svgr/babel-preset": "^5.5.0", + "@svgr/hast-util-to-babel-ast": "^5.5.0", + "svg-parser": "^2.0.2" + } + }, + "@svgr/plugin-svgo": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-5.5.0.tgz", + "integrity": "sha512-r5swKk46GuQl4RrVejVwpeeJaydoxkdwkM1mBKOgJLBUJPGaLci6ylg/IjhrRsREKDkr4kbMWdgOtbXEh0fyLQ==", + "dev": true, + "requires": { + "cosmiconfig": "^7.0.0", + "deepmerge": "^4.2.2", + "svgo": "^1.2.2" + } + }, + "@svgr/webpack": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-5.5.0.tgz", + "integrity": "sha512-DOBOK255wfQxguUta2INKkzPj6AIS6iafZYiYmHn6W3pHlycSRRlvWKCfLDG10fXfLWqE3DJHgRUOyJYmARa7g==", + "dev": true, + "requires": { + "@babel/core": "^7.12.3", + "@babel/plugin-transform-react-constant-elements": "^7.12.1", + "@babel/preset-env": "^7.12.1", + "@babel/preset-react": "^7.12.5", + "@svgr/core": "^5.5.0", + "@svgr/plugin-jsx": "^5.5.0", + "@svgr/plugin-svgo": "^5.5.0", + "loader-utils": "^2.0.0" + } + }, + "@szmarczak/http-timer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", + "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", + "dev": true, + "requires": { + "defer-to-connect": "^1.0.1" + } + }, + "@testing-library/dom": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.1.0.tgz", + "integrity": "sha512-kmW9alndr19qd6DABzQ978zKQ+J65gU2Rzkl8hriIetPnwpesRaK4//jEQyYh8fEALmGhomD/LBQqt+o+DL95Q==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^4.2.0", + "aria-query": "^4.2.2", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.6", + "lz-string": "^1.4.4", + "pretty-format": "^27.0.2" + }, + "dependencies": { + "@jest/types": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.0.6.tgz", + "integrity": "sha512-aSquT1qa9Pik26JK5/3rvnYb4bGtm1VFNesHKmNTwmPIgOrixvhL2ghIvFRNEpzy3gU+rUgjIF/KodbkFAl++g==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", + "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "pretty-format": { + "version": "27.0.6", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.0.6.tgz", + "integrity": "sha512-8tGD7gBIENgzqA+UBzObyWqQ5B778VIFZA/S66cclyd5YkFLYs2Js7gxDKf0MXtTc9zcS7t1xhdfcElJ3YIvkQ==", + "dev": true, + "requires": { + "@jest/types": "^27.0.6", + "ansi-regex": "^5.0.0", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "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 + } + } + }, + "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==", + "dev": true + } + } + }, + "@testing-library/jest-dom": { + "version": "5.16.5", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz", + "integrity": "sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==", + "dev": true, + "requires": { + "@adobe/css-tools": "^4.0.1", + "@babel/runtime": "^7.9.2", + "@types/testing-library__jest-dom": "^5.9.1", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.5.6", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "dependencies": { + "aria-query": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.2.tgz", + "integrity": "sha512-eigU3vhqSO+Z8BKDnVLN/ompjhf3pYzecKXz8+whRy+9gZu8n1TCGfwzQUUPnqdHl9ax1Hr9031orZ+UOEYr7Q==", + "dev": true + } + } + }, + "@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, + "requires": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^8.0.0", + "@types/react-dom": "<18.0.0" + } + }, + "@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, + "requires": { + "@babel/runtime": "^7.12.5", + "@types/react": ">=16.9.0", + "@types/react-dom": ">=16.9.0", + "@types/react-test-renderer": ">=16.9.0", + "react-error-boundary": "^3.1.0" + } + }, + "@testing-library/user-event": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz", + "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5" + } + }, + "@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "dev": true + }, + "@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true + }, + "@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 + }, + "@types/babel__core": { + "version": "7.1.14", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.14.tgz", + "integrity": "sha512-zGZJzzBUVDo/eV6KgbE0f0ZI7dInEYvo12Rb70uNQDshC3SkRMb67ja0GgRHZgAX3Za6rhaWlvbDO8rrGyAb1g==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "@types/babel__generator": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.2.tgz", + "integrity": "sha512-MdSJnBjl+bdwkLskZ3NGFp9YcXGx5ggLpQQPqtgakVhsWK0hTtNYhjpZLlWQTviGTvF8at+Bvli3jV7faPdgeQ==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@types/babel__template": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.0.tgz", + "integrity": "sha512-NTPErx4/FiPCGScH7foPyr+/1Dkzkni+rHiYHHoTjvwou7AQzJkNeD60A9CXRy+ZEN2B1bggmkTMCDb+Mv5k+A==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@types/babel__traverse": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.11.1.tgz", + "integrity": "sha512-Vs0hm0vPahPMYi9tDjtP66llufgO3ST16WXaSTtDGEl9cewAl3AibmxWw6TINOqHPT9z0uABKAYjT9jNSg4npw==", + "dev": true, + "requires": { + "@babel/types": "^7.3.0" + } + }, + "@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/bonjour": { + "version": "3.5.10", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.10.tgz", + "integrity": "sha512-p7ienRMiS41Nu2/igbJxxLDWrSZ0WxM8UQgCeO9KhoVF7cOVFkrKsiDr1EsJIla8vV3oEEjGcz11jc5yimhzZw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/color-name": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", + "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", + "dev": true + }, + "@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/connect-history-api-fallback": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.3.5.tgz", + "integrity": "sha512-h8QJa8xSb1WD4fpKBDcATDNGXghFj6/3GRWG6dhmRcu0RX1Ubasur2Uvx5aeEwlf0MwblEC2bMzzMQntxnw/Cw==", + "dev": true, + "requires": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "@types/eslint": { + "version": "8.4.6", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz", + "integrity": "sha512-/fqTbjxyFUaYNO7VcW5g+4npmqVACz1bB7RTHYuLj+PRjw9hrCwrUXVQFpChUS0JsyEFvMZ7U/PfmvWgxJhI9g==", + "dev": true, + "requires": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "@types/eslint-scope": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", + "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", + "dev": true, + "requires": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "@types/estree": { + "version": "0.0.51", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", + "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==", + "dev": true + }, + "@types/express": { + "version": "4.17.14", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.14.tgz", + "integrity": "sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.18", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.31", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz", + "integrity": "sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*" + } + }, + "@types/graceful-fs": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", + "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, + "@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "dev": true + }, + "@types/http-proxy": { + "version": "1.17.9", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.9.tgz", + "integrity": "sha512-QsbSjA/fSk7xB+UXlCT3wHBy5ai9wOcNDWwZAtud+jXhwOM3l+EYZh8Lng4+/6n8uar0J7xILzqftJdJ/Wdfkw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/istanbul-lib-coverage": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", + "integrity": "sha512-hRJD2ahnnpLgsj6KWMYSrmXkM3rm2Dl1qkx6IOFD5FnuNPXJIG5L0dhgKXCYTRMGzU4n0wImQ/xfmRc4POUFlg==", + "dev": true + }, + "@types/istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "*" + } + }, + "@types/istanbul-reports": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", + "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, + "@types/jest": { + "version": "26.0.23", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.23.tgz", + "integrity": "sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA==", + "dev": true, + "requires": { + "jest-diff": "^26.0.0", + "pretty-format": "^26.0.0" + } + }, + "@types/json-schema": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", + "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", + "dev": true + }, + "@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true + }, + "@types/mime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.1.tgz", + "integrity": "sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==", + "dev": true + }, + "@types/minimist": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", + "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==", + "dev": true + }, + "@types/node": { + "version": "14.0.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.27.tgz", + "integrity": "sha512-kVrqXhbclHNHGu9ztnAwSncIgJv/FaxmzXJvGXNdcCpV1b8u1/Mi6z6m0vwy0LzKeXFTPLH0NzwmoJ3fNCIq0g==", + "devOptional": true + }, + "@types/normalize-package-data": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==", + "dev": true + }, + "@types/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", + "dev": true + }, + "@types/prettier": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.1.tgz", + "integrity": "sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow==", + "dev": true + }, + "@types/prop-types": { + "version": "15.7.3", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", + "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==" + }, + "@types/q": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.4.tgz", + "integrity": "sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==", + "dev": true + }, + "@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true + }, + "@types/react": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.0.tgz", + "integrity": "sha512-aj/L7RIMsRlWML3YB6KZiXB3fV2t41+5RBGYF8z+tAKU43Px8C3cYUZsDvf1/+Bm4FK21QWBrDutu8ZJ/70qOw==", + "requires": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + }, + "dependencies": { + "csstype": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz", + "integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw==" + } + } + }, + "@types/react-dom": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.5.tgz", + "integrity": "sha512-ikqukEhH4H9gr4iJCmQVNzTB307kROe3XFfHAOTxOXPOw7lAoEXnM5KWTkzeANGL5Ce6ABfiMl/zJBYNi7ObmQ==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/react-redux": { + "version": "7.1.24", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.24.tgz", + "integrity": "sha512-7FkurKcS1k0FHZEtdbbgN8Oc6b+stGSfZYjQGicofJ0j4U0qIn/jaSvnP2pLwZKiai3/17xqqxkkrxTgN8UNbQ==", + "requires": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, + "@types/react-test-renderer": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz", + "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/react-transition-group": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.1.tgz", + "integrity": "sha512-vIo69qKKcYoJ8wKCJjwSgCTM+z3chw3g18dkrDfVX665tMH7tmbDxEAnPdey4gTlwZz5QuHGzd+hul0OVZDqqQ==", + "requires": { + "@types/react": "*" + } + }, + "@types/resolve": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", + "integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true + }, + "@types/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-d/Hs3nWDxNL2xAczmOVZNj92YZCS6RGxfBPjKzuu/XirCgXdpKEb88dYNbrYGint6IVWLNP+yonwVAuRC0T2Dg==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, + "@types/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==", + "dev": true, + "requires": { + "@types/mime": "*", + "@types/node": "*" + } + }, + "@types/sockjs": { + "version": "0.3.33", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", + "integrity": "sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/stack-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", + "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", + "dev": true + }, + "@types/testing-library__jest-dom": { + "version": "5.9.5", + "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.5.tgz", + "integrity": "sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ==", + "dev": true, + "requires": { + "@types/jest": "*" + } + }, + "@types/trusted-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", + "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==", + "dev": true + }, + "@types/ws": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", + "integrity": "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/yargs": { + "version": "15.0.13", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.13.tgz", + "integrity": "sha512-kQ5JNTrbDv3Rp5X2n/iUu37IJBDU2gsZ5R/g1/KHOOEc5IKfUFjXT6DENPGduh08I/pamwtEq4oul7gUqKTQDQ==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "@types/yargs-parser": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-15.0.0.tgz", + "integrity": "sha512-FA/BWv8t8ZWJ+gEOnLLd8ygxH/2UFbAvgEonyfN6yWGLKc7zVjbpl2Y4CTjid9h2RfgPP6SEt6uHwEOply00yw==", + "dev": true + }, + "@typescript-eslint/eslint-plugin": { + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.38.1.tgz", + "integrity": "sha512-ky7EFzPhqz3XlhS7vPOoMDaQnQMn+9o5ICR9CPr/6bw8HrFkzhMSxuA3gRfiJVvs7geYrSeawGJjZoZQKCOglQ==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "5.38.1", + "@typescript-eslint/type-utils": "5.38.1", + "@typescript-eslint/utils": "5.38.1", + "debug": "^4.3.4", + "ignore": "^5.2.0", + "regexpp": "^3.2.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/experimental-utils": { + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-5.38.1.tgz", + "integrity": "sha512-Zv0EcU0iu64DiVG3pRZU0QYCgANO//U1fS3oEs3eqHD1eIVVcQsFd/T01ckaNbL2H2aCqRojY2xZuMAPcOArEA==", + "dev": true, + "requires": { + "@typescript-eslint/utils": "5.38.1" + } + }, + "@typescript-eslint/parser": { + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.38.1.tgz", + "integrity": "sha512-LDqxZBVFFQnQRz9rUZJhLmox+Ep5kdUmLatLQnCRR6523YV+XhRjfYzStQ4MheFA8kMAfUlclHSbu+RKdRwQKw==", + "dev": true, + "requires": { + "@typescript-eslint/scope-manager": "5.38.1", + "@typescript-eslint/types": "5.38.1", + "@typescript-eslint/typescript-estree": "5.38.1", + "debug": "^4.3.4" + } + }, + "@typescript-eslint/scope-manager": { + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.38.1.tgz", + "integrity": "sha512-BfRDq5RidVU3RbqApKmS7RFMtkyWMM50qWnDAkKgQiezRtLKsoyRKIvz1Ok5ilRWeD9IuHvaidaLxvGx/2eqTQ==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.38.1", + "@typescript-eslint/visitor-keys": "5.38.1" + } + }, + "@typescript-eslint/type-utils": { + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.38.1.tgz", + "integrity": "sha512-UU3j43TM66gYtzo15ivK2ZFoDFKKP0k03MItzLdq0zV92CeGCXRfXlfQX5ILdd4/DSpHkSjIgLLLh1NtkOJOAw==", + "dev": true, + "requires": { + "@typescript-eslint/typescript-estree": "5.38.1", + "@typescript-eslint/utils": "5.38.1", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/types": { + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.38.1.tgz", + "integrity": "sha512-QTW1iHq1Tffp9lNfbfPm4WJabbvpyaehQ0SrvVK2yfV79SytD9XDVxqiPvdrv2LK7DGSFo91TB2FgWanbJAZXg==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.38.1.tgz", + "integrity": "sha512-99b5e/Enoe8fKMLdSuwrfH/C0EIbpUWmeEKHmQlGZb8msY33qn1KlkFww0z26o5Omx7EVjzVDCWEfrfCDHfE7g==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.38.1", + "@typescript-eslint/visitor-keys": "5.38.1", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + } + }, + "@typescript-eslint/utils": { + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.38.1.tgz", + "integrity": "sha512-oIuUiVxPBsndrN81oP8tXnFa/+EcZ03qLqPDfSZ5xIJVm7A9V0rlkQwwBOAGtrdN70ZKDlKv+l1BeT4eSFxwXA==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "@typescript-eslint/scope-manager": "5.38.1", + "@typescript-eslint/types": "5.38.1", + "@typescript-eslint/typescript-estree": "5.38.1", + "eslint-scope": "^5.1.1", + "eslint-utils": "^3.0.0" + }, + "dependencies": { + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + } + } + }, + "@typescript-eslint/visitor-keys": { + "version": "5.38.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.38.1.tgz", + "integrity": "sha512-bSHr1rRxXt54+j2n4k54p4fj8AHJ49VDWtjpImOpzQj4qjAiOpPni+V1Tyajh19Api1i844F757cur8wH3YvOA==", + "dev": true, + "requires": { + "@typescript-eslint/types": "5.38.1", + "eslint-visitor-keys": "^3.3.0" + } + }, + "@webassemblyjs/ast": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", + "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", + "dev": true, + "requires": { + "@webassemblyjs/helper-numbers": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1" + } + }, + "@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", + "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==", + "dev": true + }, + "@webassemblyjs/helper-api-error": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", + "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==", + "dev": true + }, + "@webassemblyjs/helper-buffer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", + "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==", + "dev": true + }, + "@webassemblyjs/helper-numbers": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", + "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", + "dev": true, + "requires": { + "@webassemblyjs/floating-point-hex-parser": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", + "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==", + "dev": true + }, + "@webassemblyjs/helper-wasm-section": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", + "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1" + } + }, + "@webassemblyjs/ieee754": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", + "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", + "dev": true, + "requires": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "@webassemblyjs/leb128": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", + "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", + "dev": true, + "requires": { + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/utf8": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", + "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==", + "dev": true + }, + "@webassemblyjs/wasm-edit": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", + "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/helper-wasm-section": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-opt": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "@webassemblyjs/wast-printer": "1.11.1" + } + }, + "@webassemblyjs/wasm-gen": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", + "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "@webassemblyjs/wasm-opt": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", + "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-buffer": "1.11.1", + "@webassemblyjs/wasm-gen": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1" + } + }, + "@webassemblyjs/wasm-parser": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", + "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/helper-api-error": "1.11.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.1", + "@webassemblyjs/ieee754": "1.11.1", + "@webassemblyjs/leb128": "1.11.1", + "@webassemblyjs/utf8": "1.11.1" + } + }, + "@webassemblyjs/wast-printer": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", + "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.1", + "@xtuc/long": "4.2.2" + } + }, + "@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "dev": true + }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true + }, + "acorn-globals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", + "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", + "dev": true, + "requires": { + "acorn": "^7.1.1", + "acorn-walk": "^7.1.1" + } + }, + "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, + "requires": {} + }, + "acorn-node": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/acorn-node/-/acorn-node-1.8.2.tgz", + "integrity": "sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==", + "dev": true, + "requires": { + "acorn": "^7.0.0", + "acorn-walk": "^7.0.0", + "xtend": "^4.0.2" + } + }, + "acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "dev": true + }, + "address": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/address/-/address-1.2.1.tgz", + "integrity": "sha512-B+6bi5D34+fDYENiH5qOlA0cV2rAGKuWZ9LeyUUehbXy8e0VS9e498yO0Jeeh+iM+6KbfudHTFjXw2MmJD4QRA==", + "dev": true + }, + "adjust-sourcemap-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", + "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "regex-parser": "^2.2.11" + } + }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "requires": { + "debug": "4" + } + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "requires": { + "ajv": "^8.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "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==", + "dev": true + } + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "requires": {} + }, + "ansi-align": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz", + "integrity": "sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw==", + "dev": true, + "requires": { + "string-width": "^3.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "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==", + "dev": true, + "requires": { + "type-fest": "^0.21.3" + }, + "dependencies": { + "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==", + "dev": true + } + } + }, + "ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.2.1.tgz", + "integrity": "sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==", + "dev": true, + "requires": { + "@types/color-name": "^1.1.1", + "color-convert": "^2.0.1" + } + }, + "anymatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "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, + "requires": { + "@babel/runtime": "^7.10.2", + "@babel/runtime-corejs3": "^7.10.2" + } + }, + "array-flatten": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", + "dev": true + }, + "array-includes": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.5.tgz", + "integrity": "sha512-iSDYZMMyTPkiFasVqfuAQnWAYcvO/SeBSCGKePoEthjp4LEMTe4uLc7b025o4jAZpHhihh8xPo99TNWUWWkGDQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.19.5", + "get-intrinsic": "^1.1.1", + "is-string": "^1.0.7" + } + }, + "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 + }, + "array.prototype.flat": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.0.tgz", + "integrity": "sha512-12IUEkHsAhA4DY5s0FPgNXIdc8VRSqD9Zp78a5au9abH/SOBrsp082JOWFNTjkMozh8mqcdiKuaLGhPeYztxSw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.2", + "es-shim-unscopables": "^1.0.0" + } + }, + "array.prototype.flatmap": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.0.tgz", + "integrity": "sha512-PZC9/8TKAIxcWKdyeb77EzULHPrIX/tIZebLJUQOMR1OwYosT8yggdfWScfTBCDj5utONvOuPQQumYsU2ULbkg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.2", + "es-shim-unscopables": "^1.0.0" + } + }, + "array.prototype.foreach": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array.prototype.foreach/-/array.prototype.foreach-1.0.1.tgz", + "integrity": "sha512-5/+XXc6Sq/X0nKTqrnYfckvE4tIAMEJDSkGsdBl0OC6/Kvr38PHgT6amItyCKP2Fng1Ifi3mI+1r01f0opJQdQ==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.5", + "es-array-method-boxes-properly": "^1.0.0", + "get-intrinsic": "^1.1.1", + "is-string": "^1.0.7" + } + }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true + }, + "ast-types-flow": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz", + "integrity": "sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==", + "dev": true + }, + "async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==", + "dev": true + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, + "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==", + "dev": true + }, + "attr-accept": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", + "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==" + }, + "autoprefixer": { + "version": "10.4.12", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.12.tgz", + "integrity": "sha512-WrCGV9/b97Pa+jtwf5UGaRjgQIg7OK3D06GnoYoZNcG1Xb8Gt3EfuKjlhh9i/VtT16g6PYjZ69jdJ2g8FxSC4Q==", + "dev": true, + "requires": { + "browserslist": "^4.21.4", + "caniuse-lite": "^1.0.30001407", + "fraction.js": "^4.2.0", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + } + }, + "autosuggest-highlight": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/autosuggest-highlight/-/autosuggest-highlight-3.3.4.tgz", + "integrity": "sha512-j6RETBD2xYnrVcoV1S5R4t3WxOlWZKyDQjkwnggDPSjF5L4jV98ZltBpvPvbkM1HtoSe5o+bNrTHyjPbieGeYA==", + "requires": { + "remove-accents": "^0.4.2" + } + }, + "axe-core": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.3.tgz", + "integrity": "sha512-32+ub6kkdhhWick/UjvEwRchgoetXqTK14INLqbGm5U2TzBkBNF3nQtLYm8ovxSkQWArjEQvftCKryjZaATu3w==", + "dev": true + }, + "axobject-query": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", + "integrity": "sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==", + "dev": true + }, + "babel-jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", + "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==", + "dev": true, + "requires": { + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "babel-loader": { + "version": "8.2.5", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.2.5.tgz", + "integrity": "sha512-OSiFfH89LrEMiWd4pLNqGz4CwJDtbs2ZVc+iGu2HrkRfPxId9F2anQj38IxWpmRfsUY0aBZYi1EFcd3mhtRMLQ==", + "dev": true, + "requires": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + } + }, + "babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "dev": true, + "requires": { + "object.assign": "^4.1.0" + } + }, + "babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + } + }, + "babel-plugin-jest-hoist": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz", + "integrity": "sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==", + "dev": true, + "requires": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.0.0", + "@types/babel__traverse": "^7.0.6" + } + }, + "babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + } + }, + "babel-plugin-named-asset-import": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/babel-plugin-named-asset-import/-/babel-plugin-named-asset-import-0.3.8.tgz", + "integrity": "sha512-WXiAc++qo7XcJ1ZnTYGtLxmBCVbddAml3CEXgWaBzNzLNoxtQ8AiGEFDMOhot9XjTCQbvP5E77Fj9Gk924f00Q==", + "dev": true, + "requires": {} + }, + "babel-plugin-polyfill-corejs2": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz", + "integrity": "sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.17.7", + "@babel/helper-define-polyfill-provider": "^0.3.3", + "semver": "^6.1.1" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "babel-plugin-polyfill-corejs3": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz", + "integrity": "sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA==", + "dev": true, + "requires": { + "@babel/helper-define-polyfill-provider": "^0.3.3", + "core-js-compat": "^3.25.1" + } + }, + "babel-plugin-polyfill-regenerator": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz", + "integrity": "sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw==", + "dev": true, + "requires": { + "@babel/helper-define-polyfill-provider": "^0.3.3" + } + }, + "babel-plugin-transform-react-remove-prop-types": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-react-remove-prop-types/-/babel-plugin-transform-react-remove-prop-types-0.4.24.tgz", + "integrity": "sha512-eqj0hVcJUR57/Ug2zE1Yswsw4LhuqqHhD+8v120T1cl3kjg76QwtyBrdIk4WVwK+lAhBJVYCd/v+4nc4y+8JsA==", + "dev": true + }, + "babel-preset-current-node-syntax": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", + "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "dev": true, + "requires": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.8.3", + "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-top-level-await": "^7.8.3" + } + }, + "babel-preset-jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz", + "integrity": "sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==", + "dev": true, + "requires": { + "babel-plugin-jest-hoist": "^27.5.1", + "babel-preset-current-node-syntax": "^1.0.0" + } + }, + "babel-preset-react-app": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-react-app/-/babel-preset-react-app-10.0.1.tgz", + "integrity": "sha512-b0D9IZ1WhhCWkrTXyFuIIgqGzSkRIH5D5AmB0bXbzYAB1OBAwHcUeyWW2LorutLWF5btNo/N7r/cIdmvvKJlYg==", + "dev": true, + "requires": { + "@babel/core": "^7.16.0", + "@babel/plugin-proposal-class-properties": "^7.16.0", + "@babel/plugin-proposal-decorators": "^7.16.4", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.0", + "@babel/plugin-proposal-numeric-separator": "^7.16.0", + "@babel/plugin-proposal-optional-chaining": "^7.16.0", + "@babel/plugin-proposal-private-methods": "^7.16.0", + "@babel/plugin-transform-flow-strip-types": "^7.16.0", + "@babel/plugin-transform-react-display-name": "^7.16.0", + "@babel/plugin-transform-runtime": "^7.16.4", + "@babel/preset-env": "^7.16.4", + "@babel/preset-react": "^7.16.0", + "@babel/preset-typescript": "^7.16.0", + "@babel/runtime": "^7.16.3", + "babel-plugin-macros": "^3.1.0", + "babel-plugin-transform-react-remove-prop-types": "^0.4.24" + } + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "requires": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + }, + "dependencies": { + "core-js": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", + "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==" + }, + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" + } + } + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true + }, + "batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true + }, + "bfj": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.0.2.tgz", + "integrity": "sha512-+e/UqUzwmzJamNF50tBV6tZPTORow7gQ96iFow+8b562OdMpEK0BcJEq2OSPEDmAbSMBQ7PKZ87ubFkgxpYWgw==", + "dev": true, + "requires": { + "bluebird": "^3.5.5", + "check-types": "^11.1.1", + "hoopy": "^0.1.4", + "tryer": "^1.0.1" + } + }, + "big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true + }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + }, + "dependencies": { + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + } + } + }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, + "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==" + }, + "body-parser": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz", + "integrity": "sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==", + "dev": true, + "requires": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.10.3", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "dependencies": { + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "bonjour-service": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.0.14.tgz", + "integrity": "sha512-HIMbgLnk1Vqvs6B4Wq5ep7mxvj9sGz5d1JJyDNSGNIdA/w2MCz6GTjWTdjqOJV1bEPj+6IkxDvWNFKEBxNt4kQ==", + "dev": true, + "requires": { + "array-flatten": "^2.1.2", + "dns-equal": "^1.0.0", + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha1-aN/1++YMUes3cl6p4+0xDcwed24=", + "dev": true + }, + "boxen": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-4.2.0.tgz", + "integrity": "sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ==", + "dev": true, + "requires": { + "ansi-align": "^3.0.0", + "camelcase": "^5.3.1", + "chalk": "^3.0.0", + "cli-boxes": "^2.2.0", + "string-width": "^4.1.0", + "term-size": "^2.1.0", + "type-fest": "^0.8.1", + "widest-line": "^3.1.0" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + } + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", + "dev": true + }, + "browserslist": { + "version": "4.21.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.4.tgz", + "integrity": "sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001400", + "electron-to-chromium": "^1.4.251", + "node-releases": "^2.0.6", + "update-browserslist-db": "^1.0.9" + } + }, + "bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "requires": { + "node-int64": "^0.4.0" + } + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "builtin-modules": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.2.0.tgz", + "integrity": "sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==", + "dev": true + }, + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true + }, + "cacheable-request": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", + "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", + "dev": true, + "requires": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^3.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^4.1.0", + "responselike": "^1.0.2" + }, + "dependencies": { + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true + }, + "normalize-url": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz", + "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==", + "dev": true + } + } + }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "requires": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + }, + "dependencies": { + "tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true + } + } + }, + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true + }, + "camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true + }, + "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==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + } + } + }, + "caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dev": true, + "requires": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "caniuse-lite": { + "version": "1.0.30001488", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001488.tgz", + "integrity": "sha512-NORIQuuL4xGpIy6iCCQGN4iFjlBXtfKWIenlUuyZJumLRIindLb7wXM+GO8erEhb7vXfcnf4BAg2PrSDN5TNLQ==", + "dev": true + }, + "case-sensitive-paths-webpack-plugin": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz", + "integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==", + "dev": true + }, + "chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true + }, + "chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "dev": true + }, + "check-types": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/check-types/-/check-types-11.1.2.tgz", + "integrity": "sha512-tzWzvgePgLORb9/3a0YenggReLKAIb2owL03H2Xdoe5pKcUyWRSEQ8xfCar8t2SIAuEDwtmx2da1YB52YuHQMQ==", + "dev": true + }, + "chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + } + }, + "chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true + }, + "ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "cjs-module-lexer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz", + "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", + "dev": true + }, + "classnames": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", + "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + }, + "clean-css": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.1.tgz", + "integrity": "sha512-lCr8OHhiWCTw4v8POJovCoh4T7I9U11yVsPjMWWnnMmp9ZowCxyad1Pathle/9HjaDp+fdQKjO9fQydE6RHTZg==", + "dev": true, + "requires": { + "source-map": "~0.6.0" + } + }, + "cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "dev": true + }, + "cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "requires": { + "restore-cursor": "^3.1.0" + } + }, + "cli-spinners": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.0.tgz", + "integrity": "sha512-t+4/y50K/+4xcCRosKkA7W4gTr1MySvLV0q+PxmG7FJ5g+66ChKurYjxBCjHggHH3HA5Hh9cy+lcUGWDqVH+4Q==", + "dev": true + }, + "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==", + "dev": true + }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "dev": true + }, + "clone-response": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", + "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", + "dev": true, + "requires": { + "mimic-response": "^1.0.0" + } + }, + "clsx": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", + "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==" + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true + }, + "coa": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/coa/-/coa-2.0.2.tgz", + "integrity": "sha512-q5/jG+YQnSy4nRTV4F7lPepBJZ8qBNJJDBuJdoejDyLXgmL7IEo+Le2JDZudFTFt7mrCqIRaSjws4ygRCTCAXA==", + "dev": true, + "requires": { + "@types/q": "^1.5.1", + "chalk": "^2.4.1", + "q": "^1.1.2" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "collect-v8-coverage": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz", + "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==", + "dev": true + }, + "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==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "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==", + "dev": true + }, + "colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true + }, + "colorette": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", + "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", + "dev": true + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true + }, + "common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true + }, + "common-tags": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.0.tgz", + "integrity": "sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==", + "dev": true + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "requires": { + "mime-db": ">= 1.43.0 < 2" + } + }, + "compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dev": true, + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "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==" + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "configstore": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", + "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", + "dev": true, + "requires": { + "dot-prop": "^5.2.0", + "graceful-fs": "^4.1.2", + "make-dir": "^3.0.0", + "unique-string": "^2.0.0", + "write-file-atomic": "^3.0.0", + "xdg-basedir": "^4.0.0" + } + }, + "confusing-browser-globals": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", + "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", + "dev": true + }, + "connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true + }, + "connected-react-router": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.9.1.tgz", + "integrity": "sha512-BbtB6t0iqAwGwygDenJl9zmlk7vpKWIRSycULmkAOn2RUaF6+bqETprl0qcIqQmY5CTqSwKanaxkLXYWiffAfQ==", + "requires": { + "immutable": "^3.8.1 || ^4.0.0-rc.1", + "lodash.isequalwith": "^4.4.0", + "prop-types": "^15.7.2", + "seamless-immutable": "^7.1.3" + } + }, + "content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "requires": { + "safe-buffer": "5.2.1" + }, + "dependencies": { + "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==", + "dev": true + } + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==", + "dev": true + }, + "convert-source-map": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz", + "integrity": "sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "dev": true + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true + }, + "core-js": { + "version": "3.25.3", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.3.tgz", + "integrity": "sha512-y1hvKXmPHvm5B7w4ln1S4uc9eV/O5+iFExSRUimnvIph11uaizFR8LFMdONN8hG3P2pipUfX4Y/fR8rAEtcHcQ==", + "dev": true + }, + "core-js-compat": { + "version": "3.25.3", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.25.3.tgz", + "integrity": "sha512-xVtYpJQ5grszDHEUU9O7XbjjcZ0ccX3LgQsyqSvTnjX97ZqEgn9F5srmrwwwMtbKzDllyFPL+O+2OFMl1lU4TQ==", + "dev": true, + "requires": { + "browserslist": "^4.21.4" + } + }, + "core-js-pure": { + "version": "3.25.3", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.25.3.tgz", + "integrity": "sha512-T/7qvgv70MEvRkZ8p6BasLZmOVYKzOaWNBEHAU8FmveCJkl4nko2quqPQOmy6AJIp5MBanhz9no3A94NoRb0XA==", + "dev": true + }, + "core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "cosmiconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz", + "integrity": "sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==", + "dev": true, + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + } + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "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==", + "dev": true + }, + "css-blank-pseudo": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz", + "integrity": "sha512-VS90XWtsHGqoM0t4KpH053c4ehxZ2E6HtGI7x68YFV0pTo/QmkV/YFA+NnlvK8guxZVNWGQhVNJGC39Q8XF4OQ==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.9" + } + }, + "css-declaration-sorter": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.3.1.tgz", + "integrity": "sha512-fBffmak0bPAnyqc/HO8C3n2sHrp9wcqQz6ES9koRF2/mLOVAx9zIQ3Y7R29sYCteTPqMCwns4WYQoCX91Xl3+w==", + "dev": true, + "requires": {} + }, + "css-has-pseudo": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-3.0.4.tgz", + "integrity": "sha512-Vse0xpR1K9MNlp2j5w1pgWIJtm1a8qS0JwS9goFYcImjlHEmywP9VUF05aGBXzGpDJF86QXk4L0ypBmwPhGArw==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.9" + } + }, + "css-loader": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.7.1.tgz", + "integrity": "sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw==", + "dev": true, + "requires": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.7", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.3.5" + } + }, + "css-mediaquery": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", + "integrity": "sha1-aiw3NEkoYYYxxUvTPO3TAdoYvqA=" + }, + "css-minimizer-webpack-plugin": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-3.4.1.tgz", + "integrity": "sha512-1u6D71zeIfgngN2XNRJefc/hY7Ybsxd74Jm4qngIXyUEk7fss3VUzuHxLAq/R8NAba4QU9OUSaMZlbpRc7bM4Q==", + "dev": true, + "requires": { + "cssnano": "^5.0.6", + "jest-worker": "^27.0.2", + "postcss": "^8.3.5", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0", + "source-map": "^0.6.1" + }, + "dependencies": { + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + } + }, + "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==", + "dev": true + }, + "schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + } + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "css-prefers-color-scheme": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-6.0.3.tgz", + "integrity": "sha512-4BqMbZksRkJQx2zAjrokiGMd07RqOa2IxIrrN10lyBe9xhn9DEvjUK79J6jkeiv9D9hQFXKb6g1jwU62jziJZA==", + "dev": true, + "requires": {} + }, + "css-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-2.1.0.tgz", + "integrity": "sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ==", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-what": "^3.2.1", + "domutils": "^1.7.0", + "nth-check": "^1.0.2" + } + }, + "css-select-base-adapter": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", + "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==", + "dev": true + }, + "css-tree": { + "version": "1.0.0-alpha.37", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", + "integrity": "sha512-DMxWJg0rnz7UgxKT0Q1HU/L9BeJI0M6ksor0OgqOnF+aRCDWg/N2641HmVyU9KVIu0OVVWOb2IpC9A+BJRnejg==", + "dev": true, + "requires": { + "mdn-data": "2.0.4", + "source-map": "^0.6.1" + } + }, + "css-vendor": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", + "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", + "requires": { + "@babel/runtime": "^7.8.3", + "is-in-browser": "^1.0.2" + } + }, + "css-what": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz", + "integrity": "sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==", + "dev": true + }, + "css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=", + "dev": true + }, + "cssdb": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.0.1.tgz", + "integrity": "sha512-pT3nzyGM78poCKLAEy2zWIVX2hikq6dIrjuZzLV98MumBg+xMTNYfHx7paUlfiRTgg91O/vR889CIf+qiv79Rw==", + "dev": true + }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, + "cssnano": { + "version": "5.1.13", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.13.tgz", + "integrity": "sha512-S2SL2ekdEz6w6a2epXn4CmMKU4K3KpcyXLKfAYc9UQQqJRkD/2eLUG0vJ3Db/9OvO5GuAdgXw3pFbR6abqghDQ==", + "dev": true, + "requires": { + "cssnano-preset-default": "^5.2.12", + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" + } + }, + "cssnano-preset-default": { + "version": "5.2.12", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.12.tgz", + "integrity": "sha512-OyCBTZi+PXgylz9HAA5kHyoYhfGcYdwFmyaJzWnzxuGRtnMw/kR6ilW9XzlzlRAtB6PLT/r+prYgkef7hngFew==", + "dev": true, + "requires": { + "css-declaration-sorter": "^6.3.0", + "cssnano-utils": "^3.1.0", + "postcss-calc": "^8.2.3", + "postcss-colormin": "^5.3.0", + "postcss-convert-values": "^5.1.2", + "postcss-discard-comments": "^5.1.2", + "postcss-discard-duplicates": "^5.1.0", + "postcss-discard-empty": "^5.1.1", + "postcss-discard-overridden": "^5.1.0", + "postcss-merge-longhand": "^5.1.6", + "postcss-merge-rules": "^5.1.2", + "postcss-minify-font-values": "^5.1.0", + "postcss-minify-gradients": "^5.1.1", + "postcss-minify-params": "^5.1.3", + "postcss-minify-selectors": "^5.2.1", + "postcss-normalize-charset": "^5.1.0", + "postcss-normalize-display-values": "^5.1.0", + "postcss-normalize-positions": "^5.1.1", + "postcss-normalize-repeat-style": "^5.1.1", + "postcss-normalize-string": "^5.1.0", + "postcss-normalize-timing-functions": "^5.1.0", + "postcss-normalize-unicode": "^5.1.0", + "postcss-normalize-url": "^5.1.0", + "postcss-normalize-whitespace": "^5.1.1", + "postcss-ordered-values": "^5.1.3", + "postcss-reduce-initial": "^5.1.0", + "postcss-reduce-transforms": "^5.1.0", + "postcss-svgo": "^5.1.0", + "postcss-unique-selectors": "^5.1.1" + } + }, + "cssnano-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", + "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", + "dev": true, + "requires": {} + }, + "csso": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dev": true, + "requires": { + "css-tree": "^1.1.2" + }, + "dependencies": { + "css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, + "requires": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + } + }, + "mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true + } + } + }, + "cssom": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", + "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==", + "dev": true + }, + "cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "requires": { + "cssom": "~0.3.6" + }, + "dependencies": { + "cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true + } + } + }, + "csstype": { + "version": "2.6.17", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.17.tgz", + "integrity": "sha512-u1wmTI1jJGzCJzWndZo8mk4wnPTZd1eOIYTYvuEyOQGfmDl3TrabCCfKnOC86FZwW/9djqTl933UF/cS425i9A==" + }, + "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 + }, + "data-urls": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", + "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", + "dev": true, + "requires": { + "abab": "^2.0.3", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.0.0" + } + }, + "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==" + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "decamelize-keys": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.0.tgz", + "integrity": "sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=", + "dev": true, + "requires": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "dependencies": { + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=", + "dev": true + } + } + }, + "decimal.js": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.1.tgz", + "integrity": "sha512-F29o+vci4DodHYT9UrR5IEbfBw9pE5eSapIJdTqXK5+6hq+t8VRxwQyKlW2i+KDKFkkJQRvFyI/QXD83h8LyQw==", + "dev": true + }, + "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==" + }, + "decompress-response": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", + "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", + "dev": true, + "requires": { + "mimic-response": "^1.0.0" + } + }, + "dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", + "dev": true + }, + "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==", + "dev": true + }, + "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 + }, + "deepmerge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" + }, + "default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dev": true, + "requires": { + "execa": "^5.0.0" + } + }, + "defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "dev": true, + "requires": { + "clone": "^1.0.2" + } + }, + "defer-to-connect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", + "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==", + "dev": true + }, + "define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true + }, + "define-properties": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.4.tgz", + "integrity": "sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==", + "requires": { + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + } + }, + "defined": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz", + "integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==", + "dev": true + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true + }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true + }, + "detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true + }, + "detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true + }, + "detect-port-alt": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz", + "integrity": "sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==", + "dev": true, + "requires": { + "address": "^1.0.1", + "debug": "^2.6.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "detective": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz", + "integrity": "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==", + "dev": true, + "requires": { + "acorn-node": "^1.8.2", + "defined": "^1.0.0", + "minimist": "^1.2.6" + } + }, + "didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "diff-sequences": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", + "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==", + "dev": true + }, + "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, + "requires": { + "path-type": "^4.0.0" + } + }, + "dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, + "dnd-core": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", + "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", + "requires": { + "@react-dnd/asap": "^4.0.0", + "@react-dnd/invariant": "^2.0.0", + "redux": "^4.1.1" + } + }, + "dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==", + "dev": true + }, + "dns-packet": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.4.0.tgz", + "integrity": "sha512-EgqGeaBB8hLiHLZtp/IbaDQTL8pZ0+IvwzSHA6d7VyMDM+B9hgddEMa9xjK5oYnw0ci0JQ6g2XCD7/f6cafU6g==", + "dev": true, + "requires": { + "@leichtgewicht/ip-codec": "^2.0.1" + } + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "dom-accessibility-api": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.6.tgz", + "integrity": "sha512-DplGLZd8L1lN64jlT27N9TVSESFR5STaEJvX+thCby7fuCHonfPpAlodYc3vuUYbDuDec5w8AMP7oCM5TWFsqw==", + "dev": true + }, + "dom-align": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.2.tgz", + "integrity": "sha512-pHuazgqrsTFrGU2WLDdXxCFabkdQDx72ddkraZNih1KsMcN5qsRSTR9O4VJRlwTPCPb5COYg3LOfiMHHcPInHg==" + }, + "dom-converter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "dev": true, + "requires": { + "utila": "~0.4" + } + }, + "dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + }, + "dependencies": { + "csstype": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz", + "integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==" + } + } + }, + "dom-serializer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", + "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", + "dev": true, + "requires": { + "domelementtype": "^2.0.1", + "entities": "^2.0.0" + }, + "dependencies": { + "domelementtype": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", + "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==", + "dev": true + } + } + }, + "domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", + "dev": true + }, + "domexception": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", + "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", + "dev": true, + "requires": { + "webidl-conversions": "^5.0.0" + }, + "dependencies": { + "webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "dev": true + } + } + }, + "domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, + "requires": { + "domelementtype": "^2.2.0" + }, + "dependencies": { + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true + } + } + }, + "dompurify": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.5.tgz", + "integrity": "sha512-jggCCd+8Iqp4Tsz0nIvpcb22InKEBrGz5dw3EQJMs8HPJDsKbFIO3STYtAvCfDx26Muevn1MHVI0XxjgFfmiSA==" + }, + "domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "dev": true, + "requires": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "requires": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + }, + "dependencies": { + "tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true + } + } + }, + "dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "requires": { + "is-obj": "^2.0.0" + } + }, + "dotenv": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-10.0.0.tgz", + "integrity": "sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==", + "dev": true + }, + "dotenv-expand": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-5.1.0.tgz", + "integrity": "sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==", + "dev": true + }, + "downloadjs": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/downloadjs/-/downloadjs-1.4.7.tgz", + "integrity": "sha1-9p+W+UDg0FU9rCkROYZaPNAQHjw=" + }, + "downshift": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/downshift/-/downshift-3.2.7.tgz", + "integrity": "sha512-mbUO9ZFhMGtksIeVWRFFjNOPN237VsUqZSEYi0VS0Wj38XNLzpgOBTUcUjdjFeB8KVgmrcRa6GGFkTbACpG6FA==", + "requires": { + "@babel/runtime": "^7.1.2", + "compute-scroll-into-view": "^1.0.9", + "prop-types": "^15.6.0", + "react-is": "^16.5.2" + } + }, + "duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true + }, + "duplexer3": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", + "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", + "dev": true + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true + }, + "electron-to-chromium": { + "version": "1.4.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.267.tgz", + "integrity": "sha512-ik4QnU3vFRsVgwt0vsn7og28++2cGnsdgqYagaE3ur1f3wj5AzmWu+1k3//SOc6CwkP2xfu46PNfVP6X+SRepg==", + "dev": true + }, + "emittery": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", + "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==", + "dev": true + }, + "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==", + "dev": true + }, + "emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "enhanced-resolve": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz", + "integrity": "sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + } + }, + "entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "dev": true, + "requires": { + "stackframe": "^1.3.4" + } + }, + "es-abstract": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.3.tgz", + "integrity": "sha512-AyrnaKVpMzljIdwjzrj+LxGmj8ik2LckwXacHqrJJ/jxz6dDDBcZ7I7nlHM0FvEW8MfbWJwOd+yT2XzYW49Frw==", + "requires": { + "call-bind": "^1.0.2", + "es-to-primitive": "^1.2.1", + "function-bind": "^1.1.1", + "function.prototype.name": "^1.1.5", + "get-intrinsic": "^1.1.3", + "get-symbol-description": "^1.0.0", + "has": "^1.0.3", + "has-property-descriptors": "^1.0.0", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.3", + "is-callable": "^1.2.6", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "is-string": "^1.0.7", + "is-weakref": "^1.0.2", + "object-inspect": "^1.12.2", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.4.3", + "safe-regex-test": "^1.0.0", + "string.prototype.trimend": "^1.0.5", + "string.prototype.trimstart": "^1.0.5", + "unbox-primitive": "^1.0.2" + } + }, + "es-array-method-boxes-properly": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", + "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==" + }, + "es-module-lexer": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", + "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==", + "dev": true + }, + "es-shim-unscopables": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz", + "integrity": "sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "requires": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + } + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, + "escape-goat": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", + "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==", + "dev": true + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "escodegen": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", + "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", + "dev": true, + "requires": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + } + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "dev": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + } + } + }, + "eslint": { + "version": "8.24.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.24.0.tgz", + "integrity": "sha512-dWFaPhGhTAiPcCgm3f6LI2MBWbogMnTJzFBbhXVRQDJPkr9pGZvVjlVfXd+vyDcWPA2Ic9L2AXPIQM0+vk/cSQ==", + "dev": true, + "requires": { + "@eslint/eslintrc": "^1.3.2", + "@humanwhocodes/config-array": "^0.10.5", + "@humanwhocodes/gitignore-to-minimatch": "^1.0.2", + "@humanwhocodes/module-importer": "^1.0.1", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.1.1", + "eslint-utils": "^3.0.0", + "eslint-visitor-keys": "^3.3.0", + "espree": "^9.4.0", + "esquery": "^1.4.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.1", + "globals": "^13.15.0", + "globby": "^11.1.0", + "grapheme-splitter": "^1.0.4", + "ignore": "^5.2.0", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-sdsl": "^4.1.4", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "regexpp": "^3.2.0", + "strip-ansi": "^6.0.1", + "strip-json-comments": "^3.1.0", + "text-table": "^0.2.0" + }, + "dependencies": { + "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 + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "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 + }, + "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, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "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, + "requires": { + "is-glob": "^4.0.3" + } + }, + "globals": { + "version": "13.17.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.17.0.tgz", + "integrity": "sha512-1C+6nQRb1GwGMKm2dH/E7enFAMxGTmGI7/dEdhy/DNelv85w9B72t3uc5frtMNXIbzrarJJ/lTCjcaZwbLJmyw==", + "dev": true, + "requires": { + "type-fest": "^0.20.2" + } + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "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, + "requires": { + "p-locate": "^5.0.0" + } + }, + "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, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "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, + "requires": { + "p-limit": "^3.0.2" + } + }, + "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 + } + } + }, + "eslint-config-react-app": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-config-react-app/-/eslint-config-react-app-7.0.1.tgz", + "integrity": "sha512-K6rNzvkIeHaTd8m/QEh1Zko0KI7BACWkkneSs6s9cKZC/J27X3eZR6Upt1jkmZ/4FK+XUOPPxMEN7+lbUXfSlA==", + "dev": true, + "requires": { + "@babel/core": "^7.16.0", + "@babel/eslint-parser": "^7.16.3", + "@rushstack/eslint-patch": "^1.1.0", + "@typescript-eslint/eslint-plugin": "^5.5.0", + "@typescript-eslint/parser": "^5.5.0", + "babel-preset-react-app": "^10.0.1", + "confusing-browser-globals": "^1.0.11", + "eslint-plugin-flowtype": "^8.0.3", + "eslint-plugin-import": "^2.25.3", + "eslint-plugin-jest": "^25.3.0", + "eslint-plugin-jsx-a11y": "^6.5.1", + "eslint-plugin-react": "^7.27.1", + "eslint-plugin-react-hooks": "^4.3.0", + "eslint-plugin-testing-library": "^5.0.1" + } + }, + "eslint-import-resolver-node": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz", + "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==", + "dev": true, + "requires": { + "debug": "^3.2.7", + "resolve": "^1.20.0" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "eslint-module-utils": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.4.tgz", + "integrity": "sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==", + "dev": true, + "requires": { + "debug": "^3.2.7" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + } + } + }, + "eslint-plugin-flowtype": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-flowtype/-/eslint-plugin-flowtype-8.0.3.tgz", + "integrity": "sha512-dX8l6qUL6O+fYPtpNRideCFSpmWOUVx5QcaGLVqe/vlDiBSe4vYljDWDETwnyFzpl7By/WVIu6rcrniCgH9BqQ==", + "dev": true, + "requires": { + "lodash": "^4.17.21", + "string-natural-compare": "^3.0.1" + } + }, + "eslint-plugin-import": { + "version": "2.26.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz", + "integrity": "sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==", + "dev": true, + "requires": { + "array-includes": "^3.1.4", + "array.prototype.flat": "^1.2.5", + "debug": "^2.6.9", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-module-utils": "^2.7.3", + "has": "^1.0.3", + "is-core-module": "^2.8.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.values": "^1.1.5", + "resolve": "^1.22.0", + "tsconfig-paths": "^3.14.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "eslint-plugin-jest": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-25.7.0.tgz", + "integrity": "sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==", + "dev": true, + "requires": { + "@typescript-eslint/experimental-utils": "^5.0.0" + } + }, + "eslint-plugin-jsx-a11y": { + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.6.1.tgz", + "integrity": "sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==", + "dev": true, + "requires": { + "@babel/runtime": "^7.18.9", + "aria-query": "^4.2.2", + "array-includes": "^3.1.5", + "ast-types-flow": "^0.0.7", + "axe-core": "^4.4.3", + "axobject-query": "^2.2.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "has": "^1.0.3", + "jsx-ast-utils": "^3.3.2", + "language-tags": "^1.0.5", + "minimatch": "^3.1.2", + "semver": "^6.3.0" + }, + "dependencies": { + "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 + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "eslint-plugin-react": { + "version": "7.31.8", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.31.8.tgz", + "integrity": "sha512-5lBTZmgQmARLLSYiwI71tiGVTLUuqXantZM6vlSY39OaDSV0M7+32K5DnLkmFrwTe+Ksz0ffuLUC91RUviVZfw==", + "dev": true, + "requires": { + "array-includes": "^3.1.5", + "array.prototype.flatmap": "^1.3.0", + "doctrine": "^2.1.0", + "estraverse": "^5.3.0", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.5", + "object.fromentries": "^2.0.5", + "object.hasown": "^1.1.1", + "object.values": "^1.1.5", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.3", + "semver": "^6.3.0", + "string.prototype.matchall": "^4.0.7" + }, + "dependencies": { + "doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "resolve": { + "version": "2.0.0-next.4", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz", + "integrity": "sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==", + "dev": true, + "requires": { + "is-core-module": "^2.9.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "eslint-plugin-react-hooks": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", + "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", + "dev": true, + "requires": {} + }, + "eslint-plugin-testing-library": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-testing-library/-/eslint-plugin-testing-library-5.7.0.tgz", + "integrity": "sha512-pI8LKtFiAflBpN4h14vTtfhKqLwtIW40TNhWyw0ckqHm0W/J0VmYtThoxpTAdHrvEWnkALSG1Z8ABBkIncMIHA==", + "dev": true, + "requires": { + "@typescript-eslint/utils": "^5.13.0" + } + }, + "eslint-scope": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", + "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + } + }, + "eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^2.0.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true + } + } + }, + "eslint-visitor-keys": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", + "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "dev": true + }, + "eslint-webpack-plugin": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-3.2.0.tgz", + "integrity": "sha512-avrKcGncpPbPSUHX6B3stNGzkKFto3eL+DKM4+VyMrVnhPc3vRczVlCq3uhuFOdRvDHTVXuzwk1ZKUrqDQHQ9w==", + "dev": true, + "requires": { + "@types/eslint": "^7.29.0 || ^8.4.1", + "jest-worker": "^28.0.2", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "jest-worker": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-28.1.3.tgz", + "integrity": "sha512-CqRA220YV/6jCo8VWvAt1KKx6eek1VIHMPeLEbpcfSfkEeWyBNppynM/o6q+Wmw+sOhos2ml34wZbSX3G13//g==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + } + }, + "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==", + "dev": true + }, + "schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + } + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "espree": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.0.tgz", + "integrity": "sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw==", + "dev": true, + "requires": { + "acorn": "^8.8.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.3.0" + }, + "dependencies": { + "acorn": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", + "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", + "dev": true + } + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esquery": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", + "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + } + }, + "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, + "requires": { + "estraverse": "^5.2.0" + } + }, + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + }, + "estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true + }, + "eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" + }, + "events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true + }, + "execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "dependencies": { + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true + } + } + }, + "exenv": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", + "integrity": "sha1-KueOhdmJQVhnCwPUe+wfA72Ru50=" + }, + "exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true + }, + "expect": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "dev": true, + "requires": { + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true + } + } + }, + "express": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.1.tgz", + "integrity": "sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q==", + "dev": true, + "requires": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.0", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.10.3", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "dev": true + }, + "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==", + "dev": true + } + } + }, + "external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dev": true, + "requires": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + } + }, + "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==" + }, + "fast-glob": { + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + } + }, + "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==", + "dev": true + }, + "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 + }, + "fastq": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", + "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==", + "dev": true, + "requires": { + "reusify": "^1.0.4" + } + }, + "faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "requires": { + "websocket-driver": ">=0.5.1" + } + }, + "fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "requires": { + "bser": "2.1.1" + } + }, + "figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "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, + "requires": { + "flat-cache": "^3.0.4" + } + }, + "file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "dependencies": { + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "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==", + "requires": { + "tslib": "^2.0.1" + }, + "dependencies": { + "tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" + } + } + }, + "filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "requires": { + "minimatch": "^5.0.1" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz", + "integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, + "filesize": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", + "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", + "dev": true + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "final-form": { + "version": "4.20.9", + "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.9.tgz", + "integrity": "sha512-shA1X/7v8RmukWMNRHx0l7+Bm41hOivY78IvOiBrPVHjyWFIyqqIEMCz7yTVRc9Ea+EU4WkZ5r4MH6whSo5taw==", + "requires": { + "@babel/runtime": "^7.10.0" + } + }, + "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==", + "requires": {} + }, + "finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dev": true, + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "requires": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + } + }, + "flatted": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", + "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", + "dev": true + }, + "follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "dev": true + }, + "fork-ts-checker-webpack-plugin": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz", + "integrity": "sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "chokidar": "^3.4.2", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "glob": "^7.1.6", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "dev": true, + "requires": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + } + }, + "schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + } + }, + "tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "dev": true + } + } + }, + "form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true + }, + "fraction.js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", + "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==", + "dev": true + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true + }, + "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==", + "dev": true, + "requires": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "fs-monkey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.3.tgz", + "integrity": "sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "function.prototype.name": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", + "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.0", + "functions-have-names": "^1.2.2" + } + }, + "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==" + }, + "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==", + "dev": true + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-intrinsic": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", + "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.3" + } + }, + "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==" + }, + "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==", + "dev": true + }, + "get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + } + }, + "glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "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, + "requires": { + "is-glob": "^4.0.1" + } + }, + "glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "global-dirs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-2.1.0.tgz", + "integrity": "sha512-MG6kdOUh/xBnyo9cJFeIKkLEc1AyFq42QTU4XiX51i2NEdxLxLWXIjEjmqKeSuKR7pAZjTqUVoT2b2huxVLgYQ==", + "dev": true, + "requires": { + "ini": "1.3.7" + }, + "dependencies": { + "ini": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.7.tgz", + "integrity": "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==", + "dev": true + } + } + }, + "global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, + "requires": { + "global-prefix": "^3.0.0" + } + }, + "global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "requires": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "dependencies": { + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "requires": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + } + }, + "got": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", + "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", + "dev": true, + "requires": { + "@sindresorhus/is": "^0.14.0", + "@szmarczak/http-timer": "^1.1.2", + "cacheable-request": "^6.0.0", + "decompress-response": "^3.3.0", + "duplexer3": "^0.1.4", + "get-stream": "^4.1.0", + "lowercase-keys": "^1.0.1", + "mimic-response": "^1.0.1", + "p-cancelable": "^1.0.0", + "to-readable-stream": "^1.0.0", + "url-parse-lax": "^3.0.0" + } + }, + "graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "dev": true + }, + "grapheme-splitter": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "dev": true + }, + "gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "requires": { + "duplexer": "^0.1.2" + } + }, + "handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true + }, + "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==", + "dev": true + }, + "harmony-reflect": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", + "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==", + "dev": true + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "has-property-descriptors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", + "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", + "requires": { + "get-intrinsic": "^1.1.1" + } + }, + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" + }, + "has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "requires": { + "has-symbols": "^1.0.2" + } + }, + "has-yarn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz", + "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==", + "dev": true + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "requires": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, + "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==", + "requires": { + "react-is": "^16.7.0" + } + }, + "hoopy": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz", + "integrity": "sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==", + "dev": true + }, + "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==", + "dev": true + }, + "hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "html-encoding-sniffer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", + "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", + "dev": true, + "requires": { + "whatwg-encoding": "^1.0.5" + } + }, + "html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "dev": true + }, + "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 + }, + "html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "dev": true, + "requires": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + } + }, + "html-webpack-plugin": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz", + "integrity": "sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw==", + "dev": true, + "requires": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + } + }, + "htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "dev": true, + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + }, + "dependencies": { + "dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true + }, + "domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + } + } + } + }, + "http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true + }, + "http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true + }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + } + }, + "http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", + "dev": true + }, + "http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "requires": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "dependencies": { + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + } + } + }, + "http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, + "requires": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + } + }, + "http-proxy-middleware": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", + "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "dev": true, + "requires": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "dependencies": { + "is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true + } + } + }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true + }, + "hyphenate-style-name": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", + "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==" + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "requires": {} + }, + "identity-obj-proxy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", + "integrity": "sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ=", + "dev": true, + "requires": { + "harmony-reflect": "^1.4.6" + } + }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true + }, + "ignore": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", + "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==", + "dev": true + }, + "immer": { + "version": "9.0.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.15.tgz", + "integrity": "sha512-2eB/sswms9AEUSkOm4SbV5Y7Vmt/bKRwByd52jfLkW4OLYeaTP3EEiJ9agqU0O/tq6Dk62Zfj+TJSqfm1rLVGQ==", + "dev": true + }, + "immutable": { + "version": "4.0.0-rc.12", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0-rc.12.tgz", + "integrity": "sha512-0M2XxkZLx/mi3t8NVwIm1g8nHoEmM9p9UBl/G9k4+hm0kBgOVdMV/B3CY5dQ8qG8qc80NN4gDV4HQv6FTJ5q7A==", + "optional": true + }, + "import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "import-lazy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", + "integrity": "sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=", + "dev": true + }, + "import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "requires": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "indefinite-observable": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/indefinite-observable/-/indefinite-observable-2.0.1.tgz", + "integrity": "sha512-G8vgmork+6H9S8lUAg1gtXEj2JxIQTo0g2PbFiYOdjkziSI0F7UYBiVwhZRuixhBCNGczAls34+5HJPyZysvxQ==", + "requires": { + "symbol-observable": "1.2.0" + } + }, + "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==", + "dev": true + }, + "inflection": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.1.tgz", + "integrity": "sha512-dldYtl2WlN0QDkIDtg8+xFwOS2Tbmp12t1cHa5/YClU6ZQjTFm7B66UcVbh9NQB+HvT5BAd2t5+yKsBkw5pcqA==" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, + "inquirer": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", + "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", + "dev": true, + "requires": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.19", + "mute-stream": "0.0.8", + "run-async": "^2.4.0", + "rxjs": "^6.6.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "internal-slot": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz", + "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==", + "requires": { + "get-intrinsic": "^1.1.0", + "has": "^1.0.3", + "side-channel": "^1.0.4" + } + }, + "ipaddr.js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.0.1.tgz", + "integrity": "sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==", + "dev": true + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "requires": { + "has-bigints": "^1.0.1" + } + }, + "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==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "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==" + }, + "is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "requires": { + "ci-info": "^2.0.0" + } + }, + "is-core-module": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.10.0.tgz", + "integrity": "sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "is-date-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", + "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==" + }, + "is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "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==", + "dev": true + }, + "is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-in-browser": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", + "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=" + }, + "is-installed-globally": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz", + "integrity": "sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g==", + "dev": true, + "requires": { + "global-dirs": "^2.0.1", + "is-path-inside": "^3.0.1" + }, + "dependencies": { + "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 + } + } + }, + "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==", + "dev": true + }, + "is-mobile": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-2.2.2.tgz", + "integrity": "sha512-wW/SXnYJkTjs++tVK5b6kVITZpAZPtUrt9SF80vvxGiF/Oywal+COk1jlRkiVq15RFNEQKQY31TkV24/1T5cVg==" + }, + "is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=", + "dev": true + }, + "is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==" + }, + "is-npm": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-4.0.0.tgz", + "integrity": "sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig==", + "dev": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true + }, + "is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", + "dev": true + }, + "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 + }, + "is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "requires": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + } + }, + "is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=", + "dev": true + }, + "is-root": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", + "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==", + "dev": true + }, + "is-shared-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", + "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "requires": { + "call-bind": "^1.0.2" + } + }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true + }, + "is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "requires": { + "has-tostringtag": "^1.0.0" + } + }, + "is-symbol": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "requires": { + "has-symbols": "^1.0.1" + } + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "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==", + "dev": true + }, + "is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "requires": { + "call-bind": "^1.0.2" + } + }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "requires": { + "is-docker": "^2.0.0" + } + }, + "is-yarn-global": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz", + "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==", + "dev": true + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "istanbul-lib-coverage": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", + "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.0.tgz", + "integrity": "sha512-6Lthe1hqXHBNsqvgDzGO6l03XNeu3CrG4RqQ1KM9+l5+jNGpEJfIELx1NS3SEHmJQA8np/u+E4EPRKRiu6m19A==", + "dev": true, + "requires": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^3.0.0", + "supports-color": "^7.1.0" + } + }, + "istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + } + }, + "istanbul-reports": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz", + "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, + "jake": { + "version": "10.8.5", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.5.tgz", + "integrity": "sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==", + "dev": true, + "requires": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.1", + "minimatch": "^3.0.4" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", + "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", + "dev": true, + "requires": { + "@jest/core": "^27.5.1", + "import-local": "^3.0.2", + "jest-cli": "^27.5.1" + } + }, + "jest-changed-files": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz", + "integrity": "sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==", + "dev": true, + "requires": { + "@jest/types": "^27.5.1", + "execa": "^5.0.0", + "throat": "^6.0.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-circus": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.5.1.tgz", + "integrity": "sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==", + "dev": true, + "requires": { + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^0.7.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3", + "throat": "^6.0.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "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, + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "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 + } + } + }, + "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==", + "dev": true + } + } + }, + "jest-cli": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.5.1.tgz", + "integrity": "sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==", + "dev": true, + "requires": { + "@jest/core": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "import-local": "^3.0.2", + "jest-config": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "prompts": "^2.0.1", + "yargs": "^16.2.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-config": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.5.1.tgz", + "integrity": "sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==", + "dev": true, + "requires": { + "@babel/core": "^7.8.0", + "@jest/test-sequencer": "^27.5.1", + "@jest/types": "^27.5.1", + "babel-jest": "^27.5.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.1", + "graceful-fs": "^4.2.9", + "jest-circus": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-jasmine2": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "ci-info": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.4.0.tgz", + "integrity": "sha512-t5QdPT5jq3o262DOQ8zA6E1tlH2upmUc4Hlvrbx1pGYJuiiHl7O7rvVNI+l8HTVhd/q3Qc9vqimkNk5yiXsAug==", + "dev": true + }, + "jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true + }, + "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, + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "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 + } + } + }, + "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==", + "dev": true + } + } + }, + "jest-diff": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", + "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^26.6.2", + "jest-get-type": "^26.3.0", + "pretty-format": "^26.6.2" + }, + "dependencies": { + "chalk": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", + "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-docblock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.5.1.tgz", + "integrity": "sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==", + "dev": true, + "requires": { + "detect-newline": "^3.0.0" + } + }, + "jest-each": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", + "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", + "dev": true, + "requires": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true + }, + "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, + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "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 + } + } + }, + "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==", + "dev": true + } + } + }, + "jest-environment-jsdom": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz", + "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==", + "dev": true, + "requires": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1", + "jsdom": "^16.6.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-environment-node": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz", + "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==", + "dev": true, + "requires": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-get-type": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", + "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true + }, + "jest-haste-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", + "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", + "dev": true, + "requires": { + "@jest/types": "^27.5.1", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "fsevents": "^2.3.2", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^27.5.1", + "jest-serializer": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "micromatch": "^4.0.4", + "walker": "^1.0.7" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + } + } + }, + "jest-jasmine2": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", + "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==", + "dev": true, + "requires": { + "@jest/environment": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "throat": "^6.0.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "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, + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "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 + } + } + }, + "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==", + "dev": true + } + } + }, + "jest-leak-detector": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", + "integrity": "sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==", + "dev": true, + "requires": { + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "dependencies": { + "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 + }, + "jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true + }, + "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, + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + } + }, + "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==", + "dev": true + } + } + }, + "jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "diff-sequences": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "dev": true + }, + "jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + } + }, + "jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true + }, + "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, + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "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 + } + } + }, + "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==", + "dev": true + } + } + }, + "jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "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, + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "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 + } + } + }, + "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==", + "dev": true + } + } + }, + "jest-mock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "dev": true, + "requires": { + "@jest/types": "^27.5.1", + "@types/node": "*" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-pnp-resolver": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", + "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", + "dev": true, + "requires": {} + }, + "jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", + "dev": true + }, + "jest-resolve": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", + "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", + "dev": true, + "requires": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "resolve": "^1.20.0", + "resolve.exports": "^1.1.0", + "slash": "^3.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-resolve-dependencies": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz", + "integrity": "sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==", + "dev": true, + "requires": { + "@jest/types": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-snapshot": "^27.5.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-runner": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.5.1.tgz", + "integrity": "sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==", + "dev": true, + "requires": { + "@jest/console": "^27.5.1", + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-leak-detector": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "source-map-support": "^0.5.6", + "throat": "^6.0.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + } + } + }, + "jest-runtime": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", + "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", + "dev": true, + "requires": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/globals": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "execa": "^5.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-serializer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", + "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==", + "dev": true, + "requires": { + "@types/node": "*", + "graceful-fs": "^4.2.9" + } + }, + "jest-snapshot": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", + "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", + "dev": true, + "requires": { + "@babel/core": "^7.7.2", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", + "@babel/types": "^7.0.0", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__traverse": "^7.0.4", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "natural-compare": "^1.4.0", + "pretty-format": "^27.5.1", + "semver": "^7.3.2" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "diff-sequences": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "dev": true + }, + "jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + } + }, + "jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true + }, + "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, + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "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 + } + } + }, + "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==", + "dev": true + } + } + }, + "jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "requires": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "ci-info": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.4.0.tgz", + "integrity": "sha512-t5QdPT5jq3o262DOQ8zA6E1tlH2upmUc4Hlvrbx1pGYJuiiHl7O7rvVNI+l8HTVhd/q3Qc9vqimkNk5yiXsAug==", + "dev": true + } + } + }, + "jest-validate": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", + "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", + "dev": true, + "requires": { + "@jest/types": "^27.5.1", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "leven": "^3.1.0", + "pretty-format": "^27.5.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true + }, + "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, + "requires": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "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 + } + } + }, + "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==", + "dev": true + } + } + }, + "jest-watch-typeahead": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jest-watch-typeahead/-/jest-watch-typeahead-1.1.0.tgz", + "integrity": "sha512-Va5nLSJTN7YFtC2jd+7wsoe1pNe5K4ShLux/E5iHEwlB9AxaxmggY7to9KUqKojhaJw3aXqt5WAb4jGPOolpEw==", + "dev": true, + "requires": { + "ansi-escapes": "^4.3.1", + "chalk": "^4.0.0", + "jest-regex-util": "^28.0.0", + "jest-watcher": "^28.0.0", + "slash": "^4.0.0", + "string-length": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "dependencies": { + "@jest/console": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-28.1.3.tgz", + "integrity": "sha512-QPAkP5EwKdK/bxIr6C1I4Vs0rm2nHiANzj/Z5X2JQkrZo6IqvC4ldZ9K95tF0HdidhA8Bo6egxSzUFPYKcEXLw==", + "dev": true, + "requires": { + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^28.1.3", + "jest-util": "^28.1.3", + "slash": "^3.0.0" + }, + "dependencies": { + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + } + } + }, + "@jest/test-result": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-28.1.3.tgz", + "integrity": "sha512-kZAkxnSE+FqE8YjW8gNuoVkkC9I7S1qmenl8sGcDOLropASP+BkcGKwhXoyqQuGOGeYY0y/ixjrd/iERpEXHNg==", + "dev": true, + "requires": { + "@jest/console": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + } + }, + "@jest/types": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-28.1.3.tgz", + "integrity": "sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ==", + "dev": true, + "requires": { + "@jest/schemas": "^28.1.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "17.0.13", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.13.tgz", + "integrity": "sha512-9sWaruZk2JGxIQU+IhI1fhPYRcQ0UuTNuKuCW9bR5fp7qi2Llf7WDzNa17Cy7TKnh3cdxDOiyTu6gaLS0eDatg==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "ci-info": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.4.0.tgz", + "integrity": "sha512-t5QdPT5jq3o262DOQ8zA6E1tlH2upmUc4Hlvrbx1pGYJuiiHl7O7rvVNI+l8HTVhd/q3Qc9vqimkNk5yiXsAug==", + "dev": true + }, + "emittery": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.10.2.tgz", + "integrity": "sha512-aITqOwnLanpHLNXZJENbOgjUBeHocD+xsSJmNrjovKBW5HbSpW3d1pEls7GFQPUWXiwG9+0P4GtHfEqC/4M0Iw==", + "dev": true + }, + "jest-message-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-28.1.3.tgz", + "integrity": "sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^28.1.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^28.1.3", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "dependencies": { + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + } + } + }, + "jest-regex-util": { + "version": "28.0.2", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-28.0.2.tgz", + "integrity": "sha512-4s0IgyNIy0y9FK+cjoVYoxamT7Zeo7MhzqRGx7YDYmaQn1wucY9rotiGkBzzcMXTtjrCAP/f7f+E0F7+fxPNdw==", + "dev": true + }, + "jest-util": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-28.1.3.tgz", + "integrity": "sha512-XdqfpHwpcSRko/C35uLYFM2emRAltIIKZiJ9eAmhjsj0CqZMa0p1ib0R5fWIqGhn1a103DebTbpqIaP1qCQ6tQ==", + "dev": true, + "requires": { + "@jest/types": "^28.1.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + } + }, + "jest-watcher": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-28.1.3.tgz", + "integrity": "sha512-t4qcqj9hze+jviFPUN3YAtAEeFnr/azITXQEMARf5cMwKY2SMBRnCQTXLixTl20OR6mLh9KLMrgVJgJISym+1g==", + "dev": true, + "requires": { + "@jest/test-result": "^28.1.3", + "@jest/types": "^28.1.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.10.2", + "jest-util": "^28.1.3", + "string-length": "^4.0.1" + }, + "dependencies": { + "string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "requires": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, + "pretty-format": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", + "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "dev": true, + "requires": { + "@jest/schemas": "^28.1.3", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "dependencies": { + "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 + } + } + }, + "react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, + "slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true + }, + "string-length": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz", + "integrity": "sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==", + "dev": true, + "requires": { + "char-regex": "^2.0.0", + "strip-ansi": "^7.0.1" + }, + "dependencies": { + "char-regex": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-2.0.1.tgz", + "integrity": "sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw==", + "dev": true + } + } + }, + "strip-ansi": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", + "integrity": "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==", + "dev": true, + "requires": { + "ansi-regex": "^6.0.1" + }, + "dependencies": { + "ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true + } + } + } + } + }, + "jest-watcher": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.5.1.tgz", + "integrity": "sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==", + "dev": true, + "requires": { + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "jest-util": "^27.5.1", + "string-length": "^4.0.1" + }, + "dependencies": { + "@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + } + }, + "@types/yargs": { + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz", + "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "jest-worker": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", + "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^7.0.0" + } + }, + "js-sdsl": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.1.4.tgz", + "integrity": "sha512-Y2/yD55y5jteOAmY50JbUZYwk3CP3wnLPEZnlR1w9oKhITrBEtAxwuWKebFf8hMrPMgbYwFoWK/lH2sBkErELw==", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsdom": { + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", + "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", + "dev": true, + "requires": { + "abab": "^2.0.5", + "acorn": "^8.2.4", + "acorn-globals": "^6.0.0", + "cssom": "^0.4.4", + "cssstyle": "^2.3.0", + "data-urls": "^2.0.0", + "decimal.js": "^10.2.1", + "domexception": "^2.0.1", + "escodegen": "^2.0.0", + "form-data": "^3.0.0", + "html-encoding-sniffer": "^2.0.1", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.0.0", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^2.0.0", + "webidl-conversions": "^6.1.0", + "whatwg-encoding": "^1.0.5", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.5.0", + "ws": "^7.4.6", + "xml-name-validator": "^3.0.0" + }, + "dependencies": { + "acorn": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", + "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", + "dev": true + } + } + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", + "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=", + "dev": true + }, + "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==", + "dev": true + }, + "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==", + "dev": true + }, + "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 + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "jsonexport": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsonexport/-/jsonexport-2.5.2.tgz", + "integrity": "sha512-4joNLCxxUAmS22GN3GA5os/MYFnq8oqXOKvoCymmcT0MPz/QPZ5eA+Fh5sIPxUji45RKq8DdQ1yoKq91p4E9VA==" + }, + "jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6", + "universalify": "^2.0.0" + } + }, + "jss": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss/-/jss-10.6.0.tgz", + "integrity": "sha512-n7SHdCozmxnzYGXBHe0NsO0eUf9TvsHVq2MXvi4JmTn3x5raynodDVE/9VQmBdWFyyj9HpHZ2B4xNZ7MMy7lkw==", + "requires": { + "@babel/runtime": "^7.3.1", + "csstype": "^3.0.2", + "indefinite-observable": "^2.0.1", + "is-in-browser": "^1.1.3", + "tiny-warning": "^1.0.2" + }, + "dependencies": { + "csstype": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz", + "integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==" + } + } + }, + "jss-plugin-camel-case": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.6.0.tgz", + "integrity": "sha512-JdLpA3aI/npwj3nDMKk308pvnhoSzkW3PXlbgHAzfx0yHWnPPVUjPhXFtLJzgKZge8lsfkUxvYSQ3X2OYIFU6A==", + "requires": { + "@babel/runtime": "^7.3.1", + "hyphenate-style-name": "^1.0.3", + "jss": "10.6.0" + } + }, + "jss-plugin-default-unit": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.6.0.tgz", + "integrity": "sha512-7y4cAScMHAxvslBK2JRK37ES9UT0YfTIXWgzUWD5euvR+JR3q+o8sQKzBw7GmkQRfZijrRJKNTiSt1PBsLI9/w==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "10.6.0" + } + }, + "jss-plugin-global": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.6.0.tgz", + "integrity": "sha512-I3w7ji/UXPi3VuWrTCbHG9rVCgB4yoBQLehGDTmsnDfXQb3r1l3WIdcO8JFp9m0YMmyy2CU7UOV6oPI7/Tmu+w==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "10.6.0" + } + }, + "jss-plugin-nested": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.6.0.tgz", + "integrity": "sha512-fOFQWgd98H89E6aJSNkEh2fAXquC9aZcAVjSw4q4RoQ9gU++emg18encR4AT4OOIFl4lQwt5nEyBBRn9V1Rk8g==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "10.6.0", + "tiny-warning": "^1.0.2" + } + }, + "jss-plugin-props-sort": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.6.0.tgz", + "integrity": "sha512-oMCe7hgho2FllNc60d9VAfdtMrZPo9n1Iu6RNa+3p9n0Bkvnv/XX5San8fTPujrTBScPqv9mOE0nWVvIaohNuw==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "10.6.0" + } + }, + "jss-plugin-rule-value-function": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.6.0.tgz", + "integrity": "sha512-TKFqhRTDHN1QrPTMYRlIQUOC2FFQb271+AbnetURKlGvRl/eWLswcgHQajwuxI464uZk91sPiTtdGi7r7XaWfA==", + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "10.6.0", + "tiny-warning": "^1.0.2" + } + }, + "jss-plugin-vendor-prefixer": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.6.0.tgz", + "integrity": "sha512-doJ7MouBXT1lypLLctCwb4nJ6lDYqrTfVS3LtXgox42Xz0gXusXIIDboeh6UwnSmox90QpVnub7au8ybrb0krQ==", + "requires": { + "@babel/runtime": "^7.3.1", + "css-vendor": "^2.0.8", + "jss": "10.6.0" + } + }, + "jsx-ast-utils": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz", + "integrity": "sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==", + "dev": true, + "requires": { + "array-includes": "^3.1.5", + "object.assign": "^4.1.3" + } + }, + "jwt-decode": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz", + "integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==" + }, + "keyv": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", + "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", + "dev": true, + "requires": { + "json-buffer": "3.0.0" + } + }, + "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==", + "dev": true + }, + "kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true + }, + "klona": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz", + "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==", + "dev": true + }, + "language-subtag-registry": { + "version": "0.3.22", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", + "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==", + "dev": true + }, + "language-tags": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz", + "integrity": "sha512-qJhlO9cGXi6hBGKoxEG/sKZDAHD5Hnu9Hs4WbOY3pCWXDhw0N8x1NenNzm2EnNLkLkk7J2SdxAkDSbb6ftT+UQ==", + "dev": true, + "requires": { + "language-subtag-registry": "~0.3.2" + } + }, + "latest-version": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz", + "integrity": "sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==", + "dev": true, + "requires": { + "package-json": "^6.3.0" + } + }, + "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==", + "dev": true + }, + "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, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "lilconfig": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz", + "integrity": "sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg==", + "dev": true + }, + "lines-and-columns": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", + "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", + "dev": true + }, + "loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true + }, + "loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, + "lodash.isequalwith": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.isequalwith/-/lodash.isequalwith-4.4.0.tgz", + "integrity": "sha1-Jmcm3dUo+FTyH06pigZWBuD7xrA=" + }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, + "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 + }, + "lodash.pick": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz", + "integrity": "sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM=" + }, + "lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=", + "dev": true + }, + "lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ=" + }, + "lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true + }, + "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==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "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==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "requires": { + "tslib": "^2.0.3" + }, + "dependencies": { + "tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true + } + } + }, + "lowercase-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", + "dev": true + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "lz-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", + "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=", + "dev": true + }, + "magic-string": { + "version": "0.25.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", + "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", + "dev": true, + "requires": { + "sourcemap-codec": "^1.4.4" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "requires": { + "tmpl": "1.0.5" + } + }, + "map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "dev": true + }, + "mdn-data": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", + "integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA==", + "dev": true + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true + }, + "memfs": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.4.7.tgz", + "integrity": "sha512-ygaiUSNalBX85388uskeCyhSAoOSgzBbtVCr9jA2RROssFL9Q19/ZXFqS+2Th2sr1ewNIWgFdLzLC3Yl1Zv+lw==", + "dev": true, + "requires": { + "fs-monkey": "^1.0.3" + } + }, + "meow": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-7.1.1.tgz", + "integrity": "sha512-GWHvA5QOcS412WCo8vwKDlTelGLsCGBVevQB5Kva961rmNfun0PCbv5+xta2kUMFJyR8/oWnn7ddeKdosbAPbA==", + "dev": true, + "requires": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^2.5.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.13.1", + "yargs-parser": "^18.1.3" + }, + "dependencies": { + "read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "requires": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "dependencies": { + "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==", + "dev": true + } + } + }, + "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==", + "dev": true, + "requires": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "dependencies": { + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + } + } + }, + "type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "dev": true + } + } + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "dev": true + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true + }, + "micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "requires": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "requires": { + "mime-db": "1.52.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true + }, + "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==", + "dev": true + }, + "mini-create-react-context": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz", + "integrity": "sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==", + "requires": { + "@babel/runtime": "^7.12.1", + "tiny-warning": "^1.0.3" + } + }, + "mini-css-extract-plugin": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.6.1.tgz", + "integrity": "sha512-wd+SD57/K6DiV7jIR34P+s3uckTRuQvx0tKPcvjFlrEylk6P4mQ2KSWk1hblj1Kxaqok7LogKOieygXqBczNlg==", + "dev": true, + "requires": { + "schema-utils": "^4.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "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==", + "dev": true + }, + "schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + } + } + } + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", + "dev": true + }, + "minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "requires": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "dependencies": { + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "dev": true + } + } + }, + "mkdirp": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", + "integrity": "sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==", + "dev": true, + "requires": { + "minimist": "^1.2.5" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "requires": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + } + }, + "mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true + }, + "nanoid": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "navidrome-music-player": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.1.tgz", + "integrity": "sha512-bHYr84ATUf/4+/PUoTpUSmpF4/igBx2UPhgnPqvda4FND+GJZtb1ikbMs1U+mhkNEUebe+2I29ob1zY7YZdtjg==", + "requires": { + "@react-icons/all-files": "^4.1.0", + "classnames": "^2.3.1", + "downloadjs": "^1.4.7", + "is-mobile": "^2.2.2", + "prop-types": "^15.7.2", + "rc-slider": "^9.7.2", + "rc-switch": "^3.2.2", + "react-draggable": "^4.4.3", + "sortablejs": "^1.13.0" + } + }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true + }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "requires": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + }, + "dependencies": { + "tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true + } + } + }, + "node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true + }, + "node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true + }, + "node-polyglot": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/node-polyglot/-/node-polyglot-2.4.2.tgz", + "integrity": "sha512-AgTVpQ32BQ5XPI+tFHJ9bCYxWwSLvtmEodX8ooftFhEuyCgBG6ijWulIVb7pH3THigtgvc9uLiPn0IO51KHpkg==", + "requires": { + "array.prototype.foreach": "^1.0.0", + "has": "^1.0.3", + "object.entries": "^1.1.4", + "string.prototype.trim": "^1.2.4", + "warning": "^4.0.3" + } + }, + "node-releases": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz", + "integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==", + "dev": true + }, + "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==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true + }, + "normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "requires": { + "path-key": "^3.0.0" + } + }, + "nth-check": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", + "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", + "dev": true, + "requires": { + "boolbase": "~1.0.0" + } + }, + "nwsapi": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.2.tgz", + "integrity": "sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true + }, + "object-inspect": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", + "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==" + }, + "object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==" + }, + "object.assign": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", + "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + } + }, + "object.entries": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz", + "integrity": "sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" + } + }, + "object.fromentries": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.5.tgz", + "integrity": "sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" + } + }, + "object.getownpropertydescriptors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.2.tgz", + "integrity": "sha512-WtxeKSzfBjlzL+F9b7M7hewDzMwy+C8NRssHd1YrNlzHzIDrXcXiNOMrezdAEM4UXixgV+vvnyBeN7Rygl2ttQ==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.2" + } + }, + "object.hasown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.1.tgz", + "integrity": "sha512-LYLe4tivNQzq4JdaWW6WO3HMZZJWzkkH8fnI6EebWl0VZth2wL2Lovm74ep2/gZzlaTdV62JZHEqHQ2yVn8Q/A==", + "dev": true, + "requires": { + "define-properties": "^1.1.4", + "es-abstract": "^1.19.5" + } + }, + "object.values": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz", + "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1" + } + }, + "obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "open": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.0.tgz", + "integrity": "sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==", + "dev": true, + "requires": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + } + }, + "optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + } + }, + "ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "requires": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, + "p-cancelable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", + "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==", + "dev": true + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "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==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "requires": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + } + }, + "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==", + "dev": true + }, + "package-json": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz", + "integrity": "sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==", + "dev": true, + "requires": { + "got": "^9.6.0", + "registry-auth-token": "^4.0.0", + "registry-url": "^5.0.0", + "semver": "^6.2.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "requires": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + }, + "dependencies": { + "tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true + } + } + }, + "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, + "requires": { + "callsites": "^3.0.0" + } + }, + "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==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, + "parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true + }, + "pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "requires": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + }, + "dependencies": { + "tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true + } + } + }, + "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==", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "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==", + "dev": true + }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "requires": { + "isarray": "0.0.1" + } + }, + "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 + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "dev": true + }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true + }, + "pirates": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz", + "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + }, + "pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + }, + "dependencies": { + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true + } + } + }, + "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==" + }, + "postcss": { + "version": "8.4.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.16.tgz", + "integrity": "sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ==", + "dev": true, + "requires": { + "nanoid": "^3.3.4", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + } + }, + "postcss-attribute-case-insensitive": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.2.tgz", + "integrity": "sha512-XIidXV8fDr0kKt28vqki84fRK8VW8eTuIa4PChv2MqKuT6C9UjmSKzen6KaWhWEoYvwxFCa7n/tC1SZ3tyq4SQ==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.10" + } + }, + "postcss-browser-comments": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-browser-comments/-/postcss-browser-comments-4.0.0.tgz", + "integrity": "sha512-X9X9/WN3KIvY9+hNERUqX9gncsgBA25XaeR+jshHz2j8+sYyHktHw1JdKuMjeLpGktXidqDhA7b/qm1mrBDmgg==", + "dev": true, + "requires": {} + }, + "postcss-calc": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", + "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.9", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-clamp": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", + "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-color-functional-notation": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-4.2.4.tgz", + "integrity": "sha512-2yrTAUZUab9s6CpxkxC4rVgFEVaR6/2Pipvi6qcgvnYiVqZcbDHEoBDhrXzyb7Efh2CCfHQNtcqWcIruDTIUeg==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-color-hex-alpha": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-8.0.4.tgz", + "integrity": "sha512-nLo2DCRC9eE4w2JmuKgVA3fGL3d01kGq752pVALF68qpGLmx2Qrk91QTKkdUqqp45T1K1XV8IhQpcu1hoAQflQ==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-color-rebeccapurple": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-7.1.1.tgz", + "integrity": "sha512-pGxkuVEInwLHgkNxUc4sdg4g3py7zUeCQ9sMfwyHAT+Ezk8a4OaaVZ8lIY5+oNqA/BXXgLyXv0+5wHP68R79hg==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-colormin": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.0.tgz", + "integrity": "sha512-WdDO4gOFG2Z8n4P8TWBpshnL3JpmNmJwdnfP2gbk2qBA8PWwOYcmjmI/t3CmMeL72a7Hkd+x/Mg9O2/0rD54Pg==", + "dev": true, + "requires": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0", + "colord": "^2.9.1", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-convert-values": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.2.tgz", + "integrity": "sha512-c6Hzc4GAv95B7suy4udszX9Zy4ETyMCgFPUDtWjdFTKH1SE9eFY/jEpHSwTH1QPuwxHpWslhckUQWbNRM4ho5g==", + "dev": true, + "requires": { + "browserslist": "^4.20.3", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-custom-media": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-8.0.2.tgz", + "integrity": "sha512-7yi25vDAoHAkbhAzX9dHx2yc6ntS4jQvejrNcC+csQJAXjj15e7VcWfMgLqBNAbOvqi5uIa9huOVwdHbf+sKqg==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-custom-properties": { + "version": "12.1.9", + "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-12.1.9.tgz", + "integrity": "sha512-/E7PRvK8DAVljBbeWrcEQJPG72jaImxF3vvCNFwv9cC8CzigVoNIpeyfnJzphnN3Fd8/auBf5wvkw6W9MfmTyg==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-custom-selectors": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-6.0.3.tgz", + "integrity": "sha512-fgVkmyiWDwmD3JbpCmB45SvvlCD6z9CG6Ie6Iere22W5aHea6oWa7EM2bpnv2Fj3I94L3VbtvX9KqwSi5aFzSg==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.4" + } + }, + "postcss-dir-pseudo-class": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-6.0.5.tgz", + "integrity": "sha512-eqn4m70P031PF7ZQIvSgy9RSJ5uI2171O/OO/zcRNYpJbvaeKFUlar1aJ7rmgiQtbm0FSPsRewjpdS0Oew7MPA==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.10" + } + }, + "postcss-discard-comments": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", + "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", + "dev": true, + "requires": {} + }, + "postcss-discard-duplicates": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", + "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", + "dev": true, + "requires": {} + }, + "postcss-discard-empty": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", + "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "dev": true, + "requires": {} + }, + "postcss-discard-overridden": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", + "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", + "dev": true, + "requires": {} + }, + "postcss-double-position-gradients": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-3.1.2.tgz", + "integrity": "sha512-GX+FuE/uBR6eskOK+4vkXgT6pDkexLokPaz/AbJna9s5Kzp/yl488pKPjhy0obB475ovfT1Wv8ho7U/cHNaRgQ==", + "dev": true, + "requires": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-env-function": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/postcss-env-function/-/postcss-env-function-4.0.6.tgz", + "integrity": "sha512-kpA6FsLra+NqcFnL81TnsU+Z7orGtDTxcOhl6pwXeEq1yFPpRMkCDpHhrz8CFQDr/Wfm0jLiNQ1OsGGPjlqPwA==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-flexbugs-fixes": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/postcss-flexbugs-fixes/-/postcss-flexbugs-fixes-5.0.2.tgz", + "integrity": "sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ==", + "dev": true, + "requires": {} + }, + "postcss-focus-visible": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-6.0.4.tgz", + "integrity": "sha512-QcKuUU/dgNsstIK6HELFRT5Y3lbrMLEOwG+A4s5cA+fx3A3y/JTq3X9LaOj3OC3ALH0XqyrgQIgey/MIZ8Wczw==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.9" + } + }, + "postcss-focus-within": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-5.0.4.tgz", + "integrity": "sha512-vvjDN++C0mu8jz4af5d52CB184ogg/sSxAFS+oUJQq2SuCe7T5U2iIsVJtsCp2d6R4j0jr5+q3rPkBVZkXD9fQ==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.9" + } + }, + "postcss-font-variant": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", + "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", + "dev": true, + "requires": {} + }, + "postcss-gap-properties": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-3.0.5.tgz", + "integrity": "sha512-IuE6gKSdoUNcvkGIqdtjtcMtZIFyXZhmFd5RUlg97iVEvp1BZKV5ngsAjCjrVy+14uhGBQl9tzmi1Qwq4kqVOg==", + "dev": true, + "requires": {} + }, + "postcss-image-set-function": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-4.0.7.tgz", + "integrity": "sha512-9T2r9rsvYzm5ndsBE8WgtrMlIT7VbtTfE7b3BQnudUqnBcBo7L758oc+o+pdj/dUV0l5wjwSdjeOH2DZtfv8qw==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-import": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-14.1.0.tgz", + "integrity": "sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + } + }, + "postcss-initial": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-initial/-/postcss-initial-4.0.1.tgz", + "integrity": "sha512-0ueD7rPqX8Pn1xJIjay0AZeIuDoF+V+VvMt/uOnn+4ezUKhZM/NokDeP6DwMNyIoYByuN/94IQnt5FEkaN59xQ==", + "dev": true, + "requires": {} + }, + "postcss-js": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.0.tgz", + "integrity": "sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==", + "dev": true, + "requires": { + "camelcase-css": "^2.0.1" + } + }, + "postcss-lab-function": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-4.2.1.tgz", + "integrity": "sha512-xuXll4isR03CrQsmxyz92LJB2xX9n+pZJ5jE9JgcnmsCammLyKdlzrBin+25dy6wIjfhJpKBAN80gsTlCgRk2w==", + "dev": true, + "requires": { + "@csstools/postcss-progressive-custom-properties": "^1.1.0", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "dev": true, + "requires": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + } + }, + "postcss-loader": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-6.2.1.tgz", + "integrity": "sha512-WbbYpmAaKcux/P66bZ40bpWsBucjx/TTgVVzRZ9yUO8yQfVBlameJ0ZGVaPfH64hNSBh63a+ICP5nqOpBA0w+Q==", + "dev": true, + "requires": { + "cosmiconfig": "^7.0.0", + "klona": "^2.0.5", + "semver": "^7.3.5" + } + }, + "postcss-logical": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-5.0.4.tgz", + "integrity": "sha512-RHXxplCeLh9VjinvMrZONq7im4wjWGlRJAqmAVLXyZaXwfDWP73/oq4NdIp+OZwhQUMj0zjqDfM5Fj7qby+B4g==", + "dev": true, + "requires": {} + }, + "postcss-media-minmax": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-media-minmax/-/postcss-media-minmax-5.0.0.tgz", + "integrity": "sha512-yDUvFf9QdFZTuCUg0g0uNSHVlJ5X1lSzDZjPSFaiCWvjgsvu8vEVxtahPrLMinIDEEGnx6cBe6iqdx5YWz08wQ==", + "dev": true, + "requires": {} + }, + "postcss-merge-longhand": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.6.tgz", + "integrity": "sha512-6C/UGF/3T5OE2CEbOuX7iNO63dnvqhGZeUnKkDeifebY0XqkkvrctYSZurpNE902LDf2yKwwPFgotnfSoPhQiw==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^5.1.0" + } + }, + "postcss-merge-rules": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.2.tgz", + "integrity": "sha512-zKMUlnw+zYCWoPN6yhPjtcEdlJaMUZ0WyVcxTAmw3lkkN/NDMRkOkiuctQEoWAOvH7twaxUUdvBWl0d4+hifRQ==", + "dev": true, + "requires": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^3.1.0", + "postcss-selector-parser": "^6.0.5" + } + }, + "postcss-minify-font-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", + "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-minify-gradients": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", + "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "dev": true, + "requires": { + "colord": "^2.9.1", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-minify-params": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.3.tgz", + "integrity": "sha512-bkzpWcjykkqIujNL+EVEPOlLYi/eZ050oImVtHU7b4lFS82jPnsCb44gvC6pxaNt38Els3jWYDHTjHKf0koTgg==", + "dev": true, + "requires": { + "browserslist": "^4.16.6", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-minify-selectors": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz", + "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.5" + } + }, + "postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true, + "requires": {} + }, + "postcss-modules-local-by-default": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz", + "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + } + }, + "postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.4" + } + }, + "postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0" + } + }, + "postcss-nested": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-5.0.6.tgz", + "integrity": "sha512-rKqm2Fk0KbA8Vt3AdGN0FB9OBOMDVajMG6ZCf/GoHgdxUJ4sBFp0A/uMIRm+MJUdo33YXEtjqIz8u7DAp8B7DA==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.6" + } + }, + "postcss-nesting": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-10.2.0.tgz", + "integrity": "sha512-EwMkYchxiDiKUhlJGzWsD9b2zvq/r2SSubcRrgP+jujMXFzqvANLt16lJANC+5uZ6hjI7lpRmI6O8JIl+8l1KA==", + "dev": true, + "requires": { + "@csstools/selector-specificity": "^2.0.0", + "postcss-selector-parser": "^6.0.10" + } + }, + "postcss-normalize": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize/-/postcss-normalize-10.0.1.tgz", + "integrity": "sha512-+5w18/rDev5mqERcG3W5GZNMJa1eoYYNGo8gB7tEwaos0ajk3ZXAI4mHGcNT47NE+ZnZD1pEpUOFLvltIwmeJA==", + "dev": true, + "requires": { + "@csstools/normalize.css": "*", + "postcss-browser-comments": "^4", + "sanitize.css": "*" + } + }, + "postcss-normalize-charset": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", + "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "dev": true, + "requires": {} + }, + "postcss-normalize-display-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", + "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-positions": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz", + "integrity": "sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-repeat-style": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz", + "integrity": "sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-string": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", + "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-timing-functions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", + "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-unicode": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.0.tgz", + "integrity": "sha512-J6M3MizAAZ2dOdSjy2caayJLQT8E8K9XjLce8AUQMwOrCvjCHv24aLC/Lps1R1ylOfol5VIDMaM/Lo9NGlk1SQ==", + "dev": true, + "requires": { + "browserslist": "^4.16.6", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", + "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "dev": true, + "requires": { + "normalize-url": "^6.0.1", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-normalize-whitespace": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", + "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-opacity-percentage": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-1.1.2.tgz", + "integrity": "sha512-lyUfF7miG+yewZ8EAk9XUBIlrHyUE6fijnesuz+Mj5zrIHIEw6KcIZSOk/elVMqzLvREmXB83Zi/5QpNRYd47w==", + "dev": true + }, + "postcss-ordered-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz", + "integrity": "sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==", + "dev": true, + "requires": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-overflow-shorthand": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-3.0.4.tgz", + "integrity": "sha512-otYl/ylHK8Y9bcBnPLo3foYFLL6a6Ak+3EQBPOTR7luMYCOsiVTUk1iLvNf6tVPNGXcoL9Hoz37kpfriRIFb4A==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-page-break": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", + "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", + "dev": true, + "requires": {} + }, + "postcss-place": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-7.0.5.tgz", + "integrity": "sha512-wR8igaZROA6Z4pv0d+bvVrvGY4GVHihBCBQieXFY3kuSuMyOmEnnfFzHl/tQuqHZkfkIVBEbDvYcFfHmpSet9g==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-preset-env": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-7.8.2.tgz", + "integrity": "sha512-rSMUEaOCnovKnwc5LvBDHUDzpGP+nrUeWZGWt9M72fBvckCi45JmnJigUr4QG4zZeOHmOCNCZnd2LKDvP++ZuQ==", + "dev": true, + "requires": { + "@csstools/postcss-cascade-layers": "^1.1.0", + "@csstools/postcss-color-function": "^1.1.1", + "@csstools/postcss-font-format-keywords": "^1.0.1", + "@csstools/postcss-hwb-function": "^1.0.2", + "@csstools/postcss-ic-unit": "^1.0.1", + "@csstools/postcss-is-pseudo-class": "^2.0.7", + "@csstools/postcss-nested-calc": "^1.0.0", + "@csstools/postcss-normalize-display-values": "^1.0.1", + "@csstools/postcss-oklab-function": "^1.1.1", + "@csstools/postcss-progressive-custom-properties": "^1.3.0", + "@csstools/postcss-stepped-value-functions": "^1.0.1", + "@csstools/postcss-text-decoration-shorthand": "^1.0.0", + "@csstools/postcss-trigonometric-functions": "^1.0.2", + "@csstools/postcss-unset-value": "^1.0.2", + "autoprefixer": "^10.4.11", + "browserslist": "^4.21.3", + "css-blank-pseudo": "^3.0.3", + "css-has-pseudo": "^3.0.4", + "css-prefers-color-scheme": "^6.0.3", + "cssdb": "^7.0.1", + "postcss-attribute-case-insensitive": "^5.0.2", + "postcss-clamp": "^4.1.0", + "postcss-color-functional-notation": "^4.2.4", + "postcss-color-hex-alpha": "^8.0.4", + "postcss-color-rebeccapurple": "^7.1.1", + "postcss-custom-media": "^8.0.2", + "postcss-custom-properties": "^12.1.9", + "postcss-custom-selectors": "^6.0.3", + "postcss-dir-pseudo-class": "^6.0.5", + "postcss-double-position-gradients": "^3.1.2", + "postcss-env-function": "^4.0.6", + "postcss-focus-visible": "^6.0.4", + "postcss-focus-within": "^5.0.4", + "postcss-font-variant": "^5.0.0", + "postcss-gap-properties": "^3.0.5", + "postcss-image-set-function": "^4.0.7", + "postcss-initial": "^4.0.1", + "postcss-lab-function": "^4.2.1", + "postcss-logical": "^5.0.4", + "postcss-media-minmax": "^5.0.0", + "postcss-nesting": "^10.2.0", + "postcss-opacity-percentage": "^1.1.2", + "postcss-overflow-shorthand": "^3.0.4", + "postcss-page-break": "^3.0.4", + "postcss-place": "^7.0.5", + "postcss-pseudo-class-any-link": "^7.1.6", + "postcss-replace-overflow-wrap": "^4.0.0", + "postcss-selector-not": "^6.0.1", + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-pseudo-class-any-link": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-7.1.6.tgz", + "integrity": "sha512-9sCtZkO6f/5ML9WcTLcIyV1yz9D1rf0tWc+ulKcvV30s0iZKS/ONyETvoWsr6vnrmW+X+KmuK3gV/w5EWnT37w==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.10" + } + }, + "postcss-reduce-initial": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.0.tgz", + "integrity": "sha512-5OgTUviz0aeH6MtBjHfbr57tml13PuedK/Ecg8szzd4XRMbYxH4572JFG067z+FqBIf6Zp/d+0581glkvvWMFw==", + "dev": true, + "requires": { + "browserslist": "^4.16.6", + "caniuse-api": "^3.0.0" + } + }, + "postcss-reduce-transforms": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", + "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0" + } + }, + "postcss-replace-overflow-wrap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", + "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", + "dev": true, + "requires": {} + }, + "postcss-selector-not": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-6.0.1.tgz", + "integrity": "sha512-1i9affjAe9xu/y9uqWH+tD4r6/hDaXJruk8xn2x1vzxC2U3J3LKO3zJW4CyxlNhA56pADJ/djpEwpH1RClI2rQ==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.10" + } + }, + "postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + } + }, + "postcss-svgo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", + "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "dev": true, + "requires": { + "postcss-value-parser": "^4.2.0", + "svgo": "^2.7.0" + }, + "dependencies": { + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true + }, + "css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + } + }, + "css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, + "requires": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + } + }, + "css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true + }, + "dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true + }, + "domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + } + }, + "mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true + }, + "nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "requires": { + "boolbase": "^1.0.0" + } + }, + "svgo": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", + "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "dev": true, + "requires": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "stable": "^0.1.8" + } + } + } + }, + "postcss-unique-selectors": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", + "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.5" + } + }, + "postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "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 + }, + "prettier": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.2.tgz", + "integrity": "sha512-BtRV9BcncDyI2tsuS19zzhzoxD8Dh8LiCx7j7tHzrkz8GFXAexeWFdi22mjE1d16dftH2qNaytVxqiRTGlMfpw==", + "dev": true + }, + "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==", + "dev": true + }, + "pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "dev": true, + "requires": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "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, + "requires": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + }, + "dependencies": { + "react-is": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", + "dev": true + } + } + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "promise": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.2.0.tgz", + "integrity": "sha512-+CMAlLHqwRYwBMXKCP+o8ns7DN+xHDUiI+0nArsiJ9y+kJVPLFxEaSw6Ha9s9H0tftxg2Yzl25wqj9G7m5wLZg==", + "dev": true, + "requires": { + "asap": "~2.0.6" + } + }, + "prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "requires": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + } + }, + "prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + }, + "dependencies": { + "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==" + } + } + }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "dependencies": { + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true + } + } + }, + "psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "pupa": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.1.1.tgz", + "integrity": "sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==", + "dev": true, + "requires": { + "escape-goat": "^2.0.0" + } + }, + "q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=", + "dev": true + }, + "qs": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", + "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==", + "dev": true, + "requires": { + "side-channel": "^1.0.4" + } + }, + "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==", + "requires": { + "decode-uri-component": "^0.2.0", + "object-assign": "^4.1.0", + "strict-uri-encode": "^1.0.0" + } + }, + "querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, + "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 + }, + "quick-lru": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "dev": true + }, + "ra-core": { + "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.19.12.tgz", + "integrity": "sha512-E0cM6OjEUtccaR+dR5mL1MLiVVYML0Yf7aPhpLEq4iue73X3+CKcLztInoBhWgeevPbFQwgAtsXhlpedeyrNNg==", + "requires": { + "classnames": "~2.3.1", + "date-fns": "^1.29.0", + "eventemitter3": "^3.0.0", + "inflection": "~1.13.1", + "lodash": "~4.17.5", + "prop-types": "^15.6.1", + "query-string": "^5.1.1", + "reselect": "~3.0.0" + } + }, + "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==", + "requires": { + "query-string": "^5.1.1", + "ra-core": "^3.19.12" + } + }, + "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==", + "requires": { + "node-polyglot": "^2.2.2", + "ra-core": "^3.19.12" + } + }, + "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==", + "requires": { + "ra-core": "^3.19.12" + } + }, + "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, + "requires": { + "@testing-library/react": "^11.2.3", + "classnames": "~2.3.1", + "lodash": "~4.17.5" + }, + "dependencies": { + "@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, + "requires": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^4.2.0", + "aria-query": "^4.2.2", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.6", + "lz-string": "^1.4.4", + "pretty-format": "^26.6.2" + } + }, + "@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, + "requires": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^7.28.1" + } + }, + "chalk": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", + "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "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==", + "requires": { + "autosuggest-highlight": "^3.1.1", + "classnames": "~2.2.5", + "connected-react-router": "^6.5.2", + "css-mediaquery": "^0.1.2", + "dompurify": "^2.4.3", + "downshift": "3.2.7", + "inflection": "~1.13.1", + "jsonexport": "^2.4.1", + "lodash": "~4.17.5", + "prop-types": "^15.7.0", + "query-string": "^5.1.1", + "react-dropzone": "^10.1.7", + "react-transition-group": "^4.4.1" + }, + "dependencies": { + "classnames": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.6.tgz", + "integrity": "sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==" + } + } + }, + "raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "dev": true, + "requires": { + "performance-now": "^2.1.0" + } + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true + }, + "raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dev": true, + "requires": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "dependencies": { + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true + } + } + }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + } + } + }, + "rc-align": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/rc-align/-/rc-align-4.0.9.tgz", + "integrity": "sha512-myAM2R4qoB6LqBul0leaqY8gFaiECDJ3MtQDmzDo9xM9NRT/04TvWOYd2YHU9zvGzqk9QXF6S9/MifzSKDZeMw==", + "requires": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "dom-align": "^1.7.0", + "rc-util": "^5.3.0", + "resize-observer-polyfill": "^1.5.1" + } + }, + "rc-motion": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.4.3.tgz", + "integrity": "sha512-GZLLFXHl/VqTfI7bSZNNZozcblNmDka1AAoQig7EZ6s0rWg5y0RlgrcHWO+W+nrOVbYfJDxoaQUoP2fEmvCWmA==", + "requires": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.2.1" + } + }, + "rc-slider": { + "version": "9.7.2", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-9.7.2.tgz", + "integrity": "sha512-mVaLRpDo6otasBs6yVnG02ykI3K6hIrLTNfT5eyaqduFv95UODI9PDS6fWuVVehVpdS4ENgOSwsTjrPVun+k9g==", + "requires": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-tooltip": "^5.0.1", + "rc-util": "^5.0.0", + "shallowequal": "^1.1.0" + } + }, + "rc-switch": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-3.2.2.tgz", + "integrity": "sha512-+gUJClsZZzvAHGy1vZfnwySxj+MjLlGRyXKXScrtCTcmiYNPzxDFOxdQ/3pK1Kt/0POvwJ/6ALOR8gwdXGhs+A==", + "requires": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-util": "^5.0.1" + } + }, + "rc-tooltip": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-5.1.1.tgz", + "integrity": "sha512-alt8eGMJulio6+4/uDm7nvV+rJq9bsfxFDCI0ljPdbuoygUscbsMYb6EQgwib/uqsXQUvzk+S7A59uYHmEgmDA==", + "requires": { + "@babel/runtime": "^7.11.2", + "rc-trigger": "^5.0.0" + } + }, + "rc-trigger": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/rc-trigger/-/rc-trigger-5.2.8.tgz", + "integrity": "sha512-Tn84oGmvNBLXI+ptpzxyJx4ArKTduuB6l74ShDLhDaJaF9f5JAMizfx31L30ELVIzRr3Ze4sekG7rzwPGwVOdw==", + "requires": { + "@babel/runtime": "^7.11.2", + "classnames": "^2.2.6", + "rc-align": "^4.0.0", + "rc-motion": "^2.0.0", + "rc-util": "^5.5.0" + } + }, + "rc-util": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.13.0.tgz", + "integrity": "sha512-hDnFgTt7uLowVNAtWxkCrVdVipqt+4IFyvasMglk9iiWMz/zXB5RjYArPp8hZ6TrxtrdctExh0qTJB3AqwLLRQ==", + "requires": { + "@babel/runtime": "^7.12.5", + "react-is": "^16.12.0", + "shallowequal": "^1.1.0" + } + }, + "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==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "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==", + "requires": { + "@material-ui/core": "^4.12.1", + "@material-ui/icons": "^4.11.2", + "@material-ui/styles": "^4.11.2", + "connected-react-router": "^6.5.2", + "final-form": "^4.20.4", + "final-form-arrays": "^3.0.2", + "ra-core": "^3.19.12", + "ra-i18n-polyglot": "^3.19.12", + "ra-language-english": "^3.19.12", + "ra-ui-materialui": "^3.19.12", + "react-final-form": "^6.5.7", + "react-final-form-arrays": "^3.1.3", + "react-redux": "^7.1.0", + "react-router": "^5.1.0", + "react-router-dom": "^5.1.0", + "redux": "^3.7.2 || ^4.0.3", + "redux-saga": "^1.0.0" + } + }, + "react-app-polyfill": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-app-polyfill/-/react-app-polyfill-3.0.0.tgz", + "integrity": "sha512-sZ41cxiU5llIB003yxxQBYrARBqe0repqPTTYBTmMqTz9szeBbE37BehCE891NZsmdZqqP+xWKdT3eo3vOzN8w==", + "dev": true, + "requires": { + "core-js": "^3.19.2", + "object-assign": "^4.1.1", + "promise": "^8.1.0", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.9", + "whatwg-fetch": "^3.6.2" + } + }, + "react-dev-utils": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", + "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.16.0", + "address": "^1.1.2", + "browserslist": "^4.18.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "detect-port-alt": "^1.1.6", + "escape-string-regexp": "^4.0.0", + "filesize": "^8.0.6", + "find-up": "^5.0.0", + "fork-ts-checker-webpack-plugin": "^6.5.0", + "global-modules": "^2.0.0", + "globby": "^11.0.4", + "gzip-size": "^6.0.0", + "immer": "^9.0.7", + "is-root": "^2.1.0", + "loader-utils": "^3.2.0", + "open": "^8.4.0", + "pkg-up": "^3.1.0", + "prompts": "^2.4.2", + "react-error-overlay": "^6.0.11", + "recursive-readdir": "^2.2.2", + "shell-quote": "^1.7.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "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 + }, + "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, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "loader-utils": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", + "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", + "dev": true + }, + "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, + "requires": { + "p-locate": "^5.0.0" + } + }, + "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, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "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, + "requires": { + "p-limit": "^3.0.2" + } + } + } + }, + "react-dnd": { + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.4.tgz", + "integrity": "sha512-AFJJXzUIWp5WAhgvI85ESkDCawM0lhoVvfo/lrseLXwFdH3kEO3v8I2C81QPqBW2UEyJBIPStOhPMGYGFtq/bg==", + "requires": { + "@react-dnd/invariant": "^2.0.0", + "@react-dnd/shallowequal": "^2.0.0", + "dnd-core": "14.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + } + }, + "react-dnd-html5-backend": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.0.2.tgz", + "integrity": "sha512-QgN6rYrOm4UUj6tIvN8ovImu6uP48xBXF2rzVsp6tvj6d5XQ7OjHI4SJ/ZgGobOneRAU3WCX4f8DGCYx0tuhlw==", + "requires": { + "dnd-core": "14.0.1" + } + }, + "react-dom": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", + "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "scheduler": "^0.20.2" + } + }, + "react-drag-listview": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/react-drag-listview/-/react-drag-listview-0.1.8.tgz", + "integrity": "sha512-ZJnjFEz89RPZ1DzI8f6LngmtsmJbLry/pMz2tEqABxHA+d8cUFRmVPS1DxZdoz/htc+uri9fCdv4dqIiPz0xIA==", + "requires": { + "babel-runtime": "^6.26.0", + "prop-types": "^15.5.8" + } + }, + "react-draggable": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.3.tgz", + "integrity": "sha512-jV4TE59MBuWm7gb6Ns3Q1mxX8Azffb7oTtDtBgFkxRvhDp38YAARmRplrj0+XGkhOJB5XziArX+4HUUABtyZ0w==", + "requires": { + "classnames": "^2.2.5", + "prop-types": "^15.6.0" + } + }, + "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==", + "requires": { + "attr-accept": "^2.0.0", + "file-selector": "^0.1.12", + "prop-types": "^15.7.2" + } + }, + "react-error-boundary": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.3.tgz", + "integrity": "sha512-A+F9HHy9fvt9t8SNDlonq01prnU8AmkjvGKV4kk8seB9kU3xMEO8J/PQlLVmoOIDODl5U2kufSBs4vrWIqhsAA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5" + } + }, + "react-error-overlay": { + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", + "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==", + "dev": true + }, + "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==", + "requires": { + "@babel/runtime": "^7.15.4" + } + }, + "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==", + "requires": { + "@babel/runtime": "^7.19.4" + } + }, + "react-ga": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/react-ga/-/react-ga-3.3.1.tgz", + "integrity": "sha512-4Vc0W5EvXAXUN/wWyxvsAKDLLgtJ3oLmhYYssx+YzphJpejtOst6cbIHCIyF50Fdxuf5DDKqRYny24yJ2y7GFQ==", + "requires": {} + }, + "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==", + "requires": { + "prop-types": "^15.6.1" + } + }, + "react-icons": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.4.0.tgz", + "integrity": "sha512-fSbvHeVYo/B5/L4VhB7sBA1i2tS8MkT0Hb9t2H1AVPkwGfVHLJCqyr2Py9dKMxsyM63Eng1GkdZfbWj+Fmv8Rg==", + "requires": {} + }, + "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==", + "requires": { + "prop-types": "^15.7.2", + "react-modal": "^3.11.1" + } + }, + "react-is": { + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz", + "integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q==" + }, + "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==" + }, + "react-measure": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/react-measure/-/react-measure-2.5.2.tgz", + "integrity": "sha512-M+rpbTLWJ3FD6FXvYV6YEGvQ5tMayQ3fGrZhRPHrE9bVlBYfDCLuDcgNttYfk8IqfOI03jz6cbpqMRTUclQnaA==", + "requires": { + "@babel/runtime": "^7.2.0", + "get-node-dimensions": "^1.2.1", + "prop-types": "^15.6.2", + "resize-observer-polyfill": "^1.5.0" + } + }, + "react-modal": { + "version": "3.11.2", + "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.11.2.tgz", + "integrity": "sha512-o8gvvCOFaG1T7W6JUvsYjRjMVToLZgLIsi5kdhFIQCtHxDkA47LznX62j+l6YQkpXDbvQegsDyxe/+JJsFQN7w==", + "requires": { + "exenv": "^1.2.0", + "prop-types": "^15.5.10", + "react-lifecycles-compat": "^3.0.0", + "warning": "^4.0.3" + } + }, + "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==", + "requires": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "dependencies": { + "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==" + } + } + }, + "react-refresh": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", + "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", + "dev": true + }, + "react-router": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.1.tgz", + "integrity": "sha512-lIboRiOtDLFdg1VTemMwud9vRVuOCZmUIT/7lUoZiSpPODiiH1UQlfXy+vPLC/7IWdFYnhRwAyNqA/+I7wnvKQ==", + "requires": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "mini-create-react-context": "^0.4.0", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + } + }, + "react-router-dom": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.0.tgz", + "integrity": "sha512-ObVBLjUZsphUUMVycibxgMdh5jJ1e3o+KpAZBVeHcNQZ4W+uUGGWsokurzlF4YOldQYRQL4y6yFRWM4m3svmuQ==", + "requires": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.2.1", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + } + }, + "react-scripts": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", + "integrity": "sha512-8VAmEm/ZAwQzJ+GOMLbBsTdDKOpuZh7RPs0UymvBR2vRk4iZWCskjbFnxqjrzoIvlNNRZ3QJFx6/qDSi6zSnaQ==", + "dev": true, + "requires": { + "@babel/core": "^7.16.0", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.3", + "@svgr/webpack": "^5.5.0", + "babel-jest": "^27.4.2", + "babel-loader": "^8.2.3", + "babel-plugin-named-asset-import": "^0.3.8", + "babel-preset-react-app": "^10.0.1", + "bfj": "^7.0.2", + "browserslist": "^4.18.1", + "camelcase": "^6.2.1", + "case-sensitive-paths-webpack-plugin": "^2.4.0", + "css-loader": "^6.5.1", + "css-minimizer-webpack-plugin": "^3.2.0", + "dotenv": "^10.0.0", + "dotenv-expand": "^5.1.0", + "eslint": "^8.3.0", + "eslint-config-react-app": "^7.0.1", + "eslint-webpack-plugin": "^3.1.1", + "file-loader": "^6.2.0", + "fs-extra": "^10.0.0", + "fsevents": "^2.3.2", + "html-webpack-plugin": "^5.5.0", + "identity-obj-proxy": "^3.0.0", + "jest": "^27.4.3", + "jest-resolve": "^27.4.2", + "jest-watch-typeahead": "^1.0.0", + "mini-css-extract-plugin": "^2.4.5", + "postcss": "^8.4.4", + "postcss-flexbugs-fixes": "^5.0.2", + "postcss-loader": "^6.2.1", + "postcss-normalize": "^10.0.1", + "postcss-preset-env": "^7.0.1", + "prompts": "^2.4.2", + "react-app-polyfill": "^3.0.0", + "react-dev-utils": "^12.0.1", + "react-refresh": "^0.11.0", + "resolve": "^1.20.0", + "resolve-url-loader": "^4.0.0", + "sass-loader": "^12.3.0", + "semver": "^7.3.5", + "source-map-loader": "^3.0.0", + "style-loader": "^3.3.1", + "tailwindcss": "^3.0.2", + "terser-webpack-plugin": "^5.2.5", + "webpack": "^5.64.4", + "webpack-dev-server": "^4.6.0", + "webpack-manifest-plugin": "^4.0.2", + "workbox-webpack-plugin": "^6.4.1" + }, + "dependencies": { + "@pmmmwh/react-refresh-webpack-plugin": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.7.tgz", + "integrity": "sha512-bcKCAzF0DV2IIROp9ZHkRJa6O4jy7NlnHdWL3GmcUxYWNjLXkK5kfELELwEfSP5hXPfVL/qOGMAROuMQb9GG8Q==", + "dev": true, + "requires": { + "ansi-html-community": "^0.0.8", + "common-path-prefix": "^3.0.0", + "core-js-pure": "^3.8.1", + "error-stack-parser": "^2.0.6", + "find-up": "^5.0.0", + "html-entities": "^2.1.0", + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0", + "source-map": "^0.7.3" + } + }, + "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, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + } + }, + "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, + "requires": { + "p-locate": "^5.0.0" + } + }, + "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, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "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, + "requires": { + "p-limit": "^3.0.2" + } + }, + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + }, + "source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true + }, + "type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "optional": true, + "peer": true + } + } + }, + "react-transition-group": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz", + "integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, + "read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "requires": { + "pify": "^2.3.0" + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "recursive-readdir": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", + "integrity": "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==", + "dev": true, + "requires": { + "minimatch": "^3.0.5" + } + }, + "redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "requires": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + } + }, + "redux": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.0.tgz", + "integrity": "sha512-oSBmcKKIuIR4ME29/AeNUnl5L+hvBq7OaJWzaptTQJAntaPvxIJqfnjbaEiCzzaIz+XmVILfqAM3Ob0aXLPfjA==", + "requires": { + "@babel/runtime": "^7.9.2" + } + }, + "redux-saga": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.1.3.tgz", + "integrity": "sha512-RkSn/z0mwaSa5/xH/hQLo8gNf4tlvT18qXDNvedihLcfzh+jMchDgaariQoehCpgRltEm4zHKJyINEz6aqswTw==", + "requires": { + "@redux-saga/core": "^1.1.3" + } + }, + "regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "regenerate-unicode-properties": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz", + "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==", + "dev": true, + "requires": { + "regenerate": "^1.4.2" + } + }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, + "regenerator-transform": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.0.tgz", + "integrity": "sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.8.4" + } + }, + "regex-parser": { + "version": "2.2.11", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.2.11.tgz", + "integrity": "sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==", + "dev": true + }, + "regexp.prototype.flags": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", + "integrity": "sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "functions-have-names": "^1.2.2" + } + }, + "regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true + }, + "regexpu-core": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.2.1.tgz", + "integrity": "sha512-HrnlNtpvqP1Xkb28tMhBUO2EbyUHdQlsnlAhzWcwHy8WJR53UWr7/MAvqrsQKMbV4qdpv03oTMG8iIhfsPFktQ==", + "dev": true, + "requires": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.1.0", + "regjsgen": "^0.7.1", + "regjsparser": "^0.9.1", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.0.0" + } + }, + "registry-auth-token": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.1.tgz", + "integrity": "sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw==", + "dev": true, + "requires": { + "rc": "^1.2.8" + } + }, + "registry-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-5.1.0.tgz", + "integrity": "sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==", + "dev": true, + "requires": { + "rc": "^1.2.8" + } + }, + "regjsgen": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.7.1.tgz", + "integrity": "sha512-RAt+8H2ZEzHeYWxZ3H2z6tF18zyyOnlcdaafLrm21Bguj7uZy6ULibiAFdXEtKQY4Sy7wDTwDiOazasMLc4KPA==", + "dev": true + }, + "regjsparser": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", + "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "dev": true + } + } + }, + "relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "dev": true + }, + "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==" + }, + "renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "dev": true, + "requires": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + }, + "dependencies": { + "css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "requires": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + } + }, + "css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true + }, + "dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true + }, + "domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + } + }, + "nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "requires": { + "boolbase": "^1.0.0" + } + } + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true + }, + "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==", + "dev": true + }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "reselect": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz", + "integrity": "sha1-79qpjqdFEyTQkrKyFjpqHXqaIUc=" + }, + "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==" + }, + "resolve": { + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", + "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", + "dev": true, + "requires": { + "is-core-module": "^2.9.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "requires": { + "resolve-from": "^5.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + } + } + }, + "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 + }, + "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==" + }, + "resolve-url-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-4.0.0.tgz", + "integrity": "sha512-05VEMczVREcbtT7Bz+C+96eUO5HDNvdthIiMB34t7FcF8ehcu4wC0sSgPUubs3XW2Q3CNLJk/BJrCU9wVRymiA==", + "dev": true, + "requires": { + "adjust-sourcemap-loader": "^4.0.0", + "convert-source-map": "^1.7.0", + "loader-utils": "^2.0.0", + "postcss": "^7.0.35", + "source-map": "0.6.1" + }, + "dependencies": { + "picocolors": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz", + "integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==", + "dev": true + }, + "postcss": { + "version": "7.0.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz", + "integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==", + "dev": true, + "requires": { + "picocolors": "^0.2.1", + "source-map": "^0.6.1" + } + } + } + }, + "resolve.exports": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.0.tgz", + "integrity": "sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==", + "dev": true + }, + "responselike": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", + "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", + "dev": true, + "requires": { + "lowercase-keys": "^1.0.0" + } + }, + "restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "requires": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + } + }, + "retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "rollup": { + "version": "2.79.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", + "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "dev": true, + "requires": { + "fsevents": "~2.3.2" + } + }, + "rollup-plugin-terser": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz", + "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.10.4", + "jest-worker": "^26.2.1", + "serialize-javascript": "^4.0.0", + "terser": "^5.0.0" + }, + "dependencies": { + "serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + } + } + }, + "run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "dev": true + }, + "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, + "requires": { + "queue-microtask": "^1.2.2" + } + }, + "rxjs": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", + "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "dev": true, + "requires": { + "tslib": "^1.9.0" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "safe-regex-test": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", + "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "requires": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "is-regex": "^1.1.4" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "sanitize.css": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/sanitize.css/-/sanitize.css-13.0.0.tgz", + "integrity": "sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==", + "dev": true + }, + "sass-loader": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", + "integrity": "sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==", + "dev": true, + "requires": { + "klona": "^2.0.4", + "neo-async": "^2.6.2" + } + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true + }, + "saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "dev": true, + "requires": { + "xmlchars": "^2.2.0" + } + }, + "scheduler": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", + "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + } + }, + "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==", + "optional": true + }, + "select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true + }, + "selfsigned": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.1.1.tgz", + "integrity": "sha512-GSL3aowiF7wa/WtSFwnUrludWFoNhftq8bUkH9pkzjpN2XSPOAYEgg6e0sS9s0rZwgJzJiQRPU18A6clnoW5wQ==", + "dev": true, + "requires": { + "node-forge": "^1" + } + }, + "semver": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", + "integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "semver-diff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz", + "integrity": "sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==", + "dev": true, + "requires": { + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dev": true, + "requires": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + } + } + }, + "serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "dev": true, + "requires": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true + }, + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dev": true, + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true + } + } + }, + "serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dev": true, + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + } + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, + "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==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "shell-quote": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.7.3.tgz", + "integrity": "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw==", + "dev": true + }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, + "signal-exit": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", + "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", + "dev": true + }, + "sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, + "sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "requires": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "sortablejs": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.0.tgz", + "integrity": "sha512-bv9qgVMjUMf89wAvM6AxVvS/4MX3sPeN0+agqShejLU5z5GX4C75ow1O2e5k4L6XItUyAK3gH6AxSbXrOM5e8w==" + }, + "source-list-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true + }, + "source-map-loader": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-3.0.1.tgz", + "integrity": "sha512-Vp1UsfyPvgujKQzi4pyDiTOnE3E4H+yHvkVRN3c/9PJmQS4CQJExvcDvaX/D+RV+xQben9HJ56jMJS3CgUeWyA==", + "dev": true, + "requires": { + "abab": "^2.0.5", + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.1" + }, + "dependencies": { + "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, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, + "source-map-support": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.20.tgz", + "integrity": "sha512-n1lZZ8Ve4ksRqizaBQgxXDgKwttHDhyfQjA6YZZn8+AroHbsIz+JjwxQDxbp+7y5OYCI8t1Yk7etjD9CRd2hIw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "dev": true + }, + "spdx-correct": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", + "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "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==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.9.tgz", + "integrity": "sha512-Ki212dKK4ogX+xDo4CtOZBVIwhsKBEfsEEcwmJfLQzirgc2jIWdzg40Unxz/HzEUqM1WFzVlQSMF9kZZ2HboLQ==", + "dev": true + }, + "spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + } + }, + "spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "dev": true + }, + "stack-utils": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz", + "integrity": "sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA==", + "dev": true, + "requires": { + "escape-string-regexp": "^2.0.0" + }, + "dependencies": { + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true + } + } + }, + "stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "dev": true + }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true + }, + "strict-uri-encode": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", + "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=" + }, + "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==", + "dev": true, + "requires": { + "safe-buffer": "~5.2.0" + }, + "dependencies": { + "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==", + "dev": true + } + } + }, + "string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "requires": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + } + }, + "string-natural-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz", + "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==", + "dev": true + }, + "string-width": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz", + "integrity": "sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "string.prototype.matchall": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz", + "integrity": "sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg==", + "dev": true, + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.19.1", + "get-intrinsic": "^1.1.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.3", + "regexp.prototype.flags": "^1.4.1", + "side-channel": "^1.0.4" + } + }, + "string.prototype.trim": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.4.tgz", + "integrity": "sha512-hWCk/iqf7lp0/AgTF7/ddO1IWtSNPASjlzCicV5irAVdE1grjsneK26YG6xACMBEdCvO8fUST0UzDMh/2Qy+9Q==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.3", + "es-abstract": "^1.18.0-next.2" + } + }, + "string.prototype.trimend": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.5.tgz", + "integrity": "sha512-I7RGvmjV4pJ7O3kdf+LXFpVfdNOxtCW/2C8f6jNiW4+PQchwxkCDzlk1/7p+Wl4bqFIZeF47qAHXLuHHWKAxog==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.19.5" + } + }, + "string.prototype.trimstart": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.5.tgz", + "integrity": "sha512-THx16TJCGlsN0o6dl2o6ncWUsdgnLRSA23rRE5pyGBw/mLr3Ej/R2LaqCtgP8VNMGZsvMWnf9ooZPyY2bHvUFg==", + "requires": { + "call-bind": "^1.0.2", + "define-properties": "^1.1.4", + "es-abstract": "^1.19.5" + } + }, + "stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "requires": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "dependencies": { + "is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", + "dev": true + } + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true + }, + "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==", + "dev": true + }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true + }, + "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==", + "dev": true, + "requires": { + "min-indent": "^1.0.0" + } + }, + "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 + }, + "style-loader": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.1.tgz", + "integrity": "sha512-GPcQ+LDJbrcxHORTRes6Jy2sfvK2kS6hpSfI/fXhPt+spVzxF6LJ1dHLN9zIGmVaaP044YKaIatFaufENRiDoQ==", + "dev": true, + "requires": {} + }, + "stylehacks": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.0.tgz", + "integrity": "sha512-SzLmvHQTrIWfSgljkQCw2++C9+Ne91d/6Sp92I8c5uHTcy/PgeHamwITIbBW9wnFTY/3ZfSXR9HIL6Ikqmcu6Q==", + "dev": true, + "requires": { + "browserslist": "^4.16.6", + "postcss-selector-parser": "^6.0.4" + } + }, + "supports-color": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, + "requires": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + } + }, + "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==", + "dev": true + }, + "svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true + }, + "svgo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-1.3.2.tgz", + "integrity": "sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw==", + "dev": true, + "requires": { + "chalk": "^2.4.1", + "coa": "^2.0.2", + "css-select": "^2.0.0", + "css-select-base-adapter": "^0.1.1", + "css-tree": "1.0.0-alpha.37", + "csso": "^4.0.2", + "js-yaml": "^3.13.1", + "mkdirp": "~0.5.1", + "object.values": "^1.1.0", + "sax": "~1.2.4", + "stable": "^0.1.8", + "unquote": "~1.1.1", + "util.promisify": "~1.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==" + }, + "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 + }, + "tailwindcss": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.1.8.tgz", + "integrity": "sha512-YSneUCZSFDYMwk+TGq8qYFdCA3yfBRdBlS7txSq0LUmzyeqRe3a8fBQzbz9M3WS/iFT4BNf/nmw9mEzrnSaC0g==", + "dev": true, + "requires": { + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "color-name": "^1.1.4", + "detective": "^5.2.1", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.2.11", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "lilconfig": "^2.0.6", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.14", + "postcss-import": "^14.1.0", + "postcss-js": "^4.0.0", + "postcss-load-config": "^3.1.4", + "postcss-nested": "5.0.6", + "postcss-selector-parser": "^6.0.10", + "postcss-value-parser": "^4.2.0", + "quick-lru": "^5.1.1", + "resolve": "^1.22.1" + }, + "dependencies": { + "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, + "requires": { + "is-glob": "^4.0.3" + } + }, + "quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true + } + } + }, + "tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true + }, + "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==", + "dev": true + }, + "tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dev": true, + "requires": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "dependencies": { + "type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true + } + } + }, + "term-size": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", + "integrity": "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==", + "dev": true + }, + "terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "dev": true, + "requires": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + } + }, + "terser": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.15.0.tgz", + "integrity": "sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA==", + "dev": true, + "requires": { + "@jridgewell/source-map": "^0.3.2", + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "dependencies": { + "acorn": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", + "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", + "dev": true + }, + "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==", + "dev": true + } + } + }, + "terser-webpack-plugin": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.6.tgz", + "integrity": "sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.14", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.0", + "terser": "^5.14.1" + }, + "dependencies": { + "jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + } + }, + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + } + }, + "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 + }, + "throat": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.1.tgz", + "integrity": "sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==", + "dev": true + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true + }, + "tiny-invariant": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", + "integrity": "sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==" + }, + "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==" + }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + }, + "tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + }, + "to-readable-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", + "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==", + "dev": true + }, + "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==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true + }, + "tough-cookie": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", + "integrity": "sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==", + "dev": true, + "requires": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "dependencies": { + "universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true + } + } + }, + "tr46": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", + "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "dev": true, + "requires": { + "punycode": "^2.1.1" + } + }, + "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==", + "dev": true + }, + "tryer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", + "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==", + "dev": true + }, + "tsconfig-paths": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz", + "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==", + "dev": true, + "requires": { + "@types/json5": "^0.0.29", + "json5": "^1.0.1", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "dependencies": { + "json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "requires": { + "minimist": "^1.2.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true + } + } + }, + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + }, + "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, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "requires": { + "is-typedarray": "^1.0.0" + } + }, + "typescript": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", + "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", + "dev": true, + "peer": true + }, + "typescript-compare": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/typescript-compare/-/typescript-compare-0.0.2.tgz", + "integrity": "sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA==", + "requires": { + "typescript-logic": "^0.0.0" + } + }, + "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==" + }, + "typescript-tuple": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/typescript-tuple/-/typescript-tuple-2.2.1.tgz", + "integrity": "sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==", + "requires": { + "typescript-compare": "^0.0.2" + } + }, + "unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "requires": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + } + }, + "unicode-canonical-property-names-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "dev": true + }, + "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==", + "dev": true, + "requires": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + } + }, + "unicode-match-property-value-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz", + "integrity": "sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw==", + "dev": true + }, + "unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true + }, + "unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "requires": { + "crypto-random-string": "^2.0.0" + } + }, + "universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true + }, + "unquote": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha1-j97XMk7G6IoP+LkF58CYzcCG1UQ=", + "dev": true + }, + "upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true + }, + "update-browserslist-db": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.9.tgz", + "integrity": "sha512-/xsqn21EGVdXI3EXSum1Yckj3ZVZugqyOZQ/CxYPBD/R+ko9NSUScf8tFF4dOKY+2pvSSJA/S+5B8s4Zr4kyvg==", + "dev": true, + "requires": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + } + }, + "update-notifier": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-4.1.3.tgz", + "integrity": "sha512-Yld6Z0RyCYGB6ckIjffGOSOmHXj1gMeE7aROz4MG+XMkmixBX4jUngrGXNYz7wPKBmtoD4MnBa2Anu7RSKht/A==", + "dev": true, + "requires": { + "boxen": "^4.2.0", + "chalk": "^3.0.0", + "configstore": "^5.0.1", + "has-yarn": "^2.1.0", + "import-lazy": "^2.1.0", + "is-ci": "^2.0.0", + "is-installed-globally": "^0.3.1", + "is-npm": "^4.0.0", + "is-yarn-global": "^0.3.0", + "latest-version": "^5.0.0", + "pupa": "^2.0.1", + "semver-diff": "^3.1.1", + "xdg-basedir": "^4.0.0" + } + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "requires": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "url-parse-lax": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", + "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", + "dev": true, + "requires": { + "prepend-http": "^2.0.0" + }, + "dependencies": { + "prepend-http": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", + "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", + "dev": true + } + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "util.promisify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.1.tgz", + "integrity": "sha512-g9JpC/3He3bm38zsLupWryXHoEcS22YHthuPQSJdMy6KNrzIRzWqcsHzD/WUnqe45whVou4VIsPew37DoXWNrA==", + "dev": true, + "requires": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.2", + "has-symbols": "^1.0.1", + "object.getownpropertydescriptors": "^2.1.0" + } + }, + "utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", + "dev": true + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, + "v8-to-istanbul": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", + "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^1.6.0", + "source-map": "^0.7.3" + }, + "dependencies": { + "source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true + } + } + }, + "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==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "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==" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true + }, + "w3c-hr-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", + "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", + "dev": true, + "requires": { + "browser-process-hrtime": "^1.0.0" + } + }, + "w3c-xmlserializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", + "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", + "dev": true, + "requires": { + "xml-name-validator": "^3.0.0" + } + }, + "walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "requires": { + "makeerror": "1.0.12" + } + }, + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + }, + "watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "requires": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + } + }, + "wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "requires": { + "minimalistic-assert": "^1.0.0" + } + }, + "wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=", + "dev": true, + "requires": { + "defaults": "^1.0.3" + } + }, + "webidl-conversions": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", + "dev": true + }, + "webpack": { + "version": "5.76.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.1.tgz", + "integrity": "sha512-4+YIK4Abzv8172/SGqObnUjaIHjLEuUasz9EwQj/9xmPPkYJy2Mh03Q/lJfSD3YLzbxy5FeTq5Uw0323Oh6SJQ==", + "dev": true, + "requires": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^0.0.51", + "@webassemblyjs/ast": "1.11.1", + "@webassemblyjs/wasm-edit": "1.11.1", + "@webassemblyjs/wasm-parser": "1.11.1", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.7.6", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.10.0", + "es-module-lexer": "^0.9.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.1.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.1.3", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "dependencies": { + "acorn": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", + "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", + "dev": true + }, + "acorn-import-assertions": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", + "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", + "dev": true, + "requires": {} + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "schema-utils": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", + "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "webpack-dev-middleware": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", + "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "dev": true, + "requires": { + "colorette": "^2.0.10", + "memfs": "^3.4.3", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "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==", + "dev": true + }, + "schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + } + } + } + }, + "webpack-dev-server": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.11.1.tgz", + "integrity": "sha512-lILVz9tAUy1zGFwieuaQtYiadImb5M3d+H+L1zDYalYoDl0cksAB1UNyuE5MMWJrG6zR1tXkCP2fitl7yoUJiw==", + "dev": true, + "requires": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/serve-static": "^1.13.10", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.1", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.1.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.1", + "ws": "^8.4.2" + }, + "dependencies": { + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "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==", + "dev": true + }, + "schema-utils": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.0.0.tgz", + "integrity": "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.8.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.0.0" + } + }, + "ws": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.9.0.tgz", + "integrity": "sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg==", + "dev": true, + "requires": {} + } + } + }, + "webpack-manifest-plugin": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-4.1.1.tgz", + "integrity": "sha512-YXUAwxtfKIJIKkhg03MKuiFAD72PlrqCiwdwO4VEXdRO5V0ORCNwaOwAZawPZalCbmH9kBDmXnNeQOw+BIEiow==", + "dev": true, + "requires": { + "tapable": "^2.0.0", + "webpack-sources": "^2.2.0" + }, + "dependencies": { + "webpack-sources": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.3.1.tgz", + "integrity": "sha512-y9EI9AO42JjEcrTJFOYmVywVZdKVUfOvDUPsJea5GIr1JOEGFVqwlY2K098fFoIjOkDzHn2AjRvM8dsBZu+gCA==", + "dev": true, + "requires": { + "source-list-map": "^2.0.1", + "source-map": "^0.6.1" + } + } + } + }, + "webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true + }, + "websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, + "requires": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + } + }, + "websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true + }, + "whatwg-encoding": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", + "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "dev": true, + "requires": { + "iconv-lite": "0.4.24" + } + }, + "whatwg-fetch": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz", + "integrity": "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==", + "dev": true + }, + "whatwg-mimetype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", + "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", + "dev": true + }, + "whatwg-url": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "dev": true, + "requires": { + "lodash": "^4.7.0", + "tr46": "^2.1.0", + "webidl-conversions": "^6.1.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "requires": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + } + }, + "widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "dev": true, + "requires": { + "string-width": "^4.0.0" + } + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true + }, + "workbox-background-sync": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.5.4.tgz", + "integrity": "sha512-0r4INQZMyPky/lj4Ou98qxcThrETucOde+7mRGJl13MPJugQNKeZQOdIJe/1AchOP23cTqHcN/YVpD6r8E6I8g==", + "dev": true, + "requires": { + "idb": "^7.0.1", + "workbox-core": "6.5.4" + }, + "dependencies": { + "idb": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.0.tgz", + "integrity": "sha512-Wsk07aAxDsntgYJY4h0knZJuTxM73eQ4reRAO+Z1liOh8eMCJ/MoDS8fCui1vGT9mnjtl1sOu3I2i/W1swPYZg==", + "dev": true + } + } + }, + "workbox-broadcast-update": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-6.5.4.tgz", + "integrity": "sha512-I/lBERoH1u3zyBosnpPEtcAVe5lwykx9Yg1k6f8/BGEPGaMMgZrwVrqL1uA9QZ1NGGFoyE6t9i7lBjOlDhFEEw==", + "dev": true, + "requires": { + "workbox-core": "6.5.4" + } + }, + "workbox-build": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-6.5.4.tgz", + "integrity": "sha512-kgRevLXEYvUW9WS4XoziYqZ8Q9j/2ziJYEtTrjdz5/L/cTUa2XfyMP2i7c3p34lgqJ03+mTiz13SdFef2POwbA==", + "dev": true, + "requires": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.11.1", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^5.2.0", + "@rollup/plugin-node-resolve": "^11.2.1", + "@rollup/plugin-replace": "^2.4.1", + "@surma/rollup-plugin-off-main-thread": "^2.2.3", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^7.1.6", + "lodash": "^4.17.20", + "pretty-bytes": "^5.3.0", + "rollup": "^2.43.1", + "rollup-plugin-terser": "^7.0.0", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "6.5.4", + "workbox-broadcast-update": "6.5.4", + "workbox-cacheable-response": "6.5.4", + "workbox-core": "6.5.4", + "workbox-expiration": "6.5.4", + "workbox-google-analytics": "6.5.4", + "workbox-navigation-preload": "6.5.4", + "workbox-precaching": "6.5.4", + "workbox-range-requests": "6.5.4", + "workbox-recipes": "6.5.4", + "workbox-routing": "6.5.4", + "workbox-strategies": "6.5.4", + "workbox-streams": "6.5.4", + "workbox-sw": "6.5.4", + "workbox-window": "6.5.4" + }, + "dependencies": { + "@apideck/better-ajv-errors": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", + "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", + "dev": true, + "requires": { + "json-schema": "^0.4.0", + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + } + }, + "@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==", + "dev": true, + "requires": { + "ejs": "^3.1.6", + "json5": "^2.2.0", + "magic-string": "^0.25.0", + "string.prototype.matchall": "^4.0.6" + } + }, + "ajv": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz", + "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ejs": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.8.tgz", + "integrity": "sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ==", + "dev": true, + "requires": { + "jake": "^10.8.5" + } + }, + "json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true + }, + "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==", + "dev": true + }, + "jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true + }, + "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==", + "dev": true, + "requires": { + "whatwg-url": "^7.0.0" + } + }, + "tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "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==", + "dev": true + }, + "whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "requires": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "workbox-recipes": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-6.5.4.tgz", + "integrity": "sha512-QZNO8Ez708NNwzLNEXTG4QYSKQ1ochzEtRLGaq+mr2PyoEIC1xFW7MrWxrONUxBFOByksds9Z4//lKAX8tHyUA==", + "dev": true, + "requires": { + "workbox-cacheable-response": "6.5.4", + "workbox-core": "6.5.4", + "workbox-expiration": "6.5.4", + "workbox-precaching": "6.5.4", + "workbox-routing": "6.5.4", + "workbox-strategies": "6.5.4" + } + } + } + }, + "workbox-cacheable-response": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-6.5.4.tgz", + "integrity": "sha512-DCR9uD0Fqj8oB2TSWQEm1hbFs/85hXXoayVwFKLVuIuxwJaihBsLsp4y7J9bvZbqtPJ1KlCkmYVGQKrBU4KAug==", + "dev": true, + "requires": { + "workbox-core": "6.5.4" + } + }, + "workbox-cli": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-cli/-/workbox-cli-6.5.4.tgz", + "integrity": "sha512-+Cc0jYh25MofhCROZqfQkpYSAGvykyrUVekuuPaLFbJ8qxX/zzX8hRRpglfwxDwokAjz8S20oEph4s+MyQc+Yw==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "chokidar": "^3.5.2", + "common-tags": "^1.8.0", + "fs-extra": "^9.0.1", + "glob": "^7.1.6", + "inquirer": "^7.3.3", + "meow": "^7.1.0", + "ora": "^5.0.0", + "pretty-bytes": "^5.3.0", + "stringify-object": "^3.3.0", + "upath": "^1.2.0", + "update-notifier": "^4.1.0", + "workbox-build": "6.5.4" + }, + "dependencies": { + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + } + } + }, + "workbox-core": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-6.5.4.tgz", + "integrity": "sha512-OXYb+m9wZm8GrORlV2vBbE5EC1FKu71GGp0H4rjmxmF4/HLbMCoTFws87M3dFwgpmg0v00K++PImpNQ6J5NQ6Q==", + "dev": true + }, + "workbox-expiration": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-6.5.4.tgz", + "integrity": "sha512-jUP5qPOpH1nXtjGGh1fRBa1wJL2QlIb5mGpct3NzepjGG2uFFBn4iiEBiI9GUmfAFR2ApuRhDydjcRmYXddiEQ==", + "dev": true, + "requires": { + "idb": "^7.0.1", + "workbox-core": "6.5.4" + }, + "dependencies": { + "idb": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.0.tgz", + "integrity": "sha512-Wsk07aAxDsntgYJY4h0knZJuTxM73eQ4reRAO+Z1liOh8eMCJ/MoDS8fCui1vGT9mnjtl1sOu3I2i/W1swPYZg==", + "dev": true + } + } + }, + "workbox-google-analytics": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-6.5.4.tgz", + "integrity": "sha512-8AU1WuaXsD49249Wq0B2zn4a/vvFfHkpcFfqAFHNHwln3jK9QUYmzdkKXGIZl9wyKNP+RRX30vcgcyWMcZ9VAg==", + "dev": true, + "requires": { + "workbox-background-sync": "6.5.4", + "workbox-core": "6.5.4", + "workbox-routing": "6.5.4", + "workbox-strategies": "6.5.4" + } + }, + "workbox-navigation-preload": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-6.5.4.tgz", + "integrity": "sha512-IIwf80eO3cr8h6XSQJF+Hxj26rg2RPFVUmJLUlM0+A2GzB4HFbQyKkrgD5y2d84g2IbJzP4B4j5dPBRzamHrng==", + "dev": true, + "requires": { + "workbox-core": "6.5.4" + } + }, + "workbox-precaching": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-6.5.4.tgz", + "integrity": "sha512-hSMezMsW6btKnxHB4bFy2Qfwey/8SYdGWvVIKFaUm8vJ4E53JAY+U2JwLTRD8wbLWoP6OVUdFlXsTdKu9yoLTg==", + "dev": true, + "requires": { + "workbox-core": "6.5.4", + "workbox-routing": "6.5.4", + "workbox-strategies": "6.5.4" + } + }, + "workbox-range-requests": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-6.5.4.tgz", + "integrity": "sha512-Je2qR1NXCFC8xVJ/Lux6saH6IrQGhMpDrPXWZWWS8n/RD+WZfKa6dSZwU+/QksfEadJEr/NfY+aP/CXFFK5JFg==", + "dev": true, + "requires": { + "workbox-core": "6.5.4" + } + }, + "workbox-routing": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-6.5.4.tgz", + "integrity": "sha512-apQswLsbrrOsBUWtr9Lf80F+P1sHnQdYodRo32SjiByYi36IDyL2r7BH1lJtFX8fwNHDa1QOVY74WKLLS6o5Pg==", + "dev": true, + "requires": { + "workbox-core": "6.5.4" + } + }, + "workbox-strategies": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-6.5.4.tgz", + "integrity": "sha512-DEtsxhx0LIYWkJBTQolRxG4EI0setTJkqR4m7r4YpBdxtWJH1Mbg01Cj8ZjNOO8etqfA3IZaOPHUxCs8cBsKLw==", + "dev": true, + "requires": { + "workbox-core": "6.5.4" + } + }, + "workbox-streams": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-6.5.4.tgz", + "integrity": "sha512-FXKVh87d2RFXkliAIheBojBELIPnWbQdyDvsH3t74Cwhg0fDheL1T8BqSM86hZvC0ZESLsznSYWw+Va+KVbUzg==", + "dev": true, + "requires": { + "workbox-core": "6.5.4", + "workbox-routing": "6.5.4" + } + }, + "workbox-sw": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-6.5.4.tgz", + "integrity": "sha512-vo2RQo7DILVRoH5LjGqw3nphavEjK4Qk+FenXeUsknKn14eCNedHOXWbmnvP4ipKhlE35pvJ4yl4YYf6YsJArA==", + "dev": true + }, + "workbox-webpack-plugin": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-webpack-plugin/-/workbox-webpack-plugin-6.5.4.tgz", + "integrity": "sha512-LmWm/zoaahe0EGmMTrSLUi+BjyR3cdGEfU3fS6PN1zKFYbqAKuQ+Oy/27e4VSXsyIwAw8+QDfk1XHNGtZu9nQg==", + "dev": true, + "requires": { + "fast-json-stable-stringify": "^2.1.0", + "pretty-bytes": "^5.4.1", + "upath": "^1.2.0", + "webpack-sources": "^1.4.3", + "workbox-build": "6.5.4" + }, + "dependencies": { + "webpack-sources": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", + "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", + "dev": true, + "requires": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + } + } + } + }, + "workbox-window": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-6.5.4.tgz", + "integrity": "sha512-HnLZJDwYBE+hpG25AQBO8RUWBJRaCsI9ksQJEp3aCOFCaG5kqaToAYXFRAHxzRluM2cQbGzdQF5rjKPWPA1fug==", + "dev": true, + "requires": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "6.5.4" + } + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "dev": true, + "requires": {} + }, + "xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "dev": true + }, + "xml-name-validator": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", + "dev": true + }, + "xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, + "xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "dependencies": { + "yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true + } + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + } + } + }, + "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 + } } } diff --git a/ui/package.json b/ui/package.json index b02012104..49e9abf73 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,89 +1,91 @@ { - "name": "ui", + "name": "navidrome-ui", + "version": "0.1.0", "private": true, - "type": "module", - "scripts": { - "start": "vite", - "build": "vite build", - "serve": "vite preview", - "test:watch": "vitest", - "test": "vitest --watch=false", - "test:coverage": "vitest run --coverage --watch=false", - "type-check": "tsc --noEmit", - "lint": "eslint . --ext ts,tsx,js,jsx --report-unused-disable-directives --max-warnings 0", - "prettier": "prettier --write ./src", - "check-formatting": "prettier -c ./src", - "postinstall": "bin/update-workbox.sh" - }, "dependencies": { - "@jsonforms/core": "^2.5.2", - "@jsonforms/material-renderers": "^2.5.2", - "@jsonforms/react": "^2.5.2", - "@material-ui/core": "^4.12.4", - "@material-ui/icons": "^4.11.3", - "@material-ui/lab": "^4.0.0-alpha.61", + "@material-ui/core": "^4.11.4", + "@material-ui/icons": "^4.11.2", + "@material-ui/lab": "^4.0.0-alpha.58", "@material-ui/styles": "^4.11.5", "blueimp-md5": "^2.19.0", - "clsx": "^2.1.1", - "connected-react-router": "^6.9.3", - "deepmerge": "^4.3.1", - "dompurify": "^3.3.2", + "clsx": "^1.1.1", + "connected-react-router": "^6.9.1", + "deepmerge": "^4.2.2", "history": "^4.10.1", - "inflection": "^3.0.2", - "jwt-decode": "^4.0.0", + "inflection": "^1.13.1", + "jwt-decode": "^3.1.2", + "lodash.pick": "^4.4.0", "lodash.throttle": "^4.1.1", - "navidrome-music-player": "4.25.4", - "prop-types": "^15.8.1", + "navidrome-music-player": "4.25.1", + "prop-types": "^15.7.2", "ra-data-json-server": "^3.19.12", "ra-i18n-polyglot": "^3.19.12", "react": "^17.0.2", "react-admin": "^3.19.12", - "react-dnd": "^14.0.5", - "react-dnd-html5-backend": "^14.1.0", + "react-dnd": "^14.0.4", + "react-dnd-html5-backend": "^14.0.2", "react-dom": "^17.0.2", - "react-drag-listview": "^0.1.9", + "react-drag-listview": "^0.1.8", "react-ga": "^3.3.1", "react-hotkeys": "^2.0.0", - "react-icons": "^5.5.0", + "react-icons": "^4.4.0", "react-image-lightbox": "^5.1.4", "react-measure": "^2.5.2", "react-redux": "^7.2.9", - "react-router-dom": "^5.3.4", - "redux": "^4.2.1", - "redux-saga": "^1.4.2", - "uuid": "^13.0.0", - "workbox-cli": "^7.3.0" + "react-router-dom": "^5.3.0", + "redux": "^4.2.0", + "redux-saga": "^1.1.3", + "uuid": "^8.3.2" }, "devDependencies": { - "@testing-library/jest-dom": "^6.6.3", + "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^7.0.2", - "@testing-library/user-event": "^14.6.1", - "@types/node": "^24.9.1", - "@types/react": "^17.0.89", - "@types/react-dom": "^17.0.26", - "@typescript-eslint/eslint-plugin": "^6.21.0", - "@typescript-eslint/parser": "^6.21.0", - "@vitejs/plugin-react": "^5.1.0", - "@vitest/coverage-v8": "^4.0.3", - "eslint": "^8.57.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.24", - "happy-dom": "^20.0.8", - "jsdom": "^26.1.0", - "prettier": "^3.6.2", + "@testing-library/user-event": "^13.5.0", + "css-mediaquery": "^0.1.2", + "prettier": "2.8.2", "ra-test": "^3.19.12", - "typescript": "^5.8.3", - "vite": "^7.3.2", - "vite-plugin-pwa": "^1.1.0", - "vitest": "^4.0.3" + "react-scripts": "5.0.1", + "workbox-cli": "^6.3.0" }, - "overrides": { - "vite": { - "rollup": "npm:@rollup/wasm-node" - } + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "lint": "eslint --max-warnings 0 src/*.js src/**/*.js", + "prettier": "prettier --write src/*.js src/**/*.js", + "check-formatting": "prettier -c src/*.js src/**/*.js", + "update-workbox": "bin/update-workbox.sh" + }, + "homepage": ".", + "proxy": "http://127.0.0.1:4633/", + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ], + "overrides": [ + { + "files": [ + "src/**/index.js", + "src/themes/*.js" + ], + "rules": { + "import/no-anonymous-default-export": "off" + } + } + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] } } diff --git a/ui/prettier.config.js b/ui/prettier.config.js deleted file mode 100644 index 723744a84..000000000 --- a/ui/prettier.config.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - singleQuote: true, - semi: false, - arrowParens: "always", -}; diff --git a/ui/public/3rdparty/workbox/workbox-core.prod.js b/ui/public/3rdparty/workbox/workbox-core.prod.js new file mode 100644 index 000000000..9c7c1f913 --- /dev/null +++ b/ui/public/3rdparty/workbox/workbox-core.prod.js @@ -0,0 +1,2 @@ +this.workbox=this.workbox||{},this.workbox.core=function(t){"use strict";try{self["workbox:core:6.2.4"]&&_()}catch(t){}const e=(t,...e)=>{let n=t;return e.length>0&&(n+=" :: "+JSON.stringify(e)),n};class n extends Error{constructor(t,n){super(e(t,n)),this.name=t,this.details=n}}const r=new Set;const o={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},s=t=>[o.prefix,t,o.suffix].filter((t=>t&&t.length>0)).join("-"),i={updateDetails:t=>{(t=>{for(const e of Object.keys(o))t(e)})((e=>{"string"==typeof t[e]&&(o[e]=t[e])}))},getGoogleAnalyticsName:t=>t||s(o.googleAnalytics),getPrecacheName:t=>t||s(o.precache),getPrefix:()=>o.prefix,getRuntimeName:t=>t||s(o.runtime),getSuffix:()=>o.suffix};function c(t,e){const n=new URL(t);for(const t of e)n.searchParams.delete(t);return n.href}let a,u;function f(){if(void 0===u){const t=new Response("");if("body"in t)try{new Response(t.body),u=!0}catch(t){u=!1}u=!1}return u}function l(t){return new Promise((e=>setTimeout(e,t)))}var g=Object.freeze({__proto__:null,assert:null,cacheMatchIgnoreParams:async function(t,e,n,r){const o=c(e.url,n);if(e.url===o)return t.match(e,r);const s=Object.assign(Object.assign({},r),{ignoreSearch:!0}),i=await t.keys(e,s);for(const e of i){if(o===c(e.url,n))return t.match(e,r)}},cacheNames:i,canConstructReadableStream:function(){if(void 0===a)try{new ReadableStream({start(){}}),a=!0}catch(t){a=!1}return a},canConstructResponseFromBodyStream:f,dontWaitFor:function(t){t.then((()=>{}))},Deferred:class{constructor(){this.promise=new Promise(((t,e)=>{this.resolve=t,this.reject=e}))}},executeQuotaErrorCallbacks:async function(){for(const t of r)await t()},getFriendlyURL:t=>new URL(String(t),location.href).href.replace(new RegExp("^"+location.origin),""),logger:null,resultingClientExists:async function(t){if(!t)return;let e=await self.clients.matchAll({type:"window"});const n=new Set(e.map((t=>t.id)));let r;const o=performance.now();for(;performance.now()-o<2e3&&(e=await self.clients.matchAll({type:"window"}),r=e.find((e=>t?e.id===t:!n.has(e.id))),!r);)await l(100);return r},timeout:l,waitUntil:function(t,e){const n=e();return t.waitUntil(n),n},WorkboxError:n});const w={get googleAnalytics(){return i.getGoogleAnalyticsName()},get precache(){return i.getPrecacheName()},get prefix(){return i.getPrefix()},get runtime(){return i.getRuntimeName()},get suffix(){return i.getSuffix()}};return t._private=g,t.cacheNames=w,t.clientsClaim=function(){self.addEventListener("activate",(()=>self.clients.claim()))},t.copyResponse=async function(t,e){let r=null;if(t.url){r=new URL(t.url).origin}if(r!==self.location.origin)throw new n("cross-origin-copy-response",{origin:r});const o=t.clone(),s={headers:new Headers(o.headers),status:o.status,statusText:o.statusText},i=e?e(s):s,c=f()?o.body:await o.blob();return new Response(c,i)},t.registerQuotaErrorCallback=function(t){r.add(t)},t.setCacheNameDetails=function(t){i.updateDetails(t)},t.skipWaiting=function(){self.skipWaiting()},t}({}); +//# sourceMappingURL=workbox-core.prod.js.map diff --git a/ui/public/3rdparty/workbox/workbox-navigation-preload.prod.js b/ui/public/3rdparty/workbox/workbox-navigation-preload.prod.js new file mode 100644 index 000000000..2876ce64d --- /dev/null +++ b/ui/public/3rdparty/workbox/workbox-navigation-preload.prod.js @@ -0,0 +1,2 @@ +this.workbox=this.workbox||{},this.workbox.navigationPreload=function(t){"use strict";try{self["workbox:navigation-preload:6.2.4"]&&_()}catch(t){}function e(){return Boolean(self.registration&&self.registration.navigationPreload)}return t.disable=function(){e()&&self.addEventListener("activate",(t=>{t.waitUntil(self.registration.navigationPreload.disable().then((()=>{})))}))},t.enable=function(t){e()&&self.addEventListener("activate",(e=>{e.waitUntil(self.registration.navigationPreload.enable().then((()=>{t&&self.registration.navigationPreload.setHeaderValue(t)})))}))},t.isSupported=e,t}({}); +//# sourceMappingURL=workbox-navigation-preload.prod.js.map diff --git a/ui/public/3rdparty/workbox/workbox-routing.prod.js b/ui/public/3rdparty/workbox/workbox-routing.prod.js new file mode 100644 index 000000000..1f0e3e743 --- /dev/null +++ b/ui/public/3rdparty/workbox/workbox-routing.prod.js @@ -0,0 +1,2 @@ +this.workbox=this.workbox||{},this.workbox.routing=function(t,e){"use strict";try{self["workbox:routing:6.2.4"]&&_()}catch(t){}const s=t=>t&&"object"==typeof t?t:{handle:t};class r{constructor(t,e,r="GET"){this.handler=s(e),this.match=t,this.method=r}setCatchHandler(t){this.catchHandler=s(t)}}class n extends r{constructor(t,e,s){super((({url:e})=>{const s=t.exec(e.href);if(s&&(e.origin===location.origin||0===s.index))return s.slice(1)}),e,s)}}class i{constructor(){this.ut=new Map,this.ft=new Map}get routes(){return this.ut}addFetchListener(){self.addEventListener("fetch",(t=>{const{request:e}=t,s=this.handleRequest({request:e,event:t});s&&t.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,s=Promise.all(e.urlsToCache.map((e=>{"string"==typeof e&&(e=[e]);const s=new Request(...e);return this.handleRequest({request:s,event:t})})));t.waitUntil(s),t.ports&&t.ports[0]&&s.then((()=>t.ports[0].postMessage(!0)))}}))}handleRequest({request:t,event:e}){const s=new URL(t.url,location.href);if(!s.protocol.startsWith("http"))return;const r=s.origin===location.origin,{params:n,route:i}=this.findMatchingRoute({event:e,request:t,sameOrigin:r,url:s});let o=i&&i.handler;const u=t.method;if(!o&&this.ft.has(u)&&(o=this.ft.get(u)),!o)return;let c;try{c=o.handle({url:s,request:t,event:e,params:n})}catch(t){c=Promise.reject(t)}const a=i&&i.catchHandler;return c instanceof Promise&&(this.lt||a)&&(c=c.catch((async r=>{if(a)try{return await a.handle({url:s,request:t,event:e,params:n})}catch(t){t instanceof Error&&(r=t)}if(this.lt)return this.lt.handle({url:s,request:t,event:e});throw r}))),c}findMatchingRoute({url:t,sameOrigin:e,request:s,event:r}){const n=this.ut.get(s.method)||[];for(const i of n){let n;const o=i.match({url:t,sameOrigin:e,request:s,event:r});if(o)return n=o,(Array.isArray(n)&&0===n.length||o.constructor===Object&&0===Object.keys(o).length||"boolean"==typeof o)&&(n=void 0),{route:i,params:n}}return{}}setDefaultHandler(t,e="GET"){this.ft.set(e,s(t))}setCatchHandler(t){this.lt=s(t)}registerRoute(t){this.ut.has(t.method)||this.ut.set(t.method,[]),this.ut.get(t.method).push(t)}unregisterRoute(t){if(!this.ut.has(t.method))throw new e.WorkboxError("unregister-route-but-not-found-with-method",{method:t.method});const s=this.ut.get(t.method).indexOf(t);if(!(s>-1))throw new e.WorkboxError("unregister-route-route-not-registered");this.ut.get(t.method).splice(s,1)}}let o;const u=()=>(o||(o=new i,o.addFetchListener(),o.addCacheListener()),o);return t.NavigationRoute=class extends r{constructor(t,{allowlist:e=[/./],denylist:s=[]}={}){super((t=>this.dt(t)),t),this.wt=e,this.gt=s}dt({url:t,request:e}){if(e&&"navigate"!==e.mode)return!1;const s=t.pathname+t.search;for(const t of this.gt)if(t.test(s))return!1;return!!this.wt.some((t=>t.test(s)))}},t.RegExpRoute=n,t.Route=r,t.Router=i,t.registerRoute=function(t,s,i){let o;if("string"==typeof t){const e=new URL(t,location.href);o=new r((({url:t})=>t.href===e.href),s,i)}else if(t instanceof RegExp)o=new n(t,s,i);else if("function"==typeof t)o=new r(t,s,i);else{if(!(t instanceof r))throw new e.WorkboxError("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});o=t}return u().registerRoute(o),o},t.setCatchHandler=function(t){u().setCatchHandler(t)},t.setDefaultHandler=function(t){u().setDefaultHandler(t)},t}({},workbox.core._private); +//# sourceMappingURL=workbox-routing.prod.js.map diff --git a/ui/public/3rdparty/workbox/workbox-strategies.prod.js b/ui/public/3rdparty/workbox/workbox-strategies.prod.js new file mode 100644 index 000000000..4116f278d --- /dev/null +++ b/ui/public/3rdparty/workbox/workbox-strategies.prod.js @@ -0,0 +1,2 @@ +this.workbox=this.workbox||{},this.workbox.strategies=function(t,e,s,r,n,i,a,o,c){"use strict";try{self["workbox:strategies:6.2.4"]&&_()}catch(t){}function h(t){return"string"==typeof t?new Request(t):t}class l{constructor(t,e){this.yt={},Object.assign(this,e),this.event=e.event,this.ot=t,this.vt=new n.Deferred,this.qt=[],this.bt=[...t.plugins],this.Et=new Map;for(const t of this.bt)this.Et.set(t,{});this.event.waitUntil(this.vt.promise)}async fetch(t){const{event:s}=this;let r=h(t);if("navigate"===r.mode&&s instanceof FetchEvent&&s.preloadResponse){const t=await s.preloadResponse;if(t)return t}const n=this.hasCallback("fetchDidFail")?r.clone():null;try{for(const t of this.iterateCallbacks("requestWillFetch"))r=await t({request:r.clone(),event:s})}catch(t){if(t instanceof Error)throw new e.WorkboxError("plugin-error-request-will-fetch",{thrownErrorMessage:t.message})}const i=r.clone();try{let t;t=await fetch(r,"navigate"===r.mode?void 0:this.ot.fetchOptions);for(const e of this.iterateCallbacks("fetchDidSucceed"))t=await e({event:s,request:i,response:t});return t}catch(t){throw n&&await this.runCallbacks("fetchDidFail",{error:t,event:s,originalRequest:n.clone(),request:i.clone()}),t}}async fetchAndCachePut(t){const e=await this.fetch(t),s=e.clone();return this.waitUntil(this.cachePut(t,s)),e}async cacheMatch(t){const e=h(t);let s;const{cacheName:r,matchOptions:n}=this.ot,i=await this.getCacheKey(e,"read"),a=Object.assign(Object.assign({},n),{cacheName:r});s=await caches.match(i,a);for(const t of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await t({cacheName:r,matchOptions:n,cachedResponse:s,request:i,event:this.event})||void 0;return s}async cachePut(t,s){const n=h(t);await c.timeout(0);const o=await this.getCacheKey(n,"write");if(!s)throw new e.WorkboxError("cache-put-with-no-response",{url:a.getFriendlyURL(o.url)});const l=await this._t(s);if(!l)return!1;const{cacheName:w,matchOptions:u}=this.ot,f=await self.caches.open(w),d=this.hasCallback("cacheDidUpdate"),p=d?await r.cacheMatchIgnoreParams(f,o.clone(),["__WB_REVISION__"],u):null;try{await f.put(o,d?l.clone():l)}catch(t){if(t instanceof Error)throw"QuotaExceededError"===t.name&&await i.executeQuotaErrorCallbacks(),t}for(const t of this.iterateCallbacks("cacheDidUpdate"))await t({cacheName:w,oldResponse:p,newResponse:l.clone(),request:o,event:this.event});return!0}async getCacheKey(t,e){if(!this.yt[e]){let s=t;for(const t of this.iterateCallbacks("cacheKeyWillBeUsed"))s=h(await t({mode:e,request:s,event:this.event,params:this.params}));this.yt[e]=s}return this.yt[e]}hasCallback(t){for(const e of this.ot.plugins)if(t in e)return!0;return!1}async runCallbacks(t,e){for(const s of this.iterateCallbacks(t))await s(e)}*iterateCallbacks(t){for(const e of this.ot.plugins)if("function"==typeof e[t]){const s=this.Et.get(e),r=r=>{const n=Object.assign(Object.assign({},r),{state:s});return e[t](n)};yield r}}waitUntil(t){return this.qt.push(t),t}async doneWaiting(){let t;for(;t=this.qt.shift();)await t}destroy(){this.vt.resolve(null)}async _t(t){let e=t,s=!1;for(const t of this.iterateCallbacks("cacheWillUpdate"))if(e=await t({request:this.request,response:e,event:this.event})||void 0,s=!0,!e)break;return s||e&&200!==e.status&&(e=void 0),e}}class w{constructor(t={}){this.cacheName=s.cacheNames.getRuntimeName(t.cacheName),this.plugins=t.plugins||[],this.fetchOptions=t.fetchOptions,this.matchOptions=t.matchOptions}handle(t){const[e]=this.handleAll(t);return e}handleAll(t){t instanceof FetchEvent&&(t={event:t,request:t.request});const e=t.event,s="string"==typeof t.request?new Request(t.request):t.request,r="params"in t?t.params:void 0,n=new l(this,{event:e,request:s,params:r}),i=this.kt(n,s,e);return[i,this.xt(i,n,s,e)]}async kt(t,s,r){await t.runCallbacks("handlerWillStart",{event:r,request:s});let n=void 0;try{if(n=await this._handle(s,t),!n||"error"===n.type)throw new e.WorkboxError("no-response",{url:s.url})}catch(e){if(e instanceof Error)for(const i of t.iterateCallbacks("handlerDidError"))if(n=await i({error:e,event:r,request:s}),n)break;if(!n)throw e}for(const e of t.iterateCallbacks("handlerWillRespond"))n=await e({event:r,request:s,response:n});return n}async xt(t,e,s,r){let n,i;try{n=await t}catch(i){}try{await e.runCallbacks("handlerDidRespond",{event:r,request:s,response:n}),await e.doneWaiting()}catch(t){t instanceof Error&&(i=t)}if(await e.runCallbacks("handlerDidComplete",{event:r,request:s,response:n,error:i}),e.destroy(),i)throw i}}const u={cacheWillUpdate:async({response:t})=>200===t.status||0===t.status?t:null};return t.CacheFirst=class extends w{async _handle(t,s){let r=await s.cacheMatch(t),n=void 0;if(!r)try{r=await s.fetchAndCachePut(t)}catch(t){t instanceof Error&&(n=t)}if(!r)throw new e.WorkboxError("no-response",{url:t.url,error:n});return r}},t.CacheOnly=class extends w{async _handle(t,s){const r=await s.cacheMatch(t);if(!r)throw new e.WorkboxError("no-response",{url:t.url});return r}},t.NetworkFirst=class extends w{constructor(t={}){super(t),this.plugins.some((t=>"cacheWillUpdate"in t))||this.plugins.unshift(u),this.Rt=t.networkTimeoutSeconds||0}async _handle(t,s){const r=[],n=[];let i;if(this.Rt){const{id:e,promise:a}=this.Wt({request:t,logs:r,handler:s});i=e,n.push(a)}const a=this.Ot({timeoutId:i,request:t,logs:r,handler:s});n.push(a);const o=await s.waitUntil((async()=>await s.waitUntil(Promise.race(n))||await a)());if(!o)throw new e.WorkboxError("no-response",{url:t.url});return o}Wt({request:t,logs:e,handler:s}){let r;return{promise:new Promise((e=>{r=setTimeout((async()=>{e(await s.cacheMatch(t))}),1e3*this.Rt)})),id:r}}async Ot({timeoutId:t,request:e,logs:s,handler:r}){let n,i;try{i=await r.fetchAndCachePut(e)}catch(t){t instanceof Error&&(n=t)}return t&&clearTimeout(t),!n&&i||(i=await r.cacheMatch(e)),i}},t.NetworkOnly=class extends w{constructor(t={}){super(t),this.Rt=t.networkTimeoutSeconds||0}async _handle(t,s){let r,n=void 0;try{const e=[s.fetch(t)];if(this.Rt){const t=c.timeout(1e3*this.Rt);e.push(t)}if(r=await Promise.race(e),!r)throw new Error("Timed out the network response after "+this.Rt+" seconds.")}catch(t){t instanceof Error&&(n=t)}if(!r)throw new e.WorkboxError("no-response",{url:t.url,error:n});return r}},t.StaleWhileRevalidate=class extends w{constructor(t={}){super(t),this.plugins.some((t=>"cacheWillUpdate"in t))||this.plugins.unshift(u)}async _handle(t,s){const r=s.fetchAndCachePut(t).catch((()=>{}));let n,i=await s.cacheMatch(t);if(i);else try{i=await r}catch(t){t instanceof Error&&(n=t)}if(!i)throw new e.WorkboxError("no-response",{url:t.url,error:n});return i}},t.Strategy=w,t.StrategyHandler=l,t}({},workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private,workbox.core._private); +//# sourceMappingURL=workbox-strategies.prod.js.map diff --git a/ui/public/3rdparty/workbox/workbox-sw.js b/ui/public/3rdparty/workbox/workbox-sw.js new file mode 100644 index 000000000..232b7a045 --- /dev/null +++ b/ui/public/3rdparty/workbox/workbox-sw.js @@ -0,0 +1,2 @@ +!function(){"use strict";try{self["workbox:sw:6.3.0"]&&_()}catch(t){}const t={backgroundSync:"background-sync",broadcastUpdate:"broadcast-update",cacheableResponse:"cacheable-response",core:"core",expiration:"expiration",googleAnalytics:"offline-ga",navigationPreload:"navigation-preload",precaching:"precaching",rangeRequests:"range-requests",routing:"routing",strategies:"strategies",streams:"streams",recipes:"recipes"};self.workbox=new class{constructor(){return this.v={},this.Pt={debug:"localhost"===self.location.hostname,modulePathPrefix:null,modulePathCb:null},this.$t=this.Pt.debug?"dev":"prod",this.Ct=!1,new Proxy(this,{get(e,s){if(e[s])return e[s];const o=t[s];return o&&e.loadModule("workbox-"+o),e[s]}})}setConfig(t={}){if(this.Ct)throw new Error("Config must be set before accessing workbox.* modules");Object.assign(this.Pt,t),this.$t=this.Pt.debug?"dev":"prod"}loadModule(t){const e=this.jt(t);try{importScripts(e),this.Ct=!0}catch(s){throw console.error(`Unable to import module '${t}' from '${e}'.`),s}}jt(t){if(this.Pt.modulePathCb)return this.Pt.modulePathCb(t,this.Pt.debug);let e=["https://storage.googleapis.com/workbox-cdn/releases/6.3.0"];const s=`${t}.${this.$t}.js`,o=this.Pt.modulePathPrefix;return o&&(e=o.split("/"),""===e[e.length-1]&&e.splice(e.length-1,1)),e.push(s),e.join("/")}}}(); +//# sourceMappingURL=workbox-sw.js.map diff --git a/ui/public/fonts/Unbounded-Variable.woff2 b/ui/public/fonts/Unbounded-Variable.woff2 deleted file mode 100644 index a2f74491f..000000000 Binary files a/ui/public/fonts/Unbounded-Variable.woff2 and /dev/null differ diff --git a/ui/index.html b/ui/public/index.html similarity index 62% rename from ui/index.html rename to ui/public/index.html index 827751856..e11abd37c 100644 --- a/ui/index.html +++ b/ui/public/index.html @@ -6,11 +6,11 @@ name="description" content="Navidrome Music Server - {{.Version}}" /> - - - - - + + + + + @@ -19,7 +19,16 @@ manifest.webmanifest provides metadata used when your web app is installed on a user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ --> - + + @@ -27,10 +36,6 @@ Navidrome - @@ -52,5 +57,4 @@ To create a production bundle, use `npm run build` or `yarn build`. --> - diff --git a/ui/public/manifest.webmanifest b/ui/public/manifest.webmanifest new file mode 100644 index 000000000..6d2fa9368 --- /dev/null +++ b/ui/public/manifest.webmanifest @@ -0,0 +1,22 @@ +{ + "name": "Navidrome", + "short_name": "Navidrome", + "description": "Navidrome, an open source web-based music collection server and streamer", + "categories": ["music", "entertainment"], + "display": "standalone", + "start_url": "./", + "background_color": "white", + "theme_color": "blue", + "icons": [ + { + "src": "./android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "./android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/ui/public/navidrome-service-worker.js b/ui/public/navidrome-service-worker.js new file mode 100644 index 000000000..a2b9ef3a6 --- /dev/null +++ b/ui/public/navidrome-service-worker.js @@ -0,0 +1,53 @@ +// documentation: https://developers.google.com/web/tools/workbox/modules/workbox-sw +importScripts('3rdparty/workbox/workbox-sw.js') + +workbox.setConfig({ + modulePathPrefix: '3rdparty/workbox/', + debug: false, +}) + +workbox.loadModule('workbox-core'); +workbox.loadModule('workbox-strategies'); +workbox.loadModule('workbox-routing'); +workbox.loadModule('workbox-navigation-preload'); + +workbox.core.clientsClaim(); +self.skipWaiting(); + +addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + skipWaiting(); + } +}); + + +const CACHE_NAME = 'offline-html'; +// This assumes /offline.html is a URL for your self-contained +// (no external images or styles) offline page. +const FALLBACK_HTML_URL = './offline.html'; +// Populate the cache with the offline HTML page when the +// service worker is installed. +self.addEventListener('install', async (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then((cache) => cache.add(FALLBACK_HTML_URL)) + ); +}); + +const networkOnly = new workbox.strategies.NetworkOnly(); +const navigationHandler = async (params) => { + try { + // Attempt a network request. + return await networkOnly.handle(params); + } catch (error) { + // If it fails, return the cached HTML. + return caches.match(FALLBACK_HTML_URL, { + cacheName: CACHE_NAME, + }); + } +}; + +// Register this strategy to handle all navigations. +workbox.routing.registerRoute( + new workbox.routing.NavigationRoute(navigationHandler) +); diff --git a/ui/public/offline.html b/ui/public/offline.html index e3c44e8d3..72f2d04ae 100644 --- a/ui/public/offline.html +++ b/ui/public/offline.html @@ -5,6 +5,6 @@

It looks like we are having trouble connecting.
-Please check your internet connection, or try again in a moment.

+Please check your internet connection and try again.

\ No newline at end of file diff --git a/ui/public/robots.txt b/ui/public/robots.txt index 77470cb39..94244084f 100644 --- a/ui/public/robots.txt +++ b/ui/public/robots.txt @@ -1,2 +1,4 @@ +User-agent: bingbot +Disallow: /manifest.webmanifest + User-agent: * -Disallow: / \ No newline at end of file diff --git a/ui/src/App.jsx b/ui/src/App.js similarity index 64% rename from ui/src/App.jsx rename to ui/src/App.js index 4de369394..2a58b768c 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.js @@ -1,12 +1,7 @@ import ReactGA from 'react-ga' import { Provider } from 'react-redux' import { createHashHistory } from 'history' -import { - Admin as RAAdmin, - Resource, - useSetLocale, - useRefresh, -} from 'react-admin' +import { Admin as RAAdmin, Resource } from 'react-admin' import { HotKeys } from 'react-hotkeys' import dataProvider from './dataProvider' import authProvider from './authProvider' @@ -20,17 +15,13 @@ import artist from './artist' import playlist from './playlist' import radio from './radio' import share from './share' -import library from './library' -import plugin from './plugin' import { Player } from './audioplayer' import customRoutes from './routes' import { - libraryReducer, themeReducer, addToPlaylistDialogReducer, expandInfoDialogReducer, listenBrainzTokenDialogReducer, - saveQueueDialogReducer, playerReducer, albumViewReducer, activityReducer, @@ -38,18 +29,13 @@ import { replayGainReducer, downloadMenuDialogReducer, shareDialogReducer, - transcodingReducer, } from './reducers' import createAdminStore from './store/createAdminStore' -import { i18nProvider, retrieveTranslation } from './i18n' +import { i18nProvider } from './i18n' import config, { shareInfo } from './config' import { keyMap } from './hotkeys' import useChangeThemeColor from './useChangeThemeColor' import SharePlayer from './share/SharePlayer' -import { HTML5Backend } from 'react-dnd-html5-backend' -import { DndProvider } from 'react-dnd' -import missing from './missing/index.js' -import { useEffect } from 'react' const history = createHashHistory() @@ -66,7 +52,6 @@ const adminStore = createAdminStore({ dataProvider, history, customReducers: { - library: libraryReducer, player: playerReducer, albumView: albumViewReducer, theme: themeReducer, @@ -74,12 +59,10 @@ const adminStore = createAdminStore({ downloadMenuDialog: downloadMenuDialogReducer, expandInfoDialog: expandInfoDialogReducer, listenBrainzTokenDialog: listenBrainzTokenDialogReducer, - saveQueueDialog: saveQueueDialogReducer, shareDialog: shareDialogReducer, activity: activityReducer, settings: settingsReducer, replayGain: replayGainReducer, - transcoding: transcodingReducer, }, }) @@ -90,26 +73,8 @@ const App = () => ( ) const Admin = (props) => { - const setLocale = useSetLocale() - const refresh = useRefresh() - useEffect(() => { - if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) { - retrieveTranslation(config.defaultLanguage) - .then(() => setLocale(config.defaultLanguage)) - .then(() => { - localStorage.setItem('locale', config.defaultLanguage) - refresh(true) - }) - .catch((e) => { - // eslint-disable-next-line no-console - console.error( - 'Cannot load language "' + config.defaultLanguage + '": ' + e, - ) - }) - } - }, [setLocale, refresh]) useChangeThemeColor() - /* eslint-disable react/jsx-key */ + return ( { , permissions === 'admin' ? ( { ) : ( ), - permissions === 'admin' ? ( - - ) : null, - permissions === 'admin' ? ( - - ) : null, - permissions === 'admin' && config.pluginsEnabled ? ( - - ) : null, - , , - , , , - , - , , ]} ) - /* eslint-enable react/jsx-key */ } const AppWithHotkeys = () => { @@ -196,9 +135,7 @@ const AppWithHotkeys = () => { } return ( - - - + ) } diff --git a/ui/src/actions/dialogs.js b/ui/src/actions/dialogs.js index dea4d6203..8fac6076a 100644 --- a/ui/src/actions/dialogs.js +++ b/ui/src/actions/dialogs.js @@ -8,8 +8,6 @@ export const EXTENDED_INFO_OPEN = 'EXTENDED_INFO_OPEN' export const EXTENDED_INFO_CLOSE = 'EXTENDED_INFO_CLOSE' export const LISTENBRAINZ_TOKEN_OPEN = 'LISTENBRAINZ_TOKEN_OPEN' export const LISTENBRAINZ_TOKEN_CLOSE = 'LISTENBRAINZ_TOKEN_CLOSE' -export const SAVE_QUEUE_OPEN = 'SAVE_QUEUE_OPEN' -export const SAVE_QUEUE_CLOSE = 'SAVE_QUEUE_CLOSE' export const DOWNLOAD_MENU_ALBUM = 'album' export const DOWNLOAD_MENU_ARTIST = 'artist' export const DOWNLOAD_MENU_PLAY = 'playlist' @@ -78,11 +76,3 @@ export const openListenBrainzTokenDialog = () => ({ export const closeListenBrainzTokenDialog = () => ({ type: LISTENBRAINZ_TOKEN_CLOSE, }) - -export const openSaveQueueDialog = () => ({ - type: SAVE_QUEUE_OPEN, -}) - -export const closeSaveQueueDialog = () => ({ - type: SAVE_QUEUE_CLOSE, -}) diff --git a/ui/src/actions/index.js b/ui/src/actions/index.js index 9f35f86a9..a319f7a69 100644 --- a/ui/src/actions/index.js +++ b/ui/src/actions/index.js @@ -1,4 +1,3 @@ -export * from './library' export * from './player' export * from './themes' export * from './albumView' diff --git a/ui/src/actions/library.js b/ui/src/actions/library.js deleted file mode 100644 index 4653ec739..000000000 --- a/ui/src/actions/library.js +++ /dev/null @@ -1,12 +0,0 @@ -export const SET_SELECTED_LIBRARIES = 'SET_SELECTED_LIBRARIES' -export const SET_USER_LIBRARIES = 'SET_USER_LIBRARIES' - -export const setSelectedLibraries = (libraryIds) => ({ - type: SET_SELECTED_LIBRARIES, - data: libraryIds, -}) - -export const setUserLibraries = (libraries) => ({ - type: SET_USER_LIBRARIES, - data: libraries, -}) diff --git a/ui/src/actions/player.js b/ui/src/actions/player.js index 9056abeb6..70fa88f50 100644 --- a/ui/src/actions/player.js +++ b/ui/src/actions/player.js @@ -6,9 +6,6 @@ export const PLAYER_CLEAR_QUEUE = 'PLAYER_CLEAR_QUEUE' export const PLAYER_PLAY_TRACKS = 'PLAYER_PLAY_TRACKS' export const PLAYER_CURRENT = 'PLAYER_CURRENT' export const PLAYER_SET_VOLUME = 'PLAYER_SET_VOLUME' -export const PLAYER_SET_MODE = 'PLAYER_SET_MODE' -export const TRANSCODING_SET_PROFILE = 'TRANSCODING_SET_PROFILE' -export const PLAYER_REFRESH_QUEUE = 'PLAYER_REFRESH_QUEUE' export const setTrack = (data) => ({ type: PLAYER_SET_TRACK, @@ -16,17 +13,10 @@ export const setTrack = (data) => ({ }) export const filterSongs = (data, ids) => { - const filteredData = Object.fromEntries( - Object.entries(data).filter(([_, song]) => !song.missing), - ) - return !ids - ? filteredData - : ids.reduce((acc, id) => { - if (filteredData[id]) { - return { ...acc, [id]: filteredData[id] } - } - return acc - }, {}) + if (!ids) { + return data + } + return ids.reduce((acc, id) => ({ ...acc, [id]: data[id] }), {}) } export const addTracks = (data, ids) => { @@ -99,18 +89,3 @@ export const setVolume = (volume) => ({ type: PLAYER_SET_VOLUME, data: { volume }, }) - -export const setPlayMode = (mode) => ({ - type: PLAYER_SET_MODE, - data: { mode }, -}) - -export const setTranscodingProfile = (profile) => ({ - type: TRANSCODING_SET_PROFILE, - data: profile, -}) - -export const refreshQueue = (resolvedUrls) => ({ - type: PLAYER_REFRESH_QUEUE, - data: resolvedUrls, -}) diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js index 892f2e27e..7d89c5feb 100644 --- a/ui/src/actions/serverEvents.js +++ b/ui/src/actions/serverEvents.js @@ -1,9 +1,6 @@ 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) => ({ type, @@ -14,22 +11,7 @@ export const scanStatusUpdate = (data) => ({ data: data, }) -export const nowPlayingCountUpdate = (data) => ({ - type: EVENT_NOW_PLAYING_COUNT, - data: data, -}) - -export const nowPlayingCountSync = (data) => ({ - type: EVENT_NOW_PLAYING_COUNT_SYNC, - data: data, -}) - export const serverDown = () => ({ type: EVENT_SERVER_START, data: {}, }) - -export const streamReconnected = () => ({ - type: EVENT_STREAM_RECONNECTED, - data: {}, -}) diff --git a/ui/src/actions/settings.js b/ui/src/actions/settings.js index 89c8f248f..e62ecde8f 100644 --- a/ui/src/actions/settings.js +++ b/ui/src/actions/settings.js @@ -1,8 +1,6 @@ export const SET_NOTIFICATIONS_STATE = 'SET_NOTIFICATIONS_STATE' export const SET_TOGGLEABLE_FIELDS = 'SET_TOGGLEABLE_FIELDS' export const SET_OMITTED_FIELDS = 'SET_OMITTED_FIELDS' -export const SET_SIDEBAR_PLAYLISTS_FAVOURITES = - 'SET_SIDEBAR_PLAYLISTS_FAVOURITES' export const setNotificationsState = (enabled) => ({ type: SET_NOTIFICATIONS_STATE, @@ -18,8 +16,3 @@ export const setOmittedFields = (obj) => ({ type: SET_OMITTED_FIELDS, data: obj, }) - -export const setSidebarPlaylistsOnlyFavourites = (enabled) => ({ - type: SET_SIDEBAR_PLAYLISTS_FAVOURITES, - data: enabled, -}) diff --git a/ui/src/album/AlbumActions.jsx b/ui/src/album/AlbumActions.js similarity index 78% rename from ui/src/album/AlbumActions.jsx rename to ui/src/album/AlbumActions.js index c3b3a4bc2..c7f20f7ce 100644 --- a/ui/src/album/AlbumActions.jsx +++ b/ui/src/album/AlbumActions.js @@ -5,7 +5,6 @@ import { Button, sanitizeListRestProps, TopToolbar, - useRecordContext, useTranslate, } from 'react-admin' import { useMediaQuery, makeStyles } from '@material-ui/core' @@ -27,21 +26,12 @@ import { } from '../actions' import { formatBytes } from '../utils' import config from '../config' -import { RefreshMetadataButton, ToggleFieldsMenu } from '../common' +import { ToggleFieldsMenu } from '../common' const useStyles = makeStyles({ toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' }, }) -const AlbumButton = ({ children, ...rest }) => { - const record = useRecordContext(rest) || {} - return ( - - ) -} - const AlbumActions = ({ className, ids, @@ -73,9 +63,8 @@ const AlbumActions = ({ }, [dispatch, data, ids]) const handleAddToPlaylist = React.useCallback(() => { - const selectedIds = ids.filter((id) => !data[id].missing) - dispatch(openAddToPlaylist({ selectedIds })) - }, [dispatch, data, ids]) + dispatch(openAddToPlaylist({ selectedIds: ids })) + }, [dispatch, ids]) const handleShare = React.useCallback(() => { dispatch(openShareMenu([record.id], 'album', record.name)) @@ -89,46 +78,43 @@ const AlbumActions = ({
- - - + {config.enableSharing && ( - + )} {config.enableDownloads && ( - - + )} -
{isNotSmall && }
diff --git a/ui/src/album/AlbumActions.test.jsx b/ui/src/album/AlbumActions.test.jsx deleted file mode 100644 index 246a15558..000000000 --- a/ui/src/album/AlbumActions.test.jsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { ThemeProvider, createTheme } from '@material-ui/core/styles' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import AlbumActions from './AlbumActions' - -const { mockConfig, mockPermissions } = vi.hoisted(() => ({ - mockConfig: { - enableSharing: false, - enableDownloads: false, - losslessFormats: 'FLAC,WAV', - }, - mockPermissions: { value: 'admin' }, -})) -vi.mock('../config', () => ({ default: mockConfig })) - -vi.mock('react-redux', () => ({ - useDispatch: () => vi.fn(), - useSelector: () => ({}), -})) - -const mockRefreshMetadata = vi.fn() - -vi.mock('react-admin', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useNotify: () => vi.fn(), - useDataProvider: () => ({ refreshMetadata: mockRefreshMetadata }), - usePermissions: () => ({ permissions: mockPermissions.value }), - useTranslate: () => (x) => x, - } -}) - -describe('AlbumActions', () => { - const record = { id: 'al1', name: 'Album', size: 1024 } - const refreshLabel = 'resources.album.actions.refresh' - - beforeEach(() => { - vi.clearAllMocks() - mockPermissions.value = 'admin' - mockRefreshMetadata.mockResolvedValue({ data: { id: 'al1' } }) - }) - - const renderAlbumActions = () => - render( - - - , - ) - - it('refreshes the album metadata for admins', async () => { - renderAlbumActions() - fireEvent.click(screen.getByRole('button', { name: refreshLabel })) - - await waitFor(() => - expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al1'), - ) - }) - - it('hides the action for non-admin users', () => { - mockPermissions.value = 'regular' - renderAlbumActions() - expect( - screen.queryByRole('button', { name: refreshLabel }), - ).not.toBeInTheDocument() - }) -}) diff --git a/ui/src/album/AlbumDatesField.jsx b/ui/src/album/AlbumDatesField.jsx deleted file mode 100644 index ce1301380..000000000 --- a/ui/src/album/AlbumDatesField.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useRecordContext } from 'react-admin' -import { formatRange } from '../common/index.js' - -const originalYearSymbol = '♫' -const releaseYearSymbol = '○' - -export const AlbumDatesField = ({ className, ...rest }) => { - const record = useRecordContext(rest) - const releaseDate = record.releaseDate - const releaseYear = releaseDate?.toString().substring(0, 4) - const yearRange = - formatRange(record, 'originalYear') || record['maxYear']?.toString() - - // Don't show anything if the year starts with "0" - if (yearRange === '0' || releaseYear?.startsWith('0')) { - return null - } - - let label = yearRange - - if (releaseYear !== undefined && yearRange !== releaseYear) { - label = `${originalYearSymbol} ${yearRange} · ${releaseYearSymbol} ${releaseYear}` - } - return {label} -} diff --git a/ui/src/album/AlbumDatesField.test.jsx b/ui/src/album/AlbumDatesField.test.jsx deleted file mode 100644 index 9bcd41567..000000000 --- a/ui/src/album/AlbumDatesField.test.jsx +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, test, expect, vi } from 'vitest' -import { render } from '@testing-library/react' -import { RecordContextProvider } from 'react-admin' -import { AlbumDatesField } from './AlbumDatesField' -import { formatRange } from '../common/index.js' - -// Mock the formatRange function -vi.mock('../common/index.js', () => ({ - formatRange: vi.fn(), -})) - -describe('AlbumDatesField', () => { - test('renders nothing when yearRange is "0"', () => { - const record = { - maxYear: '0', - releaseDate: '2020-01-01', - } - - vi.mocked(formatRange).mockReturnValue('0') - - const { container } = render( - - - , - ) - - expect(container.firstChild).toBeNull() - }) - - test('renders nothing when releaseYear is "0"', () => { - const record = { - maxYear: '2020', - releaseDate: '0-01-01', - } - - vi.mocked(formatRange).mockReturnValue('2020') - - const { container } = render( - - - , - ) - - expect(container.firstChild).toBeNull() - }) - - test('renders only yearRange when releaseYear is undefined', () => { - const record = { - maxYear: '2020', - } - - vi.mocked(formatRange).mockReturnValue('2020') - - const { container } = render( - - - , - ) - - expect(container.textContent).toBe('2020') - }) - - test('renders both years when they are different', () => { - const record = { - maxYear: '2018', - releaseDate: '2020-01-01', - } - - vi.mocked(formatRange).mockReturnValue('2018') - - const { container } = render( - - - , - ) - - expect(container.textContent).toBe('♫ 2018 · ○ 2020') - }) - - test('renders only yearRange when both years are the same', () => { - const record = { - maxYear: '2020', - releaseDate: '2020-01-01', - } - - vi.mocked(formatRange).mockReturnValue('2020') - - const { container } = render( - - - , - ) - - expect(container.textContent).toBe('2020') - }) - - test('applies className when provided', () => { - const record = { - maxYear: '2020', - } - - vi.mocked(formatRange).mockReturnValue('2020') - - const { container } = render( - - - , - ) - - expect(container.firstChild).toHaveClass('test-class') - }) -}) diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.js similarity index 65% rename from ui/src/album/AlbumDetails.jsx rename to ui/src/album/AlbumDetails.js index 8d636a047..5e2ee9b65 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.js @@ -1,7 +1,8 @@ -import { useEffect, useState } from 'react' +import React, { useMemo, useCallback, useState, useEffect } from 'react' import { Card, CardContent, + CardMedia, Collapse, makeStyles, Typography, @@ -9,33 +10,30 @@ import { withWidth, } from '@material-ui/core' import { - ArrayField, - ChipField, - Link, - SingleFieldList, useRecordContext, useTranslate, + ArrayField, + SingleFieldList, + ChipField, + Link, } from 'react-admin' import clsx from 'clsx' import Lightbox from 'react-image-lightbox' -import config from '../config' import 'react-image-lightbox/style.css' import subsonic from '../subsonic' -import { Artwork } from '../common/Artwork' import { ArtistLinkField, - CollapsibleComment, DurationField, formatRange, + SizeField, LoveButton, RatingField, - SizeField, useAlbumsPerPage, } from '../common' -import { useDateLocale } from '../i18n/useDateLocale' +import config from '../config' import { formatFullDate, intersperse } from '../utils' import AlbumExternalLinks from './AlbumExternalLinks' -import { SafeHTML } from '../common/SafeHTML' +import AnchorMe from '../common/Linkify' const useStyles = makeStyles( (theme) => ({ @@ -75,13 +73,6 @@ const useStyles = makeStyles( width: '15em', minWidth: '15em', }, - backgroundColor: 'transparent', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - noCoverAnimation: { - '&, &::before, &::after': { animation: 'none' }, }, cover: { objectFit: 'contain', @@ -89,13 +80,17 @@ const useStyles = makeStyles( display: 'block', width: '100%', height: '100%', - backgroundColor: 'transparent', - transition: 'opacity 0.3s ease-in-out', }, loveButton: { top: theme.spacing(-0.2), left: theme.spacing(0.5), }, + commentBlock: { + display: 'inline-block', + marginTop: '1em', + float: 'left', + wordBreak: 'break-word', + }, notes: { display: 'inline-block', marginTop: '1em', @@ -103,6 +98,9 @@ const useStyles = makeStyles( wordBreak: 'break-word', cursor: 'pointer', }, + pointerCursor: { + cursor: 'pointer', + }, recordName: {}, recordArtist: {}, recordMeta: {}, @@ -115,14 +113,49 @@ const useStyles = makeStyles( }), { name: 'NDAlbumDetails', - }, + } ) -const useGetHandleGenreClick = (width) => { +const AlbumComment = ({ record }) => { + const classes = useStyles() + const [expanded, setExpanded] = React.useState(false) + + const lines = record.comment.split('\n') + const formatted = useMemo(() => { + return lines.map((line, idx) => ( + + +
+
+ )) + }, [lines, record.id]) + + const handleExpandClick = useCallback(() => { + setExpanded(!expanded) + }, [expanded, setExpanded]) + + return ( + 1 && classes.pointerCursor + )} + > + + {formatted} + + + ) +} + +export const useGetHandleGenreClick = (width) => { const [perPage] = useAlbumsPerPage(width) return (id) => { - return `/album?filter={"genre_id":["${id}"]}&order=ASC&sort=name&perPage=${perPage}` + return `/album?filter={"genre_id":"${id}"}&order=ASC&sort=name&perPage=${perPage}` } } @@ -152,57 +185,69 @@ const GenreList = () => { ) } -export const Details = (props) => { +const Details = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const translate = useTranslate() const record = useRecordContext(props) - const locale = useDateLocale() - - // Create an array of detail elements let details = [] const addDetail = (obj) => { const id = details.length details.push({obj}) } - // Calculate date related fields - const yearRange = formatRange(record, 'year') - const date = record.date ? formatFullDate(record.date, locale) : yearRange - + const originalYearRange = formatRange(record, 'originalYear') const originalDate = record.originalDate - ? formatFullDate(record.originalDate, locale) - : formatRange(record, 'originalYear') - const releaseDate = - record?.releaseDate && formatFullDate(record.releaseDate, locale) + ? formatFullDate(record.originalDate) + : originalYearRange + const yearRange = formatRange(record, 'year') + const date = record.date ? formatFullDate(record.date) : yearRange + const releaseDate = record.releaseDate + ? formatFullDate(record.releaseDate) + : date - const dateToUse = originalDate || date - const isOriginalDate = originalDate && dateToUse !== date - const showDate = dateToUse && dateToUse !== releaseDate + const showReleaseDate = date !== releaseDate && releaseDate.length > 3 + const showOriginalDate = + date !== originalDate && + originalDate !== releaseDate && + originalDate.length > 3 - // Get label for the main date display - const getDateLabel = () => { - if (isXsmall) return '♫' - if (isOriginalDate) return translate('resources.album.fields.originalDate') - return null - } - - // Get label for release date display - const getReleaseDateLabel = () => { - if (!isXsmall) return translate('resources.album.fields.releaseDate') - if (showDate) return '○' - return null - } - - // Display dates with appropriate labels - if (showDate) { - addDetail(<>{[getDateLabel(), dateToUse].filter(Boolean).join(' ')}) - } - - if (releaseDate) { + showOriginalDate && + !isXsmall && addDetail( - <>{[getReleaseDateLabel(), releaseDate].filter(Boolean).join(' ')}, + <> + {[translate('resources.album.fields.originalDate'), originalDate].join( + ' ' + )} + ) - } + + yearRange && addDetail(<>{['♫', !isXsmall ? date : yearRange].join(' ')}) + + showReleaseDate && + addDetail( + <> + {(!isXsmall + ? [translate('resources.album.fields.releaseDate'), releaseDate] + : ['○', record.releaseDate.substring(0, 4)] + ).join(' ')} + + ) + + const showReleases = record.releases > 1 + showReleases && + addDetail( + <> + {!isXsmall + ? [ + record.releases, + translate('resources.album.fields.releases', { + smart_count: record.releases, + }), + ].join(' ') + : ['(', record.releases, ')))'].join(' ')} + + ) + addDetail( <> {record.songCount + @@ -210,12 +255,11 @@ export const Details = (props) => { translate('resources.song.name', { smart_count: record.songCount, })} - , + ) !isXsmall && addDetail() !isXsmall && addDetail() - // Return the details rendered with separators return <>{intersperse(details, ' · ')} } @@ -224,13 +268,14 @@ const AlbumDetails = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) const classes = useStyles() + const [isLightboxOpen, setLightboxOpen] = React.useState(false) const [expanded, setExpanded] = useState(false) const [albumInfo, setAlbumInfo] = useState() - const [isLightboxOpen, setLightboxOpen] = useState(false) - let notes = albumInfo?.notes || record.notes + let notes = + albumInfo?.notes?.replace(new RegExp('<.*>', 'g'), '') || record.notes - if (notes) { + if (notes !== undefined) { notes += '..' } @@ -244,28 +289,30 @@ const AlbumDetails = (props) => { } }) .catch((e) => { - // eslint-disable-next-line no-console console.error('error on album page', e) }) }, [record]) + const imageUrl = subsonic.getCoverArtUrl(record, 300) const fullImageUrl = subsonic.getCoverArtUrl(record) + const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), []) + const handleCloseLightbox = React.useCallback( + () => setLightboxOpen(false), + [] + ) return (
-
- + setLightboxOpen(true)} />
@@ -284,9 +331,6 @@ const AlbumDetails = (props) => { color="primary" /> - - {record?.tags?.['albumversion']} - @@ -314,7 +358,7 @@ const AlbumDetails = (props) => { )} )} - {isDesktop && notes && ( + {isDesktop && ( { variant={'body1'} onClick={() => setExpanded(!expanded)} > - - {notes} - + )} - {isDesktop && record['comment'] && ( - - )} + {isDesktop && record['comment'] && }
- {!isDesktop && record['comment'] && ( - - )} - {!isDesktop && notes && ( + {!isDesktop && record['comment'] && } + {!isDesktop && (
setExpanded(!expanded)} > - - {notes} - +
@@ -360,7 +396,7 @@ const AlbumDetails = (props) => { animationDuration={200} imageTitle={record.name} mainSrc={fullImageUrl} - onCloseRequest={() => setLightboxOpen(false)} + onCloseRequest={handleCloseLightbox} /> )}
diff --git a/ui/src/album/AlbumDetails.test.jsx b/ui/src/album/AlbumDetails.test.jsx deleted file mode 100644 index afc4bf0a0..000000000 --- a/ui/src/album/AlbumDetails.test.jsx +++ /dev/null @@ -1,413 +0,0 @@ -// ui/src/album/__tests__/AlbumDetails.test.jsx -import { describe, test, expect, beforeEach, afterEach } from 'vitest' -import { render } from '@testing-library/react' -import { RecordContextProvider } from 'react-admin' -import { useMediaQuery } from '@material-ui/core' -import { createTheme, ThemeProvider } from '@material-ui/core/styles' -import config from '../config' -import AlbumDetails, { Details } from './AlbumDetails' - -vi.mock('../subsonic', () => ({ - default: { - getAlbumInfo: () => - Promise.resolve({ - json: { 'subsonic-response': { status: 'ok', albumInfo: {} } }, - }), - getCoverArtUrl: () => '', - }, -})) - -vi.mock('react-admin', async () => { - const actual = await vi.importActual('react-admin') - return { - ...actual, - useDataProvider: () => ({ getOne: vi.fn() }), - useNotify: () => vi.fn(), - useRefresh: () => vi.fn(), - } -}) - -// Mock useMediaQuery -vi.mock('@material-ui/core', async () => { - const actual = await import('@material-ui/core') - return { - ...actual, - useMediaQuery: vi.fn(), - } -}) - -// Mock formatFullDate to return deterministic results -vi.mock('../utils', async () => { - const actual = await import('../utils') - return { - ...actual, - formatFullDate: (date) => { - if (!date) return '' - // Use en-CA locale for consistent test results - return new Date(date).toLocaleDateString('en-CA', { - year: 'numeric', - month: 'short', - day: 'numeric', - timeZone: 'UTC', - }) - }, - } -}) - -describe('Details component', () => { - describe('Desktop view', () => { - beforeEach(() => { - // Set desktop view (isXsmall = false) - vi.mocked(useMediaQuery).mockReturnValue(false) - }) - - test('renders correctly with just year range', () => { - const record = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - year: 2020, - } - - const { container } = render( - -
- , - ) - - expect(container).toMatchSnapshot() - }) - - test('renders correctly with date', () => { - const record = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - date: '2020-05-01', - } - - const { container } = render( - -
- , - ) - - expect(container).toMatchSnapshot() - }) - - test('renders correctly with originalDate', () => { - const record = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - originalDate: '2018-03-15', - } - - const { container } = render( - -
- , - ) - - expect(container).toMatchSnapshot() - }) - - test('renders correctly with date and originalDate', () => { - const record = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - date: '2020-05-01', - originalDate: '2018-03-15', - } - - const { container } = render( - -
- , - ) - - expect(container).toMatchSnapshot() - }) - - test('renders correctly with releaseDate', () => { - const record = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - releaseDate: '2020-06-15', - } - - const { container } = render( - -
- , - ) - - expect(container).toMatchSnapshot() - }) - - test('renders correctly with all date fields', () => { - const record = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - date: '2020-05-01', - originalDate: '2018-03-15', - releaseDate: '2020-06-15', - } - - const { container } = render( - -
- , - ) - - expect(container).toMatchSnapshot() - }) - }) - - describe('Mobile view', () => { - beforeEach(() => { - // Set mobile view (isXsmall = true) - vi.mocked(useMediaQuery).mockReturnValue(true) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - test('renders correctly with just year range', () => { - const record = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - year: 2020, - } - - const { container } = render( - -
- , - ) - - expect(container).toMatchSnapshot() - }) - - test('renders correctly with date', () => { - const record = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - date: '2020-05-01', - } - - const { container } = render( - -
- , - ) - - expect(container).toMatchSnapshot() - }) - - test('renders correctly with originalDate', () => { - const record = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - originalDate: '2018-03-15', - } - - const { container } = render( - -
- , - ) - - expect(container).toMatchSnapshot() - }) - - test('renders correctly with date and originalDate', () => { - const record = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - date: '2020-05-01', - originalDate: '2018-03-15', - } - - const { container } = render( - -
- , - ) - - expect(container).toMatchSnapshot() - }) - - test('renders correctly with releaseDate', () => { - const record = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - releaseDate: '2020-06-15', - } - - const { container } = render( - -
- , - ) - - expect(container).toMatchSnapshot() - }) - - test('renders correctly with all date fields', () => { - const record = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - date: '2020-05-01', - originalDate: '2018-03-15', - releaseDate: '2020-06-15', - } - - const { container } = render( - -
- , - ) - - expect(container).toMatchSnapshot() - }) - - test('renders correctly with no date fields', () => { - const record = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - } - - const { container } = render( - -
- , - ) - - expect(container).toMatchSnapshot() - }) - - test('renders correctly with year range (start and end years)', () => { - const record = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - year: 2018, - yearEnd: 2020, - } - - const { container } = render( - -
- , - ) - - expect(container).toMatchSnapshot() - }) - - test('renders correctly with originalYear range', () => { - const record = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - originalYear: 2015, - originalYearEnd: 2016, - } - - const { container } = render( - -
- , - ) - - expect(container).toMatchSnapshot() - }) - }) -}) - -describe('AlbumDetails cover animation', () => { - const albumRecord = { - id: '123', - name: 'Test Album', - songCount: 12, - duration: 3600, - size: 102400, - } - const originalEnableCoverAnimation = config.enableCoverAnimation - - beforeEach(() => { - vi.mocked(useMediaQuery).mockReturnValue(false) - }) - - afterEach(() => { - config.enableCoverAnimation = originalEnableCoverAnimation - }) - - const renderAlbum = () => - render( - - - - - , - ) - - test('applies noCoverAnimation when enableCoverAnimation is false', () => { - config.enableCoverAnimation = false - const { container } = renderAlbum() - const cover = container.querySelector('[class*="coverParent"]') - - expect(cover).not.toBeNull() - expect(cover.className).toMatch(/noCoverAnimation/) - }) - - test('omits noCoverAnimation when enableCoverAnimation is true', () => { - config.enableCoverAnimation = true - const { container } = renderAlbum() - const cover = container.querySelector('[class*="coverParent"]') - - expect(cover).not.toBeNull() - expect(cover.className).not.toMatch(/noCoverAnimation/) - }) -}) diff --git a/ui/src/album/AlbumExternalLinks.jsx b/ui/src/album/AlbumExternalLinks.js similarity index 93% rename from ui/src/album/AlbumExternalLinks.jsx rename to ui/src/album/AlbumExternalLinks.js index 4b956b496..caf4c82ea 100644 --- a/ui/src/album/AlbumExternalLinks.jsx +++ b/ui/src/album/AlbumExternalLinks.js @@ -35,7 +35,7 @@ const AlbumExternalLinks = (props) => { encodeURIComponent(record.name) }`, 'message.openIn.lastfm', - , + ) } @@ -43,7 +43,7 @@ const AlbumExternalLinks = (props) => { addLink( `https://musicbrainz.org/release/${record.mbzAlbumId}`, 'message.openIn.musicbrainz', - , + ) return
{intersperse(links, ' ')}
diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.js similarity index 56% rename from ui/src/album/AlbumGridView.jsx rename to ui/src/album/AlbumGridView.js index 2e4cb10ba..2c01e8517 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.js @@ -1,4 +1,4 @@ -import React, { useRef } from 'react' +import React from 'react' import { GridList, GridListTile, @@ -11,18 +11,15 @@ import withWidth from '@material-ui/core/withWidth' import { Link } from 'react-router-dom' import { linkToRecord, useListContext, Loading } from 'react-admin' import { withContentRect } from 'react-measure' -import { useRollChanged } from './useRollChanged' import { useDrag } from 'react-dnd' +import subsonic from '../subsonic' import { AlbumContextMenu, PlayButton, ArtistLinkField, - OverflowTooltip, + RangeDoubleField, } from '../common' import { DraggableTypes } from '../consts' -import clsx from 'clsx' -import { AlbumDatesField } from './AlbumDatesField.jsx' -import { Artwork } from '../common/Artwork' const useStyles = makeStyles( (theme) => ({ @@ -33,13 +30,14 @@ const useStyles = makeStyles( tileBar: { transition: 'all 150ms ease-out', opacity: 0, - pointerEvents: 'none', textAlign: 'left', + marginBottom: '3px', 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%)', }, tileBarMobile: { textAlign: 'left', + marginBottom: '3px', 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%)', }, @@ -57,16 +55,6 @@ const useStyles = makeStyles( whiteSpace: 'nowrap', textOverflow: 'ellipsis', }, - missingAlbum: { - opacity: 0.3, - }, - albumVersion: { - fontSize: '12px', - color: theme.palette.type === 'dark' ? '#c5c5c5' : '#696969', - overflow: 'hidden', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - }, albumSubtitle: { fontSize: '12px', color: theme.palette.type === 'dark' ? '#c5c5c5' : '#696969', @@ -78,9 +66,8 @@ const useStyles = makeStyles( position: 'relative', display: 'block', textDecoration: 'none', - '&:hover $tileBar, &:focus-within $tileBar': { + '&:hover $tileBar': { opacity: 1, - pointerEvents: 'auto', }, }, albumLink: { @@ -91,24 +78,15 @@ const useStyles = makeStyles( albumContainer: {}, albumPlayButton: { color: 'white' }, }), - { name: 'NDAlbumGridView' }, + { name: 'NDAlbumGridView' } ) const useCoverStyles = makeStyles({ - coverContainer: { - width: '100%', - aspectRatio: '1', - overflow: 'hidden', - }, cover: { display: 'inline-block', width: '100%', objectFit: 'contain', - // The image fills this box absolutely, so it lends no height: a remount that has not been - // re-measured yet would collapse the tile and blank the cover for a frame. - aspectRatio: '1', height: (props) => props.height, - transition: 'opacity 0.3s ease-in-out', }, }) @@ -120,36 +98,32 @@ const getColsForWidth = (width) => { return 9 } -const Cover = withContentRect('bounds')(({ - record, - measureRef, - contentRect, -}) => { - // Force height to be the same as the width determined by the GridList - // noinspection JSSuspiciousNameCombination - const classes = useCoverStyles({ height: contentRect.bounds.width }) - const [, dragAlbumRef] = useDrag( - () => ({ - type: DraggableTypes.ALBUM, - item: { albumIds: [record.id] }, - options: { dropEffect: 'copy' }, - }), - [record], - ) - - return ( -
-
- +const Cover = withContentRect('bounds')( + ({ record, measureRef, contentRect }) => { + // Force height to be the same as the width determined by the GridList + // noinspection JSSuspiciousNameCombination + const classes = useCoverStyles({ height: contentRect.bounds.width }) + const [, dragAlbumRef] = useDrag( + () => ({ + type: DraggableTypes.ALBUM, + item: { albumIds: [record.id] }, + options: { dropEffect: 'copy' }, + }), + [record] + ) + return ( +
+
+ {record.name} +
-
- ) -}) + ) + } +) const AlbumGridTile = ({ showArtist, record, basePath, ...props }) => { const classes = useStyles() @@ -159,12 +133,8 @@ const AlbumGridTile = ({ showArtist, record, basePath, ...props }) => { if (!record) { return null } - const computedClasses = clsx( - classes.albumContainer, - record.missing && classes.missingAlbum, - ) return ( -
+
{ - ) + } actionIcon={} /> @@ -188,21 +156,21 @@ const AlbumGridTile = ({ showArtist, record, basePath, ...props }) => { className={classes.albumLink} to={linkToRecord(basePath, record.id, 'show')} > - - - {record.name} - - {record.tags && record.tags['albumversion'] && ( - - {record.tags['albumversion']} - - )} - + {record.name} {showArtist ? ( ) : ( - + )}
) @@ -234,24 +202,10 @@ const LoadedAlbumGrid = ({ ids, data, basePath, width }) => { ) } -const AlbumGridView = ({ - albumListType, - loaded, - loading, - seed, - shownSeed, - ...props -}) => { - // ArtistShow renders this grid too, with no roll to track, so own a ref when none is passed. - const ownSeed = useRef(null) - // A re-roll replaces every album, so the previous roll must not linger while it loads. - const rerolling = - useRollChanged(shownSeed ?? ownSeed, seed, loading) && - albumListType === 'random' - const hide = rerolling || !props.data || !props.ids +const AlbumGridView = ({ albumListType, loaded, loading, ...props }) => { + const hide = + (loading && albumListType === 'random') || !props.data || !props.ids return hide ? : } -const AlbumGridViewWithWidth = withWidth()(AlbumGridView) - -export default AlbumGridViewWithWidth +export default withWidth()(AlbumGridView) diff --git a/ui/src/album/AlbumGridView.test.jsx b/ui/src/album/AlbumGridView.test.jsx deleted file mode 100644 index 1f06c3fd7..000000000 --- a/ui/src/album/AlbumGridView.test.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import { render } from '@testing-library/react' -import { describe, it, expect, vi } from 'vitest' -import AlbumGridView from './AlbumGridView' - -// react-admin's Link/useListContext need a router and a store; the grid body is not under test here. -vi.mock('react-admin', () => ({ - linkToRecord: () => '/album/1', - useListContext: () => ({}), - Loading: () =>
, -})) -describe('AlbumGridView', () => { - // ArtistShow renders the grid through ReferenceManyField, which passes no seed tracking. - it('renders without a shownSeed ref', () => { - expect(() => - render(), - ).not.toThrow() - }) -}) diff --git a/ui/src/album/AlbumInfo.js b/ui/src/album/AlbumInfo.js new file mode 100644 index 000000000..95909f734 --- /dev/null +++ b/ui/src/album/AlbumInfo.js @@ -0,0 +1,77 @@ +import Table from '@material-ui/core/Table' +import TableBody from '@material-ui/core/TableBody' +import inflection from 'inflection' +import TableCell from '@material-ui/core/TableCell' +import TableContainer from '@material-ui/core/TableContainer' +import TableRow from '@material-ui/core/TableRow' +import { + ArrayField, + BooleanField, + ChipField, + DateField, + SingleFieldList, + TextField, + useRecordContext, + useTranslate, +} from 'react-admin' +import { makeStyles } from '@material-ui/core/styles' +import { MultiLineTextField } from '../common' + +const useStyles = makeStyles({ + tableCell: { + width: '17.5%', + }, +}) + +const AlbumInfo = (props) => { + const classes = useStyles() + const translate = useTranslate() + const record = useRecordContext(props) + const data = { + album: , + albumArtist: , + genre: ( + + + + + + ), + compilation: , + updatedAt: , + comment: , + } + + const optionalFields = ['comment', 'genre'] + optionalFields.forEach((field) => { + !record[field] && delete data[field] + }) + + return ( + +
+ + {Object.keys(data).map((key) => { + return ( + + + {translate(`resources.album.fields.${key}`, { + _: inflection.humanize(inflection.underscore(key)), + })} + : + + {data[key]} + + ) + })} + +
+ + ) +} + +export default AlbumInfo diff --git a/ui/src/album/AlbumInfo.jsx b/ui/src/album/AlbumInfo.jsx deleted file mode 100644 index 075841e43..000000000 --- a/ui/src/album/AlbumInfo.jsx +++ /dev/null @@ -1,148 +0,0 @@ -import Table from '@material-ui/core/Table' -import TableBody from '@material-ui/core/TableBody' -import { humanize, underscore } from 'inflection' -import TableCell from '@material-ui/core/TableCell' -import TableContainer from '@material-ui/core/TableContainer' -import TableRow from '@material-ui/core/TableRow' -import { - ArrayField, - BooleanField, - ChipField, - DateField, - FunctionField, - SingleFieldList, - TextField, - useRecordContext, - useTranslate, -} from 'react-admin' -import { makeStyles } from '@material-ui/core/styles' -import { - ArtistLinkField, - MultiLineTextField, - ParticipantsInfo, - RangeField, -} from '../common' - -const useStyles = makeStyles({ - tableCell: { - width: '17.5%', - }, - value: { - whiteSpace: 'pre-line', - }, -}) - -const AlbumInfo = (props) => { - const classes = useStyles() - const translate = useTranslate() - const record = useRecordContext(props) - const data = { - name: , - libraryName: , - albumArtist: ( - - ), - genre: ( - - - - - - ), - date: - record?.maxYear && record.maxYear === record.minYear ? ( - - ) : ( - - ), - originalDate: - record?.maxOriginalYear && - record.maxOriginalYear === record.minOriginalYear ? ( - - ) : ( - - ), - releaseDate: , - recordLabel: ( - record.tags?.recordlabel?.join(', ')} - /> - ), - catalogNum: , - releaseType: ( - record.tags?.releasetype?.join(', ')} - /> - ), - media: ( - record.tags?.media?.join(', ')} - /> - ), - grouping: ( - record.tags?.grouping?.join(', ')} - /> - ), - mood: ( - record.tags?.mood?.join(', ')} - /> - ), - compilation: , - updatedAt: , - comment: , - } - - const optionalFields = ['comment', 'genre', 'catalogNum'] - optionalFields.forEach((field) => { - !record[field] && delete data[field] - }) - - const optionalTags = [ - 'releaseType', - 'recordLabel', - 'grouping', - 'mood', - 'media', - ] - optionalTags.forEach((field) => { - !record?.tags?.[field.toLowerCase()] && delete data[field] - }) - - return ( - - - - {Object.keys(data).map((key) => { - return ( - - - {translate(`resources.album.fields.${key}`, { - _: humanize(underscore(key)), - })} - : - - - {data[key]} - - - ) - })} - - -
-
- ) -} - -export default AlbumInfo diff --git a/ui/src/album/AlbumList.js b/ui/src/album/AlbumList.js new file mode 100644 index 000000000..10ba7e100 --- /dev/null +++ b/ui/src/album/AlbumList.js @@ -0,0 +1,144 @@ +import React from 'react' +import { useSelector } from 'react-redux' +import { Redirect, useLocation } from 'react-router-dom' +import { + AutocompleteInput, + Filter, + NullableBooleanInput, + NumberInput, + Pagination, + ReferenceInput, + SearchInput, + useTranslate, +} from 'react-admin' +import FavoriteIcon from '@material-ui/icons/Favorite' +import { withWidth } from '@material-ui/core' +import { + List, + QuickFilter, + Title, + useAlbumsPerPage, + useResourceRefresh, + useSetToggleableFields, +} from '../common' +import AlbumListActions from './AlbumListActions' +import AlbumTableView from './AlbumTableView' +import AlbumGridView from './AlbumGridView' +import albumLists, { defaultAlbumList } from './albumLists' +import config from '../config' +import AlbumInfo from './AlbumInfo' +import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' + +const AlbumFilter = (props) => { + const translate = useTranslate() + return ( + + + ({ name: [searchText] })} + > + + + ({ name: [searchText] })} + > + + + + + {config.enableFavourites && ( + } + defaultValue={true} + /> + )} + + ) +} + +const AlbumListTitle = ({ albumListType }) => { + const translate = useTranslate() + let title = translate('resources.album.name', { smart_count: 2 }) + if (albumListType) { + let listTitle = translate(`resources.album.lists.${albumListType}`, { + smart_count: 2, + }) + title = `${title} - ${listTitle}` + } + return +} + +const AlbumList = (props) => { + const { width } = props + const albumView = useSelector((state) => state.albumView) + const [perPage, perPageOptions] = useAlbumsPerPage(width) + const location = useLocation() + useResourceRefresh('album') + + const albumListType = location.pathname + .replace(/^\/album/, '') + .replace(/^\//, '') + + // Workaround to force album columns to appear the first time. + // See https://github.com/navidrome/navidrome/pull/923#issuecomment-833004842 + // TODO: Find a better solution + useSetToggleableFields( + 'album', + [ + 'artist', + 'songCount', + 'playCount', + 'year', + 'duration', + 'rating', + 'size', + 'createdAt', + ], + ['createdAt', 'size'] + ) + + // If it does not have filter/sort params (usually coming from Menu), + // reload with correct filter/sort params + if (!location.search) { + const type = + albumListType || localStorage.getItem('defaultView') || defaultAlbumList + const listParams = albumLists[type] + if (listParams) { + return <Redirect to={`/album/${type}?${listParams.params}`} /> + } + } + + return ( + <> + <List + {...props} + exporter={false} + bulkActionButtons={false} + actions={<AlbumListActions />} + filters={<AlbumFilter />} + perPage={perPage} + pagination={<Pagination rowsPerPageOptions={perPageOptions} />} + title={<AlbumListTitle albumListType={albumListType} />} + > + {albumView.grid ? ( + <AlbumGridView albumListType={albumListType} {...props} /> + ) : ( + <AlbumTableView {...props} /> + )} + </List> + <ExpandInfoDialog content={<AlbumInfo />} /> + </> + ) +} + +export default withWidth()(AlbumList) diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx deleted file mode 100644 index 81e4dcd14..000000000 --- a/ui/src/album/AlbumList.jsx +++ /dev/null @@ -1,293 +0,0 @@ -import { cloneElement, useRef } from 'react' -import { useSelector } from 'react-redux' -import { Redirect, useLocation } from 'react-router-dom' -import { - AutocompleteArrayInput, - AutocompleteInput, - Filter, - NullableBooleanInput, - NumberInput, - ReferenceArrayInput, - ReferenceInput, - SearchInput, - useListContext, - usePermissions, - useRefresh, - useTranslate, - useVersion, -} from 'react-admin' -import FavoriteIcon from '@material-ui/icons/Favorite' -import { withWidth } from '@material-ui/core' -import { - List, - Pagination, - Title, - useAlbumsPerPage, - useResourceRefresh, - useScrollRestoration, - useSetToggleableFields, -} from '../common' -import AlbumListActions from './AlbumListActions' -import AlbumTableView from './AlbumTableView' -import AlbumGridView from './AlbumGridView' -import { useRollChanged } from './useRollChanged' -import albumLists from './albumLists' -import { - getStoredDefaultView, - isResourceDefaultView, -} from '../personal/defaultViews' -import config from '../config' -import AlbumInfo from './AlbumInfo' -import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' -import { humanize } from 'inflection' -import { makeStyles } from '@material-ui/core/styles' - -// Waits for rows: restoring into an unrendered list leaves the page too short to hold the offset. -const ScrollRestorer = ({ children, ...rest }) => { - const { loaded, total } = useListContext() - useScrollRestoration(loaded && total > 0) - return cloneElement(children, rest) -} - -const useStyles = makeStyles({ - chip: { - margin: 0, - height: '24px', - }, -}) - -const formatReleaseType = (record) => - record?.tagValue ? humanize(record?.tagValue) : '-- None --' - -const AlbumFilter = (props) => { - const classes = useStyles() - const translate = useTranslate() - const { permissions } = usePermissions() - const isAdmin = permissions === 'admin' - return ( - <Filter {...props} variant={'outlined'}> - <SearchInput id="search" source="name" alwaysOn /> - <ReferenceInput - label={translate('resources.album.fields.artist')} - source="artist_id" - reference="artist" - sort={{ field: 'name', order: 'ASC' }} - filterToQuery={(searchText) => ({ name: [searchText] })} - > - <AutocompleteInput emptyText="-- None --" /> - </ReferenceInput> - <ReferenceArrayInput - label={translate('resources.album.fields.genre')} - source="genre_id" - reference="genre" - perPage={0} - sort={{ field: 'name', order: 'ASC' }} - filterToQuery={(searchText) => ({ name: [searchText] })} - > - <AutocompleteArrayInput emptyText="-- None --" classes={classes} /> - </ReferenceArrayInput> - <ReferenceInput - label={translate('resources.album.fields.recordLabel')} - source="recordlabel" - reference="tag" - perPage={0} - sort={{ field: 'tagValue', order: 'ASC' }} - filter={{ tag_name: 'recordlabel' }} - filterToQuery={(searchText) => ({ - tag_value: [searchText], - })} - > - <AutocompleteInput emptyText="-- None --" optionText="tagValue" /> - </ReferenceInput> - <ReferenceArrayInput - label={translate('resources.album.fields.grouping')} - source="grouping" - reference="tag" - perPage={0} - sort={{ field: 'tagValue', order: 'ASC' }} - filter={{ tag_name: 'grouping' }} - filterToQuery={(searchText) => ({ - tag_value: [searchText], - })} - > - <AutocompleteArrayInput - emptyText="-- None --" - classes={classes} - optionText="tagValue" - /> - </ReferenceArrayInput> - <ReferenceArrayInput - label={translate('resources.album.fields.mood')} - source="mood" - reference="tag" - perPage={0} - sort={{ field: 'tagValue', order: 'ASC' }} - filter={{ tag_name: 'mood' }} - filterToQuery={(searchText) => ({ - tag_value: [searchText], - })} - > - <AutocompleteArrayInput - emptyText="-- None --" - classes={classes} - optionText="tagValue" - /> - </ReferenceArrayInput> - <ReferenceInput - label={translate('resources.album.fields.media')} - source="media" - reference="tag" - perPage={0} - sort={{ field: 'tagValue', order: 'ASC' }} - filter={{ tag_name: 'media' }} - filterToQuery={(searchText) => ({ - tag_value: [searchText], - })} - > - <AutocompleteInput emptyText="-- None --" optionText="tagValue" /> - </ReferenceInput> - <ReferenceInput - label={translate('resources.album.fields.releaseType')} - source="releasetype" - reference="tag" - perPage={0} - sort={{ field: 'tagValue', order: 'ASC' }} - filter={{ tag_name: 'releasetype' }} - filterToQuery={(searchText) => ({ - tag_value: [searchText], - })} - > - <AutocompleteInput - emptyText="-- None --" - optionText={formatReleaseType} - /> - </ReferenceInput> - <NullableBooleanInput source="compilation" /> - <NullableBooleanInput source="played" defaultValue={false} /> - <NumberInput source="year" /> - {config.enableFavourites && ( - <NullableBooleanInput - source="starred" - label={<FavoriteIcon fontSize={'small'} />} - /> - )} - {isAdmin && <NullableBooleanInput source="missing" />} - </Filter> - ) -} - -const AlbumListTitle = ({ albumListType }) => { - const translate = useTranslate() - let title = translate('resources.album.name', { smart_count: 2 }) - if (albumListType) { - let listTitle = translate(`resources.album.lists.${albumListType}`, { - smart_count: 2, - }) - title = `${title} - ${listTitle}` - } - return <Title subTitle={title} args={{ smart_count: 2 }} /> -} - -const AlbumListPagination = ({ albumListType, seed, shownSeed, ...rest }) => { - const { loading } = useListContext() - const rerolling = useRollChanged(shownSeed, seed, loading) - if (rerolling && albumListType === 'random') { - return null - } - return <Pagination {...rest} /> -} - -const randomStartingSeed = Math.random().toString() - -const AlbumList = (props) => { - const { width } = props - const shownSeed = useRef(null) - const albumView = useSelector((state) => state.albumView) - const [perPage, perPageOptions] = useAlbumsPerPage(width) - const location = useLocation() - const version = useVersion() - const refresh = useRefresh() - useResourceRefresh('album') - - const seed = `${randomStartingSeed}-${version}` - - const albumListType = location.pathname - .replace(/^\/album/, '') - .replace(/^\//, '') - - // Workaround to force album columns to appear the first time. - // See https://github.com/navidrome/navidrome/pull/923#issuecomment-833004842 - // TODO: Find a better solution - useSetToggleableFields( - 'album', - [ - 'artist', - 'songCount', - 'playCount', - 'year', - 'mood', - 'duration', - 'rating', - 'size', - 'createdAt', - ], - ['createdAt', 'size'], - ) - - // If it does not have filter/sort params (usually coming from Menu), - // reload with correct filter/sort params - if (!location.search) { - const type = albumListType || getStoredDefaultView() - if (isResourceDefaultView(type)) { - return <Redirect to={`/${type}`} /> - } - const listParams = albumLists[type] - if (type === 'random') { - refresh() - } - if (listParams) { - return <Redirect to={`/album/${type}?${listParams.params}`} /> - } - } - - return ( - <> - <List - {...props} - exporter={false} - bulkActionButtons={false} - filter={{ seed }} - actions={<AlbumListActions />} - filters={<AlbumFilter />} - perPage={perPage} - pagination={ - <AlbumListPagination - rowsPerPageOptions={perPageOptions} - albumListType={albumListType} - seed={seed} - shownSeed={shownSeed} - /> - } - title={<AlbumListTitle albumListType={albumListType} />} - > - <ScrollRestorer> - {albumView.grid ? ( - <AlbumGridView - albumListType={albumListType} - seed={seed} - shownSeed={shownSeed} - {...props} - /> - ) : ( - <AlbumTableView {...props} /> - )} - </ScrollRestorer> - </List> - <ExpandInfoDialog content={<AlbumInfo />} /> - </> - ) -} - -const AlbumListWithWidth = withWidth()(AlbumList) - -export default AlbumListWithWidth diff --git a/ui/src/album/AlbumListActions.jsx b/ui/src/album/AlbumListActions.js similarity index 98% rename from ui/src/album/AlbumListActions.jsx rename to ui/src/album/AlbumListActions.js index a4afeee94..45825dd5d 100644 --- a/ui/src/album/AlbumListActions.jsx +++ b/ui/src/album/AlbumListActions.js @@ -64,11 +64,9 @@ const AlbumViewToggler = React.forwardRef( </ButtonGroup> </div> ) - }, + } ) -AlbumViewToggler.displayName = 'AlbumViewToggler' - const AlbumListActions = ({ currentSort, className, diff --git a/ui/src/album/AlbumShow.jsx b/ui/src/album/AlbumShow.js similarity index 85% rename from ui/src/album/AlbumShow.jsx rename to ui/src/album/AlbumShow.js index 86b5ab1bc..92a0833cd 100644 --- a/ui/src/album/AlbumShow.jsx +++ b/ui/src/album/AlbumShow.js @@ -4,13 +4,11 @@ import { ShowContextProvider, useShowContext, useShowController, - Title as RaTitle, } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' import AlbumSongs from './AlbumSongs' import AlbumDetails from './AlbumDetails' import AlbumActions from './AlbumActions' -import { useResourceRefresh, useScrollRestoration, Title } from '../common' const useStyles = makeStyles( (theme) => ({ @@ -20,19 +18,16 @@ const useStyles = makeStyles( }), { name: 'NDAlbumShow', - }, + } ) const AlbumShowLayout = (props) => { const { loading, ...context } = useShowContext(props) const { record } = context const classes = useStyles() - useResourceRefresh('album', 'song') - useScrollRestoration(!!record?.id) return ( <> - {record && <RaTitle title={<Title subTitle={record.name} />} />} {record && <AlbumDetails {...context} />} {record && ( <ReferenceManyField diff --git a/ui/src/album/AlbumSongs.jsx b/ui/src/album/AlbumSongs.js similarity index 87% rename from ui/src/album/AlbumSongs.jsx rename to ui/src/album/AlbumSongs.js index 8a7fd2ae4..ef81bc02c 100644 --- a/ui/src/album/AlbumSongs.jsx +++ b/ui/src/album/AlbumSongs.js @@ -1,12 +1,12 @@ import React, { useMemo } from 'react' import { BulkActionsToolbar, - FunctionField, ListToolbar, - NumberField, TextField, - useListContext, + NumberField, useVersion, + useListContext, + FunctionField, } from 'react-admin' import clsx from 'clsx' import { useDispatch } from 'react-redux' @@ -15,23 +15,22 @@ import { makeStyles } from '@material-ui/core/styles' import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' import { playTracks } from '../actions' import { - ArtistLinkField, - DateField, DurationField, - QualityInfo, - RatingField, - SizeField, SongBulkActions, SongContextMenu, SongDatagrid, SongInfo, SongTitleField, - useResourceRefresh, + RatingField, + QualityInfo, useSelectedFields, + useResourceRefresh, + DateField, + SizeField, + ArtistLinkField, } from '../common' import config from '../config' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' -import { removeAlbumCommentsFromSongs } from './utils.js' const useStyles = makeStyles( (theme) => ({ @@ -84,7 +83,7 @@ const useStyles = makeStyles( visibility: 'hidden', }, }), - { name: 'RaList' }, + { name: 'RaList' } ) const AlbumSongs = (props) => { @@ -98,7 +97,12 @@ const AlbumSongs = (props) => { const toggleableFields = useMemo(() => { return { trackNumber: isDesktop && ( - <TextField source="trackNumber" label="#" sortable={false} /> + <TextField + source="trackNumber" + sortBy="releaseDate asc, discNumber asc, trackNumber asc" + label="#" + sortable={false} + /> ), title: ( <SongTitleField @@ -107,16 +111,13 @@ const AlbumSongs = (props) => { showTrackNumbers={!isDesktop} /> ), - artist: isDesktop && <ArtistLinkField source="artist" sortable={false} />, - composer: isDesktop && ( - <ArtistLinkField source="composer" sortable={false} /> - ), + artist: isDesktop && <ArtistLinkField source="artist" />, duration: <DurationField source="duration" sortable={false} />, year: isDesktop && ( <FunctionField source="year" render={(r) => r.year || ''} - sortable={false} + sortByOrder={'DESC'} /> ), playCount: isDesktop && ( @@ -127,14 +128,6 @@ const AlbumSongs = (props) => { size: isDesktop && <SizeField source="size" sortable={false} />, channels: isDesktop && <NumberField source="channels" sortable={false} />, bpm: isDesktop && <NumberField source="bpm" sortable={false} />, - genre: <TextField source="genre" sortable={false} />, - mood: isDesktop && ( - <FunctionField - source="mood" - render={(r) => r.tags?.mood?.[0] ?? ''} - sortable={false} - /> - ), rating: isDesktop && config.enableStarRating && ( <RatingField resource={'song'} @@ -150,17 +143,7 @@ const AlbumSongs = (props) => { resource: 'albumSong', columns: toggleableFields, omittedColumns: ['title'], - defaultOff: [ - 'composer', - 'channels', - 'bpm', - 'year', - 'playCount', - 'playDate', - 'size', - 'mood', - 'genre', - ], + defaultOff: ['channels', 'bpm', 'year', 'playCount', 'playDate', 'size'], }) const bulkActionsLabel = isDesktop @@ -189,6 +172,7 @@ const AlbumSongs = (props) => { {...props} hasBulkActions={true} showDiscSubtitles={true} + showReleaseDivider={true} contextAlwaysVisible={!isDesktop} classes={{ row: classes.row }} > @@ -214,6 +198,14 @@ const AlbumSongs = (props) => { ) } +export const removeAlbumCommentsFromSongs = ({ album, data }) => { + if (album?.comment && data) { + Object.values(data).forEach((song) => { + song.comment = '' + }) + } +} + const SanitizedAlbumSongs = (props) => { removeAlbumCommentsFromSongs(props) const { loaded, loading, total, ...rest } = useListContext(props) diff --git a/ui/src/album/utils.test.js b/ui/src/album/AlbumSongs.test.js similarity index 92% rename from ui/src/album/utils.test.js rename to ui/src/album/AlbumSongs.test.js index 2ce56b0ad..4007bf8f5 100644 --- a/ui/src/album/utils.test.js +++ b/ui/src/album/AlbumSongs.test.js @@ -1,4 +1,4 @@ -import { removeAlbumCommentsFromSongs } from './utils.js' +import { removeAlbumCommentsFromSongs } from './AlbumSongs' describe('removeAlbumCommentsFromSongs', () => { const data = { 1: { comment: 'one' }, 2: { comment: 'two' } } diff --git a/ui/src/album/AlbumTableView.jsx b/ui/src/album/AlbumTableView.js similarity index 82% rename from ui/src/album/AlbumTableView.jsx rename to ui/src/album/AlbumTableView.js index 051d5988e..b11d55f0e 100644 --- a/ui/src/album/AlbumTableView.jsx +++ b/ui/src/album/AlbumTableView.js @@ -6,7 +6,6 @@ import { DateField, NumberField, TextField, - FunctionField, } from 'react-admin' import { useMediaQuery } from '@material-ui/core' import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' @@ -14,7 +13,6 @@ import { makeStyles } from '@material-ui/core/styles' import { useDrag } from 'react-dnd' import { ArtistLinkField, - ArtworkAvatar, DurationField, RangeField, SimpleList, @@ -25,7 +23,6 @@ import { } from '../common' import config from '../config' import { DraggableTypes } from '../consts' -import clsx from 'clsx' const useStyles = makeStyles({ columnIcon: { @@ -43,9 +40,6 @@ const useStyles = makeStyles({ }, }, }, - missingRow: { - opacity: 0.3, - }, tableCell: { width: '17.5%', }, @@ -58,24 +52,16 @@ const useStyles = makeStyles({ }) const AlbumDatagridRow = (props) => { - const { record, className } = props - const classes = useStyles() + const { record } = props const [, dragAlbumRef] = useDrag( () => ({ type: DraggableTypes.ALBUM, item: { albumIds: [record?.id] }, options: { dropEffect: 'copy' }, }), - [record], - ) - const computedClasses = clsx( - className, - classes.row, - record.missing && classes.missingRow, - ) - return ( - <DatagridRow ref={dragAlbumRef} {...props} className={computedClasses} /> + [record] ) + return <DatagridRow ref={dragAlbumRef} {...props} /> } const AlbumDatagridBody = (props) => ( @@ -109,13 +95,6 @@ const AlbumTableView = ({ year: ( <RangeField source={'year'} sortBy={'max_year'} sortByOrder={'DESC'} /> ), - mood: isDesktop && ( - <FunctionField - source="mood" - render={(r) => r.tags?.mood?.[0] || ''} - sortable={false} - /> - ), duration: isDesktop && <DurationField source="duration" />, size: isDesktop && <SizeField source="size" />, rating: config.enableStarRating && ( @@ -133,7 +112,7 @@ const AlbumTableView = ({ const columns = useSelectedFields({ resource: 'album', columns: toggleableFields, - defaultOff: ['createdAt', 'size', 'mood'], + defaultOff: ['createdAt'], }) return isXsmall ? ( @@ -162,22 +141,17 @@ const AlbumTableView = ({       </> )} - leftIcon={(r) => ( - <span style={{ marginRight: '8px' }}> - <ArtworkAvatar record={r} variant="square" /> - </span> - )} linkType={'show'} rightIcon={(r) => <AlbumContextMenu record={r} />} {...rest} /> ) : ( <AlbumDatagrid rowClick={'show'} classes={{ row: classes.row }} {...rest}> - <ArtworkAvatar source="id" variant="square" /> <TextField source="name" /> {columns} <AlbumContextMenu - source={'starred_at'} + source={'starred'} + sortBy={'starred ASC, starredAt ASC'} sortByOrder={'DESC'} sortable={config.enableFavourites} className={classes.contextMenu} diff --git a/ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap b/ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap deleted file mode 100644 index 706e50a4f..000000000 --- a/ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap +++ /dev/null @@ -1,253 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Details component > Desktop view > renders correctly with all date fields 1`] = ` -<div> - <span> - resources.album.fields.originalDate Mar 15, 2018 - </span> - · - <span> - resources.album.fields.releaseDate Jun 15, 2020 - </span> - · - <span> - 12 resources.song.name - </span> - · - <span> - <span> - 01:00:00 - </span> - </span> - · - <span> - <span - class="makeStyles-root-6" - > - 100 KB - </span> - </span> -</div> -`; - -exports[`Details component > Desktop view > renders correctly with date 1`] = ` -<div> - <span> - May 1, 2020 - </span> - · - <span> - 12 resources.song.name - </span> - · - <span> - <span> - 01:00:00 - </span> - </span> - · - <span> - <span - class="makeStyles-root-2" - > - 100 KB - </span> - </span> -</div> -`; - -exports[`Details component > Desktop view > renders correctly with date and originalDate 1`] = ` -<div> - <span> - resources.album.fields.originalDate Mar 15, 2018 - </span> - · - <span> - 12 resources.song.name - </span> - · - <span> - <span> - 01:00:00 - </span> - </span> - · - <span> - <span - class="makeStyles-root-4" - > - 100 KB - </span> - </span> -</div> -`; - -exports[`Details component > Desktop view > renders correctly with just year range 1`] = ` -<div> - <span> - 12 resources.song.name - </span> - · - <span> - <span> - 01:00:00 - </span> - </span> - · - <span> - <span - class="makeStyles-root-1" - > - 100 KB - </span> - </span> -</div> -`; - -exports[`Details component > Desktop view > renders correctly with originalDate 1`] = ` -<div> - <span> - resources.album.fields.originalDate Mar 15, 2018 - </span> - · - <span> - 12 resources.song.name - </span> - · - <span> - <span> - 01:00:00 - </span> - </span> - · - <span> - <span - class="makeStyles-root-3" - > - 100 KB - </span> - </span> -</div> -`; - -exports[`Details component > Desktop view > renders correctly with releaseDate 1`] = ` -<div> - <span> - resources.album.fields.releaseDate Jun 15, 2020 - </span> - · - <span> - 12 resources.song.name - </span> - · - <span> - <span> - 01:00:00 - </span> - </span> - · - <span> - <span - class="makeStyles-root-5" - > - 100 KB - </span> - </span> -</div> -`; - -exports[`Details component > Mobile view > renders correctly with all date fields 1`] = ` -<div> - <span> - ♫ Mar 15, 2018 - </span> - · - <span> - ○ Jun 15, 2020 - </span> - · - <span> - 12 resources.song.name - </span> -</div> -`; - -exports[`Details component > Mobile view > renders correctly with date 1`] = ` -<div> - <span> - ♫ May 1, 2020 - </span> - · - <span> - 12 resources.song.name - </span> -</div> -`; - -exports[`Details component > Mobile view > renders correctly with date and originalDate 1`] = ` -<div> - <span> - ♫ Mar 15, 2018 - </span> - · - <span> - 12 resources.song.name - </span> -</div> -`; - -exports[`Details component > Mobile view > renders correctly with just year range 1`] = ` -<div> - <span> - 12 resources.song.name - </span> -</div> -`; - -exports[`Details component > Mobile view > renders correctly with no date fields 1`] = ` -<div> - <span> - 12 resources.song.name - </span> -</div> -`; - -exports[`Details component > Mobile view > renders correctly with originalDate 1`] = ` -<div> - <span> - ♫ Mar 15, 2018 - </span> - · - <span> - 12 resources.song.name - </span> -</div> -`; - -exports[`Details component > Mobile view > renders correctly with originalYear range 1`] = ` -<div> - <span> - 12 resources.song.name - </span> -</div> -`; - -exports[`Details component > Mobile view > renders correctly with releaseDate 1`] = ` -<div> - <span> - Jun 15, 2020 - </span> - · - <span> - 12 resources.song.name - </span> -</div> -`; - -exports[`Details component > Mobile view > renders correctly with year range (start and end years) 1`] = ` -<div> - <span> - 12 resources.song.name - </span> -</div> -`; diff --git a/ui/src/album/albumLists.jsx b/ui/src/album/albumLists.js similarity index 100% rename from ui/src/album/albumLists.jsx rename to ui/src/album/albumLists.js diff --git a/ui/src/album/index.jsx b/ui/src/album/index.js similarity index 100% rename from ui/src/album/index.jsx rename to ui/src/album/index.js diff --git a/ui/src/album/useRollChanged.jsx b/ui/src/album/useRollChanged.jsx deleted file mode 100644 index b364bdc6b..000000000 --- a/ui/src/album/useRollChanged.jsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useRef } from 'react' - -// Reports that the albums on screen belong to a different roll than the one loading: only a seed -// change is a re-roll. `shown` is owned above the grid, which a refresh remounts under the new seed. -export const useRollChanged = (shown, seed, loading) => { - const wasLoading = useRef(loading) - - if (!loading && (shown.current === null || wasLoading.current)) { - shown.current = seed - } - wasLoading.current = loading - return shown.current !== seed -} diff --git a/ui/src/album/useRollChanged.test.jsx b/ui/src/album/useRollChanged.test.jsx deleted file mode 100644 index 0745efb08..000000000 --- a/ui/src/album/useRollChanged.test.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks' -import { describe, it, expect } from 'vitest' -import { useRollChanged } from './useRollChanged' - -describe('useRollChanged', () => { - // Passing `shown` in mimics AlbumList owning it across a remount. - const setup = (props, shown = { current: null }) => ({ - shown, - ...renderHook(({ seed, loading }) => useRollChanged(shown, seed, loading), { - initialProps: props, - }), - }) - - it('reports a change while a load is in flight on a fresh mount', () => { - const { result } = setup({ seed: 's1', loading: true }) - expect(result.current).toBe(true) - }) - - it('reports no change when mounted with data already in hand', () => { - const { result } = setup({ seed: 's1', loading: false }) - expect(result.current).toBe(false) - }) - - it('stays false while loading a filter change on the same roll', () => { - const { result, rerender } = setup({ seed: 's1', loading: false }) - rerender({ seed: 's1', loading: true }) - expect(result.current).toBe(false) - }) - - it('goes true while loading after the seed changes', () => { - const { result, rerender } = setup({ seed: 's1', loading: false }) - rerender({ seed: 's2', loading: true }) - expect(result.current).toBe(true) - }) - - it('clears once the new roll has loaded', () => { - const { result, rerender } = setup({ seed: 's1', loading: false }) - rerender({ seed: 's2', loading: true }) - expect(result.current).toBe(true) - rerender({ seed: 's2', loading: false }) - expect(result.current).toBe(false) - }) - - // The seed can land a render before loading flips; the new roll must not be recorded as shown. - it('still reports a change when the seed arrives before loading starts', () => { - const { result, rerender } = setup({ seed: 's1', loading: false }) - rerender({ seed: 's2', loading: false }) - expect(result.current).toBe(true) - }) - - // Refresh remounts the grid under the new seed a render before the refetch starts. - it('reports a change when a refresh remounts the grid before loading starts', () => { - const { shown, unmount } = setup({ seed: 's1', loading: false }) - expect(shown.current).toBe('s1') - unmount() - - const remounted = setup({ seed: 's2', loading: false }, shown) - expect(remounted.result.current).toBe(true) - - remounted.rerender({ seed: 's2', loading: true }) - expect(remounted.result.current).toBe(true) - remounted.rerender({ seed: 's2', loading: false }) - expect(remounted.result.current).toBe(false) - }) - - it('reports no change when a remount keeps the same roll', () => { - const { shown, unmount } = setup({ seed: 's1', loading: false }) - unmount() - - const remounted = setup({ seed: 's1', loading: false }, shown) - expect(remounted.result.current).toBe(false) - }) -}) diff --git a/ui/src/album/utils.js b/ui/src/album/utils.js deleted file mode 100644 index 6d03cef5f..000000000 --- a/ui/src/album/utils.js +++ /dev/null @@ -1,7 +0,0 @@ -export const removeAlbumCommentsFromSongs = ({ album, data }) => { - if (album?.comment && data) { - Object.values(data).forEach((song) => { - song.comment = '' - }) - } -} diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx deleted file mode 100644 index c2614c05a..000000000 --- a/ui/src/artist/ArtistActions.jsx +++ /dev/null @@ -1,197 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import { useDispatch } from 'react-redux' -import { useMediaQuery, CircularProgress } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import { - Button, - TopToolbar, - sanitizeListRestProps, - useDataProvider, - useNotify, - useTranslate, -} from 'react-admin' -import ShuffleIcon from '@material-ui/icons/Shuffle' -import PlayArrowIcon from '@material-ui/icons/PlayArrow' -import ShareIcon from '@material-ui/icons/Share' -import CloudDownloadOutlinedIcon from '@material-ui/icons/CloudDownloadOutlined' -import { IoIosRadio } from 'react-icons/io' -import { playShuffle, playTopSongs } from './actions.js' -import { playSimilar } from '../common/playbackActions.js' -import { - openShareMenu, - openDownloadMenu, - DOWNLOAD_MENU_ARTIST, -} from '../actions' -import config from '../config' -import { formatBytes } from '../utils' -import { artistDownloadSize } from '../common/artist' -import { RefreshMetadataButton } from '../common/RefreshMetadataButton' - -const useStyles = makeStyles((theme) => ({ - toolbar: { - minHeight: 'auto', - padding: '0 !important', - background: 'transparent', - boxShadow: 'none', - alignItems: 'center', - '& .MuiToolbar-root': { - minHeight: 'auto', - padding: '0 !important', - background: 'transparent', - }, - }, - button: { - [theme.breakpoints.down('xs')]: { - minWidth: 'auto', - padding: '8px 12px', - fontSize: '0.75rem', - '& .MuiButton-startIcon': { - marginRight: '4px', - }, - }, - }, - radioIcon: { - [theme.breakpoints.down('xs')]: { - fontSize: '1.5rem', - }, - }, -})) - -const LoadingButton = ({ loading, icon, ...rest }) => ( - <Button {...rest}> - {loading ? <CircularProgress size={20} color="inherit" /> : icon} - </Button> -) - -const ArtistActions = ({ className, record, ...rest }) => { - const dispatch = useDispatch() - const translate = useTranslate() - const dataProvider = useDataProvider() - const notify = useNotify() - const classes = useStyles() - const isMobile = useMediaQuery((theme) => theme.breakpoints.down('xs')) - const [loadingAction, setLoadingAction] = React.useState(null) - const isLoading = !!loadingAction - - const albumArtistSize = artistDownloadSize(record) - const hasAlbumArtistContent = Boolean(albumArtistSize) - - const handlePlay = React.useCallback(async () => { - setLoadingAction('play') - try { - await playTopSongs(dispatch, notify, record.name) - } catch (e) { - // eslint-disable-next-line no-console - console.error('Error fetching top songs for artist:', e) - notify('ra.page.error', 'warning') - } finally { - setLoadingAction(null) - } - }, [dispatch, notify, record]) - - const handleShuffle = React.useCallback(async () => { - setLoadingAction('shuffle') - try { - await playShuffle(dataProvider, dispatch, record.id) - } catch (e) { - // eslint-disable-next-line no-console - console.error('Error fetching songs for shuffle:', e) - notify('ra.page.error', 'warning') - } finally { - setLoadingAction(null) - } - }, [dataProvider, dispatch, record, notify]) - - const handleRadio = React.useCallback(async () => { - setLoadingAction('radio') - try { - await playSimilar(dispatch, notify, record.id) - } catch (e) { - // eslint-disable-next-line no-console - console.error('Error starting radio for artist:', e) - notify('ra.page.error', 'warning') - } finally { - setLoadingAction(null) - } - }, [dispatch, notify, record]) - - const handleShare = React.useCallback(() => { - dispatch(openShareMenu([record.id], 'artist', record.name)) - }, [dispatch, record]) - - const handleDownload = React.useCallback(() => { - dispatch(openDownloadMenu(record, DOWNLOAD_MENU_ARTIST)) - }, [dispatch, record]) - - return ( - <TopToolbar - className={`${className} ${classes.toolbar}`} - {...sanitizeListRestProps(rest)} - > - <LoadingButton - onClick={handlePlay} - label={translate('resources.artist.actions.topSongs')} - className={classes.button} - size={isMobile ? 'small' : 'medium'} - disabled={isLoading} - loading={loadingAction === 'play'} - icon={<PlayArrowIcon />} - /> - <LoadingButton - onClick={handleShuffle} - label={translate('resources.artist.actions.shuffle')} - className={classes.button} - size={isMobile ? 'small' : 'medium'} - disabled={isLoading} - loading={loadingAction === 'shuffle'} - icon={<ShuffleIcon />} - /> - <LoadingButton - onClick={handleRadio} - label={translate('resources.artist.actions.radio')} - className={classes.button} - size={isMobile ? 'small' : 'medium'} - disabled={isLoading} - loading={loadingAction === 'radio'} - icon={<IoIosRadio className={classes.radioIcon} />} - /> - {config.enableSharing && hasAlbumArtistContent && ( - <LoadingButton - onClick={handleShare} - label={translate('ra.action.share')} - className={classes.button} - size={isMobile ? 'small' : 'medium'} - icon={<ShareIcon />} - /> - )} - {config.enableDownloads && hasAlbumArtistContent && ( - <LoadingButton - onClick={handleDownload} - label={`${translate('ra.action.download')} (${formatBytes( - albumArtistSize, - )})`} - className={classes.button} - size={isMobile ? 'small' : 'medium'} - icon={<CloudDownloadOutlinedIcon />} - /> - )} - <RefreshMetadataButton - resource="artist" - record={record} - size={isMobile ? 'small' : 'medium'} - /> - </TopToolbar> - ) -} - -ArtistActions.propTypes = { - className: PropTypes.string, - record: PropTypes.object.isRequired, -} - -ArtistActions.defaultProps = { - className: '', -} - -export default ArtistActions diff --git a/ui/src/artist/ArtistActions.test.jsx b/ui/src/artist/ArtistActions.test.jsx deleted file mode 100644 index 05f53f835..000000000 --- a/ui/src/artist/ArtistActions.test.jsx +++ /dev/null @@ -1,321 +0,0 @@ -import React from 'react' -import { render, fireEvent, waitFor, screen } from '@testing-library/react' -import { TestContext } from 'ra-test' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import ArtistActions from './ArtistActions' -import subsonic from '../subsonic' -import { - openShareMenu, - openDownloadMenu, - DOWNLOAD_MENU_ARTIST, -} from '../actions' -import { ThemeProvider, createTheme } from '@material-ui/core/styles' - -const mockDispatch = vi.fn() -vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch })) - -vi.mock('../subsonic', () => ({ - default: { getSimilarSongs2: vi.fn(), getTopSongs: vi.fn() }, -})) - -const { mockConfig, mockPermissions } = vi.hoisted(() => ({ - mockConfig: { enableSharing: true, enableDownloads: true }, - mockPermissions: { value: 'admin' }, -})) -vi.mock('../config', () => ({ default: mockConfig })) - -const mockNotify = vi.fn() -const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] }) -const mockRefreshMetadata = vi.fn().mockResolvedValue({ data: { id: 'ar1' } }) - -vi.mock('react-admin', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useNotify: () => mockNotify, - useDataProvider: () => ({ - getList: mockGetList, - refreshMetadata: mockRefreshMetadata, - }), - usePermissions: () => ({ permissions: mockPermissions.value }), - useTranslate: () => (x) => x, - } -}) - -describe('ArtistActions', () => { - const defaultRecord = { - id: 'ar1', - name: 'Artist', - stats: { albumartist: { songCount: 3, albumCount: 1, size: 1024 } }, - } - - const renderArtistActions = (record = defaultRecord) => { - const theme = createTheme() - return render( - <TestContext> - <ThemeProvider theme={theme}> - <ArtistActions record={record} /> - </ThemeProvider> - </TestContext>, - ) - } - - const clickActionButton = (actionKey) => { - fireEvent.click(screen.getByText(`resources.artist.actions.${actionKey}`)) - } - - beforeEach(() => { - vi.clearAllMocks() - // Mock console.error to suppress error logging in tests - vi.spyOn(console, 'error').mockImplementation(() => {}) - mockConfig.enableSharing = true - mockConfig.enableDownloads = true - mockPermissions.value = 'admin' - - const songWithReplayGain = { - id: 'rec1', - replayGain: { - albumGain: -5, - albumPeak: 1, - trackGain: -6, - trackPeak: 0.8, - }, - } - - subsonic.getSimilarSongs2.mockResolvedValue({ - json: { - 'subsonic-response': { - status: 'ok', - similarSongs2: { song: [songWithReplayGain] }, - }, - }, - }) - subsonic.getTopSongs.mockResolvedValue({ - json: { - 'subsonic-response': { - status: 'ok', - topSongs: { song: [songWithReplayGain] }, - }, - }, - }) - }) - - describe('Shuffle action', () => { - it('shuffles songs when clicked', async () => { - renderArtistActions() - clickActionButton('shuffle') - - await waitFor(() => - expect(mockGetList).toHaveBeenCalledWith('song', { - pagination: { page: 1, perPage: 500 }, - sort: { field: 'random', order: 'ASC' }, - filter: { album_artist_id: 'ar1', missing: false }, - }), - ) - expect(mockDispatch).toHaveBeenCalled() - }) - }) - - describe('Radio action', () => { - it('starts radio when clicked', async () => { - renderArtistActions() - clickActionButton('radio') - - await waitFor(() => - expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('ar1', 100), - ) - expect(mockDispatch).toHaveBeenCalled() - }) - - it('maps replaygain info', async () => { - renderArtistActions() - clickActionButton('radio') - - await waitFor(() => - expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('ar1', 100), - ) - const action = mockDispatch.mock.calls[0][0] - expect(action.data.rec1).toMatchObject({ - rgAlbumGain: -5, - rgAlbumPeak: 1, - rgTrackGain: -6, - rgTrackPeak: 0.8, - }) - }) - }) - - describe('Play action', () => { - it('plays top songs when clicked', async () => { - renderArtistActions() - clickActionButton('topSongs') - - await waitFor(() => - expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100), - ) - expect(mockDispatch).toHaveBeenCalled() - }) - - it('maps replaygain info for top songs', async () => { - renderArtistActions() - clickActionButton('topSongs') - - await waitFor(() => - expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100), - ) - const action = mockDispatch.mock.calls[0][0] - expect(action.data.rec1).toMatchObject({ - rgAlbumGain: -5, - rgAlbumPeak: 1, - rgTrackGain: -6, - rgTrackPeak: 0.8, - }) - }) - - it('handles API rejection', async () => { - subsonic.getTopSongs.mockRejectedValue(new Error('Network error')) - - renderArtistActions() - clickActionButton('topSongs') - - await waitFor(() => - expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100), - ) - expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning') - expect(mockDispatch).not.toHaveBeenCalled() - }) - - it('handles failed API response', async () => { - subsonic.getTopSongs.mockResolvedValue({ - json: { - 'subsonic-response': { - status: 'failed', - error: { code: 40, message: 'Wrong username or password' }, - }, - }, - }) - - renderArtistActions() - clickActionButton('topSongs') - - await waitFor(() => - expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100), - ) - expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning') - expect(mockDispatch).not.toHaveBeenCalled() - }) - - it('handles empty song list', async () => { - subsonic.getTopSongs.mockResolvedValue({ - json: { - 'subsonic-response': { - status: 'ok', - topSongs: { song: [] }, - }, - }, - }) - - renderArtistActions() - clickActionButton('topSongs') - - await waitFor(() => - expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100), - ) - expect(mockNotify).toHaveBeenCalledWith( - 'message.noTopSongsFound', - 'warning', - ) - expect(mockDispatch).not.toHaveBeenCalled() - }) - - it('handles missing topSongs property', async () => { - subsonic.getTopSongs.mockResolvedValue({ - json: { - 'subsonic-response': { - status: 'ok', - // topSongs property is missing - }, - }, - }) - - renderArtistActions() - clickActionButton('topSongs') - - await waitFor(() => - expect(subsonic.getTopSongs).toHaveBeenCalledWith('Artist', 100), - ) - expect(mockNotify).toHaveBeenCalledWith( - 'message.noTopSongsFound', - 'warning', - ) - expect(mockDispatch).not.toHaveBeenCalled() - }) - }) - - describe('Share action', () => { - it('shows the share button and dispatches openShareMenu when clicked', () => { - renderArtistActions() - fireEvent.click(screen.getByText('ra.action.share')) - expect(mockDispatch).toHaveBeenCalledWith( - openShareMenu(['ar1'], 'artist', 'Artist'), - ) - }) - - it('hides the share button when sharing is disabled', () => { - mockConfig.enableSharing = false - renderArtistActions() - expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() - }) - }) - - describe('Download action', () => { - it('shows the download button with album-artist size and dispatches openDownloadMenu when clicked', () => { - renderArtistActions() - expect(screen.getByText('ra.action.download (1 KB)')).toBeInTheDocument() - fireEvent.click(screen.getByText(/ra\.action\.download/)) - expect(mockDispatch).toHaveBeenCalledWith( - openDownloadMenu(defaultRecord, DOWNLOAD_MENU_ARTIST), - ) - }) - - it('hides the download button when downloads are disabled', () => { - mockConfig.enableDownloads = false - renderArtistActions() - expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() - }) - }) - - describe('Album-artist gating', () => { - it('hides Share and Download for artists with no album-artist content', () => { - renderArtistActions({ id: 'ar1', name: 'Artist', stats: {} }) - expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() - expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() - }) - - it('hides Share and Download for a missing artist', () => { - renderArtistActions({ ...defaultRecord, missing: true }) - expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() - expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() - }) - }) - - describe('Refresh metadata action', () => { - const refreshLabel = 'resources.album.actions.refresh' - - it('refreshes the artist metadata for admins', async () => { - renderArtistActions() - fireEvent.click(screen.getByRole('button', { name: refreshLabel })) - - await waitFor(() => - expect(mockRefreshMetadata).toHaveBeenCalledWith('artist', 'ar1'), - ) - }) - - it('hides the action for non-admin users', () => { - mockPermissions.value = 'regular' - renderArtistActions() - expect( - screen.queryByRole('button', { name: refreshLabel }), - ).not.toBeInTheDocument() - }) - }) -}) diff --git a/ui/src/artist/ArtistExternalLink.jsx b/ui/src/artist/ArtistExternalLink.js similarity index 52% rename from ui/src/artist/ArtistExternalLink.jsx rename to ui/src/artist/ArtistExternalLink.js index 6595d1fbc..37faddc28 100644 --- a/ui/src/artist/ArtistExternalLink.jsx +++ b/ui/src/artist/ArtistExternalLink.js @@ -4,24 +4,24 @@ import { IconButton, Tooltip, Link } from '@material-ui/core' import { ImLastfm2 } from 'react-icons/im' import MusicBrainz from '../icons/MusicBrainz' -import { intersperse, isLastFmURL } from '../utils' +import { intersperse } from '../utils' import config from '../config' -import { makeStyles } from '@material-ui/core/styles' - -const useStyles = makeStyles({ - linkBar: { - minHeight: '1.875em', - }, -}) const ArtistExternalLinks = ({ artistInfo, record }) => { - const classes = useStyles() const translate = useTranslate() + let links = [] let linkButtons = [] const lastFMlink = artistInfo?.biography?.match( - /<a\s+(?:[^>]*?\s+)?href=(["'])(.*?)\1/, + /<a\s+(?:[^>]*?\s+)?href=(["'])(.*?)\1/ ) + if (lastFMlink) { + links.push(lastFMlink[2]) + } + if (artistInfo && artistInfo.musicBrainzId) { + links.push(`https://musicbrainz.org/artist/${artistInfo.musicBrainzId}`) + } + const addLink = (url, title, icon) => { const translatedTitle = translate(title) const link = ( @@ -38,29 +38,21 @@ const ArtistExternalLinks = ({ artistInfo, record }) => { } if (config.lastFMEnabled) { - // No agent may be enabled to supply a canonical URL, so fall back to a name-based one. - const lastFMUrl = - (lastFMlink && isLastFmURL(lastFMlink[2]) && lastFMlink[2]) || - (isLastFmURL(artistInfo?.lastFmUrl) && artistInfo.lastFmUrl) || - (record.name && - `https://last.fm/music/${encodeURIComponent(record.name)}`) - if (lastFMUrl) { - addLink( - lastFMUrl, - 'message.openIn.lastfm', - <ImLastfm2 className="lastfm-icon" />, - ) - } + addLink( + links[0], + 'message.openIn.lastfm', + <ImLastfm2 className="lastfm-icon" /> + ) } artistInfo?.musicBrainzId && addLink( - `https://musicbrainz.org/artist/${artistInfo.musicBrainzId}`, + links[1], 'message.openIn.musicbrainz', - <MusicBrainz className="musicbrainz-icon" />, + <MusicBrainz className="musicbrainz-icon" /> ) - return <div className={classes.linkBar}>{intersperse(linkButtons, ' ')}</div> + return <div>{intersperse(linkButtons, ' ')}</div> } export default ArtistExternalLinks diff --git a/ui/src/artist/ArtistExternalLink.test.jsx b/ui/src/artist/ArtistExternalLink.test.jsx deleted file mode 100644 index 4214c21ea..000000000 --- a/ui/src/artist/ArtistExternalLink.test.jsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { TestContext } from 'ra-test' -import ArtistExternalLinks from './ArtistExternalLink' - -const { mockConfig } = vi.hoisted(() => ({ - mockConfig: { lastFMEnabled: true }, -})) -vi.mock('../config', () => ({ default: mockConfig })) - -describe('ArtistExternalLinks', () => { - beforeEach(() => { - mockConfig.lastFMEnabled = true - }) - - const renderLinks = (artistInfo, record = { id: 'ar-1', name: 'Björk' }) => - render( - <TestContext> - <ArtistExternalLinks artistInfo={artistInfo} record={record} /> - </TestContext>, - ) - - const lastFmHref = () => - screen.getByLabelText('message.openIn.lastfm').closest('a').href - - it('uses the URL returned by the server', () => { - renderLinks({ lastFmUrl: 'https://www.last.fm/music/Bjork' }) - expect(lastFmHref()).toBe('https://www.last.fm/music/Bjork') - }) - - it('uses the URL found in the biography', () => { - renderLinks({ - biography: 'Read more on <a href="https://www.last.fm/music/Bjork">', - lastFmUrl: 'https://bjork.com', - }) - expect(lastFmHref()).toBe('https://www.last.fm/music/Bjork') - }) - - it('builds the URL from the artist name when the server has none', () => { - renderLinks({ lastFmUrl: 'https://bjork.com' }) - expect(lastFmHref()).toBe('https://last.fm/music/Bj%C3%B6rk') - }) - - it('builds the URL when there is no artist info at all', () => { - renderLinks(undefined) - expect(lastFmHref()).toBe('https://last.fm/music/Bj%C3%B6rk') - }) - - it('shows no Last.fm link when Last.fm is disabled', () => { - mockConfig.lastFMEnabled = false - renderLinks({ lastFmUrl: 'https://www.last.fm/music/Bjork' }) - expect(screen.queryByLabelText('message.openIn.lastfm')).toBeNull() - }) - - it('shows no Last.fm link when the artist has no name', () => { - renderLinks({}, { id: 'ar-1', name: '' }) - expect(screen.queryByLabelText('message.openIn.lastfm')).toBeNull() - }) -}) diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.js similarity index 59% rename from ui/src/artist/ArtistList.jsx rename to ui/src/artist/ArtistList.js index f4147221e..dbc0e0ebd 100644 --- a/ui/src/artist/ArtistList.jsx +++ b/ui/src/artist/ArtistList.js @@ -1,40 +1,36 @@ -import { useMemo } from 'react' +import React, { useMemo } from 'react' import { useHistory } from 'react-router-dom' import { + AutocompleteInput, Datagrid, DatagridBody, DatagridRow, Filter, - FunctionField, NumberField, + ReferenceInput, SearchInput, - SelectInput, TextField, useTranslate, - NullableBooleanInput, - usePermissions, } from 'react-admin' import { useMediaQuery, withWidth } from '@material-ui/core' import FavoriteIcon from '@material-ui/icons/Favorite' import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' import { makeStyles } from '@material-ui/core/styles' import { useDrag } from 'react-dnd' -import clsx from 'clsx' import { ArtistContextMenu, - ArtworkAvatar, List, + QuickFilter, useGetHandleArtistClick, + ArtistSimpleList, RatingField, useSelectedFields, useResourceRefresh, + SizeField, } from '../common' import config from '../config' import ArtistListActions from './ArtistListActions' -import ArtistSimpleList from './ArtistSimpleList' import { DraggableTypes } from '../consts' -import en from '../i18n/en.json' -import { formatBytes } from '../utils/index.js' const useStyles = makeStyles({ contextHeader: { @@ -43,10 +39,6 @@ const useStyles = makeStyles({ verticalAlign: 'text-top', }, row: { - '& td': { - paddingTop: '4px !important', - paddingBottom: '4px !important', - }, '&:hover': { '& $contextMenu': { visibility: 'visible', @@ -56,9 +48,6 @@ const useStyles = makeStyles({ }, }, }, - missingRow: { - opacity: 0.3, - }, contextMenu: { visibility: 'hidden', }, @@ -69,30 +58,26 @@ const useStyles = makeStyles({ const ArtistFilter = (props) => { const translate = useTranslate() - const { permissions } = usePermissions() - const isAdmin = permissions === 'admin' - const rolesObj = en?.resources?.artist?.roles - const roles = Object.keys(rolesObj).reduce((acc, role) => { - acc.push({ - id: role, - name: translate(`resources.artist.roles.${role}`, { - smart_count: 2, - }), - }) - return acc - }, []) - roles?.sort((a, b) => a.name.localeCompare(b.name)) return ( <Filter {...props} variant={'outlined'}> <SearchInput id="search" source="name" alwaysOn /> - <SelectInput source="role" choices={roles} alwaysOn /> + <ReferenceInput + label={translate('resources.artist.fields.genre')} + source="genre_id" + reference="genre" + perPage={0} + sort={{ field: 'name', order: 'ASC' }} + filterToQuery={(searchText) => ({ name: [searchText] })} + > + <AutocompleteInput emptyText="-- None --" /> + </ReferenceInput> {config.enableFavourites && ( - <NullableBooleanInput + <QuickFilter source="starred" label={<FavoriteIcon fontSize={'small'} />} + defaultValue={true} /> )} - {isAdmin && <NullableBooleanInput source="missing" />} </Filter> ) } @@ -105,17 +90,9 @@ const ArtistDatagridRow = (props) => { item: { artistIds: [record?.id] }, options: { dropEffect: 'copy' }, }), - [record], - ) - const classes = useStyles() - const computedClasses = clsx( - props.className, - classes.row, - record?.missing && classes.missingRow, - ) - return ( - <DatagridRow ref={dragArtistRef} {...props} className={computedClasses} /> + [record] ) + return <DatagridRow ref={dragArtistRef} {...props} /> } const ArtistDatagridBody = (props) => ( @@ -127,27 +104,17 @@ const ArtistDatagrid = (props) => ( ) const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => { - const { filterValues } = rest const classes = useStyles() const handleArtistLink = useGetHandleArtistClick(width) const history = useHistory() const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) useResourceRefresh('artist') - const role = filterValues?.role - const getCounter = (record, counter) => { - if (!record) return undefined - return role ? record?.stats?.[role]?.[counter] : record?.[counter] - } - const getAlbumCount = (record) => getCounter(record, 'albumCount') - const getSongCount = (record) => getCounter(record, 'songCount') - const getSize = (record) => { - const size = getCounter(record, 'size') - return size ? formatBytes(size) : '0 MB' - } - - const toggleableFields = useMemo( - () => ({ + const toggleableFields = useMemo(() => { + return { + albumCount: <NumberField source="albumCount" sortByOrder={'DESC'} />, + songCount: <NumberField source="songCount" sortByOrder={'DESC'} />, + size: !isXsmall && <SizeField source="size" />, playCount: <NumberField source="playCount" sortByOrder={'DESC'} />, rating: config.enableStarRating && ( <RatingField @@ -157,14 +124,16 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => { className={classes.ratingField} /> ), - }), - [classes.ratingField], - ) + } + }, [classes.ratingField, isXsmall]) - const columns = useSelectedFields({ - resource: 'artist', - columns: toggleableFields, - }) + const columns = useSelectedFields( + { + resource: 'artist', + columns: toggleableFields, + }, + ['size'] + ) return isXsmall ? ( <ArtistSimpleList @@ -173,22 +142,11 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => { /> ) : ( <ArtistDatagrid rowClick={handleArtistLink} classes={{ row: classes.row }}> - <ArtworkAvatar source="id" /> <TextField source="name" /> - <FunctionField - source="albumCount" - sortByOrder={'DESC'} - render={getAlbumCount} - /> - <FunctionField - source="songCount" - sortByOrder={'DESC'} - render={getSongCount} - /> - <FunctionField source="size" sortByOrder={'DESC'} render={getSize} /> {columns} <ArtistContextMenu - source={'starred_at'} + source={'starred'} + sortBy={'starred ASC, starredAt ASC'} sortByOrder={'DESC'} sortable={config.enableFavourites} className={classes.contextMenu} @@ -214,7 +172,6 @@ const ArtistList = (props) => { exporter={false} bulkActionButtons={false} filters={<ArtistFilter />} - filterDefaultValues={{ role: 'albumartist' }} actions={<ArtistListActions />} > <ArtistListView {...props} /> @@ -223,6 +180,4 @@ const ArtistList = (props) => { ) } -const ArtistListWithWidth = withWidth()(ArtistList) - -export default ArtistListWithWidth +export default withWidth()(ArtistList) diff --git a/ui/src/artist/ArtistListActions.jsx b/ui/src/artist/ArtistListActions.js similarity index 100% rename from ui/src/artist/ArtistListActions.jsx rename to ui/src/artist/ArtistListActions.js diff --git a/ui/src/artist/ArtistShow.js b/ui/src/artist/ArtistShow.js new file mode 100644 index 000000000..60a57e4d9 --- /dev/null +++ b/ui/src/artist/ArtistShow.js @@ -0,0 +1,84 @@ +import React, { useState, createElement, useEffect } from 'react' +import { useMediaQuery } from '@material-ui/core' +import { + useShowController, + ShowContextProvider, + useRecordContext, + useShowContext, + ReferenceManyField, +} from 'react-admin' +import subsonic from '../subsonic' +import AlbumGridView from '../album/AlbumGridView' +import MobileArtistDetails from './MobileArtistDetails' +import DesktopArtistDetails from './DesktopArtistDetails' + +const ArtistDetails = (props) => { + const record = useRecordContext(props) + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('sm')) + const [artistInfo, setArtistInfo] = useState() + + const biography = + artistInfo?.biography?.replace(new RegExp('<.*>', 'g'), '') || + record.biography + + useEffect(() => { + subsonic + .getArtistInfo(record.id) + .then((resp) => resp.json['subsonic-response']) + .then((data) => { + if (data.status === 'ok') { + setArtistInfo(data.artistInfo) + } + }) + .catch((e) => { + console.error('error on artist page', e) + }) + }, [record.id]) + + const component = isDesktop ? DesktopArtistDetails : MobileArtistDetails + return ( + <> + {createElement(component, { + artistInfo, + record, + biography, + })} + </> + ) +} + +const AlbumShowLayout = (props) => { + const showContext = useShowContext(props) + const record = useRecordContext() + + return ( + <> + {record && <ArtistDetails />} + {record && ( + <ReferenceManyField + {...showContext} + addLabel={false} + reference="album" + target="artist_id" + sort={{ field: 'max_year asc,date asc', order: 'ASC' }} + filter={{ artist_id: record?.id }} + perPage={0} + pagination={null} + > + <AlbumGridView {...props} /> + </ReferenceManyField> + )} + </> + ) +} + +const ArtistShow = (props) => { + const controllerProps = useShowController(props) + return ( + <ShowContextProvider value={controllerProps}> + <AlbumShowLayout {...controllerProps} /> + </ShowContextProvider> + ) +} + +export default ArtistShow diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx deleted file mode 100644 index 515c1ab08..000000000 --- a/ui/src/artist/ArtistShow.jsx +++ /dev/null @@ -1,151 +0,0 @@ -import { useState, useEffect } from 'react' -import { useMediaQuery, withWidth } from '@material-ui/core' -import { - useShowController, - ShowContextProvider, - useRecordContext, - useShowContext, - ReferenceManyField, - Pagination, - Title as RaTitle, -} from 'react-admin' -import subsonic from '../subsonic' -import AlbumGridView from '../album/AlbumGridView' -import MobileArtistDetails from './MobileArtistDetails' -import DesktopArtistDetails from './DesktopArtistDetails' -import { - useAlbumsPerPage, - useResourceRefresh, - useScrollRestoration, - Title, -} from '../common/index.js' -import ArtistActions from './ArtistActions' -import { makeStyles } from '@material-ui/core' - -const useStyles = makeStyles( - (theme) => ({ - actions: { - width: '100%', - justifyContent: 'flex-start', - display: 'flex', - paddingTop: '0.25em', - paddingBottom: '0.25em', - paddingLeft: '1em', - paddingRight: '1em', - flexWrap: 'wrap', - overflowX: 'auto', - [theme.breakpoints.down('xs')]: { - paddingLeft: '0.5em', - paddingRight: '0.5em', - gap: '0.5em', - justifyContent: 'space-around', - }, - }, - actionsContainer: { - paddingLeft: '.75rem', - [theme.breakpoints.down('xs')]: { - padding: '.5rem', - }, - }, - }), - { - name: 'NDArtistShow', - }, -) - -export const ArtistDetails = (props) => { - const record = useRecordContext(props) - const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('sm'), { - noSsr: true, - }) - const [artistInfo, setArtistInfo] = useState() - - const biography = artistInfo?.biography || record.biography - - useEffect(() => { - subsonic - .getArtistInfo(record.id) - .then((resp) => resp.json['subsonic-response']) - .then((data) => { - if (data.status === 'ok') { - setArtistInfo(data.artistInfo) - } - }) - .catch((e) => { - // eslint-disable-next-line no-console - console.error('error on artist page', e) - }) - // Keyed on the record, not its id: a refreshed record must re-fetch, or the stale - // artistInfo state keeps winning the `||` above. - }, [record]) - - const Component = isDesktop ? DesktopArtistDetails : MobileArtistDetails - return ( - <Component artistInfo={artistInfo} record={record} biography={biography} /> - ) -} - -const ArtistShowLayout = (props) => { - const showContext = useShowContext(props) - const record = useRecordContext() - const { width } = props - const [, perPageOptions] = useAlbumsPerPage(width) - const classes = useStyles() - useResourceRefresh('artist', 'album') - useScrollRestoration(!!record?.id) - - const maxPerPage = 90 - let perPage = 0 - let pagination = null - - // Use the main credit count instead of total count, as this is a precise measure - // of the number of albums where the artist is credited as an album artist OR - // artist - const count = record?.stats?.['maincredit']?.albumCount || 0 - - if (count > maxPerPage) { - perPage = Math.trunc(maxPerPage / perPageOptions[0]) * perPageOptions[0] - const rowsPerPageOptions = [1, 2, 3].map((option) => - Math.trunc(option * (perPage / 3)), - ) - // react-admin's Pagination on purpose: the common one would persist 30/60/90 under the album grid's key - pagination = <Pagination rowsPerPageOptions={rowsPerPageOptions} /> - } - - return ( - <> - {record && <RaTitle title={<Title subTitle={record.name} />} />} - {record && <ArtistDetails />} - {record && ( - <div className={classes.actionsContainer}> - <ArtistActions record={record} className={classes.actions} /> - </div> - )} - {record && ( - <ReferenceManyField - {...showContext} - addLabel={false} - reference="album" - target="artist_id" - sort={{ field: 'max_year', order: 'ASC' }} - filter={{ artist_id: record?.id }} - perPage={perPage} - pagination={pagination} - > - <AlbumGridView {...props} /> - </ReferenceManyField> - )} - </> - ) -} - -const ArtistShow = withWidth()((props) => { - const controllerProps = useShowController(props) - return ( - <ShowContextProvider value={controllerProps}> - <ArtistShowLayout {...controllerProps} /> - </ShowContextProvider> - ) -}) - -export default ArtistShow diff --git a/ui/src/artist/ArtistShow.test.jsx b/ui/src/artist/ArtistShow.test.jsx deleted file mode 100644 index f51cde5ed..000000000 --- a/ui/src/artist/ArtistShow.test.jsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react' -import { render, waitFor } from '@testing-library/react' -import { RecordContextProvider } from 'react-admin' -import { ThemeProvider, createTheme } from '@material-ui/core/styles' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { ArtistDetails } from './ArtistShow' -import subsonic from '../subsonic' - -vi.mock('../subsonic', () => ({ - default: { getArtistInfo: vi.fn(), getCoverArtUrl: vi.fn() }, -})) - -// Not under test here: isolate ArtistDetails from the leaf presentational views. -vi.mock('./DesktopArtistDetails', () => ({ default: () => null })) -vi.mock('./MobileArtistDetails', () => ({ default: () => null })) - -const mockGetArtistInfo = subsonic.getArtistInfo - -describe('ArtistDetails', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetArtistInfo.mockResolvedValue({ - json: { - 'subsonic-response': { - status: 'ok', - artistInfo: { biography: 'fetched' }, - }, - }, - }) - }) - - const theme = createTheme() - - const wrap = (record) => ( - <ThemeProvider theme={theme}> - <RecordContextProvider value={record}> - <ArtistDetails /> - </RecordContextProvider> - </ThemeProvider> - ) - - const renderDetails = (record) => render(wrap(record)) - - it('re-fetches the artist info when the record object changes', async () => { - const record = { id: 'ar1', name: 'Artist', biography: 'old' } - const { rerender } = renderDetails(record) - await waitFor(() => expect(mockGetArtistInfo).toHaveBeenCalledTimes(1)) - - rerender(wrap({ ...record, biography: 'new' })) - - await waitFor(() => expect(mockGetArtistInfo).toHaveBeenCalledTimes(2)) - }) - - it('does not re-fetch when the same record object is passed again', async () => { - const record = { id: 'ar1', name: 'Artist', biography: 'old' } - const { rerender } = renderDetails(record) - await waitFor(() => expect(mockGetArtistInfo).toHaveBeenCalledTimes(1)) - - rerender(wrap(record)) - - expect(mockGetArtistInfo).toHaveBeenCalledTimes(1) - }) -}) diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.js similarity index 77% rename from ui/src/artist/DesktopArtistDetails.jsx rename to ui/src/artist/DesktopArtistDetails.js index 9269dc8d7..d49eff8fd 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.js @@ -3,15 +3,14 @@ import { Typography, Collapse } from '@material-ui/core' import { makeStyles } from '@material-ui/core' import Card from '@material-ui/core/Card' import CardContent from '@material-ui/core/CardContent' +import CardMedia from '@material-ui/core/CardMedia' import ArtistExternalLinks from './ArtistExternalLink' import config from '../config' -import { LoveButton, RatingField, ImageUploadOverlay } from '../common' +import { LoveButton, RatingField } from '../common' import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' import subsonic from '../subsonic' -import { SafeHTML } from '../common/SafeHTML' -import { Artwork } from '../common/Artwork' const useStyles = makeStyles( (theme) => ({ @@ -30,7 +29,6 @@ const useStyles = makeStyles( float: 'left', wordBreak: 'break-word', cursor: 'pointer', - minHeight: '4.5em', }, content: { flex: '1 0 auto', @@ -40,21 +38,12 @@ const useStyles = makeStyles( height: '12rem', borderRadius: '6em', cursor: 'pointer', - backgroundColor: 'transparent', - transition: 'opacity 0.3s ease-in-out', - objectFit: 'cover', }, artistImage: { maxHeight: '12rem', - minHeight: '12rem', - width: '12rem', - minWidth: '12rem', backgroundColor: 'inherit', display: 'flex', - alignItems: 'center', - justifyContent: 'center', boxShadow: 'none', - position: 'relative', }, artistDetail: { flex: '1', @@ -76,30 +65,33 @@ const useStyles = makeStyles( wordBreak: 'break-word', }, }), - { name: 'NDDesktopArtistDetails' }, + { name: 'NDDesktopArtistDetails' } ) const DesktopArtistDetails = ({ artistInfo, record, biography }) => { const [expanded, setExpanded] = useState(false) const classes = useStyles() const title = record.name - const [isLightboxOpen, setLightboxOpen] = useState(false) + const [isLightboxOpen, setLightboxOpen] = React.useState(false) + + const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), []) + const handleCloseLightbox = React.useCallback( + () => setLightboxOpen(false), + [] + ) return ( <div className={classes.root}> <Card className={classes.artistDetail}> <Card className={classes.artistImage}> - <Artwork - record={record} - className={classes.cover} - title={title} - onClick={() => setLightboxOpen(true)} - /> - <ImageUploadOverlay - entityType="artist" - entityId={record.id} - hasUploadedImage={!!record.uploadedImage} - /> + {artistInfo && ( + <CardMedia + className={classes.cover} + image={subsonic.getCoverArtUrl(record, 300)} + onClick={handleOpenLightbox} + title={title} + /> + )} </Card> <div className={classes.details}> <CardContent className={classes.content}> @@ -138,9 +130,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { variant={'body1'} onClick={() => setExpanded(!expanded)} > - <span> - <SafeHTML>{biography}</SafeHTML> - </span> + <span dangerouslySetInnerHTML={{ __html: biography }} /> </Typography> </Collapse> </CardContent> @@ -156,7 +146,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { animationDuration={200} imageTitle={record.name} mainSrc={subsonic.getCoverArtUrl(record)} - onCloseRequest={() => setLightboxOpen(false)} + onCloseRequest={handleCloseLightbox} /> )} </Card> diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.js similarity index 76% rename from ui/src/artist/MobileArtistDetails.jsx rename to ui/src/artist/MobileArtistDetails.js index 0925b6f10..683280d9e 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.js @@ -2,12 +2,11 @@ import React, { useState } from 'react' import { Typography, Collapse } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import Card from '@material-ui/core/Card' +import CardMedia from '@material-ui/core/CardMedia' import config from '../config' -import { LoveButton, RatingField, ImageUploadOverlay } from '../common' +import { LoveButton, RatingField } from '../common' import Lightbox from 'react-image-lightbox' import subsonic from '../subsonic' -import { SafeHTML } from '../common/SafeHTML' -import { Artwork } from '../common/Artwork' const useStyles = makeStyles( (theme) => ({ @@ -51,9 +50,6 @@ const useStyles = makeStyles( width: 151, boxShadow: '0px 0px 6px 0px #565656', borderRadius: '5px', - backgroundColor: 'transparent', - transition: 'opacity 0.3s ease-in-out', - objectFit: 'cover', }, artistImage: { marginLeft: '1em', @@ -64,7 +60,6 @@ const useStyles = makeStyles( minWidth: '7rem', display: 'flex', borderRadius: '5em', - position: 'relative', }, loveButton: { top: theme.spacing(-0.2), @@ -77,32 +72,35 @@ const useStyles = makeStyles( wordBreak: 'break-word', }, }), - { name: 'NDMobileArtistDetails' }, + { name: 'NDMobileArtistDetails' } ) -const MobileArtistDetails = ({ biography, record }) => { - const img = subsonic.getCoverArtUrl(record, 800) +const MobileArtistDetails = ({ artistInfo, biography, record }) => { + const img = subsonic.getCoverArtUrl(record) const [expanded, setExpanded] = useState(false) const classes = useStyles({ img, expanded }) const title = record.name - const [isLightboxOpen, setLightboxOpen] = useState(false) + const [isLightboxOpen, setLightboxOpen] = React.useState(false) + + const handleOpenLightbox = React.useCallback(() => setLightboxOpen(true), []) + const handleCloseLightbox = React.useCallback( + () => setLightboxOpen(false), + [] + ) return ( <> <div className={classes.root}> <div className={classes.bgContainer}> <Card className={classes.artistImage}> - <Artwork - record={record} - className={classes.cover} - title={title} - onClick={() => setLightboxOpen(true)} - /> - <ImageUploadOverlay - entityType="artist" - entityId={record.id} - hasUploadedImage={!!record.uploadedImage} - /> + {artistInfo && ( + <CardMedia + className={classes.cover} + image={subsonic.getCoverArtUrl(record, 300)} + onClick={handleOpenLightbox} + title={title} + /> + )} </Card> <div className={classes.details}> <Typography @@ -134,9 +132,7 @@ const MobileArtistDetails = ({ biography, record }) => { <div className={classes.biography}> <Collapse collapsedHeight={'1.5em'} in={expanded} timeout={'auto'}> <Typography variant={'body1'} onClick={() => setExpanded(!expanded)}> - <span> - <SafeHTML>{biography}</SafeHTML> - </span> + <span dangerouslySetInnerHTML={{ __html: biography }} /> </Typography> </Collapse> </div> @@ -146,7 +142,7 @@ const MobileArtistDetails = ({ biography, record }) => { animationDuration={200} imageTitle={record.name} mainSrc={img} - onCloseRequest={() => setLightboxOpen(false)} + onCloseRequest={handleCloseLightbox} /> )} </> diff --git a/ui/src/artist/actions.js b/ui/src/artist/actions.js deleted file mode 100644 index 94b3adf42..000000000 --- a/ui/src/artist/actions.js +++ /dev/null @@ -1,39 +0,0 @@ -import subsonic from '../subsonic/index.js' -import { playTracks } from '../actions/index.js' -import { processSongsForPlayback } from '../common/playbackActions.js' - -export const playTopSongs = async (dispatch, notify, artistName) => { - const res = await subsonic.getTopSongs(artistName, 100) - const data = res.json['subsonic-response'] - - if (data.status !== 'ok') { - throw new Error( - `Error fetching top songs: ${data.error?.message || 'Unknown error'} (Code: ${data.error?.code || 'unknown'})`, - ) - } - - const songs = data.topSongs?.song || [] - if (!songs.length) { - notify('message.noTopSongsFound', 'warning') - return - } - - const { songData, ids } = processSongsForPlayback(songs) - dispatch(playTracks(songData, ids)) -} - -export const playShuffle = async (dataProvider, dispatch, id) => { - const res = await dataProvider.getList('song', { - pagination: { page: 1, perPage: 500 }, - sort: { field: 'random', order: 'ASC' }, - filter: { album_artist_id: id, missing: false }, - }) - - const data = {} - const ids = [] - res.data.forEach((s) => { - data[s.id] = s - ids.push(s.id) - }) - dispatch(playTracks(data, ids)) -} diff --git a/ui/src/artist/index.jsx b/ui/src/artist/index.js similarity index 100% rename from ui/src/artist/index.jsx rename to ui/src/artist/index.js diff --git a/ui/src/audioplayer/AudioTitle.jsx b/ui/src/audioplayer/AudioTitle.js similarity index 54% rename from ui/src/audioplayer/AudioTitle.jsx rename to ui/src/audioplayer/AudioTitle.js index df37edfbb..89d046db6 100644 --- a/ui/src/audioplayer/AudioTitle.jsx +++ b/ui/src/audioplayer/AudioTitle.js @@ -3,66 +3,43 @@ import { useMediaQuery } from '@material-ui/core' import { Link } from 'react-router-dom' import clsx from 'clsx' import { QualityInfo } from '../common' -import { decisionService } from '../transcode' import useStyle from './styles' -import { useDrag } from 'react-dnd' -import { DraggableTypes } from '../consts' const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => { const classes = useStyle() const className = classes.audioTitle const isDesktop = useMediaQuery('(min-width:810px)') - const song = audioInfo.song - const [, dragSongRef] = useDrag( - () => ({ - type: DraggableTypes.SONG, - item: { ids: [song?.id] }, - options: { dropEffect: 'copy' }, - }), - [song], - ) - - if (!song) { + if (!audioInfo.song) { return '' } + const song = audioInfo.song const qi = { suffix: song.suffix, bitRate: song.bitRate, - rgAlbumGain: song.rgAlbumGain, - rgAlbumPeak: song.rgAlbumPeak, - rgTrackGain: song.rgTrackGain, - rgTrackPeak: song.rgTrackPeak, + albumGain: song.rgAlbumGain, + trackGain: song.rgTrackGain, } - const decision = decisionService.getCachedDecision(audioInfo.trackId) - const transcodeProps = decision - ? { - transcodeStream: decision.transcodeStream || null, - isDirectPlay: decision.canDirectPlay, - } - : {} - - const subtitle = song.tags?.['subtitle'] - const title = song.title + (subtitle ? ` (${subtitle})` : '') - - const linkTo = audioInfo.isRadio - ? `/radio/${audioInfo.trackId}/show` - : song.playlistId - ? `/playlist/${song.playlistId}/show` - : `/album/${song.albumId}/show` - return ( - <Link to={linkTo} className={className} ref={dragSongRef}> + <Link + to={ + audioInfo.isRadio + ? `/radio/${audioInfo.trackId}/show` + : `/album/${song.albumId}/show` + } + className={className} + > <span> - <span className={clsx(classes.songTitle, 'songTitle')}>{title}</span> + <span className={clsx(classes.songTitle, 'songTitle')}> + {song.title} + </span> {isDesktop && ( <QualityInfo record={qi} className={classes.qualityInfo} {...gainInfo} - {...transcodeProps} /> )} </span> @@ -87,6 +64,4 @@ const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => { ) }) -AudioTitle.displayName = 'AudioTitle' - export default AudioTitle diff --git a/ui/src/audioplayer/AudioTitle.test.jsx b/ui/src/audioplayer/AudioTitle.test.jsx deleted file mode 100644 index 7b297c07e..000000000 --- a/ui/src/audioplayer/AudioTitle.test.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import AudioTitle from './AudioTitle' - -vi.mock('@material-ui/core', async () => { - const actual = await import('@material-ui/core') - return { - ...actual, - useMediaQuery: vi.fn(), - } -}) - -vi.mock('react-router-dom', () => ({ - // eslint-disable-next-line react/display-name - Link: React.forwardRef(({ to, children, ...props }, ref) => ( - <a href={to} ref={ref} {...props}> - {children} - </a> - )), -})) - -vi.mock('react-dnd', () => ({ - useDrag: vi.fn(() => [null, () => {}]), -})) - -describe('<AudioTitle />', () => { - const baseSong = { - id: 'song-1', - albumId: 'album-1', - playlistId: 'playlist-1', - title: 'Test Song', - artist: 'Artist', - album: 'Album', - year: '2020', - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - it('links to playlist when playlistId is provided', () => { - const audioInfo = { trackId: 'track-1', song: baseSong } - render(<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />) - const link = screen.getByRole('link') - expect(link.getAttribute('href')).toBe('/playlist/playlist-1/show') - }) - - it('falls back to album link when no playlistId', () => { - const audioInfo = { - trackId: 'track-1', - song: { ...baseSong, playlistId: undefined }, - } - render(<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />) - const link = screen.getByRole('link') - expect(link.getAttribute('href')).toBe('/album/album-1/show') - }) -}) diff --git a/ui/src/audioplayer/Player.js b/ui/src/audioplayer/Player.js new file mode 100644 index 000000000..6b4720173 --- /dev/null +++ b/ui/src/audioplayer/Player.js @@ -0,0 +1,346 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { useMediaQuery } from '@material-ui/core' +import { ThemeProvider } from '@material-ui/core/styles' +import { + createMuiTheme, + useAuthState, + useDataProvider, + useTranslate, +} from 'react-admin' +import ReactGA from 'react-ga' +import { GlobalHotKeys } from 'react-hotkeys' +import ReactJkMusicPlayer from 'navidrome-music-player' +import 'navidrome-music-player/assets/index.css' +import useCurrentTheme from '../themes/useCurrentTheme' +import config from '../config' +import useStyle from './styles' +import AudioTitle from './AudioTitle' +import { clearQueue, currentPlaying, setVolume, syncQueue } from '../actions' +import PlayerToolbar from './PlayerToolbar' +import { sendNotification } from '../utils' +import subsonic from '../subsonic' +import locale from './locale' +import { keyMap } from '../hotkeys' +import keyHandlers from './keyHandlers' + +function calculateReplayGain(preAmp, gain, peak) { + if (gain === undefined || peak === undefined) { + return 1 + } + + // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification§ion=19 + // Normalized to max gain + return Math.min(10 ** ((gain + preAmp) / 20), 1 / peak) +} + +const Player = () => { + const theme = useCurrentTheme() + const translate = useTranslate() + const playerTheme = theme.player?.theme || 'dark' + 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 [audioInstance, setAudioInstance] = useState(null) + const isDesktop = useMediaQuery('(min-width:810px)') + const isMobilePlayer = + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent + ) + + const { authenticated } = useAuthState() + const visible = authenticated && playerState.queue.length > 0 + const isRadio = playerState.current?.isRadio || false + const classes = useStyle({ + isRadio, + visible, + enableCoverAnimation: config.enableCoverAnimation, + }) + const showNotifications = useSelector( + (state) => state.settings.notifications || false + ) + const gainInfo = useSelector((state) => state.replayGain) + const [context, setContext] = useState(null) + const [gainNode, setGainNode] = useState(null) + + useEffect(() => { + if ( + context === null && + audioInstance && + config.enableReplayGain && + 'AudioContext' in window && + (gainInfo.gainMode === 'album' || gainInfo.gainMode === 'track') + ) { + const ctx = new AudioContext() + // we need this to support radios in firefox + audioInstance.crossOrigin = 'anonymous' + const source = ctx.createMediaElementSource(audioInstance) + const gain = ctx.createGain() + + source.connect(gain) + gain.connect(ctx.destination) + + setContext(ctx) + setGainNode(gain) + } + }, [audioInstance, context, gainInfo.gainMode]) + + useEffect(() => { + if (gainNode) { + const current = playerState.current || {} + const song = current.song || {} + + let numericGain + + switch (gainInfo.gainMode) { + case 'album': { + numericGain = calculateReplayGain( + gainInfo.preAmp, + song.rgAlbumGain, + song.rgAlbumPeak + ) + break + } + case 'track': { + numericGain = calculateReplayGain( + gainInfo.preAmp, + song.rgTrackGain, + song.rgTrackPeak + ) + break + } + default: { + numericGain = 1 + } + } + + gainNode.gain.setValueAtTime(numericGain, context.currentTime) + } + }, [ + audioInstance, + context, + gainNode, + gainInfo.gainMode, + gainInfo.preAmp, + playerState, + ]) + + const defaultOptions = useMemo( + () => ({ + theme: playerTheme, + bounds: 'body', + mode: 'full', + loadAudioErrorPlayNext: false, + autoPlayInitLoadPlayList: true, + clearPriorAudioLists: false, + showDestroy: true, + showDownload: false, + showLyric: true, + showReload: false, + toggleMode: !isDesktop, + glassBg: false, + showThemeSwitch: false, + showMediaSession: true, + restartCurrentOnPrev: true, + quietUpdate: true, + defaultPosition: { + top: 300, + left: 120, + }, + volumeFade: { fadeIn: 200, fadeOut: 200 }, + renderAudioTitle: (audioInfo, isMobile) => ( + <AudioTitle + audioInfo={audioInfo} + gainInfo={gainInfo} + isMobile={isMobile} + /> + ), + locale: locale(translate), + }), + [gainInfo, isDesktop, playerTheme, translate] + ) + + const options = useMemo(() => { + const current = playerState.current || {} + return { + ...defaultOptions, + audioLists: playerState.queue.map((item) => item), + playIndex: playerState.playIndex, + autoPlay: playerState.clear || playerState.playIndex === 0, + clearPriorAudioLists: playerState.clear, + extendsContent: ( + <PlayerToolbar id={current.trackId} isRadio={current.isRadio} /> + ), + defaultVolume: isMobilePlayer ? 1 : playerState.volume, + showMediaSession: !current.isRadio, + } + }, [playerState, defaultOptions, isMobilePlayer]) + + const onAudioListsChange = useCallback( + (_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)), + [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' + } + + 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) { + const audio = new Audio() + audio.src = next.musicSrc + } + setPreload(true) + return + } + + if (!scrobbled) { + info.trackId && subsonic.scrobble(info.trackId, startTime) + setScrobbled(true) + } + }, + [startTime, scrobbled, nextSong, preloaded] + ) + + const onAudioVolumeChange = useCallback( + // sqrt to compensate for the logarithmic volume + (volume) => dispatch(setVolume(Math.sqrt(volume))), + [dispatch] + ) + + 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) { + subsonic.nowPlaying(info.trackId) + } + setPreload(false) + if (config.gaTrackingId) { + ReactGA.event({ + category: 'Player', + action: 'Play song', + label: `${song.title} - ${song.artist}`, + }) + } + if (showNotifications) { + sendNotification( + song.title, + `${song.artist} - ${song.album}`, + info.cover + ) + } + } + }, + [context, dispatch, showNotifications, startTime] + ) + + const onAudioPlayTrackChange = useCallback(() => { + if (scrobbled) { + setScrobbled(false) + } + if (startTime !== null) { + setStartTime(null) + } + }, [scrobbled, startTime]) + + const onAudioPause = useCallback( + (info) => dispatch(currentPlaying(info)), + [dispatch] + ) + + const onAudioEnded = useCallback( + (currentPlayId, audioLists, info) => { + setScrobbled(false) + setStartTime(null) + dispatch(currentPlaying(info)) + dataProvider + .getOne('keepalive', { id: info.trackId }) + .catch((e) => console.log('Keepalive error:', e)) + }, + [dispatch, dataProvider] + ) + + const onCoverClick = useCallback((mode, audioLists, audioInfo) => { + if (mode === 'full' && audioInfo?.song?.albumId) { + window.location.href = `#/album/${audioInfo.song.albumId}/show` + } + }, []) + + const onBeforeDestroy = useCallback(() => { + return new Promise((resolve, reject) => { + dispatch(clearQueue()) + reject() + }) + }, [dispatch]) + + if (!visible) { + document.title = 'Navidrome' + } + + const handlers = useMemo( + () => keyHandlers(audioInstance, playerState), + [audioInstance, playerState] + ) + + useEffect(() => { + if (isMobilePlayer && audioInstance) { + audioInstance.volume = 1 + } + }, [isMobilePlayer, audioInstance]) + + return ( + <ThemeProvider theme={createMuiTheme(theme)}> + <ReactJkMusicPlayer + {...options} + className={classes.player} + onAudioListsChange={onAudioListsChange} + onAudioVolumeChange={onAudioVolumeChange} + onAudioProgress={onAudioProgress} + onAudioPlay={onAudioPlay} + onAudioPlayTrackChange={onAudioPlayTrackChange} + onAudioPause={onAudioPause} + onAudioEnded={onAudioEnded} + onCoverClick={onCoverClick} + onBeforeDestroy={onBeforeDestroy} + getAudioInstance={setAudioInstance} + /> + <GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges /> + </ThemeProvider> + ) +} + +export { Player } diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx deleted file mode 100644 index c3d795b26..000000000 --- a/ui/src/audioplayer/Player.jsx +++ /dev/null @@ -1,479 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useInterval } from '../common' -import { useDispatch, useSelector } from 'react-redux' -import { useMediaQuery } from '@material-ui/core' -import { ThemeProvider } from '@material-ui/core/styles' -import { - createMuiTheme, - useAuthState, - useDataProvider, - useTranslate, -} from 'react-admin' -import ReactGA from 'react-ga' -import { GlobalHotKeys } from 'react-hotkeys' -import ReactJkMusicPlayer from 'navidrome-music-player' -import 'navidrome-music-player/assets/index.css' -import useCurrentTheme from '../themes/useCurrentTheme' -import config from '../config' -import useStyle from './styles' -import AudioTitle from './AudioTitle' -import { - clearQueue, - currentPlaying, - refreshQueue, - setPlayMode, - setTranscodingProfile, - setVolume, - syncQueue, -} from '../actions' -import PlayerToolbar from './PlayerToolbar' -import { sendNotification } from '../utils' -import subsonic from '../subsonic' -import locale from './locale' -import { keyMap } from '../hotkeys' -import keyHandlers from './keyHandlers' -import { calculateGain } from '../utils/calculateReplayGain' -import { detectBrowserProfile, decisionService } from '../transcode' - -const Player = () => { - const theme = useCurrentTheme() - const translate = useTranslate() - const playerTheme = theme.player?.theme || 'dark' - const dataProvider = useDataProvider() - const playerState = useSelector((state) => state.player) - const dispatch = useDispatch() - 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 = - /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( - navigator.userAgent, - ) - - const { authenticated } = useAuthState() - - // Keep a ref to playerState so the mount effect can read the latest value - // without re-triggering on every queue/position change - 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(() => { - const profile = detectBrowserProfile() - decisionService.setProfile(profile) - dispatch(setTranscodingProfile(profile)) - - const state = playerStateRef.current - const currentIdx = state.savedPlayIndex || 0 - const trackIds = state.queue - .slice(currentIdx, currentIdx + 4) - .filter((item) => !item.isRadio && item.trackId) - .map((item) => item.trackId) - - if (trackIds.length === 0) { - dispatch(refreshQueue()) - return - } - - Promise.allSettled( - trackIds.map((id) => - decisionService.resolveStreamUrl(id).then((url) => [id, url]), - ), - ).then((results) => { - const resolvedUrls = {} - results.forEach((r) => { - if (r.status === 'fulfilled') { - resolvedUrls[r.value[0]] = r.value[1] - } - }) - dispatch(refreshQueue(resolvedUrls)) - }) - }, [dispatch]) - - // Pre-fetch transcode decisions for next 2-3 songs when queue or position changes - useEffect(() => { - if (!playerState.queue.length) return - - const currentIdx = playerState.savedPlayIndex || 0 - const nextSongIds = playerState.queue - .slice(currentIdx + 1, currentIdx + 4) - .filter((item) => !item.isRadio) - .map((item) => item.trackId) - - if (nextSongIds.length > 0) { - decisionService.prefetchDecisions(nextSongIds) - } - }, [playerState.queue, playerState.savedPlayIndex]) - - const visible = authenticated && playerState.queue.length > 0 - const isRadio = playerState.current?.isRadio || false - const classes = useStyle({ - isRadio, - visible, - enableCoverAnimation: config.enableCoverAnimation, - }) - const showNotifications = useSelector( - (state) => state.settings.notifications || false, - ) - const gainInfo = useSelector((state) => state.replayGain) - const [context, setContext] = useState(null) - const [gainNode, setGainNode] = useState(null) - - useEffect(() => { - if ( - context === null && - audioInstance && - config.enableReplayGain && - 'AudioContext' in window && - (gainInfo.gainMode === 'album' || gainInfo.gainMode === 'track') - ) { - const ctx = new AudioContext() - // we need this to support radios in firefox - audioInstance.crossOrigin = 'anonymous' - const source = ctx.createMediaElementSource(audioInstance) - const gain = ctx.createGain() - - source.connect(gain) - gain.connect(ctx.destination) - - setContext(ctx) - setGainNode(gain) - } - }, [audioInstance, context, gainInfo.gainMode]) - - useEffect(() => { - if (gainNode) { - const current = playerState.current || {} - const song = current.song || {} - - const numericGain = calculateGain(gainInfo, song) - gainNode.gain.setValueAtTime(numericGain, context.currentTime) - } - }, [audioInstance, context, gainNode, playerState, gainInfo]) - - useEffect(() => { - const handleBeforeUnload = (e) => { - if (playerState.current?.uuid && audioInstance && !audioInstance.paused) { - e.preventDefault() - 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) - window.addEventListener('pagehide', handlePageHide) - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload) - window.removeEventListener('pagehide', handlePageHide) - } - }, [playerState, audioInstance]) - - const defaultOptions = useMemo( - () => ({ - theme: playerTheme, - bounds: 'body', - playMode: playerState.mode, - mode: 'full', - loadAudioErrorPlayNext: false, - autoPlayInitLoadPlayList: true, - clearPriorAudioLists: false, - showDestroy: true, - showDownload: false, - showLyric: true, - showReload: false, - toggleMode: !isDesktop, - glassBg: false, - showThemeSwitch: false, - showMediaSession: true, - restartCurrentOnPrev: true, - quietUpdate: true, - defaultPosition: { - top: 300, - left: 120, - }, - volumeFade: { fadeIn: 200, fadeOut: 200 }, - renderAudioTitle: (audioInfo, isMobile) => ( - <AudioTitle - audioInfo={audioInfo} - gainInfo={gainInfo} - isMobile={isMobile} - /> - ), - locale: locale(translate), - sortableOptions: { delay: 200, delayOnTouchOnly: true }, - }), - [gainInfo, isDesktop, playerTheme, translate, playerState.mode], - ) - - const options = useMemo(() => { - const current = playerState.current || {} - return { - ...defaultOptions, - 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, - extendsContent: ( - <PlayerToolbar id={current.trackId} isRadio={current.isRadio} /> - ), - defaultVolume: isMobilePlayer ? 1 : playerState.volume, - showMediaSession: !current.isRadio, - } - }, [playerState, defaultOptions, isMobilePlayer]) - - const onAudioListsChange = useCallback( - (_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)), - [dispatch], - ) - - const onAudioProgress = useCallback((info) => { - if (info.ended) { - document.title = 'Navidrome' - } - if (!info.isRadio && info.currentTime != null) { - lastPositionMsRef.current = Math.floor(info.currentTime * 1000) - } - }, []) - - const onAudioVolumeChange = useCallback( - // sqrt to compensate for the logarithmic volume - (volume) => dispatch(setVolume(Math.sqrt(volume))), - [dispatch], - ) - - const onAudioPlay = useCallback( - (info) => { - if (context && context.state !== 'running') { - context.resume() - } - - dispatch(currentPlaying(info)) - if (info.duration) { - const song = info.song - document.title = `${song.title} - ${song.artist} - Navidrome` - if (!info.isRadio) { - 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) - } - if (config.gaTrackingId) { - ReactGA.event({ - category: 'Player', - action: 'Play song', - label: `${song.title} - ${song.artist}`, - }) - } - if (showNotifications) { - sendNotification( - song.title, - `${song.artist} - ${song.album}`, - info.cover, - ) - } - } - }, - [context, dispatch, showNotifications, currentTrackId], - ) - - const onAudioPlayTrackChange = useCallback(() => { - if (currentTrackId) { - subsonic.reportPlayback( - currentTrackId, - lastPositionMsRef.current, - 'stopped', - ) - } - setHeartbeatTrackId(null) - setCurrentTrackId(null) - }, [currentTrackId]) - - const onAudioPause = useCallback( - (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) => { - 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, currentTrackId], - ) - - const onCoverClick = useCallback((mode, audioLists, audioInfo) => { - if (mode === 'full' && audioInfo?.song?.albumId) { - window.location.href = `#/album/${audioInfo.song.albumId}/show` - } - }, []) - - const onAudioError = useCallback( - (error, currentPlayId, audioLists, audioInfo) => { - // Invalidate all cached decisions — token may be stale - decisionService.invalidateAll() - - // Pre-fetch decisions for upcoming songs with fresh tokens - const currentIdx = playerState.queue.findIndex( - (item) => item.uuid === currentPlayId, - ) - if (currentIdx >= 0) { - const nextSongIds = playerState.queue - .slice(currentIdx + 1, currentIdx + 4) - .filter((item) => !item.isRadio) - .map((item) => item.trackId) - if (nextSongIds.length > 0) { - decisionService.prefetchDecisions(nextSongIds) - } - } - }, - [playerState.queue], - ) - - 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, currentTrackId]) - - if (!visible) { - document.title = 'Navidrome' - } - - const handlers = useMemo( - () => keyHandlers(audioInstance, playerState), - [audioInstance, playerState], - ) - - useEffect(() => { - if (isMobilePlayer && audioInstance) { - audioInstance.volume = 1 - } - }, [isMobilePlayer, audioInstance]) - - // Report every seek (including programmatic ones the library does not surface - // via onAudioSeeked, e.g. restartCurrentOnPrev). Debounce coalesces drag - // bursts into one report at the final position. - useEffect(() => { - if (!audioInstance) return - let timer = null - const flush = () => { - timer = null - if ( - !currentTrackIdRef.current || - playerStateRef.current?.current?.isRadio - ) { - return - } - const posMs = Math.floor((audioInstance.currentTime || 0) * 1000) - const state = audioInstance.paused ? 'paused' : 'playing' - subsonic.reportPlayback(currentTrackIdRef.current, posMs, state) - } - const handleSeeked = () => { - if (timer) clearTimeout(timer) - timer = setTimeout(flush, 250) - } - audioInstance.addEventListener('seeked', handleSeeked) - return () => { - if (timer) clearTimeout(timer) - audioInstance.removeEventListener('seeked', handleSeeked) - } - }, [audioInstance]) - - return ( - <ThemeProvider theme={createMuiTheme(theme)}> - <ReactJkMusicPlayer - {...options} - className={classes.player} - onAudioListsChange={onAudioListsChange} - onAudioVolumeChange={onAudioVolumeChange} - onAudioProgress={onAudioProgress} - onAudioPlay={onAudioPlay} - onAudioPlayTrackChange={onAudioPlayTrackChange} - onAudioPause={onAudioPause} - onPlayModeChange={(mode) => dispatch(setPlayMode(mode))} - onAudioEnded={onAudioEnded} - onCoverClick={onCoverClick} - onAudioError={onAudioError} - onBeforeDestroy={onBeforeDestroy} - getAudioInstance={setAudioInstance} - /> - <GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges /> - </ThemeProvider> - ) -} - -export { Player } diff --git a/ui/src/audioplayer/PlayerToolbar.js b/ui/src/audioplayer/PlayerToolbar.js new file mode 100644 index 000000000..d3645f5b0 --- /dev/null +++ b/ui/src/audioplayer/PlayerToolbar.js @@ -0,0 +1,32 @@ +import React, { useCallback } from 'react' +import { useGetOne } from 'react-admin' +import { GlobalHotKeys } from 'react-hotkeys' +import { LoveButton, useToggleLove } from '../common' +import { keyMap } from '../hotkeys' + +const Placeholder = () => <LoveButton disabled={true} resource={'song'} /> + +const Toolbar = ({ id }) => { + const { data, loading } = useGetOne('song', id) + const [toggleLove, toggling] = useToggleLove('song', data) + + const handlers = { + TOGGLE_LOVE: useCallback(() => toggleLove(), [toggleLove]), + } + + return ( + <> + <GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges /> + <LoveButton + record={data} + resource={'song'} + disabled={loading || toggling} + /> + </> + ) +} + +const PlayerToolbar = ({ id, isRadio }) => + id && !isRadio ? <Toolbar id={id} /> : <Placeholder /> + +export default PlayerToolbar diff --git a/ui/src/audioplayer/PlayerToolbar.jsx b/ui/src/audioplayer/PlayerToolbar.jsx deleted file mode 100644 index 4812141ab..000000000 --- a/ui/src/audioplayer/PlayerToolbar.jsx +++ /dev/null @@ -1,120 +0,0 @@ -import React, { useCallback } from 'react' -import { useDispatch } from 'react-redux' -import { useGetOne } from 'react-admin' -import { GlobalHotKeys } from 'react-hotkeys' -import IconButton from '@material-ui/core/IconButton' -import { useMediaQuery } from '@material-ui/core' -import { RiSaveLine } from 'react-icons/ri' -import { LoveButton, useToggleLove } from '../common' -import { openSaveQueueDialog } from '../actions' -import { keyMap } from '../hotkeys' -import { makeStyles } from '@material-ui/core/styles' - -const useStyles = makeStyles((theme) => ({ - toolbar: { - display: 'flex', - alignItems: 'center', - flexGrow: 1, - justifyContent: 'flex-end', - gap: '0.5rem', - listStyle: 'none', - padding: 0, - margin: 0, - }, - mobileListItem: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - listStyle: 'none', - padding: theme.spacing(0.5), - margin: 0, - height: 24, - }, - button: { - width: '2.5rem', - height: '2.5rem', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - padding: 0, - }, - mobileButton: { - width: 24, - height: 24, - padding: 0, - margin: 0, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - fontSize: '18px', - }, - mobileIcon: { - fontSize: '18px', - display: 'flex', - alignItems: 'center', - }, -})) - -const PlayerToolbar = ({ id, isRadio }) => { - const dispatch = useDispatch() - const { data, loading } = useGetOne('song', id, { enabled: !!id && !isRadio }) - const [toggleLove, toggling] = useToggleLove('song', data) - const isDesktop = useMediaQuery('(min-width:810px)') - const classes = useStyles() - - const handlers = { - TOGGLE_LOVE: useCallback(() => toggleLove(), [toggleLove]), - } - - const handleSaveQueue = useCallback( - (e) => { - dispatch(openSaveQueueDialog()) - e.stopPropagation() - }, - [dispatch], - ) - - const buttonClass = isDesktop ? classes.button : classes.mobileButton - const listItemClass = isDesktop ? classes.toolbar : classes.mobileListItem - - const saveQueueButton = ( - <IconButton - size={isDesktop ? 'small' : undefined} - onClick={handleSaveQueue} - disabled={isRadio} - data-testid="save-queue-button" - className={buttonClass} - > - <RiSaveLine className={!isDesktop ? classes.mobileIcon : undefined} /> - </IconButton> - ) - - const loveButton = ( - <LoveButton - record={data} - resource={'song'} - size={isDesktop ? undefined : 'inherit'} - disabled={loading || toggling || !id || isRadio} - className={buttonClass} - /> - ) - - return ( - <> - <GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges /> - {isDesktop ? ( - <li className={`${listItemClass} item`}> - {saveQueueButton} - {loveButton} - </li> - ) : ( - <> - <li className={`${listItemClass} item`}>{saveQueueButton}</li> - <li className={`${listItemClass} item`}>{loveButton}</li> - </> - )} - </> - ) -} - -export default PlayerToolbar diff --git a/ui/src/audioplayer/PlayerToolbar.test.jsx b/ui/src/audioplayer/PlayerToolbar.test.jsx deleted file mode 100644 index d0368b0f0..000000000 --- a/ui/src/audioplayer/PlayerToolbar.test.jsx +++ /dev/null @@ -1,166 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent, cleanup } from '@testing-library/react' -import { useMediaQuery } from '@material-ui/core' -import { useGetOne } from 'react-admin' -import { useDispatch } from 'react-redux' -import { useToggleLove } from '../common' -import { openSaveQueueDialog } from '../actions' -import PlayerToolbar from './PlayerToolbar' - -// Mock dependencies -vi.mock('@material-ui/core', async () => { - const actual = await import('@material-ui/core') - return { - ...actual, - useMediaQuery: vi.fn(), - } -}) - -vi.mock('react-admin', () => ({ - useGetOne: vi.fn(), -})) - -vi.mock('react-redux', () => ({ - useDispatch: vi.fn(), -})) - -vi.mock('../common', () => ({ - LoveButton: ({ className, disabled }) => ( - <button data-testid="love-button" className={className} disabled={disabled}> - Love - </button> - ), - useToggleLove: vi.fn(), -})) - -vi.mock('../actions', () => ({ - openSaveQueueDialog: vi.fn(), -})) - -vi.mock('react-hotkeys', () => ({ - GlobalHotKeys: () => <div data-testid="global-hotkeys" />, -})) - -describe('<PlayerToolbar />', () => { - const mockToggleLove = vi.fn() - const mockDispatch = vi.fn() - const mockSongData = { id: 'song-1', name: 'Test Song', starred: false } - - beforeEach(() => { - vi.clearAllMocks() - useGetOne.mockReturnValue({ data: mockSongData, loading: false }) - useToggleLove.mockReturnValue([mockToggleLove, false]) - useDispatch.mockReturnValue(mockDispatch) - openSaveQueueDialog.mockReturnValue({ type: 'OPEN_SAVE_QUEUE_DIALOG' }) - }) - - afterEach(cleanup) - - describe('Desktop layout', () => { - beforeEach(() => { - useMediaQuery.mockReturnValue(true) // isDesktop = true - }) - - it('renders desktop toolbar with both buttons', () => { - render(<PlayerToolbar id="song-1" />) - - // Both buttons should be in a single list item - const listItems = screen.getAllByRole('listitem') - expect(listItems).toHaveLength(1) - - // Verify both buttons are rendered - expect(screen.getByTestId('save-queue-button')).toBeInTheDocument() - expect(screen.getByTestId('love-button')).toBeInTheDocument() - - // Verify desktop classes are applied - expect(listItems[0].className).toContain('toolbar') - }) - - it('disables save queue button when isRadio is true', () => { - render(<PlayerToolbar id="song-1" isRadio={true} />) - - const saveQueueButton = screen.getByTestId('save-queue-button') - expect(saveQueueButton).toBeDisabled() - }) - - it('disables love button when conditions are met', () => { - useGetOne.mockReturnValue({ data: mockSongData, loading: true }) - - render(<PlayerToolbar id="song-1" />) - - const loveButton = screen.getByTestId('love-button') - expect(loveButton).toBeDisabled() - }) - - it('opens save queue dialog when save button is clicked', () => { - render(<PlayerToolbar id="song-1" />) - - const saveQueueButton = screen.getByTestId('save-queue-button') - fireEvent.click(saveQueueButton) - - expect(mockDispatch).toHaveBeenCalledWith({ - type: 'OPEN_SAVE_QUEUE_DIALOG', - }) - }) - }) - - describe('Mobile layout', () => { - beforeEach(() => { - useMediaQuery.mockReturnValue(false) // isDesktop = false - }) - - it('renders mobile toolbar with buttons in separate list items', () => { - render(<PlayerToolbar id="song-1" />) - - // Each button should be in its own list item - const listItems = screen.getAllByRole('listitem') - expect(listItems).toHaveLength(2) - - // Verify both buttons are rendered - expect(screen.getByTestId('save-queue-button')).toBeInTheDocument() - expect(screen.getByTestId('love-button')).toBeInTheDocument() - - // Verify mobile classes are applied - expect(listItems[0].className).toContain('mobileListItem') - expect(listItems[1].className).toContain('mobileListItem') - }) - - it('disables save queue button when isRadio is true', () => { - render(<PlayerToolbar id="song-1" isRadio={true} />) - - const saveQueueButton = screen.getByTestId('save-queue-button') - expect(saveQueueButton).toBeDisabled() - }) - - it('disables love button when conditions are met', () => { - useGetOne.mockReturnValue({ data: mockSongData, loading: true }) - - render(<PlayerToolbar id="song-1" />) - - const loveButton = screen.getByTestId('love-button') - expect(loveButton).toBeDisabled() - }) - }) - - describe('Common behavior', () => { - it('renders global hotkeys in both layouts', () => { - // Test desktop layout - useMediaQuery.mockReturnValue(true) - render(<PlayerToolbar id="song-1" />) - expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument() - - // Cleanup and test mobile layout - cleanup() - useMediaQuery.mockReturnValue(false) - render(<PlayerToolbar id="song-1" />) - expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument() - }) - - it('disables buttons when id is not provided', () => { - render(<PlayerToolbar />) - - const loveButton = screen.getByTestId('love-button') - expect(loveButton).toBeDisabled() - }) - }) -}) diff --git a/ui/src/audioplayer/keyHandlers.jsx b/ui/src/audioplayer/keyHandlers.js similarity index 89% rename from ui/src/audioplayer/keyHandlers.jsx rename to ui/src/audioplayer/keyHandlers.js index 793276de0..444ea2c12 100644 --- a/ui/src/audioplayer/keyHandlers.jsx +++ b/ui/src/audioplayer/keyHandlers.js @@ -1,14 +1,14 @@ const keyHandlers = (audioInstance, playerState) => { const nextSong = () => { const idx = playerState.queue.findIndex( - (item) => item.uuid === playerState.current.uuid, + (item) => item.uuid === playerState.current.uuid ) return idx !== null ? playerState.queue[idx + 1] : null } const prevSong = () => { const idx = playerState.queue.findIndex( - (item) => item.uuid === playerState.current.uuid, + (item) => item.uuid === playerState.current.uuid ) return idx !== null ? playerState.queue[idx - 1] : null } diff --git a/ui/src/audioplayer/styles.js b/ui/src/audioplayer/styles.js index 30a14d4db..38b7831b9 100644 --- a/ui/src/audioplayer/styles.js +++ b/ui/src/audioplayer/styles.js @@ -87,7 +87,7 @@ const useStyle = makeStyles( }, }, }), - { name: 'NDAudioPlayer' }, + { name: 'NDAudioPlayer' } ) export default useStyle diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js index f93114ded..86d45b446 100644 --- a/ui/src/authProvider.js +++ b/ui/src/authProvider.js @@ -1,4 +1,4 @@ -import { jwtDecode } from 'jwt-decode' +import jwtDecode from 'jwt-decode' import { baseUrl } from './utils' import config from './config' @@ -8,7 +8,6 @@ if (config.auth) { try { storeAuthenticationInfo(config.auth) } catch (e) { - // eslint-disable-next-line no-console console.log(e) } } @@ -64,11 +63,6 @@ const authProvider = { logout: () => { removeItems() - // Only proxy-authenticated sessions go to the IdP; others (e.g. direct LAN access) get the login form - if (config.extAuthLogoutURL && config.auth) { - window.location.href = config.extAuthLogoutURL - return Promise.resolve(false) - } return Promise.resolve() }, diff --git a/ui/src/authProvider.test.js b/ui/src/authProvider.test.js deleted file mode 100644 index b16a39815..000000000 --- a/ui/src/authProvider.test.js +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import config from './config' -import authProvider from './authProvider' - -vi.mock('./config', () => ({ default: {} })) - -describe('authProvider.logout', () => { - const logoutURL = 'https://auth.example.com/signout' - - beforeEach(() => { - vi.stubGlobal('location', { href: '' }) - localStorage.setItem('is-authenticated', 'true') - localStorage.setItem('token', 'abc') - }) - - afterEach(() => { - vi.unstubAllGlobals() - localStorage.clear() - delete config.extAuthLogoutURL - delete config.auth - }) - - it('clears the stored session', async () => { - await authProvider.logout() - expect(localStorage.getItem('is-authenticated')).toBeNull() - expect(localStorage.getItem('token')).toBeNull() - }) - - it('does not redirect when no logout URL is configured', async () => { - config.auth = { id: '1' } - await expect(authProvider.logout()).resolves.toBeUndefined() - expect(window.location.href).toBe('') - }) - - it('redirects to the logout URL when the page was authenticated by the proxy', async () => { - config.extAuthLogoutURL = logoutURL - config.auth = { id: '1' } - await expect(authProvider.logout()).resolves.toBe(false) - expect(window.location.href).toBe(logoutURL) - }) - - it('does not redirect when the page was not authenticated by the proxy', async () => { - config.extAuthLogoutURL = logoutURL - await expect(authProvider.logout()).resolves.toBeUndefined() - expect(window.location.href).toBe('') - }) -}) diff --git a/ui/src/common/AddToPlaylistButton.jsx b/ui/src/common/AddToPlaylistButton.js similarity index 89% rename from ui/src/common/AddToPlaylistButton.jsx rename to ui/src/common/AddToPlaylistButton.js index 9cddc7499..021bd308a 100644 --- a/ui/src/common/AddToPlaylistButton.jsx +++ b/ui/src/common/AddToPlaylistButton.js @@ -12,10 +12,7 @@ export const AddToPlaylistButton = ({ resource, selectedIds, className }) => { const handleClick = () => { dispatch( - openAddToPlaylist({ - selectedIds, - onSuccess: () => unselectAll(resource), - }), + openAddToPlaylist({ selectedIds, onSuccess: () => unselectAll(resource) }) ) } diff --git a/ui/src/common/ArtistLinkField.js b/ui/src/common/ArtistLinkField.js new file mode 100644 index 000000000..26623658a --- /dev/null +++ b/ui/src/common/ArtistLinkField.js @@ -0,0 +1,49 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Link } from 'react-admin' +import { withWidth } from '@material-ui/core' +import { useAlbumsPerPage } from './index' +import config from '../config' + +export const useGetHandleArtistClick = (width) => { + const [perPage] = useAlbumsPerPage(width) + return (id) => { + return config.devShowArtistPage && id !== config.variousArtistsId + ? `/artist/${id}/show` + : `/album?filter={"artist_id":"${id}"}&order=ASC&sort=max_year&displayedFilters={"compilation":true}&perPage=${perPage}` + } +} + +export const ArtistLinkField = withWidth()( + ({ record, className, width, source }) => { + const artistLink = useGetHandleArtistClick(width) + + const id = record[source + 'Id'] + return ( + <> + {id ? ( + <Link + to={artistLink(id)} + onClick={(e) => e.stopPropagation()} + className={className} + > + {record[source]} + </Link> + ) : ( + record[source] + )} + </> + ) + } +) + +ArtistLinkField.propTypes = { + record: PropTypes.object, + className: PropTypes.string, + source: PropTypes.string, +} + +ArtistLinkField.defaultProps = { + addLabel: true, + source: 'albumArtist', +} diff --git a/ui/src/common/ArtistLinkField.jsx b/ui/src/common/ArtistLinkField.jsx deleted file mode 100644 index 2a70ded7c..000000000 --- a/ui/src/common/ArtistLinkField.jsx +++ /dev/null @@ -1,175 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import { Link } from 'react-admin' -import { withWidth } from '@material-ui/core' -import { useGetHandleArtistClick } from './useGetHandleArtistClick' -import { intersperse } from '../utils/index.js' -import { useDispatch } from 'react-redux' -import { closeExtendedInfoDialog } from '../actions/dialogs.js' - -// noSSR: withWidth otherwise renders null until mounted, so the artist line pops in and grows the row. -const ALink = withWidth({ noSSR: true })((props) => { - const { artist, width, ...rest } = props - const artistLink = useGetHandleArtistClick(width) - const dispatch = useDispatch() - - return ( - <Link - key={artist.id} - to={artistLink(artist.id)} - onClick={(e) => { - e.stopPropagation() - dispatch(closeExtendedInfoDialog()) - }} - {...rest} - > - {artist.name} - {artist.subroles?.length > 0 ? ` (${artist.subroles.join(', ')})` : ''} - </Link> - ) -}) - -const parseAndReplaceArtists = ( - displayAlbumArtist, - albumArtists, - className, -) => { - let result = [] - let lastIndex = 0 - - albumArtists?.forEach((artist) => { - const index = displayAlbumArtist.indexOf(artist.name, lastIndex) - if (index !== -1) { - // Add text before the artist name - if (index > lastIndex) { - result.push(displayAlbumArtist.slice(lastIndex, index)) - } - // Add the artist link - result.push( - <ALink artist={artist} className={className} key={artist.id} />, - ) - lastIndex = index + artist.name.length - } - }) - - if (lastIndex === 0) { - return [] - } - - // Add any remaining text after the last artist name - if (lastIndex < displayAlbumArtist.length) { - result.push(displayAlbumArtist.slice(lastIndex)) - } - - return result -} - -export const ArtistLinkField = ({ record, className, limit, source }) => { - const role = source.toLowerCase() - - // Get artists array with fallback - let artists = record?.participants?.[role] || [] - const remixers = - role === 'artist' && record?.participants?.remixer - ? record.participants.remixer.slice(0, 2) - : [] - - // Use parseAndReplaceArtists for artist and albumartist roles - if ((role === 'artist' || role === 'albumartist') && record[source]) { - const artistsLinks = parseAndReplaceArtists( - record[source], - artists, - className, - ) - - if (artistsLinks.length > 0) { - // For artist role, append remixers if available, avoiding duplicates - if (role === 'artist' && remixers.length > 0) { - // Track which artists are already displayed to avoid duplicates - const displayedArtistIds = new Set( - artists.map((artist) => artist.id).filter(Boolean), - ) - - // Only add remixers that aren't already in the artists list - const uniqueRemixers = remixers.filter( - (remixer) => remixer.id && !displayedArtistIds.has(remixer.id), - ) - - if (uniqueRemixers.length > 0) { - artistsLinks.push(' • ') - uniqueRemixers.forEach((remixer, index) => { - if (index > 0) artistsLinks.push(' • ') - artistsLinks.push( - <ALink - artist={remixer} - className={className} - key={`remixer-${remixer.id}`} - />, - ) - }) - } - } - - return <div className={className}>{artistsLinks}</div> - } - } - - // Fall back to regular handling - if (artists.length === 0 && record[source]) { - artists = [{ name: record[source], id: record[source + 'Id'] }] - } - - // For artist role, combine artists and remixers before deduplication - const allArtists = role === 'artist' ? [...artists, ...remixers] : artists - - // Dedupe artists and collect subroles - const seen = new Map() - const dedupedArtists = [] - let limitedShow = false - - for (const artist of allArtists) { - if (!artist?.id) continue - - if (!seen.has(artist.id)) { - if (dedupedArtists.length < limit) { - seen.set(artist.id, dedupedArtists.length) - dedupedArtists.push({ - ...artist, - subroles: artist.subRole ? [artist.subRole] : [], - }) - } else { - limitedShow = true - } - } else { - const position = seen.get(artist.id) - const existing = dedupedArtists[position] - if (artist.subRole && !existing.subroles.includes(artist.subRole)) { - existing.subroles.push(artist.subRole) - } - } - } - - // Create artist links - const artistsList = dedupedArtists.map((artist) => ( - <ALink artist={artist} className={className} key={artist.id} /> - )) - - if (limitedShow) { - artistsList.push(<span key="more">...</span>) - } - - return <>{intersperse(artistsList, ' • ')}</> -} - -ArtistLinkField.propTypes = { - limit: PropTypes.number, - record: PropTypes.object, - className: PropTypes.string, - source: PropTypes.string, -} - -ArtistLinkField.defaultProps = { - addLabel: true, - limit: 3, - source: 'albumArtist', -} diff --git a/ui/src/common/ArtistLinkField.test.jsx b/ui/src/common/ArtistLinkField.test.jsx deleted file mode 100644 index 09fdf64a4..000000000 --- a/ui/src/common/ArtistLinkField.test.jsx +++ /dev/null @@ -1,238 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { ArtistLinkField } from './ArtistLinkField' -import { intersperse } from '../utils/index.js' - -// Mock dependencies -vi.mock('react-redux', () => ({ - useDispatch: vi.fn(() => vi.fn()), -})) - -vi.mock('./useGetHandleArtistClick', () => ({ - useGetHandleArtistClick: vi.fn(() => (id) => `/artist/${id}`), -})) - -vi.mock('../utils/index.js', () => ({ - intersperse: vi.fn((arr) => arr), -})) - -vi.mock('@material-ui/core', () => ({ - withWidth: () => (Component) => { - const WithWidthComponent = (props) => <Component {...props} width="md" /> - WithWidthComponent.displayName = `WithWidth(${Component.displayName || Component.name || 'Component'})` - return WithWidthComponent - }, -})) - -vi.mock('react-admin', () => ({ - Link: ({ children, to, ...props }) => ( - <a href={to} {...props}> - {children} - </a> - ), -})) - -describe('ArtistLinkField', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('when rendering artists', () => { - it('renders artists from participants when available', () => { - const record = { - participants: { - artist: [ - { id: '1', name: 'Artist 1' }, - { id: '2', name: 'Artist 2' }, - ], - }, - } - - render(<ArtistLinkField record={record} source="artist" />) - - expect(screen.getByText('Artist 1')).toBeInTheDocument() - expect(screen.getByText('Artist 2')).toBeInTheDocument() - }) - - it('falls back to record[source] when participants not available', () => { - const record = { - artist: 'Fallback Artist', - artistId: '123', - } - - render(<ArtistLinkField record={record} source="artist" />) - - expect(screen.getByText('Fallback Artist')).toBeInTheDocument() - }) - - it('handles empty artists array', () => { - const record = { - participants: { - artist: [], - }, - } - - render(<ArtistLinkField record={record} source="artist" />) - - expect(intersperse).toHaveBeenCalledWith([], ' • ') - }) - }) - - describe('when handling remixers', () => { - it('adds remixers when showing artist role', () => { - const record = { - participants: { - artist: [{ id: '1', name: 'Artist 1' }], - remixer: [{ id: '2', name: 'Remixer 1' }], - }, - } - - render(<ArtistLinkField record={record} source="artist" />) - - expect(screen.getByText('Artist 1')).toBeInTheDocument() - expect(screen.getByText('Remixer 1')).toBeInTheDocument() - }) - - it('limits remixers to maximum of 2', () => { - const record = { - participants: { - artist: [{ id: '1', name: 'Artist 1' }], - remixer: [ - { id: '2', name: 'Remixer 1' }, - { id: '3', name: 'Remixer 2' }, - { id: '4', name: 'Remixer 3' }, - ], - }, - } - - render(<ArtistLinkField record={record} source="artist" />) - - expect(screen.getByText('Artist 1')).toBeInTheDocument() - expect(screen.getByText('Remixer 1')).toBeInTheDocument() - expect(screen.getByText('Remixer 2')).toBeInTheDocument() - expect(screen.queryByText('Remixer 3')).not.toBeInTheDocument() - }) - - it('deduplicates artists and remixers', () => { - const record = { - participants: { - artist: [{ id: '1', name: 'Duplicate Person' }], - remixer: [{ id: '1', name: 'Duplicate Person' }], - }, - } - - render(<ArtistLinkField record={record} source="artist" />) - - const links = screen.getAllByRole('link') - expect(links).toHaveLength(1) - expect(links[0]).toHaveTextContent('Duplicate Person') - }) - }) - - describe('when using parseAndReplaceArtists', () => { - it('uses parseAndReplaceArtists when role is albumartist', () => { - const record = { - albumArtist: 'Group Artist', - participants: { - albumartist: [{ id: '1', name: 'Group Artist' }], - }, - } - - render(<ArtistLinkField record={record} source="albumArtist" />) - - expect(screen.getByText('Group Artist')).toBeInTheDocument() - expect(screen.getByRole('link')).toHaveAttribute('href', '/artist/1') - }) - - it('uses parseAndReplaceArtists when role is artist', () => { - const record = { - artist: 'Main Artist', - participants: { - artist: [{ id: '1', name: 'Main Artist' }], - }, - } - - render(<ArtistLinkField record={record} source="artist" />) - - expect(screen.getByText('Main Artist')).toBeInTheDocument() - expect(screen.getByRole('link')).toHaveAttribute('href', '/artist/1') - }) - - it('adds remixers after parseAndReplaceArtists for artist role', () => { - const record = { - artist: 'Main Artist', - participants: { - artist: [{ id: '1', name: 'Main Artist' }], - remixer: [{ id: '2', name: 'Remixer 1' }], - }, - } - - render(<ArtistLinkField record={record} source="artist" />) - - const links = screen.getAllByRole('link') - expect(links).toHaveLength(2) - expect(links[0]).toHaveAttribute('href', '/artist/1') - expect(links[1]).toHaveAttribute('href', '/artist/2') - }) - }) - - describe('when handling artist deduplication', () => { - it('deduplicates artists with the same id', () => { - const record = { - participants: { - artist: [ - { id: '1', name: 'Duplicate Artist' }, - { id: '1', name: 'Duplicate Artist', subRole: 'Vocals' }, - ], - }, - } - - render(<ArtistLinkField record={record} source="artist" />) - - const links = screen.getAllByRole('link') - expect(links).toHaveLength(1) - expect(links[0]).toHaveTextContent('Duplicate Artist (Vocals)') - }) - - it('aggregates subroles for the same artist', () => { - const record = { - participants: { - artist: [ - { id: '1', name: 'Multi-Role Artist', subRole: 'Vocals' }, - { id: '1', name: 'Multi-Role Artist', subRole: 'Guitar' }, - ], - }, - } - - render(<ArtistLinkField record={record} source="artist" />) - - expect( - screen.getByText('Multi-Role Artist (Vocals, Guitar)'), - ).toBeInTheDocument() - }) - }) - - describe('when limiting displayed artists', () => { - it('limits the number of artists displayed', () => { - const record = { - participants: { - artist: [ - { id: '1', name: 'Artist 1' }, - { id: '2', name: 'Artist 2' }, - { id: '3', name: 'Artist 3' }, - { id: '4', name: 'Artist 4' }, - ], - }, - } - - render(<ArtistLinkField record={record} source="artist" limit={3} />) - - expect(screen.getByText('Artist 1')).toBeInTheDocument() - expect(screen.getByText('Artist 2')).toBeInTheDocument() - expect(screen.getByText('Artist 3')).toBeInTheDocument() - expect(screen.queryByText('Artist 4')).not.toBeInTheDocument() - expect(screen.getByText('...')).toBeInTheDocument() - }) - }) -}) diff --git a/ui/src/artist/ArtistSimpleList.jsx b/ui/src/common/ArtistSimpleList.js similarity index 85% rename from ui/src/artist/ArtistSimpleList.jsx rename to ui/src/common/ArtistSimpleList.js index 9dcae0470..de6994479 100644 --- a/ui/src/artist/ArtistSimpleList.jsx +++ b/ui/src/common/ArtistSimpleList.js @@ -2,13 +2,12 @@ import React from 'react' import PropTypes from 'prop-types' import List from '@material-ui/core/List' import ListItem from '@material-ui/core/ListItem' -import ListItemAvatar from '@material-ui/core/ListItemAvatar' import ListItemIcon from '@material-ui/core/ListItemIcon' import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction' import ListItemText from '@material-ui/core/ListItemText' import { makeStyles } from '@material-ui/core/styles' import { sanitizeListRestProps } from 'react-admin' -import { ArtistContextMenu, ArtworkAvatar, RatingField } from '../common' +import { ArtistContextMenu, RatingField } from './index' import config from '../config' const useStyles = makeStyles( @@ -24,10 +23,10 @@ const useStyles = makeStyles( top: '26px', }, }, - { name: 'RaArtistSimpleList' }, + { name: 'RaArtistSimpleList' } ) -const ArtistSimpleList = ({ +export const ArtistSimpleList = ({ linkType, className, classes: classesOverride, @@ -48,11 +47,7 @@ const ArtistSimpleList = ({ data[id] && ( <span key={id} onClick={() => linkType(id)}> <ListItem className={classes.listItem} button={true}> - <ListItemAvatar> - <ArtworkAvatar record={data[id]} /> - </ListItemAvatar> <ListItemText - style={{ marginLeft: '8px' }} primary={ <> <div className={classes.title}>{data[id].name}</div> @@ -74,7 +69,7 @@ const ArtistSimpleList = ({ </ListItemSecondaryAction> </ListItem> </span> - ), + ) )} </List> ) @@ -94,5 +89,3 @@ ArtistSimpleList.defaultProps = { hasBulkActions: false, selectedIds: [], } - -export default ArtistSimpleList diff --git a/ui/src/common/Artwork.jsx b/ui/src/common/Artwork.jsx deleted file mode 100644 index 21ac5e07b..000000000 --- a/ui/src/common/Artwork.jsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import PropTypes from 'prop-types' -import clsx from 'clsx' -import { makeStyles } from '@material-ui/core/styles' -import config from '../config' -import subsonic from '../subsonic' -import { useImageUrl } from './useImageUrl' -import { ThumbHashCanvas } from './ThumbHashCanvas' - -// Drives both the CSS transition and the timer that retires the placeholder, so they cannot drift. -const fadeMs = 150 - -const useStyles = makeStyles({ - // className supplies the size and shape; overflow:hidden clips the fills to it. - root: { - position: 'relative', - display: 'inline-flex', - overflow: 'hidden', - }, - fill: { - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - }, - img: { - opacity: 0, - transition: `opacity ${fadeMs}ms ease-out`, - '@media (prefers-reduced-motion: reduce)': { transition: 'none' }, - }, - imgVisible: { opacity: 1 }, - // Already-decoded blobs appear at once: fading them in would re-animate on every remount. - imgInstant: { opacity: 1, transition: 'none' }, -}) - -// Renders a cover through the shared useImageUrl blob cache, so it survives remounts without -// re-fetching. The image mounts only once its blob is ready, so it never renders broken. -export const Artwork = ({ - record, - size = config.uiCoverArtSize, - square = false, - fit = 'cover', - className, - title, - onClick, -}) => { - const classes = useStyles() - const url = record ? subsonic.getCoverArtUrl(record, size, square) : '' - const { imgUrl, fromCache } = useImageUrl(url) - - const [decoded, setDecoded] = useState(false) - const [faded, setFaded] = useState(false) - const frame = useRef(0) - useEffect(() => { - setDecoded(false) - setFaded(false) - return () => cancelAnimationFrame(frame.current) - }, [url]) - - // Two frames: a blob decodes from memory, so onLoad can beat the first paint of opacity 0, - // leaving the transition no start value and popping some tiles in while others fade. - const handleLoad = () => { - frame.current = requestAnimationFrame(() => { - frame.current = requestAnimationFrame(() => setDecoded(true)) - }) - } - - // Timer, not transitionend: under prefers-reduced-motion there is no transition to end. - useEffect(() => { - if (!decoded || faded) return undefined - const timer = setTimeout(() => setFaded(true), fadeMs) - return () => clearTimeout(timer) - }, [decoded, faded]) - - if (!record) return null - - // A blob already cached paints on the first frame, so it skips the fade. - const instant = fromCache - // Kept mounted until the fade ends; swapping on blob arrival would flash an empty container. - const showPlaceholder = !!record.thumbHash && !instant && !faded - // A square request is padded, not cropped, so `contain` keeps placeholder and image aligned. - const effectiveFit = square ? 'contain' : fit - const ratio = record.imageWidth / record.imageHeight - const handleClick = imgUrl && onClick ? onClick : undefined - return ( - <div - className={clsx(classes.root, className)} - onClick={handleClick} - style={{ cursor: handleClick ? 'pointer' : 'default' }} - > - {showPlaceholder && ( - <ThumbHashCanvas - hash={record.thumbHash} - ratio={ratio} - fit={effectiveFit} - className={classes.fill} - /> - )} - {imgUrl && ( - <img - src={imgUrl} - alt={title} - title={title} - className={clsx( - classes.fill, - classes.img, - instant && classes.imgInstant, - decoded && classes.imgVisible, - )} - style={{ objectFit: effectiveFit }} - onLoad={handleLoad} - /> - )} - </div> - ) -} - -Artwork.propTypes = { - record: PropTypes.object, - size: PropTypes.number, - square: PropTypes.bool, - fit: PropTypes.oneOf(['cover', 'contain']), - className: PropTypes.string, - title: PropTypes.string, - onClick: PropTypes.func, -} diff --git a/ui/src/common/Artwork.test.jsx b/ui/src/common/Artwork.test.jsx deleted file mode 100644 index 18b3c916f..000000000 --- a/ui/src/common/Artwork.test.jsx +++ /dev/null @@ -1,232 +0,0 @@ -import { render, fireEvent, act } from '@testing-library/react' -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('./useImageUrl', () => ({ useImageUrl: vi.fn() })) -vi.mock('../subsonic', () => ({ - default: { - // Mirrors the real hash suffix, so a refreshed record yields a different URL. - getCoverArtUrl: (record) => - '/rest/getCoverArt?id=al-1' + - (record.imageHash ? `_${record.imageHash}` : ''), - }, -})) -vi.mock('../config', () => ({ default: { uiCoverArtSize: 300 } })) - -import { useImageUrl } from './useImageUrl' -import { Artwork } from './Artwork' - -const withArt = { - id: 'al-1', - name: 'Album', - thumbHash: 'H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I', -} - -describe('Artwork', () => { - beforeEach(() => { - vi.clearAllMocks() - // jsdom has no 2D context; stub it so ThumbHashCanvas bails cleanly without console noise - HTMLCanvasElement.prototype.getContext = vi.fn(() => null) - }) - - it('renders nothing without a record', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: false }) - const { container } = render(<Artwork record={null} />) - expect(container.firstChild).toBeNull() - }) - - it('shows the placeholder and no <img> while loading', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const { container } = render(<Artwork record={withArt} title="Album" />) - expect(container.querySelector('canvas')).not.toBeNull() - expect(container.querySelector('img')).toBeNull() - }) - - it('shows neither a broken <img> nor a canvas while loading a record with no thumbhash', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const { container } = render(<Artwork record={{ id: 'al-2', name: 'X' }} />) - expect(container.querySelector('img')).toBeNull() - expect(container.querySelector('canvas')).toBeNull() - }) - - // The placeholder has to land exactly where the image will, or it jumps when the image swaps in. - it('shapes the placeholder like the artwork and fits it like the image', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const nonSquare = { ...withArt, imageWidth: 1200, imageHeight: 800 } - const { container } = render( - <Artwork record={nonSquare} fit="contain" title="Album" />, - ) - const canvas = container.querySelector('canvas') - expect(canvas.width).toBe(32) - expect(canvas.height).toBe(21) - expect(canvas.style.objectFit).toBe('contain') - }) - - // A square request is padded, not cropped, so the placeholder has to letterbox with it. - it('letterboxes the placeholder when the server pads a non-square image to a square', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const nonSquare = { ...withArt, imageWidth: 1200, imageHeight: 800 } - const { container } = render(<Artwork record={nonSquare} square />) - const canvas = container.querySelector('canvas') - expect(canvas.width).toBe(32) - expect(canvas.height).toBe(21) - expect(canvas.style.objectFit).toBe('contain') - }) - - // The padded square is aspect-fit, so `square` overrides fit="cover" as it does for the canvas. - it('fits the image itself with contain when the server padded to a square', () => { - useImageUrl.mockReturnValue({ - imgUrl: 'blob:abc', - loading: false, - fromCache: true, - }) - const { container } = render( - <Artwork record={withArt} square fit="cover" title="Album" />, - ) - expect(container.querySelector('img').style.objectFit).toBe('contain') - }) - - it('fills the box for square artwork, the overwhelmingly common case', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const sq = { ...withArt, imageWidth: 600, imageHeight: 600 } - const { container } = render(<Artwork record={sq} square />) - const canvas = container.querySelector('canvas') - expect(canvas.width).toBe(32) - expect(canvas.height).toBe(32) - }) - - // Without dimensions the placeholder falls back to the aspect the hash itself carries. - it('falls back to the hash own aspect when the record has no dimensions', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const { container } = render(<Artwork record={withArt} />) - const canvas = container.querySelector('canvas') - expect(canvas.width).toBe(32) - expect(canvas.height).toBe(32) - }) - - it('mounts the image only once its blob is ready', () => { - useImageUrl.mockReturnValue({ - imgUrl: 'blob:abc', - loading: false, - fromCache: true, - }) - const { container } = render(<Artwork record={withArt} title="Album" />) - const img = container.querySelector('img') - expect(img).not.toBeNull() - expect(img.getAttribute('src')).toBe('blob:abc') - }) - - it('keeps the placeholder under the image until the fade ends', () => { - vi.useFakeTimers() - try { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const { container, rerender } = render(<Artwork record={withArt} />) - - useImageUrl.mockReturnValue({ - imgUrl: 'blob:abc', - loading: false, - fromCache: false, - }) - rerender(<Artwork record={withArt} />) - const img = container.querySelector('img') - expect(img).not.toBeNull() - expect(container.querySelector('canvas')).not.toBeNull() - - // The clock ends the fade, not transitionend, which never fires under reduced-motion. - act(() => { - fireEvent.load(img) - }) - expect(container.querySelector('canvas')).not.toBeNull() - act(() => { - fireEvent.transitionEnd(img) - }) - expect(container.querySelector('canvas')).not.toBeNull() - // Two frames pass before the fade starts, so opacity 0 gets painted first. - act(() => { - vi.advanceTimersByTime(32) - }) - expect(container.querySelector('canvas')).not.toBeNull() - act(() => { - vi.advanceTimersByTime(150) - }) - expect(container.querySelector('canvas')).toBeNull() - } finally { - vi.useRealTimers() - } - }) - - it('does not fade an image that was already cached on mount', () => { - useImageUrl.mockReturnValue({ - imgUrl: 'blob:abc', - loading: false, - fromCache: true, - }) - const { container } = render(<Artwork record={withArt} />) - expect(container.querySelector('canvas')).toBeNull() - expect(container.querySelector('img').className).toContain('imgInstant') - }) - - it('keeps the placeholder visible when the image never decodes', () => { - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const { container, rerender } = render(<Artwork record={withArt} />) - useImageUrl.mockReturnValue({ - imgUrl: 'blob:abc', - loading: false, - fromCache: false, - }) - rerender(<Artwork record={withArt} />) - - expect(container.querySelector('canvas')).not.toBeNull() - }) - - it('fires onClick only when the image is loaded', () => { - const onClick = vi.fn() - useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) - const { container, rerender } = render( - <Artwork record={withArt} onClick={onClick} />, - ) - container.firstChild.click() - expect(onClick).not.toHaveBeenCalled() - - useImageUrl.mockReturnValue({ - imgUrl: 'blob:abc', - loading: false, - fromCache: false, - }) - rerender(<Artwork record={withArt} onClick={onClick} />) - container.firstChild.click() - expect(onClick).toHaveBeenCalledTimes(1) - }) - - it('shows the placeholder for a cached fetch failure, which has no blob', () => { - useImageUrl.mockReturnValue({ - imgUrl: null, - loading: false, - error: true, - fromCache: false, - }) - const { container } = render(<Artwork record={withArt} />) - expect(container.querySelector('img')).toBeNull() - expect(container.querySelector('canvas')).not.toBeNull() - }) - - it('falls back to the placeholder when a refresh swaps in an uncached hash', () => { - useImageUrl.mockReturnValue({ - imgUrl: 'blob:old', - loading: false, - fromCache: true, - }) - const cached = { ...withArt, imageHash: 'aaaa' } - const { container, rerender } = render(<Artwork record={cached} />) - expect(container.querySelector('canvas')).toBeNull() - - useImageUrl.mockReturnValue({ - imgUrl: null, - loading: true, - fromCache: false, - }) - rerender(<Artwork record={{ ...withArt, imageHash: 'bbbb' }} />) - - expect(container.querySelector('img')).toBeNull() - expect(container.querySelector('canvas')).not.toBeNull() - }) -}) diff --git a/ui/src/common/ArtworkAvatar.jsx b/ui/src/common/ArtworkAvatar.jsx deleted file mode 100644 index 7691050c6..000000000 --- a/ui/src/common/ArtworkAvatar.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useRecordContext } from 'react-admin' -import { makeStyles } from '@material-ui/core/styles' -import clsx from 'clsx' -import config from '../config' -import { Artwork } from './Artwork' - -const useStyles = makeStyles({ - avatar: { - width: '55px', - height: '55px', - }, - square: { - borderRadius: '4px', - }, - circular: { - borderRadius: '50%', - }, -}) - -export const ArtworkAvatar = ({ record: recordProp, variant = 'circular' }) => { - const classes = useStyles() - const recordContext = useRecordContext() - const record = recordProp || recordContext - if (!record) return null - const square = variant !== 'circular' - return ( - <Artwork - record={record} - size={config.uiCoverArtSize} - square={square} - className={clsx( - classes.avatar, - square ? classes.square : classes.circular, - )} - title={record.name} - /> - ) -} - -ArtworkAvatar.defaultProps = { label: '', sortable: false } diff --git a/ui/src/common/BatchPlayButton.jsx b/ui/src/common/BatchPlayButton.js similarity index 98% rename from ui/src/common/BatchPlayButton.jsx rename to ui/src/common/BatchPlayButton.js index f4c136922..f3b68f35e 100644 --- a/ui/src/common/BatchPlayButton.jsx +++ b/ui/src/common/BatchPlayButton.js @@ -30,7 +30,7 @@ export const BatchPlayButton = ({ // Add tracks to a map for easy lookup by ID, needed for the next step const tracks = response.data.reduce( (acc, cur) => ({ ...acc, [cur.id]: cur }), - {}, + {} ) // Add the tracks to the queue in the selection order dispatch(action(tracks, selectedIds)) diff --git a/ui/src/common/BatchShareButton.jsx b/ui/src/common/BatchShareButton.js similarity index 94% rename from ui/src/common/BatchShareButton.jsx rename to ui/src/common/BatchShareButton.js index 8294953bb..bcf76c3d4 100644 --- a/ui/src/common/BatchShareButton.jsx +++ b/ui/src/common/BatchShareButton.js @@ -18,8 +18,8 @@ export const BatchShareButton = ({ resource, selectedIds, className }) => { _: 'ra.action.bulk_actions', smart_count: selectedIds.length, }), - 'message.shareBatchDialogTitle', - ), + 'message.shareBatchDialogTitle' + ) ) unselectAll(resource) } diff --git a/ui/src/common/BitrateField.jsx b/ui/src/common/BitrateField.js similarity index 100% rename from ui/src/common/BitrateField.jsx rename to ui/src/common/BitrateField.js diff --git a/ui/src/common/CollapsibleComment.jsx b/ui/src/common/CollapsibleComment.jsx deleted file mode 100644 index 77750e7f4..000000000 --- a/ui/src/common/CollapsibleComment.jsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useCallback, useMemo, useState } from 'react' -import { Typography, Collapse } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import AnchorMe from './Linkify' -import clsx from 'clsx' - -const useStyles = makeStyles( - (theme) => ({ - commentBlock: { - display: 'inline-block', - marginTop: '1em', - float: 'left', - wordBreak: 'break-word', - }, - pointerCursor: { - cursor: 'pointer', - }, - }), - { - name: 'NDCollapsibleComment', - }, -) - -export const CollapsibleComment = ({ record }) => { - const classes = useStyles() - const [expanded, setExpanded] = useState(false) - - const lines = useMemo( - () => record.comment?.split('\n') || [], - [record.comment], - ) - const formatted = useMemo(() => { - return lines.map((line, idx) => ( - <span key={record.id + '-comment-' + idx}> - <AnchorMe text={line} /> - <br /> - </span> - )) - }, [lines, record.id]) - - const handleExpandClick = useCallback(() => { - setExpanded(!expanded) - }, [expanded, setExpanded]) - - if (lines.length === 0) { - return null - } - - return ( - <Collapse - collapsedHeight={'2em'} - in={expanded} - timeout={'auto'} - className={clsx( - classes.commentBlock, - lines.length > 1 && classes.pointerCursor, - )} - > - <Typography variant={'h6'} onClick={handleExpandClick}> - {formatted} - </Typography> - </Collapse> - ) -} diff --git a/ui/src/common/ContextMenus.jsx b/ui/src/common/ContextMenus.js similarity index 74% rename from ui/src/common/ContextMenus.jsx rename to ui/src/common/ContextMenus.js index 4407392c1..a492548a4 100644 --- a/ui/src/common/ContextMenus.jsx +++ b/ui/src/common/ContextMenus.js @@ -5,14 +5,8 @@ import IconButton from '@material-ui/core/IconButton' import Menu from '@material-ui/core/Menu' import MenuItem from '@material-ui/core/MenuItem' import MoreVertIcon from '@material-ui/icons/MoreVert' -import { MdQuestionMark } from 'react-icons/md' import { makeStyles } from '@material-ui/core/styles' -import { - useDataProvider, - useNotify, - usePermissions, - useTranslate, -} from 'react-admin' +import { useDataProvider, useNotify, useTranslate } from 'react-admin' import clsx from 'clsx' import { playNext, @@ -29,8 +23,6 @@ import { import { LoveButton } from './LoveButton' import config from '../config' import { formatBytes } from '../utils' -import { artistDownloadSize } from './artist' -import { useRefreshMetadata } from './useRefreshMetadata' const useStyles = makeStyles({ noWrap: { @@ -41,25 +33,6 @@ const useStyles = makeStyles({ }, }) -const MoreButton = ({ record, onClick, info, ...rest }) => { - const handleClick = record.missing - ? (e) => { - e.preventDefault() - info.action(record) - e.stopPropagation() - } - : onClick - return ( - <IconButton onClick={handleClick} size={'small'} {...rest}> - {record?.missing ? ( - <MdQuestionMark fontSize={'large'} /> - ) : ( - <MoreVertIcon fontSize={'small'} /> - )} - </IconButton> - ) -} - const ContextMenu = ({ resource, showLove, @@ -67,7 +40,6 @@ const ContextMenu = ({ color, className, songQueryParams, - hideShare, hideInfo, }) => { const classes = useStyles({ color }) @@ -75,13 +47,8 @@ const ContextMenu = ({ const dispatch = useDispatch() const translate = useTranslate() const notify = useNotify() - const { permissions } = usePermissions() - const refreshMetadata = useRefreshMetadata() const [anchorEl, setAnchorEl] = useState(null) - const isArtist = resource === 'artist' - const downloadSize = isArtist ? artistDownloadSize(record) : record?.size - const options = { play: { enabled: true, @@ -113,36 +80,28 @@ const ContextMenu = ({ label: translate('resources.album.actions.addToPlaylist'), action: (data, ids) => dispatch(openAddToPlaylist({ selectedIds: ids })), }, - ...(!hideShare && { - share: { - enabled: config.enableSharing && (!isArtist || downloadSize), - needData: false, - label: translate('ra.action.share'), - action: (record) => - dispatch(openShareMenu([record.id], resource, record.name)), - }, - }), - download: { - enabled: config.enableDownloads && downloadSize, + share: { + enabled: config.enableSharing, needData: false, - label: `${translate('ra.action.download')} (${formatBytes(downloadSize)})`, + label: translate('ra.action.share'), + action: (record) => + dispatch(openShareMenu([record.id], resource, record.name)), + }, + download: { + enabled: config.enableDownloads && record.size, + needData: false, + label: `${translate('ra.action.download')} (${formatBytes(record.size)})`, action: () => { dispatch( openDownloadMenu( record, record.duration !== undefined ? DOWNLOAD_MENU_ALBUM - : DOWNLOAD_MENU_ARTIST, - ), + : DOWNLOAD_MENU_ARTIST + ) ) }, }, - refresh: { - enabled: permissions === 'admin', - needData: false, - label: translate('resources.album.actions.refresh'), - action: (record) => refreshMetadata(resource, record.id), - }, ...(!hideInfo && { info: { enabled: true, @@ -168,7 +127,7 @@ const ContextMenu = ({ let extractSongsData = function (response) { const data = response.data.reduce( (acc, cur) => ({ ...acc, [cur.id]: cur }), - {}, + {} ) const ids = response.data.map((r) => r.id) return { data, ids } @@ -196,29 +155,24 @@ const ContextMenu = ({ const open = Boolean(anchorEl) - if (!record) { - return null - } - - const present = !record.missing - return ( <span className={clsx(classes.noWrap, className)}> <LoveButton record={record} resource={resource} - visible={config.enableFavourites && showLove && present} + visible={config.enableFavourites && showLove} color={color} /> - <MoreButton - record={record} - onClick={handleClick} - info={options.info} + <IconButton aria-label="more" aria-controls="context-menu" aria-haspopup="true" className={classes.menu} - /> + onClick={handleClick} + size={'small'} + > + <MoreVertIcon fontSize={'small'} /> + </IconButton> <Menu id="context-menu" anchorEl={anchorEl} @@ -232,7 +186,7 @@ const ContextMenu = ({ <MenuItem value={key} key={key} onClick={handleItemClick}> {options[key].label} </MenuItem> - ), + ) )} </Menu> </span> @@ -246,11 +200,11 @@ export const AlbumContextMenu = (props) => resource={'album'} songQueryParams={{ pagination: { page: 1, perPage: -1 }, - sort: { field: 'album', order: 'ASC' }, + sort: { field: 'releaseDate, discNumber, trackNumber', order: 'ASC' }, filter: { album_id: props.record.id, + release_date: props.releaseDate, disc_number: props.discNumber, - missing: false, }, }} /> @@ -277,10 +231,10 @@ export const ArtistContextMenu = (props) => songQueryParams={{ pagination: { page: 1, perPage: 200 }, sort: { - field: 'album', + field: 'album, releaseDate, discNumber, trackNumber', order: 'ASC', }, - filter: { album_artist_id: props.record.id, missing: false }, + filter: { album_artist_id: props.record.id }, }} /> ) : null diff --git a/ui/src/common/ContextMenus.test.jsx b/ui/src/common/ContextMenus.test.jsx deleted file mode 100644 index 59a7de966..000000000 --- a/ui/src/common/ContextMenus.test.jsx +++ /dev/null @@ -1,133 +0,0 @@ -import React from 'react' -import { render, fireEvent, screen } from '@testing-library/react' -import { TestContext } from 'ra-test' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { ThemeProvider, createTheme } from '@material-ui/core/styles' -import { AlbumContextMenu, ArtistContextMenu } from './ContextMenus' - -const mockDispatch = vi.fn() -vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch })) - -const { mockConfig } = vi.hoisted(() => ({ - mockConfig: { - enableSharing: true, - enableDownloads: true, - enableFavourites: false, - }, -})) -vi.mock('../config', () => ({ default: mockConfig })) - -const { mockPermissions, mockRefreshMetadata } = vi.hoisted(() => ({ - mockPermissions: { value: 'admin' }, - mockRefreshMetadata: vi.fn(), -})) - -vi.mock('react-admin', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useNotify: () => vi.fn(), - usePermissions: () => ({ permissions: mockPermissions.value }), - useDataProvider: () => ({ - getList: vi.fn(), - refreshMetadata: mockRefreshMetadata, - }), - useTranslate: () => (x) => x, - } -}) - -describe('ContextMenus', () => { - const renderMenu = (Menu, record) => { - render( - <TestContext> - <ThemeProvider theme={createTheme()}> - <Menu record={record} /> - </ThemeProvider> - </TestContext>, - ) - fireEvent.click(screen.getByLabelText('more')) - } - - beforeEach(() => { - vi.clearAllMocks() - mockConfig.enableSharing = true - mockConfig.enableDownloads = true - mockPermissions.value = 'admin' - }) - - describe('ArtistContextMenu', () => { - const withAlbumArtist = { - id: 'ar1', - name: 'Artist', - stats: { albumartist: { songCount: 3, albumCount: 1, size: 1024 } }, - } - - it('shows the album-artist size on the download item', () => { - renderMenu(ArtistContextMenu, withAlbumArtist) - expect(screen.getByText('ra.action.download (1 KB)')).toBeInTheDocument() - }) - - it('hides download and share for artists with no album-artist content', () => { - renderMenu(ArtistContextMenu, { id: 'ar1', name: 'Artist', stats: {} }) - expect(screen.queryByText(/ra\.action\.download/)).not.toBeInTheDocument() - expect(screen.queryByText('ra.action.share')).not.toBeInTheDocument() - }) - }) - - describe('AlbumContextMenu', () => { - it('uses the total size on the album download item', () => { - renderMenu(AlbumContextMenu, { - id: 'al1', - name: 'Album', - duration: 100, - size: 1024 * 1024, - }) - expect(screen.getByText('ra.action.download (1 MB)')).toBeInTheDocument() - }) - }) - - describe('refresh metadata', () => { - it('shows the item for admins on the album menu', () => { - renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) - expect( - screen.getByText('resources.album.actions.refresh'), - ).toBeInTheDocument() - }) - - // Menu order comes from key insertion order in the options object, so it is easy to - // change by accident when adding an entry. - it('places the item directly above Get Info', () => { - renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) - const labels = screen - .getAllByRole('menuitem') - .map((item) => item.textContent) - const refreshAt = labels.indexOf('resources.album.actions.refresh') - const infoAt = labels.indexOf('resources.album.actions.info') - - expect(refreshAt).toBeGreaterThanOrEqual(0) - expect(infoAt).toEqual(refreshAt + 1) - }) - - it('shows the item for admins on the artist menu', () => { - renderMenu(ArtistContextMenu, { id: 'ar1', name: 'Artist', stats: {} }) - expect( - screen.getByText('resources.album.actions.refresh'), - ).toBeInTheDocument() - }) - - it('hides the item for regular users', () => { - mockPermissions.value = 'regular' - renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) - expect( - screen.queryByText('resources.album.actions.refresh'), - ).not.toBeInTheDocument() - }) - - it('calls refreshMetadata with the resource and id', () => { - mockRefreshMetadata.mockResolvedValue({}) - renderMenu(AlbumContextMenu, { id: 'al1', name: 'Album', songCount: 1 }) - fireEvent.click(screen.getByText('resources.album.actions.refresh')) - expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al1') - }) - }) -}) diff --git a/ui/src/common/DateField.jsx b/ui/src/common/DateField.js similarity index 51% rename from ui/src/common/DateField.jsx rename to ui/src/common/DateField.js index dac9cff08..fab15b53c 100644 --- a/ui/src/common/DateField.jsx +++ b/ui/src/common/DateField.js @@ -1,14 +1,11 @@ import React from 'react' -import { isDateSet } from '../utils/validations' import { DateField as RADateField } from 'react-admin' -import { useDateLocale } from '../i18n/useDateLocale' export const DateField = (props) => { const { record, source } = props - const locale = useDateLocale() const value = record?.[source] - if (!isDateSet(value)) return null - return <RADateField locales={locale} {...props} /> + if (value === '0001-01-01T00:00:00Z' || value === null) return null + return <RADateField {...props} /> } DateField.defaultProps = { diff --git a/ui/src/common/DateField.test.jsx b/ui/src/common/DateField.test.jsx deleted file mode 100644 index 47d56756b..000000000 --- a/ui/src/common/DateField.test.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { DateField } from './DateField' - -vi.mock('react-admin', async (importOriginal) => ({ - ...(await importOriginal()), - useLocale: vi.fn(), -})) - -describe('<DateField>', () => { - const record = { id: '1', updatedAt: '2026-09-17T14:30:00Z' } - - beforeEach(async () => { - vi.clearAllMocks() - vi.spyOn(navigator, 'languages', 'get').mockReturnValue([]) - const { useLocale } = await import('react-admin') - vi.mocked(useLocale).mockReturnValue('de') - }) - - it('formats the date using the selected language', () => { - render(<DateField record={record} source="updatedAt" />) - expect(screen.getByText('17.9.2026')).toBeInTheDocument() - }) - - it('renders nothing when the date is not set', () => { - const { container } = render( - <DateField record={{ id: '1' }} source="updatedAt" />, - ) - expect(container).toBeEmptyDOMElement() - }) -}) diff --git a/ui/src/common/DocLink.jsx b/ui/src/common/DocLink.js similarity index 100% rename from ui/src/common/DocLink.jsx rename to ui/src/common/DocLink.js diff --git a/ui/src/common/DurationField.jsx b/ui/src/common/DurationField.js similarity index 93% rename from ui/src/common/DurationField.jsx rename to ui/src/common/DurationField.js index 63fe8b701..730acdbee 100644 --- a/ui/src/common/DurationField.jsx +++ b/ui/src/common/DurationField.js @@ -8,7 +8,6 @@ export const DurationField = ({ source, ...rest }) => { try { return <span>{formatDuration(record[source])}</span> } catch (e) { - // eslint-disable-next-line no-console console.log('Error in DurationField! Record:', record) return <span>00:00</span> } diff --git a/ui/src/common/ImageUploadOverlay.jsx b/ui/src/common/ImageUploadOverlay.jsx deleted file mode 100644 index a370e40fe..000000000 --- a/ui/src/common/ImageUploadOverlay.jsx +++ /dev/null @@ -1,139 +0,0 @@ -import { IconButton, Tooltip } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import PhotoCameraIcon from '@material-ui/icons/PhotoCamera' -import DeleteIcon from '@material-ui/icons/Delete' -import { useTranslate, useNotify, useRefresh } from 'react-admin' -import { useCallback, useRef } from 'react' -import config from '../config' -import { REST_URL } from '../consts' -import { httpClient } from '../dataProvider' - -const useStyles = makeStyles(() => ({ - coverOverlay: { - position: 'absolute', - bottom: 0, - right: 0, - display: 'flex', - gap: '2px', - padding: '2px', - backgroundColor: 'rgba(0,0,0,0.5)', - borderRadius: '4px 0 0 0', - opacity: 0, - transition: 'opacity 0.2s ease-in-out', - '*:hover > &': { - opacity: 1, - }, - }, - overlayButton: { - color: '#fff', - padding: '4px', - '&:hover': { - backgroundColor: 'rgba(255,255,255,0.2)', - }, - }, - overlayIcon: { - fontSize: '1.2rem', - }, -})) - -export const ImageUploadOverlay = ({ - entityType, - entityId, - hasUploadedImage, - onImageChange, -}) => { - const translate = useTranslate() - const notify = useNotify() - const refresh = useRefresh() - const classes = useStyles() - const fileInputRef = useRef(null) - - const canEdit = - config.enableArtworkUpload || localStorage.getItem('role') === 'admin' - - const handleUploadClick = useCallback((e) => { - e.stopPropagation() - if (fileInputRef.current) { - fileInputRef.current.click() - } - }, []) - - const handleFileChange = useCallback( - async (e) => { - const file = e.target.files[0] - if (!file || !entityId) return - - const formData = new FormData() - formData.append('image', file) - - try { - await httpClient(`${REST_URL}/${entityType}/${entityId}/image`, { - method: 'POST', - headers: new Headers({}), - body: formData, - }) - notify(`message.coverUploaded`, 'success') - if (onImageChange) onImageChange() - refresh() - } catch (err) { - notify(`message.coverUploadError`, 'warning') - } - - e.target.value = '' - }, - [entityType, entityId, notify, refresh, onImageChange], - ) - - const handleRemoveCover = useCallback( - async (e) => { - e.stopPropagation() - if (!entityId) return - - try { - await httpClient(`${REST_URL}/${entityType}/${entityId}/image`, { - method: 'DELETE', - }) - notify(`message.coverRemoved`, 'success') - if (onImageChange) onImageChange() - refresh() - } catch (err) { - notify(`message.coverRemoveError`, 'warning') - } - }, - [entityType, entityId, notify, refresh, onImageChange], - ) - - if (!canEdit) return null - - return ( - <div className={classes.coverOverlay}> - <Tooltip title={translate(`message.uploadCover`)}> - <IconButton - className={classes.overlayButton} - onClick={handleUploadClick} - size="small" - > - <PhotoCameraIcon className={classes.overlayIcon} /> - </IconButton> - </Tooltip> - {hasUploadedImage && ( - <Tooltip title={translate(`message.removeCover`)}> - <IconButton - className={classes.overlayButton} - onClick={handleRemoveCover} - size="small" - > - <DeleteIcon className={classes.overlayIcon} /> - </IconButton> - </Tooltip> - )} - <input - ref={fileInputRef} - type="file" - accept="image/*" - style={{ display: 'none' }} - onChange={handleFileChange} - /> - </div> - ) -} diff --git a/ui/src/common/LibrarySelector.jsx b/ui/src/common/LibrarySelector.jsx deleted file mode 100644 index 1e89d3ec6..000000000 --- a/ui/src/common/LibrarySelector.jsx +++ /dev/null @@ -1,221 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import { useDataProvider, useTranslate, useRefresh } from 'react-admin' -import { - Box, - Chip, - ClickAwayListener, - FormControl, - FormGroup, - FormControlLabel, - Checkbox, - Typography, - Paper, - Popper, - makeStyles, -} from '@material-ui/core' -import { ExpandMore, ExpandLess, LibraryMusic } from '@material-ui/icons' -import { setSelectedLibraries, setUserLibraries } from '../actions' -import { useRefreshOnEvents } from './useRefreshOnEvents' - -const useStyles = makeStyles((theme) => ({ - root: { - marginTop: theme.spacing(3), - marginBottom: theme.spacing(3), - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), - display: 'flex', - justifyContent: 'center', - }, - chip: { - borderRadius: theme.spacing(1), - height: theme.spacing(4.8), - fontSize: '1rem', - fontWeight: 'normal', - minWidth: '210px', - justifyContent: 'flex-start', - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(1), - marginTop: theme.spacing(0.1), - '& .MuiChip-label': { - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(1), - }, - '& .MuiChip-icon': { - fontSize: '1.2rem', - marginLeft: theme.spacing(0.5), - }, - }, - popper: { - zIndex: 1300, - }, - paper: { - padding: theme.spacing(2), - marginTop: theme.spacing(1), - minWidth: 300, - maxWidth: 400, - }, - headerContainer: { - display: 'flex', - alignItems: 'center', - marginBottom: 0, - }, - masterCheckbox: { - padding: '7px', - marginLeft: '-9px', - marginRight: 0, - }, -})) - -const LibrarySelector = () => { - const classes = useStyles() - const dispatch = useDispatch() - const dataProvider = useDataProvider() - const translate = useTranslate() - const refresh = useRefresh() - const [anchorEl, setAnchorEl] = useState(null) - const [open, setOpen] = useState(false) - - const { userLibraries, selectedLibraries } = useSelector( - (state) => state.library, - ) - - // Load user's libraries when component mounts - const loadUserLibraries = useCallback(async () => { - const userId = localStorage.getItem('userId') - if (userId) { - try { - const { data } = await dataProvider.getOne('user', { id: userId }) - const libraries = data.libraries || [] - dispatch(setUserLibraries(libraries)) - } catch (error) { - // eslint-disable-next-line no-console - console.warn( - 'Could not load user libraries (this may be expected for non-admin users):', - error, - ) - } - } - }, [dataProvider, dispatch]) - - // Initial load - useEffect(() => { - loadUserLibraries() - }, [loadUserLibraries]) - - // Reload user libraries when library changes occur - useRefreshOnEvents({ - events: ['library', 'user'], - onRefresh: loadUserLibraries, - }) - - // Don't render if user has no libraries or only has one library - if (!userLibraries.length || userLibraries.length === 1) { - return null - } - - const handleToggle = (event) => { - setAnchorEl(event.currentTarget) - const wasOpen = open - setOpen(!open) - // Refresh data when closing the dropdown - if (wasOpen) { - refresh() - } - } - - const handleClose = () => { - setOpen(false) - refresh() - } - - const handleLibraryToggle = (libraryId) => { - const newSelection = selectedLibraries.includes(libraryId) - ? selectedLibraries.filter((id) => id !== libraryId) - : [...selectedLibraries, libraryId] - - dispatch(setSelectedLibraries(newSelection)) - } - - const handleMasterCheckboxChange = () => { - if (isAllSelected) { - dispatch(setSelectedLibraries([])) - } else { - const allIds = userLibraries.map((lib) => lib.id) - dispatch(setSelectedLibraries(allIds)) - } - } - - const selectedCount = selectedLibraries.length - const totalCount = userLibraries.length - const isAllSelected = selectedCount === totalCount - const isNoneSelected = selectedCount === 0 - const isIndeterminate = selectedCount > 0 && selectedCount < totalCount - - const displayText = isNoneSelected - ? translate('menu.librarySelector.none') + ` (0 of ${totalCount})` - : isAllSelected - ? translate('menu.librarySelector.allLibraries', { count: totalCount }) - : translate('menu.librarySelector.multipleLibraries', { - selected: selectedCount, - total: totalCount, - }) - - return ( - <Box className={classes.root}> - <Chip - icon={<LibraryMusic />} - label={displayText} - onClick={handleToggle} - onDelete={open ? handleToggle : undefined} - deleteIcon={open ? <ExpandLess /> : <ExpandMore />} - variant="outlined" - className={classes.chip} - /> - - <Popper - open={open} - anchorEl={anchorEl} - placement="bottom-start" - className={classes.popper} - > - <ClickAwayListener onClickAway={handleClose}> - <Paper className={classes.paper}> - <Box className={classes.headerContainer}> - <Checkbox - checked={isAllSelected} - indeterminate={isIndeterminate} - onChange={handleMasterCheckboxChange} - size="small" - className={classes.masterCheckbox} - /> - <Typography> - {translate('menu.librarySelector.selectLibraries')}: - </Typography> - </Box> - - <FormControl component="fieldset" variant="standard" fullWidth> - <FormGroup> - {userLibraries.map((library) => ( - <FormControlLabel - key={library.id} - control={ - <Checkbox - checked={selectedLibraries.includes(library.id)} - onChange={() => handleLibraryToggle(library.id)} - size="small" - /> - } - label={library.name} - /> - ))} - </FormGroup> - </FormControl> - </Paper> - </ClickAwayListener> - </Popper> - </Box> - ) -} - -export default LibrarySelector diff --git a/ui/src/common/LibrarySelector.test.jsx b/ui/src/common/LibrarySelector.test.jsx deleted file mode 100644 index 13b607887..000000000 --- a/ui/src/common/LibrarySelector.test.jsx +++ /dev/null @@ -1,517 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import LibrarySelector from './LibrarySelector' - -// Mock dependencies -const mockDispatch = vi.fn() -const mockDataProvider = { - getOne: vi.fn(), -} -const mockIdentity = { username: 'testuser' } -const mockRefresh = vi.fn() -const mockTranslate = vi.fn((key, options = {}) => { - const translations = { - 'menu.librarySelector.allLibraries': `All Libraries (${options.count || 0})`, - 'menu.librarySelector.multipleLibraries': `${options.selected || 0} of ${options.total || 0} Libraries`, - 'menu.librarySelector.none': 'None', - 'menu.librarySelector.selectLibraries': 'Select Libraries', - } - return translations[key] || key -}) - -vi.mock('react-redux', () => ({ - useDispatch: () => mockDispatch, - useSelector: vi.fn(), -})) - -vi.mock('react-admin', () => ({ - useDataProvider: () => mockDataProvider, - useGetIdentity: () => ({ identity: mockIdentity }), - useTranslate: () => mockTranslate, - useRefresh: () => mockRefresh, -})) - -// Mock Material-UI components -vi.mock('@material-ui/core', () => ({ - Box: ({ children, className, ...props }) => ( - <div className={className} {...props}> - {children} - </div> - ), - Chip: ({ label, onClick, onDelete, deleteIcon, icon, ...props }) => ( - <button onClick={onClick} {...props}> - {icon} - {label} - {deleteIcon && <span onClick={onDelete}>{deleteIcon}</span>} - </button> - ), - ClickAwayListener: ({ children, onClickAway }) => ( - <div data-testid="click-away-listener" onMouseDown={onClickAway}> - {children} - </div> - ), - Collapse: ({ children, in: inProp }) => - inProp ? <div>{children}</div> : null, - FormControl: ({ children }) => <div>{children}</div>, - FormGroup: ({ children }) => <div>{children}</div>, - FormControlLabel: ({ control, label }) => ( - <label> - {control} - {label} - </label> - ), - Checkbox: ({ - checked, - indeterminate, - onChange, - size, - className, - ...props - }) => ( - <input - type="checkbox" - checked={checked} - ref={(el) => { - if (el) el.indeterminate = indeterminate - }} - onChange={onChange} - className={className} - {...props} - /> - ), - Typography: ({ children, variant, ...props }) => ( - <span {...props}>{children}</span> - ), - Paper: ({ children, className }) => ( - <div className={className}>{children}</div> - ), - Popper: ({ open, children, anchorEl, placement, className }) => - open ? ( - <div className={className} data-testid="popper"> - {children} - </div> - ) : null, - makeStyles: (styles) => () => { - if (typeof styles === 'function') { - return styles({ - spacing: (value) => `${value * 8}px`, - palette: { divider: '#ccc' }, - shape: { borderRadius: 4 }, - }) - } - return styles - }, -})) - -vi.mock('@material-ui/icons', () => ({ - ExpandMore: () => <span data-testid="expand-more">▼</span>, - ExpandLess: () => <span data-testid="expand-less">▲</span>, - LibraryMusic: () => <span data-testid="library-music">🎵</span>, -})) - -// Mock actions -vi.mock('../actions', () => ({ - setSelectedLibraries: (libraries) => ({ - type: 'SET_SELECTED_LIBRARIES', - data: libraries, - }), - setUserLibraries: (libraries) => ({ - type: 'SET_USER_LIBRARIES', - data: libraries, - }), -})) - -describe('LibrarySelector', () => { - const mockLibraries = [ - { id: '1', name: 'Music Library', path: '/music' }, - { id: '2', name: 'Podcasts', path: '/podcasts' }, - { id: '3', name: 'Audiobooks', path: '/audiobooks' }, - ] - - const defaultState = { - userLibraries: mockLibraries, - selectedLibraries: ['1'], - } - - let mockUseSelector - - beforeEach(async () => { - vi.clearAllMocks() - const { useSelector } = await import('react-redux') - mockUseSelector = vi.mocked(useSelector) - mockDataProvider.getOne.mockResolvedValue({ - data: { libraries: mockLibraries }, - }) - // Setup localStorage mock - Object.defineProperty(window, 'localStorage', { - value: { - getItem: vi.fn(() => null), // Default to null to prevent API calls - setItem: vi.fn(), - }, - writable: true, - }) - }) - - const renderLibrarySelector = (selectorState = defaultState) => { - mockUseSelector.mockImplementation((selector) => - selector({ library: selectorState }), - ) - - return render(<LibrarySelector />) - } - - describe('when user has no libraries', () => { - it('should not render anything', () => { - const { container } = renderLibrarySelector({ - userLibraries: [], - selectedLibraries: [], - }) - expect(container.firstChild).toBeNull() - }) - }) - - describe('when user has only one library', () => { - it('should not render anything', () => { - const singleLibrary = [mockLibraries[0]] - const { container } = renderLibrarySelector({ - userLibraries: singleLibrary, - selectedLibraries: ['1'], - }) - expect(container.firstChild).toBeNull() - }) - }) - - describe('when user has multiple libraries', () => { - it('should render the chip with correct label when one library is selected', () => { - renderLibrarySelector() - - expect(screen.getByRole('button')).toBeInTheDocument() - expect(screen.getByText('1 of 3 Libraries')).toBeInTheDocument() - expect(screen.getByTestId('library-music')).toBeInTheDocument() - expect(screen.getByTestId('expand-more')).toBeInTheDocument() - }) - - it('should render the chip with "All Libraries" when all libraries are selected', () => { - renderLibrarySelector({ - userLibraries: mockLibraries, - selectedLibraries: ['1', '2', '3'], - }) - - expect(screen.getByText('All Libraries (3)')).toBeInTheDocument() - }) - - it('should render the chip with "None" when no libraries are selected', () => { - renderLibrarySelector({ - userLibraries: mockLibraries, - selectedLibraries: [], - }) - - expect(screen.getByText('None (0 of 3)')).toBeInTheDocument() - }) - - it('should show expand less icon when dropdown is open', async () => { - const user = userEvent.setup() - renderLibrarySelector() - - const chipButton = screen.getByRole('button') - await user.click(chipButton) - - expect(screen.getByTestId('expand-less')).toBeInTheDocument() - }) - - it('should open dropdown when chip is clicked', async () => { - const user = userEvent.setup() - renderLibrarySelector() - - const chipButton = screen.getByRole('button') - await user.click(chipButton) - - expect(screen.getByTestId('popper')).toBeInTheDocument() - expect(screen.getByText('Select Libraries:')).toBeInTheDocument() - }) - - it('should display all library names in dropdown', async () => { - const user = userEvent.setup() - renderLibrarySelector() - - const chipButton = screen.getByRole('button') - await user.click(chipButton) - - expect(screen.getByText('Music Library')).toBeInTheDocument() - expect(screen.getByText('Podcasts')).toBeInTheDocument() - expect(screen.getByText('Audiobooks')).toBeInTheDocument() - }) - - it('should not display library paths', async () => { - const user = userEvent.setup() - renderLibrarySelector() - - const chipButton = screen.getByRole('button') - await user.click(chipButton) - - expect(screen.queryByText('/music')).not.toBeInTheDocument() - expect(screen.queryByText('/podcasts')).not.toBeInTheDocument() - expect(screen.queryByText('/audiobooks')).not.toBeInTheDocument() - }) - - describe('master checkbox', () => { - it('should be checked when all libraries are selected', async () => { - const user = userEvent.setup() - renderLibrarySelector({ - userLibraries: mockLibraries, - selectedLibraries: ['1', '2', '3'], - }) - - const chipButton = screen.getByRole('button') - await user.click(chipButton) - - const checkboxes = screen.getAllByRole('checkbox') - const masterCheckbox = checkboxes[0] // First checkbox is the master checkbox - expect(masterCheckbox.checked).toBe(true) - expect(masterCheckbox.indeterminate).toBe(false) - }) - - it('should be unchecked when no libraries are selected', async () => { - const user = userEvent.setup() - renderLibrarySelector({ - userLibraries: mockLibraries, - selectedLibraries: [], - }) - - const chipButton = screen.getByRole('button') - await user.click(chipButton) - - const checkboxes = screen.getAllByRole('checkbox') - const masterCheckbox = checkboxes[0] - expect(masterCheckbox.checked).toBe(false) - expect(masterCheckbox.indeterminate).toBe(false) - }) - - it('should be indeterminate when some libraries are selected', async () => { - const user = userEvent.setup() - renderLibrarySelector({ - userLibraries: mockLibraries, - selectedLibraries: ['1', '2'], - }) - - const chipButton = screen.getByRole('button') - await user.click(chipButton) - - const checkboxes = screen.getAllByRole('checkbox') - const masterCheckbox = checkboxes[0] - expect(masterCheckbox.checked).toBe(false) - expect(masterCheckbox.indeterminate).toBe(true) - }) - - it('should select all libraries when clicked and none are selected', async () => { - const user = userEvent.setup() - renderLibrarySelector({ - userLibraries: mockLibraries, - selectedLibraries: [], - }) - - // Clear the dispatch mock after initial mount (it sets user libraries) - mockDispatch.mockClear() - - const chipButton = screen.getByRole('button') - await user.click(chipButton) - - const checkboxes = screen.getAllByRole('checkbox') - const masterCheckbox = checkboxes[0] - - // Use fireEvent.click to trigger the onChange event - fireEvent.click(masterCheckbox) - - expect(mockDispatch).toHaveBeenCalledWith({ - type: 'SET_SELECTED_LIBRARIES', - data: ['1', '2', '3'], - }) - }) - - it('should deselect all libraries when clicked and all are selected', async () => { - const user = userEvent.setup() - renderLibrarySelector({ - userLibraries: mockLibraries, - selectedLibraries: ['1', '2', '3'], - }) - - // Clear the dispatch mock after initial mount (it sets user libraries) - mockDispatch.mockClear() - - const chipButton = screen.getByRole('button') - await user.click(chipButton) - - const checkboxes = screen.getAllByRole('checkbox') - const masterCheckbox = checkboxes[0] - - fireEvent.click(masterCheckbox) - - expect(mockDispatch).toHaveBeenCalledWith({ - type: 'SET_SELECTED_LIBRARIES', - data: [], - }) - }) - - it('should select all libraries when clicked and some are selected', async () => { - const user = userEvent.setup() - renderLibrarySelector({ - userLibraries: mockLibraries, - selectedLibraries: ['1'], - }) - - // Clear the dispatch mock after initial mount (it sets user libraries) - mockDispatch.mockClear() - - const chipButton = screen.getByRole('button') - await user.click(chipButton) - - const checkboxes = screen.getAllByRole('checkbox') - const masterCheckbox = checkboxes[0] - - fireEvent.click(masterCheckbox) - - expect(mockDispatch).toHaveBeenCalledWith({ - type: 'SET_SELECTED_LIBRARIES', - data: ['1', '2', '3'], - }) - }) - }) - - describe('individual library checkboxes', () => { - it('should show correct checked state for each library', async () => { - const user = userEvent.setup() - renderLibrarySelector({ - userLibraries: mockLibraries, - selectedLibraries: ['1', '3'], - }) - - const chipButton = screen.getByRole('button') - await user.click(chipButton) - - const checkboxes = screen.getAllByRole('checkbox') - // Skip master checkbox (index 0) - expect(checkboxes[1].checked).toBe(true) // Music Library - expect(checkboxes[2].checked).toBe(false) // Podcasts - expect(checkboxes[3].checked).toBe(true) // Audiobooks - }) - - it('should toggle library selection when individual checkbox is clicked', async () => { - const user = userEvent.setup() - renderLibrarySelector() - - // Clear the dispatch mock after initial mount (it sets user libraries) - mockDispatch.mockClear() - - const chipButton = screen.getByRole('button') - await user.click(chipButton) - - const checkboxes = screen.getAllByRole('checkbox') - const podcastsCheckbox = checkboxes[2] // Podcasts checkbox - - fireEvent.click(podcastsCheckbox) - - expect(mockDispatch).toHaveBeenCalledWith({ - type: 'SET_SELECTED_LIBRARIES', - data: ['1', '2'], - }) - }) - - it('should remove library from selection when clicking checked library', async () => { - const user = userEvent.setup() - renderLibrarySelector({ - userLibraries: mockLibraries, - selectedLibraries: ['1', '2'], - }) - - // Clear the dispatch mock after initial mount (it sets user libraries) - mockDispatch.mockClear() - - const chipButton = screen.getByRole('button') - await user.click(chipButton) - - const checkboxes = screen.getAllByRole('checkbox') - const musicCheckbox = checkboxes[1] // Music Library checkbox - - fireEvent.click(musicCheckbox) - - expect(mockDispatch).toHaveBeenCalledWith({ - type: 'SET_SELECTED_LIBRARIES', - data: ['2'], - }) - }) - }) - - it('should close dropdown when clicking away', async () => { - const user = userEvent.setup() - renderLibrarySelector() - - // Open dropdown - const chipButton = screen.getByRole('button') - await user.click(chipButton) - - expect(screen.getByTestId('popper')).toBeInTheDocument() - - // Click away - const clickAwayListener = screen.getByTestId('click-away-listener') - fireEvent.mouseDown(clickAwayListener) - - await waitFor(() => { - expect(screen.queryByTestId('popper')).not.toBeInTheDocument() - }) - - // Should trigger refresh when closing - expect(mockRefresh).toHaveBeenCalledTimes(1) - }) - - it('should load user libraries on mount', async () => { - // Override localStorage mock to return a userId for this test - window.localStorage.getItem.mockReturnValue('user123') - - mockDataProvider.getOne.mockResolvedValue({ - data: { libraries: mockLibraries }, - }) - - renderLibrarySelector({ userLibraries: [], selectedLibraries: [] }) - - await waitFor(() => { - expect(mockDataProvider.getOne).toHaveBeenCalledWith('user', { - id: 'user123', - }) - }) - - expect(mockDispatch).toHaveBeenCalledWith({ - type: 'SET_USER_LIBRARIES', - data: mockLibraries, - }) - }) - - it('should handle API error gracefully', async () => { - // Override localStorage mock to return a userId for this test - window.localStorage.getItem.mockReturnValue('user123') - - const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - mockDataProvider.getOne.mockRejectedValue(new Error('API Error')) - - renderLibrarySelector({ userLibraries: [], selectedLibraries: [] }) - - await waitFor(() => { - expect(consoleSpy).toHaveBeenCalledWith( - 'Could not load user libraries (this may be expected for non-admin users):', - expect.any(Error), - ) - }) - - consoleSpy.mockRestore() - }) - - it('should not load libraries when userId is not available', () => { - window.localStorage.getItem.mockReturnValue(null) - - renderLibrarySelector({ userLibraries: [], selectedLibraries: [] }) - - expect(mockDataProvider.getOne).not.toHaveBeenCalled() - }) - }) -}) diff --git a/ui/src/common/Linkify.jsx b/ui/src/common/Linkify.js similarity index 89% rename from ui/src/common/Linkify.jsx rename to ui/src/common/Linkify.js index f1c8b28c9..87947905a 100644 --- a/ui/src/common/Linkify.jsx +++ b/ui/src/common/Linkify.js @@ -10,7 +10,7 @@ const useStyles = makeStyles( color: theme.palette.primary.main, }, }), - { name: 'RaLink' }, + { name: 'RaLink' } ) const Linkify = ({ text, ...rest }) => { @@ -45,7 +45,7 @@ const Linkify = ({ text, ...rest }) => { href={href} > {href} - </Link>, + </Link> ) lastIndex = match.index + href.length @@ -53,7 +53,12 @@ const Linkify = ({ text, ...rest }) => { // Push remaining text if (text.length > lastIndex) { - elements.push(text.substring(lastIndex)) + elements.push( + <span + key={'last-span-key'} + dangerouslySetInnerHTML={{ __html: text.substring(lastIndex) }} + /> + ) } return elements.length === 1 ? elements[0] : elements diff --git a/ui/src/common/Linkify.test.jsx b/ui/src/common/Linkify.test.js similarity index 95% rename from ui/src/common/Linkify.test.jsx rename to ui/src/common/Linkify.test.js index cd19ffa03..fd896f7d2 100644 --- a/ui/src/common/Linkify.test.jsx +++ b/ui/src/common/Linkify.test.js @@ -1,5 +1,6 @@ import React from 'react' import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom/extend-expect' import Linkify from './Linkify' const URL = 'http://www.example.com' diff --git a/ui/src/common/List.jsx b/ui/src/common/List.js similarity index 66% rename from ui/src/common/List.jsx rename to ui/src/common/List.js index 0ae089460..f74ab027e 100644 --- a/ui/src/common/List.jsx +++ b/ui/src/common/List.js @@ -1,8 +1,6 @@ import React from 'react' import { List as RAList } from 'react-admin' -import config from '../config' import { Pagination } from './Pagination' -import { defaultRowsPerPageOptions, getStoredPerPage } from './perPageStore' import { Title } from './index' export const List = (props) => { @@ -15,8 +13,7 @@ export const List = (props) => { args={{ smart_count: 2 }} /> } - debounce={config.uiSearchDebounceMs} - perPage={getStoredPerPage(resource, defaultRowsPerPageOptions)} + perPage={15} pagination={<Pagination />} {...props} /> diff --git a/ui/src/common/List.test.jsx b/ui/src/common/List.test.jsx deleted file mode 100644 index 5bc4b910f..000000000 --- a/ui/src/common/List.test.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { describe, it, expect, vi } from 'vitest' -import { List } from './List' - -// Only stub the heavy react-admin List controller (data fetching, router sync); -// everything else, including our own Pagination/perPageStore wiring, stays real -// so a bad import (the bug this test guards against) throws on render. -vi.mock('react-admin', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - List: ({ children }) => <div data-testid="ra-list">{children}</div>, - } -}) - -describe('List', () => { - it('renders without throwing and shows its children', () => { - render( - <List resource="song"> - <div>list content</div> - </List>, - ) - expect(screen.getByTestId('ra-list')).toBeInTheDocument() - expect(screen.getByText('list content')).toBeInTheDocument() - }) -}) diff --git a/ui/src/common/LoveButton.jsx b/ui/src/common/LoveButton.js similarity index 64% rename from ui/src/common/LoveButton.jsx rename to ui/src/common/LoveButton.js index 128b375c2..eea91e826 100644 --- a/ui/src/common/LoveButton.jsx +++ b/ui/src/common/LoveButton.js @@ -4,28 +4,17 @@ import FavoriteIcon from '@material-ui/icons/Favorite' import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' import IconButton from '@material-ui/core/IconButton' import { makeStyles } from '@material-ui/core/styles' -import clsx from 'clsx' import { useToggleLove } from './useToggleLove' -import { useDateLocale } from '../i18n/useDateLocale' -import { formatDateTime } from '../utils/formatters' import { useRecordContext } from 'react-admin' import config from '../config' -import { isDateSet } from '../utils/validations' -const useStyles = makeStyles( - { - love: { - color: (props) => props.color, - visibility: (props) => - props.visible === false - ? 'hidden' - : props.loved - ? 'visible' - : 'inherit', - }, +const useStyles = makeStyles({ + love: { + color: (props) => props.color, + visibility: (props) => + props.visible === false ? 'hidden' : props.loved ? 'visible' : 'inherit', }, - { name: 'NDLoveButton' }, -) +}) export const LoveButton = ({ resource, @@ -35,14 +24,11 @@ export const LoveButton = ({ component: Button, addLabel, disabled, - className, - record: recordProp, ...rest }) => { - const record = useRecordContext({ record: recordProp }) || {} + const record = useRecordContext(rest) || {} const classes = useStyles({ color, visible, loved: record.starred }) const [toggleLove, loading] = useToggleLove(resource, record) - const locale = useDateLocale() const handleToggleLove = useCallback( (e) => { @@ -50,7 +36,7 @@ export const LoveButton = ({ toggleLove() e.stopPropagation() }, - [toggleLove], + [toggleLove] ) if (!config.enableFavourites) { @@ -60,13 +46,8 @@ export const LoveButton = ({ <Button onClick={handleToggleLove} size={'small'} - disabled={disabled || loading || record.missing} - className={clsx(classes.love, className)} - title={ - isDateSet(record.starredAt) - ? formatDateTime(record.starredAt, locale) - : undefined - } + disabled={disabled || loading} + className={classes.love} {...rest} > {record.starred ? ( diff --git a/ui/src/common/MultiLineTextField.jsx b/ui/src/common/MultiLineTextField.js similarity index 67% rename from ui/src/common/MultiLineTextField.jsx rename to ui/src/common/MultiLineTextField.js index 1218d4bd6..5148482dd 100644 --- a/ui/src/common/MultiLineTextField.jsx +++ b/ui/src/common/MultiLineTextField.js @@ -28,14 +28,24 @@ export const MultiLineTextField = memo( component="span" {...sanitizeFieldRestProps(rest)} > - {lines.length === 0 && emptyText ? emptyText : lines} + {lines.length === 0 && emptyText + ? emptyText + : lines.map((line, idx) => + line === '' ? ( + <br key={md5(line + idx)} /> + ) : ( + <div + data-testid={`${source}.${idx}`} + key={md5(line + idx)} + dangerouslySetInnerHTML={{ __html: line }} + /> + ) + )} </Typography> ) - }, + } ) -MultiLineTextField.displayName = 'MultiLineTextField' - MultiLineTextField.defaultProps = { addLabel: true, firstLine: 0, diff --git a/ui/src/common/MultiLineTextField.test.js b/ui/src/common/MultiLineTextField.test.js new file mode 100644 index 000000000..44dede43f --- /dev/null +++ b/ui/src/common/MultiLineTextField.test.js @@ -0,0 +1,28 @@ +import * as React from 'react' +import { render, cleanup, screen } from '@testing-library/react' +import { MultiLineTextField } from './MultiLineTextField' + +describe('<MultiLineTextField />', () => { + afterEach(cleanup) + + it('should render each line in a separated div', () => { + const record = { comment: 'line1\nline2' } + render(<MultiLineTextField record={record} source={'comment'} />) + expect(screen.queryByTestId('comment.0').textContent).toBe('line1') + expect(screen.queryByTestId('comment.1').textContent).toBe('line2') + }) + + it.each([null, undefined])( + 'should render the emptyText when value is %s', + (body) => { + render( + <MultiLineTextField + record={{ id: 123, body }} + emptyText="NA" + source="body" + /> + ) + expect(screen.getByText('NA')).toBeInTheDocument() + } + ) +}) diff --git a/ui/src/common/OverflowTooltip.jsx b/ui/src/common/OverflowTooltip.jsx deleted file mode 100644 index c000bd9f0..000000000 --- a/ui/src/common/OverflowTooltip.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import { Tooltip } from '@material-ui/core' -import { makeStyles, alpha } from '@material-ui/core/styles' -import grey from '@material-ui/core/colors/grey' - -const useStyles = makeStyles( - (theme) => ({ - tooltip: { - backgroundColor: - theme.palette.type === 'dark' - ? alpha(grey[700], 0.92) - : alpha(grey[300], 0.92), - color: - theme.palette.type === 'dark' - ? theme.palette.common.white - : theme.palette.common.black, - borderRadius: theme.shape.borderRadius, - ...theme.typography.body2, - padding: theme.spacing(0.5, 1), - maxWidth: 300, - }, - }), - { name: 'NDOverflowTooltip' }, -) - -const transitionProps = { timeout: 0 } - -export const OverflowTooltip = ({ - children, - title, - placement = 'bottom-start', -}) => { - const classes = useStyles() - const textRef = React.useRef(null) - const [isOverflowing, setIsOverflowing] = React.useState(false) - const tooltipClasses = React.useMemo( - () => ({ tooltip: classes.tooltip }), - [classes.tooltip], - ) - - React.useLayoutEffect(() => { - const el = textRef.current - if (!el) return - - const checkOverflow = () => { - setIsOverflowing(el.scrollWidth > el.clientWidth) - } - - const resizeObserver = new ResizeObserver(checkOverflow) - resizeObserver.observe(el) - - checkOverflow() - - return () => resizeObserver.disconnect() - }, []) - - const mergedRef = React.useCallback( - (el) => { - textRef.current = el - - const { ref } = children - if (typeof ref === 'function') { - ref(el) - } else if (ref && typeof ref === 'object') { - ref.current = el - } - }, - [children], - ) - - return ( - <Tooltip - title={title} - disableHoverListener={!isOverflowing} - disableTouchListener - placement={placement} - TransitionProps={transitionProps} - classes={tooltipClasses} - > - {React.cloneElement(children, { ref: mergedRef })} - </Tooltip> - ) -} - -OverflowTooltip.propTypes = { - children: PropTypes.element.isRequired, - title: PropTypes.string.isRequired, - placement: PropTypes.string, -} diff --git a/ui/src/common/Pagination.js b/ui/src/common/Pagination.js new file mode 100644 index 000000000..e17d9e63e --- /dev/null +++ b/ui/src/common/Pagination.js @@ -0,0 +1,6 @@ +import React from 'react' +import { Pagination as RAPagination } from 'react-admin' + +export const Pagination = (props) => ( + <RAPagination rowsPerPageOptions={[15, 25, 50]} {...props} /> +) diff --git a/ui/src/common/Pagination.jsx b/ui/src/common/Pagination.jsx deleted file mode 100644 index dd18961ce..000000000 --- a/ui/src/common/Pagination.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import React, { useCallback } from 'react' -import { - Pagination as RAPagination, - useListPaginationContext, -} from 'react-admin' -import { setStoredPerPage, defaultRowsPerPageOptions } from './perPageStore' - -export const Pagination = ({ - rowsPerPageOptions = defaultRowsPerPageOptions, - ...props -}) => { - const { resource, setPerPage } = useListPaginationContext() - // Persist only a selector-driven change: mount, URL params and responsive - // fallbacks never call setPerPage, so they can't overwrite the preference. - const handleSetPerPage = useCallback( - (value) => { - if (resource) setStoredPerPage(resource, value) - setPerPage(value) - }, - [resource, setPerPage], - ) - return ( - <RAPagination - rowsPerPageOptions={rowsPerPageOptions} - {...props} - setPerPage={handleSetPerPage} - /> - ) -} diff --git a/ui/src/common/Pagination.test.jsx b/ui/src/common/Pagination.test.jsx deleted file mode 100644 index a488aba91..000000000 --- a/ui/src/common/Pagination.test.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent } from '@testing-library/react' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { Pagination } from './Pagination' - -// stub RA's Pagination so a test can invoke the injected setPerPage, i.e. -// simulate an actual rows-per-page selection -vi.mock('react-admin', async () => { - const React = await vi.importActual('react') - return { - Pagination: ({ setPerPage }) => - React.createElement( - 'button', - { onClick: () => setPerPage(50) }, - 'select 50', - ), - useListPaginationContext: vi.fn(), - } -}) - -describe('Pagination', () => { - let mockContext - let setPerPage - - beforeEach(async () => { - vi.clearAllMocks() - localStorage.clear() - setPerPage = vi.fn() - const { useListPaginationContext } = await import('react-admin') - mockContext = vi.mocked(useListPaginationContext) - }) - - const selectPerPage = () => fireEvent.click(screen.getByText('select 50')) - - it('persists the page size chosen in the selector', () => { - mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) - render(<Pagination />) - selectPerPage() - expect(localStorage.getItem('perPage.song')).toEqual('50') - }) - - it('still applies the change to the list', () => { - mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) - render(<Pagination />) - selectPerPage() - expect(setPerPage).toHaveBeenCalledWith(50) - }) - - it('does not persist a page size the user did not select', () => { - mockContext.mockReturnValue({ resource: 'song', perPage: 15, setPerPage }) - render(<Pagination />) - expect(localStorage.getItem('perPage.song')).toBeNull() - }) - - it('does not persist without a resource in context', () => { - mockContext.mockReturnValue({ perPage: 15, setPerPage }) - render(<Pagination />) - selectPerPage() - expect(localStorage.getItem('perPage.undefined')).toBeNull() - expect(setPerPage).toHaveBeenCalledWith(50) - }) -}) diff --git a/ui/src/common/ParticipantsInfo.jsx b/ui/src/common/ParticipantsInfo.jsx deleted file mode 100644 index aecf4f1bc..000000000 --- a/ui/src/common/ParticipantsInfo.jsx +++ /dev/null @@ -1,54 +0,0 @@ -import { TableRow, TableCell } from '@material-ui/core' -import { humanize } from 'inflection' -import { useTranslate } from 'react-admin' - -import en from '../i18n/en.json' -import { ArtistLinkField } from './index' - -export const ParticipantsInfo = ({ classes, record }) => { - const translate = useTranslate() - const existingRoles = en?.resources?.artist?.roles ?? {} - - const roles = [] - - if (record.participants) { - for (const name of Object.keys(record.participants)) { - if (name === 'albumartist' || name === 'artist') { - continue - } - roles.push([name, record.participants[name].length]) - } - } - - if (roles.length === 0) { - return null - } - - return ( - <> - {roles.length > 0 && ( - <TableRow key={`${record.id}-separator`}> - <TableCell scope="row" className={classes.tableCell}></TableCell> - <TableCell align="left"> - <h4>{translate(`resources.song.fields.participants`)}</h4> - </TableCell> - </TableRow> - )} - {roles.map(([role, count]) => ( - <TableRow key={`${record.id}-${role}`}> - <TableCell scope="row" className={classes.tableCell}> - {role in existingRoles - ? translate(`resources.artist.roles.${role}`, { - smart_count: count, - }) - : humanize(role)} - : - </TableCell> - <TableCell align="left"> - <ArtistLinkField source={role} record={record} limit={Infinity} /> - </TableCell> - </TableRow> - ))} - </> - ) -} diff --git a/ui/src/common/PathField.jsx b/ui/src/common/PathField.jsx deleted file mode 100644 index 21822878a..000000000 --- a/ui/src/common/PathField.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import PropTypes from 'prop-types' -import React from 'react' -import { usePermissions, useRecordContext } from 'react-admin' -import config from '../config' - -export const PathField = (props) => { - const record = useRecordContext(props) - const { permissions } = usePermissions() - let path = permissions === 'admin' ? record.libraryPath : '' - - if (path && path.endsWith(config.separator)) { - path = `${path}${record.path}` - } else { - path = path ? `${path}${config.separator}${record.path}` : record.path - } - - return <span>{path}</span> -} - -PathField.propTypes = { - record: PropTypes.object, -} diff --git a/ui/src/common/PathField.test.jsx b/ui/src/common/PathField.test.jsx deleted file mode 100644 index de8b90899..000000000 --- a/ui/src/common/PathField.test.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from 'react' -import { render } from '@testing-library/react' -import { PathField } from './PathField' -import { usePermissions, useRecordContext } from 'react-admin' -import config from '../config' - -// Mock react-admin hooks -vi.mock('react-admin', () => ({ - usePermissions: vi.fn(), - useRecordContext: vi.fn(), -})) - -// Mock config -vi.mock('../config', () => ({ - default: { - separator: '/', - }, -})) - -describe('PathField', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('renders path without libraryPath for non-admin users', () => { - // Setup - usePermissions.mockReturnValue({ permissions: 'user' }) - useRecordContext.mockReturnValue({ - path: 'music/song.mp3', - libraryPath: '/data/media', - }) - - // Act - const { container } = render(<PathField />) - - // Assert - expect(container.textContent).toBe('music/song.mp3') - expect(container.textContent).not.toContain('/data/media') - }) - - it('renders combined path for admin users when libraryPath does not end with separator', () => { - // Setup - usePermissions.mockReturnValue({ permissions: 'admin' }) - useRecordContext.mockReturnValue({ - path: 'music/song.mp3', - libraryPath: '/data/media', - }) - - // Act - const { container } = render(<PathField />) - - // Assert - expect(container.textContent).toBe('/data/media/music/song.mp3') - }) - - it('renders combined path for admin users when libraryPath ends with separator', () => { - // Setup - usePermissions.mockReturnValue({ permissions: 'admin' }) - useRecordContext.mockReturnValue({ - path: 'music/song.mp3', - libraryPath: '/data/media/', - }) - - // Act - const { container } = render(<PathField />) - - // Assert - expect(container.textContent).toBe('/data/media/music/song.mp3') - }) - - it('works with a different separator from config', () => { - // Setup - config.separator = '\\' - usePermissions.mockReturnValue({ permissions: 'admin' }) - useRecordContext.mockReturnValue({ - path: 'music\\song.mp3', - libraryPath: 'C:\\data', - }) - - // Act - const { container } = render(<PathField />) - - // Assert - expect(container.textContent).toBe('C:\\data\\music\\song.mp3') - }) -}) diff --git a/ui/src/common/PlayButton.jsx b/ui/src/common/PlayButton.js similarity index 91% rename from ui/src/common/PlayButton.jsx rename to ui/src/common/PlayButton.js index d8717236f..e40927ef2 100644 --- a/ui/src/common/PlayButton.jsx +++ b/ui/src/common/PlayButton.js @@ -10,7 +10,7 @@ export const PlayButton = ({ record, size, className }) => { let extractSongsData = function (response) { const data = response.data.reduce( (acc, cur) => ({ ...acc, [cur.id]: cur }), - {}, + {} ) const ids = response.data.map((r) => r.id) return { data, ids } @@ -21,9 +21,10 @@ export const PlayButton = ({ record, size, className }) => { dataProvider .getList('song', { pagination: { page: 1, perPage: -1 }, - sort: { field: 'album', order: 'ASC' }, + sort: { field: 'releaseDate, discNumber, trackNumber', order: 'ASC' }, filter: { album_id: record.id, + release_date: record.releaseDate, disc_number: record.discNumber, }, }) diff --git a/ui/src/common/QualityInfo.js b/ui/src/common/QualityInfo.js new file mode 100644 index 000000000..e8beb3236 --- /dev/null +++ b/ui/src/common/QualityInfo.js @@ -0,0 +1,62 @@ +import React from 'react' +import PropTypes from 'prop-types' +import Chip from '@material-ui/core/Chip' +import config from '../config' +import { makeStyles } from '@material-ui/core' +import clsx from 'clsx' + +const llFormats = new Set(config.losslessFormats.split(',')) +const placeholder = 'N/A' + +const useStyle = makeStyles( + (theme) => ({ + chip: { + transform: 'scale(0.8)', + }, + }), + { + name: 'NDQualityInfo', + } +) + +export const QualityInfo = ({ record, size, gainMode, preAmp, className }) => { + const classes = useStyle() + let { suffix, bitRate } = record + let info = placeholder + + if (suffix) { + suffix = suffix.toUpperCase() + info = suffix + if (!llFormats.has(suffix) && bitRate > 0) { + info += ' ' + bitRate + } + } + + if (gainMode !== 'none') { + info += ` (${ + (gainMode === 'album' ? record.albumGain : record.trackGain) + preAmp + } dB)` + } + + return ( + <Chip + className={clsx(classes.chip, className)} + variant="outlined" + size={size} + label={info} + /> + ) +} + +QualityInfo.propTypes = { + record: PropTypes.object.isRequired, + size: PropTypes.string, + className: PropTypes.string, + gainMode: PropTypes.string, +} + +QualityInfo.defaultProps = { + record: {}, + size: 'small', + gainMode: 'none', +} diff --git a/ui/src/common/QualityInfo.jsx b/ui/src/common/QualityInfo.jsx deleted file mode 100644 index 57a8251a4..000000000 --- a/ui/src/common/QualityInfo.jsx +++ /dev/null @@ -1,96 +0,0 @@ -import React, { useMemo } from 'react' -import PropTypes from 'prop-types' -import Chip from '@material-ui/core/Chip' -import config from '../config' -import { makeStyles } from '@material-ui/core' -import clsx from 'clsx' -import { calculateGain } from '../utils/calculateReplayGain' - -const llFormats = new Set(config.losslessFormats.split(',')) -const placeholder = 'N/A' - -const useStyle = makeStyles( - (theme) => ({ - chip: { - transform: 'scale(0.8)', - }, - }), - { - name: 'NDQualityInfo', - }, -) - -export const QualityInfo = ({ - record, - size, - gainMode, - preAmp, - className, - transcodeStream, - isDirectPlay, -}) => { - const classes = useStyle() - let { suffix, bitRate, rgAlbumGain, rgAlbumPeak, rgTrackGain, rgTrackPeak } = - record - let info = placeholder - - if (suffix) { - suffix = suffix.toUpperCase() - info = suffix - if (!llFormats.has(suffix) && bitRate > 0) { - info += ' ' + bitRate - } - } - - // Show transcode target when transcoding (not direct play) - if (transcodeStream && !isDirectPlay) { - const targetCodec = (transcodeStream.codec || '').toUpperCase() - const targetBitrate = transcodeStream.audioBitrate - ? Math.round(transcodeStream.audioBitrate / 1000) - : 0 - let targetInfo = targetCodec - if (targetBitrate > 0) { - targetInfo += ' ' + targetBitrate - } - const sourceSuffix = suffix || placeholder - info = `${sourceSuffix} → ${targetInfo}` - } - - const extra = useMemo(() => { - if (gainMode !== 'none') { - const gainValue = calculateGain( - { gainMode, preAmp }, - { rgAlbumGain, rgAlbumPeak, rgTrackGain, rgTrackPeak }, - ) - // convert normalized gain (after peak) back to dB - const toDb = (Math.log10(gainValue) * 20).toFixed(2) - return ` (${toDb} dB)` - } - - return '' - }, [gainMode, preAmp, rgAlbumGain, rgAlbumPeak, rgTrackGain, rgTrackPeak]) - - return ( - <Chip - className={clsx(classes.chip, className)} - variant="outlined" - size={size} - label={`${info}${extra}`} - /> - ) -} - -QualityInfo.propTypes = { - record: PropTypes.object.isRequired, - size: PropTypes.string, - className: PropTypes.string, - gainMode: PropTypes.string, - transcodeStream: PropTypes.object, - isDirectPlay: PropTypes.bool, -} - -QualityInfo.defaultProps = { - record: {}, - size: 'small', - gainMode: 'none', -} diff --git a/ui/src/common/QualityInfo.test.js b/ui/src/common/QualityInfo.test.js new file mode 100644 index 000000000..735fe70b9 --- /dev/null +++ b/ui/src/common/QualityInfo.test.js @@ -0,0 +1,27 @@ +import * as React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { QualityInfo } from './QualityInfo' + +describe('<QualityInfo />', () => { + afterEach(cleanup) + + it('only render suffix for lossless formats', () => { + const info = { suffix: 'FLAC', bitRate: 1008 } + render(<QualityInfo record={info} />) + expect(screen.getByText('FLAC')).toBeInTheDocument() + }) + it('only render suffix and bitrate for lossy formats', () => { + const info = { suffix: 'MP3', bitRate: 320 } + render(<QualityInfo record={info} />) + expect(screen.getByText('MP3 320')).toBeInTheDocument() + }) + it('renders placeholder if suffix is missing', () => { + const info = {} + render(<QualityInfo record={info} />) + expect(screen.getByText('N/A')).toBeInTheDocument() + }) + it('does not break if record is null', () => { + render(<QualityInfo />) + expect(screen.getByText('N/A')).toBeInTheDocument() + }) +}) diff --git a/ui/src/common/QualityInfo.test.jsx b/ui/src/common/QualityInfo.test.jsx deleted file mode 100644 index 174ee8a85..000000000 --- a/ui/src/common/QualityInfo.test.jsx +++ /dev/null @@ -1,106 +0,0 @@ -import * as React from 'react' -import { cleanup, render, screen } from '@testing-library/react' -import { QualityInfo } from './QualityInfo' - -describe('<QualityInfo />', () => { - afterEach(cleanup) - - it('only render suffix for lossless formats', () => { - const info = { suffix: 'FLAC', bitRate: 1008 } - render(<QualityInfo record={info} />) - expect(screen.getByText('FLAC')).toBeInTheDocument() - }) - it('only render suffix and bitrate for lossy formats', () => { - const info = { - suffix: 'MP3', - bitRate: 320, - rgAlbumGain: -5, - rgAlbumPeak: 1, - rgTrackGain: 2.3, - rgTrackPeak: 0.5, - } - render(<QualityInfo record={info} />) - expect(screen.getByText('MP3 320')).toBeInTheDocument() - }) - it('renders placeholder if suffix is missing', () => { - const info = {} - render(<QualityInfo record={info} />) - expect(screen.getByText('N/A')).toBeInTheDocument() - }) - it('does not break if record is null', () => { - render(<QualityInfo />) - expect(screen.getByText('N/A')).toBeInTheDocument() - }) - it('renders album gain info, no peak limit', () => { - render( - <QualityInfo - gainMode="album" - preAmp={0} - record={{ - rgAlbumGain: -5, - rgAlbumPeak: 1, - rgTrackGain: -2, - rgTrackPeak: 0.2, - }} - />, - ) - expect(screen.getByText('N/A (-5.00 dB)')).toBeInTheDocument() - }) - it('renders track gain info, no peak limit capping, preAmp', () => { - render( - <QualityInfo - gainMode="track" - preAmp={-1} - record={{ - rgAlbumGain: -5, - rgAlbumPeak: 1, - rgTrackGain: 2.3, - rgTrackPeak: 0.5, - }} - />, - ) - expect(screen.getByText('N/A (1.30 dB)')).toBeInTheDocument() - }) - it('renders gain info limited by peak', () => { - render( - <QualityInfo - gainMode="track" - preAmp={-1} - record={{ - suffix: 'FLAC', - rgAlbumGain: -5, - rgAlbumPeak: 1, - rgTrackGain: 2.3, - rgTrackPeak: 1, - }} - />, - ) - expect(screen.getByText('FLAC (0.00 dB)')).toBeInTheDocument() - }) - - it('shows transcode arrow when transcodeStream is provided', () => { - const info = { suffix: 'FLAC', bitRate: 1008 } - const transcodeStream = { codec: 'opus', audioBitrate: 128000 } - render(<QualityInfo record={info} transcodeStream={transcodeStream} />) - expect(screen.getByText('FLAC → OPUS 128')).toBeInTheDocument() - }) - - it('shows transcode with lossy source including bitrate', () => { - const info = { suffix: 'FLAC', bitRate: 1008 } - const transcodeStream = { codec: 'mp3', audioBitrate: 320000 } - render(<QualityInfo record={info} transcodeStream={transcodeStream} />) - expect(screen.getByText('FLAC → MP3 320')).toBeInTheDocument() - }) - - it('does not show arrow when isDirectPlay is true', () => { - const info = { suffix: 'MP3', bitRate: 320 } - render(<QualityInfo record={info} isDirectPlay={true} />) - expect(screen.getByText('MP3 320')).toBeInTheDocument() - }) - - it('behaves normally when no transcode props are passed', () => { - const info = { suffix: 'MP3', bitRate: 320 } - render(<QualityInfo record={info} />) - expect(screen.getByText('MP3 320')).toBeInTheDocument() - }) -}) diff --git a/ui/src/common/QuickFilter.jsx b/ui/src/common/QuickFilter.js similarity index 81% rename from ui/src/common/QuickFilter.jsx rename to ui/src/common/QuickFilter.js index 79b09b333..62263ffc5 100644 --- a/ui/src/common/QuickFilter.jsx +++ b/ui/src/common/QuickFilter.js @@ -1,7 +1,7 @@ import React from 'react' import { Chip, makeStyles } from '@material-ui/core' import { useTranslate } from 'react-admin' -import { humanize, underscore } from 'inflection' +import inflection from 'inflection' const useQuickFilterStyles = makeStyles((theme) => ({ chip: { @@ -16,11 +16,11 @@ export const QuickFilter = ({ source, resource, label, defaultValue }) => { if (typeof lbl === 'string' || lbl instanceof String) { if (label) { lbl = translate(lbl, { - _: humanize(underscore(lbl)), + _: inflection.humanize(inflection.underscore(lbl)), }) } else { lbl = translate(`resources.${resource}.fields.${source}`, { - _: humanize(underscore(source)), + _: inflection.humanize(inflection.underscore(source)), }) } } diff --git a/ui/src/common/QuickFilter.test.jsx b/ui/src/common/QuickFilter.test.js similarity index 98% rename from ui/src/common/QuickFilter.test.jsx rename to ui/src/common/QuickFilter.test.js index 36df2aa0e..56426af94 100644 --- a/ui/src/common/QuickFilter.test.jsx +++ b/ui/src/common/QuickFilter.test.js @@ -22,7 +22,7 @@ describe('QuickFilter', () => { resource={'song'} source={'name'} label={<StarIcon data-testid="label-icon-test" />} - />, + /> ) expect(screen.getByTestId('label-icon-test')).not.toBeNull() }) diff --git a/ui/src/common/RangeDoubleField.js b/ui/src/common/RangeDoubleField.js new file mode 100644 index 000000000..d388abeb7 --- /dev/null +++ b/ui/src/common/RangeDoubleField.js @@ -0,0 +1,50 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useRecordContext } from 'react-admin' +import { formatRange } from '../common' + +export const RangeDoubleField = ({ + className, + source, + symbol1, + symbol2, + separator, + ...rest +}) => { + const record = useRecordContext(rest) + const yearRange = formatRange(record, source).toString() + const releases = [record.releases] + const releaseDate = [record.releaseDate] + const releaseYear = releaseDate.toString().substring(0, 4) + let subtitle = yearRange + + if (releases > 1) { + subtitle = [ + [yearRange && symbol1, yearRange].join(' '), + ['(', releases, ')))'].join(' '), + ].join(separator) + } + + if ( + yearRange !== releaseYear && + yearRange.length > 0 && + releaseYear.length > 0 + ) { + subtitle = [ + [yearRange && symbol1, yearRange].join(' '), + [symbol2, releaseYear].join(' '), + ].join(separator) + } + + return <span className={className}>{subtitle}</span> +} + +RangeDoubleField.propTypes = { + label: PropTypes.string, + record: PropTypes.object, + source: PropTypes.string.isRequired, +} + +RangeDoubleField.defaultProps = { + addLabel: true, +} diff --git a/ui/src/common/RangeField.jsx b/ui/src/common/RangeField.js similarity index 56% rename from ui/src/common/RangeField.jsx rename to ui/src/common/RangeField.js index 99746c6b1..7ea83330a 100644 --- a/ui/src/common/RangeField.jsx +++ b/ui/src/common/RangeField.js @@ -1,7 +1,20 @@ import React from 'react' import PropTypes from 'prop-types' import { useRecordContext } from 'react-admin' -import { formatRange } from './formatRange' + +export const formatRange = (record, source) => { + const nameCapitalized = source.charAt(0).toUpperCase() + source.slice(1) + const min = record[`min${nameCapitalized}`] + const max = record[`max${nameCapitalized}`] + let range = [] + if (min) { + range.push(min) + } + if (max && max !== min) { + range.push(max) + } + return range.join('-') +} export const RangeField = ({ className, source, ...rest }) => { const record = useRecordContext(rest) diff --git a/ui/src/common/RatingField.jsx b/ui/src/common/RatingField.js similarity index 70% rename from ui/src/common/RatingField.jsx rename to ui/src/common/RatingField.js index f892b475a..fe9171527 100644 --- a/ui/src/common/RatingField.jsx +++ b/ui/src/common/RatingField.js @@ -2,12 +2,9 @@ import React, { useCallback } from 'react' import PropTypes from 'prop-types' import Rating from '@material-ui/lab/Rating' import { makeStyles } from '@material-ui/core/styles' -import { isDateSet } from '../utils/validations' import StarBorderIcon from '@material-ui/icons/StarBorder' import clsx from 'clsx' import { useRating } from './useRating' -import { useDateLocale } from '../i18n/useDateLocale' -import { formatDateTime } from '../utils/formatters' import { useRecordContext } from 'react-admin' const useStyles = makeStyles({ @@ -34,7 +31,6 @@ export const RatingField = ({ const record = useRecordContext(rest) || {} const [rate, rating] = useRating(resource, record) const classes = useStyles({ color, visible }) - const locale = useDateLocale() const stopPropagation = (e) => { e.stopPropagation() @@ -42,31 +38,22 @@ export const RatingField = ({ const handleRating = useCallback( (e, val) => { - const targetId = record.mediaFileId || record.id - rate(val ?? 0, targetId) + rate(val, e.target.name) }, - [rate, record.mediaFileId, record.id], + [rate] ) return ( - <span - onClick={(e) => stopPropagation(e)} - title={ - isDateSet(record.ratedAt) - ? formatDateTime(record.ratedAt, locale) - : undefined - } - > + <span onClick={(e) => stopPropagation(e)}> <Rating - name={record.mediaFileId || record.id} + name={record.id} className={clsx( className, classes.rating, - rating > 0 ? classes.show : classes.hide, + rating > 0 ? classes.show : classes.hide )} value={rating} size={size} - disabled={record?.missing} emptyIcon={<StarBorderIcon fontSize="inherit" />} onChange={(e, newValue) => handleRating(e, newValue)} /> diff --git a/ui/src/common/ReadOnlyFields.jsx b/ui/src/common/ReadOnlyFields.jsx deleted file mode 100644 index c56643fbd..000000000 --- a/ui/src/common/ReadOnlyFields.jsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import get from 'lodash/get' -import { FieldTitle, useRecordContext } from 'react-admin' -import { TextField } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import { useDateLocale } from '../i18n/useDateLocale' -import { - formatBytes, - formatDateTime, - formatDuration2, - formatNumber, -} from '../utils/formatters' -import { isDateSet } from '../utils/validations' - -const useStyles = makeStyles( - (theme) => ({ - inputRoot: { - '&:hover $notchedOutline': { - borderColor: theme.palette.divider, - }, - }, - notchedOutline: { - borderColor: theme.palette.divider, - }, - }), - { name: 'NDReadOnlyField' }, -) - -const identity = (v) => v - -// Renders a record value as a dimmed, non-editable input, so it lines up with the inputs in a form -export const ReadOnlyTextField = ({ - source, - label, - resource, - className, - fullWidth, - format = identity, - ...props -}) => { - const classes = useStyles(props) - const record = useRecordContext(props) - const value = get(record, source) - - return ( - <TextField - id={source} - className={className} - label={<FieldTitle label={label} source={source} resource={resource} />} - value={value == null ? '' : format(value)} - variant="outlined" - margin="dense" - fullWidth={fullWidth} - focused={false} - helperText=" " - InputProps={{ - readOnly: true, - classes: { - root: classes.inputRoot, - notchedOutline: classes.notchedOutline, - }, - }} - inputProps={{ tabIndex: -1 }} - /> - ) -} - -ReadOnlyTextField.propTypes = { - source: PropTypes.string.isRequired, - label: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), - record: PropTypes.object, - resource: PropTypes.string, - className: PropTypes.string, - classes: PropTypes.object, - fullWidth: PropTypes.bool, - format: PropTypes.func, -} - -export const ReadOnlyDateField = (props) => { - const locale = useDateLocale() - const format = (v) => (isDateSet(v) ? formatDateTime(v, locale) : '') - return <ReadOnlyTextField format={format} {...props} /> -} - -export const ReadOnlyNumberField = (props) => { - const locale = useDateLocale() - return ( - <ReadOnlyTextField format={(v) => formatNumber(v, locale)} {...props} /> - ) -} - -export const ReadOnlySizeField = (props) => ( - <ReadOnlyTextField format={formatBytes} {...props} /> -) - -export const ReadOnlyDurationField = (props) => ( - <ReadOnlyTextField format={formatDuration2} {...props} /> -) diff --git a/ui/src/common/ReadOnlyFields.test.jsx b/ui/src/common/ReadOnlyFields.test.jsx deleted file mode 100644 index 00543a786..000000000 --- a/ui/src/common/ReadOnlyFields.test.jsx +++ /dev/null @@ -1,121 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { - ReadOnlyDateField, - ReadOnlyDurationField, - ReadOnlyNumberField, - ReadOnlySizeField, - ReadOnlyTextField, -} from './ReadOnlyFields' - -vi.mock('react-admin', async (importOriginal) => ({ - ...(await importOriginal()), - useLocale: vi.fn(), -})) - -describe('ReadOnlyFields', () => { - const record = { - id: '1', - client: 'NavidromeUI', - createdAt: '2026-09-17T14:30:00Z', - lastVisitedAt: '0001-01-01T00:00:00Z', - count: 1234567, - zero: 0, - size: 1536000, - duration: 3725, - } - - beforeEach(async () => { - vi.clearAllMocks() - vi.spyOn(navigator, 'languages', 'get').mockReturnValue([]) - const { useLocale } = await import('react-admin') - vi.mocked(useLocale).mockReturnValue('de') - }) - - const renderField = (Field, props) => - render(<Field record={record} resource="player" {...props} />) - - describe('<ReadOnlyTextField>', () => { - it('shows the record value with the translated field label', () => { - renderField(ReadOnlyTextField, { source: 'client' }) - const input = screen.getByLabelText('resources.player.fields.client') - expect(input).toHaveValue('NavidromeUI') - }) - - it('cannot be edited or reached with the Tab key', () => { - renderField(ReadOnlyTextField, { source: 'client' }) - const input = screen.getByRole('textbox') - expect(input).toHaveAttribute('readonly') - expect(input).toHaveAttribute('tabindex', '-1') - }) - - it('shows an empty value when the record has no value', () => { - renderField(ReadOnlyTextField, { source: 'userName' }) - expect(screen.getByRole('textbox')).toHaveValue('') - }) - - it('uses an explicit label when given', () => { - renderField(ReadOnlyTextField, { source: 'client', label: 'Custom' }) - expect(screen.getByLabelText('Custom')).toBeInTheDocument() - }) - - it('applies a custom format', () => { - renderField(ReadOnlyTextField, { - source: 'client', - format: (v) => v.toUpperCase(), - }) - expect(screen.getByRole('textbox')).toHaveValue('NAVIDROMEUI') - }) - - it('exposes theme-overridable class names', () => { - const { container } = renderField(ReadOnlyTextField, { - source: 'client', - }) - expect( - container.querySelector('[class*="NDReadOnlyField-inputRoot"]'), - ).toBeInTheDocument() - expect( - container.querySelector('[class*="NDReadOnlyField-notchedOutline"]'), - ).toBeInTheDocument() - }) - }) - - describe('<ReadOnlyDateField>', () => { - it('formats the date using the selected language', () => { - renderField(ReadOnlyDateField, { source: 'createdAt' }) - expect(screen.getByRole('textbox').value).toMatch(/^17\.9\.2026, /) - }) - - it('shows an empty value when the date is not set', () => { - renderField(ReadOnlyDateField, { source: 'lastVisitedAt' }) - expect(screen.getByRole('textbox')).toHaveValue('') - }) - }) - - describe('<ReadOnlyNumberField>', () => { - it('formats the number using the selected language', () => { - renderField(ReadOnlyNumberField, { source: 'count' }) - expect(screen.getByRole('textbox')).toHaveValue('1.234.567') - }) - - it('shows zero', () => { - renderField(ReadOnlyNumberField, { source: 'zero' }) - expect(screen.getByRole('textbox')).toHaveValue('0') - }) - }) - - describe('<ReadOnlySizeField>', () => { - it('formats bytes as a human-readable size', () => { - renderField(ReadOnlySizeField, { source: 'size' }) - expect(screen.getByRole('textbox')).toHaveValue('1.46 MB') - }) - }) - - describe('<ReadOnlyDurationField>', () => { - it('formats seconds as a human-readable duration', () => { - renderField(ReadOnlyDurationField, { source: 'duration' }) - expect(screen.getByRole('textbox')).toHaveValue('1h 2m 5s') - }) - }) -}) diff --git a/ui/src/common/RefreshMetadataButton.jsx b/ui/src/common/RefreshMetadataButton.jsx deleted file mode 100644 index aa0533ade..000000000 --- a/ui/src/common/RefreshMetadataButton.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { useCallback } from 'react' -import PropTypes from 'prop-types' -import Tooltip from '@material-ui/core/Tooltip' -import { makeStyles } from '@material-ui/core/styles' -import { Button, usePermissions, useTranslate } from 'react-admin' -import { MdRefresh } from 'react-icons/md' -import { useRefreshMetadata } from './useRefreshMetadata' - -const useStyles = makeStyles({ - // Tooltip needs a ref-holding child, and react-admin's Button does not forward one. - wrapper: { display: 'inline-flex', verticalAlign: 'middle' }, - button: { minWidth: 'auto' }, -}) - -// react-admin's Button, not an IconButton: the toolbars use it, so colour and the icon-only swap -// at xs match without restating either rule. -export const RefreshMetadataButton = ({ - resource, - record, - className, - size, -}) => { - const translate = useTranslate() - const { permissions } = usePermissions() - const refreshMetadata = useRefreshMetadata() - const classes = useStyles() - - const handleClick = useCallback( - () => refreshMetadata(resource, record?.id), - [refreshMetadata, resource, record], - ) - - if (permissions !== 'admin' || !record?.id) return null - - const label = translate('resources.album.actions.refresh') - return ( - <Tooltip title={label}> - <span className={classes.wrapper}> - <Button - aria-label={label} - className={className} - classes={{ button: classes.button }} - size={size} - onClick={handleClick} - > - <MdRefresh /> - </Button> - </span> - </Tooltip> - ) -} - -RefreshMetadataButton.propTypes = { - resource: PropTypes.oneOf(['album', 'artist']).isRequired, - record: PropTypes.object, - className: PropTypes.string, - size: PropTypes.oneOf(['small', 'medium']), -} - -RefreshMetadataButton.defaultProps = { - size: 'small', -} diff --git a/ui/src/common/RefreshMetadataButton.test.jsx b/ui/src/common/RefreshMetadataButton.test.jsx deleted file mode 100644 index 7bda0497c..000000000 --- a/ui/src/common/RefreshMetadataButton.test.jsx +++ /dev/null @@ -1,104 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { ThemeProvider, createTheme } from '@material-ui/core/styles' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { RefreshMetadataButton } from './RefreshMetadataButton' - -const mockNotify = vi.fn() -const mockRefreshMetadata = vi.fn() -const { mockPermissions } = vi.hoisted(() => ({ - mockPermissions: { value: 'admin' }, -})) - -vi.mock('react-admin', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useNotify: () => mockNotify, - useDataProvider: () => ({ refreshMetadata: mockRefreshMetadata }), - usePermissions: () => ({ permissions: mockPermissions.value }), - useTranslate: () => (x) => x, - } -}) - -describe('RefreshMetadataButton', () => { - const record = { id: 'al-1', name: 'Album' } - - beforeEach(() => { - vi.clearAllMocks() - mockPermissions.value = 'admin' - mockRefreshMetadata.mockResolvedValue({ data: { id: 'al-1' } }) - }) - - const renderButton = (props = {}) => - render( - <ThemeProvider theme={createTheme()}> - <RefreshMetadataButton resource="album" record={record} {...props} /> - </ThemeProvider>, - ) - - it('renders an icon-only button labelled by the refresh action', () => { - renderButton() - const button = screen.getByRole('button', { - name: 'resources.album.actions.refresh', - }) - expect(button).toBeInTheDocument() - expect(button).toHaveTextContent('') - }) - - it('shows the label as a tooltip on hover', async () => { - renderButton() - fireEvent.mouseOver( - screen.getByRole('button', { - name: 'resources.album.actions.refresh', - }), - ) - await waitFor(() => - expect(screen.getByRole('tooltip')).toHaveTextContent( - 'resources.album.actions.refresh', - ), - ) - }) - - it('renders nothing for non-admin users', () => { - mockPermissions.value = 'regular' - const { container } = renderButton() - expect(container).toBeEmptyDOMElement() - }) - - it('renders nothing without a record', () => { - const { container } = renderButton({ record: undefined }) - expect(container).toBeEmptyDOMElement() - }) - - it('requests a refresh for the record and notifies success', async () => { - renderButton() - fireEvent.click(screen.getByRole('button')) - - await waitFor(() => - expect(mockRefreshMetadata).toHaveBeenCalledWith('album', 'al-1'), - ) - await waitFor(() => - expect(mockNotify).toHaveBeenCalledWith('message.metadataRefreshStarted'), - ) - }) - - it('passes the artist resource through', async () => { - renderButton({ resource: 'artist', record: { id: 'ar-1' } }) - fireEvent.click(screen.getByRole('button')) - - await waitFor(() => - expect(mockRefreshMetadata).toHaveBeenCalledWith('artist', 'ar-1'), - ) - }) - - it('notifies a warning when the request fails', async () => { - mockRefreshMetadata.mockRejectedValue(new Error('boom')) - renderButton() - fireEvent.click(screen.getByRole('button')) - - await waitFor(() => - expect(mockNotify).toHaveBeenCalledWith('ra.page.error', 'warning'), - ) - }) -}) diff --git a/ui/src/common/SafeHTML.jsx b/ui/src/common/SafeHTML.jsx deleted file mode 100644 index 980883568..000000000 --- a/ui/src/common/SafeHTML.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import DOMPurify from 'dompurify' -import { useMemo } from 'react' - -export const SafeHTML = ({ children }) => { - const purified = useMemo(() => { - const purify = DOMPurify() - - purify.addHook('afterSanitizeElements', async (node) => { - if (node instanceof HTMLElement) { - // Set referrer-policy for elements with src - switch (node.tagName.toLowerCase()) { - case 'a': - case 'area': - case 'img': - case 'video': - case 'iframe': - case 'script': - node.setAttribute('referrer-policy', 'no-referrer') - } - } - }) - - return purify.sanitize(children, { ADD_ATTR: ['referrer-policy'] }) - }, [children]) - - return <span dangerouslySetInnerHTML={{ __html: purified }} /> -} diff --git a/ui/src/common/SelectLibraryInput.jsx b/ui/src/common/SelectLibraryInput.jsx deleted file mode 100644 index 0ac9783f5..000000000 --- a/ui/src/common/SelectLibraryInput.jsx +++ /dev/null @@ -1,228 +0,0 @@ -import React, { useState, useEffect, useMemo } from 'react' -import Checkbox from '@material-ui/core/Checkbox' -import CheckBoxIcon from '@material-ui/icons/CheckBox' -import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank' -import { - List, - ListItem, - ListItemIcon, - ListItemText, - Typography, - Box, -} from '@material-ui/core' -import { useGetList, useTranslate } from 'react-admin' -import PropTypes from 'prop-types' -import { makeStyles } from '@material-ui/core' - -const useStyles = makeStyles((theme) => ({ - root: { - width: '960px', - maxWidth: '100%', - }, - headerContainer: { - display: 'flex', - alignItems: 'center', - marginBottom: theme.spacing(1), - paddingLeft: theme.spacing(1), - }, - masterCheckbox: { - padding: '7px', - marginLeft: '-9px', - marginRight: theme.spacing(1), - }, - libraryList: { - height: '120px', - overflow: 'auto', - border: `1px solid ${theme.palette.divider}`, - borderRadius: theme.shape.borderRadius, - backgroundColor: theme.palette.background.paper, - }, - listItem: { - paddingTop: 0, - paddingBottom: 0, - }, - emptyMessage: { - padding: theme.spacing(2), - textAlign: 'center', - color: theme.palette.text.secondary, - }, -})) - -const EmptyLibraryMessage = () => { - const classes = useStyles() - - return ( - <div className={classes.emptyMessage}> - <Typography variant="body2">No libraries available</Typography> - </div> - ) -} - -const LibraryListItem = ({ library, isSelected, onToggle }) => { - const classes = useStyles() - - return ( - <ListItem - className={classes.listItem} - button - onClick={() => onToggle(library)} - dense - > - <ListItemIcon> - <Checkbox - icon={<CheckBoxOutlineBlankIcon fontSize="small" />} - checkedIcon={<CheckBoxIcon fontSize="small" />} - checked={isSelected} - tabIndex={-1} - disableRipple - /> - </ListItemIcon> - <ListItemText primary={library.name} /> - </ListItem> - ) -} - -export const SelectLibraryInput = ({ - onChange, - value = [], - isNewUser = false, -}) => { - const classes = useStyles() - const translate = useTranslate() - const [selectedLibraryIds, setSelectedLibraryIds] = useState([]) - const [hasInitialized, setHasInitialized] = useState(false) - - const { ids, data, isLoading } = useGetList( - 'library', - { page: 1, perPage: -1 }, - { field: 'name', order: 'ASC' }, - ) - - const options = useMemo( - () => (ids && ids.map((id) => data[id])) || [], - [ids, data], - ) - - // Reset initialization state when isNewUser changes - useEffect(() => { - if (isNewUser) { - setHasInitialized(false) - } - }, [isNewUser]) - - // Pre-select default libraries for new users - useEffect(() => { - if ( - isNewUser && - !isLoading && - options.length > 0 && - !hasInitialized && - Array.isArray(value) && - value.length === 0 - ) { - const defaultLibraryIds = options - .filter((lib) => lib.defaultNewUsers) - .map((lib) => lib.id) - - if (defaultLibraryIds.length > 0) { - setSelectedLibraryIds(defaultLibraryIds) - onChange(defaultLibraryIds) - } - - setHasInitialized(true) - } - }, [isNewUser, isLoading, options, hasInitialized, value, onChange]) - - // Update selectedLibraryIds when value prop changes (for editing mode and pre-selection) - useEffect(() => { - // For new users, only sync from value prop if it has actual data - // This prevents empty initial state from overriding our pre-selection - if (isNewUser && Array.isArray(value) && value.length === 0) { - return - } - - if (Array.isArray(value)) { - const libraryIds = value.map((item) => - typeof item === 'object' ? item.id : item, - ) - setSelectedLibraryIds(libraryIds) - } else if (value.length === 0) { - // Handle case where value is explicitly set to empty array (for existing users) - setSelectedLibraryIds([]) - } - }, [value, isNewUser, hasInitialized]) - - const isLibrarySelected = (library) => selectedLibraryIds.includes(library.id) - - const handleLibraryToggle = (library) => { - const isSelected = selectedLibraryIds.includes(library.id) - let newSelection - - if (isSelected) { - newSelection = selectedLibraryIds.filter((id) => id !== library.id) - } else { - newSelection = [...selectedLibraryIds, library.id] - } - - setSelectedLibraryIds(newSelection) - onChange(newSelection) - } - - const handleMasterCheckboxChange = () => { - const isAllSelected = selectedLibraryIds.length === options.length - const newSelection = isAllSelected ? [] : options.map((lib) => lib.id) - - setSelectedLibraryIds(newSelection) - onChange(newSelection) - } - - const selectedCount = selectedLibraryIds.length - const totalCount = options.length - const isAllSelected = selectedCount === totalCount && totalCount > 0 - const isIndeterminate = selectedCount > 0 && selectedCount < totalCount - - return ( - <div className={classes.root}> - {options.length > 1 && ( - <Box className={classes.headerContainer}> - <Checkbox - checked={isAllSelected} - indeterminate={isIndeterminate} - onChange={handleMasterCheckboxChange} - size="small" - className={classes.masterCheckbox} - /> - <Typography variant="body2"> - {translate('resources.user.message.selectAllLibraries')} - </Typography> - </Box> - )} - <List className={classes.libraryList}> - {options.length === 0 ? ( - <EmptyLibraryMessage /> - ) : ( - options.map((library) => ( - <LibraryListItem - key={library.id} - library={library} - isSelected={isLibrarySelected(library)} - onToggle={handleLibraryToggle} - /> - )) - )} - </List> - </div> - ) -} - -SelectLibraryInput.propTypes = { - onChange: PropTypes.func.isRequired, - value: PropTypes.array, - isNewUser: PropTypes.bool, -} - -LibraryListItem.propTypes = { - library: PropTypes.object.isRequired, - isSelected: PropTypes.bool.isRequired, - onToggle: PropTypes.func.isRequired, -} diff --git a/ui/src/common/SelectLibraryInput.test.jsx b/ui/src/common/SelectLibraryInput.test.jsx deleted file mode 100644 index 8a7e56d3e..000000000 --- a/ui/src/common/SelectLibraryInput.test.jsx +++ /dev/null @@ -1,458 +0,0 @@ -import * as React from 'react' -import { render, screen, fireEvent, cleanup } from '@testing-library/react' -import { SelectLibraryInput } from './SelectLibraryInput' -import { useGetList } from 'react-admin' -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' - -// Mock Material-UI components -vi.mock('@material-ui/core', () => ({ - List: ({ children }) => <div>{children}</div>, - ListItem: ({ children, button, onClick, dense, className }) => ( - <button onClick={onClick} className={className}> - {children} - </button> - ), - ListItemIcon: ({ children }) => <span>{children}</span>, - ListItemText: ({ primary }) => <span>{primary}</span>, - Typography: ({ children, variant }) => <span>{children}</span>, - Box: ({ children, className }) => <div className={className}>{children}</div>, - Checkbox: ({ - checked, - indeterminate, - onChange, - size, - className, - ...props - }) => ( - <input - type="checkbox" - checked={checked} - ref={(el) => { - if (el) el.indeterminate = indeterminate - }} - onChange={onChange} - className={className} - {...props} - /> - ), - makeStyles: () => () => ({}), -})) - -// Mock Material-UI icons -vi.mock('@material-ui/icons', () => ({ - CheckBox: () => <span>☑</span>, - CheckBoxOutlineBlank: () => <span>☐</span>, -})) - -// Mock the react-admin hook -vi.mock('react-admin', () => ({ - useGetList: vi.fn(), - useTranslate: vi.fn(() => (key) => key), // Simple translation mock -})) - -describe('<SelectLibraryInput />', () => { - const mockOnChange = vi.fn() - - beforeEach(() => { - // Reset the mock before each test - mockOnChange.mockClear() - }) - - afterEach(cleanup) - - it('should render empty message when no libraries available', () => { - // Mock empty library response - useGetList.mockReturnValue({ - ids: [], - data: {}, - }) - - render(<SelectLibraryInput onChange={mockOnChange} value={[]} />) - expect(screen.getByText('No libraries available')).not.toBeNull() - }) - - it('should render libraries when available', () => { - // Mock libraries - const mockLibraries = { - 1: { id: '1', name: 'Library 1' }, - 2: { id: '2', name: 'Library 2' }, - } - useGetList.mockReturnValue({ - ids: ['1', '2'], - data: mockLibraries, - }) - - render(<SelectLibraryInput onChange={mockOnChange} value={[]} />) - expect(screen.getByText('Library 1')).not.toBeNull() - expect(screen.getByText('Library 2')).not.toBeNull() - }) - - it('should toggle selection when a library is clicked', () => { - // Mock libraries - const mockLibraries = { - 1: { id: '1', name: 'Library 1' }, - 2: { id: '2', name: 'Library 2' }, - } - - // Test selecting an item - useGetList.mockReturnValue({ - ids: ['1', '2'], - data: mockLibraries, - }) - render(<SelectLibraryInput onChange={mockOnChange} value={[]} />) - - // Find the library buttons by their text content - const library1Button = screen.getByText('Library 1').closest('button') - fireEvent.click(library1Button) - expect(mockOnChange).toHaveBeenCalledWith(['1']) - - // Clean up to avoid DOM duplication - cleanup() - mockOnChange.mockClear() - - // Test deselecting an item - useGetList.mockReturnValue({ - ids: ['1', '2'], - data: mockLibraries, - }) - render(<SelectLibraryInput onChange={mockOnChange} value={['1']} />) - - // Find the library button again and click to deselect - const library1ButtonDeselect = screen - .getByText('Library 1') - .closest('button') - fireEvent.click(library1ButtonDeselect) - expect(mockOnChange).toHaveBeenCalledWith([]) - }) - - it('should correctly initialize with provided values', () => { - // Mock libraries - const mockLibraries = { - 1: { id: '1', name: 'Library 1' }, - 2: { id: '2', name: 'Library 2' }, - } - useGetList.mockReturnValue({ - ids: ['1', '2'], - data: mockLibraries, - }) - - // Initial value as array of IDs - render(<SelectLibraryInput onChange={mockOnChange} value={['1']} />) - - // Check that checkbox for Library 1 is checked - const checkboxes = screen.getAllByRole('checkbox') - // With master checkbox, individual checkboxes start at index 1 - expect(checkboxes[1].checked).toBe(true) // Library 1 - expect(checkboxes[2].checked).toBe(false) // Library 2 - }) - - it('should handle value as array of objects', () => { - // Mock libraries - const mockLibraries = { - 1: { id: '1', name: 'Library 1' }, - 2: { id: '2', name: 'Library 2' }, - } - useGetList.mockReturnValue({ - ids: ['1', '2'], - data: mockLibraries, - }) - - // Initial value as array of objects with id property - render(<SelectLibraryInput onChange={mockOnChange} value={[{ id: '2' }]} />) - - // Check that checkbox for Library 2 is checked - const checkboxes = screen.getAllByRole('checkbox') - // With master checkbox, index shifts by 1 - expect(checkboxes[1].checked).toBe(false) // Library 1 - expect(checkboxes[2].checked).toBe(true) // Library 2 - }) - - it('should render master checkbox when there are multiple libraries', () => { - // Mock libraries - const mockLibraries = { - 1: { id: '1', name: 'Library 1' }, - 2: { id: '2', name: 'Library 2' }, - } - useGetList.mockReturnValue({ - ids: ['1', '2'], - data: mockLibraries, - }) - - render(<SelectLibraryInput onChange={mockOnChange} value={[]} />) - - // Should render master checkbox plus individual checkboxes - const checkboxes = screen.getAllByRole('checkbox') - expect(checkboxes).toHaveLength(3) // 1 master + 2 individual - expect( - screen.getByText('resources.user.message.selectAllLibraries'), - ).not.toBeNull() - }) - - it('should not render master checkbox when there is only one library', () => { - // Mock single library - const mockLibraries = { - 1: { id: '1', name: 'Library 1' }, - } - useGetList.mockReturnValue({ - ids: ['1'], - data: mockLibraries, - }) - - render(<SelectLibraryInput onChange={mockOnChange} value={[]} />) - - // Should render only individual checkbox - const checkboxes = screen.getAllByRole('checkbox') - expect(checkboxes).toHaveLength(1) // Only 1 individual checkbox - }) - - it('should handle master checkbox selection and deselection', () => { - // Mock libraries - const mockLibraries = { - 1: { id: '1', name: 'Library 1' }, - 2: { id: '2', name: 'Library 2' }, - } - useGetList.mockReturnValue({ - ids: ['1', '2'], - data: mockLibraries, - }) - - render(<SelectLibraryInput onChange={mockOnChange} value={[]} />) - - const checkboxes = screen.getAllByRole('checkbox') - const masterCheckbox = checkboxes[0] // Master is first - - // Click master checkbox to select all - fireEvent.click(masterCheckbox) - expect(mockOnChange).toHaveBeenCalledWith(['1', '2']) - - // Clean up and test deselect all - cleanup() - mockOnChange.mockClear() - - render(<SelectLibraryInput onChange={mockOnChange} value={['1', '2']} />) - const checkboxes2 = screen.getAllByRole('checkbox') - const masterCheckbox2 = checkboxes2[0] - - // Click master checkbox to deselect all - fireEvent.click(masterCheckbox2) - expect(mockOnChange).toHaveBeenCalledWith([]) - }) - - it('should show master checkbox as indeterminate when some libraries are selected', () => { - // Mock libraries - const mockLibraries = { - 1: { id: '1', name: 'Library 1' }, - 2: { id: '2', name: 'Library 2' }, - } - useGetList.mockReturnValue({ - ids: ['1', '2'], - data: mockLibraries, - }) - - render(<SelectLibraryInput onChange={mockOnChange} value={['1']} />) - - const checkboxes = screen.getAllByRole('checkbox') - const masterCheckbox = checkboxes[0] // Master is first - - // Master checkbox should not be checked when only some libraries are selected - expect(masterCheckbox.checked).toBe(false) - // Note: Testing indeterminate property directly through JSDOM can be unreliable - // The important behavior is that it's not checked when only some are selected - }) - - describe('New User Default Library Selection', () => { - // Helper function to create mock libraries with configurable default settings - const createMockLibraries = (libraryConfigs) => { - const libraries = {} - const ids = [] - - libraryConfigs.forEach(({ id, name, defaultNewUsers }) => { - libraries[id] = { - id, - name, - ...(defaultNewUsers !== undefined && { defaultNewUsers }), - } - ids.push(id) - }) - - return { libraries, ids } - } - - // Helper function to setup useGetList mock - const setupMockLibraries = (libraryConfigs, isLoading = false) => { - const { libraries, ids } = createMockLibraries(libraryConfigs) - useGetList.mockReturnValue({ - ids, - data: libraries, - isLoading, - }) - return { libraries, ids } - } - - beforeEach(() => { - mockOnChange.mockClear() - }) - - it('should pre-select default libraries for new users', () => { - setupMockLibraries([ - { id: '1', name: 'Library 1', defaultNewUsers: true }, - { id: '2', name: 'Library 2', defaultNewUsers: false }, - { id: '3', name: 'Library 3', defaultNewUsers: true }, - ]) - - render( - <SelectLibraryInput - onChange={mockOnChange} - value={[]} - isNewUser={true} - />, - ) - - expect(mockOnChange).toHaveBeenCalledWith(['1', '3']) - }) - - it('should not pre-select default libraries if new user already has values', () => { - setupMockLibraries([ - { id: '1', name: 'Library 1', defaultNewUsers: true }, - { id: '2', name: 'Library 2', defaultNewUsers: false }, - ]) - - render( - <SelectLibraryInput - onChange={mockOnChange} - value={['2']} // Already has a value - isNewUser={true} - />, - ) - - expect(mockOnChange).not.toHaveBeenCalled() - }) - - it('should not pre-select libraries while data is still loading', () => { - setupMockLibraries( - [{ id: '1', name: 'Library 1', defaultNewUsers: true }], - true, - ) // isLoading = true - - render( - <SelectLibraryInput - onChange={mockOnChange} - value={[]} - isNewUser={true} - />, - ) - - expect(mockOnChange).not.toHaveBeenCalled() - }) - - it('should not pre-select anything if no libraries have defaultNewUsers flag', () => { - setupMockLibraries([ - { id: '1', name: 'Library 1', defaultNewUsers: false }, - { id: '2', name: 'Library 2', defaultNewUsers: false }, - ]) - - render( - <SelectLibraryInput - onChange={mockOnChange} - value={[]} - isNewUser={true} - />, - ) - - expect(mockOnChange).not.toHaveBeenCalled() - }) - - it('should reset initialization state when isNewUser prop changes', () => { - setupMockLibraries([ - { id: '1', name: 'Library 1', defaultNewUsers: true }, - ]) - - const { rerender } = render( - <SelectLibraryInput - onChange={mockOnChange} - value={[]} - isNewUser={false} // Start as existing user - />, - ) - - expect(mockOnChange).not.toHaveBeenCalled() - - // Change to new user - rerender( - <SelectLibraryInput - onChange={mockOnChange} - value={[]} - isNewUser={true} - />, - ) - - expect(mockOnChange).toHaveBeenCalledWith(['1']) - }) - - it('should not override pre-selection when value prop is empty for new users', () => { - setupMockLibraries([ - { id: '1', name: 'Library 1', defaultNewUsers: true }, - { id: '2', name: 'Library 2', defaultNewUsers: false }, - ]) - - const { rerender } = render( - <SelectLibraryInput - onChange={mockOnChange} - value={[]} - isNewUser={true} - />, - ) - - expect(mockOnChange).toHaveBeenCalledWith(['1']) - mockOnChange.mockClear() - - // Re-render with empty value prop (simulating form state update) - rerender( - <SelectLibraryInput - onChange={mockOnChange} - value={[]} // Still empty - isNewUser={true} - />, - ) - - expect(mockOnChange).not.toHaveBeenCalled() - }) - - it('should sync from value prop for existing users even when empty', () => { - setupMockLibraries([ - { id: '1', name: 'Library 1', defaultNewUsers: true }, - ]) - - render( - <SelectLibraryInput - onChange={mockOnChange} - value={[]} // Empty value for existing user - isNewUser={false} - />, - ) - - // Check that no libraries are selected (checkboxes should be unchecked) - const checkboxes = screen.getAllByRole('checkbox') - // Only one checkbox since there's only one library and no master checkbox for single library - expect(checkboxes[0].checked).toBe(false) - }) - - it('should handle libraries with missing defaultNewUsers property', () => { - setupMockLibraries([ - { id: '1', name: 'Library 1', defaultNewUsers: true }, - { id: '2', name: 'Library 2' }, // Missing defaultNewUsers property - { id: '3', name: 'Library 3', defaultNewUsers: false }, - ]) - - render( - <SelectLibraryInput - onChange={mockOnChange} - value={[]} - isNewUser={true} - />, - ) - - expect(mockOnChange).toHaveBeenCalledWith(['1']) - }) - }) -}) diff --git a/ui/src/common/ShuffleAllButton.jsx b/ui/src/common/ShuffleAllButton.js similarity index 96% rename from ui/src/common/ShuffleAllButton.jsx rename to ui/src/common/ShuffleAllButton.js index 1631e2cf5..bc455b615 100644 --- a/ui/src/common/ShuffleAllButton.jsx +++ b/ui/src/common/ShuffleAllButton.js @@ -10,7 +10,6 @@ export const ShuffleAllButton = ({ filters }) => { const dataProvider = useDataProvider() const dispatch = useDispatch() const notify = useNotify() - filters = { ...filters, missing: false } const handleOnClick = () => { dataProvider diff --git a/ui/src/common/SimpleList.jsx b/ui/src/common/SimpleList.js similarity index 99% rename from ui/src/common/SimpleList.jsx rename to ui/src/common/SimpleList.js index b803993a2..1a4f72fba 100644 --- a/ui/src/common/SimpleList.jsx +++ b/ui/src/common/SimpleList.js @@ -19,7 +19,7 @@ const useStyles = makeStyles( }, tertiary: { float: 'right', opacity: 0.541176 }, }, - { name: 'RaSimpleList' }, + { name: 'RaSimpleList' } ) const LinkOrNot = ({ diff --git a/ui/src/common/SizeField.jsx b/ui/src/common/SizeField.js similarity index 79% rename from ui/src/common/SizeField.jsx rename to ui/src/common/SizeField.js index 34e668212..b64e4b7cf 100644 --- a/ui/src/common/SizeField.jsx +++ b/ui/src/common/SizeField.js @@ -13,12 +13,7 @@ const useStyles = makeStyles((theme) => ({ export const SizeField = ({ source, ...rest }) => { const classes = useStyles() const record = useRecordContext(rest) - if (!record) return null - return ( - <span className={classes.root}> - {record[source] ? formatBytes(record[source]) : '0 MB'} - </span> - ) + return <span className={classes.root}>{formatBytes(record[source])}</span> } SizeField.propTypes = { diff --git a/ui/src/common/SongBulkActions.jsx b/ui/src/common/SongBulkActions.js similarity index 100% rename from ui/src/common/SongBulkActions.jsx rename to ui/src/common/SongBulkActions.js diff --git a/ui/src/common/SongContextMenu.js b/ui/src/common/SongContextMenu.js new file mode 100644 index 000000000..3b8f60835 --- /dev/null +++ b/ui/src/common/SongContextMenu.js @@ -0,0 +1,150 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { useDispatch } from 'react-redux' +import { useTranslate } from 'react-admin' +import { IconButton, Menu, MenuItem } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import MoreVertIcon from '@material-ui/icons/MoreVert' +import clsx from 'clsx' +import { + playNext, + addTracks, + setTrack, + openAddToPlaylist, + openExtendedInfoDialog, + openDownloadMenu, + DOWNLOAD_MENU_SONG, + openShareMenu, +} from '../actions' +import { LoveButton } from './LoveButton' +import config from '../config' +import { formatBytes } from '../utils' + +const useStyles = makeStyles({ + noWrap: { + whiteSpace: 'nowrap', + }, +}) + +export const SongContextMenu = ({ + resource, + record, + showLove, + onAddToPlaylist, + className, +}) => { + const classes = useStyles() + const dispatch = useDispatch() + const translate = useTranslate() + const [anchorEl, setAnchorEl] = useState(null) + const options = { + playNow: { + enabled: true, + label: translate('resources.song.actions.playNow'), + action: (record) => dispatch(setTrack(record)), + }, + playNext: { + enabled: true, + label: translate('resources.song.actions.playNext'), + action: (record) => dispatch(playNext({ [record.id]: record })), + }, + addToQueue: { + enabled: true, + label: translate('resources.song.actions.addToQueue'), + action: (record) => dispatch(addTracks({ [record.id]: record })), + }, + addToPlaylist: { + enabled: true, + label: translate('resources.song.actions.addToPlaylist'), + action: (record) => + dispatch( + openAddToPlaylist({ + selectedIds: [record.mediaFileId || record.id], + onSuccess: (id) => onAddToPlaylist(id), + }) + ), + }, + share: { + enabled: config.enableSharing, + label: translate('ra.action.share'), + action: (record) => + dispatch( + openShareMenu([record.mediaFileId || record.id], 'song', record.title) + ), + }, + download: { + enabled: config.enableDownloads, + label: `${translate('ra.action.download')} (${formatBytes(record.size)})`, + action: (record) => + dispatch(openDownloadMenu(record, DOWNLOAD_MENU_SONG)), + }, + info: { + enabled: true, + label: translate('resources.song.actions.info'), + action: (record) => dispatch(openExtendedInfoDialog(record)), + }, + } + + const handleClick = (e) => { + setAnchorEl(e.currentTarget) + e.stopPropagation() + } + + const handleClose = (e) => { + setAnchorEl(null) + e.stopPropagation() + } + + const handleItemClick = (e) => { + e.preventDefault() + setAnchorEl(null) + const key = e.target.getAttribute('value') + options[key].action(record) + e.stopPropagation() + } + + const open = Boolean(anchorEl) + + return ( + <span className={clsx(classes.noWrap, className)}> + <LoveButton + record={record} + resource={resource} + visible={config.enableFavourites && showLove} + /> + <IconButton onClick={handleClick} size={'small'}> + <MoreVertIcon fontSize={'small'} /> + </IconButton> + <Menu + id={'menu' + record.id} + anchorEl={anchorEl} + open={open} + onClose={handleClose} + > + {Object.keys(options).map( + (key) => + options[key].enabled && ( + <MenuItem value={key} key={key} onClick={handleItemClick}> + {options[key].label} + </MenuItem> + ) + )} + </Menu> + </span> + ) +} + +SongContextMenu.propTypes = { + resource: PropTypes.string.isRequired, + record: PropTypes.object.isRequired, + onAddToPlaylist: PropTypes.func, + showLove: PropTypes.bool, +} + +SongContextMenu.defaultProps = { + onAddToPlaylist: () => {}, + record: {}, + resource: 'song', + showLove: true, + addLabel: true, +} diff --git a/ui/src/common/SongContextMenu.jsx b/ui/src/common/SongContextMenu.jsx deleted file mode 100644 index ac5b10e13..000000000 --- a/ui/src/common/SongContextMenu.jsx +++ /dev/null @@ -1,315 +0,0 @@ -import React, { useState } from 'react' -import PropTypes from 'prop-types' -import { useDispatch } from 'react-redux' -import { - useNotify, - usePermissions, - useTranslate, - useDataProvider, -} from 'react-admin' -import { IconButton, Menu, MenuItem } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import MoreVertIcon from '@material-ui/icons/MoreVert' -import { MdQuestionMark } from 'react-icons/md' -import clsx from 'clsx' -import { - playNext, - addTracks, - setTrack, - openAddToPlaylist, - openExtendedInfoDialog, - openDownloadMenu, - DOWNLOAD_MENU_SONG, - openShareMenu, -} from '../actions' -import { LoveButton } from './LoveButton' -import config from '../config' -import { playSimilar } from './playbackActions.js' -import { formatBytes } from '../utils' -import { useRedirect } from 'react-admin' - -const useStyles = makeStyles({ - noWrap: { - whiteSpace: 'nowrap', - }, -}) - -const MoreButton = ({ record, onClick, info }) => { - const handleClick = record.missing - ? (e) => { - info.action(record) - e.stopPropagation() - } - : onClick - return ( - <IconButton onClick={handleClick} size={'small'}> - {record?.missing ? ( - <MdQuestionMark fontSize={'large'} /> - ) : ( - <MoreVertIcon fontSize={'small'} /> - )} - </IconButton> - ) -} - -export const SongContextMenu = ({ - resource, - record, - showLove, - onAddToPlaylist, - className, -}) => { - const classes = useStyles() - const dispatch = useDispatch() - const translate = useTranslate() - const notify = useNotify() - const dataProvider = useDataProvider() - const [anchorEl, setAnchorEl] = useState(null) - const [playlistAnchorEl, setPlaylistAnchorEl] = useState(null) - const [playlists, setPlaylists] = useState([]) - const [playlistsLoaded, setPlaylistsLoaded] = useState(false) - const { permissions } = usePermissions() - const redirect = useRedirect() - - const options = { - playNow: { - enabled: true, - label: translate('resources.song.actions.playNow'), - action: (record) => dispatch(setTrack(record)), - }, - playNext: { - enabled: true, - label: translate('resources.song.actions.playNext'), - action: (record) => dispatch(playNext({ [record.id]: record })), - }, - addToQueue: { - enabled: true, - label: translate('resources.song.actions.addToQueue'), - action: (record) => dispatch(addTracks({ [record.id]: record })), - }, - instantMix: { - enabled: config.enableExternalServices, - label: translate('resources.song.actions.instantMix'), - action: async (record) => { - notify('message.startingInstantMix', { type: 'info' }) - try { - const id = record.mediaFileId || record.id - await playSimilar(dispatch, notify, id, { - seedRecord: record, - shuffle: false, - }) - } catch (e) { - // eslint-disable-next-line no-console - console.error('Error starting instant mix:', e) - notify('ra.page.error', { type: 'warning' }) - } - }, - }, - addToPlaylist: { - enabled: true, - label: translate('resources.song.actions.addToPlaylist'), - action: (record) => - dispatch( - openAddToPlaylist({ - selectedIds: [record.mediaFileId || record.id], - onSuccess: (id) => onAddToPlaylist(id), - }), - ), - }, - showInPlaylist: { - enabled: true, - label: - translate('resources.song.actions.showInPlaylist') + - (playlists.length > 0 ? ' ►' : ''), - action: (record, e) => { - setPlaylistAnchorEl(e.currentTarget) - }, - }, - share: { - enabled: config.enableSharing, - label: translate('ra.action.share'), - action: (record) => - dispatch( - openShareMenu( - [record.mediaFileId || record.id], - 'song', - record.title, - ), - ), - }, - download: { - enabled: config.enableDownloads, - label: `${translate('ra.action.download')} (${formatBytes(record.size)})`, - action: (record) => - dispatch(openDownloadMenu(record, DOWNLOAD_MENU_SONG)), - }, - info: { - enabled: true, - label: translate('resources.song.actions.info'), - action: async (record) => { - let fullRecord = record - if (permissions === 'admin' && !record.missing) { - try { - let id = record.mediaFileId ?? record.id - const data = await dataProvider.inspect(id) - fullRecord = { ...record, rawTags: data.data.rawTags } - } catch (error) { - notify( - translate('ra.notification.http_error') + ': ' + error.message, - { - type: 'warning', - multiLine: true, - duration: 0, - }, - ) - } - } - - dispatch(openExtendedInfoDialog(fullRecord)) - }, - }, - } - - const handleClick = (e) => { - setAnchorEl(e.currentTarget) - if (!playlistsLoaded) { - const id = record.mediaFileId || record.id - dataProvider - .getPlaylists(id) - .then((res) => { - setPlaylists(res.data) - setPlaylistsLoaded(true) - }) - .catch((error) => { - // eslint-disable-next-line no-console - console.error('Failed to fetch playlists:', error) - setPlaylists([]) - setPlaylistsLoaded(true) - }) - } - e.stopPropagation() - } - - const handleClose = (e) => { - setAnchorEl(null) - e.stopPropagation() - } - - const handleItemClick = (e) => { - e.preventDefault() - const key = e.target.getAttribute('value') - const action = options[key].action - - if (key === 'showInPlaylist') { - // For showInPlaylist, we keep the main menu open and show submenu - action(record, e) - } else { - // For other actions, close the main menu - setAnchorEl(null) - action(record) - } - e.stopPropagation() - } - - const handlePlaylistClose = (e) => { - setPlaylistAnchorEl(null) - if (e) { - e.stopPropagation() - } - } - - const handleMainMenuClose = (e) => { - setAnchorEl(null) - setPlaylistAnchorEl(null) // Close both menus - e.stopPropagation() - } - - const handlePlaylistClick = (id, e) => { - e.stopPropagation() - redirect(`/playlist/${id}/show`) - handlePlaylistClose() - } - - const open = Boolean(anchorEl) - - if (!record) { - return null - } - - const present = !record.missing - - return ( - <span className={clsx(classes.noWrap, className)}> - <LoveButton - record={record} - resource={resource} - visible={config.enableFavourites && showLove && present} - /> - <MoreButton record={record} onClick={handleClick} info={options.info} /> - <Menu - id={'menu' + record.id} - anchorEl={anchorEl} - open={open} - onClose={handleMainMenuClose} - > - {Object.keys(options).map((key) => { - const showInPlaylistDisabled = - key === 'showInPlaylist' && !playlists.length - return ( - options[key].enabled && ( - <MenuItem - value={key} - key={key} - onClick={ - showInPlaylistDisabled - ? (e) => e.stopPropagation() - : handleItemClick - } - disabled={showInPlaylistDisabled} - style={ - showInPlaylistDisabled ? { pointerEvents: 'auto' } : undefined - } - > - {options[key].label} - </MenuItem> - ) - ) - })} - </Menu> - <Menu - anchorEl={playlistAnchorEl} - open={Boolean(playlistAnchorEl)} - onClose={handlePlaylistClose} - anchorOrigin={{ - vertical: 'top', - horizontal: 'right', - }} - transformOrigin={{ - vertical: 'top', - horizontal: 'left', - }} - > - {playlists.map((p) => ( - <MenuItem key={p.id} onClick={(e) => handlePlaylistClick(p.id, e)}> - {p.name} - </MenuItem> - ))} - </Menu> - </span> - ) -} - -SongContextMenu.propTypes = { - resource: PropTypes.string.isRequired, - record: PropTypes.object.isRequired, - onAddToPlaylist: PropTypes.func, - showLove: PropTypes.bool, -} - -SongContextMenu.defaultProps = { - onAddToPlaylist: () => {}, - record: {}, - resource: 'song', - showLove: true, - addLabel: true, -} diff --git a/ui/src/common/SongContextMenu.test.jsx b/ui/src/common/SongContextMenu.test.jsx deleted file mode 100644 index 7172742d3..000000000 --- a/ui/src/common/SongContextMenu.test.jsx +++ /dev/null @@ -1,227 +0,0 @@ -import React from 'react' -import { render, fireEvent, screen, waitFor } from '@testing-library/react' -import { TestContext } from 'ra-test' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { SongContextMenu } from './SongContextMenu' -import subsonic from '../subsonic' - -vi.mock('../dataProvider', () => ({ - httpClient: vi.fn(), -})) - -vi.mock('../subsonic', () => ({ - default: { getSimilarSongs2: vi.fn() }, -})) - -vi.mock('../config', () => ({ - default: { - enableDownloads: true, - enableFavourites: true, - enableSharing: true, - enableExternalServices: true, - }, -})) - -const mockDispatch = vi.fn() -vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch })) - -const getPlaylistsMock = vi.fn() -const mockNotify = vi.fn() - -vi.mock('react-admin', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useNotify: () => mockNotify, - useRedirect: () => (url) => { - window.location.hash = `#${url}` - }, - useDataProvider: () => ({ - getPlaylists: getPlaylistsMock, - inspect: vi.fn().mockResolvedValue({ - data: { rawTags: {} }, - }), - }), - } -}) - -describe('SongContextMenu', () => { - beforeEach(() => { - vi.clearAllMocks() - window.location.hash = '' - getPlaylistsMock.mockResolvedValue({ - data: [{ id: 'pl1', name: 'Pl 1' }], - }) - subsonic.getSimilarSongs2.mockResolvedValue({ - json: { - 'subsonic-response': { - status: 'ok', - similarSongs2: { song: [{ id: 's1' }] }, - }, - }, - }) - }) - - it('navigates to playlist when selected', async () => { - render( - <TestContext> - <SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" /> - </TestContext>, - ) - fireEvent.click(screen.getAllByRole('button')[1]) - await waitFor(() => - screen.getByText(/resources\.song\.actions\.showInPlaylist/), - ) - fireEvent.click( - screen.getByText(/resources\.song\.actions\.showInPlaylist/), - ) - await waitFor(() => screen.getByText('Pl 1')) - fireEvent.click(screen.getByText('Pl 1')) - expect(window.location.hash).toBe('#/playlist/pl1/show') - }) - - it('stops event propagation when playlist submenu is closed', async () => { - const mockOnClick = vi.fn() - render( - <TestContext> - <div onClick={mockOnClick}> - <SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" /> - </div> - </TestContext>, - ) - - // Open main menu - fireEvent.click(screen.getAllByRole('button')[1]) - await waitFor(() => - screen.getByText(/resources\.song\.actions\.showInPlaylist/), - ) - - // Open playlist submenu - fireEvent.click( - screen.getByText(/resources\.song\.actions\.showInPlaylist/), - ) - await waitFor(() => screen.getByText('Pl 1')) - - // Click outside the playlist submenu (should close it without triggering parent click) - fireEvent.click(document.body) - - expect(mockOnClick).not.toHaveBeenCalled() - }) - - it('does nothing when "Show in Playlist" is disabled', async () => { - getPlaylistsMock.mockResolvedValue({ data: [] }) - const mockOnClick = vi.fn() - render( - <TestContext> - <div onClick={mockOnClick}> - <SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" /> - </div> - </TestContext>, - ) - - fireEvent.click(screen.getAllByRole('button')[1]) - await waitFor(() => - screen.getByText(/resources\.song\.actions\.showInPlaylist/), - ) - - fireEvent.click( - screen.getByText(/resources\.song\.actions\.showInPlaylist/), - ) - expect(mockOnClick).not.toHaveBeenCalled() - }) - - describe('Instant Mix action', () => { - it('calls getSimilarSongs2 with song id and shows loading notification', async () => { - render( - <TestContext> - <SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" /> - </TestContext>, - ) - - fireEvent.click(screen.getAllByRole('button')[1]) - await waitFor(() => - screen.getByText(/resources\.song\.actions\.instantMix/), - ) - fireEvent.click(screen.getByText(/resources\.song\.actions\.instantMix/)) - - // Verify loading notification is shown - expect(mockNotify).toHaveBeenCalledWith('message.startingInstantMix', { - type: 'info', - }) - - await waitFor(() => - expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('song1', 100), - ) - expect(mockDispatch).toHaveBeenCalled() - }) - - it('plays seed song first followed by similar songs', async () => { - const seedRecord = { id: 'song1', title: 'Seed Song', size: 1 } - render( - <TestContext> - <SongContextMenu record={seedRecord} resource="song" /> - </TestContext>, - ) - - fireEvent.click(screen.getAllByRole('button')[1]) - await waitFor(() => - screen.getByText(/resources\.song\.actions\.instantMix/), - ) - fireEvent.click(screen.getByText(/resources\.song\.actions\.instantMix/)) - - await waitFor(() => expect(mockDispatch).toHaveBeenCalled()) - - // Verify dispatch was called with playTracks action - const dispatchCall = mockDispatch.mock.calls.find( - (call) => call[0]?.type === 'PLAYER_PLAY_TRACKS', - ) - expect(dispatchCall).toBeDefined() - - // Verify seed song is first (id property contains the first song to play) - const { id, data } = dispatchCall[0] - expect(id).toBe('song1') - // Verify seed song data is included - expect(data['song1']).toBeDefined() - }) - - it('uses mediaFileId when available (playlist context)', async () => { - render( - <TestContext> - <SongContextMenu - record={{ - id: 'playlistTrackId', - mediaFileId: 'actualSongId', - size: 1, - }} - resource="song" - /> - </TestContext>, - ) - - fireEvent.click(screen.getAllByRole('button')[1]) - await waitFor(() => - screen.getByText(/resources\.song\.actions\.instantMix/), - ) - fireEvent.click(screen.getByText(/resources\.song\.actions\.instantMix/)) - - await waitFor(() => - expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith( - 'actualSongId', - 100, - ), - ) - - await waitFor(() => expect(mockDispatch).toHaveBeenCalled()) - - // Verify the mediaFileId is used as the seed song id - const dispatchCall = mockDispatch.mock.calls.find( - (call) => call[0]?.type === 'PLAYER_PLAY_TRACKS', - ) - expect(dispatchCall).toBeDefined() - const { id, data } = dispatchCall[0] - expect(id).toBe('actualSongId') - // Verify seed song data is included - expect(data['actualSongId']).toBeDefined() - }) - }) -}) diff --git a/ui/src/common/SongDatagrid.jsx b/ui/src/common/SongDatagrid.js similarity index 58% rename from ui/src/common/SongDatagrid.jsx rename to ui/src/common/SongDatagrid.js index 4a813f84d..42a617a73 100644 --- a/ui/src/common/SongDatagrid.jsx +++ b/ui/src/common/SongDatagrid.js @@ -1,10 +1,4 @@ -import React, { - isValidElement, - useMemo, - useCallback, - useState, - forwardRef, -} from 'react' +import React, { isValidElement, useMemo, useCallback, forwardRef } from 'react' import { useDispatch } from 'react-redux' import { Datagrid, @@ -23,10 +17,7 @@ import { makeStyles } from '@material-ui/core/styles' import AlbumIcon from '@material-ui/icons/Album' import clsx from 'clsx' import { useDrag } from 'react-dnd' -import Lightbox from 'react-image-lightbox' -import 'react-image-lightbox/style.css' import { playTracks } from '../actions' -import subsonic from '../subsonic' import { AlbumContextMenu } from '../common' import { DraggableTypes } from '../consts' import { formatFullDate } from '../utils' @@ -37,20 +28,10 @@ const useStyles = makeStyles({ overflow: 'hidden', textOverflow: 'ellipsis', verticalAlign: 'middle', - display: 'flex', - alignItems: 'center', }, discIcon: { - marginRight: '14px', - }, - discCoverArt: { - width: '48px', - height: '48px', - marginRight: '14px', - objectFit: 'cover', - borderRadius: '4px', - flexShrink: 0, - cursor: 'pointer', + verticalAlign: 'text-top', + marginRight: '4px', }, row: { cursor: 'pointer', @@ -60,10 +41,6 @@ const useStyles = makeStyles({ }, }, }, - missingRow: { - cursor: 'inherit', - opacity: 0.3, - }, headerStyle: { '& thead': { boxShadow: '0px 3px 3px rgba(0, 0, 0, 0.15)', @@ -78,116 +55,100 @@ const useStyles = makeStyles({ }, }) -export const DiscSubtitleRow = forwardRef( +const ReleaseRow = forwardRef( ({ record, onClick, colSpan, contextAlwaysVisible }, ref) => { - const translate = useTranslate() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) const classes = useStyles({ isDesktop }) - const [imageError, setImageError] = useState(false) - const [isLightboxOpen, setLightboxOpen] = useState(false) - const lightboxClosedAt = React.useRef(0) - const handlePlaySubset = (discNumber) => () => { - // Ignore clicks shortly after the lightbox was closed to prevent - // mobile touch events from "falling through" the overlay and - // triggering playback. - if (Date.now() - lightboxClosedAt.current < 400) { - return - } - onClick(discNumber) + const translate = useTranslate() + const handlePlaySubset = (releaseDate) => () => { + onClick(releaseDate) } - const coverArtUrl = subsonic.getDiscCoverArtUrl( - record.albumId, - record.discNumber, - record.updatedAt, - 96, - ) - - const fullImageUrl = subsonic.getDiscCoverArtUrl( - record.albumId, - record.discNumber, - record.updatedAt, - ) - - const handleOpenLightbox = useCallback( - (e) => { - if (!imageError) { - e.stopPropagation() - setLightboxOpen(true) - } - }, - [imageError], - ) - - const handleCloseLightbox = useCallback(() => { - lightboxClosedAt.current = Date.now() - setLightboxOpen(false) - }, []) - - const subtitle = record.discSubtitle - ? record.discSubtitle - : translate('resources.song.fields.disc', { - discNumber: record.discNumber, - }) + let releaseTitle = [] + if (record.releaseDate) { + releaseTitle.push(translate('resources.album.fields.released')) + releaseTitle.push(formatFullDate(record.releaseDate)) + if (record.catalogNum && isDesktop) { + releaseTitle.push('· Cat #') + releaseTitle.push(record.catalogNum) + } + } return ( <TableRow hover ref={ref} - onClick={handlePlaySubset(record.discNumber)} + onClick={handlePlaySubset(record.releaseDate)} className={classes.row} > <TableCell colSpan={colSpan}> <Typography variant="h6" className={classes.subtitle}> - {!imageError ? ( - <img - src={coverArtUrl} - className={classes.discCoverArt} - alt="" - onClick={handleOpenLightbox} - onError={() => setImageError(true)} - /> - ) : ( - <AlbumIcon className={classes.discIcon} fontSize={'small'} /> - )} - {subtitle} + {releaseTitle.join(' ')} </Typography> - {isLightboxOpen && !imageError && ( - // Lightbox portals out of the row, but React still bubbles its - // events up this tree, where the row's onClick would play the disc. - <span onClick={(e) => e.stopPropagation()}> - <Lightbox - imagePadding={50} - animationDuration={200} - imageTitle={record.album + ' - ' + subtitle} - mainSrc={fullImageUrl} - onCloseRequest={handleCloseLightbox} - /> - </span> - )} </TableCell> <TableCell> <AlbumContextMenu record={{ id: record.albumId }} - discNumber={record.discNumber} + releaseDate={record.releaseDate} showLove={false} className={classes.contextMenu} - hideShare={true} - hideInfo={true} visible={contextAlwaysVisible} /> </TableCell> </TableRow> ) - }, + } ) -DiscSubtitleRow.displayName = 'DiscSubtitleRow' +const DiscSubtitleRow = forwardRef( + ({ record, onClick, colSpan, contextAlwaysVisible }, ref) => { + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) + const classes = useStyles({ isDesktop }) + const handlePlaySubset = (releaseDate, discNumber) => () => { + onClick(releaseDate, discNumber) + } + + let subtitle = [] + if (record.discNumber > 0) { + subtitle.push(record.discNumber) + } + if (record.discSubtitle) { + subtitle.push(record.discSubtitle) + } + + return ( + <TableRow + hover + ref={ref} + onClick={handlePlaySubset(record.releaseDate, record.discNumber)} + className={classes.row} + > + <TableCell colSpan={colSpan}> + <Typography variant="h6" className={classes.subtitle}> + <AlbumIcon className={classes.discIcon} fontSize={'small'} /> + {subtitle.join(': ')} + </Typography> + </TableCell> + <TableCell> + <AlbumContextMenu + record={{ id: record.albumId }} + discNumber={record.discNumber} + releaseDate={record.releaseDate} + showLove={false} + className={classes.contextMenu} + visible={contextAlwaysVisible} + /> + </TableCell> + </TableRow> + ) + } +) export const SongDatagridRow = ({ record, children, firstTracksOfDiscs, + firstTracksOfReleases, contextAlwaysVisible, onClickSubset, className, @@ -195,7 +156,7 @@ export const SongDatagridRow = ({ }) => { const classes = useStyles() const fields = React.Children.toArray(children).filter((c) => - isValidElement(c), + isValidElement(c) ) const [, dragDiscRef] = useDrag( @@ -205,13 +166,14 @@ export const SongDatagridRow = ({ discs: [ { albumId: record?.albumId, + releaseDate: record?.releaseDate, discNumber: record?.discNumber, }, ], }, options: { dropEffect: 'copy' }, }), - [record], + [record] ) const [, dragSongRef] = useDrag( @@ -220,23 +182,25 @@ export const SongDatagridRow = ({ item: { ids: [record?.mediaFileId || record?.id] }, options: { dropEffect: 'copy' }, }), - [record], + [record] ) if (!record || !record.title) { return null } - const rowClick = record.missing ? undefined : rest.rowClick - - const computedClasses = clsx( - className, - classes.row, - record.missing && classes.missingRow, - ) const childCount = fields.length return ( <> + {firstTracksOfReleases.has(record.id) && ( + <ReleaseRow + ref={dragDiscRef} + record={record} + onClick={onClickSubset} + contextAlwaysVisible={contextAlwaysVisible} + colSpan={childCount + (rest.expand ? 1 : 0)} + /> + )} {firstTracksOfDiscs.has(record.id) && ( <DiscSubtitleRow ref={dragDiscRef} @@ -250,8 +214,7 @@ export const SongDatagridRow = ({ ref={dragSongRef} record={record} {...rest} - rowClick={rowClick} - className={computedClasses} + className={clsx(className, classes.row)} > {fields} </PureDatagridRow> @@ -263,6 +226,7 @@ SongDatagridRow.propTypes = { record: PropTypes.object, children: PropTypes.node, firstTracksOfDiscs: PropTypes.instanceOf(Set), + firstTracksOfReleases: PropTypes.instanceOf(Set), contextAlwaysVisible: PropTypes.bool, onClickSubset: PropTypes.func, } @@ -274,25 +238,27 @@ SongDatagridRow.defaultProps = { const SongDatagridBody = ({ contextAlwaysVisible, showDiscSubtitles, + showReleaseDivider, ...rest }) => { const dispatch = useDispatch() const { ids, data } = rest const playSubset = useCallback( - (discNumber) => { + (releaseDate, discNumber) => { let idsToPlay = [] if (discNumber !== undefined) { - idsToPlay = ids.filter((id) => data[id].discNumber === discNumber) + idsToPlay = ids.filter( + (id) => + data[id].releaseDate === releaseDate && + data[id].discNumber === discNumber + ) + } else { + idsToPlay = ids.filter((id) => data[id].releaseDate === releaseDate) } - dispatch( - playTracks( - data, - idsToPlay?.filter((id) => !data[id].missing), - ), - ) + dispatch(playTracks(data, idsToPlay)) }, - [dispatch, data, ids], + [dispatch, data, ids] ) const firstTracksOfDiscs = useMemo(() => { @@ -308,12 +274,13 @@ const SongDatagridBody = ({ foundSubtitle = foundSubtitle || data[id].discSubtitle if ( acc.length === 0 || - (last && data[id].discNumber !== data[last].discNumber) + (last && data[id].discNumber !== data[last].discNumber) || + (last && data[id].releaseDate !== data[last].releaseDate) ) { acc.push(id) } return acc - }, []), + }, []) ) if (!showDiscSubtitles || (set.size < 2 && !foundSubtitle)) { set.clear() @@ -321,12 +288,37 @@ const SongDatagridBody = ({ return set }, [ids, data, showDiscSubtitles]) + const firstTracksOfReleases = useMemo(() => { + if (!ids) { + return new Set() + } + const set = new Set( + ids + .filter((i) => data[i]) + .reduce((acc, id) => { + const last = acc && acc[acc.length - 1] + if ( + acc.length === 0 || + (last && data[id].releaseDate !== data[last].releaseDate) + ) { + acc.push(id) + } + return acc + }, []) + ) + if (!showReleaseDivider || set.size < 2) { + set.clear() + } + return set + }, [ids, data, showReleaseDivider]) + return ( <PureDatagridBody {...rest} row={ <SongDatagridRow firstTracksOfDiscs={firstTracksOfDiscs} + firstTracksOfReleases={firstTracksOfReleases} contextAlwaysVisible={contextAlwaysVisible} onClickSubset={playSubset} /> @@ -338,18 +330,19 @@ const SongDatagridBody = ({ export const SongDatagrid = ({ contextAlwaysVisible, showDiscSubtitles, + showReleaseDivider, ...rest }) => { const classes = useStyles() return ( <Datagrid className={classes.headerStyle} - isRowSelectable={(r) => !r?.missing} {...rest} body={ <SongDatagridBody contextAlwaysVisible={contextAlwaysVisible} showDiscSubtitles={showDiscSubtitles} + showReleaseDivider={showReleaseDivider} /> } /> @@ -359,5 +352,6 @@ export const SongDatagrid = ({ SongDatagrid.propTypes = { contextAlwaysVisible: PropTypes.bool, showDiscSubtitles: PropTypes.bool, + showReleaseDivider: PropTypes.bool, classes: PropTypes.object, } diff --git a/ui/src/common/SongDatagrid.test.jsx b/ui/src/common/SongDatagrid.test.jsx deleted file mode 100644 index f8459d8eb..000000000 --- a/ui/src/common/SongDatagrid.test.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from 'react' -import { render, fireEvent, screen } from '@testing-library/react' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { createTheme, ThemeProvider } from '@material-ui/core/styles' -import { DiscSubtitleRow } from './SongDatagrid' - -vi.mock('../subsonic', () => ({ - default: { getDiscCoverArtUrl: () => 'http://localhost/cover.jpg' }, -})) - -vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() })) - -vi.mock('../common', () => ({ AlbumContextMenu: () => null })) - -vi.mock('react-dnd', () => ({ useDrag: () => [{}, vi.fn()] })) - -const record = { - id: 'song-1', - albumId: 'album-1', - album: 'The Album', - discNumber: 2, - discSubtitle: 'Bonus Disc', - updatedAt: '2024-01-01', -} - -const renderRow = (onClick) => - render( - <ThemeProvider theme={createTheme()}> - <table> - <tbody> - <DiscSubtitleRow record={record} onClick={onClick} colSpan={3} /> - </tbody> - </table> - </ThemeProvider>, - ) - -const openLightbox = () => { - fireEvent.click(document.querySelector('img')) - expect(document.querySelector('.ril__closeButton')).toBeTruthy() -} - -describe('DiscSubtitleRow', () => { - beforeEach(() => vi.clearAllMocks()) - - it('plays the disc when the row is clicked', () => { - const onClick = vi.fn() - renderRow(onClick) - fireEvent.click(screen.getByText('Bonus Disc')) - expect(onClick).toHaveBeenCalledWith(2) - }) - - it('does not play the disc when opening the lightbox', () => { - const onClick = vi.fn() - renderRow(onClick) - openLightbox() - expect(onClick).not.toHaveBeenCalled() - }) - - it('does not play the disc when closing the lightbox', () => { - const onClick = vi.fn() - renderRow(onClick) - openLightbox() - fireEvent.click(document.querySelector('.ril__closeButton')) - expect(onClick).not.toHaveBeenCalled() - }) - - it('does not play the disc when clicking the lightbox backdrop', () => { - const onClick = vi.fn() - renderRow(onClick) - openLightbox() - fireEvent.click(document.querySelector('.ril__inner')) - expect(onClick).not.toHaveBeenCalled() - }) -}) diff --git a/ui/src/common/SongInfo.js b/ui/src/common/SongInfo.js new file mode 100644 index 000000000..761bc59eb --- /dev/null +++ b/ui/src/common/SongInfo.js @@ -0,0 +1,93 @@ +import React from 'react' +import Table from '@material-ui/core/Table' +import TableBody from '@material-ui/core/TableBody' +import TableCell from '@material-ui/core/TableCell' +import TableContainer from '@material-ui/core/TableContainer' +import TableRow from '@material-ui/core/TableRow' +import { + BooleanField, + DateField, + TextField, + NumberField, + FunctionField, + useTranslate, + useRecordContext, +} from 'react-admin' +import inflection from 'inflection' +import { BitrateField, SizeField } from './index' +import { MultiLineTextField } from './MultiLineTextField' +import { makeStyles } from '@material-ui/core/styles' +import config from '../config' + +const useStyles = makeStyles({ + gain: { + '&:after': { + content: (props) => (props.gain ? " ' db'" : ''), + }, + }, + tableCell: { + width: '17.5%', + }, +}) + +export const SongInfo = (props) => { + const classes = useStyles({ gain: config.enableReplayGain }) + const translate = useTranslate() + const record = useRecordContext(props) + const data = { + path: <TextField source="path" />, + album: <TextField source="album" />, + discSubtitle: <TextField source="discSubtitle" />, + albumArtist: <TextField source="albumArtist" />, + genre: ( + <FunctionField render={(r) => r.genres?.map((g) => g.name).join(', ')} /> + ), + compilation: <BooleanField source="compilation" />, + bitRate: <BitrateField source="bitRate" />, + channels: <NumberField source="channels" />, + size: <SizeField source="size" />, + updatedAt: <DateField source="updatedAt" showTime />, + playCount: <TextField source="playCount" />, + bpm: <NumberField source="bpm" />, + comment: <MultiLineTextField source="comment" />, + } + + const optionalFields = ['discSubtitle', 'comment', 'bpm', 'genre'] + optionalFields.forEach((field) => { + !record[field] && delete data[field] + }) + if (record.playCount > 0) { + data.playDate = <DateField record={record} source="playDate" showTime /> + } + + if (config.enableReplayGain) { + data.albumGain = ( + <NumberField source="rgAlbumGain" className={classes.gain} /> + ) + data.trackGain = ( + <NumberField source="rgTrackGain" className={classes.gain} /> + ) + } + + return ( + <TableContainer> + <Table aria-label="song details" size="small"> + <TableBody> + {Object.keys(data).map((key) => { + return ( + <TableRow key={`${record.id}-${key}`}> + <TableCell scope="row" className={classes.tableCell}> + {translate(`resources.song.fields.${key}`, { + _: inflection.humanize(inflection.underscore(key)), + })} + : + </TableCell> + <TableCell align="left">{data[key]}</TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </TableContainer> + ) +} diff --git a/ui/src/common/SongInfo.jsx b/ui/src/common/SongInfo.jsx deleted file mode 100644 index 1b1a014f1..000000000 --- a/ui/src/common/SongInfo.jsx +++ /dev/null @@ -1,218 +0,0 @@ -import React, { useState } from 'react' -import Table from '@material-ui/core/Table' -import TableBody from '@material-ui/core/TableBody' -import TableCell from '@material-ui/core/TableCell' -import TableContainer from '@material-ui/core/TableContainer' -import TableRow from '@material-ui/core/TableRow' -import { - BooleanField, - DateField, - TextField, - NumberField, - FunctionField, - useTranslate, - useRecordContext, -} from 'react-admin' -import { humanize, underscore } from 'inflection' -import { - ArtistLinkField, - BitrateField, - ParticipantsInfo, - PathField, - SizeField, -} from './index' -import { MultiLineTextField } from './MultiLineTextField' -import { makeStyles } from '@material-ui/core/styles' -import config from '../config' -import { AlbumLinkField } from '../song/AlbumLinkField' -import { Tab, Tabs } from '@material-ui/core' - -const useStyles = makeStyles({ - gain: { - '&:after': { - content: (props) => (props.gain ? " ' db'" : ''), - }, - }, - tableCell: { - width: '17.5%', - }, - value: { - whiteSpace: 'pre-line', - }, -}) - -export const SongInfo = (props) => { - const classes = useStyles({ gain: config.enableReplayGain }) - const translate = useTranslate() - const record = useRecordContext(props) - const [tab, setTab] = useState(0) - - // These are already displayed in other fields or are album-level tags - const excludedTags = [ - 'genre', - 'disctotal', - 'tracktotal', - 'releasetype', - 'recordlabel', - 'media', - 'albumversion', - ] - const data = { - path: <PathField />, - libraryName: <TextField source="libraryName" />, - album: ( - <AlbumLinkField source="album" sortByOrder={'ASC'} record={record} /> - ), - discSubtitle: <TextField source="discSubtitle" />, - albumArtist: ( - <ArtistLinkField source="albumArtist" record={record} limit={Infinity} /> - ), - artist: ( - <ArtistLinkField source="artist" record={record} limit={Infinity} /> - ), - genre: ( - <FunctionField render={(r) => r.genres?.map((g) => g.name).join(' • ')} /> - ), - compilation: <BooleanField source="compilation" />, - bitRate: <BitrateField source="bitRate" />, - bitDepth: <NumberField source="bitDepth" />, - sampleRate: <NumberField source="sampleRate" />, - channels: <NumberField source="channels" />, - size: <SizeField source="size" />, - updatedAt: <DateField source="updatedAt" showTime />, - playCount: <TextField source="playCount" />, - bpm: <NumberField source="bpm" />, - comment: <MultiLineTextField source="comment" />, - } - - const roles = [] - - for (const name of Object.keys(record.participants)) { - if (name === 'albumartist' || name === 'artist') { - continue - } - roles.push([name, record.participants[name].length]) - } - - const optionalFields = [ - 'discSubtitle', - 'comment', - 'bpm', - 'genre', - 'bitDepth', - 'sampleRate', - ] - optionalFields.forEach((field) => { - !record[field] && delete data[field] - }) - if (record.playCount > 0) { - data.playDate = <DateField record={record} source="playDate" showTime /> - } - - if (config.enableReplayGain) { - data.albumGain = ( - <NumberField source="rgAlbumGain" className={classes.gain} /> - ) - data.trackGain = ( - <NumberField source="rgTrackGain" className={classes.gain} /> - ) - } - - const tags = Object.entries(record.tags ?? {}).filter( - (tag) => !excludedTags.includes(tag[0]), - ) - - return ( - <TableContainer> - {record.rawTags && ( - <Tabs value={tab} onChange={(_, value) => setTab(value)}> - <Tab - label={translate(`resources.song.fields.mappedTags`)} - id="mapped-tags-tab" - aria-controls="mapped-tags-body" - /> - <Tab - label={translate(`resources.song.fields.rawTags`)} - id="raw-tags-tab" - aria-controls="raw-tags-body" - /> - </Tabs> - )} - <div - hidden={tab === 1} - id="mapped-tags-body" - aria-labelledby={record.rawTags ? 'mapped-tags-tab' : undefined} - > - <Table aria-label="song details" size="small"> - <TableBody> - {Object.keys(data).map((key) => { - return ( - <TableRow key={`${record.id}-${key}`}> - <TableCell scope="row" className={classes.tableCell}> - {translate(`resources.song.fields.${key}`, { - _: humanize(underscore(key)), - })} - : - </TableCell> - <TableCell align="left" className={classes.value}> - {data[key]} - </TableCell> - </TableRow> - ) - })} - <ParticipantsInfo classes={classes} record={record} /> - {tags.length > 0 && ( - <TableRow key={`${record.id}-separator`}> - <TableCell - scope="row" - className={classes.tableCell} - ></TableCell> - <TableCell align="left" className={classes.value}> - <h4>{translate(`resources.song.fields.tags`)}</h4> - </TableCell> - </TableRow> - )} - {tags.map(([name, values]) => ( - <TableRow key={`${record.id}-tag-${name}`}> - <TableCell scope="row" className={classes.tableCell}> - {name}: - </TableCell> - <TableCell align="left" className={classes.value}> - {values.join(' • ')} - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - {record.rawTags && ( - <div - hidden={tab === 0} - id="raw-tags-body" - aria-labelledby="raw-tags-tab" - > - <Table size="small" aria-label="song raw tags"> - <TableBody> - <TableRow key={`${record.id}-raw-path`}> - <TableCell scope="row" className={classes.tableCell}> - {translate(`resources.song.fields.path`)}: - </TableCell> - <TableCell align="left">{data.path}</TableCell> - </TableRow> - {Object.entries(record.rawTags).map(([key, value]) => ( - <TableRow key={`${record.id}-raw-${key}`}> - <TableCell scope="row" className={classes.tableCell}> - {key}: - </TableCell> - <TableCell align="left" className={classes.value}> - {value.join(' • ')} - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </div> - )} - </TableContainer> - ) -} diff --git a/ui/src/common/SongSimpleList.jsx b/ui/src/common/SongSimpleList.js similarity index 98% rename from ui/src/common/SongSimpleList.jsx rename to ui/src/common/SongSimpleList.js index d398d3455..61a2893f7 100644 --- a/ui/src/common/SongSimpleList.jsx +++ b/ui/src/common/SongSimpleList.js @@ -47,7 +47,7 @@ const useStyles = makeStyles( top: '26px', }, }, - { name: 'RaSongSimpleList' }, + { name: 'RaSongSimpleList' } ) export const SongSimpleList = ({ @@ -108,7 +108,7 @@ export const SongSimpleList = ({ </ListItemSecondaryAction> </ListItem> </span> - ), + ) )} </List> ) diff --git a/ui/src/common/SongTitleField.jsx b/ui/src/common/SongTitleField.js similarity index 89% rename from ui/src/common/SongTitleField.jsx rename to ui/src/common/SongTitleField.js index 22c3e407c..21ceed601 100644 --- a/ui/src/common/SongTitleField.jsx +++ b/ui/src/common/SongTitleField.js @@ -21,9 +21,6 @@ const useStyles = makeStyles({ text: { verticalAlign: 'text-top', }, - subtitle: { - opacity: 0.5, - }, }) export const SongTitleField = ({ showTrackNumbers, ...props }) => { @@ -36,21 +33,11 @@ export const SongTitleField = ({ showTrackNumbers, ...props }) => { const isCurrent = currentId && (currentId === record.id || currentId === record.mediaFileId) - const subtitle = record?.tags?.['subtitle'] - const trackName = (r) => { const name = r.title if (r.trackNumber && showTrackNumbers) { return r.trackNumber.toString().padStart(2, '0') + ' ' + name } - if (subtitle) { - return ( - <> - {name} - <span className={classes.subtitle}>{' (' + subtitle + ')'}</span> - </> - ) - } return name } diff --git a/ui/src/common/ThumbHashCanvas.jsx b/ui/src/common/ThumbHashCanvas.jsx deleted file mode 100644 index 03d349313..000000000 --- a/ui/src/common/ThumbHashCanvas.jsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useEffect, useRef } from 'react' -import PropTypes from 'prop-types' -import { decode, naturalSize } from '../utils/thumbhash' - -// A thumbhash carries no detail beyond a few dozen pixels; CSS upscales the canvas. -const DECODE_SIZE = 32 - -// bitmapSize prefers the artwork's true ratio, falling back to the aspect the hash itself carries, -// which is quantised to a ratio of small integers and so only approximates the image. -const bitmapSize = (hash, ratio) => { - if (!(ratio > 0) || !Number.isFinite(ratio)) { - try { - return naturalSize(hash) - } catch { - return { width: DECODE_SIZE, height: DECODE_SIZE } - } - } - return ratio >= 1 - ? { - width: DECODE_SIZE, - height: Math.max(1, Math.round(DECODE_SIZE / ratio)), - } - : { - width: Math.max(1, Math.round(DECODE_SIZE * ratio)), - height: DECODE_SIZE, - } -} - -export const ThumbHashCanvas = ({ hash, ratio, fit, className, style }) => { - const canvasRef = useRef(null) - const { width, height } = bitmapSize(hash, ratio) - - useEffect(() => { - if (!hash || !canvasRef.current) { - return - } - const ctx = canvasRef.current.getContext('2d') - if (!ctx) { - return - } - // Clear first so a hash change that fails to decode never leaves a stale frame. - ctx.clearRect(0, 0, width, height) - try { - const pixels = decode(hash, width, height) - const imageData = ctx.createImageData(width, height) - imageData.data.set(pixels) - ctx.putImageData(imageData, 0, 0) - } catch { - // A malformed hash simply leaves the canvas blank. - } - }, [hash, width, height]) - - if (!hash) { - return null - } - return ( - <canvas - ref={canvasRef} - width={width} - height={height} - className={className} - style={{ ...style, objectFit: fit }} - aria-hidden="true" - /> - ) -} - -ThumbHashCanvas.propTypes = { - hash: PropTypes.string, - // Aspect ratio (width / height) of the image this stands in for; the hash's own when omitted. - ratio: PropTypes.number, - fit: PropTypes.oneOf(['cover', 'contain']), - className: PropTypes.string, - style: PropTypes.object, -} diff --git a/ui/src/common/ThumbHashCanvas.test.jsx b/ui/src/common/ThumbHashCanvas.test.jsx deleted file mode 100644 index b1f53fe5c..000000000 --- a/ui/src/common/ThumbHashCanvas.test.jsx +++ /dev/null @@ -1,95 +0,0 @@ -import { render } from '@testing-library/react' -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { ThumbHashCanvas } from './ThumbHashCanvas' - -// Golden hashes from core/artwork/thumbhash/testdata/golden.json. -const SQUARE = 'H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I' -const LANDSCAPE = '3wcOFJpwh4eBh3d4iIePgAj3hw==' - -describe('ThumbHashCanvas', () => { - // jsdom has no real 2D context; stub it (tracked) so specs can assert the draw path ran. - let ctxMock - let getContextSpy - beforeEach(() => { - ctxMock = { - clearRect: vi.fn(), - createImageData: vi.fn((w, h) => ({ - data: new Uint8ClampedArray(w * h * 4), - })), - putImageData: vi.fn(), - } - getContextSpy = vi - .spyOn(HTMLCanvasElement.prototype, 'getContext') - .mockReturnValue(ctxMock) - }) - afterEach(() => { - getContextSpy.mockRestore() - }) - - it('renders nothing without a hash', () => { - const { container } = render(<ThumbHashCanvas hash="" />) - expect(container.querySelector('canvas')).toBeNull() - }) - - it('decodes a valid hash and draws non-trivial pixel data', () => { - const { container } = render(<ThumbHashCanvas hash={SQUARE} />) - expect(container.querySelector('canvas')).not.toBeNull() - expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) - const [imageData] = ctxMock.putImageData.mock.calls[0] - expect(imageData.data.some((byte) => byte !== 0)).toBe(true) - }) - - it('decodes into a bitmap shaped like the image, so the blur is not distorted', () => { - const { container } = render( - <ThumbHashCanvas hash={SQUARE} ratio={1200 / 800} />, - ) - expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 21) - const canvas = container.querySelector('canvas') - expect(canvas.width).toBe(32) - expect(canvas.height).toBe(21) - }) - - it('shapes a portrait ratio the other way round', () => { - render(<ThumbHashCanvas hash={SQUARE} ratio={0.5} />) - expect(ctxMock.createImageData).toHaveBeenCalledWith(16, 32) - }) - - it('never collapses an extreme ratio to a zero-sized bitmap', () => { - render(<ThumbHashCanvas hash={SQUARE} ratio={200} />) - expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 1) - }) - - // Unlike a blurhash, a thumbhash carries its own approximate aspect, so an unknown ratio - // falls back to that rather than to a square. - it('falls back to the hash own aspect when the ratio is unknown', () => { - render(<ThumbHashCanvas hash={LANDSCAPE} ratio={0} />) - expect(ctxMock.createImageData).toHaveBeenCalledWith(32, 18) - }) - - it('applies the object-fit it is given, so it lands where the image will', () => { - const { container } = render( - <ThumbHashCanvas hash={SQUARE} ratio={1.5} fit="contain" />, - ) - expect(container.querySelector('canvas').style.objectFit).toBe('contain') - }) - - it('renders a canvas without throwing on a malformed hash, and draws nothing', () => { - const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const { container } = render( - <ThumbHashCanvas hash="!!!not-a-thumbhash!!!" />, - ) - expect(container.querySelector('canvas')).not.toBeNull() - expect(ctxMock.putImageData).not.toHaveBeenCalled() - spy.mockRestore() - }) - - it('clears the canvas when a hash change fails to decode', () => { - const { rerender } = render(<ThumbHashCanvas hash={SQUARE} />) - expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) - - rerender(<ThumbHashCanvas hash="!!!not-a-thumbhash!!!" />) - - expect(ctxMock.clearRect).toHaveBeenCalledTimes(2) - expect(ctxMock.putImageData).toHaveBeenCalledTimes(1) - }) -}) diff --git a/ui/src/common/Title.jsx b/ui/src/common/Title.js similarity index 100% rename from ui/src/common/Title.jsx rename to ui/src/common/Title.js diff --git a/ui/src/common/ToggleFieldsMenu.jsx b/ui/src/common/ToggleFieldsMenu.js similarity index 96% rename from ui/src/common/ToggleFieldsMenu.jsx rename to ui/src/common/ToggleFieldsMenu.js index 32ae51def..f8ee30d0f 100644 --- a/ui/src/common/ToggleFieldsMenu.jsx +++ b/ui/src/common/ToggleFieldsMenu.js @@ -36,7 +36,7 @@ export const ToggleFieldsMenu = ({ const dispatch = useDispatch() const translate = useTranslate() const toggleableColumns = useSelector( - (state) => state.settings.toggleableFields[resource], + (state) => state.settings.toggleableFields[resource] ) const omittedColumns = useSelector((state) => state.settings.omittedFields[resource]) || [] @@ -58,7 +58,7 @@ export const ToggleFieldsMenu = ({ ...toggleableColumns, [selectedColumn]: !toggleableColumns[selectedColumn], }, - }), + }) ) } @@ -95,7 +95,7 @@ export const ToggleFieldsMenu = ({ <Checkbox checked={val} /> {translate(`resources.${resource}.fields.${key}`)} </MenuItem> - ) : null, + ) : null )} </div> </div> diff --git a/ui/src/common/Writable.js b/ui/src/common/Writable.js new file mode 100644 index 000000000..27a46f9e0 --- /dev/null +++ b/ui/src/common/Writable.js @@ -0,0 +1,27 @@ +import { cloneElement, Children, isValidElement } from 'react' + +export const isWritable = (ownerId) => { + return ( + localStorage.getItem('userId') === ownerId || + localStorage.getItem('role') === 'admin' + ) +} + +export const isReadOnly = (ownerId) => { + return !isWritable(ownerId) +} + +export const Writable = (props) => { + const { record = {}, children } = props + if (isWritable(record.ownerId)) { + return Children.map(children, (child) => + isValidElement(child) ? cloneElement(child, props) : child + ) + } + return null +} + +export const isSmartPlaylist = (pls) => !!pls.rules + +export const canChangeTracks = (pls) => + isWritable(pls.ownerId) && !isSmartPlaylist(pls) diff --git a/ui/src/common/Writable.jsx b/ui/src/common/Writable.jsx deleted file mode 100644 index 49c1920bb..000000000 --- a/ui/src/common/Writable.jsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Children, cloneElement, isValidElement } from 'react' -import { isWritable } from './playlistUtils.js' - -export const Writable = (props) => { - const { record = {}, children } = props - if (isWritable(record.ownerId)) { - return Children.map(children, (child) => - isValidElement(child) ? cloneElement(child, props) : child, - ) - } - return null -} diff --git a/ui/src/common/artist.js b/ui/src/common/artist.js deleted file mode 100644 index 370700160..000000000 --- a/ui/src/common/artist.js +++ /dev/null @@ -1,6 +0,0 @@ -// Size of an artist's downloadable album-artist content, or undefined when there -// is nothing to download (a missing artist, or no album-artist songs). Download -// and Share only cover album-artist songs, so callers gate on this, not the -// role-inclusive total. -export const artistDownloadSize = (record) => - record?.missing ? undefined : record?.stats?.albumartist?.size diff --git a/ui/src/common/artist.test.js b/ui/src/common/artist.test.js deleted file mode 100644 index eff9935a6..000000000 --- a/ui/src/common/artist.test.js +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { artistDownloadSize } from './artist' - -describe('artistDownloadSize', () => { - it('returns the album-artist size', () => { - expect( - artistDownloadSize({ stats: { albumartist: { size: 1024 } } }), - ).toEqual(1024) - }) - - it('returns undefined for a missing artist', () => { - expect( - artistDownloadSize({ - missing: true, - stats: { albumartist: { size: 1024 } }, - }), - ).toBeUndefined() - }) - - it('returns undefined when there is no album-artist content', () => { - expect(artistDownloadSize({ stats: {} })).toBeUndefined() - expect(artistDownloadSize(undefined)).toBeUndefined() - }) -}) diff --git a/ui/src/common/formatRange.js b/ui/src/common/formatRange.js deleted file mode 100644 index 22e413f27..000000000 --- a/ui/src/common/formatRange.js +++ /dev/null @@ -1,13 +0,0 @@ -export const formatRange = (record, source) => { - const nameCapitalized = source.charAt(0).toUpperCase() + source.slice(1) - const min = record[`min${nameCapitalized}`] - const max = record[`max${nameCapitalized}`] - let range = [] - if (min) { - range.push(min) - } - if (max && max !== min) { - range.push(max) - } - return range.join('-') -} diff --git a/ui/src/common/index.js b/ui/src/common/index.js index fb8f40f00..c9d139ef5 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -1,9 +1,7 @@ export * from './AddToPlaylistButton' -export * from './artist' export * from './ArtistLinkField' export * from './BatchPlayButton' export * from './BitrateField' -export * from './CollapsibleComment' export * from './ContextMenus' export * from './DateField' export * from './DocLink' @@ -11,11 +9,10 @@ export * from './DurationField' export * from './List' export * from './MultiLineTextField' export * from './Pagination' -export * from './perPageStore' export * from './PlayButton' export * from './QuickFilter' export * from './RangeField' -export * from './ReadOnlyFields' +export * from './RangeDoubleField' export * from './ShuffleAllButton' export * from './SimpleList' export * from './SizeField' @@ -27,29 +24,15 @@ export * from './LoveButton' export * from './Title' export * from './SongBulkActions' export * from './useAlbumsPerPage' -export * from './useGetHandleArtistClick' export * from './useInterval' export * from './useResourceRefresh' -export * from './useScrollRestoration' -export * from './useRefreshOnEvents' export * from './useToggleLove' export * from './useTraceUpdate' export * from './Writable' export * from './SongSimpleList' +export * from './ArtistSimpleList' export * from './RatingField' -export * from './RefreshMetadataButton' -export * from './useRefreshMetadata' export * from './useRating' export * from './useSelectedFields' export * from './ToggleFieldsMenu' export * from './QualityInfo' -export * from './formatRange.js' -export * from './playlistUtils.js' -export * from './PathField.jsx' -export * from './ParticipantsInfo' -export * from './OverflowTooltip' -export * from './useSearchRefocus' -export * from './ImageUploadOverlay' -export * from './ArtworkAvatar' -export * from './useImageLoadingState' -export * from './useImageUrl' diff --git a/ui/src/common/perPageStore.js b/ui/src/common/perPageStore.js deleted file mode 100644 index 52a8a8f29..000000000 --- a/ui/src/common/perPageStore.js +++ /dev/null @@ -1,11 +0,0 @@ -export const defaultRowsPerPageOptions = [15, 25, 50] - -const key = (resource) => `perPage.${resource}` - -export const getStoredPerPage = (resource, options, fallback = options[0]) => { - const stored = parseInt(localStorage.getItem(key(resource)), 10) - return options.includes(stored) ? stored : fallback -} - -export const setStoredPerPage = (resource, perPage) => - localStorage.setItem(key(resource), String(perPage)) diff --git a/ui/src/common/perPageStore.test.js b/ui/src/common/perPageStore.test.js deleted file mode 100644 index 3aeba3ad8..000000000 --- a/ui/src/common/perPageStore.test.js +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { getStoredPerPage, setStoredPerPage } from './perPageStore' - -const options = [15, 25, 50] - -describe('perPageStore', () => { - beforeEach(() => { - localStorage.clear() - }) - - it('round-trips a stored value', () => { - setStoredPerPage('song', 25) - expect(getStoredPerPage('song', options, 15)).toEqual(25) - }) - - it('keys values per resource', () => { - setStoredPerPage('song', 25) - setStoredPerPage('playlist', 50) - expect(getStoredPerPage('song', options, 15)).toEqual(25) - expect(getStoredPerPage('playlist', options, 15)).toEqual(50) - }) - - it('returns the fallback when nothing is stored', () => { - expect(getStoredPerPage('song', options, 15)).toEqual(15) - }) - - it('returns the fallback for garbage values', () => { - localStorage.setItem('perPage.song', 'bogus') - expect(getStoredPerPage('song', options, 15)).toEqual(15) - }) - - it('returns the fallback when the stored value is not a valid option', () => { - setStoredPerPage('album', 90) - expect(getStoredPerPage('album', [18, 36, 72], 18)).toEqual(18) - }) - - it('defaults the fallback to the first option', () => { - expect(getStoredPerPage('song', options)).toEqual(15) - }) -}) diff --git a/ui/src/common/playbackActions.js b/ui/src/common/playbackActions.js deleted file mode 100644 index 414dbe413..000000000 --- a/ui/src/common/playbackActions.js +++ /dev/null @@ -1,76 +0,0 @@ -import subsonic from '../subsonic/index.js' -import { playTracks } from '../actions/index.js' - -const shuffleArray = (array) => { - const shuffled = [...array] - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)) - ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]] - } - return shuffled -} - -const mapReplayGain = (song) => { - const { replayGain: rg } = song - if (!rg) { - return song - } - - return { - ...song, - ...(rg.albumGain !== undefined && { rgAlbumGain: rg.albumGain }), - ...(rg.albumPeak !== undefined && { rgAlbumPeak: rg.albumPeak }), - ...(rg.trackGain !== undefined && { rgTrackGain: rg.trackGain }), - ...(rg.trackPeak !== undefined && { rgTrackPeak: rg.trackPeak }), - } -} - -export const processSongsForPlayback = (songs) => { - const songData = {} - const ids = [] - songs.forEach((s) => { - const song = mapReplayGain(s) - songData[song.id] = song - ids.push(song.id) - }) - return { songData, ids } -} - -export const playSimilar = async (dispatch, notify, id, options = {}) => { - const { seedRecord = null, shuffle = false } = options - - const res = await subsonic.getSimilarSongs2(id, 100) - const data = res.json['subsonic-response'] - - if (data.status !== 'ok') { - throw new Error( - `Error fetching similar songs: ${data.error?.message || 'Unknown error'} (Code: ${data.error?.code || 'unknown'})`, - ) - } - - let songs = data.similarSongs2?.song || [] - - // Randomize similar songs if requested - if (shuffle) { - songs = shuffleArray(songs) - } - - // If no similar songs found and no seed, show warning - if (!songs.length && !seedRecord) { - notify('message.noSimilarSongsFound', 'warning') - return - } - - const { songData, ids } = processSongsForPlayback(songs) - - // Prepend seed song if provided - if (seedRecord) { - const seedId = seedRecord.mediaFileId || seedRecord.id - // Remove seed from similar songs if it appears there - const filteredIds = ids.filter((songId) => songId !== seedId) - songData[seedId] = mapReplayGain(seedRecord) - dispatch(playTracks(songData, [seedId, ...filteredIds])) - } else { - dispatch(playTracks(songData, ids)) - } -} diff --git a/ui/src/common/playlistUtils.js b/ui/src/common/playlistUtils.js deleted file mode 100644 index 2d0c745ff..000000000 --- a/ui/src/common/playlistUtils.js +++ /dev/null @@ -1,15 +0,0 @@ -export const isWritable = (ownerId) => { - return ( - localStorage.getItem('userId') === ownerId || - localStorage.getItem('role') === 'admin' - ) -} - -export const isReadOnly = (ownerId) => { - return !isWritable(ownerId) -} - -export const isSmartPlaylist = (pls) => !!pls.rules - -export const canChangeTracks = (pls) => - isWritable(pls.ownerId) && !isSmartPlaylist(pls) && !pls.sync diff --git a/ui/src/common/playlistUtils.test.js b/ui/src/common/playlistUtils.test.js deleted file mode 100644 index 345b6045d..000000000 --- a/ui/src/common/playlistUtils.test.js +++ /dev/null @@ -1,84 +0,0 @@ -import { - isWritable, - isReadOnly, - isSmartPlaylist, - canChangeTracks, -} from './playlistUtils' - -describe('playlistUtils', () => { - beforeEach(() => { - localStorage.clear() - }) - - describe('isWritable', () => { - it('returns true if user is the owner', () => { - localStorage.setItem('userId', 'user1') - expect(isWritable('user1')).toBe(true) - }) - - it('returns true if user is an admin', () => { - localStorage.setItem('role', 'admin') - expect(isWritable('user1')).toBe(true) - }) - - it('returns false if user is not the owner and not an admin', () => { - localStorage.setItem('userId', 'user2') - expect(isWritable('user1')).toBe(false) - }) - }) - - describe('isReadOnly', () => { - it('returns true if user is not the owner and not an admin', () => { - localStorage.setItem('userId', 'user2') - expect(isReadOnly('user1')).toBe(true) - }) - - it('returns false if user is the owner', () => { - localStorage.setItem('userId', 'user1') - expect(isReadOnly('user1')).toBe(false) - }) - - it('returns false if user is an admin', () => { - localStorage.setItem('role', 'admin') - expect(isReadOnly('user1')).toBe(false) - }) - }) - - describe('isSmartPlaylist', () => { - it('returns true if playlist has rules', () => { - const playlist = { rules: [] } - expect(isSmartPlaylist(playlist)).toBe(true) - }) - - it('returns false if playlist does not have rules', () => { - const playlist = {} - expect(isSmartPlaylist(playlist)).toBe(false) - }) - }) - - describe('canChangeTracks', () => { - it('returns true if user is the owner and playlist is not smart', () => { - localStorage.setItem('userId', 'user1') - const playlist = { ownerId: 'user1' } - expect(canChangeTracks(playlist)).toBe(true) - }) - - it('returns false if user is not the owner', () => { - localStorage.setItem('userId', 'user2') - const playlist = { ownerId: 'user1' } - expect(canChangeTracks(playlist)).toBe(false) - }) - - it('returns false if playlist is smart', () => { - localStorage.setItem('userId', 'user1') - const playlist = { ownerId: 'user1', rules: [] } - expect(canChangeTracks(playlist)).toBe(false) - }) - - it('returns false if playlist is synced', () => { - localStorage.setItem('userId', 'user1') - const playlist = { ownerId: 'user1', sync: true } - expect(canChangeTracks(playlist)).toBe(false) - }) - }) -}) diff --git a/ui/src/common/sanitizeFieldRestProps.jsx b/ui/src/common/sanitizeFieldRestProps.js similarity index 100% rename from ui/src/common/sanitizeFieldRestProps.jsx rename to ui/src/common/sanitizeFieldRestProps.js diff --git a/ui/src/common/useAlbumsPerPage.js b/ui/src/common/useAlbumsPerPage.js new file mode 100644 index 000000000..6d3edf046 --- /dev/null +++ b/ui/src/common/useAlbumsPerPage.js @@ -0,0 +1,26 @@ +import { useSelector } from 'react-redux' + +const getPerPage = (width) => { + if (width === 'xs') return 12 + if (width === 'sm') return 12 + if (width === 'md') return 12 + if (width === 'lg') return 18 + return 36 +} + +const getPerPageOptions = (width) => { + const options = [3, 6, 12] + if (width === 'xs') return [12] + if (width === 'sm') return [12] + if (width === 'md') return options.map((v) => v * 4) + return options.map((v) => v * 6) +} + +export const useAlbumsPerPage = (width) => { + const perPage = + useSelector( + (state) => state?.admin.resources?.album?.list?.params?.perPage + ) || getPerPage(width) + + return [perPage, getPerPageOptions(width)] +} diff --git a/ui/src/common/useAlbumsPerPage.jsx b/ui/src/common/useAlbumsPerPage.jsx deleted file mode 100644 index 0fb5616c3..000000000 --- a/ui/src/common/useAlbumsPerPage.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useSelector } from 'react-redux' -import { getStoredPerPage } from './perPageStore' - -const getPerPage = (width) => { - if (width === 'xs') return 12 - if (width === 'sm') return 12 - if (width === 'md') return 12 - if (width === 'lg') return 18 - return 36 -} - -const getPerPageOptions = (width) => { - const options = [3, 6, 12] - if (width === 'xs') return [12] - if (width === 'sm') return [12] - if (width === 'md') return options.map((v) => v * 4) - return options.map((v) => v * 6) -} - -export const useAlbumsPerPage = (width) => { - const options = getPerPageOptions(width) - const sessionPerPage = useSelector( - (state) => state?.admin.resources?.album?.list?.params?.perPage, - ) - // Use the session value only when it's valid for the current width, so a - // size picked at a wider breakpoint can't leave an out-of-range selector. - const perPage = options.includes(sessionPerPage) - ? sessionPerPage - : getStoredPerPage('album', options, getPerPage(width)) - - return [perPage, options] -} diff --git a/ui/src/common/useAlbumsPerPage.test.jsx b/ui/src/common/useAlbumsPerPage.test.jsx deleted file mode 100644 index b194a6ef2..000000000 --- a/ui/src/common/useAlbumsPerPage.test.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { useAlbumsPerPage } from './useAlbumsPerPage' -import { setStoredPerPage } from './perPageStore' - -vi.mock('react-redux', () => ({ - useSelector: vi.fn(), -})) - -describe('useAlbumsPerPage', () => { - let mockUseSelector - - beforeEach(async () => { - vi.clearAllMocks() - localStorage.clear() - const { useSelector } = await import('react-redux') - mockUseSelector = vi.mocked(useSelector) - }) - - const setReduxPerPage = (value) => - mockUseSelector.mockImplementation((selector) => - selector({ - admin: { - resources: { album: { list: { params: { perPage: value } } } }, - }, - }), - ) - - it('prefers the redux session value over the stored one', () => { - setReduxPerPage(36) - setStoredPerPage('album', 72) - const { result } = renderHook(() => useAlbumsPerPage('lg')) - expect(result.current[0]).toEqual(36) - }) - - it('falls back to the stored value on fresh load', () => { - setReduxPerPage(undefined) - setStoredPerPage('album', 72) - const { result } = renderHook(() => useAlbumsPerPage('lg')) - expect(result.current[0]).toEqual(72) - }) - - it('ignores stored values invalid for the current width', () => { - setReduxPerPage(undefined) - setStoredPerPage('album', 72) // valid for lg, not for md - const { result } = renderHook(() => useAlbumsPerPage('md')) - expect(result.current[0]).toEqual(12) - }) - - it('returns the responsive default when nothing is stored', () => { - setReduxPerPage(undefined) - const { result } = renderHook(() => useAlbumsPerPage('xl')) - expect(result.current).toEqual([36, [18, 36, 72]]) - }) - - it('ignores a redux value invalid for the current width', () => { - setReduxPerPage(72) // valid for lg, not for md - const { result } = renderHook(() => useAlbumsPerPage('md')) - expect(result.current[0]).toEqual(12) - }) -}) diff --git a/ui/src/common/useGetHandleArtistClick.jsx b/ui/src/common/useGetHandleArtistClick.jsx deleted file mode 100644 index 0fcf2ee36..000000000 --- a/ui/src/common/useGetHandleArtistClick.jsx +++ /dev/null @@ -1,11 +0,0 @@ -import { useAlbumsPerPage } from './useAlbumsPerPage' -import config from '../config.js' - -export const useGetHandleArtistClick = (width) => { - const [perPage] = useAlbumsPerPage(width) - return (id) => { - return config.devShowArtistPage && id !== config.variousArtistsId - ? `/artist/${id}/show` - : `/album?filter={"artist_id":"${id}"}&order=ASC&sort=max_year&displayedFilters={"compilation":true}&perPage=${perPage}` - } -} diff --git a/ui/src/common/useImageLoadingState.js b/ui/src/common/useImageLoadingState.js deleted file mode 100644 index 3528b0f3f..000000000 --- a/ui/src/common/useImageLoadingState.js +++ /dev/null @@ -1,44 +0,0 @@ -import { useState, useEffect, useCallback } from 'react' - -/** - * Manages image loading/error state and lightbox open/close. - * Resets when recordId changes. - */ -export const useImageLoadingState = (recordId) => { - const [imageLoading, setImageLoading] = useState(true) - const [imageError, setImageError] = useState(false) - const [isLightboxOpen, setLightboxOpen] = useState(false) - - useEffect(() => { - setImageLoading(true) - setImageError(false) - }, [recordId]) - - const handleImageLoad = useCallback(() => { - setImageLoading(false) - setImageError(false) - }, []) - - const handleImageError = useCallback(() => { - setImageLoading(false) - setImageError(true) - }, []) - - const handleOpenLightbox = useCallback(() => { - if (!imageError) { - setLightboxOpen(true) - } - }, [imageError]) - - const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) - - return { - imageLoading, - imageError, - isLightboxOpen, - handleImageLoad, - handleImageError, - handleOpenLightbox, - handleCloseLightbox, - } -} diff --git a/ui/src/common/useImageUrl.js b/ui/src/common/useImageUrl.js deleted file mode 100644 index 8a7f2bcc7..000000000 --- a/ui/src/common/useImageUrl.js +++ /dev/null @@ -1,156 +0,0 @@ -import { useEffect, useState, useRef } from 'react' - -// Persists across component mount/unmount cycles so that -// React Admin refreshes (which remount list items) don't re-fetch images. -const cache = new Map() -const MAX_CACHE_SIZE = 300 - -// Limit concurrent fetches to leave browser connections free for API requests. -// Browsers allow ~6 connections per origin on HTTP/1.1; reserving 2 for API -// calls prevents image fetches from blocking pagination/data requests. -const MAX_CONCURRENT = 4 -let activeFetches = 0 -const pendingQueue = [] - -const processQueue = () => { - while (pendingQueue.length > 0 && activeFetches < MAX_CONCURRENT) { - const next = pendingQueue.shift() - next() - } -} - -// Evicts oldest unused entries (Map iterates in insertion order). -const evictIfNeeded = () => { - if (cache.size <= MAX_CACHE_SIZE) return - for (const [key, entry] of cache) { - if (cache.size <= MAX_CACHE_SIZE) break - if (entry.refCount === 0) { - if (entry.blobUrl) URL.revokeObjectURL(entry.blobUrl) - cache.delete(key) - } - } -} - -/** - * Loads an image via fetch() with AbortController so that in-flight requests - * are canceled on unmount (e.g., during pagination). Uses a module-level cache - * so remounting returns the cached blob URL instantly. - */ -export const useImageUrl = (url) => { - const cached = url ? cache.get(url) : null - const [imgUrl, setImgUrl] = useState(cached?.blobUrl || null) - const [loading, setLoading] = useState(!!url && !cached) - const [error, setError] = useState(cached?.error || false) - const abortedRef = useRef(false) - - // Resync during render: otherwise imgUrl holds the previous url's blob for one render, so only - // fromCache can answer "already cached" - and a remembered failure has no blob to paint. - const [fromCache, setFromCache] = useState(!!cached?.blobUrl) - const [trackedUrl, setTrackedUrl] = useState(url) - if (trackedUrl !== url) { - setTrackedUrl(url) - setImgUrl(cached?.blobUrl || null) - setLoading(!!url && !cached) - setError(cached?.error || false) - setFromCache(!!cached?.blobUrl) - } - - useEffect(() => { - abortedRef.current = false - - if (!url) { - setImgUrl(null) - setLoading(false) - setError(false) - return - } - - // Re-check: another component's effect may have populated the cache - // between this component's render and effect execution. - const entry = cache.get(url) - if (entry) { - entry.refCount++ - setImgUrl(entry.blobUrl) - setLoading(false) - setError(entry.error || false) - return () => { - entry.refCount-- - } - } - - const controller = new AbortController() - let queued = true - setImgUrl(null) - setLoading(true) - setError(false) - - const doFetch = () => { - queued = false - activeFetches++ - fetch(url, { signal: controller.signal }) - .then((res) => { - if (!res.ok) { - throw new Error(`HTTP ${res.status}`) - } - return res.blob() - }) - .then((blob) => { - activeFetches-- - processQueue() - // Guard against late resolution after abort - if (abortedRef.current) { - return - } - const objectUrl = URL.createObjectURL(blob) - // Handle concurrent fetches: if another component already cached - // this URL, use its entry and discard our blob. - const existing = cache.get(url) - if (existing && existing.blobUrl) { - existing.refCount++ - URL.revokeObjectURL(objectUrl) - setImgUrl(existing.blobUrl) - } else { - cache.set(url, { blobUrl: objectUrl, refCount: 1 }) - evictIfNeeded() - setImgUrl(objectUrl) - } - setLoading(false) - }) - .catch((err) => { - activeFetches-- - processQueue() - if (err.name === 'AbortError') { - return // Expected on unmount or URL change - } - // Cache the error so repeated mounts don't re-fetch broken URLs - cache.set(url, { blobUrl: null, error: true, refCount: 0 }) - setError(true) - setLoading(false) - }) - } - - if (activeFetches < MAX_CONCURRENT) { - queued = false - doFetch() - } else { - pendingQueue.push(doFetch) - } - - return () => { - abortedRef.current = true - if (queued) { - // Remove from queue if not yet started - const idx = pendingQueue.indexOf(doFetch) - if (idx !== -1) pendingQueue.splice(idx, 1) - } else { - controller.abort() - } - const entry = cache.get(url) - if (entry) { - entry.refCount-- - } - } - }, [url]) - - return { imgUrl, loading, error, fromCache } -} diff --git a/ui/src/common/useImageUrl.test.js b/ui/src/common/useImageUrl.test.js deleted file mode 100644 index 818732d06..000000000 --- a/ui/src/common/useImageUrl.test.js +++ /dev/null @@ -1,237 +0,0 @@ -import { renderHook, act } from '@testing-library/react-hooks' -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' - -// Helper to flush all pending promises -const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)) - -// We need a fresh module for each test to reset the module-level cache -let useImageUrl - -describe('useImageUrl', () => { - let abortSpy - let OriginalAbortController - let originalCreateObjectURL - let originalRevokeObjectURL - let originalFetch - - beforeEach(async () => { - // Reset module to clear the cache - vi.resetModules() - const mod = await import('./useImageUrl') - useImageUrl = mod.useImageUrl - - abortSpy = vi.fn() - OriginalAbortController = global.AbortController - originalCreateObjectURL = global.URL.createObjectURL - originalRevokeObjectURL = global.URL.revokeObjectURL - originalFetch = global.fetch - - global.AbortController = function () { - this.signal = 'mock-signal' - this.abort = abortSpy - } - global.URL.createObjectURL = vi.fn(() => 'blob:mock-url') - global.URL.revokeObjectURL = vi.fn() - }) - - afterEach(() => { - global.AbortController = OriginalAbortController - global.URL.createObjectURL = originalCreateObjectURL - global.URL.revokeObjectURL = originalRevokeObjectURL - global.fetch = originalFetch - vi.restoreAllMocks() - }) - - it('should return null values when url is null', () => { - const { result } = renderHook(() => useImageUrl(null)) - - expect(result.current.loading).toBe(false) - expect(result.current.imgUrl).toBeNull() - expect(result.current.error).toBe(false) - }) - - it('should return loading state initially', () => { - global.fetch = vi.fn(() => new Promise(() => {})) - const { result } = renderHook(() => - useImageUrl('http://example.com/img.jpg'), - ) - - expect(result.current.loading).toBe(true) - expect(result.current.imgUrl).toBeNull() - expect(result.current.error).toBe(false) - }) - - it('should fetch image and return blob URL on success', async () => { - const mockBlob = new Blob(['image-data'], { type: 'image/png' }) - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - blob: () => Promise.resolve(mockBlob), - }), - ) - - const { result } = renderHook(() => - useImageUrl('http://example.com/img.jpg'), - ) - - await act(async () => { - await flushPromises() - }) - - expect(result.current.loading).toBe(false) - expect(result.current.imgUrl).toBe('blob:mock-url') - expect(result.current.error).toBe(false) - expect(global.fetch).toHaveBeenCalledWith('http://example.com/img.jpg', { - signal: 'mock-signal', - }) - }) - - it('should set error on HTTP failure', async () => { - global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 })) - - const { result } = renderHook(() => - useImageUrl('http://example.com/missing.jpg'), - ) - - await act(async () => { - await flushPromises() - }) - - expect(result.current.loading).toBe(false) - expect(result.current.imgUrl).toBeNull() - expect(result.current.error).toBe(true) - }) - - it('should abort fetch on unmount', async () => { - global.fetch = vi.fn(() => new Promise(() => {})) - - const { unmount } = renderHook(() => - useImageUrl('http://example.com/img.jpg'), - ) - - await act(async () => { - await flushPromises() - }) - - unmount() - - expect(abortSpy).toHaveBeenCalled() - }) - - it('should abort previous fetch when URL changes', async () => { - const abortSpies = [] - global.AbortController = function () { - const spy = vi.fn() - abortSpies.push(spy) - this.signal = `signal-${abortSpies.length}` - this.abort = spy - } - - const mockBlob = new Blob(['data'], { type: 'image/png' }) - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - blob: () => Promise.resolve(mockBlob), - }), - ) - - const { rerender } = renderHook(({ url }) => useImageUrl(url), { - initialProps: { url: 'http://example.com/img1.jpg' }, - }) - - await act(async () => { - await flushPromises() - }) - - // Change URL - should abort the first controller - rerender({ url: 'http://example.com/img2.jpg' }) - - expect(abortSpies[0]).toHaveBeenCalled() - }) - - it('should not set error on AbortError', async () => { - const abortError = new DOMException('Aborted', 'AbortError') - global.fetch = vi.fn(() => Promise.reject(abortError)) - - const { result } = renderHook(() => - useImageUrl('http://example.com/img.jpg'), - ) - - await act(async () => { - await flushPromises() - }) - - expect(result.current.error).toBe(false) - }) - - it('should use cached blob URL on remount without re-fetching', async () => { - const mockBlob = new Blob(['data'], { type: 'image/png' }) - global.fetch = vi.fn(() => - Promise.resolve({ - ok: true, - blob: () => Promise.resolve(mockBlob), - }), - ) - - // First mount — fetches and caches - const { unmount } = renderHook(() => - useImageUrl('http://example.com/img.jpg'), - ) - - await act(async () => { - await flushPromises() - }) - - expect(global.fetch).toHaveBeenCalledTimes(1) - - // Unmount (simulates React Admin refresh) - unmount() - - // Remount with same URL — should use cache - const { result: result2 } = renderHook(() => - useImageUrl('http://example.com/img.jpg'), - ) - - await act(async () => { - await flushPromises() - }) - - // Should NOT have fetched again - expect(global.fetch).toHaveBeenCalledTimes(1) - expect(result2.current.imgUrl).toBe('blob:mock-url') - expect(result2.current.loading).toBe(false) - expect(result2.current.fromCache).toBe(true) - }) - - it('should cache errors and not re-fetch broken URLs', async () => { - global.fetch = vi.fn(() => Promise.resolve({ ok: false, status: 404 })) - - // First mount — fetch fails and error is cached - const { unmount } = renderHook(() => - useImageUrl('http://example.com/broken.jpg'), - ) - - await act(async () => { - await flushPromises() - }) - - expect(global.fetch).toHaveBeenCalledTimes(1) - unmount() - - // Remount with same URL — should use cached error, not re-fetch - const { result: result2 } = renderHook(() => - useImageUrl('http://example.com/broken.jpg'), - ) - - await act(async () => { - await flushPromises() - }) - - expect(global.fetch).toHaveBeenCalledTimes(1) - expect(result2.current.error).toBe(true) - expect(result2.current.imgUrl).toBeNull() - expect(result2.current.loading).toBe(false) - // A remembered failure has no blob, so callers must not treat it as instantly painted. - expect(result2.current.fromCache).toBe(false) - }) -}) diff --git a/ui/src/common/useInterval.jsx b/ui/src/common/useInterval.js similarity index 100% rename from ui/src/common/useInterval.jsx rename to ui/src/common/useInterval.js diff --git a/ui/src/common/useLibrarySelection.js b/ui/src/common/useLibrarySelection.js deleted file mode 100644 index c5d84a61f..000000000 --- a/ui/src/common/useLibrarySelection.js +++ /dev/null @@ -1,44 +0,0 @@ -import { useSelector } from 'react-redux' - -/** - * Hook to get the currently selected library IDs - * Returns an array of library IDs that should be used for filtering data - * If no libraries are selected (empty array), returns all user accessible libraries - */ -export const useSelectedLibraries = () => { - const { userLibraries, selectedLibraries } = useSelector( - (state) => state.library, - ) - - // If no specific selection, default to all accessible libraries - if (selectedLibraries.length === 0 && userLibraries.length > 0) { - return userLibraries.map((lib) => lib.id) - } - - return selectedLibraries -} - -/** - * Hook to get library filter parameters for data provider queries - * Returns an object that can be spread into query parameters - */ -export const useLibraryFilter = () => { - const selectedLibraryIds = useSelectedLibraries() - - // If user has access to only one library or no specific selection, no filter needed - if (selectedLibraryIds.length <= 1) { - return {} - } - - return { - libraryIds: selectedLibraryIds, - } -} - -/** - * Hook to check if a specific library is currently selected - */ -export const useIsLibrarySelected = (libraryId) => { - const selectedLibraryIds = useSelectedLibraries() - return selectedLibraryIds.includes(libraryId) -} diff --git a/ui/src/common/useLibrarySelection.test.js b/ui/src/common/useLibrarySelection.test.js deleted file mode 100644 index 30f109dc6..000000000 --- a/ui/src/common/useLibrarySelection.test.js +++ /dev/null @@ -1,204 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { - useSelectedLibraries, - useLibraryFilter, - useIsLibrarySelected, -} from './useLibrarySelection' - -// Mock dependencies -vi.mock('react-redux', () => ({ - useSelector: vi.fn(), -})) - -describe('Library Selection Hooks', () => { - const mockLibraries = [ - { id: '1', name: 'Music Library' }, - { id: '2', name: 'Podcasts' }, - { id: '3', name: 'Audiobooks' }, - ] - - let mockUseSelector - - beforeEach(async () => { - vi.clearAllMocks() - const { useSelector } = await import('react-redux') - mockUseSelector = vi.mocked(useSelector) - }) - - const setupSelector = ( - userLibraries = mockLibraries, - selectedLibraries = [], - ) => { - mockUseSelector.mockImplementation((selector) => - selector({ - library: { - userLibraries, - selectedLibraries, - }, - }), - ) - } - - describe('useSelectedLibraries', () => { - it('should return selected library IDs when libraries are explicitly selected', async () => { - setupSelector(mockLibraries, ['1', '2']) - - const { result } = renderHook(() => useSelectedLibraries()) - - expect(result.current).toEqual(['1', '2']) - }) - - it('should return all user library IDs when no libraries are selected and user has libraries', async () => { - setupSelector(mockLibraries, []) - - const { result } = renderHook(() => useSelectedLibraries()) - - expect(result.current).toEqual(['1', '2', '3']) - }) - - it('should return empty array when no libraries are selected and user has no libraries', async () => { - setupSelector([], []) - - const { result } = renderHook(() => useSelectedLibraries()) - - expect(result.current).toEqual([]) - }) - - it('should return selected libraries even if they are all user libraries', async () => { - setupSelector(mockLibraries, ['1', '2', '3']) - - const { result } = renderHook(() => useSelectedLibraries()) - - expect(result.current).toEqual(['1', '2', '3']) - }) - - it('should return single selected library', async () => { - setupSelector(mockLibraries, ['2']) - - const { result } = renderHook(() => useSelectedLibraries()) - - expect(result.current).toEqual(['2']) - }) - }) - - describe('useLibraryFilter', () => { - it('should return empty object when user has only one library', async () => { - setupSelector([mockLibraries[0]], ['1']) - - const { result } = renderHook(() => useLibraryFilter()) - - expect(result.current).toEqual({}) - }) - - it('should return empty object when no libraries are selected (defaults to all)', async () => { - setupSelector([mockLibraries[0]], []) - - const { result } = renderHook(() => useLibraryFilter()) - - expect(result.current).toEqual({}) - }) - - it('should return libraryIds filter when multiple libraries are available and some are selected', async () => { - setupSelector(mockLibraries, ['1', '2']) - - const { result } = renderHook(() => useLibraryFilter()) - - expect(result.current).toEqual({ - libraryIds: ['1', '2'], - }) - }) - - it('should return libraryIds filter when multiple libraries are available and all are selected', async () => { - setupSelector(mockLibraries, ['1', '2', '3']) - - const { result } = renderHook(() => useLibraryFilter()) - - expect(result.current).toEqual({ - libraryIds: ['1', '2', '3'], - }) - }) - - it('should return empty object when user has no libraries', async () => { - setupSelector([], []) - - const { result } = renderHook(() => useLibraryFilter()) - - expect(result.current).toEqual({}) - }) - - it('should return libraryIds filter for default selection when multiple libraries available', async () => { - setupSelector(mockLibraries, []) // No explicit selection, should default to all - - const { result } = renderHook(() => useLibraryFilter()) - - expect(result.current).toEqual({ - libraryIds: ['1', '2', '3'], - }) - }) - }) - - describe('useIsLibrarySelected', () => { - it('should return true when library is explicitly selected', async () => { - setupSelector(mockLibraries, ['1', '3']) - - const { result: result1 } = renderHook(() => useIsLibrarySelected('1')) - const { result: result2 } = renderHook(() => useIsLibrarySelected('3')) - - expect(result1.current).toBe(true) - expect(result2.current).toBe(true) - }) - - it('should return false when library is not explicitly selected', async () => { - setupSelector(mockLibraries, ['1', '3']) - - const { result } = renderHook(() => useIsLibrarySelected('2')) - - expect(result.current).toBe(false) - }) - - it('should return true when no explicit selection (defaults to all) and library exists', async () => { - setupSelector(mockLibraries, []) - - const { result: result1 } = renderHook(() => useIsLibrarySelected('1')) - const { result: result2 } = renderHook(() => useIsLibrarySelected('2')) - const { result: result3 } = renderHook(() => useIsLibrarySelected('3')) - - expect(result1.current).toBe(true) - expect(result2.current).toBe(true) - expect(result3.current).toBe(true) - }) - - it('should return false when library does not exist in user libraries', async () => { - setupSelector(mockLibraries, []) - - const { result } = renderHook(() => useIsLibrarySelected('999')) - - expect(result.current).toBe(false) - }) - - it('should return false when user has no libraries', async () => { - setupSelector([], []) - - const { result } = renderHook(() => useIsLibrarySelected('1')) - - expect(result.current).toBe(false) - }) - - it('should handle undefined libraryId', async () => { - setupSelector(mockLibraries, ['1']) - - const { result } = renderHook(() => useIsLibrarySelected(undefined)) - - expect(result.current).toBe(false) - }) - - it('should handle null libraryId', async () => { - setupSelector(mockLibraries, ['1']) - - const { result } = renderHook(() => useIsLibrarySelected(null)) - - expect(result.current).toBe(false) - }) - }) -}) diff --git a/ui/src/common/useRating.js b/ui/src/common/useRating.js new file mode 100644 index 000000000..43fc40579 --- /dev/null +++ b/ui/src/common/useRating.js @@ -0,0 +1,47 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import { useDataProvider, useNotify } from 'react-admin' +import subsonic from '../subsonic' + +export const useRating = (resource, record) => { + const [loading, setLoading] = useState(false) + const notify = useNotify() + const dataProvider = useDataProvider() + const mountedRef = useRef(false) + const rating = record.rating + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + const refreshRating = useCallback(() => { + dataProvider + .getOne(resource, { id: record.id }) + .then(() => { + if (mountedRef.current) { + setLoading(false) + } + }) + .catch((e) => { + console.log('Error encountered: ' + e) + }) + }, [dataProvider, record, resource]) + + const rate = (val, id) => { + setLoading(true) + subsonic + .setRating(id, val) + .then(refreshRating) + .catch((e) => { + console.log('Error setting star rating: ', e) + notify('ra.page.error', 'warning') + if (mountedRef.current) { + setLoading(false) + } + }) + } + + return [rate, rating, loading] +} diff --git a/ui/src/common/useRating.jsx b/ui/src/common/useRating.jsx deleted file mode 100644 index 82e9bfc06..000000000 --- a/ui/src/common/useRating.jsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useState, useCallback, useEffect, useRef } from 'react' -import { useDataProvider, useNotify, useRefresh } from 'react-admin' -import subsonic from '../subsonic' - -export const useRating = (resource, record) => { - const [loading, setLoading] = useState(false) - const notify = useNotify() - const dataProvider = useDataProvider() - const refresh = useRefresh() - const mountedRef = useRef(false) - const rating = record.rating - - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - } - }, []) - - const refreshRating = useCallback(() => { - if (record.mediaFileId) { - // A playlistTrack id is a position, not a stable key: rating a song can drop it out - // of a smart playlist, and that position then holds a different track. Refetching - // the row by id would write the neighbour's data under this row, so reload the list. - dataProvider - .getOne('song', { id: record.mediaFileId }) - .catch((e) => { - // eslint-disable-next-line no-console - console.log('Error encountered: ' + e) - }) - .finally(() => { - refresh() - if (mountedRef.current) { - setLoading(false) - } - }) - } else { - // Regular song or other resource - dataProvider - .getOne(resource, { id: record.id }) - .catch((e) => { - // eslint-disable-next-line no-console - console.log('Error encountered: ' + e) - }) - .finally(() => { - if (mountedRef.current) { - setLoading(false) - } - }) - } - }, [dataProvider, record.id, record.mediaFileId, refresh, resource]) - - const rate = (val, id) => { - setLoading(true) - subsonic - .setRating(id, val) - .then(refreshRating) - .catch((e) => { - // eslint-disable-next-line no-console - console.log('Error setting star rating: ', e) - notify('ra.page.error', 'warning') - if (mountedRef.current) { - setLoading(false) - } - }) - } - - return [rate, rating, loading] -} diff --git a/ui/src/common/useRating.test.js b/ui/src/common/useRating.test.js deleted file mode 100644 index ffe9abffd..000000000 --- a/ui/src/common/useRating.test.js +++ /dev/null @@ -1,166 +0,0 @@ -import { renderHook, act } from '@testing-library/react-hooks' -import { vi, describe, it, expect, beforeEach } from 'vitest' -import { useRating } from './useRating' -import subsonic from '../subsonic' -import { useDataProvider } from 'react-admin' - -const mockRefresh = vi.fn() - -vi.mock('../subsonic', () => ({ - default: { - setRating: vi.fn(() => Promise.resolve()), - }, -})) - -vi.mock('react-admin', async () => { - const actual = await vi.importActual('react-admin') - return { - ...actual, - useDataProvider: vi.fn(), - useNotify: vi.fn(() => vi.fn()), - useRefresh: vi.fn(() => mockRefresh), - } -}) - -describe('useRating', () => { - let getOne - beforeEach(() => { - getOne = vi.fn((resource, params) => - Promise.resolve({ data: { id: params.id } }), - ) - useDataProvider.mockReturnValue({ getOne }) - vi.clearAllMocks() - }) - - it('returns rating value from record', () => { - const record = { id: 'sg-1', rating: 3 } - const { result } = renderHook(() => useRating('song', record)) - const [rate, rating, loading] = result.current - expect(rating).toBe(3) - expect(loading).toBe(false) - expect(typeof rate).toBe('function') - }) - - it('sets rating using targetId and calls setRating API', async () => { - const record = { id: 'sg-1', rating: 0 } - const { result } = renderHook(() => useRating('song', record)) - await act(async () => { - await result.current[0](4, 'sg-1') - }) - expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 4) - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) - }) - - it('handles zero rating (unrate)', async () => { - const record = { id: 'sg-1', rating: 5 } - const { result } = renderHook(() => useRating('song', record)) - await act(async () => { - await result.current[0](0, 'sg-1') - }) - expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 0) - }) - - describe('playlist track scenarios', () => { - it('refreshes the song and reloads the list for playlist tracks', async () => { - const record = { - id: '1', - mediaFileId: 'sg-1', - playlistId: 'pl-1', - rating: 2, - } - const { result } = renderHook(() => useRating('playlistTrack', record)) - await act(async () => { - await result.current[0](5, 'sg-1') - }) - - // Should rate using the media file ID - expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 5) - - // The row is a position in the playlist, so it cannot be refetched by id: - // rating can drop the track out of a smart playlist and shift every row up - expect(getOne).toHaveBeenCalledTimes(1) - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) - expect(getOne).not.toHaveBeenCalledWith( - 'playlistTrack', - expect.anything(), - ) - expect(mockRefresh).toHaveBeenCalled() - }) - - it('reloads the list even when the song refresh fails', async () => { - getOne.mockImplementation(() => Promise.reject(new Error('boom'))) - const record = { - id: '5', - mediaFileId: 'sg-10', - playlistId: 'pl-123', - rating: 1, - } - const { result } = renderHook(() => useRating('playlistTrack', record)) - await act(async () => { - await result.current[0](3, 'sg-10') - }) - - expect(subsonic.setRating).toHaveBeenCalledWith('sg-10', 3) - expect(mockRefresh).toHaveBeenCalled() - }) - - it('only refreshes original resource when no mediaFileId present', async () => { - const record = { id: 'sg-1', rating: 4 } - const { result } = renderHook(() => useRating('song', record)) - await act(async () => { - await result.current[0](2, 'sg-1') - }) - - // Should only refresh the original resource (song), without reloading the list - expect(getOne).toHaveBeenCalledTimes(1) - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) - expect(mockRefresh).not.toHaveBeenCalled() - }) - - it('does not include playlist_id filter for non-playlist resources', async () => { - const record = { id: 'sg-1', rating: 0 } - const { result } = renderHook(() => useRating('song', record)) - await act(async () => { - await result.current[0](5, 'sg-1') - }) - - // Should refresh without any filter - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) - }) - }) - - describe('component integration scenarios', () => { - it('handles mediaFileId fallback correctly for playlist tracks', async () => { - const record = { - id: 'pt-1', - mediaFileId: 'sg-1', - playlistId: 'pl-1', - rating: 0, - } - const { result } = renderHook(() => useRating('playlistTrack', record)) - - // Simulate RatingField component behavior: uses mediaFileId || record.id - const targetId = record.mediaFileId || record.id - await act(async () => { - await result.current[0](4, targetId) - }) - - expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 4) - }) - - it('handles regular song rating without mediaFileId', async () => { - const record = { id: 'sg-1', rating: 2 } - const { result } = renderHook(() => useRating('song', record)) - - // Simulate RatingField component behavior: uses mediaFileId || record.id - const targetId = record.mediaFileId || record.id - await act(async () => { - await result.current[0](5, targetId) - }) - - expect(subsonic.setRating).toHaveBeenCalledWith('sg-1', 5) - expect(getOne).toHaveBeenCalledTimes(1) - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) - }) - }) -}) diff --git a/ui/src/common/useRefreshMetadata.js b/ui/src/common/useRefreshMetadata.js deleted file mode 100644 index d34042919..000000000 --- a/ui/src/common/useRefreshMetadata.js +++ /dev/null @@ -1,16 +0,0 @@ -import { useCallback } from 'react' -import { useDataProvider, useNotify } from 'react-admin' - -export const useRefreshMetadata = () => { - const dataProvider = useDataProvider() - const notify = useNotify() - - return useCallback( - (resource, id) => - dataProvider - .refreshMetadata(resource, id) - .then(() => notify('message.metadataRefreshStarted')) - .catch(() => notify('ra.page.error', 'warning')), - [dataProvider, notify], - ) -} diff --git a/ui/src/common/useRefreshOnEvents.jsx b/ui/src/common/useRefreshOnEvents.jsx deleted file mode 100644 index b5f1b1ede..000000000 --- a/ui/src/common/useRefreshOnEvents.jsx +++ /dev/null @@ -1,109 +0,0 @@ -import { useEffect, useState } from 'react' -import { useSelector } from 'react-redux' - -/** - * A reusable hook for triggering custom reload logic when specific SSE events occur. - * - * This hook is ideal when: - * - Your component displays derived/related data that isn't directly managed by react-admin - * - You need custom loading logic that goes beyond simple dataProvider.getMany() calls - * - Your data comes from non-standard endpoints or requires special processing - * - You want to reload parent/related resources when child resources change - * - * @param {Object} options - Configuration options - * @param {Array<string>} options.events - Array of event types to listen for (e.g., ['library', 'user', '*']) - * @param {Function} options.onRefresh - Async function to call when events occur. - * Should be wrapped in useCallback with appropriate dependencies to avoid unnecessary re-renders. - * - * @example - * // Example 1: LibrarySelector - Reload user data when library changes - * const loadUserLibraries = useCallback(async () => { - * const userId = localStorage.getItem('userId') - * if (userId) { - * const { data } = await dataProvider.getOne('user', { id: userId }) - * dispatch(setUserLibraries(data.libraries || [])) - * } - * }, [dataProvider, dispatch]) - * - * useRefreshOnEvents({ - * events: ['library', 'user'], - * onRefresh: loadUserLibraries - * }) - * - * @example - * // Example 2: Statistics Dashboard - Reload stats when any music data changes - * const loadStats = useCallback(async () => { - * const stats = await dataProvider.customRequest('GET', 'stats') - * setDashboardStats(stats) - * }, [dataProvider, setDashboardStats]) - * - * useRefreshOnEvents({ - * events: ['album', 'song', 'artist'], - * onRefresh: loadStats - * }) - * - * @example - * // Example 3: Permission-based UI - Reload permissions when user changes - * const loadPermissions = useCallback(async () => { - * const authData = await authProvider.getPermissions() - * setUserPermissions(authData) - * }, [authProvider, setUserPermissions]) - * - * useRefreshOnEvents({ - * events: ['user'], - * onRefresh: loadPermissions - * }) - * - * @example - * // Example 4: Listen to all events (use sparingly) - * const reloadAll = useCallback(async () => { - * // This will trigger on ANY refresh event - * await reloadEverything() - * }, [reloadEverything]) - * - * useRefreshOnEvents({ - * events: ['*'], - * onRefresh: reloadAll - * }) - */ -export const useRefreshOnEvents = ({ events, onRefresh }) => { - const [lastRefreshTime, setLastRefreshTime] = useState(Date.now()) - - const refreshData = useSelector( - (state) => state.activity?.refresh || { lastReceived: lastRefreshTime }, - ) - - useEffect(() => { - const { resources, lastReceived } = refreshData - - // Only process if we have new events - if (lastReceived <= lastRefreshTime) { - return - } - - // Check if any of the events we're interested in occurred - const shouldRefresh = - resources && - // Global refresh event - (resources['*'] === '*' || - // Check for specific events we're listening to - events.some((eventType) => { - if (eventType === '*') { - return true // Listen to all events - } - return resources[eventType] // Check if this specific event occurred - })) - - if (shouldRefresh) { - setLastRefreshTime(lastReceived) - - // Call the custom refresh function - if (onRefresh) { - onRefresh().catch((error) => { - // eslint-disable-next-line no-console - console.warn('Error in useRefreshOnEvents onRefresh callback:', error) - }) - } - } - }, [refreshData, lastRefreshTime, events, onRefresh]) -} diff --git a/ui/src/common/useRefreshOnEvents.test.js b/ui/src/common/useRefreshOnEvents.test.js deleted file mode 100644 index 2306cd3c9..000000000 --- a/ui/src/common/useRefreshOnEvents.test.js +++ /dev/null @@ -1,233 +0,0 @@ -import { vi } from 'vitest' -import * as React from 'react' -import * as Redux from 'react-redux' -import { useRefreshOnEvents } from './useRefreshOnEvents' - -vi.mock('react', async () => { - const actual = await vi.importActual('react') - return { - ...actual, - useState: vi.fn(), - useEffect: vi.fn(), - } -}) - -vi.mock('react-redux', async () => { - const actual = await vi.importActual('react-redux') - return { - ...actual, - useSelector: vi.fn(), - } -}) - -describe('useRefreshOnEvents', () => { - const setState = vi.fn() - const useStateMock = (initState) => [initState, setState] - const onRefresh = vi.fn().mockResolvedValue() - let lastTime - let mockUseEffect - - beforeEach(() => { - vi.spyOn(React, 'useState').mockImplementation(useStateMock) - mockUseEffect = vi.spyOn(React, 'useEffect') - lastTime = new Date(new Date().valueOf() + 1000) - onRefresh.mockClear() - setState.mockClear() - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('stores last time checked, to avoid redundant runs', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { library: ['lib-1'] }, // Need some resources to trigger the update - }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) - - // Mock useEffect to immediately call the effect callback - mockUseEffect.mockImplementation((callback) => callback()) - - useRefreshOnEvents({ - events: ['library'], - onRefresh, - }) - - expect(setState).toHaveBeenCalledWith(lastTime) - }) - - it("does not run again if lastTime didn't change", () => { - vi.spyOn(React, 'useState').mockImplementation(() => [lastTime, setState]) - const useSelectorMock = () => ({ lastReceived: lastTime }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) - - // Mock useEffect to immediately call the effect callback - mockUseEffect.mockImplementation((callback) => callback()) - - useRefreshOnEvents({ - events: ['library'], - onRefresh, - }) - - expect(setState).not.toHaveBeenCalled() - expect(onRefresh).not.toHaveBeenCalled() - }) - - describe('Event listening and refresh triggering', () => { - beforeEach(() => { - // Mock useEffect to immediately call the effect callback - mockUseEffect.mockImplementation((callback) => callback()) - }) - - it('triggers refresh when a watched event occurs', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { library: ['lib-1', 'lib-2'] }, - }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) - - useRefreshOnEvents({ - events: ['library'], - onRefresh, - }) - - expect(onRefresh).toHaveBeenCalledTimes(1) - expect(setState).toHaveBeenCalledWith(lastTime) - }) - - it('triggers refresh when multiple watched events occur', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { - library: ['lib-1'], - user: ['user-1'], - album: ['album-1'], // This shouldn't trigger since it's not watched - }, - }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) - - useRefreshOnEvents({ - events: ['library', 'user'], - onRefresh, - }) - - expect(onRefresh).toHaveBeenCalledTimes(1) - expect(setState).toHaveBeenCalledWith(lastTime) - }) - - it('does not trigger refresh when unwatched events occur', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { album: ['album-1'], song: ['song-1'] }, - }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) - - useRefreshOnEvents({ - events: ['library', 'user'], - onRefresh, - }) - - expect(onRefresh).not.toHaveBeenCalled() - expect(setState).not.toHaveBeenCalled() - }) - - it('triggers refresh on global refresh event', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { '*': '*' }, - }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) - - useRefreshOnEvents({ - events: ['library'], - onRefresh, - }) - - expect(onRefresh).toHaveBeenCalledTimes(1) - expect(setState).toHaveBeenCalledWith(lastTime) - }) - - it('triggers refresh when listening to all events with "*"', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { song: ['song-1'] }, - }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) - - useRefreshOnEvents({ - events: ['*'], - onRefresh, - }) - - expect(onRefresh).toHaveBeenCalledTimes(1) - expect(setState).toHaveBeenCalledWith(lastTime) - }) - - it('handles empty events array gracefully', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { library: ['lib-1'] }, - }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) - - useRefreshOnEvents({ - events: [], - onRefresh, - }) - - expect(onRefresh).not.toHaveBeenCalled() - expect(setState).not.toHaveBeenCalled() - }) - - it('handles missing onRefresh function gracefully', () => { - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { library: ['lib-1'] }, - }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) - - expect(() => { - useRefreshOnEvents({ - events: ['library'], - // onRefresh is undefined - }) - }).not.toThrow() - - expect(setState).toHaveBeenCalledWith(lastTime) - }) - - it('handles onRefresh errors gracefully', async () => { - const consoleWarnSpy = vi - .spyOn(console, 'warn') - .mockImplementation(() => {}) - const failingRefresh = vi - .fn() - .mockRejectedValue(new Error('Refresh failed')) - - const useSelectorMock = () => ({ - lastReceived: lastTime, - resources: { library: ['lib-1'] }, - }) - vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) - - useRefreshOnEvents({ - events: ['library'], - onRefresh: failingRefresh, - }) - - expect(failingRefresh).toHaveBeenCalledTimes(1) - expect(setState).toHaveBeenCalledWith(lastTime) - - // Wait for the promise to be rejected and handled - await new Promise((resolve) => setTimeout(resolve, 10)) - - expect(consoleWarnSpy).toHaveBeenCalledWith( - 'Error in useRefreshOnEvents onRefresh callback:', - expect.any(Error), - ) - - consoleWarnSpy.mockRestore() - }) - }) -}) diff --git a/ui/src/common/useResourceRefresh.js b/ui/src/common/useResourceRefresh.js new file mode 100644 index 000000000..4c2008089 --- /dev/null +++ b/ui/src/common/useResourceRefresh.js @@ -0,0 +1,36 @@ +import { useSelector } from 'react-redux' +import { useState } from 'react' +import { useRefresh, useDataProvider } from 'react-admin' + +export const useResourceRefresh = (...visibleResources) => { + const [lastTime, setLastTime] = useState(Date.now()) + const refresh = useRefresh() + const dataProvider = useDataProvider() + const refreshData = useSelector( + (state) => state.activity?.refresh || { lastReceived: lastTime } + ) + const { resources, lastReceived } = refreshData + + if (lastReceived <= lastTime) { + return + } + setLastTime(lastReceived) + + if ( + resources && + (resources['*'] === '*' || + Object.values(resources).find((v) => v.find((v2) => v2 === '*'))) + ) { + refresh() + return + } + if (resources) { + Object.keys(resources).forEach((r) => { + if (visibleResources.length === 0 || visibleResources?.includes(r)) { + if (resources[r]?.length > 0) { + dataProvider.getMany(r, { ids: resources[r] }) + } + } + }) + } +} diff --git a/ui/src/common/useResourceRefresh.jsx b/ui/src/common/useResourceRefresh.jsx deleted file mode 100644 index b59ae8e88..000000000 --- a/ui/src/common/useResourceRefresh.jsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useSelector } from 'react-redux' -import { useState } from 'react' -import { useRefresh, useDataProvider } from 'react-admin' - -/** - * A hook that automatically refreshes react-admin managed resources when refresh events are received via SSE. - * - * This hook is designed for components that display react-admin managed resources (like lists, shows, edits) - * and need to stay in sync when those resources are modified elsewhere in the application. - * - * **When to use this hook:** - * - Your component displays react-admin resources (albums, songs, artists, playlists, etc.) - * - You want automatic refresh when those resources are created/updated/deleted - * - Your data comes from standard dataProvider.getMany() calls - * - You're using react-admin's data management (queries, mutations, caching) - * - * **When NOT to use this hook:** - * - Your component displays derived/custom data not directly managed by react-admin - * - You need custom reload logic beyond dataProvider.getMany() - * - Your data comes from non-standard endpoints - * - Use `useRefreshOnEvents` instead for these scenarios - * - * @param {...string} visibleResources - Resource names to watch for changes. - * If no resources specified, watches all resources. - * If '*' is included in resources, triggers full page refresh. - * - * @example - * // Example 1: Album list - refresh when albums change - * const AlbumList = () => { - * useResourceRefresh('album') - * return <List resource="album">...</List> - * } - * - * @example - * // Example 2: Album show page - refresh when album or its songs change - * const AlbumShow = () => { - * useResourceRefresh('album', 'song') - * return <Show resource="album">...</Show> - * } - * - * @example - * // Example 3: Dashboard - refresh when any resource changes - * const Dashboard = () => { - * useResourceRefresh() // No parameters = watch all resources - * return <div>...</div> - * } - * - * @example - * // Example 4: Library management page - watch library resources - * const LibraryList = () => { - * useResourceRefresh('library') - * return <List resource="library">...</List> - * } - * - * **How it works:** - * - Listens to refresh events from the SSE connection - * - When events arrive, checks if they match the specified visible resources - * - For specific resource IDs: calls dataProvider.getMany(resource, {ids: [...]}) - * - For global refreshes: calls refresh() to reload the entire page - * - Uses react-admin's built-in data management and caching - * - * **Event format expected:** - * - Global refresh: { '*': '*' } or { someResource: ['*'] } - * - Specific resources: { album: ['id1', 'id2'], song: ['id3'] } - */ -// Resources whose records are media files, and so inherit their album's artwork. -const trackResources = ['song', 'playlistTrack'] - -export const useResourceRefresh = (...visibleResources) => { - const [lastTime, setLastTime] = useState(Date.now()) - const refresh = useRefresh() - const dataProvider = useDataProvider() - const refreshData = useSelector( - (state) => state.activity?.refresh || { lastReceived: lastTime }, - ) - const loadedResources = useSelector((state) => state.admin?.resources) - const { resources, lastReceived } = refreshData - - if (lastReceived <= lastTime) { - return - } - setLastTime(lastReceived) - - const isWatched = (r) => - visibleResources.length === 0 || visibleResources.includes(r) - // A wildcard on a resource this component does not show is somebody else's business: reloading - // the page for it throws away the list the user is looking at. - const hasWildcard = - resources && - (resources['*'] === '*' || - Object.entries(resources).some( - ([r, ids]) => isWatched(r) && ids.includes?.('*'), - )) - - if (hasWildcard) { - refresh() - return - } - if (!resources) { - return - } - Object.keys(resources).forEach((r) => { - if (isWatched(r)) { - if (resources[r]?.length > 0) { - // Only refetch records already in the store; ones the UI never loaded will - // arrive fresh (with the new artwork) when navigated to, so fetching them is wasteful. - const loaded = loadedResources?.[r]?.data || {} - const ids = resources[r].filter((id) => loaded[id] !== undefined) - if (ids.length > 0) { - dataProvider.getMany(r, { ids }) - } - } - } - }) - - // A track with no art of its own is served its album's, so an album's new coverArt id moves its - // tracks' too. The dependent ids are unbounded server-side, but the store knows which are loaded. - if (resources.album?.length > 0) { - const albumIds = new Set(resources.album) - trackResources.filter(isWatched).forEach((r) => { - const ids = Object.values(loadedResources?.[r]?.data || {}) - .filter((t) => albumIds.has(t?.albumId)) - .map((t) => t.id) - if (ids.length > 0) { - dataProvider.getMany(r, { ids }) - } - }) - } -} diff --git a/ui/src/common/useResourceRefresh.test.js b/ui/src/common/useResourceRefresh.test.js index ccd729836..fca8ddfcd 100644 --- a/ui/src/common/useResourceRefresh.test.js +++ b/ui/src/common/useResourceRefresh.test.js @@ -1,77 +1,47 @@ -import { vi } from 'vitest' import * as React from 'react' import * as Redux from 'react-redux' import * as RA from 'react-admin' import { useResourceRefresh } from './useResourceRefresh' -vi.mock('react', async () => { - const actual = await vi.importActual('react') - return { - ...actual, - useState: vi.fn(), - } -}) +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useState: jest.fn(), +})) -vi.mock('react-redux', async () => { - const actual = await vi.importActual('react-redux') - return { - ...actual, - useSelector: vi.fn(), - } -}) +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})) -vi.mock('react-admin', async () => { - const actual = await vi.importActual('react-admin') - return { - ...actual, - useRefresh: vi.fn(), - useDataProvider: vi.fn(), - } -}) +jest.mock('react-admin', () => ({ + ...jest.requireActual('react-admin'), + useRefresh: jest.fn(), + useDataProvider: jest.fn(), +})) describe('useResourceRefresh', () => { - const setState = vi.fn() + const setState = jest.fn() const useStateMock = (initState) => [initState, setState] - const refresh = vi.fn() + const refresh = jest.fn() const useRefreshMock = () => refresh - const getMany = vi.fn() + const getMany = jest.fn() const useDataProviderMock = () => ({ getMany }) let lastTime - // Applies each real selector against a constructed store, so both useSelector - // calls in the hook (refresh payload + loaded records) resolve correctly. - const mockStore = ({ refresh: refreshPayload, loaded = {} }) => { - const state = { - activity: { refresh: refreshPayload }, - admin: { resources: loaded }, - } - vi.spyOn(Redux, 'useSelector').mockImplementation((selector) => - selector(state), - ) - } - - // Turns id lists into the react-admin store shape: { album: { data: { 'al-1': {...} } } } - const asStore = (byResource) => - Object.fromEntries( - Object.entries(byResource).map(([r, ids]) => [ - r, - { data: Object.fromEntries(ids.map((id) => [id, { id }])) }, - ]), - ) - beforeEach(() => { - vi.spyOn(React, 'useState').mockImplementation(useStateMock) - vi.spyOn(RA, 'useRefresh').mockImplementation(useRefreshMock) - vi.spyOn(RA, 'useDataProvider').mockImplementation(useDataProviderMock) + jest.spyOn(React, 'useState').mockImplementation(useStateMock) + jest.spyOn(RA, 'useRefresh').mockImplementation(useRefreshMock) + jest.spyOn(RA, 'useDataProvider').mockImplementation(useDataProviderMock) lastTime = new Date(new Date().valueOf() + 1000) }) afterEach(() => { - vi.clearAllMocks() + jest.clearAllMocks() }) it('stores last time checked, to avoid redundant runs', () => { - mockStore({ refresh: { lastReceived: lastTime } }) + const useSelectorMock = () => ({ lastReceived: lastTime }) + jest.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -79,8 +49,9 @@ describe('useResourceRefresh', () => { }) it("does not run again if lastTime didn't change", () => { - vi.spyOn(React, 'useState').mockImplementation(() => [lastTime, setState]) - mockStore({ refresh: { lastReceived: lastTime } }) + jest.spyOn(React, 'useState').mockImplementation(() => [lastTime, setState]) + const useSelectorMock = () => ({ lastReceived: lastTime }) + jest.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -89,9 +60,11 @@ describe('useResourceRefresh', () => { describe('No visible resources specified', () => { it('triggers a UI refresh when received a "any" resource refresh', () => { - mockStore({ - refresh: { lastReceived: lastTime, resources: { '*': '*' } }, + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { '*': '*' }, }) + jest.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -100,9 +73,11 @@ describe('useResourceRefresh', () => { }) it('triggers a UI refresh when received an "any" id', () => { - mockStore({ - refresh: { lastReceived: lastTime, resources: { album: ['*'] } }, + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { album: ['*'] }, }) + jest.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -110,14 +85,12 @@ describe('useResourceRefresh', () => { expect(getMany).not.toHaveBeenCalled() }) - it('refetches only the received resources already loaded in the store', () => { - mockStore({ - refresh: { - lastReceived: lastTime, - resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, - }, - loaded: asStore({ album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }), + it('triggers a refetch of the resources received', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, }) + jest.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh() @@ -126,42 +99,15 @@ describe('useResourceRefresh', () => { expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-1', 'al-2'] }) expect(getMany).toHaveBeenCalledWith('song', { ids: ['sg-1', 'sg-2'] }) }) - - it('skips ids that are not loaded in the store', () => { - mockStore({ - refresh: { - lastReceived: lastTime, - resources: { album: ['al-1', 'al-2', 'al-3'] }, - }, - loaded: asStore({ album: ['al-2'] }), - }) - - useResourceRefresh() - - expect(getMany).toHaveBeenCalledTimes(1) - expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-2'] }) - }) - - it('does not fetch when none of the received ids are loaded', () => { - mockStore({ - refresh: { - lastReceived: lastTime, - resources: { artist: ['ar-1', 'ar-2'] }, - }, - loaded: asStore({ artist: ['ar-9'] }), - }) - - useResourceRefresh() - - expect(getMany).not.toHaveBeenCalled() - }) }) describe('Visible resources specified', () => { it('triggers a UI refresh when received a "any" resource refresh', () => { - mockStore({ - refresh: { lastReceived: lastTime, resources: { '*': '*' } }, + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { '*': '*' }, }) + jest.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh('album') @@ -169,101 +115,12 @@ describe('useResourceRefresh', () => { expect(getMany).not.toHaveBeenCalled() }) - it('does not refresh the page when the wildcard is on a resource it does not watch', () => { - // Guards other senders: a wildcard on an unwatched resource must not reload this list. - mockStore({ - refresh: { - lastReceived: lastTime, - resources: { album: ['al-1', 'al-2'], song: ['*'] }, - }, - loaded: asStore({ album: ['al-1', 'al-2'] }), - }) - - useResourceRefresh('album') - - expect(refresh).not.toHaveBeenCalled() - expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-1', 'al-2'] }) - }) - - it('refetches the loaded songs of a refreshed album', () => { - // A track with no art of its own is served its album's, so its coverArt id moves with the - // album's. The backend cannot know which tracks are loaded; the store can. - mockStore({ - refresh: { lastReceived: lastTime, resources: { album: ['al-1'] } }, - loaded: { - album: { data: { 'al-1': { id: 'al-1' } } }, - song: { - data: { - 'sg-1': { id: 'sg-1', albumId: 'al-1' }, - 'sg-2': { id: 'sg-2', albumId: 'al-2' }, - }, - }, - }, - }) - - useResourceRefresh('song') - - expect(refresh).not.toHaveBeenCalled() - expect(getMany).toHaveBeenCalledWith('song', { ids: ['sg-1'] }) - }) - - it('fans an album refresh out to loaded playlist tracks, which have their own ids', () => { - mockStore({ - refresh: { lastReceived: lastTime, resources: { album: ['al-1'] } }, - loaded: { - playlistTrack: { - data: { - 'pt-1': { id: 'pt-1', albumId: 'al-1' }, - 'pt-2': { id: 'pt-2', albumId: 'al-2' }, - }, - }, - }, - }) - - useResourceRefresh('playlistTrack', 'song', 'playlist') - - expect(refresh).not.toHaveBeenCalled() - expect(getMany).toHaveBeenCalledWith('playlistTrack', { ids: ['pt-1'] }) - }) - - it('does not fan out to track resources the component does not show', () => { - mockStore({ - refresh: { lastReceived: lastTime, resources: { album: ['al-1'] } }, - loaded: { - album: { data: { 'al-1': { id: 'al-1' } } }, - song: { data: { 'sg-1': { id: 'sg-1', albumId: 'al-1' } } }, - }, - }) - - useResourceRefresh('album') - - expect(refresh).not.toHaveBeenCalled() - expect(getMany).toHaveBeenCalledTimes(1) - expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-1'] }) - }) - - it('does not refetch songs when no loaded song belongs to the refreshed album', () => { - mockStore({ - refresh: { lastReceived: lastTime, resources: { album: ['al-9'] } }, - loaded: { - song: { data: { 'sg-1': { id: 'sg-1', albumId: 'al-1' } } }, - }, - }) - - useResourceRefresh('song') - - expect(refresh).not.toHaveBeenCalled() - expect(getMany).not.toHaveBeenCalled() - }) - - it('refetches the received resources if they are visible and loaded', () => { - mockStore({ - refresh: { - lastReceived: lastTime, - resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, - }, - loaded: asStore({ album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }), + it('triggers a refetch of the resources received if they are visible', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, }) + jest.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) useResourceRefresh('song') diff --git a/ui/src/common/useScrollRestoration.jsx b/ui/src/common/useScrollRestoration.jsx deleted file mode 100644 index 94e5cb2fd..000000000 --- a/ui/src/common/useScrollRestoration.jsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useEffect, useLayoutEffect, useRef } from 'react' -import { useHistory, useLocation } from 'react-router-dom' - -// Keyed on the route because hash history assigns no location.key, so every page would -// otherwise share one slot and overwrite the offset we came back for. -const positions = new Map() -const MAX_ENTRIES = 50 -const MAX_FRAMES = 60 - -export const useScrollRestoration = (ready = true) => { - const { pathname, search } = useLocation() - const history = useHistory() - const key = pathname + search - const handled = useRef(null) - const latest = useRef(0) - - useEffect(() => { - latest.current = window.scrollY - const track = () => { - latest.current = window.scrollY - } - window.addEventListener('scroll', track, { passive: true }) - return () => window.removeEventListener('scroll', track) - }, [key]) - - // Layout cleanup, so the offset is banked while this page is torn down. A passive one runs - // after the incoming page tops itself, and this page's listener records that 0 first. - useLayoutEffect( - () => () => { - positions.delete(key) - positions.set(key, latest.current) - if (positions.size > MAX_ENTRIES) { - positions.delete(positions.keys().next().value) - } - }, - [key], - ) - - // Layout effect: a passive one runs after paint, so the list would be painted at the old - // offset first and visibly jump. - useLayoutEffect(() => { - if (!ready || handled.current === key) return - handled.current = key - const saved = positions.get(key) - const top = history.action === 'POP' && saved !== undefined ? saved : 0 - window.scrollTo({ top }) - if (!top || Math.round(window.scrollY) === top) return - - // The page can still be filling in, and until it is tall enough the browser clamps us to - // the top. Keep asking until the offset sticks, yielding if anything else moves us. - let frame - let frames = 0 - let landed = Math.round(window.scrollY) - const retry = () => { - const y = Math.round(window.scrollY) - if (y === top || y !== landed || ++frames > MAX_FRAMES) return - window.scrollTo({ top }) - landed = Math.round(window.scrollY) - frame = requestAnimationFrame(retry) - } - frame = requestAnimationFrame(retry) - return () => cancelAnimationFrame(frame) - }, [ready, key, history.action]) -} diff --git a/ui/src/common/useScrollRestoration.test.jsx b/ui/src/common/useScrollRestoration.test.jsx deleted file mode 100644 index 7ca67a724..000000000 --- a/ui/src/common/useScrollRestoration.test.jsx +++ /dev/null @@ -1,222 +0,0 @@ -import { renderHook, act } from '@testing-library/react-hooks' -import { render } from '@testing-library/react' -import { useLayoutEffect } from 'react' -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' - -const mockLocation = { pathname: '/album', search: '' } -const mockHistory = { action: 'PUSH' } -vi.mock('react-router-dom', () => ({ - useLocation: () => mockLocation, - useHistory: () => mockHistory, -})) - -import { useScrollRestoration } from './useScrollRestoration' - -describe('useScrollRestoration', () => { - beforeEach(() => { - window.scrollTo = vi.fn() - window.scrollY = 0 - mockLocation.pathname = '/album' - mockLocation.search = '' - mockHistory.action = 'PUSH' - }) - afterEach(() => { - vi.useRealTimers() - vi.clearAllMocks() - }) - - const scrollTo = (y) => { - window.scrollY = y - act(() => { - window.dispatchEvent(new Event('scroll')) - }) - } - - // Returns to a page whose rows have not arrived, so scrollTo still clamps. The returned setter - // grows the page to the height it reaches once they do. - const returnToUnfilledPage = (saved) => { - vi.useFakeTimers() - let maxScroll = saved - window.scrollTo = vi.fn(({ top }) => { - window.scrollY = Math.min(top, maxScroll) - }) - - const first = renderHook(() => useScrollRestoration()) - scrollTo(saved) - first.unmount() - - maxScroll = 0 - window.scrollY = 0 - mockHistory.action = 'POP' - renderHook(() => useScrollRestoration()) - return (height) => (maxScroll = height) - } - - const advanceFrames = () => act(() => vi.advanceTimersByTime(100)) - - it('starts a pushed route at the top', () => { - renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 }) - }) - - it('restores the saved offset when returning to an entry', () => { - const first = renderHook(() => useScrollRestoration()) - scrollTo(640) - first.unmount() - - mockLocation.pathname = '/album/al-1/show' - const second = renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 0 }) - second.unmount() - - mockLocation.pathname = '/album' - mockHistory.action = 'POP' - renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 640 }) - }) - - it('tops a pushed route even if that key was seen before', () => { - const first = renderHook(() => useScrollRestoration()) - scrollTo(500) - first.unmount() - - mockHistory.action = 'PUSH' - renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 0 }) - }) - - // Restoring before the rows exist leaves the document too short: the browser clamps to the - // top and the offset is lost with no error. - it('waits for readiness before touching the scroll position', () => { - const { rerender } = renderHook( - ({ ready }) => useScrollRestoration(ready), - { - initialProps: { ready: false }, - }, - ) - expect(window.scrollTo).not.toHaveBeenCalled() - - rerender({ ready: true }) - expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 }) - }) - - // Hash history assigns no location.key, so an implementation keyed on it would collapse every - // route into one slot and the detail page would erase the list's offset before we returned. - it('keeps a separate offset per route', () => { - const list = renderHook(() => useScrollRestoration()) - scrollTo(900) - list.unmount() - - mockLocation.pathname = '/album/al-1/show' - const detail = renderHook(() => useScrollRestoration()) - scrollTo(0) - detail.unmount() - - mockLocation.pathname = '/album' - mockHistory.action = 'POP' - renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 900 }) - }) - - // Without the per-instance guard this fires again when readiness flickers, fighting a user who - // has already started scrolling. Nothing else in the suite pins it. - it('does not scroll again when readiness flickers on the same route', () => { - const { rerender } = renderHook( - ({ ready }) => useScrollRestoration(ready), - { - initialProps: { ready: true }, - }, - ) - expect(window.scrollTo).toHaveBeenCalledTimes(1) - - rerender({ ready: false }) - rerender({ ready: true }) - expect(window.scrollTo).toHaveBeenCalledTimes(1) - }) - - it('scrolls once per entry, not on every re-render', () => { - const { rerender } = renderHook(() => useScrollRestoration()) - rerender() - rerender() - expect(window.scrollTo).toHaveBeenCalledTimes(1) - }) - - // The artist page reports ready as soon as its header record is cached, while the albums below - // are still loading. A single scrollTo lands on a viewport-tall document and is clamped to 0. - it('keeps trying until the page is tall enough to hold the offset', () => { - const fillPageTo = returnToUnfilledPage(800) - expect(window.scrollY).toBe(0) - - fillPageTo(1200) - advanceFrames() - expect(window.scrollY).toBe(800) - }) - - it('stops retrying once the user scrolls somewhere else', () => { - const fillPageTo = returnToUnfilledPage(800) - - fillPageTo(1200) - window.scrollY = 300 - advanceFrames() - expect(window.scrollY).toBe(300) - }) - - // A trackpad back-swipe keeps firing wheel momentum through the restore, so treating input as - // the yield signal cancels the gesture's own restore. - it('keeps restoring while wheel momentum fires, as a back-swipe does', () => { - const fillPageTo = returnToUnfilledPage(800) - - act(() => { - for (let i = 0; i < 10; i++) { - window.dispatchEvent( - new WheelEvent('wheel', { deltaX: -30, deltaY: 0 }), - ) - } - }) - fillPageTo(1200) - advanceFrames() - expect(window.scrollY).toBe(800) - }) - - // The incoming page tops itself in its own layout effect, while this page's scroll listener is - // still attached. Banking the offset any later than the teardown records that 0 instead. - it('records the offset before the next page scrolls to the top', () => { - const Outgoing = () => { - useScrollRestoration() - return null - } - const Incoming = () => { - useLayoutEffect(() => { - window.scrollY = 0 - window.dispatchEvent(new Event('scroll')) - }, []) - return null - } - - const { rerender } = render(<Outgoing />) - scrollTo(1400) - - mockLocation.pathname = '/album/al-1/show' - rerender(<Incoming />) - - mockLocation.pathname = '/album' - mockHistory.action = 'POP' - renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 1400 }) - }) - - it('keeps saving after the restore, so leaving again remembers the new offset', () => { - const first = renderHook(() => useScrollRestoration()) - scrollTo(200) - first.unmount() - - mockHistory.action = 'POP' - const second = renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 200 }) - scrollTo(900) - second.unmount() - - renderHook(() => useScrollRestoration()) - expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 900 }) - }) -}) diff --git a/ui/src/common/useSearchRefocus.js b/ui/src/common/useSearchRefocus.js deleted file mode 100644 index 4daad26f9..000000000 --- a/ui/src/common/useSearchRefocus.js +++ /dev/null @@ -1,50 +0,0 @@ -import { useEffect, useRef } from 'react' -import { useLocation } from 'react-router-dom' - -// Search field names used by SearchInput across different list views: -// - 'name': AlbumList, ArtistList, LibraryList, PlayerList, RadioList, UserList -// - 'title': SongList -// - 'q': PlaylistList -// If a new list view uses a different source field, add it here. -const SEARCH_FIELDS = ['name', 'title', 'q'] - -const getSearchValue = (filter) => { - for (const field of SEARCH_FIELDS) { - if (filter[field]) return filter[field] - } - return '' -} - -export const useSearchRefocus = () => { - const location = useLocation() - const prevSearchValue = useRef(null) - - useEffect(() => { - const params = new URLSearchParams(location.search) - const filterStr = params.get('filter') || '{}' - - let filter = {} - try { - filter = JSON.parse(filterStr) - } catch (e) { - // Invalid JSON, ignore - } - - const searchValue = getSearchValue(filter) - - if (prevSearchValue.current && !searchValue) { - // Use requestAnimationFrame to wait for React to finish re-rendering - // after the URL change before focusing the input - requestAnimationFrame(() => { - // Selector depends on react-admin's internal class naming. - // If react-admin changes these class names, this will need updating. - const input = document.querySelector('[class*="RaSearchInput"] input') - if (input) { - input.focus() - } - }) - } - - prevSearchValue.current = searchValue - }, [location.search]) -} diff --git a/ui/src/common/useSearchRefocus.test.js b/ui/src/common/useSearchRefocus.test.js deleted file mode 100644 index 2bce8320d..000000000 --- a/ui/src/common/useSearchRefocus.test.js +++ /dev/null @@ -1,84 +0,0 @@ -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest' -import { renderHook } from '@testing-library/react-hooks' -import { useSearchRefocus } from './useSearchRefocus' - -const mockLocation = { search: '' } -vi.mock('react-router-dom', () => ({ - useLocation: () => mockLocation, -})) - -describe('useSearchRefocus', () => { - let container - let rafCallbacks - - beforeEach(() => { - rafCallbacks = [] - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { - rafCallbacks.push(cb) - return rafCallbacks.length - }) - - container = document.createElement('div') - container.innerHTML = ` - <div class="RaSearchInput-input"> - <input type="text" /> - </div> - ` - document.body.appendChild(container) - mockLocation.search = '' - }) - - afterEach(() => { - vi.restoreAllMocks() - document.body.removeChild(container) - }) - - const flushRAF = () => { - rafCallbacks.forEach((cb) => cb()) - rafCallbacks = [] - } - - it('focuses the input when search filter is cleared', () => { - const input = container.querySelector('input') - const focusSpy = vi.spyOn(input, 'focus') - - mockLocation.search = '?filter={"name":"test"}' - const { rerender } = renderHook(() => useSearchRefocus()) - - expect(focusSpy).not.toHaveBeenCalled() - - mockLocation.search = '?filter={}' - rerender() - flushRAF() - - expect(focusSpy).toHaveBeenCalledTimes(1) - }) - - it('does not focus if filter was already empty', () => { - const input = container.querySelector('input') - const focusSpy = vi.spyOn(input, 'focus') - - mockLocation.search = '?filter={}' - const { rerender } = renderHook(() => useSearchRefocus()) - - mockLocation.search = '?filter={}' - rerender() - flushRAF() - - expect(focusSpy).not.toHaveBeenCalled() - }) - - it('does not focus if filter value changed but not cleared', () => { - const input = container.querySelector('input') - const focusSpy = vi.spyOn(input, 'focus') - - mockLocation.search = '?filter={"name":"test"}' - const { rerender } = renderHook(() => useSearchRefocus()) - - mockLocation.search = '?filter={"name":"other"}' - rerender() - flushRAF() - - expect(focusSpy).not.toHaveBeenCalled() - }) -}) diff --git a/ui/src/common/useSelectedFields.jsx b/ui/src/common/useSelectedFields.js similarity index 97% rename from ui/src/common/useSelectedFields.jsx rename to ui/src/common/useSelectedFields.js index 0560de523..07ccc9180 100644 --- a/ui/src/common/useSelectedFields.jsx +++ b/ui/src/common/useSelectedFields.js @@ -12,7 +12,7 @@ export const useSelectedFields = ({ }) => { const dispatch = useDispatch() const resourceFields = useSelector( - (state) => state.settings.toggleableFields, + (state) => state.settings.toggleableFields )?.[resource] const omittedFields = useSelector((state) => state.settings.omittedFields)?.[ resource @@ -81,7 +81,7 @@ useSelectedFields.propTypes = { export const useSetToggleableFields = ( resource, toggleableColumns, - defaultOff = [], + defaultOff = [] ) => { const current = useSelector((state) => state.settings.toggleableFields)?.album const dispatch = useDispatch() @@ -95,7 +95,7 @@ export const useSetToggleableFields = ( ...{ [cur]: true }, } }, {}), - }), + }) ) dispatch(setOmittedFields({ [resource]: defaultOff })) } diff --git a/ui/src/common/useToggleLove.js b/ui/src/common/useToggleLove.js new file mode 100644 index 000000000..793349d9f --- /dev/null +++ b/ui/src/common/useToggleLove.js @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useDataProvider, useNotify } from 'react-admin' +import subsonic from '../subsonic' + +export const useToggleLove = (resource, record = {}) => { + const [loading, setLoading] = useState(false) + const notify = useNotify() + + const mountedRef = useRef(false) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + const dataProvider = useDataProvider() + + const refreshRecord = useCallback(() => { + dataProvider.getOne(resource, { id: record.id }).then(() => { + if (mountedRef.current) { + setLoading(false) + } + }) + }, [dataProvider, record.id, resource]) + + const toggleLove = () => { + const toggle = record.starred ? subsonic.unstar : subsonic.star + + setLoading(true) + toggle(record.id) + .then(refreshRecord) + .catch((e) => { + console.log('Error toggling love: ', e) + notify('ra.page.error', 'warning') + if (mountedRef.current) { + setLoading(false) + } + }) + } + + return [toggleLove, loading] +} diff --git a/ui/src/common/useToggleLove.jsx b/ui/src/common/useToggleLove.jsx deleted file mode 100644 index 22468cbe1..000000000 --- a/ui/src/common/useToggleLove.jsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { useDataProvider, useNotify, useRefresh } from 'react-admin' -import subsonic from '../subsonic' - -export const useToggleLove = (resource, record = {}) => { - const [loading, setLoading] = useState(false) - const notify = useNotify() - - const mountedRef = useRef(false) - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - } - }, []) - - const dataProvider = useDataProvider() - const refresh = useRefresh() - - const refreshRecord = useCallback(() => { - // A playlistTrack id is a position, not a stable key: loving a song can drop it out of - // a smart playlist, and that position then holds a different track. Refetching the row - // by id would write the neighbour's data under this row, so reload the list instead. - const isPlaylistTrack = !!record.mediaFileId - const target = isPlaylistTrack - ? { resource: 'song', params: { id: record.mediaFileId } } - : { resource, params: { id: record.id } } - - dataProvider - .getOne(target.resource, target.params) - .catch((e) => { - // eslint-disable-next-line no-console - console.log('Error encountered: ' + e) - }) - .finally(() => { - if (isPlaylistTrack) { - refresh() - } - if (mountedRef.current) { - setLoading(false) - } - }) - }, [dataProvider, record.mediaFileId, record.id, refresh, resource]) - - const toggleLove = () => { - const toggle = record.starred ? subsonic.unstar : subsonic.star - const id = record.mediaFileId || record.id - - setLoading(true) - toggle(id) - .then(refreshRecord) - .catch((e) => { - // eslint-disable-next-line no-console - console.log('Error toggling love: ', e) - notify('ra.page.error', 'warning') - if (mountedRef.current) { - setLoading(false) - } - }) - } - - return [toggleLove, loading] -} diff --git a/ui/src/common/useToggleLove.test.js b/ui/src/common/useToggleLove.test.js deleted file mode 100644 index 10db1b3c3..000000000 --- a/ui/src/common/useToggleLove.test.js +++ /dev/null @@ -1,135 +0,0 @@ -import { renderHook, act } from '@testing-library/react-hooks' -import { vi, describe, it, expect, beforeEach } from 'vitest' -import { useToggleLove } from './useToggleLove' -import subsonic from '../subsonic' -import { useDataProvider } from 'react-admin' - -const mockRefresh = vi.fn() - -vi.mock('../subsonic', () => ({ - default: { - star: vi.fn(() => Promise.resolve()), - unstar: vi.fn(() => Promise.resolve()), - }, -})) - -vi.mock('react-admin', async () => { - const actual = await vi.importActual('react-admin') - return { - ...actual, - useDataProvider: vi.fn(), - useNotify: vi.fn(() => vi.fn()), - useRefresh: vi.fn(() => mockRefresh), - } -}) - -describe('useToggleLove', () => { - let getOne - beforeEach(() => { - getOne = vi.fn(() => Promise.resolve()) - useDataProvider.mockReturnValue({ getOne }) - vi.clearAllMocks() - }) - - it('uses mediaFileId when present', async () => { - const record = { id: 'pt-1', mediaFileId: 'sg-1', starred: false } - const { result } = renderHook(() => useToggleLove('song', record)) - await act(async () => { - await result.current[0]() - }) - expect(subsonic.star).toHaveBeenCalledWith('sg-1') - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) - }) - - it('falls back to id when mediaFileId not present', async () => { - const record = { id: 'sg-1', starred: false } - const { result } = renderHook(() => useToggleLove('song', record)) - await act(async () => { - await result.current[0]() - }) - expect(subsonic.star).toHaveBeenCalledWith('sg-1') - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) - }) - - it('calls unstar when record is already loved', async () => { - const record = { id: 'sg-1', starred: true } - const { result } = renderHook(() => useToggleLove('song', record)) - await act(async () => { - await result.current[0]() - }) - expect(subsonic.unstar).toHaveBeenCalledWith('sg-1') - }) - - describe('playlist track scenarios', () => { - it('refreshes the song and reloads the list for playlist tracks', async () => { - const record = { - id: '1', - mediaFileId: 'sg-1', - playlistId: 'pl-1', - starred: false, - } - const { result } = renderHook(() => - useToggleLove('playlistTrack', record), - ) - await act(async () => { - await result.current[0]() - }) - - // Should star using the media file ID - expect(subsonic.star).toHaveBeenCalledWith('sg-1') - - // The row is a position in the playlist, so it cannot be refetched by id: - // loving can drop the track out of a smart playlist and shift every row up - expect(getOne).toHaveBeenCalledTimes(1) - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) - expect(getOne).not.toHaveBeenCalledWith( - 'playlistTrack', - expect.anything(), - ) - expect(mockRefresh).toHaveBeenCalled() - }) - - it('reloads the list even when the song refresh fails', async () => { - getOne.mockImplementation(() => Promise.reject(new Error('boom'))) - const record = { - id: '5', - mediaFileId: 'sg-10', - playlistId: 'pl-123', - starred: true, - } - const { result } = renderHook(() => - useToggleLove('playlistTrack', record), - ) - await act(async () => { - await result.current[0]() - }) - - expect(subsonic.unstar).toHaveBeenCalledWith('sg-10') - expect(mockRefresh).toHaveBeenCalled() - }) - - it('only refreshes original resource when no mediaFileId present', async () => { - const record = { id: 'sg-1', starred: false } - const { result } = renderHook(() => useToggleLove('song', record)) - await act(async () => { - await result.current[0]() - }) - - // Should only refresh the original resource (song), without reloading the list - expect(getOne).toHaveBeenCalledTimes(1) - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) - expect(mockRefresh).not.toHaveBeenCalled() - }) - - it('does not include playlist_id filter for non-playlist resources', async () => { - const record = { id: 'sg-1', starred: false } - const { result } = renderHook(() => useToggleLove('song', record)) - await act(async () => { - await result.current[0]() - }) - - // Should refresh without any filter - expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) - }) - }) -}) diff --git a/ui/src/common/useTraceUpdate.jsx b/ui/src/common/useTraceUpdate.js similarity index 90% rename from ui/src/common/useTraceUpdate.jsx rename to ui/src/common/useTraceUpdate.js index e447afcbe..981f04783 100644 --- a/ui/src/common/useTraceUpdate.jsx +++ b/ui/src/common/useTraceUpdate.js @@ -10,7 +10,6 @@ export function useTraceUpdate(props) { return ps }, {}) if (Object.keys(changedProps).length > 0) { - // eslint-disable-next-line no-console console.log('Changed props:', changedProps) } prev.current = props diff --git a/ui/src/config.js b/ui/src/config.js index 62b3cb822..7548ab134 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -5,7 +5,7 @@ const defaultConfig = { version: 'dev', firstTime: false, baseURL: '', - variousArtistsId: '63sqASlAfjbGMuLP4JhnZU', // See consts.VariousArtistsID in consts.go + variousArtistsId: '03b645ef2100dfc42fa9785ea3102295', // See consts.VariousArtistsID in consts.go // Login backgrounds from https://unsplash.com/collections/1065384/music-wallpapers loginBackgroundURL: 'https://source.unsplash.com/collection/1065384/1600x900', maxSidebarPlaylists: 100, @@ -20,32 +20,19 @@ const defaultConfig = { defaultTheme: 'Dark', defaultLanguage: '', defaultUIVolume: 100, - uiSearchDebounceMs: 200, - uiCoverArtSize: 600, enableUserEditing: true, - enableArtworkUpload: true, enableSharing: true, - shareURL: '', defaultDownloadableShare: true, devSidebarPlaylists: true, lastFMEnabled: true, + lastFMApiKey: '9b94a5515ea66b2da3ec03c12300327e', listenBrainzEnabled: true, enableExternalServices: true, enableCoverAnimation: true, - pidAlbum: 'musicbrainz_albumid|albumartistid,album,albumversion,releasedate', // See consts.DefaultAlbumPID - pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title', // See consts.DefaultTrackPID - enableNowPlaying: true, - playbackReportIntervalMs: 60000, devShowArtistPage: true, - devUIShowConfig: true, - devNewEventStream: false, enableReplayGain: true, - enableQuickConnect: false, defaultDownsamplingFormat: 'opus', publicBaseUrl: '/share', - separator: '/', - enableInspect: true, - pluginsEnabled: true, } let config diff --git a/ui/src/consts.js b/ui/src/consts.js index 46c76e194..20c8bac2b 100644 --- a/ui/src/consts.js +++ b/ui/src/consts.js @@ -1,14 +1,9 @@ export const REST_URL = '/api' -export const INSIGHTS_DOC_URL = - 'https://navidrome.org/docs/getting-started/insights' - export const M3U_MIME_TYPE = 'audio/x-mpegurl' export const AUTO_THEME_ID = 'AUTO_THEME_ID' -export const AUTO_THEME_CONFIG_VALUE = 'Auto' - export const DraggableTypes = { SONG: 'song', ALBUM: 'album', @@ -21,19 +16,11 @@ DraggableTypes.ALL.push( DraggableTypes.SONG, DraggableTypes.ALBUM, DraggableTypes.DISC, - DraggableTypes.ARTIST, + DraggableTypes.ARTIST ) -export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg' - export const DEFAULT_SHARE_BITRATE = 128 export const BITRATE_CHOICES = [ 32, 48, 64, 80, 96, 112, 128, 160, 192, 256, 320, ].map((b) => ({ id: b, name: b.toString() })) - -// 0 is a valid stored value ("no default bit rate") that BITRATE_CHOICES cannot express. -export const TRANSCODING_BITRATE_CHOICES = [ - { id: 0, name: 'resources.transcoding.choices.noDefaultBitRate' }, - ...BITRATE_CHOICES, -] diff --git a/ui/src/dataProvider/httpClient.js b/ui/src/dataProvider/httpClient.js index 706486fa6..0e9f5433a 100644 --- a/ui/src/dataProvider/httpClient.js +++ b/ui/src/dataProvider/httpClient.js @@ -2,11 +2,11 @@ import { fetchUtils } from 'react-admin' import { v4 as uuidv4 } from 'uuid' import { baseUrl } from '../utils' import config from '../config' -import { jwtDecode } from 'jwt-decode' +import jwtDecode from 'jwt-decode' const customAuthorizationHeader = 'X-ND-Authorization' -export const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' -export const clientUniqueId = uuidv4() +const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' +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 059023378..e9ceea761 100644 --- a/ui/src/dataProvider/index.js +++ b/ui/src/dataProvider/index.js @@ -1,6 +1,6 @@ -import httpClient, { clientUniqueId, clientUniqueIdHeader } from './httpClient' +import httpClient from './httpClient' import wrapperDataProvider from './wrapperDataProvider' -export { httpClient, clientUniqueId, clientUniqueIdHeader } +export { httpClient } export default wrapperDataProvider diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js index 7de20bcce..a86da6536 100644 --- a/ui/src/dataProvider/wrapperDataProvider.js +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -4,156 +4,29 @@ import { REST_URL } from '../consts' const dataProvider = jsonServerProvider(REST_URL, httpClient) -const REFRESH_KIND = { album: 'al', artist: 'ar' } - -const isAdmin = () => { - const role = localStorage.getItem('role') - return role === 'admin' -} - -const getSelectedLibraries = () => { - try { - const state = JSON.parse(localStorage.getItem('state')) - const selectedLibraries = state?.library?.selectedLibraries || [] - const userLibraries = state?.library?.userLibraries || [] - - // Validate selected libraries against current user libraries - const userLibraryIds = userLibraries.map((lib) => lib.id) - const validatedSelection = selectedLibraries.filter((id) => - userLibraryIds.includes(id), - ) - - // If user has only one library, return empty array (no filter needed) - if (userLibraryIds.length === 1) { - return [] - } - - return validatedSelection - } catch (err) { - return [] - } -} - -// Function to apply library filtering to appropriate resources -const applyLibraryFilter = (resource, params) => { - // Content resources that should be filtered by selected libraries - const filteredResources = ['album', 'song', 'artist', 'playlistTrack', 'tag'] - - // Get selected libraries from localStorage - const selectedLibraries = getSelectedLibraries() - - // Add library filter for content resources if libraries are selected - if (filteredResources.includes(resource) && selectedLibraries.length > 0) { - if (!params.filter) { - params.filter = {} - } - params.filter.library_id = selectedLibraries - } - - return params -} - const mapResource = (resource, params) => { switch (resource) { - // /api/playlistTrack?playlist_id=123 => /api/playlist/123/tracks - case 'playlistTrack': { - params.filter = params.filter || {} - + case 'playlistTrack': + // /api/playlistTrack?playlist_id=123 => /api/playlist/123/tracks let plsId = '0' - plsId = params.filter.playlist_id - if (!isAdmin()) { - params.filter.missing = false + if (params.filter) { + plsId = params.filter.playlist_id } - params = applyLibraryFilter(resource, params) - return [`playlist/${plsId}/tracks`, params] - } - case 'album': - case 'song': - case 'artist': - case 'tag': { - params.filter = params.filter || {} - if (!isAdmin()) { - params.filter.missing = false - } - params = applyLibraryFilter(resource, params) - return [resource, params] - } default: return [resource, params] } } const callDeleteMany = (resource, params) => { - const ids = (params.ids || []).map((id) => `id=${id}`) - const query = ids.length > 0 ? `?${ids.join('&')}` : '' - return httpClient(`${REST_URL}/${resource}${query}`, { + const ids = params.ids.map((id) => `id=${id}`) + const idsParam = ids.join('&') + return httpClient(`${REST_URL}/${resource}?${idsParam}`, { method: 'DELETE', }).then((response) => ({ data: response.json.ids || [] })) } -// Helper function to handle user-library associations -const handleUserLibraryAssociation = async (userId, libraryIds) => { - if (!libraryIds || libraryIds.length === 0) { - return // Admin users or users without library assignments - } - - try { - await httpClient(`${REST_URL}/user/${userId}/library`, { - method: 'PUT', - body: JSON.stringify({ libraryIds }), - }) - } catch (error) { - console.error('Error setting user libraries:', error) //eslint-disable-line no-console - throw error - } -} - -// Enhanced user creation that handles library associations -const createUser = async (params) => { - const { data } = params - const { libraryIds, ...userData } = data - - // First create the user - const userResponse = await dataProvider.create('user', { data: userData }) - const userId = userResponse.data.id - - // Then set library associations for non-admin users - if (!userData.isAdmin && libraryIds && libraryIds.length > 0) { - await handleUserLibraryAssociation(userId, libraryIds) - } - - return userResponse -} - -// Enhanced user update that handles library associations -const updateUser = async (params) => { - const { data } = params - const { libraryIds, ...userData } = data - const userId = params.id - - // First update the user - const userResponse = await dataProvider.update('user', { - ...params, - data: userData, - }) - - // Then handle library associations for non-admin users. Only admins can call - // this endpoint; for self-edits the server manages library assignments - if (isAdmin() && !userData.isAdmin && libraryIds !== undefined) { - await handleUserLibraryAssociation(userId, libraryIds) - } - - return userResponse -} - -// ra-data-json-server merges the request body into the result; re-read so the plaintext key is never cached -const createPlayer = async (resource, params) => { - const { data } = await dataProvider.create(resource, params) - return dataProvider.getOne(resource, { id: data.id }) -} - const wrapperDataProvider = { ...dataProvider, getList: (resource, params) => { @@ -162,19 +35,7 @@ const wrapperDataProvider = { }, getOne: (resource, params) => { const [r, p] = mapResource(resource, params) - const response = dataProvider.getOne(r, p) - - // Transform user data to ensure libraryIds is present for form compatibility - if (resource === 'user') { - return response.then((result) => { - if (result.data.libraries && Array.isArray(result.data.libraries)) { - result.data.libraryIds = result.data.libraries.map((lib) => lib.id) - } - return result - }) - } - - return response + return dataProvider.getOne(r, p) }, getMany: (resource, params) => { const [r, p] = mapResource(resource, params) @@ -185,9 +46,6 @@ const wrapperDataProvider = { return dataProvider.getManyReference(r, p) }, update: (resource, params) => { - if (resource === 'user') { - return updateUser(params) - } const [r, p] = mapResource(resource, params) return dataProvider.update(r, p) }, @@ -196,13 +54,7 @@ const wrapperDataProvider = { return dataProvider.updateMany(r, p) }, create: (resource, params) => { - if (resource === 'user') { - return createUser(params) - } const [r, p] = mapResource(resource, params) - if (resource === 'player') { - return createPlayer(r, p) - } return dataProvider.create(r, p) }, delete: (resource, params) => { @@ -211,7 +63,7 @@ const wrapperDataProvider = { }, deleteMany: (resource, params) => { const [r, p] = mapResource(resource, params) - if (r.endsWith('/tracks') || resource === 'missing') { + if (r.endsWith('/tracks')) { return callDeleteMany(r, p) } return dataProvider.deleteMany(r, p) @@ -222,31 +74,6 @@ const wrapperDataProvider = { body: JSON.stringify(data), }).then(({ json }) => ({ data: json })) }, - getPlaylists: (songId) => { - return httpClient(`${REST_URL}/song/${songId}/playlists`).then( - ({ json }) => ({ data: json }), - ) - }, - lookupQuickConnect: (code) => - httpClient( - `${REST_URL}/quickconnect?code=${encodeURIComponent(code)}`, - ).then(({ json }) => ({ data: json })), - authorizeQuickConnect: (code) => - httpClient(`${REST_URL}/quickconnect/authorize`, { - method: 'POST', - body: JSON.stringify({ code }), - }).then(({ json }) => ({ data: json })), - inspect: (songId) => { - return httpClient(`${REST_URL}/inspect?id=${songId}`).then(({ json }) => ({ - data: json, - })) - }, - // The endpoint answers 204 with no body, but react-admin rejects any response without a - // `data` key, so the id stands in for one. - refreshMetadata: (resource, id) => - httpClient(`${REST_URL}/metadata/${REFRESH_KIND[resource]}/${id}/refresh`, { - method: 'POST', - }).then(() => ({ data: { id } })), } export default wrapperDataProvider diff --git a/ui/src/dataProvider/wrapperDataProvider.test.js b/ui/src/dataProvider/wrapperDataProvider.test.js deleted file mode 100644 index 1c33aad5d..000000000 --- a/ui/src/dataProvider/wrapperDataProvider.test.js +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import wrapperDataProvider from './wrapperDataProvider' - -const { mockProvider, mockHttpClient } = vi.hoisted(() => ({ - mockProvider: { - update: vi.fn(), - create: vi.fn(), - getOne: vi.fn(), - }, - mockHttpClient: vi.fn(), -})) - -vi.mock('ra-data-json-server', () => ({ default: () => mockProvider })) -vi.mock('./httpClient', () => ({ default: mockHttpClient })) - -describe('wrapperDataProvider', () => { - beforeEach(() => { - vi.clearAllMocks() - localStorage.clear() - mockProvider.update.mockResolvedValue({ data: { id: 'u1' } }) - mockProvider.create.mockResolvedValue({ data: { id: 'u1' } }) - mockHttpClient.mockResolvedValue({ json: [] }) - }) - - describe('update user', () => { - it('sets library associations when an admin edits a non-admin user', async () => { - localStorage.setItem('role', 'admin') - - await wrapperDataProvider.update('user', { - id: 'u1', - data: { name: 'Sam', isAdmin: false, libraryIds: [1] }, - }) - - expect(mockProvider.update).toHaveBeenCalledWith( - 'user', - expect.objectContaining({ id: 'u1' }), - ) - expect(mockHttpClient).toHaveBeenCalledWith('/api/user/u1/library', { - method: 'PUT', - body: JSON.stringify({ libraryIds: [1] }), - }) - }) - - it('does not call the admin-only library endpoint when a non-admin edits their own profile', async () => { - localStorage.setItem('role', 'regular') - - await wrapperDataProvider.update('user', { - id: 'u1', - data: { - name: 'Sam', - isAdmin: false, - libraryIds: [1], - currentPassword: 'old', - password: 'new', - }, - }) - - expect(mockProvider.update).toHaveBeenCalled() - expect(mockHttpClient).not.toHaveBeenCalled() - }) - - it('does not set library associations when the edited user is an admin', async () => { - localStorage.setItem('role', 'admin') - - await wrapperDataProvider.update('user', { - id: 'u1', - data: { name: 'Sam', isAdmin: true, libraryIds: [1] }, - }) - - expect(mockProvider.update).toHaveBeenCalled() - expect(mockHttpClient).not.toHaveBeenCalled() - }) - - it('strips libraryIds from the user update payload', async () => { - localStorage.setItem('role', 'admin') - - await wrapperDataProvider.update('user', { - id: 'u1', - data: { name: 'Sam', isAdmin: false, libraryIds: [1] }, - }) - - expect(mockProvider.update).toHaveBeenCalledWith( - 'user', - expect.objectContaining({ - data: { name: 'Sam', isAdmin: false }, - }), - ) - }) - }) - - describe('create player', () => { - it('returns the server record, never the plaintext API key', async () => { - const data = { name: 'Phone', apiKey: 'nds_0123456789abcdefghijkl' } - const saved = { id: 'p1', name: 'Phone', hasApiKey: true, userId: 'u1' } - mockProvider.create.mockResolvedValue({ data: { ...data, id: 'p1' } }) - mockProvider.getOne.mockResolvedValue({ data: saved }) - - const result = await wrapperDataProvider.create('player', { data }) - - expect(mockProvider.create).toHaveBeenCalledWith('player', { data }) - expect(mockProvider.getOne).toHaveBeenCalledWith('player', { id: 'p1' }) - expect(result.data).toEqual(saved) - }) - }) - - describe('refreshMetadata', () => { - it('posts to the album metadata refresh endpoint', () => { - mockHttpClient.mockResolvedValue({ json: {} }) - wrapperDataProvider.refreshMetadata('album', 'al-1') - expect(mockHttpClient).toHaveBeenCalledWith( - expect.stringContaining('/metadata/al/al-1/refresh'), - { method: 'POST' }, - ) - }) - - it('posts to the artist metadata refresh endpoint', () => { - mockHttpClient.mockResolvedValue({ json: {} }) - wrapperDataProvider.refreshMetadata('artist', 'ar-1') - expect(mockHttpClient).toHaveBeenCalledWith( - expect.stringContaining('/metadata/ar/ar-1/refresh'), - { method: 'POST' }, - ) - }) - - // react-admin rejects a custom method whose response has no `data` key, and the - // endpoint answers 204 with no body. - it('resolves to a react-admin shaped response', async () => { - mockHttpClient.mockResolvedValue({ - status: 204, - body: '', - json: undefined, - }) - await expect( - wrapperDataProvider.refreshMetadata('album', 'al-1'), - ).resolves.toEqual({ data: { id: 'al-1' } }) - }) - }) -}) diff --git a/ui/src/dialogs/AboutDialog.js b/ui/src/dialogs/AboutDialog.js new file mode 100644 index 000000000..046e2eb77 --- /dev/null +++ b/ui/src/dialogs/AboutDialog.js @@ -0,0 +1,125 @@ +import React from 'react' +import PropTypes from 'prop-types' +import Link from '@material-ui/core/Link' +import Dialog from '@material-ui/core/Dialog' +import IconButton from '@material-ui/core/IconButton' +import TableContainer from '@material-ui/core/TableContainer' +import Table from '@material-ui/core/Table' +import TableBody from '@material-ui/core/TableBody' +import TableRow from '@material-ui/core/TableRow' +import TableCell from '@material-ui/core/TableCell' +import Paper from '@material-ui/core/Paper' +import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' +import inflection from 'inflection' +import { useTranslate } from 'react-admin' +import config from '../config' +import { DialogTitle } from './DialogTitle' +import { DialogContent } from './DialogContent' + +const links = { + homepage: 'navidrome.org', + reddit: 'reddit.com/r/Navidrome', + twitter: 'twitter.com/navidrome', + discord: 'discord.gg/xh7j7yF', + source: 'github.com/navidrome/navidrome', + bugReports: 'github.com/navidrome/navidrome/issues/new/choose', + featureRequests: 'github.com/navidrome/navidrome/discussions/new', +} + +const LinkToVersion = ({ version }) => { + if (version === 'dev') { + return <TableCell align="left">{version}</TableCell> + } + + const parts = version.split(' ') + const commitID = parts[1].replace(/[()]/g, '') + const isSnapshot = version.includes('SNAPSHOT') + const url = isSnapshot + ? `https://github.com/navidrome/navidrome/compare/v${ + parts[0].split('-')[0] + }...${commitID}` + : `https://github.com/navidrome/navidrome/releases/tag/v${parts[0]}` + return ( + <TableCell align="left"> + <Link href={url} target="_blank" rel="noopener noreferrer"> + {parts[0]} + </Link> + {' (' + commitID + ')'} + </TableCell> + ) +} + +const AboutDialog = ({ open, onClose }) => { + const translate = useTranslate() + return ( + <Dialog onClose={onClose} aria-labelledby="about-dialog-title" open={open}> + <DialogTitle id="about-dialog-title" onClose={onClose}> + Navidrome Music Server + </DialogTitle> + <DialogContent dividers> + <TableContainer component={Paper}> + <Table aria-label={translate('menu.about')} size="small"> + <TableBody> + <TableRow> + <TableCell align="right" component="th" scope="row"> + {translate('menu.version')}: + </TableCell> + <LinkToVersion version={config.version} /> + </TableRow> + {Object.keys(links).map((key) => { + return ( + <TableRow key={key}> + <TableCell align="right" component="th" scope="row"> + {translate(`about.links.${key}`, { + _: inflection.humanize(inflection.underscore(key)), + })} + : + </TableCell> + <TableCell align="left"> + <Link + href={`https://${links[key]}`} + target="_blank" + rel="noopener noreferrer" + > + {links[key]} + </Link> + </TableCell> + </TableRow> + ) + })} + <TableRow> + <TableCell align="right" component="th" scope="row"> + <Link + href={'https://github.com/sponsors/deluan'} + target="_blank" + rel="noopener noreferrer" + > + <IconButton size={'small'}> + <FavoriteBorderIcon fontSize={'small'} /> + </IconButton> + </Link> + </TableCell> + <TableCell align="left"> + <Link + href={'https://ko-fi.com/deluan'} + target="_blank" + rel="noopener noreferrer" + > + ko-fi.com/deluan + </Link> + </TableCell> + </TableRow> + </TableBody> + </Table> + </TableContainer> + </DialogContent> + </Dialog> + ) +} + +AboutDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +} + +export { AboutDialog, LinkToVersion } diff --git a/ui/src/dialogs/AboutDialog.jsx b/ui/src/dialogs/AboutDialog.jsx deleted file mode 100644 index 486cc0492..000000000 --- a/ui/src/dialogs/AboutDialog.jsx +++ /dev/null @@ -1,499 +0,0 @@ -import React, { useEffect, useState } from 'react' -import PropTypes from 'prop-types' -import Link from '@material-ui/core/Link' -import Dialog from '@material-ui/core/Dialog' -import IconButton from '@material-ui/core/IconButton' -import TableContainer from '@material-ui/core/TableContainer' -import Table from '@material-ui/core/Table' -import TableBody from '@material-ui/core/TableBody' -import TableRow from '@material-ui/core/TableRow' -import TableCell from '@material-ui/core/TableCell' -import Paper from '@material-ui/core/Paper' -import CloudDownloadIcon from '@material-ui/icons/CloudDownload' -import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' -import FileCopyIcon from '@material-ui/icons/FileCopy' -import Button from '@material-ui/core/Button' -import { humanize, underscore } from 'inflection' -import { useGetOne, usePermissions, useTranslate, useNotify } from 'react-admin' -import { Tabs, Tab } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import config from '../config' -import { DialogTitle } from './DialogTitle' -import { DialogContent } from './DialogContent' -import { INSIGHTS_DOC_URL } from '../consts.js' -import subsonic from '../subsonic/index.js' -import { Typography } from '@material-ui/core' -import TableHead from '@material-ui/core/TableHead' -import { configToToml, separateAndSortConfigs } from './aboutUtils' - -const useStyles = makeStyles((theme) => ({ - configNameColumn: { - maxWidth: '200px', - width: '200px', - wordWrap: 'break-word', - overflowWrap: 'break-word', - }, - envVarColumn: { - maxWidth: '250px', - width: '250px', - fontFamily: 'monospace', - wordWrap: 'break-word', - overflowWrap: 'break-word', - }, - copyButton: { - marginBottom: theme.spacing(2), - marginTop: theme.spacing(1), - }, - devSectionHeader: { - '& td': { - paddingTop: theme.spacing(2), - paddingBottom: theme.spacing(2), - borderTop: `2px solid ${theme.palette.divider}`, - borderBottom: `1px solid ${theme.palette.divider}`, - textAlign: 'left', - fontWeight: 600, - }, - }, - configContainer: { - paddingTop: theme.spacing(1), - }, - tableContainer: { - maxHeight: '60vh', - overflow: 'auto', - }, - devFlagsTitle: { - fontWeight: 600, - }, - expandableDialog: { - transition: 'max-width 300ms ease', - }, -})) - -const links = { - homepage: 'navidrome.org', - reddit: 'reddit.com/r/Navidrome', - twitter: 'twitter.com/navidrome', - discord: 'discord.gg/xh7j7yF', - source: 'github.com/navidrome/navidrome', - bugReports: 'github.com/navidrome/navidrome/issues/new/choose', - featureRequests: 'github.com/navidrome/navidrome/discussions/new', -} - -const LinkToVersion = ({ version }) => { - if (version === 'dev') { - return <>{version}</> - } - - const parts = version.split(' ') - const commitID = parts[1].replace(/[()]/g, '') - const isSnapshot = version.includes('SNAPSHOT') - const url = isSnapshot - ? `https://github.com/navidrome/navidrome/compare/v${ - parts[0].split('-')[0] - }...${commitID}` - : `https://github.com/navidrome/navidrome/releases/tag/v${parts[0]}` - return ( - <> - <Link href={url} target="_blank" rel="noopener noreferrer"> - {parts[0]} - </Link> - {' (' + commitID + ')'} - </> - ) -} - -const ShowVersion = ({ uiVersion, serverVersion }) => { - const translate = useTranslate() - const showRefresh = uiVersion !== serverVersion - - return ( - <> - <TableRow> - <TableCell align="right" component="th" scope="row"> - {translate('menu.version')}: - </TableCell> - <TableCell align="left"> - <LinkToVersion version={serverVersion} /> - </TableCell> - </TableRow> - {showRefresh && ( - <TableRow> - <TableCell align="right" component="th" scope="row"> - UI {translate('menu.version')}: - </TableCell> - <TableCell align="left"> - <div> - <LinkToVersion version={uiVersion} /> - </div> - <div> - <Link onClick={() => window.location.reload()}> - <Typography variant={'caption'}> - {translate('ra.notification.new_version')} - </Typography> - </Link> - </div> - </TableCell> - </TableRow> - )} - </> - ) -} - -const AboutTabContent = ({ - uiVersion, - serverVersion, - insightsData, - loading, - permissions, -}) => { - const translate = useTranslate() - - const lastRun = !loading && insightsData?.lastRun - let insightsStatus = 'N/A' - if (lastRun === 'disabled') { - insightsStatus = translate('about.links.insights.disabled') - } else if (lastRun && lastRun?.startsWith('1969-12-31')) { - insightsStatus = translate('about.links.insights.waiting') - } else if (lastRun) { - insightsStatus = lastRun - } - - return ( - <Table aria-label={translate('menu.about')} size="small"> - <TableBody> - <ShowVersion uiVersion={uiVersion} serverVersion={serverVersion} /> - {Object.keys(links).map((key) => { - return ( - <TableRow key={key}> - <TableCell align="right" component="th" scope="row"> - {translate(`about.links.${key}`, { - _: humanize(underscore(key)), - })} - : - </TableCell> - <TableCell align="left"> - <Link - href={`https://${links[key]}`} - target="_blank" - rel="noopener noreferrer" - > - {links[key]} - </Link> - </TableCell> - </TableRow> - ) - })} - {permissions === 'admin' ? ( - <TableRow> - <TableCell align="right" component="th" scope="row"> - {translate(`about.links.lastInsightsCollection`)}: - </TableCell> - <TableCell align="left"> - <Link href={INSIGHTS_DOC_URL}>{insightsStatus}</Link> - </TableCell> - </TableRow> - ) : null} - <TableRow> - <TableCell align="right" component="th" scope="row"> - <Link - href={'https://github.com/sponsors/deluan'} - target="_blank" - rel="noopener noreferrer" - > - <IconButton size={'small'}> - <FavoriteBorderIcon fontSize={'small'} /> - </IconButton> - </Link> - </TableCell> - <TableCell align="left"> - <Link - href={'https://ko-fi.com/deluan'} - target="_blank" - rel="noopener noreferrer" - > - ko-fi.com/deluan - </Link> - </TableCell> - </TableRow> - </TableBody> - </Table> - ) -} - -const ConfigTabContent = ({ configData }) => { - const classes = useStyles() - const translate = useTranslate() - const notify = useNotify() - - if (!configData || !configData.config) { - return null - } - - // Use the shared separation and sorting logic - const { regularConfigs, devConfigs } = separateAndSortConfigs( - configData.config, - ) - - const handleCopyToml = async () => { - try { - const tomlContent = configToToml(configData, translate) - await navigator.clipboard.writeText(tomlContent) - notify(translate('about.config.exportSuccess'), 'info') - } catch (err) { - // eslint-disable-next-line no-console - console.error('Failed to copy TOML:', err) - notify(translate('about.config.exportFailed'), 'error') - } - } - - const handleDownloadToml = () => { - const tomlContent = configToToml(configData, translate) - const tomlFile = new File([tomlContent], 'navidrome.toml', { - type: 'text/plain', - }) - - const tomlFileLink = document.createElement('a') - const tomlFileUrl = URL.createObjectURL(tomlFile) - tomlFileLink.href = tomlFileUrl - tomlFileLink.download = tomlFile.name - tomlFileLink.click() - - URL.revokeObjectURL(tomlFileUrl) - } - - return ( - <div className={classes.configContainer}> - <Button - variant="outlined" - startIcon={<FileCopyIcon />} - onClick={handleCopyToml} - className={classes.copyButton} - disabled={ - !configData || !navigator.clipboard || !window.isSecureContext - } - size="small" - > - {translate('about.config.exportToml')} - </Button> - <Button - variant="outlined" - startIcon={<CloudDownloadIcon />} - onClick={handleDownloadToml} - className={classes.copyButton} - disabled={!configData} - size="small" - > - {translate('about.config.downloadToml')} - </Button> - <TableContainer className={classes.tableContainer}> - <Table size="small" stickyHeader> - <TableHead> - <TableRow> - <TableCell - align="left" - component="th" - scope="col" - className={classes.configNameColumn} - > - {translate('about.config.configName')} - </TableCell> - <TableCell align="left" component="th" scope="col"> - {translate('about.config.environmentVariable')} - </TableCell> - <TableCell align="left" component="th" scope="col"> - {translate('about.config.currentValue')} - </TableCell> - </TableRow> - </TableHead> - <TableBody> - {configData?.configFile && ( - <TableRow> - <TableCell - align="left" - component="th" - scope="row" - className={classes.configNameColumn} - > - {translate('about.config.configurationFile')} - </TableCell> - <TableCell align="left" className={classes.envVarColumn}> - ND_CONFIGFILE - </TableCell> - <TableCell align="left">{configData.configFile}</TableCell> - </TableRow> - )} - {regularConfigs.map(({ key, envVar, value }) => ( - <TableRow key={key}> - <TableCell - align="left" - component="th" - scope="row" - className={classes.configNameColumn} - > - {key} - </TableCell> - <TableCell align="left" className={classes.envVarColumn}> - {envVar} - </TableCell> - <TableCell align="left">{String(value)}</TableCell> - </TableRow> - ))} - {devConfigs.length > 0 && ( - <TableRow className={classes.devSectionHeader}> - <TableCell colSpan={3}> - <Typography - variant="subtitle1" - component="div" - className={classes.devFlagsTitle} - > - 🚧 {translate('about.config.devFlagsHeader')} - </Typography> - </TableCell> - </TableRow> - )} - {devConfigs.map(({ key, envVar, value }) => ( - <TableRow key={key}> - <TableCell - align="left" - component="th" - scope="row" - className={classes.configNameColumn} - > - {key} - </TableCell> - <TableCell align="left" className={classes.envVarColumn}> - {envVar} - </TableCell> - <TableCell align="left">{String(value)}</TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </TableContainer> - </div> - ) -} - -const TabContent = ({ - tab, - setTab, - showConfigTab, - uiVersion, - serverVersion, - insightsData, - loading, - permissions, - configData, -}) => { - const translate = useTranslate() - - return ( - <TableContainer component={Paper}> - {showConfigTab && ( - <Tabs value={tab} onChange={(_, value) => setTab(value)}> - <Tab - label={translate('about.tabs.about')} - id="about-tab" - aria-controls="about-panel" - /> - <Tab - label={translate('about.tabs.config')} - id="config-tab" - aria-controls="config-panel" - /> - </Tabs> - )} - <div - id="about-panel" - role="tabpanel" - aria-labelledby="about-tab" - hidden={showConfigTab && tab === 1} - > - <AboutTabContent - uiVersion={uiVersion} - serverVersion={serverVersion} - insightsData={insightsData} - loading={loading} - permissions={permissions} - /> - </div> - {showConfigTab && ( - <div - id="config-panel" - role="tabpanel" - aria-labelledby="config-tab" - hidden={tab === 0} - > - <ConfigTabContent configData={configData} /> - </div> - )} - </TableContainer> - ) -} - -const AboutDialog = ({ open, onClose }) => { - const classes = useStyles() - const { permissions } = usePermissions() - const { data: insightsData, loading } = useGetOne( - 'insights', - 'insights_status', - ) - const [serverVersion, setServerVersion] = useState('') - const showConfigTab = permissions === 'admin' && config.devUIShowConfig - const [tab, setTab] = useState(0) - const { data: configData } = useGetOne('config', 'config', { - enabled: showConfigTab, - }) - const expanded = showConfigTab && tab === 1 - const uiVersion = config.version - - useEffect(() => { - subsonic - .ping() - .then((resp) => resp.json['subsonic-response']) - .then((data) => { - if (data.status === 'ok') { - setServerVersion(data.serverVersion) - } - }) - .catch((e) => { - // eslint-disable-next-line no-console - console.error('error pinging server', e) - }) - }, [setServerVersion]) - - return ( - <Dialog - onClose={onClose} - aria-labelledby="about-dialog-title" - open={open} - fullWidth={true} - maxWidth={expanded ? 'lg' : 'sm'} - className={classes.expandableDialog} - > - <DialogTitle id="about-dialog-title" onClose={onClose}> - Navidrome Music Server - </DialogTitle> - <DialogContent dividers> - <TabContent - tab={tab} - setTab={setTab} - showConfigTab={showConfigTab} - uiVersion={uiVersion} - serverVersion={serverVersion} - insightsData={insightsData} - loading={loading} - permissions={permissions} - configData={configData} - /> - </DialogContent> - </Dialog> - ) -} - -AboutDialog.propTypes = { - open: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, -} - -export { AboutDialog, LinkToVersion } diff --git a/ui/src/dialogs/AboutDialog.test.jsx b/ui/src/dialogs/AboutDialog.test.js similarity index 91% rename from ui/src/dialogs/AboutDialog.test.jsx rename to ui/src/dialogs/AboutDialog.test.js index a751930cd..389034fec 100644 --- a/ui/src/dialogs/AboutDialog.test.jsx +++ b/ui/src/dialogs/AboutDialog.test.js @@ -4,15 +4,12 @@ import { LinkToVersion } from './AboutDialog' import TableBody from '@material-ui/core/TableBody' import TableRow from '@material-ui/core/TableRow' import Table from '@material-ui/core/Table' -import TableCell from '@material-ui/core/TableCell' const Wrapper = ({ version }) => ( <Table> <TableBody> <TableRow> - <TableCell> - <LinkToVersion version={version} /> - </TableCell> + <LinkToVersion version={version} /> </TableRow> </TableBody> </Table> @@ -33,7 +30,7 @@ describe('<LinkToVersion />', () => { const link = screen.queryByRole('link') expect(link.href).toBe( - 'https://github.com/navidrome/navidrome/releases/tag/v0.40.0', + 'https://github.com/navidrome/navidrome/releases/tag/v0.40.0' ) expect(link.textContent).toBe('0.40.0') @@ -47,7 +44,7 @@ describe('<LinkToVersion />', () => { const link = screen.queryByRole('link') expect(link.href).toBe( - 'https://github.com/navidrome/navidrome/compare/v0.40.0...300a0292', + 'https://github.com/navidrome/navidrome/compare/v0.40.0...300a0292' ) expect(link.textContent).toBe('0.40.0-SNAPSHOT') diff --git a/ui/src/dialogs/AddToPlaylistDialog.jsx b/ui/src/dialogs/AddToPlaylistDialog.js similarity index 85% rename from ui/src/dialogs/AddToPlaylistDialog.jsx rename to ui/src/dialogs/AddToPlaylistDialog.js index 91521d1ce..47098f22f 100644 --- a/ui/src/dialogs/AddToPlaylistDialog.jsx +++ b/ui/src/dialogs/AddToPlaylistDialog.js @@ -12,7 +12,6 @@ import { DialogActions, DialogContent, DialogTitle, - makeStyles, } from '@material-ui/core' import { closeAddToPlaylist, @@ -24,21 +23,7 @@ import DuplicateSongDialog from './DuplicateSongDialog' import { httpClient } from '../dataProvider' import { REST_URL } from '../consts' -const useStyles = makeStyles({ - dialogPaper: { - height: '26em', - maxHeight: '26em', - }, - dialogContent: { - height: '17.5em', - overflowY: 'auto', - paddingTop: '0.5em', - paddingBottom: '0.5em', - }, -}) - export const AddToPlaylistDialog = () => { - const classes = useStyles() const { open, selectedIds, onSuccess, duplicateSong, duplicateIds } = useSelector((state) => state.addToPlaylistDialog) const dispatch = useDispatch() @@ -69,19 +54,15 @@ export const AddToPlaylistDialog = () => { }) .then(() => { const len = trackIds.length - notify('message.songsAddedToPlaylist', { - messageArgs: { smart_count: len }, - }) + notify('message.songsAddedToPlaylist', 'info', { smart_count: len }) onSuccess && onSuccess(value, len) refresh() }) .catch(() => { - notify('ra.page.error', { type: 'warning' }) + notify('ra.page.error', 'warning') }) } else { - notify('message.songsAddedToPlaylist', { - messageArgs: { smart_count: 0 }, - }) + notify('message.songsAddedToPlaylist', 'info', { smart_count: 0 }) } } @@ -91,7 +72,7 @@ export const AddToPlaylistDialog = () => { const tracks = res.json if (tracks) { const dupSng = tracks.filter((song) => - selectedIds.some((id) => id === song.mediaFileId), + selectedIds.some((id) => id === song.mediaFileId) ) if (dupSng.length) { @@ -101,7 +82,8 @@ export const AddToPlaylistDialog = () => { } setCheck(true) }) - .catch(() => { + .catch((error) => { + console.error(error) notify('ra.page.error', 'warning') }) } @@ -146,7 +128,7 @@ export const AddToPlaylistDialog = () => { } const handleSkip = () => { const distinctSongs = selectedIds.filter( - (id) => duplicateIds.indexOf(id) < 0, + (id) => duplicateIds.indexOf(id) < 0 ) value.slice(-1).pop().distinctIds = distinctSongs dispatch(closeDuplicateSongDialog()) @@ -160,14 +142,11 @@ export const AddToPlaylistDialog = () => { aria-labelledby="form-dialog-new-playlist" fullWidth={true} maxWidth={'sm'} - classes={{ - paper: classes.dialogPaper, - }} > <DialogTitle id="form-dialog-new-playlist"> {translate('resources.playlist.actions.selectPlaylist')} </DialogTitle> - <DialogContent className={classes.dialogContent}> + <DialogContent> <SelectPlaylistInput onChange={handleChange} /> </DialogContent> <DialogActions> diff --git a/ui/src/dialogs/AddToPlaylistDialog.test.jsx b/ui/src/dialogs/AddToPlaylistDialog.test.js similarity index 78% rename from ui/src/dialogs/AddToPlaylistDialog.test.jsx rename to ui/src/dialogs/AddToPlaylistDialog.test.js index 60d3cca0d..0932af306 100644 --- a/ui/src/dialogs/AddToPlaylistDialog.test.jsx +++ b/ui/src/dialogs/AddToPlaylistDialog.test.js @@ -9,7 +9,6 @@ import { screen, } from '@testing-library/react' import { AddToPlaylistDialog } from './AddToPlaylistDialog' -import { describe, beforeAll, afterEach, it, expect, vi } from 'vitest' const mockData = [ { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' }, @@ -60,12 +59,12 @@ const createTestUtils = (mockDataProvider) => > <AddToPlaylistDialog /> </TestContext> - </DataProviderContext.Provider>, + </DataProviderContext.Provider> ) -vi.mock('../dataProvider', () => ({ - ...vi.importActual('../dataProvider'), - httpClient: vi.fn(), +jest.mock('../dataProvider', () => ({ + ...jest.requireActual('../dataProvider'), + httpClient: jest.fn(), })) describe('AddToPlaylistDialog', () => { @@ -73,33 +72,27 @@ describe('AddToPlaylistDialog', () => { afterEach(cleanup) it('adds distinct songs to already existing playlists', async () => { - const dataProvider = await import('../dataProvider') - vi.spyOn(dataProvider, 'httpClient').mockResolvedValue({ data: mockData }) + const dataProvider = require('../dataProvider') + jest.spyOn(dataProvider, 'httpClient').mockResolvedValue({ data: mockData }) const mockDataProvider = { - getList: vi + getList: jest .fn() .mockResolvedValue({ data: mockData, total: mockData.length }), - getOne: vi.fn().mockResolvedValue({ data: { id: 'song-3' }, total: 1 }), - create: vi.fn().mockResolvedValue({ + getOne: jest.fn().mockResolvedValue({ data: { id: 'song-3' }, total: 1 }), + create: jest.fn().mockResolvedValue({ data: { id: 'created-id', name: 'created-name' }, }), } createTestUtils(mockDataProvider) - // Filter to see sample playlists let textBox = screen.getByRole('textbox') fireEvent.change(textBox, { target: { value: 'sample' } }) - - // Click on first playlist - const firstPlaylist = screen.getByText('sample playlist 1') - fireEvent.click(firstPlaylist) - - // Click on second playlist - const secondPlaylist = screen.getByText('sample playlist 2') - fireEvent.click(secondPlaylist) - + fireEvent.keyDown(textBox, { key: 'ArrowDown' }) + fireEvent.keyDown(textBox, { key: 'Enter' }) + fireEvent.keyDown(textBox, { key: 'ArrowDown' }) + fireEvent.keyDown(textBox, { key: 'Enter' }) await waitFor(() => { expect(screen.getByTestId('playlist-add')).not.toBeDisabled() }) @@ -111,7 +104,7 @@ describe('AddToPlaylistDialog', () => { { data: { ids: selectedIds }, filter: { playlist_id: 'sample-id1' }, - }, + } ) }) await waitFor(() => { @@ -121,29 +114,30 @@ describe('AddToPlaylistDialog', () => { { data: { ids: selectedIds }, filter: { playlist_id: 'sample-id2' }, - }, + } ) }) }) it('adds distinct songs to a new playlist', async () => { const mockDataProvider = { - getList: vi + getList: jest .fn() .mockResolvedValue({ data: mockData, total: mockData.length }), - getOne: vi.fn().mockResolvedValue({ data: { id: 'song-3' }, total: 1 }), - create: vi.fn().mockResolvedValue({ + getOne: jest.fn().mockResolvedValue({ data: { id: 'song-3' }, total: 1 }), + create: jest.fn().mockResolvedValue({ data: { id: 'created-id1', name: 'created-name' }, }), } createTestUtils(mockDataProvider) - // Type a new playlist name and press Enter to create it let textBox = screen.getByRole('textbox') fireEvent.change(textBox, { target: { value: 'sample' } }) + fireEvent.keyDown(textBox, { key: 'ArrowDown' }) + fireEvent.keyDown(textBox, { key: 'ArrowDown' }) + fireEvent.keyDown(textBox, { key: 'ArrowDown' }) fireEvent.keyDown(textBox, { key: 'Enter' }) - await waitFor(() => { expect(screen.getByTestId('playlist-add')).not.toBeDisabled() }) @@ -159,32 +153,31 @@ describe('AddToPlaylistDialog', () => { { data: { ids: selectedIds }, filter: { playlist_id: 'created-id1' }, - }, + } ) }) it('adds distinct songs to multiple new playlists', async () => { const mockDataProvider = { - getList: vi + getList: jest .fn() .mockResolvedValue({ data: mockData, total: mockData.length }), - getOne: vi.fn().mockResolvedValue({ data: { id: 'song-3' }, total: 1 }), - create: vi.fn().mockResolvedValue({ + getOne: jest.fn().mockResolvedValue({ data: { id: 'song-3' }, total: 1 }), + create: jest.fn().mockResolvedValue({ data: { id: 'created-id1', name: 'created-name' }, }), } createTestUtils(mockDataProvider) - // Create first playlist let textBox = screen.getByRole('textbox') fireEvent.change(textBox, { target: { value: 'sample' } }) + fireEvent.keyDown(textBox, { key: 'ArrowDown' }) + fireEvent.keyDown(textBox, { key: 'ArrowDown' }) + fireEvent.keyDown(textBox, { key: 'ArrowDown' }) fireEvent.keyDown(textBox, { key: 'Enter' }) - - // Create second playlist fireEvent.change(textBox, { target: { value: 'new playlist' } }) fireEvent.keyDown(textBox, { key: 'Enter' }) - await waitFor(() => { expect(screen.getByTestId('playlist-add')).not.toBeDisabled() }) diff --git a/ui/src/dialogs/DialogContent.jsx b/ui/src/dialogs/DialogContent.js similarity index 100% rename from ui/src/dialogs/DialogContent.jsx rename to ui/src/dialogs/DialogContent.js diff --git a/ui/src/dialogs/DialogTitle.jsx b/ui/src/dialogs/DialogTitle.js similarity index 100% rename from ui/src/dialogs/DialogTitle.jsx rename to ui/src/dialogs/DialogTitle.js diff --git a/ui/src/dialogs/Dialogs.jsx b/ui/src/dialogs/Dialogs.js similarity index 81% rename from ui/src/dialogs/Dialogs.jsx rename to ui/src/dialogs/Dialogs.js index f37c55823..74f5a094d 100644 --- a/ui/src/dialogs/Dialogs.jsx +++ b/ui/src/dialogs/Dialogs.js @@ -2,12 +2,10 @@ import { AddToPlaylistDialog } from './AddToPlaylistDialog' import DownloadMenuDialog from './DownloadMenuDialog' import { HelpDialog } from './HelpDialog' import { ShareDialog } from './ShareDialog' -import { SaveQueueDialog } from './SaveQueueDialog' export const Dialogs = (props) => ( <> <AddToPlaylistDialog /> - <SaveQueueDialog /> <DownloadMenuDialog /> <HelpDialog /> <ShareDialog /> diff --git a/ui/src/dialogs/DownloadMenuDialog.jsx b/ui/src/dialogs/DownloadMenuDialog.js similarity index 84% rename from ui/src/dialogs/DownloadMenuDialog.jsx rename to ui/src/dialogs/DownloadMenuDialog.js index 61e84a08a..290431598 100644 --- a/ui/src/dialogs/DownloadMenuDialog.jsx +++ b/ui/src/dialogs/DownloadMenuDialog.js @@ -8,14 +8,13 @@ import { DialogTitle, } from '@material-ui/core' import subsonic from '../subsonic' -import { closeDownloadMenu, DOWNLOAD_MENU_ARTIST } from '../actions' +import { closeDownloadMenu } from '../actions' import { formatBytes } from '../utils' -import { artistDownloadSize } from '../common/artist' import { useTranscodingOptions } from './useTranscodingOptions' const DownloadMenuDialog = () => { const { open, record, recordType } = useSelector( - (state) => state.downloadMenuDialog, + (state) => state.downloadMenuDialog ) const dispatch = useDispatch() const translate = useTranslate() @@ -23,12 +22,6 @@ const DownloadMenuDialog = () => { const { TranscodingOptionsInput, format, maxBitRate, originalFormat } = useTranscodingOptions() - // Artist downloads only include album-artist songs, so show that size - const downloadSize = - recordType === DOWNLOAD_MENU_ARTIST - ? artistDownloadSize(record) - : record?.size - const handleClose = (e) => { dispatch(closeDownloadMenu()) e.stopPropagation() @@ -62,7 +55,7 @@ const DownloadMenuDialog = () => { smart_count: 1, }).toLocaleLowerCase(), name: record?.name || record?.title, - size: formatBytes(downloadSize), + size: formatBytes(record?.size), })} </DialogTitle> <DialogContent> diff --git a/ui/src/dialogs/DownloadMenuDialog.test.jsx b/ui/src/dialogs/DownloadMenuDialog.test.jsx deleted file mode 100644 index b9ce7f077..000000000 --- a/ui/src/dialogs/DownloadMenuDialog.test.jsx +++ /dev/null @@ -1,58 +0,0 @@ -import * as React from 'react' -import { TestContext } from 'ra-test' -import { render, screen, cleanup } from '@testing-library/react' -import { describe, afterEach, it, expect, vi } from 'vitest' -import DownloadMenuDialog from './DownloadMenuDialog' -import { DOWNLOAD_MENU_ALBUM, DOWNLOAD_MENU_ARTIST } from '../actions' - -vi.mock('./useTranscodingOptions', () => ({ - useTranscodingOptions: () => ({ - TranscodingOptionsInput: () => null, - format: '', - maxBitRate: 0, - originalFormat: true, - }), -})) - -vi.mock('react-admin', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useTranslate: () => (key, opts) => - opts?.size ? `${key}:${opts.name}:${opts.size}` : key, - } -}) - -const renderDialog = (record, recordType) => - render( - <TestContext - initialState={{ downloadMenuDialog: { open: true, record, recordType } }} - > - <DownloadMenuDialog /> - </TestContext>, - ) - -describe('DownloadMenuDialog', () => { - afterEach(cleanup) - - it('shows the album-artist size (not the total) for an artist download', () => { - renderDialog( - { - id: 'ar1', - name: 'Artist', - size: 999999999, - stats: { albumartist: { size: 1024 } }, - }, - DOWNLOAD_MENU_ARTIST, - ) - expect(screen.getByText(/:Artist:1 KB$/)).toBeInTheDocument() - }) - - it('shows the total size for an album download', () => { - renderDialog( - { id: 'al1', name: 'Album', size: 1024 * 1024 }, - DOWNLOAD_MENU_ALBUM, - ) - expect(screen.getByText(/:Album:1 MB$/)).toBeInTheDocument() - }) -}) diff --git a/ui/src/dialogs/DuplicateSongDialog.jsx b/ui/src/dialogs/DuplicateSongDialog.js similarity index 100% rename from ui/src/dialogs/DuplicateSongDialog.jsx rename to ui/src/dialogs/DuplicateSongDialog.js diff --git a/ui/src/dialogs/ExpandInfoDialog.jsx b/ui/src/dialogs/ExpandInfoDialog.js similarity index 98% rename from ui/src/dialogs/ExpandInfoDialog.jsx rename to ui/src/dialogs/ExpandInfoDialog.js index be84546fc..c94d7e30f 100644 --- a/ui/src/dialogs/ExpandInfoDialog.jsx +++ b/ui/src/dialogs/ExpandInfoDialog.js @@ -27,7 +27,7 @@ const ExpandInfoDialog = ({ title, content }) => { onClose={handleClose} aria-labelledby="info-dialog-album" fullWidth={true} - maxWidth={'md'} + maxWidth={'sm'} > <DialogTitle id="info-dialog-album"> {translate(title || 'resources.song.actions.info')} diff --git a/ui/src/dialogs/HelpDialog.jsx b/ui/src/dialogs/HelpDialog.js similarity index 96% rename from ui/src/dialogs/HelpDialog.jsx rename to ui/src/dialogs/HelpDialog.js index 1aa9db60e..bba473813 100644 --- a/ui/src/dialogs/HelpDialog.jsx +++ b/ui/src/dialogs/HelpDialog.js @@ -9,7 +9,7 @@ import TableBody from '@material-ui/core/TableBody' import TableRow from '@material-ui/core/TableRow' import TableCell from '@material-ui/core/TableCell' import { useTranslate } from 'react-admin' -import { humanize } from 'inflection' +import inflection from 'inflection' import { keyMap } from '../hotkeys' import { DialogTitle } from './DialogTitle' import { DialogContent } from './DialogContent' @@ -29,7 +29,7 @@ const HelpTable = (props) => { {Object.keys(keyMap).map((key) => { const { sequences, name } = keyMap[key] const description = translate(`help.hotkeys.${name}`, { - _: humanize(name), + _: inflection.humanize(name), }) return ( <TableRow key={key}> @@ -54,7 +54,7 @@ const HelpTable = (props) => { </TableContainer> </DialogContent> </Dialog>, - document.body, + document.body ) } diff --git a/ui/src/dialogs/ListenBrainzTokenDialog.jsx b/ui/src/dialogs/ListenBrainzTokenDialog.js similarity index 98% rename from ui/src/dialogs/ListenBrainzTokenDialog.jsx rename to ui/src/dialogs/ListenBrainzTokenDialog.js index 8966675ff..571c5af17 100644 --- a/ui/src/dialogs/ListenBrainzTokenDialog.jsx +++ b/ui/src/dialogs/ListenBrainzTokenDialog.js @@ -58,7 +58,7 @@ export const ListenBrainzTokenDialog = ({ setLinked }) => { event.stopPropagation() }) }, - [dispatch, notify, setLinked, token], + [dispatch, notify, setLinked, token] ) const handleClickClose = (event) => { @@ -74,7 +74,7 @@ export const ListenBrainzTokenDialog = ({ setLinked }) => { handleSave(event) } }, - [token, handleSave], + [token, handleSave] ) return ( diff --git a/ui/src/dialogs/QuickConnectDialog.jsx b/ui/src/dialogs/QuickConnectDialog.jsx deleted file mode 100644 index cde7e9eaf..000000000 --- a/ui/src/dialogs/QuickConnectDialog.jsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useState } from 'react' -import PropTypes from 'prop-types' -import { useDataProvider, useNotify, useTranslate } from 'react-admin' -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, - TextField, -} from '@material-ui/core' - -export const QuickConnectDialog = ({ open, onClose }) => { - const translate = useTranslate() - const notify = useNotify() - const dataProvider = useDataProvider() - const [code, setCode] = useState('') - const [pending, setPending] = useState(null) - const [loading, setLoading] = useState(false) - - const handleClose = () => { - setCode('') - setPending(null) - onClose() - } - - const handleError = (error) => { - setPending(null) - const invalid = error?.status === 404 || error?.status === 409 - notify( - invalid ? 'message.quickConnectInvalidCode' : 'message.quickConnectError', - 'warning', - ) - } - - const run = (request, onSuccess) => { - setLoading(true) - request - .then(({ data }) => onSuccess(data)) - .catch(handleError) - .finally(() => setLoading(false)) - } - - const lookup = (event) => { - event.preventDefault() - run(dataProvider.lookupQuickConnect(code), setPending) - } - - const approve = () => - run(dataProvider.authorizeQuickConnect(code), (data) => { - notify('message.quickConnectApproved', 'success', { - app: data.appName, - device: data.deviceName, - }) - handleClose() - }) - - return ( - <Dialog - open={open} - onClose={handleClose} - aria-labelledby="quick-connect-dialog" - fullWidth - maxWidth="xs" - > - <DialogTitle id="quick-connect-dialog"> - {translate('menu.quickConnect.name')} - </DialogTitle> - {pending ? ( - <> - <DialogContent> - <DialogContentText> - {translate('menu.quickConnect.confirm', { - app: pending.appName, - version: pending.appVersion, - device: pending.deviceName, - })} - </DialogContentText> - </DialogContent> - <DialogActions> - <Button onClick={() => setPending(null)} color="primary"> - {translate('ra.action.back')} - </Button> - <Button onClick={approve} color="primary" disabled={loading}> - {translate('menu.quickConnect.approve')} - </Button> - </DialogActions> - </> - ) : ( - <form onSubmit={lookup}> - <DialogContent> - <DialogContentText> - {translate('menu.quickConnect.help')} - </DialogContentText> - <TextField - id="quickConnectCode" - variant="outlined" - fullWidth - autoFocus - label={translate('menu.quickConnect.code')} - value={code} - onChange={(event) => setCode(event.target.value)} - inputProps={{ inputMode: 'numeric', autoComplete: 'off' }} - /> - </DialogContent> - <DialogActions> - <Button onClick={handleClose} color="primary"> - {translate('ra.action.cancel')} - </Button> - <Button - type="submit" - color="primary" - disabled={!code.trim() || loading} - > - {translate('menu.quickConnect.continue')} - </Button> - </DialogActions> - </form> - )} - </Dialog> - ) -} - -QuickConnectDialog.propTypes = { - open: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, -} diff --git a/ui/src/dialogs/QuickConnectDialog.test.jsx b/ui/src/dialogs/QuickConnectDialog.test.jsx deleted file mode 100644 index 5dda1cbdf..000000000 --- a/ui/src/dialogs/QuickConnectDialog.test.jsx +++ /dev/null @@ -1,103 +0,0 @@ -import * as React from 'react' -import { TestContext } from 'ra-test' -import { DataProviderContext } from 'react-admin' -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react' -import { describe, afterEach, it, expect, vi } from 'vitest' -import { QuickConnectDialog } from './QuickConnectDialog' - -const finamp = { appName: 'Finamp', appVersion: '1.0.0', deviceName: 'Pixel 7' } - -const renderDialog = (dataProvider, onClose = vi.fn()) => { - render( - <DataProviderContext.Provider value={dataProvider}> - <TestContext initialState={{ admin: { ui: { optimistic: false } } }}> - <QuickConnectDialog open={true} onClose={onClose} /> - </TestContext> - </DataProviderContext.Provider>, - ) - return onClose -} - -const enterCode = (code) => { - fireEvent.change(screen.getByRole('textbox'), { target: { value: code } }) - fireEvent.click(screen.getByText('menu.quickConnect.continue')) -} - -describe('QuickConnectDialog', () => { - afterEach(cleanup) - - it('shows the device before approving the code', async () => { - const dataProvider = { - lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), - authorizeQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), - } - const onClose = renderDialog(dataProvider) - - enterCode('123 456') - await screen.findByText('menu.quickConnect.approve') - expect(dataProvider.lookupQuickConnect.mock.calls[0][0]).toBe('123 456') - expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled() - - fireEvent.click(screen.getByText('menu.quickConnect.approve')) - await waitFor(() => expect(onClose).toHaveBeenCalled()) - expect(dataProvider.authorizeQuickConnect.mock.calls[0][0]).toBe('123 456') - }) - - it('goes back to the code input', async () => { - const dataProvider = { - lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), - authorizeQuickConnect: vi.fn(), - } - renderDialog(dataProvider) - - enterCode('123456') - await screen.findByText('menu.quickConnect.approve') - fireEvent.click(screen.getByText('ra.action.back')) - - expect(screen.getByRole('textbox')).toHaveValue('123456') - expect(dataProvider.authorizeQuickConnect).not.toHaveBeenCalled() - }) - - it('stays on the code input when the code is unknown', async () => { - const dataProvider = { - lookupQuickConnect: vi.fn().mockRejectedValue({ status: 404 }), - authorizeQuickConnect: vi.fn(), - } - const onClose = renderDialog(dataProvider) - - enterCode('000000') - await waitFor(() => - expect(dataProvider.lookupQuickConnect).toHaveBeenCalled(), - ) - expect(screen.getByRole('textbox')).toBeInTheDocument() - expect(screen.queryByText('menu.quickConnect.approve')).toBeNull() - expect(onClose).not.toHaveBeenCalled() - }) - - it('returns to the code input when approval fails', async () => { - const dataProvider = { - lookupQuickConnect: vi.fn().mockResolvedValue({ data: finamp }), - authorizeQuickConnect: vi.fn().mockRejectedValue({ status: 409 }), - } - const onClose = renderDialog(dataProvider) - - enterCode('123456') - fireEvent.click(await screen.findByText('menu.quickConnect.approve')) - - await screen.findByRole('textbox') - expect(onClose).not.toHaveBeenCalled() - }) - - it('disables Continue until a code is typed', () => { - renderDialog({ lookupQuickConnect: vi.fn() }) - expect( - screen.getByText('menu.quickConnect.continue').closest('button'), - ).toBeDisabled() - }) -}) diff --git a/ui/src/dialogs/SaveQueueDialog.jsx b/ui/src/dialogs/SaveQueueDialog.jsx deleted file mode 100644 index f916a0793..000000000 --- a/ui/src/dialogs/SaveQueueDialog.jsx +++ /dev/null @@ -1,120 +0,0 @@ -import React, { useState, useCallback } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import { - useDataProvider, - useNotify, - useTranslate, - useRefresh, -} from 'react-admin' -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - TextField, - CircularProgress, -} from '@material-ui/core' -import { closeSaveQueueDialog } from '../actions' -import { useHistory } from 'react-router-dom' - -export const SaveQueueDialog = () => { - const dispatch = useDispatch() - const { open } = useSelector((state) => state.saveQueueDialog) - const queue = useSelector((state) => state.player.queue) - const [name, setName] = useState('') - const dataProvider = useDataProvider() - const notify = useNotify() - const translate = useTranslate() - const history = useHistory() - const [isSaving, setIsSaving] = useState(false) - const refresh = useRefresh() - - const handleClose = useCallback( - (e) => { - setName('') - dispatch(closeSaveQueueDialog()) - e.stopPropagation() - }, - [dispatch], - ) - - const handleSave = useCallback(() => { - setIsSaving(true) - const ids = queue.map((item) => item.trackId) - dataProvider - .create('playlist', { data: { name } }) - .then((res) => { - const playlistId = res.data.id - if (ids.length) { - return dataProvider - .create('playlistTrack', { - data: { ids }, - filter: { playlist_id: playlistId }, - }) - .then(() => res) - } - return res - }) - .then((res) => { - notify('ra.notification.created', { - type: 'info', - messageArgs: { smart_count: 1 }, - }) - dispatch(closeSaveQueueDialog()) - refresh() - history.push(`/playlist/${res.data.id}/show`) - }) - .catch(() => notify('ra.page.error', { type: 'warning' })) - .finally(() => setIsSaving(false)) - }, [dataProvider, dispatch, notify, queue, name, history, refresh]) - - const handleKeyPress = useCallback( - (e) => { - if (e.key === 'Enter' && name.trim() !== '') { - handleSave() - } - }, - [handleSave, name], - ) - - return ( - <Dialog - open={open} - onClose={isSaving ? undefined : handleClose} - aria-labelledby="save-queue-dialog" - fullWidth={true} - maxWidth={'sm'} - > - <DialogTitle id="save-queue-dialog"> - {translate('resources.playlist.actions.saveQueue', { _: 'Save Queue' })} - </DialogTitle> - <DialogContent> - <TextField - value={name} - onChange={(e) => setName(e.target.value)} - onKeyPress={handleKeyPress} - autoFocus - fullWidth - variant={'outlined'} - label={translate('resources.playlist.fields.name')} - disabled={isSaving} - /> - </DialogContent> - <DialogActions> - <Button onClick={handleClose} color="primary" disabled={isSaving}> - {translate('ra.action.cancel')} - </Button> - <Button - onClick={handleSave} - color="primary" - disabled={name.trim() === '' || isSaving} - data-testid="save-queue-save" - startIcon={isSaving ? <CircularProgress size={20} /> : null} - > - {translate('ra.action.save')} - </Button> - </DialogActions> - </Dialog> - ) -} diff --git a/ui/src/dialogs/SaveQueueDialog.test.jsx b/ui/src/dialogs/SaveQueueDialog.test.jsx deleted file mode 100644 index c3a91385f..000000000 --- a/ui/src/dialogs/SaveQueueDialog.test.jsx +++ /dev/null @@ -1,91 +0,0 @@ -import * as React from 'react' -import { TestContext } from 'ra-test' -import { DataProviderContext } from 'react-admin' -import { - cleanup, - fireEvent, - render, - waitFor, - screen, -} from '@testing-library/react' -import { SaveQueueDialog } from './SaveQueueDialog' -import { describe, afterEach, it, expect, vi, beforeAll } from 'vitest' - -const queue = [{ trackId: 'song-1' }, { trackId: 'song-2' }] - -const createTestUtils = (mockDataProvider) => - render( - <DataProviderContext.Provider value={mockDataProvider}> - <TestContext - initialState={{ - saveQueueDialog: { open: true }, - player: { queue }, - admin: { ui: { optimistic: false } }, - }} - > - <SaveQueueDialog /> - </TestContext> - </DataProviderContext.Provider>, - ) - -// Mock useHistory to update window.location.hash on push -vi.mock('react-router-dom', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useHistory: () => ({ - push: (url) => { - window.location.hash = `#${url}` - }, - }), - } -}) - -beforeAll(() => { - // No need to patch pushState anymore -}) - -describe('SaveQueueDialog', () => { - afterEach(cleanup) - - it('creates playlist and saves queue', async () => { - const mockDataProvider = { - create: vi - .fn() - .mockResolvedValueOnce({ data: { id: 'created-id' } }) - .mockResolvedValueOnce({ data: { id: 'pt-id' } }), - } - - createTestUtils(mockDataProvider) - - fireEvent.change(screen.getByRole('textbox'), { - target: { value: 'my playlist' }, - }) - fireEvent.click(screen.getByTestId('save-queue-save')) - - await waitFor(() => { - expect(mockDataProvider.create).toHaveBeenNthCalledWith(1, 'playlist', { - data: { name: 'my playlist' }, - }) - }) - await waitFor(() => { - expect(mockDataProvider.create).toHaveBeenNthCalledWith( - 2, - 'playlistTrack', - { - data: { ids: ['song-1', 'song-2'] }, - filter: { playlist_id: 'created-id' }, - }, - ) - }) - await waitFor(() => { - expect(window.location.hash).toBe('#/playlist/created-id/show') - }) - }) - - it('disables save button when name is empty', () => { - const mockDataProvider = { create: vi.fn() } - createTestUtils(mockDataProvider) - expect(screen.getByTestId('save-queue-save')).toBeDisabled() - }) -}) diff --git a/ui/src/dialogs/SelectPlaylistInput.js b/ui/src/dialogs/SelectPlaylistInput.js new file mode 100644 index 000000000..e35af00fa --- /dev/null +++ b/ui/src/dialogs/SelectPlaylistInput.js @@ -0,0 +1,123 @@ +import React from 'react' +import TextField from '@material-ui/core/TextField' +import Checkbox from '@material-ui/core/Checkbox' +import CheckBoxIcon from '@material-ui/icons/CheckBox' +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank' +import Autocomplete, { + createFilterOptions, +} from '@material-ui/lab/Autocomplete' +import { useGetList, useTranslate } from 'react-admin' +import PropTypes from 'prop-types' +import { isWritable } from '../common' +import { makeStyles } from '@material-ui/core' + +const filter = createFilterOptions() + +const useStyles = makeStyles({ + root: { width: '100%' }, + checkbox: { marginRight: 8 }, +}) + +export const SelectPlaylistInput = ({ onChange }) => { + const classes = useStyles() + const translate = useTranslate() + const { ids, data } = useGetList( + 'playlist', + { page: 1, perPage: -1 }, + { field: 'name', order: 'ASC' }, + { smart: false } + ) + + const options = + ids && + ids.map((id) => data[id]).filter((option) => isWritable(option.ownerId)) + + const handleOnChange = (event, newValue) => { + let newState = [] + if (newValue && newValue.length) { + newValue.forEach((playlistObject) => { + if (playlistObject.inputValue) { + newState.push({ + name: playlistObject.inputValue, + }) + } else if (typeof playlistObject === 'string') { + newState.push({ + name: playlistObject, + }) + } else { + newState.push(playlistObject) + } + }) + } + onChange(newState) + } + + const icon = <CheckBoxOutlineBlankIcon fontSize="small" /> + const checkedIcon = <CheckBoxIcon fontSize="small" /> + + return ( + <Autocomplete + multiple + disableCloseOnSelect + onChange={handleOnChange} + filterOptions={(options, params) => { + const filtered = filter(options, params) + + // Suggest the creation of a new value + if (params.inputValue !== '') { + filtered.push({ + inputValue: params.inputValue, + name: translate('resources.playlist.actions.addNewPlaylist', { + name: params.inputValue, + }), + }) + } + + return filtered + }} + clearOnBlur + handleHomeEndKeys + openOnFocus + selectOnFocus + id="select-playlist-input" + options={options} + getOptionLabel={(option) => { + // Value selected with enter, right from the input + if (typeof option === 'string') { + return option + } + // Add "xxx" option created dynamically + if (option.inputValue) { + return option.inputValue + } + // Regular option + return option.name + }} + renderOption={(option, { selected }) => ( + <React.Fragment> + <Checkbox + icon={icon} + checkedIcon={checkedIcon} + className={classes.checkbox} + checked={selected} + /> + {option.name} + </React.Fragment> + )} + className={classes.root} + freeSolo + renderInput={(params) => ( + <TextField + autoFocus + variant={'outlined'} + {...params} + label={translate('resources.playlist.fields.name')} + /> + )} + /> + ) +} + +SelectPlaylistInput.propTypes = { + onChange: PropTypes.func.isRequired, +} diff --git a/ui/src/dialogs/SelectPlaylistInput.jsx b/ui/src/dialogs/SelectPlaylistInput.jsx deleted file mode 100644 index 2f040a7f7..000000000 --- a/ui/src/dialogs/SelectPlaylistInput.jsx +++ /dev/null @@ -1,399 +0,0 @@ -import React, { useState } from 'react' -import TextField from '@material-ui/core/TextField' -import Checkbox from '@material-ui/core/Checkbox' -import CheckBoxIcon from '@material-ui/icons/CheckBox' -import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank' -import { - List, - ListItem, - ListItemIcon, - ListItemText, - Typography, - Box, - InputAdornment, - IconButton, -} from '@material-ui/core' -import AddIcon from '@material-ui/icons/Add' -import { useGetList, useTranslate } from 'react-admin' -import PropTypes from 'prop-types' -import { canChangeTracks } from '../common' -import { makeStyles } from '@material-ui/core' - -const useStyles = makeStyles((theme) => ({ - root: { - width: '100%', - height: '100%', - display: 'flex', - flexDirection: 'column', - }, - searchField: { - marginBottom: theme.spacing(2), - width: '100%', - flexShrink: 0, - }, - playlistList: { - flex: 1, - minHeight: 0, - overflow: 'auto', - border: `1px solid ${theme.palette.divider}`, - borderRadius: theme.shape.borderRadius, - backgroundColor: theme.palette.background.paper, - }, - listItem: { - paddingTop: 0, - paddingBottom: 0, - }, - createIcon: { - fontSize: '1.25rem', - margin: '9px', - }, - selectedPlaylistsContainer: { - marginTop: theme.spacing(2), - flexShrink: 0, - maxHeight: '30%', - overflow: 'auto', - }, - selectedPlaylist: { - display: 'inline-flex', - alignItems: 'center', - margin: theme.spacing(0.5), - padding: theme.spacing(0.5, 1), - backgroundColor: theme.palette.primary.main, - color: theme.palette.primary.contrastText, - borderRadius: theme.shape.borderRadius, - fontSize: '0.875rem', - }, - removeButton: { - marginLeft: theme.spacing(0.5), - padding: 2, - color: 'inherit', - }, - emptyMessage: { - padding: theme.spacing(2), - textAlign: 'center', - color: theme.palette.text.secondary, - }, -})) - -const PlaylistSearchField = ({ - searchText, - onSearchChange, - onCreateNew, - onKeyDown, - canCreateNew, -}) => { - const classes = useStyles() - const translate = useTranslate() - - return ( - <TextField - autoFocus - variant="outlined" - className={classes.searchField} - label={translate('resources.playlist.fields.name')} - value={searchText} - onChange={(e) => onSearchChange(e.target.value)} - onKeyDown={onKeyDown} - placeholder={translate('resources.playlist.actions.searchOrCreate')} - InputProps={{ - endAdornment: canCreateNew && ( - <InputAdornment position="end"> - <IconButton - onClick={onCreateNew} - size="small" - title={translate('resources.playlist.actions.addNewPlaylist', { - name: searchText, - })} - > - <AddIcon /> - </IconButton> - </InputAdornment> - ), - }} - /> - ) -} - -const EmptyPlaylistMessage = ({ searchText, canCreateNew }) => { - const classes = useStyles() - const translate = useTranslate() - - return ( - <div className={classes.emptyMessage}> - <Typography variant="body2"> - {searchText - ? translate('resources.playlist.message.noPlaylistsFound') - : translate('resources.playlist.message.noPlaylists')} - </Typography> - {canCreateNew && ( - <Typography variant="body2" color="primary"> - {translate('resources.playlist.actions.pressEnterToCreate')} - </Typography> - )} - </div> - ) -} - -const PlaylistListItem = ({ playlist, isSelected, onToggle }) => { - const classes = useStyles() - - return ( - <ListItem - className={classes.listItem} - button - onClick={() => onToggle(playlist)} - dense - > - <ListItemIcon> - <Checkbox - icon={<CheckBoxOutlineBlankIcon fontSize="small" />} - checkedIcon={<CheckBoxIcon fontSize="small" />} - checked={isSelected} - tabIndex={-1} - disableRipple - /> - </ListItemIcon> - <ListItemText primary={playlist.name} /> - </ListItem> - ) -} - -const CreatePlaylistItem = ({ searchText, onCreateNew }) => { - const classes = useStyles() - const translate = useTranslate() - - return ( - <ListItem className={classes.listItem} button onClick={onCreateNew} dense> - <ListItemIcon> - <AddIcon className={classes.createIcon} /> - </ListItemIcon> - <ListItemText - primary={translate('resources.playlist.actions.addNewPlaylist', { - name: searchText, - })} - /> - </ListItem> - ) -} - -const PlaylistList = ({ - filteredOptions, - selectedPlaylists, - onPlaylistToggle, - searchText, - canCreateNew, - onCreateNew, -}) => { - const classes = useStyles() - - const isPlaylistSelected = (playlist) => - selectedPlaylists.some((p) => p.id === playlist.id) - - return ( - <List className={classes.playlistList}> - {filteredOptions.length === 0 ? ( - <EmptyPlaylistMessage - searchText={searchText} - canCreateNew={canCreateNew} - /> - ) : ( - filteredOptions.map((playlist) => ( - <PlaylistListItem - key={playlist.id} - playlist={playlist} - isSelected={isPlaylistSelected(playlist)} - onToggle={onPlaylistToggle} - /> - )) - )} - {canCreateNew && filteredOptions.length > 0 && ( - <CreatePlaylistItem searchText={searchText} onCreateNew={onCreateNew} /> - )} - </List> - ) -} - -const SelectedPlaylistChip = ({ playlist, onRemove }) => { - const classes = useStyles() - const translate = useTranslate() - - return ( - <span className={classes.selectedPlaylist}> - {playlist.name} - <IconButton - className={classes.removeButton} - size="small" - onClick={() => onRemove(playlist)} - title={translate('resources.playlist.actions.removeFromSelection')} - > - {'×'} - </IconButton> - </span> - ) -} - -const SelectedPlaylistsDisplay = ({ selectedPlaylists, onRemoveSelected }) => { - const classes = useStyles() - const translate = useTranslate() - - if (selectedPlaylists.length === 0) { - return null - } - - return ( - <Box className={classes.selectedPlaylistsContainer}> - <Box> - {selectedPlaylists.map((playlist, index) => ( - <SelectedPlaylistChip - key={playlist.id || `new-${index}`} - playlist={playlist} - onRemove={onRemoveSelected} - /> - ))} - </Box> - </Box> - ) -} - -export const SelectPlaylistInput = ({ onChange }) => { - const classes = useStyles() - const [searchText, setSearchText] = useState('') - const [selectedPlaylists, setSelectedPlaylists] = useState([]) - - const { ids, data } = useGetList( - 'playlist', - { page: 1, perPage: -1 }, - { field: 'name', order: 'ASC' }, - { smart: false }, - ) - - const options = - ids && ids.map((id) => data[id]).filter((option) => canChangeTracks(option)) - - // Filter playlists based on search text - const filteredOptions = - options?.filter((option) => - option.name.toLowerCase().includes(searchText.toLowerCase()), - ) || [] - - const handlePlaylistToggle = (playlist) => { - const isSelected = selectedPlaylists.some((p) => p.id === playlist.id) - let newSelection - - if (isSelected) { - newSelection = selectedPlaylists.filter((p) => p.id !== playlist.id) - } else { - newSelection = [...selectedPlaylists, playlist] - } - - setSelectedPlaylists(newSelection) - onChange(newSelection) - } - - const handleRemoveSelected = (playlistToRemove) => { - const newSelection = selectedPlaylists.filter( - (p) => p.id !== playlistToRemove.id, - ) - setSelectedPlaylists(newSelection) - onChange(newSelection) - } - - const handleCreateNew = () => { - if (searchText.trim()) { - const newPlaylist = { name: searchText.trim() } - const newSelection = [...selectedPlaylists, newPlaylist] - setSelectedPlaylists(newSelection) - onChange(newSelection) - setSearchText('') - } - } - - const handleKeyDown = (e) => { - if (e.key === 'Enter' && searchText.trim()) { - e.preventDefault() - handleCreateNew() - } - } - - const canCreateNew = Boolean( - searchText.trim() && - !filteredOptions.some( - (option) => option.name.toLowerCase() === searchText.toLowerCase().trim(), - ) && - !selectedPlaylists.some((p) => p.name === searchText.trim()), - ) - - return ( - <div className={classes.root}> - <PlaylistSearchField - searchText={searchText} - onSearchChange={setSearchText} - onCreateNew={handleCreateNew} - onKeyDown={handleKeyDown} - canCreateNew={canCreateNew} - /> - - <PlaylistList - filteredOptions={filteredOptions} - selectedPlaylists={selectedPlaylists} - onPlaylistToggle={handlePlaylistToggle} - searchText={searchText} - canCreateNew={canCreateNew} - onCreateNew={handleCreateNew} - /> - - <SelectedPlaylistsDisplay - selectedPlaylists={selectedPlaylists} - onRemoveSelected={handleRemoveSelected} - /> - </div> - ) -} - -SelectPlaylistInput.propTypes = { - onChange: PropTypes.func.isRequired, -} - -// PropTypes for sub-components -PlaylistSearchField.propTypes = { - searchText: PropTypes.string.isRequired, - onSearchChange: PropTypes.func.isRequired, - onCreateNew: PropTypes.func.isRequired, - onKeyDown: PropTypes.func.isRequired, - canCreateNew: PropTypes.bool.isRequired, -} - -EmptyPlaylistMessage.propTypes = { - searchText: PropTypes.string.isRequired, - canCreateNew: PropTypes.bool.isRequired, -} - -PlaylistListItem.propTypes = { - playlist: PropTypes.object.isRequired, - isSelected: PropTypes.bool.isRequired, - onToggle: PropTypes.func.isRequired, -} - -CreatePlaylistItem.propTypes = { - searchText: PropTypes.string.isRequired, - onCreateNew: PropTypes.func.isRequired, -} - -PlaylistList.propTypes = { - filteredOptions: PropTypes.array.isRequired, - selectedPlaylists: PropTypes.array.isRequired, - onPlaylistToggle: PropTypes.func.isRequired, - searchText: PropTypes.string.isRequired, - canCreateNew: PropTypes.bool.isRequired, - onCreateNew: PropTypes.func.isRequired, -} - -SelectedPlaylistChip.propTypes = { - playlist: PropTypes.object.isRequired, - onRemove: PropTypes.func.isRequired, -} - -SelectedPlaylistsDisplay.propTypes = { - selectedPlaylists: PropTypes.array.isRequired, - onRemoveSelected: PropTypes.func.isRequired, -} diff --git a/ui/src/dialogs/SelectPlaylistInput.test.js b/ui/src/dialogs/SelectPlaylistInput.test.js new file mode 100644 index 000000000..13fab2afb --- /dev/null +++ b/ui/src/dialogs/SelectPlaylistInput.test.js @@ -0,0 +1,124 @@ +import * as React from 'react' +import { TestContext } from 'ra-test' +import { DataProviderContext } from 'react-admin' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { SelectPlaylistInput } from './SelectPlaylistInput' + +describe('SelectPlaylistInput', () => { + beforeAll(() => localStorage.setItem('userId', 'admin')) + afterEach(cleanup) + const onChangeHandler = jest.fn() + + it('should call the handler with the selections', async () => { + const mockData = [ + { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' }, + { id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' }, + ] + const mockIndexedData = { + 'sample-id1': { + id: 'sample-id1', + name: 'sample playlist 1', + ownerId: 'admin', + }, + 'sample-id2': { + id: 'sample-id2', + name: 'sample playlist 2', + ownerId: 'admin', + }, + } + + const mockDataProvider = { + getList: jest + .fn() + .mockResolvedValue({ data: mockData, total: mockData.length }), + } + + render( + <DataProviderContext.Provider value={mockDataProvider}> + <TestContext + initialState={{ + addToPlaylistDialog: { open: true, duplicateSong: false }, + admin: { + ui: { optimistic: false }, + resources: { + playlist: { + data: mockIndexedData, + list: { + cachedRequests: { + '{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"smart":false}}': + { + ids: ['sample-id1', 'sample-id2'], + total: 2, + }, + }, + }, + }, + }, + }, + }} + > + <SelectPlaylistInput onChange={onChangeHandler} /> + </TestContext> + </DataProviderContext.Provider> + ) + + await waitFor(() => { + expect(mockDataProvider.getList).toHaveBeenCalledWith('playlist', { + filter: { smart: false }, + pagination: { page: 1, perPage: -1 }, + sort: { field: 'name', order: 'ASC' }, + }) + }) + + let textBox = screen.getByRole('textbox') + fireEvent.change(textBox, { target: { value: 'sample' } }) + fireEvent.keyDown(textBox, { key: 'ArrowDown' }) + fireEvent.keyDown(textBox, { key: 'Enter' }) + await waitFor(() => { + expect(onChangeHandler).toHaveBeenCalledWith([ + { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' }, + ]) + }) + + fireEvent.keyDown(textBox, { key: 'ArrowDown' }) + fireEvent.keyDown(textBox, { key: 'Enter' }) + await waitFor(() => { + expect(onChangeHandler).toHaveBeenCalledWith([ + { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' }, + { id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' }, + ]) + }) + + fireEvent.change(textBox, { + target: { value: 'new playlist' }, + }) + fireEvent.keyDown(textBox, { key: 'ArrowDown' }) + fireEvent.keyDown(textBox, { key: 'Enter' }) + await waitFor(() => { + expect(onChangeHandler).toHaveBeenCalledWith([ + { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' }, + { id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' }, + { name: 'new playlist' }, + ]) + }) + + fireEvent.change(textBox, { + target: { value: 'another new playlist' }, + }) + fireEvent.keyDown(textBox, { key: 'Enter' }) + await waitFor(() => { + expect(onChangeHandler).toHaveBeenCalledWith([ + { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' }, + { id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' }, + { name: 'new playlist' }, + { name: 'another new playlist' }, + ]) + }) + }) +}) diff --git a/ui/src/dialogs/SelectPlaylistInput.test.jsx b/ui/src/dialogs/SelectPlaylistInput.test.jsx deleted file mode 100644 index 753d12c03..000000000 --- a/ui/src/dialogs/SelectPlaylistInput.test.jsx +++ /dev/null @@ -1,498 +0,0 @@ -import * as React from 'react' -import { TestContext } from 'ra-test' -import { DataProviderContext } from 'react-admin' -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react' -import { SelectPlaylistInput } from './SelectPlaylistInput' -import { describe, beforeAll, afterEach, it, expect, vi } from 'vitest' - -const mockPlaylists = [ - { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' }, - { id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' }, - { id: 'playlist-3', name: 'Electronic Beats', ownerId: 'admin' }, - { id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' }, // Not writable by admin - { id: 'playlist-5', name: 'Synced List', ownerId: 'admin', sync: true }, -] - -const mockIndexedData = { - 'playlist-1': { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' }, - 'playlist-2': { id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' }, - 'playlist-3': { - id: 'playlist-3', - name: 'Electronic Beats', - ownerId: 'admin', - }, - 'playlist-4': { id: 'playlist-4', name: 'Chill Vibes', ownerId: 'user2' }, - 'playlist-5': { - id: 'playlist-5', - name: 'Synced List', - ownerId: 'admin', - sync: true, - }, -} - -const createTestComponent = ( - mockDataProvider = null, - onChangeMock = vi.fn(), - playlists = mockPlaylists, - indexedData = mockIndexedData, -) => { - const dataProvider = mockDataProvider || { - getList: vi.fn().mockResolvedValue({ - data: playlists, - total: playlists.length, - }), - } - - return render( - <DataProviderContext.Provider value={dataProvider}> - <TestContext - initialState={{ - admin: { - ui: { optimistic: false }, - resources: { - playlist: { - data: indexedData, - list: { - cachedRequests: { - '{"pagination":{"page":1,"perPage":-1},"sort":{"field":"name","order":"ASC"},"filter":{"smart":false}}': - { - ids: Object.keys(indexedData), - total: Object.keys(indexedData).length, - }, - }, - }, - }, - }, - }, - }} - > - <SelectPlaylistInput onChange={onChangeMock} /> - </TestContext> - </DataProviderContext.Provider>, - ) -} - -describe('SelectPlaylistInput', () => { - beforeAll(() => localStorage.setItem('userId', 'admin')) - afterEach(cleanup) - - describe('Basic Functionality', () => { - it('should render search field and playlist list', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByRole('textbox')).toBeInTheDocument() - expect(screen.getByText('Rock Classics')).toBeInTheDocument() - expect(screen.getByText('Jazz Collection')).toBeInTheDocument() - expect(screen.getByText('Electronic Beats')).toBeInTheDocument() - }) - - // Should not show playlists not owned by admin (not writable) - expect(screen.queryByText('Chill Vibes')).not.toBeInTheDocument() - // Should not show synced playlists (their tracks are not editable) - expect(screen.queryByText('Synced List')).not.toBeInTheDocument() - }) - - it('should filter playlists based on search input', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByText('Rock Classics')).toBeInTheDocument() - }) - - const searchInput = screen.getByRole('textbox') - fireEvent.change(searchInput, { target: { value: 'rock' } }) - - await waitFor(() => { - expect(screen.getByText('Rock Classics')).toBeInTheDocument() - expect(screen.queryByText('Jazz Collection')).not.toBeInTheDocument() - expect(screen.queryByText('Electronic Beats')).not.toBeInTheDocument() - }) - }) - - it('should handle case-insensitive search', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByText('Jazz Collection')).toBeInTheDocument() - }) - - const searchInput = screen.getByRole('textbox') - fireEvent.change(searchInput, { target: { value: 'JAZZ' } }) - - await waitFor(() => { - expect(screen.getByText('Jazz Collection')).toBeInTheDocument() - expect(screen.queryByText('Rock Classics')).not.toBeInTheDocument() - }) - }) - }) - - describe('Playlist Selection', () => { - it('should select and deselect playlists by clicking', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByText('Rock Classics')).toBeInTheDocument() - }) - - // Select first playlist - const rockPlaylist = screen.getByText('Rock Classics') - fireEvent.click(rockPlaylist) - - await waitFor(() => { - expect(onChangeMock).toHaveBeenCalledWith([ - { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' }, - ]) - }) - - // Select second playlist - const jazzPlaylist = screen.getByText('Jazz Collection') - fireEvent.click(jazzPlaylist) - - await waitFor(() => { - expect(onChangeMock).toHaveBeenCalledWith([ - { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' }, - { id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' }, - ]) - }) - - // Deselect first playlist - fireEvent.click(rockPlaylist) - - await waitFor(() => { - expect(onChangeMock).toHaveBeenCalledWith([ - { id: 'playlist-2', name: 'Jazz Collection', ownerId: 'admin' }, - ]) - }) - }) - - it('should show selected playlists as chips', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByText('Rock Classics')).toBeInTheDocument() - }) - - // Select a playlist - const rockPlaylist = screen.getByText('Rock Classics') - fireEvent.click(rockPlaylist) - - await waitFor(() => { - // Should show the selected playlist as a chip - const chips = screen.getAllByText('Rock Classics') - expect(chips.length).toBeGreaterThan(1) // One in list, one in chip - }) - }) - - it('should remove selected playlists via chip remove button', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByText('Rock Classics')).toBeInTheDocument() - }) - - // Select a playlist - const rockPlaylist = screen.getByText('Rock Classics') - fireEvent.click(rockPlaylist) - - await waitFor(() => { - // Should show selected playlist as chip - const chips = screen.getAllByText('Rock Classics') - expect(chips.length).toBeGreaterThan(1) - }) - - // Find and click the remove button (translation key) - const removeButton = screen.getByText('×') - fireEvent.click(removeButton) - - await waitFor(() => { - expect(onChangeMock).toHaveBeenCalledWith([]) - // Should only have one instance (in the list) after removal - const remainingChips = screen.getAllByText('Rock Classics') - expect(remainingChips.length).toBe(1) - }) - }) - }) - - describe('Create New Playlist', () => { - it('should create new playlist by pressing Enter', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - - const searchInput = screen.getByRole('textbox') - fireEvent.change(searchInput, { target: { value: 'My New Playlist' } }) - fireEvent.keyDown(searchInput, { key: 'Enter' }) - - await waitFor(() => { - expect(onChangeMock).toHaveBeenCalledWith([{ name: 'My New Playlist' }]) - }) - - // Input should be cleared after creating - expect(searchInput.value).toBe('') - }) - - it('should create new playlist by clicking add button', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - - const searchInput = screen.getByRole('textbox') - fireEvent.change(searchInput, { target: { value: 'Another Playlist' } }) - - // Find the add button by the translation key title - const addButton = screen.getByTitle( - 'resources.playlist.actions.addNewPlaylist', - ) - fireEvent.click(addButton) - - await waitFor(() => { - expect(onChangeMock).toHaveBeenCalledWith([ - { name: 'Another Playlist' }, - ]) - }) - }) - - it('should not show create option for existing playlist names', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - - const searchInput = screen.getByRole('textbox') - fireEvent.change(searchInput, { target: { value: 'Rock Classics' } }) - - await waitFor(() => { - expect( - screen.queryByText('resources.playlist.actions.addNewPlaylist'), - ).not.toBeInTheDocument() - }) - }) - - it('should not create playlist with empty name', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - - const searchInput = screen.getByRole('textbox') - fireEvent.change(searchInput, { target: { value: ' ' } }) // Only spaces - fireEvent.keyDown(searchInput, { key: 'Enter' }) - - // Should not call onChange - expect(onChangeMock).not.toHaveBeenCalled() - }) - - it('should show create options in appropriate contexts', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - - const searchInput = screen.getByRole('textbox') - - // When typing a new name, should show create options - fireEvent.change(searchInput, { target: { value: 'My New Playlist' } }) - - await waitFor(() => { - // Should show the add button in the search field - expect( - screen.getByTitle('resources.playlist.actions.addNewPlaylist'), - ).toBeInTheDocument() - // Should also show hint in empty message when no matches - expect( - screen.getByText('resources.playlist.actions.pressEnterToCreate'), - ).toBeInTheDocument() - }) - }) - }) - - describe('Mixed Operations', () => { - it('should handle selecting existing playlists and creating new ones', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByText('Rock Classics')).toBeInTheDocument() - }) - - // Select existing playlist - const rockPlaylist = screen.getByText('Rock Classics') - fireEvent.click(rockPlaylist) - - await waitFor(() => { - expect(onChangeMock).toHaveBeenCalledWith([ - { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' }, - ]) - }) - - // Create new playlist - const searchInput = screen.getByRole('textbox') - fireEvent.change(searchInput, { target: { value: 'New Mix' } }) - fireEvent.keyDown(searchInput, { key: 'Enter' }) - - await waitFor(() => { - expect(onChangeMock).toHaveBeenCalledWith([ - { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' }, - { name: 'New Mix' }, - ]) - }) - }) - - it('should maintain selections when searching', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByText('Rock Classics')).toBeInTheDocument() - }) - - // Select a playlist - const rockPlaylist = screen.getByText('Rock Classics') - fireEvent.click(rockPlaylist) - - // Filter the list - const searchInput = screen.getByRole('textbox') - fireEvent.change(searchInput, { target: { value: 'jazz' } }) - - await waitFor(() => { - // Should still show selected playlists section - // Rock Classics should still be visible as a selected chip even though filtered out - expect(screen.getByText('Rock Classics')).toBeInTheDocument() // In selected chips - expect(screen.getByText('Jazz Collection')).toBeInTheDocument() - }) - }) - }) - - describe('Empty States', () => { - it('should show empty message when no playlists exist', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock, [], {}) - - await waitFor(() => { - expect( - screen.getByText('resources.playlist.message.noPlaylists'), - ).toBeInTheDocument() - }) - }) - - it('should show "no results" message when search returns no matches', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - - const searchInput = screen.getByRole('textbox') - fireEvent.change(searchInput, { - target: { value: 'NonExistentPlaylist' }, - }) - - await waitFor(() => { - expect( - screen.getByText('resources.playlist.message.noPlaylistsFound'), - ).toBeInTheDocument() - expect( - screen.getByText('resources.playlist.actions.pressEnterToCreate'), - ).toBeInTheDocument() - }) - }) - }) - - describe('Keyboard Navigation', () => { - it('should not create playlist on Enter if input is empty', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - - const searchInput = screen.getByRole('textbox') - fireEvent.keyDown(searchInput, { key: 'Enter' }) - - expect(onChangeMock).not.toHaveBeenCalled() - }) - - it('should handle other keys without side effects', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByRole('textbox')).toBeInTheDocument() - }) - - const searchInput = screen.getByRole('textbox') - fireEvent.change(searchInput, { target: { value: 'test' } }) - fireEvent.keyDown(searchInput, { key: 'ArrowDown' }) - fireEvent.keyDown(searchInput, { key: 'Tab' }) - fireEvent.keyDown(searchInput, { key: 'Escape' }) - - // Should not create playlist or trigger onChange - expect(onChangeMock).not.toHaveBeenCalled() - expect(searchInput.value).toBe('test') - }) - }) - - describe('Integration Scenarios', () => { - it('should handle complex workflow: search, select, create, remove', async () => { - const onChangeMock = vi.fn() - createTestComponent(null, onChangeMock) - - await waitFor(() => { - expect(screen.getByText('Rock Classics')).toBeInTheDocument() - }) - - // Search and select existing playlist - const searchInput = screen.getByRole('textbox') - fireEvent.change(searchInput, { target: { value: 'rock' } }) - - const rockPlaylist = screen.getByText('Rock Classics') - fireEvent.click(rockPlaylist) - - // Clear search and create new playlist - fireEvent.change(searchInput, { target: { value: 'My Custom Mix' } }) - fireEvent.keyDown(searchInput, { key: 'Enter' }) - - await waitFor(() => { - expect(onChangeMock).toHaveBeenCalledWith([ - { id: 'playlist-1', name: 'Rock Classics', ownerId: 'admin' }, - { name: 'My Custom Mix' }, - ]) - }) - - // Remove the first selected playlist via chip - const removeButtons = screen.getAllByText('×') - fireEvent.click(removeButtons[0]) - - await waitFor(() => { - expect(onChangeMock).toHaveBeenCalledWith([{ name: 'My Custom Mix' }]) - }) - }) - }) -}) diff --git a/ui/src/dialogs/ShareDialog.jsx b/ui/src/dialogs/ShareDialog.js similarity index 95% rename from ui/src/dialogs/ShareDialog.jsx rename to ui/src/dialogs/ShareDialog.js index 21856b9e4..3de15fe32 100644 --- a/ui/src/dialogs/ShareDialog.jsx +++ b/ui/src/dialogs/ShareDialog.js @@ -14,7 +14,7 @@ import { useTranslate, } from 'react-admin' import { useEffect, useState } from 'react' -import { sharePlayerUrl } from '../utils' +import { shareUrl } from '../utils' import { useTranscodingOptions } from './useTranscodingOptions' import { useDispatch, useSelector } from 'react-redux' import { closeShareMenu } from '../actions' @@ -33,7 +33,7 @@ export const ShareDialog = () => { const translate = useTranslate() const [description, setDescription] = useState('') const [downloadable, setDownloadable] = useState( - config.defaultDownloadableShare && config.enableDownloads, + config.defaultDownloadableShare && config.enableDownloads ) useEffect(() => { setDescription('') @@ -52,7 +52,7 @@ export const ShareDialog = () => { }, { onSuccess: (res) => { - const url = sharePlayerUrl(res?.data?.id) + const url = shareUrl(res?.data?.id) if (navigator.clipboard && window.isSecureContext) { navigator.clipboard .writeText(url) @@ -66,7 +66,7 @@ export const ShareDialog = () => { type: 'warning', multiLine: true, duration: 0, - }, + } ) }) } else prompt(translate('message.shareCopyToClipboard'), url) @@ -75,7 +75,7 @@ export const ShareDialog = () => { notify(translate('ra.page.error') + ': ' + error.message, { type: 'warning', }), - }, + } ) const handleShare = (e) => { diff --git a/ui/src/dialogs/aboutUtils.js b/ui/src/dialogs/aboutUtils.js deleted file mode 100644 index 7c92692de..000000000 --- a/ui/src/dialogs/aboutUtils.js +++ /dev/null @@ -1,278 +0,0 @@ -/** - * TOML utility functions for configuration export - */ - -/** - * Flattens nested configuration object and generates environment variable names - * @param {Object} config - The nested configuration object from the backend - * @param {string} prefix - The current prefix for nested keys - * @returns {Array} - Array of config objects with key, envVar, and value properties - */ -export const flattenConfig = (config, prefix = '') => { - const result = [] - - if (!config || typeof config !== 'object') { - return result - } - - Object.keys(config).forEach((key) => { - const value = config[key] - const currentKey = prefix ? `${prefix}.${key}` : key - - if (value && typeof value === 'object' && !Array.isArray(value)) { - // Recursively flatten nested objects - result.push(...flattenConfig(value, currentKey)) - } else { - // Generate environment variable name: ND_ + uppercase with dots replaced by underscores - const envVar = 'ND_' + currentKey.toUpperCase().replace(/\./g, '_') - - // Convert value to string for display - let displayValue = value - if ( - Array.isArray(value) || - (typeof value === 'object' && value !== null) - ) { - displayValue = JSON.stringify(value) - } else { - displayValue = String(value) - } - - result.push({ - key: currentKey, - envVar: envVar, - value: displayValue, - }) - } - }) - - return result -} - -/** - * Separates and sorts configuration entries into regular and dev configs - * @param {Array|Object} configEntries - Array of config objects with key and value, or nested config object - * @returns {Object} - Object with regularConfigs and devConfigs arrays, both sorted - */ -export const separateAndSortConfigs = (configEntries) => { - const regularConfigs = [] - const devConfigs = [] - - // Handle both the old array format and new nested object format - let flattenedConfigs - if (Array.isArray(configEntries)) { - // Old format - already flattened - flattenedConfigs = configEntries - } else { - // New format - need to flatten - flattenedConfigs = flattenConfig(configEntries) - } - - flattenedConfigs?.forEach((config) => { - // Skip configFile as it's displayed separately - if (config.key === 'ConfigFile') { - return - } - - if (config.key.startsWith('Dev')) { - devConfigs.push(config) - } else { - regularConfigs.push(config) - } - }) - - // Sort configurations alphabetically - regularConfigs.sort((a, b) => a.key.localeCompare(b.key)) - devConfigs.sort((a, b) => a.key.localeCompare(b.key)) - - return { regularConfigs, devConfigs } -} - -/** - * Escapes TOML keys that contain special characters - * @param {string} key - The key to potentially escape - * @returns {string} - The escaped key if needed, or the original key - */ -export const escapeTomlKey = (key) => { - // Convert to string first to handle null/undefined - const keyStr = String(key) - - // Empty strings always need quotes - if (keyStr === '') { - return '""' - } - - // TOML bare keys can only contain letters, numbers, underscores, and hyphens - // If the key contains other characters, it needs to be quoted - if (/^[a-zA-Z0-9_-]+$/.test(keyStr)) { - return keyStr - } - - // Escape quotes in the key and wrap in quotes - return `"${keyStr.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` -} - -/** - * Converts a value to proper TOML format - * @param {*} value - The value to format - * @returns {string} - The TOML-formatted value - */ -export const formatTomlValue = (value) => { - if (value === null || value === undefined) { - return '""' - } - - const str = String(value) - - // Boolean values - if (str === 'true' || str === 'false') { - return str - } - - // Numbers (integers and floats) - if (/^-?\d+$/.test(str)) { - return str // Integer - } - if (/^-?\d*\.\d+$/.test(str)) { - return str // Float - } - - // Duration values (like "300ms", "1s", "5m") - if (/^\d+(\.\d+)?(ns|us|µs|ms|s|m|h)$/.test(str)) { - return `"${str}"` - } - - // Handle arrays and objects - if (str.startsWith('[') || str.startsWith('{')) { - try { - const parsed = JSON.parse(str) - - // If it's an array, format as TOML array - if (Array.isArray(parsed)) { - const formattedItems = parsed.map((item) => { - if (typeof item === 'string') { - return `"${item.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` - } else if (typeof item === 'number' || typeof item === 'boolean') { - return String(item) - } else { - return `"${String(item).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` - } - }) - - if (formattedItems.length === 0) { - return '[ ]' - } - return `[ ${formattedItems.join(', ')} ]` - } - - // For objects, keep the JSON string format with triple quotes - return `"""${str}"""` - } catch { - return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` - } - } - - // String values (escape backslashes and quotes) - return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` -} - -/** - * Converts nested keys to TOML sections - * @param {Array} configs - Array of config objects with key and value - * @returns {Object} - Object with sections and rootKeys - */ -export const buildTomlSections = (configs) => { - const sections = {} - const rootKeys = [] - - configs.forEach(({ key, value }) => { - if (key.includes('.')) { - const parts = key.split('.') - const sectionName = parts[0] - const keyName = parts.slice(1).join('.') - - if (!sections[sectionName]) { - sections[sectionName] = [] - } - sections[sectionName].push({ key: keyName, value }) - } else { - rootKeys.push({ key, value }) - } - }) - - return { sections, rootKeys } -} - -/** - * Converts configuration data to TOML format - * @param {Object} configData - The configuration data object - * @param {Function} translate - Translation function for internationalization - * @returns {string} - The TOML-formatted configuration - */ -export const configToToml = (configData, translate = (key) => key) => { - let tomlContent = `# Navidrome Configuration\n# Generated on ${new Date().toISOString()}\n\n` - - // Handle both old array format (configData.config is array) and new nested format (configData.config is object) - let configs - if (Array.isArray(configData.config)) { - // Old format - already flattened - configs = configData.config - } else { - // New format - need to flatten - configs = flattenConfig(configData.config) - } - - const { regularConfigs, devConfigs } = separateAndSortConfigs(configs) - - // Process regular configs - const { sections: regularSections, rootKeys: regularRootKeys } = - buildTomlSections(regularConfigs) - - // Add root-level keys first - if (regularRootKeys.length > 0) { - regularRootKeys.forEach(({ key, value }) => { - tomlContent += `${key} = ${formatTomlValue(value)}\n` - }) - tomlContent += '\n' - } - - // Add dev configs if any - if (devConfigs.length > 0) { - tomlContent += `# ${translate('about.config.devFlagsHeader')}\n` - tomlContent += `# ${translate('about.config.devFlagsComment')}\n\n` - - const { sections: devSections, rootKeys: devRootKeys } = - buildTomlSections(devConfigs) - - // Add dev root-level keys - devRootKeys.forEach(({ key, value }) => { - tomlContent += `${key} = ${formatTomlValue(value)}\n` - }) - if (devRootKeys.length > 0) { - tomlContent += '\n' - } - - // Add dev sections - Object.keys(devSections) - .sort() - .forEach((sectionName) => { - tomlContent += `[${sectionName}]\n` - devSections[sectionName].forEach(({ key, value }) => { - tomlContent += `${escapeTomlKey(key)} = ${formatTomlValue(value)}\n` - }) - tomlContent += '\n' - }) - } - - // Add sections - Object.keys(regularSections) - .sort() - .forEach((sectionName) => { - tomlContent += `[${sectionName}]\n` - regularSections[sectionName].forEach(({ key, value }) => { - tomlContent += `${escapeTomlKey(key)} = ${formatTomlValue(value)}\n` - }) - tomlContent += '\n' - }) - - return tomlContent -} diff --git a/ui/src/dialogs/aboutUtils.test.js b/ui/src/dialogs/aboutUtils.test.js deleted file mode 100644 index 4632c5aed..000000000 --- a/ui/src/dialogs/aboutUtils.test.js +++ /dev/null @@ -1,737 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - formatTomlValue, - buildTomlSections, - configToToml, - separateAndSortConfigs, - flattenConfig, - escapeTomlKey, -} from './aboutUtils' - -describe('formatTomlValue', () => { - it('handles null and undefined values', () => { - expect(formatTomlValue(null)).toBe('""') - expect(formatTomlValue(undefined)).toBe('""') - }) - - it('handles boolean values', () => { - expect(formatTomlValue('true')).toBe('true') - expect(formatTomlValue('false')).toBe('false') - expect(formatTomlValue(true)).toBe('true') - expect(formatTomlValue(false)).toBe('false') - }) - - it('handles integer values', () => { - expect(formatTomlValue('123')).toBe('123') - expect(formatTomlValue('-456')).toBe('-456') - expect(formatTomlValue('0')).toBe('0') - expect(formatTomlValue(789)).toBe('789') - }) - - it('handles float values', () => { - expect(formatTomlValue('123.45')).toBe('123.45') - expect(formatTomlValue('-67.89')).toBe('-67.89') - expect(formatTomlValue('0.0')).toBe('0.0') - expect(formatTomlValue(12.34)).toBe('12.34') - }) - - it('handles duration values', () => { - expect(formatTomlValue('300ms')).toBe('"300ms"') - expect(formatTomlValue('5s')).toBe('"5s"') - expect(formatTomlValue('10m')).toBe('"10m"') - expect(formatTomlValue('2h')).toBe('"2h"') - expect(formatTomlValue('1.5s')).toBe('"1.5s"') - }) - - it('handles JSON arrays and objects', () => { - expect(formatTomlValue('["item1", "item2"]')).toBe('[ "item1", "item2" ]') - expect(formatTomlValue('{"key": "value"}')).toBe('"""{"key": "value"}"""') - }) - - it('formats different types of arrays correctly', () => { - // String array - expect(formatTomlValue('["genre", "tcon", "©gen"]')).toBe( - '[ "genre", "tcon", "©gen" ]', - ) - // Mixed array with numbers and strings - expect(formatTomlValue('[42, "test", true]')).toBe('[ 42, "test", true ]') - // Empty array - expect(formatTomlValue('[]')).toBe('[ ]') - // Array with special characters in strings - expect( - formatTomlValue('["item with spaces", "item\\"with\\"quotes"]'), - ).toBe('[ "item with spaces", "item\\"with\\"quotes" ]') - }) - - it('handles invalid JSON as regular strings', () => { - expect(formatTomlValue('[invalid json')).toBe('"[invalid json"') - expect(formatTomlValue('{broken')).toBe('"{broken"') - }) - - it('handles regular strings with quote escaping', () => { - expect(formatTomlValue('simple string')).toBe('"simple string"') - expect(formatTomlValue('string with "quotes"')).toBe( - '"string with \\"quotes\\""', - ) - expect(formatTomlValue('/path/to/file')).toBe('"/path/to/file"') - }) - - it('handles strings with backslashes and quotes', () => { - expect(formatTomlValue('C:\\Program Files\\app')).toBe( - '"C:\\\\Program Files\\\\app"', - ) - expect(formatTomlValue('path\\to"file')).toBe('"path\\\\to\\"file"') - expect(formatTomlValue('backslash\\ and "quote"')).toBe( - '"backslash\\\\ and \\"quote\\""', - ) - expect(formatTomlValue('single\\backslash')).toBe('"single\\\\backslash"') - }) - - it('handles empty strings', () => { - expect(formatTomlValue('')).toBe('""') - }) -}) - -describe('buildTomlSections', () => { - it('separates root keys from nested keys', () => { - const configs = [ - { key: 'RootKey1', value: 'value1' }, - { key: 'Section.NestedKey', value: 'value2' }, - { key: 'RootKey2', value: 'value3' }, - { key: 'Section.AnotherKey', value: 'value4' }, - { key: 'AnotherSection.Key', value: 'value5' }, - ] - - const result = buildTomlSections(configs) - - expect(result.rootKeys).toEqual([ - { key: 'RootKey1', value: 'value1' }, - { key: 'RootKey2', value: 'value3' }, - ]) - - expect(result.sections).toEqual({ - Section: [ - { key: 'NestedKey', value: 'value2' }, - { key: 'AnotherKey', value: 'value4' }, - ], - AnotherSection: [{ key: 'Key', value: 'value5' }], - }) - }) - - it('handles deeply nested keys', () => { - const configs = [{ key: 'Section.SubSection.DeepKey', value: 'deepValue' }] - - const result = buildTomlSections(configs) - - expect(result.rootKeys).toEqual([]) - expect(result.sections).toEqual({ - Section: [{ key: 'SubSection.DeepKey', value: 'deepValue' }], - }) - }) - - it('handles empty input', () => { - const result = buildTomlSections([]) - - expect(result.rootKeys).toEqual([]) - expect(result.sections).toEqual({}) - }) -}) - -describe('configToToml', () => { - const mockTranslate = (key) => { - const translations = { - 'about.config.devFlagsHeader': - 'Development Flags (subject to change/removal)', - 'about.config.devFlagsComment': - 'These are experimental settings and may be removed in future versions', - } - return translations[key] || key - } - - it('generates TOML with header and timestamp', () => { - const configData = { - config: [{ key: 'TestKey', value: 'testValue' }], - } - - const result = configToToml(configData, mockTranslate) - - expect(result).toContain('# Navidrome Configuration') - expect(result).toContain('# Generated on') - expect(result).toContain('TestKey = "testValue"') - }) - - it('separates and sorts regular and dev configs', () => { - const configData = { - config: [ - { key: 'ZRegularKey', value: 'regularValue' }, - { key: 'DevTestFlag', value: 'true' }, - { key: 'ARegularKey', value: 'anotherValue' }, - { key: 'DevAnotherFlag', value: 'false' }, - ], - } - - const result = configToToml(configData, mockTranslate) - - // Check that regular configs come first and are sorted - const lines = result.split('\n') - const aRegularIndex = lines.findIndex((line) => - line.includes('ARegularKey'), - ) - const zRegularIndex = lines.findIndex((line) => - line.includes('ZRegularKey'), - ) - const devHeaderIndex = lines.findIndex((line) => - line.includes('Development Flags'), - ) - const devAnotherIndex = lines.findIndex((line) => - line.includes('DevAnotherFlag'), - ) - const devTestIndex = lines.findIndex((line) => line.includes('DevTestFlag')) - - expect(aRegularIndex).toBeLessThan(zRegularIndex) - expect(zRegularIndex).toBeLessThan(devHeaderIndex) - expect(devHeaderIndex).toBeLessThan(devAnotherIndex) - expect(devAnotherIndex).toBeLessThan(devTestIndex) - }) - - it('skips ConfigFile entries', () => { - const configData = { - config: [ - { key: 'ConfigFile', value: '/path/to/config.toml' }, - { key: 'TestKey', value: 'testValue' }, - ], - } - - const result = configToToml(configData, mockTranslate) - - expect(result).not.toContain('ConfigFile =') - expect(result).toContain('TestKey = "testValue"') - }) - - it('handles sections correctly', () => { - const configData = { - config: [ - { key: 'RootKey', value: 'rootValue' }, - { key: 'Section.NestedKey', value: 'nestedValue' }, - { key: 'Section.AnotherKey', value: 'anotherValue' }, - { key: 'DevA', value: 'DevValue' }, - ], - } - - const result = configToToml(configData, mockTranslate) - // Fields in a section are sorted alphabetically - const fields = [ - 'RootKey = "rootValue"', - 'DevA = "DevValue"', - '[Section]', - 'AnotherKey = "anotherValue"', - 'NestedKey = "nestedValue"', - ] - - for (let idx = 0; idx < fields.length - 1; idx++) { - expect(result).toContain(fields[idx]) - - const idxA = result.indexOf(fields[idx]) - const idxB = result.indexOf(fields[idx + 1]) - - expect(idxA).toBeLessThan(idxB) - } - - expect(result).toContain(fields[fields.length - 1]) - }) - - it('includes dev flags header when dev configs exist', () => { - const configData = { - config: [ - { key: 'RegularKey', value: 'regularValue' }, - { key: 'DevTestFlag', value: 'true' }, - ], - } - - const result = configToToml(configData, mockTranslate) - - expect(result).toContain('# Development Flags (subject to change/removal)') - expect(result).toContain( - '# These are experimental settings and may be removed in future versions', - ) - expect(result).toContain('DevTestFlag = true') - }) - - it('does not include dev flags header when no dev configs exist', () => { - const configData = { - config: [{ key: 'RegularKey', value: 'regularValue' }], - } - - const result = configToToml(configData, mockTranslate) - - expect(result).not.toContain('Development Flags') - expect(result).toContain('RegularKey = "regularValue"') - }) - - it('handles empty config data', () => { - const configData = { config: [] } - - const result = configToToml(configData, mockTranslate) - - expect(result).toContain('# Navidrome Configuration') - expect(result).not.toContain('Development Flags') - }) - - it('handles missing config array', () => { - const configData = {} - - const result = configToToml(configData, mockTranslate) - - expect(result).toContain('# Navidrome Configuration') - expect(result).not.toContain('Development Flags') - }) - - it('works without translate function', () => { - const configData = { - config: [{ key: 'DevTestFlag', value: 'true' }], - } - - const result = configToToml(configData) - - expect(result).toContain('# about.config.devFlagsHeader') - expect(result).toContain('# about.config.devFlagsComment') - expect(result).toContain('DevTestFlag = true') - }) - - it('handles various data types correctly', () => { - const configData = { - config: [ - { key: 'StringValue', value: 'test string' }, - { key: 'BooleanValue', value: 'true' }, - { key: 'IntegerValue', value: '42' }, - { key: 'FloatValue', value: '3.14' }, - { key: 'DurationValue', value: '5s' }, - { key: 'ArrayValue', value: '["item1", "item2"]' }, - ], - } - - const result = configToToml(configData, mockTranslate) - - expect(result).toContain('StringValue = "test string"') - expect(result).toContain('BooleanValue = true') - expect(result).toContain('IntegerValue = 42') - expect(result).toContain('FloatValue = 3.14') - expect(result).toContain('DurationValue = "5s"') - expect(result).toContain('ArrayValue = [ "item1", "item2" ]') - }) - - it('handles nested config object format correctly', () => { - const configData = { - config: { - Address: '127.0.0.1', - Port: 4533, - EnableDownloads: true, - DevLogSourceLine: false, - LastFM: { - Enabled: true, - ApiKey: 'secret123', - Language: 'en', - }, - Scanner: { - Schedule: 'daily', - Enabled: true, - }, - }, - } - - const result = configToToml(configData, mockTranslate) - - // Should contain regular configs - expect(result).toContain('Address = "127.0.0.1"') - expect(result).toContain('Port = 4533') - expect(result).toContain('EnableDownloads = true') - - // Should contain dev configs with header - expect(result).toContain('# Development Flags (subject to change/removal)') - expect(result).toContain('DevLogSourceLine = false') - - // Should contain sections - expect(result).toContain('[LastFM]') - expect(result).toContain('Enabled = true') - expect(result).toContain('ApiKey = "secret123"') - expect(result).toContain('Language = "en"') - - expect(result).toContain('[Scanner]') - expect(result).toContain('Schedule = "daily"') - }) - - it('handles mixed nested and flat structure', () => { - const configData = { - config: { - MusicFolder: '/music', - DevAutoLoginUsername: 'testuser', - Jukebox: { - Enabled: false, - AdminOnly: true, - }, - }, - } - - const result = configToToml(configData, mockTranslate) - - expect(result).toContain('MusicFolder = "/music"') - expect(result).toContain('DevAutoLoginUsername = "testuser"') - expect(result).toContain('[Jukebox]') - expect(result).toContain('Enabled = false') - expect(result).toContain('AdminOnly = true') - }) - - it('properly escapes keys with special characters in sections', () => { - const configData = { - config: [ - { key: 'DevLogLevels.persistence/sql_base_repository', value: 'trace' }, - { key: 'DevLogLevels.core/scanner', value: 'debug' }, - { key: 'DevLogLevels.regular_key', value: 'info' }, - { key: 'Tags.genre.Aliases', value: '["tcon","genre","©gen"]' }, - ], - } - - const result = configToToml(configData, mockTranslate) - - // Keys with forward slashes should be quoted - expect(result).toContain('"persistence/sql_base_repository" = "trace"') - expect(result).toContain('"core/scanner" = "debug"') - - // Regular keys should not be quoted - expect(result).toContain('regular_key = "info"') - - // Arrays should be formatted correctly - expect(result).toContain('"genre.Aliases" = [ "tcon", "genre", "©gen" ]') - - // Should contain proper sections - expect(result).toContain('[DevLogLevels]') - expect(result).toContain('[Tags]') - }) -}) - -describe('flattenConfig', () => { - it('flattens simple nested objects correctly', () => { - const config = { - Address: '0.0.0.0', - Port: 4533, - EnableDownloads: true, - LastFM: { - Enabled: true, - ApiKey: 'secret123', - Language: 'en', - }, - } - - const result = flattenConfig(config) - - expect(result).toContainEqual({ - key: 'Address', - envVar: 'ND_ADDRESS', - value: '0.0.0.0', - }) - - expect(result).toContainEqual({ - key: 'Port', - envVar: 'ND_PORT', - value: '4533', - }) - - expect(result).toContainEqual({ - key: 'EnableDownloads', - envVar: 'ND_ENABLEDOWNLOADS', - value: 'true', - }) - - expect(result).toContainEqual({ - key: 'LastFM.Enabled', - envVar: 'ND_LASTFM_ENABLED', - value: 'true', - }) - - expect(result).toContainEqual({ - key: 'LastFM.ApiKey', - envVar: 'ND_LASTFM_APIKEY', - value: 'secret123', - }) - - expect(result).toContainEqual({ - key: 'LastFM.Language', - envVar: 'ND_LASTFM_LANGUAGE', - value: 'en', - }) - }) - - it('handles deeply nested objects', () => { - const config = { - Scanner: { - Schedule: 'daily', - Options: { - ExtractorType: 'taglib', - ArtworkPriority: 'cover.jpg', - }, - }, - } - - const result = flattenConfig(config) - - expect(result).toContainEqual({ - key: 'Scanner.Schedule', - envVar: 'ND_SCANNER_SCHEDULE', - value: 'daily', - }) - - expect(result).toContainEqual({ - key: 'Scanner.Options.ExtractorType', - envVar: 'ND_SCANNER_OPTIONS_EXTRACTORTYPE', - value: 'taglib', - }) - - expect(result).toContainEqual({ - key: 'Scanner.Options.ArtworkPriority', - envVar: 'ND_SCANNER_OPTIONS_ARTWORKPRIORITY', - value: 'cover.jpg', - }) - }) - - it('handles arrays correctly', () => { - const config = { - DeviceList: ['device1', 'device2'], - Settings: { - EnabledFormats: ['mp3', 'flac', 'ogg'], - }, - } - - const result = flattenConfig(config) - - expect(result).toContainEqual({ - key: 'DeviceList', - envVar: 'ND_DEVICELIST', - value: '["device1","device2"]', - }) - - expect(result).toContainEqual({ - key: 'Settings.EnabledFormats', - envVar: 'ND_SETTINGS_ENABLEDFORMATS', - value: '["mp3","flac","ogg"]', - }) - }) - - it('handles null and undefined values', () => { - const config = { - NullValue: null, - UndefinedValue: undefined, - EmptyString: '', - ZeroValue: 0, - } - - const result = flattenConfig(config) - - expect(result).toContainEqual({ - key: 'NullValue', - envVar: 'ND_NULLVALUE', - value: 'null', - }) - - expect(result).toContainEqual({ - key: 'UndefinedValue', - envVar: 'ND_UNDEFINEDVALUE', - value: 'undefined', - }) - - expect(result).toContainEqual({ - key: 'EmptyString', - envVar: 'ND_EMPTYSTRING', - value: '', - }) - - expect(result).toContainEqual({ - key: 'ZeroValue', - envVar: 'ND_ZEROVALUE', - value: '0', - }) - }) - - it('handles empty object', () => { - const result = flattenConfig({}) - expect(result).toEqual([]) - }) - - it('handles null/undefined input', () => { - expect(flattenConfig(null)).toEqual([]) - expect(flattenConfig(undefined)).toEqual([]) - }) - - it('handles non-object input', () => { - expect(flattenConfig('string')).toEqual([]) - expect(flattenConfig(123)).toEqual([]) - expect(flattenConfig(true)).toEqual([]) - }) -}) - -describe('separateAndSortConfigs', () => { - it('separates regular and dev configs correctly with array input', () => { - const configs = [ - { key: 'RegularKey1', value: 'value1' }, - { key: 'DevTestFlag', value: 'true' }, - { key: 'AnotherRegular', value: 'value2' }, - { key: 'DevAnotherFlag', value: 'false' }, - ] - - const result = separateAndSortConfigs(configs) - - expect(result.regularConfigs).toEqual([ - { key: 'AnotherRegular', value: 'value2' }, - { key: 'RegularKey1', value: 'value1' }, - ]) - - expect(result.devConfigs).toEqual([ - { key: 'DevAnotherFlag', value: 'false' }, - { key: 'DevTestFlag', value: 'true' }, - ]) - }) - - it('separates regular and dev configs correctly with nested object input', () => { - const config = { - Address: '127.0.0.1', - Port: 4533, - DevAutoLoginUsername: 'testuser', - DevLogSourceLine: true, - LastFM: { - Enabled: true, - ApiKey: 'secret123', - }, - } - - const result = separateAndSortConfigs(config) - - expect(result.regularConfigs).toEqual([ - { key: 'Address', envVar: 'ND_ADDRESS', value: '127.0.0.1' }, - { key: 'LastFM.ApiKey', envVar: 'ND_LASTFM_APIKEY', value: 'secret123' }, - { key: 'LastFM.Enabled', envVar: 'ND_LASTFM_ENABLED', value: 'true' }, - { key: 'Port', envVar: 'ND_PORT', value: '4533' }, - ]) - - expect(result.devConfigs).toEqual([ - { - key: 'DevAutoLoginUsername', - envVar: 'ND_DEVAUTOLOGINUSERNAME', - value: 'testuser', - }, - { key: 'DevLogSourceLine', envVar: 'ND_DEVLOGSOURCELINE', value: 'true' }, - ]) - }) - - it('skips ConfigFile entries', () => { - const configs = [ - { key: 'ConfigFile', value: '/path/to/config.toml' }, - { key: 'RegularKey', value: 'value' }, - { key: 'DevFlag', value: 'true' }, - ] - - const result = separateAndSortConfigs(configs) - - expect(result.regularConfigs).toEqual([ - { key: 'RegularKey', value: 'value' }, - ]) - expect(result.devConfigs).toEqual([{ key: 'DevFlag', value: 'true' }]) - }) - - it('skips ConfigFile entries with nested object input', () => { - const config = { - ConfigFile: '/path/to/config.toml', - RegularKey: 'value', - DevFlag: true, - } - - const result = separateAndSortConfigs(config) - - expect(result.regularConfigs).toEqual([ - { key: 'RegularKey', envVar: 'ND_REGULARKEY', value: 'value' }, - ]) - expect(result.devConfigs).toEqual([ - { key: 'DevFlag', envVar: 'ND_DEVFLAG', value: 'true' }, - ]) - }) - - it('handles empty input', () => { - const result = separateAndSortConfigs([]) - - expect(result.regularConfigs).toEqual([]) - expect(result.devConfigs).toEqual([]) - }) - - it('handles null/undefined input', () => { - const result1 = separateAndSortConfigs(null) - const result2 = separateAndSortConfigs(undefined) - - expect(result1.regularConfigs).toEqual([]) - expect(result1.devConfigs).toEqual([]) - expect(result2.regularConfigs).toEqual([]) - expect(result2.devConfigs).toEqual([]) - }) - - it('sorts configs alphabetically', () => { - const configs = [ - { key: 'ZRegular', value: 'z' }, - { key: 'ARegular', value: 'a' }, - { key: 'DevZ', value: 'z' }, - { key: 'DevA', value: 'a' }, - ] - - const result = separateAndSortConfigs(configs) - - expect(result.regularConfigs[0].key).toBe('ARegular') - expect(result.regularConfigs[1].key).toBe('ZRegular') - expect(result.devConfigs[0].key).toBe('DevA') - expect(result.devConfigs[1].key).toBe('DevZ') - }) -}) - -describe('escapeTomlKey', () => { - it('does not escape valid bare keys', () => { - expect(escapeTomlKey('RegularKey')).toBe('RegularKey') - expect(escapeTomlKey('regular_key')).toBe('regular_key') - expect(escapeTomlKey('regular-key')).toBe('regular-key') - expect(escapeTomlKey('key123')).toBe('key123') - expect(escapeTomlKey('Key_with_underscores')).toBe('Key_with_underscores') - expect(escapeTomlKey('Key-with-hyphens')).toBe('Key-with-hyphens') - }) - - it('escapes keys with special characters', () => { - // Keys with forward slashes (like DevLogLevels keys) - expect(escapeTomlKey('persistence/sql_base_repository')).toBe( - '"persistence/sql_base_repository"', - ) - expect(escapeTomlKey('core/scanner')).toBe('"core/scanner"') - - // Keys with dots - expect(escapeTomlKey('Section.NestedKey')).toBe('"Section.NestedKey"') - - // Keys with spaces - expect(escapeTomlKey('key with spaces')).toBe('"key with spaces"') - - // Keys with other special characters - expect(escapeTomlKey('key@with@symbols')).toBe('"key@with@symbols"') - expect(escapeTomlKey('key+with+plus')).toBe('"key+with+plus"') - }) - - it('escapes quotes in keys', () => { - expect(escapeTomlKey('key"with"quotes')).toBe('"key\\"with\\"quotes"') - expect(escapeTomlKey('key with "quotes" inside')).toBe( - '"key with \\"quotes\\" inside"', - ) - }) - - it('escapes backslashes in keys', () => { - expect(escapeTomlKey('key\\with\\backslashes')).toBe( - '"key\\\\with\\\\backslashes"', - ) - expect(escapeTomlKey('path\\to\\file')).toBe('"path\\\\to\\\\file"') - }) - - it('handles empty and null keys', () => { - expect(escapeTomlKey('')).toBe('""') - expect(escapeTomlKey(null)).toBe('null') - expect(escapeTomlKey(undefined)).toBe('undefined') - }) -}) diff --git a/ui/src/dialogs/index.js b/ui/src/dialogs/index.js index 3234e4685..7f71529f8 100644 --- a/ui/src/dialogs/index.js +++ b/ui/src/dialogs/index.js @@ -1,6 +1,4 @@ export * from './AboutDialog' export * from './SelectPlaylistInput' export * from './ListenBrainzTokenDialog' -export * from './SaveQueueDialog' -export * from './QuickConnectDialog' export * from './Dialogs' diff --git a/ui/src/dialogs/useDialog.jsx b/ui/src/dialogs/useDialog.js similarity index 100% rename from ui/src/dialogs/useDialog.jsx rename to ui/src/dialogs/useDialog.js diff --git a/ui/src/dialogs/useTranscodingOptions.jsx b/ui/src/dialogs/useTranscodingOptions.js similarity index 90% rename from ui/src/dialogs/useTranscodingOptions.jsx rename to ui/src/dialogs/useTranscodingOptions.js index c7d44a217..4a0e421d9 100644 --- a/ui/src/dialogs/useTranscodingOptions.jsx +++ b/ui/src/dialogs/useTranscodingOptions.js @@ -20,7 +20,7 @@ export const useTranscodingOptions = () => { page: 1, perPage: 1000, }, - { field: 'name', order: 'ASC' }, + { field: 'name', order: 'ASC' } ) const formatOptions = useMemo( @@ -30,7 +30,7 @@ export const useTranscodingOptions = () => { : Object.values(formats).map((f) => { return { id: f.targetFormat, name: f.name } }), - [formats, loadingFormats], + [formats, loadingFormats] ) const handleOriginal = useCallback( @@ -41,11 +41,11 @@ export const useTranscodingOptions = () => { setMaxBitRate(DEFAULT_SHARE_BITRATE) } }, - [setUseOriginalFormat, setFormat, setMaxBitRate], + [setUseOriginalFormat, setFormat, setMaxBitRate] ) const TranscodingOptionsInput = useMemo(() => { - const Component = ({ label, basePath, ...props }) => { + return ({ label, basePath, ...props }) => { return ( <> <BooleanInput @@ -83,9 +83,6 @@ export const useTranscodingOptions = () => { </> ) } - - Component.displayName = 'TranscodingOptionsInput' - return Component }, [ handleOriginal, formatOptions, diff --git a/ui/src/eventStream.js b/ui/src/eventStream.js index c91dae875..d5df78971 100644 --- a/ui/src/eventStream.js +++ b/ui/src/eventStream.js @@ -1,8 +1,7 @@ import { baseUrl } from './utils' import throttle from 'lodash.throttle' -import { processEvent, serverDown, streamReconnected } from './actions' +import { processEvent, serverDown } from './actions' import { REST_URL } from './consts' -import config from './config' const newEventStream = async () => { let url = baseUrl(`${REST_URL}/events`) @@ -12,51 +11,6 @@ const newEventStream = async () => { return new EventSource(url) } -let eventStream -let reconnectTimer -const RECONNECT_DELAY = 5000 - -const setupHandlers = (stream, dispatchFn) => { - stream.addEventListener('serverStart', eventHandler(dispatchFn)) - stream.addEventListener('scanStatus', throttledEventHandler(dispatchFn)) - stream.addEventListener('refreshResource', eventHandler(dispatchFn)) - if (config.enableNowPlaying) { - stream.addEventListener('nowPlayingCount', eventHandler(dispatchFn)) - } - stream.addEventListener('keepAlive', eventHandler(dispatchFn)) - stream.onerror = (e) => { - // eslint-disable-next-line no-console - console.log('EventStream error', e) - dispatchFn(serverDown()) - if (stream) stream.close() - scheduleReconnect(dispatchFn) - } -} - -const scheduleReconnect = (dispatchFn) => { - if (!reconnectTimer) { - reconnectTimer = setTimeout(() => { - reconnectTimer = null - connect(dispatchFn) - }, RECONNECT_DELAY) - } -} - -const connect = async (dispatchFn) => { - try { - const stream = await newEventStream() - eventStream = stream - setupHandlers(stream, dispatchFn) - // Dispatch reconnection event to refresh critical data - dispatchFn(streamReconnected()) - return stream - } catch (e) { - // eslint-disable-next-line no-console - console.log(`Error connecting to server:`, e) - scheduleReconnect(dispatchFn) - } -} - const eventHandler = (dispatchFn) => (event) => { const data = JSON.parse(event.data) if (event.type !== 'keepAlive') { @@ -67,48 +21,28 @@ const eventHandler = (dispatchFn) => (event) => { const throttledEventHandler = (dispatchFn) => throttle(eventHandler(dispatchFn), 100, { trailing: true }) -const startEventStreamLegacy = async (dispatchFn) => { +const startEventStream = async (dispatchFn) => { + if (!localStorage.getItem('is-authenticated')) { + return Promise.resolve() + } return newEventStream() .then((newStream) => { newStream.addEventListener('serverStart', eventHandler(dispatchFn)) newStream.addEventListener( 'scanStatus', - throttledEventHandler(dispatchFn), + throttledEventHandler(dispatchFn) ) newStream.addEventListener('refreshResource', eventHandler(dispatchFn)) - if (config.enableNowPlaying) { - newStream.addEventListener('nowPlayingCount', eventHandler(dispatchFn)) - } newStream.addEventListener('keepAlive', eventHandler(dispatchFn)) newStream.onerror = (e) => { - // eslint-disable-next-line no-console console.log('EventStream error', e) dispatchFn(serverDown()) } return newStream }) .catch((e) => { - // eslint-disable-next-line no-console console.log(`Error connecting to server:`, e) }) } -const startEventStreamNew = async (dispatchFn) => { - if (eventStream) { - eventStream.close() - eventStream = null - } - return connect(dispatchFn) -} - -const startEventStream = async (dispatchFn) => { - if (!localStorage.getItem('is-authenticated')) { - return Promise.resolve() - } - if (config.devNewEventStream) { - return startEventStreamNew(dispatchFn) - } - return startEventStreamLegacy(dispatchFn) -} - export { startEventStream } diff --git a/ui/src/eventStream.test.js b/ui/src/eventStream.test.js deleted file mode 100644 index 27f53c872..000000000 --- a/ui/src/eventStream.test.js +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, beforeEach, vi, expect } from 'vitest' -import { startEventStream } from './eventStream' -import { serverDown } from './actions' -import config from './config' - -class MockEventSource { - constructor(url) { - this.url = url - this.readyState = 1 - this.listeners = {} - this.onerror = null - } - addEventListener(type, handler) { - this.listeners[type] = handler - } - close() { - this.readyState = 2 - } -} - -describe('startEventStream', () => { - vi.useFakeTimers() - let dispatch - let instance - - beforeEach(() => { - dispatch = vi.fn() - global.EventSource = vi.fn().mockImplementation(function (url) { - instance = new MockEventSource(url) - return instance - }) - localStorage.setItem('is-authenticated', 'true') - localStorage.setItem('token', 'abc') - config.devNewEventStream = true - // Mock console.log to suppress output during tests - vi.spyOn(console, 'log').mockImplementation(() => {}) - }) - - afterEach(() => { - config.devNewEventStream = false - }) - - it('reconnects after an error', async () => { - await startEventStream(dispatch) - expect(global.EventSource).toHaveBeenCalledTimes(1) - instance.onerror(new Event('error')) - expect(dispatch).toHaveBeenCalledWith(serverDown()) - vi.advanceTimersByTime(5000) - expect(global.EventSource).toHaveBeenCalledTimes(2) - }) -}) diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index f694ea75f..33b191a0b 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -1,784 +1,466 @@ + { - "languageName": "English", - "resources": { - "song": { - "name": "Song |||| Songs", - "fields": { - "albumArtist": "Album Artist", - "duration": "Time", - "trackNumber": "#", - "playCount": "Plays", - "title": "Title", - "artist": "Artist", - "composer": "Composer", - "album": "Album", - "path": "File path", - "libraryName": "Library", - "genre": "Genre", - "compilation": "Compilation", - "year": "Year", - "size": "File size", - "updatedAt": "Updated at", - "bitRate": "Bit rate", - "bitDepth": "Bit depth", - "sampleRate": "Sample rate", - "albumGain": "Album gain", - "trackGain": "Track gain", - "channels": "Channels", - "disc": "Disc %{discNumber}", - "discSubtitle": "Disc Subtitle", - "starred": "Favourite", - "comment": "Comment", - "rating": "Rating", - "quality": "Quality", - "bpm": "BPM", - "playDate": "Last Played", - "createdAt": "Date added", - "grouping": "Grouping", - "mood": "Mood", - "participants": "Additional participants", - "tags": "Additional Tags", - "mappedTags": "Mapped tags", - "rawTags": "Raw tags", - "missing": "Missing" - }, - "actions": { - "addToQueue": "Play Later", - "playNow": "Play Now", - "addToPlaylist": "Add to Playlist", - "showInPlaylist": "Show in Playlist", - "shuffleAll": "Shuffle All", - "download": "Download", - "playNext": "Play Next", - "info": "Get Info", - "instantMix": "Instant Mix" - } + "languageName": "English", + "resources": { + "song": { + "name": "Song |||| Songs", + "fields": { + "albumArtist": "Album Artist", + "duration": "Time", + "trackNumber": "#", + "playCount": "Plays", + "title": "Title", + "artist": "Artist", + "album": "Album", + "path": "File path", + "genre": "Genre", + "compilation": "Compilation", + "year": "Year", + "size": "File size", + "updatedAt": "Updated at", + "bitRate": "Bit rate", + "channels": "Channels", + "discSubtitle": "Disc Subtitle", + "starred": "Favourite", + "comment": "Comment", + "rating": "Rating", + "quality": "Quality", + "bpm": "BPM", + "playDate": "Last Played", + "createdAt": "Date added" + }, + "actions": { + "addToQueue": "Play Later", + "playNow": "Play Now", + "addToPlaylist": "Add to Playlist", + "shuffleAll": "Shuffle All", + "download": "Download", + "playNext": "Play Next", + "info": "Get Info" + } + }, + "album": { + "name": "Album |||| Albums", + "fields": { + "albumArtist": "Album Artist", + "artist": "Artist", + "duration": "Time", + "songCount": "Songs", + "playCount": "Plays", + "size": "Size", + "name": "Name", + "genre": "Genre", + "compilation": "Compilation", + "year": "Year", + "originalDate": "Original", + "releaseDate": "Released", + "releases": "Release |||| Releases", + "released": "Released", + "updatedAt": "Updated at", + "comment": "Comment", + "rating": "Rating", + "createdAt": "Date added" + }, + "actions": { + "playAll": "Play", + "playNext": "Play Next", + "addToQueue": "Play Later", + "share": "Share", + "shuffle": "Shuffle", + "addToPlaylist": "Add to Playlist", + "download": "Download", + "info": "Get Info" + }, + "lists": { + "all": "All", + "random": "Random", + "recentlyAdded": "Recently Added", + "recentlyPlayed": "Recently Played", + "mostPlayed": "Most Played", + "starred": "Favourites", + "topRated": "Top Rated" + } + }, + "artist": { + "name": "Artist |||| Artists", + "fields": { + "name": "Name", + "albumCount": "Album Count", + "songCount": "Song Count", + "size": "Size", + "playCount": "Plays", + "rating": "Rating", + "genre": "Genre" + } + }, + "user": { + "name": "User |||| Users", + "fields": { + "userName": "Username", + "isAdmin": "Is Admin", + "lastLoginAt": "Last Login At", + "updatedAt": "Updated at", + "name": "Name", + "password": "Password", + "createdAt": "Created at", + "changePassword": "Change Password?", + "currentPassword": "Current Password", + "newPassword": "New Password", + "token": "Token" + }, + "helperTexts": { + "name": "Changes to your name will only be reflected on next login" + }, + "notifications": { + "created": "User created", + "updated": "User updated", + "deleted": "User deleted" + }, + "message": { + "listenBrainzToken": "Enter your ListenBrainz user token.", + "clickHereForToken": "Click here to get your token" + } + }, + "player": { + "name": "Player |||| Players", + "fields": { + "name": "Name", + "transcodingId": "Transcoding", + "maxBitRate": "Max. Bit Rate", + "client": "Client", + "userName": "Username", + "lastSeen": "Last Seen At", + "reportRealPath": "Report Real Path", + "scrobbleEnabled": "Send Scrobbles to external services" + } + }, + "transcoding": { + "name": "Transcoding |||| Transcodings", + "fields": { + "name": "Name", + "targetFormat": "Target Format", + "defaultBitRate": "Default Bit Rate", + "command": "Command" + } + }, + "playlist": { + "name": "Playlist |||| Playlists", + "fields": { + "name": "Name", + "duration": "Duration", + "ownerName": "Owner", + "public": "Public", + "updatedAt": "Updated at", + "createdAt": "Created at", + "songCount": "Songs", + "comment": "Comment", + "sync": "Auto-import", + "path": "Import from" + }, + "actions": { + "selectPlaylist": "Select a playlist:", + "addNewPlaylist": "Create \"%{name}\"", + "export": "Export", + "makePublic": "Make Public", + "makePrivate": "Make Private" + }, + "message": { + "duplicate_song": "Add duplicated songs", + "song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?" + } + }, + "radio": { + "name": "Radio |||| Radios", + "fields": { + "name": "Name", + "streamUrl": "Stream URL", + "homePageUrl": "Home Page URL", + "updatedAt": "Updated at", + "createdAt": "Created at" + }, + "actions": { + "playNow": "Play Now" + } + }, + "share": { + "name": "Share |||| Shares", + "fields": { + "username": "Shared By", + "url": "URL", + "description": "Description", + "downloadable": "Allow Downloads?", + "contents": "Contents", + "expiresAt": "Expires", + "lastVisitedAt": "Last Visited", + "visitCount": "Visits", + "format": "Format", + "maxBitRate": "Max. Bit Rate", + "updatedAt": "Updated at", + "createdAt": "Created at" + }, + "notifications": { + }, + "actions": { + } + } }, - "album": { - "name": "Album |||| Albums", - "fields": { - "albumArtist": "Album Artist", - "artist": "Artist", - "duration": "Time", - "songCount": "Songs", - "playCount": "Plays", - "size": "Size", - "name": "Name", - "libraryName": "Library", - "genre": "Genre", - "compilation": "Compilation", - "year": "Year", - "date": "Recording Date", - "originalDate": "Original", - "releaseDate": "Released", - "releases": "Release |||| Releases", - "released": "Released", - "updatedAt": "Updated at", - "comment": "Comment", - "rating": "Rating", - "createdAt": "Date added", - "recordLabel": "Label", - "catalogNum": "Catalog Number", - "releaseType": "Type", - "grouping": "Grouping", - "media": "Media", - "mood": "Mood", - "missing": "Missing", - "played": "Played" - }, - "actions": { - "playAll": "Play", - "playNext": "Play Next", - "addToQueue": "Play Later", - "share": "Share", - "shuffle": "Shuffle", - "addToPlaylist": "Add to Playlist", - "download": "Download", - "info": "Get Info", - "refresh": "Refresh Metadata" - }, - "lists": { - "all": "All", - "random": "Random", - "recentlyAdded": "Recently Added", - "recentlyPlayed": "Recently Played", - "mostPlayed": "Most Played", - "starred": "Favourites", - "topRated": "Top Rated" - } - }, - "artist": { - "name": "Artist |||| Artists", - "fields": { - "name": "Name", - "albumCount": "Album Count", - "songCount": "Song Count", - "size": "Size", - "playCount": "Plays", - "rating": "Rating", - "genre": "Genre", - "role": "Role", - "missing": "Missing" - }, - "roles": { - "albumartist": "Album Artist |||| Album Artists", - "artist": "Artist |||| Artists", - "composer": "Composer |||| Composers", - "conductor": "Conductor |||| Conductors", - "lyricist": "Lyricist |||| Lyricists", - "arranger": "Arranger |||| Arrangers", - "producer": "Producer |||| Producers", - "director": "Director |||| Directors", - "engineer": "Engineer |||| Engineers", - "mixer": "Mixer |||| Mixers", - "remixer": "Remixer |||| Remixers", - "djmixer": "DJ Mixer |||| DJ Mixers", - "performer": "Performer |||| Performers", - "maincredit": "Album Artist or Artist |||| Album Artists or Artists" - }, - "actions": { - "topSongs": "Top Songs", - "shuffle": "Shuffle", - "radio": "Radio" - } - }, - "user": { - "name": "User |||| Users", - "fields": { - "userName": "Username", - "isAdmin": "Is Admin", - "lastLoginAt": "Last Login", - "lastAccessAt": "Last Access", - "updatedAt": "Updated at", - "name": "Name", - "password": "Password", - "createdAt": "Created at", - "changePassword": "Change Password?", - "currentPassword": "Current Password", - "newPassword": "New Password", - "token": "Token", - "libraries": "Libraries", - "scrobbleFilter": "Scrobble filter" - }, - "helperTexts": { - "name": "Changes to your name will only be reflected on next login", - "libraries": "Select specific libraries for this user, or leave empty to use default libraries", - "scrobbleFilter": "Songs matching these smart playlist rules are not sent to Last.fm, ListenBrainz or scrobbler plugins. Uses the same JSON syntax and behavior as smart playlists. Example: {\"all\":[{\"lt\":{\"rating\":4}}]}. Leave empty to scrobble everything. Local play counts are not affected." - }, - "notifications": { - "created": "User created", - "updated": "User updated", - "deleted": "User deleted" - }, - "validation": { - "librariesRequired": "At least one library must be selected for non-admin users", - "invalidScrobbleFilter": "Must be valid smart playlist rules. Limit, offset and refresh delay are not supported." - }, - "message": { - "listenBrainzToken": "Enter your ListenBrainz user token.", - "clickHereForToken": "Click here to get your token", - "selectAllLibraries": "Select all libraries", - "adminAutoLibraries": "Admin users automatically have access to all libraries" - } - }, - "player": { - "name": "Player |||| Players", - "menuName": "Players & API keys", - "fields": { - "name": "Name", - "transcodingId": "Transcoding", - "maxBitRate": "Max. Bit Rate", - "client": "Client", - "userName": "Username", - "lastSeen": "Last Seen At", - "reportRealPath": "Report Real Path", - "scrobbleEnabled": "Send Scrobbles to external services", - "hasApiKey": "API Key" - }, - "actions": { - "generateApiKey": "Generate API key", - "regenerateApiKey": "Regenerate", - "revokeApiKey": "Revoke", - "copyApiKey": "Copy" - }, - "message": { - "apiKeyActive": "This player has an API key. Use it in your app as the API key, or as the password if the app does not use token authentication.", - "apiKeyNone": "No API key. Generate one to connect an app to this player.", - "apiKeyNoneOther": "No API key.", - "apiKeyPending": "Copy this key now. It is saved when you click Save and will not be shown again.", - "apiKeyRevokePending": "The API key will be removed when you save.", - "deleteWithKeyTitle": "Delete player", - "deleteWithKeyContent": "This player has an API key. Apps using it will stop working." - }, - "notifications": { - "apiKeyCopied": "API key copied to clipboard" - }, - "validation": { - "apiKeyFormat": "Invalid API key format" - } - }, - "transcoding": { - "name": "Transcoding |||| Transcodings", - "fields": { - "name": "Name", - "targetFormat": "Target Format", - "defaultBitRate": "Default Bit Rate", - "command": "Command" - }, - "choices": { - "noDefaultBitRate": "None" - } - }, - "playlist": { - "name": "Playlist |||| Playlists", - "fields": { - "name": "Name", - "duration": "Duration", - "ownerName": "Owner", - "public": "Public", - "updatedAt": "Updated at", - "createdAt": "Created at", - "songCount": "Songs", - "comment": "Comment", - "sync": "Auto-import", - "path": "Import from", - "starred": "Favourite" - }, - "actions": { - "selectPlaylist": "Select a playlist:", - "addNewPlaylist": "Create \"%{name}\"", - "export": "Export", - "saveQueue": "Save Queue to Playlist", - "makePublic": "Make Public", - "makePrivate": "Make Private", - "searchOrCreate": "Search playlists or type to create new...", - "pressEnterToCreate": "Press Enter to create new playlist", - "removeFromSelection": "Remove from selection" - }, - "message": { - "duplicate_song": "Add duplicated songs", - "song_exist": "There are duplicates being added to the playlist. Would you like to add the duplicates or skip them?", - "noPlaylistsFound": "No playlists found", - "noPlaylists": "No playlists available" - } - }, - "radio": { - "name": "Radio |||| Radios", - "fields": { - "name": "Name", - "streamUrl": "Stream URL", - "homePageUrl": "Home Page URL", - "updatedAt": "Updated at", - "createdAt": "Created at" - }, - "actions": { - "playNow": "Play Now" - } - }, - "share": { - "name": "Share |||| Shares", - "fields": { - "username": "Shared By", - "url": "URL", - "description": "Description", - "downloadable": "Allow Downloads?", - "contents": "Contents", - "expiresAt": "Expires", - "lastVisitedAt": "Last Visited", - "visitCount": "Visits", - "format": "Format", - "maxBitRate": "Max. Bit Rate", - "updatedAt": "Updated at", - "createdAt": "Created at" - }, - "notifications": {}, - "actions": {} - }, - "missing": { - "name": "Missing File |||| Missing Files", - "empty": "No Missing Files", - "fields": { - "path": "Path", - "size": "Size", - "libraryName": "Library", - "updatedAt": "Disappeared on" - }, - "actions": { - "remove": "Remove", - "remove_all": "Remove All" - }, - "notifications": { - "removed": "Missing file(s) removed" - } - }, - "library": { - "name": "Library |||| Libraries", - "fields": { - "name": "Name", - "path": "Path", - "remotePath": "Remote Path", - "lastScanAt": "Last Scan", - "songCount": "Songs", - "albumCount": "Albums", - "artistCount": "Artists", - "totalSongs": "Songs", - "totalAlbums": "Albums", - "totalArtists": "Artists", - "totalFolders": "Folders", - "totalFiles": "Files", - "totalMissingFiles": "Missing Files", - "totalSize": "Total Size", - "totalDuration": "Duration", - "defaultNewUsers": "Default for New Users", - "createdAt": "Created", - "updatedAt": "Updated", - "pidAlbum": "Album grouping", - "pidTrack": "Track identity" - }, - "sections": { - "basic": "Basic Information", - "statistics": "Statistics", - "pid": "Persistent IDs" - }, - "pid": { - "global": "Use global setting (%{value})", - "folder": "Folder (one album per folder)", - "custom": "Custom", - "spec": "PID spec", - "help": "Tags and attributes that identify an item. See the documentation for the syntax:", - "docs": "Persistent IDs" - }, - "actions": { - "scan": "Scan Library", - "quickScan": "Quick Scan", - "fullScan": "Full Scan", - "manageUsers": "Manage User Access", - "viewDetails": "View Details" - }, - "notifications": { - "created": "Library created successfully", - "updated": "Library updated successfully", - "deleted": "Library deleted successfully", - "scanStarted": "Library scan started", - "quickScanStarted": "Quick scan started", - "fullScanStarted": "Full scan started", - "scanError": "Error starting scan. Check logs", - "scanCompleted": "Library scan completed" - }, - "validation": { - "nameRequired": "Library name is required", - "pathRequired": "Library path is required", - "pathNotDirectory": "Library path must be a directory", - "pathNotFound": "Library path not found", - "pathNotAccessible": "Library path is not accessible", - "pathInvalid": "Invalid library path" - }, - "messages": { - "deleteConfirm": "Are you sure you want to delete this library? This will remove all associated data and user access.", - "scanInProgress": "Scan in progress...", - "noLibrariesAssigned": "No libraries assigned to this user", - "pidChangeTitle": "Change persistent IDs?", - "pidChangeConfirm": "This regroups albums and tracks in this library. A full rescan of this library starts now. Track stars, ratings and play counts are kept. Album stars and ratings move to the new albums where an old album maps to a new one." - } - }, - "plugin": { - "name": "Plugin |||| Plugins", - "fields": { - "id": "ID", - "name": "Name", - "description": "Description", - "version": "Version", - "author": "Author", - "website": "Website", - "permissions": "Permissions", - "enabled": "Enabled", - "status": "Status", - "path": "Path", - "lastError": "Error", - "hasError": "Error", - "updatedAt": "Updated", - "createdAt": "Installed", - "configKey": "Key", - "configValue": "Value", - "allUsers": "Allow all users", - "selectedUsers": "Selected users", - "allLibraries": "Allow all libraries", - "selectedLibraries": "Selected libraries", - "allowWriteAccess": "Allow write access" - }, - "sections": { - "status": "Status", - "info": "Plugin Information", - "configuration": "Configuration", - "manifest": "Manifest", - "usersPermission": "Users Permission", - "libraryPermission": "Library Permission" - }, - "status": { - "enabled": "Enabled", - "disabled": "Disabled" - }, - "actions": { - "enable": "Enable", - "disable": "Disable", - "disabledDueToError": "Fix the error before enabling", - "disabledUsersRequired": "Select users before enabling", - "disabledLibrariesRequired": "Select libraries before enabling", - "addConfig": "Add Configuration", - "rescan": "Rescan" - }, - "notifications": { - "enabled": "Plugin enabled", - "disabled": "Plugin disabled", - "updated": "Plugin updated", - "error": "Error updating plugin" - }, - "validation": { - "invalidJson": "Configuration must be valid JSON" - }, - "messages": { - "idHelp": "The plugin ID, derived from its file name. Use it when referencing this plugin in configuration options, such as Agents.", - "configHelp": "Configure the plugin using key-value pairs. Leave empty if the plugin requires no configuration.", - "configValidationError": "Configuration validation failed:", - "schemaRenderError": "Unable to render configuration form. The plugin's schema may be invalid.", - "clickPermissions": "Click a permission for details", - "noConfig": "No configuration set", - "allUsersHelp": "When enabled, the plugin will have access to all users, including those created in the future.", - "noUsers": "No users selected", - "permissionReason": "Reason", - "usersRequired": "This plugin requires access to user information. Select which users the plugin can access, or enable 'Allow all users'.", - "allLibrariesHelp": "When enabled, the plugin will have access to all libraries, including those created in the future.", - "noLibraries": "No libraries selected", - "librariesRequired": "This plugin requires access to library information. Select which libraries the plugin can access, or enable 'Allow all libraries'.", - "allowWriteAccessHelp": "When enabled, the plugin can modify files in the library directories. By default, plugins have read-only access.", - "requiredHosts": "Required hosts" - }, - "placeholders": { - "configKey": "key", - "configValue": "value" - } - } - }, - "ra": { - "auth": { - "welcome1": "Thanks for installing Navidrome!", - "welcome2": "To start, create an admin user", - "confirmPassword": "Confirm Password", - "buttonCreateAdmin": "Create Admin", - "auth_check_error": "Please login to continue", - "user_menu": "Profile", - "username": "Username", - "password": "Password", - "sign_in": "Sign in", - "sign_in_error": "Authentication failed, please retry", - "logout": "Logout", - "insightsCollectionNote": "Navidrome collects anonymous usage data to\nhelp improve the project. Click [here] to learn\nmore and to opt-out if you want" - }, - "validation": { - "invalidChars": "Please only use letters and numbers", - "passwordDoesNotMatch": "Password does not match", - "required": "Required", - "minLength": "Must be %{min} characters at least", - "maxLength": "Must be %{max} characters or less", - "minValue": "Must be at least %{min}", - "maxValue": "Must be %{max} or less", - "number": "Must be a number", - "email": "Must be a valid email", - "oneOf": "Must be one of: %{options}", - "regex": "Must match a specific format (regexp): %{pattern}", - "unique": "Must be unique", - "url": "Must be a valid URL" - }, - "action": { - "add_filter": "Add filter", - "add": "Add", - "back": "Go Back", - "bulk_actions": "1 item selected |||| %{smart_count} items selected", - "bulk_actions_mobile": "1 |||| %{smart_count}", - "cancel": "Cancel", - "clear_input_value": "Clear value", - "clone": "Clone", - "confirm": "Confirm", - "create": "Create", - "delete": "Delete", - "edit": "Edit", - "export": "Export", - "list": "List", - "refresh": "Refresh", - "remove_filter": "Remove this filter", - "remove": "Remove", - "save": "Save", - "search": "Search", - "show": "Show", - "sort": "Sort", - "undo": "Undo", - "expand": "Expand", - "close": "Close", - "open_menu": "Open menu", - "close_menu": "Close menu", - "unselect": "Unselect", - "skip": "Skip", - "share": "Share", - "download": "Download" - }, - "boolean": { - "true": "Yes", - "false": "No" - }, - "page": { - "create": "Create %{name}", - "dashboard": "Dashboard", - "edit": "%{name} #%{id}", - "error": "Something went wrong", - "list": "%{name}", - "loading": "Loading", - "not_found": "Not Found", - "show": "%{name} #%{id}", - "empty": "No %{name} yet.", - "invite": "Do you want to add one?" - }, - "input": { - "file": { - "upload_several": "Drop some files to upload, or click to select one.", - "upload_single": "Drop a file to upload, or click to select it." - }, - "image": { - "upload_several": "Drop some pictures to upload, or click to select one.", - "upload_single": "Drop a picture to upload, or click to select it." - }, - "references": { - "all_missing": "Unable to find references data.", - "many_missing": "At least one of the associated references no longer appears to be available.", - "single_missing": "Associated reference no longer appears to be available." - }, - "password": { - "toggle_visible": "Hide password", - "toggle_hidden": "Show password" - } + "ra": { + "auth": { + "welcome1": "Thanks for installing Navidrome!", + "welcome2": "To start, create an admin user", + "confirmPassword": "Confirm Password", + "buttonCreateAdmin": "Create Admin", + "auth_check_error": "Please login to continue", + "user_menu": "Profile", + "username": "Username", + "password": "Password", + "sign_in": "Sign in", + "sign_in_error": "Authentication failed, please retry", + "logout": "Logout" + }, + "validation": { + "invalidChars": "Please only use letter and numbers", + "passwordDoesNotMatch": "Password does not match", + "required": "Required", + "minLength": "Must be %{min} characters at least", + "maxLength": "Must be %{max} characters or less", + "minValue": "Must be at least %{min}", + "maxValue": "Must be %{max} or less", + "number": "Must be a number", + "email": "Must be a valid email", + "oneOf": "Must be one of: %{options}", + "regex": "Must match a specific format (regexp): %{pattern}", + "unique": "Must be unique", + "url": "Must be a valid URL" + }, + "action": { + "add_filter": "Add filter", + "add": "Add", + "back": "Go Back", + "bulk_actions": "1 item selected |||| %{smart_count} items selected", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "cancel": "Cancel", + "clear_input_value": "Clear value", + "clone": "Clone", + "confirm": "Confirm", + "create": "Create", + "delete": "Delete", + "edit": "Edit", + "export": "Export", + "list": "List", + "refresh": "Refresh", + "remove_filter": "Remove this filter", + "remove": "Remove", + "save": "Save", + "search": "Search", + "show": "Show", + "sort": "Sort", + "undo": "Undo", + "expand": "Expand", + "close": "Close", + "open_menu": "Open menu", + "close_menu": "Close menu", + "unselect": "Unselect", + "skip": "Skip", + "share": "Share", + "download": "Download" + }, + "boolean": { + "true": "Yes", + "false": "No" + }, + "page": { + "create": "Create %{name}", + "dashboard": "Dashboard", + "edit": "%{name} #%{id}", + "error": "Something went wrong", + "list": "%{name}", + "loading": "Loading", + "not_found": "Not Found", + "show": "%{name} #%{id}", + "empty": "No %{name} yet.", + "invite": "Do you want to add one?" + }, + "input": { + "file": { + "upload_several": "Drop some files to upload, or click to select one.", + "upload_single": "Drop a file to upload, or click to select it." + }, + "image": { + "upload_several": "Drop some pictures to upload, or click to select one.", + "upload_single": "Drop a picture to upload, or click to select it." + }, + "references": { + "all_missing": "Unable to find references data.", + "many_missing": "At least one of the associated references no longer appears to be available.", + "single_missing": "Associated reference no longer appears to be available." + }, + "password": { + "toggle_visible": "Hide password", + "toggle_hidden": "Show password" + } + }, + "message": { + "about": "About", + "are_you_sure": "Are you sure?", + "bulk_delete_content": "Are you sure you want to delete this %{name}? |||| Are you sure you want to delete these %{smart_count} items?", + "bulk_delete_title": "Delete %{name} |||| Delete %{smart_count} %{name}", + "delete_content": "Are you sure you want to delete this item?", + "delete_title": "Delete %{name} #%{id}", + "details": "Details", + "error": "A client error occurred and your request couldn't be completed.", + "invalid_form": "The form is not valid. Please check for errors", + "loading": "The page is loading, just a moment please", + "no": "No", + "not_found": "Either you typed a wrong URL, or you followed a bad link.", + "yes": "Yes", + "unsaved_changes": "Some of your changes weren't saved. Are you sure you want to ignore them?" + }, + "navigation": { + "no_results": "No results found", + "no_more_results": "The page number %{page} is out of boundaries. Try the previous page.", + "page_out_of_boundaries": "Page number %{page} out of boundaries", + "page_out_from_end": "Cannot go after last page", + "page_out_from_begin": "Cannot go before page 1", + "page_range_info": "%{offsetBegin}-%{offsetEnd} of %{total}", + "page_rows_per_page": "Items per page:", + "next": "Next", + "prev": "Prev", + "skip_nav": "Skip to content" + }, + "notification": { + "updated": "Element updated |||| %{smart_count} elements updated", + "created": "Element created", + "deleted": "Element deleted |||| %{smart_count} elements deleted", + "bad_item": "Incorrect element", + "item_doesnt_exist": "Element does not exist", + "http_error": "Server communication error", + "data_provider_error": "dataProvider error. Check the console for details.", + "i18n_error": "Cannot load the translations for the specified language", + "canceled": "Action cancelled", + "logged_out": "Your session has ended, please reconnect.", + "new_version": "New version available! Please refresh this window." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Columns To Display", + "layout": "Layout", + "grid": "Grid", + "table": "Table" + } }, "message": { - "about": "About", - "are_you_sure": "Are you sure?", - "bulk_delete_content": "Are you sure you want to delete this %{name}? |||| Are you sure you want to delete these %{smart_count} items?", - "bulk_delete_title": "Delete %{name} |||| Delete %{smart_count} %{name}", - "delete_content": "Are you sure you want to delete this item?", - "delete_title": "Delete %{name} #%{id}", - "details": "Details", - "error": "A client error occurred and your request couldn't be completed.", - "invalid_form": "The form is not valid. Please check for errors", - "loading": "The page is loading, just a moment please", - "no": "No", - "not_found": "Either you typed a wrong URL, or you followed a bad link.", - "yes": "Yes", - "unsaved_changes": "Some of your changes weren't saved. Are you sure you want to ignore them?" + "note": "NOTE", + "transcodingDisabled": "Changing the transcoding configuration through the web interface is disabled for security reasons. If you would like to change (edit or add) transcoding options, restart the server with the %{config} configuration option.", + "transcodingEnabled": "Navidrome is currently running with %{config}, making it possible to run system commands from the transcoding settings using the web interface. We recommend to disable it for security reasons and only enable it when configuring Transcoding options.", + "songsAddedToPlaylist": "Added 1 song to playlist |||| Added %{smart_count} songs to playlist", + "noPlaylistsAvailable": "None available", + "delete_user_title": "Delete user '%{name}'", + "delete_user_content": "Are you sure you want to delete this user and all their data (including playlists and preferences)?", + "notifications_blocked": "You have blocked Notifications for this site in your browser's settings", + "notifications_not_available": "This browser does not support desktop notifications or you are not accessing Navidrome over https", + "lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled", + "lastfmLinkFailure": "Last.fm could not be linked", + "lastfmUnlinkSuccess": "Last.fm unlinked and scrobbling disabled", + "lastfmUnlinkFailure": "Last.fm could not be unlinked", + "listenBrainzLinkSuccess": "ListenBrainz successfully linked and scrobbling enabled as user: %{user}", + "listenBrainzLinkFailure": "ListenBrainz could not be linked: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz unlinked and scrobbling disabled", + "listenBrainzUnlinkFailure": "ListenBrainz could not be unlinked", + "openIn": { + "lastfm": "Open in Last.fm", + "musicbrainz": "Open in MusicBrainz" + }, + "lastfmLink": "Read More...", + "shareOriginalFormat": "Share in original format", + "shareDialogTitle": "Share %{resource} '%{name}'", + "shareBatchDialogTitle": "Share 1 %{resource} |||| Share %{smart_count} %{resource}", + "shareCopyToClipboard": "Copy to clipboard: Ctrl+C, Enter", + "shareSuccess": "URL copied to clipboard: %{url}", + "shareFailure": "Error copying URL %{url} to clipboard", + "downloadDialogTitle": "Download %{resource} '%{name}' (%{size})", + "downloadOriginalFormat": "Download in original format" }, - "navigation": { - "no_results": "No results found", - "no_more_results": "The page number %{page} is out of boundaries. Try the previous page.", - "page_out_of_boundaries": "Page number %{page} out of boundaries", - "page_out_from_end": "Cannot go after last page", - "page_out_from_begin": "Cannot go before page 1", - "page_range_info": "%{offsetBegin}-%{offsetEnd} of %{total}", - "page_rows_per_page": "Items per page:", - "next": "Next", - "prev": "Prev", - "skip_nav": "Skip to content" - }, - "notification": { - "updated": "Element updated |||| %{smart_count} elements updated", - "created": "Element created", - "deleted": "Element deleted |||| %{smart_count} elements deleted", - "bad_item": "Incorrect element", - "item_doesnt_exist": "Element does not exist", - "http_error": "Server communication error", - "data_provider_error": "dataProvider error. Check the console for details.", - "i18n_error": "Cannot load the translations for the specified language", - "canceled": "Action cancelled", - "logged_out": "Your session has ended, please reconnect.", - "new_version": "New version available! Please refresh this window." - }, - "toggleFieldsMenu": { - "columnsToDisplay": "Columns To Display", - "layout": "Layout", - "grid": "Grid", - "table": "Table" - } - }, - "message": { - "uploadCover": "Upload Cover", - "removeCover": "Remove Cover", - "coverUploaded": "Cover art updated", - "coverRemoved": "Cover art removed", - "coverUploadError": "Error uploading cover art", - "coverRemoveError": "Error removing cover art", - "metadataRefreshStarted": "Refreshing metadata in the background", - "note": "NOTE", - "transcodingDisabled": "Changing the transcoding configuration through the web interface is disabled for security reasons. If you would like to change (edit or add) transcoding options, restart the server with the %{config} configuration option.", - "transcodingEnabled": "Navidrome is currently running with %{config}, making it possible to run system commands from the transcoding settings using the web interface. We recommend to disable it for security reasons and only enable it when configuring Transcoding options.", - "songsAddedToPlaylist": "Added 1 song to playlist |||| Added %{smart_count} songs to playlist", - "noSimilarSongsFound": "No similar songs found", - "startingInstantMix": "Loading Instant Mix...", - "noTopSongsFound": "No top songs found", - "noPlaylistsAvailable": "None available", - "delete_user_title": "Delete user '%{name}'", - "delete_user_content": "Are you sure you want to delete this user and all their data (including playlists and preferences)?", - "remove_missing_title": "Remove missing files", - "remove_missing_content": "Are you sure you want to remove the selected missing files from the database? This will remove permanently any references to them, including their play counts and ratings.", - "remove_all_missing_title": "Remove all missing files", - "remove_all_missing_content": "Are you sure you want to remove all missing files from the database? This will permanently remove any references to them, including their play counts and ratings.", - "notifications_blocked": "You have blocked Notifications for this site in your browser's settings", - "notifications_not_available": "This browser does not support desktop notifications or you are not accessing Navidrome over https", - "quickConnectApproved": "%{app} on %{device} is now signed in", - "quickConnectInvalidCode": "Invalid or expired code", - "quickConnectError": "Could not approve the code", - "lastfmLinkSuccess": "Last.fm successfully linked and scrobbling enabled", - "lastfmLinkFailure": "Last.fm could not be linked", - "lastfmUnlinkSuccess": "Last.fm unlinked and scrobbling disabled", - "lastfmUnlinkFailure": "Last.fm could not be unlinked", - "listenBrainzLinkSuccess": "ListenBrainz successfully linked and scrobbling enabled as user: %{user}", - "listenBrainzLinkFailure": "ListenBrainz could not be linked: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz unlinked and scrobbling disabled", - "listenBrainzUnlinkFailure": "ListenBrainz could not be unlinked", - "openIn": { - "lastfm": "Open in Last.fm", - "musicbrainz": "Open in MusicBrainz" - }, - "lastfmLink": "Read More...", - "shareOriginalFormat": "Share in original format", - "shareDialogTitle": "Share %{resource} '%{name}'", - "shareBatchDialogTitle": "Share 1 %{resource} |||| Share %{smart_count} %{resource}", - "shareCopyToClipboard": "Copy to clipboard: Ctrl+C, Enter", - "shareSuccess": "URL copied to clipboard: %{url}", - "shareFailure": "Error copying URL %{url} to clipboard", - "downloadDialogTitle": "Download %{resource} '%{name}' (%{size})", - "downloadOriginalFormat": "Download in original format" - }, - "menu": { - "library": "Library", - "librarySelector": { - "allLibraries": "All Libraries (%{count})", - "multipleLibraries": "%{selected} of %{total} Libraries", - "selectLibraries": "Select Libraries", - "none": "None" - }, - "settings": "Settings", - "quickConnect": { - "name": "Quick Connect", - "code": "Code", - "help": "Enter the code shown by a Jellyfin app to sign it in to your account", - "confirm": "Sign in %{app} %{version} on %{device} to your account?", - "continue": "Continue", - "approve": "Approve" - }, - "version": "Version", - "theme": "Theme", - "personal": { - "name": "Personal", - "options": { + "menu": { + "library": "Library", + "settings": "Settings", + "version": "Version", "theme": "Theme", - "language": "Language", - "defaultView": "Default View", - "desktop_notifications": "Desktop Notifications", - "lastfmNotConfigured": "Last.fm API-Key is not configured", - "lastfmScrobbling": "Scrobble to Last.fm", - "listenBrainzScrobbling": "Scrobble to ListenBrainz", - "replaygain": "ReplayGain Mode", - "preAmp": "ReplayGain PreAmp (dB)", - "gain": { - "none": "Disabled", - "album": "Use Album Gain", - "track": "Use Track Gain" + "personal": { + "name": "Personal", + "options": { + "theme": "Theme", + "language": "Language", + "defaultView": "Default View", + "desktop_notifications": "Desktop Notifications", + "lastfmScrobbling": "Scrobble to Last.fm", + "listenBrainzScrobbling": "Scrobble to ListenBrainz", + "replaygain": "ReplayGain Mode", + "preAmp": "ReplayGain PreAmp (dB)", + "gain": { + "none": "Disabled", + "album": "Use Album Gain", + "track": "Use Track Gain" + } + } + }, + "albumList": "Albums", + "playlists": "Playlists", + "sharedPlaylists": "Shared Playlists", + "about": "About" + }, + "player": { + "playListsText": "Play Queue", + "openText": "Open", + "closeText": "Close", + "notContentText": "No music", + "clickToPlayText": "Click to play", + "clickToPauseText": "Click to pause", + "nextTrackText": "Next track", + "previousTrackText": "Previous track", + "reloadText": "Reload", + "volumeText": "Volume", + "toggleLyricText": "Toggle lyrics", + "toggleMiniModeText": "Minimize", + "destroyText": "Destroy", + "downloadText": "Download", + "removeAudioListsText": "Delete audio lists", + "clickToDeleteText": "Click to delete %{name}", + "emptyLyricText": "No lyrics", + "playModeText": { + "order": "In order", + "orderLoop": "Repeat", + "singleLoop": "Repeat One", + "shufflePlay": "Shuffle" } - } + }, - "albumList": "Albums", - "playlists": "Playlists", - "onlyFavourites": "Only show favourites", - "sharedPlaylists": "Shared Playlists", - "about": "About" - }, - "player": { - "playListsText": "Play Queue", - "openText": "Open", - "closeText": "Close", - "notContentText": "No music", - "clickToPlayText": "Click to play", - "clickToPauseText": "Click to pause", - "nextTrackText": "Next track", - "previousTrackText": "Previous track", - "reloadText": "Reload", - "volumeText": "Volume", - "toggleLyricText": "Toggle lyrics", - "toggleMiniModeText": "Minimize", - "destroyText": "Destroy", - "downloadText": "Download", - "removeAudioListsText": "Delete audio lists", - "clickToDeleteText": "Click to delete %{name}", - "emptyLyricText": "No lyrics", - "playModeText": { - "order": "In order", - "orderLoop": "Repeat", - "singleLoop": "Repeat One", - "shufflePlay": "Shuffle" - } - }, - "about": { - "links": { - "homepage": "Home page", - "source": "Source code", - "featureRequests": "Feature requests", - "lastInsightsCollection": "Last insights collection", - "insights": { - "disabled": "Disabled", - "waiting": "Waiting" - } + "about": { + "links": { + "homepage": "Home page", + "source": "Source code", + "featureRequests": "Feature requests" + } }, - "tabs": { - "about": "About", - "config": "Configuration" + "activity": { + "title": "Activity", + "totalScanned": "Total Folders Scanned", + "quickScan": "Quick Scan", + "fullScan": "Full Scan", + "serverUptime": "Server Uptime", + "serverDown": "OFFLINE" }, - "config": { - "configName": "Config Name", - "environmentVariable": "Environment Variable", - "currentValue": "Current Value", - "configurationFile": "Configuration File", - "exportToml": "Export Configuration (TOML)", - "downloadToml": "Download Configuration (TOML)", - "exportSuccess": "Configuration exported to clipboard in TOML format", - "exportFailed": "Failed to copy configuration", - "devFlagsHeader": "Development Flags (subject to change/removal)", - "devFlagsComment": "These are experimental settings and may be removed in future versions" + "help": { + "title": "Navidrome Hotkeys", + "hotkeys": { + "show_help": "Show This Help", + "toggle_menu": "Toggle Menu Side Bar", + "toggle_play": "Play / Pause", + "prev_song": "Previous Song", + "next_song": "Next Song", + "current_song": "Go to Current Song", + "vol_up": "Volume Up", + "vol_down": "Volume Down", + "toggle_love": "Add this track to favourites" + } } - }, - "activity": { - "title": "Activity", - "totalScanned": "Total Folders Scanned", - "quickScan": "Quick", - "fullScan": "Full", - "selectiveScan": "Selective", - "serverUptime": "Server Uptime", - "serverDown": "OFFLINE", - "scanType": "Last Scan", - "status": "Scan Error", - "elapsedTime": "Elapsed Time" - }, - "nowPlaying": { - "title": "Now Playing", - "empty": "Nothing playing", - "minutesAgo": "%{smart_count} minute ago |||| %{smart_count} minutes ago" - }, - "help": { - "title": "Navidrome Hotkeys", - "hotkeys": { - "show_help": "Show This Help", - "toggle_menu": "Toggle Menu Side Bar", - "toggle_play": "Play / Pause", - "prev_song": "Previous Song", - "next_song": "Next Song", - "current_song": "Go to Current Song", - "vol_up": "Volume Up", - "vol_down": "Volume Down", - "toggle_love": "Add this track to favourites" - } - } } diff --git a/ui/src/i18n/provider.js b/ui/src/i18n/provider.js index 58f1782bb..b1f302f8c 100644 --- a/ui/src/i18n/provider.js +++ b/ui/src/i18n/provider.js @@ -27,10 +27,7 @@ export function retrieveTranslation(locale) { const removeEmpty = (obj) => { for (let k in obj) { - if ( - Object.prototype.hasOwnProperty.call(obj, k) && - typeof obj[k] === 'object' - ) { + if (obj.hasOwnProperty(k) && typeof obj[k] === 'object') { removeEmpty(obj[k]) } else { if (!obj[k]) { @@ -42,14 +39,13 @@ const removeEmpty = (obj) => { const prepareLanguage = (lang) => { removeEmpty(lang) - // Aliases below go on the merged copy: mutating `en` would corrupt the completion baseline - const merged = deepmerge(en, lang) // Make "albumSong" and "playlistTrack" resource use the same translations as "song" - merged.resources.albumSong = merged.resources.song - merged.resources.playlistTrack = merged.resources.song + lang.resources.albumSong = lang.resources.song + lang.resources.playlistTrack = lang.resources.song // ra.boolean.null should always be empty - merged.ra.boolean.null = '' - return merged + lang.ra.boolean.null = '' + // Fallback to english translations + return deepmerge(en, lang) } export default polyglotI18nProvider((locale) => { diff --git a/ui/src/i18n/provider.test.js b/ui/src/i18n/provider.test.js deleted file mode 100644 index 82b593788..000000000 --- a/ui/src/i18n/provider.test.js +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import en from './en.json' - -vi.mock('../dataProvider', () => ({ default: { getOne: vi.fn() } })) - -const countLeaves = (obj) => - Object.values(obj).reduce( - (sum, v) => - sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0), - 0, - ) - -describe('i18n provider', () => { - it('does not mutate the bundled English translations', async () => { - const before = countLeaves(en) - await import('./provider') - expect(countLeaves(en)).toEqual(before) - }) -}) diff --git a/ui/src/i18n/useDateLocale.js b/ui/src/i18n/useDateLocale.js deleted file mode 100644 index c9298a473..000000000 --- a/ui/src/i18n/useDateLocale.js +++ /dev/null @@ -1,14 +0,0 @@ -import { useLocale } from 'react-admin' - -// Our language codes are mostly region-less ("en"), and Intl reads a bare "en" -// as en-US. Borrow the region from the browser when it speaks the same language. -const resolveDateLocale = (locale, browserLocales = []) => { - if (!locale || locale.includes('-')) return locale - const base = locale.toLowerCase() - return ( - browserLocales.find((l) => l.toLowerCase().split('-')[0] === base) || locale - ) -} - -export const useDateLocale = () => - resolveDateLocale(useLocale(), navigator.languages) diff --git a/ui/src/i18n/useDateLocale.test.js b/ui/src/i18n/useDateLocale.test.js deleted file mode 100644 index c0ea5ab40..000000000 --- a/ui/src/i18n/useDateLocale.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { useDateLocale } from './useDateLocale' - -vi.mock('react-admin', () => ({ - useLocale: vi.fn(), -})) - -describe('useDateLocale', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - const renderWith = async (locale, browserLocales) => { - const { useLocale } = await import('react-admin') - vi.mocked(useLocale).mockReturnValue(locale) - vi.spyOn(navigator, 'languages', 'get').mockReturnValue(browserLocales) - return renderHook(() => useDateLocale()).result - } - - it('adds the region from the browser when the language has none', async () => { - const result = await renderWith('en', ['en-GB', 'fr-FR']) - expect(result.current).toEqual('en-GB') - }) - - it('falls back to the language when no browser entry matches', async () => { - const result = await renderWith('de', ['en-US', 'fr-FR']) - expect(result.current).toEqual('de') - }) - - it('keeps a language that already carries a region or script', async () => { - expect((await renderWith('pt-br', ['pt-PT'])).current).toEqual('pt-br') - expect((await renderWith('zh-Hans', ['zh-TW'])).current).toEqual('zh-Hans') - }) - - it('matches the browser language case-insensitively', async () => { - const result = await renderWith('pt', ['PT-PT']) - expect(result.current).toEqual('PT-PT') - }) - - it('returns undefined when there is no language', async () => { - const result = await renderWith(undefined, ['en-GB']) - expect(result.current).toBeUndefined() - }) -}) diff --git a/ui/src/i18n/useGetLanguageChoices.js b/ui/src/i18n/useGetLanguageChoices.js new file mode 100644 index 000000000..0c6773ae6 --- /dev/null +++ b/ui/src/i18n/useGetLanguageChoices.js @@ -0,0 +1,21 @@ +// React Hook to get a list of all languages available. English is hardcoded +import { useGetList } from 'react-admin' + +const useGetLanguageChoices = () => { + const { ids, data, loaded, loading } = useGetList( + 'translation', + { page: 1, perPage: -1 }, + { field: '', order: '' }, + {} + ) + + const choices = [{ id: 'en', name: 'English' }] + if (loaded) { + ids.forEach((id) => choices.push({ id: id, name: data[id].name })) + } + choices.sort((a, b) => a.name.localeCompare(b.name)) + + return { choices, loaded, loading } +} + +export default useGetLanguageChoices diff --git a/ui/src/i18n/useGetLanguageChoices.jsx b/ui/src/i18n/useGetLanguageChoices.jsx deleted file mode 100644 index 38f95e422..000000000 --- a/ui/src/i18n/useGetLanguageChoices.jsx +++ /dev/null @@ -1,44 +0,0 @@ -// React Hook to get a list of all languages available. English is hardcoded -import { useGetList } from 'react-admin' -import en from './en.json' - -const countLeaves = (obj) => - Object.values(obj).reduce( - (sum, v) => - sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0), - 0, - ) - -const enTermCount = countLeaves(en) - -const withPercentage = ({ id, name, termCount }) => { - if (!termCount) return { id, name } - const pct = Math.min(100, Math.round((100 * termCount) / enTermCount)) - // Isolate the percentage, or it renders as "(%61)" next to a right-to-left name - return { id, name: `${name} ⁦(${pct}%)⁩` } -} - -const useGetLanguageChoices = () => { - const { ids, data, loaded, loading } = useGetList( - 'translation', - { page: 1, perPage: -1 }, - { field: '', order: '' }, - {}, - ) - - const languages = [{ id: 'en', name: 'English', termCount: enTermCount }] - if (loaded) { - ids.forEach((id) => - languages.push({ - id, - name: data[id].name, - termCount: data[id].termCount, - }), - ) - } - languages.sort((a, b) => a.name.localeCompare(b.name)) - - return { choices: languages.map(withPercentage), loaded, loading } -} - -export default useGetLanguageChoices diff --git a/ui/src/i18n/useGetLanguageChoices.test.jsx b/ui/src/i18n/useGetLanguageChoices.test.jsx deleted file mode 100644 index b8214868e..000000000 --- a/ui/src/i18n/useGetLanguageChoices.test.jsx +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { renderHook } from '@testing-library/react-hooks' -import { useGetList } from 'react-admin' -import en from './en.json' -import useGetLanguageChoices from './useGetLanguageChoices' - -vi.mock('react-admin', () => ({ - useGetList: vi.fn(), -})) - -const countLeaves = (obj) => - Object.values(obj).reduce( - (sum, v) => - sum + (typeof v === 'object' && v !== null ? countLeaves(v) : v ? 1 : 0), - 0, - ) -const enTermCount = countLeaves(en) - -// The percentage is wrapped in a left-to-right isolate, so it reads the same -// next to right-to-left language names -const label = (pct) => `⁦(${pct}%)⁩` - -const mockLanguages = (languages) => { - const data = {} - languages.forEach((l) => (data[l.id] = l)) - useGetList.mockReturnValue({ - ids: languages.map((l) => l.id), - data, - loaded: true, - loading: false, - }) -} - -const choiceFor = (id) => { - const { result } = renderHook(() => useGetLanguageChoices()) - return result.current.choices.find((c) => c.id === id) -} - -describe('useGetLanguageChoices', () => { - it('appends the completion percentage to incomplete languages', () => { - const termCount = Math.round(enTermCount * 0.62) - mockLanguages([{ id: 'cs', name: 'Čeština', termCount }]) - - const pct = Math.round((100 * termCount) / enTermCount) - expect(choiceFor('cs').name).toEqual(`Čeština ${label(pct)}`) - }) - - it('shows 100% for a complete language', () => { - mockLanguages([{ id: 'de', name: 'Deutsch', termCount: enTermCount }]) - - expect(choiceFor('de').name).toEqual(`Deutsch ${label(100)}`) - }) - - it('caps the percentage at 100 when a language has extra terms', () => { - mockLanguages([ - { id: 'pt', name: 'Português', termCount: enTermCount + 20 }, - ]) - - expect(choiceFor('pt').name).toEqual(`Português ${label(100)}`) - }) - - it('isolates the percentage next to a right-to-left name', () => { - const termCount = Math.round(enTermCount * 0.61) - mockLanguages([{ id: 'ar', name: 'العربية', termCount }]) - - const pct = Math.round((100 * termCount) / enTermCount) - expect(choiceFor('ar').name).toEqual(`العربية ⁦(${pct}%)⁩`) - }) - - it('omits the percentage when the server does not send a term count', () => { - mockLanguages([{ id: 'fr', name: 'Français' }]) - - expect(choiceFor('fr').name).toEqual('Français') - }) - - it('shows 100% for the bundled English', () => { - mockLanguages([]) - - expect(choiceFor('en').name).toEqual(`English ${label(100)}`) - }) - - it('sorts by language name, ignoring the percentage', () => { - mockLanguages([ - { id: 'no', name: 'Norsk', termCount: enTermCount }, - { id: 'da', name: 'Dansk', termCount: 1 }, - ]) - - const { result } = renderHook(() => useGetLanguageChoices()) - - expect(result.current.choices.map((c) => c.id)).toEqual(['da', 'en', 'no']) - }) -}) diff --git a/ui/src/icons/MusicBrainz.jsx b/ui/src/icons/MusicBrainz.js similarity index 100% rename from ui/src/icons/MusicBrainz.jsx rename to ui/src/icons/MusicBrainz.js diff --git a/ui/src/icons/Playlist.jsx b/ui/src/icons/Playlist.js similarity index 100% rename from ui/src/icons/Playlist.jsx rename to ui/src/icons/Playlist.js diff --git a/ui/src/icons/SmartPlaylist.jsx b/ui/src/icons/SmartPlaylist.js similarity index 100% rename from ui/src/icons/SmartPlaylist.jsx rename to ui/src/icons/SmartPlaylist.js diff --git a/ui/src/index.css b/ui/src/index.css index e42e66184..519dc8c47 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1,17 +1,17 @@ body { margin: 0; - font-family: - -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', - 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } code { - font-family: - source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; } .rc-slider { - z-index: 78; + z-index:78 } diff --git a/ui/src/index.js b/ui/src/index.js new file mode 100644 index 000000000..ebbacb3ff --- /dev/null +++ b/ui/src/index.js @@ -0,0 +1,12 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import './index.css' +import App from './App' +import * as serviceWorker from './serviceWorker' + +ReactDOM.render(<App />, document.getElementById('root')) + +// If you want your app to work offline and load faster, you can change +// unregister() to register() below. Note this comes with some pitfalls. +// Learn more about service workers: https://bit.ly/CRA-PWA +serviceWorker.register() diff --git a/ui/src/index.jsx b/ui/src/index.jsx deleted file mode 100644 index e2e63d352..000000000 --- a/ui/src/index.jsx +++ /dev/null @@ -1,10 +0,0 @@ -window.global = window // fix "global is not defined" error in react-image-lightbox - -import ReactDOM from 'react-dom' -import './index.css' -import App from './App' -import { registerSW } from 'virtual:pwa-register' - -registerSW({ immediate: true }) - -ReactDOM.render(<App />, document.getElementById('root')) diff --git a/ui/src/layout/ActivityPanel.jsx b/ui/src/layout/ActivityPanel.js similarity index 54% rename from ui/src/layout/ActivityPanel.jsx rename to ui/src/layout/ActivityPanel.js index 085911ed7..840c54b6f 100644 --- a/ui/src/layout/ActivityPanel.jsx +++ b/ui/src/layout/ActivityPanel.js @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react' -import { useSelector } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { useNotify, useTranslate } from 'react-admin' import { Popover, @@ -12,28 +12,21 @@ import { CardActions, Divider, Box, - Typography, } from '@material-ui/core' import { FiActivity } from 'react-icons/fi' -import { BiError, BiMessageError } from 'react-icons/bi' +import { BiError } from 'react-icons/bi' import { VscSync } from 'react-icons/vsc' import { GiMagnifyingGlass } from 'react-icons/gi' import subsonic from '../subsonic' -import { useInitialScanStatus } from './useInitialScanStatus' +import { scanStatusUpdate } from '../actions' import { useInterval } from '../common' -import { useScanElapsedTime } from './useScanElapsedTime' -import { formatDuration, formatShortDuration } from '../utils' +import { formatDuration } from '../utils' import config from '../config' const useStyles = makeStyles((theme) => ({ wrapper: { position: 'relative', - color: (props) => - props.serverDown - ? theme.palette.error.main - : props.hasWarning - ? theme.palette.warning.main - : null, + color: (props) => (props.up ? null : 'orange'), }, progress: { color: theme.palette.primary.light, @@ -47,16 +40,7 @@ const useStyles = makeStyles((theme) => ({ zIndex: 2, }, counterStatus: { - minWidth: '20em', - }, - error: { - color: theme.palette.error.main, - }, - card: { - maxWidth: 'none', - }, - cardContent: { - padding: theme.spacing(2, 3), + minWidth: '15em', }, })) @@ -75,65 +59,41 @@ const Uptime = () => { const ActivityPanel = () => { const serverStart = useSelector((state) => state.activity.serverStart) const up = serverStart.startTime - const scanStatus = useSelector((state) => state.activity.scanStatus) - const elapsed = useScanElapsedTime( - scanStatus.scanning, - scanStatus.elapsedTime, - ) - // Determine icon state: error (server down), warning (scan error), or normal - const serverDown = !up - const hasWarning = Boolean(scanStatus.error) - const classes = useStyles({ serverDown, hasWarning }) + const classes = useStyles({ up }) const translate = useTranslate() const notify = useNotify() const [anchorEl, setAnchorEl] = useState(null) const open = Boolean(anchorEl) - useInitialScanStatus() + const dispatch = useDispatch() + const scanStatus = useSelector((state) => state.activity.scanStatus) - const handleMenuOpen = (event) => { - setAnchorEl(event.currentTarget) - } - - const handleMenuClose = () => { - setAnchorEl(null) - } + const handleMenuOpen = (event) => setAnchorEl(event.currentTarget) + const handleMenuClose = () => setAnchorEl(null) const triggerScan = (full) => () => subsonic.startScan({ fullScan: full }) + // Get updated status on component mount + useEffect(() => { + subsonic + .getScanStatus() + .then((resp) => resp.json['subsonic-response']) + .then((data) => { + if (data.status === 'ok') { + dispatch(scanStatusUpdate(data.scanStatus)) + } + }) + }, [dispatch]) + useEffect(() => { if (serverStart.version && serverStart.version !== config.version) { notify('ra.notification.new_version', 'info', {}, false, 604800000 * 50) } }, [serverStart, notify]) - const tooltipTitle = scanStatus.error - ? `${translate('activity.status')}: ${scanStatus.error}` - : translate('activity.title') - - const lastScanType = (() => { - switch (scanStatus.scanType) { - case 'full': - return translate('activity.fullScan') - case 'quick': - return translate('activity.quickScan') - case 'full-selective': - case 'quick-selective': - return translate('activity.selectiveScan') - default: - return '' - } - })() - return ( <div className={classes.wrapper}> - <Tooltip title={tooltipTitle}> + <Tooltip title={translate('activity.title')}> <IconButton className={classes.button} onClick={handleMenuOpen}> - {serverDown ? ( - <BiError data-testid="activity-error-icon" size={'20'} /> - ) : hasWarning ? ( - <BiMessageError data-testid="activity-warning-icon" size={'20'} /> - ) : ( - <FiActivity data-testid="activity-ok-icon" size={'20'} /> - )} + {up ? <FiActivity size={'20'} /> : <BiError size={'20'} />} </IconButton> </Tooltip> {scanStatus.scanning && ( @@ -153,23 +113,19 @@ const ActivityPanel = () => { open={open} onClose={handleMenuClose} > - <Card className={classes.card}> - <CardContent className={classes.cardContent}> + <Card> + <CardContent> <Box display="flex" className={classes.counterStatus}> <Box component="span" flex={2}> {translate('activity.serverUptime')}: </Box> - <Box - component="span" - flex={1} - className={!up ? classes.error : null} - > + <Box component="span" flex={1}> {up ? <Uptime /> : translate('activity.serverDown')} </Box> </Box> </CardContent> <Divider /> - <CardContent className={classes.cardContent}> + <CardContent> <Box display="flex" className={classes.counterStatus}> <Box component="span" flex={2}> {translate('activity.totalScanned')}: @@ -178,38 +134,6 @@ const ActivityPanel = () => { {scanStatus.folderCount || '-'} </Box> </Box> - - <Box display="flex" className={classes.counterStatus} mt={2}> - <Box component="span" flex={2}> - {translate('activity.scanType')}: - </Box> - <Box component="span" flex={1}> - {lastScanType} - </Box> - </Box> - - <Box display="flex" className={classes.counterStatus} mt={2}> - <Box component="span" flex={2}> - {translate('activity.elapsedTime')}: - </Box> - <Box component="span" flex={1}> - {formatShortDuration(elapsed)} - </Box> - </Box> - - {scanStatus.error && ( - <Box - display="flex" - flexDirection="column" - mt={2} - className={classes.error} - > - <Typography variant="subtitle2"> - {translate('activity.status')}: - </Typography> - <Typography variant="body2">{scanStatus.error}</Typography> - </Box> - )} </CardContent> <Divider /> <CardActions> diff --git a/ui/src/layout/ActivityPanel.test.jsx b/ui/src/layout/ActivityPanel.test.jsx deleted file mode 100644 index 3a951df5d..000000000 --- a/ui/src/layout/ActivityPanel.test.jsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent } from '@testing-library/react' -import { Provider } from 'react-redux' -import { createStore, combineReducers } from 'redux' -import { describe, it, beforeEach } from 'vitest' - -import ActivityPanel from './ActivityPanel' -import { activityReducer } from '../reducers' -import config from '../config' -import subsonic from '../subsonic' - -vi.mock('../subsonic', () => ({ - default: { - getScanStatus: vi.fn(() => - Promise.resolve({ - json: { - 'subsonic-response': { - status: 'ok', - scanStatus: { error: 'Scan failed' }, - }, - }, - }), - ), - startScan: vi.fn(), - }, -})) - -describe('<ActivityPanel />', () => { - let store - - beforeEach(() => { - store = createStore(combineReducers({ activity: activityReducer }), { - activity: { - scanStatus: { - scanning: false, - folderCount: 0, - count: 0, - error: 'Scan failed', - elapsedTime: 0, - }, - serverStart: { version: config.version, startTime: Date.now() }, - }, - }) - }) - - it('shows warning icon when server reports a scan error', () => { - render( - <Provider store={store}> - <ActivityPanel /> - </Provider>, - ) - - // Warning icon should be visible when there's a scan error - expect(screen.getByTestId('activity-warning-icon')).toBeInTheDocument() - - // Open the panel - warning icon should still be visible - const button = screen.getByRole('button') - fireEvent.click(button) - expect(screen.getByTestId('activity-warning-icon')).toBeInTheDocument() - expect(screen.getByText('Scan failed')).toBeInTheDocument() - }) - - it('shows error icon when server is down', () => { - const downStore = createStore( - combineReducers({ activity: activityReducer }), - { - activity: { - scanStatus: { - scanning: false, - folderCount: 0, - count: 0, - error: '', - elapsedTime: 0, - }, - serverStart: { version: config.version, startTime: null }, // null startTime = server down - }, - }, - ) - - render( - <Provider store={downStore}> - <ActivityPanel /> - </Provider>, - ) - - // Error icon should be visible when server is down - expect(screen.getByTestId('activity-error-icon')).toBeInTheDocument() - }) -}) diff --git a/ui/src/layout/AppBar.jsx b/ui/src/layout/AppBar.js similarity index 59% rename from ui/src/layout/AppBar.jsx rename to ui/src/layout/AppBar.js index 460d33bb9..c72f36bff 100644 --- a/ui/src/layout/AppBar.jsx +++ b/ui/src/layout/AppBar.js @@ -6,20 +6,16 @@ import { usePermissions, getResources, } from 'react-admin' -import { - MdInfo, - MdPerson, - MdPhonelink, - MdSupervisorAccount, -} from 'react-icons/md' import { useSelector } from 'react-redux' import { makeStyles, MenuItem, ListItemIcon, Divider } from '@material-ui/core' import ViewListIcon from '@material-ui/icons/ViewList' +import InfoIcon from '@material-ui/icons/Info' +import PersonIcon from '@material-ui/icons/Person' +import SupervisorAccountIcon from '@material-ui/icons/SupervisorAccount' import { Dialogs } from '../dialogs/Dialogs' -import { AboutDialog, QuickConnectDialog } from '../dialogs' +import { AboutDialog } from '../dialogs' import PersonalMenu from './PersonalMenu' import ActivityPanel from './ActivityPanel' -import NowPlayingPanel from './NowPlayingPanel' import UserMenu from './UserMenu' import config from '../config' @@ -35,37 +31,34 @@ const useStyles = makeStyles( }), { name: 'NDAppBar', - }, + } ) -const DialogMenuItem = forwardRef( - ({ onClick, label, icon, dialog, ...rest }, ref) => { - const classes = useStyles(rest) - const [open, setOpen] = React.useState(false) +const AboutMenuItem = forwardRef(({ onClick, ...rest }, ref) => { + const classes = useStyles(rest) + const translate = useTranslate() + const [open, setOpen] = React.useState(false) - const handleClose = () => { - onClick && onClick() - setOpen(false) - } - return ( - <> - <MenuItem - ref={ref} - onClick={() => setOpen(true)} - className={classes.root} - > - <ListItemIcon className={classes.icon}> - {createElement(icon, { title: label, size: 24 })} - </ListItemIcon> - {label} - </MenuItem> - {createElement(dialog, { onClose: handleClose, open })} - </> - ) - }, -) - -DialogMenuItem.displayName = 'DialogMenuItem' + const handleOpen = () => { + setOpen(true) + } + const handleClose = () => { + onClick && onClick() + setOpen(false) + } + const label = translate('menu.about') + return ( + <> + <MenuItem ref={ref} onClick={handleOpen} className={classes.root}> + <ListItemIcon className={classes.icon}> + <InfoIcon titleAccess={label} /> + </ListItemIcon> + {label} + </MenuItem> + <AboutDialog onClose={handleClose} open={open} /> + </> + ) +}) const settingsResources = (resource) => resource.name !== 'user' && @@ -91,22 +84,20 @@ const CustomUserMenu = ({ onClick, ...rest }) => { if (!config.enableUserEditing) { return null } - userResource.icon = MdPerson + userResource.icon = PersonIcon } else { - userResource.icon = MdSupervisorAccount + userResource.icon = SupervisorAccountIcon } return renderSettingsMenuItemLink( userResource, - permissions !== 'admin' ? localStorage.getItem('userId') : null, + permissions !== 'admin' ? localStorage.getItem('userId') : null ) } const renderSettingsMenuItemLink = (resource, id) => { - const label = resource.options.label - ? translate(resource.options.label) - : translate(`resources.${resource.name}.name`, { - smart_count: id ? 1 : 2, - }) + const label = translate(`resources.${resource.name}.name`, { + smart_count: id ? 1 : 2, + }) const link = id ? `/${resource.name}/${id}` : `/${resource.name}` return ( <MenuItemLink @@ -116,9 +107,7 @@ const CustomUserMenu = ({ onClick, ...rest }) => { to={link} primaryText={label} leftIcon={ - (resource.icon && createElement(resource.icon, { size: 24 })) || ( - <ViewListIcon /> - ) + (resource.icon && createElement(resource.icon)) || <ViewListIcon /> } onClick={onClick} sidebarIsOpen={true} @@ -128,30 +117,16 @@ const CustomUserMenu = ({ onClick, ...rest }) => { return ( <> - {config.devActivityPanel && - permissions === 'admin' && - config.enableNowPlaying && <NowPlayingPanel />} {config.devActivityPanel && permissions === 'admin' && <ActivityPanel />} <UserMenu {...rest}> <PersonalMenu sidebarIsOpen={true} onClick={onClick} /> - {config.enableQuickConnect && ( - <DialogMenuItem - label={translate('menu.quickConnect.name')} - icon={MdPhonelink} - dialog={QuickConnectDialog} - /> - )} <Divider /> {renderUserMenuItemLink()} {resources .filter(settingsResources) .map((r) => renderSettingsMenuItemLink(r))} <Divider /> - <DialogMenuItem - label={translate('menu.about')} - icon={MdInfo} - dialog={AboutDialog} - /> + <AboutMenuItem /> </UserMenu> <Dialogs /> </> diff --git a/ui/src/layout/AppBar.test.jsx b/ui/src/layout/AppBar.test.jsx deleted file mode 100644 index cc5ef781d..000000000 --- a/ui/src/layout/AppBar.test.jsx +++ /dev/null @@ -1,109 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { describe, it, beforeEach, vi } from 'vitest' -import { Provider } from 'react-redux' -import { createStore, combineReducers } from 'redux' -import { activityReducer } from '../reducers' -import AppBar from './AppBar' -import config from '../config' - -let store - -const mocks = vi.hoisted(() => ({ resources: [] })) - -vi.mock('react-admin', () => ({ - AppBar: ({ userMenu }) => <div data-testid="appbar">{userMenu}</div>, - MenuItemLink: ({ primaryText }) => <div>{primaryText}</div>, - useTranslate: () => (x) => x, - usePermissions: () => ({ permissions: 'admin' }), - getResources: () => mocks.resources, -})) - -vi.mock('./NowPlayingPanel', () => ({ - default: () => <div data-testid="now-playing-panel" />, -})) -vi.mock('./ActivityPanel', () => ({ - default: () => <div data-testid="activity-panel" />, -})) -vi.mock('./PersonalMenu', () => ({ - default: () => <div />, -})) -vi.mock('./UserMenu', () => ({ - default: ({ children }) => <div>{children}</div>, -})) -vi.mock('../dialogs/Dialogs', () => ({ - Dialogs: () => <div />, -})) -vi.mock('../dialogs', () => ({ - AboutDialog: () => <div />, - QuickConnectDialog: () => <div />, -})) - -describe('<AppBar />', () => { - beforeEach(() => { - config.devActivityPanel = true - config.enableNowPlaying = true - config.enableQuickConnect = false - mocks.resources = [] - store = createStore(combineReducers({ activity: activityReducer }), { - activity: { nowPlayingCount: 0 }, - }) - }) - - it('renders NowPlayingPanel when enabled', () => { - render( - <Provider store={store}> - <AppBar /> - </Provider>, - ) - expect(screen.getByTestId('now-playing-panel')).toBeInTheDocument() - }) - - it('hides NowPlayingPanel when disabled', () => { - config.enableNowPlaying = false - render( - <Provider store={store}> - <AppBar /> - </Provider>, - ) - expect(screen.queryByTestId('now-playing-panel')).toBeNull() - }) - - it('shows the Quick Connect menu item when enabled', () => { - config.enableQuickConnect = true - render( - <Provider store={store}> - <AppBar /> - </Provider>, - ) - expect(screen.queryAllByText('menu.quickConnect.name')).not.toHaveLength(0) - }) - - it('hides the Quick Connect menu item when disabled', () => { - render( - <Provider store={store}> - <AppBar /> - </Provider>, - ) - expect(screen.queryAllByText('menu.quickConnect.name')).toHaveLength(0) - expect(screen.queryAllByText('menu.about')).not.toHaveLength(0) - }) - - it('uses the resource label for settings items when set', () => { - mocks.resources = [ - { - name: 'player', - hasList: true, - options: { subMenu: 'settings', label: 'resources.player.menuName' }, - }, - { name: 'transcoding', hasList: true, options: { subMenu: 'settings' } }, - ] - render( - <Provider store={store}> - <AppBar /> - </Provider>, - ) - expect(screen.getByText('resources.player.menuName')).toBeInTheDocument() - expect(screen.getByText('resources.transcoding.name')).toBeInTheDocument() - }) -}) diff --git a/ui/src/layout/DynamicMenuIcon.jsx b/ui/src/layout/DynamicMenuIcon.js similarity index 100% rename from ui/src/layout/DynamicMenuIcon.jsx rename to ui/src/layout/DynamicMenuIcon.js diff --git a/ui/src/layout/DynamicMenuIcon.test.jsx b/ui/src/layout/DynamicMenuIcon.test.js similarity index 96% rename from ui/src/layout/DynamicMenuIcon.test.jsx rename to ui/src/layout/DynamicMenuIcon.test.js index 51b0ba635..3d8e238fb 100644 --- a/ui/src/layout/DynamicMenuIcon.test.jsx +++ b/ui/src/layout/DynamicMenuIcon.test.js @@ -17,7 +17,7 @@ describe('<DynamicMenuIcon />', () => { render( <Router history={history}> <DynamicMenuIcon icon={StarIcon} path={'test'} /> - </Router>, + </Router> ) expect(screen.getByTestId('icon')).not.toBeNull() }) @@ -34,7 +34,7 @@ describe('<DynamicMenuIcon />', () => { activeIcon={StarBorderIcon} path={'otherpath'} /> - </Router>, + </Router> ) expect(screen.getByTestId('icon')).not.toBeNull() }) @@ -51,7 +51,7 @@ describe('<DynamicMenuIcon />', () => { activeIcon={StarBorderIcon} path={'path'} /> - </Router>, + </Router> ) expect(screen.getByTestId('activeIcon')).not.toBeNull() }) diff --git a/ui/src/layout/Layout.jsx b/ui/src/layout/Layout.js similarity index 68% rename from ui/src/layout/Layout.jsx rename to ui/src/layout/Layout.js index 44cf9b42c..0ae30c2e8 100644 --- a/ui/src/layout/Layout.jsx +++ b/ui/src/layout/Layout.js @@ -3,11 +3,12 @@ import { useDispatch, useSelector } from 'react-redux' import { Layout as RALayout, toggleSidebar } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' import { HotKeys } from 'react-hotkeys' +import { HTML5Backend } from 'react-dnd-html5-backend' +import { DndProvider } from 'react-dnd' import Menu from './Menu' import AppBar from './AppBar' import Notification from './Notification' import useCurrentTheme from '../themes/useCurrentTheme' -import { useSearchRefocus } from '../common' const useStyles = makeStyles({ root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) }, @@ -18,23 +19,24 @@ const Layout = (props) => { const queue = useSelector((state) => state.player?.queue) const classes = useStyles({ addPadding: queue.length > 0 }) const dispatch = useDispatch() - useSearchRefocus() const keyHandlers = { TOGGLE_MENU: useCallback(() => dispatch(toggleSidebar()), [dispatch]), } return ( - <HotKeys handlers={keyHandlers}> - <RALayout - {...props} - className={classes.root} - menu={Menu} - appBar={AppBar} - theme={theme} - notification={Notification} - /> - </HotKeys> + <DndProvider backend={HTML5Backend}> + <HotKeys handlers={keyHandlers}> + <RALayout + {...props} + className={classes.root} + menu={Menu} + appBar={AppBar} + theme={theme} + notification={Notification} + /> + </HotKeys> + </DndProvider> ) } diff --git a/ui/src/layout/Login.jsx b/ui/src/layout/Login.js similarity index 79% rename from ui/src/layout/Login.jsx rename to ui/src/layout/Login.js index a7763cff3..a197b495b 100644 --- a/ui/src/layout/Login.jsx +++ b/ui/src/layout/Login.js @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react' +import React, { useState, useCallback, useEffect } from 'react' import PropTypes from 'prop-types' import { Field, Form } from 'react-final-form' import { useDispatch } from 'react-redux' @@ -6,13 +6,14 @@ import Button from '@material-ui/core/Button' import Card from '@material-ui/core/Card' import CardActions from '@material-ui/core/CardActions' import CircularProgress from '@material-ui/core/CircularProgress' -import Link from '@material-ui/core/Link' import TextField from '@material-ui/core/TextField' import { ThemeProvider, makeStyles } from '@material-ui/core/styles' import { createMuiTheme, useLogin, useNotify, + useRefresh, + useSetLocale, useTranslate, useVersion, } from 'react-admin' @@ -22,7 +23,7 @@ import Notification from './Notification' import useCurrentTheme from '../themes/useCurrentTheme' import config from '../config' import { clearQueue } from '../actions' -import { INSIGHTS_DOC_URL } from '../consts.js' +import { retrieveTranslation } from '../i18n' const useStyles = makeStyles( (theme) => ({ @@ -80,15 +81,8 @@ const useStyles = makeStyles( systemNameLink: { textDecoration: 'none', }, - message: { - marginTop: '1em', - padding: '0 1em 1em 1em', - textAlign: 'center', - wordBreak: 'break-word', - fontSize: '0.875em', - }, }), - { name: 'NDLogin' }, + { name: 'NDLogin' } ) const renderInput = ({ @@ -98,13 +92,8 @@ const renderInput = ({ }) => ( <TextField error={!!(touched && error)} - inputProps={{ - // mobile keyboards: suppress capitalization and correction for login related fields - autocapitalize: 'none', - autocorrect: 'off', - ...inputProps, - }} helperText={touched && error} + {...inputProps} {...props} fullWidth /> @@ -138,8 +127,6 @@ const FormLogin = ({ loading, handleSubmit, validate }) => { {config.welcomeMessage && ( <div className={classes.welcome} - // Use dangerouslySetInnerHTML to allow admins to configure - // whatever content they want dangerouslySetInnerHTML={{ __html: config.welcomeMessage }} /> )} @@ -186,62 +173,6 @@ const FormLogin = ({ loading, handleSubmit, validate }) => { ) } -const InsightsNotice = ({ url }) => { - const translate = useTranslate() - const classes = useStyles() - - const anchorRegex = /\[(.+?)]/g - const originalMsg = translate('ra.auth.insightsCollectionNote') - - // Split the entire message on newlines - const lines = originalMsg.split('\n') - - const renderedLines = lines.map((line, lineIndex) => { - const segments = [] - let lastIndex = 0 - let match - - // Find bracketed text in each line - while ((match = anchorRegex.exec(line)) !== null) { - // match.index is where "[something]" starts - // match[1] is the text inside the brackets - const bracketText = match[1] - - // Push the text before the bracket - segments.push(line.slice(lastIndex, match.index)) - - // Push the <Link> component - segments.push( - <Link - href={url} - target="_blank" - rel="noopener noreferrer" - key={`${lineIndex}-${match.index}`} - style={{ cursor: 'pointer' }} - > - {bracketText} - </Link>, - ) - - // Update lastIndex to the character right after the bracketed text - lastIndex = match.index + match[0].length - } - - // Push the remaining text after the last bracket - segments.push(line.slice(lastIndex)) - - // Return this line’s parts, plus a <br/> if not the last line - return ( - <React.Fragment key={lineIndex}> - {segments} - {lineIndex < lines.length - 1 && <br />} - </React.Fragment> - ) - }) - - return <div className={classes.message}>{renderedLines}</div> -} - const FormSignUp = ({ loading, handleSubmit, validate }) => { const translate = useTranslate() const classes = useStyles() @@ -306,7 +237,6 @@ const FormSignUp = ({ loading, handleSubmit, validate }) => { {translate('ra.auth.buttonCreateAdmin')} </Button> </CardActions> - <InsightsNotice url={INSIGHTS_DOC_URL} /> </Card> <Notification /> </div> @@ -315,7 +245,6 @@ const FormSignUp = ({ loading, handleSubmit, validate }) => { /> ) } - const Login = ({ location }) => { const [loading, setLoading] = useState(false) const translate = useTranslate() @@ -334,14 +263,14 @@ const Login = ({ location }) => { typeof error === 'string' ? error : typeof error === 'undefined' || !error.message - ? 'ra.auth.sign_in_error' - : error.message, - 'warning', + ? 'ra.auth.sign_in_error' + : error.message, + 'warning' ) - }, + } ) }, - [dispatch, login, notify, setLoading, location], + [dispatch, login, notify, setLoading, location] ) const validateLogin = useCallback( @@ -355,7 +284,7 @@ const Login = ({ location }) => { } return errors }, - [translate], + [translate] ) const validateSignup = useCallback( @@ -373,7 +302,7 @@ const Login = ({ location }) => { } return errors }, - [translate, validateLogin], + [translate, validateLogin] ) if (config.firstTime) { @@ -404,8 +333,27 @@ Login.propTypes = { // the right theme const LoginWithTheme = (props) => { const theme = useCurrentTheme() + const setLocale = useSetLocale() + const refresh = useRefresh() const version = useVersion() + useEffect(() => { + if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) { + retrieveTranslation(config.defaultLanguage) + .then(() => { + setLocale(config.defaultLanguage).then(() => { + localStorage.setItem('locale', config.defaultLanguage) + }) + refresh(true) + }) + .catch((e) => { + throw new Error( + 'Cannot load language "' + config.defaultLanguage + '": ' + e + ) + }) + } + }, [refresh, setLocale]) + return ( <ThemeProvider theme={createMuiTheme(theme)}> <Login key={version} {...props} /> diff --git a/ui/src/layout/Logout.jsx b/ui/src/layout/Logout.js similarity index 100% rename from ui/src/layout/Logout.jsx rename to ui/src/layout/Logout.js diff --git a/ui/src/layout/Menu.jsx b/ui/src/layout/Menu.js similarity index 93% rename from ui/src/layout/Menu.jsx rename to ui/src/layout/Menu.js index 45f40b26d..7ed21ae8f 100644 --- a/ui/src/layout/Menu.jsx +++ b/ui/src/layout/Menu.js @@ -3,13 +3,13 @@ import { useSelector } from 'react-redux' import { Divider, makeStyles } from '@material-ui/core' import clsx from 'clsx' import { useTranslate, MenuItemLink, getResources } from 'react-admin' +import { withRouter } from 'react-router-dom' import ViewListIcon from '@material-ui/icons/ViewList' import AlbumIcon from '@material-ui/icons/Album' import SubMenu from './SubMenu' -import { humanize, pluralize } from 'inflection' +import inflection from 'inflection' import albumLists from '../album/albumLists' import PlaylistsSubMenu from './PlaylistsSubMenu' -import LibrarySelector from '../common/LibrarySelector' import config from '../config' const useStyles = makeStyles((theme) => ({ @@ -43,7 +43,7 @@ const translatedResourceName = (resource, translate) => smart_count: 2, _: resource.options.label, }) - : humanize(pluralize(resource.name)), + : inflection.humanize(inflection.pluralize(resource.name)), }) const Menu = ({ dense = false }) => { @@ -112,7 +112,6 @@ const Menu = ({ dense = false }) => { [classes.closed]: !open, })} > - {open && <LibrarySelector />} <SubMenu handleToggle={() => handleToggle('menuAlbumList')} isOpen={state.menuAlbumList} @@ -122,7 +121,7 @@ const Menu = ({ dense = false }) => { dense={dense} > {Object.keys(albumLists).map((type) => - renderAlbumMenuItemLink(type, albumLists[type]), + renderAlbumMenuItemLink(type, albumLists[type]) )} </SubMenu> {resources.filter(subItems(undefined)).map(renderResourceMenuItemLink)} @@ -143,4 +142,4 @@ const Menu = ({ dense = false }) => { ) } -export default Menu +export default withRouter(Menu) diff --git a/ui/src/layout/Notification.js b/ui/src/layout/Notification.js new file mode 100644 index 000000000..001d3fc01 --- /dev/null +++ b/ui/src/layout/Notification.js @@ -0,0 +1,11 @@ +import React from 'react' +import { Notification as RANotification } from 'react-admin' + +const Notification = (props) => ( + <RANotification + {...props} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} + /> +) + +export default Notification diff --git a/ui/src/layout/Notification.jsx b/ui/src/layout/Notification.jsx deleted file mode 100644 index ff641ec22..000000000 --- a/ui/src/layout/Notification.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react' -import { Notification as RANotification } from 'react-admin' -import { makeStyles } from '@material-ui/core/styles' - -// RA's primary.light Undo is unreadable on the light snackbar of dark themes -const useStyles = makeStyles( - { - undo: { - color: 'inherit', - }, - }, - { name: 'NDNotification' }, -) - -const Notification = (props) => { - const classes = useStyles() - return ( - <RANotification - {...props} - classes={{ undo: classes.undo }} - anchorOrigin={{ vertical: 'top', horizontal: 'center' }} - /> - ) -} - -export default Notification diff --git a/ui/src/layout/NowPlayingPanel.jsx b/ui/src/layout/NowPlayingPanel.jsx deleted file mode 100644 index 509263b42..000000000 --- a/ui/src/layout/NowPlayingPanel.jsx +++ /dev/null @@ -1,491 +0,0 @@ -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' -import { - Popover, - IconButton, - makeStyles, - Tooltip, - List, - ListItem, - Avatar, - Badge, - Card, - CardContent, - Typography, - LinearProgress, - useTheme, - useMediaQuery, -} from '@material-ui/core' -import { FaRegCirclePlay, FaPause } from 'react-icons/fa6' -import subsonic from '../subsonic' -import { useInterval } from '../common' -import { nowPlayingCountSync } from '../actions' -import { formatDuration } from '../utils' -import config from '../config' - -const useStyles = makeStyles((theme) => ({ - button: { color: 'inherit' }, - list: { - width: '26em', - maxHeight: (props) => { - const entryHeight = 120 - const maxEntries = Math.min(props.entryCount || 0, 3) - return maxEntries > 0 ? `${maxEntries * entryHeight}px` : '12em' - }, - overflowY: 'auto', - padding: 0, - }, - card: { - padding: 0, - }, - cardContent: { - padding: `${theme.spacing(1)}px !important`, - '&:last-child': { - paddingBottom: `${theme.spacing(1)}px !important`, - }, - }, - listItem: { - 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: '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, - }, - }, -})) - -// NowPlayingButton component - handles the button with badge -const NowPlayingButton = React.memo(({ count, onClick }) => { - const classes = useStyles() - const translate = useTranslate() - - return ( - <Tooltip title={translate('nowPlaying.title')}> - <IconButton - className={classes.button} - onClick={onClick} - aria-label={translate('nowPlaying.title')} - aria-haspopup="true" - > - <Badge - badgeContent={count} - color="primary" - overlap="rectangular" - className={classes.badge} - > - <FaRegCirclePlay size={20} /> - </Badge> - </IconButton> - </Tooltip> - ) -}) - -NowPlayingButton.displayName = 'NowPlayingButton' - -NowPlayingButton.propTypes = { - count: PropTypes.number.isRequired, - onClick: PropTypes.func.isRequired, -} - -const NowPlayingItem = React.memo( - ({ nowPlayingEntry, onLinkClick, getArtistLink, now }) => { - const classes = useStyles() - 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 ( - <ListItem className={classes.listItem}> - <div className={classes.avatarContainer}> - <Link - to={`/album/${nowPlayingEntry.albumId}/show`} - onClick={onLinkClick} - > - <Avatar - className={classes.avatar} - src={subsonic.getCoverArtUrl(nowPlayingEntry, 80)} - variant="square" - alt={`${nowPlayingEntry.album} cover art`} - loading="lazy" - /> - </Link> - {isPaused && ( - <div className={classes.stateOverlay}> - <FaPause className={classes.stateIcon} /> - </div> - )} - </div> - <div className={classes.entryContent}> - <Typography - className={classes.trackTitle} - title={nowPlayingEntry.title} - > - {nowPlayingEntry.title} - </Typography> - {artistId ? ( - <Link - to={getArtistLink(artistId)} - className={classes.artistLink} - onClick={onLinkClick} - > - {artistName} - </Link> - ) : ( - <Typography className={classes.trackDetail}> - {artistName} - </Typography> - )} - <Typography - className={classes.trackDetail} - title={nowPlayingEntry.album} - > - {nowPlayingEntry.album} - </Typography> - <div className={classes.progressRow}> - <span className={classes.progressTime}> - {formatDuration(positionSec)} - </span> - <LinearProgress - className={classes.progressBar} - variant="determinate" - value={Math.min(progress, 100)} - /> - <span className={classes.progressTime}> - {formatDuration(durationSec)} - </span> - </div> - <Typography className={classes.userInfo}> - {nowPlayingEntry.username} - {nowPlayingEntry.playerName - ? ` (${nowPlayingEntry.playerName})` - : ''} - </Typography> - </div> - </ListItem> - ) - }, -) - -NowPlayingItem.displayName = 'NowPlayingItem' - -NowPlayingItem.propTypes = { - nowPlayingEntry: PropTypes.shape({ - playerId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) - .isRequired, - albumId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) - .isRequired, - albumArtistId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - artistId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - albumArtist: PropTypes.string, - artist: PropTypes.string, - title: PropTypes.string.isRequired, - username: PropTypes.string.isRequired, - playerName: PropTypes.string, - 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, now }) => { - const classes = useStyles({ entryCount: entries.length }) - const translate = useTranslate() - - return ( - <Popover - id="panel-nowplaying" - anchorEl={anchorEl} - anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} - transformOrigin={{ vertical: 'top', horizontal: 'right' }} - open={open} - onClose={onClose} - aria-labelledby="now-playing-title" - > - <Card className={classes.card}> - <CardContent className={classes.cardContent}> - {entries.length === 0 ? ( - <Typography id="now-playing-title"> - {translate('nowPlaying.empty')} - </Typography> - ) : ( - <List - className={classes.list} - dense - aria-label={translate('nowPlaying.title')} - > - {entries.map((nowPlayingEntry) => ( - <NowPlayingItem - key={`${nowPlayingEntry.username}-${nowPlayingEntry.playerName}`} - nowPlayingEntry={nowPlayingEntry} - onLinkClick={onLinkClick} - getArtistLink={getArtistLink} - now={now} - /> - ))} - </List> - )} - </CardContent> - </Card> - </Popover> - ) - }, -) - -NowPlayingList.displayName = 'NowPlayingList' - -NowPlayingList.propTypes = { - anchorEl: PropTypes.object, - open: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - 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, - ) - const serverUp = useSelector( - (state) => !!state.activity.serverStart.startTime, - ) - const translate = useTranslate() - const notify = useNotify() - const theme = useTheme() - const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')) - - const [anchorEl, setAnchorEl] = useState(null) - const [entries, setEntries] = useState([]) - const [now, setNow] = useState(Date.now()) - const open = Boolean(anchorEl) - - const handleMenuOpen = useCallback((event) => { - setAnchorEl(event.currentTarget) - }, []) - - const handleMenuClose = useCallback(() => { - setAnchorEl(null) - }, []) - - // Close panel when link is clicked on small screens - const handleLinkClick = useCallback(() => { - if (isSmallScreen) { - handleMenuClose() - } - }, [isSmallScreen, handleMenuClose]) - - const getArtistLink = useCallback((artistId) => { - if (!artistId) return null - return config.devShowArtistPage && artistId !== config.variousArtistsId - ? `/artist/${artistId}/show` - : `/album?filter={"artist_id":"${artistId}"}&order=ASC&sort=max_year&displayedFilters={"compilation":true}&perPage=15` - }, []) - - 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' }, - }) - }) - 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 NowPlaying updates from SSE events (if panel is open) - useEffect(() => { - if (open && serverUp) fetchList() - }, [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( - () => { - if (open && serverUp) fetchList() - }, - open ? 10000 : null, - ) - - // Periodic refresh when panel is closed (60 seconds) to keep badge accurate - useInterval( - () => { - if (!open && serverUp) fetchList() - }, - !open ? 60000 : null, - ) - - return ( - <div> - <NowPlayingButton count={count} onClick={handleMenuOpen} /> - <NowPlayingList - anchorEl={anchorEl} - open={open} - onClose={handleMenuClose} - entries={entries} - now={now} - onLinkClick={handleLinkClick} - getArtistLink={getArtistLink} - /> - </div> - ) -} - -NowPlayingPanel.propTypes = {} - -export default NowPlayingPanel diff --git a/ui/src/layout/NowPlayingPanel.test.jsx b/ui/src/layout/NowPlayingPanel.test.jsx deleted file mode 100644 index ea4a3568b..000000000 --- a/ui/src/layout/NowPlayingPanel.test.jsx +++ /dev/null @@ -1,363 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { describe, it, beforeEach, vi } from 'vitest' -import { Provider } from 'react-redux' -import { createStore, combineReducers } from 'redux' -import { activityReducer } from '../reducers' -import NowPlayingPanel from './NowPlayingPanel' -import subsonic from '../subsonic' - -vi.mock('../subsonic', () => ({ - default: { - getNowPlaying: vi.fn(), - getAvatarUrl: vi.fn(() => '/avatar'), - getCoverArtUrl: vi.fn(() => '/cover'), - }, -})) - -// Create a mock for useMediaQuery -const mockUseMediaQuery = vi.fn() - -vi.mock('react-admin', async (importOriginal) => { - const actual = await importOriginal() - const redux = await import('react-redux') - return { - ...actual, - useTranslate: () => (x) => x, - useSelector: redux.useSelector, - useDispatch: redux.useDispatch, - Link: ({ to, children, onClick, ...props }) => ( - <a - href={to} - onClick={(e) => { - e.preventDefault() // Prevent navigation in tests - if (onClick) onClick(e) - }} - {...props} - > - {children} - </a> - ), - } -}) - -// Mock the specific Material-UI hooks we need -vi.mock('@material-ui/core/useMediaQuery', () => ({ - default: () => mockUseMediaQuery(), -})) - -vi.mock('@material-ui/core/styles/useTheme', () => ({ - default: () => ({ - breakpoints: { - down: () => '(max-width:959.95px)', // Mock breakpoint string - }, - }), -})) - -describe('<NowPlayingPanel />', () => { - const createMockStore = (overrides = {}) => { - const defaultState = { - activity: { - nowPlayingCount: 1, - serverStart: { startTime: Date.now() }, // Server is up by default - streamReconnected: 0, - ...overrides, - }, - } - return createStore( - combineReducers({ activity: activityReducer }), - defaultState, - ) - } - - afterEach(() => { - vi.useRealTimers() - }) - - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllMocks() - mockUseMediaQuery.mockReturnValue(false) // Default to large screen - - subsonic.getNowPlaying.mockResolvedValue({ - json: { - 'subsonic-response': { - status: 'ok', - nowPlaying: { - entry: [ - { - playerId: 1, - username: 'u1', - playerName: 'Chrome Browser', - title: 'Song', - albumArtist: 'Artist', - albumId: 'album1', - albumArtistId: 'artist1', - minutesAgo: 2, - }, - ], - }, - }, - }, - }) - }) - - it('fetches and displays entries when opened', async () => { - const store = createMockStore() - render( - <Provider store={store}> - <NowPlayingPanel /> - </Provider>, - ) - - // Advance past debounce and flush promises - await vi.advanceTimersByTimeAsync(500) - - fireEvent.click(screen.getByRole('button')) - await waitFor(() => { - expect(screen.getByText('Artist')).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'Artist' })).toHaveAttribute( - 'href', - '/artist/artist1/show', - ) - }) - }) - - it('displays player name after username', async () => { - const store = createMockStore() - render( - <Provider store={store}> - <NowPlayingPanel /> - </Provider>, - ) - - await vi.advanceTimersByTimeAsync(500) - - fireEvent.click(screen.getByRole('button')) - await waitFor(() => { - expect(screen.getByText('u1 (Chrome Browser)')).toBeInTheDocument() - }) - }) - - it('handles entries without player name', async () => { - subsonic.getNowPlaying.mockResolvedValue({ - json: { - 'subsonic-response': { - status: 'ok', - nowPlaying: { - entry: [ - { - playerId: 1, - username: 'u1', - title: 'Song', - albumArtist: 'Artist', - albumId: 'album1', - albumArtistId: 'artist1', - minutesAgo: 2, - }, - ], - }, - }, - }, - }) - - const store = createMockStore() - render( - <Provider store={store}> - <NowPlayingPanel /> - </Provider>, - ) - - await vi.advanceTimersByTimeAsync(500) - - fireEvent.click(screen.getByRole('button')) - await waitFor(() => { - expect(screen.getByText('u1')).toBeInTheDocument() - }) - }) - - it('shows empty message when no entries', async () => { - subsonic.getNowPlaying.mockResolvedValue({ - json: { - 'subsonic-response': { status: 'ok', nowPlaying: { entry: [] } }, - }, - }) - const store = createMockStore({ nowPlayingCount: 0 }) - render( - <Provider store={store}> - <NowPlayingPanel /> - </Provider>, - ) - - await vi.advanceTimersByTimeAsync(500) - - fireEvent.click(screen.getByRole('button')) - await waitFor(() => { - expect(screen.getByText('nowPlaying.empty')).toBeInTheDocument() - }) - }) - - it('does not close panel when artist link is clicked on large screens', async () => { - mockUseMediaQuery.mockReturnValue(false) // Simulate large screen - - const store = createMockStore() - render( - <Provider store={store}> - <NowPlayingPanel /> - </Provider>, - ) - - await vi.advanceTimersByTimeAsync(500) - - // Open the panel - fireEvent.click(screen.getByRole('button')) - await waitFor(() => { - expect(screen.getByText('Artist')).toBeInTheDocument() - }) - - // Check that the popover is open - expect(screen.getByRole('presentation')).toBeInTheDocument() - - // Click the artist link - fireEvent.click(screen.getByRole('link', { name: 'Artist' })) - - // Panel should remain open (popover should still be in document) - expect(screen.getByRole('presentation')).toBeInTheDocument() - expect(screen.getByText('Artist')).toBeInTheDocument() - }) - - it('does not fetch on mount when server is down', () => { - const store = createMockStore({ - nowPlayingCount: 1, - serverStart: { startTime: null }, // Server is down - }) - render( - <Provider store={store}> - <NowPlayingPanel /> - </Provider>, - ) - - // Should not have made initial fetch request due to server being down - expect(subsonic.getNowPlaying).not.toHaveBeenCalled() - }) - - it('does not fetch on stream reconnection when server is down', () => { - const store = createMockStore({ - nowPlayingCount: 1, - serverStart: { startTime: null }, // Server is down - streamReconnected: Date.now(), // Stream reconnected - }) - render( - <Provider store={store}> - <NowPlayingPanel /> - </Provider>, - ) - - // Should not have made fetch request due to server being down - expect(subsonic.getNowPlaying).not.toHaveBeenCalled() - }) - - it('does not double-fetch on server reconnection', async () => { - vi.useFakeTimers() - - const initialStore = createMockStore({ - nowPlayingCount: 1, - serverStart: { startTime: null }, // Server initially down - streamReconnected: 0, - }) - const { rerender } = render( - <Provider store={initialStore}> - <NowPlayingPanel /> - </Provider>, - ) - - // Clear initial (empty) calls - vi.clearAllMocks() - - // Simulate server coming back up with stream reconnection (both state changes happen) - const reconnectedStore = createMockStore({ - nowPlayingCount: 1, - serverStart: { startTime: Date.now() }, // Server back up - streamReconnected: Date.now(), // Stream reconnected - }) - rerender( - <Provider store={reconnectedStore}> - <NowPlayingPanel /> - </Provider>, - ) - - // 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', () => { - vi.useFakeTimers() - - const store = createMockStore({ - nowPlayingCount: 1, - serverStart: { startTime: null }, // Server is down - }) - render( - <Provider store={store}> - <NowPlayingPanel /> - </Provider>, - ) - - // Clear initial mount fetch - vi.clearAllMocks() - - // Advance time by 70 seconds to trigger polling interval - vi.advanceTimersByTime(70000) - - // Should not have made any additional requests due to server being down - expect(subsonic.getNowPlaying).not.toHaveBeenCalled() - - vi.useRealTimers() - }) - - it('resumes polling when server comes back up', () => { - vi.useFakeTimers() - - const store = createMockStore({ - nowPlayingCount: 1, - serverStart: { startTime: null }, // Server is down - }) - const { rerender } = render( - <Provider store={store}> - <NowPlayingPanel /> - </Provider>, - ) - - // Clear initial mount fetch - vi.clearAllMocks() - - // Advance time - should not poll when server is down - vi.advanceTimersByTime(70000) - expect(subsonic.getNowPlaying).not.toHaveBeenCalled() - - // Update state to indicate server is back up - const updatedStore = createMockStore({ - nowPlayingCount: 1, - serverStart: { startTime: Date.now() }, // Server is back up - }) - rerender( - <Provider store={updatedStore}> - <NowPlayingPanel /> - </Provider>, - ) - - // Clear the fetch that happens due to initial mount of rerender - vi.clearAllMocks() - - // Advance time again - should now poll since server is up - vi.advanceTimersByTime(70000) - expect(subsonic.getNowPlaying).toHaveBeenCalled() - - vi.useRealTimers() - }) -}) diff --git a/ui/src/layout/PersonalMenu.jsx b/ui/src/layout/PersonalMenu.js similarity index 84% rename from ui/src/layout/PersonalMenu.jsx rename to ui/src/layout/PersonalMenu.js index 12f8beeb1..2c098d3f0 100644 --- a/ui/src/layout/PersonalMenu.jsx +++ b/ui/src/layout/PersonalMenu.js @@ -1,7 +1,7 @@ import React, { forwardRef } from 'react' import { MenuItemLink, useTranslate } from 'react-admin' -import { MdTune } from 'react-icons/md' import { makeStyles } from '@material-ui/core' +import TuneIcon from '@material-ui/icons/Tune' const useStyles = makeStyles((theme) => ({ menuItem: { @@ -17,7 +17,7 @@ const PersonalMenu = forwardRef(({ onClick, sidebarIsOpen, dense }, ref) => { ref={ref} to="/personal" primaryText={translate('menu.personal.name')} - leftIcon={<MdTune size={24} />} + leftIcon={<TuneIcon />} onClick={onClick} className={classes.menuItem} sidebarIsOpen={sidebarIsOpen} @@ -26,6 +26,4 @@ const PersonalMenu = forwardRef(({ onClick, sidebarIsOpen, dense }, ref) => { ) }) -PersonalMenu.displayName = 'PersonalMenu' - export default PersonalMenu diff --git a/ui/src/layout/PlaylistsSubMenu.jsx b/ui/src/layout/PlaylistsSubMenu.js similarity index 52% rename from ui/src/layout/PlaylistsSubMenu.jsx rename to ui/src/layout/PlaylistsSubMenu.js index f332f6810..e16aa7e18 100644 --- a/ui/src/layout/PlaylistsSubMenu.jsx +++ b/ui/src/layout/PlaylistsSubMenu.js @@ -1,24 +1,19 @@ -import React, { useCallback, useMemo, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' +import React, { useCallback } from 'react' import { MenuItemLink, useDataProvider, useNotify, useQueryWithStore, - useTranslate, } from 'react-admin' import { useHistory } from 'react-router-dom' import QueueMusicIcon from '@material-ui/icons/QueueMusic' import { Typography } from '@material-ui/core' import QueueMusicOutlinedIcon from '@material-ui/icons/QueueMusicOutlined' -import FavoriteIcon from '@material-ui/icons/Favorite' -import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' -import { BiListUl } from 'react-icons/bi' +import { BiCog } from 'react-icons/bi' import { useDrop } from 'react-dnd' import SubMenu from './SubMenu' -import { canChangeTracks, OverflowTooltip, useRefreshOnEvents } from '../common' +import { canChangeTracks } from '../common' import { DraggableTypes } from '../consts' -import { setSidebarPlaylistsOnlyFavourites } from '../actions' import config from '../config' const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => { @@ -44,11 +39,9 @@ const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => { <MenuItemLink to={`/playlist/${pls.id}/show`} primaryText={ - <OverflowTooltip title={pls.name} placement="right"> - <Typography variant="inherit" noWrap ref={dropRef}> - {pls.name} - </Typography> - </OverflowTooltip> + <Typography variant="inherit" noWrap ref={dropRef}> + {pls.name} + </Typography> } sidebarIsOpen={sidebarIsOpen} dense={false} @@ -58,37 +51,6 @@ const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => { const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { const history = useHistory() - const dispatch = useDispatch() - const translate = useTranslate() - const onlyFavourites = useSelector( - (state) => state.settings.sidebarPlaylistsOnlyFavourites, - ) - // Ignore a persisted preference when the feature is off, so disabling it later - // (with the toggle now hidden) doesn't strand the user on a filtered sidebar - const showFavouritesOnly = config.enableFavourites && onlyFavourites - const playlistData = useSelector( - (state) => state.admin.resources.playlist?.data, - ) - // Fingerprint of local star state; changes only when a playlist is (un)starred, - // so a local toggle refetches the sidebar without the SSE echo the actor never gets - const starFingerprint = useMemo(() => { - const data = playlistData || {} - return Object.keys(data) - .filter((id) => data[id]?.starred) - .sort() - .join(',') - }, [playlistData]) - const [refreshCount, setRefreshCount] = useState(0) - - // Only the favourites-only view depends on star state changing elsewhere; - // when showing all playlists a star event from another client changes nothing - // async because useRefreshOnEvents calls .catch() on the returned value - const onRefresh = useCallback(async () => { - if (showFavouritesOnly) setRefreshCount((count) => count + 1) - }, [showFavouritesOnly]) - useRefreshOnEvents({ events: ['playlist'], onRefresh }) - - // A changed payload signature makes useQueryWithStore refetch const { data, loaded } = useQueryWithStore({ type: 'getList', resource: 'playlist', @@ -98,11 +60,6 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { perPage: config.maxSidebarPlaylists, }, sort: { field: 'name' }, - ...(showFavouritesOnly && { - filter: { starred: true }, - starFingerprint, - refresh: refreshCount, - }), }, }) @@ -136,13 +93,9 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { const onPlaylistConfig = useCallback( () => history.push('/playlist'), - [history], + [history] ) - const handleToggleFavourites = useCallback(() => { - dispatch(setSidebarPlaylistsOnlyFavourites(!onlyFavourites)) - }, [dispatch, onlyFavourites]) - return ( <> <SubMenu @@ -152,20 +105,8 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { name={'menu.playlists'} icon={<QueueMusicIcon />} dense={dense} - actionIcon={<BiListUl />} + actionIcon={<BiCog />} onAction={onPlaylistConfig} - onSecondaryAction={ - config.enableFavourites ? handleToggleFavourites : undefined - } - secondaryActionIcon={ - onlyFavourites ? ( - <FavoriteIcon fontSize={'small'} /> - ) : ( - <FavoriteBorderIcon fontSize={'small'} /> - ) - } - secondaryActionTitle={translate('menu.onlyFavourites')} - secondaryActionActive={onlyFavourites} > {myPlaylists.map(renderPlaylistMenuItemLink)} </SubMenu> diff --git a/ui/src/layout/PlaylistsSubMenu.test.jsx b/ui/src/layout/PlaylistsSubMenu.test.jsx deleted file mode 100644 index 617f60a4e..000000000 --- a/ui/src/layout/PlaylistsSubMenu.test.jsx +++ /dev/null @@ -1,180 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent, act } from '@testing-library/react' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { Provider } from 'react-redux' -import { createStore, combineReducers } from 'redux' -import { ThemeProvider, createTheme } from '@material-ui/core/styles' -import { settingsReducer, activityReducer } from '../reducers' -import { processEvent, EVENT_REFRESH_RESOURCE } from '../actions' -import PlaylistsSubMenu from './PlaylistsSubMenu' - -const mockUseQueryWithStore = vi.fn() - -vi.mock('../config', () => ({ - // losslessFormats is read at module-load time by common/QualityInfo.jsx, - // pulled in transitively via the '../common' barrel file - default: { - enableFavourites: true, - maxSidebarPlaylists: 100, - losslessFormats: '', - }, -})) - -vi.mock('react-dnd', () => ({ - useDrop: () => [{}, () => {}], -})) - -vi.mock('react-router-dom', () => ({ - useHistory: () => ({ push: vi.fn() }), -})) - -vi.mock('react-admin', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useTranslate: () => (x) => x, - useDataProvider: () => ({ addToPlaylist: vi.fn() }), - useNotify: () => vi.fn(), - useQueryWithStore: (query) => mockUseQueryWithStore(query), - MenuItemLink: ({ primaryText }) => <div>{primaryText}</div>, - } -}) - -const playlists = { - 'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' }, - 'pl-2': { id: 'pl-2', name: 'Theirs', ownerId: 'user-2' }, -} - -const SET_PLAYLIST_DATA = 'TEST/SET_PLAYLIST_DATA' -const adminReducer = (state = { resources: {} }, action) => - action.type === SET_PLAYLIST_DATA - ? { resources: { playlist: { data: action.data } } } - : state - -const renderMenu = (preloadedSettings = {}, preloadedPlaylistData) => { - const store = createStore( - combineReducers({ - settings: settingsReducer, - activity: activityReducer, - admin: adminReducer, - }), - { - settings: preloadedSettings, - activity: {}, - admin: { - resources: preloadedPlaylistData - ? { playlist: { data: preloadedPlaylistData } } - : {}, - }, - }, - ) - const theme = createTheme() - render( - <Provider store={store}> - <ThemeProvider theme={theme}> - <PlaylistsSubMenu - state={{ menuPlaylists: true, menuSharedPlaylists: true }} - setState={vi.fn()} - sidebarIsOpen={true} - dense={false} - /> - </ThemeProvider> - </Provider>, - ) - return store -} - -const lastQuery = () => - mockUseQueryWithStore.mock.calls[ - mockUseQueryWithStore.mock.calls.length - 1 - ][0] - -describe('<PlaylistsSubMenu />', () => { - beforeEach(() => { - vi.clearAllMocks() - localStorage.setItem('userId', 'user-1') - mockUseQueryWithStore.mockReturnValue({ data: playlists, loaded: true }) - // SubMenu uses MUI's useMediaQuery, which needs window.matchMedia in jsdom - window.matchMedia = (query) => ({ - matches: false, - media: query, - addListener: () => {}, - removeListener: () => {}, - }) - // OverflowTooltip (via MenuItemLink) needs ResizeObserver, unavailable in jsdom - window.ResizeObserver = class { - observe() {} - unobserve() {} - disconnect() {} - } - }) - - it('queries without a starred filter by default', () => { - renderMenu() - expect(lastQuery().payload.filter).toBeUndefined() - expect(screen.getByText('Mine')).not.toBeNull() - expect(screen.getByText('Theirs')).not.toBeNull() - }) - - it('adds the starred filter when favourites-only is enabled', () => { - renderMenu({ sidebarPlaylistsOnlyFavourites: true }) - expect(lastQuery().payload.filter).toEqual({ starred: true }) - }) - - it('toggles the setting when the heart action is clicked', () => { - const store = renderMenu() - fireEvent.click(screen.getByTitle('menu.onlyFavourites')) - expect(store.getState().settings.sidebarPlaylistsOnlyFavourites).toBe(true) - expect(lastQuery().payload.filter).toEqual({ starred: true }) - }) - - it('refetches on a playlist SSE event when favourites-only is on', async () => { - const store = renderMenu({ sidebarPlaylistsOnlyFavourites: true }) - const before = lastQuery().payload.refresh - // useRefreshOnEvents compares Date.now() timestamps; make sure it advances - await act(() => new Promise((resolve) => setTimeout(resolve, 5))) - act(() => { - store.dispatch( - processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }), - ) - }) - expect(lastQuery().payload.refresh).toBe(before + 1) - }) - - it('does not change the query signature on an SSE event when favourites-only is off', async () => { - const store = renderMenu() - const before = JSON.stringify(lastQuery().payload) - await act(() => new Promise((resolve) => setTimeout(resolve, 5))) - act(() => { - store.dispatch( - processEvent(EVENT_REFRESH_RESOURCE, { playlist: ['pl-1'] }), - ) - }) - // Signature unchanged → useQueryWithStore dedupes, no wasted refetch - expect(lastQuery().payload.refresh).toBeUndefined() - expect(JSON.stringify(lastQuery().payload)).toBe(before) - }) - - it('refetches when a playlist is starred locally (no SSE echo)', () => { - const store = renderMenu( - { sidebarPlaylistsOnlyFavourites: true }, - { 'pl-1': { id: 'pl-1', name: 'Mine', ownerId: 'user-1' } }, - ) - const before = lastQuery().payload.starFingerprint - act(() => { - store.dispatch({ - type: SET_PLAYLIST_DATA, - data: { - 'pl-1': { - id: 'pl-1', - name: 'Mine', - ownerId: 'user-1', - starred: true, - }, - }, - }) - }) - expect(lastQuery().payload.starFingerprint).not.toBe(before) - expect(lastQuery().payload.starFingerprint).toContain('pl-1') - }) -}) diff --git a/ui/src/layout/SubMenu.jsx b/ui/src/layout/SubMenu.js similarity index 80% rename from ui/src/layout/SubMenu.jsx rename to ui/src/layout/SubMenu.js index ee1bf343e..d06d61bbd 100644 --- a/ui/src/layout/SubMenu.jsx +++ b/ui/src/layout/SubMenu.js @@ -33,9 +33,6 @@ const useStyles = makeStyles( menuHeader: { width: '100%', }, - headerText: { - flexGrow: 1, - }, headerWrapper: { display: 'flex', '&:hover $actionIcon': { @@ -45,7 +42,7 @@ const useStyles = makeStyles( }), { name: 'NDSubMenu', - }, + } ) const SubMenu = ({ @@ -58,10 +55,6 @@ const SubMenu = ({ dense, onAction, actionIcon, - onSecondaryAction, - secondaryActionIcon, - secondaryActionTitle, - secondaryActionActive, }) => { const translate = useTranslate() const classes = useStyles() @@ -77,11 +70,6 @@ const SubMenu = ({ } } - const handleSecondaryClick = (e) => { - e.stopPropagation() - onSecondaryAction(e) - } - const header = ( <div className={classes.headerWrapper}> <MenuItem @@ -93,26 +81,9 @@ const SubMenu = ({ <ListItemIcon className={classes.icon}> {isOpen ? <ExpandMore /> : icon} </ListItemIcon> - <Typography - variant="inherit" - color="textSecondary" - className={classes.headerText} - > + <Typography variant="inherit" color="textSecondary"> {translate(name)} </Typography> - {onSecondaryAction && sidebarIsOpen && ( - <IconButton - size={'small'} - title={secondaryActionTitle} - aria-label={secondaryActionTitle} - className={ - isDesktop && !secondaryActionActive ? classes.actionIcon : null - } - onClick={handleSecondaryClick} - > - {secondaryActionIcon} - </IconButton> - )} {onAction && sidebarIsOpen && ( <IconButton size={'small'} diff --git a/tests/fixtures/#snapshot/.gitkeep b/ui/src/layout/Themes.js similarity index 100% rename from tests/fixtures/#snapshot/.gitkeep rename to ui/src/layout/Themes.js diff --git a/ui/src/layout/Themes.jsx b/ui/src/layout/Themes.jsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/ui/src/layout/UserMenu.jsx b/ui/src/layout/UserMenu.js similarity index 95% rename from ui/src/layout/UserMenu.jsx rename to ui/src/layout/UserMenu.js index e33185578..980a82b32 100644 --- a/ui/src/layout/UserMenu.jsx +++ b/ui/src/layout/UserMenu.js @@ -28,9 +28,6 @@ import { useDispatch } from 'react-redux' const useStyles = makeStyles((theme) => ({ user: {}, - button: { - color: 'inherit', - }, avatar: { width: theme.spacing(4), height: theme.spacing(4), @@ -75,11 +72,12 @@ const UserMenu = (props) => { <div className={classes.user}> <Tooltip title={label && translate(label, { _: label })}> <IconButton - className={classes.button} aria-label={label && translate(label, { _: label })} aria-owns={open ? 'menu-appbar' : null} aria-haspopup={true} + color="inherit" onClick={handleMenu} + size={'small'} > {loaded && identity.avatar ? ( <Avatar @@ -120,9 +118,9 @@ const UserMenu = (props) => { ? cloneElement(menuItem, { onClick: handleClose, }) - : null, + : null )} - {(!config.auth || !!config.extAuthLogoutURL) && logout} + {!config.auth && logout} </MenuList> </Popover> </div> diff --git a/ui/src/layout/useInitialScanStatus.jsx b/ui/src/layout/useInitialScanStatus.jsx deleted file mode 100644 index 913f68e47..000000000 --- a/ui/src/layout/useInitialScanStatus.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useEffect } from 'react' -import { useDispatch } from 'react-redux' -import subsonic from '../subsonic' -import { scanStatusUpdate } from '../actions' - -export const useInitialScanStatus = () => { - const dispatch = useDispatch() - useEffect(() => { - subsonic - .getScanStatus() - .then((resp) => resp.json['subsonic-response']) - .then((data) => { - if (data.status === 'ok') { - dispatch(scanStatusUpdate(data.scanStatus)) - } - }) - }, [dispatch]) -} diff --git a/ui/src/layout/useScanElapsedTime.jsx b/ui/src/layout/useScanElapsedTime.jsx deleted file mode 100644 index af084a12d..000000000 --- a/ui/src/layout/useScanElapsedTime.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect, useState, useRef } from 'react' -import { useInterval } from '../common' - -export const useScanElapsedTime = (scanning, elapsedTime) => { - const [elapsed, setElapsed] = useState(Number(elapsedTime) || 0) - const prevScanningRef = useRef(scanning) - - useEffect(() => { - const prevScanning = prevScanningRef.current - const serverElapsed = Number(elapsedTime) || 0 - - if (scanning !== prevScanning) { - // Scan has just started or stopped - sync with server value - setElapsed(serverElapsed) - } else if (!scanning) { - // Not scanning -> always reflect server value (initial load or after finish) - setElapsed(serverElapsed) - } - - prevScanningRef.current = scanning - }, [scanning, elapsedTime]) - - useInterval(() => setElapsed((prev) => prev + 1e9), scanning ? 1000 : null) - - return elapsed -} diff --git a/ui/src/layout/useScanElapsedTime.test.jsx b/ui/src/layout/useScanElapsedTime.test.jsx deleted file mode 100644 index 1e948504b..000000000 --- a/ui/src/layout/useScanElapsedTime.test.jsx +++ /dev/null @@ -1,135 +0,0 @@ -import { renderHook, act } from '@testing-library/react-hooks' -import { vi } from 'vitest' -import { useScanElapsedTime } from './useScanElapsedTime' - -describe('useScanElapsedTime', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('increments elapsed time while scanning', () => { - const { result } = renderHook( - ({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed), - { - initialProps: { scanning: true, elapsed: 0 }, - }, - ) - - act(() => { - vi.advanceTimersByTime(3000) - }) - - expect(result.current).toBe(3e9) - }) - - it('stops incrementing when not scanning', () => { - const { result, rerender } = renderHook( - ({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed), - { - initialProps: { scanning: false, elapsed: 2e9 }, - }, - ) - - act(() => { - vi.advanceTimersByTime(2000) - }) - - expect(result.current).toBe(2e9) - - rerender({ scanning: true, elapsed: 2e9 }) - act(() => { - vi.advanceTimersByTime(1000) - }) - - expect(result.current).toBe(3e9) - }) - - it('initializes with server value when scan starts', () => { - const { result, rerender } = renderHook( - ({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed), - { - initialProps: { scanning: false, elapsed: 5e9 }, - }, - ) - - // Start scanning with a new elapsed time from server - rerender({ scanning: true, elapsed: 10e9 }) - - // Should use the server value when starting - expect(result.current).toBe(10e9) - - act(() => { - vi.advanceTimersByTime(2000) - }) - - // Should continue from server value - expect(result.current).toBe(12e9) - }) - - it('updates elapsed time when not scanning and server value changes', () => { - const { result, rerender } = renderHook( - ({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed), - { - initialProps: { scanning: false, elapsed: 0 }, - }, - ) - - // Server reports new elapsed time without changing scanning state - rerender({ scanning: false, elapsed: 8e9 }) - - expect(result.current).toBe(8e9) - }) - - it('ignores server updates during scanning', () => { - const { result, rerender } = renderHook( - ({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed), - { - initialProps: { scanning: true, elapsed: 0 }, - }, - ) - - act(() => { - vi.advanceTimersByTime(3000) - }) - - expect(result.current).toBe(3e9) - - // Server sends updated elapsed time during scan - rerender({ scanning: true, elapsed: 10e9 }) - - // Should ignore server update while scanning - expect(result.current).toBe(3e9) - - act(() => { - vi.advanceTimersByTime(1000) - }) - - // Should continue from local timer - expect(result.current).toBe(4e9) - }) - - it('uses final server value when scan ends', () => { - const { result, rerender } = renderHook( - ({ scanning, elapsed }) => useScanElapsedTime(scanning, elapsed), - { - initialProps: { scanning: true, elapsed: 0 }, - }, - ) - - act(() => { - vi.advanceTimersByTime(3000) - }) - - expect(result.current).toBe(3e9) - - // Scan ends with final server value - rerender({ scanning: false, elapsed: 5e9 }) - - // Should use the final server value - expect(result.current).toBe(5e9) - }) -}) diff --git a/ui/src/library/DeleteLibraryButton.jsx b/ui/src/library/DeleteLibraryButton.jsx deleted file mode 100644 index 8d9ff6ed2..000000000 --- a/ui/src/library/DeleteLibraryButton.jsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react' -import DeleteIcon from '@material-ui/icons/Delete' -import { makeStyles, alpha } from '@material-ui/core/styles' -import clsx from 'clsx' -import { - useNotify, - useDeleteWithConfirmController, - Button, - Confirm, - useTranslate, - useRedirect, -} from 'react-admin' - -const useStyles = makeStyles( - (theme) => ({ - deleteButton: { - color: theme.palette.error.main, - '&:hover': { - backgroundColor: alpha(theme.palette.error.main, 0.12), - // Reset on mouse devices - '@media (hover: none)': { - backgroundColor: 'transparent', - }, - }, - }, - }), - { name: 'RaDeleteWithConfirmButton' }, -) - -const DeleteLibraryButton = ({ - record, - resource, - basePath, - className, - ...props -}) => { - const translate = useTranslate() - const notify = useNotify() - const redirect = useRedirect() - - const onSuccess = () => { - notify('resources.library.notifications.deleted', 'info', { - smart_count: 1, - }) - redirect('/library') - } - - const { open, loading, handleDialogOpen, handleDialogClose, handleDelete } = - useDeleteWithConfirmController({ - resource, - record, - basePath, - onSuccess, - }) - - const classes = useStyles(props) - return ( - <> - <Button - label="ra.action.delete" - onClick={handleDialogOpen} - disabled={loading} - className={clsx('ra-delete-button', classes.deleteButton, className)} - {...props} - > - <DeleteIcon /> - </Button> - <Confirm - isOpen={open} - loading={loading} - title={translate('resources.library.name', { smart_count: 1 })} - content={translate('resources.library.messages.deleteConfirm')} - onConfirm={handleDelete} - onClose={handleDialogClose} - /> - </> - ) -} - -export default DeleteLibraryButton diff --git a/ui/src/library/LibraryCreate.jsx b/ui/src/library/LibraryCreate.jsx deleted file mode 100644 index 8166bb2f3..000000000 --- a/ui/src/library/LibraryCreate.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import React, { useCallback } from 'react' -import PropTypes from 'prop-types' -import { - Create, - SimpleForm, - TextInput, - BooleanInput, - required, - useTranslate, - useMutation, - useNotify, - useRedirect, -} from 'react-admin' -import { Typography } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import { Title } from '../common' -import { PIDInputs } from './PIDInput' - -const useStyles = makeStyles((theme) => ({ - spaced: { marginTop: theme.spacing(3) }, -})) - -// SimpleForm passes form props (variant, record, ...) to its children, so Typography can't be used directly -const SectionTitle = ({ label, spaced }) => { - const translate = useTranslate() - const classes = useStyles() - return ( - <Typography - variant="h6" - gutterBottom - className={spaced ? classes.spaced : undefined} - > - {translate(label)} - </Typography> - ) -} - -SectionTitle.propTypes = { - label: PropTypes.string.isRequired, - spaced: PropTypes.bool, -} - -const LibraryCreate = (props) => { - const translate = useTranslate() - const [mutate] = useMutation() - const notify = useNotify() - const redirect = useRedirect() - const resourceName = translate('resources.library.name', { smart_count: 1 }) - const title = translate('ra.page.create', { - name: `${resourceName}`, - }) - - const save = useCallback( - async (values) => { - try { - await mutate( - { - type: 'create', - resource: 'library', - payload: { data: values }, - }, - { returnPromise: true }, - ) - notify('resources.library.notifications.created', 'info', { - smart_count: 1, - }) - redirect('/library') - } catch (error) { - // Handle validation errors with proper field mapping - if (error.body && error.body.errors) { - return error.body.errors - } - - // Handle other structured errors from the server - if (error.body && error.body.error) { - const errorMsg = error.body.error - - // Handle database constraint violations - if (errorMsg.includes('UNIQUE constraint failed: library.name')) { - return { name: 'ra.validation.unique' } - } - if (errorMsg.includes('UNIQUE constraint failed: library.path')) { - return { path: 'ra.validation.unique' } - } - - // Show a general notification for other server errors - notify(errorMsg, 'error') - return - } - - // Fallback for unexpected error formats - const fallbackMessage = - error.message || - (typeof error === 'string' ? error : 'An unexpected error occurred') - notify(fallbackMessage, 'error') - } - }, - [mutate, notify, redirect], - ) - - return ( - <Create title={<Title subTitle={title} />} {...props}> - <SimpleForm save={save} variant={'outlined'}> - <SectionTitle label="resources.library.sections.basic" /> - <TextInput source="name" validate={[required()]} /> - <TextInput source="path" validate={[required()]} fullWidth /> - <BooleanInput source="defaultNewUsers" /> - <SectionTitle label="resources.library.sections.pid" spaced /> - <PIDInputs /> - </SimpleForm> - </Create> - ) -} - -export default LibraryCreate diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx deleted file mode 100644 index c42c7ac4b..000000000 --- a/ui/src/library/LibraryEdit.jsx +++ /dev/null @@ -1,235 +0,0 @@ -import React, { useCallback, useState } from 'react' -import PropTypes from 'prop-types' -import { - Edit, - FormWithRedirect, - TextInput, - BooleanInput, - Confirm, - required, - SaveButton, - useTranslate, - useMutation, - useNotify, - useRedirect, - Toolbar, -} from 'react-admin' -import { Typography, Box } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import DeleteLibraryButton from './DeleteLibraryButton' -import { - ReadOnlyDateField, - ReadOnlyDurationField, - ReadOnlyNumberField, - ReadOnlySizeField, - Title, -} from '../common' -import config from '../config' -import { PIDInputs } from './PIDInput' -import { pidConfigChanged } from './pidPresets' - -const useStyles = makeStyles({ - toolbar: { - display: 'flex', - justifyContent: 'space-between', - }, -}) - -const readOnlyProps = { resource: 'library', fullWidth: true } - -const LibraryTitle = ({ record }) => { - const translate = useTranslate() - const resourceName = translate('resources.library.name', { smart_count: 1 }) - return ( - <Title subTitle={`${resourceName} ${record ? `"${record.name}"` : ''}`} /> - ) -} - -const CustomToolbar = ({ showDelete, ...props }) => ( - <Toolbar {...props} classes={useStyles()}> - <SaveButton disabled={props.pristine} /> - {showDelete && ( - <DeleteLibraryButton - record={props.record} - resource="library" - basePath="/library" - /> - )} - </Toolbar> -) - -export const LibraryEditForm = ({ formProps, canEditPath, canDelete }) => { - const translate = useTranslate() - const [confirmOpen, setConfirmOpen] = useState(false) - - // Every submit path (Save button and Enter key) goes through here, so a PID change always asks first - const submit = () => { - if ( - pidConfigChanged( - formProps.form.getState().values, - formProps.record, - config, - ) - ) { - setConfirmOpen(true) - return - } - formProps.handleSubmit() - } - - const handleConfirm = () => { - setConfirmOpen(false) - formProps.handleSubmit() - } - - return ( - <form - onSubmit={(event) => { - event.preventDefault() - submit() - }} - > - <Box p="1em" maxWidth="800px"> - <Box display="flex"> - <Box flex={1} mr="1em"> - {/* Basic Information */} - <Typography variant="h6" gutterBottom> - {translate('resources.library.sections.basic')} - </Typography> - - <TextInput - source="name" - label={translate('resources.library.fields.name')} - validate={[required()]} - variant="outlined" - /> - <TextInput - source="path" - label={translate('resources.library.fields.path')} - validate={[required()]} - fullWidth - variant="outlined" - InputProps={{ readOnly: !canEditPath }} // Disable editing path for library 1 - /> - <BooleanInput - source="defaultNewUsers" - label={translate('resources.library.fields.defaultNewUsers')} - variant="outlined" - /> - - <Box mt="2em" /> - <Typography variant="h6" gutterBottom> - {translate('resources.library.sections.pid')} - </Typography> - <PIDInputs /> - - <Box mt="2em" /> - - {/* Statistics - Two Column Layout */} - <Typography variant="h6" gutterBottom> - {translate('resources.library.sections.statistics')} - </Typography> - - <Box - display="grid" - gridTemplateColumns="1fr 1fr" - gridColumnGap="1em" - > - <ReadOnlyNumberField source="totalSongs" {...readOnlyProps} /> - <ReadOnlyNumberField source="totalAlbums" {...readOnlyProps} /> - <ReadOnlyNumberField source="totalArtists" {...readOnlyProps} /> - <ReadOnlySizeField source="totalSize" {...readOnlyProps} /> - <ReadOnlyDurationField - source="totalDuration" - {...readOnlyProps} - /> - <ReadOnlyNumberField - source="totalMissingFiles" - {...readOnlyProps} - /> - <Box gridColumn="1 / -1"> - <ReadOnlyDateField source="lastScanAt" {...readOnlyProps} /> - </Box> - <ReadOnlyDateField source="updatedAt" {...readOnlyProps} /> - <ReadOnlyDateField source="createdAt" {...readOnlyProps} /> - </Box> - </Box> - </Box> - </Box> - - <CustomToolbar - handleSubmitWithRedirect={submit} - pristine={formProps.pristine} - saving={formProps.saving} - record={formProps.record} - showDelete={canDelete} - /> - <Confirm - isOpen={confirmOpen} - loading={formProps.saving} - title="resources.library.messages.pidChangeTitle" - content="resources.library.messages.pidChangeConfirm" - onConfirm={handleConfirm} - onClose={() => setConfirmOpen(false)} - /> - </form> - ) -} - -LibraryEditForm.propTypes = { - formProps: PropTypes.object.isRequired, - canEditPath: PropTypes.bool, - canDelete: PropTypes.bool, -} - -const LibraryEdit = (props) => { - const [mutate] = useMutation() - const notify = useNotify() - const redirect = useRedirect() - - // Library ID 1 is protected (main library) - const canDelete = props.id !== '1' - const canEditPath = props.id !== '1' - - const save = useCallback( - async (values) => { - try { - await mutate( - { - type: 'update', - resource: 'library', - payload: { id: values.id, data: values }, - }, - { returnPromise: true }, - ) - notify('resources.library.notifications.updated', 'info', { - smart_count: 1, - }) - redirect('/library') - } catch (error) { - if (error.body && error.body.errors) { - return error.body.errors - } - } - }, - [mutate, notify, redirect], - ) - - return ( - <Edit title={<LibraryTitle />} undoable={false} {...props}> - <FormWithRedirect - {...props} - save={save} - render={(formProps) => ( - <LibraryEditForm - formProps={formProps} - canEditPath={canEditPath} - canDelete={canDelete} - /> - )} - /> - </Edit> - ) -} - -export default LibraryEdit diff --git a/ui/src/library/LibraryEdit.test.jsx b/ui/src/library/LibraryEdit.test.jsx deleted file mode 100644 index 926adc839..000000000 --- a/ui/src/library/LibraryEdit.test.jsx +++ /dev/null @@ -1,125 +0,0 @@ -import * as React from 'react' -import { TestContext } from 'ra-test' -import { - FormWithRedirect, - RecordContextProvider, - SaveContextProvider, -} from 'react-admin' -import { - cleanup, - fireEvent, - render, - screen, - waitFor, - within, -} from '@testing-library/react' -import { describe, it, expect, vi, afterEach } from 'vitest' -import { LibraryEditForm } from './LibraryEdit' -import config from '../config' - -const record = { - id: '2', - name: 'Jazz', - path: '/music/jazz', - pidAlbum: '', - pidTrack: '', -} - -// Edit provides a save context in the app. SaveButton only reads these setters from it -const saveContext = { - save: vi.fn(), - setOnSuccess: vi.fn(), - setOnFailure: vi.fn(), - setTransform: vi.fn(), -} - -const renderForm = (save) => - render( - <TestContext> - <SaveContextProvider value={saveContext}> - <RecordContextProvider value={record}> - <FormWithRedirect - record={record} - save={save} - render={(formProps) => ( - <LibraryEditForm - formProps={formProps} - canEditPath - canDelete={false} - /> - )} - /> - </RecordContextProvider> - </SaveContextProvider> - </TestContext>, - ) - -const chooseAlbumGrouping = (optionText) => { - fireEvent.mouseDown( - screen.getByLabelText('resources.library.fields.pidAlbum'), - ) - fireEvent.click(within(screen.getByRole('listbox')).getByText(optionText)) -} - -const dialogTitle = 'resources.library.messages.pidChangeTitle' - -describe('LibraryEditForm', () => { - afterEach(cleanup) - - it('saves directly when the PID config did not change', async () => { - const save = vi.fn() - renderForm(save) - fireEvent.change(screen.getByLabelText(/resources.library.fields.name/), { - target: { value: 'Jazz Renamed' }, - }) - fireEvent.click(screen.getByText('ra.action.save')) - await waitFor(() => expect(save).toHaveBeenCalled()) - expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument() - }) - - it('asks before saving a PID change, and Cancel keeps the edits', async () => { - const save = vi.fn() - renderForm(save) - chooseAlbumGrouping('resources.library.pid.folder') - fireEvent.click(screen.getByText('ra.action.save')) - - expect(await screen.findByText(dialogTitle)).toBeInTheDocument() - expect(save).not.toHaveBeenCalled() - - fireEvent.click(screen.getByText('ra.action.cancel')) - await waitFor(() => - expect(screen.queryByText(dialogTitle)).not.toBeInTheDocument(), - ) - expect(save).not.toHaveBeenCalled() - expect(screen.getByText('resources.library.pid.folder')).toBeInTheDocument() - }) - - it('saves the PID change after Confirm', async () => { - const save = vi.fn() - renderForm(save) - chooseAlbumGrouping('resources.library.pid.folder') - fireEvent.click(screen.getByText('ra.action.save')) - fireEvent.click(await screen.findByText('ra.action.confirm')) - - await waitFor(() => expect(save).toHaveBeenCalled()) - expect(save.mock.calls[0][0]).toMatchObject({ pidAlbum: 'folder' }) - }) - - it('pre-fills a Custom spec with the global spec', () => { - renderForm(vi.fn()) - chooseAlbumGrouping('resources.library.pid.custom') - expect(screen.getByLabelText(/resources.library.pid.spec/)).toHaveValue( - config.pidAlbum, - ) - }) - - it('asks before saving when the form is submitted with Enter', async () => { - const save = vi.fn() - const { container } = renderForm(save) - chooseAlbumGrouping('resources.library.pid.folder') - fireEvent.submit(container.querySelector('form')) - - expect(await screen.findByText(dialogTitle)).toBeInTheDocument() - expect(save).not.toHaveBeenCalled() - }) -}) diff --git a/ui/src/library/LibraryList.jsx b/ui/src/library/LibraryList.jsx deleted file mode 100644 index 35d627cbb..000000000 --- a/ui/src/library/LibraryList.jsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from 'react' -import { - Datagrid, - Filter, - SearchInput, - SimpleList, - TextField, - NumberField, - BooleanField, -} from 'react-admin' -import { useMediaQuery } from '@material-ui/core' -import { List, DateField, useResourceRefresh, SizeField } from '../common' -import LibraryListBulkActions from './LibraryListBulkActions' -import LibraryListActions from './LibraryListActions' - -const LibraryFilter = (props) => ( - <Filter {...props} variant={'outlined'}> - <SearchInput source="name" alwaysOn /> - </Filter> -) - -const LibraryList = (props) => { - const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) - const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) - useResourceRefresh('library') - - return ( - <List - {...props} - sort={{ field: 'name', order: 'ASC' }} - exporter={false} - bulkActionButtons={!isXsmall && <LibraryListBulkActions />} - filters={<LibraryFilter />} - actions={<LibraryListActions />} - > - {isXsmall ? ( - <SimpleList - primaryText={(record) => record.name} - secondaryText={(record) => record.path} - /> - ) : ( - <Datagrid rowClick="edit"> - <TextField source="name" /> - {isDesktop && <TextField source="path" />} - <BooleanField source="defaultNewUsers" /> - <NumberField source="totalSongs" /> - <NumberField source="totalAlbums" /> - <NumberField source="totalMissingFiles" /> - <SizeField source="totalSize" /> - <DateField source="lastScanAt" sortByOrder={'DESC'} /> - </Datagrid> - )} - </List> - ) -} - -export default LibraryList diff --git a/ui/src/library/LibraryListActions.jsx b/ui/src/library/LibraryListActions.jsx deleted file mode 100644 index f4d0913df..000000000 --- a/ui/src/library/LibraryListActions.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { cloneElement } from 'react' -import { sanitizeListRestProps, TopToolbar, CreateButton } from 'react-admin' -import LibraryScanButton from './LibraryScanButton' - -const LibraryListActions = ({ - className, - filters, - resource, - showFilter, - displayedFilters, - filterValues, - ...rest -}) => { - return ( - <TopToolbar className={className} {...sanitizeListRestProps(rest)}> - {filters && - cloneElement(filters, { - resource, - showFilter, - displayedFilters, - filterValues, - context: 'button', - })} - <LibraryScanButton fullScan={false} /> - <LibraryScanButton fullScan={true} /> - <CreateButton /> - </TopToolbar> - ) -} - -export default LibraryListActions diff --git a/ui/src/library/LibraryListBulkActions.jsx b/ui/src/library/LibraryListBulkActions.jsx deleted file mode 100644 index 8862a4f51..000000000 --- a/ui/src/library/LibraryListBulkActions.jsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react' -import LibraryScanButton from './LibraryScanButton' - -const LibraryListBulkActions = (props) => ( - <> - <LibraryScanButton fullScan={false} {...props} /> - <LibraryScanButton fullScan={true} {...props} /> - </> -) - -export default LibraryListBulkActions diff --git a/ui/src/library/LibraryScanButton.jsx b/ui/src/library/LibraryScanButton.jsx deleted file mode 100644 index b793844fd..000000000 --- a/ui/src/library/LibraryScanButton.jsx +++ /dev/null @@ -1,77 +0,0 @@ -import React, { useState } from 'react' -import PropTypes from 'prop-types' -import { - Button, - useNotify, - useRefresh, - useTranslate, - useUnselectAll, -} from 'react-admin' -import { useSelector } from 'react-redux' -import { GiMagnifyingGlass } from 'react-icons/gi' -import { VscSync } from 'react-icons/vsc' -import subsonic from '../subsonic' - -const LibraryScanButton = ({ fullScan, selectedIds, className }) => { - const [loading, setLoading] = useState(false) - const notify = useNotify() - const refresh = useRefresh() - const translate = useTranslate() - const unselectAll = useUnselectAll() - const scanStatus = useSelector((state) => state.activity.scanStatus) - - const handleClick = async () => { - setLoading(true) - try { - // Build scan options - const options = { fullScan } - - // If specific libraries are selected, scan only those - // Format: "libraryID:" to scan entire library (no folder path specified) - if (selectedIds && selectedIds.length > 0) { - options.target = selectedIds.map((id) => `${id}:`) - } - - await subsonic.startScan(options) - const notificationKey = fullScan - ? 'resources.library.notifications.fullScanStarted' - : 'resources.library.notifications.quickScanStarted' - notify(notificationKey, 'info') - refresh() - - // Unselect all items after successful scan - unselectAll('library') - } catch (error) { - notify('resources.library.notifications.scanError', 'warning') - } finally { - setLoading(false) - } - } - - const isDisabled = loading || scanStatus.scanning - - const label = fullScan - ? translate('resources.library.actions.fullScan') - : translate('resources.library.actions.quickScan') - - const icon = fullScan ? <GiMagnifyingGlass /> : <VscSync /> - - return ( - <Button - onClick={handleClick} - disabled={isDisabled} - label={label} - className={className} - > - {icon} - </Button> - ) -} - -LibraryScanButton.propTypes = { - fullScan: PropTypes.bool.isRequired, - selectedIds: PropTypes.array, - className: PropTypes.string, -} - -export default LibraryScanButton diff --git a/ui/src/library/PIDInput.jsx b/ui/src/library/PIDInput.jsx deleted file mode 100644 index 6481dc392..000000000 --- a/ui/src/library/PIDInput.jsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useState } from 'react' -import PropTypes from 'prop-types' -import { TextInput, required, useTranslate } from 'react-admin' -import { useField } from 'react-final-form' -import { FormHelperText, Link, MenuItem, TextField } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import { - PID_CUSTOM, - PID_FOLDER, - PID_GLOBAL, - pidModeFromValue, - pidValueForMode, -} from './pidPresets' -import config from '../config' -import { docsUrl } from '../utils' - -const PID_DOCS_URL = docsUrl('/docs/usage/pids/') - -const useStyles = makeStyles((theme) => ({ - help: { marginBottom: theme.spacing(1) }, -})) - -// PIDInput edits a library PID override: use the global setting, a preset, or a custom spec -export const PIDInput = ({ source, label, globalValue, allowFolder }) => { - const translate = useTranslate() - const classes = useStyles() - const { input } = useField(source) - // Local state, so choosing Custom shows the text box before anything is typed - const [mode, setMode] = useState(() => - pidModeFromValue(input.value, allowFolder), - ) - - const choices = [ - { - id: PID_GLOBAL, - name: translate('resources.library.pid.global', { value: globalValue }), - }, - ...(allowFolder - ? [{ id: PID_FOLDER, name: translate('resources.library.pid.folder') }] - : []), - { id: PID_CUSTOM, name: translate('resources.library.pid.custom') }, - ] - - const handleModeChange = (event) => { - const newMode = event.target.value - setMode(newMode) - input.onChange(pidValueForMode(newMode, globalValue)) - } - - return ( - <> - <TextField - id={`${source}-mode`} - select - fullWidth - variant="outlined" - margin="dense" - label={label} - value={mode} - onChange={handleModeChange} - > - {choices.map((choice) => ( - <MenuItem key={choice.id} value={choice.id}> - {choice.name} - </MenuItem> - ))} - </TextField> - {mode === PID_CUSTOM && ( - <> - <TextInput - source={source} - label={translate('resources.library.pid.spec')} - validate={[required()]} - fullWidth - variant="outlined" - helperText={false} - /> - <FormHelperText className={classes.help}> - {translate('resources.library.pid.help')}{' '} - <Link href={PID_DOCS_URL} target="_blank" rel="noopener noreferrer"> - {translate('resources.library.pid.docs')} - </Link> - </FormHelperText> - </> - )} - </> - ) -} - -PIDInput.propTypes = { - source: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - globalValue: PropTypes.string, - allowFolder: PropTypes.bool, -} - -export const PIDInputs = () => { - const translate = useTranslate() - return ( - <> - <PIDInput - source="pidAlbum" - label={translate('resources.library.fields.pidAlbum')} - globalValue={config.pidAlbum} - allowFolder - /> - <PIDInput - source="pidTrack" - label={translate('resources.library.fields.pidTrack')} - globalValue={config.pidTrack} - /> - </> - ) -} diff --git a/ui/src/library/index.js b/ui/src/library/index.js deleted file mode 100644 index 3a8b71b52..000000000 --- a/ui/src/library/index.js +++ /dev/null @@ -1,11 +0,0 @@ -import { MdLibraryMusic } from 'react-icons/md' -import LibraryList from './LibraryList' -import LibraryEdit from './LibraryEdit' -import LibraryCreate from './LibraryCreate' - -export default { - icon: MdLibraryMusic, - list: LibraryList, - edit: LibraryEdit, - create: LibraryCreate, -} diff --git a/ui/src/library/pidPresets.js b/ui/src/library/pidPresets.js deleted file mode 100644 index 0483fc691..000000000 --- a/ui/src/library/pidPresets.js +++ /dev/null @@ -1,33 +0,0 @@ -export const PID_GLOBAL = 'global' -export const PID_FOLDER = 'folder' -export const PID_CUSTOM = 'custom' - -export const pidModeFromValue = (value, allowFolder) => { - const v = (value || '').trim() - if (v === '') return PID_GLOBAL - if (allowFolder && v === PID_FOLDER) return PID_FOLDER - return PID_CUSTOM -} - -// Returns the value to store for a dropdown choice. Custom starts from the global spec -export const pidValueForMode = (mode, globalValue) => { - switch (mode) { - case PID_GLOBAL: - return '' - case PID_FOLDER: - return PID_FOLDER - default: - return globalValue || '' - } -} - -// Reports whether the form values change the effective PID spec of the saved record. Like the -// server, it trims, treats empty as the global value and compares case-insensitively -export const pidConfigChanged = (values, record, globals) => { - const effective = (value, field) => - ((value || '').trim() || globals[field] || '').toLowerCase() - return ['pidAlbum', 'pidTrack'].some( - (field) => - effective(values[field], field) !== effective(record[field], field), - ) -} diff --git a/ui/src/library/pidPresets.test.js b/ui/src/library/pidPresets.test.js deleted file mode 100644 index dab1c82e2..000000000 --- a/ui/src/library/pidPresets.test.js +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - PID_CUSTOM, - PID_FOLDER, - PID_GLOBAL, - pidConfigChanged, - pidModeFromValue, - pidValueForMode, -} from './pidPresets' - -describe('pidModeFromValue', () => { - it('maps an empty value to the global setting', () => { - expect(pidModeFromValue('', true)).toBe(PID_GLOBAL) - expect(pidModeFromValue(undefined, true)).toBe(PID_GLOBAL) - }) - it('maps folder to the Folder preset when allowed', () => { - expect(pidModeFromValue('folder', true)).toBe(PID_FOLDER) - }) - it('maps folder to Custom when the Folder preset is not offered', () => { - expect(pidModeFromValue('folder', false)).toBe(PID_CUSTOM) - }) - it('maps any other value to Custom', () => { - expect(pidModeFromValue('album|title', true)).toBe(PID_CUSTOM) - }) -}) - -describe('pidValueForMode', () => { - it('stores an empty value for the global setting', () => { - expect(pidValueForMode(PID_GLOBAL, 'album')).toBe('') - }) - it('stores folder for the Folder preset', () => { - expect(pidValueForMode(PID_FOLDER, '')).toBe('folder') - }) - it('starts Custom from the global spec', () => { - expect(pidValueForMode(PID_CUSTOM, 'album|title')).toBe('album|title') - expect(pidValueForMode(PID_CUSTOM, undefined)).toBe('') - }) -}) - -describe('pidConfigChanged', () => { - const record = { pidAlbum: 'folder', pidTrack: '' } - const globals = { - pidAlbum: 'musicbrainz_albumid|albumartistid,album', - pidTrack: 'musicbrainz_trackid|albumid,discnumber,tracknumber,title', - } - it.each([ - ['nothing changed', { pidAlbum: 'folder', pidTrack: '' }, false], - ['a missing value equals an empty one', { pidAlbum: 'folder' }, false], - [ - 'Custom set to the global value', - { pidAlbum: 'folder', pidTrack: globals.pidTrack }, - false, - ], - ['a case-only change', { pidAlbum: 'FOLDER', pidTrack: '' }, false], - [ - 'a whitespace-only change', - { pidAlbum: ' folder ', pidTrack: ' ' }, - false, - ], - ['the album PID changed', { pidAlbum: '', pidTrack: '' }, true], - ['the track PID changed', { pidAlbum: 'folder', pidTrack: 'title' }, true], - ])('%s', (_, values, expected) => { - expect(pidConfigChanged(values, record, globals)).toBe(expected) - }) -}) diff --git a/ui/src/missing/DeleteMissingFilesButton.jsx b/ui/src/missing/DeleteMissingFilesButton.jsx deleted file mode 100644 index f02bb81ae..000000000 --- a/ui/src/missing/DeleteMissingFilesButton.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import React, { useState } from 'react' -import DeleteIcon from '@material-ui/icons/Delete' -import { makeStyles, alpha } from '@material-ui/core/styles' -import clsx from 'clsx' -import { - Button, - Confirm, - useNotify, - useDeleteMany, - useRefresh, - useUnselectAll, -} from 'react-admin' - -const useStyles = makeStyles( - (theme) => ({ - deleteButton: { - color: theme.palette.error.main, - '&:hover': { - backgroundColor: alpha(theme.palette.error.main, 0.12), - // Reset on mouse devices - '@media (hover: none)': { - backgroundColor: 'transparent', - }, - }, - }, - }), - { name: 'RaDeleteWithConfirmButton' }, -) - -const DeleteMissingFilesButton = (props) => { - const { selectedIds, className, deleteAll = false } = props - const [open, setOpen] = useState(false) - const unselectAll = useUnselectAll() - const refresh = useRefresh() - const notify = useNotify() - - const ids = deleteAll ? [] : selectedIds - const [deleteMany, { loading }] = useDeleteMany('missing', ids, { - onSuccess: () => { - notify('resources.missing.notifications.removed') - refresh() - unselectAll('missing') - }, - onFailure: (error) => - notify('Error: missing files not deleted', { type: 'warning' }), - }) - const handleClick = () => setOpen(true) - const handleDialogClose = () => setOpen(false) - const handleConfirm = () => { - deleteMany() - setOpen(false) - } - - const classes = useStyles(props) - - return ( - <> - <Button - onClick={handleClick} - label={ - deleteAll - ? 'resources.missing.actions.remove_all' - : 'ra.action.remove' - } - key="button" - className={clsx('ra-delete-button', classes.deleteButton, className)} - > - <DeleteIcon /> - </Button> - <Confirm - isOpen={open} - loading={loading} - title={ - deleteAll - ? 'message.remove_all_missing_title' - : 'message.remove_missing_title' - } - content={ - deleteAll - ? 'message.remove_all_missing_content' - : 'message.remove_missing_content' - } - onConfirm={handleConfirm} - onClose={handleDialogClose} - /> - </> - ) -} - -export default DeleteMissingFilesButton diff --git a/ui/src/missing/DeleteMissingFilesButton.test.jsx b/ui/src/missing/DeleteMissingFilesButton.test.jsx deleted file mode 100644 index cafae9afe..000000000 --- a/ui/src/missing/DeleteMissingFilesButton.test.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react' -import { render } from '@testing-library/react' -import { describe, it, expect, beforeEach, vi } from 'vitest' -import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx' -import * as RA from 'react-admin' - -vi.mock('react-admin', async () => { - const actual = await vi.importActual('react-admin') - return { - ...actual, - Button: ({ children, onClick, label }) => ( - <button onClick={onClick}>{label || children}</button> - ), - Confirm: ({ isOpen }) => (isOpen ? <div data-testid="confirm" /> : null), - useNotify: vi.fn(), - useDeleteMany: vi.fn(() => [vi.fn(), { loading: false }]), - useRefresh: vi.fn(), - useUnselectAll: vi.fn(), - } -}) - -describe('DeleteMissingFilesButton', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('uses remove_all label when deleteAll is true', () => { - const { getByRole } = render(<DeleteMissingFilesButton deleteAll />) - expect(getByRole('button').textContent).toBe( - 'resources.missing.actions.remove_all', - ) - }) - - it('calls useDeleteMany with empty ids when deleteAll is true', () => { - render(<DeleteMissingFilesButton deleteAll />) - expect(RA.useDeleteMany).toHaveBeenCalledWith( - 'missing', - [], - expect.any(Object), - ) - }) -}) diff --git a/ui/src/missing/MissingFilesList.jsx b/ui/src/missing/MissingFilesList.jsx deleted file mode 100644 index a09f552d9..000000000 --- a/ui/src/missing/MissingFilesList.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import { - List, - Pagination, - SizeField, - getStoredPerPage, - useResourceRefresh, -} from '../common/index' -import { - Datagrid, - DateField, - TextField, - downloadCSV, - Filter, - ReferenceInput, - useTranslate, - SelectInput, -} from 'react-admin' -import jsonExport from 'jsonexport/dist' -import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx' -import MissingListActions from './MissingListActions.jsx' -import React from 'react' - -const exporter = (files) => { - const filesToExport = files.map((file) => { - const { path } = file - return { path } - }) - jsonExport(filesToExport, { includeHeaders: false }, (err, csv) => { - downloadCSV(csv, 'navidrome_missing_files') - }) -} - -const MissingFilesFilter = (props) => { - const translate = useTranslate() - return ( - <Filter {...props} variant={'outlined'}> - <ReferenceInput - label={translate('resources.missing.fields.libraryName')} - source="library_id" - reference="library" - sort={{ field: 'name', order: 'ASC' }} - filterToQuery={(searchText) => ({ name: [searchText] })} - alwaysOn - > - <SelectInput emptyText="-- All --" optionText="name" /> - </ReferenceInput> - </Filter> - ) -} - -const BulkActionButtons = (props) => ( - <> - <DeleteMissingFilesButton {...props} /> - </> -) - -const missingPerPageOptions = [50, 100, 200] - -const MissingPagination = (props) => ( - <Pagination rowsPerPageOptions={missingPerPageOptions} {...props} /> -) - -const MissingFilesList = (props) => { - useResourceRefresh('song') - return ( - <List - {...props} - sort={{ field: 'updated_at', order: 'DESC' }} - exporter={exporter} - actions={<MissingListActions />} - filters={<MissingFilesFilter />} - bulkActionButtons={<BulkActionButtons />} - perPage={getStoredPerPage('missing', missingPerPageOptions)} - pagination={<MissingPagination />} - > - <Datagrid> - <TextField source={'libraryName'} /> - <TextField source={'path'} /> - <SizeField source={'size'} /> - <DateField source={'updatedAt'} showTime /> - </Datagrid> - </List> - ) -} - -export default MissingFilesList diff --git a/ui/src/missing/MissingListActions.jsx b/ui/src/missing/MissingListActions.jsx deleted file mode 100644 index fc5c4f7e3..000000000 --- a/ui/src/missing/MissingListActions.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react' -import { TopToolbar, ExportButton, useListContext } from 'react-admin' -import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx' - -const MissingListActions = (props) => { - const { total } = useListContext() - return ( - <TopToolbar {...props}> - <ExportButton maxResults={total} /> - <DeleteMissingFilesButton deleteAll /> - </TopToolbar> - ) -} - -export default MissingListActions diff --git a/ui/src/missing/index.js b/ui/src/missing/index.js deleted file mode 100644 index 471dcd1e9..000000000 --- a/ui/src/missing/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import { GrDocumentMissing } from 'react-icons/gr' -import MissingList from './MissingFilesList' -export default { - list: MissingList, - icon: GrDocumentMissing, -} diff --git a/ui/src/personal/HelpMsg.jsx b/ui/src/personal/HelpMsg.js similarity index 100% rename from ui/src/personal/HelpMsg.jsx rename to ui/src/personal/HelpMsg.js diff --git a/ui/src/personal/LastfmScrobbleToggle.jsx b/ui/src/personal/LastfmScrobbleToggle.js similarity index 60% rename from ui/src/personal/LastfmScrobbleToggle.jsx rename to ui/src/personal/LastfmScrobbleToggle.js index c8e07328f..92faf307f 100644 --- a/ui/src/personal/LastfmScrobbleToggle.jsx +++ b/ui/src/personal/LastfmScrobbleToggle.js @@ -3,20 +3,30 @@ import { useNotify, useTranslate } from 'react-admin' import { FormControl, FormControlLabel, - FormHelperText, LinearProgress, Switch, - Tooltip, } from '@material-ui/core' import { useInterval } from '../common' +import config from '../config' import { baseUrl, openInNewTab } from '../utils' import { httpClient } from '../dataProvider' const Progress = (props) => { - const { setLinked, setCheckingLink, openedTab } = props + const { setLinked, setCheckingLink } = props const notify = useNotify() let linkCheckDelay = 2000 let linkChecks = 30 + const openedTab = useRef() + + useEffect(() => { + const callbackEndpoint = baseUrl( + `/api/lastfm/link/callback?uid=${localStorage.getItem('userId')}` + ) + const callbackUrl = `${window.location.origin}${callbackEndpoint}` + openedTab.current = openInNewTab( + `https://www.last.fm/api/auth/?api_key=${config.lastFMApiKey}&cb=${callbackUrl}` + ) + }, []) const endChecking = (success) => { linkCheckDelay = null @@ -64,56 +74,10 @@ export const LastfmScrobbleToggle = (props) => { const translate = useTranslate() const [linked, setLinked] = useState(null) const [checkingLink, setCheckingLink] = useState(false) - const [apiKey, setApiKey] = useState(false) - const openedTab = useRef() - - useEffect(() => { - httpClient('/api/lastfm/link') - .then((response) => { - setLinked(response.json.status === true) - setApiKey(response.json.apiKey) - }) - .catch(() => { - setLinked(false) - }) - }, [setLinked, setApiKey]) - - const startLink = () => { - // Open the tab synchronously so popup blockers attribute it to the click. - let tab - try { - tab = openInNewTab('about:blank') - } catch { - notify('message.lastfmLinkFailure', 'warning') - return - } - openedTab.current = tab - setCheckingLink(true) - httpClient('/api/lastfm/link') - .then((response) => { - const linkToken = response.json.linkToken - if (!linkToken) { - tab?.close() - notify('message.lastfmLinkFailure', 'warning') - setCheckingLink(false) - return - } - const callbackEndpoint = baseUrl( - `/api/lastfm/link/callback?uid=${encodeURIComponent(linkToken)}`, - ) - const callbackUrl = `${window.location.origin}${callbackEndpoint}` - tab.location.href = `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${callbackUrl}` - }) - .catch(() => { - tab?.close() - notify('message.lastfmLinkFailure', 'warning') - setCheckingLink(false) - }) - } const toggleScrobble = () => { if (!linked) { - startLink() + setCheckingLink(true) } else { httpClient('/api/lastfm/link', { method: 'DELETE' }) .then(() => { @@ -124,6 +88,16 @@ export const LastfmScrobbleToggle = (props) => { } } + useEffect(() => { + httpClient('/api/lastfm/link') + .then((response) => { + setLinked(response.json.status === true) + }) + .catch(() => { + setLinked(false) + }) + }, []) + return ( <FormControl> <FormControlLabel @@ -132,7 +106,7 @@ export const LastfmScrobbleToggle = (props) => { id={'lastfm'} color="primary" checked={linked || checkingLink} - disabled={!apiKey || linked === null || checkingLink} + disabled={linked === null || checkingLink} onChange={toggleScrobble} /> } @@ -141,16 +115,7 @@ export const LastfmScrobbleToggle = (props) => { } /> {checkingLink && ( - <Progress - setLinked={setLinked} - setCheckingLink={setCheckingLink} - openedTab={openedTab} - /> - )} - {!apiKey && ( - <FormHelperText id="scrobble-lastfm-disabled-helper-text"> - {translate('menu.personal.options.lastfmNotConfigured')} - </FormHelperText> + <Progress setLinked={setLinked} setCheckingLink={setCheckingLink} /> )} </FormControl> ) diff --git a/ui/src/personal/ListenBrainzScrobbleToggle.jsx b/ui/src/personal/ListenBrainzScrobbleToggle.js similarity index 100% rename from ui/src/personal/ListenBrainzScrobbleToggle.jsx rename to ui/src/personal/ListenBrainzScrobbleToggle.js diff --git a/ui/src/personal/NotificationsToggle.jsx b/ui/src/personal/NotificationsToggle.js similarity index 100% rename from ui/src/personal/NotificationsToggle.jsx rename to ui/src/personal/NotificationsToggle.js diff --git a/ui/src/personal/Personal.jsx b/ui/src/personal/Personal.js similarity index 100% rename from ui/src/personal/Personal.jsx rename to ui/src/personal/Personal.js diff --git a/ui/src/personal/ReplayGainToggle.jsx b/ui/src/personal/ReplayGainToggle.js similarity index 100% rename from ui/src/personal/ReplayGainToggle.jsx rename to ui/src/personal/ReplayGainToggle.js diff --git a/ui/src/personal/SelectDefaultView.jsx b/ui/src/personal/SelectDefaultView.js similarity index 63% rename from ui/src/personal/SelectDefaultView.jsx rename to ui/src/personal/SelectDefaultView.js index e90fd65bc..71c87305c 100644 --- a/ui/src/personal/SelectDefaultView.jsx +++ b/ui/src/personal/SelectDefaultView.js @@ -1,10 +1,13 @@ import { SelectInput, useTranslate } from 'react-admin' -import { getDefaultViewChoices, getStoredDefaultView } from './defaultViews' +import albumLists, { defaultAlbumList } from '../album/albumLists' export const SelectDefaultView = (props) => { const translate = useTranslate() - const current = getStoredDefaultView() - const choices = getDefaultViewChoices(translate) + const current = localStorage.getItem('defaultView') || defaultAlbumList + const choices = Object.keys(albumLists).map((type) => ({ + id: type, + name: translate(`resources.album.lists.${type}`), + })) return ( <SelectInput diff --git a/ui/src/personal/SelectLanguage.jsx b/ui/src/personal/SelectLanguage.js similarity index 100% rename from ui/src/personal/SelectLanguage.jsx rename to ui/src/personal/SelectLanguage.js diff --git a/ui/src/personal/SelectTheme.jsx b/ui/src/personal/SelectTheme.js similarity index 99% rename from ui/src/personal/SelectTheme.jsx rename to ui/src/personal/SelectTheme.js index 6ec39b09b..e69d996ff 100644 --- a/ui/src/personal/SelectTheme.jsx +++ b/ui/src/personal/SelectTheme.js @@ -21,7 +21,7 @@ export const SelectTheme = (props) => { themeChoices.push( ...Object.keys(themes).map((key) => { return { id: key, name: themes[key].themeName } - }), + }) ) themeChoices.push({ id: helpKey, diff --git a/ui/src/personal/defaultViews.js b/ui/src/personal/defaultViews.js deleted file mode 100644 index a5a2c074d..000000000 --- a/ui/src/personal/defaultViews.js +++ /dev/null @@ -1,20 +0,0 @@ -import albumLists, { defaultAlbumList } from '../album/albumLists' - -export const resourceDefaultViews = ['artist', 'song', 'playlist', 'radio'] - -export const isResourceDefaultView = (defaultView) => - resourceDefaultViews.includes(defaultView) - -export const getDefaultViewChoices = (translate) => [ - ...Object.keys(albumLists).map((type) => ({ - id: type, - name: translate(`resources.album.lists.${type}`), - })), - ...resourceDefaultViews.map((resource) => ({ - id: resource, - name: translate(`resources.${resource}.name`, { smart_count: 2 }), - })), -] - -export const getStoredDefaultView = () => - localStorage.getItem('defaultView') || defaultAlbumList diff --git a/ui/src/personal/defaultViews.test.js b/ui/src/personal/defaultViews.test.js deleted file mode 100644 index 4e80b2340..000000000 --- a/ui/src/personal/defaultViews.test.js +++ /dev/null @@ -1,50 +0,0 @@ -import { - getDefaultViewChoices, - getStoredDefaultView, - isResourceDefaultView, - resourceDefaultViews, -} from './defaultViews' -import albumLists, { defaultAlbumList } from '../album/albumLists' - -describe('defaultViews', () => { - beforeEach(() => { - localStorage.clear() - }) - - it('includes album lists and top-level resource lists as choices', () => { - const choices = getDefaultViewChoices((key, options) => - options?.smart_count ? `${key}:${options.smart_count}` : key, - ) - - expect(choices.map((choice) => choice.id)).toEqual([ - ...Object.keys(albumLists), - ...resourceDefaultViews, - ]) - expect(choices).toEqual( - expect.arrayContaining([ - { id: 'artist', name: 'resources.artist.name:2' }, - { id: 'song', name: 'resources.song.name:2' }, - { id: 'playlist', name: 'resources.playlist.name:2' }, - { id: 'radio', name: 'resources.radio.name:2' }, - ]), - ) - }) - - it('identifies resource-backed default views', () => { - expect(isResourceDefaultView('artist')).toBe(true) - expect(isResourceDefaultView('song')).toBe(true) - expect(isResourceDefaultView('playlist')).toBe(true) - expect(isResourceDefaultView('radio')).toBe(true) - expect(isResourceDefaultView('recentlyAdded')).toBe(false) - }) - - it('falls back to the default album list when no default view is stored', () => { - expect(getStoredDefaultView()).toBe(defaultAlbumList) - }) - - it('returns the stored default view', () => { - localStorage.setItem('defaultView', 'playlist') - - expect(getStoredDefaultView()).toBe('playlist') - }) -}) diff --git a/ui/src/player/ApiKeyInput.jsx b/ui/src/player/ApiKeyInput.jsx deleted file mode 100644 index 1f2dd1ed2..000000000 --- a/ui/src/player/ApiKeyInput.jsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import { useInput, useNotify, useTranslate } from 'react-admin' -import { Button, TextField } from '@material-ui/core' -import { FaKey } from 'react-icons/fa' -import { MdContentCopy, MdDelete, MdRefresh } from 'react-icons/md' -import { isWritable } from '../common/playlistUtils' -import { generateApiKey } from './apiKey' - -const identity = (v) => v -const MASK = '•'.repeat(26) - -const ApiKeyInput = ({ record, isCreate, fullWidth, className, ...props }) => { - const translate = useTranslate() - const notify = useNotify() - // Identity format/parse keep "" (revoke) distinct from undefined (untouched) - const { - input: { value, onChange }, - meta: { error, touched }, - } = useInput({ ...props, format: identity, parse: identity }) - - const isOwner = isCreate || record?.userId === localStorage.getItem('userId') - const pending = !!value - const revoking = value === '' && !!record?.hasApiKey - const saved = value == null && !!record?.hasApiKey - const hasKey = pending || saved - - const copy = () => { - const fallback = () => - prompt(translate('message.shareCopyToClipboard'), value) - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard - .writeText(value) - .then( - () => notify('resources.player.notifications.apiKeyCopied'), - fallback, - ) - } else { - fallback() - } - } - - const helperText = pending - ? 'resources.player.message.apiKeyPending' - : revoking - ? 'resources.player.message.apiKeyRevokePending' - : saved - ? 'resources.player.message.apiKeyActive' - : isOwner - ? 'resources.player.message.apiKeyNone' - : 'resources.player.message.apiKeyNoneOther' - - return ( - <div> - <TextField - label={translate('resources.player.fields.hasApiKey')} - value={pending ? value : saved ? MASK : ''} - variant="outlined" - margin="dense" - fullWidth={fullWidth} - className={className} - // Monospace keeps the whole key visible in a standard-width input - inputProps={{ - style: { fontFamily: 'monospace', fontSize: '0.875rem' }, - }} - InputProps={{ readOnly: true }} - error={!!(touched && error)} - helperText={translate(touched && error ? error : helperText)} - /> - <div> - {pending && ( - <Button startIcon={<MdContentCopy />} onClick={copy}> - {translate('resources.player.actions.copyApiKey')} - </Button> - )} - {isOwner && ( - <Button - startIcon={hasKey ? <MdRefresh /> : <FaKey />} - onClick={() => onChange(generateApiKey())} - > - {translate( - hasKey - ? 'resources.player.actions.regenerateApiKey' - : 'resources.player.actions.generateApiKey', - )} - </Button> - )} - {saved && isWritable(record?.userId) && ( - <Button startIcon={<MdDelete />} onClick={() => onChange('')}> - {translate('resources.player.actions.revokeApiKey')} - </Button> - )} - </div> - </div> - ) -} - -ApiKeyInput.propTypes = { - source: PropTypes.string.isRequired, - record: PropTypes.object, - isCreate: PropTypes.bool, - fullWidth: PropTypes.bool, - className: PropTypes.string, - validate: PropTypes.oneOfType([PropTypes.func, PropTypes.array]), -} - -export default ApiKeyInput diff --git a/ui/src/player/ApiKeyInput.test.jsx b/ui/src/player/ApiKeyInput.test.jsx deleted file mode 100644 index dd4269d6e..000000000 --- a/ui/src/player/ApiKeyInput.test.jsx +++ /dev/null @@ -1,172 +0,0 @@ -import * as React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { Form } from 'react-final-form' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import ApiKeyInput from './ApiKeyInput' - -const hooks = vi.hoisted(() => ({ notify: vi.fn() })) -const KEY = 'nds_0123456789abcdefghijkl' -const KEY_FORMAT = /^nds_[0-9A-Za-z]{22}$/ - -vi.mock('react-admin', async () => { - const actual = await vi.importActual('react-admin') - return { - ...actual, - useNotify: () => hooks.notify, - useTranslate: () => (key) => key, - } -}) - -const renderInput = ({ - record, - initialValues = {}, - isCreate = false, - fullWidth, -}) => { - let values - const utils = render( - <Form - onSubmit={() => {}} - initialValues={initialValues} - render={({ values: v }) => { - values = v - return ( - <ApiKeyInput - source="apiKey" - record={record} - isCreate={isCreate} - fullWidth={fullWidth} - /> - ) - }} - />, - ) - return { ...utils, values: () => values } -} - -const text = (key) => screen.queryByText(key) - -describe('ApiKeyInput', () => { - beforeEach(() => { - vi.clearAllMocks() - localStorage.setItem('userId', 'owner') - localStorage.setItem('role', 'regular') - }) - - it('shows a pending key with copy and regenerate on create', () => { - const { values } = renderInput({ - record: {}, - isCreate: true, - initialValues: { apiKey: KEY }, - }) - expect(screen.getByDisplayValue(KEY)).toBeInTheDocument() - expect(text('resources.player.message.apiKeyPending')).toBeInTheDocument() - expect( - screen.getByRole('button', { - name: 'resources.player.actions.copyApiKey', - }), - ).toBeInTheDocument() - expect( - text('resources.player.actions.revokeApiKey'), - ).not.toBeInTheDocument() - - fireEvent.click( - screen.getByText('resources.player.actions.regenerateApiKey'), - ) - expect(values().apiKey).toMatch(KEY_FORMAT) - expect(values().apiKey).not.toBe(KEY) - }) - - it('masks a saved key and lets the owner regenerate or revoke', () => { - const { values } = renderInput({ - record: { id: 'p1', userId: 'owner', hasApiKey: true }, - }) - expect(screen.queryByDisplayValue(/^nds_/)).not.toBeInTheDocument() - expect(text('resources.player.message.apiKeyActive')).toBeInTheDocument() - expect( - text('resources.player.actions.regenerateApiKey'), - ).toBeInTheDocument() - - fireEvent.click(screen.getByText('resources.player.actions.revokeApiKey')) - expect(values().apiKey).toBe('') - expect( - text('resources.player.message.apiKeyRevokePending'), - ).toBeInTheDocument() - expect(text('resources.player.actions.generateApiKey')).toBeInTheDocument() - }) - - it('lets the owner generate a key when there is none', () => { - const { values } = renderInput({ - record: { id: 'p1', userId: 'owner', hasApiKey: false }, - }) - expect(values().apiKey).toBeUndefined() - expect(text('resources.player.message.apiKeyNone')).toBeInTheDocument() - - fireEvent.click(screen.getByText('resources.player.actions.generateApiKey')) - expect(values().apiKey).toMatch(KEY_FORMAT) - expect(text('resources.player.message.apiKeyPending')).toBeInTheDocument() - }) - - it('lets an admin revoke but not set a key on another user player', () => { - localStorage.setItem('role', 'admin') - renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: true } }) - expect( - text('resources.player.actions.regenerateApiKey'), - ).not.toBeInTheDocument() - expect(text('resources.player.actions.revokeApiKey')).toBeInTheDocument() - }) - - it('falls back to a prompt when the clipboard write fails', async () => { - vi.stubGlobal('isSecureContext', true) - vi.stubGlobal('prompt', vi.fn()) - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, - }) - try { - renderInput({ - record: {}, - isCreate: true, - initialValues: { apiKey: KEY }, - }) - fireEvent.click( - screen.getByRole('button', { - name: 'resources.player.actions.copyApiKey', - }), - ) - await waitFor(() => - expect(window.prompt).toHaveBeenCalledWith( - 'message.shareCopyToClipboard', - KEY, - ), - ) - expect(hooks.notify).not.toHaveBeenCalled() - } finally { - vi.unstubAllGlobals() - delete navigator.clipboard - } - }) - - it('shows a neutral message to an admin viewing another user player with no key', () => { - localStorage.setItem('role', 'admin') - renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: false } }) - expect(text('resources.player.message.apiKeyNoneOther')).toBeInTheDocument() - expect(text('resources.player.message.apiKeyNone')).not.toBeInTheDocument() - expect(screen.queryAllByRole('button')).toHaveLength(0) - }) - - it('shows no actions to another regular user', () => { - renderInput({ record: { id: 'p1', userId: 'someone', hasApiKey: true } }) - expect(screen.queryAllByRole('button')).toHaveLength(0) - }) - - it('is not full width unless asked', () => { - const record = { id: 'p1', userId: 'owner', hasApiKey: true } - const { container, unmount } = renderInput({ record }) - expect(container.querySelector('.MuiFormControl-fullWidth')).toBeNull() - unmount() - - const { container: wide } = renderInput({ record, fullWidth: true }) - expect(wide.querySelector('.MuiFormControl-fullWidth')).not.toBeNull() - }) -}) diff --git a/ui/src/player/PlayerCreate.jsx b/ui/src/player/PlayerCreate.jsx deleted file mode 100644 index bf02cde1b..000000000 --- a/ui/src/player/PlayerCreate.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React, { useMemo } from 'react' -import { Create, SimpleForm, required, useTranslate } from 'react-admin' -import { Title } from '../common' -import { playerInputs } from './playerInputs' -import ApiKeyInput from './ApiKeyInput' -import { generateApiKey } from './apiKey' - -const PlayerCreateTitle = () => { - const translate = useTranslate() - const resourceName = translate('resources.player.name', { smart_count: 1 }) - return ( - <Title subTitle={translate('ra.page.create', { name: resourceName })} /> - ) -} - -const PlayerCreate = (props) => { - // Memoized so re-renders don't swap the key the user may have already copied - const initialValues = useMemo(() => ({ apiKey: generateApiKey() }), []) - return ( - <Create title={<PlayerCreateTitle />} {...props}> - <SimpleForm - variant="outlined" - redirect="list" - initialValues={initialValues} - > - {playerInputs()} - <ApiKeyInput source="apiKey" isCreate validate={required()} /> - </SimpleForm> - </Create> - ) -} - -export default PlayerCreate diff --git a/ui/src/player/PlayerCreate.test.jsx b/ui/src/player/PlayerCreate.test.jsx deleted file mode 100644 index dc1f817db..000000000 --- a/ui/src/player/PlayerCreate.test.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import * as React from 'react' -import { render } from '@testing-library/react' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import PlayerCreate from './PlayerCreate' -import ApiKeyInput from './ApiKeyInput' - -const hooks = vi.hoisted(() => ({ forms: [] })) - -vi.mock('react-admin', async () => { - const actual = await vi.importActual('react-admin') - return { - ...actual, - Create: ({ children }) => children, - SimpleForm: (props) => { - hooks.forms.push(props) - return null - }, - } -}) - -describe('PlayerCreate', () => { - beforeEach(() => { - hooks.forms = [] - }) - - it('pre-fills one generated API key that survives re-renders', () => { - const { rerender } = render(<PlayerCreate resource="player" />) - rerender(<PlayerCreate resource="player" />) - - const [first, second] = hooks.forms.map((f) => f.initialValues) - expect(hooks.forms).toHaveLength(2) - expect(first.apiKey).toMatch(/^nds_[0-9A-Za-z]{22}$/) - expect(second).toBe(first) - }) - - it('requires the API key', () => { - render(<PlayerCreate resource="player" />) - - const input = React.Children.toArray(hooks.forms[0].children).find( - (child) => child.type === ApiKeyInput, - ) - expect(input.props.source).toBe('apiKey') - expect(input.props.isCreate).toBe(true) - expect(input.props.validate('')).toBeTruthy() - expect(input.props.validate('nds_0123456789abcdefghijkl')).toBeUndefined() - }) -}) diff --git a/ui/src/player/PlayerEdit.js b/ui/src/player/PlayerEdit.js new file mode 100644 index 000000000..1826500bd --- /dev/null +++ b/ui/src/player/PlayerEdit.js @@ -0,0 +1,44 @@ +import { + TextInput, + BooleanInput, + TextField, + Edit, + required, + SimpleForm, + SelectInput, + ReferenceInput, + useTranslate, +} from 'react-admin' +import { Title } from '../common' +import config from '../config' +import { BITRATE_CHOICES } from '../consts' + +const PlayerTitle = ({ record }) => { + const translate = useTranslate() + const resourceName = translate('resources.player.name', { smart_count: 1 }) + return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} /> +} + +const PlayerEdit = (props) => ( + <Edit title={<PlayerTitle />} {...props}> + <SimpleForm variant={'outlined'}> + <TextInput source="name" validate={[required()]} /> + <ReferenceInput + source="transcodingId" + reference="transcoding" + sort={{ field: 'name', order: 'ASC' }} + > + <SelectInput source="name" resettable /> + </ReferenceInput> + <SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} /> + <BooleanInput source="reportRealPath" fullWidth /> + {(config.lastFMEnabled || config.listenBrainzEnabled) && ( + <BooleanInput source="scrobbleEnabled" fullWidth /> + )} + <TextField source="client" /> + <TextField source="userName" /> + </SimpleForm> + </Edit> +) + +export default PlayerEdit diff --git a/ui/src/player/PlayerEdit.jsx b/ui/src/player/PlayerEdit.jsx deleted file mode 100644 index d785eb04e..000000000 --- a/ui/src/player/PlayerEdit.jsx +++ /dev/null @@ -1,54 +0,0 @@ -import { - Edit, - SimpleForm, - useTranslate, - DeleteButton, - DeleteWithConfirmButton, - SaveButton, - Toolbar, -} from 'react-admin' -import { makeStyles } from '@material-ui/core/styles' -import { ReadOnlyTextField, Title } from '../common' -import ApiKeyInput from './ApiKeyInput' -import { playerInputs } from './playerInputs' - -const PlayerTitle = ({ record }) => { - const translate = useTranslate() - const resourceName = translate('resources.player.name', { smart_count: 1 }) - return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} /> -} - -const useToolbarStyles = makeStyles({ - toolbar: { - display: 'flex', - justifyContent: 'space-between', - }, -}) - -const PlayerEditToolbar = (props) => ( - <Toolbar {...props} classes={useToolbarStyles()}> - <SaveButton /> - {props.record?.hasApiKey ? ( - <DeleteWithConfirmButton - mutationMode="pessimistic" - confirmTitle="resources.player.message.deleteWithKeyTitle" - confirmContent="resources.player.message.deleteWithKeyContent" - /> - ) : ( - <DeleteButton /> - )} - </Toolbar> -) - -const PlayerEdit = (props) => ( - <Edit title={<PlayerTitle />} mutationMode="pessimistic" {...props}> - <SimpleForm variant={'outlined'} toolbar={<PlayerEditToolbar />}> - {playerInputs()} - <ReadOnlyTextField source="client" /> - <ReadOnlyTextField source="userName" /> - <ApiKeyInput source="apiKey" /> - </SimpleForm> - </Edit> -) - -export default PlayerEdit diff --git a/ui/src/player/PlayerEdit.test.jsx b/ui/src/player/PlayerEdit.test.jsx deleted file mode 100644 index 38fa2cc8e..000000000 --- a/ui/src/player/PlayerEdit.test.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import * as React from 'react' -import { render } from '@testing-library/react' -import { describe, it, expect, vi } from 'vitest' -import PlayerEdit from './PlayerEdit' - -const hooks = vi.hoisted(() => ({ editProps: null })) - -vi.mock('react-admin', async () => { - const actual = await vi.importActual('react-admin') - return { - ...actual, - Edit: (props) => { - hooks.editProps = props - return null - }, - } -}) - -describe('PlayerEdit', () => { - // An optimistic or undoable save would put the new key in react-admin's cache - it('saves pessimistically', () => { - render(<PlayerEdit resource="player" id="p1" />) - expect(hooks.editProps.mutationMode).toBe('pessimistic') - }) -}) diff --git a/ui/src/player/PlayerList.jsx b/ui/src/player/PlayerList.js similarity index 73% rename from ui/src/player/PlayerList.jsx rename to ui/src/player/PlayerList.js index c7bd32570..a2b009bad 100644 --- a/ui/src/player/PlayerList.jsx +++ b/ui/src/player/PlayerList.js @@ -2,20 +2,18 @@ import React from 'react' import { Datagrid, TextField, + DateField, FunctionField, ReferenceField, Filter, SearchInput, - NullableBooleanInput, } from 'react-admin' import { useMediaQuery } from '@material-ui/core' -import { FaKey } from 'react-icons/fa' -import { SimpleList, List, DateField } from '../common' +import { SimpleList, List } from '../common' const PlayerFilter = (props) => ( <Filter {...props} variant={'outlined'}> <SearchInput id="search" source="name" alwaysOn /> - <NullableBooleanInput source="hasApiKey" alwaysOn /> </Filter> ) @@ -32,11 +30,7 @@ const PlayerList = ({ permissions, ...props }) => { <SimpleList primaryText={(r) => r.name} secondaryText={(r) => r.userName} - tertiaryText={(r) => ( - <> - {r.hasApiKey && <FaKey />} {r.maxBitRate ? r.maxBitRate : '-'} - </> - )} + tertiaryText={(r) => (r.maxBitRate ? r.maxBitRate : '-')} /> ) : ( <Datagrid rowClick="edit"> @@ -49,11 +43,6 @@ const PlayerList = ({ permissions, ...props }) => { source="maxBitRate" render={(r) => (r.maxBitRate ? r.maxBitRate : '-')} /> - <FunctionField - source="hasApiKey" - sortable={false} - render={(r) => (r.hasApiKey ? <FaKey /> : null)} - /> <DateField source="lastSeen" showTime sortByOrder={'DESC'} /> </Datagrid> )} diff --git a/ui/src/player/apiKey.js b/ui/src/player/apiKey.js deleted file mode 100644 index 27d683052..000000000 --- a/ui/src/player/apiKey.js +++ /dev/null @@ -1,15 +0,0 @@ -const API_KEY_PREFIX = 'nds_' -const ALPHABET = - '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' -const KEY_LENGTH = 22 - -// Bytes >= 248 are dropped so every character is equally likely (248 = 4 * 62). -export const generateApiKey = () => { - let key = '' - while (key.length < KEY_LENGTH) { - for (const b of window.crypto.getRandomValues(new Uint8Array(32))) { - if (b < 248 && key.length < KEY_LENGTH) key += ALPHABET[b % 62] - } - } - return API_KEY_PREFIX + key -} diff --git a/ui/src/player/apiKey.test.js b/ui/src/player/apiKey.test.js deleted file mode 100644 index 4c41871e7..000000000 --- a/ui/src/player/apiKey.test.js +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { generateApiKey } from './apiKey' - -describe('generateApiKey', () => { - it('returns the prefix plus 22 base62 characters', () => { - for (let i = 0; i < 50; i++) { - expect(generateApiKey()).toMatch(/^nds_[0-9A-Za-z]{22}$/) - } - }) - - it('returns a different key each time', () => { - const keys = new Set(Array.from({ length: 100 }, generateApiKey)) - expect(keys.size).toBe(100) - }) -}) diff --git a/ui/src/player/index.js b/ui/src/player/index.js index c28cfa414..aaa3d58d7 100644 --- a/ui/src/player/index.js +++ b/ui/src/player/index.js @@ -1,11 +1,9 @@ import { BsFillMusicPlayerFill } from 'react-icons/bs' import PlayerList from './PlayerList' import PlayerEdit from './PlayerEdit' -import PlayerCreate from './PlayerCreate' export default { list: PlayerList, edit: PlayerEdit, - create: PlayerCreate, icon: BsFillMusicPlayerFill, } diff --git a/ui/src/player/playerInputs.jsx b/ui/src/player/playerInputs.jsx deleted file mode 100644 index a8b719a81..000000000 --- a/ui/src/player/playerInputs.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react' -import { - BooleanInput, - ReferenceInput, - SelectInput, - TextInput, - required, -} from 'react-admin' -import config from '../config' -import { BITRATE_CHOICES } from '../consts' - -// Returned as an array, not a component, so SimpleForm still injects its props into each input. -export const playerInputs = () => - [ - <TextInput key="name" source="name" validate={[required()]} />, - <ReferenceInput - key="transcodingId" - source="transcodingId" - reference="transcoding" - sort={{ field: 'name', order: 'ASC' }} - > - <SelectInput source="name" resettable /> - </ReferenceInput>, - <SelectInput - key="maxBitRate" - source="maxBitRate" - resettable - choices={BITRATE_CHOICES} - />, - <BooleanInput key="reportRealPath" source="reportRealPath" fullWidth />, - (config.lastFMEnabled || config.listenBrainzEnabled) && ( - <BooleanInput key="scrobbleEnabled" source="scrobbleEnabled" fullWidth /> - ), - ].filter(Boolean) diff --git a/ui/src/playlist/ChangePublicStatusButton.jsx b/ui/src/playlist/ChangePublicStatusButton.js similarity index 100% rename from ui/src/playlist/ChangePublicStatusButton.jsx rename to ui/src/playlist/ChangePublicStatusButton.js diff --git a/ui/src/playlist/PlaylistActions.jsx b/ui/src/playlist/PlaylistActions.js similarity index 98% rename from ui/src/playlist/PlaylistActions.jsx rename to ui/src/playlist/PlaylistActions.js index 1e7bef9b8..d98596c3b 100644 --- a/ui/src/playlist/PlaylistActions.jsx +++ b/ui/src/playlist/PlaylistActions.js @@ -59,7 +59,7 @@ const PlaylistActions = ({ className, ids, data, record, ...rest }) => { .then((res) => { const data = res.data.reduce( (acc, curr) => ({ ...acc, [curr.id]: curr }), - {}, + {} ) dispatch(action(data)) }) @@ -67,7 +67,7 @@ const PlaylistActions = ({ className, ids, data, record, ...rest }) => { notify('ra.page.error', 'warning') }) }, - [dataProvider, dispatch, record, data, ids, notify], + [dataProvider, dispatch, record, data, ids, notify] ) const handlePlay = React.useCallback(() => { @@ -108,7 +108,7 @@ const PlaylistActions = ({ className, ids, data, record, ...rest }) => { link.click() link.parentNode.removeChild(link) }), - [record], + [record] ) return ( diff --git a/ui/src/playlist/PlaylistCreate.jsx b/ui/src/playlist/PlaylistCreate.js similarity index 100% rename from ui/src/playlist/PlaylistCreate.jsx rename to ui/src/playlist/PlaylistCreate.js diff --git a/ui/src/playlist/PlaylistDetails.js b/ui/src/playlist/PlaylistDetails.js new file mode 100644 index 000000000..954f0c841 --- /dev/null +++ b/ui/src/playlist/PlaylistDetails.js @@ -0,0 +1,79 @@ +import React from 'react' +import { Card, CardContent, Typography } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import { useTranslate } from 'react-admin' +import { DurationField, SizeField } from '../common' +import Linkify from '../common/Linkify' + +const useStyles = makeStyles( + (theme) => ({ + container: { + [theme.breakpoints.down('xs')]: { + padding: '0.7em', + minWidth: '24em', + }, + [theme.breakpoints.up('sm')]: { + padding: '1em', + minWidth: '32em', + }, + }, + details: { + display: 'inline-block', + verticalAlign: 'top', + [theme.breakpoints.down('xs')]: { + width: '14em', + }, + [theme.breakpoints.up('sm')]: { + width: '26em', + }, + [theme.breakpoints.up('lg')]: { + width: '38em', + }, + }, + title: { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + }), + { + name: 'NDPlaylistDetails', + } +) + +const PlaylistDetails = (props) => { + const { record = {} } = props + const translate = useTranslate() + const classes = useStyles() + + return ( + <Card className={classes.container}> + <CardContent className={classes.details}> + <Typography variant="h5" className={classes.title}> + {record.name || translate('ra.page.loading')} + </Typography> + <Typography component="h6"> + <Linkify text={record.comment} /> + </Typography> + <Typography component="p"> + {record.songCount ? ( + <span> + {record.songCount}{' '} + {translate('resources.song.name', { + smart_count: record.songCount, + })} + {' · '} + <DurationField record={record} source={'duration'} /> + {' · '} + <SizeField record={record} source={'size'} /> + </span> + ) : ( + <span> </span> + )} + </Typography> + </CardContent> + </Card> + ) +} + +export default PlaylistDetails diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx deleted file mode 100644 index 5d0f6631e..000000000 --- a/ui/src/playlist/PlaylistDetails.jsx +++ /dev/null @@ -1,178 +0,0 @@ -import { useState } from 'react' -import { Card, CardContent, Typography, useMediaQuery } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import { useTranslate } from 'react-admin' -import Lightbox from 'react-image-lightbox' -import 'react-image-lightbox/style.css' -import { - CollapsibleComment, - DurationField, - ImageUploadOverlay, - LoveButton, - SizeField, - isWritable, - OverflowTooltip, -} from '../common' -import subsonic from '../subsonic' -import { Artwork } from '../common/Artwork' - -const useStyles = makeStyles( - (theme) => ({ - root: { - [theme.breakpoints.down('xs')]: { - padding: '0.7em', - minWidth: '20em', - }, - [theme.breakpoints.up('sm')]: { - padding: '1em', - minWidth: '32em', - }, - }, - cardContents: { - display: 'flex', - }, - details: { - display: 'flex', - flexDirection: 'column', - }, - content: { - flex: '2 0 auto', - }, - coverParent: { - [theme.breakpoints.down('xs')]: { - height: '8em', - width: '8em', - minWidth: '8em', - }, - [theme.breakpoints.up('sm')]: { - height: '10em', - width: '10em', - minWidth: '10em', - }, - [theme.breakpoints.up('lg')]: { - height: '15em', - width: '15em', - minWidth: '15em', - }, - backgroundColor: 'transparent', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - position: 'relative', - }, - cover: { - objectFit: 'contain', - cursor: 'pointer', - display: 'block', - width: '100%', - height: '100%', - backgroundColor: 'transparent', - transition: 'opacity 0.3s ease-in-out', - }, - title: { - overflow: 'hidden', - textOverflow: 'ellipsis', - wordBreak: 'break-word', - minWidth: 0, - }, - titleRow: { - display: 'flex', - alignItems: 'center', - }, - loveButton: { - marginLeft: theme.spacing(0.5), - flexShrink: 0, - }, - stats: { - marginTop: '1em', - marginBottom: '0.5em', - }, - }), - { - name: 'NDPlaylistDetails', - }, -) - -const PlaylistDetails = (props) => { - const { record = {} } = props - const translate = useTranslate() - const classes = useStyles() - const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) - const [isLightboxOpen, setLightboxOpen] = useState(false) - - const fullImageUrl = subsonic.getCoverArtUrl(record) - - return ( - <Card className={classes.root}> - <div className={classes.cardContents}> - <div className={classes.coverParent}> - <Artwork - record={record} - square - fit="contain" - className={classes.cover} - title={record.name} - onClick={() => setLightboxOpen(true)} - /> - {isWritable(record.ownerId) && ( - <ImageUploadOverlay - entityType="playlist" - entityId={record.id} - hasUploadedImage={!!record.uploadedImage} - /> - )} - </div> - <div className={classes.details}> - <CardContent className={classes.content}> - <div className={classes.titleRow}> - <OverflowTooltip title={record.name || ''}> - <Typography - variant={isDesktop ? 'h5' : 'h6'} - className={classes.title} - > - {record.name || translate('ra.page.loading')} - </Typography> - </OverflowTooltip> - <LoveButton - className={classes.loveButton} - record={record} - resource={'playlist'} - size={isDesktop ? 'default' : 'small'} - aria-label="love" - color="primary" - /> - </div> - <Typography component="p" className={classes.stats}> - {record.songCount ? ( - <span> - {record.songCount}{' '} - {translate('resources.song.name', { - smart_count: record.songCount, - })} - {' · '} - <DurationField record={record} source={'duration'} /> - {' · '} - <SizeField record={record} source={'size'} /> - </span> - ) : ( - <span> </span> - )} - </Typography> - <CollapsibleComment record={record} /> - </CardContent> - </div> - </div> - {isLightboxOpen && ( - <Lightbox - imagePadding={50} - animationDuration={200} - imageTitle={record.name} - mainSrc={fullImageUrl} - onCloseRequest={() => setLightboxOpen(false)} - /> - )} - </Card> - ) -} - -export default PlaylistDetails diff --git a/ui/src/playlist/PlaylistEdit.jsx b/ui/src/playlist/PlaylistEdit.js similarity index 75% rename from ui/src/playlist/PlaylistEdit.jsx rename to ui/src/playlist/PlaylistEdit.js index c7594fa13..23f9cc1b5 100644 --- a/ui/src/playlist/PlaylistEdit.jsx +++ b/ui/src/playlist/PlaylistEdit.js @@ -1,8 +1,11 @@ +import React, { Fragment } from 'react' + import { Edit, FormDataConsumer, SimpleForm, TextInput, + TextField, BooleanInput, required, useTranslate, @@ -10,15 +13,14 @@ import { ReferenceInput, SelectInput, } from 'react-admin' -import { isWritable, ReadOnlyTextField, Title } from '../common' +import { isWritable, Title } from '../common' const SyncFragment = ({ formData, variant, ...rest }) => { - if (!formData.path) return null return ( - <> - <BooleanInput source="sync" {...rest} /> - <ReadOnlyTextField source="path" {...rest} /> - </> + <Fragment> + {formData.path && <BooleanInput source="sync" {...rest} />} + {formData.path && <TextField source="path" {...rest} />} + </Fragment> ) } @@ -34,15 +36,7 @@ const PlaylistEditForm = (props) => { return ( <SimpleForm redirect="list" variant={'outlined'} {...props}> <TextInput source="name" validate={required()} /> - <TextInput - multiline - minRows={3} - source="comment" - fullWidth - inputProps={{ - style: { resize: 'vertical' }, - }} - /> + <TextInput multiline source="comment" /> {permissions === 'admin' ? ( <ReferenceInput source="ownerId" @@ -56,10 +50,10 @@ const PlaylistEditForm = (props) => { /> </ReferenceInput> ) : ( - <ReadOnlyTextField source="ownerName" /> + <TextField source="ownerName" /> )} <BooleanInput source="public" disabled={!isWritable(record.ownerId)} /> - <FormDataConsumer fullWidth> + <FormDataConsumer> {(formDataProps) => <SyncFragment {...formDataProps} />} </FormDataConsumer> </SimpleForm> diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.js similarity index 58% rename from ui/src/playlist/PlaylistList.jsx rename to ui/src/playlist/PlaylistList.js index e1695e980..9f5667d7b 100644 --- a/ui/src/playlist/PlaylistList.jsx +++ b/ui/src/playlist/PlaylistList.js @@ -4,7 +4,6 @@ import { DateField, EditButton, Filter, - NullableBooleanInput, NumberField, ReferenceInput, SearchInput, @@ -17,29 +16,18 @@ import { usePermissions, } from 'react-admin' import Switch from '@material-ui/core/Switch' -import { makeStyles } from '@material-ui/core/styles' import { useMediaQuery } from '@material-ui/core' import { - ArtworkAvatar, DurationField, List, - LoveButton, Writable, isWritable, useSelectedFields, useResourceRefresh, } from '../common' -import FavoriteIcon from '@material-ui/icons/Favorite' -import config from '../config' import PlaylistListActions from './PlaylistListActions' import ChangePublicStatusButton from './ChangePublicStatusButton' -const useStyles = makeStyles((theme) => ({ - button: { - color: theme.palette.type === 'dark' ? 'white' : undefined, - }, -})) - const PlaylistFilter = (props) => { const { permissions } = usePermissions() return ( @@ -57,81 +45,50 @@ const PlaylistFilter = (props) => { <SelectInput optionText="name" /> </ReferenceInput> )} - {config.enableFavourites && ( - <NullableBooleanInput - source="starred" - label={<FavoriteIcon fontSize={'small'} />} - /> - )} </Filter> ) } -export const ToggleField = ({ resource, source }) => { +const TogglePublicInput = ({ resource, source }) => { const record = useRecordContext() const notify = useNotify() - const [toggle] = useUpdate( + const [togglePublic] = useUpdate( resource, - record?.id, + record.id, { ...record, - [source]: !record?.[source], + public: !record.public, }, { undoable: false, onFailure: (error) => { + console.log(error) notify('ra.page.error', 'warning') }, - }, + } ) const handleClick = (e) => { - toggle() + togglePublic() e.stopPropagation() } - if (!record) return null - return ( <Switch checked={record[source]} - color="primary" onClick={handleClick} disabled={!isWritable(record.ownerId)} /> ) } -export const ToggleAutoImport = (props) => { - const record = useRecordContext() - return record?.path ? <ToggleField {...props} /> : null -} - -const PlaylistListBulkActions = (props) => { - const classes = useStyles() - return ( - <> - <ChangePublicStatusButton - public={true} - {...props} - className={classes.button} - /> - <ChangePublicStatusButton - public={false} - {...props} - className={classes.button} - /> - <BulkDeleteButton {...props} className={classes.button} /> - </> - ) -} - -// Datagrid reads `source`/`sortable`/`label` off this element for the column -// header; only record/resource are forwarded so they never leak onto the button. -export const PlaylistLove = ({ record, className }) => ( - <LoveButton record={record} resource={'playlist'} className={className} /> +const PlaylistListBulkActions = (props) => ( + <> + <ChangePublicStatusButton public={true} {...props} /> + <ChangePublicStatusButton public={false} {...props} /> + <BulkDeleteButton {...props} /> + </> ) -PlaylistLove.defaultProps = { source: 'starred', sortable: false } const PlaylistList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) @@ -146,14 +103,12 @@ const PlaylistList = (props) => { updatedAt: isDesktop && ( <DateField source="updatedAt" sortByOrder={'DESC'} /> ), - public: !isXsmall && <ToggleField source="public" sortByOrder={'DESC'} />, - comment: <TextField source="comment" />, - sync: !isXsmall && ( - <ToggleAutoImport source="sync" sortByOrder={'DESC'} /> + public: !isXsmall && ( + <TogglePublicInput source="public" sortByOrder={'DESC'} /> ), - starred: config.enableFavourites && <PlaylistLove />, + comment: <TextField source="comment" />, }), - [isDesktop, isXsmall], + [isDesktop, isXsmall] ) const columns = useSelectedFields({ @@ -166,13 +121,11 @@ const PlaylistList = (props) => { <List {...props} exporter={false} - sort={{ field: 'name', order: 'ASC' }} filters={<PlaylistFilter />} actions={<PlaylistListActions />} bulkActionButtons={!isXsmall && <PlaylistListBulkActions />} > <Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}> - <ArtworkAvatar source="id" variant="square" /> <TextField source="name" /> {columns} <Writable> diff --git a/ui/src/playlist/PlaylistList.test.jsx b/ui/src/playlist/PlaylistList.test.jsx deleted file mode 100644 index c05833166..000000000 --- a/ui/src/playlist/PlaylistList.test.jsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { describe, it, expect, vi } from 'vitest' -import { TestContext } from 'ra-test' -import { RecordContextProvider } from 'react-admin' -import { PlaylistLove, ToggleField, ToggleAutoImport } from './PlaylistList' - -vi.mock('../config', () => ({ - default: { enableFavourites: true }, -})) - -vi.mock('../common', () => ({ - LoveButton: ({ record, resource }) => ( - <button data-testid="love" data-resource={resource}> - {record?.starred ? 'starred' : 'not-starred'} - </button> - ), - isWritable: (ownerId) => ownerId === 'me', -})) - -describe('<PlaylistLove />', () => { - it('renders a LoveButton bound to the playlist resource', () => { - render(<PlaylistLove record={{ id: 'pl-1', starred: true }} />) - const btn = screen.getByTestId('love') - expect(btn.getAttribute('data-resource')).toBe('playlist') - expect(btn.textContent).toBe('starred') - }) - - it('exposes datagrid header props so the column renders unsorted', () => { - // The Datagrid reads these off the element; the wrapper body must not - // forward them to the button (which would leak onto the DOM). - expect(PlaylistLove.defaultProps).toEqual({ - source: 'starred', - sortable: false, - }) - }) -}) - -// react-admin evicts records older than 10 minutes while the list still holds -// their ids, so rows can render with no record. -describe('playlist toggles without a record', () => { - it('<ToggleField /> renders nothing', () => { - const { container } = render( - <TestContext> - <ToggleField resource="playlist" source="public" /> - </TestContext>, - ) - expect(container.innerHTML).toBe('') - }) - - it('<ToggleAutoImport /> renders nothing', () => { - const { container } = render( - <TestContext> - <ToggleAutoImport resource="playlist" source="sync" /> - </TestContext>, - ) - expect(container.innerHTML).toBe('') - }) -}) - -// Secondary is a surface color in many themes, so these toggles must use primary -describe('<ToggleField />', () => { - const renderToggle = (record) => - render( - <TestContext> - <RecordContextProvider value={record}> - <ToggleField resource="playlist" source="public" /> - </RecordContextProvider> - </TestContext>, - ) - - it.each([ - ['owner', 'me', false], - ['non-owner', 'someone-else', true], - ])('renders a primary-colored switch for the %s', (_, ownerId, disabled) => { - renderToggle({ id: 'pl-1', public: true, ownerId }) - const input = screen.getByRole('checkbox') - const switchBase = input.closest('.MuiSwitch-switchBase') - expect(input.checked).toBe(true) - expect(input.disabled).toBe(disabled) - expect(switchBase.classList).toContain('MuiSwitch-colorPrimary') - expect(switchBase.classList).not.toContain('MuiSwitch-colorSecondary') - }) -}) diff --git a/ui/src/playlist/PlaylistListActions.jsx b/ui/src/playlist/PlaylistListActions.js similarity index 100% rename from ui/src/playlist/PlaylistListActions.jsx rename to ui/src/playlist/PlaylistListActions.js diff --git a/ui/src/playlist/PlaylistShow.jsx b/ui/src/playlist/PlaylistShow.js similarity index 76% rename from ui/src/playlist/PlaylistShow.jsx rename to ui/src/playlist/PlaylistShow.js index 4c2fea315..66fb383a0 100644 --- a/ui/src/playlist/PlaylistShow.jsx +++ b/ui/src/playlist/PlaylistShow.js @@ -4,21 +4,13 @@ import { ShowContextProvider, useShowContext, useShowController, - Title as RaTitle, + Pagination, } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' import PlaylistDetails from './PlaylistDetails' import PlaylistSongs from './PlaylistSongs' import PlaylistActions from './PlaylistActions' -import { - Pagination, - Title, - canChangeTracks, - getStoredPerPage, - useResourceRefresh, -} from '../common' - -const playlistTrackPerPageOptions = [100, 250, 500] +import { Title, canChangeTracks } from '../common' const useStyles = makeStyles( (theme) => ({ @@ -28,18 +20,16 @@ const useStyles = makeStyles( }), { name: 'NDPlaylistShow', - }, + } ) const PlaylistShowLayout = (props) => { const { loading, ...context } = useShowContext(props) const { record } = context const classes = useStyles() - useResourceRefresh('playlistTrack', 'song') return ( <> - {record && <RaTitle title={<Title subTitle={record.name} />} />} {record && <PlaylistDetails {...context} />} {record && ( <ReferenceManyField @@ -48,10 +38,7 @@ const PlaylistShowLayout = (props) => { reference="playlistTrack" target="playlist_id" sort={{ field: 'id', order: 'ASC' }} - perPage={getStoredPerPage( - 'playlistTrack', - playlistTrackPerPageOptions, - )} + perPage={100} filter={{ playlist_id: props.id }} > <PlaylistSongs @@ -66,9 +53,7 @@ const PlaylistShowLayout = (props) => { } resource={'playlistTrack'} exporter={false} - pagination={ - <Pagination rowsPerPageOptions={playlistTrackPerPageOptions} /> - } + pagination={<Pagination rowsPerPageOptions={[100, 250, 500]} />} /> </ReferenceManyField> )} diff --git a/ui/src/playlist/PlaylistSongBulkActions.jsx b/ui/src/playlist/PlaylistSongBulkActions.js similarity index 86% rename from ui/src/playlist/PlaylistSongBulkActions.jsx rename to ui/src/playlist/PlaylistSongBulkActions.js index ac19f96f8..020dd21ef 100644 --- a/ui/src/playlist/PlaylistSongBulkActions.jsx +++ b/ui/src/playlist/PlaylistSongBulkActions.js @@ -4,7 +4,6 @@ import { useUnselectAll, ResourceContextProvider, } from 'react-admin' -import { MdOutlinePlaylistRemove } from 'react-icons/md' import PropTypes from 'prop-types' // Replace original resource with "fake" one for removing tracks from playlist @@ -25,8 +24,6 @@ const PlaylistSongBulkActions = ({ <Fragment> <BulkDeleteButton {...rest} - label={'ra.action.remove'} - icon={<MdOutlinePlaylistRemove />} resource={mappedResource} onClick={onUnselectItems} /> diff --git a/ui/src/playlist/PlaylistSongs.jsx b/ui/src/playlist/PlaylistSongs.js similarity index 83% rename from ui/src/playlist/PlaylistSongs.jsx rename to ui/src/playlist/PlaylistSongs.js index 4718a7cf4..a968eefa6 100644 --- a/ui/src/playlist/PlaylistSongs.jsx +++ b/ui/src/playlist/PlaylistSongs.js @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react' +import React, { useCallback, useMemo } from 'react' import { BulkActionsToolbar, ListToolbar, @@ -26,13 +26,11 @@ import { useResourceRefresh, DateField, ArtistLinkField, - RatingField, } from '../common' import { AlbumLinkField } from '../song/AlbumLinkField' import { playTracks } from '../actions' import PlaylistSongBulkActions from './PlaylistSongBulkActions' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' -import config from '../config' const useStyles = makeStyles( (theme) => ({ @@ -68,19 +66,13 @@ const useStyles = makeStyles( '& $contextMenu': { visibility: 'visible', }, - '& $ratingField': { - visibility: 'visible', - }, }, }, contextMenu: { visibility: (props) => (props.isDesktop ? 'hidden' : 'visible'), }, - ratingField: { - visibility: 'hidden', - }, }), - { name: 'RaList' }, + { name: 'RaList' } ) const ReorderableList = ({ readOnly, children, ...rest }) => { @@ -92,21 +84,14 @@ const ReorderableList = ({ readOnly, children, ...rest }) => { const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { const listContext = useListContext() - const { data, ids, selectedIds, onUnselectItems, refetch, setPage } = - listContext + const { data, ids, selectedIds, onUnselectItems, refetch } = listContext const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) const classes = useStyles({ isDesktop }) const dispatch = useDispatch() const dataProvider = useDataProvider() const notify = useNotify() const version = useVersion() - // The rows are stored as playlistTrack, not song: their ids are playlist entries. - useResourceRefresh('playlistTrack', 'song', 'playlist') - - useEffect(() => { - setPage(1) - window.scrollTo({ top: 0, behavior: 'smooth' }) - }, [playlistId, setPage]) + useResourceRefresh('song', 'playlist') const onAddToPlaylist = useCallback( (pls) => { @@ -114,7 +99,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { refetch() } }, - [playlistId, refetch], + [playlistId, refetch] ) const reorder = useCallback( @@ -132,7 +117,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { notify('ra.page.error', 'warning') }) }, - [dataProvider, notify, refetch], + [dataProvider, notify, refetch] ) const handleDragEnd = useCallback( @@ -141,7 +126,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { const fromId = ids[from] reorder(playlistId, fromId, toId) }, - [playlistId, reorder, ids], + [playlistId, reorder, ids] ) const toggleableFields = useMemo(() => { @@ -164,23 +149,12 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { playCount: isDesktop && ( <NumberField source="playCount" sortByOrder={'DESC'} /> ), - playDate: isDesktop && ( - <DateField source="playDate" sortByOrder={'DESC'} showTime /> - ), + playDate: <DateField source="playDate" sortByOrder={'DESC'} showTime />, quality: isDesktop && <QualityInfo source="quality" sortable={false} />, channels: isDesktop && <NumberField source="channels" />, bpm: isDesktop && <NumberField source="bpm" />, - genre: <TextField source="genre" />, - rating: config.enableStarRating && ( - <RatingField - source="rating" - sortByOrder={'DESC'} - resource={'song'} - className={classes.ratingField} - /> - ), } - }, [isDesktop, classes.draggable, classes.ratingField]) + }, [isDesktop, classes.draggable]) const columns = useSelectedFields({ resource: 'playlistTrack', @@ -192,8 +166,6 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { 'playCount', 'playDate', 'albumArtist', - 'genre', - 'rating', ], }) @@ -233,7 +205,7 @@ const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { {columns} <SongContextMenu onAddToPlaylist={onAddToPlaylist} - showLove={true} + showLove={false} className={classes.contextMenu} /> </SongDatagrid> diff --git a/ui/src/playlist/index.jsx b/ui/src/playlist/index.js similarity index 100% rename from ui/src/playlist/index.jsx rename to ui/src/playlist/index.js diff --git a/ui/src/plugin/ConfigCard.jsx b/ui/src/plugin/ConfigCard.jsx deleted file mode 100644 index d9815aa3e..000000000 --- a/ui/src/plugin/ConfigCard.jsx +++ /dev/null @@ -1,123 +0,0 @@ -import React, { useCallback, useState, useMemo } from 'react' -import PropTypes from 'prop-types' -import { Card, CardContent, Typography, Box } from '@material-ui/core' -import Alert from '@material-ui/lab/Alert' -import { SchemaConfigEditor } from './SchemaConfigEditor' - -// Format error with field title and full path for nested fields -const formatError = (error, schema) => { - // Get path parts from various error formats - const rawPath = - error.dataPath || error.property || error.instancePath?.replace(/\//g, '.') - const parts = rawPath?.split('.').filter(Boolean) || [] - - // Navigate schema to find field title, build bracket-notation path - let currentSchema = schema - let fieldName = parts[parts.length - 1] - const pathParts = [] - - for (const part of parts) { - if (/^\d+$/.test(part)) { - pathParts.push(`[${part}]`) - currentSchema = currentSchema?.items - } else { - fieldName = currentSchema?.properties?.[part]?.title || part - pathParts.push(part) - currentSchema = currentSchema?.properties?.[part] - } - } - - const path = pathParts.join('.').replace(/\.\[/g, '[') - const isNested = path.includes('[') || path.includes('.') - // Replace property name in message with full path for nested fields - const message = isNested - ? error.message.replace(/'[^']+'\s*$/, `'${path}'`) - : error.message - - return { fieldName, message } -} - -export const ConfigCard = ({ - manifest, - configData, - onConfigDataChange, - classes, - translate, -}) => { - const [validationErrors, setValidationErrors] = useState([]) - - // Handle changes from JSONForms - const handleChange = useCallback( - (newData, errors) => { - setValidationErrors(errors || []) - onConfigDataChange(newData, errors) - }, - [onConfigDataChange], - ) - - // Only show config card if manifest has config schema defined - const hasConfigSchema = manifest?.config?.schema - - // Format validation errors with proper field names - const formattedErrors = useMemo(() => { - if (!hasConfigSchema) return [] - return validationErrors.map((error) => - formatError(error, manifest.config.schema), - ) - }, [validationErrors, manifest, hasConfigSchema]) - - if (!hasConfigSchema) { - return null - } - - const { schema, uiSchema } = manifest.config - - return ( - <Card className={classes.section}> - <CardContent> - <Typography variant="h6" className={classes.sectionTitle}> - {translate('resources.plugin.sections.configuration')} - </Typography> - - {formattedErrors.length > 0 && ( - <Box mb={2}> - <Alert severity="error"> - {translate('resources.plugin.messages.configValidationError')} - <ul style={{ margin: '8px 0 0', paddingLeft: 20 }}> - {formattedErrors.map((error, index) => ( - <li key={index}> - {error.fieldName && <strong>{error.fieldName}</strong>} - {error.fieldName && ': '} - {error.message} - </li> - ))} - </ul> - </Alert> - </Box> - )} - - <Box mt={formattedErrors.length > 0 ? 0 : 2}> - <SchemaConfigEditor - schema={schema} - uiSchema={uiSchema} - data={configData} - onChange={handleChange} - /> - </Box> - </CardContent> - </Card> - ) -} - -ConfigCard.propTypes = { - manifest: PropTypes.shape({ - config: PropTypes.shape({ - schema: PropTypes.object, - uiSchema: PropTypes.object, - }), - }), - configData: PropTypes.object, - onConfigDataChange: PropTypes.func.isRequired, - classes: PropTypes.object.isRequired, - translate: PropTypes.func.isRequired, -} diff --git a/ui/src/plugin/ErrorSection.jsx b/ui/src/plugin/ErrorSection.jsx deleted file mode 100644 index 61c048e2a..000000000 --- a/ui/src/plugin/ErrorSection.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react' -import { Typography } from '@material-ui/core' -import Alert from '@material-ui/lab/Alert' - -export const ErrorSection = ({ error, translate }) => { - if (!error) return null - - return ( - <Alert severity="error" style={{ marginBottom: 16 }}> - <Typography variant="subtitle2"> - {translate('resources.plugin.fields.lastError')} - </Typography> - <Typography variant="body2">{error}</Typography> - </Alert> - ) -} diff --git a/ui/src/plugin/InfoCard.jsx b/ui/src/plugin/InfoCard.jsx deleted file mode 100644 index 3a7bb24c7..000000000 --- a/ui/src/plugin/InfoCard.jsx +++ /dev/null @@ -1,244 +0,0 @@ -import React, { useState } from 'react' -import { - Card, - CardContent, - Typography, - Grid, - Box, - Chip, - Tooltip, - Link, - ClickAwayListener, -} from '@material-ui/core' -import { useTranslate } from 'react-admin' -import { DateField } from '../common' - -// Helper component for permission chips with clickable persistent tooltips -const PermissionChip = ({ label, permission, classes }) => { - const [open, setOpen] = useState(false) - const translate = useTranslate() - - if (!permission) return null - - const hasHosts = permission.requiredHosts?.length > 0 - const hasTooltip = permission.reason || hasHosts - - const handleClick = () => { - if (hasTooltip) { - setOpen((prev) => !prev) - } - } - - const handleClose = () => { - setOpen(false) - } - - const tooltipContent = ( - <Box className={classes.tooltipContent}> - {permission.reason && ( - <Typography variant="body2">{permission.reason}</Typography> - )} - {hasHosts && ( - <Box mt={permission.reason ? 0.5 : 0}> - <Typography variant="caption" component="div"> - {translate('resources.plugin.messages.requiredHosts')}:{' '} - {permission.requiredHosts.map((host, i) => ( - <span key={host}> - {i > 0 && ', '} - <code>{host}</code> - </span> - ))} - </Typography> - </Box> - )} - </Box> - ) - - const chip = ( - <Chip - size="small" - label={label} - className={classes.permissionChip} - onClick={hasTooltip ? handleClick : undefined} - clickable={hasTooltip} - /> - ) - - if (!hasTooltip) { - return chip - } - - return ( - <ClickAwayListener onClickAway={handleClose}> - <div> - <Tooltip - title={tooltipContent} - arrow - open={open} - disableFocusListener - disableHoverListener - disableTouchListener - PopperProps={{ - disablePortal: true, - }} - > - {chip} - </Tooltip> - </div> - </ClickAwayListener> - ) -} - -// Info row component for responsive grid -const InfoRow = ({ label, children, classes, isSmall }) => ( - <> - <Grid item xs={12} sm={3}> - <Typography - variant="body2" - className={classes.infoLabel} - component={isSmall ? 'div' : 'span'} - > - {label} - </Typography> - </Grid> - <Grid item xs={12} sm={9}> - <Typography variant="body2" component="div"> - {children} - </Typography> - </Grid> - </> -) - -// Plugin information card -export const InfoCard = ({ record, manifest, classes, translate, isSmall }) => ( - <Card className={classes.section}> - <CardContent> - <Typography variant="h6" className={classes.sectionTitle}> - {translate('resources.plugin.sections.info')} - </Typography> - <Grid container spacing={1} className={classes.infoGrid}> - <InfoRow - label={translate('resources.plugin.fields.id')} - classes={classes} - isSmall={isSmall} - > - {record.id} - <Typography - variant="caption" - color="textSecondary" - className={classes.fieldHelp} - > - {translate('resources.plugin.messages.idHelp')} - </Typography> - </InfoRow> - - {manifest?.name && ( - <InfoRow - label={translate('resources.plugin.fields.name')} - classes={classes} - isSmall={isSmall} - > - {manifest.name} - </InfoRow> - )} - - {manifest?.version && ( - <InfoRow - label={translate('resources.plugin.fields.version')} - classes={classes} - isSmall={isSmall} - > - {manifest.version} - </InfoRow> - )} - - {manifest?.description && ( - <InfoRow - label={translate('resources.plugin.fields.description')} - classes={classes} - isSmall={isSmall} - > - {manifest.description} - </InfoRow> - )} - - {manifest?.author && ( - <InfoRow - label={translate('resources.plugin.fields.author')} - classes={classes} - isSmall={isSmall} - > - {manifest.author} - </InfoRow> - )} - - {manifest?.website && ( - <InfoRow - label={translate('resources.plugin.fields.website')} - classes={classes} - isSmall={isSmall} - > - <Link - href={manifest.website} - target="_blank" - rel="noopener noreferrer" - > - {manifest.website} - </Link> - </InfoRow> - )} - - {manifest?.permissions && - Object.keys(manifest.permissions).length > 0 && ( - <InfoRow - label={translate('resources.plugin.fields.permissions')} - classes={classes} - isSmall={isSmall} - > - <Box className={classes.permissionsContainer}> - {Object.entries(manifest.permissions).map(([key, value]) => ( - <PermissionChip - key={key} - label={key} - permission={value} - classes={classes} - /> - ))} - </Box> - <Typography - variant="caption" - color="textSecondary" - className={classes.fieldHelp} - > - {translate('resources.plugin.messages.clickPermissions')} - </Typography> - </InfoRow> - )} - - <InfoRow - label={translate('resources.plugin.fields.path')} - classes={classes} - isSmall={isSmall} - > - <span className={classes.pathField}>{record.path}</span> - </InfoRow> - - <InfoRow - label={translate('resources.plugin.fields.updatedAt')} - classes={classes} - isSmall={isSmall} - > - <DateField record={record} source="updatedAt" showTime /> - </InfoRow> - - <InfoRow - label={translate('resources.plugin.fields.createdAt')} - classes={classes} - isSmall={isSmall} - > - <DateField record={record} source="createdAt" showTime /> - </InfoRow> - </Grid> - </CardContent> - </Card> -) diff --git a/ui/src/plugin/InfoCard.test.jsx b/ui/src/plugin/InfoCard.test.jsx deleted file mode 100644 index 24fa48caa..000000000 --- a/ui/src/plugin/InfoCard.test.jsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import { describe, it, expect, vi } from 'vitest' - -vi.mock('../common', () => ({ - DateField: ({ source }) => <span data-testid={`date-${source}`} />, -})) - -const { InfoCard } = await import('./InfoCard') - -const record = { - id: 'apple-music', - path: '/data/plugins/apple-music.ndp', - updatedAt: '2026-01-01T00:00:00Z', - createdAt: '2026-01-01T00:00:00Z', -} - -const renderCard = () => - render( - <InfoCard - record={record} - manifest={{ name: 'Apple Music Metadata Agent' }} - classes={{}} - translate={(key) => key} - isSmall={false} - />, - ) - -describe('InfoCard', () => { - it('shows the plugin ID', () => { - renderCard() - expect(screen.getByText('apple-music')).toBeInTheDocument() - }) - - it('explains that the ID is the name used in config options', () => { - renderCard() - expect( - screen.getByText('resources.plugin.messages.idHelp'), - ).toBeInTheDocument() - }) -}) diff --git a/ui/src/plugin/LibraryPermissionCard.jsx b/ui/src/plugin/LibraryPermissionCard.jsx deleted file mode 100644 index d3c237279..000000000 --- a/ui/src/plugin/LibraryPermissionCard.jsx +++ /dev/null @@ -1,201 +0,0 @@ -import React from 'react' -import { - Card, - CardContent, - Typography, - Box, - FormControlLabel, - Switch, - List, - ListItem, - ListItemIcon, - ListItemText, - Checkbox, -} from '@material-ui/core' -import CheckBoxIcon from '@material-ui/icons/CheckBox' -import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank' -import Alert from '@material-ui/lab/Alert' -import { useGetList, useTranslate } from 'react-admin' -import PropTypes from 'prop-types' - -export const LibraryPermissionCard = ({ - manifest, - classes, - selectedLibraries, - allLibraries, - allowWriteAccess, - onSelectedLibrariesChange, - onAllLibrariesChange, - onAllowWriteAccessChange, -}) => { - const translate = useTranslate() - - // Fetch all libraries - const { data: librariesData, loading: librariesLoading } = useGetList( - 'library', - { - pagination: { page: 1, perPage: 1000 }, - sort: { field: 'name', order: 'ASC' }, - }, - ) - - const libraries = React.useMemo(() => { - return librariesData ? Object.values(librariesData) : [] - }, [librariesData]) - - const handleToggleLibrary = React.useCallback( - (libraryId) => { - const newSelected = selectedLibraries.includes(libraryId) - ? selectedLibraries.filter((id) => id !== libraryId) - : [...selectedLibraries, libraryId] - onSelectedLibrariesChange(newSelected) - }, - [selectedLibraries, onSelectedLibrariesChange], - ) - - const handleAllLibrariesToggle = React.useCallback( - (event) => { - onAllLibrariesChange(event.target.checked) - }, - [onAllLibrariesChange], - ) - - const handleAllowWriteAccessToggle = React.useCallback( - (event) => { - onAllowWriteAccessChange(event.target.checked) - }, - [onAllowWriteAccessChange], - ) - - // Get permission reason from manifest - const libraryPermission = manifest?.permissions?.library - const reason = libraryPermission?.reason - const hasFilesystem = libraryPermission?.filesystem === true - - // Check if permission is required but not configured - const isConfigurationRequired = - libraryPermission && !allLibraries && selectedLibraries.length === 0 - - if (!libraryPermission) { - return null - } - - return ( - <Card className={classes.section}> - <CardContent> - <Typography variant="h6" className={classes.sectionTitle}> - {translate('resources.plugin.sections.libraryPermission')} - </Typography> - - {reason && ( - <Typography variant="body2" color="textSecondary" gutterBottom> - {translate('resources.plugin.messages.permissionReason')}: {reason} - </Typography> - )} - - {isConfigurationRequired && ( - <Box mb={2}> - <Alert severity="warning"> - {translate('resources.plugin.messages.librariesRequired')} - </Alert> - </Box> - )} - - <Box mb={2}> - <FormControlLabel - control={ - <Switch - checked={allLibraries} - onChange={handleAllLibrariesToggle} - color="primary" - /> - } - label={translate('resources.plugin.fields.allLibraries')} - /> - <Typography variant="body2" color="textSecondary"> - {translate('resources.plugin.messages.allLibrariesHelp')} - </Typography> - </Box> - - {hasFilesystem && ( - <Box mb={2}> - <FormControlLabel - control={ - <Switch - checked={allowWriteAccess} - onChange={handleAllowWriteAccessToggle} - color="primary" - /> - } - label={translate('resources.plugin.fields.allowWriteAccess')} - /> - <Typography variant="body2" color="textSecondary"> - {translate('resources.plugin.messages.allowWriteAccessHelp')} - </Typography> - </Box> - )} - - {!allLibraries && ( - <Box className={classes.usersList}> - <Typography variant="subtitle2" gutterBottom> - {translate('resources.plugin.fields.selectedLibraries')} - </Typography> - {librariesLoading ? ( - <Typography variant="body2" color="textSecondary"> - {translate('ra.message.loading')} - </Typography> - ) : libraries.length === 0 ? ( - <Typography variant="body2" color="textSecondary"> - {translate('resources.plugin.messages.noLibraries')} - </Typography> - ) : ( - <List - dense - style={{ - maxHeight: 200, - overflow: 'auto', - border: '1px solid rgba(0, 0, 0, 0.12)', - borderRadius: 4, - }} - > - {libraries.map((library) => ( - <ListItem - key={library.id} - button - onClick={() => handleToggleLibrary(library.id)} - dense - > - <ListItemIcon> - <Checkbox - icon={<CheckBoxOutlineBlankIcon fontSize="small" />} - checkedIcon={<CheckBoxIcon fontSize="small" />} - checked={selectedLibraries.includes(library.id)} - tabIndex={-1} - disableRipple - /> - </ListItemIcon> - <ListItemText - primary={library.name} - secondary={library.path} - /> - </ListItem> - ))} - </List> - )} - </Box> - )} - </CardContent> - </Card> - ) -} - -LibraryPermissionCard.propTypes = { - manifest: PropTypes.object, - classes: PropTypes.object.isRequired, - selectedLibraries: PropTypes.array.isRequired, - allLibraries: PropTypes.bool.isRequired, - allowWriteAccess: PropTypes.bool.isRequired, - onSelectedLibrariesChange: PropTypes.func.isRequired, - onAllLibrariesChange: PropTypes.func.isRequired, - onAllowWriteAccessChange: PropTypes.func.isRequired, -} diff --git a/ui/src/plugin/ManifestSection.jsx b/ui/src/plugin/ManifestSection.jsx deleted file mode 100644 index 3fef65f70..000000000 --- a/ui/src/plugin/ManifestSection.jsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react' -import { - Accordion, - AccordionSummary, - AccordionDetails, - Typography, - Box, -} from '@material-ui/core' -import { MdExpandMore } from 'react-icons/md' - -export const ManifestSection = ({ manifestJson, classes, translate }) => ( - <Accordion className={classes.section}> - <AccordionSummary expandIcon={<MdExpandMore />}> - <Typography variant="h6"> - {translate('resources.plugin.sections.manifest')} - </Typography> - </AccordionSummary> - <AccordionDetails> - <Box className={classes.manifestBox} width="100%"> - {manifestJson} - </Box> - </AccordionDetails> - </Accordion> -) diff --git a/ui/src/plugin/OutlinedRenderers.jsx b/ui/src/plugin/OutlinedRenderers.jsx deleted file mode 100644 index 8020a5e4f..000000000 --- a/ui/src/plugin/OutlinedRenderers.jsx +++ /dev/null @@ -1,266 +0,0 @@ -/* eslint-disable react-refresh/only-export-components */ -import React, { useState } from 'react' -import { - rankWith, - isStringControl, - isIntegerControl, - isNumberControl, - isEnumControl, - isOneOfEnumControl, - and, - not, - or, - optionIs, - isDescriptionHidden, -} from '@jsonforms/core' -import { - withJsonFormsControlProps, - withJsonFormsEnumProps, - withJsonFormsOneOfEnumProps, -} from '@jsonforms/react' -import { - TextField, - FormControl, - FormHelperText, - InputLabel, - Select, - MenuItem, -} from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import merge from 'lodash/merge' - -const useStyles = makeStyles( - (theme) => ({ - control: { - marginBottom: theme.spacing(2), - }, - }), - { name: 'NDOutlinedRenderers' }, -) - -/** - * Hook for common control state (focus, validation, description visibility) - */ -const useControlState = (props) => { - const { config, uischema, description, visible, errors } = props - const [isFocused, setIsFocused] = useState(false) - - const appliedUiSchemaOptions = merge({}, config, uischema?.options) - // errors is a string when there are validation errors, empty/undefined when valid - const showError = errors && errors.length > 0 - - const showDescription = !isDescriptionHidden( - visible, - description, - isFocused, - appliedUiSchemaOptions.showUnfocusedDescription, - ) - - const helperText = showError ? errors : showDescription ? description : '' - - const handleFocus = () => setIsFocused(true) - const handleBlur = () => setIsFocused(false) - - return { - isFocused, - appliedUiSchemaOptions, - showError, - helperText, - handleFocus, - handleBlur, - } -} - -/** - * Base outlined control component that uses TextField with outlined variant - * instead of the default Input component used by JSONForms 2.x - */ -const OutlinedControl = (props) => { - const classes = useStyles() - const { - data, - id, - enabled, - label, - visible, - type = 'text', - inputProps: extraInputProps = {}, - onChange, - } = props - - const { - appliedUiSchemaOptions, - showError, - helperText, - handleFocus, - handleBlur, - } = useControlState(props) - - if (!visible) { - return null - } - - return ( - <TextField - id={id} - label={label} - type={type} - value={data ?? ''} - onChange={onChange} - onFocus={handleFocus} - onBlur={handleBlur} - disabled={!enabled} - autoFocus={appliedUiSchemaOptions.focus} - multiline={type === 'text' && appliedUiSchemaOptions.multi} - rows={appliedUiSchemaOptions.multi ? 3 : undefined} - variant="outlined" - fullWidth - size="small" - error={showError} - helperText={helperText} - inputProps={extraInputProps} - className={classes.control} - /> - ) -} - -// Text control wrapper -const OutlinedTextControl = (props) => { - const { path, handleChange, schema, config, uischema } = props - const appliedUiSchemaOptions = merge({}, config, uischema?.options) - - const inputProps = {} - if (appliedUiSchemaOptions.restrict && schema?.maxLength) { - inputProps.maxLength = schema.maxLength - } - - return ( - <OutlinedControl - {...props} - type={appliedUiSchemaOptions.format === 'password' ? 'password' : 'text'} - inputProps={inputProps} - onChange={(ev) => handleChange(path, ev.target.value)} - /> - ) -} - -// Number control wrapper -const OutlinedNumberControl = (props) => { - const { path, handleChange, schema } = props - const { minimum, maximum } = schema || {} - - const inputProps = {} - if (minimum !== undefined) inputProps.min = minimum - if (maximum !== undefined) inputProps.max = maximum - - const handleNumberChange = (ev) => { - const value = ev.target.value - if (value === '') { - handleChange(path, undefined) - } else { - const numValue = Number(value) - if (!isNaN(numValue)) { - handleChange(path, numValue) - } - } - } - - return ( - <OutlinedControl - {...props} - type="number" - inputProps={inputProps} - onChange={handleNumberChange} - /> - ) -} - -// Enum/Select control wrapper -const OutlinedEnumControl = (props) => { - const classes = useStyles() - const { - data, - id, - enabled, - path, - handleChange, - options, - label, - visible, - required, - } = props - const { - appliedUiSchemaOptions, - showError, - helperText, - handleFocus, - handleBlur, - } = useControlState(props) - - if (!visible) { - return null - } - - return ( - <FormControl - fullWidth - variant="outlined" - size="small" - error={showError} - className={classes.control} - > - <InputLabel id={`${id}-label`}>{label}</InputLabel> - <Select - labelId={`${id}-label`} - id={id} - value={data ?? ''} - onChange={(ev) => { - handleChange( - path, - ev.target.value === '' ? undefined : ev.target.value, - ) - }} - onFocus={handleFocus} - onBlur={handleBlur} - disabled={!enabled} - autoFocus={appliedUiSchemaOptions.focus} - label={label} - fullWidth - > - {!required && ( - <MenuItem value=""> - <em>None</em> - </MenuItem> - )} - {options?.map((option) => ( - <MenuItem key={option.value} value={option.value}> - {option.label} - </MenuItem> - ))} - </Select> - {helperText && <FormHelperText>{helperText}</FormHelperText>} - </FormControl> - ) -} - -// Testers - higher rank than default to override default renderers -// Enum renderers have highest rank since isStringControl also matches enum fields -export const OutlinedEnumRenderer = { - tester: rankWith(5, isEnumControl), - renderer: withJsonFormsEnumProps(OutlinedEnumControl), -} - -export const OutlinedOneOfEnumRenderer = { - tester: rankWith(5, isOneOfEnumControl), - renderer: withJsonFormsOneOfEnumProps(OutlinedEnumControl), -} - -export const OutlinedTextRenderer = { - tester: rankWith(3, and(isStringControl, not(optionIs('format', 'radio')))), - renderer: withJsonFormsControlProps(OutlinedTextControl), -} - -export const OutlinedNumberRenderer = { - tester: rankWith(3, or(isIntegerControl, isNumberControl)), - renderer: withJsonFormsControlProps(OutlinedNumberControl), -} diff --git a/ui/src/plugin/PluginList.jsx b/ui/src/plugin/PluginList.jsx deleted file mode 100644 index 346e1ceae..000000000 --- a/ui/src/plugin/PluginList.jsx +++ /dev/null @@ -1,178 +0,0 @@ -import React, { useMemo, useState, useCallback } from 'react' -import { - Button, - Datagrid, - Empty, - TextField, - TopToolbar, - useNotify, - useRecordContext, - useRefresh, - useTranslate, -} from 'react-admin' -import { makeStyles } from '@material-ui/core/styles' -import { - useMediaQuery, - Tooltip, - Chip, - Typography, - Box, -} from '@material-ui/core' -import { MdError, MdRefresh } from 'react-icons/md' -import { List, DateField, SimpleList, useResourceRefresh } from '../common' -import { httpClient } from '../dataProvider' -import ToggleEnabledSwitch from './ToggleEnabledSwitch' - -const useStyles = makeStyles((theme) => ({ - errorIcon: { - color: theme.palette.error.main, - marginRight: theme.spacing(0.5), - verticalAlign: 'middle', - }, - errorChip: { - backgroundColor: theme.palette.error.light, - color: theme.palette.error.contrastText, - }, -})) - -const useManifest = () => { - const record = useRecordContext() - return useMemo(() => { - if (!record?.manifest) return null - try { - return JSON.parse(record.manifest) - } catch { - return null - } - }, [record?.manifest]) -} - -const EnabledOrErrorField = () => { - const record = useRecordContext() - const translate = useTranslate() - const classes = useStyles() - const manifest = useManifest() - - if (record.lastError) { - return ( - <Tooltip title={record.lastError}> - <Chip - size="small" - icon={<MdError className={classes.errorIcon} />} - label={translate('resources.plugin.fields.hasError')} - className={classes.errorChip} - /> - </Tooltip> - ) - } - - return <ToggleEnabledSwitch source={'enabled'} manifest={manifest} /> -} - -const ManifestField = ({ source }) => { - const manifest = useManifest() - - if (!manifest) { - return <Typography variant="body2">-</Typography> - } - - return <Typography variant="body2">{manifest[source] || '-'}</Typography> -} - -const RescanButton = () => { - const notify = useNotify() - const refresh = useRefresh() - const [loading, setLoading] = useState(false) - - const handleRescan = useCallback(() => { - setLoading(true) - httpClient('/api/plugin/rescan', { method: 'POST' }) - .then(() => { - refresh() - }) - .catch((error) => { - notify(error.message || 'ra.page.error', { type: 'warning' }) - }) - .finally(() => { - setLoading(false) - }) - }, [notify, refresh]) - - return ( - <Button - onClick={handleRescan} - disabled={loading} - label="resources.plugin.actions.rescan" - data-testid="rescan-button" - > - <MdRefresh /> - </Button> - ) -} - -const PluginListActions = () => { - return ( - <TopToolbar> - <RescanButton /> - </TopToolbar> - ) -} - -const PluginEmpty = () => { - return ( - <> - <Empty /> - <Box textAlign="center" mt={2}> - <RescanButton /> - </Box> - </> - ) -} - -const PluginList = (props) => { - const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) - const translate = useTranslate() - useResourceRefresh('plugin') - - return ( - <List - {...props} - sort={{ field: 'id', order: 'ASC' }} - exporter={false} - bulkActionButtons={false} - actions={<PluginListActions />} - empty={<PluginEmpty />} - > - {isXsmall ? ( - <SimpleList - primaryText={(record) => record.id} - secondaryText={(record) => { - try { - const manifest = JSON.parse(record.manifest) - return manifest.description || '' - } catch { - return '' - } - }} - tertiaryText={(record) => - record.enabled - ? translate('resources.plugin.status.enabled') - : translate('resources.plugin.status.disabled') - } - linkType="show" - /> - ) : ( - <Datagrid rowClick="show"> - <TextField source="id" /> - <ManifestField source="name" /> - {!isXsmall && <ManifestField source="description" />} - <ManifestField source="version" /> - <EnabledOrErrorField source={'enabled'} /> - <DateField source="updatedAt" sortByOrder={'DESC'} /> - </Datagrid> - )} - </List> - ) -} - -export default PluginList diff --git a/ui/src/plugin/PluginList.test.jsx b/ui/src/plugin/PluginList.test.jsx deleted file mode 100644 index c2e0414eb..000000000 --- a/ui/src/plugin/PluginList.test.jsx +++ /dev/null @@ -1,173 +0,0 @@ -import React from 'react' -import { - render, - screen, - fireEvent, - waitFor, - within, -} from '@testing-library/react' -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const mockNotify = vi.fn() -const mockRefresh = vi.fn() - -// Mock react-admin hooks -vi.mock('react-admin', async () => { - const actual = await vi.importActual('react-admin') - return { - ...actual, - useUpdate: vi.fn(() => [vi.fn(), { loading: false }]), - useNotify: vi.fn(() => mockNotify), - useRefresh: vi.fn(() => mockRefresh), - useTranslate: vi.fn(() => (key) => key), - useResourceContext: vi.fn(() => 'plugin'), - useRecordContext: vi.fn(() => ({ - id: 'test-plugin', - manifest: JSON.stringify({ - name: 'Test Plugin', - version: '1.0.0', - description: 'Test plugin', - }), - enabled: true, - lastError: null, - })), - Button: ({ onClick, disabled, label, children }) => ( - <button onClick={onClick} disabled={disabled} data-testid="rescan-button"> - {children} - {label} - </button> - ), - TopToolbar: ({ children }) => ( - <div data-testid="top-toolbar">{children}</div> - ), - Empty: () => <div data-testid="ra-empty">No resources</div>, - Datagrid: ({ children }) => <div data-testid="datagrid">{children}</div>, - TextField: ({ source }) => <span data-testid={`text-${source}`} />, - } -}) - -// Mock common components -vi.mock('../common', async () => { - return { - List: ({ children, actions, empty, ...props }) => ( - <div data-testid="list"> - {actions} - {empty && <div data-testid="empty-state">{empty}</div>} - {children} - </div> - ), - DateField: ({ source }) => <span data-testid={`date-${source}`} />, - SimpleList: ({ primaryText, secondaryText }) => ( - <div data-testid="simple-list" /> - ), - useResourceRefresh: vi.fn(), - } -}) - -// Mock Material-UI -vi.mock('@material-ui/core', async () => { - const actual = await vi.importActual('@material-ui/core') - return { - ...actual, - useMediaQuery: vi.fn(() => false), - } -}) - -// Mock ToggleEnabledSwitch -vi.mock('./ToggleEnabledSwitch', () => ({ - default: () => <span data-testid="toggle-switch" />, -})) - -// Mock httpClient -const mockHttpClient = vi.fn() -vi.mock('../dataProvider', () => ({ - httpClient: (...args) => mockHttpClient(...args), -})) - -import PluginList from './PluginList' - -describe('PluginList', () => { - beforeEach(() => { - vi.clearAllMocks() - mockHttpClient.mockResolvedValue({}) - }) - - it('renders the list component', () => { - render(<PluginList />) - expect(screen.getByTestId('list')).toBeInTheDocument() - }) - - it('renders the datagrid on desktop', () => { - render(<PluginList />) - expect(screen.getByTestId('datagrid')).toBeInTheDocument() - }) - - it('renders the rescan button in the toolbar', () => { - render(<PluginList />) - const toolbar = screen.getByTestId('top-toolbar') - expect(within(toolbar).getByTestId('rescan-button')).toBeInTheDocument() - }) - - it('calls rescan endpoint when rescan button is clicked', async () => { - render(<PluginList />) - const toolbar = screen.getByTestId('top-toolbar') - const rescanButton = within(toolbar).getByTestId('rescan-button') - - fireEvent.click(rescanButton) - - await waitFor(() => { - expect(mockHttpClient).toHaveBeenCalledWith('/api/plugin/rescan', { - method: 'POST', - }) - }) - }) - - it('calls refresh after successful rescan', async () => { - render(<PluginList />) - const toolbar = screen.getByTestId('top-toolbar') - const rescanButton = within(toolbar).getByTestId('rescan-button') - - fireEvent.click(rescanButton) - - await waitFor(() => { - expect(mockRefresh).toHaveBeenCalled() - }) - }) - - it('shows error notification on rescan failure', async () => { - mockHttpClient.mockRejectedValue(new Error('Network error')) - - render(<PluginList />) - const toolbar = screen.getByTestId('top-toolbar') - const rescanButton = within(toolbar).getByTestId('rescan-button') - - fireEvent.click(rescanButton) - - await waitFor(() => { - expect(mockNotify).toHaveBeenCalledWith('Network error', { - type: 'warning', - }) - }) - }) - - it('renders a rescan button in the empty state', () => { - render(<PluginList />) - const emptyState = screen.getByTestId('empty-state') - expect(emptyState).toBeInTheDocument() - expect(within(emptyState).getByTestId('rescan-button')).toBeInTheDocument() - }) - - it('empty state rescan button triggers rescan', async () => { - render(<PluginList />) - const emptyState = screen.getByTestId('empty-state') - const rescanButton = within(emptyState).getByTestId('rescan-button') - - fireEvent.click(rescanButton) - - await waitFor(() => { - expect(mockHttpClient).toHaveBeenCalledWith('/api/plugin/rescan', { - method: 'POST', - }) - }) - }) -}) diff --git a/ui/src/plugin/PluginShow.jsx b/ui/src/plugin/PluginShow.jsx deleted file mode 100644 index caea44a75..000000000 --- a/ui/src/plugin/PluginShow.jsx +++ /dev/null @@ -1,350 +0,0 @@ -import React, { useState, useCallback, useMemo } from 'react' -import { - ShowContextProvider, - useShowController, - useShowContext, - useTranslate, - useUpdate, - useNotify, - useRefresh, - Title as RaTitle, - Loading, -} from 'react-admin' -import { Box, useMediaQuery, Button } from '@material-ui/core' -import { MdSave } from 'react-icons/md' -import Alert from '@material-ui/lab/Alert' -import { Title, useResourceRefresh } from '../common' -import { usePluginShowStyles } from './styles.js' -import { ErrorSection } from './ErrorSection' -import { StatusCard } from './StatusCard' -import { InfoCard } from './InfoCard' -import { ManifestSection } from './ManifestSection' -import { ConfigCard } from './ConfigCard' -import { UsersPermissionCard } from './UsersPermissionCard' -import { LibraryPermissionCard } from './LibraryPermissionCard' - -// Main show layout component -const PluginShowLayout = () => { - const { record, isPending, error } = useShowContext() - const classes = usePluginShowStyles() - const translate = useTranslate() - const notify = useNotify() - const refresh = useRefresh() - const isSmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) - useResourceRefresh('plugin') - - const [configData, setConfigData] = useState({}) - const [configErrors, setConfigErrors] = useState([]) - const [isDirty, setIsDirty] = useState(false) - const [lastRecordConfig, setLastRecordConfig] = useState(null) - const [isConfigInitialized, setIsConfigInitialized] = useState(false) - - // Users permission state - const [selectedUsers, setSelectedUsers] = useState([]) - const [allUsers, setAllUsers] = useState(false) - const [lastRecordUsers, setLastRecordUsers] = useState(null) - const [lastRecordAllUsers, setLastRecordAllUsers] = useState(null) - - // Libraries permission state - const [selectedLibraries, setSelectedLibraries] = useState([]) - const [allLibraries, setAllLibraries] = useState(false) - const [allowWriteAccess, setAllowWriteAccess] = useState(false) - const [lastRecordLibraries, setLastRecordLibraries] = useState(null) - const [lastRecordAllLibraries, setLastRecordAllLibraries] = useState(null) - const [lastRecordAllowWriteAccess, setLastRecordAllowWriteAccess] = - useState(null) - - // Parse JSON config to object - const jsonToObject = useCallback((jsonString) => { - if (!jsonString || jsonString.trim() === '') return {} - try { - return JSON.parse(jsonString) - } catch { - return {} - } - }, []) - - // Initialize/update config when record loads or changes (e.g., from SSE refresh) - React.useEffect(() => { - const recordConfig = record?.config || '' - if (record && recordConfig !== lastRecordConfig && !isDirty) { - setConfigData(jsonToObject(recordConfig)) - setLastRecordConfig(recordConfig) - // Reset initialization flag - AJV will apply defaults on first render - setIsConfigInitialized(false) - } - }, [record, lastRecordConfig, isDirty, jsonToObject]) - - // Initialize/update users permission state when record loads or changes - React.useEffect(() => { - if (record && !isDirty) { - const recordUsers = record.users || '' - const recordAllUsers = record.allUsers || false - - if ( - recordUsers !== lastRecordUsers || - recordAllUsers !== lastRecordAllUsers - ) { - try { - setSelectedUsers(recordUsers ? JSON.parse(recordUsers) : []) - } catch { - setSelectedUsers([]) - } - setAllUsers(recordAllUsers) - setLastRecordUsers(recordUsers) - setLastRecordAllUsers(recordAllUsers) - } - } - }, [record, lastRecordUsers, lastRecordAllUsers, isDirty]) - - // Initialize/update libraries permission state when record loads or changes - React.useEffect(() => { - if (record && !isDirty) { - const recordLibraries = record.libraries || '' - const recordAllLibraries = record.allLibraries || false - const recordAllowWriteAccess = record.allowWriteAccess || false - - if ( - recordLibraries !== lastRecordLibraries || - recordAllLibraries !== lastRecordAllLibraries || - recordAllowWriteAccess !== lastRecordAllowWriteAccess - ) { - try { - setSelectedLibraries( - recordLibraries ? JSON.parse(recordLibraries) : [], - ) - } catch { - setSelectedLibraries([]) - } - setAllLibraries(recordAllLibraries) - setAllowWriteAccess(recordAllowWriteAccess) - setLastRecordLibraries(recordLibraries) - setLastRecordAllLibraries(recordAllLibraries) - setLastRecordAllowWriteAccess(recordAllowWriteAccess) - } - } - }, [ - record, - lastRecordLibraries, - lastRecordAllLibraries, - lastRecordAllowWriteAccess, - isDirty, - ]) - - const handleConfigDataChange = useCallback( - (newData, errors) => { - setConfigData(newData) - setConfigErrors(errors || []) - // Skip marking dirty on initial onChange (when AJV applies defaults) - if (isConfigInitialized) { - setIsDirty(true) - } else { - setIsConfigInitialized(true) - } - }, - [isConfigInitialized], - ) - - const handleSelectedUsersChange = useCallback((newSelectedUsers) => { - setSelectedUsers(newSelectedUsers) - setIsDirty(true) - }, []) - - const handleAllUsersChange = useCallback((newAllUsers) => { - setAllUsers(newAllUsers) - setIsDirty(true) - }, []) - - const handleSelectedLibrariesChange = useCallback((newSelectedLibraries) => { - setSelectedLibraries(newSelectedLibraries) - setIsDirty(true) - }, []) - - const handleAllLibrariesChange = useCallback((newAllLibraries) => { - setAllLibraries(newAllLibraries) - setIsDirty(true) - }, []) - - const handleAllowWriteAccessChange = useCallback((newAllowWriteAccess) => { - setAllowWriteAccess(newAllowWriteAccess) - setIsDirty(true) - }, []) - - const [updatePlugin, { loading }] = useUpdate( - 'plugin', - record?.id, - {}, - record, - { - undoable: false, - onSuccess: () => { - refresh() - setIsDirty(false) - setLastRecordConfig(null) // Reset to reinitialize from server - setLastRecordUsers(null) - setLastRecordAllUsers(null) - setLastRecordLibraries(null) - setLastRecordAllLibraries(null) - setLastRecordAllowWriteAccess(null) - notify('resources.plugin.notifications.updated', 'info') - }, - onFailure: (err) => { - notify( - err?.message || 'resources.plugin.notifications.error', - 'warning', - ) - }, - }, - ) - - const handleSaveConfig = useCallback(() => { - if (!record) return - const parsedManifest = record.manifest ? JSON.parse(record.manifest) : null - const data = {} - - // Only include config if the plugin has a config schema - if (parsedManifest?.config?.schema) { - data.config = - Object.keys(configData).length > 0 ? JSON.stringify(configData) : '' - } - - // Include users data if users permission is present - if (parsedManifest?.permissions?.users) { - data.users = JSON.stringify(selectedUsers) - data.allUsers = allUsers - } - - // Include libraries data if library permission is present - if (parsedManifest?.permissions?.library) { - data.libraries = JSON.stringify(selectedLibraries) - data.allLibraries = allLibraries - data.allowWriteAccess = allowWriteAccess - } - - updatePlugin('plugin', record.id, data, record) - }, [ - updatePlugin, - record, - configData, - selectedUsers, - allUsers, - selectedLibraries, - allLibraries, - allowWriteAccess, - ]) - - // Parse manifest - const { manifest, manifestJson } = useMemo(() => { - if (!record?.manifest) return { manifest: null, manifestJson: '' } - try { - const parsed = JSON.parse(record.manifest) - return { manifest: parsed, manifestJson: JSON.stringify(parsed, null, 2) } - } catch { - return { manifest: null, manifestJson: record.manifest } - } - }, [record?.manifest]) - - // Handle loading state - if (isPending) { - return <Loading /> - } - - // Handle error state - if (error) { - return ( - <Alert severity="error">{translate('ra.notification.http_error')}</Alert> - ) - } - - // Handle missing record - if (!record) { - return null - } - - return ( - <> - <RaTitle - title={ - <Title - subTitle={`${translate('resources.plugin.name', { smart_count: 1 })} "${record.id}"`} - /> - } - /> - <Box className={classes.root}> - <ErrorSection error={record.lastError} translate={translate} /> - - <StatusCard - classes={classes} - translate={translate} - manifest={manifest} - /> - - <InfoCard - record={record} - manifest={manifest} - classes={classes} - translate={translate} - isSmall={isSmall} - /> - - <ManifestSection - manifestJson={manifestJson} - classes={classes} - translate={translate} - /> - - <ConfigCard - manifest={manifest} - configData={configData} - onConfigDataChange={handleConfigDataChange} - classes={classes} - translate={translate} - /> - - <UsersPermissionCard - manifest={manifest} - classes={classes} - selectedUsers={selectedUsers} - allUsers={allUsers} - onSelectedUsersChange={handleSelectedUsersChange} - onAllUsersChange={handleAllUsersChange} - /> - - <LibraryPermissionCard - manifest={manifest} - classes={classes} - selectedLibraries={selectedLibraries} - allLibraries={allLibraries} - allowWriteAccess={allowWriteAccess} - onSelectedLibrariesChange={handleSelectedLibrariesChange} - onAllLibrariesChange={handleAllLibrariesChange} - onAllowWriteAccessChange={handleAllowWriteAccessChange} - /> - - <Box display="flex" justifyContent="flex-end"> - <Button - variant="contained" - color="primary" - startIcon={<MdSave />} - onClick={handleSaveConfig} - disabled={!isDirty || loading || configErrors.length > 0} - className={classes.saveButton} - > - {translate('ra.action.save')} - </Button> - </Box> - </Box> - </> - ) -} - -const PluginShow = (props) => { - const controllerProps = useShowController(props) - return ( - <ShowContextProvider value={controllerProps}> - <PluginShowLayout /> - </ShowContextProvider> - ) -} - -export default PluginShow diff --git a/ui/src/plugin/SchemaConfigEditor.jsx b/ui/src/plugin/SchemaConfigEditor.jsx deleted file mode 100644 index dc8f8a0f1..000000000 --- a/ui/src/plugin/SchemaConfigEditor.jsx +++ /dev/null @@ -1,239 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react' -import PropTypes from 'prop-types' -import { JsonForms } from '@jsonforms/react' -import { materialRenderers, materialCells } from '@jsonforms/material-renderers' -import { makeStyles } from '@material-ui/core/styles' -import { Typography } from '@material-ui/core' -import { useTranslate } from 'react-admin' -import Ajv from 'ajv' -import { - OutlinedTextRenderer, - OutlinedNumberRenderer, - OutlinedEnumRenderer, - OutlinedOneOfEnumRenderer, -} from './OutlinedRenderers' - -// Error boundary for catching JSONForms rendering errors -class SchemaErrorBoundary extends React.Component { - constructor(props) { - super(props) - this.state = { hasError: false, error: null } - } - - static getDerivedStateFromError(error) { - return { hasError: true, error } - } - - render() { - if (this.state.hasError) { - return this.props.fallback(this.state.error) - } - return this.props.children - } -} - -SchemaErrorBoundary.propTypes = { - children: PropTypes.node.isRequired, - fallback: PropTypes.func.isRequired, -} - -// Custom AJV instance that fixes "required" error paths for JSONForms. -// AJV outputs required errors pointing to the parent (e.g., "/users/1") with -// params.missingProperty. We transform them to point to the field directly -// (e.g., "/users/1/username") so JSONForms displays them under the correct input. -const ajv = new Ajv({ - useDefaults: true, - allErrors: true, - verbose: true, - jsonPointers: true, -}) -const origCompile = ajv.compile.bind(ajv) -ajv.compile = (schema) => { - const validate = origCompile(schema) - const wrapped = (data) => { - const valid = validate(data) - validate.errors?.forEach((e) => { - if (e.keyword === 'required' && e.params?.missingProperty) { - e.dataPath = `${e.dataPath || ''}/${e.params.missingProperty}` - } - }) - wrapped.errors = validate.errors - return valid - } - wrapped.schema = validate.schema - return wrapped -} - -const useStyles = makeStyles( - (theme) => ({ - root: { - '& .MuiFormControl-root': { - marginBottom: theme.spacing(2), - }, - // Label elements (type: "Label" in UI schema) - make slightly smaller - '& .MuiTypography-h6': { - fontSize: '0.95rem', - }, - // Group/array styling - '& .MuiPaper-root': { - backgroundColor: 'transparent', - }, - // Array items styling - '& .MuiAccordion-root': { - marginBottom: theme.spacing(1), - '&:before': { - display: 'none', - }, - }, - '& .MuiAccordionSummary-root': { - backgroundColor: - theme.palette.type === 'dark' - ? theme.palette.grey[800] - : theme.palette.grey[100], - // Hide expand icon - items are always expanded - '& .MuiAccordionSummary-expandIcon': { - display: 'none', - }, - }, - // Checkbox/switch styling - '& .MuiCheckbox-root, & .MuiSwitch-root': { - color: theme.palette.text.secondary, - }, - '& .Mui-checked': { - color: theme.palette.primary.main, - }, - }, - errorContainer: { - padding: theme.spacing(2), - backgroundColor: - theme.palette.type === 'dark' - ? 'rgba(244, 67, 54, 0.1)' - : 'rgba(244, 67, 54, 0.05)', - borderRadius: theme.shape.borderRadius, - border: `1px solid ${theme.palette.error.main}`, - }, - errorMessage: { - color: theme.palette.error.main, - marginBottom: theme.spacing(1), - }, - errorDetails: { - color: theme.palette.text.secondary, - fontSize: '0.85em', - fontFamily: 'monospace', - whiteSpace: 'pre-wrap', - wordBreak: 'break-word', - }, - }), - { name: 'NDSchemaConfigEditor' }, -) - -// Custom renderers with outlined text inputs and always-expanded array layout -const customRenderers = [ - // Put our custom renderers first (higher priority) - OutlinedTextRenderer, - OutlinedNumberRenderer, - OutlinedEnumRenderer, - OutlinedOneOfEnumRenderer, - // Then all the standard material renderers - ...materialRenderers, -] - -export const SchemaConfigEditor = ({ - schema, - uiSchema, - data, - onChange, - readOnly = false, -}) => { - const classes = useStyles() - const translate = useTranslate() - const containerRef = useRef(null) - - // Disable browser autocomplete on all inputs - useEffect(() => { - if (!containerRef.current) return - - const disableAutocomplete = () => { - const inputs = containerRef.current.querySelectorAll('input') - inputs.forEach((input) => { - input.setAttribute('autocomplete', 'off') - }) - } - - // Run immediately and observe for changes (new inputs added) - disableAutocomplete() - const observer = new MutationObserver(disableAutocomplete) - observer.observe(containerRef.current, { childList: true, subtree: true }) - - return () => observer.disconnect() - }, [data]) - - // Memoize the change handler to extract just the data - const handleChange = useCallback( - ({ data: newData, errors }) => { - if (onChange) { - onChange(newData, errors) - } - }, - [onChange], - ) - - // Use custom renderers with always-expanded array layout - const renderers = useMemo(() => customRenderers, []) - const cells = useMemo(() => materialCells, []) - - // JSONForms config - always show descriptions - const config = { - showUnfocusedDescription: true, - } - - // Ensure schema has required fields for JSONForms - const normalizedSchema = useMemo(() => { - if (!schema) return null - // JSONForms requires type to be set at root level - return { - type: 'object', - ...schema, - } - }, [schema]) - - if (!normalizedSchema) { - return null - } - - const renderError = (error) => ( - <div className={classes.errorContainer}> - <Typography className={classes.errorMessage}> - {translate('resources.plugin.messages.schemaRenderError')} - </Typography> - <Typography className={classes.errorDetails}>{error?.message}</Typography> - </div> - ) - - return ( - <div ref={containerRef} className={classes.root}> - <SchemaErrorBoundary fallback={renderError}> - <JsonForms - schema={normalizedSchema} - uischema={uiSchema} - data={data || {}} - renderers={renderers} - cells={cells} - config={config} - onChange={handleChange} - readonly={readOnly} - ajv={ajv} - validationMode="ValidateAndShow" - /> - </SchemaErrorBoundary> - </div> - ) -} - -SchemaConfigEditor.propTypes = { - schema: PropTypes.object, - uiSchema: PropTypes.object, - data: PropTypes.object, - onChange: PropTypes.func, - readOnly: PropTypes.bool, -} diff --git a/ui/src/plugin/SchemaConfigEditor.test.jsx b/ui/src/plugin/SchemaConfigEditor.test.jsx deleted file mode 100644 index ab93e3ac8..000000000 --- a/ui/src/plugin/SchemaConfigEditor.test.jsx +++ /dev/null @@ -1,86 +0,0 @@ -import React from 'react' -import { describe, it, expect, vi } from 'vitest' -import { render } from '@testing-library/react' -import { ThemeProvider, createTheme } from '@material-ui/core/styles' -import { Provider } from 'react-redux' -import { createStore } from 'redux' -import { SchemaConfigEditor } from './SchemaConfigEditor' - -const theme = createTheme() - -// JSONForms requires Redux -const mockStore = createStore(() => ({})) - -const renderWithProviders = (component) => { - return render( - <Provider store={mockStore}> - <ThemeProvider theme={theme}>{component}</ThemeProvider> - </Provider>, - ) -} - -describe('SchemaConfigEditor', () => { - const basicSchema = { - type: 'object', - properties: { - name: { - type: 'string', - title: 'Name', - }, - enabled: { - type: 'boolean', - title: 'Enabled', - }, - }, - } - - it('renders nothing when schema is null', () => { - const { container } = renderWithProviders( - <SchemaConfigEditor schema={null} data={{}} onChange={vi.fn()} />, - ) - expect(container.firstChild).toBeNull() - }) - - it('renders the component wrapper with valid schema', () => { - const { container } = renderWithProviders( - <SchemaConfigEditor schema={basicSchema} data={{}} onChange={vi.fn()} />, - ) - // Check that the wrapper div is rendered (class name is generated) - expect( - container.querySelector('[class*="NDSchemaConfigEditor-root"]'), - ).toBeTruthy() - }) - - it('calls onChange on initial render', () => { - const onChange = vi.fn() - renderWithProviders( - <SchemaConfigEditor - schema={basicSchema} - data={{ name: 'Test' }} - onChange={onChange} - />, - ) - - // JSONForms calls onChange on initial render with initial state - expect(onChange).toHaveBeenCalled() - }) - - it('passes data and errors to onChange callback', () => { - const onChange = vi.fn() - const initialData = { name: 'Test Value' } - - renderWithProviders( - <SchemaConfigEditor - schema={basicSchema} - data={initialData} - onChange={onChange} - />, - ) - - // Check that onChange was called with data and errors - expect(onChange).toHaveBeenCalledWith( - expect.objectContaining({ name: 'Test Value' }), - expect.any(Array), - ) - }) -}) diff --git a/ui/src/plugin/StatusCard.jsx b/ui/src/plugin/StatusCard.jsx deleted file mode 100644 index 323a4ec10..000000000 --- a/ui/src/plugin/StatusCard.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import { Card, CardContent, Typography } from '@material-ui/core' -import ToggleEnabledSwitch from './ToggleEnabledSwitch' - -export const StatusCard = ({ classes, translate, manifest }) => { - return ( - <Card className={classes.section}> - <CardContent> - <Typography variant="h6" className={classes.sectionTitle}> - {translate('resources.plugin.sections.status')} - </Typography> - <ToggleEnabledSwitch showLabel size="medium" manifest={manifest} /> - </CardContent> - </Card> - ) -} - -StatusCard.propTypes = { - classes: PropTypes.object.isRequired, - translate: PropTypes.func.isRequired, - manifest: PropTypes.object, -} diff --git a/ui/src/plugin/ToggleEnabledSwitch.jsx b/ui/src/plugin/ToggleEnabledSwitch.jsx deleted file mode 100644 index 0b7b4d7d7..000000000 --- a/ui/src/plugin/ToggleEnabledSwitch.jsx +++ /dev/null @@ -1,197 +0,0 @@ -import React, { useCallback, useMemo } from 'react' -import { - useUpdate, - useNotify, - useRefresh, - useRecordContext, - useTranslate, - useResourceContext, -} from 'react-admin' -import Switch from '@material-ui/core/Switch' -import { makeStyles } from '@material-ui/core/styles' -import { Tooltip, FormControlLabel } from '@material-ui/core' -import PropTypes from 'prop-types' - -const useStyles = makeStyles((theme) => ({ - enabledSwitch: { - '& .MuiSwitch-colorSecondary.Mui-checked': { - color: theme.palette.success?.main || theme.palette.primary.main, - }, - '& .MuiSwitch-colorSecondary.Mui-checked + .MuiSwitch-track': { - backgroundColor: - theme.palette.success?.main || theme.palette.primary.main, - }, - }, - errorSwitch: { - '& .MuiSwitch-thumb': { - backgroundColor: theme.palette.warning.main, - }, - '& .MuiSwitch-track': { - backgroundColor: theme.palette.warning.light, - opacity: 0.7, - }, - }, -})) - -/** - * Shared toggle switch for enabling/disabling plugins. - * Used in both PluginList (compact) and PluginShow (with label). - * - * @param {Object} props - * @param {boolean} [props.showLabel=false] - Whether to show the enable/disable label - * @param {string} [props.size='small'] - Switch size ('small' or 'medium') - * @param {Object} [props.manifest=null] - Parsed manifest object for permission checking - */ -const ToggleEnabledSwitch = ({ - showLabel = false, - size = 'small', - manifest = null, -}) => { - const resource = useResourceContext() - const record = useRecordContext() - const notify = useNotify() - const refresh = useRefresh() - const translate = useTranslate() - const classes = useStyles() - - const [toggleEnabled, { loading }] = useUpdate( - resource, - record?.id, - { enabled: !record?.enabled }, - record, - { - undoable: false, - onSuccess: () => { - refresh() - notify( - record?.enabled - ? 'resources.plugin.notifications.disabled' - : 'resources.plugin.notifications.enabled', - 'info', - ) - }, - onFailure: (error) => { - refresh() - notify( - error?.message || 'resources.plugin.notifications.error', - 'warning', - ) - }, - }, - ) - - const handleClick = useCallback( - (e) => { - e.stopPropagation() - toggleEnabled() - }, - [toggleEnabled], - ) - - const hasError = !!record?.lastError - - // Check if users permission is required but not configured - const usersPermissionRequired = useMemo(() => { - if (!manifest?.permissions?.users) return false - if (record?.allUsers) return false - // Check if users array is empty or not set - if (!record?.users) return true - try { - const users = JSON.parse(record.users) - return users.length === 0 - } catch { - return true - } - }, [manifest, record?.allUsers, record?.users]) - - // Check if library permission is required but not configured - const libraryPermissionRequired = useMemo(() => { - if (!manifest?.permissions?.library) return false - if (record?.allLibraries) return false - // Check if libraries array is empty or not set - if (!record?.libraries) return true - try { - const libraries = JSON.parse(record.libraries) - return libraries.length === 0 - } catch { - return true - } - }, [manifest, record?.allLibraries, record?.libraries]) - - const permissionRequired = - usersPermissionRequired || libraryPermissionRequired - const isDisabled = - loading || hasError || (permissionRequired && !record?.enabled) - - const tooltipTitle = useMemo(() => { - if (hasError) { - return translate('resources.plugin.actions.disabledDueToError') - } - if (usersPermissionRequired && !record?.enabled) { - return translate('resources.plugin.actions.disabledUsersRequired') - } - if (libraryPermissionRequired && !record?.enabled) { - return translate('resources.plugin.actions.disabledLibrariesRequired') - } - if (!showLabel) { - return translate( - record?.enabled - ? 'resources.plugin.actions.disable' - : 'resources.plugin.actions.enable', - ) - } - return '' - }, [ - hasError, - usersPermissionRequired, - libraryPermissionRequired, - showLabel, - record?.enabled, - translate, - ]) - - const switchElement = ( - <Switch - checked={record?.enabled ?? false} - onClick={handleClick} - disabled={isDisabled} - className={isDisabled ? classes.errorSwitch : classes.enabledSwitch} - size={size} - color="primary" - /> - ) - - if (showLabel) { - const showTooltip = hasError || (permissionRequired && !record?.enabled) - return ( - <Tooltip - title={tooltipTitle} - disableHoverListener={!showTooltip} - disableFocusListener={!showTooltip} - > - <FormControlLabel - control={switchElement} - label={translate( - record?.enabled - ? 'resources.plugin.actions.disable' - : 'resources.plugin.actions.enable', - )} - /> - </Tooltip> - ) - } - - return ( - <Tooltip title={tooltipTitle}> - <span>{switchElement}</span> - </Tooltip> - ) -} - -ToggleEnabledSwitch.propTypes = { - showLabel: PropTypes.bool, - size: PropTypes.oneOf(['small', 'medium']), - manifest: PropTypes.object, -} - -export default ToggleEnabledSwitch diff --git a/ui/src/plugin/UsersPermissionCard.jsx b/ui/src/plugin/UsersPermissionCard.jsx deleted file mode 100644 index 54a004ce8..000000000 --- a/ui/src/plugin/UsersPermissionCard.jsx +++ /dev/null @@ -1,168 +0,0 @@ -import React from 'react' -import { - Card, - CardContent, - Typography, - Box, - FormControlLabel, - Switch, - List, - ListItem, - ListItemIcon, - ListItemText, - Checkbox, -} from '@material-ui/core' -import CheckBoxIcon from '@material-ui/icons/CheckBox' -import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank' -import Alert from '@material-ui/lab/Alert' -import { useGetList, useTranslate } from 'react-admin' -import PropTypes from 'prop-types' - -export const UsersPermissionCard = ({ - manifest, - classes, - selectedUsers, - allUsers, - onSelectedUsersChange, - onAllUsersChange, -}) => { - const translate = useTranslate() - - // Fetch all users - const { data: usersData, loading: usersLoading } = useGetList('user', { - pagination: { page: 1, perPage: 1000 }, - sort: { field: 'userName', order: 'ASC' }, - }) - - const users = React.useMemo(() => { - return usersData ? Object.values(usersData) : [] - }, [usersData]) - - const handleToggleUser = React.useCallback( - (userId) => { - const newSelected = selectedUsers.includes(userId) - ? selectedUsers.filter((id) => id !== userId) - : [...selectedUsers, userId] - onSelectedUsersChange(newSelected) - }, - [selectedUsers, onSelectedUsersChange], - ) - - const handleAllUsersToggle = React.useCallback( - (event) => { - onAllUsersChange(event.target.checked) - }, - [onAllUsersChange], - ) - - // Get permission reason from manifest - const usersPermission = manifest?.permissions?.users - const reason = usersPermission?.reason - - // Check if permission is required but not configured - const isConfigurationRequired = - usersPermission && !allUsers && selectedUsers.length === 0 - - if (!usersPermission) { - return null - } - - return ( - <Card className={classes.section}> - <CardContent> - <Typography variant="h6" className={classes.sectionTitle}> - {translate('resources.plugin.sections.usersPermission')} - </Typography> - - {reason && ( - <Typography variant="body2" color="textSecondary" gutterBottom> - {translate('resources.plugin.messages.permissionReason')}: {reason} - </Typography> - )} - - {isConfigurationRequired && ( - <Box mb={2}> - <Alert severity="warning"> - {translate('resources.plugin.messages.usersRequired')} - </Alert> - </Box> - )} - - <Box mb={2}> - <FormControlLabel - control={ - <Switch - checked={allUsers} - onChange={handleAllUsersToggle} - color="primary" - /> - } - label={translate('resources.plugin.fields.allUsers')} - /> - <Typography variant="body2" color="textSecondary"> - {translate('resources.plugin.messages.allUsersHelp')} - </Typography> - </Box> - - {!allUsers && ( - <Box className={classes.usersList}> - <Typography variant="subtitle2" gutterBottom> - {translate('resources.plugin.fields.selectedUsers')} - </Typography> - {usersLoading ? ( - <Typography variant="body2" color="textSecondary"> - {translate('ra.message.loading')} - </Typography> - ) : users.length === 0 ? ( - <Typography variant="body2" color="textSecondary"> - {translate('resources.plugin.messages.noUsers')} - </Typography> - ) : ( - <List - dense - style={{ - maxHeight: 200, - overflow: 'auto', - border: '1px solid rgba(0, 0, 0, 0.12)', - borderRadius: 4, - }} - > - {users.map((user) => ( - <ListItem - key={user.id} - button - onClick={() => handleToggleUser(user.id)} - dense - > - <ListItemIcon> - <Checkbox - icon={<CheckBoxOutlineBlankIcon fontSize="small" />} - checkedIcon={<CheckBoxIcon fontSize="small" />} - checked={selectedUsers.includes(user.id)} - tabIndex={-1} - disableRipple - /> - </ListItemIcon> - <ListItemText - primary={user.name || user.userName} - secondary={user.name ? user.userName : null} - /> - </ListItem> - ))} - </List> - )} - </Box> - )} - </CardContent> - </Card> - ) -} - -UsersPermissionCard.propTypes = { - manifest: PropTypes.object, - classes: PropTypes.object.isRequired, - selectedUsers: PropTypes.array.isRequired, - allUsers: PropTypes.bool.isRequired, - onSelectedUsersChange: PropTypes.func.isRequired, - onAllUsersChange: PropTypes.func.isRequired, -} diff --git a/ui/src/plugin/index.js b/ui/src/plugin/index.js deleted file mode 100644 index 2385308cc..000000000 --- a/ui/src/plugin/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import { VscExtensions } from 'react-icons/vsc' -import PluginList from './PluginList' -import PluginShow from './PluginShow' - -export default { - icon: VscExtensions, - list: PluginList, - show: PluginShow, -} diff --git a/ui/src/plugin/jsonValidation.js b/ui/src/plugin/jsonValidation.js deleted file mode 100644 index 408d6dac5..000000000 --- a/ui/src/plugin/jsonValidation.js +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Validates a JSON string and returns validation result - * @param {string} value - The JSON string to validate - * @returns {{ valid: boolean, error: string|null, parsed: object|null }} - */ -export const validateJson = (value) => { - if (!value || value.trim() === '') { - return { valid: true, error: null, parsed: null } - } - - try { - const parsed = JSON.parse(value) - // Ensure config is an object, not an array or primitive - if ( - typeof parsed !== 'object' || - parsed === null || - Array.isArray(parsed) - ) { - return { - valid: false, - error: 'Configuration must be a JSON object', - parsed: null, - } - } - return { valid: true, error: null, parsed } - } catch (e) { - // Try to provide helpful error messages - let error = 'Invalid JSON' - - if (e instanceof SyntaxError) { - const message = e.message - - // Extract position information if available - const positionMatch = message.match(/position (\d+)/) - if (positionMatch) { - const position = parseInt(positionMatch[1], 10) - const lines = value.substring(0, position).split('\n') - const line = lines.length - const column = lines[lines.length - 1].length + 1 - error = `Invalid JSON at line ${line}, column ${column}` - } else if (message.includes('Unexpected end of JSON')) { - error = 'Incomplete JSON - check for missing brackets or quotes' - } else if (message.includes('Unexpected token')) { - error = 'Invalid JSON - unexpected character found' - } - } - - return { valid: false, error, parsed: null } - } -} - -/** - * Formats JSON string with proper indentation - * @param {string} value - The JSON string to format - * @returns {string} - Formatted JSON string or original if invalid - */ -export const formatJson = (value) => { - if (!value || value.trim() === '') { - return value - } - - try { - const parsed = JSON.parse(value) - return JSON.stringify(parsed, null, 2) - } catch { - return value - } -} diff --git a/ui/src/plugin/jsonValidation.test.js b/ui/src/plugin/jsonValidation.test.js deleted file mode 100644 index f56549d79..000000000 --- a/ui/src/plugin/jsonValidation.test.js +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { validateJson, formatJson } from './jsonValidation' - -describe('validateJson', () => { - it('returns valid for empty string', () => { - const result = validateJson('') - expect(result.valid).toBe(true) - expect(result.error).toBeNull() - expect(result.parsed).toBeNull() - }) - - it('returns valid for whitespace only', () => { - const result = validateJson(' ') - expect(result.valid).toBe(true) - expect(result.error).toBeNull() - }) - - it('returns valid for valid JSON object', () => { - const result = validateJson('{"key": "value"}') - expect(result.valid).toBe(true) - expect(result.error).toBeNull() - expect(result.parsed).toEqual({ key: 'value' }) - }) - - it('returns valid for nested JSON object', () => { - const result = validateJson('{"outer": {"inner": 123}}') - expect(result.valid).toBe(true) - expect(result.parsed).toEqual({ outer: { inner: 123 } }) - }) - - it('returns invalid for JSON array', () => { - const result = validateJson('[1, 2, 3]') - expect(result.valid).toBe(false) - expect(result.error).toBe('Configuration must be a JSON object') - }) - - it('returns invalid for JSON primitive string', () => { - const result = validateJson('"hello"') - expect(result.valid).toBe(false) - expect(result.error).toBe('Configuration must be a JSON object') - }) - - it('returns invalid for JSON primitive number', () => { - const result = validateJson('42') - expect(result.valid).toBe(false) - expect(result.error).toBe('Configuration must be a JSON object') - }) - - it('returns invalid for JSON null', () => { - const result = validateJson('null') - expect(result.valid).toBe(false) - expect(result.error).toBe('Configuration must be a JSON object') - }) - - it('returns invalid for malformed JSON', () => { - const result = validateJson('{"key": }') - expect(result.valid).toBe(false) - expect(result.error).toContain('Invalid JSON') - }) - - it('returns invalid for incomplete JSON', () => { - const result = validateJson('{"key": "value"') - expect(result.valid).toBe(false) - expect(result.error).toContain('Invalid JSON') - }) - - it('returns invalid for JSON with trailing comma', () => { - const result = validateJson('{"key": "value",}') - expect(result.valid).toBe(false) - expect(result.error).toContain('Invalid JSON') - }) -}) - -describe('formatJson', () => { - it('returns empty string unchanged', () => { - expect(formatJson('')).toBe('') - }) - - it('returns whitespace unchanged', () => { - expect(formatJson(' ')).toBe(' ') - }) - - it('formats compact JSON with indentation', () => { - const result = formatJson('{"key":"value"}') - expect(result).toBe('{\n "key": "value"\n}') - }) - - it('formats nested JSON with proper indentation', () => { - const result = formatJson('{"outer":{"inner":123}}') - expect(result).toBe('{\n "outer": {\n "inner": 123\n }\n}') - }) - - it('returns invalid JSON unchanged', () => { - const invalid = '{"key": }' - expect(formatJson(invalid)).toBe(invalid) - }) -}) diff --git a/ui/src/plugin/styles.js b/ui/src/plugin/styles.js deleted file mode 100644 index 68c0be929..000000000 --- a/ui/src/plugin/styles.js +++ /dev/null @@ -1,89 +0,0 @@ -import { makeStyles } from '@material-ui/core/styles' - -export const usePluginShowStyles = makeStyles( - (theme) => ({ - root: { - padding: theme.spacing(2), - maxWidth: 900, - }, - section: { - marginBottom: theme.spacing(3), - }, - sectionTitle: { - marginBottom: theme.spacing(1), - fontWeight: 600, - }, - manifestBox: { - backgroundColor: - theme.palette.type === 'dark' - ? theme.palette.grey[900] - : theme.palette.grey[100], - padding: theme.spacing(2), - borderRadius: theme.shape.borderRadius, - fontFamily: 'monospace', - fontSize: '0.85rem', - whiteSpace: 'pre-wrap', - wordBreak: 'break-word', - overflow: 'auto', - maxHeight: 400, - }, - saveButton: { - marginTop: theme.spacing(2), - }, - infoGrid: { - '& .MuiGrid-item': { - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5), - }, - }, - infoLabel: { - fontWeight: 500, - color: theme.palette.text.secondary, - }, - pathField: { - fontFamily: 'monospace', - fontSize: '0.85rem', - wordBreak: 'break-all', - }, - fieldHelp: { - marginTop: theme.spacing(0.5), - display: 'block', - }, - permissionsContainer: { - display: 'flex', - flexWrap: 'wrap', - gap: theme.spacing(0.5), - }, - permissionChip: { - fontSize: '0.75rem', - }, - tooltipContent: { - '& code': { - fontFamily: 'monospace', - fontSize: '0.8em', - backgroundColor: 'rgba(255,255,255,0.1)', - padding: '1px 4px', - borderRadius: 2, - }, - }, - configTable: { - '& .MuiTableCell-root': { - padding: theme.spacing(1), - }, - }, - configTableInput: { - fontFamily: 'monospace', - fontSize: '0.85rem', - }, - configActionIconButton: { - backgroundColor: theme.palette.action.hover, - borderRadius: theme.shape.borderRadius, - padding: theme.spacing(0.5, 1), - fontWeight: 700, - '&:hover': { - backgroundColor: theme.palette.action.selected, - }, - }, - }), - { name: 'NDPluginShow' }, -) diff --git a/ui/src/radio/RadioCreate.jsx b/ui/src/radio/RadioCreate.js similarity index 100% rename from ui/src/radio/RadioCreate.jsx rename to ui/src/radio/RadioCreate.js diff --git a/ui/src/radio/RadioEdit.js b/ui/src/radio/RadioEdit.js new file mode 100644 index 000000000..f00f889f3 --- /dev/null +++ b/ui/src/radio/RadioEdit.js @@ -0,0 +1,44 @@ +import { + DateField, + Edit, + required, + SimpleForm, + TextInput, + useTranslate, +} from 'react-admin' +import { urlValidate } from '../utils/validations' +import { Title } from '../common' + +const RadioTitle = ({ record }) => { + const translate = useTranslate() + const resourceName = translate('resources.radio.name', { + smart_count: 1, + }) + return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} /> +} + +const RadioEdit = (props) => { + return ( + <Edit title={<RadioTitle />} {...props}> + <SimpleForm variant="outlined" {...props}> + <TextInput source="name" validate={[required()]} /> + <TextInput + type="url" + source="streamUrl" + fullWidth + validate={[required(), urlValidate]} + /> + <TextInput + type="url" + source="homePageUrl" + fullWidth + validate={[urlValidate]} + /> + <DateField variant="body1" source="updatedAt" showTime /> + <DateField variant="body1" source="createdAt" showTime /> + </SimpleForm> + </Edit> + ) +} + +export default RadioEdit diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx deleted file mode 100644 index af879deaa..000000000 --- a/ui/src/radio/RadioEdit.jsx +++ /dev/null @@ -1,114 +0,0 @@ -import { - Edit, - required, - SimpleForm, - TextInput, - useTranslate, -} from 'react-admin' -import { CardMedia } from '@material-ui/core' -import { makeStyles } from '@material-ui/core/styles' -import { urlValidate } from '../utils/validations' -import { - Title, - ImageUploadOverlay, - ReadOnlyDateField, - useImageLoadingState, -} from '../common' -import subsonic from '../subsonic' -import config from '../config' -import { RADIO_PLACEHOLDER_IMAGE } from '../consts' - -const useStyles = makeStyles({ - coverParent: { - display: 'inline-flex', - position: 'relative', - width: '8rem', - height: '8rem', - marginBottom: '1em', - }, - cover: { - width: '8rem', - height: '8rem', - objectFit: 'cover', - cursor: 'pointer', - transition: 'opacity 0.3s ease-in-out', - }, - coverLoading: { - opacity: 0.5, - }, - placeholder: { - width: '8rem', - height: '8rem', - objectFit: 'contain', - }, -}) - -const RadioTitle = ({ record }) => { - const translate = useTranslate() - const resourceName = translate('resources.radio.name', { - smart_count: 1, - }) - return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} /> -} - -const RadioEdit = (props) => { - return ( - <Edit title={<RadioTitle />} {...props}> - <SimpleForm variant="outlined" {...props}> - <RadioCoverArt /> - <TextInput source="name" validate={[required()]} /> - <TextInput - type="url" - source="streamUrl" - fullWidth - validate={[required(), urlValidate]} - /> - <TextInput - type="url" - source="homePageUrl" - fullWidth - validate={[urlValidate]} - /> - <ReadOnlyDateField source="updatedAt" /> - <ReadOnlyDateField source="createdAt" /> - </SimpleForm> - </Edit> - ) -} - -const RadioCoverArt = ({ record }) => { - const classes = useStyles() - const { imageLoading, handleImageLoad, handleImageError } = - useImageLoadingState(record?.id) - - if (!record) return null - - return ( - <div className={classes.coverParent}> - {record.uploadedImage ? ( - <CardMedia - component="img" - src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)} - className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} - onLoad={handleImageLoad} - onError={handleImageError} - title={record.name} - alt={record.name} - /> - ) : ( - <img - src={RADIO_PLACEHOLDER_IMAGE} - className={classes.placeholder} - alt={record.name} - /> - )} - <ImageUploadOverlay - entityType="radio" - entityId={record.id} - hasUploadedImage={!!record.uploadedImage} - /> - </div> - ) -} - -export default RadioEdit diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.js similarity index 71% rename from ui/src/radio/RadioList.jsx rename to ui/src/radio/RadioList.js index 0cfb2cd68..551c4c691 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.js @@ -1,4 +1,4 @@ -import { Avatar, makeStyles, useMediaQuery } from '@material-ui/core' +import { makeStyles, useMediaQuery } from '@material-ui/core' import React, { cloneElement } from 'react' import { CreateButton, @@ -6,6 +6,7 @@ import { DateField, EditButton, Filter, + List, sanitizeListRestProps, SearchInput, SimpleList, @@ -14,18 +15,10 @@ import { UrlField, useTranslate, } from 'react-admin' -import { - List, - defaultRowsPerPageOptions, - getStoredPerPage, - ToggleFieldsMenu, - useSelectedFields, -} from '../common' -import { Artwork } from '../common/Artwork' +import { ToggleFieldsMenu, useSelectedFields } from '../common' import { StreamField } from './StreamField' import { setTrack } from '../actions' import { songFromRadio } from './helper' -import { RADIO_PLACEHOLDER_IMAGE } from '../consts' import { useDispatch } from 'react-redux' const useStyles = makeStyles({ @@ -80,38 +73,6 @@ const RadioListActions = ({ ) } -const avatarStyle = { width: 40, height: 40 } - -const useCoverStyles = makeStyles({ - cover: { width: 40, height: 40, borderRadius: '4px' }, -}) - -const CoverArtField = ({ record }) => { - const classes = useCoverStyles() - if (!record) return null - // Radios resolve art only from an uploaded image; otherwise show the generic radio icon. - if (record.uploadedImage) { - return ( - <Artwork - record={record} - size={40} - square - className={classes.cover} - title={record.name} - /> - ) - } - return ( - <Avatar - src={RADIO_PLACEHOLDER_IMAGE} - variant="rounded" - style={avatarStyle} - alt={record.name} - /> - ) -} -CoverArtField.defaultProps = { label: '' } - const RadioList = ({ permissions, ...props }) => { const classes = useStyles() const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) @@ -119,7 +80,6 @@ const RadioList = ({ permissions, ...props }) => { const isAdmin = permissions === 'admin' const toggleableFields = { - coverArt: <CoverArtField source="id" sortable={false} />, name: <TextField source="name" />, homePageUrl: ( <UrlField @@ -137,7 +97,7 @@ const RadioList = ({ permissions, ...props }) => { const columns = useSelectedFields({ resource: 'radio', columns: toggleableFields, - defaultOff: ['streamUrl', 'createdAt'], + defaultOff: ['createdAt'], }) const handleRowClick = async (id, basePath, record) => { @@ -153,15 +113,10 @@ const RadioList = ({ permissions, ...props }) => { hasCreate={isAdmin} actions={<RadioListActions isAdmin={isAdmin} />} filters={<RadioFilter />} - perPage={getStoredPerPage( - 'radio', - defaultRowsPerPageOptions, - isXsmall ? 25 : 10, - )} + perPage={isXsmall ? 25 : 10} > {isXsmall ? ( <SimpleList - leftAvatar={(r) => <CoverArtField record={r} />} leftIcon={(r) => ( <StreamField record={r} diff --git a/ui/src/radio/StreamField.jsx b/ui/src/radio/StreamField.js similarity index 97% rename from ui/src/radio/StreamField.jsx rename to ui/src/radio/StreamField.js index 2327f3c1a..302a7f199 100644 --- a/ui/src/radio/StreamField.jsx +++ b/ui/src/radio/StreamField.js @@ -26,7 +26,7 @@ export const StreamField = (props) => { evt.preventDefault() dispatch(setTrack(await songFromRadio(record))) }, - [dispatch, record], + [dispatch, record] ) return ( diff --git a/ui/src/radio/helper.js b/ui/src/radio/helper.js new file mode 100644 index 000000000..5fc4098f1 --- /dev/null +++ b/ui/src/radio/helper.js @@ -0,0 +1,35 @@ +export async function songFromRadio(radio) { + if (!radio) { + return undefined + } + + let cover = 'internet-radio-icon.svg' + try { + const url = new URL(radio.homePageUrl ?? radio.streamUrl) + url.pathname = '/favicon.ico' + await resourceExists(url) + cover = url.toString() + } catch {} + + return { + ...radio, + title: radio.name, + album: radio.homePageUrl || radio.name, + artist: radio.name, + cover, + isRadio: true, + } +} + +const resourceExists = (url) => { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = function () { + resolve(url) + } + img.onerror = function () { + reject('not found') + } + img.src = url + }) +} diff --git a/ui/src/radio/helper.jsx b/ui/src/radio/helper.jsx deleted file mode 100644 index b451dae9a..000000000 --- a/ui/src/radio/helper.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import subsonic from '../subsonic' -import config from '../config' -import { RADIO_PLACEHOLDER_IMAGE } from '../consts' - -export async function songFromRadio(radio) { - if (!radio) { - return undefined - } - - let cover = RADIO_PLACEHOLDER_IMAGE - if (radio.uploadedImage) { - cover = subsonic.getCoverArtUrl(radio, config.uiCoverArtSize, true) - } else { - // Try favicon as fallback - try { - const url = new URL(radio.homePageUrl ?? radio.streamUrl) - url.pathname = '/favicon.ico' - await resourceExists(url) - cover = url.toString() - } catch { - // No cover available - } - } - - return { - ...radio, - title: radio.name, - album: radio.homePageUrl || radio.name, - artist: radio.name, - cover, - isRadio: true, - } -} - -const resourceExists = (url) => { - return new Promise((resolve, reject) => { - const img = new Image() - img.onload = function () { - resolve(url) - } - img.onerror = function () { - reject('not found') - } - img.src = url - }) -} diff --git a/ui/src/radio/index.jsx b/ui/src/radio/index.js similarity index 100% rename from ui/src/radio/index.jsx rename to ui/src/radio/index.js diff --git a/ui/src/reducers/activityReducer.js b/ui/src/reducers/activityReducer.js index d61ed6018..d2a7d310b 100644 --- a/ui/src/reducers/activityReducer.js +++ b/ui/src/reducers/activityReducer.js @@ -2,34 +2,19 @@ import { EVENT_REFRESH_RESOURCE, EVENT_SCAN_STATUS, EVENT_SERVER_START, - EVENT_NOW_PLAYING_COUNT, - EVENT_NOW_PLAYING_COUNT_SYNC, - EVENT_STREAM_RECONNECTED, } from '../actions' import config from '../config' const initialState = { - scanStatus: { - scanning: false, - folderCount: 0, - count: 0, - error: '', - elapsedTime: 0, - }, + scanStatus: { scanning: false, folderCount: 0, count: 0 }, serverStart: { version: config.version }, - nowPlayingCount: 0, - nowPlayingLastUpdate: 0, - streamReconnected: 0, // Timestamp of last reconnection } export const activityReducer = (previousState = initialState, payload) => { const { type, data } = payload - switch (type) { - case EVENT_SCAN_STATUS: { - const elapsedTime = Number(data.elapsedTime) || 0 - return { ...previousState, scanStatus: { ...data, elapsedTime } } - } + case EVENT_SCAN_STATUS: + return { ...previousState, scanStatus: data } case EVENT_SERVER_START: return { ...previousState, @@ -46,16 +31,6 @@ export const activityReducer = (previousState = initialState, payload) => { resources: data, }, } - 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() } default: return previousState } diff --git a/ui/src/reducers/activityReducer.test.js b/ui/src/reducers/activityReducer.test.js deleted file mode 100644 index e52a262c1..000000000 --- a/ui/src/reducers/activityReducer.test.js +++ /dev/null @@ -1,165 +0,0 @@ -import { activityReducer } from './activityReducer' -import { - EVENT_SCAN_STATUS, - EVENT_SERVER_START, - EVENT_NOW_PLAYING_COUNT, - EVENT_STREAM_RECONNECTED, -} from '../actions' -import config from '../config' - -describe('activityReducer', () => { - const initialState = { - scanStatus: { - scanning: false, - folderCount: 0, - count: 0, - error: '', - elapsedTime: 0, - }, - serverStart: { version: config.version }, - nowPlayingCount: 0, - nowPlayingLastUpdate: 0, - streamReconnected: 0, - } - - it('returns the initial state when no action is specified', () => { - expect(activityReducer(undefined, {})).toEqual(initialState) - }) - - it('handles EVENT_SCAN_STATUS action with elapsedTime field', () => { - const elapsedTime = 123456789 // nanoseconds - const action = { - type: EVENT_SCAN_STATUS, - data: { - scanning: true, - folderCount: 5, - count: 100, - error: '', - elapsedTime: elapsedTime, - }, - } - - const newState = activityReducer(initialState, action) - expect(newState.scanStatus).toEqual({ - scanning: true, - folderCount: 5, - count: 100, - error: '', - elapsedTime: elapsedTime, - }) - }) - - it('handles EVENT_SCAN_STATUS action with string elapsedTime', () => { - const action = { - type: EVENT_SCAN_STATUS, - data: { - scanning: true, - folderCount: 5, - count: 100, - error: '', - elapsedTime: '123456789', - }, - } - - const newState = activityReducer(initialState, action) - expect(newState.scanStatus.elapsedTime).toEqual(123456789) - }) - - it('handles EVENT_SCAN_STATUS with error field', () => { - const action = { - type: EVENT_SCAN_STATUS, - data: { - scanning: false, - folderCount: 0, - count: 0, - error: 'Test error message', - elapsedTime: 0, - }, - } - - const newState = activityReducer(initialState, action) - expect(newState.scanStatus.error).toEqual('Test error message') - }) - - it('handles EVENT_SERVER_START action', () => { - const action = { - type: EVENT_SERVER_START, - data: { - version: '1.0.0', - startTime: '2023-01-01T00:00:00Z', - }, - } - - const newState = activityReducer(initialState, action) - expect(newState.serverStart).toEqual({ - version: '1.0.0', - startTime: Date.parse('2023-01-01T00:00:00Z'), - }) - }) - - it('preserves the scanStatus when handling EVENT_SERVER_START', () => { - const currentState = { - scanStatus: { - scanning: true, - folderCount: 5, - count: 100, - error: 'Previous error', - elapsedTime: 12345, - }, - serverStart: { version: config.version }, - } - - const action = { - type: EVENT_SERVER_START, - data: { - version: '1.0.0', - startTime: '2023-01-01T00:00:00Z', - }, - } - - const newState = activityReducer(currentState, action) - expect(newState.scanStatus).toEqual(currentState.scanStatus) - expect(newState.serverStart).toEqual({ - version: '1.0.0', - startTime: Date.parse('2023-01-01T00:00:00Z'), - }) - }) - - it('handles EVENT_NOW_PLAYING_COUNT', () => { - const action = { - type: EVENT_NOW_PLAYING_COUNT, - data: { count: 5 }, - } - const newState = activityReducer(initialState, action) - 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, - data: {}, - } - const beforeTimestamp = Date.now() - const newState = activityReducer(initialState, action) - const afterTimestamp = Date.now() - - expect(newState.streamReconnected).toBeGreaterThanOrEqual(beforeTimestamp) - expect(newState.streamReconnected).toBeLessThanOrEqual(afterTimestamp) - }) -}) diff --git a/ui/src/reducers/albumView.js b/ui/src/reducers/albumView.js index ef6833471..ec47a1d5e 100644 --- a/ui/src/reducers/albumView.js +++ b/ui/src/reducers/albumView.js @@ -4,7 +4,7 @@ export const albumViewReducer = ( previousState = { grid: true, }, - payload, + payload ) => { const { type } = payload switch (type) { diff --git a/ui/src/reducers/dialogReducer.js b/ui/src/reducers/dialogReducer.js index e1a77f100..f80cb7350 100644 --- a/ui/src/reducers/dialogReducer.js +++ b/ui/src/reducers/dialogReducer.js @@ -13,8 +13,6 @@ import { EXTENDED_INFO_CLOSE, LISTENBRAINZ_TOKEN_OPEN, LISTENBRAINZ_TOKEN_CLOSE, - SAVE_QUEUE_OPEN, - SAVE_QUEUE_CLOSE, SHARE_MENU_OPEN, SHARE_MENU_CLOSE, } from '../actions' @@ -26,7 +24,7 @@ export const shareDialogReducer = ( resource: '', name: '', }, - payload, + payload ) => { const { type, ids, resource, name, label } = payload switch (type) { @@ -54,7 +52,7 @@ export const addToPlaylistDialogReducer = ( open: false, duplicateSong: false, }, - payload, + payload ) => { const { type } = payload switch (type) { @@ -84,7 +82,7 @@ export const downloadMenuDialogReducer = ( previousState = { open: false, }, - payload, + payload ) => { const { type } = payload switch (type) { @@ -126,9 +124,8 @@ export const downloadMenuDialogReducer = ( export const expandInfoDialogReducer = ( previousState = { open: false, - record: undefined, }, - payload, + payload ) => { const { type } = payload switch (type) { @@ -142,7 +139,6 @@ export const expandInfoDialogReducer = ( return { ...previousState, open: false, - record: undefined, } default: return previousState @@ -153,7 +149,7 @@ export const listenBrainzTokenDialogReducer = ( previousState = { open: false, }, - payload, + payload ) => { const { type } = payload switch (type) { @@ -171,18 +167,3 @@ export const listenBrainzTokenDialogReducer = ( return previousState } } - -export const saveQueueDialogReducer = ( - previousState = { open: false }, - payload, -) => { - const { type } = payload - switch (type) { - case SAVE_QUEUE_OPEN: - return { ...previousState, open: true } - case SAVE_QUEUE_CLOSE: - return { ...previousState, open: false } - default: - return previousState - } -} diff --git a/ui/src/reducers/index.js b/ui/src/reducers/index.js index 64a0049b7..b9414c864 100644 --- a/ui/src/reducers/index.js +++ b/ui/src/reducers/index.js @@ -1,4 +1,3 @@ -export * from './libraryReducer' export * from './themeReducer' export * from './dialogReducer' export * from './playerReducer' @@ -6,4 +5,3 @@ export * from './albumView' export * from './activityReducer' export * from './settingsReducer' export * from './replayGainReducer' -export * from './transcodingReducer' diff --git a/ui/src/reducers/libraryReducer.js b/ui/src/reducers/libraryReducer.js deleted file mode 100644 index ef613260f..000000000 --- a/ui/src/reducers/libraryReducer.js +++ /dev/null @@ -1,52 +0,0 @@ -import { SET_SELECTED_LIBRARIES, SET_USER_LIBRARIES } from '../actions' - -const initialState = { - userLibraries: [], - selectedLibraries: [], // Empty means "all accessible libraries" -} - -export const libraryReducer = (previousState = initialState, payload) => { - const { type, data } = payload - switch (type) { - case SET_USER_LIBRARIES: { - const newUserLibraryIds = data.map((lib) => lib.id) - - // Validate and filter selected libraries to only include IDs that exist in new user libraries - const validatedSelection = previousState.selectedLibraries.filter((id) => - newUserLibraryIds.includes(id), - ) - - // Determine the final selection: - // 1. If first time setting libraries (no previous user libraries), select all - // 2. If user now has only one library, reset to empty (no filter needed) - // 3. Otherwise, use validated selection (may be empty if all previous selections were invalid) - let finalSelection - if ( - previousState.selectedLibraries.length === 0 && - previousState.userLibraries.length === 0 - ) { - // First time: select all libraries - finalSelection = newUserLibraryIds - } else if (newUserLibraryIds.length === 1) { - // Single library: reset selection (empty means "all accessible") - finalSelection = [] - } else { - // Multiple libraries: use validated selection - finalSelection = validatedSelection - } - - return { - ...previousState, - userLibraries: data, - selectedLibraries: finalSelection, - } - } - case SET_SELECTED_LIBRARIES: - return { - ...previousState, - selectedLibraries: data, - } - default: - return previousState - } -} diff --git a/ui/src/reducers/libraryReducer.test.js b/ui/src/reducers/libraryReducer.test.js deleted file mode 100644 index b962c1036..000000000 --- a/ui/src/reducers/libraryReducer.test.js +++ /dev/null @@ -1,186 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { libraryReducer } from './libraryReducer' -import { SET_SELECTED_LIBRARIES, SET_USER_LIBRARIES } from '../actions' - -describe('libraryReducer', () => { - const mockLibraries = [ - { id: '1', name: 'Music Library' }, - { id: '2', name: 'Podcasts' }, - { id: '3', name: 'Audiobooks' }, - ] - - const initialState = { - userLibraries: [], - selectedLibraries: [], - } - - describe('SET_USER_LIBRARIES', () => { - it('should set user libraries and select all on first load', () => { - const action = { - type: SET_USER_LIBRARIES, - data: mockLibraries, - } - - const result = libraryReducer(initialState, action) - - expect(result.userLibraries).toEqual(mockLibraries) - expect(result.selectedLibraries).toEqual(['1', '2', '3']) - }) - - it('should reset selection to empty when user has only one library', () => { - const previousState = { - userLibraries: mockLibraries, - selectedLibraries: ['1', '2'], - } - - const action = { - type: SET_USER_LIBRARIES, - data: [mockLibraries[0]], // Only one library now - } - - const result = libraryReducer(previousState, action) - - expect(result.userLibraries).toEqual([mockLibraries[0]]) - expect(result.selectedLibraries).toEqual([]) // Reset for single library - }) - - it('should filter out invalid library IDs from selection', () => { - const previousState = { - userLibraries: mockLibraries, - selectedLibraries: ['1', '2', '3'], - } - - const action = { - type: SET_USER_LIBRARIES, - data: [mockLibraries[0], mockLibraries[1]], // Only libraries 1 and 2 remain - } - - const result = libraryReducer(previousState, action) - - expect(result.userLibraries).toEqual([mockLibraries[0], mockLibraries[1]]) - expect(result.selectedLibraries).toEqual(['1', '2']) // Library 3 removed - }) - - it('should keep valid selection when libraries change', () => { - const previousState = { - userLibraries: mockLibraries, - selectedLibraries: ['1'], - } - - const action = { - type: SET_USER_LIBRARIES, - data: mockLibraries, // Same libraries - } - - const result = libraryReducer(previousState, action) - - expect(result.userLibraries).toEqual(mockLibraries) - expect(result.selectedLibraries).toEqual(['1']) // Selection preserved - }) - - it('should handle selection becoming empty after filtering invalid IDs', () => { - const previousState = { - userLibraries: mockLibraries, - selectedLibraries: ['1', '2'], - } - - const newLibraries = [{ id: '4', name: 'New Library' }] - const action = { - type: SET_USER_LIBRARIES, - data: newLibraries, - } - - const result = libraryReducer(previousState, action) - - expect(result.userLibraries).toEqual(newLibraries) - expect(result.selectedLibraries).toEqual([]) // All selected IDs were invalid - }) - - it('should handle transition from multiple to single library with invalid selection', () => { - const previousState = { - userLibraries: mockLibraries, - selectedLibraries: ['2', '3'], // User had libraries 2 and 3 selected - } - - const action = { - type: SET_USER_LIBRARIES, - data: [mockLibraries[0]], // Now only has access to library 1 - } - - const result = libraryReducer(previousState, action) - - expect(result.userLibraries).toEqual([mockLibraries[0]]) - expect(result.selectedLibraries).toEqual([]) // Reset for single library - }) - - it('should handle empty library list', () => { - const previousState = { - userLibraries: mockLibraries, - selectedLibraries: ['1', '2'], - } - - const action = { - type: SET_USER_LIBRARIES, - data: [], - } - - const result = libraryReducer(previousState, action) - - expect(result.userLibraries).toEqual([]) - expect(result.selectedLibraries).toEqual([]) // All selections filtered out - }) - }) - - describe('SET_SELECTED_LIBRARIES', () => { - it('should update selected libraries', () => { - const previousState = { - userLibraries: mockLibraries, - selectedLibraries: ['1'], - } - - const action = { - type: SET_SELECTED_LIBRARIES, - data: ['2', '3'], - } - - const result = libraryReducer(previousState, action) - - expect(result.selectedLibraries).toEqual(['2', '3']) - expect(result.userLibraries).toEqual(mockLibraries) // Unchanged - }) - - it('should allow setting empty selection', () => { - const previousState = { - userLibraries: mockLibraries, - selectedLibraries: ['1', '2'], - } - - const action = { - type: SET_SELECTED_LIBRARIES, - data: [], - } - - const result = libraryReducer(previousState, action) - - expect(result.selectedLibraries).toEqual([]) - }) - }) - - describe('unknown action', () => { - it('should return previous state for unknown action', () => { - const previousState = { - userLibraries: mockLibraries, - selectedLibraries: ['1'], - } - - const action = { - type: 'UNKNOWN_ACTION', - data: null, - } - - const result = libraryReducer(previousState, action) - - expect(result).toBe(previousState) // Same reference - }) - }) -}) diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index d6ab7484b..be8259f26 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -1,6 +1,5 @@ import { v4 as uuidv4 } from 'uuid' import subsonic from '../subsonic' -import { decisionService } from '../transcode' import { PLAYER_ADD_TRACKS, PLAYER_CLEAR_QUEUE, @@ -10,8 +9,6 @@ import { PLAYER_SET_TRACK, PLAYER_SET_VOLUME, PLAYER_SYNC_QUEUE, - PLAYER_SET_MODE, - PLAYER_REFRESH_QUEUE, } from '../actions' import config from '../config' @@ -23,22 +20,8 @@ const initialState = { savedPlayIndex: 0, } -const pad = (value) => { - const str = value.toString() - if (str.length === 1) { - return `0${str}` - } else { - return str - } -} - -const makeMusicSrc = (trackId) => - decisionService.getProfile() - ? () => - decisionService - .resolveStreamUrl(trackId) - .catch(() => subsonic.streamUrl(trackId)) - : subsonic.streamUrl(trackId) +const timestampRegex = + /(\[([0-9]{1,2}:)?([0-9]{1,2}:)([0-9]{1,2})(\.[0-9]{1,2})?\])/g const mapToAudioLists = (item) => { // If item comes from a playlist, trackId is mediaFileId @@ -57,43 +40,22 @@ const mapToAudioLists = (item) => { } const { lyrics } = item - let lyricText = '' - - if (lyrics) { - const structured = JSON.parse(lyrics) - for (const structuredLyric of structured) { - if (structuredLyric.synced) { - for (const line of structuredLyric.line) { - let time = Math.floor(line.start / 10) - const ms = time % 100 - time = Math.floor(time / 100) - const sec = time % 60 - time = Math.floor(time / 60) - const min = time % 60 - - ms.toString() - lyricText += `[${pad(min)}:${pad(sec)}.${pad(ms)}] ${line.value}\n` - } - } - } - } - return { trackId, uuid: uuidv4(), song: item, name: item.title, - lyric: lyricText, + lyric: timestampRegex.test(lyrics) ? lyrics : '', singer: item.artist, duration: item.duration, - musicSrc: makeMusicSrc(trackId), + musicSrc: subsonic.streamUrl(trackId), cover: subsonic.getCoverArtUrl( { id: trackId, updatedAt: item.updatedAt, album: item.album, }, - 300, + 300 ), } } @@ -134,7 +96,6 @@ const reduceAddTracks = (state, { data }) => { } const reducePlayNext = (state, { data }) => { - const newTracks = Object.keys(data).map((id) => mapToAudioLists(data[id])) const newQueue = [] const current = state.current || {} let foundPos = false @@ -142,11 +103,15 @@ const reducePlayNext = (state, { data }) => { newQueue.push(item) if (item.uuid === current.uuid) { foundPos = true - newQueue.push(...newTracks) + Object.keys(data).forEach((id) => { + newQueue.push(mapToAudioLists(data[id])) + }) } }) if (!foundPos) { - newQueue.push(...newTracks) + Object.keys(data).forEach((id) => { + newQueue.push(mapToAudioLists(data[id])) + }) } return { @@ -164,50 +129,28 @@ const reduceSetVolume = (state, { data: { volume } }) => { } const reduceSyncQueue = (state, { data: { audioInfo, audioLists } }) => { - // 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.clear || state.playIndex !== state.savedPlayIndex) return { ...state, queue: audioLists, - clear: hasPendingSwitch ? state.clear : false, - playIndex: hasPendingSwitch ? state.playIndex : undefined, + clear: false, + playIndex: undefined, } } const reduceCurrent = (state, { data }) => { const current = data.ended ? {} : data const savedPlayIndex = state.queue.findIndex( - (item) => item.uuid === current.uuid, + (item) => item.uuid === current.uuid ) - // When a track selection is pending (playIndex is set), keep it alive - // until the music player confirms it actually switched to the requested - // track. Without this, a premature onAudioPlay callback for the - // still-playing old track would overwrite the pending selection. - const pending = state.playIndex != null && savedPlayIndex !== state.playIndex return { ...state, current, - playIndex: pending ? state.playIndex : undefined, - clear: pending ? state.clear : false, - savedPlayIndex: pending ? state.savedPlayIndex : savedPlayIndex, + playIndex: undefined, + savedPlayIndex, volume: data.volume, } } -const reduceMode = (state, { data: { mode } }) => { - return { - ...state, - mode, - } -} - export const playerReducer = (previousState = initialState, payload) => { const { type } = payload switch (type) { @@ -227,24 +170,6 @@ export const playerReducer = (previousState = initialState, payload) => { return reduceSyncQueue(previousState, payload) case PLAYER_CURRENT: return reduceCurrent(previousState, payload) - case PLAYER_SET_MODE: - return reduceMode(previousState, payload) - case PLAYER_REFRESH_QUEUE: { - const resolvedUrls = payload.data || {} - return { - ...previousState, - queue: previousState.queue.map((item) => ({ - ...item, - musicSrc: item.isRadio - ? item.musicSrc - : resolvedUrls[item.trackId] || subsonic.streamUrl(item.trackId), - })), - clear: true, - autoPlay: false, - playIndex: - previousState.savedPlayIndex >= 0 ? previousState.savedPlayIndex : 0, - } - } default: return previousState } diff --git a/ui/src/reducers/playerReducer.test.js b/ui/src/reducers/playerReducer.test.js deleted file mode 100644 index 110ce8c53..000000000 --- a/ui/src/reducers/playerReducer.test.js +++ /dev/null @@ -1,227 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { playerReducer } from './playerReducer' -import { - PLAYER_SYNC_QUEUE, - PLAYER_CURRENT, - PLAYER_REFRESH_QUEUE, -} from '../actions' - -describe('playerReducer', () => { - describe('pending track selection survives SYNC_QUEUE and premature CURRENT', () => { - // Simulates the real sequence when clicking a new song while one is playing: - // 1. PLAYER_PLAY_TRACKS sets playIndex and clear - // 2. PLAYER_SYNC_QUEUE fires when music player syncs its internal queue - // 3. PLAYER_CURRENT fires for the OLD still-playing track - // 4. PLAYER_CURRENT fires for the NEW track (player switched) - const stateAfterPlayTracks = { - queue: [ - { trackId: 's1', uuid: 'aaa', name: 'Song 1' }, - { trackId: 's2', uuid: 'bbb', name: 'Song 2' }, - { trackId: 's3', uuid: 'ccc', name: 'Song 3' }, - ], - current: { uuid: 'ccc', name: 'Song 3' }, - playIndex: 0, // user clicked Song 1 - savedPlayIndex: 2, // Song 3 was playing - clear: true, - volume: 1, - } - - it('SYNC_QUEUE preserves pending playIndex and clear', () => { - const newQueue = [ - { trackId: 's1', uuid: 'xxx', name: 'Song 1' }, - { trackId: 's2', uuid: 'yyy', name: 'Song 2' }, - { trackId: 's3', uuid: 'zzz', name: 'Song 3' }, - ] - const action = { - type: PLAYER_SYNC_QUEUE, - data: { audioInfo: {}, audioLists: newQueue }, - } - const result = playerReducer(stateAfterPlayTracks, action) - expect(result.playIndex).toBe(0) - expect(result.clear).toBe(true) - expect(result.queue).toBe(newQueue) - }) - - it('SYNC_QUEUE clears playIndex when no pending selection', () => { - const stateNoPending = { ...stateAfterPlayTracks, playIndex: undefined } - const action = { - type: PLAYER_SYNC_QUEUE, - data: { audioInfo: {}, audioLists: stateNoPending.queue }, - } - const result = playerReducer(stateNoPending, action) - expect(result.playIndex).toBeUndefined() - expect(result.clear).toBe(false) - }) - - it('CURRENT for old track preserves pending playIndex', () => { - // After SYNC_QUEUE, queue has new UUIDs. The old track's UUID (zzz) - // is at index 2, but playIndex is 0. This is a premature callback. - const stateAfterSync = { - ...stateAfterPlayTracks, - queue: [ - { trackId: 's1', uuid: 'xxx', name: 'Song 1' }, - { trackId: 's2', uuid: 'yyy', name: 'Song 2' }, - { trackId: 's3', uuid: 'zzz', name: 'Song 3' }, - ], - } - const action = { - type: PLAYER_CURRENT, - data: { uuid: 'zzz', name: 'Song 3', volume: 1 }, - } - const result = playerReducer(stateAfterSync, action) - expect(result.playIndex).toBe(0) - expect(result.clear).toBe(true) - expect(result.savedPlayIndex).toBe(2) // preserved from before - }) - - it('CURRENT for correct track consumes pending playIndex', () => { - const stateAfterSync = { - ...stateAfterPlayTracks, - queue: [ - { trackId: 's1', uuid: 'xxx', name: 'Song 1' }, - { trackId: 's2', uuid: 'yyy', name: 'Song 2' }, - { trackId: 's3', uuid: 'zzz', name: 'Song 3' }, - ], - } - // Player switched to Song 1 (uuid 'xxx', index 0 == playIndex) - const action = { - type: PLAYER_CURRENT, - data: { uuid: 'xxx', name: 'Song 1', volume: 1 }, - } - const result = playerReducer(stateAfterSync, action) - expect(result.playIndex).toBeUndefined() - expect(result.clear).toBe(false) - expect(result.savedPlayIndex).toBe(0) - expect(result.current.name).toBe('Song 1') - }) - }) - - 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 = { - queue: [ - { trackId: 'song-1', musicSrc: 'old-url', uuid: 'a' }, - { trackId: 'song-2', musicSrc: 'old-url', uuid: 'b' }, - ], - savedPlayIndex: -1, - current: {}, - clear: false, - volume: 1, - } - const action = { type: PLAYER_REFRESH_QUEUE, data: {} } - const result = playerReducer(state, action) - expect(result.playIndex).toBe(0) - }) - - it('preserves valid savedPlayIndex', () => { - const state = { - queue: [ - { trackId: 'song-1', musicSrc: 'old-url', uuid: 'a' }, - { trackId: 'song-2', musicSrc: 'old-url', uuid: 'b' }, - ], - savedPlayIndex: 1, - current: {}, - clear: false, - volume: 1, - } - const action = { type: PLAYER_REFRESH_QUEUE, data: {} } - const result = playerReducer(state, action) - expect(result.playIndex).toBe(1) - }) - - it('uses savedPlayIndex of 0 correctly', () => { - const state = { - queue: [{ trackId: 'song-1', musicSrc: 'old-url', uuid: 'a' }], - savedPlayIndex: 0, - current: {}, - clear: false, - volume: 1, - } - const action = { type: PLAYER_REFRESH_QUEUE, data: {} } - const result = playerReducer(state, action) - expect(result.playIndex).toBe(0) - }) - }) -}) diff --git a/ui/src/reducers/replayGainReducer.js b/ui/src/reducers/replayGainReducer.js index 41b087a85..49d9e0ba0 100644 --- a/ui/src/reducers/replayGainReducer.js +++ b/ui/src/reducers/replayGainReducer.js @@ -1,10 +1,7 @@ import { CHANGE_GAIN, CHANGE_PREAMP } from '../actions' -const GAIN_KEY = 'gainMode' -const PREAMP_KEY = 'preAmp' - const getPreAmp = () => { - const storage = localStorage.getItem(PREAMP_KEY) + const storage = localStorage.getItem('preamp') if (storage === null) { return 0 @@ -15,17 +12,17 @@ const getPreAmp = () => { } const initialState = { - gainMode: localStorage.getItem(GAIN_KEY) || 'none', + gainMode: localStorage.getItem('gainMode') || 'none', preAmp: getPreAmp(), } export const replayGainReducer = ( previousState = initialState, - { type, payload }, + { type, payload } ) => { switch (type) { case CHANGE_GAIN: { - localStorage.setItem(GAIN_KEY, payload) + localStorage.setItem('gainMode', payload) return { ...previousState, gainMode: payload, @@ -36,7 +33,7 @@ export const replayGainReducer = ( if (isNaN(value)) { return previousState } - localStorage.setItem(PREAMP_KEY, payload) + localStorage.setItem('preAmp', payload) return { ...previousState, preAmp: value, diff --git a/ui/src/reducers/settingsReducer.js b/ui/src/reducers/settingsReducer.js index 3f8278ea4..0e598c22d 100644 --- a/ui/src/reducers/settingsReducer.js +++ b/ui/src/reducers/settingsReducer.js @@ -1,7 +1,6 @@ import { SET_NOTIFICATIONS_STATE, SET_OMITTED_FIELDS, - SET_SIDEBAR_PLAYLISTS_FAVOURITES, SET_TOGGLEABLE_FIELDS, } from '../actions' @@ -9,7 +8,6 @@ const initialState = { notifications: false, toggleableFields: {}, omittedFields: {}, - sidebarPlaylistsOnlyFavourites: false, } export const settingsReducer = (previousState = initialState, payload) => { @@ -36,11 +34,6 @@ export const settingsReducer = (previousState = initialState, payload) => { ...data, }, } - case SET_SIDEBAR_PLAYLISTS_FAVOURITES: - return { - ...previousState, - sidebarPlaylistsOnlyFavourites: data, - } default: return previousState } diff --git a/ui/src/reducers/settingsReducer.test.js b/ui/src/reducers/settingsReducer.test.js deleted file mode 100644 index 7ad68b291..000000000 --- a/ui/src/reducers/settingsReducer.test.js +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { settingsReducer } from './settingsReducer' -import { - SET_SIDEBAR_PLAYLISTS_FAVOURITES, - setSidebarPlaylistsOnlyFavourites, -} from '../actions' - -describe('settingsReducer', () => { - it('defaults sidebarPlaylistsOnlyFavourites to false', () => { - const state = settingsReducer(undefined, { type: 'UNKNOWN' }) - expect(state.sidebarPlaylistsOnlyFavourites).toBe(false) - }) - - it('enables the flag via the action creator', () => { - const state = settingsReducer( - undefined, - setSidebarPlaylistsOnlyFavourites(true), - ) - expect(state.sidebarPlaylistsOnlyFavourites).toBe(true) - }) - - it('disables the flag and preserves other settings', () => { - const initial = settingsReducer(undefined, { type: 'UNKNOWN' }) - const on = settingsReducer(initial, { - type: SET_SIDEBAR_PLAYLISTS_FAVOURITES, - data: true, - }) - const off = settingsReducer(on, { - type: SET_SIDEBAR_PLAYLISTS_FAVOURITES, - data: false, - }) - expect(off.sidebarPlaylistsOnlyFavourites).toBe(false) - expect(off.notifications).toEqual(initial.notifications) - expect(off.toggleableFields).toEqual(initial.toggleableFields) - }) -}) diff --git a/ui/src/reducers/themeReducer.js b/ui/src/reducers/themeReducer.js index 16d5fa87b..ef5ccc7ce 100644 --- a/ui/src/reducers/themeReducer.js +++ b/ui/src/reducers/themeReducer.js @@ -1,22 +1,18 @@ import { CHANGE_THEME } from '../actions' -import { AUTO_THEME_ID, AUTO_THEME_CONFIG_VALUE } from '../consts' import config from '../config' import themes from '../themes' const defaultTheme = () => { - if (config.defaultTheme === AUTO_THEME_CONFIG_VALUE) { - return AUTO_THEME_ID - } return ( Object.keys(themes).find( - (t) => themes[t].themeName === config.defaultTheme, + (t) => themes[t].themeName === config.defaultTheme ) || 'DarkTheme' ) } export const themeReducer = ( previousState = defaultTheme(), - { type, payload }, + { type, payload } ) => { if (type === CHANGE_THEME) { return payload diff --git a/ui/src/reducers/themeReducer.test.js b/ui/src/reducers/themeReducer.test.js deleted file mode 100644 index 2a66ea851..000000000 --- a/ui/src/reducers/themeReducer.test.js +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { AUTO_THEME_ID, AUTO_THEME_CONFIG_VALUE } from '../consts' - -describe('themeReducer', () => { - beforeEach(() => { - vi.resetModules() - }) - - it.each([ - { - configTheme: AUTO_THEME_CONFIG_VALUE, - expected: AUTO_THEME_ID, - description: 'is "Auto"', - }, - { configTheme: 'Dark', expected: 'DarkTheme', description: 'is "Dark"' }, - { - configTheme: 'NonExistent', - expected: 'DarkTheme', - description: 'is unrecognized', - }, - ])( - 'returns $expected when defaultTheme config $description', - async ({ configTheme, expected }) => { - vi.doMock('../config', () => ({ - default: { defaultTheme: configTheme }, - })) - const { themeReducer } = await import('./themeReducer') - const result = themeReducer(undefined, { type: 'UNKNOWN' }) - expect(result).toBe(expected) - }, - ) -}) diff --git a/ui/src/reducers/transcodingReducer.js b/ui/src/reducers/transcodingReducer.js deleted file mode 100644 index db7a3708a..000000000 --- a/ui/src/reducers/transcodingReducer.js +++ /dev/null @@ -1,14 +0,0 @@ -import { TRANSCODING_SET_PROFILE } from '../actions' - -const initialState = { - browserProfile: null, -} - -export const transcodingReducer = (state = initialState, { type, data }) => { - switch (type) { - case TRANSCODING_SET_PROFILE: - return { ...state, browserProfile: data } - default: - return state - } -} diff --git a/ui/src/reducers/transcodingReducer.test.js b/ui/src/reducers/transcodingReducer.test.js deleted file mode 100644 index eb3e7a490..000000000 --- a/ui/src/reducers/transcodingReducer.test.js +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { transcodingReducer } from './transcodingReducer' -import { TRANSCODING_SET_PROFILE } from '../actions' - -describe('transcodingReducer', () => { - const initialState = { browserProfile: null } - - it('returns initial state', () => { - expect(transcodingReducer(undefined, {})).toEqual(initialState) - }) - - it('handles TRANSCODING_SET_PROFILE', () => { - const profile = { - name: 'NavidromeUI', - directPlayProfiles: [{ containers: ['mp3'] }], - } - const state = transcodingReducer(initialState, { - type: TRANSCODING_SET_PROFILE, - data: profile, - }) - expect(state.browserProfile).toEqual(profile) - }) -}) diff --git a/ui/src/routes.jsx b/ui/src/routes.js similarity index 57% rename from ui/src/routes.jsx rename to ui/src/routes.js index 0b36ea5a9..bd2cd9fc9 100644 --- a/ui/src/routes.jsx +++ b/ui/src/routes.js @@ -2,8 +2,6 @@ import React from 'react' import { Route } from 'react-router-dom' import Personal from './personal/Personal' -const routes = [ - <Route exact path="/personal" render={() => <Personal />} key={'personal'} />, -] +const routes = [<Route exact path="/personal" render={() => <Personal />} />] export default routes diff --git a/ui/src/serviceWorker.js b/ui/src/serviceWorker.js new file mode 100644 index 000000000..420ee107e --- /dev/null +++ b/ui/src/serviceWorker.js @@ -0,0 +1,141 @@ +// This optional code is used to register a service worker. +// register() is not called by default. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on subsequent visits to a page, after all the +// existing tabs open on the page have been closed, since previously cached +// resources are updated in the background. + +// To learn more about the benefits of this model and instructions on how to +// opt-in, read https://bit.ly/CRA-PWA + +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.0/8 are considered localhost for IPv4. + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + ) +) + +export function register(config) { + if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href) + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebook/create-react-app/issues/2374 + return + } + + window.addEventListener('load', () => { + const swUrl = `${process.env.PUBLIC_URL}/navidrome-service-worker.js` + + if (isLocalhost) { + // This is running on localhost. Let's check if a service worker still exists or not. + checkValidServiceWorker(swUrl, config) + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.log( + 'This web app is being served cache-first by a service ' + + 'worker. To learn more, visit https://bit.ly/CRA-PWA' + ) + }) + } else { + // Is not localhost. Just register service worker + registerValidSW(swUrl, config) + } + }) + } +} + +function registerValidSW(swUrl, config) { + navigator.serviceWorker + .register(swUrl) + .then((registration) => { + registration.onupdatefound = () => { + const installingWorker = registration.installing + if (installingWorker == null) { + return + } + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + // At this point, the updated precached content has been fetched, + // but the previous service worker will still serve the older + // content until all client tabs are closed. + console.log( + 'New content is available and will be used when all ' + + 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' + ) + + // Execute callback + if (config && config.onUpdate) { + config.onUpdate(registration) + } + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + console.log('Content is cached for offline use.') + + // Execute callback + if (config && config.onSuccess) { + config.onSuccess(registration) + } + } + } + } + } + }) + .catch((error) => { + console.error('Error during service worker registration:', error) + }) +} + +function checkValidServiceWorker(swUrl, config) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl, { + headers: { 'Service-Worker': 'script' }, + }) + .then((response) => { + // Ensure service worker exists, and that we really are getting a JS file. + const contentType = response.headers.get('content-type') + if ( + response.status === 404 || + (contentType != null && contentType.indexOf('javascript') === -1) + ) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then((registration) => { + registration.unregister().then(() => { + window.location.reload() + }) + }) + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl, config) + } + }) + .catch(() => { + console.log( + 'No internet connection found. App is running in offline mode.' + ) + }) +} + +export function unregister() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then((registration) => { + registration.unregister() + }) + .catch((error) => { + console.error(error.message) + }) + } +} diff --git a/ui/src/setupTests.js b/ui/src/setupTests.js index 7cb46e09c..84f521dcd 100644 --- a/ui/src/setupTests.js +++ b/ui/src/setupTests.js @@ -2,7 +2,7 @@ // allows you to do things like: // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom' +import '@testing-library/jest-dom/extend-expect' const localStorageMock = (function () { let store = {} @@ -14,9 +14,6 @@ const localStorageMock = (function () { setItem: function (key, value) { store[key] = value.toString() }, - removeItem: function (key) { - delete store[key] - }, clear: function () { store = {} }, diff --git a/ui/src/share/ShareEdit.js b/ui/src/share/ShareEdit.js new file mode 100644 index 000000000..91a7b11dc --- /dev/null +++ b/ui/src/share/ShareEdit.js @@ -0,0 +1,36 @@ +import { + DateTimeInput, + BooleanInput, + Edit, + NumberField, + SimpleForm, + TextInput, +} from 'react-admin' +import { shareUrl } from '../utils' +import { Link } from '@material-ui/core' +import { DateField } from '../common' +import config from '../config' + +export const ShareEdit = (props) => { + const { id, basePath, hasCreate, ...rest } = props + const url = shareUrl(id) + return ( + <Edit {...props}> + <SimpleForm {...rest}> + <Link source="URL" href={url} target="_blank" rel="noopener noreferrer"> + {url} + </Link> + <TextInput source="description" /> + {config.enableDownloads && <BooleanInput source="downloadable" />} + <DateTimeInput source="expiresAt" /> + <TextInput source="contents" disabled /> + <TextInput source="format" disabled /> + <TextInput source="maxBitRate" disabled /> + <TextInput source="username" disabled /> + <NumberField source="visitCount" disabled /> + <DateField source="lastVisitedAt" disabled showTime /> + <DateField source="createdAt" disabled showTime /> + </SimpleForm> + </Edit> + ) +} diff --git a/ui/src/share/ShareEdit.jsx b/ui/src/share/ShareEdit.jsx deleted file mode 100644 index a222d3369..000000000 --- a/ui/src/share/ShareEdit.jsx +++ /dev/null @@ -1,45 +0,0 @@ -import { - DateTimeInput, - BooleanInput, - Edit, - SimpleForm, - TextInput, -} from 'react-admin' -import { sharePlayerUrl } from '../utils' -import { Link } from '@material-ui/core' -import { - ReadOnlyDateField, - ReadOnlyNumberField, - ReadOnlyTextField, -} from '../common' -import config from '../config' - -export const ShareEdit = (props) => { - const { id, basePath, hasCreate, ...rest } = props - const url = sharePlayerUrl(id) - return ( - <Edit {...props}> - <SimpleForm variant={'outlined'} {...rest}> - <Link - source="URL" - href={url} - target="_blank" - rel="noopener noreferrer" - variant="inherit" - > - {url} - </Link> - <TextInput source="description" /> - {config.enableDownloads && <BooleanInput source="downloadable" />} - <DateTimeInput source="expiresAt" /> - <ReadOnlyTextField source="contents" /> - <ReadOnlyTextField source="format" /> - <ReadOnlyTextField source="maxBitRate" /> - <ReadOnlyTextField source="username" /> - <ReadOnlyNumberField source="visitCount" /> - <ReadOnlyDateField source="lastVisitedAt" /> - <ReadOnlyDateField source="createdAt" /> - </SimpleForm> - </Edit> - ) -} diff --git a/ui/src/share/ShareList.jsx b/ui/src/share/ShareList.js similarity index 95% rename from ui/src/share/ShareList.jsx rename to ui/src/share/ShareList.js index 48ab44e18..0219e49db 100644 --- a/ui/src/share/ShareList.jsx +++ b/ui/src/share/ShareList.js @@ -2,18 +2,18 @@ import { Datagrid, FunctionField, BooleanField, + List, NumberField, SimpleList, TextField, useNotify, useTranslate, } from 'react-admin' -import { List } from '../common' import React from 'react' import { IconButton, Link, useMediaQuery } from '@material-ui/core' import ShareIcon from '@material-ui/icons/Share' import { DateField, QualityInfo } from '../common' -import { sharePlayerUrl } from '../utils' +import { shareUrl } from '../utils' import config from '../config' export const FormatInfo = ({ record, size }) => { @@ -30,7 +30,7 @@ const ShareList = (props) => { const notify = useNotify() const handleShare = (r) => (e) => { - const url = sharePlayerUrl(r?.id) + const url = shareUrl(r?.id) if (navigator.clipboard && window.isSecureContext) { navigator.clipboard .writeText(url) @@ -48,7 +48,7 @@ const ShareList = (props) => { type: 'warning', multiLine: true, duration: 0, - }, + } ) }) } else prompt(translate('message.shareCopyToClipboard'), url) @@ -89,7 +89,7 @@ const ShareList = (props) => { source={'id'} render={(r) => ( <Link - href={sharePlayerUrl(r.id)} + href={shareUrl(r.id)} label="URL" target="_blank" rel="noopener noreferrer" diff --git a/ui/src/share/SharePlayer.jsx b/ui/src/share/SharePlayer.js similarity index 51% rename from ui/src/share/SharePlayer.jsx rename to ui/src/share/SharePlayer.js index 2384866ea..38493d3ce 100644 --- a/ui/src/share/SharePlayer.jsx +++ b/ui/src/share/SharePlayer.js @@ -1,24 +1,15 @@ import ReactJkMusicPlayer from 'navidrome-music-player' -import { useCallback, useEffect, useRef, useState } from 'react' import config, { shareInfo } from '../config' import { shareCoverUrl, shareDownloadUrl, shareStreamUrl } from '../utils' import { makeStyles } from '@material-ui/core/styles' -// How long the download button stays inert after a click. The browser needs a -// moment to show its own download UI; until then the page looks unresponsive. -export const DOWNLOAD_FEEDBACK_MS = 5000 - const useStyle = makeStyles({ player: { '& .group .next-audio': { pointerEvents: (props) => props.single && 'none', opacity: (props) => props.single && 0.65, }, - '& .group.audio-download': { - pointerEvents: (props) => props.downloading && 'none', - opacity: (props) => props.downloading && 0.65, - }, '@media (min-width: 768px)': { '& .react-jinke-music-player-mobile > div': { width: 768, @@ -32,41 +23,22 @@ const useStyle = makeStyles({ }) const SharePlayer = () => { - const [downloading, setDownloading] = useState(false) - const timer = useRef(null) - const classes = useStyle({ - single: shareInfo?.tracks.length === 1, - downloading, - }) - - useEffect(() => () => clearTimeout(timer.current), []) + const classes = useStyle({ single: shareInfo?.tracks.length === 1 }) const list = shareInfo?.tracks.map((s) => { return { name: s.title, musicSrc: shareStreamUrl(s.id), - cover: shareCoverUrl(s.id, true), + cover: shareCoverUrl(s.id), singer: s.artist, duration: s.duration, } }) - // An anchor, not a navigation: the service worker's NavigationRoute would - // intercept the streamed archive and fail it. - const customDownloader = useCallback(() => { - const link = document.createElement('a') - link.href = shareDownloadUrl(shareInfo?.id) - link.download = '' - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - - setDownloading(true) - clearTimeout(timer.current) - timer.current = setTimeout( - () => setDownloading(false), - DOWNLOAD_FEEDBACK_MS, - ) - }, []) + const onBeforeAudioDownload = () => { + return Promise.resolve({ + src: shareDownloadUrl(shareInfo?.id), + }) + } const options = { audioLists: list, mode: 'full', @@ -81,13 +53,12 @@ const SharePlayer = () => { remove: false, spaceBar: true, volumeFade: { fadeIn: 200, fadeOut: 200 }, - sortableOptions: { delay: 200, delayOnTouchOnly: true }, } return ( <ReactJkMusicPlayer {...options} className={classes.player} - customDownloader={customDownloader} + onBeforeAudioDownload={onBeforeAudioDownload} /> ) } diff --git a/ui/src/share/SharePlayer.test.jsx b/ui/src/share/SharePlayer.test.jsx deleted file mode 100644 index 68d1eff59..000000000 --- a/ui/src/share/SharePlayer.test.jsx +++ /dev/null @@ -1,139 +0,0 @@ -import { render, act } from '@testing-library/react' -import SharePlayer, { DOWNLOAD_FEEDBACK_MS } from './SharePlayer' - -let playerProps -let renderCount - -vi.mock('navidrome-music-player', () => ({ - default: (props) => { - playerProps = props - renderCount++ - return <div data-testid="player" /> - }, -})) - -vi.mock('../config', () => ({ - default: { enableDownloads: true }, - shareInfo: { - id: 'share-1', - downloadable: true, - tracks: [{ id: 't1', title: 'One', artist: 'A', duration: 100 }], - }, -})) - -vi.mock('../utils', () => ({ - shareDownloadUrl: (id) => `/share/d/${id}`, - shareStreamUrl: (id) => `/share/s/${id}`, - shareCoverUrl: (id) => `/share/img/${id}`, -})) - -describe('SharePlayer', () => { - let clickSpy - - beforeEach(() => { - vi.useFakeTimers() - playerProps = null - renderCount = 0 - // Downloading for real would navigate the jsdom window. - clickSpy = vi - .spyOn(HTMLAnchorElement.prototype, 'click') - .mockImplementation(() => {}) - }) - - afterEach(() => { - vi.runOnlyPendingTimers() - vi.useRealTimers() - vi.restoreAllMocks() - }) - - it('downloads via an anchor so the service worker does not intercept it', () => { - render(<SharePlayer />) - - let anchor - clickSpy.mockImplementation(function () { - anchor = { href: this.href, download: this.download } - }) - - act(() => { - playerProps.customDownloader() - }) - - expect(clickSpy).toHaveBeenCalledTimes(1) - expect(anchor.href).toContain('/share/d/share-1') - // Empty, so the server's Content-Disposition filename wins. - expect(anchor.download).toBe('') - }) - - it('removes the anchor from the document after clicking', () => { - render(<SharePlayer />) - - act(() => { - playerProps.customDownloader() - }) - - expect(document.querySelectorAll('a[download]')).toHaveLength(0) - }) - - // The inert styling itself is driven by JSS function values, which jsdom does - // not evaluate; it is verified in a browser. What is checked here is the - // state machine feeding it -- that the component re-renders on download and - // again when the window closes. - it('re-renders when the feedback window opens and closes', () => { - render(<SharePlayer />) - - const beforeDownload = renderCount - act(() => { - playerProps.customDownloader() - }) - expect(renderCount).toBeGreaterThan(beforeDownload) - - const beforeExpiry = renderCount - act(() => { - vi.runAllTimers() - }) - expect(renderCount).toBeGreaterThan(beforeExpiry) - }) - - it('restarts the feedback window on a repeat download', () => { - render(<SharePlayer />) - - act(() => { - playerProps.customDownloader() - }) - const elapsedBeforeRepeat = Math.floor(DOWNLOAD_FEEDBACK_MS / 2) - act(() => { - vi.advanceTimersByTime(elapsedBeforeRepeat) - }) - act(() => { - playerProps.customDownloader() - }) - - // Past the first timer's deadline, which it would have fired at had the - // repeat download not replaced it. - const beforeOriginalDeadline = renderCount - act(() => { - vi.advanceTimersByTime(DOWNLOAD_FEEDBACK_MS - elapsedBeforeRepeat + 1) - }) - expect(renderCount).toBe(beforeOriginalDeadline) - - act(() => { - vi.runAllTimers() - }) - expect(renderCount).toBeGreaterThan(beforeOriginalDeadline) - }) - - it('does not update state after unmount', () => { - const { unmount } = render(<SharePlayer />) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - act(() => { - playerProps.customDownloader() - }) - unmount() - act(() => { - vi.runAllTimers() - }) - - expect(errorSpy).not.toHaveBeenCalled() - }) -}) diff --git a/ui/src/share/index.jsx b/ui/src/share/index.js similarity index 100% rename from ui/src/share/index.jsx rename to ui/src/share/index.js diff --git a/ui/src/song/AlbumLinkField.js b/ui/src/song/AlbumLinkField.js new file mode 100644 index 000000000..786370b74 --- /dev/null +++ b/ui/src/song/AlbumLinkField.js @@ -0,0 +1,21 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Link } from 'react-admin' + +export const AlbumLinkField = (props) => ( + <Link + to={`/album/${props.record.albumId}/show`} + onClick={(e) => e.stopPropagation()} + > + {props.record.album} + </Link> +) + +AlbumLinkField.propTypes = { + sortBy: PropTypes.string, + sortByOrder: PropTypes.oneOf(['ASC', 'DESC']), +} + +AlbumLinkField.defaultProps = { + addLabel: true, +} diff --git a/ui/src/song/AlbumLinkField.jsx b/ui/src/song/AlbumLinkField.jsx deleted file mode 100644 index 3c00c6251..000000000 --- a/ui/src/song/AlbumLinkField.jsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import { Link } from 'react-admin' -import { useDispatch } from 'react-redux' -import { closeExtendedInfoDialog } from '../actions' - -export const AlbumLinkField = (props) => { - const dispatch = useDispatch() - - return ( - <Link - to={`/album/${props.record.albumId}/show`} - onClick={(e) => { - e.stopPropagation() - dispatch(closeExtendedInfoDialog()) - }} - > - {props.record.album} - </Link> - ) -} - -AlbumLinkField.propTypes = { - sortBy: PropTypes.string, - sortByOrder: PropTypes.oneOf(['ASC', 'DESC']), -} - -AlbumLinkField.defaultProps = { - addLabel: true, -} diff --git a/ui/src/song/SongList.jsx b/ui/src/song/SongList.js similarity index 66% rename from ui/src/song/SongList.jsx rename to ui/src/song/SongList.js index 6b7bfaf96..8222896c8 100644 --- a/ui/src/song/SongList.jsx +++ b/ui/src/song/SongList.js @@ -1,15 +1,13 @@ -import { useMemo } from 'react' +import React from 'react' import { - AutocompleteArrayInput, + AutocompleteInput, Filter, FunctionField, NumberField, - ReferenceArrayInput, + ReferenceInput, SearchInput, TextField, useTranslate, - NullableBooleanInput, - usePermissions, } from 'react-admin' import { useMediaQuery } from '@material-ui/core' import FavoriteIcon from '@material-ui/icons/Favorite' @@ -20,14 +18,12 @@ import { SongContextMenu, SongDatagrid, SongInfo, + QuickFilter, SongTitleField, SongSimpleList, RatingField, useResourceRefresh, ArtistLinkField, - PathField, - defaultRowsPerPageOptions, - getStoredPerPage, } from '../common' import { useDispatch } from 'react-redux' import { makeStyles } from '@material-ui/core/styles' @@ -61,21 +57,14 @@ const useStyles = makeStyles({ ratingField: { visibility: 'hidden', }, - chip: { - margin: 0, - height: '24px', - }, }) const SongFilter = (props) => { - const classes = useStyles() const translate = useTranslate() - const { permissions } = usePermissions() - const isAdmin = permissions === 'admin' return ( <Filter {...props} variant={'outlined'}> <SearchInput source="title" alwaysOn /> - <ReferenceArrayInput + <ReferenceInput label={translate('resources.song.fields.genre')} source="genre_id" reference="genre" @@ -83,49 +72,15 @@ const SongFilter = (props) => { sort={{ field: 'name', order: 'ASC' }} filterToQuery={(searchText) => ({ name: [searchText] })} > - <AutocompleteArrayInput emptyText="-- None --" classes={classes} /> - </ReferenceArrayInput> - <ReferenceArrayInput - label={translate('resources.song.fields.grouping')} - source="grouping" - reference="tag" - perPage={0} - sort={{ field: 'tagValue', order: 'ASC' }} - filter={{ tag_name: 'grouping' }} - filterToQuery={(searchText) => ({ - tag_value: [searchText], - })} - > - <AutocompleteArrayInput - emptyText="-- None --" - classes={classes} - optionText="tagValue" - /> - </ReferenceArrayInput> - <ReferenceArrayInput - label={translate('resources.song.fields.mood')} - source="mood" - reference="tag" - perPage={0} - sort={{ field: 'tagValue', order: 'ASC' }} - filter={{ tag_name: 'mood' }} - filterToQuery={(searchText) => ({ - tag_value: [searchText], - })} - > - <AutocompleteArrayInput - emptyText="-- None --" - classes={classes} - optionText="tagValue" - /> - </ReferenceArrayInput> + <AutocompleteInput emptyText="-- None --" /> + </ReferenceInput> {config.enableFavourites && ( - <NullableBooleanInput + <QuickFilter source="starred" label={<FavoriteIcon fontSize={'small'} />} + defaultValue={true} /> )} - {isAdmin && <NullableBooleanInput source="missing" />} </Filter> ) } @@ -141,15 +96,20 @@ const SongList = (props) => { dispatch(setTrack(record)) } - const toggleableFields = useMemo(() => { + const toggleableFields = React.useMemo(() => { return { - album: isDesktop && <AlbumLinkField source="album" sortByOrder={'ASC'} />, - artist: <ArtistLinkField source="artist" />, - composer: <ArtistLinkField source="composer" sortable={false} />, - albumArtist: <ArtistLinkField source="albumArtist" />, - trackNumber: isDesktop && ( - <NumberField source="trackNumber" sortable={false} /> + album: isDesktop && ( + <AlbumLinkField + source="album" + sortBy={ + 'album, order_album_artist_name, release_date, disc_number, track_number, title' + } + sortByOrder={'ASC'} + /> ), + artist: <ArtistLinkField source="artist" />, + albumArtist: <ArtistLinkField source="albumArtist" />, + trackNumber: isDesktop && <NumberField source="trackNumber" />, playCount: isDesktop && ( <NumberField source="playCount" sortByOrder={'DESC'} /> ), @@ -176,18 +136,9 @@ const SongList = (props) => { ), bpm: isDesktop && <NumberField source="bpm" />, genre: <TextField source="genre" />, - mood: isDesktop && ( - <FunctionField - source="mood" - render={(r) => r.tags?.mood?.[0] || ''} - sortable={false} - /> - ), comment: <TextField source="comment" />, - path: <PathField source="path" />, - createdAt: ( - <DateField source="createdAt" sortBy="recently_added" showTime /> - ), + path: <TextField source="path" />, + createdAt: <DateField source="createdAt" showTime />, } }, [isDesktop, classes.ratingField]) @@ -195,13 +146,11 @@ const SongList = (props) => { resource: 'song', columns: toggleableFields, defaultOff: [ - 'composer', 'channels', 'bpm', 'playDate', 'albumArtist', 'genre', - 'mood', 'comment', 'path', 'createdAt', @@ -217,11 +166,7 @@ const SongList = (props) => { bulkActionButtons={<SongBulkActions />} actions={<SongListActions />} filters={<SongFilter />} - perPage={getStoredPerPage( - 'song', - defaultRowsPerPageOptions, - isXsmall ? 50 : 15, - )} + perPage={isXsmall ? 50 : 15} > {isXsmall ? ( <SongSimpleList /> @@ -234,7 +179,8 @@ const SongList = (props) => { <SongTitleField source="title" showTrackNumbers={false} /> {columns} <SongContextMenu - source={'starred_at'} + source={'starred'} + sortBy={'starred ASC, starredAt ASC'} sortByOrder={'DESC'} sortable={config.enableFavourites} className={classes.contextMenu} diff --git a/ui/src/song/SongListActions.jsx b/ui/src/song/SongListActions.js similarity index 100% rename from ui/src/song/SongListActions.jsx rename to ui/src/song/SongListActions.js index 44fe9b5f5..82dbe789e 100644 --- a/ui/src/song/SongListActions.jsx +++ b/ui/src/song/SongListActions.js @@ -24,7 +24,6 @@ export const SongListActions = ({ const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm')) return ( <TopToolbar className={className} {...sanitizeListRestProps(rest)}> - <ShuffleAllButton filters={filterValues} /> {filters && cloneElement(filters, { resource, @@ -33,6 +32,7 @@ export const SongListActions = ({ filterValues, context: 'button', })} + <ShuffleAllButton filters={filterValues} /> {isNotSmall && <ToggleFieldsMenu resource="song" />} </TopToolbar> ) diff --git a/ui/src/song/index.jsx b/ui/src/song/index.js similarity index 100% rename from ui/src/song/index.jsx rename to ui/src/song/index.js diff --git a/ui/src/store/createAdminStore.js b/ui/src/store/createAdminStore.js index 4888e49e4..ba8d40771 100644 --- a/ui/src/store/createAdminStore.js +++ b/ui/src/store/createAdminStore.js @@ -9,6 +9,7 @@ import createSagaMiddleware from 'redux-saga' import { all, fork } from 'redux-saga/effects' import { adminReducer, adminSaga, USER_LOGOUT } from 'react-admin' import throttle from 'lodash.throttle' +import pick from 'lodash.pick' import { loadState, saveState } from './persistState' const createAdminStore = ({ @@ -47,9 +48,7 @@ const createAdminStore = ({ const store = createStore( resettableAppReducer, persistedState, - composeEnhancers( - applyMiddleware(sagaMiddleware, routerMiddleware(history)), - ), + composeEnhancers(applyMiddleware(sagaMiddleware, routerMiddleware(history))) ) store.subscribe( @@ -57,17 +56,12 @@ const createAdminStore = ({ const state = store.getState() saveState({ theme: state.theme, - library: state.library, - player: (({ queue, volume, savedPlayIndex }) => ({ - queue, - volume, - savedPlayIndex, - }))(state.player), + player: pick(state.player, ['queue', 'volume', 'savedPlayIndex']), albumView: state.albumView, settings: state.settings, }) }), - 1000, + 1000 ) sagaMiddleware.run(saga) diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 9f0ad28d7..1616a70d5 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -1,22 +1,11 @@ import { baseUrl } from '../utils' -import { - httpClient, - clientUniqueId, - clientUniqueIdHeader, -} from '../dataProvider' +import { httpClient } from '../dataProvider' const url = (command, id, options) => { - const username = localStorage.getItem('username') - const token = localStorage.getItem('subsonic-token') - const salt = localStorage.getItem('subsonic-salt') - if (!username || !token || !salt) { - return '' - } - const params = new URLSearchParams() - params.append('u', username) - params.append('t', token) - params.append('s', salt) + params.append('u', localStorage.getItem('username')) + params.append('t', localStorage.getItem('subsonic-token')) + params.append('s', localStorage.getItem('subsonic-salt')) params.append('f', 'json') params.append('v', '1.8.0') params.append('c', 'NavidromeUI') @@ -27,35 +16,21 @@ const url = (command, id, options) => { delete options.ts } Object.keys(options).forEach((k) => { - const value = options[k] - // Handle array parameters by appending each value separately - if (Array.isArray(value)) { - value.forEach((v) => params.append(k, v)) - } else { - params.append(k, value) - } + params.append(k, options[k]) }) } return `/rest/${command}?${params.toString()}` } -const ping = () => httpClient(url('ping')) - -const reportPlaybackUrl = (mediaId, positionMs, state) => - url('reportPlayback', null, { mediaId, mediaType: 'song', positionMs, state }) - -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 scrobble = (id, time, submission = true) => + httpClient( + url('scrobble', id, { + ...(submission && time && { time }), + submission, }) - } -} + ) + +const nowPlaying = (id) => scrobble(id, null, false) const star = (id) => httpClient(url('star', id)) @@ -70,51 +45,22 @@ const startScan = (options) => httpClient(url('startScan', null, options)) const getScanStatus = () => httpClient(url('getScanStatus')) -const getNowPlaying = () => httpClient(url('getNowPlaying')) - -const getAvatarUrl = (username, size) => - baseUrl( - url('getAvatar', null, { - username, - ...(size && { size }), - }), - ) - -const getCoverArtUrl = (record, size, square) => { - const suffix = record.imageHash ? '_' + record.imageHash : '' +const getCoverArtUrl = (record, size) => { const options = { - // A hash-suffixed url is already pixel-versioned; the buster would defeat immutable caching. - ...(!record.imageHash && record.updatedAt && { _: record.updatedAt }), + ...(record.updatedAt && { _: record.updatedAt }), ...(size && { size }), - ...(square && { square }), } - // TODO Move this logic to server + // TODO Move this logic to server. `song` and `album` should have a CoverArtID if (record.album) { - return baseUrl(url('getCoverArt', 'mf-' + record.id + suffix, options)) - } else if (record.albumArtist) { - return baseUrl(url('getCoverArt', 'al-' + record.id + suffix, options)) - } else if (record.sync !== undefined) { - // This is a playlist - return baseUrl(url('getCoverArt', 'pl-' + record.id + suffix, options)) - } else if (record.streamUrl !== undefined) { - // This is a radio station - return baseUrl(url('getCoverArt', 'ra-' + record.id + suffix, options)) + return baseUrl(url('getCoverArt', 'mf-' + record.id, options)) + } else if (record.artist) { + return baseUrl(url('getCoverArt', 'al-' + record.id, options)) } else { - return baseUrl(url('getCoverArt', 'ar-' + record.id + suffix, options)) + return baseUrl(url('getCoverArt', 'ar-' + record.id, options)) } } -const getDiscCoverArtUrl = (albumId, discNumber, updatedAt, size) => { - const options = { - ...(updatedAt && { _: updatedAt }), - ...(size && { size }), - } - return baseUrl( - url('getCoverArt', 'dc-' + albumId + ':' + discNumber, options), - ) -} - const getArtistInfo = (id) => { return httpClient(url('getArtistInfo', id)) } @@ -123,41 +69,27 @@ const getAlbumInfo = (id) => { return httpClient(url('getAlbumInfo', id)) } -const getSimilarSongs2 = (id, count = 100) => { - return httpClient(url('getSimilarSongs2', id, { count })) -} - -const getTopSongs = (artist, count = 50) => { - return httpClient(url('getTopSongs', null, { artist, count })) -} - const streamUrl = (id, options) => { return baseUrl( url('stream', id, { ts: true, ...options, - }), + }) ) } export default { url, - ping, - reportPlayback, - reportPlaybackKeepalive, + scrobble, + nowPlaying, download, star, unstar, setRating, startScan, getScanStatus, - getNowPlaying, getCoverArtUrl, - getDiscCoverArtUrl, - getAvatarUrl, streamUrl, getAlbumInfo, getArtistInfo, - getTopSongs, - getSimilarSongs2, } diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js deleted file mode 100644 index 32c699a41..000000000 --- a/ui/src/subsonic/index.test.js +++ /dev/null @@ -1,267 +0,0 @@ -import { vi } from 'vitest' -import config from '../config' -import subsonic from './index' - -describe('getCoverArtUrl', () => { - beforeEach(() => { - // Mock window.location - delete window.location - window.location = { href: 'http://localhost:3000/app' } - - // Mock localStorage values required by subsonic - const localStorageMock = { - getItem: vi.fn((key) => { - const values = { - username: 'testuser', - 'subsonic-token': 'testtoken', - 'subsonic-salt': 'testsalt', - } - return values[key] || null - }), - setItem: vi.fn(), - clear: vi.fn(), - } - Object.defineProperty(window, 'localStorage', { value: localStorageMock }) - }) - - it('should return playlist cover art URL for records with sync property', () => { - const playlistRecord = { - id: 'playlist-123', - sync: true, - updatedAt: '2023-01-01T00:00:00Z', - } - - const url = subsonic.getCoverArtUrl( - playlistRecord, - config.uiCoverArtSize, - true, - ) - - expect(url).toContain('pl-playlist-123') - expect(url).toContain('size=600') - expect(url).toContain('square=true') - expect(url).toContain('_=2023-01-01T00%3A00%3A00Z') - }) - - it('should add timestamp for playlists without updatedAt', () => { - const playlistRecord = { - id: 'playlist-123', - sync: true, - } - - const url = subsonic.getCoverArtUrl( - playlistRecord, - config.uiCoverArtSize, - true, - ) - - expect(url).toContain('pl-playlist-123') - expect(url).toContain('size=600') - expect(url).toContain('square=true') - expect(url).not.toContain('_=') - }) - - it('should return album cover art URL for records with albumArtist', () => { - const albumRecord = { - id: 'album-123', - albumArtist: 'Test Artist', - updatedAt: '2023-01-01T00:00:00Z', - } - - const url = subsonic.getCoverArtUrl( - albumRecord, - config.uiCoverArtSize, - true, - ) - - expect(url).toContain('al-album-123') - expect(url).toContain('size=600') - expect(url).toContain('square=true') - }) - - it('should return media file cover art URL for records with album', () => { - const songRecord = { - id: 'song-123', - album: 'Test Album', - updatedAt: '2023-01-01T00:00:00Z', - } - - const url = subsonic.getCoverArtUrl(songRecord, config.uiCoverArtSize, true) - - expect(url).toContain('mf-song-123') - expect(url).toContain('size=600') - expect(url).toContain('square=true') - }) - - it('should return artist cover art URL for other records', () => { - const artistRecord = { - id: 'artist-123', - updatedAt: '2023-01-01T00:00:00Z', - } - - const url = subsonic.getCoverArtUrl( - artistRecord, - config.uiCoverArtSize, - true, - ) - - expect(url).toContain('ar-artist-123') - expect(url).toContain('size=600') - expect(url).toContain('square=true') - }) - - it('should handle records without updatedAt', () => { - const record = { - id: 'test-123', - } - - const url = subsonic.getCoverArtUrl(record) - - expect(url).toContain('ar-test-123') - expect(url).not.toContain('_=') - }) - - it('appends the content hash to the artwork id', () => { - const url = subsonic.getCoverArtUrl({ - id: 'album-123', - albumArtist: 'AA', - imageHash: '0123456789abcdef', - }) - expect(url).toContain('al-album-123_0123456789abcdef') - }) - - it('drops the updatedAt buster once the url is hash-versioned', () => { - const url = subsonic.getCoverArtUrl({ - id: 'album-123', - albumArtist: 'AA', - updatedAt: '2023-01-01T00:00:00Z', - imageHash: '0123456789abcdef', - }) - expect(url).toContain('al-album-123_0123456789abcdef') - expect(url).not.toContain('_=') - }) - - it('keeps the updatedAt buster while artwork is unresolved', () => { - const url = subsonic.getCoverArtUrl({ - id: 'album-123', - albumArtist: 'AA', - updatedAt: '2023-01-01T00:00:00Z', - }) - expect(url).toContain('al-album-123') - expect(url).toContain('_=') - }) - - it('still builds a url for known-absent artwork so the server placeholder renders', () => { - // Returning '' here made <img src=undefined> render as a broken icon; the server serves a - // proper placeholder for absent art, so the client must still request it. - const url = subsonic.getCoverArtUrl({ - id: 'album-123', - albumArtist: 'AA', - imageAbsent: true, - }) - expect(url).toContain('al-album-123') - }) -}) - -describe('getDiscCoverArtUrl', () => { - 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 URL with dc-albumId:discNumber format, size, and cache param', () => { - const url = subsonic.getDiscCoverArtUrl( - 'album-123', - 2, - '2023-01-01T00:00:00Z', - 48, - ) - - expect(url).toContain('getCoverArt') - expect(url).toContain('id=dc-album-123%3A2') - expect(url).toContain('size=48') - expect(url).toContain('_=2023-01-01T00%3A00%3A00Z') - }) - - it('should handle missing updatedAt', () => { - const url = subsonic.getDiscCoverArtUrl('album-123', 1, undefined, 48) - - expect(url).toContain('id=dc-album-123%3A1') - expect(url).toContain('size=48') - expect(url).not.toContain('_=') - }) - - it('should handle missing size', () => { - const url = subsonic.getDiscCoverArtUrl( - 'album-123', - 1, - '2023-01-01T00:00:00Z', - ) - - expect(url).toContain('id=dc-album-123%3A1') - expect(url).toContain('_=2023-01-01T00%3A00%3A00Z') - expect(url).not.toContain('size=') - }) -}) - -describe('getAvatarUrl', () => { - beforeEach(() => { - // Mock localStorage values required by subsonic - 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 include username parameter', () => { - const url = subsonic.getAvatarUrl('john') - expect(url).toContain('getAvatar') - 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/sw.js b/ui/src/sw.js deleted file mode 100644 index edc272850..000000000 --- a/ui/src/sw.js +++ /dev/null @@ -1,55 +0,0 @@ -/* eslint-disable */ - -import { createNavigationHandler } from './swNavigation' - -// documentation: https://developers.google.com/web/tools/workbox/modules/workbox-sw -importScripts('3rdparty/workbox/workbox-sw.js') - -workbox.setConfig({ - modulePathPrefix: '3rdparty/workbox/', - debug: false, -}) - -workbox.loadModule('workbox-core') -workbox.loadModule('workbox-strategies') -workbox.loadModule('workbox-routing') -workbox.loadModule('workbox-navigation-preload') -workbox.loadModule('workbox-precaching') - -workbox.core.clientsClaim() -self.skipWaiting() - -addEventListener('message', (event) => { - if (event.data && event.data.type === 'SKIP_WAITING') { - skipWaiting() - } -}) - -const CACHE_NAME = 'offline-html' -// This assumes /offline.html is a URL for your self-contained -// (no external images or styles) offline page. -const FALLBACK_HTML_URL = './offline.html' -// Populate the cache with the offline HTML page when the -// service worker is installed. -self.addEventListener('install', async (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => cache.add(FALLBACK_HTML_URL)), - ) -}) - -const networkOnly = new workbox.strategies.NetworkOnly() -const fallbackToCachedHtml = () => - caches.match(FALLBACK_HTML_URL, { cacheName: CACHE_NAME }) - -const navigationHandler = createNavigationHandler( - networkOnly, - fallbackToCachedHtml, -) - -// self.__WB_MANIFEST is default injection point -workbox.precaching.precacheAndRoute(self.__WB_MANIFEST) - -// Register this strategy to handle all navigations. -workbox.routing.registerRoute( - new workbox.routing.NavigationRoute(navigationHandler), -) diff --git a/ui/src/swNavigation.js b/ui/src/swNavigation.js deleted file mode 100644 index 28d01eaef..000000000 --- a/ui/src/swNavigation.js +++ /dev/null @@ -1,14 +0,0 @@ -// Lives outside sw.js so it can be unit tested: sw.js only loads inside a -// service worker, where workbox arrives via importScripts. -export const createNavigationHandler = - (networkOnly, offlineFallback) => async (params) => { - try { - // Attempt a network request. - const response = await networkOnly.handle(params) - // A 5xx reaches us as a normal response, but carries no usable app - return response.status >= 500 ? offlineFallback() : response - } catch (error) { - // If it fails, return the cached HTML. - return offlineFallback() - } - } diff --git a/ui/src/swNavigation.test.js b/ui/src/swNavigation.test.js deleted file mode 100644 index 8ab749757..000000000 --- a/ui/src/swNavigation.test.js +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { createNavigationHandler } from './swNavigation' - -const OFFLINE = { offlinePage: true } - -const setup = (networkResult) => { - const networkOnly = { - handle: vi.fn(() => - networkResult instanceof Error - ? Promise.reject(networkResult) - : Promise.resolve(networkResult), - ), - } - const offlineFallback = vi.fn(() => Promise.resolve(OFFLINE)) - return { - networkOnly, - offlineFallback, - handler: createNavigationHandler(networkOnly, offlineFallback), - } -} - -describe('createNavigationHandler', () => { - it('serves the response from the network', async () => { - const response = { status: 200 } - const { handler, offlineFallback } = setup(response) - - await expect(handler({})).resolves.toBe(response) - expect(offlineFallback).not.toHaveBeenCalled() - }) - - it('falls back to the offline page when the network fails', async () => { - const { handler } = setup(new Error('Failed to fetch')) - - await expect(handler({})).resolves.toBe(OFFLINE) - }) - - it.each([500, 502, 503])( - 'falls back to the offline page on %i', - async (status) => { - const { handler } = setup({ status }) - - await expect(handler({})).resolves.toBe(OFFLINE) - }, - ) - - it.each([304, 401, 404])('passes %i through untouched', async (status) => { - const response = { status } - const { handler, offlineFallback } = setup(response) - - await expect(handler({})).resolves.toBe(response) - expect(offlineFallback).not.toHaveBeenCalled() - }) - - it('passes the navigation params to the network strategy', async () => { - const params = { request: { url: '/app/' } } - const { handler, networkOnly } = setup({ status: 200 }) - - await handler(params) - - expect(networkOnly.handle).toHaveBeenCalledWith(params) - }) -}) diff --git a/ui/src/themes/README.md b/ui/src/themes/README.md index a5148018a..9d61a12aa 100644 --- a/ui/src/themes/README.md +++ b/ui/src/themes/README.md @@ -1,2 +1,2 @@ -To create and contribute with new themes, please refer to +To create and contribute with new themes, please refer to https://www.navidrome.org/docs/developers/creating-themes/ diff --git a/ui/src/themes/SquiddiesGlass.css.js b/ui/src/themes/SquiddiesGlass.css.js deleted file mode 100644 index 2c8e4f1d6..000000000 --- a/ui/src/themes/SquiddiesGlass.css.js +++ /dev/null @@ -1,175 +0,0 @@ -const stylesheet = ` - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { - background: #c231ab -} -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track, -.react-jinke-music-player-mobile-progress .rc-slider-track { - background: linear-gradient(to left, #c231ab, #380eff) -} - -.react-jinke-music-player-mobile { - background-color: #171717 !important; -} - -.react-jinke-music-player-mobile-progress .rc-slider-handle { - background: #c231ab; - height: 20px; - width: 20px; - margin-top: -9px; -} - -.react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #c231ab; -} - -.react-jinke-music-player-pause-icon { - background-color: #c231ab; - border-radius: 50%; - outline: auto; - color: white; -} -.react-jinke-music-player-main .music-player-panel .panel-content .player-content { - z-index: 99999; -} -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-btn svg { - border-radius: 50%; - outline: auto; - color: white; -} -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-btn svg:hover { - background-color: #c231ab; - border-radius: 50%; - outline: auto; - color: white; -} - -.react-jinke-music-player-main svg:hover { - color: #c231ab; -} - -.react-jinke-music-player .music-player-controller { - color: #c231ab; - border: 1px solid #e14ac2; -} - -.react-jinke-music-player .music-player-controller.music-player-playing:before { - border: 1px solid rgba(194, 49, 171, 0.3); -} - -.react-jinke-music-player .music-player .destroy-btn { - background-color: #c2c1c2; - top: -7px; - border-radius: 50%; - display: flex; -} - -.react-jinke-music-player .music-player .destroy-btn svg { - font-size: 20px; -} - -@media screen and (max-width: 767px) { - .react-jinke-music-player .music-player .destroy-btn { - right: -12px; - } -} - -.react-jinke-music-player-mobile-header-right { - right: 0; - top: 0; -} - -@media screen and (max-width: 767px) { - .react-jinke-music-player-main svg { - font-size: 32px; - } -} - -@keyframes gradientFlow { - 0% { background-position: 0% 50%; } - 50% { background-position: 100% 50%; } - 100% { background-position: 0% 50%; } -} - -.RaBulkActionsToolbar .MuiButton-label { - color: white; -} - -a[aria-current="page"] { - color: #c231ab !important; - font-weight: bold; -} - -a[aria-current="page"] .MuiListItemIcon-root { - color: #c231ab !important; -} - -.panel-content { - position: relative; - overflow: hidden; - background: linear-gradient(90deg, #311f2f, #0a0912, #2f0c28); - background-size: 300% 300%; - animation: gradientFlow 10s ease-in-out infinite; -} - -/* Equalizer bars */ -.panel-content::before { - content: ""; - position: absolute; - inset: 0; - background: repeating-linear-gradient( - 90deg, - rgba(255, 255, 255, 0.05) 0px, - rgba(255, 255, 255, 0.05) 2px, - transparent 1px, - transparent 3px - ); - animation: equalizer 1.8s infinite ease-in-out; - filter: blur(1px); - opacity: 0.5; -} - -@keyframes backgroundFlow { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } -} - -/* Vertical movement, equalizer type */ -@keyframes equalizer { - 0%, 100% { - transform: scaleY(1); - opacity: 0.2; - } - 25% { - transform: scaleY(1.4); - opacity: 0.9; - } - 50% { - transform: scaleY(0.7); - opacity: 0.2; - } - 75% { - transform: scaleY(1.2); - opacity: 0.8; - } -} - -@keyframes pulse { - 0% { opacity: 0.5; } - 100% { opacity: 1; } -} - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} -` - -export default stylesheet diff --git a/ui/src/themes/SquiddiesGlass.js b/ui/src/themes/SquiddiesGlass.js deleted file mode 100644 index 880b0be20..000000000 --- a/ui/src/themes/SquiddiesGlass.js +++ /dev/null @@ -1,608 +0,0 @@ -import stylesheet from './SquiddiesGlass.css.js' - -/** - * Color constants used throughout the Squiddies Glass theme. - * Provides a consistent color palette with pink, gray, purple, and basic colors. - * @type {Object} - */ -const colors = { - pink: { - 100: '#fbe3f4', - 200: '#f5b9e3', - 300: '#ec7cd6', - 400: '#e14ac2', - 500: '#c231ab', // base - 600: '#a31a92', - 700: '#8b0f7e', - 800: '#7a006d', - 900: '#670066', - }, - gray: { - 50: '#c2c1c2', - 100: '#b3b3b3', // light gray - 200: '#282828', // medium dark - 300: '#1d1d1d', // darker - 400: '#181818', // even darker - 500: '#171717', // darkest - }, - purple: { - 400: '#524590', - 500: '#4d3249', - 600: '#6d1c5e', - }, - black: '#000', - white: '#fff', - dark: '#121212', -} - -/** - * Shared style object for music list action buttons. - * Defines common styling for buttons in music lists, including hover effects and responsive scaling. - * @type {Object} - */ -const musicListActions = { - padding: '1rem 0', - alignItems: 'center', - '@global': { - button: { - border: '1px solid transparent', - backgroundColor: 'inherit', - color: colors.gray[100], - '&:hover': { - border: `1px solid ${colors.gray[100]}`, - backgroundColor: 'inherit !important', - }, - }, - 'button:first-child:not(:only-child)': { - '@media screen and (max-width: 720px)': { - transform: 'scale(1.3)', - margin: '1em', - '&:hover': { - transform: 'scale(1.2) !important', - }, - }, - transform: 'scale(1.3)', - margin: '1em', - minWidth: 0, - padding: 5, - transition: 'transform .3s ease', - background: colors.pink[500], - color: `${colors.black} !important`, - borderRadius: 500, - border: 0, - '&:hover': { - transform: 'scale(1.2)', - backgroundColor: `${colors.pink[500]} !important`, - border: 0, - }, - }, - 'button:only-child': { - marginTop: '0.3em', - }, - 'button:first-child>span:first-child': { - padding: 0, - color: `${colors.black} !important`, - }, - 'button:first-child>span:first-child>span': { - display: 'none', - }, - 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': - { - color: colors.gray[100], - }, - }, -} - -/** - * Squiddies Glass theme configuration object. - * Defines the complete theme structure including typography, palette, component overrides, and player settings. - * @type {Object} - */ -export default { - /** - * The name of the theme. - * @type {string} - */ - themeName: 'Squiddies Glass', - - /** - * Typography settings for the theme. - * Specifies font family and heading sizes. - * @type {Object} - */ - typography: { - fontFamily: "system-ui, 'Helvetica Neue', Helvetica, Arial, sans-serif", - h6: { - fontSize: '1rem', // AppBar title - }, - }, - - /** - * Color palette configuration. - * Defines primary, secondary, and background colors for the theme. - * @type {Object} - */ - palette: { - primary: { - light: colors.pink[300], - main: colors.pink[500], - }, - secondary: { - main: colors.white, - contrastText: colors.white, - }, - background: { - default: colors.dark, - paper: colors.dark, - }, - type: 'dark', - }, - - /** - * Component overrides for Material-UI and custom Navidrome components. - * Customizes the appearance and behavior of various UI components. - * @type {Object} - */ - overrides: { - // Material-UI Components - MuiAppBar: { - positionFixed: { - backgroundColor: `${colors.black} !important`, - boxShadow: 'none', - }, - }, - MuiButton: { - root: { - background: colors.pink[500], - color: colors.white, - border: '1px solid transparent', - borderRadius: 500, - '&:hover': { - background: `${colors.pink[900]} !important`, - }, - }, - textSecondary: { - border: `1px solid ${colors.gray[100]}`, - background: colors.black, - '&:hover': { - border: `1px solid ${colors.white} !important`, - background: `${colors.black} !important`, - }, - }, - label: { - color: colors.white, - paddingRight: '1rem', - paddingLeft: '0.7rem', - }, - }, - MuiCardMedia: { - root: { - position: 'relative', - overflow: 'hidden', - boxShadow: `0 2px 32px rgba(0,0,0,0.5), 0px 1px 5px rgba(0,0,0,0.1)`, - }, - }, - MuiDivider: { - root: { - margin: '.75rem 0', - }, - }, - MuiDrawer: { - root: { - background: colors.gray[500], - paddingTop: '10px', - }, - }, - MuiFormGroup: { - root: { - color: colors.pink[500], - }, - }, - MuiMenuItem: { - root: { - fontSize: '0.875rem', - }, - }, - MuiTableCell: { - root: { - borderBottom: `1px solid ${colors.gray[300]}`, - padding: '10px !important', - color: `${colors.gray[100]} !important`, - '& img[alt="playing"], & img[alt="paused"]': { - filter: - 'brightness(0) saturate(100%) invert(36%) sepia(93%) saturate(7463%) hue-rotate(289deg) brightness(95%) contrast(102%)', - }, - '& img[alt="playing"] + span, & img[alt="paused"] + span': { - color: colors.pink[500], - }, - }, - head: { - borderBottom: `1px solid ${colors.gray[200]}`, - fontSize: '0.75rem', - textTransform: 'uppercase', - letterSpacing: 1.2, - }, - }, - MuiTableRow: { - root: { - padding: '10px 0', - transition: 'background-color .3s ease', - '&:hover': { - backgroundColor: `${colors.gray[300]} !important`, - }, - '@global': { - 'td:nth-child(4)': { - color: `${colors.white} !important`, - }, - }, - }, - }, - - // React Admin Components - RaBulkActionsToolbar: { - topToolbar: { - gap: '8px', - }, - }, - RaFilter: { - form: { - '& .MuiOutlinedInput-input:-webkit-autofill': { - '-webkit-box-shadow': `0 0 0 100px ${colors.gray[50]} inset`, - '-webkit-text-fill-color': colors.white, - }, - }, - }, - RaFilterButton: { - root: { - marginRight: '1rem', - }, - }, - RaLayout: { - content: { - padding: '0 !important', - background: `linear-gradient(${colors.dark}, ${colors.gray[500]})`, - borderTopRightRadius: '8px', - borderTopLeftRadius: '8px', - }, - contentWithSidebar: { - gap: '2px', - }, - }, - RaList: { - content: { - backgroundColor: 'inherit', - }, - bulkActionsDisplayed: { - marginTop: '-20px', - }, - }, - RaListToolbar: { - toolbar: { - padding: '0 .55rem !important', - }, - }, - RaPaginationActions: { - currentPageButton: { - border: `1px solid ${colors.gray[100]}`, - }, - button: { - backgroundColor: 'inherit', - minWidth: 48, - margin: '0 4px', - border: `1px solid ${colors.gray[200]}`, - '@global': { - '> .MuiButton-label': { - padding: 0, - }, - }, - }, - actions: { - '@global': { - '.next-page': { - marginLeft: 8, - marginRight: 8, - }, - '.previous-page': { - marginRight: 8, - }, - }, - }, - }, - RaSearchInput: { - input: { - paddingLeft: '.9rem', - border: 0, - '& .MuiInputBase-root': { - backgroundColor: `${colors.white} !important`, - borderRadius: '20px !important', - color: colors.black, - border: '0px', - '& fieldset': { - borderColor: colors.white, - }, - '&:hover fieldset': { - borderColor: colors.white, - }, - '&.Mui-focused fieldset': { - borderColor: colors.white, - }, - '& svg': { - color: `${colors.black} !important`, - }, - '& .MuiOutlinedInput-input:-webkit-autofill': { - borderRadius: '20px 0px 0px 20px', - '-webkit-box-shadow': `0 0 0 100px ${colors.gray[50]} inset`, - '-webkit-text-fill-color': colors.black, - }, - }, - }, - }, - RaSidebar: { - root: { - height: 'initial', - borderTopRightRadius: '8px', - borderTopLeftRadius: '8px', - }, - }, - - // Navidrome Custom Components - NDAlbumDetails: { - root: { - boxShadow: 'none', - background: `linear-gradient(45deg, ${colors.purple[500]}, ${colors.purple[400]}, ${colors.purple[600]})`, - backgroundSize: '200% 200%', - animation: 'gradientFlow 8s ease-in-out infinite', - position: 'relative', - '&:before': { - content: '""', - position: 'absolute', - top: '0', - left: '0', - width: '100%', - height: '100%', - background: `linear-gradient(to bottom, transparent, ${colors.dark})`, - }, - }, - cardContents: { - alignItems: 'flex-start', - }, - coverParent: { - zIndex: '99999', - position: 'relative', - backgroundColor: 'rgba(0, 0, 0, 0.5)', - '&::before': { - content: '""', - position: 'absolute', - inset: '0', - width: '100%', - height: '100%', - borderRadius: '50%', - animation: 'pulse 1.5s ease-in-out infinite alternate', - zIndex: -1, - }, - '&::after': { - content: '""', - position: 'absolute', - inset: '0', - zIndex: '-1', - borderRadius: '50%', - background: - 'repeating-conic-gradient(from 0deg, rgba(255,255,255,0.08) 0deg, rgba(255,255,255,0.08) 0.5deg, rgba(0,0,0,1) 1deg)', - filter: 'contrast(999) sepia(1)', - boxShadow: - 'inset 0 0 25px rgba(255,255,255,0.05), inset 0 0 95px rgba(0,0,0,0.9)', - animation: 'spin 6s linear infinite', - }, - }, - details: { - zIndex: '99999', - }, - recordName: { - fontSize: 'calc(1rem + 1.5vw)', - fontWeight: 900, - }, - recordArtist: { - fontSize: '1.5rem', - fontWeight: 700, - textShadow: '0 2px 16px rgba(0, 0, 0, 0.3)', - }, - recordMeta: { - fontSize: '.875rem', - color: `rgba(${colors.white}, 0.8)`, - }, - content: { - paddingBottom: '0px !important', - paddingTop: '0px', - }, - }, - RaSingleFieldList: { - root: { - '& a:first-of-type > .MuiChip-root': { - marginLeft: '0px', - }, - '& a > .MuiChip-root': { - backgroundColor: colors.pink[500], - fontSize: '0.6rem', - height: '20px', - '& .MuiChip-label': { - color: colors.white, - paddingLeft: '5px', - paddingRight: '5px', - }, - }, - }, - }, - MuiGridListTile: { - tile: { - '&:hover': { - boxShadow: '0 2px 32px rgba(0,0,0,0.5), 0px 1px 5px rgba(0,0,0,0.1)', - }, - }, - }, - NDAlbumGridView: { - tileBar: { - background: - 'linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0.4) 50%, rgba(0, 0, 0, 0) 100%)', - marginBottom: '2px', - }, - albumName: { - marginTop: '0.5rem', - fontWeight: 700, - textTransform: 'none', - color: colors.white, - }, - albumSubtitle: { - color: colors.gray[100], - }, - albumContainer: { - backgroundColor: colors.gray[400], - borderRadius: '.5rem', - padding: '.75rem', - transition: 'background-color .3s ease', - '&:hover': { - backgroundColor: colors.gray[200], - }, - }, - albumPlayButton: { - color: colors.black, - backgroundColor: colors.pink[500], - borderRadius: '50%', - boxShadow: '0 8px 8px rgb(0 0 0 / 30%)', - padding: '0.35rem', - transition: 'padding .3s ease', - '&:hover': { - background: `${colors.pink[500]} !important`, - padding: '0.45rem', - }, - }, - }, - NDAlbumShow: { - albumActions: musicListActions, - }, - NDArtistShow: { - actions: { - padding: '2rem 0', - alignItems: 'center', - overflow: 'visible', - minHeight: '120px', - '@global': { - button: { - border: '1px solid transparent', - backgroundColor: 'inherit', - color: colors.gray[100], - margin: '0 0.5rem', - '&:hover': { - border: `1px solid ${colors.gray[100]}`, - backgroundColor: 'inherit !important', - }, - }, - // Hide shuffle button label (first button) - 'button:first-child>span:first-child>span': { - display: 'none', - }, - // Style shuffle button (first button) - 'button:first-child': { - '@media screen and (max-width: 720px)': { - transform: 'scale(1.5)', - margin: '1rem', - '&:hover': { - transform: 'scale(1.6) !important', - }, - }, - transform: 'scale(2)', - margin: '1.5rem', - minWidth: 0, - padding: 5, - transition: 'transform .3s ease', - background: colors.pink[500], - color: colors.white, - borderRadius: 500, - border: 0, - '&:hover': { - transform: 'scale(2.1)', - backgroundColor: `${colors.pink[500]} !important`, - border: 0, - }, - }, - 'button:first-child>span:first-child': { - padding: 0, - color: `${colors.black} !important`, - }, - 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': - { - color: colors.gray[100], - }, - }, - }, - actionsContainer: { - overflow: 'visible', - }, - }, - NDAudioPlayer: { - audioTitle: { - color: colors.white, - fontSize: '1.5rem', - '& span:nth-child(3)': { - fontSize: '0.8rem', - }, - }, - songTitle: { - fontWeight: 900, - }, - songInfo: { - fontSize: '0.9rem', - color: colors.gray[100], - }, - }, - NDCollapsibleComment: { - commentBlock: { - fontSize: '.875rem', - color: `rgba(${colors.white}, 0.8)`, - }, - }, - NDLogin: { - main: { - boxShadow: `inset 0 0 0 2000px rgba(${colors.black}, .75)`, - }, - systemNameLink: { - color: colors.white, - }, - card: { - border: `1px solid ${colors.gray[200]}`, - }, - avatar: { - marginBottom: 0, - }, - }, - NDPlaylistDetails: { - container: { - background: `linear-gradient(${colors.gray[300]}, transparent)`, - borderRadius: 0, - paddingTop: '2.5rem !important', - boxShadow: 'none', - }, - title: { - fontSize: 'calc(1.5rem + 1.5vw)', - fontWeight: 700, - color: colors.white, - }, - details: { - fontSize: '.875rem', - color: `rgba(${colors.white}, 0.8)`, - }, - }, - NDPlaylistShow: { - playlistActions: musicListActions, - }, - }, - - /** - * Player configuration settings. - * Specifies the player theme and associated stylesheet. - * @type {Object} - */ - player: { - theme: 'dark', - stylesheet, - }, -} diff --git a/ui/src/themes/amusic.css.js b/ui/src/themes/amusic.css.js deleted file mode 100644 index dcdd8bee8..000000000 --- a/ui/src/themes/amusic.css.js +++ /dev/null @@ -1,92 +0,0 @@ -const stylesheet = ` -.react-jinke-music-player-main .music-player-panel svg { - color: #eee -} -.react-jinke-music-player-main .music-player-panel button:disabled svg { - opacity: 0.3 -} -.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { - color: #D60017 -} -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #ff4e6b -} -.react-jinke-music-player-main ::-webkit-scrollbar-thumb, -.react-jinke-music-player-mobile-progress .rc-slider-handle, -.react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #ff4e6b -} -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #ff4e6b -} -.audio-lists-panel-content .audio-item.playing, -.react-jinke-music-player-main .audio-item.playing svg, -.react-jinke-music-player-main .group player-delete { - color: #ff4e6b -} -.audio-lists-panel-content .audio-item:hover, -.audio-lists-panel-content .audio-item:hover svg -.audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg{ - color: #D60017 -} -.react-jinke-music-player-main .audio-item.playing .player-singer { - color: #ff4e6b !important -} -.react-jinke-music-player-main .lyric-btn-active svg{ - color: #ff4e6b !important -} -.react-jinke-music-player-main .lyric-btn-active { - color: #D60017 !important -} -.react-jinke-music-player-main .loading svg { - color: #ff4e6b !important -} -.react-jinke-music-player .music-player-controller .music-player-controller-setting{ - background: #ff4e6b4d -} -.react-jinke-music-player-main .music-player-lyric{ - color: #ff4e6b !important; - text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000 -} -.react-jinke-music-player-main .music-player-panel, -.react-jinke-music-player-mobile, -.ril__outer{ - background-color: #1a1a1a; - border: 1px solid #fff1; -} -.ril__toolbarItem{ - font-size: 100%; - color: #eee -} -.audio-lists-panel, -.ril__toolbar{ - background-color: #1f1f1f; - border: 1px solid #fff1; - border-radius: 6px 6px 0 0; -} -.react-jinke-music-player-main .music-player-panel .panel-content .img-rotate, -.react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover img.cover, -.react-jinke-music-player-mobile-cover { - border-radius: 6px !important; - animation-duration: 0s !important -} -.react-jinke-music-player-main .music-player-panel .panel-content .img-content{ - width: 60px; - height: 60px -} -.react-jinke-music-player-main .songTitle{ - color: #eee -} -.react-jinke-music-player .music-player-controller{ - color: #ff4e6b -} -.audio-lists-panel-mobile .audio-item:not(.audio-lists-panel-sortable-highlight-bg){ - background: unset -} -.lastfm-icon, -.musicbrainz-icon{ - color: #eee -} -` -export default stylesheet diff --git a/ui/src/themes/amusic.js b/ui/src/themes/amusic.js deleted file mode 100644 index eef7f90d6..000000000 --- a/ui/src/themes/amusic.js +++ /dev/null @@ -1,259 +0,0 @@ -import stylesheet from './amusic.css.js' - -export default { - themeName: 'AMusic', - typography: { - fontFamily: - '-apple-system, BlinkMacSystemFont, Apple Color Emoji, SF Pro, SF Pro Icons, Helvetica Neue, Helvetica, Arial, sans-serif', - h6: { - fontSize: '1rem', // AppBar title - }, - h5: { - fontSize: '2em', - fontWeight: '600', - }, - }, - palette: { - primary: { - main: '#ff4e6b', - }, - secondary: { - main: '#D60017', - contrastText: '#eee', - }, - background: { - default: '#1a1a1a', - paper: '#1a1a1a', - }, - type: 'dark', - }, - overrides: { - MuiFormGroup: { - root: { - color: 'white', - }, - }, - MuiAppBar: { - positionFixed: { - backgroundColor: '#1d1d1d !important', - boxShadow: 'none', - borderBottom: '1px solid #fff1', - }, - colorSecondary: { - color: '#eee', - }, - }, - MuiDrawer: { - root: { - background: '#1d1d1d', - borderRight: '1px solid #fff1', - }, - }, - MuiToolbar: { - root: { - background: 'transparent !important', - }, - }, - MuiCardMedia: { - img: { - borderRadius: '10px', - boxShadow: '5px 5px 20px #111', - }, - }, - MuiButton: { - root: { - background: '#D60017', - color: '#fff', - borderRadius: '6px', - paddingRight: '0.5rem', - paddingLeft: '0.5rem', - marginLeft: '0.5rem', - marginBottom: '0.5rem', - textTransform: 'capitalize', - fontWeight: 600, - }, - textPrimary: { - color: '#eee', - }, - textSecondary: { - color: '#eee', - backgroundColor: '#ff4e6b', - }, - containedPrimary: { - color: '#fff', - backgroundColor: '#D60017', - '&:hover': { - backgroundColor: '#a30011', - '@media (hover: none)': { - backgroundColor: '#D60017', - }, - }, - }, - textSizeSmall: { - fontSize: '0.8rem', - paddingRight: '0.5rem', - paddingLeft: '0.5rem', - }, - label: { - paddingRight: '1rem', - paddingLeft: '0.7rem', - }, - }, - MuiListItemIcon: { - root: { - color: '#ff4e6b', - }, - }, - MuiChip: { - root: { - borderRadius: '6px', - }, - }, - MuiIconButton: { - root: { - color: '#ff4e6b', - }, - }, - MuiTableBody: { - root: { - '&>tr:nth-child(odd)': { - background: 'rgba(255, 255, 255, 0.025)', - }, - }, - }, - MuiTableRow: { - root: { - background: 'transparent', - }, - }, - MuiTableCell: { - root: { - borderBottom: '0 none !important', - padding: '10px !important', - color: '#b3b3b3 !important', - }, - head: { - color: '#b3b3b3 !important', - }, - }, - MuiMenuItem: { - root: { - fontSize: '0.875rem', - borderRadius: '10px', - color: '#eee', - }, - }, - NDAlbumGridView: { - albumName: { - color: '#eee', - }, - albumPlayButton: { - color: '#ff4e6b', - }, - albumArtistName: { - color: '#ccc', - }, - cover: { - borderRadius: '6px', - }, - }, - NDLogin: { - systemNameLink: { - color: '#ff4e6b', - }, - welcome: { - color: '#eee', - }, - card: { - minWidth: 300, - backgroundColor: '#1d1d1d', - }, - icon: { - filter: 'hue-rotate(115deg)', - }, - }, - MuiPaper: { - elevation1: { - boxShadow: 'none', - }, - root: { - color: '#eee', - }, - rounded: { - borderRadius: '6px', - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: '#1a1a1a', - }, - artistName: { - fontWeight: '600', - fontSize: '2em', - }, - }, - NDDesktopArtistDetails: { - artistName: { - fontWeight: '600', - fontSize: '2em', - }, - artistDetail: { - padding: 'unset', - paddingBottom: '1rem', - }, - }, - NDNotification: { - undo: { - color: '#fff', - }, - }, - RaConfirm: { - confirmPrimary: { - color: '#fff', - }, - }, - RaDeleteWithConfirmButton: { - deleteButton: { - color: '#fff !important', - }, - }, - RaDeleteWithUndoButton: { - deleteButton: { - color: '#fff !important', - }, - }, - RaBulkDeleteWithConfirmButton: { - deleteButton: { - color: '#fff !important', - }, - }, - RaBulkDeleteWithUndoButton: { - deleteButton: { - color: '#fff !important', - }, - }, - RaPaginationActions: { - currentPageButton: { - border: '2px solid #D60017', - background: 'transparent', - }, - button: { - border: '2px solid #D60017', - }, - actions: { - '@global': { - '.next-page': { - border: '0 none', - }, - '.previous-page': { - border: '0 none', - }, - }, - }, - }, - }, - player: { - theme: 'dark', - stylesheet, - }, -} diff --git a/ui/src/themes/catppuccinFrappe.css.js b/ui/src/themes/catppuccinFrappe.css.js deleted file mode 100644 index f927d3ba1..000000000 --- a/ui/src/themes/catppuccinFrappe.css.js +++ /dev/null @@ -1,158 +0,0 @@ -const stylesheet = ` - .react-jinke-music-player-main svg:active, - .react-jinke-music-player-main svg:hover { - color: #949cbb; /* overlay2 */ - } - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #a5adce; /* subtext0 */ - } - - .react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #a5adce; /* subtext0 */ - } - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #a5adce; /* subtext0 */ - } - - .react-jinke-music-player-main .audio-item.playing svg { - color: #a5adce; /* subtext0 */ - } - - .react-jinke-music-player-main .audio-item.playing .player-singer { - color: #a5adce !important; - } - - .react-jinke-music-player-main .loading svg { - color: #a5adce !important; - } - - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { - border: hidden; - box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - } - - - .rc-slider-rail, .rc-slider-track { - height: 6px; - } - - .rc-slider { - padding: 3px 0; - } - - .sound-operation > div:nth-child(4) { - transform: translateX(-50%) translateY(5%) !important; - } - - .sound-operation { - padding: 4px 0; - } - - .react-jinke-music-player-main .music-player-panel { - background-color: #292c3c; /* mantle */ - color: #c6d0f5; /* text */ - box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); - } - - .audio-lists-panel { - background-color: #292c3c; /* mantle */ - bottom: 6.25rem; - box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - } - - .audio-lists-panel-content .audio-item.playing { - background-color: rgba(0, 0, 0, 0); - } - - .audio-lists-panel-content .audio-item:nth-child(2n+1) { - background-color: rgba(0, 0, 0, 0); - } - - .audio-lists-panel-content .audio-item:active, - .audio-lists-panel-content .audio-item:hover { - background-color:rgba(255, 255, 255, 0.08); - } - - .audio-lists-panel-header { - border-bottom:1px solid #414559; /* surface0 */ - } - - .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { - background-color:rgba(0,0,0,0); - box-shadow:0 0 0 0; - } - - .audio-lists-panel-content .audio-item { - line-height: 32px; - } - - .react-jinke-music-player-main .music-player-panel .panel-content .img-content { - box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - } - - .react-jinke-music-player-main .music-player-lyric { - color: #a5adce; /* subtext0 */ - -webkit-text-stroke: 0.5px #303446; /* base */ - font-weight: bolder; - } - - .react-jinke-music-player-main .lyric-btn-active, - .react-jinke-music-player-main .lyric-btn-active svg { - color: #a5adce !important; - } - - .audio-lists-panel-header { - border-bottom:1px solid rgba(0, 0, 0, 0.25); - box-shadow:none; - } - - .audio-lists-panel-content .audio-item.playing, - .audio-lists-panel-content .audio-item.playing svg { - color: #a5adce; /* subtext0 */ - } - - .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, - .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { - color: #a5adce; /* subtext0 */ - } - - .audio-lists-panel-content .audio-item .player-icons { - scale: 75%; - } - - .audio-lists-panel-content .audio-item:active, - .audio-lists-panel-content .audio-item:hover { - background-color: #51576d; /* surface1 */ - } - -/* Mobile */ - - .react-jinke-music-player-mobile-cover { - border: none; - box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - } - - .react-jinke-music-player .music-player-controller { - border: none; - box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - color: #a5adce; /* subtext0 */ - } - - .react-jinke-music-player .music-player-controller .music-player-controller-setting { - color: rgba(122,160,236,.3); - } - - .react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #a5adce; /* subtext0 */ - } - - .react-jinke-music-player-mobile-progress .rc-slider-handle { - border: none; - } -` - -export default stylesheet diff --git a/ui/src/themes/catppuccinFrappe.js b/ui/src/themes/catppuccinFrappe.js deleted file mode 100644 index 87ff6258f..000000000 --- a/ui/src/themes/catppuccinFrappe.js +++ /dev/null @@ -1,106 +0,0 @@ -import stylesheet from './catppuccinFrappe.css.js' - -export default { - themeName: 'Catppuccin Frappé', - palette: { - primary: { - main: '#ca9ee6', // Mauve - }, - secondary: { - main: '#292c3c', //Mantle - contrastText: '#c6d0f5', // Text - }, - type: 'dark', - background: { - default: '#303446', // Base - }, - }, - overrides: { - MuiPaper: { - root: { - color: '#c6d0f5', // Text - backgroundColor: '#292c3c', // Mantle - MuiSnackbarContent: { - root: { - color: '#c6d0f5', // Text - backgroundColor: '#e78284', // Red - }, - message: { - color: '#c6d0f5', // Text - backgroundColor: '#e78284', // Red - }, - }, - }, - }, - MuiButton: { - textPrimary: { - color: '#8caaee', // Blue - }, - textSecondary: { - color: '#c6d0f5', // Text - }, - }, - MuiChip: { - clickable: { - background: '#292c3c', //Mantle - }, - }, - MuiFormGroup: { - root: { - color: '#c6d0f5', // Text - }, - }, - MuiFormHelperText: { - root: { - Mui: { - error: { - color: '#e78284', // Red - }, - }, - }, - }, - MuiTableHead: { - root: { - color: '#c6d0f5', // Text - background: '#292c3c', //Mantle - }, - }, - MuiTableCell: { - root: { - color: '#c6d0f5', // Text - background: '#292c3c !important', //Mantle - }, - head: { - color: '#c6d0f5', // Text - background: '#292c3c !important', //Mantle - }, - }, - NDLogin: { - systemNameLink: { - color: '#ca9ee6', // Mauve - }, - icon: {}, - welcome: { - color: '#c6d0f5', // Text - }, - card: { - minWidth: 300, - background: '#303446', // Base - }, - avatar: {}, - button: { - boxShadow: '3px 3px 5px #303446', // Base - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: - 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', - }, - }, - }, - player: { - theme: 'dark', - stylesheet, - }, -} diff --git a/ui/src/themes/catppuccinLatte.css.js b/ui/src/themes/catppuccinLatte.css.js deleted file mode 100644 index 006601cd9..000000000 --- a/ui/src/themes/catppuccinLatte.css.js +++ /dev/null @@ -1,203 +0,0 @@ -const stylesheet = ` - .react-jinke-music-player-main.light-theme svg, - .react-jinke-music-player .music-player-controller, - .react-jinke-music-player .audio-circle-process-bar circle[class='stroke'] { - color: #6c6f85; /* subtext0 */ - stroke: #6c6f85; /* subtext0 */ - } - - .react-jinke-music-player-main svg:active, - .react-jinke-music-player-main svg:hover { - color: #7c7f93; /* overlay2 */ - } - - .react-jinke-music-player-main.light-theme svg:active, - .react-jinke-music-player-main.light-theme svg:hover { - color: #7c7f93; /* overlay2 */ - } - - .react-jinke-music-player-mobile-play-model-tip, - .react-jinke-music-player-main.light-theme .play-mode-title { - background-color: #6c6f85; /* subtext0 */ - color: #eff1f5; /* base */ - } - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #6c6f85; /* subtext0 */ - } - - .react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #6c6f85; /* subtext0 */ - } - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #6c6f85; /* subtext0 */ - } - - .react-jinke-music-player-main .audio-item.playing svg { - color: #6c6f85; /* subtext0 */ - } - - .react-jinke-music-player-main .audio-item.playing .player-singer { - color: #6c6f85 !important; - } - - .react-jinke-music-player-main .loading svg { - color: #6c6f85 !important; - } - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { - border: hidden; - box-shadow: - rgba(76, 79, 105, 0.12) 0px 4px 6px, - rgba(76, 79, 105, 0.08) 0px 5px 7px; - } - - .rc-slider-rail, - .rc-slider-track { - height: 6px; - } - - .rc-slider { - padding: 3px 0; - } - - .react-jinke-music-player-main.light-theme .rc-switch-checked { - background-color: #6c6f85 !important; - border: 1px solid #6c6f85; /* subtext0 */ - } - - .sound-operation > div:nth-child(4) { - transform: translateX(-50%) translateY(5%) !important; - } - - .sound-operation { - padding: 4px 0; - } - - .react-jinke-music-player-main .music-player-panel { - background-color: #e6e9ef; /* mantle */ - color: #4c4f69; /* text */ - box-shadow: 0 0 8px rgba(76, 79, 105, 0.15); - } - - .react-jinke-music-player-main.light-theme .music-player-panel { - color: #4c4f69; /* text */ - } - - .audio-lists-panel { - background-color: #e6e9ef; /* mantle */ - bottom: 6.25rem; - box-shadow: - rgba(76, 79, 105, 0.12) 0px 4px 6px, - rgba(76, 79, 105, 0.08) 0px 5px 7px; - } - - .audio-lists-panel-content .audio-item.playing { - background-color: rgba(0, 0, 0, 0); - } - - .audio-lists-panel-content .audio-item:nth-child(2n+1) { - background-color: rgba(0, 0, 0, 0); - } - - .audio-lists-panel-content .audio-item:active, - .audio-lists-panel-content .audio-item:hover { - background-color: rgba(76, 79, 105, 0.08); - } - - .audio-lists-panel-header { - border-bottom: 1px solid #ccd0da; /* surface0 */ - box-shadow: none; - } - - .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { - background-color: rgba(0, 0, 0, 0); - box-shadow: 0 0 0 0; - } - - .react-jinke-music-player-main.light-theme .audio-lists-panel-header { - background-color: #e6e9ef; /* mantle */ - color: #4c4f69; /* text */ - } - - .audio-lists-panel-content .audio-item { - line-height: 32px; - color: #4c4f69; /* text */ - } - - .react-jinke-music-player-main .music-player-panel .panel-content .img-content { - box-shadow: - rgba(76, 79, 105, 0.12) 0px 4px 6px, - rgba(76, 79, 105, 0.08) 0px 5px 7px; - } - - .react-jinke-music-player-main .music-player-lyric { - color: #6c6f85; /* subtext0 */ - -webkit-text-stroke: 0.35px #eff1f5; /* base */ - font-weight: bolder; - } - - .react-jinke-music-player-main .lyric-btn-active, - .react-jinke-music-player-main .lyric-btn-active svg { - color: #6c6f85 !important; - } - - .audio-lists-panel-content .audio-item.playing, - .audio-lists-panel-content .audio-item.playing svg { - color: #6c6f85; /* subtext0 */ - } - - .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, - .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { - color: #6c6f85; /* subtext0 */ - } - - .audio-lists-panel-content .audio-item .player-icons { - scale: 75%; - } - - .audio-lists-panel-content .audio-item:active, - .audio-lists-panel-content .audio-item:hover { - background-color: #dce0e8; /* surface1 */ - } - - /* Mobile */ - .react-jinke-music-player-mobile-cover { - border: none; - box-shadow: - rgba(76, 79, 105, 0.12) 0px 4px 6px, - rgba(76, 79, 105, 0.08) 0px 5px 7px; - } - - .react-jinke-music-player .music-player-controller { - border: none; - background-color: #e6e9ef; /* mantle */ - border-color: #e6e9ef; /* mantle */ - box-shadow: - rgba(76, 79, 105, 0.12) 0px 4px 6px, - rgba(76, 79, 105, 0.08) 0px 5px 7px; - color: #6c6f85; /* subtext0 */ - } - - .react-jinke-music-player .music-player-controller.music-player-playing:before { - border: 1px solid rgba(76, 79, 105, 0.18); - } - - .react-jinke-music-player .music-player-controller .music-player-controller-setting { - background: rgba(108, 111, 133, 0.2); - color: #eff1f5; /* base */ - } - - .react-jinke-music-player-mobile-progress .rc-slider-handle, - .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #6c6f85; /* subtext0 */ - } - - .react-jinke-music-player-mobile-progress .rc-slider-handle { - border: none; - } -` - -export default stylesheet diff --git a/ui/src/themes/catppuccinLatte.js b/ui/src/themes/catppuccinLatte.js deleted file mode 100644 index 3624cd853..000000000 --- a/ui/src/themes/catppuccinLatte.js +++ /dev/null @@ -1,104 +0,0 @@ -import stylesheet from './catppuccinLatte.css.js' - -export default { - themeName: 'Catppuccin Latte', - palette: { - primary: { main: '#8839ef' }, // mauve - secondary: { - main: '#ccd0da', // surface0 - contrastText: '#4c4f69', // text - }, - type: 'light', - background: { - default: '#eff1f5', // base - }, - }, - - overrides: { - MuiPaper: { - root: { - color: '#4c4f69', // text - backgroundColor: '#e6e9ef', // mantle - }, - }, - - MuiButton: { - textPrimary: { - color: '#1e66f5', // blue - }, - textSecondary: { - color: '#4c4f69', // text - }, - }, - - MuiChip: { - clickable: { - background: '#ccd0da', // surface0 - }, - }, - - MuiFormGroup: { - root: { - color: '#4c4f69', - }, - }, - - MuiFormHelperText: { - root: { - Mui: { - error: { - color: '#d20f39', // red - }, - }, - }, - }, - - MuiTableHead: { - root: { - color: '#4c4f69', - background: '#e6e9ef', - }, - }, - - MuiTableCell: { - root: { - color: '#4c4f69', - background: '#e6e9ef !important', - }, - head: { - color: '#4c4f69', - background: '#e6e9ef !important', - }, - }, - - NDLogin: { - systemNameLink: { - color: '#8839ef', // mauve - }, - icon: {}, - welcome: { - color: '#4c4f69', - }, - card: { - minWidth: 300, - background: '#eff1f5', - }, - avatar: {}, - button: { - boxShadow: '3px 3px 5px #ccd0da', - }, - }, - - NDMobileArtistDetails: { - bgContainer: { - background: - 'linear-gradient(to bottom, rgba(255 255 255 / 72%), rgb(239 241 245))!important', - }, - }, - }, - - player: { - theme: 'light', - stylesheet, - }, -} diff --git a/ui/src/themes/catppuccinMacchiato.css.js b/ui/src/themes/catppuccinMacchiato.css.js deleted file mode 100644 index e415a990b..000000000 --- a/ui/src/themes/catppuccinMacchiato.css.js +++ /dev/null @@ -1,158 +0,0 @@ -const stylesheet = ` - .react-jinke-music-player-main svg:active, - .react-jinke-music-player-main svg:hover { - color: #939ab7; /* overlay2 */ - } - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #a5adcb; /* subtext0 */ - } - - .react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #a5adcb; /* subtext0 */ - } - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #a5adcb; /* subtext0 */ - } - - .react-jinke-music-player-main .audio-item.playing svg { - color: #a5adcb; /* subtext0 */ - } - - .react-jinke-music-player-main .audio-item.playing .player-singer { - color: #a5adcb !important; - } - - .react-jinke-music-player-main .loading svg { - color: #a5adcb !important; - } - - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { - border: hidden; - box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - } - - - .rc-slider-rail, .rc-slider-track { - height: 6px; - } - - .rc-slider { - padding: 3px 0; - } - - .sound-operation > div:nth-child(4) { - transform: translateX(-50%) translateY(5%) !important; - } - - .sound-operation { - padding: 4px 0; - } - - .react-jinke-music-player-main .music-player-panel { - background-color: #1e2030; /* mantle */ - color: #cad3f5; /* text */ - box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); - } - - .audio-lists-panel { - background-color: #1e2030; /* mantle */ - bottom: 6.25rem; - box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - } - - .audio-lists-panel-content .audio-item.playing { - background-color: rgba(0, 0, 0, 0); - } - - .audio-lists-panel-content .audio-item:nth-child(2n+1) { - background-color: rgba(0, 0, 0, 0); - } - - .audio-lists-panel-content .audio-item:active, - .audio-lists-panel-content .audio-item:hover { - background-color:rgba(255, 255, 255, 0.08); - } - - .audio-lists-panel-header { - border-bottom:1px solid #363a4f; /* surface0 */ - } - - .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { - background-color:rgba(0,0,0,0); - box-shadow:0 0 0 0; - } - - .audio-lists-panel-content .audio-item { - line-height: 32px; - } - - .react-jinke-music-player-main .music-player-panel .panel-content .img-content { - box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - } - - .react-jinke-music-player-main .music-player-lyric { - color: #a5adcb; /* subtext0 */ - -webkit-text-stroke: 0.5px #1e1e2e; /* base */ - font-weight: bolder; - } - - .react-jinke-music-player-main .lyric-btn-active, - .react-jinke-music-player-main .lyric-btn-active svg { - color: #a5adcb !important; - } - - .audio-lists-panel-header { - border-bottom:1px solid rgba(0, 0, 0, 0.25); - box-shadow:none; - } - - .audio-lists-panel-content .audio-item.playing, - .audio-lists-panel-content .audio-item.playing svg { - color: #a5adcb; /* subtext0 */ - } - - .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, - .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { - color: #a5adcb; /* subtext0 */ - } - - .audio-lists-panel-content .audio-item .player-icons { - scale: 75%; - } - - .audio-lists-panel-content .audio-item:active, - .audio-lists-panel-content .audio-item:hover { - background-color: #494d64; /* surface1 */ - } - -/* Mobile */ - - .react-jinke-music-player-mobile-cover { - border: none; - box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - } - - .react-jinke-music-player .music-player-controller { - border: none; - box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - color: #a5adcb; /* subtext0 */ - } - - .react-jinke-music-player .music-player-controller .music-player-controller-setting { - color: rgba(122,160,236,.3); - } - - .react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #a5adcb; /* subtext0 */ - } - - .react-jinke-music-player-mobile-progress .rc-slider-handle { - border: none; - } -` - -export default stylesheet diff --git a/ui/src/themes/catppuccinMacchiato.js b/ui/src/themes/catppuccinMacchiato.js deleted file mode 100644 index b41a7b2ea..000000000 --- a/ui/src/themes/catppuccinMacchiato.js +++ /dev/null @@ -1,106 +0,0 @@ -import stylesheet from './catppuccinMacchiato.css.js' - -export default { - themeName: 'Catppuccin Macchiato', - palette: { - primary: { - main: '#c6a0f6', // Mauve - }, - secondary: { - main: '#1e2030', // Mantle - contrastText: '#cad3f5', // Text - }, - type: 'dark', - background: { - default: '#24273a', // Base - }, - }, - overrides: { - MuiPaper: { - root: { - color: '#cad3f5', // Text - backgroundColor: '#1e2030', // Mantle - MuiSnackbarContent: { - root: { - color: '#cad3f5', // Text - backgroundColor: '#ed8796', // Red - }, - message: { - color: '#cad3f5', // Text - backgroundColor: '#ed8796', // Red - }, - }, - }, - }, - MuiButton: { - textPrimary: { - color: '#8aadf4', // Blue - }, - textSecondary: { - color: '#cad3f5', // Text - }, - }, - MuiChip: { - clickable: { - background: '#1e2030', // Mantle - }, - }, - MuiFormGroup: { - root: { - color: '#cad3f5', // Text - }, - }, - MuiFormHelperText: { - root: { - Mui: { - error: { - color: '#ed8796', // Red - }, - }, - }, - }, - MuiTableHead: { - root: { - color: '#cad3f5', // Text - background: '#1e2030', // Mantle - }, - }, - MuiTableCell: { - root: { - color: '#cad3f5', // Text - background: '#1e2030 !important', // Mantle - }, - head: { - color: '#cad3f5', // Text - background: '#1e2030 !important', // Mantle - }, - }, - NDLogin: { - systemNameLink: { - color: '#c6a0f6', // Mauve - }, - icon: {}, - welcome: { - color: '#cad3f5', // Text - }, - card: { - minWidth: 300, - background: '#24273a', // Base - }, - avatar: {}, - button: { - boxShadow: '3px 3px 5px #24273a', // Base - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: - 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', - }, - }, - }, - player: { - theme: 'dark', - stylesheet, - }, -} diff --git a/ui/src/themes/catppuccinMocha.css.js b/ui/src/themes/catppuccinMocha.css.js deleted file mode 100644 index 7a238468a..000000000 --- a/ui/src/themes/catppuccinMocha.css.js +++ /dev/null @@ -1,158 +0,0 @@ -const stylesheet = ` - .react-jinke-music-player-main svg:active, - .react-jinke-music-player-main svg:hover { - color: #9399b2; /* overlay2 */ - } - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #a6adc8; /* subtext0 */ - } - - .react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #a6adc8; /* subtext0 */ - } - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #a6adc8; /* subtext0 */ - } - - .react-jinke-music-player-main .audio-item.playing svg { - color: #a6adc8; /* subtext0 */ - } - - .react-jinke-music-player-main .audio-item.playing .player-singer { - color: #a6adc8 !important; - } - - .react-jinke-music-player-main .loading svg { - color: #a6adc8 !important; - } - - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { - border: hidden; - box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - } - - - .rc-slider-rail, .rc-slider-track { - height: 6px; - } - - .rc-slider { - padding: 3px 0; - } - - .sound-operation > div:nth-child(4) { - transform: translateX(-50%) translateY(5%) !important; - } - - .sound-operation { - padding: 4px 0; - } - - .react-jinke-music-player-main .music-player-panel { - background-color: #181825; /* mantle */ - color: #cdd6f4; /* text */ - box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); - } - - .audio-lists-panel { - background-color: #181825; /* mantle */ - bottom: 6.25rem; - box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - } - - .audio-lists-panel-content .audio-item.playing { - background-color: rgba(0, 0, 0, 0); - } - - .audio-lists-panel-content .audio-item:nth-child(2n+1) { - background-color: rgba(0, 0, 0, 0); - } - - .audio-lists-panel-content .audio-item:active, - .audio-lists-panel-content .audio-item:hover { - background-color:rgba(255, 255, 255, 0.08); - } - - .audio-lists-panel-header { - border-bottom:1px solid #313244; /* surface0 */ - } - - .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { - background-color:rgba(0,0,0,0); - box-shadow:0 0 0 0; - } - - .audio-lists-panel-content .audio-item { - line-height: 32px; - } - - .react-jinke-music-player-main .music-player-panel .panel-content .img-content { - box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - } - - .react-jinke-music-player-main .music-player-lyric { - color: #a6adc8; /* subtext0 */ - -webkit-text-stroke: 0.5px #1e1e2e; /* base */ - font-weight: bolder; - } - - .react-jinke-music-player-main .lyric-btn-active, - .react-jinke-music-player-main .lyric-btn-active svg { - color: #a6adc8 !important; - } - - .audio-lists-panel-header { - border-bottom:1px solid rgba(0, 0, 0, 0.25); - box-shadow:none; - } - - .audio-lists-panel-content .audio-item.playing, - .audio-lists-panel-content .audio-item.playing svg { - color: #a6adc8; /* subtext0 */ - } - - .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, - .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { - color: #a6adc8; /* subtext0 */ - } - - .audio-lists-panel-content .audio-item .player-icons { - scale: 75%; - } - - .audio-lists-panel-content .audio-item:active, - .audio-lists-panel-content .audio-item:hover { - background-color: #45475a; /* surface1 */ - } - -/* Mobile */ - - .react-jinke-music-player-mobile-cover { - border: none; - box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - } - - .react-jinke-music-player .music-player-controller { - border: none; - box-shadow:rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - color: #a6adc8; /* subtext0 */ - } - - .react-jinke-music-player .music-player-controller .music-player-controller-setting { - color: rgba(122,160,236,.3); - } - - .react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #a6adc8; /* subtext0 */ - } - - .react-jinke-music-player-mobile-progress .rc-slider-handle { - border: none; - } -` - -export default stylesheet diff --git a/ui/src/themes/catppuccinMocha.js b/ui/src/themes/catppuccinMocha.js deleted file mode 100644 index bdfb33520..000000000 --- a/ui/src/themes/catppuccinMocha.js +++ /dev/null @@ -1,106 +0,0 @@ -import stylesheet from './catppuccinMocha.css.js' - -export default { - themeName: 'Catppuccin Mocha', - palette: { - primary: { - main: '#cba6f7', // Mauve - }, - secondary: { - main: '#181825', // Mantle - contrastText: '#cdd6f4', // Text - }, - type: 'dark', - background: { - default: '#1e1e2e', // Base - }, - }, - overrides: { - MuiPaper: { - root: { - color: '#cdd6f4', // Text - backgroundColor: '#181825', // Mantle - MuiSnackbarContent: { - root: { - color: '#cdd6f4', // Text - backgroundColor: '#f38ba8', // Red - }, - message: { - color: '#cdd6f4', // Text - backgroundColor: '#f38ba8', // Red - }, - }, - }, - }, - MuiButton: { - textPrimary: { - color: '#89b4fa', // Blue - }, - textSecondary: { - color: '#cdd6f4', // Text - }, - }, - MuiChip: { - clickable: { - background: '#181825', // Mantle - }, - }, - MuiFormGroup: { - root: { - color: '#cdd6f4', // Text - }, - }, - MuiFormHelperText: { - root: { - Mui: { - error: { - color: '#f38ba8', // Red - }, - }, - }, - }, - MuiTableHead: { - root: { - color: '#cdd6f4', // Text - background: '#181825', // Mantle - }, - }, - MuiTableCell: { - root: { - color: '#cdd6f4', // Text - background: '#181825 !important', // Mantle - }, - head: { - color: '#cdd6f4', // Text - background: '#181825 !important', // Mantle - }, - }, - NDLogin: { - systemNameLink: { - color: '#cba6f7', // Mauve - }, - icon: {}, - welcome: { - color: '#cdd6f4', // Text - }, - card: { - minWidth: 300, - background: '#1e1e2e', // Base - }, - avatar: {}, - button: { - boxShadow: '3px 3px 5px #1e1e2e', // Base - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: - 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', - }, - }, - }, - player: { - theme: 'dark', - stylesheet, - }, -} diff --git a/ui/src/themes/dark.css.js b/ui/src/themes/dark.css.js index 7cbb19c7a..4c78905e4 100644 --- a/ui/src/themes/dark.css.js +++ b/ui/src/themes/dark.css.js @@ -1,4 +1,4 @@ -const stylesheet = ` +module.exports = ` .react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { color: #7171d5 @@ -30,6 +30,5 @@ const stylesheet = ` .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { color: #5f5fc4 } -` -export default stylesheet +` diff --git a/ui/src/themes/dark.js b/ui/src/themes/dark.js index 15d8aa365..69cdec053 100644 --- a/ui/src/themes/dark.js +++ b/ui/src/themes/dark.js @@ -1,5 +1,4 @@ import blue from '@material-ui/core/colors/blue' -import stylesheet from './dark.css.js' export default { themeName: 'Dark', @@ -16,11 +15,6 @@ export default { color: 'white', }, }, - MuiButton: { - textPrimary: { - color: '#fff', - }, - }, NDLogin: { systemNameLink: { color: '#0085ff', @@ -47,6 +41,6 @@ export default { }, player: { theme: 'dark', - stylesheet, + stylesheet: require('./dark.css.js'), }, } diff --git a/ui/src/themes/dracula.css.js b/ui/src/themes/dracula.css.js deleted file mode 100644 index 0e837be24..000000000 --- a/ui/src/themes/dracula.css.js +++ /dev/null @@ -1,181 +0,0 @@ -const stylesheet = ` - -/* Icon hover: pink */ -.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { - color: #ff79c6 -} - -/* Progress bar: purple */ -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #bd93f9 -} - -/* Volume bar: green */ -.sound-operation .rc-slider-handle, .sound-operation .rc-slider-track { - background-color: #50fa7b !important -} - -.sound-operation .rc-slider-handle:active { - box-shadow: 0 0 2px #50fa7b !important -} - -/* Scrollbar: comment */ -.react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #6272a4; -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #bd93f9 -} - -/* Now playing icon: cyan */ -.react-jinke-music-player-main .audio-item.playing svg { - color: #8be9fd -} - -/* Now playing artist: cyan */ -.react-jinke-music-player-main .audio-item.playing .player-singer { - color: #8be9fd !important -} - -/* Loading spinner: orange */ -.react-jinke-music-player-main .loading svg { - color: #ffb86c !important -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { - border: hidden; - box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px; -} - -.rc-slider-rail, .rc-slider-track { - height: 6px; -} - -.rc-slider { - padding: 3px 0; -} - -.sound-operation > div:nth-child(4) { - transform: translateX(-50%) translateY(5%) !important; -} - -.sound-operation { - padding: 4px 0; -} - -/* Player panel background */ -.react-jinke-music-player-main .music-player-panel { - background-color: #282a36; - color: #f8f8f2; - box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); -} - -/* Song title in player: foreground */ -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-title { - color: #f8f8f2; -} - -/* Duration/time text: yellow */ -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .duration, .react-jinke-music-player-main .music-player-panel .panel-content .player-content .current-time { - color: #f1fa8c -} - -/* Audio list panel */ -.audio-lists-panel { - background-color: #282a36; - bottom: 6.25rem; - box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px; -} - -.audio-lists-panel-content .audio-item.playing { - background-color: transparent; -} - -.audio-lists-panel-content .audio-item:nth-child(2n+1) { - background-color: transparent; -} - -/* Playlist hover: current line */ -.audio-lists-panel-content .audio-item:active, -.audio-lists-panel-content .audio-item:hover { - background-color: #44475a; -} - -.audio-lists-panel-header { - border-bottom: 1px solid rgba(0, 0, 0, 0.25); - box-shadow: none; -} - -/* Playlist header text: orange */ -.audio-lists-panel-header-title { - color: #ffb86c; -} - -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { - background-color: transparent; - box-shadow: none; -} - -.audio-lists-panel-content .audio-item { - line-height: 32px; -} - -.react-jinke-music-player-main .music-player-panel .panel-content .img-content { - box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px; -} - -/* Lyrics: yellow */ -.react-jinke-music-player-main .music-player-lyric { - color: #f1fa8c; - -webkit-text-stroke: 0.5px #282a36; - font-weight: bolder; -} - -/* Lyric button active: yellow */ -.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { - color: #f1fa8c !important; -} - -/* Playlist now playing: cyan */ -.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { - color: #8be9fd -} - -/* Playlist hover icons: pink */ -.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { - color: #ff79c6 -} - -.audio-lists-panel-content .audio-item .player-icons { - scale: 75%; -} - -/* Mobile */ - -.react-jinke-music-player-mobile-cover { - border: none; - box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px; -} - -.react-jinke-music-player .music-player-controller { - border: none; - box-shadow: rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px; - color: #bd93f9; -} - -.react-jinke-music-player .music-player-controller .music-player-controller-setting { - color: rgba(189, 147, 249, 0.3); -} - -/* Mobile progress: green */ -.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #50fa7b; -} - -.react-jinke-music-player-mobile-progress .rc-slider-handle { - border: none; -} -` - -export default stylesheet diff --git a/ui/src/themes/dracula.js b/ui/src/themes/dracula.js deleted file mode 100644 index 45559c3af..000000000 --- a/ui/src/themes/dracula.js +++ /dev/null @@ -1,387 +0,0 @@ -import stylesheet from './dracula.css.js' - -// Dracula color palette -const background = '#282a36' -const currentLine = '#44475a' -const foreground = '#f8f8f2' -const comment = '#6272a4' -const cyan = '#8be9fd' -const green = '#50fa7b' -const pink = '#ff79c6' -const purple = '#bd93f9' -const orange = '#ffb86c' -const red = '#ff5555' -const yellow = '#f1fa8c' - -// Darker shade for surfaces -const surface = '#21222c' - -// For Album, Playlist play button -const musicListActions = { - alignItems: 'center', - '@global': { - 'button:first-child:not(:only-child)': { - '@media screen and (max-width: 720px)': { - transform: 'scale(1.5)', - margin: '1rem', - '&:hover': { - transform: 'scale(1.6) !important', - }, - }, - transform: 'scale(2)', - margin: '1.5rem', - minWidth: 0, - padding: 5, - transition: 'transform .3s ease', - backgroundColor: `${green} !important`, - color: background, - borderRadius: 500, - border: 0, - '&:hover': { - transform: 'scale(2.1)', - backgroundColor: `${green} !important`, - border: 0, - }, - }, - 'button:only-child': { - margin: '1.5rem', - }, - 'button:first-child>span:first-child': { - padding: 0, - }, - 'button:first-child>span:first-child>span': { - display: 'none', - }, - 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': - { - color: foreground, - }, - }, -} - -export default { - themeName: 'Dracula', - palette: { - primary: { - main: purple, - }, - secondary: { - main: currentLine, - contrastText: foreground, - }, - error: { - main: red, - }, - type: 'dark', - background: { - default: background, - paper: surface, - }, - }, - overrides: { - MuiPaper: { - root: { - color: foreground, - backgroundColor: surface, - }, - }, - MuiAppBar: { - positionFixed: { - backgroundColor: `${currentLine} !important`, - boxShadow: - 'rgba(20, 21, 28, 0.25) 0px 4px 6px, rgba(20, 21, 28, 0.1) 0px 5px 7px', - }, - }, - MuiDrawer: { - root: { - background: background, - }, - }, - MuiButton: { - textPrimary: { - color: purple, - }, - textSecondary: { - color: foreground, - }, - }, - MuiIconButton: { - root: { - color: foreground, - }, - }, - MuiChip: { - root: { - backgroundColor: currentLine, - }, - }, - MuiFormGroup: { - root: { - color: foreground, - }, - }, - MuiFormLabel: { - root: { - color: comment, - '&$focused': { - color: purple, - }, - }, - }, - MuiToolbar: { - root: { - backgroundColor: `${surface} !important`, - }, - }, - MuiOutlinedInput: { - root: { - '& $notchedOutline': { - borderColor: currentLine, - }, - '&:hover $notchedOutline': { - borderColor: comment, - }, - '&$focused $notchedOutline': { - borderColor: purple, - }, - }, - }, - MuiFilledInput: { - root: { - backgroundColor: currentLine, - '&:hover': { - backgroundColor: comment, - }, - '&$focused': { - backgroundColor: currentLine, - }, - }, - }, - MuiTableRow: { - root: { - transition: 'background-color .3s ease', - '&:hover': { - backgroundColor: `${currentLine} !important`, - }, - }, - }, - MuiTableHead: { - root: { - color: foreground, - background: surface, - }, - }, - MuiTableCell: { - root: { - color: foreground, - background: `${surface} !important`, - borderBottom: `1px solid ${currentLine}`, - }, - head: { - color: `${yellow} !important`, - background: `${currentLine} !important`, - }, - body: { - color: `${foreground} !important`, - }, - }, - NDAlbumGridView: { - albumName: { - marginTop: '0.5rem', - fontWeight: 700, - color: foreground, - }, - albumSubtitle: { - color: comment, - }, - albumContainer: { - backgroundColor: surface, - borderRadius: '8px', - padding: '.75rem', - transition: 'background-color .3s ease', - '&:hover': { - backgroundColor: currentLine, - }, - }, - albumPlayButton: { - backgroundColor: green, - borderRadius: '50%', - boxShadow: '0 8px 8px rgb(0 0 0 / 30%)', - padding: '0.35rem', - transition: 'padding .3s ease', - '&:hover': { - background: `${green} !important`, - padding: '0.45rem', - }, - }, - }, - NDPlaylistDetails: { - container: { - background: `linear-gradient(${currentLine}, transparent)`, - borderRadius: 0, - paddingTop: '2.5rem !important', - boxShadow: 'none', - }, - title: { - fontWeight: 700, - color: foreground, - }, - details: { - fontSize: '.875rem', - color: comment, - }, - }, - NDAlbumDetails: { - root: { - background: `linear-gradient(${currentLine}, transparent)`, - borderRadius: 0, - boxShadow: 'none', - }, - cardContents: { - alignItems: 'center', - paddingTop: '1.5rem', - }, - recordName: { - fontWeight: 700, - color: foreground, - }, - recordArtist: { - fontSize: '.875rem', - fontWeight: 700, - color: pink, - }, - recordMeta: { - fontSize: '.875rem', - color: comment, - }, - }, - NDCollapsibleComment: { - commentBlock: { - fontSize: '.875rem', - color: comment, - }, - }, - NDAlbumShow: { - albumActions: musicListActions, - }, - NDPlaylistShow: { - playlistActions: musicListActions, - }, - NDAudioPlayer: { - audioTitle: { - color: foreground, - fontSize: '0.875rem', - }, - songTitle: { - fontWeight: 400, - }, - songInfo: { - fontSize: '0.675rem', - color: comment, - }, - }, - NDLogin: { - systemNameLink: { - color: purple, - }, - welcome: { - color: foreground, - }, - card: { - minWidth: 300, - background: background, - }, - button: { - boxShadow: '3px 3px 5px #191a21', - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: `linear-gradient(to bottom, rgba(40 42 54 / 72%), ${surface})!important`, - }, - }, - RaLayout: { - content: { - padding: '0 !important', - background: surface, - }, - root: { - backgroundColor: background, - }, - }, - RaList: { - content: { - backgroundColor: surface, - }, - }, - RaListToolbar: { - toolbar: { - backgroundColor: background, - padding: '0 .55rem !important', - }, - }, - RaSidebar: { - fixed: { - backgroundColor: background, - }, - drawerPaper: { - backgroundColor: `${background} !important`, - }, - }, - MuiTableSortLabel: { - root: { - color: `${yellow} !important`, - '&:hover': { - color: `${orange} !important`, - }, - '&$active': { - color: `${orange} !important`, - '&& $icon': { - color: `${orange} !important`, - }, - }, - }, - }, - RaMenuItemLink: { - root: { - color: foreground, - '&[aria-current="page"]': { - color: `${pink} !important`, - }, - '&[aria-current="page"] .MuiListItemIcon-root': { - color: `${pink} !important`, - }, - }, - active: { - color: `${pink} !important`, - '& .MuiListItemIcon-root': { - color: `${pink} !important`, - }, - }, - }, - RaLink: { - link: { - color: cyan, - }, - }, - RaButton: { - button: { - margin: '0 5px 0 5px', - }, - }, - RaPaginationActions: { - currentPageButton: { - border: `2px solid ${purple}`, - }, - button: { - backgroundColor: currentLine, - minWidth: 48, - margin: '0 4px', - }, - }, - }, - player: { - theme: 'dark', - stylesheet, - }, -} diff --git a/ui/src/themes/electricPurple.css.js b/ui/src/themes/electricPurple.css.js index fd64d17a8..5cfeb71fd 100644 --- a/ui/src/themes/electricPurple.css.js +++ b/ui/src/themes/electricPurple.css.js @@ -1,4 +1,4 @@ -const stylesheet = ` +module.exports = ` .react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { color: #bd4aff; @@ -34,6 +34,6 @@ const stylesheet = ` .react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { background-color: #8800cb; } -` -export default stylesheet + +` diff --git a/ui/src/themes/electricPurple.js b/ui/src/themes/electricPurple.js index 108c8e529..7431b492e 100644 --- a/ui/src/themes/electricPurple.js +++ b/ui/src/themes/electricPurple.js @@ -1,5 +1,3 @@ -import stylesheet from './electricPurple.css.js' - export default { themeName: 'Electric Purple', palette: { @@ -52,6 +50,6 @@ export default { }, player: { theme: 'dark', - stylesheet, + stylesheet: require('./electricPurple.css.js'), }, } diff --git a/ui/src/themes/extradark.js b/ui/src/themes/extradark.js index ef7140338..04cdc3c76 100644 --- a/ui/src/themes/extradark.js +++ b/ui/src/themes/extradark.js @@ -1,5 +1,4 @@ import blue from '@material-ui/core/colors/blue' -import stylesheet from './dark.css.js' export default { themeName: 'Extra Dark', @@ -39,6 +38,6 @@ export default { player: { theme: 'dark', - stylesheet, + stylesheet: require('./dark.css.js'), }, } diff --git a/ui/src/themes/gruvboxDark.css.js b/ui/src/themes/gruvboxDark.css.js index f482451b2..b7dbfec83 100644 --- a/ui/src/themes/gruvboxDark.css.js +++ b/ui/src/themes/gruvboxDark.css.js @@ -1,11 +1,11 @@ -const stylesheet = ` +module.exports = ` .react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { color: #458588 } .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #ebdbb2 + background-color: #458588 } .react-jinke-music-player-main ::-webkit-scrollbar-thumb { @@ -50,13 +50,5 @@ const stylesheet = ` .MuiCheckbox-colorSecondary.Mui-checked { color: #458588 !important } -.react-jinke-music-player-main .music-player-panel svg { - color: #ebdbb2; - fill: #ebdbb2; -} -.react-jinke-music-player-main .music-player-panel button { - color: #ebdbb2; -} -` -export default stylesheet +` diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js index 3e2955dcd..36dd09a0b 100644 --- a/ui/src/themes/gruvboxDark.js +++ b/ui/src/themes/gruvboxDark.js @@ -1,5 +1,3 @@ -import stylesheet from './gruvboxDark.css.js' - export default { themeName: 'Gruvbox Dark', palette: { @@ -14,34 +12,22 @@ export default { background: { default: '#282828', }, - text: { - primary: '#ebdbb2', - secondary: '#a89984', - }, }, overrides: { MuiPaper: { root: { color: '#ebdbb2', backgroundColor: '#3c3836', - }, - }, - MuiSnackbarContent: { - root: { - color: '#3c3836', - backgroundColor: '#a89984', - }, - message: { - color: '#3c3836', - backgroundColor: '#a89984', - }, - }, - MuiTypography: { - root: { - color: '#ebdbb2', - }, - colorTextSecondary: { - color: '#a89984', + MuiSnackbarContent: { + root: { + color: '#ebdbb2', + backgroundColor: '#cc241d', + }, + message: { + color: '#ebdbb2', + backgroundColor: '#cc241d', + }, + }, }, }, MuiButton: { @@ -52,24 +38,6 @@ export default { color: '#ebdbb2', }, }, - MuiIconButton: { - root: { - color: '#ebdbb2', - }, - }, - MuiListItemIcon: { - root: { - color: '#ebdbb2', - }, - }, - MuiListItemText: { - primary: { - color: '#ebdbb2', - }, - secondary: { - color: '#a89984', - }, - }, MuiChip: { clickable: { background: '#49483e', @@ -82,10 +50,11 @@ export default { }, MuiFormHelperText: { root: { - color: '#ebdbb2', - }, - error: { - color: '#cc241d', + Mui: { + error: { + color: '#cc241d', + }, + }, }, }, MuiTableHead: { @@ -127,20 +96,9 @@ export default { 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', }, }, - NDAlbumGridView: { - albumName: { - marginTop: '0.5rem', - fontWeight: 700, - textTransform: 'none', - color: '#ebdbb2', - }, - albumSubtitle: { - color: '#a89984', - }, - }, }, player: { theme: 'dark', - stylesheet, + stylesheet: require('./gruvboxDark.css.js'), }, } diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index 391a97f2c..ee0cdf6e2 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -8,23 +8,6 @@ import MonokaiTheme from './monokai' import ElectricPurpleTheme from './electricPurple' import NordTheme from './nord' import GruvboxDarkTheme from './gruvboxDark' -import CatppuccinFrappeTheme from './catppuccinFrappe' -import CatppuccinMacchiatoTheme from './catppuccinMacchiato' -import CatppuccinMochaTheme from './catppuccinMocha' -import CatppuccinLatteTheme from './catppuccinLatte' -import DraculaTheme from './dracula' -import NuclearTheme from './nuclear' -import NutballTheme from './nutball' -import RosePineTheme from './rosePine' -import RosePineDawnTheme from './rosePineDawn' -import RosePineMoonTheme from './rosePineMoon' -import AmusicTheme from './amusic' -import SquiddiesGlassTheme from './SquiddiesGlass' -import NautilineTheme from './nautiline' -import MoonbaseAlphaTheme from './moonbaseAlpha' -import MoonbaseBravoTheme from './moonbaseBravo' -import TokyoNightLightTheme from './tokyoNightLight' -import TokyoNightTheme from './tokyoNight' export default { // Classic default themes @@ -32,29 +15,12 @@ export default { DarkTheme, // New themes should be added here, in alphabetic order - AmusicTheme, - CatppuccinFrappeTheme, - CatppuccinLatteTheme, - CatppuccinMacchiatoTheme, - CatppuccinMochaTheme, - DraculaTheme, ElectricPurpleTheme, ExtraDarkTheme, GreenTheme, GruvboxDarkTheme, LigeraTheme, MonokaiTheme, - MoonbaseAlphaTheme, - MoonbaseBravoTheme, - NautilineTheme, NordTheme, - NuclearTheme, - NutballTheme, - RosePineDawnTheme, - RosePineMoonTheme, - RosePineTheme, SpotifyTheme, - SquiddiesGlassTheme, - TokyoNightLightTheme, - TokyoNightTheme, } diff --git a/ui/src/themes/ligera.js b/ui/src/themes/ligera.js index 363a379bc..c339d8836 100644 --- a/ui/src/themes/ligera.js +++ b/ui/src/themes/ligera.js @@ -70,7 +70,7 @@ export default { }, background: { default: '#f0f2f5', - paper: bLight['500'], + paper: 'inherit', }, text: { secondary: '#232323', @@ -115,7 +115,7 @@ export default { }, MuiFormGroup: { root: { - color: '#464646', + color: bLight['500'], }, }, MuiMenuItem: { @@ -300,8 +300,6 @@ export default { fontSize: '.875rem', color: 'rgb(113 113 113 / 80%)', }, - }, - NDCollapsibleComment: { commentBlock: { fontSize: '.875rem', color: 'rgb(113 113 113 / 80%)', @@ -448,28 +446,15 @@ export default { backgroundColor: bLight['500'], }, }, - RaButton: { - button: { - margin: '0 5px 0 5px', - }, - }, RaPaginationActions: { button: { - backgroundColor: '#fff', - color: '#000', + backgroundColor: 'inherit', minWidth: 48, margin: '0 4px', - border: '1px solid #cccccc', + border: '1px solid #282828', '@global': { '> .MuiButton-label': { padding: 0, - color: '#656565', - '&:hover': { - color: '#fff !important', - }, - }, - '> .MuiButton-label > svg': { - color: '#656565', }, }, }, diff --git a/ui/src/themes/light.css.js b/ui/src/themes/light.css.js index a084cf146..f4ab24a02 100644 --- a/ui/src/themes/light.css.js +++ b/ui/src/themes/light.css.js @@ -1,4 +1,4 @@ -const stylesheet = ` +module.exports = ` .react-jinke-music-player-main.light-theme .loading svg { color: #5f5fc4; font-size: 24px @@ -118,5 +118,3 @@ const stylesheet = ` color: #5f5fc4 !important } ` - -export default stylesheet diff --git a/ui/src/themes/light.js b/ui/src/themes/light.js index 63a32bf1a..ee1737fd6 100644 --- a/ui/src/themes/light.js +++ b/ui/src/themes/light.js @@ -1,5 +1,3 @@ -import stylesheet from './light.css.js' - export default { themeName: 'Light', palette: { @@ -57,6 +55,6 @@ export default { }, player: { theme: 'light', - stylesheet, + stylesheet: require('./light.css.js'), }, } diff --git a/ui/src/themes/monokai.css.js b/ui/src/themes/monokai.css.js index 12227c901..5aac9a016 100644 --- a/ui/src/themes/monokai.css.js +++ b/ui/src/themes/monokai.css.js @@ -1,4 +1,4 @@ -const stylesheet = ` +module.exports = ` .react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { color: #fd971f @@ -46,6 +46,5 @@ const stylesheet = ` .progress-bar-content .audio-title a { color: #f8f8f2 } -` -export default stylesheet +` diff --git a/ui/src/themes/monokai.js b/ui/src/themes/monokai.js index 9e4dd7ffa..7bf5ee299 100644 --- a/ui/src/themes/monokai.js +++ b/ui/src/themes/monokai.js @@ -1,5 +1,3 @@ -import stylesheet from './monokai.css.js' - export default { themeName: 'Monokai', palette: { @@ -101,6 +99,6 @@ export default { }, player: { theme: 'dark', - stylesheet, + stylesheet: require('./monokai.css.js'), }, } diff --git a/ui/src/themes/moonbaseAlpha.css.js b/ui/src/themes/moonbaseAlpha.css.js deleted file mode 100644 index 757cfc03c..000000000 --- a/ui/src/themes/moonbaseAlpha.css.js +++ /dev/null @@ -1,63 +0,0 @@ -const stylesheet = ` - -.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { - color: #9a7420 -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #b8862e; - border-color: #9a7420 -} - -.react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #c9b896; -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #b8862e -} - -.react-jinke-music-player-main .audio-item.playing svg { - color: #9a7420 -} - -.react-jinke-music-player-main .audio-item.playing .player-singer { - color: #9a7420 !important -} - -.react-jinke-music-player-main .rc-slider-rail { - background-color: #ddd7cc !important -} - -.react-jinke-music-player-main .lyric-btn { - color: #1a1917 !important -} - -.react-jinke-music-player-main .music-player-panel { - color: #1a1917 !important -} - -.react-jinke-music-player-main .lyric-btn-active svg { - color: #9a7420 !important -} - -.music-player-lyric { - color: #9a7420 !important -} - -.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { - color: #9a7420 -} -.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { - color: #9a7420 -} - -.progress-bar-content .audio-title a { - color: #1a1917 -} - -.MuiCheckbox-colorSecondary.Mui-checked { - color: #b8862e !important -} -` -export default stylesheet diff --git a/ui/src/themes/moonbaseAlpha.js b/ui/src/themes/moonbaseAlpha.js deleted file mode 100644 index 51d8a2696..000000000 --- a/ui/src/themes/moonbaseAlpha.js +++ /dev/null @@ -1,90 +0,0 @@ -import stylesheet from './moonbaseAlpha.css.js' - -export default { - themeName: 'Moonbase - Alpha', - palette: { - primary: { - main: '#9a7420', - }, - secondary: { - main: '#ede8df', - contrastText: '#1a1917', - }, - type: 'light', - background: { - default: '#f5f0e8', - }, - }, - overrides: { - MuiPaper: { - root: { - color: '#1a1917', - backgroundColor: '#faf8f4', - }, - }, - MuiButton: { - textPrimary: { - color: '#9a7420', - }, - textSecondary: { - color: '#1a1917', - }, - }, - MuiChip: { - clickable: { - background: '#ede8df', - }, - }, - MuiFormGroup: { - root: { - color: '#1a1917', - }, - }, - MuiFormHelperText: { - error: { - color: '#b04a2e', - }, - }, - MuiTableHead: { - root: { - color: '#6b635a', - background: '#f5f0e8 !important', - }, - }, - MuiTableCell: { - root: { - color: '#1a1917', - background: '#faf8f4 !important', - }, - head: { - color: '#6b635a', - background: '#f5f0e8 !important', - }, - }, - NDLogin: { - systemNameLink: { - color: '#9a7420', - }, - welcome: { - color: '#1a1917', - }, - card: { - minWidth: 300, - background: '#faf8f4', - }, - button: { - boxShadow: '3px 3px 5px rgba(0, 0, 0, 0.12)', - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: - 'linear-gradient(to bottom, rgba(245, 240, 232, 0.72), #faf8f4)!important', - }, - }, - }, - player: { - theme: 'light', - stylesheet, - }, -} diff --git a/ui/src/themes/moonbaseBravo.css.js b/ui/src/themes/moonbaseBravo.css.js deleted file mode 100644 index 580b054cc..000000000 --- a/ui/src/themes/moonbaseBravo.css.js +++ /dev/null @@ -1,63 +0,0 @@ -const stylesheet = ` - -.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { - color: #d4a039 -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #d4a039; - border-color: #b8862e -} - -.react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #d4a039; -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #d4a039 -} - -.react-jinke-music-player-main .audio-item.playing svg { - color: #d4a039 -} - -.react-jinke-music-player-main .audio-item.playing .player-singer { - color: #d4a039 !important -} - -.react-jinke-music-player-main .rc-slider-rail { - background-color: #2a2a27 !important -} - -.react-jinke-music-player-main .lyric-btn { - color: #e5ddd3 !important -} - -.react-jinke-music-player-main .music-player-panel { - color: #e5ddd3 !important -} - -.react-jinke-music-player-main .lyric-btn-active svg { - color: #d4a039 !important -} - -.music-player-lyric { - color: #d4a039 !important -} - -.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { - color: #d4a039 -} -.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { - color: #d4a039 -} - -.progress-bar-content .audio-title a { - color: #e5ddd3 -} - -.MuiCheckbox-colorSecondary.Mui-checked { - color: #d4a039 !important -} -` -export default stylesheet diff --git a/ui/src/themes/moonbaseBravo.js b/ui/src/themes/moonbaseBravo.js deleted file mode 100644 index 87585df29..000000000 --- a/ui/src/themes/moonbaseBravo.js +++ /dev/null @@ -1,90 +0,0 @@ -import stylesheet from './moonbaseBravo.css.js' - -export default { - themeName: 'Moonbase - Bravo', - palette: { - primary: { - main: '#d4a039', - }, - secondary: { - main: '#1e1e1c', - contrastText: '#e5ddd3', - }, - type: 'dark', - background: { - default: '#0a0a09', - }, - }, - overrides: { - MuiPaper: { - root: { - color: '#e5ddd3', - backgroundColor: '#141413', - }, - }, - MuiButton: { - textPrimary: { - color: '#d4a039', - }, - textSecondary: { - color: '#e5ddd3', - }, - }, - MuiChip: { - clickable: { - background: '#1e1e1c', - }, - }, - MuiFormGroup: { - root: { - color: '#e5ddd3', - }, - }, - MuiFormHelperText: { - error: { - color: '#c45c3c', - }, - }, - MuiTableHead: { - root: { - color: '#8a8278', - background: '#0a0a09 !important', - }, - }, - MuiTableCell: { - root: { - color: '#e5ddd3', - background: '#141413 !important', - }, - head: { - color: '#8a8278', - background: '#0a0a09 !important', - }, - }, - NDLogin: { - systemNameLink: { - color: '#d4a039', - }, - welcome: { - color: '#e5ddd3', - }, - card: { - minWidth: 300, - background: '#1e1e1c', - }, - button: { - boxShadow: '3px 3px 5px #0a0a09', - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: - 'linear-gradient(to bottom, rgba(10, 10, 9, 0.72), #141413)!important', - }, - }, - }, - player: { - theme: 'dark', - stylesheet, - }, -} diff --git a/ui/src/themes/nautiline.js b/ui/src/themes/nautiline.js deleted file mode 100644 index 9194ad7a0..000000000 --- a/ui/src/themes/nautiline.js +++ /dev/null @@ -1,903 +0,0 @@ -/** - * Nautiline Theme for Navidrome - * Light theme inspired by the Nautiline iOS app - */ - -// ============================================ -// CONFIGURATION -// ============================================ - -const ACCENT_COLOR = '#009688' // Material teal -const UNBOUNDED_FONT_PATH = 'fonts/Unbounded-Variable.woff2' - -// ============================================ -// DESIGN TOKENS -// ============================================ - -const hexToRgb = (hex) => { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) - return result - ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16), - } - : null -} - -const rgb = hexToRgb(ACCENT_COLOR) -const rgba = (alpha) => - rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})` : 'transparent' - -const tokens = { - colors: { - accent: { - main: ACCENT_COLOR, - faded: rgba(0.1), - hover: rgba(0.15), - }, - background: { - primary: '#FFFFFF', - secondary: '#F5F5F7', - tertiary: '#E5E5EA', - }, - text: { - primary: '#1A1A1A', - secondary: '#8E8E93', - tertiary: '#AEAEB2', - }, - ui: { - separator: 'rgba(0, 0, 0, 0.08)', - shadow: 'rgba(0, 0, 0, 0.04)', - glassBg: 'rgba(255, 255, 255, 0.72)', - }, - }, - typography: { - fontFamily: { - base: [ - '-apple-system', - 'BlinkMacSystemFont', - '"SF Pro Text"', - '"Helvetica Neue"', - 'Arial', - 'sans-serif', - ].join(','), - heading: '"Unbounded", sans-serif', - }, - fontFace: ` - @font-face { - font-family: 'Unbounded'; - font-style: normal; - font-weight: 300 800; - font-display: swap; - src: url('${UNBOUNDED_FONT_PATH}') format('woff2'); - } - `, - }, - spacing: { - xs: '0.25rem', - sm: '0.5rem', - md: '0.75rem', - lg: '1rem', - xl: '1.5rem', - }, - radii: { - sm: '0.25rem', - md: '0.5rem', - lg: '0.625rem', - xl: '0.75rem', - full: '50%', - pill: '1rem', - }, - breakpoints: { - xs: 599, - sm: 600, - md: 720, - lg: 1280, - }, - sizing: { - cover: { - sm: '14em', - lg: '18em', - }, - icon: '1.25rem', - iconMinWidth: '2.5rem', - }, - blur: '1.25rem', -} - -const { colors, typography, spacing, radii, sizing, breakpoints } = tokens - -// ============================================ -// REUSABLE STYLE FACTORIES -// ============================================ - -const headingStyle = (weight, letterSpacing) => ({ - fontFamily: typography.fontFamily.heading, - fontWeight: weight, - ...(letterSpacing && { letterSpacing }), -}) - -const coverSizing = () => ({ - [`@media (min-width: ${breakpoints.sm}px)`]: { - height: sizing.cover.sm, - width: sizing.cover.sm, - minWidth: sizing.cover.sm, - }, - [`@media (min-width: ${breakpoints.lg}px)`]: { - height: sizing.cover.lg, - width: sizing.cover.lg, - minWidth: sizing.cover.lg, - }, -}) - -const customTooltipStyle = () => ({ - display: 'inline', - position: 'absolute', - bottom: '100%', - left: '50%', - transform: 'translateX(-50%)', - marginBottom: spacing.xs, - fontSize: '0.75rem', - whiteSpace: 'nowrap', - backgroundColor: colors.text.primary, - color: colors.background.primary, - padding: `${spacing.xs} ${spacing.sm}`, - borderRadius: radii.sm, - zIndex: 9999, -}) - -const actionButtonsStyle = () => ({ - padding: `${spacing.lg} 0`, - alignItems: 'center', - '@global': { - button: { - border: '1px solid transparent', - backgroundColor: colors.background.secondary, - color: colors.text.secondary, - margin: `0 ${spacing.sm}`, - borderRadius: radii.full, - minWidth: 0, - padding: spacing.lg, - position: 'relative', - '&:hover': { - backgroundColor: `${colors.background.tertiary} !important`, - border: '1px solid transparent', - }, - }, - 'button:first-child:not(:only-child)': { - [`@media screen and (max-width: ${breakpoints.md}px)`]: { - transform: 'scale(1.5)', - margin: spacing.lg, - '&:hover': { - transform: 'scale(1.6) !important', - }, - }, - transform: 'scale(2)', - margin: spacing.xl, - minWidth: 0, - padding: '0.3125rem', - transition: 'transform .3s ease', - background: colors.accent.main, - color: '#fff', - borderRadius: radii.full, - border: 0, - '&:hover': { - transform: 'scale(2.1)', - backgroundColor: `${colors.accent.main} !important`, - border: 0, - }, - }, - 'button:only-child': { - margin: spacing.xl, - }, - 'button:first-child>span:first-child': { - padding: 0, - }, - 'button>span:first-child>span': { - display: 'none', - }, - 'button:not(:first-child):hover>span:first-child>span': - customTooltipStyle(), - 'button:not(:first-child)>span:first-child>svg': { - color: colors.text.secondary, - }, - }, -}) - -const menuIconStyle = () => ({ - color: colors.text.primary, - minWidth: sizing.iconMinWidth, - '& svg': { - fontSize: sizing.icon, - }, -}) - -const activeLinkStyle = { - color: `${colors.accent.main} !important`, - '& .MuiListItemIcon-root': { - color: `${colors.accent.main} !important`, - }, -} - -// ============================================ -// THEME DEFINITION -// ============================================ - -// Note: !important declarations are required to override react-admin and third-party component styles -const NautilineTheme = { - themeName: 'Nautiline', - palette: { - type: 'light', - primary: { - main: colors.accent.main, - contrastText: '#FFFFFF', - }, - secondary: { - main: colors.accent.main, - contrastText: '#FFFFFF', - }, - background: { - default: colors.background.primary, - paper: colors.background.primary, - }, - text: { - primary: colors.text.primary, - secondary: colors.text.secondary, - }, - action: { - active: colors.accent.main, - hover: colors.accent.faded, - selected: colors.accent.faded, - }, - }, - typography: { - fontFamily: typography.fontFamily.base, - h1: headingStyle(700, '-0.02em'), - h2: headingStyle(700, '-0.02em'), - h3: headingStyle(600, '-0.01em'), - h4: headingStyle(600), - h5: headingStyle(600), - h6: headingStyle(600), - subtitle1: { fontWeight: 500 }, - subtitle2: { fontWeight: 500 }, - body1: { fontWeight: 400 }, - body2: { fontWeight: 400 }, - button: { fontWeight: 500, textTransform: 'none' }, - }, - shape: { - borderRadius: radii.xl, - }, - overrides: { - MuiCssBaseline: { - '@global': { - '@font-face': { - fontFamily: 'Unbounded', - fontStyle: 'normal', - fontWeight: '300 800', - fontDisplay: 'swap', - src: `url('${UNBOUNDED_FONT_PATH}') format('woff2')`, - }, - body: { - backgroundColor: colors.background.primary, - }, - }, - }, - MuiAppBar: { - root: { - boxShadow: 'none', - borderBottom: `1px solid ${colors.ui.separator}`, - }, - colorSecondary: { - backgroundColor: colors.background.primary, - color: colors.text.primary, - }, - }, - MuiToolbar: { - root: { - backgroundColor: colors.background.primary, - }, - }, - MuiPaper: { - root: { - backgroundColor: colors.background.primary, - }, - elevation1: { - boxShadow: `0 0.0625rem 0.1875rem ${colors.ui.shadow}`, - }, - elevation2: { - boxShadow: `0 0.125rem ${spacing.sm} ${colors.ui.shadow}`, - }, - }, - MuiCard: { - root: { - backgroundColor: colors.background.primary, - borderRadius: radii.xl, - boxShadow: `0 0.125rem ${spacing.sm} ${colors.ui.shadow}`, - }, - }, - MuiButton: { - root: { - borderRadius: radii.md, - textTransform: 'none', - fontWeight: 600, - }, - contained: { - boxShadow: 'none', - '&:hover': { boxShadow: 'none' }, - }, - containedPrimary: { - backgroundColor: colors.accent.main, - '&:hover': { - backgroundColor: colors.accent.main, - filter: 'brightness(0.9)', - }, - }, - text: { - color: colors.accent.main, - }, - }, - MuiIconButton: { - root: { - color: colors.text.primary, - '&:hover': { - backgroundColor: colors.accent.faded, - }, - }, - colorPrimary: { - color: colors.accent.main, - }, - sizeSmall: { - padding: spacing.md, - }, - }, - MuiSvgIcon: { - colorPrimary: { - color: colors.accent.main, - }, - }, - MuiCheckbox: { - root: { - color: 'rgba(0, 0, 0, 0.15)', - '&$checked': { - color: colors.accent.main, - }, - }, - }, - MuiChip: { - root: { - backgroundColor: colors.background.secondary, - color: colors.text.primary, - borderRadius: radii.pill, - }, - colorPrimary: { - backgroundColor: colors.accent.faded, - color: colors.accent.main, - }, - }, - MuiTableRow: { - root: { - '&:hover': { - backgroundColor: `${colors.accent.faded} !important`, - }, - }, - }, - MuiTableCell: { - root: { - borderBottomColor: 'rgba(0, 0, 0, 0.04)', - }, - head: { - backgroundColor: colors.background.secondary, - color: colors.text.secondary, - fontWeight: 600, - fontSize: '0.75rem', - textTransform: 'uppercase', - letterSpacing: '0.05em', - }, - body: { - color: colors.text.primary, - }, - }, - MuiListItem: { - root: { - color: colors.text.primary, - '&:hover': { - backgroundColor: colors.accent.faded, - }, - '&$selected': { - backgroundColor: colors.accent.faded, - color: colors.accent.main, - '& .MuiListItemIcon-root': { - color: colors.accent.main, - }, - '&:hover': { - backgroundColor: colors.accent.faded, - }, - }, - }, - button: { - color: colors.text.primary, - '&:hover': { - backgroundColor: colors.accent.faded, - color: colors.text.primary, - }, - }, - }, - MuiListItemIcon: { - root: menuIconStyle(), - }, - MuiListItemText: { - primary: { - color: 'inherit', - }, - }, - MuiMenuItem: { - root: { - fontSize: '0.875rem', - paddingTop: '4px', - paddingBottom: '4px', - paddingLeft: '10px', - margin: '5px', - borderRadius: radii.md, - color: colors.text.primary, - }, - }, - MuiDrawer: { - paper: { - backgroundColor: colors.background.primary, - borderRight: `1px solid ${colors.ui.separator}`, - }, - }, - MuiSlider: { - root: { - color: colors.accent.main, - }, - track: { - backgroundColor: colors.accent.main, - }, - thumb: { - backgroundColor: colors.accent.main, - '&:hover': { - boxShadow: `0 0 0 ${spacing.sm} ${colors.accent.faded}`, - }, - }, - rail: { - backgroundColor: colors.background.tertiary, - }, - }, - MuiLinearProgress: { - root: { - backgroundColor: colors.background.tertiary, - borderRadius: radii.sm, - }, - bar: { - backgroundColor: colors.accent.main, - borderRadius: radii.sm, - }, - }, - MuiTabs: { - root: { - borderBottom: `1px solid ${colors.ui.separator}`, - }, - indicator: { - backgroundColor: colors.accent.main, - height: '0.1875rem', - borderRadius: '0.1875rem 0.1875rem 0 0', - }, - }, - MuiTab: { - root: { - textTransform: 'none', - fontWeight: 500, - fontFamily: typography.fontFamily.heading, - '&$selected': { - color: colors.accent.main, - fontWeight: 600, - }, - }, - }, - MuiInputBase: { - root: { - backgroundColor: colors.background.secondary, - borderRadius: radii.lg, - }, - }, - MuiOutlinedInput: { - root: { - borderRadius: radii.lg, - '& $notchedOutline': { - borderColor: colors.ui.separator, - }, - '&:hover $notchedOutline': { - borderColor: colors.text.tertiary, - }, - '&$focused $notchedOutline': { - borderColor: colors.accent.main, - borderWidth: '0.125rem', - }, - }, - }, - MuiFilledInput: { - root: { - backgroundColor: colors.background.secondary, - borderRadius: radii.lg, - '&:hover': { - backgroundColor: colors.background.tertiary, - }, - '&$focused': { - backgroundColor: colors.background.secondary, - }, - }, - }, - MuiFab: { - primary: { - backgroundColor: colors.accent.main, - '&:hover': { - backgroundColor: colors.accent.main, - filter: 'brightness(0.9)', - }, - }, - }, - MuiAvatar: { - root: { - borderRadius: radii.md, - }, - }, - MuiRating: { - iconFilled: { - color: colors.accent.main, - }, - iconHover: { - color: colors.accent.main, - }, - }, - MuiTooltip: { - tooltip: { - backgroundColor: colors.text.primary, - color: colors.background.primary, - fontSize: '0.75rem', - padding: `${spacing.xs} ${spacing.sm}`, - borderRadius: radii.sm, - }, - }, - MuiBottomNavigation: { - root: { - backgroundColor: colors.ui.glassBg, - backdropFilter: `blur(${tokens.blur})`, - borderTop: `1px solid ${colors.ui.separator}`, - }, - }, - MuiBottomNavigationAction: { - root: { - color: colors.text.secondary, - '&$selected': { - color: colors.accent.main, - }, - }, - label: { - fontFamily: typography.fontFamily.heading, - fontSize: '0.65rem', - '&$selected': { - fontSize: '0.65rem', - }, - }, - }, - NDAppBar: { - root: { - color: colors.text.primary, - }, - }, - NDLogin: { - main: { - backgroundColor: colors.background.primary, - }, - card: { - backgroundColor: colors.background.primary, - borderRadius: radii.pill, - boxShadow: `0 ${spacing.xs} ${spacing.xl} ${colors.ui.shadow}`, - }, - }, - NDAlbumGridView: { - link: { - borderRadius: radii.md, - overflow: 'hidden', - }, - albumTitle: { - fontWeight: 600, - color: colors.text.primary, - }, - albumSubtitle: { - color: colors.text.secondary, - }, - albumPlayButton: { - backgroundColor: colors.accent.main, - borderRadius: radii.full, - boxShadow: `0 ${spacing.sm} ${spacing.sm} rgba(0, 0, 0, 0.15)`, - padding: '0.35rem', - transition: 'padding .3s ease', - '&:hover': { - backgroundColor: `${colors.accent.main} !important`, - padding: '0.45rem', - }, - }, - }, - NDAlbumDetails: { - root: { - [`@media (max-width: ${breakpoints.xs}px)`]: { - padding: '0.7em', - minWidth: 'unset', - }, - }, - cardContents: { - [`@media (max-width: ${breakpoints.xs}px)`]: { - flexDirection: 'column', - alignItems: 'center', - }, - }, - details: { - [`@media (max-width: ${breakpoints.xs}px)`]: { - width: '100%', - }, - }, - cover: { - borderRadius: radii.md, - }, - coverParent: { - marginRight: spacing.xl, - [`@media (max-width: ${breakpoints.xs}px)`]: { - width: '100%', - height: 'auto', - minWidth: 'unset', - aspectRatio: '1', - marginRight: 0, - marginBottom: spacing.lg, - }, - ...coverSizing(), - }, - recordName: { - fontSize: '1.75rem', - fontWeight: 700, - marginBottom: '0.15rem', - }, - recordArtist: { - marginBottom: spacing.md, - }, - recordMeta: { - marginBottom: spacing.sm, - }, - genreList: { - marginTop: spacing.md, - }, - loveButton: { - marginLeft: spacing.sm, - }, - }, - NDAlbumShow: { - albumActions: actionButtonsStyle(), - }, - NDPlaylistShow: { - playlistActions: actionButtonsStyle(), - }, - NDSubMenu: { - icon: menuIconStyle(), - menuHeader: { - color: colors.text.primary, - '& .MuiTypography-root': { - color: colors.text.primary, - }, - }, - actionIcon: { - marginLeft: spacing.sm, - }, - }, - RaMenuItemLink: { - root: { - color: `${colors.text.primary} !important`, - '& .MuiListItemIcon-root': menuIconStyle(), - '&[class*="makeStyles-active"]': activeLinkStyle, - }, - active: activeLinkStyle, - }, - NDDesktopArtistDetails: { - root: { - [`@media (min-width: ${breakpoints.sm}px)`]: { - padding: '1em', - }, - [`@media (min-width: ${breakpoints.lg}px)`]: { - padding: '1em', - }, - }, - cover: { - borderRadius: radii.md, - ...coverSizing(), - }, - artistImage: { - borderRadius: radii.md, - marginRight: spacing.xl, - [`@media (min-width: ${breakpoints.sm}px)`]: { - height: sizing.cover.sm, - width: sizing.cover.sm, - minWidth: sizing.cover.sm, - maxHeight: sizing.cover.sm, - minHeight: sizing.cover.sm, - }, - [`@media (min-width: ${breakpoints.lg}px)`]: { - height: sizing.cover.lg, - width: sizing.cover.lg, - minWidth: sizing.cover.lg, - maxHeight: sizing.cover.lg, - minHeight: sizing.cover.lg, - }, - }, - artistName: { - fontSize: '1.75rem', - fontWeight: 700, - marginBottom: spacing.sm, - }, - }, - NDMobileArtistDetails: { - cover: { - borderRadius: radii.md, - }, - artistImage: { - borderRadius: radii.md, - }, - }, - RaList: { - content: { - overflow: 'visible', - }, - }, - RaBulkActionsToolbar: { - topToolbar: { - backgroundColor: 'transparent', - boxShadow: 'none', - padding: spacing.sm, - '@global': { - button: { - border: '1px solid transparent', - backgroundColor: colors.background.secondary, - color: colors.text.secondary, - margin: `0 ${spacing.xs}`, - borderRadius: radii.full, - minWidth: 0, - padding: spacing.sm, - position: 'relative', - '&:hover': { - backgroundColor: `${colors.background.tertiary} !important`, - border: '1px solid transparent', - }, - }, - 'button>span:first-child>span': { - display: 'none', - }, - 'button:hover>span:first-child>span': customTooltipStyle(), - 'button>span:first-child>svg': { - color: colors.text.secondary, - }, - }, - }, - }, - RaPaginationActions: { - currentPageButton: { - backgroundColor: colors.accent.faded, - }, - }, - }, - player: { - theme: 'light', - stylesheet: ` - @font-face { - font-family: 'Unbounded'; - font-style: normal; - font-weight: 300 800; - font-display: swap; - src: url('${UNBOUNDED_FONT_PATH}') format('woff2'); - } - - .react-jinke-music-player-main { - background-color: ${colors.background.primary} !important; - font-family: ${typography.fontFamily.base} !important; - } - - .react-jinke-music-player-main .music-player-panel { - background-color: ${colors.ui.glassBg} !important; - backdrop-filter: blur(${tokens.blur}) !important; - -webkit-backdrop-filter: blur(${tokens.blur}) !important; - border-top: 1px solid ${colors.ui.separator} !important; - box-shadow: 0 -0.125rem 1.25rem rgba(0, 0, 0, 0.06) !important; - } - - .react-jinke-music-player-main svg { - color: ${colors.text.primary} !important; - } - - .react-jinke-music-player-main svg:hover { - color: ${colors.accent.main} !important; - } - - .react-jinke-music-player-main .rc-slider-track, - .react-jinke-music-player-main .rc-slider-handle { - background-color: ${colors.accent.main} !important; - } - - .react-jinke-music-player-main .rc-slider-handle { - border-color: ${colors.accent.main} !important; - } - - .react-jinke-music-player-main .rc-slider-rail { - background-color: ${colors.background.secondary} !important; - } - - .react-jinke-music-player-main .rc-slider { - height: 4px !important; - } - - .react-jinke-music-player-main .rc-slider-rail, - .react-jinke-music-player-main .rc-slider-track { - height: 4px !important; - border-radius: 2px !important; - } - - .react-jinke-music-player-main .rc-slider-handle { - width: 12px !important; - height: 12px !important; - margin-top: -4px !important; - } - - .react-jinke-music-player-main .audio-lists-panel, - .react-jinke-music-player-main .audio-lists-panel-content { - background-color: ${colors.background.primary} !important; - } - - .react-jinke-music-player-main .audio-lists-panel-content .audio-item { - background-color: transparent !important; - color: ${colors.text.primary} !important; - } - - .react-jinke-music-player-main .audio-lists-panel-content .audio-item:hover { - background-color: ${colors.accent.faded} !important; - } - - .react-jinke-music-player-main .audio-lists-panel-content .audio-item.playing { - background-color: ${colors.accent.faded} !important; - color: ${colors.accent.main} !important; - } - - .react-jinke-music-player-main .lyric-btn-active, - .react-jinke-music-player-main .play-mode-title { - color: ${colors.accent.main} !important; - } - - .react-jinke-music-player-main .music-player-panel .player-content .music-player-controller .music-player-info .music-player-title { - color: ${colors.text.primary} !important; - font-weight: 600 !important; - font-family: ${typography.fontFamily.heading} !important; - } - - .react-jinke-music-player-main .music-player-panel .player-content .music-player-controller .music-player-info .music-player-artist { - color: ${colors.text.secondary} !important; - } - - .react-jinke-music-player-main.mini-player { - background-color: ${colors.ui.glassBg} !important; - backdrop-filter: blur(${tokens.blur}) !important; - -webkit-backdrop-filter: blur(${tokens.blur}) !important; - border-radius: ${radii.xl} !important; - box-shadow: 0 ${spacing.xs} 1.25rem rgba(0, 0, 0, 0.08) !important; - } - - - .MuiTypography-h1, - .MuiTypography-h2, - .MuiTypography-h3, - .MuiTypography-h4, - .MuiTypography-h5, - .MuiTypography-h6 { - font-family: ${typography.fontFamily.heading} !important; - } - `, - }, -} - -export default NautilineTheme diff --git a/ui/src/themes/nord.css.js b/ui/src/themes/nord.css.js index 0634c2309..f15ba359e 100644 --- a/ui/src/themes/nord.css.js +++ b/ui/src/themes/nord.css.js @@ -1,4 +1,4 @@ -const stylesheet = ` +module.exports = ` .react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { color: #81A1C1 @@ -163,6 +163,5 @@ const stylesheet = ` .react-jinke-music-player-mobile-progress .rc-slider-handle { border: none; } -` -export default stylesheet +` diff --git a/ui/src/themes/nord.js b/ui/src/themes/nord.js index 5420bbc60..492de9c06 100644 --- a/ui/src/themes/nord.js +++ b/ui/src/themes/nord.js @@ -1,5 +1,3 @@ -import stylesheet from './nord.css.js' - // For Album, Playlist const musicListActions = { alignItems: 'center', @@ -259,6 +257,7 @@ export default { }, details: { fontSize: '.875rem', + minWidth: '75vw', color: 'rgba(255,255,255, 0.8)', }, }, @@ -290,8 +289,6 @@ export default { fontSize: '.875rem', color: 'rgba(255,255,255, 0.8)', }, - }, - NDCollapsibleComment: { commentBlock: { fontSize: '.875rem', color: 'rgba(255,255,255, 0.8)', @@ -339,7 +336,6 @@ export default { content: { padding: '0 !important', background: '#3B4252', - backgroundColor: 'rgb(59, 66, 82)', }, root: { backgroundColor: '#2E3440', @@ -421,6 +417,6 @@ export default { }, player: { theme: 'dark', - stylesheet, + stylesheet: require('./nord.css.js'), }, } diff --git a/ui/src/themes/nuclear.css.js b/ui/src/themes/nuclear.css.js deleted file mode 100644 index c4457703b..000000000 --- a/ui/src/themes/nuclear.css.js +++ /dev/null @@ -1,63 +0,0 @@ -const stylesheet = ` - -.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { - color: #b8bb26 -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #b8bb26; - border-color: #79740e -} - -.react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #b8bb26; -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #b8bb26 -} - -.react-jinke-music-player-main .audio-item.playing svg { - color: #b8bb26 -} - -.react-jinke-music-player-main .audio-item.playing .player-singer { - color: #b8bb26 !important -} - -.react-jinke-music-player-main .rc-slider-rail { - background-color: #e6e9c1 !important -} - -.react-jinke-music-player-main .lyric-btn { - color: #ebdbb2 !important -} - -.react-jinke-music-player-main .music-player-panel { - color: #ebdbb2 !important -} - -.react-jinke-music-player-main .lyric-btn-active svg { - color: #b8bb26 !important -} - -.music-player-lyric { - color: #b8bb26 !important -} - -.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { - color: #b8bb26 -} -.audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { - color: #b8bb26 -} - -.progress-bar-content .audio-title a { - color: #ebdbb2 -} - -.MuiCheckbox-colorSecondary.Mui-checked { - color: #b8bb26 !important -} -` -export default stylesheet diff --git a/ui/src/themes/nuclear.js b/ui/src/themes/nuclear.js deleted file mode 100644 index bf804139e..000000000 --- a/ui/src/themes/nuclear.js +++ /dev/null @@ -1,212 +0,0 @@ -import stylesheet from './nuclear.css.js' - -const nukeCol = { - primary: '#1d2021', - secondary: '#282828', - accent: '#32302f', - text: '#ebdbb2', - textAlt: '#bdae93', - icon: '#b8bb26', - link: '#c44129', - border: '#a89984', -} - -export default { - themeName: 'Nuclear', - palette: { - primary: { - main: nukeCol['primary'], - }, - secondary: { - main: nukeCol['secondary'], - }, - background: { - default: nukeCol['primary'], - }, - text: { - primary: nukeCol['text'], - secondary: nukeCol['text'], - }, - type: 'dark', - }, - overrides: { - MuiTypography: { - root: { - color: nukeCol['text'], - }, - colorPrimary: { - color: nukeCol['text'], - }, - }, - MuiPaper: { - root: { - backgroundColor: nukeCol['secondary'], - }, - }, - MuiFormGroup: { - root: { - color: nukeCol['text'], - }, - }, - NDAlbumGridView: { - albumName: { - marginTop: '0.5rem', - fontWeight: 700, - textTransform: 'none', - color: nukeCol['text'], - }, - albumSubtitle: { - color: nukeCol['textAlt'], - }, - }, - MuiAppBar: { - colorSecondary: { - color: nukeCol['text'], - }, - positionFixed: { - backgroundColor: nukeCol['primary'], - boxShadow: - 'rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px', - }, - }, - MuiButton: { - root: { - border: '1px solid transparent', - '&:hover': { - backgroundColor: nukeCol['accent'], - }, - }, - label: { - color: nukeCol['text'], - }, - contained: { - boxShadow: 'none', - '&:hover': { - boxShadow: 'none', - }, - }, - }, - NDNotification: { - undo: { - '& .MuiButton-label': { - color: 'inherit', - }, - }, - }, - MuiChip: { - root: { - backgroundColor: nukeCol['accent'], - }, - label: { - color: nukeCol['icon'], - }, - }, - RaLink: { - link: { - color: nukeCol['link'], - }, - }, - MuiTableCell: { - root: { - borderBottom: 'none', - color: nukeCol['text'], - padding: '10px !important', - }, - head: { - fontSize: '0.75rem', - textTransform: 'uppercase', - letterSpacing: 1.2, - backgroundColor: nukeCol['accent'], - color: nukeCol['text'], - }, - body: { - color: nukeCol['text'], - }, - }, - MuiInput: { - root: { - color: nukeCol['text'], - }, - }, - MuiFormLabel: { - root: { - '&$focused': { - color: nukeCol['text'], - fontWeight: 'bold', - }, - }, - }, - MuiOutlinedInput: { - notchedOutline: { - borderColor: nukeCol['border'], - }, - }, - //Icons - MuiIconButton: { - label: { - color: nukeCol['icon'], - }, - }, - MuiListItemIcon: { - root: { - color: nukeCol['icon'], - }, - }, - MuiSelect: { - icon: { - color: nukeCol['icon'], - }, - }, - MuiSvgIcon: { - root: { - color: nukeCol['icon'], - }, - colorDisabled: { - color: nukeCol['icon'], - }, - }, - MuiSwitch: { - colorPrimary: { - '&$checked + $track': { - backgroundColor: '#f9f5d7', - }, - }, - track: { - backgroundColor: '#665c54', - }, - }, - RaButton: { - smallIcon: { - color: nukeCol['icon'], - }, - }, - RaDatagrid: { - headerCell: { - backgroundColor: nukeCol['accent'], - }, - }, - //Login Screen - NDLogin: { - systemNameLink: { - color: nukeCol['text'], - }, - card: { - minWidth: 300, - backgroundColor: nukeCol['secondary'], - }, - button: { - boxShadow: '3px 3px 5px #000000a3', - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: - 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', - }, - }, - }, - player: { - theme: 'dark', - stylesheet, - }, -} diff --git a/ui/src/themes/nutball.css.js b/ui/src/themes/nutball.css.js deleted file mode 100644 index aaf1c0e16..000000000 --- a/ui/src/themes/nutball.css.js +++ /dev/null @@ -1,380 +0,0 @@ -const stylesheet = ` -html { - scrollbar-width: none; -} -body { - -ms-overflow-style: none; - font-family: monospace; -} -body::-webkit-scrollbar, body::-webkit-scrollbar-button { - display: none; -} -.react-jinke-music-player-main .music-player-panel { - background-color: white!important; - box-shadow: none; - font-family: monospace; - color: black; - border-top: 1px solid black; -} -.react-jinke-music-player-main .music-player-panel .panel-content div.img-content { - animation: none; - box-shadow: none; - border-radius: 5px; -} -.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content { - flex: 0 0 auto; - width: calc(50% - 150px); - margin-left: 10px; - padding: 0; -} -section.audio-main { - position: absolute; - width: calc(100% - 131px)!important; - bottom: 0; - margin-bottom: 10px; -} -span.audio-title { - margin-bottom: 20px; -} -span.audio-title .songTitle { - color: black!important; -} -.react-jinke-music-player-main .music-player-panel .panel-content .player-content { - flex: 1; - margin-bottom: 20px; - padding-left: 0; -} -div.player-content > span:first-child { - flex: 1!important; - justify-content: flex-start!important; -} -div.player-content > span:first-child svg { - width: 50px; -} -.react-jinke-music-player-main .music-player-panel .panel-content .player-content > .group { - flex: 0; -} -.play-sounds svg, .loop-btn svg, .audio-lists-btn svg, .destroy-btn { - margin-left: 0!important; -} -.play-sounds svg, .loop-btn svg, .audio-lists-btn svg, .destroy-btn svg { - width: 20px; -} -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { - padding: 0; -} -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn .audio-lists-icon svg { - height: .75em; -} -.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .audio-main .current-time, .react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .audio-main .duration { - flex-basis: 0; -} -.progress-bar > div:nth-child(2) > div:nth-child(4) { - transform: translateX(-50%) translateY(5%) !important; -} -.progress-load-bar { - display: none; -} -.sound-operation > div:nth-child(4) { - transform: translateX(-50%) translateY(5%) !important; -} -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds .sound-operation { - width: 60px; -} -.rc-slider { - border-radius: 0px; - border: 1px solid black; - padding: 3px 0!important; -} -.rc-slider .rc-slider-handle { - box-shadow: none!important; - border-radius: 0px; - background-color: black!important; - border: hidden!important; -} -.rc-slider[style*="left: 0%"] { - transform: translateX(0) !important; -} -.rc-slider .rc-slider-track { - display: none; -} -.react-jinke-music-player-main .rc-slider-rail, .react-jinke-music-player-main.light-theme .rc-slider-rail { - background-color: white!important; -} -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds .sounds-icon { - margin-right: 10px; -} -.lyric-btn { - display: none!important; -} -button[data-testid="save-queue-button"] { - display: none!important; -} -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { - box-shadow: 0 0 0 0; - margin: 0; - margin-left: -8px; - margin-right: -5px; -} -.audio-lists-btn:hover span, -.audio-lists-btn:hover svg { - color: #a8fe40!important; -} -.react-jinke-music-player-main.light-theme .audio-lists-btn { - background-color: white!important; -} -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn .audio-lists-num { - color: grey; - margin-left: 5px; - font-size: .7rem; -} -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .hide-panel { - margin-left: 2px; -} -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .hide-panel svg { - stroke-width: 15px; - stroke: #fff; - height: .8em; -} -@media screen and (max-width: 810px) { - .react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds .sounds-icon { - margin-left: 5px; - margin-right: 0; - } - .react-jinke-music-player-main .music-player-panel .panel-content .player-content .loop-btn { - margin-left: 5px; - } - .play-sounds svg, .loop-btn svg, .audio-lists-btn svg, .destroy-btn { - margin-left: -3px!important; - } -} -.panel-content li { - flex-grow: 0; -} -.react-jinke-music-player .music-player-controller, -.react-jinke-music-player-main.light-theme .music-player-controller { - border-radius: 5px; - box-shadow: none; -} -.react-jinke-music-player .music-player-controller:hover, -.react-jinke-music-player .music-player-controller:has(+ .destroy-btn:hover) { - border: 1px solid black; -} -.react-jinke-music-player .music-player-controller .controller-title, -.react-jinke-music-player .music-player-controller .music-player-controller-setting { - display: none; -} -.react-jinke-music-player .music-player-controller.music-player-playing:before { - animation: none; - border: none; -} -@media screen and (max-width:767px) { - .react-jinke-music-player .music-player .destroy-btn { - right: 0; - } - .react-jinke-music-player-main .destroy-btn svg { - font-size: 10px; - } -} -.react-jinke-music-player-main svg { - transition: none; -} -.react-jinke-music-player-main svg, .react-jinke-music-player-main.light-theme svg { - color: black; -} -.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover, -.react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover { - color: #a8fe40; -} -.react-jinke-music-player-main .play-mode-title { - font-family: monospace; - background-color: white; - color: black; -} -.react-jinke-music-player-mobile, -.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile { - font-family: monospace; - background-color: rgba(255, 255, 255, .9); - color: black!important; - justify-content: center; - padding: 50px; -} -.react-jinke-music-player-mobile:before { - content: " "; - display: block; - position: absolute; - margin-left: auto; - margin-right: auto; - left: 0; - right: 0; - text-align: center; - width: 90%; - height: 700px; - background-color: white; - border: 1px solid black; - z-index: -1; - border-radius: 4px; -} -.react-jinke-music-player-mobile-header { - align-items: start; - margin-bottom: 4rem; - justify-content: start; -} -.react-jinke-music-player-mobile-header-title { - text-align: left; - padding: 0; -} -.react-jinke-music-player-mobile-header-right { - color: black; -} -.react-jinke-music-player-mobile > .group { - flex: 0; -} -.react-jinke-music-player-mobile-cover, -.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile-cover { - border-radius: 5px; - box-shadow: none; - animation: none; - border: 1px solid black; - margin: 0 auto 4rem auto; - width: auto; - height: auto; -} -.react-jinke-music-player-mobile-cover .cover { - animation: none; -} -.react-jinke-music-player-mobile-progress .current-time { - /* margin-right: 17px; */ -} -.react-jinke-music-player-mobile-progress .current-time, .react-jinke-music-player-mobile-progress .duration { - color: black!important; -} -.react-jinke-music-player-mobile-progress .rc-slider { - height: 24px; -} -.react-jinke-music-player-mobile-progress .rc-slider-handle { - border: 2px solid black; - margin-top: -4px; - height: 24px; - width: 24px; -} -.react-jinke-music-player-mobile-toggle { - margin-bottom: 1rem; - padding: 2rem 0; -} -.react-jinke-music-player-mobile-operation .items .item svg { - color: black!important; - font-size: 2rem; - width: 2rem; -} -.react-jinke-music-player-mobile-operation .items .item svg:hover, -.react-jinke-music-player-mobile-operation .items .item button:hover svg { - color: #a8fe40!important; -} -.react-jinke-music-player-mobile-operation .items .item .MuiIconButton-root:hover { - background-color: rgba(0, 0, 0, 0.0); -} -.react-jinke-music-player-mobile-operation .MuiButtonBase-root.Mui-disabled { - cursor: pointer; - pointer-events: auto; -} -.react-jinke-music-player-mobile-operation .items li:nth-child(5) svg { - font-size: 1.4rem; -} -.react-jinke-music-player-mobile-operation .items li:nth-child(5) svg g path:nth-child(2) { - stroke-width: .4px; -} -.react-jinke-music-player-mobile-operation .items li:nth-child(2), -.react-jinke-music-player-mobile-operation .items li:nth-child(3) { - display: none; -} -.react-jinke-music-player-mobile-play-model-tip { - display: none; -} -.audio-lists-panel { - overflow-y: scroll; - scrollbar-width: none; - border-radius: .625rem; - bottom: 6.25rem; -} -.react-jinke-music-player-main.light-theme .audio-lists-panel { - font-family: monospace; - box-shadow: none; - border: 1px solid black; -} -.react-jinke-music-player-main.light-theme .audio-lists-panel-header { - text-shadow: none; - border-bottom: 1px solid black; -} -.audio-lists-panel-header-line { - width: 0; -} -.audio-lists-panel-header-close-btn:hover svg { - animation: none; -} -.audio-lists-panel-content .audio-item, -.react-jinke-music-player-main.light-theme .audio-item { - border-radius: 0px; - margin: 0; - border-bottom: none; - box-shadow: none; - transition: none; -} -.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-lists-panel-content .audio-item:nth-child(2n+1) { - background-color: white!important; -} -.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-lists-panel-content .audio-item:nth-child(2n+1):hover { - background-color: #fafafa!important; -} -.audio-lists-panel-content .audio-item .player-singer { - width: unset; - padding-right: 20px; -} -.audio-lists-panel-content .audio-item .player-delete:hover svg { - color: #a8fe40!important; - animation: none; -} -.react-jinke-music-player-main .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, -.react-jinke-music-player-main .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg, -.react-jinke-music-player-main.light-theme .audio-item:active svg, -.react-jinke-music-player-main.light-theme .audio-item:hover svg { - color: black; -} -.audio-lists-panel-content .audio-item .player-delete { - justify-content: center; - width: 25px; -} -.audio-lists-panel-content .audio-item .player-delete svg { - font-size: 20px; -} -.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing, .react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing svg { - color: #a8fe40!important; -} -.audio-lists-panel-content .audio-item .player-name, -.audio-lists-panel-content .audio-item .player-singer, -.react-jinke-music-player-main.light-theme .audio-item.playing .player-singer, -.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing, .react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing .player-delete svg { - color: black!important; -} -.audio-lists-panel-mobile { - height: 750px !important; - top: calc(100vh / 2 - 375px) !important; - width: 91% !important; - margin: 0 auto; -} -.audio-lists-panel-mobile .audio-lists-panel-content { - height: auto!important; -} -.audio-lists-panel-content { - scrollbar-width: none; -} -@keyframes fromOut { - 0% { - transform:scale(1) translateZ(0) - } - to { - transform:scale(1) translate3d(0,150%,0); - } -} -` -export default stylesheet diff --git a/ui/src/themes/nutball.js b/ui/src/themes/nutball.js deleted file mode 100644 index f43250f5c..000000000 --- a/ui/src/themes/nutball.js +++ /dev/null @@ -1,684 +0,0 @@ -import stylesheet from './nutball.css.js' - -export default { - themeName: 'Nutball', - palette: { - primary: { - main: '#80ea00', - light: '#fff', - }, - secondary: { - main: '#80ea00', - contrastText: '#fff', - }, - }, - typography: { - fontFamily: 'monospace', - h6: { - fontSize: '1rem', - }, - h4: { - fontSize: '1.2rem', - }, - h1: { - fontSize: '1.4rem', - }, - body: { - fontFamily: 'monospace', - }, - }, - overrides: { - MuiAppBar: { - root: { - borderBottom: '1px solid black', - }, - colorSecondary: { - color: 'black', - backgroundColor: 'white', - }, - }, - MuiPaper: { - elevation1: { - boxShadow: 'none', - }, - elevation4: { - boxShadow: 'none', - }, - elevation6: { - boxShadow: 'none', - }, - elevation8: { - boxShadow: 'none', - border: '1px solid black', - }, - elevation16: { - boxShadow: 'none', - borderRight: '1px solid grey!important', - }, - elevation24: { - boxShadow: 'none', - border: '1px solid black', - }, - }, - MuiButton: { - root: { - color: '#80ea00', - border: '1px solid rgba(0, 0, 0, 0.23)', - transition: 'none', - '&[aria-label="Grid"]': { - width: '50%', - marginLeft: '15px', - marginRight: '2px', - marginBottom: '10px', - '& .MuiButton-label': { - justifyContent: 'center', - }, - }, - '&[aria-label="Table"]': { - width: '50%', - marginRight: '15px', - marginLeft: '2px', - marginBottom: '10px', - '& .MuiButton-label': { - justifyContent: 'center', - }, - }, - }, - textPrimary: { - color: 'rgba(0,0,0,.57)', - '&:hover': { - borderColor: 'black', - backgroundColor: '#eaeaea', - }, - '&[aria-label="Grid"]': { - color: 'black', - borderColor: 'black!important', - }, - '&[aria-label="Table"]': { - color: 'black', - borderColor: 'black!important', - }, - }, - textSecondary: { - color: 'rgba(0,0,0,.57)', - '&:hover': { - borderColor: 'black', - backgroundColor: '#eaeaea', - }, - '&[aria-label="Grid"]': { - color: 'grey', - borderColor: 'grey!important', - }, - '&[aria-label="Table"]': { - color: 'grey', - borderColor: 'grey!important', - }, - }, - label: { - '& svg': { - display: 'none', - }, - '& span': { - paddingLeft: '0', - }, - }, - contained: { - boxShadow: 'none', - '&:hover': { - boxShadow: 'none', - }, - }, - }, - MuiButtonGroup: { - groupedTextHorizontal: { - justifyContent: 'flex-start', - margin: '0 .5rem', - '& button': { - width: '25%', - }, - }, - groupedTextPrimary: { - '&:not(:last-child)': { - border: 'none', - }, - }, - }, - MuiIconButton: { - root: { - '&[aria-label="Settings"]': { - padding: '12px!important', - marginRight: '-9px!important', - }, - }, - }, - MuiSwitch: { - thumb: { - color: '#eaeaea', - boxShadow: 'none', - borderRadius: '0', - }, - track: { - borderRadius: '0', - }, - switchBase: { - color: '#eaeaea', - }, - }, - MuiCheckbox: { - root: { - '& svg': { - width: '.8em', - }, - }, - }, - PrivateSwitchBase: { - root: { - padding: '8px 8px', - }, - }, - RaButton: { - button: { - marginRight: '10px', - lineHeight: 'normal', - }, - }, - MuiMenu: { - list: { - '& p': { - fontSize: '.85rem', - }, - '& p:first-of-type': { - margin: '6px 1rem', - }, - '& li:has(span.MuiCheckbox-root)': { - marginLeft: '-10px', - }, - '& span.MuiCheckbox-root .MuiSvgIcon-root': { - width: '.75em', - }, - }, - }, - MuiMenuItem: { - root: { - fontSize: '.85rem', - minHeight: 'inherit', - '&[aria-label="Clear value"]:before': { - display: 'block', - content: "'(any)'", - }, - }, - }, - MuiListItem: { - button: { - '& span.MuiCheckbox-root': { - padding: '0px 8px', - }, - }, - }, - MuiTooltip: { - tooltip: { - backgroundColor: 'rgb(117 117 117)', - }, - }, - MuiCircularProgress: { - root: { - color: '#80ea00!important', - }, - }, - MuiAvatar: { - img: { - borderRadius: '5px', - }, - }, - MuiFab: { - root: { - boxShadow: 'none', - }, - }, - MuiTableHead: { - root: { - boxShadow: 'none!important', - }, - }, - MuiTableCell: { - root: { - borderBottom: 'none', - }, - sizeSmall: { - '&:last-child': { - textAlign: 'right', - }, - '&:last-child:is(th)': { - paddingRight: '45px', - }, - }, - }, - MuiTablePagination: { - root: { - fontSize: '.6rem', - }, - caption: { - fontSize: '.6rem', - }, - menuItem: { - fontSize: '.6rem', - }, - }, - MuiTabs: { - root: { - marginBottom: '1rem', - }, - }, - MuiToolbar: { - gutters: { - '@media (min-width: 600px)': { - paddingLeft: '16px', - }, - }, - }, - RaListToolbar: { - toolbar: { - alignItems: 'start', - '& form:has(> div:nth-child(3))': { - paddingBottom: '.6rem', - }, - }, - actions: { - paddingRight: '0!important', - marginTop: '-8px', - textWrap: 'nowrap', - '@media (max-width: 599.95px)': { - marginTop: '3px', - }, - '& .MuiButton-text': { - height: '2.5rem', - padding: '7px 10px', - marginRight: '0', - }, - }, - }, - RaTopToolbar: { - root: { - '& div:first-of-type > div:first-of-type': { - display: 'flex', - flexWrap: 'wrap', - rowGap: '10px', - }, - '& div:first-of-type > div:first-of-type button': { - height: '2rem', - }, - '& div:first-of-type > div:first-of-type .MuiIconButton-root': { - padding: '0', - marginRight: '2rem', - }, - '& div:first-of-type > div:nth-of-type(2)': { - height: '2rem', - }, - }, - }, - RaToolbar: { - toolbar: { - backgroundColor: 'white', - }, - }, - RaFilterButton: { - root: { - textWrap: 'nowrap', - '& button': { - '@media (max-width: 599.95px)': { - padding: '12px', - }, - }, - "&[resource*='song']": { - marginLeft: '10px', - }, - }, - }, - RaDeleteWithUndoButton: { - deleteButton: { - color: 'rgba(0,0,0,.57)', - '&:hover': { - backgroundColor: 'rgba(0, 0, 0, 0.04)', - }, - }, - }, - RaAutocompleteSuggestionList: { - suggestionsContainer: { - borderRadius: '4px', - outline: '1px solid black', - backgroundColor: 'white', - }, - }, - RaEmpty: { - message: { - marginTop: '3rem', - }, - icon: { - display: 'none', - }, - }, - RaAutocompleteArrayInput: { - chipContainerOutlined: { - '&:empty': { - margin: '0', - }, - margin: '10px 0', - }, - chip: { - margin: '4px 4px 4px 0!important', - }, - inputInput: { - flexGrow: '0', - '& #genre_id': { - flexGrow: '0', - }, - }, - }, - RaLayout: { - content: { - width: '100%', - }, - }, - RaDatagrid: { - headerCell: { - fontWeight: 'bold', - }, - }, - NDAlbumShow: { - albumActions: { - padding: '0', - alignItems: 'center', - margin: '1rem 0', - }, - }, - MuiCardContent: { - root: { - fontFamily: 'monospace', - fontSize: '.8rem', - '& #now-playing-title': { - fontSize: '.8rem', - }, - '&:last-child': { - paddingBottom: '16px', - }, - '&[class*="makeStyles-usernameWrap-"]': { - paddingBottom: '16px', - }, - }, - }, - MuiDialogContent: { - root: { - '& .MuiTableCell-sizeSmall:last-child': { - textAlign: 'left', - }, - }, - }, - MuiGridList: { - root: { - '&:empty': { - display: 'none', - }, - backgroundColor: 'white', - borderRadius: '4px', - }, - }, - MuiGridListTile: { - root: { - '@media (max-width: 599.95px)': { - padding: '7px!important', - }, - }, - tile: { - '& img': { - borderRadius: '5px', - }, - }, - }, - NDAlbumGridView: { - root: { - '&:has(.MuiGridList-root:empty)': { - display: 'none', - }, - }, - albumContainer: { - border: '1px solid white', - borderRadius: '5px', - '& a:hover img': { - outline: '1px solid black', - }, - '& a:hover > div:nth-of-type(2)': { - border: 'none', - outline: '1px solid black', - }, - }, - albumLink: { - paddingRight: '6px', - }, - albumSubtitle: { - fontFamily: 'monospace', - }, - tileBar: { - transition: 'all 50ms ease-out', - }, - tileBarMobile: { - transition: 'all 50ms ease-out', - borderLeft: '1px solid black', - borderRight: '1px solid black', - borderBottom: '1px solid black', - }, - }, - MuiGridListTileBar: { - root: { - height: '30px!important', - background: 'white!important', - borderTop: '1px solid black', - borderBottom: '1px solid black', - borderRadius: '0 0 5px 5px', - }, - titleWrap: { - marginLeft: '0px', - }, - titlePositionBottom: { - bottom: '0', - }, - subtitle: { - '& button': { - color: 'black!important', - }, - }, - actionIcon: { - '& button': { - color: 'black!important', - }, - }, - }, - RaFilter: { - form: { - width: '100%', - '& div.filter-field:first-child': { - flex: '1 100%', - '& [class*="RaSearchInput-input-"]': { - width: '100%', - }, - }, - }, - }, - MuiInputAdornment: { - positionEnd: { - justifyContent: 'flex-end', - }, - }, - RaFilterFormInput: { - body: { - '& label': { - transform: 'translate(14px, -6px) scale(0.75)!important', - backgroundColor: '#fafafa', - padding: '0 5px', - }, - }, - hideButton: { - order: '1', - marginLeft: '2px', - top: '-7px', - padding: '8px', - }, - spacer: { - order: '2', - }, - }, - RaPaginationActions: { - actions: { - '& button': { - border: 'none', - fontSize: '.6rem', - }, - }, - }, - NDAlbumDetails: { - cover: { - borderRadius: '5px', - }, - content: { - padding: '0', - marginLeft: '1rem', - }, - externalLinks: { - marginTop: '5px', - }, - notes: { - display: 'none', - }, - root: { - '& p': { - fontSize: '.7rem', - backgroundColor: '#e0e0e0', - borderRadius: '10px', - width: 'fit-content', - padding: '2px 7px', - }, - }, - }, - NDPlaylistDetails: { - cover: { - borderRadius: '5px', - }, - }, - NDDesktopArtistDetails: { - cover: { - borderRadius: '0px', - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: - 'linear-gradient(to bottom, rgb(255 255 255 / 51%), rgb(250 250 250))!important', - }, - }, - NDArtistShow: { - actionsContainer: { - '& button': { - padding: '4px 5px', - fontSize: '0.8125rem', - height: '2rem', - }, - }, - }, - NDAudioPlayer: { - audioTitle: { - color: 'black', - }, - }, - NDLogin: { - main: { - background: 'white', - '& .MuiFormLabel-root': { - color: '#000', - }, - '& .MuiFormLabel-root.Mui-error': { - color: '#000', - }, - '& .MuiInput-underline:before': { - borderBottom: 'none', - }, - '& .MuiInput-underline:after': { - borderBottom: 'none', - }, - '& .MuiFormHelperText-root.Mui-error': { - color: '#000', - paddingLeft: '10px', - }, - '& .MuiInput-underline:hover:not(.Mui-disabled):before': { - borderBottom: 'none', - }, - }, - card: { - minWidth: 300, - marginTop: '6em', - backgroundColor: '#ffffffe6', - border: '1px solid black', - }, - avatar: { - marginTop: '1rem', - '& img': { - filter: 'invert(1)', - }, - }, - icon: {}, - input: { - '& .MuiInput-root': { - border: '1px solid black', - borderRadius: '4px', - padding: '10px', - }, - '& .MuiInputLabel-root': { - padding: '10px', - }, - '& .MuiInputLabel-shrink': { - transform: 'translate(0, -5.5px) scale(0.75)', - }, - }, - actions: { - marginTop: '2rem', - }, - button: { - boxShadow: 'none', - '&:hover': { - boxShadow: 'none', - backgroundColor: 'rgb(117, 177, 44)', - }, - }, - systemNameLink: { - fontFamily: 'monospace', - marginBottom: '1rem', - color: 'black', - '&:before': { - content: "'Welcome to '", - }, - '&:after': { - content: "' *~*!'", - }, - }, - }, - MuiCssBaseline: { - '@global': { - '*::-webkit-scrollbar': { - display: 'none', - }, - }, - }, - MuiBackdrop: { - root: { - backgroundColor: 'rgba(255, 255, 255, 0.5)', - }, - }, - RaLoading: { - message: { - fontFamily: 'monospace', - }, - }, - }, - player: { - theme: 'light', - stylesheet, - }, -} diff --git a/ui/src/themes/rosePine.css.js b/ui/src/themes/rosePine.css.js deleted file mode 100644 index a1c21f562..000000000 --- a/ui/src/themes/rosePine.css.js +++ /dev/null @@ -1,148 +0,0 @@ -const stylesheet = ` - -.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { - color: #c4a7e7 -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #ebbcba -} - -.react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #ebbcba; -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #ebbcba -} - -.react-jinke-music-player-main .audio-item.playing svg { - color: #ebbcba -} - -.react-jinke-music-player-main .audio-item.playing .player-singer { - color: #ebbcba !important -} - -.react-jinke-music-player-main .loading svg { - color: #ebbcba !important -} - - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { - border: none; - box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px; -} - - -.rc-slider-rail, .rc-slider-track { - height: 6px; -} - -.rc-slider { - padding: 3px 0; -} - -.sound-operation > div:nth-child(4) { - transform: translateX(-50%) translateY(5%) !important; -} - -.sound-operation { - padding: 4px 0; -} - -.react-jinke-music-player-main .music-player-panel { - background-color: #1f1d2e; - color: #e0def4; - box-shadow: 0 0 8px rgba(25, 23, 36, 0.35); -} - -.audio-lists-panel { - background-color: #1f1d2e; - bottom: 6.25rem; - box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px; -} - -.audio-lists-panel-content .audio-item.playing { - background-color: rgba(0, 0, 0, 0); -} - -.audio-lists-panel-content .audio-item:nth-child(2n+1) { - background-color: rgba(0, 0, 0, 0); -} - - -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { - background-color:rgba(0,0,0,0); - box-shadow:0 0 0 0; -} - -.audio-lists-panel-content .audio-item { - line-height: 32px; -} - -.react-jinke-music-player-main .music-player-panel .panel-content .img-content { - box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px; -} - -.react-jinke-music-player-main .music-player-lyric { - color: #908caa; - -webkit-text-stroke: 0.5px #191724; - font-weight: bolder; -} - -.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { - color: #908caa !important; -} - -.audio-lists-panel-header { - border-bottom:1px solid #26233a; - box-shadow:none; -} - -.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { - color: #ebbcba -} - -.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { - color: #ebbcba -} - -.audio-lists-panel-content .audio-item .player-icons { - scale: 75%; -} - -.audio-lists-panel-content .audio-item:active, -.audio-lists-panel-content .audio-item:hover { - background-color: #26233a; -} - -/* Mobile */ - -.react-jinke-music-player-mobile-cover { - border: none; - box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px; -} - -.react-jinke-music-player .music-player-controller { - border: none; - background-color: #1f1d2e; - border-color: #1f1d2e; - box-shadow:rgba(25, 23, 36, 0.35) 0px 4px 6px, rgba(25, 23, 36, 0.2) 0px 5px 7px; - color: #ebbcba; -} - -.react-jinke-music-player .music-player-controller .music-player-controller-setting { - color: rgba(196,167,231,.3); -} - -.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #ebbcba; -} - -.react-jinke-music-player-mobile-progress .rc-slider-handle { - border: none; -} -` - -export default stylesheet diff --git a/ui/src/themes/rosePine.js b/ui/src/themes/rosePine.js deleted file mode 100644 index 547a1f764..000000000 --- a/ui/src/themes/rosePine.js +++ /dev/null @@ -1,108 +0,0 @@ -import stylesheet from './rosePine.css.js' - -export default { - themeName: 'Rosé Pine', - palette: { - primary: { - main: '#ebbcba', - }, - secondary: { - main: '#1f1d2e', - contrastText: '#e0def4', - }, - type: 'dark', - background: { - default: '#191724', - paper: '#1f1d2e', - }, - }, - overrides: { - MuiPaper: { - root: { - color: '#e0def4', - backgroundColor: '#1f1d2e', - }, - }, - MuiButton: { - textPrimary: { - color: '#31748f', - }, - textSecondary: { - color: '#e0def4', - }, - }, - MuiIconButton: { - colorSecondary: { - color: '#6e6a86', - }, - }, - MuiChip: { - clickable: { - background: '#26233a', - }, - }, - MuiCheckbox: { - colorSecondary: { - color: '#6e6a86', - '&$checked': { - color: '#ebbcba', - }, - }, - }, - MuiFormGroup: { - root: { - color: '#e0def4', - }, - }, - MuiFormHelperText: { - root: { - '&$error': { - color: '#eb6f92', - }, - }, - }, - MuiTableHead: { - root: { - color: '#e0def4', - background: '#1f1d2e', - }, - }, - MuiTableCell: { - root: { - color: '#e0def4', - background: '#1f1d2e !important', - }, - head: { - color: '#e0def4', - background: '#1f1d2e !important', - }, - }, - NDLogin: { - systemNameLink: { - color: '#ebbcba', - }, - icon: {}, - welcome: { - color: '#e0def4', - }, - card: { - minWidth: 300, - background: '#191724', - }, - avatar: {}, - button: { - boxShadow: '3px 3px 5px rgba(25, 23, 36, 0.35)', - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: - 'linear-gradient(to bottom, rgba(25, 23, 36, 0.72), rgb(25, 23, 36))!important', - }, - }, - }, - player: { - theme: 'dark', - stylesheet, - }, -} diff --git a/ui/src/themes/rosePineDawn.css.js b/ui/src/themes/rosePineDawn.css.js deleted file mode 100644 index e3c882815..000000000 --- a/ui/src/themes/rosePineDawn.css.js +++ /dev/null @@ -1,198 +0,0 @@ -const stylesheet = ` - .react-jinke-music-player-main.light-theme svg, - .react-jinke-music-player .music-player-controller, - .react-jinke-music-player .audio-circle-process-bar circle[class='stroke'] { - color: #797593; - stroke: #797593; - } - - .react-jinke-music-player-main svg:active, - .react-jinke-music-player-main svg:hover { - color: #907aa9; - } - - .react-jinke-music-player-main.light-theme svg:active, - .react-jinke-music-player-main.light-theme svg:hover { - color: #907aa9; - } - - .react-jinke-music-player-mobile-play-model-tip, - .react-jinke-music-player-main.light-theme .play-mode-title { - background-color: #d7827e; - color: #faf4ed; - } - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #d7827e; - } - - .react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #d7827e; - } - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #d7827e; - } - - .react-jinke-music-player-main .audio-item.playing svg { - color: #d7827e; - } - - .react-jinke-music-player-main .audio-item.playing .player-singer { - color: #d7827e !important; - } - - .react-jinke-music-player-main .loading svg { - color: #d7827e !important; - } - - .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { - border: none; - box-shadow: - rgba(70, 66, 97, 0.12) 0px 4px 6px, - rgba(70, 66, 97, 0.08) 0px 5px 7px; - } - - .rc-slider-rail, - .rc-slider-track { - height: 6px; - } - - .rc-slider { - padding: 3px 0; - } - - .react-jinke-music-player-main.light-theme .rc-switch-checked { - background-color: #d7827e !important; - border: 1px solid #d7827e; - } - - .sound-operation > div:nth-child(4) { - transform: translateX(-50%) translateY(5%) !important; - } - - .sound-operation { - padding: 4px 0; - } - - .react-jinke-music-player-main .music-player-panel { - background-color: #fffaf3; - color: #464261; - box-shadow: 0 0 8px rgba(70, 66, 97, 0.12); - } - - .react-jinke-music-player-main.light-theme .music-player-panel { - color: #464261; - } - - .audio-lists-panel { - background-color: #fffaf3; - bottom: 6.25rem; - box-shadow: - rgba(70, 66, 97, 0.12) 0px 4px 6px, - rgba(70, 66, 97, 0.08) 0px 5px 7px; - } - - .audio-lists-panel-content .audio-item.playing { - background-color: rgba(0, 0, 0, 0); - } - - .audio-lists-panel-content .audio-item:nth-child(2n+1) { - background-color: rgba(0, 0, 0, 0); - } - - .audio-lists-panel-header { - border-bottom: 1px solid #f2e9e1; - box-shadow: none; - } - - .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { - background-color: rgba(0, 0, 0, 0); - box-shadow: 0 0 0 0; - } - - .react-jinke-music-player-main.light-theme .audio-lists-panel-header { - background-color: #fffaf3; - color: #464261; - } - - .audio-lists-panel-content .audio-item { - line-height: 32px; - color: #464261; - } - - .react-jinke-music-player-main .music-player-panel .panel-content .img-content { - box-shadow: - rgba(70, 66, 97, 0.12) 0px 4px 6px, - rgba(70, 66, 97, 0.08) 0px 5px 7px; - } - - .react-jinke-music-player-main .music-player-lyric { - color: #797593; - -webkit-text-stroke: 0.35px #faf4ed; - font-weight: bolder; - } - - .react-jinke-music-player-main .lyric-btn-active, - .react-jinke-music-player-main .lyric-btn-active svg { - color: #797593 !important; - } - - .audio-lists-panel-content .audio-item.playing, - .audio-lists-panel-content .audio-item.playing svg { - color: #d7827e; - } - - .audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, - .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { - color: #d7827e; - } - - .audio-lists-panel-content .audio-item .player-icons { - scale: 75%; - } - - .audio-lists-panel-content .audio-item:active, - .audio-lists-panel-content .audio-item:hover { - background-color: #f2e9e1; - } - - /* Mobile */ - .react-jinke-music-player-mobile-cover { - border: none; - box-shadow: - rgba(70, 66, 97, 0.12) 0px 4px 6px, - rgba(70, 66, 97, 0.08) 0px 5px 7px; - } - - .react-jinke-music-player .music-player-controller { - border: none; - background-color: #fffaf3; - border-color: #fffaf3; - box-shadow: - rgba(70, 66, 97, 0.12) 0px 4px 6px, - rgba(70, 66, 97, 0.08) 0px 5px 7px; - color: #d7827e; - } - - .react-jinke-music-player .music-player-controller.music-player-playing:before { - border: 1px solid rgba(70, 66, 97, 0.18); - } - - .react-jinke-music-player .music-player-controller .music-player-controller-setting { - background: rgba(215, 130, 126, 0.2); - color: #faf4ed; - } - - .react-jinke-music-player-mobile-progress .rc-slider-handle, - .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #d7827e; - } - - .react-jinke-music-player-mobile-progress .rc-slider-handle { - border: none; - } -` - -export default stylesheet diff --git a/ui/src/themes/rosePineDawn.js b/ui/src/themes/rosePineDawn.js deleted file mode 100644 index aea903c1a..000000000 --- a/ui/src/themes/rosePineDawn.js +++ /dev/null @@ -1,108 +0,0 @@ -import stylesheet from './rosePineDawn.css.js' - -export default { - themeName: 'Rosé Pine Dawn', - palette: { - primary: { - main: '#d7827e', - }, - secondary: { - main: '#fffaf3', - contrastText: '#464261', - }, - type: 'light', - background: { - default: '#faf4ed', - paper: '#fffaf3', - }, - }, - overrides: { - MuiPaper: { - root: { - color: '#464261', - backgroundColor: '#fffaf3', - }, - }, - MuiButton: { - textPrimary: { - color: '#286983', - }, - textSecondary: { - color: '#464261', - }, - }, - MuiIconButton: { - colorSecondary: { - color: '#9893a5', - }, - }, - MuiChip: { - clickable: { - background: '#f2e9e1', - }, - }, - MuiCheckbox: { - colorSecondary: { - color: '#9893a5', - '&$checked': { - color: '#d7827e', - }, - }, - }, - MuiFormGroup: { - root: { - color: '#464261', - }, - }, - MuiFormHelperText: { - root: { - '&$error': { - color: '#b4637a', - }, - }, - }, - MuiTableHead: { - root: { - color: '#464261', - background: '#fffaf3', - }, - }, - MuiTableCell: { - root: { - color: '#464261', - background: '#fffaf3 !important', - }, - head: { - color: '#464261', - background: '#fffaf3 !important', - }, - }, - NDLogin: { - systemNameLink: { - color: '#d7827e', - }, - icon: {}, - welcome: { - color: '#464261', - }, - card: { - minWidth: 300, - background: '#faf4ed', - }, - avatar: {}, - button: { - boxShadow: '3px 3px 5px rgba(87, 82, 121, 0.12)', - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: - 'linear-gradient(to bottom, rgba(250, 244, 237, 0.72), rgb(250, 244, 237))!important', - }, - }, - }, - player: { - theme: 'light', - stylesheet, - }, -} diff --git a/ui/src/themes/rosePineMoon.css.js b/ui/src/themes/rosePineMoon.css.js deleted file mode 100644 index 0eb7aaf57..000000000 --- a/ui/src/themes/rosePineMoon.css.js +++ /dev/null @@ -1,148 +0,0 @@ -const stylesheet = ` - -.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { - color: #c4a7e7 -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #ea9a97 -} - -.react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #ea9a97; -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #ea9a97 -} - -.react-jinke-music-player-main .audio-item.playing svg { - color: #ea9a97 -} - -.react-jinke-music-player-main .audio-item.playing .player-singer { - color: #ea9a97 !important -} - -.react-jinke-music-player-main .loading svg { - color: #ea9a97 !important -} - - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { - border: none; - box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px; -} - - -.rc-slider-rail, .rc-slider-track { - height: 6px; -} - -.rc-slider { - padding: 3px 0; -} - -.sound-operation > div:nth-child(4) { - transform: translateX(-50%) translateY(5%) !important; -} - -.sound-operation { - padding: 4px 0; -} - -.react-jinke-music-player-main .music-player-panel { - background-color: #2a273f; - color: #e0def4; - box-shadow: 0 0 8px rgba(35, 33, 54, 0.35); -} - -.audio-lists-panel { - background-color: #2a273f; - bottom: 6.25rem; - box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px; -} - -.audio-lists-panel-content .audio-item.playing { - background-color: rgba(0, 0, 0, 0); -} - -.audio-lists-panel-content .audio-item:nth-child(2n+1) { - background-color: rgba(0, 0, 0, 0); -} - - -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { - background-color:rgba(0,0,0,0); - box-shadow:0 0 0 0; -} - -.audio-lists-panel-content .audio-item { - line-height: 32px; -} - -.react-jinke-music-player-main .music-player-panel .panel-content .img-content { - box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px; -} - -.react-jinke-music-player-main .music-player-lyric { - color: #908caa; - -webkit-text-stroke: 0.5px #232136; - font-weight: bolder; -} - -.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { - color: #908caa !important; -} - -.audio-lists-panel-header { - border-bottom:1px solid #393552; - box-shadow:none; -} - -.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { - color: #ea9a97 -} - -.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { - color: #ea9a97 -} - -.audio-lists-panel-content .audio-item .player-icons { - scale: 75%; -} - -.audio-lists-panel-content .audio-item:active, -.audio-lists-panel-content .audio-item:hover { - background-color: #393552; -} - -/* Mobile */ - -.react-jinke-music-player-mobile-cover { - border: none; - box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px; -} - -.react-jinke-music-player .music-player-controller { - border: none; - background-color: #2a273f; - border-color: #2a273f; - box-shadow:rgba(35, 33, 54, 0.35) 0px 4px 6px, rgba(35, 33, 54, 0.2) 0px 5px 7px; - color: #ea9a97; -} - -.react-jinke-music-player .music-player-controller .music-player-controller-setting { - color: rgba(196,167,231,.3); -} - -.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #ea9a97; -} - -.react-jinke-music-player-mobile-progress .rc-slider-handle { - border: none; -} -` - -export default stylesheet diff --git a/ui/src/themes/rosePineMoon.js b/ui/src/themes/rosePineMoon.js deleted file mode 100644 index facf09446..000000000 --- a/ui/src/themes/rosePineMoon.js +++ /dev/null @@ -1,108 +0,0 @@ -import stylesheet from './rosePineMoon.css.js' - -export default { - themeName: 'Rosé Pine Moon', - palette: { - primary: { - main: '#ea9a97', - }, - secondary: { - main: '#2a273f', - contrastText: '#e0def4', - }, - type: 'dark', - background: { - default: '#232136', - paper: '#2a273f', - }, - }, - overrides: { - MuiPaper: { - root: { - color: '#e0def4', - backgroundColor: '#2a273f', - }, - }, - MuiButton: { - textPrimary: { - color: '#3e8fb0', - }, - textSecondary: { - color: '#e0def4', - }, - }, - MuiIconButton: { - colorSecondary: { - color: '#6e6a86', - }, - }, - MuiChip: { - clickable: { - background: '#393552', - }, - }, - MuiCheckbox: { - colorSecondary: { - color: '#6e6a86', - '&$checked': { - color: '#ea9a97', - }, - }, - }, - MuiFormGroup: { - root: { - color: '#e0def4', - }, - }, - MuiFormHelperText: { - root: { - '&$error': { - color: '#eb6f92', - }, - }, - }, - MuiTableHead: { - root: { - color: '#e0def4', - background: '#2a273f', - }, - }, - MuiTableCell: { - root: { - color: '#e0def4', - background: '#2a273f !important', - }, - head: { - color: '#e0def4', - background: '#2a273f !important', - }, - }, - NDLogin: { - systemNameLink: { - color: '#ea9a97', - }, - icon: {}, - welcome: { - color: '#e0def4', - }, - card: { - minWidth: 300, - background: '#232136', - }, - avatar: {}, - button: { - boxShadow: '3px 3px 5px rgba(35, 33, 54, 0.35)', - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: - 'linear-gradient(to bottom, rgba(35, 33, 54, 0.72), rgb(35, 33, 54))!important', - }, - }, - }, - player: { - theme: 'dark', - stylesheet, - }, -} diff --git a/ui/src/themes/spotify.js b/ui/src/themes/spotify.js index 725831cc7..7813291ad 100644 --- a/ui/src/themes/spotify.js +++ b/ui/src/themes/spotify.js @@ -204,6 +204,7 @@ export default { }, details: { fontSize: '.875rem', + minWidth: '75vw', color: 'rgba(255,255,255, 0.8)', }, }, @@ -229,8 +230,6 @@ export default { fontSize: '.875rem', color: 'rgba(255,255,255, 0.8)', }, - }, - NDCollapsibleComment: { commentBlock: { fontSize: '.875rem', color: 'rgba(255,255,255, 0.8)', @@ -242,64 +241,6 @@ export default { NDPlaylistShow: { playlistActions: musicListActions, }, - NDArtistShow: { - actions: { - padding: '2rem 0', - alignItems: 'center', - overflow: 'visible', - minHeight: '120px', - '@global': { - button: { - border: '1px solid transparent', - backgroundColor: 'inherit', - color: '#b3b3b3', - margin: '0 0.5rem', - '&:hover': { - border: '1px solid #b3b3b3', - backgroundColor: 'inherit !important', - }, - }, - // Hide shuffle button label (first button) - 'button:first-child>span:first-child>span': { - display: 'none', - }, - // Style shuffle button (first button) - 'button:first-child': { - '@media screen and (max-width: 720px)': { - transform: 'scale(1.5)', - margin: '1rem', - '&:hover': { - transform: 'scale(1.6) !important', - }, - }, - transform: 'scale(2)', - margin: '1.5rem', - minWidth: 0, - padding: 5, - transition: 'transform .3s ease', - background: spotifyGreen['500'], - color: '#fff', - borderRadius: 500, - border: 0, - '&:hover': { - transform: 'scale(2.1)', - backgroundColor: `${spotifyGreen['500']} !important`, - border: 0, - }, - }, - 'button:first-child>span:first-child': { - padding: 0, - }, - 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': - { - color: '#b3b3b3', - }, - }, - }, - actionsContainer: { - overflow: 'visible', - }, - }, NDAudioPlayer: { audioTitle: { color: '#fff', @@ -389,11 +330,6 @@ export default { marginRight: '1rem', }, }, - RaButton: { - button: { - margin: '0 5px 0 5px', - }, - }, RaPaginationActions: { currentPageButton: { border: '1px solid #b3b3b3', diff --git a/ui/src/themes/theme.test.js b/ui/src/themes/theme.test.js deleted file mode 100644 index a015ae696..000000000 --- a/ui/src/themes/theme.test.js +++ /dev/null @@ -1,36 +0,0 @@ -import themes from './index' -import { describe, it, expect } from 'vitest' - -describe('NDPlaylistDetails styles', () => { - const themeEntries = Object.entries(themes) - - it.each(themeEntries)( - '%s should not set minWidth on details', - (themeName, theme) => { - const details = theme.overrides?.NDPlaylistDetails?.details - expect(details?.minWidth).toBeUndefined() - }, - ) -}) - -describe('NDAlbumGridView styles', () => { - const themeEntries = Object.entries(themes) - - // The hover overlay is a sibling of the image, so it keeps square corners. - it.each(themeEntries)( - '%s should not round the grid cover image on its own', - (themeName, theme) => { - const container = theme.overrides?.NDAlbumGridView?.albumContainer - expect(container?.['& img']?.borderRadius).toBeUndefined() - }, - ) - - it.each(themeEntries)( - '%s should clip the grid cover link when it is rounded', - (themeName, theme) => { - const link = theme.overrides?.NDAlbumGridView?.link - if (!link?.borderRadius) return - expect(link.overflow).toBe('hidden') - }, - ) -}) diff --git a/ui/src/themes/tokyoNight.css.js b/ui/src/themes/tokyoNight.css.js deleted file mode 100644 index 882fcd3eb..000000000 --- a/ui/src/themes/tokyoNight.css.js +++ /dev/null @@ -1,143 +0,0 @@ -const stylesheet = ` - -.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { - color: #7aa2f7 -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #7aa2f7 -} - -.react-jinke-music-player-main ::-webkit-scrollbar-thumb { - background-color: #7aa2f7; -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #7aa2f7 -} - -.react-jinke-music-player-main .audio-item.playing svg { - color: #7aa2f7 -} - -.react-jinke-music-player-main .audio-item.playing .player-singer { - color: #7aa2f7 !important -} - -.react-jinke-music-player-main .loading svg { - color: #7aa2f7 !important -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { - border: hidden; - box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; -} - -.rc-slider-rail, .rc-slider-track { - height: 6px; -} - -.rc-slider { - padding: 3px 0; -} - -.sound-operation > div:nth-child(4) { - transform: translateX(-50%) translateY(5%) !important; -} - -.sound-operation { - padding: 4px 0; -} - -.react-jinke-music-player-main .music-player-panel { - background-color: #24283b; - color: #c0caf5; - box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); -} - -.audio-lists-panel { - background-color: #24283b; - bottom: 6.25rem; - box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; -} - -.audio-lists-panel-content .audio-item.playing { - background-color: rgba(0, 0, 0, 0); -} - -.audio-lists-panel-content .audio-item:nth-child(2n+1) { - background-color: rgba(0, 0, 0, 0); -} - -.audio-lists-panel-content .audio-item:active, -.audio-lists-panel-content .audio-item:hover { - background-color: #292e42; -} - -.audio-lists-panel-header { - border-bottom: 1px solid rgba(0, 0, 0, 0.25); - box-shadow: none; -} - -.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { - background-color: rgba(0, 0, 0, 0); - box-shadow: 0 0 0 0; -} - -.audio-lists-panel-content .audio-item { - line-height: 32px; -} - -.react-jinke-music-player-main .music-player-panel .panel-content .img-content { - box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; -} - -.react-jinke-music-player-main .music-player-lyric { - color: #c0caf5; - -webkit-text-stroke: 0.5px #1a1b26; - font-weight: bolder; -} - -.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { - color: #7aa2f7 !important; -} - -.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { - color: #7aa2f7 -} - -.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { - color: #7aa2f7 -} - -.audio-lists-panel-content .audio-item .player-icons { - scale: 75%; -} - -/* Mobile */ - -.react-jinke-music-player-mobile-cover { - border: none; - box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; -} - -.react-jinke-music-player .music-player-controller { - border: none; - box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; - color: #7aa2f7; -} - -.react-jinke-music-player .music-player-controller .music-player-controller-setting { - color: rgba(122, 162, 247, .3); -} - -.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { - background-color: #7aa2f7; -} - -.react-jinke-music-player-mobile-progress .rc-slider-handle { - border: none; -} -` - -export default stylesheet diff --git a/ui/src/themes/tokyoNight.js b/ui/src/themes/tokyoNight.js deleted file mode 100644 index 9f6424b77..000000000 --- a/ui/src/themes/tokyoNight.js +++ /dev/null @@ -1,372 +0,0 @@ -import stylesheet from './tokyoNight.css.js' - -const background = '#1a1b26' -const surface = '#24283b' -const currentLine = '#292e42' -const foreground = '#c0caf5' -const comment = '#565f89' -const blue = '#7aa2f7' -const cyan = '#7dcfff' -const purple = '#bb9af7' -const red = '#f7768e' - -// For Album, Playlist play button -const musicListActions = { - alignItems: 'center', - '@global': { - 'button:first-child:not(:only-child)': { - '@media screen and (max-width: 720px)': { - transform: 'scale(1.5)', - margin: '1rem', - '&:hover': { - transform: 'scale(1.6) !important', - }, - }, - transform: 'scale(2)', - margin: '1.5rem', - minWidth: 0, - padding: 5, - transition: 'transform .3s ease', - backgroundColor: `${blue} !important`, - color: background, - borderRadius: 500, - border: 0, - '&:hover': { - transform: 'scale(2.1)', - backgroundColor: `${blue} !important`, - border: 0, - }, - }, - 'button:only-child': { - margin: '1.5rem', - }, - 'button:first-child>span:first-child': { - padding: 0, - }, - 'button:first-child>span:first-child>span': { - display: 'none', - }, - 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': - { - color: foreground, - }, - }, -} - -export default { - themeName: 'Tokyo Night', - palette: { - primary: { - main: blue, - }, - secondary: { - main: purple, - contrastText: foreground, - }, - error: { - main: red, - }, - type: 'dark', - background: { - default: background, - paper: surface, - }, - }, - overrides: { - MuiPaper: { - root: { - color: foreground, - backgroundColor: surface, - }, - }, - MuiAppBar: { - positionFixed: { - backgroundColor: `${surface} !important`, - boxShadow: - 'rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px', - }, - }, - MuiDrawer: { - root: { - background: background, - }, - }, - MuiButton: { - textPrimary: { - color: blue, - }, - textSecondary: { - color: foreground, - }, - }, - MuiIconButton: { - root: { - color: foreground, - }, - }, - MuiChip: { - root: { - backgroundColor: currentLine, - }, - }, - MuiFormGroup: { - root: { - color: foreground, - }, - }, - MuiFormLabel: { - root: { - color: comment, - '&$focused': { - color: blue, - }, - }, - }, - MuiFormHelperText: { - error: { - color: red, - }, - }, - MuiToolbar: { - root: { - backgroundColor: `${surface} !important`, - }, - }, - MuiOutlinedInput: { - root: { - '& $notchedOutline': { - borderColor: currentLine, - }, - '&:hover $notchedOutline': { - borderColor: comment, - }, - '&$focused $notchedOutline': { - borderColor: blue, - }, - }, - }, - MuiFilledInput: { - root: { - backgroundColor: currentLine, - '&:hover': { - backgroundColor: comment, - }, - '&$focused': { - backgroundColor: currentLine, - }, - }, - }, - MuiTableRow: { - root: { - transition: 'background-color .3s ease', - '&:hover': { - backgroundColor: `${currentLine} !important`, - }, - }, - }, - MuiTableHead: { - root: { - color: foreground, - background: surface, - }, - }, - MuiTableCell: { - root: { - color: foreground, - background: `${surface} !important`, - borderBottom: `1px solid ${currentLine}`, - }, - head: { - color: `${blue} !important`, - background: `${currentLine} !important`, - }, - body: { - color: `${foreground} !important`, - }, - }, - NDAlbumGridView: { - albumName: { - marginTop: '0.5rem', - fontWeight: 700, - color: foreground, - }, - albumSubtitle: { - color: comment, - }, - albumContainer: { - backgroundColor: surface, - borderRadius: '8px', - padding: '.75rem', - transition: 'background-color .3s ease', - '&:hover': { - backgroundColor: currentLine, - }, - }, - albumPlayButton: { - backgroundColor: blue, - borderRadius: '50%', - boxShadow: '0 8px 8px rgb(0 0 0 / 30%)', - padding: '0.35rem', - transition: 'padding .3s ease', - '&:hover': { - background: `${blue} !important`, - padding: '0.45rem', - }, - }, - }, - NDPlaylistDetails: { - container: { - background: `linear-gradient(${currentLine}, transparent)`, - borderRadius: 0, - paddingTop: '2.5rem !important', - boxShadow: 'none', - }, - title: { - fontWeight: 700, - color: foreground, - }, - details: { - fontSize: '.875rem', - color: comment, - }, - }, - NDAlbumDetails: { - root: { - background: `linear-gradient(${currentLine}, transparent)`, - borderRadius: 0, - boxShadow: 'none', - }, - cardContents: { - alignItems: 'center', - paddingTop: '1.5rem', - }, - recordName: { - fontWeight: 700, - color: foreground, - }, - recordArtist: { - fontSize: '.875rem', - fontWeight: 700, - color: purple, - }, - recordMeta: { - fontSize: '.875rem', - color: comment, - }, - }, - NDCollapsibleComment: { - commentBlock: { - fontSize: '.875rem', - color: comment, - }, - }, - NDAlbumShow: { - albumActions: musicListActions, - }, - NDPlaylistShow: { - playlistActions: musicListActions, - }, - NDAudioPlayer: { - audioTitle: { - color: foreground, - fontSize: '0.875rem', - }, - songTitle: { - fontWeight: 400, - }, - songInfo: { - fontSize: '0.675rem', - color: comment, - }, - }, - NDLogin: { - systemNameLink: { - color: blue, - }, - welcome: { - color: foreground, - }, - card: { - minWidth: 300, - background: surface, - }, - button: { - boxShadow: '3px 3px 5px #15161e', - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: `linear-gradient(to bottom, rgba(26 27 38 / 72%), ${background})!important`, - }, - }, - RaLayout: { - content: { - padding: '0 !important', - background: background, - }, - root: { - backgroundColor: background, - }, - }, - RaList: { - content: { - backgroundColor: background, - }, - }, - RaListToolbar: { - toolbar: { - backgroundColor: background, - padding: '0 .55rem !important', - }, - }, - RaSidebar: { - fixed: { - backgroundColor: background, - }, - drawerPaper: { - backgroundColor: `${background} !important`, - }, - }, - RaMenuItemLink: { - root: { - color: foreground, - '&[aria-current="page"]': { - color: `${blue} !important`, - }, - '&[aria-current="page"] .MuiListItemIcon-root': { - color: `${blue} !important`, - }, - }, - active: { - color: `${blue} !important`, - '& .MuiListItemIcon-root': { - color: `${blue} !important`, - }, - }, - }, - RaLink: { - link: { - color: cyan, - }, - }, - RaButton: { - button: { - margin: '0 5px 0 5px', - }, - }, - RaPaginationActions: { - currentPageButton: { - border: `2px solid ${blue}`, - }, - button: { - backgroundColor: currentLine, - minWidth: 48, - margin: '0 4px', - }, - }, - }, - player: { - theme: 'dark', - stylesheet, - }, -} diff --git a/ui/src/themes/tokyoNightLight.css.js b/ui/src/themes/tokyoNightLight.css.js deleted file mode 100644 index a22c82d03..000000000 --- a/ui/src/themes/tokyoNightLight.css.js +++ /dev/null @@ -1,123 +0,0 @@ -const stylesheet = ` -.react-jinke-music-player-main.light-theme .loading svg { - color: #2e7de9; - font-size: 24px -} - -.react-jinke-music-player-mobile-play-model-tip { - background-color: #2e7de9; -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #2e7de9 -} - -.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { - box-shadow: 0 0 2px #2e7de9 -} - -.react-jinke-music-player-main.light-theme .audio-item.playing svg { - color: #2e7de9 -} - -.react-jinke-music-player-main.light-theme .audio-item.playing .player-singer { - color: #2e7de9 !important -} - -.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { - color: #2e7de9 -} - -.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { - color: #2e7de9 -} - -.react-jinke-music-player-main.light-theme ::-webkit-scrollbar-thumb { - background-color: #2e7de9; -} - -.react-jinke-music-player-main.light-theme svg { - color: #3760bf -} - -.react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover { - color: #2e7de9 -} - -.react-jinke-music-player-main.light-theme .rc-slider-rail { - background-color: rgba(55, 96, 191, .12) !important -} - -.react-jinke-music-player-main.light-theme .music-player-controller { - background-color: #d5d6db; - border-color: #d5d6db -} - -.react-jinke-music-player-main.light-theme .music-player-panel { - background-color: #d5d6db; - box-shadow: 0 1px 2px 0 rgba(0, 34, 77, .05); - color: #3760bf -} - -.react-jinke-music-player-main.light-theme .music-player-panel .img-content { - box-shadow: 0 0 10px #c4c8da -} - -.react-jinke-music-player-main.light-theme .music-player-panel .progress-load-bar { - background-color: rgba(55, 96, 191, .08) !important -} - -.react-jinke-music-player-main.light-theme .rc-switch { - color: #fff -} - -.react-jinke-music-player-main.light-theme .rc-switch:after { - background-color: #fff -} - -.react-jinke-music-player-main.light-theme .rc-switch-checked { - background-color: #2e7de9 !important; - border: 1px solid #2e7de9 -} - -.react-jinke-music-player-main.light-theme .rc-switch-inner { - color: #fff -} - -.react-jinke-music-player-main.light-theme .audio-lists-btn { - background-color: #e1e2e7 !important -} - -.react-jinke-music-player-main.light-theme .audio-lists-btn:active, .react-jinke-music-player-main.light-theme .audio-lists-btn:hover { - background-color: #ebebed; - color: #3760bf -} - -.react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover, .react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover > svg { - color: #2e7de9 -} - -.react-jinke-music-player-main.light-theme .audio-lists-panel { - background-color: #d5d6db; - box-shadow: 0 0 2px #c4c8da; - color: #3760bf -} - -.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item { - background-color: #d5d6db -} - -.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item:nth-child(odd) { - background-color: #dadbe0 !important -} - -.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing { - background-color: #c4c8da !important -} - -.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing, .react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing svg { - color: #2e7de9 !important -} -` - -export default stylesheet diff --git a/ui/src/themes/tokyoNightLight.js b/ui/src/themes/tokyoNightLight.js deleted file mode 100644 index a61c0fe87..000000000 --- a/ui/src/themes/tokyoNightLight.js +++ /dev/null @@ -1,372 +0,0 @@ -import stylesheet from './tokyoNightLight.css.js' - -const background = '#e1e2e7' -const surface = '#d5d6db' -const currentLine = '#c4c8da' -const foreground = '#3760bf' -const comment = '#848cb5' -const blue = '#2e7de9' -const cyan = '#007197' -const purple = '#9854f1' -const red = '#f52a65' - -// For Album, Playlist play button -const musicListActions = { - alignItems: 'center', - '@global': { - 'button:first-child:not(:only-child)': { - '@media screen and (max-width: 720px)': { - transform: 'scale(1.5)', - margin: '1rem', - '&:hover': { - transform: 'scale(1.6) !important', - }, - }, - transform: 'scale(2)', - margin: '1.5rem', - minWidth: 0, - padding: 5, - transition: 'transform .3s ease', - backgroundColor: `${blue} !important`, - color: background, - borderRadius: 500, - border: 0, - '&:hover': { - transform: 'scale(2.1)', - backgroundColor: `${blue} !important`, - border: 0, - }, - }, - 'button:only-child': { - margin: '1.5rem', - }, - 'button:first-child>span:first-child': { - padding: 0, - }, - 'button:first-child>span:first-child>span': { - display: 'none', - }, - 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': - { - color: foreground, - }, - }, -} - -export default { - themeName: 'Tokyo Night Light', - palette: { - primary: { - main: blue, - }, - secondary: { - main: purple, - contrastText: foreground, - }, - error: { - main: red, - }, - type: 'light', - background: { - default: background, - paper: surface, - }, - }, - overrides: { - MuiPaper: { - root: { - color: foreground, - backgroundColor: surface, - }, - }, - MuiAppBar: { - positionFixed: { - backgroundColor: `${surface} !important`, - boxShadow: - 'rgba(15, 17, 21, 0.15) 0px 4px 6px, rgba(15, 17, 21, 0.08) 0px 5px 7px', - }, - }, - MuiDrawer: { - root: { - background: background, - }, - }, - MuiButton: { - textPrimary: { - color: blue, - }, - textSecondary: { - color: foreground, - }, - }, - MuiIconButton: { - root: { - color: foreground, - }, - }, - MuiChip: { - root: { - backgroundColor: currentLine, - }, - }, - MuiFormGroup: { - root: { - color: foreground, - }, - }, - MuiFormLabel: { - root: { - color: comment, - '&$focused': { - color: blue, - }, - }, - }, - MuiFormHelperText: { - error: { - color: red, - }, - }, - MuiToolbar: { - root: { - backgroundColor: `${surface} !important`, - }, - }, - MuiOutlinedInput: { - root: { - '& $notchedOutline': { - borderColor: currentLine, - }, - '&:hover $notchedOutline': { - borderColor: comment, - }, - '&$focused $notchedOutline': { - borderColor: blue, - }, - }, - }, - MuiFilledInput: { - root: { - backgroundColor: currentLine, - '&:hover': { - backgroundColor: comment, - }, - '&$focused': { - backgroundColor: currentLine, - }, - }, - }, - MuiTableRow: { - root: { - transition: 'background-color .3s ease', - '&:hover': { - backgroundColor: `${currentLine} !important`, - }, - }, - }, - MuiTableHead: { - root: { - color: foreground, - background: surface, - }, - }, - MuiTableCell: { - root: { - color: foreground, - background: `${surface} !important`, - borderBottom: `1px solid ${currentLine}`, - }, - head: { - color: `${blue} !important`, - background: `${currentLine} !important`, - }, - body: { - color: `${foreground} !important`, - }, - }, - NDAlbumGridView: { - albumName: { - marginTop: '0.5rem', - fontWeight: 700, - color: foreground, - }, - albumSubtitle: { - color: comment, - }, - albumContainer: { - backgroundColor: surface, - borderRadius: '8px', - padding: '.75rem', - transition: 'background-color .3s ease', - '&:hover': { - backgroundColor: currentLine, - }, - }, - albumPlayButton: { - backgroundColor: blue, - borderRadius: '50%', - boxShadow: '0 8px 8px rgb(0 0 0 / 20%)', - padding: '0.35rem', - transition: 'padding .3s ease', - '&:hover': { - background: `${blue} !important`, - padding: '0.45rem', - }, - }, - }, - NDPlaylistDetails: { - container: { - background: `linear-gradient(${currentLine}, transparent)`, - borderRadius: 0, - paddingTop: '2.5rem !important', - boxShadow: 'none', - }, - title: { - fontWeight: 700, - color: foreground, - }, - details: { - fontSize: '.875rem', - color: comment, - }, - }, - NDAlbumDetails: { - root: { - background: `linear-gradient(${currentLine}, transparent)`, - borderRadius: 0, - boxShadow: 'none', - }, - cardContents: { - alignItems: 'center', - paddingTop: '1.5rem', - }, - recordName: { - fontWeight: 700, - color: foreground, - }, - recordArtist: { - fontSize: '.875rem', - fontWeight: 700, - color: purple, - }, - recordMeta: { - fontSize: '.875rem', - color: comment, - }, - }, - NDCollapsibleComment: { - commentBlock: { - fontSize: '.875rem', - color: comment, - }, - }, - NDAlbumShow: { - albumActions: musicListActions, - }, - NDPlaylistShow: { - playlistActions: musicListActions, - }, - NDAudioPlayer: { - audioTitle: { - color: foreground, - fontSize: '0.875rem', - }, - songTitle: { - fontWeight: 400, - }, - songInfo: { - fontSize: '0.675rem', - color: comment, - }, - }, - NDLogin: { - systemNameLink: { - color: blue, - }, - welcome: { - color: foreground, - }, - card: { - minWidth: 300, - background: surface, - }, - button: { - boxShadow: '3px 3px 5px #a8aecb', - }, - }, - NDMobileArtistDetails: { - bgContainer: { - background: `linear-gradient(to bottom, rgba(225 226 231 / 72%), ${background})!important`, - }, - }, - RaLayout: { - content: { - padding: '0 !important', - background: background, - }, - root: { - backgroundColor: background, - }, - }, - RaList: { - content: { - backgroundColor: background, - }, - }, - RaListToolbar: { - toolbar: { - backgroundColor: background, - padding: '0 .55rem !important', - }, - }, - RaSidebar: { - fixed: { - backgroundColor: background, - }, - drawerPaper: { - backgroundColor: `${background} !important`, - }, - }, - RaMenuItemLink: { - root: { - color: foreground, - '&[aria-current="page"]': { - color: `${blue} !important`, - }, - '&[aria-current="page"] .MuiListItemIcon-root': { - color: `${blue} !important`, - }, - }, - active: { - color: `${blue} !important`, - '& .MuiListItemIcon-root': { - color: `${blue} !important`, - }, - }, - }, - RaLink: { - link: { - color: cyan, - }, - }, - RaButton: { - button: { - margin: '0 5px 0 5px', - }, - }, - RaPaginationActions: { - currentPageButton: { - border: `2px solid ${blue}`, - }, - button: { - backgroundColor: currentLine, - minWidth: 48, - margin: '0 4px', - }, - }, - }, - player: { - theme: 'light', - stylesheet, - }, -} diff --git a/ui/src/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js index fbb5e9bc8..61138d7b4 100644 --- a/ui/src/themes/useCurrentTheme.js +++ b/ui/src/themes/useCurrentTheme.js @@ -3,14 +3,10 @@ import useMediaQuery from '@material-ui/core/useMediaQuery' import themes from './index' import { AUTO_THEME_ID } from '../consts' import config from '../config' -import { useEffect, useMemo } from 'react' +import { useEffect } from 'react' const useCurrentTheme = () => { - // Runs above the ThemeProvider carrying the prop below, so it needs its own noSsr or the - // auto theme renders dark first and flips. - const prefersLightMode = useMediaQuery('(prefers-color-scheme: light)', { - noSsr: true, - }) + const prefersLightMode = useMediaQuery('(prefers-color-scheme: light)') const theme = useSelector((state) => { if (state.theme === AUTO_THEME_ID) { return prefersLightMode ? themes.LightTheme : themes.DarkTheme @@ -18,7 +14,7 @@ const useCurrentTheme = () => { const themeName = Object.keys(themes).find((t) => t === state.theme) || Object.keys(themes).find( - (t) => themes[t].themeName === config.defaultTheme, + (t) => themes[t].themeName === config.defaultTheme ) || 'DarkTheme' return themes[themeName] @@ -46,29 +42,9 @@ const useCurrentTheme = () => { document.head.removeChild(style) } } - - // Set body background color to match theme (fixes white background on pull-to-refresh) - const isDark = theme.palette?.type === 'dark' - const bgColor = - theme.palette?.background?.default || (isDark ? '#303030' : '#fafafa') - document.body.style.backgroundColor = bgColor }, [theme]) - // We never server-render, so let media queries resolve on the first render: the default - // defers them to an effect, which makes every mount paint the wrong breakpoint and reflow. - return useMemo( - () => ({ - ...theme, - props: { - ...theme.props, - MuiUseMediaQuery: { noSsr: true }, - MuiPopover: { disableScrollLock: true }, - // MUI defaults to secondary, which many themes use as a surface color - MuiSwitch: { color: 'primary' }, - }, - }), - [theme], - ) + return theme } export default useCurrentTheme diff --git a/ui/src/themes/useCurrentTheme.test.jsx b/ui/src/themes/useCurrentTheme.test.js similarity index 60% rename from ui/src/themes/useCurrentTheme.test.jsx rename to ui/src/themes/useCurrentTheme.test.js index 6553d9866..03775d34f 100644 --- a/ui/src/themes/useCurrentTheme.test.jsx +++ b/ui/src/themes/useCurrentTheme.test.js @@ -3,10 +3,6 @@ import { Provider } from 'react-redux' import { createStore } from 'redux' import mediaQuery from 'css-mediaquery' import { renderHook } from '@testing-library/react-hooks' -import { render, screen } from '@testing-library/react' -import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles' -import Switch from '@material-ui/core/Switch' -import themes from './index' import useCurrentTheme from './useCurrentTheme' import { themeReducer } from '../reducers/themeReducer' import { AUTO_THEME_ID } from '../consts' @@ -19,10 +15,6 @@ function createMatchMedia(theme) { }) } -beforeEach(() => { - document.body.style.backgroundColor = '' -}) - describe('useCurrentTheme', () => { describe('with user preference theme as light', () => { beforeAll(() => { @@ -125,67 +117,4 @@ describe('useCurrentTheme', () => { expect(result.current.themeName).toMatch('Spotify-ish') }) }) - describe('body background color', () => { - beforeAll(() => { - window.matchMedia = createMatchMedia('dark') - }) - it('sets body background for dark theme', () => { - renderHook(() => useCurrentTheme(), { - wrapper: ({ children }) => ( - <Provider store={createStore(themeReducer, { theme: 'DarkTheme' })}> - {children} - </Provider> - ), - }) - // Dark theme uses MUI default dark background - expect(document.body.style.backgroundColor).toBe('rgb(48, 48, 48)') - }) - it('sets body background for light theme', () => { - renderHook(() => useCurrentTheme(), { - wrapper: ({ children }) => ( - <Provider store={createStore(themeReducer, { theme: 'LightTheme' })}> - {children} - </Provider> - ), - }) - // Light theme uses MUI default light background - expect(document.body.style.backgroundColor).toBe('rgb(250, 250, 250)') - }) - it('sets body background for theme with custom background', () => { - renderHook(() => useCurrentTheme(), { - wrapper: ({ children }) => ( - <Provider - store={createStore(themeReducer, { theme: 'SpotifyTheme' })} - > - {children} - </Provider> - ), - }) - // Spotify theme has explicit background.default: #121212 - expect(document.body.style.backgroundColor).toBe('rgb(18, 18, 18)') - }) - }) - describe('switch color', () => { - it.each(Object.keys(themes))( - 'renders switches with the primary color in %s', - (theme) => { - const { result } = renderHook(() => useCurrentTheme(), { - wrapper: ({ children }) => ( - <Provider store={createStore(themeReducer, { theme })}> - {children} - </Provider> - ), - }) - render( - <ThemeProvider theme={createMuiTheme(result.current)}> - <Switch checked onChange={() => {}} /> - </ThemeProvider>, - ) - const switchBase = screen - .getByRole('checkbox') - .closest('.MuiSwitch-switchBase') - expect(switchBase.classList).toContain('MuiSwitch-colorPrimary') - }, - ) - }) }) diff --git a/ui/src/transcode/browserProfile.js b/ui/src/transcode/browserProfile.js deleted file mode 100644 index d268af7c9..000000000 --- a/ui/src/transcode/browserProfile.js +++ /dev/null @@ -1,80 +0,0 @@ -// Each entry: { codec name for the server, container, mime: [MIME probe strings] } -export const CODEC_PROBES = [ - { codec: 'mp3', container: 'mp3', mime: ['audio/mpeg; codecs="mp3"'] }, - { codec: 'opus', container: 'ogg', mime: ['audio/ogg; codecs="opus"'] }, - { codec: 'vorbis', container: 'ogg', mime: ['audio/ogg; codecs="vorbis"'] }, - { - codec: 'flac', - container: 'flac', - mime: ['audio/flac', 'audio/flac; codecs="flac"'], - }, - { codec: 'wav', container: 'wav', mime: ['audio/wav; codecs="1"'] }, - { codec: 'alac', container: 'mp4', mime: ['audio/mp4; codecs="alac"'] }, - { codec: 'aac', container: 'mp4', mime: ['audio/mp4; codecs="mp4a.40.2"'] }, -] - -// Transcoding targets in preference order (lossless first, then lossy). -// Derived from CODEC_PROBES to avoid duplicating MIME strings. -// MP3 is always included as a universal fallback. -const TRANSCODE_CODECS = ['flac', 'opus', 'mp3'] - -// Safari transcoding is limited to mp3 only. Safari cannot reliably stream -// Ogg containers (reports canPlayType support but fails on non-seekable -// transcoded streams), and FLAC transcoding also fails in practice. -const SAFARI_TRANSCODE_CODECS = ['mp3'] - -function canPlay(audio, mimeList) { - return mimeList.some((m) => { - const result = audio.canPlayType(m) - return result === 'probably' || result === 'maybe' - }) -} - -function probeSupported(audio, probes) { - return probes.filter(({ mime }) => canPlay(audio, mime)) -} - -function isSafari() { - const ua = navigator.userAgent - return ( - ua.includes('Safari') && !ua.includes('Chrome') && !ua.includes('Chromium') - ) -} - -export function detectBrowserProfile() { - const audio = new Audio() - - const directPlayProfiles = probeSupported(audio, CODEC_PROBES).map( - ({ codec, container }) => ({ - containers: [container], - audioCodecs: [codec], - protocols: ['http'], - }), - ) - - // Build transcoding profiles from supported codecs, always keeping mp3 as fallback. - // Safari is limited to mp3 transcoding only. - const transcodeCodecs = isSafari() - ? SAFARI_TRANSCODE_CODECS - : TRANSCODE_CODECS - const transcodingProfiles = transcodeCodecs.reduce((profiles, codec) => { - const probe = CODEC_PROBES.find((p) => p.codec === codec) - if (!probe) return profiles - if (canPlay(audio, probe.mime) || codec === 'mp3') { - profiles.push({ - container: probe.container, - audioCodec: codec, - protocol: 'http', - }) - } - return profiles - }, []) - - return { - name: 'NavidromeUI', - platform: navigator.userAgent, - directPlayProfiles, - transcodingProfiles, - codecProfiles: [], - } -} diff --git a/ui/src/transcode/browserProfile.test.js b/ui/src/transcode/browserProfile.test.js deleted file mode 100644 index e79b7a744..000000000 --- a/ui/src/transcode/browserProfile.test.js +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { detectBrowserProfile, CODEC_PROBES } from './browserProfile' - -describe('detectBrowserProfile', () => { - let mockCanPlayType - - beforeEach(() => { - mockCanPlayType = vi.fn() - vi.stubGlobal( - 'Audio', - class { - canPlayType = mockCanPlayType - }, - ) - }) - - it('includes codecs that return "probably"', () => { - mockCanPlayType.mockImplementation((mime) => { - if (mime === 'audio/mpeg; codecs="mp3"') return 'probably' - if (mime === 'audio/ogg; codecs="opus"') return 'probably' - return '' - }) - - const profile = detectBrowserProfile() - - expect(profile.name).toBe('NavidromeUI') - expect(profile.directPlayProfiles.length).toBe(2) - - const codecs = profile.directPlayProfiles.flatMap((p) => p.audioCodecs) - expect(codecs).toContain('mp3') - expect(codecs).toContain('opus') - }) - - it('includes codecs that return "maybe"', () => { - mockCanPlayType.mockImplementation((mime) => { - if (mime === 'audio/flac') return 'maybe' - return '' - }) - - const profile = detectBrowserProfile() - const codecs = profile.directPlayProfiles.flatMap((p) => p.audioCodecs) - expect(codecs).toContain('flac') - }) - - it('excludes codecs that return empty string', () => { - mockCanPlayType.mockReturnValue('') - - const profile = detectBrowserProfile() - expect(profile.directPlayProfiles).toEqual([]) - }) - - it('sets protocol to "http" for all direct play profiles', () => { - mockCanPlayType.mockReturnValue('probably') - - const profile = detectBrowserProfile() - profile.directPlayProfiles.forEach((p) => { - expect(p.protocols).toEqual(['http']) - }) - }) - - it('filters transcoding profiles by canPlayType', () => { - mockCanPlayType.mockImplementation((mime) => { - if (mime === 'audio/mpeg; codecs="mp3"') return 'probably' - if (mime === 'audio/ogg; codecs="opus"') return 'probably' - return '' - }) - - const profile = detectBrowserProfile() - const codecs = profile.transcodingProfiles.map((p) => p.audioCodec) - expect(codecs).toEqual(['opus', 'mp3']) - expect(codecs).not.toContain('flac') - profile.transcodingProfiles.forEach((p) => { - expect(p.protocol).toBe('http') - }) - }) - - it('always includes mp3 fallback in transcoding profiles', () => { - mockCanPlayType.mockReturnValue('') - - const profile = detectBrowserProfile() - expect(profile.transcodingProfiles.length).toBe(1) - expect(profile.transcodingProfiles[0].audioCodec).toBe('mp3') - expect(profile.transcodingProfiles[0].protocol).toBe('http') - }) - - it('does not duplicate mp3 when canPlayType supports it', () => { - mockCanPlayType.mockReturnValue('probably') - - const profile = detectBrowserProfile() - const mp3Count = profile.transcodingProfiles.filter( - (p) => p.audioCodec === 'mp3', - ).length - expect(mp3Count).toBe(1) - }) - - it('preserves transcoding profile preference order', () => { - mockCanPlayType.mockReturnValue('probably') - - const profile = detectBrowserProfile() - const codecs = profile.transcodingProfiles.map((p) => p.audioCodec) - expect(codecs).toEqual(['flac', 'opus', 'mp3']) - }) - - it('sets codecProfiles to empty array', () => { - mockCanPlayType.mockReturnValue('probably') - - const profile = detectBrowserProfile() - expect(profile.codecProfiles).toEqual([]) - }) - - it('matches codec when any mime variant returns "probably"', () => { - mockCanPlayType.mockImplementation((mime) => { - if (mime === 'audio/flac; codecs="flac"') return 'probably' - return '' - }) - - const profile = detectBrowserProfile() - const codecs = profile.directPlayProfiles.flatMap((p) => p.audioCodecs) - expect(codecs).toContain('flac') - }) - - it('includes platform info', () => { - const profile = detectBrowserProfile() - expect(typeof profile.platform).toBe('string') - }) - - describe('Safari restrictions', () => { - beforeEach(() => { - // Safari reports canPlayType for Ogg as positive, but can't actually - // stream transcoded Ogg. Simulate Safari: supports everything. - mockCanPlayType.mockReturnValue('probably') - }) - - it('still includes ogg in direct play profiles on Safari', () => { - vi.stubGlobal('navigator', { - userAgent: - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15', - }) - - const profile = detectBrowserProfile() - const containers = profile.directPlayProfiles.flatMap((p) => p.containers) - expect(containers).toContain('ogg') - }) - - it('limits Safari transcoding to mp3 only', () => { - vi.stubGlobal('navigator', { - userAgent: - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15', - }) - - const profile = detectBrowserProfile() - const codecs = profile.transcodingProfiles.map((p) => p.audioCodec) - expect(codecs).toEqual(['mp3']) - }) - - it('does NOT restrict transcoding on Chrome', () => { - vi.stubGlobal('navigator', { - userAgent: - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - }) - - const profile = detectBrowserProfile() - const codecs = profile.transcodingProfiles.map((p) => p.audioCodec) - expect(codecs).toContain('opus') - expect(codecs).toContain('flac') - }) - - it('applies same restrictions on iOS Safari', () => { - vi.stubGlobal('navigator', { - userAgent: - 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', - }) - - const profile = detectBrowserProfile() - const codecs = profile.transcodingProfiles.map((p) => p.audioCodec) - expect(codecs).toEqual(['mp3']) - }) - }) -}) diff --git a/ui/src/transcode/decisionService.js b/ui/src/transcode/decisionService.js deleted file mode 100644 index 9228cc882..000000000 --- a/ui/src/transcode/decisionService.js +++ /dev/null @@ -1,111 +0,0 @@ -import { jwtDecode } from 'jwt-decode' -import subsonic from '../subsonic' -import { baseUrl } from '../utils' - -// Decode the exp claim from a JWT token (no signature verification needed client-side). -// The JWT token is meant to be opaque to the client, we are only allowing ourselves to do -// this here because the UI is tightly integrated with the server; normally we would -// need to rely on the getTranscodeStream returning an error on stale tokens. -export function decodeJwtExp(token) { - try { - if (!token) return null - const payload = jwtDecode(token) - return typeof payload.exp === 'number' ? payload.exp : null - } catch { - return null - } -} - -export function createDecisionService(fetchFn) { - const cache = new Map() - let currentProfile = null - - function isFresh(entry) { - const exp = decodeJwtExp(entry.decision?.transcodeParams) - if (exp == null) return false - // exp is in seconds, Date.now() in milliseconds; 60s buffer avoids mid-request expiry - return Date.now() < (exp - 60) * 1000 - } - - function setProfile(profile) { - currentProfile = profile - } - - function getProfile() { - return currentProfile - } - - async function getDecision(songId, browserProfile) { - const profile = browserProfile || currentProfile - if (!profile) return null - - const cached = cache.get(songId) - if (cached && isFresh(cached)) { - return cached.decision - } - - const decision = await fetchFn(songId, profile) - cache.set(songId, { decision }) - return decision - } - - async function prefetchDecisions(songIds, browserProfile) { - const profile = browserProfile || currentProfile - if (!profile) return - - const uncached = songIds.filter((id) => { - const entry = cache.get(id) - return !entry || !isFresh(entry) - }) - - await Promise.allSettled( - uncached.map(async (id) => { - const decision = await fetchFn(id, profile) - cache.set(id, { decision }) - }), - ) - } - - function invalidateAll() { - cache.clear() - } - - function buildStreamUrl(songId, transcodeParams, offset) { - const params = { - mediaId: songId, - mediaType: 'song', - transcodeParams, - } - if (offset != null && offset > 0) { - params.offset = offset - } - return baseUrl(subsonic.url('getTranscodeStream', null, params)) - } - - async function resolveStreamUrl(songId) { - const decision = await getDecision(songId) - if (!decision?.transcodeParams) { - return baseUrl(subsonic.streamUrl(songId)) - } - return buildStreamUrl(songId, decision.transcodeParams) - } - - function getCachedDecision(songId) { - const entry = cache.get(songId) - if (entry && isFresh(entry)) { - return entry.decision - } - return null - } - - return { - getDecision, - getCachedDecision, - prefetchDecisions, - resolveStreamUrl, - invalidateAll, - buildStreamUrl, - setProfile, - getProfile, - } -} diff --git a/ui/src/transcode/decisionService.test.js b/ui/src/transcode/decisionService.test.js deleted file mode 100644 index a2718ade9..000000000 --- a/ui/src/transcode/decisionService.test.js +++ /dev/null @@ -1,256 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' -import { createDecisionService, decodeJwtExp } from './decisionService' - -// Helper: create a fake JWT with a given exp (seconds since epoch) -function fakeJwt(expSeconds) { - const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) - const payload = btoa(JSON.stringify({ exp: expSeconds })) - return `${header}.${payload}.fake-signature` -} - -// Helper: create a fake JWT with no exp claim -function fakeJwtNoExp() { - const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) - const payload = btoa(JSON.stringify({ sub: 'test' })) - return `${header}.${payload}.fake-signature` -} - -describe('decodeJwtExp', () => { - it('extracts exp from a valid JWT', () => { - const exp = 1700000000 - expect(decodeJwtExp(fakeJwt(exp))).toBe(exp) - }) - - it('returns null for JWT without exp claim', () => { - expect(decodeJwtExp(fakeJwtNoExp())).toBeNull() - }) - - it('returns null for non-JWT string', () => { - expect(decodeJwtExp('not-a-jwt')).toBeNull() - }) - - it('returns null for empty string', () => { - expect(decodeJwtExp('')).toBeNull() - }) - - it('returns null for null/undefined', () => { - expect(decodeJwtExp(null)).toBeNull() - expect(decodeJwtExp(undefined)).toBeNull() - }) -}) - -describe('decisionService', () => { - let service - let mockFetchFn - - const fakeProfile = { - name: 'NavidromeUI', - platform: 'test', - directPlayProfiles: [], - transcodingProfiles: [], - codecProfiles: [], - } - - // Token that expires 1 hour from "now" (will be relative to fake timers) - function makeFakeDecision(expiresInMs = 3600 * 1000) { - const expSeconds = Math.floor((Date.now() + expiresInMs) / 1000) - return { - canDirectPlay: true, - canTranscode: false, - transcodeParams: fakeJwt(expSeconds), - sourceStream: { codec: 'mp3', container: 'mp3' }, - } - } - - beforeEach(() => { - localStorage.setItem('username', 'testuser') - localStorage.setItem('subsonic-token', 'testtoken') - localStorage.setItem('subsonic-salt', 'testsalt') - mockFetchFn = vi.fn().mockImplementation(() => { - return Promise.resolve(makeFakeDecision()) - }) - service = createDecisionService(mockFetchFn) - }) - - afterEach(() => { - vi.restoreAllMocks() - localStorage.clear() - }) - - describe('getDecision', () => { - it('fetches and caches a decision', async () => { - const result = await service.getDecision('song-1', fakeProfile) - expect(result.canDirectPlay).toBe(true) - expect(mockFetchFn).toHaveBeenCalledTimes(1) - expect(mockFetchFn).toHaveBeenCalledWith('song-1', fakeProfile) - - // Second call uses cache - const result2 = await service.getDecision('song-1', fakeProfile) - expect(result2).toEqual(result) - expect(mockFetchFn).toHaveBeenCalledTimes(1) - }) - - it('re-fetches after token expires', async () => { - vi.useFakeTimers() - - // Token expires in 1 hour - mockFetchFn.mockResolvedValue(makeFakeDecision(3600 * 1000)) - await service.getDecision('song-1', fakeProfile) - expect(mockFetchFn).toHaveBeenCalledTimes(1) - - // Advance past expiration - vi.advanceTimersByTime(3600 * 1000 + 1000) - await service.getDecision('song-1', fakeProfile) - expect(mockFetchFn).toHaveBeenCalledTimes(2) - vi.useRealTimers() - }) - - it('does not re-fetch before token expires', async () => { - vi.useFakeTimers() - - // Token expires in 1 hour - mockFetchFn.mockResolvedValue(makeFakeDecision(3600 * 1000)) - await service.getDecision('song-1', fakeProfile) - - // 30 minutes later — still fresh - vi.advanceTimersByTime(1800 * 1000) - await service.getDecision('song-1', fakeProfile) - expect(mockFetchFn).toHaveBeenCalledTimes(1) - vi.useRealTimers() - }) - - it('re-fetches immediately when token has no exp claim', async () => { - const noExpDecision = { - canDirectPlay: true, - canTranscode: false, - transcodeParams: fakeJwtNoExp(), - sourceStream: { codec: 'mp3', container: 'mp3' }, - } - mockFetchFn.mockResolvedValue(noExpDecision) - await service.getDecision('song-1', fakeProfile) - - // Should re-fetch because token has no exp - await service.getDecision('song-1', fakeProfile) - expect(mockFetchFn).toHaveBeenCalledTimes(2) - }) - - it('caches different songs independently', async () => { - await service.getDecision('song-1', fakeProfile) - await service.getDecision('song-2', fakeProfile) - expect(mockFetchFn).toHaveBeenCalledTimes(2) - }) - }) - - describe('getCachedDecision', () => { - it('returns null when song is not cached', () => { - expect(service.getCachedDecision('song-1')).toBeNull() - }) - - it('returns cached decision after getDecision', async () => { - await service.getDecision('song-1', fakeProfile) - const cached = service.getCachedDecision('song-1') - expect(cached).not.toBeNull() - expect(cached.canDirectPlay).toBe(true) - }) - - it('returns null after cache is invalidated', async () => { - await service.getDecision('song-1', fakeProfile) - service.invalidateAll() - expect(service.getCachedDecision('song-1')).toBeNull() - }) - - it('returns null after token expires', async () => { - vi.useFakeTimers() - mockFetchFn.mockResolvedValue(makeFakeDecision(3600 * 1000)) - await service.getDecision('song-1', fakeProfile) - - vi.advanceTimersByTime(3600 * 1000 + 1000) - expect(service.getCachedDecision('song-1')).toBeNull() - vi.useRealTimers() - }) - }) - - describe('prefetchDecisions', () => { - it('fetches decisions for uncached songs', async () => { - await service.prefetchDecisions(['song-1', 'song-2'], fakeProfile) - expect(mockFetchFn).toHaveBeenCalledTimes(2) - }) - - it('skips already cached songs', async () => { - await service.getDecision('song-1', fakeProfile) - mockFetchFn.mockClear() - - await service.prefetchDecisions(['song-1', 'song-2'], fakeProfile) - expect(mockFetchFn).toHaveBeenCalledTimes(1) - expect(mockFetchFn).toHaveBeenCalledWith('song-2', fakeProfile) - }) - - it('silently ignores fetch errors', async () => { - mockFetchFn.mockRejectedValue(new Error('network error')) - await expect( - service.prefetchDecisions(['song-1'], fakeProfile), - ).resolves.not.toThrow() - }) - }) - - describe('invalidateAll', () => { - it('clears cache so next getDecision re-fetches', async () => { - await service.getDecision('song-1', fakeProfile) - expect(mockFetchFn).toHaveBeenCalledTimes(1) - - service.invalidateAll() - - await service.getDecision('song-1', fakeProfile) - expect(mockFetchFn).toHaveBeenCalledTimes(2) - }) - }) - - describe('resolveStreamUrl', () => { - it('fetches decision and returns built URL', async () => { - service.setProfile(fakeProfile) - const url = await service.resolveStreamUrl('song-1') - expect(url).toContain('getTranscodeStream') - expect(url).toContain('mediaId=song-1') - expect(mockFetchFn).toHaveBeenCalledTimes(1) - }) - - it('falls back to stream URL when decision has no transcodeParams', async () => { - service.setProfile(fakeProfile) - mockFetchFn.mockResolvedValue({ - canDirectPlay: true, - canTranscode: false, - }) - const url = await service.resolveStreamUrl('song-1') - expect(url).toContain('stream') - expect(url).not.toContain('getTranscodeStream') - }) - - it('falls back to stream URL when decision is null', async () => { - service.setProfile(fakeProfile) - mockFetchFn.mockResolvedValue(null) - const url = await service.resolveStreamUrl('song-1') - expect(url).toContain('stream') - expect(url).not.toContain('getTranscodeStream') - }) - }) - - describe('buildStreamUrl', () => { - it('builds URL with required parameters', () => { - const url = service.buildStreamUrl('song-1', 'jwt-token-123') - expect(url).toContain('getTranscodeStream') - expect(url).toContain('mediaId=song-1') - expect(url).toContain('mediaType=song') - expect(url).toContain('transcodeParams=jwt-token-123') - }) - - it('includes offset when provided', () => { - const url = service.buildStreamUrl('song-1', 'jwt-token-123', 30) - expect(url).toContain('offset=30') - }) - - it('omits offset when not provided', () => { - const url = service.buildStreamUrl('song-1', 'jwt-token-123') - expect(url).not.toContain('offset') - }) - }) -}) diff --git a/ui/src/transcode/fetchDecision.js b/ui/src/transcode/fetchDecision.js deleted file mode 100644 index 1c794082b..000000000 --- a/ui/src/transcode/fetchDecision.js +++ /dev/null @@ -1,23 +0,0 @@ -import subsonic from '../subsonic' -import { httpClient } from '../dataProvider' - -export async function fetchTranscodeDecision(songId, browserProfile) { - const fetchUrl = subsonic.url('getTranscodeDecision', null, { - mediaId: songId, - mediaType: 'song', - }) - - const { json } = await httpClient(fetchUrl, { - method: 'POST', - body: JSON.stringify(browserProfile), - }) - - const subsonicResponse = json['subsonic-response'] - - if (subsonicResponse.status !== 'ok') { - const err = subsonicResponse.error || {} - throw new Error(`getTranscodeDecision error: ${err.code} ${err.message}`) - } - - return subsonicResponse.transcodeDecision -} diff --git a/ui/src/transcode/fetchDecision.test.js b/ui/src/transcode/fetchDecision.test.js deleted file mode 100644 index 83f934f33..000000000 --- a/ui/src/transcode/fetchDecision.test.js +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' - -// Mock httpClient before importing module under test -vi.mock('../dataProvider', () => ({ - httpClient: vi.fn(), -})) - -import { fetchTranscodeDecision } from './fetchDecision' -import { httpClient } from '../dataProvider' - -describe('fetchTranscodeDecision', () => { - const fakeProfile = { - name: 'NavidromeUI', - platform: 'test', - directPlayProfiles: [ - { containers: ['mp3'], audioCodecs: ['mp3'], protocols: ['http'] }, - ], - transcodingProfiles: [], - codecProfiles: [], - } - - const fakeJson = { - 'subsonic-response': { - status: 'ok', - transcodeDecision: { - canDirectPlay: true, - canTranscode: false, - transcodeParams: 'jwt-token', - sourceStream: { codec: 'mp3' }, - }, - }, - } - - beforeEach(() => { - localStorage.setItem('username', 'testuser') - localStorage.setItem('subsonic-token', 'testtoken') - localStorage.setItem('subsonic-salt', 'testsalt') - - httpClient.mockResolvedValue({ json: fakeJson }) - }) - - afterEach(() => { - vi.restoreAllMocks() - localStorage.clear() - }) - - it('makes a POST request to getTranscodeDecision with correct URL', async () => { - await fetchTranscodeDecision('song-1', fakeProfile) - - expect(httpClient).toHaveBeenCalledTimes(1) - const [url, options] = httpClient.mock.calls[0] - expect(url).toContain('getTranscodeDecision') - expect(url).toContain('mediaId=song-1') - expect(url).toContain('mediaType=song') - expect(options.method).toBe('POST') - }) - - it('sends the browser profile as JSON body', async () => { - await fetchTranscodeDecision('song-1', fakeProfile) - - const [, options] = httpClient.mock.calls[0] - expect(JSON.parse(options.body)).toEqual(fakeProfile) - }) - - it('returns the transcodeDecision from response', async () => { - const result = await fetchTranscodeDecision('song-1', fakeProfile) - expect(result).toEqual(fakeJson['subsonic-response'].transcodeDecision) - }) - - it('throws on HTTP error (httpClient rejects)', async () => { - httpClient.mockRejectedValue(new Error('Server Error')) - - await expect( - fetchTranscodeDecision('song-1', fakeProfile), - ).rejects.toThrow() - }) - - it('throws on Subsonic error response', async () => { - httpClient.mockResolvedValue({ - json: { - 'subsonic-response': { - status: 'failed', - error: { code: 70, message: 'not found' }, - }, - }, - }) - - await expect( - fetchTranscodeDecision('song-1', fakeProfile), - ).rejects.toThrow() - }) -}) diff --git a/ui/src/transcode/index.js b/ui/src/transcode/index.js deleted file mode 100644 index aa0cb216c..000000000 --- a/ui/src/transcode/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import { createDecisionService } from './decisionService' -import { fetchTranscodeDecision } from './fetchDecision' -export { detectBrowserProfile } from './browserProfile' - -export const decisionService = createDecisionService(fetchTranscodeDecision) diff --git a/ui/src/transcoding/TranscodingCreate.jsx b/ui/src/transcoding/TranscodingCreate.js similarity index 91% rename from ui/src/transcoding/TranscodingCreate.jsx rename to ui/src/transcoding/TranscodingCreate.js index 014a94e48..aaf122665 100644 --- a/ui/src/transcoding/TranscodingCreate.jsx +++ b/ui/src/transcoding/TranscodingCreate.js @@ -8,7 +8,7 @@ import { useTranslate, } from 'react-admin' import { Title } from '../common' -import { TRANSCODING_BITRATE_CHOICES } from '../consts' +import { BITRATE_CHOICES } from '../consts' const TranscodingTitle = () => { const translate = useTranslate() @@ -28,7 +28,7 @@ const TranscodingCreate = (props) => ( <TextInput source="targetFormat" validate={[required()]} /> <SelectInput source="defaultBitRate" - choices={TRANSCODING_BITRATE_CHOICES} + choices={BITRATE_CHOICES} defaultValue={192} /> <TextInput diff --git a/ui/src/transcoding/TranscodingEdit.jsx b/ui/src/transcoding/TranscodingEdit.js similarity index 84% rename from ui/src/transcoding/TranscodingEdit.jsx rename to ui/src/transcoding/TranscodingEdit.js index bf500920f..5aba9a4e3 100644 --- a/ui/src/transcoding/TranscodingEdit.jsx +++ b/ui/src/transcoding/TranscodingEdit.js @@ -9,7 +9,7 @@ import { } from 'react-admin' import { Title } from '../common' import { TranscodingNote } from './TranscodingNote' -import { TRANSCODING_BITRATE_CHOICES } from '../consts' +import { BITRATE_CHOICES } from '../consts' const TranscodingTitle = ({ record }) => { const translate = useTranslate() @@ -28,10 +28,7 @@ const TranscodingEdit = (props) => { <SimpleForm variant={'outlined'}> <TextInput source="name" validate={[required()]} /> <TextInput source="targetFormat" validate={[required()]} /> - <SelectInput - source="defaultBitRate" - choices={TRANSCODING_BITRATE_CHOICES} - /> + <SelectInput source="defaultBitRate" choices={BITRATE_CHOICES} /> <TextInput source="command" fullWidth validate={[required()]} /> </SimpleForm> </Edit> diff --git a/ui/src/transcoding/TranscodingList.jsx b/ui/src/transcoding/TranscodingList.js similarity index 56% rename from ui/src/transcoding/TranscodingList.jsx rename to ui/src/transcoding/TranscodingList.js index d1371c04a..cf7820938 100644 --- a/ui/src/transcoding/TranscodingList.jsx +++ b/ui/src/transcoding/TranscodingList.js @@ -1,38 +1,24 @@ import React from 'react' -import { Datagrid, SelectField, TextField } from 'react-admin' +import { Datagrid, TextField } from 'react-admin' import { useMediaQuery } from '@material-ui/core' import { SimpleList, List } from '../common' -import { TRANSCODING_BITRATE_CHOICES } from '../consts' import config from '../config' const TranscodingList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) return ( - <List - {...props} - exporter={false} - bulkActionButtons={config.enableTranscodingConfig} - > + <List exporter={false} {...props}> {isXsmall ? ( <SimpleList primaryText={(r) => r.name} secondaryText={(r) => `format: ${r.targetFormat}`} - tertiaryText={(r) => ( - <SelectField - record={r} - source="defaultBitRate" - choices={TRANSCODING_BITRATE_CHOICES} - /> - )} + tertiaryText={(r) => r.defaultBitRate} /> ) : ( <Datagrid rowClick={config.enableTranscodingConfig ? 'edit' : 'show'}> <TextField source="name" /> <TextField source="targetFormat" /> - <SelectField - source="defaultBitRate" - choices={TRANSCODING_BITRATE_CHOICES} - /> + <TextField source="defaultBitRate" /> <TextField source="command" /> </Datagrid> )} diff --git a/ui/src/transcoding/TranscodingNote.jsx b/ui/src/transcoding/TranscodingNote.js similarity index 100% rename from ui/src/transcoding/TranscodingNote.jsx rename to ui/src/transcoding/TranscodingNote.js diff --git a/ui/src/transcoding/TranscodingShow.jsx b/ui/src/transcoding/TranscodingShow.js similarity index 71% rename from ui/src/transcoding/TranscodingShow.jsx rename to ui/src/transcoding/TranscodingShow.js index b7ec2f595..e132afee5 100644 --- a/ui/src/transcoding/TranscodingShow.jsx +++ b/ui/src/transcoding/TranscodingShow.js @@ -1,8 +1,7 @@ import React from 'react' -import { SelectField, Show, SimpleShowLayout, TextField } from 'react-admin' +import { Show, SimpleShowLayout, TextField } from 'react-admin' import { Title } from '../common' import { TranscodingNote } from './TranscodingNote' -import { TRANSCODING_BITRATE_CHOICES } from '../consts' const TranscodingTitle = ({ record }) => { return <Title subTitle={`Transcoding ${record ? record.name : ''}`} /> @@ -17,10 +16,7 @@ const TranscodingShow = (props) => { <SimpleShowLayout> <TextField source="name" /> <TextField source="targetFormat" /> - <SelectField - source="defaultBitRate" - choices={TRANSCODING_BITRATE_CHOICES} - /> + <TextField source="defaultBitRate" /> <TextField source="command" /> </SimpleShowLayout> </Show> diff --git a/ui/src/transcoding/index.js b/ui/src/transcoding/index.js index 0bff293b3..cb3491920 100644 --- a/ui/src/transcoding/index.js +++ b/ui/src/transcoding/index.js @@ -1,4 +1,4 @@ -import { MdTransform } from 'react-icons/md' +import TransformIcon from '@material-ui/icons/Transform' import TranscodingList from './TranscodingList' import TranscodingEdit from './TranscodingEdit' import TranscodingCreate from './TranscodingCreate' @@ -10,5 +10,5 @@ export default { edit: config.enableTranscodingConfig && TranscodingEdit, create: config.enableTranscodingConfig && TranscodingCreate, show: !config.enableTranscodingConfig && TranscodingShow, - icon: MdTransform, + icon: TransformIcon, } diff --git a/ui/src/useChangeThemeColor.jsx b/ui/src/useChangeThemeColor.js similarity index 100% rename from ui/src/useChangeThemeColor.jsx rename to ui/src/useChangeThemeColor.js diff --git a/ui/src/user/DeleteUserButton.jsx b/ui/src/user/DeleteUserButton.js similarity index 87% rename from ui/src/user/DeleteUserButton.jsx rename to ui/src/user/DeleteUserButton.js index 8d78e6bcd..11b8e7473 100644 --- a/ui/src/user/DeleteUserButton.jsx +++ b/ui/src/user/DeleteUserButton.js @@ -1,6 +1,7 @@ import React from 'react' import DeleteIcon from '@material-ui/icons/Delete' -import { makeStyles, alpha } from '@material-ui/core/styles' +import { makeStyles } from '@material-ui/core/styles' +import { fade } from '@material-ui/core/styles/colorManipulator' import clsx from 'clsx' import { useDeleteWithConfirmController, @@ -15,7 +16,7 @@ const useStyles = makeStyles( deleteButton: { color: theme.palette.error.main, '&:hover': { - backgroundColor: alpha(theme.palette.error.main, 0.12), + backgroundColor: fade(theme.palette.error.main, 0.12), // Reset on mouse devices '@media (hover: none)': { backgroundColor: 'transparent', @@ -23,7 +24,7 @@ const useStyles = makeStyles( }, }, }), - { name: 'RaDeleteWithConfirmButton' }, + { name: 'RaDeleteWithConfirmButton' } ) const DeleteUserButton = (props) => { diff --git a/ui/src/user/LibrarySelectionField.jsx b/ui/src/user/LibrarySelectionField.jsx deleted file mode 100644 index 4967720cd..000000000 --- a/ui/src/user/LibrarySelectionField.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useInput, useTranslate, useRecordContext } from 'react-admin' -import { Box, FormControl, FormLabel, Typography } from '@material-ui/core' -import { SelectLibraryInput } from '../common/SelectLibraryInput.jsx' -import React, { useMemo } from 'react' - -export const LibrarySelectionField = () => { - const translate = useTranslate() - const record = useRecordContext() - - const { - input: { name, onChange, value }, - meta: { error, touched }, - } = useInput({ source: 'libraryIds' }) - - // Extract library IDs from either 'libraries' array or 'libraryIds' array - const libraryIds = useMemo(() => { - // First check if form has libraryIds (create mode or already transformed) - if (value && Array.isArray(value)) { - return value - } - - // Then check if record has libraries array (edit mode from backend) - if (record?.libraries && Array.isArray(record.libraries)) { - return record.libraries.map((lib) => lib.id) - } - - return [] - }, [value, record]) - - // Determine if this is a new user (no ID means new record) - const isNewUser = !record?.id - - return ( - <FormControl error={!!(touched && error)} fullWidth margin="normal"> - <FormLabel component="legend"> - {translate('resources.user.fields.libraries')} - </FormLabel> - <Box mt={1} mb={1}> - <SelectLibraryInput - onChange={onChange} - value={libraryIds} - isNewUser={isNewUser} - /> - </Box> - {touched && error && ( - <Typography color="error" variant="caption"> - {error} - </Typography> - )} - <Typography variant="caption" color="textSecondary"> - {translate('resources.user.helperTexts.libraries')} - </Typography> - </FormControl> - ) -} diff --git a/ui/src/user/LibrarySelectionField.test.jsx b/ui/src/user/LibrarySelectionField.test.jsx deleted file mode 100644 index 9777bab99..000000000 --- a/ui/src/user/LibrarySelectionField.test.jsx +++ /dev/null @@ -1,168 +0,0 @@ -import * as React from 'react' -import { render, screen, cleanup } from '@testing-library/react' -import { LibrarySelectionField } from './LibrarySelectionField' -import { useInput, useTranslate, useRecordContext } from 'react-admin' -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { SelectLibraryInput } from '../common/SelectLibraryInput' - -// Mock the react-admin hooks -vi.mock('react-admin', () => ({ - useInput: vi.fn(), - useTranslate: vi.fn(), - useRecordContext: vi.fn(), -})) - -// Mock the SelectLibraryInput component -vi.mock('../common/SelectLibraryInput.jsx', () => ({ - SelectLibraryInput: vi.fn(() => <div data-testid="select-library-input" />), -})) - -describe('<LibrarySelectionField />', () => { - const defaultProps = { - input: { - name: 'libraryIds', - value: [], - onChange: vi.fn(), - }, - meta: { - touched: false, - error: undefined, - }, - } - - const mockTranslate = vi.fn((key) => key) - - beforeEach(() => { - useInput.mockReturnValue(defaultProps) - useTranslate.mockReturnValue(mockTranslate) - useRecordContext.mockReturnValue({}) - SelectLibraryInput.mockClear() - }) - - afterEach(cleanup) - - it('should render field label from translations', () => { - render(<LibrarySelectionField />) - expect(screen.getByText('resources.user.fields.libraries')).not.toBeNull() - }) - - it('should render helper text from translations', () => { - render(<LibrarySelectionField />) - expect( - screen.getByText('resources.user.helperTexts.libraries'), - ).not.toBeNull() - }) - - it('should render SelectLibraryInput with correct props', () => { - render(<LibrarySelectionField />) - expect(screen.getByTestId('select-library-input')).not.toBeNull() - expect(SelectLibraryInput).toHaveBeenCalledWith( - expect.objectContaining({ - onChange: defaultProps.input.onChange, - value: defaultProps.input.value, - }), - expect.anything(), - ) - }) - - it('should render error message when touched and has error', () => { - useInput.mockReturnValue({ - ...defaultProps, - meta: { - touched: true, - error: 'This field is required', - }, - }) - - render(<LibrarySelectionField />) - expect(screen.getByText('This field is required')).not.toBeNull() - }) - - it('should not render error message when not touched', () => { - useInput.mockReturnValue({ - ...defaultProps, - meta: { - touched: false, - error: 'This field is required', - }, - }) - - render(<LibrarySelectionField />) - expect(screen.queryByText('This field is required')).toBeNull() - }) - - it('should initialize with empty array when value is null', () => { - useInput.mockReturnValue({ - ...defaultProps, - input: { - ...defaultProps.input, - value: null, - }, - }) - - render(<LibrarySelectionField />) - expect(SelectLibraryInput).toHaveBeenCalledWith( - expect.objectContaining({ - value: [], - }), - expect.anything(), - ) - }) - - it('should extract library IDs from record libraries array when editing user', () => { - // Mock a record with libraries array (from backend during edit) - useRecordContext.mockReturnValue({ - id: 'user123', - name: 'John Doe', - libraries: [ - { id: 1, name: 'Music Library 1', path: '/music1' }, - { id: 3, name: 'Music Library 3', path: '/music3' }, - ], - }) - - // Mock input without libraryIds (edit mode scenario) - useInput.mockReturnValue({ - ...defaultProps, - input: { - ...defaultProps.input, - value: undefined, - }, - }) - - render(<LibrarySelectionField />) - expect(SelectLibraryInput).toHaveBeenCalledWith( - expect.objectContaining({ - value: [1, 3], // Should extract IDs from libraries array - }), - expect.anything(), - ) - }) - - it('should prefer libraryIds when both libraryIds and libraries are present', () => { - // Mock a record with libraries array - useRecordContext.mockReturnValue({ - id: 'user123', - libraries: [ - { id: 1, name: 'Music Library 1', path: '/music1' }, - { id: 3, name: 'Music Library 3', path: '/music3' }, - ], - }) - - // Mock input with explicit libraryIds (create mode or already transformed) - useInput.mockReturnValue({ - ...defaultProps, - input: { - ...defaultProps.input, - value: [2, 4], // Different IDs than in libraries - }, - }) - - render(<LibrarySelectionField />) - expect(SelectLibraryInput).toHaveBeenCalledWith( - expect.objectContaining({ - value: [2, 4], // Should prefer libraryIds over libraries - }), - expect.anything(), - ) - }) -}) diff --git a/ui/src/user/UserCreate.jsx b/ui/src/user/UserCreate.js similarity index 58% rename from ui/src/user/UserCreate.jsx rename to ui/src/user/UserCreate.js index ce69b6542..5d817dd01 100644 --- a/ui/src/user/UserCreate.jsx +++ b/ui/src/user/UserCreate.js @@ -2,20 +2,17 @@ import React, { useCallback } from 'react' import { BooleanInput, Create, - email, - FormDataConsumer, + TextInput, PasswordInput, required, + email, SimpleForm, - TextInput, + useTranslate, useMutation, useNotify, useRedirect, - useTranslate, } from 'react-admin' -import { Typography } from '@material-ui/core' import { Title } from '../common' -import { LibrarySelectionField } from './LibrarySelectionField.jsx' const UserCreate = (props) => { const translate = useTranslate() @@ -36,7 +33,7 @@ const UserCreate = (props) => { resource: 'user', payload: { data: values }, }, - { returnPromise: true }, + { returnPromise: true } ) notify('resources.user.notifications.created', 'info', { smart_count: 1, @@ -48,20 +45,12 @@ const UserCreate = (props) => { } } }, - [mutate, notify, redirect], + [mutate, notify, redirect] ) - // Custom validation function - const validateUserForm = (values) => { - const errors = {} - // Library selection is optional for non-admin users since they will be auto-assigned to default libraries - // No validation required for library selection - return errors - } - return ( <Create title={<Title subTitle={title} />} {...props}> - <SimpleForm save={save} validate={validateUserForm} variant={'outlined'}> + <SimpleForm save={save} variant={'outlined'}> <TextInput spellCheck={false} source="userName" @@ -75,25 +64,6 @@ const UserCreate = (props) => { validate={[required()]} /> <BooleanInput source="isAdmin" defaultValue={false} /> - - {/* Conditional Library Selection */} - <FormDataConsumer> - {({ formData }) => ( - <> - {!formData.isAdmin && <LibrarySelectionField />} - - {formData.isAdmin && ( - <Typography - variant="body2" - color="textSecondary" - style={{ marginTop: 16, marginBottom: 16 }} - > - {translate('resources.user.message.adminAutoLibraries')} - </Typography> - )} - </> - )} - </FormDataConsumer> </SimpleForm> </Create> ) diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.js similarity index 67% rename from ui/src/user/UserEdit.jsx rename to ui/src/user/UserEdit.js index feadafff1..81883f0ce 100644 --- a/ui/src/user/UserEdit.jsx +++ b/ui/src/user/UserEdit.js @@ -3,6 +3,7 @@ import { makeStyles } from '@material-ui/core/styles' import { TextInput, BooleanInput, + DateField, PasswordInput, Edit, required, @@ -17,13 +18,9 @@ import { useRefresh, FormDataConsumer, usePermissions, - useRecordContext, } from 'react-admin' -import { Typography } from '@material-ui/core' -import { ReadOnlyDateField, Title } from '../common' +import { Title } from '../common' import DeleteUserButton from './DeleteUserButton' -import { LibrarySelectionField } from './LibrarySelectionField.jsx' -import { validateUserForm } from './userValidation' const useStyles = makeStyles({ toolbar: { @@ -88,34 +85,27 @@ const UserEdit = (props) => { resource: 'user', payload: { id: values.id, data: values }, }, - { returnPromise: true }, + { returnPromise: true } ) notify('resources.user.notifications.updated', 'info', { smart_count: 1, }) permissions === 'admin' ? redirect('/user') : refresh() } catch (error) { - if (error?.body?.errors) { + if (error.body.errors) { return error.body.errors } - notify('ra.page.error', 'warning') } }, - [mutate, notify, permissions, redirect, refresh], + [mutate, notify, permissions, redirect, refresh] ) - // Custom validation function - const validateForm = (values) => { - return validateUserForm(values, translate) - } - return ( <Edit title={<UserTitle />} undoable={false} {...props}> <SimpleForm variant={'outlined'} toolbar={<UserToolbar showDelete={canDelete} />} save={save} - validate={validateForm} > {permissions === 'admin' && ( <TextInput @@ -149,43 +139,10 @@ const UserEdit = (props) => { {permissions === 'admin' && ( <BooleanInput source="isAdmin" initialValue={false} /> )} - - {/* Conditional Library Selection for Admin Users Only */} - {permissions === 'admin' && ( - <FormDataConsumer> - {({ formData }) => ( - <> - {!formData.isAdmin && <LibrarySelectionField />} - - {formData.isAdmin && ( - <Typography - variant="body2" - color="textSecondary" - style={{ marginTop: 16, marginBottom: 16 }} - > - {translate('resources.user.message.adminAutoLibraries')} - </Typography> - )} - </> - )} - </FormDataConsumer> - )} - - <TextInput - source="scrobbleFilter" - multiline - minRows={2} - fullWidth - spellCheck={false} - style={{ maxWidth: '40em' }} - inputProps={{ style: { resize: 'vertical' } }} - helperText={translate('resources.user.helperTexts.scrobbleFilter')} - /> - - <ReadOnlyDateField source="lastLoginAt" /> - <ReadOnlyDateField source="lastAccessAt" /> - <ReadOnlyDateField source="updatedAt" /> - <ReadOnlyDateField source="createdAt" /> + <DateField variant="body1" source="lastLoginAt" showTime /> + {/*<DateField source="lastAccessAt" showTime />*/} + <DateField variant="body1" source="updatedAt" showTime /> + <DateField variant="body1" source="createdAt" showTime /> </SimpleForm> </Edit> ) diff --git a/ui/src/user/UserEdit.test.jsx b/ui/src/user/UserEdit.test.jsx deleted file mode 100644 index 837b25ad6..000000000 --- a/ui/src/user/UserEdit.test.jsx +++ /dev/null @@ -1,201 +0,0 @@ -import * as React from 'react' -import { render, screen } from '@testing-library/react' -import UserEdit from './UserEdit' -import { describe, it, expect, vi } from 'vitest' - -const defaultUser = { - id: 'user1', - userName: 'testuser', - name: 'Test User', - email: 'test@example.com', - isAdmin: false, - libraries: [ - { id: 1, name: 'Library 1', path: '/music1' }, - { id: 2, name: 'Library 2', path: '/music2' }, - ], - lastLoginAt: '2023-01-01T12:00:00Z', - lastAccessAt: '2023-01-02T12:00:00Z', - updatedAt: '2023-01-03T12:00:00Z', - createdAt: '2023-01-04T12:00:00Z', -} - -const adminUser = { - ...defaultUser, - id: 'admin1', - userName: 'admin', - name: 'Admin User', - isAdmin: true, -} - -const hooks = vi.hoisted(() => ({ - save: null, - mutate: vi.fn(), - notify: vi.fn(), - redirect: vi.fn(), - refresh: vi.fn(), -})) - -// Mock React-Admin completely with simpler implementations -vi.mock('react-admin', () => ({ - Edit: ({ children, title }) => ( - <div data-testid="edit-component"> - {title} - {children} - </div> - ), - SimpleForm: ({ children, save }) => { - hooks.save = save - return <form data-testid="simple-form">{children}</form> - }, - TextInput: ({ source }) => <input data-testid={`text-input-${source}`} />, - BooleanInput: ({ source }) => ( - <input type="checkbox" data-testid={`boolean-input-${source}`} /> - ), - PasswordInput: ({ source }) => ( - <input type="password" data-testid={`password-input-${source}`} /> - ), - Toolbar: ({ children }) => <div data-testid="toolbar">{children}</div>, - SaveButton: () => <button data-testid="save-button">Save</button>, - FormDataConsumer: ({ children }) => children({ formData: {} }), - Typography: ({ children }) => <p>{children}</p>, - required: () => () => null, - email: () => () => null, - useMutation: () => [hooks.mutate], - useNotify: () => hooks.notify, - useRedirect: () => hooks.redirect, - useRefresh: () => hooks.refresh, - usePermissions: () => ({ permissions: 'admin' }), - useTranslate: () => (key) => key, -})) - -vi.mock('./LibrarySelectionField.jsx', () => ({ - LibrarySelectionField: () => <div data-testid="library-selection-field" />, -})) - -vi.mock('./DeleteUserButton', () => ({ - __esModule: true, - default: () => <button data-testid="delete-user-button">Delete</button>, -})) - -vi.mock('../common', () => ({ - Title: ({ subTitle }) => <div data-testid="title">{subTitle}</div>, - ReadOnlyDateField: ({ source }) => ( - <div data-testid={`date-field-${source}`}>Date</div> - ), -})) - -// Mock Material-UI -vi.mock('@material-ui/core/styles', () => ({ - makeStyles: () => () => ({}), -})) - -vi.mock('@material-ui/core', () => ({ - Typography: ({ children }) => <p>{children}</p>, -})) - -describe('<UserEdit />', () => { - it('should render the user edit form', () => { - render(<UserEdit id="user1" permissions="admin" />) - - // Check if the edit component renders - expect(screen.getByTestId('edit-component')).toBeInTheDocument() - expect(screen.getByTestId('simple-form')).toBeInTheDocument() - }) - - it('should render text inputs for admin users', () => { - render(<UserEdit id="user1" permissions="admin" />) - - // Should render username input for admin - expect(screen.getByTestId('text-input-userName')).toBeInTheDocument() - expect(screen.getByTestId('text-input-name')).toBeInTheDocument() - expect(screen.getByTestId('text-input-email')).toBeInTheDocument() - }) - - it('should render admin checkbox for admin permissions', () => { - render(<UserEdit id="user1" permissions="admin" />) - - // Should render isAdmin checkbox for admin users - expect(screen.getByTestId('boolean-input-isAdmin')).toBeInTheDocument() - }) - - it('should render date fields', () => { - render(<UserEdit id="user1" permissions="admin" />) - - expect(screen.getByTestId('date-field-lastLoginAt')).toBeInTheDocument() - expect(screen.getByTestId('date-field-lastAccessAt')).toBeInTheDocument() - expect(screen.getByTestId('date-field-updatedAt')).toBeInTheDocument() - expect(screen.getByTestId('date-field-createdAt')).toBeInTheDocument() - }) - - it('should render the scrobble filter input', () => { - render(<UserEdit id="user1" permissions="admin" />) - - expect(screen.getByTestId('text-input-scrobbleFilter')).toBeInTheDocument() - }) - - it('should not render username input for non-admin users', () => { - render(<UserEdit id="user1" permissions="user" />) - - // Should not render username input for non-admin - expect(screen.queryByTestId('text-input-userName')).not.toBeInTheDocument() - // But should still render name and email - expect(screen.getByTestId('text-input-name')).toBeInTheDocument() - expect(screen.getByTestId('text-input-email')).toBeInTheDocument() - }) - - describe('save', () => { - beforeEach(() => { - vi.clearAllMocks() - hooks.save = null - }) - - it('notifies success and redirects when the update succeeds', async () => { - hooks.mutate.mockResolvedValue({ data: defaultUser }) - render(<UserEdit id="user1" permissions="admin" />) - - await hooks.save({ id: 'user1', name: 'New Name' }) - - expect(hooks.notify).toHaveBeenCalledWith( - 'resources.user.notifications.updated', - 'info', - { smart_count: 1 }, - ) - expect(hooks.redirect).toHaveBeenCalledWith('/user') - }) - - it('returns field errors when the update fails validation', async () => { - const fieldErrors = { currentPassword: 'ra.validation.required' } - hooks.mutate.mockRejectedValue({ body: { errors: fieldErrors } }) - render(<UserEdit id="user1" permissions="admin" />) - - const result = await hooks.save({ id: 'user1' }) - - expect(result).toEqual(fieldErrors) - expect(hooks.notify).not.toHaveBeenCalledWith( - 'resources.user.notifications.updated', - 'info', - { smart_count: 1 }, - ) - }) - - it('notifies an error when the update fails without field errors', async () => { - hooks.mutate.mockRejectedValue(new Error('Forbidden')) - render(<UserEdit id="user1" permissions="admin" />) - - await hooks.save({ id: 'user1' }) - - expect(hooks.notify).toHaveBeenCalledWith('ra.page.error', 'warning') - expect(hooks.redirect).not.toHaveBeenCalled() - }) - - it('notifies an error when the update rejects with a non-object error', async () => { - hooks.mutate.mockRejectedValue(undefined) - render(<UserEdit id="user1" permissions="admin" />) - - await hooks.save({ id: 'user1' }) - - expect(hooks.notify).toHaveBeenCalledWith('ra.page.error', 'warning') - expect(hooks.redirect).not.toHaveBeenCalled() - }) - }) -}) diff --git a/ui/src/user/UserList.jsx b/ui/src/user/UserList.js similarity index 81% rename from ui/src/user/UserList.jsx rename to ui/src/user/UserList.js index 7922ca08d..d97fb691c 100644 --- a/ui/src/user/UserList.jsx +++ b/ui/src/user/UserList.js @@ -9,8 +9,6 @@ import { } from 'react-admin' import { useMediaQuery } from '@material-ui/core' import { List, DateField } from '../common' -import { useDateLocale } from '../i18n/useDateLocale' -import { formatDateTime } from '../utils/formatters' const UserFilter = (props) => ( <Filter {...props} variant={'outlined'}> @@ -20,7 +18,6 @@ const UserFilter = (props) => ( const UserList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) - const locale = useDateLocale() return ( <List @@ -34,7 +31,7 @@ const UserList = (props) => { <SimpleList primaryText={(record) => record.userName} secondaryText={(record) => - record.lastLoginAt && formatDateTime(record.lastLoginAt, locale) + record.lastLoginAt && new Date(record.lastLoginAt).toLocaleString() } tertiaryText={(record) => (record.isAdmin ? '[admin]️' : '')} /> @@ -44,7 +41,6 @@ const UserList = (props) => { <TextField source="name" /> <BooleanField source="isAdmin" /> <DateField source="lastLoginAt" sortByOrder={'DESC'} /> - <DateField source="lastAccessAt" sortByOrder={'DESC'} /> <DateField source="updatedAt" sortByOrder={'DESC'} /> </Datagrid> )} diff --git a/ui/src/user/userValidation.js b/ui/src/user/userValidation.js deleted file mode 100644 index 9eb1b8f05..000000000 --- a/ui/src/user/userValidation.js +++ /dev/null @@ -1,29 +0,0 @@ -// User form validation utilities -export const validateUserForm = (values, translate) => { - const errors = {} - - // Only require library selection for non-admin users - if (!values.isAdmin) { - // Check both libraryIds (array of IDs) and libraries (array of objects) - const hasLibraryIds = values.libraryIds && values.libraryIds.length > 0 - const hasLibraries = values.libraries && values.libraries.length > 0 - - if (!hasLibraryIds && !hasLibraries) { - errors.libraryIds = translate( - 'resources.user.validation.librariesRequired', - ) - } - } - - if (values.scrobbleFilter && values.scrobbleFilter.trim() !== '') { - try { - JSON.parse(values.scrobbleFilter) - } catch { - errors.scrobbleFilter = translate( - 'resources.user.validation.invalidScrobbleFilter', - ) - } - } - - return errors -} diff --git a/ui/src/user/userValidation.test.js b/ui/src/user/userValidation.test.js deleted file mode 100644 index de52c17df..000000000 --- a/ui/src/user/userValidation.test.js +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { validateUserForm } from './userValidation' - -describe('User Validation Utilities', () => { - const mockTranslate = vi.fn((key) => key) - - describe('validateUserForm', () => { - it('should not return errors for admin users', () => { - const values = { - isAdmin: true, - libraryIds: [], - } - const errors = validateUserForm(values, mockTranslate) - expect(errors).toEqual({}) - }) - - it('should not return errors for non-admin users with libraries', () => { - const values = { - isAdmin: false, - libraryIds: [1, 2, 3], - } - const errors = validateUserForm(values, mockTranslate) - expect(errors).toEqual({}) - }) - - it('should return error for non-admin users without libraries', () => { - const values = { - isAdmin: false, - libraryIds: [], - } - const errors = validateUserForm(values, mockTranslate) - expect(errors.libraryIds).toBe( - 'resources.user.validation.librariesRequired', - ) - }) - - it('should return error for non-admin users with undefined libraryIds', () => { - const values = { - isAdmin: false, - } - const errors = validateUserForm(values, mockTranslate) - expect(errors.libraryIds).toBe( - 'resources.user.validation.librariesRequired', - ) - }) - - it('should not return errors for non-admin users with libraries array', () => { - const values = { - isAdmin: false, - libraries: [ - { id: 1, name: 'Library 1' }, - { id: 2, name: 'Library 2' }, - ], - } - const errors = validateUserForm(values, mockTranslate) - expect(errors).toEqual({}) - }) - - it('should return error for non-admin users with empty libraries array', () => { - const values = { - isAdmin: false, - libraries: [], - } - const errors = validateUserForm(values, mockTranslate) - expect(errors.libraryIds).toBe( - 'resources.user.validation.librariesRequired', - ) - }) - }) - - describe('scrobbleFilter validation', () => { - it('accepts an empty filter', () => { - const errors = validateUserForm({ isAdmin: true }, mockTranslate) - expect(errors.scrobbleFilter).toBeUndefined() - }) - it('accepts valid JSON', () => { - const errors = validateUserForm( - { isAdmin: true, scrobbleFilter: '{"all":[{"lt":{"rating":4}}]}' }, - mockTranslate, - ) - expect(errors.scrobbleFilter).toBeUndefined() - }) - it('rejects malformed JSON', () => { - const errors = validateUserForm( - { isAdmin: true, scrobbleFilter: '{broken' }, - mockTranslate, - ) - expect(errors.scrobbleFilter).toEqual( - 'resources.user.validation.invalidScrobbleFilter', - ) - }) - }) -}) diff --git a/ui/src/utils/calculateReplayGain.js b/ui/src/utils/calculateReplayGain.js deleted file mode 100644 index 0ce69fa4b..000000000 --- a/ui/src/utils/calculateReplayGain.js +++ /dev/null @@ -1,31 +0,0 @@ -const calculateReplayGain = (preAmp, gain, peak) => { - if (gain === undefined || peak === undefined) { - return 1 - } - - // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification§ion=19 - // Normalized to max gain - return Math.min(10 ** ((gain + preAmp) / 20), 1 / peak) -} - -export const calculateGain = (gainInfo, song) => { - switch (gainInfo.gainMode) { - case 'album': { - return calculateReplayGain( - gainInfo.preAmp, - song.rgAlbumGain, - song.rgAlbumPeak, - ) - } - case 'track': { - return calculateReplayGain( - gainInfo.preAmp, - song.rgTrackGain, - song.rgTrackPeak, - ) - } - default: { - return 1 - } - } -} diff --git a/ui/src/utils/formatters.js b/ui/src/utils/formatters.js index f7c59c195..d257ac73d 100644 --- a/ui/src/utils/formatters.js +++ b/ui/src/utils/formatters.js @@ -25,63 +25,7 @@ export const formatDuration = (d) => { return `${days > 0 ? days + ':' : ''}${f}` } -export const formatDuration2 = (totalSeconds) => { - if (totalSeconds == null || totalSeconds < 0) { - return '0s' - } - const days = Math.floor(totalSeconds / 86400) - const hours = Math.floor((totalSeconds % 86400) / 3600) - const minutes = Math.floor((totalSeconds % 3600) / 60) - const seconds = Math.floor(totalSeconds % 60) - - const parts = [] - - if (days > 0) { - // When days are present, show only d h m (3 levels max) - parts.push(`${days}d`) - if (hours > 0) { - parts.push(`${hours}h`) - } - if (minutes > 0) { - parts.push(`${minutes}m`) - } - } else { - // When no days, show h m s (3 levels max) - if (hours > 0) { - parts.push(`${hours}h`) - } - if (minutes > 0) { - parts.push(`${minutes}m`) - } - if (seconds > 0 || parts.length === 0) { - parts.push(`${seconds}s`) - } - } - - return parts.join(' ') -} - -export const formatShortDuration = (ns) => { - // Convert nanoseconds to seconds - const seconds = ns / 1e9 - if (seconds < 1.0) { - return '<1s' - } - - const hours = Math.floor(seconds / 3600) - const minutes = Math.floor((seconds % 3600) / 60) - const secs = Math.floor(seconds % 60) - - if (hours > 0) { - return `${hours}h${minutes}m` - } - if (minutes > 0) { - return `${minutes}m${secs}s` - } - return `${secs}s` -} - -export const formatFullDate = (date, locale) => { +export const formatFullDate = (date) => { const dashes = date.split('-').length - 1 let options = { year: 'numeric', @@ -92,13 +36,5 @@ export const formatFullDate = (date, locale) => { if (dashes > 2 || (dashes === 0 && date.length > 4)) { return '' } - return new Date(date).toLocaleDateString(locale, options) -} - -export const formatDateTime = (value, locale) => - new Date(value).toLocaleString(locale) - -export const formatNumber = (value, locale) => { - if (value === null || value === undefined) return '0' - return value.toLocaleString(locale) + return new Date(date).toLocaleDateString(undefined, options) } diff --git a/ui/src/utils/formatters.test.js b/ui/src/utils/formatters.test.js index d633e96f2..610143ffa 100644 --- a/ui/src/utils/formatters.test.js +++ b/ui/src/utils/formatters.test.js @@ -1,11 +1,4 @@ -import { - formatBytes, - formatDuration, - formatDuration2, - formatFullDate, - formatNumber, - formatShortDuration, -} from './formatters' +import { formatBytes, formatDuration, formatFullDate } from './formatters' describe('formatBytes', () => { it('format bytes', () => { @@ -32,124 +25,25 @@ describe('formatDuration', () => { it('formats days, hours and minutes', () => { expect(formatDuration(hour + minute + 1)).toEqual('01:01:01') expect(formatDuration(3 * day + 3 * hour + 7 * minute)).toEqual( - '3:03:07:00', + '3:03:07:00' ) expect(formatDuration(day)).toEqual('1:00:00:00') expect(formatDuration(day + minute + 0.6)).toEqual('1:00:01:01') }) }) -describe('formatShortDuration', () => { - // Convert seconds to nanoseconds for the tests - const toNs = (seconds) => seconds * 1e9 - - it('formats less than a second', () => { - expect(formatShortDuration(toNs(0.5))).toEqual('<1s') - expect(formatShortDuration(toNs(0))).toEqual('<1s') - }) - - it('formats seconds', () => { - expect(formatShortDuration(toNs(1))).toEqual('1s') - expect(formatShortDuration(toNs(59))).toEqual('59s') - }) - - it('formats minutes and seconds', () => { - expect(formatShortDuration(toNs(60))).toEqual('1m0s') - expect(formatShortDuration(toNs(90))).toEqual('1m30s') - expect(formatShortDuration(toNs(59 * 60 + 59))).toEqual('59m59s') - }) - - it('formats hours and minutes', () => { - expect(formatShortDuration(toNs(3600))).toEqual('1h0m') - expect(formatShortDuration(toNs(3600 + 30 * 60))).toEqual('1h30m') - expect(formatShortDuration(toNs(24 * 3600 - 1))).toEqual('23h59m') - }) -}) - -describe('formatDuration2', () => { - it('handles null and undefined values', () => { - expect(formatDuration2(null)).toEqual('0s') - expect(formatDuration2(undefined)).toEqual('0s') - }) - - it('handles negative values', () => { - expect(formatDuration2(-10)).toEqual('0s') - expect(formatDuration2(-1)).toEqual('0s') - }) - - it('formats zero seconds', () => { - expect(formatDuration2(0)).toEqual('0s') - }) - - it('formats seconds only', () => { - expect(formatDuration2(1)).toEqual('1s') - expect(formatDuration2(30)).toEqual('30s') - expect(formatDuration2(59)).toEqual('59s') - }) - - it('formats minutes and seconds', () => { - expect(formatDuration2(60)).toEqual('1m') - expect(formatDuration2(90)).toEqual('1m 30s') - expect(formatDuration2(119)).toEqual('1m 59s') - expect(formatDuration2(120)).toEqual('2m') - }) - - it('formats hours, minutes and seconds', () => { - expect(formatDuration2(3600)).toEqual('1h') - expect(formatDuration2(3661)).toEqual('1h 1m 1s') - expect(formatDuration2(7200)).toEqual('2h') - expect(formatDuration2(7260)).toEqual('2h 1m') - expect(formatDuration2(7261)).toEqual('2h 1m 1s') - }) - - it('handles decimal values by flooring', () => { - expect(formatDuration2(59.9)).toEqual('59s') - expect(formatDuration2(60.1)).toEqual('1m') - expect(formatDuration2(3600.9)).toEqual('1h') - }) - - it('formats days with maximum 3 levels (d h m)', () => { - expect(formatDuration2(86400)).toEqual('1d') - expect(formatDuration2(86461)).toEqual('1d 1m') // seconds dropped when days present - expect(formatDuration2(90061)).toEqual('1d 1h 1m') // seconds dropped when days present - expect(formatDuration2(172800)).toEqual('2d') - expect(formatDuration2(176400)).toEqual('2d 1h') - expect(formatDuration2(176460)).toEqual('2d 1h 1m') - expect(formatDuration2(176461)).toEqual('2d 1h 1m') // seconds dropped when days present - }) -}) - -describe('formatNumber', () => { - it('handles null and undefined values', () => { - expect(formatNumber(null, 'en-CA')).toEqual('0') - expect(formatNumber(undefined, 'en-CA')).toEqual('0') - }) - - it('formats integers', () => { - expect(formatNumber(0, 'en-CA')).toEqual('0') - expect(formatNumber(1, 'en-CA')).toEqual('1') - expect(formatNumber(123, 'en-CA')).toEqual('123') - expect(formatNumber(1000, 'en-CA')).toEqual('1,000') - expect(formatNumber(1234567, 'en-CA')).toEqual('1,234,567') - }) - - it('formats decimal numbers', () => { - expect(formatNumber(123.45, 'en-CA')).toEqual('123.45') - expect(formatNumber(1234.567, 'en-CA')).toEqual('1,234.567') - }) - - it('formats negative numbers', () => { - expect(formatNumber(-123, 'en-CA')).toEqual('-123') - expect(formatNumber(-1234, 'en-CA')).toEqual('-1,234') - expect(formatNumber(-123.45, 'en-CA')).toEqual('-123.45') - }) -}) - describe('formatFullDate', () => { - it('format dates', () => { - expect(formatFullDate('2011', 'en-CA')).toEqual('2011') - expect(formatFullDate('2011-06', 'en-CA')).toEqual('Jun 2011') - expect(formatFullDate('1985-01-01', 'en-CA')).toEqual('Jan 1, 1985') + beforeAll(() => { + const toLocaleString = Date.prototype.toLocaleString + // eslint-disable-next-line no-extend-native + Date.prototype.toLocaleString = function (locale = 'en-US', ...args) { + return toLocaleString.call(this, locale, ...args) + } + }) + it('format bytes', () => { + expect(formatFullDate('2011')).toEqual('2011') + expect(formatFullDate('2011-06')).toEqual('Jun 2011') + expect(formatFullDate('1985-01-01')).toEqual('Jan 1, 1985') expect(formatFullDate('199704')).toEqual('') }) }) diff --git a/ui/src/utils/intersperse.js b/ui/src/utils/intersperse.js index ce48f5e86..01ff7f87c 100644 --- a/ui/src/utils/intersperse.js +++ b/ui/src/utils/intersperse.js @@ -15,6 +15,6 @@ export const intersperse = (arr, sep) => { function (xs, x, i) { return xs.concat([sep, x]) }, - [arr[0]], + [arr[0]] ) } diff --git a/ui/src/utils/thumbhash.js b/ui/src/utils/thumbhash.js deleted file mode 100644 index f4ec673d2..000000000 --- a/ui/src/utils/thumbhash.js +++ /dev/null @@ -1,138 +0,0 @@ -// ThumbHash decoder (https://github.com/evanw/thumbhash), kept byte-compatible with the reference: -// core/artwork/thumbhash/testdata/thumbhash.js pins the pixels the specs assert on. - -const toBytes = (hash) => { - if (typeof hash !== 'string' || hash === '') { - throw new Error('thumbhash: empty hash') - } - const binary = atob(hash) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - if (bytes.length < 5) { - throw new Error('thumbhash: hash too short') - } - return bytes -} - -// header unpacks the fixed 5-byte prefix plus the optional alpha byte. -const header = (bytes) => { - const header24 = bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) - const header16 = bytes[3] | (bytes[4] << 8) - const hasAlpha = header24 >> 23 !== 0 - if (hasAlpha && bytes.length < 6) { - throw new Error('thumbhash: hash too short for alpha') - } - const isLandscape = header16 >> 15 !== 0 - const alphaLimit = hasAlpha ? 5 : 7 - return { - lDC: (header24 & 63) / 63, - pDC: ((header24 >> 6) & 63) / 31.5 - 1, - qDC: ((header24 >> 12) & 63) / 31.5 - 1, - lScale: ((header24 >> 18) & 31) / 31, - hasAlpha, - pScale: ((header16 >> 3) & 63) / 63, - qScale: ((header16 >> 9) & 63) / 63, - lx: Math.max(3, isLandscape ? alphaLimit : header16 & 7), - ly: Math.max(3, isLandscape ? header16 & 7 : alphaLimit), - aDC: hasAlpha ? (bytes[5] & 15) / 15 : 1, - aScale: hasAlpha ? (bytes[5] >> 4) / 15 : 0, - } -} - -// naturalSize is the reference decoder's own output size: 32 on the long edge, at the aspect the -// hash approximates. Callers with the true dimensions should decode at those instead. -export const naturalSize = (hash) => { - const { lx, ly } = header(toBytes(hash)) - const ratio = lx / ly - return ratio > 1 - ? { width: 32, height: Math.round(32 / ratio) } - : { width: Math.round(32 * ratio), height: 32 } -} - -const cosTable = (n, size) => { - const table = new Float64Array(n * size) - for (let c = 0; c < n; c++) { - for (let i = 0; i < size; i++) { - table[c * size + i] = Math.cos((Math.PI / size) * (i + 0.5) * c) - } - } - return table -} - -export const decode = (hash, width, height) => { - const bytes = toBytes(hash) - const h = header(bytes) - if (!(width > 0) || !(height > 0)) { - throw new Error('thumbhash: width and height must be positive') - } - - const acStart = h.hasAlpha ? 6 : 5 - let acIndex = 0 - const channel = (nx, ny, scale) => { - const ac = [] - for (let cy = 0; cy < ny; cy++) { - for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) { - const byte = bytes[acStart + (acIndex >> 1)] ?? 0 - ac.push((((byte >> ((acIndex++ & 1) << 2)) & 15) / 7.5 - 1) * scale) - } - } - return ac - } - // The 1.25x chroma boost is the reference's quantisation compensation, not a free parameter. - const lAC = channel(h.lx, h.ly, h.lScale) - const pAC = channel(3, 3, h.pScale * 1.25) - const qAC = channel(3, 3, h.qScale * 1.25) - const aAC = h.hasAlpha ? channel(5, 5, h.aScale) : [] - - const nx = Math.max(h.lx, h.hasAlpha ? 5 : 3) - const ny = Math.max(h.ly, h.hasAlpha ? 5 : 3) - const fx = cosTable(nx, width) - const fy = cosTable(ny, height) - - const pixels = new Uint8ClampedArray(width * height * 4) - for (let y = 0; y < height; y++) { - for (let x = 0; x < width; x++) { - let l = h.lDC - let p = h.pDC - let q = h.qDC - let a = h.aDC - - for (let cy = 0, j = 0; cy < h.ly; cy++) { - const fy2 = fy[cy * height + y] * 2 - for (let cx = cy ? 0 : 1; cx * h.ly < h.lx * (h.ly - cy); cx++, j++) { - l += lAC[j] * fx[cx * width + x] * fy2 - } - } - for (let cy = 0, j = 0; cy < 3; cy++) { - const fy2 = fy[cy * height + y] * 2 - for (let cx = cy ? 0 : 1; cx < 3 - cy; cx++, j++) { - const f = fx[cx * width + x] * fy2 - p += pAC[j] * f - q += qAC[j] * f - } - } - if (h.hasAlpha) { - for (let cy = 0, j = 0; cy < 5; cy++) { - const fy2 = fy[cy * height + y] * 2 - for (let cx = cy ? 0 : 1; cx < 5 - cy; cx++, j++) { - a += aAC[j] * fx[cx * width + x] * fy2 - } - } - } - - const b = l - (2 / 3) * p - const r = (3 * l - b + q) / 2 - const g = r - q - const idx = 4 * x + y * width * 4 - // Explicit floor: the reference writes into a Uint8Array, which truncates, while the - // Uint8ClampedArray that createImageData needs would round. - pixels[idx] = Math.floor(Math.max(0, 255 * Math.min(1, r))) - pixels[idx + 1] = Math.floor(Math.max(0, 255 * Math.min(1, g))) - pixels[idx + 2] = Math.floor(Math.max(0, 255 * Math.min(1, b))) - pixels[idx + 3] = Math.floor(Math.max(0, 255 * Math.min(1, a))) - } - } - return pixels -} diff --git a/ui/src/utils/thumbhash.test.js b/ui/src/utils/thumbhash.test.js deleted file mode 100644 index 6abb4d7e4..000000000 --- a/ui/src/utils/thumbhash.test.js +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { decode, naturalSize } from './thumbhash' - -// Digests pinned from evanw/thumbhash's reference decoder (vendored at -// core/artwork/thumbhash/testdata/thumbhash.js), so any drift from it fails here. -const fnv = (bytes) => { - let h = 2166136261 >>> 0 - for (const b of bytes) { - h ^= b - h = Math.imul(h, 16777619) >>> 0 - } - return h.toString(16) -} - -const REFERENCE = [ - ['alpha', 'JOiFBQ4nkIexh3p4iA8uB+lYhIeAh3d4dw==', 32, 32, '500141bc'], - ['landscape', '3wcOFJpwh4eBh3d4iIePgAj3hw==', 32, 18, '18465aed'], - ['portrait', '3/cNFBqBB4iId4d3d4iAjwj4hw==', 18, 32, '56500cc1'], - ['solid', 'HoUBBwB4eHeHd3hweId3h3h4B2+Ih4gA', 32, 32, '39cc5c5'], - ['square', 'H/gNBxpwh4dwd3eIiHd3iHeHeJ+dcH8I', 32, 32, '740d8afc'], - ['tiny', 'HoU9tx4I9wiIh4hwj3CI+AiIcH/494cP', 32, 32, 'f2555987'], -] - -describe('thumbhash decode', () => { - it.each(REFERENCE)( - 'reproduces the reference pixels for %s', - (_name, hash, w, h, digest) => { - expect(fnv(decode(hash, w, h))).toEqual(digest) - }, - ) - - it.each(REFERENCE)( - 'reports the reference natural size for %s', - (_name, hash, w, h) => { - expect(naturalSize(hash)).toEqual({ width: w, height: h }) - }, - ) - - it('decodes to any requested grid, not just the natural one', () => { - const [, hash] = REFERENCE[4] - const pixels = decode(hash, 8, 5) - expect(pixels).toHaveLength(8 * 5 * 4) - // Opaque hash: every alpha byte is saturated. - for (let i = 3; i < pixels.length; i += 4) { - expect(pixels[i]).toBe(255) - } - }) - - it('carries alpha through for a hash that has it', () => { - const [, hash, w, h] = REFERENCE[0] - const pixels = decode(hash, w, h) - const alphas = new Set() - for (let i = 3; i < pixels.length; i += 4) { - alphas.add(pixels[i]) - } - expect(alphas.size).toBeGreaterThan(1) - }) - - it.each([ - ['empty', ''], - ['too short to hold a header', 'AAAA'], - ['not base64', '!!!not-a-thumbhash!!!'], - ])('throws on a hash that is %s', (_name, hash) => { - expect(() => decode(hash, 32, 32)).toThrow() - }) -}) diff --git a/ui/src/utils/urls.js b/ui/src/utils/urls.js index 80207fe83..fe6c19c6f 100644 --- a/ui/src/utils/urls.js +++ b/ui/src/utils/urls.js @@ -7,53 +7,24 @@ export const baseUrl = (path) => { return parts.join('/') } -export const shareUrl = (path) => { - if (config.shareURL !== '') { - const base = config.shareURL || '' - const parts = [base] - parts.push(path.replace(/^\//, '')) - return parts.join('/') - } - return baseUrl(path) -} - -export const sharePlayerUrl = (id) => { +export const shareUrl = (id) => { const url = new URL( - shareUrl(config.publicBaseUrl + '/' + id), - window.location.href, + baseUrl(config.publicBaseUrl + '/' + id), + window.location.href ) return url.href } export const shareStreamUrl = (id) => { - return shareUrl(config.publicBaseUrl + '/s/' + id) + return baseUrl(config.publicBaseUrl + '/s/' + id) } export const shareDownloadUrl = (id) => { - return shareUrl(config.publicBaseUrl + '/d/' + id) + return baseUrl(config.publicBaseUrl + '/d/' + id) } -export const shareCoverUrl = (id, square) => { - return shareUrl( - config.publicBaseUrl + - '/img/' + - id + - '?size=300' + - (square ? '&square=true' : ''), - ) +export const shareCoverUrl = (id) => { + return baseUrl(config.publicBaseUrl + '/img/' + id + '?size=300') } export const docsUrl = (path) => `https://www.navidrome.org${path}` - -export const isLastFmURL = (url) => { - try { - const parsed = new URL(url) - return ( - (parsed.protocol === 'http:' || parsed.protocol === 'https:') && - (parsed.hostname === 'last.fm' || parsed.hostname.endsWith('.last.fm')) && - parsed.pathname.startsWith('/music/') - ) - } catch (e) { - return false - } -} diff --git a/ui/src/utils/urls.test.js b/ui/src/utils/urls.test.js deleted file mode 100644 index 26bdd1283..000000000 --- a/ui/src/utils/urls.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { isLastFmURL } from './urls' - -describe('isLastFmURL', () => { - it('returns true for valid Last.fm music URLs', () => { - expect(isLastFmURL('https://last.fm/music/The+Beatles')).toBe(true) - expect(isLastFmURL('http://last.fm/music/Radiohead')).toBe(true) - expect(isLastFmURL('https://www.last.fm/music/Daft+Punk')).toBe(true) - }) - - it('returns false for non-http(s) protocols (XSS prevention)', () => { - expect(isLastFmURL('javascript:alert(1)//last.fm/music/')).toBe(false) - expect(isLastFmURL('data:text/html,<script>//last.fm/music/')).toBe(false) - }) - - it('returns false for non-last.fm domains', () => { - expect(isLastFmURL('https://example.com/?q=last.fm/music/')).toBe(false) - expect(isLastFmURL('https://fake-last.fm/music/Artist')).toBe(false) - }) - - it('returns false for invalid paths or inputs', () => { - expect(isLastFmURL('https://last.fm/user/someone')).toBe(false) - expect(isLastFmURL(null)).toBe(false) - expect(isLastFmURL('not-a-url')).toBe(false) - }) -}) diff --git a/ui/src/utils/validations.js b/ui/src/utils/validations.js index 792726e70..8b163c156 100644 --- a/ui/src/utils/validations.js +++ b/ui/src/utils/validations.js @@ -10,16 +10,3 @@ export const urlValidate = (value) => { return 'ra.validation.url' } } - -export function isDateSet(date) { - if (!date) { - return false - } - if (typeof date === 'string') { - return date !== '0001-01-01T00:00:00Z' - } - if (date instanceof Date) { - return date.toISOString() !== '0001-01-01T00:00:00Z' - } - return !!date -} diff --git a/ui/src/utils/validations.test.js b/ui/src/utils/validations.test.js deleted file mode 100644 index 10f67d186..000000000 --- a/ui/src/utils/validations.test.js +++ /dev/null @@ -1,73 +0,0 @@ -import { isDateSet, urlValidate } from './validations' - -describe('urlValidate', () => { - it('returns undefined for valid URLs', () => { - expect(urlValidate('https://example.com')).toBeUndefined() - expect(urlValidate('http://localhost:3000')).toBeUndefined() - expect(urlValidate('ftp://files.example.com')).toBeUndefined() - }) - - it('returns undefined for empty values', () => { - expect(urlValidate('')).toBeUndefined() - expect(urlValidate(null)).toBeUndefined() - expect(urlValidate(undefined)).toBeUndefined() - }) - - it('returns error for invalid URLs', () => { - expect(urlValidate('not-a-url')).toEqual('ra.validation.url') - expect(urlValidate('example.com')).toEqual('ra.validation.url') - expect(urlValidate('://missing-protocol')).toEqual('ra.validation.url') - }) -}) - -describe('isDateSet', () => { - describe('with falsy values', () => { - it('returns false for null', () => { - expect(isDateSet(null)).toBe(false) - }) - - it('returns false for undefined', () => { - expect(isDateSet(undefined)).toBe(false) - }) - - it('returns false for empty string', () => { - expect(isDateSet('')).toBe(false) - }) - }) - - describe('with Go zero date string', () => { - it('returns false for Go zero date', () => { - expect(isDateSet('0001-01-01T00:00:00Z')).toBe(false) - }) - }) - - describe('with valid date strings', () => { - it('returns true for ISO date strings', () => { - expect(isDateSet('2024-01-15T10:30:00Z')).toBe(true) - expect(isDateSet('2023-12-25T00:00:00Z')).toBe(true) - }) - - it('returns true for other date formats', () => { - expect(isDateSet('2024-01-15')).toBe(true) - }) - }) - - describe('with Date objects', () => { - it('returns true for valid Date objects', () => { - expect(isDateSet(new Date())).toBe(true) - expect(isDateSet(new Date('2024-01-15T10:30:00Z'))).toBe(true) - }) - - // Note: Date objects representing Go zero date would return true because - // toISOString() adds milliseconds (0001-01-01T00:00:00.000Z). - // In practice, dates from the API come as strings, not Date objects, - // so this edge case doesn't occur. - }) - - describe('with other truthy values', () => { - it('returns true for non-date truthy values', () => { - expect(isDateSet(123)).toBe(true) - expect(isDateSet({})).toBe(true) - }) - }) -}) diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2a..000000000 --- a/ui/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// <reference types="vite/client" /> diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json deleted file mode 100644 index d739292ae..000000000 --- a/ui/tsconfig.app.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"] -} diff --git a/ui/tsconfig.json b/ui/tsconfig.json deleted file mode 100644 index ea9d0cd82..000000000 --- a/ui/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "files": [], - "references": [ - { - "path": "./tsconfig.app.json" - }, - { - "path": "./tsconfig.node.json" - } - ] -} diff --git a/ui/tsconfig.node.json b/ui/tsconfig.node.json deleted file mode 100644 index 3afdd6e38..000000000 --- a/ui/tsconfig.node.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "strict": true, - "noEmit": true - }, - "include": ["vite.config.ts"] -} diff --git a/ui/vite.config.js b/ui/vite.config.js deleted file mode 100644 index 182e825b0..000000000 --- a/ui/vite.config.js +++ /dev/null @@ -1,84 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import { VitePWA } from 'vite-plugin-pwa' - -const frontendPort = parseInt(process.env.PORT) || 4533 -const backendPort = frontendPort + 100 - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [ - react(), - VitePWA({ - manifest: manifest(), - strategies: 'injectManifest', - srcDir: 'src', - filename: 'sw.js', - injectManifest: { - maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, // 3 MiB - // index.html is rendered per-user by the server, so a precached copy - // would pin one user's config (and auth payload) across logins - globIgnores: ['index.html'], - }, - devOptions: { - enabled: true, - }, - }), - ], - server: { - host: true, - port: frontendPort, - proxy: { - '^/(auth|api|rest|backgrounds)/.*': 'http://localhost:' + backendPort, - }, - }, - base: './', - define: { - // JSONForms and other libraries use process.env - 'process.env': JSON.stringify({}), - }, - build: { - outDir: 'build', - sourcemap: true, - }, - test: { - globals: true, - environment: 'jsdom', - setupFiles: './src/setupTests.js', - css: true, - reporters: ['verbose'], - // reporters: ['default', 'hanging-process'], - coverage: { - reporter: ['text', 'json', 'html'], - include: ['src/**/*'], - exclude: [], - }, - }, -}) - -// PWA manifest -function manifest() { - return { - name: 'Navidrome', - short_name: 'Navidrome', - description: - 'Navidrome, an open source web-based music collection server and streamer', - categories: ['music', 'entertainment'], - display: 'standalone', - start_url: './', - background_color: 'white', - theme_color: 'blue', - icons: [ - { - src: './android-chrome-192x192.png', - sizes: '192x192', - type: 'image/png', - }, - { - src: './android-chrome-512x512.png', - sizes: '512x512', - type: 'image/png', - }, - ], - } -} diff --git a/update-translations.sh b/update-translations.sh new file mode 100755 index 000000000..35d599559 --- /dev/null +++ b/update-translations.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +set -e + +download_lang() { + url=$(curl -s -X POST https://poeditor.com/api/ \ + -d api_token="${POEDITOR_APIKEY}" \ + -d action="export" \ + -d id="${POEDITOR_PROJECTID}" \ + -d language="$1" \ + -d type="key_value_json" | jq -r .item) + if [ -z "$url" ]; then + echo "Failed to export $1" + return 1 + fi + curl -sSL "$url" > resources/i18n/"$1".json +} + +for file in resources/i18n/*.json; do + name=$(basename "$file") + code=$(echo "$name" | cut -f1 -d.) + lang=$(jq -r .languageName < "$file") + echo "Downloading $lang ($code)" + download_lang "$code" +done diff --git a/utils/cache/benchmark_test.go b/utils/cache/benchmark_test.go deleted file mode 100644 index 9ab07cf18..000000000 --- a/utils/cache/benchmark_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package cache - -import ( - "context" - "fmt" - "io" - "os" - "runtime" - "strings" - "sync" - "testing" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" -) - -type benchItem struct { - key string -} - -func (b *benchItem) Key() string { return b.key } - -// setupBenchCache creates a file cache in a temp directory. Returns the cache and cleanup function. -func setupBenchCache(b *testing.B, cacheSize string, getReader ReadFunc) (*fileCache, func()) { - b.Helper() - tmpDir, err := os.MkdirTemp("", "bench-cache-*") - if err != nil { - b.Fatal(err) - } - b.Cleanup(configtest.SetupConfig()) - conf.Server.CacheFolder = conf.NewDir(tmpDir) - - fc := NewFileCache("bench", cacheSize, "bench", 0, getReader).(*fileCache) - - // Wait for cache to be ready - for !fc.ready.Load() { - runtime.Gosched() // Yield to allow background init goroutine to run - } - - teardown := func() { - os.RemoveAll(tmpDir) - } - return fc, teardown -} - -func BenchmarkCacheWrite(b *testing.B) { - // Simulate writing 50KB images (typical 300px JPEG) - imageData := strings.Repeat("x", 50*1024) - - fc, cleanup := setupBenchCache(b, "100MB", func(ctx context.Context, item Item) (io.Reader, error) { - return strings.NewReader(imageData), nil - }) - defer cleanup() - - b.SetBytes(int64(len(imageData))) - b.ResetTimer() - for i := 0; i < b.N; i++ { - key := fmt.Sprintf("write-bench-%d", i) - s, err := fc.Get(context.Background(), &benchItem{key: key}) - if err != nil { - b.Fatal(err) - } - _, _ = io.ReadAll(s) - s.Close() - } -} - -func BenchmarkCacheRead(b *testing.B) { - imageData := strings.Repeat("x", 50*1024) - - fc, cleanup := setupBenchCache(b, "100MB", func(ctx context.Context, item Item) (io.Reader, error) { - return strings.NewReader(imageData), nil - }) - defer cleanup() - - // Pre-populate cache - item := &benchItem{key: "read-bench"} - s, err := fc.Get(context.Background(), item) - if err != nil { - b.Fatal(err) - } - _, _ = io.ReadAll(s) - s.Close() - - b.SetBytes(int64(len(imageData))) - b.ResetTimer() - for i := 0; i < b.N; i++ { - s, err := fc.Get(context.Background(), item) - if err != nil { - b.Fatal(err) - } - _, _ = io.ReadAll(s) - s.Close() - } -} - -func BenchmarkConcurrentCacheRead(b *testing.B) { - imageData := strings.Repeat("x", 50*1024) - - fc, cleanup := setupBenchCache(b, "100MB", func(ctx context.Context, item Item) (io.Reader, error) { - return strings.NewReader(imageData), nil - }) - defer cleanup() - - // Pre-populate cache - item := &benchItem{key: "concurrent-read"} - s, _ := fc.Get(context.Background(), item) - _, _ = io.ReadAll(s) - s.Close() - - concurrencyLevels := []int{1, 10, 50} - for _, n := range concurrencyLevels { - b.Run(fmt.Sprintf("goroutines_%d", n), func(b *testing.B) { - b.SetBytes(int64(len(imageData))) - b.ResetTimer() - for i := 0; i < b.N; i++ { - var wg sync.WaitGroup - wg.Add(n) - for range n { - go func() { - defer wg.Done() - s, err := fc.Get(context.Background(), item) - if err != nil { - b.Error(err) - return - } - _, _ = io.ReadAll(s) - s.Close() - }() - } - wg.Wait() - } - }) - } -} - -func BenchmarkConcurrentCacheMiss(b *testing.B) { - imageData := strings.Repeat("x", 50*1024) - - concurrencyLevels := []int{1, 10, 50} - for _, n := range concurrencyLevels { - b.Run(fmt.Sprintf("goroutines_%d", n), func(b *testing.B) { - fc, cleanup := setupBenchCache(b, "100MB", func(ctx context.Context, item Item) (io.Reader, error) { - return strings.NewReader(imageData), nil - }) - defer cleanup() - - b.SetBytes(int64(len(imageData))) - b.ResetTimer() - for i := 0; i < b.N; i++ { - var wg sync.WaitGroup - wg.Add(n) - // All goroutines request the SAME key (not yet cached) - item := &benchItem{key: fmt.Sprintf("miss-%d", i)} - for range n { - go func() { - defer wg.Done() - s, err := fc.Get(context.Background(), item) - if err != nil { - b.Error(err) - return - } - _, _ = io.ReadAll(s) - s.Close() - }() - } - wg.Wait() - } - }) - } -} diff --git a/utils/cache/file_caches.go b/utils/cache/file_caches.go index 48cd135cb..1157d4736 100644 --- a/utils/cache/file_caches.go +++ b/utils/cache/file_caches.go @@ -2,10 +2,8 @@ package cache import ( "context" - "errors" "fmt" "io" - "io/fs" "path/filepath" "sync" "sync/atomic" @@ -19,62 +17,17 @@ import ( "github.com/navidrome/navidrome/log" ) -// Item represents an item that can be cached. It must implement the Key method that returns a unique key for a -// given item. type Item interface { Key() string } -// ReadFunc is a function that retrieves the data to be cached. It receives the Item to be cached and returns -// an io.Reader with the data and an error. type ReadFunc func(ctx context.Context, item Item) (io.Reader, error) -// FileCache is designed to cache data on the filesystem to improve performance by avoiding repeated data -// retrieval operations. -// -// Errors are handled gracefully. If the cache is not initialized or an error occurs during data -// retrieval, it will log the error and proceed without caching. type FileCache interface { - - // Get retrieves data from the cache. This method checks if the data is already cached. If it is, it - // returns the cached data. If not, it retrieves the data using the provided getReader function and caches it. - // - // Example Usage: - // - // s, err := fc.Get(context.Background(), cacheKey("testKey")) - // if err != nil { - // log.Fatal(err) - // } - // defer s.Close() - // - // data, err := io.ReadAll(s) - // if err != nil { - // log.Fatal(err) - // } - // fmt.Println(string(data)) Get(ctx context.Context, item Item) (*CachedStream, error) - - // Available checks if the cache is available Available(ctx context.Context) bool - - // Disabled reports if the cache has been permanently disabled - Disabled(ctx context.Context) bool } -// NewFileCache creates a new FileCache. This function initializes the cache and starts it in the background. -// -// name: A string representing the name of the cache. -// cacheSize: A string representing the maximum size of the cache (e.g., "1KB", "10MB"). -// cacheFolder: A string representing the folder where the cache files will be stored. -// maxItems: An integer representing the maximum number of items the cache can hold. -// getReader: A function of type ReadFunc that retrieves the data to be cached. -// -// Example Usage: -// -// fc := NewFileCache("exampleCache", "10MB", "cacheFolder", 100, func(ctx context.Context, item Item) (io.Reader, error) { -// // Implement the logic to retrieve the data for the given item -// return strings.NewReader(item.Key()), nil -// }) func NewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader ReadFunc) FileCache { fc := &fileCache{ name: name, @@ -87,11 +40,10 @@ func NewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader R go func() { start := time.Now() - cache, sfs, err := newFSCache(fc.name, fc.cacheSize, fc.cacheFolder, fc.maxItems) + cache, err := newFSCache(fc.name, fc.cacheSize, fc.cacheFolder, fc.maxItems) fc.mutex.Lock() defer fc.mutex.Unlock() fc.cache = cache - fc.fs = sfs fc.disabled = cache == nil || err != nil log.Info("Finished initializing cache", "cache", fc.name, "maxSize", fc.cacheSize, "elapsedTime", time.Since(start)) fc.ready.Store(true) @@ -112,7 +64,6 @@ type fileCache struct { cacheFolder string maxItems int cache fscache.Cache - fs *spreadFS getReader ReadFunc disabled bool ready atomic.Bool @@ -126,13 +77,6 @@ func (fc *fileCache) Available(_ context.Context) bool { return fc.ready.Load() && !fc.disabled } -func (fc *fileCache) Disabled(_ context.Context) bool { - fc.mutex.RLock() - defer fc.mutex.RUnlock() - - return fc.disabled -} - func (fc *fileCache) invalidate(ctx context.Context, key string) error { if !fc.Available(ctx) { log.Debug(ctx, "Cache not initialized yet. Cannot invalidate key", "cache", fc.name, "key", key) @@ -160,13 +104,6 @@ func (fc *fileCache) Get(ctx context.Context, arg Item) (*CachedStream, error) { key := arg.Key() r, w, err := fc.cache.Get(key) - if errors.Is(err, fs.ErrNotExist) { - // The entry outlived its data file. Drop it and retry, or every future Get - // for this key fails for the rest of the process's life. - log.Debug(ctx, "Cache entry lost its data file. Re-fetching", "cache", fc.name, "key", key) - _ = fc.invalidate(ctx, key) - r, w, err = fc.cache.Get(key) - } if err != nil { return nil, err } @@ -183,7 +120,7 @@ func (fc *fileCache) Get(ctx context.Context, arg Item) (*CachedStream, error) { return nil, err } go func() { - if err := fc.copyAndClose(ctx, key, w, reader); err != nil { + if err := copyAndClose(w, reader); err != nil { log.Debug(ctx, "Error storing file in cache", "cache", fc.name, "key", key, err) _ = fc.invalidate(ctx, key) } else { @@ -213,7 +150,6 @@ func (fc *fileCache) Get(ctx context.Context, arg Item) (*CachedStream, error) { return &CachedStream{Reader: r, Cached: cached}, nil } -// CachedStream is a wrapper around an io.ReadCloser that allows reading from a cache. type CachedStream struct { io.Reader io.Seeker @@ -242,8 +178,7 @@ func getFinalCachedSize(r fscache.ReadAtCloser) int64 { return -1 } -// copyAndClose marks the entry complete before closing w, so EOF implies the entry is settled on disk. -func (fc *fileCache) copyAndClose(ctx context.Context, key string, w io.WriteCloser, r io.Reader) error { +func copyAndClose(w io.WriteCloser, r io.Reader) error { _, err := io.Copy(w, r) if err != nil { err = fmt.Errorf("copying data to cache: %w", err) @@ -253,38 +188,14 @@ func (fc *fileCache) copyAndClose(ctx context.Context, key string, w io.WriteClo err = multierror.Append(err, fmt.Errorf("closing source stream: %w", cErr)) } } - if err == nil { - fc.markComplete(ctx, key) - } else if cw, ok := w.(interface{ CloseWithError(error) error }); ok { - // Cancel instead of close, so readers fail with the cause rather than - // draining a truncated entry to a clean EOF. - if cErr := cw.CloseWithError(err); cErr != nil { - // Join, not Append: err is now shared with readers and must not be mutated. - return errors.Join(err, fmt.Errorf("closing cache writer: %w", cErr)) - } - return err - } else { - log.Warn(ctx, "Cache writer cannot report failures; readers will see a truncated entry as a clean EOF", - "cache", fc.name, "key", key, err) - } + if cErr := w.Close(); cErr != nil { err = multierror.Append(err, fmt.Errorf("closing cache writer: %w", cErr)) } return err } -// markComplete records on disk that the entry for key was written in full, -// so it is eligible for adoption after a restart (see spreadFS.Reload). -func (fc *fileCache) markComplete(ctx context.Context, key string) { - if fc.fs == nil { - return - } - if err := fc.fs.MarkComplete(key); err != nil { - log.Warn(ctx, "Error writing cache completion marker", "cache", fc.name, "key", key, err) - } -} - -func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, *spreadFS, error) { +func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cache, error) { size, err := humanize.ParseBytes(cacheSize) if err != nil { log.Error("Invalid cache size. Using default size", "cache", name, "size", cacheSize, @@ -293,27 +204,27 @@ func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cach } if size == 0 { log.Warn(fmt.Sprintf("%s cache disabled", name)) - return nil, nil, nil + return nil, nil } - lru := NewFileHaunter(name, maxItems, size, consts.DefaultCacheCleanUpInterval) + lru := NewFileHaunter(name, maxItems, int64(size), consts.DefaultCacheCleanUpInterval) h := fscache.NewLRUHaunterStrategy(lru) - cacheFolder = filepath.Join(conf.Server.CacheFolder.MustPath(), cacheFolder) + cacheFolder = filepath.Join(conf.Server.CacheFolder, cacheFolder) var fs *spreadFS log.Info(fmt.Sprintf("Creating %s cache", name), "path", cacheFolder, "maxSize", humanize.Bytes(size)) fs, err = NewSpreadFS(cacheFolder, 0755) if err != nil { log.Error(fmt.Sprintf("Error initializing %s cache FS", name), err) - return nil, nil, err + return nil, err } ck, err := fscache.NewCacheWithHaunter(fs, h) if err != nil { log.Error(fmt.Sprintf("Error initializing %s cache", name), err) - return nil, nil, err + return nil, err } ck.SetKeyMapper(fs.KeyMapper) - return ck, fs, nil + return ck, nil } diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go index 3189de6b2..3679c79f3 100644 --- a/utils/cache/file_caches_test.go +++ b/utils/cache/file_caches_test.go @@ -7,8 +7,6 @@ import ( "os" "path/filepath" "strings" - "sync/atomic" - "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" @@ -19,7 +17,7 @@ import ( // Call NewFileCache and wait for it to be ready func callNewFileCache(name, cacheSize, cacheFolder string, maxItems int, getReader ReadFunc) *fileCache { fc := NewFileCache(name, cacheSize, cacheFolder, maxItems, getReader).(*fileCache) - Eventually(func() bool { return fc.ready.Load() }, 10*time.Second).Should(BeTrue()) + Eventually(func() bool { return fc.ready.Load() }).Should(BeTrue()) return fc } @@ -30,14 +28,14 @@ var _ = Describe("File Caches", func() { configtest.SetupConfig() _ = os.RemoveAll(tmpDir) }) - conf.Server.CacheFolder = conf.NewDir(tmpDir) + conf.Server.CacheFolder = tmpDir }) Describe("NewFileCache", func() { It("creates the cache folder", func() { Expect(callNewFileCache("test", "1k", "test", 0, nil)).ToNot(BeNil()) - _, err := os.Stat(filepath.Join(conf.Server.CacheFolder.String(), "test")) + _, err := os.Stat(filepath.Join(conf.Server.CacheFolder, "test")) Expect(os.IsNotExist(err)).To(BeFalse()) }) @@ -52,13 +50,6 @@ var _ = Describe("File Caches", func() { Expect(fc.cache).To(BeNil()) Expect(fc.disabled).To(BeTrue()) }) - - It("reports when cache is disabled", func() { - fc := callNewFileCache("test", "0", "test", 0, nil) - Expect(fc.Disabled(context.Background())).To(BeTrue()) - fc = callNewFileCache("test", "1KB", "test", 0, nil) - Expect(fc.Disabled(context.Background())).To(BeFalse()) - }) }) Describe("FileCache", func() { @@ -106,74 +97,6 @@ var _ = Describe("File Caches", func() { Expect(called).To(BeTrue()) }) - It("writes a completion marker after a successful cache write", func() { - fc := callNewFileCache("test", "1KB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - return strings.NewReader("complete-data"), nil - }) - s, err := fc.Get(context.Background(), &testArg{"markme"}) - Expect(err).To(BeNil()) - _, _ = io.ReadAll(s) - _ = s.Close() - - // EOF must imply the entry is settled on disk (Windows temp-dir cleanups rely on it). - dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"markme"}).Key()) - _, statErr := os.Stat(dataPath + ".complete") - Expect(statErr).ToNot(HaveOccurred()) - }) - - It("serves a concurrent reader from an in-progress write and marks complete once", func() { - pr, pw := io.Pipe() - fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - return pr, nil // slow, still-being-produced stream - }) - - // First Get → MISS; the cache starts copying pr into the entry in a goroutine. - s1, err := fc.Get(context.Background(), &testArg{"live"}) - Expect(err).To(BeNil()) - - // Write the first chunk so the entry exists with in-flight bytes, - // but leave the pipe open so the second reader can attach mid-stream. - // io.Pipe writes block until the cache goroutine reads them, giving us - // a deterministic happens-before: the entry is live before we call Get again. - _, err = pw.Write([]byte("hello ")) - Expect(err).To(BeNil()) - - // Second Get while the pipe is still open → attaches to the in-progress entry. - s2, err := fc.Get(context.Background(), &testArg{"live"}) - Expect(err).To(BeNil()) - - // Drain both readers concurrently; they race against the producer below. - ch1 := make(chan []byte, 1) - ch2 := make(chan []byte, 1) - go func() { b, _ := io.ReadAll(s1); ch1 <- b }() - go func() { b, _ := io.ReadAll(s2); ch2 <- b }() - - // Deliver the rest of the stream and close; both draining goroutines must see it. - _, err = pw.Write([]byte("world")) - Expect(err).To(BeNil()) - Expect(pw.Close()).To(Succeed()) - - Expect(string(<-ch1)).To(Equal("hello world")) - Expect(string(<-ch2)).To(Equal("hello world")) - _ = s1.Close() - _ = s2.Close() - - // Exactly one completion marker must appear. - dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"live"}).Key()) - Eventually(func() bool { - _, e := os.Stat(dataPath + ".complete") - return e == nil - }).Should(BeTrue()) - - // Steady-state HIT: full data, Cached flag set. - s3, err := fc.Get(context.Background(), &testArg{"live"}) - Expect(err).To(BeNil()) - got3, _ := io.ReadAll(s3) - _ = s3.Close() - Expect(s3.Cached).To(BeTrue()) - Expect(string(got3)).To(Equal("hello world")) - }) - Context("reader errors", func() { When("creating a reader fails", func() { It("does not cache", func() { @@ -208,211 +131,6 @@ var _ = Describe("File Caches", func() { }) }) }) - - Context("crash leftover (issue #5636)", func() { - It("does not serve a partial file left on disk as a complete HIT", func() { - // First init: empties + writes the migration sentinel. - fc1 := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - return strings.NewReader("UNUSED"), nil - }) - _ = fc1 - - // Plant a partial file (no marker), simulating a killed process. - sfs, err := NewSpreadFS(filepath.Join(conf.Server.CacheFolder.String(), "test"), 0755) - Expect(err).To(BeNil()) - partialPath := sfs.KeyMapper((&testArg{"track"}).Key()) - Expect(os.MkdirAll(filepath.Dir(partialPath), 0755)).To(Succeed()) - Expect(os.WriteFile(partialPath, []byte("PARTIAL"), 0600)).To(Succeed()) - - // "Restart": a fresh cache over the same folder (sentinel present → strict). - getReaderCalled := false - fc2 := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - getReaderCalled = true - return strings.NewReader("FULL-TRANSCODE"), nil - }) - - s, err := fc2.Get(context.Background(), &testArg{"track"}) - Expect(err).To(BeNil()) - data, _ := io.ReadAll(s) - _ = s.Close() - - Expect(getReaderCalled).To(BeTrue()) // re-transcoded, not served stale - Expect(string(data)).To(Equal("FULL-TRANSCODE")) - }) - }) - - Context("live error path still invalidates", func() { - It("leaves no data file and no marker after a mid-stream reader error", func() { - fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - return errFakeReader{errors.New("boom")}, nil - }) - s, err := fc.Get(context.Background(), &testArg{"err"}) - Expect(err).To(BeNil()) - _, _ = io.Copy(io.Discard, s) - _ = s.Close() - - dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"err"}).Key()) - Eventually(func() bool { - _, e1 := os.Stat(dataPath) - _, e2 := os.Stat(dataPath + ".complete") - return os.IsNotExist(e1) && os.IsNotExist(e2) - }).Should(BeTrue()) - }) - - It("gets a writer that can report failures to readers", func() { - // Guards the fork adoption: if the fscache replace directive is ever lost, - // this fails in CI instead of silently reviving the truncation bug. - fc := callNewFileCache("test", "10MB", "test", 0, nil) - _, w, err := fc.cache.Get("capability") - Expect(err).To(BeNil()) - DeferCleanup(func() { _ = w.Close() }) - - _, ok := w.(interface{ CloseWithError(error) error }) - Expect(ok).To(BeTrue(), "fscache writer lost CloseWithError; check the go.mod replace directive") - }) - - It("fails the reader with the cause instead of a clean EOF", func() { - fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - return &partialThenErrReader{data: []byte("PARTIAL"), err: errors.New("transcoder died")}, nil - }) - s, err := fc.Get(context.Background(), &testArg{"inband"}) - Expect(err).To(BeNil()) - DeferCleanup(func() { _ = s.Close() }) - - _, err = io.ReadAll(s) - Expect(err).To(MatchError(ContainSubstring("transcoder died"))) - }) - - It("fails a reader that joined mid-write with the same cause", func() { - pr, pw := io.Pipe() - fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - return pr, nil - }) - s1, err := fc.Get(context.Background(), &testArg{"joined"}) - Expect(err).To(BeNil()) - DeferCleanup(func() { _ = s1.Close() }) - - // The blocking pipe write gives a happens-before: the entry is in flight. - _, err = pw.Write([]byte("PARTIAL")) - Expect(err).To(BeNil()) - - s2, err := fc.Get(context.Background(), &testArg{"joined"}) - Expect(err).To(BeNil()) - DeferCleanup(func() { _ = s2.Close() }) - Expect(s2.Cached).To(BeTrue()) - - Expect(pw.CloseWithError(errors.New("transcoder died"))).To(Succeed()) - - _, err = io.ReadAll(s2) - Expect(err).To(MatchError(ContainSubstring("transcoder died"))) - }) - - It("does not write a completion marker when the write fails after partial bytes", func() { - // Mimics a transcode that produces real output and then dies: - // the bytes land on disk, but the entry must NOT be marked complete. - fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - return &partialThenErrReader{data: []byte("PARTIAL-OUTPUT"), err: errors.New("transcoder died")}, nil - }) - s, err := fc.Get(context.Background(), &testArg{"partial"}) - Expect(err).To(BeNil()) - _, _ = io.Copy(io.Discard, s) - _ = s.Close() - - dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"partial"}).Key()) - // The marker must never appear for a failed write. Give the async - // writer time to finish, then assert the marker stays absent. - Consistently(func() bool { - _, e := os.Stat(dataPath + ".complete") - return os.IsNotExist(e) - }).Should(BeTrue()) - }) - }) - - Context("entry outliving its data file", func() { - It("re-fetches when the data file vanished behind the cache's back", func() { - var calls atomic.Int32 - fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - calls.Add(1) - return strings.NewReader("payload"), nil - }) - - s, err := fc.Get(context.Background(), &testArg{"vanish"}) - Expect(err).To(BeNil()) - Expect(io.ReadAll(s)).To(Equal([]byte("payload"))) - Expect(s.Close()).To(Succeed()) - - dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"vanish"}).Key()) - Eventually(func() error { _, e := os.Stat(dataPath); return e }).Should(Succeed()) - Expect(os.Remove(dataPath)).To(Succeed()) - - s2, err := fc.Get(context.Background(), &testArg{"vanish"}) - Expect(err).ToNot(HaveOccurred()) - Expect(io.ReadAll(s2)).To(Equal([]byte("payload"))) - _ = s2.Close() - Expect(calls.Load()).To(BeNumerically("==", 2)) - }) - - It("removes a failed entry promptly, without eating its replacement", func() { - // Cancel closes the failed entry's readers, so its removal no longer defers - // past the point where a new entry re-creates the same file. - var n atomic.Int32 - fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - if n.Add(1) == 1 { - return &partialThenErrReader{data: []byte("PARTIAL"), err: errors.New("died")}, nil - } - return strings.NewReader("GOOD"), nil - }) - - key := (&testArg{"deferred"}).Key() - s1, err := fc.Get(context.Background(), &testArg{"deferred"}) - Expect(err).To(BeNil()) - - Eventually(func() bool { return fc.cache.Exists(key) }).Should(BeFalse()) - - s2, err := fc.Get(context.Background(), &testArg{"deferred"}) - Expect(err).To(BeNil()) - Expect(io.ReadAll(s2)).To(Equal([]byte("GOOD"))) - Expect(s2.Close()).To(Succeed()) - - Expect(s1.Close()).To(Succeed()) - - dataPath := fcSpreadFS(fc).KeyMapper(key) - Consistently(func() error { - _, e := os.Stat(dataPath) - return e - }).Should(Succeed(), "the replacement entry's file must survive the failed entry's cleanup") - - s3, err := fc.Get(context.Background(), &testArg{"deferred"}) - Expect(err).ToNot(HaveOccurred()) - Expect(io.ReadAll(s3)).To(Equal([]byte("GOOD"))) - _ = s3.Close() - Expect(n.Load()).To(Equal(int32(2)), "the third Get must be served from cache") - }) - - It("re-fetches when an adopted entry's data file vanished", func() { - // Entries adopted on startup take a different code path than in-process ones. - var calls atomic.Int32 - fc := callNewFileCache("test", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - calls.Add(1) - return strings.NewReader("payload"), nil - }) - dataPath := fcSpreadFS(fc).KeyMapper((&testArg{"adopted"}).Key()) - Expect(os.MkdirAll(filepath.Dir(dataPath), 0755)).To(Succeed()) - Expect(os.WriteFile(dataPath, []byte("payload"), 0600)).To(Succeed()) - Expect(fcSpreadFS(fc).MarkComplete(dataPath)).To(Succeed()) - - adopted := callNewFileCache("test2", "10MB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { - calls.Add(1) - return strings.NewReader("payload"), nil - }) - Expect(os.Remove(dataPath)).To(Succeed()) - - s, err := adopted.Get(context.Background(), &testArg{"adopted"}) - Expect(err).ToNot(HaveOccurred()) - Expect(io.ReadAll(s)).To(Equal([]byte("payload"))) - _ = s.Close() - }) - }) }) }) @@ -423,23 +141,3 @@ func (t *testArg) Key() string { return t.s } type errFakeReader struct{ err error } func (e errFakeReader) Read([]byte) (int, error) { return 0, e.err } - -// partialThenErrReader emits data once, then fails — mimicking a transcoder -// that produces some output and then dies mid-stream. -type partialThenErrReader struct { - data []byte - err error - done bool -} - -func (r *partialThenErrReader) Read(p []byte) (int, error) { - if r.done { - return 0, r.err - } - r.done = true - return copy(p, r.data), nil -} - -func fcSpreadFS(fc *fileCache) *spreadFS { - return fc.fs -} diff --git a/utils/cache/file_haunter.go b/utils/cache/file_haunter.go index bee06ef28..22d34c5c6 100644 --- a/utils/cache/file_haunter.go +++ b/utils/cache/file_haunter.go @@ -21,7 +21,7 @@ type haunterKV struct { // If maxItems or maxSize are 0, they won't be checked // // Based on fscache.NewLRUHaunter -func NewFileHaunter(name string, maxItems int, maxSize uint64, period time.Duration) fscache.LRUHaunter { +func NewFileHaunter(name string, maxItems int, maxSize int64, period time.Duration) fscache.LRUHaunter { return &fileHaunter{ name: name, period: period, @@ -34,7 +34,7 @@ type fileHaunter struct { name string period time.Duration maxItems int - maxSize uint64 + maxSize int64 } func (j *fileHaunter) Next() time.Duration { @@ -43,10 +43,10 @@ func (j *fileHaunter) Next() time.Duration { func (j *fileHaunter) Scrub(c fscache.CacheAccessor) (keysToReap []string) { var count int - var size uint64 + var size int64 var okFiles []haunterKV - log.Trace("Running cache cleanup", "cache", j.name, "maxSize", humanize.Bytes(j.maxSize)) + log.Trace("Running cache cleanup", "cache", j.name, "maxSize", humanize.Bytes(uint64(j.maxSize))) c.EnumerateEntries(func(key string, e fscache.Entry) bool { if e.InUse() { return true @@ -63,7 +63,7 @@ func (j *fileHaunter) Scrub(c fscache.CacheAccessor) (keysToReap []string) { } count++ - size = size + uint64(fileInfo.Size()) + size = size + fileInfo.Size() okFiles = append(okFiles, haunterKV{ key: key, value: e, @@ -94,7 +94,7 @@ func (j *fileHaunter) Scrub(c fscache.CacheAccessor) (keysToReap []string) { return true } - log.Trace("Current cache stats", "cache", j.name, "size", humanize.Bytes(size), "numItems", count) + log.Trace("Current cache stats", "cache", j.name, "size", humanize.Bytes(uint64(size)), "numItems", count) if j.maxItems > 0 { for count > j.maxItems { @@ -118,13 +118,13 @@ func (j *fileHaunter) Scrub(c fscache.CacheAccessor) (keysToReap []string) { return keysToReap } -func (j *fileHaunter) removeFirst(items *[]haunterKV, count int, size uint64) (*string, int, uint64, error) { +func (j *fileHaunter) removeFirst(items *[]haunterKV, count int, size int64) (*string, int, int64, error) { var f haunterKV f, *items = (*items)[0], (*items)[1:] count-- - size = size - uint64(f.info.Size()) + size = size - f.info.Size() return &f.key, count, size, nil } diff --git a/utils/cache/file_haunter_test.go b/utils/cache/file_haunter_test.go deleted file mode 100644 index 6c5151abb..000000000 --- a/utils/cache/file_haunter_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package cache_test - -import ( - "errors" - "fmt" - "io" - "os" - "path/filepath" - "time" - - "github.com/djherbis/fscache" - "github.com/navidrome/navidrome/utils/cache" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("FileHaunter", func() { - var fs fscache.FileSystem - var fsCache *fscache.FSCache - var cacheDir string - var err error - var maxItems int - var maxSize uint64 - - JustBeforeEach(func() { - tempDir, _ := os.MkdirTemp("", "spread_fs") - cacheDir = filepath.Join(tempDir, "cache1") - fs, err = fscache.NewFs(cacheDir, 0700) - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) - - // Use a short haunter period so cleanup runs promptly; the assertions - // below poll with Eventually instead of racing a fixed sleep. - fsCache, err = fscache.NewCacheWithHaunter(fs, fscache.NewLRUHaunterStrategy( - cache.NewFileHaunter("", maxItems, maxSize, 100*time.Millisecond), - )) - Expect(err).ToNot(HaveOccurred()) - DeferCleanup(fsCache.Clean) - - Expect(createTestFiles(fsCache)).To(Succeed()) - }) - - Context("When maxSize is defined", func() { - BeforeEach(func() { - maxSize = 20 - }) - - It("removes files", func() { - // stream-0..4 hold "hello" (5 bytes each) and stream-5 is empty. - // With maxSize=20, the haunter scrubs the empty file plus enough of - // the oldest files to bring the total size down to <= 20 bytes. - // Which files survive (and therefore the exact count) depends on - // access-time ordering, so we only assert the haunter's guarantees: - // the empty file is always scrubbed and the total size stays within - // the configured limit. - Eventually(func(g Gomega) { - g.Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") - size, err := dirSize(cacheDir) - g.Expect(err).ToNot(HaveOccurred()) - g.Expect(size).To(BeNumerically("<=", maxSize)) - }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Succeed()) - }) - }) - - Context("When maxItems is defined", func() { - BeforeEach(func() { - maxItems = 3 - }) - - It("removes files", func() { - // With maxItems=3, the haunter scrubs the empty file plus enough of - // the oldest files to bring the count within the limit. As above, the - // exact survivors depend on access-time ordering, so we assert the - // guaranteed invariants: the empty file is gone and the item count - // stays within the configured limit. - Eventually(func(g Gomega) { - g.Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") - entries, readErr := os.ReadDir(cacheDir) - g.Expect(readErr).ToNot(HaveOccurred()) - g.Expect(len(entries)).To(BeNumerically("<=", maxItems)) - }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Succeed()) - }) - }) -}) - -func createTestFiles(c *fscache.FSCache) error { - // Create 5 normal files and 1 empty - for i := range 6 { - name := fmt.Sprintf("stream-%v", i) - var r fscache.ReadAtCloser - if i < 5 { - r = createCachedStream(c, name, "hello") - } else { // Last one is empty - r = createCachedStream(c, name, "") - } - - if !c.Exists(name) { - return errors.New(name + " should exist") - } - - <-time.After(10 * time.Millisecond) - - err := r.Close() - if err != nil { - return err - } - } - return nil -} - -// dirSize returns the total size in bytes of all regular files in dir. -func dirSize(dir string) (uint64, error) { - entries, err := os.ReadDir(dir) - if err != nil { - return 0, err - } - var total uint64 - for _, e := range entries { - info, err := e.Info() - if err != nil { - return 0, err - } - if !info.Mode().IsRegular() { - continue - } - total += uint64(info.Size()) - } - return total, nil -} - -func createCachedStream(c *fscache.FSCache, name string, contents string) fscache.ReadAtCloser { - r, w, _ := c.Get(name) - _, _ = w.Write([]byte(contents)) - _ = w.Close() - _, _ = io.Copy(io.Discard, r) - return r -} diff --git a/utils/cache/file_hauter_test.go b/utils/cache/file_hauter_test.go new file mode 100644 index 000000000..6bdad4a9d --- /dev/null +++ b/utils/cache/file_hauter_test.go @@ -0,0 +1,81 @@ +package cache_test + +import ( + "fmt" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/djherbis/fscache" + "github.com/navidrome/navidrome/utils/cache" +) + +func TestFileHaunterMaxSize(t *testing.T) { + tempDir, _ := os.MkdirTemp("", "spread_fs") + cacheDir := filepath.Join(tempDir, "cache1") + fs, err := fscache.NewFs(cacheDir, 0700) + if err != nil { + t.Error(err.Error()) + t.FailNow() + } + defer os.RemoveAll(tempDir) + + c, err := fscache.NewCacheWithHaunter(fs, fscache.NewLRUHaunterStrategy(cache.NewFileHaunter("", 0, 24, 400*time.Millisecond))) + if err != nil { + t.Error(err.Error()) + return + } + defer c.Clean() //nolint:errcheck + + // Create 5 normal files and 1 empty + for i := 0; i < 6; i++ { + name := fmt.Sprintf("stream-%v", i) + var r fscache.ReadAtCloser + if i < 5 { + r = createCachedStream(c, name, "hello") + } else { // Last one is empty + r = createCachedStream(c, name, "") + } + + if !c.Exists(name) { + t.Errorf(name + " should exist") + } + + <-time.After(10 * time.Millisecond) + + err := r.Close() + if err != nil { + t.Error(err) + } + } + + <-time.After(400 * time.Millisecond) + + if c.Exists("stream-0") { + t.Errorf("stream-0 should have been scrubbed") + } + + if c.Exists("stream-5") { + t.Errorf("stream-5 should have been scrubbed") + } + + files, err := os.ReadDir(cacheDir) + if err != nil { + t.Error(err.Error()) + return + } + + if len(files) != 4 { + t.Errorf("expected 4 items in directory") + } +} + +func createCachedStream(c *fscache.FSCache, name string, contents string) fscache.ReadAtCloser { + r, w, _ := c.Get(name) + _, _ = w.Write([]byte(contents)) + _ = w.Close() + _, _ = io.Copy(io.Discard, r) + return r +} diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go deleted file mode 100644 index 494451c9e..000000000 --- a/utils/cache/simple_cache.go +++ /dev/null @@ -1,192 +0,0 @@ -package cache - -import ( - "context" - "errors" - "fmt" - "runtime" - "sync" - "sync/atomic" - "time" - - "github.com/jellydator/ttlcache/v3" -) - -type SimpleCache[K comparable, V any] interface { - Add(key K, value V) error - 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 - OnExpiration(fn func(K, V)) func() -} - -type Options struct { - SizeLimit uint64 - DefaultTTL time.Duration -} - -func NewSimpleCache[K comparable, V any](options ...Options) SimpleCache[K, V] { - opts := []ttlcache.Option[K, V]{ - ttlcache.WithDisableTouchOnHit[K, V](), - } - if len(options) > 0 { - o := options[0] - if o.SizeLimit > 0 { - opts = append(opts, ttlcache.WithCapacity[K, V](o.SizeLimit)) - } - if o.DefaultTTL > 0 { - opts = append(opts, ttlcache.WithTTL[K, V](o.DefaultTTL)) - } - } - - c := ttlcache.New[K, V](opts...) - cache := &simpleCache[K, V]{ - data: c, - loads: make(map[K]*flight[V]), - } - go cache.data.Start() - - // Automatic cleanup to prevent goroutine leak when cache is garbage collected - runtime.AddCleanup(cache, func(ttlCache *ttlcache.Cache[K, V]) { - ttlCache.Stop() - }, cache.data) - - return cache -} - -const evictionTimeout = 1 * time.Hour - -type simpleCache[K comparable, V any] struct { - data *ttlcache.Cache[K, V] - evictionDeadline atomic.Pointer[time.Time] - loadsMu sync.Mutex - loads map[K]*flight[V] -} - -// flight tracks an in-progress load so concurrent misses of the same key share it. -type flight[V any] struct { - done chan struct{} - val V - err error -} - -func (f *flight[V]) result() (V, error) { - if f.err != nil { - var zero V - return zero, fmt.Errorf("cache error: loader returned %w", f.err) - } - return f.val, nil -} - -func (c *simpleCache[K, V]) Add(key K, value V) error { - c.evictExpired() - return c.AddWithTTL(key, value, ttlcache.DefaultTTL) -} - -func (c *simpleCache[K, V]) AddWithTTL(key K, value V, ttl time.Duration) error { - c.evictExpired() - item := c.data.Set(key, value, ttl) - if item == nil { - return errors.New("failed to add item") - } - 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 { - var zero V - return zero, errors.New("item not found") - } - return item.Value(), nil -} - -// GetWithLoader loads misses via the loader, deduplicating concurrent loads of -// the same key: one loader call runs, and every waiter shares its result (or error). -func (c *simpleCache[K, V]) GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error) { - if item := c.data.Get(key); item != nil { - return item.Value(), nil - } - - c.loadsMu.Lock() - if f, ok := c.loads[key]; ok { - c.loadsMu.Unlock() - <-f.done - return f.result() - } - f := &flight[V]{done: make(chan struct{}), err: errLoaderPanicked} - c.loads[key] = f - c.loadsMu.Unlock() - - // Deregister even if the loader panics, so waiters get an error instead of - // blocking forever on a flight that will never complete. - defer func() { - close(f.done) - c.loadsMu.Lock() - delete(c.loads, key) - c.loadsMu.Unlock() - }() - - if item := c.data.Get(key); item != nil { // a flight may have completed since the miss - f.val, f.err = item.Value(), nil - } else { - c.evictExpired() - var ttl time.Duration - f.val, ttl, f.err = loader(key) - if f.err == nil { - c.data.Set(key, f.val, ttl) - } - } - return f.result() -} - -var errLoaderPanicked = errors.New("loader panicked") - -func (c *simpleCache[K, V]) evictExpired() { - if c.evictionDeadline.Load() == nil || c.evictionDeadline.Load().Before(time.Now()) { - c.data.DeleteExpired() - c.evictionDeadline.Store(new(time.Now().Add(evictionTimeout))) - } -} - -func (c *simpleCache[K, V]) Keys() []K { - res := make([]K, 0, c.data.Len()) - c.data.Range(func(item *ttlcache.Item[K, V]) bool { - if !item.IsExpired() { - res = append(res, item.Key()) - } - return true - }) - return res -} - -func (c *simpleCache[K, V]) Values() []V { - res := make([]V, 0, c.data.Len()) - c.data.Range(func(item *ttlcache.Item[K, V]) bool { - if !item.IsExpired() { - res = append(res, item.Value()) - } - return true - }) - return res -} - -func (c *simpleCache[K, V]) Len() int { - return c.data.Len() -} - -func (c *simpleCache[K, V]) OnExpiration(fn func(K, V)) func() { - return c.data.OnEviction(func(_ context.Context, reason ttlcache.EvictionReason, item *ttlcache.Item[K, V]) { - if reason == ttlcache.EvictionReasonExpired { - fn(item.Key(), item.Value()) - } - }) -} diff --git a/utils/cache/simple_cache_test.go b/utils/cache/simple_cache_test.go deleted file mode 100644 index 1c4f5c9bb..000000000 --- a/utils/cache/simple_cache_test.go +++ /dev/null @@ -1,273 +0,0 @@ -package cache - -import ( - "errors" - "fmt" - "sync" - "sync/atomic" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("SimpleCache", func() { - var ( - cache SimpleCache[string, string] - ) - - BeforeEach(func() { - cache = NewSimpleCache[string, string]() - }) - - Describe("Add and Get", func() { - It("should add and retrieve a value", func() { - err := cache.Add("key", "value") - Expect(err).NotTo(HaveOccurred()) - - value, err := cache.Get("key") - Expect(err).NotTo(HaveOccurred()) - Expect(value).To(Equal("value")) - }) - }) - - Describe("AddWithTTL and Get", func() { - It("should add a value with TTL and retrieve it", func() { - err := cache.AddWithTTL("key", "value", 1*time.Minute) - Expect(err).NotTo(HaveOccurred()) - - value, err := cache.Get("key") - Expect(err).NotTo(HaveOccurred()) - Expect(value).To(Equal("value")) - }) - - It("should not retrieve a value after its TTL has expired", func() { - err := cache.AddWithTTL("key", "value", 10*time.Millisecond) - Expect(err).NotTo(HaveOccurred()) - - time.Sleep(50 * time.Millisecond) - - _, err = cache.Get("key") - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("GetWithLoader", func() { - It("should retrieve a value using the loader function", func() { - loader := func(key string) (string, time.Duration, error) { - return fmt.Sprintf("%s=value", key), 1 * time.Minute, nil - } - - value, err := cache.GetWithLoader("key", loader) - Expect(err).NotTo(HaveOccurred()) - Expect(value).To(Equal("key=value")) - }) - - It("should return the error returned by the loader function", func() { - loader := func(key string) (string, time.Duration, error) { - return "", 0, errors.New("some error") - } - - _, err := cache.GetWithLoader("key", loader) - Expect(err).To(HaveOccurred()) - }) - - It("suppresses concurrent loads for the same key", func() { - var calls atomic.Int32 - release := make(chan struct{}) - started := make(chan struct{}, 10) - loader := func(key string) (string, time.Duration, error) { - calls.Add(1) - started <- struct{}{} - <-release - return "shared", time.Minute, nil - } - - const n = 5 - var wg sync.WaitGroup - results := make([]string, n) - errs := make([]error, n) - for i := range n { - wg.Go(func() { - results[i], errs[i] = cache.GetWithLoader("key", loader) - }) - } - - Eventually(started).Should(Receive()) - Consistently(started).ShouldNot(Receive()) - close(release) - wg.Wait() - - Expect(calls.Load()).To(Equal(int32(1))) - for i := range n { - Expect(errs[i]).ToNot(HaveOccurred()) - Expect(results[i]).To(Equal("shared")) - } - }) - - It("returns the loader error to all concurrent callers", func() { - release := make(chan struct{}) - started := make(chan struct{}, 10) - loader := func(key string) (string, time.Duration, error) { - started <- struct{}{} - <-release - return "", 0, errors.New("load failed") - } - - const n = 3 - var wg sync.WaitGroup - errs := make([]error, n) - for i := range n { - wg.Go(func() { - _, errs[i] = cache.GetWithLoader("key", loader) - }) - } - - Eventually(started).Should(Receive()) - Consistently(started).ShouldNot(Receive()) - close(release) - wg.Wait() - - for i := range n { - Expect(errs[i]).To(MatchError(ContainSubstring("load failed"))) - } - }) - - It("supports interface value types with nil results", func() { - c := NewSimpleCache[string, any]() - v, err := c.GetWithLoader("key", func(string) (any, time.Duration, error) { - return nil, time.Minute, nil - }) - Expect(err).ToNot(HaveOccurred()) - Expect(v).To(BeNil()) - }) - - It("cleans up the in-flight registration when the loader panics", func() { - Expect(func() { - _, _ = cache.GetWithLoader("key", func(string) (string, time.Duration, error) { - panic("boom") - }) - }).To(PanicWith("boom")) - - // Without cleanup this would deadlock on the never-completed flight - v, err := cache.GetWithLoader("key", func(string) (string, time.Duration, error) { - return "ok", 0, nil - }) - Expect(err).ToNot(HaveOccurred()) - Expect(v).To(Equal("ok")) - }) - - It("loads different keys independently", func() { - release := make(chan struct{}) - started := make(chan struct{}, 10) - loader := func(key string) (string, time.Duration, error) { - started <- struct{}{} - <-release - return key + "=value", time.Minute, nil - } - - var wg sync.WaitGroup - for _, key := range []string{"key1", "key2"} { - wg.Go(func() { - value, err := cache.GetWithLoader(key, loader) - Expect(err).ToNot(HaveOccurred()) - Expect(value).To(Equal(key + "=value")) - }) - } - - // Both loaders must be in flight at once: distinct keys are not suppressed - Eventually(started).Should(Receive()) - Eventually(started).Should(Receive()) - close(release) - wg.Wait() - }) - }) - - Describe("Keys and Values", func() { - It("should return all keys and all values", func() { - err := cache.Add("key1", "value1") - Expect(err).NotTo(HaveOccurred()) - - err = cache.Add("key2", "value2") - Expect(err).NotTo(HaveOccurred()) - - keys := cache.Keys() - Expect(keys).To(ConsistOf("key1", "key2")) - - values := cache.Values() - Expect(values).To(ConsistOf("value1", "value2")) - }) - - Context("when there are expired items in the cache", func() { - It("should not return expired items", func() { - Expect(cache.Add("key0", "value0")).To(Succeed()) - for i := 1; i <= 3; i++ { - err := cache.AddWithTTL(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i), 10*time.Millisecond) - Expect(err).NotTo(HaveOccurred()) - } - - time.Sleep(50 * time.Millisecond) - - Expect(cache.Keys()).To(ConsistOf("key0")) - Expect(cache.Values()).To(ConsistOf("value0")) - }) - }) - }) - - Describe("Options", func() { - Context("when size limit is set", func() { - BeforeEach(func() { - cache = NewSimpleCache[string, string](Options{ - SizeLimit: 2, - }) - }) - - It("should drop the oldest item when the size limit is reached", func() { - for i := 1; i <= 3; i++ { - err := cache.Add(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i)) - Expect(err).NotTo(HaveOccurred()) - } - - Expect(cache.Keys()).To(ConsistOf("key2", "key3")) - }) - }) - - Context("when default TTL is set", func() { - BeforeEach(func() { - cache = NewSimpleCache[string, string](Options{ - DefaultTTL: 10 * time.Millisecond, - }) - }) - - It("should expire items after the default TTL", func() { - Expect(cache.AddWithTTL("key0", "value0", 1*time.Minute)).To(Succeed()) - for i := 1; i <= 3; i++ { - err := cache.Add(fmt.Sprintf("key%d", i), fmt.Sprintf("value%d", i)) - Expect(err).NotTo(HaveOccurred()) - } - - time.Sleep(50 * time.Millisecond) - - for i := 1; i <= 3; i++ { - _, err := cache.Get(fmt.Sprintf("key%d", i)) - Expect(err).To(HaveOccurred()) - } - Expect(cache.Get("key0")).To(Equal("value0")) - }) - }) - - Describe("OnExpiration", func() { - It("should call callback when item expires", func() { - cache = NewSimpleCache[string, string]() - expired := make(chan struct{}) - cache.OnExpiration(func(k, v string) { close(expired) }) - Expect(cache.AddWithTTL("key", "value", 10*time.Millisecond)).To(Succeed()) - select { - case <-expired: - case <-time.After(100 * time.Millisecond): - Fail("expiration callback not called") - } - }) - }) - }) -}) diff --git a/utils/cache/spread_fs.go b/utils/cache/spread_fs.go index 11801b324..281e2bfab 100644 --- a/utils/cache/spread_fs.go +++ b/utils/cache/spread_fs.go @@ -14,9 +14,6 @@ import ( "github.com/navidrome/navidrome/log" ) -const completeMarkerSuffix = ".complete" -const sentinelName = ".nd-migrated" - type spreadFS struct { root string mode os.FileMode @@ -43,83 +40,30 @@ func NewSpreadFS(dir string, mode os.FileMode) (*spreadFS, error) { } func (sfs *spreadFS) Reload(f func(key string, name string)) error { - // On the first run after upgrade (no sentinel yet), pre-existing files have - // no completion marker. Migrate them instead of discarding them as partials, - // so a user's whole cache isn't wiped. After the sentinel exists, an unmarked - // file is a crash partial and is discarded. - sentinel := filepath.Join(sfs.root, sentinelName) - _, sErr := os.Stat(sentinel) - migrating := os.IsNotExist(sErr) - count := 0 - err := sfs.walkDataFiles(func(absoluteFilePath string) { - if _, statErr := os.Stat(sfs.markerPath(absoluteFilePath)); statErr != nil { - switch { - case migrating: - if mErr := sfs.MarkComplete(absoluteFilePath); mErr != nil { - log.Warn("Error migrating cache file", "file", absoluteFilePath, mErr) - } - case os.IsNotExist(statErr): - // No completion marker: this is a partial left by a crash. Discard it. - log.Debug("Removing incomplete cache file", "file", absoluteFilePath) - _ = os.Remove(absoluteFilePath) //nolint:gosec // best-effort cleanup; re-swept on next Reload - return - default: - // Marker may exist but is unreadable (transient I/O, permissions): - // skip adoption without destroying a possibly-valid entry. - log.Warn("Error reading cache completion marker", "file", absoluteFilePath, statErr) - return - } - } - f(absoluteFilePath, absoluteFilePath) - count++ - }) - if err != nil { - return err - } - - log.Debug("Loaded cache", "dir", sfs.root, "numItems", count) - // Only record the migration as done after a clean walk, so a partial walk - // doesn't leave valid-but-unmarked files to be discarded on the next run. - if migrating { - if wErr := os.WriteFile(sentinel, nil, 0600); wErr != nil { - log.Warn("Error writing cache migration sentinel", "file", sentinel, wErr) - } - } - return nil -} - -// walkDataFiles visits every cache data file (named XX/XX/<40-hex>), skipping -// completion markers and opportunistically cleaning up orphaned ones. -func (sfs *spreadFS) walkDataFiles(visit func(absoluteFilePath string)) error { - return filepath.WalkDir(sfs.root, func(absoluteFilePath string, _ fs.DirEntry, err error) error { + err := filepath.WalkDir(sfs.root, func(absoluteFilePath string, de fs.DirEntry, err error) error { if err != nil { log.Error("Error loading cache", "dir", sfs.root, err) - return nil } path, err := filepath.Rel(sfs.root, absoluteFilePath) if err != nil { return nil //nolint:nilerr } - // Skip marker files; also clean orphan markers (data file gone). - if strings.HasSuffix(path, completeMarkerSuffix) { - dataPath := strings.TrimSuffix(absoluteFilePath, completeMarkerSuffix) - if _, statErr := os.Stat(dataPath); os.IsNotExist(statErr) { - _ = os.Remove(absoluteFilePath) //nolint:gosec // best-effort cleanup; re-swept on next Reload - } - return nil - } - // Skip if name is not in the format XX/XX/XXXXXXXXXXXX parts := strings.Split(path, string(os.PathSeparator)) if len(parts) != 3 || len(parts[0]) != 2 || len(parts[1]) != 2 || len(parts[2]) != 40 { return nil } - visit(absoluteFilePath) + f(absoluteFilePath, absoluteFilePath) + count++ return nil }) + if err == nil { + log.Debug("Loaded cache", "dir", sfs.root, "numItems", count) + } + return err } func (sfs *spreadFS) Create(name string) (stream.File, error) { @@ -128,34 +72,14 @@ func (sfs *spreadFS) Create(name string) (stream.File, error) { if err != nil { return nil, err } - return createDataFile(name) + return os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) } func (sfs *spreadFS) Open(name string) (stream.File, error) { return os.Open(name) } -func (sfs *spreadFS) markerPath(dataPath string) string { - return dataPath + completeMarkerSuffix -} - -// MarkComplete records that the cache entry for key was written in full. -// Only files with a marker are adopted on the next Reload; this is what -// distinguishes a complete cache entry from a partial one left by a crash. -// key may be an original cache key or an already-mapped data path; KeyMapper -// is idempotent for the latter (see KeyMapper). -func (sfs *spreadFS) MarkComplete(key string) error { - f, err := os.OpenFile(sfs.markerPath(sfs.KeyMapper(key)), os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - return err - } - return f.Close() -} - func (sfs *spreadFS) Remove(name string) error { - if err := os.Remove(sfs.markerPath(name)); err != nil && !os.IsNotExist(err) { - log.Warn("Error removing cache completion marker", "file", name, err) - } return os.Remove(name) } diff --git a/utils/cache/spread_fs_test.go b/utils/cache/spread_fs_test.go index d0b54567d..2768ea2d5 100644 --- a/utils/cache/spread_fs_test.go +++ b/utils/cache/spread_fs_test.go @@ -1,13 +1,10 @@ package cache import ( - "io" "os" "path/filepath" - "runtime" "strings" - "github.com/djherbis/stream" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -42,126 +39,31 @@ var _ = Describe("Spread FS", func() { }) }) - Describe("Create", func() { - It("leaves an already-open reader's bytes intact", func() { - // Shrinking a file an older stream still serves spins its reader at a premature EOF. - if runtime.GOOS == "windows" { - Skip("Windows cannot unlink a file with open handles, so Create reuses the inode") - } - name := filepath.Join(rootDir, "aa", "bb", "data") - s, err := stream.NewStream(name, fs) - Expect(err).To(BeNil()) - _, err = s.Write([]byte("PARTIAL")) - Expect(err).To(BeNil()) - r, err := s.NextReader() - Expect(err).To(BeNil()) - Expect(s.Close()).To(Succeed()) - - f, err := fs.Create(name) - Expect(err).To(BeNil()) - _, err = f.Write([]byte("GOOD")) - Expect(err).To(BeNil()) - Expect(f.Close()).To(Succeed()) - - done := make(chan []byte, 1) - go func() { - b, _ := io.ReadAll(r) - done <- b - }() - Eventually(done).Should(Receive(Equal([]byte("PARTIAL")))) - Expect(r.Close()).To(Succeed()) - - Expect(os.ReadFile(name)).To(Equal([]byte("GOOD"))) - }) - }) - - Describe("MarkComplete / Remove markers", func() { - It("creates a .complete marker for a data file", func() { - data := fs.KeyMapper("song1") - f, err := fs.Create(data) - Expect(err).To(BeNil()) - _, _ = f.Write([]byte("ok")) - _ = f.Close() - - Expect(fs.MarkComplete(data)).To(Succeed()) - _, statErr := os.Stat(data + ".complete") - Expect(statErr).To(BeNil()) - }) - - It("removes the sibling marker when the data file is removed", func() { - data := fs.KeyMapper("song2") - f, err := fs.Create(data) - Expect(err).To(BeNil()) - _, _ = f.Write([]byte("ok")) - _ = f.Close() - Expect(fs.MarkComplete(data)).To(Succeed()) - - Expect(fs.Remove(data)).To(Succeed()) - _, dataErr := os.Stat(data) - Expect(os.IsNotExist(dataErr)).To(BeTrue()) - _, markErr := os.Stat(data + ".complete") - Expect(os.IsNotExist(markErr)).To(BeTrue()) - }) - }) - Describe("Reload", func() { - makeData := func(content string) string { - file := fs.KeyMapper(content) - f, err := fs.Create(file) - Expect(err).To(BeNil()) - _, _ = f.Write([]byte(content)) - _ = f.Close() - return file - } + var files []string - It("migrates all existing files on first run and writes the sentinel", func() { - for _, c := range []string{"aaaaa", "bbbbb", "ccccc"} { - makeData(c) // no markers, simulating a pre-upgrade cache + BeforeEach(func() { + files = []string{"aaaaa", "bbbbb", "ccccc"} + for _, content := range files { + file := fs.KeyMapper(content) + f, err := fs.Create(file) + Expect(err).To(BeNil()) + _, _ = f.Write([]byte(content)) + _ = f.Close() } + }) + It("loads all files from fs", func() { var actual []string - err := fs.Reload(func(key, name string) { + err := fs.Reload(func(key string, name string) { Expect(key).To(Equal(name)) - data, _ := os.ReadFile(name) + data, err := os.ReadFile(name) + Expect(err).To(BeNil()) actual = append(actual, string(data)) }) Expect(err).To(BeNil()) - Expect(actual).To(ContainElements("aaaaa", "bbbbb", "ccccc")) - Expect(actual).To(HaveLen(3)) - - _, sentinelErr := os.Stat(filepath.Join(rootDir, ".nd-migrated")) - Expect(sentinelErr).To(BeNil()) - }) - - It("after migration, adopts only marked files and deletes unmarked partials", func() { - // Pretend migration already happened. - Expect(os.WriteFile(filepath.Join(rootDir, ".nd-migrated"), nil, 0600)).To(Succeed()) - - good := makeData("good") - Expect(fs.MarkComplete(good)).To(Succeed()) - bad := makeData("bad") // partial: no marker - - var actual []string - err := fs.Reload(func(key, name string) { actual = append(actual, name) }) - Expect(err).To(BeNil()) - Expect(actual).To(ConsistOf(good)) - - _, badErr := os.Stat(bad) - Expect(os.IsNotExist(badErr)).To(BeTrue()) // partial deleted - }) - - It("ignores and cleans orphan markers", func() { - Expect(os.WriteFile(filepath.Join(rootDir, ".nd-migrated"), nil, 0600)).To(Succeed()) - orphan := fs.KeyMapper("orphan") + ".complete" - Expect(os.MkdirAll(filepath.Dir(orphan), 0755)).To(Succeed()) - Expect(os.WriteFile(orphan, nil, 0600)).To(Succeed()) - - var actual []string - err := fs.Reload(func(key, name string) { actual = append(actual, name) }) - Expect(err).To(BeNil()) - Expect(actual).To(BeEmpty()) - _, orphanErr := os.Stat(orphan) - Expect(os.IsNotExist(orphanErr)).To(BeTrue()) + Expect(actual).To(HaveLen(len(files))) + Expect(actual).To(ContainElements(files[0], files[1], files[2])) }) }) }) diff --git a/utils/cache/spread_fs_unix.go b/utils/cache/spread_fs_unix.go deleted file mode 100644 index c7cb19cf8..000000000 --- a/utils/cache/spread_fs_unix.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build !windows - -package cache - -import ( - "os" - - "github.com/djherbis/stream" -) - -// createDataFile unlinks instead of truncating, so a re-created entry gets a fresh -// inode and readers still holding the old one see its full contents. -func createDataFile(name string) (stream.File, error) { - if err := os.Remove(name); err != nil && !os.IsNotExist(err) { - return nil, err - } - return os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600) -} diff --git a/utils/cache/spread_fs_windows.go b/utils/cache/spread_fs_windows.go deleted file mode 100644 index ca4186d65..000000000 --- a/utils/cache/spread_fs_windows.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build windows - -package cache - -import ( - "os" - - "github.com/djherbis/stream" -) - -// createDataFile truncates in place: Windows refuses to unlink a file another handle -// has open, and failing the create would break every miss on a path with a live reader. -func createDataFile(name string) (stream.File, error) { - return os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) -} diff --git a/utils/cache/cached_http_client.go b/utils/cached_http_client.go similarity index 50% rename from utils/cache/cached_http_client.go rename to utils/cached_http_client.go index 4eed243dd..b644bc795 100644 --- a/utils/cache/cached_http_client.go +++ b/utils/cached_http_client.go @@ -1,4 +1,4 @@ -package cache +package utils import ( "bufio" @@ -10,15 +10,15 @@ import ( "strings" "time" + "github.com/ReneKroon/ttlcache/v2" "github.com/navidrome/navidrome/log" ) const cacheSizeLimit = 100 -type HTTPClient struct { - cache SimpleCache[string, string] +type CachedHTTPClient struct { + cache *ttlcache.Cache hc httpDoer - ttl time.Duration } type httpDoer interface { @@ -32,42 +32,39 @@ type requestData struct { Body *string } -func NewHTTPClient(wrapped httpDoer, ttl time.Duration) *HTTPClient { - c := &HTTPClient{hc: wrapped, ttl: ttl} - c.cache = NewSimpleCache[string, string](Options{ - SizeLimit: cacheSizeLimit, - DefaultTTL: ttl, +func NewCachedHTTPClient(wrapped httpDoer, ttl time.Duration) *CachedHTTPClient { + c := &CachedHTTPClient{hc: wrapped} + c.cache = ttlcache.NewCache() + c.cache.SetCacheSizeLimit(cacheSizeLimit) + c.cache.SkipTTLExtensionOnHit(true) + c.cache.SetLoaderFunction(func(key string) (interface{}, time.Duration, error) { + req, err := c.deserializeReq(key) + if err != nil { + return nil, 0, err + } + resp, err := c.hc.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + return c.serializeResponse(resp), ttl, nil + }) + c.cache.SetNewItemCallback(func(key string, value interface{}) { + log.Trace("New request cached", "req", key, "resp", value) }) return c } -func (c *HTTPClient) Do(req *http.Request) (*http.Response, error) { +func (c *CachedHTTPClient) Do(req *http.Request) (*http.Response, error) { key := c.serializeReq(req) - cached := true - start := time.Now() - respStr, err := c.cache.GetWithLoader(key, func(key string) (string, time.Duration, error) { - cached = false - req, err := c.deserializeReq(key) - if err != nil { - log.Trace(req.Context(), "CachedHTTPClient.Do", "key", key, err) - return "", 0, err - } - resp, err := c.hc.Do(req) - if err != nil { - log.Trace(req.Context(), "CachedHTTPClient.Do", "req", req, err) - return "", 0, err - } - defer resp.Body.Close() - return c.serializeResponse(resp), c.ttl, nil - }) - log.Trace(req.Context(), "CachedHTTPClient.Do", "key", key, "cached", cached, "elapsed", time.Since(start), err) + respStr, err := c.cache.Get(key) if err != nil { return nil, err } - return c.deserializeResponse(req, respStr) + return c.deserializeResponse(req, respStr.(string)) } -func (c *HTTPClient) serializeReq(req *http.Request) string { +func (c *CachedHTTPClient) serializeReq(req *http.Request) string { data := requestData{ Method: req.Method, Header: req.Header, @@ -75,13 +72,14 @@ func (c *HTTPClient) serializeReq(req *http.Request) string { } if req.Body != nil { bodyData, _ := io.ReadAll(req.Body) - data.Body = new(base64.StdEncoding.EncodeToString(bodyData)) + bodyStr := base64.StdEncoding.EncodeToString(bodyData) + data.Body = &bodyStr } j, _ := json.Marshal(&data) return string(j) } -func (c *HTTPClient) deserializeReq(reqStr string) (*http.Request, error) { +func (c *CachedHTTPClient) deserializeReq(reqStr string) (*http.Request, error) { var data requestData _ = json.Unmarshal([]byte(reqStr), &data) var body io.Reader @@ -97,13 +95,13 @@ func (c *HTTPClient) deserializeReq(reqStr string) (*http.Request, error) { return req, nil } -func (c *HTTPClient) serializeResponse(resp *http.Response) string { +func (c *CachedHTTPClient) serializeResponse(resp *http.Response) string { var b = &bytes.Buffer{} _ = resp.Write(b) return b.String() } -func (c *HTTPClient) deserializeResponse(req *http.Request, respStr string) (*http.Response, error) { +func (c *CachedHTTPClient) deserializeResponse(req *http.Request, respStr string) (*http.Response, error) { r := bufio.NewReader(strings.NewReader(respStr)) return http.ReadResponse(r, req) } diff --git a/utils/cache/cached_http_client_test.go b/utils/cached_http_client_test.go similarity index 90% rename from utils/cache/cached_http_client_test.go rename to utils/cached_http_client_test.go index 5f8b0029c..0d00b120a 100644 --- a/utils/cache/cached_http_client_test.go +++ b/utils/cached_http_client_test.go @@ -1,4 +1,4 @@ -package cache +package utils import ( "fmt" @@ -12,22 +12,20 @@ import ( . "github.com/onsi/gomega" ) -var _ = Describe("HTTPClient", func() { +var _ = Describe("CachedHttpClient", func() { Context("GET", func() { - var chc *HTTPClient + var chc *CachedHTTPClient var ts *httptest.Server var requestsReceived int var header string BeforeEach(func() { - requestsReceived = 0 - header = "" ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestsReceived++ header = r.Header.Get("head") _, _ = fmt.Fprintf(w, "Hello, %s", r.URL.Query()["name"]) })) - chc = NewHTTPClient(http.DefaultClient, consts.DefaultHttpClientTimeOut) + chc = NewCachedHTTPClient(http.DefaultClient, consts.DefaultHttpClientTimeOut) }) AfterEach(func() { @@ -75,7 +73,7 @@ var _ = Describe("HTTPClient", func() { It("expires responses after TTL", func() { requestsReceived = 0 - chc = NewHTTPClient(http.DefaultClient, 10*time.Millisecond) + chc = NewCachedHTTPClient(http.DefaultClient, 10*time.Millisecond) r, _ := http.NewRequest("GET", ts.URL+"?name=doe", nil) _, err := chc.Do(r) diff --git a/utils/chrono/meter.go b/utils/chrono/meter.go deleted file mode 100644 index 2a249f455..000000000 --- a/utils/chrono/meter.go +++ /dev/null @@ -1,32 +0,0 @@ -package chrono - -import ( - "time" -) - -// Meter is a simple stopwatch -type Meter struct { - elapsed time.Duration - mark *time.Time -} - -func (m *Meter) Start() { - m.mark = new(time.Now()) -} - -func (m *Meter) Stop() time.Duration { - if m.mark == nil { - return m.elapsed - } - m.elapsed += time.Since(*m.mark) - m.mark = nil - return m.elapsed -} - -func (m *Meter) Elapsed() time.Duration { - elapsed := m.elapsed - if m.mark != nil { - elapsed += time.Since(*m.mark) - } - return elapsed -} diff --git a/utils/chrono/meter_test.go b/utils/chrono/meter_test.go deleted file mode 100644 index 8e18d37ad..000000000 --- a/utils/chrono/meter_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package chrono_test - -import ( - "testing" - "time" - - "github.com/navidrome/navidrome/tests" - . "github.com/navidrome/navidrome/utils/chrono" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestChrono(t *testing.T) { - tests.Init(t, false) - RegisterFailHandler(Fail) - RunSpecs(t, "Chrono Suite") -} - -// Note: These tests use longer sleep durations and generous tolerances to avoid flakiness -// due to system scheduling delays. For a more elegant approach in the future, consider -// using Go 1.24's experimental testing/synctest package with GOEXPERIMENT=synctest. - -var _ = Describe("Meter", func() { - var meter *Meter - - BeforeEach(func() { - meter = &Meter{} - }) - - Describe("Stop", func() { - It("should return the elapsed time", func() { - meter.Start() - time.Sleep(50 * time.Millisecond) - elapsed := meter.Stop() - // Use generous tolerance to account for system scheduling delays - Expect(elapsed).To(BeNumerically(">=", 30*time.Millisecond)) - Expect(elapsed).To(BeNumerically("<=", 200*time.Millisecond)) - }) - - It("should accumulate elapsed time on multiple starts and stops", func() { - // First cycle - meter.Start() - time.Sleep(50 * time.Millisecond) - firstElapsed := meter.Stop() - - // Second cycle - meter.Start() - time.Sleep(50 * time.Millisecond) - totalElapsed := meter.Stop() - - // Test that time accumulates (second measurement should be greater than first) - Expect(totalElapsed).To(BeNumerically(">", firstElapsed)) - - // Test that accumulated time is reasonable (should be roughly double the first) - Expect(totalElapsed).To(BeNumerically(">=", time.Duration(float64(firstElapsed)*1.5))) - Expect(totalElapsed).To(BeNumerically("<=", firstElapsed*3)) - - // Sanity check: total should be at least 60ms (allowing for some timing variance) - Expect(totalElapsed).To(BeNumerically(">=", 60*time.Millisecond)) - }) - }) - - Describe("Elapsed", func() { - It("should return the total elapsed time", func() { - meter.Start() - time.Sleep(50 * time.Millisecond) - meter.Stop() - - // Should not count the time the meter was stopped - time.Sleep(50 * time.Millisecond) - - meter.Start() - time.Sleep(50 * time.Millisecond) - meter.Stop() - - elapsed := meter.Elapsed() - // Should be roughly 100ms (2 x 50ms), but allow for significant variance - Expect(elapsed).To(BeNumerically(">=", 60*time.Millisecond)) - Expect(elapsed).To(BeNumerically("<=", 300*time.Millisecond)) - }) - - It("should include the current running time if started", func() { - meter.Start() - time.Sleep(50 * time.Millisecond) - elapsed := meter.Elapsed() - // Use generous tolerance to account for system scheduling delays - Expect(elapsed).To(BeNumerically(">=", 30*time.Millisecond)) - Expect(elapsed).To(BeNumerically("<=", 200*time.Millisecond)) - }) - }) -}) diff --git a/utils/diodes/diodes.go b/utils/diodes/diodes.go new file mode 100644 index 000000000..64e2e436f --- /dev/null +++ b/utils/diodes/diodes.go @@ -0,0 +1,38 @@ +package diodes + +import ( + "context" + + "code.cloudfoundry.org/go-diodes" +) + +type Diode[T any] struct { + d *diodes.Waiter +} + +type Alerter = diodes.Alerter + +type AlertFunc = diodes.AlertFunc + +func New[T any](ctx context.Context, size int, alerter Alerter) *Diode[T] { + return &Diode[T]{ + d: diodes.NewWaiter(diodes.NewOneToOne(size, alerter), diodes.WithWaiterContext(ctx)), + } +} + +func (d *Diode[T]) Put(data T) { + d.d.Set(diodes.GenericDataType(&data)) +} + +func (d *Diode[T]) TryNext() (*T, bool) { + data, ok := d.d.TryNext() + if !ok { + return nil, ok + } + return (*T)(data), true +} + +func (d *Diode[T]) Next() *T { + data := d.d.Next() + return (*T)(data) +} diff --git a/utils/diodes/diodes_test.go b/utils/diodes/diodes_test.go new file mode 100644 index 000000000..fda9746e2 --- /dev/null +++ b/utils/diodes/diodes_test.go @@ -0,0 +1,62 @@ +package diodes_test + +import ( + "context" + "testing" + + "github.com/navidrome/navidrome/tests" + . "github.com/navidrome/navidrome/utils/diodes" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDiodes(t *testing.T) { + tests.Init(t, false) + RegisterFailHandler(Fail) + RunSpecs(t, "Diodes Suite") +} + +var _ = Describe("Diode", func() { + type message struct { + data string + } + var diode *Diode[message] + var ctx context.Context + var ctxCancel context.CancelFunc + var missed int + + BeforeEach(func() { + missed = 0 + ctx, ctxCancel = context.WithCancel(context.Background()) + diode = New[message](ctx, 2, AlertFunc(func(m int) { missed = m })) + }) + + It("enqueues the data correctly", func() { + diode.Put(message{data: "1"}) + diode.Put(message{data: "2"}) + Expect(diode.Next()).To(Equal(&message{data: "1"})) + Expect(diode.Next()).To(Equal(&message{data: "2"})) + Expect(missed).To(BeZero()) + }) + + It("drops messages when Diode is full", func() { + diode.Put(message{data: "1"}) + diode.Put(message{data: "2"}) + diode.Put(message{data: "3"}) + next, ok := diode.TryNext() + Expect(ok).To(BeTrue()) + Expect(next).To(Equal(&message{data: "3"})) + + _, ok = diode.TryNext() + Expect(ok).To(BeFalse()) + + Expect(missed).To(Equal(2)) + }) + + It("returns nil when Diode is empty and the context is canceled", func() { + diode.Put(message{data: "1"}) + ctxCancel() + Expect(diode.Next()).To(Equal(&message{data: "1"})) + Expect(diode.Next()).To(BeNil()) + }) +}) diff --git a/utils/encrypt.go b/utils/encrypt.go index d2d228c74..e3185047e 100644 --- a/utils/encrypt.go +++ b/utils/encrypt.go @@ -6,7 +6,6 @@ import ( "crypto/cipher" "crypto/rand" "encoding/base64" - "errors" "io" "github.com/navidrome/navidrome/log" @@ -37,14 +36,7 @@ func Encrypt(ctx context.Context, encKey []byte, data string) (string, error) { return base64.StdEncoding.EncodeToString(ciphertext), nil } -func Decrypt(ctx context.Context, encKey []byte, encData string) (value string, err error) { - // Recover from any panics - defer func() { - if r := recover(); r != nil { - err = errors.New("decryption panicked") - } - }() - +func Decrypt(ctx context.Context, encKey []byte, encData string) (string, error) { enc, _ := base64.StdEncoding.DecodeString(encData) block, err := aes.NewCipher(encKey) diff --git a/utils/files.go b/utils/files.go deleted file mode 100644 index 2fce307ea..000000000 --- a/utils/files.go +++ /dev/null @@ -1,42 +0,0 @@ -package utils - -import ( - "os" - "path" - "path/filepath" - "regexp" - "strings" - - "github.com/navidrome/navidrome/model/id" -) - -var cleanFileNameRe = regexp.MustCompile(`[^a-z0-9_-]`) - -func TempFileName(prefix, suffix string) string { - return filepath.Join(os.TempDir(), prefix+id.NewRandom()+suffix) -} - -func BaseName(filePath string) string { - p := path.Base(filePath) - return strings.TrimSuffix(p, path.Ext(p)) -} - -// CleanFileName produces a filesystem-safe, human-readable version of a name. -// It lowercases, replaces spaces with underscores, strips non-alphanumeric -// characters (except underscore and hyphen), and truncates to 50 characters. -func CleanFileName(name string) string { - s := strings.ToLower(strings.TrimSpace(name)) - s = strings.ReplaceAll(s, " ", "_") - s = cleanFileNameRe.ReplaceAllString(s, "") - if len(s) > 50 { - s = s[:50] - } - s = strings.TrimRight(s, "_-") - return s -} - -// FileExists checks if a file or directory exists -func FileExists(path string) bool { - _, err := os.Stat(path) - return err == nil || !os.IsNotExist(err) -} diff --git a/utils/files_test.go b/utils/files_test.go deleted file mode 100644 index c6e578f05..000000000 --- a/utils/files_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package utils_test - -import ( - "os" - "path/filepath" - "strings" - - "github.com/navidrome/navidrome/utils" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("TempFileName", func() { - It("creates a temporary file name with prefix and suffix", func() { - prefix := "test-" - suffix := ".tmp" - result := utils.TempFileName(prefix, suffix) - - Expect(result).To(ContainSubstring(prefix)) - Expect(result).To(HaveSuffix(suffix)) - Expect(result).To(ContainSubstring(os.TempDir())) - }) - - It("creates unique file names on multiple calls", func() { - prefix := "unique-" - suffix := ".test" - - result1 := utils.TempFileName(prefix, suffix) - result2 := utils.TempFileName(prefix, suffix) - - Expect(result1).NotTo(Equal(result2)) - }) - - It("handles empty prefix and suffix", func() { - result := utils.TempFileName("", "") - - Expect(result).To(ContainSubstring(os.TempDir())) - Expect(len(result)).To(BeNumerically(">", len(os.TempDir()))) - }) - - It("creates proper file path separators", func() { - prefix := "path-test-" - suffix := ".ext" - result := utils.TempFileName(prefix, suffix) - - expectedDir := os.TempDir() - Expect(result).To(HavePrefix(expectedDir)) - Expect(strings.Count(result, string(filepath.Separator))).To(BeNumerically(">=", strings.Count(expectedDir, string(filepath.Separator)))) - }) -}) - -var _ = Describe("BaseName", func() { - It("extracts basename from a simple filename", func() { - result := utils.BaseName("test.mp3") - Expect(result).To(Equal("test")) - }) - - It("extracts basename from a file path", func() { - result := utils.BaseName("/path/to/file.txt") - Expect(result).To(Equal("file")) - }) - - It("handles files without extension", func() { - result := utils.BaseName("/path/to/filename") - Expect(result).To(Equal("filename")) - }) - - It("handles files with multiple dots", func() { - result := utils.BaseName("archive.tar.gz") - Expect(result).To(Equal("archive.tar")) - }) - - It("handles hidden files", func() { - // For hidden files without additional extension, path.Ext returns the entire name - // So basename becomes empty string after TrimSuffix - result := utils.BaseName(".hidden") - Expect(result).To(Equal("")) - }) - - It("handles hidden files with extension", func() { - result := utils.BaseName(".config.json") - Expect(result).To(Equal(".config")) - }) - - It("handles empty string", func() { - // The actual behavior returns empty string for empty input - result := utils.BaseName("") - Expect(result).To(Equal("")) - }) - - It("handles path ending with separator", func() { - result := utils.BaseName("/path/to/dir/") - Expect(result).To(Equal("dir")) - }) - - It("handles complex nested path", func() { - result := utils.BaseName("/very/long/path/to/my/favorite/song.mp3") - Expect(result).To(Equal("song")) - }) -}) - -var _ = Describe("CleanFileName", func() { - It("lowercases and replaces spaces with underscores", func() { - Expect(utils.CleanFileName("My Cool Playlist")).To(Equal("my_cool_playlist")) - }) - - It("strips special characters", func() { - Expect(utils.CleanFileName("Rock & Roll! (2024)")).To(Equal("rock__roll_2024")) - }) - - It("handles unicode characters", func() { - Expect(utils.CleanFileName("Música Favorita")).To(Equal("msica_favorita")) - }) - - It("preserves hyphens", func() { - Expect(utils.CleanFileName("lo-fi beats")).To(Equal("lo-fi_beats")) - }) - - It("returns empty string for empty input", func() { - Expect(utils.CleanFileName("")).To(BeEmpty()) - }) - - It("returns empty string for whitespace-only input", func() { - Expect(utils.CleanFileName(" ")).To(BeEmpty()) - }) - - It("returns empty string when all characters are stripped", func() { - Expect(utils.CleanFileName("!!!@@@###")).To(BeEmpty()) - }) - - It("truncates to 50 characters", func() { - long := strings.Repeat("abcdefghij", 10) // 100 chars - result := utils.CleanFileName(long) - Expect(len(result)).To(Equal(50)) - }) - - It("trims trailing underscores and hyphens after truncation", func() { - // 49 a's + space + "b" = after clean: 49 a's + "_b" = 51 chars, truncated to 50 = 49 a's + "_" - name := strings.Repeat("a", 49) + " b" - result := utils.CleanFileName(name) - Expect(result).To(Equal(strings.Repeat("a", 49))) - }) -}) - -var _ = Describe("FileExists", func() { - var tempFile *os.File - var tempDir string - - BeforeEach(func() { - var err error - tempFile, err = os.CreateTemp("", "fileexists-test-*.txt") - Expect(err).NotTo(HaveOccurred()) - - tempDir, err = os.MkdirTemp("", "fileexists-test-dir-*") - Expect(err).NotTo(HaveOccurred()) - }) - - AfterEach(func() { - if tempFile != nil { - os.Remove(tempFile.Name()) - tempFile.Close() - } - if tempDir != "" { - os.RemoveAll(tempDir) - } - }) - - It("returns true for existing file", func() { - Expect(utils.FileExists(tempFile.Name())).To(BeTrue()) - }) - - It("returns true for existing directory", func() { - Expect(utils.FileExists(tempDir)).To(BeTrue()) - }) - - It("returns false for non-existing file", func() { - nonExistentPath := filepath.Join(tempDir, "does-not-exist.txt") - Expect(utils.FileExists(nonExistentPath)).To(BeFalse()) - }) - - It("returns false for empty path", func() { - Expect(utils.FileExists("")).To(BeFalse()) - }) - - It("handles nested non-existing path", func() { - nonExistentPath := "/this/path/definitely/does/not/exist/file.txt" - Expect(utils.FileExists(nonExistentPath)).To(BeFalse()) - }) - - Context("when file is deleted after creation", func() { - It("returns false after file deletion", 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 - - Expect(utils.FileExists(filePath)).To(BeFalse()) - }) - }) - - Context("when directory is deleted after creation", func() { - It("returns false after directory deletion", func() { - dirPath := tempDir - Expect(utils.FileExists(dirPath)).To(BeTrue()) - - err := os.RemoveAll(dirPath) - Expect(err).NotTo(HaveOccurred()) - tempDir = "" // Prevent cleanup attempt - - Expect(utils.FileExists(dirPath)).To(BeFalse()) - }) - }) - - It("handles permission denied scenarios gracefully", func() { - // This test might be platform specific, but we test the general case - result := utils.FileExists("/root/.ssh/id_rsa") // Likely to not exist or be inaccessible - Expect(result).To(Or(BeTrue(), BeFalse())) // Should not panic - }) -}) diff --git a/utils/gg/gg.go b/utils/gg/gg.go index 837f56339..95b2aa1e3 100644 --- a/utils/gg/gg.go +++ b/utils/gg/gg.go @@ -1,28 +1,49 @@ // Package gg implements simple "extensions" to Go language. Based on https://github.com/icza/gog package gg -// V returns the value of the input pointer, or a zero value if the input pointer is nil. -func V[T any](p *T) T { +// If returns v if it is a non-zero value, orElse otherwise. +// +// This is similar to elvis operator (?:) in Groovy and other languages. +// Note: Different from the real elvis operator, the orElse expression will always get evaluated. +func If[T comparable](v T, orElse T) T { + var zero T + if v != zero { + return v + } + return orElse +} + +// P returns a pointer to a value, or nil if the value is the zero value of the type. +func P[T comparable](t T) *T { + var zero T + if t == zero { + return nil + } + return &t +} + +// V returns the value of a pointer, or the zero value of the type if the pointer is nil. +func V[T comparable](p *T) T { + var zero T if p == nil { - var zero T return zero } return *p } -func If[T any](cond bool, v1, v2 T) T { - if cond { - return v1 +// Coalesce returns the first non-zero value from listed arguments. +// Returns the zero value of the type parameter if no arguments are given or all are the zero value. +// Useful when you want to initialize a variable to the first non-zero value from a list of fallback values. +// +// For example: +// +// hostVal := Coalesce(hostName, os.Getenv("HOST"), "localhost") +func Coalesce[T comparable](values ...T) (v T) { + var zero T + for _, v = range values { + if v != zero { + return + } } - return v2 -} - -// Clone returns a pointer to a fresh copy of *p, or nil if p is nil. Use it to -// avoid aliasing the pointed-to value when a separate *T is needed. -func Clone[T any](p *T) *T { - if p == nil { - return nil - } - v := *p - return &v + return } diff --git a/utils/gg/gg_test.go b/utils/gg/gg_test.go index bb6fae867..c82079b69 100644 --- a/utils/gg/gg_test.go +++ b/utils/gg/gg_test.go @@ -16,55 +16,82 @@ func TestGG(t *testing.T) { } var _ = Describe("GG", func() { - Describe("V", func() { - It("returns the value of the input pointer", func() { - Expect(gg.V(new(123))).To(Equal(123)) - }) - - It("returns a zero value if the input pointer is nil", func() { - var v *int - Expect(gg.V(v)).To(Equal(0)) - }) - }) - Describe("If", func() { - It("returns the first value if the condition is true", func() { - Expect(gg.If(true, 1, 2)).To(Equal(1)) - }) + DescribeTable("string", + func(v, orElse, expected string) { + Expect(gg.If(v, orElse)).To(Equal(expected)) + }, + Entry("zero value", "", "default", "default"), + Entry("non-zero value", "anything", "default", "anything"), + ) + DescribeTable("numeric", + func(v, orElse, expected int) { + Expect(gg.If(v, orElse)).To(Equal(expected)) + }, + Entry("zero value", 0, 2, 2), + Entry("non-zero value", -1, 2, -1), + ) + type testStruct struct { + field1 int + } + DescribeTable("struct", + func(v, orElse, expected testStruct) { + Expect(gg.If(v, orElse)).To(Equal(expected)) + }, + Entry("zero value", testStruct{}, testStruct{123}, testStruct{123}), + Entry("non-zero value", testStruct{456}, testStruct{123}, testStruct{456}), + ) + }) - It("returns the second value if the condition is false", func() { - Expect(gg.If(false, 1, 2)).To(Equal(2)) - }) + Describe("Coalesce", func() { + Context("when given a list of strings", func() { + It("returns the first non-empty value", func() { + Expect(gg.Coalesce("foo", "bar", "baz", "default")).To(Equal("foo")) + Expect(gg.Coalesce("", "", "qux", "default")).To(Equal("qux")) + }) - It("works with string values", func() { - Expect(gg.If(true, "a", "b")).To(Equal("a")) - Expect(gg.If(false, "a", "b")).To(Equal("b")) - }) - - It("works with different types", func() { - Expect(gg.If(true, 1.1, 2.2)).To(Equal(1.1)) - Expect(gg.If(false, 1.1, 2.2)).To(Equal(2.2)) + It("returns the default value if all values are empty", func() { + Expect(gg.Coalesce("", "", "", "default")).To(Equal("default")) + Expect(gg.Coalesce("", "", "", "")).To(Equal("")) + }) }) }) - Describe("Clone", func() { - It("returns a pointer to a copy of the value", func() { - original := 123 - cloned := gg.Clone(&original) - Expect(cloned).To(HaveValue(Equal(123))) - Expect(cloned).NotTo(BeIdenticalTo(&original)) + Describe("P", func() { + Context("when given a non-zero value", func() { + It("should return a non-nil pointer to that value", func() { + value := 42 + result := gg.P(value) + Expect(result).ToNot(BeNil()) + Expect(*result).To(Equal(value)) + }) }) - It("does not alias the original value", func() { - original := 123 - cloned := gg.Clone(&original) - original = 456 - Expect(*cloned).To(Equal(123)) + Context("when given the zero value of a type", func() { + It("should return nil", func() { + var value string + result := gg.P(value) + Expect(result).To(BeNil()) + }) + }) + }) + + Describe("V", func() { + Context("when given a non-nil pointer", func() { + It("should return the value of that pointer", func() { + value := 42 + pointer := &value + result := gg.V(pointer) + Expect(result).To(Equal(value)) + }) }) - It("returns nil when the input is nil", func() { - var v *int - Expect(gg.Clone(v)).To(BeNil()) + Context("when given a nil pointer", func() { + It("should return the zero value of the type", func() { + var pointer *string + result := gg.V(pointer) + Expect(result).To(Equal("")) + }) }) }) }) diff --git a/utils/gravatar/gravatar.go b/utils/gravatar/gravatar.go index 1ab2464c6..ea18212b9 100644 --- a/utils/gravatar/gravatar.go +++ b/utils/gravatar/gravatar.go @@ -1,9 +1,11 @@ package gravatar import ( - "crypto/sha256" + "crypto/md5" "fmt" "strings" + + "github.com/navidrome/navidrome/utils/number" ) const baseUrl = "https://www.gravatar.com/avatar" @@ -13,11 +15,11 @@ const maxSize = 2048 func Url(email string, size int) string { email = strings.ToLower(email) email = strings.TrimSpace(email) - hash := sha256.Sum256([]byte(email)) + hash := md5.Sum([]byte(email)) if size < 1 { size = defaultSize } - size = min(maxSize, size) + size = number.Min(maxSize, size) return fmt.Sprintf("%s/%x?s=%d", baseUrl, hash, size) } diff --git a/utils/gravatar/gravatar_test.go b/utils/gravatar/gravatar_test.go index b8298910b..a66c25af9 100644 --- a/utils/gravatar/gravatar_test.go +++ b/utils/gravatar/gravatar_test.go @@ -3,6 +3,7 @@ package gravatar_test import ( "testing" + "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils/gravatar" . "github.com/onsi/ginkgo/v2" @@ -11,19 +12,20 @@ import ( func TestGravatar(t *testing.T) { tests.Init(t, false) + log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) RunSpecs(t, "Gravatar Test Suite") } var _ = Describe("Gravatar", func() { It("returns a well formatted gravatar URL", func() { - Expect(gravatar.Url("my@email.com", 100)).To(Equal("https://www.gravatar.com/avatar/cf3d8259741b19a2b09e17d4fa9a97c63adc44bf2a5fa075cdcb5491f525feaa?s=100")) + Expect(gravatar.Url("my@email.com", 100)).To(Equal("https://www.gravatar.com/avatar/4f384e9f3e8e625aae72b52658323d70?s=100")) }) It("sets the default size", func() { - Expect(gravatar.Url("my@email.com", 0)).To(Equal("https://www.gravatar.com/avatar/cf3d8259741b19a2b09e17d4fa9a97c63adc44bf2a5fa075cdcb5491f525feaa?s=80")) + Expect(gravatar.Url("my@email.com", 0)).To(Equal("https://www.gravatar.com/avatar/4f384e9f3e8e625aae72b52658323d70?s=80")) }) It("caps maximum size", func() { - Expect(gravatar.Url("my@email.com", 3000)).To(Equal("https://www.gravatar.com/avatar/cf3d8259741b19a2b09e17d4fa9a97c63adc44bf2a5fa075cdcb5491f525feaa?s=2048")) + Expect(gravatar.Url("my@email.com", 3000)).To(Equal("https://www.gravatar.com/avatar/4f384e9f3e8e625aae72b52658323d70?s=2048")) }) It("ignores case", func() { Expect(gravatar.Url("MY@email.com", 0)).To(Equal(gravatar.Url("my@email.com", 0))) diff --git a/utils/hasher/hasher.go b/utils/hasher/hasher.go deleted file mode 100644 index 279cbe444..000000000 --- a/utils/hasher/hasher.go +++ /dev/null @@ -1,74 +0,0 @@ -package hasher - -import ( - "hash/maphash" - "strconv" - "sync" - - "github.com/navidrome/navidrome/utils/random" -) - -var instance = NewHasher() - -func Reseed(id string) { - instance.Reseed(id) -} - -func SetSeed(id string, seed string) { - instance.SetSeed(id, seed) -} - -func CurrentSeed(id string) string { - instance.mutex.RLock() - defer instance.mutex.RUnlock() - return instance.seeds[id] -} - -func HashFunc() func(id, str string) uint64 { - return instance.HashFunc() -} - -type Hasher struct { - seeds map[string]string - mutex sync.RWMutex - hashSeed maphash.Seed -} - -func NewHasher() *Hasher { - h := new(Hasher) - h.seeds = make(map[string]string) - h.hashSeed = maphash.MakeSeed() - return h -} - -// SetSeed sets a seed for the given id -func (h *Hasher) SetSeed(id string, seed string) { - h.mutex.Lock() - defer h.mutex.Unlock() - h.seeds[id] = seed -} - -// Reseed generates a new random seed for the given id -func (h *Hasher) Reseed(id string) { - _ = h.reseed(id) -} - -func (h *Hasher) reseed(id string) string { - seed := strconv.FormatUint(random.Uint64(), 36) - h.SetSeed(id, seed) - return seed -} - -// HashFunc returns a function that hashes a string using the seed for the given id -func (h *Hasher) HashFunc() func(id, str string) uint64 { - return func(id, str string) uint64 { - h.mutex.RLock() - seed, ok := h.seeds[id] - h.mutex.RUnlock() - if !ok { - seed = h.reseed(id) - } - - return maphash.Bytes(h.hashSeed, []byte(seed+str)) - } -} diff --git a/utils/hasher/hasher_test.go b/utils/hasher/hasher_test.go deleted file mode 100644 index 7ecfe6980..000000000 --- a/utils/hasher/hasher_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package hasher_test - -import ( - "strconv" - "testing" - - "github.com/navidrome/navidrome/utils/hasher" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestHasher(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Hasher Suite") -} - -var _ = Describe("HashFunc", func() { - const input = "123e4567e89b12d3a456426614174000" - - It("hashes the input and returns the sum", func() { - hashFunc := hasher.HashFunc() - sum := hashFunc("1", input) - Expect(sum > 0).To(BeTrue()) - }) - - It("hashes the input, reseeds and returns a different sum", func() { - hashFunc := hasher.HashFunc() - sum := hashFunc("1", input) - hasher.Reseed("1") - sum2 := hashFunc("1", input) - Expect(sum).NotTo(Equal(sum2)) - }) - - It("keeps different hashes for different ids", func() { - hashFunc := hasher.HashFunc() - sum := hashFunc("1", input) - sum2 := hashFunc("2", input) - - Expect(sum).NotTo(Equal(sum2)) - - Expect(sum).To(Equal(hashFunc("1", input))) - Expect(sum2).To(Equal(hashFunc("2", input))) - }) - - It("keeps the same hash for the same id and seed", func() { - id := "1" - hashFunc := hasher.HashFunc() - hasher.SetSeed(id, "original_seed") - sum := hashFunc(id, input) - Expect(sum).To(Equal(hashFunc(id, input))) - - hasher.Reseed(id) - Expect(sum).NotTo(Equal(hashFunc(id, input))) - - hasher.SetSeed(id, "original_seed") - Expect(sum).To(Equal(hashFunc(id, input))) - }) - - It("does not cause race conditions", func() { - for i := range 1000 { - go func() { - hashFunc := hasher.HashFunc() - sum := hashFunc(strconv.Itoa(i), input) - Expect(sum).ToNot(BeZero()) - }() - } - }) -}) diff --git a/utils/httpclient/httpclient.go b/utils/httpclient/httpclient.go deleted file mode 100644 index b0e7b5681..000000000 --- a/utils/httpclient/httpclient.go +++ /dev/null @@ -1,84 +0,0 @@ -// Package httpclient provides a shared http.Client factory that identifies -// Navidrome via the User-Agent header on all outgoing requests. -package httpclient - -import ( - "context" - "net" - "net/http" - "net/netip" - "net/url" - "time" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/utils/netguard" -) - -type uaTransport struct { - base http.RoundTripper -} - -func (t *uaTransport) RoundTrip(req *http.Request) (*http.Response, error) { - if _, ok := req.Header["User-Agent"]; !ok { - req = req.Clone(req.Context()) - req.Header.Set("User-Agent", consts.HTTPUserAgent) - } - return t.base.RoundTrip(req) -} - -// NewTransport wraps base (or http.DefaultTransport if nil) to set the -// Navidrome User-Agent on requests that don't have one. -func NewTransport(base http.RoundTripper) http.RoundTripper { - if base == nil { - base = http.DefaultTransport - } - return &uaTransport{base: base} -} - -func New(timeout time.Duration) *http.Client { - return &http.Client{Timeout: timeout, Transport: NewTransport(nil)} -} - -// proxyFunc resolves the proxy for a request; tests replace it. -var proxyFunc = http.ProxyFromEnvironment - -type proxyAddrKey struct{} - -// NewExternal is New for URLs from untrusted sources: it refuses to dial private, loopback, -// link-local and unspecified addresses, except those covered by allowed. -func NewExternal(timeout time.Duration, allowed ...netip.Prefix) *http.Client { - t := http.DefaultTransport.(*http.Transport).Clone() - t.Proxy = proxyFunc - direct := net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second} - guarded := direct - guarded.Control = netguard.DialControl(allowed...) - t.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { - // Exempt the hop to the proxy, which relays the request and is operator config. A URL - // aimed at the proxy's own address is not proxied, so it stays guarded. - if proxy, ok := ctx.Value(proxyAddrKey{}).(string); ok && proxy == addr { - return direct.DialContext(ctx, network, addr) - } - return guarded.DialContext(ctx, network, addr) - } - return &http.Client{Timeout: timeout, Transport: NewTransport(&proxyTagger{base: t})} -} - -// proxyTagger records the proxy each request resolves to, so the dialer can tell a hop to the -// proxy from a dial to the URL's own host. -type proxyTagger struct{ base http.RoundTripper } - -func (p *proxyTagger) RoundTrip(req *http.Request) (*http.Response, error) { - if u, err := proxyFunc(req); err == nil && u != nil { - req = req.WithContext(context.WithValue(req.Context(), proxyAddrKey{}, proxyAddr(u))) - } - return p.base.RoundTrip(req) -} - -// proxyAddr mirrors how net/http addresses a proxy connection. -func proxyAddr(u *url.URL) string { - port := u.Port() - if port == "" { - port = map[string]string{"http": "80", "https": "443", "socks5": "1080", "socks5h": "1080"}[u.Scheme] - } - return net.JoinHostPort(u.Hostname(), port) -} diff --git a/utils/httpclient/httpclient_proxy_internal_test.go b/utils/httpclient/httpclient_proxy_internal_test.go deleted file mode 100644 index 2d542d8fe..000000000 --- a/utils/httpclient/httpclient_proxy_internal_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package httpclient - -import ( - "net/http" - "net/http/httptest" - "net/url" - "sync/atomic" - "time" - - "github.com/navidrome/navidrome/utils/netguard" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("NewExternal with a proxy", func() { - var proxy *httptest.Server - var proxied atomic.Int32 - var proxyURL *url.URL - - BeforeEach(func() { - proxied.Store(0) - proxy = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - proxied.Add(1) - _, _ = w.Write([]byte("via proxy")) - })) - DeferCleanup(proxy.Close) - proxyURL, _ = url.Parse(proxy.URL) - prev := proxyFunc - DeferCleanup(func() { proxyFunc = prev }) - }) - - It("dials the configured proxy even though it listens on a private address", func() { - proxyFunc = func(*http.Request) (*url.URL, error) { return proxyURL, nil } - - resp, err := NewExternal(time.Second).Get("http://navidrome.example.com/cover.jpg") - Expect(err).ToNot(HaveOccurred()) - defer resp.Body.Close() - Expect(proxied.Load()).To(Equal(int32(1))) - }) - - // net/http never proxies loopback targets, so a URL aimed at a loopback proxy is dialed directly. - It("refuses a direct dial to the proxy's own address when the request is not proxied", func() { - proxyFunc = func(r *http.Request) (*url.URL, error) { - if r.URL.Hostname() == "127.0.0.1" { - return nil, nil - } - return proxyURL, nil - } - - _, err := NewExternal(time.Second).Get(proxy.URL + "/secret") - Expect(err).To(MatchError(netguard.ErrPrivateAddress)) - Expect(proxied.Load()).To(BeZero()) - }) - - It("still refuses a direct dial to a private address", func() { - proxyFunc = func(r *http.Request) (*url.URL, error) { - if r.URL.Scheme == "https" { - return proxyURL, nil - } - return nil, nil - } - target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - DeferCleanup(target.Close) - - _, err := NewExternal(time.Second).Get(target.URL) - Expect(err).To(MatchError(netguard.ErrPrivateAddress)) - }) -}) diff --git a/utils/httpclient/httpclient_suite_test.go b/utils/httpclient/httpclient_suite_test.go deleted file mode 100644 index e18a9d0ad..000000000 --- a/utils/httpclient/httpclient_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package httpclient_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestHTTPClient(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "HTTPClient Suite") -} diff --git a/utils/httpclient/httpclient_test.go b/utils/httpclient/httpclient_test.go deleted file mode 100644 index 7b1e58797..000000000 --- a/utils/httpclient/httpclient_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package httpclient_test - -import ( - "net/http" - "net/http/httptest" - "net/netip" - "time" - - "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/utils/httpclient" - "github.com/navidrome/navidrome/utils/netguard" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("httpclient", func() { - var server *httptest.Server - var receivedUA string - - BeforeEach(func() { - receivedUA = "" - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - receivedUA = r.Header.Get("User-Agent") - })) - DeferCleanup(server.Close) - }) - - Describe("New", func() { - It("sets the Navidrome User-Agent when the request has none", func() { - c := httpclient.New(time.Second) - resp, err := c.Get(server.URL) - Expect(err).ToNot(HaveOccurred()) - resp.Body.Close() - Expect(receivedUA).To(Equal(consts.HTTPUserAgent)) - }) - - It("keeps a User-Agent already set by the caller", func() { - c := httpclient.New(time.Second) - req, err := http.NewRequest(http.MethodGet, server.URL, nil) - Expect(err).ToNot(HaveOccurred()) - req.Header.Set("User-Agent", "CustomAgent/1.0") - resp, err := c.Do(req) - Expect(err).ToNot(HaveOccurred()) - resp.Body.Close() - Expect(receivedUA).To(Equal("CustomAgent/1.0")) - }) - - It("applies the given timeout", func() { - c := httpclient.New(5 * time.Second) - Expect(c.Timeout).To(Equal(5 * time.Second)) - }) - }) - - Describe("NewExternal", func() { - It("refuses to connect to a loopback server", func() { - c := httpclient.NewExternal(time.Second) - _, err := c.Get(server.URL) - Expect(err).To(MatchError(netguard.ErrPrivateAddress)) - Expect(receivedUA).To(BeEmpty()) - }) - - It("refuses a redirect from an allowed host to a private address", func() { - redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound) - })) - DeferCleanup(redirector.Close) - - c := httpclient.NewExternal(time.Second, netip.MustParsePrefix("127.0.0.1/32")) - _, err := c.Get(redirector.URL) - Expect(err).To(MatchError(netguard.ErrPrivateAddress)) - }) - - It("connects to addresses covered by an allowed prefix and sets the User-Agent", func() { - c := httpclient.NewExternal(time.Second, netip.MustParsePrefix("127.0.0.0/8")) - resp, err := c.Get(server.URL) - Expect(err).ToNot(HaveOccurred()) - resp.Body.Close() - Expect(receivedUA).To(Equal(consts.HTTPUserAgent)) - }) - }) - - Describe("NewTransport", func() { - It("uses the default transport when base is nil", func() { - c := &http.Client{Transport: httpclient.NewTransport(nil)} - resp, err := c.Get(server.URL) - Expect(err).ToNot(HaveOccurred()) - resp.Body.Close() - Expect(receivedUA).To(Equal(consts.HTTPUserAgent)) - }) - - It("does not modify the original request", func() { - c := &http.Client{Transport: httpclient.NewTransport(nil)} - req, err := http.NewRequest(http.MethodGet, server.URL, nil) - Expect(err).ToNot(HaveOccurred()) - resp, err := c.Do(req) - Expect(err).ToNot(HaveOccurred()) - resp.Body.Close() - Expect(req.Header).ToNot(HaveKey("User-Agent")) - }) - }) - - Describe("HTTPUserAgent", func() { - It("identifies Navidrome with version and project URL", func() { - Expect(consts.HTTPUserAgent).To(Equal("Navidrome/" + consts.Version + " - https://github.com/navidrome")) - }) - }) -}) diff --git a/utils/index_group_parser.go b/utils/index_group_parser.go index 786f5c457..76a407646 100644 --- a/utils/index_group_parser.go +++ b/utils/index_group_parser.go @@ -18,17 +18,18 @@ type IndexGroups map[string]string // for grouping accented characters (such as A, \u00C0 and \u00C1) // // Files and folders that are not covered by an index entry will be placed under the index entry "#". - -var indexGroupsRx = regexp.MustCompile(`(.+)\((.+)\)`) - func ParseIndexGroups(spec string) IndexGroups { parsed := make(IndexGroups) - split := strings.SplitSeq(spec, " ") - for g := range split { - sub := indexGroupsRx.FindStringSubmatch(g) + split := strings.Split(spec, " ") + re := regexp.MustCompile(`(.+)\((.+)\)`) + for _, g := range split { + sub := re.FindStringSubmatch(g) if len(sub) > 0 { - for _, c := range sub[2] { - parsed[string(c)] = sub[1] + i := 0 + chars := strings.Split(sub[2], "") + for _, c := range chars { + parsed[c] = sub[1] + i++ } } else { parsed[g] = g diff --git a/utils/ioutils/ioutils.go b/utils/ioutils/ioutils.go deleted file mode 100644 index 89d3997f3..000000000 --- a/utils/ioutils/ioutils.go +++ /dev/null @@ -1,33 +0,0 @@ -package ioutils - -import ( - "io" - "os" - - "golang.org/x/text/encoding/unicode" - "golang.org/x/text/transform" -) - -// UTF8Reader wraps an io.Reader to handle Byte Order Mark (BOM) properly. -// It strips UTF-8 BOM if present, and converts UTF-16 (LE/BE) to UTF-8. -// This is particularly useful for reading user-provided text files (like LRC lyrics, -// playlists) that may have been created on Windows, which often adds BOM markers. -// -// Reference: https://en.wikipedia.org/wiki/Byte_order_mark -func UTF8Reader(r io.Reader) io.Reader { - return transform.NewReader(r, unicode.BOMOverride(unicode.UTF8.NewDecoder())) -} - -// UTF8ReadFile reads the named file and returns its contents as a byte slice, -// automatically handling BOM markers. It's similar to os.ReadFile but strips -// UTF-8 BOM and converts UTF-16 encoded files to UTF-8. -func UTF8ReadFile(filename string) ([]byte, error) { - file, err := os.Open(filename) - if err != nil { - return nil, err - } - defer file.Close() - - reader := UTF8Reader(file) - return io.ReadAll(reader) -} diff --git a/utils/ioutils/ioutils_test.go b/utils/ioutils/ioutils_test.go deleted file mode 100644 index 7f5483879..000000000 --- a/utils/ioutils/ioutils_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package ioutils - -import ( - "bytes" - "io" - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestIOUtils(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "IO Utils Suite") -} - -var _ = Describe("UTF8Reader", func() { - Context("when reading text with UTF-8 BOM", func() { - It("strips the UTF-8 BOM marker", func() { - // UTF-8 BOM is EF BB BF - input := []byte{0xEF, 0xBB, 0xBF, 'h', 'e', 'l', 'l', 'o'} - reader := UTF8Reader(bytes.NewReader(input)) - - output, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(output)).To(Equal("hello")) - }) - - It("strips UTF-8 BOM from multi-line text", func() { - // Test with the actual LRC file format - input := []byte{0xEF, 0xBB, 0xBF, '[', '0', '0', ':', '0', '0', '.', '0', '0', ']', ' ', 't', 'e', 's', 't'} - reader := UTF8Reader(bytes.NewReader(input)) - - output, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(output)).To(Equal("[00:00.00] test")) - }) - }) - - Context("when reading text without BOM", func() { - It("passes through unchanged", func() { - input := []byte("hello world") - reader := UTF8Reader(bytes.NewReader(input)) - - output, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(output)).To(Equal("hello world")) - }) - }) - - Context("when reading UTF-16 LE encoded text", func() { - It("converts to UTF-8 and strips BOM", func() { - // UTF-16 LE BOM (FF FE) followed by "hi" in UTF-16 LE - input := []byte{0xFF, 0xFE, 'h', 0x00, 'i', 0x00} - reader := UTF8Reader(bytes.NewReader(input)) - - output, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(output)).To(Equal("hi")) - }) - }) - - Context("when reading UTF-16 BE encoded text", func() { - It("converts to UTF-8 and strips BOM", func() { - // UTF-16 BE BOM (FE FF) followed by "hi" in UTF-16 BE - input := []byte{0xFE, 0xFF, 0x00, 'h', 0x00, 'i'} - reader := UTF8Reader(bytes.NewReader(input)) - - output, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(output)).To(Equal("hi")) - }) - }) - - Context("when reading empty content", func() { - It("returns empty string", func() { - reader := UTF8Reader(bytes.NewReader([]byte{})) - - output, err := io.ReadAll(reader) - Expect(err).ToNot(HaveOccurred()) - Expect(string(output)).To(Equal("")) - }) - }) -}) - -var _ = Describe("UTF8ReadFile", func() { - Context("when reading a file with UTF-8 BOM", func() { - It("strips the BOM marker", func() { - // Use the actual fixture from issue #4631 - contents, err := UTF8ReadFile("../../tests/fixtures/bom-test.lrc") - Expect(err).ToNot(HaveOccurred()) - - // Should NOT start with BOM - Expect(contents[0]).ToNot(Equal(byte(0xEF))) - // Should start with '[' - Expect(contents[0]).To(Equal(byte('['))) - Expect(string(contents)).To(HavePrefix("[00:00.00]")) - }) - }) - - Context("when reading a file without BOM", func() { - It("reads the file normally", func() { - contents, err := UTF8ReadFile("../../tests/fixtures/test.lrc") - Expect(err).ToNot(HaveOccurred()) - - // Should contain the expected content - Expect(string(contents)).To(ContainSubstring("We're no strangers to love")) - }) - }) - - Context("when reading a non-existent file", func() { - It("returns an error", func() { - _, err := UTF8ReadFile("../../tests/fixtures/nonexistent.lrc") - Expect(err).To(HaveOccurred()) - }) - }) -}) diff --git a/utils/jsoncommentstrip/jsoncommentstrip.go b/utils/jsoncommentstrip/jsoncommentstrip.go deleted file mode 100644 index 54ddd88fa..000000000 --- a/utils/jsoncommentstrip/jsoncommentstrip.go +++ /dev/null @@ -1,128 +0,0 @@ -// Package jsoncommentstrip provides an io.Reader that strips JavaScript-style -// comments (// line and /* block */) from JSON input while preserving -// comment-like sequences inside JSON string values. -package jsoncommentstrip - -import ( - "bufio" - "io" -) - -type state int - -const ( - stateNormal state = iota - stateInString - stateInStringEscape - stateMaybeComment // saw '/' - stateLineComment // inside // ... - stateBlockComment // inside /* ... */ - stateMaybeBlockEnd // saw '*' inside block comment -) - -type reader struct { - r *bufio.Reader - state state -} - -// NewReader returns an io.Reader that strips JSON comments from the -// underlying reader. It removes single-line comments (// to end of line) -// and block comments (/* ... */), while preserving comment-like sequences -// that appear inside JSON string values. -func NewReader(r io.Reader) io.Reader { - return &reader{ - r: bufio.NewReader(r), - state: stateNormal, - } -} - -func (cr *reader) Read(p []byte) (int, error) { - n := 0 - for n < len(p) { - b, err := cr.r.ReadByte() - if err != nil { - if cr.state == stateMaybeComment { - // Emit the pending '/' before returning EOF - p[n] = '/' - n++ - cr.state = stateNormal - } - return n, err - } - - switch cr.state { - case stateNormal: - switch b { - case '"': - p[n] = b - n++ - cr.state = stateInString - case '/': - cr.state = stateMaybeComment - default: - p[n] = b - n++ - } - - case stateInString: - p[n] = b - n++ - switch b { - case '\\': - cr.state = stateInStringEscape - case '"': - cr.state = stateNormal - } - - case stateInStringEscape: - p[n] = b - n++ - cr.state = stateInString - - case stateMaybeComment: - switch b { - case '/': - cr.state = stateLineComment - case '*': - cr.state = stateBlockComment - default: - // The '/' was not a comment start; emit it and the current byte - p[n] = '/' - n++ - if n < len(p) { - p[n] = b - n++ - } else { - // We need to "unread" the current byte since buffer is full - _ = cr.r.UnreadByte() - } - cr.state = stateNormal - } - - case stateLineComment: - if b == '\n' || b == '\r' { - p[n] = b - n++ - cr.state = stateNormal - } - // Otherwise, consume and discard - - case stateBlockComment: - if b == '*' { - cr.state = stateMaybeBlockEnd - } - // Otherwise, consume and discard - - case stateMaybeBlockEnd: - if b == '/' { - cr.state = stateNormal - } else if b == '*' { - // Stay in stateMaybeBlockEnd (consecutive *'s) - cr.state = stateMaybeBlockEnd - } else { - cr.state = stateBlockComment - } - } - } - return n, nil -} diff --git a/utils/jsoncommentstrip/jsoncommentstrip_test.go b/utils/jsoncommentstrip/jsoncommentstrip_test.go deleted file mode 100644 index 21e4bd0b1..000000000 --- a/utils/jsoncommentstrip/jsoncommentstrip_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package jsoncommentstrip_test - -import ( - "bytes" - "encoding/json" - "io" - "strings" - "testing" - - "github.com/navidrome/navidrome/utils/jsoncommentstrip" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestJsonCommentStrip(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "JsonCommentStrip Suite") -} - -var _ = Describe("NewReader", func() { - read := func(input string) string { - r := jsoncommentstrip.NewReader(strings.NewReader(input)) - out, err := io.ReadAll(r) - Expect(err).ToNot(HaveOccurred()) - return string(out) - } - - // compact returns the compacted JSON form of s, for readable comparisons. - compact := func(s string) string { - var buf bytes.Buffer - ExpectWithOffset(1, json.Compact(&buf, []byte(s))).To(Succeed()) - return buf.String() - } - - It("passes through JSON without comments unchanged", func() { - input := `{"key": "value", "num": 42}` - Expect(read(input)).To(Equal(input)) - }) - - It("strips single-line comments", func() { - input := `{ - // this is a comment - "key": "value" - }` - Expect(compact(read(input))).To(Equal(compact(`{ - "key": "value" - }`))) - }) - - It("strips single-line comments at end of line", func() { - input := `{ - "key": "value" // inline comment - }` - Expect(compact(read(input))).To(Equal(compact(`{ - "key": "value" - }`))) - }) - - It("strips block comments", func() { - input := `{/* comment */"key": "value"}` - Expect(compact(read(input))).To(Equal(`{"key":"value"}`)) - }) - - It("strips multi-line block comments", func() { - input := `{ - /* this is - a multi-line - comment */ - "key": "value" - }` - Expect(compact(read(input))).To(Equal(compact(`{ - "key": "value" - }`))) - }) - - It("preserves // inside JSON strings", func() { - input := `{"key": "value // not a comment"}` - Expect(read(input)).To(Equal(input)) - }) - - It("preserves /* inside JSON strings", func() { - input := `{"key": "value /* not a comment */"}` - Expect(read(input)).To(Equal(input)) - }) - - It("handles escaped quotes in strings", func() { - input := `{"key": "val\"ue // not a comment"}` - Expect(read(input)).To(Equal(input)) - }) - - It("handles / at end of input as literal", func() { - input := `{"key": "value"}/` - Expect(read(input)).To(Equal(input)) - }) - - It("handles * inside block comment not followed by /", func() { - input := `{/* a * b */"key": "value"}` - Expect(compact(read(input))).To(Equal(`{"key":"value"}`)) - }) - - It("handles empty input", func() { - Expect(read("")).To(Equal("")) - }) - - It("handles mixed comments with real content", func() { - input := `{ - // line comment - "name": "test", /* inline block */ - /* multi - line */ - "value": "hello // world", - "other": 123 // trailing - }` - Expect(compact(read(input))).To(Equal(compact(`{ - "name": "test", - "value": "hello // world", - "other": 123 - }`))) - }) - - It("handles consecutive slashes that are not comments", func() { - input := `{"path": "/a/b"}` - Expect(read(input)).To(Equal(input)) - }) - - It("handles block comment at end of input", func() { - input := `{"key": "value"}/* comment */` - Expect(compact(read(input))).To(Equal(`{"key":"value"}`)) - }) - - It("strips comment with windows-style line endings", func() { - input := "{\r\n// comment\r\n\"key\": \"value\"\r\n}" - Expect(compact(read(input))).To(Equal(compact(`{"key": "value"}`))) - }) - - It("strips line comments with mixed line endings", func() { - // From original library: // comments with both \n and \r\n, including multiple on same line - input := "{\n\"one\": 1, // test //\n\"two\": 2, //test //\r\n\"string\": \"value\"\n//test\n}" - expected := "{\n\"one\": 1, \n\"two\": 2, \r\n\"string\": \"value\"\n\n}" - Expect(read(input)).To(Equal(expected)) - }) - - It("strips line comment at start of JSON", func() { - // From original library: // comment as first thing in JSON - input := "{// woot\n\"one\": 1, // test //\n\"two\": 2, //test //\r\n\"string\": \"value\"\n//test\n}" - expected := "{\n\"one\": 1, \n\"two\": 2, \r\n\"string\": \"value\"\n\n}" - Expect(read(input)).To(Equal(expected)) - }) - - It("strips block comments with mixed line endings inside", func() { - // From original library: block comment containing \r\n - input := "{/* multi\nline\r\ncomment */\"one\":1}" - expected := "{\"one\":1}" - Expect(read(input)).To(Equal(expected)) - }) - - It("handles complex mix of escaped quotes, comments, and strings", func() { - // From original library TestQuotationEscape: escaped quote inside string followed by - // comment-like chars, then real comments of both types - input := "{/* multi\nline\r\ncomment */\"one\": \"a value \\\" // /*woot\"/* m\nl *///woot\r\n}" - expected := "{\"one\": \"a value \\\" // /*woot\"\r\n}" - Expect(read(input)).To(Equal(expected)) - }) -}) diff --git a/utils/limiter.go b/utils/limiter.go deleted file mode 100644 index 84153e5cb..000000000 --- a/utils/limiter.go +++ /dev/null @@ -1,26 +0,0 @@ -package utils - -import ( - "cmp" - "sync" - "time" - - "golang.org/x/time/rate" -) - -// Limiter is a rate limiter that allows a function to be executed at most once per ID and per interval. -type Limiter struct { - Interval time.Duration - sm sync.Map -} - -// Do executes the provided function `f` if the rate limiter for the given `id` allows it. -// It uses the interval specified in the Limiter struct or defaults to 1 minute if not set. -func (m *Limiter) Do(id string, f func()) { - interval := cmp.Or( - m.Interval, - time.Minute, // Default every 1 minute - ) - limiter, _ := m.sm.LoadOrStore(id, &rate.Sometimes{Interval: interval}) - limiter.(*rate.Sometimes).Do(f) -} diff --git a/utils/merge/merge_fs.go b/utils/merge_fs.go similarity index 70% rename from utils/merge/merge_fs.go rename to utils/merge_fs.go index b3b02e55b..5e8890b50 100644 --- a/utils/merge/merge_fs.go +++ b/utils/merge_fs.go @@ -1,23 +1,21 @@ -package merge +package utils import ( - "cmp" "errors" "io" "io/fs" - "maps" - "slices" + "sort" ) -// FS implements a simple merged fs.FS, that can combine a Base FS with an Overlay FS. The semantics are: +// MergeFS implements a simple merged fs.FS, that can combine a Base FS with an Overlay FS. The semantics are: // - Files from the Overlay FS will override files with the same name in the Base FS // - Directories are combined, with priority for the Overlay FS over the Base FS for files with matching names -type FS struct { +type MergeFS struct { Base fs.FS Overlay fs.FS } -func (m FS) Open(name string) (fs.File, error) { +func (m MergeFS) Open(name string) (fs.File, error) { file, err := m.Overlay.Open(name) if err != nil { return m.Base.Open(name) @@ -45,26 +43,34 @@ func (m FS) Open(name string) (fs.File, error) { return m.mergeDirs(name, info, baseDirFile, overlayDirFile) } -func (m FS) mergeDirs(name string, info fs.FileInfo, baseDir fs.ReadDirFile, overlayDir fs.ReadDirFile) (fs.File, error) { +func (m MergeFS) mergeDirs(name string, info fs.FileInfo, baseDir fs.ReadDirFile, overlayDir fs.ReadDirFile) (fs.File, error) { + merged := map[string]fs.DirEntry{} + baseFiles, err := baseDir.ReadDir(-1) if err != nil { return nil, err } + sort.Slice(baseFiles, func(i, j int) bool { return baseFiles[i].Name() < baseFiles[j].Name() }) overlayFiles, err := overlayDir.ReadDir(-1) if err != nil { overlayFiles = nil } + sort.Slice(overlayFiles, func(i, j int) bool { return overlayFiles[i].Name() < overlayFiles[j].Name() }) - merged := map[string]fs.DirEntry{} for _, f := range baseFiles { merged[f.Name()] = f } for _, f := range overlayFiles { merged[f.Name()] = f } - it := maps.Values(merged) - entries := slices.SortedFunc(it, func(i, j fs.DirEntry) int { return cmp.Compare(i.Name(), j.Name()) }) + + var entries []fs.DirEntry + for _, i := range merged { + entries = append(entries, i) + } + + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) return &mergedDir{ name: name, info: info, @@ -95,6 +101,6 @@ func (d *mergedDir) ReadDir(count int) ([]fs.DirEntry, error) { func (d *mergedDir) Close() error { return nil } func (d *mergedDir) Stat() (fs.FileInfo, error) { return d.info, nil } -func (d *mergedDir) Read([]byte) (int, error) { +func (d *mergedDir) Read([]byte) (n int, err error) { return 0, &fs.PathError{Op: "read", Path: d.name, Err: errors.New("is a directory")} } diff --git a/utils/merge/merge_fs_test.go b/utils/merge_fs_test.go similarity index 78% rename from utils/merge/merge_fs_test.go rename to utils/merge_fs_test.go index 7241308d2..8789ff5bd 100644 --- a/utils/merge/merge_fs_test.go +++ b/utils/merge_fs_test.go @@ -1,32 +1,26 @@ -package merge_test +package utils_test import ( "io" "io/fs" "os" "path/filepath" - "testing" - "github.com/navidrome/navidrome/utils/merge" + "github.com/navidrome/navidrome/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -func TestMergeFS(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "MergeFS Suite") -} - -var _ = Describe("FS", func() { +var _ = Describe("MergeFS", func() { var baseName, overlayName string - var mergedDir fs.FS + var baseDir, overlayDir, mergedDir fs.FS BeforeEach(func() { baseName, _ = os.MkdirTemp("", "merge_fs_base_test") overlayName, _ = os.MkdirTemp("", "merge_fs_overlay_test") - baseDir := os.DirFS(baseName) - overlayDir := os.DirFS(overlayName) - mergedDir = merge.FS{Base: baseDir, Overlay: overlayDir} + baseDir = os.DirFS(baseName) + overlayDir = os.DirFS(overlayName) + mergedDir = utils.MergeFS{Base: baseDir, Overlay: overlayDir} }) AfterEach(func() { _ = os.RemoveAll(baseName) @@ -85,21 +79,21 @@ var _ = Describe("FS", func() { }) It("allows to seek to the beginning of the directory", func() { - _f(baseName, "1111.txt") - _f(baseName, "2222.txt") - _f(baseName, "3333.txt") + _f(baseName, "1111") + _f(baseName, "2222") + _f(baseName, "3333") dir, err := mergedDir.Open(".") Expect(err).To(BeNil()) list, _ := dir.(fs.ReadDirFile).ReadDir(2) Expect(list).To(HaveLen(2)) - Expect(list[0].Name()).To(Equal("1111.txt")) - Expect(list[1].Name()).To(Equal("2222.txt")) + Expect(list[0].Name()).To(Equal("1111")) + Expect(list[1].Name()).To(Equal("2222")) list, _ = dir.(fs.ReadDirFile).ReadDir(2) Expect(list).To(HaveLen(1)) - Expect(list[0].Name()).To(Equal("3333.txt")) + Expect(list[0].Name()).To(Equal("3333")) }) }) diff --git a/utils/nanoid/nanoid.go b/utils/nanoid/nanoid.go deleted file mode 100644 index 17d32e72f..000000000 --- a/utils/nanoid/nanoid.go +++ /dev/null @@ -1,52 +0,0 @@ -package nanoid - -import ( - "crypto/rand" - "errors" - "math" -) - -// Generate returns a cryptographically secure random string of `size` characters -// drawn from `alphabet`. It uses bitmask with rejection sampling to avoid modulo bias. -// The alphabet must be non-empty, contain at most 255 characters, and consist only of -// ASCII characters. Non-ASCII alphabets (e.g., multi-byte UTF-8) are not supported. -func Generate(alphabet string, size int) (string, error) { - if len(alphabet) == 0 || len(alphabet) > 255 { - return "", errors.New("alphabet must be non-empty and at most 255 characters") - } - if size <= 0 { - return "", errors.New("size must be a positive integer") - } - - mask := getMask(len(alphabet)) - step := int(math.Ceil(1.6 * float64(mask) * float64(size) / float64(len(alphabet)))) - - id := make([]byte, size) - bytes := make([]byte, step) - for j := 0; ; { - if _, err := rand.Read(bytes); err != nil { - return "", err - } - for i := range step { - idx := int(bytes[i]) & mask - if idx < len(alphabet) { - id[j] = alphabet[idx] - j++ - if j == size { - return string(id), nil - } - } - } - } -} - -// getMask returns the smallest bitmask >= alphabetSize-1. -func getMask(alphabetSize int) int { - for i := 1; i <= 8; i++ { - mask := (2 << uint(i)) - 1 - if mask >= alphabetSize-1 { - return mask - } - } - return 0 -} diff --git a/utils/nanoid/nanoid_test.go b/utils/nanoid/nanoid_test.go deleted file mode 100644 index 99d4e5715..000000000 --- a/utils/nanoid/nanoid_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package nanoid_test - -import ( - "testing" - - "github.com/navidrome/navidrome/utils/nanoid" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestNanoid(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Nanoid Suite") -} - -var _ = Describe("Generate", func() { - const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - - It("generates a string of the requested length", func() { - id, err := nanoid.Generate(alphabet, 22) - Expect(err).ToNot(HaveOccurred()) - Expect(id).To(HaveLen(22)) - }) - - It("generates a short string of the requested length", func() { - id, err := nanoid.Generate(alphabet, 10) - Expect(err).ToNot(HaveOccurred()) - Expect(id).To(HaveLen(10)) - }) - - It("only contains characters from the alphabet", func() { - id, err := nanoid.Generate(alphabet, 100) - Expect(err).ToNot(HaveOccurred()) - for _, c := range id { - Expect(alphabet).To(ContainSubstring(string(c))) - } - }) - - It("generates unique IDs", func() { - seen := make(map[string]bool) - for range 1000 { - id, err := nanoid.Generate(alphabet, 22) - Expect(err).ToNot(HaveOccurred()) - Expect(seen).ToNot(HaveKey(id)) - seen[id] = true - } - }) - - It("works with a single-character alphabet", func() { - id, err := nanoid.Generate("a", 5) - Expect(err).ToNot(HaveOccurred()) - Expect(id).To(Equal("aaaaa")) - }) - - It("works with a small alphabet", func() { - id, err := nanoid.Generate("ab", 10) - Expect(err).ToNot(HaveOccurred()) - Expect(id).To(HaveLen(10)) - for _, c := range id { - Expect(string(c)).To(BeElementOf("a", "b")) - } - }) - - It("returns error on empty alphabet", func() { - _, err := nanoid.Generate("", 10) - Expect(err).To(HaveOccurred()) - }) - - It("returns error on alphabet larger than 255 characters", func() { - bigAlphabet := make([]byte, 256) - for i := range bigAlphabet { - bigAlphabet[i] = byte(i) - } - _, err := nanoid.Generate(string(bigAlphabet), 10) - Expect(err).To(HaveOccurred()) - }) - - It("returns error on non-positive size", func() { - _, err := nanoid.Generate(alphabet, 0) - Expect(err).To(HaveOccurred()) - - _, err = nanoid.Generate(alphabet, -1) - Expect(err).To(HaveOccurred()) - }) -}) diff --git a/utils/natural/natural.go b/utils/natural/natural.go deleted file mode 100644 index d8ddcc405..000000000 --- a/utils/natural/natural.go +++ /dev/null @@ -1,119 +0,0 @@ -// Package natural provides natural (alphanumeric) string comparison. -// When both strings have digit sequences at the same position, they are -// compared numerically (so "file2" < "file10"); otherwise bytes are -// compared one-by-one. No allocations are made. -package natural - -import "strings" - -// Compare returns a negative value if a < b, zero if a == b, -// or a positive value if a > b using natural sort ordering. -// -// When two numeric segments are numerically equal (e.g. "01" vs "1"), -// comparison continues with the remaining suffixes, and the padding -// difference is kept as a final tie-breaker that only decides strings -// that are otherwise equal (e.g. "a01" < "a1", "a0" < "a00"). Deferring -// it that way is what keeps the ordering transitive, which SQLite -// requires of a collating function. -func Compare(a, b string) int { - return compare(a, b, false) -} - -// CompareFold is Compare with ASCII case folding, matching SQLite's NOCASE -// collation: only A-Z fold, bytes >= 0x80 are compared as-is. -func CompareFold(a, b string) int { - return compare(a, b, true) -} - -func compare(a, b string, fold bool) int { - ia, ib := 0, 0 - // Set when two runs are numerically equal but differently padded. Applying it - // immediately would break transitivity, so it only decides otherwise-equal strings. - padTie := 0 - for ia < len(a) && ib < len(b) { - ca, cb := a[ia], b[ib] - da, db := isDigit(ca), isDigit(cb) - if fold { - ca, cb = lower(ca), lower(cb) - } - - switch { - case da && db: - // Both are in digit sequences — compare numerically. - endA := ia - for endA < len(a) && isDigit(a[endA]) { - endA++ - } - endB := ib - for endB < len(b) && isDigit(b[endB]) { - endB++ - } - - if c := compareNumbers(a[ia:endA], b[ib:endB]); c != 0 { - return c - } - if t := strings.Compare(a[ia:endA], b[ib:endB]); t != 0 { - padTie = t - } - ia = endA - ib = endB - case da != db: - return int(ca) - int(cb) - default: - if ca != cb { - return int(ca) - int(cb) - } - ia++ - ib++ - } - } - if c := (len(a) - ia) - (len(b) - ib); c != 0 { - return c - } - return padTie -} - -// compareNumbers compares two digit strings numerically. -// Leading zeros are stripped before comparison. -func compareNumbers(a, b string) int { - // Strip leading zeros. - sa := stripZeros(a) - sb := stripZeros(b) - - // Different lengths after stripping means different magnitude. - if len(sa) != len(sb) { - return len(sa) - len(sb) - } - - // Same length — compare digit by digit. - for i := range len(sa) { - if sa[i] != sb[i] { - return int(sa[i]) - int(sb[i]) - } - } - return 0 -} - -// stripZeros returns s with leading '0' bytes removed. -// If s is all zeros, returns the last byte (a single "0"). -func stripZeros(s string) string { - i := 0 - for i < len(s) && s[i] == '0' { - i++ - } - if i == len(s) && len(s) > 0 { - return s[len(s)-1:] - } - return s[i:] -} - -func isDigit(c byte) bool { - return c >= '0' && c <= '9' -} - -func lower(c byte) byte { - if c >= 'A' && c <= 'Z' { - return c + 'a' - 'A' - } - return c -} diff --git a/utils/natural/natural_test.go b/utils/natural/natural_test.go deleted file mode 100644 index 534885d40..000000000 --- a/utils/natural/natural_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package natural_test - -import ( - "testing" - - "github.com/navidrome/navidrome/utils/natural" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestNatural(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Natural Suite") -} - -// expectOrder asserts the sign of cmp(a, b) matches expected. -func expectOrder(cmp func(string, string) int, a, b string, expected int) { - result := cmp(a, b) - switch { - case expected < 0: - ExpectWithOffset(1, result).To(BeNumerically("<", 0), "expected %q < %q", a, b) - case expected > 0: - ExpectWithOffset(1, result).To(BeNumerically(">", 0), "expected %q > %q", a, b) - default: - ExpectWithOffset(1, result).To(Equal(0), "expected %q == %q", a, b) - } -} - -var _ = Describe("Compare", func() { - DescribeTable("returns correct ordering", - func(a, b string, expected int) { - expectOrder(natural.Compare, a, b, expected) - }, - // Basic string ordering - Entry("a < b", "a", "b", -1), - Entry("b > a", "b", "a", 1), - Entry("a < aa (prefix)", "a", "aa", -1), - Entry("aa > a", "aa", "a", 1), - - // Equal strings - Entry("equal strings return 0", "abc", "abc", 0), - Entry("both empty", "", "", 0), - Entry("a01 == a01", "a01", "a01", 0), - Entry("a1 == a1", "a1", "a1", 0), - - // Empty string edge cases - Entry("empty < non-empty", "", "a", -1), - Entry("non-empty > empty", "a", "", 1), - - // Numeric comparison - Entry("2 < 10 numerically", "2", "10", -1), - Entry("10 > 2 numerically", "10", "2", 1), - Entry("equal numbers", "42", "42", 0), - Entry("9 < 10", "9", "10", -1), - Entry("99 < 100", "99", "100", -1), - - // Simple numeric segments (from original library) - Entry("a0 < a1", "a0", "a1", -1), - Entry("a0 < a00", "a0", "a00", -1), - Entry("a00 < a01", "a00", "a01", -1), - Entry("a01 < a1", "a01", "a1", -1), - Entry("a01 < a2", "a01", "a2", -1), - Entry("a01x < a2x", "a01x", "a2x", -1), - Entry("a01 > a00", "a01", "a00", 1), - Entry("a2 > a01", "a2", "a01", 1), - Entry("a2x > a01x", "a2x", "a01x", 1), - - // Multiple numeric groups (from original library) - Entry("a0b00 < a00b1", "a0b00", "a00b1", -1), - Entry("a0b00 < a00b01", "a0b00", "a00b01", -1), - Entry("a00b0 < a0b00", "a00b0", "a0b00", -1), - Entry("a00b00 < a0b01", "a00b00", "a0b01", -1), - Entry("a00b00 < a0b1", "a00b00", "a0b1", -1), - Entry("a00b00 > a0b0", "a00b00", "a0b0", 1), - Entry("a00b01 > a0b00", "a00b01", "a0b00", 1), - // Distinct strings must not compare equal: the padding difference in the first - // run decides once everything else matches. - Entry("a00b00 > a0b00", "a00b00", "a0b00", 1), - - // Leading zeros at end of string — lexical tie-break - Entry("file01 < file1", "file01", "file1", -1), - - // Prefix comparison - Entry("abc < abcd", "abc", "abcd", -1), - Entry("abcd > abc", "abcd", "abc", 1), - - // Navidrome use cases: cover art sorting - Entry("cover < cover.1", "cover", "cover.1", -1), - Entry("cover.1 < cover.2", "cover.1", "cover.2", -1), - Entry("cover.2 < cover.10", "cover.2", "cover.10", -1), - - // Navidrome use cases: disc sorting - Entry("disc1 < disc2", "disc1", "disc2", -1), - Entry("disc2 < disc10", "disc2", "disc10", -1), - Entry("disc1 < disc10", "disc1", "disc10", -1), - - // Multiple numeric segments - Entry("a1b2 < a1b10", "a1b2", "a1b10", -1), - Entry("a2b1 > a1b2", "a2b1", "a1b2", 1), - - // Numbers at the start - Entry("2abc < 10abc", "2abc", "10abc", -1), - - // Numbers larger than uint64 max (from original library) - Entry("large: fewer digits < more digits", - "a99999999999999999999", "a100000000000000000000", -1), - Entry("large: digit-by-digit comparison", - "a123456789012345678901234567890", "a123456789012345678901234567891", -1), - Entry("large: more digits > fewer digits", - "a999999999999999999999", "a1000000000000000000000", -1), - Entry("large: 20 digits < 100 digits by length", - "a20000000000000000000", "a100000000000000000000", -1), - Entry("large: 100 digits > 20 digits", - "a100000000000000000000", "a20000000000000000000", 1), - Entry("large: reverse of above", - "a1000000000000000000000", "a999999999999999999999", 1), - Entry("large: equal", - "a100000000000000000000", "a100000000000000000000", 0), - Entry("large: leading zeros with trailing data", - "a00000000000000000000001x", "a1x", -1), - Entry("large: leading zeros with trailing data (2)", - "a099999999999999999999x", "a99999999999999999999x", -1), - ) -}) - -var _ = Describe("CompareFold", func() { - DescribeTable("orders case-insensitively", - func(a, b string, expected int) { - expectOrder(natural.CompareFold, a, b, expected) - }, - Entry("numbers compare numerically", "foo 2", "foo 10", -1), - Entry("numbers compare numerically, reversed", "foo 10", "foo 2", 1), - Entry("case is ignored", "apple 2", "Banana 10", -1), - Entry("case is ignored, reversed", "Banana 10", "apple 2", 1), - Entry("same word, different case, is equal", "ABC", "abc", 0), - Entry("case ignored while comparing numbers", "Vol 2", "vol 10", -1), - Entry("uppercase digits boundary", "Track9", "track10", -1), - Entry("empty vs empty", "", "", 0), - Entry("empty sorts first", "", "a", -1), - Entry("non-ASCII is left untouched", "café 2", "café 10", -1), - ) - - // SQLite requires a collating function to be transitive; if it is not, the behavior of - // ORDER BY is undefined and paginated queries can drop or duplicate rows. - It("is transitive, as a SQLite collation requires", func() { - var corpus []string - var build func(prefix string, depth int) - build = func(prefix string, depth int) { - if prefix != "" { - corpus = append(corpus, prefix) - } - if depth == 0 { - return - } - for _, c := range []string{"0", "1", "a"} { - build(prefix+c, depth-1) - } - } - build("", 3) - - sign := func(n int) int { - switch { - case n < 0: - return -1 - case n > 0: - return 1 - } - return 0 - } - for _, a := range corpus { - for _, b := range corpus { - ab := sign(natural.CompareFold(a, b)) - for _, c := range corpus { - bc := sign(natural.CompareFold(b, c)) - ac := sign(natural.CompareFold(a, c)) - if ab == 0 && bc == 0 { - Expect(ac).To(Equal(0), "%q==%q and %q==%q but %q vs %q is %d", a, b, b, c, a, c, ac) - } - if ab < 0 && bc < 0 { - Expect(ac).To(BeNumerically("<", 0), "%q<%q<%q but %q vs %q is %d", a, b, c, a, c, ac) - } - } - } - } - }) - - It("matches Compare when both sides are already lowercase", func() { - pairs := [][2]string{{"foo 2", "foo 10"}, {"a01", "a1"}, {"a", "aa"}, {"vol 3", "vol 3"}} - for _, p := range pairs { - Expect(natural.CompareFold(p[0], p[1])).To(Equal(natural.Compare(p[0], p[1])), - "CompareFold(%q,%q) should match Compare", p[0], p[1]) - } - }) -}) diff --git a/utils/netguard/netguard.go b/utils/netguard/netguard.go deleted file mode 100644 index 43d80ecc9..000000000 --- a/utils/netguard/netguard.go +++ /dev/null @@ -1,38 +0,0 @@ -// Package netguard keeps outbound connections driven by untrusted input away from internal addresses. -package netguard - -import ( - "errors" - "fmt" - "net" - "net/netip" - "syscall" -) - -var ErrPrivateAddress = errors.New("dial to private/loopback address blocked") - -// IsPrivateIP reports whether ip is loopback, private, link-local or unspecified. -func IsPrivateIP(ip net.IP) bool { - return ip.IsLoopback() || ip.IsUnspecified() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() -} - -// DialControl returns a net.Dialer Control hook that rejects private addresses not covered by allowed. -// It sees the resolved IP, so DNS names, redirects and rebinding cannot get around it. -func DialControl(allowed ...netip.Prefix) func(network, address string, c syscall.RawConn) error { - return func(_, address string, _ syscall.RawConn) error { - ap, err := netip.ParseAddrPort(address) - if err != nil { - return fmt.Errorf("%w: unparseable address %q: %w", ErrPrivateAddress, address, err) - } - addr := ap.Addr().Unmap() - if !IsPrivateIP(addr.AsSlice()) { - return nil - } - for _, p := range allowed { - if p.Contains(addr) { - return nil - } - } - return fmt.Errorf("%w: %s", ErrPrivateAddress, address) - } -} diff --git a/utils/netguard/netguard_suite_test.go b/utils/netguard/netguard_suite_test.go deleted file mode 100644 index 7769e0c82..000000000 --- a/utils/netguard/netguard_suite_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package netguard_test - -import ( - "testing" - - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestNetguard(t *testing.T) { - tests.Init(t, false) - log.SetLevel(log.LevelFatal) - RegisterFailHandler(Fail) - RunSpecs(t, "Netguard Suite") -} diff --git a/utils/netguard/netguard_test.go b/utils/netguard/netguard_test.go deleted file mode 100644 index 733204226..000000000 --- a/utils/netguard/netguard_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package netguard_test - -import ( - "net" - "net/netip" - - "github.com/navidrome/navidrome/utils/netguard" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("netguard", func() { - DescribeTable("IsPrivateIP", - func(addr string, expected bool) { - Expect(netguard.IsPrivateIP(net.ParseIP(addr))).To(Equal(expected)) - }, - Entry("IPv4 loopback", "127.0.0.1", true), - Entry("IPv4 loopback range", "127.0.0.2", true), - Entry("IPv6 loopback", "::1", true), - Entry("IPv4-mapped IPv6 loopback", "::ffff:127.0.0.1", true), - Entry("10.x", "10.1.2.3", true), - Entry("172.16.x", "172.16.0.1", true), - Entry("192.168.x", "192.168.1.10", true), - Entry("IPv6 unique local", "fd00::1", true), - Entry("link-local (cloud metadata)", "169.254.169.254", true), - Entry("IPv6 link-local", "fe80::1", true), - Entry("link-local multicast", "224.0.0.1", true), - Entry("IPv4 unspecified", "0.0.0.0", true), - Entry("IPv6 unspecified", "::", true), - Entry("public IPv4", "93.184.216.34", false), - Entry("172.32.x is outside the private block", "172.32.0.1", false), - Entry("public IPv6", "2606:4700:4700::1111", false), - ) - - Describe("DialControl", func() { - It("rejects private and loopback addresses", func() { - control := netguard.DialControl() - for _, addr := range []string{"127.0.0.1:80", "[::1]:443", "169.254.169.254:80", "10.0.0.1:8080", "0.0.0.0:80"} { - Expect(control("tcp", addr, nil)).To(MatchError(netguard.ErrPrivateAddress), addr) - } - }) - - It("allows public addresses", func() { - control := netguard.DialControl() - Expect(control("tcp", "93.184.216.34:443", nil)).To(Succeed()) - Expect(control("tcp6", "[2606:4700:4700::1111]:443", nil)).To(Succeed()) - }) - - It("allows private addresses covered by an allowed prefix, and only those", func() { - control := netguard.DialControl(netip.MustParsePrefix("127.0.0.1/32")) - Expect(control("tcp", "127.0.0.1:8080", nil)).To(Succeed()) - Expect(control("tcp", "127.0.0.2:8080", nil)).To(MatchError(netguard.ErrPrivateAddress)) - }) - - It("matches IPv4-mapped IPv6 addresses against IPv4 prefixes", func() { - control := netguard.DialControl(netip.MustParsePrefix("127.0.0.0/8")) - Expect(control("tcp", "[::ffff:127.0.0.1]:80", nil)).To(Succeed()) - }) - - It("fails closed when the address is not an IP", func() { - Expect(netguard.DialControl()("tcp", "localhost:80", nil)).To(HaveOccurred()) - Expect(netguard.DialControl()("tcp", "garbage", nil)).To(HaveOccurred()) - }) - }) -}) diff --git a/utils/number/number.go b/utils/number/number.go index daf3b4deb..cc8aa7112 100644 --- a/utils/number/number.go +++ b/utils/number/number.go @@ -1,16 +1,41 @@ package number import ( - "strconv" + "crypto/rand" + "math/big" + + "golang.org/x/exp/constraints" ) -// Integer is a constraint that permits any integer type. -type Integer interface { - ~int | ~int8 | ~int16 | ~int32 | ~int64 | - ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr +func Min[T constraints.Ordered](vs ...T) T { + if len(vs) == 0 { + var zero T + return zero + } + min := vs[0] + for _, v := range vs[1:] { + if v < min { + min = v + } + } + return min } -func ParseInt[T Integer](s string) T { - r, _ := strconv.ParseInt(s, 10, 64) - return T(r) +func Max[T constraints.Ordered](vs ...T) T { + if len(vs) == 0 { + var zero T + return zero + } + max := vs[0] + for _, v := range vs[1:] { + if v > max { + max = v + } + } + return max +} + +func RandomInt64(max int64) int64 { + rnd, _ := rand.Int(rand.Reader, big.NewInt(max)) + return rnd.Int64() } diff --git a/utils/number/number_test.go b/utils/number/number_test.go index dd6614871..d4c34ae7b 100644 --- a/utils/number/number_test.go +++ b/utils/number/number_test.go @@ -13,19 +13,26 @@ func TestNumber(t *testing.T) { RunSpecs(t, "Number Suite") } -var _ = Describe("number package", func() { - Describe("ParseInt", func() { - It("should parse a string into an int", func() { - Expect(number.ParseInt[int64]("123")).To(Equal(int64(123))) - }) - It("should parse a string into an int32", func() { - Expect(number.ParseInt[int32]("123")).To(Equal(int32(123))) - }) - It("should parse a string into an int64", func() { - Expect(number.ParseInt[int]("123")).To(Equal(123)) - }) - It("should parse a string into an uint", func() { - Expect(number.ParseInt[uint]("123")).To(Equal(uint(123))) - }) +var _ = Describe("Min", func() { + It("returns zero value if no arguments are passed", func() { + Expect(number.Min[int]()).To(BeZero()) + }) + It("returns the smallest int", func() { + Expect(number.Min(1, 2)).To(Equal(1)) + }) + It("returns the smallest float", func() { + Expect(number.Min(-4.1, -4.2, -4.0)).To(Equal(-4.2)) + }) +}) + +var _ = Describe("Max", func() { + It("returns zero value if no arguments are passed", func() { + Expect(number.Max[int]()).To(BeZero()) + }) + It("returns the biggest int", func() { + Expect(number.Max(1, 2)).To(Equal(2)) + }) + It("returns the biggest float", func() { + Expect(number.Max(-4.1, -4.2, -4.0)).To(Equal(-4.0)) }) }) diff --git a/utils/pl/pipelines.go b/utils/pl/pipelines.go index df4ee030c..ed85c6b94 100644 --- a/utils/pl/pipelines.go +++ b/utils/pl/pipelines.go @@ -29,7 +29,7 @@ func Stage[In any, Out any]( limit := int64(maxWorkers) sem1 := semaphore.NewWeighted(limit) - go func() { //nolint:gosec // intentional context.Background() below to wait for workers after ctx cancellation + go func() { defer close(outputChannel) defer close(errorChannel) @@ -58,7 +58,7 @@ func Stage[In any, Out any]( // By using context.Background() here we are assuming the fn will stop when the context // is canceled. This is required so we can wait for the workers to finish and avoid closing // the outputChannel before they are done. - if err := sem1.Acquire(context.Background(), limit); err != nil { //nolint:gosec // intentional: must wait for workers after ctx cancellation + if err := sem1.Acquire(context.Background(), limit); err != nil { log.Error(ctx, "Failed waiting for workers", err) } }() @@ -152,7 +152,7 @@ func Tee[T any](ctx context.Context, in <-chan T) (<-chan T, <-chan T) { defer close(out2) for val := range ReadOrDone(ctx, in) { var out1, out2 = out1, out2 - for range 2 { + for i := 0; i < 2; i++ { select { case <-ctx.Done(): case out1 <- val: diff --git a/utils/pl/pipelines_test.go b/utils/pl/pipelines_test.go index aa0b7faff..f5da6e49f 100644 --- a/utils/pl/pipelines_test.go +++ b/utils/pl/pipelines_test.go @@ -22,7 +22,7 @@ var _ = Describe("Pipeline", func() { Context("happy path", func() { It("calls the 'transform' function and returns values and errors", func() { inC := make(chan int, 4) - for i := range 4 { + for i := 0; i < 4; i++ { inC <- i } close(inC) @@ -48,7 +48,7 @@ var _ = Describe("Pipeline", func() { const numJobs = 100 It("starts multiple workers, respecting the limit", func() { inC := make(chan int, numJobs) - for i := range numJobs { + for i := 0; i < numJobs; i++ { inC <- i } close(inC) @@ -94,7 +94,7 @@ var _ = Describe("Pipeline", func() { BeforeEach(func() { in1 = make(chan int, 4) in2 = make(chan int, 4) - for i := range 4 { + for i := 0; i < 4; i++ { in1 <- i in2 <- i + 4 } @@ -126,7 +126,7 @@ var _ = Describe("Pipeline", func() { It("copies them to its output channel", func() { in := make(chan int) out := pl.ReadOrDone(context.Background(), in) - for i := range 4 { + for i := 0; i < 4; i++ { in <- i j := <-out Expect(i).To(Equal(j)) diff --git a/utils/random/number.go b/utils/random/number.go deleted file mode 100644 index e93344c19..000000000 --- a/utils/random/number.go +++ /dev/null @@ -1,24 +0,0 @@ -package random - -import ( - "crypto/rand" - "encoding/binary" - "math/big" - - "github.com/navidrome/navidrome/utils/number" -) - -// Int64N returns a random int64 between 0 and max. -// This is a reimplementation of math/rand/v2.Int64N using a cryptographically secure random number generator. -func Int64N[T number.Integer](max T) int64 { - rnd, _ := rand.Int(rand.Reader, big.NewInt(int64(max))) - return rnd.Int64() -} - -// Uint64 returns a random uint64. -// This is a reimplementation of math/rand/v2.Uint64 using a cryptographically secure random number generator. -func Uint64() uint64 { - buffer := make([]byte, 8) - _, _ = rand.Read(buffer) - return binary.BigEndian.Uint64(buffer) -} diff --git a/utils/random/number_test.go b/utils/random/number_test.go deleted file mode 100644 index 985a39426..000000000 --- a/utils/random/number_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package random_test - -import ( - "testing" - - "github.com/navidrome/navidrome/utils/random" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestRandom(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Random Suite") -} - -var _ = Describe("number package", func() { - Describe("Int64N", func() { - It("should return a random int64", func() { - for range 10000 { - Expect(random.Int64N(100)).To(BeNumerically("<", 100)) - } - }) - }) -}) diff --git a/utils/random/weighted_random_chooser.go b/utils/random/weighted_random_chooser.go deleted file mode 100644 index f382ab4dc..000000000 --- a/utils/random/weighted_random_chooser.go +++ /dev/null @@ -1,70 +0,0 @@ -package random - -import ( - "errors" - "slices" -) - -// WeightedChooser allows to randomly choose an entry based on their weights -// (higher weight = higher chance of being chosen). Based on the subtraction method described in -// https://eli.thegreenplace.net/2010/01/22/weighted-random-generation-in-python/ -type WeightedChooser[T any] struct { - entries []T - weights []int - totalWeight int -} - -func NewWeightedChooser[T any]() *WeightedChooser[T] { - return &WeightedChooser[T]{} -} - -func (w *WeightedChooser[T]) Add(value T, weight int) { - w.entries = append(w.entries, value) - w.weights = append(w.weights, weight) - w.totalWeight += weight -} - -// Pick choose a random entry based on their weights, and removes it from the list -func (w *WeightedChooser[T]) Pick() (T, error) { - var empty T - if w.totalWeight == 0 { - return empty, errors.New("cannot choose from zero weight") - } - i, err := w.weightedChoice() - if err != nil { - return empty, err - } - entry := w.entries[i] - _ = w.Remove(i) - return entry, nil -} - -func (w *WeightedChooser[T]) weightedChoice() (int, error) { - if len(w.entries) == 0 { - return 0, errors.New("cannot choose from empty list") - } - rnd := Int64N(w.totalWeight) - for i, weight := range w.weights { - rnd -= int64(weight) - if rnd < 0 { - return i, nil - } - } - return 0, errors.New("internal error - code should not reach this point") -} - -func (w *WeightedChooser[T]) Remove(i int) error { - if i < 0 || i >= len(w.entries) { - return errors.New("index out of bounds") - } - - w.totalWeight -= w.weights[i] - - w.weights = slices.Delete(w.weights, i, i+1) - w.entries = slices.Delete(w.entries, i, i+1) - return nil -} - -func (w *WeightedChooser[T]) Size() int { - return len(w.entries) -} diff --git a/utils/random/weighted_random_chooser_test.go b/utils/random/weighted_random_chooser_test.go deleted file mode 100644 index b336e6ca6..000000000 --- a/utils/random/weighted_random_chooser_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package random - -import ( - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("WeightedChooser", func() { - var w *WeightedChooser[int] - BeforeEach(func() { - w = NewWeightedChooser[int]() - for i := range 10 { - w.Add(i, i+1) - } - }) - - It("selects and removes a random item", func() { - Expect(w.Size()).To(Equal(10)) - _, err := w.Pick() - Expect(err).ToNot(HaveOccurred()) - Expect(w.Size()).To(Equal(9)) - }) - - It("removes items", func() { - Expect(w.Size()).To(Equal(10)) - for range 10 { - Expect(w.Remove(0)).To(Succeed()) - } - Expect(w.Size()).To(Equal(0)) - }) - - It("returns error if trying to remove an invalid index", func() { - Expect(w.Size()).To(Equal(10)) - Expect(w.Remove(-1)).ToNot(Succeed()) - Expect(w.Remove(10000)).ToNot(Succeed()) - Expect(w.Size()).To(Equal(10)) - }) - - It("returns the sole item", func() { - ws := NewWeightedChooser[string]() - ws.Add("a", 1) - Expect(ws.Pick()).To(Equal("a")) - }) - - It("returns all items from the list", func() { - for range 10 { - Expect(w.Pick()).To(BeElementOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)) - } - Expect(w.Size()).To(Equal(0)) - }) - - It("fails when trying to choose from empty set", func() { - w = NewWeightedChooser[int]() - w.Add(1, 1) - w.Add(2, 1) - Expect(w.Pick()).To(BeElementOf(1, 2)) - Expect(w.Pick()).To(BeElementOf(1, 2)) - _, err := w.Pick() - Expect(err).To(HaveOccurred()) - }) - - It("chooses based on weights", func() { - counts := [10]int{} - for range 200000 { - c, _ := w.weightedChoice() - counts[c] = counts[c] + 1 - } - for i := range 9 { - Expect(counts[i]).To(BeNumerically("<", counts[i+1])) - } - }) -}) diff --git a/utils/req/req.go b/utils/req/req.go deleted file mode 100644 index 6a863184c..000000000 --- a/utils/req/req.go +++ /dev/null @@ -1,198 +0,0 @@ -package req - -import ( - "errors" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "github.com/navidrome/navidrome/log" -) - -type Values struct { - *http.Request -} - -func Params(r *http.Request) *Values { - return &Values{r} -} - -var ( - ErrMissingParam = errors.New("missing parameter") - ErrInvalidParam = errors.New("invalid parameter") -) - -func newError(err error, param string) error { - return fmt.Errorf("%w: '%s'", err, param) -} -func (r *Values) String(param string) (string, error) { - v := r.URL.Query().Get(param) - if v == "" { - return "", newError(ErrMissingParam, param) - } - return v, nil -} - -func (r *Values) StringPtr(param string) *string { - var v *string - if _, exists := r.URL.Query()[param]; exists { - v = new(r.URL.Query().Get(param)) - } - return v -} - -func (r *Values) BoolPtr(param string) *bool { - var v *bool - if _, exists := r.URL.Query()[param]; exists { - s := r.URL.Query().Get(param) - v = new(strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/")) - } - return v -} - -func (r *Values) StringOr(param, def string) string { - v, _ := r.String(param) - if v == "" { - return def - } - return v -} - -// Strings returns all occurrences of the param, or a nil (empty) slice when absent. Callers that -// require the param should check for emptiness themselves. -func (r *Values) Strings(param string) []string { - return r.URL.Query()[param] -} - -func (r *Values) TimeOr(param string, def time.Time) time.Time { - v, _ := r.String(param) - if v == "" || v == "-1" { - return def - } - value, err := strconv.ParseInt(v, 10, 64) - if err != nil { - return def - } - t := time.UnixMilli(value) - if t.Before(time.Date(1970, time.January, 2, 0, 0, 0, 0, time.UTC)) { - return def - } - return t -} - -func (r *Values) Times(param string) ([]time.Time, error) { - pStr := r.Strings(param) - if len(pStr) == 0 { - return nil, newError(ErrMissingParam, param) - } - times := make([]time.Time, len(pStr)) - for i, t := range pStr { - ti, err := strconv.ParseInt(t, 10, 64) - if err != nil { - log.Warn(r.Context(), "Ignoring invalid time param", "time", t, err) - times[i] = time.Now() - continue - } - times[i] = time.UnixMilli(ti) - } - return times, nil -} - -func (r *Values) Int64(param string) (int64, error) { - v, err := r.String(param) - if err != nil { - return 0, err - } - value, err := strconv.ParseInt(v, 10, 64) - if err != nil { - return 0, fmt.Errorf("%w '%s': expected integer, got '%s'", ErrInvalidParam, param, v) - } - return value, nil -} - -func (r *Values) Int(param string) (int, error) { - v, err := r.Int64(param) - if err != nil { - return 0, err - } - return int(v), nil -} - -func (r *Values) IntOr(param string, def int) int { - v, err := r.Int64(param) - if err != nil { - return def - } - return int(v) -} - -func (r *Values) Int64Or(param string, def int64) int64 { - v, err := r.Int64(param) - if err != nil { - return def - } - return v -} - -func (r *Values) Ints(param string) ([]int, error) { - pStr := r.Strings(param) - if len(pStr) == 0 { - return nil, newError(ErrMissingParam, param) - } - ints := make([]int, 0, len(pStr)) - for _, s := range pStr { - i, err := strconv.ParseInt(s, 10, 64) - if err == nil { - ints = append(ints, int(i)) - } - } - return ints, nil -} - -func (r *Values) Bool(param string) (bool, error) { - v, err := r.String(param) - if err != nil { - return false, err - } - return strings.Contains("/true/on/1/", "/"+strings.ToLower(v)+"/"), nil -} - -func (r *Values) BoolOr(param string, def bool) bool { - v, err := r.Bool(param) - if err != nil { - return def - } - 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 -} - -// IfNoneMatch reports whether the request's If-None-Match asserts etag (unquoted), using RFC 9110 weak comparison. -func IfNoneMatch(r *http.Request, etag string) bool { - header := strings.TrimSpace(r.Header.Get("If-None-Match")) - if header == "" { - return false - } - if header == "*" { - return true - } - for tag := range strings.SplitSeq(header, ",") { - tag = strings.TrimPrefix(strings.TrimSpace(tag), "W/") - if strings.Trim(tag, `"`) == etag { - return true - } - } - return false -} diff --git a/utils/req/req_test.go b/utils/req/req_test.go deleted file mode 100644 index d64f51b02..000000000 --- a/utils/req/req_test.go +++ /dev/null @@ -1,310 +0,0 @@ -package req_test - -import ( - "fmt" - "net/http/httptest" - "testing" - "time" - - "github.com/navidrome/navidrome/utils/req" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestUtils(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Request Helpers Suite") -} - -var _ = Describe("Request Helpers", func() { - var r *req.Values - - Describe("ParamString", func() { - BeforeEach(func() { - r = req.Params(httptest.NewRequest("GET", "/ping?a=123", nil)) - }) - - It("returns param as string", func() { - Expect(r.String("a")).To(Equal("123")) - }) - - It("returns empty string if param does not exist", func() { - v, err := r.String("NON_EXISTENT_PARAM") - Expect(err).To(MatchError(req.ErrMissingParam)) - Expect(err.Error()).To(ContainSubstring("NON_EXISTENT_PARAM")) - Expect(v).To(BeEmpty()) - }) - }) - - Describe("ParamStringDefault", func() { - BeforeEach(func() { - r = req.Params(httptest.NewRequest("GET", "/ping?a=123", nil)) - }) - - It("returns param as string", func() { - Expect(r.StringOr("a", "default_value")).To(Equal("123")) - }) - - It("returns default string if param does not exist", func() { - Expect(r.StringOr("xx", "default_value")).To(Equal("default_value")) - }) - }) - - Describe("ParamStrings", func() { - BeforeEach(func() { - r = req.Params(httptest.NewRequest("GET", "/ping?a=123&a=456", nil)) - }) - - It("returns all param occurrences as []string", func() { - Expect(r.Strings("a")).To(Equal([]string{"123", "456"})) - }) - - It("returns empty array if param does not exist", func() { - Expect(r.Strings("xx")).To(BeEmpty()) - }) - }) - - Describe("ParamTime", func() { - d := time.Date(2002, 8, 9, 12, 11, 13, 1000000, time.Local) - t := d.UnixMilli() - now := time.Now() - BeforeEach(func() { - r = req.Params(httptest.NewRequest("GET", fmt.Sprintf("/ping?t=%d&inv=abc", t), nil)) - }) - - It("returns parsed time", func() { - Expect(r.TimeOr("t", now)).To(Equal(d)) - }) - - It("returns default time if param does not exist", func() { - Expect(r.TimeOr("xx", now)).To(Equal(now)) - }) - - It("returns default time if param is an invalid timestamp", func() { - Expect(r.TimeOr("inv", now)).To(Equal(now)) - }) - }) - - Describe("ParamTimes", func() { - d1 := time.Date(2002, 8, 9, 12, 11, 13, 1000000, time.Local) - d2 := time.Date(2002, 8, 9, 12, 13, 56, 0000000, time.Local) - t1 := d1.UnixMilli() - t2 := d2.UnixMilli() - BeforeEach(func() { - r = req.Params(httptest.NewRequest("GET", fmt.Sprintf("/ping?t=%d&t=%d", t1, t2), nil)) - }) - - It("returns all param occurrences as []time.Time", func() { - Expect(r.Times("t")).To(Equal([]time.Time{d1, d2})) - }) - - It("returns empty string if param does not exist", func() { - v, err := r.Times("xx") - Expect(err).To(MatchError(req.ErrMissingParam)) - Expect(v).To(BeEmpty()) - }) - - It("returns current time as default if param is invalid", func() { - now := time.Now() - r = req.Params(httptest.NewRequest("GET", "/ping?t=null", nil)) - times, err := r.Times("t") - Expect(err).ToNot(HaveOccurred()) - Expect(times).To(HaveLen(1)) - Expect(times[0]).To(BeTemporally(">=", now)) - }) - }) - - Describe("ParamInt", func() { - BeforeEach(func() { - r = req.Params(httptest.NewRequest("GET", "/ping?i=123&inv=123.45", nil)) - }) - Context("int", func() { - It("returns parsed int", func() { - Expect(r.IntOr("i", 999)).To(Equal(123)) - }) - - It("returns default value if param does not exist", func() { - Expect(r.IntOr("xx", 999)).To(Equal(999)) - }) - - It("returns default value if param is an invalid int", func() { - Expect(r.IntOr("inv", 999)).To(Equal(999)) - }) - - It("returns error if param is an invalid int", func() { - _, err := r.Int("inv") - Expect(err).To(MatchError(req.ErrInvalidParam)) - }) - }) - Context("int64", func() { - It("returns parsed int64", func() { - Expect(r.Int64Or("i", 999)).To(Equal(int64(123))) - }) - - It("returns default value if param does not exist", func() { - Expect(r.Int64Or("xx", 999)).To(Equal(int64(999))) - }) - - It("returns default value if param is an invalid int", func() { - Expect(r.Int64Or("inv", 999)).To(Equal(int64(999))) - }) - - It("returns error if param is an invalid int", func() { - _, err := r.Int64("inv") - Expect(err).To(MatchError(req.ErrInvalidParam)) - }) - }) - }) - - Describe("ParamInts", func() { - BeforeEach(func() { - r = req.Params(httptest.NewRequest("GET", "/ping?i=123&i=456", nil)) - }) - - It("returns array of occurrences found", func() { - Expect(r.Ints("i")).To(Equal([]int{123, 456})) - }) - - It("returns empty array if param does not exist", func() { - v, err := r.Ints("xx") - Expect(err).To(MatchError(req.ErrMissingParam)) - Expect(v).To(BeEmpty()) - }) - }) - - Describe("ParamBool", func() { - Context("value is true", func() { - BeforeEach(func() { - r = req.Params(httptest.NewRequest("GET", "/ping?b=true&c=on&d=1&e=True", nil)) - }) - - It("parses 'true'", func() { - Expect(r.BoolOr("b", false)).To(BeTrue()) - }) - - It("parses 'on'", func() { - Expect(r.BoolOr("c", false)).To(BeTrue()) - }) - - It("parses '1'", func() { - Expect(r.BoolOr("d", false)).To(BeTrue()) - }) - - It("parses 'True'", func() { - Expect(r.BoolOr("e", false)).To(BeTrue()) - }) - }) - - Context("value is false", func() { - BeforeEach(func() { - r = req.Params(httptest.NewRequest("GET", "/ping?b=false&c=off&d=0", nil)) - }) - - It("parses 'false'", func() { - Expect(r.BoolOr("b", true)).To(BeFalse()) - }) - - It("parses 'off'", func() { - Expect(r.BoolOr("c", true)).To(BeFalse()) - }) - - It("parses '0'", func() { - Expect(r.BoolOr("d", true)).To(BeFalse()) - }) - - It("returns default value if param does not exist", func() { - Expect(r.BoolOr("xx", true)).To(BeTrue()) - }) - }) - }) - - Describe("ParamStringPtr", func() { - BeforeEach(func() { - r = req.Params(httptest.NewRequest("GET", "/ping?a=123", nil)) - }) - - It("returns pointer to string if param exists", func() { - ptr := r.StringPtr("a") - Expect(ptr).ToNot(BeNil()) - Expect(*ptr).To(Equal("123")) - }) - - It("returns nil if param does not exist", func() { - ptr := r.StringPtr("xx") - Expect(ptr).To(BeNil()) - }) - - It("returns pointer to empty string if param exists but is empty", func() { - r = req.Params(httptest.NewRequest("GET", "/ping?a=", nil)) - ptr := r.StringPtr("a") - Expect(ptr).ToNot(BeNil()) - Expect(*ptr).To(Equal("")) - }) - }) - - 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() { - r = req.Params(httptest.NewRequest("GET", "/ping?b=true", nil)) - }) - - It("returns pointer to true if param is 'true'", func() { - ptr := r.BoolPtr("b") - Expect(ptr).ToNot(BeNil()) - Expect(*ptr).To(BeTrue()) - }) - }) - - Context("value is false", func() { - BeforeEach(func() { - r = req.Params(httptest.NewRequest("GET", "/ping?b=false", nil)) - }) - - It("returns pointer to false if param is 'false'", func() { - ptr := r.BoolPtr("b") - Expect(ptr).ToNot(BeNil()) - Expect(*ptr).To(BeFalse()) - }) - }) - - It("returns nil if param does not exist", func() { - ptr := r.BoolPtr("xx") - Expect(ptr).To(BeNil()) - }) - }) -}) - -var _ = Describe("IfNoneMatch", func() { - DescribeTable("matches the ETag", - func(header string, expected bool) { - r := httptest.NewRequest("GET", "/", nil) - if header != "" { - r.Header.Set("If-None-Match", header) - } - Expect(req.IfNoneMatch(r, "abc123")).To(Equal(expected)) - }, - Entry("absent header", "", false), - Entry("exact quoted tag", `"abc123"`, true), - Entry("weak tag", `W/"abc123"`, true), - Entry("tag in a list", `"other", W/"abc123"`, true), - Entry("wildcard", "*", true), - Entry("different tag", `"stale"`, false), - ) -}) diff --git a/utils/request_helpers.go b/utils/request_helpers.go new file mode 100644 index 000000000..8f372ffe1 --- /dev/null +++ b/utils/request_helpers.go @@ -0,0 +1,101 @@ +package utils + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/navidrome/navidrome/log" +) + +func ParamString(r *http.Request, param string) string { + return r.URL.Query().Get(param) +} + +func ParamStringDefault(r *http.Request, param, def string) string { + v := ParamString(r, param) + if v == "" { + return def + } + return v +} + +func ParamStrings(r *http.Request, param string) []string { + return r.URL.Query()[param] +} + +func ParamTimes(r *http.Request, param string) []time.Time { + pStr := ParamStrings(r, param) + times := make([]time.Time, len(pStr)) + for i, t := range pStr { + ti, err := strconv.ParseInt(t, 10, 64) + if err != nil { + log.Warn(r.Context(), "Ignoring invalid time param", "time", t, err) + times[i] = time.Now() + continue + } + times[i] = ToTime(ti) + } + return times +} + +func ParamTime(r *http.Request, param string, def time.Time) time.Time { + v := ParamString(r, param) + if v == "" || v == "-1" { + return def + } + value, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return def + } + t := ToTime(value) + if t.Before(time.Date(1970, time.January, 2, 0, 0, 0, 0, time.UTC)) { + return def + } + return t +} + +func ParamInt(r *http.Request, param string, def int) int { + v := ParamString(r, param) + if v == "" { + return def + } + value, err := strconv.ParseInt(v, 10, 32) + if err != nil { + return def + } + return int(value) +} + +func ParamInt64(r *http.Request, param string, def int64) int64 { + v := ParamString(r, param) + if v == "" { + return def + } + value, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return def + } + return value +} + +func ParamInts(r *http.Request, param string) []int { + pStr := ParamStrings(r, param) + ints := make([]int, 0, len(pStr)) + for _, s := range pStr { + i, err := strconv.ParseInt(s, 10, 32) + if err == nil { + ints = append(ints, int(i)) + } + } + return ints +} + +func ParamBool(r *http.Request, param string, def bool) bool { + p := strings.ToLower(ParamString(r, param)) + if p == "" { + return def + } + return strings.Contains("/true/on/1/", "/"+p+"/") +} diff --git a/utils/request_helpers_test.go b/utils/request_helpers_test.go new file mode 100644 index 000000000..6956a286d --- /dev/null +++ b/utils/request_helpers_test.go @@ -0,0 +1,199 @@ +package utils + +import ( + "fmt" + "net/http" + "net/http/httptest" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Request Helpers", func() { + var r *http.Request + + Describe("ParamString", func() { + BeforeEach(func() { + r = httptest.NewRequest("GET", "/ping?a=123", nil) + }) + + It("returns empty string if param does not exist", func() { + Expect(ParamString(r, "xx")).To(Equal("")) + }) + + It("returns param as string", func() { + Expect(ParamString(r, "a")).To(Equal("123")) + }) + }) + + Describe("ParamStringDefault", func() { + BeforeEach(func() { + r = httptest.NewRequest("GET", "/ping?a=123", nil) + }) + + It("returns default string if param does not exist", func() { + Expect(ParamStringDefault(r, "xx", "default_value")).To(Equal("default_value")) + }) + + It("returns param as string", func() { + Expect(ParamStringDefault(r, "a", "default_value")).To(Equal("123")) + }) + }) + + Describe("ParamStrings", func() { + BeforeEach(func() { + r = httptest.NewRequest("GET", "/ping?a=123&a=456", nil) + }) + + It("returns empty array if param does not exist", func() { + Expect(ParamStrings(r, "xx")).To(BeEmpty()) + }) + + It("returns all param occurrences as []string", func() { + Expect(ParamStrings(r, "a")).To(Equal([]string{"123", "456"})) + }) + }) + + Describe("ParamTime", func() { + d := time.Date(2002, 8, 9, 12, 11, 13, 1000000, time.Local) + t := ToMillis(d) + now := time.Now() + BeforeEach(func() { + r = httptest.NewRequest("GET", fmt.Sprintf("/ping?t=%d&inv=abc", t), nil) + }) + + It("returns default time if param does not exist", func() { + Expect(ParamTime(r, "xx", now)).To(Equal(now)) + }) + + It("returns default time if param is an invalid timestamp", func() { + Expect(ParamTime(r, "inv", now)).To(Equal(now)) + }) + + It("returns parsed time", func() { + Expect(ParamTime(r, "t", now)).To(Equal(d)) + }) + }) + + Describe("ParamTimes", func() { + d1 := time.Date(2002, 8, 9, 12, 11, 13, 1000000, time.Local) + d2 := time.Date(2002, 8, 9, 12, 13, 56, 0000000, time.Local) + t1 := ToMillis(d1) + t2 := ToMillis(d2) + BeforeEach(func() { + r = httptest.NewRequest("GET", fmt.Sprintf("/ping?t=%d&t=%d", t1, t2), nil) + }) + + It("returns empty string if param does not exist", func() { + Expect(ParamTimes(r, "xx")).To(BeEmpty()) + }) + + It("returns all param occurrences as []time.Time", func() { + Expect(ParamTimes(r, "t")).To(Equal([]time.Time{d1, d2})) + }) + It("returns current time as default if param is invalid", func() { + now := time.Now() + r = httptest.NewRequest("GET", "/ping?t=null", nil) + times := ParamTimes(r, "t") + Expect(times).To(HaveLen(1)) + Expect(times[0]).To(BeTemporally(">=", now)) + }) + }) + + Describe("ParamInt", func() { + BeforeEach(func() { + r = httptest.NewRequest("GET", "/ping?i=123&inv=123.45", nil) + }) + + It("returns default value if param does not exist", func() { + Expect(ParamInt(r, "xx", 999)).To(Equal(999)) + }) + + It("returns default value if param is an invalid int", func() { + Expect(ParamInt(r, "inv", 999)).To(Equal(999)) + }) + + It("returns parsed time", func() { + Expect(ParamInt(r, "i", 999)).To(Equal(123)) + }) + }) + + Describe("ParamInt64", func() { + BeforeEach(func() { + r = httptest.NewRequest("GET", "/ping?i=123&inv=123.45", nil) + }) + + It("returns default value if param does not exist", func() { + Expect(ParamInt64(r, "xx", 999)).To(Equal(int64(999))) + }) + + It("returns default value if param is an invalid int", func() { + Expect(ParamInt64(r, "inv", 999)).To(Equal(int64(999))) + }) + + It("returns parsed time", func() { + Expect(ParamInt64(r, "i", 999)).To(Equal(int64(123))) + }) + }) + + Describe("ParamInts", func() { + BeforeEach(func() { + r = httptest.NewRequest("GET", "/ping?i=123&i=456", nil) + }) + + It("returns empty array if param does not exist", func() { + Expect(ParamInts(r, "xx")).To(BeEmpty()) + }) + + It("returns array of occurrences found", func() { + Expect(ParamInts(r, "i")).To(Equal([]int{123, 456})) + }) + }) + + Describe("ParamBool", func() { + Context("value is true", func() { + BeforeEach(func() { + r = httptest.NewRequest("GET", "/ping?b=true&c=on&d=1&e=True", nil) + }) + + It("parses 'true'", func() { + Expect(ParamBool(r, "b", false)).To(BeTrue()) + }) + + It("parses 'on'", func() { + Expect(ParamBool(r, "c", false)).To(BeTrue()) + }) + + It("parses '1'", func() { + Expect(ParamBool(r, "d", false)).To(BeTrue()) + }) + + It("parses 'True'", func() { + Expect(ParamBool(r, "e", false)).To(BeTrue()) + }) + }) + + Context("value is false", func() { + BeforeEach(func() { + r = httptest.NewRequest("GET", "/ping?b=false&c=off&d=0", nil) + }) + + It("returns default value if param does not exist", func() { + Expect(ParamBool(r, "xx", true)).To(BeTrue()) + }) + + It("parses 'false'", func() { + Expect(ParamBool(r, "b", true)).To(BeFalse()) + }) + + It("parses 'off'", func() { + Expect(ParamBool(r, "c", true)).To(BeFalse()) + }) + + It("parses '0'", func() { + Expect(ParamBool(r, "d", true)).To(BeFalse()) + }) + }) + }) +}) diff --git a/utils/run/run.go b/utils/run/run.go deleted file mode 100644 index 182eec42c..000000000 --- a/utils/run/run.go +++ /dev/null @@ -1,29 +0,0 @@ -package run - -import "golang.org/x/sync/errgroup" - -// Sequentially runs the given functions sequentially, -// If any function returns an error, it stops the execution and returns that error. -// If all functions return nil, it returns nil. -func Sequentially(fs ...func() error) error { - for _, f := range fs { - if err := f(); err != nil { - return err - } - } - return nil -} - -// Parallel runs the given functions in parallel, -// It waits for all functions to finish and returns the first error encountered. -func Parallel(fs ...func() error) func() error { - return func() error { - g := errgroup.Group{} - for _, f := range fs { - g.Go(func() error { - return f() - }) - } - return g.Wait() - } -} diff --git a/utils/run/run_test.go b/utils/run/run_test.go deleted file mode 100644 index 07d2d3994..000000000 --- a/utils/run/run_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package run_test - -import ( - "errors" - "sync/atomic" - "testing" - "time" - - "github.com/navidrome/navidrome/utils/run" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestRun(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Run Suite") -} - -var _ = Describe("Sequentially", func() { - It("should return nil if no functions are provided", func() { - err := run.Sequentially() - Expect(err).To(BeNil()) - }) - - It("should return nil if all functions succeed", func() { - err := run.Sequentially( - func() error { return nil }, - func() error { return nil }, - ) - Expect(err).To(BeNil()) - }) - - It("should return the error from the first failing function", func() { - expectedErr := errors.New("error in function 2") - err := run.Sequentially( - func() error { return nil }, - func() error { return expectedErr }, - func() error { return errors.New("error in function 3") }, - ) - Expect(err).To(Equal(expectedErr)) - }) - - It("should not run functions after the first failing function", func() { - expectedErr := errors.New("error in function 1") - var runCount int - err := run.Sequentially( - func() error { runCount++; return expectedErr }, - func() error { runCount++; return nil }, - ) - Expect(err).To(Equal(expectedErr)) - Expect(runCount).To(Equal(1)) - }) -}) - -var _ = Describe("Parallel", func() { - It("should return a function that returns nil if no functions are provided", func() { - parallelFunc := run.Parallel() - err := parallelFunc() - Expect(err).To(BeNil()) - }) - - It("should return a function that returns nil if all functions succeed", func() { - parallelFunc := run.Parallel( - func() error { return nil }, - func() error { return nil }, - func() error { return nil }, - ) - err := parallelFunc() - Expect(err).To(BeNil()) - }) - - It("should return the first error encountered when functions fail", func() { - expectedErr := errors.New("parallel error") - parallelFunc := run.Parallel( - func() error { return nil }, - func() error { return expectedErr }, - func() error { return errors.New("another error") }, - ) - err := parallelFunc() - Expect(err).To(HaveOccurred()) - // Note: We can't guarantee which error will be returned first in parallel execution - // but we can ensure an error is returned - }) - - It("should run all functions in parallel", func() { - var runCount atomic.Int32 - sync := make(chan struct{}) - - parallelFunc := run.Parallel( - func() error { - runCount.Add(1) - <-sync - runCount.Add(-1) - return nil - }, - func() error { - runCount.Add(1) - <-sync - runCount.Add(-1) - return nil - }, - func() error { - runCount.Add(1) - <-sync - runCount.Add(-1) - return nil - }, - ) - - // Run the parallel function in a goroutine - go func() { - Expect(parallelFunc()).To(Succeed()) - }() - - // Wait for all functions to start running - Eventually(func() int32 { return runCount.Load() }).Should(Equal(int32(3))) - - // Release the functions to complete - close(sync) - - // Wait for all functions to finish - Eventually(func() int32 { return runCount.Load() }).Should(Equal(int32(0))) - }) - - It("should wait for all functions to complete before returning", func() { - var completedCount atomic.Int32 - - parallelFunc := run.Parallel( - func() error { - completedCount.Add(1) - return nil - }, - func() error { - completedCount.Add(1) - return nil - }, - func() error { - completedCount.Add(1) - return nil - }, - ) - - Expect(parallelFunc()).To(Succeed()) - Expect(completedCount.Load()).To(Equal(int32(3))) - }) - - It("should return an error even if other functions are still running", func() { - expectedErr := errors.New("fast error") - var slowFunctionCompleted bool - - parallelFunc := run.Parallel( - func() error { - return expectedErr // Return error immediately - }, - func() error { - time.Sleep(50 * time.Millisecond) // Slow function - slowFunctionCompleted = true - return nil - }, - ) - - start := time.Now() - err := parallelFunc() - duration := time.Since(start) - - Expect(err).To(HaveOccurred()) - // Should wait for all functions to complete, even if one fails early - Expect(duration).To(BeNumerically(">=", 50*time.Millisecond)) - Expect(slowFunctionCompleted).To(BeTrue()) - }) -}) diff --git a/utils/sanitize_strings.go b/utils/sanitize_strings.go new file mode 100644 index 000000000..a8d9b576a --- /dev/null +++ b/utils/sanitize_strings.go @@ -0,0 +1,40 @@ +package utils + +import ( + "html" + "regexp" + "sort" + "strings" + + "github.com/deluan/sanitize" + "github.com/microcosm-cc/bluemonday" +) + +var quotesRegex = regexp.MustCompile("[“”‘’'\"\\[\\(\\{\\]\\)\\}]") + +func SanitizeStrings(text ...string) string { + sanitizedText := strings.Builder{} + for _, txt := range text { + sanitizedText.WriteString(strings.TrimSpace(sanitize.Accents(strings.ToLower(txt))) + " ") + } + words := make(map[string]struct{}) + for _, w := range strings.Fields(sanitizedText.String()) { + words[w] = struct{}{} + } + var fullText []string + for w := range words { + w = quotesRegex.ReplaceAllString(w, "") + if w != "" { + fullText = append(fullText, w) + } + } + sort.Strings(fullText) + return strings.Join(fullText, " ") +} + +var policy = bluemonday.UGCPolicy() + +func SanitizeText(text string) string { + s := policy.Sanitize(text) + return html.UnescapeString(s) +} diff --git a/utils/sanitize_strings_test.go b/utils/sanitize_strings_test.go new file mode 100644 index 000000000..393111fc5 --- /dev/null +++ b/utils/sanitize_strings_test.go @@ -0,0 +1,32 @@ +package utils + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SanitizeStrings", func() { + It("returns all lowercase chars", func() { + Expect(SanitizeStrings("Some Text")).To(Equal("some text")) + }) + + It("removes accents", func() { + Expect(SanitizeStrings("Quintão")).To(Equal("quintao")) + }) + + It("remove extra spaces", func() { + Expect(SanitizeStrings(" some text ")).To(Equal("some text")) + }) + + It("remove duplicated words", func() { + Expect(SanitizeStrings("legião urbana urbana legiÃo")).To(Equal("legiao urbana")) + }) + + It("remove symbols", func() { + Expect(SanitizeStrings("Tom’s Diner ' “40” ‘A’")).To(Equal("40 a diner toms")) + }) + + It("remove opening brackets", func() { + Expect(SanitizeStrings("[Five Years]")).To(Equal("five years")) + }) +}) diff --git a/utils/shellquote/shellquote.go b/utils/shellquote/shellquote.go deleted file mode 100644 index 685e3c2a7..000000000 --- a/utils/shellquote/shellquote.go +++ /dev/null @@ -1,115 +0,0 @@ -package shellquote - -import ( - "errors" - "strings" -) - -var ( - ErrUnterminatedSingleQuote = errors.New("unterminated single-quoted string") - ErrUnterminatedDoubleQuote = errors.New("unterminated double-quoted string") - ErrUnterminatedEscape = errors.New("unterminated backslash-escape") -) - -type state int - -const ( - stateUnquoted state = iota - stateSingleQuoted - stateDoubleQuoted -) - -// Split splits a string into words following POSIX-like shell quoting rules. -// It handles single quotes, double quotes, and backslash escapes. -func Split(input string) ([]string, error) { - var words []string - var word strings.Builder - inWord := false - parseState := stateUnquoted - - i := 0 - for i < len(input) { - ch := input[i] - - switch parseState { - case stateUnquoted: - switch { - case ch == '\\': - if i+1 >= len(input) { - return nil, ErrUnterminatedEscape - } - if input[i+1] == '\n' { - // Line continuation: skip both backslash and newline - i += 2 - continue - } - i++ - word.WriteByte(input[i]) - inWord = true - case ch == '\'': - parseState = stateSingleQuoted - inWord = true - case ch == '"': - parseState = stateDoubleQuoted - inWord = true - case ch == ' ' || ch == '\t' || ch == '\n': - if inWord { - words = append(words, word.String()) - word.Reset() - inWord = false - } - default: - word.WriteByte(ch) - inWord = true - } - - case stateSingleQuoted: - if ch == '\'' { - parseState = stateUnquoted - } else { - word.WriteByte(ch) - } - - case stateDoubleQuoted: - switch { - case ch == '"': - parseState = stateUnquoted - case ch == '\\': - if i+1 >= len(input) { - return nil, ErrUnterminatedEscape - } - next := input[i+1] - // In double quotes, backslash only escapes: $ ` " \n \ - if next == '$' || next == '`' || next == '"' || next == '\n' || next == '\\' { - if next == '\n' { - // Line continuation: skip both backslash and newline - i += 2 - continue - } - i++ - word.WriteByte(next) - } else { - // Backslash is literal for other characters - word.WriteByte(ch) - } - default: - word.WriteByte(ch) - } - } - - i++ - } - - switch parseState { - case stateSingleQuoted: - return nil, ErrUnterminatedSingleQuote - case stateDoubleQuoted: - return nil, ErrUnterminatedDoubleQuote - } - - if inWord { - words = append(words, word.String()) - } - - return words, nil -} diff --git a/utils/shellquote/shellquote_test.go b/utils/shellquote/shellquote_test.go deleted file mode 100644 index 889b83a5f..000000000 --- a/utils/shellquote/shellquote_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package shellquote_test - -import ( - "testing" - - "github.com/navidrome/navidrome/utils/shellquote" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestShellquote(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Shellquote Suite") -} - -var _ = Describe("Split", func() { - It("splits simple space-separated words", func() { - words, err := shellquote.Split("a b c") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"a", "b", "c"})) - }) - - It("handles multiple spaces between words", func() { - words, err := shellquote.Split("a b") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"a", "b"})) - }) - - It("handles single-quoted strings", func() { - words, err := shellquote.Split("'hello world'") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"hello world"})) - }) - - It("handles double-quoted strings", func() { - words, err := shellquote.Split(`"hello world"`) - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"hello world"})) - }) - - It("handles backslash escapes in unquoted mode", func() { - words, err := shellquote.Split(`hello\ world`) - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"hello world"})) - }) - - It("handles escaped quotes inside double quotes", func() { - words, err := shellquote.Split(`"hello \" world"`) - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{`hello " world`})) - }) - - It("handles mixed quoting in a single argument", func() { - words, err := shellquote.Split("he'llo wo'rld") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"hello world"})) - }) - - It("returns empty slice for empty input", func() { - words, err := shellquote.Split("") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(BeEmpty()) - }) - - It("returns empty slice for whitespace-only input", func() { - words, err := shellquote.Split(" ") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(BeEmpty()) - }) - - It("returns error for unterminated single quote", func() { - _, err := shellquote.Split("'hello") - Expect(err).To(MatchError(shellquote.ErrUnterminatedSingleQuote)) - }) - - It("returns error for unterminated double quote", func() { - _, err := shellquote.Split(`"hello`) - Expect(err).To(MatchError(shellquote.ErrUnterminatedDoubleQuote)) - }) - - It("returns error for unterminated escape", func() { - _, err := shellquote.Split(`hello\`) - Expect(err).To(MatchError(shellquote.ErrUnterminatedEscape)) - }) - - It("handles tabs and newlines as delimiters", func() { - words, err := shellquote.Split("a\tb\nc") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"a", "b", "c"})) - }) - - It("parses the default MPV command template", func() { - words, err := shellquote.Split("mpv --audio-device=%d --no-audio-display --pause %f --input-ipc-server=%s") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(HaveLen(6)) - Expect(words).To(Equal([]string{ - "mpv", - "--audio-device=%d", - "--no-audio-display", - "--pause", - "%f", - "--input-ipc-server=%s", - })) - }) - - It("preserves spaces in quoted paths", func() { - words, err := shellquote.Split(`--ao-pcm-file="/audio/my folder/file"`) - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{`--ao-pcm-file=/audio/my folder/file`})) - }) - - It("handles backslash in double quotes for special chars", func() { - words, err := shellquote.Split(`"hello\\world"`) - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{`hello\world`})) - }) - - It("preserves backslash in double quotes for non-special chars", func() { - words, err := shellquote.Split(`"hello\nworld"`) - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{`hello\nworld`})) - }) - - It("handles escaped newline in double quotes", func() { - words, err := shellquote.Split("\"hello\\\nworld\"") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"helloworld"})) - }) - - // Cases from original go-shellquote test suite - It("handles shell glob characters as literals", func() { - words, err := shellquote.Split("glob* test?") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"glob*", "test?"})) - }) - - It("handles backslash-escaped special characters", func() { - words, err := shellquote.Split("don\\'t you know the dewey decimal system\\?") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"don't", "you", "know", "the", "dewey", "decimal", "system?"})) - }) - - It("handles single-quote escape idiom", func() { - // Shell idiom: end single-quote, escaped literal quote, start single-quote again - words, err := shellquote.Split("'don'\\''t you know the dewey decimal system?'") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"don't you know the dewey decimal system?"})) - }) - - It("handles empty string argument via quotes", func() { - words, err := shellquote.Split("one '' two") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"one", "", "two"})) - }) - - It("handles backslash-newline joining words in unquoted mode", func() { - words, err := shellquote.Split("text with\\\na backslash-escaped newline") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"text", "witha", "backslash-escaped", "newline"})) - }) - - It("handles quoted newline inside double quotes", func() { - words, err := shellquote.Split("text \"with\na\" quoted newline") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"text", "with\na", "quoted", "newline"})) - }) - - It("handles complex double-quoted escapes with backslash-newline", func() { - words, err := shellquote.Split("\"quoted\\d\\\\\\\" text with\\\na backslash-escaped newline\"") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"quoted\\d\\\" text witha backslash-escaped newline"})) - }) - - It("handles backslash-newline between words", func() { - words, err := shellquote.Split("text with an escaped \\\n newline in the middle") - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"text", "with", "an", "escaped", "newline", "in", "the", "middle"})) - }) - - It("handles double-quoted substring concatenation", func() { - words, err := shellquote.Split(`foo"bar"baz`) - Expect(err).ToNot(HaveOccurred()) - Expect(words).To(Equal([]string{"foobarbaz"})) - }) - - It("returns error for unterminated quote after escape idiom", func() { - _, err := shellquote.Split("'test'\\''ing") - Expect(err).To(MatchError(shellquote.ErrUnterminatedSingleQuote)) - }) - - It("returns error for unterminated double quote with single quote inside", func() { - _, err := shellquote.Split("\"foo'bar") - Expect(err).To(MatchError(shellquote.ErrUnterminatedDoubleQuote)) - }) - - It("returns error for unterminated escape with leading whitespace", func() { - _, err := shellquote.Split(" \\") - Expect(err).To(MatchError(shellquote.ErrUnterminatedEscape)) - }) -}) diff --git a/utils/singleton/singleton.go b/utils/singleton/singleton.go index 8271034ad..e1202e19c 100644 --- a/utils/singleton/singleton.go +++ b/utils/singleton/singleton.go @@ -3,77 +3,49 @@ package singleton import ( "fmt" "reflect" - "sync" "github.com/navidrome/navidrome/log" ) var ( - instances = map[string]any{} - pending = map[string]chan struct{}{} - lock sync.RWMutex + instances = make(map[string]any) + getOrCreateC = make(chan entry) ) +type entry struct { + f func() any + object any + resultC chan any +} + +// GetInstance returns an existing instance of object. If it is not yet created, calls `constructor`, stores the +// result for future calls and return it func GetInstance[T any](constructor func() T) T { - var v T - name := reflect.TypeOf(v).String() - - // First check with read lock - lock.RLock() - if instance, ok := instances[name]; ok { - defer lock.RUnlock() - return instance.(T) + var t T + e := entry{ + object: t, + f: func() any { + return constructor() + }, + resultC: make(chan any), } - lock.RUnlock() - - // Now check if someone is already creating this type - lock.Lock() - - // Check again with the write lock - someone might have created it - if instance, ok := instances[name]; ok { - lock.Unlock() - return instance.(T) - } - - // Check if creation is pending - wait, isPending := pending[name] - if !isPending { - // We'll be the one creating it - pending[name] = make(chan struct{}) - wait = pending[name] - } - lock.Unlock() - - // If someone else is creating it, wait for them - if isPending { - <-wait // Wait for creation to complete - - // Now it should be in the instances map - lock.RLock() - defer lock.RUnlock() - return instances[name].(T) - } - - // We're responsible for creating the instance - newInstance := constructor() - - // Store it and signal other goroutines - lock.Lock() - instances[name] = newInstance - close(wait) // Signal that creation is complete - delete(pending, name) // Clean up - log.Trace("Created new singleton", "type", name, "instance", fmt.Sprintf("%+v", newInstance)) - lock.Unlock() - - return newInstance + getOrCreateC <- e + v := <-e.resultC + return v.(T) } -// DeleteInstance drops the cached instance of type T so the next GetInstance rebuilds it. -// Intended for tests and benchmarks that need a fresh instance regardless of run order. -func DeleteInstance[T any]() { - var v T - name := reflect.TypeOf(v).String() - lock.Lock() - delete(instances, name) - lock.Unlock() +func init() { + go func() { + for { + e := <-getOrCreateC + name := reflect.TypeOf(e.object).String() + v, created := instances[name] + if !created { + v = e.f() + log.Trace("Created new singleton", "type", name, "instance", fmt.Sprintf("%+v", v)) + instances[name] = v + } + e.resultC <- v + } + }() } diff --git a/utils/singleton/singleton_test.go b/utils/singleton/singleton_test.go index 08e635652..b187731a9 100644 --- a/utils/singleton/singleton_test.go +++ b/utils/singleton/singleton_test.go @@ -5,7 +5,8 @@ import ( "sync/atomic" "testing" - "github.com/navidrome/navidrome/model/id" + "github.com/google/uuid" + "github.com/navidrome/navidrome/utils/singleton" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -18,15 +19,15 @@ func TestSingleton(t *testing.T) { var _ = Describe("GetInstance", func() { type T struct{ id string } - var numInstancesCreated int + var numInstances int constructor := func() *T { - numInstancesCreated++ - return &T{id: id.NewRandom()} + numInstances++ + return &T{id: uuid.NewString()} } It("calls the constructor to create a new instance", func() { instance := singleton.GetInstance(constructor) - Expect(numInstancesCreated).To(Equal(1)) + Expect(numInstances).To(Equal(1)) Expect(instance).To(BeAssignableToTypeOf(&T{})) }) @@ -35,68 +36,49 @@ var _ = Describe("GetInstance", func() { newInstance := singleton.GetInstance(constructor) Expect(newInstance.id).To(Equal(instance.id)) - Expect(numInstancesCreated).To(Equal(1)) + Expect(numInstances).To(Equal(1)) }) It("makes a distinction between a type and its pointer", func() { instance := singleton.GetInstance(constructor) newInstance := singleton.GetInstance(func() T { - numInstancesCreated++ - return T{id: id.NewRandom()} + numInstances++ + return T{id: uuid.NewString()} }) Expect(instance).To(BeAssignableToTypeOf(&T{})) Expect(newInstance).To(BeAssignableToTypeOf(T{})) Expect(newInstance.id).ToNot(Equal(instance.id)) - Expect(numInstancesCreated).To(Equal(2)) + Expect(numInstances).To(Equal(2)) }) It("only calls the constructor once when called concurrently", func() { - // This test creates 80000 goroutines that call GetInstance concurrently. If the constructor is called more than once, the test will fail. - const numCallsToDo = 80000 - var numCallsDone atomic.Uint32 - - // This WaitGroup is used to make sure all goroutines are ready before the test starts - prepare := sync.WaitGroup{} - prepare.Add(numCallsToDo) - - // This WaitGroup is used to synchronize the start of all goroutines as simultaneous as possible + const maxCalls = 8000 + var numCalls int32 start := sync.WaitGroup{} start.Add(1) - - // This WaitGroup is used to wait for all goroutines to be done + prepare := sync.WaitGroup{} + prepare.Add(maxCalls) done := sync.WaitGroup{} - done.Add(numCallsToDo) - - numInstancesCreated = 0 - for range numCallsToDo { + done.Add(maxCalls) + numInstances = 0 + for i := 0; i < maxCalls; i++ { go func() { - // This is needed to make sure the test does not hang if it fails - defer GinkgoRecover() - - // Wait for all goroutines to be ready start.Wait() - instance := singleton.GetInstance(func() struct{ I int } { - numInstancesCreated++ - return struct{ I int }{I: numInstancesCreated} + singleton.GetInstance(func() struct{ I int } { + numInstances++ + return struct{ I int }{I: 1} }) - // Increment the number of calls done - numCallsDone.Add(1) - - // Flag the main WaitGroup that this goroutine is done + atomic.AddInt32(&numCalls, 1) done.Done() - - // Make sure the instance we get is always the same one - Expect(instance.I).To(Equal(1)) }() - // Flag that this goroutine is ready to start prepare.Done() } - prepare.Wait() // Wait for all goroutines to be ready - start.Done() // Start all goroutines - done.Wait() // Wait for all goroutines to be done + prepare.Wait() + start.Done() + done.Wait() - Expect(numCallsDone.Load()).To(Equal(uint32(numCallsToDo))) - Expect(numInstancesCreated).To(Equal(1)) + Expect(numCalls).To(Equal(int32(maxCalls))) + Expect(numInstances).To(Equal(1)) }) }) diff --git a/utils/slice/slice.go b/utils/slice/slice.go index e3677e754..cd012330b 100644 --- a/utils/slice/slice.go +++ b/utils/slice/slice.go @@ -1,15 +1,5 @@ package slice -import ( - "bufio" - "bytes" - "cmp" - "io" - "iter" - "maps" - "slices" -) - func Map[T any, R any](t []T, mapFunc func(T) R) []R { r := make([]R, len(t)) for i, e := range t { @@ -18,12 +8,6 @@ func Map[T any, R any](t []T, mapFunc func(T) R) []R { return r } -func MapWithArg[I any, O any, A any](t []I, arg A, mapFunc func(A, I) O) []O { - return Map(t, func(e I) O { - return mapFunc(arg, e) - }) -} - func Group[T any, K comparable](s []T, keyFunc func(T) K) map[K][]T { m := map[K][]T{} for _, item := range s { @@ -33,71 +17,25 @@ func Group[T any, K comparable](s []T, keyFunc func(T) K) map[K][]T { return m } -// GroupOrdered is Group with the groups in first-seen order. -func GroupOrdered[T any, K comparable](s []T, keyFunc func(T) K) [][]T { - var groups [][]T - index := map[K]int{} - for _, item := range s { - k := keyFunc(item) - i, ok := index[k] - if !ok { - i = len(groups) - index[k] = i - groups = append(groups, nil) - } - groups[i] = append(groups[i], item) - } - return groups -} - -func ToMap[T any, K comparable, V any](s []T, transformFunc func(T) (K, V)) map[K]V { - m := make(map[K]V, len(s)) - for _, item := range s { - k, v := transformFunc(item) - m[k] = v - } - return m -} - -// ToSet builds a set (a map keyed by the slice's elements) for O(1) membership tests. Duplicate -// elements collapse to a single key. -func ToSet[T comparable](s []T) map[T]struct{} { - m := make(map[T]struct{}, len(s)) - for _, item := range s { - m[item] = struct{}{} - } - return m -} - -func CompactByFrequency[T comparable](list []T) []T { - counters := make(map[T]int) - for _, item := range list { - counters[item]++ - } - - return slices.SortedFunc(maps.Keys(counters), func(i, j T) int { - return cmp.Compare(counters[j], counters[i]) - }) -} - func MostFrequent[T comparable](list []T) T { - var zero T if len(list) == 0 { + var zero T return zero } - - counters := make(map[T]int) var topItem T var topCount int + counters := map[T]int{} - for _, value := range list { - if value == zero { - continue - } - counters[value]++ - if counters[value] > topCount { - topItem = value - topCount = counters[value] + if len(list) == 1 { + topItem = list[0] + } else { + for _, id := range list { + c := counters[id] + 1 + counters[id] = c + if c > topCount { + topItem = id + topCount = c + } } } @@ -116,93 +54,3 @@ func Move[T any](slice []T, srcIndex int, dstIndex int) []T { value := slice[srcIndex] return Insert(Remove(slice, srcIndex), value, dstIndex) } - -func Unique[T comparable](list []T) []T { - seen := make(map[T]struct{}) - var result []T - for _, item := range list { - if _, ok := seen[item]; !ok { - seen[item] = struct{}{} - result = append(result, item) - } - } - return result -} - -// LinesFrom returns a Seq that reads lines from the given reader -func LinesFrom(reader io.Reader) iter.Seq[string] { - return func(yield func(string) bool) { - scanner := bufio.NewScanner(reader) - scanner.Split(scanLines) - for scanner.Scan() { - if !yield(scanner.Text()) { - return - } - } - } -} - -// From https://stackoverflow.com/a/41433698 -func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) { - if atEOF && len(data) == 0 { - return 0, nil, nil - } - if i := bytes.IndexAny(data, "\r\n"); i >= 0 { - if data[i] == '\n' { - // We have a line terminated by single newline. - return i + 1, data[0:i], nil - } - advance = i + 1 - if len(data) > i+1 && data[i+1] == '\n' { - advance += 1 - } - return advance, data[0:i], nil - } - // If we're at EOF, we have a final, non-terminated line. Return it. - if atEOF { - return len(data), data, nil - } - // Request more data. - return 0, nil, nil -} - -// CollectChunks collects chunks of n elements from the input sequence and return a Seq of chunks -func CollectChunks[T any](it iter.Seq[T], n int) iter.Seq[[]T] { - return func(yield func([]T) bool) { - s := make([]T, 0, n) - for x := range it { - s = append(s, x) - if len(s) >= n { - if !yield(s) { - return - } - s = make([]T, 0, n) - } - } - if len(s) > 0 { - yield(s) - } - } -} - -// SeqFunc returns a Seq that iterates over the slice with the given mapping function -func SeqFunc[I, O any](s []I, f func(I) O) iter.Seq[O] { - return func(yield func(O) bool) { - for _, x := range s { - if !yield(f(x)) { - return - } - } - } -} - -// Filter returns a new slice containing only the elements of s for which filterFunc returns true -func Filter[T any](s []T, filterFunc func(T) bool) []T { - var result []T - for _, item := range s { - if filterFunc(item) { - result = append(result, item) - } - } - return result -} diff --git a/utils/slice/slice_test.go b/utils/slice/slice_test.go index 506b61b9f..a27416f3d 100644 --- a/utils/slice/slice_test.go +++ b/utils/slice/slice_test.go @@ -1,19 +1,15 @@ package slice_test import ( - "os" - "slices" "strconv" "testing" - "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) func TestSlice(t *testing.T) { - tests.Init(t, false) RegisterFailHandler(Fail) RunSpecs(t, "Slice Suite") } @@ -33,20 +29,6 @@ var _ = Describe("Slice Utils", func() { }) }) - Describe("MapWithArg", func() { - It("returns empty slice for an empty input", func() { - mapFunc := func(a int, v int) string { return strconv.Itoa(a + v) } - result := slice.MapWithArg([]int{}, 10, mapFunc) - Expect(result).To(BeEmpty()) - }) - - It("returns a new slice with elements mapped", func() { - mapFunc := func(a int, v int) string { return strconv.Itoa(a + v) } - result := slice.MapWithArg([]int{1, 2, 3, 4}, 10, mapFunc) - Expect(result).To(ConsistOf("11", "12", "13", "14")) - }) - }) - Describe("Group", func() { It("returns empty map for an empty input", func() { keyFunc := func(v int) int { return v % 2 } @@ -63,60 +45,6 @@ var _ = Describe("Slice Utils", func() { }) }) - Describe("GroupOrdered", func() { - It("returns nil for an empty input", func() { - Expect(slice.GroupOrdered([]int{}, func(v int) int { return v })).To(BeNil()) - }) - - It("keeps groups in first-seen order and items in input order", func() { - keyFunc := func(v int) int { return v % 3 } - result := slice.GroupOrdered([]int{2, 1, 4, 3, 5, 6, 8}, keyFunc) - Expect(result).To(Equal([][]int{{2, 5, 8}, {1, 4}, {3, 6}})) - }) - }) - - Describe("ToMap", func() { - It("returns empty map for an empty input", func() { - transformFunc := func(v int) (int, string) { return v, strconv.Itoa(v) } - result := slice.ToMap([]int{}, transformFunc) - Expect(result).To(BeEmpty()) - }) - - It("returns a map with the result of the transform function", func() { - transformFunc := func(v int) (int, string) { return v * 2, strconv.Itoa(v * 2) } - result := slice.ToMap([]int{1, 2, 3, 4}, transformFunc) - Expect(result).To(HaveLen(4)) - Expect(result).To(HaveKeyWithValue(2, "2")) - Expect(result).To(HaveKeyWithValue(4, "4")) - Expect(result).To(HaveKeyWithValue(6, "6")) - Expect(result).To(HaveKeyWithValue(8, "8")) - }) - }) - - Describe("ToSet", func() { - It("returns empty set for an empty input", func() { - Expect(slice.ToSet([]int{})).To(BeEmpty()) - }) - - It("builds a set with one key per distinct element", func() { - result := slice.ToSet([]int{1, 2, 2, 3, 3, 3}) - Expect(result).To(HaveLen(3)) - Expect(result).To(HaveKey(1)) - Expect(result).To(HaveKey(2)) - Expect(result).To(HaveKey(3)) - }) - }) - - Describe("CompactByFrequency", func() { - It("returns empty slice for an empty input", func() { - Expect(slice.CompactByFrequency([]int{})).To(BeEmpty()) - }) - - It("groups by frequency", func() { - Expect(slice.CompactByFrequency([]int{1, 2, 1, 2, 3, 2})).To(HaveExactElements(2, 1, 3)) - }) - }) - Describe("MostFrequent", func() { It("returns zero value if no arguments are passed", func() { Expect(slice.MostFrequent([]int{})).To(BeZero()) @@ -128,112 +56,17 @@ var _ = Describe("Slice Utils", func() { It("returns the item that appeared more times", func() { Expect(slice.MostFrequent([]string{"1", "2", "1", "2", "3", "2"})).To(Equal("2")) }) - It("ignores zero values", func() { - Expect(slice.MostFrequent([]int{0, 0, 0, 2, 2})).To(Equal(2)) - }) }) Describe("Move", func() { It("moves item to end of slice", func() { - Expect(slice.Move([]string{"1", "2", "3"}, 0, 2)).To(HaveExactElements("2", "3", "1")) + Expect(slice.Move([]string{"1", "2", "3"}, 0, 2)).To(ConsistOf("2", "3", "1")) }) It("moves item to beginning of slice", func() { - Expect(slice.Move([]string{"1", "2", "3"}, 2, 0)).To(HaveExactElements("3", "1", "2")) + Expect(slice.Move([]string{"1", "2", "3"}, 2, 0)).To(ConsistOf("3", "1", "2")) }) It("keeps item in same position if srcIndex == dstIndex", func() { - Expect(slice.Move([]string{"1", "2", "3"}, 1, 1)).To(HaveExactElements("1", "2", "3")) - }) - }) - - Describe("Unique", func() { - It("returns empty slice for an empty input", func() { - Expect(slice.Unique([]int{})).To(BeEmpty()) - }) - - It("returns the unique elements", func() { - Expect(slice.Unique([]int{1, 2, 1, 2, 3, 2})).To(HaveExactElements(1, 2, 3)) - }) - }) - - DescribeTable("LinesFrom", - func(path string, expected int) { - count := 0 - file, _ := os.Open(path) - defer file.Close() - for range slice.LinesFrom(file) { - count++ - } - Expect(count).To(Equal(expected)) - }, - Entry("returns empty slice for an empty input", "tests/fixtures/empty.txt", 0), - Entry("returns the lines of a file", "tests/fixtures/playlists/pls1.m3u", 2), - Entry("returns empty if file does not exist", "tests/fixtures/NON-EXISTENT", 0), - ) - - DescribeTable("CollectChunks", - func(input []int, n int, expected [][]int) { - var result [][]int - for chunks := range slice.CollectChunks(slices.Values(input), n) { - result = append(result, chunks) - } - Expect(result).To(Equal(expected)) - }, - Entry("returns empty slice (nil) for an empty input", []int{}, 1, nil), - Entry("returns the slice in one chunk if len < chunkSize", []int{1, 2, 3}, 10, [][]int{{1, 2, 3}}), - Entry("breaks up the slice if len > chunkSize", []int{1, 2, 3, 4, 5}, 3, [][]int{{1, 2, 3}, {4, 5}}), - ) - - Describe("SeqFunc", func() { - It("returns empty slice for an empty input", func() { - it := slice.SeqFunc([]int{}, func(v int) int { return v }) - - result := slices.Collect(it) - Expect(result).To(BeEmpty()) - }) - - It("returns a new slice with mapped elements", func() { - it := slice.SeqFunc([]int{1, 2, 3, 4}, func(v int) string { return strconv.Itoa(v * 2) }) - - result := slices.Collect(it) - Expect(result).To(ConsistOf("2", "4", "6", "8")) - }) - }) - - Describe("Filter", func() { - It("returns empty slice for an empty input", func() { - filterFunc := func(v int) bool { return v > 0 } - result := slice.Filter([]int{}, filterFunc) - Expect(result).To(BeEmpty()) - }) - - It("returns all elements when filter matches all", func() { - filterFunc := func(v int) bool { return v > 0 } - result := slice.Filter([]int{1, 2, 3, 4}, filterFunc) - Expect(result).To(HaveExactElements(1, 2, 3, 4)) - }) - - It("returns empty slice when filter matches none", func() { - filterFunc := func(v int) bool { return v > 10 } - result := slice.Filter([]int{1, 2, 3, 4}, filterFunc) - Expect(result).To(BeEmpty()) - }) - - It("returns only matching elements", func() { - filterFunc := func(v int) bool { return v%2 == 0 } - result := slice.Filter([]int{1, 2, 3, 4, 5, 6}, filterFunc) - Expect(result).To(HaveExactElements(2, 4, 6)) - }) - - It("works with string slices", func() { - filterFunc := func(s string) bool { return len(s) > 3 } - result := slice.Filter([]string{"a", "abc", "abcd", "ab", "abcde"}, filterFunc) - Expect(result).To(HaveExactElements("abcd", "abcde")) - }) - - It("preserves order of elements", func() { - filterFunc := func(v int) bool { return v%2 == 1 } - result := slice.Filter([]int{9, 8, 7, 6, 5, 4, 3, 2, 1}, filterFunc) - Expect(result).To(HaveExactElements(9, 7, 5, 3, 1)) + Expect(slice.Move([]string{"1", "2", "3"}, 1, 1)).To(ConsistOf("1", "2", "3")) }) }) }) diff --git a/utils/str/content_disposition.go b/utils/str/content_disposition.go deleted file mode 100644 index 34ae8a5c6..000000000 --- a/utils/str/content_disposition.go +++ /dev/null @@ -1,80 +0,0 @@ -package str - -import ( - "cmp" - "fmt" - "mime" - "path" - "strings" - "unicode" - "unicode/utf8" - - "github.com/deluan/sanitize" -) - -const ( - maxFilenameBytes = 255 - maxExtensionBytes = 16 - fallbackFilename = "download" -) - -// ContentDispositionAttachment builds an RFC 6266 attachment header value for a user-controlled filename. -// Non-ASCII names also get a filename* parameter, which clients prefer over the ASCII fallback. -func ContentDispositionAttachment(filename string) string { - stem, ext := splitFilename(filename) - header := fmt.Sprintf("attachment; filename=%q", joinFilename(toASCII(stem), toASCII(ext))) - name := joinFilename(stem, ext) - if isASCII(name) { - return header - } - // FormatMediaType emits a percent-encoded filename* for non-ASCII values - extended := mime.FormatMediaType("attachment", map[string]string{"filename": name}) - return header + strings.TrimPrefix(extended, "attachment") -} - -func splitFilename(filename string) (stem, ext string) { - name := strings.Map(func(r rune) rune { - if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) { - return -1 - } - return r - }, SanitizeFilename(strings.ToValidUTF8(filename, "_"))) - ext = path.Ext(name) - if len(ext) > maxExtensionBytes { - ext = "" - } - stem = strings.TrimSuffix(name, ext) - if limit := maxFilenameBytes - len(ext); len(stem) > limit { - for limit > 0 && !utf8.RuneStart(stem[limit]) { - limit-- - } - stem = stem[:limit] - } - return stem, ext -} - -func joinFilename(stem, ext string) string { - stem = cmp.Or(strings.TrimSpace(stem), fallbackFilename) - return cmp.Or(strings.TrimRight(stem+ext, " ."), fallbackFilename) -} - -func toASCII(s string) string { - return strings.Map(func(r rune) rune { - switch { - case r == ',': - return '_' - case r >= ' ' && r <= '~': - return r - } - return -1 - }, sanitize.Accents(SanitizeFilename(Clear(s)))) -} - -func isASCII(s string) bool { - for i := 0; i < len(s); i++ { - if s[i] >= utf8.RuneSelf { - return false - } - } - return true -} diff --git a/utils/str/content_disposition_test.go b/utils/str/content_disposition_test.go deleted file mode 100644 index 944392a4e..000000000 --- a/utils/str/content_disposition_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package str_test - -import ( - "mime" - "regexp" - "strings" - - "github.com/navidrome/navidrome/utils/str" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var asciiFilenameRe = regexp.MustCompile(`(?:^|; )filename="([^"]*)"`) - -// asciiName returns the quoted filename parameter, the one used by clients that ignore filename*. -func asciiName(filename string) string { - m := asciiFilenameRe.FindAllStringSubmatch(str.ContentDispositionAttachment(filename), -1) - ExpectWithOffset(1, m).To(HaveLen(1), "expected exactly one quoted filename parameter") - return m[0][1] -} - -// decodedName returns the name an RFC 6266 client picks, preferring filename* when present. -func decodedName(filename string) string { - disp, params, err := mime.ParseMediaType(str.ContentDispositionAttachment(filename)) - ExpectWithOffset(1, err).ToNot(HaveOccurred()) - ExpectWithOffset(1, disp).To(Equal("attachment")) - return params["filename"] -} - -var _ = Describe("ContentDispositionAttachment", func() { - Describe("parameter injection", func() { - const attack = `party"; filename="evil.html.m3u` - - It("does not let a quote in the name open a second parameter", func() { - Expect(str.ContentDispositionAttachment(attack)).To(Equal(`attachment; filename="party_; filename=_evil.html.m3u"`)) - }) - - It("keeps the header parseable", func() { - Expect(decodedName(attack)).To(Equal("party_; filename=_evil.html.m3u")) - }) - - It("does not let a quote in a non-ASCII name inject either", func() { - const utf8Attack = `東京"; filename="evil.html.m3u` - header := str.ContentDispositionAttachment(utf8Attack) - Expect(strings.Count(header, `"`)).To(Equal(2)) - Expect(decodedName(utf8Attack)).To(Equal("東京_; filename=_evil.html.m3u")) - }) - }) - - Describe("ASCII names", func() { - It("sends only the quoted filename, unchanged", func() { - Expect(str.ContentDispositionAttachment("My Playlist.m3u")).To(Equal(`attachment; filename="My Playlist.m3u"`)) - }) - - It("keeps leading dots", func() { - Expect(asciiName("...And Justice for All.zip")).To(Equal("...And Justice for All.zip")) - }) - - It("trims surrounding spaces and trailing dots", func() { - Expect(asciiName(" Greatest Hits .zip")).To(Equal("Greatest Hits.zip")) - Expect(asciiName("Loose End. ")).To(Equal("Loose End")) - }) - - It("falls back to a placeholder when only dots are left", func() { - Expect(str.ContentDispositionAttachment("...")).To(Equal(`attachment; filename="download"`)) - }) - - It("caps a long name whose last dot is not an extension", func() { - name := asciiName("a." + strings.Repeat("x", 300)) - Expect(len(name)).To(BeNumerically("<=", 255)) - Expect(name).To(HavePrefix("a.xxx")) - }) - - It("replaces path separators and reserved characters", func() { - Expect(asciiName("AC/DC: Live, 1979?.zip")).To(Equal("AC_DC_ Live_ 1979_.zip")) - }) - - It("drops control characters", func() { - Expect(asciiName("line\r\nbreak\x00\t.mp3")).To(Equal("linebreak.mp3")) - }) - }) - - Describe("non-ASCII names", func() { - It("transliterates accents in the ASCII fallback", func() { - Expect(asciiName("Legião Urbana.zip")).To(Equal("Legiao Urbana.zip")) - }) - - It("converts typographic punctuation in the ASCII fallback", func() { - Expect(asciiName("She’s a Woman — Live.mp3")).To(Equal("She's a Woman - Live.mp3")) - Expect(asciiName("“Heroes”.mp3")).To(Equal("_Heroes_.mp3")) - Expect(decodedName("She’s a Woman.mp3")).To(Equal("She’s a Woman.mp3")) - }) - - It("keeps the extension when no ASCII letters survive", func() { - Expect(asciiName("東京.mp3")).To(Equal("download.mp3")) - Expect(asciiName("Кино.m3u")).To(Equal("download.m3u")) - Expect(asciiName("東京")).To(Equal("download")) - }) - - DescribeTable("filename* carries the sanitized UTF-8 name", - func(filename, expected string) { - Expect(decodedName(filename)).To(Equal(expected)) - }, - Entry("accented", "Legião Urbana.zip", "Legião Urbana.zip"), - Entry("CJK", "東京.zip", "東京.zip"), - Entry("emoji", "🎵 mix.zip", "🎵 mix.zip"), - Entry("comma and percent", "Sigur Rós, 100%.zip", "Sigur Rós, 100%.zip"), - Entry("path separators", "Sigur Rós/Live: Heima.zip", "Sigur Rós_Live_ Heima.zip"), - Entry("control characters", "Sigur Rós\r\n\x00.zip", "Sigur Rós.zip"), - Entry("bidi override", "Björk\u202Eexe.mp3", "Björkexe.mp3"), - Entry("invalid UTF-8", "Bj\xf6rk Café.mp3", "Bj_rk Café.mp3"), - ) - - It("caps the name length and keeps the extension", func() { - name := decodedName(strings.Repeat("東", 300) + ".zip") - Expect(len(name)).To(BeNumerically("<=", 255)) - Expect(name).To(HaveSuffix("東.zip")) - }) - }) -}) diff --git a/utils/str/normalize_fts.go b/utils/str/normalize_fts.go deleted file mode 100644 index 994f77cc9..000000000 --- a/utils/str/normalize_fts.go +++ /dev/null @@ -1,45 +0,0 @@ -package str - -import ( - "regexp" - "strings" - - "github.com/deluan/sanitize" -) - -// FTSPunctStrip matches any character that is not a letter or number. Index-time -// normalization (NormalizeForFTS) and query-time processing in persistence share it -// so both sides produce matching tokens. -var FTSPunctStrip = regexp.MustCompile(`[^\p{L}\p{N}]`) - -// 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.FieldsSeq(v) { - transliterated := sanitize.Accents(word) - // Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne. - add(word, FTSPunctStrip.ReplaceAllString(transliterated, "")) - // Accent-only transliteration for words without name-punctuation (Bjørk → Bjork). - add(word, transliterated) - } - } - return strings.Join(result, " ") -} diff --git a/utils/str/normalize_fts_test.go b/utils/str/normalize_fts_test.go deleted file mode 100644 index 52387cb1d..000000000 --- a/utils/str/normalize_fts_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package str_test - -import ( - "github.com/navidrome/navidrome/utils/str" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = DescribeTable("NormalizeForFTS", - func(expected string, values ...string) { - Expect(str.NormalizeForFTS(values...)).To(Equal(expected)) - }, - Entry("strips dots and concatenates", "REM", "R.E.M."), - Entry("strips slash", "ACDC", "AC/DC"), - Entry("strips hyphen", "Aha", "A-ha"), - Entry("skips unchanged 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"), -) diff --git a/utils/str/sanitize_strings.go b/utils/str/sanitize_strings.go deleted file mode 100644 index 11f828270..000000000 --- a/utils/str/sanitize_strings.go +++ /dev/null @@ -1,90 +0,0 @@ -package str - -import ( - "html" - "regexp" - "slices" - "strings" - - "github.com/deluan/sanitize" - "github.com/microcosm-cc/bluemonday" - "github.com/navidrome/navidrome/conf" -) - -var ignoredCharsRegex = regexp.MustCompile("[“”‘’'\"\\[({\\])},]") -var slashRemover = strings.NewReplacer("\\", " ", "/", " ") - -func SanitizeStrings(text ...string) string { - // Concatenate all strings, removing extra spaces - sanitizedText := strings.Builder{} - for _, txt := range text { - sanitizedText.WriteString(strings.TrimSpace(txt)) - sanitizedText.WriteByte(' ') - } - - // Remove special symbols, accents, extra spaces and slashes - sanitizedStrings := slashRemover.Replace(Clear(sanitizedText.String())) - sanitizedStrings = sanitize.Accents(strings.ToLower(sanitizedStrings)) - sanitizedStrings = ignoredCharsRegex.ReplaceAllString(sanitizedStrings, "") - fullText := strings.Fields(sanitizedStrings) - - // Remove duplicated words - slices.Sort(fullText) - fullText = slices.Compact(fullText) - - // Returns the sanitized text as a single string - return strings.Join(fullText, " ") -} - -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)) -} - -func SanitizeFieldForSortingNoArticle(originalValue string) string { - v := strings.TrimSpace(sanitize.Accents(originalValue)) - return Clear(strings.ToLower(strings.TrimSpace(RemoveArticle(v)))) -} - -func RemoveArticle(name string) string { - articles := strings.SplitSeq(conf.Server.IgnoredArticles, " ") - for a := range articles { - n := strings.TrimPrefix(name, a+" ") - if n != name { - return n - } - } - return name -} diff --git a/utils/str/sanitize_strings_test.go b/utils/str/sanitize_strings_test.go deleted file mode 100644 index 6527f326b..000000000 --- a/utils/str/sanitize_strings_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package str_test - -import ( - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/utils/str" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("Sanitize Strings", func() { - Describe("SanitizeStrings", func() { - It("returns all lowercase chars", func() { - Expect(str.SanitizeStrings("Some Text")).To(Equal("some text")) - }) - - It("removes accents", func() { - Expect(str.SanitizeStrings("Quintão")).To(Equal("quintao")) - }) - - It("remove extra spaces", func() { - Expect(str.SanitizeStrings(" some text ", "text some")).To(Equal("some text")) - }) - - It("remove duplicated words", func() { - Expect(str.SanitizeStrings("legião urbana", "urbana legiÃo")).To(Equal("legiao urbana")) - }) - - It("remove symbols", func() { - Expect(str.SanitizeStrings("Tom’s Diner ' “40” ‘A’")).To(Equal("40 a diner toms")) - }) - - It("remove opening brackets", func() { - Expect(str.SanitizeStrings("[Five Years]")).To(Equal("five years")) - }) - - It("remove slashes", func() { - Expect(str.SanitizeStrings("folder/file\\yyyy")).To(Equal("file folder yyyy")) - }) - - It("normalizes utf chars", func() { - // These uses different types of hyphens - Expect(str.SanitizeStrings("k—os", "k−os")).To(Equal("k-os")) - }) - - It("remove commas", func() { - // This is specially useful for handling cases where the Sort field uses comma. - // It reduces the size of the resulting string, thus reducing the size of the DB table and indexes. - Expect(str.SanitizeStrings("Bob Marley", "Marley, Bob")).To(Equal("bob marley")) - }) - }) - - Describe("SanitizeFieldForSorting", func() { - BeforeEach(func() { - conf.Server.IgnoredArticles = "The O" - }) - It("sanitize accents", func() { - Expect(str.SanitizeFieldForSorting("Céu")).To(Equal("ceu")) - }) - It("removes articles", func() { - Expect(str.SanitizeFieldForSorting("The Beatles")).To(Equal("the beatles")) - }) - It("removes accented articles", func() { - Expect(str.SanitizeFieldForSorting("Õ Blésq Blom")).To(Equal("o blesq blom")) - }) - }) - - 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("<b>ok</b>")) - }) - }) - - Describe("SanitizeHTML", func() { - It("removes dangerous content from raw html", func() { - sanitized := str.SanitizeHTML(`<img src=x onerror=alert(1)><script>alert(2)</script><b>ok</b>`) - - Expect(sanitized).To(ContainSubstring("<b>ok</b>")) - Expect(sanitized).ToNot(ContainSubstring("onerror")) - Expect(sanitized).ToNot(ContainSubstring("<script")) - }) - - It("removes dangerous content from entity-encoded html", func() { - sanitized := str.SanitizeHTML(`<img src=x onerror=alert(1)><script>alert(2)</script><b>ok</b>`) - - Expect(sanitized).To(ContainSubstring("<b>ok</b>")) - Expect(sanitized).ToNot(ContainSubstring("onerror")) - Expect(sanitized).ToNot(ContainSubstring("<script")) - }) - }) - - Describe("SanitizeFieldForSortingNoArticle", func() { - BeforeEach(func() { - conf.Server.IgnoredArticles = "The O" - }) - It("sanitize accents", func() { - Expect(str.SanitizeFieldForSortingNoArticle("Céu")).To(Equal("ceu")) - }) - It("removes articles", func() { - Expect(str.SanitizeFieldForSortingNoArticle("The Beatles")).To(Equal("beatles")) - }) - It("removes accented articles", func() { - Expect(str.SanitizeFieldForSortingNoArticle("Õ Blésq Blom")).To(Equal("blesq blom")) - }) - }) - - Describe("RemoveArticle", func() { - Context("Empty articles list", func() { - BeforeEach(func() { - conf.Server.IgnoredArticles = "" - }) - It("returns empty if string is empty", func() { - Expect(str.RemoveArticle("")).To(BeEmpty()) - }) - It("returns same string", func() { - Expect(str.RemoveArticle("The Beatles")).To(Equal("The Beatles")) - }) - }) - Context("Default articles", func() { - BeforeEach(func() { - conf.Server.IgnoredArticles = "The El La Los Las Le Les Os As O A" - }) - It("returns empty if string is empty", func() { - Expect(str.RemoveArticle("")).To(BeEmpty()) - }) - It("remove prefix article from string", func() { - Expect(str.RemoveArticle("Os Paralamas do Sucesso")).To(Equal("Paralamas do Sucesso")) - }) - It("does not remove article if it is part of the first word", func() { - Expect(str.RemoveArticle("Thelonious Monk")).To(Equal("Thelonious Monk")) - }) - }) - }) -}) diff --git a/utils/str/str.go b/utils/str/str.go deleted file mode 100644 index 177b48191..000000000 --- a/utils/str/str.go +++ /dev/null @@ -1,61 +0,0 @@ -package str - -import ( - "strings" - "unicode/utf8" -) - -var utf8ToAscii = func() *strings.Replacer { - var utf8Map = map[string]string{ - "'": `‘’‛′`, - `"`: `"〃ˮײ᳓″‶˶ʺ“”˝‟`, - "-": `‐–—−―`, - } - - list := make([]string, 0, len(utf8Map)*2) - for ascii, utf8 := range utf8Map { - for _, r := range utf8 { - list = append(list, string(r), ascii) - } - } - return strings.NewReplacer(list...) -}() - -func Clear(name string) string { - return utf8ToAscii.Replace(name) -} - -func LongestCommonPrefix(list []string) string { - if len(list) == 0 { - return "" - } - - for l := 0; l < len(list[0]); l++ { - c := list[0][l] - for i := 1; i < len(list); i++ { - if l >= len(list[i]) || list[i][l] != c { - return list[i][0:l] - } - } - } - return list[0] -} - -// TruncateRunes truncates a string to a maximum number of runes, adding a suffix if truncated. -// The suffix is included in the rune count, so if maxRunes is 30 and suffix is "...", the actual -// string content will be truncated to fit within the maxRunes limit including the suffix. -func TruncateRunes(s string, maxRunes int, suffix string) string { - if utf8.RuneCountInString(s) <= maxRunes { - return s - } - - suffixRunes := utf8.RuneCountInString(suffix) - truncateAt := max(maxRunes-suffixRunes, 0) - - runes := []rune(s) - if truncateAt >= len(runes) { - return s + suffix - } - - return string(runes[:truncateAt]) + suffix -} diff --git a/utils/str/str_suite_test.go b/utils/str/str_suite_test.go deleted file mode 100644 index 15cb12783..000000000 --- a/utils/str/str_suite_test.go +++ /dev/null @@ -1,13 +0,0 @@ -package str_test - -import ( - "testing" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -func TestStrClear(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Str Suite") -} diff --git a/utils/str/str_test.go b/utils/str/str_test.go deleted file mode 100644 index 511805831..000000000 --- a/utils/str/str_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package str_test - -import ( - "github.com/navidrome/navidrome/utils/str" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("String Utils", func() { - Describe("Clear", func() { - DescribeTable("replaces some Unicode chars with their equivalent ASCII", - func(input, expected string) { - Expect(str.Clear(input)).To(Equal(expected)) - }, - Entry("k-os", "k–os", "k-os"), - Entry("k‐os", "k‐os", "k-os"), - Entry(`"Weird" Al Yankovic`, "“Weird” Al Yankovic", `"Weird" Al Yankovic`), - Entry("Single quotes", "‘Single’ quotes", "'Single' quotes"), - ) - }) - - Describe("LongestCommonPrefix", func() { - It("finds the longest common prefix", func() { - Expect(str.LongestCommonPrefix(testPaths)).To(Equal("/Music/iTunes 1/iTunes Media/Music/")) - }) - It("does NOT handle partial prefixes", func() { - albums := []string{ - "/artist/albumOne", - "/artist/albumTwo", - } - Expect(str.LongestCommonPrefix(albums)).To(Equal("/artist/album")) - }) - }) - - Describe("TruncateRunes", func() { - It("returns string unchanged if under max runes", func() { - Expect(str.TruncateRunes("hello", 10, "...")).To(Equal("hello")) - }) - - It("returns string unchanged if exactly at max runes", func() { - Expect(str.TruncateRunes("hello", 5, "...")).To(Equal("hello")) - }) - - It("truncates and adds suffix when over max runes", func() { - Expect(str.TruncateRunes("hello world", 8, "...")).To(Equal("hello...")) - }) - - It("handles unicode characters correctly", func() { - // 6 emoji characters, maxRunes=5, suffix="..." (3 runes) - // So content gets 5-3=2 runes - Expect(str.TruncateRunes("😀😁😂😃😄😅", 5, "...")).To(Equal("😀😁...")) - }) - - It("handles multi-byte UTF-8 characters", func() { - // Characters like é are single runes - Expect(str.TruncateRunes("Café au Lait", 5, "...")).To(Equal("Ca...")) - }) - - It("works with empty suffix", func() { - Expect(str.TruncateRunes("hello world", 5, "")).To(Equal("hello")) - }) - - It("accounts for suffix length in truncation", func() { - // maxRunes=10, suffix="..." (3 runes) -> leaves 7 runes for content - result := str.TruncateRunes("hello world this is long", 10, "...") - Expect(result).To(Equal("hello w...")) - // Verify total rune count is <= maxRunes - runeCount := len([]rune(result)) - Expect(runeCount).To(BeNumerically("<=", 10)) - }) - - It("handles very long suffix gracefully", func() { - // If suffix is longer than maxRunes, we still add it - // but the content will be truncated to 0 - result := str.TruncateRunes("hello world", 5, "... (truncated)") - // Result will be just the suffix (since truncateAt=0) - Expect(result).To(Equal("... (truncated)")) - }) - - It("handles empty string", func() { - Expect(str.TruncateRunes("", 10, "...")).To(Equal("")) - }) - - It("uses custom suffix", func() { - // maxRunes=11, suffix=" [...]" (6 runes) -> content gets 5 runes - // "hello world" is 11 runes exactly, so we need a longer string - Expect(str.TruncateRunes("hello world extra", 11, " [...]")).To(Equal("hello [...]")) - }) - - DescribeTable("truncates at rune boundaries (not byte boundaries)", - func(input string, maxRunes int, suffix string, expected string) { - Expect(str.TruncateRunes(input, maxRunes, suffix)).To(Equal(expected)) - }, - Entry("ASCII", "abcdefghij", 5, "...", "ab..."), - Entry("Mixed ASCII and Unicode", "ab😀cd", 4, ".", "ab😀."), - Entry("All emoji", "😀😁😂😃😄", 3, "…", "😀😁…"), - Entry("Japanese", "こんにちは世界", 3, "…", "こん…"), - ) - }) -}) - -var testPaths = []string{ - "/Music/iTunes 1/iTunes Media/Music/ABBA/Gold_ Greatest Hits/Dancing Queen.m4a", - "/Music/iTunes 1/iTunes Media/Music/ABBA/Gold_ Greatest Hits/Mamma Mia.m4a", - "/Music/iTunes 1/iTunes Media/Music/Art Blakey/A Night At Birdland, Vol. 1/01 Annoucement By Pee Wee Marquette.m4a", - "/Music/iTunes 1/iTunes Media/Music/Art Blakey/A Night At Birdland, Vol. 1/02 Split Kick.m4a", - "/Music/iTunes 1/iTunes Media/Music/As Frenéticas/As Frenéticas/Perigosa.m4a", - "/Music/iTunes 1/iTunes Media/Music/Bachman-Turner Overdrive/Gold/Down Down.m4a", - "/Music/iTunes 1/iTunes Media/Music/Bachman-Turner Overdrive/Gold/Hey You.m4a", - "/Music/iTunes 1/iTunes Media/Music/Bachman-Turner Overdrive/Gold/Hold Back The Water.m4a", - "/Music/iTunes 1/iTunes Media/Music/Belle And Sebastian/Write About Love/01 I Didn't See It Coming.m4a", - "/Music/iTunes 1/iTunes Media/Music/Belle And Sebastian/Write About Love/02 Come On Sister.m4a", - "/Music/iTunes 1/iTunes Media/Music/Black Eyed Peas/Elephunk/03 Let's Get Retarded.m4a", - "/Music/iTunes 1/iTunes Media/Music/Black Eyed Peas/Elephunk/04 Hey Mama.m4a", - "/Music/iTunes 1/iTunes Media/Music/Black Eyed Peas/Monkey Business/10 They Don't Want Music (Feat. James Brown).m4a", - "/Music/iTunes 1/iTunes Media/Music/Black Eyed Peas/The E.N.D/1-01 Boom Boom Pow.m4a", - "/Music/iTunes 1/iTunes Media/Music/Black Eyed Peas/Timeless/01 Mas Que Nada.m4a", - "/Music/iTunes 1/iTunes Media/Music/Blondie/Heart Of Glass/Heart Of Glass.m4a", - "/Music/iTunes 1/iTunes Media/Music/Bob Dylan/Nashville Skyline/06 Lay Lady Lay.m4a", - "/Music/iTunes 1/iTunes Media/Music/Botany/Feeling Today - EP/03 Waterparker.m4a", - "/Music/iTunes 1/iTunes Media/Music/Céu/CéU/06 10 Contados.m4a", - "/Music/iTunes 1/iTunes Media/Music/Chance/Six Through Ten/03 Forgive+Forget.m4a", - "/Music/iTunes 1/iTunes Media/Music/Clive Tanaka Y Su Orquesta/Jet Set Siempre 1°/03 Neu Chicago (Side A) [For Dance].m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Absolute Rock Classics/1-02 Smoke on the water.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Almost Famous Soundtrack/10 Simple Man.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Audio News - Rock'n' Roll Forever/01 Rock Around The Clock.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Austin Powers_ International Man Of Mystery/01 The Magic Piper (Of Love).m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Austin Powers_ The Spy Who Shagged Me/04 American Woman.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Back To Dance/03 Long Cool Woman In A Black Dress.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Back To The 70's - O Album Da Década/03 American Pie.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Bambolê/09 In The Mood.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Bambolê - Volume II/03 Blue Moon.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Big Brother Brasil 2004/04 I Will Survive.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Collateral Soundtrack/03 Hands Of Time.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Forrest Gump - The Soundtrack/1-12 California Dreamin'.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Forrest Gump - The Soundtrack/1-16 Mrs. Robinson.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Ghost World - Original Motion Picture Soundtrack/01 Jaan Pechechaan Ho.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Grease [Original Soundtrack]/01 Grease.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/La Bamba/09 Summertime Blues.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Pretty Woman/10 Oh Pretty Woman.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents African Groove/01 Saye Mogo Bana.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents Arabic Groove/02 Galbi.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents Asian Groove/03 Remember Tomorrow.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/01 Midnight Dream.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/03 Banal Reality.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/04 Parchman Blues.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents Blues Lounge/06 Run On.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents Brazilian Groove/01 Maria Moita.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents Brazilian Lounge/08 E Depois....m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents Brazilian Lounge/11 Os Grilos.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents Euro Lounge/01 Un Simple Histoire.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents Euro Lounge/02 Limbe.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents Euro Lounge/05 Sempre Di Domenica.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents Euro Lounge/12 Voulez-Vous_.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents World Lounge/03 Santa Maria.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents_ A New Groove/02 Dirty Laundry.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents_ Blues Around the World/02 Canceriano Sem Lar (Clinica Tobias Blues).m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents_ Euro Groove/03 Check In.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Putumayo Presents_ World Groove/01 Attention.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Saturday Night Fever/01 Stayin' Alive.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/Saturday Night Fever/03 Night Fever.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/The Best Air Guitar Album In The World... Ever!/2-06 Johnny B. Goode.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/The Full Monty - Soundtrack/02 You Sexy Thing.m4a", - "/Music/iTunes 1/iTunes Media/Music/Compilations/The Full Monty - Soundtrack/11 We Are Family.m4a", - "/Music/iTunes 1/iTunes Media/Music/Cut Copy/Zonoscope (Bonus Version)/10 Corner of the Sky.m4a", - "/Music/iTunes 1/iTunes Media/Music/David Bowie/Changesbowie/07 Diamond Dogs.m4a", - "/Music/iTunes 1/iTunes Media/Music/Douster & Savage Skulls/Get Rich or High Tryin' - EP/01 Bad Gal.m4a", - "/Music/iTunes 1/iTunes Media/Music/Elton John/Greatest Hits 1970-2002/1-04 Rocket Man (I Think It's Going to Be a Long, Long Time).m4a", - "/Music/iTunes 1/iTunes Media/Music/Elvis Presley/ELV1S 30 #1 Hits/02 Don't Be Cruel.m4a", - "/Music/iTunes 1/iTunes Media/Music/Eric Clapton/The Cream Of Clapton/03 I Feel Free.m4a", - "/Music/iTunes 1/iTunes Media/Music/Fleetwood Mac/The Very Best Of Fleetwood Mac/02 Don't Stop.m4a", - "/Music/iTunes 1/iTunes Media/Music/Françoise Hardy/Comment te dire adieu/Comment te dire adieu.m4a", - "/Music/iTunes 1/iTunes Media/Music/Games/That We Can Play - EP/01 Strawberry Skies.m4a", - "/Music/iTunes 1/iTunes Media/Music/Grand Funk Railroad/Collectors Series/The Loco-Motion.m4a", - "/Music/iTunes 1/iTunes Media/Music/Henry Mancini/The Pink Panther (Music from the Film Score)/The Pink Panther Theme.m4a", - "/Music/iTunes 1/iTunes Media/Music/Holy Ghost!/Do It Again - Single/01 Do It Again.m4a", - "/Music/iTunes 1/iTunes Media/Music/K.C. & The Sunshine Band/The Best of/03 I'm Your Boogie Man.m4a", - "/Music/iTunes 1/iTunes Media/Music/K.C. & The Sunshine Band/Unknown Album/Megamix (Thats The Way, Shake Your Booty, Get Down Tonight, Give It Up).m4a", - "/Music/iTunes 1/iTunes Media/Music/Kim Ann Foxman & Andy Butler/Creature - EP/01 Creature.m4a", - "/Music/iTunes 1/iTunes Media/Music/Nico/Chelsea Girl/01 The Fairest Of The Seasons.m4a", - "/Music/iTunes 1/iTunes Media/Music/oOoOO/oOoOO - EP/02 Burnout Eyess.m4a", - "/Music/iTunes 1/iTunes Media/Music/Peter Frampton/The Very Best of Peter Frampton/Baby, I Love Your Way.m4a", - "/Music/iTunes 1/iTunes Media/Music/Peter Frampton/The Very Best of Peter Frampton/Show Me The Way.m4a", - "/Music/iTunes 1/iTunes Media/Music/Raul Seixas/A Arte De Raul Seixas/03 Metamorfose Ambulante.m4a", - "/Music/iTunes 1/iTunes Media/Music/Raul Seixas/A Arte De Raul Seixas/18 Eu Nasci há 10 Mil Anos Atrás.m4a", - "/Music/iTunes 1/iTunes Media/Music/Rick James/Street Songs/Super Freak.m4a", - "/Music/iTunes 1/iTunes Media/Music/Rita Lee/Fruto Proibido/Agora Só Falta Você.m4a", - "/Music/iTunes 1/iTunes Media/Music/Rita Lee/Fruto Proibido/Esse Tal De Roque Enrow.m4a", - "/Music/iTunes 1/iTunes Media/Music/Roberto Carlos/Roberto Carlos 1966/05 Negro Gato.m4a", - "/Music/iTunes 1/iTunes Media/Music/SOHO/Goddess/02 Hippychick.m4a", - "/Music/iTunes 1/iTunes Media/Music/Stan Getz/Getz_Gilberto/05 Corcovado (Quiet Nights of Quiet Stars).m4a", - "/Music/iTunes 1/iTunes Media/Music/Steely Dan/Pretzel Logic/Rikki Don't Loose That Number.m4a", - "/Music/iTunes 1/iTunes Media/Music/Stevie Wonder/For Once In My Life/I Don't Know Why.m4a", - "/Music/iTunes 1/iTunes Media/Music/Teebs/Ardour/While You Doooo.m4a", - "/Music/iTunes 1/iTunes Media/Music/The Beatles/Magical Mystery Tour/08 Strawberry Fields Forever.m4a", - "/Music/iTunes 1/iTunes Media/Music/The Beatles/Past Masters, Vol. 1/10 Long Tall Sally.m4a", - "/Music/iTunes 1/iTunes Media/Music/The Beatles/Please Please Me/14 Twist And Shout.m4a", - "/Music/iTunes 1/iTunes Media/Music/The Beatles/Sgt. Pepper's Lonely Hearts Club Band/03 Lucy In The Sky With Diamonds.m4a", - "/Music/iTunes 1/iTunes Media/Music/The Black Crowes/Amorica/09 Wiser Time.m4a", - "/Music/iTunes 1/iTunes Media/Music/The Black Crowes/By Your Side/05 Only A Fool.m4a", - "/Music/iTunes 1/iTunes Media/Music/The Black Crowes/Shake Your Money Maker/04 Could I''ve Been So Blind.m4a", - "/Music/iTunes 1/iTunes Media/Music/The Black Crowes/The Southern Harmony And Musical Companion/01 Sting Me.m4a", - "/Music/iTunes 1/iTunes Media/Music/The Black Crowes/Three Snakes And One Charm/02 Good Friday.m4a", - "/Music/iTunes 1/iTunes Media/Music/The Doors/Strange Days (40th Anniversary Mixes)/01 Strange Days.m4a", - "/Music/iTunes 1/iTunes Media/Music/The Rolling Stones/Forty Licks/1-03 (I Can't Get No) Satisfaction.m4a", - "/Music/iTunes 1/iTunes Media/Music/The Velvet Underground/The Velvet Underground & Nico/02 I'm Waiting For The Man.m4a", - "/Music/iTunes 1/iTunes Media/Music/The Velvet Underground/The Velvet Underground & Nico/03 Femme Fatale.m4a", - "/Music/iTunes 1/iTunes Media/Music/The Velvet Underground/White Light_White Heat/04 Here She Comes Now.m4a", - "/Music/iTunes 1/iTunes Media/Music/The Who/Sings My Generation/My Generation.m4a", - "/Music/iTunes 1/iTunes Media/Music/Village People/The Very Best Of Village People/Macho Man.m4a", - "/Music/iTunes 1/iTunes Media/Music/Vondelpark/Sauna - EP/01 California Analog Dream.m4a", - "/Music/iTunes 1/iTunes Media/Music/War/Why Can't We Be Friends/Low Rider.m4a", - "/Music/iTunes 1/iTunes Media/Music/Yes/Fragile/01 Roundabout.m4a", -} diff --git a/utils/strings.go b/utils/strings.go new file mode 100644 index 000000000..2753aeacf --- /dev/null +++ b/utils/strings.go @@ -0,0 +1,76 @@ +package utils + +import ( + "strings" + + "github.com/navidrome/navidrome/conf" +) + +func NoArticle(name string) string { + articles := strings.Split(conf.Server.IgnoredArticles, " ") + for _, a := range articles { + n := strings.TrimPrefix(name, a+" ") + if n != name { + return n + } + } + return name +} + +func BreakUpStringSlice(items []string, chunkSize int) [][]string { + numTracks := len(items) + var chunks [][]string + for i := 0; i < numTracks; i += chunkSize { + end := i + chunkSize + if end > numTracks { + end = numTracks + } + + chunks = append(chunks, items[i:end]) + } + return chunks +} + +func RangeByChunks(items []string, chunkSize int, cb func([]string) error) error { + chunks := BreakUpStringSlice(items, chunkSize) + for _, chunk := range chunks { + err := cb(chunk) + if err != nil { + return err + } + } + return nil +} + +func LongestCommonPrefix(list []string) string { + if len(list) == 0 { + return "" + } + + for l := 0; l < len(list[0]); l++ { + c := list[0][l] + for i := 1; i < len(list); i++ { + if l >= len(list[i]) || list[i][l] != c { + return list[i][0:l] + } + } + } + return list[0] +} + +func SplitFunc(delimiter rune) func(c rune) bool { + open := 0 + return func(c rune) bool { + if c == '(' { + open++ + return false + } + if open > 0 { + if c == ')' { + open-- + } + return false + } + return c == delimiter + } +} diff --git a/utils/strings_test.go b/utils/strings_test.go new file mode 100644 index 000000000..706ae6843 --- /dev/null +++ b/utils/strings_test.go @@ -0,0 +1,93 @@ +package utils + +import ( + "strings" + + "github.com/navidrome/navidrome/conf" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Strings", func() { + Describe("NoArticle", func() { + Context("Empty articles list", func() { + BeforeEach(func() { + conf.Server.IgnoredArticles = "" + }) + It("returns empty if string is empty", func() { + Expect(NoArticle("")).To(BeEmpty()) + }) + It("returns same string", func() { + Expect(NoArticle("The Beatles")).To(Equal("The Beatles")) + }) + }) + Context("Default articles", func() { + BeforeEach(func() { + conf.Server.IgnoredArticles = "The El La Los Las Le Les Os As O A" + }) + It("returns empty if string is empty", func() { + Expect(NoArticle("")).To(BeEmpty()) + }) + It("remove prefix article from string", func() { + Expect(NoArticle("Os Paralamas do Sucesso")).To(Equal("Paralamas do Sucesso")) + }) + It("does not remove article if it is part of the first word", func() { + Expect(NoArticle("Thelonious Monk")).To(Equal("Thelonious Monk")) + }) + }) + }) + + Describe("BreakUpStringSlice", func() { + It("returns no chunks if slice is empty", func() { + var slice []string + chunks := BreakUpStringSlice(slice, 10) + Expect(chunks).To(HaveLen(0)) + }) + It("returns the slice in one chunk if len < chunkSize", func() { + slice := []string{"a", "b", "c"} + chunks := BreakUpStringSlice(slice, 10) + Expect(chunks).To(HaveLen(1)) + Expect(chunks[0]).To(ConsistOf("a", "b", "c")) + }) + It("breaks up the slice if len > chunkSize", func() { + slice := []string{"a", "b", "c", "d", "e"} + chunks := BreakUpStringSlice(slice, 3) + Expect(chunks).To(HaveLen(2)) + Expect(chunks[0]).To(ConsistOf("a", "b", "c")) + Expect(chunks[1]).To(ConsistOf("d", "e")) + }) + }) + + Describe("LongestCommonPrefix", func() { + var testPaths = []string{ + "/Music/iTunes 1/iTunes Media/Music/ABBA/Gold_ Greatest Hits/Dancing Queen.m4a", + "/Music/iTunes 1/iTunes Media/Music/ABBA/Gold_ Greatest Hits/Mamma Mia.m4a", + "/Music/iTunes 1/iTunes Media/Music/Bachman-Turner Overdrive/Gold/Down Down.m4a", + "/Music/iTunes 1/iTunes Media/Music/Bachman-Turner Overdrive/Gold/Hey You.m4a", + "/Music/iTunes 1/iTunes Media/Music/Bachman-Turner Overdrive/Gold/Hold Back The Water.m4a", + "/Music/iTunes 1/iTunes Media/Music/Compilations/Saturday Night Fever/01 Stayin' Alive.m4a", + "/Music/iTunes 1/iTunes Media/Music/Compilations/Saturday Night Fever/03 Night Fever.m4a", + "/Music/iTunes 1/iTunes Media/Music/Yes/Fragile/01 Roundabout.m4a", + } + + It("finds the longest common prefix", func() { + Expect(LongestCommonPrefix(testPaths)).To(Equal("/Music/iTunes 1/iTunes Media/Music/")) + }) + }) + + Describe("SplitFunc", func() { + DescribeTable("when splitting strings with a delimiter", + func(delimiter rune, input string, expected []string) { + splitFunc := SplitFunc(delimiter) + actual := strings.FieldsFunc(input, splitFunc) + Expect(actual).To(Equal(expected)) + }, + Entry("should split strings without parentheses", ',', "name,age,email", []string{"name", "age", "email"}), + Entry("should not split strings within parentheses", ',', "name, substr(email, 0, 3), age", []string{"name", " substr(email, 0, 3)", " age"}), + Entry("should handle multiple delimiters outside parentheses", ';', "name;age;email", []string{"name", "age", "email"}), + Entry("should return the whole input as a single element if the delimiter is not found", ';', "name,age,email", []string{"name,age,email"}), + Entry("should handle empty input", ',', "", []string{}), + Entry("should handle input with only delimiters", ',', ",,,", []string{}), + ) + }) +}) diff --git a/utils/time.go b/utils/time.go index b3f1a98fb..0d070a052 100644 --- a/utils/time.go +++ b/utils/time.go @@ -1,75 +1,12 @@ package utils -import ( - "fmt" - "regexp" - "strconv" - "strings" - "time" -) +import "time" -func TimeNewest(times ...time.Time) time.Time { - newest := time.Time{} - for _, t := range times { - if t.After(newest) { - newest = t - } - } - return newest +func ToTime(millis int64) time.Time { + t := time.Unix(0, millis*int64(time.Millisecond)) + return t.Local() } -var durationDayWeekRe = regexp.MustCompile(`-?\d+(?:\.\d+)?[dw]`) - -// ParseDuration is time.ParseDuration extended with d (24h) and w (168h) units. -// Negative durations are rejected. -func ParseDuration(s string) (time.Duration, error) { - expanded := durationDayWeekRe.ReplaceAllStringFunc(s, func(match string) string { - value, err := strconv.ParseFloat(match[:len(match)-1], 64) - if err != nil { - return match - } - hours := value * 24 - if match[len(match)-1] == 'w' { - hours = value * 24 * 7 - } - return strconv.FormatFloat(hours, 'f', -1, 64) + "h" - }) - d, err := time.ParseDuration(expanded) - if err != nil { - return 0, fmt.Errorf("invalid duration %q: %w", s, err) - } - if d < 0 { - return 0, fmt.Errorf("negative duration not allowed: %q", s) - } - return d, nil -} - -// FormatDuration renders whole w/d multiples with those units, falling back to -// time.Duration.String for the sub-day remainder, so ParseDuration round-trips. -func FormatDuration(d time.Duration) string { - if d < 24*time.Hour { - return formatSubDay(d) - } - var b strings.Builder - weekDuration := 7 * 24 * time.Hour - if weeks := d / weekDuration; weeks > 0 { - b.WriteString(strconv.Itoa(int(weeks)) + "w") - d %= weekDuration - } - dayDuration := 24 * time.Hour - if days := d / dayDuration; days > 0 { - b.WriteString(strconv.Itoa(int(days)) + "d") - d %= dayDuration - } - if d > 0 { - b.WriteString(formatSubDay(d)) - } - return b.String() -} - -func formatSubDay(d time.Duration) string { - if d >= time.Hour && d%time.Hour == 0 { - return strconv.Itoa(int(d/time.Hour)) + "h" - } - return d.String() +func ToMillis(t time.Time) int64 { + return t.UnixNano() / int64(time.Millisecond) } diff --git a/utils/time_test.go b/utils/time_test.go index 8460b98f9..1f7e01970 100644 --- a/utils/time_test.go +++ b/utils/time_test.go @@ -1,99 +1,16 @@ -package utils_test +package utils import ( "time" - "github.com/navidrome/navidrome/utils" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) -var _ = Describe("TimeNewest", func() { - It("returns zero time when no times are provided", func() { - Expect(utils.TimeNewest()).To(Equal(time.Time{})) - }) - - It("returns the time when only one time is provided", func() { - t1 := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) - Expect(utils.TimeNewest(t1)).To(Equal(t1)) - }) - - It("returns the newest time when multiple times are provided", func() { - t1 := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) - t2 := time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC) - t3 := time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC) - - Expect(utils.TimeNewest(t1, t2, t3)).To(Equal(t2)) +var _ = Describe("Time Conversion", func() { + It("converts from Date to Millis and back to Date", func() { + date := time.Date(2002, 8, 9, 12, 11, 13, 1000000, time.Local) + milli := ToMillis(date) + Expect(ToTime(milli)).To(Equal(date)) }) }) - -var _ = Describe("ParseDuration", func() { - DescribeTable("parses valid durations", - func(input string, expected time.Duration) { - d, err := utils.ParseDuration(input) - Expect(err).ToNot(HaveOccurred()) - Expect(d).To(Equal(expected)) - }, - Entry("standard Go units", "90m", 90*time.Minute), - Entry("hours", "12h", 12*time.Hour), - Entry("days", "1d", 24*time.Hour), - Entry("weeks", "1w", 7*24*time.Hour), - Entry("multiple days", "3d", 72*time.Hour), - Entry("mixed day and hours", "1d12h", 36*time.Hour), - Entry("mixed week, day and hours", "1w2d3h", (7*24+2*24+3)*time.Hour), - Entry("fractional days", "0.5d", 12*time.Hour), - ) - - DescribeTable("rejects invalid durations", - func(input string) { - _, err := utils.ParseDuration(input) - Expect(err).To(HaveOccurred()) - }, - Entry("empty string", ""), - Entry("not a duration", "tomorrow"), - Entry("bare number", "42"), - Entry("unit only", "d"), - Entry("unknown unit", "5y"), - ) - - DescribeTable("rejects negative durations", - func(input string) { - _, err := utils.ParseDuration(input) - Expect(err).To(MatchError(ContainSubstring("negative duration"))) - }, - Entry("negative days", "-1d"), - Entry("negative weeks", "-0.5w"), - Entry("negative Go units", "-30m"), - ) -}) - -var _ = Describe("FormatDuration", func() { - DescribeTable("formats durations using the largest whole units", - func(input time.Duration, expected string) { - Expect(utils.FormatDuration(input)).To(Equal(expected)) - }, - Entry("whole weeks", 7*24*time.Hour, "1w"), - Entry("whole days", 24*time.Hour, "1d"), - Entry("multiple days", 72*time.Hour, "3d"), - Entry("day and hours", 36*time.Hour, "1d12h"), - Entry("week, day and hours", (7*24+2*24+3)*time.Hour, "1w2d3h"), - Entry("hours only", 12*time.Hour, "12h"), - Entry("sub-hour", 90*time.Minute, "1h30m0s"), - Entry("zero", time.Duration(0), "0s"), - ) - - DescribeTable("round-trips through ParseDuration", - func(input string) { - d, err := utils.ParseDuration(input) - Expect(err).ToNot(HaveOccurred()) - formatted := utils.FormatDuration(d) - d2, err := utils.ParseDuration(formatted) - Expect(err).ToNot(HaveOccurred()) - Expect(d2).To(Equal(d)) - }, - Entry("1d", "1d"), - Entry("1w", "1w"), - Entry("1d12h", "1d12h"), - Entry("90m", "90m"), - ) -}) diff --git a/utils/weighted_random_chooser.go b/utils/weighted_random_chooser.go new file mode 100644 index 000000000..0b4ab905b --- /dev/null +++ b/utils/weighted_random_chooser.go @@ -0,0 +1,67 @@ +package utils + +import ( + "errors" + + "github.com/navidrome/navidrome/utils/number" +) + +type WeightedChooser struct { + entries []interface{} + weights []int + totalWeight int +} + +func NewWeightedRandomChooser() *WeightedChooser { + return &WeightedChooser{} +} + +func (w *WeightedChooser) Add(value interface{}, weight int) { + w.entries = append(w.entries, value) + w.weights = append(w.weights, weight) + w.totalWeight += weight +} + +// GetAndRemove choose a random entry based on their weights, and removes it from the list +func (w *WeightedChooser) GetAndRemove() (interface{}, error) { + if w.totalWeight == 0 { + return nil, errors.New("cannot choose from zero weight") + } + i, err := w.weightedChoice() + if err != nil { + return nil, err + } + entry := w.entries[i] + w.Remove(i) + return entry, nil +} + +// Based on https://eli.thegreenplace.net/2010/01/22/weighted-random-generation-in-python/ +func (w *WeightedChooser) weightedChoice() (int, error) { + if w.totalWeight == 0 { + return 0, errors.New("no choices available") + } + rnd := number.RandomInt64(int64(w.totalWeight)) + for i, weight := range w.weights { + rnd -= int64(weight) + if rnd < 0 { + return i, nil + } + } + return 0, errors.New("internal error - code should not reach this point") +} + +func (w *WeightedChooser) Remove(i int) { + w.totalWeight -= w.weights[i] + + w.weights[i] = w.weights[len(w.weights)-1] + w.weights = w.weights[:len(w.weights)-1] + + w.entries[i] = w.entries[len(w.entries)-1] + w.entries[len(w.entries)-1] = nil + w.entries = w.entries[:len(w.entries)-1] +} + +func (w *WeightedChooser) Size() int { + return len(w.entries) +} diff --git a/utils/weighted_random_chooser_test.go b/utils/weighted_random_chooser_test.go new file mode 100644 index 000000000..48c20ffab --- /dev/null +++ b/utils/weighted_random_chooser_test.go @@ -0,0 +1,50 @@ +package utils + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("WeightedRandomChooser", func() { + var w *WeightedChooser + BeforeEach(func() { + w = NewWeightedRandomChooser() + for i := 0; i < 10; i++ { + w.Add(i, i) + } + }) + + It("removes a random item", func() { + Expect(w.Size()).To(Equal(10)) + _, err := w.GetAndRemove() + Expect(err).ToNot(HaveOccurred()) + Expect(w.Size()).To(Equal(9)) + }) + + It("returns the sole item", func() { + w = NewWeightedRandomChooser() + w.Add("a", 1) + Expect(w.GetAndRemove()).To(Equal("a")) + }) + + It("fails when trying to choose from empty set", func() { + w = NewWeightedRandomChooser() + w.Add("a", 1) + w.Add("b", 1) + Expect(w.GetAndRemove()).To(BeElementOf("a", "b")) + Expect(w.GetAndRemove()).To(BeElementOf("a", "b")) + _, err := w.GetAndRemove() + Expect(err).To(HaveOccurred()) + }) + + It("chooses based on weights", func() { + counts := [10]int{} + for i := 0; i < 200000; i++ { + c, _ := w.weightedChoice() + counts[c] = counts[c] + 1 + } + for i := 0; i < 9; i++ { + Expect(counts[i]).To(BeNumerically("<", counts[i+1])) + } + }) +})